Files
UHD_Media_Tool/apps/web/app/components/sites/site-fields.ts
T
rootandClaude Opus 5.5 b1d30bd6ee fix(sites): 观众健康检查误报未登录,健康失败原因细分(HC-1)
观众 2026 版首页没有 <a href=logout>:退出按钮在 userbar 的 data-uploader-actions JSON
里由脚本渲染,旧登录标志 a[href*="logout"] 永远不命中,健康检查报"未登录"而用户数据正常。

- audiences 定义 loginSuccess 改为 #info_block / usercp.php / logout 任一(0.2.1)
- 健康检查判定细分:登录页 / 权限页 / 疑似人机验证 / 找不到登录标志(带选择器),
  原始判定写入 last_health.detail,站点概览技术信息展示
- 首页只是找不到登录标志时,以 24 小时内用户数据解析成功为已登录证据(同 MP)
- 新 SiteError 类 challenge(cf-mitigated: challenge),不算凭据错误;健康状态 challenge
- testkit mock 增加 setHeaderLayout;加观众首页脱敏结构样本的差分测试

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 20:38:48 +08:00

257 lines
10 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,
} 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",
};
/** 站点类型名(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);
}
/** 限流:"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 挑战页,无法自动登录。改用 Cookie", suggestCookie: true };
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: "失败",
};