diff --git a/apps/api/package.json b/apps/api/package.json index c5584c6..3d52b69 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,6 +14,7 @@ "@cross-ai/domain": "file:../../packages/domain", "@cross-ai/report-schema": "file:../../packages/report-schema", "@fastify/cors": "^11.2.0", - "fastify": "^5.8.4" + "fastify": "^5.8.4", + "playwright-core": "^1.59.1" } } diff --git a/apps/api/src/ops/qr-login.test.ts b/apps/api/src/ops/qr-login.test.ts new file mode 100644 index 0000000..5724058 --- /dev/null +++ b/apps/api/src/ops/qr-login.test.ts @@ -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; + page: Page; +}; + +function createSessionManagerStub() { + const importManualSession = vi.fn< + (input: JdLiveSessionInput, source?: string) => Promise + >(async () => ({ status: "healthy" } as JdSessionManagerState)); + const runHealthCheck = vi.fn<(trigger?: string) => Promise>( + 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) { + Object.assign( + (this as unknown as { latestCapturedSession: Record }).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((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; + + 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); + }); +}); diff --git a/apps/api/src/ops/qr-login.ts b/apps/api/src/ops/qr-login.ts new file mode 100644 index 0000000..cdbceb4 --- /dev/null +++ b/apps/api/src/ops/qr-login.ts @@ -0,0 +1,1243 @@ +import { Buffer } from "node:buffer"; +import { existsSync } from "node:fs"; + +import type { Browser, BrowserContext, Page } from "playwright-core"; +import { chromium } from "playwright-core"; + +import { + parseJdDetailApiResponse, + parseJdReviewsApiResponse, + parseJdSearchApiResponse, + parseJdSearchHtml +} from "../platforms/jd/parsers"; +import type { + JdBrowserPreviewProvider, + JdDetailPreviewResult, + JdLiveSessionInput, + JdReviewsPaginationSummary, + JdReviewsPreviewOptions, + JdReviewsPreviewResult, + JdSearchMode, + JdSearchPreviewResult, + JdSessionManager +} from "../platforms/jd/types"; +import type { + TmallLiveSessionInput, + TmallSessionManager +} from "../platforms/tmall/types"; + +const DEFAULT_JD_CAPTURE_SKU = "100068388533"; +const DEFAULT_TMALL_CAPTURE_ITEM_ID = "934454505228"; +const DEFAULT_WAIT_TIMEOUT_MS = 3 * 60 * 1000; +const SCREENSHOT_POLL_INTERVAL_MS = 1500; +const DEFAULT_OPS_BROWSER_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"; +const JD_LOGIN_URL_PATTERN = /passport\.jd\.com/i; +const JD_SEARCH_URL_PATTERN = /search\.jd\.com\/Search/i; +const JD_PRODUCT_URL_PATTERN = /item\.jd\.com\/\d+\.html/i; +const TMALL_LOGIN_URL_PATTERN = /login\.taobao\.com|havanaone\/login/i; + +export type OpsQrLoginStatus = + | "idle" + | "launching" + | "waiting_for_scan" + | "capturing_session" + | "completed" + | "failed" + | "cancelled"; + +export interface OpsQrLoginState { + platform: "jd" | "tmall"; + status: OpsQrLoginStatus; + note: string; + startedAt?: string | undefined; + updatedAt?: string | undefined; + completedAt?: string | undefined; + currentUrl?: string | undefined; + qrImageDataUrl?: string | undefined; + errorMessage?: string | undefined; + targetId?: string | undefined; + sessionImported: boolean; +} + +export interface OpsQrLoginController { + getState(): OpsQrLoginState; + start(): Promise; + resumeManualRecovery(): Promise; + cancel(reason?: string): Promise; + shutdown(): Promise; +} + +type BrowserResources = { + browser: Browser; + context: BrowserContext; + page: Page; +}; + +type BrowserStorageState = Awaited>; + +type PlatformSessionPayload = JdLiveSessionInput | TmallLiveSessionInput; + +type TmallQrLoginOptions = { + resolveTargetItemId?: () => string | null | undefined; + timeoutMs?: number; +}; + +type JdQrLoginOptions = { + resolveTargetSkuId?: () => string | null | undefined; + resolveSearchQuery?: () => string | null | undefined; + timeoutMs?: number; +}; + +function nowIso(): string { + return new Date().toISOString(); +} + +function normalizeOptionalString(value: string | null | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + +function resolveBrowserExecutablePath(): string { + const explicit = normalizeOptionalString(process.env.OPS_QR_BROWSER_PATH); + if (explicit) { + return explicit; + } + + const candidates = [ + "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", + "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe", + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe" + ]; + + const found = candidates.find((candidate) => { + return existsSync(candidate); + }); + + if (found) { + return found; + } + + return candidates[0] ?? "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"; +} + +function resolveHeadlessMode(): boolean { + const configured = normalizeOptionalString(process.env.OPS_QR_BROWSER_HEADLESS)?.toLowerCase(); + if (!configured) { + return process.env.NODE_ENV === "production"; + } + + return configured !== "0" && configured !== "false" && configured !== "off" && configured !== "no"; +} + +function resolveBrowserUserAgent(): string { + return normalizeOptionalString(process.env.OPS_QR_BROWSER_USER_AGENT) + ?? DEFAULT_OPS_BROWSER_USER_AGENT; +} + +async function createBrowserResources(): Promise { + return createBrowserResourcesWithOptions(); +} + +async function createBrowserResourcesWithOptions(options?: { + storageState?: BrowserStorageState | undefined; + userAgent?: string | undefined; +}): Promise { + const browser = await chromium.launch({ + headless: resolveHeadlessMode(), + executablePath: resolveBrowserExecutablePath(), + args: ["--disable-blink-features=AutomationControlled"] + }); + const context = await browser.newContext({ + locale: "zh-CN", + userAgent: options?.userAgent ?? resolveBrowserUserAgent(), + viewport: { + width: 1440, + height: 1180 + }, + ...(options?.storageState ? { storageState: options.storageState } : {}) + }); + await context.addInitScript(() => { + Object.defineProperty(window.navigator, "webdriver", { + configurable: true, + get: () => undefined + }); + }); + const page = await context.newPage(); + + return { + browser, + context, + page + }; +} + +async function closeBrowserResources(resources: Partial | null): Promise { + if (!resources) { + return; + } + + await resources.page?.close().catch(() => undefined); + await resources.context?.close().catch(() => undefined); + await resources.browser?.close().catch(() => undefined); +} + +function toCookieHeader( + cookies: Array<{ + name: string; + value: string; + domain: string; + }>, + platform: "jd" | "tmall" +): string { + const allowCookie = (domain: string) => { + const normalizedDomain = domain.toLowerCase(); + if (platform === "jd") { + return normalizedDomain.includes("jd.com"); + } + + return normalizedDomain.includes("tmall.com") || normalizedDomain.includes("taobao.com"); + }; + + return cookies + .filter((cookie) => allowCookie(cookie.domain)) + .map((cookie) => `${cookie.name}=${cookie.value}`) + .join("; "); +} + +function sanitizeCapturedRequestHeaders( + headers: Record +): Record | undefined { + const isValidReplayHeaderName = (key: string) => /^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(key); + const entries = Object.entries(headers) + .map(([key, value]) => [key.trim().toLowerCase(), value.trim()] as const) + .filter(([key, value]) => { + return ( + key.length > 0 && + value.length > 0 && + !key.startsWith(":") && + isValidReplayHeaderName(key) && + key !== "cookie" && + key !== "referer" && + key !== "host" && + key !== "content-length" && + key !== "connection" && + key !== "accept-encoding" + ); + }); + + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +function buildTmallReviewsTemplateUrl(itemId: string): string { + const template = new URL("https://h5api.m.tmall.com/h5/mtop.taobao.rate.detaillist.get/6.0/"); + template.searchParams.set("api", "mtop.taobao.rate.detaillist.get"); + template.searchParams.set("v", "6.0"); + template.searchParams.set( + "data", + JSON.stringify({ + auctionNumId: itemId, + pageNo: 1, + pageSize: 20 + }) + ); + return template.toString(); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function normalizeJdReviewsPreviewOptions( + options?: number | JdReviewsPreviewOptions +): { + requestedPage: number; + requestedCommentCount: number; + maxPages: number; +} { + if (typeof options === "number") { + return { + requestedPage: 1, + requestedCommentCount: options, + maxPages: 1 + }; + } + + return { + requestedPage: options?.page ?? 1, + requestedCommentCount: options?.commentCount ?? 5, + maxPages: options?.maxPages ?? 1 + }; +} + +function mergeBrowserReviewPages( + skuId: string, + pages: JdReviewsPreviewResult["reviews"][] +): JdReviewsPreviewResult["reviews"] { + const firstPage = pages[0]; + if (!firstPage) { + return { + skuId, + total: null, + goodRate: null, + pictureCount: null, + tags: [], + comments: [] + }; + } + + const commentsById = new Map(firstPage.comments.map((comment) => [comment.id, comment])); + for (const page of pages.slice(1)) { + for (const comment of page.comments) { + if (!commentsById.has(comment.id)) { + commentsById.set(comment.id, comment); + } + } + } + + return { + ...firstPage, + comments: Array.from(commentsById.values()) + }; +} + +export async function takeLocatorScreenshotDataUrl( + page: Page, + selectors: string[] +): Promise { + for (const selector of selectors) { + const locator = page.locator(selector).first(); + + try { + if ((await locator.count()) === 0) { + continue; + } + + if (!(await locator.isVisible())) { + continue; + } + + // QR markup can be replaced mid-capture when the page redirects after scan. + const screenshot = await locator.screenshot({ + type: "png" + }); + return `data:image/png;base64,${Buffer.from(screenshot).toString("base64")}`; + } catch { + continue; + } + } + + return undefined; +} + +class BaseOpsQrLoginService implements OpsQrLoginController { + private state: OpsQrLoginState; + private runPromise: Promise | null = null; + private cancelled = false; + private resources: BrowserResources | null = null; + private preserveBrowserAfterRun = false; + + constructor(protected readonly platform: "jd" | "tmall") { + this.state = { + platform, + status: "idle", + note: "尚未启动扫码登录。", + sessionImported: false + }; + } + + getState(): OpsQrLoginState { + return { + ...this.state + }; + } + + async start(): Promise { + await this.cancel("restart"); + this.cancelled = false; + this.preserveBrowserAfterRun = false; + this.setState({ + status: "launching", + note: "正在启动后台浏览器并加载登录二维码。", + startedAt: nowIso(), + updatedAt: nowIso(), + completedAt: undefined, + currentUrl: undefined, + qrImageDataUrl: undefined, + errorMessage: undefined, + sessionImported: false, + targetId: undefined + }); + + this.runPromise = this.run().finally(() => { + this.runPromise = null; + }); + void this.runPromise; + + return this.getState(); + } + + async resumeManualRecovery(): Promise { + throw new Error("Manual recovery resume is not supported for this platform."); + } + + async cancel(reason = "manual"): Promise { + this.cancelled = true; + this.preserveBrowserAfterRun = false; + await closeBrowserResources(this.resources); + this.resources = null; + + if (this.state.status !== "idle" && this.state.status !== "completed") { + this.setState({ + status: "cancelled", + note: `扫码登录已取消:${reason}。`, + updatedAt: nowIso(), + completedAt: nowIso(), + currentUrl: undefined, + qrImageDataUrl: undefined, + errorMessage: undefined + }); + } + + return this.getState(); + } + + async shutdown(): Promise { + await this.cancel("shutdown"); + } + + protected setState(patch: Partial): void { + this.state = { + ...this.state, + ...patch, + updatedAt: patch.updatedAt ?? nowIso() + }; + } + + protected ensureNotCancelled(): void { + if (this.cancelled) { + throw new Error("QR login was cancelled."); + } + } + + protected getResources(): BrowserResources | null { + return this.resources; + } + + protected setResources(resources: BrowserResources | null): void { + this.resources = resources; + } + + protected preserveBrowserForManualRecovery(): void { + this.preserveBrowserAfterRun = true; + } + + protected getLatestOpenPage(): Page | null { + const pages = this.resources?.context + .pages() + .filter((page) => !page.isClosed()) + ?? []; + return pages.at(-1) ?? this.resources?.page ?? null; + } + + protected async waitForLoginCompletion( + getPage: () => Page, + screenshotSelectors: string[], + isCompleted: () => Promise, + timeoutMs: number + ): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + this.ensureNotCancelled(); + const page = getPage(); + const qrImageDataUrl = await takeLocatorScreenshotDataUrl(page, screenshotSelectors).catch( + () => undefined + ); + this.setState({ + status: "waiting_for_scan", + note: "二维码已生成,请用手机扫码并在 App 内确认登录。", + currentUrl: page.url(), + ...(qrImageDataUrl ? { qrImageDataUrl } : {}) + }); + + if (await isCompleted()) { + return; + } + + await sleep(SCREENSHOT_POLL_INTERVAL_MS); + } + + throw new Error("等待扫码登录超时,请刷新二维码后重试。"); + } + + private async run(): Promise { + try { + const resources = await createBrowserResources(); + this.setResources(resources); + await this.runWithBrowser(resources); + } catch (error) { + if (this.cancelled) { + return; + } + + this.setState({ + status: "failed", + note: error instanceof Error ? error.message : "扫码登录失败。", + errorMessage: error instanceof Error ? error.message : "扫码登录失败。", + completedAt: nowIso(), + currentUrl: this.resources?.page.url() + }); + } finally { + if (!this.preserveBrowserAfterRun) { + await closeBrowserResources(this.resources); + this.resources = null; + } + } + } + + protected async runWithBrowser(_resources: BrowserResources): Promise { + throw new Error("Not implemented."); + } +} + +export class JdOpsQrLoginService + extends BaseOpsQrLoginService + implements JdBrowserPreviewProvider +{ + private readonly timeoutMs: number; + private latestStorageState: BrowserStorageState | null = null; + private latestCapturedSession: { + targetSkuId?: string; + targetSearchQuery?: string; + targetProductUrl?: string; + targetSearchUrl?: string; + searchApiTemplateUrl?: string; + detailTemplateUrl?: string; + reviewsTemplateUrl?: string; + searchCapturedAt?: number | undefined; + detailCapturedAt?: number | undefined; + reviewsCapturedAt?: number | undefined; + searchRequestHeaders?: Record | undefined; + detailRequestHeaders?: Record | undefined; + reviewsRequestHeaders?: Record | undefined; + } = {}; + + constructor( + private readonly sessionManager: JdSessionManager, + private readonly options: JdQrLoginOptions = {} + ) { + super("jd"); + this.timeoutMs = options.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS; + } + + private resetCapturedSession(): void { + this.latestStorageState = null; + this.latestCapturedSession = {}; + } + + private captureSessionRequest( + url: string, + rawHeaders: Record, + currentPageUrl: string + ): void { + const headers = sanitizeCapturedRequestHeaders(rawHeaders); + const referer = normalizeOptionalString(rawHeaders.referer); + const normalizedCurrentPageUrl = normalizeOptionalString(currentPageUrl); + + if (url.includes("functionId=pc_search_searchWare")) { + const nextSearchUrl = + referer ?? + (normalizedCurrentPageUrl && JD_SEARCH_URL_PATTERN.test(normalizedCurrentPageUrl) + ? normalizedCurrentPageUrl + : this.latestCapturedSession.targetSearchUrl); + this.latestCapturedSession.searchApiTemplateUrl = url; + this.latestCapturedSession.searchRequestHeaders = headers; + this.latestCapturedSession.searchCapturedAt = Date.now(); + if (nextSearchUrl) { + this.latestCapturedSession.targetSearchUrl = nextSearchUrl; + } + } + + if (url.includes("functionId=pc_detailpage_wareBusiness")) { + const nextProductUrl = + referer ?? + (normalizedCurrentPageUrl && JD_PRODUCT_URL_PATTERN.test(normalizedCurrentPageUrl) + ? normalizedCurrentPageUrl + : this.latestCapturedSession.targetProductUrl); + this.latestCapturedSession.detailTemplateUrl = url; + this.latestCapturedSession.detailRequestHeaders = headers; + this.latestCapturedSession.detailCapturedAt = Date.now(); + if (nextProductUrl) { + this.latestCapturedSession.targetProductUrl = nextProductUrl; + } + } + + if (url.includes("functionId=getLegoWareDetailComment")) { + const nextProductUrl = + referer ?? + (normalizedCurrentPageUrl && JD_PRODUCT_URL_PATTERN.test(normalizedCurrentPageUrl) + ? normalizedCurrentPageUrl + : this.latestCapturedSession.targetProductUrl); + this.latestCapturedSession.reviewsTemplateUrl = url; + this.latestCapturedSession.reviewsRequestHeaders = headers; + this.latestCapturedSession.reviewsCapturedAt = Date.now(); + if (nextProductUrl) { + this.latestCapturedSession.targetProductUrl = nextProductUrl; + } + } + } + + private updateCapturedPageUrls(page: Page): void { + const currentUrl = normalizeOptionalString(page.url()); + if (!currentUrl) { + return; + } + + if (JD_SEARCH_URL_PATTERN.test(currentUrl)) { + this.latestCapturedSession.targetSearchUrl = currentUrl; + return; + } + + if (JD_PRODUCT_URL_PATTERN.test(currentUrl)) { + this.latestCapturedSession.targetProductUrl = currentUrl; + } + } + + private async refreshSearchCapture(page: Page, fallbackSearchUrl: string): Promise { + const previousCaptureAt = this.latestCapturedSession.searchCapturedAt ?? 0; + const currentUrl = page.url(); + const navigationTarget = JD_SEARCH_URL_PATTERN.test(currentUrl) + ? currentUrl + : fallbackSearchUrl; + const requestPromise = page + .waitForRequest((request) => request.url().includes("functionId=pc_search_searchWare"), { + timeout: 20_000 + }) + .then(async (request) => { + const rawHeaders = await request.allHeaders().catch(() => ({})); + this.captureSessionRequest(request.url(), rawHeaders, page.url()); + }) + .catch(() => undefined); + + if (JD_SEARCH_URL_PATTERN.test(currentUrl)) { + await page + .reload({ + waitUntil: "domcontentloaded", + timeout: 60_000 + }) + .catch(async () => { + await page + .goto(navigationTarget, { + waitUntil: "domcontentloaded", + timeout: 60_000 + }) + .catch(() => undefined); + }); + } else { + await page + .goto(navigationTarget, { + waitUntil: "domcontentloaded", + timeout: 60_000 + }) + .catch(() => undefined); + } + await page.waitForLoadState("networkidle").catch(() => undefined); + await requestPromise; + this.updateCapturedPageUrls(page); + + if ((this.latestCapturedSession.searchCapturedAt ?? 0) <= previousCaptureAt) { + throw new Error( + "JD recovery browser did not emit a fresh search request. Refresh the search results in the browser and try resume again." + ); + } + + await page.waitForTimeout(1_000); + } + + private async snapshotStorageState(context: BrowserContext): Promise { + this.latestStorageState = await context.storageState().catch(() => null); + } + + private buildBrowserSearchUrl(query: string): string { + const searchUrl = this.latestCapturedSession.targetSearchUrl + ? new URL(this.latestCapturedSession.targetSearchUrl) + : new URL("https://search.jd.com/Search"); + 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(); + } + + private async navigateForBrowserPreview(page: Page, url: string): Promise { + await page + .goto(url, { + waitUntil: "commit", + timeout: 15_000 + }) + .catch(() => undefined); + await page + .waitForLoadState("domcontentloaded", { + timeout: 8_000 + }) + .catch(() => undefined); + await page.waitForTimeout(1_500); + } + + private async withBrowserPreviewPage(runner: (page: Page) => Promise): Promise { + const liveContext = this.getResources()?.context; + const livePage = this.getLatestOpenPage(); + if (liveContext && livePage) { + return runner(livePage); + } + + if (!this.latestStorageState) { + throw new Error("JD browser preview session is unavailable. Start QR login again."); + } + + const resources = await createBrowserResourcesWithOptions({ + storageState: this.latestStorageState + }); + try { + const result = await runner(resources.page); + await this.snapshotStorageState(resources.context); + return result; + } finally { + await closeBrowserResources(resources); + } + } + + private async captureBrowserResponse( + page: Page, + options: { + matcher: (url: string) => boolean; + action: () => Promise; + timeoutMs?: number; + } + ): Promise<{ status: number; finalUrl: string; text: string } | null> { + const responsePromise = page + .waitForResponse((response) => options.matcher(response.url()), { + timeout: options.timeoutMs ?? 20_000 + }) + .then(async (response) => ({ + status: response.status(), + finalUrl: response.url(), + text: await response.text().catch(() => "") + })) + .catch(() => null); + + await options.action(); + return responsePromise; + } + + async previewSearch(query: string, mode?: JdSearchMode): Promise { + const normalizedQuery = query.trim(); + if (!normalizedQuery) { + throw new Error("query is required for JD browser preview search."); + } + + return this.withBrowserPreviewPage(async (page) => { + const resolvedMode: JdSearchMode = + mode ?? (this.latestCapturedSession.searchApiTemplateUrl ? "api" : "html"); + const searchUrl = this.buildBrowserSearchUrl(normalizedQuery); + const response = await this.captureBrowserResponse(page, { + matcher: (url) => url.includes("functionId=pc_search_searchWare"), + action: async () => { + await this.navigateForBrowserPreview(page, searchUrl); + await page.waitForLoadState("networkidle").catch(() => undefined); + await page.waitForTimeout(2_000); + } + }); + this.updateCapturedPageUrls(page); + + if (resolvedMode === "api" && response && response.status >= 200 && response.status < 400) { + const candidates = parseJdSearchApiResponse(normalizedQuery, { text: response.text }); + return { + query: normalizedQuery, + source: "api", + candidateCount: candidates.length, + candidates + }; + } + + const html = await page.content(); + const candidates = parseJdSearchHtml(normalizedQuery, html); + return { + query: normalizedQuery, + source: "html", + candidateCount: candidates.length, + candidates + }; + }); + } + + async previewDetail(skuId: string): Promise { + const normalizedSkuId = skuId.trim(); + if (!normalizedSkuId) { + throw new Error("skuId is required for JD browser preview detail."); + } + + return this.withBrowserPreviewPage(async (page) => { + const response = await this.captureBrowserResponse(page, { + matcher: (url) => url.includes("functionId=pc_detailpage_wareBusiness"), + action: async () => { + await this.navigateForBrowserPreview(page, `https://item.jd.com/${normalizedSkuId}.html`); + await page.waitForLoadState("networkidle").catch(() => undefined); + await page.waitForTimeout(2_000); + } + }); + this.updateCapturedPageUrls(page); + + if (!response || response.status >= 400) { + throw new Error("JD browser preview could not capture a valid detail response."); + } + + return { + skuId: normalizedSkuId, + source: "api", + detail: parseJdDetailApiResponse(normalizedSkuId, { text: response.text }) + }; + }); + } + + async previewReviews( + skuId: string, + options?: number | JdReviewsPreviewOptions + ): Promise { + const normalizedSkuId = skuId.trim(); + if (!normalizedSkuId) { + throw new Error("skuId is required for JD browser preview reviews."); + } + + return this.withBrowserPreviewPage(async (page) => { + const requested = normalizeJdReviewsPreviewOptions(options); + const response = await this.captureBrowserResponse(page, { + matcher: (url) => url.includes("functionId=getLegoWareDetailComment"), + timeoutMs: 25_000, + action: async () => { + await this.navigateForBrowserPreview(page, `https://item.jd.com/${normalizedSkuId}.html`); + await page.waitForLoadState("networkidle").catch(() => undefined); + await page.waitForTimeout(2_000); + this.updateCapturedPageUrls(page); + await page.mouse.wheel(0, 2600).catch(() => undefined); + await page.waitForTimeout(2_000); + await page.getByText(/鍟嗗搧璇勪环|鍏ㄩ儴璇勪环|璇勪环/).first().click().catch(() => undefined); + await page.waitForTimeout(3_000); + } + }); + + if (!response || response.status >= 400) { + throw new Error("JD browser preview could not capture a valid reviews response."); + } + + const parsedPage = parseJdReviewsApiResponse(normalizedSkuId, { text: response.text }); + const mergedReviews = mergeBrowserReviewPages(normalizedSkuId, [parsedPage]); + const pagination: JdReviewsPaginationSummary = { + requestedPage: requested.requestedPage, + requestedCommentCount: requested.requestedCommentCount, + maxPages: requested.maxPages, + pagesFetched: 1 + }; + + return { + skuId: normalizedSkuId, + source: "api", + pagination, + reviews: { + ...mergedReviews, + comments: mergedReviews.comments.slice(0, requested.requestedCommentCount) + } + }; + }); + } + + private async buildImportedSessionFromOpenBrowser(): Promise { + const resources = this.getResources(); + const activePage = this.getLatestOpenPage(); + if (!resources || !activePage) { + throw new Error("JD recovery browser is not available. Start QR login again."); + } + + const userAgent = + (await activePage.evaluate(() => window.navigator.userAgent).catch(() => "")) || undefined; + const cookieHeader = toCookieHeader(await resources.context.cookies(), "jd"); + const currentUrl = normalizeOptionalString(activePage.url()); + const currentSearchUrl = + currentUrl && JD_SEARCH_URL_PATTERN.test(currentUrl) ? currentUrl : undefined; + const currentProductUrl = + currentUrl && JD_PRODUCT_URL_PATTERN.test(currentUrl) ? currentUrl : undefined; + const detailTemplateUrl = this.latestCapturedSession.detailTemplateUrl; + const reviewsTemplateUrl = this.latestCapturedSession.reviewsTemplateUrl; + + if (!detailTemplateUrl || !reviewsTemplateUrl) { + throw new Error( + "JD recovery browser is missing detail/reviews templates. Start QR login again." + ); + } + + return { + cookieHeader, + ...(userAgent ? { userAgent } : {}), + ...(this.latestCapturedSession.searchApiTemplateUrl + ? { searchApiTemplateUrl: this.latestCapturedSession.searchApiTemplateUrl } + : {}), + detailTemplateUrl, + reviewsTemplateUrl, + ...(currentSearchUrl ?? this.latestCapturedSession.targetSearchUrl + ? { searchReferer: currentSearchUrl ?? this.latestCapturedSession.targetSearchUrl } + : {}), + ...(currentProductUrl ?? this.latestCapturedSession.targetProductUrl + ? { detailReferer: currentProductUrl ?? this.latestCapturedSession.targetProductUrl } + : {}), + ...(this.latestCapturedSession.searchRequestHeaders + ? { searchRequestHeaders: this.latestCapturedSession.searchRequestHeaders } + : {}), + ...(this.latestCapturedSession.detailRequestHeaders + ? { detailRequestHeaders: this.latestCapturedSession.detailRequestHeaders } + : {}), + ...(this.latestCapturedSession.reviewsRequestHeaders + ? { reviewsRequestHeaders: this.latestCapturedSession.reviewsRequestHeaders } + : {}) + }; + } + + override async resumeManualRecovery(): Promise { + const resources = this.getResources(); + const activePage = this.getLatestOpenPage(); + if (!resources || !activePage) { + throw new Error("JD recovery browser is not available. Start QR login again."); + } + + const targetSearchUrl = + (JD_SEARCH_URL_PATTERN.test(activePage.url()) ? activePage.url() : undefined) ?? + this.latestCapturedSession.targetSearchUrl ?? + `https://search.jd.com/Search?keyword=${encodeURIComponent( + this.latestCapturedSession.targetSearchQuery ?? "iPhone 15" + )}`; + + await this.refreshSearchCapture(activePage, targetSearchUrl); + await this.snapshotStorageState(resources.context); + + const payload = await this.buildImportedSessionFromOpenBrowser(); + await this.sessionManager.importManualSession(payload, "ops-qr-login-manual-recovery"); + const healthCheck = await this.sessionManager.runHealthCheck("ops-qr-login-manual-recovery"); + + if ( + healthCheck.state.status === "manual_action_required" && + healthCheck.state.lastFailureCode === "RISK_BLOCKED" + ) { + this.preserveBrowserForManualRecovery(); + this.setState({ + status: "failed", + note: + "已重新导入当前浏览器会话,但京东仍要求人工完成风控验证。请继续在浏览器中完成验证后,再点“人工恢复后重新导入”。", + currentUrl: activePage.url(), + completedAt: nowIso(), + sessionImported: true, + targetId: this.latestCapturedSession.targetSkuId, + errorMessage: healthCheck.state.lastFailureMessage + }); + return this.getState(); + } + + const currentUrl = activePage.url(); + await closeBrowserResources(resources); + this.setResources(null); + this.setState({ + status: "completed", + note: "京东人工恢复完成,已重新导入风控后的浏览器会话。", + currentUrl, + completedAt: nowIso(), + sessionImported: true, + targetId: this.latestCapturedSession.targetSkuId, + errorMessage: undefined, + qrImageDataUrl: undefined + }); + return this.getState(); + } + + protected override async runWithBrowser(resources: BrowserResources): Promise { + this.resetCapturedSession(); + const targetSkuId = + normalizeOptionalString(this.options.resolveTargetSkuId?.()) ?? DEFAULT_JD_CAPTURE_SKU; + const targetSearchQuery = + normalizeOptionalString(this.options.resolveSearchQuery?.()) ?? "iPhone 15"; + const targetProductUrl = `https://item.jd.com/${targetSkuId}.html`; + const targetSearchUrl = `https://search.jd.com/Search?keyword=${encodeURIComponent(targetSearchQuery)}`; + const loginUrl = `https://passport.jd.com/new/login.aspx?ReturnUrl=${encodeURIComponent(targetProductUrl)}`; + this.latestCapturedSession = { + targetSkuId, + targetSearchQuery, + targetProductUrl, + targetSearchUrl + }; + let activePage = resources.page; + + resources.context.on("page", (nextPage) => { + activePage = nextPage; + }); + resources.context.on("request", (request) => { + const url = request.url(); + const captureHeaders = async () => { + const headers = await request.allHeaders().catch(() => ({})); + this.captureSessionRequest(url, headers, activePage.url()); + }; + + void captureHeaders(); + }); + + await activePage.goto(loginUrl, { + waitUntil: "domcontentloaded", + timeout: 60_000 + }); + await activePage.waitForTimeout(3_000); + + const qrSelectors = [".qrcode-login .qrcode-img", ".qrcode-img", "#J-qrcode"]; + const qrImageDataUrl = await takeLocatorScreenshotDataUrl(activePage, qrSelectors); + this.setState({ + status: "waiting_for_scan", + note: "京东二维码已加载,请用京东 App 扫码登录。", + currentUrl: activePage.url(), + targetId: targetSkuId, + ...(qrImageDataUrl ? { qrImageDataUrl } : {}) + }); + + await this.waitForLoginCompletion( + () => activePage, + qrSelectors, + async () => { + const cookies = await resources.context.cookies(); + const names = new Set(cookies.map((cookie) => cookie.name)); + const currentUrl = activePage.url(); + if (!JD_LOGIN_URL_PATTERN.test(currentUrl) && currentUrl.includes("jd.com")) { + return true; + } + + return names.has("thor") || names.has("pin") || names.has("pt_key"); + }, + this.timeoutMs + ); + + this.setState({ + status: "capturing_session", + note: "京东扫码成功,正在回放商品页并提取模板。", + currentUrl: activePage.url(), + targetId: targetSkuId + }); + + await activePage.goto(targetSearchUrl, { + waitUntil: "domcontentloaded", + timeout: 60_000 + }); + await activePage.waitForLoadState("networkidle").catch(() => undefined); + await activePage.waitForTimeout(4_000); + this.updateCapturedPageUrls(activePage); + + const searchCaptureDeadline = Date.now() + 12_000; + while (Date.now() < searchCaptureDeadline) { + this.ensureNotCancelled(); + if (this.latestCapturedSession.searchApiTemplateUrl) { + break; + } + await activePage.waitForTimeout(1_000); + } + + await activePage.goto(targetProductUrl, { + waitUntil: "domcontentloaded", + timeout: 60_000 + }); + await activePage.waitForLoadState("networkidle").catch(() => undefined); + await activePage.waitForTimeout(4_000); + this.updateCapturedPageUrls(activePage); + await activePage.mouse.wheel(0, 2600).catch(() => undefined); + await activePage.waitForTimeout(3_000); + await activePage.getByText(/商品评价|全部评价|评价/).first().click().catch(() => undefined); + await activePage.waitForTimeout(4_000); + + const captureDeadline = Date.now() + 20_000; + while (Date.now() < captureDeadline) { + this.ensureNotCancelled(); + if ( + this.latestCapturedSession.detailTemplateUrl && + this.latestCapturedSession.reviewsTemplateUrl + ) { + break; + } + await activePage.waitForTimeout(1_000); + } + + if ( + !this.latestCapturedSession.detailTemplateUrl || + !this.latestCapturedSession.reviewsTemplateUrl + ) { + throw new Error("京东已登录,但未捕获到详情或评论模板,请刷新后重试或暂时使用手工注入。"); + } + + const payload = await this.buildImportedSessionFromOpenBrowser(); + await this.snapshotStorageState(resources.context); + + await this.sessionManager.importManualSession(payload, "ops-qr-login"); + const healthCheck = await this.sessionManager.runHealthCheck("ops-qr-login"); + + if ( + healthCheck.state.status === "manual_action_required" && + healthCheck.state.lastFailureCode === "RISK_BLOCKED" + ) { + const recoverySearchUrl = this.latestCapturedSession.targetSearchUrl ?? targetSearchUrl; + await activePage + .goto(recoverySearchUrl, { + waitUntil: "domcontentloaded", + timeout: 60_000 + }) + .catch(() => undefined); + this.updateCapturedPageUrls(activePage); + this.preserveBrowserForManualRecovery(); + this.setState({ + status: "failed", + note: + "Cookie 与模板已导入,但京东仍要求人工完成风控验证。请在已打开浏览器中完成验证后,回运维页点击“人工恢复后重新导入”。", + currentUrl: activePage.url(), + completedAt: nowIso(), + sessionImported: true, + targetId: targetSkuId, + errorMessage: healthCheck.state.lastFailureMessage + }); + return; + } + + this.setState({ + status: "completed", + note: "京东扫码登录完成,Cookie 与模板已导入运维会话。", + currentUrl: activePage.url(), + completedAt: nowIso(), + sessionImported: true, + targetId: targetSkuId + }); + } +} + +export class TmallOpsQrLoginService extends BaseOpsQrLoginService { + private readonly timeoutMs: number; + + constructor( + private readonly sessionManager: TmallSessionManager, + private readonly options: TmallQrLoginOptions = {} + ) { + super("tmall"); + this.timeoutMs = options.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS; + } + + protected override async runWithBrowser(resources: BrowserResources): Promise { + const targetItemId = + normalizeOptionalString(this.options.resolveTargetItemId?.()) ?? DEFAULT_TMALL_CAPTURE_ITEM_ID; + const targetDetailUrl = `https://detail.tmall.com/item.htm?id=${targetItemId}`; + const loginUrl = `https://login.taobao.com/member/login.jhtml?redirectURL=${encodeURIComponent( + targetDetailUrl + )}`; + let activePage = resources.page; + let reviewsTemplateUrl: string | undefined; + + resources.context.on("page", (nextPage) => { + activePage = nextPage; + }); + resources.context.on("request", (request) => { + const url = request.url(); + if ( + (url.includes("mtop.taobao.rate.detaillist.get") || + url.includes("mtop.alibaba.review.list.for.new.pc.detail")) && + !reviewsTemplateUrl + ) { + reviewsTemplateUrl = url; + } + }); + + await activePage.goto(loginUrl, { + waitUntil: "domcontentloaded", + timeout: 60_000 + }); + await activePage.waitForTimeout(4_000); + + const qrSelectors = [".qrcode-login .qrcode-img", ".qrcode-img", "canvas"]; + const qrImageDataUrl = await takeLocatorScreenshotDataUrl(activePage, qrSelectors); + this.setState({ + status: "waiting_for_scan", + note: "天猫二维码已加载,请用淘宝或天猫 App 扫码登录。", + currentUrl: activePage.url(), + targetId: targetItemId, + ...(qrImageDataUrl ? { qrImageDataUrl } : {}) + }); + + await this.waitForLoginCompletion( + () => activePage, + qrSelectors, + async () => { + const cookies = await resources.context.cookies(); + const names = new Set(cookies.map((cookie) => cookie.name)); + const currentUrl = activePage.url(); + if (!TMALL_LOGIN_URL_PATTERN.test(currentUrl) && /tmall\.com|taobao\.com/i.test(currentUrl)) { + return true; + } + + return names.has("_m_h5_tk"); + }, + this.timeoutMs + ); + + this.setState({ + status: "capturing_session", + note: "天猫扫码成功,正在回放商品页并提取评论模板。", + currentUrl: activePage.url(), + targetId: targetItemId + }); + + await activePage.goto(targetDetailUrl, { + waitUntil: "domcontentloaded", + timeout: 60_000 + }); + await activePage.waitForLoadState("networkidle").catch(() => undefined); + await activePage.waitForTimeout(4_000); + await activePage.mouse.wheel(0, 2200).catch(() => undefined); + await activePage.waitForTimeout(3_000); + await activePage.getByText(/商品评价|全部评价|评价/).first().click().catch(() => undefined); + await activePage.waitForTimeout(4_000); + + const captureDeadline = Date.now() + 12_000; + while (Date.now() < captureDeadline) { + this.ensureNotCancelled(); + if (reviewsTemplateUrl) { + break; + } + await activePage.waitForTimeout(1_000); + } + + const userAgent = + (await activePage.evaluate(() => window.navigator.userAgent).catch(() => "")) || undefined; + const cookieHeader = toCookieHeader(await resources.context.cookies(), "tmall"); + const payload: TmallLiveSessionInput = { + cookieHeader, + ...(userAgent ? { userAgent } : {}), + detailTemplateUrl: targetDetailUrl, + reviewsTemplateUrl: reviewsTemplateUrl ?? buildTmallReviewsTemplateUrl(targetItemId), + detailReferer: targetDetailUrl + }; + + await this.sessionManager.importManualSession(payload, "ops-qr-login"); + await this.sessionManager.runHealthCheck("ops-qr-login"); + + this.setState({ + status: "completed", + note: "天猫扫码登录完成,Cookie 与评论模板已导入运维会话。", + currentUrl: activePage.url(), + completedAt: nowIso(), + sessionImported: true, + targetId: targetItemId + }); + } +} diff --git a/apps/api/src/platforms/jd/live-session.test.ts b/apps/api/src/platforms/jd/live-session.test.ts index bb78ebe..eb8b73b 100644 --- a/apps/api/src/platforms/jd/live-session.test.ts +++ b/apps/api/src/platforms/jd/live-session.test.ts @@ -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>(async () => + buildResponse({ + data: { + wareList: [ + { + skuId: "100068388533", + imageurl: "//img14.360buyimg.com/n7/jfs/t1/example.jpg", + wname: "大疆 Pocket 3 全能套装", + good: "99%", + jdPrice: "5199.00", + shopName: "京东自营" + } + ] + } + }) + ); + vi.stubGlobal("fetch", fetchMock); + + const service = new JdLiveSessionService(); + service.importSession({ + cookieHeader: "thor=masked;", + searchApiTemplateUrl: + "https://api.m.jd.com/api?functionId=pc_search_searchWare&body=%7B%22keyword%22:%22iPhone%2015%22,%22wq%22:%22iPhone%2015%22,%22page%22:1%7D&keyword=iPhone%2015&enc=utf-8&suggest=2.def.0.test&wq=iPhone%2015&pvid=test-pvid&spmTag=test-spm", + searchRequestHeaders: { + ":authority": "api.m.jd.com", + "accept-language": "zh-CN,zh;q=0.9", + "sec-fetch-site": "same-site", + cookie: "should-be-ignored" + } + }); + + const preview = await service.previewSearch("大疆pocket3"); + + expect(preview.source).toBe("api"); + expect(preview.candidateCount).toBe(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + const requestUrl = fetchMock.mock.calls[0]?.[0]; + const requestInit = fetchMock.mock.calls[0]?.[1]; + if (!requestUrl || !requestInit) { + throw new Error("Expected JD search preview to call fetch with url and init."); + } + + const parsedUrl = new URL(requestUrl); + const decodedBody = decodeURIComponent(parsedUrl.searchParams.get("body") ?? ""); + expect(parsedUrl.searchParams.get("keyword")).toBe("大疆pocket3"); + expect(parsedUrl.searchParams.get("wq")).toBe("大疆pocket3"); + expect(parsedUrl.searchParams.get("pvid")).toBe("test-pvid"); + expect(decodedBody).toContain(`"keyword":"${parsedUrl.searchParams.get("keyword")}"`); + expect(decodedBody).toContain(`"wq":"${parsedUrl.searchParams.get("wq")}"`); + expect(requestInit.headers).toMatchObject({ + Referer: expect.stringContaining("keyword=%E5%A4%A7%E7%96%86pocket3"), + "User-Agent": expect.any(String), + "accept-language": "zh-CN,zh;q=0.9", + "sec-fetch-site": "same-site" + }); + expect(requestInit.headers).not.toHaveProperty(":authority"); + }); + it("paginates and deduplicates JD reviews across multiple pages", async () => { const fetchMock = vi .fn<(input: string, init?: RequestInit) => Promise>() @@ -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>(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>(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>(async () => + new Response("blocked", { status: 403 }) + ); + vi.stubGlobal("fetch", fetchMock); + + const service = new JdLiveSessionService(); + const browserPreviewReviews = vi.fn(async () => ({ + skuId: "100068388535", + source: "api" as const, + pagination: { + requestedPage: 1, + requestedCommentCount: 2, + maxPages: 1, + pagesFetched: 1 + }, + reviews: { + skuId: "100068388535", + total: "1000", + goodRate: "96%", + pictureCount: "120", + tags: [], + comments: [ + { + id: "comment-1", + content: "great", + score: "5", + creationTime: null, + userLevelName: null + } + ] + } + })); + service.setBrowserPreviewProvider({ + previewSearch: vi.fn(), + previewDetail: vi.fn(), + previewReviews: browserPreviewReviews + }); + service.importSession({ + cookieHeader: "thor=masked;", + reviewsTemplateUrl: + "https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22shopType%22:%220%22,%22sku%22:100068388533,%22commentNum%22:2,%22page%22:1,%22source%22:%22pc%22%7D" + }); + + const preview = await service.previewReviews("100068388535", { + commentCount: 2, + page: 1, + maxPages: 1 + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(browserPreviewReviews).toHaveBeenCalledWith("100068388535", { + commentCount: 2, + page: 1, + maxPages: 1 + }); + expect(preview.reviews.comments).toHaveLength(1); + }); + it("rejects a detail template that still resolves to another sku", async () => { const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise>(async () => buildResponse({ diff --git a/apps/api/src/platforms/jd/live-session.ts b/apps/api/src/platforms/jd/live-session.ts index 40db654..0f62e34 100644 --- a/apps/api/src/platforms/jd/live-session.ts +++ b/apps/api/src/platforms/jd/live-session.ts @@ -5,6 +5,7 @@ import { parseJdSearchHtml } from "./parsers"; import type { + JdBrowserPreviewProvider, JdDetailPreviewResult, JdLiveService, JdProductPreviewResult, @@ -38,6 +39,7 @@ const REVIEW_PAGE_BODY_KEYS = [ "commentPage" ]; const REVIEW_PAGE_SIZE_BODY_KEYS = ["commentNum", "pageSize", "pageLimit"]; +const SEARCH_QUERY_BODY_KEYS = ["keyword", "wq", "word", "keyWord", "query"]; const SEARCH_FUNCTION_ID = "pc_search_searchWare"; const DETAIL_FUNCTION_ID = "pc_detailpage_wareBusiness"; const REVIEWS_FUNCTION_ID = "getLegoWareDetailComment"; @@ -81,6 +83,9 @@ type StoredJdLiveSession = { reviewsTemplateUrl?: string | undefined; searchReferer?: string | undefined; detailReferer?: string | undefined; + searchRequestHeaders?: Record | undefined; + detailRequestHeaders?: Record | undefined; + reviewsRequestHeaders?: Record | undefined; }; class JdLiveError extends Error { @@ -158,20 +163,52 @@ function buildTemplateSummary(templateUrl: string | undefined): JdTemplateSummar }; } -function templateMatchesQuery( - templateUrl: string | undefined, - query: string -): boolean { - if (!templateUrl) { - return false; +function normalizeCapturedHeaders( + input: Record | undefined +): Record | undefined { + if (!input) { + return undefined; } - try { - const templateKeyword = new URL(templateUrl).searchParams.get("keyword"); - return Boolean(templateKeyword && templateKeyword === query); - } catch { - return false; + const isValidReplayHeaderName = (key: string) => /^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(key); + const entries = Object.entries(input) + .map(([key, value]) => [key.trim().toLowerCase(), value.trim()] as const) + .filter(([key, value]) => { + return ( + key.length > 0 && + value.length > 0 && + !key.startsWith(":") && + isValidReplayHeaderName(key) && + key !== "cookie" && + key !== "referer" && + key !== "host" && + key !== "content-length" && + key !== "connection" && + key !== "accept-encoding" + ); + }); + + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +function buildReplayHeaders( + capturedHeaders: Record | undefined, + required: { + accept: string; + cookie: string; + referer: string; + userAgent: string; } +): Record { + const normalizedCapturedHeaders = normalizeCapturedHeaders(capturedHeaders); + + return { + ...(normalizedCapturedHeaders ?? {}), + ...(normalizedCapturedHeaders?.accept ? {} : { Accept: required.accept }), + Cookie: required.cookie, + Referer: required.referer, + "User-Agent": required.userAgent + }; } function coerceBodyValue(existing: unknown, nextValue: number | string): number | string { @@ -316,6 +353,78 @@ function buildDetailRequestUrl(templateUrl: string, skuId: string): string { })); } +function buildSearchApiRequestUrl(templateUrl: string, query: string): string { + let requestUrl = new URL(templateUrl); + requestUrl.searchParams.set("keyword", query); + + if (requestUrl.searchParams.has("wq")) { + requestUrl.searchParams.set("wq", query); + } + + if (!requestUrl.searchParams.has("enc")) { + requestUrl.searchParams.set("enc", "utf-8"); + } + + if (readQueryBody(requestUrl)) { + requestUrl = new URL( + withUpdatedQueryBody(requestUrl, (currentBody) => { + const nextBody: Record = { + ...currentBody + }; + let updated = false; + + for (const key of SEARCH_QUERY_BODY_KEYS) { + if (!(key in currentBody)) { + continue; + } + + nextBody[key] = coerceBodyValue(currentBody[key], query); + updated = true; + } + + if (!updated) { + nextBody.keyword = query; + } + + return nextBody; + }) + ); + } + + return requestUrl.toString(); +} + +function buildSearchPageUrlFromTemplate(templateUrl: string, query: string): string { + const template = new URL(templateUrl); + const searchUrl = new URL("https://search.jd.com/Search"); + + for (const [key, value] of template.searchParams.entries()) { + if ( + key === "functionId" || + key === "appid" || + key === "client" || + key === "clientVersion" || + key === "t" || + key === "loginType" || + key === "body" + ) { + continue; + } + + searchUrl.searchParams.set(key, value); + } + + searchUrl.searchParams.set("keyword", query); + if (searchUrl.searchParams.has("wq")) { + searchUrl.searchParams.set("wq", query); + } + if (!searchUrl.searchParams.has("enc")) { + searchUrl.searchParams.set("enc", "utf-8"); + } + + return searchUrl.toString(); +} + function buildReviewsRequestUrl( templateUrl: string, skuId: string, @@ -472,6 +581,11 @@ export function getJdLiveErrorCode(error: unknown): JdLiveErrorCode | undefined export class JdLiveSessionService implements JdLiveService { private session: StoredJdLiveSession | null = readEnvSession(); + private browserPreviewProvider: JdBrowserPreviewProvider | null = null; + + setBrowserPreviewProvider(provider: JdBrowserPreviewProvider | null): void { + this.browserPreviewProvider = provider; + } getSessionSummary(): JdLiveSessionSummary { return { @@ -491,6 +605,9 @@ export class JdLiveSessionService implements JdLiveService { const reviewsTemplateUrl = input.reviewsTemplateUrl?.trim(); const searchReferer = input.searchReferer?.trim(); const detailReferer = input.detailReferer?.trim(); + const searchRequestHeaders = normalizeCapturedHeaders(input.searchRequestHeaders); + const detailRequestHeaders = normalizeCapturedHeaders(input.detailRequestHeaders); + const reviewsRequestHeaders = normalizeCapturedHeaders(input.reviewsRequestHeaders); validateImportedTemplates({ searchApiTemplateUrl, @@ -506,7 +623,10 @@ export class JdLiveSessionService implements JdLiveService { ...(detailTemplateUrl ? { detailTemplateUrl } : {}), ...(reviewsTemplateUrl ? { reviewsTemplateUrl } : {}), ...(searchReferer ? { searchReferer } : {}), - ...(detailReferer ? { detailReferer } : {}) + ...(detailReferer ? { detailReferer } : {}), + ...(searchRequestHeaders ? { searchRequestHeaders } : {}), + ...(detailRequestHeaders ? { detailRequestHeaders } : {}), + ...(reviewsRequestHeaders ? { reviewsRequestHeaders } : {}) }; return this.getSessionSummary(); @@ -526,77 +646,75 @@ export class JdLiveSessionService implements JdLiveService { throw new JdLiveError("query is required for JD live search preview.", 400, "BAD_REQUEST"); } - const resolvedMode = - mode ?? - (templateMatchesQuery(session.searchApiTemplateUrl, normalizedQuery) ? "api" : "html"); + const resolvedMode = mode ?? (session.searchApiTemplateUrl ? "api" : "html"); - if (resolvedMode === "api") { - if (!session.searchApiTemplateUrl) { - throw new JdLiveError( - "JD search API template is missing. Import a fresh search request URL or use mode=html.", - 409, - "TEMPLATE_MISSING" - ); - } - - const templateUrl = new URL(session.searchApiTemplateUrl); - const templateKeyword = templateUrl.searchParams.get("keyword"); - if (templateKeyword && templateKeyword !== normalizedQuery) { - throw new JdLiveError( - `Imported search API template is locked to query "${templateKeyword}". ` + - "Capture a fresh request for the target query or use mode=html.", - 409, - "TEMPLATE_QUERY_LOCKED" + try { + if (resolvedMode === "api") { + if (!session.searchApiTemplateUrl) { + throw new JdLiveError( + "JD search API template is missing. Import a fresh search request URL or use mode=html.", + 409, + "TEMPLATE_MISSING" + ); + } + + const requestUrl = buildSearchApiRequestUrl(session.searchApiTemplateUrl, normalizedQuery); + const referer = buildSearchPageUrlFromTemplate( + session.searchApiTemplateUrl, + normalizedQuery ); + const response = await fetchTextOrThrow( + requestUrl, + { + headers: buildReplayHeaders(session.searchRequestHeaders, { + accept: "application/json, text/plain, */*", + cookie: session.cookieHeader, + referer, + userAgent: session.userAgent + }) + }, + "JD search session appears invalid. Re-login in the browser and re-import the cookie/header." + ); + + const candidates = parseJdSearchApiResponse(normalizedQuery, { text: response.text }); + return { + query: normalizedQuery, + source: "api", + candidateCount: candidates.length, + candidates + }; } + const searchUrl = `https://search.jd.com/Search?keyword=${encodeURIComponent(normalizedQuery)}`; const response = await fetchTextOrThrow( - session.searchApiTemplateUrl, + searchUrl, { - headers: { - Accept: "application/json, text/plain, */*", - Cookie: session.cookieHeader, - Referer: - session.searchReferer ?? - `https://search.jd.com/Search?keyword=${encodeURIComponent(normalizedQuery)}`, - "User-Agent": session.userAgent - } + headers: buildReplayHeaders(session.searchRequestHeaders, { + accept: + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + cookie: session.cookieHeader, + referer: session.searchReferer ?? "https://www.jd.com/", + userAgent: session.userAgent + }) }, "JD search session appears invalid. Re-login in the browser and re-import the cookie/header." ); - const candidates = parseJdSearchApiResponse(normalizedQuery, { text: response.text }); + const candidates = parseJdSearchHtml(normalizedQuery, response.text); return { query: normalizedQuery, - source: "api", + source: "html", candidateCount: candidates.length, candidates }; + } catch (error) { + const browserPreview = await this.tryBrowserSearchFallback(error, normalizedQuery, resolvedMode); + if (browserPreview) { + return browserPreview; + } + + throw error; } - - const searchUrl = `https://search.jd.com/Search?keyword=${encodeURIComponent(normalizedQuery)}`; - const response = await fetchTextOrThrow( - searchUrl, - { - headers: { - Accept: - "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", - "Accept-Language": "zh-CN,zh;q=0.9", - Cookie: session.cookieHeader, - Referer: session.searchReferer ?? "https://www.jd.com/", - "User-Agent": session.userAgent - } - }, - "JD search session appears invalid. Re-login in the browser and re-import the cookie/header." - ); - - const candidates = parseJdSearchHtml(normalizedQuery, response.text); - return { - query: normalizedQuery, - source: "html", - candidateCount: candidates.length, - candidates - }; } async previewDetail(skuId: string): Promise { @@ -616,33 +734,42 @@ export class JdLiveSessionService implements JdLiveService { const requestUrl = buildDetailRequestUrl(session.detailTemplateUrl, normalizedSkuId); - const response = await fetchTextOrThrow( - requestUrl, - { - headers: { - Accept: "application/json, text/plain, */*", - Cookie: session.cookieHeader, - Referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`, - "User-Agent": session.userAgent - } - }, - "JD detail session appears invalid. Re-login in the browser and re-import the cookie/header." - ); - - const detail = parseJdDetailApiResponse(normalizedSkuId, { text: response.text }); - if (detail.skuId !== normalizedSkuId) { - throw new JdLiveError( - `JD detail template appears bound to another sku (${detail.skuId}). Capture a fresh pc_detailpage_wareBusiness request first.`, - 409, - "TEMPLATE_EXPIRED" + try { + const response = await fetchTextOrThrow( + requestUrl, + { + headers: buildReplayHeaders(session.detailRequestHeaders, { + accept: "application/json, text/plain, */*", + cookie: session.cookieHeader, + referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`, + userAgent: session.userAgent + }) + }, + "JD detail session appears invalid. Re-login in the browser and re-import the cookie/header." ); - } - return { - skuId: normalizedSkuId, - source: "api", - detail - }; + const detail = parseJdDetailApiResponse(normalizedSkuId, { text: response.text }); + if (detail.skuId !== normalizedSkuId) { + throw new JdLiveError( + `JD detail template appears bound to another sku (${detail.skuId}). Capture a fresh pc_detailpage_wareBusiness request first.`, + 409, + "TEMPLATE_EXPIRED" + ); + } + + return { + skuId: normalizedSkuId, + source: "api", + detail + }; + } catch (error) { + const browserPreview = await this.tryBrowserDetailFallback(error, normalizedSkuId); + if (browserPreview) { + return browserPreview; + } + + throw error; + } } async previewReviews( @@ -668,64 +795,77 @@ export class JdLiveSessionService implements JdLiveService { let pageKey: string | undefined; const seenCommentIds = new Set(); - for (let pageOffset = 0; pageOffset < resolvedOptions.maxPages; pageOffset += 1) { - const currentPage = resolvedOptions.page + pageOffset; - const request = buildReviewsRequestUrl( - session.reviewsTemplateUrl, - normalizedSkuId, - resolvedOptions, - currentPage - ); - pageKey ??= request.pageKey; + try { + for (let pageOffset = 0; pageOffset < resolvedOptions.maxPages; pageOffset += 1) { + const currentPage = resolvedOptions.page + pageOffset; + const request = buildReviewsRequestUrl( + session.reviewsTemplateUrl, + normalizedSkuId, + resolvedOptions, + currentPage + ); + pageKey ??= request.pageKey; - const response = await fetchTextOrThrow( - request.url, - { - headers: { - Accept: "application/json, text/plain, */*", - Cookie: session.cookieHeader, - Referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`, - "User-Agent": session.userAgent + const response = await fetchTextOrThrow( + request.url, + { + headers: buildReplayHeaders(session.reviewsRequestHeaders, { + accept: "application/json, text/plain, */*", + cookie: session.cookieHeader, + referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`, + userAgent: session.userAgent + }) + }, + "JD reviews session appears invalid. Re-login in the browser and re-import the cookie/header." + ); + + const parsedPage = parseJdReviewsApiResponse(normalizedSkuId, { text: response.text }); + reviewPages.push(parsedPage); + let newCommentCount = 0; + for (const comment of parsedPage.comments) { + if (seenCommentIds.has(comment.id)) { + continue; } - }, - "JD reviews session appears invalid. Re-login in the browser and re-import the cookie/header." - ); - const parsedPage = parseJdReviewsApiResponse(normalizedSkuId, { text: response.text }); - reviewPages.push(parsedPage); - let newCommentCount = 0; - for (const comment of parsedPage.comments) { - if (seenCommentIds.has(comment.id)) { - continue; + seenCommentIds.add(comment.id); + newCommentCount += 1; } - seenCommentIds.add(comment.id); - newCommentCount += 1; + if ( + parsedPage.comments.length === 0 || + newCommentCount === 0 || + parsedPage.comments.length < resolvedOptions.commentCount + ) { + break; + } } - if ( - parsedPage.comments.length === 0 || - newCommentCount === 0 || - parsedPage.comments.length < resolvedOptions.commentCount - ) { - break; + const pagination: JdReviewsPaginationSummary = { + requestedPage: resolvedOptions.page, + requestedCommentCount: resolvedOptions.commentCount, + maxPages: resolvedOptions.maxPages, + pagesFetched: reviewPages.length, + ...(pageKey ? { pageKey } : {}) + }; + + return { + skuId: normalizedSkuId, + source: "api", + pagination, + reviews: mergeReviewPages(normalizedSkuId, reviewPages) + }; + } catch (error) { + const browserPreview = await this.tryBrowserReviewsFallback( + error, + normalizedSkuId, + options + ); + if (browserPreview) { + return browserPreview; } + + throw error; } - - const pagination: JdReviewsPaginationSummary = { - requestedPage: resolvedOptions.page, - requestedCommentCount: resolvedOptions.commentCount, - maxPages: resolvedOptions.maxPages, - pagesFetched: reviewPages.length, - ...(pageKey ? { pageKey } : {}) - }; - - return { - skuId: normalizedSkuId, - source: "api", - pagination, - reviews: mergeReviewPages(normalizedSkuId, reviewPages) - }; } async previewProduct( @@ -757,4 +897,47 @@ export class JdLiveSessionService implements JdLiveService { return this.session; } + + private shouldUseBrowserFallback(error: unknown): boolean { + const code = getJdLiveErrorCode(error); + return Boolean( + this.browserPreviewProvider && + (code === "RISK_BLOCKED" || code === "NETWORK_ERROR" || code === "HTTP_ERROR") + ); + } + + private async tryBrowserSearchFallback( + error: unknown, + query: string, + mode: JdSearchMode + ): Promise { + if (!this.shouldUseBrowserFallback(error) || !this.browserPreviewProvider) { + return null; + } + + return this.browserPreviewProvider.previewSearch(query, mode); + } + + private async tryBrowserDetailFallback( + error: unknown, + skuId: string + ): Promise { + if (!this.shouldUseBrowserFallback(error) || !this.browserPreviewProvider) { + return null; + } + + return this.browserPreviewProvider.previewDetail(skuId); + } + + private async tryBrowserReviewsFallback( + error: unknown, + skuId: string, + options?: number | JdReviewsPreviewOptions + ): Promise { + if (!this.shouldUseBrowserFallback(error) || !this.browserPreviewProvider) { + return null; + } + + return this.browserPreviewProvider.previewReviews(skuId, options); + } } diff --git a/apps/api/src/platforms/jd/parsers.test.ts b/apps/api/src/platforms/jd/parsers.test.ts index 25d8543..9fc4c00 100644 --- a/apps/api/src/platforms/jd/parsers.test.ts +++ b/apps/api/src/platforms/jd/parsers.test.ts @@ -148,4 +148,62 @@ describe("JD parsers", () => { userLevelName: "PLUS会员" }); }); + it("falls back to lastCommentInfoList when commentInfoList is missing", () => { + const reviews = parseJdReviewsApiResponse("100068388533", { + allCnt: "3", + goodRate: "98%", + lastCommentInfoList: [ + { + commentId: "comment-last-1", + commentData: "fallback review", + commentDate: "2026-04-07 19:00:05", + userNickName: "fallback-user" + } + ] + }); + + expect(reviews.comments).toEqual([ + expect.objectContaining({ + id: "comment-last-1", + content: "fallback review", + creationTime: "2026-04-07 19:00:05", + userLevelName: "fallback-user" + }) + ]); + }); + + it("unwraps nested JD review payloads and commentInfo wrappers", () => { + const reviews = parseJdReviewsApiResponse("100068388533", { + code: 0, + data: { + allCnt: "12", + goodRate: "96%", + commentInfoList: [ + { + commentInfo: { + commentId: "comment-nested-1", + content: "nested review", + commentScore: 4, + creationTime: "2026-04-07 20:12:33", + userNickName: "nested-user" + } + } + ] + } + }); + + expect(reviews).toMatchObject({ + total: "12", + goodRate: "96%" + }); + expect(reviews.comments).toEqual([ + expect.objectContaining({ + id: "comment-nested-1", + content: "nested review", + score: "4", + creationTime: "2026-04-07 20:12:33", + userLevelName: "nested-user" + }) + ]); + }); }); diff --git a/apps/api/src/platforms/jd/parsers.ts b/apps/api/src/platforms/jd/parsers.ts index 32d4604..16b1e59 100644 --- a/apps/api/src/platforms/jd/parsers.ts +++ b/apps/api/src/platforms/jd/parsers.ts @@ -30,6 +30,66 @@ function unwrapCapturedPayload(input: unknown): unknown { } } +function collectNestedPayloads( + root: Record | null +): Record[] { + if (!root) { + return []; + } + + const queue: Record[] = [root]; + const seen = new Set>(); + const collected: Record[] = []; + + 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): 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 | null { + const root = asRecord(unwrapCapturedPayload(input)); + if (!root) { + return null; + } + + return collectNestedPayloads(root).find((payload) => hasReviewPayloadMarkers(payload)) ?? root; +} + function extractSpecLabel(title: string): string { const storageMatch = title.match(/\b\d+(?:GB|TB)\b/i); if (storageMatch) { @@ -313,11 +373,32 @@ function parseReviewTag(input: unknown): JdReviewTagSnapshot | null { } function parseReviewComment(input: unknown): JdReviewCommentSnapshot | null { - const comment = asRecord(input); + const wrapper = asRecord(input); + const comment = asRecord(wrapper?.commentInfo) ?? wrapper; const content = normalizeInlineText( - firstString(comment?.content, comment?.commentData, comment?.tagCommentContent) + firstString( + comment?.content, + comment?.commentData, + comment?.tagCommentContent, + comment?.commentContent, + comment?.commentText, + comment?.text, + wrapper?.content, + wrapper?.commentData, + wrapper?.tagCommentContent, + wrapper?.commentContent, + wrapper?.commentText, + wrapper?.text + ) + ); + const id = firstString( + comment?.id, + comment?.commentId, + comment?.commentUuid, + wrapper?.id, + wrapper?.commentId, + wrapper?.commentUuid ); - const id = firstString(comment?.id, comment?.commentId); if (!content || !id) { return null; @@ -326,14 +407,37 @@ function parseReviewComment(input: unknown): JdReviewCommentSnapshot | null { return { id, content, - score: firstString(comment?.score, comment?.commentScore), + score: firstString( + comment?.score, + comment?.commentScore, + comment?.scoring, + wrapper?.score, + wrapper?.commentScore, + wrapper?.scoring + ), creationTime: firstString( comment?.creationTime, comment?.creationDate, - comment?.commentDate + comment?.commentDate, + comment?.time, + wrapper?.creationTime, + wrapper?.creationDate, + wrapper?.commentDate, + wrapper?.time ), userLevelName: normalizeInlineText( - firstString(comment?.userLevelName, comment?.userClientShow) + firstString( + comment?.userLevelName, + comment?.userClientShow, + comment?.userLevel, + comment?.userNickName, + comment?.userName, + wrapper?.userLevelName, + wrapper?.userClientShow, + wrapper?.userLevel, + wrapper?.userNickName, + wrapper?.userName + ) ) }; } @@ -342,13 +446,20 @@ export function parseJdReviewsApiResponse( skuId: string, input: unknown ): JdProductReviewsSnapshot { - const payload = asRecord(unwrapCapturedPayload(input)); + const payload = resolveJdReviewsPayload(input); const tags = asArray(payload?.tagStatisticsinfoList) .map((tag) => parseReviewTag(tag)) .filter((tag): tag is JdReviewTagSnapshot => Boolean(tag)); - const comments = asArray(payload?.commentInfoList) - .map((comment) => parseReviewComment(comment)) - .filter((comment): comment is JdReviewCommentSnapshot => Boolean(comment)); + const commentsById = new Map(); + for (const comment of [ + ...asArray(payload?.commentInfoList), + ...asArray(payload?.lastCommentInfoList) + ]) { + const parsedComment = parseReviewComment(comment); + if (parsedComment && !commentsById.has(parsedComment.id)) { + commentsById.set(parsedComment.id, parsedComment); + } + } return { skuId, @@ -356,6 +467,6 @@ export function parseJdReviewsApiResponse( goodRate: firstString(payload?.goodRate, payload?.goodRateShow), pictureCount: firstString(payload?.pictureCnt, payload?.showPicCnt), tags, - comments + comments: Array.from(commentsById.values()) }; } diff --git a/apps/api/src/platforms/jd/session-manager.test.ts b/apps/api/src/platforms/jd/session-manager.test.ts index 7af700f..96473fd 100644 --- a/apps/api/src/platforms/jd/session-manager.test.ts +++ b/apps/api/src/platforms/jd/session-manager.test.ts @@ -198,7 +198,7 @@ describe("JdSessionManagerService", () => { vi.unstubAllEnvs(); }); - it("accepts manual session imports and marks the manager healthy", async () => { + it("accepts manual session imports and waits for a health check before marking ready", async () => { const onSessionReady = vi.fn(); const manager = new JdSessionManagerService(createJdLiveServiceStub(), { onSessionReady @@ -213,8 +213,8 @@ describe("JdSessionManagerService", () => { "https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22sku%22:100068388533%7D" }); - expect(onSessionReady).toHaveBeenCalledOnce(); - expect(state.status).toBe("healthy"); + expect(onSessionReady).not.toHaveBeenCalled(); + expect(state.status).toBe("degraded"); expect(state.pendingManualAction).toBe(false); expect(state.session).toMatchObject({ configured: true, @@ -294,7 +294,7 @@ describe("JdSessionManagerService", () => { expect(result.recovered).toBe(false); expect(result.state.status).toBe("healthy"); expect(onSessionReady).toHaveBeenCalledOnce(); - expect(previewSearch).toHaveBeenCalledWith("iPhone 15", "html"); + expect(previewSearch).toHaveBeenCalledWith("iPhone 15", "api"); expect(previewDetail).toHaveBeenCalledWith("100068388533"); expect(previewReviews).toHaveBeenCalledWith("100068388533", { commentCount: 1, diff --git a/apps/api/src/platforms/jd/session-manager.ts b/apps/api/src/platforms/jd/session-manager.ts index 0bad1c4..8d847ee 100644 --- a/apps/api/src/platforms/jd/session-manager.ts +++ b/apps/api/src/platforms/jd/session-manager.ts @@ -276,15 +276,14 @@ export class JdSessionManagerService implements JdSessionManager { source = "ops-manual" ): Promise { this.liveService.importSession(input); - this.callbacks.onSessionReady?.(); this.state = { ...this.state, - status: "healthy", + status: "degraded", pendingManualAction: false, - note: `京东会话已通过 ${source} 更新,等待下一轮健康检查。`, - publicNote: "京东会话由运维后台维护,当前可用。", - lastRecoveredAt: nowIso(), - lastHealthyAt: nowIso(), + note: `JD session imported via ${source}; waiting for health check.`, + publicNote: "JD session was updated and is being verified.", + lastRecoveredAt: undefined, + lastHealthyAt: undefined, lastFailureCode: undefined, lastFailureMessage: undefined, session: this.liveService.getSessionSummary() @@ -556,7 +555,10 @@ export class JdSessionManagerService implements JdSessionManager { throw new Error("京东会话缺少可校验的详情/评论模板。"); } - await this.liveService.previewSearch(this.config.heartbeatQuery, "html"); + await this.liveService.previewSearch( + this.config.heartbeatQuery, + summary.searchApiTemplate.available ? "api" : "html" + ); await this.liveService.previewDetail(detailSkuId); await this.liveService.previewReviews(reviewsSkuId, { commentCount: 1, diff --git a/apps/api/src/platforms/jd/types.ts b/apps/api/src/platforms/jd/types.ts index e50d33d..0c9f9e4 100644 --- a/apps/api/src/platforms/jd/types.ts +++ b/apps/api/src/platforms/jd/types.ts @@ -15,6 +15,9 @@ export interface JdLiveSessionInput { reviewsTemplateUrl?: string | undefined; searchReferer?: string | undefined; detailReferer?: string | undefined; + searchRequestHeaders?: Record | undefined; + detailRequestHeaders?: Record | undefined; + reviewsRequestHeaders?: Record | undefined; } export interface JdLiveSessionSummary { @@ -157,6 +160,15 @@ export interface JdProductPreviewResult { reviews: JdProductReviewsSnapshot; } +export interface JdBrowserPreviewProvider { + previewSearch(query: string, mode?: JdSearchMode): Promise; + previewDetail(skuId: string): Promise; + previewReviews( + skuId: string, + options?: number | JdReviewsPreviewOptions + ): Promise; +} + export interface JdLiveService { getSessionSummary(): JdLiveSessionSummary; importSession(input: JdLiveSessionInput): JdLiveSessionSummary; diff --git a/apps/api/src/server.dual-platform-live.test.ts b/apps/api/src/server.dual-platform-live.test.ts index 93eee34..a4d586d 100644 --- a/apps/api/src/server.dual-platform-live.test.ts +++ b/apps/api/src/server.dual-platform-live.test.ts @@ -85,9 +85,18 @@ function createJdLiveServiceStub(overrides: Partial = {}): JdLive importedAt: "2026-04-03T10:00:00.000Z", hasCookie: true, userAgent: input.userAgent ?? "stub-user-agent", - searchApiTemplate: { available: Boolean(input.searchApiTemplateUrl) }, - detailTemplate: { available: Boolean(input.detailTemplateUrl) }, - reviewsTemplate: { available: Boolean(input.reviewsTemplateUrl) } + searchApiTemplate: { + available: Boolean(input.searchApiTemplateUrl), + skuId: input.searchApiTemplateUrl ? "100068388533" : undefined + }, + detailTemplate: { + available: Boolean(input.detailTemplateUrl), + skuId: input.detailTemplateUrl ? "100068388533" : undefined + }, + reviewsTemplate: { + available: Boolean(input.reviewsTemplateUrl), + skuId: input.reviewsTemplateUrl ? "100068388533" : undefined + } }; return summary; }, diff --git a/apps/api/src/server.jd-live.test.ts b/apps/api/src/server.jd-live.test.ts index 930d9cf..9f26c11 100644 --- a/apps/api/src/server.jd-live.test.ts +++ b/apps/api/src/server.jd-live.test.ts @@ -36,9 +36,18 @@ function createJdLiveServiceStub( importedAt: "2026-04-02T12:00:00.000Z", hasCookie: true, userAgent: input.userAgent ?? "stub-user-agent", - searchApiTemplate: { available: Boolean(input.searchApiTemplateUrl) }, - detailTemplate: { available: Boolean(input.detailTemplateUrl) }, - reviewsTemplate: { available: Boolean(input.reviewsTemplateUrl) } + searchApiTemplate: { + available: Boolean(input.searchApiTemplateUrl), + skuId: input.searchApiTemplateUrl ? "100068388533" : undefined + }, + detailTemplate: { + available: Boolean(input.detailTemplateUrl), + skuId: input.detailTemplateUrl ? "100068388533" : undefined + }, + reviewsTemplate: { + available: Boolean(input.reviewsTemplateUrl), + skuId: input.reviewsTemplateUrl ? "100068388533" : undefined + } }; return summary; }, diff --git a/apps/api/src/server.test.ts b/apps/api/src/server.test.ts index be50f71..5666999 100644 --- a/apps/api/src/server.test.ts +++ b/apps/api/src/server.test.ts @@ -12,6 +12,7 @@ import type { JdReviewsPreviewResult, JdSearchPreviewResult } from "./platforms/jd/types"; +import type { OpsQrLoginController, OpsQrLoginState } from "./ops/qr-login"; import { createServer } from "./server"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -63,6 +64,31 @@ async function createCompletedTask(app: ReturnType, query: return confirmResponse.json().task; } +async function waitForTask( + app: ReturnType, + taskId: string, + predicate: (task: Awaited>) => 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>; + + if (predicate(task)) { + return task; + } + + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + throw new Error(`Task ${taskId} did not reach the expected state within ${timeoutMs}ms.`); +} + function backdateStoredTask(storagePath: string, taskId: string, ageDays: number) { const timestamp = new Date(Date.now() - ageDays * DAY_MS).toISOString(); const snapshot = JSON.parse(readFileSync(storagePath, "utf8")) as { @@ -168,9 +194,18 @@ function createJdLiveServiceStub( importedAt: "2026-04-02T12:00:00.000Z", hasCookie: true, userAgent: input.userAgent ?? "stub-user-agent", - searchApiTemplate: { available: Boolean(input.searchApiTemplateUrl) }, - detailTemplate: { available: Boolean(input.detailTemplateUrl) }, - reviewsTemplate: { available: Boolean(input.reviewsTemplateUrl) } + searchApiTemplate: { + available: Boolean(input.searchApiTemplateUrl), + skuId: input.searchApiTemplateUrl ? "100068388533" : undefined + }, + detailTemplate: { + available: Boolean(input.detailTemplateUrl), + skuId: input.detailTemplateUrl ? "100068388533" : undefined + }, + reviewsTemplate: { + available: Boolean(input.reviewsTemplateUrl), + skuId: input.reviewsTemplateUrl ? "100068388533" : undefined + } }; return summary; }, @@ -305,6 +340,57 @@ function createJdLiveServiceStub( }; } +function createQrLoginStub(platform: "jd" | "tmall"): OpsQrLoginController { + let state: OpsQrLoginState = { + platform, + status: "idle", + note: "尚未启动扫码登录。", + sessionImported: false + }; + + return { + getState() { + return state; + }, + async start() { + state = { + ...state, + status: "waiting_for_scan", + note: "二维码已生成,请扫码。", + targetId: platform === "jd" ? "100068388533" : "934454505228", + qrImageDataUrl: "data:image/png;base64,stub", + startedAt: "2026-04-07T12:00:00.000Z", + updatedAt: "2026-04-07T12:00:00.000Z" + }; + return state; + }, + async cancel() { + state = { + ...state, + status: "cancelled", + note: "扫码登录已取消。", + completedAt: "2026-04-07T12:10:00.000Z", + updatedAt: "2026-04-07T12:10:00.000Z" + }; + return state; + }, + async resumeManualRecovery() { + state = { + ...state, + status: "completed", + note: "人工恢复完成。", + completedAt: "2026-04-07T12:11:00.000Z", + updatedAt: "2026-04-07T12:11:00.000Z", + sessionImported: true + }; + return state; + }, + async shutdown() { + return; + } + }; +} + describe("API server", () => { it("returns platform readiness with jd blocked by default", async () => { const app = createServer(); @@ -331,6 +417,79 @@ describe("API server", () => { await app.close(); }); + it("exposes the JD qr-login state and can start the flow", async () => { + const app = createServer({ + jdQrLoginService: createQrLoginStub("jd") + }); + await app.ready(); + + const initialResponse = await app.inject({ + method: "GET", + url: "/api/ops/jd/session-manager/qr-login" + }); + expect(initialResponse.statusCode).toBe(200); + expect(initialResponse.json().qrLogin).toMatchObject({ + platform: "jd", + status: "idle" + }); + + const startResponse = await app.inject({ + method: "POST", + url: "/api/ops/jd/session-manager/qr-login/start" + }); + expect(startResponse.statusCode).toBe(200); + expect(startResponse.json().qrLogin).toMatchObject({ + platform: "jd", + status: "waiting_for_scan", + targetId: "100068388533" + }); + + await app.close(); + }); + + it("can cancel the Tmall qr-login flow", async () => { + const qrLoginService = createQrLoginStub("tmall"); + await qrLoginService.start(); + const app = createServer({ + tmallQrLoginService: qrLoginService + }); + await app.ready(); + + const cancelResponse = await app.inject({ + method: "POST", + url: "/api/ops/tmall/session-manager/qr-login/cancel" + }); + expect(cancelResponse.statusCode).toBe(200); + expect(cancelResponse.json().qrLogin).toMatchObject({ + platform: "tmall", + status: "cancelled" + }); + + await app.close(); + }); + + it("can resume the JD qr-login manual recovery flow", async () => { + const qrLoginService = createQrLoginStub("jd"); + await qrLoginService.start(); + const app = createServer({ + jdQrLoginService: qrLoginService + }); + await app.ready(); + + const resumeResponse = await app.inject({ + method: "POST", + url: "/api/ops/jd/session-manager/qr-login/resume" + }); + expect(resumeResponse.statusCode).toBe(200); + expect(resumeResponse.json().qrLogin).toMatchObject({ + platform: "jd", + status: "completed", + sessionImported: true + }); + + await app.close(); + }); + it("exposes session state with a 24-hour expiry window after preparation", async () => { const app = createServer(); await app.ready(); @@ -404,6 +563,47 @@ describe("API server", () => { await app.close(); }); + it("runs confirmed tasks in the background when the execution mode is background", async () => { + const app = createServer({ executionMode: "background" }); + await app.ready(); + + const createdTask = await createTask(app, "Nintendo Switch 2"); + const candidatesResponse = await app.inject({ + method: "GET", + url: `/api/tasks/${createdTask.taskId}/candidates` + }); + const firstCandidateId = candidatesResponse.json().candidates.tmall[0].candidateId; + + const confirmResponse = await app.inject({ + method: "POST", + url: `/api/tasks/${createdTask.taskId}/confirm`, + payload: { + selections: [ + { + platform: "tmall", + candidateIds: [firstCandidateId] + } + ] + } + }); + + expect(confirmResponse.statusCode).toBe(200); + expect(confirmResponse.json().task.taskStatus).toBe("Running"); + expect(confirmResponse.json().task.taskStage).toBe("session_check"); + expect(confirmResponse.json().task.defaultReportVersion).toBeUndefined(); + + const completedTask = await waitForTask( + app, + createdTask.taskId, + (task) => task.taskStatus === "Completed" + ); + + expect(completedTask.defaultReportVersion).toBe(1); + expect(completedTask.reportVersions).toEqual([1]); + + await app.close(); + }); + it("records strategy attempts and observability metrics for platform search", async () => { const app = createServer(); await app.ready(); @@ -1239,7 +1439,6 @@ describe("API server", () => { method: "GET", url: `/api/tasks/${createdTask.taskId}/audit` }); - expect(auditResponse.statusCode).toBe(200); expect(auditResponse.json().audit).toEqual( expect.arrayContaining([ @@ -1277,6 +1476,61 @@ describe("API server", () => { await app.close(); }); + it("auto-resumes JD SearchBlocked tasks after the managed session passes health check", async () => { + const app = createServer({ + jdLiveService: createJdLiveServiceStub() + }); + await app.ready(); + + const createdTask = await createTask(app, "iPhone 15 Pro"); + + expect( + createdTask.platformRuns.find((run: { platform: string }) => run.platform === "jd") + ).toMatchObject({ + platform: "jd", + status: "SearchBlocked" + }); + + const importResponse = await app.inject({ + method: "POST", + url: "/api/ops/jd/session-manager/session", + payload: { + cookieHeader: "thor=masked; pin=masked;", + detailTemplateUrl: + "https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness&body=%7B%22skuId%22:%22100068388533%22%7D", + reviewsTemplateUrl: + "https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22sku%22:100068388533%7D" + } + }); + + expect(importResponse.statusCode).toBe(200); + + const recoveredTask = await waitForTask( + app, + createdTask.taskId, + (task) => + task.platformRuns.some( + (run: { platform: string; status: string }) => + run.platform === "jd" && run.status === "AwaitingSelection" + ) + ); + + expect( + recoveredTask.platformRuns.find((run: { platform: string }) => run.platform === "jd") + ).toMatchObject({ + platform: "jd", + status: "AwaitingSelection" + }); + + const candidatesResponse = await app.inject({ + method: "GET", + url: `/api/tasks/${createdTask.taskId}/candidates` + }); + expect(candidatesResponse.json().candidates.jd).toHaveLength(1); + + await app.close(); + }); + it("publishes a new report version when a blocked platform recovers successfully", async () => { const app = createServer(); await app.ready(); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 349685a..99ca82c 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -16,6 +16,11 @@ import type { JdSessionManager, JdSessionManagerAutoMode } from "./platforms/jd/types"; +import { + JdOpsQrLoginService, + TmallOpsQrLoginService, + type OpsQrLoginController +} from "./ops/qr-login"; import { TmallLiveSessionService, isTmallLiveError } from "./platforms/tmall/live-session"; import { TmallSessionManagerService } from "./platforms/tmall/session-manager"; import type { @@ -30,8 +35,11 @@ export function createServer( storagePath?: string; jdLiveService?: JdLiveService; jdSessionManager?: JdSessionManager; + jdQrLoginService?: OpsQrLoginController; tmallLiveService?: TmallLiveService; tmallSessionManager?: TmallSessionManager; + tmallQrLoginService?: OpsQrLoginController; + executionMode?: "synchronous" | "background"; } = {} ) { const app = Fastify({ logger: false }); @@ -43,12 +51,17 @@ export function createServer( (process.env.NODE_ENV === "test" ? undefined : resolve(process.cwd(), ".data", "task-store.json")); - const store = new InMemoryTaskStore({ storagePath, jdLiveService, tmallLiveService }); + const store = new InMemoryTaskStore({ + storagePath, + jdLiveService, + tmallLiveService, + executionMode: options.executionMode + }); const jdSessionManager = options.jdSessionManager ?? new JdSessionManagerService(jdLiveService, { onSessionReady: () => { - store.preparePlatform("jd"); + store.notifyManagedSessionReady("jd"); }, onSessionUnavailable: () => { store.clearPlatformSession("jd"); @@ -64,11 +77,27 @@ export function createServer( store.clearPlatformSession("tmall"); } }); + const jdQrLoginService = + options.jdQrLoginService ?? + new JdOpsQrLoginService(jdSessionManager, { + resolveSearchQuery: () => jdSessionManager.getState().heartbeatQuery + }); + const tmallQrLoginService = + options.tmallQrLoginService ?? + new TmallOpsQrLoginService(tmallSessionManager, { + resolveTargetItemId: () => tmallSessionManager.getState().heartbeatItemId + }); + if ( + jdLiveService instanceof JdLiveSessionService && + jdQrLoginService instanceof JdOpsQrLoginService + ) { + jdLiveService.setBrowserPreviewProvider(jdQrLoginService); + } store.setJdSessionManager(jdSessionManager); store.setTmallSessionManager(tmallSessionManager); if (jdLiveService.getSessionSummary().configured && !store.getSession("jd").ready) { - store.preparePlatform("jd"); + store.notifyManagedSessionReady("jd"); } if (tmallLiveService.getSessionSummary().configured && !store.getSession("tmall").ready) { store.preparePlatform("tmall"); @@ -77,6 +106,8 @@ export function createServer( app.addHook("onClose", async () => { jdSessionManager.shutdown(); tmallSessionManager.shutdown(); + await jdQrLoginService.shutdown(); + await tmallQrLoginService.shutdown(); }); app.register(cors, { origin: true }); @@ -211,9 +242,13 @@ export function createServer( }; }>("/api/ops/jd/session-manager/session", async (request, reply) => { try { - const manager = await jdSessionManager.importManualSession(request.body, "ops-manual"); + await jdSessionManager.importManualSession(request.body, "ops-manual"); + const result = await jdSessionManager.runHealthCheck("ops-manual"); reply.code(200); - return { manager }; + return { + manager: result.state, + recovered: result.recovered + }; } catch (error) { reply.code(isJdLiveError(error) ? error.statusCode : 400); return { @@ -226,6 +261,42 @@ export function createServer( manager: jdSessionManager.clearManagedSession("ops-manual-clear") })); + app.get("/api/ops/jd/session-manager/qr-login", async () => ({ + qrLogin: jdQrLoginService.getState() + })); + + app.post("/api/ops/jd/session-manager/qr-login/start", async (_request, reply) => { + try { + const qrLogin = await jdQrLoginService.start(); + reply.code(200); + return { qrLogin }; + } catch (error) { + reply.code(500); + return { + message: error instanceof Error ? error.message : "JD QR login start failed." + }; + } + }); + + app.post("/api/ops/jd/session-manager/qr-login/cancel", async (_request, reply) => { + const qrLogin = await jdQrLoginService.cancel("ops-cancel"); + reply.code(200); + return { qrLogin }; + }); + + app.post("/api/ops/jd/session-manager/qr-login/resume", async (_request, reply) => { + try { + const qrLogin = await jdQrLoginService.resumeManualRecovery(); + reply.code(200); + return { qrLogin }; + } catch (error) { + reply.code(500); + return { + message: error instanceof Error ? error.message : "JD QR login manual recovery failed." + }; + } + }); + app.get("/api/ops/tmall/session-manager", async () => ({ manager: tmallSessionManager.getState() })); @@ -290,6 +361,29 @@ export function createServer( manager: tmallSessionManager.clearManagedSession("ops-manual-clear") })); + app.get("/api/ops/tmall/session-manager/qr-login", async () => ({ + qrLogin: tmallQrLoginService.getState() + })); + + app.post("/api/ops/tmall/session-manager/qr-login/start", async (_request, reply) => { + try { + const qrLogin = await tmallQrLoginService.start(); + reply.code(200); + return { qrLogin }; + } catch (error) { + reply.code(500); + return { + message: error instanceof Error ? error.message : "Tmall QR login start failed." + }; + } + }); + + app.post("/api/ops/tmall/session-manager/qr-login/cancel", async (_request, reply) => { + const qrLogin = await tmallQrLoginService.cancel("ops-cancel"); + reply.code(200); + return { qrLogin }; + }); + app.get("/api/platforms/tmall/live-session", async () => ({ session: tmallLiveService.getSessionSummary() })); @@ -307,6 +401,7 @@ export function createServer( }>("/api/platforms/jd/live-session", async (request, reply) => { try { await jdSessionManager.importManualSession(request.body, "legacy-live-session"); + await jdSessionManager.runHealthCheck("legacy-live-session"); reply.code(200); return { session: jdLiveService.getSessionSummary() }; } catch (error) { @@ -806,15 +901,28 @@ export function createServer( return { message: "Task not found." }; } + reply.hijack(); reply.raw.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", - Connection: "keep-alive" + Connection: "keep-alive", + "Access-Control-Allow-Origin": request.headers.origin ?? "*", + Vary: "Origin" + }); + const writeSnapshot = (nextTask = task) => { + reply.raw.write(`event: task.snapshot\n`); + reply.raw.write(`data: ${JSON.stringify({ task: nextTask })}\n\n`); + }; + const unsubscribe = store.subscribeToTask(request.params.taskId, writeSnapshot); + const heartbeat = setInterval(() => { + reply.raw.write(`: keep-alive\n\n`); + }, 15000); + + writeSnapshot(task); + request.raw.on("close", () => { + clearInterval(heartbeat); + unsubscribe(); }); - reply.raw.write(`event: task.snapshot\n`); - reply.raw.write(`data: ${JSON.stringify(task)}\n\n`); - reply.raw.end(); - return reply; }); return app; diff --git a/apps/api/src/store.ts b/apps/api/src/store.ts index 5439128..824e0ec 100644 --- a/apps/api/src/store.ts +++ b/apps/api/src/store.ts @@ -282,12 +282,15 @@ function isReportableTaskStatus( return status === "Completed" || status === "PartialCompleted"; } +type TaskExecutionMode = "synchronous" | "background"; + type InMemoryTaskStoreOptions = { storagePath?: string | undefined; jdLiveService?: JdLiveService | undefined; jdSessionManager?: JdSessionManager | undefined; tmallLiveService?: TmallLiveService | undefined; tmallSessionManager?: TmallSessionManager | undefined; + executionMode?: TaskExecutionMode | undefined; }; type TaskExecutionArtifact = { @@ -424,11 +427,18 @@ export class InMemoryTaskStore { Partial> >(); private readonly executionArtifacts = new Map(); + private readonly taskSubscribers = new Map< + string, + Set<(task: TaskRecord) => void> + >(); + private readonly pendingExecutions = new Map>(); + private readonly pendingManagedSessionRetries = new Set(); private readonly storagePath: string | undefined; private readonly jdLiveService: JdLiveService | undefined; private jdSessionManager: JdSessionManager | undefined; private readonly tmallLiveService: TmallLiveService | undefined; private tmallSessionManager: TmallSessionManager | undefined; + private readonly executionMode: TaskExecutionMode; constructor(options: InMemoryTaskStoreOptions = {}) { this.storagePath = options.storagePath; @@ -436,6 +446,8 @@ export class InMemoryTaskStore { this.jdSessionManager = options.jdSessionManager; this.tmallLiveService = options.tmallLiveService; this.tmallSessionManager = options.tmallSessionManager; + this.executionMode = + options.executionMode ?? (process.env.NODE_ENV === "test" ? "synchronous" : "background"); const persistedState = this.loadPersistedState(); if (persistedState) { @@ -483,6 +495,12 @@ export class InMemoryTaskStore { return this.toSessionRecord(next); } + notifyManagedSessionReady(platform: PlatformId): SessionStateRecord { + const session = this.preparePlatform(platform); + void this.resumeTasksWaitingForManagedSession(platform); + return session; + } + clearPlatformSession(platform: PlatformId): void { const cleared = createMissingSession(platform); this.readiness.set(platform, cleared); @@ -567,6 +585,24 @@ export class InMemoryTaskStore { return this.tasks.get(taskId); } + subscribeToTask(taskId: string, listener: (task: TaskRecord) => void): () => void { + const listeners = this.taskSubscribers.get(taskId) ?? new Set<(task: TaskRecord) => void>(); + listeners.add(listener); + this.taskSubscribers.set(taskId, listeners); + + return () => { + const currentListeners = this.taskSubscribers.get(taskId); + if (!currentListeners) { + return; + } + + currentListeners.delete(listener); + if (currentListeners.size === 0) { + this.taskSubscribers.delete(taskId); + } + }; + } + getCandidates(taskId: string): Record | undefined { return this.tasks.get(taskId)?.platformCandidates; } @@ -714,6 +750,12 @@ export class InMemoryTaskStore { task.taskStage = "session_check"; this.pushEvent(task, "task.running", "系统开始执行抓取前校验。"); + if (this.executionMode === "background") { + this.persistState(); + this.scheduleTaskExecution(task.taskId); + return task; + } + await this.executeSelectedPlatforms(task, selectedRuns); task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns); task.updatedAt = nowIso(); @@ -1664,6 +1706,44 @@ export class InMemoryTaskStore { this.pushEvent(task, "task.awaiting_confirmation", "候选召回完成,等待人工确认。"); } + private async resumeTasksWaitingForManagedSession(platform: PlatformId): Promise { + const taskIds = Array.from(this.tasks.values()) + .filter((task) => this.shouldResumeTaskAfterManagedSessionReady(task, platform)) + .map((task) => task.taskId); + + for (const taskId of taskIds) { + const retryKey = `${taskId}:${platform}`; + if (this.pendingManagedSessionRetries.has(retryKey)) { + continue; + } + + this.pendingManagedSessionRetries.add(retryKey); + try { + await this.retryPlatform(taskId, platform); + } catch { + // retryPlatform already records recoverable failures on the task. + } finally { + this.pendingManagedSessionRetries.delete(retryKey); + } + } + } + + private shouldResumeTaskAfterManagedSessionReady( + task: TaskRecord, + platform: PlatformId + ): boolean { + const run = task.platformRuns.find((item) => item.platform === platform); + if (!run) { + return false; + } + + if (run.status === "SearchBlocked") { + return true; + } + + return run.status === "Blocked" && run.selectedCandidateIds.length > 0; + } + private buildReport(task: TaskRecord): ReportSnapshot { const reportVersion = this.getNextReportVersion(task); const completedRuns = task.platformRuns.filter((run) => run.status === "Completed"); @@ -2064,6 +2144,11 @@ export class InMemoryTaskStore { run.status = "Searching"; run.reason = undefined; run.lastUpdatedAt = nowIso(); + this.pushEvent( + task, + `platform.${run.platform}.searching`, + `${platformCatalogMap[run.platform].label} 正在执行候选搜索。` + ); let candidates: CandidateRecord[] = []; if (run.platform === "jd" && this.hasConfiguredJdLiveSession()) { @@ -2963,6 +3048,85 @@ export class InMemoryTaskStore { }; task.events.push(event); task.updatedAt = event.createdAt; + this.emitTaskSnapshot(task); + } + + private emitTaskSnapshot(task: TaskRecord): void { + const listeners = this.taskSubscribers.get(task.taskId); + if (!listeners || listeners.size === 0) { + return; + } + + for (const listener of listeners) { + listener(task); + } + } + + private scheduleTaskExecution(taskId: string): void { + if (this.pendingExecutions.has(taskId)) { + return; + } + + const execution = new Promise((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 { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 727ec3b..fae04ec 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -29,6 +29,7 @@ import { clearJdSessionManagerConfig, clearPlatformSession, confirmTask, + createTaskEventsSource, createTask, deleteTask, getJdKeywordPreview, @@ -80,6 +81,50 @@ function isRetryablePlatformStatus( 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) => { + 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( taskId: string, taskStatus: TaskStatus, @@ -552,6 +597,8 @@ function CandidateCard(props: { function ConfirmPage() { const navigate = useNavigate(); const { taskId = "" } = useParams(); + const queryClient = useQueryClient(); + useTaskLiveSync(taskId, { includeCandidates: true }); const taskQuery = useQuery({ queryKey: ["task", taskId], queryFn: () => getTask(taskId) @@ -590,6 +637,16 @@ function ConfirmPage() { 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) { return ( @@ -625,9 +682,27 @@ function ConfirmPage() {

{platformRun?.reason ?? "当前没有候选结果。"}

{platformRun?.status === "SearchBlocked" ? ( isOpsManagedPlatform(platform) ? ( - - 京东阻塞恢复已切到运维后台,当前页面不提供直接恢复入口。 - + <> + + 京东阻塞恢复已切到运维后台,当前页面不提供直接恢复入口。 + +
+ + 去运维恢复 + + +
+ ) : ( ) ) : null} + {platformRun?.status === "Failed" ? ( +
+ +
+ ) : null} ) : (
@@ -683,6 +770,7 @@ function ConfirmPage() { function RunPage() { const queryClient = useQueryClient(); const { taskId = "" } = useParams(); + useTaskLiveSync(taskId); const taskQuery = useQuery({ queryKey: ["task", taskId], queryFn: () => getTask(taskId) diff --git a/apps/web/src/NewTaskPage.test.tsx b/apps/web/src/NewTaskPage.test.tsx index b5cda64..66085d6 100644 --- a/apps/web/src/NewTaskPage.test.tsx +++ b/apps/web/src/NewTaskPage.test.tsx @@ -1,11 +1,13 @@ 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 type { ReactNode } from "react"; import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("./lib/api", () => ({ + cancelJdQrLogin: vi.fn(), + cancelTmallQrLogin: vi.fn(), clearJdManagedSession: vi.fn(), clearJdSessionManagerConfig: vi.fn(), clearPlatformSession: vi.fn(), @@ -13,9 +15,15 @@ vi.mock("./lib/api", () => ({ clearTmallSessionManagerConfig: vi.fn(), confirmTask: vi.fn(), createTask: vi.fn(), + createTaskEventsSource: vi.fn(() => ({ + addEventListener: vi.fn(), + close: vi.fn(), + removeEventListener: vi.fn() + })), deleteTask: vi.fn(), getJdKeywordPreview: vi.fn(), getJdLiveSession: vi.fn(), + getJdQrLoginState: vi.fn(), getJdSessionManager: vi.fn(), getHistoryTasks: vi.fn(), getPlatformReadiness: vi.fn(), @@ -24,37 +32,50 @@ vi.mock("./lib/api", () => ({ getTaskCandidates: vi.fn(), getTaskReport: vi.fn(), getTmallLiveSession: vi.fn(), + getTmallQrLoginState: vi.fn(), getTmallSessionManager: vi.fn(), importJdManagedSession: vi.fn(), importTmallManagedSession: vi.fn(), preparePlatform: vi.fn(), + resumeJdQrLoginManualRecovery: vi.fn(), runJdSessionManagerHealthCheck: vi.fn(), runJdSessionManagerRecovery: vi.fn(), runTmallSessionManagerHealthCheck: vi.fn(), retryTaskPlatform: vi.fn(), + startJdQrLogin: vi.fn(), + startTmallQrLogin: vi.fn(), updateJdSessionManagerConfig: vi.fn(), updateTmallSessionManagerConfig: vi.fn() })); import { App, NewTaskPage } from "./App"; import { + cancelJdQrLogin, + cancelTmallQrLogin, clearJdManagedSession, clearPlatformSession, clearTmallManagedSession, getJdKeywordPreview, getJdLiveSession, + getJdQrLoginState, getJdSessionManager, getHistoryTasks, getPlatformReadiness, getPlatformSession, + getTask, + getTaskCandidates, getTmallLiveSession, + getTmallQrLoginState, getTmallSessionManager, importJdManagedSession, importTmallManagedSession, preparePlatform, + createTaskEventsSource, runJdSessionManagerHealthCheck, runJdSessionManagerRecovery, runTmallSessionManagerHealthCheck, + startJdQrLogin, + startTmallQrLogin, updateJdSessionManagerConfig } from "./lib/api"; @@ -219,6 +240,14 @@ describe("task composer and session console", () => { } } } as any); + vi.mocked(getJdQrLoginState).mockResolvedValue({ + qrLogin: { + platform: "jd", + status: "idle", + note: "尚未启动扫码登录。", + sessionImported: false + } + } as any); vi.mocked(getJdKeywordPreview).mockResolvedValue({ preview: { query: "小米手环10", @@ -323,6 +352,14 @@ describe("task composer and session console", () => { vi.mocked(getTmallLiveSession).mockResolvedValue({ session: tmallManagerState.session } as any); + vi.mocked(getTmallQrLoginState).mockResolvedValue({ + qrLogin: { + platform: "tmall", + status: "idle", + note: "尚未启动扫码登录。", + sessionImported: false + } + } as any); vi.mocked(clearPlatformSession).mockResolvedValue(undefined); vi.mocked(importJdManagedSession).mockResolvedValue({ manager: { @@ -408,10 +445,46 @@ describe("task composer and session console", () => { state: jdManagerState, recovered: true } 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({ state: tmallManagerState, recovered: false } 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({ platform: "jd", session_ready: true, @@ -480,6 +553,134 @@ describe("task composer and session console", () => { expect(screen.getByText(/返回业务页面:\/tasks\/new/)).toBeInTheDocument(); }); + it("updates the confirm page when a task snapshot restores JD candidates", async () => { + let snapshotHandler: ((event: MessageEvent) => void) | undefined; + vi.mocked(createTaskEventsSource).mockReturnValue({ + addEventListener: vi.fn((_type: string, handler: EventListenerOrEventListenerObject) => { + snapshotHandler = handler as (event: MessageEvent) => 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(, ["/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 () => { const user = userEvent.setup(); diff --git a/apps/web/src/OpsSessionManagerPage.tsx b/apps/web/src/OpsSessionManagerPage.tsx index 159710d..c72d3de 100644 --- a/apps/web/src/OpsSessionManagerPage.tsx +++ b/apps/web/src/OpsSessionManagerPage.tsx @@ -4,23 +4,31 @@ import { useEffect, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { + cancelJdQrLogin, + cancelTmallQrLogin, clearJdManagedSession, clearJdSessionManagerConfig, clearTmallManagedSession, clearTmallSessionManagerConfig, getJdLiveSession, + getJdQrLoginState, getJdSessionManager, getTmallLiveSession, + getTmallQrLoginState, getTmallSessionManager, importJdManagedSession, importTmallManagedSession, runJdSessionManagerHealthCheck, runJdSessionManagerRecovery, runTmallSessionManagerHealthCheck, + resumeJdQrLoginManualRecovery, type JdLiveSessionInput, type JdSessionManagerConfigInput, + type OpsQrLoginState, type TmallLiveSessionInput, type TmallSessionManagerConfigInput, + startJdQrLogin, + startTmallQrLogin, updateJdSessionManagerConfig, updateTmallSessionManagerConfig } from "./lib/api"; @@ -52,6 +60,112 @@ function formatTimestamp(timestamp?: string) { 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 ( +
+

QR Login

+ {props.platformLabel}扫码登录 +

{props.platformHint}

+
+
+ {qrPreview ? ( + {`${props.platformLabel} + ) : ( +
{emptyState}
+ )} +
+
+
+
+ Status + {props.qrLogin?.status ?? "idle"} +
+
+ Target + {props.qrLogin?.targetId ?? "默认目标"} +
+
+ Updated + {formatTimestamp(props.qrLogin?.updatedAt)} +
+

{props.qrLogin?.note ?? "尚未启动扫码流程。"}

+ {props.qrLogin?.currentUrl ? ( +

{props.qrLogin.currentUrl}

+ ) : null} +
+
+ + + {canResumeManualRecovery ? ( + + ) : null} +
+
+
+
+ ); +} + export function OpsSessionManagerPage() { const queryClient = useQueryClient(); const [searchParams, setSearchParams] = useSearchParams(); @@ -102,6 +216,12 @@ export function OpsSessionManagerPage() { queryKey: ["jd-live-session"], 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({ queryKey: ["tmall-session-manager"], queryFn: getTmallSessionManager @@ -110,11 +230,18 @@ export function OpsSessionManagerPage() { queryKey: ["tmall-live-session"], 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 () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["jd-session-manager"] }), queryClient.invalidateQueries({ queryKey: ["jd-live-session"] }), + queryClient.invalidateQueries({ queryKey: ["jd-qr-login"] }), queryClient.invalidateQueries({ queryKey: ["platform-readiness"] }) ]); }; @@ -123,6 +250,7 @@ export function OpsSessionManagerPage() { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["tmall-session-manager"] }), queryClient.invalidateQueries({ queryKey: ["tmall-live-session"] }), + queryClient.invalidateQueries({ queryKey: ["tmall-qr-login"] }), queryClient.invalidateQueries({ queryKey: ["platform-readiness"] }) ]); }; @@ -157,6 +285,18 @@ export function OpsSessionManagerPage() { mutationFn: clearJdManagedSession, 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({ mutationFn: (payload: TmallSessionManagerConfigInput) => @@ -185,11 +325,21 @@ export function OpsSessionManagerPage() { mutationFn: clearTmallManagedSession, onSuccess: invalidateTmallOpsQueries }); + const tmallStartQrLoginMutation = useMutation({ + mutationFn: startTmallQrLogin, + onSuccess: invalidateTmallOpsQueries + }); + const tmallCancelQrLoginMutation = useMutation({ + mutationFn: cancelTmallQrLogin, + onSuccess: invalidateTmallOpsQueries + }); const jdManager = jdManagerQuery.data?.manager; const jdLiveSession = jdLiveSessionQuery.data?.session; + const jdQrLogin = jdQrLoginQuery.data?.qrLogin; const tmallManager = tmallManagerQuery.data?.manager; const tmallLiveSession = tmallLiveSessionQuery.data?.session; + const tmallQrLogin = tmallQrLoginQuery.data?.qrLogin; useEffect(() => { if (!jdManager || jdConfigDirty) { @@ -221,6 +371,22 @@ export function OpsSessionManagerPage() { }); }, [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 next = new URLSearchParams(searchParams); next.set("platform", platform); @@ -323,6 +489,18 @@ export function OpsSessionManagerPage() {

+ jdCancelQrLoginMutation.mutate()} + onResumeManualRecovery={() => jdResumeQrLoginMutation.mutate()} + onStart={() => jdStartQrLoginMutation.mutate()} + platformHint="后端会启动受控浏览器,实时截图京东登录二维码;扫码成功后会自动抓取 Cookie、详情模板和评论模板并导入当前运维会话。" + platformLabel="京东" + qrLogin={jdQrLogin} + resumePending={jdResumeQrLoginMutation.isPending} + startPending={jdStartQrLoginMutation.isPending} + /> +

Automation

自动恢复配置 @@ -604,6 +782,10 @@ export function OpsSessionManagerPage() { Live Session {jdLiveSession?.configured ? "已导入" : "未导入"}
+
+ QR Login + {jdQrLogin?.status ?? "idle"} +
Detail Template @@ -656,6 +838,24 @@ export function OpsSessionManagerPage() { {jdClearSessionMutation.error.message}

) : null} + {jdQrLoginQuery.error instanceof Error ? ( +

{jdQrLoginQuery.error.message}

+ ) : null} + {jdStartQrLoginMutation.error instanceof Error ? ( +

+ {jdStartQrLoginMutation.error.message} +

+ ) : null} + {jdCancelQrLoginMutation.error instanceof Error ? ( +

+ {jdCancelQrLoginMutation.error.message} +

+ ) : null} + {jdResumeQrLoginMutation.error instanceof Error ? ( +

+ {jdResumeQrLoginMutation.error.message} +

+ ) : null}
) : ( @@ -670,6 +870,16 @@ export function OpsSessionManagerPage() {

+ tmallCancelQrLoginMutation.mutate()} + onStart={() => tmallStartQrLoginMutation.mutate()} + platformHint="后端会启动受控浏览器,实时截图淘宝/天猫登录二维码;扫码成功后会自动导入 Cookie,并优先抓取或回退生成评论模板。" + platformLabel="天猫" + qrLogin={tmallQrLogin} + startPending={tmallStartQrLoginMutation.isPending} + /> +

Automation

自动巡检配置 @@ -874,6 +1084,10 @@ export function OpsSessionManagerPage() { Live Session {tmallLiveSession?.configured ? "已导入" : "未导入"}
+
+ QR Login + {tmallQrLogin?.status ?? "idle"} +
Detail Template @@ -927,6 +1141,19 @@ export function OpsSessionManagerPage() { {tmallClearSessionMutation.error.message}

) : null} + {tmallQrLoginQuery.error instanceof Error ? ( +

{tmallQrLoginQuery.error.message}

+ ) : null} + {tmallStartQrLoginMutation.error instanceof Error ? ( +

+ {tmallStartQrLoginMutation.error.message} +

+ ) : null} + {tmallCancelQrLoginMutation.error instanceof Error ? ( +

+ {tmallCancelQrLoginMutation.error.message} +

+ ) : null}
)} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 525c3a4..70c67f8 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -37,6 +37,7 @@ a { .app-shell { display: grid; grid-template-columns: 280px minmax(0, 1fr); + align-items: start; min-height: 100vh; } @@ -44,6 +45,12 @@ a { display: flex; flex-direction: column; justify-content: space-between; + position: sticky; + top: 0; + align-self: start; + height: 100vh; + height: 100dvh; + overflow: hidden; padding: 32px 24px; border-right: 1px solid rgba(31, 42, 48, 0.08); background: rgba(251, 248, 242, 0.72); @@ -75,6 +82,7 @@ a { .main-content { display: grid; gap: 24px; + min-height: 100vh; padding: 32px; } @@ -669,6 +677,39 @@ a { padding: 0 12px; border-radius: 999px; 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); font-size: 13px; font-weight: 700; @@ -711,3 +752,7 @@ a { display: none; } } + .qr-login-card { + grid-template-columns: 1fr; + } + diff --git a/package-lock.json b/package-lock.json index c519610..2f16e62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,8 @@ "@cross-ai/domain": "file:../../packages/domain", "@cross-ai/report-schema": "file:../../packages/report-schema", "@fastify/cors": "^11.2.0", - "fastify": "^5.8.4" + "fastify": "^5.8.4", + "playwright-core": "^1.59.1" } }, "apps/web": { @@ -3404,6 +3405,18 @@ "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": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",