Files
UHD_Media_Tool/apps/web/app/components/sites/site-fields.ts
T

374 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
SITE_KINDS,
type SiteAccount,
type SiteAccountOverall,
type SiteAdapter,
type SiteAuthKind,
type SiteCapability,
type SiteDefinitionView,
type SiteFailureSample,
type SiteFailureSampleSummary,
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);
}
const SAMPLE_KIND: Record<string, string> = { parse_error: "页面结构无法识别", challenge: "人机验证" };
const SAMPLE_REQUEST: Record<string, string> = {
search: "搜索",
list: "列表",
detail: "详情",
health: "健康检测",
other: "请求",
};
/** 失败样本一行:"搜索 · 人机验证 · HTTP 200" */
export function failureSampleTitle(s: Pick<SiteFailureSampleSummary, "kind" | "requestKind" | "httpStatus">): string {
return [
SAMPLE_REQUEST[s.requestKind] ?? s.requestKind,
SAMPLE_KIND[s.kind] ?? s.kind,
s.httpStatus !== null ? `HTTP ${s.httpStatus}` : null,
]
.filter(Boolean)
.join(" · ");
}
/** 失败样本的技术信息条目(地址、响应头、规则、文案) */
export function failureSampleItems(s: SiteFailureSampleSummary): { label: string; value: string; mono?: boolean }[] {
return [
{ label: "地址", value: s.url, mono: true },
{ label: "错误", value: s.message },
{ label: "命中规则", value: s.rule ?? "", mono: true },
...Object.entries(s.headers).map(([k, v]) => ({ label: k, value: v, mono: true })),
{ label: "页面", value: `${formatBytes(s.bodyBytes)}${s.truncated ? "(只留了开头)" : ""}` },
].filter((it) => it.value);
}
/** 下载用的文本:头部是请求信息,后面是脱敏后的页面 */
export function failureSampleText(s: SiteFailureSample): string {
const head = [
`# 站点失败样本(已脱敏)`,
`# 时间:${s.createdAt}`,
`# 类型:${failureSampleTitle(s)}`,
`# 地址:${s.url}`,
...Object.entries(s.headers).map(([k, v]) => `# ${k}: ${v}`),
...(s.rule ? [`# 规则:${s.rule}`] : []),
`# 错误:${s.message}`,
...(s.truncated ? ["# 页面超过 64 KB,只留了开头"] : []),
];
return `${head.join("\n")}\n\n${s.body}`;
}
/** 下载文件名:site-sample-<请求类型>-<类型>-<时间>.html.txt */
export function failureSampleFileName(s: Pick<SiteFailureSample, "kind" | "requestKind" | "createdAt">): string {
const t = s.createdAt.replace(/[-:]/g, "").replace(/\..*$/, "").replace("T", "-");
return `site-sample-${s.requestKind}-${s.kind}-${t}.html.txt`;
}
/** 限流:"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: "失败",
};