Compare commits

..

No commits in common. "4800a375eea08369eebde5d9e86785761c625bf6" and "29cea8b0aa4e56c822be60a017bfa2d23df6dd4b" have entirely different histories.

17 changed files with 62 additions and 3056 deletions

101
TODO.md
View File

@ -1,101 +0,0 @@
# TODO
- 更新时间2026-04-02
- 进度基线2026-04-02 已完成一次 MVP 收敛;`npm run test``npm run typecheck` 通过Web/API 实机流程已验证JD 实时会话导入与 `search/detail/reviews` preview 已实机验证
- 关联文档:
- `docs/tasks.md`
- `docs/DevelopmentPlan.md`
- `docs/tdd.md`
## 维护约定
- 已完成任务统一使用 `- [x]`
- 未完成任务统一使用 `- [ ]`
- 进行中任务使用 `- [ ] ...(进行中)`
- 阻塞任务使用 `- [ ] ...(阻塞:原因)`
- 任务编号必须与 `docs/tasks.md` 对齐;若任务拆分、合并或改号,两个文件必须同步更新
## 当前主线
- [x] `S1-06` 会话中心 v1 与全局会话准备后端入口落地MVP mock 版,支持 24h 会话、清理与回跳)
- [x] `S1-07` 新建任务页与全局会话准备入口落地
- [x] `S2-01` 首个平台预检查与搜索适配器落地MVP mock 版)
- [x] `S2-05` 标准化 v1 与最小报告快照落地(规则版)
- [ ] `S2-06` 单平台执行页闭环与回归包落地(进行中:闭环已可演示,回归包与真实异步执行待补)
- [ ] `S3-01` 第二平台 `precheck/search/detail/reviews` 适配器落地(进行中:当前双平台仍以 mock 适配为主)
- [ ] `S3-03` 阻塞恢复与 `L3 Browser Recovery` 落地(进行中:恢复页与重试链路已通,真实远程浏览器接管待补)
- [ ] `S4-02` AI 结构化报告生成与版本规则落地(进行中:版本规则已落地,真实 AI 生成待接入)
- [ ] `S4-05` 留存、删除 API 与联动清理链路落地(进行中:删除 API 与 30/90 天本地清理作业已落地,对象存储联动待补)
- [ ] `S4-06` 完整可观测性与审计日志落地进行中overview / audit 已有,完整指标体系待补)
## 阶段快照
- [ ] `S0` 双平台能力矩阵、fixture/HAR、PoC 验证与 `strategy_attempts` 口径仍未冻结(进行中)
- [ ] `S1` 本地 JSON 持久化、API/BFF、会话准备、新建任务页与状态机骨架已可用但数据库、队列、真实 `SSE` 仍未完成(进行中)
- [ ] `S2` 单平台最小闭环和最小报告已可演示JD `search/detail/reviews` 实时 preview 已验证,但任务执行与标准化主链仍以 mock 数据为主(进行中)
- [ ] `S3` 双平台候选确认、执行控制台、恢复页与平台级重试已可用,但第二平台真实适配、`L2` 模板刷新与真实 `L3` 恢复未完成(进行中)
- [ ] `S4` 报告版本规则、报告页、历史任务页、版本切换、删除入口与观测概览已落地,但完整聚合、真实 AI、对象存储联动与完整审计仍未完成进行中
- [ ] `S5` 稳定性、性能、UAT、部署与发布准备尚未进入实施未开始
## `S0`
- [ ] `S0-01` 冻结双平台能力矩阵(未开始)
- [ ] `S0-02` 产出双平台首批 fixture 与 HAR 样本(未开始)
- [ ] `S0-03` 验证服务端受控浏览器与会话快照 PoC进行中
- [ ] `S0-04` 验证至少一个平台的非浏览器主路径 PoC进行中JD 已完成授权会话下 `search/detail/reviews` 实时 API 预览验证,待补模板刷新与量化口径)
- [x] `S0-05` 搭建 Monorepo 与基础工程骨架
- [ ] `S0-06` 冻结 Phase 0 量化评分表、`strategy_attempts` 记录格式与进入开发门槛(未开始)
## `S1`
- [x] `S1-01` 共享领域模型与枚举包落地
- [ ] `S1-02` 数据库、事件日志与对象存储模型落地进行中MVP 先落本地 JSON 持久化,正式数据库与对象存储待补)
- [ ] `S1-03` 任务编排、事件持久化与状态机骨架落地(进行中:状态机、事件日志、平台级重试已可用,队列化执行待补)
- [ ] `S1-04` API / BFF、平台就绪摘要与 `SSE` 基础接口落地进行中REST/BFF 已可用,`SSE` 仍是最小 snapshot 形态)
- [x] `S1-05` Web 工作台基础壳层与核心路由落地
- [x] `S1-06` 会话中心 v1 与全局会话准备后端入口落地MVP mock 版)
- [x] `S1-07` 新建任务页与全局会话准备入口落地
- [ ] `S1-08` TDD 与 CI 基础链路落地(进行中)
## `S2`
- [x] `S2-01` 首个平台预检查与搜索适配器落地MVP mock 版)
- [x] `S2-02` 候选确认页与确认 API 落地
- [ ] `S2-03` 单平台商品详情抓取链路落地进行中JD live detail preview 已接入真实 `pc_detailpage_wareBusiness`,会话导入与解析已验证,待纳入任务执行与标准化主链)
- [ ] `S2-04` 单平台评论采集与抽样链路落地进行中JD live reviews preview 已接入真实 `getLegoWareDetailComment`,分页参数改写与解析已验证,待纳入任务执行与抽样主链)
- [x] `S2-05` 标准化 v1 与最小报告快照落地(规则版)
- [ ] `S2-06` 单平台执行页闭环与回归包落地(进行中:新建 -> 确认 -> 执行 -> 报告已打通)
## `S3`
- [ ] `S3-01` 第二平台 `precheck/search/detail/reviews` 适配器落地(进行中)
- [ ] `S3-02` 模板刷新与 `L2` 路径落地(未开始)
- [ ] `S3-03` 阻塞恢复与 `L3 Browser Recovery` 落地(进行中)
- [ ] `S3-04` 双平台候选确认与执行控制台落地(进行中:页面与状态展示已具备,真实并发执行待补)
- [x] `S3-05` `PartialCompleted``Blocked``Failed` 汇总规则落地
- [ ] `S3-06` 双平台主回归包落地(未开始)
## `S4`
- [ ] `S4-01` 完整标准化与三级聚合落地(进行中)
- [ ] `S4-02` AI 结构化报告生成与版本规则落地(进行中)
- [ ] `S4-03` 报告页、证据抽屉与质量标记落地(进行中:报告页、质量标记与证据索引已落地,证据抽屉待补)
- [x] `S4-04` 历史任务页、版本切换与删除入口落地
- [ ] `S4-05` 留存、删除 API 与联动清理链路落地(进行中:删除 API 与 30/90 天本地清理作业已落地,对象存储联动待补)
- [ ] `S4-06` 完整可观测性与审计日志落地(进行中)
## `S5`
- [ ] `S5-01` 平台级定向重试稳定化(进行中)
- [ ] `S5-02` 性能与成本优化(未开始)
- [ ] `S5-03` UAT 与试运行任务集执行(未开始)
- [ ] `S5-04` 部署、值守、排障与热修手册落地(未开始)
- [ ] `S5-05` 最终验收与文档同步收口(未开始)
## 横向任务
- [ ] `X-01` 上下游文档变更同步(进行中)
- [ ] `X-02` 安全与合规检查(未开始)
- [ ] `X-03` 测试资产维护(进行中)
- [ ] `X-04` 设计一致性与可访问性检查(进行中)
- [ ] `X-05` 观测指标复盘(未开始)

View File

@ -1,372 +0,0 @@
import {
parseJdDetailApiResponse,
parseJdReviewsApiResponse,
parseJdSearchApiResponse,
parseJdSearchHtml
} from "./parsers";
import type {
JdDetailPreviewResult,
JdLiveService,
JdLiveSessionInput,
JdLiveSessionSummary,
JdReviewsPreviewResult,
JdSearchMode,
JdSearchPreviewResult,
JdTemplateSummary
} from "./types";
import { firstString, readQueryBody, withUpdatedQueryBody } from "./utils";
const DEFAULT_JD_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36";
type StoredJdLiveSession = {
cookieHeader: string;
importedAt: string;
userAgent: string;
searchApiTemplateUrl?: string | undefined;
detailTemplateUrl?: string | undefined;
reviewsTemplateUrl?: string | undefined;
searchReferer?: string | undefined;
detailReferer?: string | undefined;
};
class JdLiveError extends Error {
constructor(
message: string,
readonly statusCode: number = 400
) {
super(message);
this.name = "JdLiveError";
}
}
function nowIso(): string {
return new Date().toISOString();
}
function readEnvSession(): StoredJdLiveSession | null {
const cookieHeader = process.env.JD_COOKIE_HEADER?.trim();
if (!cookieHeader) {
return null;
}
const searchApiTemplateUrl = process.env.JD_SEARCH_API_TEMPLATE_URL?.trim();
const detailTemplateUrl = process.env.JD_DETAIL_TEMPLATE_URL?.trim();
const reviewsTemplateUrl = process.env.JD_REVIEWS_TEMPLATE_URL?.trim();
const searchReferer = process.env.JD_SEARCH_REFERER?.trim();
const detailReferer = process.env.JD_DETAIL_REFERER?.trim();
return {
cookieHeader,
importedAt: nowIso(),
userAgent: process.env.JD_USER_AGENT?.trim() || DEFAULT_JD_USER_AGENT,
...(searchApiTemplateUrl ? { searchApiTemplateUrl } : {}),
...(detailTemplateUrl ? { detailTemplateUrl } : {}),
...(reviewsTemplateUrl ? { reviewsTemplateUrl } : {}),
...(searchReferer ? { searchReferer } : {}),
...(detailReferer ? { detailReferer } : {})
};
}
function requireNonEmptyCookie(cookieHeader: string): string {
const normalized = cookieHeader.trim();
if (!normalized) {
throw new JdLiveError("cookieHeader is required for JD live requests.");
}
return normalized;
}
function extractTemplateSkuId(templateUrl: string | undefined): string | undefined {
if (!templateUrl) {
return undefined;
}
const url = new URL(templateUrl);
const body = readQueryBody(url);
return firstString(body?.skuId, body?.sku) ?? undefined;
}
function buildTemplateSummary(templateUrl: string | undefined): JdTemplateSummary {
const skuId = extractTemplateSkuId(templateUrl);
return {
available: Boolean(templateUrl),
...(skuId ? { skuId } : {})
};
}
function templateMatchesQuery(
templateUrl: string | undefined,
query: string
): boolean {
if (!templateUrl) {
return false;
}
const templateKeyword = new URL(templateUrl).searchParams.get("keyword");
return Boolean(templateKeyword && templateKeyword === query);
}
async function fetchTextOrThrow(
url: string,
init: RequestInit,
sessionExpiredMessage: string
): Promise<{ finalUrl: string; text: string }> {
let response: Response;
try {
response = await fetch(url, {
...init,
redirect: "follow"
});
} catch (error) {
throw new JdLiveError(
`JD live request failed before receiving a response: ${
error instanceof Error ? error.message : "unknown error"
}`,
502
);
}
const text = await response.text();
if (response.url.includes("passport.jd.com") || text.includes("passport.jd.com")) {
throw new JdLiveError(sessionExpiredMessage, 409);
}
if (!response.ok) {
throw new JdLiveError(
`JD live request failed with status ${response.status}.`,
502
);
}
return {
finalUrl: response.url,
text
};
}
export function isJdLiveError(error: unknown): error is Error & { statusCode: number } {
return error instanceof Error && "statusCode" in error;
}
export class JdLiveSessionService implements JdLiveService {
private session: StoredJdLiveSession | null = readEnvSession();
getSessionSummary(): JdLiveSessionSummary {
return {
configured: Boolean(this.session),
hasCookie: Boolean(this.session?.cookieHeader),
...(this.session?.importedAt ? { importedAt: this.session.importedAt } : {}),
...(this.session?.userAgent ? { userAgent: this.session.userAgent } : {}),
searchApiTemplate: buildTemplateSummary(this.session?.searchApiTemplateUrl),
detailTemplate: buildTemplateSummary(this.session?.detailTemplateUrl),
reviewsTemplate: buildTemplateSummary(this.session?.reviewsTemplateUrl)
};
}
importSession(input: JdLiveSessionInput): JdLiveSessionSummary {
const searchApiTemplateUrl = input.searchApiTemplateUrl?.trim();
const detailTemplateUrl = input.detailTemplateUrl?.trim();
const reviewsTemplateUrl = input.reviewsTemplateUrl?.trim();
const searchReferer = input.searchReferer?.trim();
const detailReferer = input.detailReferer?.trim();
this.session = {
cookieHeader: requireNonEmptyCookie(input.cookieHeader),
importedAt: nowIso(),
userAgent: input.userAgent?.trim() || DEFAULT_JD_USER_AGENT,
...(searchApiTemplateUrl ? { searchApiTemplateUrl } : {}),
...(detailTemplateUrl ? { detailTemplateUrl } : {}),
...(reviewsTemplateUrl ? { reviewsTemplateUrl } : {}),
...(searchReferer ? { searchReferer } : {}),
...(detailReferer ? { detailReferer } : {})
};
return this.getSessionSummary();
}
clearSession(): void {
this.session = readEnvSession();
}
async previewSearch(
query: string,
mode?: JdSearchMode
): Promise<JdSearchPreviewResult> {
const session = this.requireSession();
const normalizedQuery = query.trim();
if (!normalizedQuery) {
throw new JdLiveError("query is required for JD live search preview.");
}
const resolvedMode =
mode ??
(templateMatchesQuery(session.searchApiTemplateUrl, normalizedQuery) ? "api" : "html");
if (resolvedMode === "api") {
if (!session.searchApiTemplateUrl) {
throw new JdLiveError(
"JD search API template is missing. Import a fresh search request URL or use mode=html."
);
}
const templateUrl = new URL(session.searchApiTemplateUrl);
const templateKeyword = templateUrl.searchParams.get("keyword");
if (templateKeyword && templateKeyword !== normalizedQuery) {
throw new JdLiveError(
`Imported search API template is locked to query "${templateKeyword}". ` +
"Capture a fresh request for the target query or use mode=html."
);
}
const response = await fetchTextOrThrow(
session.searchApiTemplateUrl,
{
headers: {
Accept: "application/json, text/plain, */*",
Cookie: session.cookieHeader,
Referer:
session.searchReferer ??
`https://search.jd.com/Search?keyword=${encodeURIComponent(normalizedQuery)}`,
"User-Agent": session.userAgent
}
},
"JD search session appears invalid. Re-login in the browser and re-import the cookie/header."
);
const candidates = parseJdSearchApiResponse(normalizedQuery, { text: response.text });
return {
query: normalizedQuery,
source: "api",
candidateCount: candidates.length,
candidates
};
}
const searchUrl = `https://search.jd.com/Search?keyword=${encodeURIComponent(normalizedQuery)}`;
const response = await fetchTextOrThrow(
searchUrl,
{
headers: {
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9",
Cookie: session.cookieHeader,
Referer: session.searchReferer ?? "https://www.jd.com/",
"User-Agent": session.userAgent
}
},
"JD search session appears invalid. Re-login in the browser and re-import the cookie/header."
);
const candidates = parseJdSearchHtml(normalizedQuery, response.text);
return {
query: normalizedQuery,
source: "html",
candidateCount: candidates.length,
candidates
};
}
async previewDetail(skuId: string): Promise<JdDetailPreviewResult> {
const session = this.requireSession();
const normalizedSkuId = skuId.trim();
if (!normalizedSkuId) {
throw new JdLiveError("skuId is required for JD detail preview.");
}
if (!session.detailTemplateUrl) {
throw new JdLiveError(
"JD detail template is missing. Capture a fresh pc_detailpage_wareBusiness request and import it first."
);
}
const templateSkuId = extractTemplateSkuId(session.detailTemplateUrl);
if (templateSkuId && templateSkuId !== normalizedSkuId) {
throw new JdLiveError(
`Imported detail template is bound to sku ${templateSkuId}. Open the matching JD item page and capture a fresh request for sku ${normalizedSkuId}.`
);
}
const response = await fetchTextOrThrow(
session.detailTemplateUrl,
{
headers: {
Accept: "application/json, text/plain, */*",
Cookie: session.cookieHeader,
Referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`,
"User-Agent": session.userAgent
}
},
"JD detail session appears invalid. Re-login in the browser and re-import the cookie/header."
);
return {
skuId: normalizedSkuId,
source: "api",
detail: parseJdDetailApiResponse(normalizedSkuId, { text: response.text })
};
}
async previewReviews(
skuId: string,
commentCount = 5
): Promise<JdReviewsPreviewResult> {
const session = this.requireSession();
const normalizedSkuId = skuId.trim();
if (!normalizedSkuId) {
throw new JdLiveError("skuId is required for JD reviews preview.");
}
if (!session.reviewsTemplateUrl) {
throw new JdLiveError(
"JD reviews template is missing. Capture a fresh getLegoWareDetailComment request and import it first."
);
}
const templateSkuId = extractTemplateSkuId(session.reviewsTemplateUrl);
if (templateSkuId && templateSkuId !== normalizedSkuId) {
throw new JdLiveError(
`Imported reviews template is bound to sku ${templateSkuId}. Open the matching JD item page and capture a fresh request for sku ${normalizedSkuId}.`
);
}
const templateUrl = new URL(session.reviewsTemplateUrl);
const requestUrl = withUpdatedQueryBody(templateUrl, (body) => ({
...body,
commentNum: commentCount
}));
const response = await fetchTextOrThrow(
requestUrl,
{
headers: {
Accept: "application/json, text/plain, */*",
Cookie: session.cookieHeader,
Referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`,
"User-Agent": session.userAgent
}
},
"JD reviews session appears invalid. Re-login in the browser and re-import the cookie/header."
);
return {
skuId: normalizedSkuId,
source: "api",
reviews: parseJdReviewsApiResponse(normalizedSkuId, { text: response.text })
};
}
private requireSession(): StoredJdLiveSession {
if (!this.session?.cookieHeader) {
throw new JdLiveError(
"JD live session is not configured. Import a browser cookie/header first."
);
}
return this.session;
}
}

