Files
MoonTVPlus/src/lib/anime-keyword-expr.ts
T
mtvpls 333c482e19 增强追番订阅:关键词表达式、字幕组快捷、右键添加与缺集补搜
- 过滤/排除支持 & | (),兼容旧逗号语义
- 字幕组快捷单选填入;同名订阅拒绝重复
- 单集只下一次、缺集按「番名+补零集数」重搜
- VideoCard 管理员可添加追番;PlayRecord 增加 is_anime 及迁移
2026-07-26 14:48:58 +08:00

314 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 追番订阅关键词表达式(纯函数,可被客户端安全引用)
*
* 语法(优先级:() > & > |):
* expr := or_expr
* or_expr := and_expr ( '|' and_expr )*
* and_expr := primary ( '&' primary )*
* primary := '(' expr ')' | keyword
*
* 兼容旧数据:字符串中不含 & | ( ) 时
* - mode 'and'filter):逗号 = AND
* - mode 'or'exclude):逗号 = OR
*
* 全角 &|() 会归一化为半角。
*/
export type KeywordExprMode = 'and' | 'or';
/** 判断 CMS / 分类文案是否为动漫(客户端/服务端均可) */
export function isAnimeCategoryText(
...parts: Array<string | undefined | null>
): boolean {
const text = parts.filter(Boolean).join(' ');
if (!text) return false;
return /动画|動漫|动漫|anime|アニメ/i.test(text);
}
type ExprNode =
| { type: 'and'; children: ExprNode[] }
| { type: 'or'; children: ExprNode[] }
| { type: 'kw'; value: string };
const OP_CHARS = new Set(['&', '|', '(', ')']);
/** 是否含有表达式运算符(半角或全角) */
export function hasExprOperators(text: string): boolean {
return /[&|()()]||/.test(text);
}
function normalizeOps(text: string): string {
return text
.replace(//g, '&')
.replace(//g, '|')
.replace(//g, '(')
.replace(//g, ')');
}
function normalizeCommas(text: string): string {
return text.replace(//g, ',');
}
/** 旧式逗号分隔关键词 */
export function parseCommaKeywords(text: string): string[] {
return normalizeCommas(text)
.split(',')
.map((k) => k.trim())
.filter(Boolean);
}
type Token =
| { kind: 'op'; value: '&' | '|' | '(' | ')' }
| { kind: 'kw'; value: string };
function tokenize(input: string): Token[] {
const s = normalizeOps(input);
const tokens: Token[] = [];
let i = 0;
while (i < s.length) {
const ch = s[i];
if (/\s/.test(ch)) {
i += 1;
continue;
}
if (ch === '&' || ch === '|' || ch === '(' || ch === ')') {
tokens.push({ kind: 'op', value: ch });
i += 1;
continue;
}
// 关键词可含空格,直到运算符为止
let j = i;
while (j < s.length && !OP_CHARS.has(s[j])) {
j += 1;
}
const raw = s.slice(i, j).trim();
if (raw) {
tokens.push({ kind: 'kw', value: raw });
}
i = j;
}
return tokens;
}
class ParseError extends Error {
constructor(message: string) {
super(message);
this.name = 'KeywordExprParseError';
}
}
function parseTokens(tokens: Token[]): ExprNode {
let pos = 0;
const peek = () => tokens[pos];
const consume = () => {
const t = tokens[pos];
pos += 1;
return t;
};
function parseOr(): ExprNode {
const parts: ExprNode[] = [parseAnd()];
while (peek()?.kind === 'op' && peek().value === '|') {
consume();
parts.push(parseAnd());
}
if (parts.length === 1) return parts[0];
return { type: 'or', children: parts };
}
function parseAnd(): ExprNode {
const parts: ExprNode[] = [parsePrimary()];
while (peek()?.kind === 'op' && peek().value === '&') {
consume();
parts.push(parsePrimary());
}
if (parts.length === 1) return parts[0];
return { type: 'and', children: parts };
}
function parsePrimary(): ExprNode {
const t = peek();
if (!t) {
throw new ParseError('表达式不完整');
}
if (t.kind === 'op' && t.value === '(') {
consume();
const inner = parseOr();
const close = consume();
if (!close || close.kind !== 'op' || close.value !== ')') {
throw new ParseError('缺少右括号 )');
}
return inner;
}
if (t.kind === 'kw') {
consume();
return { type: 'kw', value: t.value };
}
throw new ParseError(`意外的符号: ${t.value}`);
}
if (tokens.length === 0) {
throw new ParseError('空表达式');
}
const root = parseOr();
if (pos < tokens.length) {
throw new ParseError('表达式存在多余内容');
}
return root;
}
function evalNode(title: string, node: ExprNode): boolean {
switch (node.type) {
case 'kw':
return title.includes(node.value);
case 'and':
return node.children.every((c) => evalNode(title, c));
case 'or':
return node.children.some((c) => evalNode(title, c));
default:
return false;
}
}
/**
* 解析并匹配关键词表达式。
* @param mode 无运算符时的逗号语义:filter 用 andexclude 用 or
* @returns 匹配结果;非法表达式时 match=false 且带 error
*/
export function matchKeywordExpr(
title: string,
exprText: string | undefined | null,
mode: KeywordExprMode
): { match: boolean; error?: string } {
if (exprText == null || !String(exprText).trim()) {
// filter 空 = 全过;exclude 空 = 不排除
return { match: mode === 'and' };
}
const text = String(exprText).trim();
try {
if (!hasExprOperators(text)) {
const keywords = parseCommaKeywords(text);
if (keywords.length === 0) {
return { match: mode === 'and' };
}
if (mode === 'and') {
return { match: keywords.every((k) => title.includes(k)) };
}
return { match: keywords.some((k) => title.includes(k)) };
}
const tokens = tokenize(text);
if (tokens.length === 0) {
return { match: mode === 'and' };
}
const ast = parseTokens(tokens);
return { match: evalNode(title, ast) };
} catch (e) {
const message = e instanceof Error ? e.message : '表达式解析失败';
return { match: false, error: message };
}
}
/** 包含关键词(filter):空=通过;非法表达式=不通过 */
export function matchesFilter(title: string, filterText: string): boolean {
if (!filterText) return true;
const result = matchKeywordExpr(title, filterText, 'and');
if (result.error) {
console.warn(`[AnimeSubscription] 过滤表达式无效: ${result.error} | ${filterText}`);
}
return result.match;
}
/** 排除关键词(exclude):空=不排除;命中=true 表示应跳过 */
export function matchesExclude(title: string, excludeText?: string): boolean {
if (!excludeText) return false;
const result = matchKeywordExpr(title, excludeText, 'or');
if (result.error) {
console.warn(`[AnimeSubscription] 排除表达式无效: ${result.error} | ${excludeText}`);
// 非法排除式:保守起见不排除(避免误杀全部),但已打日志
return false;
}
return result.match;
}
/** 校验表达式是否可解析(供 API/UI) */
export function validateKeywordExpr(
exprText: string | undefined | null,
mode: KeywordExprMode = 'and'
): { ok: boolean; error?: string } {
if (exprText == null || !String(exprText).trim()) {
return { ok: true };
}
const text = String(exprText).trim();
if (!hasExprOperators(text)) {
return { ok: true };
}
try {
const tokens = tokenize(text);
if (tokens.length === 0) return { ok: true };
parseTokens(tokens);
return { ok: true };
} catch (e) {
return {
ok: false,
error: e instanceof Error ? e.message : '表达式解析失败',
};
}
}
// ---------------------------------------------------------------------------
// 单集只下一次:同集择优
// ---------------------------------------------------------------------------
/** 网页友好打分:内嵌 > 内封,简日双语 > 简中 */
export function scoreTorrentTitle(title: string): number {
let score = 0;
if (/简日双语|简日雙語/.test(title)) score += 5;
else if (/简日内嵌|簡日內嵌/.test(title)) score += 4;
else if (/简中|简体|CHS|GB/i.test(title)) score += 3;
else if (/简日/.test(title)) score += 2;
if (/内嵌|內嵌/.test(title)) score += 4;
else if (/内封|內封/.test(title)) score += 1;
if (/1080/.test(title)) score += 2;
else if (/720/.test(title)) score -= 1;
// MP4 略优于默认(网页更友好)
if (/MP4|mp4/.test(title)) score += 1;
return score;
}
export interface EpisodeCandidate {
episode: number;
title: string;
[key: string]: unknown;
}
/**
* 每个集数只保留打分最高的一条(同分保留先出现的)
*/
export function pickOnePerEpisode<T extends EpisodeCandidate>(items: T[]): T[] {
const best = new Map<number, T>();
for (const item of items) {
const prev = best.get(item.episode);
if (!prev) {
best.set(item.episode, item);
continue;
}
const sNew = scoreTorrentTitle(item.title);
const sOld = scoreTorrentTitle(prev.title);
if (sNew > sOld) {
best.set(item.episode, item);
}
}
return Array.from(best.values()).sort((a, b) => a.episode - b.episode);
}