feat(identity): weighted evidence table, sequel numbers, generic-name guard (ID-2)
Configurable DEFAULT_MATCH_WEIGHTS; Chinese/English names both count and corroborate; Chapter/Part/第N章 sequel conflicts reject; missing season penalised; category words and single-word aliases colliding in the pool cannot confirm alone; alias-only hits need year or second-script support; same-level ties broken by exact year then matched name scripts. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
51e0032d14
commit
f6ec993b5f
5 files changed
+527
-34
No files matched your search
@@ -1,8 +1,11 @@
|
||||
export {
|
||||
DEFAULT_MATCH_WEIGHTS,
|
||||
type EvidenceLevel,
|
||||
type IdentityCandidate,
|
||||
type IdentityResolution,
|
||||
type MatchOptions,
|
||||
type MatchWeights,
|
||||
poolGenericNames,
|
||||
queryTitles,
|
||||
type RankedCandidate,
|
||||
rankCandidates,
|
||||
@@ -12,5 +15,6 @@ export {
|
||||
type TypeMatch,
|
||||
type YearMatch,
|
||||
} from "./match.ts";
|
||||
export { normalizeTitle, stripSeasonSuffix } from "./normalize.ts";
|
||||
export { isGenericAliasName, normalizeTitle, stripSeasonSuffix } from "./normalize.ts";
|
||||
export { sequelNumbers } from "./sequel.ts";
|
||||
export { TRAD_TO_SIMP, toSimplified } from "./zh-map.ts";
|
||||
@@ -1,16 +1,21 @@
|
||||
import type { Confidence, ParsedRelease } from "../release-parser/types.ts";
|
||||
import { normalizeTitle } from "./normalize.ts";
|
||||
import { isGenericAliasName, normalizeTitle } from "./normalize.ts";
|
||||
import { sequelNumbers } from "./sequel.ts";
|
||||
|
||||
export interface IdentityCandidate {
|
||||
/** tmdb / local(本地别名召回)/ 其他提供方 */
|
||||
provider: string;
|
||||
entityType: "movie" | "tv";
|
||||
externalId: string;
|
||||
/** 主标题与原标题(以及其他语言的本地化标题);精确命中这里算 exact */
|
||||
titles: string[];
|
||||
year?: number;
|
||||
/** 别名 / 译名(TMDB alternative_titles + translations、本地登记的别名);命中这里算 alias */
|
||||
aliases: string[];
|
||||
/** 剧集各季首播年份(发布标题里的年份可能是季年份) */
|
||||
seasonYears?: number[];
|
||||
/** 剧集已有的季号(取了详情才有;用于季号区分) */
|
||||
seasonNumbers?: number[];
|
||||
/** local 候选:本地实体 UUID 及其已登记的外部映射 */
|
||||
localEntityId?: string;
|
||||
linkedExternalIds?: { provider: string; id: string }[];
|
||||
@@ -21,6 +26,39 @@ export type TitleMatch = "exact" | "alias" | "partial" | "none";
|
||||
export type YearMatch = "exact" | "season_year" | "within_tolerance" | "unknown" | "mismatch";
|
||||
export type TypeMatch = "match" | "mismatch" | "unknown";
|
||||
|
||||
/**
|
||||
* 评分权重表(ID-2,可配置):各项证据的分值相加,按阈值落到证据等级;硬规则(年份不符、续作序号冲突、
|
||||
* 标题无关)直接拒绝,部分包含 / 通用词只靠别名 / 别名命中缺少佐证 / 类型冲突封顶 needs_confirm。
|
||||
* 分值只用于定级与同等级排序,不是概率。默认值下 v0.1 的规则式判定不变(identity.test.ts),
|
||||
* 在此基础上加了第二语言名称、续作序号、季号三项。
|
||||
*/
|
||||
export interface MatchWeights {
|
||||
title: Record<TitleMatch, number>;
|
||||
year: Record<Exclude<YearMatch, "mismatch">, number>;
|
||||
/** 年份未知,但剧集季号是明写的且类型一致 */
|
||||
tvSeasonNoYear: number;
|
||||
/** 中文名与英文名(不同文字)各自命中,每多一种加一次 */
|
||||
secondName: number;
|
||||
/** 续作 / 章节 / Part 序号一致 */
|
||||
sequelMatch: number;
|
||||
/** 明写的季号在候选的季列表里 / 不在 */
|
||||
seasonMatch: number;
|
||||
seasonMissing: number;
|
||||
/** 分值 ≥ 阈值即达到该等级 */
|
||||
thresholds: { rule_confirmed: number; multi_evidence: number; needs_confirm: number };
|
||||
}
|
||||
|
||||
export const DEFAULT_MATCH_WEIGHTS: MatchWeights = {
|
||||
title: { exact: 4, alias: 3, partial: 1, none: 0 },
|
||||
year: { exact: 3, season_year: 3, within_tolerance: 2, unknown: 0 },
|
||||
tvSeasonNoYear: 2,
|
||||
secondName: 1,
|
||||
sequelMatch: 1,
|
||||
seasonMatch: 0,
|
||||
seasonMissing: -3,
|
||||
thresholds: { rule_confirmed: 7, multi_evidence: 5, needs_confirm: 1 },
|
||||
};
|
||||
|
||||
export interface RankedCandidate {
|
||||
candidate: IdentityCandidate;
|
||||
level: EvidenceLevel;
|
||||
@@ -28,11 +66,46 @@ export interface RankedCandidate {
|
||||
yearMatch: YearMatch;
|
||||
typeMatch: TypeMatch;
|
||||
evidence: string[];
|
||||
/** 排序用信号(分值不是概率,只在同等级间比较) */
|
||||
signals: {
|
||||
points: number;
|
||||
/** 命中(精确或别名)的名称文字种数:中文 / 拉丁 */
|
||||
nameLangs: number;
|
||||
sequel: "match" | "conflict" | "none";
|
||||
genericName: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MatchOptions {
|
||||
/** 年份容差,默认 ±1 */
|
||||
yearTolerance?: number;
|
||||
/** 覆盖部分权重 */
|
||||
weights?: Partial<MatchWeights>;
|
||||
/**
|
||||
* 候选池里"撞名"的通用词(归一化形态):单个拉丁词且池中 ≥ 3 个候选的名称包含它(`Archives`)。
|
||||
* rankCandidates / resolveIdentity 按池自动计算;单独调用 scoreCandidate 时可传入。
|
||||
*/
|
||||
genericNames?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** 池里撞名的阈值:单个拉丁词被 ≥ 3 个候选的名称包含 */
|
||||
const GENERIC_POOL_HITS = 3;
|
||||
|
||||
/** 按候选池找出通用词:单个拉丁词、且池中多个候选的标题/别名都包含它 */
|
||||
export function poolGenericNames(p: ParsedRelease, candidates: readonly IdentityCandidate[]): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const q of queryNames(p)) {
|
||||
if (!isSingleLatinWord(q)) continue;
|
||||
let hits = 0;
|
||||
for (const c of candidates) {
|
||||
if ([...c.titles, ...c.aliases].some((t) => normalizeTitle(t).includes(q.norm))) hits++;
|
||||
if (hits >= GENERIC_POOL_HITS) {
|
||||
out.add(q.norm);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const LEVEL_RANK: Record<EvidenceLevel, number> = {
|
||||
@@ -52,30 +125,81 @@ const YEAR_RANK: Record<YearMatch, number> = {
|
||||
|
||||
const raise = (l: EvidenceLevel): EvidenceLevel =>
|
||||
l === "needs_confirm" ? "multi_evidence" : l === "multi_evidence" ? "rule_confirmed" : l;
|
||||
const capAt = (l: EvidenceLevel, max: EvidenceLevel): EvidenceLevel => (LEVEL_RANK[l] > LEVEL_RANK[max] ? max : l);
|
||||
|
||||
export function queryTitles(p: ParsedRelease): string[] {
|
||||
const out: string[] = [];
|
||||
for (const t of [p.titleCn, p.titleEn, p.title]) {
|
||||
if (!t) continue;
|
||||
const n = normalizeTitle(t);
|
||||
if (n && !out.includes(n)) out.push(n);
|
||||
}
|
||||
function weightsOf(opts: MatchOptions): MatchWeights {
|
||||
const w = opts.weights;
|
||||
if (!w) return DEFAULT_MATCH_WEIGHTS;
|
||||
return {
|
||||
...DEFAULT_MATCH_WEIGHTS,
|
||||
...w,
|
||||
title: { ...DEFAULT_MATCH_WEIGHTS.title, ...w.title },
|
||||
year: { ...DEFAULT_MATCH_WEIGHTS.year, ...w.year },
|
||||
thresholds: { ...DEFAULT_MATCH_WEIGHTS.thresholds, ...w.thresholds },
|
||||
};
|
||||
}
|
||||
|
||||
interface QueryName {
|
||||
raw: string;
|
||||
norm: string;
|
||||
/** primary = 主标题解析出的中/英文名;alt = 副标题别名(命中只算 alias) */
|
||||
primary: boolean;
|
||||
script: "han" | "latin";
|
||||
}
|
||||
|
||||
function queryNames(p: ParsedRelease): QueryName[] {
|
||||
const out: QueryName[] = [];
|
||||
const add = (raw: string | undefined, primary: boolean) => {
|
||||
if (!raw) return;
|
||||
const norm = normalizeTitle(raw);
|
||||
// 通用词守卫:分类词 / 单字 / 过短 / 停用词不作为名称参与比对
|
||||
if (!norm || isGenericAliasName(norm) || out.some((q) => q.norm === norm)) return;
|
||||
out.push({ raw, norm, primary, script: /\p{Script=Han}/u.test(raw) ? "han" : "latin" });
|
||||
};
|
||||
add(p.titleCn, true);
|
||||
add(p.titleEn, true);
|
||||
add(p.title, true);
|
||||
for (const a of p.altTitles ?? []) add(a, false);
|
||||
return out;
|
||||
}
|
||||
|
||||
function titleMatch(queries: string[], c: IdentityCandidate): TitleMatch {
|
||||
/** 召回/比对用的归一化名称:中文名、英文名、展示名,再加副标题里的别名(已排除通用词) */
|
||||
export function queryTitles(p: ParsedRelease): string[] {
|
||||
return queryNames(p).map((q) => q.norm);
|
||||
}
|
||||
|
||||
/** 单个拉丁词(去掉冠词后);再加上池里撞名(poolGenericNames)才算通用词 */
|
||||
const isSingleLatinWord = (q: QueryName) =>
|
||||
q.script === "latin" &&
|
||||
q.raw
|
||||
.trim()
|
||||
.replace(/^(?:the|a|an)\s+/i, "")
|
||||
.split(/[\s._-]+/)
|
||||
.filter(Boolean).length === 1;
|
||||
|
||||
interface NameResult {
|
||||
tm: TitleMatch;
|
||||
matched: QueryName[];
|
||||
}
|
||||
|
||||
function titleMatch(queries: QueryName[], c: IdentityCandidate): NameResult {
|
||||
const titles = c.titles.map(normalizeTitle).filter(Boolean);
|
||||
const aliases = c.aliases.map(normalizeTitle).filter(Boolean);
|
||||
if (queries.some((q) => titles.includes(q))) return "exact";
|
||||
if (queries.some((q) => aliases.includes(q))) return "alias";
|
||||
const aliases = c.aliases.map(normalizeTitle).filter((a) => a && !isGenericAliasName(a));
|
||||
const exact = queries.filter((q) => q.primary && titles.includes(q.norm));
|
||||
const alias = queries.filter((q) => !exact.includes(q) && (titles.includes(q.norm) || aliases.includes(q.norm)));
|
||||
const matched = [...exact, ...alias];
|
||||
if (exact.length) return { tm: "exact", matched };
|
||||
if (alias.length) return { tm: "alias", matched };
|
||||
const all = [...titles, ...aliases];
|
||||
const partial = queries.some((q) =>
|
||||
all.some((t) => {
|
||||
const [short, long] = q.length <= t.length ? [q, t] : [t, q];
|
||||
return short.length >= 2 && long.includes(short);
|
||||
}),
|
||||
const partial = queries.some(
|
||||
(q) =>
|
||||
q.primary &&
|
||||
all.some((t) => {
|
||||
const [short, long] = q.norm.length <= t.length ? [q.norm, t] : [t, q.norm];
|
||||
return short.length >= 2 && long.includes(short);
|
||||
}),
|
||||
);
|
||||
return partial ? "partial" : "none";
|
||||
return { tm: partial ? "partial" : "none", matched: [] };
|
||||
}
|
||||
|
||||
function yearMatch(p: ParsedRelease, c: IdentityCandidate, tol: number): YearMatch {
|
||||
@@ -93,13 +217,31 @@ function typeMatch(p: ParsedRelease, c: IdentityCandidate): TypeMatch {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/** 单个候选的证据等级(纯函数,不产生概率数字) */
|
||||
function sequelOf(p: ParsedRelease, c: IdentityCandidate): "match" | "conflict" | "none" {
|
||||
const q = new Set<number>();
|
||||
for (const t of [p.titleEn, p.titleCn, ...(p.altTitles ?? [])]) for (const n of sequelNumbers(t ?? "")) q.add(n);
|
||||
if (!q.size) return "none";
|
||||
const cand = new Set<number>();
|
||||
for (const t of [...c.titles, ...c.aliases]) for (const n of sequelNumbers(t)) cand.add(n);
|
||||
if (!cand.size) return "none";
|
||||
return [...q].some((n) => cand.has(n)) ? "match" : "conflict";
|
||||
}
|
||||
|
||||
/** 单个候选的证据等级(纯函数) */
|
||||
export function scoreCandidate(p: ParsedRelease, c: IdentityCandidate, opts: MatchOptions = {}): RankedCandidate {
|
||||
const w = weightsOf(opts);
|
||||
const tol = opts.yearTolerance ?? 1;
|
||||
const tm = titleMatch(queryTitles(p), c);
|
||||
const queries = queryNames(p);
|
||||
const { tm, matched } = titleMatch(queries, c);
|
||||
const ym = yearMatch(p, c, tol);
|
||||
const ty = typeMatch(p, c);
|
||||
const sequel = sequelOf(p, c);
|
||||
const nameLangs = new Set(matched.map((q) => q.script)).size;
|
||||
const genericName =
|
||||
tm === "alias" && matched.every((q) => isSingleLatinWord(q) && (opts.genericNames?.has(q.norm) ?? false));
|
||||
const evidence: string[] = [`title:${tm}`, `year:${ym}`, `type:${ty}`];
|
||||
if (nameLangs > 1) evidence.push(`name_langs:${nameLangs}`);
|
||||
if (sequel !== "none") evidence.push(`sequel:${sequel}`);
|
||||
const explicit = p.explicitIds.some(
|
||||
(x) =>
|
||||
(x.provider === c.provider && x.id === c.externalId) ||
|
||||
@@ -108,37 +250,83 @@ export function scoreCandidate(p: ParsedRelease, c: IdentityCandidate, opts: Mat
|
||||
if (explicit) evidence.push("explicit_id");
|
||||
if (c.provider === "local") evidence.push("local_entity");
|
||||
|
||||
let level: EvidenceLevel;
|
||||
if (ym === "mismatch") level = "rejected";
|
||||
// 分值
|
||||
let points = w.title[tm] + (ym === "mismatch" ? 0 : w.year[ym]);
|
||||
const explicitSeason = p.season !== undefined && !p.seasonInferred;
|
||||
if (ym === "unknown" && ty === "match" && explicitSeason) points += w.tvSeasonNoYear;
|
||||
if (nameLangs > 1) points += w.secondName * (nameLangs - 1);
|
||||
if (sequel === "match") points += w.sequelMatch;
|
||||
if (explicitSeason && p.season! > 0 && c.entityType === "tv" && c.seasonNumbers?.length) {
|
||||
if (c.seasonNumbers.includes(p.season!)) points += w.seasonMatch;
|
||||
else {
|
||||
points += w.seasonMissing;
|
||||
evidence.push("season:missing");
|
||||
}
|
||||
}
|
||||
|
||||
let level: EvidenceLevel =
|
||||
points >= w.thresholds.rule_confirmed
|
||||
? "rule_confirmed"
|
||||
: points >= w.thresholds.multi_evidence
|
||||
? "multi_evidence"
|
||||
: points >= w.thresholds.needs_confirm
|
||||
? "needs_confirm"
|
||||
: "rejected";
|
||||
// 硬规则
|
||||
if (ym === "mismatch" || sequel === "conflict") level = "rejected";
|
||||
else if (tm === "none") level = explicit ? "needs_confirm" : "rejected";
|
||||
else if (tm === "partial") level = "needs_confirm";
|
||||
else if (ym === "exact" || ym === "season_year") level = tm === "exact" ? "rule_confirmed" : "multi_evidence";
|
||||
else if (ym === "within_tolerance") level = "multi_evidence";
|
||||
else {
|
||||
// 年份未知:剧集有季集信息且类型一致时仍算多证据,电影需确认
|
||||
level = ty === "match" && p.season !== undefined ? "multi_evidence" : "needs_confirm";
|
||||
if (level === "rejected") level = "needs_confirm";
|
||||
if (tm === "partial") level = capAt(level, "needs_confirm");
|
||||
if (genericName) {
|
||||
level = capAt(level, "needs_confirm");
|
||||
evidence.push("generic_name");
|
||||
}
|
||||
// 只靠别名命中:还要年份相容或另一种文字的名字也命中,否则只作提示
|
||||
if (tm === "alias" && nameLangs < 2 && ym === "unknown") {
|
||||
level = capAt(level, "needs_confirm");
|
||||
evidence.push("alias_uncorroborated");
|
||||
}
|
||||
}
|
||||
if (level !== "rejected" && ty === "mismatch") {
|
||||
level = "needs_confirm";
|
||||
evidence.push("type_conflict");
|
||||
}
|
||||
if (explicit && level !== "rejected" && tm !== "none") level = raise(level);
|
||||
return { candidate: c, level, titleMatch: tm, yearMatch: ym, typeMatch: ty, evidence };
|
||||
return {
|
||||
candidate: c,
|
||||
level,
|
||||
titleMatch: tm,
|
||||
yearMatch: ym,
|
||||
typeMatch: ty,
|
||||
evidence,
|
||||
signals: { points, nameLangs, sequel, genericName },
|
||||
};
|
||||
}
|
||||
|
||||
/** 候选排序:等级 > 标题 > 年份 > 本地优先 > 原顺序 */
|
||||
/** 同等级内的取舍:年份精确优先,再按命中的名称文字种数,再按分值 */
|
||||
function tieBreak(a: RankedCandidate, b: RankedCandidate): number {
|
||||
return (
|
||||
YEAR_RANK[b.yearMatch] - YEAR_RANK[a.yearMatch] ||
|
||||
b.signals.nameLangs - a.signals.nameLangs ||
|
||||
b.signals.points - a.signals.points
|
||||
);
|
||||
}
|
||||
|
||||
/** 候选排序:等级 > 年份 > 名称文字种数 > 分值 > 标题 > 本地优先 > 原顺序 */
|
||||
export function rankCandidates(
|
||||
p: ParsedRelease,
|
||||
candidates: readonly IdentityCandidate[],
|
||||
opts: MatchOptions = {},
|
||||
): RankedCandidate[] {
|
||||
const o = opts.genericNames ? opts : { ...opts, genericNames: poolGenericNames(p, candidates) };
|
||||
return candidates
|
||||
.map((c, i) => ({ r: scoreCandidate(p, c, opts), i }))
|
||||
.map((c, i) => ({ r: scoreCandidate(p, c, o), i }))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
LEVEL_RANK[b.r.level] - LEVEL_RANK[a.r.level] ||
|
||||
tieBreak(a.r, b.r) ||
|
||||
TITLE_RANK[b.r.titleMatch] - TITLE_RANK[a.r.titleMatch] ||
|
||||
YEAR_RANK[b.r.yearMatch] - YEAR_RANK[a.r.yearMatch] ||
|
||||
Number(b.r.candidate.provider === "local") - Number(a.r.candidate.provider === "local") ||
|
||||
a.i - b.i,
|
||||
)
|
||||
@@ -173,6 +361,8 @@ export function resolveIdentity(
|
||||
const rivals = ranked.filter(
|
||||
(r) => r !== top && LEVEL_RANK[r.level] >= LEVEL_RANK[top.level] && !sameIdentity(r.candidate, top.candidate),
|
||||
);
|
||||
if (rivals.length > 0) return { status: "ambiguous", best: top, ranked, reason: "multiple_candidates_same_level" };
|
||||
return { status: "auto", best: top, ranked, reason: top.level };
|
||||
// 同等级多个候选:年份精确 / 名称文字种数 / 分值上严格占优才自动确认,否则进池
|
||||
if (rivals.some((r) => tieBreak(top, r) >= 0))
|
||||
return { status: "ambiguous", best: top, ranked, reason: "multiple_candidates_same_level" };
|
||||
return { status: "auto", best: top, ranked, reason: rivals.length ? `${top.level}:tie_break` : top.level };
|
||||
}
|
||||
@@ -35,3 +35,69 @@ export function normalizeTitle(s: string): string {
|
||||
.replace(/&/g, "and")
|
||||
.replace(/[^\p{L}\p{N}]+/gu, "");
|
||||
}
|
||||
|
||||
/** 分类词(站点分类 / RSS 标题前缀)与常见停用词:不能当片名或别名用于身份比对(ID-2) */
|
||||
const GENERIC_NAMES = new Set(
|
||||
[
|
||||
"电影",
|
||||
"电视剧",
|
||||
"剧集",
|
||||
"连续剧",
|
||||
"动漫",
|
||||
"动画",
|
||||
"番剧",
|
||||
"国漫",
|
||||
"日漫",
|
||||
"综艺",
|
||||
"纪录片",
|
||||
"记录片",
|
||||
"音乐",
|
||||
"短剧",
|
||||
"微短剧",
|
||||
"体育",
|
||||
"游戏",
|
||||
"软件",
|
||||
"学习",
|
||||
"其他",
|
||||
"其它",
|
||||
"未知",
|
||||
"新",
|
||||
"合集",
|
||||
"全集",
|
||||
"中字",
|
||||
"国语",
|
||||
"粤语",
|
||||
"高清",
|
||||
"蓝光",
|
||||
"原盘",
|
||||
"movie",
|
||||
"movies",
|
||||
"tv",
|
||||
"tvseries",
|
||||
"series",
|
||||
"anime",
|
||||
"animation",
|
||||
"documentary",
|
||||
"music",
|
||||
"variety",
|
||||
"show",
|
||||
"the",
|
||||
"season",
|
||||
"complete",
|
||||
"unknown",
|
||||
"other",
|
||||
].map((s) => s.toLowerCase()),
|
||||
);
|
||||
|
||||
/**
|
||||
* 归一化后的名称是否太"通用"不能用于身份比对:分类词、停用词、单个汉字、两个以内的拉丁字符、纯数字。
|
||||
* 参数是 normalizeTitle 之后的形态。
|
||||
*/
|
||||
export function isGenericAliasName(norm: string): boolean {
|
||||
if (!norm) return true;
|
||||
if (GENERIC_NAMES.has(norm)) return true;
|
||||
if (/^\d+$/.test(norm)) return true;
|
||||
const han = norm.match(/\p{Script=Han}/gu)?.length ?? 0;
|
||||
if (han > 0) return [...norm].length < 2;
|
||||
return norm.length <= 2;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 续作 / 章节 / Part 序号(ID-2):`Chapter III`、`Part Two`、`Vol. 2`、`第3章`、`第三章`、`Ⅲ`。
|
||||
* 只认显式关键词或罗马数字符号,不把片名末尾的普通数字当序号(`Blade Runner 2049`、`Apollo 13`)。
|
||||
* 季号不在这里(季由解析器单独给出)。
|
||||
*/
|
||||
import { cnToInt } from "../release-parser/cn-number.ts";
|
||||
|
||||
const WORD_NUM: Record<string, number> = {
|
||||
one: 1,
|
||||
two: 2,
|
||||
three: 3,
|
||||
four: 4,
|
||||
five: 5,
|
||||
six: 6,
|
||||
seven: 7,
|
||||
eight: 8,
|
||||
nine: 9,
|
||||
ten: 10,
|
||||
first: 1,
|
||||
second: 2,
|
||||
third: 3,
|
||||
fourth: 4,
|
||||
final: -1,
|
||||
};
|
||||
const ROMAN: Record<string, number> = { i: 1, ii: 2, iii: 3, iv: 4, v: 5, vi: 6, vii: 7, viii: 8, ix: 9, x: 10 };
|
||||
const ROMAN_CHAR: Record<string, number> = {
|
||||
Ⅰ: 1,
|
||||
Ⅱ: 2,
|
||||
Ⅲ: 3,
|
||||
Ⅳ: 4,
|
||||
Ⅴ: 5,
|
||||
Ⅵ: 6,
|
||||
Ⅶ: 7,
|
||||
Ⅷ: 8,
|
||||
Ⅸ: 9,
|
||||
Ⅹ: 10,
|
||||
};
|
||||
|
||||
function toNum(tok: string): number | undefined {
|
||||
const t = tok.toLowerCase();
|
||||
if (/^\d{1,2}$/.test(t)) return Number(t);
|
||||
if (t in ROMAN) return ROMAN[t];
|
||||
if (t in WORD_NUM) return WORD_NUM[t]! > 0 ? WORD_NUM[t] : undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function sequelNumbers(title: string): number[] {
|
||||
if (!title) return [];
|
||||
const out = new Set<number>();
|
||||
const s = title.replace(/[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]/g, (c) => ` ${ROMAN_CHAR[c]} `);
|
||||
for (const m of s.matchAll(/\b(?:chapter|part|vol(?:ume)?)\b\.?\s*[::-]?\s*([0-9]{1,2}|[ivx]{1,4}|[a-z]+)\b/giu)) {
|
||||
const n = toNum(m[1]!);
|
||||
if (n !== undefined) out.add(n);
|
||||
}
|
||||
for (const m of s.matchAll(/第\s*([0-9]{1,2}|[一二三四五六七八九十]{1,3})\s*章/gu)) {
|
||||
const n = /^\d+$/.test(m[1]!) ? Number(m[1]) : cnToInt(m[1]!);
|
||||
if (n !== undefined) out.add(n);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
Reference in new issue
Block a user