View File

@ -1,151 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
parseJdDetailApiResponse,
parseJdReviewsApiResponse,
parseJdSearchApiResponse,
parseJdSearchHtml
} from "./parsers";
function readFixture(path: string): unknown {
return JSON.parse(readFileSync(new URL(path, import.meta.url), "utf8")) as unknown;
}
describe("JD parsers", () => {
it("parses real JD search API fixtures into candidate records", () => {
const fixture = readFixture("../../../../../jd-search-json-shape.json") as {
firstWare: Record<string, unknown>;
};
const candidates = parseJdSearchApiResponse("iPhone 15", {
data: {
wareList: [fixture.firstWare]
}
});
expect(candidates).toHaveLength(1);
expect(candidates[0]).toMatchObject({
platform: "jd",
title: expect.stringContaining("iPhone"),
priceLabel: expect.stringMatching(/^¥/),
storeName: expect.any(String)
});
expect(candidates[0]?.title).not.toContain("<font");
});
it("parses captured JD search card blocks from HTML markup", () => {
const fixture = readFixture("../../../../../jd-search-card-blocks.json") as Array<{
sku: string;
html: string;
}>;
const html = fixture.map((item) => item.html).join("");
const candidates = parseJdSearchHtml("iPhone 15", html);
expect(candidates).toHaveLength(3);
expect(candidates).toEqual(
expect.arrayContaining([
expect.objectContaining({
candidateId: "jd-100068388533",
storeName: "Apple产品京东自营旗舰店",
productUrl: "https://item.jd.com/100068388533.html",
salesHint: expect.stringContaining("已售500万+")
})
])
);
});
it("parses JD detail payloads from raw API objects", () => {
const detail = parseJdDetailApiResponse("100068388533", {
wareInfo: {
wareInfoMap: {
sku_status: 1
}
},
skuHeadVO: {
skuTitle: "Apple/苹果 iPhone 15 (A3092) 128GB 绿色 支持移动联通电信5G 双卡双待手机"
},
price: {
p: "4398.00",
op: "4599.00"
},
itemShopInfo: {
shopName: "Apple产品京东自营旗舰店"
},
crumbInfoVO: {
crumbs: [
{ text: "手机通讯" },
{ text: "手机" },
{ text: "Apple" }
]
},
stockInfo: {
stockStateDesc: "<strong>有货</strong>仅剩318件"
},
mainImageVO: {
mainImageArea: {
imageUrl: "jfs/t1/example.jpg"
}
}
});
expect(detail).toMatchObject({
skuId: "100068388533",
title: "Apple/苹果 iPhone 15 (A3092) 128GB 绿色 支持移动联通电信5G 双卡双待手机",
price: "4398.00",
originalPrice: "4599.00",
shopName: "Apple产品京东自营旗舰店",
categoryPath: ["手机通讯", "手机", "Apple"],
stockState: "有货仅剩318件",
mainImage: "https://img14.360buyimg.com/n2/jfs/t1/example.jpg"
});
});
it("parses JD reviews payloads from raw API objects", () => {
const reviews = parseJdReviewsApiResponse("100068388533", {
allCnt: "10000",
goodRate: "95%",
pictureCnt: "500",
tagStatisticsinfoList: [
{
tagId: "tag-1",
name: "拍照效果超清晰",
count: "9313"
},
{
tagId: "tag-2",
name: "手感很舒服",
count: "8628"
}
],
commentInfoList: [
{
commentId: "103893190162198263",
commentData: "蓝色 iPhone 15 颜值很高。",
commentScore: 5,
commentDate: "2026-04-02 19:23:16",
userLevelName: "PLUS会员"
}
]
});
expect(reviews).toMatchObject({
skuId: "100068388533",
total: "10000",
goodRate: "95%",
pictureCount: "500"
});
expect(reviews.tags[0]).toMatchObject({
tagId: "tag-1",
name: "拍照效果超清晰",
count: "9313"
});
expect(reviews.comments[0]).toMatchObject({
id: "103893190162198263",
content: "蓝色 iPhone 15 颜值很高。",
score: "5",
userLevelName: "PLUS会员"
});
});
});

View File

