320 lines
13 KiB
TypeScript
320 lines
13 KiB
TypeScript
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<SiteAdapter, string> = {
|
|
nexusphp: "NexusPHP",
|
|
mteam_api: "M-Team API",
|
|
api: "站点 API",
|
|
generic_rss: "通用 RSS",
|
|
subtitle: "字幕站",
|
|
};
|
|
|
|
/** 站点类型名(IA-2);旧数据缺 kind 时按 PT 站 */
|
|
export function kindLabel(kind: SiteKind | undefined): string {
|
|
return SITE_KINDS[kind ?? "pt"].label;
|
|
}
|
|
|
|
/** 站点支持某种获取方式(服务端按类型 ∩ 站点定义算好的 capabilities) */
|
|
export function siteCan(site: Pick<SiteListItem, "capabilities">, cap: SiteCapability): boolean {
|
|
return (site.capabilities ?? []).includes(cap);
|
|
}
|
|
|
|
/** 有登录与账号(凭证、账号数据、刷新登录、检测健康)的站点 */
|
|
export function hasLogin(site: Pick<SiteListItem, "capabilities">): boolean {
|
|
return siteCan(site, "login");
|
|
}
|
|
|
|
export const ACCOUNT_OVERALL: Record<SiteAccountOverall, { label: string; tone: StatusTone }> = {
|
|
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<SiteAuthKind, string> = {
|
|
cookie: "Cookie",
|
|
api_key: "API Key",
|
|
password: "账号密码",
|
|
passkey: "Passkey",
|
|
};
|
|
|
|
/** 主凭证三选一(passkey 只用于 RSS / 下载,不参与登录) */
|
|
export type LoginKind = "password" | "cookie" | "api_key";
|
|
|
|
/** 定义支持的登录方式;定义没写 authSupported 时按适配器推断 */
|
|
export function supportedKinds(def: Pick<SiteDefinitionView, "adapter" | "authSupported"> | undefined): LoginKind[] {
|
|
if (!def) return ["cookie", "password", "api_key"];
|
|
const map: Record<string, LoginKind> = {
|
|
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<string, string> = {
|
|
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<string, string> = {
|
|
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<string, string> = {
|
|
auto: "等它自动放行",
|
|
vision: "视觉子流程点了复选框",
|
|
human: "你在原站视图里通过",
|
|
};
|
|
const CF_RESULT: Record<string, string> = {
|
|
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<SiteLoginFailureReason, string> = {
|
|
captcha: "需验证码",
|
|
two_factor: "需二次验证",
|
|
bad_credentials: "密码错误",
|
|
account_blocked: "账号封禁",
|
|
challenge: "Cloudflare 挑战",
|
|
auth_expired: "凭证失效",
|
|
network: "网络失败",
|
|
unsupported: "不支持",
|
|
unknown: "失败",
|
|
};
|