import { SITE_KINDS, type SiteAccount, type SiteAccountOverall, type SiteAdapter, type SiteAuthKind, type SiteCapability, type SiteDefinitionView, type SiteKind, type SiteLastHealth, type SiteListItem, type SiteLoginFailureReason, type SiteRateLimit, siteCloudflareState, } from "@umt/contracts"; import type { StatusTone } from "~/components/console/status-badge.tsx"; import { relTime } from "~/components/workbench/release-fields.ts"; import { formatBytes } from "~/lib/utils.ts"; // 站点页的展示口径(docs/design/site-center.md §4.2)。缺失字段显示"—",不当成 0。 export const ADAPTER_LABEL: Record = { nexusphp: "NexusPHP", mteam_api: "M-Team API", api: "站点 API", generic_rss: "通用 RSS", }; /** 站点类型名(IA-2);旧数据缺 kind 时按 PT 站 */ export function kindLabel(kind: SiteKind | undefined): string { return SITE_KINDS[kind ?? "pt"].label; } /** 站点支持某种获取方式(服务端按类型 ∩ 站点定义算好的 capabilities) */ export function siteCan(site: Pick, cap: SiteCapability): boolean { return (site.capabilities ?? []).includes(cap); } /** 有登录与账号(凭证、账号数据、刷新登录、检测健康)的站点 */ export function hasLogin(site: Pick): boolean { return siteCan(site, "login"); } export const ACCOUNT_OVERALL: Record = { active: { label: "正常", tone: "success" }, expired: { label: "已失效", tone: "destructive" }, invalid: { label: "已失效", tone: "destructive" }, challenge: { label: "需验证", tone: "warning" }, unknown: { label: "未验证", tone: "neutral" }, unconfigured: { label: "未配置", tone: "neutral" }, }; export const AUTH_KIND_LABEL: Record = { cookie: "Cookie", api_key: "API Key", password: "账号密码", passkey: "Passkey", }; /** 主凭证三选一(passkey 只用于 RSS / 下载,不参与登录) */ export type LoginKind = "password" | "cookie" | "api_key"; /** 定义支持的登录方式;定义没写 authSupported 时按适配器推断 */ export function supportedKinds(def: Pick | undefined): LoginKind[] { if (!def) return ["cookie", "password", "api_key"]; const map: Record = { cookie: "cookie", credential: "password", password: "password", apikey: "api_key", api_key: "api_key", }; const listed = [...new Set(def.authSupported.map((a) => map[a]).filter((x): x is LoginKind => !!x))]; if (listed.length) return listed; if (def.adapter === "mteam_api") return ["api_key"]; if (def.adapter === "nexusphp") return ["cookie", "password"]; if (def.adapter === "api") return ["api_key", "cookie"]; return []; } /** 站点主账号:启用的里取最差状态那个;没有启用账号取第一个 */ const WORST: SiteAccount["status"][] = ["invalid", "expired", "challenge", "unknown", "active"]; export function primaryAccount(accounts: SiteAccount[] | undefined): SiteAccount | null { const list = accounts ?? []; const enabled = list.filter((a) => a.enabled); if (!enabled.length) return list[0] ?? null; return [...enabled].sort((a, b) => WORST.indexOf(a.status) - WORST.indexOf(b.status))[0] ?? null; } /** 有用户数据的账号(优先正常的) */ export function dataAccount(accounts: SiteAccount[] | undefined): SiteAccount | null { const list = (accounts ?? []).filter((a) => a.enabled && a.userData); return list.find((a) => a.status === "active") ?? list[0] ?? null; } export function overallStatus(site: SiteListItem): SiteAccountOverall { if (site.accountStatus) return site.accountStatus; const acc = primaryAccount(site.accounts); return acc?.enabled ? acc.status : "unconfigured"; } /** 需处理:账号非正常(已配置)或健康故障;没有登录的站点只看健康 */ export function needsAttention(site: SiteListItem): boolean { if (!hasLogin(site)) return site.enabled && site.healthStatus === "failing"; const st = overallStatus(site); return ( site.enabled && (st === "expired" || st === "invalid" || st === "challenge" || site.healthStatus === "failing") ); } export const bytes = (n: number | null | undefined) => (n === null || n === undefined ? "—" : formatBytes(n, 2)); /** 分享率:download=0 显示 ∞;null 显示 — */ export function ratioText(d: { ratio: number | null; download: number } | null | undefined): string { if (!d) return "—"; if (d.download === 0 && d.ratio === null) return "∞"; if (d.ratio === null) return "—"; return d.ratio >= 1000 ? d.ratio.toFixed(0) : d.ratio.toFixed(2); } export function hostOf(url: string): string { try { return new URL(url).host; } catch { return url; } } const HEALTH_STATUS_TEXT: Record = { ok: "正常", auth_missing: "未登录", auth_expired: "登录已失效", auth_invalid: "凭证无效", rate_limited: "被站点限流", unreachable: "连不上站点", site_maintenance: "站点维护中", parse_error: "页面解析失败", challenge: "需要人机验证", }; /** 健康检测一行:"正常 · 320 ms · 5 分钟前" / 失败原因一句 */ export function healthLine(h: SiteLastHealth | null, at: string | null): { text: string; tone: StatusTone } { if (!h) return { text: "未检测", tone: "neutral" }; const parts = [HEALTH_STATUS_TEXT[h.status] ?? h.status]; // 延迟毫秒数不上屏(DEAI-2),需要时看站点详情 parts.push(relTime(at)); const tone: StatusTone = h.ok ? "success" : h.status.startsWith("auth_") ? "destructive" : "warning"; return { text: parts.join(" · "), tone }; } export function healthMessage(h: SiteLastHealth | null): string | null { if (!h || h.ok) return null; return h.message; } const HEALTH_REASON_TEXT: Record = { logged_in: "首页有登录标志", user_data: "首页没找到登录标志,按用户数据解析成功判为已登录", login_page: "返回登录页", permission_page: "返回权限提示页", challenge: "疑似人机验证", marker_missing: "页面里找不到登录标志", redirect_login: "跳转登录页", http: "HTTP 错误", }; /** 健康检测的原始判定 → 技术信息条目(判定、规则、选择器、页面标题、HTTP 状态) */ export function healthTechItems(h: SiteLastHealth | null): { label: string; value: string; mono?: boolean }[] { const d = h?.detail; if (!d) return []; const str = (k: string) => (typeof d[k] === "string" || typeof d[k] === "number" ? String(d[k]) : ""); const reason = str("reason"); return [ { label: "判定", value: reason ? `${HEALTH_REASON_TEXT[reason] ?? reason}(${reason})` : "" }, { label: "命中规则", value: str("rule"), mono: true }, { label: "登录标志选择器", value: str("selector"), mono: true }, { label: "检测页面", value: str("path"), mono: true }, { label: "页面标题", value: str("title") }, { label: "HTTP 状态", value: str("httpStatus") }, { label: "用户数据时间", value: str("userDataAt") }, ].filter((it) => it.value); } /** * Cloudflare 状态一行(CF-2):没遇到过挑战不显示。 * 需要你验证 → 浏览器代理中,至 hh:mm → 自动通过(最近一次怎么过的)。 */ export function cloudflareLine( config: unknown, now: Date = new Date(), ): { text: string; tone: StatusTone; takeover: boolean } | null { const st = siteCloudflareState(config); if (!st?.lastChallengeAt) return null; if (st.pending === "human") return { text: "Cloudflare:需要你验证", tone: "warning", takeover: true }; if (st.browserUntil && Date.parse(st.browserUntil) > now.getTime()) { const t = new Date(st.browserUntil); const hm = `${String(t.getHours()).padStart(2, "0")}:${String(t.getMinutes()).padStart(2, "0")}`; return { text: `Cloudflare:浏览器代理中,至 ${hm}`, tone: "neutral", takeover: false }; } if (st.lastSolvedBy) return { text: "Cloudflare:自动通过", tone: "success", takeover: false }; if (st.lastError) return { text: "Cloudflare:没能通过", tone: "warning", takeover: false }; return null; } const CF_SOLVED_BY: Record = { auto: "等它自动放行", vision: "视觉子流程点了复选框", human: "你在原站视图里通过", }; const CF_RESULT: Record = { passed: "已通过", reuse_ok: "已通过,HTTP 复用 cf_clearance 有效", reuse_fails: "已通过,HTTP 复用 cf_clearance 无效,改由浏览器代抓", browser: "由浏览器代抓", browser_challenge: "浏览器也被拦下", needs_human: "等你在原站视图里验证", failed: "没能通过", skipped: "没有已登录的账号,未处理", }; /** Cloudflare 原始记录 → 技术信息条目 */ export function cloudflareTechItems(config: unknown): { label: string; value: string; mono?: boolean }[] { const st = siteCloudflareState(config); if (!st?.lastChallengeAt) return []; const t = (v: string | null) => (v ? new Date(v).toLocaleString("zh-CN", { hour12: false }) : ""); return [ { label: "最近遇到挑战", value: t(st.lastChallengeAt) }, { label: "挑战规则", value: st.lastRule ?? "", mono: true }, { label: "处理结果", value: st.lastResult ? `${CF_RESULT[st.lastResult] ?? st.lastResult}(${st.lastResult})` : "" }, { label: "最近通过方式", value: st.lastSolvedBy ? (CF_SOLVED_BY[st.lastSolvedBy] ?? st.lastSolvedBy) : "" }, { label: "最近通过时间", value: t(st.lastSolvedAt) }, { label: "cf_clearance 复用", value: st.cfReuse === "ok" ? "有效(ok)" : st.cfReuse === "fails" ? "无效(fails)" : "", }, { label: "浏览器代理至", value: t(st.browserUntil) }, { label: "最近尝试", value: t(st.lastAttemptAt) }, { label: "最近错误", value: st.lastError ?? "" }, ].filter((it) => it.value); } /** 限流:"20 次/分";空对象 = 默认 */ export function rateLimitText(r: SiteRateLimit | null | undefined): string { if (!r || (!r.requestsPerMinute && !r.minIntervalMs && !r.concurrency)) return "默认"; const parts: string[] = []; if (r.requestsPerMinute) parts.push(`${r.requestsPerMinute} 次/分`); if (r.minIntervalMs) parts.push(`间隔 ${(r.minIntervalMs / 1000).toFixed(1).replace(/\.0$/, "")} 秒`); if (r.concurrency) parts.push(`并发 ${r.concurrency}`); return parts.join(" · "); } /** 登录失败分类文案(先说发生了什么,再说怎么办) */ export function loginFailureText( reason: SiteLoginFailureReason | null, message: string | null, authKind?: SiteAuthKind, ): { text: string; suggestCookie: boolean } { switch (reason) { case "captcha": return { text: "登录失败:站点要求验证码。改用 Cookie,或稍后重试", suggestCookie: true }; case "two_factor": return { text: "登录失败:站点要求二次验证。填写 TOTP 密钥后重试,或改用 Cookie", suggestCookie: true }; case "challenge": return { text: "站点要求人机验证(Cloudflare)。会用浏览器自动过,过不去时在待处理里提醒你", suggestCookie: false, }; case "bad_credentials": return { text: "登录失败:账号或密码错误", suggestCookie: false }; case "account_blocked": return { text: `登录失败:账号被封禁或未确认${message ? `(${message})` : ""}`, suggestCookie: false }; case "auth_expired": return { text: authKind === "api_key" ? "API Key 无效。更新 API Key 后重新刷新登录" : "Cookie 已失效。更新 Cookie 后重新刷新登录", suggestCookie: false, }; case "network": return { text: `连不上站点${message ? `:${message}` : ""}`, suggestCookie: false }; case "unsupported": return { text: message ?? "这个站点不支持该凭证类型的自动登录", suggestCookie: false }; default: return { text: message ?? "登录失败,原因未知", suggestCookie: false }; } } /** 列表排序键 */ export type SortKey = "name" | "ratio" | "upload" | "lastLogin"; export function sortSites(items: SiteListItem[], key: SortKey): SiteListItem[] { const val = (s: SiteListItem): number => { const acc = dataAccount(s.accounts); if (key === "ratio") return acc?.userData ? (acc.userData.download === 0 ? Infinity : (acc.userData.ratio ?? -1)) : -2; if (key === "upload") return acc?.userData?.upload ?? -1; if (key === "lastLogin") { const t = primaryAccount(s.accounts)?.lastLoginAt; return t ? new Date(t).getTime() : -1; } return 0; }; if (key === "name") return [...items].sort((a, b) => a.name.localeCompare(b.name, "zh-CN")); return [...items].sort((a, b) => val(b) - val(a)); } /** 失败原因短标签(登录历史徽标) */ export const REASON_SHORT: Record = { captcha: "需验证码", two_factor: "需二次验证", bad_credentials: "密码错误", account_blocked: "账号封禁", challenge: "Cloudflare 挑战", auth_expired: "凭证失效", network: "网络失败", unsupported: "不支持", unknown: "失败", };