Compare commits
No commits in common. "c6885502990d05f21a659f279153d9eedba6a1f7" and "fba6dd527214947cbb5ddf36d62a4e0ddc6505a9" have entirely different histories.
c688550299
...
fba6dd5272
3
.gitignore
vendored
3
.gitignore
vendored
@ -173,7 +173,4 @@ cython_debug/
|
||||
.codex-config.staged.toml
|
||||
.turbo/
|
||||
node_modules/
|
||||
apps/api/.data/
|
||||
.data/
|
||||
.tmp/
|
||||
|
||||
|
||||
10
AGENT.md
10
AGENT.md
@ -14,8 +14,6 @@
|
||||
- `docs/tdd.md`
|
||||
- `docs/tasks.md`
|
||||
|
||||
其中 `docs/tasks.md` 只负责任务拆解;项目进度、当前状态和阻塞统一维护在仓库根目录 `TODO.md`,不要继续写回 `docs/tasks.md`。
|
||||
|
||||
可选文档按需要补充:
|
||||
|
||||
- `docs/Page_Design.md`
|
||||
@ -33,8 +31,8 @@
|
||||
4. 开发规划阶段:基于 `docs/PRD.md` 与 `docs/FeatureSummary.md` 产出 `docs/DevelopmentPlan.md`,覆盖技术选型、开发周期、MVP 范围。
|
||||
5. UI 设计阶段:基于 `docs/PRD.md`、`docs/FeatureSummary.md`、`docs/DevelopmentPlan.md` 产出 `docs/UIDesign.md`。优先使用 Gemini 3 Pro;若使用 Claude Code,则优先启用 frontend design skill。
|
||||
6. TDD 阶段:基于 `docs/PRD.md`、`docs/FeatureSummary.md`、`docs/DevelopmentPlan.md`、`docs/UIDesign.md` 产出 `docs/tdd.md`。
|
||||
7. 任务拆解阶段:基于 `docs/PRD.md`、`docs/FeatureSummary.md`、`docs/DevelopmentPlan.md`、`docs/UIDesign.md`、`docs/tdd.md` 产出 `docs/tasks.md`,只定义任务、依赖、验收和测试门禁。
|
||||
8. 开发阶段:只有在上述文档全部完成并通过审阅后,才开始编码;开发推进中的任务状态、阶段快照和阻塞项统一维护在仓库根目录 `TODO.md`。优先使用 Claude Code Opus 4.5;若不可用,则由 Codex 接续执行。
|
||||
7. 任务拆解阶段:基于 `docs/PRD.md`、`docs/FeatureSummary.md`、`docs/DevelopmentPlan.md`、`docs/UIDesign.md`、`docs/tdd.md` 产出 `docs/tasks.md`。
|
||||
8. 开发阶段:只有在上述文档全部完成并通过审阅后,才开始编码。优先使用 Claude Code Opus 4.5;若不可用,则由 Codex 接续执行。
|
||||
|
||||
## Review Workflow
|
||||
每份核心文档都必须经过 Claude、Gemini、Codex 三方审阅。三方审阅完成后,再由人工做一轮终审,确认是否进入下一阶段。
|
||||
@ -56,7 +54,7 @@
|
||||
不要只做语气润色或表面改写。
|
||||
|
||||
## Change Propagation
|
||||
上游文档变更后,必须同步检查下游文档是否失效。例如 `RequirementsDoc.md` 更新后,必须重新检查 `PRD.md`、`FeatureSummary.md`、`DevelopmentPlan.md`、`UIDesign.md`、`tdd.md`、`tasks.md` 是否仍然一致;若影响任务编号、当前排期或阶段判断,也要同步更新 `TODO.md`。禁止只改上游、不处理影响面。
|
||||
上游文档变更后,必须同步检查下游文档是否失效。例如 `RequirementsDoc.md` 更新后,必须重新检查 `PRD.md`、`FeatureSummary.md`、`DevelopmentPlan.md`、`UIDesign.md`、`tdd.md`、`tasks.md` 是否仍然一致。禁止只改上游、不处理影响面。
|
||||
|
||||
## Agent Rules
|
||||
在本仓库中,AI 代理不应跳过文档阶段直接写代码。即使用户要求尽快开发,也应先确认核心文档是否齐全。
|
||||
@ -114,6 +112,6 @@
|
||||
|
||||
- 变更是否与当前阶段目标一致
|
||||
- 文档与代码是否仍然相互对应
|
||||
- 是否遗漏下游文档或 `TODO.md` 进度同步
|
||||
- 是否遗漏下游文档同步
|
||||
- 是否混入无关改动
|
||||
- 提交信息是否符合中文 Conventional Commit 规范
|
||||
|
||||
@ -1,7 +1,3 @@
|
||||
# cross-platform_products_ai_analyst
|
||||
|
||||
对某一个商品,进行多平台聚合,分析其商品信息和评论
|
||||
|
||||
项目进度维护见 `TODO.md`,任务拆解见 `docs/tasks.md`。
|
||||
|
||||
京东商品详情与评论抓取链路研究见 `docs/JdDetailReviewsCrawler.md`。
|
||||
对某一个商品,进行多平台聚合,分析其商品信息和评论
|
||||
28
TODO.md
28
TODO.md
@ -1,7 +1,7 @@
|
||||
# TODO
|
||||
|
||||
- 更新时间:2026-04-07
|
||||
- 进度基线:2026-04-02 已完成一次 MVP 收敛;`npm run test`、`npm run typecheck` 通过;Web/API 实机流程已验证;JD 实时会话导入与 `search/detail/reviews` preview 已实机验证。2026-04-03 已补齐 JD detail/reviews 可换 SKU 模板回放、评论多页聚合去重与 `live-product-preview` API;同日已把 JD live session 导入页、实时 search、实时 detail/reviews 抓取、规则化分析与报告发布并入任务主链,最小闭环 `输入商品 -> 选择 -> 抓取 -> 分析 -> 结果展示` 已可通过 Web/API 跑通;同日也已通过 MCP 浏览器实时登录态验证天猫 `live-product-preview`,真实商品 `934454505228` 可抓到详情与评论。最新一轮已把京东登录态维护从普通用户页切到 `/ops/jd/session-manager` 运维页,并补齐后端 Session Manager 健康检查、自动恢复、手工注入与前后端回归测试;本轮继续将天猫也切入运维侧 Session Manager,新增 `/api/ops/tmall/session-manager` 与统一 `/ops/session-manager` 运维页,普通用户页不再暴露天猫登录和模板入口。随后已补齐天猫实时 `search` HTML 解析、`/api/platforms/tmall/live-search-preview` 与任务搜索主链接入,并新增 JD + 天猫双平台 live 主回归用例。最新补充了天猫 `Blocked -> retry success` 报告升版回归,以及 `SearchBlocked` 恢复后二次确认只补跑新恢复平台、不重复执行已完成平台的主链修复与回归。2026-04-07 已把确认后的主执行链切到开发环境后台异步执行,`/api/tasks/:taskId/events` 从一次性 snapshot 升级为持续 SSE,执行页已接入 `EventSource` 实时接收任务快照;本地热重载环境可直接边看页面边验证后端推进。同日已在统一 `/ops/session-manager` 运维页接入京东/天猫扫码登录,后端受控浏览器会实时截图真实登录二维码并在扫码成功后自动导入 Cookie 与模板到 Session Manager;目前已实机验证两平台都能进入 `waiting_for_scan`,完整人工扫码闭环仍待真实账号验收。仓库级 `npm run test` 与 `npm run typecheck` 已再次通过
|
||||
- 更新时间:2026-04-02
|
||||
- 进度基线:2026-04-02 已完成一次 MVP 收敛;`npm run test`、`npm run typecheck` 通过;Web/API 实机流程已验证;JD 实时会话导入与 `search/detail/reviews` preview 已实机验证
|
||||
- 关联文档:
|
||||
- `docs/tasks.md`
|
||||
- `docs/DevelopmentPlan.md`
|
||||
@ -21,8 +21,8 @@
|
||||
- [x] `S1-07` 新建任务页与全局会话准备入口落地
|
||||
- [x] `S2-01` 首个平台预检查与搜索适配器落地(MVP mock 版)
|
||||
- [x] `S2-05` 标准化 v1 与最小报告快照落地(规则版)
|
||||
- [ ] `S2-06` 单平台执行页闭环与回归包落地(进行中:新建 -> 确认 -> 异步执行 -> 报告已打通,执行页 SSE 实时更新已接入,回归包仍待继续补齐)
|
||||
- [ ] `S3-01` 第二平台 `precheck/search/detail/reviews` 适配器落地(进行中:天猫商品详情已改为登录态 HTML/SSR 内嵌状态解析,评论已确认 `mtop.taobao.rate.detaillist.get` 并在 `apps/api` 补齐 `_m_h5_tk` 重签名回放;已通过 MCP 浏览器实时登录态实机验证真实商品 `934454505228` 的 `live-product-preview` 可返回详情与评论;本轮已补齐天猫运维侧 Session Manager、健康检查、统一运维页、实时 `search` HTML 解析、`live-search-preview` API 与任务搜索主链接入;当前待补真实 fixture/HAR)
|
||||
- [ ] `S2-06` 单平台执行页闭环与回归包落地(进行中:闭环已可演示,回归包与真实异步执行待补)
|
||||
- [ ] `S3-01` 第二平台 `precheck/search/detail/reviews` 适配器落地(进行中:当前双平台仍以 mock 适配为主)
|
||||
- [ ] `S3-03` 阻塞恢复与 `L3 Browser Recovery` 落地(进行中:恢复页与重试链路已通,真实远程浏览器接管待补)
|
||||
- [ ] `S4-02` AI 结构化报告生成与版本规则落地(进行中:版本规则已落地,真实 AI 生成待接入)
|
||||
- [ ] `S4-05` 留存、删除 API 与联动清理链路落地(进行中:删除 API 与 30/90 天本地清理作业已落地,对象存储联动待补)
|
||||
@ -32,8 +32,8 @@
|
||||
|
||||
- [ ] `S0` 双平台能力矩阵、fixture/HAR、PoC 验证与 `strategy_attempts` 口径仍未冻结(进行中)
|
||||
- [ ] `S1` 本地 JSON 持久化、API/BFF、会话准备、新建任务页与状态机骨架已可用,但数据库、队列、真实 `SSE` 仍未完成(进行中)
|
||||
- [ ] `S2` 单平台最小闭环和最小报告已可演示,JD `search/detail/reviews` 已并入任务执行与报告主链,评论预算分配与 `40/30/30` 抽样已落地,开发环境后台异步执行与执行页实时更新已打通,但模板刷新与真实 AI 归纳仍待补(进行中)
|
||||
- [ ] `S3` 双平台候选确认、执行控制台、恢复页与平台级重试已可用,第二平台 `search/detail/reviews` 任务主链也已接通,但真实 fixture/HAR、`L2` 模板刷新与真实 `L3` 恢复未完成(进行中)
|
||||
- [ ] `S2` 单平台最小闭环和最小报告已可演示,JD `search/detail/reviews` 实时 preview 已验证,但任务执行与标准化主链仍以 mock 数据为主(进行中)
|
||||
- [ ] `S3` 双平台候选确认、执行控制台、恢复页与平台级重试已可用,但第二平台真实适配、`L2` 模板刷新与真实 `L3` 恢复未完成(进行中)
|
||||
- [ ] `S4` 报告版本规则、报告页、历史任务页、版本切换、删除入口与观测概览已落地,但完整聚合、真实 AI、对象存储联动与完整审计仍未完成(进行中)
|
||||
- [ ] `S5` 稳定性、性能、UAT、部署与发布准备尚未进入实施(未开始)
|
||||
|
||||
@ -51,7 +51,7 @@
|
||||
- [x] `S1-01` 共享领域模型与枚举包落地
|
||||
- [ ] `S1-02` 数据库、事件日志与对象存储模型落地(进行中:MVP 先落本地 JSON 持久化,正式数据库与对象存储待补)
|
||||
- [ ] `S1-03` 任务编排、事件持久化与状态机骨架落地(进行中:状态机、事件日志、平台级重试已可用,队列化执行待补)
|
||||
- [ ] `S1-04` API / BFF、平台就绪摘要与 `SSE` 基础接口落地(进行中:REST/BFF 已可用,任务级 `SSE` 已升级为持续推送,完整实时编排仍待补)
|
||||
- [ ] `S1-04` API / BFF、平台就绪摘要与 `SSE` 基础接口落地(进行中:REST/BFF 已可用,`SSE` 仍是最小 snapshot 形态)
|
||||
- [x] `S1-05` Web 工作台基础壳层与核心路由落地
|
||||
- [x] `S1-06` 会话中心 v1 与全局会话准备后端入口落地(MVP mock 版)
|
||||
- [x] `S1-07` 新建任务页与全局会话准备入口落地
|
||||
@ -61,19 +61,19 @@
|
||||
|
||||
- [x] `S2-01` 首个平台预检查与搜索适配器落地(MVP mock 版)
|
||||
- [x] `S2-02` 候选确认页与确认 API 落地
|
||||
- [ ] `S2-03` 单平台商品详情抓取链路落地(进行中:JD live detail 已并入任务执行主链,确认页选中的真实 SKU 会进入 `previewProduct` 抓取与报告证据生成;模板失效、风控拦截与会话失效已做结构化归类,`L2` 刷新待补)
|
||||
- [ ] `S2-04` 单平台评论采集与抽样链路落地(进行中:JD live reviews 已并入任务执行与报告主链,支持分页参数改写、多页聚合去重、重复页提前收口、候选级评论预算分配与 `40/30/30` 抽样;模板刷新待补)
|
||||
- [ ] `S2-03` 单平台商品详情抓取链路落地(进行中:JD live detail preview 已接入真实 `pc_detailpage_wareBusiness`,会话导入与解析已验证,待纳入任务执行与标准化主链)
|
||||
- [ ] `S2-04` 单平台评论采集与抽样链路落地(进行中:JD live reviews preview 已接入真实 `getLegoWareDetailComment`,分页参数改写与解析已验证,待纳入任务执行与抽样主链)
|
||||
- [x] `S2-05` 标准化 v1 与最小报告快照落地(规则版)
|
||||
- [ ] `S2-06` 单平台执行页闭环与回归包落地(进行中:新建 -> 确认 -> 异步执行 -> 报告已打通,执行页 SSE 实时更新已接入)
|
||||
- [ ] `S2-06` 单平台执行页闭环与回归包落地(进行中:新建 -> 确认 -> 执行 -> 报告已打通)
|
||||
|
||||
## `S3`
|
||||
|
||||
- [ ] `S3-01` 第二平台 `precheck/search/detail/reviews` 适配器落地(进行中:天猫详情已切到登录态 HTML/SSR 内嵌状态解析,评论走带 `_m_h5_tk` 重签名的 `rate.detaillist` 回放;`live-session`、`live-search/detail/reviews/product preview` 与任务执行主链回放已通过测试,待补真实 fixture/HAR)
|
||||
- [ ] `S3-01` 第二平台 `precheck/search/detail/reviews` 适配器落地(进行中)
|
||||
- [ ] `S3-02` 模板刷新与 `L2` 路径落地(未开始)
|
||||
- [ ] `S3-03` 阻塞恢复与 `L3 Browser Recovery` 落地(进行中)
|
||||
- [ ] `S3-04` 双平台候选确认与执行控制台落地(进行中:页面与状态展示已具备,真实并发执行待补)
|
||||
- [x] `S3-05` `PartialCompleted`、`Blocked`、`Failed` 汇总规则落地
|
||||
- [ ] `S3-06` 双平台主回归包落地(进行中:已新增 JD + 天猫 live 搜索、确认、执行、报告的主链 API 回归,并覆盖 `tmall SearchBlocked + jd Completed`、`tmall NoResult + jd Completed`、`tmall Blocked + jd Completed`、`tmall Blocked -> retry success -> report v2`、`tmall Blocked -> retry blocked -> report unchanged`、`tmall SearchBlocked -> retry success -> audit/metrics`,以及 `tmall SearchBlocked` 恢复后二次确认仅补跑新恢复平台的回归,待继续补更多失败/恢复组合)
|
||||
- [ ] `S3-06` 双平台主回归包落地(未开始)
|
||||
|
||||
## `S4`
|
||||
|
||||
@ -86,7 +86,7 @@
|
||||
|
||||
## `S5`
|
||||
|
||||
- [ ] `S5-01` 平台级定向重试稳定化(进行中:已补天猫 `SearchBlocked` 恢复后的审计与 `retryCount/recoveryCount` 回归,待继续扩展更多失败来源与版本差异检测)
|
||||
- [ ] `S5-01` 平台级定向重试稳定化(进行中)
|
||||
- [ ] `S5-02` 性能与成本优化(未开始)
|
||||
- [ ] `S5-03` UAT 与试运行任务集执行(未开始)
|
||||
- [ ] `S5-04` 部署、值守、排障与热修手册落地(未开始)
|
||||
@ -96,6 +96,6 @@
|
||||
|
||||
- [ ] `X-01` 上下游文档变更同步(进行中)
|
||||
- [ ] `X-02` 安全与合规检查(未开始)
|
||||
- [ ] `X-03` 测试资产维护(进行中:已补天猫搜索解析/服务回归,以及双平台 live 主链、`SearchBlocked`、`NoResult`、`Blocked`、`Blocked -> retry success`、`Blocked -> retry blocked -> report unchanged`、`SearchBlocked -> retry success -> audit/metrics` 与 `SearchBlocked` 恢复后二次确认不重复执行已完成平台的回归,真实 fixture/HAR 待补)
|
||||
- [ ] `X-03` 测试资产维护(进行中)
|
||||
- [ ] `X-04` 设计一致性与可访问性检查(进行中)
|
||||
- [ ] `X-05` 观测指标复盘(未开始)
|
||||
|
||||
@ -14,7 +14,6 @@
|
||||
"@cross-ai/domain": "file:../../packages/domain",
|
||||
"@cross-ai/report-schema": "file:../../packages/report-schema",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"fastify": "^5.8.4",
|
||||
"playwright-core": "^1.59.1"
|
||||
"fastify": "^5.8.4"
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,7 +19,6 @@ export function createMockCandidates(
|
||||
const storeName = platform === "tmall" ? "官方旗舰店" : "京东自营";
|
||||
const basePrice = platform === "tmall" ? 7999 : 8099;
|
||||
const slug = slugify(query);
|
||||
const tmallItemIds = ["833444005595", "833444005596", "833444005597"];
|
||||
|
||||
return Array.from({ length: 3 }, (_, index) => ({
|
||||
candidateId: `${platform}-${slug}-${index + 1}`,
|
||||
@ -28,10 +27,7 @@ export function createMockCandidates(
|
||||
price: basePrice + index * 120,
|
||||
priceLabel: `¥${basePrice + index * 120}`,
|
||||
storeName,
|
||||
productUrl:
|
||||
platform === "tmall"
|
||||
? `https://detail.tmall.com/item.htm?id=${tmallItemIds[index] ?? tmallItemIds[0]}`
|
||||
: `https://example.com/${platformName}/${slug}-${index + 1}`,
|
||||
productUrl: `https://example.com/${platformName}/${slug}-${index + 1}`,
|
||||
imageUrl: `https://placehold.co/640x480?text=${platformName.toUpperCase()}+${index + 1}`,
|
||||
salesHint:
|
||||
platform === "tmall"
|
||||
|
||||
@ -1,214 +0,0 @@
|
||||
import type { BrowserContext, Page, Request } from "playwright-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
JdLiveSessionInput,
|
||||
JdSessionManager,
|
||||
JdSessionManagerConfigInput,
|
||||
JdSessionManagerRunResult,
|
||||
JdSessionManagerState
|
||||
} from "../platforms/jd/types";
|
||||
import { JdOpsQrLoginService, takeLocatorScreenshotDataUrl } from "./qr-login";
|
||||
|
||||
const DEFAULT_TEST_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
|
||||
|
||||
type FakeResources = {
|
||||
context: Pick<BrowserContext, "cookies" | "pages" | "close" | "storageState">;
|
||||
page: Page;
|
||||
};
|
||||
|
||||
function createSessionManagerStub() {
|
||||
const importManualSession = vi.fn<
|
||||
(input: JdLiveSessionInput, source?: string) => Promise<JdSessionManagerState>
|
||||
>(async () => ({ status: "healthy" } as JdSessionManagerState));
|
||||
const runHealthCheck = vi.fn<(trigger?: string) => Promise<JdSessionManagerRunResult>>(
|
||||
async () =>
|
||||
({
|
||||
recovered: false,
|
||||
state: {
|
||||
status: "healthy"
|
||||
}
|
||||
}) as JdSessionManagerRunResult
|
||||
);
|
||||
|
||||
const stub: JdSessionManager = {
|
||||
getState(): JdSessionManagerState {
|
||||
return { status: "idle" } as JdSessionManagerState;
|
||||
},
|
||||
configure(_input: JdSessionManagerConfigInput): JdSessionManagerState {
|
||||
return { status: "idle" } as JdSessionManagerState;
|
||||
},
|
||||
clearConfig(): JdSessionManagerState {
|
||||
return { status: "idle" } as JdSessionManagerState;
|
||||
},
|
||||
importManualSession,
|
||||
clearManagedSession(): JdSessionManagerState {
|
||||
return { status: "idle" } as JdSessionManagerState;
|
||||
},
|
||||
runHealthCheck,
|
||||
async runAutoRecovery() {
|
||||
return {
|
||||
recovered: false,
|
||||
state: { status: "idle" } as JdSessionManagerState
|
||||
};
|
||||
},
|
||||
async handleLiveFailure() {
|
||||
return false;
|
||||
},
|
||||
shutdown() {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
stub,
|
||||
importManualSession,
|
||||
runHealthCheck
|
||||
};
|
||||
}
|
||||
|
||||
class TestableJdOpsQrLoginService extends JdOpsQrLoginService {
|
||||
setTestResources(resources: FakeResources) {
|
||||
this.setResources(resources as unknown as {
|
||||
browser: never;
|
||||
context: BrowserContext;
|
||||
page: Page;
|
||||
});
|
||||
}
|
||||
|
||||
setCapturedSession(session: Record<string, unknown>) {
|
||||
Object.assign(
|
||||
(this as unknown as { latestCapturedSession: Record<string, unknown> }).latestCapturedSession,
|
||||
session
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe("JdOpsQrLoginService", () => {
|
||||
it("skips a detached QR locator and captures the next visible selector", async () => {
|
||||
const staleLocator = {
|
||||
first: vi.fn(function (this: typeof staleLocator) {
|
||||
return this;
|
||||
}),
|
||||
count: vi.fn(async () => 1),
|
||||
isVisible: vi.fn(async () => true),
|
||||
screenshot: vi.fn(async () => {
|
||||
throw new Error("Element is not attached to the DOM");
|
||||
})
|
||||
};
|
||||
const liveScreenshot = Uint8Array.from([1, 2, 3]);
|
||||
const liveLocator = {
|
||||
first: vi.fn(function (this: typeof liveLocator) {
|
||||
return this;
|
||||
}),
|
||||
count: vi.fn(async () => 1),
|
||||
isVisible: vi.fn(async () => true),
|
||||
screenshot: vi.fn(async () => liveScreenshot)
|
||||
};
|
||||
const page = {
|
||||
locator: vi.fn((selector: string) => {
|
||||
return selector === ".stale-qr" ? staleLocator : liveLocator;
|
||||
})
|
||||
} as unknown as Page;
|
||||
|
||||
await expect(takeLocatorScreenshotDataUrl(page, [".stale-qr", ".live-qr"])).resolves.toBe(
|
||||
"data:image/png;base64,AQID"
|
||||
);
|
||||
expect(staleLocator.screenshot).toHaveBeenCalledTimes(1);
|
||||
expect(liveLocator.screenshot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("waits for a fresh search request during manual recovery and reimports captured headers", async () => {
|
||||
const { stub, importManualSession, runHealthCheck } = createSessionManagerStub();
|
||||
const service = new TestableJdOpsQrLoginService(stub);
|
||||
let currentUrl =
|
||||
"https://search.jd.com/Search?keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=old";
|
||||
let resolveRequest: ((request: Request) => void) | undefined;
|
||||
|
||||
const request = {
|
||||
url: () =>
|
||||
"https://api.m.jd.com/api?functionId=pc_search_searchWare&body=%7B%22keyword%22:%22%E5%A4%A7%E7%96%86pocket3%22%7D&keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=2.def.0.real&wq=%E5%A4%A7%E7%96%86pocket3&pvid=real-pvid&spmTag=real-spm",
|
||||
allHeaders: async () => ({
|
||||
":authority": "api.m.jd.com",
|
||||
referer:
|
||||
"https://search.jd.com/Search?keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=2.def.0.real&wq=%E5%A4%A7%E7%96%86pocket3&pvid=real-pvid&spmTag=real-spm",
|
||||
"accept-language": "zh-CN,zh;q=0.9",
|
||||
"sec-fetch-site": "same-site"
|
||||
})
|
||||
} as unknown as Request;
|
||||
|
||||
const page = {
|
||||
url: () => currentUrl,
|
||||
isClosed: () => false,
|
||||
waitForRequest: vi.fn(async () => {
|
||||
return await new Promise<Request>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
}),
|
||||
reload: vi.fn(async () => {
|
||||
currentUrl =
|
||||
"https://search.jd.com/Search?keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=2.def.0.real&wq=%E5%A4%A7%E7%96%86pocket3&pvid=real-pvid&spmTag=real-spm";
|
||||
resolveRequest?.(request);
|
||||
}),
|
||||
goto: vi.fn(async () => undefined),
|
||||
waitForLoadState: vi.fn(async () => undefined),
|
||||
waitForTimeout: vi.fn(async () => undefined),
|
||||
evaluate: vi.fn(async () => DEFAULT_TEST_USER_AGENT),
|
||||
close: vi.fn(async () => undefined)
|
||||
} as unknown as Page;
|
||||
|
||||
const context = {
|
||||
cookies: vi.fn(async () => [
|
||||
{
|
||||
name: "thor",
|
||||
value: "masked",
|
||||
domain: ".jd.com"
|
||||
}
|
||||
]),
|
||||
pages: vi.fn(() => [page]),
|
||||
close: vi.fn(async () => undefined),
|
||||
storageState: vi.fn(async () => ({
|
||||
cookies: [],
|
||||
origins: []
|
||||
}))
|
||||
} as unknown as Pick<BrowserContext, "cookies" | "pages" | "close" | "storageState">;
|
||||
|
||||
service.setTestResources({
|
||||
context,
|
||||
page
|
||||
});
|
||||
service.setCapturedSession({
|
||||
targetSkuId: "100068388533",
|
||||
targetSearchQuery: "大疆pocket3",
|
||||
targetProductUrl: "https://item.jd.com/100068388533.html",
|
||||
targetSearchUrl:
|
||||
"https://search.jd.com/Search?keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=old",
|
||||
detailTemplateUrl:
|
||||
"https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness&body=%7B%22skuId%22:%22100068388533%22%7D",
|
||||
reviewsTemplateUrl:
|
||||
"https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22sku%22:100068388533,%22page%22:1,%22commentNum%22:5%7D",
|
||||
searchCapturedAt: 1
|
||||
});
|
||||
|
||||
const state = await service.resumeManualRecovery();
|
||||
|
||||
expect(page.reload).toHaveBeenCalledTimes(1);
|
||||
expect(importManualSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cookieHeader: "thor=masked",
|
||||
userAgent: DEFAULT_TEST_USER_AGENT,
|
||||
searchApiTemplateUrl: request.url(),
|
||||
searchReferer:
|
||||
"https://search.jd.com/Search?keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=2.def.0.real&wq=%E5%A4%A7%E7%96%86pocket3&pvid=real-pvid&spmTag=real-spm",
|
||||
searchRequestHeaders: {
|
||||
"accept-language": "zh-CN,zh;q=0.9",
|
||||
"sec-fetch-site": "same-site"
|
||||
}
|
||||
}),
|
||||
"ops-qr-login-manual-recovery"
|
||||
);
|
||||
expect(runHealthCheck).toHaveBeenCalledWith("ops-qr-login-manual-recovery");
|
||||
expect(state.status).toBe("completed");
|
||||
expect(state.sessionImported).toBe(true);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,170 +0,0 @@
|
||||
import type { CandidateRecord } from "@cross-ai/domain";
|
||||
|
||||
import type { JdProductPreviewResult, JdSearchMode } from "./types";
|
||||
|
||||
const NON_ALPHANUMERIC_PATTERN = /[^\p{L}\p{N}]+/gu;
|
||||
const TOKEN_SPLIT_PATTERN = /[\s,,。/\\|+_:-]+/u;
|
||||
|
||||
export interface RankedJdKeywordCandidate {
|
||||
candidate: CandidateRecord;
|
||||
skuId: string;
|
||||
score: number;
|
||||
summary: string;
|
||||
matchedTokens: string[];
|
||||
}
|
||||
|
||||
export interface JdKeywordPreviewResult {
|
||||
query: string;
|
||||
search: {
|
||||
source: JdSearchMode;
|
||||
candidateCount: number;
|
||||
selected: RankedJdKeywordCandidate;
|
||||
alternatives: RankedJdKeywordCandidate[];
|
||||
};
|
||||
product: JdProductPreviewResult;
|
||||
}
|
||||
|
||||
function normalizeText(value: string): string {
|
||||
return value.toLowerCase().replace(NON_ALPHANUMERIC_PATTERN, "");
|
||||
}
|
||||
|
||||
function tokenizeQuery(query: string): string[] {
|
||||
const normalizedQuery = normalizeText(query);
|
||||
const rawTokens = query
|
||||
.split(TOKEN_SPLIT_PATTERN)
|
||||
.map((token) => normalizeText(token))
|
||||
.filter(Boolean);
|
||||
const uniqueTokens = new Set<string>();
|
||||
|
||||
for (const token of rawTokens) {
|
||||
if (token.length >= 2 || /[\u4e00-\u9fff]/u.test(token)) {
|
||||
uniqueTokens.add(token);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedQuery) {
|
||||
uniqueTokens.add(normalizedQuery);
|
||||
}
|
||||
|
||||
return [...uniqueTokens];
|
||||
}
|
||||
|
||||
function extractSkuId(candidate: CandidateRecord): string | null {
|
||||
const productUrlMatch = candidate.productUrl.match(/item\.jd\.com\/(\d+)\.html/i);
|
||||
if (productUrlMatch?.[1]) {
|
||||
return productUrlMatch[1];
|
||||
}
|
||||
|
||||
const candidateIdMatch = candidate.candidateId.match(/^jd-(\d+)$/i);
|
||||
return candidateIdMatch?.[1] ?? null;
|
||||
}
|
||||
|
||||
function isFallbackCandidate(candidate: CandidateRecord): boolean {
|
||||
return (
|
||||
candidate.candidateId.startsWith("jd-fallback-") ||
|
||||
candidate.highlights.some((highlight) => highlight.includes("需要刷新搜索模板")) ||
|
||||
candidate.salesHint.includes("未解析出稳定商品卡片")
|
||||
);
|
||||
}
|
||||
|
||||
function buildSelectionSummary(
|
||||
candidate: CandidateRecord,
|
||||
matchedTokens: string[],
|
||||
fullQueryMatched: boolean,
|
||||
index: number,
|
||||
hasSkuId: boolean
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (fullQueryMatched) {
|
||||
parts.push("标题完整命中关键词");
|
||||
} else if (matchedTokens.length > 0) {
|
||||
parts.push(`标题/卖点命中 ${matchedTokens.length} 个关键词片段`);
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
parts.push("位于搜索结果前列");
|
||||
}
|
||||
|
||||
if (hasSkuId) {
|
||||
parts.push("已解析出可回放 SKU");
|
||||
}
|
||||
|
||||
if (candidate.storeName.includes("自营")) {
|
||||
parts.push("店铺信息完整");
|
||||
}
|
||||
|
||||
return parts.join(";") || "沿用搜索结果排序命中候选";
|
||||
}
|
||||
|
||||
export function rankJdCandidatesForKeyword(
|
||||
query: string,
|
||||
candidates: CandidateRecord[]
|
||||
): RankedJdKeywordCandidate[] {
|
||||
const normalizedQuery = normalizeText(query);
|
||||
const tokens = tokenizeQuery(query);
|
||||
|
||||
return candidates
|
||||
.map((candidate, index) => {
|
||||
const skuId = extractSkuId(candidate);
|
||||
if (!skuId || isFallbackCandidate(candidate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = normalizeText(candidate.title);
|
||||
const storeName = normalizeText(candidate.storeName);
|
||||
const highlights = candidate.highlights.map((item) => normalizeText(item));
|
||||
const matchedTokens = tokens.filter(
|
||||
(token) =>
|
||||
title.includes(token) ||
|
||||
storeName.includes(token) ||
|
||||
highlights.some((highlight) => highlight.includes(token))
|
||||
);
|
||||
const fullQueryMatched = normalizedQuery.length > 0 && title.includes(normalizedQuery);
|
||||
let score = 0;
|
||||
|
||||
if (fullQueryMatched) {
|
||||
score += 120;
|
||||
}
|
||||
|
||||
score += matchedTokens.reduce(
|
||||
(sum, token) => sum + (token.length >= 4 || /[\u4e00-\u9fff]/u.test(token) ? 26 : 16),
|
||||
0
|
||||
);
|
||||
|
||||
if (matchedTokens.length === tokens.length && tokens.length > 1) {
|
||||
score += 36;
|
||||
}
|
||||
|
||||
if (/item\.jd\.com\/\d+\.html/i.test(candidate.productUrl)) {
|
||||
score += 8;
|
||||
}
|
||||
|
||||
if (candidate.price > 0) {
|
||||
score += 4;
|
||||
}
|
||||
|
||||
if (candidate.storeName.includes("自营")) {
|
||||
score += 4;
|
||||
}
|
||||
|
||||
score -= index * 2;
|
||||
|
||||
return {
|
||||
candidate,
|
||||
skuId,
|
||||
score,
|
||||
summary: buildSelectionSummary(candidate, matchedTokens, fullQueryMatched, index, true),
|
||||
matchedTokens
|
||||
} satisfies RankedJdKeywordCandidate;
|
||||
})
|
||||
.filter((candidate): candidate is RankedJdKeywordCandidate => Boolean(candidate))
|
||||
.sort((left, right) => right.score - left.score);
|
||||
}
|
||||
|
||||
export function selectJdCandidateForKeyword(
|
||||
query: string,
|
||||
candidates: CandidateRecord[]
|
||||
): RankedJdKeywordCandidate | null {
|
||||
return rankJdCandidatesForKeyword(query, candidates)[0] ?? null;
|
||||
}
|
||||
@ -1,509 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { JdLiveSessionService } from "./live-session";
|
||||
|
||||
function buildResponse(body: Record<string, unknown>): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("JdLiveSessionService", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("rebinds detail templates to the requested sku", async () => {
|
||||
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
|
||||
buildResponse({
|
||||
skuHeadVO: {
|
||||
skuTitle: "Apple iPhone 15"
|
||||
},
|
||||
price: {
|
||||
p: "4398.00"
|
||||
}
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const service = new JdLiveSessionService();
|
||||
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(preview.detail).toMatchObject({
|
||||
skuId: "100068388535",
|
||||
title: "Apple iPhone 15",
|
||||
price: "4398.00"
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const firstCall = fetchMock.mock.calls[0];
|
||||
expect(firstCall).toBeDefined();
|
||||
const requestUrl = firstCall?.[0];
|
||||
const requestInit = firstCall?.[1];
|
||||
if (!requestUrl || !requestInit) {
|
||||
throw new Error("Expected fetch to receive both url and init.");
|
||||
}
|
||||
expect(decodeURIComponent(new URL(requestUrl).searchParams.get("body") ?? "")).toContain(
|
||||
'"skuId":"100068388535"'
|
||||
);
|
||||
expect(requestInit.headers).toMatchObject({
|
||||
Referer: "https://item.jd.com/100068388535.html"
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses imported JD search API templates and rewrites query parameters", async () => {
|
||||
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
|
||||
buildResponse({
|
||||
data: {
|
||||
wareList: [
|
||||
{
|
||||
skuId: "100068388533",
|
||||
imageurl: "//img14.360buyimg.com/n7/jfs/t1/example.jpg",
|
||||
wname: "大疆 Pocket 3 全能套装",
|
||||
good: "99%",
|
||||
jdPrice: "5199.00",
|
||||
shopName: "京东自营"
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const service = new JdLiveSessionService();
|
||||
service.importSession({
|
||||
cookieHeader: "thor=masked;",
|
||||
searchApiTemplateUrl:
|
||||
"https://api.m.jd.com/api?functionId=pc_search_searchWare&body=%7B%22keyword%22:%22iPhone%2015%22,%22wq%22:%22iPhone%2015%22,%22page%22:1%7D&keyword=iPhone%2015&enc=utf-8&suggest=2.def.0.test&wq=iPhone%2015&pvid=test-pvid&spmTag=test-spm",
|
||||
searchRequestHeaders: {
|
||||
":authority": "api.m.jd.com",
|
||||
"accept-language": "zh-CN,zh;q=0.9",
|
||||
"sec-fetch-site": "same-site",
|
||||
cookie: "should-be-ignored"
|
||||
}
|
||||
});
|
||||
|
||||
const preview = await service.previewSearch("大疆pocket3");
|
||||
|
||||
expect(preview.source).toBe("api");
|
||||
expect(preview.candidateCount).toBe(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const requestUrl = fetchMock.mock.calls[0]?.[0];
|
||||
const requestInit = fetchMock.mock.calls[0]?.[1];
|
||||
if (!requestUrl || !requestInit) {
|
||||
throw new Error("Expected JD search preview to call fetch with url and init.");
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(requestUrl);
|
||||
const decodedBody = decodeURIComponent(parsedUrl.searchParams.get("body") ?? "");
|
||||
expect(parsedUrl.searchParams.get("keyword")).toBe("大疆pocket3");
|
||||
expect(parsedUrl.searchParams.get("wq")).toBe("大疆pocket3");
|
||||
expect(parsedUrl.searchParams.get("pvid")).toBe("test-pvid");
|
||||
expect(decodedBody).toContain(`"keyword":"${parsedUrl.searchParams.get("keyword")}"`);
|
||||
expect(decodedBody).toContain(`"wq":"${parsedUrl.searchParams.get("wq")}"`);
|
||||
expect(requestInit.headers).toMatchObject({
|
||||
Referer: expect.stringContaining("keyword=%E5%A4%A7%E7%96%86pocket3"),
|
||||
"User-Agent": expect.any(String),
|
||||
"accept-language": "zh-CN,zh;q=0.9",
|
||||
"sec-fetch-site": "same-site"
|
||||
});
|
||||
expect(requestInit.headers).not.toHaveProperty(":authority");
|
||||
});
|
||||
|
||||
it("paginates and deduplicates JD reviews across multiple pages", async () => {
|
||||
const fetchMock = vi
|
||||
.fn<(input: string, init?: RequestInit) => Promise<Response>>()
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
allCnt: "10000",
|
||||
goodRate: "95%",
|
||||
pictureCnt: "500",
|
||||
tagStatisticsinfoList: [
|
||||
{
|
||||
tagId: "tag-1",
|
||||
name: "拍照效果超清晰",
|
||||
count: "9313"
|
||||
}
|
||||
],
|
||||
commentInfoList: [
|
||||
{
|
||||
commentId: "comment-1",
|
||||
commentData: "第一页评论一",
|
||||
commentScore: 5
|
||||
},
|
||||
{
|
||||
commentId: "comment-2",
|
||||
commentData: "第一页评论二",
|
||||
commentScore: 5
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
allCnt: "10000",
|
||||
goodRate: "95%",
|
||||
pictureCnt: "500",
|
||||
commentInfoList: [
|
||||
{
|
||||
commentId: "comment-2",
|
||||
commentData: "第一页评论二",
|
||||
commentScore: 5
|
||||
},
|
||||
{
|
||||
commentId: "comment-3",
|
||||
commentData: "第二页评论三",
|
||||
commentScore: 4
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const service = new JdLiveSessionService();
|
||||
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: 2
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const firstUrl = fetchMock.mock.calls[0]?.[0];
|
||||
const secondUrl = fetchMock.mock.calls[1]?.[0];
|
||||
if (!firstUrl || !secondUrl) {
|
||||
throw new Error("Expected paged review requests to be issued.");
|
||||
}
|
||||
expect(decodeURIComponent(new URL(firstUrl).searchParams.get("body") ?? "")).toContain(
|
||||
'"sku":100068388535'
|
||||
);
|
||||
expect(decodeURIComponent(new URL(firstUrl).searchParams.get("body") ?? "")).toContain(
|
||||
'"page":1'
|
||||
);
|
||||
expect(decodeURIComponent(new URL(secondUrl).searchParams.get("body") ?? "")).toContain(
|
||||
'"page":2'
|
||||
);
|
||||
|
||||
expect(preview.pagination).toMatchObject({
|
||||
requestedPage: 1,
|
||||
requestedCommentCount: 2,
|
||||
maxPages: 2,
|
||||
pagesFetched: 2,
|
||||
pageKey: "page"
|
||||
});
|
||||
expect(preview.reviews.tags).toHaveLength(1);
|
||||
expect(preview.reviews.comments.map((comment) => comment.id)).toEqual([
|
||||
"comment-1",
|
||||
"comment-2",
|
||||
"comment-3"
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects multi-page review replay when the imported template has no page field", async () => {
|
||||
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const service = new JdLiveSessionService();
|
||||
service.importSession({
|
||||
cookieHeader: "thor=masked;",
|
||||
reviewsTemplateUrl:
|
||||
"https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22shopType%22:%220%22,%22sku%22:100068388533,%22commentNum%22:5,%22source%22:%22pc%22%7D"
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.previewReviews("100068388535", {
|
||||
commentCount: 5,
|
||||
maxPages: 2
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
message: expect.stringContaining("does not expose a page field")
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops paged review replay when the next page contains no new comments", async () => {
|
||||
const fetchMock = vi
|
||||
.fn<(input: string, init?: RequestInit) => Promise<Response>>()
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
commentInfoList: [
|
||||
{
|
||||
commentId: "comment-1",
|
||||
commentData: "第一页评论一",
|
||||
commentScore: 5
|
||||
},
|
||||
{
|
||||
commentId: "comment-2",
|
||||
commentData: "第一页评论二",
|
||||
commentScore: 4
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
commentInfoList: [
|
||||
{
|
||||
commentId: "comment-1",
|
||||
commentData: "第一页评论一",
|
||||
commentScore: 5
|
||||
},
|
||||
{
|
||||
commentId: "comment-2",
|
||||
commentData: "第一页评论二",
|
||||
commentScore: 4
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
commentInfoList: [
|
||||
{
|
||||
commentId: "comment-3",
|
||||
commentData: "第三页评论三",
|
||||
commentScore: 4
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const service = new JdLiveSessionService();
|
||||
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: 3
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(preview.pagination.pagesFetched).toBe(2);
|
||||
expect(preview.reviews.comments.map((comment) => comment.id)).toEqual([
|
||||
"comment-1",
|
||||
"comment-2"
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces JD verification pages as a risk-blocked error", async () => {
|
||||
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
|
||||
new Response("请完成验证码验证后继续访问", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/html"
|
||||
}
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const service = new JdLiveSessionService();
|
||||
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"
|
||||
});
|
||||
|
||||
await expect(service.previewDetail("100068388535")).rejects.toMatchObject({
|
||||
statusCode: 423,
|
||||
code: "RISK_BLOCKED"
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to browser-backed search preview when Node replay is risk-blocked", async () => {
|
||||
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
|
||||
new Response("blocked", { status: 403 })
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const service = new JdLiveSessionService();
|
||||
const browserPreviewSearch = vi.fn(async () => ({
|
||||
query: "大疆pocket3",
|
||||
source: "html" as const,
|
||||
candidateCount: 1,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: "jd-100068388533",
|
||||
platform: "jd" as const,
|
||||
title: "大疆 Pocket 3 全能套装",
|
||||
price: 5199,
|
||||
priceLabel: "CNY 5199",
|
||||
storeName: "京东自营",
|
||||
productUrl: "https://item.jd.com/100068388533.html",
|
||||
imageUrl: "https://img14.360buyimg.com/n7/jfs/t1/example.jpg",
|
||||
salesHint: "sold 500+",
|
||||
specLabel: "标准版",
|
||||
highlights: ["browser fallback"]
|
||||
}
|
||||
]
|
||||
}));
|
||||
service.setBrowserPreviewProvider({
|
||||
previewSearch: browserPreviewSearch,
|
||||
previewDetail: vi.fn(),
|
||||
previewReviews: vi.fn()
|
||||
});
|
||||
service.importSession({
|
||||
cookieHeader: "thor=masked;",
|
||||
searchApiTemplateUrl:
|
||||
"https://api.m.jd.com/api?functionId=pc_search_searchWare&body=%7B%22keyword%22:%22iPhone%2015%22,%22wq%22:%22iPhone%2015%22,%22page%22:1%7D&keyword=iPhone%2015&enc=utf-8"
|
||||
});
|
||||
|
||||
const preview = await service.previewSearch("大疆pocket3");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(browserPreviewSearch).toHaveBeenCalledWith("大疆pocket3", "api");
|
||||
expect(preview).toMatchObject({
|
||||
source: "html",
|
||||
candidateCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to browser-backed detail preview when Node replay is risk-blocked", async () => {
|
||||
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
|
||||
new Response("blocked", { status: 403 })
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const service = new JdLiveSessionService();
|
||||
const browserPreviewDetail = vi.fn(async () => ({
|
||||
skuId: "100068388535",
|
||||
source: "api" as const,
|
||||
detail: {
|
||||
skuId: "100068388535",
|
||||
title: "Apple iPhone 15",
|
||||
price: "4398.00",
|
||||
originalPrice: "4599.00",
|
||||
estimatedPrice: "4398.00",
|
||||
shopName: "JD Self Operated",
|
||||
vendorId: null,
|
||||
categoryPath: [],
|
||||
stockState: "in stock",
|
||||
mainImage: null,
|
||||
averageScore: "4.9"
|
||||
}
|
||||
}));
|
||||
service.setBrowserPreviewProvider({
|
||||
previewSearch: vi.fn(),
|
||||
previewDetail: browserPreviewDetail,
|
||||
previewReviews: vi.fn()
|
||||
});
|
||||
service.importSession({
|
||||
cookieHeader: "thor=masked;",
|
||||
detailTemplateUrl:
|
||||
"https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness&body=%7B%22skuId%22:%22100068388533%22,%22area%22:%222_2813_61125_0%22%7D"
|
||||
});
|
||||
|
||||
const preview = await service.previewDetail("100068388535");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(browserPreviewDetail).toHaveBeenCalledWith("100068388535");
|
||||
expect(preview.detail.title).toBe("Apple iPhone 15");
|
||||
});
|
||||
|
||||
it("falls back to browser-backed reviews preview when Node replay is risk-blocked", async () => {
|
||||
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
|
||||
new Response("blocked", { status: 403 })
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const service = new JdLiveSessionService();
|
||||
const browserPreviewReviews = vi.fn(async () => ({
|
||||
skuId: "100068388535",
|
||||
source: "api" as const,
|
||||
pagination: {
|
||||
requestedPage: 1,
|
||||
requestedCommentCount: 2,
|
||||
maxPages: 1,
|
||||
pagesFetched: 1
|
||||
},
|
||||
reviews: {
|
||||
skuId: "100068388535",
|
||||
total: "1000",
|
||||
goodRate: "96%",
|
||||
pictureCount: "120",
|
||||
tags: [],
|
||||
comments: [
|
||||
{
|
||||
id: "comment-1",
|
||||
content: "great",
|
||||
score: "5",
|
||||
creationTime: null,
|
||||
userLevelName: null
|
||||
}
|
||||
]
|
||||
}
|
||||
}));
|
||||
service.setBrowserPreviewProvider({
|
||||
previewSearch: vi.fn(),
|
||||
previewDetail: vi.fn(),
|
||||
previewReviews: browserPreviewReviews
|
||||
});
|
||||
service.importSession({
|
||||
cookieHeader: "thor=masked;",
|
||||
reviewsTemplateUrl:
|
||||
"https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22shopType%22:%220%22,%22sku%22:100068388533,%22commentNum%22:2,%22page%22:1,%22source%22:%22pc%22%7D"
|
||||
});
|
||||
|
||||
const preview = await service.previewReviews("100068388535", {
|
||||
commentCount: 2,
|
||||
page: 1,
|
||||
maxPages: 1
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(browserPreviewReviews).toHaveBeenCalledWith("100068388535", {
|
||||
commentCount: 2,
|
||||
page: 1,
|
||||
maxPages: 1
|
||||
});
|
||||
expect(preview.reviews.comments).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects a detail template that still resolves to another sku", async () => {
|
||||
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
|
||||
buildResponse({
|
||||
wareInfo: {
|
||||
skuId: "100068388533"
|
||||
},
|
||||
skuHeadVO: {
|
||||
skuTitle: "Apple iPhone 15"
|
||||
},
|
||||
price: {
|
||||
p: "4398.00"
|
||||
}
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const service = new JdLiveSessionService();
|
||||
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"
|
||||
});
|
||||
|
||||
await expect(service.previewDetail("100068388535")).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
code: "TEMPLATE_EXPIRED",
|
||||
message: expect.stringContaining("another sku")
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -5,75 +5,21 @@ import {
|
||||
parseJdSearchHtml
|
||||
} from "./parsers";
|
||||
import type {
|
||||
JdBrowserPreviewProvider,
|
||||
JdDetailPreviewResult,
|
||||
JdLiveService,
|
||||
JdProductPreviewResult,
|
||||
JdLiveSessionInput,
|
||||
JdLiveSessionSummary,
|
||||
JdReviewsPaginationSummary,
|
||||
JdReviewsPreviewOptions,
|
||||
JdReviewsPreviewResult,
|
||||
JdSearchMode,
|
||||
JdSearchPreviewResult,
|
||||
JdTemplateSummary
|
||||
} from "./types";
|
||||
import {
|
||||
findFirstBodyKey,
|
||||
firstString,
|
||||
readQueryBody,
|
||||
withUpdatedQueryBody
|
||||
} from "./utils";
|
||||
import { firstString, readQueryBody, withUpdatedQueryBody } from "./utils";
|
||||
|
||||
const DEFAULT_JD_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36";
|
||||
|
||||
const DETAIL_SKU_BODY_KEYS = ["skuId", "sku", "wareId", "itemSkuId"];
|
||||
const REVIEW_SKU_BODY_KEYS = ["sku", "skuId", "wareId", "itemSkuId"];
|
||||
const REVIEW_PAGE_BODY_KEYS = [
|
||||
"page",
|
||||
"pageIndex",
|
||||
"pageNum",
|
||||
"currentPage",
|
||||
"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";
|
||||
const JD_LOGIN_MARKERS = ["passport.jd.com", "请登录", "登录后可见"];
|
||||
const JD_RISK_MARKERS = [
|
||||
"验证码",
|
||||
"安全验证",
|
||||
"请完成验证",
|
||||
"访问受限",
|
||||
"异常访问",
|
||||
"verify",
|
||||
"captcha"
|
||||
];
|
||||
|
||||
type JdLiveErrorCode =
|
||||
| "INVALID_COOKIE"
|
||||
| "MISSING_SESSION"
|
||||
| "INVALID_TEMPLATE"
|
||||
| "TEMPLATE_MISSING"
|
||||
| "TEMPLATE_EXPIRED"
|
||||
| "TEMPLATE_PAGE_FIELD_MISSING"
|
||||
| "TEMPLATE_QUERY_LOCKED"
|
||||
| "SESSION_REQUIRED"
|
||||
| "RISK_BLOCKED"
|
||||
| "NETWORK_ERROR"
|
||||
| "HTTP_ERROR"
|
||||
| "BAD_REQUEST";
|
||||
|
||||
type ResolvedJdReviewsPreviewOptions = {
|
||||
commentCount: number;
|
||||
page: number;
|
||||
maxPages: number;
|
||||
};
|
||||
|
||||
type StoredJdLiveSession = {
|
||||
cookieHeader: string;
|
||||
importedAt: string;
|
||||
@ -83,16 +29,12 @@ type StoredJdLiveSession = {
|
||||
reviewsTemplateUrl?: string | undefined;
|
||||
searchReferer?: string | undefined;
|
||||
detailReferer?: string | undefined;
|
||||
searchRequestHeaders?: Record<string, string> | undefined;
|
||||
detailRequestHeaders?: Record<string, string> | undefined;
|
||||
reviewsRequestHeaders?: Record<string, string> | undefined;
|
||||
};
|
||||
|
||||
class JdLiveError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number = 400,
|
||||
readonly code: JdLiveErrorCode = "BAD_REQUEST"
|
||||
readonly statusCode: number = 400
|
||||
) {
|
||||
super(message);
|
||||
this.name = "JdLiveError";
|
||||
@ -130,11 +72,7 @@ function readEnvSession(): StoredJdLiveSession | null {
|
||||
function requireNonEmptyCookie(cookieHeader: string): string {
|
||||
const normalized = cookieHeader.trim();
|
||||
if (!normalized) {
|
||||
throw new JdLiveError(
|
||||
"cookieHeader is required for JD live requests.",
|
||||
400,
|
||||
"INVALID_COOKIE"
|
||||
);
|
||||
throw new JdLiveError("cookieHeader is required for JD live requests.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
@ -145,13 +83,9 @@ function extractTemplateSkuId(templateUrl: string | undefined): string | undefin
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(templateUrl);
|
||||
const body = readQueryBody(url);
|
||||
return firstString(body?.skuId, body?.sku) ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const url = new URL(templateUrl);
|
||||
const body = readQueryBody(url);
|
||||
return firstString(body?.skuId, body?.sku) ?? undefined;
|
||||
}
|
||||
|
||||
function buildTemplateSummary(templateUrl: string | undefined): JdTemplateSummary {
|
||||
@ -163,338 +97,16 @@ function buildTemplateSummary(templateUrl: string | undefined): JdTemplateSummar
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCapturedHeaders(
|
||||
input: Record<string, string> | undefined
|
||||
): Record<string, string> | undefined {
|
||||
if (!input) {
|
||||
return undefined;
|
||||
function templateMatchesQuery(
|
||||
templateUrl: string | undefined,
|
||||
query: string
|
||||
): boolean {
|
||||
if (!templateUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isValidReplayHeaderName = (key: string) => /^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(key);
|
||||
const entries = Object.entries(input)
|
||||
.map(([key, value]) => [key.trim().toLowerCase(), value.trim()] as const)
|
||||
.filter(([key, value]) => {
|
||||
return (
|
||||
key.length > 0 &&
|
||||
value.length > 0 &&
|
||||
!key.startsWith(":") &&
|
||||
isValidReplayHeaderName(key) &&
|
||||
key !== "cookie" &&
|
||||
key !== "referer" &&
|
||||
key !== "host" &&
|
||||
key !== "content-length" &&
|
||||
key !== "connection" &&
|
||||
key !== "accept-encoding"
|
||||
);
|
||||
});
|
||||
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
||||
}
|
||||
|
||||
function buildReplayHeaders(
|
||||
capturedHeaders: Record<string, string> | undefined,
|
||||
required: {
|
||||
accept: string;
|
||||
cookie: string;
|
||||
referer: string;
|
||||
userAgent: string;
|
||||
}
|
||||
): Record<string, string> {
|
||||
const normalizedCapturedHeaders = normalizeCapturedHeaders(capturedHeaders);
|
||||
|
||||
return {
|
||||
...(normalizedCapturedHeaders ?? {}),
|
||||
...(normalizedCapturedHeaders?.accept ? {} : { Accept: required.accept }),
|
||||
Cookie: required.cookie,
|
||||
Referer: required.referer,
|
||||
"User-Agent": required.userAgent
|
||||
};
|
||||
}
|
||||
|
||||
function coerceBodyValue(existing: unknown, nextValue: number | string): number | string {
|
||||
if (typeof existing === "number") {
|
||||
return typeof nextValue === "number" ? nextValue : Number.parseInt(nextValue, 10);
|
||||
}
|
||||
|
||||
return String(nextValue);
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(
|
||||
value: number | undefined,
|
||||
fieldName: string,
|
||||
fallback: number,
|
||||
max: number
|
||||
): number {
|
||||
if (value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(value) || value < 1 || value > max) {
|
||||
throw new JdLiveError(`${fieldName} must be an integer between 1 and ${max}.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeReviewsPreviewOptions(
|
||||
options?: number | JdReviewsPreviewOptions
|
||||
): ResolvedJdReviewsPreviewOptions {
|
||||
if (typeof options === "number") {
|
||||
return {
|
||||
commentCount: normalizePositiveInteger(options, "commentCount", 5, 50),
|
||||
page: 1,
|
||||
maxPages: 1
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
commentCount: normalizePositiveInteger(options?.commentCount, "commentCount", 5, 50),
|
||||
page: normalizePositiveInteger(options?.page, "page", 1, 1000),
|
||||
maxPages: normalizePositiveInteger(options?.maxPages, "maxPages", 1, 10)
|
||||
};
|
||||
}
|
||||
|
||||
function parseTemplateUrlOrThrow(
|
||||
templateUrl: string,
|
||||
templateLabel: string
|
||||
): URL {
|
||||
try {
|
||||
return new URL(templateUrl);
|
||||
} catch {
|
||||
throw new JdLiveError(
|
||||
`${templateLabel} must be a valid URL.`,
|
||||
400,
|
||||
"INVALID_TEMPLATE"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateTemplateFunctionId(
|
||||
templateUrl: string,
|
||||
templateLabel: string,
|
||||
expectedFunctionId: string
|
||||
): URL {
|
||||
const url = parseTemplateUrlOrThrow(templateUrl, templateLabel);
|
||||
const functionId = url.searchParams.get("functionId");
|
||||
|
||||
if (functionId !== expectedFunctionId) {
|
||||
throw new JdLiveError(
|
||||
`${templateLabel} must point to functionId=${expectedFunctionId}.`,
|
||||
400,
|
||||
"INVALID_TEMPLATE"
|
||||
);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
function validateTemplateBodyKey(
|
||||
url: URL,
|
||||
bodyKeys: string[],
|
||||
_templateLabel: string,
|
||||
errorMessage: string
|
||||
): void {
|
||||
const body = readQueryBody(url);
|
||||
if (!findFirstBodyKey(body, bodyKeys)) {
|
||||
throw new JdLiveError(errorMessage, 409, "TEMPLATE_EXPIRED");
|
||||
}
|
||||
}
|
||||
|
||||
function validateImportedTemplates(input: {
|
||||
searchApiTemplateUrl?: string | undefined;
|
||||
detailTemplateUrl?: string | undefined;
|
||||
reviewsTemplateUrl?: string | undefined;
|
||||
}): void {
|
||||
if (input.searchApiTemplateUrl) {
|
||||
validateTemplateFunctionId(
|
||||
input.searchApiTemplateUrl,
|
||||
"JD search API template",
|
||||
SEARCH_FUNCTION_ID
|
||||
);
|
||||
}
|
||||
|
||||
if (input.detailTemplateUrl) {
|
||||
const detailUrl = validateTemplateFunctionId(
|
||||
input.detailTemplateUrl,
|
||||
"JD detail template",
|
||||
DETAIL_FUNCTION_ID
|
||||
);
|
||||
validateTemplateBodyKey(
|
||||
detailUrl,
|
||||
DETAIL_SKU_BODY_KEYS,
|
||||
"JD detail template",
|
||||
"JD detail template does not expose a sku field. Capture a fresh pc_detailpage_wareBusiness request first."
|
||||
);
|
||||
}
|
||||
|
||||
if (input.reviewsTemplateUrl) {
|
||||
const reviewsUrl = validateTemplateFunctionId(
|
||||
input.reviewsTemplateUrl,
|
||||
"JD reviews template",
|
||||
REVIEWS_FUNCTION_ID
|
||||
);
|
||||
validateTemplateBodyKey(
|
||||
reviewsUrl,
|
||||
REVIEW_SKU_BODY_KEYS,
|
||||
"JD reviews template",
|
||||
"JD reviews template does not expose a sku field. Capture a fresh getLegoWareDetailComment request first."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildDetailRequestUrl(templateUrl: string, skuId: string): string {
|
||||
const template = new URL(templateUrl);
|
||||
const body = readQueryBody(template);
|
||||
const skuKey = findFirstBodyKey(body, DETAIL_SKU_BODY_KEYS) ?? "skuId";
|
||||
|
||||
return withUpdatedQueryBody(template, (currentBody) => ({
|
||||
...currentBody,
|
||||
[skuKey]: coerceBodyValue(currentBody[skuKey], skuId)
|
||||
}));
|
||||
}
|
||||
|
||||
function buildSearchApiRequestUrl(templateUrl: string, query: string): string {
|
||||
let requestUrl = new URL(templateUrl);
|
||||
requestUrl.searchParams.set("keyword", query);
|
||||
|
||||
if (requestUrl.searchParams.has("wq")) {
|
||||
requestUrl.searchParams.set("wq", query);
|
||||
}
|
||||
|
||||
if (!requestUrl.searchParams.has("enc")) {
|
||||
requestUrl.searchParams.set("enc", "utf-8");
|
||||
}
|
||||
|
||||
if (readQueryBody(requestUrl)) {
|
||||
requestUrl = new URL(
|
||||
withUpdatedQueryBody(requestUrl, (currentBody) => {
|
||||
const nextBody: Record<string, unknown> = {
|
||||
...currentBody
|
||||
};
|
||||
let updated = false;
|
||||
|
||||
for (const key of SEARCH_QUERY_BODY_KEYS) {
|
||||
if (!(key in currentBody)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nextBody[key] = coerceBodyValue(currentBody[key], query);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (!updated) {
|
||||
nextBody.keyword = query;
|
||||
}
|
||||
|
||||
return nextBody;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return requestUrl.toString();
|
||||
}
|
||||
|
||||
function buildSearchPageUrlFromTemplate(templateUrl: string, query: string): string {
|
||||
const template = new URL(templateUrl);
|
||||
const searchUrl = new URL("https://search.jd.com/Search");
|
||||
|
||||
for (const [key, value] of template.searchParams.entries()) {
|
||||
if (
|
||||
key === "functionId" ||
|
||||
key === "appid" ||
|
||||
key === "client" ||
|
||||
key === "clientVersion" ||
|
||||
key === "t" ||
|
||||
key === "loginType" ||
|
||||
key === "body"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
searchUrl.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
searchUrl.searchParams.set("keyword", query);
|
||||
if (searchUrl.searchParams.has("wq")) {
|
||||
searchUrl.searchParams.set("wq", query);
|
||||
}
|
||||
if (!searchUrl.searchParams.has("enc")) {
|
||||
searchUrl.searchParams.set("enc", "utf-8");
|
||||
}
|
||||
|
||||
return searchUrl.toString();
|
||||
}
|
||||
|
||||
function buildReviewsRequestUrl(
|
||||
templateUrl: string,
|
||||
skuId: string,
|
||||
options: ResolvedJdReviewsPreviewOptions,
|
||||
page: number
|
||||
): {
|
||||
url: string;
|
||||
pageKey?: string | undefined;
|
||||
} {
|
||||
const template = new URL(templateUrl);
|
||||
const body = readQueryBody(template);
|
||||
const skuKey = findFirstBodyKey(body, REVIEW_SKU_BODY_KEYS) ?? "sku";
|
||||
const pageKey = findFirstBodyKey(body, REVIEW_PAGE_BODY_KEYS) ?? undefined;
|
||||
const pageSizeKey = findFirstBodyKey(body, REVIEW_PAGE_SIZE_BODY_KEYS) ?? "commentNum";
|
||||
|
||||
if ((options.maxPages > 1 || options.page > 1) && !pageKey) {
|
||||
throw new JdLiveError(
|
||||
"Imported reviews template does not expose a page field. Capture a paged getLegoWareDetailComment request first.",
|
||||
409,
|
||||
"TEMPLATE_PAGE_FIELD_MISSING"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
url: withUpdatedQueryBody(template, (currentBody) => {
|
||||
const nextBody: Record<string, unknown> = {
|
||||
...currentBody,
|
||||
[skuKey]: coerceBodyValue(currentBody[skuKey], skuId),
|
||||
[pageSizeKey]: coerceBodyValue(currentBody[pageSizeKey], options.commentCount)
|
||||
};
|
||||
|
||||
if (pageKey) {
|
||||
nextBody[pageKey] = coerceBodyValue(currentBody[pageKey], page);
|
||||
}
|
||||
|
||||
return nextBody;
|
||||
}),
|
||||
...(pageKey ? { pageKey } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function mergeReviewPages(
|
||||
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())
|
||||
};
|
||||
const templateKeyword = new URL(templateUrl).searchParams.get("keyword");
|
||||
return Boolean(templateKeyword && templateKeyword === query);
|
||||
}
|
||||
|
||||
async function fetchTextOrThrow(
|
||||
@ -514,52 +126,19 @@ async function fetchTextOrThrow(
|
||||
`JD live request failed before receiving a response: ${
|
||||
error instanceof Error ? error.message : "unknown error"
|
||||
}`,
|
||||
502,
|
||||
"NETWORK_ERROR"
|
||||
502
|
||||
);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const normalizedResponseUrl = response.url.toLowerCase();
|
||||
const normalizedText = text.toLowerCase();
|
||||
|
||||
if (
|
||||
JD_LOGIN_MARKERS.some(
|
||||
(marker) =>
|
||||
normalizedResponseUrl.includes(marker.toLowerCase()) ||
|
||||
normalizedText.includes(marker.toLowerCase())
|
||||
)
|
||||
) {
|
||||
throw new JdLiveError(sessionExpiredMessage, 409, "SESSION_REQUIRED");
|
||||
}
|
||||
|
||||
if (
|
||||
JD_RISK_MARKERS.some(
|
||||
(marker) =>
|
||||
normalizedResponseUrl.includes(marker.toLowerCase()) ||
|
||||
normalizedText.includes(marker.toLowerCase())
|
||||
)
|
||||
) {
|
||||
throw new JdLiveError(
|
||||
"JD request hit verification or risk control. Refresh the session/template in the browser first.",
|
||||
423,
|
||||
"RISK_BLOCKED"
|
||||
);
|
||||
if (response.url.includes("passport.jd.com") || text.includes("passport.jd.com")) {
|
||||
throw new JdLiveError(sessionExpiredMessage, 409);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorCode: JdLiveErrorCode =
|
||||
response.status === 401
|
||||
? "SESSION_REQUIRED"
|
||||
: response.status === 403 || response.status === 412 || response.status === 423
|
||||
? "RISK_BLOCKED"
|
||||
: "HTTP_ERROR";
|
||||
const errorStatus =
|
||||
errorCode === "SESSION_REQUIRED" ? 409 : errorCode === "RISK_BLOCKED" ? 423 : 502;
|
||||
throw new JdLiveError(
|
||||
`JD live request failed with status ${response.status}.`,
|
||||
errorStatus,
|
||||
errorCode
|
||||
502
|
||||
);
|
||||
}
|
||||
|
||||
@ -569,23 +148,12 @@ async function fetchTextOrThrow(
|
||||
};
|
||||
}
|
||||
|
||||
export function isJdLiveError(
|
||||
error: unknown
|
||||
): error is Error & { statusCode: number; code?: JdLiveErrorCode } {
|
||||
export function isJdLiveError(error: unknown): error is Error & { statusCode: number } {
|
||||
return error instanceof Error && "statusCode" in error;
|
||||
}
|
||||
|
||||
export function getJdLiveErrorCode(error: unknown): JdLiveErrorCode | undefined {
|
||||
return isJdLiveError(error) && typeof error.code === "string" ? error.code : 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 {
|
||||
@ -605,15 +173,6 @@ 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,
|
||||
detailTemplateUrl,
|
||||
reviewsTemplateUrl
|
||||
});
|
||||
|
||||
this.session = {
|
||||
cookieHeader: requireNonEmptyCookie(input.cookieHeader),
|
||||
@ -623,10 +182,7 @@ export class JdLiveSessionService implements JdLiveService {
|
||||
...(detailTemplateUrl ? { detailTemplateUrl } : {}),
|
||||
...(reviewsTemplateUrl ? { reviewsTemplateUrl } : {}),
|
||||
...(searchReferer ? { searchReferer } : {}),
|
||||
...(detailReferer ? { detailReferer } : {}),
|
||||
...(searchRequestHeaders ? { searchRequestHeaders } : {}),
|
||||
...(detailRequestHeaders ? { detailRequestHeaders } : {}),
|
||||
...(reviewsRequestHeaders ? { reviewsRequestHeaders } : {})
|
||||
...(detailReferer ? { detailReferer } : {})
|
||||
};
|
||||
|
||||
return this.getSessionSummary();
|
||||
@ -643,301 +199,174 @@ export class JdLiveSessionService implements JdLiveService {
|
||||
const session = this.requireSession();
|
||||
const normalizedQuery = query.trim();
|
||||
if (!normalizedQuery) {
|
||||
throw new JdLiveError("query is required for JD live search preview.", 400, "BAD_REQUEST");
|
||||
throw new JdLiveError("query is required for JD live search preview.");
|
||||
}
|
||||
|
||||
const resolvedMode = mode ?? (session.searchApiTemplateUrl ? "api" : "html");
|
||||
const resolvedMode =
|
||||
mode ??
|
||||
(templateMatchesQuery(session.searchApiTemplateUrl, normalizedQuery) ? "api" : "html");
|
||||
|
||||
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
|
||||
if (resolvedMode === "api") {
|
||||
if (!session.searchApiTemplateUrl) {
|
||||
throw new JdLiveError(
|
||||
"JD search API template is missing. Import a fresh search request URL or use mode=html."
|
||||
);
|
||||
}
|
||||
|
||||
const templateUrl = new URL(session.searchApiTemplateUrl);
|
||||
const templateKeyword = templateUrl.searchParams.get("keyword");
|
||||
if (templateKeyword && templateKeyword !== normalizedQuery) {
|
||||
throw new JdLiveError(
|
||||
`Imported search API template is locked to query "${templateKeyword}". ` +
|
||||
"Capture a fresh request for the target query or use mode=html."
|
||||
);
|
||||
const response = await fetchTextOrThrow(
|
||||
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(
|
||||
searchUrl,
|
||||
session.searchApiTemplateUrl,
|
||||
{
|
||||
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
|
||||
})
|
||||
headers: {
|
||||
Accept: "application/json, text/plain, */*",
|
||||
Cookie: session.cookieHeader,
|
||||
Referer:
|
||||
session.searchReferer ??
|
||||
`https://search.jd.com/Search?keyword=${encodeURIComponent(normalizedQuery)}`,
|
||||
"User-Agent": session.userAgent
|
||||
}
|
||||
},
|
||||
"JD search session appears invalid. Re-login in the browser and re-import the cookie/header."
|
||||
);
|
||||
|
||||
const candidates = parseJdSearchHtml(normalizedQuery, response.text);
|
||||
const candidates = parseJdSearchApiResponse(normalizedQuery, { text: response.text });
|
||||
return {
|
||||
query: normalizedQuery,
|
||||
source: "html",
|
||||
source: "api",
|
||||
candidateCount: candidates.length,
|
||||
candidates
|
||||
};
|
||||
} catch (error) {
|
||||
const browserPreview = await this.tryBrowserSearchFallback(error, normalizedQuery, resolvedMode);
|
||||
if (browserPreview) {
|
||||
return browserPreview;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const searchUrl = `https://search.jd.com/Search?keyword=${encodeURIComponent(normalizedQuery)}`;
|
||||
const response = await fetchTextOrThrow(
|
||||
searchUrl,
|
||||
{
|
||||
headers: {
|
||||
Accept:
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
Cookie: session.cookieHeader,
|
||||
Referer: session.searchReferer ?? "https://www.jd.com/",
|
||||
"User-Agent": session.userAgent
|
||||
}
|
||||
},
|
||||
"JD search session appears invalid. Re-login in the browser and re-import the cookie/header."
|
||||
);
|
||||
|
||||
const candidates = parseJdSearchHtml(normalizedQuery, response.text);
|
||||
return {
|
||||
query: normalizedQuery,
|
||||
source: "html",
|
||||
candidateCount: candidates.length,
|
||||
candidates
|
||||
};
|
||||
}
|
||||
|
||||
async previewDetail(skuId: string): Promise<JdDetailPreviewResult> {
|
||||
const session = this.requireSession();
|
||||
const normalizedSkuId = skuId.trim();
|
||||
if (!normalizedSkuId) {
|
||||
throw new JdLiveError("skuId is required for JD detail preview.", 400, "BAD_REQUEST");
|
||||
throw new JdLiveError("skuId is required for JD detail preview.");
|
||||
}
|
||||
|
||||
if (!session.detailTemplateUrl) {
|
||||
throw new JdLiveError(
|
||||
"JD detail template is missing. Capture a fresh pc_detailpage_wareBusiness request and import it first.",
|
||||
409,
|
||||
"TEMPLATE_MISSING"
|
||||
"JD detail template is missing. Capture a fresh pc_detailpage_wareBusiness request and import it first."
|
||||
);
|
||||
}
|
||||
|
||||
const requestUrl = buildDetailRequestUrl(session.detailTemplateUrl, normalizedSkuId);
|
||||
|
||||
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."
|
||||
const templateSkuId = extractTemplateSkuId(session.detailTemplateUrl);
|
||||
if (templateSkuId && templateSkuId !== normalizedSkuId) {
|
||||
throw new JdLiveError(
|
||||
`Imported detail template is bound to sku ${templateSkuId}. Open the matching JD item page and capture a fresh request for sku ${normalizedSkuId}.`
|
||||
);
|
||||
|
||||
const 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;
|
||||
}
|
||||
|
||||
const response = await fetchTextOrThrow(
|
||||
session.detailTemplateUrl,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/json, text/plain, */*",
|
||||
Cookie: session.cookieHeader,
|
||||
Referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`,
|
||||
"User-Agent": session.userAgent
|
||||
}
|
||||
},
|
||||
"JD detail session appears invalid. Re-login in the browser and re-import the cookie/header."
|
||||
);
|
||||
|
||||
return {
|
||||
skuId: normalizedSkuId,
|
||||
source: "api",
|
||||
detail: parseJdDetailApiResponse(normalizedSkuId, { text: response.text })
|
||||
};
|
||||
}
|
||||
|
||||
async previewReviews(
|
||||
skuId: string,
|
||||
options?: number | JdReviewsPreviewOptions
|
||||
commentCount = 5
|
||||
): Promise<JdReviewsPreviewResult> {
|
||||
const session = this.requireSession();
|
||||
const normalizedSkuId = skuId.trim();
|
||||
const resolvedOptions = normalizeReviewsPreviewOptions(options);
|
||||
if (!normalizedSkuId) {
|
||||
throw new JdLiveError("skuId is required for JD reviews preview.", 400, "BAD_REQUEST");
|
||||
throw new JdLiveError("skuId is required for JD reviews preview.");
|
||||
}
|
||||
|
||||
if (!session.reviewsTemplateUrl) {
|
||||
throw new JdLiveError(
|
||||
"JD reviews template is missing. Capture a fresh getLegoWareDetailComment request and import it first.",
|
||||
409,
|
||||
"TEMPLATE_MISSING"
|
||||
"JD reviews template is missing. Capture a fresh getLegoWareDetailComment request and import it first."
|
||||
);
|
||||
}
|
||||
|
||||
const reviewPages: JdReviewsPreviewResult["reviews"][] = [];
|
||||
let pageKey: string | undefined;
|
||||
const seenCommentIds = new Set<string>();
|
||||
|
||||
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: 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;
|
||||
}
|
||||
|
||||
seenCommentIds.add(comment.id);
|
||||
newCommentCount += 1;
|
||||
}
|
||||
|
||||
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
|
||||
const templateSkuId = extractTemplateSkuId(session.reviewsTemplateUrl);
|
||||
if (templateSkuId && templateSkuId !== normalizedSkuId) {
|
||||
throw new JdLiveError(
|
||||
`Imported reviews template is bound to sku ${templateSkuId}. Open the matching JD item page and capture a fresh request for sku ${normalizedSkuId}.`
|
||||
);
|
||||
if (browserPreview) {
|
||||
return browserPreview;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async previewProduct(
|
||||
skuId: string,
|
||||
options?: number | JdReviewsPreviewOptions
|
||||
): Promise<JdProductPreviewResult> {
|
||||
const [detailPreview, reviewsPreview] = await Promise.all([
|
||||
this.previewDetail(skuId),
|
||||
this.previewReviews(skuId, options)
|
||||
]);
|
||||
const templateUrl = new URL(session.reviewsTemplateUrl);
|
||||
const requestUrl = withUpdatedQueryBody(templateUrl, (body) => ({
|
||||
...body,
|
||||
commentNum: commentCount
|
||||
}));
|
||||
|
||||
const response = await fetchTextOrThrow(
|
||||
requestUrl,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/json, text/plain, */*",
|
||||
Cookie: session.cookieHeader,
|
||||
Referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`,
|
||||
"User-Agent": session.userAgent
|
||||
}
|
||||
},
|
||||
"JD reviews session appears invalid. Re-login in the browser and re-import the cookie/header."
|
||||
);
|
||||
|
||||
return {
|
||||
skuId: detailPreview.skuId,
|
||||
skuId: normalizedSkuId,
|
||||
source: "api",
|
||||
detail: detailPreview.detail,
|
||||
pagination: reviewsPreview.pagination,
|
||||
reviews: reviewsPreview.reviews
|
||||
reviews: parseJdReviewsApiResponse(normalizedSkuId, { text: response.text })
|
||||
};
|
||||
}
|
||||
|
||||
private requireSession(): StoredJdLiveSession {
|
||||
if (!this.session?.cookieHeader) {
|
||||
throw new JdLiveError(
|
||||
"JD live session is not configured. Import a browser cookie/header first.",
|
||||
409,
|
||||
"MISSING_SESSION"
|
||||
"JD live session is not configured. Import a browser cookie/header first."
|
||||
);
|
||||
}
|
||||
|
||||
return this.session;
|
||||
}
|
||||
|
||||
private shouldUseBrowserFallback(error: unknown): boolean {
|
||||
const code = getJdLiveErrorCode(error);
|
||||
return Boolean(
|
||||
this.browserPreviewProvider &&
|
||||
(code === "RISK_BLOCKED" || code === "NETWORK_ERROR" || code === "HTTP_ERROR")
|
||||
);
|
||||
}
|
||||
|
||||
private async tryBrowserSearchFallback(
|
||||
error: unknown,
|
||||
query: string,
|
||||
mode: JdSearchMode
|
||||
): Promise<JdSearchPreviewResult | null> {
|
||||
if (!this.shouldUseBrowserFallback(error) || !this.browserPreviewProvider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.browserPreviewProvider.previewSearch(query, mode);
|
||||
}
|
||||
|
||||
private async tryBrowserDetailFallback(
|
||||
error: unknown,
|
||||
skuId: string
|
||||
): Promise<JdDetailPreviewResult | null> {
|
||||
if (!this.shouldUseBrowserFallback(error) || !this.browserPreviewProvider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.browserPreviewProvider.previewDetail(skuId);
|
||||
}
|
||||
|
||||
private async tryBrowserReviewsFallback(
|
||||
error: unknown,
|
||||
skuId: string,
|
||||
options?: number | JdReviewsPreviewOptions
|
||||
): Promise<JdReviewsPreviewResult | null> {
|
||||
if (!this.shouldUseBrowserFallback(error) || !this.browserPreviewProvider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.browserPreviewProvider.previewReviews(skuId, options);
|
||||
}
|
||||
}
|
||||
|
||||
@ -148,62 +148,4 @@ describe("JD parsers", () => {
|
||||
userLevelName: "PLUS会员"
|
||||
});
|
||||
});
|
||||
it("falls back to lastCommentInfoList when commentInfoList is missing", () => {
|
||||
const reviews = parseJdReviewsApiResponse("100068388533", {
|
||||
allCnt: "3",
|
||||
goodRate: "98%",
|
||||
lastCommentInfoList: [
|
||||
{
|
||||
commentId: "comment-last-1",
|
||||
commentData: "fallback review",
|
||||
commentDate: "2026-04-07 19:00:05",
|
||||
userNickName: "fallback-user"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(reviews.comments).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "comment-last-1",
|
||||
content: "fallback review",
|
||||
creationTime: "2026-04-07 19:00:05",
|
||||
userLevelName: "fallback-user"
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
it("unwraps nested JD review payloads and commentInfo wrappers", () => {
|
||||
const reviews = parseJdReviewsApiResponse("100068388533", {
|
||||
code: 0,
|
||||
data: {
|
||||
allCnt: "12",
|
||||
goodRate: "96%",
|
||||
commentInfoList: [
|
||||
{
|
||||
commentInfo: {
|
||||
commentId: "comment-nested-1",
|
||||
content: "nested review",
|
||||
commentScore: 4,
|
||||
creationTime: "2026-04-07 20:12:33",
|
||||
userNickName: "nested-user"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
expect(reviews).toMatchObject({
|
||||
total: "12",
|
||||
goodRate: "96%"
|
||||
});
|
||||
expect(reviews.comments).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "comment-nested-1",
|
||||
content: "nested review",
|
||||
score: "4",
|
||||
creationTime: "2026-04-07 20:12:33",
|
||||
userLevelName: "nested-user"
|
||||
})
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -30,66 +30,6 @@ function unwrapCapturedPayload(input: unknown): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
function collectNestedPayloads(
|
||||
root: Record<string, unknown> | null
|
||||
): Record<string, unknown>[] {
|
||||
if (!root) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queue: Record<string, unknown>[] = [root];
|
||||
const seen = new Set<Record<string, unknown>>();
|
||||
const collected: Record<string, unknown>[] = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current || seen.has(current)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(current);
|
||||
collected.push(current);
|
||||
|
||||
for (const nested of [
|
||||
asRecord(current.data),
|
||||
asRecord(current.result),
|
||||
asRecord(current.bizData),
|
||||
asRecord(current.commentInfo),
|
||||
asRecord(current.module)
|
||||
]) {
|
||||
if (nested && !seen.has(nested)) {
|
||||
queue.push(nested);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
function hasReviewPayloadMarkers(payload: Record<string, unknown>): boolean {
|
||||
return (
|
||||
"commentInfoList" in payload ||
|
||||
"lastCommentInfoList" in payload ||
|
||||
"tagStatisticsinfoList" in payload ||
|
||||
"allCnt" in payload ||
|
||||
"allCntStr" in payload ||
|
||||
"goodCnt" in payload ||
|
||||
"goodRate" in payload ||
|
||||
"goodRateShow" in payload ||
|
||||
"pictureCnt" in payload ||
|
||||
"showPicCnt" in payload
|
||||
);
|
||||
}
|
||||
|
||||
function resolveJdReviewsPayload(input: unknown): Record<string, unknown> | null {
|
||||
const root = asRecord(unwrapCapturedPayload(input));
|
||||
if (!root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return collectNestedPayloads(root).find((payload) => hasReviewPayloadMarkers(payload)) ?? root;
|
||||
}
|
||||
|
||||
function extractSpecLabel(title: string): string {
|
||||
const storageMatch = title.match(/\b\d+(?:GB|TB)\b/i);
|
||||
if (storageMatch) {
|
||||
@ -373,32 +313,11 @@ function parseReviewTag(input: unknown): JdReviewTagSnapshot | null {
|
||||
}
|
||||
|
||||
function parseReviewComment(input: unknown): JdReviewCommentSnapshot | null {
|
||||
const wrapper = asRecord(input);
|
||||
const comment = asRecord(wrapper?.commentInfo) ?? wrapper;
|
||||
const comment = asRecord(input);
|
||||
const content = normalizeInlineText(
|
||||
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
|
||||
firstString(comment?.content, comment?.commentData, comment?.tagCommentContent)
|
||||
);
|
||||
const id = firstString(comment?.id, comment?.commentId);
|
||||
|
||||
if (!content || !id) {
|
||||
return null;
|
||||
@ -407,37 +326,14 @@ function parseReviewComment(input: unknown): JdReviewCommentSnapshot | null {
|
||||
return {
|
||||
id,
|
||||
content,
|
||||
score: firstString(
|
||||
comment?.score,
|
||||
comment?.commentScore,
|
||||
comment?.scoring,
|
||||
wrapper?.score,
|
||||
wrapper?.commentScore,
|
||||
wrapper?.scoring
|
||||
),
|
||||
score: firstString(comment?.score, comment?.commentScore),
|
||||
creationTime: firstString(
|
||||
comment?.creationTime,
|
||||
comment?.creationDate,
|
||||
comment?.commentDate,
|
||||
comment?.time,
|
||||
wrapper?.creationTime,
|
||||
wrapper?.creationDate,
|
||||
wrapper?.commentDate,
|
||||
wrapper?.time
|
||||
comment?.commentDate
|
||||
),
|
||||
userLevelName: normalizeInlineText(
|
||||
firstString(
|
||||
comment?.userLevelName,
|
||||
comment?.userClientShow,
|
||||
comment?.userLevel,
|
||||
comment?.userNickName,
|
||||
comment?.userName,
|
||||
wrapper?.userLevelName,
|
||||
wrapper?.userClientShow,
|
||||
wrapper?.userLevel,
|
||||
wrapper?.userNickName,
|
||||
wrapper?.userName
|
||||
)
|
||||
firstString(comment?.userLevelName, comment?.userClientShow)
|
||||
)
|
||||
};
|
||||
}
|
||||
@ -446,20 +342,13 @@ export function parseJdReviewsApiResponse(
|
||||
skuId: string,
|
||||
input: unknown
|
||||
): JdProductReviewsSnapshot {
|
||||
const payload = resolveJdReviewsPayload(input);
|
||||
const payload = asRecord(unwrapCapturedPayload(input));
|
||||
const tags = asArray(payload?.tagStatisticsinfoList)
|
||||
.map((tag) => parseReviewTag(tag))
|
||||
.filter((tag): tag is JdReviewTagSnapshot => Boolean(tag));
|
||||
const commentsById = new Map<string, JdReviewCommentSnapshot>();
|
||||
for (const comment of [
|
||||
...asArray(payload?.commentInfoList),
|
||||
...asArray(payload?.lastCommentInfoList)
|
||||
]) {
|
||||
const parsedComment = parseReviewComment(comment);
|
||||
if (parsedComment && !commentsById.has(parsedComment.id)) {
|
||||
commentsById.set(parsedComment.id, parsedComment);
|
||||
}
|
||||
}
|
||||
const comments = asArray(payload?.commentInfoList)
|
||||
.map((comment) => parseReviewComment(comment))
|
||||
.filter((comment): comment is JdReviewCommentSnapshot => Boolean(comment));
|
||||
|
||||
return {
|
||||
skuId,
|
||||
@ -467,6 +356,6 @@ export function parseJdReviewsApiResponse(
|
||||
goodRate: firstString(payload?.goodRate, payload?.goodRateShow),
|
||||
pictureCount: firstString(payload?.pictureCnt, payload?.showPicCnt),
|
||||
tags,
|
||||
comments: Array.from(commentsById.values())
|
||||
comments
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,367 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { JdSessionManagerService } from "./session-manager";
|
||||
import type {
|
||||
JdDetailPreviewResult,
|
||||
JdLiveService,
|
||||
JdLiveSessionInput,
|
||||
JdLiveSessionSummary,
|
||||
JdProductPreviewResult,
|
||||
JdReviewsPreviewOptions,
|
||||
JdReviewsPreviewResult,
|
||||
JdSearchPreviewResult
|
||||
} from "./types";
|
||||
|
||||
function createConfiguredSummary(): JdLiveSessionSummary {
|
||||
return {
|
||||
configured: true,
|
||||
importedAt: "2026-04-03T08:00:00.000Z",
|
||||
hasCookie: true,
|
||||
userAgent: "stub-user-agent",
|
||||
searchApiTemplate: {
|
||||
available: true,
|
||||
skuId: "100068388533"
|
||||
},
|
||||
detailTemplate: {
|
||||
available: true,
|
||||
skuId: "100068388533"
|
||||
},
|
||||
reviewsTemplate: {
|
||||
available: true,
|
||||
skuId: "100068388533"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptySummary(): JdLiveSessionSummary {
|
||||
return {
|
||||
configured: false,
|
||||
hasCookie: false,
|
||||
searchApiTemplate: {
|
||||
available: false
|
||||
},
|
||||
detailTemplate: {
|
||||
available: false
|
||||
},
|
||||
reviewsTemplate: {
|
||||
available: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createJdLiveServiceStub(
|
||||
overrides: Partial<JdLiveService> = {}
|
||||
): JdLiveService {
|
||||
let summary = createEmptySummary();
|
||||
|
||||
return {
|
||||
getSessionSummary() {
|
||||
return overrides.getSessionSummary?.() ?? summary;
|
||||
},
|
||||
importSession(input: JdLiveSessionInput) {
|
||||
if (overrides.importSession) {
|
||||
return overrides.importSession(input);
|
||||
}
|
||||
|
||||
summary = {
|
||||
...createConfiguredSummary(),
|
||||
importedAt: "2026-04-03T08:30:00.000Z",
|
||||
userAgent: input.userAgent ?? "stub-user-agent",
|
||||
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;
|
||||
},
|
||||
clearSession() {
|
||||
if (overrides.clearSession) {
|
||||
overrides.clearSession();
|
||||
return;
|
||||
}
|
||||
|
||||
summary = createEmptySummary();
|
||||
},
|
||||
async previewSearch(query, mode) {
|
||||
if (overrides.previewSearch) {
|
||||
return overrides.previewSearch(query, mode);
|
||||
}
|
||||
|
||||
const preview: JdSearchPreviewResult = {
|
||||
query,
|
||||
source: mode ?? "html",
|
||||
candidateCount: 1,
|
||||
candidates: []
|
||||
};
|
||||
|
||||
return preview;
|
||||
},
|
||||
async previewDetail(skuId) {
|
||||
if (overrides.previewDetail) {
|
||||
return overrides.previewDetail(skuId);
|
||||
}
|
||||
|
||||
const preview: JdDetailPreviewResult = {
|
||||
skuId,
|
||||
source: "api",
|
||||
detail: {
|
||||
skuId,
|
||||
title: "Apple iPhone 15",
|
||||
price: "4398.00",
|
||||
originalPrice: "4599.00",
|
||||
estimatedPrice: "4398.00",
|
||||
shopName: "JD Self Operated",
|
||||
vendorId: null,
|
||||
categoryPath: ["phones"],
|
||||
stockState: "in stock",
|
||||
mainImage: "https://img14.360buyimg.com/n2/jfs/t1/example.jpg",
|
||||
averageScore: "4.9"
|
||||
}
|
||||
};
|
||||
|
||||
return preview;
|
||||
},
|
||||
async previewReviews(skuId, options) {
|
||||
if (overrides.previewReviews) {
|
||||
return overrides.previewReviews(skuId, options);
|
||||
}
|
||||
|
||||
const preview: JdReviewsPreviewResult = {
|
||||
skuId,
|
||||
source: "api",
|
||||
pagination: {
|
||||
requestedPage: typeof options === "object" ? (options?.page ?? 1) : 1,
|
||||
requestedCommentCount:
|
||||
typeof options === "number" ? options : (options?.commentCount ?? 1),
|
||||
maxPages: typeof options === "object" ? (options?.maxPages ?? 1) : 1,
|
||||
pagesFetched: 1,
|
||||
pageKey: "page"
|
||||
},
|
||||
reviews: {
|
||||
skuId,
|
||||
total: "1000",
|
||||
goodRate: "96%",
|
||||
pictureCount: "120",
|
||||
tags: [],
|
||||
comments: [
|
||||
{
|
||||
id: "comment-1",
|
||||
content: "great product",
|
||||
score: "5",
|
||||
creationTime: "2026-04-03 08:00:00",
|
||||
userLevelName: "PLUS"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return preview;
|
||||
},
|
||||
async previewProduct(skuId, options?: number | JdReviewsPreviewOptions) {
|
||||
if (overrides.previewProduct) {
|
||||
return overrides.previewProduct(skuId, options);
|
||||
}
|
||||
|
||||
const detail = await this.previewDetail(skuId);
|
||||
const reviews = await this.previewReviews(skuId, options);
|
||||
const preview: JdProductPreviewResult = {
|
||||
skuId,
|
||||
source: "api",
|
||||
detail: detail.detail,
|
||||
pagination: reviews.pagination,
|
||||
reviews: reviews.reviews
|
||||
};
|
||||
|
||||
return preview;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("JdSessionManagerService", () => {
|
||||
const managers: JdSessionManagerService[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const manager of managers.splice(0)) {
|
||||
manager.shutdown();
|
||||
}
|
||||
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
managers.push(manager);
|
||||
|
||||
const state = await manager.importManualSession({
|
||||
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(onSessionReady).not.toHaveBeenCalled();
|
||||
expect(state.status).toBe("degraded");
|
||||
expect(state.pendingManualAction).toBe(false);
|
||||
expect(state.session).toMatchObject({
|
||||
configured: true,
|
||||
detailTemplate: {
|
||||
available: true,
|
||||
skuId: "100068388533"
|
||||
},
|
||||
reviewsTemplate: {
|
||||
available: true,
|
||||
skuId: "100068388533"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("passes health checks for a configured and valid session", async () => {
|
||||
const previewSearch = vi.fn<
|
||||
(query: string, mode?: "html" | "api") => Promise<JdSearchPreviewResult>
|
||||
>(async (query, mode) => ({
|
||||
query,
|
||||
source: mode ?? "html",
|
||||
candidateCount: 1,
|
||||
candidates: []
|
||||
}));
|
||||
const previewDetail = vi.fn<(skuId: string) => Promise<JdDetailPreviewResult>>(async (skuId) => ({
|
||||
skuId,
|
||||
source: "api",
|
||||
detail: {
|
||||
skuId,
|
||||
title: "Apple iPhone 15",
|
||||
price: "4398.00",
|
||||
originalPrice: "4599.00",
|
||||
estimatedPrice: "4398.00",
|
||||
shopName: "JD Self Operated",
|
||||
vendorId: null,
|
||||
categoryPath: ["phones"],
|
||||
stockState: "in stock",
|
||||
mainImage: "https://img14.360buyimg.com/n2/jfs/t1/example.jpg",
|
||||
averageScore: "4.9"
|
||||
}
|
||||
}));
|
||||
const previewReviews = vi.fn<
|
||||
(skuId: string, options?: number | JdReviewsPreviewOptions) => Promise<JdReviewsPreviewResult>
|
||||
>(async (skuId, options) => ({
|
||||
skuId,
|
||||
source: "api",
|
||||
pagination: {
|
||||
requestedPage: typeof options === "object" ? (options?.page ?? 1) : 1,
|
||||
requestedCommentCount:
|
||||
typeof options === "number" ? options : (options?.commentCount ?? 1),
|
||||
maxPages: typeof options === "object" ? (options?.maxPages ?? 1) : 1,
|
||||
pagesFetched: 1,
|
||||
pageKey: "page"
|
||||
},
|
||||
reviews: {
|
||||
skuId,
|
||||
total: "1000",
|
||||
goodRate: "96%",
|
||||
pictureCount: "120",
|
||||
tags: [],
|
||||
comments: []
|
||||
}
|
||||
}));
|
||||
const onSessionReady = vi.fn();
|
||||
const liveService = createJdLiveServiceStub({
|
||||
getSessionSummary: () => createConfiguredSummary(),
|
||||
previewSearch,
|
||||
previewDetail,
|
||||
previewReviews
|
||||
});
|
||||
const manager = new JdSessionManagerService(liveService, {
|
||||
onSessionReady
|
||||
});
|
||||
managers.push(manager);
|
||||
|
||||
const result = await manager.runHealthCheck("ops");
|
||||
|
||||
expect(result.recovered).toBe(false);
|
||||
expect(result.state.status).toBe("healthy");
|
||||
expect(onSessionReady).toHaveBeenCalledOnce();
|
||||
expect(previewSearch).toHaveBeenCalledWith("iPhone 15", "api");
|
||||
expect(previewDetail).toHaveBeenCalledWith("100068388533");
|
||||
expect(previewReviews).toHaveBeenCalledWith("100068388533", {
|
||||
commentCount: 1,
|
||||
maxPages: 1
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-recovers a missing session via the configured recovery command", async () => {
|
||||
const executeRecoveryCommand = vi.fn(async () => ({
|
||||
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"
|
||||
}));
|
||||
const onSessionReady = vi.fn();
|
||||
const manager = new JdSessionManagerService(
|
||||
createJdLiveServiceStub(),
|
||||
{
|
||||
onSessionReady
|
||||
},
|
||||
{
|
||||
executeRecoveryCommand
|
||||
}
|
||||
);
|
||||
managers.push(manager);
|
||||
|
||||
manager.configure({
|
||||
enabled: true,
|
||||
autoLoginMode: "command",
|
||||
loginCommand: "node scripts/jd-login-ops.mjs"
|
||||
});
|
||||
|
||||
const result = await manager.runHealthCheck("ops");
|
||||
|
||||
expect(executeRecoveryCommand).toHaveBeenCalledOnce();
|
||||
expect(onSessionReady).toHaveBeenCalledOnce();
|
||||
expect(result.recovered).toBe(true);
|
||||
expect(result.state.status).toBe("healthy");
|
||||
expect(result.state.session.configured).toBe(true);
|
||||
});
|
||||
|
||||
it("switches to manual_action_required when JD triggers risk verification", async () => {
|
||||
const riskBlockedError = Object.assign(new Error("captcha required"), {
|
||||
statusCode: 423,
|
||||
code: "RISK_BLOCKED" as const
|
||||
});
|
||||
const onSessionUnavailable = vi.fn();
|
||||
const manager = new JdSessionManagerService(
|
||||
createJdLiveServiceStub({
|
||||
getSessionSummary: () => createConfiguredSummary(),
|
||||
previewSearch: async () => {
|
||||
throw riskBlockedError;
|
||||
}
|
||||
}),
|
||||
{
|
||||
onSessionUnavailable
|
||||
}
|
||||
);
|
||||
managers.push(manager);
|
||||
|
||||
const result = await manager.runHealthCheck("ops");
|
||||
|
||||
expect(result.recovered).toBe(false);
|
||||
expect(result.state.status).toBe("manual_action_required");
|
||||
expect(result.state.pendingManualAction).toBe(true);
|
||||
expect(result.state.lastFailureCode).toBe("RISK_BLOCKED");
|
||||
expect(onSessionUnavailable).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@ -1,647 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import { getJdLiveErrorCode, isJdLiveError } from "./live-session";
|
||||
import type {
|
||||
JdLiveService,
|
||||
JdLiveSessionInput,
|
||||
JdSessionManager,
|
||||
JdSessionManagerAutoMode,
|
||||
JdSessionManagerConfigInput,
|
||||
JdSessionManagerRunResult,
|
||||
JdSessionManagerState,
|
||||
JdSessionManagerStatus
|
||||
} from "./types";
|
||||
|
||||
const DEFAULT_HEARTBEAT_QUERY = "iPhone 15";
|
||||
const DEFAULT_CHECK_INTERVAL_MS = 10 * 60 * 1000;
|
||||
const DEFAULT_RUNNER_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
type StoredJdSessionManagerConfig = {
|
||||
enabled: boolean;
|
||||
autoLoginMode: JdSessionManagerAutoMode;
|
||||
loginCommand?: string | undefined;
|
||||
browserProfilePath?: string | undefined;
|
||||
heartbeatQuery: string;
|
||||
account?: string | undefined;
|
||||
password?: string | undefined;
|
||||
checkIntervalMs: number;
|
||||
runnerTimeoutMs: number;
|
||||
configuredAt?: string | undefined;
|
||||
};
|
||||
|
||||
type JdSessionManagerCallbacks = {
|
||||
onSessionReady?: () => void;
|
||||
onSessionUnavailable?: () => void;
|
||||
};
|
||||
|
||||
type JdSessionManagerDependencies = {
|
||||
executeRecoveryCommand?: (
|
||||
config: StoredJdSessionManagerConfig
|
||||
) => Promise<JdLiveSessionInput>;
|
||||
};
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function readBooleanEnv(name: string, fallback: boolean): boolean {
|
||||
const value = process.env[name]?.trim().toLowerCase();
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return value === "1" || value === "true" || value === "yes" || value === "on";
|
||||
}
|
||||
|
||||
function readPositiveIntegerEnv(name: string, fallback: number): number {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: string | null | undefined): string | undefined {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function maskAccount(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (value.length <= 4) {
|
||||
return "*".repeat(value.length);
|
||||
}
|
||||
|
||||
return `${value.slice(0, 2)}***${value.slice(-2)}`;
|
||||
}
|
||||
|
||||
function buildConfigFromEnv(): StoredJdSessionManagerConfig {
|
||||
const autoLoginMode =
|
||||
normalizeOptionalString(process.env.JD_OPS_LOGIN_COMMAND) ? "command" : "disabled";
|
||||
|
||||
return {
|
||||
enabled: readBooleanEnv("JD_OPS_AUTO_ENABLED", true),
|
||||
autoLoginMode,
|
||||
loginCommand: normalizeOptionalString(process.env.JD_OPS_LOGIN_COMMAND),
|
||||
browserProfilePath: normalizeOptionalString(process.env.JD_OPS_BROWSER_PROFILE_DIR),
|
||||
heartbeatQuery:
|
||||
normalizeOptionalString(process.env.JD_OPS_HEARTBEAT_QUERY) ?? DEFAULT_HEARTBEAT_QUERY,
|
||||
account: normalizeOptionalString(process.env.JD_OPS_ACCOUNT),
|
||||
password: normalizeOptionalString(process.env.JD_OPS_PASSWORD),
|
||||
checkIntervalMs: readPositiveIntegerEnv(
|
||||
"JD_OPS_CHECK_INTERVAL_MS",
|
||||
DEFAULT_CHECK_INTERVAL_MS
|
||||
),
|
||||
runnerTimeoutMs: readPositiveIntegerEnv(
|
||||
"JD_OPS_RUNNER_TIMEOUT_MS",
|
||||
DEFAULT_RUNNER_TIMEOUT_MS
|
||||
),
|
||||
configuredAt:
|
||||
normalizeOptionalString(process.env.JD_OPS_LOGIN_COMMAND) ||
|
||||
normalizeOptionalString(process.env.JD_OPS_ACCOUNT) ||
|
||||
normalizeOptionalString(process.env.JD_OPS_BROWSER_PROFILE_DIR)
|
||||
? nowIso()
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseState(
|
||||
liveService: JdLiveService,
|
||||
config: StoredJdSessionManagerConfig
|
||||
): JdSessionManagerState {
|
||||
return {
|
||||
status: liveService.getSessionSummary().configured ? "degraded" : "idle",
|
||||
enabled: config.enabled,
|
||||
autoLoginMode: config.autoLoginMode,
|
||||
commandConfigured: Boolean(config.loginCommand),
|
||||
accountConfigured: Boolean(config.account),
|
||||
passwordConfigured: Boolean(config.password),
|
||||
accountLabel: maskAccount(config.account),
|
||||
browserProfilePath: config.browserProfilePath,
|
||||
heartbeatQuery: config.heartbeatQuery,
|
||||
checkIntervalMs: config.checkIntervalMs,
|
||||
runnerTimeoutMs: config.runnerTimeoutMs,
|
||||
pendingManualAction: false,
|
||||
note: liveService.getSessionSummary().configured
|
||||
? "京东会话已导入,等待后台健康检查确认。"
|
||||
: "京东会话管理器已初始化,等待首次会话注入或自动恢复。",
|
||||
publicNote: liveService.getSessionSummary().configured
|
||||
? "京东会话正在后台校验。"
|
||||
: "京东会话由运维后台维护,当前尚未就绪。",
|
||||
configuredAt: config.configuredAt,
|
||||
session: liveService.getSessionSummary()
|
||||
};
|
||||
}
|
||||
|
||||
function isRecoverableFailureCode(code: string | undefined): boolean {
|
||||
return (
|
||||
code === "MISSING_SESSION" ||
|
||||
code === "SESSION_REQUIRED" ||
|
||||
code === "INVALID_COOKIE" ||
|
||||
code === "TEMPLATE_MISSING" ||
|
||||
code === "TEMPLATE_EXPIRED" ||
|
||||
code === "TEMPLATE_PAGE_FIELD_MISSING" ||
|
||||
code === "TEMPLATE_QUERY_LOCKED" ||
|
||||
code === "INVALID_TEMPLATE" ||
|
||||
code === "RISK_BLOCKED"
|
||||
);
|
||||
}
|
||||
|
||||
export class JdSessionManagerService implements JdSessionManager {
|
||||
private config = buildConfigFromEnv();
|
||||
private state: JdSessionManagerState;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private recoveryInFlight: Promise<boolean> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly liveService: JdLiveService,
|
||||
private readonly callbacks: JdSessionManagerCallbacks = {},
|
||||
private readonly dependencies: JdSessionManagerDependencies = {}
|
||||
) {
|
||||
this.state = createBaseState(liveService, this.config);
|
||||
this.restartScheduler();
|
||||
}
|
||||
|
||||
getState(): JdSessionManagerState {
|
||||
return {
|
||||
...this.state,
|
||||
session: this.liveService.getSessionSummary()
|
||||
};
|
||||
}
|
||||
|
||||
configure(input: JdSessionManagerConfigInput): JdSessionManagerState {
|
||||
const nextConfig: StoredJdSessionManagerConfig = {
|
||||
...this.config,
|
||||
...(typeof input.enabled === "boolean" ? { enabled: input.enabled } : {}),
|
||||
...(input.autoLoginMode ? { autoLoginMode: input.autoLoginMode } : {}),
|
||||
...(input.loginCommand !== undefined
|
||||
? { loginCommand: normalizeOptionalString(input.loginCommand) }
|
||||
: {}),
|
||||
...(input.browserProfilePath !== undefined
|
||||
? { browserProfilePath: normalizeOptionalString(input.browserProfilePath) }
|
||||
: {}),
|
||||
...(input.heartbeatQuery !== undefined
|
||||
? {
|
||||
heartbeatQuery:
|
||||
normalizeOptionalString(input.heartbeatQuery) ?? DEFAULT_HEARTBEAT_QUERY
|
||||
}
|
||||
: {}),
|
||||
...(input.account !== undefined ? { account: normalizeOptionalString(input.account) } : {}),
|
||||
...(input.password !== undefined
|
||||
? { password: normalizeOptionalString(input.password) }
|
||||
: {}),
|
||||
...(input.checkIntervalMs !== undefined && input.checkIntervalMs !== null
|
||||
? {
|
||||
checkIntervalMs: Number.isInteger(input.checkIntervalMs) && input.checkIntervalMs > 0
|
||||
? input.checkIntervalMs
|
||||
: this.config.checkIntervalMs
|
||||
}
|
||||
: {}),
|
||||
...(input.runnerTimeoutMs !== undefined && input.runnerTimeoutMs !== null
|
||||
? {
|
||||
runnerTimeoutMs:
|
||||
Number.isInteger(input.runnerTimeoutMs) && input.runnerTimeoutMs > 0
|
||||
? input.runnerTimeoutMs
|
||||
: this.config.runnerTimeoutMs
|
||||
}
|
||||
: {}),
|
||||
configuredAt: nowIso()
|
||||
};
|
||||
|
||||
if (!nextConfig.loginCommand) {
|
||||
nextConfig.autoLoginMode = "disabled";
|
||||
}
|
||||
|
||||
this.config = nextConfig;
|
||||
this.state = {
|
||||
...this.state,
|
||||
enabled: nextConfig.enabled,
|
||||
autoLoginMode: nextConfig.autoLoginMode,
|
||||
commandConfigured: Boolean(nextConfig.loginCommand),
|
||||
accountConfigured: Boolean(nextConfig.account),
|
||||
passwordConfigured: Boolean(nextConfig.password),
|
||||
accountLabel: maskAccount(nextConfig.account),
|
||||
browserProfilePath: nextConfig.browserProfilePath,
|
||||
heartbeatQuery: nextConfig.heartbeatQuery,
|
||||
checkIntervalMs: nextConfig.checkIntervalMs,
|
||||
runnerTimeoutMs: nextConfig.runnerTimeoutMs,
|
||||
configuredAt: nextConfig.configuredAt,
|
||||
note: "京东运维配置已更新。",
|
||||
publicNote: this.state.publicNote,
|
||||
session: this.liveService.getSessionSummary()
|
||||
};
|
||||
this.restartScheduler();
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
clearConfig(): JdSessionManagerState {
|
||||
this.config = {
|
||||
enabled: false,
|
||||
autoLoginMode: "disabled",
|
||||
heartbeatQuery: DEFAULT_HEARTBEAT_QUERY,
|
||||
checkIntervalMs: DEFAULT_CHECK_INTERVAL_MS,
|
||||
runnerTimeoutMs: DEFAULT_RUNNER_TIMEOUT_MS
|
||||
};
|
||||
this.state = {
|
||||
...this.state,
|
||||
enabled: false,
|
||||
autoLoginMode: "disabled",
|
||||
commandConfigured: false,
|
||||
accountConfigured: false,
|
||||
passwordConfigured: false,
|
||||
accountLabel: undefined,
|
||||
browserProfilePath: undefined,
|
||||
heartbeatQuery: DEFAULT_HEARTBEAT_QUERY,
|
||||
checkIntervalMs: DEFAULT_CHECK_INTERVAL_MS,
|
||||
runnerTimeoutMs: DEFAULT_RUNNER_TIMEOUT_MS,
|
||||
configuredAt: nowIso(),
|
||||
note: "京东运维配置已清空,自动恢复已停用。",
|
||||
publicNote: this.liveService.getSessionSummary().configured
|
||||
? "京东会话已存在,但后台自动恢复已停用。"
|
||||
: "京东会话由运维后台维护,当前自动恢复未启用。",
|
||||
pendingManualAction: false,
|
||||
session: this.liveService.getSessionSummary()
|
||||
};
|
||||
this.restartScheduler();
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
async importManualSession(
|
||||
input: JdLiveSessionInput,
|
||||
source = "ops-manual"
|
||||
): Promise<JdSessionManagerState> {
|
||||
this.liveService.importSession(input);
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: "degraded",
|
||||
pendingManualAction: false,
|
||||
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()
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
clearManagedSession(reason = "ops-manual-clear"): JdSessionManagerState {
|
||||
this.liveService.clearSession();
|
||||
this.callbacks.onSessionUnavailable?.();
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: "idle",
|
||||
pendingManualAction: false,
|
||||
note: `京东会话已清理:${reason}。`,
|
||||
publicNote: "京东会话由运维后台维护,当前尚未就绪。",
|
||||
session: this.liveService.getSessionSummary()
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
async runHealthCheck(trigger = "manual"): Promise<JdSessionManagerRunResult> {
|
||||
this.state = {
|
||||
...this.state,
|
||||
lastCheckAt: nowIso(),
|
||||
session: this.liveService.getSessionSummary()
|
||||
};
|
||||
|
||||
const summary = this.liveService.getSessionSummary();
|
||||
if (!summary.configured) {
|
||||
if (this.canAutoRecover()) {
|
||||
return this.runAutoRecovery(`${trigger}:missing_session`);
|
||||
}
|
||||
|
||||
this.callbacks.onSessionUnavailable?.();
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: "manual_action_required",
|
||||
pendingManualAction: true,
|
||||
note: "当前没有可用京东会话,等待运维注入或自动恢复命令。",
|
||||
publicNote: "京东会话由运维后台维护,当前尚未就绪。",
|
||||
session: summary
|
||||
};
|
||||
return {
|
||||
state: this.getState(),
|
||||
recovered: false
|
||||
};
|
||||
}
|
||||
|
||||
if (!summary.detailTemplate.available || !summary.reviewsTemplate.available) {
|
||||
if (this.canAutoRecover()) {
|
||||
return this.runAutoRecovery(`${trigger}:template_missing`);
|
||||
}
|
||||
|
||||
this.callbacks.onSessionUnavailable?.();
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: "manual_action_required",
|
||||
pendingManualAction: true,
|
||||
note: "京东详情或评论模板缺失,需要运维刷新模板。",
|
||||
publicNote: "京东会话缺少有效模板,运维后台正在处理。",
|
||||
session: summary
|
||||
};
|
||||
return {
|
||||
state: this.getState(),
|
||||
recovered: false
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await this.verifyCurrentSession();
|
||||
this.callbacks.onSessionReady?.();
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: "healthy",
|
||||
pendingManualAction: false,
|
||||
note: `京东会话健康检查通过:${trigger}。`,
|
||||
publicNote: "京东会话由运维后台维护,当前可用。",
|
||||
lastHealthyAt: nowIso(),
|
||||
lastFailureCode: undefined,
|
||||
lastFailureMessage: undefined,
|
||||
session: this.liveService.getSessionSummary()
|
||||
};
|
||||
return {
|
||||
state: this.getState(),
|
||||
recovered: false
|
||||
};
|
||||
} catch (error) {
|
||||
const recovered = await this.handleFailure(error, `${trigger}:health_check`);
|
||||
return {
|
||||
state: this.getState(),
|
||||
recovered
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async runAutoRecovery(trigger = "manual"): Promise<JdSessionManagerRunResult> {
|
||||
const recovered = await this.attemptAutoRecovery(trigger);
|
||||
return {
|
||||
state: this.getState(),
|
||||
recovered
|
||||
};
|
||||
}
|
||||
|
||||
async handleLiveFailure(
|
||||
error: unknown,
|
||||
context: {
|
||||
capability?: "search" | "detail" | "reviews";
|
||||
taskId?: string | undefined;
|
||||
trigger?: string | undefined;
|
||||
} = {}
|
||||
): Promise<boolean> {
|
||||
const contextLabel = [
|
||||
context.trigger ?? "system",
|
||||
context.capability ?? "unknown",
|
||||
context.taskId ?? "no-task"
|
||||
].join(":");
|
||||
return this.handleFailure(error, contextLabel);
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private restartScheduler(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
if (!this.config.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
void this.runHealthCheck("scheduler");
|
||||
}, this.config.checkIntervalMs);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
private canAutoRecover(): boolean {
|
||||
return (
|
||||
this.config.enabled &&
|
||||
this.config.autoLoginMode === "command" &&
|
||||
Boolean(this.config.loginCommand)
|
||||
);
|
||||
}
|
||||
|
||||
private async handleFailure(error: unknown, trigger: string): Promise<boolean> {
|
||||
const code = getJdLiveErrorCode(error);
|
||||
const message = error instanceof Error ? error.message : "unknown error";
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: isRecoverableFailureCode(code) ? "degraded" : "manual_action_required",
|
||||
pendingManualAction: code === "RISK_BLOCKED" || !this.canAutoRecover(),
|
||||
lastFailureCode: code,
|
||||
lastFailureMessage: message,
|
||||
note: `京东会话检测失败:${message}`,
|
||||
publicNote:
|
||||
code === "RISK_BLOCKED"
|
||||
? "京东触发了风控验证,运维后台需要人工恢复。"
|
||||
: "京东会话正在后台恢复,请稍后自动重试。",
|
||||
session: this.liveService.getSessionSummary()
|
||||
};
|
||||
|
||||
if (!isRecoverableFailureCode(code)) {
|
||||
this.callbacks.onSessionUnavailable?.();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (code === "RISK_BLOCKED" && !this.canAutoRecover()) {
|
||||
this.callbacks.onSessionUnavailable?.();
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: "manual_action_required",
|
||||
pendingManualAction: true,
|
||||
note: "京东触发风控验证,需要运维人工处理。",
|
||||
publicNote: "京东触发了风控验证,运维后台需要人工恢复。"
|
||||
};
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.attemptAutoRecovery(trigger);
|
||||
}
|
||||
|
||||
private async attemptAutoRecovery(trigger: string): Promise<boolean> {
|
||||
if (!this.canAutoRecover()) {
|
||||
this.callbacks.onSessionUnavailable?.();
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: "manual_action_required",
|
||||
pendingManualAction: true,
|
||||
note: "未配置自动恢复命令,等待运维人工更新京东会话。",
|
||||
publicNote: "京东会话需要运维人工恢复,系统会在恢复后自动继续。"
|
||||
};
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.recoveryInFlight) {
|
||||
return this.recoveryInFlight;
|
||||
}
|
||||
|
||||
this.recoveryInFlight = this.runRecoveryCommand(trigger).finally(() => {
|
||||
this.recoveryInFlight = null;
|
||||
});
|
||||
return this.recoveryInFlight;
|
||||
}
|
||||
|
||||
private async runRecoveryCommand(trigger: string): Promise<boolean> {
|
||||
this.callbacks.onSessionUnavailable?.();
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: "recovering",
|
||||
pendingManualAction: false,
|
||||
lastRecoverAttemptAt: nowIso(),
|
||||
note: `正在执行京东自动恢复:${trigger}`,
|
||||
publicNote: "京东会话正在后台恢复,请稍后自动重试。",
|
||||
session: this.liveService.getSessionSummary()
|
||||
};
|
||||
|
||||
try {
|
||||
const payload = await this.executeRecoveryCommand();
|
||||
this.liveService.importSession(payload);
|
||||
await this.verifyCurrentSession();
|
||||
this.callbacks.onSessionReady?.();
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: "healthy",
|
||||
pendingManualAction: false,
|
||||
note: `京东自动恢复成功:${trigger}`,
|
||||
publicNote: "京东会话由运维后台维护,当前可用。",
|
||||
lastRecoveredAt: nowIso(),
|
||||
lastHealthyAt: nowIso(),
|
||||
lastFailureCode: undefined,
|
||||
lastFailureMessage: undefined,
|
||||
session: this.liveService.getSessionSummary()
|
||||
};
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "unknown error";
|
||||
const code =
|
||||
getJdLiveErrorCode(error) ??
|
||||
(isJdLiveError(error) ? String(error.statusCode) : "OPS_RECOVERY_FAILED");
|
||||
this.callbacks.onSessionUnavailable?.();
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: "manual_action_required",
|
||||
pendingManualAction: true,
|
||||
note: `京东自动恢复失败:${message}`,
|
||||
publicNote: "京东会话需要运维人工恢复,系统会在恢复后自动继续。",
|
||||
lastFailureCode: code,
|
||||
lastFailureMessage: message,
|
||||
session: this.liveService.getSessionSummary()
|
||||
};
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyCurrentSession(): Promise<void> {
|
||||
const summary = this.liveService.getSessionSummary();
|
||||
const detailSkuId = summary.detailTemplate.skuId;
|
||||
const reviewsSkuId = summary.reviewsTemplate.skuId ?? detailSkuId;
|
||||
|
||||
if (!summary.configured || !detailSkuId || !reviewsSkuId) {
|
||||
throw new Error("京东会话缺少可校验的详情/评论模板。");
|
||||
}
|
||||
|
||||
await this.liveService.previewSearch(
|
||||
this.config.heartbeatQuery,
|
||||
summary.searchApiTemplate.available ? "api" : "html"
|
||||
);
|
||||
await this.liveService.previewDetail(detailSkuId);
|
||||
await this.liveService.previewReviews(reviewsSkuId, {
|
||||
commentCount: 1,
|
||||
maxPages: 1
|
||||
});
|
||||
}
|
||||
|
||||
private executeRecoveryCommand(): Promise<JdLiveSessionInput> {
|
||||
if (this.dependencies.executeRecoveryCommand) {
|
||||
return this.dependencies.executeRecoveryCommand(this.config);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const command = this.config.loginCommand;
|
||||
if (!command) {
|
||||
reject(new Error("JD ops login command is not configured."));
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(command, {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
JD_OPS_ACCOUNT: this.config.account ?? "",
|
||||
JD_OPS_PASSWORD: this.config.password ?? "",
|
||||
JD_OPS_BROWSER_PROFILE_DIR: this.config.browserProfilePath ?? "",
|
||||
JD_OPS_HEARTBEAT_QUERY: this.config.heartbeatQuery
|
||||
},
|
||||
shell: true,
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill();
|
||||
reject(
|
||||
new Error(
|
||||
`JD ops login command timed out after ${this.config.runnerTimeoutMs}ms.`
|
||||
)
|
||||
);
|
||||
}, this.config.runnerTimeoutMs);
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (code !== 0) {
|
||||
reject(
|
||||
new Error(
|
||||
`JD ops login command failed with code ${code}. ${stderr.trim() || stdout.trim()}`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) {
|
||||
reject(new Error("JD ops login command returned an empty payload."));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(trimmed) as JdLiveSessionInput;
|
||||
resolve(payload);
|
||||
} catch {
|
||||
reject(
|
||||
new Error(
|
||||
`JD ops login command did not return valid JSON. Output: ${trimmed.slice(-500)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -15,9 +15,6 @@ export interface JdLiveSessionInput {
|
||||
reviewsTemplateUrl?: string | undefined;
|
||||
searchReferer?: string | undefined;
|
||||
detailReferer?: string | undefined;
|
||||
searchRequestHeaders?: Record<string, string> | undefined;
|
||||
detailRequestHeaders?: Record<string, string> | undefined;
|
||||
reviewsRequestHeaders?: Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
export interface JdLiveSessionSummary {
|
||||
@ -30,57 +27,6 @@ export interface JdLiveSessionSummary {
|
||||
reviewsTemplate: JdTemplateSummary;
|
||||
}
|
||||
|
||||
export type JdSessionManagerStatus =
|
||||
| "idle"
|
||||
| "healthy"
|
||||
| "degraded"
|
||||
| "recovering"
|
||||
| "manual_action_required";
|
||||
|
||||
export type JdSessionManagerAutoMode = "disabled" | "command";
|
||||
|
||||
export interface JdSessionManagerConfigInput {
|
||||
enabled?: boolean | undefined;
|
||||
autoLoginMode?: JdSessionManagerAutoMode | undefined;
|
||||
loginCommand?: string | null | undefined;
|
||||
browserProfilePath?: string | null | undefined;
|
||||
heartbeatQuery?: string | null | undefined;
|
||||
account?: string | null | undefined;
|
||||
password?: string | null | undefined;
|
||||
checkIntervalMs?: number | null | undefined;
|
||||
runnerTimeoutMs?: number | null | undefined;
|
||||
}
|
||||
|
||||
export interface JdSessionManagerState {
|
||||
status: JdSessionManagerStatus;
|
||||
enabled: boolean;
|
||||
autoLoginMode: JdSessionManagerAutoMode;
|
||||
commandConfigured: boolean;
|
||||
accountConfigured: boolean;
|
||||
passwordConfigured: boolean;
|
||||
accountLabel?: string | undefined;
|
||||
browserProfilePath?: string | undefined;
|
||||
heartbeatQuery: string;
|
||||
checkIntervalMs: number;
|
||||
runnerTimeoutMs: number;
|
||||
pendingManualAction: boolean;
|
||||
note: string;
|
||||
publicNote: string;
|
||||
configuredAt?: string | undefined;
|
||||
lastCheckAt?: string | undefined;
|
||||
lastHealthyAt?: string | undefined;
|
||||
lastRecoverAttemptAt?: string | undefined;
|
||||
lastRecoveredAt?: string | undefined;
|
||||
lastFailureCode?: string | undefined;
|
||||
lastFailureMessage?: string | undefined;
|
||||
session: JdLiveSessionSummary;
|
||||
}
|
||||
|
||||
export interface JdSessionManagerRunResult {
|
||||
state: JdSessionManagerState;
|
||||
recovered: boolean;
|
||||
}
|
||||
|
||||
export interface JdSearchPreviewResult {
|
||||
query: string;
|
||||
source: JdSearchMode;
|
||||
@ -125,20 +71,6 @@ export interface JdProductReviewsSnapshot {
|
||||
comments: JdReviewCommentSnapshot[];
|
||||
}
|
||||
|
||||
export interface JdReviewsPreviewOptions {
|
||||
commentCount?: number | undefined;
|
||||
page?: number | undefined;
|
||||
maxPages?: number | undefined;
|
||||
}
|
||||
|
||||
export interface JdReviewsPaginationSummary {
|
||||
requestedPage: number;
|
||||
requestedCommentCount: number;
|
||||
maxPages: number;
|
||||
pagesFetched: number;
|
||||
pageKey?: string | undefined;
|
||||
}
|
||||
|
||||
export interface JdDetailPreviewResult {
|
||||
skuId: string;
|
||||
source: "api";
|
||||
@ -148,61 +80,14 @@ export interface JdDetailPreviewResult {
|
||||
export interface JdReviewsPreviewResult {
|
||||
skuId: string;
|
||||
source: "api";
|
||||
pagination: JdReviewsPaginationSummary;
|
||||
reviews: JdProductReviewsSnapshot;
|
||||
}
|
||||
|
||||
export interface JdProductPreviewResult {
|
||||
skuId: string;
|
||||
source: "api";
|
||||
detail: JdProductDetailSnapshot;
|
||||
pagination: JdReviewsPaginationSummary;
|
||||
reviews: JdProductReviewsSnapshot;
|
||||
}
|
||||
|
||||
export interface JdBrowserPreviewProvider {
|
||||
previewSearch(query: string, mode?: JdSearchMode): Promise<JdSearchPreviewResult>;
|
||||
previewDetail(skuId: string): Promise<JdDetailPreviewResult>;
|
||||
previewReviews(
|
||||
skuId: string,
|
||||
options?: number | JdReviewsPreviewOptions
|
||||
): Promise<JdReviewsPreviewResult>;
|
||||
}
|
||||
|
||||
export interface JdLiveService {
|
||||
getSessionSummary(): JdLiveSessionSummary;
|
||||
importSession(input: JdLiveSessionInput): JdLiveSessionSummary;
|
||||
clearSession(): void;
|
||||
previewSearch(query: string, mode?: JdSearchMode): Promise<JdSearchPreviewResult>;
|
||||
previewDetail(skuId: string): Promise<JdDetailPreviewResult>;
|
||||
previewReviews(
|
||||
skuId: string,
|
||||
options?: number | JdReviewsPreviewOptions
|
||||
): Promise<JdReviewsPreviewResult>;
|
||||
previewProduct(
|
||||
skuId: string,
|
||||
options?: number | JdReviewsPreviewOptions
|
||||
): Promise<JdProductPreviewResult>;
|
||||
}
|
||||
|
||||
export interface JdSessionManager {
|
||||
getState(): JdSessionManagerState;
|
||||
configure(input: JdSessionManagerConfigInput): JdSessionManagerState;
|
||||
clearConfig(): JdSessionManagerState;
|
||||
importManualSession(
|
||||
input: JdLiveSessionInput,
|
||||
source?: string
|
||||
): Promise<JdSessionManagerState>;
|
||||
clearManagedSession(reason?: string): JdSessionManagerState;
|
||||
runHealthCheck(trigger?: string): Promise<JdSessionManagerRunResult>;
|
||||
runAutoRecovery(trigger?: string): Promise<JdSessionManagerRunResult>;
|
||||
handleLiveFailure(
|
||||
error: unknown,
|
||||
context?: {
|
||||
capability?: "search" | "detail" | "reviews";
|
||||
taskId?: string | undefined;
|
||||
trigger?: string | undefined;
|
||||
}
|
||||
): Promise<boolean>;
|
||||
shutdown(): void;
|
||||
previewReviews(skuId: string, commentCount?: number): Promise<JdReviewsPreviewResult>;
|
||||
}
|
||||
|
||||
@ -103,23 +103,6 @@ export function readQueryBody(url: URL): Record<string, unknown> | null {
|
||||
return parseEmbeddedJson(url.searchParams.get("body"));
|
||||
}
|
||||
|
||||
export function findFirstBodyKey(
|
||||
body: Record<string, unknown> | null,
|
||||
candidates: string[]
|
||||
): string | null {
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate in body) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function withUpdatedQueryBody(
|
||||
url: URL,
|
||||
updater: (body: Record<string, unknown>) => Record<string, unknown>
|
||||
|
||||
@ -1,356 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { TmallLiveSessionService } from "./live-session";
|
||||
|
||||
function buildResponse(body: Record<string, unknown>): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildHtmlResponse(body: string): Response {
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/html"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildTextResponse(body: string): Response {
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/javascript"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("TmallLiveSessionService", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("fetches the mall search page and parses candidates from embedded state", async () => {
|
||||
const html = `<script>var g_page_config = ${JSON.stringify({
|
||||
mods: {
|
||||
itemlist: {
|
||||
data: {
|
||||
auctions: [
|
||||
{
|
||||
nid: "934454505228",
|
||||
raw_title: "Apple iPhone 15 Natural Titanium 128GB",
|
||||
view_price: "4399.00",
|
||||
nick: "Apple Flagship Store",
|
||||
detail_url: "//detail.tmall.com/item.htm?id=934454505228",
|
||||
pic_url: "//img.alicdn.com/example-search.jpg",
|
||||
view_sales: "sold 10k+",
|
||||
isTmall: "true"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})};</script>`;
|
||||
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
|
||||
buildHtmlResponse(html)
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const service = new TmallLiveSessionService();
|
||||
service.importSession({
|
||||
cookieHeader: "_tb_token_=masked;"
|
||||
});
|
||||
|
||||
const preview = await service.previewSearch("iPhone 15");
|
||||
|
||||
expect(preview).toMatchObject({
|
||||
query: "iPhone 15",
|
||||
source: "html",
|
||||
candidateCount: 1,
|
||||
candidates: [
|
||||
expect.objectContaining({
|
||||
candidateId: "tmall-934454505228",
|
||||
productUrl: "https://detail.tmall.com/item.htm?id=934454505228"
|
||||
})
|
||||
]
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
"https://s.taobao.com/search?q=iPhone%2015&tab=mall"
|
||||
);
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
headers: {
|
||||
Referer: "https://www.tmall.com/"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("parses detail from the embedded Tmall SSR page state", async () => {
|
||||
const pageState = {
|
||||
appData: null,
|
||||
loaderData: {
|
||||
home: {
|
||||
data: {
|
||||
res: {
|
||||
seller: {
|
||||
shopName: "Apple Flagship Store",
|
||||
pcShopUrl: "//apple.tmall.com",
|
||||
sellerType: "B"
|
||||
},
|
||||
item: {
|
||||
itemId: "833444005596",
|
||||
title: "Apple iPhone 15",
|
||||
images: ["//img.alicdn.com/example.jpg"],
|
||||
vagueSellCount: "sold 10k+"
|
||||
},
|
||||
componentsVO: {
|
||||
titleVO: {
|
||||
title: {
|
||||
title: "Apple iPhone 15"
|
||||
},
|
||||
salesDesc: "sold 10k+"
|
||||
},
|
||||
priceVO: {
|
||||
extraPrice: {
|
||||
priceText: "4399.00"
|
||||
},
|
||||
price: {
|
||||
priceText: "4999.00"
|
||||
}
|
||||
},
|
||||
rateVO: {
|
||||
totalCount: "20k+"
|
||||
},
|
||||
headImageVO: {
|
||||
images: ["//img.alicdn.com/example.jpg"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const html = [
|
||||
"<html><head><title>detail</title></head><body>",
|
||||
"<script>",
|
||||
"!(function () {",
|
||||
"var a = window.__ICE_APP_CONTEXT__ || {};",
|
||||
`var b = ${JSON.stringify(pageState)};`,
|
||||
"window.__ICE_APP_CONTEXT__ = Object.assign({}, a, b);",
|
||||
"})();",
|
||||
"</script>",
|
||||
"</body></html>"
|
||||
].join("");
|
||||
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
|
||||
buildHtmlResponse(html)
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const service = new TmallLiveSessionService();
|
||||
service.importSession({
|
||||
cookieHeader: "_tb_token_=masked;"
|
||||
});
|
||||
|
||||
const preview = await service.previewDetail("833444005596");
|
||||
|
||||
expect(preview.detail).toMatchObject({
|
||||
itemId: "833444005596",
|
||||
title: "Apple iPhone 15",
|
||||
shopName: "Apple Flagship Store",
|
||||
price: "4399.00",
|
||||
originalPrice: "4999.00",
|
||||
salesDesc: "sold 10k+",
|
||||
commentCount: "20k+",
|
||||
sellerType: "tmall"
|
||||
});
|
||||
expect(preview.source).toBe("html");
|
||||
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 fetch to receive both url and init.");
|
||||
}
|
||||
|
||||
expect(requestUrl).toBe("https://detail.tmall.com/item.htm?id=833444005596");
|
||||
expect(requestInit.headers).toMatchObject({
|
||||
Referer: "https://detail.tmall.com/"
|
||||
});
|
||||
});
|
||||
|
||||
it("paginates and deduplicates Tmall PC reviews across multiple pages", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1775186257185);
|
||||
const fetchMock = vi
|
||||
.fn<(input: string, init?: RequestInit) => Promise<Response>>()
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
ret: ["SUCCESS::ok"],
|
||||
data: {
|
||||
module: {
|
||||
hasNext: "true",
|
||||
reviewVOList: [
|
||||
{
|
||||
id: "review-1",
|
||||
userNick: "Alice",
|
||||
reviewDate: "2026-04-03",
|
||||
reviewWordContent: "First review",
|
||||
reviewPicPathList: ["//img.alicdn.com/review-1.jpg"]
|
||||
},
|
||||
{
|
||||
id: "review-2",
|
||||
userNick: "Bob",
|
||||
reviewDate: "2026-04-03",
|
||||
reviewWordContent: "Second review"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildResponse({
|
||||
ret: ["SUCCESS::ok"],
|
||||
data: {
|
||||
module: {
|
||||
hasNext: "false",
|
||||
reviewVOList: [
|
||||
{
|
||||
id: "review-2",
|
||||
userNick: "Bob",
|
||||
reviewDate: "2026-04-03",
|
||||
reviewWordContent: "Second review"
|
||||
},
|
||||
{
|
||||
id: "review-3",
|
||||
userNick: "Cathy",
|
||||
reviewDate: "2026-04-04",
|
||||
reviewWordContent: "Third review"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const service = new TmallLiveSessionService();
|
||||
service.importSession({
|
||||
cookieHeader:
|
||||
"_tb_token_=masked; _m_h5_tk=fcce0507fd586e928c94a9d54fec6a5c_1775194955427;",
|
||||
reviewsTemplateUrl:
|
||||
"https://h5api.m.taobao.com/h5/mtop.alibaba.review.list.for.new.pc.detail/1.0/?" +
|
||||
"api=mtop.alibaba.review.list.for.new.pc.detail&v=1.0&" +
|
||||
"data=%7B%22itemId%22%3A%22833444005595%22%2C%22bizCode%22%3A%22ali.china.tmall%22%2C%22channel%22%3A%22pc_detail%22%2C%22pageNum%22%3A1%2C%22pageSize%22%3A2%7D"
|
||||
});
|
||||
|
||||
const preview = await service.previewReviews("833444005596", {
|
||||
commentCount: 2,
|
||||
page: 1,
|
||||
maxPages: 2
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const firstUrl = fetchMock.mock.calls[0]?.[0];
|
||||
const secondUrl = fetchMock.mock.calls[1]?.[0];
|
||||
if (!firstUrl || !secondUrl) {
|
||||
throw new Error("Expected paged reviews requests to be issued.");
|
||||
}
|
||||
|
||||
const firstData = JSON.parse(
|
||||
decodeURIComponent(new URL(firstUrl).searchParams.get("data") ?? "")
|
||||
) as Record<string, unknown>;
|
||||
const secondData = JSON.parse(
|
||||
decodeURIComponent(new URL(secondUrl).searchParams.get("data") ?? "")
|
||||
) as Record<string, unknown>;
|
||||
|
||||
expect(firstData.itemId).toBe("833444005596");
|
||||
expect(firstData.pageNum).toBe(1);
|
||||
expect(secondData.pageNum).toBe(2);
|
||||
expect(new URL(firstUrl).searchParams.get("t")).toBe("1775186257185");
|
||||
expect(new URL(firstUrl).searchParams.get("sign")).toBe(
|
||||
"b8fa133d502fd87c9d5bc9dbf95032d7"
|
||||
);
|
||||
expect(new URL(secondUrl).searchParams.get("sign")).toBe(
|
||||
"8eda756f901c9f588209b0498e898928"
|
||||
);
|
||||
|
||||
expect(preview.pagination).toMatchObject({
|
||||
requestedPage: 1,
|
||||
requestedCommentCount: 2,
|
||||
maxPages: 2,
|
||||
pagesFetched: 2,
|
||||
pageKey: "pageNum"
|
||||
});
|
||||
expect(preview.reviews.comments.map((comment) => comment.id)).toEqual([
|
||||
"review-1",
|
||||
"review-2",
|
||||
"review-3"
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses H5 review JSONP responses from mtop.taobao.rate.detaillist.get", async () => {
|
||||
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
|
||||
buildTextResponse(
|
||||
'mtopjsonp13({"api":"mtop.taobao.rate.detaillist.get","data":{"hasNext":"true","fuzzyRateCount":"5万+","feedPicCountFuzzy":"7000+","feedAppendCountFuzzy":"800+","imprItemVOS":[{"title":"发货速度快","count":"139"}],"rateList":[{"id":"1300870633402","feedback":"挺好的,外观高级","feedbackDate":"2026年3月27日","userNick":"可**月","headPicUrl":"//sns.m.taobao.com/avatar/example","skuMap":{"颜色分类":"小米手环10 银色"},"feedPicPathList":["//img.alicdn.com/review-1.jpg"],"interactInfo":{"likeCount":"1"},"reply":"感谢您的认可"}]}})'
|
||||
)
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const service = new TmallLiveSessionService();
|
||||
service.importSession({
|
||||
cookieHeader:
|
||||
"_tb_token_=masked; _m_h5_tk=41fb9bae259f3b525fa0be5bfa989739_1775196962107;",
|
||||
reviewsTemplateUrl:
|
||||
"https://h5api.m.tmall.com/h5/mtop.taobao.rate.detaillist.get/6.0/?" +
|
||||
"api=mtop.taobao.rate.detaillist.get&v=6.0&" +
|
||||
"data=%7B%22auctionNumId%22%3A%22934454505228%22%2C%22pageNo%22%3A1%2C%22pageSize%22%3A20%7D"
|
||||
});
|
||||
|
||||
const preview = await service.previewReviews("934454505228");
|
||||
|
||||
expect(preview.reviews.tags).toEqual([{ name: "发货速度快", count: "139" }]);
|
||||
expect(preview.reviews.comments).toHaveLength(1);
|
||||
expect(preview.reviews.comments[0]).toMatchObject({
|
||||
id: "1300870633402",
|
||||
content: "挺好的,外观高级",
|
||||
date: "2026年3月27日",
|
||||
userNick: "可**月",
|
||||
likeCount: "1",
|
||||
reply: "感谢您的认可"
|
||||
});
|
||||
expect(preview.reviews.comments[0]?.pictureUrls).toEqual([
|
||||
"https://img.alicdn.com/review-1.jpg"
|
||||
]);
|
||||
expect(preview.reviews.comments[0]?.skuText).toEqual(["小米手环10 银色"]);
|
||||
});
|
||||
|
||||
it("rejects review replay with a login redirect response", async () => {
|
||||
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
|
||||
buildResponse({
|
||||
ret: ["RGV587_ERROR::SM::session required"],
|
||||
data: {
|
||||
url: "https://login.taobao.com/member/login.jhtml?redirectURL=masked"
|
||||
}
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const service = new TmallLiveSessionService();
|
||||
service.importSession({
|
||||
cookieHeader:
|
||||
"_tb_token_=masked; _m_h5_tk=fcce0507fd586e928c94a9d54fec6a5c_1775194955427;",
|
||||
reviewsTemplateUrl:
|
||||
"https://h5api.m.taobao.com/h5/mtop.taobao.rate.detaillist.get/6.0/?" +
|
||||
"api=mtop.taobao.rate.detaillist.get&v=6.0&data=%7B%22auctionNumId%22%3A%22833444005595%22%2C%22pageNo%22%3A1%2C%22pageSize%22%3A20%7D"
|
||||
});
|
||||
|
||||
await expect(service.previewReviews("833444005596")).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
message: expect.stringContaining("Re-login")
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,705 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
extractTmallRetCodes,
|
||||
hasTmallSearchNoResultMarker,
|
||||
parseTmallDetailApiResponse,
|
||||
parseTmallDetailHtmlResponse,
|
||||
parseTmallSearchHtml,
|
||||
parseTmallReviewsApiResponse
|
||||
} from "./parsers";
|
||||
import type {
|
||||
TmallDetailPreviewResult,
|
||||
TmallLiveService,
|
||||
TmallLiveSessionInput,
|
||||
TmallLiveSessionSummary,
|
||||
TmallProductPreviewResult,
|
||||
TmallReviewsPaginationSummary,
|
||||
TmallReviewsPreviewOptions,
|
||||
TmallReviewsPreviewResult,
|
||||
TmallSearchPreviewResult,
|
||||
TmallTemplateSummary
|
||||
} from "./types";
|
||||
import {
|
||||
asRecord,
|
||||
findFirstRecordKey,
|
||||
firstString,
|
||||
parseEmbeddedJson,
|
||||
readNestedJsonRecord,
|
||||
readQueryData,
|
||||
stringFrom,
|
||||
withUpdatedQueryData
|
||||
} from "./utils";
|
||||
|
||||
const DEFAULT_TMALL_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 DETAIL_ITEM_DATA_KEYS = ["id", "itemId", "itemNumId"];
|
||||
const REVIEW_PC_ITEM_DATA_KEYS = ["itemId", "id"];
|
||||
const REVIEW_H5_ITEM_DATA_KEYS = ["auctionNumId", "itemId", "id"];
|
||||
const REVIEW_PAGE_DATA_KEYS = ["pageNum", "pageNo", "page", "currentPage"];
|
||||
const REVIEW_PAGE_SIZE_DATA_KEYS = ["pageSize", "pageLimit", "commentSize"];
|
||||
const DEFAULT_LOGIC_VERSION = "2025031302";
|
||||
|
||||
type ResolvedTmallReviewsPreviewOptions = {
|
||||
commentCount: number;
|
||||
page: number;
|
||||
maxPages: number;
|
||||
};
|
||||
|
||||
type StoredTmallLiveSession = {
|
||||
cookieHeader: string;
|
||||
importedAt: string;
|
||||
userAgent: string;
|
||||
detailTemplateUrl?: string | undefined;
|
||||
reviewsTemplateUrl?: string | undefined;
|
||||
detailReferer?: string | undefined;
|
||||
};
|
||||
|
||||
class TmallLiveError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number = 400
|
||||
) {
|
||||
super(message);
|
||||
this.name = "TmallLiveError";
|
||||
}
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function readEnvSession(): StoredTmallLiveSession | null {
|
||||
const cookieHeader = process.env.TMALL_COOKIE_HEADER?.trim();
|
||||
if (!cookieHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const detailTemplateUrl = process.env.TMALL_DETAIL_TEMPLATE_URL?.trim();
|
||||
const reviewsTemplateUrl = process.env.TMALL_REVIEWS_TEMPLATE_URL?.trim();
|
||||
const detailReferer = process.env.TMALL_DETAIL_REFERER?.trim();
|
||||
|
||||
return {
|
||||
cookieHeader,
|
||||
importedAt: nowIso(),
|
||||
userAgent: process.env.TMALL_USER_AGENT?.trim() || DEFAULT_TMALL_USER_AGENT,
|
||||
...(detailTemplateUrl ? { detailTemplateUrl } : {}),
|
||||
...(reviewsTemplateUrl ? { reviewsTemplateUrl } : {}),
|
||||
...(detailReferer ? { detailReferer } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function requireNonEmptyCookie(cookieHeader: string): string {
|
||||
const normalized = cookieHeader.trim();
|
||||
if (!normalized) {
|
||||
throw new TmallLiveError("cookieHeader is required for Tmall live requests.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function extractCookieValue(cookieHeader: string, name: string): string | null {
|
||||
let matched: string | null = null;
|
||||
|
||||
for (const part of cookieHeader.split(";")) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const separatorIndex = trimmed.indexOf("=");
|
||||
if (separatorIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = trimmed.slice(0, separatorIndex).trim();
|
||||
if (key !== name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
matched = trimmed.slice(separatorIndex + 1).trim();
|
||||
}
|
||||
|
||||
return matched;
|
||||
}
|
||||
|
||||
function extractMtopToken(cookieHeader: string): string {
|
||||
const tokenCookie = extractCookieValue(cookieHeader, "_m_h5_tk");
|
||||
const token = tokenCookie?.split("_")[0]?.trim();
|
||||
if (!token) {
|
||||
throw new TmallLiveError(
|
||||
"Tmall cookie header is missing _m_h5_tk, so signed mtop replay cannot be rebuilt."
|
||||
);
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
function signMtopData(token: string, timestamp: string, appKey: string, data: string): string {
|
||||
return createHash("md5").update(`${token}&${timestamp}&${appKey}&${data}`).digest("hex");
|
||||
}
|
||||
|
||||
function refreshMtopSignature(url: string, cookieHeader: string): string {
|
||||
const nextUrl = new URL(url);
|
||||
const data = nextUrl.searchParams.get("data") ?? nextUrl.searchParams.get("body");
|
||||
if (!data) {
|
||||
return nextUrl.toString();
|
||||
}
|
||||
|
||||
const appKey = nextUrl.searchParams.get("appKey") ?? "12574478";
|
||||
const timestamp = String(Date.now());
|
||||
const sign = signMtopData(extractMtopToken(cookieHeader), timestamp, appKey, data);
|
||||
|
||||
nextUrl.searchParams.set("appKey", appKey);
|
||||
nextUrl.searchParams.set("t", timestamp);
|
||||
nextUrl.searchParams.set("sign", sign);
|
||||
return nextUrl.toString();
|
||||
}
|
||||
|
||||
function extractTemplateApi(templateUrl: string | undefined): string | undefined {
|
||||
if (!templateUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const url = new URL(templateUrl);
|
||||
const direct = url.searchParams.get("api");
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const match = url.pathname.match(/\/h5\/([^/]+)\/[^/]+\/$/i);
|
||||
return match?.[1] ? match[1] : undefined;
|
||||
}
|
||||
|
||||
function extractTemplateItemId(templateUrl: string | undefined): string | undefined {
|
||||
if (!templateUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const data = readQueryData(new URL(templateUrl));
|
||||
const direct =
|
||||
firstString(data?.id, data?.itemId, data?.itemNumId, data?.auctionNumId) ?? undefined;
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const exParams = readNestedJsonRecord(data?.exParams);
|
||||
const queryParams = stringFrom(exParams?.queryParams);
|
||||
if (!queryParams) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(queryParams);
|
||||
return firstString(
|
||||
params.get("itemId"),
|
||||
params.get("id"),
|
||||
params.get("itemNumId"),
|
||||
params.get("auctionNumId")
|
||||
) ?? undefined;
|
||||
}
|
||||
|
||||
function buildTemplateSummary(templateUrl: string | undefined): TmallTemplateSummary {
|
||||
const api = extractTemplateApi(templateUrl);
|
||||
const itemId = extractTemplateItemId(templateUrl);
|
||||
|
||||
return {
|
||||
available: Boolean(templateUrl),
|
||||
...(api ? { api } : {}),
|
||||
...(itemId ? { itemId } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function coerceValue(existing: unknown, nextValue: number | string): number | string {
|
||||
if (typeof existing === "number") {
|
||||
return typeof nextValue === "number" ? nextValue : Number.parseInt(nextValue, 10);
|
||||
}
|
||||
|
||||
return String(nextValue);
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(
|
||||
value: number | undefined,
|
||||
fieldName: string,
|
||||
fallback: number,
|
||||
max: number
|
||||
): number {
|
||||
if (value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(value) || value < 1 || value > max) {
|
||||
throw new TmallLiveError(`${fieldName} must be an integer between 1 and ${max}.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeReviewsPreviewOptions(
|
||||
options?: number | TmallReviewsPreviewOptions
|
||||
): ResolvedTmallReviewsPreviewOptions {
|
||||
if (typeof options === "number") {
|
||||
return {
|
||||
commentCount: normalizePositiveInteger(options, "commentCount", 20, 50),
|
||||
page: 1,
|
||||
maxPages: 1
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
commentCount: normalizePositiveInteger(options?.commentCount, "commentCount", 20, 50),
|
||||
page: normalizePositiveInteger(options?.page, "page", 1, 1000),
|
||||
maxPages: normalizePositiveInteger(options?.maxPages, "maxPages", 1, 10)
|
||||
};
|
||||
}
|
||||
|
||||
function buildSecurityNonce(itemId: string, logicVer: string): string {
|
||||
const hash = createHash("sha256").update(`${itemId}${logicVer}`).digest("hex");
|
||||
const prefix = (
|
||||
14802254 ^
|
||||
Number.parseInt(hash.slice(0, 6), 16) ^
|
||||
Number.parseInt(hash.slice(0, 6), 16)
|
||||
)
|
||||
.toString(16)
|
||||
.padStart(6, "0");
|
||||
|
||||
return `${prefix}${hash.slice(6)}`;
|
||||
}
|
||||
|
||||
function updateDetailExParams(
|
||||
currentData: Record<string, unknown>,
|
||||
nextData: Record<string, unknown>,
|
||||
itemId: string
|
||||
): void {
|
||||
const exParams = readNestedJsonRecord(currentData.exParams);
|
||||
if (!exParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams(stringFrom(exParams.queryParams) ?? "");
|
||||
for (const key of ["itemId", "id", "itemNumId", "auctionNumId"]) {
|
||||
if (queryParams.has(key)) {
|
||||
queryParams.set(key, itemId);
|
||||
}
|
||||
}
|
||||
|
||||
if (![...queryParams.keys()].some((key) => key === "itemId" || key === "id")) {
|
||||
queryParams.set("itemId", itemId);
|
||||
queryParams.set("id", itemId);
|
||||
}
|
||||
|
||||
const logicVer = stringFrom(exParams.logicVer) ?? DEFAULT_LOGIC_VERSION;
|
||||
const nextExParams: Record<string, unknown> = {
|
||||
...exParams,
|
||||
queryParams: queryParams.toString()
|
||||
};
|
||||
|
||||
if ("logicVer" in exParams || "nonce" in exParams) {
|
||||
nextExParams.logicVer = logicVer;
|
||||
nextExParams.nonce = buildSecurityNonce(itemId, logicVer);
|
||||
}
|
||||
|
||||
nextData.exParams = JSON.stringify(nextExParams);
|
||||
}
|
||||
|
||||
function buildDetailRequestUrl(templateUrl: string, itemId: string): string {
|
||||
const template = new URL(templateUrl);
|
||||
const data = readQueryData(template);
|
||||
|
||||
return withUpdatedQueryData(template, (currentData) => {
|
||||
const nextData: Record<string, unknown> = {
|
||||
...currentData
|
||||
};
|
||||
let foundItemKey = false;
|
||||
|
||||
for (const key of DETAIL_ITEM_DATA_KEYS) {
|
||||
if (key in currentData) {
|
||||
nextData[key] = coerceValue(currentData[key], itemId);
|
||||
foundItemKey = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundItemKey) {
|
||||
const fallbackKey = findFirstRecordKey(data, DETAIL_ITEM_DATA_KEYS) ?? "id";
|
||||
nextData[fallbackKey] = itemId;
|
||||
}
|
||||
|
||||
updateDetailExParams(currentData, nextData, itemId);
|
||||
return nextData;
|
||||
});
|
||||
}
|
||||
|
||||
function buildReviewsRequestUrl(
|
||||
templateUrl: string,
|
||||
itemId: string,
|
||||
options: ResolvedTmallReviewsPreviewOptions,
|
||||
page: number,
|
||||
cookieHeader: string
|
||||
): { url: string; pageKey?: string | undefined } {
|
||||
const template = new URL(templateUrl);
|
||||
const data = readQueryData(template);
|
||||
const api = extractTemplateApi(templateUrl) ?? "";
|
||||
const itemKeys = api.endsWith("mtop.taobao.rate.detaillist.get")
|
||||
? REVIEW_H5_ITEM_DATA_KEYS
|
||||
: REVIEW_PC_ITEM_DATA_KEYS;
|
||||
const pageKey = findFirstRecordKey(data, REVIEW_PAGE_DATA_KEYS) ?? undefined;
|
||||
const pageSizeKey = findFirstRecordKey(data, REVIEW_PAGE_SIZE_DATA_KEYS) ?? "pageSize";
|
||||
|
||||
if ((options.maxPages > 1 || options.page > 1) && !pageKey) {
|
||||
throw new TmallLiveError(
|
||||
"Imported Tmall reviews template does not expose a page field. Capture a paged reviews request first."
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
url: refreshMtopSignature(
|
||||
withUpdatedQueryData(template, (currentData) => {
|
||||
const nextData: Record<string, unknown> = {
|
||||
...currentData
|
||||
};
|
||||
let foundItemKey = false;
|
||||
|
||||
for (const key of itemKeys) {
|
||||
if (key in currentData) {
|
||||
nextData[key] = coerceValue(currentData[key], itemId);
|
||||
foundItemKey = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundItemKey) {
|
||||
nextData[itemKeys[0] ?? "itemId"] = itemId;
|
||||
}
|
||||
|
||||
nextData[pageSizeKey] = coerceValue(currentData[pageSizeKey], options.commentCount);
|
||||
if (pageKey) {
|
||||
nextData[pageKey] = coerceValue(currentData[pageKey], page);
|
||||
}
|
||||
|
||||
return nextData;
|
||||
}),
|
||||
cookieHeader
|
||||
),
|
||||
...(pageKey ? { pageKey } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function mergeReviewPages(
|
||||
itemId: string,
|
||||
pages: TmallReviewsPreviewResult["reviews"][]
|
||||
): TmallReviewsPreviewResult["reviews"] {
|
||||
const firstPage = pages[0];
|
||||
if (!firstPage) {
|
||||
return {
|
||||
itemId,
|
||||
total: null,
|
||||
hasNext: false,
|
||||
allCount: null,
|
||||
pictureCount: null,
|
||||
appendCount: 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())
|
||||
};
|
||||
}
|
||||
|
||||
function extractJsonRecord(text: string): Record<string, unknown> | null {
|
||||
return parseEmbeddedJson(text);
|
||||
}
|
||||
|
||||
function extractLoginUrl(text: string): string | null {
|
||||
if (text.includes("login.taobao.com") || text.includes("login.m.taobao.com")) {
|
||||
return "login";
|
||||
}
|
||||
|
||||
const payload = extractJsonRecord(text);
|
||||
const data = asRecord(payload?.data);
|
||||
return firstString(data?.url, data?.h5url, asRecord(data?.pcTrade)?.redirectUrl);
|
||||
}
|
||||
|
||||
function summarizeRetCodes(text: string): string[] {
|
||||
const payload = extractJsonRecord(text);
|
||||
if (!payload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return extractTmallRetCodes(payload);
|
||||
}
|
||||
|
||||
function isSuccessfulRet(retCode: string): boolean {
|
||||
return retCode.includes("SUCCESS::");
|
||||
}
|
||||
|
||||
async function fetchTextOrThrow(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
sessionExpiredMessage: string
|
||||
): Promise<{ finalUrl: string; text: string }> {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...init,
|
||||
redirect: "follow"
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TmallLiveError(
|
||||
`Tmall live request failed before receiving a response: ${
|
||||
error instanceof Error ? error.message : "unknown error"
|
||||
}`,
|
||||
502
|
||||
);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const loginUrl = extractLoginUrl(text);
|
||||
const retCodes = summarizeRetCodes(text);
|
||||
|
||||
if (response.url.includes("login.taobao.com") || loginUrl) {
|
||||
throw new TmallLiveError(sessionExpiredMessage, 409);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new TmallLiveError(
|
||||
`Tmall live request failed with status ${response.status}.`,
|
||||
502
|
||||
);
|
||||
}
|
||||
|
||||
if (retCodes.length > 0 && retCodes.every((code) => !isSuccessfulRet(code))) {
|
||||
throw new TmallLiveError(
|
||||
`Tmall live request returned ${retCodes.join(" | ")}.`,
|
||||
502
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
finalUrl: response.url,
|
||||
text
|
||||
};
|
||||
}
|
||||
|
||||
export function isTmallLiveError(error: unknown): error is Error & { statusCode: number } {
|
||||
return error instanceof Error && "statusCode" in error;
|
||||
}
|
||||
|
||||
export class TmallLiveSessionService implements TmallLiveService {
|
||||
private session: StoredTmallLiveSession | null = readEnvSession();
|
||||
|
||||
getSessionSummary(): TmallLiveSessionSummary {
|
||||
return {
|
||||
configured: Boolean(this.session),
|
||||
hasCookie: Boolean(this.session?.cookieHeader),
|
||||
...(this.session?.importedAt ? { importedAt: this.session.importedAt } : {}),
|
||||
...(this.session?.userAgent ? { userAgent: this.session.userAgent } : {}),
|
||||
detailTemplate: buildTemplateSummary(this.session?.detailTemplateUrl),
|
||||
reviewsTemplate: buildTemplateSummary(this.session?.reviewsTemplateUrl)
|
||||
};
|
||||
}
|
||||
|
||||
importSession(input: TmallLiveSessionInput): TmallLiveSessionSummary {
|
||||
const detailTemplateUrl = input.detailTemplateUrl?.trim();
|
||||
const reviewsTemplateUrl = input.reviewsTemplateUrl?.trim();
|
||||
const detailReferer = input.detailReferer?.trim();
|
||||
|
||||
this.session = {
|
||||
cookieHeader: requireNonEmptyCookie(input.cookieHeader),
|
||||
importedAt: nowIso(),
|
||||
userAgent: input.userAgent?.trim() || DEFAULT_TMALL_USER_AGENT,
|
||||
...(detailTemplateUrl ? { detailTemplateUrl } : {}),
|
||||
...(reviewsTemplateUrl ? { reviewsTemplateUrl } : {}),
|
||||
...(detailReferer ? { detailReferer } : {})
|
||||
};
|
||||
|
||||
return this.getSessionSummary();
|
||||
}
|
||||
|
||||
clearSession(): void {
|
||||
this.session = readEnvSession();
|
||||
}
|
||||
|
||||
async previewSearch(query: string): Promise<TmallSearchPreviewResult> {
|
||||
const session = this.requireSession();
|
||||
const normalizedQuery = query.trim();
|
||||
if (!normalizedQuery) {
|
||||
throw new TmallLiveError("query is required for Tmall live search preview.");
|
||||
}
|
||||
|
||||
const requestUrl = `https://s.taobao.com/search?q=${encodeURIComponent(normalizedQuery)}&tab=mall`;
|
||||
const response = await fetchTextOrThrow(
|
||||
requestUrl,
|
||||
{
|
||||
headers: {
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
Cookie: session.cookieHeader,
|
||||
Referer: "https://www.tmall.com/",
|
||||
"User-Agent": session.userAgent
|
||||
}
|
||||
},
|
||||
"Tmall search session appears invalid. Re-login in the browser and re-import the cookie/header."
|
||||
);
|
||||
|
||||
const candidates = parseTmallSearchHtml(normalizedQuery, response.text);
|
||||
if (candidates.length === 0 && !hasTmallSearchNoResultMarker(response.text)) {
|
||||
throw new TmallLiveError(
|
||||
"Tmall search page fetched successfully, but no stable mall candidates could be parsed.",
|
||||
502
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
query: normalizedQuery,
|
||||
source: "html",
|
||||
candidateCount: candidates.length,
|
||||
candidates
|
||||
};
|
||||
}
|
||||
|
||||
async previewDetail(itemId: string): Promise<TmallDetailPreviewResult> {
|
||||
const session = this.requireSession();
|
||||
const normalizedItemId = itemId.trim();
|
||||
if (!normalizedItemId) {
|
||||
throw new TmallLiveError("itemId is required for Tmall detail preview.");
|
||||
}
|
||||
|
||||
const requestUrl = `https://detail.tmall.com/item.htm?id=${normalizedItemId}`;
|
||||
const response = await fetchTextOrThrow(
|
||||
requestUrl,
|
||||
{
|
||||
headers: {
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
Cookie: session.cookieHeader,
|
||||
Referer: session.detailReferer ?? "https://detail.tmall.com/",
|
||||
"User-Agent": session.userAgent
|
||||
}
|
||||
},
|
||||
"Tmall detail session appears invalid. Re-login in the browser and re-import the cookie/header."
|
||||
);
|
||||
|
||||
const detail = parseTmallDetailHtmlResponse(normalizedItemId, { text: response.text });
|
||||
if (!detail.title && !detail.shopName && !detail.mainImage) {
|
||||
throw new TmallLiveError(
|
||||
"Tmall detail page fetched successfully, but the embedded page state could not be parsed.",
|
||||
502
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
itemId: normalizedItemId,
|
||||
source: "html",
|
||||
detail
|
||||
};
|
||||
}
|
||||
|
||||
async previewReviews(
|
||||
itemId: string,
|
||||
options?: number | TmallReviewsPreviewOptions
|
||||
): Promise<TmallReviewsPreviewResult> {
|
||||
const session = this.requireSession();
|
||||
const normalizedItemId = itemId.trim();
|
||||
const resolvedOptions = normalizeReviewsPreviewOptions(options);
|
||||
if (!normalizedItemId) {
|
||||
throw new TmallLiveError("itemId is required for Tmall reviews preview.");
|
||||
}
|
||||
|
||||
if (!session.reviewsTemplateUrl) {
|
||||
throw new TmallLiveError(
|
||||
"Tmall reviews template is missing. Capture a fresh reviews request and import it first."
|
||||
);
|
||||
}
|
||||
|
||||
const reviewPages = [];
|
||||
let pageKey: string | undefined;
|
||||
|
||||
for (let pageOffset = 0; pageOffset < resolvedOptions.maxPages; pageOffset += 1) {
|
||||
const currentPage = resolvedOptions.page + pageOffset;
|
||||
const request = buildReviewsRequestUrl(
|
||||
session.reviewsTemplateUrl,
|
||||
normalizedItemId,
|
||||
resolvedOptions,
|
||||
currentPage,
|
||||
session.cookieHeader
|
||||
);
|
||||
pageKey ??= request.pageKey;
|
||||
|
||||
const response = await fetchTextOrThrow(
|
||||
request.url,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/json, text/plain, */*",
|
||||
Cookie: session.cookieHeader,
|
||||
Referer:
|
||||
session.detailReferer ??
|
||||
`https://detail.tmall.com/wow/z/app/tbpc/pc-detail-ssr-2025/home?itemId=${normalizedItemId}`,
|
||||
"User-Agent": session.userAgent
|
||||
}
|
||||
},
|
||||
"Tmall reviews session appears invalid. Re-login in the browser and re-import the cookie/header."
|
||||
);
|
||||
|
||||
const parsedPage = parseTmallReviewsApiResponse(normalizedItemId, { text: response.text });
|
||||
reviewPages.push(parsedPage);
|
||||
|
||||
if (parsedPage.comments.length < resolvedOptions.commentCount || !parsedPage.hasNext) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const pagination: TmallReviewsPaginationSummary = {
|
||||
requestedPage: resolvedOptions.page,
|
||||
requestedCommentCount: resolvedOptions.commentCount,
|
||||
maxPages: resolvedOptions.maxPages,
|
||||
pagesFetched: reviewPages.length,
|
||||
...(pageKey ? { pageKey } : {})
|
||||
};
|
||||
|
||||
return {
|
||||
itemId: normalizedItemId,
|
||||
source: "api",
|
||||
pagination,
|
||||
reviews: mergeReviewPages(normalizedItemId, reviewPages)
|
||||
};
|
||||
}
|
||||
|
||||
async previewProduct(
|
||||
itemId: string,
|
||||
options?: number | TmallReviewsPreviewOptions
|
||||
): Promise<TmallProductPreviewResult> {
|
||||
const [detailPreview, reviewsPreview] = await Promise.all([
|
||||
this.previewDetail(itemId),
|
||||
this.previewReviews(itemId, options)
|
||||
]);
|
||||
|
||||
return {
|
||||
itemId: detailPreview.itemId,
|
||||
source: detailPreview.source === reviewsPreview.source ? detailPreview.source : "hybrid",
|
||||
detail: detailPreview.detail,
|
||||
pagination: reviewsPreview.pagination,
|
||||
reviews: reviewsPreview.reviews
|
||||
};
|
||||
}
|
||||
|
||||
private requireSession(): StoredTmallLiveSession {
|
||||
if (!this.session?.cookieHeader) {
|
||||
throw new TmallLiveError(
|
||||
"Tmall live session is not configured. Import a browser cookie/header first."
|
||||
);
|
||||
}
|
||||
|
||||
return this.session;
|
||||
}
|
||||
}
|
||||
@ -1,94 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { hasTmallSearchNoResultMarker, parseTmallSearchHtml } from "./parsers";
|
||||
|
||||
describe("Tmall search parsers", () => {
|
||||
it("parses mall candidates from g_page_config payloads", () => {
|
||||
const payload = {
|
||||
mods: {
|
||||
itemlist: {
|
||||
data: {
|
||||
auctions: [
|
||||
{
|
||||
nid: "934454505228",
|
||||
raw_title: "Apple iPhone 15 Natural Titanium 128GB",
|
||||
view_price: "4399.00",
|
||||
nick: "Apple Flagship Store",
|
||||
detail_url: "//detail.tmall.com/item.htm?id=934454505228",
|
||||
pic_url: "//img.alicdn.com/example-1.jpg",
|
||||
view_sales: "sold 10k+",
|
||||
comment_count: "20k+",
|
||||
isTmall: "true",
|
||||
iconList: [{ text: "Free shipping" }]
|
||||
},
|
||||
{
|
||||
nid: "934454505229",
|
||||
raw_title: "Apple iPhone 15 Pink 256GB",
|
||||
view_price: "4799.00",
|
||||
nick: "Apple Flagship Store",
|
||||
detail_url: "//detail.tmall.com/item.htm?id=934454505229",
|
||||
pic_url: "//img.alicdn.com/example-2.jpg",
|
||||
view_sales: "sold 5k+",
|
||||
comment_count: "12k+",
|
||||
isTmall: true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const html = `<script>var g_page_config = ${JSON.stringify(payload)};</script>`;
|
||||
|
||||
const candidates = parseTmallSearchHtml("iPhone 15", html);
|
||||
|
||||
expect(candidates).toHaveLength(2);
|
||||
expect(candidates[0]).toMatchObject({
|
||||
candidateId: "tmall-934454505228",
|
||||
platform: "tmall",
|
||||
title: "Apple iPhone 15 Natural Titanium 128GB",
|
||||
price: 4399,
|
||||
priceLabel: "CNY 4399",
|
||||
storeName: "Apple Flagship Store",
|
||||
productUrl: "https://detail.tmall.com/item.htm?id=934454505228",
|
||||
imageUrl: "https://img.alicdn.com/example-1.jpg",
|
||||
specLabel: "128GB"
|
||||
});
|
||||
expect(candidates[0]?.highlights).toEqual(["Free shipping", "天猫"]);
|
||||
});
|
||||
|
||||
it("parses fallback mall candidate blocks when embedded JSON is missing", () => {
|
||||
const html = [
|
||||
"<div>",
|
||||
'<a href="//detail.tmall.com/item.htm?id=934454505230" title="Apple iPhone 15 Blue 512GB">item</a>',
|
||||
'<img src="//img.alicdn.com/example-3.jpg" />',
|
||||
'<script type="application/json">',
|
||||
JSON.stringify({
|
||||
nick: "Apple Flagship Store",
|
||||
view_price: "5799.00",
|
||||
view_sales: "sold 2k+"
|
||||
}),
|
||||
"</script>",
|
||||
"</div>"
|
||||
].join("");
|
||||
|
||||
const candidates = parseTmallSearchHtml("iPhone 15", html);
|
||||
|
||||
expect(candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
candidateId: "tmall-934454505230",
|
||||
productUrl: "https://detail.tmall.com/item.htm?id=934454505230",
|
||||
title: "Apple iPhone 15 Blue 512GB",
|
||||
price: 5799,
|
||||
storeName: "Apple Flagship Store",
|
||||
imageUrl: "https://img.alicdn.com/example-3.jpg"
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects no-result markers from the search page", () => {
|
||||
expect(hasTmallSearchNoResultMarker("<div>很抱歉,没有找到与 iPhone 15 相关的商品</div>")).toBe(
|
||||
true
|
||||
);
|
||||
expect(hasTmallSearchNoResultMarker("<div>search results ready</div>")).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -1,918 +0,0 @@
|
||||
import type { CandidateRecord } from "@cross-ai/domain";
|
||||
|
||||
import type {
|
||||
TmallProductDetailSnapshot,
|
||||
TmallProductReviewsSnapshot,
|
||||
TmallReviewCommentSnapshot,
|
||||
TmallReviewTagSnapshot
|
||||
} from "./types";
|
||||
import {
|
||||
absolutizeTmallUrl,
|
||||
asArray,
|
||||
asRecord,
|
||||
firstString,
|
||||
normalizeWhitespace,
|
||||
parseBooleanish,
|
||||
parseEmbeddedJson,
|
||||
pickFirstStringByPaths,
|
||||
readPath,
|
||||
stringFrom,
|
||||
uniqueStrings
|
||||
} from "./utils";
|
||||
|
||||
type PathSegment = string | number;
|
||||
|
||||
const TMALL_SEARCH_RESULT_PATHS: PathSegment[][] = [
|
||||
["mods", "itemlist", "data", "auctions"],
|
||||
["root", "fields", "mods", "itemlist", "data", "auctions"],
|
||||
["data", "root", "fields", "mods", "itemlist", "data", "auctions"],
|
||||
["props", "pageProps", "mods", "itemlist", "data", "auctions"],
|
||||
["itemlist", "data", "auctions"],
|
||||
["itemList"],
|
||||
["items"]
|
||||
];
|
||||
|
||||
const TMALL_SEARCH_JSON_MARKERS = [
|
||||
"g_page_config =",
|
||||
"window.g_page_config =",
|
||||
"window.__SEARCH_DATA__ =",
|
||||
"window.__INITIAL_STATE__ =",
|
||||
"window.__PRELOADED_STATE__ ="
|
||||
];
|
||||
|
||||
const TMALL_SEARCH_NO_RESULT_MARKERS = [
|
||||
"没有找到相关宝贝",
|
||||
"没有找到相应的宝贝",
|
||||
"很抱歉,没有找到与",
|
||||
"未找到相关商品",
|
||||
"暂无相关商品"
|
||||
];
|
||||
|
||||
function extractSpecLabel(title: string): string {
|
||||
const storageMatch = title.match(/\b\d+(?:GB|TB)\b/i);
|
||||
if (storageMatch) {
|
||||
return storageMatch[0].toUpperCase();
|
||||
}
|
||||
|
||||
const colorMatch = title.match(
|
||||
/(黑色|白色|蓝色|粉色|绿色|黄色|紫色|原色|银色|金色|灰色|红色|深空黑|午夜色|星光色|natural titanium)/i
|
||||
);
|
||||
if (colorMatch?.[0]) {
|
||||
return normalizeWhitespace(colorMatch[0]);
|
||||
}
|
||||
|
||||
return "标准款";
|
||||
}
|
||||
|
||||
function normalizePriceText(value: string | null): { value: number; label: string } | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stripped = value.replace(/[^\d.]/g, "");
|
||||
if (!stripped) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number.parseFloat(stripped);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedLabel = Number.isInteger(parsed) ? parsed.toFixed(0) : parsed.toFixed(2);
|
||||
return {
|
||||
value: parsed,
|
||||
label: `CNY ${normalizedLabel}`
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeInlineText(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeWhitespace(value) || null;
|
||||
}
|
||||
|
||||
function matchFirst(value: string, pattern: RegExp): string | null {
|
||||
const match = pattern.exec(value);
|
||||
return match?.[1] ? normalizeWhitespace(match[1]) : null;
|
||||
}
|
||||
|
||||
function unwrapCapturedPayload(input: unknown): Record<string, unknown> | null {
|
||||
const record = asRecord(input);
|
||||
const text = stringFrom(record?.text);
|
||||
if (text) {
|
||||
try {
|
||||
return asRecord(JSON.parse(text));
|
||||
} catch {
|
||||
return parseEmbeddedJson(text) ?? record;
|
||||
}
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
function unwrapTmallData(input: unknown): Record<string, unknown> | null {
|
||||
const payload = unwrapCapturedPayload(input);
|
||||
return asRecord(payload?.data) ?? payload;
|
||||
}
|
||||
|
||||
function extractTextPayload(input: unknown): string | null {
|
||||
if (typeof input === "string") {
|
||||
return input;
|
||||
}
|
||||
|
||||
const record = asRecord(input);
|
||||
return stringFrom(record?.text);
|
||||
}
|
||||
|
||||
function extractJsonObjectLiteral(
|
||||
source: string,
|
||||
startIndex: number
|
||||
): Record<string, unknown> | null {
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let isEscaped = false;
|
||||
let objectStart = -1;
|
||||
|
||||
for (let index = startIndex; index < source.length; index += 1) {
|
||||
const current = source[index];
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (objectStart < 0) {
|
||||
if (current === "{") {
|
||||
objectStart = index;
|
||||
depth = 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString) {
|
||||
if (isEscaped) {
|
||||
isEscaped = false;
|
||||
} else if (current === "\\") {
|
||||
isEscaped = true;
|
||||
} else if (current === "\"") {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current === "\"") {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current === "{") {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return parseEmbeddedJson(source.slice(objectStart, index + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractSearchPayloads(html: string): Record<string, unknown>[] {
|
||||
const payloads: Record<string, unknown>[] = [];
|
||||
|
||||
for (const marker of TMALL_SEARCH_JSON_MARKERS) {
|
||||
let searchIndex = 0;
|
||||
while (searchIndex < html.length) {
|
||||
const markerIndex = html.indexOf(marker, searchIndex);
|
||||
if (markerIndex < 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const payload = extractJsonObjectLiteral(html, markerIndex + marker.length);
|
||||
if (payload) {
|
||||
payloads.push(payload);
|
||||
}
|
||||
|
||||
searchIndex = markerIndex + marker.length;
|
||||
}
|
||||
}
|
||||
|
||||
const jsonScriptMatches = html.matchAll(
|
||||
/<script[^>]*type="application\/json"[^>]*>([\s\S]*?)<\/script>/gi
|
||||
);
|
||||
for (const match of jsonScriptMatches) {
|
||||
const payload = parseEmbeddedJson(match[1] ?? null);
|
||||
if (payload) {
|
||||
payloads.push(payload);
|
||||
}
|
||||
}
|
||||
|
||||
return payloads;
|
||||
}
|
||||
|
||||
function looksLikeTmallSearchItem(input: Record<string, unknown>): boolean {
|
||||
const detailUrl = normalizeInlineText(
|
||||
firstString(
|
||||
input.detail_url,
|
||||
input.detailUrl,
|
||||
input.itemUrl,
|
||||
input.url,
|
||||
input.item_url
|
||||
)
|
||||
);
|
||||
const itemId = firstString(
|
||||
input.nid,
|
||||
input.itemId,
|
||||
input.item_id,
|
||||
input.id,
|
||||
input.auctionNumId
|
||||
);
|
||||
const title = normalizeInlineText(
|
||||
firstString(
|
||||
input.raw_title,
|
||||
input.title,
|
||||
input.name,
|
||||
input.itemTitle,
|
||||
asRecord(input.item)?.title
|
||||
)
|
||||
);
|
||||
|
||||
if (!title) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (detailUrl && /detail\.tmall\.com\/item\.htm/i.test(detailUrl)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(itemId && (detailUrl || "view_price" in input || "price" in input));
|
||||
}
|
||||
|
||||
function collectSearchItems(
|
||||
root: unknown,
|
||||
depth = 0,
|
||||
seenCollections = new Set<string>()
|
||||
): Record<string, unknown>[] {
|
||||
if (depth > 8) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const directList = TMALL_SEARCH_RESULT_PATHS.flatMap((path) => {
|
||||
const value = readPath(root, path);
|
||||
const collection = asArray(value)
|
||||
.map((entry) => asRecord(entry))
|
||||
.filter((entry): entry is Record<string, unknown> => Boolean(entry))
|
||||
.filter((entry) => looksLikeTmallSearchItem(entry));
|
||||
|
||||
return collection.length > 0 ? [collection] : [];
|
||||
});
|
||||
if (directList.length > 0) {
|
||||
return directList[0] ?? [];
|
||||
}
|
||||
|
||||
const record = asRecord(root);
|
||||
if (record) {
|
||||
const signature = Object.keys(record).sort().join("|");
|
||||
if (signature) {
|
||||
seenCollections.add(signature);
|
||||
}
|
||||
|
||||
const nestedCollections = Object.values(record).flatMap((value) => {
|
||||
const entries = asArray(value)
|
||||
.map((entry) => asRecord(entry))
|
||||
.filter((entry): entry is Record<string, unknown> => Boolean(entry));
|
||||
if (entries.length > 0 && entries.some((entry) => looksLikeTmallSearchItem(entry))) {
|
||||
const collectionSignature = entries
|
||||
.map((entry) =>
|
||||
firstString(entry.nid, entry.itemId, entry.item_id, entry.id, entry.auctionNumId) ??
|
||||
firstString(entry.detail_url, entry.detailUrl, entry.url) ??
|
||||
""
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join("|");
|
||||
if (collectionSignature && !seenCollections.has(collectionSignature)) {
|
||||
seenCollections.add(collectionSignature);
|
||||
return [entries];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
if (nestedCollections.length > 0) {
|
||||
return nestedCollections[0] ?? [];
|
||||
}
|
||||
|
||||
for (const value of Object.values(record)) {
|
||||
const nested = collectSearchItems(value, depth + 1, seenCollections);
|
||||
if (nested.length > 0) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const array = asArray(root);
|
||||
for (const value of array) {
|
||||
const nested = collectSearchItems(value, depth + 1, seenCollections);
|
||||
if (nested.length > 0) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractTmallDetailPageState(input: unknown): Record<string, unknown> | null {
|
||||
const text = extractTextPayload(input);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const anchor = text.indexOf("window.__ICE_APP_CONTEXT__");
|
||||
if (anchor < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const marker = "var b = ";
|
||||
const markerIndex = text.indexOf(marker, anchor);
|
||||
if (markerIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const context = extractJsonObjectLiteral(text, markerIndex + marker.length);
|
||||
const loaderData = asRecord(context?.loaderData);
|
||||
const home = asRecord(loaderData?.home);
|
||||
const data = asRecord(home?.data);
|
||||
return asRecord(data?.res);
|
||||
}
|
||||
|
||||
export function extractTmallRetCodes(input: unknown): string[] {
|
||||
const payload = unwrapCapturedPayload(input);
|
||||
const ret = payload?.ret;
|
||||
if (Array.isArray(ret)) {
|
||||
return ret
|
||||
.map((value) => stringFrom(value))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
}
|
||||
|
||||
const asString = stringFrom(ret);
|
||||
return asString ? [asString] : [];
|
||||
}
|
||||
|
||||
function pickFirstImageByPaths(root: unknown, paths: PathSegment[][]): string | null {
|
||||
for (const path of paths) {
|
||||
const value = readPath(root, path);
|
||||
const candidate =
|
||||
stringFrom(value) ??
|
||||
firstString(
|
||||
asRecord(value)?.url,
|
||||
asRecord(value)?.image,
|
||||
asRecord(value)?.imageUrl,
|
||||
asRecord(value)?.picUrl
|
||||
);
|
||||
|
||||
if (candidate) {
|
||||
return absolutizeTmallUrl(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickCategoryPath(root: unknown): string[] {
|
||||
const candidates: PathSegment[][] = [
|
||||
["item", "categoryPath"],
|
||||
["item", "categoryNamePath"],
|
||||
["item", "categoryPathNames"],
|
||||
["vertical", "categoryPath"],
|
||||
["categoryPath"],
|
||||
["categoryNamePath"]
|
||||
];
|
||||
|
||||
for (const path of candidates) {
|
||||
const value = readPath(root, path);
|
||||
if (typeof value === "string") {
|
||||
const parts = value
|
||||
.split(/[>/|]/g)
|
||||
.map((part) => normalizeWhitespace(part))
|
||||
.filter(Boolean);
|
||||
if (parts.length > 0) {
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
|
||||
const items = asArray(value)
|
||||
.map((entry) => {
|
||||
const record = asRecord(entry);
|
||||
return firstString(entry, record?.name, record?.text, record?.title);
|
||||
})
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.map((entry) => normalizeWhitespace(entry));
|
||||
|
||||
if (items.length > 0) {
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseSkuText(value: unknown): string[] {
|
||||
if (typeof value === "string") {
|
||||
return uniqueStrings(
|
||||
value
|
||||
.split(/[;/]/g)
|
||||
.map((entry) => normalizeWhitespace(entry))
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return uniqueStrings(
|
||||
Object.values(record).map((entry) =>
|
||||
typeof entry === "string" ? normalizeWhitespace(entry) : null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function collectImageUrls(value: unknown): string[] {
|
||||
return uniqueStrings(
|
||||
asArray(value).flatMap((entry) => {
|
||||
const record = asRecord(entry);
|
||||
const direct = absolutizeTmallUrl(stringFrom(entry));
|
||||
if (direct) {
|
||||
return [direct];
|
||||
}
|
||||
|
||||
return uniqueStrings([
|
||||
absolutizeTmallUrl(firstString(record?.url, record?.image, record?.picPath)),
|
||||
absolutizeTmallUrl(firstString(record?.sourceUrl, record?.cloudVideoUrl))
|
||||
]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function parseReviewTag(input: unknown): TmallReviewTagSnapshot | null {
|
||||
const tag = asRecord(input);
|
||||
const name = firstString(tag?.name, tag?.imprName, tag?.label, tag?.title);
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: normalizeWhitespace(name),
|
||||
count: firstString(tag?.count, tag?.showCount, tag?.num)
|
||||
};
|
||||
}
|
||||
|
||||
function parseH5ReviewComment(input: unknown): TmallReviewCommentSnapshot | null {
|
||||
const comment = asRecord(input);
|
||||
const appended = asRecord(comment?.appendedFeed);
|
||||
const video = asRecord(comment?.video);
|
||||
const content = firstString(comment?.feedback, comment?.reviewWordContent, comment?.content);
|
||||
const id = firstString(comment?.id, comment?.commentId, comment?.rateId);
|
||||
|
||||
if (!content || !id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
content: normalizeWhitespace(content),
|
||||
date: firstString(comment?.feedbackDate, comment?.reviewDate, comment?.date),
|
||||
userNick: firstString(comment?.userNick, asRecord(comment?.userInfo)?.nickName),
|
||||
userAvatar: absolutizeTmallUrl(
|
||||
firstString(comment?.headPicUrl, asRecord(comment?.userInfo)?.image)
|
||||
),
|
||||
skuText: parseSkuText(comment?.skuMap ?? comment?.skuText),
|
||||
pictureUrls: collectImageUrls(comment?.feedPicPathList ?? comment?.reviewPicPathList),
|
||||
videoUrls: collectImageUrls(video?.videoId ? [video] : []),
|
||||
likeCount: firstString(asRecord(comment?.interactInfo)?.likeCount),
|
||||
reply: firstString(comment?.reply),
|
||||
appendContent: firstString(appended?.appendedFeedback),
|
||||
appendPictureUrls: collectImageUrls(appended?.appendFeedPicPathList)
|
||||
};
|
||||
}
|
||||
|
||||
function parsePcReviewComment(input: unknown): TmallReviewCommentSnapshot | null {
|
||||
const comment = asRecord(input);
|
||||
const append = asRecord(comment?.reviewAppendVO);
|
||||
const content = firstString(comment?.reviewWordContent, comment?.feedback, comment?.content);
|
||||
const id = firstString(comment?.id, comment?.commentId);
|
||||
|
||||
if (!content || !id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
content: normalizeWhitespace(content),
|
||||
date: firstString(comment?.reviewDate, comment?.feedbackDate, comment?.date),
|
||||
userNick: firstString(comment?.userNick, asRecord(comment?.userInfo)?.nickName),
|
||||
userAvatar: absolutizeTmallUrl(
|
||||
firstString(comment?.headPicUrl, asRecord(comment?.userInfo)?.image)
|
||||
),
|
||||
skuText: parseSkuText(comment?.skuText ?? comment?.skuMap),
|
||||
pictureUrls: collectImageUrls(comment?.reviewPicPathList),
|
||||
videoUrls: collectImageUrls(comment?.videoVOList),
|
||||
likeCount: firstString(asRecord(comment?.interactionVO)?.likeCount),
|
||||
reply: firstString(comment?.reply),
|
||||
appendContent: firstString(append?.appendedWordContent),
|
||||
appendPictureUrls: collectImageUrls(append?.reviewPicPathList)
|
||||
};
|
||||
}
|
||||
|
||||
function parseSearchItem(input: Record<string, unknown>): CandidateRecord | null {
|
||||
const nestedItem = asRecord(input.item);
|
||||
const record = nestedItem ? { ...nestedItem, ...input } : input;
|
||||
const detailUrl = absolutizeTmallUrl(
|
||||
firstString(record.detail_url, record.detailUrl, record.itemUrl, record.url, record.item_url)
|
||||
);
|
||||
const itemId =
|
||||
firstString(record.nid, record.itemId, record.item_id, record.id, record.auctionNumId) ??
|
||||
detailUrl?.match(/[?&](?:id|itemId|itemNumId|auctionNumId)=(\d+)/i)?.[1] ??
|
||||
null;
|
||||
const title = normalizeInlineText(
|
||||
firstString(
|
||||
record.raw_title,
|
||||
record.title,
|
||||
record.name,
|
||||
record.itemTitle,
|
||||
asRecord(record.item)?.title
|
||||
)
|
||||
);
|
||||
const price = normalizePriceText(
|
||||
firstString(
|
||||
record.view_price,
|
||||
record.price,
|
||||
record.priceText,
|
||||
asRecord(record.priceInfo)?.priceText,
|
||||
asRecord(record.priceInfo)?.price
|
||||
)
|
||||
);
|
||||
const storeName =
|
||||
normalizeInlineText(
|
||||
firstString(
|
||||
record.nick,
|
||||
record.shopName,
|
||||
record.sellerNick,
|
||||
asRecord(record.shopcard)?.shopName
|
||||
)
|
||||
) ?? "天猫店铺";
|
||||
const salesHints = uniqueStrings([
|
||||
normalizeInlineText(
|
||||
firstString(
|
||||
record.view_sales,
|
||||
record.sold,
|
||||
record.salesDesc,
|
||||
record.soldText,
|
||||
record.comment_count ? `累计评价 ${stringFrom(record.comment_count)}` : null
|
||||
)
|
||||
),
|
||||
normalizeInlineText(firstString(record.commentCount, record.comment_count))
|
||||
]);
|
||||
const featureFlags = asArray(record.iconList)
|
||||
.map((entry) =>
|
||||
normalizeInlineText(firstString(entry, asRecord(entry)?.text, asRecord(entry)?.name))
|
||||
)
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
const highlights = uniqueStrings([
|
||||
...featureFlags,
|
||||
parseBooleanish(record.isTmall) ? "天猫" : null,
|
||||
normalizeInlineText(firstString(record.shopIcon, record.shopType))
|
||||
]).slice(0, 4);
|
||||
|
||||
if (!itemId || !title || !detailUrl || !/detail\.tmall\.com\/item\.htm/i.test(detailUrl)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
candidateId: `tmall-${itemId}`,
|
||||
platform: "tmall",
|
||||
title,
|
||||
price: price?.value ?? 0,
|
||||
priceLabel: price?.label ?? "CNY 0",
|
||||
storeName,
|
||||
productUrl: detailUrl,
|
||||
imageUrl:
|
||||
absolutizeTmallUrl(
|
||||
firstString(
|
||||
record.pic_url,
|
||||
record.image,
|
||||
record.imageUrl,
|
||||
asRecord(record.pic)?.url
|
||||
)
|
||||
) ?? "https://placehold.co/640x480?text=TMALL",
|
||||
salesHint: salesHints.join(" | ") || "搜索页已返回,但缺少稳定销量文案",
|
||||
specLabel: extractSpecLabel(title),
|
||||
highlights: highlights.length > 0 ? highlights : ["天猫搜索结果候选"]
|
||||
};
|
||||
}
|
||||
|
||||
function parseSearchCandidateBlocks(query: string, html: string): CandidateRecord[] {
|
||||
const blocks = html.matchAll(
|
||||
/((?:https?:)?\/\/detail\.tmall\.com\/item\.htm\?[^"'<>\\\s]*\bid=\d+[\s\S]{0,1200})/gi
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
const candidates: CandidateRecord[] = [];
|
||||
|
||||
for (const match of blocks) {
|
||||
const block = match[1] ?? "";
|
||||
const itemId = block.match(/[?&](?:id|itemId|itemNumId|auctionNumId)=(\d+)/i)?.[1];
|
||||
if (!itemId || seen.has(itemId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(itemId);
|
||||
const detailUrlMatch = block.match(
|
||||
/((?:https?:)?\/\/detail\.tmall\.com\/item\.htm\?[^"'<>\\\s]*\bid=\d+)/i
|
||||
);
|
||||
const title =
|
||||
matchFirst(block, /"(?:raw_title|title|itemTitle)"\s*:\s*"([^"]+)"/i) ??
|
||||
matchFirst(block, /title="([^"]{4,200})"/i) ??
|
||||
query;
|
||||
const price = normalizePriceText(
|
||||
matchFirst(block, /"(?:view_price|price|priceText)"\s*:\s*"([^"]+)"/i) ??
|
||||
matchFirst(block, />\s*([0-9]+(?:\.[0-9]+)?)\s*</i)
|
||||
);
|
||||
const storeName =
|
||||
matchFirst(block, /"(?:nick|shopName|sellerNick)"\s*:\s*"([^"]+)"/i) ?? "天猫店铺";
|
||||
const imageUrl =
|
||||
absolutizeTmallUrl(
|
||||
matchFirst(block, /"(?:pic_url|image|imageUrl)"\s*:\s*"([^"]+)"/i) ??
|
||||
matchFirst(block, /<img[^>]+src="([^"]+)"/i)
|
||||
) ?? "https://placehold.co/640x480?text=TMALL";
|
||||
const salesHint =
|
||||
matchFirst(block, /"(?:view_sales|sold|salesDesc|comment_count)"\s*:\s*"([^"]+)"/i) ??
|
||||
"搜索页已返回,但未抽取到稳定销量文案";
|
||||
|
||||
candidates.push({
|
||||
candidateId: `tmall-${itemId}`,
|
||||
platform: "tmall",
|
||||
title,
|
||||
price: price?.value ?? 0,
|
||||
priceLabel: price?.label ?? "CNY 0",
|
||||
storeName,
|
||||
productUrl: absolutizeTmallUrl(detailUrlMatch?.[1] ?? null) ??
|
||||
`https://detail.tmall.com/item.htm?id=${itemId}`,
|
||||
imageUrl,
|
||||
salesHint,
|
||||
specLabel: extractSpecLabel(title),
|
||||
highlights: ["搜索页候选回退解析"]
|
||||
});
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function normalizeTmallSellerType(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "b" || normalized.includes("tmall")) {
|
||||
return "tmall";
|
||||
}
|
||||
|
||||
if (normalized === "c" || normalized.includes("taobao")) {
|
||||
return "taobao";
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function hasTmallSearchNoResultMarker(html: string): boolean {
|
||||
return TMALL_SEARCH_NO_RESULT_MARKERS.some((marker) => html.includes(marker));
|
||||
}
|
||||
|
||||
export function parseTmallSearchHtml(query: string, html: string): CandidateRecord[] {
|
||||
const seen = new Set<string>();
|
||||
const candidates: CandidateRecord[] = [];
|
||||
|
||||
for (const payload of extractSearchPayloads(html)) {
|
||||
const items = collectSearchItems(payload);
|
||||
for (const item of items) {
|
||||
const candidate = parseSearchItem(item);
|
||||
if (!candidate || seen.has(candidate.candidateId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(candidate.candidateId);
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length > 0) {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
for (const candidate of parseSearchCandidateBlocks(query, html)) {
|
||||
if (seen.has(candidate.candidateId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(candidate.candidateId);
|
||||
candidates.push(candidate);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function parseTmallDetailHtmlResponse(
|
||||
itemId: string,
|
||||
input: unknown
|
||||
): TmallProductDetailSnapshot {
|
||||
const payload = extractTmallDetailPageState(input) ?? {};
|
||||
|
||||
return {
|
||||
itemId:
|
||||
pickFirstStringByPaths(payload, [["item", "itemId"], ["itemId"], ["id"]]) ?? itemId,
|
||||
title:
|
||||
pickFirstStringByPaths(payload, [
|
||||
["componentsVO", "titleVO", "title", "title"],
|
||||
["item", "title"]
|
||||
]) ?? null,
|
||||
subtitle:
|
||||
pickFirstStringByPaths(payload, [["item", "subtitle"], ["item", "subTitle"]]) ?? null,
|
||||
price:
|
||||
pickFirstStringByPaths(payload, [
|
||||
["componentsVO", "priceVO", "extraPrice", "priceText"],
|
||||
["componentsVO", "priceVO", "price", "priceText"]
|
||||
]) ?? null,
|
||||
originalPrice:
|
||||
pickFirstStringByPaths(payload, [["componentsVO", "priceVO", "price", "priceText"]]) ??
|
||||
null,
|
||||
shopName:
|
||||
pickFirstStringByPaths(payload, [
|
||||
["seller", "shopName"],
|
||||
["componentsVO", "storeCardVO", "shopName"]
|
||||
]) ?? null,
|
||||
shopUrl:
|
||||
pickFirstImageByPaths(payload, [
|
||||
["seller", "pcShopUrl"],
|
||||
["componentsVO", "storeCardVO", "shopUrl"]
|
||||
]) ?? null,
|
||||
sellerType: normalizeTmallSellerType(
|
||||
pickFirstStringByPaths(payload, [
|
||||
["seller", "sellerType"],
|
||||
["componentsVO", "storeCardVO", "sellerType"]
|
||||
])
|
||||
),
|
||||
categoryPath: pickCategoryPath(payload),
|
||||
mainImage:
|
||||
pickFirstImageByPaths(payload, [
|
||||
["componentsVO", "headImageVO", "images", 0],
|
||||
["item", "images", 0]
|
||||
]) ?? null,
|
||||
salesDesc:
|
||||
pickFirstStringByPaths(payload, [
|
||||
["componentsVO", "titleVO", "salesDesc"],
|
||||
["item", "vagueSellCount"]
|
||||
]) ?? null,
|
||||
commentCount:
|
||||
pickFirstStringByPaths(payload, [
|
||||
["componentsVO", "rateVO", "totalCount"],
|
||||
["componentsVO", "rateVO", "total"],
|
||||
["item", "commentCount"]
|
||||
]) ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function parseTmallDetailApiResponse(
|
||||
itemId: string,
|
||||
input: unknown
|
||||
): TmallProductDetailSnapshot {
|
||||
const payload = unwrapTmallData(input) ?? {};
|
||||
const price = asRecord(payload.price) ?? asRecord(payload.priceInfo) ?? {};
|
||||
|
||||
return {
|
||||
itemId:
|
||||
pickFirstStringByPaths(payload, [["item", "itemId"], ["itemId"], ["id"]]) ?? itemId,
|
||||
title:
|
||||
pickFirstStringByPaths(payload, [
|
||||
["item", "title"],
|
||||
["item", "itemTitle"],
|
||||
["itemInfo", "title"],
|
||||
["itemInfoModel", "title"],
|
||||
["title"]
|
||||
]) ?? null,
|
||||
subtitle:
|
||||
pickFirstStringByPaths(payload, [
|
||||
["item", "subtitle"],
|
||||
["item", "subTitle"],
|
||||
["subTitle"]
|
||||
]) ?? null,
|
||||
price:
|
||||
pickFirstStringByPaths(payload, [
|
||||
["price", "componentsVO", "priceVO", "priceText"],
|
||||
["price", "priceText"],
|
||||
["price", "price"],
|
||||
["priceInfo", "priceText"],
|
||||
["vertical", "price", "priceText"]
|
||||
]) ??
|
||||
pickFirstStringByPaths(price, [["priceText"], ["price"]]) ??
|
||||
null,
|
||||
originalPrice:
|
||||
pickFirstStringByPaths(payload, [
|
||||
["price", "componentsVO", "priceVO", "crossPriceText"],
|
||||
["price", "componentsVO", "priceVO", "subPriceText"],
|
||||
["price", "originPriceText"],
|
||||
["price", "originalPrice"],
|
||||
["priceInfo", "originPriceText"]
|
||||
]) ?? null,
|
||||
shopName:
|
||||
pickFirstStringByPaths(payload, [
|
||||
["seller", "shopName"],
|
||||
["seller", "shopTitle"],
|
||||
["shop", "shopName"],
|
||||
["shopInfo", "shopName"]
|
||||
]) ?? null,
|
||||
shopUrl:
|
||||
pickFirstImageByPaths(payload, [
|
||||
["seller", "shopUrl"],
|
||||
["shop", "shopUrl"],
|
||||
["shopInfo", "shopUrl"]
|
||||
]) ?? null,
|
||||
sellerType: normalizeTmallSellerType(
|
||||
pickFirstStringByPaths(payload, [
|
||||
["seller", "sellerType"],
|
||||
["shop", "sellerType"],
|
||||
["feature", "sellerType"]
|
||||
])
|
||||
),
|
||||
categoryPath: pickCategoryPath(payload),
|
||||
mainImage:
|
||||
pickFirstImageByPaths(payload, [
|
||||
["item", "mainPic"],
|
||||
["item", "mainImage"],
|
||||
["item", "images", 0],
|
||||
["item", "images", 0, "url"],
|
||||
["item", "picGallery", 0],
|
||||
["item", "picGallery", 0, "image"],
|
||||
["skuBase", "images", 0],
|
||||
["skuBase", "images", 0, "url"]
|
||||
]) ?? null,
|
||||
salesDesc:
|
||||
pickFirstStringByPaths(payload, [
|
||||
["item", "sellCount"],
|
||||
["item", "sellCountText"],
|
||||
["item", "soldCount"],
|
||||
["item", "soldCountText"],
|
||||
["vertical", "salesDesc"]
|
||||
]) ?? null,
|
||||
commentCount:
|
||||
pickFirstStringByPaths(payload, [
|
||||
["rate", "total"],
|
||||
["rate", "commentCount"],
|
||||
["review", "total"],
|
||||
["item", "commentCount"]
|
||||
]) ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function parseTmallReviewsApiResponse(
|
||||
itemId: string,
|
||||
input: unknown
|
||||
): TmallProductReviewsSnapshot {
|
||||
const payload = unwrapTmallData(input) ?? {};
|
||||
const module = asRecord(payload.module);
|
||||
const h5Comments = asArray(payload.rateList)
|
||||
.map((comment) => parseH5ReviewComment(comment))
|
||||
.filter((comment): comment is TmallReviewCommentSnapshot => Boolean(comment));
|
||||
const pcComments = asArray(module?.reviewVOList)
|
||||
.map((comment) => parsePcReviewComment(comment))
|
||||
.filter((comment): comment is TmallReviewCommentSnapshot => Boolean(comment));
|
||||
const tags = asArray(payload.imprItemList ?? payload.imprItemVOS ?? module?.imprItemList)
|
||||
.map((tag) => parseReviewTag(tag))
|
||||
.filter((tag): tag is TmallReviewTagSnapshot => Boolean(tag));
|
||||
|
||||
return {
|
||||
itemId,
|
||||
total: firstString(payload.totalFuzzy, payload.total, module?.totalFuzzy, module?.total),
|
||||
hasNext: parseBooleanish(firstString(payload.hasNext, module?.hasNext)),
|
||||
allCount: firstString(payload.fuzzyRateCount, payload.feedAllCount, module?.feedAllCount),
|
||||
pictureCount: firstString(
|
||||
payload.feedMediaCountFuzzy,
|
||||
payload.feedMediaCount,
|
||||
module?.feedMediaCountFuzzy,
|
||||
module?.feedMediaCount
|
||||
),
|
||||
appendCount: firstString(
|
||||
payload.feedAppendCountFuzzy,
|
||||
payload.feedAppendCount,
|
||||
module?.feedAppendCountFuzzy,
|
||||
module?.feedAppendCount
|
||||
),
|
||||
tags,
|
||||
comments: h5Comments.length > 0 ? h5Comments : pcComments
|
||||
};
|
||||
}
|
||||
@ -1,187 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { TmallSessionManagerService } from "./session-manager";
|
||||
import type { TmallLiveService, TmallLiveSessionSummary } from "./types";
|
||||
|
||||
function createSessionSummary(configured: boolean): TmallLiveSessionSummary {
|
||||
return configured
|
||||
? {
|
||||
configured: true,
|
||||
importedAt: "2026-04-03T10:00:00.000Z",
|
||||
hasCookie: true,
|
||||
userAgent: "stub-user-agent",
|
||||
detailTemplate: {
|
||||
available: true,
|
||||
api: "mtop.taobao.pcdetail.data.get",
|
||||
itemId: "934454505228"
|
||||
},
|
||||
reviewsTemplate: {
|
||||
available: true,
|
||||
api: "mtop.taobao.rate.detaillist.get",
|
||||
itemId: "934454505228"
|
||||
}
|
||||
}
|
||||
: {
|
||||
configured: false,
|
||||
hasCookie: false,
|
||||
detailTemplate: {
|
||||
available: false
|
||||
},
|
||||
reviewsTemplate: {
|
||||
available: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createLiveService(): TmallLiveService {
|
||||
let summary = createSessionSummary(false);
|
||||
|
||||
return {
|
||||
getSessionSummary() {
|
||||
return summary;
|
||||
},
|
||||
importSession() {
|
||||
summary = createSessionSummary(true);
|
||||
return summary;
|
||||
},
|
||||
clearSession() {
|
||||
summary = createSessionSummary(false);
|
||||
},
|
||||
async previewSearch(query) {
|
||||
return {
|
||||
query,
|
||||
source: "html",
|
||||
candidateCount: 1,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: "tmall-934454505228",
|
||||
platform: "tmall",
|
||||
title: "Apple iPhone 15",
|
||||
price: 4399,
|
||||
priceLabel: "CNY 4399",
|
||||
storeName: "Apple 官方旗舰店",
|
||||
productUrl: "https://detail.tmall.com/item.htm?id=934454505228",
|
||||
imageUrl: "https://img.alicdn.com/example.jpg",
|
||||
salesHint: "已售 70万+",
|
||||
specLabel: "128GB",
|
||||
highlights: ["天猫"]
|
||||
}
|
||||
]
|
||||
};
|
||||
},
|
||||
async previewDetail(itemId) {
|
||||
return {
|
||||
itemId,
|
||||
source: "html",
|
||||
detail: {
|
||||
itemId,
|
||||
title: "Apple iPhone 15",
|
||||
subtitle: null,
|
||||
price: "4399.00",
|
||||
originalPrice: "4999.00",
|
||||
shopName: "Apple 官方旗舰店",
|
||||
shopUrl: null,
|
||||
sellerType: "tmall",
|
||||
categoryPath: [],
|
||||
mainImage: null,
|
||||
salesDesc: "已售 70万+",
|
||||
commentCount: "20万+"
|
||||
}
|
||||
};
|
||||
},
|
||||
async previewReviews(itemId) {
|
||||
return {
|
||||
itemId,
|
||||
source: "api",
|
||||
pagination: {
|
||||
requestedPage: 1,
|
||||
requestedCommentCount: 1,
|
||||
maxPages: 1,
|
||||
pagesFetched: 1
|
||||
},
|
||||
reviews: {
|
||||
itemId,
|
||||
total: "20万+",
|
||||
hasNext: false,
|
||||
allCount: "20万+",
|
||||
pictureCount: "1万+",
|
||||
appendCount: "5000+",
|
||||
tags: [],
|
||||
comments: []
|
||||
}
|
||||
};
|
||||
},
|
||||
async previewProduct(itemId, options) {
|
||||
const detail = await this.previewDetail(itemId);
|
||||
const reviews = await this.previewReviews(itemId, options);
|
||||
return {
|
||||
itemId,
|
||||
source: "hybrid",
|
||||
detail: detail.detail,
|
||||
pagination: reviews.pagination,
|
||||
reviews: reviews.reviews
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("TmallSessionManagerService", () => {
|
||||
it("imports a manual session and exposes healthy ops state", async () => {
|
||||
const onSessionReady = vi.fn();
|
||||
const service = createLiveService();
|
||||
const manager = new TmallSessionManagerService(service, {
|
||||
onSessionReady
|
||||
});
|
||||
|
||||
const state = await manager.importManualSession({
|
||||
cookieHeader: "_m_h5_tk=masked_token_123;",
|
||||
detailTemplateUrl: "https://detail.tmall.com/item.htm?id=934454505228",
|
||||
reviewsTemplateUrl:
|
||||
"https://h5api.m.tmall.com/h5/mtop.taobao.rate.detaillist.get/6.0/?data=%7B%22auctionNumId%22%3A%22934454505228%22%7D"
|
||||
});
|
||||
|
||||
expect(onSessionReady).toHaveBeenCalledTimes(1);
|
||||
expect(state).toMatchObject({
|
||||
status: "healthy",
|
||||
publicNote: "天猫会话由运维后台维护,当前可用。",
|
||||
session: {
|
||||
configured: true
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("marks the manager for manual action when the session has expired", async () => {
|
||||
const onSessionUnavailable = vi.fn();
|
||||
const service = createLiveService();
|
||||
service.importSession({
|
||||
cookieHeader: "_m_h5_tk=masked_token_123;",
|
||||
detailTemplateUrl: "https://detail.tmall.com/item.htm?id=934454505228",
|
||||
reviewsTemplateUrl:
|
||||
"https://h5api.m.tmall.com/h5/mtop.taobao.rate.detaillist.get/6.0/?data=%7B%22auctionNumId%22%3A%22934454505228%22%7D"
|
||||
});
|
||||
service.previewDetail = vi.fn(async () => {
|
||||
const error = new Error("Tmall detail session appears invalid.") as Error & {
|
||||
statusCode: number;
|
||||
};
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
});
|
||||
|
||||
const manager = new TmallSessionManagerService(service, {
|
||||
onSessionUnavailable
|
||||
});
|
||||
|
||||
const result = await manager.runHealthCheck("ops");
|
||||
|
||||
expect(onSessionUnavailable).toHaveBeenCalledTimes(1);
|
||||
expect(result).toMatchObject({
|
||||
recovered: false,
|
||||
state: {
|
||||
status: "manual_action_required",
|
||||
pendingManualAction: true,
|
||||
lastFailureCode: "SESSION_REQUIRED",
|
||||
publicNote: "天猫会话需要运维重新登录并更新 Cookie。"
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,391 +0,0 @@
|
||||
import { isTmallLiveError } from "./live-session";
|
||||
import type {
|
||||
TmallLiveService,
|
||||
TmallLiveSessionInput,
|
||||
TmallSessionManager,
|
||||
TmallSessionManagerConfigInput,
|
||||
TmallSessionManagerRunResult,
|
||||
TmallSessionManagerState,
|
||||
TmallSessionManagerStatus
|
||||
} from "./types";
|
||||
|
||||
const DEFAULT_CHECK_INTERVAL_MS = 10 * 60 * 1000;
|
||||
const DEFAULT_HEARTBEAT_ITEM_ID = "934454505228";
|
||||
|
||||
type StoredTmallSessionManagerConfig = {
|
||||
enabled: boolean;
|
||||
heartbeatItemId?: string | undefined;
|
||||
checkIntervalMs: number;
|
||||
configuredAt?: string | undefined;
|
||||
};
|
||||
|
||||
type TmallSessionManagerCallbacks = {
|
||||
onSessionReady?: () => void;
|
||||
onSessionUnavailable?: () => void;
|
||||
};
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function readBooleanEnv(name: string, fallback: boolean): boolean {
|
||||
const value = process.env[name]?.trim().toLowerCase();
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return value === "1" || value === "true" || value === "yes" || value === "on";
|
||||
}
|
||||
|
||||
function readPositiveIntegerEnv(name: string, fallback: number): number {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: string | null | undefined): string | undefined {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function buildConfigFromEnv(): StoredTmallSessionManagerConfig {
|
||||
const heartbeatItemId =
|
||||
normalizeOptionalString(process.env.TMALL_OPS_HEARTBEAT_ITEM_ID) ?? DEFAULT_HEARTBEAT_ITEM_ID;
|
||||
|
||||
return {
|
||||
enabled: readBooleanEnv("TMALL_OPS_ENABLED", true),
|
||||
heartbeatItemId,
|
||||
checkIntervalMs: readPositiveIntegerEnv(
|
||||
"TMALL_OPS_CHECK_INTERVAL_MS",
|
||||
DEFAULT_CHECK_INTERVAL_MS
|
||||
),
|
||||
configuredAt: heartbeatItemId ? nowIso() : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseState(
|
||||
liveService: TmallLiveService,
|
||||
config: StoredTmallSessionManagerConfig
|
||||
): TmallSessionManagerState {
|
||||
return {
|
||||
status: liveService.getSessionSummary().configured ? "degraded" : "idle",
|
||||
enabled: config.enabled,
|
||||
heartbeatItemId: config.heartbeatItemId,
|
||||
checkIntervalMs: config.checkIntervalMs,
|
||||
pendingManualAction: false,
|
||||
note: liveService.getSessionSummary().configured
|
||||
? "天猫会话已导入,等待后台健康检查确认。"
|
||||
: "天猫会话管理器已初始化,等待首次会话注入。",
|
||||
publicNote: liveService.getSessionSummary().configured
|
||||
? "天猫会话正在后台校验。"
|
||||
: "天猫会话由运维后台维护,当前尚未就绪。",
|
||||
configuredAt: config.configuredAt,
|
||||
session: liveService.getSessionSummary()
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFailureDescriptor(error: unknown): {
|
||||
code: string;
|
||||
status: TmallSessionManagerStatus;
|
||||
pendingManualAction: boolean;
|
||||
publicNote: string;
|
||||
} {
|
||||
const message = error instanceof Error ? error.message : "unknown error";
|
||||
const normalizedMessage = message.toLowerCase();
|
||||
|
||||
if (
|
||||
(isTmallLiveError(error) && error.statusCode === 409) ||
|
||||
normalizedMessage.includes("session appears invalid") ||
|
||||
normalizedMessage.includes("live session is not configured") ||
|
||||
normalizedMessage.includes("cookie/header")
|
||||
) {
|
||||
return {
|
||||
code: "SESSION_REQUIRED",
|
||||
status: "manual_action_required",
|
||||
pendingManualAction: true,
|
||||
publicNote: "天猫会话需要运维重新登录并更新 Cookie。"
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedMessage.includes("template is missing") ||
|
||||
normalizedMessage.includes("capture a fresh") ||
|
||||
normalizedMessage.includes("does not expose a page field")
|
||||
) {
|
||||
return {
|
||||
code: "TEMPLATE_REQUIRED",
|
||||
status: "manual_action_required",
|
||||
pendingManualAction: true,
|
||||
publicNote: "天猫会话缺少有效模板,运维后台需要刷新模板。"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code:
|
||||
isTmallLiveError(error) && typeof error.statusCode === "number"
|
||||
? `TMALL_${error.statusCode}`
|
||||
: "TMALL_HEALTH_CHECK_FAILED",
|
||||
status: "degraded",
|
||||
pendingManualAction: false,
|
||||
publicNote: "天猫会话健康检查失败,运维后台需要复核。"
|
||||
};
|
||||
}
|
||||
|
||||
export class TmallSessionManagerService implements TmallSessionManager {
|
||||
private config = buildConfigFromEnv();
|
||||
private state: TmallSessionManagerState;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly liveService: TmallLiveService,
|
||||
private readonly callbacks: TmallSessionManagerCallbacks = {}
|
||||
) {
|
||||
this.state = createBaseState(liveService, this.config);
|
||||
this.restartScheduler();
|
||||
}
|
||||
|
||||
getState(): TmallSessionManagerState {
|
||||
return {
|
||||
...this.state,
|
||||
session: this.liveService.getSessionSummary()
|
||||
};
|
||||
}
|
||||
|
||||
configure(input: TmallSessionManagerConfigInput): TmallSessionManagerState {
|
||||
const nextConfig: StoredTmallSessionManagerConfig = {
|
||||
...this.config,
|
||||
...(typeof input.enabled === "boolean" ? { enabled: input.enabled } : {}),
|
||||
...(input.heartbeatItemId !== undefined
|
||||
? { heartbeatItemId: normalizeOptionalString(input.heartbeatItemId) }
|
||||
: {}),
|
||||
...(input.checkIntervalMs !== undefined && input.checkIntervalMs !== null
|
||||
? {
|
||||
checkIntervalMs:
|
||||
Number.isInteger(input.checkIntervalMs) && input.checkIntervalMs > 0
|
||||
? input.checkIntervalMs
|
||||
: this.config.checkIntervalMs
|
||||
}
|
||||
: {}),
|
||||
configuredAt: nowIso()
|
||||
};
|
||||
|
||||
this.config = nextConfig;
|
||||
this.state = {
|
||||
...this.state,
|
||||
enabled: nextConfig.enabled,
|
||||
heartbeatItemId: nextConfig.heartbeatItemId,
|
||||
checkIntervalMs: nextConfig.checkIntervalMs,
|
||||
configuredAt: nextConfig.configuredAt,
|
||||
note: "天猫运维配置已更新。",
|
||||
publicNote: this.state.publicNote,
|
||||
session: this.liveService.getSessionSummary()
|
||||
};
|
||||
this.restartScheduler();
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
clearConfig(): TmallSessionManagerState {
|
||||
this.config = {
|
||||
enabled: false,
|
||||
heartbeatItemId: DEFAULT_HEARTBEAT_ITEM_ID,
|
||||
checkIntervalMs: DEFAULT_CHECK_INTERVAL_MS
|
||||
};
|
||||
this.state = {
|
||||
...this.state,
|
||||
enabled: false,
|
||||
heartbeatItemId: DEFAULT_HEARTBEAT_ITEM_ID,
|
||||
checkIntervalMs: DEFAULT_CHECK_INTERVAL_MS,
|
||||
configuredAt: nowIso(),
|
||||
note: "天猫运维配置已清空,自动巡检已停用。",
|
||||
publicNote: this.liveService.getSessionSummary().configured
|
||||
? "天猫会话已存在,但后台自动巡检已停用。"
|
||||
: "天猫会话由运维后台维护,当前自动巡检未启用。",
|
||||
pendingManualAction: false,
|
||||
session: this.liveService.getSessionSummary()
|
||||
};
|
||||
this.restartScheduler();
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
async importManualSession(
|
||||
input: TmallLiveSessionInput,
|
||||
source = "ops-manual"
|
||||
): Promise<TmallSessionManagerState> {
|
||||
this.liveService.importSession(input);
|
||||
this.callbacks.onSessionReady?.();
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: "healthy",
|
||||
pendingManualAction: false,
|
||||
note: `天猫会话已通过 ${source} 更新,等待下一轮健康检查。`,
|
||||
publicNote: "天猫会话由运维后台维护,当前可用。",
|
||||
lastHealthyAt: nowIso(),
|
||||
lastFailureCode: undefined,
|
||||
lastFailureMessage: undefined,
|
||||
session: this.liveService.getSessionSummary()
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
clearManagedSession(reason = "ops-manual-clear"): TmallSessionManagerState {
|
||||
this.liveService.clearSession();
|
||||
this.callbacks.onSessionUnavailable?.();
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: "idle",
|
||||
pendingManualAction: false,
|
||||
note: `天猫会话已清理:${reason}。`,
|
||||
publicNote: "天猫会话由运维后台维护,当前尚未就绪。",
|
||||
session: this.liveService.getSessionSummary()
|
||||
};
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
async runHealthCheck(trigger = "manual"): Promise<TmallSessionManagerRunResult> {
|
||||
this.state = {
|
||||
...this.state,
|
||||
lastCheckAt: nowIso(),
|
||||
session: this.liveService.getSessionSummary()
|
||||
};
|
||||
|
||||
const summary = this.liveService.getSessionSummary();
|
||||
if (!summary.configured) {
|
||||
this.callbacks.onSessionUnavailable?.();
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: "manual_action_required",
|
||||
pendingManualAction: true,
|
||||
note: "当前没有可用天猫会话,等待运维注入。",
|
||||
publicNote: "天猫会话由运维后台维护,当前尚未就绪。",
|
||||
session: summary
|
||||
};
|
||||
return {
|
||||
state: this.getState(),
|
||||
recovered: false
|
||||
};
|
||||
}
|
||||
|
||||
if (!summary.detailTemplate.available || !summary.reviewsTemplate.available) {
|
||||
this.callbacks.onSessionUnavailable?.();
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: "manual_action_required",
|
||||
pendingManualAction: true,
|
||||
note: "天猫详情或评论模板缺失,需要运维刷新模板。",
|
||||
publicNote: "天猫会话缺少有效模板,运维后台正在处理。",
|
||||
session: summary
|
||||
};
|
||||
return {
|
||||
state: this.getState(),
|
||||
recovered: false
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await this.verifyCurrentSession();
|
||||
this.callbacks.onSessionReady?.();
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: "healthy",
|
||||
pendingManualAction: false,
|
||||
note: `天猫会话健康检查通过:${trigger}。`,
|
||||
publicNote: "天猫会话由运维后台维护,当前可用。",
|
||||
lastHealthyAt: nowIso(),
|
||||
lastFailureCode: undefined,
|
||||
lastFailureMessage: undefined,
|
||||
session: this.liveService.getSessionSummary()
|
||||
};
|
||||
return {
|
||||
state: this.getState(),
|
||||
recovered: false
|
||||
};
|
||||
} catch (error) {
|
||||
await this.handleFailure(error);
|
||||
return {
|
||||
state: this.getState(),
|
||||
recovered: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async handleLiveFailure(
|
||||
error: unknown,
|
||||
_context: {
|
||||
capability?: "search" | "detail" | "reviews";
|
||||
taskId?: string | undefined;
|
||||
trigger?: string | undefined;
|
||||
} = {}
|
||||
): Promise<boolean> {
|
||||
await this.handleFailure(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private restartScheduler(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
if (!this.config.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
void this.runHealthCheck("scheduler");
|
||||
}, this.config.checkIntervalMs);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
private resolveHeartbeatItemId(): string | null {
|
||||
const summary = this.liveService.getSessionSummary();
|
||||
return (
|
||||
this.config.heartbeatItemId ??
|
||||
summary.detailTemplate.itemId ??
|
||||
summary.reviewsTemplate.itemId ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private async verifyCurrentSession(): Promise<void> {
|
||||
const summary = this.liveService.getSessionSummary();
|
||||
const heartbeatItemId = this.resolveHeartbeatItemId();
|
||||
|
||||
if (!summary.configured || !heartbeatItemId) {
|
||||
throw new Error("天猫会话缺少可校验的详情/评论模板。");
|
||||
}
|
||||
|
||||
await this.liveService.previewDetail(heartbeatItemId);
|
||||
await this.liveService.previewReviews(heartbeatItemId, {
|
||||
commentCount: 1,
|
||||
maxPages: 1
|
||||
});
|
||||
}
|
||||
|
||||
private async handleFailure(error: unknown): Promise<void> {
|
||||
const message = error instanceof Error ? error.message : "unknown error";
|
||||
const failure = resolveFailureDescriptor(error);
|
||||
|
||||
this.callbacks.onSessionUnavailable?.();
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: failure.status,
|
||||
pendingManualAction: failure.pendingManualAction,
|
||||
lastFailureCode: failure.code,
|
||||
lastFailureMessage: message,
|
||||
note: `天猫会话检测失败:${message}`,
|
||||
publicNote: failure.publicNote,
|
||||
session: this.liveService.getSessionSummary()
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,182 +0,0 @@
|
||||
import type { CandidateRecord } from "@cross-ai/domain";
|
||||
|
||||
export interface TmallTemplateSummary {
|
||||
available: boolean;
|
||||
api?: string | undefined;
|
||||
itemId?: string | undefined;
|
||||
}
|
||||
|
||||
export interface TmallLiveSessionInput {
|
||||
cookieHeader: string;
|
||||
userAgent?: string | undefined;
|
||||
detailTemplateUrl?: string | undefined;
|
||||
reviewsTemplateUrl?: string | undefined;
|
||||
detailReferer?: string | undefined;
|
||||
}
|
||||
|
||||
export interface TmallLiveSessionSummary {
|
||||
configured: boolean;
|
||||
importedAt?: string | undefined;
|
||||
hasCookie: boolean;
|
||||
userAgent?: string | undefined;
|
||||
detailTemplate: TmallTemplateSummary;
|
||||
reviewsTemplate: TmallTemplateSummary;
|
||||
}
|
||||
|
||||
export type TmallSessionManagerStatus =
|
||||
| "idle"
|
||||
| "healthy"
|
||||
| "degraded"
|
||||
| "manual_action_required";
|
||||
|
||||
export interface TmallSessionManagerConfigInput {
|
||||
enabled?: boolean | undefined;
|
||||
heartbeatItemId?: string | null | undefined;
|
||||
checkIntervalMs?: number | null | undefined;
|
||||
}
|
||||
|
||||
export interface TmallSessionManagerState {
|
||||
status: TmallSessionManagerStatus;
|
||||
enabled: boolean;
|
||||
heartbeatItemId?: string | undefined;
|
||||
checkIntervalMs: number;
|
||||
pendingManualAction: boolean;
|
||||
note: string;
|
||||
publicNote: string;
|
||||
configuredAt?: string | undefined;
|
||||
lastCheckAt?: string | undefined;
|
||||
lastHealthyAt?: string | undefined;
|
||||
lastFailureCode?: string | undefined;
|
||||
lastFailureMessage?: string | undefined;
|
||||
session: TmallLiveSessionSummary;
|
||||
}
|
||||
|
||||
export interface TmallSessionManagerRunResult {
|
||||
state: TmallSessionManagerState;
|
||||
recovered: boolean;
|
||||
}
|
||||
|
||||
export interface TmallProductDetailSnapshot {
|
||||
itemId: string;
|
||||
title: string | null;
|
||||
subtitle: string | null;
|
||||
price: string | null;
|
||||
originalPrice: string | null;
|
||||
shopName: string | null;
|
||||
shopUrl: string | null;
|
||||
sellerType: string | null;
|
||||
categoryPath: string[];
|
||||
mainImage: string | null;
|
||||
salesDesc: string | null;
|
||||
commentCount: string | null;
|
||||
}
|
||||
|
||||
export interface TmallReviewTagSnapshot {
|
||||
name: string;
|
||||
count: string | null;
|
||||
}
|
||||
|
||||
export interface TmallReviewCommentSnapshot {
|
||||
id: string;
|
||||
content: string;
|
||||
date: string | null;
|
||||
userNick: string | null;
|
||||
userAvatar: string | null;
|
||||
skuText: string[];
|
||||
pictureUrls: string[];
|
||||
videoUrls: string[];
|
||||
likeCount: string | null;
|
||||
reply: string | null;
|
||||
appendContent: string | null;
|
||||
appendPictureUrls: string[];
|
||||
}
|
||||
|
||||
export interface TmallProductReviewsSnapshot {
|
||||
itemId: string;
|
||||
total: string | null;
|
||||
hasNext: boolean;
|
||||
allCount: string | null;
|
||||
pictureCount: string | null;
|
||||
appendCount: string | null;
|
||||
tags: TmallReviewTagSnapshot[];
|
||||
comments: TmallReviewCommentSnapshot[];
|
||||
}
|
||||
|
||||
export interface TmallReviewsPreviewOptions {
|
||||
commentCount?: number | undefined;
|
||||
page?: number | undefined;
|
||||
maxPages?: number | undefined;
|
||||
}
|
||||
|
||||
export interface TmallReviewsPaginationSummary {
|
||||
requestedPage: number;
|
||||
requestedCommentCount: number;
|
||||
maxPages: number;
|
||||
pagesFetched: number;
|
||||
pageKey?: string | undefined;
|
||||
}
|
||||
|
||||
export interface TmallSearchPreviewResult {
|
||||
query: string;
|
||||
source: "html";
|
||||
candidateCount: number;
|
||||
candidates: CandidateRecord[];
|
||||
}
|
||||
|
||||
export interface TmallDetailPreviewResult {
|
||||
itemId: string;
|
||||
source: "api" | "html";
|
||||
detail: TmallProductDetailSnapshot;
|
||||
}
|
||||
|
||||
export interface TmallReviewsPreviewResult {
|
||||
itemId: string;
|
||||
source: "api";
|
||||
pagination: TmallReviewsPaginationSummary;
|
||||
reviews: TmallProductReviewsSnapshot;
|
||||
}
|
||||
|
||||
export interface TmallProductPreviewResult {
|
||||
itemId: string;
|
||||
source: "api" | "html" | "hybrid";
|
||||
detail: TmallProductDetailSnapshot;
|
||||
pagination: TmallReviewsPaginationSummary;
|
||||
reviews: TmallProductReviewsSnapshot;
|
||||
}
|
||||
|
||||
export interface TmallLiveService {
|
||||
getSessionSummary(): TmallLiveSessionSummary;
|
||||
importSession(input: TmallLiveSessionInput): TmallLiveSessionSummary;
|
||||
clearSession(): void;
|
||||
previewSearch(query: string): Promise<TmallSearchPreviewResult>;
|
||||
previewDetail(itemId: string): Promise<TmallDetailPreviewResult>;
|
||||
previewReviews(
|
||||
itemId: string,
|
||||
options?: number | TmallReviewsPreviewOptions
|
||||
): Promise<TmallReviewsPreviewResult>;
|
||||
previewProduct(
|
||||
itemId: string,
|
||||
options?: number | TmallReviewsPreviewOptions
|
||||
): Promise<TmallProductPreviewResult>;
|
||||
}
|
||||
|
||||
export interface TmallSessionManager {
|
||||
getState(): TmallSessionManagerState;
|
||||
configure(input: TmallSessionManagerConfigInput): TmallSessionManagerState;
|
||||
clearConfig(): TmallSessionManagerState;
|
||||
importManualSession(
|
||||
input: TmallLiveSessionInput,
|
||||
source?: string
|
||||
): Promise<TmallSessionManagerState>;
|
||||
clearManagedSession(reason?: string): TmallSessionManagerState;
|
||||
runHealthCheck(trigger?: string): Promise<TmallSessionManagerRunResult>;
|
||||
handleLiveFailure(
|
||||
error: unknown,
|
||||
context?: {
|
||||
capability?: "search" | "detail" | "reviews";
|
||||
taskId?: string | undefined;
|
||||
trigger?: string | undefined;
|
||||
}
|
||||
): Promise<boolean>;
|
||||
shutdown(): void;
|
||||
}
|
||||
@ -1,187 +0,0 @@
|
||||
type PathSegment = string | number;
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
export function stringFrom(value: unknown): string | null {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function firstString(...values: unknown[]): string | null {
|
||||
for (const value of values) {
|
||||
const resolved = stringFrom(value);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseBooleanish(value: unknown): boolean {
|
||||
const normalized = stringFrom(value)?.toLowerCase();
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes";
|
||||
}
|
||||
|
||||
export function decodeHtmlEntities(value: string): string {
|
||||
return value
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
export function stripTags(value: string): string {
|
||||
return value.replace(/<[^>]+>/g, " ");
|
||||
}
|
||||
|
||||
export function normalizeWhitespace(value: string): string {
|
||||
return decodeHtmlEntities(stripTags(value)).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function uniqueStrings(values: Array<string | null | undefined>): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
values
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function absolutizeTmallUrl(value: string | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.startsWith("http://") || value.startsWith("https://")) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value.startsWith("//")) {
|
||||
return `https:${value}`;
|
||||
}
|
||||
|
||||
if (value.startsWith("/")) {
|
||||
return `https://detail.tmall.com${value}`;
|
||||
}
|
||||
|
||||
return `https://${value}`;
|
||||
}
|
||||
|
||||
export function parseEmbeddedJson(value: string | null): Record<string, unknown> | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
return asRecord(parsed);
|
||||
} catch {
|
||||
const jsonpMatch = value.match(/^[^(]+\(([\s\S]*)\)\s*;?\s*$/);
|
||||
if (!jsonpMatch?.[1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonpMatch[1]) as unknown;
|
||||
return asRecord(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function readNestedJsonRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value === "string") {
|
||||
return parseEmbeddedJson(value);
|
||||
}
|
||||
|
||||
return asRecord(value);
|
||||
}
|
||||
|
||||
export function readQueryData(url: URL): Record<string, unknown> | null {
|
||||
return (
|
||||
parseEmbeddedJson(url.searchParams.get("data")) ??
|
||||
parseEmbeddedJson(url.searchParams.get("body"))
|
||||
);
|
||||
}
|
||||
|
||||
export function findFirstRecordKey(
|
||||
record: Record<string, unknown> | null,
|
||||
candidates: string[]
|
||||
): string | null {
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate in record) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function withUpdatedQueryData(
|
||||
url: URL,
|
||||
updater: (data: Record<string, unknown>) => Record<string, unknown>
|
||||
): string {
|
||||
const nextUrl = new URL(url.toString());
|
||||
const paramName = nextUrl.searchParams.has("data") ? "data" : "body";
|
||||
const currentData = readQueryData(nextUrl) ?? {};
|
||||
nextUrl.searchParams.set(paramName, JSON.stringify(updater(currentData)));
|
||||
return nextUrl.toString();
|
||||
}
|
||||
|
||||
export function readPath(root: unknown, path: PathSegment[]): unknown {
|
||||
let current: unknown = root;
|
||||
|
||||
for (const segment of path) {
|
||||
if (typeof segment === "number") {
|
||||
const currentArray = asArray(current);
|
||||
current = currentArray[segment];
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentRecord = asRecord(current);
|
||||
current = currentRecord?.[segment];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
export function pickFirstStringByPaths(
|
||||
root: unknown,
|
||||
paths: PathSegment[][]
|
||||
): string | null {
|
||||
for (const path of paths) {
|
||||
const value = stringFrom(readPath(root, path));
|
||||
if (value) {
|
||||
return normalizeWhitespace(value);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -1,268 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
JdDetailPreviewResult,
|
||||
JdLiveService,
|
||||
JdLiveSessionSummary,
|
||||
JdProductPreviewResult,
|
||||
JdReviewsPreviewOptions,
|
||||
JdReviewsPreviewResult,
|
||||
JdSearchPreviewResult
|
||||
} from "./platforms/jd/types";
|
||||
import { createServer } from "./server";
|
||||
|
||||
async function createTask(app: ReturnType<typeof createServer>, query: string) {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tasks",
|
||||
payload: {
|
||||
query,
|
||||
perLinkLimit: 5,
|
||||
taskTotalLimit: 10
|
||||
}
|
||||
});
|
||||
|
||||
return response.json().task;
|
||||
}
|
||||
|
||||
function createJdLiveServiceStub(
|
||||
overrides: Partial<JdLiveService> = {}
|
||||
): JdLiveService {
|
||||
let summary: JdLiveSessionSummary = {
|
||||
configured: false,
|
||||
hasCookie: false,
|
||||
searchApiTemplate: { available: false },
|
||||
detailTemplate: { available: false },
|
||||
reviewsTemplate: { available: false }
|
||||
};
|
||||
|
||||
return {
|
||||
getSessionSummary() {
|
||||
return overrides.getSessionSummary?.() ?? summary;
|
||||
},
|
||||
importSession(input) {
|
||||
if (overrides.importSession) {
|
||||
return overrides.importSession(input);
|
||||
}
|
||||
|
||||
summary = {
|
||||
configured: true,
|
||||
importedAt: "2026-04-07T10:00:00.000Z",
|
||||
hasCookie: true,
|
||||
userAgent: input.userAgent ?? "stub-user-agent",
|
||||
searchApiTemplate: { available: Boolean(input.searchApiTemplateUrl) },
|
||||
detailTemplate: { available: Boolean(input.detailTemplateUrl) },
|
||||
reviewsTemplate: { available: Boolean(input.reviewsTemplateUrl) }
|
||||
};
|
||||
return summary;
|
||||
},
|
||||
clearSession() {
|
||||
if (overrides.clearSession) {
|
||||
overrides.clearSession();
|
||||
return;
|
||||
}
|
||||
|
||||
summary = {
|
||||
configured: false,
|
||||
hasCookie: false,
|
||||
searchApiTemplate: { available: false },
|
||||
detailTemplate: { available: false },
|
||||
reviewsTemplate: { available: false }
|
||||
};
|
||||
},
|
||||
async previewSearch(query) {
|
||||
if (overrides.previewSearch) {
|
||||
return overrides.previewSearch(query);
|
||||
}
|
||||
|
||||
const preview: JdSearchPreviewResult = {
|
||||
query,
|
||||
source: "api",
|
||||
candidateCount: 1,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: "jd-100068388533",
|
||||
platform: "jd",
|
||||
title: "Nintendo Switch 2",
|
||||
price: 2999,
|
||||
priceLabel: "¥2999",
|
||||
storeName: "京东自营",
|
||||
productUrl: "https://item.jd.com/100068388533.html",
|
||||
imageUrl: "https://img14.360buyimg.com/n2/jfs/t1/example.jpg",
|
||||
salesHint: "已售 1000+",
|
||||
specLabel: "标准版",
|
||||
highlights: ["掌机", "续航"]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return preview;
|
||||
},
|
||||
async previewDetail(skuId) {
|
||||
if (overrides.previewDetail) {
|
||||
return overrides.previewDetail(skuId);
|
||||
}
|
||||
|
||||
const preview: JdDetailPreviewResult = {
|
||||
skuId,
|
||||
source: "api",
|
||||
detail: {
|
||||
skuId,
|
||||
title: "Nintendo Switch 2",
|
||||
price: "2999.00",
|
||||
originalPrice: "3299.00",
|
||||
estimatedPrice: "2999.00",
|
||||
shopName: "京东自营",
|
||||
vendorId: null,
|
||||
categoryPath: ["游戏设备", "掌机"],
|
||||
stockState: "有货",
|
||||
mainImage: "https://img14.360buyimg.com/n2/jfs/t1/example.jpg",
|
||||
averageScore: "4.9"
|
||||
}
|
||||
};
|
||||
|
||||
return preview;
|
||||
},
|
||||
async previewReviews(skuId, options) {
|
||||
if (overrides.previewReviews) {
|
||||
return overrides.previewReviews(skuId, options);
|
||||
}
|
||||
|
||||
const requestedCommentCount =
|
||||
typeof options === "number" ? options : (options?.commentCount ?? 5);
|
||||
const preview: JdReviewsPreviewResult = {
|
||||
skuId,
|
||||
source: "api",
|
||||
pagination: {
|
||||
requestedPage: typeof options === "object" ? (options?.page ?? 1) : 1,
|
||||
requestedCommentCount,
|
||||
maxPages: typeof options === "object" ? (options?.maxPages ?? 1) : 1,
|
||||
pagesFetched: 1
|
||||
},
|
||||
reviews: {
|
||||
skuId,
|
||||
total: "2",
|
||||
goodRate: "95%",
|
||||
pictureCount: "1",
|
||||
tags: [{ tagId: "tag-1", name: "续航稳定", count: "2" }],
|
||||
comments: [
|
||||
{
|
||||
id: "comment-1",
|
||||
content: "第一条抓取评论,重点提到运行流畅。",
|
||||
score: "5",
|
||||
creationTime: "2026-04-07 10:00:00",
|
||||
userLevelName: "PLUS"
|
||||
},
|
||||
{
|
||||
id: "comment-2",
|
||||
content: "第二条抓取评论,重点提到续航稳定。",
|
||||
score: "4",
|
||||
creationTime: "2026-04-06 18:00:00",
|
||||
userLevelName: "会员"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return preview;
|
||||
},
|
||||
async previewProduct(skuId, options?: number | JdReviewsPreviewOptions) {
|
||||
if (overrides.previewProduct) {
|
||||
return overrides.previewProduct(skuId, options);
|
||||
}
|
||||
|
||||
const detail = await this.previewDetail(skuId);
|
||||
const reviews = await this.previewReviews(skuId, options);
|
||||
const preview: JdProductPreviewResult = {
|
||||
skuId,
|
||||
source: "api",
|
||||
detail: detail.detail,
|
||||
pagination: reviews.pagination,
|
||||
reviews: reviews.reviews
|
||||
};
|
||||
|
||||
return preview;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("report review collections", () => {
|
||||
it("publishes per-link review collections for report pages", async () => {
|
||||
const app = createServer({ jdLiveService: createJdLiveServiceStub() });
|
||||
await app.ready();
|
||||
|
||||
const importResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/platforms/jd/live-session",
|
||||
payload: {
|
||||
cookieHeader: "thor=masked; pin=masked;",
|
||||
searchApiTemplateUrl:
|
||||
"https://api.m.jd.com/?functionId=pc_search_searchWare&body=%7B%22keyword%22:%22switch%22%7D",
|
||||
detailTemplateUrl:
|
||||
"https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness&body=%7B%22skuId%22:%22100068388533%22%7D",
|
||||
reviewsTemplateUrl:
|
||||
"https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22sku%22:100068388533%7D"
|
||||
}
|
||||
});
|
||||
|
||||
expect(importResponse.statusCode).toBe(200);
|
||||
|
||||
const prepareResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/platforms/jd/prepare"
|
||||
});
|
||||
|
||||
expect(prepareResponse.statusCode).toBe(200);
|
||||
|
||||
const task = await createTask(app, "Nintendo Switch 2");
|
||||
const candidatesResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/tasks/${task.taskId}/candidates`
|
||||
});
|
||||
const firstCandidateId = candidatesResponse.json().candidates.jd[0].candidateId;
|
||||
|
||||
const confirmResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/tasks/${task.taskId}/confirm`,
|
||||
payload: {
|
||||
selections: [
|
||||
{
|
||||
platform: "jd",
|
||||
candidateIds: [firstCandidateId]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
expect(confirmResponse.statusCode).toBe(200);
|
||||
expect(confirmResponse.json().task.taskStatus).toBe("Completed");
|
||||
|
||||
const reportResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/tasks/${task.taskId}/report`
|
||||
});
|
||||
|
||||
expect(reportResponse.statusCode).toBe(200);
|
||||
expect(reportResponse.json().report.review_collections).toEqual([
|
||||
expect.objectContaining({
|
||||
platform: "jd",
|
||||
title: "Nintendo Switch 2",
|
||||
review_count: 2,
|
||||
product_evidence_id: expect.any(String),
|
||||
sampled_review_refs: expect.arrayContaining(["comment-1"]),
|
||||
comments: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
review_ref: "comment-1",
|
||||
content: "第一条抓取评论,重点提到运行流畅。"
|
||||
}),
|
||||
expect.objectContaining({
|
||||
review_ref: "comment-2",
|
||||
content: "第二条抓取评论,重点提到续航稳定。"
|
||||
})
|
||||
])
|
||||
})
|
||||
]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@ -1,92 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildReviewBudgetPlan,
|
||||
sampleReviewComments,
|
||||
type ReviewSamplingComment
|
||||
} from "./review-sampling";
|
||||
|
||||
function buildComment(
|
||||
id: string,
|
||||
content: string,
|
||||
score: string,
|
||||
authorLabel = "PLUS"
|
||||
): ReviewSamplingComment {
|
||||
return {
|
||||
id,
|
||||
content,
|
||||
score,
|
||||
createdAt: "2026-04-03 10:00:00",
|
||||
authorLabel
|
||||
};
|
||||
}
|
||||
|
||||
describe("review-sampling", () => {
|
||||
it("distributes the task-level review budget across selected links", () => {
|
||||
const budgets = buildReviewBudgetPlan(["a", "b", "c"], 100, 5);
|
||||
|
||||
expect(Array.from(budgets.entries())).toEqual([
|
||||
["a", 2],
|
||||
["b", 2],
|
||||
["c", 1]
|
||||
]);
|
||||
expect(Array.from(budgets.values()).reduce((sum, value) => sum + value, 0)).toBe(5);
|
||||
});
|
||||
|
||||
it("allows zero-review allocations when the task total limit is below selected link count", () => {
|
||||
const budgets = buildReviewBudgetPlan(["a", "b", "c", "d", "e"], 100, 2);
|
||||
|
||||
expect(Array.from(budgets.entries())).toEqual([
|
||||
["a", 1],
|
||||
["b", 1],
|
||||
["c", 0],
|
||||
["d", 0],
|
||||
["e", 0]
|
||||
]);
|
||||
});
|
||||
|
||||
it("samples comments with 40/30/30 latest-hot-negative targets when all buckets are available", () => {
|
||||
const comments = [
|
||||
buildComment("latest-1", "物流很快,系统很流畅。", "5"),
|
||||
buildComment("latest-2", "拍照效果不错,续航也稳定。", "5"),
|
||||
buildComment("latest-3", "屏幕亮度高,外观好看。", "5"),
|
||||
buildComment("latest-4", "音质清晰,做工扎实。", "5"),
|
||||
buildComment("hot-1", "这是我写过最长的一条好评,细节描述非常完整,便于归入热评桶。", "5"),
|
||||
buildComment("hot-2", "另一条较长的高分评论,包含更多使用场景和优点描述。", "5"),
|
||||
buildComment("hot-3", "第三条高信息量评论,强调拍照、手感和续航表现。", "4"),
|
||||
buildComment("negative-1", "发热明显,打游戏会卡顿。", "2"),
|
||||
buildComment("negative-2", "续航一般,晚上掉电有点快。", "3"),
|
||||
buildComment("negative-3", "边框有瑕疵,体验一般。", "2")
|
||||
];
|
||||
|
||||
const sampled = sampleReviewComments(comments, 10, ["发热", "卡顿", "掉电", "瑕疵"]);
|
||||
|
||||
expect(sampled.actualCount).toBe(10);
|
||||
expect(sampled.sampleInsufficient).toBe(false);
|
||||
expect(sampled.bucketCounts).toEqual({
|
||||
latest: 4,
|
||||
hot: 3,
|
||||
negative: 3
|
||||
});
|
||||
});
|
||||
|
||||
it("redistributes missing negative quota to the remaining buckets", () => {
|
||||
const comments = [
|
||||
buildComment("c-1", "外观好看。", "5"),
|
||||
buildComment("c-2", "系统流畅。", "5"),
|
||||
buildComment("c-3", "续航不错。", "5"),
|
||||
buildComment("c-4", "拍照清晰。", "5"),
|
||||
buildComment("c-5", "做工扎实。", "4")
|
||||
];
|
||||
|
||||
const sampled = sampleReviewComments(comments, 5, ["发热", "卡顿"]);
|
||||
|
||||
expect(sampled.actualCount).toBe(5);
|
||||
expect(sampled.sampleInsufficient).toBe(false);
|
||||
expect(sampled.bucketCounts).toEqual({
|
||||
latest: 3,
|
||||
hot: 2,
|
||||
negative: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,312 +0,0 @@
|
||||
export type ReviewSamplingBucket = "latest" | "hot" | "negative";
|
||||
|
||||
export interface ReviewSamplingComment {
|
||||
id: string;
|
||||
content: string;
|
||||
score: string | null;
|
||||
createdAt: string | null;
|
||||
authorLabel: string | null;
|
||||
}
|
||||
|
||||
export interface SampledReviewComment<T extends ReviewSamplingComment = ReviewSamplingComment> {
|
||||
bucket: ReviewSamplingBucket;
|
||||
comment: T;
|
||||
}
|
||||
|
||||
export interface ReviewSamplingResult<T extends ReviewSamplingComment = ReviewSamplingComment> {
|
||||
targetCount: number;
|
||||
actualCount: number;
|
||||
sampleInsufficient: boolean;
|
||||
bucketCounts: Record<ReviewSamplingBucket, number>;
|
||||
comments: Array<SampledReviewComment<T>>;
|
||||
}
|
||||
|
||||
const BUCKET_WEIGHTS: Record<ReviewSamplingBucket, number> = {
|
||||
latest: 0.4,
|
||||
hot: 0.3,
|
||||
negative: 0.3
|
||||
};
|
||||
|
||||
const QUOTA_PRIORITY: ReviewSamplingBucket[] = ["latest", "hot", "negative"];
|
||||
const SELECTION_PRIORITY: ReviewSamplingBucket[] = ["negative", "hot", "latest"];
|
||||
const FALLBACK_PRIORITY: ReviewSamplingBucket[] = ["latest", "hot", "negative"];
|
||||
|
||||
function normalizeBudget(value: number): number {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function parseScore(score: string | null): number | null {
|
||||
if (!score) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(score, 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
function isNegativeComment(
|
||||
comment: ReviewSamplingComment,
|
||||
negativeKeywords: string[]
|
||||
): boolean {
|
||||
const score = parseScore(comment.score);
|
||||
if (score !== null && score <= 3) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedContent = comment.content.toLowerCase();
|
||||
return negativeKeywords.some((keyword) => normalizedContent.includes(keyword.toLowerCase()));
|
||||
}
|
||||
|
||||
function getHotScore(comment: ReviewSamplingComment): number {
|
||||
const score = parseScore(comment.score);
|
||||
const scoreBonus = score !== null && score >= 4 ? 16 : score === 3 ? 8 : 0;
|
||||
const authorBonus = comment.authorLabel ? 12 : 0;
|
||||
const timestampBonus = comment.createdAt ? 4 : 0;
|
||||
|
||||
return Math.min(comment.content.length, 160) + scoreBonus + authorBonus + timestampBonus;
|
||||
}
|
||||
|
||||
function dedupeComments<T extends ReviewSamplingComment>(comments: T[]): T[] {
|
||||
const deduped = new Map<string, T>();
|
||||
for (const comment of comments) {
|
||||
if (!deduped.has(comment.id)) {
|
||||
deduped.set(comment.id, comment);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(deduped.values());
|
||||
}
|
||||
|
||||
function buildBucketTargets(
|
||||
targetCount: number,
|
||||
availability: Record<ReviewSamplingBucket, boolean>
|
||||
): Record<ReviewSamplingBucket, number> {
|
||||
const normalizedTarget = normalizeBudget(targetCount);
|
||||
const activeBuckets = QUOTA_PRIORITY.filter((bucket) => availability[bucket]);
|
||||
const targets: Record<ReviewSamplingBucket, number> = {
|
||||
latest: 0,
|
||||
hot: 0,
|
||||
negative: 0
|
||||
};
|
||||
|
||||
if (normalizedTarget === 0 || activeBuckets.length === 0) {
|
||||
return targets;
|
||||
}
|
||||
|
||||
const activeWeight = activeBuckets.reduce((sum, bucket) => sum + BUCKET_WEIGHTS[bucket], 0);
|
||||
let allocated = 0;
|
||||
const fractions = activeBuckets.map((bucket) => {
|
||||
const raw = (normalizedTarget * BUCKET_WEIGHTS[bucket]) / activeWeight;
|
||||
const base = Math.floor(raw);
|
||||
targets[bucket] = base;
|
||||
allocated += base;
|
||||
|
||||
return {
|
||||
bucket,
|
||||
fraction: raw - base
|
||||
};
|
||||
});
|
||||
|
||||
let remainder = normalizedTarget - allocated;
|
||||
fractions
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.fraction - left.fraction ||
|
||||
QUOTA_PRIORITY.indexOf(left.bucket) - QUOTA_PRIORITY.indexOf(right.bucket)
|
||||
)
|
||||
.forEach(({ bucket }) => {
|
||||
if (remainder <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
targets[bucket] += 1;
|
||||
remainder -= 1;
|
||||
});
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
function hasUnusedComment<T extends ReviewSamplingComment>(pool: T[], usedIds: Set<string>): boolean {
|
||||
return pool.some((comment) => !usedIds.has(comment.id));
|
||||
}
|
||||
|
||||
function pickNextComment<T extends ReviewSamplingComment>(
|
||||
bucket: ReviewSamplingBucket,
|
||||
pool: T[],
|
||||
usedIds: Set<string>,
|
||||
selected: Array<SampledReviewComment<T>>,
|
||||
bucketCounts: Record<ReviewSamplingBucket, number>
|
||||
): boolean {
|
||||
for (const comment of pool) {
|
||||
if (usedIds.has(comment.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
usedIds.add(comment.id);
|
||||
selected.push({
|
||||
bucket,
|
||||
comment
|
||||
});
|
||||
bucketCounts[bucket] += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildReviewBudgetPlan(
|
||||
candidateIds: string[],
|
||||
perLinkLimit: number,
|
||||
taskTotalLimit: number
|
||||
): Map<string, number> {
|
||||
const uniqueCandidateIds = Array.from(new Set(candidateIds));
|
||||
const budgets = new Map<string, number>(
|
||||
uniqueCandidateIds.map((candidateId) => [candidateId, 0] as const)
|
||||
);
|
||||
const normalizedPerLinkLimit = normalizeBudget(perLinkLimit);
|
||||
const normalizedTaskTotalLimit = normalizeBudget(taskTotalLimit);
|
||||
|
||||
if (
|
||||
uniqueCandidateIds.length === 0 ||
|
||||
normalizedPerLinkLimit === 0 ||
|
||||
normalizedTaskTotalLimit === 0
|
||||
) {
|
||||
return budgets;
|
||||
}
|
||||
|
||||
const totalBudget = Math.min(
|
||||
normalizedTaskTotalLimit,
|
||||
uniqueCandidateIds.length * normalizedPerLinkLimit
|
||||
);
|
||||
const baseAllocation = Math.min(
|
||||
normalizedPerLinkLimit,
|
||||
Math.floor(totalBudget / uniqueCandidateIds.length)
|
||||
);
|
||||
|
||||
for (const candidateId of uniqueCandidateIds) {
|
||||
budgets.set(candidateId, baseAllocation);
|
||||
}
|
||||
|
||||
let remainder = totalBudget - baseAllocation * uniqueCandidateIds.length;
|
||||
while (remainder > 0) {
|
||||
let assigned = false;
|
||||
|
||||
for (const candidateId of uniqueCandidateIds) {
|
||||
const currentBudget = budgets.get(candidateId) ?? 0;
|
||||
if (currentBudget >= normalizedPerLinkLimit) {
|
||||
continue;
|
||||
}
|
||||
|
||||
budgets.set(candidateId, currentBudget + 1);
|
||||
remainder -= 1;
|
||||
assigned = true;
|
||||
|
||||
if (remainder === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!assigned) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return budgets;
|
||||
}
|
||||
|
||||
export function sampleReviewComments<T extends ReviewSamplingComment>(
|
||||
comments: T[],
|
||||
targetCount: number,
|
||||
negativeKeywords: string[]
|
||||
): ReviewSamplingResult<T> {
|
||||
const uniqueComments = dedupeComments(comments);
|
||||
const normalizedTarget = normalizeBudget(targetCount);
|
||||
const bucketCounts: Record<ReviewSamplingBucket, number> = {
|
||||
latest: 0,
|
||||
hot: 0,
|
||||
negative: 0
|
||||
};
|
||||
|
||||
if (normalizedTarget === 0 || uniqueComments.length === 0) {
|
||||
return {
|
||||
targetCount: normalizedTarget,
|
||||
actualCount: 0,
|
||||
sampleInsufficient: normalizedTarget > 0,
|
||||
bucketCounts,
|
||||
comments: []
|
||||
};
|
||||
}
|
||||
|
||||
const negativeCommentIds = new Set(
|
||||
uniqueComments
|
||||
.filter((comment) => isNegativeComment(comment, negativeKeywords))
|
||||
.map((comment) => comment.id)
|
||||
);
|
||||
const negativePool = uniqueComments.filter((comment) => negativeCommentIds.has(comment.id));
|
||||
const latestPool = uniqueComments.filter((comment) => !negativeCommentIds.has(comment.id));
|
||||
const hotPool = [...latestPool].sort(
|
||||
(left, right) =>
|
||||
getHotScore(right) - getHotScore(left) ||
|
||||
latestPool.findIndex((comment) => comment.id === left.id) -
|
||||
latestPool.findIndex((comment) => comment.id === right.id)
|
||||
);
|
||||
const bucketPools: Record<ReviewSamplingBucket, T[]> = {
|
||||
latest: latestPool,
|
||||
hot: hotPool,
|
||||
negative: negativePool
|
||||
};
|
||||
const bucketTargets = buildBucketTargets(normalizedTarget, {
|
||||
latest: latestPool.length > 0,
|
||||
hot: hotPool.length > 0,
|
||||
negative: negativePool.length > 0
|
||||
});
|
||||
const selected: Array<SampledReviewComment<T>> = [];
|
||||
const usedIds = new Set<string>();
|
||||
|
||||
for (const bucket of SELECTION_PRIORITY) {
|
||||
while (
|
||||
bucketCounts[bucket] < bucketTargets[bucket] &&
|
||||
pickNextComment(bucket, bucketPools[bucket], usedIds, selected, bucketCounts)
|
||||
) {
|
||||
// Continue until this bucket reaches its target or runs out of comments.
|
||||
}
|
||||
}
|
||||
|
||||
while (selected.length < normalizedTarget) {
|
||||
const deficitBucket = SELECTION_PRIORITY.find(
|
||||
(bucket) =>
|
||||
bucketCounts[bucket] < bucketTargets[bucket] &&
|
||||
hasUnusedComment(bucketPools[bucket], usedIds)
|
||||
);
|
||||
|
||||
if (deficitBucket) {
|
||||
if (pickNextComment(deficitBucket, bucketPools[deficitBucket], usedIds, selected, bucketCounts)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackBucket = FALLBACK_PRIORITY.find((bucket) =>
|
||||
hasUnusedComment(bucketPools[bucket], usedIds)
|
||||
);
|
||||
|
||||
if (!fallbackBucket) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!pickNextComment(fallbackBucket, bucketPools[fallbackBucket], usedIds, selected, bucketCounts)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
targetCount: normalizedTarget,
|
||||
actualCount: selected.length,
|
||||
sampleInsufficient: selected.length < normalizedTarget,
|
||||
bucketCounts,
|
||||
comments: selected
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -3,9 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
JdDetailPreviewResult,
|
||||
JdLiveService,
|
||||
JdProductPreviewResult,
|
||||
JdLiveSessionSummary,
|
||||
JdReviewsPreviewOptions,
|
||||
JdReviewsPreviewResult,
|
||||
JdSearchPreviewResult
|
||||
} from "./platforms/jd/types";
|
||||
@ -36,18 +34,9 @@ function createJdLiveServiceStub(
|
||||
importedAt: "2026-04-02T12:00:00.000Z",
|
||||
hasCookie: true,
|
||||
userAgent: input.userAgent ?? "stub-user-agent",
|
||||
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
|
||||
}
|
||||
searchApiTemplate: { available: Boolean(input.searchApiTemplateUrl) },
|
||||
detailTemplate: { available: Boolean(input.detailTemplateUrl) },
|
||||
reviewsTemplate: { available: Boolean(input.reviewsTemplateUrl) }
|
||||
};
|
||||
return summary;
|
||||
},
|
||||
@ -118,24 +107,14 @@ function createJdLiveServiceStub(
|
||||
|
||||
return preview;
|
||||
},
|
||||
async previewReviews(skuId, options) {
|
||||
async previewReviews(skuId, commentCount) {
|
||||
if (overrides.previewReviews) {
|
||||
return overrides.previewReviews(skuId, options);
|
||||
return overrides.previewReviews(skuId, commentCount);
|
||||
}
|
||||
|
||||
const preview: JdReviewsPreviewResult = {
|
||||
skuId,
|
||||
source: "api",
|
||||
pagination: {
|
||||
requestedPage: typeof options === "object" ? (options?.page ?? 1) : 1,
|
||||
requestedCommentCount:
|
||||
typeof options === "number"
|
||||
? options
|
||||
: (options?.commentCount ?? 5),
|
||||
maxPages: typeof options === "object" ? (options?.maxPages ?? 1) : 1,
|
||||
pagesFetched: typeof options === "object" ? (options?.maxPages ?? 1) : 1,
|
||||
pageKey: typeof options === "object" && options?.maxPages ? "page" : undefined
|
||||
},
|
||||
reviews: {
|
||||
skuId,
|
||||
total: "10000",
|
||||
@ -160,23 +139,6 @@ function createJdLiveServiceStub(
|
||||
}
|
||||
};
|
||||
|
||||
return preview;
|
||||
},
|
||||
async previewProduct(skuId, options?: number | JdReviewsPreviewOptions) {
|
||||
if (overrides.previewProduct) {
|
||||
return overrides.previewProduct(skuId, options);
|
||||
}
|
||||
|
||||
const detail = (await this.previewDetail(skuId)) as JdDetailPreviewResult;
|
||||
const reviews = (await this.previewReviews(skuId, options)) as JdReviewsPreviewResult;
|
||||
const preview: JdProductPreviewResult = {
|
||||
skuId,
|
||||
source: "api",
|
||||
detail: detail.detail,
|
||||
pagination: reviews.pagination,
|
||||
reviews: reviews.reviews
|
||||
};
|
||||
|
||||
return preview;
|
||||
}
|
||||
};
|
||||
@ -294,21 +256,6 @@ describe("JD live server endpoints", () => {
|
||||
skuId: "100068388533",
|
||||
goodRate: "95%"
|
||||
});
|
||||
expect(reviewsResponse.json().preview.pagination).toMatchObject({
|
||||
requestedCommentCount: 3
|
||||
});
|
||||
|
||||
const productResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/platforms/jd/live-product-preview?skuId=100068388533&commentCount=3&maxPages=2"
|
||||
});
|
||||
expect(productResponse.statusCode).toBe(200);
|
||||
expect(productResponse.json().preview).toMatchObject({
|
||||
skuId: "100068388533",
|
||||
pagination: {
|
||||
maxPages: 2
|
||||
}
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@ -1,341 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createServer } from "./server";
|
||||
import type { JdLiveService, JdSessionManager, JdSessionManagerState } from "./platforms/jd/types";
|
||||
|
||||
function createSessionSummary(configured: boolean) {
|
||||
return configured
|
||||
? {
|
||||
configured: true,
|
||||
importedAt: "2026-04-03T08:30:00.000Z",
|
||||
hasCookie: true,
|
||||
userAgent: "stub-user-agent",
|
||||
searchApiTemplate: {
|
||||
available: true,
|
||||
skuId: "100068388533"
|
||||
},
|
||||
detailTemplate: {
|
||||
available: true,
|
||||
skuId: "100068388533"
|
||||
},
|
||||
reviewsTemplate: {
|
||||
available: true,
|
||||
skuId: "100068388533"
|
||||
}
|
||||
}
|
||||
: {
|
||||
configured: false,
|
||||
hasCookie: false,
|
||||
searchApiTemplate: {
|
||||
available: false
|
||||
},
|
||||
detailTemplate: {
|
||||
available: false
|
||||
},
|
||||
reviewsTemplate: {
|
||||
available: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createManagerState(
|
||||
overrides: Partial<JdSessionManagerState> = {}
|
||||
): JdSessionManagerState {
|
||||
return {
|
||||
status: "idle",
|
||||
enabled: true,
|
||||
autoLoginMode: "disabled",
|
||||
commandConfigured: false,
|
||||
accountConfigured: false,
|
||||
passwordConfigured: false,
|
||||
heartbeatQuery: "iPhone 15",
|
||||
checkIntervalMs: 600000,
|
||||
runnerTimeoutMs: 300000,
|
||||
pendingManualAction: false,
|
||||
note: "京东会话等待运维注入。",
|
||||
publicNote: "京东会话由运维后台维护,当前尚未就绪。",
|
||||
session: createSessionSummary(false),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe("JD ops session manager routes", () => {
|
||||
const apps: Array<Awaited<ReturnType<typeof createServer>>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (apps.length > 0) {
|
||||
await apps.pop()!.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes ops endpoints and keeps readiness/live-session state in sync", async () => {
|
||||
let state = createManagerState();
|
||||
const manager: JdSessionManager = {
|
||||
getState() {
|
||||
return state;
|
||||
},
|
||||
configure(input) {
|
||||
state = createManagerState({
|
||||
...state,
|
||||
enabled: input.enabled ?? state.enabled,
|
||||
autoLoginMode: input.autoLoginMode ?? state.autoLoginMode,
|
||||
commandConfigured: Boolean(input.loginCommand),
|
||||
heartbeatQuery: input.heartbeatQuery ?? state.heartbeatQuery,
|
||||
checkIntervalMs: input.checkIntervalMs ?? state.checkIntervalMs,
|
||||
runnerTimeoutMs: input.runnerTimeoutMs ?? state.runnerTimeoutMs,
|
||||
note: "京东运维配置已更新。"
|
||||
});
|
||||
return state;
|
||||
},
|
||||
clearConfig() {
|
||||
state = createManagerState({
|
||||
...state,
|
||||
enabled: false,
|
||||
autoLoginMode: "disabled",
|
||||
commandConfigured: false,
|
||||
note: "京东运维配置已清空。"
|
||||
});
|
||||
return state;
|
||||
},
|
||||
async importManualSession() {
|
||||
state = createManagerState({
|
||||
...state,
|
||||
status: "healthy",
|
||||
note: "京东会话已通过 ops-manual 更新。",
|
||||
publicNote: "京东会话由运维后台维护,当前可用。",
|
||||
session: createSessionSummary(true)
|
||||
});
|
||||
return state;
|
||||
},
|
||||
clearManagedSession() {
|
||||
state = createManagerState({
|
||||
...state,
|
||||
status: "idle",
|
||||
note: "京东会话已清理。",
|
||||
publicNote: "京东会话由运维后台维护,当前尚未就绪。",
|
||||
session: createSessionSummary(false)
|
||||
});
|
||||
return state;
|
||||
},
|
||||
async runHealthCheck() {
|
||||
state = createManagerState({
|
||||
...state,
|
||||
status: "healthy",
|
||||
note: "京东会话健康检查通过。",
|
||||
publicNote: "京东会话由运维后台维护,当前可用。",
|
||||
session: createSessionSummary(true)
|
||||
});
|
||||
return {
|
||||
state,
|
||||
recovered: false
|
||||
};
|
||||
},
|
||||
async runAutoRecovery() {
|
||||
state = createManagerState({
|
||||
...state,
|
||||
status: "healthy",
|
||||
autoLoginMode: "command",
|
||||
commandConfigured: true,
|
||||
note: "京东自动恢复成功。",
|
||||
publicNote: "京东会话由运维后台维护,当前可用。",
|
||||
session: createSessionSummary(true)
|
||||
});
|
||||
return {
|
||||
state,
|
||||
recovered: true
|
||||
};
|
||||
},
|
||||
async handleLiveFailure() {
|
||||
return true;
|
||||
},
|
||||
shutdown() {}
|
||||
};
|
||||
const jdLiveService: JdLiveService = {
|
||||
getSessionSummary() {
|
||||
return state.session;
|
||||
},
|
||||
importSession() {
|
||||
return state.session;
|
||||
},
|
||||
clearSession() {},
|
||||
async previewSearch() {
|
||||
return {
|
||||
query: "iPhone 15",
|
||||
source: "html",
|
||||
candidateCount: 0,
|
||||
candidates: []
|
||||
};
|
||||
},
|
||||
async previewDetail(skuId) {
|
||||
return {
|
||||
skuId,
|
||||
source: "api",
|
||||
detail: {
|
||||
skuId,
|
||||
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"
|
||||
}
|
||||
};
|
||||
},
|
||||
async previewReviews(skuId) {
|
||||
return {
|
||||
skuId,
|
||||
source: "api",
|
||||
pagination: {
|
||||
requestedPage: 1,
|
||||
requestedCommentCount: 1,
|
||||
maxPages: 1,
|
||||
pagesFetched: 1
|
||||
},
|
||||
reviews: {
|
||||
skuId,
|
||||
total: "1000",
|
||||
goodRate: "96%",
|
||||
pictureCount: "120",
|
||||
tags: [],
|
||||
comments: []
|
||||
}
|
||||
};
|
||||
},
|
||||
async previewProduct(skuId) {
|
||||
const detail = await this.previewDetail(skuId);
|
||||
const reviews = await this.previewReviews(skuId);
|
||||
return {
|
||||
skuId,
|
||||
source: "api",
|
||||
detail: detail.detail,
|
||||
pagination: reviews.pagination,
|
||||
reviews: reviews.reviews
|
||||
};
|
||||
}
|
||||
};
|
||||
const app = createServer({
|
||||
jdLiveService,
|
||||
jdSessionManager: manager
|
||||
});
|
||||
apps.push(app);
|
||||
await app.ready();
|
||||
|
||||
const getManagerResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/ops/jd/session-manager"
|
||||
});
|
||||
|
||||
expect(getManagerResponse.statusCode).toBe(200);
|
||||
expect(getManagerResponse.json().manager).toMatchObject({
|
||||
status: "idle",
|
||||
publicNote: "京东会话由运维后台维护,当前尚未就绪。"
|
||||
});
|
||||
|
||||
const readinessResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/platforms/readiness"
|
||||
});
|
||||
|
||||
expect(
|
||||
readinessResponse
|
||||
.json()
|
||||
.platforms.find((platform: { platform: string }) => platform.platform === "jd")
|
||||
).toMatchObject({
|
||||
platform: "jd",
|
||||
reason: "京东会话由运维后台维护,当前尚未就绪。"
|
||||
});
|
||||
|
||||
const configResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/ops/jd/session-manager/config",
|
||||
payload: {
|
||||
enabled: true,
|
||||
autoLoginMode: "command",
|
||||
loginCommand: "node scripts/jd-login-ops.mjs"
|
||||
}
|
||||
});
|
||||
|
||||
expect(configResponse.statusCode).toBe(200);
|
||||
expect(configResponse.json().manager).toMatchObject({
|
||||
autoLoginMode: "command",
|
||||
commandConfigured: true
|
||||
});
|
||||
|
||||
const healthCheckResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/ops/jd/session-manager/check"
|
||||
});
|
||||
|
||||
expect(healthCheckResponse.statusCode).toBe(200);
|
||||
expect(healthCheckResponse.json()).toMatchObject({
|
||||
recovered: false,
|
||||
state: {
|
||||
status: "healthy"
|
||||
}
|
||||
});
|
||||
|
||||
const recoverResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/ops/jd/session-manager/recover"
|
||||
});
|
||||
|
||||
expect(recoverResponse.statusCode).toBe(200);
|
||||
expect(recoverResponse.json()).toMatchObject({
|
||||
recovered: true,
|
||||
state: {
|
||||
status: "healthy",
|
||||
autoLoginMode: "command"
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
expect(importResponse.json().manager).toMatchObject({
|
||||
status: "healthy",
|
||||
session: {
|
||||
configured: true
|
||||
}
|
||||
});
|
||||
|
||||
const liveSessionResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/platforms/jd/live-session"
|
||||
});
|
||||
|
||||
expect(liveSessionResponse.statusCode).toBe(200);
|
||||
expect(liveSessionResponse.json().session).toMatchObject({
|
||||
configured: true,
|
||||
detailTemplate: {
|
||||
available: true
|
||||
}
|
||||
});
|
||||
|
||||
const clearResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/ops/jd/session-manager/session"
|
||||
});
|
||||
|
||||
expect(clearResponse.statusCode).toBe(200);
|
||||
expect(clearResponse.json().manager).toMatchObject({
|
||||
status: "idle",
|
||||
session: {
|
||||
configured: false
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,395 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
TmallDetailPreviewResult,
|
||||
TmallLiveService,
|
||||
TmallLiveSessionSummary,
|
||||
TmallProductPreviewResult,
|
||||
TmallReviewsPreviewResult,
|
||||
TmallSearchPreviewResult
|
||||
} from "./platforms/tmall/types";
|
||||
import { createServer } from "./server";
|
||||
|
||||
async function createTask(app: ReturnType<typeof createServer>, query: string) {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tasks",
|
||||
payload: {
|
||||
query,
|
||||
perLinkLimit: 100,
|
||||
taskTotalLimit: 500
|
||||
}
|
||||
});
|
||||
|
||||
return response.json().task;
|
||||
}
|
||||
|
||||
function createTmallLiveServiceStub(
|
||||
overrides: Partial<TmallLiveService> = {}
|
||||
): TmallLiveService {
|
||||
let summary: TmallLiveSessionSummary = {
|
||||
configured: false,
|
||||
hasCookie: false,
|
||||
detailTemplate: { available: false },
|
||||
reviewsTemplate: { available: false }
|
||||
};
|
||||
|
||||
return {
|
||||
getSessionSummary() {
|
||||
return overrides.getSessionSummary?.() ?? summary;
|
||||
},
|
||||
importSession(input) {
|
||||
if (overrides.importSession) {
|
||||
return overrides.importSession(input);
|
||||
}
|
||||
|
||||
summary = {
|
||||
configured: true,
|
||||
importedAt: "2026-04-03T02:30:00.000Z",
|
||||
hasCookie: true,
|
||||
userAgent: input.userAgent ?? "stub-user-agent",
|
||||
detailTemplate: { available: Boolean(input.detailTemplateUrl), itemId: "833444005595" },
|
||||
reviewsTemplate: { available: Boolean(input.reviewsTemplateUrl), itemId: "833444005595" }
|
||||
};
|
||||
return summary;
|
||||
},
|
||||
clearSession() {
|
||||
if (overrides.clearSession) {
|
||||
overrides.clearSession();
|
||||
return;
|
||||
}
|
||||
|
||||
summary = {
|
||||
configured: false,
|
||||
hasCookie: false,
|
||||
detailTemplate: { available: false },
|
||||
reviewsTemplate: { available: false }
|
||||
};
|
||||
},
|
||||
async previewSearch(query) {
|
||||
if (overrides.previewSearch) {
|
||||
return overrides.previewSearch(query);
|
||||
}
|
||||
|
||||
const preview: TmallSearchPreviewResult = {
|
||||
query,
|
||||
source: "html",
|
||||
candidateCount: 1,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: "tmall-833444005595",
|
||||
platform: "tmall",
|
||||
title: "Apple iPhone 15",
|
||||
price: 4399,
|
||||
priceLabel: "CNY 4399",
|
||||
storeName: "Apple Flagship Store",
|
||||
productUrl: "https://detail.tmall.com/item.htm?id=833444005595",
|
||||
imageUrl: "https://img.alicdn.com/example.jpg",
|
||||
salesHint: "sold 10k+",
|
||||
specLabel: "128GB",
|
||||
highlights: ["Tmall", "Fast shipping"]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return preview;
|
||||
},
|
||||
async previewDetail(itemId) {
|
||||
if (overrides.previewDetail) {
|
||||
return overrides.previewDetail(itemId);
|
||||
}
|
||||
|
||||
const preview: TmallDetailPreviewResult = {
|
||||
itemId,
|
||||
source: "api",
|
||||
detail: {
|
||||
itemId,
|
||||
title: "Apple iPhone 15",
|
||||
subtitle: null,
|
||||
price: "4399.00",
|
||||
originalPrice: "4999.00",
|
||||
shopName: "Apple Flagship Store",
|
||||
shopUrl: null,
|
||||
sellerType: "tmall",
|
||||
categoryPath: ["Phones", "Mobile", "Apple"],
|
||||
mainImage: "https://img.alicdn.com/example.jpg",
|
||||
salesDesc: "sold 10k+",
|
||||
commentCount: "20k+"
|
||||
}
|
||||
};
|
||||
|
||||
return preview;
|
||||
},
|
||||
async previewReviews(itemId, options) {
|
||||
if (overrides.previewReviews) {
|
||||
return overrides.previewReviews(itemId, options);
|
||||
}
|
||||
|
||||
const commentCount =
|
||||
typeof options === "number" ? options : (options?.commentCount ?? 20);
|
||||
const page = typeof options === "number" ? 1 : (options?.page ?? 1);
|
||||
const maxPages = typeof options === "number" ? 1 : (options?.maxPages ?? 1);
|
||||
|
||||
const preview: TmallReviewsPreviewResult = {
|
||||
itemId,
|
||||
source: "api",
|
||||
pagination: {
|
||||
requestedPage: page,
|
||||
requestedCommentCount: commentCount,
|
||||
maxPages,
|
||||
pagesFetched: 1,
|
||||
pageKey: "pageNum"
|
||||
},
|
||||
reviews: {
|
||||
itemId,
|
||||
total: "20k+",
|
||||
hasNext: false,
|
||||
allCount: "20k+",
|
||||
pictureCount: "5000+",
|
||||
appendCount: "1200+",
|
||||
tags: [
|
||||
{
|
||||
name: "good looking",
|
||||
count: "5313"
|
||||
}
|
||||
],
|
||||
comments: [
|
||||
{
|
||||
id: "review-1",
|
||||
content: "Smooth and well built.",
|
||||
date: "2026-04-03",
|
||||
userNick: "Alice",
|
||||
userAvatar: null,
|
||||
skuText: ["Black", "128GB"],
|
||||
pictureUrls: [],
|
||||
videoUrls: [],
|
||||
likeCount: "3",
|
||||
reply: null,
|
||||
appendContent: null,
|
||||
appendPictureUrls: []
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return preview;
|
||||
},
|
||||
async previewProduct(itemId, options) {
|
||||
if (overrides.previewProduct) {
|
||||
return overrides.previewProduct(itemId, options);
|
||||
}
|
||||
|
||||
const detail = await this.previewDetail(itemId);
|
||||
const reviews = await this.previewReviews(itemId, options);
|
||||
const preview: TmallProductPreviewResult = {
|
||||
itemId,
|
||||
source: "api",
|
||||
detail: detail.detail,
|
||||
pagination: reviews.pagination,
|
||||
reviews: reviews.reviews
|
||||
};
|
||||
|
||||
return preview;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("Tmall live session endpoints", () => {
|
||||
it("imports a Tmall live session and updates readiness", async () => {
|
||||
const tmallLiveService = createTmallLiveServiceStub();
|
||||
const app = createServer({ tmallLiveService });
|
||||
await app.ready();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/platforms/tmall/live-session",
|
||||
payload: {
|
||||
cookieHeader: "_tb_token_=masked;",
|
||||
detailTemplateUrl:
|
||||
"https://h5api.m.taobao.com/h5/mtop.taobao.pcdetail.data.get/1.0/?data=%7B%22id%22%3A%22833444005595%22%7D",
|
||||
reviewsTemplateUrl:
|
||||
"https://h5api.m.taobao.com/h5/mtop.alibaba.review.list.for.new.pc.detail/1.0/?data=%7B%22itemId%22%3A%22833444005595%22%7D"
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().session).toMatchObject({
|
||||
configured: true,
|
||||
hasCookie: true,
|
||||
detailTemplate: {
|
||||
available: true
|
||||
},
|
||||
reviewsTemplate: {
|
||||
available: true
|
||||
}
|
||||
});
|
||||
|
||||
const readinessResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/platforms/readiness"
|
||||
});
|
||||
expect(
|
||||
readinessResponse
|
||||
.json()
|
||||
.platforms.find((platform: { platform: string }) => platform.platform === "tmall")
|
||||
).toMatchObject({
|
||||
platform: "tmall",
|
||||
ready: true,
|
||||
status: "ready"
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("exposes Tmall live preview endpoints through the injected service", async () => {
|
||||
const tmallLiveService = createTmallLiveServiceStub();
|
||||
const app = createServer({ tmallLiveService });
|
||||
await app.ready();
|
||||
|
||||
const searchResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/platforms/tmall/live-search-preview?query=iPhone%2015"
|
||||
});
|
||||
expect(searchResponse.statusCode).toBe(200);
|
||||
expect(searchResponse.json().preview).toMatchObject({
|
||||
query: "iPhone 15",
|
||||
source: "html",
|
||||
candidateCount: 1
|
||||
});
|
||||
|
||||
const detailResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/platforms/tmall/live-detail-preview?itemId=833444005595"
|
||||
});
|
||||
expect(detailResponse.statusCode).toBe(200);
|
||||
expect(detailResponse.json().preview.detail).toMatchObject({
|
||||
itemId: "833444005595",
|
||||
shopName: "Apple Flagship Store"
|
||||
});
|
||||
|
||||
const reviewsResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/platforms/tmall/live-reviews-preview?itemId=833444005595&commentCount=20&page=1&maxPages=2"
|
||||
});
|
||||
expect(reviewsResponse.statusCode).toBe(200);
|
||||
expect(reviewsResponse.json().preview.pagination).toMatchObject({
|
||||
requestedCommentCount: 20,
|
||||
maxPages: 2
|
||||
});
|
||||
|
||||
const productResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/platforms/tmall/live-product-preview?itemId=833444005595&commentCount=20&page=1&maxPages=2"
|
||||
});
|
||||
expect(productResponse.statusCode).toBe(200);
|
||||
expect(productResponse.json().preview).toMatchObject({
|
||||
itemId: "833444005595",
|
||||
pagination: {
|
||||
requestedCommentCount: 20,
|
||||
maxPages: 2
|
||||
}
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("closes the tmall minimal loop with live detail/reviews replay and report publishing", async () => {
|
||||
const tmallLiveService = createTmallLiveServiceStub();
|
||||
const app = createServer({ tmallLiveService });
|
||||
await app.ready();
|
||||
|
||||
const importResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/platforms/tmall/live-session",
|
||||
payload: {
|
||||
cookieHeader: "_tb_token_=masked;",
|
||||
detailTemplateUrl:
|
||||
"https://h5api.m.taobao.com/h5/mtop.taobao.pcdetail.data.get/1.0/?data=%7B%22id%22%3A%22833444005595%22%7D",
|
||||
reviewsTemplateUrl:
|
||||
"https://h5api.m.taobao.com/h5/mtop.alibaba.review.list.for.new.pc.detail/1.0/?data=%7B%22itemId%22%3A%22833444005595%22%7D"
|
||||
}
|
||||
});
|
||||
expect(importResponse.statusCode).toBe(200);
|
||||
|
||||
const createdTask = await createTask(app, "iPhone 15");
|
||||
|
||||
const candidatesResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/tasks/${createdTask.taskId}/candidates`
|
||||
});
|
||||
const firstTmallCandidate = candidatesResponse.json().candidates.tmall[0];
|
||||
expect(firstTmallCandidate).toMatchObject({
|
||||
productUrl: "https://detail.tmall.com/item.htm?id=833444005595"
|
||||
});
|
||||
|
||||
const confirmResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/tasks/${createdTask.taskId}/confirm`,
|
||||
payload: {
|
||||
selections: [
|
||||
{
|
||||
platform: "tmall",
|
||||
candidateIds: [firstTmallCandidate.candidateId]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
expect(confirmResponse.statusCode).toBe(200);
|
||||
expect(confirmResponse.json().task).toMatchObject({
|
||||
taskStatus: "Completed",
|
||||
defaultReportVersion: 1
|
||||
});
|
||||
|
||||
const reportResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/tasks/${createdTask.taskId}/report`
|
||||
});
|
||||
|
||||
expect(reportResponse.statusCode).toBe(200);
|
||||
expect(reportResponse.json().report.summary.headline).toContain("天猫");
|
||||
expect(reportResponse.json().report.product_snapshot.review_sample_count).toBe(1);
|
||||
expect(reportResponse.json().report.evidence_index).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
platform: "tmall",
|
||||
source_type: "product",
|
||||
source_url: "https://detail.tmall.com/item.htm?id=833444005595"
|
||||
}),
|
||||
expect.objectContaining({
|
||||
platform: "tmall",
|
||||
source_type: "review",
|
||||
review_ref: "review-1"
|
||||
})
|
||||
])
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("surfaces Tmall live preview failures with service-provided status codes", async () => {
|
||||
const tmallLiveService = createTmallLiveServiceStub({
|
||||
async previewDetail() {
|
||||
const error = new Error("Tmall detail session appears invalid.") as Error & {
|
||||
statusCode: number;
|
||||
};
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
const app = createServer({ tmallLiveService });
|
||||
await app.ready();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/platforms/tmall/live-detail-preview?itemId=833444005595"
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json()).toMatchObject({
|
||||
message: "Tmall detail session appears invalid."
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@ -1,310 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createServer } from "./server";
|
||||
import type {
|
||||
TmallLiveService,
|
||||
TmallSessionManager,
|
||||
TmallSessionManagerState
|
||||
} from "./platforms/tmall/types";
|
||||
|
||||
function createSessionSummary(configured: boolean) {
|
||||
return configured
|
||||
? {
|
||||
configured: true,
|
||||
importedAt: "2026-04-03T08:30:00.000Z",
|
||||
hasCookie: true,
|
||||
userAgent: "stub-user-agent",
|
||||
detailTemplate: {
|
||||
available: true,
|
||||
api: "mtop.taobao.pcdetail.data.get",
|
||||
itemId: "934454505228"
|
||||
},
|
||||
reviewsTemplate: {
|
||||
available: true,
|
||||
api: "mtop.taobao.rate.detaillist.get",
|
||||
itemId: "934454505228"
|
||||
}
|
||||
}
|
||||
: {
|
||||
configured: false,
|
||||
hasCookie: false,
|
||||
detailTemplate: {
|
||||
available: false
|
||||
},
|
||||
reviewsTemplate: {
|
||||
available: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createManagerState(
|
||||
overrides: Partial<TmallSessionManagerState> = {}
|
||||
): TmallSessionManagerState {
|
||||
return {
|
||||
status: "idle",
|
||||
enabled: true,
|
||||
heartbeatItemId: "934454505228",
|
||||
checkIntervalMs: 600000,
|
||||
pendingManualAction: false,
|
||||
note: "天猫会话等待运维注入。",
|
||||
publicNote: "天猫会话由运维后台维护,当前尚未就绪。",
|
||||
session: createSessionSummary(false),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe("Tmall ops session manager routes", () => {
|
||||
const apps: Array<Awaited<ReturnType<typeof createServer>>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (apps.length > 0) {
|
||||
await apps.pop()!.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes ops endpoints and keeps readiness/live-session state in sync", async () => {
|
||||
let state = createManagerState();
|
||||
const manager: TmallSessionManager = {
|
||||
getState() {
|
||||
return state;
|
||||
},
|
||||
configure(input) {
|
||||
state = createManagerState({
|
||||
...state,
|
||||
enabled: input.enabled ?? state.enabled,
|
||||
heartbeatItemId: input.heartbeatItemId ?? state.heartbeatItemId,
|
||||
checkIntervalMs: input.checkIntervalMs ?? state.checkIntervalMs,
|
||||
note: "天猫运维配置已更新。"
|
||||
});
|
||||
return state;
|
||||
},
|
||||
clearConfig() {
|
||||
state = createManagerState({
|
||||
...state,
|
||||
enabled: false,
|
||||
note: "天猫运维配置已清空。"
|
||||
});
|
||||
return state;
|
||||
},
|
||||
async importManualSession() {
|
||||
state = createManagerState({
|
||||
...state,
|
||||
status: "healthy",
|
||||
note: "天猫会话已通过 ops-manual 更新。",
|
||||
publicNote: "天猫会话由运维后台维护,当前可用。",
|
||||
session: createSessionSummary(true)
|
||||
});
|
||||
return state;
|
||||
},
|
||||
clearManagedSession() {
|
||||
state = createManagerState({
|
||||
...state,
|
||||
status: "idle",
|
||||
note: "天猫会话已清理。",
|
||||
publicNote: "天猫会话由运维后台维护,当前尚未就绪。",
|
||||
session: createSessionSummary(false)
|
||||
});
|
||||
return state;
|
||||
},
|
||||
async runHealthCheck() {
|
||||
state = createManagerState({
|
||||
...state,
|
||||
status: "healthy",
|
||||
note: "天猫会话健康检查通过。",
|
||||
publicNote: "天猫会话由运维后台维护,当前可用。",
|
||||
session: createSessionSummary(true)
|
||||
});
|
||||
return {
|
||||
state,
|
||||
recovered: false
|
||||
};
|
||||
},
|
||||
async handleLiveFailure() {
|
||||
return false;
|
||||
},
|
||||
shutdown() {}
|
||||
};
|
||||
|
||||
const tmallLiveService: TmallLiveService = {
|
||||
getSessionSummary() {
|
||||
return state.session;
|
||||
},
|
||||
importSession() {
|
||||
return state.session;
|
||||
},
|
||||
clearSession() {},
|
||||
async previewSearch(query) {
|
||||
return {
|
||||
query,
|
||||
source: "html",
|
||||
candidateCount: 1,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: "tmall-934454505228",
|
||||
platform: "tmall",
|
||||
title: "Apple iPhone 15",
|
||||
price: 4399,
|
||||
priceLabel: "CNY 4399",
|
||||
storeName: "Apple 官方旗舰店",
|
||||
productUrl: "https://detail.tmall.com/item.htm?id=934454505228",
|
||||
imageUrl: "https://img.alicdn.com/example.jpg",
|
||||
salesHint: "已售 70万+",
|
||||
specLabel: "128GB",
|
||||
highlights: ["天猫"]
|
||||
}
|
||||
]
|
||||
};
|
||||
},
|
||||
async previewDetail(itemId) {
|
||||
return {
|
||||
itemId,
|
||||
source: "html",
|
||||
detail: {
|
||||
itemId,
|
||||
title: "Apple iPhone 15",
|
||||
subtitle: null,
|
||||
price: "4399.00",
|
||||
originalPrice: "4999.00",
|
||||
shopName: "Apple 官方旗舰店",
|
||||
shopUrl: null,
|
||||
sellerType: "tmall",
|
||||
categoryPath: [],
|
||||
mainImage: null,
|
||||
salesDesc: "已售 70万+",
|
||||
commentCount: "20万+"
|
||||
}
|
||||
};
|
||||
},
|
||||
async previewReviews(itemId) {
|
||||
return {
|
||||
itemId,
|
||||
source: "api",
|
||||
pagination: {
|
||||
requestedPage: 1,
|
||||
requestedCommentCount: 1,
|
||||
maxPages: 1,
|
||||
pagesFetched: 1
|
||||
},
|
||||
reviews: {
|
||||
itemId,
|
||||
total: "20万+",
|
||||
hasNext: false,
|
||||
allCount: "20万+",
|
||||
pictureCount: "1万+",
|
||||
appendCount: "5000+",
|
||||
tags: [],
|
||||
comments: []
|
||||
}
|
||||
};
|
||||
},
|
||||
async previewProduct(itemId, options) {
|
||||
const detail = await this.previewDetail(itemId);
|
||||
const reviews = await this.previewReviews(itemId, options);
|
||||
return {
|
||||
itemId,
|
||||
source: "hybrid",
|
||||
detail: detail.detail,
|
||||
pagination: reviews.pagination,
|
||||
reviews: reviews.reviews
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const app = createServer({
|
||||
tmallLiveService,
|
||||
tmallSessionManager: manager
|
||||
});
|
||||
apps.push(app);
|
||||
await app.ready();
|
||||
|
||||
const getManagerResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/ops/tmall/session-manager"
|
||||
});
|
||||
expect(getManagerResponse.statusCode).toBe(200);
|
||||
expect(getManagerResponse.json().manager).toMatchObject({
|
||||
status: "idle",
|
||||
publicNote: "天猫会话由运维后台维护,当前尚未就绪。"
|
||||
});
|
||||
|
||||
const readinessResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/platforms/readiness"
|
||||
});
|
||||
expect(
|
||||
readinessResponse
|
||||
.json()
|
||||
.platforms.find((platform: { platform: string }) => platform.platform === "tmall")
|
||||
).toMatchObject({
|
||||
platform: "tmall",
|
||||
reason: "天猫会话由运维后台维护,当前尚未就绪。"
|
||||
});
|
||||
|
||||
const configResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/ops/tmall/session-manager/config",
|
||||
payload: {
|
||||
enabled: true,
|
||||
heartbeatItemId: "934454505228"
|
||||
}
|
||||
});
|
||||
expect(configResponse.statusCode).toBe(200);
|
||||
expect(configResponse.json().manager).toMatchObject({
|
||||
heartbeatItemId: "934454505228"
|
||||
});
|
||||
|
||||
const healthCheckResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/ops/tmall/session-manager/check"
|
||||
});
|
||||
expect(healthCheckResponse.statusCode).toBe(200);
|
||||
expect(healthCheckResponse.json()).toMatchObject({
|
||||
recovered: false,
|
||||
state: {
|
||||
status: "healthy"
|
||||
}
|
||||
});
|
||||
|
||||
const importResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/ops/tmall/session-manager/session",
|
||||
payload: {
|
||||
cookieHeader: "_m_h5_tk=masked_token_123;",
|
||||
detailTemplateUrl: "https://detail.tmall.com/item.htm?id=934454505228",
|
||||
reviewsTemplateUrl:
|
||||
"https://h5api.m.tmall.com/h5/mtop.taobao.rate.detaillist.get/6.0/?data=%7B%22auctionNumId%22%3A%22934454505228%22%7D"
|
||||
}
|
||||
});
|
||||
expect(importResponse.statusCode).toBe(200);
|
||||
expect(importResponse.json().manager).toMatchObject({
|
||||
status: "healthy",
|
||||
session: {
|
||||
configured: true
|
||||
}
|
||||
});
|
||||
|
||||
const liveSessionResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/platforms/tmall/live-session"
|
||||
});
|
||||
expect(liveSessionResponse.statusCode).toBe(200);
|
||||
expect(liveSessionResponse.json().session).toMatchObject({
|
||||
configured: true,
|
||||
detailTemplate: {
|
||||
available: true
|
||||
}
|
||||
});
|
||||
|
||||
const clearResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/ops/tmall/session-manager/session"
|
||||
});
|
||||
expect(clearResponse.statusCode).toBe(200);
|
||||
expect(clearResponse.json().manager).toMatchObject({
|
||||
status: "idle",
|
||||
session: {
|
||||
configured: false
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -5,110 +5,15 @@ import type {
|
||||
} from "@cross-ai/domain";
|
||||
import cors from "@fastify/cors";
|
||||
import Fastify from "fastify";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { rankJdCandidatesForKeyword } from "./platforms/jd/keyword-preview";
|
||||
import { JdLiveSessionService, isJdLiveError } from "./platforms/jd/live-session";
|
||||
import { JdSessionManagerService } from "./platforms/jd/session-manager";
|
||||
import type {
|
||||
JdLiveService,
|
||||
JdSearchMode,
|
||||
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 {
|
||||
TmallLiveService,
|
||||
TmallSessionManager,
|
||||
TmallSessionManagerConfigInput
|
||||
} from "./platforms/tmall/types";
|
||||
import type { JdLiveService, JdSearchMode } from "./platforms/jd/types";
|
||||
import { InMemoryTaskStore } from "./store";
|
||||
|
||||
export function createServer(
|
||||
options: {
|
||||
storagePath?: string;
|
||||
jdLiveService?: JdLiveService;
|
||||
jdSessionManager?: JdSessionManager;
|
||||
jdQrLoginService?: OpsQrLoginController;
|
||||
tmallLiveService?: TmallLiveService;
|
||||
tmallSessionManager?: TmallSessionManager;
|
||||
tmallQrLoginService?: OpsQrLoginController;
|
||||
executionMode?: "synchronous" | "background";
|
||||
} = {}
|
||||
) {
|
||||
export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
|
||||
const app = Fastify({ logger: false });
|
||||
const store = new InMemoryTaskStore();
|
||||
const jdLiveService = options.jdLiveService ?? new JdLiveSessionService();
|
||||
const tmallLiveService = options.tmallLiveService ?? new TmallLiveSessionService();
|
||||
const storagePath =
|
||||
options.storagePath ??
|
||||
process.env.TASK_STORE_PATH ??
|
||||
(process.env.NODE_ENV === "test"
|
||||
? undefined
|
||||
: resolve(process.cwd(), ".data", "task-store.json"));
|
||||
const store = new InMemoryTaskStore({
|
||||
storagePath,
|
||||
jdLiveService,
|
||||
tmallLiveService,
|
||||
executionMode: options.executionMode
|
||||
});
|
||||
const jdSessionManager =
|
||||
options.jdSessionManager ??
|
||||
new JdSessionManagerService(jdLiveService, {
|
||||
onSessionReady: () => {
|
||||
store.notifyManagedSessionReady("jd");
|
||||
},
|
||||
onSessionUnavailable: () => {
|
||||
store.clearPlatformSession("jd");
|
||||
}
|
||||
});
|
||||
const tmallSessionManager =
|
||||
options.tmallSessionManager ??
|
||||
new TmallSessionManagerService(tmallLiveService, {
|
||||
onSessionReady: () => {
|
||||
store.preparePlatform("tmall");
|
||||
},
|
||||
onSessionUnavailable: () => {
|
||||
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.notifyManagedSessionReady("jd");
|
||||
}
|
||||
if (tmallLiveService.getSessionSummary().configured && !store.getSession("tmall").ready) {
|
||||
store.preparePlatform("tmall");
|
||||
}
|
||||
|
||||
app.addHook("onClose", async () => {
|
||||
jdSessionManager.shutdown();
|
||||
tmallSessionManager.shutdown();
|
||||
await jdQrLoginService.shutdown();
|
||||
await tmallQrLoginService.shutdown();
|
||||
});
|
||||
|
||||
app.register(cors, { origin: true });
|
||||
|
||||
@ -118,19 +23,7 @@ export function createServer(
|
||||
}));
|
||||
|
||||
app.get("/api/platforms/readiness", async () => ({
|
||||
platforms: store.getPlatformReadiness().map((platform) =>
|
||||
platform.platform === "jd"
|
||||
? {
|
||||
...platform,
|
||||
reason: jdSessionManager.getState().publicNote
|
||||
}
|
||||
: platform.platform === "tmall"
|
||||
? {
|
||||
...platform,
|
||||
reason: tmallSessionManager.getState().publicNote
|
||||
}
|
||||
: platform
|
||||
)
|
||||
platforms: store.getPlatformReadiness()
|
||||
}));
|
||||
|
||||
app.get("/api/sessions", async () => ({
|
||||
@ -168,226 +61,6 @@ export function createServer(
|
||||
session: jdLiveService.getSessionSummary()
|
||||
}));
|
||||
|
||||
app.get("/api/ops/jd/session-manager", async () => ({
|
||||
manager: jdSessionManager.getState()
|
||||
}));
|
||||
|
||||
app.post<{
|
||||
Body: {
|
||||
enabled?: boolean;
|
||||
autoLoginMode?: JdSessionManagerAutoMode;
|
||||
loginCommand?: string | null;
|
||||
browserProfilePath?: string | null;
|
||||
heartbeatQuery?: string | null;
|
||||
account?: string | null;
|
||||
password?: string | null;
|
||||
checkIntervalMs?: number | null;
|
||||
runnerTimeoutMs?: number | null;
|
||||
};
|
||||
}>("/api/ops/jd/session-manager/config", async (request, reply) => {
|
||||
try {
|
||||
const manager = jdSessionManager.configure(request.body);
|
||||
reply.code(200);
|
||||
return { manager };
|
||||
} catch (error) {
|
||||
reply.code(400);
|
||||
return {
|
||||
message:
|
||||
error instanceof Error ? error.message : "Invalid JD ops session manager config."
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/ops/jd/session-manager/config", async () => ({
|
||||
manager: jdSessionManager.clearConfig()
|
||||
}));
|
||||
|
||||
app.post("/api/ops/jd/session-manager/check", async (_request, reply) => {
|
||||
try {
|
||||
const result = await jdSessionManager.runHealthCheck("ops");
|
||||
reply.code(200);
|
||||
return result;
|
||||
} catch (error) {
|
||||
reply.code(502);
|
||||
return {
|
||||
message:
|
||||
error instanceof Error ? error.message : "JD ops health check failed."
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/ops/jd/session-manager/recover", async (_request, reply) => {
|
||||
try {
|
||||
const result = await jdSessionManager.runAutoRecovery("ops");
|
||||
reply.code(200);
|
||||
return result;
|
||||
} catch (error) {
|
||||
reply.code(502);
|
||||
return {
|
||||
message:
|
||||
error instanceof Error ? error.message : "JD ops auto recovery failed."
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{
|
||||
Body: {
|
||||
cookieHeader: string;
|
||||
userAgent?: string;
|
||||
searchApiTemplateUrl?: string;
|
||||
detailTemplateUrl?: string;
|
||||
reviewsTemplateUrl?: string;
|
||||
searchReferer?: string;
|
||||
detailReferer?: string;
|
||||
};
|
||||
}>("/api/ops/jd/session-manager/session", async (request, reply) => {
|
||||
try {
|
||||
await jdSessionManager.importManualSession(request.body, "ops-manual");
|
||||
const result = await jdSessionManager.runHealthCheck("ops-manual");
|
||||
reply.code(200);
|
||||
return {
|
||||
manager: result.state,
|
||||
recovered: result.recovered
|
||||
};
|
||||
} catch (error) {
|
||||
reply.code(isJdLiveError(error) ? error.statusCode : 400);
|
||||
return {
|
||||
message: error instanceof Error ? error.message : "Invalid JD ops live session payload."
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/ops/jd/session-manager/session", async () => ({
|
||||
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()
|
||||
}));
|
||||
|
||||
app.post<{
|
||||
Body: TmallSessionManagerConfigInput;
|
||||
}>("/api/ops/tmall/session-manager/config", async (request, reply) => {
|
||||
try {
|
||||
const manager = tmallSessionManager.configure(request.body);
|
||||
reply.code(200);
|
||||
return { manager };
|
||||
} catch (error) {
|
||||
reply.code(400);
|
||||
return {
|
||||
message:
|
||||
error instanceof Error ? error.message : "Invalid Tmall ops session manager config."
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/ops/tmall/session-manager/config", async () => ({
|
||||
manager: tmallSessionManager.clearConfig()
|
||||
}));
|
||||
|
||||
app.post("/api/ops/tmall/session-manager/check", async (_request, reply) => {
|
||||
try {
|
||||
const result = await tmallSessionManager.runHealthCheck("ops");
|
||||
reply.code(200);
|
||||
return result;
|
||||
} catch (error) {
|
||||
reply.code(502);
|
||||
return {
|
||||
message:
|
||||
error instanceof Error ? error.message : "Tmall ops health check failed."
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{
|
||||
Body: {
|
||||
cookieHeader: string;
|
||||
userAgent?: string;
|
||||
detailTemplateUrl?: string;
|
||||
reviewsTemplateUrl?: string;
|
||||
detailReferer?: string;
|
||||
};
|
||||
}>("/api/ops/tmall/session-manager/session", async (request, reply) => {
|
||||
try {
|
||||
const manager = await tmallSessionManager.importManualSession(request.body, "ops-manual");
|
||||
reply.code(200);
|
||||
return { manager };
|
||||
} catch (error) {
|
||||
reply.code(isTmallLiveError(error) ? error.statusCode : 400);
|
||||
return {
|
||||
message:
|
||||
error instanceof Error ? error.message : "Invalid Tmall ops live session payload."
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/ops/tmall/session-manager/session", async () => ({
|
||||
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()
|
||||
}));
|
||||
|
||||
app.post<{
|
||||
Body: {
|
||||
cookieHeader: string;
|
||||
@ -400,10 +73,10 @@ 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");
|
||||
const session = jdLiveService.importSession(request.body);
|
||||
store.preparePlatform("jd");
|
||||
reply.code(200);
|
||||
return { session: jdLiveService.getSessionSummary() };
|
||||
return { session };
|
||||
} catch (error) {
|
||||
reply.code(isJdLiveError(error) ? error.statusCode : 400);
|
||||
return {
|
||||
@ -412,36 +85,9 @@ export function createServer(
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{
|
||||
Body: {
|
||||
cookieHeader: string;
|
||||
userAgent?: string;
|
||||
detailTemplateUrl?: string;
|
||||
reviewsTemplateUrl?: string;
|
||||
detailReferer?: string;
|
||||
};
|
||||
}>("/api/platforms/tmall/live-session", async (request, reply) => {
|
||||
try {
|
||||
await tmallSessionManager.importManualSession(request.body, "legacy-live-session");
|
||||
const session = tmallLiveService.getSessionSummary();
|
||||
reply.code(200);
|
||||
return { session };
|
||||
} catch (error) {
|
||||
reply.code(isTmallLiveError(error) ? error.statusCode : 400);
|
||||
return {
|
||||
message: error instanceof Error ? error.message : "Invalid Tmall live session payload."
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/platforms/jd/live-session", async (_request, reply) => {
|
||||
jdSessionManager.clearManagedSession("legacy-live-session-clear");
|
||||
reply.code(204);
|
||||
return null;
|
||||
});
|
||||
|
||||
app.delete("/api/platforms/tmall/live-session", async (_request, reply) => {
|
||||
tmallSessionManager.clearManagedSession("legacy-live-session-clear");
|
||||
jdLiveService.clearSession();
|
||||
store.clearPlatformSession("jd");
|
||||
reply.code(204);
|
||||
return null;
|
||||
});
|
||||
@ -454,9 +100,6 @@ export function createServer(
|
||||
if (request.params.platform === "jd") {
|
||||
jdLiveService.clearSession();
|
||||
}
|
||||
if (request.params.platform === "tmall") {
|
||||
tmallLiveService.clearSession();
|
||||
}
|
||||
reply.code(204);
|
||||
return null;
|
||||
} catch {
|
||||
@ -468,7 +111,7 @@ export function createServer(
|
||||
app.post<{
|
||||
Body: CreateTaskInput;
|
||||
}>("/api/tasks", async (request, reply) => {
|
||||
const task = await store.createTask(request.body);
|
||||
const task = store.createTask(request.body);
|
||||
reply.code(201);
|
||||
return { task };
|
||||
});
|
||||
@ -537,7 +180,7 @@ export function createServer(
|
||||
Body: ConfirmTaskPayload;
|
||||
}>("/api/tasks/:taskId/confirm", async (request, reply) => {
|
||||
try {
|
||||
const task = await store.confirmTask(request.params.taskId, request.body);
|
||||
const task = store.confirmTask(request.params.taskId, request.body);
|
||||
return { task };
|
||||
} catch {
|
||||
reply.code(404);
|
||||
@ -549,7 +192,7 @@ export function createServer(
|
||||
Params: { taskId: string; platform: PlatformId };
|
||||
}>("/api/tasks/:taskId/platforms/:platform/retry", async (request, reply) => {
|
||||
try {
|
||||
const task = await store.retryPlatform(request.params.taskId, request.params.platform);
|
||||
const task = store.retryPlatform(request.params.taskId, request.params.platform);
|
||||
return { task };
|
||||
} catch {
|
||||
reply.code(404);
|
||||
@ -613,71 +256,7 @@ export function createServer(
|
||||
});
|
||||
|
||||
app.get<{
|
||||
Querystring: {
|
||||
query?: string;
|
||||
commentCount?: string;
|
||||
maxPages?: string;
|
||||
mode?: JdSearchMode;
|
||||
};
|
||||
}>("/api/platforms/jd/live-keyword-preview", async (request, reply) => {
|
||||
try {
|
||||
const query = request.query.query?.trim();
|
||||
if (!query) {
|
||||
reply.code(400);
|
||||
return { message: "query is required." };
|
||||
}
|
||||
|
||||
const commentCount = request.query.commentCount
|
||||
? Number.parseInt(request.query.commentCount, 10)
|
||||
: undefined;
|
||||
const maxPages = request.query.maxPages
|
||||
? Number.parseInt(request.query.maxPages, 10)
|
||||
: undefined;
|
||||
const searchPreview = await jdLiveService.previewSearch(query, request.query.mode);
|
||||
const rankedCandidates = rankJdCandidatesForKeyword(query, searchPreview.candidates);
|
||||
const selectedCandidate = rankedCandidates[0];
|
||||
|
||||
if (!selectedCandidate) {
|
||||
reply.code(404);
|
||||
return {
|
||||
message:
|
||||
"JD live search returned no valid item candidates for this keyword. Capture a fresher search session or refine the query."
|
||||
};
|
||||
}
|
||||
|
||||
const preview = await jdLiveService.previewProduct(selectedCandidate.skuId, {
|
||||
commentCount,
|
||||
maxPages: Number.isNaN(maxPages ?? Number.NaN) ? undefined : maxPages
|
||||
});
|
||||
|
||||
return {
|
||||
preview: {
|
||||
query,
|
||||
search: {
|
||||
source: searchPreview.source,
|
||||
candidateCount: searchPreview.candidateCount,
|
||||
selected: selectedCandidate,
|
||||
alternatives: rankedCandidates.slice(1, 4)
|
||||
},
|
||||
product: preview
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
reply.code(isJdLiveError(error) ? error.statusCode : 502);
|
||||
return {
|
||||
message:
|
||||
error instanceof Error ? error.message : "JD live keyword preview failed."
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{
|
||||
Querystring: {
|
||||
skuId?: string;
|
||||
commentCount?: string;
|
||||
page?: string;
|
||||
maxPages?: string;
|
||||
};
|
||||
Querystring: { skuId?: string; commentCount?: string };
|
||||
}>("/api/platforms/jd/live-reviews-preview", async (request, reply) => {
|
||||
try {
|
||||
const skuId = request.query.skuId?.trim();
|
||||
@ -689,15 +268,7 @@ export function createServer(
|
||||
const commentCount = request.query.commentCount
|
||||
? Number.parseInt(request.query.commentCount, 10)
|
||||
: undefined;
|
||||
const page = request.query.page ? Number.parseInt(request.query.page, 10) : undefined;
|
||||
const maxPages = request.query.maxPages
|
||||
? Number.parseInt(request.query.maxPages, 10)
|
||||
: undefined;
|
||||
const preview = await jdLiveService.previewReviews(skuId, {
|
||||
commentCount,
|
||||
page: Number.isNaN(page ?? Number.NaN) ? undefined : page,
|
||||
maxPages: Number.isNaN(maxPages ?? Number.NaN) ? undefined : maxPages
|
||||
});
|
||||
const preview = await jdLiveService.previewReviews(skuId, commentCount);
|
||||
return { preview };
|
||||
} catch (error) {
|
||||
reply.code(isJdLiveError(error) ? error.statusCode : 502);
|
||||
@ -708,182 +279,6 @@ export function createServer(
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{
|
||||
Querystring: {
|
||||
skuId?: string;
|
||||
commentCount?: string;
|
||||
page?: string;
|
||||
maxPages?: string;
|
||||
};
|
||||
}>("/api/platforms/jd/live-product-preview", async (request, reply) => {
|
||||
try {
|
||||
const skuId = request.query.skuId?.trim();
|
||||
if (!skuId) {
|
||||
reply.code(400);
|
||||
return { message: "skuId is required." };
|
||||
}
|
||||
|
||||
const commentCount = request.query.commentCount
|
||||
? Number.parseInt(request.query.commentCount, 10)
|
||||
: undefined;
|
||||
const page = request.query.page ? Number.parseInt(request.query.page, 10) : undefined;
|
||||
const maxPages = request.query.maxPages
|
||||
? Number.parseInt(request.query.maxPages, 10)
|
||||
: undefined;
|
||||
|
||||
const preview = await jdLiveService.previewProduct(skuId, {
|
||||
commentCount,
|
||||
page: Number.isNaN(page ?? Number.NaN) ? undefined : page,
|
||||
maxPages: Number.isNaN(maxPages ?? Number.NaN) ? undefined : maxPages
|
||||
});
|
||||
return { preview };
|
||||
} catch (error) {
|
||||
reply.code(isJdLiveError(error) ? error.statusCode : 502);
|
||||
return {
|
||||
message:
|
||||
error instanceof Error ? error.message : "JD live product preview failed."
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{
|
||||
Querystring: { itemId?: string };
|
||||
}>("/api/platforms/tmall/live-detail-preview", async (request, reply) => {
|
||||
try {
|
||||
const itemId = request.query.itemId?.trim();
|
||||
if (!itemId) {
|
||||
reply.code(400);
|
||||
return { message: "itemId is required." };
|
||||
}
|
||||
|
||||
const preview = await tmallLiveService.previewDetail(itemId);
|
||||
return { preview };
|
||||
} catch (error) {
|
||||
reply.code(isTmallLiveError(error) ? error.statusCode : 502);
|
||||
return {
|
||||
message:
|
||||
error instanceof Error ? error.message : "Tmall live detail preview failed."
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{
|
||||
Querystring: { query?: string };
|
||||
}>("/api/platforms/tmall/live-search-preview", async (request, reply) => {
|
||||
try {
|
||||
const query = request.query.query?.trim();
|
||||
if (!query) {
|
||||
reply.code(400);
|
||||
return { message: "query is required." };
|
||||
}
|
||||
|
||||
const preview = await tmallLiveService.previewSearch(query);
|
||||
return { preview };
|
||||
} catch (error) {
|
||||
reply.code(isTmallLiveError(error) ? error.statusCode : 502);
|
||||
return {
|
||||
message:
|
||||
error instanceof Error ? error.message : "Tmall live search preview failed."
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{
|
||||
Querystring: {
|
||||
itemId?: string;
|
||||
commentCount?: string;
|
||||
page?: string;
|
||||
maxPages?: string;
|
||||
};
|
||||
}>("/api/platforms/tmall/live-reviews-preview", async (request, reply) => {
|
||||
try {
|
||||
const itemId = request.query.itemId?.trim();
|
||||
if (!itemId) {
|
||||
reply.code(400);
|
||||
return { message: "itemId is required." };
|
||||
}
|
||||
|
||||
const commentCount = request.query.commentCount
|
||||
? Number.parseInt(request.query.commentCount, 10)
|
||||
: undefined;
|
||||
const page = request.query.page ? Number.parseInt(request.query.page, 10) : undefined;
|
||||
const maxPages = request.query.maxPages
|
||||
? Number.parseInt(request.query.maxPages, 10)
|
||||
: undefined;
|
||||
|
||||
const preview = await tmallLiveService.previewReviews(itemId, {
|
||||
commentCount,
|
||||
page: Number.isNaN(page ?? Number.NaN) ? undefined : page,
|
||||
maxPages: Number.isNaN(maxPages ?? Number.NaN) ? undefined : maxPages
|
||||
});
|
||||
return { preview };
|
||||
} catch (error) {
|
||||
reply.code(isTmallLiveError(error) ? error.statusCode : 502);
|
||||
return {
|
||||
message:
|
||||
error instanceof Error ? error.message : "Tmall live reviews preview failed."
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{
|
||||
Querystring: {
|
||||
itemId?: string;
|
||||
commentCount?: string;
|
||||
page?: string;
|
||||
maxPages?: string;
|
||||
};
|
||||
}>("/api/platforms/tmall/live-product-preview", async (request, reply) => {
|
||||
try {
|
||||
const itemId = request.query.itemId?.trim();
|
||||
if (!itemId) {
|
||||
reply.code(400);
|
||||
return { message: "itemId is required." };
|
||||
}
|
||||
|
||||
const commentCount = request.query.commentCount
|
||||
? Number.parseInt(request.query.commentCount, 10)
|
||||
: undefined;
|
||||
const page = request.query.page ? Number.parseInt(request.query.page, 10) : undefined;
|
||||
const maxPages = request.query.maxPages
|
||||
? Number.parseInt(request.query.maxPages, 10)
|
||||
: undefined;
|
||||
|
||||
const preview = await tmallLiveService.previewProduct(itemId, {
|
||||
commentCount,
|
||||
page: Number.isNaN(page ?? Number.NaN) ? undefined : page,
|
||||
maxPages: Number.isNaN(maxPages ?? Number.NaN) ? undefined : maxPages
|
||||
});
|
||||
return { preview };
|
||||
} catch (error) {
|
||||
reply.code(isTmallLiveError(error) ? error.statusCode : 502);
|
||||
return {
|
||||
message:
|
||||
error instanceof Error ? error.message : "Tmall live product preview failed."
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{
|
||||
Body?: {
|
||||
dryRun?: boolean;
|
||||
asOf?: string;
|
||||
rawRetentionDays?: number;
|
||||
reportRetentionDays?: number;
|
||||
};
|
||||
}>("/api/retention/cleanup", async (request, reply) => {
|
||||
try {
|
||||
const cleanup = store.runRetentionCleanup(request.body ?? {});
|
||||
return { cleanup };
|
||||
} catch (error) {
|
||||
reply.code(400);
|
||||
return {
|
||||
message:
|
||||
error instanceof Error ? error.message : "Invalid retention cleanup request."
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/history", async () => ({
|
||||
tasks: store.listHistory()
|
||||
}));
|
||||
@ -901,28 +296,15 @@ 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",
|
||||
"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();
|
||||
Connection: "keep-alive"
|
||||
});
|
||||
reply.raw.write(`event: task.snapshot\n`);
|
||||
reply.raw.write(`data: ${JSON.stringify(task)}\n\n`);
|
||||
reply.raw.end();
|
||||
return reply;
|
||||
});
|
||||
|
||||
return app;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,6 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>跨平台商品聚合与 AI 分析</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="16" fill="#146c6e" />
|
||||
<path
|
||||
d="M18 20h28v6H18zm0 18h18v6H18zm0-9h28v6H18zm24 9h4v6h-4z"
|
||||
fill="#fbf8f2"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 221 B |
1260
apps/web/src/App.tsx
1260
apps/web/src/App.tsx
File diff suppressed because it is too large
Load Diff
@ -6,28 +6,18 @@ import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./lib/api", () => ({
|
||||
clearJdManagedSession: vi.fn(),
|
||||
clearJdSessionManagerConfig: vi.fn(),
|
||||
clearPlatformSession: vi.fn(),
|
||||
confirmTask: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
getJdKeywordPreview: vi.fn(),
|
||||
getJdLiveSession: vi.fn(),
|
||||
getJdSessionManager: vi.fn(),
|
||||
getHistoryTasks: vi.fn(),
|
||||
getPlatformReadiness: vi.fn(),
|
||||
getPlatformSession: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
getTaskCandidates: vi.fn(),
|
||||
getTaskReport: vi.fn(),
|
||||
importJdManagedSession: vi.fn(),
|
||||
preparePlatform: vi.fn(),
|
||||
runJdSessionManagerHealthCheck: vi.fn(),
|
||||
runJdSessionManagerRecovery: vi.fn(),
|
||||
retryTaskPlatform: vi.fn()
|
||||
,
|
||||
updateJdSessionManagerConfig: vi.fn()
|
||||
}));
|
||||
|
||||
import { HistoryPage } from "./App";
|
||||
|
||||
@ -1,82 +1,32 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { 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 { MemoryRouter, Route, Routes } 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(),
|
||||
clearTmallManagedSession: vi.fn(),
|
||||
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(),
|
||||
getPlatformSession: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
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()
|
||||
retryTaskPlatform: vi.fn()
|
||||
}));
|
||||
|
||||
import { App, NewTaskPage } from "./App";
|
||||
import { NewTaskPage, SessionPreparePage } 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
|
||||
preparePlatform
|
||||
} from "./lib/api";
|
||||
|
||||
function renderWithProviders(node: ReactNode, initialEntries?: string[]) {
|
||||
@ -101,69 +51,6 @@ function renderWithProviders(node: ReactNode, initialEntries?: string[]) {
|
||||
|
||||
describe("task composer and session console", () => {
|
||||
beforeEach(() => {
|
||||
const jdManagerState = {
|
||||
status: "healthy",
|
||||
enabled: true,
|
||||
autoLoginMode: "command",
|
||||
commandConfigured: true,
|
||||
accountConfigured: true,
|
||||
passwordConfigured: false,
|
||||
accountLabel: "jd***86",
|
||||
browserProfilePath: "D:\\ops\\jd-profile",
|
||||
heartbeatQuery: "iPhone 15",
|
||||
checkIntervalMs: 600000,
|
||||
runnerTimeoutMs: 300000,
|
||||
pendingManualAction: false,
|
||||
note: "京东会话当前可用。",
|
||||
publicNote: "京东会话由运维后台维护,当前可用。",
|
||||
configuredAt: "2026-04-02T08:00:00.000Z",
|
||||
lastCheckAt: "2026-04-02T10:00:00.000Z",
|
||||
lastRecoveredAt: "2026-04-02T09:00:00.000Z",
|
||||
session: {
|
||||
configured: true,
|
||||
importedAt: "2026-04-02T10:00:00.000Z",
|
||||
hasCookie: true,
|
||||
userAgent: "stub-user-agent",
|
||||
searchApiTemplate: {
|
||||
available: true
|
||||
},
|
||||
detailTemplate: {
|
||||
available: true,
|
||||
skuId: "100068388533"
|
||||
},
|
||||
reviewsTemplate: {
|
||||
available: true,
|
||||
skuId: "100068388533"
|
||||
}
|
||||
}
|
||||
};
|
||||
const tmallManagerState = {
|
||||
status: "healthy",
|
||||
enabled: true,
|
||||
heartbeatItemId: "934454505228",
|
||||
checkIntervalMs: 600000,
|
||||
pendingManualAction: false,
|
||||
note: "天猫会话当前可用。",
|
||||
publicNote: "天猫会话由运维后台维护,当前可用。",
|
||||
configuredAt: "2026-04-02T08:00:00.000Z",
|
||||
lastCheckAt: "2026-04-02T10:00:00.000Z",
|
||||
lastHealthyAt: "2026-04-02T10:00:00.000Z",
|
||||
session: {
|
||||
configured: true,
|
||||
importedAt: "2026-04-02T10:00:00.000Z",
|
||||
hasCookie: true,
|
||||
userAgent: "stub-user-agent",
|
||||
detailTemplate: {
|
||||
available: true,
|
||||
itemId: "934454505228"
|
||||
},
|
||||
reviewsTemplate: {
|
||||
available: true,
|
||||
itemId: "934454505228"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
vi.mocked(getPlatformReadiness).mockResolvedValue({
|
||||
platforms: [
|
||||
{
|
||||
@ -209,10 +96,10 @@ describe("task composer and session console", () => {
|
||||
} as any);
|
||||
vi.mocked(getPlatformSession).mockResolvedValue({
|
||||
session: {
|
||||
platform: "tmall",
|
||||
platform: "jd",
|
||||
ready: true,
|
||||
status: "ready",
|
||||
searchRequirement: "recommended",
|
||||
searchRequirement: "required",
|
||||
scope: "workspace",
|
||||
ttlHours: 24,
|
||||
lastPreparedAt: "2026-04-02T10:00:00.000Z",
|
||||
@ -221,270 +108,7 @@ describe("task composer and session console", () => {
|
||||
cipherLabel: "mock-aes-gcm-v1"
|
||||
}
|
||||
} as any);
|
||||
vi.mocked(getJdLiveSession).mockResolvedValue({
|
||||
session: {
|
||||
configured: true,
|
||||
importedAt: "2026-04-02T10:00:00.000Z",
|
||||
hasCookie: true,
|
||||
userAgent: "stub-user-agent",
|
||||
searchApiTemplate: {
|
||||
available: false
|
||||
},
|
||||
detailTemplate: {
|
||||
available: true,
|
||||
skuId: "100068388533"
|
||||
},
|
||||
reviewsTemplate: {
|
||||
available: true,
|
||||
skuId: "100068388533"
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
vi.mocked(getJdQrLoginState).mockResolvedValue({
|
||||
qrLogin: {
|
||||
platform: "jd",
|
||||
status: "idle",
|
||||
note: "尚未启动扫码登录。",
|
||||
sessionImported: false
|
||||
}
|
||||
} as any);
|
||||
vi.mocked(getJdKeywordPreview).mockResolvedValue({
|
||||
preview: {
|
||||
query: "小米手环10",
|
||||
search: {
|
||||
source: "html",
|
||||
candidateCount: 2,
|
||||
selected: {
|
||||
skuId: "222222222222",
|
||||
score: 168,
|
||||
summary: "标题完整命中关键词;位于搜索结果前列;已解析出可回放 SKU",
|
||||
matchedTokens: ["小米手环10"],
|
||||
candidate: {
|
||||
candidateId: "jd-222222222222",
|
||||
platform: "jd",
|
||||
title: "小米手环10 标准版 智能手环",
|
||||
price: 269,
|
||||
priceLabel: "¥269",
|
||||
storeName: "小米京东自营旗舰店",
|
||||
productUrl: "https://item.jd.com/222222222222.html",
|
||||
imageUrl: "https://img14.360buyimg.com/n2/jfs/t1/example-10.jpg",
|
||||
salesHint: "已售50万+",
|
||||
specLabel: "标准版",
|
||||
highlights: ["14天续航"]
|
||||
}
|
||||
},
|
||||
alternatives: [
|
||||
{
|
||||
skuId: "111111111111",
|
||||
score: 118,
|
||||
summary: "标题/卖点命中 1 个关键词片段;已解析出可回放 SKU",
|
||||
matchedTokens: ["小米"],
|
||||
candidate: {
|
||||
candidateId: "jd-111111111111",
|
||||
platform: "jd",
|
||||
title: "小米手环9 NFC版",
|
||||
price: 249,
|
||||
priceLabel: "¥249",
|
||||
storeName: "小米京东自营旗舰店",
|
||||
productUrl: "https://item.jd.com/111111111111.html",
|
||||
imageUrl: "https://img14.360buyimg.com/n2/jfs/t1/example-9.jpg",
|
||||
salesHint: "已售20万+",
|
||||
specLabel: "NFC版",
|
||||
highlights: ["健康监测"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
product: {
|
||||
skuId: "222222222222",
|
||||
source: "api",
|
||||
detail: {
|
||||
skuId: "222222222222",
|
||||
title: "小米手环10 标准版 智能手环",
|
||||
price: "269.00",
|
||||
originalPrice: "299.00",
|
||||
estimatedPrice: "269.00",
|
||||
shopName: "小米京东自营旗舰店",
|
||||
vendorId: null,
|
||||
categoryPath: ["智能设备", "智能手环"],
|
||||
stockState: "现货",
|
||||
mainImage: "https://img14.360buyimg.com/n2/jfs/t1/example-10.jpg",
|
||||
averageScore: "4.9"
|
||||
},
|
||||
pagination: {
|
||||
requestedPage: 1,
|
||||
requestedCommentCount: 12,
|
||||
maxPages: 2,
|
||||
pagesFetched: 2,
|
||||
pageKey: "page"
|
||||
},
|
||||
reviews: {
|
||||
skuId: "222222222222",
|
||||
total: "10000+",
|
||||
goodRate: "97%",
|
||||
pictureCount: "800",
|
||||
tags: [
|
||||
{
|
||||
tagId: "tag-1",
|
||||
name: "续航很久",
|
||||
count: "5300"
|
||||
}
|
||||
],
|
||||
comments: [
|
||||
{
|
||||
id: "comment-1",
|
||||
content: "表带舒适,睡眠监测比较准。",
|
||||
score: "5",
|
||||
creationTime: "2026-04-03 09:00:00",
|
||||
userLevelName: "PLUS会员"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
vi.mocked(getJdSessionManager).mockResolvedValue({
|
||||
manager: jdManagerState
|
||||
} as any);
|
||||
vi.mocked(getTmallSessionManager).mockResolvedValue({
|
||||
manager: tmallManagerState
|
||||
} as any);
|
||||
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: {
|
||||
...jdManagerState,
|
||||
note: "京东会话已更新。",
|
||||
lastRecoveredAt: "2026-04-02T10:05:00.000Z",
|
||||
session: {
|
||||
...jdManagerState.session,
|
||||
importedAt: "2026-04-02T10:05:00.000Z",
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
vi.mocked(importTmallManagedSession).mockResolvedValue({
|
||||
manager: {
|
||||
...tmallManagerState,
|
||||
note: "天猫会话已更新。",
|
||||
lastHealthyAt: "2026-04-02T10:05:00.000Z",
|
||||
session: {
|
||||
...tmallManagerState.session,
|
||||
importedAt: "2026-04-02T10:05:00.000Z"
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
vi.mocked(clearJdManagedSession).mockResolvedValue({
|
||||
manager: {
|
||||
status: "idle",
|
||||
enabled: true,
|
||||
autoLoginMode: "command",
|
||||
commandConfigured: true,
|
||||
accountConfigured: true,
|
||||
passwordConfigured: false,
|
||||
accountLabel: "jd***86",
|
||||
heartbeatQuery: "iPhone 15",
|
||||
checkIntervalMs: 600000,
|
||||
runnerTimeoutMs: 300000,
|
||||
pendingManualAction: false,
|
||||
note: "京东会话已清理。",
|
||||
publicNote: "京东会话由运维后台维护,当前尚未就绪。",
|
||||
session: {
|
||||
configured: false,
|
||||
hasCookie: false,
|
||||
searchApiTemplate: {
|
||||
available: false
|
||||
},
|
||||
detailTemplate: {
|
||||
available: false
|
||||
},
|
||||
reviewsTemplate: {
|
||||
available: false
|
||||
}
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
vi.mocked(clearTmallManagedSession).mockResolvedValue({
|
||||
manager: {
|
||||
status: "idle",
|
||||
enabled: true,
|
||||
heartbeatItemId: "934454505228",
|
||||
checkIntervalMs: 600000,
|
||||
pendingManualAction: false,
|
||||
note: "天猫会话已清理。",
|
||||
publicNote: "天猫会话由运维后台维护,当前尚未就绪。",
|
||||
session: {
|
||||
configured: false,
|
||||
hasCookie: false,
|
||||
detailTemplate: {
|
||||
available: false
|
||||
},
|
||||
reviewsTemplate: {
|
||||
available: false
|
||||
}
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
vi.mocked(updateJdSessionManagerConfig).mockResolvedValue({
|
||||
manager: jdManagerState
|
||||
} as any);
|
||||
vi.mocked(runJdSessionManagerHealthCheck).mockResolvedValue({
|
||||
state: jdManagerState,
|
||||
recovered: false
|
||||
} as any);
|
||||
vi.mocked(runJdSessionManagerRecovery).mockResolvedValue({
|
||||
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,
|
||||
@ -512,232 +136,23 @@ describe("task composer and session console", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("runs the jd keyword-only preview loop from the new task page", async () => {
|
||||
it("shows session details and allows clearing the current session", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(<NewTaskPage />);
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route element={<SessionPreparePage />} path="/sessions/:platform/prepare" />
|
||||
</Routes>,
|
||||
["/sessions/jd/prepare?from=/tasks/new"]
|
||||
);
|
||||
|
||||
await user.clear(await screen.findByLabelText("商品关键词 / 描述"));
|
||||
await user.type(screen.getByLabelText("商品关键词 / 描述"), "小米手环10");
|
||||
await user.click(screen.getByRole("button", { name: "只用关键词抓京东" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getJdKeywordPreview).toHaveBeenCalledWith("小米手环10", {
|
||||
commentCount: 12,
|
||||
maxPages: 2
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findByText("小米手环10 标准版 智能手环")).toBeInTheDocument();
|
||||
expect(await screen.findByText(/SKU 222222222222/)).toBeInTheDocument();
|
||||
expect(await screen.findByText("好评率 97%")).toBeInTheDocument();
|
||||
expect(await screen.findByText("表带舒适,睡眠监测比较准。")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects the tmall prepare route to the unified ops page and clears the managed session", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(<App />, ["/ops/platforms/tmall/prepare?from=/tasks/new"]);
|
||||
expect(await screen.findByText("已加密保存")).toBeInTheDocument();
|
||||
expect(screen.getByText(/完成后将返回:\/tasks\/new/)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "清理当前会话" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(clearTmallManagedSession).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects the legacy jd prepare route to the ops session manager page", async () => {
|
||||
renderWithProviders(<App />, ["/sessions/jd/prepare?from=/tasks/new"]);
|
||||
|
||||
expect(await screen.findByText("京东运维会话管理")).toBeInTheDocument();
|
||||
expect(screen.getByText(/返回业务页面:\/tasks\/new/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates the confirm page when a task snapshot restores JD candidates", async () => {
|
||||
let snapshotHandler: ((event: MessageEvent<string>) => void) | undefined;
|
||||
vi.mocked(createTaskEventsSource).mockReturnValue({
|
||||
addEventListener: vi.fn((_type: string, handler: EventListenerOrEventListenerObject) => {
|
||||
snapshotHandler = handler as (event: MessageEvent<string>) => void;
|
||||
}),
|
||||
close: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
} as unknown as EventSource);
|
||||
|
||||
vi.mocked(getTask).mockResolvedValue({
|
||||
task: {
|
||||
taskId: "task-confirm-live",
|
||||
query: "iPhone 15",
|
||||
createdAt: "2026-04-07T09:00:00.000Z",
|
||||
updatedAt: "2026-04-07T09:00:00.000Z",
|
||||
perLinkLimit: 100,
|
||||
taskTotalLimit: 500,
|
||||
taskStatus: "AwaitingConfirmation",
|
||||
taskStage: "confirmation",
|
||||
platformRuns: [
|
||||
{
|
||||
platform: "tmall",
|
||||
searchRequirement: "recommended",
|
||||
status: "AwaitingSelection",
|
||||
candidateCount: 1,
|
||||
selectedCandidateIds: [],
|
||||
lastUpdatedAt: "2026-04-07T09:00:00.000Z"
|
||||
},
|
||||
{
|
||||
platform: "jd",
|
||||
searchRequirement: "required",
|
||||
status: "SearchBlocked",
|
||||
candidateCount: 0,
|
||||
selectedCandidateIds: [],
|
||||
reason: "waiting for ops recovery",
|
||||
lastUpdatedAt: "2026-04-07T09:00:00.000Z"
|
||||
}
|
||||
],
|
||||
platformCandidates: {
|
||||
tmall: [],
|
||||
jd: []
|
||||
},
|
||||
events: [],
|
||||
reportVersions: []
|
||||
}
|
||||
} as any);
|
||||
vi.mocked(getTaskCandidates)
|
||||
.mockResolvedValueOnce({
|
||||
candidates: {
|
||||
tmall: [],
|
||||
jd: []
|
||||
}
|
||||
} as any)
|
||||
.mockResolvedValue({
|
||||
candidates: {
|
||||
tmall: [],
|
||||
jd: [
|
||||
{
|
||||
candidateId: "jd-100068388533",
|
||||
platform: "jd",
|
||||
title: "Apple iPhone 15",
|
||||
price: 3898,
|
||||
priceLabel: "CNY 3898",
|
||||
storeName: "JD Self Operated",
|
||||
productUrl: "https://item.jd.com/100068388533.html",
|
||||
imageUrl: "https://img14.360buyimg.com/example.jpg",
|
||||
salesHint: "sold 500+",
|
||||
specLabel: "128GB",
|
||||
highlights: ["A16"]
|
||||
}
|
||||
]
|
||||
}
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<App />, ["/tasks/task-confirm-live/confirm"]);
|
||||
|
||||
expect(await screen.findByText("waiting for ops recovery")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
snapshotHandler?.(
|
||||
new MessageEvent("task.snapshot", {
|
||||
data: JSON.stringify({
|
||||
task: {
|
||||
taskId: "task-confirm-live",
|
||||
query: "iPhone 15",
|
||||
createdAt: "2026-04-07T09:00:00.000Z",
|
||||
updatedAt: "2026-04-07T09:01:00.000Z",
|
||||
perLinkLimit: 100,
|
||||
taskTotalLimit: 500,
|
||||
taskStatus: "AwaitingConfirmation",
|
||||
taskStage: "confirmation",
|
||||
platformRuns: [
|
||||
{
|
||||
platform: "tmall",
|
||||
searchRequirement: "recommended",
|
||||
status: "AwaitingSelection",
|
||||
candidateCount: 1,
|
||||
selectedCandidateIds: [],
|
||||
lastUpdatedAt: "2026-04-07T09:00:00.000Z"
|
||||
},
|
||||
{
|
||||
platform: "jd",
|
||||
searchRequirement: "required",
|
||||
status: "AwaitingSelection",
|
||||
candidateCount: 1,
|
||||
selectedCandidateIds: [],
|
||||
lastUpdatedAt: "2026-04-07T09:01:00.000Z"
|
||||
}
|
||||
],
|
||||
platformCandidates: {
|
||||
tmall: [],
|
||||
jd: []
|
||||
},
|
||||
events: [],
|
||||
reportVersions: []
|
||||
}
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Apple iPhone 15")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(getTaskCandidates).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("imports jd managed session payload from the ops page", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(<App />, ["/ops/session-manager?platform=jd&from=/tasks/new"]);
|
||||
|
||||
await user.clear(await screen.findByLabelText("Cookie Header"));
|
||||
await user.type(screen.getByLabelText("Cookie Header"), "thor=masked; pin=masked;");
|
||||
await user.type(
|
||||
screen.getByLabelText("Detail Template URL"),
|
||||
"https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness"
|
||||
);
|
||||
await user.type(
|
||||
screen.getByLabelText("Reviews Template URL"),
|
||||
"https://api.m.jd.com/?functionId=getLegoWareDetailComment"
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "注入京东会话" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(importJdManagedSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cookieHeader: "thor=masked; pin=masked;",
|
||||
detailTemplateUrl: "https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness",
|
||||
reviewsTemplateUrl: "https://api.m.jd.com/?functionId=getLegoWareDetailComment"
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("imports tmall managed session payload from the unified ops page", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(<App />, ["/ops/session-manager?platform=tmall&from=/tasks/new"]);
|
||||
|
||||
await user.clear(await screen.findByLabelText("Cookie Header"));
|
||||
await user.type(
|
||||
screen.getByLabelText("Cookie Header"),
|
||||
"_m_h5_tk=masked_token_123; cookie2=masked;"
|
||||
);
|
||||
await user.type(
|
||||
screen.getByLabelText("Detail Template URL"),
|
||||
"https://detail.tmall.com/item.htm?id=934454505228"
|
||||
);
|
||||
await user.type(
|
||||
screen.getByLabelText("Reviews Template URL"),
|
||||
"https://h5api.m.tmall.com/h5/mtop.taobao.rate.detaillist.get/6.0/?data=%7B%22auctionNumId%22%3A%22934454505228%22%7D"
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "注入天猫会话" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(importTmallManagedSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cookieHeader: "_m_h5_tk=masked_token_123; cookie2=masked;",
|
||||
detailTemplateUrl: "https://detail.tmall.com/item.htm?id=934454505228",
|
||||
reviewsTemplateUrl:
|
||||
"https://h5api.m.tmall.com/h5/mtop.taobao.rate.detaillist.get/6.0/?data=%7B%22auctionNumId%22%3A%22934454505228%22%7D"
|
||||
})
|
||||
);
|
||||
expect(clearPlatformSession).toHaveBeenCalledWith("jd");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,273 +0,0 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, render, screen } 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(),
|
||||
clearTmallManagedSession: vi.fn(),
|
||||
clearTmallSessionManagerConfig: vi.fn(),
|
||||
confirmTask: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
createTaskEventsSource: vi.fn(() => ({
|
||||
addEventListener: vi.fn(),
|
||||
close: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
})),
|
||||
deleteTask: vi.fn(),
|
||||
getHistoryTasks: vi.fn(),
|
||||
getJdKeywordPreview: vi.fn(),
|
||||
getJdLiveSession: vi.fn(),
|
||||
getJdQrLoginState: vi.fn(),
|
||||
getJdSessionManager: vi.fn(),
|
||||
getPlatformReadiness: vi.fn(),
|
||||
getPlatformSession: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
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(),
|
||||
retryTaskPlatform: vi.fn(),
|
||||
runJdSessionManagerHealthCheck: vi.fn(),
|
||||
runJdSessionManagerRecovery: vi.fn(),
|
||||
runTmallSessionManagerHealthCheck: vi.fn(),
|
||||
startJdQrLogin: vi.fn(),
|
||||
startTmallQrLogin: vi.fn(),
|
||||
updateJdSessionManagerConfig: vi.fn(),
|
||||
updateTmallSessionManagerConfig: vi.fn()
|
||||
}));
|
||||
|
||||
import { App } from "./App";
|
||||
import { getTask, getTaskReport } from "./lib/api";
|
||||
|
||||
function renderWithProviders(node: ReactNode, initialEntries: string[]) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={initialEntries}>{node}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ReportPage", () => {
|
||||
const taskId = "task-report";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getTask).mockResolvedValue({
|
||||
task: {
|
||||
taskId,
|
||||
query: "Nintendo Switch 2",
|
||||
createdAt: "2026-04-02T11:40:00.000Z",
|
||||
updatedAt: "2026-04-02T12:00:00.000Z",
|
||||
perLinkLimit: 100,
|
||||
taskTotalLimit: 500,
|
||||
taskStatus: "Completed",
|
||||
taskStage: "publish",
|
||||
platformRuns: [
|
||||
{
|
||||
platform: "jd",
|
||||
searchRequirement: "required",
|
||||
status: "Completed",
|
||||
candidateCount: 1,
|
||||
selectedCandidateIds: ["jd-1"],
|
||||
lastUpdatedAt: "2026-04-02T11:58:00.000Z"
|
||||
}
|
||||
],
|
||||
platformCandidates: {
|
||||
tmall: [],
|
||||
jd: []
|
||||
},
|
||||
events: [],
|
||||
reportVersions: [1],
|
||||
defaultReportVersion: 1,
|
||||
latestSuccessfulReportVersion: 1
|
||||
}
|
||||
} as any);
|
||||
|
||||
vi.mocked(getTaskReport).mockResolvedValue({
|
||||
report: {
|
||||
report_id: "report-1",
|
||||
report_version: 1,
|
||||
task_id: taskId,
|
||||
generated_at: "2026-04-02T12:00:00.000Z",
|
||||
task_status: "Completed",
|
||||
summary: {
|
||||
headline: "报告已包含真实评论证据",
|
||||
key_points: ["评论样本已写入报告证据,可在站内查看。"],
|
||||
limitations: []
|
||||
},
|
||||
product_snapshot: {
|
||||
query: "Nintendo Switch 2",
|
||||
normalized_product_name: "Nintendo Switch 2",
|
||||
platform_count: 1,
|
||||
selected_link_count: 1,
|
||||
review_sample_count: 5,
|
||||
analysis_time_range: {
|
||||
start: "2026-04-02T11:40:00.000Z",
|
||||
end: "2026-04-02T12:00:00.000Z"
|
||||
}
|
||||
},
|
||||
platform_insights: [
|
||||
{
|
||||
platform: "jd",
|
||||
execution_status: "completed",
|
||||
selected_link_count: 1,
|
||||
price_range: null,
|
||||
selling_points: [],
|
||||
positive_themes: [],
|
||||
negative_themes: [],
|
||||
store_diff_notes: []
|
||||
}
|
||||
],
|
||||
cross_platform_insights: [],
|
||||
recommendations: [],
|
||||
evidence_index: [
|
||||
{
|
||||
evidence_id: "evidence-product-1",
|
||||
platform: "jd",
|
||||
source_type: "product",
|
||||
source_url: "https://item.jd.com/100068388533.html",
|
||||
review_ref: null,
|
||||
snippet: "Nintendo Switch 2 | 店铺 京东自营 | 价格 ¥2999 | 库存 有货",
|
||||
captured_at: "2026-04-02T11:58:00.000Z"
|
||||
},
|
||||
{
|
||||
evidence_id: "evidence-review-1",
|
||||
platform: "jd",
|
||||
source_type: "review",
|
||||
source_url: "https://item.jd.com/100068388533.html",
|
||||
review_ref: "comment-1",
|
||||
review_detail: {
|
||||
content: "这是一条抓取到的完整评论内容。",
|
||||
score: "5",
|
||||
created_at: "2026-04-02",
|
||||
author_label: "PLUS会员",
|
||||
sku_labels: ["标准版"],
|
||||
like_count: "9",
|
||||
reply: "感谢支持。",
|
||||
append_content: "追评:一周后依然满意。",
|
||||
picture_urls: [],
|
||||
video_urls: [],
|
||||
append_picture_urls: []
|
||||
},
|
||||
snippet: "样本 latest | 评分 5 | PLUS会员 | 这是一条抓取到的完整评论内容。",
|
||||
captured_at: "2026-04-02T11:59:00.000Z"
|
||||
}
|
||||
],
|
||||
review_collections: [
|
||||
{
|
||||
collection_id: "review-collection-jd-1",
|
||||
candidate_id: "jd-1",
|
||||
product_evidence_id: "evidence-product-1",
|
||||
platform: "jd",
|
||||
source_url: "https://item.jd.com/100068388533.html",
|
||||
title: "Nintendo Switch 2",
|
||||
store_name: "京东自营",
|
||||
price_label: "¥2999",
|
||||
captured_at: "2026-04-02T11:59:00.000Z",
|
||||
review_count: 2,
|
||||
sampled_review_refs: ["comment-1"],
|
||||
comments: [
|
||||
{
|
||||
review_ref: "comment-1",
|
||||
sample_bucket: "latest",
|
||||
content: "这是一条抓取到的完整评论内容。",
|
||||
score: "5",
|
||||
created_at: "2026-04-02",
|
||||
author_label: "PLUS会员",
|
||||
sku_labels: ["标准版"],
|
||||
like_count: "9",
|
||||
reply: "感谢支持。",
|
||||
append_content: "追评:一周后依然满意。",
|
||||
picture_urls: [],
|
||||
video_urls: [],
|
||||
append_picture_urls: []
|
||||
},
|
||||
{
|
||||
review_ref: "comment-2",
|
||||
sample_bucket: null,
|
||||
content: "第二条抓取评论,主要提到续航稳定。",
|
||||
score: "4",
|
||||
created_at: "2026-04-01",
|
||||
author_label: "普通会员",
|
||||
sku_labels: ["标准版"],
|
||||
like_count: "2",
|
||||
reply: null,
|
||||
append_content: null,
|
||||
picture_urls: [],
|
||||
video_urls: [],
|
||||
append_picture_urls: []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
quality_flags: {
|
||||
sample_insufficient: false,
|
||||
partial_platform_failure: false,
|
||||
blocked_platforms: [],
|
||||
failed_platforms: []
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("expands all captured comments from the review sample metric", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(<App />, [`/tasks/${taskId}/report`]);
|
||||
|
||||
expect(await screen.findByText("评论样本已写入报告证据,可在站内查看。")).toBeInTheDocument();
|
||||
expect(screen.queryByText("第二条抓取评论,主要提到续航稳定。")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "查看全部抓取评论" }));
|
||||
|
||||
expect(await screen.findByText("第二条抓取评论,主要提到续航稳定。")).toBeInTheDocument();
|
||||
expect(screen.getByText("共保留 2 条抓取评论")).toBeInTheDocument();
|
||||
expect(screen.getByText("已抽样 1 条进入报告样本")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands per-link comments and sampled review details inside the evidence index", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(<App />, [`/tasks/${taskId}/report`]);
|
||||
|
||||
expect(await screen.findByText("评论样本已写入报告证据,可在站内查看。")).toBeInTheDocument();
|
||||
expect(screen.queryByText("第二条抓取评论,主要提到续航稳定。")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "查看该链接评论(2)" }));
|
||||
|
||||
expect(await screen.findByText("第二条抓取评论,主要提到续航稳定。")).toBeInTheDocument();
|
||||
expect(screen.getByText("该链接共抓取 2 条评论")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("评论正文")).toHaveLength(2);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "查看样本评论" }));
|
||||
|
||||
expect(await screen.findAllByText("评论正文")).toHaveLength(3);
|
||||
expect(screen.getAllByText("追评")).toHaveLength(2);
|
||||
expect(screen.getAllByText("商家回复")).toHaveLength(2);
|
||||
expect(screen.getAllByText("点赞 9")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@ -1,43 +1,42 @@
|
||||
:root {
|
||||
--bg-canvas: #f2eee6;
|
||||
--bg-surface: #fbf8f2;
|
||||
--bg-elevated: #ffffff;
|
||||
--text-primary: #1f2a30;
|
||||
--text-secondary: #5b6971;
|
||||
--line-subtle: #d7d0c4;
|
||||
--brand-primary: #146c6e;
|
||||
--brand-primary-soft: #d9efeb;
|
||||
--accent-amber: #8c5a16;
|
||||
--success: #2e7d5b;
|
||||
--warning: #9a4b24;
|
||||
--blocked: #9e3f22;
|
||||
--danger: #b63e2f;
|
||||
--info: #2f6d8a;
|
||||
color: var(--text-primary);
|
||||
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(20, 108, 110, 0.08), transparent 28%),
|
||||
linear-gradient(180deg, #f6f1e8 0%, var(--bg-canvas) 100%);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-canvas: #f2eee6;
|
||||
--bg-surface: #fbf8f2;
|
||||
--bg-elevated: #ffffff;
|
||||
--text-primary: #1f2a30;
|
||||
--text-secondary: #5b6971;
|
||||
--line-subtle: #d7d0c4;
|
||||
--brand-primary: #146c6e;
|
||||
--brand-primary-soft: #d9efeb;
|
||||
--accent-amber: #8c5a16;
|
||||
--success: #2e7d5b;
|
||||
--warning: #9a4b24;
|
||||
--blocked: #9e3f22;
|
||||
--danger: #b63e2f;
|
||||
--info: #2f6d8a;
|
||||
color: var(--text-primary);
|
||||
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(20, 108, 110, 0.08), transparent 28%),
|
||||
linear-gradient(180deg, #f6f1e8 0%, var(--bg-canvas) 100%);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@ -45,96 +44,89 @@ 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);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.sidebar__title {
|
||||
margin: 8px 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sidebar__copy {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sidebar__nav {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar__nav a {
|
||||
padding: 12px 16px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.64);
|
||||
border: 1px solid rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
|
||||
.sidebar__title {
|
||||
margin: 8px 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sidebar__copy {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sidebar__nav {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar__nav a {
|
||||
padding: 12px 16px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.64);
|
||||
border: 1px solid rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
min-height: 100vh;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.page-panel {
|
||||
padding: 24px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(31, 42, 48, 0.08);
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
box-shadow: 0 18px 40px rgba(31, 42, 48, 0.05);
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.hero-panel__copy h2 {
|
||||
margin: 6px 0 14px;
|
||||
font-size: 36px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: var(--accent-amber);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.task-form,
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stack--dense {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
.page-panel {
|
||||
padding: 24px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(31, 42, 48, 0.08);
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
box-shadow: 0 18px 40px rgba(31, 42, 48, 0.05);
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.hero-panel__copy h2 {
|
||||
margin: 6px 0 14px;
|
||||
font-size: 36px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: var(--accent-amber);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.task-form,
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stack--dense {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field textarea,
|
||||
.field input,
|
||||
.field select {
|
||||
@ -151,39 +143,39 @@ a {
|
||||
grid-template-columns: 96px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field-grid,
|
||||
.page-grid,
|
||||
.report-grid,
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field-grid,
|
||||
.page-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.report-grid {
|
||||
grid-template-columns: 1.4fr 0.8fr;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
|
||||
.field-grid,
|
||||
.page-grid,
|
||||
.report-grid,
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field-grid,
|
||||
.page-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.report-grid {
|
||||
grid-template-columns: 1.4fr 0.8fr;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 46px;
|
||||
padding: 0 18px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-primary);
|
||||
color: white;
|
||||
font: inherit;
|
||||
min-height: 46px;
|
||||
padding: 0 18px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-primary);
|
||||
color: white;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
@ -216,12 +208,12 @@ a {
|
||||
.primary-button--link {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.text-link {
|
||||
color: var(--brand-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
.text-link {
|
||||
color: var(--brand-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.readiness-card,
|
||||
.platform-run-panel,
|
||||
.history-card,
|
||||
@ -244,18 +236,18 @@ a {
|
||||
.mini-task-link {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
|
||||
.readiness-card__header,
|
||||
.platform-run-panel__header,
|
||||
.history-card__topline,
|
||||
.candidate-section__header,
|
||||
.report-hero__topline,
|
||||
.task-context-header,
|
||||
.sticky-actions,
|
||||
.history-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.task-context-header,
|
||||
.sticky-actions,
|
||||
.history-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@ -285,39 +277,6 @@ a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.evidence-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.evidence-card p,
|
||||
.evidence-review-detail p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.evidence-card__header,
|
||||
.evidence-card__meta,
|
||||
.evidence-card__actions,
|
||||
.evidence-review-detail__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.evidence-card__header {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.evidence-review-detail {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(20, 108, 110, 0.16);
|
||||
background: rgba(20, 108, 110, 0.06);
|
||||
}
|
||||
|
||||
.mini-task-link__topline {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
@ -329,169 +288,169 @@ a {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-context-header {
|
||||
padding: 20px 0 0;
|
||||
}
|
||||
|
||||
.task-context-header h1 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.task-context-header__meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-spine {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-spine__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.64);
|
||||
border: 1px solid rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
.task-spine__item--active {
|
||||
background: var(--brand-primary-soft);
|
||||
border-color: rgba(20, 108, 110, 0.28);
|
||||
}
|
||||
|
||||
.task-spine__index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-pill--neutral {
|
||||
background: rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
.status-pill--info {
|
||||
background: rgba(47, 109, 138, 0.12);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.status-pill--progress {
|
||||
background: rgba(20, 108, 110, 0.12);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.status-pill--success {
|
||||
background: rgba(46, 125, 91, 0.12);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-pill--warning {
|
||||
background: rgba(154, 75, 36, 0.12);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-pill--blocked {
|
||||
background: rgba(158, 63, 34, 0.12);
|
||||
color: var(--blocked);
|
||||
}
|
||||
|
||||
.status-pill--danger {
|
||||
background: rgba(182, 62, 47, 0.12);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-pill--empty {
|
||||
background: rgba(140, 90, 22, 0.12);
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.platform-identity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.platform-identity__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-primary-soft);
|
||||
color: var(--brand-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.candidate-card {
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.candidate-card img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.candidate-card__content {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.candidate-card__topline {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.candidate-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.event-feed {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.event-row {
|
||||
display: grid;
|
||||
grid-template-columns: 92px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
.task-context-header {
|
||||
padding: 20px 0 0;
|
||||
}
|
||||
|
||||
.task-context-header h1 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.task-context-header__meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-spine {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-spine__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.64);
|
||||
border: 1px solid rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
.task-spine__item--active {
|
||||
background: var(--brand-primary-soft);
|
||||
border-color: rgba(20, 108, 110, 0.28);
|
||||
}
|
||||
|
||||
.task-spine__index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-pill--neutral {
|
||||
background: rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
.status-pill--info {
|
||||
background: rgba(47, 109, 138, 0.12);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.status-pill--progress {
|
||||
background: rgba(20, 108, 110, 0.12);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.status-pill--success {
|
||||
background: rgba(46, 125, 91, 0.12);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-pill--warning {
|
||||
background: rgba(154, 75, 36, 0.12);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-pill--blocked {
|
||||
background: rgba(158, 63, 34, 0.12);
|
||||
color: var(--blocked);
|
||||
}
|
||||
|
||||
.status-pill--danger {
|
||||
background: rgba(182, 62, 47, 0.12);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-pill--empty {
|
||||
background: rgba(140, 90, 22, 0.12);
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.platform-identity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.platform-identity__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-primary-soft);
|
||||
color: var(--brand-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.candidate-card {
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.candidate-card img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.candidate-card__content {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.candidate-card__topline {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.candidate-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.event-feed {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.event-row {
|
||||
display: grid;
|
||||
grid-template-columns: 92px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.inline-note {
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
@ -503,24 +462,12 @@ a {
|
||||
background: rgba(31, 42, 48, 0.06);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
.metric-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.metric-card--button {
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.metric-card--button:hover {
|
||||
border-color: rgba(20, 108, 110, 0.24);
|
||||
box-shadow: 0 12px 24px rgba(20, 108, 110, 0.08);
|
||||
}
|
||||
|
||||
.metric-card span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
@ -530,17 +477,6 @@ a {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.metric-card small {
|
||||
color: var(--brand-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.review-comment-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.history-card__actions,
|
||||
.sticky-actions {
|
||||
margin-top: 16px;
|
||||
@ -639,77 +575,38 @@ a {
|
||||
.sticky-actions {
|
||||
position: sticky;
|
||||
bottom: 24px;
|
||||
padding: 16px 20px;
|
||||
border-radius: 20px;
|
||||
background: rgba(31, 42, 48, 0.92);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.session-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.session-placeholder {
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 0.7fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
padding: 16px 20px;
|
||||
border-radius: 20px;
|
||||
background: rgba(31, 42, 48, 0.92);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.session-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.session-placeholder {
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 0.7fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.session-placeholder__viewport,
|
||||
.session-placeholder__sidebar {
|
||||
min-height: 320px;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
border: 1px dashed rgba(31, 42, 48, 0.18);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
border-radius: 18px;
|
||||
border: 1px dashed rgba(31, 42, 48, 0.18);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.session-placeholder__viewport {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.session-import-form {
|
||||
width: 100%;
|
||||
align-self: stretch;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.session-details {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@ -735,94 +632,20 @@ a {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.quick-preview-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.quick-preview-panel__header {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.quick-preview-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
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;
|
||||
}
|
||||
|
||||
.comment-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(140, 90, 22, 0.08);
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(140, 90, 22, 0.08);
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.app-shell,
|
||||
.hero-panel,
|
||||
@ -832,20 +655,11 @@ a {
|
||||
.history-toolbar,
|
||||
.field-grid,
|
||||
.metrics-grid,
|
||||
.quick-preview-summary,
|
||||
.session-placeholder {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.qr-login-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.qr-login-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
@ -1,193 +0,0 @@
|
||||
# 京东商品详情与评论抓取方案
|
||||
|
||||
- 文档状态:Draft
|
||||
- 更新时间:2026-04-03
|
||||
- 关联任务:`S2-03`、`S2-04`
|
||||
|
||||
## 1. 当前结论
|
||||
|
||||
当前阶段,京东商品详情和评论的稳定抓取路径应明确收敛为:
|
||||
|
||||
1. 浏览器负责登录、会话校验、模板采集与模板刷新。
|
||||
2. 服务端负责在授权会话下回放 `api.m.jd.com` 请求,解析商品详情与评论 JSON。
|
||||
3. 页面 DOM 只用于辅助观测,不应继续作为详情和评论主采集路径。
|
||||
|
||||
## 2. 2026-04-03 实时验证结果
|
||||
|
||||
基于本地 Playwright 实测,当前京东 PC 商品页的关键链路如下:
|
||||
|
||||
1. 搜索页 `https://search.jd.com/Search?keyword=iPhone%2015` 当前可以直接打开,但核心候选数据仍来自 `api.m.jd.com/api?functionId=pc_search_searchWare`。
|
||||
2. 商品页 `https://item.jd.com/100068388535.html` 当前可以直接打开,页面初始化阶段会触发风控与签名相关请求,如 `jra.jd.com/jsTk.do`、`cactus.jd.com/request_algo`。
|
||||
3. 商品详情主数据来自 `https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness`。
|
||||
4. 评论主数据来自 `https://api.m.jd.com/?functionId=getLegoWareDetailComment`。
|
||||
5. 这两条接口都伴随 `h5st`、`x-api-eid-token`、`uuid`、`appid`、`client`、`clientVersion` 等动态上下文,不应假设为匿名公开接口。
|
||||
|
||||
## 3. 详情接口
|
||||
|
||||
当前实测详情请求形态:
|
||||
|
||||
```text
|
||||
GET https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness
|
||||
body={
|
||||
"skuId":"100068388535",
|
||||
"area":"2_2813_61125_0",
|
||||
"num":"1",
|
||||
"sfTime":"1,0,0"
|
||||
}
|
||||
appid=pc-item-soa
|
||||
client=pc
|
||||
loginType=3
|
||||
```
|
||||
|
||||
当前可稳定解析出的字段:
|
||||
|
||||
1. `skuId`
|
||||
2. `title`
|
||||
3. `price`
|
||||
4. `originalPrice`
|
||||
5. `estimatedPrice`
|
||||
6. `shopName`
|
||||
7. `vendorId`
|
||||
8. `categoryPath`
|
||||
9. `stockState`
|
||||
10. `mainImage`
|
||||
11. `averageScore`
|
||||
|
||||
## 4. 评论接口
|
||||
|
||||
当前实测评论请求形态:
|
||||
|
||||
```text
|
||||
GET https://api.m.jd.com/?functionId=getLegoWareDetailComment
|
||||
body={
|
||||
"shopType":"0",
|
||||
"sku":100068388535,
|
||||
"commentNum":5,
|
||||
"source":"pc"
|
||||
}
|
||||
appid=item-v3
|
||||
client=pc
|
||||
loginType=3
|
||||
build=100000
|
||||
```
|
||||
|
||||
当前可稳定解析出的字段:
|
||||
|
||||
1. `total`
|
||||
2. `goodRate`
|
||||
3. `pictureCount`
|
||||
4. `tagStatisticsinfoList`
|
||||
5. `commentInfoList`
|
||||
|
||||
评论正文、标签和总量都来自 JSON,本轮不建议退回到页面滚动或 DOM 抽取。
|
||||
|
||||
## 5. 分页策略
|
||||
|
||||
评论分页不要硬编码猜测字段,应以真实抓包模板为准。当前代码已经支持:
|
||||
|
||||
1. 保留导入模板中的原始 `body` 结构。
|
||||
2. 自动重写 `sku` / `skuId` 一类商品标识字段。
|
||||
3. 自动重写 `commentNum` 一类每页评论数参数。
|
||||
4. 若模板中存在 `page` / `pageIndex` / `pageNum` / `currentPage` / `commentPage` 之一,则按页递增回放并聚合评论。
|
||||
5. 多页聚合时按评论 `id` 去重。
|
||||
|
||||
注意:
|
||||
|
||||
1. 如果导入的评论模板没有页码字段,当前服务会拒绝 `maxPages > 1` 的请求。
|
||||
2. 想做多页评论回放,建议先在浏览器里翻到评论第 2 页,再抓一次 `getLegoWareDetailComment` 请求作为模板。
|
||||
|
||||
## 6. 当前代码落点
|
||||
|
||||
`apps/api/src/platforms/jd/live-session.ts` 已补齐以下能力:
|
||||
|
||||
1. `previewDetail(skuId)`:详情模板支持换 SKU 回放,不再被初始模板 SKU 锁死。
|
||||
2. `previewReviews(skuId, { commentCount, page, maxPages })`:评论模板支持页码改写、多页聚合、评论去重。
|
||||
3. `previewProduct(skuId, options)`:单次返回商品详情与评论,便于后续并入任务执行主链。
|
||||
4. 对会话失效、模板失效、页码模板缺失和风控验证页做了结构化错误归类,任务编排不再依赖 message contains。
|
||||
5. 多页评论回放在遇到空页、短页或重复页时会提前收口,避免无效翻页。
|
||||
|
||||
新增 API:
|
||||
|
||||
1. `GET /api/platforms/jd/live-detail-preview`
|
||||
2. `GET /api/platforms/jd/live-reviews-preview`
|
||||
3. `GET /api/platforms/jd/live-product-preview`
|
||||
4. `GET /api/platforms/jd/live-keyword-preview`
|
||||
|
||||
`live-keyword-preview` 的目标是补齐最小“关键词直连”闭环:
|
||||
|
||||
1. 输入一个关键词。
|
||||
2. 服务端先调用 `previewSearch(query)` 拉回候选。
|
||||
3. 依据标题完整命中、关键词片段覆盖、搜索排序位置和可回放 SKU 解析结果自动选一个最匹配候选。
|
||||
4. 再调用 `previewProduct(skuId, { commentCount, maxPages })` 返回结构化详情和评论。
|
||||
|
||||
这条路由不会替代原有“人工确认后执行”的任务主链,但它能让当前系统在会话和模板已准备好的前提下,直接完成“关键词 -> 商品信息 + 评论”验证。
|
||||
|
||||
## 7. 浏览器采集模板建议
|
||||
|
||||
推荐流程:
|
||||
|
||||
1. 在浏览器中登录京东。
|
||||
2. 打开目标商品页。
|
||||
3. 在 Network 面板过滤 `pc_detailpage_wareBusiness` 和 `getLegoWareDetailComment`。
|
||||
4. 导出:
|
||||
- Cookie Header
|
||||
- Detail Request URL
|
||||
- Reviews Request URL
|
||||
5. 如果需要多页评论,翻到第 2 页后再抓一条评论请求模板。
|
||||
6. 把这些值导入 `/api/ops/jd/session-manager/session`,或通过 `/ops/jd/session-manager` 运维页手工注入。
|
||||
|
||||
## 8. 当前剩余缺口
|
||||
|
||||
这轮已经把京东 `search/detail/reviews` 并入任务主链:
|
||||
|
||||
1. 京东会话导入已切到 ops-only Session Manager,普通用户页不再暴露登录与恢复入口。
|
||||
2. `store` 已在 JD live session 可用时切到真实搜索候选与 `previewProduct` 抓取。
|
||||
3. 报告证据索引已直接引用京东实时商品详情与评论,不再只依赖 mock 候选摘要。
|
||||
|
||||
当前剩余缺口主要是:
|
||||
|
||||
1. `40/30/30` 抽样已接到真实 JD 评论分页结果,但当前 `hot` 桶仍是基于已抓到评论集合做近似代表性抽样,还没接平台侧专用热评路由。
|
||||
2. 模板刷新与 `L2` 自动恢复还没落地到执行编排。
|
||||
3. 真实 AI 归纳仍未替换当前规则化分析层。
|
||||
4. 评论预算已经按已确认链接和任务总上限分配,但前端结果页还没把每个链接的目标样本数和不足标记显式展示出来。
|
||||
|
||||
下一步建议直接推进:
|
||||
|
||||
1. 把 `TemplateExpired` / `NeedLogin` / `RiskBlocked` 接到 `S3-02` 的模板刷新与恢复动作。
|
||||
2. 如果要进一步逼近 PRD 口径,补抓“热评/差评”专用模板或参数模板,而不是只在 merged comment set 上做近似抽样。
|
||||
3. 用真实评论样本驱动 `S4-02` 的 AI 结构化总结,而不是仅输出规则卡片。
|
||||
|
||||
## 9. Ops-Only Session Manager
|
||||
|
||||
当前京东登录态维护已经从普通用户流程中剥离,统一收口到后端运维链路:
|
||||
|
||||
1. 用户页面只消费抓取结果、任务状态和阻塞说明,不再显示京东登录/恢复入口。
|
||||
2. 运维页面统一使用 `/ops/jd/session-manager`。
|
||||
3. 后端接口统一使用 `/api/ops/jd/session-manager/*`。
|
||||
4. 老的 `/api/platforms/jd/live-session` 仍保留为兼容入口,但内部已经转交给 Session Manager 处理。
|
||||
5. 当会话缺失、模板过期或风控拦截时,任务侧会优先展示 Session Manager 的 `publicNote`,提示“后台正在恢复”或“需要人工运维”。
|
||||
|
||||
这套设计的目标是:
|
||||
|
||||
1. 京东账密、浏览器 profile 和验证码处理逻辑只存在于后端/运维侧,不进入普通任务页面。
|
||||
2. 自动恢复失败时可切到人工注入最新 Cookie 与模板,但仍不影响前台用户路径。
|
||||
3. 搜索、详情、评论三段抓取共享同一套受控会话状态,减少“前台能进、后台不能跑”的割裂。
|
||||
|
||||
## 10. 环境变量约定
|
||||
|
||||
`JdSessionManagerService` 当前支持以下运维环境变量:
|
||||
|
||||
1. `JD_OPS_AUTO_ENABLED`
|
||||
2. `JD_OPS_LOGIN_COMMAND`
|
||||
3. `JD_OPS_BROWSER_PROFILE_DIR`
|
||||
4. `JD_OPS_ACCOUNT`
|
||||
5. `JD_OPS_PASSWORD`
|
||||
6. `JD_OPS_HEARTBEAT_QUERY`
|
||||
7. `JD_OPS_CHECK_INTERVAL_MS`
|
||||
8. `JD_OPS_RUNNER_TIMEOUT_MS`
|
||||
|
||||
推荐约定:
|
||||
|
||||
1. `JD_OPS_LOGIN_COMMAND` 输出一段 JSON,字段与 `JdLiveSessionInput` 一致。
|
||||
2. JSON 至少应包含 `cookieHeader`、`detailTemplateUrl`、`reviewsTemplateUrl`。
|
||||
3. 如果命令链路遇到验证码/短信验证,自动恢复应返回失败,由运维在 `/ops/jd/session-manager` 页面手工注入最新会话。
|
||||
@ -1,179 +0,0 @@
|
||||
# 天猫详情页与评论抓取研究
|
||||
|
||||
- 状态:Draft
|
||||
- 更新时间:2026-04-03
|
||||
- 关联任务:`S3-01`
|
||||
|
||||
## 结论
|
||||
|
||||
1. 天猫详情和评论链路已经可以明确到具体入口与 API,不再是“靠 DOM 碰运气”的问题。
|
||||
2. 默认可交付路径应是“登录态页面状态解析 + 登录态模板回放”的组合,不是匿名直连。
|
||||
3. 2026-04-03 的实测里,天猫详情和评论 API 在服务端匿名请求下都会回到带登录跳转的 `RGV587_ERROR`,因此当前必须把登录态作为前置依赖。
|
||||
4. 详情页存在可稳定访问的 PC SSR 壳路由,适合做资源发现、版本跟踪和模板漂移观测;真正稳定可回放的详情数据来源则应优先落在登录态 HTML/SSR 内嵌状态,而不是继续依赖易漂移的详情 mtop。
|
||||
5. 最新一轮已把天猫 `search` 主路径补到服务端:直接回放 `https://s.taobao.com/search?q=<query>&tab=mall` 的登录态 HTML,并优先解析内嵌 `g_page_config` / JSON 负载,必要时再从商品链接块回退提取候选。
|
||||
|
||||
## 已验证入口
|
||||
|
||||
### 1. 商品详情壳页
|
||||
|
||||
- 直接访问 `https://detail.tmall.com/item.htm?id=<itemId>`,当前会返回登录跳转页,并带 `x-sec-reason: FAIL_SYS_SESSION_EXPIRED`。
|
||||
- 直接访问 `https://detail.tmall.com/wow/z/app/tbpc/pc-detail-ssr-2025/home?itemId=<itemId>`,可稳定拿到 SSR 壳页与资源清单。
|
||||
- 2026-04-03 实测的资源版本为:
|
||||
- `https://g.alicdn.com/code/npm/@ali/tbpc-pc-detail-ssr-2025/0.0.54/js/data-loader.js`
|
||||
- `https://g.alicdn.com/code/npm/@ali/tbpc-pc-detail-ssr-2025/0.0.54/js/p_home.js`
|
||||
|
||||
### 2. 详情 API
|
||||
|
||||
`data-loader.js` 暴露了两条详情接口:
|
||||
|
||||
- `mtop.taobao.pcdetail.data.get`
|
||||
- `mtop.taobao.pcdetail.business.data.get`
|
||||
|
||||
当前主链更像是:
|
||||
|
||||
1. 详情页 SSR 壳加载 `data-loader.js`
|
||||
2. `data-loader.js` 组装 `data` 负载:
|
||||
- `id`
|
||||
- `detail_v: "3.3.2"`
|
||||
- `exParams.queryParams`
|
||||
- `exParams.domain`
|
||||
- `exParams.path_name`
|
||||
- `exParams.pcSource`
|
||||
- `exParams.logicVer`
|
||||
- `exParams.nonce`
|
||||
3. 浏览器中的 `lib-mtop` 发起详情请求
|
||||
|
||||
### 3. 评论 API
|
||||
|
||||
`p_home.js` 暴露了两条评论接口:
|
||||
|
||||
- PC 评论:`mtop.alibaba.review.list.for.new.pc.detail`
|
||||
- H5 评论兜底:`mtop.taobao.rate.detaillist.get`
|
||||
|
||||
实测与 bundle 还原出的关键参数如下。
|
||||
|
||||
PC 评论请求:
|
||||
|
||||
- `itemId`
|
||||
- `bizCode`
|
||||
- 天猫为 `ali.china.tmall`
|
||||
- 淘宝为 `ali.china.taobao`
|
||||
- `channel: "pc_detail"`
|
||||
- `pageSize`
|
||||
- `pageNum`
|
||||
- 可选 `rateType`
|
||||
- 可选 `orderType`
|
||||
|
||||
H5 评论请求:
|
||||
|
||||
- `auctionNumId`
|
||||
- `pageNo`
|
||||
- `pageSize`
|
||||
- `showTrueCount: false`
|
||||
- `rateSrc: "pc_rate_list"`
|
||||
- 可选 `rateType`
|
||||
- 可选 `orderType`
|
||||
- 可选 `expression`
|
||||
|
||||
### 4. 2026-04-03 追加实机结论
|
||||
|
||||
- 当前真实商品页 `https://detail.tmall.com/item.htm?id=934454505228...` 的页面内详情请求稳定命中:
|
||||
- `mtop.tmall.kangaroo.core.service.route.aldlampservicefixedresv2`
|
||||
- 该接口在已登录浏览器中的真实返回里,`data.resultValue["36500751"].data` 只暴露 `distinctId`、`dataSetId`、`resId` 等路由信息,本身不是最终商品详情快照。
|
||||
- 同一页面的 SSR 内嵌脚本 `window.__ICE_APP_CONTEXT__` 已直接包含:
|
||||
- 标题
|
||||
- 店铺名 / 店铺链接 / 店铺类型
|
||||
- 主图
|
||||
- 当前展示价格 / 划线价
|
||||
- 已售文案
|
||||
- 评论总量与首屏评论摘要
|
||||
- 结论:
|
||||
- 商品详情抓取应优先采用“登录态 HTML + `window.__ICE_APP_CONTEXT__` 解析”。
|
||||
- 评论抓取继续采用 `mtop.taobao.rate.detaillist.get` 更合适。
|
||||
- 服务端回放评论模板时,必须基于 Cookie 中 `_m_h5_tk` 重新计算 `t/sign`;仅替换 `data` 而不重签名会被 mtop 拒绝。
|
||||
|
||||
## 2026-04-03 实测结果
|
||||
|
||||
### 0. MCP 浏览器实时登录态闭环
|
||||
|
||||
- 使用 MCP Playwright 浏览器直接打开真实商品页 `https://detail.tmall.com/item.htm?id=934454505228`,页面可在登录态下稳定打开。
|
||||
- 通过 `page.context().cookies()` 抽取当前 `*.tmall.com` 与 `detail.tmall.com` Cookie,回灌到 `POST /api/platforms/tmall/live-session` 后,`GET /api/platforms/tmall/live-product-preview?itemId=934454505228&commentCount=20&page=1&maxPages=2` 已实机返回 200。
|
||||
- 实机返回中已包含:
|
||||
- 商品详情:标题、店铺、主图、价格、评论总量。
|
||||
- 评论结果:标签、分页信息,以及 20 条真实评论样本。
|
||||
- 本轮还暴露并修复了一个真实问题:`mtop.taobao.rate.detaillist.get` 返回的是 JSONP,服务端解析器此前未先解包 `mtopjsonp(...)`,导致“请求成功但评论为空”。该问题已在 `apps/api` 修复并补齐回归测试。
|
||||
|
||||
### 1. 详情 API
|
||||
|
||||
匿名服务端请求 `mtop.taobao.pcdetail.data.get` 时,返回:
|
||||
|
||||
- `ret: ["RGV587_ERROR::SM::..."]`
|
||||
- `data.url` 指向 `https://login.taobao.com/member/login.jhtml?...`
|
||||
|
||||
结论:
|
||||
|
||||
- 详情 API 当前必须依赖登录态 Cookie。
|
||||
- 仅靠匿名 `_m_h5_tk` / H5 签名不足以形成稳定交付链路。
|
||||
|
||||
### 2. 评论 API
|
||||
|
||||
匿名服务端请求:
|
||||
|
||||
- `mtop.alibaba.review.list.for.new.pc.detail`
|
||||
- `mtop.taobao.rate.detaillist.get`
|
||||
|
||||
同样返回:
|
||||
|
||||
- `ret: ["RGV587_ERROR::SM::..."]`
|
||||
- `data.url` / `data.h5url` 指向淘宝登录页
|
||||
|
||||
结论:
|
||||
|
||||
- 评论接口虽然前端代码里标记为 `needLogin: false`,但当前实测依然被服务端登录门槛或风控前置拦住。
|
||||
- 对项目来说,评论抓取和详情抓取一样,都要走“先登录、后回放”。
|
||||
|
||||
## 已落库的工程推进
|
||||
|
||||
本轮已在 `apps/api` 补了天猫 live-session 与任务主链骨架:
|
||||
|
||||
- `apps/api/src/platforms/tmall/live-session.ts`
|
||||
- `apps/api/src/platforms/tmall/parsers.ts`
|
||||
- `apps/api/src/platforms/tmall/types.ts`
|
||||
- `apps/api/src/platforms/tmall/utils.ts`
|
||||
|
||||
当前能力包括:
|
||||
|
||||
1. 导入天猫 `cookieHeader`
|
||||
2. 导入评论模板 URL,并保留详情模板 URL 作为漂移观测输入
|
||||
3. 登录态搜索页改为 `s.taobao.com/search?...&tab=mall` HTML 拉取,并解析内嵌状态与候选链接
|
||||
4. 商品详情改为登录态详情页 HTML 拉取,并解析 `window.__ICE_APP_CONTEXT__` 的 SSR 内嵌状态
|
||||
5. 评论模板按新 `itemId`、页码、每页数量改写请求参数
|
||||
6. 评论回放前基于 `_m_h5_tk` 按真实 mtop 公式重算 `t/sign`
|
||||
7. 评论多页回放后做去重聚合
|
||||
8. 对登录跳转响应统一抛出 `409`,可直接接入 `NeedLogin` / `Blocked`
|
||||
9. 暴露 `live-search-preview` / `live-detail-preview` / `live-reviews-preview` / `live-product-preview` 预览入口
|
||||
10. 把天猫实时 `search/detail/reviews` 接入任务执行主链,并新增 JD + 天猫双平台 live 主回归、`Blocked -> retry success` 报告升版回归、`Blocked -> retry blocked -> report unchanged` 稳定性回归,以及 `SearchBlocked` 恢复后二次确认只补跑新恢复平台的回归
|
||||
|
||||
## 下一步建议
|
||||
|
||||
1. 用真实登录态继续固化搜索页、评论模板与样本响应,验证 `apps/api` 的 preview 接口:
|
||||
- `GET /api/platforms/tmall/live-search-preview`
|
||||
- `POST /api/platforms/tmall/live-session`
|
||||
- `GET /api/platforms/tmall/live-detail-preview`
|
||||
- `GET /api/platforms/tmall/live-reviews-preview`
|
||||
- `GET /api/platforms/tmall/live-product-preview`
|
||||
2. 抓一轮真实任务闭环:
|
||||
- 导入 live session
|
||||
- 创建任务并选中带 `itemId` 的天猫候选
|
||||
- 验证报告证据是否落到真实 `search/detail/reviews` 回放
|
||||
3. 固化第一批真实 fixture / HAR,补齐 `S0-02`
|
||||
4. 继续扩大双平台主回归包覆盖面;`SearchBlocked` / `NoResult` / `Blocked` 组合、`Blocked -> retry success` 升版、`Blocked -> retry blocked -> report unchanged`,以及 `SearchBlocked` 恢复后二次确认不重复执行已完成平台已覆盖,后续补更多失败/恢复组合与真实 fixture 驱动回归
|
||||
5. 如果模板漂移频繁,再补 `L2` 模板刷新与 `L3` 登录恢复
|
||||
|
||||
## 参考链接
|
||||
|
||||
- <https://www.tmall.com/robots.txt>
|
||||
- <https://list.tmall.com/robots.txt>
|
||||
- <https://detail.tmall.com/wow/z/app/tbpc/pc-detail-ssr-2025/home?itemId=833444005595>
|
||||
- <https://g.alicdn.com/code/npm/@ali/tbpc-pc-detail-ssr-2025/0.0.54/js/data-loader.js>
|
||||
- <https://g.alicdn.com/code/npm/@ali/tbpc-pc-detail-ssr-2025/0.0.54/js/p_home.js>
|
||||
153
docs/tasks.md
153
docs/tasks.md
@ -1,7 +1,7 @@
|
||||
# 跨平台商品聚合与 AI 分析开发任务拆解
|
||||
# 跨平台商品聚合与 AI 分析开发任务清单
|
||||
|
||||
- 文档状态:Draft
|
||||
- 版本:v0.4
|
||||
- 版本:v0.3
|
||||
- 更新时间:2026-04-02
|
||||
- 依据文档:
|
||||
- `docs/PRD.md`
|
||||
@ -9,10 +9,11 @@
|
||||
- `docs/DevelopmentPlan.md`
|
||||
- `docs/UIDesign.md`
|
||||
- `docs/tdd.md`
|
||||
- 当前进度快照基于:2026-04-02 仓库实现、`git status`、`npm run test` 与 `npm run typecheck`
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文件用于把上游产品、研发、UI 与 TDD 文档收敛为可执行的开发任务拆解,作为后续排期、分工、验收和变更同步的直接依据。项目当前进度、阶段判断与任务状态统一维护在 `TODO.md`。
|
||||
本文件用于把上游产品、研发、UI 与 TDD 文档收敛为可执行的开发任务清单,作为后续排期、分工、验收和变更同步的直接依据。
|
||||
|
||||
本文重点解决四件事:
|
||||
|
||||
@ -23,11 +24,21 @@
|
||||
|
||||
## 2. 使用说明
|
||||
|
||||
### 2.1 进度维护约定
|
||||
### 2.1 任务状态约定
|
||||
|
||||
- `docs/tasks.md` 只维护任务范围、依赖关系、主要产出、验收标准和测试门禁。
|
||||
- `TODO.md` 是项目进度的单一维护入口,负责记录阶段快照、任务状态、阻塞点和近期动作。
|
||||
- `TODO.md` 中使用的任务编号必须与本文保持一致;若任务拆分、合并或改号,两个文件必须同步更新。
|
||||
本文所有任务初始状态默认为 `未开始`。执行时建议维护以下状态:
|
||||
|
||||
| 状态 | 含义 |
|
||||
| --- | --- |
|
||||
| `未开始` | 尚未进入开发 |
|
||||
| `进行中` | 已开始执行但未达到退出条件 |
|
||||
| `阻塞` | 受外部依赖、平台策略或设计未决问题阻塞 |
|
||||
| `已完成` | 已满足本文定义的产出、验收和测试门禁 |
|
||||
|
||||
补充说明:
|
||||
|
||||
- 本文已在阶段任务与横向任务表中维护 `当前状态` 列。
|
||||
- `进行中` 表示已有骨架、样板或 mock 实现,但尚未满足该任务定义的完整产出、验收标准或测试门禁。
|
||||
|
||||
### 2.2 编号约定
|
||||
|
||||
@ -58,7 +69,7 @@
|
||||
| 人工确认不可省略 | 商品同款判断必须经过“系统召回 + 人工确认”闭环 |
|
||||
| 部分成功可交付 | 单平台失败、阻塞或无结果不应拖死整任务;`PartialCompleted` 是正式可交付状态 |
|
||||
| 报告必须可追溯 | 强结论必须引用 `evidence_ids`,并保留 90 天证据索引与摘录 |
|
||||
| 变更必须联动同步 | 上游文档或枚举变更后,需同步检查 `docs/tasks.md`、`TODO.md` 与下游实现是否失效 |
|
||||
| 变更必须联动同步 | 上游文档或枚举变更后,需同步检查 `tasks.md` 与下游实现是否失效 |
|
||||
|
||||
## 4. 开发启动门禁
|
||||
|
||||
@ -68,7 +79,6 @@
|
||||
| --- | --- |
|
||||
| 文档齐备 | `PRD`、`FeatureSummary`、`DevelopmentPlan`、`UIDesign`、`tdd`、`tasks` 已齐全 |
|
||||
| 任务拆解完成 | 本文已完成并可直接用于排期 |
|
||||
| 进度面板建立 | `TODO.md` 已创建,并可按任务编号维护当前状态、阻塞与下一步 |
|
||||
| 评审门禁 | 本文需按仓库流程完成 Claude、Gemini、Codex 三方审阅与人工终审 |
|
||||
| 平台范围冻结 | MVP 范围固定为天猫、京东;淘宝、抖音电商不进入本轮开发 |
|
||||
| 状态与报告口径冻结 | 三层状态模型、`NoSelection` 终态、`PartialCompleted` 终态、报告 Schema 已冻结 |
|
||||
@ -86,98 +96,109 @@
|
||||
| `S4` | 第 10-11 周 | 标准化、三级聚合、AI 报告、历史任务、版本管理 | `M5` |
|
||||
| `S5` | 第 12 周 | 稳定性、性能、试运行、发布准备 | `M6` |
|
||||
|
||||
### 5.1 当前阶段进度快照
|
||||
|
||||
| 阶段 | 当前状态 | 当前判断 |
|
||||
| --- | --- | --- |
|
||||
| `S0` | `进行中` | 工程骨架已搭好,但能力矩阵、fixture/HAR、PoC 验证与 `strategy_attempts` 口径未落地 |
|
||||
| `S1` | `进行中` | 共享领域模型、报告 Schema、核心路由已落地;持久化、队列、真实 `SSE`、完整会话中心仍未完成 |
|
||||
| `S2` | `进行中` | 候选确认与最小闭环可演示,但搜索/详情/评论/标准化仍以 mock 为主 |
|
||||
| `S3` | `进行中` | 恢复页、双平台工作台、`PartialCompleted` 与平台级重试已具备雏形;`L2` 模板刷新和双平台回归包未开始 |
|
||||
| `S4` | `进行中` | 报告版本规则、历史任务页、版本切换、删除入口已落地;完整聚合、真正 AI 报告、留存清理与审计未完成 |
|
||||
| `S5` | `未开始` | 稳定性、性能、UAT、部署与发布准备尚未进入实施 |
|
||||
|
||||
## 6. 阶段任务清单
|
||||
|
||||
### 6.1 `S0` 双平台可行性勘探与方案冻结
|
||||
|
||||
阶段目标:在正式编码前,验证双平台非浏览器主路径可行、服务端受控浏览器可接管、能力矩阵和工程方案可落地。
|
||||
|
||||
| 编号 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `S0-01` | 冻结双平台能力矩阵 | 产品、平台、自动化 | `PRD`、`FeatureSummary`、`DevelopmentPlan` | 天猫/京东 `search/detail/reviews/login` 能力矩阵,`search_requirement`,默认路径与降级路径 | 每个平台都明确 `L0/L1/L2/L3` 路径、登录依赖、阻塞分类、可接受兜底成本 | fixture 解析测试、能力矩阵契约测试 |
|
||||
| `S0-02` | 产出双平台首批 fixture 与 HAR 样本 | 平台、自动化、QA | `S0-01` | 搜索、详情、评论、阻塞场景样本;最小 HAR 回放样本 | 双平台至少覆盖“正常返回、无结果、需登录、抓取失败、样本不足”五类样本 | HAR 回放测试、fixture 冒烟测试 |
|
||||
| `S0-03` | 验证服务端受控浏览器与会话快照 PoC | 自动化、后端 | `UIDesign`、`DevelopmentPlan` | 远程浏览器接管 PoC、会话快照保存与恢复 PoC | 用户可完成一次远程登录,系统可加密保存并复用会话,完成后可回跳来源页 | 浏览器接管冒烟测试、会话快照读写测试 |
|
||||
| `S0-04` | 验证至少一个平台的非浏览器主路径 PoC | 平台、后端 | `S0-01`、`S0-02` | 至少一个平台的 `search/detail/reviews` 非浏览器主路径 PoC | 至少一个平台满足“搜索成功率 >= 80%、详情字段完整率 >= 85%、50 条评论耗时 <= 90s” | PoC 性能基准测试、路径降级测试 |
|
||||
| `S0-05` | 搭建 Monorepo 与基础工程骨架 | 后端、前端 | `DevelopmentPlan` | `pnpm workspace + Turborepo`、应用目录、共享包目录、统一脚本 | `apps/`、`packages/` 结构落地;`dev/build/test/lint` 可运行 | 构建冒烟测试、CI 骨架校验 |
|
||||
| `S0-06` | 冻结 Phase 0 量化评分表、`strategy_attempts` 记录格式与进入开发门槛 | 产品、QA、平台 | `S0-03`、`S0-04` | 量化评分表、`strategy_attempts` 最小记录格式、是否进入正式开发的结论 | 明确 `M1` 是否通过;若未通过,记录阻塞点和改造方向;PoC 路由选择、结果和耗时统计口径已冻结 | Phase 0 结果审计清单、`strategy_attempts` 契约测试 |
|
||||
| 编号 | 当前状态 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `S0-01` | `未开始` | 冻结双平台能力矩阵 | 产品、平台、自动化 | `PRD`、`FeatureSummary`、`DevelopmentPlan` | 天猫/京东 `search/detail/reviews/login` 能力矩阵,`search_requirement`,默认路径与降级路径 | 每个平台都明确 `L0/L1/L2/L3` 路径、登录依赖、阻塞分类、可接受兜底成本 | fixture 解析测试、能力矩阵契约测试 |
|
||||
| `S0-02` | `未开始` | 产出双平台首批 fixture 与 HAR 样本 | 平台、自动化、QA | `S0-01` | 搜索、详情、评论、阻塞场景样本;最小 HAR 回放样本 | 双平台至少覆盖“正常返回、无结果、需登录、抓取失败、样本不足”五类样本 | HAR 回放测试、fixture 冒烟测试 |
|
||||
| `S0-03` | `进行中` | 验证服务端受控浏览器与会话快照 PoC | 自动化、后端 | `UIDesign`、`DevelopmentPlan` | 远程浏览器接管 PoC、会话快照保存与恢复 PoC | 用户可完成一次远程登录,系统可加密保存并复用会话,完成后可回跳来源页 | 浏览器接管冒烟测试、会话快照读写测试 |
|
||||
| `S0-04` | `未开始` | 验证至少一个平台的非浏览器主路径 PoC | 平台、后端 | `S0-01`、`S0-02` | 至少一个平台的 `search/detail/reviews` 非浏览器主路径 PoC | 至少一个平台满足“搜索成功率 >= 80%、详情字段完整率 >= 85%、50 条评论耗时 <= 90s” | PoC 性能基准测试、路径降级测试 |
|
||||
| `S0-05` | `已完成` | 搭建 Monorepo 与基础工程骨架 | 后端、前端 | `DevelopmentPlan` | `pnpm workspace + Turborepo`、应用目录、共享包目录、统一脚本 | `apps/`、`packages/` 结构落地;`dev/build/test/lint` 可运行 | 构建冒烟测试、CI 骨架校验 |
|
||||
| `S0-06` | `未开始` | 冻结 Phase 0 量化评分表、`strategy_attempts` 记录格式与进入开发门槛 | 产品、QA、平台 | `S0-03`、`S0-04` | 量化评分表、`strategy_attempts` 最小记录格式、是否进入正式开发的结论 | 明确 `M1` 是否通过;若未通过,记录阻塞点和改造方向;PoC 路由选择、结果和耗时统计口径已冻结 | Phase 0 结果审计清单、`strategy_attempts` 契约测试 |
|
||||
|
||||
### 6.2 `S1` 基础骨架与任务系统
|
||||
|
||||
阶段目标:搭起可测的系统骨架,固化状态模型、会话中心、任务创建与执行框架。
|
||||
|
||||
| 编号 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `S1-01` | 共享领域模型与枚举包落地 | 后端、数据AI | `S0-05` | `task_status`、`task_stage`、`platform_status`、`execution_status`、报告 Schema 包 | Web、API、Worker 共用同一份枚举与类型定义;`NoSelection`、`PartialCompleted` 正式入模 | 枚举契约测试、报告 Schema 基础测试 |
|
||||
| `S1-02` | 数据库、事件日志与对象存储模型落地 | 后端 | `S1-01` | `tasks`、`platform_runs`、`selected_links`、`task_events`、`strategy_attempts`、`raw_*`、`normalized_*`、`report_snapshots`、`evidence_index`、`session_states`、`artifact_refs` 等表结构与迁移 | 表结构覆盖 P0 全流程;事件、策略尝试和对象存储引用可关联任务与平台执行记录 | 数据迁移测试、实体约束测试、事件表契约测试 |
|
||||
| `S1-03` | 任务编排、事件持久化与状态机骨架落地 | 后端 | `S1-01`、`S1-02` | BullMQ 队列、任务状态机、`task_events` 记录、平台并发控制、重试约束 | 可支持 `Draft -> Searching -> AwaitingConfirmation` 主路径;每次阶段/状态变更写入事件日志;`SearchBlocked` 不拖死整任务 | 状态机转移测试、事件持久化测试、队列载荷测试 |
|
||||
| `S1-04` | API / BFF、平台就绪摘要与 `SSE` 基础接口落地 | 后端 | `S1-03` | 任务创建、任务详情、候选查询、历史查询、平台 readiness 摘要、会话入口、实时事件流接口 | 前端可查询任务、平台就绪状态、订阅事件并读取平台状态 | REST 契约测试、`SSE` 事件契约测试、platform readiness 契约测试 |
|
||||
| `S1-05` | Web 工作台基础壳层与核心路由落地 | 前端、设计 | `UIDesign`、`S1-04` | 左侧导航、任务上下文头部、`TaskSpine`、基础页面路由 | 可访问 `/tasks/new`、`/tasks/:id/confirm`、`/tasks/:id/run`、`/history`、`/sessions/:platform/prepare`;基础布局与状态占位正确 | 页面路由测试、共享组件快照测试 |
|
||||
| `S1-06` | 会话中心 v1 与全局会话准备后端入口落地 | 后端、自动化、前端 | `S0-03`、`S1-02`、`S1-04` | 会话保存、过期时间、手动清理、`/sessions/:platform/prepare` 入口、来源页回跳协议 | 支持加密存储、24 小时有效期、按平台查看与清理会话;完成准备后可返回来源页并刷新状态 | 会话保存/过期/清理测试、prepare 回跳测试 |
|
||||
| `S1-07` | 新建任务页与全局会话准备入口落地 | 前端、后端、设计 | `UIDesign`、`S1-04`、`S1-05`、`S1-06` | `/tasks/new`、`Hero Composer`、`Sampling Config`、`Platform Readiness Panel`、`Recent Tasks Mini List`、全局会话准备入口 | 支持自然语言输入;默认 `per_link_limit = 100`、`task_total_limit = 500` 且允许按规则调整;正确展示 `required/recommended` 平台提示与创建前会话预热;创建成功后进入确认页 | 新建任务页交互测试、输入校验测试、prepare 入口回跳测试 |
|
||||
| `S1-08` | TDD 与 CI 基础链路落地 | QA、前端、后端 | `S0-05` | `Vitest`、`Playwright`、Schema 校验、`lint/build/test` 流水线 | 提交前与 PR 阶段的最小测试链路可运行 | CI 冒烟测试、空场景回归测试 |
|
||||
| 编号 | 当前状态 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `S1-01` | `已完成` | 共享领域模型与枚举包落地 | 后端、数据AI | `S0-05` | `task_status`、`task_stage`、`platform_status`、`execution_status`、报告 Schema 包 | Web、API、Worker 共用同一份枚举与类型定义;`NoSelection`、`PartialCompleted` 正式入模 | 枚举契约测试、报告 Schema 基础测试 |
|
||||
| `S1-02` | `未开始` | 数据库、事件日志与对象存储模型落地 | 后端 | `S1-01` | `tasks`、`platform_runs`、`selected_links`、`task_events`、`strategy_attempts`、`raw_*`、`normalized_*`、`report_snapshots`、`evidence_index`、`session_states`、`artifact_refs` 等表结构与迁移 | 表结构覆盖 P0 全流程;事件、策略尝试和对象存储引用可关联任务与平台执行记录 | 数据迁移测试、实体约束测试、事件表契约测试 |
|
||||
| `S1-03` | `进行中` | 任务编排、事件持久化与状态机骨架落地 | 后端 | `S1-01`、`S1-02` | BullMQ 队列、任务状态机、`task_events` 记录、平台并发控制、重试约束 | 可支持 `Draft -> Searching -> AwaitingConfirmation` 主路径;每次阶段/状态变更写入事件日志;`SearchBlocked` 不拖死整任务 | 状态机转移测试、事件持久化测试、队列载荷测试 |
|
||||
| `S1-04` | `进行中` | API / BFF、平台就绪摘要与 `SSE` 基础接口落地 | 后端 | `S1-03` | 任务创建、任务详情、候选查询、历史查询、平台 readiness 摘要、会话入口、实时事件流接口 | 前端可查询任务、平台就绪状态、订阅事件并读取平台状态 | REST 契约测试、`SSE` 事件契约测试、platform readiness 契约测试 |
|
||||
| `S1-05` | `已完成` | Web 工作台基础壳层与核心路由落地 | 前端、设计 | `UIDesign`、`S1-04` | 左侧导航、任务上下文头部、`TaskSpine`、基础页面路由 | 可访问 `/tasks/new`、`/tasks/:id/confirm`、`/tasks/:id/run`、`/history`、`/sessions/:platform/prepare`;基础布局与状态占位正确 | 页面路由测试、共享组件快照测试 |
|
||||
| `S1-06` | `进行中` | 会话中心 v1 与全局会话准备后端入口落地 | 后端、自动化、前端 | `S0-03`、`S1-02`、`S1-04` | 会话保存、过期时间、手动清理、`/sessions/:platform/prepare` 入口、来源页回跳协议 | 支持加密存储、24 小时有效期、按平台查看与清理会话;完成准备后可返回来源页并刷新状态 | 会话保存/过期/清理测试、prepare 回跳测试 |
|
||||
| `S1-07` | `进行中` | 新建任务页与全局会话准备入口落地 | 前端、后端、设计 | `UIDesign`、`S1-04`、`S1-05`、`S1-06` | `/tasks/new`、`Hero Composer`、`Sampling Config`、`Platform Readiness Panel`、`Recent Tasks Mini List`、全局会话准备入口 | 支持自然语言输入;默认 `per_link_limit = 100`、`task_total_limit = 500` 且允许按规则调整;正确展示 `required/recommended` 平台提示与创建前会话预热;创建成功后进入确认页 | 新建任务页交互测试、输入校验测试、prepare 入口回跳测试 |
|
||||
| `S1-08` | `进行中` | TDD 与 CI 基础链路落地 | QA、前端、后端 | `S0-05` | `Vitest`、`Playwright`、Schema 校验、`lint/build/test` 流水线 | 提交前与 PR 阶段的最小测试链路可运行 | CI 冒烟测试、空场景回归测试 |
|
||||
|
||||
### 6.3 `S2` 单平台 API 优先闭环
|
||||
|
||||
阶段目标:先在一个平台跑通完整闭环,验证搜索、确认、详情、评论、标准化与最小报告链路。
|
||||
|
||||
| 编号 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `S2-01` | 首个平台预检查与搜索适配器落地 | 平台、后端 | `S0-04`、`S1-03`、`S1-04` | 单平台 `precheck/search` 适配器、候选标准化结果、搜索阶段 `strategy_attempts` 记录 | 返回候选、`NoResult` 或 `SearchBlocked` 三类明确结果;`required/recommended` 平台搜索前行为符合既定口径;搜索路径选择与失败分类可回溯 | 搜索 fixture 测试、预检查规则测试、路由选择测试 |
|
||||
| `S2-02` | 候选确认页与确认 API 落地 | 前端、后端、设计 | `S1-05`、`S2-01` | `/tasks/:taskId/confirm` 页、确认提交接口、`SelectionBasket` | 支持单选、多选、跳过、零确认收口;进入 `NoSelection` 时不生成报告 | 候选确认 E2E、`NoSelection` 终态测试 |
|
||||
| `S2-03` | 单平台商品详情抓取链路落地 | 平台、后端 | `S2-01` | 详情采集器、原始字段留存、对象存储引用、详情阶段 `strategy_attempts` 记录 | 商品标题、价格、规格、店铺、评分、销量、抓取时间可抓取并回溯;详情抓取路径与失败分类可追溯 | 详情解析测试、原始字段留存测试、详情路由测试 |
|
||||
| `S2-04` | 单平台评论采集与抽样链路落地 | 平台、后端 | `S2-03` | 评论采集器、三桶抽样、去重与样本不足标记、评论阶段 `strategy_attempts` 记录 | 满足 `40/30/30` 规则;评论不足时正确打 `sample_insufficient` 标记;评论抓取路径与失败分类可追溯 | 评论抽样测试、去重测试、样本不足测试、评论路由测试 |
|
||||
| `S2-05` | 标准化 v1 与最小报告快照落地 | 数据AI、后端 | `S2-03`、`S2-04` | 商品/评论标准化、最小 `report_snapshot`、最小 `evidence_index` | 可生成单平台结构化摘要;关键字段统一到既定口径 | 标准化测试、最小报告 Schema 测试 |
|
||||
| `S2-06` | 单平台执行页闭环与回归包落地 | 前端、后端、QA | `S1-07`、`S2-02`、`S2-05` | 执行页单平台闭环、首条端到端回归包 | 单平台可从新建任务走到 `Completed`;`NoSelection` 可独立收口;关键路由尝试与事件日志可用于回放问题 | 单平台 E2E、`SSE` 更新测试、可访问性基础测试 |
|
||||
| 编号 | 当前状态 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `S2-01` | `进行中` | 首个平台预检查与搜索适配器落地 | 平台、后端 | `S0-04`、`S1-03`、`S1-04` | 单平台 `precheck/search` 适配器、候选标准化结果、搜索阶段 `strategy_attempts` 记录 | 返回候选、`NoResult` 或 `SearchBlocked` 三类明确结果;`required/recommended` 平台搜索前行为符合既定口径;搜索路径选择与失败分类可回溯 | 搜索 fixture 测试、预检查规则测试、路由选择测试 |
|
||||
| `S2-02` | `已完成` | 候选确认页与确认 API 落地 | 前端、后端、设计 | `S1-05`、`S2-01` | `/tasks/:taskId/confirm` 页、确认提交接口、`SelectionBasket` | 支持单选、多选、跳过、零确认收口;进入 `NoSelection` 时不生成报告 | 候选确认 E2E、`NoSelection` 终态测试 |
|
||||
| `S2-03` | `未开始` | 单平台商品详情抓取链路落地 | 平台、后端 | `S2-01` | 详情采集器、原始字段留存、对象存储引用、详情阶段 `strategy_attempts` 记录 | 商品标题、价格、规格、店铺、评分、销量、抓取时间可抓取并回溯;详情抓取路径与失败分类可追溯 | 详情解析测试、原始字段留存测试、详情路由测试 |
|
||||
| `S2-04` | `未开始` | 单平台评论采集与抽样链路落地 | 平台、后端 | `S2-03` | 评论采集器、三桶抽样、去重与样本不足标记、评论阶段 `strategy_attempts` 记录 | 满足 `40/30/30` 规则;评论不足时正确打 `sample_insufficient` 标记;评论抓取路径与失败分类可追溯 | 评论抽样测试、去重测试、样本不足测试、评论路由测试 |
|
||||
| `S2-05` | `进行中` | 标准化 v1 与最小报告快照落地 | 数据AI、后端 | `S2-03`、`S2-04` | 商品/评论标准化、最小 `report_snapshot`、最小 `evidence_index` | 可生成单平台结构化摘要;关键字段统一到既定口径 | 标准化测试、最小报告 Schema 测试 |
|
||||
| `S2-06` | `进行中` | 单平台执行页闭环与回归包落地 | 前端、后端、QA | `S1-07`、`S2-02`、`S2-05` | 执行页单平台闭环、首条端到端回归包 | 单平台可从新建任务走到 `Completed`;`NoSelection` 可独立收口;关键路由尝试与事件日志可用于回放问题 | 单平台 E2E、`SSE` 更新测试、可访问性基础测试 |
|
||||
|
||||
### 6.4 `S3` 双平台模板刷新与恢复体系
|
||||
|
||||
阶段目标:扩展到双平台,补齐阻塞恢复、模板刷新、部分成功与平台级重试。
|
||||
|
||||
| 编号 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `S3-01` | 第二平台 `precheck/search/detail/reviews` 适配器落地 | 平台、后端 | `S2-05` | 双平台搜索、详情、评论适配器 | 双平台都能返回候选、无结果或阻塞原因,且至少有一条详情与评论抓取路径可用 | 双平台 fixture 测试、适配器回放测试 |
|
||||
| `S3-02` | 模板刷新与 `L2` 路径落地 | 自动化、平台 | `S0-03`、`S3-01` | 模板刷新、动态参数补齐、策略切换逻辑 | 请求模板失效时可转入 `L2`,成功后回到 HTTP 主路径 | 路由降级测试、模板刷新冒烟测试 |
|
||||
| `S3-03` | 阻塞恢复与 `L3 Browser Recovery` 落地 | 自动化、前端、后端 | `S1-06`、`S3-02` | `/tasks/:taskId/recovery/:platform`、恢复流程、恢复后回跳 | `SearchBlocked`、`Blocked` 平台可发起恢复;恢复成功后任务继续 | 阻塞恢复 E2E、会话恢复回跳测试 |
|
||||
| `S3-04` | 双平台候选确认与执行控制台落地 | 前端、设计、后端 | `S3-01`、`S3-03` | 双平台 `Candidate Board`、`PlatformRunPanel`、`Live Event Feed` | 候选页、执行页能并列展示平台状态;新事件到达不打断当前阅读 | 双平台页面交互测试、`SSE` 并发更新测试 |
|
||||
| `S3-05` | `PartialCompleted`、`Blocked`、`Failed` 汇总规则落地 | 后端、数据AI | `S3-01`、`S3-03` | 任务汇总逻辑、平台级重试入口、`RetryablePlatformPicker` | 一个平台成功、一个平台阻塞时进入 `PartialCompleted`;已确认平台全部失败时进入 `Failed`;`NoResult` / `Skipped` 不误算为 `Completed` 或 `Failed`;仅失败/阻塞平台可重试 | 状态汇总测试、平台级重试范围测试、`NoResult/Skipped` 汇总测试 |
|
||||
| `S3-06` | 双平台主回归包落地 | QA、前端、后端 | `S3-05` | 双平台回归包与阶段验收记录 | 覆盖“一个 `SearchBlocked`、一个成功”“一个 `NoResult`、一个成功”“一个成功、一个 `Blocked`”“已确认平台全部失败”四类主场景 | 双平台 E2E、回归报告 |
|
||||
| 编号 | 当前状态 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `S3-01` | `进行中` | 第二平台 `precheck/search/detail/reviews` 适配器落地 | 平台、后端 | `S2-05` | 双平台搜索、详情、评论适配器 | 双平台都能返回候选、无结果或阻塞原因,且至少有一条详情与评论抓取路径可用 | 双平台 fixture 测试、适配器回放测试 |
|
||||
| `S3-02` | `未开始` | 模板刷新与 `L2` 路径落地 | 自动化、平台 | `S0-03`、`S3-01` | 模板刷新、动态参数补齐、策略切换逻辑 | 请求模板失效时可转入 `L2`,成功后回到 HTTP 主路径 | 路由降级测试、模板刷新冒烟测试 |
|
||||
| `S3-03` | `进行中` | 阻塞恢复与 `L3 Browser Recovery` 落地 | 自动化、前端、后端 | `S1-06`、`S3-02` | `/tasks/:taskId/recovery/:platform`、恢复流程、恢复后回跳 | `SearchBlocked`、`Blocked` 平台可发起恢复;恢复成功后任务继续 | 阻塞恢复 E2E、会话恢复回跳测试 |
|
||||
| `S3-04` | `进行中` | 双平台候选确认与执行控制台落地 | 前端、设计、后端 | `S3-01`、`S3-03` | 双平台 `Candidate Board`、`PlatformRunPanel`、`Live Event Feed` | 候选页、执行页能并列展示平台状态;新事件到达不打断当前阅读 | 双平台页面交互测试、`SSE` 并发更新测试 |
|
||||
| `S3-05` | `已完成` | `PartialCompleted`、`Blocked`、`Failed` 汇总规则落地 | 后端、数据AI | `S3-01`、`S3-03` | 任务汇总逻辑、平台级重试入口、`RetryablePlatformPicker` | 一个平台成功、一个平台阻塞时进入 `PartialCompleted`;已确认平台全部失败时进入 `Failed`;`NoResult` / `Skipped` 不误算为 `Completed` 或 `Failed`;仅失败/阻塞平台可重试 | 状态汇总测试、平台级重试范围测试、`NoResult/Skipped` 汇总测试 |
|
||||
| `S3-06` | `未开始` | 双平台主回归包落地 | QA、前端、后端 | `S3-05` | 双平台回归包与阶段验收记录 | 覆盖“一个 `SearchBlocked`、一个成功”“一个 `NoResult`、一个成功”“一个成功、一个 `Blocked`”“已确认平台全部失败”四类主场景 | 双平台 E2E、回归报告 |
|
||||
|
||||
### 6.5 `S4` 标准化、三级聚合、AI 报告与历史任务
|
||||
|
||||
阶段目标:完成报告产品化交付,包括标准化、聚合、证据索引、历史版本、留存与删除。
|
||||
|
||||
| 编号 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `S4-01` | 完整标准化与三级聚合落地 | 数据AI、后端 | `S3-01`、`S3-05` | 商品/评论标准化、链接级/平台级/跨平台级聚合视图 | 平台内多链接不强制合并;跨平台默认以平台级为主视角 | 标准化全量测试、三级聚合测试 |
|
||||
| `S4-02` | AI 结构化报告生成与版本规则落地 | 数据AI、后端 | `S4-01`、`S1-01` | `summary`、`product_snapshot`、`platform_insights`、`cross_platform_insights`、`recommendations`、`evidence_index`、`quality_flags`、`report_version` 生成规则 | 报告仅允许 `Completed` / `PartialCompleted`;强结论必须带证据;同一 `task_id` 下 `report_version` 从 `1` 递增且结果未变化不生成新版本 | 完整报告 Schema 测试、证据约束测试、版本规则测试 |
|
||||
| `S4-03` | 报告页、证据抽屉与质量标记落地 | 前端、设计 | `S4-02`、`UIDesign` | `/tasks/:taskId/report`、`InsightCard`、`EvidenceDrawer`、`QualityFlagPanel` | 先展示摘要,再支持证据下钻;异常平台使用 `execution_status` 展示 | 报告页组件测试、证据抽屉测试、a11y 测试 |
|
||||
| `S4-04` | 历史任务页、版本切换与删除入口落地 | 前端、后端 | `S4-02`、`S4-03` | `/history`、`VersionSwitcher`、搜索/筛选、删除确认、平台级重试入口、无报告任务展示 | 同一任务可切换 `report_version`;`NoSelection` / `Failed` 任务不显示为空白;历史页支持筛选、回看、删除和平台级重试入口 | 历史任务测试、版本切换测试、删除交互测试 |
|
||||
| `S4-05` | 留存、删除 API 与联动清理链路落地 | 后端、QA | `S1-02`、`S4-04` | 30/90 天清理作业、任务级删除 API、对象存储联动清理、残留审计 | 原始数据 30 天、标准化与报告 90 天;用户删除后关联数据与产物一致清理;前台删除动作与后台清理结果一致 | 留存作业测试、删除 API 契约测试、删除联动测试 |
|
||||
| `S4-06` | 完整可观测性与审计日志落地 | 后端、自动化、数据AI | `S1-02`、`S3-02`、`S4-02` | `strategy_attempts` 聚合查询、`platform_run_metrics`、`report_metrics`、`retention_metrics`、AI 请求摘要与恢复审计日志 | 能支持策略命中率、浏览器占比、重试效果、留存清理与报告质量统计;关键动作具备审计追溯能力 | 指标完整性测试、审计日志测试 |
|
||||
| 编号 | 当前状态 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `S4-01` | `进行中` | 完整标准化与三级聚合落地 | 数据AI、后端 | `S3-01`、`S3-05` | 商品/评论标准化、链接级/平台级/跨平台级聚合视图 | 平台内多链接不强制合并;跨平台默认以平台级为主视角 | 标准化全量测试、三级聚合测试 |
|
||||
| `S4-02` | `进行中` | AI 结构化报告生成与版本规则落地 | 数据AI、后端 | `S4-01`、`S1-01` | `summary`、`product_snapshot`、`platform_insights`、`cross_platform_insights`、`recommendations`、`evidence_index`、`quality_flags`、`report_version` 生成规则 | 报告仅允许 `Completed` / `PartialCompleted`;强结论必须带证据;同一 `task_id` 下 `report_version` 从 `1` 递增且结果未变化不生成新版本 | 完整报告 Schema 测试、证据约束测试、版本规则测试 |
|
||||
| `S4-03` | `进行中` | 报告页、证据抽屉与质量标记落地 | 前端、设计 | `S4-02`、`UIDesign` | `/tasks/:taskId/report`、`InsightCard`、`EvidenceDrawer`、`QualityFlagPanel` | 先展示摘要,再支持证据下钻;异常平台使用 `execution_status` 展示 | 报告页组件测试、证据抽屉测试、a11y 测试 |
|
||||
| `S4-04` | `已完成` | 历史任务页、版本切换与删除入口落地 | 前端、后端 | `S4-02`、`S4-03` | `/history`、`VersionSwitcher`、搜索/筛选、删除确认、平台级重试入口、无报告任务展示 | 同一任务可切换 `report_version`;`NoSelection` / `Failed` 任务不显示为空白;历史页支持筛选、回看、删除和平台级重试入口 | 历史任务测试、版本切换测试、删除交互测试 |
|
||||
| `S4-05` | `进行中` | 留存、删除 API 与联动清理链路落地 | 后端、QA | `S1-02`、`S4-04` | 30/90 天清理作业、任务级删除 API、对象存储联动清理、残留审计 | 原始数据 30 天、标准化与报告 90 天;用户删除后关联数据与产物一致清理;前台删除动作与后台清理结果一致 | 留存作业测试、删除 API 契约测试、删除联动测试 |
|
||||
| `S4-06` | `未开始` | 完整可观测性与审计日志落地 | 后端、自动化、数据AI | `S1-02`、`S3-02`、`S4-02` | `strategy_attempts` 聚合查询、`platform_run_metrics`、`report_metrics`、`retention_metrics`、AI 请求摘要与恢复审计日志 | 能支持策略命中率、浏览器占比、重试效果、留存清理与报告质量统计;关键动作具备审计追溯能力 | 指标完整性测试、审计日志测试 |
|
||||
|
||||
### 6.6 `S5` 稳定性、性能、试运行与发布准备
|
||||
|
||||
阶段目标:把系统从“能跑”推进到“可试运行、可排障、可热修”。
|
||||
|
||||
| 编号 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `S5-01` | 平台级定向重试稳定化 | 后端、平台、自动化 | `S3-05`、`S4-02`、`S4-04` | 失败/阻塞平台定向重试、重试幂等保护、结果差异检测接入既有版本规则 | 仅 `SearchBlocked`、`Blocked`、`Failed` 可重试;已成功平台不被误重跑;结果变化时沿用既定版本规则生成新版本 | 重试规则测试、重试范围测试、幂等性测试 |
|
||||
| `S5-02` | 性能与成本优化 | 后端、平台、数据AI | `S4-06` | 限流、并发、评论分页、缓存、浏览器占比优化 | 任务时长 `P50 <= 20 分钟`;全量浏览器兜底占比 `<= 30%` | 性能基准测试、指标对账测试 |
|
||||
| `S5-03` | UAT 与试运行任务集执行 | 产品、QA、前端、后端 | `S5-01`、`S5-02` | UAT 用例、试运行记录、问题清单 | 达到“报告可用于决策 >= 4/5”“报告采纳率 >= 70%” | 三条主链路 E2E、人工验收记录 |
|
||||
| `S5-04` | 部署、值守、排障与热修手册落地 | 后端、自动化、QA | `S4-06` | 部署说明、回滚策略、值守流程、热修策略 | 形成内部受控环境发布包;明确按平台、按能力热修方式 | 预发布冒烟测试、回滚演练 |
|
||||
| `S5-05` | 最终验收与文档同步收口 | 产品、设计、前端、后端、QA | `S5-03`、`S5-04` | P0 验收清单、偏差记录、必要文档回写 | 所有 P0 验收项通过;实现偏差已同步回上游文档 | P0 总体验收清单、文档一致性检查 |
|
||||
| 编号 | 当前状态 | 任务 | 责任角色 | 前置依赖 | 主要产出 | 验收标准 | 测试门禁 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `S5-01` | `进行中` | 平台级定向重试稳定化 | 后端、平台、自动化 | `S3-05`、`S4-02`、`S4-04` | 失败/阻塞平台定向重试、重试幂等保护、结果差异检测接入既有版本规则 | 仅 `SearchBlocked`、`Blocked`、`Failed` 可重试;已成功平台不被误重跑;结果变化时沿用既定版本规则生成新版本 | 重试规则测试、重试范围测试、幂等性测试 |
|
||||
| `S5-02` | `未开始` | 性能与成本优化 | 后端、平台、数据AI | `S4-06` | 限流、并发、评论分页、缓存、浏览器占比优化 | 任务时长 `P50 <= 20 分钟`;全量浏览器兜底占比 `<= 30%` | 性能基准测试、指标对账测试 |
|
||||
| `S5-03` | `未开始` | UAT 与试运行任务集执行 | 产品、QA、前端、后端 | `S5-01`、`S5-02` | UAT 用例、试运行记录、问题清单 | 达到“报告可用于决策 >= 4/5”“报告采纳率 >= 70%” | 三条主链路 E2E、人工验收记录 |
|
||||
| `S5-04` | `未开始` | 部署、值守、排障与热修手册落地 | 后端、自动化、QA | `S4-06` | 部署说明、回滚策略、值守流程、热修策略 | 形成内部受控环境发布包;明确按平台、按能力热修方式 | 预发布冒烟测试、回滚演练 |
|
||||
| `S5-05` | `未开始` | 最终验收与文档同步收口 | 产品、设计、前端、后端、QA | `S5-03`、`S5-04` | P0 验收清单、偏差记录、必要文档回写 | 所有 P0 验收项通过;实现偏差已同步回上游文档 | P0 总体验收清单、文档一致性检查 |
|
||||
|
||||
## 7. 横向持续任务
|
||||
|
||||
以下任务不属于单一阶段,应从 `S0` 持续到 `S5`:
|
||||
|
||||
| 编号 | 任务 | 责任角色 | 执行时机 | 产出与要求 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `X-01` | 上下游文档变更同步 | 产品、设计、前端、后端 | 任何上游文档修改后 | 检查 `PRD`、`FeatureSummary`、`DevelopmentPlan`、`UIDesign`、`tdd`、`tasks`、`TODO.md` 是否失效,必要时同步修订 |
|
||||
| `X-02` | 安全与合规检查 | 后端、自动化、QA | 每阶段出口前 | 确认不保存账号密码、会话加密、日志脱敏、仅抓取有权访问的数据 |
|
||||
| `X-03` | 测试资产维护 | QA、平台、自动化 | 每新增平台能力或异常样本时 | 补齐 fixture、HAR、UI 状态快照、报告快照样本 |
|
||||
| `X-04` | 设计一致性与可访问性检查 | 设计、前端、QA | 每个页面进入联调前 | 对照 `UIDesign.md` 检查状态语义、组件一致性、`WCAG AA`、`aria-live` |
|
||||
| `X-05` | 观测指标复盘 | 产品、后端、平台、QA | 每阶段结束时 | 复盘 `strategy_attempts`、平台成功率、浏览器兜底占比、报告质量与重试效果 |
|
||||
| 编号 | 当前状态 | 任务 | 责任角色 | 执行时机 | 产出与要求 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `X-01` | `进行中` | 上下游文档变更同步 | 产品、设计、前端、后端 | 任何上游文档修改后 | 检查 `PRD`、`FeatureSummary`、`DevelopmentPlan`、`UIDesign`、`tdd`、`tasks` 是否失效,必要时同步修订 |
|
||||
| `X-02` | `未开始` | 安全与合规检查 | 后端、自动化、QA | 每阶段出口前 | 确认不保存账号密码、会话加密、日志脱敏、仅抓取有权访问的数据 |
|
||||
| `X-03` | `进行中` | 测试资产维护 | QA、平台、自动化 | 每新增平台能力或异常样本时 | 补齐 fixture、HAR、UI 状态快照、报告快照样本 |
|
||||
| `X-04` | `进行中` | 设计一致性与可访问性检查 | 设计、前端、QA | 每个页面进入联调前 | 对照 `UIDesign.md` 检查状态语义、组件一致性、`WCAG AA`、`aria-live` |
|
||||
| `X-05` | `未开始` | 观测指标复盘 | 产品、后端、平台、QA | 每阶段结束时 | 复盘 `strategy_attempts`、平台成功率、浏览器兜底占比、报告质量与重试效果 |
|
||||
|
||||
## 8. P0 验收映射
|
||||
|
||||
@ -213,4 +234,4 @@
|
||||
|
||||
## 10. 一句话结论
|
||||
|
||||
本轮开发任务应严格围绕 P0 闭环展开:先冻结双平台能力矩阵和测试资产,再搭骨架与单平台闭环,再扩双平台与阻塞恢复,最后补齐结构化报告、历史版本、留存删除与试运行;任何偏离这条主线的需求,都不应进入当前开发排期。项目实际执行状态、阶段快照与近期优先项统一维护在 `TODO.md`。
|
||||
本轮开发任务应严格围绕 P0 闭环展开:先冻结双平台能力矩阵和测试资产,再搭骨架与单平台闭环,再扩双平台与阻塞恢复,最后补齐结构化报告、历史版本、留存删除与试运行;任何偏离这条主线的需求,都不应进入当前开发排期。当前仓库已完成的关键节点为 `S0-05`、`S1-01`、`S1-05`、`S2-02`、`S3-05`、`S4-04`,其余任务按上表继续推进。
|
||||
|
||||
@ -1,164 +0,0 @@
|
||||
{
|
||||
"detail": {
|
||||
"price": "4398.00",
|
||||
"originalPrice": "4599.00",
|
||||
"shopName": "Apple产品京东自营旗舰店",
|
||||
"venderId": null,
|
||||
"category": {
|
||||
"crumbs": [
|
||||
{
|
||||
"text": "手机通讯",
|
||||
"url": "https://list.jd.com/list.html?cat=9987"
|
||||
},
|
||||
{
|
||||
"text": "手机",
|
||||
"url": "https://list.jd.com/list.html?cat=9987,653"
|
||||
},
|
||||
{
|
||||
"text": "手机",
|
||||
"url": "https://list.jd.com/list.html?cat=9987,653,655"
|
||||
},
|
||||
{
|
||||
"text": "Apple",
|
||||
"url": "https://list.jd.com/list.html?cat=9987,653,655&ev=exbrand_14026"
|
||||
}
|
||||
]
|
||||
},
|
||||
"score": null,
|
||||
"stockState": "<strong>有货</strong>,仅剩318件",
|
||||
"mainImage": null
|
||||
},
|
||||
"reviews": {
|
||||
"total": "10000",
|
||||
"goodRate": "95%",
|
||||
"hasPictures": "500",
|
||||
"tags": [
|
||||
{
|
||||
"id": "a6614ff9bdafbea2128dc45d20b0b100##V10003T42",
|
||||
"name": "拍照效果超清晰",
|
||||
"count": "9313",
|
||||
"tagId": "a6614ff9bdafbea2128dc45d20b0b100##V10003T42",
|
||||
"type": "4",
|
||||
"canBeFiltered": true,
|
||||
"stand": "1",
|
||||
"ckeKeyWordBury": ""
|
||||
},
|
||||
{
|
||||
"id": "8d2e7cb5464835b1307971a3bd7d44ba##V10003T42",
|
||||
"name": "手感很舒服",
|
||||
"count": "8628",
|
||||
"tagId": "8d2e7cb5464835b1307971a3bd7d44ba##V10003T42",
|
||||
"type": "4",
|
||||
"canBeFiltered": true,
|
||||
"stand": "1",
|
||||
"ckeKeyWordBury": ""
|
||||
},
|
||||
{
|
||||
"id": "3b5c16cc91d6a145455fd72ce2ce5821##V10003T42",
|
||||
"name": "运行超顺畅",
|
||||
"count": "10964",
|
||||
"tagId": "3b5c16cc91d6a145455fd72ce2ce5821##V10003T42",
|
||||
"type": "4",
|
||||
"canBeFiltered": true,
|
||||
"stand": "1",
|
||||
"ckeKeyWordBury": ""
|
||||
},
|
||||
{
|
||||
"id": "e887749c7317607fe07419e70b089ae4##V10003T42",
|
||||
"name": "音质超清晰",
|
||||
"count": "3499",
|
||||
"tagId": "e887749c7317607fe07419e70b089ae4##V10003T42",
|
||||
"type": "4",
|
||||
"canBeFiltered": true,
|
||||
"stand": "1",
|
||||
"ckeKeyWordBury": ""
|
||||
},
|
||||
{
|
||||
"id": "ce73a03791233de43c7e7be4531bac7c##V10003T42",
|
||||
"name": "续航超久",
|
||||
"count": "3235",
|
||||
"tagId": "ce73a03791233de43c7e7be4531bac7c##V10003T42",
|
||||
"type": "4",
|
||||
"canBeFiltered": true,
|
||||
"stand": "1",
|
||||
"ckeKeyWordBury": ""
|
||||
},
|
||||
{
|
||||
"id": "23adf3c87035879818b8d94b8923b314##V10003T42",
|
||||
"name": "尺寸正合适",
|
||||
"count": "1421",
|
||||
"tagId": "23adf3c87035879818b8d94b8923b314##V10003T42",
|
||||
"type": "4",
|
||||
"canBeFiltered": true,
|
||||
"stand": "1",
|
||||
"ckeKeyWordBury": ""
|
||||
},
|
||||
{
|
||||
"id": "052ed349edd454ef48edc671425ef0b8##V10003T42",
|
||||
"name": "手感超轻薄",
|
||||
"count": "611",
|
||||
"tagId": "052ed349edd454ef48edc671425ef0b8##V10003T42",
|
||||
"type": "4",
|
||||
"canBeFiltered": true,
|
||||
"stand": "1",
|
||||
"ckeKeyWordBury": ""
|
||||
},
|
||||
{
|
||||
"id": "ebdfddf566c04834fc6af6711b5fa01f##V10003T42",
|
||||
"name": "颜色超惊艳",
|
||||
"count": "4627",
|
||||
"tagId": "ebdfddf566c04834fc6af6711b5fa01f##V10003T42",
|
||||
"type": "4",
|
||||
"canBeFiltered": true,
|
||||
"stand": "1",
|
||||
"ckeKeyWordBury": ""
|
||||
},
|
||||
{
|
||||
"id": "17e1740cf8bbdc1d9053f1d9813f539d##V10003T42",
|
||||
"name": "内存足够用",
|
||||
"count": "215",
|
||||
"tagId": "17e1740cf8bbdc1d9053f1d9813f539d##V10003T42",
|
||||
"type": "4",
|
||||
"canBeFiltered": true,
|
||||
"stand": "1",
|
||||
"ckeKeyWordBury": ""
|
||||
},
|
||||
{
|
||||
"id": "22f896dd0630ab079da6bb8464b1e525##V10003T42",
|
||||
"name": "用起来超方便",
|
||||
"count": "1196",
|
||||
"tagId": "22f896dd0630ab079da6bb8464b1e525##V10003T42",
|
||||
"type": "4",
|
||||
"canBeFiltered": true,
|
||||
"stand": "1",
|
||||
"ckeKeyWordBury": ""
|
||||
}
|
||||
],
|
||||
"firstComments": [
|
||||
{
|
||||
"id": "103893190162198263",
|
||||
"content": "1. 蓝色iPhone 15颜值真的高,磨砂背板手感舒服,单手握持刚刚好,女生用超合适。 2. 系统流畅不卡顿,A16芯片日常刷视频、玩游戏都很稳,用几年都没问题。 3. 4800万像素拍照很清晰,人像、夜景都在线,随手拍都好看。 4. 终于换成Type‑C接口,和安卓线通用,出门不用带多根线,太方便。 5. 灵动岛比想象实用,听歌、导航、消息提醒一目了然,体验很好。 6. 机身轻薄,做工精致,续航够用一天,整体很满意。",
|
||||
"score": null,
|
||||
"creationTime": null,
|
||||
"skuInfo": null,
|
||||
"userLevelName": null
|
||||
},
|
||||
{
|
||||
"id": "104032820164852108",
|
||||
"content": "iPhone 15黑色款质感超棒,磨砂背板高级不沾指纹,单手握持舒适。A3092国行正品,支持移动联通电信5G,双卡双待很方便。128GB日常够用,系统流畅不卡顿,拍照清晰色彩自然,续航稳定。正品保障、发货快、包装严实,性价比很高,非常满意!",
|
||||
"score": null,
|
||||
"creationTime": null,
|
||||
"skuInfo": null,
|
||||
"userLevelName": null
|
||||
},
|
||||
{
|
||||
"id": "104130640174421612",
|
||||
"content": "手机用了一段时间才来评价,非常满意!性能很流畅,玩游戏完全不卡,拍照效果比上一代提升不少,晚上拍也很清晰。续航很顶,一天一充没问题。外观也很喜欢,手感很好,值得入手!",
|
||||
"score": null,
|
||||
"creationTime": null,
|
||||
"skuInfo": null,
|
||||
"userLevelName": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -1,116 +0,0 @@
|
||||
{
|
||||
"detailUrl": "https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness&body=%7B%22skuId%22:%22100068388533%22,%22area%22:%222_2813_61125_0%22,%22num%22:%221%22,%22sfTime%22:%221,0,0%22%7D&h5st=20260402192316556%3Bzeeiin55neev5eb0%3Bfb5df%3Btk03w90b31b8b18n0vDkY0Up5kImRN4qbO-R46kQbfNV2YCXdBftznTRsbwGi8T21NtA7hxg7EHe_oQLCWr2Njw-l3GF%3Bd3d3ba510ea39bda6817f4ebadb30cfad400fa3a456df747fc1a2844d9dd9347%3B5.3%3B1775128992556%3Bq3EpJLIg6zpfJrJdJnYOJipjLDrgJrITGWIOGWFQ2XIQCGVSGCEjLDIj7SFjLrJp-LYfLDIj9e1TJrpjh7JjGaYd5nYe6bVfFWFS1jYdKmoSybYe4ToS7jVe0XFS0TFjLDIj7SnQEiVS0ipjLDrgJf4TIWoSyXFS4bISzXVS2bVd4nIeFOYf6jlS2TVSHO4TJrJdJfUT1yVTIipjLDrgJrIjLDIj3XETJrpjh7Jj5z5f9XIOMWlQCS1U2LFjLDIj1ipjLDrgJHWT4XIV6fnepipjxjZQ8aFQKiEjLrJp-jJf4zVRUipjxjJS7ipjLDrguqpjhjJPlipjxj5f6XETJrpjLrJp-fojxjZf6XETJrpjLrJp-bYfLDIj7nYOJipjLrpjh7pd_rJdJjYf2iFjLrpjLDrgJj4WjaUV4bVPVOYOHipjxjZQ8aFQKiEjLrpjLDrg7rJdJLYOJipjLrpjh7pfLDIj0XETJrpjLrJp-fIfLDIj1XETJrpjLrJp-rojxjZe2iFjLrpjLDrg7rJdJbYOJipjLrpjh7pd4rJdJfYOJipjLrpjh7JjyzZf9rIjLDIj6XETJrpjLrJp-rojxj5R0ipjLrpjh7ZeLDIj46FjLrpjLDrg7rJdJ7FjLrpjLDrg7rJdJb1OJrpjLrJpwqJdJbFQGakNGipjLDrguqpjhjpf2jldzHIe4bISKi1eMaFRJrJdJjoPJrpjLrJpwqJdJrkPJrpjh7Jj3ToNL-oe1zVRUq5d7zpf6rpWdq5P0ulS9G1WJrJdJnVO4ipjLD7N%3B4b258a50a1be927035558871cc8e5c8659eb2926e1e08d21567f06a9d6f4beff%3BqbkgHGHQ8GlOIyVOF6JQ8G1P5WFW3yVSC61T-bEQGGlQI6ZNHuFT-bVR7qUT&uuid=17751088581151741472086&loginType=3&appid=pc-item-soa&clientVersion=1.0.0&client=pc&t=1775128992354&x-api-eid-token=jdd035VXPJVT43M4XT6R5HFBY2GG3MEWIW4W3SPD5A3DRRMHP3BAJPMVGDGOGPZXHYWUDBFQS6BC5NBEHIZY7RVWPLZXOUAAAAAM5JXXOLIYAAAAADBEOGVOOOTXSUYX",
|
||||
"reviewsUrl": "https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22shopType%22:%220%22,%22sku%22:100068388533,%22commentNum%22:5,%22source%22:%22pc%22%7D&h5st=20260402192316866%3Bzeeiin55neev5eb0%3Bfb5df%3Btk03w90b31b8b18n0vDkY0Up5kImRN4qbO-R46kQbfNV2YCXdBftznTRsbwGi8T21NtA7hxg7EHe_oQLCWr2Njw-l3GF%3B1d5fa81d2385429ff1387bc9335fbb0e8aecd52a696803de7ce08b28df318915%3B5.3%3B1775128992866%3Bq3EpJLIg6zpfJrJdJnYOJipjLDrgJrITGWIOGWFQ2XIQCGVSGCEjLDIj7SFjLrJp-LYfLDIj9e1TJrpjh7JjGaYd5nYe6bVfFWFS1jYdKmoSybYe4ToS7jVe0XFS0TFjLDIj7SnQEiVS0ipjLDrgJf4TIWoSyXFS4bISzXVS2bVd4nIeFOYf6jlS2TVSHO4TJrJdJfUT1yVTIipjLDrgJrIjLDIj3XETJrpjh7Jj5z5f9XIOMWlQCS1U2LFjLDIj1ipjLDrgJLkS4L0R3XEelipjxjZQ8aFQKiEjLrJp-jJf4zVRUipjxjJS7ipjLDrguqpjhjJPlipjxj5f6XETJrpjLrJp-fojxjZf6XETJrpjLrJp-bYfLDIj7nYOJipjLrpjh7pd_rJdJjYf2iFjLrpjLDrgJfld2uVZ4DVg9ekV4jpjxjZQ8aFQKiEjLrpjLDrg7rJdJLYOJipjLrpjh7pfLDIj0XETJrpjLrJp-fIfLDIj1XETJrpjLrJp-rojxjZe2iFjLrpjLDrg7rJdJbYOJipjLrpjh7Ze4rJdJfYOJipjLrpjh7JjyzZf9rIjLDIj6XETJrpjLrJp-rojxj5R0ipjLrpjh7ZeLDIj46FjLrpjLDrg7rJdJ7FjLrpjLDrg7rJdJb1OJrpjLrJpwqJdJbFQGakNGipjLDrguqpjhjpf2jldzHIe4bISKi1eMaFRJrJdJjoPJrpjLrJpwqJdJrkPJrpjh7Jj3ToNL-oe1zVRUq5d7zpf6rpWdq5P0ulS9G1WJrJdJnVO4ipjLD7N%3Be3b7535fd3467ee531b4c1ca4455bdb932c6d1c84d7527401caba6189c043074%3BqbkgHGHQ8GlOIyVOF6JQ8G1P5WFW3yVSC61T-bEQGGlQI6ZNHuFT-bVR7qUT&uuid=17751088581151741472086&loginType=3&appid=item-v3&clientVersion=1.0.0&client=pc&t=1775128992862&x-api-eid-token=jdd035VXPJVT43M4XT6R5HFBY2GG3MEWIW4W3SPD5A3DRRMHP3BAJPMVGDGOGPZXHYWUDBFQS6BC5NBEHIZY7RVWPLZXOUAAAAAM5JXXOLIYAAAAADBEOGVOOOTXSUYX&build=100000",
|
||||
"detailTopKeys": [
|
||||
"userInfo",
|
||||
"productAttributeVO",
|
||||
"laXinInfo",
|
||||
"commentNoticeVO",
|
||||
"promiseFxgInfo",
|
||||
"beltBanner",
|
||||
"bestPromotion",
|
||||
"csfhText",
|
||||
"isInstallNow",
|
||||
"preferenceVO",
|
||||
"price",
|
||||
"ipCityCode",
|
||||
"pageConfigVO",
|
||||
"isSfkd",
|
||||
"beltBannerInfo",
|
||||
"ext",
|
||||
"isServiceJdkd",
|
||||
"contentTabSwitchVO",
|
||||
"qualityLifeVO",
|
||||
"itemShopInfo",
|
||||
"bottomBtnVO",
|
||||
"isSamMember",
|
||||
"skuHeadVO",
|
||||
"carButlerVO",
|
||||
"brandVipInfo",
|
||||
"pkVO",
|
||||
"isFsAndCustom",
|
||||
"urlRedirectVO",
|
||||
"colorSizeVO",
|
||||
"isLogin",
|
||||
"commonLimitInfo",
|
||||
"govSupportInfo",
|
||||
"corpHighInfo",
|
||||
"wareInfoReadMap",
|
||||
"serviceTagsVO",
|
||||
"isCsfh",
|
||||
"warePriceGatherVO",
|
||||
"stepperVO",
|
||||
"abData",
|
||||
"rankInfoList",
|
||||
"stockVO",
|
||||
"wareInfo",
|
||||
"addCartVO",
|
||||
"govTextSwitch",
|
||||
"mainImageVO",
|
||||
"bybtInfo",
|
||||
"stockInfo",
|
||||
"commonConfig",
|
||||
"isPlusMember",
|
||||
"isJdkd",
|
||||
"brandForCartAnimationVO",
|
||||
"crumbInfoVO",
|
||||
"isJdwl",
|
||||
"promotion",
|
||||
"tracerId"
|
||||
],
|
||||
"detailDataKeys": [],
|
||||
"reviewTopKeys": [
|
||||
"newStyleText",
|
||||
"canConsultFlag",
|
||||
"commentFloorShowNum",
|
||||
"semanticTagList",
|
||||
"isAllDefaultGoodComment",
|
||||
"styleType",
|
||||
"showCommentFloor",
|
||||
"normalCnt",
|
||||
"commentButtonText",
|
||||
"questionList",
|
||||
"allCnt",
|
||||
"lastCommentInfoList",
|
||||
"showQualityComment",
|
||||
"allCntStr",
|
||||
"showPicCnt",
|
||||
"noQuestionText",
|
||||
"biData",
|
||||
"noCommentText",
|
||||
"goodCnt",
|
||||
"pictureCnt",
|
||||
"cardStyle",
|
||||
"defaultGoodCountText",
|
||||
"isShowBuyersShow",
|
||||
"abTest",
|
||||
"goodRate",
|
||||
"code",
|
||||
"commentInfoList",
|
||||
"showLastComment",
|
||||
"tagStatisticsinfoList",
|
||||
"isShowConsultBtn",
|
||||
"questionGeneralInfo",
|
||||
"commentIconInfo",
|
||||
"isShowYoutuShaituBtn",
|
||||
"sensitiveBook",
|
||||
"currentSkuStatus",
|
||||
"commentVideoInfoList",
|
||||
"isShowCommentBtn",
|
||||
"badCnt",
|
||||
"showTag",
|
||||
"consultBtnName",
|
||||
"mdKey",
|
||||
"youTuOrShaiTu",
|
||||
"videoAutoPlaySwitch",
|
||||
"consultationCount",
|
||||
"newCommentStyle",
|
||||
"showUgc",
|
||||
"commentTitle",
|
||||
"testId",
|
||||
"showKoc",
|
||||
"userVideoStyleType",
|
||||
"defaultGoodLogo"
|
||||
],
|
||||
"reviewDataKeys": []
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@ -1 +0,0 @@
|
||||
[]
|
||||
@ -1 +0,0 @@
|
||||
[]
|
||||
@ -1,32 +0,0 @@
|
||||
[
|
||||
{
|
||||
"tag": "DIV",
|
||||
"sku": "10141989827074",
|
||||
"text": "广告【准新机】Apple【分期0首付】苹果15promax iPhone15pro 双卡双待 全网通 5G手机 苹果15pro 白色钛金属 256GB 全网通+质保2年+配件礼包准新手机热卖榜第2名20万+人加购|钛金属|5千+人收藏准新手机热卖榜第2名¥5008到手价¥6238首购礼金100元券满5000减630已售2万+Apple优选买手店搜同款收藏对比",
|
||||
"href": "https://chat.jd.com/index.action?entry=jd_search&pid=10141989827074&score=0&commentNum=1%25E4%25B8%2587%252B&seller=Apple%25E4%25BC%2598%25E9%2580%2589%25E4%25B9%25B0%25E6%2589%258B%25E5%25BA%2597&imgUrl=jfs%252Ft1%252F404729%252F18%252F7544%252F140284%252F69b8fb6fFea548f70%252F0083320320bc0dc6.png&stock=%25E6%259C%2589%25E8%25B4%25A7&wname=%25E3%2580%2590%25E5%2587%2586%25E6%2596%25B0%25E6%259C%25BA%25E3%2580%2591Apple%25E3%2580%2590%25E5%2588%2586%25E6%259C%259F0%25E9%25A6%2596%25E4%25BB%2598%25E3%2580%2591%25E8%258B%25B9%25E6%259E%259C15promax%2520iPhone15pro%2520%25E5%258F%258C%25E5%258D%25A1%25E5%258F%258C%25E5%25BE%2585%2520%25E5%2585%25A8%25E7%25BD%2591%25E9%2580%259A%25205G%25E6%2589%258B%25E6%259C%25BA%2520%25E8%258B%25B9%25E6%259E%259C15pro%2520%25E7%2599%25BD%25E8%2589%25B2%25E9%2592%259B%25E9%2587%2591%25E5%25B1%259E%2520256GB%2520%25E5%2585%25A8%25E7%25BD%2591%25E9%2580%259A%252B%25E8%25B4%25A8%25E4%25BF%259D2%25E5%25B9%25B4%252B%25E9%2585%258D%25E4%25BB%25B6%25E7%25A4%25BC%25E5%258C%2585"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"sku": "100068388533",
|
||||
"text": "Apple/苹果 iPhone 15 (A3092) 128GB 绿色 支持移动联通电信5G 双卡双待手机A16仿生芯片|4800万像素|灵动岛设计|5G高速领国家补贴减¥500A16仿生芯片|4800万像素|灵动岛设计|5G高速¥3898已补¥1601到店自取包邮已售500万+30天加购飙升5倍Apple产品京东自营旗舰店搜同款收藏对比",
|
||||
"href": "https://chat.jd.com/index.action?entry=jd_search&pid=100068388533&score=5&evaluationRate=95&commentNum=400%25E4%25B8%2587%252B&seller=Apple%25E4%25BA%25A7%25E5%2593%2581%25E4%25BA%25AC%25E4%25B8%259C%25E8%2587%25AA%25E8%2590%25A5%25E6%2597%2597%25E8%2588%25B0%25E5%25BA%2597&imgUrl=jfs%252Ft1%252F264511%252F37%252F12568%252F20331%252F6788b9a0F515ead43%252F0f115e62b66d5b96.jpg&stock=%25E6%259C%2589%25E8%25B4%25A7&wname=Apple%252F%25E8%258B%25B9%25E6%259E%259C%2520iPhone%252015%2520(A3092)%2520128GB%2520%25E7%25BB%25BF%25E8%2589%25B2%2520%25E6%2594%25AF%25E6%258C%2581%25E7%25A7%25BB%25E5%258A%25A8%25E8%2581%2594%25E9%2580%259A%25E7%2594%25B5%25E4%25BF%25A15G%2520%25E5%258F%258C%25E5%258D%25A1%25E5%258F%258C%25E5%25BE%2585%25E6%2589%258B%25E6%259C%25BA"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"sku": "10138987709574",
|
||||
"text": "苹果 iPhone 15 Pro可自定义焦距|操作按钮|A17 Pro|严选货源¥3109到手价¥3309券满2000减200包邮已售40万+30天种草飙升5倍拍拍二手自营旗舰店搜同款收藏对比",
|
||||
"href": "https://chat.jd.com/index.action?entry=jd_search&pid=10138987709574&score=5&evaluationRate=94&commentNum=20%25E4%25B8%2587%252B&seller=%25E6%258B%258D%25E6%258B%258D%25E4%25BA%258C%25E6%2589%258B%25E8%2587%25AA%25E8%2590%25A5%25E6%2597%2597%25E8%2588%25B0%25E5%25BA%2597&imgUrl=jfs%252Ft1%252F402845%252F19%252F9929%252F53689%252F69b38521F776dbcb2%252F00833203202323aa.png&stock=%25E6%259C%2589%25E8%25B4%25A7&wname=%25E8%258B%25B9%25E6%259E%259C%2520iPhone%252015%2520Pro"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"sku": "100167414379",
|
||||
"text": "【99成新】Apple iPhone 15 (A3092) 256GB 粉色 支持移动联通电信5G 双卡双待手机大容量存储|粉色时尚|5G网络支持|双卡双待¥3753.73已售9000+30天种草飙升5倍拍拍二手自营专区搜同款收藏对比",
|
||||
"href": "https://chat.jd.com/index.action?entry=jd_search&pid=100167414379&score=5&evaluationRate=98&commentNum=5000%252B&seller=%25E6%258B%258D%25E6%258B%258D%25E4%25BA%258C%25E6%2589%258B%25E8%2587%25AA%25E8%2590%25A5%25E4%25B8%2593%25E5%258C%25BA&imgUrl=jfs%252Ft20280330%252F410137%252F35%252F15376%252F44497%252F69cbb23eF89fa32e0%252F0a023203203428ed.jpg&stock=%25E6%259C%2589%25E8%25B4%25A7&wname=%25E3%2580%259099%25E6%2588%2590%25E6%2596%25B0%25E3%2580%2591Apple%2520iPhone%252015%2520(A3092)%2520256GB%2520%25E7%25B2%2589%25E8%2589%25B2%2520%25E6%2594%25AF%25E6%258C%2581%25E7%25A7%25BB%25E5%258A%25A8%25E8%2581%2594%25E9%2580%259A%25E7%2594%25B5%25E4%25BF%25A15G%2520%25E5%258F%258C%25E5%258D%25A1%25E5%258F%258C%25E5%25BE%2585%25E6%2589%258B%25E6%259C%25BA"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"sku": "10171791229302",
|
||||
"text": "广告【准新机】苹果【京配速发】苹果15iPhone15 系列苹果15promax 全网通5G苹果手机 苹果15 粉色 6.1寸 128G 配件礼包+店保2年45万+人浏览|超瓷晶面板|4万+人加购¥3588到手价¥6018立减2000元已售1万+柯乐家手机通讯买手店搜同款收藏对比",
|
||||
"href": "https://chat.jd.com/index.action?entry=jd_search&pid=10171791229302&score=0&commentNum=5000%252B&seller=%25E6%259F%25AF%25E4%25B9%2590%25E5%25AE%25B6%25E6%2589%258B%25E6%259C%25BA%25E9%2580%259A%25E8%25AE%25AF%25E4%25B9%25B0%25E6%2589%258B%25E5%25BA%2597&imgUrl=jfs%252Ft1%252F413656%252F16%252F1600%252F70367%252F69cceedeF005394fa%252F008332032056bc1c.jpg&stock=%25E6%259C%2589%25E8%25B4%25A7&wname=%25E3%2580%2590%25E5%2587%2586%25E6%2596%25B0%25E6%259C%25BA%25E3%2580%2591%25E8%258B%25B9%25E6%259E%259C%25E3%2580%2590%25E4%25BA%25AC%25E9%2585%258D%25E9%2580%259F%25E5%258F%2591%25E3%2580%2591%25E8%258B%25B9%25E6%259E%259C15iPhone15%2520%25E7%25B3%25BB%25E5%2588%2597%25E8%258B%25B9%25E6%259E%259C15promax%2520%25E5%2585%25A8%25E7%25BD%2591%25E9%2580%259A5G%25E8%258B%25B9%25E6%259E%259C%25E6%2589%258B%25E6%259C%25BA%2520%25E8%258B%25B9%25E6%259E%259C15%2520%25E7%25B2%2589%25E8%2589%25B2%25206.1%25E5%25AF%25B8%2520128G%2520%25E9%2585%258D%25E4%25BB%25B6%25E7%25A4%25BC%25E5%258C%2585%252B%25E5%25BA%2597%25E4%25BF%259D2%25E5%25B9%25B4"
|
||||
}
|
||||
]
|
||||
@ -1,778 +0,0 @@
|
||||
{
|
||||
"topKeys": [
|
||||
"abBuriedTagMap",
|
||||
"code",
|
||||
"data",
|
||||
"msg"
|
||||
],
|
||||
"dataKeys": [
|
||||
"resultCount",
|
||||
"hidePriceFilter",
|
||||
"listKeyWord",
|
||||
"isPlusPin",
|
||||
"exposedOptionKey",
|
||||
"photoIconShow",
|
||||
"intervalPrice",
|
||||
"HcCid3s",
|
||||
"abBuriedTag",
|
||||
"price",
|
||||
"dataBuried",
|
||||
"priceUpTypeMap",
|
||||
"advCount",
|
||||
"wareList",
|
||||
"repeatCount",
|
||||
"filterBoxMixedList",
|
||||
"area",
|
||||
"searchType",
|
||||
"logparm_expand_show",
|
||||
"showMoreMsg",
|
||||
"ip",
|
||||
"isShowCarFilter",
|
||||
"isLessRecommend",
|
||||
"Mtest",
|
||||
"viewType",
|
||||
"isKeyIdList",
|
||||
"isEnterprise",
|
||||
"page",
|
||||
"hcCid1s",
|
||||
"selceted",
|
||||
"qpstatus",
|
||||
"regCorpUrl",
|
||||
"areaProvinceName",
|
||||
"isHealth",
|
||||
"searchDirect",
|
||||
"otherFilterBoxList",
|
||||
"advShop",
|
||||
"isShowMoreJudge",
|
||||
"isLogin",
|
||||
"sortList",
|
||||
"seniorAttrList",
|
||||
"hcCid2s",
|
||||
"pageCount",
|
||||
"outputSignature",
|
||||
"isSimpleSearch",
|
||||
"pageInfo",
|
||||
"search000014_log",
|
||||
"expandName",
|
||||
"filterBoxList",
|
||||
"HcCid2s",
|
||||
"showLvTips",
|
||||
"hcCid3s",
|
||||
"resultFuzzyCount",
|
||||
"searchListType"
|
||||
],
|
||||
"wareCount": 30,
|
||||
"firstWare": {
|
||||
"actGoods": 0,
|
||||
"activityFloorDTO": null,
|
||||
"actor": null,
|
||||
"actualServ": 0,
|
||||
"addCartUrl": null,
|
||||
"advClickUrl": "",
|
||||
"advClientClickUrl": "https://ccc-x.jd.com/dsp/nc?ext=aHR0cHM6Ly9pdGVtLmpkLmNvbS8xMDE0MTk4OTgyNzA3NC5odG1s&log=_wC9ihEKbYNnMNub9qdWuThUGeiusv9CA69AS9z9LK2MYlt2y0L5f7opxMfLUHBR1ULljzJ_cUOsmY6LeWKcaHLg_IP63soEEJG1_Jj7oqLcYrvqeImrSBn_YYiYVJxpyFwy-BYZE0Yd-z9NkX8BJSCg-fhZ6PEEsfPKXween6FOILg9CYRsY9yrigEQl4DT-hJr5UoDX51tUoDQUs6nhV2kUv7lBe6ejlUQX7pTvBUXP2CIfRjdZsbrOy60__4-Dj2aSzxltxFXiv3JqutIRahFZsi7LLRNasveUa3JtGS9TN-yoiuuj2Kv3hDnhRM5ckG6bHV8Ld4TKTbGLOS9ev3nzFKCXZrWo4z1HkKSWV8TG7mWzKr2AubCTahj0ZEirlLLXoPKXGN80_IpXA6GjY7PUY8sA-pA13QcSwtWJIzysDGrckeulJratgf_VQxwrpBmQi2NFMI0wvMrmWh0p0GVNcRWGDBFQWu_yFROd_zv5BKi3NxK2lVzQ9MkxhKJkU2nJBiLGIImvRHWrFfOxgEFoHJym_8n7tGADbT7FV6F-Uf1hrjnhvSkYQRdoDevNEZmOwCLf1Fmup12OKFdpw8gdUQ3-j8mqwpGKVWcRF-nGTts4Q5Wv1ouQkxJ1511DD0tNhHhd9GZOxxm74R11uSHLzNLAZmatExi0k7CPwTTZ_-LmZmrLMBVIqVAt9Wy5Ada3D1OPRxYvYKw3muXpDPL4GDjTsBFvbnnzN6RMFDCMJqwFmjdMOoaz7WlfYzZyZjcJFdw2dqQaMyodFFDecPCUnieNGP5iy4GE49paDB3IeXa-ZS0cd7L8BU4cC_78C8r4s4ZG7NOTmG-FWXvalVQeglXq7xyoOb66yC-fVC7Zu_zSoEllK_fj2u21JfBBEFdst7hfP85vKHh1AQskWcO-u7cUFQy1zEcc1pD_f7qhF2t_GQJW_-4PwaoAPGLP1nRRTDj3QjXhLdFCdoIpee61bjRxjNU7mnBCVmyG_fIwEfBv--nE-C8NKH2I_6wSA-he-cOLPaAceKNr3lANOjmGjlTTjiOt-7Nfbj5Hc-3Z6DR0yfCBfXqpZpF_K1dZNsPhrXac08hjAxjxOcQoQ5KKqqt8SG6-JXRnoXJZClzlzixpGuKKjBEdxPuizvwVs9qXM15ozZUs3EdSbU1kOzO5iJu-hW1wjZWC3X8-0pMerqNvRqwOLqfMUPZMua94Uy4NE92gs_Yfq8bZCI1I-LYDbk52NG5ctAf7BKM5-HdpJ5boj9y_rJiJgDmjiDjiWexQixajOQ5pK0ERIIpcuMZm8pafT_Dq0aNtdTqjQMKcp42j8RQldBgtOWIzx8krWLMpkIQpMDZmX2gai2YcZ1yxf-xAfizqB52T3WTd9qOPiOyi2j45ot2x4SJ3T8SjTiPvT7pyHArOJyCZ0j-KMVhzSIpTIdhfqJc3EfUjZI&v=404&clicktype=1&&clicktype=1",
|
||||
"advShowType": "normal",
|
||||
"advanceBooking": 0,
|
||||
"adwords": "",
|
||||
"aiAttriInfo": null,
|
||||
"aiParagraphInfo": null,
|
||||
"artist": null,
|
||||
"auction": null,
|
||||
"auctionPrice": "",
|
||||
"auctionStatus": 0,
|
||||
"author": null,
|
||||
"authorList": null,
|
||||
"averageScore": "0",
|
||||
"award": "",
|
||||
"benefitList": [
|
||||
{
|
||||
"backgroundColor": "#FFF4E8",
|
||||
"color": "#994D00",
|
||||
"extInfo": null,
|
||||
"fontSize": null,
|
||||
"iconUrl": "https://img13.360buyimg.com/img/jfs/t1/258382/8/22262/871/67b68a7fF0f05a5eb/10efc95f1e57eb5b.png",
|
||||
"linkUrl": null,
|
||||
"title": [
|
||||
"准新手机热卖榜第2名"
|
||||
],
|
||||
"type": 2
|
||||
},
|
||||
{
|
||||
"backgroundColor": null,
|
||||
"color": "#888B94",
|
||||
"extInfo": null,
|
||||
"fontSize": null,
|
||||
"iconUrl": "",
|
||||
"linkUrl": null,
|
||||
"title": [
|
||||
"20万+人加购",
|
||||
"钛金属",
|
||||
"5千+人收藏"
|
||||
],
|
||||
"type": 5
|
||||
}
|
||||
],
|
||||
"bjp": 0,
|
||||
"bookId": "0",
|
||||
"bookLabels": null,
|
||||
"brand": "",
|
||||
"brandGoods": 0,
|
||||
"brandId": "",
|
||||
"bybt": 0,
|
||||
"cashFirst": "100",
|
||||
"catid": "39053",
|
||||
"cid1": "0",
|
||||
"cid2": "0",
|
||||
"clickBrand": 0,
|
||||
"color": "苹果15pro 白色钛金属",
|
||||
"comment": null,
|
||||
"commentFuzzy": "1万+",
|
||||
"commentSalesFloor": [
|
||||
{
|
||||
"attr": "totalSales",
|
||||
"text": "已售2万+"
|
||||
}
|
||||
],
|
||||
"commentcountSixM": "0",
|
||||
"conversationId": null,
|
||||
"customAttrList": null,
|
||||
"customize": 0,
|
||||
"d30ItemUvDesc": null,
|
||||
"decoration": 0,
|
||||
"deliveryDays": 0,
|
||||
"deliveryable": 1,
|
||||
"disableCart": 0,
|
||||
"discount": "",
|
||||
"drawer": null,
|
||||
"dredisprice": "",
|
||||
"ebook": 0,
|
||||
"editerList": null,
|
||||
"editor": null,
|
||||
"eredisPrice": "",
|
||||
"exposalUrl": "https://im-x.jd.com/dsp/np?log=_wC9ihEKbYNnMNub9qdWuThUGeiusv9CA69AS9z9LK2MYlt2y0L5f7opxMfLUHBR1ULljzJ_cUOsmY6LeWKcaHLg_IP63soEEJG1_Jj7oqLcYrvqeImrSBn_YYiYVJxpyFwy-BYZE0Yd-z9NkX8BJSCg-fhZ6PEEsfPKXween6FOILg9CYRsY9yrigEQl4DT-hJr5UoDX51tUoDQUs6nhV2kUv7lBe6ejlUQX7pTvBVL5MLU8d2ljuNsUlP-u1fBbqiBdG29il0aQBFxNrXLLa4Ox3tN02K4q3IFwj0y4_h-MlspnPVzOA5PU9Du4yHM72hjHmWDbXKHLNk49DdW_eBKdsHprlDgzf7qAGdL_Gr5QGvgm32qGoBQUBXv_TIsSRwxtxccNpcBjUn9HkSC5BiOCoZyy2X1kGUd3Ax-QMNNM6xuH8QoThJzvCXGeS6V2fAeAyp_jrIdhrL_bQuZer5_00BG2uGuwp6tOddXqJOqQpjdqwLNyLd_QEoaoJtKANUM4uoYBLvRpDzhofghnKrQMNzg0tK8oGv06fUcxH-mhs1_KDaamm_WcEZ5vbHiepn-VUnzmIyFlkIZuUKw9piEsTmEU_MoO6SmJZ0nvCmNnzf28PGB0JdFoXFxFpjKGYFxbdURS7wmMmU2y8ftm95uCNOFCGxdGAA1M3oQJ2JdJvZEDYtU1MOh57Sb-_2IDmHhSFton6SP5kxkERZGFGUSIoUurCKPY_oMSRv9Y7-qXl2wEDB8g1GugBCb9bWl3J3FdEka5rx1Gi4tTjEqUeGlO_y0o0BH1ELv29FckUZdF-i0ZAX2nWIPNlZD3iALshwiGRKdkqPYHhiFfKI1vmVYKzkFMlA8BL2Q5TXZy-vdI8O7vDV67opnk9h4EUGnKaEIrzqko6D05OvRdWl529B5KmpVtIwvmpFgqrWFVJ6WBWb_nMZqipanhMeQ_uMe0N7Lm3cPelWwilwB5_sBKhzHZhNuyHOCgAiWCcSkTxmb3_lDT00uUTJ_sVHhJ0u23WxdWaG4SkNqz5iE6D1ElqT5NDX9dVmP_HIYa1zqCFUkLmVCogg4BVA26QPfLMcZSWrxZSmz1s0JDC8r5QNGP2Bw9I3YJPOqXZpNQ2nS3qJJU61FonVYTZPbRI4RoIBC5Ll822Ta5gcas_vxGfqIn3C_xKCLZU78BOedz2I81bpdmozL1CRc493_Q8VT2V_7Y2s62E1I6tmFcENDsNgThQ8KJR83Long9sFljnk4T-8qLhlAaY_mXs7wkkagUTBCGvIpxPm4AlbIQbsQ-mQ8hwCZ37cQ4tXIgHoj7Gh7yXEU6z0QxS9nRp63DkZZMJ4LbYC6PvfGEabXYYlUZWv1dDhkwxUeH8zQprpuVAlRHU0zqweKR6y1Zob_Alnsu5RXvoGR2Q5bx1yYRKnb_MEtvVaFjC8RwEUWTmt3JtQnKQarDJLj4YivYViP11aWe8D9&v=404&rt=3",
|
||||
"extensionId": "{\"ad\":\"1476\",\"business_type\":\"2\",\"campaign_type\":\"2\",\"ch\":\"2\",\"organic_atrribution_type\":\"0\",\"sku\":\"10141989827074\",\"ts\":\"1775128975\",\"uniqid\":\"{\\\"material_id\\\":\\\"65954543657\\\",\\\"pos_id\\\":\\\"1476\\\",\\\"sid\\\":\\\"5d1a0a65-d97d-40a8-bc27-907d57aec3cc\\\"}\"}",
|
||||
"extra": "0",
|
||||
"farfetch": 0,
|
||||
"featuredGoods": 0,
|
||||
"finalPrice": {
|
||||
"count": 1,
|
||||
"estimatedPrice": "5008",
|
||||
"isFinal": 1,
|
||||
"subPriceType": null,
|
||||
"title": "到手价",
|
||||
"type": 1
|
||||
},
|
||||
"finalPriceJson": "{\"count\":1,\"estimatedPrice\":\"5008\",\"isFinal\":1,\"title\":\"到手价\",\"type\":1}",
|
||||
"flags": "",
|
||||
"flashBuy": 0,
|
||||
"floatLayerInfo": null,
|
||||
"floatLayerInfoJson": "",
|
||||
"followBrand": 0,
|
||||
"followShop": 0,
|
||||
"followed": 0,
|
||||
"freeFreight": 0,
|
||||
"freeHandle": 0,
|
||||
"freeMark": 0,
|
||||
"freeShipping": 0,
|
||||
"fxg": 0,
|
||||
"globalPurchaseMaster": 0,
|
||||
"good": "",
|
||||
"goodShop": 0,
|
||||
"govSubsidyBenefit": null,
|
||||
"hidePrice": 0,
|
||||
"hideShop": 0,
|
||||
"highlightContent": null,
|
||||
"hitQuery": "-1",
|
||||
"homeDelivery": 0,
|
||||
"hprice": "",
|
||||
"icoInfo": "https://m.360buyimg.com/umm/jfs/t1/407888/6/18124/2602/69c67a01F7ddca96f/015303203268fd12.png",
|
||||
"icon": 0,
|
||||
"iconList1": [],
|
||||
"iconList2": [
|
||||
{
|
||||
"borderColor": "#FFB7BD",
|
||||
"backgroundColor": "#fff",
|
||||
"code": "sglj",
|
||||
"color": "#FF0F23",
|
||||
"labelType": "1",
|
||||
"source": "customPromotion",
|
||||
"text": "首购礼金100元",
|
||||
"label": "sglj",
|
||||
"attr": "cashFirst",
|
||||
"type": "28",
|
||||
"category": "marketing",
|
||||
"class": "cashGift"
|
||||
},
|
||||
{
|
||||
"code": "promotionList",
|
||||
"source": "customPromotion",
|
||||
"text": "券满5000减630",
|
||||
"type": "24",
|
||||
"category": "marketing",
|
||||
"attrList": "promotionList",
|
||||
"tips": "本商品可领用优惠券"
|
||||
}
|
||||
],
|
||||
"iconList3": [],
|
||||
"iconList4": [],
|
||||
"imageFloatingTier": 1,
|
||||
"imageurl": "jfs/t1/404729/18/7544/140284/69b8fb6fFea548f70/0083320320bc0dc6.png",
|
||||
"insertActivity": null,
|
||||
"insertAdvertisement": null,
|
||||
"insertShop": null,
|
||||
"intervalNewPrice": null,
|
||||
"isAdv": 1,
|
||||
"isBook": 0,
|
||||
"isContrast": 1,
|
||||
"isExpand": 0,
|
||||
"isIbsWare": "0",
|
||||
"isJzfp": 0,
|
||||
"isNSNGgoods": 0,
|
||||
"isOverseaPurchase": null,
|
||||
"isPop": 0,
|
||||
"isPublicWelfare": 0,
|
||||
"isSearchEX": "",
|
||||
"isShortName": 0,
|
||||
"isbook": 0,
|
||||
"jdDelivery": 0,
|
||||
"jdMarket": 0,
|
||||
"jdPrice": "6238.00",
|
||||
"jdPriceDesc": "",
|
||||
"jdPriceFuzzy": "",
|
||||
"jdPriceText": "6238.00",
|
||||
"jdwjc": "",
|
||||
"jpComputer": 0,
|
||||
"jpDigital": 0,
|
||||
"jpElectric": 0,
|
||||
"jpPhone": 0,
|
||||
"jpStationery": 0,
|
||||
"jumpMap": null,
|
||||
"jxzy": 0,
|
||||
"jxzyNew": 0,
|
||||
"labelUrl": null,
|
||||
"limitCompanyBuy": 0,
|
||||
"localService": 0,
|
||||
"localStore": 0,
|
||||
"longImageUrl": "",
|
||||
"lowestbuy": 0,
|
||||
"mD5FreeBook": null,
|
||||
"mgsbp": 0,
|
||||
"mgsbpText": "",
|
||||
"midHighSellingPoints": null,
|
||||
"midTagList": null,
|
||||
"minThirtyPrice": "",
|
||||
"mockFinalPrice": null,
|
||||
"mtestAct": "",
|
||||
"multiSuppliers": null,
|
||||
"newRegionFloor": {
|
||||
"backgroundColor": "#FFF4E8",
|
||||
"color": "#994D00",
|
||||
"extInfo": null,
|
||||
"fontSize": null,
|
||||
"iconUrl": "https://img13.360buyimg.com/img/jfs/t1/258382/8/22262/871/67b68a7fF0f05a5eb/10efc95f1e57eb5b.png",
|
||||
"linkUrl": null,
|
||||
"title": [
|
||||
"准新手机热卖榜第2名"
|
||||
],
|
||||
"type": 2
|
||||
},
|
||||
"newRegionFloorType": 2,
|
||||
"newSeason": 0,
|
||||
"officialDiscount": "",
|
||||
"oldWare": 0,
|
||||
"orderBrand": 0,
|
||||
"orderShop": 0,
|
||||
"orderWare": 0,
|
||||
"oriPrice": "6238.00",
|
||||
"paipaiSecondHand": 0,
|
||||
"paragraphInfo": null,
|
||||
"pfc": null,
|
||||
"pickUpShop": 0,
|
||||
"pingou": 0,
|
||||
"plus95": 0,
|
||||
"plusGoodShop": 0,
|
||||
"plusLimit": 0,
|
||||
"preferred": 0,
|
||||
"presale": 0,
|
||||
"priceGuarantee": 0,
|
||||
"prize": null,
|
||||
"productId": "",
|
||||
"productUrl": "",
|
||||
"productext2": 0,
|
||||
"promotionDiscount": "",
|
||||
"promotionList": null,
|
||||
"promotionSet": {
|
||||
"belt": null,
|
||||
"couponInfo": null,
|
||||
"promotionalPrice": null,
|
||||
"promotionalWords": null
|
||||
},
|
||||
"publisher": null,
|
||||
"publishtime": -1,
|
||||
"pzqjImgUrl": null,
|
||||
"quaTrace": 0,
|
||||
"qyPrice": "",
|
||||
"qydgzz": 0,
|
||||
"qyjc": 0,
|
||||
"qyvat": 0,
|
||||
"rankInfo": {
|
||||
"rankId": "3168955",
|
||||
"rankTitle": "准新手机热卖榜第2名",
|
||||
"rankType": "10"
|
||||
},
|
||||
"rawAiImageurl": "",
|
||||
"realPrice": "6238.00",
|
||||
"recommendBook": 0,
|
||||
"regularBuy": 0,
|
||||
"sameOffLine": 0,
|
||||
"sdx": "",
|
||||
"secPriceBenefitText": null,
|
||||
"seckill": 0,
|
||||
"selfPurchase": 0,
|
||||
"selfSupport": 0,
|
||||
"sellingPoint": [
|
||||
"20万+人加购",
|
||||
"钛金属",
|
||||
"5千+人收藏"
|
||||
],
|
||||
"sendService": 0,
|
||||
"septax": 0,
|
||||
"shopId": "17015372",
|
||||
"shopName": "Apple优选买手店",
|
||||
"shoppingMalls": 0,
|
||||
"shortName": "",
|
||||
"showAddCart": 1,
|
||||
"showPurchaseList": 0,
|
||||
"singer": null,
|
||||
"skuId": "10141989827074",
|
||||
"slaveSuiteList": null,
|
||||
"slaveWareList": null,
|
||||
"slsp": 0,
|
||||
"sports": 0,
|
||||
"stock": 1,
|
||||
"streamPvId": null,
|
||||
"subsidyPrice": "",
|
||||
"superNewSeason": 0,
|
||||
"supplierSend": 0,
|
||||
"supportSubsidy": 0,
|
||||
"textLabel": "",
|
||||
"textPromotionIcon": "",
|
||||
"textWeight": "",
|
||||
"timeLimitPrice": null,
|
||||
"timeOrder": 0,
|
||||
"timeOrderType": 0,
|
||||
"totalSales": "2万+",
|
||||
"translator": null,
|
||||
"unrealExposalUrl": null,
|
||||
"venderId": "19039159",
|
||||
"voiceover": null,
|
||||
"wareBuried": {
|
||||
"extension_id": "{\"ad\":\"1476\",\"business_type\":\"2\",\"campaign_type\":\"2\",\"ch\":\"2\",\"organic_atrribution_type\":\"0\",\"sku\":\"10141989827074\",\"ts\":\"1775128975\",\"uniqid\":\"{\\\"material_id\\\":\\\"65954543657\\\",\\\"pos_id\\\":\\\"1476\\\",\\\"sid\\\":\\\"5d1a0a65-d97d-40a8-bc27-907d57aec3cc\\\"}\"}",
|
||||
"is_subphoto": "0",
|
||||
"adStatus": "1",
|
||||
"ori_price": "6238.00",
|
||||
"store_under_tag": "首购礼金100元@28#券满5000减630@24",
|
||||
"InterestType": "-100",
|
||||
"pos": 1,
|
||||
"price": "6238.00",
|
||||
"shopid": "17015372",
|
||||
"page": 1,
|
||||
"foreLabel": "-100",
|
||||
"skuid": "10141989827074",
|
||||
"subsidy_price": "",
|
||||
"mtest_act": ""
|
||||
},
|
||||
"wareBuriedJson": "{\"extension_id\":\"{\\\"ad\\\":\\\"1476\\\",\\\"business_type\\\":\\\"2\\\",\\\"campaign_type\\\":\\\"2\\\",\\\"ch\\\":\\\"2\\\",\\\"organic_atrribution_type\\\":\\\"0\\\",\\\"sku\\\":\\\"10141989827074\\\",\\\"ts\\\":\\\"1775128975\\\",\\\"uniqid\\\":\\\"{\\\\\\\"material_id\\\\\\\":\\\\\\\"65954543657\\\\\\\",\\\\\\\"pos_id\\\\\\\":\\\\\\\"1476\\\\\\\",\\\\\\\"sid\\\\\\\":\\\\\\\"5d1a0a65-d97d-40a8-bc27-907d57aec3cc\\\\\\\"}\\\"}\",\"is_subphoto\":\"0\",\"adStatus\":\"1\",\"ori_price\":\"\",\"store_under_tag\":\"-100\",\"InterestType\":\"-100\",\"pos\":1,\"price\":\"\",\"shopid\":\"17015372\",\"page\":1,\"foreLabel\":\"-100\",\"skuid\":\"10141989827074\",\"subsidy_price\":\"\",\"mtest_act\":\"\"}",
|
||||
"wareCardType": null,
|
||||
"wareExtend": null,
|
||||
"wareId": "10141989827074",
|
||||
"wareName": "【准新机】Apple【分期0首付】苹果<font class=\"skcolor_ljg\">15</font>promax <font class=\"skcolor_ljg\">iPhone15</font>pro 双卡双待 全网通 5G手机 苹果<font class=\"skcolor_ljg\">15</font>pro 白色钛金属 256GB 全网通+质保2年+配件<font class=\"skcolor_ljg\">礼包</font>",
|
||||
"wareType": 1,
|
||||
"wareTypeNew": 1,
|
||||
"warepId": "10141989827067",
|
||||
"weight": "",
|
||||
"wholeSales": "",
|
||||
"wredisPrice": "",
|
||||
"yinFengShop": 0,
|
||||
"yushouInfo": null,
|
||||
"yuyueInfo": null,
|
||||
"yuyueYushouJson": ""
|
||||
},
|
||||
"secondWare": {
|
||||
"actGoods": 0,
|
||||
"activityFloorDTO": null,
|
||||
"actor": null,
|
||||
"actualServ": 0,
|
||||
"addCartUrl": null,
|
||||
"advClickUrl": "",
|
||||
"advClientClickUrl": "",
|
||||
"advShowType": null,
|
||||
"advanceBooking": 0,
|
||||
"adwords": "",
|
||||
"aiAttriInfo": null,
|
||||
"aiParagraphInfo": null,
|
||||
"artist": null,
|
||||
"auction": null,
|
||||
"auctionPrice": "",
|
||||
"auctionStatus": 0,
|
||||
"author": null,
|
||||
"authorList": null,
|
||||
"averageScore": "5",
|
||||
"award": "",
|
||||
"benefitList": [
|
||||
{
|
||||
"backgroundColor": null,
|
||||
"color": "#888B94",
|
||||
"extInfo": null,
|
||||
"fontSize": null,
|
||||
"iconUrl": "",
|
||||
"linkUrl": null,
|
||||
"title": [
|
||||
"A16仿生芯片",
|
||||
"4800万像素",
|
||||
"灵动岛设计",
|
||||
"5G高速"
|
||||
],
|
||||
"type": 5
|
||||
},
|
||||
{
|
||||
"backgroundColor": null,
|
||||
"color": "#48A145",
|
||||
"extInfo": null,
|
||||
"fontSize": null,
|
||||
"iconUrl": "",
|
||||
"linkUrl": null,
|
||||
"title": [
|
||||
"领国家补贴减¥500"
|
||||
],
|
||||
"type": 8
|
||||
}
|
||||
],
|
||||
"bjp": 0,
|
||||
"bookId": "0",
|
||||
"bookLabels": null,
|
||||
"brand": "",
|
||||
"brandGoods": 0,
|
||||
"brandId": "",
|
||||
"bybt": 1,
|
||||
"cashFirst": "",
|
||||
"catid": "655",
|
||||
"cid1": "9987",
|
||||
"cid2": "653",
|
||||
"clickBrand": 0,
|
||||
"color": "绿色",
|
||||
"comment": "4000000",
|
||||
"commentFuzzy": "400万+",
|
||||
"commentSalesFloor": [
|
||||
{
|
||||
"attr": "totalSales",
|
||||
"text": "已售500万+"
|
||||
},
|
||||
{
|
||||
"attr": "highlightContent",
|
||||
"text": "30天加购飙升5倍"
|
||||
}
|
||||
],
|
||||
"commentcountSixM": "0",
|
||||
"conversationId": null,
|
||||
"customAttrList": [
|
||||
"A16",
|
||||
"4800万像素",
|
||||
"FHD+"
|
||||
],
|
||||
"customize": 0,
|
||||
"d30ItemUvDesc": null,
|
||||
"decoration": 0,
|
||||
"deliveryDays": 0,
|
||||
"deliveryable": 1,
|
||||
"disableCart": 0,
|
||||
"discount": "",
|
||||
"drawer": null,
|
||||
"dredisprice": "",
|
||||
"ebook": 1,
|
||||
"editerList": null,
|
||||
"editor": null,
|
||||
"eredisPrice": "",
|
||||
"exposalUrl": null,
|
||||
"extensionId": "",
|
||||
"extra": "0",
|
||||
"farfetch": 0,
|
||||
"featuredGoods": 0,
|
||||
"finalPrice": {
|
||||
"count": 1,
|
||||
"estimatedPrice": "3898",
|
||||
"isFinal": 1,
|
||||
"subPriceType": null,
|
||||
"title": "国补领后价",
|
||||
"type": 104
|
||||
},
|
||||
"finalPriceJson": "{\"count\":1,\"estimatedPrice\":\"3898\",\"isFinal\":1,\"title\":\"国补领后价\",\"type\":104}",
|
||||
"flags": "20975752",
|
||||
"flashBuy": 0,
|
||||
"floatLayerInfo": null,
|
||||
"floatLayerInfoJson": "",
|
||||
"followBrand": 0,
|
||||
"followShop": 0,
|
||||
"followed": 0,
|
||||
"freeFreight": 1,
|
||||
"freeHandle": 0,
|
||||
"freeMark": 0,
|
||||
"freeShipping": 0,
|
||||
"fxg": 0,
|
||||
"globalPurchaseMaster": 0,
|
||||
"good": "95",
|
||||
"goodShop": 0,
|
||||
"govSubsidyBenefit": {
|
||||
"amount": "500",
|
||||
"benefitRate": null,
|
||||
"benefitText": "领国家补贴减%s",
|
||||
"fundingParty": "1"
|
||||
},
|
||||
"hidePrice": 0,
|
||||
"hideShop": 0,
|
||||
"highlightContent": "30天加购飙升5倍",
|
||||
"hitQuery": "-1",
|
||||
"homeDelivery": 0,
|
||||
"hprice": "",
|
||||
"icoInfo": "https://m.360buyimg.com/umm/jfs/t1/404920/39/6832/1582/69b92331F3bd45b8c/0153032032551a8e.png",
|
||||
"icon": 0,
|
||||
"iconList1": [
|
||||
{
|
||||
"imgUrl": "//img11.360buyimg.com/imagetools/jfs/t1/274758/36/238/2219/67ce4f01Fb5fa4601/373b5ccb83c6a398.png",
|
||||
"source": "ware",
|
||||
"label": "zy",
|
||||
"text": "自营",
|
||||
"attr": "selfSupport",
|
||||
"type": "15"
|
||||
}
|
||||
],
|
||||
"iconList2": [
|
||||
{
|
||||
"imgUrl": "https://img14.360buyimg.com/img/jfs/t1/258439/22/21327/9149/67b430d7F733dce21/cac4329c8860083a.png",
|
||||
"code": "bybt",
|
||||
"labelType": "0",
|
||||
"source": "ware",
|
||||
"text": "百亿补贴",
|
||||
"label": "bybt",
|
||||
"attr": "bybt",
|
||||
"type": "35",
|
||||
"category": "strategy"
|
||||
},
|
||||
{
|
||||
"code": "ddzq",
|
||||
"source": "ware",
|
||||
"text": "到店自取",
|
||||
"attr": "pickUpShop",
|
||||
"type": "9",
|
||||
"category": "logistics-2",
|
||||
"class": "goods-icons3",
|
||||
"tips": "该商品支持到店自取"
|
||||
},
|
||||
{
|
||||
"imgUrl": "https://img10.360buyimg.com/img/jfs/t1/326301/1/3355/8762/6899db80Facef7eef/45b3214e1a10ec48.png",
|
||||
"amount": "¥500",
|
||||
"code": "gjbt",
|
||||
"tipsX": "领国家补贴减{amount}",
|
||||
"labelType": "0",
|
||||
"source": "customPromotion",
|
||||
"text": "国家补贴",
|
||||
"label": "gjbt",
|
||||
"attr": "guobu",
|
||||
"type": "33",
|
||||
"category": "marketing"
|
||||
},
|
||||
{
|
||||
"code": "freeFreight",
|
||||
"source": "ware",
|
||||
"text": "包邮",
|
||||
"attr": "freeFreight",
|
||||
"type": "22",
|
||||
"category": "service-2",
|
||||
"tips": "当前收货地址,本商品免邮费"
|
||||
}
|
||||
],
|
||||
"iconList3": [],
|
||||
"iconList4": [],
|
||||
"imageFloatingTier": 0,
|
||||
"imageurl": "jfs/t1/264511/37/12568/20331/6788b9a0F515ead43/0f115e62b66d5b96.jpg",
|
||||
"insertActivity": null,
|
||||
"insertAdvertisement": null,
|
||||
"insertShop": null,
|
||||
"intervalNewPrice": null,
|
||||
"isAdv": 0,
|
||||
"isBook": 0,
|
||||
"isContrast": 1,
|
||||
"isExpand": 0,
|
||||
"isIbsWare": "0",
|
||||
"isJzfp": 0,
|
||||
"isNSNGgoods": 0,
|
||||
"isOverseaPurchase": "0",
|
||||
"isPop": 0,
|
||||
"isPublicWelfare": 0,
|
||||
"isSearchEX": "",
|
||||
"isShortName": 1,
|
||||
"isbook": 0,
|
||||
"jdDelivery": 0,
|
||||
"jdMarket": 0,
|
||||
"jdPrice": "4398.00",
|
||||
"jdPriceDesc": "",
|
||||
"jdPriceFuzzy": "",
|
||||
"jdPriceText": "4398.00",
|
||||
"jdwjc": "",
|
||||
"jpComputer": 0,
|
||||
"jpDigital": 0,
|
||||
"jpElectric": 0,
|
||||
"jpPhone": 0,
|
||||
"jpStationery": 0,
|
||||
"jumpMap": null,
|
||||
"jxzy": 0,
|
||||
"jxzyNew": 0,
|
||||
"labelUrl": null,
|
||||
"limitCompanyBuy": 0,
|
||||
"localService": 0,
|
||||
"localStore": 0,
|
||||
"longImageUrl": "",
|
||||
"lowestbuy": 0,
|
||||
"mD5FreeBook": null,
|
||||
"mgsbp": 0,
|
||||
"mgsbpText": "",
|
||||
"midHighSellingPoints": null,
|
||||
"midTagList": null,
|
||||
"minThirtyPrice": "",
|
||||
"mockFinalPrice": null,
|
||||
"mtestAct": "2035,2017,361,2015,103,12001,301,2019,2026,2016,2029,2030,2036,2014,300",
|
||||
"multiSuppliers": null,
|
||||
"newRegionFloor": {
|
||||
"backgroundColor": null,
|
||||
"color": "#888B94",
|
||||
"extInfo": null,
|
||||
"fontSize": null,
|
||||
"iconUrl": "",
|
||||
"linkUrl": null,
|
||||
"title": [
|
||||
"A16仿生芯片",
|
||||
"4800万像素",
|
||||
"灵动岛设计",
|
||||
"5G高速"
|
||||
],
|
||||
"type": 5
|
||||
},
|
||||
"newRegionFloorType": 5,
|
||||
"newSeason": 0,
|
||||
"officialDiscount": "",
|
||||
"oldWare": 0,
|
||||
"orderBrand": 0,
|
||||
"orderShop": 0,
|
||||
"orderWare": 0,
|
||||
"oriPrice": "4599.00",
|
||||
"paipaiSecondHand": 0,
|
||||
"paragraphInfo": null,
|
||||
"pfc": null,
|
||||
"pickUpShop": 1,
|
||||
"pingou": 0,
|
||||
"plus95": 0,
|
||||
"plusGoodShop": 0,
|
||||
"plusLimit": 0,
|
||||
"preferred": 0,
|
||||
"presale": 0,
|
||||
"priceGuarantee": 0,
|
||||
"prize": null,
|
||||
"productId": "100068388533",
|
||||
"productUrl": null,
|
||||
"productext2": 324259173212684300,
|
||||
"promotionDiscount": "",
|
||||
"promotionList": null,
|
||||
"promotionSet": {
|
||||
"belt": null,
|
||||
"couponInfo": null,
|
||||
"promotionalPrice": null,
|
||||
"promotionalWords": null
|
||||
},
|
||||
"publisher": null,
|
||||
"publishtime": 0,
|
||||
"pzqjImgUrl": null,
|
||||
"quaTrace": 0,
|
||||
"qyPrice": "",
|
||||
"qydgzz": 0,
|
||||
"qyjc": 0,
|
||||
"qyvat": 0,
|
||||
"rankInfo": null,
|
||||
"rawAiImageurl": "",
|
||||
"realPrice": "4398.00",
|
||||
"recommendBook": 0,
|
||||
"regularBuy": 0,
|
||||
"sameOffLine": 0,
|
||||
"sdx": "",
|
||||
"secPriceBenefitText": null,
|
||||
"seckill": 0,
|
||||
"selfPurchase": 0,
|
||||
"selfSupport": 1,
|
||||
"sellingPoint": [
|
||||
"A16仿生芯片",
|
||||
"4800万像素",
|
||||
"灵动岛设计",
|
||||
"5G高速"
|
||||
],
|
||||
"sendService": 0,
|
||||
"septax": 0,
|
||||
"shopId": "1000000127",
|
||||
"shopName": "Apple产品京东自营旗舰店",
|
||||
"shoppingMalls": 0,
|
||||
"shortName": "",
|
||||
"showAddCart": 1,
|
||||
"showPurchaseList": 0,
|
||||
"singer": null,
|
||||
"skuId": "100068388533",
|
||||
"slaveSuiteList": null,
|
||||
"slaveWareList": null,
|
||||
"slsp": 0,
|
||||
"sports": 0,
|
||||
"stock": 1,
|
||||
"streamPvId": null,
|
||||
"subsidyPrice": "1601",
|
||||
"superNewSeason": 0,
|
||||
"supplierSend": 0,
|
||||
"supportSubsidy": 0,
|
||||
"textLabel": "",
|
||||
"textPromotionIcon": "",
|
||||
"textWeight": "130",
|
||||
"timeLimitPrice": null,
|
||||
"timeOrder": 0,
|
||||
"timeOrderType": 0,
|
||||
"totalSales": "500万+",
|
||||
"translator": null,
|
||||
"unrealExposalUrl": null,
|
||||
"venderId": "1000000127",
|
||||
"voiceover": null,
|
||||
"wareBuried": {
|
||||
"extension_id": "",
|
||||
"is_subphoto": "0",
|
||||
"adStatus": "0",
|
||||
"ori_price": "4599.00",
|
||||
"store_under_tag": "百亿补贴@35#到店自取@9#国家补贴@33#包邮@22",
|
||||
"InterestType": "-100",
|
||||
"pos": 2,
|
||||
"price": "4398.00",
|
||||
"shopid": "1000000127",
|
||||
"page": 1,
|
||||
"foreLabel": "自营@15",
|
||||
"skuid": "100068388533",
|
||||
"subsidy_price": "1601",
|
||||
"mtest_act": "2035,2017,361,2015,103,12001,301,2019,2026,2016,2029,2030,2036,2014,300"
|
||||
},
|
||||
"wareBuriedJson": "{\"extension_id\":\"\",\"is_subphoto\":\"0\",\"adStatus\":\"0\",\"ori_price\":\"\",\"store_under_tag\":\"-100\",\"InterestType\":\"-100\",\"pos\":2,\"price\":\"\",\"shopid\":\"1000000127\",\"page\":1,\"foreLabel\":\"-100\",\"skuid\":\"100068388533\",\"subsidy_price\":\"\",\"mtest_act\":\"2035,2017,361,2015,103,12001,301,2019,2026,2016,2029,2030,2036,2014,300\"}",
|
||||
"wareCardType": null,
|
||||
"wareExtend": null,
|
||||
"wareId": "100068388533",
|
||||
"wareName": "Apple/苹果 iPhone 15 (A3092) 128GB 绿色 支持移动联通电信5G 双卡双待手机",
|
||||
"wareType": 1,
|
||||
"wareTypeNew": 10,
|
||||
"warepId": "100066896214",
|
||||
"weight": "4707",
|
||||
"wholeSales": "",
|
||||
"wredisPrice": "",
|
||||
"yinFengShop": 0,
|
||||
"yushouInfo": null,
|
||||
"yuyueInfo": null,
|
||||
"yuyueYushouJson": ""
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@ -1,102 +0,0 @@
|
||||
[
|
||||
{
|
||||
"tag": "DIV",
|
||||
"className": "",
|
||||
"text": "商品上传和粘贴图片搜同款上传图片搜索Ctrl + V粘贴图片到此处上传图片搜索iphone17pro maxiphone15proiphone15pro maxiphone17proiphone14pro苹果iphone17iphone14iphone15plusiphone14promaxiphone16pro全部商品企业精选商品配送至上海徐汇区长桥街道传统模式简洁模式功能抑菌散热夜光抗指纹抗氧化抗蓝光防震护眼膜防指纹防偷窥防摔防水磁吸防磨补光防尘防爆更多展开筛选高级筛选输入电压图案场景工艺款式长度包装规格主色系成色电流主流功率MFi苹果认证快充协议氮化镓芯片贴膜神器CPU型号是否原装包装形"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"className": "_container_n3bf5_2",
|
||||
"text": "商品上传和粘贴图片搜同款上传图片搜索Ctrl + V粘贴图片到此处上传图片搜索iphone17pro maxiphone15proiphone15pro maxiphone17proiphone14pro苹果iphone17iphone14iphone15plusiphone14promaxiphone16pro全部商品企业精选商品配送至上海徐汇区长桥街道传统模式简洁模式功能抑菌散热夜光抗指纹抗氧化抗蓝光防震护眼膜防指纹防偷窥防摔防水磁吸防磨补光防尘防爆更多展开筛选高级筛选输入电压图案场景工艺款式长度包装规格主色系成色电流主流功率MFi苹果认证快充协议氮化镓芯片贴膜神器CPU型号是否原装包装形"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"className": "",
|
||||
"text": "商品上传和粘贴图片搜同款上传图片搜索Ctrl + V粘贴图片到此处上传图片搜索iphone17pro maxiphone15proiphone15pro maxiphone17proiphone14pro苹果iphone17iphone14iphone15plusiphone14promaxiphone16pro全部商品企业精选商品配送至上海徐汇区长桥街道传统模式简洁模式功能抑菌散热夜光抗指纹抗氧化抗蓝光防震护眼膜防指纹防偷窥防摔防水磁吸防磨补光防尘防爆更多展开筛选高级筛选输入电压图案场景工艺款式长度包装规格主色系成色电流主流功率MFi苹果认证快充协议氮化镓芯片贴膜神器CPU型号是否原装包装形"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"className": "",
|
||||
"text": "全部商品企业精选商品配送至上海徐汇区长桥街道传统模式简洁模式功能抑菌散热夜光抗指纹抗氧化抗蓝光防震护眼膜防指纹防偷窥防摔防水磁吸防磨补光防尘防爆更多展开筛选高级筛选输入电压图案场景工艺款式长度包装规格主色系成色电流主流功率MFi苹果认证快充协议氮化镓芯片贴膜神器CPU型号是否原装包装形式来源渠道振膜类型屏幕尺寸电池容量屏幕材质屏幕分辨率特色功能后摄主像素类型充电功率三防标准综合 销量价格价格区间 京东物流自营/旗舰新品拍拍二手京东国际货到付款仅显示有货211限时达可配送全球1/100更多广告【准新机】Apple【分期0首付】苹果15promax iPhone15pro 双卡双待 全网通 5G手"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"className": "_searchCenter_n3bf5_9",
|
||||
"text": "全部商品企业精选商品配送至上海徐汇区长桥街道传统模式简洁模式功能抑菌散热夜光抗指纹抗氧化抗蓝光防震护眼膜防指纹防偷窥防摔防水磁吸防磨补光防尘防爆更多展开筛选高级筛选输入电压图案场景工艺款式长度包装规格主色系成色电流主流功率MFi苹果认证快充协议氮化镓芯片贴膜神器CPU型号是否原装包装形式来源渠道振膜类型屏幕尺寸电池容量屏幕材质屏幕分辨率特色功能后摄主像素类型充电功率三防标准综合 销量价格价格区间 京东物流自营/旗舰新品拍拍二手京东国际货到付款仅显示有货211限时达可配送全球1/100更多广告【准新机】Apple【分期0首付】苹果15promax iPhone15pro 双卡双待 全网通 5G手"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"className": "_searchContent_n3bf5_20",
|
||||
"text": "全部商品企业精选商品配送至上海徐汇区长桥街道传统模式简洁模式功能抑菌散热夜光抗指纹抗氧化抗蓝光防震护眼膜防指纹防偷窥防摔防水磁吸防磨补光防尘防爆更多展开筛选高级筛选输入电压图案场景工艺款式长度包装规格主色系成色电流主流功率MFi苹果认证快充协议氮化镓芯片贴膜神器CPU型号是否原装包装形式来源渠道振膜类型屏幕尺寸电池容量屏幕材质屏幕分辨率特色功能后摄主像素类型充电功率三防标准综合 销量价格价格区间 京东物流自营/旗舰新品拍拍二手京东国际货到付款仅显示有货211限时达可配送全球1/100更多广告【准新机】Apple【分期0首付】苹果15promax iPhone15pro 双卡双待 全网通 5G手"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"className": "_left_n3bf5_26 _left_to_all_n3bf5_36",
|
||||
"text": "全部商品企业精选商品配送至上海徐汇区长桥街道传统模式简洁模式功能抑菌散热夜光抗指纹抗氧化抗蓝光防震护眼膜防指纹防偷窥防摔防水磁吸防磨补光防尘防爆更多展开筛选高级筛选输入电压图案场景工艺款式长度包装规格主色系成色电流主流功率MFi苹果认证快充协议氮化镓芯片贴膜神器CPU型号是否原装包装形式来源渠道振膜类型屏幕尺寸电池容量屏幕材质屏幕分辨率特色功能后摄主像素类型充电功率三防标准综合 销量价格价格区间 京东物流自营/旗舰新品拍拍二手京东国际货到付款仅显示有货211限时达可配送全球1/100更多广告【准新机】Apple【分期0首付】苹果15promax iPhone15pro 双卡双待 全网通 5G手"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"className": "_wrapper_n8i1c_18",
|
||||
"text": "广告【准新机】Apple【分期0首付】苹果15promax iPhone15pro 双卡双待 全网通 5G手机 苹果15pro 白色钛金属 256GB 全网通+质保2年+配件礼包准新手机热卖榜第2名20万+人加购|钛金属|5千+人收藏准新手机热卖榜第2名¥5008到手价¥6238首购礼金100元券满5000减630已售2万+Apple优选买手店搜同款收藏对比Apple/苹果 iPhone 15 (A3092) 128GB 绿色 支持移动联通电信5G 双卡双待手机A16仿生芯片|4800万像素|灵动岛设计|5G高速领国家补贴减¥500A16仿生芯片|4800万像素|灵动岛设计|5G高速¥3898"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"className": "_goodsContainer-box_n8i1c_2",
|
||||
"text": "广告【准新机】Apple【分期0首付】苹果15promax iPhone15pro 双卡双待 全网通 5G手机 苹果15pro 白色钛金属 256GB 全网通+质保2年+配件礼包准新手机热卖榜第2名20万+人加购|钛金属|5千+人收藏准新手机热卖榜第2名¥5008到手价¥6238首购礼金100元券满5000减630已售2万+Apple优选买手店搜同款收藏对比Apple/苹果 iPhone 15 (A3092) 128GB 绿色 支持移动联通电信5G 双卡双待手机A16仿生芯片|4800万像素|灵动岛设计|5G高速领国家补贴减¥500A16仿生芯片|4800万像素|灵动岛设计|5G高速¥3898"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"className": "_goodsContainer_n8i1c_2 plugin_goodsContainer",
|
||||
"text": "广告【准新机】Apple【分期0首付】苹果15promax iPhone15pro 双卡双待 全网通 5G手机 苹果15pro 白色钛金属 256GB 全网通+质保2年+配件礼包准新手机热卖榜第2名20万+人加购|钛金属|5千+人收藏准新手机热卖榜第2名¥5008到手价¥6238首购礼金100元券满5000减630已售2万+Apple优选买手店搜同款收藏对比Apple/苹果 iPhone 15 (A3092) 128GB 绿色 支持移动联通电信5G 双卡双待手机A16仿生芯片|4800万像素|灵动岛设计|5G高速领国家补贴减¥500A16仿生芯片|4800万像素|灵动岛设计|5G高速¥3898"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"className": "_wrapper_1v6qy_3 plugin_goodsCardWrapper _row_6_1v6qy_13",
|
||||
"text": "Apple/苹果 iPhone 15 (A3092) 128GB 绿色 支持移动联通电信5G 双卡双待手机A16仿生芯片|4800万像素|灵动岛设计|5G高速领国家补贴减¥500A16仿生芯片|4800万像素|灵动岛设计|5G高速¥3898已补¥1601到店自取包邮已售500万+30天加购飙升5倍Apple产品京东自营旗舰店搜同款收藏对比"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"className": "_card_1v6qy_62",
|
||||
"text": "Apple/苹果 iPhone 15 (A3092) 128GB 绿色 支持移动联通电信5G 双卡双待手机A16仿生芯片|4800万像素|灵动岛设计|5G高速领国家补贴减¥500A16仿生芯片|4800万像素|灵动岛设计|5G高速¥3898已补¥1601到店自取包邮已售500万+30天加购飙升5倍Apple产品京东自营旗舰店搜同款收藏对比"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"className": "_info_w_1v6qy_66",
|
||||
"text": "Apple/苹果 iPhone 15 (A3092) 128GB 绿色 支持移动联通电信5G 双卡双待手机A16仿生芯片|4800万像素|灵动岛设计|5G高速领国家补贴减¥500A16仿生芯片|4800万像素|灵动岛设计|5G高速¥3898已补¥1601到店自取包邮已售500万+30天加购飙升5倍Apple产品京东自营旗舰店搜同款收藏对比"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"className": "_info_1v6qy_66",
|
||||
"text": "Apple/苹果 iPhone 15 (A3092) 128GB 绿色 支持移动联通电信5G 双卡双待手机A16仿生芯片|4800万像素|灵动岛设计|5G高速领国家补贴减¥500A16仿生芯片|4800万像素|灵动岛设计|5G高速¥3898已补¥1601到店自取包邮已售500万+30天加购飙升5倍Apple产品京东自营旗舰店搜同款收藏对比"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"className": "_goods_title_container_1g56m_1 _clip2_1g56m_14",
|
||||
"text": "Apple/苹果 iPhone 15 (A3092) 128GB 绿色 支持移动联通电信5G 双卡双待手机"
|
||||
},
|
||||
{
|
||||
"tag": "SPAN",
|
||||
"className": "_text_1g56m_31",
|
||||
"text": "Apple/苹果 iPhone 15 (A3092) 128GB 绿色 支持移动联通电信5G 双卡双待手机"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"className": "_wrapper_1v6qy_3 plugin_goodsCardWrapper _row_6_1v6qy_13",
|
||||
"text": "苹果 iPhone 15 Pro可自定义焦距|操作按钮|A17 Pro|严选货源¥3109到手价¥3309券满2000减200包邮已售40万+30天种草飙升5倍拍拍二手自营旗舰店搜同款收藏对比"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"className": "_card_1v6qy_62",
|
||||
"text": "苹果 iPhone 15 Pro可自定义焦距|操作按钮|A17 Pro|严选货源¥3109到手价¥3309券满2000减200包邮已售40万+30天种草飙升5倍拍拍二手自营旗舰店搜同款收藏对比"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"className": "_info_w_1v6qy_66",
|
||||
"text": "苹果 iPhone 15 Pro可自定义焦距|操作按钮|A17 Pro|严选货源¥3109到手价¥3309券满2000减200包邮已售40万+30天种草飙升5倍拍拍二手自营旗舰店搜同款收藏对比"
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"className": "_info_1v6qy_66",
|
||||
"text": "苹果 iPhone 15 Pro可自定义焦距|操作按钮|A17 Pro|严选货源¥3109到手价¥3309券满2000减200包邮已售40万+30天种草飙升5倍拍拍二手自营旗舰店搜同款收藏对比"
|
||||
}
|
||||
]
|
||||
15
package-lock.json
generated
15
package-lock.json
generated
@ -27,8 +27,7 @@
|
||||
"@cross-ai/domain": "file:../../packages/domain",
|
||||
"@cross-ai/report-schema": "file:../../packages/report-schema",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"fastify": "^5.8.4",
|
||||
"playwright-core": "^1.59.1"
|
||||
"fastify": "^5.8.4"
|
||||
}
|
||||
},
|
||||
"apps/web": {
|
||||
@ -3405,18 +3404,6 @@
|
||||
"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",
|
||||
|
||||
@ -40,59 +40,10 @@ export const EvidenceSchema = z.object({
|
||||
source_type: z.enum(evidenceSourceTypes),
|
||||
source_url: z.string().url(),
|
||||
review_ref: z.string().nullable(),
|
||||
review_detail: z
|
||||
.object({
|
||||
content: z.string().min(1),
|
||||
score: z.string().nullable(),
|
||||
created_at: z.string().nullable(),
|
||||
author_label: z.string().nullable(),
|
||||
sku_labels: z.array(z.string().min(1)).optional(),
|
||||
like_count: z.string().nullable().optional(),
|
||||
reply: z.string().nullable().optional(),
|
||||
append_content: z.string().nullable().optional(),
|
||||
picture_urls: z.array(z.string().min(1)).optional(),
|
||||
video_urls: z.array(z.string().min(1)).optional(),
|
||||
append_picture_urls: z.array(z.string().min(1)).optional()
|
||||
})
|
||||
.optional(),
|
||||
snippet: z.string().min(1),
|
||||
captured_at: z.string().datetime()
|
||||
});
|
||||
|
||||
const ReviewDetailSchema = z.object({
|
||||
content: z.string().min(1),
|
||||
score: z.string().nullable(),
|
||||
created_at: z.string().nullable(),
|
||||
author_label: z.string().nullable(),
|
||||
sku_labels: z.array(z.string().min(1)).optional(),
|
||||
like_count: z.string().nullable().optional(),
|
||||
reply: z.string().nullable().optional(),
|
||||
append_content: z.string().nullable().optional(),
|
||||
picture_urls: z.array(z.string().min(1)).optional(),
|
||||
video_urls: z.array(z.string().min(1)).optional(),
|
||||
append_picture_urls: z.array(z.string().min(1)).optional()
|
||||
});
|
||||
|
||||
const ReviewCollectionCommentSchema = ReviewDetailSchema.extend({
|
||||
review_ref: z.string().min(1),
|
||||
sample_bucket: z.enum(["latest", "hot", "negative"]).nullable().optional()
|
||||
});
|
||||
|
||||
const ReviewCollectionSchema = z.object({
|
||||
collection_id: z.string().min(1),
|
||||
candidate_id: z.string().min(1),
|
||||
product_evidence_id: z.string().min(1),
|
||||
platform: z.enum(platforms),
|
||||
source_url: z.string().url(),
|
||||
title: z.string().min(1),
|
||||
store_name: z.string().nullable(),
|
||||
price_label: z.string().nullable(),
|
||||
captured_at: z.string().datetime(),
|
||||
review_count: z.number().int().nonnegative(),
|
||||
sampled_review_refs: z.array(z.string().min(1)),
|
||||
comments: z.array(ReviewCollectionCommentSchema)
|
||||
});
|
||||
|
||||
export const PlatformInsightSchema = z.object({
|
||||
platform: z.enum(platforms),
|
||||
execution_status: z.enum(executionStatuses),
|
||||
@ -135,7 +86,6 @@ export const ReportSchema = z.object({
|
||||
cross_platform_insights: z.array(InsightCardSchema),
|
||||
recommendations: z.array(InsightCardSchema),
|
||||
evidence_index: z.array(EvidenceSchema),
|
||||
review_collections: z.array(ReviewCollectionSchema).optional(),
|
||||
quality_flags: z.object({
|
||||
sample_insufficient: z.boolean(),
|
||||
partial_platform_failure: z.boolean(),
|
||||
|
||||
@ -101,75 +101,6 @@ describe("ReportSchema", () => {
|
||||
review_ref: null,
|
||||
snippet: "详情页强调 5 倍长焦与钛金属材质。",
|
||||
captured_at: "2026-04-02T11:58:00.000Z"
|
||||
},
|
||||
{
|
||||
evidence_id: "evidence-2",
|
||||
platform: "tmall",
|
||||
source_type: "review",
|
||||
source_url: "https://example.com/tmall/iphone-15-pro",
|
||||
review_ref: "comment-1",
|
||||
review_detail: {
|
||||
content: "拍照效果稳定,夜景比预期更干净。",
|
||||
score: "5",
|
||||
created_at: "2026-04-02",
|
||||
author_label: "88VIP",
|
||||
sku_labels: ["远峰蓝 / 256G"],
|
||||
like_count: "12",
|
||||
reply: "感谢支持",
|
||||
append_content: "追评:一周后续航依然稳定。",
|
||||
picture_urls: ["https://example.com/review/pic-1.jpg"],
|
||||
video_urls: [],
|
||||
append_picture_urls: []
|
||||
},
|
||||
snippet: "样本 latest | 评分 5 | 88VIP | 拍照效果稳定,夜景更干净。",
|
||||
captured_at: "2026-04-02T11:59:00.000Z"
|
||||
}
|
||||
],
|
||||
review_collections: [
|
||||
{
|
||||
collection_id: "review-collection-candidate-1",
|
||||
candidate_id: "candidate-1",
|
||||
product_evidence_id: "evidence-1",
|
||||
platform: "tmall",
|
||||
source_url: "https://example.com/tmall/iphone-15-pro",
|
||||
title: "iPhone 15 Pro",
|
||||
store_name: "Apple 官方旗舰店",
|
||||
price_label: "¥7999",
|
||||
captured_at: "2026-04-02T11:59:00.000Z",
|
||||
review_count: 2,
|
||||
sampled_review_refs: ["comment-1"],
|
||||
comments: [
|
||||
{
|
||||
review_ref: "comment-1",
|
||||
sample_bucket: "latest",
|
||||
content: "拍照效果稳定,夜景比预期更干净。",
|
||||
score: "5",
|
||||
created_at: "2026-04-02",
|
||||
author_label: "88VIP",
|
||||
sku_labels: ["远峰蓝 / 256G"],
|
||||
like_count: "12",
|
||||
reply: "感谢支持",
|
||||
append_content: "追评:一周后续航依然稳定。",
|
||||
picture_urls: ["https://example.com/review/pic-1.jpg"],
|
||||
video_urls: [],
|
||||
append_picture_urls: []
|
||||
},
|
||||
{
|
||||
review_ref: "comment-2",
|
||||
sample_bucket: null,
|
||||
content: "手感不错,续航也正常。",
|
||||
score: "4",
|
||||
created_at: "2026-04-01",
|
||||
author_label: null,
|
||||
sku_labels: [],
|
||||
like_count: null,
|
||||
reply: null,
|
||||
append_content: null,
|
||||
picture_urls: [],
|
||||
video_urls: [],
|
||||
append_picture_urls: []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
quality_flags: {
|
||||
@ -182,7 +113,6 @@ describe("ReportSchema", () => {
|
||||
|
||||
expect(report.report_version).toBe(1);
|
||||
expect(report.platform_insights).toHaveLength(2);
|
||||
expect(report.review_collections).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects a strong insight without evidence ids", () => {
|
||||
|
||||
406
start.py
406
start.py
@ -1,406 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
STATE_DIR = ROOT / ".tmp" / "dev-manager"
|
||||
STATE_FILE = STATE_DIR / "state.json"
|
||||
LOG_FILE = STATE_DIR / "dev.log"
|
||||
ERR_FILE = STATE_DIR / "dev.err.log"
|
||||
SUPERVISOR_MODE = "__serve"
|
||||
API_URL = "http://localhost:3001"
|
||||
WEB_URL = "http://localhost:5173"
|
||||
NPM_EXECUTABLE = "npm.cmd" if os.name == "nt" else "npm"
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def print_line(message: str) -> None:
|
||||
print(message, flush=True)
|
||||
|
||||
|
||||
def ensure_state_dir() -> None:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def append_log_header(path: Path, title: str) -> None:
|
||||
timestamp = now_iso()
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(f"\n[{timestamp}] {title}\n")
|
||||
|
||||
|
||||
def read_state() -> dict[str, Any] | None:
|
||||
if not STATE_FILE.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def write_state(state: dict[str, Any]) -> None:
|
||||
ensure_state_dir()
|
||||
STATE_FILE.write_text(
|
||||
json.dumps(state, ensure_ascii=True, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def remove_state(expected_token: str | None = None) -> None:
|
||||
if not STATE_FILE.exists():
|
||||
return
|
||||
|
||||
if expected_token is not None:
|
||||
current = read_state()
|
||||
if not current or current.get("token") != expected_token:
|
||||
return
|
||||
|
||||
STATE_FILE.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def process_exists(pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_process_commandline(pid: int) -> str | None:
|
||||
if os.name == "nt":
|
||||
command = [
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
(
|
||||
f'$process = Get-CimInstance Win32_Process -Filter "ProcessId = {pid}"; '
|
||||
'if ($process) { [Console]::Out.Write($process.CommandLine) }'
|
||||
)
|
||||
]
|
||||
else:
|
||||
command = ["ps", "-o", "command=", "-p", str(pid)]
|
||||
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=str(ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="ignore",
|
||||
check=False
|
||||
)
|
||||
|
||||
commandline = result.stdout.strip()
|
||||
return commandline or None
|
||||
|
||||
|
||||
def is_expected_supervisor(state: dict[str, Any]) -> bool:
|
||||
pid = int(state.get("pid", 0))
|
||||
token = str(state.get("token", "")).strip()
|
||||
if pid <= 0 or not token or not process_exists(pid):
|
||||
return False
|
||||
|
||||
commandline = get_process_commandline(pid)
|
||||
if not commandline:
|
||||
return True
|
||||
|
||||
expected_fragments = [Path(__file__).name, SUPERVISOR_MODE, token]
|
||||
return all(fragment in commandline for fragment in expected_fragments)
|
||||
|
||||
|
||||
def get_running_state() -> dict[str, Any] | None:
|
||||
state = read_state()
|
||||
if not state:
|
||||
return None
|
||||
|
||||
if not is_expected_supervisor(state):
|
||||
remove_state()
|
||||
return None
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def tail_text(path: Path, line_count: int = 20) -> str:
|
||||
if not path.exists():
|
||||
return ""
|
||||
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
lines = content.splitlines()
|
||||
if not lines:
|
||||
return ""
|
||||
|
||||
return "\n".join(lines[-line_count:])
|
||||
|
||||
|
||||
def ensure_npm_available() -> bool:
|
||||
return shutil.which(NPM_EXECUTABLE) is not None
|
||||
|
||||
|
||||
def wait_for_process_exit(pid: int, attempts: int = 20, interval: float = 0.25) -> bool:
|
||||
for _ in range(attempts):
|
||||
if not process_exists(pid):
|
||||
return True
|
||||
time.sleep(interval)
|
||||
return not process_exists(pid)
|
||||
|
||||
|
||||
def stop_process_tree(pid: int) -> bool:
|
||||
if os.name == "nt":
|
||||
graceful = subprocess.run(
|
||||
["taskkill", "/PID", str(pid), "/T"],
|
||||
cwd=str(ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False
|
||||
)
|
||||
if graceful.returncode == 0:
|
||||
if wait_for_process_exit(pid):
|
||||
return True
|
||||
|
||||
forced = subprocess.run(
|
||||
["taskkill", "/PID", str(pid), "/T", "/F"],
|
||||
cwd=str(ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False
|
||||
)
|
||||
if forced.returncode == 0:
|
||||
wait_for_process_exit(pid)
|
||||
return True
|
||||
return not process_exists(pid)
|
||||
|
||||
try:
|
||||
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
||||
except OSError:
|
||||
return not process_exists(pid)
|
||||
|
||||
if wait_for_process_exit(pid):
|
||||
return True
|
||||
|
||||
try:
|
||||
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
||||
except OSError:
|
||||
return not process_exists(pid)
|
||||
|
||||
if wait_for_process_exit(pid):
|
||||
return True
|
||||
|
||||
return not process_exists(pid)
|
||||
|
||||
|
||||
def start_supervisor() -> int:
|
||||
ensure_state_dir()
|
||||
append_log_header(LOG_FILE, "starting root dev command")
|
||||
append_log_header(ERR_FILE, "starting root dev command")
|
||||
|
||||
token = uuid.uuid4().hex
|
||||
creation_flags = 0
|
||||
if os.name == "nt":
|
||||
creation_flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
creation_flags |= getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, str(Path(__file__).resolve()), SUPERVISOR_MODE, token],
|
||||
cwd=str(ROOT),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=creation_flags,
|
||||
close_fds=True
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + 10
|
||||
state: dict[str, Any] | None = None
|
||||
while time.monotonic() < deadline:
|
||||
current = read_state()
|
||||
if current and current.get("token") == token and is_expected_supervisor(current):
|
||||
state = current
|
||||
break
|
||||
if process.poll() is not None:
|
||||
break
|
||||
time.sleep(0.2)
|
||||
|
||||
if not state:
|
||||
print_line("启动失败:后台 supervisor 没有成功创建。")
|
||||
error_tail = tail_text(ERR_FILE)
|
||||
if error_tail:
|
||||
print_line(error_tail)
|
||||
return 1
|
||||
|
||||
time.sleep(3)
|
||||
state = get_running_state()
|
||||
if not state:
|
||||
print_line("启动失败:`npm run dev` 很快退出了。")
|
||||
error_tail = tail_text(ERR_FILE)
|
||||
if error_tail:
|
||||
print_line(error_tail)
|
||||
else:
|
||||
log_tail = tail_text(LOG_FILE)
|
||||
if log_tail:
|
||||
print_line(log_tail)
|
||||
return 1
|
||||
|
||||
print_line("已启动前后端开发环境。")
|
||||
print_line(f"Web: {WEB_URL}")
|
||||
print_line(f"API: {API_URL}")
|
||||
print_line(f"状态文件: {STATE_FILE}")
|
||||
print_line(f"日志: {LOG_FILE}")
|
||||
print_line("关闭命令: python start.py stop")
|
||||
print_line("无参数执行时会自动在启动和关闭之间切换。")
|
||||
return 0
|
||||
|
||||
|
||||
def stop_supervisor() -> int:
|
||||
state = get_running_state()
|
||||
if not state:
|
||||
remove_state()
|
||||
print_line("当前没有运行中的开发环境。")
|
||||
return 0
|
||||
|
||||
pid = int(state["pid"])
|
||||
token = str(state["token"])
|
||||
stopped = stop_process_tree(pid)
|
||||
remove_state(expected_token=token)
|
||||
|
||||
if stopped:
|
||||
print_line("已关闭前后端开发环境。")
|
||||
return 0
|
||||
|
||||
print_line(f"关闭失败:无法结束 supervisor 进程 {pid}。")
|
||||
return 1
|
||||
|
||||
|
||||
def show_status() -> int:
|
||||
state = get_running_state()
|
||||
if not state:
|
||||
print_line("状态: 未运行")
|
||||
print_line("启动命令: python start.py start")
|
||||
return 0
|
||||
|
||||
print_line("状态: 运行中")
|
||||
print_line(f"Supervisor PID: {state['pid']}")
|
||||
child_pid = state.get("childPid")
|
||||
if child_pid:
|
||||
print_line(f"Root dev PID: {child_pid}")
|
||||
print_line(f"启动时间: {state['startedAt']}")
|
||||
print_line(f"Web: {state['webUrl']}")
|
||||
print_line(f"API: {state['apiUrl']}")
|
||||
print_line(f"日志: {state['logFile']}")
|
||||
print_line(f"错误日志: {state['errorLogFile']}")
|
||||
return 0
|
||||
|
||||
|
||||
def run_supervisor(token: str) -> int:
|
||||
ensure_state_dir()
|
||||
state = {
|
||||
"pid": os.getpid(),
|
||||
"token": token,
|
||||
"startedAt": now_iso(),
|
||||
"root": str(ROOT),
|
||||
"logFile": str(LOG_FILE),
|
||||
"errorLogFile": str(ERR_FILE),
|
||||
"webUrl": WEB_URL,
|
||||
"apiUrl": API_URL,
|
||||
"command": [NPM_EXECUTABLE, "run", "dev"]
|
||||
}
|
||||
write_state(state)
|
||||
|
||||
with LOG_FILE.open("a", encoding="utf-8") as stdout_handle, ERR_FILE.open(
|
||||
"a",
|
||||
encoding="utf-8"
|
||||
) as stderr_handle:
|
||||
stdout_handle.write(f"[{now_iso()}] supervisor pid={os.getpid()}\n")
|
||||
stdout_handle.flush()
|
||||
|
||||
child = subprocess.Popen(
|
||||
[NPM_EXECUTABLE, "run", "dev"],
|
||||
cwd=str(ROOT),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=stdout_handle,
|
||||
stderr=stderr_handle
|
||||
)
|
||||
|
||||
state["childPid"] = child.pid
|
||||
write_state(state)
|
||||
|
||||
try:
|
||||
return child.wait()
|
||||
finally:
|
||||
append_log_header(LOG_FILE, "root dev command stopped")
|
||||
remove_state(expected_token=token)
|
||||
|
||||
|
||||
def show_usage() -> int:
|
||||
print_line("用法: python start.py [start|stop|restart|status|toggle]")
|
||||
print_line("无参数默认执行 toggle。")
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
action = sys.argv[1].strip().lower() if len(sys.argv) > 1 else "toggle"
|
||||
|
||||
if action == SUPERVISOR_MODE:
|
||||
if len(sys.argv) < 3:
|
||||
return 1
|
||||
return run_supervisor(sys.argv[2])
|
||||
|
||||
if action == "start":
|
||||
running = get_running_state()
|
||||
if running:
|
||||
print_line("开发环境已在运行。")
|
||||
return show_status()
|
||||
if not ensure_npm_available():
|
||||
print_line("未找到 npm,请先安装 Node.js 并确认 npm 已加入 PATH。")
|
||||
return 1
|
||||
return start_supervisor()
|
||||
|
||||
if action == "stop":
|
||||
return stop_supervisor()
|
||||
|
||||
if action == "restart":
|
||||
stop_code = stop_supervisor()
|
||||
if stop_code != 0:
|
||||
return stop_code
|
||||
if not ensure_npm_available():
|
||||
print_line("未找到 npm,请先安装 Node.js 并确认 npm 已加入 PATH。")
|
||||
return 1
|
||||
return start_supervisor()
|
||||
|
||||
if action == "status":
|
||||
return show_status()
|
||||
|
||||
if action == "toggle":
|
||||
if get_running_state():
|
||||
return stop_supervisor()
|
||||
if not ensure_npm_available():
|
||||
print_line("未找到 npm,请先安装 Node.js 并确认 npm 已加入 PATH。")
|
||||
return 1
|
||||
return start_supervisor()
|
||||
|
||||
return show_usage()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -1,47 +0,0 @@
|
||||
[
|
||||
{
|
||||
"tag": "DIV",
|
||||
"text": "搜索搜索\n \n \n \n iPhone 15\n \n \n \n \n \n \n \n 宝贝\n \n \n \n \n ",
|
||||
"href": null
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"text": "搜索搜索\n \n \n \n iPhone 15\n \n \n \n \n \n \n \n 宝贝\n \n \n \n \n ",
|
||||
"href": null
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"text": "搜索搜索\n \n \n \n iPhone 15\n \n \n \n \n \n \n \n 宝贝\n \n \n \n \n ",
|
||||
"href": null
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"text": "搜索搜索\n \n \n \n iPhone 15\n \n \n \n \n \n \n \n 宝贝\n \n \n \n \n ",
|
||||
"href": null
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"text": "搜索\n \n \n \n iPhone 15\n \n \n \n \n \n \n \n 宝贝",
|
||||
"href": null
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"text": "iPhone 15",
|
||||
"href": null
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"text": "iPhone 15",
|
||||
"href": null
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"text": "iPhone 15",
|
||||
"href": null
|
||||
},
|
||||
{
|
||||
"tag": "DIV",
|
||||
"text": "iPhone 15",
|
||||
"href": null
|
||||
}
|
||||
]
|
||||
@ -1,27 +0,0 @@
|
||||
[
|
||||
{
|
||||
"index": 22,
|
||||
"type": "",
|
||||
"text": "\n var g_config = {\n \"bizCode\": \"search\",\n \"appid\": 3,\n \"toolbar\": false,\n \"webww\": true,\n \"footer\": true,\n \"jstracker2\": {\n \"collection_url\": \"http://2023.s.taobao.com\",\n \"pid\": \"24688-tracker\",\n \"p_sampling\": 1\n },\n \"barrierFree\": true,\n \"region\": true,\n \"toolkit\": {\n \"backTop\": {\n \"priority\": 100,\n "
|
||||
},
|
||||
{
|
||||
"index": 31,
|
||||
"type": "",
|
||||
"text": "\n // 客户端动态CSS样式注入逻辑\n try {\n var vm_isPre = location.host.indexOf('pre-') > -1;\n var finalCssHref = vm_isPre ? 'https://dev.o.alicdn.com/tb-pc-page/assets/css/search.css' : 'https://o.alicdn.com/tb-pc-page/assets/css/search.css';\n var ua = window.navigator.userAgent || '';\n var isClient = (ua.indexOf('NEW_PC_TAOBAO_Client') > -1) || (ua.indexOf('TAOBAO_PC_Client') > -1);\n if(isClient) {\n // 在head里注入一个css的cdn\n "
|
||||
},
|
||||
{
|
||||
"index": 32,
|
||||
"type": "",
|
||||
"text": "\n // 客户端动态JS脚本注入逻辑\n try {\n var final_tbpc_arms_config_version = '';\n var final_tbpc_arms_config_env = 'prod';\n try {\n final_tbpc_arms_config_version = ('/main-search/pc-search-2024/1.8.40' || '').split('/')[3] || '';\n final_tbpc_arms_config_env = ('prod' || 'prod');\n } catch (e) {}\n window.__tbpc_arms_config = {\n pid: \"tb_pc_search_new\",\n env: final_tbpc_arms_config_en"
|
||||
},
|
||||
{
|
||||
"index": 38,
|
||||
"type": "",
|
||||
"text": "\n // 卡片骨架渲染\n try {\n var cardListWrapper = document.querySelector('.boneClass_cardListWrapper');\n var innerHtmlStr = '';\n for (var i = 0; i < 30; i++) {\n innerHtmlStr += \n `\n <div class=\"boneClass_cardWrapper\">\n <div class=\"boneClass_mainImg boneClass_lightSlideAnimation\"></div>\n <div class=\"boneClass_title boneClass_lightSlideAnimation\"></div>\n <div"
|
||||
},
|
||||
{
|
||||
"index": 39,
|
||||
"type": "",
|
||||
"text": "window.__ASSET_PATH__ = '//g.alicdn.com/main-search/pc-search-2024/1.8.40/'"
|
||||
}
|
||||
]
|
||||
@ -1,27 +0,0 @@
|
||||
[
|
||||
"pageXOffset",
|
||||
"pageYOffset",
|
||||
"onsearch",
|
||||
"oncontentvisibilityautostatechange",
|
||||
"onformdata",
|
||||
"onloadeddata",
|
||||
"onloadedmetadata",
|
||||
"onpagehide",
|
||||
"onpageshow",
|
||||
"onpopstate",
|
||||
"onpageswap",
|
||||
"onpagereveal",
|
||||
"g_config",
|
||||
"final_tbpc_arms_config_version",
|
||||
"final_tbpc_arms_config_env",
|
||||
"__tbpc_arms_isMtopMiddlewareAdded",
|
||||
"searchSuggestObj",
|
||||
"__ai_page_mask_ids",
|
||||
"__itempage_performance_sdk",
|
||||
"goldlog_queue",
|
||||
"__last_search_params",
|
||||
"g_aplus_grey_launched",
|
||||
"g_tb_aplus_loaded",
|
||||
"g_SPM",
|
||||
"g_bizdata_collector"
|
||||
]
|
||||
Loading…
x
Reference in New Issue
Block a user