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:
root committed 2026-09-29 17:43:00 +08:00
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,
+7 -1
View File
@@ -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;
}