@ -1,361 +0,0 @@
import type { CandidateRecord } from "@cross-ai/domain";
import type {
JdProductDetailSnapshot,
JdProductReviewsSnapshot,
JdReviewCommentSnapshot,
JdReviewTagSnapshot
} from "./types";
import {
absolutizeUrl,
asArray,
asRecord,
firstString,
normalizeWhitespace,
stringFrom,
uniqueStrings
} from "./utils";
function unwrapCapturedPayload(input: unknown): unknown {
const record = asRecord(input);
const text = stringFrom(record?.text);
if (!text) {
return input;
}
try {
return JSON.parse(text) as unknown;
} catch {
return input;
}
}
function extractSpecLabel(title: string): string {
const storageMatch = title.match(/\b\d+(?:GB|TB)\b/i);
if (storageMatch) {
return storageMatch[0].toUpperCase();
}
const colorMatch = title.match(/(黑色|白色|蓝色|粉色|绿色|黄色|紫色|原色|钛金属)/);
if (colorMatch) {
return colorMatch[0];
}
return "标准版";
}
function normalizePriceText(value: string | null): { value: number; label: string } | null {
if (!value) {
return null;
}
const stripped = value.replace(/[^\d.]/g, "");
if (!stripped) {
return null;
}
const parsed = Number.parseFloat(stripped);
if (Number.isNaN(parsed)) {
return null;
}
return {
value: parsed,
label: `¥${parsed.toString()}`
};
}
function normalizeInlineText(value: string | null): string | null {
if (!value) {
return null;
}
const normalized = normalizeWhitespace(value).replace(/\s+([,。;:、])/g, "$1");
return normalized || null;
}
function matchFirst(value: string, pattern: RegExp): string | null {
const match = pattern.exec(value);
return match?.[1] ? normalizeWhitespace(match[1]) : null;
}
function extractSearchCardBlocks(html: string): string[] {
const matches = html.matchAll(
/<div[^>]*plugin_goodsCardWrapper[^>]*data-sku="[^"]+"[\s\S]*?(?=<div[^>]*plugin_goodsCardWrapper[^>]*data-sku="|$)/g
);
return Array.from(matches, (match) => match[0]);
}
function parseSearchCardBlock(block: string): CandidateRecord | null {
const sku = matchFirst(block, /data-sku="([^"]+)"/);
const title =
matchFirst(
block,
/_goods_title_container_[^"]*"[\s\S]*?<span[^>]*title="([^"]+)"/
) ?? matchFirst(block, /title="([^"]+)"/);
const priceText = matchFirst(block, /<span class="[^"]*_price_[^"]*"[^>]*>([\s\S]*?)<\/span>/);
const price = normalizePriceText(priceText);
const storeName = matchFirst(
block,
/<span class="[^"]*_name_[^"]*"[\s\S]*?<span>([\s\S]*?)<\/span>/
);
const imageUrl = absolutizeUrl(
matchFirst(block, /<img[^>]+src="([^"]+)"[^>]*alt="">/) ??
matchFirst(block, /<img[^>]+data-src="([^"]+)"/)
);
const soldHint = matchFirst(block, /title="(已售[^"]+)"/);
const trendHint = matchFirst(block, /title="(30天[^"]+)"/);
const featureMatches = Array.from(
block.matchAll(/<span>([^<]+)<\/span>/g),
(match) => normalizeWhitespace(match[1] ?? "")
).filter(Boolean);
const highlights = uniqueStrings(featureMatches).slice(0, 4);
if (!sku || !title || !price || !storeName) {
return null;
}
return {
candidateId: `jd-${sku}`,
platform: "jd",
title,
price: price.value,
priceLabel: price.label,
storeName,
productUrl: `https://item.jd.com/${sku}.html`,
imageUrl: imageUrl ?? "https://placehold.co/640x480?text=JD",
salesHint: uniqueStrings([soldHint, trendHint]).join(" · ") || "暂无销量信息",
specLabel: extractSpecLabel(title),
highlights: highlights.length > 0 ? highlights : ["京东商品卡片已命中"]
};
}
export function parseJdSearchHtml(query: string, html: string): CandidateRecord[] {
const blocks = extractSearchCardBlocks(html);
const seen = new Set<string>();
const candidates: CandidateRecord[] = [];
for (const block of blocks) {
const candidate = parseSearchCardBlock(block);
if (!candidate || seen.has(candidate.candidateId)) {
continue;
}
seen.add(candidate.candidateId);
candidates.push(candidate);
}
if (candidates.length > 0) {
return candidates;
}
if (html.includes("暂无") || html.includes("很抱歉没有找到")) {
return [];
}
return [
{
candidateId: `jd-fallback-${encodeURIComponent(query)}`,
platform: "jd",
title: query,
price: 0,
priceLabel: "¥0",
storeName: "京东",
productUrl: `https://search.jd.com/Search?keyword=${encodeURIComponent(query)}`,
imageUrl: "https://placehold.co/640x480?text=JD",
salesHint: "页面已返回,但未解析出稳定商品卡片",
specLabel: "待确认",
highlights: ["需要刷新搜索模板或调整解析器"]
}
];
}
export function parseJdSearchApiResponse(query: string, input: unknown): CandidateRecord[] {
const payload = asRecord(unwrapCapturedPayload(input));
const data = asRecord(payload?.data);
const wareList = asArray(data?.wareList);
const seen = new Set<string>();
const candidates: CandidateRecord[] = [];
for (const item of wareList) {
const ware = asRecord(item);
const sku = stringFrom(ware?.skuId);
const title = normalizeInlineText(firstString(ware?.wareName, ware?.wname));
const priceText = firstString(
ware?.jdPrice,
asRecord(ware?.finalPrice)?.estimatedPrice,
ware?.price
);
const price = normalizePriceText(priceText);
if (!sku || !title || !price || seen.has(sku)) {
continue;
}
const storeName =
normalizeInlineText(firstString(ware?.shopName, ware?.storeName, "京东店铺")) ??
"京东店铺";
const totalSales = stringFrom(ware?.totalSales);
const commentFuzzy = firstString(ware?.commentFuzzy, ware?.comment);
const highlights = uniqueStrings([
stringFrom(ware?.selfSupport) === "1" ? "京东自营" : null,
stringFrom(ware?.good),
stringFrom(ware?.averageScore) ? `评分 ${stringFrom(ware?.averageScore)}` : null
]);
seen.add(sku);
candidates.push({
candidateId: `jd-${sku}`,
platform: "jd",
title,
price: price.value,
priceLabel: price.label,
storeName,
productUrl: `https://item.jd.com/${sku}.html`,
imageUrl:
absolutizeUrl(stringFrom(ware?.imageurl)) ??
"https://placehold.co/640x480?text=JD",
salesHint:
uniqueStrings([
totalSales ? `已售${totalSales}` : null,
commentFuzzy ? `累计评价 ${commentFuzzy}` : null
]).join(" · ") || "暂无销量信息",
specLabel: extractSpecLabel(title),
highlights: highlights.length > 0 ? highlights : ["京东 API 返回候选"]
});
}
return candidates;
}
export function parseJdDetailApiResponse(
skuId: string,
input: unknown
): JdProductDetailSnapshot {
const payload = asRecord(unwrapCapturedPayload(input));
const wareInfo = asRecord(payload?.wareInfo);
const wareInfoMap = asRecord(wareInfo?.wareInfoMap);
const price = asRecord(payload?.price);
const finalPrice = asRecord(price?.finalPrice);
const itemShopInfo = asRecord(payload?.itemShopInfo);
const crumbInfo = asRecord(payload?.crumbInfoVO);
const stockInfo = asRecord(payload?.stockInfo) ?? asRecord(payload?.stockVO);
const mainImage = asRecord(payload?.mainImageVO);
const mainImageArea = asRecord(mainImage?.mainImageArea);
const skuHead = asRecord(payload?.skuHeadVO);
const categoryPath = asArray(crumbInfo?.crumbs)
.map((item) => {
const crumb = asRecord(item);
return firstString(crumb?.text, crumb?.name);
})
.filter((item): item is string => Boolean(item));
return {
skuId: firstString(wareInfo?.skuId, wareInfoMap?.skuId, skuId) ?? skuId,
title: normalizeInlineText(
firstString(
skuHead?.skuTitle,
wareInfo?.wname,
wareInfo?.name,
wareInfoMap?.wname,
wareInfoMap?.name,
skuHead?.seoTitle
)
),
price: firstString(price?.p, price?.price),
originalPrice: firstString(price?.op, price?.originalPrice),
estimatedPrice: firstString(finalPrice?.estimatedPrice, finalPrice?.price),
shopName: normalizeInlineText(
firstString(itemShopInfo?.shopName, itemShopInfo?.venderName)
),
vendorId: firstString(itemShopInfo?.venderId, itemShopInfo?.shopId),
categoryPath,
stockState: normalizeInlineText(
firstString(
stockInfo?.stockStateDesc,
stockInfo?.stockDesc,
stockInfo?.stockStateName,
stockInfo?.stockState
)
),
mainImage: absolutizeUrl(
firstString(
mainImageArea?.imageUrl,
mainImage?.mainImageUrl,
mainImage?.mainImage,
asRecord(asArray(mainImage?.carouselArea)[0])?.imageUrl,
wareInfo?.imageUrl,
wareInfoMap?.imageUrl
)
),
averageScore: firstString(
wareInfo?.averageScore,
wareInfo?.score,
wareInfoMap?.averageScore,
wareInfoMap?.score
)
};
}
function parseReviewTag(input: unknown): JdReviewTagSnapshot | null {
const tag = asRecord(input);
const name = firstString(tag?.name, tag?.tagName);
if (!name) {
return null;
}
return {
tagId: firstString(tag?.tagId, tag?.id),
name,
count: firstString(tag?.count, tag?.num)
};
}
function parseReviewComment(input: unknown): JdReviewCommentSnapshot | null {
const comment = asRecord(input);
const content = normalizeInlineText(
firstString(comment?.content, comment?.commentData, comment?.tagCommentContent)
);
const id = firstString(comment?.id, comment?.commentId);
if (!content || !id) {
return null;
}
return {
id,
content,
score: firstString(comment?.score, comment?.commentScore),
creationTime: firstString(
comment?.creationTime,
comment?.creationDate,
comment?.commentDate
),
userLevelName: normalizeInlineText(
firstString(comment?.userLevelName, comment?.userClientShow)
)
};
}
export function parseJdReviewsApiResponse(
skuId: string,
input: unknown
): JdProductReviewsSnapshot {
const payload = asRecord(unwrapCapturedPayload(input));
const tags = asArray(payload?.tagStatisticsinfoList)
.map((tag) => parseReviewTag(tag))
.filter((tag): tag is JdReviewTagSnapshot => Boolean(tag));
const comments = asArray(payload?.commentInfoList)
.map((comment) => parseReviewComment(comment))
.filter((comment): comment is JdReviewCommentSnapshot => Boolean(comment));
return {
skuId,
total: firstString(payload?.allCnt, payload?.allCntStr, payload?.goodCnt),
goodRate: firstString(payload?.goodRate, payload?.goodRateShow),
pictureCount: firstString(payload?.pictureCnt, payload?.showPicCnt),
tags,
comments
};
}

View File

@ -1,93 +0,0 @@
import type { CandidateRecord } from "@cross-ai/domain";
export type JdSearchMode = "html" | "api";
export interface JdTemplateSummary {
available: boolean;
skuId?: string | undefined;
}
export interface JdLiveSessionInput {
cookieHeader: string;
userAgent?: string | undefined;
searchApiTemplateUrl?: string | undefined;
detailTemplateUrl?: string | undefined;
reviewsTemplateUrl?: string | undefined;
searchReferer?: string | undefined;
detailReferer?: string | undefined;
}
export interface JdLiveSessionSummary {
configured: boolean;
importedAt?: string | undefined;
hasCookie: boolean;
userAgent?: string | undefined;
searchApiTemplate: JdTemplateSummary;
detailTemplate: JdTemplateSummary;
reviewsTemplate: JdTemplateSummary;
}
export interface JdSearchPreviewResult {
query: string;
source: JdSearchMode;
candidateCount: number;
candidates: CandidateRecord[];
}
export interface JdProductDetailSnapshot {
skuId: string;
title: string | null;
price: string | null;
originalPrice: string | null;
estimatedPrice: string | null;
shopName: string | null;
vendorId: string | null;
categoryPath: string[];
stockState: string | null;
mainImage: string | null;
averageScore: string | null;
}
export interface JdReviewTagSnapshot {
tagId: string | null;
name: string;
count: string | null;
}
export interface JdReviewCommentSnapshot {
id: string;
content: string;
score: string | null;
creationTime: string | null;
userLevelName: string | null;
}
export interface JdProductReviewsSnapshot {
skuId: string;
total: string | null;
goodRate: string | null;
pictureCount: string | null;
tags: JdReviewTagSnapshot[];
comments: JdReviewCommentSnapshot[];
}
export interface JdDetailPreviewResult {
skuId: string;
source: "api";
detail: JdProductDetailSnapshot;
}
export interface JdReviewsPreviewResult {
skuId: string;
source: "api";
reviews: JdProductReviewsSnapshot;
}
export interface JdLiveService {
getSessionSummary(): JdLiveSessionSummary;
importSession(input: JdLiveSessionInput): JdLiveSessionSummary;
clearSession(): void;
previewSearch(query: string, mode?: JdSearchMode): Promise<JdSearchPreviewResult>;
previewDetail(skuId: string): Promise<JdDetailPreviewResult>;
previewReviews(skuId: string, commentCount?: number): Promise<JdReviewsPreviewResult>;
}

View File

@ -1,115 +0,0 @@
export function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
export function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
export function stringFrom(value: unknown): string | null {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return null;
}
export function firstString(...values: unknown[]): string | null {
for (const value of values) {
const resolved = stringFrom(value);
if (resolved) {
return resolved;
}
}
return null;
}
export function absolutizeUrl(value: string | null | undefined): string | null {
if (!value) {
return null;
}
if (value.startsWith("http://") || value.startsWith("https://")) {
return value;
}
if (value.startsWith("//")) {
return `https:${value}`;
}
if (value.startsWith("/")) {
return `https://www.jd.com${value}`;
}
if (value.startsWith("jfs/") || value.startsWith("t1/") || value.startsWith("t202")) {
return `https://img14.360buyimg.com/n2/${value}`;
}
return `https://${value}`;
}
export function decodeHtmlEntities(value: string): string {
return value
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
}
export function stripTags(value: string): string {
return value.replace(/<[^>]+>/g, " ");
}
export function normalizeWhitespace(value: string): string {
return decodeHtmlEntities(stripTags(value)).replace(/\s+/g, " ").trim();
}
export function uniqueStrings(values: Array<string | null | undefined>): string[] {
return Array.from(
new Set(
values
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value))
)
);
}
export function parseEmbeddedJson(value: string | null): Record<string, unknown> | null {
if (!value) {
return null;
}
try {
const parsed = JSON.parse(value) as unknown;
return asRecord(parsed);
} catch {
return null;
}
}
export function readQueryBody(url: URL): Record<string, unknown> | null {
return parseEmbeddedJson(url.searchParams.get("body"));
}
export function withUpdatedQueryBody(
url: URL,
updater: (body: Record<string, unknown>) => Record<string, unknown>
): string {
const body = readQueryBody(url) ?? {};
const next = updater(body);
const nextUrl = new URL(url.toString());
nextUrl.searchParams.set("body", JSON.stringify(next));
return nextUrl.toString();
}

View File

