From 9b433c82611339eb0f175cb953110f5c84943e18 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 21 Feb 2026 20:16:51 +0900 Subject: [PATCH 1/9] =?UTF-8?q?=E6=8A=8A=20fetchIconByDomain=20=E6=94=B9?= =?UTF-8?q?=E6=88=90=20scriptcat.org?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/store/favicons.ts | 67 ++++--------------------------------- 1 file changed, 6 insertions(+), 61 deletions(-) diff --git a/src/pages/store/favicons.ts b/src/pages/store/favicons.ts index 0ed61ef6f..488acb1aa 100644 --- a/src/pages/store/favicons.ts +++ b/src/pages/store/favicons.ts @@ -2,7 +2,6 @@ import { type Script, ScriptDAO } from "@App/app/repo/scripts"; import { FaviconDAO, type FaviconFile, type FaviconRecord } from "@App/app/repo/favicon"; import { v5 as uuidv5 } from "uuid"; import { getFaviconRootFolder } from "@App/app/service/service_worker/utils"; -import { readBlobContent } from "@App/pkg/utils/encoding"; let scriptDAO: ScriptDAO | null = null; let faviconDAO: FaviconDAO | null = null; @@ -92,17 +91,6 @@ export const timeoutAbortSignal = return signal; }; -/** - * 解析相对URL为绝对URL - */ -const resolveUrl = (href: string, base: string): string => { - try { - return new URL(href, base).href; - } catch { - return href; // 如果解析失败,返回原始href - } -}; - export const parseFaviconsNew = (html: string, callback: (href: string) => void) => { // Early exit if no link tags if (!html.toLowerCase().includes(" void) return; }; -const getFilename = (url: string) => { - const i = url.lastIndexOf("/"); - if (i >= 0) return url.substring(i + 1); - return url; -}; - -const checkFileNameEqual = (a: string, b: string) => { - const name1 = getFilename(a); - const name2 = getFilename(b); - return 0 === name1.localeCompare(name2, "en", { sensitivity: "base" }); -}; - /** * 从域名获取favicon */ -export async function fetchIconByDomain(domain: string): Promise { +export async function fetchIconByDomain(domain: string): Promise { const url = `https://${domain}`; - const icons: string[] = []; - - // 设置超时时间(例如 5 秒) - const timeout = 5000; // 单位:毫秒 - - // 获取页面HTML - const response = await fetch(url, { signal: timeoutAbortSignal(timeout) }); - const html = await readBlobContent(response, response.headers.get("content-type")); - const resolvedPageUrl = response.url; - const resolvedUrl = new URL(resolvedPageUrl); - const resolvedOrigin = resolvedUrl.origin; - - parseFaviconsNew(html, (href) => icons.push(resolveUrl(href, resolvedPageUrl))); - - // 检查默认favicon位置 - if (icons.length === 0) { - const faviconUrl = `${resolvedOrigin}/favicon.ico`; - icons.push(faviconUrl); + const sDomain = new URL(url).hostname; + if (!sDomain || sDomain.length > 253) { + throw new Error("invalid domain name"); } - - const urls = await Promise.all( - icons.map((icon) => - fetch(icon, { method: "HEAD", signal: timeoutAbortSignal(timeout) }) - .then((res) => { - if (res.ok && checkFileNameEqual(res.url, icon)) { - return res.url; - } - }) - .catch(() => { - // 忽略错误 - }) - ) - ); - - return urls.filter((url) => !!url) as string[]; + return `https://scriptcat.org/api/v2/open/favicons?domain=${encodeURIComponent(sDomain)}&sz=64`; } // 获取脚本的favicon @@ -199,8 +145,7 @@ export const getScriptFavicon = async (uuid: string): Promise = domains.map(async (domain) => { try { if (domain.domain) { - const icons = await fetchIconByDomain(domain.domain); - const icon = icons.length > 0 ? icons[0] : ""; + const icon = await fetchIconByDomain(domain.domain); return { match: domain.match, website: "http://" + domain.domain, icon }; } } catch { From 4f3b91884d830fd67c509ce071cdecad88b356d5 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:20:58 +0900 Subject: [PATCH 2/9] scriptcat.org -> ext.scriptcat.org --- src/pages/store/favicons.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/store/favicons.ts b/src/pages/store/favicons.ts index 488acb1aa..541eb8fe7 100644 --- a/src/pages/store/favicons.ts +++ b/src/pages/store/favicons.ts @@ -7,6 +7,9 @@ let scriptDAO: ScriptDAO | null = null; let faviconDAO: FaviconDAO | null = null; const loadFaviconPromises = new Map(); // 关联 iconUrl 和 blobUrl +const FETCH_SERVICE_URL = "https://ext.scriptcat.org/api/v1/open/favicons"; +const FETCH_ICON_SIZE = 64; + /** * 从URL模式中提取域名 */ @@ -122,7 +125,7 @@ export async function fetchIconByDomain(domain: string): Promise { if (!sDomain || sDomain.length > 253) { throw new Error("invalid domain name"); } - return `https://scriptcat.org/api/v2/open/favicons?domain=${encodeURIComponent(sDomain)}&sz=64`; + return `${FETCH_SERVICE_URL}?domain=${encodeURIComponent(sDomain)}&sz=${FETCH_ICON_SIZE}`; } // 获取脚本的favicon From 28ffa93e9c1546a77c6f144ebe675a6e748fe7ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 11 Mar 2026 21:23:54 +0800 Subject: [PATCH 3/9] =?UTF-8?q?=E2=9C=A8=20=E6=94=AF=E6=8C=81=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=8C=96=20Favicon=20=E6=9C=8D=E5=8A=A1=E6=8F=90?= =?UTF-8?q?=E4=BE=9B=E8=80=85=20#1268?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增图标服务设置项,支持 ScriptCat / 本地获取 / Google 三种 provider, 默认使用 ScriptCat API。切换 provider 时自动清除 favicon 缓存。 同时增加了对接口返回异常状态码(>=300)的处理。 --- src/locales/de-DE/translation.json | 5 +++ src/locales/en-US/translation.json | 5 +++ src/locales/ja-JP/translation.json | 5 +++ src/locales/ru-RU/translation.json | 5 +++ src/locales/vi-VN/translation.json | 5 +++ src/locales/zh-CN/translation.json | 5 +++ src/locales/zh-TW/translation.json | 5 +++ src/pages/options/routes/ScriptList/hooks.tsx | 4 ++- src/pages/options/routes/Setting.tsx | 36 +++++++++++++++++++ src/pages/store/favicons.ts | 32 +++++++++++++---- src/pkg/config/config.ts | 10 ++++++ 11 files changed, 110 insertions(+), 7 deletions(-) diff --git a/src/locales/de-DE/translation.json b/src/locales/de-DE/translation.json index 9df67cfd8..b01f555b4 100644 --- a/src/locales/de-DE/translation.json +++ b/src/locales/de-DE/translation.json @@ -581,6 +581,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 9769ef7f7..186228e50 100644 --- a/src/locales/en-US/translation.json +++ b/src/locales/en-US/translation.json @@ -581,6 +581,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 823949c52..3b02d866c 100644 --- a/src/locales/ja-JP/translation.json +++ b/src/locales/ja-JP/translation.json @@ -581,6 +581,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 1b6b72973..e7ba825bb 100644 --- a/src/locales/ru-RU/translation.json +++ b/src/locales/ru-RU/translation.json @@ -581,6 +581,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 c69cb10be..96788df47 100644 --- a/src/locales/vi-VN/translation.json +++ b/src/locales/vi-VN/translation.json @@ -581,6 +581,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 1267d3472..105fc3855 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -581,6 +581,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 a56ddd57e..e86a5b42a 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -581,6 +581,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..9f22a9ded 100644 --- a/src/pages/options/routes/Setting.tsx +++ b/src/pages/options/routes/Setting.tsx @@ -15,6 +15,7 @@ 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 { type TKeyValue } from "@Packages/message/message_queue"; import { useEffect, useMemo } from "react"; import { systemConfig } from "@App/pages/store/global"; @@ -41,6 +42,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 +83,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 +309,39 @@ function Setting() { + + {/* Favicon 服务 */} +
+
{t("favicon_service")}
+
+
+ +
+ {t("favicon_service_desc")} +
+
diff --git a/src/pages/store/favicons.ts b/src/pages/store/favicons.ts index 0ed61ef6f..1ba9d7da0 100644 --- a/src/pages/store/favicons.ts +++ b/src/pages/store/favicons.ts @@ -3,6 +3,7 @@ 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; @@ -179,8 +180,22 @@ 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": + return 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 +214,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 +248,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 +276,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 +325,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 +339,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..1f98fcebc 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) { + this._set("favicon_service", val); + } } let lazyScriptNamePrefix: string = ""; From 7bfe3b11498bfdb350e3a005ff9d1313ad15b895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 11 Mar 2026 21:25:33 +0800 Subject: [PATCH 4/9] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=E5=9B=BE?= =?UTF-8?q?=E6=A0=87=E6=9C=8D=E5=8A=A1=E8=AE=BE=E7=BD=AE=E9=A1=B9=E5=B8=83?= =?UTF-8?q?=E5=B1=80=EF=BC=8C=E6=A0=87=E9=A2=98=E4=B8=8E=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E6=A1=86=E6=94=BE=E5=9C=A8=E5=90=8C=E4=B8=80=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/options/routes/Setting.tsx | 56 ++++++++++++++-------------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/src/pages/options/routes/Setting.tsx b/src/pages/options/routes/Setting.tsx index 9f22a9ded..41ff6e6a3 100644 --- a/src/pages/options/routes/Setting.tsx +++ b/src/pages/options/routes/Setting.tsx @@ -311,36 +311,34 @@ function Setting() { {/* Favicon 服务 */} -
-
{t("favicon_service")}
-
-
- -
- {t("favicon_service_desc")} +
+
+ {t("favicon_service")} +
+ {t("favicon_service_desc")}
From 86ed745fb94fa0ed976d4847d156dc16bbe4538d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 11 Mar 2026 21:40:13 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20local?= =?UTF-8?q?=20=E6=A8=A1=E5=BC=8F=E4=B8=8B=20favicon=20=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E8=BF=98=E5=8E=9F=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E6=8A=93=E5=8F=96=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/store/favicons.ts | 73 ++++++++++++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 10 deletions(-) diff --git a/src/pages/store/favicons.ts b/src/pages/store/favicons.ts index 28b5bffa8..34d68e1fd 100644 --- a/src/pages/store/favicons.ts +++ b/src/pages/store/favicons.ts @@ -9,9 +9,6 @@ let scriptDAO: ScriptDAO | null = null; let faviconDAO: FaviconDAO | null = null; const loadFaviconPromises = new Map(); // 关联 iconUrl 和 blobUrl -const FETCH_SERVICE_URL = "https://ext.scriptcat.org/api/v1/open/favicons"; -const FETCH_ICON_SIZE = 64; - /** * 从URL模式中提取域名 */ @@ -96,6 +93,17 @@ export const timeoutAbortSignal = return signal; }; +/** + * 解析相对URL为绝对URL + */ +const resolveUrl = (href: string, base: string): string => { + try { + return new URL(href, base).href; + } catch { + return href; // 如果解析失败,返回原始href + } +}; + export const parseFaviconsNew = (html: string, callback: (href: string) => void) => { // Early exit if no link tags if (!html.toLowerCase().includes(" void) return; }; +const getFilename = (url: string) => { + const i = url.lastIndexOf("/"); + if (i >= 0) return url.substring(i + 1); + return url; +}; + +const checkFileNameEqual = (a: string, b: string) => { + const name1 = getFilename(a); + const name2 = getFilename(b); + return 0 === name1.localeCompare(name2, "en", { sensitivity: "base" }); +}; + /** * 从域名获取favicon */ -export async function fetchIconByDomain(domain: string): Promise { +export async function fetchIconByDomain(domain: string): Promise { const url = `https://${domain}`; - const sDomain = new URL(url).hostname; - if (!sDomain || sDomain.length > 253) { - throw new Error("invalid domain name"); + const icons: string[] = []; + + // 设置超时时间(例如 5 秒) + const timeout = 5000; // 单位:毫秒 + + // 获取页面HTML + const response = await fetch(url, { signal: timeoutAbortSignal(timeout) }); + const html = await readBlobContent(response, response.headers.get("content-type")); + const resolvedPageUrl = response.url; + const resolvedUrl = new URL(resolvedPageUrl); + const resolvedOrigin = resolvedUrl.origin; + + parseFaviconsNew(html, (href) => icons.push(resolveUrl(href, resolvedPageUrl))); + + // 检查默认favicon位置 + if (icons.length === 0) { + const faviconUrl = `${resolvedOrigin}/favicon.ico`; + icons.push(faviconUrl); } - return `${FETCH_SERVICE_URL}?domain=${encodeURIComponent(sDomain)}&sz=${FETCH_ICON_SIZE}`; + + const urls = await Promise.all( + icons.map((icon) => + fetch(icon, { method: "HEAD", signal: timeoutAbortSignal(timeout) }) + .then((res) => { + if (res.ok && checkFileNameEqual(res.url, icon)) { + return res.url; + } + }) + .catch(() => { + // 忽略错误 + }) + ) + ); + + return urls.filter((url) => !!url) as string[]; } /** @@ -140,12 +190,15 @@ export async function fetchIconByService(domain: string, service: FaviconService case "google": return [`https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`]; case "local": - return fetchIconByDomain(domain); + return await fetchIconByDomain(domain); } } // 获取脚本的favicon -export const getScriptFavicon = async (uuid: string, service: FaviconService = "scriptcat"): Promise => { +export const getScriptFavicon = async ( + uuid: string, + service: FaviconService = "scriptcat" +): Promise => { scriptDAO ||= new ScriptDAO(); faviconDAO ||= new FaviconDAO(); const script = await scriptDAO.get(uuid); From 0a69c27cb74033549cc6e43dd6367603b018df0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 11 Mar 2026 21:43:25 +0800 Subject: [PATCH 6/9] =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/options/routes/Setting.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/options/routes/Setting.tsx b/src/pages/options/routes/Setting.tsx index 41ff6e6a3..3f03ca51d 100644 --- a/src/pages/options/routes/Setting.tsx +++ b/src/pages/options/routes/Setting.tsx @@ -334,8 +334,8 @@ function Setting() { }} > {t("favicon_service_scriptcat")} - {t("favicon_service_local")} {t("favicon_service_google")} + {t("favicon_service_local")}
{t("favicon_service_desc")} From ff828522e5d4a9b1fb53112d54d11b796f7a87d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 16 Mar 2026 14:30:45 +0800 Subject: [PATCH 7/9] =?UTF-8?q?=E2=9C=85=20=E4=B8=BA=20#1268=20favicon=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=A2=9E=E5=8A=A0=E5=8D=95=E5=85=83=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/store/favicons.test.ts | 308 ++++++++++++++++++++++++++++++- 1 file changed, 306 insertions(+), 2 deletions(-) diff --git a/src/pages/store/favicons.test.ts b/src/pages/store/favicons.test.ts index 6f78f81b0..0914a274c 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,301 @@ 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); + }); }); From e963330dc4e3db08508c8aa26377163587d9c058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 16 Mar 2026 14:47:01 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20favicon?= =?UTF-8?q?=20=E6=9C=8D=E5=8A=A1=E5=88=87=E6=8D=A2=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetchIconByService 添加 default 分支回退到 local - 切换服务时清除内存中的 blob URL 缓存 - 使用 deletes() 批量删除替代逐条删除 - setFaviconService 添加 return - 修复 pre-push 正则匹配 release/ 分支 --- .husky/pre-push | 2 +- src/pages/options/routes/Setting.tsx | 5 ++++- src/pages/store/favicons.ts | 13 +++++++++++++ src/pkg/config/config.ts | 2 +- 4 files changed, 19 insertions(+), 3 deletions(-) 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/pages/options/routes/Setting.tsx b/src/pages/options/routes/Setting.tsx index 3f03ca51d..0ac87c935 100644 --- a/src/pages/options/routes/Setting.tsx +++ b/src/pages/options/routes/Setting.tsx @@ -16,6 +16,7 @@ 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"; @@ -323,10 +324,12 @@ function Setting() { try { const faviconDAO = new FaviconDAO(); const allFavicons = await faviconDAO.find(); - await Promise.all(allFavicons.map((f) => faviconDAO.delete(f.uuid))); + await faviconDAO.deletes(allFavicons.map((f) => f.uuid)); // 清除 OPFS 缓存:删除并重建目录 const opfsRoot = await navigator.storage.getDirectory(); await opfsRoot.removeEntry("cached_favicons", { recursive: true }).catch(() => {}); + // 清除内存中的 blob URL 缓存 + clearFaviconMemoryCache(); } catch { // 忽略清除缓存的错误 } diff --git a/src/pages/store/favicons.ts b/src/pages/store/favicons.ts index 34d68e1fd..a4f5e80f4 100644 --- a/src/pages/store/favicons.ts +++ b/src/pages/store/favicons.ts @@ -9,6 +9,18 @@ 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模式中提取域名 */ @@ -190,6 +202,7 @@ export async function fetchIconByService(domain: string, service: FaviconService case "google": return [`https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`]; case "local": + default: return await fetchIconByDomain(domain); } } diff --git a/src/pkg/config/config.ts b/src/pkg/config/config.ts index 1f98fcebc..109d334fa 100644 --- a/src/pkg/config/config.ts +++ b/src/pkg/config/config.ts @@ -482,7 +482,7 @@ export class SystemConfig { } setFaviconService(val: FaviconService) { - this._set("favicon_service", val); + return this._set("favicon_service", val); } } From 48186388721beef102ba91b65963921be8240864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 16 Mar 2026 15:12:22 +0800 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=8E=A8=20=E4=BF=AE=E5=A4=8D=20favicon?= =?UTF-8?q?s=20=E7=9B=B8=E5=85=B3=E6=96=87=E4=BB=B6=E7=9A=84=20prettier=20?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/store/favicons.test.ts | 28 ++++++---------------------- src/pages/store/favicons.ts | 12 +++++++----- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/src/pages/store/favicons.test.ts b/src/pages/store/favicons.test.ts index 0914a274c..d3a9f023f 100644 --- a/src/pages/store/favicons.test.ts +++ b/src/pages/store/favicons.test.ts @@ -50,10 +50,7 @@ describe("extractFaviconsDomain", () => { }); it("相同域名应该去重", () => { - const result = extractFaviconsDomain( - ["https://example.com/page1", "https://example.com/page2"], - [] - ); + 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"); @@ -161,9 +158,7 @@ describe("fetchIconByService", () => { 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", - ]); + expect(result).toEqual(["https://ext.scriptcat.org/api/v1/open/favicons?domain=example.com&sz=64"]); }); it("google服务应返回Google favicon URL", async () => { @@ -213,9 +208,7 @@ describe("fetchIconByDomain", () => { 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(mockHtmlResponse("https://noicon.com/", "")); } return Promise.resolve({ ok: true, url: "https://noicon.com/favicon.ico" }); }) @@ -232,10 +225,7 @@ describe("fetchIconByDomain", () => { const urlStr = url.toString(); if (urlStr === "https://fail.com") { return Promise.resolve( - mockHtmlResponse( - "https://fail.com/", - '' - ) + mockHtmlResponse("https://fail.com/", '') ); } return Promise.reject(new Error("Not found")); @@ -253,10 +243,7 @@ describe("fetchIconByDomain", () => { const urlStr = url.toString(); if (urlStr === "https://badstatus.com") { return Promise.resolve( - mockHtmlResponse( - "https://badstatus.com/", - '' - ) + mockHtmlResponse("https://badstatus.com/", '') ); } return Promise.resolve({ ok: false, url: "https://badstatus.com/icon.png" }); @@ -274,10 +261,7 @@ describe("fetchIconByDomain", () => { const urlStr = url.toString(); if (urlStr === "https://redirect.com") { return Promise.resolve( - mockHtmlResponse( - "https://redirect.com/", - '' - ) + mockHtmlResponse("https://redirect.com/", '') ); } return Promise.resolve({ ok: true, url: "https://redirect.com/404.html" }); diff --git a/src/pages/store/favicons.ts b/src/pages/store/favicons.ts index a4f5e80f4..feb2da4df 100644 --- a/src/pages/store/favicons.ts +++ b/src/pages/store/favicons.ts @@ -12,11 +12,13 @@ const loadFaviconPromises = new Map(); // 关联 iconUrl 和 blobUr // 清除内存中的 favicon 缓存,切换服务时调用 export const clearFaviconMemoryCache = () => { loadFaviconPromises.forEach((promise) => { - Promise.resolve(promise).then((blobUrl) => { - if (typeof blobUrl === "string" && blobUrl.startsWith("blob:")) { - URL.revokeObjectURL(blobUrl); - } - }).catch(() => {}); + Promise.resolve(promise) + .then((blobUrl) => { + if (typeof blobUrl === "string" && blobUrl.startsWith("blob:")) { + URL.revokeObjectURL(blobUrl); + } + }) + .catch(() => {}); }); loadFaviconPromises.clear(); };