diff --git a/src/app/repo/resource.ts b/src/app/repo/resource.ts index 6f4ef629a..962622893 100644 --- a/src/app/repo/resource.ts +++ b/src/app/repo/resource.ts @@ -110,7 +110,7 @@ export class ResourceDAO extends Repo { } // CompiledResource结构或 matches 计算规则变更时,建议修改 CompiledResourceNamespace 以删除旧Cache -export const CompiledResourceNamespace = "a8d3d2a3-db3a-4e87-ab6f-9817fe6bd942"; +export const CompiledResourceNamespace = "39a5eaa9-1cd2-446b-82b8-dd7bd3719f52"; export class CompiledResourceDAO extends Repo { constructor() { diff --git a/src/app/repo/scripts.ts b/src/app/repo/scripts.ts index cf99f6de4..301ba7304 100644 --- a/src/app/repo/scripts.ts +++ b/src/app/repo/scripts.ts @@ -109,7 +109,7 @@ export interface ScriptRunResource extends Script { resource: ScriptResource; // 资源列表,包含脚本需要的资源 resourceByType?: ScriptResourceByType; metadata: SCMetadata; // 经自定义覆盖的 Metadata - originalMetadata: SCMetadata; // 原本的 Metadata (目前只需要 match, include, exclude) + originalMetadata: SCMetadata; // 原本的 Metadata (只需要 match, include, exclude, exclude-match) } /** diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 4d7a3fb69..6fc93ce28 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -847,6 +847,17 @@ describe("getPageLoadScriptCacheKey 页面加载缓存键生成", () => { expect(k1).not.toBe(k2); }); + it("@exclude-match 规则变化 → 缓存键不同", () => { + const s1 = _createMockScript({ metadata: { match: ["https://a.com/*"], "exclude-match": ["https://a.com/x/*"] } }); + const s2 = _createMockScript({ + ...s1, + metadata: { match: ["https://a.com/*"], "exclude-match": ["https://a.com/y/*"] }, + }); + const k1 = (runtime as any).getPageLoadScriptCacheKey(buildScriptRunResourceBasic(s1)); + const k2 = (runtime as any).getPageLoadScriptCacheKey(buildScriptRunResourceBasic(s2)); + expect(k1).not.toBe(k2); + }); + it("status 不同 → 缓存键不同(禁用脚本 key 与启用脚本 key 区分)", () => { const enabled = _createMockScript({ updatetime: 50, status: SCRIPT_STATUS_ENABLE }); const disabled = { ...enabled, status: SCRIPT_STATUS_DISABLE }; diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 5dfa33d01..5f35d85c9 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -1354,9 +1354,9 @@ export class RuntimeService { private getPageLoadScriptCacheKey(scriptRes: ScriptRunResource) { const { status, type, updatetime, metadata } = scriptRes; // 代码/原始 metadata/userConfig/资源变化时,主要靠事件处理器(enable/install/delete)失效缓存。 - // 此缓存键只是低成本的兜底:用于 selfMetadata 修改 match/include/exclude 这类「合并后 metadata 变了 + // 此缓存键只是低成本的兜底:用于 selfMetadata 修改 match/include/exclude/exclude-match 这类「合并后 metadata 变了 // 但不一定 bump updatetime」的情况。 - return `${status}:${type}:${updatetime || 0}~${JSON.stringify([metadata.match, metadata.include, metadata.exclude])}`; + return `${status}:${type}:${updatetime || 0}~${JSON.stringify([metadata.match, metadata.include, metadata.exclude, metadata["exclude-match"]])}`; } private getCodeCacheKey(script: Script) { diff --git a/src/app/service/service_worker/script.test.ts b/src/app/service/service_worker/script.test.ts index 7326c66e8..a116f8c36 100644 --- a/src/app/service/service_worker/script.test.ts +++ b/src/app/service/service_worker/script.test.ts @@ -1156,6 +1156,15 @@ describe("ScriptService selfMetadata 用户覆盖", () => { }); }); + it("作者 @exclude-match 已排除当前网址时不应再写入排除", async () => { + const script = createMockScript({ metadata: { match: ["*://*/*"], "exclude-match": ["*://current.example/*"] } }); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + + await scriptService.excludeFromMatch({ uuid: script.uuid, host, url }); + + expect(mockScriptDAO.update).not.toHaveBeenCalled(); + }); + it("当前站点本就不在匹配范围内时不应写入任何覆盖", async () => { const script = createMockScript({ metadata: { match: ["*://other.example/*"] } }); vi.mocked(mockScriptDAO.get).mockResolvedValue(script); diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 543018c68..b6e0d4cfe 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -971,6 +971,7 @@ export class ScriptService { const match = script.selfMetadata?.match ?? script.metadata?.match ?? []; const include = script.selfMetadata?.include ?? script.metadata?.include ?? []; const exclude = script.selfMetadata?.exclude ?? script.metadata?.exclude ?? []; + const excludeMatch = script.selfMetadata?.["exclude-match"] ?? script.metadata?.["exclude-match"] ?? []; // 通配网域(`*://*/*`、`*://*.example.com/*`)不只服务于当前站点,移除会连带关掉其他站点 const keptMatch = match.filter((pattern) => getMatchPatternHost(pattern) !== host); const removedFromMatch = keptMatch.length !== match.length; @@ -982,6 +983,7 @@ export class ScriptService { ...keptMatch.map((e) => `@match ${e}`), ...include.map((e) => `@include ${e}`), ...exclude.map((e) => `@exclude ${e}`), + ...excludeMatch.map((e) => `@exclude-match ${e}`), ]); if (isUrlIncluded(url, rules)) { // 用户覆盖整体替换作者规则,因此把作者 @exclude 一并并入用户覆盖,避免丢作者规则 diff --git a/src/app/service/service_worker/utils.test.ts b/src/app/service/service_worker/utils.test.ts index c83c88854..89bc65570 100644 --- a/src/app/service/service_worker/utils.test.ts +++ b/src/app/service/service_worker/utils.test.ts @@ -9,12 +9,15 @@ import { shouldAutoOpenChangelog, scriptURLPatternResults, openNotificationUrl, + buildScriptRunResourceBasic, + parseScriptLoadInfo, } from "./utils"; import type { SCMetadata, Script, ScriptRunResource } from "@App/app/repo/scripts"; import { SELF_METADATA_ONLY_RUN_ON_URL } from "@App/app/repo/metadata"; import { SCRIPT_TYPE_NORMAL, SCRIPT_STATUS_ENABLE, SCRIPT_RUN_STATUS_COMPLETE } from "@App/app/repo/scripts"; import type { ScriptMatchInfo } from "./types"; import { extractUrlPatterns, RuleTypeBit } from "@App/pkg/utils/url_matcher"; +import { isUrlExcluded, isUrlIncluded } from "@App/pkg/utils/match"; describe.concurrent("parseUrlSRI", () => { it.concurrent("should parse URL SRI", () => { @@ -500,3 +503,74 @@ describe.concurrent("scriptURLPatternResults", () => { expect(result!.originalUrlPatterns.map((rule) => rule.patternString)).toEqual(["*://example.com/*"]); }); }); + +describe.concurrent("@exclude-match 进入生效规则与注册", () => { + const createScript = (metadata: SCMetadata, selfMetadata?: SCMetadata): Script => ({ + uuid: "exclude-match-uuid", + name: "exclude-match", + namespace: "test", + type: SCRIPT_TYPE_NORMAL, + status: SCRIPT_STATUS_ENABLE, + sort: 0, + runStatus: SCRIPT_RUN_STATUS_COMPLETE, + createtime: 0, + checktime: 0, + metadata, + selfMetadata, + }); + const toMatchInfo = (script: Script) => { + const scriptRes = buildScriptRunResourceBasic(script); + const patterns = scriptURLPatternResults(scriptRes)!; + return { scriptRes, patterns, matchInfo: { ...scriptRes, ...patterns } as ScriptMatchInfo }; + }; + + it.concurrent("作者的 @exclude-match 进入 excludeMatches,不产生 excludeGlobs", () => { + const { matchInfo } = toMatchInfo( + createScript({ + match: ["*://example.com/*"], + "exclude-match": ["*://example.com/admin/*", "https://example.com/private/*"], + }) + ); + + const { registerScript } = getUserScriptRegister(matchInfo); + + expect(registerScript.matches).toEqual(["*://example.com/*"]); + expect(registerScript.excludeMatches).toEqual(["*://example.com/admin/*", "https://example.com/private/*"]); + expect(registerScript.excludeGlobs).toEqual([]); + }); + + it.concurrent("用户覆盖匹配后原始规则仍带 @exclude-match,Popup 判断不会把被排除的站点算作原本匹配", () => { + const { patterns } = toMatchInfo( + createScript( + { match: ["*://example.com/*"], "exclude-match": ["*://example.com/admin/*"] }, + { match: ["*://other.example/*"] } + ) + ); + + expect(isUrlIncluded("https://example.com/admin/x", patterns.originalUrlPatterns)).toBe(false); + expect(isUrlIncluded("https://example.com/home", patterns.originalUrlPatterns)).toBe(true); + }); + + it.concurrent("用户以空列表覆盖 @exclude-match 时生效规则不再排除,原始规则仍排除", () => { + const { patterns } = toMatchInfo( + createScript( + { match: ["*://example.com/*"], "exclude-match": ["*://example.com/admin/*"] }, + { "exclude-match": [] } + ) + ); + + expect(isUrlIncluded("https://example.com/admin/x", patterns.scriptUrlPatterns)).toBe(true); + expect(isUrlIncluded("https://example.com/admin/x", patterns.originalUrlPatterns)).toBe(false); + }); + + it.concurrent("early-start 含正则时注入代码携带的规则同样排除 @exclude-match", () => { + const { scriptRes, patterns } = toMatchInfo( + createScript({ include: ["/example\\.com/"], "exclude-match": ["*://example.com/admin/*"] }) + ); + + const loadInfo = parseScriptLoadInfo(scriptRes, patterns.scriptUrlPatterns); + + expect(isUrlExcluded("https://example.com/admin/x", loadInfo.scriptUrlPatterns!)).toBe(true); + expect(isUrlExcluded("https://example.com/home", loadInfo.scriptUrlPatterns!)).toBe(false); + }); +}); diff --git a/src/app/service/service_worker/utils.ts b/src/app/service/service_worker/utils.ts index 27bac8e61..45b2e77b5 100644 --- a/src/app/service/service_worker/utils.ts +++ b/src/app/service/service_worker/utils.ts @@ -249,8 +249,8 @@ export function getUserScriptRegister(scriptMatchInfo: ScriptMatchInfo) { export function buildScriptRunResourceBasic(script: Script): ScriptRunResource { const ret: ScriptRunResource = { ...script } as ScriptRunResource; // 自定义配置 - const { match, include, exclude } = ret.metadata; - ret.originalMetadata = { match, include, exclude }; // 目前只需要 match, include, exclude + const { match, include, exclude, "exclude-match": excludeMatch } = ret.metadata; + ret.originalMetadata = { match, include, exclude, "exclude-match": excludeMatch }; // 只需要 URL 匹配规则 if (ret.selfMetadata) { ret.metadata = getCombinedMeta(ret.metadata, ret.selfMetadata); } @@ -274,6 +274,7 @@ export function scriptURLPatternResults(scriptRes: { const metaMatch = metadata.match; const metaInclude = metadata.include; const metaExclude = metadata.exclude; + const metaExcludeMatch = metadata["exclude-match"]; // 生效规则为空时仍要解析原始规则:用户把当前站点从匹配中移除后可能一条不剩, // 此时脚本不匹配任何站点,但 Popup 要靠原始规则继续列出它(未生效),用户才有恢复入口。 // 只有连原始规则都没有的脚本才真的无从匹配。 @@ -289,6 +290,7 @@ export function scriptURLPatternResults(scriptRes: { ...(metaMatch || []).map((e) => `@match ${e}`), ...(metaInclude || []).map((e) => `@include ${e}`), ...(metaExclude || []).map((e) => `@exclude ${e}`), + ...(metaExcludeMatch || []).map((e) => `@exclude-match ${e}`), ]); // 如果使用了自定义排除,无法在脚本原有的网域看到匹配情况 @@ -296,11 +298,12 @@ export function scriptURLPatternResults(scriptRes: { const selfMetadata = scriptRes.selfMetadata; const originalUrlPatterns: URLRuleEntry[] | null = - selfMetadata?.match || selfMetadata?.include || selfMetadata?.exclude + selfMetadata?.match || selfMetadata?.include || selfMetadata?.exclude || selfMetadata?.["exclude-match"] ? extractUrlPatterns([ ...(originalMetadata.match || []).map((e) => `@match ${e}`), ...(originalMetadata.include || []).map((e) => `@include ${e}`), ...(originalMetadata.exclude || []).map((e) => `@exclude ${e}`), + ...(originalMetadata["exclude-match"] || []).map((e) => `@exclude-match ${e}`), ]) : scriptUrlPatterns; diff --git a/src/pages/install/compat.test.ts b/src/pages/install/compat.test.ts index cb150baf4..0bf8cf390 100644 --- a/src/pages/install/compat.test.ts +++ b/src/pages/install/compat.test.ts @@ -36,9 +36,9 @@ describe("安装页兼容性标记", () => { }); it("标记按作者的原始写法呈现", () => { - const { code, metadata } = build(`// @name X\n// @Exclude-Match *://b.com/*\n// @storageName s\n`); + const { code, metadata } = build(`// @name X\n// @SandBox raw\n// @storageName s\n`); const marks = deriveCompatMarks(metadata, code); - expect(marks.tags).toEqual([{ tag: "Exclude-Match", line: 3 }]); + expect(marks.tags).toEqual([{ tag: "SandBox", line: 3 }]); expect(marks.scriptcatOnlyTags).toEqual([{ tag: "storageName", line: 4 }]); }); @@ -97,6 +97,16 @@ describe("安装页兼容性标记", () => { expect(marks.tags).toEqual([]); }); + it("@exclude-match 受支持不标记;解析不出规则的取值连同取值标出并定位,不混进运行网站行", () => { + const { code, metadata } = build( + `// @name X\n// @match *://a.com/*\n// @exclude-match *://a.com/admin/*\n// @exclude-match *admin*\n` + ); + const marks = deriveCompatMarks(metadata, code); + expect(marks.tags).toEqual([{ tag: "exclude-match", value: "*admin*", line: 5 }]); + expect(marks.matches).toEqual(new Map()); + expect(marks.scriptcatOnlyTags).toEqual([]); + }); + it("脚本猫独有的指令写了也不生效时只按不生效标一次", () => { const { code, metadata } = build(`// @name X\n// @early-start\n`); const marks = deriveCompatMarks(metadata, code); diff --git a/src/pages/install/useInstallData.test.ts b/src/pages/install/useInstallData.test.ts index 3654c6c73..01b7f5fb9 100644 --- a/src/pages/install/useInstallData.test.ts +++ b/src/pages/install/useInstallData.test.ts @@ -118,10 +118,10 @@ describe("assembleInstallView 组装安装视图", () => { }); it("组装时派生不生效标记,行号取自待安装代码", () => { - const metadata = { name: ["示例脚本"], version: ["2.3.1"], "exclude-match": ["*://a.com/*"], grant: ["GM_audio"] }; + const metadata = { name: ["示例脚本"], version: ["2.3.1"], sandbox: ["JavaScript"], grant: ["GM_audio"] }; const code = `// ==UserScript== // @name 示例脚本 -// @exclude-match *://a.com/* +// @sandbox JavaScript // @grant GM_audio // ==/UserScript==`; const view = assembleInstallView({ @@ -132,7 +132,7 @@ describe("assembleInstallView 组装安装视图", () => { oldVersion: null, }); expect(view.compat.grants).toEqual(new Map([["GM_audio", 4]])); - expect(view.compat.tags).toEqual([{ tag: "exclude-match", line: 3 }]); + expect(view.compat.tags).toEqual([{ tag: "sandbox", line: 3 }]); expect(view.compat.scriptcatOnlyTags).toEqual([]); }); diff --git a/src/pkg/backup/self_metadata.test.ts b/src/pkg/backup/self_metadata.test.ts index 9b5e802d5..f6d5ffc36 100644 --- a/src/pkg/backup/self_metadata.test.ts +++ b/src/pkg/backup/self_metadata.test.ts @@ -44,10 +44,18 @@ describe("vmCustomToOverride", () => { expect(ov.merge_excludes).toBe(true); expect(ov.run_at).toBe("document-start"); }); - it("excludeMatch 折入 exclude", () => { + it("excludeMatch/origExcludeMatch 与 exclude/origExclude 各自独立归一", () => { const ov = vmCustomToOverride({ exclude: ["a"], excludeMatch: ["b"], origExclude: false, origExcludeMatch: true }); - expect(ov.use_excludes).toEqual(["a", "b"]); - expect(ov.merge_excludes).toBe(false); // origExclude=false → 不再合并 + expect(ov.use_excludes).toEqual(["a"]); + expect(ov.merge_excludes).toBe(false); + expect(ov.use_exclude_matches).toEqual(["b"]); + expect(ov.merge_exclude_matches).toBe(true); + }); + it("VM 关掉脚本自带 @exclude-match 时导入后以用户列表替换,不复活作者规则", () => { + const m: SCMetadata = { exclude: ["https://a.com/x"], "exclude-match": ["*://a.com/admin/*"] }; + const self = overrideToSelfMetadata(vmCustomToOverride({ excludeMatch: [], origExcludeMatch: false }), m); + expect(self["exclude-match"]).toEqual([]); + expect(self.exclude).toBeUndefined(); }); it("把 VM custom 的 tag/origTag 归一到 override", () => { const ov = vmCustomToOverride({ tag: ["te", "tag"], origTag: true }); diff --git a/src/pkg/backup/self_metadata.ts b/src/pkg/backup/self_metadata.ts index 244f08c31..e36cfdc85 100644 --- a/src/pkg/backup/self_metadata.ts +++ b/src/pkg/backup/self_metadata.ts @@ -5,10 +5,12 @@ export type OverrideInput = { use_matches?: string[]; use_includes?: string[]; use_excludes?: string[]; + use_exclude_matches?: string[]; use_tags?: string[]; merge_matches?: boolean; merge_includes?: boolean; merge_excludes?: boolean; + merge_exclude_matches?: boolean; merge_tags?: boolean; run_at?: string | null; noframes?: boolean | null; @@ -18,6 +20,7 @@ const LIST_MAP = [ ["match", "use_matches", "merge_matches"], ["include", "use_includes", "merge_includes"], ["exclude", "use_excludes", "merge_excludes"], + ["exclude-match", "use_exclude_matches", "merge_exclude_matches"], ["tag", "use_tags", "merge_tags"], ] as const; @@ -65,15 +68,16 @@ type VMCustom = { /** Violentmonkey custom → 归一化 override */ export function vmCustomToOverride(custom: VMCustom | undefined): OverrideInput { const c = custom || {}; - const hasExclude = c.exclude !== undefined || c.excludeMatch !== undefined; return { use_matches: c.match, use_includes: c.include, - use_excludes: hasExclude ? [...(c.exclude || []), ...(c.excludeMatch || [])] : undefined, + use_excludes: c.exclude, + use_exclude_matches: c.excludeMatch, use_tags: c.tag, merge_matches: c.origMatch !== false, merge_includes: c.origInclude !== false, - merge_excludes: c.origExclude !== false && c.origExcludeMatch !== false, + merge_excludes: c.origExclude !== false, + merge_exclude_matches: c.origExcludeMatch !== false, merge_tags: c.origTag !== false, run_at: c.runAt, noframes: c.noframes == null ? null : !!c.noframes, diff --git a/src/pkg/utils/script_compat.test.ts b/src/pkg/utils/script_compat.test.ts index c2a6a0761..0b38d66f0 100644 --- a/src/pkg/utils/script_compat.test.ts +++ b/src/pkg/utils/script_compat.test.ts @@ -20,7 +20,17 @@ import { describe("元数据指令支持判定", () => { it("运行时会消费的指令视为支持", () => { - for (const tag of ["match", "include", "exclude", "grant", "run-at", "run-in", "noframes", "connect"]) { + for (const tag of [ + "match", + "include", + "exclude", + "exclude-match", + "grant", + "run-at", + "run-in", + "noframes", + "connect", + ]) { expect(isSupportedMetadataTag(tag), tag).toBe(true); } }); @@ -32,7 +42,7 @@ describe("元数据指令支持判定", () => { }); it("别家管理器会执行、脚本猫未实现的指令视为不支持", () => { - for (const tag of ["exclude-match", "top-level-await", "sandbox", "webRequest", "allFrames", "user-agent"]) { + for (const tag of ["top-level-await", "sandbox", "webRequest", "allFrames", "user-agent"]) { expect(isSupportedMetadataTag(tag), tag).toBe(false); } }); @@ -46,7 +56,8 @@ describe("元数据指令支持判定", () => { it("指令名大小写不敏感", () => { expect(isSupportedMetadataTag("MATCH")).toBe(true); expect(isSupportedMetadataTag("Run-At")).toBe(true); - expect(isSupportedMetadataTag("EXCLUDE-MATCH")).toBe(false); + expect(isSupportedMetadataTag("EXCLUDE-MATCH")).toBe(true); + expect(isSupportedMetadataTag("TOP-LEVEL-AWAIT")).toBe(false); }); it("name/description 的语言后缀归到同一指令", () => { @@ -149,6 +160,15 @@ describe("元数据取值支持判定", () => { ]); }); + it("解析不出 match pattern 的 @exclude-match 报为不生效,不会退化成 @exclude 的 glob/正则", () => { + expect( + ineffectiveMetadataValues({ "exclude-match": ["*://a.com/admin/*", "*admin*", "/admin/", "www.a.com/*"] }) + ).toEqual([ + { tag: "exclude-match", index: 1, value: "*admin*" }, + { tag: "exclude-match", index: 2, value: "/admin/" }, + ]); + }); + it("不限取值的指令不做取值判定", () => { expect(ineffectiveMetadataValues({ include: ["anything"], namespace: ["x"], noframes: ["whatever"] })).toEqual([]); }); diff --git a/src/pkg/utils/script_compat.ts b/src/pkg/utils/script_compat.ts index 1a164b57f..aa3290adb 100644 --- a/src/pkg/utils/script_compat.ts +++ b/src/pkg/utils/script_compat.ts @@ -7,7 +7,7 @@ import { extractUrlPatterns } from "./url_matcher"; * * 判定是二元的——指令/能力要么被脚本猫消费,要么写了也不会生效,没有中间档。 * 表外即「不生效」,因此收录标准是「脚本猫会消费它」或「脚本猫不消费但它也不改变脚本运行行为」; - * 只有会改变别家管理器下脚本行为、而脚本猫没实现的指令才刻意留在表外(如 @exclude-match)。 + * 只有会改变别家管理器下脚本行为、而脚本猫没实现的指令才刻意留在表外(如 @sandbox)。 * 取值同理:取值有限的指令只认白名单里的取值,运行时对不认得的取值会静默回退。 */ @@ -32,6 +32,7 @@ const CONSUMED_TAGS = [ "match", "include", "exclude", + "exclude-match", "connect", "grant", "require", @@ -153,7 +154,7 @@ export interface IneffectiveMetadataValue { value: string; } -/** 受支持指令里不会按写法生效的取值:不在白名单内、运行时只读第一个而被忽略的后续取值、解析不出规则的 @match */ +/** 受支持指令里不会按写法生效的取值:不在白名单内、运行时只读第一个而被忽略的后续取值、解析不出规则的 @match/@exclude-match */ export function ineffectiveMetadataValues(metadata: SCMetadata): IneffectiveMetadataValue[] { const result: IneffectiveMetadataValue[] = []; for (const [tag, allowed] of Object.entries(SUPPORTED_TAG_VALUES)) { @@ -166,10 +167,12 @@ export function ineffectiveMetadataValues(metadata: SCMetadata): IneffectiveMeta if (!effective) result.push({ tag, index, value }); }); } - // @match 写法开放,交给运行时同一个解析器判:解析不出规则的会被静默丢弃(@include/@exclude 总能退化成 glob) - (metadata.match || []).forEach((value, index) => { - if (!extractUrlPatterns([`@match ${value}`]).length) result.push({ tag: "match", index, value }); - }); + // @match/@exclude-match 写法开放,交给运行时同一个解析器判:解析不出规则的会被静默丢弃(@include/@exclude 总能退化成 glob) + for (const tag of ["match", "exclude-match"]) { + (metadata[tag] || []).forEach((value, index) => { + if (!extractUrlPatterns([`@${tag} ${value}`]).length) result.push({ tag, index, value }); + }); + } return result; } diff --git a/src/pkg/utils/url_matcher.test.ts b/src/pkg/utils/url_matcher.test.ts index 1043e8d1d..e09063d9b 100644 --- a/src/pkg/utils/url_matcher.test.ts +++ b/src/pkg/utils/url_matcher.test.ts @@ -9,6 +9,7 @@ import { embeddedPatternCheckerString, getMatchPatternHost, } from "./url_matcher"; +import { isUrlIncluded } from "./match"; describe.concurrent("extractMatchPatternsFromGlobs", () => { it.concurrent("test1", () => { @@ -1048,3 +1049,79 @@ describe.concurrent("embeddedPatternChecker", () => { expect(eval(code3)).toBe(false); }); }); + +describe.concurrent("@exclude-match", () => { + const run = (lines: string[], url: string) => isUrlIncluded(url, extractUrlPatterns(lines)); + + it.concurrent("一条或多条 @exclude-match 解析为 match pattern 排除规则", () => { + const rules = extractUrlPatterns([ + "@match *://*.example.com/*", + "@exclude-match https://example.com/admin/*", + "@exclude-match *://cdn.example.com/*", + ]); + expect(rules.slice(1)).toEqual([ + { + ruleType: RuleType.MATCH_EXCLUDE, + ruleContent: ["https", "example.com", "admin/*"], + ruleTag: "exclude-match", + patternString: "https://example.com/admin/*", + }, + { + ruleType: RuleType.MATCH_EXCLUDE, + ruleContent: ["*", "cdn.example.com", "*"], + ruleTag: "exclude-match", + patternString: "*://cdn.example.com/*", + }, + ]); + }); + + it.concurrent("不命中 @exclude-match 的网址仍按 @match 执行", () => { + expect( + run(["@match *://*.example.com/*", "@exclude-match *://example.com/admin/*"], "https://example.com/home") + ).toBe(true); + }); + + it.concurrent("同时命中 @match 与 @exclude-match 时不执行,与声明顺序无关", () => { + const url = "https://example.com/admin/users"; + expect(run(["@match *://example.com/*", "@exclude-match *://example.com/admin/*"], url)).toBe(false); + expect(run(["@exclude-match *://example.com/admin/*", "@match *://example.com/*"], url)).toBe(false); + }); + + it.concurrent("按 match pattern 的通配网域与 scheme 语义排除", () => { + const lines = ["@match *://*/*", "@exclude-match *://*.example.com/*", "@exclude-match https://secure.test/*"]; + expect(run(lines, "https://example.com/")).toBe(false); + expect(run(lines, "http://a.b.example.com/x")).toBe(false); + expect(run(lines, "https://notexample.com/")).toBe(true); + expect(run(lines, "https://secure.test/page")).toBe(false); + expect(run(lines, "http://secure.test/page")).toBe(true); + }); + + it.concurrent("与 @match 相同的 TM 兼容写法:省略 scheme、端口、http*", () => { + expect(extractUrlPatterns(["@exclude-match www.example.com/*"])[0].patternString).toBe("*://www.example.com/*"); + expect(extractUrlPatterns(["@exclude-match https://example.com:8080/*"])[0].patternString).toBe( + "https://example.com/*" + ); + expect(extractUrlPatterns(["@exclude-match http*://example.com/*"])[0].patternString).toBe("*://example.com/*"); + }); + + it.concurrent("不合法的 match pattern 直接丢弃,不退化为 @exclude 的 glob/正则", () => { + for (const value of ["/example\\.com/", "*example*", "https://exa*mple.com/*", "hello-world^^"]) { + expect(extractUrlPatterns([`@exclude-match ${value}`]), value).toEqual([]); + } + // 同一取值写成 @exclude 仍按 glob 生效 + expect(extractUrlPatterns(["@exclude *example*"])[0].ruleType).toBe(RuleType.GLOB_EXCLUDE); + }); + + it.concurrent("嵌入式检查器(unwrap / early-start)同样排除,与声明顺序无关", () => { + const check = (lines: string[], url: string) => { + const reduced = extractUrlPatterns(lines).map(({ ruleType, ruleContent }) => ({ ruleType, ruleContent })); + return eval(embeddedPatternCheckerString(JSON.stringify(url), JSON.stringify(reduced))); + }; + const url = "https://example.com/admin/users"; + expect(check(["@include /example\\.com/", "@exclude-match *://example.com/admin/*"], url)).toBe(false); + expect(check(["@exclude-match *://example.com/admin/*", "@include /example\\.com/"], url)).toBe(false); + expect(check(["@include /example\\.com/", "@exclude-match *://example.com/admin/*"], "https://example.com/")).toBe( + true + ); + }); +}); diff --git a/src/pkg/utils/url_matcher.ts b/src/pkg/utils/url_matcher.ts index ace965fab..76c367174 100644 --- a/src/pkg/utils/url_matcher.ts +++ b/src/pkg/utils/url_matcher.ts @@ -23,7 +23,7 @@ export type URLRuleEntry = { const URL_MATCH_CACHE_MAX_SIZE = 512; // 用来做简单缓存,512 算是足够大小应付需要。 -// 检查 @match @include @exclude 是否按照MV3的 match pattern +// 检查 @match @include @exclude @exclude-match 是否按照MV3的 match pattern // export 只用于测试,不要在外部直接引用 checkUrlMatch export const checkUrlMatch = (s: string): string[] | null => { s = s.trim(); @@ -72,14 +72,16 @@ const globSplit = (text: string): string[] => { export const extractUrlPatterns = (lines: string[]): URLRuleEntry[] => { const rules = []; for (const line of lines) { - const mt = /@(match|include|exclude)\s+([^\t\r\n]+?)([\r\n]|$)/.exec(line); + const mt = /@(match|include|exclude-match|exclude)\s+([^\t\r\n]+?)([\r\n]|$)/.exec(line); if (!mt) continue; const [_, tag0, content0] = mt; const tag = tag0; + // @exclude-match 是 @match 的排除版,取值按同一套 match pattern 规则处理 + const isMatchPattern = tag === "match" || tag === "exclude-match"; let content = content0; if (content.charAt(0) !== "/") { - if (tag === "match") { - // @match + if (isMatchPattern) { + // @match, @exclude-match let m: RegExpExecArray | null; if (content === "*") { @@ -160,12 +162,12 @@ export const extractUrlPatterns = (lines: string[]): URLRuleEntry[] => { } } - if (tag === "match") { - // match pattern + if (isMatchPattern) { + // match pattern;解析失败直接丢弃,不像 @exclude 那样退化成 glob/正则 const mch = checkUrlMatch(content); if (!mch) continue; rules.push({ - ruleType: RuleType.MATCH_INCLUDE, + ruleType: tag === "match" ? RuleType.MATCH_INCLUDE : RuleType.MATCH_EXCLUDE, ruleContent: mch, ruleTag: tag, patternString: content,