@ -1,288 +0,0 @@
import { describe, expect, it } from "vitest";
import type {
JdDetailPreviewResult,
JdLiveService,
JdLiveSessionSummary,
JdReviewsPreviewResult,
JdSearchPreviewResult
} from "./platforms/jd/types";
import { createServer } from "./server";
function createJdLiveServiceStub(
overrides: Partial<JdLiveService> = {}
): JdLiveService {
let summary: JdLiveSessionSummary = {
configured: false,
hasCookie: false,
searchApiTemplate: { available: false },
detailTemplate: { available: false },
reviewsTemplate: { available: false }
};
return {
getSessionSummary() {
return overrides.getSessionSummary?.() ?? summary;
},
importSession(input) {
if (overrides.importSession) {
return overrides.importSession(input);
}
summary = {
configured: true,
importedAt: "2026-04-02T12:00:00.000Z",
hasCookie: true,
userAgent: input.userAgent ?? "stub-user-agent",
searchApiTemplate: { available: Boolean(input.searchApiTemplateUrl) },
detailTemplate: { available: Boolean(input.detailTemplateUrl) },
reviewsTemplate: { available: Boolean(input.reviewsTemplateUrl) }
};
return summary;
},
clearSession() {
if (overrides.clearSession) {
overrides.clearSession();
return;
}
summary = {
configured: false,
hasCookie: false,
searchApiTemplate: { available: false },
detailTemplate: { available: false },
reviewsTemplate: { available: false }
};
},
async previewSearch(query, mode) {
if (overrides.previewSearch) {
return overrides.previewSearch(query, mode);
}
const preview: JdSearchPreviewResult = {
query,
source: "api",
candidateCount: 1,
candidates: [
{
candidateId: "jd-100068388533",
platform: "jd",
title: "Apple iPhone 15",
price: 3898,
priceLabel: "CNY 3898",
storeName: "JD Self Operated",
productUrl: "https://item.jd.com/100068388533.html",
imageUrl: "https://img14.360buyimg.com/n2/jfs/t1/example.jpg",
salesHint: "sold 500+",
specLabel: "128GB",
highlights: ["A16"]
}
]
};
return preview;
},
async previewDetail(skuId) {
if (overrides.previewDetail) {
return overrides.previewDetail(skuId);
}
const preview: JdDetailPreviewResult = {
skuId,
source: "api",
detail: {
skuId,
title: "Apple iPhone 15",
price: "4398.00",
originalPrice: "4599.00",
estimatedPrice: "3898",
shopName: "JD Self Operated",
vendorId: null,
categoryPath: ["phones", "smartphones", "apple"],
stockState: "in stock",
mainImage: "https://img14.360buyimg.com/n2/jfs/t1/example.jpg",
averageScore: null
}
};
return preview;
},
async previewReviews(skuId, commentCount) {
if (overrides.previewReviews) {
return overrides.previewReviews(skuId, commentCount);
}
const preview: JdReviewsPreviewResult = {
skuId,
source: "api",
reviews: {
skuId,
total: "10000",
goodRate: "95%",
pictureCount: "500",
tags: [
{
tagId: "tag-1",
name: "clear camera",
count: "9313"
}
],
comments: [
{
id: "comment-1",
content: "smooth system and sharp photos",
score: "5",
creationTime: "2026-04-02 19:23:16",
userLevelName: "PLUS"
}
]
}
};
return preview;
}
};
}
describe("JD live server endpoints", () => {
it("imports and clears a JD live session through dedicated endpoints", async () => {
const jdLiveService = createJdLiveServiceStub();
const app = createServer({ jdLiveService });
await app.ready();
const importResponse = await app.inject({
method: "POST",
url: "/api/platforms/jd/live-session",
payload: {
cookieHeader: "thor=masked; pin=masked;",
searchApiTemplateUrl:
"https://api.m.jd.com/?functionId=pc_search_searchWare&body=%7B%22keyword%22:%22iphone%2015%22%7D",
detailTemplateUrl:
"https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness&body=%7B%22skuId%22:%22100068388533%22%7D",
reviewsTemplateUrl:
"https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22sku%22:100068388533%7D"
}
});
expect(importResponse.statusCode).toBe(200);
expect(importResponse.json().session).toMatchObject({
configured: true,
hasCookie: true,
searchApiTemplate: {
available: true
},
detailTemplate: {
available: true
},
reviewsTemplate: {
available: true
}
});
const summaryResponse = await app.inject({
method: "GET",
url: "/api/platforms/jd/live-session"
});
expect(summaryResponse.statusCode).toBe(200);
expect(summaryResponse.json().session).toMatchObject({
configured: true,
hasCookie: true
});
const readinessResponse = await app.inject({
method: "GET",
url: "/api/platforms/readiness"
});
expect(
readinessResponse
.json()
.platforms.find((platform: { platform: string }) => platform.platform === "jd")
).toMatchObject({
platform: "jd",
ready: true,
status: "ready"
});
const clearResponse = await app.inject({
method: "DELETE",
url: "/api/platforms/jd/live-session"
});
expect(clearResponse.statusCode).toBe(204);
const clearedSummaryResponse = await app.inject({
method: "GET",
url: "/api/platforms/jd/live-session"
});
expect(clearedSummaryResponse.json().session).toMatchObject({
configured: false,
hasCookie: false
});
await app.close();
});
it("exposes JD live preview endpoints through the injected service", async () => {
const jdLiveService = createJdLiveServiceStub();
const app = createServer({ jdLiveService });
await app.ready();
const searchResponse = await app.inject({
method: "GET",
url: "/api/platforms/jd/live-search-preview?query=iPhone%2015"
});
expect(searchResponse.statusCode).toBe(200);
expect(searchResponse.json().preview).toMatchObject({
query: "iPhone 15",
source: "api",
candidateCount: 1
});
const detailResponse = await app.inject({
method: "GET",
url: "/api/platforms/jd/live-detail-preview?skuId=100068388533"
});
expect(detailResponse.statusCode).toBe(200);
expect(detailResponse.json().preview.detail).toMatchObject({
skuId: "100068388533",
shopName: "JD Self Operated"
});
const reviewsResponse = await app.inject({
method: "GET",
url: "/api/platforms/jd/live-reviews-preview?skuId=100068388533&commentCount=3"
});
expect(reviewsResponse.statusCode).toBe(200);
expect(reviewsResponse.json().preview.reviews).toMatchObject({
skuId: "100068388533",
goodRate: "95%"
});
await app.close();
});
it("surfaces JD live preview failures with service-provided status codes", async () => {
const jdLiveService = createJdLiveServiceStub({
async previewDetail() {
const error = new Error("Imported detail template is bound to another sku.") as Error & {
statusCode: number;
};
error.statusCode = 409;
throw error;
}
});
const app = createServer({ jdLiveService });
await app.ready();
const response = await app.inject({
method: "GET",
url: "/api/platforms/jd/live-detail-preview?skuId=100068388533"
});
expect(response.statusCode).toBe(409);
expect(response.json()).toMatchObject({
message: "Imported detail template is bound to another sku."
});
await app.close();
});
});

View File

