feat: 接入运维扫码登录与任务实时执行链路
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
})
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user