T1-4/T1-7: search engine resolver + site zoom store (TDD RED->GREEN, 32 tests)
This commit is contained in:
parent
21b7c4d8bd
commit
470c9267f2
9 changed files with 522 additions and 0 deletions
34
VideoDownloader.App/Browser/SearchEngine.cs
Normal file
34
VideoDownloader.App/Browser/SearchEngine.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
namespace VideoDownloader.App.Browser;
|
||||
|
||||
/// <summary>검색엔진 정의. QueryTemplate 의 {0} 이 URL 인코딩된 검색어로 치환된다.</summary>
|
||||
public sealed record SearchEngine(string Name, string Keyword, string QueryTemplate);
|
||||
|
||||
/// <summary>기본 프리셋 엔진. Keyword 는 주소창에서 `<키워드> <검색어>` 형태로 즉시 전환에 쓰인다.</summary>
|
||||
public static class SearchEngines
|
||||
{
|
||||
public static readonly IReadOnlyList<SearchEngine> Presets = new[]
|
||||
{
|
||||
new SearchEngine("Google", "g", "https://www.google.com/search?q={0}"),
|
||||
new SearchEngine("Bing", "b", "https://www.bing.com/search?q={0}"),
|
||||
new SearchEngine("DuckDuckGo", "d", "https://duckduckgo.com/?q={0}"),
|
||||
new SearchEngine("Naver", "n", "https://search.naver.com/search.naver?query={0}"),
|
||||
new SearchEngine("YouTube", "yt", "https://www.youtube.com/results?search_query={0}"),
|
||||
};
|
||||
|
||||
public static SearchEngine? FindByName(string? name, IEnumerable<SearchEngine>? extra = null)
|
||||
{
|
||||
var pool = extra != null ? Presets.Concat(extra) : Presets;
|
||||
return pool.FirstOrDefault(e =>
|
||||
string.Equals(e.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public static SearchEngine? FindByKeyword(string? keyword, IEnumerable<SearchEngine>? extra = null)
|
||||
{
|
||||
var pool = extra != null ? Presets.Concat(extra) : Presets;
|
||||
return pool.FirstOrDefault(e =>
|
||||
string.Equals(e.Keyword, keyword, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>주소창 입력에 대한 네비게이션 의도. IsUrl=false 면 Target 은 검색 결과 URL.</summary>
|
||||
public sealed record NavigationIntent(bool IsUrl, string Target, string? EngineName = null);
|
||||
80
VideoDownloader.App/Browser/SearchEngineResolver.cs
Normal file
80
VideoDownloader.App/Browser/SearchEngineResolver.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace VideoDownloader.App.Browser;
|
||||
|
||||
/// <summary>
|
||||
/// 주소창 입력을 URL 또는 검색 의도로 판별한다(T1-4).
|
||||
/// 판별 순서: ① 명시적 스킴 ② localhost/IPv4 ③ 도메인 형태 ④ 키워드 접두(`g `, `yt `) ⑤ 기본 엔진 검색.
|
||||
/// </summary>
|
||||
public sealed partial class SearchEngineResolver
|
||||
{
|
||||
private readonly SearchEngine _default;
|
||||
private readonly List<SearchEngine> _custom;
|
||||
|
||||
public SearchEngineResolver(string defaultEngineName, IEnumerable<SearchEngine>? customEngines = null)
|
||||
{
|
||||
_default = SearchEngines.FindByName(defaultEngineName, customEngines)
|
||||
?? SearchEngines.Presets[0];
|
||||
_custom = customEngines?.ToList() ?? new();
|
||||
}
|
||||
|
||||
public SearchEngine DefaultEngine => _default;
|
||||
|
||||
/// <summary>입력이 비었으면 null, URL 이면 URL 의도, 아니면 검색 결과 URL 의도.</summary>
|
||||
public NavigationIntent? Resolve(string? input)
|
||||
{
|
||||
input = input?.Trim();
|
||||
if (string.IsNullOrEmpty(input)) return null;
|
||||
|
||||
if (HasScheme(input))
|
||||
return new NavigationIntent(true, input);
|
||||
|
||||
if (IsLocalHost(input) || IsIpv4(input))
|
||||
return new NavigationIntent(true, "https://" + input);
|
||||
|
||||
if (LooksLikeDomain(input))
|
||||
return new NavigationIntent(true, "https://" + input);
|
||||
|
||||
// 키워드 전환: "g 고양이" → Google 검색. 미등록 키워드는 검색어의 일부.
|
||||
if (MatchKeyword(input) is { } kw)
|
||||
return new NavigationIntent(false, Format(kw.engine, kw.query), kw.engine.Name);
|
||||
|
||||
return new NavigationIntent(false, Format(_default, input), _default.Name);
|
||||
}
|
||||
|
||||
private static string Format(SearchEngine engine, string query)
|
||||
=> string.Format(engine.QueryTemplate, Uri.EscapeDataString(query).Replace("%20", "+"));
|
||||
|
||||
private (SearchEngine engine, string query)? MatchKeyword(string input)
|
||||
{
|
||||
var m = KeywordPrefix().Match(input);
|
||||
if (!m.Success) return null;
|
||||
var engine = SearchEngines.FindByKeyword(m.Groups[1].Value, _custom);
|
||||
return engine == null ? null : (engine, m.Groups[2].Value);
|
||||
}
|
||||
|
||||
private static bool HasScheme(string s) =>
|
||||
s.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||
|| s.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|
||||
|| s.StartsWith("file://", StringComparison.OrdinalIgnoreCase)
|
||||
|| s.StartsWith("about:", StringComparison.OrdinalIgnoreCase)
|
||||
|| s.StartsWith("data:", StringComparison.OrdinalIgnoreCase)
|
||||
|| s.StartsWith("blob:", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsLocalHost(string s) =>
|
||||
s.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|
||||
|| s.StartsWith("localhost:", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsIpv4(string s) =>
|
||||
s.Length > 7 && s[0] >= '0' && s[0] <= '9' && Regex.IsMatch(s, @"^\d{1,3}(\.\d{1,3}){3}(:\d+)?([/?#].*)?$");
|
||||
|
||||
private static bool LooksLikeDomain(string s) =>
|
||||
DomainPattern().IsMatch(s);
|
||||
|
||||
// 라벨.라벨[.라벨...] 형태(최소 점 1개) + 선택적 포트/경로. 공백 포함 시 불일치.
|
||||
[GeneratedRegex(@"^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)+(:\d+)?([/?#].*)?$")]
|
||||
private static partial Regex DomainPattern();
|
||||
|
||||
[GeneratedRegex(@"^(\S+)\s+(.+)$", RegexOptions.Singleline)]
|
||||
private static partial Regex KeywordPrefix();
|
||||
}
|
||||
106
VideoDownloader.App/Browser/SiteZoomStore.cs
Normal file
106
VideoDownloader.App/Browser/SiteZoomStore.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace VideoDownloader.App.Browser;
|
||||
|
||||
/// <summary>
|
||||
/// 사이트별 줌 배율 영속 저장소(T1-7). WebView2 는 사이트별 줌 영속을 보장하지 않아
|
||||
/// 앱이 도메인→배율 사전을 SSOT 로 관리한다. LRU 300개, 저장 즉시 원자적 파일 기록.
|
||||
/// </summary>
|
||||
public sealed class SiteZoomStore
|
||||
{
|
||||
private sealed record Entry(double Zoom, long LastUsedTicks);
|
||||
|
||||
private const int MaxEntries = 300;
|
||||
private const double Min = 0.1, Max = 5.0;
|
||||
|
||||
private readonly string _path;
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<string, Entry> _map = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public SiteZoomStore(string path)
|
||||
{
|
||||
_path = path;
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var loaded = JsonSerializer.Deserialize<Dictionary<string, Entry>>(File.ReadAllText(path));
|
||||
if (loaded != null)
|
||||
foreach (var (k, v) in loaded) _map[k] = v;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 손상된 JSON → 빈 상태로 시작(기존 BrowserStore 정책과 동일)
|
||||
_map.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public int Count { get { lock (_lock) return _map.Count; } }
|
||||
|
||||
/// <summary>URL 의 호스트에 저장된 배율. 없으면 1.0. 읽기는 LRU 를 갱신한다.</summary>
|
||||
public double Get(string? url)
|
||||
{
|
||||
var host = NormalizeHost(url);
|
||||
if (host == null) return 1.0;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_map.TryGetValue(host, out var e))
|
||||
{
|
||||
_map[host] = e with { LastUsedTicks = DateTime.UtcNow.Ticks };
|
||||
return e.Zoom;
|
||||
}
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
/// <summary>배율 저장(0.1~5.0 클램프). 초과분은 가장 오래된 사용 순으로 제거 후 즉시 영속.</summary>
|
||||
public void Set(string? url, double zoom)
|
||||
{
|
||||
var host = NormalizeHost(url);
|
||||
if (host == null) return;
|
||||
zoom = Math.Clamp(zoom, Min, Max);
|
||||
lock (_lock)
|
||||
{
|
||||
_map[host] = new Entry(zoom, DateTime.UtcNow.Ticks);
|
||||
if (_map.Count > MaxEntries)
|
||||
foreach (var k in _map.OrderBy(kv => kv.Value.LastUsedTicks).Take(_map.Count - MaxEntries).Select(kv => kv.Key).ToList())
|
||||
_map.Remove(k);
|
||||
Save();
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_map.Clear();
|
||||
try { if (File.Exists(_path)) File.Delete(_path); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_path);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
var tmp = _path + ".tmp";
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(_map));
|
||||
File.Move(tmp, _path, overwrite: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 저장 실패는 메모리 상태는 유지(조용한 내구성 저하)
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeHost(string? url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return null;
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || string.IsNullOrEmpty(uri.Host)) return null;
|
||||
var host = uri.Host;
|
||||
return host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) ? host[4..] : host;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue