using System.IO; using AxCopilot.SDK; using AxCopilot.Services; using AxCopilot.Themes; namespace AxCopilot.Handlers; /// /// 웹 검색 핸들러. "?" 예약어로 사용합니다. /// 예: ? 오늘 날씨 → 기본 검색엔진으로 검색 /// ? github.com → URL 직접 열기 /// ?g 파이썬 튜토리얼 → Google 검색 /// ?n naver 뉴스 → Naver 검색 /// ?y youtube 음악 → YouTube 검색 /// ?g github.com → GitHub 직접 /// public class WebSearchHandler : IActionHandler { private readonly SettingsService _settings; /// 내장 검색 엔진 아이콘 폴더 (Assets\SearchEngines) 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 _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"), }; /// 엔진 키에 대응하는 내장 아이콘 파일 경로를 반환합니다. 없으면 null. 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> GetItemsAsync(string query, CancellationToken ct) { if (IsInternalMode) { return Task.FromResult>( [ new LauncherItem( "사내모드에서는 웹 검색이 차단됩니다", "설정에서 operationMode를 external로 변경하면 사용할 수 있습니다.", null, null, Symbol: Symbols.Warning) ]); } if (string.IsNullOrWhiteSpace(query)) { var defIcon = GetIconPath(DefaultEngineKey); return Task.FromResult>( [ new LauncherItem( "검색어를 입력하세요", $"예: ? 오늘 날씨 · ?g python · ?y 음악 · ?n 뉴스 | {SecurityWarn}", defIcon, null, Symbol: Symbols.Globe) ]); } var items = new List(); 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>(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>(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 연동) ───────────────────────── /// /// URL에 대한 파비콘 경로를 반환합니다. /// FaviconService 디스크 캐시에 있으면 즉시 반환, 없으면 백그라운드 다운로드를 시작합니다. /// 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; } } }