feat(parser): site category prefix, RSS embedded description and subtitle titles (ID-2)

Strip [电视剧]/[动漫]/[音乐]… prefixes into parsed.category instead of titleCn,
split RSS-embedded site descriptions into the subtitle, take the first Chinese
name of the subtitle as titleCn and the rest as altTitles, and stop reading
'Travis - 12 Memories' as episode 12. PARSER_VERSION 2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 17:41:22 +08:00
1 parent c3aaf901ad
commit 51e0032d14
6 files changed
+195 -18

No files matched your search

+54 -13
View File
@@ -2,6 +2,7 @@ import { CN_NUM, cnToInt } from "./cn-number.ts";
import { evalEpisodeOffset } from "./offset.ts"; import { evalEpisodeOffset } from "./offset.ts";
import { PLATFORMS } from "./platforms.ts"; import { PLATFORMS } from "./platforms.ts";
import { buildGroupRegex, defaultGroupRegex } from "./release-groups.ts"; import { buildGroupRegex, defaultGroupRegex } from "./release-groups.ts";
import { splitEmbeddedDescription, subtitleTitles } from "./subtitle-titles.ts";
import { import {
type Confidence, type Confidence,
type ContentType, type ContentType,
@@ -753,6 +754,9 @@ const ANIME_TAG_BARE = new RegExp(`★\\s*${ANIME_TAG_CORE}\\s*★?|(?<![\\p{Scr
/** 开头方括号是分类词(动画/电影/纪录片/Animation/Movie…),MP 同样整块去掉 */ /** 开头方括号是分类词(动画/电影/纪录片/Animation/Movie…),MP 同样整块去掉 */
const CATEGORY_LEAD = const CATEGORY_LEAD =
/^\s*[[【](?:[^\]】]*?(?:Animations?|Movies?|Documentar(?:y|ies)|Anime)\b[^\]】]*|[动動漫画畫纪紀录錄片电電影视視连連续續剧劇集日美韩韓中港台海外亚亞洲华華语語大陆陸综綜艺藝原盘盤高清]{2,})[\]】]\s*(?=[[【])/iu; /^\s*[[【](?:[^\]】]*?(?:Animations?|Movies?|Documentar(?:y|ies)|Anime)\b[^\]】]*|[动動漫画畫纪紀录錄片电電影视視连連续續剧劇集日美韩韓中港台海外亚亞洲华華语語大陆陸综綜艺藝原盘盤高清]{2,})[\]】]\s*(?=[[【])/iu;
/** 开头方括号只有一个分类词(RSS 标题 `[动漫]Name S01 …`、`[电视剧]…`),后面直接接片名 */
const CATEGORY_WORD_LEAD =
/^\s*[[【](动漫|動漫|动画|動畫|电影|電影|电视剧|電視劇|剧集|劇集|综艺|綜藝|纪录片|紀錄片|短剧|短劇|体育|體育|音乐|音樂|MV|游戏|遊戲|软件|軟體|学习|學習|其他|其它)[\]】]\s*/u;
const CN_ALIAS_SLASH = const CN_ALIAS_SLASH =
/(?<=^|[\s\]】])[^\s/[\]【】]*\p{Script=Han}[^\s/[\]【】]*\s*\/\s*(?=[^\s/[\]【】]*\p{Script=Han})/u; /(?<=^|[\s\]】])[^\s/[\]【】]*\p{Script=Han}[^\s/[\]【】]*\s*\/\s*(?=[^\s/[\]【】]*\p{Script=Han})/u;
@@ -814,6 +818,14 @@ function parseExplicitBlock(body: string): { ids: ExplicitId[]; meta: NonNullabl
return hit ? { ids, meta } : undefined; return hit ? { ids, meta } : undefined;
} }
/** 副标题里的中文名能否当主中文名:去掉季号后不含空白分隔的多段汉字、不是句子、长度合理;返回去掉季号的名字 */
function cnTitleFromSubtitle(t: string): string | undefined {
const core = t.replace(new RegExp(`\\s*第\\s*${CN_NUM}\\s*[季部]\\s*$`, "u"), "").trim();
if (!core || [...core].length > 24) return undefined;
if (/\p{Script=Han}\s+\p{Script=Han}/u.test(core)) return undefined;
return /[。??]/u.test(core) ? undefined : core;
}
/** 常见拼音音节(不含声调);用于判断英文名是否其实是拼音 */ /** 常见拼音音节(不含声调);用于判断英文名是否其实是拼音 */
const PINYIN_SYLLABLE = const PINYIN_SYLLABLE =
/^(?:[bpmfdtnlgkhjqxrzcsyw]|zh|ch|sh)?(?:iang|iong|uang|ueng|ang|eng|ing|ong|ian|iao|uai|uan|ai|ei|ao|ou|an|en|in|un|ia|ie|iu|ua|uo|ui|ue|er|a|o|e|i|u|v)$/i; /^(?:[bpmfdtnlgkhjqxrzcsyw]|zh|ch|sh)?(?:iang|iong|uang|ueng|ang|eng|ing|ong|ian|iao|uai|uan|ai|ei|ao|ou|an|en|in|un|ia|ie|iu|ua|uo|ui|ue|er|a|o|e|i|u|v)$/i;
@@ -839,6 +851,17 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
s = s.slice(0, -(ext.length + 1)); s = s.slice(0, -(ext.length + 1));
} else if (opts.isFile) d.isFile = true; } else if (opts.isFile) d.isFile = true;
// 1b. RSS 标题末尾内嵌的站点描述 `…-GRP[中文名 / … | 第N集 | …][体积]`:拆出来当副标题用
let subtitle = opts.subtitle;
if (!kind) {
const emb = splitEmbeddedDescription(s);
if (emb.description) {
s = emb.title;
subtitle = subtitle ? `${emb.description} | ${subtitle}` : emb.description;
ev.push("subtitle:embedded");
}
}
// 2. 显式身份标签:先取 `{[k=v;…]}` 块(季/集/类型一并给出),再取单个 `[tmdbid=…]` // 2. 显式身份标签:先取 `{[k=v;…]}` 块(季/集/类型一并给出),再取单个 `[tmdbid=…]`
s = s.replace(/\{\[([^\]{}]*)\]\}/g, (all, body: string) => { s = s.replace(/\{\[([^\]{}]*)\]\}/g, (all, body: string) => {
const block = parseExplicitBlock(body); const block = parseExplicitBlock(body);
@@ -860,7 +883,11 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
s = s.replace(BRACKET_DOT_TITLE, " $1 "); s = s.replace(BRACKET_DOT_TITLE, " $1 ");
// 2c. 动漫前缀标签(★7月新番★、[国漫]、【22年日剧】、[2022年7月番])与开头的分类方括号([Animations(动画片)]) // 2c. 动漫前缀标签(★7月新番★、[国漫]、【22年日剧】、[2022年7月番])与开头的分类方括号([Animations(动画片)])
s = s.replace(ANIME_TAG_BRACKET, " ").replace(ANIME_TAG_BARE, " "); s = s.replace(ANIME_TAG_BRACKET, " ").replace(ANIME_TAG_BARE, " ");
s = s.replace(CATEGORY_LEAD, ""); s = s.replace(CATEGORY_LEAD, "").replace(CATEGORY_WORD_LEAD, (_all, c: string) => {
d.category = c;
ev.push("category:lead");
return "";
});
// 2d. `中文名A / 中文名B`:同一作品的两个中文译名只留后一个(与 MP 取法一致),避免拼成一个名字 // 2d. `中文名A / 中文名B`:同一作品的两个中文译名只留后一个(与 MP 取法一致),避免拼成一个名字
s = s.replace(CN_ALIAS_SLASH, ""); s = s.replace(CN_ALIAS_SLASH, "");
@@ -955,13 +982,17 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
ev.push("pack:fin"); ev.push("pack:fin");
return MARK_HARD; return MARK_HARD;
}); });
s = s.replace(/\s-\s(\d{1,3})(?:v\d)?(?=\s|\[|【|$)/u, (_all, n: string) => { // `Travis - 12 Memories`:数字后紧跟一个普通单词时是片名的一部分,不是集号
if (!d.episodes) { s = s.replace(
d.episodes = { start: Number(n), end: Number(n) }; /\s-\s(\d{1,3})(?:v\d)?(?=\s|\[|【|$)(?!\s+(?!(?:END|Fin|Final|RAW)\b)[A-Za-z][a-z]+\b)/u,
ev.push("episode:anime_dash"); (_all, n: string) => {
} if (!d.episodes) {
return MARK_HARD; d.episodes = { start: Number(n), end: Number(n) };
}); ev.push("episode:anime_dash");
}
return MARK_HARD;
},
);
// `[Group][Name][05]`、`Name [01][1080p]`:名称后的方括号纯数字是集号 // `[Group][Name][05]`、`Name [01][1080p]`:名称后的方括号纯数字是集号
s = s.replace(/(?<=\S\s*)[[【](\d{1,3})(?:v\d)?[\]】]/u, (all, n: string) => { s = s.replace(/(?<=\S\s*)[[【](\d{1,3})(?:v\d)?[\]】]/u, (all, n: string) => {
if (d.episodes) return all; if (d.episodes) return all;
@@ -991,26 +1022,36 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
} }
// 13. 副标题补缺 // 13. 副标题补缺
if (opts.subtitle) { if (subtitle) {
// 英文名其实是拼音(Mai Xiang ↔ 麦香):中文名取副标题第一个词 // 英文名其实是拼音(Mai Xiang ↔ 麦香):中文名取副标题第一个词
if (!d.titleCn && d.titleEn) { if (!d.titleCn && d.titleEn) {
const words = d.titleEn.split(/\s+/); const words = d.titleEn.split(/\s+/);
const first = /^\s*(\p{Script=Han}{2,12})(?=[\s||/,,]|$)/u.exec(opts.subtitle)?.[1]; const first = /^\s*(\p{Script=Han}{2,12})(?=[\s||/,,]|$)/u.exec(subtitle)?.[1];
if (first && words.length === [...first].length && words.every((w) => PINYIN_SYLLABLE.test(w))) { if (first && words.length === [...first].length && words.every((w) => PINYIN_SYLLABLE.test(w))) {
d.titleCn = first; d.titleCn = first;
ev.push("subtitle:title_cn_pinyin"); ev.push("subtitle:title_cn_pinyin");
} }
} }
const sub = scanCnMarkers(opts.subtitle); // 副标题第一段的片名(ID-2):主标题没有中文名时取第一个中文名;其余作为别名参与匹配
const names = subtitleTitles(subtitle);
const first = names.find((n) => /\p{Script=Han}/u.test(n));
const cn = first ? cnTitleFromSubtitle(first) : undefined;
if (!d.titleCn && cn) {
d.titleCn = cn;
ev.push("subtitle:title_cn");
}
const alts = names.filter((n) => n !== d.titleCn && n !== d.titleEn).slice(0, 6);
if (alts.length) d.altTitles = alts;
const sub = scanCnMarkers(subtitle);
// 副标题里的拉丁字幕标记 CHS/CHT/GB/BIG5(`60 GB` 这类体积不算) // 副标题里的拉丁字幕标记 CHS/CHT/GB/BIG5(`60 GB` 这类体积不算)
for (const m of opts.subtitle.matchAll(/(?<![A-Za-z0-9])(?<!\d\s?)(CHS|CHT|GB|BIG5)(?![A-Za-z0-9])/gi)) { for (const m of subtitle.matchAll(/(?<![A-Za-z0-9])(?<!\d\s?)(CHS|CHT|GB|BIG5)(?![A-Za-z0-9])/gi)) {
const lang = /^(CHS|GB)$/i.test(m[1]!) ? "zh-Hans" : "zh-Hant"; const lang = /^(CHS|GB)$/i.test(m[1]!) ? "zh-Hans" : "zh-Hant";
sub.subtitles = [...new Set([...(sub.subtitles ?? []), lang])]; sub.subtitles = [...new Set([...(sub.subtitles ?? []), lang])];
if (!sub.evidence.includes("subtitle:latin")) sub.evidence.push("subtitle:latin"); if (!sub.evidence.includes("subtitle:latin")) sub.evidence.push("subtitle:latin");
} }
applyCnMarkers(d, sub, "subtitle:"); applyCnMarkers(d, sub, "subtitle:");
if (!d.airDate) { if (!d.airDate) {
const sad = extractAirDate(opts.subtitle); const sad = extractAirDate(subtitle);
if (sad.airDate) { if (sad.airDate) {
d.airDate = sad.airDate; d.airDate = sad.airDate;
ev.push(`subtitle:${sad.ev}`); ev.push(`subtitle:${sad.ev}`);
@@ -0,0 +1,71 @@
/**
* 副标题 / RSS 内嵌描述里的片名(ID-2)。
*
* 憨憨等站点的主标题只有英文名(`Martial Master S01 2020 … -HHWEB`),中文名在副标题第一段
* (`武神主宰 / 武神主宰 第一季 / The God of War Dominates | 第685集 | 1080p | 类型: …`);RSS 标题则把这段描述
* 用方括号拼在发布组后面(`…-HHWEB[武神主宰 / … | 第685集 | …][180.86 MB]`)。
* MP 的 MetaInfo 只从副标题取季集,不取片名;这里把第一段里的片名收进解析结果参与匹配(对照 MP 属于增强)。
*/
/** 末尾体积方括号 `[180.86 MB]` */
const SIZE_BRACKET = /\s*[[【]\s*\d+(?:\.\d+)?\s*(?:[KMGT]i?B)\s*[\]】]\s*$/iu;
/**
* 把 RSS 标题末尾的内嵌描述方括号拆出来:`Title … -GRP[描述 | …][体积]` → { title, description }。
* 只认含中文且含 `|` 或 ` / ` 分隔的方括号(站点描述的形态),`[新]` 这类短标签不拆。
*/
export function splitEmbeddedDescription(input: string): { title: string; description?: string } {
let s = input.replace(SIZE_BRACKET, "");
if (!/[\]】]\s*$/u.test(s)) return { title: input };
s = s.trimEnd();
// 从末尾按深度找配对的左括号(描述里可能嵌套 `[内嵌中字]`)
let depth = 0;
let start = -1;
for (let i = s.length - 1; i >= 0; i--) {
const c = s[i]!;
if (c === "]" || c === "】") depth++;
else if (c === "[" || c === "【") {
depth--;
if (depth === 0) {
start = i;
break;
}
}
}
if (start <= 0) return { title: input };
const content = s.slice(start + 1, -1).trim();
const head = s.slice(0, start).trimEnd();
if (!/\p{Script=Han}/u.test(content) || !/\||\s\/\s/u.test(content)) return { title: input };
// 前面必须像一个发布名(含拉丁字母)
if (!/[A-Za-z]{2,}/.test(head)) return { title: input };
return { title: head, description: content };
}
/** 名称里出现这些词就不是片名(字幕/音轨/版本/简介字段) */
const NOT_A_TITLE =
/中字|字幕|[国國粤粵]语|[国國]配|双语|雙語|多语|音轨|音軌|配音|杜比|原盘|原盤|特效|章节|章節|类型|類型|导演|導演|主演|简繁|簡繁|简体|繁体|繁體|内封|内嵌|內嵌|高码|作品|收藏|合集|更新|完结|完結|[全共]\s*\d+\s*[集期]|第\s*\d+\s*[集期]|\d+\s*集|4K|HDR|\d{3,4}[pP]|DTS|TrueHD|Atmos|FLAC|\bAAC\b|DDP|\d\.\d|\bTop\b|\*|#/u;
const REGION_NOTE = /\s*[((]\s*(?:港|台|澳|港台|大陆|陆|新|马)\s*[))]\s*/gu;
const BRACKETED = /[[【][^\]】]*[\]】]/gu;
const VERSION_SUFFIX = /\s*\S*版\s*$/u;
/**
* 副标题第一段(第一个 `|` 之前)里的片名,按 `/` 拆分,顺序保留;去掉地区注记与方括号标签,
* 丢弃字幕/音轨/简介字段。只返回看起来像片名的项(含字母或汉字,长度合理)。
*/
export function subtitleTitles(subtitle: string | undefined): string[] {
if (!subtitle) return [];
const first = subtitle.split(/\s*[||]\s*/u)[0] ?? "";
const out: string[] = [];
for (const raw of first.replace(BRACKETED, " ").split(/\s*[//]\s*/u)) {
let t = raw.replace(REGION_NOTE, " ").replace(/\s+/g, " ").trim();
if (!t) continue;
if (NOT_A_TITLE.test(t)) continue;
// `星际穿越 IMAX版`:版本后缀不属于片名
if (/\p{Script=Han}/u.test(t) && /\s\S*版$/u.test(t)) t = t.replace(VERSION_SUFFIX, "").trim();
if (!/[\p{L}\p{N}]/u.test(t) || [...t].length > 40) continue;
// 非拉丁/汉字文字(天城文等)不参与匹配
if (!/^[\p{Script=Han}\p{Script=Latin}\p{N}\p{P}\p{S}\p{Zs}ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]+$/u.test(t)) continue;
if (!out.includes(t)) out.push(t);
}
return out;
}
+6 -2
View File
@@ -1,5 +1,5 @@
/** 解析器版本:规则变化时递增,缓存/识别结果按此失效 */ /** 解析器版本:规则变化时递增,缓存/识别结果按此失效 */
export const PARSER_VERSION = 1; export const PARSER_VERSION = 2;
export type Resolution = "4320p" | "2160p" | "1440p" | "1080p" | "1080i" | "720p" | "576p" | "480p"; export type Resolution = "4320p" | "2160p" | "1440p" | "1080p" | "1080i" | "720p" | "576p" | "480p";
@@ -75,6 +75,10 @@ export interface ParsedRelease {
title?: string; title?: string;
titleCn?: string; titleCn?: string;
titleEn?: string; titleEn?: string;
/** 标题开头的站点分类方括号(`[电视剧]`、`[音乐]`),不属于片名 */
category?: string;
/** 副标题 / RSS 内嵌描述第一段里的其他片名(译名、别名),参与身份匹配,不用于展示 */
altTitles?: string[];
year?: number; year?: number;
season?: number; season?: number;
seasonEnd?: number; seasonEnd?: number;
@@ -125,7 +129,7 @@ export interface ParsedRelease {
} }
export interface ParseOptions { export interface ParseOptions {
/** 站点副标题,只补主标题缺失的季集信息 */ /** 站点副标题:补主标题缺失的季集信息;第一段里的片名补中文名与别名(altTitles) */
subtitle?: string; subtitle?: string;
/** 附加发布组(设置表扩展) */ /** 附加发布组(设置表扩展) */
releaseGroups?: string[]; releaseGroups?: string[];
@@ -1467,6 +1467,18 @@
"mp": null, "mp": null,
"ours": "swtyblz", "ours": "swtyblz",
"note": "未知发布组兜底(末段 -GROUP / 开头方括号),MP 组表未命中为 None" "note": "未知发布组兜底(末段 -GROUP / 开头方括号),MP 组表未命中为 None"
},
"name": {
"kind": "intentional",
"mp": "thematrix",
"ours": "黑客帝国",
"note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md"
},
"cn_name": {
"kind": "intentional",
"mp": null,
"ours": "黑客帝国",
"note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md"
} }
}, },
"Z-17": { "Z-17": {
@@ -1554,6 +1566,48 @@
"ours": "douban:35593344", "ours": "douban:35593344",
"note": "MP 不认 {doubanid=..} 花括号写法,我们认" "note": "MP 不认 {doubanid=..} 花括号写法,我们认"
} }
},
"MP-61": {
"name": {
"kind": "intentional",
"mp": "iwokeupavampire",
"ours": "醒来变成吸血鬼",
"note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md"
},
"cn_name": {
"kind": "intentional",
"mp": null,
"ours": "醒来变成吸血鬼",
"note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md"
}
},
"MP-62": {
"name": {
"kind": "intentional",
"mp": "shadowsofthevoid",
"ours": "虚无边境",
"note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md"
},
"cn_name": {
"kind": "intentional",
"mp": null,
"ours": "虚无边境",
"note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md"
}
},
"Z-15": {
"name": {
"kind": "intentional",
"mp": "duneparttwo",
"ours": "沙丘2",
"note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md"
},
"cn_name": {
"kind": "intentional",
"mp": null,
"ours": "沙丘2",
"note": "ID-2:副标题第一段的中文片名补作中文名(MP 副标题只取季集),见 docs/porting/moviepilot-identity-match.md"
}
} }
} }
} }
+8 -2
View File
@@ -566,7 +566,13 @@ const MP_PARITY: Sample[] = [
subtitle: "麦香 | 全36集 | 4K", subtitle: "麦香 | 全36集 | 4K",
expect: { titleCn: "麦香", title: "麦香" }, expect: { titleCn: "麦香", title: "麦香" },
}, },
{ id: "M4b", input: "Blade Runner 2017 1080p WEB-DL", subtitle: "银翼杀手 | 科幻", expect: { titleCn: undefined } }, // ID-2:副标题第一段的中文片名补作中文名(M4b 原先断言不取)
{
id: "M4b",
input: "Blade Runner 2017 1080p WEB-DL",
subtitle: "银翼杀手 | 科幻",
expect: { titleCn: "银翼杀手", titleEn: "Blade Runner" },
},
{ id: "M5", input: "Thor Love and Thunder (2022) [1080p] [WEBRip] [5.1]", expect: { audio: ["5.1"] } }, { id: "M5", input: "Thor Love and Thunder (2022) [1080p] [WEBRip] [5.1]", expect: { audio: ["5.1"] } },
{ {
id: "M6", id: "M6",
@@ -629,7 +635,7 @@ describe("parseRelease 样本表", () => {
it(`${s.id} ${s.input}${s.deviation ? ` [deviation: ${s.deviation}]` : ""}`, () => { it(`${s.id} ${s.input}${s.deviation ? ` [deviation: ${s.deviation}]` : ""}`, () => {
const r = parseRelease(s.input, s.subtitle ? { subtitle: s.subtitle } : {}); const r = parseRelease(s.input, s.subtitle ? { subtitle: s.subtitle } : {});
check(r, s.expect); check(r, s.expect);
expect(r.parserVersion).toBe(1); expect(r.parserVersion).toBe(2);
expect(r.raw).toBe(s.input); expect(r.raw).toBe(s.input);
}); });
} }
+2 -1
View File
@@ -3,6 +3,7 @@ import {
DEFAULT_TEMPLATES, DEFAULT_TEMPLATES,
evaluateCandidate, evaluateCandidate,
type IdentityCandidate, type IdentityCandidate,
PARSER_VERSION,
parseRelease, parseRelease,
planOrganize, planOrganize,
resolveIdentity, resolveIdentity,
@@ -237,7 +238,7 @@ describe("SiteRelease ↔ source_release", () => {
expect(C.SourceReleaseCreate.safeParse(create).success).toBe(true); expect(C.SourceReleaseCreate.safeParse(create).success).toBe(true);
expect(create).toMatchObject({ siteId: U(40), infohashV1: "a".repeat(40), infohashV2: null, categoryRaw: "401" }); expect(create).toMatchObject({ siteId: U(40), infohashV1: "a".repeat(40), infohashV2: null, categoryRaw: "401" });
expect(JSON.stringify(create)).not.toContain("download.php"); expect(JSON.stringify(create)).not.toContain("download.php");
expect(create.parserVersion).toBe("1"); expect(create.parserVersion).toBe(String(PARSER_VERSION));
}); });
it("source_release 行还原为 SiteReleaseLike(下载入口除外)", () => { it("source_release 行还原为 SiteReleaseLike(下载入口除外)", () => {