Files
AX-Copilot-Codex/src/AxCopilot/Handlers/WebSearchHandler.cs

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; }
}
}