feat(browser): unify site credentials with browser profiles (BR-2)
- Inject the site account Cookie (manual + auto-login session, session wins)
into the camofox profile before the first tab opens; password accounts
without a session run refresh-login first; API Key sites are skipped.
- Cookie expired after injection -> credential.status=expired; cookie
accounts go to waiting/login_required ("log in once, applies site-wide").
- Write back site-domain cookies from the browser on handover, session close,
every 10 min of activity, and from refresh-login when the stored cookie is
invalid (verified before saving). user_info.cookieOrigin records
source=browser; source shown as manual/login/browser in the origin view.
- New camofox plugin umt-cookies (GET/POST /umt/sessions/:userId/cookies
under the access key); profile dir 0700/files 0600 in the desktop image.
- Single entry: sources-identity/service/credentials.ts.
- Real check: HHClub cookie injected into a fresh profile -> home page
logged in (logout link present), 5 cookies read back.
Requires rebuilding umt-browser-desktop (docker compose build camofox &&
docker compose up -d camofox) to take effect.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
901b01d1a1
commit
28974d1e1a
58 files changed
+2843
-1112
No files matched your search
@@ -18,6 +18,7 @@
|
||||
import type {
|
||||
BrowserCapabilities,
|
||||
BrowserControlLease,
|
||||
BrowserCookie,
|
||||
BrowserHealth,
|
||||
BrowserSession,
|
||||
BrowserSnapshot,
|
||||
@@ -150,6 +151,8 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
private readonly sink: DownloadSink;
|
||||
private readonly now: () => Date;
|
||||
private readonly newId: () => string;
|
||||
/** Cookie 接口:plugin = umt-cookies 插件(读写);upstream = 上游导入端点(只写,要求 CAMOFOX_API_KEY 与访问凭据相同);none = 都没有 */
|
||||
private cookieApi: "plugin" | "upstream" | "none" | null = null;
|
||||
|
||||
constructor(private readonly cfg: CamofoxConfig) {
|
||||
this.base = cfg.baseUrl.replace(/\/+$/, "");
|
||||
@@ -368,6 +371,16 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
if (s.session.profileKey === profileKey && s.session.controller !== "closed")
|
||||
throw new BrowserError("control_conflict", "该站点/账号已有未关闭的浏览器会话");
|
||||
const sessionId = this.newId();
|
||||
let cookies: OpenSessionResult["cookies"];
|
||||
if (req.cookies?.length) {
|
||||
try {
|
||||
cookies = { status: "injected", count: (await this.putCookies(profileKey, req.cookies)).count };
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserError && e.kind === "not_implemented") cookies = { status: "unsupported", count: 0 };
|
||||
else if (e instanceof BrowserError && e.kind !== "auth") cookies = { status: "failed", count: 0 };
|
||||
else throw e;
|
||||
}
|
||||
}
|
||||
const res = await this.request("POST", "/tabs", {
|
||||
body: { userId: profileKey, sessionKey: sessionId, ...(req.initialUrl ? { url: req.initialUrl } : {}) },
|
||||
timeoutMs: req.budget.timeoutMs,
|
||||
@@ -425,7 +438,7 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
};
|
||||
this.assertLanded(rawUrl, req.budget);
|
||||
}
|
||||
return { session: { ...session }, tab: { ...tab }, navigation };
|
||||
return { session: { ...session }, tab: { ...tab }, navigation, ...(cookies ? { cookies } : {}) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -442,6 +455,91 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
throw new BrowserError("domain_blocked", `导航被重定向到白名单外:${redactBrowserUrl(new URL(rawUrl).origin)}`);
|
||||
}
|
||||
|
||||
// ---------- Cookie(BR-2 凭据统一) ----------
|
||||
|
||||
/**
|
||||
* 写 Cookie:先走 umt-cookies 插件 `POST /umt/sessions/{userId}/cookies`(docker/browser-desktop 自带,访问凭据即可);
|
||||
* 插件不在(404)再试上游 `POST /sessions/{userId}/cookies`(1.17.0 要求 CAMOFOX_API_KEY,与访问凭据相同时可用);
|
||||
* 都不行报 not_implemented。两条路上游都会立即把 profile 落盘。
|
||||
*/
|
||||
setCookies(profileKey: string, cookies: BrowserCookie[]): Promise<{ count: number }> {
|
||||
return this.withProfile(profileKey, () => this.putCookies(profileKey, cookies));
|
||||
}
|
||||
|
||||
private async putCookies(profileKey: string, cookies: BrowserCookie[]): Promise<{ count: number }> {
|
||||
const list = cookies.slice(0, 500);
|
||||
const body = { cookies: list } as unknown as Json;
|
||||
const path = `/sessions/${encodeURIComponent(profileKey)}/cookies`;
|
||||
if (this.cookieApi === null || this.cookieApi === "plugin") {
|
||||
try {
|
||||
const res = await this.json(
|
||||
await this.request("POST", `/umt${path}`, { body }),
|
||||
"/umt/sessions/{userId}/cookies",
|
||||
);
|
||||
this.cookieApi = "plugin";
|
||||
return { count: int(res.count) ?? list.length };
|
||||
} catch (e) {
|
||||
if (!(e instanceof BrowserError && e.httpStatus === 404)) throw e;
|
||||
this.cookieApi = null;
|
||||
}
|
||||
}
|
||||
if (this.cookieApi === null || this.cookieApi === "upstream") {
|
||||
try {
|
||||
const res = await this.json(await this.request("POST", path, { body }), "/sessions/{userId}/cookies");
|
||||
this.cookieApi = "upstream";
|
||||
return { count: int(res.count) ?? list.length };
|
||||
} catch (e) {
|
||||
// 403:上游导入口令(CAMOFOX_API_KEY)未设置或与访问凭据不同
|
||||
if (!(e instanceof BrowserError && (e.httpStatus === 403 || e.httpStatus === 404))) throw e;
|
||||
this.cookieApi = "none";
|
||||
}
|
||||
}
|
||||
throw new BrowserError("not_implemented", "浏览器服务没有 Cookie 接口(镜像未带 umt-cookies 插件)");
|
||||
}
|
||||
|
||||
/** 读 Cookie:只有 umt-cookies 插件提供(上游 1.17.0 没有读取端点);返回作用于这些地址的 Cookie */
|
||||
async getCookies(profileKey: string, urls: string[]): Promise<BrowserCookie[]> {
|
||||
if (this.cookieApi === "upstream" || this.cookieApi === "none")
|
||||
throw new BrowserError("not_implemented", "浏览器服务不支持读回 Cookie(镜像未带 umt-cookies 插件)");
|
||||
let res: Json;
|
||||
try {
|
||||
res = await this.json(
|
||||
await this.request("GET", `/umt/sessions/${encodeURIComponent(profileKey)}/cookies`, {
|
||||
query: { urls: urls.join(",") },
|
||||
}),
|
||||
"/umt/sessions/{userId}/cookies",
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserError && e.httpStatus === 404) {
|
||||
throw new BrowserError("not_implemented", "浏览器服务不支持读回 Cookie(镜像未带 umt-cookies 插件)");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
this.cookieApi = "plugin";
|
||||
const list = Array.isArray(res.cookies) ? res.cookies : [];
|
||||
const out: BrowserCookie[] = [];
|
||||
for (const c of list) {
|
||||
if (!c || typeof c !== "object") continue;
|
||||
const o = c as Json;
|
||||
const name = str(o.name);
|
||||
const domain = str(o.domain);
|
||||
const value = str(o.value);
|
||||
if (!name || !domain || value === undefined) continue;
|
||||
const sameSite = str(o.sameSite);
|
||||
out.push({
|
||||
name,
|
||||
value,
|
||||
domain,
|
||||
path: str(o.path) ?? "/",
|
||||
expires: typeof o.expires === "number" ? o.expires : -1,
|
||||
httpOnly: o.httpOnly === true,
|
||||
secure: o.secure === true,
|
||||
...(sameSite === "Strict" || sameSite === "Lax" || sameSite === "None" ? { sameSite } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** DELETE /sessions/{userId}:关闭该 profile 的全部上游标签页;上游 404(已被回收)视为已关闭 */
|
||||
async closeSession(sessionId: string, reason = "closed"): Promise<void> {
|
||||
const s = this.state(sessionId);
|
||||
@@ -567,6 +665,18 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
}
|
||||
if (lost.length) s.refsValid = false;
|
||||
if (s.tabs.get(s.currentTabId)?.status !== "open") s.currentTabId = alive[0] ?? "";
|
||||
// 桌面模式下人在画面里导航不经本适配器:按上游报告的当前地址同步(登录页判定、回写登录态前要用最新地址)
|
||||
const cur = list.find((t) => (str(t.tabId) ?? str(t.targetId)) === s.currentTabId);
|
||||
const curUrl = cur ? str(cur.url) : undefined;
|
||||
if (curUrl && curUrl !== s.rawUrl && /^https?:/.test(curUrl)) {
|
||||
s.refsValid = false;
|
||||
this.touch(s, curUrl);
|
||||
const tab = s.tabs.get(s.currentTabId);
|
||||
if (tab) {
|
||||
tab.url = s.session.currentUrl;
|
||||
tab.updatedAt = at;
|
||||
}
|
||||
}
|
||||
return { alive, lost };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createMockCamofox, type MockCamofox } from "@umt/testkit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
BrowserError,
|
||||
type BrowserLogger,
|
||||
cookieDomainMatches,
|
||||
cookiesFromHeader,
|
||||
createCamofoxBrowserProvider,
|
||||
headerFromCookies,
|
||||
mergeCookieHeaders,
|
||||
parseCookieHeader,
|
||||
profileKeyFor,
|
||||
sameCookieHeader,
|
||||
staticBrowserSecrets,
|
||||
} from "./index.ts";
|
||||
|
||||
// BR-2:Cookie 头串 ⇄ 浏览器 Cookie 互转,与 camofox Cookie 接口(umt-cookies 插件 / 上游导入端点 / 都没有)。
|
||||
|
||||
const ACCESS_KEY = "camofox-access-key-br2";
|
||||
const PROVIDER = "11111111-1111-4111-8111-111111111111";
|
||||
const SITE = "22222222-2222-4222-8222-222222222222";
|
||||
const ACCOUNT = "33333333-3333-4333-8333-333333333333";
|
||||
const SECRET = "c0ffee-session-value-br2";
|
||||
|
||||
describe("Cookie 头串互转", () => {
|
||||
it("解析 / 合并(后者优先)/ 等价比较", () => {
|
||||
expect([...parseCookieHeader(" a=1; b = 2 ;;bad; c=x=y ")]).toEqual([
|
||||
["a", "1"],
|
||||
["b", "2"],
|
||||
["c", "x=y"],
|
||||
]);
|
||||
expect(mergeCookieHeaders("a=1; b=2", "b=3; c=4")).toBe("a=1; b=3; c=4");
|
||||
expect(mergeCookieHeaders(null, "")).toBeNull();
|
||||
expect(sameCookieHeader("a=1; b=2", "b=2;a=1")).toBe(true);
|
||||
expect(sameCookieHeader("a=1", "a=2")).toBe(false);
|
||||
});
|
||||
|
||||
it("注入对象:站点主机 host-only、https → secure、30 天期限", () => {
|
||||
const list = cookiesFromHeader("uid=1; pass=x", "https://hhanclub.net/", 1000);
|
||||
expect(list).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "uid",
|
||||
domain: "hhanclub.net",
|
||||
path: "/",
|
||||
secure: true,
|
||||
expires: 1000 + 30 * 86400,
|
||||
}),
|
||||
expect.objectContaining({ name: "pass", value: "x" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("读回只取站点域名、未过期的 Cookie;同名取更具体的", () => {
|
||||
expect(cookieDomainMatches(".hhanclub.net", "www.hhanclub.net")).toBe(true);
|
||||
expect(cookieDomainMatches("hhanclub.net", "evilhhanclub.net")).toBe(false);
|
||||
const h = headerFromCookies(
|
||||
[
|
||||
{ name: "pass", value: "root", domain: ".hhanclub.net", path: "/", expires: -1, httpOnly: true, secure: true },
|
||||
{ name: "pass", value: "host", domain: "hhanclub.net", path: "/", expires: -1, httpOnly: true, secure: true },
|
||||
{ name: "old", value: "1", domain: "hhanclub.net", path: "/", expires: 10, httpOnly: false, secure: false },
|
||||
{ name: "_ga", value: "t", domain: ".google.com", path: "/", expires: -1, httpOnly: false, secure: false },
|
||||
],
|
||||
"https://hhanclub.net/",
|
||||
100,
|
||||
);
|
||||
expect(h).toBe("pass=host");
|
||||
expect(headerFromCookies([], "https://hhanclub.net/")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("camofox Cookie 接口", () => {
|
||||
let mock: MockCamofox | undefined;
|
||||
afterEach(async () => {
|
||||
await mock?.close();
|
||||
mock = undefined;
|
||||
});
|
||||
const provider = (m: MockCamofox, logs?: Parameters<BrowserLogger>[0][]) =>
|
||||
createCamofoxBrowserProvider({
|
||||
providerId: PROVIDER,
|
||||
baseUrl: m.url,
|
||||
secrets: staticBrowserSecrets({ [`provider:${PROVIDER}`]: ACCESS_KEY }),
|
||||
...(logs ? { logger: (e) => void logs.push(e) } : {}),
|
||||
});
|
||||
const key = profileKeyFor(SITE, ACCOUNT);
|
||||
const cookies = cookiesFromHeader(`c_secure_uid=1001; c_secure_pass=${SECRET}`, "https://audiences.example/");
|
||||
|
||||
it("umt-cookies 插件:写入(带访问凭据)与读回;日志不含 Cookie 值", async () => {
|
||||
mock = await createMockCamofox({ accessKey: ACCESS_KEY });
|
||||
const logs: Parameters<BrowserLogger>[0][] = [];
|
||||
const p = provider(mock, logs);
|
||||
expect(await p.setCookies(key, cookies)).toEqual({ count: 2 });
|
||||
const call = mock.callsTo("/umt/sessions/")[0]!;
|
||||
expect(call.headers.authorization).toBe(`Bearer ${ACCESS_KEY}`);
|
||||
const back = await p.getCookies(key, ["https://audiences.example/"]);
|
||||
expect(headerFromCookies(back, "https://audiences.example/")).toBe(`c_secure_pass=${SECRET}; c_secure_uid=1001`);
|
||||
expect(await p.getCookies(key, ["https://other.example/"])).toEqual([]);
|
||||
expect(JSON.stringify(logs)).not.toContain(SECRET);
|
||||
});
|
||||
|
||||
it("openSession 带 cookies:先注入再开标签页", async () => {
|
||||
mock = await createMockCamofox({ accessKey: ACCESS_KEY });
|
||||
const p = provider(mock);
|
||||
const r = await p.openSession({
|
||||
providerId: PROVIDER,
|
||||
siteId: SITE,
|
||||
accountId: ACCOUNT,
|
||||
initialUrl: "https://audiences.example/",
|
||||
budget: { domainAllowlist: ["audiences.example"], timeoutMs: 500, maxRetries: 0, maxBytes: 1_000_000 },
|
||||
cookies,
|
||||
});
|
||||
expect(r.cookies).toEqual({ status: "injected", count: 2 });
|
||||
const paths = mock.calls.map((c) => c.path);
|
||||
expect(paths.findIndex((x) => x.startsWith("/umt/"))).toBeLessThan(paths.indexOf("/tabs"));
|
||||
});
|
||||
|
||||
it("没有插件:回退上游导入端点(CAMOFOX_API_KEY 与访问凭据相同时);读回报不支持", async () => {
|
||||
mock = await createMockCamofox({ accessKey: ACCESS_KEY, cookieApi: "upstream", cookieImportKey: ACCESS_KEY });
|
||||
const p = provider(mock);
|
||||
expect(await p.setCookies(key, cookies)).toEqual({ count: 2 });
|
||||
expect(mock.cookieJars.get(key)).toHaveLength(2);
|
||||
await expect(p.getCookies(key, ["https://audiences.example/"])).rejects.toMatchObject({ kind: "not_implemented" });
|
||||
});
|
||||
|
||||
it("插件与上游导入都不可用:not_implemented;openSession 照常打开并标 unsupported", async () => {
|
||||
mock = await createMockCamofox({ accessKey: ACCESS_KEY, cookieApi: "upstream" });
|
||||
const p = provider(mock);
|
||||
const e = await p.setCookies(key, cookies).catch((x: unknown) => x);
|
||||
expect(e).toBeInstanceOf(BrowserError);
|
||||
expect((e as BrowserError).kind).toBe("not_implemented");
|
||||
expect((e as BrowserError).message).not.toContain(SECRET);
|
||||
const r = await p.openSession({
|
||||
providerId: PROVIDER,
|
||||
siteId: SITE,
|
||||
accountId: ACCOUNT,
|
||||
budget: { domainAllowlist: ["audiences.example"], timeoutMs: 500, maxRetries: 0, maxBytes: 1_000_000 },
|
||||
cookies,
|
||||
});
|
||||
expect(r.cookies).toEqual({ status: "unsupported", count: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { BrowserCookie } from "@umt/contracts";
|
||||
|
||||
// BR-2 凭据统一:站点账号里存的是 HTTP `Cookie:` 请求头串("a=1; b=2"),浏览器 profile 里是带域名/路径/期限的 Cookie 对象。
|
||||
// 这里只做两者互转与比较;值是凭据,不打印、不进错误信息。
|
||||
|
||||
/** 注入时 Cookie 头串里没有期限信息:给 30 天,站点响应的 Set-Cookie 会按站点自己的期限覆盖 */
|
||||
export const INJECTED_COOKIE_TTL_SEC = 30 * 24 * 3600;
|
||||
|
||||
/** "a=1; b=2" → [[a, 1], [b, 2]](同名后者覆盖;去掉空段与首尾空白) */
|
||||
export function parseCookieHeader(header: string | null | undefined): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const part of (header ?? "").split(";")) {
|
||||
const i = part.indexOf("=");
|
||||
if (i <= 0) continue;
|
||||
const name = part.slice(0, i).trim();
|
||||
if (!name || /[\s,;]/.test(name)) continue;
|
||||
out.set(name, part.slice(i + 1).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Map → "a=1; b=2"(按名字排序,比较稳定) */
|
||||
export function formatCookieHeader(jar: Map<string, string>): string {
|
||||
return [...jar.entries()]
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
/** 多个 Cookie 头串合并,靠后的优先(用户填的 + 自动登录得到的会话 Cookie → 会话 Cookie 覆盖同名项) */
|
||||
export function mergeCookieHeaders(...headers: (string | null | undefined)[]): string | null {
|
||||
const jar = new Map<string, string>();
|
||||
for (const h of headers) for (const [k, v] of parseCookieHeader(h)) jar.set(k, v);
|
||||
return jar.size ? formatCookieHeader(jar) : null;
|
||||
}
|
||||
|
||||
/** 两个 Cookie 头串是否等价(忽略顺序与空白) */
|
||||
export function sameCookieHeader(a: string | null | undefined, b: string | null | undefined): boolean {
|
||||
const x = parseCookieHeader(a);
|
||||
const y = parseCookieHeader(b);
|
||||
if (x.size !== y.size) return false;
|
||||
for (const [k, v] of x) if (y.get(k) !== v) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 站点地址 → 注入用 Cookie 对象:域名 = 站点主机(host-only),路径 /,https 站点标 secure */
|
||||
export function cookiesFromHeader(
|
||||
header: string,
|
||||
siteUrl: string,
|
||||
nowSec = Math.floor(Date.now() / 1000),
|
||||
): BrowserCookie[] {
|
||||
const u = new URL(siteUrl);
|
||||
const secure = u.protocol === "https:";
|
||||
return [...parseCookieHeader(header)].map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
domain: u.hostname,
|
||||
path: "/",
|
||||
expires: nowSec + INJECTED_COOKIE_TTL_SEC,
|
||||
httpOnly: false,
|
||||
secure,
|
||||
sameSite: "Lax" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Cookie 的 domain 是否作用于该主机(host-only 精确匹配;.根域 匹配子域) */
|
||||
export function cookieDomainMatches(cookieDomain: string, host: string): boolean {
|
||||
const d = cookieDomain.toLowerCase().replace(/^\./, "");
|
||||
const h = host.toLowerCase();
|
||||
return h === d || h.endsWith(`.${d}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器读回的 Cookie → 站点 Cookie 头串。只取作用于站点主机、路径为根(或站点路径前缀)且未过期的,
|
||||
* 其它域名(统计、CDN、第三方)的 Cookie 一律丢弃(回写只对该站域名)。
|
||||
*/
|
||||
export function headerFromCookies(
|
||||
cookies: readonly BrowserCookie[],
|
||||
siteUrl: string,
|
||||
nowSec = Math.floor(Date.now() / 1000),
|
||||
): string | null {
|
||||
const u = new URL(siteUrl);
|
||||
const jar = new Map<string, { v: string; specific: number }>();
|
||||
for (const c of cookies) {
|
||||
if (!cookieDomainMatches(c.domain, u.hostname)) continue;
|
||||
if (c.expires !== undefined && c.expires > 0 && c.expires <= nowSec) continue;
|
||||
const path = c.path || "/";
|
||||
if (!u.pathname.startsWith(path)) continue;
|
||||
// 同名多条:更具体的域名 / 路径优先(与浏览器发送顺序一致)
|
||||
const specific = c.domain.replace(/^\./, "").length * 1000 + path.length;
|
||||
const prev = jar.get(c.name);
|
||||
if (!prev || specific >= prev.specific) jar.set(c.name, { v: c.value, specific });
|
||||
}
|
||||
if (!jar.size) return null;
|
||||
return formatCookieHeader(new Map([...jar].map(([k, x]) => [k, x.v])));
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./budget.ts";
|
||||
export * from "./camofox/index.ts";
|
||||
export * from "./cookies.ts";
|
||||
export * from "./errors.ts";
|
||||
export * from "./image.ts";
|
||||
export * from "./none.ts";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
BrowserCapabilities,
|
||||
BrowserControlLease,
|
||||
BrowserCookie,
|
||||
BrowserHealth,
|
||||
BrowserLeaseHolderKind,
|
||||
BrowserProviderKind,
|
||||
@@ -65,6 +66,18 @@ export interface BrowserProvider {
|
||||
* 只读,不要求自动化控制权;上游不支持 evaluate 时返回 null。
|
||||
*/
|
||||
pageMetrics(ref: TabRef): Promise<PageMetrics | null>;
|
||||
|
||||
// ── BR-2 凭据统一 ──
|
||||
/**
|
||||
* 把 Cookie 写进该 profile 的浏览器上下文(并落盘到 profile)。上游没有可用的 Cookie 接口时报
|
||||
* BrowserError("not_implemented")。Cookie 值不进日志与错误信息。
|
||||
*/
|
||||
setCookies?(profileKey: string, cookies: BrowserCookie[]): Promise<{ count: number }>;
|
||||
/**
|
||||
* 读该 profile 作用于这些地址的 Cookie(活动上下文优先,否则读 profile 落盘文件);没有时返回空数组。
|
||||
* 上游没有接口时报 BrowserError("not_implemented")。
|
||||
*/
|
||||
getCookies?(profileKey: string, urls: string[]): Promise<BrowserCookie[]>;
|
||||
}
|
||||
|
||||
export interface PageMetrics {
|
||||
@@ -106,12 +119,16 @@ export interface OpenSessionRequest {
|
||||
/** 可选初始地址,同样受 budget 约束 */
|
||||
initialUrl?: string;
|
||||
budget: NavigationBudget;
|
||||
/** 打开前先注入的 Cookie(站点账号登录态,BR-2);注入失败不阻止打开,结果见 OpenSessionResult.cookies */
|
||||
cookies?: BrowserCookie[];
|
||||
}
|
||||
|
||||
export interface OpenSessionResult {
|
||||
session: BrowserSession;
|
||||
tab: BrowserTab;
|
||||
navigation: NavigationResult | null;
|
||||
/** 传了 cookies 时:injected = 注入个数;unsupported = 上游没有 Cookie 接口;failed = 请求失败 */
|
||||
cookies?: { status: "injected" | "unsupported" | "failed"; count: number };
|
||||
}
|
||||
|
||||
/** 会话内标签页定位;tabId 缺省 = 会话当前标签页 */
|
||||
|
||||
@@ -88,33 +88,14 @@ PT 站点适配器,按 MoviePilot v3(提交 e6820a8)迁移:通用配置驱动 sp
|
||||
|
||||
| 状态 | 站点 |
|
||||
| --- | --- |
|
||||
| 启用,**已实测 2026-09-29**(真实账号只读核对) | hhanclub 憨憨 |
|
||||
| 启用(主力,仍须真实账号核验) | audiences 观众、chdbits 彩虹岛、mteam 馒头 |
|
||||
| 启用(主力,仍须真实账号核验) | audiences 观众、hhanclub 憨憨、chdbits 彩虹岛、mteam 馒头 |
|
||||
| `enabled:false`,**未实测** | agsvpt、hdarea、hddolby、hdfans、hdhome、hdsky、hdtime、hdvideo、keepfrds、nicept、ourbits、piggo、pter、pthome、pttime、soulvoice、ssd、tjupt、ttg |
|
||||
|
||||
未实测站点只经 schema 校验,选择器是 MovieClaw 作者在 2026-08/09 的实测值,未经本项目核验。TTG 需要自定义适配器(中文分类、`/t/<id>` 路径、做种/下载同列),通用 spider 结果不可靠;OurBits 的 `cat[]` 分类参数已由定义表达;hddolby 在 MovieClaw 中按 HTML 站点配置,MP 则用 API spider(`HDDolby`),两条路径待账号确认后二选一。
|
||||
|
||||
### 待真实站点核验(W1-3c 新增)
|
||||
|
||||
1. HHClub(2026-09-29 已实测,见下节):仍未见到的只有促销(`promotion-tag-*`)与 H&R(`div.torrent-info-hr`)命中的实例——当次第 1 页 100 条全部无促销、无 H&R,类名与 Jackett 现行定义一致,暂不改。
|
||||
|
||||
### HHClub 实测记录(2026-09-29)
|
||||
|
||||
真实账号只读核对,对站点共发 1 次详情请求(列表、用户数据、健康检测用添加站点时产品自己抓的结果;下载令牌只核对列表里的下载链接形态,没有下载)。
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| 列表 `div.torrent-table-sub-info` 卡片、分类 `?cat[]=` 链接、大小 / 做种 / 下载 / 完成 | 正确,全为数字;分类 401–405/412 原始 ID 与名称都在 |
|
||||
| 标题 | **有误,已修**:标题链接文本末尾带新种标记「[新]」(100 条里 58 条;详情页标题没有),`title` 加 `replace` 过滤 |
|
||||
| 副标题 | 正确,纯文本,无 HTML 残片 |
|
||||
| 发布时间 | 正确:`span[title]` 绝对时间按 `+08:00` 转 UTC,相对时间只进 `date_elapsed` |
|
||||
| 详情页 | 标题 / 副标题取到;没有做种、完成、分类名、基本信息行,**刷新详情会用 null 冲掉列表值,已修**(服务端合并,见 `mergeDetailOverList`) |
|
||||
| 用户数据(`NexusHhanclub`) | 正确;等级「贵宾」之前 `vip` 为 null,**已加 `umt.vipLevels`** |
|
||||
| 健康检测 / 登录标志 `a[href='logout.php']` | 正确 |
|
||||
| 下载链接 | 列表给 `download.php?id=…&passkey=…`,入库时 passkey 脱敏 |
|
||||
| 标签(官方 / 中字 / 国语等) | 列表没有可用的标签选择器;界面按标题解析与副标题原文生成规格标签 |
|
||||
|
||||
访问默认(`umt.defaults`,与 `settings.sites` 取更保守的一方):快照每 60 分钟只抓第 1 页、健康检测 6 小时、用户数据 6 小时、实时搜索并发 1、详情缓存 24 小时;限流 3 秒 1 个令牌、突发 2。fixture:`@umt/testkit` 的 `HHANCLUB_LIST_PAGE_2026`(脱敏最小片段)。
|
||||
1. HHClub:`div.torrent-table-sub-info` 卡片结构、`?cat[]=` 分类链接(若 URL 编码为 `cat%5B%5D=` 则 `\d+` 会取错,需改 querystring 过滤)、`promotion-tag-*` 新旧类名、`div.torrent-info-hr`、详情页 Tailwind 选择器、分页 0 起。
|
||||
2. CHDBits:域名 `ptchdbits.co`、`font.subtitle`、`td.embedded > span[title]` 促销截止、`a[href='logout.php']`。
|
||||
3. 专用 spider 的 JSON 字段均按 MP 读取的键编写 fixture,真实响应待核;HDDolby `hr` 字段语义、`page_number` 起点;TorrentLeech 时区;Yema `tagList` 类型;直链缺失时的回退地址(`TODO(verify)`)。
|
||||
4. 用户信息解析器 fixture 均为最小自编 HTML/JSON;`normalizeDatetime` 不处理相对时间(MP 用 dateparser)。
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
CHDBITS_LIST_PAGE,
|
||||
CHDBITS_LOGIN_PAGE,
|
||||
HHANCLUB_DETAIL_PAGE,
|
||||
HHANCLUB_LIST_PAGE,
|
||||
HHANCLUB_LIST_PAGE_2026,
|
||||
} from "@umt/testkit";
|
||||
import { CHDBITS_LIST_PAGE, CHDBITS_LOGIN_PAGE, HHANCLUB_DETAIL_PAGE, HHANCLUB_LIST_PAGE } from "@umt/testkit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getSiteDefinition, listSiteDefinitions } from "../registry.ts";
|
||||
import { parseDetailPage } from "../spider/detail.ts";
|
||||
@@ -108,44 +102,6 @@ describe("HHClub 定义(div 卡片模板)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("2026-09-29 实测结构:去掉新种标记「[新]」,短剧 412、完成数 0、无促销、时间按 +08:00、下载链接 passkey 脱敏", () => {
|
||||
const [a, b] = parse(HHANCLUB_LIST_PAGE_2026);
|
||||
expect(a).toMatchObject({
|
||||
siteReleaseId: "91001",
|
||||
title: "示例短剧 1080p WEB-DL H265 AAC-HHWEB",
|
||||
subtitle: "示例短剧 | 全30集 | 1080p",
|
||||
category: { id: "412", name: "短剧" },
|
||||
sizeBytes: Math.round(185.38 * 1024 ** 2),
|
||||
seeders: 1,
|
||||
leechers: 58,
|
||||
snatched: 0,
|
||||
promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null },
|
||||
hr: false,
|
||||
uploadedAt: "2026-09-29T06:07:03.000Z",
|
||||
detailUrl: "https://hhanclub.net/details.php?id=91001&hit=1",
|
||||
});
|
||||
expect(a!.downloadUrl).not.toContain("0000000000000000");
|
||||
expect(b).toMatchObject({
|
||||
title: "Example Show S01 2026 2160p WEB-DL H.264 AAC-HHWEB",
|
||||
category: { id: "402", name: "电视剧" },
|
||||
snatched: 187,
|
||||
uploadedAt: "2026-09-29T04:03:28.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("站点访问默认:快照只抓 1 页、健康检测 6 小时、详情缓存 24 小时、搜索并发 1;3 秒 1 个令牌、突发 2", () => {
|
||||
expect(hhanclubDefinition.umt.defaults).toEqual({
|
||||
snapshotIntervalSec: 3600,
|
||||
snapshotMaxPages: 1,
|
||||
healthCheckIntervalSec: 21600,
|
||||
userDataIntervalSec: 21600,
|
||||
searchConcurrency: 1,
|
||||
detailCacheSec: 86400,
|
||||
});
|
||||
expect(hhanclubDefinition.umt.rateLimit).toEqual({ minIntervalMs: 3000, burst: 2 });
|
||||
expect(hhanclubDefinition.umt.vipLevels).toContain("贵宾");
|
||||
});
|
||||
|
||||
it("浏览从 page=0 起(MovieClaw page_offset -1);mtype=tv 选 402/403/407/412", () => {
|
||||
expect(new SiteSpider(hhanclubDefinition, { page: 0 }).getSearchUrl()).toBe(
|
||||
"https://hhanclub.net/torrents.php?page=0",
|
||||
|
||||
@@ -86,17 +86,7 @@
|
||||
"attribute": "title"
|
||||
},
|
||||
"title": {
|
||||
"text": "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}",
|
||||
"filters": [
|
||||
{
|
||||
"name": "replace",
|
||||
"args": ["[新]", ""]
|
||||
},
|
||||
{
|
||||
"name": "replace",
|
||||
"args": ["(新)", ""]
|
||||
}
|
||||
]
|
||||
"text": "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}"
|
||||
},
|
||||
"details": {
|
||||
"selector": "div.torrent-title a[href*='details.php']",
|
||||
@@ -185,13 +175,13 @@
|
||||
}
|
||||
},
|
||||
"umt": {
|
||||
"version": "[email protected]+umt.1",
|
||||
"version": "[email protected]",
|
||||
"kind": "nexusphp",
|
||||
"displayName": "憨憨",
|
||||
"timezone": "+08:00",
|
||||
"rateLimit": {
|
||||
"minIntervalMs": 3000,
|
||||
"burst": 2
|
||||
"burst": 1
|
||||
},
|
||||
"capabilities": {
|
||||
"list": true,
|
||||
@@ -259,15 +249,6 @@
|
||||
"douban": "a[href*='douban.com']::attr(href)",
|
||||
"fileList": "td.rowfollow > ul > li::text"
|
||||
}
|
||||
},
|
||||
"vipLevels": ["贵宾", "VIP"],
|
||||
"defaults": {
|
||||
"snapshotIntervalSec": 3600,
|
||||
"snapshotMaxPages": 1,
|
||||
"healthCheckIntervalSec": 21600,
|
||||
"userDataIntervalSec": 21600,
|
||||
"searchConcurrency": 1,
|
||||
"detailCacheSec": 86400
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,14 +74,6 @@ const CapabilitiesSchema = z.strictObject({
|
||||
download: z.boolean(),
|
||||
rss: z.boolean(),
|
||||
});
|
||||
const AccessDefaultsSchema = z.strictObject({
|
||||
snapshotIntervalSec: z.number().int().min(300).optional(),
|
||||
snapshotMaxPages: z.number().int().min(1).max(10).optional(),
|
||||
healthCheckIntervalSec: z.number().int().min(300).optional(),
|
||||
userDataIntervalSec: z.number().int().min(300).optional(),
|
||||
searchConcurrency: z.number().int().min(1).max(4).optional(),
|
||||
detailCacheSec: z.number().int().min(0).optional(),
|
||||
});
|
||||
const RateLimitSchema = z.strictObject({ minIntervalMs: z.number().int().min(0), burst: z.number().int().min(1) });
|
||||
const TimezoneSchema = z.string().regex(/^[+-]\d{2}:\d{2}$/, "时区须为 ±HH:MM 偏移");
|
||||
const PairSchema = z.tuple([z.string(), z.string()]);
|
||||
@@ -95,8 +87,6 @@ const UmtNexusSchema = z.strictObject({
|
||||
authSupported: z.array(z.enum(["cookie", "credential", "apikey"])).optional(),
|
||||
timezone: TimezoneSchema,
|
||||
rateLimit: RateLimitSchema,
|
||||
/** 站点级访问默认(与 settings.sites 取更保守的一方) */
|
||||
defaults: AccessDefaultsSchema.optional(),
|
||||
capabilities: CapabilitiesSchema,
|
||||
categoryNames: z.record(z.string(), z.string()).optional(),
|
||||
loginSuccess: z.string().optional(),
|
||||
|
||||
@@ -121,21 +121,6 @@ export interface IndexerDefinition {
|
||||
umt: UmtSiteExtension;
|
||||
}
|
||||
|
||||
export interface SiteAccessDefaults {
|
||||
/** 定时快照周期(秒) */
|
||||
snapshotIntervalSec?: number;
|
||||
/** 快照(定时 / 手动)最多抓几页列表 */
|
||||
snapshotMaxPages?: number;
|
||||
/** 健康检测最短间隔(秒) */
|
||||
healthCheckIntervalSec?: number;
|
||||
/** 用户数据刷新间隔(秒;定时任务 sites.user_data 的周期,仅作说明与校验) */
|
||||
userDataIntervalSec?: number;
|
||||
/** 同站实时搜索并发 */
|
||||
searchConcurrency?: number;
|
||||
/** 详情页结果缓存(秒):期内不重复抓详情 */
|
||||
detailCacheSec?: number;
|
||||
}
|
||||
|
||||
export interface UmtSiteExtension {
|
||||
/** 定义版本,入 site_snapshot */
|
||||
version: string;
|
||||
@@ -166,11 +151,6 @@ export interface UmtSiteExtension {
|
||||
/** 站点时间时区偏移,如 "+08:00";pubdate/freedate 按此转 UTC */
|
||||
timezone: string;
|
||||
rateLimit: { minIntervalMs: number; burst: number };
|
||||
/**
|
||||
* 站点级访问默认(本项目扩展):按站点实测给出的保守下限。运行时与 settings.sites 取更保守的一方
|
||||
* (周期 / 缓存取较长,页数 / 并发取较小);站点行自己设了快照周期时以站点行为准。
|
||||
*/
|
||||
defaults?: SiteAccessDefaults;
|
||||
capabilities: { list: boolean; search: boolean; detail: boolean; download: boolean; rss: boolean };
|
||||
/** 原始分类 ID → 站点分类名(列表只有 ID 时补名称;不做投影) */
|
||||
categoryNames?: Record<string, string>;
|
||||
|
||||
@@ -286,6 +286,47 @@ export const BrowserProvidersQuery = z.object({
|
||||
export type BrowserProvidersQuery = z.infer<typeof BrowserProvidersQuery>;
|
||||
|
||||
/** 会话视图:会话 + 标签页 + 当前有效租约 + 界面可用能力 */
|
||||
// ── BR-2 凭据统一:站点账号 Cookie ⇄ 浏览器 profile ──
|
||||
|
||||
/** 浏览器 Cookie(Playwright 形状);value 是凭据,只在服务端与浏览器服务之间传,不进日志、模型与 API 响应 */
|
||||
export const BrowserCookie = z.object({
|
||||
name: z.string().min(1),
|
||||
value: z.string(),
|
||||
/** 主机名(host-only)或 .根域 */
|
||||
domain: z.string().min(1),
|
||||
path: z.string().default("/"),
|
||||
/** Unix 秒;-1 = 会话 Cookie */
|
||||
expires: z.number().default(-1),
|
||||
httpOnly: z.boolean().default(false),
|
||||
secure: z.boolean().default(false),
|
||||
sameSite: z.enum(["Strict", "Lax", "None"]).optional(),
|
||||
});
|
||||
export type BrowserCookie = z.infer<typeof BrowserCookie>;
|
||||
|
||||
/** 站点账号登录态(Cookie)来源:手填 / 账密自动登录 / 浏览器回写 */
|
||||
export const SiteCookieSource = z.enum(["manual", "login", "browser"]);
|
||||
export type SiteCookieSource = z.infer<typeof SiteCookieSource>;
|
||||
|
||||
/**
|
||||
* 会话打开时的凭据注入结果(存 recovery_state.credential):
|
||||
* injected = 已把账号 Cookie 注入浏览器 profile;none = 账号没有可注入的 Cookie;
|
||||
* not_applicable = 匿名会话或 API Key 类站点(网页登录态与 API Key 无关,不注入);
|
||||
* unsupported = 浏览器服务没有 Cookie 接口(镜像未带 umt-cookies 插件且上游导入口令不同);
|
||||
* failed = 注入请求失败;expired = 注入后首页仍是登录页(Cookie 过期),会话转"需要人工登录"。
|
||||
*/
|
||||
export const BrowserCredentialState = z.object({
|
||||
status: z.enum(["injected", "none", "not_applicable", "unsupported", "failed", "expired"]),
|
||||
source: SiteCookieSource.nullable(),
|
||||
/** 该来源最近一次写入凭据的时间 */
|
||||
sourceAt: Timestamp.nullable(),
|
||||
/** 注入的 Cookie 个数(不含值) */
|
||||
count: NonNegInt,
|
||||
message: z.string().nullable(),
|
||||
/** 最近一次从浏览器读回并比对的时间 */
|
||||
syncedAt: Timestamp.nullable(),
|
||||
});
|
||||
export type BrowserCredentialState = z.infer<typeof BrowserCredentialState>;
|
||||
|
||||
export const BrowserSessionView = BrowserSession.extend({
|
||||
tabs: z.array(BrowserTab),
|
||||
currentTabId: z.string().nullable(),
|
||||
@@ -294,6 +335,8 @@ export const BrowserSessionView = BrowserSession.extend({
|
||||
needsSnapshot: z.boolean(),
|
||||
/** 等待处理/接管原因(login_required / captcha / human_takeover / lease_expired …) */
|
||||
waitingReason: z.string().nullable(),
|
||||
/** 打开会话时的凭据注入与回写状态(BR-2);旧会话没有 */
|
||||
credential: BrowserCredentialState.nullable().optional(),
|
||||
});
|
||||
export type BrowserSessionView = z.infer<typeof BrowserSessionView>;
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { dropTables, execDdl } from "../src/migration-helpers.ts";
|
||||
|
||||
// 站点请求计数:每次从站点限流器拿到令牌(列表 / 搜索 / 详情 / 下载 / 登录 / 用户数据 / 原站视图)记一次,
|
||||
// 按小时聚合,api / worker 多进程共用。站点页展示"最近 24 小时请求次数";保留 7 天,写入时顺手清理。
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
create table site_request_count (
|
||||
site_id uuid not null references site(id) on delete cascade,
|
||||
hour timestamptz not null,
|
||||
count integer not null default 0 check (count >= 0),
|
||||
primary key (site_id, hour)
|
||||
);
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, dropTables(["site_request_count"]));
|
||||
}
|
||||
@@ -119,8 +119,6 @@ const EXPECTED_TABLES = [
|
||||
"rate_limit_bucket",
|
||||
// C2-A PG 短期缓存(0042,UNLOGGED)
|
||||
"kv_cache",
|
||||
// 站点请求计数(0046,按小时聚合)
|
||||
"site_request_count",
|
||||
].sort();
|
||||
|
||||
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */
|
||||
@@ -149,8 +147,6 @@ const NON_ENTITY_TABLES = [
|
||||
"rate_limit_bucket",
|
||||
// 缓存:(region, key) 主键
|
||||
"kv_cache",
|
||||
// 站点请求计数:(site_id, hour) 主键
|
||||
"site_request_count",
|
||||
];
|
||||
|
||||
describe.skipIf(skip)("schema migrations (real PG)", () => {
|
||||
|
||||
@@ -34,8 +34,16 @@ export interface MockCamofoxSite {
|
||||
/** 下载内容;缺省返回固定字节 */
|
||||
torrent?: (id: string) => Uint8Array;
|
||||
passkey?: string;
|
||||
/**
|
||||
* 登录态 Cookie(BR-2):注入的 Cookie 里 name 的值在 valid 中 → 该 profile 视为已登录;值不在其中 → 视为未登录(过期)。
|
||||
* 表单登录成功后把 name=fresh 写进该 profile 的 Cookie(读回时可见)。
|
||||
*/
|
||||
sessionCookie?: { name: string; valid: string[]; fresh: string };
|
||||
}
|
||||
|
||||
/** mock 的 Cookie 接口形态:plugin = umt-cookies 插件(读写);upstream = 只有上游导入端点(要求 CAMOFOX_API_KEY);none = 都没有 */
|
||||
export type MockCookieApi = "plugin" | "upstream" | "none";
|
||||
|
||||
export const MOCK_VIEWPORT = { width: 1280, height: 800 } as const;
|
||||
export const MOCK_ROW_Y0 = 20;
|
||||
export const MOCK_ROW_H = 40;
|
||||
@@ -52,6 +60,21 @@ export interface MockCamofoxOptions {
|
||||
/** 视口(CSS 像素)与设备像素比:截图尺寸 = 视口 × DPR;元素行坐标按 CSS 像素。缺省 MOCK_VIEWPORT、DPR 1 */
|
||||
viewport?: { width: number; height: number };
|
||||
devicePixelRatio?: number;
|
||||
/** 缺省 plugin */
|
||||
cookieApi?: MockCookieApi;
|
||||
/** upstream 模式下上游导入端点认的口令(模拟 CAMOFOX_API_KEY);缺省不设 → 403 */
|
||||
cookieImportKey?: string;
|
||||
}
|
||||
|
||||
export interface MockCamofoxCookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
httpOnly: boolean;
|
||||
secure: boolean;
|
||||
sameSite?: string;
|
||||
}
|
||||
|
||||
export interface MockCamofoxTab {
|
||||
@@ -90,6 +113,8 @@ export type MockCamofox = MockHttp & {
|
||||
actions: MockCamofoxAction[];
|
||||
/** 已登录的 userId(profile) */
|
||||
loggedIn: Set<string>;
|
||||
/** 各 profile 的 Cookie(注入的 + 表单登录写入的) */
|
||||
cookieJars: Map<string, MockCamofoxCookie[]>;
|
||||
/** 模拟站点登录过期(全部 profile 或指定 profile) */
|
||||
expireLogin(userId?: string): void;
|
||||
/** 模拟上游回收标签页(空闲超时 / 重启) */
|
||||
@@ -298,7 +323,40 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
const tabs = new Map<string, MockCamofoxTab>();
|
||||
const actions: MockCamofoxAction[] = [];
|
||||
const loggedIn = new Set<string>();
|
||||
const cookieJars = new Map<string, MockCamofoxCookie[]>();
|
||||
const cookieApi = opts.cookieApi ?? "plugin";
|
||||
const site = opts.site;
|
||||
const putCookie = (userId: string, c: MockCamofoxCookie) => {
|
||||
const jar = (cookieJars.get(userId) ?? []).filter(
|
||||
(x) => !(x.name === c.name && x.domain === c.domain && x.path === c.path),
|
||||
);
|
||||
jar.push(c);
|
||||
cookieJars.set(userId, jar);
|
||||
const sc = site?.sessionCookie;
|
||||
if (sc && c.name === sc.name && new URL(site.origin).hostname.endsWith(c.domain.replace(/^\./, ""))) {
|
||||
if (sc.valid.includes(c.value)) loggedIn.add(userId);
|
||||
else loggedIn.delete(userId);
|
||||
}
|
||||
};
|
||||
const importCookies = (userId: string, b: Body): MockReply => {
|
||||
const list = Array.isArray(b.cookies) ? (b.cookies as Record<string, unknown>[]) : null;
|
||||
if (!list) return err(400, "cookies must be an array");
|
||||
for (const c of list) {
|
||||
if (typeof c.name !== "string" || typeof c.value !== "string" || typeof c.domain !== "string")
|
||||
return err(400, "invalid cookie objects");
|
||||
putCookie(userId, {
|
||||
name: c.name,
|
||||
value: c.value,
|
||||
domain: c.domain,
|
||||
path: typeof c.path === "string" ? c.path : "/",
|
||||
expires: typeof c.expires === "number" ? c.expires : -1,
|
||||
httpOnly: c.httpOnly === true,
|
||||
secure: c.secure === true,
|
||||
...(typeof c.sameSite === "string" ? { sameSite: c.sameSite } : {}),
|
||||
});
|
||||
}
|
||||
return { json: { ok: true, userId, count: list.length } };
|
||||
};
|
||||
const viewport = opts.viewport ?? MOCK_VIEWPORT;
|
||||
const dpr = opts.devicePixelRatio ?? 1;
|
||||
const loginPath = site?.loginPath ?? "/login.php";
|
||||
@@ -351,6 +409,19 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
return;
|
||||
}
|
||||
loggedIn.add(t.userId);
|
||||
if (site.sessionCookie) {
|
||||
if (!site.sessionCookie.valid.includes(site.sessionCookie.fresh))
|
||||
site.sessionCookie.valid.push(site.sessionCookie.fresh);
|
||||
putCookie(t.userId, {
|
||||
name: site.sessionCookie.name,
|
||||
value: site.sessionCookie.fresh,
|
||||
domain: new URL(site.origin).hostname,
|
||||
path: "/",
|
||||
expires: Math.floor(Date.now() / 1000) + 86400,
|
||||
httpOnly: true,
|
||||
secure: site.origin.startsWith("https:"),
|
||||
});
|
||||
}
|
||||
go(t, `${site.origin}${back}`);
|
||||
};
|
||||
|
||||
@@ -713,6 +784,32 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
return { json: { downloads: list } };
|
||||
},
|
||||
],
|
||||
[
|
||||
/^\/umt\/sessions\/([^/]+)\/cookies$/,
|
||||
(m, q, req, b) => {
|
||||
if (cookieApi !== "plugin") return err(404, "no route");
|
||||
const userId = decodeURIComponent(m[1]!);
|
||||
if (req.method === "POST") return importCookies(userId, b);
|
||||
const hosts = (q.urls ?? "")
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.map((u) => new URL(u).hostname);
|
||||
const cookies = (cookieJars.get(userId) ?? []).filter((c) => {
|
||||
const d = c.domain.replace(/^\./, "");
|
||||
return hosts.some((h) => h === d || h.endsWith(`.${d}`));
|
||||
});
|
||||
return { json: { source: "live", cookies } };
|
||||
},
|
||||
],
|
||||
[
|
||||
/^\/sessions\/([^/]+)\/cookies$/,
|
||||
(m, _q, req, b) => {
|
||||
if (cookieApi === "none") return err(404, "no route");
|
||||
if (!opts.cookieImportKey || req.headers.authorization !== `Bearer ${opts.cookieImportKey}`)
|
||||
return err(403, "Forbidden");
|
||||
return importCookies(decodeURIComponent(m[1]!), b);
|
||||
},
|
||||
],
|
||||
[
|
||||
/^\/sessions\/([^/]+)$/,
|
||||
(m) => {
|
||||
@@ -731,7 +828,8 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
port: opts.port,
|
||||
guard: (req) => {
|
||||
const path = new URL(req.url ?? "/", "http://mock").pathname;
|
||||
if (!opts.accessKey || path === "/health") return null;
|
||||
// 与上游一致:导入端点有自己的口令(CAMOFOX_API_KEY),不走访问凭据
|
||||
if (!opts.accessKey || path === "/health" || /^\/sessions\/[^/]+\/cookies$/.test(path)) return null;
|
||||
return req.headers.authorization === `Bearer ${opts.accessKey}` ? null : err(401, "Unauthorized");
|
||||
},
|
||||
},
|
||||
@@ -740,6 +838,7 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
tabs,
|
||||
actions,
|
||||
loggedIn,
|
||||
cookieJars,
|
||||
expireLogin(userId?: string) {
|
||||
if (userId) loggedIn.delete(userId);
|
||||
else loggedIn.clear();
|
||||
|
||||
@@ -45,38 +45,3 @@ export const HHANCLUB_DETAIL_PAGE = `<!DOCTYPE html><html><head><title>HHCLUB ::
|
||||
<div><a href="download.php?id=90002">下载种子</a></div>
|
||||
<div id="kdescr"><a href="https://www.imdb.com/title/tt15239678/">IMDb</a> <a href="https://movie.douban.com/subject/35575567/">豆瓣</a> 简介……</div>
|
||||
</div></body></html>`;
|
||||
|
||||
/**
|
||||
* 2026-09-29 真实账号核对后补的最小脱敏片段(名称、ID、数值均已替换;不含 passkey 与用户信息)。
|
||||
* 核对到的真实行为:列表标题链接文本末尾带新种标记「[新]」(详情页标题没有);短剧分类 412;
|
||||
* 新种完成数为 0;无促销时没有 promotion-tag 元素;上传时间在 span[title] 里,文本是相对时间。
|
||||
* 下载链接带 passkey(这里用占位值)。
|
||||
*/
|
||||
export const HHANCLUB_LIST_PAGE_2026 = `<!DOCTYPE html><html><head><title>HHCLUB :: 种子</title></head><body>
|
||||
<div id="user-info-panel"><a href="userdetails.php?id=7" class="VIP_Name">bob</a> <a href="logout.php">退出</a></div>
|
||||
<div class="torrent-table">
|
||||
<div class="torrent-table-sub-info">
|
||||
<div class="torrent-cat"><a href="?cat[]=412"><img src="pic/cattrans.gif" alt="短剧"/></a></div>
|
||||
<div class="torrent-title"><a class="torrent-info-text-name" href="details.php?id=91001&hit=1">示例短剧 1080p WEB-DL H265 AAC-HHWEB[新]</a></div>
|
||||
<div class="torrent-info-text-small_name">示例短剧 | 全30集 | 1080p</div>
|
||||
<div class="torrent-manage"><a href="download.php?id=91001&passkey=0000000000000000">下载</a></div>
|
||||
<div class="torrent-info-text-added"><span title="2026-09-29 14:07:03">5分钟</span></div>
|
||||
<div class="torrent-info-text-size">185.38 MB</div>
|
||||
<div class="torrent-info-text-seeders">1</div>
|
||||
<div class="torrent-info-text-leechers">58</div>
|
||||
<div class="torrent-info-text-finished">0</div>
|
||||
</div>
|
||||
<div class="torrent-table-sub-info">
|
||||
<div class="torrent-cat"><a href="?cat[]=402"><img src="pic/cattrans.gif" alt="电视剧"/></a></div>
|
||||
<div class="torrent-title"><a class="torrent-info-text-name" href="details.php?id=91002&hit=1">Example Show S01 2026 2160p WEB-DL H.264 AAC-HHWEB</a></div>
|
||||
<div class="torrent-info-text-small_name">示例剧集 | 第12集 | 4K | 类型: 奇幻 [内嵌中字]</div>
|
||||
<div class="torrent-manage"><a href="download.php?id=91002&passkey=0000000000000000">下载</a></div>
|
||||
<div class="torrent-info-text-added"><span title="2026-09-29 12:03:28">2时8分钟</span></div>
|
||||
<div class="torrent-info-text-size">1.46 GB</div>
|
||||
<div class="torrent-info-text-seeders">131</div>
|
||||
<div class="torrent-info-text-leechers">5</div>
|
||||
<div class="torrent-info-text-finished">187</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pagination"><a href="?page=0">1-100</a><a href="?page=1">101-200</a></div>
|
||||
</body></html>`;
|
||||
Reference in new issue
Block a user