feat(subtitles): 字幕站(SubHD、字幕库)在线搜索与下载;rar / 7z 解压;验证码与页面解析在沙箱里
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
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 {
|
||||
|
||||
@@ -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(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/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]>;
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user