@ -49,46 +49,6 @@ describe("API server", () => {
await app.close(); await app.close();
}); });
it("exposes session state with a 24-hour expiry window after preparation", async () => {
const app = createServer();
await app.ready();
const prepareResponse = await app.inject({
method: "POST",
url: "/api/platforms/jd/prepare"
});
expect(prepareResponse.statusCode).toBe(200);
const preparedPayload = prepareResponse.json();
expect(preparedPayload).toMatchObject({
platform: "jd",
session_ready: true,
status: "ready",
encrypted_snapshot_available: true
});
expect(
Date.parse(preparedPayload.expires_at) - Date.parse(preparedPayload.last_prepared_at)
).toBe(24 * 60 * 60 * 1000);
const sessionResponse = await app.inject({
method: "GET",
url: "/api/sessions/jd"
});
expect(sessionResponse.statusCode).toBe(200);
expect(sessionResponse.json().session).toMatchObject({
platform: "jd",
ready: true,
status: "ready",
scope: "workspace",
ttlHours: 24,
encryptedSnapshotAvailable: true,
cipherLabel: "mock-aes-gcm-v1"
});
await app.close();
});
it("creates a task and lands in AwaitingConfirmation with mock candidates", async () => { it("creates a task and lands in AwaitingConfirmation with mock candidates", async () => {
const app = createServer(); const app = createServer();
await app.ready(); await app.ready();
@ -122,102 +82,6 @@ describe("API server", () => {
await app.close(); await app.close();
}); });
it("records strategy attempts and observability metrics for platform search", async () => {
const app = createServer();
await app.ready();
const createdTask = await createTask(app, "iPhone 15 Pro");
const attemptsResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/strategy-attempts`
});
expect(attemptsResponse.statusCode).toBe(200);
expect(attemptsResponse.json().attempts).toEqual(
expect.arrayContaining([
expect.objectContaining({
platform: "tmall",
capability: "search",
outcome: "succeeded",
layer: "L1"
}),
expect.objectContaining({
platform: "jd",
capability: "search",
outcome: "blocked",
layer: "L0",
errorType: "session_required"
})
])
);
const overviewResponse = await app.inject({
method: "GET",
url: "/api/observability/overview"
});
expect(overviewResponse.statusCode).toBe(200);
expect(overviewResponse.json().overview.strategyAttempts).toMatchObject({
total: 2,
searchSuccessRate: 50
});
expect(
overviewResponse
.json()
.overview.platformRuns.find((metric: { platform: string }) => metric.platform === "jd")
).toMatchObject({
platform: "jd",
searchDurationMs: expect.any(Number)
});
await app.close();
});
it("clears a prepared session and updates the readiness summary", async () => {
const app = createServer();
await app.ready();
await preparePlatform(app, "jd");
const deleteResponse = await app.inject({
method: "DELETE",
url: "/api/sessions/jd"
});
expect(deleteResponse.statusCode).toBe(204);
const sessionResponse = await app.inject({
method: "GET",
url: "/api/sessions/jd"
});
expect(sessionResponse.statusCode).toBe(200);
expect(sessionResponse.json().session).toMatchObject({
platform: "jd",
ready: false,
status: "missing",
encryptedSnapshotAvailable: false
});
const readinessResponse = await app.inject({
method: "GET",
url: "/api/platforms/readiness"
});
expect(
readinessResponse
.json()
.platforms.find((platform: { platform: string }) => platform.platform === "jd")
).toMatchObject({
platform: "jd",
ready: false,
status: "missing"
});
await app.close();
});
it("supports NoSelection terminal state when user confirms nothing", async () => { it("supports NoSelection terminal state when user confirms nothing", async () => {
const app = createServer(); const app = createServer();
await app.ready(); await app.ready();
@ -317,62 +181,6 @@ describe("API server", () => {
await app.close(); await app.close();
}); });
it("records recovery audit entries and retry metrics for recovered platforms", async () => {
const app = createServer();
await app.ready();
const createdTask = await createTask(app, "iPhone 15 Pro");
await preparePlatform(app, "jd");
const retryResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/platforms/jd/retry`
});
expect(retryResponse.statusCode).toBe(200);
const auditResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/audit`
});
expect(auditResponse.statusCode).toBe(200);
expect(auditResponse.json().audit).toEqual(
expect.arrayContaining([
expect.objectContaining({
action: "platform.retry_started",
platform: "jd"
}),
expect.objectContaining({
action: "task.recovery_completed",
platform: "jd"
}),
expect.objectContaining({
action: "platform.retry_completed",
platform: "jd"
})
])
);
const overviewResponse = await app.inject({
method: "GET",
url: "/api/observability/overview"
});
expect(
overviewResponse
.json()
.overview.platformRuns.find((metric: { platform: string }) => metric.platform === "jd")
).toMatchObject({
platform: "jd",
retryCount: 1,
recoveryCount: 1
});
expect(overviewResponse.json().overview.audits.recoveryActions).toBe(2);
await app.close();
});
it("publishes a new report version when a blocked platform recovers successfully", async () => { it("publishes a new report version when a blocked platform recovers successfully", async () => {
const app = createServer(); const app = createServer();
await app.ready(); await app.ready();
@ -532,16 +340,6 @@ describe("API server", () => {
.tasks.some((task: { taskId: string }) => task.taskId === createdTask.taskId) .tasks.some((task: { taskId: string }) => task.taskId === createdTask.taskId)
).toBe(false); ).toBe(false);
const overviewResponse = await app.inject({
method: "GET",
url: "/api/observability/overview"
});
expect(overviewResponse.json().overview.retention).toMatchObject({
taskDeletes: 1,
deletedReports: 1
});
expect(overviewResponse.json().overview.audits.deleteActions).toBe(1);
await app.close(); await app.close();
}); });
}); });

View File

@ -6,14 +6,11 @@ import type {
import cors from "@fastify/cors"; import cors from "@fastify/cors";
import Fastify from "fastify"; import Fastify from "fastify";
import { JdLiveSessionService, isJdLiveError } from "./platforms/jd/live-session";
import type { JdLiveService, JdSearchMode } from "./platforms/jd/types";
import { InMemoryTaskStore } from "./store"; import { InMemoryTaskStore } from "./store";
export function createServer(options: { jdLiveService?: JdLiveService } = {}) { export function createServer() {
const app = Fastify({ logger: false }); const app = Fastify({ logger: false });
const store = new InMemoryTaskStore(); const store = new InMemoryTaskStore();
const jdLiveService = options.jdLiveService ?? new JdLiveSessionService();
app.register(cors, { origin: true }); app.register(cors, { origin: true });
@ -26,88 +23,18 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
platforms: store.getPlatformReadiness() platforms: store.getPlatformReadiness()
})); }));
app.get("/api/sessions", async () => ({
sessions: store.listSessions()
}));
app.get<{
Params: { platform: PlatformId };
}>("/api/sessions/:platform", async (request, reply) => {
try {
const session = store.getSession(request.params.platform);
return { session };
} catch {
reply.code(404);
return { message: "Session not found." };
}
});
app.post<{ app.post<{
Params: { platform: PlatformId }; Params: { platform: PlatformId };
}>("/api/platforms/:platform/prepare", async (request, reply) => { }>("/api/platforms/:platform/prepare", async (request, reply) => {
const session = store.preparePlatform(request.params.platform); const readiness = store.preparePlatform(request.params.platform);
reply.code(200); reply.code(200);
return { return {
platform: session.platform, platform: readiness.platform,
session_ready: session.ready, session_ready: readiness.ready,
status: session.status, last_prepared_at: readiness.lastPreparedAt
last_prepared_at: session.lastPreparedAt,
expires_at: session.expiresAt,
encrypted_snapshot_available: session.encryptedSnapshotAvailable
}; };
}); });
app.get("/api/platforms/jd/live-session", async () => ({
session: jdLiveService.getSessionSummary()
}));
app.post<{
Body: {
cookieHeader: string;
userAgent?: string;
searchApiTemplateUrl?: string;
detailTemplateUrl?: string;
reviewsTemplateUrl?: string;
searchReferer?: string;
detailReferer?: string;
};
}>("/api/platforms/jd/live-session", async (request, reply) => {
try {
const session = jdLiveService.importSession(request.body);
store.preparePlatform("jd");
reply.code(200);
return { session };
} catch (error) {
reply.code(isJdLiveError(error) ? error.statusCode : 400);
return {
message: error instanceof Error ? error.message : "Invalid JD live session payload."
};
}
});
app.delete("/api/platforms/jd/live-session", async (_request, reply) => {
jdLiveService.clearSession();
store.clearPlatformSession("jd");
reply.code(204);
return null;
});
app.delete<{
Params: { platform: PlatformId };
}>("/api/sessions/:platform", async (request, reply) => {
try {
store.clearPlatformSession(request.params.platform);
if (request.params.platform === "jd") {
jdLiveService.clearSession();
}
reply.code(204);
return null;
} catch {
reply.code(404);
return { message: "Session not found." };
}
});
app.post<{ app.post<{
Body: CreateTaskInput; Body: CreateTaskInput;
}>("/api/tasks", async (request, reply) => { }>("/api/tasks", async (request, reply) => {
@ -151,30 +78,6 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
return { candidates }; return { candidates };
}); });
app.get<{
Params: { taskId: string };
}>("/api/tasks/:taskId/strategy-attempts", async (request, reply) => {
const attempts = store.getTaskStrategyAttempts(request.params.taskId);
if (!attempts) {
reply.code(404);
return { message: "Task not found." };
}
return { attempts };
});
app.get<{
Params: { taskId: string };
}>("/api/tasks/:taskId/audit", async (request, reply) => {
const audit = store.getTaskAuditLogs(request.params.taskId);
if (!audit) {
reply.code(404);
return { message: "Task not found." };
}
return { audit };
});
app.post<{ app.post<{
Params: { taskId: string }; Params: { taskId: string };
Body: ConfirmTaskPayload; Body: ConfirmTaskPayload;
@ -213,80 +116,10 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
return { report }; return { report };
}); });
app.get<{
Querystring: { query?: string; mode?: JdSearchMode };
}>("/api/platforms/jd/live-search-preview", async (request, reply) => {
try {
const query = request.query.query?.trim();
if (!query) {
reply.code(400);
return { message: "query is required." };
}
const preview = await jdLiveService.previewSearch(query, request.query.mode);
return { preview };
} catch (error) {
reply.code(isJdLiveError(error) ? error.statusCode : 502);
return {
message:
error instanceof Error ? error.message : "JD live search preview failed."
};
}
});
app.get<{
Querystring: { skuId?: string };
}>("/api/platforms/jd/live-detail-preview", async (request, reply) => {
try {
const skuId = request.query.skuId?.trim();
if (!skuId) {
reply.code(400);
return { message: "skuId is required." };
}
const preview = await jdLiveService.previewDetail(skuId);
return { preview };
} catch (error) {
reply.code(isJdLiveError(error) ? error.statusCode : 502);
return {
message:
error instanceof Error ? error.message : "JD live detail preview failed."
};
}
});
app.get<{
Querystring: { skuId?: string; commentCount?: string };
}>("/api/platforms/jd/live-reviews-preview", async (request, reply) => {
try {
const skuId = request.query.skuId?.trim();
if (!skuId) {
reply.code(400);
return { message: "skuId is required." };
}
const commentCount = request.query.commentCount
? Number.parseInt(request.query.commentCount, 10)
: undefined;
const preview = await jdLiveService.previewReviews(skuId, commentCount);
return { preview };
} catch (error) {
reply.code(isJdLiveError(error) ? error.statusCode : 502);
return {
message:
error instanceof Error ? error.message : "JD live reviews preview failed."
};
}
});
app.get("/api/history", async () => ({ app.get("/api/history", async () => ({
tasks: store.listHistory() tasks: store.listHistory()
})); }));
app.get("/api/observability/overview", async () => ({
overview: store.getObservabilityOverview()
}));
app.get<{ app.get<{
Params: { taskId: string }; Params: { taskId: string };
}>("/api/tasks/:taskId/events", async (request, reply) => { }>("/api/tasks/:taskId/events", async (request, reply) => {

View File

@ -1,32 +1,17 @@
import { import {
auditActions,
deriveTaskStatusFromConfirmedPlatforms, deriveTaskStatusFromConfirmedPlatforms,
mapPlatformStatusToExecutionStatus, mapPlatformStatusToExecutionStatus,
platformCatalog, platformCatalog,
platformCatalogMap, platformCatalogMap,
platforms, platforms,
strategyAttemptOutcomes,
strategyLayers,
type AuditAction,
type AuditLogRecord,
type CandidateRecord, type CandidateRecord,
type ConfirmTaskPayload, type ConfirmTaskPayload,
type CreateTaskInput, type CreateTaskInput,
type HistoryTaskRecord, type HistoryTaskRecord,
type ObservabilityOverview,
type PlatformId, type PlatformId,
type PlatformCapability,
type PlatformRunMetricRecord,
type PlatformRunRecord, type PlatformRunRecord,
type PlatformStatus, type PlatformStatus,
type ReportMetricRecord,
type RetentionMetricRecord,
type SessionReadinessRecord, type SessionReadinessRecord,
type SessionStateRecord,
type StrategyAttemptOutcome,
type StrategyAttemptRecord,
type StrategyAttemptTrigger,
type StrategyLayer,
type TaskEventRecord, type TaskEventRecord,
type TaskRecord type TaskRecord
} from "@cross-ai/domain"; } from "@cross-ai/domain";
@ -46,135 +31,6 @@ function createPlatformCandidatesRecord(): Record<PlatformId, CandidateRecord[]>
}; };
} }
function createPlatformRunMetricsRecord(
taskId: string,
timestamp: string
): Record<PlatformId, PlatformRunMetricRecord> {
return {
tmall: {
taskId,
platform: "tmall",
searchDurationMs: 0,
detailDurationMs: 0,
reviewsDurationMs: 0,
retryCount: 0,
recoveryCount: 0,
lastUpdatedAt: timestamp
},
jd: {
taskId,
platform: "jd",
searchDurationMs: 0,
detailDurationMs: 0,
reviewsDurationMs: 0,
retryCount: 0,
recoveryCount: 0,
lastUpdatedAt: timestamp
}
};
}
const mockCapabilityDurations: Record<
Exclude<PlatformCapability, "login">,
Record<PlatformId, number>
> = {
search: {
tmall: 420,
jd: 760
},
detail: {
tmall: 260,
jd: 320
},
reviews: {
tmall: 880,
jd: 980
}
};
const mockLoginDurations: Record<PlatformId, number> = {
tmall: 640,
jd: 720
};
function resolveDefaultLayer(
platform: PlatformId,
capability: PlatformCapability
): StrategyLayer {
if (capability === "login") {
return "L3";
}
if (capability === "search") {
return platform === "jd" ? "L0" : "L1";
}
return capability === "detail" && platform === "jd" ? "L0" : "L1";
}
function getMockDurationMs(
platform: PlatformId,
capability: PlatformCapability,
outcome: StrategyAttemptOutcome
): number {
const baseDuration =
capability === "login"
? mockLoginDurations[platform]
: mockCapabilityDurations[capability][platform];
switch (outcome) {
case "blocked":
return baseDuration + 180;
case "failed":
return baseDuration + 220;
case "no_result":
return Math.max(120, baseDuration - 140);
case "skipped":
return Math.max(80, baseDuration - 200);
default:
return baseDuration;
}
}
const SESSION_TTL_HOURS = 24;
const SESSION_TTL_MS = SESSION_TTL_HOURS * 60 * 60 * 1000;
type StoredSessionState = Omit<SessionStateRecord, "encryptedSnapshotAvailable"> & {
encryptedSnapshot: string | null;
};
function addDurationMs(timestamp: string, durationMs: number): string {
return new Date(Date.parse(timestamp) + durationMs).toISOString();
}
function createEncryptedSnapshot(platform: PlatformId, timestamp: string): string {
return `mock-aes-gcm-v1:${platform}:${Buffer.from(timestamp).toString("base64url")}`;
}
function createMissingSession(platform: PlatformId): StoredSessionState {
return {
platform,
ready: false,
status: "missing",
searchRequirement: platformCatalogMap[platform].searchRequirement,
scope: "workspace",
ttlHours: SESSION_TTL_HOURS,
encryptedSnapshot: null
};
}
function createPreparedSession(platform: PlatformId, timestamp: string): StoredSessionState {
return {
...createMissingSession(platform),
ready: true,
status: "ready",
lastPreparedAt: timestamp,
expiresAt: addDurationMs(timestamp, SESSION_TTL_MS),
encryptedSnapshot: createEncryptedSnapshot(platform, timestamp),
cipherLabel: "mock-aes-gcm-v1"
};
}
type MockExecutionScenario = { type MockExecutionScenario = {
outcome: Extract<PlatformStatus, "Blocked" | "Failed">; outcome: Extract<PlatformStatus, "Blocked" | "Failed">;
mode: "once" | "always"; mode: "once" | "always";
@ -222,63 +78,39 @@ export class InMemoryTaskStore {
private readonly tasks = new Map<string, TaskRecord>(); private readonly tasks = new Map<string, TaskRecord>();
private readonly reports = new Map<string, ReportSnapshot[]>(); private readonly reports = new Map<string, ReportSnapshot[]>();
private readonly reportFingerprints = new Map<string, string[]>(); private readonly reportFingerprints = new Map<string, string[]>();
private readonly readiness = new Map<PlatformId, StoredSessionState>(); private readonly readiness = new Map<PlatformId, SessionReadinessRecord>();
private readonly strategyAttempts = new Map<string, StrategyAttemptRecord[]>();
private readonly platformRunMetrics = new Map<
string,
Record<PlatformId, PlatformRunMetricRecord>
>();
private readonly reportMetrics: ReportMetricRecord[] = [];
private readonly retentionMetrics: RetentionMetricRecord[] = [];
private readonly auditLogs: AuditLogRecord[] = [];
private readonly executionScenarios = new Map< private readonly executionScenarios = new Map<
string, string,
Partial<Record<PlatformId, MockExecutionScenario>> Partial<Record<PlatformId, MockExecutionScenario>>
>(); >();
constructor() { constructor() {
const timestamp = nowIso(); this.readiness.set("tmall", {
this.readiness.set("tmall", createPreparedSession("tmall", timestamp)); platform: "tmall",
this.readiness.set("jd", createMissingSession("jd")); ready: true,
searchRequirement: "recommended",
lastPreparedAt: nowIso()
});
this.readiness.set("jd", {
platform: "jd",
ready: false,
searchRequirement: "required"
});
} }
getPlatformReadiness(): SessionReadinessRecord[] { getPlatformReadiness(): SessionReadinessRecord[] {
return platforms.map((platform) => this.requireReadiness(platform)); return platforms.map((platform) => this.requireReadiness(platform));
} }
listSessions(): SessionStateRecord[] { preparePlatform(platform: PlatformId): SessionReadinessRecord {
return platforms.map((platform) => this.getSession(platform)); const readiness = this.requireReadiness(platform);
} const next: SessionReadinessRecord = {
...readiness,
getSession(platform: PlatformId): SessionStateRecord { ready: true,
return this.toSessionRecord(this.requireSession(platform)); lastPreparedAt: nowIso()
} };
preparePlatform(platform: PlatformId): SessionStateRecord {
const timestamp = nowIso();
const next = createPreparedSession(platform, timestamp);
this.readiness.set(platform, next); this.readiness.set(platform, next);
this.pushAudit("session.prepared", { return next;
platform,
message: `${platformCatalogMap[platform].label} 会话已标记为预热完成。`,
metadata: {
search_requirement: next.searchRequirement,
expires_at: next.expiresAt ?? null
}
});
return this.toSessionRecord(next);
}
clearPlatformSession(platform: PlatformId): void {
const cleared = createMissingSession(platform);
this.readiness.set(platform, cleared);
this.pushAudit("session.cleared", {
platform,
message: `${platformCatalogMap[platform].label} 会话已清理。`,
metadata: {
search_requirement: cleared.searchRequirement
}
});
} }
createTask(input: CreateTaskInput): TaskRecord { createTask(input: CreateTaskInput): TaskRecord {
@ -309,9 +141,6 @@ export class InMemoryTaskStore {
reportVersions: [] reportVersions: []
}; };
this.strategyAttempts.set(taskId, []);
this.platformRunMetrics.set(taskId, createPlatformRunMetricsRecord(taskId, timestamp));
if (Object.keys(executionScenarios).length > 0) { if (Object.keys(executionScenarios).length > 0) {
this.executionScenarios.set(taskId, executionScenarios); this.executionScenarios.set(taskId, executionScenarios);
} }
@ -355,93 +184,6 @@ export class InMemoryTaskStore {
return this.tasks.get(taskId)?.platformCandidates; return this.tasks.get(taskId)?.platformCandidates;
} }
getTaskStrategyAttempts(taskId: string): StrategyAttemptRecord[] | undefined {
if (!this.tasks.has(taskId)) {
return undefined;
}
return [...(this.strategyAttempts.get(taskId) ?? [])];
}
getTaskAuditLogs(taskId: string): AuditLogRecord[] | undefined {
if (!this.tasks.has(taskId)) {
return undefined;
}
return this.auditLogs.filter((entry) => entry.taskId === taskId);
}
getObservabilityOverview(): ObservabilityOverview {
const strategyAttempts = Array.from(this.strategyAttempts.values()).flat();
const totalAttempts = strategyAttempts.length;
const searchAttempts = strategyAttempts.filter((attempt) => attempt.capability === "search");
const successfulSearchAttempts = searchAttempts.filter(
(attempt) => attempt.outcome === "succeeded"
);
const platformRuns = Array.from(this.platformRunMetrics.values()).flatMap((record) =>
platforms.map((platform) => record[platform])
);
return {
strategyAttempts: {
total: totalAttempts,
searchSuccessRate:
searchAttempts.length > 0
? Number(
((successfulSearchAttempts.length / searchAttempts.length) * 100).toFixed(2)
)
: 0,
browserFallbackShare:
totalAttempts > 0
? Number(
(
(strategyAttempts.filter((attempt) => attempt.layer === "L3").length /
totalAttempts) *
100
).toFixed(2)
)
: 0,
byLayer: strategyLayers.map((layer) => ({
layer,
count: strategyAttempts.filter((attempt) => attempt.layer === layer).length
})),
byOutcome: strategyAttemptOutcomes.map((outcome) => ({
outcome,
count: strategyAttempts.filter((attempt) => attempt.outcome === outcome).length
}))
},
platformRuns,
reports: {
published: this.reportMetrics.length,
unchanged: this.auditLogs.filter((entry) => entry.action === "report.unchanged").length,
sampleInsufficient: this.reportMetrics.filter((metric) => metric.sampleInsufficient).length,
partialFailures: this.reportMetrics.filter((metric) => metric.partialPlatformFailure).length
},
retention: {
taskDeletes: this.retentionMetrics.filter((metric) => metric.action === "task_deleted")
.length,
cleanupRuns: this.retentionMetrics.filter((metric) => metric.action === "retention_cleanup")
.length,
deletedReports: this.retentionMetrics.reduce(
(sum, metric) => sum + metric.deletedReportCount,
0
),
residualArtifacts: this.retentionMetrics.reduce(
(sum, metric) => sum + metric.residualArtifactCount,
0
)
},
audits: {
total: this.auditLogs.length,
recoveryActions: this.auditLogs.filter(
(entry) =>
entry.action === "session.prepared" || entry.action === "task.recovery_completed"
).length,
deleteActions: this.auditLogs.filter((entry) => entry.action === "task.deleted").length
}
};
}
confirmTask(taskId: string, payload: ConfirmTaskPayload): TaskRecord { confirmTask(taskId: string, payload: ConfirmTaskPayload): TaskRecord {
const task = this.requireTask(taskId); const task = this.requireTask(taskId);
const selectionMap = new Map( const selectionMap = new Map(
@ -511,27 +253,12 @@ export class InMemoryTaskStore {
return task; return task;
} }
this.incrementRetryCount(task.taskId, platform);
this.incrementRecoveryCount(task.taskId, platform);
this.pushEvent( this.pushEvent(
task, task,
`platform.${platform}.retry_started`, `platform.${platform}.retry_started`,
`${platformCatalogMap[platform].label} 正在重新获取候选结果。` `${platformCatalogMap[platform].label} 正在重新获取候选结果。`
); );
this.pushAudit("platform.retry_started", { this.runSearchForPlatform(task, run);
taskId,
platform,
message: `${platformCatalogMap[platform].label} 已发起候选重试。`,
metadata: {
previous_status: "SearchBlocked"
}
});
this.pushAudit("task.recovery_completed", {
taskId,
platform,
message: `${platformCatalogMap[platform].label} 已完成恢复,准备重新搜索。`
});
this.runSearchForPlatform(task, run, "recovery");
const recoveredCandidateCount = task.platformCandidates[platform].length; const recoveredCandidateCount = task.platformCandidates[platform].length;
task.taskStage = "confirmation"; task.taskStage = "confirmation";
task.taskStatus = "AwaitingConfirmation"; task.taskStatus = "AwaitingConfirmation";
@ -543,18 +270,6 @@ export class InMemoryTaskStore {
? `${platformCatalogMap[platform].label} 已恢复候选确认。` ? `${platformCatalogMap[platform].label} 已恢复候选确认。`
: `${platformCatalogMap[platform].label} 重试后仍未返回候选结果。` : `${platformCatalogMap[platform].label} 重试后仍未返回候选结果。`
); );
this.pushAudit("platform.retry_completed", {
taskId,
platform,
message:
recoveredCandidateCount > 0
? `${platformCatalogMap[platform].label} 候选重试已恢复。`
: `${platformCatalogMap[platform].label} 候选重试后仍无结果。`,
metadata: {
candidate_count: recoveredCandidateCount,
next_status: run.status
}
});
return task; return task;
} }
@ -567,16 +282,6 @@ export class InMemoryTaskStore {
return task; return task;
} }
const previousStatus = run.status;
this.incrementRetryCount(task.taskId, platform);
if (previousStatus === "Blocked") {
this.incrementRecoveryCount(task.taskId, platform);
this.pushAudit("task.recovery_completed", {
taskId,
platform,
message: `${platformCatalogMap[platform].label} 已完成恢复,准备重新执行。`
});
}
task.taskStatus = "Running"; task.taskStatus = "Running";
task.taskStage = "session_check"; task.taskStage = "session_check";
this.pushEvent( this.pushEvent(
@ -584,28 +289,11 @@ export class InMemoryTaskStore {
`platform.${platform}.retry_started`, `platform.${platform}.retry_started`,
`${platformCatalogMap[platform].label} 正在执行定向重试。` `${platformCatalogMap[platform].label} 正在执行定向重试。`
); );
this.pushAudit("platform.retry_started", {
taskId,
platform,
message: `${platformCatalogMap[platform].label} 已发起平台级重试。`,
metadata: {
previous_status: previousStatus
}
});
this.executeSelectedPlatforms(task, [run], previousStatus === "Blocked" ? "recovery" : "retry"); this.executeSelectedPlatforms(task, [run]);
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns); task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
task.updatedAt = nowIso(); task.updatedAt = nowIso();
this.publishReportIfNeeded(task); this.publishReportIfNeeded(task);
this.pushAudit("platform.retry_completed", {
taskId,
platform,
message: `${platformCatalogMap[platform].label} 平台级重试已结束。`,
metadata: {
next_status: run.status,
task_status: task.taskStatus
}
});
return task; return task;
} }
@ -624,29 +312,10 @@ export class InMemoryTaskStore {
} }
deleteTask(taskId: string): void { deleteTask(taskId: string): void {
const task = this.requireTask(taskId); this.requireTask(taskId);
const deletedReportCount = this.reports.get(taskId)?.length ?? 0;
this.retentionMetrics.push({
metricId: randomUUID(),
action: "task_deleted",
taskId,
deletedReportCount,
deletedArtifactCount: 0,
residualArtifactCount: 0,
recordedAt: nowIso()
});
this.pushAudit("task.deleted", {
taskId,
message: `任务“${task.query}”及其关联报告已删除。`,
metadata: {
deleted_report_count: deletedReportCount
}
});
this.tasks.delete(taskId); this.tasks.delete(taskId);
this.reports.delete(taskId); this.reports.delete(taskId);
this.reportFingerprints.delete(taskId); this.reportFingerprints.delete(taskId);
this.strategyAttempts.delete(taskId);
this.platformRunMetrics.delete(taskId);
this.executionScenarios.delete(taskId); this.executionScenarios.delete(taskId);
} }
@ -835,28 +504,13 @@ export class InMemoryTaskStore {
}); });
} }
private runSearchForPlatform( private runSearchForPlatform(task: TaskRecord, run: PlatformRunRecord): void {
task: TaskRecord,
run: PlatformRunRecord,
trigger: StrategyAttemptTrigger = "system"
): void {
const readiness = this.requireReadiness(run.platform); const readiness = this.requireReadiness(run.platform);
if (readiness.searchRequirement === "required" && !readiness.ready) { if (readiness.searchRequirement === "required" && !readiness.ready) {
run.status = "SearchBlocked"; run.status = "SearchBlocked";
run.reason = platformCatalogMap[run.platform].recoveryHint; run.reason = platformCatalogMap[run.platform].recoveryHint;
run.candidateCount = 0; run.candidateCount = 0;
run.lastUpdatedAt = nowIso(); run.lastUpdatedAt = nowIso();
this.recordStrategyAttempt(
task.taskId,
run.platform,
"search",
"blocked",
trigger,
`${platformCatalogMap[run.platform].label} 搜索前检查缺少有效会话。`,
{
errorType: "session_required"
}
);
this.pushEvent( this.pushEvent(
task, task,
`platform.${run.platform}.search_blocked`, `platform.${run.platform}.search_blocked`,
@ -877,21 +531,6 @@ export class InMemoryTaskStore {
? undefined ? undefined
: `${platformCatalogMap[run.platform].label} 当前未找到可供确认的候选。`; : `${platformCatalogMap[run.platform].label} 当前未找到可供确认的候选。`;
run.lastUpdatedAt = nowIso(); run.lastUpdatedAt = nowIso();
this.recordStrategyAttempt(
task.taskId,
run.platform,
"search",
candidates.length > 0 ? "succeeded" : "no_result",
trigger,
candidates.length > 0
? `${platformCatalogMap[run.platform].label} 已返回候选列表。`
: `${platformCatalogMap[run.platform].label} 本轮搜索没有候选结果。`,
candidates.length > 0
? undefined
: {
errorType: "no_candidates"
}
);
this.pushEvent( this.pushEvent(
task, task,
`platform.${run.platform}.searched`, `platform.${run.platform}.searched`,
@ -903,8 +542,7 @@ export class InMemoryTaskStore {
private executeSelectedPlatforms( private executeSelectedPlatforms(
task: TaskRecord, task: TaskRecord,
runs: PlatformRunRecord[], runs: PlatformRunRecord[]
trigger: StrategyAttemptTrigger = "system"
): void { ): void {
task.taskStage = "crawl"; task.taskStage = "crawl";
@ -914,18 +552,6 @@ export class InMemoryTaskStore {
run.status = "Blocked"; run.status = "Blocked";
run.reason = platformCatalogMap[run.platform].recoveryHint; run.reason = platformCatalogMap[run.platform].recoveryHint;
run.lastUpdatedAt = nowIso(); run.lastUpdatedAt = nowIso();
this.recordStrategyAttempt(
task.taskId,
run.platform,
"login",
"blocked",
trigger,
`${platformCatalogMap[run.platform].label} 抓取前校验失败,需要先恢复会话。`,
{
layer: "L3",
errorType: "session_required"
}
);
this.pushEvent( this.pushEvent(
task, task,
`platform.${run.platform}.blocked`, `platform.${run.platform}.blocked`,
@ -939,18 +565,6 @@ export class InMemoryTaskStore {
run.status = "Blocked"; run.status = "Blocked";
run.reason = `${platformCatalogMap[run.platform].label} 命中模拟阻塞,需要先进入恢复流程。`; run.reason = `${platformCatalogMap[run.platform].label} 命中模拟阻塞,需要先进入恢复流程。`;
run.lastUpdatedAt = nowIso(); run.lastUpdatedAt = nowIso();
this.recordStrategyAttempt(
task.taskId,
run.platform,
"login",
"blocked",
trigger,
`${platformCatalogMap[run.platform].label} 命中模拟阻塞,需要浏览器恢复。`,
{
layer: "L3",
errorType: "platform_blocked"
}
);
this.pushEvent( this.pushEvent(
task, task,
`platform.${run.platform}.blocked`, `platform.${run.platform}.blocked`,
@ -967,27 +581,8 @@ export class InMemoryTaskStore {
`platform.${run.platform}.running`, `platform.${run.platform}.running`,
`${platformCatalogMap[run.platform].label} 开始抓取商品与评论。` `${platformCatalogMap[run.platform].label} 开始抓取商品与评论。`
); );
this.recordStrategyAttempt(
task.taskId,
run.platform,
"detail",
"succeeded",
trigger,
`${platformCatalogMap[run.platform].label} 已通过 ${resolveDefaultLayer(run.platform, "detail")} 抓取商品详情。`
);
if (mockOutcome === "Failed") { if (mockOutcome === "Failed") {
this.recordStrategyAttempt(
task.taskId,
run.platform,
"reviews",
"failed",
trigger,
`${platformCatalogMap[run.platform].label} 评论抓取失败,可稍后定向重试。`,
{
errorType: "mock_failure"
}
);
run.status = "Failed"; run.status = "Failed";
run.reason = `${platformCatalogMap[run.platform].label} 命中模拟失败,可稍后发起定向重试。`; run.reason = `${platformCatalogMap[run.platform].label} 命中模拟失败,可稍后发起定向重试。`;
run.lastUpdatedAt = nowIso(); run.lastUpdatedAt = nowIso();
@ -999,14 +594,6 @@ export class InMemoryTaskStore {
continue; continue;
} }
this.recordStrategyAttempt(
task.taskId,
run.platform,
"reviews",
"succeeded",
trigger,
`${platformCatalogMap[run.platform].label} 已完成评论抓取与抽样。`
);
run.status = "Completed"; run.status = "Completed";
run.reason = undefined; run.reason = undefined;
run.lastUpdatedAt = nowIso(); run.lastUpdatedAt = nowIso();
@ -1048,13 +635,6 @@ export class InMemoryTaskStore {
if (lastFingerprint === fingerprint) { if (lastFingerprint === fingerprint) {
this.pushEvent(task, "task.report_unchanged", "本轮重试未改变报告结果,沿用当前版本。"); this.pushEvent(task, "task.report_unchanged", "本轮重试未改变报告结果,沿用当前版本。");
this.pushAudit("report.unchanged", {
taskId: task.taskId,
message: "报告结果未变化,继续沿用当前版本。",
metadata: {
task_status: task.taskStatus
}
});
return false; return false;
} }
@ -1065,28 +645,7 @@ export class InMemoryTaskStore {
task.reportVersions = [...task.reportVersions, report.report_version]; task.reportVersions = [...task.reportVersions, report.report_version];
task.defaultReportVersion = report.report_version; task.defaultReportVersion = report.report_version;
task.latestSuccessfulReportVersion = report.report_version; task.latestSuccessfulReportVersion = report.report_version;
this.reportMetrics.push({
taskId: task.taskId,
reportVersion: report.report_version,
taskStatus: task.taskStatus,
selectedLinkCount: report.product_snapshot.selected_link_count,
reviewSampleCount: report.product_snapshot.review_sample_count,
blockedPlatforms: report.quality_flags.blocked_platforms,
failedPlatforms: report.quality_flags.failed_platforms,
sampleInsufficient: report.quality_flags.sample_insufficient,
partialPlatformFailure: report.quality_flags.partial_platform_failure,
generatedAt: report.generated_at
});
this.pushEvent(task, "task.report_published", `报告 v${report.report_version} 已生成。`); this.pushEvent(task, "task.report_published", `报告 v${report.report_version} 已生成。`);
this.pushAudit("report.published", {
taskId: task.taskId,
message: `报告 v${report.report_version} 已生成。`,
metadata: {
report_version: report.report_version,
task_status: task.taskStatus,
review_sample_count: report.product_snapshot.review_sample_count
}
});
return true; return true;
} }
@ -1132,98 +691,6 @@ export class InMemoryTaskStore {
return scenario.outcome; return scenario.outcome;
} }
private recordStrategyAttempt(
taskId: string,
platform: PlatformId,
capability: PlatformCapability,
outcome: StrategyAttemptOutcome,
trigger: StrategyAttemptTrigger,
detail: string,
options?: {
layer?: StrategyLayer;
errorType?: string;
}
): StrategyAttemptRecord {
const durationMs = getMockDurationMs(platform, capability, outcome);
const finishedAt = nowIso();
const startedAt = new Date(Date.parse(finishedAt) - durationMs).toISOString();
const attempt: StrategyAttemptRecord = {
attemptId: randomUUID(),
taskId,
platform,
capability,
layer: options?.layer ?? resolveDefaultLayer(platform, capability),
outcome,
trigger,
startedAt,
finishedAt,
durationMs,
errorType: options?.errorType,
detail
};
const attempts = this.strategyAttempts.get(taskId) ?? [];
attempts.push(attempt);
this.strategyAttempts.set(taskId, attempts);
this.addCapabilityDuration(taskId, platform, capability, durationMs);
return attempt;
}
private addCapabilityDuration(
taskId: string,
platform: PlatformId,
capability: PlatformCapability,
durationMs: number
): void {
const metrics = this.requirePlatformMetrics(taskId);
const target = metrics[platform];
if (capability === "search") {
target.searchDurationMs += durationMs;
} else if (capability === "detail") {
target.detailDurationMs += durationMs;
} else if (capability === "reviews") {
target.reviewsDurationMs += durationMs;
}
target.lastUpdatedAt = nowIso();
}
private incrementRetryCount(taskId: string, platform: PlatformId): void {
const metrics = this.requirePlatformMetrics(taskId);
metrics[platform].retryCount += 1;
metrics[platform].lastUpdatedAt = nowIso();
}
private incrementRecoveryCount(taskId: string, platform: PlatformId): void {
const metrics = this.requirePlatformMetrics(taskId);
metrics[platform].recoveryCount += 1;
metrics[platform].lastUpdatedAt = nowIso();
}
private pushAudit(
action: AuditAction,
input: {
message: string;
taskId?: string;
platform?: PlatformId;
metadata?: AuditLogRecord["metadata"];
}
): void {
if (!auditActions.includes(action)) {
throw new Error(`Unsupported audit action ${action}.`);
}
this.auditLogs.push({
auditId: randomUUID(),
taskId: input.taskId,
platform: input.platform,
action,
message: input.message,
createdAt: nowIso(),
metadata: input.metadata
});
}
private pushEvent(task: TaskRecord, type: string, message: string): void { private pushEvent(task: TaskRecord, type: string, message: string): void {
const event: TaskEventRecord = { const event: TaskEventRecord = {
eventId: randomUUID(), eventId: randomUUID(),
@ -1243,82 +710,11 @@ export class InMemoryTaskStore {
return task; return task;
} }
private requirePlatformMetrics( private requireReadiness(platform: PlatformId): SessionReadinessRecord {
taskId: string const readiness = this.readiness.get(platform);
): Record<PlatformId, PlatformRunMetricRecord> { if (!readiness) {
const metrics = this.platformRunMetrics.get(taskId);
if (!metrics) {
throw new Error(`Platform metrics for task ${taskId} not found.`);
}
return metrics;
}
private toSessionRecord(session: StoredSessionState): SessionStateRecord {
return {
platform: session.platform,
ready: session.ready,
status: session.status,
searchRequirement: session.searchRequirement,
scope: session.scope,
ttlHours: session.ttlHours,
lastPreparedAt: session.lastPreparedAt,
expiresAt: session.expiresAt,
encryptedSnapshotAvailable: Boolean(session.encryptedSnapshot),
cipherLabel: session.encryptedSnapshot ? session.cipherLabel : undefined
};
}
private toReadiness(session: StoredSessionState): SessionReadinessRecord {
return {
platform: session.platform,
ready: session.ready,
status: session.status,
searchRequirement: session.searchRequirement,
reason: this.getSessionReason(session),
lastPreparedAt: session.lastPreparedAt,
expiresAt: session.expiresAt
};
}
private getSessionReason(session: StoredSessionState): string {
switch (session.status) {
case "ready":
return "当前工作区存在可复用会话,创建任务时会再次校验。";
case "expired":
return "最近一次会话已过期,请重新完成会话准备。";
default:
return session.searchRequirement === "required"
? platformCatalogMap[session.platform].recoveryHint
: "当前没有可复用会话,建议先预热以提升搜索稳定性。";
}
}
private requireSession(platform: PlatformId): StoredSessionState {
const session = this.readiness.get(platform);
if (!session) {
throw new Error(`Platform ${platform} not found.`); throw new Error(`Platform ${platform} not found.`);
} }
return readiness;
if (
session.status === "ready" &&
session.expiresAt &&
Date.parse(session.expiresAt) <= Date.now()
) {
const expired: StoredSessionState = {
...session,
ready: false,
status: "expired",
encryptedSnapshot: null,
cipherLabel: undefined
};
this.readiness.set(platform, expired);
return expired;
}
return session;
}
private requireReadiness(platform: PlatformId): SessionReadinessRecord {
return this.toReadiness(this.requireSession(platform));
} }
} }

View File

@ -4,8 +4,6 @@ import {
type CandidateRecord, type CandidateRecord,
type PlatformId, type PlatformId,
type PlatformStatus, type PlatformStatus,
type SessionReadinessRecord,
type SessionStateRecord,
type TaskRecord, type TaskRecord,
type TaskStatus type TaskStatus
} from "@cross-ai/domain"; } from "@cross-ai/domain";
@ -24,13 +22,11 @@ import { PlatformIdentity, PlatformStatusPill, TaskStatusPill } from "./componen
import { TaskContextHeader } from "./components/TaskContextHeader"; import { TaskContextHeader } from "./components/TaskContextHeader";
import { TaskSpine } from "./components/TaskSpine"; import { TaskSpine } from "./components/TaskSpine";
import { import {
clearPlatformSession,
confirmTask, confirmTask,
createTask, createTask,
deleteTask, deleteTask,
getHistoryTasks, getHistoryTasks,
getPlatformReadiness, getPlatformReadiness,
getPlatformSession,
getTask, getTask,
getTaskCandidates, getTaskCandidates,
getTaskReport, getTaskReport,
@ -122,65 +118,12 @@ function getNoReportSummary(task: TaskRecord) {
} }
} }
function formatTimestamp(timestamp?: string) { function NewTaskPage() {
if (!timestamp) {
return "暂无";
}
return new Date(timestamp).toLocaleString("zh-CN");
}
function getSearchRequirementLabel(
searchRequirement: SessionReadinessRecord["searchRequirement"]
) {
switch (searchRequirement) {
case "required":
return "需准备会话";
case "recommended":
return "建议预热";
default:
return "无需会话";
}
}
function getReadinessSummary(readiness: SessionReadinessRecord) {
if (readiness.status === "ready") {
return readiness.expiresAt
? `当前工作区已有可复用会话,有效至 ${formatTimestamp(readiness.expiresAt)}`
: readiness.reason;
}
if (readiness.status === "expired") {
return `最近一次会话已过期,上次准备时间为 ${formatTimestamp(readiness.lastPreparedAt)}`;
}
return readiness.reason;
}
function getSessionSnapshotSummary(session: SessionStateRecord) {
if (session.status === "ready") {
return session.expiresAt
? `已保存加密快照,可复用至 ${formatTimestamp(session.expiresAt)}`
: "已保存加密快照,可用于当前工作区复用。";
}
if (session.status === "expired") {
return "最近一次会话已过期,需重新完成会话准备。";
}
return "当前还没有可复用会话快照。";
}
export function NewTaskPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const readinessQuery = useQuery({ const readinessQuery = useQuery({
queryKey: ["platform-readiness"], queryKey: ["platform-readiness"],
queryFn: getPlatformReadiness queryFn: getPlatformReadiness
}); });
const historyQuery = useQuery({
queryKey: ["history"],
queryFn: getHistoryTasks
});
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [perLinkLimit, setPerLinkLimit] = useState(100); const [perLinkLimit, setPerLinkLimit] = useState(100);
const [taskTotalLimit, setTaskTotalLimit] = useState(500); const [taskTotalLimit, setTaskTotalLimit] = useState(500);
@ -191,7 +134,6 @@ export function NewTaskPage() {
navigate(`/tasks/${task.taskId}/confirm`); navigate(`/tasks/${task.taskId}/confirm`);
} }
}); });
const recentTasks = (historyQuery.data?.tasks ?? []).slice(0, 4);
return ( return (
<Layout> <Layout>
@ -267,63 +209,18 @@ export function NewTaskPage() {
status={platform.ready ? "Completed" : "SearchBlocked"} status={platform.ready ? "Completed" : "SearchBlocked"}
/> />
</div> </div>
<p className="readiness-card__caption"> <p>{platformCatalogMap[platform.platform].description}</p>
{getSearchRequirementLabel(platform.searchRequirement)}
</p>
<p>{getReadinessSummary(platform)}</p>
<div className="readiness-card__meta">
<span className="inline-note inline-note--subtle">
{formatTimestamp(platform.lastPreparedAt)}
</span>
<span className="inline-note inline-note--subtle">
{platform.expiresAt
? `有效至 ${formatTimestamp(platform.expiresAt)}`
: "当前无有效期中的会话"}
</span>
</div>
<div className="panel-actions">
<a <a
className="text-link" className="text-link"
href={`/sessions/${platform.platform}/prepare?from=/tasks/new`} href={`/sessions/${platform.platform}/prepare?from=/tasks/new`}
> >
</a> </a>
</div>
</article> </article>
))} ))}
</div> </div>
</div> </div>
<div className="stack">
<div className="page-panel">
<p className="eyebrow">Recent Tasks</p>
<h3></h3>
<div className="stack stack--dense">
{recentTasks.length > 0 ? (
recentTasks.map((task) => (
<a
key={task.taskId}
className="mini-task-link"
href={getTaskDestination(
task.taskId,
task.taskStatus,
task.hasReport,
task.defaultReportVersion
)}
>
<div className="mini-task-link__topline">
<strong>{task.query}</strong>
<TaskStatusPill status={task.taskStatus} />
</div>
<p>{new Date(task.updatedAt).toLocaleString("zh-CN")}</p>
</a>
))
) : (
<div className="empty-state"></div>
)}
</div>
</div>
<div className="page-panel"> <div className="page-panel">
<p className="eyebrow">Scope Reminder</p> <p className="eyebrow">Scope Reminder</p>
<h3>P0 </h3> <h3>P0 </h3>
@ -333,7 +230,6 @@ export function NewTaskPage() {
<li></li> <li></li>
</ul> </ul>
</div> </div>
</div>
</section> </section>
</Layout> </Layout>
); );
@ -1087,87 +983,34 @@ export function HistoryPage() {
); );
} }
export function SessionPreparePage() { function SessionPreparePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { platform = "tmall" } = useParams(); const { platform = "tmall" } = useParams();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const from = searchParams.get("from") ?? "/tasks/new"; const from = searchParams.get("from") ?? "/tasks/new";
const platformId = platform as PlatformId;
const sessionQuery = useQuery({
queryKey: ["session", platformId],
queryFn: () => getPlatformSession(platformId)
});
const prepareMutation = useMutation({ const prepareMutation = useMutation({
mutationFn: () => preparePlatform(platformId), mutationFn: () => preparePlatform(platform as PlatformId),
onSuccess: async () => { onSuccess: async () => {
await Promise.all([ await queryClient.invalidateQueries({ queryKey: ["platform-readiness"] });
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] }),
queryClient.invalidateQueries({ queryKey: ["session", platformId] })
]);
navigate(from); navigate(from);
} }
}); });
const clearMutation = useMutation({
mutationFn: () => clearPlatformSession(platformId),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] }),
queryClient.invalidateQueries({ queryKey: ["session", platformId] })
]);
}
});
const session = sessionQuery.data?.session;
return ( return (
<Layout> <Layout>
<section className="page-panel session-panel"> <section className="page-panel session-panel">
<p className="eyebrow">Session Console</p> <p className="eyebrow">Session Console</p>
<h2>{platformCatalogMap[platformId].label} </h2> <h2>{platformCatalogMap[platform as PlatformId].label} </h2>
<p>{platformCatalogMap[platformId].recoveryHint}</p> <p>{platformCatalogMap[platform as PlatformId].recoveryHint}</p>
<div className="session-placeholder"> <div className="session-placeholder">
<div className="session-placeholder__viewport">Remote Browser Viewport</div> <div className="session-placeholder__viewport">Remote Browser Viewport</div>
<div className="session-placeholder__sidebar"> <div className="session-placeholder__sidebar">
<strong>prepare</strong> <strong>prepare</strong>
<p></p> <p></p>
<div className="session-details"> <button className="primary-button" onClick={() => prepareMutation.mutate()} type="button">
<div className="session-details__row">
<span></span>
<PlatformStatusPill status={session?.ready ? "Completed" : "SearchBlocked"} />
</div>
<div className="session-details__row">
<span></span>
<strong>{getSearchRequirementLabel(platformCatalogMap[platformId].searchRequirement)}</strong>
</div>
<div className="session-details__row">
<span></span>
<strong>{session?.encryptedSnapshotAvailable ? "已加密保存" : "尚未生成"}</strong>
</div>
<div className="session-details__row">
<span></span>
<strong>{session?.expiresAt ? formatTimestamp(session.expiresAt) : "暂无"}</strong>
</div>
<p>{session ? getSessionSnapshotSummary(session) : "正在读取当前会话状态..."}</p>
<p className="session-return-target">{from}</p>
</div>
<div className="panel-actions">
<button
className="primary-button"
disabled={prepareMutation.isPending}
onClick={() => prepareMutation.mutate()}
type="button"
>
</button> </button>
<button
className="ghost-button"
disabled={clearMutation.isPending || !session?.encryptedSnapshotAvailable}
onClick={() => clearMutation.mutate()}
type="button"
>
</button>
</div>
</div> </div>
</div> </div>
</section> </section>

View File

@ -6,13 +6,11 @@ import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./lib/api", () => ({ vi.mock("./lib/api", () => ({
clearPlatformSession: vi.fn(),
confirmTask: vi.fn(), confirmTask: vi.fn(),
createTask: vi.fn(), createTask: vi.fn(),
deleteTask: vi.fn(), deleteTask: vi.fn(),
getHistoryTasks: vi.fn(), getHistoryTasks: vi.fn(),
getPlatformReadiness: vi.fn(), getPlatformReadiness: vi.fn(),
getPlatformSession: vi.fn(),
getTask: vi.fn(), getTask: vi.fn(),
getTaskCandidates: vi.fn(), getTaskCandidates: vi.fn(),
getTaskReport: vi.fn(), getTaskReport: vi.fn(),

View File

@ -1,158 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./lib/api", () => ({
clearPlatformSession: vi.fn(),
confirmTask: vi.fn(),
createTask: vi.fn(),
deleteTask: vi.fn(),
getHistoryTasks: vi.fn(),
getPlatformReadiness: vi.fn(),
getPlatformSession: vi.fn(),
getTask: vi.fn(),
getTaskCandidates: vi.fn(),
getTaskReport: vi.fn(),
preparePlatform: vi.fn(),
retryTaskPlatform: vi.fn()
}));
import { NewTaskPage, SessionPreparePage } from "./App";
import {
clearPlatformSession,
getHistoryTasks,
getPlatformReadiness,
getPlatformSession,
preparePlatform
} from "./lib/api";
function renderWithProviders(node: ReactNode, initialEntries?: string[]) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false
}
}
});
return render(
<QueryClientProvider client={queryClient}>
{initialEntries ? (
<MemoryRouter initialEntries={initialEntries}>{node}</MemoryRouter>
) : (
<MemoryRouter>{node}</MemoryRouter>
)}
</QueryClientProvider>
);
}
describe("task composer and session console", () => {
beforeEach(() => {
vi.mocked(getPlatformReadiness).mockResolvedValue({
platforms: [
{
platform: "tmall",
ready: true,
status: "ready",
searchRequirement: "recommended",
reason: "当前工作区存在可复用会话,创建任务时会再次校验。",
lastPreparedAt: "2026-04-02T12:00:00.000Z",
expiresAt: "2026-04-03T12:00:00.000Z"
},
{
platform: "jd",
ready: false,
status: "missing",
searchRequirement: "required",
reason: "需要先完成会话准备,否则系统会标记为 SearchBlocked。"
}
]
} as any);
vi.mocked(getHistoryTasks).mockResolvedValue({
tasks: [
{
taskId: "task-1",
query: "Nintendo Switch 2",
taskStatus: "Completed",
updatedAt: "2026-04-02T12:00:00.000Z",
hasReport: true,
defaultReportVersion: 2,
failedPlatforms: [],
blockedPlatforms: []
},
{
taskId: "task-2",
query: "DJI Pocket 3",
taskStatus: "AwaitingConfirmation",
updatedAt: "2026-04-02T11:30:00.000Z",
hasReport: false,
failedPlatforms: [],
blockedPlatforms: ["jd"]
}
]
} as any);
vi.mocked(getPlatformSession).mockResolvedValue({
session: {
platform: "jd",
ready: true,
status: "ready",
searchRequirement: "required",
scope: "workspace",
ttlHours: 24,
lastPreparedAt: "2026-04-02T10:00:00.000Z",
expiresAt: "2026-04-03T10:00:00.000Z",
encryptedSnapshotAvailable: true,
cipherLabel: "mock-aes-gcm-v1"
}
} as any);
vi.mocked(clearPlatformSession).mockResolvedValue(undefined);
vi.mocked(preparePlatform).mockResolvedValue({
platform: "jd",
session_ready: true,
status: "ready",
last_prepared_at: "2026-04-02T10:00:00.000Z",
expires_at: "2026-04-03T10:00:00.000Z",
encrypted_snapshot_available: true
} as any);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("shows readiness details and recent task shortcuts on the new task page", async () => {
renderWithProviders(<NewTaskPage />);
expect(await screen.findByText("最近任务捷径")).toBeInTheDocument();
expect(await screen.findByText("Nintendo Switch 2")).toBeInTheDocument();
expect(await screen.findByText("DJI Pocket 3")).toBeInTheDocument();
expect(await screen.findByText("搜索要求:建议预热")).toBeInTheDocument();
expect(
await screen.findByText(/当前工作区已有可复用会话,有效至/)
).toBeInTheDocument();
});
it("shows session details and allows clearing the current session", async () => {
const user = userEvent.setup();
renderWithProviders(
<Routes>
<Route element={<SessionPreparePage />} path="/sessions/:platform/prepare" />
</Routes>,
["/sessions/jd/prepare?from=/tasks/new"]
);
expect(await screen.findByText("已加密保存")).toBeInTheDocument();
expect(screen.getByText(/完成后将返回:\/tasks\/new/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "清理当前会话" }));
await waitFor(() => {
expect(clearPlatformSession).toHaveBeenCalledWith("jd");
});
});
});

View File

@ -220,8 +220,7 @@ a {
.candidate-card, .candidate-card,
.metric-card, .metric-card,
.insight-card, .insight-card,
.evidence-card, .evidence-card {
.mini-task-link {
border: 1px solid rgba(31, 42, 48, 0.08); border: 1px solid rgba(31, 42, 48, 0.08);
border-radius: 18px; border-radius: 18px;
background: var(--bg-elevated); background: var(--bg-elevated);
@ -232,8 +231,7 @@ a {
.history-card, .history-card,
.metric-card, .metric-card,
.insight-card, .insight-card,
.evidence-card, .evidence-card {
.mini-task-link {
padding: 16px; padding: 16px;
} }
@ -258,37 +256,6 @@ a {
flex-wrap: wrap; flex-wrap: wrap;
} }
.readiness-card__caption {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
font-weight: 700;
}
.readiness-card__meta {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.mini-task-link {
display: grid;
gap: 8px;
color: inherit;
}
.mini-task-link__topline {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
}
.mini-task-link p {
margin: 0;
color: var(--text-secondary);
}
.task-context-header { .task-context-header {
padding: 20px 0 0; padding: 20px 0 0;
} }
@ -457,12 +424,6 @@ a {
background: rgba(20, 108, 110, 0.08); background: rgba(20, 108, 110, 0.08);
} }
.inline-note--subtle {
padding: 8px 10px;
background: rgba(31, 42, 48, 0.06);
color: var(--text-secondary);
}
.metric-card { .metric-card {
display: grid; display: grid;
gap: 6px; gap: 6px;
@ -607,31 +568,6 @@ a {
color: var(--text-secondary); color: var(--text-secondary);
} }
.session-details {
display: grid;
gap: 12px;
padding: 14px;
border-radius: 16px;
background: rgba(31, 42, 48, 0.04);
}
.session-details p {
margin: 0;
color: var(--text-secondary);
}
.session-details__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.session-return-target {
font-family: "IBM Plex Mono", monospace;
font-size: 13px;
}
.list { .list {
margin: 0; margin: 0;
padding-left: 18px; padding-left: 18px;

View File

@ -73,15 +73,12 @@
4. 同时可观察到 `cactus.jd.com/request_algo``jra.jd.com/jsTk.do``sgm-w.jd.com/h5` 等风控/参数初始化请求。 4. 同时可观察到 `cactus.jd.com/request_algo``jra.jd.com/jsTk.do``sgm-w.jd.com/h5` 等风控/参数初始化请求。
5. `https://item.jd.com/robots.txt` 的公开信息非常有限,不能据此推断搜索或详情路径可稳定匿名抓取。 5. `https://item.jd.com/robots.txt` 的公开信息非常有限,不能据此推断搜索或详情路径可稳定匿名抓取。
参考:<https://item.jd.com/robots.txt> 参考:<https://item.jd.com/robots.txt>
6. 2026-04-02 登录后复测中,`https://search.jd.com/Search?keyword=iPhone%2015` 返回的是 Vite 壳页 HTML响应内不再稳定包含商品卡片同 query 的 `pc_search_searchWare` API 回放可稳定返回 30 条候选。
7. 同次复测中,`pc_detailpage_wareBusiness``getLegoWareDetailComment` 在授权会话下可稳定返回价格、店铺、库存、主图、标签与评论正文。
判断: 判断:
1. 京东 PC 端存在明确的接口层,不是只能靠浏览器 DOM 抓;其中搜索页 HTML 已明显退化为前端壳页,不能再把稳定 DOM 解析当作默认主路径 1. 京东 PC 端存在明确的接口层,不是只能靠浏览器 DOM 抓。
2. 但接口调用明显依赖会话与动态参数上下文,不能把它当作无状态公开接口。 2. 但接口调用明显依赖会话与动态参数上下文,不能把它当作无状态公开接口。
3. 当前更稳的默认路线应收敛为“授权会话下的搜索/详情/评论 API 回放”,浏览器负责登录、模板刷新与阻塞恢复。 3. 京东的非浏览器路线是可行的,但必须建立在“先会话、后请求”的体系上。
4. 因此京东的非浏览器路线是可行的,但必须建立在“先会话、后请求”的体系上。
### 3.3 淘宝 ### 3.3 淘宝

View File

@ -4,27 +4,6 @@ export type PlatformId = (typeof platforms)[number];
export const searchRequirements = ["none", "recommended", "required"] as const; export const searchRequirements = ["none", "recommended", "required"] as const;
export type SearchRequirement = (typeof searchRequirements)[number]; export type SearchRequirement = (typeof searchRequirements)[number];
export const sessionStatuses = ["missing", "ready", "expired"] as const;
export type SessionStatus = (typeof sessionStatuses)[number];
export const strategyLayers = ["L0", "L1", "L2", "L3"] as const;
export type StrategyLayer = (typeof strategyLayers)[number];
export const platformCapabilities = ["search", "detail", "reviews", "login"] as const;
export type PlatformCapability = (typeof platformCapabilities)[number];
export const strategyAttemptOutcomes = [
"succeeded",
"blocked",
"failed",
"no_result",
"skipped"
] as const;
export type StrategyAttemptOutcome = (typeof strategyAttemptOutcomes)[number];
export const strategyAttemptTriggers = ["system", "retry", "recovery"] as const;
export type StrategyAttemptTrigger = (typeof strategyAttemptTriggers)[number];
export const taskStatuses = [ export const taskStatuses = [
"Draft", "Draft",
"Searching", "Searching",
@ -85,18 +64,3 @@ export type SampleFlag = (typeof sampleFlags)[number];
export const evidenceSourceTypes = ["product", "review"] as const; export const evidenceSourceTypes = ["product", "review"] as const;
export type EvidenceSourceType = (typeof evidenceSourceTypes)[number]; export type EvidenceSourceType = (typeof evidenceSourceTypes)[number];
export const retentionActions = ["task_deleted", "retention_cleanup"] as const;
export type RetentionAction = (typeof retentionActions)[number];
export const auditActions = [
"session.prepared",
"session.cleared",
"task.recovery_completed",
"platform.retry_started",
"platform.retry_completed",
"task.deleted",
"report.published",
"report.unchanged"
] as const;
export type AuditAction = (typeof auditActions)[number];

View File

@ -1,15 +1,7 @@
import type { import type {
AuditAction,
PlatformCapability,
PlatformId, PlatformId,
PlatformStatus, PlatformStatus,
SearchRequirement, SearchRequirement,
ReportableTaskStatus,
RetentionAction,
SessionStatus,
StrategyAttemptOutcome,
StrategyAttemptTrigger,
StrategyLayer,
TaskStage, TaskStage,
TaskStatus TaskStatus
} from "./enums"; } from "./enums";
@ -54,120 +46,8 @@ export interface TaskEventRecord {
export interface SessionReadinessRecord { export interface SessionReadinessRecord {
platform: PlatformId; platform: PlatformId;
ready: boolean; ready: boolean;
status: SessionStatus;
searchRequirement: SearchRequirement; searchRequirement: SearchRequirement;
reason: string;
lastPreparedAt?: string | undefined; lastPreparedAt?: string | undefined;
expiresAt?: string | undefined;
}
export interface SessionStateRecord {
platform: PlatformId;
ready: boolean;
status: SessionStatus;
searchRequirement: SearchRequirement;
scope: "workspace";
ttlHours: number;
lastPreparedAt?: string | undefined;
expiresAt?: string | undefined;
encryptedSnapshotAvailable: boolean;
cipherLabel?: string | undefined;
}
export interface StrategyAttemptRecord {
attemptId: string;
taskId: string;
platform: PlatformId;
capability: PlatformCapability;
layer: StrategyLayer;
outcome: StrategyAttemptOutcome;
trigger: StrategyAttemptTrigger;
startedAt: string;
finishedAt: string;
durationMs: number;
errorType?: string | undefined;
detail: string;
}
export interface PlatformRunMetricRecord {
taskId: string;
platform: PlatformId;
searchDurationMs: number;
detailDurationMs: number;
reviewsDurationMs: number;
retryCount: number;
recoveryCount: number;
lastUpdatedAt: string;
}
export interface ReportMetricRecord {
taskId: string;
reportVersion: number;
taskStatus: ReportableTaskStatus;
selectedLinkCount: number;
reviewSampleCount: number;
blockedPlatforms: PlatformId[];
failedPlatforms: PlatformId[];
sampleInsufficient: boolean;
partialPlatformFailure: boolean;
generatedAt: string;
}
export interface RetentionMetricRecord {
metricId: string;
action: RetentionAction;
taskId?: string | undefined;
deletedReportCount: number;
deletedArtifactCount: number;
residualArtifactCount: number;
recordedAt: string;
}
export type AuditMetadataValue = string | number | boolean | null;
export type AuditMetadata = Record<string, AuditMetadataValue>;
export interface AuditLogRecord {
auditId: string;
taskId?: string | undefined;
platform?: PlatformId | undefined;
action: AuditAction;
message: string;
createdAt: string;
metadata?: AuditMetadata | undefined;
}
export interface ObservabilityOverview {
strategyAttempts: {
total: number;
searchSuccessRate: number;
browserFallbackShare: number;
byLayer: Array<{
layer: StrategyLayer;
count: number;
}>;
byOutcome: Array<{
outcome: StrategyAttemptOutcome;
count: number;
}>;
};
platformRuns: PlatformRunMetricRecord[];
reports: {
published: number;
unchanged: number;
sampleInsufficient: number;
partialFailures: number;
};
retention: {
taskDeletes: number;
cleanupRuns: number;
deletedReports: number;
residualArtifacts: number;
};
audits: {
total: number;
recoveryActions: number;
deleteActions: number;
};
} }
export interface TaskRecord { export interface TaskRecord {