217 lines
9.0 KiB
C#
217 lines
9.0 KiB
C#
using System.IO;
|
|
using AxCopilot.SDK;
|
|
using AxCopilot.Services;
|
|
using AxCopilot.Themes;
|
|
|
|
namespace AxCopilot.Handlers;
|
|
|
|
/// <summary>
|
|
/// 웹 검색 핸들러. "?" 예약어로 사용합니다.
|
|
/// 예: ? 오늘 날씨 → 기본 검색엔진으로 검색
|
|
/// ? github.com → URL 직접 열기
|
|
/// ?g 파이썬 튜토리얼 → Google 검색
|
|
/// ?n naver 뉴스 → Naver 검색
|
|
/// ?y youtube 음악 → YouTube 검색
|
|
/// ?g github.com → GitHub 직접
|
|
/// </summary>
|
|
public class WebSearchHandler : IActionHandler
|
|
{
|
|
private readonly SettingsService _settings;
|
|
|
|
/// <summary>내장 검색 엔진 아이콘 폴더 (Assets\SearchEngines)</summary>
|
|
private static readonly string _iconDir = Path.Combine(
|
|
Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location) ?? ".",
|
|
"Assets", "SearchEngines");
|
|
|
|
public WebSearchHandler(SettingsService settings) => _settings = settings;
|
|
|
|
public string? Prefix => "?";
|
|
|
|
public PluginMetadata Metadata => new(
|
|
"WebSearch",
|
|
"웹 검색 — ? 뒤에 검색어 또는 URL 입력",
|
|
"1.0",
|
|
"AX");
|
|
|
|
// 검색 엔진 단축 접두어 — Icon: 내장 PNG 파일명 (확장자 제외)
|
|
private static readonly Dictionary<string, (string Name, string UrlTemplate, string Icon)> _engines = new()
|
|
{
|
|
["g"] = ("Google", "https://www.google.com/search?q={0}", "google"),
|
|
["n"] = ("Naver", "https://search.naver.com/search.naver?query={0}", "naver"),
|
|
["y"] = ("YouTube", "https://www.youtube.com/results?search_query={0}", "youtube"),
|
|
["gh"] = ("GitHub", "https://github.com/search?q={0}", "github"),
|
|
["d"] = ("DuckDuckGo", "https://duckduckgo.com/?q={0}", "duckduckgo"),
|
|
["w"] = ("Wikipedia", "https://ko.wikipedia.org/w/index.php?search={0}", "wikipedia"),
|
|
["nm"] = ("Naver Map", "https://map.naver.com/p/search/{0}", "navermap"),
|
|
["nw"] = ("나무위키", "https://namu.wiki/Special:Search?query={0}", "namuwiki"),
|
|
["ni"] = ("Naver 이미지", "https://search.naver.com/search.naver?where=image&query={0}", "naver"),
|
|
["gi"] = ("Google 이미지", "https://www.google.com/search?tbm=isch&q={0}", "google"),
|
|
};
|
|
|
|
/// <summary>엔진 키에 대응하는 내장 아이콘 파일 경로를 반환합니다. 없으면 null.</summary>
|
|
private static string? GetIconPath(string engineKey)
|
|
{
|
|
if (!_engines.TryGetValue(engineKey, out var eng)) return null;
|
|
var path = Path.Combine(_iconDir, $"{eng.Icon}.png");
|
|
return File.Exists(path) ? path : null;
|
|
}
|
|
|
|
private string DefaultEngineKey =>
|
|
_settings.Settings.Launcher.WebSearchEngine ?? "g";
|
|
|
|
private (string Name, string UrlTemplate, string Icon) DefaultEngine =>
|
|
_engines.TryGetValue(DefaultEngineKey, out var eng) ? eng : _engines["g"];
|
|
|
|
private const string SecurityWarn = "⚠ 검색어가 외부로 전송됩니다 — 기밀 데이터 입력 금지";
|
|
private bool IsInternalMode => OperationModePolicy.IsInternal(_settings.Settings);
|
|
|
|
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
|
{
|
|
if (IsInternalMode)
|
|
{
|
|
return Task.FromResult<IEnumerable<LauncherItem>>(
|
|
[
|
|
new LauncherItem(
|
|
"사내모드에서는 웹 검색이 차단됩니다",
|
|
"설정에서 operationMode를 external로 변경하면 사용할 수 있습니다.",
|
|
null, null, Symbol: Symbols.Warning)
|
|
]);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(query))
|
|
{
|
|
var defIcon = GetIconPath(DefaultEngineKey);
|
|
return Task.FromResult<IEnumerable<LauncherItem>>(
|
|
[
|
|
new LauncherItem(
|
|
"검색어를 입력하세요",
|
|
$"예: ? 오늘 날씨 · ?g python · ?y 음악 · ?n 뉴스 | {SecurityWarn}",
|
|
defIcon, null, Symbol: Symbols.Globe)
|
|
]);
|
|
}
|
|
|
|
var items = new List<LauncherItem>();
|
|
var q = query.Trim();
|
|
|
|
// URL인 경우 직접 열기 — FaviconService로 웹에서 아이콘 가져오기
|
|
if (IsUrl(q))
|
|
{
|
|
var url = q.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? q : "https://" + q;
|
|
var faviconPath = GetFaviconPathForUrl(url);
|
|
items.Add(new LauncherItem(
|
|
$"열기: {q}",
|
|
$"{url} | {SecurityWarn}",
|
|
faviconPath, url, Symbol: Symbols.Globe));
|
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
|
}
|
|
|
|
// 엔진 단축어 확인 (예: "g 파이썬" → Google 검색)
|
|
var parts = q.Split(' ', 2);
|
|
var engineKey = parts[0].ToLowerInvariant();
|
|
if (parts.Length == 2 && _engines.TryGetValue(engineKey, out var engine))
|
|
{
|
|
var searchQuery = parts[1].Trim();
|
|
if (!string.IsNullOrWhiteSpace(searchQuery))
|
|
{
|
|
var url = string.Format(engine.UrlTemplate, Uri.EscapeDataString(searchQuery));
|
|
items.Add(new LauncherItem(
|
|
$"{engine.Name}에서 '{searchQuery}' 검색",
|
|
$"{SecurityWarn}",
|
|
GetIconPath(engineKey), url, Symbol: Symbols.Globe));
|
|
}
|
|
}
|
|
|
|
// 기본 엔진 검색 항목
|
|
var defKey = DefaultEngineKey;
|
|
var def = DefaultEngine;
|
|
var defaultUrl = string.Format(def.UrlTemplate, Uri.EscapeDataString(q));
|
|
items.Add(new LauncherItem(
|
|
$"{def.Name}에서 '{q}' 검색",
|
|
SecurityWarn,
|
|
GetIconPath(defKey), defaultUrl, Symbol: Symbols.Globe));
|
|
|
|
// 빠른 접속 사이트 표시 (쿼리가 짧을 때만)
|
|
if (q.Length <= 2)
|
|
{
|
|
foreach (var (key, eng) in _engines)
|
|
{
|
|
items.Add(new LauncherItem(
|
|
$"?{key} — {eng.Name}",
|
|
$"예: ?{key} {q} | {SecurityWarn}",
|
|
GetIconPath(key), null, Symbol: Symbols.Globe));
|
|
}
|
|
}
|
|
|
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
|
}
|
|
|
|
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
|
{
|
|
if (IsInternalMode)
|
|
return Task.CompletedTask;
|
|
|
|
if (item.Data is string url && !string.IsNullOrWhiteSpace(url))
|
|
{
|
|
try
|
|
{
|
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url)
|
|
{ UseShellExecute = true });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LogService.Warn($"웹 검색 열기 실패: {ex.Message}");
|
|
}
|
|
}
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// ─── URL 판별 ────────────────────────────────────────────────────────────
|
|
|
|
private static bool IsUrl(string input)
|
|
{
|
|
if (input.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
|
input.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
|
|
// xxx.yyy 형태 (도메인처럼 보이는 경우)
|
|
if (!input.Contains(' ') && input.Contains('.') && input.Length > 3)
|
|
{
|
|
var domainPart = input.Split('/')[0];
|
|
var tldPart = domainPart.Split('.');
|
|
return tldPart.Length >= 2
|
|
&& tldPart[^1].Length >= 2
|
|
&& tldPart[^1].Length <= 6
|
|
&& tldPart[^1].All(char.IsLetter);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// ─── URL 파비콘 가져오기 (FaviconService 연동) ─────────────────────────
|
|
|
|
/// <summary>
|
|
/// URL에 대한 파비콘 경로를 반환합니다.
|
|
/// FaviconService 디스크 캐시에 있으면 즉시 반환, 없으면 백그라운드 다운로드를 시작합니다.
|
|
/// </summary>
|
|
private static string? GetFaviconPathForUrl(string url)
|
|
{
|
|
try
|
|
{
|
|
if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
|
url = "https://" + url;
|
|
var domain = new Uri(url).Host.ToLowerInvariant();
|
|
var cachePath = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
"AxCopilot", "favicons", $"{domain}.png");
|
|
|
|
if (File.Exists(cachePath))
|
|
return cachePath;
|
|
|
|
// 디스크 캐시에 없으면 FaviconService를 통해 백그라운드 다운로드 시작
|
|
FaviconService.GetFavicon(url);
|
|
return null;
|
|
}
|
|
catch { return null; }
|
|
}
|
|
}
|