feat: 接入运维扫码登录与任务实时执行链路

This commit is contained in:
2026-04-07 19:53:40 +08:00
parent 8c4d057202
commit 4ea9eaf4a6
20 changed files with 3338 additions and 188 deletions
+2 -1
View File
@@ -14,6 +14,7 @@
"@cross-ai/domain": "file:../../packages/domain",
"@cross-ai/report-schema": "file:../../packages/report-schema",
"@fastify/cors": "^11.2.0",
"fastify": "^5.8.4"
"fastify": "^5.8.4",
"playwright-core": "^1.59.1"
}
}
+214
View File
@@ -0,0 +1,214 @@
import type { BrowserContext, Page, Request } from "playwright-core";
import { describe, expect, it, vi } from "vitest";
import type {
JdLiveSessionInput,
JdSessionManager,
JdSessionManagerConfigInput,
JdSessionManagerRunResult,
JdSessionManagerState
} from "../platforms/jd/types";
import { JdOpsQrLoginService, takeLocatorScreenshotDataUrl } from "./qr-login";
const DEFAULT_TEST_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
type FakeResources = {
context: Pick<BrowserContext, "cookies" | "pages" | "close" | "storageState">;
page: Page;
};
function createSessionManagerStub() {
const importManualSession = vi.fn<
(input: JdLiveSessionInput, source?: string) => Promise<JdSessionManagerState>
>(async () => ({ status: "healthy" } as JdSessionManagerState));
const runHealthCheck = vi.fn<(trigger?: string) => Promise<JdSessionManagerRunResult>>(
async () =>
({
recovered: false,
state: {
status: "healthy"
}
}) as JdSessionManagerRunResult
);
const stub: JdSessionManager = {
getState(): JdSessionManagerState {
return { status: "idle" } as JdSessionManagerState;
},
configure(_input: JdSessionManagerConfigInput): JdSessionManagerState {
return { status: "idle" } as JdSessionManagerState;
},
clearConfig(): JdSessionManagerState {
return { status: "idle" } as JdSessionManagerState;
},
importManualSession,
clearManagedSession(): JdSessionManagerState {
return { status: "idle" } as JdSessionManagerState;
},
runHealthCheck,
async runAutoRecovery() {
return {
recovered: false,
state: { status: "idle" } as JdSessionManagerState
};
},
async handleLiveFailure() {
return false;
},
shutdown() {
return;
}
};
return {
stub,
importManualSession,
runHealthCheck
};
}
class TestableJdOpsQrLoginService extends JdOpsQrLoginService {
setTestResources(resources: FakeResources) {
this.setResources(resources as unknown as {
browser: never;
context: BrowserContext;
page: Page;
});
}
setCapturedSession(session: Record<string, unknown>) {
Object.assign(
(this as unknown as { latestCapturedSession: Record<string, unknown> }).latestCapturedSession,
session
);
}
}
describe("JdOpsQrLoginService", () => {
it("skips a detached QR locator and captures the next visible selector", async () => {
const staleLocator = {
first: vi.fn(function (this: typeof staleLocator) {
return this;
}),
count: vi.fn(async () => 1),
isVisible: vi.fn(async () => true),
screenshot: vi.fn(async () => {
throw new Error("Element is not attached to the DOM");
})
};
const liveScreenshot = Uint8Array.from([1, 2, 3]);
const liveLocator = {
first: vi.fn(function (this: typeof liveLocator) {
return this;
}),
count: vi.fn(async () => 1),
isVisible: vi.fn(async () => true),
screenshot: vi.fn(async () => liveScreenshot)
};
const page = {
locator: vi.fn((selector: string) => {
return selector === ".stale-qr" ? staleLocator : liveLocator;
})
} as unknown as Page;
await expect(takeLocatorScreenshotDataUrl(page, [".stale-qr", ".live-qr"])).resolves.toBe(
"data:image/png;base64,AQID"
);
expect(staleLocator.screenshot).toHaveBeenCalledTimes(1);
expect(liveLocator.screenshot).toHaveBeenCalledTimes(1);
});
it("waits for a fresh search request during manual recovery and reimports captured headers", async () => {
const { stub, importManualSession, runHealthCheck } = createSessionManagerStub();
const service = new TestableJdOpsQrLoginService(stub);
let currentUrl =
"https://search.jd.com/Search?keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=old";
let resolveRequest: ((request: Request) => void) | undefined;
const request = {
url: () =>
"https://api.m.jd.com/api?functionId=pc_search_searchWare&body=%7B%22keyword%22:%22%E5%A4%A7%E7%96%86pocket3%22%7D&keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=2.def.0.real&wq=%E5%A4%A7%E7%96%86pocket3&pvid=real-pvid&spmTag=real-spm",
allHeaders: async () => ({
":authority": "api.m.jd.com",
referer:
"https://search.jd.com/Search?keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=2.def.0.real&wq=%E5%A4%A7%E7%96%86pocket3&pvid=real-pvid&spmTag=real-spm",
"accept-language": "zh-CN,zh;q=0.9",
"sec-fetch-site": "same-site"
})
} as unknown as Request;
const page = {
url: () => currentUrl,
isClosed: () => false,
waitForRequest: vi.fn(async () => {
return await new Promise<Request>((resolve) => {
resolveRequest = resolve;
});
}),
reload: vi.fn(async () => {
currentUrl =
"https://search.jd.com/Search?keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=2.def.0.real&wq=%E5%A4%A7%E7%96%86pocket3&pvid=real-pvid&spmTag=real-spm";
resolveRequest?.(request);
}),
goto: vi.fn(async () => undefined),
waitForLoadState: vi.fn(async () => undefined),
waitForTimeout: vi.fn(async () => undefined),
evaluate: vi.fn(async () => DEFAULT_TEST_USER_AGENT),
close: vi.fn(async () => undefined)
} as unknown as Page;
const context = {
cookies: vi.fn(async () => [
{
name: "thor",
value: "masked",
domain: ".jd.com"
}
]),
pages: vi.fn(() => [page]),
close: vi.fn(async () => undefined),
storageState: vi.fn(async () => ({
cookies: [],
origins: []
}))
} as unknown as Pick<BrowserContext, "cookies" | "pages" | "close" | "storageState">;
service.setTestResources({
context,
page
});
service.setCapturedSession({
targetSkuId: "100068388533",
targetSearchQuery: "大疆pocket3",
targetProductUrl: "https://item.jd.com/100068388533.html",
targetSearchUrl:
"https://search.jd.com/Search?keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=old",
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,%22page%22:1,%22commentNum%22:5%7D",
searchCapturedAt: 1
});
const state = await service.resumeManualRecovery();
expect(page.reload).toHaveBeenCalledTimes(1);
expect(importManualSession).toHaveBeenCalledWith(
expect.objectContaining({
cookieHeader: "thor=masked",
userAgent: DEFAULT_TEST_USER_AGENT,
searchApiTemplateUrl: request.url(),
searchReferer:
"https://search.jd.com/Search?keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=2.def.0.real&wq=%E5%A4%A7%E7%96%86pocket3&pvid=real-pvid&spmTag=real-spm",
searchRequestHeaders: {
"accept-language": "zh-CN,zh;q=0.9",
"sec-fetch-site": "same-site"
}
}),
"ops-qr-login-manual-recovery"
);
expect(runHealthCheck).toHaveBeenCalledWith("ops-qr-login-manual-recovery");
expect(state.status).toBe("completed");
expect(state.sessionImported).toBe(true);
});
});
File diff suppressed because it is too large Load Diff
@@ -60,6 +60,65 @@ describe("JdLiveSessionService", () => {
});
});
it("reuses imported JD search API templates and rewrites query parameters", async () => {
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
buildResponse({
data: {
wareList: [
{
skuId: "100068388533",
imageurl: "//img14.360buyimg.com/n7/jfs/t1/example.jpg",
wname: "大疆 Pocket 3 全能套装",
good: "99%",
jdPrice: "5199.00",
shopName: "京东自营"
}
]
}
})
);
vi.stubGlobal("fetch", fetchMock);
const service = new JdLiveSessionService();
service.importSession({
cookieHeader: "thor=masked;",
searchApiTemplateUrl:
"https://api.m.jd.com/api?functionId=pc_search_searchWare&body=%7B%22keyword%22:%22iPhone%2015%22,%22wq%22:%22iPhone%2015%22,%22page%22:1%7D&keyword=iPhone%2015&enc=utf-8&suggest=2.def.0.test&wq=iPhone%2015&pvid=test-pvid&spmTag=test-spm",
searchRequestHeaders: {
":authority": "api.m.jd.com",
"accept-language": "zh-CN,zh;q=0.9",
"sec-fetch-site": "same-site",
cookie: "should-be-ignored"
}
});
const preview = await service.previewSearch("大疆pocket3");
expect(preview.source).toBe("api");
expect(preview.candidateCount).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
const requestUrl = fetchMock.mock.calls[0]?.[0];
const requestInit = fetchMock.mock.calls[0]?.[1];
if (!requestUrl || !requestInit) {
throw new Error("Expected JD search preview to call fetch with url and init.");
}
const parsedUrl = new URL(requestUrl);
const decodedBody = decodeURIComponent(parsedUrl.searchParams.get("body") ?? "");
expect(parsedUrl.searchParams.get("keyword")).toBe("大疆pocket3");
expect(parsedUrl.searchParams.get("wq")).toBe("大疆pocket3");
expect(parsedUrl.searchParams.get("pvid")).toBe("test-pvid");
expect(decodedBody).toContain(`"keyword":"${parsedUrl.searchParams.get("keyword")}"`);
expect(decodedBody).toContain(`"wq":"${parsedUrl.searchParams.get("wq")}"`);
expect(requestInit.headers).toMatchObject({
Referer: expect.stringContaining("keyword=%E5%A4%A7%E7%96%86pocket3"),
"User-Agent": expect.any(String),
"accept-language": "zh-CN,zh;q=0.9",
"sec-fetch-site": "same-site"
});
expect(requestInit.headers).not.toHaveProperty(":authority");
});
it("paginates and deduplicates JD reviews across multiple pages", async () => {
const fetchMock = vi
.fn<(input: string, init?: RequestInit) => Promise<Response>>()
@@ -269,6 +328,155 @@ describe("JdLiveSessionService", () => {
});
});
it("falls back to browser-backed search preview when Node replay is risk-blocked", async () => {
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
new Response("blocked", { status: 403 })
);
vi.stubGlobal("fetch", fetchMock);
const service = new JdLiveSessionService();
const browserPreviewSearch = vi.fn(async () => ({
query: "大疆pocket3",
source: "html" as const,
candidateCount: 1,
candidates: [
{
candidateId: "jd-100068388533",
platform: "jd" as const,
title: "大疆 Pocket 3 全能套装",
price: 5199,
priceLabel: "CNY 5199",
storeName: "京东自营",
productUrl: "https://item.jd.com/100068388533.html",
imageUrl: "https://img14.360buyimg.com/n7/jfs/t1/example.jpg",
salesHint: "sold 500+",
specLabel: "标准版",
highlights: ["browser fallback"]
}
]
}));
service.setBrowserPreviewProvider({
previewSearch: browserPreviewSearch,
previewDetail: vi.fn(),
previewReviews: vi.fn()
});
service.importSession({
cookieHeader: "thor=masked;",
searchApiTemplateUrl:
"https://api.m.jd.com/api?functionId=pc_search_searchWare&body=%7B%22keyword%22:%22iPhone%2015%22,%22wq%22:%22iPhone%2015%22,%22page%22:1%7D&keyword=iPhone%2015&enc=utf-8"
});
const preview = await service.previewSearch("大疆pocket3");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(browserPreviewSearch).toHaveBeenCalledWith("大疆pocket3", "api");
expect(preview).toMatchObject({
source: "html",
candidateCount: 1
});
});
it("falls back to browser-backed detail preview when Node replay is risk-blocked", async () => {
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
new Response("blocked", { status: 403 })
);
vi.stubGlobal("fetch", fetchMock);
const service = new JdLiveSessionService();
const browserPreviewDetail = vi.fn(async () => ({
skuId: "100068388535",
source: "api" as const,
detail: {
skuId: "100068388535",
title: "Apple iPhone 15",
price: "4398.00",
originalPrice: "4599.00",
estimatedPrice: "4398.00",
shopName: "JD Self Operated",
vendorId: null,
categoryPath: [],
stockState: "in stock",
mainImage: null,
averageScore: "4.9"
}
}));
service.setBrowserPreviewProvider({
previewSearch: vi.fn(),
previewDetail: browserPreviewDetail,
previewReviews: vi.fn()
});
service.importSession({
cookieHeader: "thor=masked;",
detailTemplateUrl:
"https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness&body=%7B%22skuId%22:%22100068388533%22,%22area%22:%222_2813_61125_0%22%7D"
});
const preview = await service.previewDetail("100068388535");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(browserPreviewDetail).toHaveBeenCalledWith("100068388535");
expect(preview.detail.title).toBe("Apple iPhone 15");
});
it("falls back to browser-backed reviews preview when Node replay is risk-blocked", async () => {
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
new Response("blocked", { status: 403 })
);
vi.stubGlobal("fetch", fetchMock);
const service = new JdLiveSessionService();
const browserPreviewReviews = vi.fn(async () => ({
skuId: "100068388535",
source: "api" as const,
pagination: {
requestedPage: 1,
requestedCommentCount: 2,
maxPages: 1,
pagesFetched: 1
},
reviews: {
skuId: "100068388535",
total: "1000",
goodRate: "96%",
pictureCount: "120",
tags: [],
comments: [
{
id: "comment-1",
content: "great",
score: "5",
creationTime: null,
userLevelName: null
}
]
}
}));
service.setBrowserPreviewProvider({
previewSearch: vi.fn(),
previewDetail: vi.fn(),
previewReviews: browserPreviewReviews
});
service.importSession({
cookieHeader: "thor=masked;",
reviewsTemplateUrl:
"https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22shopType%22:%220%22,%22sku%22:100068388533,%22commentNum%22:2,%22page%22:1,%22source%22:%22pc%22%7D"
});
const preview = await service.previewReviews("100068388535", {
commentCount: 2,
page: 1,
maxPages: 1
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(browserPreviewReviews).toHaveBeenCalledWith("100068388535", {
commentCount: 2,
page: 1,
maxPages: 1
});
expect(preview.reviews.comments).toHaveLength(1);
});
it("rejects a detail template that still resolves to another sku", async () => {
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
buildResponse({
+323 -140
View File
@@ -5,6 +5,7 @@ import {
parseJdSearchHtml
} from "./parsers";
import type {
JdBrowserPreviewProvider,
JdDetailPreviewResult,
JdLiveService,
JdProductPreviewResult,
@@ -38,6 +39,7 @@ const REVIEW_PAGE_BODY_KEYS = [
"commentPage"
];
const REVIEW_PAGE_SIZE_BODY_KEYS = ["commentNum", "pageSize", "pageLimit"];
const SEARCH_QUERY_BODY_KEYS = ["keyword", "wq", "word", "keyWord", "query"];
const SEARCH_FUNCTION_ID = "pc_search_searchWare";
const DETAIL_FUNCTION_ID = "pc_detailpage_wareBusiness";
const REVIEWS_FUNCTION_ID = "getLegoWareDetailComment";
@@ -81,6 +83,9 @@ type StoredJdLiveSession = {
reviewsTemplateUrl?: string | undefined;
searchReferer?: string | undefined;
detailReferer?: string | undefined;
searchRequestHeaders?: Record<string, string> | undefined;
detailRequestHeaders?: Record<string, string> | undefined;
reviewsRequestHeaders?: Record<string, string> | undefined;
};
class JdLiveError extends Error {
@@ -158,20 +163,52 @@ function buildTemplateSummary(templateUrl: string | undefined): JdTemplateSummar
};
}
function templateMatchesQuery(
templateUrl: string | undefined,
query: string
): boolean {
if (!templateUrl) {
return false;
function normalizeCapturedHeaders(
input: Record<string, string> | undefined
): Record<string, string> | undefined {
if (!input) {
return undefined;
}
try {
const templateKeyword = new URL(templateUrl).searchParams.get("keyword");
return Boolean(templateKeyword && templateKeyword === query);
} catch {
return false;
const isValidReplayHeaderName = (key: string) => /^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(key);
const entries = Object.entries(input)
.map(([key, value]) => [key.trim().toLowerCase(), value.trim()] as const)
.filter(([key, value]) => {
return (
key.length > 0 &&
value.length > 0 &&
!key.startsWith(":") &&
isValidReplayHeaderName(key) &&
key !== "cookie" &&
key !== "referer" &&
key !== "host" &&
key !== "content-length" &&
key !== "connection" &&
key !== "accept-encoding"
);
});
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
function buildReplayHeaders(
capturedHeaders: Record<string, string> | undefined,
required: {
accept: string;
cookie: string;
referer: string;
userAgent: string;
}
): Record<string, string> {
const normalizedCapturedHeaders = normalizeCapturedHeaders(capturedHeaders);
return {
...(normalizedCapturedHeaders ?? {}),
...(normalizedCapturedHeaders?.accept ? {} : { Accept: required.accept }),
Cookie: required.cookie,
Referer: required.referer,
"User-Agent": required.userAgent
};
}
function coerceBodyValue(existing: unknown, nextValue: number | string): number | string {
@@ -316,6 +353,78 @@ function buildDetailRequestUrl(templateUrl: string, skuId: string): string {
}));
}
function buildSearchApiRequestUrl(templateUrl: string, query: string): string {
let requestUrl = new URL(templateUrl);
requestUrl.searchParams.set("keyword", query);
if (requestUrl.searchParams.has("wq")) {
requestUrl.searchParams.set("wq", query);
}
if (!requestUrl.searchParams.has("enc")) {
requestUrl.searchParams.set("enc", "utf-8");
}
if (readQueryBody(requestUrl)) {
requestUrl = new URL(
withUpdatedQueryBody(requestUrl, (currentBody) => {
const nextBody: Record<string, unknown> = {
...currentBody
};
let updated = false;
for (const key of SEARCH_QUERY_BODY_KEYS) {
if (!(key in currentBody)) {
continue;
}
nextBody[key] = coerceBodyValue(currentBody[key], query);
updated = true;
}
if (!updated) {
nextBody.keyword = query;
}
return nextBody;
})
);
}
return requestUrl.toString();
}
function buildSearchPageUrlFromTemplate(templateUrl: string, query: string): string {
const template = new URL(templateUrl);
const searchUrl = new URL("https://search.jd.com/Search");
for (const [key, value] of template.searchParams.entries()) {
if (
key === "functionId" ||
key === "appid" ||
key === "client" ||
key === "clientVersion" ||
key === "t" ||
key === "loginType" ||
key === "body"
) {
continue;
}
searchUrl.searchParams.set(key, value);
}
searchUrl.searchParams.set("keyword", query);
if (searchUrl.searchParams.has("wq")) {
searchUrl.searchParams.set("wq", query);
}
if (!searchUrl.searchParams.has("enc")) {
searchUrl.searchParams.set("enc", "utf-8");
}
return searchUrl.toString();
}
function buildReviewsRequestUrl(
templateUrl: string,
skuId: string,
@@ -472,6 +581,11 @@ export function getJdLiveErrorCode(error: unknown): JdLiveErrorCode | undefined
export class JdLiveSessionService implements JdLiveService {
private session: StoredJdLiveSession | null = readEnvSession();
private browserPreviewProvider: JdBrowserPreviewProvider | null = null;
setBrowserPreviewProvider(provider: JdBrowserPreviewProvider | null): void {
this.browserPreviewProvider = provider;
}
getSessionSummary(): JdLiveSessionSummary {
return {
@@ -491,6 +605,9 @@ export class JdLiveSessionService implements JdLiveService {
const reviewsTemplateUrl = input.reviewsTemplateUrl?.trim();
const searchReferer = input.searchReferer?.trim();
const detailReferer = input.detailReferer?.trim();
const searchRequestHeaders = normalizeCapturedHeaders(input.searchRequestHeaders);
const detailRequestHeaders = normalizeCapturedHeaders(input.detailRequestHeaders);
const reviewsRequestHeaders = normalizeCapturedHeaders(input.reviewsRequestHeaders);
validateImportedTemplates({
searchApiTemplateUrl,
@@ -506,7 +623,10 @@ export class JdLiveSessionService implements JdLiveService {
...(detailTemplateUrl ? { detailTemplateUrl } : {}),
...(reviewsTemplateUrl ? { reviewsTemplateUrl } : {}),
...(searchReferer ? { searchReferer } : {}),
...(detailReferer ? { detailReferer } : {})
...(detailReferer ? { detailReferer } : {}),
...(searchRequestHeaders ? { searchRequestHeaders } : {}),
...(detailRequestHeaders ? { detailRequestHeaders } : {}),
...(reviewsRequestHeaders ? { reviewsRequestHeaders } : {})
};
return this.getSessionSummary();
@@ -526,77 +646,75 @@ export class JdLiveSessionService implements JdLiveService {
throw new JdLiveError("query is required for JD live search preview.", 400, "BAD_REQUEST");
}
const resolvedMode =
mode ??
(templateMatchesQuery(session.searchApiTemplateUrl, normalizedQuery) ? "api" : "html");
const resolvedMode = mode ?? (session.searchApiTemplateUrl ? "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.",
409,
"TEMPLATE_MISSING"
);
}
try {
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.",
409,
"TEMPLATE_MISSING"
);
}
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.",
409,
"TEMPLATE_QUERY_LOCKED"
const requestUrl = buildSearchApiRequestUrl(session.searchApiTemplateUrl, normalizedQuery);
const referer = buildSearchPageUrlFromTemplate(
session.searchApiTemplateUrl,
normalizedQuery
);
const response = await fetchTextOrThrow(
requestUrl,
{
headers: buildReplayHeaders(session.searchRequestHeaders, {
accept: "application/json, text/plain, */*",
cookie: session.cookieHeader,
referer,
userAgent: 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(
session.searchApiTemplateUrl,
searchUrl,
{
headers: {
Accept: "application/json, text/plain, */*",
Cookie: session.cookieHeader,
Referer:
session.searchReferer ??
`https://search.jd.com/Search?keyword=${encodeURIComponent(normalizedQuery)}`,
"User-Agent": session.userAgent
}
headers: buildReplayHeaders(session.searchRequestHeaders, {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
cookie: session.cookieHeader,
referer: session.searchReferer ?? "https://www.jd.com/",
userAgent: 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 });
const candidates = parseJdSearchHtml(normalizedQuery, response.text);
return {
query: normalizedQuery,
source: "api",
source: "html",
candidateCount: candidates.length,
candidates
};
} catch (error) {
const browserPreview = await this.tryBrowserSearchFallback(error, normalizedQuery, resolvedMode);
if (browserPreview) {
return browserPreview;
}
throw error;
}
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> {
@@ -616,33 +734,42 @@ export class JdLiveSessionService implements JdLiveService {
const requestUrl = buildDetailRequestUrl(session.detailTemplateUrl, normalizedSkuId);
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 detail session appears invalid. Re-login in the browser and re-import the cookie/header."
);
const detail = parseJdDetailApiResponse(normalizedSkuId, { text: response.text });
if (detail.skuId !== normalizedSkuId) {
throw new JdLiveError(
`JD detail template appears bound to another sku (${detail.skuId}). Capture a fresh pc_detailpage_wareBusiness request first.`,
409,
"TEMPLATE_EXPIRED"
try {
const response = await fetchTextOrThrow(
requestUrl,
{
headers: buildReplayHeaders(session.detailRequestHeaders, {
accept: "application/json, text/plain, */*",
cookie: session.cookieHeader,
referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`,
userAgent: session.userAgent
})
},
"JD detail session appears invalid. Re-login in the browser and re-import the cookie/header."
);
}
return {
skuId: normalizedSkuId,
source: "api",
detail
};
const detail = parseJdDetailApiResponse(normalizedSkuId, { text: response.text });
if (detail.skuId !== normalizedSkuId) {
throw new JdLiveError(
`JD detail template appears bound to another sku (${detail.skuId}). Capture a fresh pc_detailpage_wareBusiness request first.`,
409,
"TEMPLATE_EXPIRED"
);
}
return {
skuId: normalizedSkuId,
source: "api",
detail
};
} catch (error) {
const browserPreview = await this.tryBrowserDetailFallback(error, normalizedSkuId);
if (browserPreview) {
return browserPreview;
}
throw error;
}
}
async previewReviews(
@@ -668,64 +795,77 @@ export class JdLiveSessionService implements JdLiveService {
let pageKey: string | undefined;
const seenCommentIds = new Set<string>();
for (let pageOffset = 0; pageOffset < resolvedOptions.maxPages; pageOffset += 1) {
const currentPage = resolvedOptions.page + pageOffset;
const request = buildReviewsRequestUrl(
session.reviewsTemplateUrl,
normalizedSkuId,
resolvedOptions,
currentPage
);
pageKey ??= request.pageKey;
try {
for (let pageOffset = 0; pageOffset < resolvedOptions.maxPages; pageOffset += 1) {
const currentPage = resolvedOptions.page + pageOffset;
const request = buildReviewsRequestUrl(
session.reviewsTemplateUrl,
normalizedSkuId,
resolvedOptions,
currentPage
);
pageKey ??= request.pageKey;
const response = await fetchTextOrThrow(
request.url,
{
headers: {
Accept: "application/json, text/plain, */*",
Cookie: session.cookieHeader,
Referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`,
"User-Agent": session.userAgent
const response = await fetchTextOrThrow(
request.url,
{
headers: buildReplayHeaders(session.reviewsRequestHeaders, {
accept: "application/json, text/plain, */*",
cookie: session.cookieHeader,
referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`,
userAgent: session.userAgent
})
},
"JD reviews session appears invalid. Re-login in the browser and re-import the cookie/header."
);
const parsedPage = parseJdReviewsApiResponse(normalizedSkuId, { text: response.text });
reviewPages.push(parsedPage);
let newCommentCount = 0;
for (const comment of parsedPage.comments) {
if (seenCommentIds.has(comment.id)) {
continue;
}
},
"JD reviews session appears invalid. Re-login in the browser and re-import the cookie/header."
);
const parsedPage = parseJdReviewsApiResponse(normalizedSkuId, { text: response.text });
reviewPages.push(parsedPage);
let newCommentCount = 0;
for (const comment of parsedPage.comments) {
if (seenCommentIds.has(comment.id)) {
continue;
seenCommentIds.add(comment.id);
newCommentCount += 1;
}
seenCommentIds.add(comment.id);
newCommentCount += 1;
if (
parsedPage.comments.length === 0 ||
newCommentCount === 0 ||
parsedPage.comments.length < resolvedOptions.commentCount
) {
break;
}
}
if (
parsedPage.comments.length === 0 ||
newCommentCount === 0 ||
parsedPage.comments.length < resolvedOptions.commentCount
) {
break;
const pagination: JdReviewsPaginationSummary = {
requestedPage: resolvedOptions.page,
requestedCommentCount: resolvedOptions.commentCount,
maxPages: resolvedOptions.maxPages,
pagesFetched: reviewPages.length,
...(pageKey ? { pageKey } : {})
};
return {
skuId: normalizedSkuId,
source: "api",
pagination,
reviews: mergeReviewPages(normalizedSkuId, reviewPages)
};
} catch (error) {
const browserPreview = await this.tryBrowserReviewsFallback(
error,
normalizedSkuId,
options
);
if (browserPreview) {
return browserPreview;
}
throw error;
}
const pagination: JdReviewsPaginationSummary = {
requestedPage: resolvedOptions.page,
requestedCommentCount: resolvedOptions.commentCount,
maxPages: resolvedOptions.maxPages,
pagesFetched: reviewPages.length,
...(pageKey ? { pageKey } : {})
};
return {
skuId: normalizedSkuId,
source: "api",
pagination,
reviews: mergeReviewPages(normalizedSkuId, reviewPages)
};
}
async previewProduct(
@@ -757,4 +897,47 @@ export class JdLiveSessionService implements JdLiveService {
return this.session;
}
private shouldUseBrowserFallback(error: unknown): boolean {
const code = getJdLiveErrorCode(error);
return Boolean(
this.browserPreviewProvider &&
(code === "RISK_BLOCKED" || code === "NETWORK_ERROR" || code === "HTTP_ERROR")
);
}
private async tryBrowserSearchFallback(
error: unknown,
query: string,
mode: JdSearchMode
): Promise<JdSearchPreviewResult | null> {
if (!this.shouldUseBrowserFallback(error) || !this.browserPreviewProvider) {
return null;
}
return this.browserPreviewProvider.previewSearch(query, mode);
}
private async tryBrowserDetailFallback(
error: unknown,
skuId: string
): Promise<JdDetailPreviewResult | null> {
if (!this.shouldUseBrowserFallback(error) || !this.browserPreviewProvider) {
return null;
}
return this.browserPreviewProvider.previewDetail(skuId);
}
private async tryBrowserReviewsFallback(
error: unknown,
skuId: string,
options?: number | JdReviewsPreviewOptions
): Promise<JdReviewsPreviewResult | null> {
if (!this.shouldUseBrowserFallback(error) || !this.browserPreviewProvider) {
return null;
}
return this.browserPreviewProvider.previewReviews(skuId, options);
}
}
+58
View File
@@ -148,4 +148,62 @@ describe("JD parsers", () => {
userLevelName: "PLUS会员"
});
});
it("falls back to lastCommentInfoList when commentInfoList is missing", () => {
const reviews = parseJdReviewsApiResponse("100068388533", {
allCnt: "3",
goodRate: "98%",
lastCommentInfoList: [
{
commentId: "comment-last-1",
commentData: "fallback review",
commentDate: "2026-04-07 19:00:05",
userNickName: "fallback-user"
}
]
});
expect(reviews.comments).toEqual([
expect.objectContaining({
id: "comment-last-1",
content: "fallback review",
creationTime: "2026-04-07 19:00:05",
userLevelName: "fallback-user"
})
]);
});
it("unwraps nested JD review payloads and commentInfo wrappers", () => {
const reviews = parseJdReviewsApiResponse("100068388533", {
code: 0,
data: {
allCnt: "12",
goodRate: "96%",
commentInfoList: [
{
commentInfo: {
commentId: "comment-nested-1",
content: "nested review",
commentScore: 4,
creationTime: "2026-04-07 20:12:33",
userNickName: "nested-user"
}
}
]
}
});
expect(reviews).toMatchObject({
total: "12",
goodRate: "96%"
});
expect(reviews.comments).toEqual([
expect.objectContaining({
id: "comment-nested-1",
content: "nested review",
score: "4",
creationTime: "2026-04-07 20:12:33",
userLevelName: "nested-user"
})
]);
});
});
+122 -11
View File
@@ -30,6 +30,66 @@ function unwrapCapturedPayload(input: unknown): unknown {
}
}
function collectNestedPayloads(
root: Record<string, unknown> | null
): Record<string, unknown>[] {
if (!root) {
return [];
}
const queue: Record<string, unknown>[] = [root];
const seen = new Set<Record<string, unknown>>();
const collected: Record<string, unknown>[] = [];
while (queue.length > 0) {
const current = queue.shift();
if (!current || seen.has(current)) {
continue;
}
seen.add(current);
collected.push(current);
for (const nested of [
asRecord(current.data),
asRecord(current.result),
asRecord(current.bizData),
asRecord(current.commentInfo),
asRecord(current.module)
]) {
if (nested && !seen.has(nested)) {
queue.push(nested);
}
}
}
return collected;
}
function hasReviewPayloadMarkers(payload: Record<string, unknown>): boolean {
return (
"commentInfoList" in payload ||
"lastCommentInfoList" in payload ||
"tagStatisticsinfoList" in payload ||
"allCnt" in payload ||
"allCntStr" in payload ||
"goodCnt" in payload ||
"goodRate" in payload ||
"goodRateShow" in payload ||
"pictureCnt" in payload ||
"showPicCnt" in payload
);
}
function resolveJdReviewsPayload(input: unknown): Record<string, unknown> | null {
const root = asRecord(unwrapCapturedPayload(input));
if (!root) {
return null;
}
return collectNestedPayloads(root).find((payload) => hasReviewPayloadMarkers(payload)) ?? root;
}
function extractSpecLabel(title: string): string {
const storageMatch = title.match(/\b\d+(?:GB|TB)\b/i);
if (storageMatch) {
@@ -313,11 +373,32 @@ function parseReviewTag(input: unknown): JdReviewTagSnapshot | null {
}
function parseReviewComment(input: unknown): JdReviewCommentSnapshot | null {
const comment = asRecord(input);
const wrapper = asRecord(input);
const comment = asRecord(wrapper?.commentInfo) ?? wrapper;
const content = normalizeInlineText(
firstString(comment?.content, comment?.commentData, comment?.tagCommentContent)
firstString(
comment?.content,
comment?.commentData,
comment?.tagCommentContent,
comment?.commentContent,
comment?.commentText,
comment?.text,
wrapper?.content,
wrapper?.commentData,
wrapper?.tagCommentContent,
wrapper?.commentContent,
wrapper?.commentText,
wrapper?.text
)
);
const id = firstString(
comment?.id,
comment?.commentId,
comment?.commentUuid,
wrapper?.id,
wrapper?.commentId,
wrapper?.commentUuid
);
const id = firstString(comment?.id, comment?.commentId);
if (!content || !id) {
return null;
@@ -326,14 +407,37 @@ function parseReviewComment(input: unknown): JdReviewCommentSnapshot | null {
return {
id,
content,
score: firstString(comment?.score, comment?.commentScore),
score: firstString(
comment?.score,
comment?.commentScore,
comment?.scoring,
wrapper?.score,
wrapper?.commentScore,
wrapper?.scoring
),
creationTime: firstString(
comment?.creationTime,
comment?.creationDate,
comment?.commentDate
comment?.commentDate,
comment?.time,
wrapper?.creationTime,
wrapper?.creationDate,
wrapper?.commentDate,
wrapper?.time
),
userLevelName: normalizeInlineText(
firstString(comment?.userLevelName, comment?.userClientShow)
firstString(
comment?.userLevelName,
comment?.userClientShow,
comment?.userLevel,
comment?.userNickName,
comment?.userName,
wrapper?.userLevelName,
wrapper?.userClientShow,
wrapper?.userLevel,
wrapper?.userNickName,
wrapper?.userName
)
)
};
}
@@ -342,13 +446,20 @@ export function parseJdReviewsApiResponse(
skuId: string,
input: unknown
): JdProductReviewsSnapshot {
const payload = asRecord(unwrapCapturedPayload(input));
const payload = resolveJdReviewsPayload(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));
const commentsById = new Map<string, JdReviewCommentSnapshot>();
for (const comment of [
...asArray(payload?.commentInfoList),
...asArray(payload?.lastCommentInfoList)
]) {
const parsedComment = parseReviewComment(comment);
if (parsedComment && !commentsById.has(parsedComment.id)) {
commentsById.set(parsedComment.id, parsedComment);
}
}
return {
skuId,
@@ -356,6 +467,6 @@ export function parseJdReviewsApiResponse(
goodRate: firstString(payload?.goodRate, payload?.goodRateShow),
pictureCount: firstString(payload?.pictureCnt, payload?.showPicCnt),
tags,
comments
comments: Array.from(commentsById.values())
};
}
@@ -198,7 +198,7 @@ describe("JdSessionManagerService", () => {
vi.unstubAllEnvs();
});
it("accepts manual session imports and marks the manager healthy", async () => {
it("accepts manual session imports and waits for a health check before marking ready", async () => {
const onSessionReady = vi.fn();
const manager = new JdSessionManagerService(createJdLiveServiceStub(), {
onSessionReady
@@ -213,8 +213,8 @@ describe("JdSessionManagerService", () => {
"https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22sku%22:100068388533%7D"
});
expect(onSessionReady).toHaveBeenCalledOnce();
expect(state.status).toBe("healthy");
expect(onSessionReady).not.toHaveBeenCalled();
expect(state.status).toBe("degraded");
expect(state.pendingManualAction).toBe(false);
expect(state.session).toMatchObject({
configured: true,
@@ -294,7 +294,7 @@ describe("JdSessionManagerService", () => {
expect(result.recovered).toBe(false);
expect(result.state.status).toBe("healthy");
expect(onSessionReady).toHaveBeenCalledOnce();
expect(previewSearch).toHaveBeenCalledWith("iPhone 15", "html");
expect(previewSearch).toHaveBeenCalledWith("iPhone 15", "api");
expect(previewDetail).toHaveBeenCalledWith("100068388533");
expect(previewReviews).toHaveBeenCalledWith("100068388533", {
commentCount: 1,
+9 -7
View File
@@ -276,15 +276,14 @@ export class JdSessionManagerService implements JdSessionManager {
source = "ops-manual"
): Promise<JdSessionManagerState> {
this.liveService.importSession(input);
this.callbacks.onSessionReady?.();
this.state = {
...this.state,
status: "healthy",
status: "degraded",
pendingManualAction: false,
note: `京东会话已通过 ${source} 更新,等待下一轮健康检查。`,
publicNote: "京东会话由运维后台维护,当前可用。",
lastRecoveredAt: nowIso(),
lastHealthyAt: nowIso(),
note: `JD session imported via ${source}; waiting for health check.`,
publicNote: "JD session was updated and is being verified.",
lastRecoveredAt: undefined,
lastHealthyAt: undefined,
lastFailureCode: undefined,
lastFailureMessage: undefined,
session: this.liveService.getSessionSummary()
@@ -556,7 +555,10 @@ export class JdSessionManagerService implements JdSessionManager {
throw new Error("京东会话缺少可校验的详情/评论模板。");
}
await this.liveService.previewSearch(this.config.heartbeatQuery, "html");
await this.liveService.previewSearch(
this.config.heartbeatQuery,
summary.searchApiTemplate.available ? "api" : "html"
);
await this.liveService.previewDetail(detailSkuId);
await this.liveService.previewReviews(reviewsSkuId, {
commentCount: 1,
+12
View File
@@ -15,6 +15,9 @@ export interface JdLiveSessionInput {
reviewsTemplateUrl?: string | undefined;
searchReferer?: string | undefined;
detailReferer?: string | undefined;
searchRequestHeaders?: Record<string, string> | undefined;
detailRequestHeaders?: Record<string, string> | undefined;
reviewsRequestHeaders?: Record<string, string> | undefined;
}
export interface JdLiveSessionSummary {
@@ -157,6 +160,15 @@ export interface JdProductPreviewResult {
reviews: JdProductReviewsSnapshot;
}
export interface JdBrowserPreviewProvider {
previewSearch(query: string, mode?: JdSearchMode): Promise<JdSearchPreviewResult>;
previewDetail(skuId: string): Promise<JdDetailPreviewResult>;
previewReviews(
skuId: string,
options?: number | JdReviewsPreviewOptions
): Promise<JdReviewsPreviewResult>;
}
export interface JdLiveService {
getSessionSummary(): JdLiveSessionSummary;
importSession(input: JdLiveSessionInput): JdLiveSessionSummary;
+12 -3
View File
@@ -85,9 +85,18 @@ function createJdLiveServiceStub(overrides: Partial<JdLiveService> = {}): JdLive
importedAt: "2026-04-03T10: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) }
searchApiTemplate: {
available: Boolean(input.searchApiTemplateUrl),
skuId: input.searchApiTemplateUrl ? "100068388533" : undefined
},
detailTemplate: {
available: Boolean(input.detailTemplateUrl),
skuId: input.detailTemplateUrl ? "100068388533" : undefined
},
reviewsTemplate: {
available: Boolean(input.reviewsTemplateUrl),
skuId: input.reviewsTemplateUrl ? "100068388533" : undefined
}
};
return summary;
},
+12 -3
View File
@@ -36,9 +36,18 @@ function createJdLiveServiceStub(
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) }
searchApiTemplate: {
available: Boolean(input.searchApiTemplateUrl),
skuId: input.searchApiTemplateUrl ? "100068388533" : undefined
},
detailTemplate: {
available: Boolean(input.detailTemplateUrl),
skuId: input.detailTemplateUrl ? "100068388533" : undefined
},
reviewsTemplate: {
available: Boolean(input.reviewsTemplateUrl),
skuId: input.reviewsTemplateUrl ? "100068388533" : undefined
}
};
return summary;
},
+258 -4
View File
@@ -12,6 +12,7 @@ import type {
JdReviewsPreviewResult,
JdSearchPreviewResult
} from "./platforms/jd/types";
import type { OpsQrLoginController, OpsQrLoginState } from "./ops/qr-login";
import { createServer } from "./server";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -63,6 +64,31 @@ async function createCompletedTask(app: ReturnType<typeof createServer>, query:
return confirmResponse.json().task;
}
async function waitForTask(
app: ReturnType<typeof createServer>,
taskId: string,
predicate: (task: Awaited<ReturnType<typeof createTask>>) => boolean,
timeoutMs = 1000
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const response = await app.inject({
method: "GET",
url: `/api/tasks/${taskId}`
});
const task = response.json().task as Awaited<ReturnType<typeof createTask>>;
if (predicate(task)) {
return task;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`Task ${taskId} did not reach the expected state within ${timeoutMs}ms.`);
}
function backdateStoredTask(storagePath: string, taskId: string, ageDays: number) {
const timestamp = new Date(Date.now() - ageDays * DAY_MS).toISOString();
const snapshot = JSON.parse(readFileSync(storagePath, "utf8")) as {
@@ -168,9 +194,18 @@ function createJdLiveServiceStub(
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) }
searchApiTemplate: {
available: Boolean(input.searchApiTemplateUrl),
skuId: input.searchApiTemplateUrl ? "100068388533" : undefined
},
detailTemplate: {
available: Boolean(input.detailTemplateUrl),
skuId: input.detailTemplateUrl ? "100068388533" : undefined
},
reviewsTemplate: {
available: Boolean(input.reviewsTemplateUrl),
skuId: input.reviewsTemplateUrl ? "100068388533" : undefined
}
};
return summary;
},
@@ -305,6 +340,57 @@ function createJdLiveServiceStub(
};
}
function createQrLoginStub(platform: "jd" | "tmall"): OpsQrLoginController {
let state: OpsQrLoginState = {
platform,
status: "idle",
note: "尚未启动扫码登录。",
sessionImported: false
};
return {
getState() {
return state;
},
async start() {
state = {
...state,
status: "waiting_for_scan",
note: "二维码已生成,请扫码。",
targetId: platform === "jd" ? "100068388533" : "934454505228",
qrImageDataUrl: "data:image/png;base64,stub",
startedAt: "2026-04-07T12:00:00.000Z",
updatedAt: "2026-04-07T12:00:00.000Z"
};
return state;
},
async cancel() {
state = {
...state,
status: "cancelled",
note: "扫码登录已取消。",
completedAt: "2026-04-07T12:10:00.000Z",
updatedAt: "2026-04-07T12:10:00.000Z"
};
return state;
},
async resumeManualRecovery() {
state = {
...state,
status: "completed",
note: "人工恢复完成。",
completedAt: "2026-04-07T12:11:00.000Z",
updatedAt: "2026-04-07T12:11:00.000Z",
sessionImported: true
};
return state;
},
async shutdown() {
return;
}
};
}
describe("API server", () => {
it("returns platform readiness with jd blocked by default", async () => {
const app = createServer();
@@ -331,6 +417,79 @@ describe("API server", () => {
await app.close();
});
it("exposes the JD qr-login state and can start the flow", async () => {
const app = createServer({
jdQrLoginService: createQrLoginStub("jd")
});
await app.ready();
const initialResponse = await app.inject({
method: "GET",
url: "/api/ops/jd/session-manager/qr-login"
});
expect(initialResponse.statusCode).toBe(200);
expect(initialResponse.json().qrLogin).toMatchObject({
platform: "jd",
status: "idle"
});
const startResponse = await app.inject({
method: "POST",
url: "/api/ops/jd/session-manager/qr-login/start"
});
expect(startResponse.statusCode).toBe(200);
expect(startResponse.json().qrLogin).toMatchObject({
platform: "jd",
status: "waiting_for_scan",
targetId: "100068388533"
});
await app.close();
});
it("can cancel the Tmall qr-login flow", async () => {
const qrLoginService = createQrLoginStub("tmall");
await qrLoginService.start();
const app = createServer({
tmallQrLoginService: qrLoginService
});
await app.ready();
const cancelResponse = await app.inject({
method: "POST",
url: "/api/ops/tmall/session-manager/qr-login/cancel"
});
expect(cancelResponse.statusCode).toBe(200);
expect(cancelResponse.json().qrLogin).toMatchObject({
platform: "tmall",
status: "cancelled"
});
await app.close();
});
it("can resume the JD qr-login manual recovery flow", async () => {
const qrLoginService = createQrLoginStub("jd");
await qrLoginService.start();
const app = createServer({
jdQrLoginService: qrLoginService
});
await app.ready();
const resumeResponse = await app.inject({
method: "POST",
url: "/api/ops/jd/session-manager/qr-login/resume"
});
expect(resumeResponse.statusCode).toBe(200);
expect(resumeResponse.json().qrLogin).toMatchObject({
platform: "jd",
status: "completed",
sessionImported: true
});
await app.close();
});
it("exposes session state with a 24-hour expiry window after preparation", async () => {
const app = createServer();
await app.ready();
@@ -404,6 +563,47 @@ describe("API server", () => {
await app.close();
});
it("runs confirmed tasks in the background when the execution mode is background", async () => {
const app = createServer({ executionMode: "background" });
await app.ready();
const createdTask = await createTask(app, "Nintendo Switch 2");
const candidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/candidates`
});
const firstCandidateId = candidatesResponse.json().candidates.tmall[0].candidateId;
const confirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: [
{
platform: "tmall",
candidateIds: [firstCandidateId]
}
]
}
});
expect(confirmResponse.statusCode).toBe(200);
expect(confirmResponse.json().task.taskStatus).toBe("Running");
expect(confirmResponse.json().task.taskStage).toBe("session_check");
expect(confirmResponse.json().task.defaultReportVersion).toBeUndefined();
const completedTask = await waitForTask(
app,
createdTask.taskId,
(task) => task.taskStatus === "Completed"
);
expect(completedTask.defaultReportVersion).toBe(1);
expect(completedTask.reportVersions).toEqual([1]);
await app.close();
});
it("records strategy attempts and observability metrics for platform search", async () => {
const app = createServer();
await app.ready();
@@ -1239,7 +1439,6 @@ describe("API server", () => {
method: "GET",
url: `/api/tasks/${createdTask.taskId}/audit`
});
expect(auditResponse.statusCode).toBe(200);
expect(auditResponse.json().audit).toEqual(
expect.arrayContaining([
@@ -1277,6 +1476,61 @@ describe("API server", () => {
await app.close();
});
it("auto-resumes JD SearchBlocked tasks after the managed session passes health check", async () => {
const app = createServer({
jdLiveService: createJdLiveServiceStub()
});
await app.ready();
const createdTask = await createTask(app, "iPhone 15 Pro");
expect(
createdTask.platformRuns.find((run: { platform: string }) => run.platform === "jd")
).toMatchObject({
platform: "jd",
status: "SearchBlocked"
});
const importResponse = await app.inject({
method: "POST",
url: "/api/ops/jd/session-manager/session",
payload: {
cookieHeader: "thor=masked; pin=masked;",
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);
const recoveredTask = await waitForTask(
app,
createdTask.taskId,
(task) =>
task.platformRuns.some(
(run: { platform: string; status: string }) =>
run.platform === "jd" && run.status === "AwaitingSelection"
)
);
expect(
recoveredTask.platformRuns.find((run: { platform: string }) => run.platform === "jd")
).toMatchObject({
platform: "jd",
status: "AwaitingSelection"
});
const candidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/candidates`
});
expect(candidatesResponse.json().candidates.jd).toHaveLength(1);
await app.close();
});
it("publishes a new report version when a blocked platform recovers successfully", async () => {
const app = createServer();
await app.ready();
+118 -10
View File
@@ -16,6 +16,11 @@ import type {
JdSessionManager,
JdSessionManagerAutoMode
} from "./platforms/jd/types";
import {
JdOpsQrLoginService,
TmallOpsQrLoginService,
type OpsQrLoginController
} from "./ops/qr-login";
import { TmallLiveSessionService, isTmallLiveError } from "./platforms/tmall/live-session";
import { TmallSessionManagerService } from "./platforms/tmall/session-manager";
import type {
@@ -30,8 +35,11 @@ export function createServer(
storagePath?: string;
jdLiveService?: JdLiveService;
jdSessionManager?: JdSessionManager;
jdQrLoginService?: OpsQrLoginController;
tmallLiveService?: TmallLiveService;
tmallSessionManager?: TmallSessionManager;
tmallQrLoginService?: OpsQrLoginController;
executionMode?: "synchronous" | "background";
} = {}
) {
const app = Fastify({ logger: false });
@@ -43,12 +51,17 @@ export function createServer(
(process.env.NODE_ENV === "test"
? undefined
: resolve(process.cwd(), ".data", "task-store.json"));
const store = new InMemoryTaskStore({ storagePath, jdLiveService, tmallLiveService });
const store = new InMemoryTaskStore({
storagePath,
jdLiveService,
tmallLiveService,
executionMode: options.executionMode
});
const jdSessionManager =
options.jdSessionManager ??
new JdSessionManagerService(jdLiveService, {
onSessionReady: () => {
store.preparePlatform("jd");
store.notifyManagedSessionReady("jd");
},
onSessionUnavailable: () => {
store.clearPlatformSession("jd");
@@ -64,11 +77,27 @@ export function createServer(
store.clearPlatformSession("tmall");
}
});
const jdQrLoginService =
options.jdQrLoginService ??
new JdOpsQrLoginService(jdSessionManager, {
resolveSearchQuery: () => jdSessionManager.getState().heartbeatQuery
});
const tmallQrLoginService =
options.tmallQrLoginService ??
new TmallOpsQrLoginService(tmallSessionManager, {
resolveTargetItemId: () => tmallSessionManager.getState().heartbeatItemId
});
if (
jdLiveService instanceof JdLiveSessionService &&
jdQrLoginService instanceof JdOpsQrLoginService
) {
jdLiveService.setBrowserPreviewProvider(jdQrLoginService);
}
store.setJdSessionManager(jdSessionManager);
store.setTmallSessionManager(tmallSessionManager);
if (jdLiveService.getSessionSummary().configured && !store.getSession("jd").ready) {
store.preparePlatform("jd");
store.notifyManagedSessionReady("jd");
}
if (tmallLiveService.getSessionSummary().configured && !store.getSession("tmall").ready) {
store.preparePlatform("tmall");
@@ -77,6 +106,8 @@ export function createServer(
app.addHook("onClose", async () => {
jdSessionManager.shutdown();
tmallSessionManager.shutdown();
await jdQrLoginService.shutdown();
await tmallQrLoginService.shutdown();
});
app.register(cors, { origin: true });
@@ -211,9 +242,13 @@ export function createServer(
};
}>("/api/ops/jd/session-manager/session", async (request, reply) => {
try {
const manager = await jdSessionManager.importManualSession(request.body, "ops-manual");
await jdSessionManager.importManualSession(request.body, "ops-manual");
const result = await jdSessionManager.runHealthCheck("ops-manual");
reply.code(200);
return { manager };
return {
manager: result.state,
recovered: result.recovered
};
} catch (error) {
reply.code(isJdLiveError(error) ? error.statusCode : 400);
return {
@@ -226,6 +261,42 @@ export function createServer(
manager: jdSessionManager.clearManagedSession("ops-manual-clear")
}));
app.get("/api/ops/jd/session-manager/qr-login", async () => ({
qrLogin: jdQrLoginService.getState()
}));
app.post("/api/ops/jd/session-manager/qr-login/start", async (_request, reply) => {
try {
const qrLogin = await jdQrLoginService.start();
reply.code(200);
return { qrLogin };
} catch (error) {
reply.code(500);
return {
message: error instanceof Error ? error.message : "JD QR login start failed."
};
}
});
app.post("/api/ops/jd/session-manager/qr-login/cancel", async (_request, reply) => {
const qrLogin = await jdQrLoginService.cancel("ops-cancel");
reply.code(200);
return { qrLogin };
});
app.post("/api/ops/jd/session-manager/qr-login/resume", async (_request, reply) => {
try {
const qrLogin = await jdQrLoginService.resumeManualRecovery();
reply.code(200);
return { qrLogin };
} catch (error) {
reply.code(500);
return {
message: error instanceof Error ? error.message : "JD QR login manual recovery failed."
};
}
});
app.get("/api/ops/tmall/session-manager", async () => ({
manager: tmallSessionManager.getState()
}));
@@ -290,6 +361,29 @@ export function createServer(
manager: tmallSessionManager.clearManagedSession("ops-manual-clear")
}));
app.get("/api/ops/tmall/session-manager/qr-login", async () => ({
qrLogin: tmallQrLoginService.getState()
}));
app.post("/api/ops/tmall/session-manager/qr-login/start", async (_request, reply) => {
try {
const qrLogin = await tmallQrLoginService.start();
reply.code(200);
return { qrLogin };
} catch (error) {
reply.code(500);
return {
message: error instanceof Error ? error.message : "Tmall QR login start failed."
};
}
});
app.post("/api/ops/tmall/session-manager/qr-login/cancel", async (_request, reply) => {
const qrLogin = await tmallQrLoginService.cancel("ops-cancel");
reply.code(200);
return { qrLogin };
});
app.get("/api/platforms/tmall/live-session", async () => ({
session: tmallLiveService.getSessionSummary()
}));
@@ -307,6 +401,7 @@ export function createServer(
}>("/api/platforms/jd/live-session", async (request, reply) => {
try {
await jdSessionManager.importManualSession(request.body, "legacy-live-session");
await jdSessionManager.runHealthCheck("legacy-live-session");
reply.code(200);
return { session: jdLiveService.getSessionSummary() };
} catch (error) {
@@ -806,15 +901,28 @@ export function createServer(
return { message: "Task not found." };
}
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive"
Connection: "keep-alive",
"Access-Control-Allow-Origin": request.headers.origin ?? "*",
Vary: "Origin"
});
const writeSnapshot = (nextTask = task) => {
reply.raw.write(`event: task.snapshot\n`);
reply.raw.write(`data: ${JSON.stringify({ task: nextTask })}\n\n`);
};
const unsubscribe = store.subscribeToTask(request.params.taskId, writeSnapshot);
const heartbeat = setInterval(() => {
reply.raw.write(`: keep-alive\n\n`);
}, 15000);
writeSnapshot(task);
request.raw.on("close", () => {
clearInterval(heartbeat);
unsubscribe();
});
reply.raw.write(`event: task.snapshot\n`);
reply.raw.write(`data: ${JSON.stringify(task)}\n\n`);
reply.raw.end();
return reply;
});
return app;
+164
View File
@@ -282,12 +282,15 @@ function isReportableTaskStatus(
return status === "Completed" || status === "PartialCompleted";
}
type TaskExecutionMode = "synchronous" | "background";
type InMemoryTaskStoreOptions = {
storagePath?: string | undefined;
jdLiveService?: JdLiveService | undefined;
jdSessionManager?: JdSessionManager | undefined;
tmallLiveService?: TmallLiveService | undefined;
tmallSessionManager?: TmallSessionManager | undefined;
executionMode?: TaskExecutionMode | undefined;
};
type TaskExecutionArtifact = {
@@ -424,11 +427,18 @@ export class InMemoryTaskStore {
Partial<Record<PlatformId, MockExecutionScenario>>
>();
private readonly executionArtifacts = new Map<string, TaskExecutionArtifact[]>();
private readonly taskSubscribers = new Map<
string,
Set<(task: TaskRecord) => void>
>();
private readonly pendingExecutions = new Map<string, Promise<void>>();
private readonly pendingManagedSessionRetries = new Set<string>();
private readonly storagePath: string | undefined;
private readonly jdLiveService: JdLiveService | undefined;
private jdSessionManager: JdSessionManager | undefined;
private readonly tmallLiveService: TmallLiveService | undefined;
private tmallSessionManager: TmallSessionManager | undefined;
private readonly executionMode: TaskExecutionMode;
constructor(options: InMemoryTaskStoreOptions = {}) {
this.storagePath = options.storagePath;
@@ -436,6 +446,8 @@ export class InMemoryTaskStore {
this.jdSessionManager = options.jdSessionManager;
this.tmallLiveService = options.tmallLiveService;
this.tmallSessionManager = options.tmallSessionManager;
this.executionMode =
options.executionMode ?? (process.env.NODE_ENV === "test" ? "synchronous" : "background");
const persistedState = this.loadPersistedState();
if (persistedState) {
@@ -483,6 +495,12 @@ export class InMemoryTaskStore {
return this.toSessionRecord(next);
}
notifyManagedSessionReady(platform: PlatformId): SessionStateRecord {
const session = this.preparePlatform(platform);
void this.resumeTasksWaitingForManagedSession(platform);
return session;
}
clearPlatformSession(platform: PlatformId): void {
const cleared = createMissingSession(platform);
this.readiness.set(platform, cleared);
@@ -567,6 +585,24 @@ export class InMemoryTaskStore {
return this.tasks.get(taskId);
}
subscribeToTask(taskId: string, listener: (task: TaskRecord) => void): () => void {
const listeners = this.taskSubscribers.get(taskId) ?? new Set<(task: TaskRecord) => void>();
listeners.add(listener);
this.taskSubscribers.set(taskId, listeners);
return () => {
const currentListeners = this.taskSubscribers.get(taskId);
if (!currentListeners) {
return;
}
currentListeners.delete(listener);
if (currentListeners.size === 0) {
this.taskSubscribers.delete(taskId);
}
};
}
getCandidates(taskId: string): Record<PlatformId, CandidateRecord[]> | undefined {
return this.tasks.get(taskId)?.platformCandidates;
}
@@ -714,6 +750,12 @@ export class InMemoryTaskStore {
task.taskStage = "session_check";
this.pushEvent(task, "task.running", "系统开始执行抓取前校验。");
if (this.executionMode === "background") {
this.persistState();
this.scheduleTaskExecution(task.taskId);
return task;
}
await this.executeSelectedPlatforms(task, selectedRuns);
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
task.updatedAt = nowIso();
@@ -1664,6 +1706,44 @@ export class InMemoryTaskStore {
this.pushEvent(task, "task.awaiting_confirmation", "候选召回完成,等待人工确认。");
}
private async resumeTasksWaitingForManagedSession(platform: PlatformId): Promise<void> {
const taskIds = Array.from(this.tasks.values())
.filter((task) => this.shouldResumeTaskAfterManagedSessionReady(task, platform))
.map((task) => task.taskId);
for (const taskId of taskIds) {
const retryKey = `${taskId}:${platform}`;
if (this.pendingManagedSessionRetries.has(retryKey)) {
continue;
}
this.pendingManagedSessionRetries.add(retryKey);
try {
await this.retryPlatform(taskId, platform);
} catch {
// retryPlatform already records recoverable failures on the task.
} finally {
this.pendingManagedSessionRetries.delete(retryKey);
}
}
}
private shouldResumeTaskAfterManagedSessionReady(
task: TaskRecord,
platform: PlatformId
): boolean {
const run = task.platformRuns.find((item) => item.platform === platform);
if (!run) {
return false;
}
if (run.status === "SearchBlocked") {
return true;
}
return run.status === "Blocked" && run.selectedCandidateIds.length > 0;
}
private buildReport(task: TaskRecord): ReportSnapshot {
const reportVersion = this.getNextReportVersion(task);
const completedRuns = task.platformRuns.filter((run) => run.status === "Completed");
@@ -2064,6 +2144,11 @@ export class InMemoryTaskStore {
run.status = "Searching";
run.reason = undefined;
run.lastUpdatedAt = nowIso();
this.pushEvent(
task,
`platform.${run.platform}.searching`,
`${platformCatalogMap[run.platform].label} 正在执行候选搜索。`
);
let candidates: CandidateRecord[] = [];
if (run.platform === "jd" && this.hasConfiguredJdLiveSession()) {
@@ -2963,6 +3048,85 @@ export class InMemoryTaskStore {
};
task.events.push(event);
task.updatedAt = event.createdAt;
this.emitTaskSnapshot(task);
}
private emitTaskSnapshot(task: TaskRecord): void {
const listeners = this.taskSubscribers.get(task.taskId);
if (!listeners || listeners.size === 0) {
return;
}
for (const listener of listeners) {
listener(task);
}
}
private scheduleTaskExecution(taskId: string): void {
if (this.pendingExecutions.has(taskId)) {
return;
}
const execution = new Promise<void>((resolve) => {
setTimeout(() => {
void (async () => {
const task = this.requireTask(taskId);
const selectedRuns = task.platformRuns.filter((run) => run.status === "Selected");
if (selectedRuns.length > 0) {
await this.executeSelectedPlatforms(task, selectedRuns);
}
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
task.updatedAt = nowIso();
const published = this.publishReportIfNeeded(task);
this.persistState();
if (!published) {
this.emitTaskSnapshot(task);
}
})()
.catch((error) => {
this.failBackgroundTaskExecution(taskId, error);
})
.finally(() => {
this.pendingExecutions.delete(taskId);
resolve();
});
}, 0);
});
this.pendingExecutions.set(taskId, execution);
}
private failBackgroundTaskExecution(taskId: string, error: unknown): void {
const task = this.tasks.get(taskId);
if (!task) {
return;
}
for (const run of task.platformRuns) {
if (run.selectedCandidateIds.length === 0) {
continue;
}
if (run.status === "Selected" || run.status === "Running") {
run.status = "Failed";
run.reason = "后台执行异常中断,请稍后重试。";
run.lastUpdatedAt = nowIso();
}
}
task.taskStage = "publish";
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
this.pushEvent(
task,
"task.execution_failed",
error instanceof Error
? `后台执行异常中断:${error.message}`
: "后台执行异常中断,请稍后重试。"
);
this.persistState();
}
private requireTask(taskId: string): TaskRecord {