Merge branch 'hh2'
# Conflicts: # apps/server/src/modules/browser/service.ts # apps/web/app/components/browser/desktop-view.tsx # apps/web/app/components/browser/site-origin-view.tsx # packages/contracts/src/browser.ts
This commit is contained in:
commit
f41cd96fee
33 files changed
+1919
-302
No files matched your search
@@ -96,7 +96,10 @@ describe.skipIf(!up)("e2e:登录失效 → 人工接管 → 交还 → Agent 重
|
||||
const fresh = await invoke("browser.snapshot", { siteId: h.siteId });
|
||||
expect(fresh).toMatchObject({ controller: "automation", loginRequired: false, waitingReason: null });
|
||||
expect(fresh.snapshot.text).toContain("种子列表");
|
||||
expect((await h.req(`/browser/sessions/${sid}`)).body.data.needsSnapshot).toBe(false);
|
||||
const afterAgent = (await h.req(`/browser/sessions/${sid}`)).body.data;
|
||||
expect(afterAgent.needsSnapshot).toBe(false);
|
||||
// 原站视图据此显示「Agent 正在操作」;人工动作不刷新它
|
||||
expect(Date.now() - Date.parse(afterAgent.lastAutomationAt)).toBeLessThan(60_000);
|
||||
const dl = await invoke("browser.download", { sessionId: sid, target: { ref: "e11" }, timeoutMs: 2_000 });
|
||||
expect(dl).toMatchObject({
|
||||
sourceReleaseId: h.releaseId,
|
||||
|
||||
@@ -161,6 +161,8 @@ export interface RecoveryState {
|
||||
/** 打开 / 重连时浏览器服务的实例标识;变了 = 浏览器服务重启过 */
|
||||
instanceId?: string | null;
|
||||
reconnect?: BrowserReconnectState;
|
||||
/** 最近一次自动化(Agent 能力)动作成功的时间;原站视图据此区分「Agent 正在操作」与「无人操作」 */
|
||||
lastAutomationAt?: string;
|
||||
}
|
||||
|
||||
/** 同一浏览器服务的实例标识多久查一次 */
|
||||
@@ -674,6 +676,7 @@ export class BrowserService {
|
||||
credential: rs.credential ?? null,
|
||||
needsReconnect: rs.needsReconnect === true,
|
||||
reconnect: rs.reconnect ?? null,
|
||||
lastAutomationAt: rs.lastAutomationAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1263,7 +1266,10 @@ export class BrowserService {
|
||||
throw fromBrowserError(e2);
|
||||
}
|
||||
}
|
||||
if (opts.persist !== false) await this.persist(id, loaded.provider, opts.patch?.(out) ?? {});
|
||||
const automation: RecoveryState = actor.kind === "automation" ? { lastAutomationAt: this.now().toISOString() } : {};
|
||||
if (opts.persist !== false)
|
||||
await this.persist(id, loaded.provider, { ...(opts.patch?.(out) ?? {}), ...automation });
|
||||
else if (automation.lastAutomationAt) await this.patchRecovery(await this.sessionRow(id), automation);
|
||||
this.maybeSyncCookies(row);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { errorBody } from "../../../http/errors.ts";
|
||||
import type { ModuleApp } from "../../types.ts";
|
||||
import { route } from "../http.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { detailScreenshotUrl } from "../service/detail-page.ts";
|
||||
import { detailScreenshotRequest } from "../service/detail-page.ts";
|
||||
import { fetchPublicImage, imageResponse } from "../service/image-proxy.ts";
|
||||
import { listSiteReleases, releaseDetail } from "../service/workbench.ts";
|
||||
|
||||
@@ -53,9 +53,13 @@ export function workbenchRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
return c.json(errorBody("bad_request", "截图地址无效"), 400);
|
||||
}
|
||||
const r = rt();
|
||||
const url = await detailScreenshotUrl(r, rid, index);
|
||||
if (!url) return c.json(errorBody("not_found", "截图不存在或详情缓存已过期"), 404);
|
||||
const got = await fetchPublicImage(r.overrides.fetch ?? fetch, url, { maxBytes: SHOT_MAX_BYTES, label: "截图" });
|
||||
const req = await detailScreenshotRequest(r, rid, index);
|
||||
if (!req) return c.json(errorBody("not_found", "截图不存在或详情缓存已过期"), 404);
|
||||
const got = await fetchPublicImage(r.overrides.fetch ?? fetch, req.url, {
|
||||
maxBytes: SHOT_MAX_BYTES,
|
||||
label: "截图",
|
||||
...(req.referer ? { referer: req.referer } : {}),
|
||||
});
|
||||
if (!got.ok) return c.json(errorBody("upstream_unavailable", got.message), 502);
|
||||
return imageResponse(got, 86_400);
|
||||
});
|
||||
|
||||
@@ -64,6 +64,38 @@ export async function detailScreenshotUrl(rt: SourcesRuntime, srId: string, inde
|
||||
return (await loadStored(rt, srId))?.screenshots[index] ?? null;
|
||||
}
|
||||
|
||||
/** 根域(去 www.、取最后两级):pic.hhanclub.net 与 hhanclub.net 视为同站 */
|
||||
function rootHost(u: string): string | null {
|
||||
try {
|
||||
const host = new URL(u).hostname.toLowerCase().replace(/^www\./, "");
|
||||
return /^[\d.]+$/.test(host) || host.includes(":") ? host : host.split(".").slice(-2).join(".");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 截图代理请求:原始地址 + 站点自家图床(同根域,如 pic.hhanclub.net / proxy-cover.hhanclub.net)要带的 Referer。
|
||||
* 站点 CDN 常做防盗链,只认本站 Referer;外部图床不带 Referer。任何情况下都不带 Cookie。
|
||||
*/
|
||||
export async function detailScreenshotRequest(
|
||||
rt: SourcesRuntime,
|
||||
srId: string,
|
||||
index: number,
|
||||
): Promise<{ url: string; referer?: string } | null> {
|
||||
const url = await detailScreenshotUrl(rt, srId, index);
|
||||
if (!url) return null;
|
||||
const site = await rt.ctx.db
|
||||
.selectFrom("source_release as r")
|
||||
.innerJoin("site as s", "s.id", "r.site_id")
|
||||
.select(["s.base_url"])
|
||||
.where("r.id", "=", srId)
|
||||
.executeTakeFirst();
|
||||
const siteRoot = site ? rootHost(site.base_url) : null;
|
||||
if (siteRoot && rootHost(url) === siteRoot) return { url, referer: `${new URL(site!.base_url).origin}/` };
|
||||
return { url };
|
||||
}
|
||||
|
||||
export async function loadDetailPage(rt: SourcesRuntime, srId: string): Promise<SiteReleasePage | null> {
|
||||
const p = await loadStored(rt, srId);
|
||||
if (!p) return null;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type FeedFetchState,
|
||||
type FeedItem,
|
||||
getSiteDefinition,
|
||||
normalizeSiteFeedItem,
|
||||
} from "@umt/adapters";
|
||||
import type { FeedHealth } from "@umt/contracts";
|
||||
import { type FeedSourceRow, type SiteRow, sql } from "@umt/db";
|
||||
@@ -157,6 +158,9 @@ export async function ingestFeed(
|
||||
|
||||
const url = await secrets.resolve(feed.url_secret_ref);
|
||||
const result = await rt.rss.fetchFeed(url, stateFromRow(feed), { ignoreBackoff: !!opts.manual });
|
||||
// 挂站点的 Feed:每次真正发出的 RSS 请求也记进站点请求计数(熔断 / 退避跳过的不算)
|
||||
if (site && result.kind !== "skipped") rt.countSiteRequest(site.id);
|
||||
if (site) await renormalizeFeedReleases(rt, site).catch(() => {});
|
||||
const now = rt.now();
|
||||
const auth = isAuthFailure(result);
|
||||
const health: FeedHealth = auth ? "failing" : healthOf(result);
|
||||
@@ -175,7 +179,7 @@ export async function ingestFeed(
|
||||
if (result.kind === "ok") {
|
||||
const applied = applyFeedItems(window, result.feed.items);
|
||||
window = applied.cursor;
|
||||
const counts = await writeItems(rt, trx, feed, site?.id ?? null, applied.newItems, applied.updatedItems);
|
||||
const counts = await writeItems(rt, trx, feed, site ?? null, applied.newItems, applied.updatedItems);
|
||||
const unchanged = result.feed.items
|
||||
.filter((i) => !applied.newItems.includes(i) && !applied.updatedItems.includes(i))
|
||||
.map((i) => i.guid);
|
||||
@@ -312,16 +316,36 @@ function observationRaw(item: FeedItem) {
|
||||
};
|
||||
}
|
||||
|
||||
/** 规范化后的 RSS 条目字段(写进 source_release.raw.rss,v 为规范化版本) */
|
||||
export const RSS_NORMALIZE_VERSION = 1;
|
||||
|
||||
function feedFields(siteKey: string, item: Pick<FeedItem, "title" | "size" | "categories">) {
|
||||
const n = normalizeSiteFeedItem(siteKey, item.title);
|
||||
// 分类:标题前缀 → RSS <category>(可能是名称)
|
||||
const catName = n.category.name ?? item.categories[0] ?? null;
|
||||
return {
|
||||
title: n.title,
|
||||
subtitle: n.subtitle,
|
||||
categoryRaw: n.category.id ?? catName,
|
||||
categoryName: catName,
|
||||
mediaType: n.mediaType,
|
||||
// enclosure length 是精确字节数,标题尾部的大小是四舍五入后的显示值
|
||||
sizeBytes: item.size > 0 ? item.size : n.sizeBytes,
|
||||
uploader: n.uploader,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeItems(
|
||||
rt: SourcesRuntime,
|
||||
trx: Exec,
|
||||
feed: FeedSourceRow,
|
||||
siteId: string | null,
|
||||
site: Pick<SiteRow, "id" | "key"> | null,
|
||||
newItems: FeedItem[],
|
||||
updatedItems: FeedItem[],
|
||||
): Promise<{ newReleases: number }> {
|
||||
let newReleases = 0;
|
||||
const updated = new Set(updatedItems);
|
||||
const siteId = site?.id ?? null;
|
||||
for (const item of [...newItems, ...updatedItems]) {
|
||||
const raw = observationRaw(item);
|
||||
const obs = (
|
||||
@@ -337,7 +361,7 @@ async function writeItems(
|
||||
returning id, source_release_id, (select content_digest from prev) as prev_digest`.execute(trx)
|
||||
).rows[0]!;
|
||||
|
||||
if (!siteId) {
|
||||
if (!site || !siteId) {
|
||||
// 无站点的通用 Feed:只留观察,无法按站点资源聚合
|
||||
await trx
|
||||
.updateTable("observation")
|
||||
@@ -349,20 +373,36 @@ async function writeItems(
|
||||
const srid =
|
||||
extractSiteReleaseId(item) ?? `guid:${createHash("sha1").update(item.guid).digest("hex").slice(0, 20)}`;
|
||||
const contentChanged = updated.has(item) || (obs.prev_digest !== null && obs.prev_digest !== item.rawDigest);
|
||||
const sizeBytes = item.size > 0 ? item.size : null;
|
||||
const f = feedFields(site.key, item);
|
||||
const rssRaw = {
|
||||
link: item.link,
|
||||
categories: item.categories,
|
||||
rawTitle: item.title,
|
||||
categoryName: f.categoryName,
|
||||
mediaType: f.mediaType,
|
||||
...(f.uploader ? { uploader: f.uploader } : {}),
|
||||
v: RSS_NORMALIZE_VERSION,
|
||||
};
|
||||
// 与列表快照按站点资源 ID 合并:快照见过(raw._site)的行,标题 / 副标题 / 分类 / 做种 / 促销 / H&R 以快照为准,
|
||||
// RSS 只补空缺;快照没见过的行,内容变化时用 RSS 的新值
|
||||
const rel = (
|
||||
await sql<{ id: string; inserted: boolean; prev_title: string | null; resolution_locked: boolean }>`
|
||||
with prev as (select title from source_release where site_id = ${siteId} and site_release_id = ${srid})
|
||||
insert into source_release (site_id, site_release_id, title, category_raw, size_bytes, source_published_at, raw)
|
||||
values (${siteId}, ${srid}, ${item.title}, ${item.categories[0] ?? null}, ${sizeBytes}, ${item.publishedAt ?? null},
|
||||
${jsonb({ rss: { link: item.link, categories: item.categories } })}::jsonb)
|
||||
insert into source_release (site_id, site_release_id, title, subtitle, category_raw, size_bytes, source_published_at, raw)
|
||||
values (${siteId}, ${srid}, ${f.title}, ${f.subtitle}, ${f.categoryRaw}, ${f.sizeBytes}, ${item.publishedAt ?? null},
|
||||
${jsonb({ rss: rssRaw })}::jsonb)
|
||||
on conflict (site_id, site_release_id) do update set
|
||||
last_seen_at = now(),
|
||||
title = case when ${contentChanged} then excluded.title else source_release.title end,
|
||||
title = case when source_release.raw ? '_site' then source_release.title
|
||||
when ${contentChanged} then excluded.title else source_release.title end,
|
||||
subtitle = case when source_release.raw ? '_site' then coalesce(source_release.subtitle, excluded.subtitle)
|
||||
when ${contentChanged} then coalesce(excluded.subtitle, source_release.subtitle)
|
||||
else coalesce(source_release.subtitle, excluded.subtitle) end,
|
||||
size_bytes = case when ${contentChanged} then coalesce(excluded.size_bytes, source_release.size_bytes)
|
||||
else coalesce(source_release.size_bytes, excluded.size_bytes) end,
|
||||
source_published_at = coalesce(source_release.source_published_at, excluded.source_published_at),
|
||||
category_raw = coalesce(source_release.category_raw, excluded.category_raw)
|
||||
category_raw = coalesce(source_release.category_raw, excluded.category_raw),
|
||||
raw = source_release.raw || excluded.raw
|
||||
returning id, (xmax = 0) as inserted, (select title from prev) as prev_title, resolution_locked`.execute(trx)
|
||||
).rows[0]!;
|
||||
await trx
|
||||
@@ -379,7 +419,7 @@ async function writeItems(
|
||||
{ sourceReleaseId: rel.id, observationId: obs.id, siteId, isNew: true },
|
||||
{ idempotencyKey: `release.observed:${rel.id}` },
|
||||
);
|
||||
} else if (contentChanged && rel.prev_title !== item.title && !rel.resolution_locked) {
|
||||
} else if (contentChanged && rel.prev_title !== f.title && !rel.resolution_locked) {
|
||||
// 标题修正:按新标题重新识别(证据版本变化)
|
||||
await rt.ctx.outbox.emit(
|
||||
trx,
|
||||
@@ -391,3 +431,50 @@ async function writeItems(
|
||||
}
|
||||
return { newReleases };
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧 RSS 行补规范化:规范化上线前写进来的站点 RSS 行(raw.rss 没有 v,标题还是 `[分类]…[大小]` 整串),
|
||||
* 在该站 Feed 下次抓取时按原标题重算一次(每次最多 500 行,不请求站点)。快照见过的行不动。
|
||||
* 标题变了且未锁定的,重新发 release.observed 让身份解析按干净标题再识别。
|
||||
*/
|
||||
export async function renormalizeFeedReleases(rt: SourcesRuntime, site: Pick<SiteRow, "id" | "key">): Promise<number> {
|
||||
const rows = await rt.ctx.db
|
||||
.selectFrom("source_release")
|
||||
.select(["id", "title", "subtitle", "category_raw", "size_bytes", "raw", "resolution_locked"])
|
||||
.where("site_id", "=", site.id)
|
||||
.where(sql<boolean>`raw ? 'rss'`)
|
||||
.where(sql<boolean>`not (raw ? '_site')`)
|
||||
.where(sql<boolean>`coalesce((raw->'rss'->>'v')::int, 0) < ${RSS_NORMALIZE_VERSION}`)
|
||||
.limit(500)
|
||||
.execute();
|
||||
if (!rows.length) return 0;
|
||||
await rt.ctx.db.transaction().execute(async (trx) => {
|
||||
for (const r of rows) {
|
||||
const rss = ((r.raw ?? {}) as { rss?: Record<string, unknown> }).rss ?? {};
|
||||
const rawTitle = typeof rss.rawTitle === "string" ? rss.rawTitle : r.title;
|
||||
const categories = Array.isArray(rss.categories) ? (rss.categories as string[]) : [];
|
||||
const f = feedFields(site.key, { title: rawTitle, size: Number(r.size_bytes ?? 0), categories });
|
||||
const nextRss = {
|
||||
...rss,
|
||||
rawTitle,
|
||||
categoryName: f.categoryName,
|
||||
mediaType: f.mediaType,
|
||||
...(f.uploader ? { uploader: f.uploader } : {}),
|
||||
v: RSS_NORMALIZE_VERSION,
|
||||
};
|
||||
await sql`update source_release set title = ${f.title}, subtitle = coalesce(subtitle, ${f.subtitle}),
|
||||
category_raw = coalesce(${f.categoryRaw}, category_raw), size_bytes = coalesce(size_bytes, ${f.sizeBytes}),
|
||||
raw = raw || jsonb_build_object('rss', ${JSON.stringify(nextRss)}::jsonb)
|
||||
where id = ${r.id}`.execute(trx);
|
||||
if (f.title !== r.title && !r.resolution_locked) {
|
||||
await rt.ctx.outbox.emit(
|
||||
trx,
|
||||
"release.observed",
|
||||
{ sourceReleaseId: r.id, observationId: null, siteId: site.id, isNew: false },
|
||||
{ idempotencyKey: `release.observed:${r.id}:rss-norm-${RSS_NORMALIZE_VERSION}` },
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
return rows.length;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { SiteRelease } from "@umt/adapters";
|
||||
import { createMockRssServer, type MockFeedItem, type MockRssServer } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { ingestFeed } from "../../src/modules/sources-identity/service/feeds.ts";
|
||||
import { upsertSiteRelease } from "../../src/modules/sources-identity/service/snapshot.ts";
|
||||
import { listSiteReleases } from "../../src/modules/sources-identity/service/workbench.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedFeed, seedSite } from "./harness.ts";
|
||||
|
||||
// HH-2:憨憨 RSS 条目规范化(`[分类]种子名[副标题][大小]`)、与列表快照合并、旧行补规范化、RSS 请求计数
|
||||
const up = await pgAvailable();
|
||||
const BASE = "https://hhanclub.example";
|
||||
|
||||
const item = (id: string, title: string, pubDate: string, extra: Partial<MockFeedItem> = {}): MockFeedItem => ({
|
||||
guid: `hh-${id}`,
|
||||
title,
|
||||
link: `${BASE}/details.php?id=${id}`,
|
||||
enclosureUrl: `${BASE}/download.php?id=${id}&passkey=PK`,
|
||||
size: 731525231,
|
||||
pubDate: new Date(pubDate),
|
||||
categories: [],
|
||||
...extra,
|
||||
});
|
||||
|
||||
const snapshotRelease = (id: string, title: string, uploadedAt: string): SiteRelease => ({
|
||||
siteId: "hhanclub",
|
||||
siteReleaseId: id,
|
||||
title,
|
||||
subtitle: "快照副标题",
|
||||
category: { id: "402", name: "电视剧" },
|
||||
sizeBytes: 1024,
|
||||
seeders: 22,
|
||||
leechers: 66,
|
||||
snatched: 5,
|
||||
promotion: { type: "free", downloadFactor: 0, uploadFactor: 1, expiresAt: null },
|
||||
hr: false,
|
||||
uploadedAt,
|
||||
detailUrl: `${BASE}/details.php?id=${id}`,
|
||||
tags: [],
|
||||
raw: {},
|
||||
observedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
describe.skipIf(!up)("站点 RSS 条目规范化(HH-2)", () => {
|
||||
let h: Harness;
|
||||
let siteId: string;
|
||||
const servers: MockRssServer[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createHarness("rssnorm");
|
||||
siteId = (await seedSite(h, { key: "hhanclub", baseUrl: `${BASE}/`, cookie: "c=1" })).site.id;
|
||||
});
|
||||
afterAll(async () => {
|
||||
for (const s of servers) await s.close();
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
const feed = async (items: MockFeedItem[]) => {
|
||||
const s = await createMockRssServer({ items, passkey: "PK" });
|
||||
servers.push(s);
|
||||
return { server: s, row: await seedFeed(h, { siteId, url: s.feedUrl, name: "憨憨 RSS" }) };
|
||||
};
|
||||
const rel = (id: string) =>
|
||||
h.db
|
||||
.selectFrom("source_release")
|
||||
.selectAll()
|
||||
.where("site_id", "=", siteId)
|
||||
.where("site_release_id", "=", id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
it("拆出分类 / 副标题 / 干净标题;大小取 enclosure 精确值;原始标题留在 raw.rss;请求计入站点计数", async () => {
|
||||
const { row } = await feed([
|
||||
item(
|
||||
"219390",
|
||||
"[音乐]Travis - L.A. Times (2024) - WEB-DL - 24bit ALAC-HHWEB[Travis - L.A. Times | ALAC | 苹果音乐][697.64 MB]",
|
||||
"2026-09-29T08:51:01Z",
|
||||
),
|
||||
item("219391", "[纪录片]Some Doc 2026 2160p WEB-DL-HHWEB[新][某纪录片][1.5 GB]", "2026-09-29T08:10:00Z", {
|
||||
size: 0,
|
||||
}),
|
||||
]);
|
||||
expect((await ingestFeed(h.rt, row.id)).status).toBe("ok");
|
||||
const r = await rel("219390");
|
||||
expect(r).toMatchObject({
|
||||
title: "Travis - L.A. Times (2024) - WEB-DL - 24bit ALAC-HHWEB",
|
||||
subtitle: "Travis - L.A. Times | ALAC | 苹果音乐",
|
||||
category_raw: "410",
|
||||
});
|
||||
expect(Number(r.size_bytes)).toBe(731525231);
|
||||
expect((r.raw as { rss: Record<string, unknown> }).rss).toMatchObject({
|
||||
categoryName: "音乐",
|
||||
mediaType: "music",
|
||||
rawTitle: expect.stringContaining("[697.64 MB]"),
|
||||
v: 1,
|
||||
});
|
||||
const doc = await rel("219391");
|
||||
expect(doc).toMatchObject({ title: "Some Doc 2026 2160p WEB-DL-HHWEB", subtitle: "某纪录片", category_raw: "404" });
|
||||
expect(Number(doc.size_bytes)).toBe(Math.round(1.5 * 1024 ** 3));
|
||||
|
||||
// 计数是异步写的,等一下
|
||||
let n = 0;
|
||||
for (let i = 0; i < 50 && n === 0; i++) {
|
||||
n = await h.rt.siteRequestCount(siteId);
|
||||
if (!n) await new Promise((res) => setTimeout(res, 20));
|
||||
}
|
||||
expect(n).toBe(1);
|
||||
});
|
||||
|
||||
it("与列表快照按站点资源 ID 合并:快照补做种 / 促销 / H&R,之后 RSS 更新不再覆盖快照标题", async () => {
|
||||
const { server, row } = await feed([
|
||||
item("219380", "[电视剧]Show S01 2026 1080p WEB-DL-HHWEB[示例剧 | 第1集][1.2 GB]", "2026-09-29T07:00:00Z"),
|
||||
]);
|
||||
await ingestFeed(h.rt, row.id);
|
||||
await h.db
|
||||
.transaction()
|
||||
.execute((trx) =>
|
||||
upsertSiteRelease(
|
||||
h.rt,
|
||||
trx,
|
||||
{ id: siteId },
|
||||
snapshotRelease("219380", "Show S01 2026 1080p WEB-DL-HHWEB", "2026-09-29T07:00:00Z"),
|
||||
),
|
||||
);
|
||||
let r = await rel("219380");
|
||||
expect(r).toMatchObject({ seeders: 22, leechers: 66, subtitle: "快照副标题", category_raw: "402" });
|
||||
expect((r.raw as { _site: { hr: boolean } })._site.hr).toBe(false);
|
||||
expect((r.raw as { rss: { categoryName: string } }).rss.categoryName).toBe("电视剧");
|
||||
|
||||
server.updateItem("hh-219380", { title: "[电视剧]Show S01 2026 1080p WEB-DL-HHWEB[示例剧 | 第1-2集][2.4 GB]" });
|
||||
await ingestFeed(h.rt, row.id, { manual: true });
|
||||
r = await rel("219380");
|
||||
expect(r.title).toBe("Show S01 2026 1080p WEB-DL-HHWEB");
|
||||
expect(r.seeders).toBe(22);
|
||||
});
|
||||
|
||||
it("规范化前写进来的旧 RSS 行:下次抓取时按原标题补规范化并重新触发识别", async () => {
|
||||
const dirty = "[音乐]Old Album (2020) - WEB-DL - 16bit ALAC-HHWEB[Old Album | ALAC][120.5 MB]";
|
||||
const old = await h.db
|
||||
.insertInto("source_release")
|
||||
.values({
|
||||
site_id: siteId,
|
||||
site_release_id: "219001",
|
||||
title: dirty,
|
||||
size_bytes: 126353408,
|
||||
raw: JSON.stringify({ rss: { link: `${BASE}/details.php?id=219001`, categories: [] } }),
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
const { row } = await feed([
|
||||
item("219002", "[电影]New Movie 2026 1080p-HHWEB[新片][4 GB]", "2026-09-29T09:00:00Z"),
|
||||
]);
|
||||
await ingestFeed(h.rt, row.id);
|
||||
const r = await rel("219001");
|
||||
expect(r).toMatchObject({
|
||||
title: "Old Album (2020) - WEB-DL - 16bit ALAC-HHWEB",
|
||||
subtitle: "Old Album | ALAC",
|
||||
category_raw: "410",
|
||||
});
|
||||
expect((r.raw as { rss: { rawTitle: string } }).rss.rawTitle).toBe(dirty);
|
||||
const events = (await h.outbox("release.observed")).filter((e) => e.payload.sourceReleaseId === old.id);
|
||||
expect(events.map((e) => e.payload.isNew)).toEqual([false]);
|
||||
});
|
||||
|
||||
it("资源列表按发布时间排序:缺做种 / H&R 的 RSS 条目不会因此排到前面", async () => {
|
||||
await h.db
|
||||
.transaction()
|
||||
.execute((trx) =>
|
||||
upsertSiteRelease(
|
||||
h.rt,
|
||||
trx,
|
||||
{ id: siteId },
|
||||
snapshotRelease("219500", "Newest Snapshot 2026-HHWEB", "2026-09-29T10:00:00Z"),
|
||||
),
|
||||
);
|
||||
const list = await listSiteReleases(h.rt, siteId, { limit: 50, live: "false" });
|
||||
const ids = list.items.map((i) => i.siteReleaseId);
|
||||
expect(ids[0]).toBe("219500");
|
||||
expect(ids.indexOf("219390")).toBeLessThan(ids.indexOf("219391"));
|
||||
const times = list.items.map((i) => Date.parse(i.sourcePublishedAt ?? i.firstSeenAt));
|
||||
expect([...times].sort((a, b) => b - a)).toEqual(times);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ export function planTodoItems(steps: AgentPlanStep[]): TodoItem[] {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
status: STEP_STATUS[s.status],
|
||||
...(s.note ? { detail: <span className="block max-w-48 truncate">{s.note}</span> } : {}),
|
||||
...(s.note ? { detail: <span title={s.note}>{s.note}</span> } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 改造:SVG 状态图标保留几何(虚线圈=待办、弧线=进行中、勾=完成、叉=取消),pathLength 弹簧动画换成
|
||||
// CSS 过渡/animate-spin(motion-reduce 关闭);完成色 emerald → success,取消 rose → destructive,且都配 sr-only 文字;
|
||||
// 表头完成数去掉滚字;折叠用 Disclosure;maxHeight 改成 Tailwind 类;新增 `error` 状态(工具失败)、
|
||||
// `skipped` 状态(Agent 步骤清单里跳过的步骤)与 `detail` 槽。
|
||||
// `skipped` 状态(Agent 步骤清单里跳过的步骤)与 `detail` 槽(可压缩,标题至少留一半宽度)。
|
||||
import { ChevronDown, ListTodo } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
@@ -162,8 +162,10 @@ export function TodoList({
|
||||
<StatusIcon status={s} />
|
||||
<span className="sr-only">{LABEL[s]}:</span>
|
||||
<span
|
||||
title={typeof item.title === "string" ? item.title : undefined}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-caption leading-5",
|
||||
// 标题至少占一半宽度:备注(detail)再长也只能压缩自己,不能把标题挤成一条竖线
|
||||
"min-w-[50%] flex-1 truncate text-caption leading-5",
|
||||
s === "pending" && "text-muted-foreground",
|
||||
s === "in-progress" && "text-foreground",
|
||||
s === "completed" && "text-muted-foreground line-through decoration-foreground/30",
|
||||
@@ -174,7 +176,7 @@ export function TodoList({
|
||||
{item.title}
|
||||
</span>
|
||||
{item.detail ? (
|
||||
<span className="shrink-0 text-meta text-muted-foreground">{item.detail}</span>
|
||||
<span className="min-w-0 shrink truncate text-meta text-muted-foreground">{item.detail}</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { holderName, toViewport, waitingText } from "./coords.ts";
|
||||
import { agentActive, controllerLabel, holderName, toViewport, waitingText } from "./coords.ts";
|
||||
|
||||
describe("原站视图坐标换算", () => {
|
||||
const natural = { width: 1280, height: 800 };
|
||||
@@ -29,3 +29,18 @@ describe("原站视图坐标换算", () => {
|
||||
expect(holderName("admin_session:alice")).toBe("alice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("当前控制文案", () => {
|
||||
const now = Date.parse("2026-09-29T10:00:00Z");
|
||||
it("自动化且没有最近动作 → 「自动化」;两分钟内有自动化动作 → 「Agent」", () => {
|
||||
expect(controllerLabel({ controller: "automation", lastAutomationAt: null }, now)).toBe("自动化");
|
||||
expect(controllerLabel({ controller: "automation" }, now)).toBe("自动化");
|
||||
expect(controllerLabel({ controller: "automation", lastAutomationAt: "2026-09-29T09:50:00Z" }, now)).toBe("自动化");
|
||||
expect(controllerLabel({ controller: "automation", lastAutomationAt: "2026-09-29T09:59:10Z" }, now)).toBe("Agent");
|
||||
expect(agentActive({ controller: "automation", lastAutomationAt: "2026-09-29T09:59:10Z" }, now)).toBe(true);
|
||||
});
|
||||
it("人工接管 / 等待处理不看自动化时间", () => {
|
||||
expect(controllerLabel({ controller: "human", lastAutomationAt: "2026-09-29T09:59:50Z" }, now)).toBe("人工接管");
|
||||
expect(controllerLabel({ controller: "waiting" }, now)).toBe("等待处理");
|
||||
});
|
||||
});
|
||||
@@ -17,12 +17,33 @@ export function toViewport(
|
||||
}
|
||||
|
||||
export const CONTROLLER_LABEL: Record<BrowserController, string> = {
|
||||
automation: "Agent",
|
||||
automation: "自动化",
|
||||
human: "人工接管",
|
||||
waiting: "等待处理",
|
||||
closed: "已关闭",
|
||||
};
|
||||
|
||||
/** 自动化动作在这段时间内发生过,算「Agent 正在操作」 */
|
||||
export const AGENT_ACTIVE_MS = 2 * 60_000;
|
||||
|
||||
/** Agent 是否正在用这个会话:控制者是自动化,且最近 AGENT_ACTIVE_MS 内有过自动化动作 */
|
||||
export function agentActive(
|
||||
s: { controller: BrowserController; lastAutomationAt?: string | null },
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
if (s.controller !== "automation" || !s.lastAutomationAt) return false;
|
||||
const t = new Date(s.lastAutomationAt).getTime();
|
||||
return Number.isFinite(t) && now - t < AGENT_ACTIVE_MS;
|
||||
}
|
||||
|
||||
/** 「当前控制」文案:无人操作时是「自动化」,Agent 正在操作才显示「Agent」 */
|
||||
export function controllerLabel(
|
||||
s: { controller: BrowserController; lastAutomationAt?: string | null },
|
||||
now = Date.now(),
|
||||
): string {
|
||||
return agentActive(s, now) ? "Agent" : CONTROLLER_LABEL[s.controller];
|
||||
}
|
||||
|
||||
const WAITING_TEXT: Record<string, string> = {
|
||||
captcha: "登录页需要验证码",
|
||||
challenge: "页面需要人工验证",
|
||||
|
||||
@@ -9,7 +9,7 @@ import { StatusBadge, type StatusTone } from "~/components/console/status-badge.
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { apiData, errorMessage } from "~/lib/api.ts";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { CONTROLLER_LABEL, credentialBadge, holderName, waitingText } from "./coords.ts";
|
||||
import { agentActive, controllerLabel, credentialBadge, holderName, waitingText } from "./coords.ts";
|
||||
import { ReconnectPanel, ReloadNotice, useAutoReconnect } from "./reconnect.tsx";
|
||||
import { SiteOriginView } from "./site-origin-view.tsx";
|
||||
|
||||
@@ -153,7 +153,7 @@ function DesktopView({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge tone={conn.tone}>{conn.text}</StatusBadge>
|
||||
<StatusBadge tone={CONTROLLER_TONE[s.controller] ?? "neutral"}>
|
||||
当前控制:{CONTROLLER_LABEL[s.controller]}
|
||||
当前控制:{controllerLabel(s)}
|
||||
{human && leaseHolder ? `(${leaseHolder})` : ""}
|
||||
</StatusBadge>
|
||||
{waiting ? <StatusBadge tone="warning">{waiting}</StatusBadge> : null}
|
||||
@@ -217,7 +217,9 @@ function DesktopView({
|
||||
<p className="text-caption text-muted-foreground">
|
||||
{mine
|
||||
? "直接在画面里点击和输入。中文、日文用本机输入法输入;复制粘贴用 Ctrl+C / Ctrl+V。"
|
||||
: "只读观看,Agent 可能正在操作。需要自己点或输入时先接管。"}
|
||||
: agentActive(s)
|
||||
? "只读观看,Agent 正在操作。需要自己点或输入时先接管,Agent 会暂停。"
|
||||
: "只读观看,目前没有 Agent 在操作。需要自己点或输入时先接管。"}
|
||||
</p>
|
||||
|
||||
<div className="pg-slab relative min-w-0 overflow-hidden rounded-xl">
|
||||
|
||||
@@ -34,7 +34,7 @@ import { StatusBadge, type StatusTone } from "~/components/console/status-badge.
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { CONTROLLER_LABEL, credentialBadge, holderName, toViewport, waitingText } from "./coords.ts";
|
||||
import { controllerLabel, credentialBadge, holderName, toViewport, waitingText } from "./coords.ts";
|
||||
import { isBrowserGone, ReconnectPanel, ReloadNotice, useAutoReconnect } from "./reconnect.tsx";
|
||||
|
||||
// 原站视图(REQ §4.7.1、§4.7.3):同一个 camofox 会话的定时截图 + 人工输入映射。
|
||||
@@ -156,7 +156,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge tone={conn.tone}>{conn.text}</StatusBadge>
|
||||
<StatusBadge tone={CONTROLLER_TONE[s.controller] ?? "neutral"}>
|
||||
当前控制:{CONTROLLER_LABEL[s.controller]}
|
||||
当前控制:{controllerLabel(s)}
|
||||
{human && leaseHolder ? `(${leaseHolder})` : ""}
|
||||
</StatusBadge>
|
||||
{waiting ? <StatusBadge tone="warning">{waiting}</StatusBadge> : null}
|
||||
|
||||
@@ -390,9 +390,9 @@ function ReleaseRow({ r, onDownload }: { r: SearchRelease; onDownload: () => voi
|
||||
<span>{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}</span>
|
||||
<span className="inline-flex items-center gap-0.5" title="做种 / 下载">
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
{r.seeders ?? "?"}
|
||||
{r.seeders ?? "—"}
|
||||
<ArrowDown className="ml-1 h-3 w-3" />
|
||||
{r.leechers ?? "?"}
|
||||
{r.leechers ?? "—"}
|
||||
</span>
|
||||
<span title="站点发布时间">发布 {relTime(r.uploadedAt)}</span>
|
||||
{tech ? <span className="break-all">{tech}</span> : null}
|
||||
|
||||
@@ -217,7 +217,7 @@ export function ReleaseDetailSheet({
|
||||
{ label: "促销截止", value: formatDateTime(detail.data.volatile.promotionExpiresAt) },
|
||||
{
|
||||
label: "做种 / 下载",
|
||||
value: `${r.seeders ?? "?"} / ${r.leechers ?? "?"}(${relTime(detail.data.volatile.peersObservedAt)}观察)`,
|
||||
value: `${r.seeders ?? "—"} / ${r.leechers ?? "—"}(${relTime(detail.data.volatile.peersObservedAt)}观察)`,
|
||||
},
|
||||
{ label: "详情抓取于", value: formatDateTime(detail.data.volatile.detailFetchedAt) },
|
||||
]}
|
||||
|
||||
@@ -16,9 +16,23 @@ export function hrOf(r: Pick<SourceRelease, "raw">): "yes" | "no" | "unknown" {
|
||||
return hr === true ? "yes" : hr === false ? "no" : "unknown";
|
||||
}
|
||||
|
||||
/** 站点 RSS 规范化后的字段(raw.rss):分类名、媒体大类 */
|
||||
function rssExtra(r: Pick<SourceRelease, "raw">): { categoryName?: unknown } {
|
||||
const s = (r.raw as { rss?: { categoryName?: unknown } }).rss;
|
||||
return s && typeof s === "object" ? s : {};
|
||||
}
|
||||
|
||||
/** 分类名:列表快照的站点分类名 → RSS 标题前缀里的分类名 → 站点分类 ID */
|
||||
export function categoryLabel(r: Pick<SourceRelease, "raw" | "categoryRaw">): string | null {
|
||||
const name = siteExtra(r).categoryName;
|
||||
return typeof name === "string" && name ? name : r.categoryRaw;
|
||||
if (typeof name === "string" && name) return name;
|
||||
const rss = rssExtra(r).categoryName;
|
||||
return typeof rss === "string" && rss ? rss : r.categoryRaw;
|
||||
}
|
||||
|
||||
/** 列表里的数字:未知显示「—」(未知 ≠ 0) */
|
||||
export function countText(n: number | null | undefined): string {
|
||||
return typeof n === "number" && Number.isFinite(n) ? String(n) : "—";
|
||||
}
|
||||
|
||||
const PROMO_LABEL: Record<string, string> = {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
import {
|
||||
absTime,
|
||||
categoryLabel,
|
||||
countText,
|
||||
hrOf,
|
||||
ORIGIN_LABEL,
|
||||
promotionLabel,
|
||||
@@ -15,8 +16,11 @@ import {
|
||||
snatchedOf,
|
||||
} from "./release-fields.ts";
|
||||
|
||||
/** 促销 / H&R 徽章(列表与详情共用):促销带剩余时间,过期的写明 */
|
||||
export function ReleaseBadges({ r }: { r: WorkbenchRelease }) {
|
||||
/**
|
||||
* 促销 / H&R 徽章(列表与详情共用):促销带剩余时间,过期的写明。
|
||||
* H&R 未知只在 showUnknownHr 时标出(详情里);列表行不为未知字段挂徽章,避免 RSS 条目满屏「未知」。
|
||||
*/
|
||||
export function ReleaseBadges({ r, showUnknownHr = false }: { r: WorkbenchRelease; showUnknownHr?: boolean }) {
|
||||
const promo = promotionLabel(r);
|
||||
const hr = hrOf(r);
|
||||
const left = promo && !promo.expired ? remainingLabel(r.promotionExpiresAt) : null;
|
||||
@@ -31,7 +35,7 @@ export function ReleaseBadges({ r }: { r: WorkbenchRelease }) {
|
||||
</span>
|
||||
) : null}
|
||||
{hr === "yes" ? <StatusBadge tone="warning">H&R</StatusBadge> : null}
|
||||
{hr === "unknown" ? <StatusBadge>H&R 未知</StatusBadge> : null}
|
||||
{hr === "unknown" && showUnknownHr ? <StatusBadge>H&R 未知</StatusBadge> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -159,19 +163,17 @@ export function ReleaseRow({
|
||||
{r.subtitle ?? ""}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 tabular-nums sm:shrink-0 sm:flex-nowrap sm:gap-x-4">
|
||||
<span className="sm:w-14 sm:truncate" title={r.categoryRaw ? `站点分类 ${r.categoryRaw}` : undefined}>
|
||||
{cat ?? "分类未知"}
|
||||
</span>
|
||||
<span className="sm:w-16 sm:text-right">
|
||||
{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}
|
||||
<span className="sm:w-14 sm:truncate" title={r.categoryRaw ? `站点分类 ${r.categoryRaw}` : "分类未知"}>
|
||||
{cat ?? "—"}
|
||||
</span>
|
||||
<span className="sm:w-16 sm:text-right">{r.sizeBytes === null ? "—" : formatBytes(r.sizeBytes, 1)}</span>
|
||||
<span className="inline-flex items-center gap-0.5 sm:w-32" title="做种 / 下载 / 完成">
|
||||
<ArrowUp className="h-3 w-3" aria-label="做种" />
|
||||
<span className={cn(r.seeders === 0 && "text-warning")}>{r.seeders ?? "?"}</span>
|
||||
<span className={cn(r.seeders === 0 && "text-warning")}>{countText(r.seeders)}</span>
|
||||
<ArrowDown className="ml-1.5 h-3 w-3" aria-label="下载" />
|
||||
{r.leechers ?? "?"}
|
||||
{countText(r.leechers)}
|
||||
<Check className="ml-1.5 h-3 w-3" aria-label="完成" />
|
||||
{snatched ?? "?"}
|
||||
{countText(snatched)}
|
||||
</span>
|
||||
<span className="sm:w-20 sm:text-right" title={timeTitle}>
|
||||
{relTime(r.sourcePublishedAt)}
|
||||
|
||||
Reference in new issue
Block a user