feat(subtitles): 字幕站(SubHD、字幕库)在线搜索与下载;rar / 7z 解压;验证码与页面解析在沙箱里

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 02:28:29 +08:00
1 parent 92d456d123
commit 59b3680f35
32 files changed
+1704 -166

No files matched your search

@@ -25,6 +25,7 @@ import {
SiteUserDataPoint,
SiteUserDataQuery,
type SourceRelease,
SUBTITLE_SITES,
} from "@umt/contracts";
import { type FeedSourceRow, sql } from "@umt/db";
import { z } from "zod";
@@ -92,19 +93,35 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
const rows = await db().selectFrom("site").select(["id", "key"]).where("deleted_at", "is", null).execute();
const byKey = new Map(rows.map((r) => [r.key, r.id]));
return {
items: listSiteDefinitions().map((d) => ({
key: d.id,
name: d.umt.kind === "nexusphp" ? (d.umt.displayName ?? d.name) : d.umt.displayName,
kind: definitionKind(d),
adapter: adapterOf(d),
baseUrl: d.domain,
shippedEnabled: d.enabled !== false,
definitionVersion: d.umt.version,
capabilities: d.umt.capabilities,
defaultRateLimit: d.umt.rateLimit,
authSupported: ("authSupported" in d.umt ? d.umt.authSupported : undefined) ?? [],
siteId: byKey.get(d.id) ?? null,
})),
items: [
...listSiteDefinitions().map((d) => ({
key: d.id,
name: d.umt.kind === "nexusphp" ? (d.umt.displayName ?? d.name) : d.umt.displayName,
kind: definitionKind(d),
adapter: adapterOf(d),
baseUrl: d.domain,
shippedEnabled: d.enabled !== false,
definitionVersion: d.umt.version,
capabilities: d.umt.capabilities,
defaultRateLimit: d.umt.rateLimit,
authSupported: ("authSupported" in d.umt ? d.umt.authSupported : undefined) ?? [],
siteId: byKey.get(d.id) ?? null,
})),
// 内置字幕站:不是资源适配器,只用于补字幕(subtitles 模块)
...SUBTITLE_SITES.map((d) => ({
key: d.key,
name: d.name,
kind: "subtitle" as const,
adapter: "subtitle" as const,
baseUrl: d.baseUrl,
shippedEnabled: true,
definitionVersion: "builtin",
capabilities: { list: false, search: false, detail: false, download: true, rss: false },
defaultRateLimit: { minIntervalMs: 2000, burst: 2 },
authSupported: [],
siteId: byKey.get(d.key) ?? null,
})),
],
};
},
);
@@ -159,6 +176,9 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
throw new ApiException("bad_request", `注册表中没有站点 ${body.key};未适配站点只能以 generic_rss 登记`);
}
if (def && definitionKind(def) !== "pt") throw new ApiException("bad_request", `${body.key} 不是 PT 站`);
} else if (kind === "subtitle") {
if (!SUBTITLE_SITES.some((d) => d.key === body.key) || body.adapter !== "subtitle")
throw new ApiException("bad_request", "字幕站要从内置字幕站里选(SubHD、字幕库)");
} else if (publicDef) {
if (body.adapter !== adapterOf(publicDef))
throw new ApiException("bad_request", `站点 ${body.key} 的适配器类型应为 ${adapterOf(publicDef)}`);
@@ -810,7 +810,20 @@ export async function checkSiteHealth(
let message: string | null = null;
let detail: SiteHealthDetail | null = null;
const def = getSiteDefinition(site.key);
if (!def) {
if (site.kind === "subtitle") {
// 字幕站:用站点出口(代理 / UA / Cloudflare 处理)打开首页,2xx 即可用
const t0 = Date.now();
try {
const res = await rt.siteFetch(site)(site.base_url, { signal: AbortSignal.timeout(20_000) });
await res.body?.cancel();
ok = res.ok;
status = res.ok ? "ok" : "unreachable";
latencyMs = Date.now() - t0;
message = res.ok ? null : `首页返回 HTTP ${res.status}`;
} catch (e) {
message = (e as Error).message.slice(0, 300);
}
} else if (!def) {
message = "站点没有适配器定义,无法检测";
status = "unsupported";
} else {
+35 -1
View File
@@ -1,6 +1,9 @@
import { Api, type SubtitleApplyRequest } from "@umt/contracts";
import { Api, type SubtitleApplyRequest, type SubtitleFetchRequest, type SubtitleSource } from "@umt/contracts";
import { ApiException } from "../../http/errors.ts";
import { route } from "../agent/http.ts";
import { sourcesIdentityModule } from "../sources-identity/index.ts";
import type { ServerModule } from "../types.ts";
import { downloadOnline, SubtitleSiteError, searchOnline, subtitleSearchQuery } from "./online/index.ts";
import { applySubtitles, parseSubtitles, subtitleTargets, subtitleWrites, undoSubtitleWrite } from "./service.ts";
/** 补字幕(docs/plan/subtitles.md):上传 / 在线下载的字幕解析、配对、写入、历史与撤销 */
@@ -9,8 +12,39 @@ export function createSubtitlesModule(): ServerModule {
name: "subtitles",
registerRoutes(app, ctx) {
const tag = { tag: "subtitles" };
const rt = () => sourcesIdentityModule.runtime(ctx);
const siteErr = async <T>(fn: () => Promise<T>): Promise<T> => {
try {
return await fn();
} catch (e) {
if (e instanceof SubtitleSiteError) throw new ApiException("upstream_unavailable", e.message);
throw e;
}
};
route(app, Api.subtitles.targets, ({ query }) => subtitleTargets(ctx, query.entityId), tag);
route(app, Api.subtitles.parse, ({ body }) => parseSubtitles(ctx, body.files, body.entityId), tag);
route(
app,
Api.subtitles.search,
({ body }) => siteErr(async () => searchOnline(ctx, rt(), await subtitleSearchQuery(ctx, body.entityId, body))),
tag,
);
route(
app,
Api.subtitles.fetch,
({ body }) =>
siteErr(async () => {
const b = body as SubtitleFetchRequest;
const f = await downloadOnline(ctx, rt(), b.siteId, b.ref);
const parsed = await parseSubtitles(ctx, [{ name: f.name, base64: f.base64 }], b.entityId);
// 在线来源:每项带上字幕站与条目地址,写入时记进历史
return {
...parsed,
items: parsed.items.map((it) => ({ ...it, source: f.siteKey as SubtitleSource, sourceRef: f.pageUrl })),
};
}),
tag,
);
route(
app,
Api.subtitles.apply,
@@ -0,0 +1,359 @@
// 在线字幕(docs/plan/subtitles.md 第 2 步):在已启用的字幕站(site.kind = subtitle)搜索、下载。
// 请求走站点出口 rt.siteFetch(站点代理、UA、Cloudflare 挑战走浏览器);页面解析与验证码识别在沙箱里(ADR-005)。
// 流程对照 MP 插件 online_subtitles/providers/{subhd,zimuku}.py,页面结构 2026-10-03 实测。
import type { SubtitleSearchResponse, SubtitleSearchResult } from "@umt/contracts";
import type { SiteRow } from "@umt/db";
import { subtitleEpisode } from "@umt/domain";
import { runSandbox } from "../../../sandbox/client.ts";
import { b64encode } from "../../../sandbox/jobs.ts";
import type { SourcesRuntime } from "../../sources-identity/runtime.ts";
import type { ServerContext } from "../../types.ts";
import type { PageEntry, PageKind, PageResult } from "./parse.ts";
const MAX_DOWNLOAD_BYTES = 30 * 1024 * 1024;
/** 站内操作失败(给用户看的原因) */
export class SubtitleSiteError extends Error {
override name = "SubtitleSiteError";
}
// ── 请求:站点出口 + 每站一个 Cookie 罐(字幕库的验证 Cookie、SubHD 的会话) ──
const jars = new Map<string, Map<string, string>>();
interface Env {
rt: SourcesRuntime;
site: SiteRow;
}
async function request(
env: Env,
url: string,
init: { referer?: string; json?: unknown; signal?: AbortSignal } = {},
): Promise<Response> {
const jar = jars.get(env.site.id) ?? new Map<string, string>();
jars.set(env.site.id, jar);
const headers: Record<string, string> = { Accept: "text/html,application/json;q=0.9,*/*;q=0.8" };
if (jar.size) headers.cookie = [...jar].map(([k, v]) => `${k}=${v}`).join("; ");
if (init.referer) headers.referer = init.referer;
if (init.json !== undefined) headers["content-type"] = "application/json";
let res: Response;
try {
res = await env.rt.siteFetch(env.site)(url, {
method: init.json === undefined ? "GET" : "POST",
headers,
...(init.json !== undefined ? { body: JSON.stringify(init.json) } : {}),
signal: init.signal ?? AbortSignal.timeout(30_000),
});
} catch (e) {
const n = (e as Error).name;
if (n === "TimeoutError" || n === "AbortError")
throw new SubtitleSiteError(`连接${env.site.name}超时,稍后再试(站点可能在限流或网络不通)`);
throw new SubtitleSiteError(`连接${env.site.name}失败:${(e as Error).message.slice(0, 200)}`);
}
for (const c of res.headers.getSetCookie()) {
const kv = c.split(";")[0] ?? "";
const i = kv.indexOf("=");
if (i > 0) jar.set(kv.slice(0, i).trim(), kv.slice(i + 1).trim());
}
return res;
}
async function getText(env: Env, url: string, referer?: string): Promise<string> {
const res = await request(env, url, referer ? { referer } : {});
if (!res.ok) throw new SubtitleSiteError(`${env.site.name} 返回 HTTP ${res.status}`);
return await res.text();
}
const page = <K extends PageKind>(kind: K, html: string) =>
runSandbox("subtitle.page", { kind, html }, { timeoutMs: 15_000 }) as Promise<PageResult<K>>;
const abs = (env: Env, path: string) => new URL(path, env.site.base_url).href;
/** content-disposition 里的文件名:常见是 UTF-8 字节被当成 latin1 发出,按字节还原 */
function fileNameOf(res: Response, url: string): string {
const cd = res.headers.get("content-disposition") ?? "";
const star = /filename\*=UTF-8''([^;]+)/i.exec(cd)?.[1];
if (star) return decodeURIComponent(star);
const plain = /filename="?([^";]+)"?/i.exec(cd)?.[1];
if (plain) {
const bytes = Uint8Array.from(plain, (c) => c.charCodeAt(0) & 0xff);
const utf8 = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
return utf8.includes("�") ? plain : utf8;
}
return decodeURIComponent(new URL(url).pathname.split("/").pop() || "subtitle.zip");
}
async function getFile(env: Env, url: string, referer: string): Promise<{ name: string; bytes: Uint8Array }> {
const res = await request(env, url, { referer });
if (!res.ok) throw new SubtitleSiteError(`下载返回 HTTP ${res.status}`);
const len = Number(res.headers.get("content-length") ?? 0);
if (len > MAX_DOWNLOAD_BYTES) throw new SubtitleSiteError("字幕包超过 30 MB");
const bytes = new Uint8Array(await res.arrayBuffer());
if (bytes.length > MAX_DOWNLOAD_BYTES) throw new SubtitleSiteError("字幕包超过 30 MB");
const head = new TextDecoder().decode(bytes.subarray(0, 200)).toLowerCase();
if (/<!doctype html|<html/.test(head)) throw new SubtitleSiteError("下载地址返回的是网页,不是字幕文件");
return { name: fileNameOf(res, res.url || url), bytes };
}
// ── 搜索词与匹配 ──
export interface SearchQuery {
keywords: string[];
doubanId: string | null;
season: number | null;
episode: number | null;
}
const norm = (s: string) => s.toLowerCase().replace(/[\s\p{P}\p{S}]/gu, "");
/** 标题里含任一搜索词(去掉空白与标点比较) */
function relevant(title: string, keywords: string[]): boolean {
const t = norm(title);
return keywords.some((k) => {
const n = norm(k);
return n.length >= 2 && t.includes(n);
});
}
/** 季集不符的丢掉;合集 / 季包保留 */
function episodeOk(title: string, q: SearchQuery): { ok: boolean; season: number | null; episode: number | null } {
const ep = subtitleEpisode(`${title}.srt`);
if (q.season !== null && !ep.seasonInferred && ep.season !== null && ep.season !== q.season)
return { ok: false, season: ep.season, episode: ep.episode };
if (q.episode !== null && ep.episode !== null && ep.episode !== q.episode)
return { ok: false, season: ep.season, episode: ep.episode };
return { ok: true, season: ep.seasonInferred ? null : ep.season, episode: ep.episode };
}
const LANG_SCORE: Record<string, number> = { 双语: 3, 简体: 2, 繁体: 1 };
function toResult(env: Env, e: PageEntry, q: SearchQuery): SubtitleSearchResult | null {
const ep = episodeOk(e.title, q);
if (!ep.ok) return null;
const lang = Math.max(0, ...e.languages.map((l) => LANG_SCORE[l] ?? 0));
const exact = q.episode !== null && ep.episode === q.episode ? 2 : 0;
return {
siteId: env.site.id,
siteKey: env.site.key,
siteName: env.site.name,
ref: e.ref,
title: e.title,
pageUrl: abs(env, e.ref),
languages: e.languages,
format: e.format,
downloads: e.downloads,
uploader: e.uploader,
publishedAt: e.publishedAt,
season: ep.season,
episode: ep.episode,
score: lang * 10 + exact * 5 + Math.log10((e.downloads ?? 0) + 1),
};
}
// ── SubHD ──
async function subhdSearch(env: Env, q: SearchQuery): Promise<PageEntry[]> {
// SubHD 的条目 ID 就是豆瓣条目 ID:有豆瓣 ID 直接打开条目页
if (q.doubanId) {
const d = await page("subhd.list", await getText(env, abs(env, `/d/${q.doubanId}`), env.site.base_url));
if (d.entries.length) return d.entries;
}
for (const kw of q.keywords.slice(0, 3)) {
const html = await getText(env, abs(env, `/search/${encodeURIComponent(kw)}`), env.site.base_url);
const r = await page("subhd.list", html);
const hits = r.entries.filter((e) => relevant(e.title, q.keywords));
if (hits.length) return hits;
for (const d of r.details.filter((x) => relevant(x.title, q.keywords)).slice(0, 2)) {
const dd = await page("subhd.list", await getText(env, abs(env, `/d/${d.id}`), env.site.base_url));
if (dd.entries.length) return dd.entries;
}
}
return [];
}
async function subhdDownload(env: Env, ref: string): Promise<{ name: string; bytes: Uint8Array }> {
const subUrl = abs(env, ref);
const sub = await page("subhd.sub", await getText(env, subUrl, env.site.base_url));
let down = sub.down;
if (!down && sub.dataSid) {
const prep = (await (
await request(env, abs(env, "/api/sub/prepare-download"), { referer: subUrl, json: { sid: sub.dataSid } })
).json()) as { success?: boolean; url?: string };
if (prep.success && prep.url?.includes("/down/")) down = prep.url;
}
if (!down) throw new SubtitleSiteError("SubHD 字幕页没有下载按钮");
const downUrl = abs(env, down);
const sid = new URL(downUrl).pathname.split("/").pop() ?? "";
await (await request(env, downUrl, { referer: subUrl })).body?.cancel();
const api = async (cap: string) =>
(await (await request(env, abs(env, "/api/sub/down"), { referer: downUrl, json: { sid, cap } })).json()) as {
success?: boolean;
pass?: boolean;
msg?: string;
url?: string;
};
let r = await api("");
if (r.pass === false) {
// SVG 文字验证码:沙箱里识别后带上再提交一次
const { code } = await runSandbox("captcha.solve", { kind: "subhd", svg: String(r.msg ?? "") });
r = await api(code ?? "");
}
if (!r.success || !r.url)
throw new SubtitleSiteError("SubHD 下载验证没通过:可在站点的原站视图里打开这条字幕手动下载后上传");
return await getFile(env, abs(env, r.url), downUrl);
}
// ── 字幕库 ──
/** 打开页面;遇到云锁验证码页就在沙箱里识别、提交验证后重开(最多 3 次) */
async function zimukuPage(env: Env, url: string, referer?: string): Promise<string> {
for (let i = 0; i < 3; i++) {
const res = await request(env, url, referer ? { referer } : {});
const html = await res.text();
const cap = await page("zimuku.captcha", html);
if (!cap.bmpBase64) {
if (!res.ok) throw new SubtitleSiteError(`${env.site.name} 返回 HTTP ${res.status}`);
return html;
}
const { code } = await runSandbox("captcha.solve", { kind: "zimuku", bmpBase64: cap.bmpBase64 });
if (!code) break;
const hex = [...code].map((c) => c.charCodeAt(0).toString(16)).join("");
await (await request(env, `${url}${url.includes("?") ? "&" : "?"}security_verify_img=${hex}`)).body?.cancel();
}
throw new SubtitleSiteError("字幕库的验证码没能自动通过:可在站点的原站视图里打开一次再试");
}
async function zimukuSearch(env: Env, q: SearchQuery): Promise<PageEntry[]> {
for (const kw of q.keywords.slice(0, 3)) {
const s = await page("zimuku.search", await zimukuPage(env, abs(env, `/search?q=${encodeURIComponent(kw)}`)));
const subs = s.subs.filter((x) => !x.title || relevant(x.title, q.keywords)).slice(0, 3);
const out: PageEntry[] = [];
for (const x of subs) {
const p = await page("zimuku.subs", await zimukuPage(env, abs(env, x.path), env.site.base_url));
out.push(...p.entries);
}
if (out.length) return out;
}
return [];
}
async function zimukuDownload(env: Env, ref: string): Promise<{ name: string; bytes: Uint8Array }> {
const detailUrl = abs(env, ref);
const d = await page("zimuku.detail", await zimukuPage(env, detailUrl, env.site.base_url));
if (!d.dl) throw new SubtitleSiteError("字幕库字幕页没有下载入口");
const dlUrl = new URL(d.dl, detailUrl).href;
const links = (await page("zimuku.dl", await zimukuPage(env, dlUrl, detailUrl))).links;
let last = "字幕库没有可用的下载线路";
for (const l of links) {
try {
const f = await getFile(env, new URL(l, dlUrl).href, dlUrl);
if (f.bytes.length > 64) return f;
} catch (e) {
last = (e as Error).message;
}
}
throw new SubtitleSiteError(last);
}
const PROVIDERS: Record<
string,
{
search(env: Env, q: SearchQuery): Promise<PageEntry[]>;
download(env: Env, ref: string): Promise<{ name: string; bytes: Uint8Array }>;
}
> = {
subhd: { search: subhdSearch, download: subhdDownload },
zimuku: { search: zimukuSearch, download: zimukuDownload },
};
// ── 对外 ──
export async function subtitleSearchQuery(
ctx: ServerContext,
entityId: string,
opts: { season?: number | undefined; episode?: number | undefined; keyword?: string | undefined },
): Promise<SearchQuery> {
const e = await ctx.db
.selectFrom("media_entity")
.select(["title", "original_title"])
.where("id", "=", entityId)
.executeTakeFirst();
if (!e) throw new SubtitleSiteError("作品不存在");
const aliases = await ctx.db
.selectFrom("title_alias")
.select("alias")
.where("media_entity_id", "=", entityId)
.where("content_unit_id", "is", null)
.limit(5)
.execute();
const douban = await ctx.db
.selectFrom("external_identity")
.select("external_id")
.where("media_entity_id", "=", entityId)
.where("provider", "=", "douban")
.where("status", "=", "accepted")
.executeTakeFirst();
const keywords = [opts.keyword, e.title, e.original_title, ...aliases.map((a) => a.alias)]
.map((x) => x?.trim())
.filter((x): x is string => !!x && x.length >= 2);
return {
keywords: [...new Set(keywords)],
doubanId: douban?.external_id ?? null,
season: opts.season ?? null,
episode: opts.episode ?? null,
};
}
async function subtitleSites(ctx: ServerContext): Promise<SiteRow[]> {
return await ctx.db
.selectFrom("site")
.selectAll()
.where("kind", "=", "subtitle")
.where("enabled", "=", true)
.where("deleted_at", "is", null)
.orderBy("name")
.execute();
}
export async function searchOnline(
ctx: ServerContext,
rt: SourcesRuntime,
q: SearchQuery,
): Promise<SubtitleSearchResponse> {
const sites = (await subtitleSites(ctx)).filter((s) => PROVIDERS[s.key]);
const results: SubtitleSearchResult[] = [];
const errors: SubtitleSearchResponse["errors"] = [];
await Promise.all(
sites.map(async (site) => {
const env = { rt, site };
try {
const entries = await PROVIDERS[site.key]!.search(env, q);
const seen = new Set<string>();
for (const e of entries) {
if (seen.has(e.ref)) continue;
seen.add(e.ref);
const r = toResult(env, e, q);
if (r) results.push(r);
}
} catch (e) {
errors.push({ siteName: site.name, reason: (e as Error).message.slice(0, 300) });
}
}),
);
results.sort((a, b) => b.score - a.score);
return { results: results.slice(0, 60), keywords: q.keywords, errors, noSites: sites.length === 0 };
}
export async function downloadOnline(
ctx: ServerContext,
rt: SourcesRuntime,
siteId: string,
ref: string,
): Promise<{ name: string; base64: string; siteKey: string; pageUrl: string }> {
const site = (await subtitleSites(ctx)).find((s) => s.id === siteId);
if (!site || !PROVIDERS[site.key]) throw new SubtitleSiteError("字幕站不存在或已停用");
const env = { rt, site };
const f = await PROVIDERS[site.key]!.download(env, ref);
return { name: f.name, base64: b64encode(f.bytes), siteKey: site.key, pageUrl: abs(env, ref) };
}
@@ -0,0 +1,169 @@
// 字幕站页面解析(纯函数,在沙箱任务 subtitle.page 里跑):SubHD / 字幕库的搜索页、条目页、字幕页、下载页 → 结构化数据。
// 页面结构 2026-10-03 实测(样本在 .local/site-samples/subs/),对照 MP 插件 online_subtitles/providers/{subhd,zimuku}.py。
export interface PageEntry {
/** 站内引用:SubHD 为 /a/<sid>,字幕库为 /detail/<id>.html */
ref: string;
title: string;
/** 简体 / 繁体 / 双语 / 英文 …(站点标注或从标题、说明里猜) */
languages: string[];
format: string | null;
downloads: number | null;
uploader: string | null;
publishedAt: string | null;
}
const strip = (s: string) =>
s
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&#39;/g, "'")
.replace(/&quot;/g, '"')
.replace(/\s+/g, " ")
.trim();
const href = (q: string) => `href=["']${q}["']`;
/** 从标题 / 说明里猜语言 */
export function guessLanguages(text: string): string[] {
const t = text.toLowerCase();
const out: string[] = [];
if (/双语|中英|简英|繁英|chs&eng|cht&eng|bilingual/.test(t)) out.push("双语");
if (/简体|简中|chs|gb|简繁|简日/.test(t)) out.push("简体");
if (/繁体|繁體|繁中|cht|big5|简繁/.test(t)) out.push("繁体");
if (/英文|英语|\beng?\b|english/.test(t) && !out.includes("双语")) out.push("英文");
return [...new Set(out)];
}
const formatOf = (text: string) => /\b(ass|ssa|srt|sup|vtt|sub|idx)\b/i.exec(text)?.[1]?.toUpperCase() ?? null;
const num = (s: string | undefined) => {
if (!s) return null;
const m = /([\d.]+)\s*(万)?/.exec(s.replace(/,/g, ""));
return m ? Math.round(Number(m[1]) * (m[2] ? 10_000 : 1)) : null;
};
// ── SubHD ──
/** 搜索页 / 条目页(/d/<豆瓣 ID>):结果区里的字幕(/a/<sid>),不含侧栏热门 */
function subhdList(html: string): { entries: PageEntry[]; details: { id: string; title: string }[] } {
const hot = html.search(/<h\d[^>]*>\s*热门|class="[^"]*hot[^"]*"/);
const body = hot > 0 ? html.slice(0, hot) : html;
const entries: PageEntry[] = [];
const seen = new Set<string>();
for (const m of body.matchAll(new RegExp(`<a[^>]+${href("/a/([A-Za-z0-9_-]+)")}[^>]*>([\\s\\S]*?)</a>`, "g"))) {
const sid = m[1]!;
const title = strip(m[2]!);
if (seen.has(sid) || !title) continue;
seen.add(sid);
const around = strip(body.slice(Math.max(0, m.index! - 600), m.index! + 1200));
entries.push({
ref: `/a/${sid}`,
title,
languages: guessLanguages(`${title} ${around}`),
format: formatOf(`${title} ${around}`),
downloads: null,
uploader: /发布人\s*(\S+)/.exec(around)?.[1] ?? null,
publishedAt: /<time datetime="([^"]+)"/.exec(body.slice(m.index!, m.index! + 2500))?.[1] ?? null,
});
}
const details: { id: string; title: string }[] = [];
for (const m of body.matchAll(new RegExp(`<a[^>]+${href("/d/(\\d+)")}[^>]*>([\\s\\S]*?)</a>`, "g"))) {
const alt = /alt=["']([^"']+)["']/.exec(m[2]!)?.[1];
const title = strip(m[2]!) || alt || "";
if (title && !details.some((d) => d.id === m[1])) details.push({ id: m[1]!, title });
}
return { entries, details };
}
/** 字幕页(/a/<sid>):下载按钮 /down/<sid> 或 data-sid(要先 prepare-download) */
function subhdSub(html: string): { down: string | null; dataSid: string | null } {
return {
down: new RegExp(`${href("([^\"']*/down/[A-Za-z0-9_-]+[^\"']*)")}`).exec(html)?.[1] ?? null,
dataSid: /\bdata-sid=["']([A-Za-z0-9_-]+)["']/.exec(html)?.[1] ?? null,
};
}
// ── 字幕库 ──
/** 字幕库语言旗帜的说明(「简体中文字幕」「双语字幕」…)→ 统一叫法 */
function langLabel(t: string): string {
if (/双语/.test(t)) return "双语";
if (/繁/.test(t)) return "繁体";
if (/简/.test(t)) return "简体";
if (/英/.test(t)) return "英文";
if (/日/.test(t)) return "日文";
if (/韩|韓/.test(t)) return "韩文";
return t.replace(/字幕$/, "");
}
function zimukuSearch(html: string): { subs: { path: string; title: string }[] } {
const subs: { path: string; title: string }[] = [];
for (const m of html.matchAll(/<a[^>]+href=["']([^"']*\/subs\/\d+\.html)["'][^>]*>([\s\S]*?)<\/a>/g)) {
// 链接可能写成 //zimuku.org/subs/…(别的域名):只取路径,拼到站点地址上
const path = /\/subs\/\d+\.html/.exec(m[1]!)![0];
const title =
strip(m[2]!) || /title=["']([^"']+)["']/.exec(m[0])?.[1] || /alt=["']([^"']+)["']/.exec(m[2]!)?.[1] || "";
const prev = subs.find((s) => s.path === path);
if (!prev) subs.push({ path, title });
else if (!prev.title && title) prev.title = title;
}
return { subs };
}
/** 条目页(/subs/<id>.html)的字幕表 */
function zimukuSubs(html: string): { entries: PageEntry[] } {
const entries: PageEntry[] = [];
for (const row of html.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/g)) {
const r = row[1]!;
const a = /<a href=["'](\/detail\/\d+\.html)["'][^>]*title=["']([^"']+)["']/.exec(r);
if (!a) continue;
const langs = [...r.matchAll(/<img[^>]+title=["']([^"']+字幕)["']/g)].map((m) => langLabel(m[1]!));
const cells = [...r.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g)].map((m) => m[1]!);
entries.push({
ref: a[1]!,
title: strip(a[2]!),
languages: langs.length ? [...new Set(langs)] : guessLanguages(a[2]!),
format: /<span class="label label-info">([^<]+)<\/span>/.exec(r)?.[1]?.trim() ?? formatOf(a[2]!),
downloads: num(strip(cells[4] ?? "")),
uploader: /<a href=["']\/u\/[^"']+["']>([^<]+)<\/a>/.exec(r)?.[1] ?? null,
publishedAt: strip(/glyphicon-time"><\/span>([^<]+)</.exec(r)?.[1] ?? "") || null,
});
}
return { entries };
}
/** 字幕页:下载入口(li.dlsub 里的链接) */
function zimukuDetail(html: string): { dl: string | null } {
return {
dl: /<li[^>]+class=["'][^"']*dlsub[^"']*["'][^>]*>[\s\S]*?<a[^>]+href=["']([^"']+)["']/.exec(html)?.[1] ?? null,
};
}
/** 下载跳转页:各线路的 /download/ 链接 */
function zimukuDl(html: string): { links: string[] } {
return { links: [...new Set([...html.matchAll(/href=["']([^"']*\/download\/[^"']+)["']/g)].map((m) => m[1]!))] };
}
/** 是否是字幕库(云锁)的验证码页;是则带回 BMP */
function zimukuCaptcha(html: string): { bmpBase64: string | null } {
return {
bmpBase64:
/class=["']verifyimg["'][^>]*src=["']data:image\/bmp;base64,([^"']+)["']/.exec(html)?.[1] ??
/data:image\/bmp;base64,([^"']+)/.exec(html)?.[1] ??
null,
};
}
export const PAGE_PARSERS = {
"subhd.list": subhdList,
"subhd.sub": subhdSub,
"zimuku.search": zimukuSearch,
"zimuku.subs": zimukuSubs,
"zimuku.detail": zimukuDetail,
"zimuku.dl": zimukuDl,
"zimuku.captcha": zimukuCaptcha,
} as const;
export type PageKind = keyof typeof PAGE_PARSERS;
export type PageResult<K extends PageKind> = ReturnType<(typeof PAGE_PARSERS)[K]>;
+1 -1
View File
@@ -269,7 +269,7 @@ export async function applySubtitles(
library_root_id: t.libraryRootId,
relative_path: rel,
language: it.language,
source: body.source ?? "upload",
source: it.source ?? body.source ?? "upload",
source_name: it.name,
source_ref: it.sourceRef ?? null,
backup_path: backup,