feat: 接入运维扫码登录与任务实时执行链路
This commit is contained in:
parent
8c4d057202
commit
4ea9eaf4a6
@ -14,6 +14,7 @@
|
|||||||
"@cross-ai/domain": "file:../../packages/domain",
|
"@cross-ai/domain": "file:../../packages/domain",
|
||||||
"@cross-ai/report-schema": "file:../../packages/report-schema",
|
"@cross-ai/report-schema": "file:../../packages/report-schema",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"fastify": "^5.8.4"
|
"fastify": "^5.8.4",
|
||||||
|
"playwright-core": "^1.59.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
214
apps/api/src/ops/qr-login.test.ts
Normal file
214
apps/api/src/ops/qr-login.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
1243
apps/api/src/ops/qr-login.ts
Normal file
1243
apps/api/src/ops/qr-login.ts
Normal file
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 () => {
|
it("paginates and deduplicates JD reviews across multiple pages", async () => {
|
||||||
const fetchMock = vi
|
const fetchMock = vi
|
||||||
.fn<(input: string, init?: RequestInit) => Promise<Response>>()
|
.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 () => {
|
it("rejects a detail template that still resolves to another sku", async () => {
|
||||||
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
|
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
|
||||||
buildResponse({
|
buildResponse({
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
parseJdSearchHtml
|
parseJdSearchHtml
|
||||||
} from "./parsers";
|
} from "./parsers";
|
||||||
import type {
|
import type {
|
||||||
|
JdBrowserPreviewProvider,
|
||||||
JdDetailPreviewResult,
|
JdDetailPreviewResult,
|
||||||
JdLiveService,
|
JdLiveService,
|
||||||
JdProductPreviewResult,
|
JdProductPreviewResult,
|
||||||
@ -38,6 +39,7 @@ const REVIEW_PAGE_BODY_KEYS = [
|
|||||||
"commentPage"
|
"commentPage"
|
||||||
];
|
];
|
||||||
const REVIEW_PAGE_SIZE_BODY_KEYS = ["commentNum", "pageSize", "pageLimit"];
|
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 SEARCH_FUNCTION_ID = "pc_search_searchWare";
|
||||||
const DETAIL_FUNCTION_ID = "pc_detailpage_wareBusiness";
|
const DETAIL_FUNCTION_ID = "pc_detailpage_wareBusiness";
|
||||||
const REVIEWS_FUNCTION_ID = "getLegoWareDetailComment";
|
const REVIEWS_FUNCTION_ID = "getLegoWareDetailComment";
|
||||||
@ -81,6 +83,9 @@ type StoredJdLiveSession = {
|
|||||||
reviewsTemplateUrl?: string | undefined;
|
reviewsTemplateUrl?: string | undefined;
|
||||||
searchReferer?: string | undefined;
|
searchReferer?: string | undefined;
|
||||||
detailReferer?: string | undefined;
|
detailReferer?: string | undefined;
|
||||||
|
searchRequestHeaders?: Record<string, string> | undefined;
|
||||||
|
detailRequestHeaders?: Record<string, string> | undefined;
|
||||||
|
reviewsRequestHeaders?: Record<string, string> | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
class JdLiveError extends Error {
|
class JdLiveError extends Error {
|
||||||
@ -158,20 +163,52 @@ function buildTemplateSummary(templateUrl: string | undefined): JdTemplateSummar
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function templateMatchesQuery(
|
function normalizeCapturedHeaders(
|
||||||
templateUrl: string | undefined,
|
input: Record<string, string> | undefined
|
||||||
query: string
|
): Record<string, string> | undefined {
|
||||||
): boolean {
|
if (!input) {
|
||||||
if (!templateUrl) {
|
return undefined;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const isValidReplayHeaderName = (key: string) => /^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(key);
|
||||||
const templateKeyword = new URL(templateUrl).searchParams.get("keyword");
|
const entries = Object.entries(input)
|
||||||
return Boolean(templateKeyword && templateKeyword === query);
|
.map(([key, value]) => [key.trim().toLowerCase(), value.trim()] as const)
|
||||||
} catch {
|
.filter(([key, value]) => {
|
||||||
return false;
|
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 {
|
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(
|
function buildReviewsRequestUrl(
|
||||||
templateUrl: string,
|
templateUrl: string,
|
||||||
skuId: string,
|
skuId: string,
|
||||||
@ -472,6 +581,11 @@ export function getJdLiveErrorCode(error: unknown): JdLiveErrorCode | undefined
|
|||||||
|
|
||||||
export class JdLiveSessionService implements JdLiveService {
|
export class JdLiveSessionService implements JdLiveService {
|
||||||
private session: StoredJdLiveSession | null = readEnvSession();
|
private session: StoredJdLiveSession | null = readEnvSession();
|
||||||
|
private browserPreviewProvider: JdBrowserPreviewProvider | null = null;
|
||||||
|
|
||||||
|
setBrowserPreviewProvider(provider: JdBrowserPreviewProvider | null): void {
|
||||||
|
this.browserPreviewProvider = provider;
|
||||||
|
}
|
||||||
|
|
||||||
getSessionSummary(): JdLiveSessionSummary {
|
getSessionSummary(): JdLiveSessionSummary {
|
||||||
return {
|
return {
|
||||||
@ -491,6 +605,9 @@ export class JdLiveSessionService implements JdLiveService {
|
|||||||
const reviewsTemplateUrl = input.reviewsTemplateUrl?.trim();
|
const reviewsTemplateUrl = input.reviewsTemplateUrl?.trim();
|
||||||
const searchReferer = input.searchReferer?.trim();
|
const searchReferer = input.searchReferer?.trim();
|
||||||
const detailReferer = input.detailReferer?.trim();
|
const detailReferer = input.detailReferer?.trim();
|
||||||
|
const searchRequestHeaders = normalizeCapturedHeaders(input.searchRequestHeaders);
|
||||||
|
const detailRequestHeaders = normalizeCapturedHeaders(input.detailRequestHeaders);
|
||||||
|
const reviewsRequestHeaders = normalizeCapturedHeaders(input.reviewsRequestHeaders);
|
||||||
|
|
||||||
validateImportedTemplates({
|
validateImportedTemplates({
|
||||||
searchApiTemplateUrl,
|
searchApiTemplateUrl,
|
||||||
@ -506,7 +623,10 @@ export class JdLiveSessionService implements JdLiveService {
|
|||||||
...(detailTemplateUrl ? { detailTemplateUrl } : {}),
|
...(detailTemplateUrl ? { detailTemplateUrl } : {}),
|
||||||
...(reviewsTemplateUrl ? { reviewsTemplateUrl } : {}),
|
...(reviewsTemplateUrl ? { reviewsTemplateUrl } : {}),
|
||||||
...(searchReferer ? { searchReferer } : {}),
|
...(searchReferer ? { searchReferer } : {}),
|
||||||
...(detailReferer ? { detailReferer } : {})
|
...(detailReferer ? { detailReferer } : {}),
|
||||||
|
...(searchRequestHeaders ? { searchRequestHeaders } : {}),
|
||||||
|
...(detailRequestHeaders ? { detailRequestHeaders } : {}),
|
||||||
|
...(reviewsRequestHeaders ? { reviewsRequestHeaders } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.getSessionSummary();
|
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");
|
throw new JdLiveError("query is required for JD live search preview.", 400, "BAD_REQUEST");
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedMode =
|
const resolvedMode = mode ?? (session.searchApiTemplateUrl ? "api" : "html");
|
||||||
mode ??
|
|
||||||
(templateMatchesQuery(session.searchApiTemplateUrl, normalizedQuery) ? "api" : "html");
|
|
||||||
|
|
||||||
if (resolvedMode === "api") {
|
try {
|
||||||
if (!session.searchApiTemplateUrl) {
|
if (resolvedMode === "api") {
|
||||||
throw new JdLiveError(
|
if (!session.searchApiTemplateUrl) {
|
||||||
"JD search API template is missing. Import a fresh search request URL or use mode=html.",
|
throw new JdLiveError(
|
||||||
409,
|
"JD search API template is missing. Import a fresh search request URL or use mode=html.",
|
||||||
"TEMPLATE_MISSING"
|
409,
|
||||||
);
|
"TEMPLATE_MISSING"
|
||||||
}
|
);
|
||||||
|
}
|
||||||
const templateUrl = new URL(session.searchApiTemplateUrl);
|
|
||||||
const templateKeyword = templateUrl.searchParams.get("keyword");
|
const requestUrl = buildSearchApiRequestUrl(session.searchApiTemplateUrl, normalizedQuery);
|
||||||
if (templateKeyword && templateKeyword !== normalizedQuery) {
|
const referer = buildSearchPageUrlFromTemplate(
|
||||||
throw new JdLiveError(
|
session.searchApiTemplateUrl,
|
||||||
`Imported search API template is locked to query "${templateKeyword}". ` +
|
normalizedQuery
|
||||||
"Capture a fresh request for the target query or use mode=html.",
|
|
||||||
409,
|
|
||||||
"TEMPLATE_QUERY_LOCKED"
|
|
||||||
);
|
);
|
||||||
|
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(
|
const response = await fetchTextOrThrow(
|
||||||
session.searchApiTemplateUrl,
|
searchUrl,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: buildReplayHeaders(session.searchRequestHeaders, {
|
||||||
Accept: "application/json, text/plain, */*",
|
accept:
|
||||||
Cookie: session.cookieHeader,
|
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||||
Referer:
|
cookie: session.cookieHeader,
|
||||||
session.searchReferer ??
|
referer: session.searchReferer ?? "https://www.jd.com/",
|
||||||
`https://search.jd.com/Search?keyword=${encodeURIComponent(normalizedQuery)}`,
|
userAgent: session.userAgent
|
||||||
"User-Agent": session.userAgent
|
})
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"JD search session appears invalid. Re-login in the browser and re-import the cookie/header."
|
"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 {
|
return {
|
||||||
query: normalizedQuery,
|
query: normalizedQuery,
|
||||||
source: "api",
|
source: "html",
|
||||||
candidateCount: candidates.length,
|
candidateCount: candidates.length,
|
||||||
candidates
|
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> {
|
async previewDetail(skuId: string): Promise<JdDetailPreviewResult> {
|
||||||
@ -616,33 +734,42 @@ export class JdLiveSessionService implements JdLiveService {
|
|||||||
|
|
||||||
const requestUrl = buildDetailRequestUrl(session.detailTemplateUrl, normalizedSkuId);
|
const requestUrl = buildDetailRequestUrl(session.detailTemplateUrl, normalizedSkuId);
|
||||||
|
|
||||||
const response = await fetchTextOrThrow(
|
try {
|
||||||
requestUrl,
|
const response = await fetchTextOrThrow(
|
||||||
{
|
requestUrl,
|
||||||
headers: {
|
{
|
||||||
Accept: "application/json, text/plain, */*",
|
headers: buildReplayHeaders(session.detailRequestHeaders, {
|
||||||
Cookie: session.cookieHeader,
|
accept: "application/json, text/plain, */*",
|
||||||
Referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`,
|
cookie: session.cookieHeader,
|
||||||
"User-Agent": session.userAgent
|
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."
|
},
|
||||||
);
|
"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"
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
const detail = parseJdDetailApiResponse(normalizedSkuId, { text: response.text });
|
||||||
skuId: normalizedSkuId,
|
if (detail.skuId !== normalizedSkuId) {
|
||||||
source: "api",
|
throw new JdLiveError(
|
||||||
detail
|
`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(
|
async previewReviews(
|
||||||
@ -668,64 +795,77 @@ export class JdLiveSessionService implements JdLiveService {
|
|||||||
let pageKey: string | undefined;
|
let pageKey: string | undefined;
|
||||||
const seenCommentIds = new Set<string>();
|
const seenCommentIds = new Set<string>();
|
||||||
|
|
||||||
for (let pageOffset = 0; pageOffset < resolvedOptions.maxPages; pageOffset += 1) {
|
try {
|
||||||
const currentPage = resolvedOptions.page + pageOffset;
|
for (let pageOffset = 0; pageOffset < resolvedOptions.maxPages; pageOffset += 1) {
|
||||||
const request = buildReviewsRequestUrl(
|
const currentPage = resolvedOptions.page + pageOffset;
|
||||||
session.reviewsTemplateUrl,
|
const request = buildReviewsRequestUrl(
|
||||||
normalizedSkuId,
|
session.reviewsTemplateUrl,
|
||||||
resolvedOptions,
|
normalizedSkuId,
|
||||||
currentPage
|
resolvedOptions,
|
||||||
);
|
currentPage
|
||||||
pageKey ??= request.pageKey;
|
);
|
||||||
|
pageKey ??= request.pageKey;
|
||||||
|
|
||||||
const response = await fetchTextOrThrow(
|
const response = await fetchTextOrThrow(
|
||||||
request.url,
|
request.url,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: buildReplayHeaders(session.reviewsRequestHeaders, {
|
||||||
Accept: "application/json, text/plain, */*",
|
accept: "application/json, text/plain, */*",
|
||||||
Cookie: session.cookieHeader,
|
cookie: session.cookieHeader,
|
||||||
Referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`,
|
referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`,
|
||||||
"User-Agent": session.userAgent
|
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 });
|
seenCommentIds.add(comment.id);
|
||||||
reviewPages.push(parsedPage);
|
newCommentCount += 1;
|
||||||
let newCommentCount = 0;
|
|
||||||
for (const comment of parsedPage.comments) {
|
|
||||||
if (seenCommentIds.has(comment.id)) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
seenCommentIds.add(comment.id);
|
if (
|
||||||
newCommentCount += 1;
|
parsedPage.comments.length === 0 ||
|
||||||
|
newCommentCount === 0 ||
|
||||||
|
parsedPage.comments.length < resolvedOptions.commentCount
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const pagination: JdReviewsPaginationSummary = {
|
||||||
parsedPage.comments.length === 0 ||
|
requestedPage: resolvedOptions.page,
|
||||||
newCommentCount === 0 ||
|
requestedCommentCount: resolvedOptions.commentCount,
|
||||||
parsedPage.comments.length < resolvedOptions.commentCount
|
maxPages: resolvedOptions.maxPages,
|
||||||
) {
|
pagesFetched: reviewPages.length,
|
||||||
break;
|
...(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(
|
async previewProduct(
|
||||||
@ -757,4 +897,47 @@ export class JdLiveSessionService implements JdLiveService {
|
|||||||
|
|
||||||
return this.session;
|
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会员"
|
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 {
|
function extractSpecLabel(title: string): string {
|
||||||
const storageMatch = title.match(/\b\d+(?:GB|TB)\b/i);
|
const storageMatch = title.match(/\b\d+(?:GB|TB)\b/i);
|
||||||
if (storageMatch) {
|
if (storageMatch) {
|
||||||
@ -313,11 +373,32 @@ function parseReviewTag(input: unknown): JdReviewTagSnapshot | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseReviewComment(input: unknown): JdReviewCommentSnapshot | null {
|
function parseReviewComment(input: unknown): JdReviewCommentSnapshot | null {
|
||||||
const comment = asRecord(input);
|
const wrapper = asRecord(input);
|
||||||
|
const comment = asRecord(wrapper?.commentInfo) ?? wrapper;
|
||||||
const content = normalizeInlineText(
|
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) {
|
if (!content || !id) {
|
||||||
return null;
|
return null;
|
||||||
@ -326,14 +407,37 @@ function parseReviewComment(input: unknown): JdReviewCommentSnapshot | null {
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
content,
|
content,
|
||||||
score: firstString(comment?.score, comment?.commentScore),
|
score: firstString(
|
||||||
|
comment?.score,
|
||||||
|
comment?.commentScore,
|
||||||
|
comment?.scoring,
|
||||||
|
wrapper?.score,
|
||||||
|
wrapper?.commentScore,
|
||||||
|
wrapper?.scoring
|
||||||
|
),
|
||||||
creationTime: firstString(
|
creationTime: firstString(
|
||||||
comment?.creationTime,
|
comment?.creationTime,
|
||||||
comment?.creationDate,
|
comment?.creationDate,
|
||||||
comment?.commentDate
|
comment?.commentDate,
|
||||||
|
comment?.time,
|
||||||
|
wrapper?.creationTime,
|
||||||
|
wrapper?.creationDate,
|
||||||
|
wrapper?.commentDate,
|
||||||
|
wrapper?.time
|
||||||
),
|
),
|
||||||
userLevelName: normalizeInlineText(
|
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,
|
skuId: string,
|
||||||
input: unknown
|
input: unknown
|
||||||
): JdProductReviewsSnapshot {
|
): JdProductReviewsSnapshot {
|
||||||
const payload = asRecord(unwrapCapturedPayload(input));
|
const payload = resolveJdReviewsPayload(input);
|
||||||
const tags = asArray(payload?.tagStatisticsinfoList)
|
const tags = asArray(payload?.tagStatisticsinfoList)
|
||||||
.map((tag) => parseReviewTag(tag))
|
.map((tag) => parseReviewTag(tag))
|
||||||
.filter((tag): tag is JdReviewTagSnapshot => Boolean(tag));
|
.filter((tag): tag is JdReviewTagSnapshot => Boolean(tag));
|
||||||
const comments = asArray(payload?.commentInfoList)
|
const commentsById = new Map<string, JdReviewCommentSnapshot>();
|
||||||
.map((comment) => parseReviewComment(comment))
|
for (const comment of [
|
||||||
.filter((comment): comment is JdReviewCommentSnapshot => Boolean(comment));
|
...asArray(payload?.commentInfoList),
|
||||||
|
...asArray(payload?.lastCommentInfoList)
|
||||||
|
]) {
|
||||||
|
const parsedComment = parseReviewComment(comment);
|
||||||
|
if (parsedComment && !commentsById.has(parsedComment.id)) {
|
||||||
|
commentsById.set(parsedComment.id, parsedComment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
skuId,
|
skuId,
|
||||||
@ -356,6 +467,6 @@ export function parseJdReviewsApiResponse(
|
|||||||
goodRate: firstString(payload?.goodRate, payload?.goodRateShow),
|
goodRate: firstString(payload?.goodRate, payload?.goodRateShow),
|
||||||
pictureCount: firstString(payload?.pictureCnt, payload?.showPicCnt),
|
pictureCount: firstString(payload?.pictureCnt, payload?.showPicCnt),
|
||||||
tags,
|
tags,
|
||||||
comments
|
comments: Array.from(commentsById.values())
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -198,7 +198,7 @@ describe("JdSessionManagerService", () => {
|
|||||||
vi.unstubAllEnvs();
|
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 onSessionReady = vi.fn();
|
||||||
const manager = new JdSessionManagerService(createJdLiveServiceStub(), {
|
const manager = new JdSessionManagerService(createJdLiveServiceStub(), {
|
||||||
onSessionReady
|
onSessionReady
|
||||||
@ -213,8 +213,8 @@ describe("JdSessionManagerService", () => {
|
|||||||
"https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22sku%22:100068388533%7D"
|
"https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22sku%22:100068388533%7D"
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(onSessionReady).toHaveBeenCalledOnce();
|
expect(onSessionReady).not.toHaveBeenCalled();
|
||||||
expect(state.status).toBe("healthy");
|
expect(state.status).toBe("degraded");
|
||||||
expect(state.pendingManualAction).toBe(false);
|
expect(state.pendingManualAction).toBe(false);
|
||||||
expect(state.session).toMatchObject({
|
expect(state.session).toMatchObject({
|
||||||
configured: true,
|
configured: true,
|
||||||
@ -294,7 +294,7 @@ describe("JdSessionManagerService", () => {
|
|||||||
expect(result.recovered).toBe(false);
|
expect(result.recovered).toBe(false);
|
||||||
expect(result.state.status).toBe("healthy");
|
expect(result.state.status).toBe("healthy");
|
||||||
expect(onSessionReady).toHaveBeenCalledOnce();
|
expect(onSessionReady).toHaveBeenCalledOnce();
|
||||||
expect(previewSearch).toHaveBeenCalledWith("iPhone 15", "html");
|
expect(previewSearch).toHaveBeenCalledWith("iPhone 15", "api");
|
||||||
expect(previewDetail).toHaveBeenCalledWith("100068388533");
|
expect(previewDetail).toHaveBeenCalledWith("100068388533");
|
||||||
expect(previewReviews).toHaveBeenCalledWith("100068388533", {
|
expect(previewReviews).toHaveBeenCalledWith("100068388533", {
|
||||||
commentCount: 1,
|
commentCount: 1,
|
||||||
|
|||||||
@ -276,15 +276,14 @@ export class JdSessionManagerService implements JdSessionManager {
|
|||||||
source = "ops-manual"
|
source = "ops-manual"
|
||||||
): Promise<JdSessionManagerState> {
|
): Promise<JdSessionManagerState> {
|
||||||
this.liveService.importSession(input);
|
this.liveService.importSession(input);
|
||||||
this.callbacks.onSessionReady?.();
|
|
||||||
this.state = {
|
this.state = {
|
||||||
...this.state,
|
...this.state,
|
||||||
status: "healthy",
|
status: "degraded",
|
||||||
pendingManualAction: false,
|
pendingManualAction: false,
|
||||||
note: `京东会话已通过 ${source} 更新,等待下一轮健康检查。`,
|
note: `JD session imported via ${source}; waiting for health check.`,
|
||||||
publicNote: "京东会话由运维后台维护,当前可用。",
|
publicNote: "JD session was updated and is being verified.",
|
||||||
lastRecoveredAt: nowIso(),
|
lastRecoveredAt: undefined,
|
||||||
lastHealthyAt: nowIso(),
|
lastHealthyAt: undefined,
|
||||||
lastFailureCode: undefined,
|
lastFailureCode: undefined,
|
||||||
lastFailureMessage: undefined,
|
lastFailureMessage: undefined,
|
||||||
session: this.liveService.getSessionSummary()
|
session: this.liveService.getSessionSummary()
|
||||||
@ -556,7 +555,10 @@ export class JdSessionManagerService implements JdSessionManager {
|
|||||||
throw new Error("京东会话缺少可校验的详情/评论模板。");
|
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.previewDetail(detailSkuId);
|
||||||
await this.liveService.previewReviews(reviewsSkuId, {
|
await this.liveService.previewReviews(reviewsSkuId, {
|
||||||
commentCount: 1,
|
commentCount: 1,
|
||||||
|
|||||||
@ -15,6 +15,9 @@ export interface JdLiveSessionInput {
|
|||||||
reviewsTemplateUrl?: string | undefined;
|
reviewsTemplateUrl?: string | undefined;
|
||||||
searchReferer?: string | undefined;
|
searchReferer?: string | undefined;
|
||||||
detailReferer?: string | undefined;
|
detailReferer?: string | undefined;
|
||||||
|
searchRequestHeaders?: Record<string, string> | undefined;
|
||||||
|
detailRequestHeaders?: Record<string, string> | undefined;
|
||||||
|
reviewsRequestHeaders?: Record<string, string> | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JdLiveSessionSummary {
|
export interface JdLiveSessionSummary {
|
||||||
@ -157,6 +160,15 @@ export interface JdProductPreviewResult {
|
|||||||
reviews: JdProductReviewsSnapshot;
|
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 {
|
export interface JdLiveService {
|
||||||
getSessionSummary(): JdLiveSessionSummary;
|
getSessionSummary(): JdLiveSessionSummary;
|
||||||
importSession(input: JdLiveSessionInput): JdLiveSessionSummary;
|
importSession(input: JdLiveSessionInput): JdLiveSessionSummary;
|
||||||
|
|||||||
@ -85,9 +85,18 @@ function createJdLiveServiceStub(overrides: Partial<JdLiveService> = {}): JdLive
|
|||||||
importedAt: "2026-04-03T10:00:00.000Z",
|
importedAt: "2026-04-03T10:00:00.000Z",
|
||||||
hasCookie: true,
|
hasCookie: true,
|
||||||
userAgent: input.userAgent ?? "stub-user-agent",
|
userAgent: input.userAgent ?? "stub-user-agent",
|
||||||
searchApiTemplate: { available: Boolean(input.searchApiTemplateUrl) },
|
searchApiTemplate: {
|
||||||
detailTemplate: { available: Boolean(input.detailTemplateUrl) },
|
available: Boolean(input.searchApiTemplateUrl),
|
||||||
reviewsTemplate: { available: Boolean(input.reviewsTemplateUrl) }
|
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;
|
return summary;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -36,9 +36,18 @@ function createJdLiveServiceStub(
|
|||||||
importedAt: "2026-04-02T12:00:00.000Z",
|
importedAt: "2026-04-02T12:00:00.000Z",
|
||||||
hasCookie: true,
|
hasCookie: true,
|
||||||
userAgent: input.userAgent ?? "stub-user-agent",
|
userAgent: input.userAgent ?? "stub-user-agent",
|
||||||
searchApiTemplate: { available: Boolean(input.searchApiTemplateUrl) },
|
searchApiTemplate: {
|
||||||
detailTemplate: { available: Boolean(input.detailTemplateUrl) },
|
available: Boolean(input.searchApiTemplateUrl),
|
||||||
reviewsTemplate: { available: Boolean(input.reviewsTemplateUrl) }
|
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;
|
return summary;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import type {
|
|||||||
JdReviewsPreviewResult,
|
JdReviewsPreviewResult,
|
||||||
JdSearchPreviewResult
|
JdSearchPreviewResult
|
||||||
} from "./platforms/jd/types";
|
} from "./platforms/jd/types";
|
||||||
|
import type { OpsQrLoginController, OpsQrLoginState } from "./ops/qr-login";
|
||||||
import { createServer } from "./server";
|
import { createServer } from "./server";
|
||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
@ -63,6 +64,31 @@ async function createCompletedTask(app: ReturnType<typeof createServer>, query:
|
|||||||
return confirmResponse.json().task;
|
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) {
|
function backdateStoredTask(storagePath: string, taskId: string, ageDays: number) {
|
||||||
const timestamp = new Date(Date.now() - ageDays * DAY_MS).toISOString();
|
const timestamp = new Date(Date.now() - ageDays * DAY_MS).toISOString();
|
||||||
const snapshot = JSON.parse(readFileSync(storagePath, "utf8")) as {
|
const snapshot = JSON.parse(readFileSync(storagePath, "utf8")) as {
|
||||||
@ -168,9 +194,18 @@ function createJdLiveServiceStub(
|
|||||||
importedAt: "2026-04-02T12:00:00.000Z",
|
importedAt: "2026-04-02T12:00:00.000Z",
|
||||||
hasCookie: true,
|
hasCookie: true,
|
||||||
userAgent: input.userAgent ?? "stub-user-agent",
|
userAgent: input.userAgent ?? "stub-user-agent",
|
||||||
searchApiTemplate: { available: Boolean(input.searchApiTemplateUrl) },
|
searchApiTemplate: {
|
||||||
detailTemplate: { available: Boolean(input.detailTemplateUrl) },
|
available: Boolean(input.searchApiTemplateUrl),
|
||||||
reviewsTemplate: { available: Boolean(input.reviewsTemplateUrl) }
|
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;
|
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", () => {
|
describe("API server", () => {
|
||||||
it("returns platform readiness with jd blocked by default", async () => {
|
it("returns platform readiness with jd blocked by default", async () => {
|
||||||
const app = createServer();
|
const app = createServer();
|
||||||
@ -331,6 +417,79 @@ describe("API server", () => {
|
|||||||
await app.close();
|
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 () => {
|
it("exposes session state with a 24-hour expiry window after preparation", async () => {
|
||||||
const app = createServer();
|
const app = createServer();
|
||||||
await app.ready();
|
await app.ready();
|
||||||
@ -404,6 +563,47 @@ describe("API server", () => {
|
|||||||
await app.close();
|
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 () => {
|
it("records strategy attempts and observability metrics for platform search", async () => {
|
||||||
const app = createServer();
|
const app = createServer();
|
||||||
await app.ready();
|
await app.ready();
|
||||||
@ -1239,7 +1439,6 @@ describe("API server", () => {
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
url: `/api/tasks/${createdTask.taskId}/audit`
|
url: `/api/tasks/${createdTask.taskId}/audit`
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(auditResponse.statusCode).toBe(200);
|
expect(auditResponse.statusCode).toBe(200);
|
||||||
expect(auditResponse.json().audit).toEqual(
|
expect(auditResponse.json().audit).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
@ -1277,6 +1476,61 @@ describe("API server", () => {
|
|||||||
await app.close();
|
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 () => {
|
it("publishes a new report version when a blocked platform recovers successfully", async () => {
|
||||||
const app = createServer();
|
const app = createServer();
|
||||||
await app.ready();
|
await app.ready();
|
||||||
|
|||||||
@ -16,6 +16,11 @@ import type {
|
|||||||
JdSessionManager,
|
JdSessionManager,
|
||||||
JdSessionManagerAutoMode
|
JdSessionManagerAutoMode
|
||||||
} from "./platforms/jd/types";
|
} from "./platforms/jd/types";
|
||||||
|
import {
|
||||||
|
JdOpsQrLoginService,
|
||||||
|
TmallOpsQrLoginService,
|
||||||
|
type OpsQrLoginController
|
||||||
|
} from "./ops/qr-login";
|
||||||
import { TmallLiveSessionService, isTmallLiveError } from "./platforms/tmall/live-session";
|
import { TmallLiveSessionService, isTmallLiveError } from "./platforms/tmall/live-session";
|
||||||
import { TmallSessionManagerService } from "./platforms/tmall/session-manager";
|
import { TmallSessionManagerService } from "./platforms/tmall/session-manager";
|
||||||
import type {
|
import type {
|
||||||
@ -30,8 +35,11 @@ export function createServer(
|
|||||||
storagePath?: string;
|
storagePath?: string;
|
||||||
jdLiveService?: JdLiveService;
|
jdLiveService?: JdLiveService;
|
||||||
jdSessionManager?: JdSessionManager;
|
jdSessionManager?: JdSessionManager;
|
||||||
|
jdQrLoginService?: OpsQrLoginController;
|
||||||
tmallLiveService?: TmallLiveService;
|
tmallLiveService?: TmallLiveService;
|
||||||
tmallSessionManager?: TmallSessionManager;
|
tmallSessionManager?: TmallSessionManager;
|
||||||
|
tmallQrLoginService?: OpsQrLoginController;
|
||||||
|
executionMode?: "synchronous" | "background";
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false });
|
||||||
@ -43,12 +51,17 @@ export function createServer(
|
|||||||
(process.env.NODE_ENV === "test"
|
(process.env.NODE_ENV === "test"
|
||||||
? undefined
|
? undefined
|
||||||
: resolve(process.cwd(), ".data", "task-store.json"));
|
: 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 =
|
const jdSessionManager =
|
||||||
options.jdSessionManager ??
|
options.jdSessionManager ??
|
||||||
new JdSessionManagerService(jdLiveService, {
|
new JdSessionManagerService(jdLiveService, {
|
||||||
onSessionReady: () => {
|
onSessionReady: () => {
|
||||||
store.preparePlatform("jd");
|
store.notifyManagedSessionReady("jd");
|
||||||
},
|
},
|
||||||
onSessionUnavailable: () => {
|
onSessionUnavailable: () => {
|
||||||
store.clearPlatformSession("jd");
|
store.clearPlatformSession("jd");
|
||||||
@ -64,11 +77,27 @@ export function createServer(
|
|||||||
store.clearPlatformSession("tmall");
|
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.setJdSessionManager(jdSessionManager);
|
||||||
store.setTmallSessionManager(tmallSessionManager);
|
store.setTmallSessionManager(tmallSessionManager);
|
||||||
|
|
||||||
if (jdLiveService.getSessionSummary().configured && !store.getSession("jd").ready) {
|
if (jdLiveService.getSessionSummary().configured && !store.getSession("jd").ready) {
|
||||||
store.preparePlatform("jd");
|
store.notifyManagedSessionReady("jd");
|
||||||
}
|
}
|
||||||
if (tmallLiveService.getSessionSummary().configured && !store.getSession("tmall").ready) {
|
if (tmallLiveService.getSessionSummary().configured && !store.getSession("tmall").ready) {
|
||||||
store.preparePlatform("tmall");
|
store.preparePlatform("tmall");
|
||||||
@ -77,6 +106,8 @@ export function createServer(
|
|||||||
app.addHook("onClose", async () => {
|
app.addHook("onClose", async () => {
|
||||||
jdSessionManager.shutdown();
|
jdSessionManager.shutdown();
|
||||||
tmallSessionManager.shutdown();
|
tmallSessionManager.shutdown();
|
||||||
|
await jdQrLoginService.shutdown();
|
||||||
|
await tmallQrLoginService.shutdown();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.register(cors, { origin: true });
|
app.register(cors, { origin: true });
|
||||||
@ -211,9 +242,13 @@ export function createServer(
|
|||||||
};
|
};
|
||||||
}>("/api/ops/jd/session-manager/session", async (request, reply) => {
|
}>("/api/ops/jd/session-manager/session", async (request, reply) => {
|
||||||
try {
|
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);
|
reply.code(200);
|
||||||
return { manager };
|
return {
|
||||||
|
manager: result.state,
|
||||||
|
recovered: result.recovered
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reply.code(isJdLiveError(error) ? error.statusCode : 400);
|
reply.code(isJdLiveError(error) ? error.statusCode : 400);
|
||||||
return {
|
return {
|
||||||
@ -226,6 +261,42 @@ export function createServer(
|
|||||||
manager: jdSessionManager.clearManagedSession("ops-manual-clear")
|
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 () => ({
|
app.get("/api/ops/tmall/session-manager", async () => ({
|
||||||
manager: tmallSessionManager.getState()
|
manager: tmallSessionManager.getState()
|
||||||
}));
|
}));
|
||||||
@ -290,6 +361,29 @@ export function createServer(
|
|||||||
manager: tmallSessionManager.clearManagedSession("ops-manual-clear")
|
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 () => ({
|
app.get("/api/platforms/tmall/live-session", async () => ({
|
||||||
session: tmallLiveService.getSessionSummary()
|
session: tmallLiveService.getSessionSummary()
|
||||||
}));
|
}));
|
||||||
@ -307,6 +401,7 @@ export function createServer(
|
|||||||
}>("/api/platforms/jd/live-session", async (request, reply) => {
|
}>("/api/platforms/jd/live-session", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
await jdSessionManager.importManualSession(request.body, "legacy-live-session");
|
await jdSessionManager.importManualSession(request.body, "legacy-live-session");
|
||||||
|
await jdSessionManager.runHealthCheck("legacy-live-session");
|
||||||
reply.code(200);
|
reply.code(200);
|
||||||
return { session: jdLiveService.getSessionSummary() };
|
return { session: jdLiveService.getSessionSummary() };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -806,15 +901,28 @@ export function createServer(
|
|||||||
return { message: "Task not found." };
|
return { message: "Task not found." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reply.hijack();
|
||||||
reply.raw.writeHead(200, {
|
reply.raw.writeHead(200, {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
"Cache-Control": "no-cache",
|
"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;
|
return app;
|
||||||
|
|||||||
@ -282,12 +282,15 @@ function isReportableTaskStatus(
|
|||||||
return status === "Completed" || status === "PartialCompleted";
|
return status === "Completed" || status === "PartialCompleted";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TaskExecutionMode = "synchronous" | "background";
|
||||||
|
|
||||||
type InMemoryTaskStoreOptions = {
|
type InMemoryTaskStoreOptions = {
|
||||||
storagePath?: string | undefined;
|
storagePath?: string | undefined;
|
||||||
jdLiveService?: JdLiveService | undefined;
|
jdLiveService?: JdLiveService | undefined;
|
||||||
jdSessionManager?: JdSessionManager | undefined;
|
jdSessionManager?: JdSessionManager | undefined;
|
||||||
tmallLiveService?: TmallLiveService | undefined;
|
tmallLiveService?: TmallLiveService | undefined;
|
||||||
tmallSessionManager?: TmallSessionManager | undefined;
|
tmallSessionManager?: TmallSessionManager | undefined;
|
||||||
|
executionMode?: TaskExecutionMode | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TaskExecutionArtifact = {
|
type TaskExecutionArtifact = {
|
||||||
@ -424,11 +427,18 @@ export class InMemoryTaskStore {
|
|||||||
Partial<Record<PlatformId, MockExecutionScenario>>
|
Partial<Record<PlatformId, MockExecutionScenario>>
|
||||||
>();
|
>();
|
||||||
private readonly executionArtifacts = new Map<string, TaskExecutionArtifact[]>();
|
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 storagePath: string | undefined;
|
||||||
private readonly jdLiveService: JdLiveService | undefined;
|
private readonly jdLiveService: JdLiveService | undefined;
|
||||||
private jdSessionManager: JdSessionManager | undefined;
|
private jdSessionManager: JdSessionManager | undefined;
|
||||||
private readonly tmallLiveService: TmallLiveService | undefined;
|
private readonly tmallLiveService: TmallLiveService | undefined;
|
||||||
private tmallSessionManager: TmallSessionManager | undefined;
|
private tmallSessionManager: TmallSessionManager | undefined;
|
||||||
|
private readonly executionMode: TaskExecutionMode;
|
||||||
|
|
||||||
constructor(options: InMemoryTaskStoreOptions = {}) {
|
constructor(options: InMemoryTaskStoreOptions = {}) {
|
||||||
this.storagePath = options.storagePath;
|
this.storagePath = options.storagePath;
|
||||||
@ -436,6 +446,8 @@ export class InMemoryTaskStore {
|
|||||||
this.jdSessionManager = options.jdSessionManager;
|
this.jdSessionManager = options.jdSessionManager;
|
||||||
this.tmallLiveService = options.tmallLiveService;
|
this.tmallLiveService = options.tmallLiveService;
|
||||||
this.tmallSessionManager = options.tmallSessionManager;
|
this.tmallSessionManager = options.tmallSessionManager;
|
||||||
|
this.executionMode =
|
||||||
|
options.executionMode ?? (process.env.NODE_ENV === "test" ? "synchronous" : "background");
|
||||||
|
|
||||||
const persistedState = this.loadPersistedState();
|
const persistedState = this.loadPersistedState();
|
||||||
if (persistedState) {
|
if (persistedState) {
|
||||||
@ -483,6 +495,12 @@ export class InMemoryTaskStore {
|
|||||||
return this.toSessionRecord(next);
|
return this.toSessionRecord(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifyManagedSessionReady(platform: PlatformId): SessionStateRecord {
|
||||||
|
const session = this.preparePlatform(platform);
|
||||||
|
void this.resumeTasksWaitingForManagedSession(platform);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
clearPlatformSession(platform: PlatformId): void {
|
clearPlatformSession(platform: PlatformId): void {
|
||||||
const cleared = createMissingSession(platform);
|
const cleared = createMissingSession(platform);
|
||||||
this.readiness.set(platform, cleared);
|
this.readiness.set(platform, cleared);
|
||||||
@ -567,6 +585,24 @@ export class InMemoryTaskStore {
|
|||||||
return this.tasks.get(taskId);
|
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 {
|
getCandidates(taskId: string): Record<PlatformId, CandidateRecord[]> | undefined {
|
||||||
return this.tasks.get(taskId)?.platformCandidates;
|
return this.tasks.get(taskId)?.platformCandidates;
|
||||||
}
|
}
|
||||||
@ -714,6 +750,12 @@ export class InMemoryTaskStore {
|
|||||||
task.taskStage = "session_check";
|
task.taskStage = "session_check";
|
||||||
this.pushEvent(task, "task.running", "系统开始执行抓取前校验。");
|
this.pushEvent(task, "task.running", "系统开始执行抓取前校验。");
|
||||||
|
|
||||||
|
if (this.executionMode === "background") {
|
||||||
|
this.persistState();
|
||||||
|
this.scheduleTaskExecution(task.taskId);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
await this.executeSelectedPlatforms(task, selectedRuns);
|
await this.executeSelectedPlatforms(task, selectedRuns);
|
||||||
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
|
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
|
||||||
task.updatedAt = nowIso();
|
task.updatedAt = nowIso();
|
||||||
@ -1664,6 +1706,44 @@ export class InMemoryTaskStore {
|
|||||||
this.pushEvent(task, "task.awaiting_confirmation", "候选召回完成,等待人工确认。");
|
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 {
|
private buildReport(task: TaskRecord): ReportSnapshot {
|
||||||
const reportVersion = this.getNextReportVersion(task);
|
const reportVersion = this.getNextReportVersion(task);
|
||||||
const completedRuns = task.platformRuns.filter((run) => run.status === "Completed");
|
const completedRuns = task.platformRuns.filter((run) => run.status === "Completed");
|
||||||
@ -2064,6 +2144,11 @@ export class InMemoryTaskStore {
|
|||||||
run.status = "Searching";
|
run.status = "Searching";
|
||||||
run.reason = undefined;
|
run.reason = undefined;
|
||||||
run.lastUpdatedAt = nowIso();
|
run.lastUpdatedAt = nowIso();
|
||||||
|
this.pushEvent(
|
||||||
|
task,
|
||||||
|
`platform.${run.platform}.searching`,
|
||||||
|
`${platformCatalogMap[run.platform].label} 正在执行候选搜索。`
|
||||||
|
);
|
||||||
let candidates: CandidateRecord[] = [];
|
let candidates: CandidateRecord[] = [];
|
||||||
|
|
||||||
if (run.platform === "jd" && this.hasConfiguredJdLiveSession()) {
|
if (run.platform === "jd" && this.hasConfiguredJdLiveSession()) {
|
||||||
@ -2963,6 +3048,85 @@ export class InMemoryTaskStore {
|
|||||||
};
|
};
|
||||||
task.events.push(event);
|
task.events.push(event);
|
||||||
task.updatedAt = event.createdAt;
|
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 {
|
private requireTask(taskId: string): TaskRecord {
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import {
|
|||||||
clearJdSessionManagerConfig,
|
clearJdSessionManagerConfig,
|
||||||
clearPlatformSession,
|
clearPlatformSession,
|
||||||
confirmTask,
|
confirmTask,
|
||||||
|
createTaskEventsSource,
|
||||||
createTask,
|
createTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
getJdKeywordPreview,
|
getJdKeywordPreview,
|
||||||
@ -80,6 +81,50 @@ function isRetryablePlatformStatus(
|
|||||||
return status === "SearchBlocked" || status === "Blocked" || status === "Failed";
|
return status === "SearchBlocked" || status === "Blocked" || status === "Failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useTaskLiveSync(
|
||||||
|
taskId: string,
|
||||||
|
options: {
|
||||||
|
includeCandidates?: boolean;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!taskId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventSource = createTaskEventsSource(taskId);
|
||||||
|
const handleSnapshot = (event: MessageEvent<string>) => {
|
||||||
|
const payload = JSON.parse(event.data) as { task: TaskRecord };
|
||||||
|
queryClient.setQueryData(["task", taskId], payload);
|
||||||
|
|
||||||
|
if (options.includeCandidates) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["task-candidates", taskId] });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
payload.task.taskStatus === "Completed" ||
|
||||||
|
payload.task.taskStatus === "PartialCompleted" ||
|
||||||
|
payload.task.taskStatus === "Blocked" ||
|
||||||
|
payload.task.taskStatus === "Failed"
|
||||||
|
) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["history"] });
|
||||||
|
if (payload.task.defaultReportVersion) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["report", taskId] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.addEventListener("task.snapshot", handleSnapshot as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSource.removeEventListener("task.snapshot", handleSnapshot as EventListener);
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
}, [options.includeCandidates, queryClient, taskId]);
|
||||||
|
}
|
||||||
|
|
||||||
function getTaskDestination(
|
function getTaskDestination(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
taskStatus: TaskStatus,
|
taskStatus: TaskStatus,
|
||||||
@ -552,6 +597,8 @@ function CandidateCard(props: {
|
|||||||
function ConfirmPage() {
|
function ConfirmPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { taskId = "" } = useParams();
|
const { taskId = "" } = useParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
useTaskLiveSync(taskId, { includeCandidates: true });
|
||||||
const taskQuery = useQuery({
|
const taskQuery = useQuery({
|
||||||
queryKey: ["task", taskId],
|
queryKey: ["task", taskId],
|
||||||
queryFn: () => getTask(taskId)
|
queryFn: () => getTask(taskId)
|
||||||
@ -590,6 +637,16 @@ function ConfirmPage() {
|
|||||||
navigate(`/tasks/${task.taskId}/run`);
|
navigate(`/tasks/${task.taskId}/run`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const retryMutation = useMutation({
|
||||||
|
mutationFn: (platform: PlatformId) => retryTaskPlatform(taskId, platform),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["task", taskId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["task-candidates", taskId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["history"] })
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (!taskQuery.data || !candidatesQuery.data) {
|
if (!taskQuery.data || !candidatesQuery.data) {
|
||||||
return (
|
return (
|
||||||
@ -625,9 +682,27 @@ function ConfirmPage() {
|
|||||||
<p>{platformRun?.reason ?? "当前没有候选结果。"}</p>
|
<p>{platformRun?.reason ?? "当前没有候选结果。"}</p>
|
||||||
{platformRun?.status === "SearchBlocked" ? (
|
{platformRun?.status === "SearchBlocked" ? (
|
||||||
isOpsManagedPlatform(platform) ? (
|
isOpsManagedPlatform(platform) ? (
|
||||||
<span className="inline-note inline-note--subtle">
|
<>
|
||||||
京东阻塞恢复已切到运维后台,当前页面不提供直接恢复入口。
|
<span className="inline-note inline-note--subtle">
|
||||||
</span>
|
京东阻塞恢复已切到运维后台,当前页面不提供直接恢复入口。
|
||||||
|
</span>
|
||||||
|
<div className="panel-actions">
|
||||||
|
<a
|
||||||
|
className="text-link"
|
||||||
|
href={buildOpsSessionManagerHref(platform, `/tasks/${taskId}/confirm`)}
|
||||||
|
>
|
||||||
|
去运维恢复
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
className="ghost-button"
|
||||||
|
disabled={retryMutation.isPending && retryMutation.variables === platform}
|
||||||
|
onClick={() => retryMutation.mutate(platform)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
恢复后重试候选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<a
|
<a
|
||||||
className="text-link"
|
className="text-link"
|
||||||
@ -637,6 +712,18 @@ function ConfirmPage() {
|
|||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
|
{platformRun?.status === "Failed" ? (
|
||||||
|
<div className="panel-actions">
|
||||||
|
<button
|
||||||
|
className="ghost-button"
|
||||||
|
disabled={retryMutation.isPending && retryMutation.variables === platform}
|
||||||
|
onClick={() => retryMutation.mutate(platform)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
重试平台搜索
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
@ -683,6 +770,7 @@ function ConfirmPage() {
|
|||||||
function RunPage() {
|
function RunPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { taskId = "" } = useParams();
|
const { taskId = "" } = useParams();
|
||||||
|
useTaskLiveSync(taskId);
|
||||||
const taskQuery = useQuery({
|
const taskQuery = useQuery({
|
||||||
queryKey: ["task", taskId],
|
queryKey: ["task", taskId],
|
||||||
queryFn: () => getTask(taskId)
|
queryFn: () => getTask(taskId)
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("./lib/api", () => ({
|
vi.mock("./lib/api", () => ({
|
||||||
|
cancelJdQrLogin: vi.fn(),
|
||||||
|
cancelTmallQrLogin: vi.fn(),
|
||||||
clearJdManagedSession: vi.fn(),
|
clearJdManagedSession: vi.fn(),
|
||||||
clearJdSessionManagerConfig: vi.fn(),
|
clearJdSessionManagerConfig: vi.fn(),
|
||||||
clearPlatformSession: vi.fn(),
|
clearPlatformSession: vi.fn(),
|
||||||
@ -13,9 +15,15 @@ vi.mock("./lib/api", () => ({
|
|||||||
clearTmallSessionManagerConfig: vi.fn(),
|
clearTmallSessionManagerConfig: vi.fn(),
|
||||||
confirmTask: vi.fn(),
|
confirmTask: vi.fn(),
|
||||||
createTask: vi.fn(),
|
createTask: vi.fn(),
|
||||||
|
createTaskEventsSource: vi.fn(() => ({
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
removeEventListener: vi.fn()
|
||||||
|
})),
|
||||||
deleteTask: vi.fn(),
|
deleteTask: vi.fn(),
|
||||||
getJdKeywordPreview: vi.fn(),
|
getJdKeywordPreview: vi.fn(),
|
||||||
getJdLiveSession: vi.fn(),
|
getJdLiveSession: vi.fn(),
|
||||||
|
getJdQrLoginState: vi.fn(),
|
||||||
getJdSessionManager: vi.fn(),
|
getJdSessionManager: vi.fn(),
|
||||||
getHistoryTasks: vi.fn(),
|
getHistoryTasks: vi.fn(),
|
||||||
getPlatformReadiness: vi.fn(),
|
getPlatformReadiness: vi.fn(),
|
||||||
@ -24,37 +32,50 @@ vi.mock("./lib/api", () => ({
|
|||||||
getTaskCandidates: vi.fn(),
|
getTaskCandidates: vi.fn(),
|
||||||
getTaskReport: vi.fn(),
|
getTaskReport: vi.fn(),
|
||||||
getTmallLiveSession: vi.fn(),
|
getTmallLiveSession: vi.fn(),
|
||||||
|
getTmallQrLoginState: vi.fn(),
|
||||||
getTmallSessionManager: vi.fn(),
|
getTmallSessionManager: vi.fn(),
|
||||||
importJdManagedSession: vi.fn(),
|
importJdManagedSession: vi.fn(),
|
||||||
importTmallManagedSession: vi.fn(),
|
importTmallManagedSession: vi.fn(),
|
||||||
preparePlatform: vi.fn(),
|
preparePlatform: vi.fn(),
|
||||||
|
resumeJdQrLoginManualRecovery: vi.fn(),
|
||||||
runJdSessionManagerHealthCheck: vi.fn(),
|
runJdSessionManagerHealthCheck: vi.fn(),
|
||||||
runJdSessionManagerRecovery: vi.fn(),
|
runJdSessionManagerRecovery: vi.fn(),
|
||||||
runTmallSessionManagerHealthCheck: vi.fn(),
|
runTmallSessionManagerHealthCheck: vi.fn(),
|
||||||
retryTaskPlatform: vi.fn(),
|
retryTaskPlatform: vi.fn(),
|
||||||
|
startJdQrLogin: vi.fn(),
|
||||||
|
startTmallQrLogin: vi.fn(),
|
||||||
updateJdSessionManagerConfig: vi.fn(),
|
updateJdSessionManagerConfig: vi.fn(),
|
||||||
updateTmallSessionManagerConfig: vi.fn()
|
updateTmallSessionManagerConfig: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { App, NewTaskPage } from "./App";
|
import { App, NewTaskPage } from "./App";
|
||||||
import {
|
import {
|
||||||
|
cancelJdQrLogin,
|
||||||
|
cancelTmallQrLogin,
|
||||||
clearJdManagedSession,
|
clearJdManagedSession,
|
||||||
clearPlatformSession,
|
clearPlatformSession,
|
||||||
clearTmallManagedSession,
|
clearTmallManagedSession,
|
||||||
getJdKeywordPreview,
|
getJdKeywordPreview,
|
||||||
getJdLiveSession,
|
getJdLiveSession,
|
||||||
|
getJdQrLoginState,
|
||||||
getJdSessionManager,
|
getJdSessionManager,
|
||||||
getHistoryTasks,
|
getHistoryTasks,
|
||||||
getPlatformReadiness,
|
getPlatformReadiness,
|
||||||
getPlatformSession,
|
getPlatformSession,
|
||||||
|
getTask,
|
||||||
|
getTaskCandidates,
|
||||||
getTmallLiveSession,
|
getTmallLiveSession,
|
||||||
|
getTmallQrLoginState,
|
||||||
getTmallSessionManager,
|
getTmallSessionManager,
|
||||||
importJdManagedSession,
|
importJdManagedSession,
|
||||||
importTmallManagedSession,
|
importTmallManagedSession,
|
||||||
preparePlatform,
|
preparePlatform,
|
||||||
|
createTaskEventsSource,
|
||||||
runJdSessionManagerHealthCheck,
|
runJdSessionManagerHealthCheck,
|
||||||
runJdSessionManagerRecovery,
|
runJdSessionManagerRecovery,
|
||||||
runTmallSessionManagerHealthCheck,
|
runTmallSessionManagerHealthCheck,
|
||||||
|
startJdQrLogin,
|
||||||
|
startTmallQrLogin,
|
||||||
updateJdSessionManagerConfig
|
updateJdSessionManagerConfig
|
||||||
} from "./lib/api";
|
} from "./lib/api";
|
||||||
|
|
||||||
@ -219,6 +240,14 @@ describe("task composer and session console", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} as any);
|
} as any);
|
||||||
|
vi.mocked(getJdQrLoginState).mockResolvedValue({
|
||||||
|
qrLogin: {
|
||||||
|
platform: "jd",
|
||||||
|
status: "idle",
|
||||||
|
note: "尚未启动扫码登录。",
|
||||||
|
sessionImported: false
|
||||||
|
}
|
||||||
|
} as any);
|
||||||
vi.mocked(getJdKeywordPreview).mockResolvedValue({
|
vi.mocked(getJdKeywordPreview).mockResolvedValue({
|
||||||
preview: {
|
preview: {
|
||||||
query: "小米手环10",
|
query: "小米手环10",
|
||||||
@ -323,6 +352,14 @@ describe("task composer and session console", () => {
|
|||||||
vi.mocked(getTmallLiveSession).mockResolvedValue({
|
vi.mocked(getTmallLiveSession).mockResolvedValue({
|
||||||
session: tmallManagerState.session
|
session: tmallManagerState.session
|
||||||
} as any);
|
} as any);
|
||||||
|
vi.mocked(getTmallQrLoginState).mockResolvedValue({
|
||||||
|
qrLogin: {
|
||||||
|
platform: "tmall",
|
||||||
|
status: "idle",
|
||||||
|
note: "尚未启动扫码登录。",
|
||||||
|
sessionImported: false
|
||||||
|
}
|
||||||
|
} as any);
|
||||||
vi.mocked(clearPlatformSession).mockResolvedValue(undefined);
|
vi.mocked(clearPlatformSession).mockResolvedValue(undefined);
|
||||||
vi.mocked(importJdManagedSession).mockResolvedValue({
|
vi.mocked(importJdManagedSession).mockResolvedValue({
|
||||||
manager: {
|
manager: {
|
||||||
@ -408,10 +445,46 @@ describe("task composer and session console", () => {
|
|||||||
state: jdManagerState,
|
state: jdManagerState,
|
||||||
recovered: true
|
recovered: true
|
||||||
} as any);
|
} as any);
|
||||||
|
vi.mocked(startJdQrLogin).mockResolvedValue({
|
||||||
|
qrLogin: {
|
||||||
|
platform: "jd",
|
||||||
|
status: "waiting_for_scan",
|
||||||
|
note: "二维码已生成,请扫码。",
|
||||||
|
targetId: "100068388533",
|
||||||
|
qrImageDataUrl: "data:image/png;base64,stub",
|
||||||
|
sessionImported: false
|
||||||
|
}
|
||||||
|
} as any);
|
||||||
|
vi.mocked(cancelJdQrLogin).mockResolvedValue({
|
||||||
|
qrLogin: {
|
||||||
|
platform: "jd",
|
||||||
|
status: "cancelled",
|
||||||
|
note: "扫码已取消。",
|
||||||
|
sessionImported: false
|
||||||
|
}
|
||||||
|
} as any);
|
||||||
vi.mocked(runTmallSessionManagerHealthCheck).mockResolvedValue({
|
vi.mocked(runTmallSessionManagerHealthCheck).mockResolvedValue({
|
||||||
state: tmallManagerState,
|
state: tmallManagerState,
|
||||||
recovered: false
|
recovered: false
|
||||||
} as any);
|
} as any);
|
||||||
|
vi.mocked(startTmallQrLogin).mockResolvedValue({
|
||||||
|
qrLogin: {
|
||||||
|
platform: "tmall",
|
||||||
|
status: "waiting_for_scan",
|
||||||
|
note: "二维码已生成,请扫码。",
|
||||||
|
targetId: "934454505228",
|
||||||
|
qrImageDataUrl: "data:image/png;base64,stub",
|
||||||
|
sessionImported: false
|
||||||
|
}
|
||||||
|
} as any);
|
||||||
|
vi.mocked(cancelTmallQrLogin).mockResolvedValue({
|
||||||
|
qrLogin: {
|
||||||
|
platform: "tmall",
|
||||||
|
status: "cancelled",
|
||||||
|
note: "扫码已取消。",
|
||||||
|
sessionImported: false
|
||||||
|
}
|
||||||
|
} as any);
|
||||||
vi.mocked(preparePlatform).mockResolvedValue({
|
vi.mocked(preparePlatform).mockResolvedValue({
|
||||||
platform: "jd",
|
platform: "jd",
|
||||||
session_ready: true,
|
session_ready: true,
|
||||||
@ -480,6 +553,134 @@ describe("task composer and session console", () => {
|
|||||||
expect(screen.getByText(/返回业务页面:\/tasks\/new/)).toBeInTheDocument();
|
expect(screen.getByText(/返回业务页面:\/tasks\/new/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updates the confirm page when a task snapshot restores JD candidates", async () => {
|
||||||
|
let snapshotHandler: ((event: MessageEvent<string>) => void) | undefined;
|
||||||
|
vi.mocked(createTaskEventsSource).mockReturnValue({
|
||||||
|
addEventListener: vi.fn((_type: string, handler: EventListenerOrEventListenerObject) => {
|
||||||
|
snapshotHandler = handler as (event: MessageEvent<string>) => void;
|
||||||
|
}),
|
||||||
|
close: vi.fn(),
|
||||||
|
removeEventListener: vi.fn()
|
||||||
|
} as unknown as EventSource);
|
||||||
|
|
||||||
|
vi.mocked(getTask).mockResolvedValue({
|
||||||
|
task: {
|
||||||
|
taskId: "task-confirm-live",
|
||||||
|
query: "iPhone 15",
|
||||||
|
createdAt: "2026-04-07T09:00:00.000Z",
|
||||||
|
updatedAt: "2026-04-07T09:00:00.000Z",
|
||||||
|
perLinkLimit: 100,
|
||||||
|
taskTotalLimit: 500,
|
||||||
|
taskStatus: "AwaitingConfirmation",
|
||||||
|
taskStage: "confirmation",
|
||||||
|
platformRuns: [
|
||||||
|
{
|
||||||
|
platform: "tmall",
|
||||||
|
searchRequirement: "recommended",
|
||||||
|
status: "AwaitingSelection",
|
||||||
|
candidateCount: 1,
|
||||||
|
selectedCandidateIds: [],
|
||||||
|
lastUpdatedAt: "2026-04-07T09:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: "jd",
|
||||||
|
searchRequirement: "required",
|
||||||
|
status: "SearchBlocked",
|
||||||
|
candidateCount: 0,
|
||||||
|
selectedCandidateIds: [],
|
||||||
|
reason: "waiting for ops recovery",
|
||||||
|
lastUpdatedAt: "2026-04-07T09:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
platformCandidates: {
|
||||||
|
tmall: [],
|
||||||
|
jd: []
|
||||||
|
},
|
||||||
|
events: [],
|
||||||
|
reportVersions: []
|
||||||
|
}
|
||||||
|
} as any);
|
||||||
|
vi.mocked(getTaskCandidates)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
candidates: {
|
||||||
|
tmall: [],
|
||||||
|
jd: []
|
||||||
|
}
|
||||||
|
} as any)
|
||||||
|
.mockResolvedValue({
|
||||||
|
candidates: {
|
||||||
|
tmall: [],
|
||||||
|
jd: [
|
||||||
|
{
|
||||||
|
candidateId: "jd-100068388533",
|
||||||
|
platform: "jd",
|
||||||
|
title: "Apple iPhone 15",
|
||||||
|
price: 3898,
|
||||||
|
priceLabel: "CNY 3898",
|
||||||
|
storeName: "JD Self Operated",
|
||||||
|
productUrl: "https://item.jd.com/100068388533.html",
|
||||||
|
imageUrl: "https://img14.360buyimg.com/example.jpg",
|
||||||
|
salesHint: "sold 500+",
|
||||||
|
specLabel: "128GB",
|
||||||
|
highlights: ["A16"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
renderWithProviders(<App />, ["/tasks/task-confirm-live/confirm"]);
|
||||||
|
|
||||||
|
expect(await screen.findByText("waiting for ops recovery")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
snapshotHandler?.(
|
||||||
|
new MessageEvent("task.snapshot", {
|
||||||
|
data: JSON.stringify({
|
||||||
|
task: {
|
||||||
|
taskId: "task-confirm-live",
|
||||||
|
query: "iPhone 15",
|
||||||
|
createdAt: "2026-04-07T09:00:00.000Z",
|
||||||
|
updatedAt: "2026-04-07T09:01:00.000Z",
|
||||||
|
perLinkLimit: 100,
|
||||||
|
taskTotalLimit: 500,
|
||||||
|
taskStatus: "AwaitingConfirmation",
|
||||||
|
taskStage: "confirmation",
|
||||||
|
platformRuns: [
|
||||||
|
{
|
||||||
|
platform: "tmall",
|
||||||
|
searchRequirement: "recommended",
|
||||||
|
status: "AwaitingSelection",
|
||||||
|
candidateCount: 1,
|
||||||
|
selectedCandidateIds: [],
|
||||||
|
lastUpdatedAt: "2026-04-07T09:00:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: "jd",
|
||||||
|
searchRequirement: "required",
|
||||||
|
status: "AwaitingSelection",
|
||||||
|
candidateCount: 1,
|
||||||
|
selectedCandidateIds: [],
|
||||||
|
lastUpdatedAt: "2026-04-07T09:01:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
platformCandidates: {
|
||||||
|
tmall: [],
|
||||||
|
jd: []
|
||||||
|
},
|
||||||
|
events: [],
|
||||||
|
reportVersions: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("Apple iPhone 15")).toBeInTheDocument();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getTaskCandidates).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("imports jd managed session payload from the ops page", async () => {
|
it("imports jd managed session payload from the ops page", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
|||||||
@ -4,23 +4,31 @@ import { useEffect, useState } from "react";
|
|||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
cancelJdQrLogin,
|
||||||
|
cancelTmallQrLogin,
|
||||||
clearJdManagedSession,
|
clearJdManagedSession,
|
||||||
clearJdSessionManagerConfig,
|
clearJdSessionManagerConfig,
|
||||||
clearTmallManagedSession,
|
clearTmallManagedSession,
|
||||||
clearTmallSessionManagerConfig,
|
clearTmallSessionManagerConfig,
|
||||||
getJdLiveSession,
|
getJdLiveSession,
|
||||||
|
getJdQrLoginState,
|
||||||
getJdSessionManager,
|
getJdSessionManager,
|
||||||
getTmallLiveSession,
|
getTmallLiveSession,
|
||||||
|
getTmallQrLoginState,
|
||||||
getTmallSessionManager,
|
getTmallSessionManager,
|
||||||
importJdManagedSession,
|
importJdManagedSession,
|
||||||
importTmallManagedSession,
|
importTmallManagedSession,
|
||||||
runJdSessionManagerHealthCheck,
|
runJdSessionManagerHealthCheck,
|
||||||
runJdSessionManagerRecovery,
|
runJdSessionManagerRecovery,
|
||||||
runTmallSessionManagerHealthCheck,
|
runTmallSessionManagerHealthCheck,
|
||||||
|
resumeJdQrLoginManualRecovery,
|
||||||
type JdLiveSessionInput,
|
type JdLiveSessionInput,
|
||||||
type JdSessionManagerConfigInput,
|
type JdSessionManagerConfigInput,
|
||||||
|
type OpsQrLoginState,
|
||||||
type TmallLiveSessionInput,
|
type TmallLiveSessionInput,
|
||||||
type TmallSessionManagerConfigInput,
|
type TmallSessionManagerConfigInput,
|
||||||
|
startJdQrLogin,
|
||||||
|
startTmallQrLogin,
|
||||||
updateJdSessionManagerConfig,
|
updateJdSessionManagerConfig,
|
||||||
updateTmallSessionManagerConfig
|
updateTmallSessionManagerConfig
|
||||||
} from "./lib/api";
|
} from "./lib/api";
|
||||||
@ -52,6 +60,112 @@ function formatTimestamp(timestamp?: string) {
|
|||||||
return new Date(timestamp).toLocaleString("zh-CN");
|
return new Date(timestamp).toLocaleString("zh-CN");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SCREENSHOT_POLL_INTERVAL_MS = 1500;
|
||||||
|
|
||||||
|
function isActiveQrLogin(state?: OpsQrLoginState) {
|
||||||
|
return (
|
||||||
|
state?.status === "launching" ||
|
||||||
|
state?.status === "waiting_for_scan" ||
|
||||||
|
state?.status === "capturing_session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QrLoginCard(props: {
|
||||||
|
platformLabel: string;
|
||||||
|
platformHint: string;
|
||||||
|
qrLogin?: OpsQrLoginState | undefined;
|
||||||
|
onStart: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onResumeManualRecovery?: (() => void) | undefined;
|
||||||
|
startPending: boolean;
|
||||||
|
cancelPending: boolean;
|
||||||
|
resumePending?: boolean | undefined;
|
||||||
|
}) {
|
||||||
|
const active = isActiveQrLogin(props.qrLogin);
|
||||||
|
const canResumeManualRecovery =
|
||||||
|
props.qrLogin?.status === "failed" &&
|
||||||
|
props.qrLogin.sessionImported &&
|
||||||
|
Boolean(props.onResumeManualRecovery);
|
||||||
|
const startLabel = active ? "重新生成二维码" : "开始扫码登录";
|
||||||
|
const qrPreview = active ? props.qrLogin?.qrImageDataUrl : undefined;
|
||||||
|
|
||||||
|
let emptyState = "点击“开始扫码登录”后,这里会显示实时二维码截图。";
|
||||||
|
if (props.qrLogin?.status === "launching") {
|
||||||
|
emptyState = "二维码生成中...";
|
||||||
|
} else if (props.qrLogin?.status === "failed") {
|
||||||
|
emptyState = "上一次二维码已失效,请点击“重新生成二维码”。";
|
||||||
|
} else if (props.qrLogin?.status === "cancelled") {
|
||||||
|
emptyState = "当前扫码流程已取消,请重新生成二维码。";
|
||||||
|
} else if (props.qrLogin?.status === "completed") {
|
||||||
|
emptyState = "扫码流程已完成,可在右侧查看会话导入状态。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-panel ops-panel">
|
||||||
|
<p className="eyebrow">QR Login</p>
|
||||||
|
<strong>{props.platformLabel}扫码登录</strong>
|
||||||
|
<p className="inline-note">{props.platformHint}</p>
|
||||||
|
<div className="qr-login-card">
|
||||||
|
<div className="qr-login-card__preview">
|
||||||
|
{qrPreview ? (
|
||||||
|
<img alt={`${props.platformLabel} 登录二维码`} src={qrPreview} />
|
||||||
|
) : (
|
||||||
|
<div className="qr-login-card__empty">{emptyState}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="stack stack--dense">
|
||||||
|
<div className="session-details">
|
||||||
|
<div className="session-details__row">
|
||||||
|
<span>Status</span>
|
||||||
|
<strong>{props.qrLogin?.status ?? "idle"}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="session-details__row">
|
||||||
|
<span>Target</span>
|
||||||
|
<strong>{props.qrLogin?.targetId ?? "默认目标"}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="session-details__row">
|
||||||
|
<span>Updated</span>
|
||||||
|
<strong>{formatTimestamp(props.qrLogin?.updatedAt)}</strong>
|
||||||
|
</div>
|
||||||
|
<p>{props.qrLogin?.note ?? "尚未启动扫码流程。"}</p>
|
||||||
|
{props.qrLogin?.currentUrl ? (
|
||||||
|
<p className="session-return-target">{props.qrLogin.currentUrl}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="panel-actions">
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
disabled={props.startPending}
|
||||||
|
onClick={props.onStart}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{startLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button"
|
||||||
|
disabled={props.cancelPending || !active}
|
||||||
|
onClick={props.onCancel}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
取消当前扫码
|
||||||
|
</button>
|
||||||
|
{canResumeManualRecovery ? (
|
||||||
|
<button
|
||||||
|
className="ghost-button"
|
||||||
|
disabled={props.resumePending}
|
||||||
|
onClick={props.onResumeManualRecovery}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
人工恢复后重新导入
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function OpsSessionManagerPage() {
|
export function OpsSessionManagerPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@ -102,6 +216,12 @@ export function OpsSessionManagerPage() {
|
|||||||
queryKey: ["jd-live-session"],
|
queryKey: ["jd-live-session"],
|
||||||
queryFn: getJdLiveSession
|
queryFn: getJdLiveSession
|
||||||
});
|
});
|
||||||
|
const jdQrLoginQuery = useQuery({
|
||||||
|
queryKey: ["jd-qr-login"],
|
||||||
|
queryFn: getJdQrLoginState,
|
||||||
|
refetchInterval: (query) =>
|
||||||
|
isActiveQrLogin(query.state.data?.qrLogin) ? SCREENSHOT_POLL_INTERVAL_MS : false
|
||||||
|
});
|
||||||
const tmallManagerQuery = useQuery({
|
const tmallManagerQuery = useQuery({
|
||||||
queryKey: ["tmall-session-manager"],
|
queryKey: ["tmall-session-manager"],
|
||||||
queryFn: getTmallSessionManager
|
queryFn: getTmallSessionManager
|
||||||
@ -110,11 +230,18 @@ export function OpsSessionManagerPage() {
|
|||||||
queryKey: ["tmall-live-session"],
|
queryKey: ["tmall-live-session"],
|
||||||
queryFn: getTmallLiveSession
|
queryFn: getTmallLiveSession
|
||||||
});
|
});
|
||||||
|
const tmallQrLoginQuery = useQuery({
|
||||||
|
queryKey: ["tmall-qr-login"],
|
||||||
|
queryFn: getTmallQrLoginState,
|
||||||
|
refetchInterval: (query) =>
|
||||||
|
isActiveQrLogin(query.state.data?.qrLogin) ? SCREENSHOT_POLL_INTERVAL_MS : false
|
||||||
|
});
|
||||||
|
|
||||||
const invalidateJdOpsQueries = async () => {
|
const invalidateJdOpsQueries = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["jd-session-manager"] }),
|
queryClient.invalidateQueries({ queryKey: ["jd-session-manager"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["jd-live-session"] }),
|
queryClient.invalidateQueries({ queryKey: ["jd-live-session"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["jd-qr-login"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] })
|
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] })
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
@ -123,6 +250,7 @@ export function OpsSessionManagerPage() {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["tmall-session-manager"] }),
|
queryClient.invalidateQueries({ queryKey: ["tmall-session-manager"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["tmall-live-session"] }),
|
queryClient.invalidateQueries({ queryKey: ["tmall-live-session"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tmall-qr-login"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] })
|
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] })
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
@ -157,6 +285,18 @@ export function OpsSessionManagerPage() {
|
|||||||
mutationFn: clearJdManagedSession,
|
mutationFn: clearJdManagedSession,
|
||||||
onSuccess: invalidateJdOpsQueries
|
onSuccess: invalidateJdOpsQueries
|
||||||
});
|
});
|
||||||
|
const jdStartQrLoginMutation = useMutation({
|
||||||
|
mutationFn: startJdQrLogin,
|
||||||
|
onSuccess: invalidateJdOpsQueries
|
||||||
|
});
|
||||||
|
const jdCancelQrLoginMutation = useMutation({
|
||||||
|
mutationFn: cancelJdQrLogin,
|
||||||
|
onSuccess: invalidateJdOpsQueries
|
||||||
|
});
|
||||||
|
const jdResumeQrLoginMutation = useMutation({
|
||||||
|
mutationFn: resumeJdQrLoginManualRecovery,
|
||||||
|
onSuccess: invalidateJdOpsQueries
|
||||||
|
});
|
||||||
|
|
||||||
const tmallSaveConfigMutation = useMutation({
|
const tmallSaveConfigMutation = useMutation({
|
||||||
mutationFn: (payload: TmallSessionManagerConfigInput) =>
|
mutationFn: (payload: TmallSessionManagerConfigInput) =>
|
||||||
@ -185,11 +325,21 @@ export function OpsSessionManagerPage() {
|
|||||||
mutationFn: clearTmallManagedSession,
|
mutationFn: clearTmallManagedSession,
|
||||||
onSuccess: invalidateTmallOpsQueries
|
onSuccess: invalidateTmallOpsQueries
|
||||||
});
|
});
|
||||||
|
const tmallStartQrLoginMutation = useMutation({
|
||||||
|
mutationFn: startTmallQrLogin,
|
||||||
|
onSuccess: invalidateTmallOpsQueries
|
||||||
|
});
|
||||||
|
const tmallCancelQrLoginMutation = useMutation({
|
||||||
|
mutationFn: cancelTmallQrLogin,
|
||||||
|
onSuccess: invalidateTmallOpsQueries
|
||||||
|
});
|
||||||
|
|
||||||
const jdManager = jdManagerQuery.data?.manager;
|
const jdManager = jdManagerQuery.data?.manager;
|
||||||
const jdLiveSession = jdLiveSessionQuery.data?.session;
|
const jdLiveSession = jdLiveSessionQuery.data?.session;
|
||||||
|
const jdQrLogin = jdQrLoginQuery.data?.qrLogin;
|
||||||
const tmallManager = tmallManagerQuery.data?.manager;
|
const tmallManager = tmallManagerQuery.data?.manager;
|
||||||
const tmallLiveSession = tmallLiveSessionQuery.data?.session;
|
const tmallLiveSession = tmallLiveSessionQuery.data?.session;
|
||||||
|
const tmallQrLogin = tmallQrLoginQuery.data?.qrLogin;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!jdManager || jdConfigDirty) {
|
if (!jdManager || jdConfigDirty) {
|
||||||
@ -221,6 +371,22 @@ export function OpsSessionManagerPage() {
|
|||||||
});
|
});
|
||||||
}, [tmallConfigDirty, tmallManager]);
|
}, [tmallConfigDirty, tmallManager]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!jdQrLogin?.sessionImported || jdQrLogin.status !== "completed") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void invalidateJdOpsQueries();
|
||||||
|
}, [jdQrLogin?.completedAt]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tmallQrLogin?.sessionImported || tmallQrLogin.status !== "completed") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void invalidateTmallOpsQueries();
|
||||||
|
}, [tmallQrLogin?.completedAt]);
|
||||||
|
|
||||||
const switchPlatform = (platform: PlatformId) => {
|
const switchPlatform = (platform: PlatformId) => {
|
||||||
const next = new URLSearchParams(searchParams);
|
const next = new URLSearchParams(searchParams);
|
||||||
next.set("platform", platform);
|
next.set("platform", platform);
|
||||||
@ -323,6 +489,18 @@ export function OpsSessionManagerPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<QrLoginCard
|
||||||
|
cancelPending={jdCancelQrLoginMutation.isPending}
|
||||||
|
onCancel={() => jdCancelQrLoginMutation.mutate()}
|
||||||
|
onResumeManualRecovery={() => jdResumeQrLoginMutation.mutate()}
|
||||||
|
onStart={() => jdStartQrLoginMutation.mutate()}
|
||||||
|
platformHint="后端会启动受控浏览器,实时截图京东登录二维码;扫码成功后会自动抓取 Cookie、详情模板和评论模板并导入当前运维会话。"
|
||||||
|
platformLabel="京东"
|
||||||
|
qrLogin={jdQrLogin}
|
||||||
|
resumePending={jdResumeQrLoginMutation.isPending}
|
||||||
|
startPending={jdStartQrLoginMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="page-panel ops-panel">
|
<div className="page-panel ops-panel">
|
||||||
<p className="eyebrow">Automation</p>
|
<p className="eyebrow">Automation</p>
|
||||||
<strong>自动恢复配置</strong>
|
<strong>自动恢复配置</strong>
|
||||||
@ -604,6 +782,10 @@ export function OpsSessionManagerPage() {
|
|||||||
<span>Live Session</span>
|
<span>Live Session</span>
|
||||||
<strong>{jdLiveSession?.configured ? "已导入" : "未导入"}</strong>
|
<strong>{jdLiveSession?.configured ? "已导入" : "未导入"}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="session-details__row">
|
||||||
|
<span>QR Login</span>
|
||||||
|
<strong>{jdQrLogin?.status ?? "idle"}</strong>
|
||||||
|
</div>
|
||||||
<div className="session-details__row">
|
<div className="session-details__row">
|
||||||
<span>Detail Template</span>
|
<span>Detail Template</span>
|
||||||
<strong>
|
<strong>
|
||||||
@ -656,6 +838,24 @@ export function OpsSessionManagerPage() {
|
|||||||
{jdClearSessionMutation.error.message}
|
{jdClearSessionMutation.error.message}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{jdQrLoginQuery.error instanceof Error ? (
|
||||||
|
<p className="inline-note inline-note--subtle">{jdQrLoginQuery.error.message}</p>
|
||||||
|
) : null}
|
||||||
|
{jdStartQrLoginMutation.error instanceof Error ? (
|
||||||
|
<p className="inline-note inline-note--subtle">
|
||||||
|
{jdStartQrLoginMutation.error.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{jdCancelQrLoginMutation.error instanceof Error ? (
|
||||||
|
<p className="inline-note inline-note--subtle">
|
||||||
|
{jdCancelQrLoginMutation.error.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{jdResumeQrLoginMutation.error instanceof Error ? (
|
||||||
|
<p className="inline-note inline-note--subtle">
|
||||||
|
{jdResumeQrLoginMutation.error.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -670,6 +870,16 @@ export function OpsSessionManagerPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<QrLoginCard
|
||||||
|
cancelPending={tmallCancelQrLoginMutation.isPending}
|
||||||
|
onCancel={() => tmallCancelQrLoginMutation.mutate()}
|
||||||
|
onStart={() => tmallStartQrLoginMutation.mutate()}
|
||||||
|
platformHint="后端会启动受控浏览器,实时截图淘宝/天猫登录二维码;扫码成功后会自动导入 Cookie,并优先抓取或回退生成评论模板。"
|
||||||
|
platformLabel="天猫"
|
||||||
|
qrLogin={tmallQrLogin}
|
||||||
|
startPending={tmallStartQrLoginMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="page-panel ops-panel">
|
<div className="page-panel ops-panel">
|
||||||
<p className="eyebrow">Automation</p>
|
<p className="eyebrow">Automation</p>
|
||||||
<strong>自动巡检配置</strong>
|
<strong>自动巡检配置</strong>
|
||||||
@ -874,6 +1084,10 @@ export function OpsSessionManagerPage() {
|
|||||||
<span>Live Session</span>
|
<span>Live Session</span>
|
||||||
<strong>{tmallLiveSession?.configured ? "已导入" : "未导入"}</strong>
|
<strong>{tmallLiveSession?.configured ? "已导入" : "未导入"}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="session-details__row">
|
||||||
|
<span>QR Login</span>
|
||||||
|
<strong>{tmallQrLogin?.status ?? "idle"}</strong>
|
||||||
|
</div>
|
||||||
<div className="session-details__row">
|
<div className="session-details__row">
|
||||||
<span>Detail Template</span>
|
<span>Detail Template</span>
|
||||||
<strong>
|
<strong>
|
||||||
@ -927,6 +1141,19 @@ export function OpsSessionManagerPage() {
|
|||||||
{tmallClearSessionMutation.error.message}
|
{tmallClearSessionMutation.error.message}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{tmallQrLoginQuery.error instanceof Error ? (
|
||||||
|
<p className="inline-note inline-note--subtle">{tmallQrLoginQuery.error.message}</p>
|
||||||
|
) : null}
|
||||||
|
{tmallStartQrLoginMutation.error instanceof Error ? (
|
||||||
|
<p className="inline-note inline-note--subtle">
|
||||||
|
{tmallStartQrLoginMutation.error.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{tmallCancelQrLoginMutation.error instanceof Error ? (
|
||||||
|
<p className="inline-note inline-note--subtle">
|
||||||
|
{tmallCancelQrLoginMutation.error.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -37,6 +37,7 @@ a {
|
|||||||
.app-shell {
|
.app-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 280px minmax(0, 1fr);
|
grid-template-columns: 280px minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,6 +45,12 @@ a {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
align-self: start;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
padding: 32px 24px;
|
padding: 32px 24px;
|
||||||
border-right: 1px solid rgba(31, 42, 48, 0.08);
|
border-right: 1px solid rgba(31, 42, 48, 0.08);
|
||||||
background: rgba(251, 248, 242, 0.72);
|
background: rgba(251, 248, 242, 0.72);
|
||||||
@ -75,6 +82,7 @@ a {
|
|||||||
.main-content {
|
.main-content {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
|
min-height: 100vh;
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -669,6 +677,39 @@ a {
|
|||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(20, 108, 110, 0.08);
|
background: rgba(20, 108, 110, 0.08);
|
||||||
|
.qr-login-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-login-card__preview {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 248px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(20, 108, 110, 0.08) 0%, rgba(255, 255, 255, 0.88) 100%);
|
||||||
|
border: 1px solid rgba(20, 108, 110, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-login-card__preview img {
|
||||||
|
display: block;
|
||||||
|
width: min(100%, 240px);
|
||||||
|
max-height: 240px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-login-card__empty {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
color: var(--brand-primary);
|
color: var(--brand-primary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@ -711,3 +752,7 @@ a {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.qr-login-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
15
package-lock.json
generated
15
package-lock.json
generated
@ -27,7 +27,8 @@
|
|||||||
"@cross-ai/domain": "file:../../packages/domain",
|
"@cross-ai/domain": "file:../../packages/domain",
|
||||||
"@cross-ai/report-schema": "file:../../packages/report-schema",
|
"@cross-ai/report-schema": "file:../../packages/report-schema",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"fastify": "^5.8.4"
|
"fastify": "^5.8.4",
|
||||||
|
"playwright-core": "^1.59.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"apps/web": {
|
"apps/web": {
|
||||||
@ -3404,6 +3405,18 @@
|
|||||||
"pathe": "^2.0.1"
|
"pathe": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.8",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user