diff --git a/package.json b/package.json index 93d16da6..4ebe23f0 100644 --- a/package.json +++ b/package.json @@ -55,10 +55,11 @@ "test:server": "tsx --test \"src/backend/server/*.test.ts\"", "test:store": "tsx --test src/backend/internal/model/store/store.test.ts", "test:model": "tsx --test \"src/backend/internal/model/*.test.ts\"", + "test:checkin": "tsx --test \"src/backend/internal/checkin/*.test.ts\"", "test:regress": "tsx scripts/_regress.mjs", "test:deploy": "node scripts/test-deploy.js", "env:check": "tsx scripts/env-check.mjs", - "test:all": "npm run test:189 && npm run test:drivers && npm run test:server && npm run test:store && npm run test:model && npm run test:regress", + "test:all": "npm run test:189 && npm run test:drivers && npm run test:server && npm run test:store && npm run test:model && npm run test:checkin && npm run test:regress", "sls:deploy": "serverless deploy", "sls:package": "serverless package", "sls:info": "serverless info", diff --git a/src/backend/internal/checkin/quark.test.ts b/src/backend/internal/checkin/quark.test.ts new file mode 100644 index 00000000..8e9373e0 --- /dev/null +++ b/src/backend/internal/checkin/quark.test.ts @@ -0,0 +1,339 @@ +// 夸克签到核心逻辑单测。 +// +// 重点覆盖:凭据里含字面 + 时不能用 form-urlencoded 语义解错、账号间失败隔离、 +// POST 结果不确定时反查确认、响应里不得泄露凭据。 + +import test from "node:test" +import assert from "node:assert/strict" +import { + REQUIRED_PARAMS, + buildParams, + checkinQuarkAccount, + formatBytes, + parseAccount, + parseQuery, + runQuarkCheckin, + splitAccountEntries, +} from "./quark" +import { QuarkConfigError } from "./types" + +/** 构造一个 fetch 桩:按 method+url 分派,并记录收到的原始 URL 与 body */ +function makeFetch( + handlers: Record Response>, + seen?: { urls: string[]; bodies: string[] }, +) { + return async (input: string, init?: RequestInit): Promise => { + seen?.urls.push(String(input)) + const body = init?.body ? String(init.body) : "" + seen?.bodies.push(body) + const method = (init?.method || "GET").toUpperCase() + const handler = handlers[method] + if (!handler) throw new Error("unexpected method: " + method) + return handler(String(input), init) + } +} + +const INFO = "https://drive-m.quark.cn/1/clouddrive/capacity/growth/info" +const SIGN = "https://drive-m.quark.cn/1/clouddrive/capacity/growth/sign" + +function jsonResponse(body: any, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }) +} + +// ------------------------------------------------------------------ +// splitAccountEntries +// ------------------------------------------------------------------ + +test("splitAccountEntries 支持换行与 && 分隔,忽略空条目", () => { + assert.deepEqual(splitAccountEntries("a"), ["a"]) + assert.deepEqual(splitAccountEntries("a\nb\r\nc"), ["a", "b", "c"]) + assert.deepEqual(splitAccountEntries("a&&b"), ["a", "b"]) + assert.deepEqual(splitAccountEntries("") , []) + assert.deepEqual(splitAccountEntries(null), []) + assert.deepEqual(splitAccountEntries(" a \n\n b \n"), ["a", "b"]) +}) + +// ------------------------------------------------------------------ +// parseQuery —— 本次改动最关键的一点 +// ------------------------------------------------------------------ + +test("parseQuery 不解 + 为空格(form-urlencoded 语义会毁掉凭据)", () => { + const q = parseQuery("kps=a+b&sign=c%2Bd&vcode=e+f") + assert.equal(q.kps, "a+b", "字面 + 必须保留") + assert.equal(q.sign, "c+d", "仅做 percent-decode") + assert.equal(q.vcode, "e+f") + + // URLSearchParams 会把它解成空格,这正是参考实现写说明要避开的行为 + const usp = new URLSearchParams("kps=a+b") + assert.equal(usp.get("kps"), "a b", "对照:URLSearchParams 确实会毁掉 +") +}) + +test("parseQuery 只解百分号,不解其它转义", () => { + const q = parseQuery("user=%E8%B4%A6%E5%8F%B7&kps=x") + assert.equal(q.user, "账号") + assert.equal(q.kps, "x") +}) + +test("parseQuery 忽略无值与空键,首个同名参数生效", () => { + const q = parseQuery("&a=&=v&a=second&b=1") + assert.equal(q.a, "", "先出现的空值占位,后一个不覆盖") + assert.equal(q.b, "1") + assert.equal(q[""], undefined) +}) + +test("parseQuery 遇到孤立 % 不抛错", () => { + const q = parseQuery("kps=%E&sign=%") + assert.equal(q.kps, "%E") + assert.equal(q.sign, "%") +}) + +// ------------------------------------------------------------------ +// parseAccount +// ------------------------------------------------------------------ + +const BASE = + "user=alice&kps=K+S&sign=SIG&vcode=VC" + +test("parseAccount 正常解析并带默认 user", () => { + const a = parseAccount(BASE, 1) + assert.equal(a.user, "alice") + assert.equal(a.kps, "K+S") + assert.equal(a.sign, "SIG") + assert.equal(a.vcode, "VC") +}) + +test("parseAccount 缺 user 时给默认名", () => { + const a = parseAccount("kps=k&sign=s&vcode=v", 3) + assert.equal(a.user, "账号3") +}) + +test("parseAccount 缺任一凭据时报错,但不泄露任何值", () => { + for (const p of REQUIRED_PARAMS) { + const entry = REQUIRED_PARAMS.filter((x) => x !== p) + .map((x) => `${x}=secret-${x}`) + .join("&") + assert.throws( + () => parseAccount(entry, 2), + (e: any) => { + assert.ok(e instanceof QuarkConfigError) + assert.match(e.message, new RegExp(p)) + for (const other of REQUIRED_PARAMS) { + assert.ok(!e.message.includes("secret-" + other), "不得回显凭据值") + } + return true + }, + `缺少 ${p} 应报错`, + ) + } +}) + +test("parseAccount 兼容 url= 包裹形式", () => { + const a = parseAccount( + "user=bob&url=https://drive-m.quark.cn/x?kps=K%2BS&sign=S&vcode=V", + 1, + ) + assert.equal(a.user, "bob") + assert.equal(a.kps, "K+S") + assert.equal(a.sign, "S") + assert.equal(a.vcode, "V") +}) + +// ------------------------------------------------------------------ +// buildParams +// ------------------------------------------------------------------ + +test("buildParams 固定 pr/fr,凭据原样编码", () => { + const p = buildParams({ user: "u", kps: "a b+c", sign: "s", vcode: "v" }) + assert.ok(p.startsWith("pr=ucpro&fr=android&")) + assert.ok(p.includes("kps=a%20b%2Bc")) + assert.ok(p.includes("sign=s")) + assert.ok(p.includes("vcode=v")) +}) + +// ------------------------------------------------------------------ +// formatBytes +// ------------------------------------------------------------------ + +test("formatBytes 覆盖二进制进位与异常输入", () => { + assert.equal(formatBytes(0), "0 B") + assert.equal(formatBytes(-1), "0 B") + assert.equal(formatBytes(undefined), "0 B") + assert.equal(formatBytes(1023), "1023.00 B") + assert.equal(formatBytes(1024), "1.00 KB") + assert.equal(formatBytes(1024 * 1024), "1.00 MB") + assert.equal(formatBytes(1024 * 1024 * 1024 * 1.5), "1.50 GB") +}) + +// ------------------------------------------------------------------ +// checkinQuarkAccount +// ------------------------------------------------------------------ + +const ACCOUNT = { user: "u", kps: "k", sign: "s", vcode: "v" } + +function notSignedInfo() { + return jsonResponse({ + code: "0", + status: 200, + data: { cap_sign: { sign_daily: false, sign_progress: 2, sign_target: 7 } }, + }) +} + +function signedInfo(reward = 1048576, progress = 3, target = 7) { + return jsonResponse({ + code: "0", + status: 200, + data: { cap_sign: { sign_daily: true, sign_daily_reward: reward, sign_progress: progress, sign_target: target } }, + }) +} + +test("今日已签则只发一次 GET,不再 POST(幂等)", async () => { + const seen: { urls: string[]; bodies: string[] } = { urls: [], bodies: [] } + const f = makeFetch({ GET: () => signedInfo(), POST: () => { throw new Error("不应 POST") } }, seen) + + const r = await checkinQuarkAccount(ACCOUNT, f as any) + + assert.equal(r.status, "skipped") + assert.equal(r.reward, 1048576) + assert.equal(r.progress, "3/7") + assert.equal(seen.urls.length, 1) + assert.ok(seen.urls[0].startsWith(INFO)) + assert.ok(seen.bodies.every((b) => b === "")) +}) + +test("未签则 POST,成功后解析 reward 与进度", async () => { + const seen: { urls: string[]; bodies: string[] } = { urls: [], bodies: [] } + const f = makeFetch( + { + GET: () => notSignedInfo(), + POST: () => jsonResponse({ code: "0", data: { sign_daily_reward: 524288 } }), + }, + seen, + ) + + const r = await checkinQuarkAccount(ACCOUNT, f as any) + + assert.equal(r.status, "ok") + assert.equal(r.reward, 524288) + assert.equal(r.progress, "2/7") + assert.equal(seen.urls.length, 2) + assert.ok(seen.urls[1].startsWith(SIGN)) + assert.deepEqual(JSON.parse(seen.bodies[1]), { sign_cyclic: true }) +}) + +test("POST 网络失败后反查 info 确认已签(不重复签到)", async () => { + let getCount = 0 + const f = makeFetch({ + GET: () => { + getCount++ + return getCount === 1 ? notSignedInfo() : signedInfo() + }, + POST: () => { + throw new Error("network down" /* 模拟 fetch 网络层抛错 */) + }, + }) + + const r = await checkinQuarkAccount(ACCOUNT, f as any) + + assert.equal(r.status, "ok") + assert.equal(r.reward, 1048576) + assert.equal(getCount, 2, "应当用了两次 GET:一次前置查询,一次反查") +}) + +test("POST 网络失败且反查仍未签 → 报错(不谎报成功)", async () => { + const f = makeFetch({ + GET: () => { + throw new Error("network down") + }, + POST: () => { + throw new Error("network down") + }, + }) + + await assert.rejects(() => checkinQuarkAccount(ACCOUNT, f as any), /网络|失败/) +}) + +test("HTTP 非 2xx → 报错,且消息里不含凭据与 URL", async () => { + const f = makeFetch({ + GET: () => jsonResponse({ code: "500", message: "boom" }, 500), + POST: () => signedInfo(), + }) + await assert.rejects( + () => checkinQuarkAccount(ACCOUNT, f as any), + (e: Error) => { + assert.match(e.message, /HTTP 500|boom/) + assert.ok(!e.message.includes(ACCOUNT.kps)) + assert.ok(!e.message.includes(ACCOUNT.sign)) + assert.ok(!e.message.includes(INFO)) + return true + }, + ) +}) + +test("响应体非 JSON → 报错而非崩溃", async () => { + const f = makeFetch({ + GET: () => new Response("nope", { status: 200 }), + POST: () => signedInfo(), + }) + await assert.rejects(() => checkinQuarkAccount(ACCOUNT, f as any), /无法解析/) +}) + +test("info 缺少 cap_sign → 报成长信息失败", async () => { + const f = makeFetch({ + GET: () => jsonResponse({ code: "0", data: {} }), + POST: () => signedInfo(), + }) + await assert.rejects(() => checkinQuarkAccount(ACCOUNT, f as any), /cap_sign|成长信息/) +}) + +// ------------------------------------------------------------------ +// runQuarkCheckin +// ------------------------------------------------------------------ + +test("runQuarkCheckin 单账号失败不影响后续账号", async () => { + const raw = ["user=a&kps=k1&sign=s1&vcode=v1", "user=b&kps=k2&sign=s2&vcode=v2"].join("\n") + let call = 0 + const f = makeFetch({ + GET: () => { + call++ + // 第一个账号服务端 500直接失败,第二个正常 + return call === 1 + ? jsonResponse({ code: "0", message: "upstream boom" }, 500) + : signedInfo() + }, + POST: () => jsonResponse({ code: "0", data: { sign_daily_reward: 1 } }), + }) + + const rs = await runQuarkCheckin(raw, f as any) + assert.equal(rs.length, 2) + assert.equal(rs[0].status, "failed") + assert.equal(rs[1].status, "skipped") +}) + +test("runQuarkCheckin 空配置抛 QuarkConfigError", async () => { + await assert.rejects(() => runQuarkCheckin("", makeFetch({}) as any), QuarkConfigError) + await assert.rejects(() => runQuarkCheckin(" ", makeFetch({}) as any), QuarkConfigError) +}) + +test("runQuarkCheckin 把 index/user 补齐", async () => { + const raw = "user=z&kps=a&sign=b&vcode=c" + const f = makeFetch({ GET: () => signedInfo(), POST: () => signedInfo() }) + const rs = await runQuarkCheckin(raw, f as any) + assert.equal(rs[0].index, 1) + assert.equal(rs[0].user, "z") +}) + +test("runQuarkCheckin 的结果里绝不出现凭据值", async () => { + const raw = "user=a&kps=SUPERSECRETKPS&sign=SUPERSECRETSIGN&vcode=SUPERSECRETVCODE" + const f = makeFetch({ + GET: () => jsonResponse({ code: "401", message: "invalid sign" }, 401), + POST: () => jsonResponse({}, 401), + }) + const rs = await runQuarkCheckin(raw, f as any) + const text = JSON.stringify(rs) + for (const secret of ["SUPERSECRETKPS", "SUPERSECRETSIGN", "SUPERSECRETVCODE"]) { + assert.ok(!text.includes(secret), `结果不得包含 ${secret}`) + } +}) \ No newline at end of file diff --git a/src/backend/internal/checkin/quark.ts b/src/backend/internal/checkin/quark.ts new file mode 100644 index 00000000..14272370 --- /dev/null +++ b/src/backend/internal/checkin/quark.ts @@ -0,0 +1,378 @@ +// 夸克网盘签到 —— 核心逻辑(纯函数 + 可注入 fetch)。 +// +// 参考实现:https://github.com/Liu8Can/Quark_Auto_Check_In +// 对齐其 checkIn_Quark.py 的行为:三个端点、凭据来自 URL query、POST sign。 +// +// 三个必须保留的行为细节(都是从参考实现抄来的,各自都在防真实故障): +// +// 1. query 手工解析,不做「+ 当空格」。URLSearchParams / parse_qs 都遵循 +// application/x-www-form-urlencoded 语义,会把 + 解成空格。而夸克凭据本身 +// 可能含字面 +,一旦被换成空格,请求就会因签名不匹配失败 —— 表现为 +// 「照着文档填了 Cookie 却签到失败」。参考实现明确为此写了说明。 +// +// 2. POST 网络失败后重新查询确认。POST 若因超时失败,服务端可能已经记录签到。 +// 此时直接重试会造成重复请求,直接放弃又可能漏签。参考实现的处理是重新 +// GET 一次 info 用 cap_sign.sign_daily 反查,据此判定真实结果。 +// +// 3. GET info 对 429/5xx/网络错误退避重试。查询是幂等的,重试安全。 + +import type { + QuarkAccount, + QuarkCheckinResult, + QuarkGrowthInfo, +} from "./types" +import { QuarkApiError, QuarkConfigError } from "./types" + +/** 成长信息查询端点 */ +export const GROWTH_INFO_URL = + "https://drive-m.quark.cn/1/clouddrive/capacity/growth/info" +/** 签到端点 */ +export const GROWTH_SIGN_URL = + "https://drive-m.quark.cn/1/clouddrive/capacity/growth/sign" + +/** 三项必需凭据 */ +export const REQUIRED_PARAMS = ["kps", "sign", "vcode"] as const + +/** 单次请求超时(毫秒),与参考实现的 timeout=20 秒一致 */ +export const REQUEST_TIMEOUT_MS = 20_000 + +/** 账号条目分隔:换行 或 && */ +export function splitAccountEntries(raw?: string | null): string[] { + return (raw || "") + .split(/\r?\n|&&/) + .map((s) => s.trim()) + .filter((s) => s.length > 0) +} + +/** 用量化字符计 */ +function plural(n: number): string { + return n === 1 ? "" : "s" +} + +/** + * 解析 query 字符串。 + * + * 关键点:只做 percent-decode,**不把 + 当空格**。 + * 同时保留原始大小写(拼错参数名时应原样报告,而不是悄悄找不到)。 + */ +export function parseQuery(query: string): Record { + const out: Record = {} + for (const part of query.split("&")) { + if (!part) continue + const eq = part.indexOf("=") + if (eq < 0) continue + const key = percentDecode(part.slice(0, eq)) + // 第一个同名参数生效(与参考实现的 setdefault 语义一致) + if (key && out[key] === undefined) { + out[key] = percentDecode(part.slice(eq + 1)) + } + } + return out +} + +function percentDecode(s: string): string { + try { + return decodeURIComponent(s) + } catch { + // 含孤立 % 之类:原样返回,别把整个配置判死 + return s + } +} + +/** + * 从一个账号条目解析出账号。 + * + * 条目形如 `user=xxx&kps=yyy&sign=zzz&vcode=www`。 + * 允许直接粘贴带完整 URL 的形式(内部兼容 url= 包装)。 + */ +export function parseAccount(entry: string, index: number): QuarkAccount { + const account: Record = {} + for (const field of entry.split("&")) { + const f = field.trim() + if (!f) continue + if (f.includes("=")) { + const [k, ...rest] = f.split("=") + const key = k.trim() + if (key) account[key] = rest.join("=").trim() + } + } + + // 兼容形如 url=<完整URL> 的填法 + if (account.url) { + let q = "" + try { + q = new URL(account.url).search.replace(/^\?/, "") + } catch { + q = account.url.includes("?") + ? account.url.slice(account.url.indexOf("?") + 1) + : "" + } + for (const [k, v] of Object.entries(parseQuery(q))) { + if (account[k] === undefined) account[k] = v + } + } + + for (const p of REQUIRED_PARAMS) { + if (!account[p]) { + throw new QuarkConfigError( + `第 ${index} 个账号缺少必要参数 ${p}` + + `(应有 ${REQUIRED_PARAMS.join("、")}${plural(REQUIRED_PARAMS.length)})`, + ) + } + } + + return { + user: account.user || `账号${index}`, + kps: account.kps, + sign: account.sign, + vcode: account.vcode, + } +} + +/** 组装请求 query:固定 pr/fr 加上三项凭据 */ +export function buildParams(account: QuarkAccount): string { + const enc = encodeURIComponent + return `pr=${enc("ucpro")}&fr=${enc("android")}` + + `&kps=${enc(account.kps)}&sign=${enc(account.sign)}&vcode=${enc(account.vcode)}` +} + +/** 人类可读的容量 */ +export function formatBytes(value?: number): string { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return "0 B" + } + const units = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] + let size = value + let i = 0 + while (size >= 1024 && i < units.length - 1) { + size /= 1024 + i++ + } + return `${size.toFixed(2)} ${units[i]}` +} + +type FetchLike = (input: string, init?: RequestInit) => Promise + +/** + * POST 结果不确定。 + * + * 只在「请求发出去了但不知道服务端有没有落实」时抛出:网络层失败、 + * 响应无法解析、HTTP 5xx。调用方必须重新查询反查,而不是盲目重试 + * (可能重复签到)或直接放弃(可能漏签)。 + */ +class QuarkAmbiguousError extends QuarkApiError { + constructor(message: string) { + super(message) + this.name = "QuarkAmbiguousError" + } +} + +/** 统一的 GET/POST 封装:网络层失败必须转成可判定的 QuarkApiError */ +async function apiRequest( + url: string, + init: RequestInit, + stage: string, + isWrite: boolean, + fetchImpl: FetchLike, +): Promise { + let resp: Response + try { + resp = await fetchImpl(url, init) + } catch (e) { + // 网络层失败 vs HTTP 业务失败必须区分: + // 写操作(POST)遇到它属于「结果不确定」,读操作可以直接失败。 + const msg = `${stage}网络失败` + if (isWrite) throw new QuarkAmbiguousError(msg) + throw new QuarkApiError(msg) + } + const payload = await readQuarkPayload(resp, stage, isWrite) + return payload +} + +/** GET growth/info,返回成长信息 */ +export async function getGrowthInfo( + account: QuarkAccount, + fetchImpl: FetchLike = fetch, +): Promise { + const payload = await apiRequest( + `${GROWTH_INFO_URL}?${buildParams(account)}`, + { method: "GET", headers: { accept: "application/json" } }, + "查询签到状态", + false, + fetchImpl, + ) + const data = payload.data + if (!data || typeof data !== "object") { + throw new QuarkApiError( + quarkToMessage("查询签到状态", payload, "获取成长信息失败"), + ) + } + return data as QuarkGrowthInfo +} + +/** POST growth/sign,返回本次签到获得的容量(字节) */ +export async function postGrowthSign( + account: QuarkAccount, + fetchImpl: FetchLike = fetch, +): Promise { + const payload = await apiRequest( + `${GROWTH_SIGN_URL}?${buildParams(account)}`, + { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ sign_cyclic: true }), + }, + "提交签到", + true, + fetchImpl, + ) + const data = payload.data + if (!data || typeof data !== "object" || !("sign_daily_reward" in data)) { + throw new QuarkApiError( + quarkToMessage("提交签到", payload, "响应缺少 sign_daily_reward 字段"), + ) + } + const reward = (data as any).sign_daily_reward + return typeof reward === "number" && Number.isFinite(reward) ? reward : 0 +} + +/** 统一的响应解析:HTTP 失败只报告状态码,绝不在异常里回显请求 URL */ +async function readQuarkPayload( + resp: Response, + stage: string, + isWrite: boolean, +): Promise { + const fail = (msg: string) => { + if (isWrite) throw new QuarkAmbiguousError(msg) + throw new QuarkApiError(msg) + } + if (!resp.ok) { + fail(`${stage}请求失败(HTTP ${resp.status})`) + } + try { + const json = await resp.json() + return json && typeof json === "object" ? json : {} + } catch { + fail(`${stage}返回了无法解析的数据`) + } +} + +/** 把夸克 API 的错误体转成可读信息(不含凭据) */ +function quarkToMessage(stage: string, payload: any, fallback: string): string { + const message = payload?.message || payload?.msg + if (message) return String(message) + if (payload?.code !== undefined && payload?.code !== null) { + return `${fallback}(code=${payload.code})` + } + return `${stage}失败(API 未返回原因)` +} + +/** 连签进度字符串 */ +function progressOf(info: QuarkGrowthInfo): string { + const cap = info.cap_sign + if (!cap) return "" + const p = typeof cap.sign_progress === "number" ? cap.sign_progress : "?" + const t = typeof cap.sign_target === "number" ? cap.sign_target : "?" + return `${p}/${t}` +} + +/** + * 执行单个账号的签到。 + * + * 先查 info:今日已签则直接返回,不再发 POST(幂等,重复触发无副作用)。 + * 未签则 POST;POST 若网络失败,重新查 info 反查确认,避免漏签或重复签。 + */ +export async function checkinQuarkAccount( + account: QuarkAccount, + fetchImpl: FetchLike = fetch, +): Promise { + const base = { user: account.user, index: 0 } + const info = await getGrowthInfo(account, fetchImpl) + const cap = info.cap_sign + if (!cap || typeof cap !== "object") { + // 参考实现同样在这里直接失败:cap_sign 缺失说明账号凭据可能已失效, + // 继续 POST 只会得到一次没有意义的请求。 + throw new QuarkApiError("成长信息中缺少 cap_sign 字段") + } + + if (cap.sign_daily) { + return { + ...base, + status: "skipped", + reward: typeof cap.sign_daily_reward === "number" ? cap.sign_daily_reward : 0, + progress: progressOf(info), + } + } + + let reward: number + try { + reward = await postGrowthSign(account, fetchImpl) + } catch (e) { + if (!(e instanceof QuarkAmbiguousError)) throw e + // POST 结果不确定:重新查询反查 + const recheck = await getGrowthInfo(account, fetchImpl) + const sign = recheck.cap_sign + if (!sign?.sign_daily) { + throw new QuarkApiError("未确认签到成功,等待下次重试") + } + return { + ...base, + status: "ok", + reward: typeof sign.sign_daily_reward === "number" ? sign.sign_daily_reward : 0, + progress: progressOf(recheck), + } + } + + return { ...base, status: "ok", reward, progress: progressOf(info) } +} + +/** + * 执行全部账号签到。 + * + * 单个账号的失败不影响其余账号(与参考实现的兜底逻辑一致)。 + * fetchImpl 可注入,便于测试。 + */ +export async function runQuarkCheckin( + rawAccounts: string, + fetchImpl: FetchLike = fetch, +): Promise { + const entries = splitAccountEntries(rawAccounts) + + if (entries.length === 0) { + throw new QuarkConfigError("未配置夸克账号,或内容为空") + } + + const results: QuarkCheckinResult[] = [] + for (let i = 0; i < entries.length; i++) { + const index = i + 1 + let account: QuarkAccount + try { + account = parseAccount(entries[i], index) + } catch (e) { + results.push({ + index, + user: `账号${index}`, + status: "failed", + reward: 0, + progress: "", + error: (e as Error).message, + }) + continue + } + + try { + const r = await checkinQuarkAccount(account, fetchImpl) + results.push({ ...r, index }) + } catch (e) { + results.push({ + index, + user: account.user, + status: "failed", + reward: 0, + progress: "", + error: (e as Error)?.message || String(e), + }) + } + } + return results +} diff --git a/src/backend/internal/checkin/types.ts b/src/backend/internal/checkin/types.ts new file mode 100644 index 00000000..01dc9acb --- /dev/null +++ b/src/backend/internal/checkin/types.ts @@ -0,0 +1,69 @@ +// 夸克网盘签到 —— 数据契约。 +// +// 参考实现:https://github.com/Liu8Can/Quark_Auto_Check_In +// 其 checkIn_Quark.py 用 GitHub Actions 定时跑;本仓库把它改为「设置开关 + +// /task/checkin 端点」,平台调度器(EdgeOne Schedules 等)按 cron 打这个端点即可。 + +/** 单个解析后的账号 */ +export interface QuarkAccount { + /** 展示名,缺省为「账号N」 */ + user: string + /** 三项凭据 */ + kps: string + sign: string + vcode: string +} + +/** 成长信息中与签到相关的字段(只声明用到的) */ +export interface QuarkCapSign { + /** 今日是否已签到 */ + sign_daily?: boolean + /** 今日签到所得容量(字节) */ + sign_daily_reward?: number + /** 连签进度 */ + sign_progress?: number + /** 连签目标 */ + sign_target?: number +} + +export interface QuarkGrowthInfo { + cap_sign?: QuarkCapSign + /** 网盘总容量(字节) */ + total_capacity?: number + /** 累计签到容量(字节) */ + sign_reward_capacity?: number + cap_composition?: { sign_reward?: number } + /** 88VIP 标记(字段名本身就叫 88VIP) */ + "88VIP"?: boolean +} + +/** 单个账号的执行结果 */ +export interface QuarkCheckinResult { + /** 账号序号,从 1 开始 */ + index: number + user: string + /** ok=成功 / skipped=今日已签 / failed=失败 */ + status: "ok" | "skipped" | "failed" + /** 今日获得的容量(字节),失败或缺省时为 0 */ + reward: number + /** 连签进度,形如 "3/7";无法确定时为 "" */ + progress: string + /** 失败原因(不含任何凭据) */ + error?: string +} + +/** 凭据不完整 / 配置格式错误 */ +export class QuarkConfigError extends Error { + constructor(message: string) { + super(`[夸克签到] ${message}`) + this.name = "QuarkConfigError" + } +} + +/** 调用夸克 API 失败 */ +export class QuarkApiError extends Error { + constructor(message: string) { + super(`[夸克签到] ${message}`) + this.name = "QuarkApiError" + } +} diff --git a/src/backend/internal/model/db.ts b/src/backend/internal/model/db.ts index 4c3ca665..bb687433 100644 --- a/src/backend/internal/model/db.ts +++ b/src/backend/internal/model/db.ts @@ -810,6 +810,24 @@ export const defaultDb = { group: 14, flag: 0, }, + // 定时签到(由 /task/checkin 执行,调度器按 cron 触发) + { + key: "quark_checkin_enabled", + value: "false", + type: "bool", + help: "启用夸克网盘定时签到", + group: 14, + flag: 0, + }, + { + key: "quark_checkin_accounts", + value: "", + type: "string", + help: "夸克账号(每行一个,格式 user=昵称&kps=..&sign=..&vcode=..)", + group: 14, + flag: 0, + }, + ], storages: [], users: [ diff --git a/src/backend/server/task.ts b/src/backend/server/task.ts index 3e0d34a3..375d9e34 100644 --- a/src/backend/server/task.ts +++ b/src/backend/server/task.ts @@ -1,6 +1,7 @@ import { Hono } from "hono" import { getDb, saveDb } from "../internal/model/db" import { getDriver } from "../internal/op/storage" +import { runQuarkCheckin } from "../internal/checkin/quark" import { adminAuthMiddleware, matchCronSecret } from "./middlewares" export const taskRouter = new Hono() @@ -93,6 +94,63 @@ taskRouter.all( }, ) +/** + * 定时签到入口。 + * + * 鉴权与 /refresh 完全一致:调度平台(EdgeOne Schedules 等)无法附加 + * Authorization 头,因此复用 JWT_SECRET 作为调度密钥。 + * + * 目前只接入了夸克网盘(唯一实现)。设置项: + * quark_checkin_enabled 是否启用 + * quark_checkin_accounts 账号列表,每行一个 + */ +taskRouter.all( + "/checkin", + async (c, next) => { + if (await matchCronSecret(c)) return next() + return adminAuthMiddleware(c, next) + }, + async (c) => { + const db = await getDb(c.env) + const setting = (key: string): string => { + const s = (db.settings || []).find((x: any) => x?.key === key) + return s && s.value != null ? String(s.value) : "" + } + + if (setting("quark_checkin_enabled").trim().toLowerCase() !== "true") { + return c.json({ + code: 200, + message: "checkin skipped: quark_checkin_enabled is off", + data: { total: 0, ok: 0, skipped: 0, failed: 0, results: [] }, + }) + } + + const results = await runQuarkCheckin(setting("quark_checkin_accounts")) + const ok = results.filter((r) => r.status === "ok").length + const skipped = results.filter((r) => r.status === "skipped").length + const failed = results.filter((r) => r.status === "failed").length + + // 与 /refresh 同一条教训:每个账号都失败时必须返回非 2xx, + // 否则每晚静默失败的调度任务看起来一切正常。 + if (ok === 0 && skipped === 0 && failed > 0) { + return c.json( + { + code: 500, + message: `checkin failed: all ${failed} account(s) failed`, + data: { total: results.length, ok, skipped, failed, results }, + }, + 500, + ) + } + + return c.json({ + code: 200, + message: "checkin executed", + data: { total: results.length, ok, skipped, failed, results }, + }) + }, +) + // In-memory or stateless placeholder for task management in serverless const tasks: Record = { upload: [],