102 lines
5.1 KiB
C#
102 lines
5.1 KiB
C#
using System.IO;
|
|
using System.Reflection;
|
|
using Microsoft.Web.WebView2.Core;
|
|
using VideoDownloader.Browser.Tabs;
|
|
|
|
namespace VideoDownloader.App.Browser.Features;
|
|
|
|
/// <summary>
|
|
/// 리더 모드(T2-4, Firefox Reader View 패턴): Mozilla Readability.js 로 본문을 추출해
|
|
/// 스타일된 정적 HTML 로 교체. 원본 URL 은 탭에 보존해 토글 복귀 지원.
|
|
/// </summary>
|
|
public sealed class ReaderMode
|
|
{
|
|
private static string? _bundle;
|
|
|
|
/// <summary>번들된 Readability.js 소스(어셈블리 리소스, 1회 로드).</summary>
|
|
public static string Bundle =>
|
|
_bundle ??= new StreamReader(
|
|
Assembly.GetExecutingAssembly().GetManifestResourceStream(
|
|
"VideoDownloader.Assets.readability.js")!).ReadToEnd();
|
|
|
|
/// <summary>리더 토글. 반환: 리더로 진입했으면 true, 원본 복귀면 false. 추출 실패 시 null.</summary>
|
|
public static async Task<bool?> ToggleAsync(TabItem tab, bool lightTheme)
|
|
{
|
|
var cv = tab.Core;
|
|
if (cv == null) return null;
|
|
|
|
if (tab.IsReader)
|
|
{
|
|
// 원본 복귀
|
|
tab.IsReader = false;
|
|
var original = tab.ReaderOriginalUrl;
|
|
if (!string.IsNullOrEmpty(original))
|
|
{
|
|
cv.Navigate(original);
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// 본문 추출
|
|
var extractJs =
|
|
Bundle + "\n" +
|
|
"""
|
|
(function(){ try {
|
|
var docClone = document.cloneNode(true);
|
|
var article = new Readability(docClone).parse();
|
|
return JSON.stringify(article || null);
|
|
} catch(e){ return null; } })();
|
|
""";
|
|
string? json = null;
|
|
try { json = await cv.ExecuteScriptAsync(extractJs); }
|
|
catch { }
|
|
if (string.IsNullOrEmpty(json) || json == "null" || json == "\"null\"") return null;
|
|
|
|
// ExecuteScriptAsync 는 결과를 JSON 문자열로 한 겹 감싼다 — 언랩 후 파싱
|
|
var wrapped = System.Text.Json.JsonSerializer.Deserialize<string>(json);
|
|
if (string.IsNullOrEmpty(wrapped) || wrapped == "null") return null;
|
|
var article = System.Text.Json.JsonDocument.Parse(wrapped).RootElement;
|
|
if (!article.TryGetProperty("content", out var contentEl) ||
|
|
string.IsNullOrWhiteSpace(contentEl.GetString())) return null;
|
|
|
|
var title = article.TryGetProperty("title", out var t) ? t.GetString() : "";
|
|
var excerpt = article.TryGetProperty("excerpt", out var e) ? e.GetString() : "";
|
|
var byline = article.TryGetProperty("byline", out var b) ? b.GetString() : "";
|
|
|
|
// 진입 정보 저장 (복귀용)
|
|
tab.ReaderOriginalUrl = cv.Source;
|
|
tab.IsReader = true;
|
|
tab.Title = string.IsNullOrWhiteSpace(title) ? tab.Title : title + " (리더)";
|
|
|
|
cv.NavigateToString(BuildReaderHtml(title ?? "", byline ?? "", excerpt ?? "", contentEl.GetString() ?? "", lightTheme, AppFontStack));
|
|
return true;
|
|
}
|
|
|
|
/// <summary>호스트가 주입하는 앱 폰트 스택(UC-F17 — 폰트 하드코딩 제거). 기본값은 WPF 기본 스택.</summary>
|
|
public static string AppFontStack { get; set; } = "Segoe UI, sans-serif";
|
|
|
|
/// <summary>리더 HTML(Firefox 스타일 — 폰트/여백/테마). trusted-types 회피를 위해 iframe srcdoc 형태 대신 직접 문서.</summary>
|
|
public static string BuildReaderHtml(string title, string byline, string excerpt, string contentHtml, bool lightTheme, string? fontStack = null)
|
|
{
|
|
var bg = lightTheme ? "#faf8f5" : "#1b1c24";
|
|
var fg = lightTheme ? "#1b1c24" : "#e6e6ec";
|
|
var muted = lightTheme ? "#666" : "#9a9ab0";
|
|
var font = string.IsNullOrWhiteSpace(fontStack) ? AppFontStack : fontStack;
|
|
var css = $@"
|
|
body {{ background: {bg}; color: {fg}; font-family: '{font.Replace("'", "")}', sans-serif; max-width: 720px; margin: 0 auto; padding: 40px 24px 80px; line-height: 1.75; }}
|
|
h1 {{ font-size: 1.7em; line-height: 1.3; margin-bottom: 6px; }}
|
|
.byline {{ color: {muted}; font-size: 0.9em; margin-bottom: 4px; }}
|
|
.excerpt {{ color: {muted}; font-style: italic; margin: 14px 0 26px; padding-left: 12px; border-left: 3px solid {muted}; }}
|
|
img, video {{ max-width: 100%; height: auto; }}
|
|
a {{ color: #5b8def; }}
|
|
pre {{ overflow-x: auto; background: {(lightTheme ? "#f0eee9" : "#262736")}; padding: 12px; border-radius: 8px; }}
|
|
blockquote {{ border-left: 3px solid {muted}; margin-left: 0; padding-left: 14px; color: {muted}; }}
|
|
";
|
|
var titleEsc = System.Net.WebUtility.HtmlEncode(title);
|
|
var bylineEsc = System.Net.WebUtility.HtmlEncode(byline);
|
|
var excerptEsc = System.Net.WebUtility.HtmlEncode(excerpt);
|
|
// UC-A17: lang="ko" 선언으로 화면 낭독기가 한국어 음성으로 읽는다.
|
|
return $"""<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{titleEsc}</title><style>{css}</style></head><body><h1>{titleEsc}</h1>{(bylineEsc.Length > 0 ? $"<div class=\"byline\">{bylineEsc}</div>" : "")}{(excerptEsc.Length > 0 ? $"<p class=\"excerpt\">{excerptEsc}</p>" : "")}{contentHtml}</body></html>""";
|
|
}
|
|
}
|