diff --git a/.husky/pre-push b/.husky/pre-push index 06c7151c4..12257e946 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -13,7 +13,7 @@ need_check=0 while read local_ref local_sha remote_ref remote_sha; do branch=$(echo "$remote_ref" | sed 's|refs/heads/||') - if [ "$branch" = "main" ] || echo "$branch" | grep -q "^release"; then + if [ "$branch" = "main" ] || echo "$branch" | grep -q "^release/"; then need_check=1 echo "🔍 Detected push target: $branch" fi diff --git a/src/locales/de-DE/translation.json b/src/locales/de-DE/translation.json index 38f643d8d..fbe32f243 100644 --- a/src/locales/de-DE/translation.json +++ b/src/locales/de-DE/translation.json @@ -580,6 +580,11 @@ "maybe_later": "Vielleicht später", "settings_hint": "Sie können diese Option jederzeit in den Einstellungen ändern." }, + "favicon_service": "Favicon-Dienst", + "favicon_service_desc": "Dienst zum Abrufen von Website-Symbolen auswählen", + "favicon_service_scriptcat": "ScriptCat", + "favicon_service_local": "Lokal abrufen", + "favicon_service_google": "Google", "editor": { "show_script_list": "Skriptliste anzeigen", "hide_script_list": "Skriptliste ausblenden" diff --git a/src/locales/en-US/translation.json b/src/locales/en-US/translation.json index 69a4cf668..309fb2b5f 100644 --- a/src/locales/en-US/translation.json +++ b/src/locales/en-US/translation.json @@ -580,6 +580,11 @@ "maybe_later": "Maybe Later", "settings_hint": "You can change this option in settings at any time." }, + "favicon_service": "Favicon Service", + "favicon_service_desc": "Choose the service for fetching website icons", + "favicon_service_scriptcat": "ScriptCat", + "favicon_service_local": "Local Fetch", + "favicon_service_google": "Google", "editor": { "show_script_list": "Show Script List", "hide_script_list": "Hide Script List" diff --git a/src/locales/ja-JP/translation.json b/src/locales/ja-JP/translation.json index c51ec0285..da291da2a 100644 --- a/src/locales/ja-JP/translation.json +++ b/src/locales/ja-JP/translation.json @@ -580,6 +580,11 @@ "maybe_later": "後で", "settings_hint": "設定ページでいつでも変更できます。" }, + "favicon_service": "Favicon サービス", + "favicon_service_desc": "ウェブサイトアイコンの取得サービスを選択", + "favicon_service_scriptcat": "ScriptCat", + "favicon_service_local": "ローカル取得", + "favicon_service_google": "Google", "editor": { "show_script_list": "スクリプトリストを表示", "hide_script_list": "スクリプトリストを非表示" diff --git a/src/locales/ru-RU/translation.json b/src/locales/ru-RU/translation.json index 553d1e33d..53b912313 100644 --- a/src/locales/ru-RU/translation.json +++ b/src/locales/ru-RU/translation.json @@ -580,6 +580,11 @@ "maybe_later": "Может быть позже", "settings_hint": "Вы можете изменить эту опцию в настройках в любое время." }, + "favicon_service": "Сервис Favicon", + "favicon_service_desc": "Выберите сервис для получения значков сайтов", + "favicon_service_scriptcat": "ScriptCat", + "favicon_service_local": "Локальное получение", + "favicon_service_google": "Google", "editor": { "show_script_list": "Показать список скриптов", "hide_script_list": "Скрыть список скриптов" diff --git a/src/locales/vi-VN/translation.json b/src/locales/vi-VN/translation.json index d9363752c..bf34700de 100644 --- a/src/locales/vi-VN/translation.json +++ b/src/locales/vi-VN/translation.json @@ -580,6 +580,11 @@ "maybe_later": "Để sau", "settings_hint": "Bạn có thể thay đổi tùy chọn này trong cài đặt bất kỳ lúc nào." }, + "favicon_service": "Dịch vụ Favicon", + "favicon_service_desc": "Chọn dịch vụ để lấy biểu tượng trang web", + "favicon_service_scriptcat": "ScriptCat", + "favicon_service_local": "Lấy cục bộ", + "favicon_service_google": "Google", "editor": { "show_script_list": "Hiển thị danh sách script", "hide_script_list": "Ẩn danh sách script" diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 7d7eca279..800109e87 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -580,6 +580,11 @@ "maybe_later": "暂不启用", "settings_hint": "你可以随时在设置中修改此选项。" }, + "favicon_service": "图标服务", + "favicon_service_desc": "选择获取网站图标的服务", + "favicon_service_scriptcat": "ScriptCat", + "favicon_service_local": "本地获取", + "favicon_service_google": "Google", "editor": { "show_script_list": "显示脚本列表", "hide_script_list": "隐藏脚本列表" diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 6f740789d..de3d49ee1 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -580,6 +580,11 @@ "maybe_later": "暫不啟用", "settings_hint": "你可以隨時在設定中修改此選項。" }, + "favicon_service": "圖示服務", + "favicon_service_desc": "選擇取得網站圖示的服務", + "favicon_service_scriptcat": "ScriptCat", + "favicon_service_local": "本地取得", + "favicon_service_google": "Google", "editor": { "show_script_list": "顯示腳本列表", "hide_script_list": "隱藏腳本列表" diff --git a/src/pages/options/routes/ScriptList/hooks.tsx b/src/pages/options/routes/ScriptList/hooks.tsx index 425082748..6cb0909ee 100644 --- a/src/pages/options/routes/ScriptList/hooks.tsx +++ b/src/pages/options/routes/ScriptList/hooks.tsx @@ -10,6 +10,7 @@ import { } from "@App/app/repo/scripts"; import { fetchScript, fetchScriptList } from "@App/pages/store/features/script"; import { loadScriptFavicons } from "@App/pages/store/favicons"; +import { systemConfig } from "@App/pages/store/global"; import { parseTags } from "@App/app/repo/metadata"; import { getCombinedMeta } from "@App/app/service/service_worker/utils"; import { cacheInstance } from "@App/app/cache"; @@ -76,7 +77,8 @@ export function useScriptDataManagement() { setLoadingList(false); cacheInstance.tx("faviconOPFSControl", async () => { if (!mounted) return; - for await (const { chunkResults } of loadScriptFavicons(list)) { + const faviconService = await systemConfig.getFaviconService(); + for await (const { chunkResults } of loadScriptFavicons(list, faviconService)) { if (!mounted) return; setScriptList((prev) => { const favMap = new Map(chunkResults.map((r) => [r.uuid, r])); diff --git a/src/pages/options/routes/Setting.tsx b/src/pages/options/routes/Setting.tsx index afb779499..0ac87c935 100644 --- a/src/pages/options/routes/Setting.tsx +++ b/src/pages/options/routes/Setting.tsx @@ -15,6 +15,8 @@ import CustomTrans from "@App/pages/components/CustomTrans"; import { useSystemConfig } from "./utils"; import { subscribeMessage } from "@App/pages/store/global"; import { SystemConfigChange, type SystemConfigKey } from "@App/pkg/config/config"; +import { FaviconDAO } from "@App/app/repo/favicon"; +import { clearFaviconMemoryCache } from "@App/pages/store/favicons"; import { type TKeyValue } from "@Packages/message/message_queue"; import { useEffect, useMemo } from "react"; import { systemConfig } from "@App/pages/store/global"; @@ -41,6 +43,7 @@ function Setting() { const [badgeTextColor, setBadgeTextColor, submitBadgeTextColor] = useSystemConfig("badge_text_color"); const [scriptMenuDisplayType, setScriptMenuDisplayType, submitScriptMenuDisplayType] = useSystemConfig("script_menu_display_type"); + const [faviconService, setFaviconService, submitFaviconService] = useSystemConfig("favicon_service"); const [editorTypeDefinition, setEditorTypeDefinition, submitEditorTypeDefinition] = useSystemConfig("editor_type_definition"); @@ -81,6 +84,7 @@ function Setting() { badge_background_color: setBadgeBackgroundColor, badge_text_color: setBadgeTextColor, script_menu_display_type: setScriptMenuDisplayType, + favicon_service: setFaviconService, editor_type_definition: setEditorTypeDefinition, } as const; const hookMgr = new HookManager(); @@ -306,6 +310,39 @@ function Setting() { + + {/* Favicon 服务 */} +
+
+ {t("favicon_service")} + +
+ {t("favicon_service_desc")} +
diff --git a/src/pages/store/favicons.test.ts b/src/pages/store/favicons.test.ts index 6f78f81b0..d3a9f023f 100644 --- a/src/pages/store/favicons.test.ts +++ b/src/pages/store/favicons.test.ts @@ -1,5 +1,12 @@ -import { extractFaviconsDomain } from "@App/pages/store/favicons"; -import { describe, it, expect } from "vitest"; +import { + extractFaviconsDomain, + extractDomainFromPattern, + parseFaviconsNew, + fetchIconByService, + fetchIconByDomain, + timeoutAbortSignal, +} from "@App/pages/store/favicons"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; describe("extractFaviconsDomain", () => { it("应该正确提取各种URL模式的域名", () => { @@ -41,4 +48,285 @@ describe("extractFaviconsDomain", () => { expect(extractFaviconsDomain([], [])).toEqual([]); expect(extractFaviconsDomain()).toEqual([]); }); + + it("相同域名应该去重", () => { + const result = extractFaviconsDomain(["https://example.com/page1", "https://example.com/page2"], []); + // 两个pattern提取出相同域名 example.com,应去重 + expect(result).toHaveLength(1); + expect(result[0].domain).toBe("example.com"); + }); +}); + +describe("extractDomainFromPattern", () => { + // 基础场景已在 extractFaviconsDomain 中覆盖,这里只测试额外边界情况 + it("应该从带query参数的URL中提取域名", () => { + expect(extractDomainFromPattern("https://www.google.com/search?q=test")).toBe("www.google.com"); + }); + + it("空字符串应返回null", () => { + expect(extractDomainFromPattern("")).toBe(null); + }); +}); + +describe("parseFaviconsNew", () => { + it("应该解析标准favicon link标签", () => { + const hrefs: string[] = []; + const html = ''; + parseFaviconsNew(html, (href) => hrefs.push(href)); + expect(hrefs).toEqual(["/favicon.ico"]); + }); + + it("应该解析apple-touch-icon", () => { + const hrefs: string[] = []; + const html = ''; + parseFaviconsNew(html, (href) => hrefs.push(href)); + expect(hrefs).toEqual(["/apple-icon.png"]); + }); + + it("应该解析apple-touch-icon-precomposed", () => { + const hrefs: string[] = []; + const html = ''; + parseFaviconsNew(html, (href) => hrefs.push(href)); + expect(hrefs).toEqual(["/precomposed.png"]); + }); + + it("应该解析多个favicon link标签", () => { + const hrefs: string[] = []; + const html = ` + + + + `; + parseFaviconsNew(html, (href) => hrefs.push(href)); + expect(hrefs).toHaveLength(3); + expect(hrefs).toEqual(["/icon1.png", "/icon2.png", "/icon3.png"]); + }); + + it("没有link标签时不应调用回调", () => { + const callback = vi.fn(); + parseFaviconsNew("hello", callback); + expect(callback).not.toHaveBeenCalled(); + }); + + it("应该忽略非favicon的link标签", () => { + const hrefs: string[] = []; + const html = ''; + parseFaviconsNew(html, (href) => hrefs.push(href)); + expect(hrefs).toEqual(["/icon.png"]); + }); + + it("应该处理单引号和双引号", () => { + const hrefs: string[] = []; + const html = ``; + parseFaviconsNew(html, (href) => hrefs.push(href)); + expect(hrefs).toEqual(["/icon1.png", "/icon2.png"]); + }); + + it("应该处理大小写混合的标签", () => { + const hrefs: string[] = []; + const html = ''; + parseFaviconsNew(html, (href) => hrefs.push(href)); + expect(hrefs).toEqual(["/icon.png"]); + }); +}); + +// 创建模拟HTML Response的辅助函数 +const mockHtmlResponse = (url: string, html: string) => ({ + ok: true, + url, + headers: new Headers({ "content-type": "text/html; charset=utf-8" }), + arrayBuffer: () => Promise.resolve(new TextEncoder().encode(html).buffer), +}); + +describe("fetchIconByService", () => { + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + url: "https://example.com/favicon.ico", + text: () => Promise.resolve(""), + blob: () => Promise.resolve(new Blob()), + headers: new Headers({ "content-type": "text/html" }), + }) + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("scriptcat服务应返回scriptcat API URL", async () => { + const result = await fetchIconByService("example.com", "scriptcat"); + expect(result).toEqual(["https://ext.scriptcat.org/api/v1/open/favicons?domain=example.com&sz=64"]); + }); + + it("google服务应返回Google favicon URL", async () => { + const result = await fetchIconByService("example.com", "google"); + expect(result).toEqual(["https://www.google.com/s2/favicons?domain=example.com&sz=64"]); + }); + + it("应该对域名进行URL编码", async () => { + const result = await fetchIconByService("例え.jp", "scriptcat"); + expect(result).toEqual([ + `https://ext.scriptcat.org/api/v1/open/favicons?domain=${encodeURIComponent("例え.jp")}&sz=64`, + ]); + }); + + // local 服务的具体行为已在 fetchIconByDomain 测试中充分覆盖 +}); + +describe("fetchIconByDomain", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("应该从HTML中解析favicon并验证", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((url: string | URL) => { + const urlStr = url.toString(); + if (urlStr === "https://example.com") { + return Promise.resolve( + mockHtmlResponse( + "https://example.com/", + '' + ) + ); + } + return Promise.resolve({ ok: true, url: "https://example.com/static/favicon.ico" }); + }) + ); + + const icons = await fetchIconByDomain("example.com"); + expect(icons).toEqual(["https://example.com/static/favicon.ico"]); + }); + + it("没有link标签时应回退到/favicon.ico", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((url: string | URL) => { + const urlStr = url.toString(); + if (urlStr === "https://noicon.com") { + return Promise.resolve(mockHtmlResponse("https://noicon.com/", "")); + } + return Promise.resolve({ ok: true, url: "https://noicon.com/favicon.ico" }); + }) + ); + + const icons = await fetchIconByDomain("noicon.com"); + expect(icons).toEqual(["https://noicon.com/favicon.ico"]); + }); + + it("HEAD请求失败时应过滤掉该icon", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((url: string | URL) => { + const urlStr = url.toString(); + if (urlStr === "https://fail.com") { + return Promise.resolve( + mockHtmlResponse("https://fail.com/", '') + ); + } + return Promise.reject(new Error("Not found")); + }) + ); + + const icons = await fetchIconByDomain("fail.com"); + expect(icons).toEqual([]); + }); + + it("HEAD请求返回非OK状态时应过滤掉该icon", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((url: string | URL) => { + const urlStr = url.toString(); + if (urlStr === "https://badstatus.com") { + return Promise.resolve( + mockHtmlResponse("https://badstatus.com/", '') + ); + } + return Promise.resolve({ ok: false, url: "https://badstatus.com/icon.png" }); + }) + ); + + const icons = await fetchIconByDomain("badstatus.com"); + expect(icons).toEqual([]); + }); + + it("HEAD请求重定向到不同文件名时应过滤", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((url: string | URL) => { + const urlStr = url.toString(); + if (urlStr === "https://redirect.com") { + return Promise.resolve( + mockHtmlResponse("https://redirect.com/", '') + ); + } + return Promise.resolve({ ok: true, url: "https://redirect.com/404.html" }); + }) + ); + + const icons = await fetchIconByDomain("redirect.com"); + expect(icons).toEqual([]); + }); + + it("应该正确解析相对URL为绝对URL", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((url: string | URL) => { + const urlStr = url.toString(); + if (urlStr === "https://cdn.example.com") { + // 页面重定向到了不同的URL + return Promise.resolve( + mockHtmlResponse( + "https://www.example.com/home", + '' + ) + ); + } + return Promise.resolve({ ok: true, url: "https://www.example.com/assets/icon.png" }); + }) + ); + + const icons = await fetchIconByDomain("cdn.example.com"); + expect(icons).toEqual(["https://www.example.com/assets/icon.png"]); + }); + + it("应该处理多个favicon并全部验证", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((url: string | URL) => { + const urlStr = url.toString(); + if (urlStr === "https://multi.com") { + return Promise.resolve( + mockHtmlResponse( + "https://multi.com/", + ` + + + + ` + ) + ); + } + // 所有HEAD请求都成功 + return Promise.resolve({ ok: true, url: urlStr }); + }) + ); + + const icons = await fetchIconByDomain("multi.com"); + expect(icons).toHaveLength(3); + expect(icons).toContain("https://multi.com/icon16.png"); + expect(icons).toContain("https://multi.com/icon32.png"); + expect(icons).toContain("https://multi.com/apple.png"); + }); +}); + +describe("timeoutAbortSignal", () => { + it("应该返回AbortSignal", () => { + const signal = timeoutAbortSignal(5000); + expect(signal).toBeInstanceOf(AbortSignal); + }); }); diff --git a/src/pages/store/favicons.ts b/src/pages/store/favicons.ts index 0ed61ef6f..feb2da4df 100644 --- a/src/pages/store/favicons.ts +++ b/src/pages/store/favicons.ts @@ -3,11 +3,26 @@ import { FaviconDAO, type FaviconFile, type FaviconRecord } from "@App/app/repo/ import { v5 as uuidv5 } from "uuid"; import { getFaviconRootFolder } from "@App/app/service/service_worker/utils"; import { readBlobContent } from "@App/pkg/utils/encoding"; +import type { FaviconService } from "@App/pkg/config/config"; let scriptDAO: ScriptDAO | null = null; let faviconDAO: FaviconDAO | null = null; const loadFaviconPromises = new Map(); // 关联 iconUrl 和 blobUrl +// 清除内存中的 favicon 缓存,切换服务时调用 +export const clearFaviconMemoryCache = () => { + loadFaviconPromises.forEach((promise) => { + Promise.resolve(promise) + .then((blobUrl) => { + if (typeof blobUrl === "string" && blobUrl.startsWith("blob:")) { + URL.revokeObjectURL(blobUrl); + } + }) + .catch(() => {}); + }); + loadFaviconPromises.clear(); +}; + /** * 从URL模式中提取域名 */ @@ -179,8 +194,26 @@ export async function fetchIconByDomain(domain: string): Promise { return urls.filter((url) => !!url) as string[]; } +/** + * 根据服务类型获取favicon URL列表 + */ +export async function fetchIconByService(domain: string, service: FaviconService): Promise { + switch (service) { + case "scriptcat": + return [`https://ext.scriptcat.org/api/v1/open/favicons?domain=${encodeURIComponent(domain)}&sz=64`]; + case "google": + return [`https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`]; + case "local": + default: + return await fetchIconByDomain(domain); + } +} + // 获取脚本的favicon -export const getScriptFavicon = async (uuid: string): Promise => { +export const getScriptFavicon = async ( + uuid: string, + service: FaviconService = "scriptcat" +): Promise => { scriptDAO ||= new ScriptDAO(); faviconDAO ||= new FaviconDAO(); const script = await scriptDAO.get(uuid); @@ -199,7 +232,7 @@ export const getScriptFavicon = async (uuid: string): Promise = domains.map(async (domain) => { try { if (domain.domain) { - const icons = await fetchIconByDomain(domain.domain); + const icons = await fetchIconByService(domain.domain, service); const icon = icons.length > 0 ? icons[0] : ""; return { match: domain.match, website: "http://" + domain.domain, icon }; } @@ -233,6 +266,11 @@ export const loadFavicon = async (iconUrl: string): Promise => { // 文件不存在,下载并保存 const newFileHandle = await directoryHandle.getFileHandle(filename, { create: true }); const response = await fetch(iconUrl); + if (response.status >= 300) { + // 状态码异常,删除创建的空文件并抛出错误 + await directoryHandle.removeEntry(filename).catch(() => {}); + throw new Error(`Favicon fetch failed with status ${response.status}`); + } const blob = await response.blob(); const writable = await newFileHandle.createWritable(); await writable.write(blob); @@ -256,9 +294,9 @@ const getFileFromOPFS = async (opfsRet: FaviconFile): Promise => { }; // 处理单个脚本的favicon -const processScriptFavicon = async (script: Script) => { +const processScriptFavicon = async (script: Script, service: FaviconService = "scriptcat") => { const favFnAsync = async () => { - const icons = await getScriptFavicon(script.uuid); // 恒久。不会因SW重启而失效 + const icons = await getScriptFavicon(script.uuid, service); // 恒久。不会因SW重启而失效 if (icons.length === 0) return []; const newIcons = await Promise.all( icons.map(async (icon) => { @@ -305,7 +343,7 @@ type FavIconResult = { type TFaviconStack = { chunkResults: FavIconResult[]; pendingCount: number }; // 处理favicon加载,以批次方式处理 -export const loadScriptFavicons = async function* (scripts: Script[]) { +export const loadScriptFavicons = async function* (scripts: Script[], service: FaviconService = "scriptcat") { const stack: TFaviconStack[] = []; const asyncWaiter: { promise?: any; resolve?: any } = {}; const createPromise = () => { @@ -319,7 +357,7 @@ export const loadScriptFavicons = async function* (scripts: Script[]) { const results: FavIconResult[] = []; let waiting = false; for (const script of scripts) { - processScriptFavicon(script).then((result: FavIconResult) => { + processScriptFavicon(script, service).then((result: FavIconResult) => { results.push(result); // 下一个 MacroTask 执行。 // 使用 requestAnimationFrame 而非setTimeout 是因为前台才要显示。而且网页绘画中时会延后这个 diff --git a/src/pkg/config/config.ts b/src/pkg/config/config.ts index 4215fb516..109d334fa 100644 --- a/src/pkg/config/config.ts +++ b/src/pkg/config/config.ts @@ -19,6 +19,8 @@ export type CloudSyncConfig = { params: { [key: string]: any }; }; +export type FaviconService = "scriptcat" | "local" | "google"; + export type CATFileStorage = { filesystem: FileSystemType; params: { [key: string]: any }; @@ -474,6 +476,14 @@ export class SystemConfig { getScriptMenuDisplayType(): Promise<"no_browser" | "all"> { return this._get("script_menu_display_type", "all"); } + + getFaviconService() { + return this._get("favicon_service", "scriptcat"); + } + + setFaviconService(val: FaviconService) { + return this._set("favicon_service", val); + } } let lazyScriptNamePrefix: string = "";