Initial commit: Market-Assistant standalone (Django + Vue + JD crawler copy)

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-10 18:03:35 +08:00
commit d8086dfdd5
129 changed files with 35614 additions and 0 deletions

35
.env.example Normal file
View File

@ -0,0 +1,35 @@
# =============================================================================
# 唯一环境变量模板:复制为同目录 .env 后填写(.env 勿提交仓库)
# copy .env.example .env Windows
# cp .env.example .env Linux/macOS
# =============================================================================
# --- 数据工作区根目录(可选)---
# 不填时默认为本仓库根目录market_assistant数据写在 ./data/JD/。
# 若数据盘与代码分离,可设为绝对路径(须可写,运行时会自动创建 data/JD
# LOW_GI_PROJECT_ROOT=D:\data\low-gi-workspace
# --- Django ---
DJANGO_SECRET_KEY=please-change-me
DJANGO_DEBUG=True
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
# 可选SQLite 绝对路径;不填则使用 backend/db.sqlite3
# DJANGO_SQLITE_PATH=D:\PythonProject\Low GI\market_assistant\backend\db.sqlite3
# --- 浏览器访问前端时的 Origin开发Vite 5173生产改为实际域名---
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
CSRF_TRUSTED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# --- LLM配料图识别、竞品报告、策略稿OpenAI 兼容接口,与 AI_crawler 共用本文件)---
# OPENAI_API_KEY=sk-your-key-here
# OPENAI_BASE_URL=https://llm.example.com/v1
# OPENAI_VISION_MODEL=Qwen/Qwen3-Omni-30B-A3B
# 纯文本优先OPENAI_TEXT_MODEL 或 LLM_TEXT_MODEL未设则回退到视觉模型名
# OPENAI_TEXT_MODEL=
# 别名LLM_API_KEY、LLM_BASE_URL、LLM_MODEL
# --- 可选:流水线侧 LLM 开关 ---
# MA_SKIP_LLM_KEYWORD_SUGGEST=1
# MA_ENABLE_LLM_COMMENT_SENTIMENT=1
# MA_SKIP_LLM_COMMENT_SENTIMENT=1

34
.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
# 独立成库时使用本文件即可(勿提交密钥与本地产物)
.env
.env.local
.env.*.local
backend/db.sqlite3
*.sqlite3
backend/**/__pycache__/
**/__pycache__/
*.py[cod]
*.pyc
*.egg-info/
.eggs/
backend/runtime_cookies/
# 京东登录 Cookie本地放置勿提交
backend/crawler_copy/jd_pc_search/common/jd_cookie.txt
# 流水线 / 京东采集产出(默认在仓库根下 data/JD
/data/
venv/
.venv/
.idea/
.vscode/
.cursor/
frontend/node_modules/
frontend/dist/
backend/crawler_copy/jd_pc_search/node_modules/

120
README.md Normal file
View File

@ -0,0 +1,120 @@
# Market-Assistant低 GI / 京东采集与竞品分析)
**本目录可作为独立 Git 仓库根目录**:克隆后只需配置 `.env` 与数据区即可部署并实现当前工作台全部能力任务、入库、浏览、报告、策略、LLM 等)。代码不依赖仓库外的 `crawler/` 等目录;爬虫副本在 `backend/crawler_copy/jd_pc_search`
面向「前台事业部」的 Web 工作台:提交京东关键词采集任务、查看流水线产出、**库内分页浏览**已入库的搜索/商详/评价数据、生成竞品分析报告,并支持导出 JSON / CSV / Excel。
跑批 CSV、`pipeline_runs` 等默认写在**本仓库根目录**下的 `data/JD/`;若数据需放在其它磁盘,可在 `.env` 中设置 **LOW_GI_PROJECT_ROOT** 为绝对路径。
**环境变量**:全栈**只使用一份** `market_assistant/.env`(模板为 `.env.example`),勿在仓库根或其它子目录再建第二份 `.env`
**研发对接**:任务产物、状态与 REST 能力见项目内 **流水线输出说明****OpenAPI 子集**;部署与 Git 整理见 **docs/DEPLOY_AND_GIT.md**
---
## 技术栈
| 部分 | 说明 |
|------|------|
| 后端 | Django 5 + Django REST FrameworkSQLite可配置路径 |
| 前端 | Vue 3 + Vite 5 + Vue Router开发时通过 Vite 代理访问 API |
| 采集 | 京东 PC 搜索侧脚本副本(与流水线任务联动) |
---
## 环境准备
- **Python** 3.11+(建议虚拟环境)
- **Node.js** 18+(用于前端)
- **唯一**环境文件:在本目录(`market_assistant/`)执行:
```bash
copy .env.example .env
```
编辑 `.env`,至少设置:
- **DJANGO_SECRET_KEY**;生产环境将 **DJANGO_DEBUG** 设为 False并配置 **ALLOWED_HOSTS** 与 CORS/CSRF。
- **LOW_GI_PROJECT_ROOT**(可选):不填则数据写在仓库根下 `data/JD/`;单独数据盘时再填绝对路径。
- 若使用配料识别、报告/策略 LLM在同一文件填写 **OPENAI_*** 或 **LLM_***(与 `AI_crawler` 共用,无需另建 `.env`)。
---
## 启动后端
在后端子目录下执行:
```bash
cd market_assistant/backend
# 安装依赖(建议在 venv 中)
pip install -r requirements.txt
# 数据库迁移
python manage.py migrate
# 开发服务(默认 http://127.0.0.1:8000
python manage.py runserver
```
管理后台(可选):创建超级用户后访问 Django 管理地址。
---
## 启动前端
在前端子目录下执行:
```bash
cd market_assistant/frontend
# 首次安装依赖
npm install
# 开发模式(默认 http://127.0.0.1:5173
npm run dev
```
浏览器打开本地开发地址。开发环境下,前端将 **API** 代理到后端端口,因此需**先启动后端**,再启动前端。
其他脚本:
```bash
npm run build # 生产构建
npm run preview # 本地预览构建结果
```
---
## 常用功能说明
1. **搜索采集**创建京东关键词流水线任务翻页、SKU 上限、Cookie 等;报告统计规则在「报告生成」)。
2. **任务与结果**:查看任务状态;成功任务可 **库内浏览**、文件预览与下载、导出。
3. **报告生成**:配置统计规则并重新生成分析报告文件。
4. **报告查看**:在线预览、单文件下载、加载结构化摘要、**一键下载简报包**ZIP
5. **结构化摘要**:与报告同口径的规则化 JSON供联调或其它工具使用。
6. **市场策略制定**:选成功任务,可选填业务备注,生成策略向 Markdown目标、战场、定位选项、支柱与行动规则版、非大模型
任务**成功结束后**会自动执行入库;也可在「库内浏览」里从批次目录重新入库。
---
## API 前缀
开发时 REST 接口默认在后端根地址下的 **/api**;前端通过同源代理访问即可。
---
## 相关文档
均在项目 **docs** 目录下,主要包括:项目进展与里程碑、流水线输出说明、演示与脱敏、工程说明等。
**部署、Git 仓库整理、单 `.env` 约定**:见 [docs/DEPLOY_AND_GIT.md](docs/DEPLOY_AND_GIT.md)。
---
## 目录结构(简要)
- **backend**Django 与任务流水线 API
- **frontend**Vue + Vite 工作台
- **docs**:说明、模板、演示与 OpenAPI 等

View File

16
backend/config/asgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
ASGI config for config project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_asgi_application()

112
backend/config/settings.py Normal file
View File

@ -0,0 +1,112 @@
"""
Django settings for config project.
环境变量**** ``market_assistant/.env`` 加载 ``.env.example`` 同目录
勿再在 ``backend/.env`` 或仓库根目录使用第二份 .env以免部署混淆
"""
from pathlib import Path
from dotenv import load_dotenv
import os
BASE_DIR = Path(__file__).resolve().parent.parent
MA_ROOT = BASE_DIR.parent
load_dotenv(MA_ROOT / ".env")
_raw_low = (os.environ.get("LOW_GI_PROJECT_ROOT") or "").strip().strip('"').strip("'")
if _raw_low:
LOW_GI_PROJECT_ROOT = str(Path(_raw_low).expanduser().resolve())
else:
LOW_GI_PROJECT_ROOT = str(MA_ROOT.resolve())
os.environ["LOW_GI_PROJECT_ROOT"] = LOW_GI_PROJECT_ROOT
Path(LOW_GI_PROJECT_ROOT).joinpath("data", "JD").mkdir(parents=True, exist_ok=True)
CRAWLER_JD_ROOT = BASE_DIR / "crawler_copy" / "jd_pc_search"
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "django-insecure-dev-only-change-in-env")
DEBUG = os.environ.get("DJANGO_DEBUG", "True").lower() in ("1", "true", "yes")
ALLOWED_HOSTS = [
h.strip()
for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
if h.strip()
]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"corsheaders",
"pipeline",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "config.wsgi.application"
_sqlite = os.environ.get("DJANGO_SQLITE_PATH", "").strip()
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": Path(_sqlite).expanduser().resolve()
if _sqlite
else (BASE_DIR / "db.sqlite3"),
}
}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "zh-hans"
TIME_ZONE = "Asia/Shanghai"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
REST_FRAMEWORK = {
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
],
}
_cors = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173")
CORS_ALLOWED_ORIGINS = [x.strip() for x in _cors.split(",") if x.strip()]
CORS_ALLOW_CREDENTIALS = True
_csrf = os.environ.get("CSRF_TRUSTED_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173")
CSRF_TRUSTED_ORIGINS = [x.strip() for x in _csrf.split(",") if x.strip()]

7
backend/config/urls.py Normal file
View File

@ -0,0 +1,7 @@
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("pipeline.urls")),
]

16
backend/config/wsgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
WSGI config for config project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_wsgi_application()

View File

@ -0,0 +1,804 @@
# -*- coding: utf-8 -*-
"""
从本地图片路径或图片 URL 调用 OpenAI 兼容多模态接口提取配料表等并提供**纯文本** ``chat/completions`` 供报告/策略等场景复用
**密钥与网关仅通过环境变量配置**勿写入代码
- ``OPENAI_API_KEY``API Key必填
- ``OPENAI_BASE_URL``网关根地址 ``https://llm.example.com/v1``必填勿尾斜杠多余路径
- ``OPENAI_VISION_MODEL``视觉模型名可选默认 ``Qwen/Qwen3-Omni-30B-A3B``
**纯文本调用**``chat_completion_text``优先使用
- ``OPENAI_TEXT_MODEL`` ``LLM_TEXT_MODEL``未设置时依次回退到 ``OPENAI_VISION_MODEL````LLM_MODEL``上述默认
兼容别名二选一即可``LLM_API_KEY````LLM_BASE_URL````LLM_MODEL``
上述变量与 Django 共用 **一份** ``market_assistant/.env``与本脚本所在 ``backend`` 的上三级目录下的 ``.env`` ``pip install python-dotenv``
**运行方式**在下方运行配置里改好 ``IMAGE_SOURCE`` 等变量后直接执行 ``python AI_crawler.py``无需命令行参数
"""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from typing import Any
import requests
_SCRIPT_DIR = Path(__file__).resolve().parent
# backend/crawler_copy/jd_pc_search -> parents[3] == market_assistant
_MA_ROOT = Path(__file__).resolve().parents[3]
def _load_market_assistant_dotenv() -> None:
"""先于 LOW_GI_PROJECT_ROOT 解析加载 ``market_assistant/.env``(唯一配置源)。"""
try:
from dotenv import load_dotenv
except ImportError:
return
p = _MA_ROOT / ".env"
if p.is_file():
load_dotenv(p)
_load_market_assistant_dotenv()
from _low_gi_root import low_gi_project_root # noqa: E402
_PROJECT_ROOT = low_gi_project_root()
# ---------------------------------------------------------------------------
# 运行配置(按需修改;启动时不要求命令行参数)
# ---------------------------------------------------------------------------
# 必填:本地图片路径,或 http(s) 图片链接(如京东主图 / 详情图)
IMAGE_SOURCE = "https://img30.360buyimg.com/sku/jfs/t1/390444/8/13018/103574/6982e951Fc44d9d7b/00d62ee56189d75d.jpg.avif"
# IMAGE_SOURCE = "https://img30.360buyimg.com/sku/jfs/t1/382894/31/7432/241977/694cf41aFa27be91e/00d63164ffeb8b46.jpg.avif"
# 提示词:留空则使用 ``PROMPT_DEFAULT``
USER_PROMPT = ""
PROMPT_DEFAULT = (
"请识别图片中的配料表,只输出配料列表本身,不要将菜品做法、步骤、用料等认为是配料表,不要误识别为食谱;用中文逗号或顿号分隔,"
"输出为连续一段文字,不要使用多行换行(避免与食谱、做法步骤混淆)。"
"每种原料名称只出现一次,禁止重复罗列同一添加剂(如磷酸三钾、磷酸三钠等勿循环抄写多遍);"
"若图为表格中多行同类添加剂,可概括为「食品添加剂(按国家标准使用)」或合并为一句,勿展开成数百字重复。"
"【禁止猜测】必须严格依据图中清晰可见的印刷文字归纳;不得根据商品品类、常识或模糊字迹推测、补全、编造任何原料。"
"若本图无配料表、仅有产品信息/广告、文字被裁切、过小、模糊到无法逐字确认,或你只能「猜」出部分内容,则禁止输出配料列表:"
"请只输出且仅输出一句「无法识别图片中的配料表」(不要解释、不要道歉长文、不要列出疑似项)。"
)
# 拉取远程图时的 Referer京东图床一般需类似商城域名
IMAGE_REFERER = "https://www.jd.com/"
TEMPERATURE = 0.0
MAX_TOKENS = 2048
# 部分 Qwen 网关需要关闭 thinking
QWEN_OMNI_TEMPLATE = False
# ---------------------------------------------------------------------------
DEFAULT_MODEL = "Qwen/Qwen3-Omni-30B-A3B"
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
def _normalize_chat_content(content: Any) -> str:
"""
兼容 OpenAI 兼容网关``message.content`` 可能是 str也可能是
``[{type:text, text:...}, ...]``避免对 list 误用 ``.strip()`` 或得到怪异字符串
"""
if content is None:
return ""
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict):
if item.get("type") == "text":
parts.append(str(item.get("text") or ""))
elif "text" in item:
parts.append(str(item.get("text") or ""))
elif isinstance(item, str):
parts.append(item)
return "".join(parts).strip()
return str(content).strip()
def normalize_ingredients_text_for_csv(text: str) -> str:
"""
将配料 OCR 结果压成**单行**便于 ``detail_ware_export.csv`` / 合并表展示
模型常按一行一项输出食谱或列表会产生多换行合并为非换行文本行间用中文分号分隔
"""
t = (text or "").replace("\r\n", "\n").replace("\r", "\n").strip()
if not t:
return ""
lines = [ln.strip() for ln in t.split("\n") if ln.strip()]
if len(lines) <= 1:
return lines[0] if lines else ""
return "".join(lines)
def _split_ingredient_segments(text: str) -> list[str]:
"""按常见分隔符拆成原料小段(用于检测尾部循环复读)。"""
t = (text or "").strip()
if not t:
return []
return [p.strip() for p in re.split(r"[;、,]+", t) if p.strip()]
def sanitize_vision_ingredients_output(text: str) -> str:
"""
清洗多模态配料识别结果去掉尾部引号切除仅两三种词循环数百次的模型复读尾巴超长截断
典型故障真实配料后无限重复磷酸三钾磷酸三钠仍因前半段通过业务校验
"""
t = (text or "").strip()
_trail_q = frozenset({'"', "'", "\u201c", "\u201d", "\u2018", "\u2019", "\uff02"})
while t and t[-1] in _trail_q:
t = t[:-1].strip()
segs = _split_ingredient_segments(t)
if not segs:
return ""
min_spam_run = 28
cut_i = len(segs)
for i in range(0, max(0, len(segs) - min_spam_run + 1)):
suf = segs[i:]
if len(suf) >= min_spam_run and len(set(suf)) <= 3:
cut_i = i
break
segs = segs[:cut_i]
if not segs:
return ""
t = "".join(segs)
# 字符级兜底:同一短词组高频重复(未按顿号切分时)
t = re.sub(
r"(磷酸三[钾钠][、,]?\s*){35,}",
"磷酸三钾、磷酸三钠等(按国家标准使用)",
t,
)
max_chars = 3200
if len(t) > max_chars:
cut = t[:max_chars]
last = max(cut.rfind(""), cut.rfind(""), cut.rfind(","), cut.rfind(""))
if last > max_chars // 2:
t = cut[: last + 1] + "…(已截断)"
else:
t = cut + "…(已截断)"
return t.strip()
def _resolve_credentials(
api_key: str | None,
base_url: str | None,
model: str | None,
) -> tuple[str, str, str]:
"""凭证只从环境变量(及可选函数参数)读取,不在代码中写死。"""
key = (
(api_key or "").strip()
or (os.environ.get("OPENAI_API_KEY") or os.environ.get("LLM_API_KEY") or "").strip()
)
base = (
(base_url or "").strip().rstrip("/")
or (
os.environ.get("OPENAI_BASE_URL") or os.environ.get("LLM_BASE_URL") or ""
).strip().rstrip("/")
)
m = (
(model or "").strip()
or (
os.environ.get("OPENAI_VISION_MODEL")
or os.environ.get("LLM_MODEL")
or DEFAULT_MODEL
).strip()
)
if not key:
raise ValueError("请设置环境变量 OPENAI_API_KEY或 LLM_API_KEY")
if not base:
raise ValueError(
"请设置环境变量 OPENAI_BASE_URL或 LLM_BASE_URL例如 https://your-gateway.com/v1"
)
return key, base, m
def resolve_text_model_name(model: str | None = None) -> str:
"""
文本补全所用模型显式 ``model`` 优先否则读环境变量见模块文档
"""
m = (model or "").strip()
if m:
return m
for env in (
"OPENAI_TEXT_MODEL",
"LLM_TEXT_MODEL",
"OPENAI_VISION_MODEL",
"LLM_MODEL",
):
v = (os.environ.get(env) or "").strip()
if v:
return v
return DEFAULT_MODEL
def strip_outer_markdown_fence(text: str) -> str:
"""若模型用 ``` / ```markdown 包裹全文,去掉最外层围栏。"""
t = (text or "").strip()
if not t.startswith("```"):
return t
lines = t.split("\n")
if lines and lines[0].strip().startswith("```"):
lines = lines[1:]
while lines and lines[-1].strip() == "```":
lines = lines[:-1]
return "\n".join(lines).strip()
def chat_completion_text(
*,
system_prompt: str,
user_prompt: str,
api_key: str | None = None,
base_url: str | None = None,
model: str | None = None,
temperature: float = 0.2,
max_tokens: int = 8192,
timeout: int = 300,
extra_json: dict[str, Any] | None = None,
) -> str:
"""
OpenAI 兼容网关的**纯文本**多轮占位为 system + user 各一条 ``extract_ingredients_from_image`` 共用凭证与端点
返回助手消息正文 ``strip`` / 兼容 list content
"""
k, b, _ = _resolve_credentials(api_key, base_url, None)
m = resolve_text_model_name(model)
body: dict[str, Any] = {
"model": m,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": temperature,
"max_tokens": max_tokens,
}
if extra_json:
body.update(extra_json)
r = requests.post(
f"{b}/chat/completions",
headers={
"Authorization": f"Bearer {k}",
"Content-Type": "application/json",
},
json=body,
timeout=timeout,
)
r.raise_for_status()
data = r.json()
msg = (data.get("choices") or [{}])[0].get("message") or {}
return _normalize_chat_content(msg.get("content"))
def _mime_for_path(path: str) -> str:
ext = path.lower().rsplit(".", 1)[-1]
return {
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
"webp": "image/webp",
"gif": "image/gif",
"avif": "image/avif",
}.get(ext, "image/jpeg")
def _mime_from_response(url: str, content_type: str | None) -> str:
if content_type and content_type.lower().startswith("image/"):
return content_type.split(";")[0].strip().lower()
u = url.lower().split("?")[0]
for suf, mime in (
(".png", "image/png"),
(".webp", "image/webp"),
(".avif", "image/avif"),
(".gif", "image/gif"),
(".jpg", "image/jpeg"),
(".jpeg", "image/jpeg"),
):
if u.endswith(suf):
return mime
return "image/jpeg"
def image_to_data_url(
source: str,
*,
referer: str = "https://www.jd.com/",
timeout: int = 60,
) -> tuple[str, str]:
"""
``source`` 为本地路径或以 http(s) 开头的 URL
返回 (data_url, 来源说明)
"""
s = source.strip()
if s.lower().startswith(("http://", "https://")):
headers = {
"User-Agent": DEFAULT_USER_AGENT,
"Accept": "image/avif,image/webp,image/*,*/*;q=0.8",
"Referer": referer,
}
r = requests.get(s, headers=headers, timeout=timeout, allow_redirects=True)
r.raise_for_status()
mime = _mime_from_response(s, r.headers.get("Content-Type"))
b64 = base64.standard_b64encode(r.content).decode("ascii")
return f"data:{mime};base64,{b64}", f"url:{s[:80]}"
with open(s, "rb") as f:
raw = f.read()
mime = _mime_for_path(s)
b64 = base64.standard_b64encode(raw).decode("ascii")
return f"data:{mime};base64,{b64}", f"file:{s}"
def extract_ingredients_from_image(
image_path_or_url: str,
*,
api_key: str | None = None,
base_url: str | None = None,
model: str | None = None,
user_prompt: str | None = None,
temperature: float = 0.0,
max_tokens: int = 2048,
referer: str = "https://www.jd.com/",
extra_json: dict[str, Any] | None = None,
prompt_default: str | None = None,
) -> str:
"""
从本地图片路径或图片 URL 识别配料表可改 ``user_prompt`` 扩展为营养成分表等
未传入 ``api_key`` / ``base_url`` / ``model`` 时从环境变量读取
返回值为经 ``normalize_ingredients_text_for_csv`` 处理后的**单行**文本便于写入 CSV
"""
k, b, m = _resolve_credentials(api_key, base_url, model)
data_url, _src = image_to_data_url(image_path_or_url, referer=referer)
_fallback = (
prompt_default
or "请识别图片中的配料表,只输出配料列表,不要误识别为做法用料;用逗号或顿号分隔为一段,不要换行分段。"
)
prompt = user_prompt if user_prompt is not None and str(user_prompt).strip() else _fallback
body: dict[str, Any] = {
"model": m,
"messages": [
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": data_url}},
{"type": "text", "text": prompt},
],
}
],
"temperature": temperature,
"max_tokens": max_tokens,
}
if extra_json:
body.update(extra_json)
r = requests.post(
f"{b}/chat/completions",
headers={
"Authorization": f"Bearer {k}",
"Content-Type": "application/json",
},
json=body,
timeout=120,
)
r.raise_for_status()
data = r.json()
msg = (data.get("choices") or [{}])[0].get("message") or {}
raw = normalize_ingredients_text_for_csv(_normalize_chat_content(msg.get("content")))
return sanitize_vision_ingredients_output(raw)
def parse_joined_image_urls(joined: str) -> list[str]:
"""
解析详情 DOM 拼出的 URL 与列 ``detail_body_ingredients`` URL阶段同形分号换行分隔的 http(s) 链接
保持从前到后的顺序去重不在这里做上游已去重
"""
t = (joined or "").strip()
if not t:
return []
t = t.replace("\r\n", "\n").replace("\r", "\n")
parts = re.split(r"\s*;\s*|\s*\n\s*", t)
out: list[str] = []
for p in parts:
u = p.strip()
if u.startswith(("http://", "https://")):
out.append(u)
return out
def _looks_like_recipe_or_dish_prep(text: str) -> bool:
"""
判断模型输出是否更像**菜谱/做法备料**详情图里常见而非包装配料表
命中则不应写入 ``detail_body_ingredients``继续尝试其它长图
"""
t = (text or "").strip()
if not t:
return False
recipe_kw = (
"做法",
"制作步骤",
"烹饪步骤",
"第一步",
"第二步",
"第三步",
"教程",
"准备食材",
"食材准备",
"下锅",
"翻炒",
"煮熟",
"大火烧开",
"转小火",
"装盘",
"小贴士",
"腌制",
"爆香",
"焯水",
"切丝",
"切丁",
"切片",
"打匀",
"搅拌均匀",
"油热",
"调味",
)
if any(k in t for k in recipe_kw):
return True
# 「葱花蒜末 各1勺」类菜谱用量
if re.search(r"各[一二两三四五六七八九十\d零]+勺", t):
return True
# 多条「短名称 + 数量 + 料理常用单位」并列(典型备料清单)
dish_qty = re.findall(
r"[^\n。,,、]{1,14}\s+\d+(?:\.\d+)?\s*[个只根块片勺条袋包杯碗适量克gG毫升mlML]{1,4}",
t,
)
if len(dish_qty) >= 2:
return True
# 半块/半根等家常分量词 + 生鲜食材名(包装配料表极少这样写)
if "半块" in t and re.search(r"鸡胸|鸡腿|牛肉|猪肉|黄瓜|番茄|土豆|豆腐", t):
return True
if "半根" in t and re.search(r"黄瓜|胡萝卜|玉米|香肠|葱", t):
return True
# 规范化后的「ABC…」若多段都很短且多段含数字多为做法用料枚举
parts = [p.strip() for p in t.split("") if p.strip()]
if len(parts) >= 4:
short_with_digit = [p for p in parts if len(p) <= 24 and re.search(r"\d", p)]
if len(short_with_digit) >= 4:
return True
# 多行/多段里至少 3 条「短句 + 数字 + 个根块勺克」
lines = [ln.strip() for ln in re.split(r"[\n]", t) if ln.strip()]
if len(lines) >= 3:
n_short_qty = sum(
1
for ln in lines
if len(ln) <= 22
and re.search(r"\d", ln)
and re.search(r"[个只根块片勺克gG]", ln)
)
if n_short_qty >= 3:
return True
return False
def _looks_like_packaged_ingredient_enumeration(text: str) -> bool:
"""
视觉模型常把包装图上的配料表整段压成**逗号/顿号分隔的原料枚举**丢掉标题与含量行
此类文本与菜谱备料鸡胸黄瓜葱花等可区分时视为有效配料信号
"""
t = (text or "").strip()
if not t:
return False
parts = [p.strip() for p in re.split(r"[,,、;]", t) if p.strip()]
if len(parts) < 3:
return False
# 多段像「家常备料」则不走此路(避免鸡胸、鸡蛋、黄瓜…误过)
recipe_seg = re.compile(
r"鸡胸|鸡腿|牛腩|牛肉|五花肉|里脊|鸡蛋|鸭蛋|皮蛋|黄瓜|番茄|西红柿|土豆|马铃薯|"
r"葱花|蒜末|姜丝|小米椒|青椒|洋葱|胡萝卜|生菜|菠菜|白菜|芹菜|香菜|小葱|"
r"面条$|挂面|粉条|粉丝"
)
n_recipe_like = sum(1 for p in parts if recipe_seg.search(p))
if n_recipe_like >= 2:
return False
# 工业化配料常见子串(粉体、纤维、添加剂类别、粮谷原料等)
industrial = re.compile(
r"食用|食品添加|麦麸|纤维|淀粉|魔芋|提取物|谷朊|谷胱|麸皮|糖浆|山梨|麦芽|柠檬酸|碳酸|"
r"酵母|乳粉|全脂|脱脂|果胶|黄原|卡拉胶|海藻酸|小麦|面粉|荞麦|燕麦|藜麦|青稞|糙米|黑米|"
r"棕榈|植物油|精炼油|氢化|起酥|可可脂"
)
n_industrial = sum(1 for p in parts if industrial.search(p))
if len(parts) >= 4 and n_industrial >= 2:
return True
if len(parts) >= 3 and n_industrial >= 3:
return True
return False
def _has_packaged_ingredient_table_signals(text: str) -> bool:
"""
正向判断是否像**包装配料表**标题+含量行内含量多段工业化原料枚举
OCR 出一段家常食材名无上述结构时返回 False
"""
t = (text or "").strip()
if not t:
return False
# 行内「××含量≥50%)」等,常见于包装,不强制出现「配料表」标题
if re.search(
r"[\u4e00-\u9fff\\d]{1,18}[(]\s*含量\s*[≥>=]?\s*[\d.]+\s*%?\s*[)]",
t,
):
return True
label = bool(
re.search(r"配料表", t)
or re.search(r"\s*料\s*[:]", t)
or re.search(r"\s*料\s*[:]", t)
or re.search(r"食品添加剂", t)
or re.search(r"产品\s*配\s*料", t)
)
# 「含量」相关信息百分比、不等式、法规用语、添加量表述等不含单独「50克」类菜谱用量
content = bool(
re.search(r"含量", t)
or re.search(r"添加量", t)
or re.search(r"\d+(?:\.\d+)?\s*[%]", t)
or re.search(r"[≥>>]\s*[\d.]+", t)
or re.search(r"\s*添\s*加\s*量\s*递\s*减", t)
)
if label and content:
return True
# 模型只输出「原料1,原料2,…」时仍可能是正规配料表
if _looks_like_packaged_ingredient_enumeration(t):
return True
return False
def _ingredient_extraction_acceptable(text: str) -> bool:
"""粗判模型输出是否像有效配料信息(过滤拒识句、伪列表、过短碎片、菜谱备料)。
通过条件之一配料表标题+含量类信号行内××含量x%或多段工业化原料枚举
``_looks_like_packaged_ingredient_enumeration``用于模型只输出逗号分隔原料丢掉标题时
"""
t = (text or "").strip()
if len(t) < 6:
return False
# 模型偶发输出类似 Python 列表的字符串,或 JSON 数组形态
if re.match(r"^\s*\[.*\]\s*$", t):
return False
refuse = (
"无法识别",
"没有配料",
"看不清",
"不存在配料",
"未在图中",
"未在图片",
"抱歉,我",
"抱歉,无法",
"不能识别",
"没有识别到",
"图中没有",
"图片中没有",
"无配料",
"未见配料",
)
if any(x in t for x in refuse):
return False
# 真配料表通常含分隔符或足够长;避免「无」「暂无」等被当成命中
if t in ("", "暂无", "没有", "无。", "无,"):
return False
if _looks_like_recipe_or_dish_prep(t):
return False
if not _has_packaged_ingredient_table_signals(t):
return False
tail = _split_ingredient_segments(t)
if len(tail) >= 32:
if len(set(tail[-32:])) <= 3:
return False
sep_chars = ",、,;"
if len(t) < 18 and not any(c in t for c in sep_chars):
return False
return True
REASON_NO_BODY_URLS = "【未识别到配料】未解析到任何详情长图 URL。"
REASON_NO_VISION_API = (
"【未识别到配料】未配置多模态 API需环境变量 OPENAI_API_KEY + OPENAI_BASE_URL"
"或 LLM_API_KEY + LLM_BASE_URL"
)
def extract_ingredients_from_body_image_urls_reversed_with_source(
urls_joined: str,
*,
referer: str | None = None,
user_prompt: str | None = None,
prompt_default: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
extra_json: dict[str, Any] | None = None,
) -> tuple[str, str | None]:
"""
``extract_ingredients_from_body_image_urls_reversed`` 相同逻辑额外返回命中配料时所用的**图片 URL**
自后向前首次通过校验的那张未命中或失败时第二项为 ``None``
"""
urls = parse_joined_image_urls(urls_joined)
if not urls:
return REASON_NO_BODY_URLS, None
try:
_resolve_credentials(None, None, None)
except ValueError:
return REASON_NO_VISION_API, None
ref = (referer if referer is not None else IMAGE_REFERER) or "https://www.jd.com/"
temp = float(temperature) if temperature is not None else float(TEMPERATURE)
mt = int(max_tokens) if max_tokens is not None else int(MAX_TOKENS)
extra = extra_json
if extra is None and QWEN_OMNI_TEMPLATE:
extra = {"chat_template_kwargs": {"enable_thinking": False}}
pu = user_prompt if user_prompt is not None else ((USER_PROMPT or "").strip() or None)
pd = prompt_default if prompt_default is not None else PROMPT_DEFAULT
n = len(urls)
n_err = 0
n_rejected = 0
for url in reversed(urls):
try:
text = extract_ingredients_from_image(
url,
user_prompt=pu,
referer=ref.strip(),
temperature=temp,
max_tokens=mt,
extra_json=extra,
prompt_default=pd,
)
except Exception:
n_err += 1
continue
t = (text or "").strip()
if _ingredient_extraction_acceptable(t):
return t, url
if t:
n_rejected += 1
parts = [
f"【未识别到配料】已对 {n} 张详情长图自后向前依次尝试(命中即停),未得到有效配料表。"
]
if n_err:
parts.append(f" 请求异常 {n_err} 次。")
if n_rejected:
parts.append(f"{n_rejected} 次返回未通过配料校验。")
if not n_err and not n_rejected:
parts.append(" 模型返回均为空或过短。")
return "".join(parts), None
def extract_ingredients_from_body_image_urls_reversed(
urls_joined: str,
*,
referer: str | None = None,
user_prompt: str | None = None,
prompt_default: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
extra_json: dict[str, Any] | None = None,
) -> str:
"""
URL 串拆出的链接 **从后往前**依次调用视觉模型**首次**通过校验的配料文本立即返回省时间
若始终无命中返回以 ``未识别到配料`` 开头的原因说明**不再返回空串**
未配置 API 时返回 ``REASON_NO_VISION_API``
命中条件 ``_ingredient_extraction_acceptable``须像**包装配料表**配料/含量标题结构
``××含量x%``或多段工业化原料逗号/顿号枚举模型常省略标题纯家常备料鸡胸黄瓜葱花等
仍丢弃并试下一张图
若需同时得到所用图片 URL请用 ``extract_ingredients_from_body_image_urls_reversed_with_source``
"""
text, _ = extract_ingredients_from_body_image_urls_reversed_with_source(
urls_joined,
referer=referer,
user_prompt=user_prompt,
prompt_default=prompt_default,
temperature=temperature,
max_tokens=max_tokens,
extra_json=extra_json,
)
return text
def main() -> None:
try:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
src = (IMAGE_SOURCE or "").strip()
if not src:
print(
"[AI_crawler] 请在文件顶部设置 IMAGE_SOURCE图片路径或 URL后重试。",
file=sys.stderr,
)
sys.exit(2)
prompt_use = (USER_PROMPT or "").strip() or None
extra = None
if QWEN_OMNI_TEMPLATE:
extra = {"chat_template_kwargs": {"enable_thinking": False}}
try:
text = extract_ingredients_from_image(
src,
user_prompt=prompt_use,
referer=(IMAGE_REFERER or "https://www.jd.com/").strip(),
temperature=float(TEMPERATURE),
max_tokens=int(MAX_TOKENS),
extra_json=extra,
prompt_default=PROMPT_DEFAULT,
)
except ValueError as e:
print(f"[AI_crawler] {e}", file=sys.stderr)
sys.exit(2)
except requests.HTTPError as e:
err_body = ""
if e.response is not None and e.response.text:
err_body = e.response.text[:1500]
print(f"[AI_crawler] HTTP 错误: {e}\n{err_body}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"[AI_crawler] 失败: {e}", file=sys.stderr)
sys.exit(1)
t = (text or "").strip()
if _ingredient_extraction_acceptable(t):
print(t)
else:
print(
"【未通过配料表校验】输出须同时包含包装配料表常见结构(如「配料/配料表/原料/食品添加剂」)"
"与含量或百分比等信息或为「××含量≥x%)」形态;纯食材/菜谱备料枚举不会采纳。"
"与 extract_ingredients_from_body_image_urls_reversed 流水线规则一致。"
)
if t:
print(f"[AI_crawler] 模型原始输出(未采纳): {t}", file=sys.stderr)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
"""
数据工作区根目录流水线与爬虫副本在其下读写 ``data/JD/``
- 若已设置 ``LOW_GI_PROJECT_ROOT`` Django settings ``market_assistant/.env``使用该路径
- 未设置时默认为 **本仓库根**``market_assistant``便于独立克隆后无需再指向上级目录
"""
from __future__ import annotations
import os
from pathlib import Path
def _market_assistant_root() -> Path:
"""本文件位于 backend/crawler_copy/jd_pc_search/_low_gi_root.py → 上溯 3 级为 MA 根。"""
return Path(__file__).resolve().parents[3]
def low_gi_project_root() -> Path:
raw = (os.environ.get("LOW_GI_PROJECT_ROOT") or "").strip().strip('"').strip("'")
if raw:
p = Path(raw).expanduser().resolve()
else:
p = _market_assistant_root().resolve()
if not p.is_dir():
raise RuntimeError(f"LOW_GI_PROJECT_ROOT 不是有效目录: {p}")
return p

View File

@ -0,0 +1,99 @@
/**
* stdout 一行 JSON{ url, method, form, headers } POST client.action item.jd.com 抓包一致
*
* node jd_export_item_comment_page_request.js --sku 10145684793035 --category 36574;44419;44439 \\
* --first-guid T6NdP8N2gZgtdRyCXKCcwHaB --page-num 1 --is-first true
*/
const path = require("path");
const { loadJdSearchAuth } = require("../common/jd_search_common.js");
const {
get_h5st_item_comment_page,
build_item_comment_page_client_action_form,
} = require("./jd_h5st_item_comment_page.js");
const { buildJdPcItemCommentHeaders } = require("./jd_pc_item_comment_headers.js");
const DEFAULT_COOKIE = path.join(__dirname, "..", "common", "jd_cookie.txt");
function parseCli(argv = process.argv.slice(2)) {
const out = {
sku: null,
category: null,
firstGuid: null,
pageNum: "1",
isFirst: true,
shopType: "0",
spuId: null,
style: "1",
functionId:
process.env.JD_COMMENT_LIST_FUNCTION_ID || "getCommentListPage",
cookiePath: DEFAULT_COOKIE,
};
const a = argv;
for (let i = 0; i < a.length; i++) {
const x = a[i];
const take = (key) => {
if (a[i + 1]) out[key] = a[++i];
};
if (x === "--sku") take("sku");
else if (x.startsWith("--sku=")) out.sku = x.slice(6);
else if (x === "--category") take("category");
else if (x.startsWith("--category=")) out.category = x.slice(11);
else if (x === "--first-guid") take("firstGuid");
else if (x.startsWith("--first-guid=")) out.firstGuid = x.slice(13);
else if (x === "--page-num") take("pageNum");
else if (x.startsWith("--page-num=")) out.pageNum = x.slice(11);
else if (x === "--is-first") take("isFirst");
else if (x.startsWith("--is-first=")) {
const v = x.slice(11).toLowerCase();
out.isFirst = v === "true" || v === "1" || v === "yes";
} else if (x === "--shop-type") take("shopType");
else if (x.startsWith("--shop-type=")) out.shopType = x.slice(12);
else if (x === "--spu-id") take("spuId");
else if (x.startsWith("--spu-id=")) out.spuId = x.slice(9);
else if (x === "--style") take("style");
else if (x.startsWith("--style=")) out.style = x.slice(8);
else if (x === "--function-id") take("functionId");
else if (x.startsWith("--function-id=")) out.functionId = x.slice(14);
else if (x === "--cookie-file") take("cookiePath");
else if (x.startsWith("--cookie-file=")) out.cookiePath = x.slice(14);
}
if (typeof out.isFirst === "string") {
const v = String(out.isFirst).toLowerCase();
out.isFirst = v === "true" || v === "1" || v === "yes";
}
return out;
}
try {
const cli = parseCli();
if (!cli.sku) throw new Error("需要 --sku");
if (!cli.category) throw new Error("需要 --category如 36574;44419;44439");
if (!cli.firstGuid) throw new Error("需要 --first-guid首条评价 guid");
if (!cli.functionId) throw new Error("需要 --function-id 或环境变量 JD_COMMENT_LIST_FUNCTION_ID");
const { cookie, uuid } = loadJdSearchAuth(cli.cookiePath);
if (!cookie) throw new Error("Cookie 为空或不存在");
if (!uuid) throw new Error("缺少 uuidCookie 中 __jdu / mba_muid");
const pack = get_h5st_item_comment_page({
sku: cli.sku,
category: cli.category,
firstCommentGuid: cli.firstGuid,
pageNum: cli.pageNum,
isFirstRequest: cli.isFirst,
shopType: cli.shopType,
spuId: cli.spuId || undefined,
style: cli.style,
functionId: cli.functionId,
});
const { url, form } = build_item_comment_page_client_action_form(pack, {
uuid,
});
const headers = buildJdPcItemCommentHeaders({ cookie, sku: cli.sku });
process.stdout.write(
JSON.stringify({ url, method: "POST", form, headers })
);
} catch (e) {
console.error(e.message || String(e));
process.exit(1);
}

View File

@ -0,0 +1,100 @@
/**
* stdout 输出一行 JSON{ url, headers } jd_h5_item_comment_requests.py 使用
*
* cd crawler/jd_pc_search/comment && node jd_export_item_comment_request.js --sku 10145684793035
* node jd_export_item_comment_request.js --sku 10145684793035 --comment-num 5 --shop-type 0
*/
const path = require("path");
const { loadJdSearchAuth } = require("../common/jd_search_common.js");
const {
get_h5st_item_lego_detail_comment,
build_item_lego_comment_api_url,
} = require("./jd_h5st_item_comment.js");
const { buildJdPcItemCommentHeaders } = require("./jd_pc_item_comment_headers.js");
const DEFAULT_COOKIE = path.join(__dirname, "..", "common", "jd_cookie.txt");
function parseItemCommentCliArgs(argv = process.argv.slice(2)) {
const out = {
sku: null,
commentNum: 5,
shopType: "0",
source: "pc",
cookiePath: DEFAULT_COOKIE,
};
const a = argv;
for (let i = 0; i < a.length; i++) {
const x = a[i];
if (x === "--sku" && a[i + 1]) {
out.sku = String(a[++i]).trim();
continue;
}
if (x.startsWith("--sku=")) {
out.sku = x.slice(6).trim();
continue;
}
if (x === "--comment-num" && a[i + 1]) {
out.commentNum = Math.max(1, parseInt(a[++i], 10) || 5);
continue;
}
if (x.startsWith("--comment-num=")) {
out.commentNum = Math.max(1, parseInt(x.slice(14), 10) || 5);
continue;
}
if (x === "--shop-type" && a[i + 1]) {
out.shopType = String(a[++i]);
continue;
}
if (x.startsWith("--shop-type=")) {
out.shopType = x.slice(12);
continue;
}
if (x === "--source" && a[i + 1]) {
out.source = String(a[++i]);
continue;
}
if (x.startsWith("--source=")) {
out.source = x.slice(9);
continue;
}
if (x === "--cookie-file" && a[i + 1]) {
out.cookiePath = String(a[++i]);
continue;
}
if (x.startsWith("--cookie-file=")) {
out.cookiePath = x.slice(14);
continue;
}
}
return out;
}
try {
const cli = parseItemCommentCliArgs();
if (!cli.sku) throw new Error("需要 --sku商品 SKU与 item.jd.com/{sku}.html 一致)");
const { cookie, uuid, xApiEidToken } = loadJdSearchAuth(cli.cookiePath);
if (!cookie) throw new Error("Cookie 为空或不存在jd_cookie.txt 或 --cookie-file");
if (!uuid || !xApiEidToken)
throw new Error("缺少 uuid 或 x-api-eid-tokenCookie 中 __jdu/mba_muid 与 3AB9D23F7A4B3CSS");
const pack = get_h5st_item_lego_detail_comment({
sku: cli.sku,
commentNum: cli.commentNum,
shopType: cli.shopType,
source: cli.source,
});
const url = build_item_lego_comment_api_url(pack, {
uuid,
xApiEidToken,
bodyMode: "json",
});
const headers = buildJdPcItemCommentHeaders({
cookie,
sku: cli.sku,
});
process.stdout.write(JSON.stringify({ url, headers }));
} catch (e) {
console.error(e.message || String(e));
process.exit(1);
}

View File

@ -0,0 +1,666 @@
# -*- coding: utf-8 -*-
"""
京东商品详情页评论getLegoWareDetailComment
由同目录 Node ``jd_export_item_comment_request.js`` 生成 **url + headers**
ParamsSign / h5st ``jd_h5st_item_comment.js``再用 **Playwright/Chromium**
GET ``search/jd_search_playwright.py`` 相同方式浏览器 TLS减轻 jfe 403
依赖: pip install playwright && playwright install chromium
鉴权 Cookie Node 读入请求头路径见下方配置项 ``COOKIE_FILE`` / ``COOKIE_OVERRIDE``
用法本仓库默认: 修改下方运行配置 ``python jd_h5_item_comment_requests.py``无命令行参数
"""
from __future__ import annotations
import csv
import json
import random
import subprocess
import sys
import time
from io import StringIO
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from playwright.sync_api import sync_playwright
_JD_PKG_ROOT = Path(__file__).resolve().parent.parent
if str(_JD_PKG_ROOT) not in sys.path:
sys.path.insert(0, str(_JD_PKG_ROOT))
from common.jd_delay_utils import parse_request_delay_range
from _low_gi_root import low_gi_project_root # noqa: E402
_JD_COMMENT_DIR = Path(__file__).resolve().parent
# ---------------------------------------------------------------------------
# 运行配置(按需改这里)
# ---------------------------------------------------------------------------
# 路径:副本通过 LOW_GI_PROJECT_ROOT 指向「Low GI」根目录
_PROJECT_ROOT = low_gi_project_root()
_PROJECT_DATA = _PROJECT_ROOT / "data" / "JD"
_JD_COMMON_COOKIE = Path(__file__).resolve().parents[1] / "common" / "jd_cookie.txt"
# SKU单个商品 ID与 SKU_FILE 二选一
SKU = "10166848058665"
# SKU_FILE每行一个 SKU# 注释);与 SKU 二选一
SKU_FILE = ""
# COMMENT_NUM首屏 Lego 请求 body.commentNum评价条数相关
COMMENT_NUM = 5
# SHOP_TYPEbody.shopType一般 "0"
SHOP_TYPE = "0"
# COOKIE_FILE传给 Node 读 Cookie 的文件路径(与搜索/详情共用 jd_cookie.txt
COOKIE_FILE = str(_JD_COMMON_COOKIE)
# COOKIE_OVERRIDE非空则覆盖请求头中的 Cookie
COOKIE_OVERRIDE = ""
# TIMEOUT_SEC单次 Playwright GET/POST 超时(秒)
TIMEOUT_SEC = 30.0
# REQUEST_DELAY每次发起新 HTTP 前的随机等待,如 "30-60""0-0" 可关闭
REQUEST_DELAY = "30-60"
# OUT_JSONL网络采集时每条接口一行 JSONL空则打印 stdout
OUT_JSONL = str(_PROJECT_DATA / "jd_comments.jsonl")
# PRETTY单 SKU 且无 OUT_JSONL 时是否缩进打印业务 JSONparsed
PRETTY = False
# RAISE_HTTPTrue 时 HTTP 非 2xx 直接退出进程
RAISE_HTTP = False
# HEADEDTrue 有头浏览器
HEADED = False
# COMMENTS_OUT解析后的扁平评价扩展名 .csv 或 .jsonl可与采集同时写
COMMENTS_OUT = str(_PROJECT_DATA / "jd_comments_flat.csv")
# FROM_JSONL非空则离线模式仅从已存 JSONL 抽评价;须同时设 COMMENTS_OUT不走浏览器
FROM_JSONL = ""
# WITH_COMMENT_LIST首屏 Lego 成功后是否继续请求分页评价列表POST client.action
WITH_COMMENT_LIST = False
# LIST_PAGES列表分页规格如 "1"、"1-5"、"1,3,5"
LIST_PAGES = "1"
# LIST_FUNCTION_ID列表接口 functionId须与抓包一致
LIST_FUNCTION_ID = "getCommentListPage"
# LIST_STYLE非首包分页请求的 style
LIST_STYLE = "1"
# LIST_CATEGORY可选手动 body.category默认从首条评价 maidianInfo 解析)
LIST_CATEGORY = ""
# LIST_FIRST_GUID可选手动 firstCommentGuid默认首屏 commentInfoList[0].guid
LIST_FIRST_GUID = ""
# ---------------------------------------------------------------------------
def export_item_comment_request_json(
sku: str,
*,
comment_num: int = 5,
shop_type: str = "0",
cookie_file: str | None = None,
) -> dict[str, Any]:
"""Node 输出 {url, headers}h5st 与 body 与商品页抓包一致。"""
cmd = [
"node",
str(_JD_COMMENT_DIR / "jd_export_item_comment_request.js"),
"--sku",
str(sku).strip(),
"--comment-num",
str(max(1, int(comment_num))),
"--shop-type",
str(shop_type),
]
cf = (cookie_file or "").strip()
if cf:
cmd.extend(["--cookie-file", cf])
r = subprocess.run(
cmd,
cwd=str(_JD_COMMENT_DIR),
capture_output=True,
text=True,
encoding="utf-8",
)
if r.returncode != 0:
print(r.stderr or r.stdout, file=sys.stderr)
sys.exit(r.returncode or 1)
return json.loads(r.stdout)
def export_item_comment_page_request_json(
sku: str,
*,
category: str,
first_guid: str,
page_num: str,
is_first: bool,
function_id: str,
shop_type: str = "0",
spu_id: str | None = None,
style: str = "1",
cookie_file: str | None = None,
) -> dict[str, Any]:
"""Node 输出 POST client.action{ url, method, form, headers }。"""
cmd = [
"node",
str(_JD_COMMENT_DIR / "jd_export_item_comment_page_request.js"),
"--sku",
str(sku).strip(),
"--category",
str(category).strip(),
"--first-guid",
str(first_guid).strip(),
"--page-num",
str(page_num).strip(),
"--is-first",
"true" if is_first else "false",
"--function-id",
str(function_id).strip(),
"--shop-type",
str(shop_type),
"--style",
str(style),
]
if spu_id and str(spu_id).strip():
cmd.extend(["--spu-id", str(spu_id).strip()])
cf = (cookie_file or "").strip()
if cf:
cmd.extend(["--cookie-file", cf])
r = subprocess.run(
cmd,
cwd=str(_JD_COMMENT_DIR),
capture_output=True,
text=True,
encoding="utf-8",
)
if r.returncode != 0:
print(r.stderr or r.stdout, file=sys.stderr)
sys.exit(r.returncode or 1)
return json.loads(r.stdout)
def _sleep_between_jd_requests(
delay_range: tuple[float, float], label: str = "请求间隔"
) -> None:
"""在「上一包已完成、即将发起下一包」时调用0-0 视为不等待。"""
lo, hi = delay_range
if lo <= 0 and hi <= 0:
return
sec = random.uniform(lo, hi)
print(
f"[京东] {label} sleep {sec:.1f}s区间 {lo:g}{hi:g}",
file=sys.stderr,
)
time.sleep(sec)
def parse_list_pages_spec(spec: str) -> list[str]:
"""
--list-pages``1-5`` 1..5``1,3,5`` 单页序列单数字 ``2`` [\"2\"]。
"""
s = (spec or "").strip()
if not s:
return ["1"]
if "," in s:
return [p.strip() for p in s.split(",") if p.strip()]
if "-" in s:
parts = s.split("-", 1)
lo, hi = int(parts[0].strip()), int(parts[1].strip())
if lo > hi:
lo, hi = hi, lo
return [str(i) for i in range(lo, hi + 1)]
return [s]
def category_and_first_guid_from_lego(parsed: Any) -> tuple[str, str]:
"""从 getLegoWareDetailComment 的 commentInfoList[0] 取 categorymaidianInfo 前缀)与 guid。"""
if not isinstance(parsed, dict):
return "", ""
lst = parsed.get("commentInfoList")
if not isinstance(lst, list) or not lst:
return "", ""
first = lst[0]
if not isinstance(first, dict):
return "", ""
guid = str(first.get("guid") or "").strip()
maidian = str(first.get("maidianInfo") or "").strip()
category = maidian.split("_", 1)[0].strip() if maidian else ""
return category, guid
def _read_sku_lines(path: str) -> list[str]:
p = Path(path)
if not p.is_file():
print(f"文件不存在: {path}", file=sys.stderr)
sys.exit(2)
out: list[str] = []
for line in p.read_text(encoding="utf-8").splitlines():
s = line.strip()
if not s or s.startswith("#"):
continue
out.append(s)
return out
def _loads_jd_plain_json(text: str) -> Any:
s = (text or "").strip()
if not s:
return None
try:
return json.loads(s)
except json.JSONDecodeError:
return None
def _jd_business_ok(parsed: Any) -> bool:
if not isinstance(parsed, dict):
return False
if parsed.get("success") is False:
return False
c = parsed.get("code")
if c is None:
return True
return c == 0 or str(c) == "0"
def _clean_text(v: Any) -> str:
if v is None:
return ""
s = str(v).strip()
return " ".join(s.split()) if s else ""
def _large_pic_urls_from_picture_list(pil: Any) -> list[str]:
out: list[str] = []
if not isinstance(pil, list):
return out
for p in pil:
if not isinstance(p, dict):
continue
u = p.get("largePicURL") or p.get("largePicUrl")
if u:
t = str(u).strip()
if t and t not in out:
out.append(t)
return out
def _is_jd_single_comment_dict(d: dict) -> bool:
"""区分「一条评价」与标签/楼层等对象getCommentListPage 里多为 commentInfo 扁平结构)。"""
cid = d.get("commentId")
if cid is None or str(cid).strip() == "":
return False
if d.get("userNickName") is None and not (
d.get("tagCommentContent") or d.get("commentData")
):
return False
return True
def _walk_collect_comment_dicts(obj: Any, acc: list[dict[str, Any]]) -> None:
"""深度遍历 JSON收集所有像单条评价的 dict含 Lego 的 commentInfoList 项与列表页的 commentInfo"""
if isinstance(obj, dict):
if _is_jd_single_comment_dict(obj):
acc.append(obj)
for v in obj.values():
_walk_collect_comment_dicts(v, acc)
elif isinstance(obj, list):
for x in obj:
_walk_collect_comment_dicts(x, acc)
def _row_from_comment_dict(sku: str, item: dict[str, Any]) -> dict[str, Any]:
text = _clean_text(
item.get("tagCommentContent") or item.get("commentData")
)
buy = _clean_text(
item.get("buyCountText") or item.get("repurchaseInfo")
)
date = _clean_text(
item.get("commentDate") or item.get("newCommentDate")
)
return {
"sku": str(sku).strip(),
"commentId": str(item.get("commentId") or "").strip(),
"userNickName": _clean_text(item.get("userNickName")),
"tagCommentContent": text,
"commentDate": date,
"buyCountText": buy,
"largePicURLs": _large_pic_urls_from_picture_list(
item.get("pictureInfoList")
),
"commentScore":str(item.get("commentScore") or "").strip(),
}
def extract_comment_rows_from_parsed(sku: str, parsed: Any) -> list[dict[str, Any]]:
"""
从整段 parsed 深度遍历抽取评价
- getLegoWareDetailCommentcommentInfoList / lastCommentInfoList
- getCommentListPageresult.floors data { commentInfo: {...} } 已拍平为内层字段同上
"""
if not isinstance(parsed, dict):
return []
acc: list[dict[str, Any]] = []
_walk_collect_comment_dicts(parsed, acc)
seen: set[str] = set()
rows: list[dict[str, Any]] = []
for item in acc:
cid = str(item.get("commentId") or "").strip()
dedup_key = f"{sku}:{cid}" if cid else f"{sku}:{id(item)}"
if dedup_key in seen:
continue
seen.add(dedup_key)
rows.append(_row_from_comment_dict(sku, item))
return rows
def _comment_flat_fieldnames() -> list[str]:
return [
"sku",
"commentId",
"userNickName",
"tagCommentContent",
"commentDate",
"buyCountText",
"largePicURLs",
"commentScore",
]
def _write_comments_csv(path: Path, rows: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
buf = StringIO()
fn = _comment_flat_fieldnames()
w = csv.DictWriter(buf, fieldnames=fn, extrasaction="ignore")
w.writeheader()
for r in rows:
line = {k: r.get(k, "") for k in fn}
line["largePicURLs"] = json.dumps(
r.get("largePicURLs") or [], ensure_ascii=False
)
w.writerow(line)
path.write_text("\ufeff" + buf.getvalue(), encoding="utf-8")
def write_comments_flat_csv(path: Path | str, rows: list[dict[str, Any]]) -> None:
"""与 ``COMMENTS_OUT`` 为 ``.csv`` 时相同格式UTF-8 BOM供流水线等复用。"""
_write_comments_csv(Path(path), rows)
def _append_comments_jsonl(f, rows: list[dict[str, Any]]) -> None:
for r in rows:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
def _extract_rows_from_jsonl_file(path: Path) -> list[dict[str, Any]]:
all_rows: list[dict[str, Any]] = []
for line in path.read_text(encoding="utf-8").splitlines():
s = line.strip()
if not s or s.startswith("#"):
continue
try:
rec = json.loads(s)
except json.JSONDecodeError:
continue
sku = str(rec.get("sku") or "").strip()
parsed = rec.get("parsed")
all_rows.extend(extract_comment_rows_from_parsed(sku, parsed))
return all_rows
def main() -> None:
args = SimpleNamespace(
sku=(SKU or "").strip(),
sku_file=(SKU_FILE or "").strip(),
comment_num=int(COMMENT_NUM),
shop_type=str(SHOP_TYPE),
cookie_file=(COOKIE_FILE or "").strip(),
cookie=(COOKIE_OVERRIDE or "").strip(),
timeout=float(TIMEOUT_SEC),
request_delay=(REQUEST_DELAY or "").strip() or "30-60",
out=(OUT_JSONL or "").strip() or None,
pretty=bool(PRETTY),
raise_http=bool(RAISE_HTTP),
headed=bool(HEADED),
comments_out=(COMMENTS_OUT or "").strip(),
from_jsonl=(FROM_JSONL or "").strip(),
with_comment_list=bool(WITH_COMMENT_LIST),
list_pages=(LIST_PAGES or "1").strip(),
list_function_id=(LIST_FUNCTION_ID or "getCommentListPage").strip(),
list_style=(LIST_STYLE or "1").strip(),
list_category=(LIST_CATEGORY or "").strip(),
list_first_guid=(LIST_FIRST_GUID or "").strip(),
)
comments_out = args.comments_out
from_jsonl = args.from_jsonl
if from_jsonl:
if not comments_out:
print("离线模式:请配置 FROM_JSONL 与 COMMENTS_OUT", file=sys.stderr)
sys.exit(2)
co_path = Path(comments_out)
rows = _extract_rows_from_jsonl_file(Path(from_jsonl))
suf = co_path.suffix.lower()
if suf == ".csv":
_write_comments_csv(co_path, rows)
elif suf == ".jsonl":
co_path.parent.mkdir(parents=True, exist_ok=True)
with co_path.open("w", encoding="utf-8") as cf:
_append_comments_jsonl(cf, rows)
else:
print("COMMENTS_OUT 请使用 .csv 或 .jsonl 扩展名", file=sys.stderr)
sys.exit(2)
print(f"[京东] 已从 JSONL 抽取 {len(rows)} 条评价 → {co_path}", file=sys.stderr)
return
sku_one = args.sku
sku_file = args.sku_file
if bool(sku_one) == bool(sku_file):
print("请只配置其一SKU 或 SKU_FILE", file=sys.stderr)
sys.exit(2)
skus = [sku_one] if sku_one else _read_sku_lines(sku_file)
if not skus:
print("SKU 列表为空", file=sys.stderr)
sys.exit(2)
rd = args.request_delay
try:
delay_range = parse_request_delay_range(rd)
except ValueError as e:
print(f"[京东] REQUEST_DELAY 无效: {e}", file=sys.stderr)
sys.exit(2)
cookie_file_node = (args.cookie_file or "").strip()
if cookie_file_node:
cookie_file_node = str(Path(cookie_file_node).resolve())
timeout_ms = max(1000, int(args.timeout * 1000))
cookie_override = (args.cookie or "").strip()
out_f = None
if args.out:
outp = Path(args.out)
outp.parent.mkdir(parents=True, exist_ok=True)
out_f = outp.open("w", encoding="utf-8")
comments_path = Path(comments_out) if comments_out else None
comments_csv_rows: list[dict[str, Any]] = []
comments_jsonl_f = None
if comments_path is not None:
suf = comments_path.suffix.lower()
if suf not in (".csv", ".jsonl"):
print("COMMENTS_OUT 请使用 .csv 或 .jsonl 扩展名", file=sys.stderr)
sys.exit(2)
comments_path.parent.mkdir(parents=True, exist_ok=True)
if suf == ".jsonl":
comments_jsonl_f = comments_path.open("w", encoding="utf-8")
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=not args.headed)
context = browser.new_context()
try:
gap_before_next = False
for sku in skus:
if gap_before_next:
_sleep_between_jd_requests(delay_range, "请求间隔")
pack = export_item_comment_request_json(
sku,
comment_num=args.comment_num,
shop_type=str(args.shop_type),
cookie_file=cookie_file_node or None,
)
url = pack["url"]
hdrs = {str(k): str(v) for k, v in pack["headers"].items()}
if cookie_override:
hdrs["Cookie"] = cookie_override
resp = context.request.get(url, headers=hdrs, timeout=timeout_ms)
status = resp.status
text = resp.text()
print(
f"[京东] sku={sku} HTTP {status} {resp.status_text or ''}",
file=sys.stderr,
)
if args.raise_http and status // 100 != 2:
print(f"[京东] HTTP {status}--raise-http 已启用", file=sys.stderr)
sys.exit(1)
parsed = _loads_jd_plain_json(text)
http_ok = 200 <= status < 300
row = {
"sku": sku,
"http_status": status,
"http_ok": http_ok,
"ok": http_ok and _jd_business_ok(parsed),
"parsed": parsed,
"raw": text if parsed is None else None,
}
line = json.dumps(row, ensure_ascii=False)
if out_f:
out_f.write(line + "\n")
elif len(skus) == 1 and args.pretty and parsed is not None:
sys.stdout.write(
json.dumps(parsed, ensure_ascii=False, indent=2) + "\n"
)
elif len(skus) == 1:
sys.stdout.write(line + "\n")
else:
sys.stdout.write(line + "\n")
flat = extract_comment_rows_from_parsed(sku, parsed)
if comments_path is not None:
if comments_jsonl_f is not None:
_append_comments_jsonl(comments_jsonl_f, flat)
else:
comments_csv_rows.extend(flat)
gap_before_next = True
if args.with_comment_list:
cat, fguid = category_and_first_guid_from_lego(parsed)
if (args.list_category or "").strip():
cat = (args.list_category or "").strip()
if (args.list_first_guid or "").strip():
fguid = (args.list_first_guid or "").strip()
if not cat or not fguid:
print(
"[京东] --with-comment-list 跳过:缺少 category 或 firstCommentGuid"
"请确认首屏有评价,或使用 --list-category / --list-first-guid",
file=sys.stderr,
)
else:
pages = parse_list_pages_spec(args.list_pages or "1")
lfid = (args.list_function_id or "getCommentListPage").strip()
lstyle = (args.list_style or "1").strip()
for pi, pnum in enumerate(pages):
if gap_before_next:
_sleep_between_jd_requests(
delay_range, "列表分页请求间隔"
)
is_first = pi == 0
pack_p = export_item_comment_page_request_json(
sku,
category=cat,
first_guid=fguid,
page_num=pnum,
is_first=is_first,
function_id=lfid,
shop_type=str(args.shop_type),
cookie_file=cookie_file_node or None,
style=lstyle,
)
url_p = pack_p["url"]
hdrs_p = {
str(k): str(v)
for k, v in pack_p["headers"].items()
}
if cookie_override:
hdrs_p["Cookie"] = cookie_override
form_p = pack_p.get("form") or {}
form_pw = {
str(k): str(v) for k, v in form_p.items()
}
resp_p = context.request.post(
url_p,
headers=hdrs_p,
form=form_pw,
timeout=timeout_ms,
)
st_p = resp_p.status
text_p = resp_p.text()
print(
f"[京东] sku={sku} 列表页 pageNum={pnum} "
f"HTTP {st_p} {resp_p.status_text or ''}",
file=sys.stderr,
)
if args.raise_http and st_p // 100 != 2:
print(
"[京东] 列表分页 --raise-http 已启用",
file=sys.stderr,
)
sys.exit(1)
parsed_p = _loads_jd_plain_json(text_p)
http_ok_p = 200 <= st_p < 300
row_p = {
"sku": sku,
"kind": "comment_list_page",
"page_num": pnum,
"http_status": st_p,
"http_ok": http_ok_p,
"ok": http_ok_p
and _jd_business_ok(parsed_p),
"parsed": parsed_p,
"raw": text_p if parsed_p is None else None,
}
line_p = json.dumps(row_p, ensure_ascii=False)
if out_f:
out_f.write(line_p + "\n")
else:
sys.stdout.write(line_p + "\n")
flat_p = extract_comment_rows_from_parsed(
sku, parsed_p
)
if comments_path is not None:
if comments_jsonl_f is not None:
_append_comments_jsonl(
comments_jsonl_f, flat_p
)
else:
comments_csv_rows.extend(flat_p)
gap_before_next = True
finally:
browser.close()
finally:
if out_f:
out_f.close()
if comments_jsonl_f:
comments_jsonl_f.close()
if comments_path is not None and comments_path.suffix.lower() == ".csv":
_write_comments_csv(comments_path, comments_csv_rows)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,114 @@
/**
* 商品详情 getLegoWareDetailComment h5stParamsSign appId fb5df
* jd_h5st.js 分离避免改动搜索列表 pc_search 链路
*/
require("../common/jd_browser_env.js");
require("../common/code.js");
const CryptoJS = require("crypto-js");
const ITEM_COMMENT_PARAMS_SIGN_APP_ID = "fb5df";
let _psign = null;
function _ensurePsignItemComment() {
if (!_psign) {
_psign = new window.ParamsSign({
appId: ITEM_COMMENT_PARAMS_SIGN_APP_ID,
preRequest: false,
onSign: () => {},
onRequestTokenRemotely: () => {},
});
}
return _psign;
}
/**
* @param {object} opt
* @param {number|string} opt.sku
* @param {number} [opt.commentNum=5]
* @param {string} [opt.shopType='0']
* @param {string} [opt.source='pc']
* @param {number} [opt.t]
*/
function get_h5st_item_lego_detail_comment(opt) {
const o = opt || {};
const sku = o.sku != null ? Number(o.sku) : NaN;
if (!Number.isFinite(sku) || sku <= 0) {
throw new Error("get_h5st_item_lego_detail_comment: 需要有效 opt.sku");
}
const commentNum = Math.max(
1,
parseInt(String(o.commentNum != null ? o.commentNum : 5), 10) || 5
);
const shopType = o.shopType != null ? String(o.shopType) : "0";
const source = o.source != null ? String(o.source) : "pc";
const time = o.t != null ? Number(o.t) : Date.now();
const bodyObj = {
shopType,
sku,
commentNum,
source,
};
const bodyJson = JSON.stringify(bodyObj);
const bodySha = CryptoJS.SHA256(bodyJson).toString();
const functionId = "getLegoWareDetailComment";
const paramsH5sign = {
appid: "item-v3",
functionId,
client: "pc",
clientVersion: "1.0.0",
t: time,
body: bodySha,
};
const signed = _ensurePsignItemComment()._$sdnmd({
...paramsH5sign,
});
return {
h5st: signed.h5st,
signed,
bodyJson,
bodySha256: signed.body,
bodyObj,
tQuerySecond: String(signed.t),
};
}
/**
* @param {object} pack get_h5st_item_lego_detail_comment 返回值
* @param {object} opts
* @param {string} opts.uuid
* @param {string} opts.xApiEidToken
* @param {'json'|'sha256'} [opts.bodyMode='json']
*/
function build_item_lego_comment_api_url(pack, opts) {
const uuid = opts.uuid != null ? String(opts.uuid) : "";
const xApiEidToken =
opts.xApiEidToken != null ? String(opts.xApiEidToken) : "";
const bodyMode = opts.bodyMode === "sha256" ? "sha256" : "json";
const signed = pack.signed;
const bodyValue = bodyMode === "sha256" ? pack.bodySha256 : pack.bodyJson;
const build = opts.build != null ? String(opts.build) : "100000";
const qParts = [
["functionId", signed.functionId],
["body", bodyValue],
["h5st", signed.h5st],
["uuid", uuid],
["loginType", "3"],
["appid", signed.appid],
["clientVersion", signed.clientVersion],
["client", signed.client],
["t", pack.tQuerySecond],
["x-api-eid-token", xApiEidToken],
["build", build],
];
const qs = qParts
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
return `https://api.m.jd.com/?${qs}`;
}
module.exports = {
get_h5st_item_lego_detail_comment,
build_item_lego_comment_api_url,
};

View File

@ -0,0 +1,172 @@
/**
* 商品页评价列表分页POST https://api.m.jd.com/client.action
*application/x-www-form-urlencoded item.jd.com 抓包一致
*
* - 参与签名的 appid**pc-rate-qa** item-v3
* - ParamsSign 构造器 appId**01a47**h5st 第三段 fb5df
* - 表单字段appidbodyclientclientVersionfunctionIdh5stloginTypetuuid
*
* 首包 isFirstRequest:true style后续包 isFirstRequest:false 且含 style:"1"
*/
require("../common/jd_browser_env.js");
require("../common/code.js");
const CryptoJS = require("crypto-js");
const COMMENT_LIST_CLIENT_ACTION_APPID = "pc-rate-qa";
/** 与浏览器 h5st 中第三段一致 */
const COMMENT_LIST_PARAMS_SIGN_APP_ID = "01a47";
let _psign = null;
function _ensurePsign() {
if (!_psign) {
_psign = new window.ParamsSign({
appId: COMMENT_LIST_PARAMS_SIGN_APP_ID,
preRequest: false,
onSign: () => {},
onRequestTokenRemotely: () => {},
});
}
return _psign;
}
function _extInfoBlock(spuId) {
const s = String(spuId != null ? spuId : "");
return {
isQzc: "0",
spuId: s,
commentRate: "1",
needTopAlbum: "1",
bbtf: "",
userGroupComment: "1",
};
}
/**
* @param {object} opt
* @param {string} opt.sku
* @param {string} opt.category 36574;44419;44439来自首屏 maidianInfo
* @param {string} opt.firstCommentGuid 首条评价 guid
* @param {string|number} opt.pageNum
* @param {boolean} opt.isFirstRequest
* @param {string} [opt.shopType='0']
* @param {string} [opt.spuId] 默认与 sku 字符串相同
* @param {string} [opt.style='1'] isFirstRequest false 时写入 body
* @param {string} [opt.num='10']
* @param {string} [opt.pageSize='10']
* @param {string} [opt.sortType='5']
* @param {number} [opt.t]
*/
function build_item_comment_page_body(opt) {
const o = opt || {};
const skuStr = String(o.sku != null ? o.sku : "").trim();
if (!skuStr) throw new Error("build_item_comment_page_body: 需要 opt.sku");
const categoryStr = String(o.category != null ? o.category : "").trim();
if (!categoryStr) throw new Error("build_item_comment_page_body: 需要 opt.category");
const guid = String(o.firstCommentGuid != null ? o.firstCommentGuid : "").trim();
if (!guid) throw new Error("build_item_comment_page_body: 需要 opt.firstCommentGuid");
const shopTypeStr = o.shopType != null ? String(o.shopType) : "0";
const spuId = o.spuId != null ? String(o.spuId) : skuStr;
const pageNum = String(o.pageNum != null ? o.pageNum : "1");
const isFirst = Boolean(o.isFirstRequest);
const num = o.num != null ? String(o.num) : "10";
const pageSize = o.pageSize != null ? String(o.pageSize) : "10";
const sortType = o.sortType != null ? String(o.sortType) : "5";
const extInfo = _extInfoBlock(spuId);
/** @type {Record<string, unknown>} */
const base = {
requestSource: "pc",
shopComment: 0,
sameComment: 0,
channel: null,
extInfo,
num,
pictureCommentType: "A",
scval: null,
shadowMainSku: "0",
shopType: shopTypeStr,
firstCommentGuid: guid,
sku: skuStr,
category: categoryStr,
shieldCurrentComment: "1",
pageSize,
isFirstRequest: isFirst,
};
if (!isFirst) {
base.style = o.style != null ? String(o.style) : "1";
}
base.isCurrentSku = false;
base.sortType = sortType;
base.tagId = "";
base.tagType = "";
base.type = "0";
base.pageNum = pageNum;
return base;
}
/**
* @param {object} opt build_item_comment_page_body另需 functionId
* @param {string} opt.functionId
*/
function get_h5st_item_comment_page(opt) {
const o = opt || {};
const functionId = o.functionId != null ? String(o.functionId).trim() : "";
if (!functionId) throw new Error("get_h5st_item_comment_page: 需要 opt.functionId");
const time = o.t != null ? Number(o.t) : Date.now();
const bodyObj = build_item_comment_page_body(o);
const bodyJson = JSON.stringify(bodyObj);
const bodySha = CryptoJS.SHA256(bodyJson).toString();
const paramsH5sign = {
appid: COMMENT_LIST_CLIENT_ACTION_APPID,
functionId,
client: "pc",
clientVersion: "1.0.0",
t: time,
body: bodySha,
};
const signed = _ensurePsign()._$sdnmd({ ...paramsH5sign });
return {
h5st: signed.h5st,
signed,
bodyJson,
bodySha256: signed.body,
bodyObj,
tQuerySecond: String(signed.t),
};
}
const CLIENT_ACTION_URL = "https://api.m.jd.com/client.action";
/**
* POST client.action x-www-form-urlencoded 字段顺序与常见抓包一致
* @param {object} pack get_h5st_item_comment_page 返回值
* @param {object} opts
* @param {string} opts.uuid
*/
function build_item_comment_page_client_action_form(pack, opts) {
const uuid = opts.uuid != null ? String(opts.uuid) : "";
if (!uuid) throw new Error("build_item_comment_page_client_action_form: 需要 opts.uuid");
const signed = pack.signed;
return {
url: CLIENT_ACTION_URL,
form: {
appid: COMMENT_LIST_CLIENT_ACTION_APPID,
body: pack.bodyJson,
client: signed.client,
clientVersion: signed.clientVersion,
functionId: signed.functionId,
h5st: signed.h5st,
loginType: "3",
t: pack.tQuerySecond,
uuid,
},
};
}
module.exports = {
build_item_comment_page_body,
get_h5st_item_comment_page,
build_item_comment_page_client_action_form,
COMMENT_LIST_CLIENT_ACTION_APPID,
COMMENT_LIST_PARAMS_SIGN_APP_ID,
};

View File

@ -0,0 +1,34 @@
/**
* Chrome 访问 item.jd.com api.m.jd.com getLegoWareDetailComment 的请求头对齐
*/
function buildJdPcItemCommentHeaders(opts) {
const sku = opts.sku != null ? String(opts.sku).trim() : "";
const referer = sku
? `https://item.jd.com/${encodeURIComponent(sku)}.html`
: "https://item.jd.com/";
const h = {
Accept: "application/json, text/plain, */*",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN,zh;q=0.9",
"Cache-Control": "no-cache",
Pragma: "no-cache",
Priority: "u=1, i",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
Referer: referer,
Origin: "https://item.jd.com",
"sec-ch-ua":
'"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
"x-referer-page": referer,
"x-rp-client": "h5_1.0.0",
};
if (opts.cookie) h.Cookie = opts.cookie;
return h;
}
module.exports = { buildJdPcItemCommentHeaders };

View File

@ -0,0 +1 @@
# 京东 PC 爬虫共享Cookie、签名环境、请求间隔工具等。

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,131 @@
/**
* Node 下为 crawler/code.jsParamsSign / h5st 相关补齐最小浏览器全局环境
*
* 用法必须在 require("./code.js") 之前
* require("./jd_browser_env.js");
*
* 说明仅满足当前 bundle 在加载期访问到的 DOM/BOM若京东更新脚本仍可能缺字段再按报错补桩
*/
(function applyJDBrowserEnv() {
const g = globalThis;
if (g.__JD_BROWSER_ENV_APPLIED__) {
return;
}
g.__JD_BROWSER_ENV_APPLIED__ = true;
g.window = g;
function Element() {}
Element.prototype.scrollIntoViewIfNeeded = function () {};
g.Element = Element;
const memStore = Object.create(null);
const storage = {
getItem(k) {
return Object.prototype.hasOwnProperty.call(memStore, k)
? memStore[k]
: null;
},
setItem(k, v) {
memStore[String(k)] = String(v);
},
removeItem(k) {
delete memStore[String(k)];
},
clear() {
for (const k of Object.keys(memStore)) {
delete memStore[k];
}
},
};
g.document = {
all: null,
cookie: "",
domain: "jd.com",
referrer: "https://search.jd.com/",
createElement() {
return Object.assign(new Element(), {
style: {},
appendChild() {},
setAttribute() {},
remove() {},
});
},
getElementsByTagName() {
return [{ appendChild() {} }];
},
querySelector() {
return null;
},
};
g.navigator = {
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
language: "zh-CN",
languages: ["zh-CN", "zh", "en"],
mimeTypes: { length: 0 },
plugins: { length: 0 },
appVersion: "5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
platform: "Win32",
webdriver: false,
hardwareConcurrency: 8,
};
g.location = {
href: "https://search.jd.com/Search?keyword=&enc=utf-8",
origin: "https://search.jd.com",
protocol: "https:",
host: "search.jd.com",
pathname: "/Search",
search: "",
};
g.history = {
replaceState() {},
pushState() {},
back() {},
};
g.screen = {
width: 1920,
height: 1080,
availWidth: 1920,
availHeight: 1040,
};
g.outerWidth = 1920;
g.outerHeight = 1080;
g.innerWidth = 1920;
g.innerHeight = 969;
g.devicePixelRatio = 1;
g.chrome = {};
g.localStorage = { ...storage };
g.sessionStorage = { ...storage };
function XMLHttpRequest() {
this.readyState = 0;
this.status = 0;
this.responseText = "";
}
XMLHttpRequest.prototype.open = function () {};
XMLHttpRequest.prototype.setRequestHeader = function () {};
XMLHttpRequest.prototype.send = function () {};
XMLHttpRequest.prototype.abort = function () {};
g.XMLHttpRequest = XMLHttpRequest;
g.getComputedStyle = function () {
return {};
};
g.MutationObserver = function () {
this.observe = function () {};
this.disconnect = function () {};
};
g.WebKitMutationObserver = g.MutationObserver;
})();
module.exports = {};

View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
"""搜索/评论等脚本共用的「请求间隔」解析与 sleep避免 comment 依赖整份 jd_h5_search_requests"""
from __future__ import annotations
import random
import sys
import time
def parse_request_delay_range(s: str | None) -> tuple[float, float]:
"""
解析 CLIMIN-MAX为随机等待区间
``30-60`` uniform(30, 60)
"""
t = (s or "").strip()
if not t:
raise ValueError("空字符串")
parts = t.split("-", 1)
if len(parts) != 2:
raise ValueError(f"应为 MIN-MAX如 30-60收到: {t!r}")
lo = float(parts[0].strip())
hi = float(parts[1].strip())
if lo < 0 or hi < 0:
raise ValueError("延迟不能为负")
if lo > hi:
lo, hi = hi, lo
return (lo, hi)
def sleep_pc_search_request_gap(delay_range: tuple[float, float] | None) -> None:
"""在已有至少一次请求之后、发起下一次之前调用。"""
if not delay_range:
return
lo, hi = delay_range
sec = random.uniform(lo, hi)
print(
f"[京东] pc_search 间隔 sleep {sec:.1f}s区间 {lo:g}{hi:g}",
file=sys.stderr,
)
time.sleep(sec)

View File

@ -0,0 +1,60 @@
/**
* 直连 HTTPS GET不走代理自动按 Content-Encoding 解压 gzip/deflate/br
*/
const https = require("https");
const zlib = require("zlib");
const { URL } = require("url");
function decodeBody(buf, headers) {
if (!buf || !buf.length) return "";
const enc = String(headers["content-encoding"] || "").toLowerCase();
try {
if (enc.includes("br")) return zlib.brotliDecompressSync(buf).toString("utf8");
if (enc.includes("gzip")) return zlib.gunzipSync(buf).toString("utf8");
if (enc.includes("deflate")) return zlib.inflateSync(buf).toString("utf8");
} catch {
/* 非压缩或损坏时按原文 UTF-8 */
}
return buf.toString("utf8");
}
function httpsGet(urlString, headers) {
return new Promise((resolve, reject) => {
const u = new URL(urlString);
const opt = {
hostname: u.hostname,
port: u.port || 443,
path: u.pathname + u.search,
method: "GET",
headers: { ...headers, Host: u.hostname },
};
const req = https.request(opt, (res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
const buf = Buffer.concat(chunks);
const body = decodeBody(buf, res.headers);
resolve({
status: res.statusCode || 0,
statusMessage: res.statusMessage || "",
headers: res.headers,
body,
});
});
});
req.on("error", (err) => {
if (err && err.name === "AggregateError" && err.errors && err.errors[0]) {
reject(err.errors[0]);
} else {
reject(err);
}
});
req.setTimeout(45000, () => {
req.destroy();
reject(new Error("timeout"));
});
req.end();
});
}
module.exports = { httpsGet };

View File

@ -0,0 +1,110 @@
const fs = require("fs");
const path = require("path");
const DEFAULT_COOKIE = path.join(__dirname, "jd_cookie.txt");
function readCookieFile(p = DEFAULT_COOKIE) {
if (!fs.existsSync(p)) return "";
const chunks = [];
for (const line of fs.readFileSync(p, "utf8").split(/\r?\n/)) {
const s = line.trim();
if (!s || s.startsWith("#")) continue;
chunks.push(s);
}
return chunks.join("; ").trim();
}
function cookieGet(cookie, name) {
const m = new RegExp(`(?:^|;\\s*)${name}=([^;]*)`).exec(cookie);
return m ? decodeURIComponent(m[1].trim()) : "";
}
function parseSearchCliArgs(argv = process.argv.slice(2)) {
const out = {
q: process.env.JD_KEYWORD || "低GI",
page: Math.max(1, parseInt(process.env.JD_PAGE || "1", 10) || 1),
s: Math.max(1, parseInt(process.env.JD_S || "1", 10) || 1),
pvid: (process.env.JD_PVID || "").trim() || null,
cookiePath: DEFAULT_COOKIE,
};
const a = argv;
for (let i = 0; i < a.length; i++) {
const x = a[i];
if (x === "--q" && a[i + 1]) {
out.q = a[++i];
continue;
}
if (x.startsWith("--q=")) {
out.q = x.slice(4);
continue;
}
// --page写入 pc_search **body.page**(与 Python 脚本「逻辑页 L」不同L 的首包为 2L-1
if (x === "--page" && a[i + 1]) {
out.page = Math.max(1, parseInt(a[++i], 10) || 1);
continue;
}
if (x.startsWith("--page=")) {
out.page = Math.max(1, parseInt(x.slice(7), 10) || 1);
continue;
}
if (x === "--s" && a[i + 1]) {
out.s = Math.max(1, parseInt(a[++i], 10) || 1);
continue;
}
if (x.startsWith("--s=")) {
out.s = Math.max(1, parseInt(x.slice(4), 10) || 1);
continue;
}
if (x === "--pvid" && a[i + 1]) {
out.pvid = String(a[++i]).trim() || null;
continue;
}
if (x.startsWith("--pvid=")) {
const v = x.slice(7).trim();
out.pvid = v || null;
continue;
}
if (x === "--cookie-file" && a[i + 1]) {
out.cookiePath = String(a[++i]);
continue;
}
if (x.startsWith("--cookie-file=")) {
out.cookiePath = x.slice(14);
continue;
}
}
// 兼容node script.js 低GI 2首参词、次参为 **body.page**)。不得在有 --q 时把 a[1](往往是关键词)当成 page。
if (a[0] && !a[0].startsWith("-")) {
out.q = a[0];
}
if (a[1] && !a[1].startsWith("-")) {
const pi = parseInt(a[1], 10);
if (!Number.isNaN(pi)) {
out.page = Math.max(1, pi);
}
}
return out;
}
function loadJdSearchAuth(cookiePath = DEFAULT_COOKIE) {
const cookie = readCookieFile(cookiePath);
const uuid =
process.env.JD_UUID ||
cookieGet(cookie, "__jdu") ||
cookieGet(cookie, "mba_muid") ||
"";
const xApiEidToken =
process.env.JD_X_API_EID_TOKEN ||
cookieGet(cookie, "3AB9D23F7A4B3CSS") ||
cookieGet(cookie, "cd_eid") ||
"";
return { cookie, uuid, xApiEidToken };
}
module.exports = {
DEFAULT_COOKIE,
readCookieFile,
cookieGet,
parseSearchCliArgs,
loadJdSearchAuth,
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,135 @@
/**
* stdout 一行 JSON{ url, headers } jd_detail_ware_business_requests.py 使用
*
* cd crawler/jd_pc_search/detail && node jd_export_detail_ware_business_request.js --sku 10166848058665
* node jd_export_detail_ware_business_request.js --sku 10166848058665 --area 19_1601_50258_129167
*
* 与浏览器 URL 对齐可用 --uuid--x-api-eid-token 覆盖同次抓包里 Query 可能与 Cookie 3CSS 不一致
* 亦可设环境变量 JD_UUIDJD_X_API_EID_TOKEN common/jd_search_common.js
*/
const path = require("path");
const { loadJdSearchAuth } = require("../common/jd_search_common.js");
const {
get_h5st_detail_ware_business,
build_detail_ware_business_api_url,
} = require("./jd_h5st_detail_ware_business.js");
const {
buildJdPcDetailWareBusinessHeaders,
} = require("./jd_pc_detail_ware_business_headers.js");
const DEFAULT_COOKIE = path.join(__dirname, "..", "common", "jd_cookie.txt");
function parseCliArgs(argv = process.argv.slice(2)) {
const out = {
skuId: null,
area: null,
num: "1",
sfTime: "1,0,0",
cookiePath: DEFAULT_COOKIE,
uuid: null,
xApiEidToken: null,
};
const a = argv;
for (let i = 0; i < a.length; i++) {
const x = a[i];
if ((x === "--sku" || x === "--sku-id") && a[i + 1]) {
out.skuId = String(a[++i]).trim();
continue;
}
if (x.startsWith("--sku=")) {
out.skuId = x.slice(6).trim();
continue;
}
if (x.startsWith("--sku-id=")) {
out.skuId = x.slice(9).trim();
continue;
}
if (x === "--area" && a[i + 1]) {
out.area = String(a[++i]).trim();
continue;
}
if (x.startsWith("--area=")) {
out.area = x.slice(7).trim();
continue;
}
if (x === "--num" && a[i + 1]) {
out.num = String(a[++i]).trim();
continue;
}
if (x.startsWith("--num=")) {
out.num = x.slice(6).trim();
continue;
}
if (x === "--sf-time" && a[i + 1]) {
out.sfTime = String(a[++i]).trim();
continue;
}
if (x.startsWith("--sf-time=")) {
out.sfTime = x.slice(10).trim();
continue;
}
if (x === "--cookie-file" && a[i + 1]) {
out.cookiePath = String(a[++i]);
continue;
}
if (x.startsWith("--cookie-file=")) {
out.cookiePath = x.slice(14);
continue;
}
if (x === "--uuid" && a[i + 1]) {
out.uuid = String(a[++i]).trim();
continue;
}
if (x.startsWith("--uuid=")) {
out.uuid = x.slice(7).trim();
continue;
}
if (x === "--x-api-eid-token" && a[i + 1]) {
out.xApiEidToken = String(a[++i]).trim();
continue;
}
if (x.startsWith("--x-api-eid-token=")) {
out.xApiEidToken = x.slice(18).trim();
continue;
}
}
return out;
}
try {
const cli = parseCliArgs();
if (!cli.skuId) {
throw new Error("需要 --sku 或 --sku-id商品 SKU");
}
if (cli.uuid) process.env.JD_UUID = cli.uuid;
if (cli.xApiEidToken) process.env.JD_X_API_EID_TOKEN = cli.xApiEidToken;
const { cookie, uuid, xApiEidToken } = loadJdSearchAuth(cli.cookiePath);
if (!cookie) throw new Error("Cookie 为空或不存在jd_cookie.txt 或 --cookie-file");
if (!uuid || !xApiEidToken) {
throw new Error(
"缺少 uuid 或 x-api-eid-tokenCookie 中 __jdu/mba_muid 与 3AB9D23F7A4B3CSS"
);
}
const pack = get_h5st_detail_ware_business({
skuId: cli.skuId,
area: cli.area || undefined,
num: cli.num,
sfTime: cli.sfTime,
});
const url = build_detail_ware_business_api_url(pack, {
uuid,
xApiEidToken,
bodyMode: "json",
});
const headers = buildJdPcDetailWareBusinessHeaders({
cookie,
skuId: cli.skuId,
});
process.stdout.write(JSON.stringify({ url, headers }));
} catch (e) {
console.error(e.message || String(e));
process.exit(1);
}

View File

@ -0,0 +1,109 @@
/**
* 商品详情 pc_detailpage_wareBusinessappid=pc-item-soa h5st
* jd_h5st.js搜索jd_h5st_item_comment.js评论分离
* ParamsSign 使用抓包第二段 appIdfb5df getLegoWareDetailComment 一致
*/
require("../common/jd_browser_env.js");
require("../common/code.js");
const CryptoJS = require("crypto-js");
const DETAIL_WARE_BUSINESS_SIGN_APP_ID = "fb5df";
const DEFAULT_AREA = "19_1601_50258_129167";
let _psign = null;
function _ensurePsign() {
if (!_psign) {
_psign = new window.ParamsSign({
appId: DETAIL_WARE_BUSINESS_SIGN_APP_ID,
preRequest: false,
onSign: () => {},
onRequestTokenRemotely: () => {},
});
}
return _psign;
}
/**
* @param {object} opt
* @param {string|number} opt.skuId 商品 SKU item.jd.com/{sku}.html 一致body 里为字符串
* @param {string} [opt.area]
* @param {string} [opt.num='1']
* @param {string} [opt.sfTime='1,0,0'] PC 详情抓包一致
* @param {number} [opt.t]
*/
function get_h5st_detail_ware_business(opt) {
const o = opt || {};
const skuId = o.skuId != null ? String(o.skuId).trim() : "";
if (!skuId || !/^\d+$/.test(skuId)) {
throw new Error("get_h5st_detail_ware_business: 需要有效 opt.skuId数字 SKU");
}
const area = o.area != null ? String(o.area) : DEFAULT_AREA;
const num = o.num != null ? String(o.num) : "1";
const sfTime = o.sfTime != null ? String(o.sfTime) : "1,0,0";
const time = o.t != null ? Number(o.t) : Date.now();
const bodyObj = {
skuId,
area,
num,
sfTime,
};
const bodyJson = JSON.stringify(bodyObj);
const bodySha = CryptoJS.SHA256(bodyJson).toString();
const functionId = "pc_detailpage_wareBusiness";
const appid = "pc-item-soa";
const paramsH5sign = {
appid,
functionId,
client: "pc",
clientVersion: "1.0.0",
t: time,
body: bodySha,
};
const signed = _ensurePsign()._$sdnmd({ ...paramsH5sign });
return {
h5st: signed.h5st,
signed,
bodyJson,
bodySha256: signed.body,
bodyObj,
tQuerySecond: String(signed.t),
};
}
/**
* https://api.m.jd.com/?functionId=...(与 DevTools 路径一致,无 /api 前缀)。
* Query 键顺序与 Chrome 一致functionId, body, h5st, uuid, loginType, appid,
* clientVersion, client, t, x-api-eid-token
* body JSON 字符串 SHA256键顺序 skuId area num sfTime值均为字符串
*/
function build_detail_ware_business_api_url(pack, opts) {
const uuid = opts.uuid != null ? String(opts.uuid) : "";
const xApiEidToken = opts.xApiEidToken != null ? String(opts.xApiEidToken) : "";
const bodyMode = opts.bodyMode === "sha256" ? "sha256" : "json";
const signed = pack.signed;
const bodyValue = bodyMode === "sha256" ? pack.bodySha256 : pack.bodyJson;
const qParts = [
["functionId", signed.functionId],
["body", bodyValue],
["h5st", signed.h5st],
["uuid", uuid],
["loginType", "3"],
["appid", signed.appid],
["clientVersion", signed.clientVersion],
["client", signed.client],
["t", pack.tQuerySecond],
["x-api-eid-token", xApiEidToken],
];
const qs = qParts
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
return `https://api.m.jd.com/?${qs}`;
}
module.exports = {
get_h5st_detail_ware_business,
build_detail_ware_business_api_url,
DEFAULT_AREA,
};

View File

@ -0,0 +1,40 @@
/**
* Chrome 146 访问 item.jd.com api.m.jd.com pc_detailpage_wareBusiness 的请求头对齐
* Accept-Encoding: zstd DevTools 抓包一致
*
* Playwright APIRequestContext 对部分 sec-ch-* 会忽略 per-request headers
* Python 侧在 ``browser.new_context({ userAgent, extraHTTPHeaders })`` 中重复注入同组
* Client Hints jd_detail_ware_business_requests.py与真实请求一致
*/
function buildJdPcDetailWareBusinessHeaders(opts) {
const sku = opts.skuId != null ? String(opts.skuId).trim() : "";
const itemPage = sku
? `https://item.jd.com/${encodeURIComponent(sku)}.html`
: "https://item.jd.com/";
const h = {
Accept: "application/json, text/plain, */*",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "zh-CN,zh;q=0.9",
"Cache-Control": "no-cache",
Pragma: "no-cache",
Priority: "u=1, i",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
Referer: "https://item.jd.com/",
Origin: "https://item.jd.com",
"sec-ch-ua":
'"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
"x-referer-page": itemPage,
"x-rp-client": "h5_1.0.0",
};
if (opts.cookie) h.Cookie = opts.cookie;
return h;
}
module.exports = { buildJdPcDetailWareBusinessHeaders };

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,886 @@
# -*- coding: utf-8 -*-
"""
关键词 京东 PC 搜索 对若干 SKU 拉取详情pc_detailpage_wareBusiness与评论首屏 Lego
可选继续 ``getCommentListPage`` 分页与同目录 ``jd_h5_item_comment_requests`` 一致
合并为一行 CSV搜索列 + 详情摘要 + 评价摘要
依赖Node搜索/评论签 h5stPlaywright本仓库 ``common/jd_cookie.txt``
用法修改下方运行配置在项目任意目录执行::
python crawler/jd_pc_search/jd_keyword_pipeline.py
::
cd crawler/jd_pc_search && python jd_keyword_pipeline.py
每次运行默认在 ``data/JD/pipeline_runs/<时间戳>_<关键词>/`` 下集中写入合并表
PC 搜索导出 CSV评价扁平 CSV详情汇总 CSV``detail_ware_export.csv``
SKU 规整 JSON``detail/ware_{sku}_response.json``以及可选pc_search 原始包与请求记录
合并表 ``keyword_pipeline_merged.csv`` 默认 ``MERGED_CSV_MODE=lean``搜索全列 + **竞品报告/入库实际用到的商详子集** ``_MERGED_LEAN_DETAIL_FIELDNAMES``+ 评论摘要全量商详扁平请设 ``MERGED_CSV_MODE="full"````WARE_BUSINESS_MERGE_FIELDNAMES``
``detail_ware_export.csv`` 默认 ``DETAIL_WARE_CSV_MODE=lean`` ``skuId`` + 与合并表一致的商详子集品牌/到手价/店铺/类目/参数/配料全列请设 ``DETAIL_WARE_CSV_MODE="full"``
``EXTRACT_INGREDIENTS_FROM_DETAIL_BODY_IMAGES=True``配料列为**配料表文本**``detail_body_ingredients_source_url`` 仅在内存/全量详情 CSV 使用**不写** lean 合并表关闭视觉提取时合并表配料列为 **#detail-main 长图 URL 串**。
默认启用 **应用场景筛选**``brief_content.txt`` 4.1 中式面点/主食 + 4.2 烘焙仅命中关键词的 SKU 进入详情与评论队列词表见 ``scenario_filter.py````SCENARIO_FILTER_ENABLED=False`` 可关闭``SCENARIO_FILTER_PC_SEARCH_CSV="filtered"`` 可使导出 CSV 与筛选后列表一致
SKU 完整接口 JSON 仍在 ``detail/ware_{sku}_response.json``
端到端竞品速览 Markdown配置 ``jd_competitor_report.py`` 顶部 ``KEYWORD`` 后执行 ``python jd_competitor_report.py``内部调用本模块 ``main(keyword=...)``
"""
from __future__ import annotations
import csv
import json
import random
import sys
import time
from io import StringIO
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from playwright.sync_api import sync_playwright
# ---------------------------------------------------------------------------
# 路径与运行配置
# ---------------------------------------------------------------------------
_ROOT = Path(__file__).resolve().parent
from _low_gi_root import low_gi_project_root # noqa: E402
_PROJECT_ROOT = low_gi_project_root()
# 京东采集统一目录(与 search / detail / comment 脚本默认一致;副本依赖 LOW_GI_PROJECT_ROOT
_PROJECT_DATA = _PROJECT_ROOT / "data" / "JD"
_COOKIE_FILE = str((_ROOT / "common" / "jd_cookie.txt").resolve())
# 运行时覆盖(由 Market-Assistant 等在 main() 前设置):非空则优先于 _COOKIE_FILE / 默认空 override
PIPELINE_COOKIE_FILE = ""
PIPELINE_COOKIE_OVERRIDE = ""
# KEYWORD搜索词与 pc_search 一致)
KEYWORD = "低GI"
# PAGE_START / PAGE_TO逻辑页范围与 jd_search_playwright 含义相同)
PAGE_START = 1
PAGE_TO = 2
# PVID可选与搜索结果页 URL 中 pvid 一致时填写
PVID = ""
# REQUEST_DELAYpc_search 包间随机等待,如 "30-60"None 关闭
REQUEST_DELAY = "30-60"
PAGE_DELAY_SEC = 1.2
FETCH_RETRIES = 3
FETCH_RETRY_DELAY_SEC = 3.0
# PIPELINE_RUN_DIR本次运行输出根目录。空则自动创建
# ``data/JD/pipeline_runs/<YYYYMMDD_HHMMSS>_<关键词>/``;非空则用该路径(相对路径相对 data/JD
PIPELINE_RUN_DIR = ""
# 工作台「终止任务」:可调用无参,返回 True 时在可停点结束并写出已采集部分(协作式,非杀进程)
PIPELINE_CANCEL_CHECK = None # Callable[[], bool] | None
# 是否将 pc_search 原始响应 / 请求记录写入运行目录子文件夹(与 jd_search_playwright 一致)
PIPELINE_SAVE_PC_SEARCH_RAW = True
PIPELINE_SAVE_PC_SEARCH_RECORDS = True
# 非空时覆盖上面两项,直接指定目录(与单独跑搜索脚本相同)
SAVE_SEARCH_RAW_DIR = ""
RECORD_SEARCH_REQ_DIR = ""
# MAX_SKUS搜索去重后最多对多少个 SKU 继续拉详情+评论(控制总耗时)
MAX_SKUS = 5
# COMMENT_NUMLego 接口 body.commentNum仅首屏条数更多评价靠分页
COMMENT_NUM = 5
SHOP_TYPE = "0"
# WITH_COMMENT_LIST首屏 Lego 成功后是否继续请求评价列表分页POST client.action
WITH_COMMENT_LIST = True
# LIST_PAGES分页规格如 "1"、"1-5"、"1,3,5"(与 jd_h5_item_comment_requests 相同)
LIST_PAGES = "1-2"
LIST_FUNCTION_ID = "getCommentListPage"
LIST_STYLE = "1"
# LIST_CATEGORY / LIST_FIRST_GUID一般留空从首屏 parsed 解析;抓包不一致时再填
LIST_CATEGORY = ""
LIST_FIRST_GUID = ""
# COMMENT_LIST_DELAY分页请求之间的随机等待空字符串表示沿用 SKU_STEP_DELAY
COMMENT_LIST_DELAY = ""
# SKU_STEP_DELAY每个 SKU 内「详情→首评」及步骤间随机等待(秒)
SKU_STEP_DELAY = "4-10"
# 详情:与 jd_detail_ware_business_requests 一致,结果为空或无效时重试
DETAIL_FETCH_MAX_ATTEMPTS = 3
DETAIL_FETCH_RETRY_DELAY_SEC = 2.0
# USE_CHROMETrue 使用本机 Chrome
USE_CHROME = True
HEADED = False
# 运行目录内固定文件名(一般无需改)
FILE_MERGED_CSV = "keyword_pipeline_merged.csv"
FILE_PC_SEARCH_CSV = "pc_search_export.csv"
FILE_COMMENTS_FLAT_CSV = "comments_flat.csv"
FILE_DETAIL_WARE_CSV = "detail_ware_export.csv"
FILE_RUN_META_JSON = "run_meta.json"
# MERGED_CSV_MODE``lean`` 时合并表为搜索全列 + 商详子集(``_MERGED_LEAN_DETAIL_FIELDNAMES``+ 评论摘要;``full`` 为搜索全列 + ``WARE_BUSINESS_MERGE_FIELDNAMES`` 全量
MERGED_CSV_MODE = "lean"
# DETAIL_WARE_CSV_MODE``lean`` 时 ``detail_ware_export.csv`` 为 ``skuId`` + lean 商详子集;``full`` 为完整详情扁平列(含 http_status 与各 detail_*
DETAIL_WARE_CSV_MODE = "lean"
# 应用场景筛选(对齐 brief 4.1 中式面点/主食 + 4.2 烘焙):仅命中关键词的商品进入详情/评论队列
SCENARIO_FILTER_ENABLED = True
# ``pc_search_export.csv````full`` 保留搜索全量;``filtered`` 仅写入命中场景的行(与详情样本一致)
SCENARIO_FILTER_PC_SEARCH_CSV = "full"
# 若启用筛选后无命中行是否回退为未筛选列表避免跑空False 则仍按空列表继续
SCENARIO_FILTER_FALLBACK_TO_UNFILTERED = True
# True对 ``meta`` 中 ``detail_body_image_urls`` 从后往前调用 ``AI_crawler``**首次**校验通过即写入配料;
# 未命中时列内为 ``【未识别到配料】…`` 原因说明(非空串)。需 .env未配置 API 时写入对应提示。关此开关时该列仍为长图 URL 串。
EXTRACT_INGREDIENTS_FROM_DETAIL_BODY_IMAGES = True
# ---------------------------------------------------------------------------
# 保证可导入 search / comment / detail 下脚本与 common
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
_SEARCH_DIR = _ROOT / "search"
_COMMENT_DIR = _ROOT / "comment"
_DETAIL_DIR = _ROOT / "detail"
for _p in (_SEARCH_DIR, _COMMENT_DIR, _DETAIL_DIR):
s = str(_p.resolve())
if s not in sys.path:
sys.path.insert(0, s)
from collect_pc_search_items import ( # noqa: E402
SearchCollectionCancelled,
collect_pc_search_export_rows,
)
from common.jd_delay_utils import parse_request_delay_range # noqa: E402
from scenario_filter import filter_rows_by_scenario # noqa: E402
from jd_detail_ware_business_requests import ( # noqa: E402
DETAIL_WARE_LEAN_CSV_FIELDNAMES,
WARE_BUSINESS_MERGE_FIELDNAMES,
WARE_PARSED_CSV_FIELDNAMES,
_JD_DETAIL_CONTEXT_EXTRA_HEADERS,
_JD_DETAIL_UA,
detail_ware_lean_csv_row,
fetch_ware_business,
format_ware_response_for_save,
parse_ware_business_response_text,
ware_parsed_row,
)
from jd_h5_item_comment_requests import ( # noqa: E402
category_and_first_guid_from_lego,
export_item_comment_page_request_json,
export_item_comment_request_json,
extract_comment_rows_from_parsed,
parse_list_pages_spec,
write_comments_flat_csv,
)
from jd_h5_search_requests import ( # noqa: E402
CSV_FIELDS,
JD_EXPORT_COLUMN_HEADERS,
jd_row_to_export,
)
_SKU_CSV_HEADER = JD_EXPORT_COLUMN_HEADERS["sku_id"]
_MERGED_EXTRA_FIELDS = (
["pipeline_keyword"]
+ list(WARE_BUSINESS_MERGE_FIELDNAMES)
+ ["comment_count", "comment_preview"]
)
# lean 合并表·商详块jd_competitor_report + ingest + 配料);须与 pipeline/csv_schema.MERGED_LEAN_DETAIL_KEYS 一致
_MERGED_LEAN_DETAIL_FIELDNAMES: tuple[str, ...] = (
"detail_brand",
"detail_price_final",
"detail_shop_name",
"detail_category_path",
"detail_product_attributes",
"detail_body_ingredients",
)
# 合并表精简列:搜索列与 jd_h5_search_requests 一致 + 上表商详子集 + 评论摘要
_MERGED_LEAN_FIELDNAMES: tuple[str, ...] = (
"pipeline_keyword",
"SKU(skuId)",
"主商品ID(wareId)",
"标题(wareName)",
"标价(jdPrice,jdPriceText,realPrice)",
"券后到手价(couponPrice,subsidyPrice,finalPrice.estimatedPrice,priceShow)",
"原价(oriPrice,originalPrice,marketPrice)",
"卖点(sellingPoint)",
"榜单类文案(标签/腰带/标题数组中的榜、TOP 等)",
"评价量(commentFuzzy)",
"销量楼层(commentSalesFloor)",
"店铺名(shopName)",
"商品链接(toUrl,clickUrl,item.m.jd.com)",
"主图(imageurl,imageUrl)",
"规格属性(propertyList,color,catid,shortName)",
"类目(leafCategory,cid3Name,catid)",
"搜索词(keyword)",
"页码(page)",
*_MERGED_LEAN_DETAIL_FIELDNAMES,
"comment_count",
"comment_preview",
)
def _merged_csv_fieldnames() -> list[str]:
if (MERGED_CSV_MODE or "lean").strip().lower() == "full":
return list(CSV_FIELDS) + [
f for f in _MERGED_EXTRA_FIELDS if f not in CSV_FIELDS
]
return list(_MERGED_LEAN_FIELDNAMES)
def _detail_ware_csv_fieldnames() -> list[str]:
if (DETAIL_WARE_CSV_MODE or "lean").strip().lower() == "full":
return list(WARE_PARSED_CSV_FIELDNAMES)
return list(DETAIL_WARE_LEAN_CSV_FIELDNAMES)
def _sleep_range(spec: str, label: str) -> None:
try:
lo, hi = parse_request_delay_range(spec)
except ValueError:
return
if hi <= 0 and lo <= 0:
return
t = random.uniform(lo, hi)
print(f"[流水线] {label} 等待 {t:.1f}s", file=sys.stderr)
time.sleep(t)
def _dedupe_comment_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""按 commentId 去重(跨首屏 + 多页列表)。"""
seen: set[str] = set()
out: list[dict[str, Any]] = []
for r in rows:
cid = str(r.get("commentId") or "").strip()
if cid:
if cid in seen:
continue
seen.add(cid)
out.append(r)
return out
def _comment_fields_from_rows(rows: list[dict[str, Any]]) -> dict[str, str]:
previews: list[str] = []
for r in rows[:8]:
t = str(r.get("tagCommentContent") or "").strip()
if t:
previews.append(t[:400])
joined = " | ".join(previews)[:4000]
return {
"comment_count": str(len(rows)),
"comment_preview": joined,
}
def _loads_json(text: str) -> Any:
try:
return json.loads(text)
except json.JSONDecodeError:
return None
def _safe_dir_segment(s: str, max_len: int = 48) -> str:
bad = '<>:"/\\|?*\n\r\t'
t = "".join("_" if c in bad else c for c in (s or "").strip())[:max_len]
t = t.strip(" .") or "run"
return t
def _resolve_pipeline_run_dir(kw: str) -> Path:
raw = (PIPELINE_RUN_DIR or "").strip()
if raw:
p = Path(raw).expanduser()
if not p.is_absolute():
p = _PROJECT_DATA / p
return p.resolve()
stamp = time.strftime("%Y%m%d_%H%M%S")
seg = _safe_dir_segment(kw)
return (_PROJECT_DATA / "pipeline_runs" / f"{stamp}_{seg}").resolve()
class PipelineCancelled(Exception):
"""工作台请求终止本次流水线;携带已分配的运行目录(可有部分产出)。"""
def __init__(self, run_dir: Path) -> None:
self.run_dir = run_dir.resolve()
super().__init__("pipeline cancelled")
def _pipeline_cancel_requested() -> bool:
fn = PIPELINE_CANCEL_CHECK
try:
return fn is not None and callable(fn) and bool(fn())
except Exception:
return False
def main(keyword: str | None = None) -> Path:
"""
跑完整条流水线``keyword`` 非空时覆盖文件内 ``KEYWORD``返回本次运行目录
``jd_competitor_report`` 等脚本 ``import`` 调用命令行仍执行 ``main()`` 无参
"""
try:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
kw = (keyword if keyword is not None else KEYWORD) or ""
kw = str(kw).strip()
if not kw:
print("[流水线] 请配置 KEYWORD 或传入 keyword 参数", file=sys.stderr)
sys.exit(2)
page_start = max(1, int(PAGE_START))
page_to = PAGE_TO if PAGE_TO is not None else page_start
page_to = max(page_start, int(page_to))
req_delay_range: tuple[float, float] | None = None
if REQUEST_DELAY:
try:
req_delay_range = parse_request_delay_range(str(REQUEST_DELAY).strip())
except ValueError as e:
print(f"[流水线] REQUEST_DELAY 无效: {e}", file=sys.stderr)
sys.exit(2)
run_dir = _resolve_pipeline_run_dir(kw)
run_dir.mkdir(parents=True, exist_ok=True)
stop_pipeline = False
print(f"[流水线] 本次输出目录: {run_dir}", file=sys.stderr)
if (SAVE_SEARCH_RAW_DIR or "").strip():
save_js = Path(SAVE_SEARCH_RAW_DIR).expanduser().resolve()
elif PIPELINE_SAVE_PC_SEARCH_RAW:
save_js = run_dir / "pc_search_raw"
else:
save_js = None
if (RECORD_SEARCH_REQ_DIR or "").strip():
record_req = Path(RECORD_SEARCH_REQ_DIR).expanduser().resolve()
elif PIPELINE_SAVE_PC_SEARCH_RECORDS:
record_req = run_dir / "pc_search_requests"
else:
record_req = None
node_pvid = (PVID or "").strip() or None
search_args = SimpleNamespace(
q=kw,
page_delay=float(PAGE_DELAY_SEC),
fetch_retries=int(FETCH_RETRIES),
fetch_retry_delay=float(FETCH_RETRY_DELAY_SEC),
pretty_raw_json=True,
csv=True,
out="",
)
print(f"[流水线] 搜索词={kw!r} 逻辑页 {page_start}{page_to}", file=sys.stderr)
merged_rows: list[dict[str, str]] = []
all_comment_rows: list[dict[str, Any]] = []
detail_csv_rows: list[dict[str, str]] = []
launch_kw: dict[str, Any] = {"headless": not HEADED}
if USE_CHROME:
launch_kw["channel"] = "chrome"
_ac_mod: Any = None
_ingredient_vision_ok = False
if EXTRACT_INGREDIENTS_FROM_DETAIL_BODY_IMAGES:
try:
import AI_crawler as _ac_mod # noqa: WPS433
_ac_mod._resolve_credentials(None, None, None)
_ingredient_vision_ok = True
except Exception as e:
print(
f"[流水线] 已开启配料视觉提取但未就绪({e}"
f"各 SKU 列 detail_body_ingredients 将写入「未配置 API」类原因说明非 URL",
file=sys.stderr,
)
_pcf = (PIPELINE_COOKIE_FILE or "").strip()
if _pcf:
_pcfp = Path(_pcf).expanduser().resolve()
cookie_path = str(_pcfp) if _pcfp.is_file() else None
if cookie_path is None:
print(
f"[流水线] 警告PIPELINE_COOKIE_FILE 不是有效文件,将回退 common/jd_cookie.txt"
f"{_pcf!r}",
file=sys.stderr,
)
else:
cookie_path = None
if cookie_path is None:
cookie_path = _COOKIE_FILE if Path(_COOKIE_FILE).is_file() else None
_cookie_override = (PIPELINE_COOKIE_OVERRIDE or "").strip()
with sync_playwright() as pw:
browser = pw.chromium.launch(**launch_kw)
ctx = browser.new_context()
try:
export_rows_full = collect_pc_search_export_rows(
ctx,
search_args,
page_start=page_start,
pe=page_to,
req_delay_range=req_delay_range,
save_js_dir=save_js,
record_req_dir=record_req,
node_pvid=node_pvid,
cancel_check=_pipeline_cancel_requested,
node_cookie_file=cookie_path,
)
except SearchCollectionCancelled as e:
export_rows_full = [jd_row_to_export(r) for r in e.partial_rows]
stop_pipeline = True
print(
"[流水线] 已按请求在下一请求前终止 PC 搜索(保留已得行)",
file=sys.stderr,
)
if _pipeline_cancel_requested():
stop_pipeline = True
scenario_filter_on = bool(SCENARIO_FILTER_ENABLED)
scenario_stats: dict[str, Any] | None = None
export_rows_for_skus: list[dict[str, str]] = list(export_rows_full)
if scenario_filter_on:
fr, scenario_stats = filter_rows_by_scenario(export_rows_full)
export_rows_for_skus = fr
print(
f"[流水线] 应用场景筛选:全量 {scenario_stats['input_rows']} 行 → "
f"保留 {scenario_stats['kept_rows']} 行(剔除 {scenario_stats['dropped_rows']}"
f"标签分布 {scenario_stats.get('tag_counts')!r}",
file=sys.stderr,
)
if not export_rows_for_skus and SCENARIO_FILTER_FALLBACK_TO_UNFILTERED:
print(
"[流水线] 筛选后无命中行,已按 SCENARIO_FILTER_FALLBACK_TO_UNFILTERED "
"回退为未筛选列表",
file=sys.stderr,
)
export_rows_for_skus = list(export_rows_full)
scenario_stats = {
**(scenario_stats or {}),
"fallback_unfiltered": True,
}
csv_mode = (SCENARIO_FILTER_PC_SEARCH_CSV or "full").strip().lower()
if csv_mode == "filtered":
rows_for_search_csv = (
export_rows_for_skus
if scenario_filter_on
else list(export_rows_full)
)
else:
rows_for_search_csv = list(export_rows_full)
skus_ordered: list[str] = []
seen: set[str] = set()
for row in export_rows_for_skus:
sid = str(row.get(_SKU_CSV_HEADER) or "").strip()
if not sid or sid in seen:
continue
seen.add(sid)
skus_ordered.append(sid)
if len(skus_ordered) >= max(1, int(MAX_SKUS)):
break
print(
f"[流水线] 搜索导出 {len(export_rows_full)} 行(写入 CSV {len(rows_for_search_csv)} 行),"
f"取前 {len(skus_ordered)} 个 SKU 拉详情+评论",
file=sys.stderr,
)
if _pipeline_cancel_requested():
stop_pipeline = True
search_csv_path = run_dir / FILE_PC_SEARCH_CSV
sbuf = StringIO()
sw = csv.DictWriter(
sbuf, fieldnames=list(CSV_FIELDS), extrasaction="ignore"
)
sw.writeheader()
sw.writerows(rows_for_search_csv)
search_csv_path.write_text("\ufeff" + sbuf.getvalue(), encoding="utf-8")
print(
f"[流水线] 已写 PC 搜索导出 {search_csv_path}",
file=sys.stderr,
)
detail_dir = run_dir / "detail"
detail_dir.mkdir(parents=True, exist_ok=True)
detail_ctx = browser.new_context(
user_agent=_JD_DETAIL_UA,
locale="zh-CN",
timezone_id="Asia/Shanghai",
extra_http_headers=dict(_JD_DETAIL_CONTEXT_EXTRA_HEADERS),
)
page = detail_ctx.new_page()
for idx, sku in enumerate(skus_ordered):
if stop_pipeline or _pipeline_cancel_requested():
stop_pipeline = True
break
if idx > 0:
if stop_pipeline or _pipeline_cancel_requested():
stop_pipeline = True
break
_sleep_range(SKU_STEP_DELAY, "SKU 间隔")
search_row = next(
(
r
for r in export_rows_full
if str(r.get(_SKU_CSV_HEADER) or "").strip() == sku
),
{},
)
merged: dict[str, str] = {k: str(search_row.get(k) or "") for k in CSV_FIELDS}
merged["pipeline_keyword"] = kw
if stop_pipeline or _pipeline_cancel_requested():
stop_pipeline = True
break
d_code, d_text, d_meta = fetch_ware_business(
detail_ctx,
page,
sku,
cookie_file=cookie_path,
timeout_ms=45_000,
cookie_override=_cookie_override,
max_attempts=int(DETAIL_FETCH_MAX_ATTEMPTS),
retry_delay_sec=float(DETAIL_FETCH_RETRY_DELAY_SEC),
cancel_check=_pipeline_cancel_requested,
)
body_for_parse = d_text if d_code == 200 else ""
ware_flat, _wok = parse_ware_business_response_text(body_for_parse)
merged.update(ware_flat)
raw_body_urls = str(d_meta.get("detail_body_image_urls") or "").strip()
_skip_vision_and_later_network = (
stop_pipeline or _pipeline_cancel_requested()
)
if _skip_vision_and_later_network:
stop_pipeline = True
if (
EXTRACT_INGREDIENTS_FROM_DETAIL_BODY_IMAGES
and _ingredient_vision_ok
and not _skip_vision_and_later_network
):
try:
_ex_src = getattr(
_ac_mod,
"extract_ingredients_from_body_image_urls_reversed_with_source",
None,
)
if callable(_ex_src):
di, src_u = _ex_src(raw_body_urls)
merged["detail_body_ingredients"] = di
merged["detail_body_ingredients_source_url"] = (
str(src_u).strip() if src_u else ""
)
else:
merged["detail_body_ingredients"] = (
_ac_mod.extract_ingredients_from_body_image_urls_reversed(
raw_body_urls
)
)
merged["detail_body_ingredients_source_url"] = ""
di = merged["detail_body_ingredients"]
if str(di).startswith("【未识别"):
print(f"[流水线] sku={sku} {di}", file=sys.stderr)
else:
su = str(
merged.get("detail_body_ingredients_source_url") or ""
).strip()
if su:
print(
f"[流水线] sku={sku} 已从详情长图解析配料表,图源: {su}",
file=sys.stderr,
)
else:
print(
f"[流水线] sku={sku} 已从详情长图(自后向前,首次命中)解析配料表",
file=sys.stderr,
)
except Exception as e:
print(
f"[流水线] sku={sku} 配料视觉提取异常: {e}",
file=sys.stderr,
)
merged["detail_body_ingredients"] = (
f"【未识别到配料】识别过程异常:{e}"[:800]
)
merged["detail_body_ingredients_source_url"] = ""
elif EXTRACT_INGREDIENTS_FROM_DETAIL_BODY_IMAGES and not _ingredient_vision_ok:
merged["detail_body_ingredients"] = (
"【未识别到配料】未配置或无效的多模态 API已跳过识别。"
)
merged["detail_body_ingredients_source_url"] = ""
else:
merged["detail_body_ingredients"] = raw_body_urls
merged["detail_body_ingredients_source_url"] = ""
response_body = format_ware_response_for_save(
d_text or "",
normalize=True,
sort_keys=True,
indent=2,
)
(detail_dir / f"ware_{sku}_response.json").write_text(
response_body, encoding="utf-8"
)
_d_ing = str(merged.get("detail_body_ingredients") or "").strip()
_d_src = str(
merged.get("detail_body_ingredients_source_url") or ""
).strip()
if (DETAIL_WARE_CSV_MODE or "lean").strip().lower() == "full":
detail_csv_rows.append(
ware_parsed_row(
sku,
d_code,
d_text or "",
detail_body_ingredients=_d_ing,
detail_body_ingredients_source_url=_d_src,
)
)
else:
detail_csv_rows.append(
detail_ware_lean_csv_row(
sku,
d_code,
d_text or "",
detail_body_ingredients=_d_ing,
detail_body_ingredients_source_url=_d_src,
)
)
if stop_pipeline or _pipeline_cancel_requested():
stop_pipeline = True
merged["comment_count"] = "0"
merged["comment_preview"] = ""
merged_rows.append(merged)
break
_sleep_range(SKU_STEP_DELAY, "详情→评论")
if stop_pipeline or _pipeline_cancel_requested():
stop_pipeline = True
merged["comment_count"] = "0"
merged["comment_preview"] = ""
merged_rows.append(merged)
break
try:
pack = export_item_comment_request_json(
sku,
comment_num=int(COMMENT_NUM),
shop_type=str(SHOP_TYPE),
cookie_file=cookie_path,
)
except SystemExit:
merged["comment_count"] = "0"
merged["comment_preview"] = ""
merged_rows.append(merged)
continue
if stop_pipeline or _pipeline_cancel_requested():
stop_pipeline = True
merged["comment_count"] = "0"
merged["comment_preview"] = ""
merged_rows.append(merged)
break
try:
url = pack["url"]
hdrs = {str(k): str(v) for k, v in pack["headers"].items()}
resp = ctx.request.get(url, headers=hdrs, timeout=45_000)
c_st = resp.status
c_text = resp.text()
parsed = _loads_json(c_text)
comment_rows: list[dict[str, Any]] = []
if isinstance(parsed, dict):
comment_rows.extend(
extract_comment_rows_from_parsed(sku, parsed)
)
list_delay = (
(COMMENT_LIST_DELAY or "").strip() or SKU_STEP_DELAY
)
if (
WITH_COMMENT_LIST
and isinstance(parsed, dict)
and 200 <= c_st < 300
):
cat, fguid = category_and_first_guid_from_lego(parsed)
if (LIST_CATEGORY or "").strip():
cat = LIST_CATEGORY.strip()
if (LIST_FIRST_GUID or "").strip():
fguid = LIST_FIRST_GUID.strip()
if cat and fguid:
pages = parse_list_pages_spec(LIST_PAGES or "1")
lfid = (LIST_FUNCTION_ID or "getCommentListPage").strip()
lstyle = (LIST_STYLE or "1").strip()
for pi, pnum in enumerate(pages):
if stop_pipeline or _pipeline_cancel_requested():
stop_pipeline = True
break
_sleep_range(list_delay, "评论列表分页")
if stop_pipeline or _pipeline_cancel_requested():
stop_pipeline = True
break
try:
pack_p = export_item_comment_page_request_json(
sku,
category=cat,
first_guid=fguid,
page_num=pnum,
is_first=(pi == 0),
function_id=lfid,
shop_type=str(SHOP_TYPE),
cookie_file=cookie_path,
style=lstyle,
)
except SystemExit:
print(
"[流水线] 评论分页 Node 失败,已停止该 SKU 后续分页",
file=sys.stderr,
)
break
if stop_pipeline or _pipeline_cancel_requested():
stop_pipeline = True
break
url_p = pack_p["url"]
hdrs_p = {
str(k): str(v)
for k, v in pack_p["headers"].items()
}
form_p = {
str(k): str(v)
for k, v in (pack_p.get("form") or {}).items()
}
try:
resp_p = ctx.request.post(
url_p,
headers=hdrs_p,
form=form_p,
timeout=45_000,
)
st_p = resp_p.status
text_p = resp_p.text()
except Exception as e:
print(
f"[流水线] 评论分页 POST 异常: {e}",
file=sys.stderr,
)
break
parsed_p = _loads_json(text_p)
if isinstance(parsed_p, dict):
comment_rows.extend(
extract_comment_rows_from_parsed(
sku, parsed_p
)
)
else:
print(
"[流水线] WITH_COMMENT_LIST 已开但缺少 category 或 "
"firstCommentGuid仅保留首屏评价",
file=sys.stderr,
)
comment_rows = _dedupe_comment_rows(comment_rows)
merged.update(_comment_fields_from_rows(comment_rows))
all_comment_rows.extend(comment_rows)
except Exception as e:
print(
f"[流水线] sku={sku} 评论请求异常: {e}",
file=sys.stderr,
)
merged["comment_count"] = "0"
merged["comment_preview"] = ""
merged_rows.append(merged)
print(f"[流水线] [{idx + 1}/{len(skus_ordered)}] sku={sku} OK", file=sys.stderr)
if stop_pipeline:
break
try:
page.close()
except Exception:
pass
try:
detail_ctx.close()
except Exception:
pass
browser.close()
out_path = run_dir / FILE_MERGED_CSV
fieldnames = _merged_csv_fieldnames()
buf = StringIO()
w = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore")
w.writeheader()
w.writerows(merged_rows)
out_path.write_text("\ufeff" + buf.getvalue(), encoding="utf-8")
print(
f"[流水线] 已写合并表 {out_path}{len(merged_rows)}"
f"MERGED_CSV_MODE={MERGED_CSV_MODE!r}{len(fieldnames)} 列)",
file=sys.stderr,
)
detail_csv_path = run_dir / FILE_DETAIL_WARE_CSV
detail_csv_path.parent.mkdir(parents=True, exist_ok=True)
detail_fn = _detail_ware_csv_fieldnames()
with detail_csv_path.open("w", encoding="utf-8-sig", newline="") as dcf:
dw = csv.DictWriter(
dcf,
fieldnames=detail_fn,
extrasaction="ignore",
)
dw.writeheader()
dw.writerows(detail_csv_rows)
print(
f"[流水线] 已写详情扁平表 {detail_csv_path}{len(detail_csv_rows)}"
f"DETAIL_WARE_CSV_MODE={DETAIL_WARE_CSV_MODE!r}{len(detail_fn)} 列)",
file=sys.stderr,
)
comments_path = run_dir / FILE_COMMENTS_FLAT_CSV
write_comments_flat_csv(comments_path, all_comment_rows)
print(
f"[流水线] 已写评价扁平表 {comments_path}{len(all_comment_rows)}",
file=sys.stderr,
)
meta = {
"keyword": kw,
"page_start": page_start,
"page_to": page_to,
"max_skus_config": int(MAX_SKUS),
"extract_ingredients_from_detail_body_images": bool(
EXTRACT_INGREDIENTS_FROM_DETAIL_BODY_IMAGES
),
"ingredient_vision_api_ready": bool(_ingredient_vision_ok),
"scenario_filter_enabled": bool(SCENARIO_FILTER_ENABLED),
"scenario_filter_pc_search_csv": (SCENARIO_FILTER_PC_SEARCH_CSV or "full")
.strip()
.lower(),
"scenario_filter_stats": scenario_stats,
"pc_search_export_rows": len(rows_for_search_csv),
"pc_search_export_rows_full": len(export_rows_full),
"merged_rows": len(merged_rows),
"merged_csv_mode": (MERGED_CSV_MODE or "lean").strip().lower(),
"merged_csv_column_count": len(fieldnames),
"detail_ware_csv_mode": (DETAIL_WARE_CSV_MODE or "lean").strip().lower(),
"detail_ware_csv_column_count": len(detail_fn),
"comment_flat_rows": len(all_comment_rows),
"detail_ware_csv_rows": len(detail_csv_rows),
"with_comment_list": bool(WITH_COMMENT_LIST),
"list_pages": (LIST_PAGES or "").strip(),
}
(run_dir / FILE_RUN_META_JSON).write_text(
json.dumps(meta, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
if stop_pipeline:
print("[流水线] 已按请求终止(已写出当前进度)", file=sys.stderr)
raise PipelineCancelled(run_dir)
return run_dir
if __name__ == "__main__":
main()

View File

@ -0,0 +1,18 @@
{
"name": "jd_pc_search",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"crypto-js": "^4.2.0"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
}
}
}

View File

@ -0,0 +1,5 @@
{
"dependencies": {
"crypto-js": "^4.2.0"
}
}

View File

@ -0,0 +1,152 @@
# -*- coding: utf-8 -*-
"""
根据 ``brief_content.txt`` 4.1 / 4.2 应用场景对京东搜索导出行做关键词保留筛选
- **保留**标题/卖点/类目/规格中至少命中一类场景词中式面点主食或烘焙
- **剔除**未命中任一类如饮料保健品与面点/烘焙无关的零食等
说明基于展示文案的规则匹配边界案例需人工复核可随时扩充 ``_KW_41`` / ``_KW_42``
"""
from __future__ import annotations
from typing import Any
# 与 CSV 导出列名一致jd_h5_search_requests.CSV_FIELDS 子集)
_SCENARIO_TEXT_FIELDS: tuple[str, ...] = (
"标题(wareName)",
"卖点(sellingPoint)",
"类目(leafCategory,cid3Name,catid)",
"规格属性(propertyList,color,catid,shortName)",
)
# 4.1 中式(米)面点及主食(含常见同义/细分)
_KW_41: tuple[str, ...] = (
"包子",
"馒头",
"花卷",
"饺子",
"饺皮",
"饺子皮",
"水饺",
"蒸饺",
"锅贴",
"馄饨",
"云吞",
"抄手",
"面条",
"挂面",
"拉面",
"刀削面",
"凉面",
"冷面",
"热干面",
"意面",
"方便面",
"泡面",
"速食面",
"米粉",
"米线",
"河粉",
"粉丝",
"螺蛳粉",
"米糕",
"年糕",
"糍粑",
"发糕",
"重组米",
"重组大米",
"大米",
"米饭",
"杂粮饭",
"白米饭",
"自热米饭",
"煲仔饭",
"烧麦",
"烧卖",
"面皮",
"春卷",
"手抓饼",
"葱油饼",
"馅饼",
"烧饼",
"油条",
"窝头",
"窝窝头",
"荞麦面",
"青稞",
"全麦面条",
)
# 4.2 烘焙(含与 brief 一致的低温慢烤饼干等)
_KW_42: tuple[str, ...] = (
"面包",
"吐司",
"列巴",
"欧包",
"贝果",
"可颂",
"牛角",
"蛋糕",
"糕点",
"饼干",
"曲奇",
"烘焙",
"酥饼",
"桃酥",
"威化",
"华夫",
"司康",
"蛋挞",
"月饼",
"酥性",
"苏打饼干",
"全麦面包",
"手撕面包",
)
def scenario_row_text(row: dict[str, Any]) -> str:
parts: list[str] = []
for k in _SCENARIO_TEXT_FIELDS:
parts.append(str(row.get(k) or ""))
return "\n".join(parts)
def row_scenario_match(row: dict[str, Any]) -> tuple[bool, str]:
"""
是否命中应用场景第二个返回值为标签 ``4.1`` / ``4.2`` / ``4.1+4.2`` / ````
"""
text = scenario_row_text(row)
hit41 = any(kw in text for kw in _KW_41)
hit42 = any(kw in text for kw in _KW_42)
if hit41 and hit42:
return True, "4.1+4.2"
if hit41:
return True, "4.1"
if hit42:
return True, "4.2"
return False, ""
def filter_rows_by_scenario(
rows: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""
保持原顺序仅保留命中 4.1 4.2 的行
返回 (filtered_rows, stats)
"""
kept: list[dict[str, Any]] = []
tag_counts: dict[str, int] = {}
for r in rows:
ok, tag = row_scenario_match(r)
if ok:
kept.append(r)
tag_counts[tag] = tag_counts.get(tag, 0) + 1
stats: dict[str, Any] = {
"input_rows": len(rows),
"kept_rows": len(kept),
"dropped_rows": len(rows) - len(kept),
"tag_counts": tag_counts,
}
return kept, stats

View File

@ -0,0 +1,487 @@
# -*- coding: utf-8 -*-
"""
pc_search 多逻辑页采集 ``jd_search_playwright`` ``jd_keyword_pipeline`` 共用
"""
from __future__ import annotations
import json
import re
import sys
import time
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Callable
from urllib.parse import parse_qs, unquote, urlparse
class SearchCollectionCancelled(Exception):
"""工作台请求终止:携带已解析、尚未 jd_row_to_export 的累计行。"""
def __init__(self, partial_rows: list[dict[str, str]]) -> None:
self.partial_rows = partial_rows
super().__init__("search collection cancelled")
from jd_h5_search_requests import (
JD_PC_SEARCH_CHUNKS_PER_LOGICAL_PAGE,
JD_PC_SEARCH_FALLBACK_S_STEP,
JD_PC_SEARCH_ITEMS_PER_PAGE,
_detect_blocked,
_jd_row_count_for_page,
export_pc_search_request_json,
jd_pc_api_body_page_first_pack,
jd_row_to_export,
parse_items_and_pc_search_s_step_from_response_body,
pc_search_response_is_empty_ware_list,
pc_search_should_retry_fetch,
pc_search_ware_list_slot_count_from_body,
sleep_pc_search_request_gap,
)
def _pc_request_record_from_url(url: str) -> dict[str, object]:
u = urlparse(url)
q = parse_qs(u.query, keep_blank_values=True)
flat: dict[str, object] = {}
for k, v in q.items():
flat[k] = v[0] if len(v) == 1 else v
body_raw = flat.get("body")
body_json: object = None
if isinstance(body_raw, str):
try:
body_json = json.loads(unquote(body_raw))
except json.JSONDecodeError:
body_json = body_raw
return {
"url_host": u.netloc,
"url_path": u.path,
"query_params": flat,
"body_param_json": body_json,
}
def save_pc_request_record(
directory: Path,
seq: int,
*,
label: str,
keyword: str,
api_page: int,
api_s: int,
log_ctx: str,
url: str,
headers: dict[str, str],
http_status: int,
status_text: str,
content_type: str,
) -> None:
directory.mkdir(parents=True, exist_ok=True)
safe_label = re.sub(r"[^\w.\-]+", "_", label).strip("_") or "req"
path = directory / f"pc_request_{seq:03d}_{safe_label}_p{api_page}_s{api_s}.json"
record: dict[str, object] = {
"seq": seq,
"keyword": keyword,
"log_ctx": log_ctx,
"api_body_page": api_page,
"api_body_s": api_s,
"http_status": http_status,
"http_status_text": status_text,
"response_content_type": content_type,
"request_url_full": url,
**_pc_request_record_from_url(url),
"request_headers": headers,
}
path.write_text(
json.dumps(record, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(f"[京东] 已保存请求记录: {path}", file=sys.stderr)
def save_pc_search_response_raw(
directory: Path,
seq: int,
body: str,
*,
label: str,
req_page: int,
req_s: int,
pretty: bool,
) -> None:
directory.mkdir(parents=True, exist_ok=True)
ext = "json" if pretty else "js"
safe_label = re.sub(r"[^\w.\-]+", "_", label).strip("_") or "resp"
path = directory / f"pc_search_{seq:03d}_{safe_label}_req_p{req_page}_s{req_s}.{ext}"
if pretty:
try:
out = json.dumps(json.loads(body), ensure_ascii=False, indent=2) + "\n"
except json.JSONDecodeError:
out = body
else:
out = body
path.write_text(out, encoding="utf-8")
print(f"[京东] 已保存原始响应: {path}", file=sys.stderr)
def collect_pc_search_export_rows(
context: Any,
args: SimpleNamespace,
*,
page_start: int,
pe: int,
req_delay_range: tuple[float, float] | None,
save_js_dir: Path | None,
record_req_dir: Path | None,
node_pvid: str | None,
cancel_check: Callable[[], bool] | None = None,
node_cookie_file: str | None = None,
) -> list[dict[str, str]]:
"""
执行与 ``jd_search_playwright`` 相同的多页 pc_search 逻辑返回
``jd_row_to_export`` 后的行CSV 表头为键不写 CSV 文件
"""
fetch_seq: list[int] = [0]
all_rows: list[dict[str, str]] = []
seen: set[str] = set()
api_page = 1
api_s = 1
run_aborted = False
n_api_requests = 0
def _fetch_pc_body_pw(
ap: int,
as_: int,
*,
log_ctx: str = "",
save_label: str = "fetch",
apply_request_gap: bool = True,
) -> tuple[str, str, int]:
nonlocal n_api_requests
if cancel_check is not None and cancel_check():
raise SearchCollectionCancelled(list(all_rows))
if apply_request_gap and n_api_requests > 0:
sleep_pc_search_request_gap(req_delay_range)
if cancel_check is not None and cancel_check():
raise SearchCollectionCancelled(list(all_rows))
n_api_requests += 1
fetch_seq[0] += 1
seq_n = fetch_seq[0]
data = export_pc_search_request_json(
args.q,
ap,
s=as_,
pvid=node_pvid,
cookie_file=node_cookie_file,
)
if cancel_check is not None and cancel_check():
raise SearchCollectionCancelled(list(all_rows))
u = data["url"]
hdrs = {str(k): str(v) for k, v in data["headers"].items()}
r = context.request.get(u, headers=hdrs)
ctx = f" {log_ctx}" if log_ctx else ""
print(
f"[京东]{ctx} body.page={ap} body.s={as_} "
f"HTTP {r.status} {r.status_text}",
file=sys.stderr,
)
ct = r.headers.get("content-type", "") or ""
if record_req_dir is not None:
save_pc_request_record(
record_req_dir,
seq_n,
label=save_label,
keyword=args.q,
api_page=ap,
api_s=as_,
log_ctx=log_ctx,
url=u,
headers=hdrs,
http_status=r.status,
status_text=r.status_text or "",
content_type=ct,
)
return u, r.text(), seq_n
max_fetch_tries = max(1, int(args.fetch_retries) + 1)
retry_pause = max(0.0, float(args.fetch_retry_delay))
last_s_step = JD_PC_SEARCH_FALLBACK_S_STEP
for _skip_screen in range(max(0, page_start - 1)):
for _skip_chunk in range(JD_PC_SEARCH_CHUNKS_PER_LOGICAL_PAGE):
sl_base = (
f"skip_screen{_skip_screen + 1}_"
f"chunk{_skip_chunk + 1}"
)
url, body, seq_n = "", "", 0
_skip_rows: list[dict[str, str]] = []
s_step = 0
blocked: str | None = None
for rt in range(max_fetch_tries):
sl = sl_base if rt == 0 else f"{sl_base}_retry{rt}"
if rt > 0:
if retry_pause > 0:
time.sleep(retry_pause)
print(
f"[京东] 跳过前序屏 重试 {rt}/{max_fetch_tries - 1} "
f"body.page={api_page} body.s={api_s}",
file=sys.stderr,
)
url, body, seq_n = _fetch_pc_body_pw(
api_page,
api_s,
log_ctx="跳过前序屏",
save_label=sl,
apply_request_gap=(rt == 0),
)
if save_js_dir is not None:
save_pc_search_response_raw(
save_js_dir,
seq_n,
body,
label=sl,
req_page=api_page,
req_s=api_s,
pretty=args.pretty_raw_json,
)
blocked = _detect_blocked(body)
if blocked:
break
_skip_rows, s_step = (
parse_items_and_pc_search_s_step_from_response_body(
body,
keyword=args.q,
page=page_start,
request_api_page=api_page,
request_body_s=api_s,
)
)
if _skip_rows or s_step > 0:
break
if rt + 1 < max_fetch_tries and pc_search_should_retry_fetch(
body, has_rows=bool(_skip_rows), s_step=s_step
):
continue
break
if blocked:
print(
f"[京东] 跳过前序屏时 body.page={api_page} body.s={api_s}{blocked}",
file=sys.stderr,
)
print(f" 当前 URL: {url[:160]}", file=sys.stderr)
run_aborted = True
break
if s_step <= 0 and not _skip_rows:
if pc_search_response_is_empty_ware_list(body):
print(
"[京东] 跳过前序屏:接口返回空 wareList停止",
file=sys.stderr,
)
run_aborted = True
break
print(
f"[京东] 跳过前序屏 本包仍无有效数据,按 Δs={last_s_step} 强推进游标并继续",
file=sys.stderr,
)
api_page += 1
api_s += last_s_step
continue
last_s_step = max(last_s_step, s_step)
api_page += 1
api_s += s_step
if run_aborted:
break
if not run_aborted:
expect_after_skip = jd_pc_api_body_page_first_pack(page_start)
if api_page != expect_after_skip:
print(
f"[京东] 警告:跳过前序页后首包 body.page 应为 {expect_after_skip},当前 {api_page}",
file=sys.stderr,
)
if not run_aborted:
for user_p in range(page_start, pe + 1):
page_aborted = False
expect_first = jd_pc_api_body_page_first_pack(user_p)
if api_page != expect_first:
print(
f"[京东] 警告:逻辑第{user_p}页 首包 body.page 应为 {expect_first},当前 {api_page}",
file=sys.stderr,
)
for _attempt in range(JD_PC_SEARCH_CHUNKS_PER_LOGICAL_PAGE):
sl_base = (
f"logic{user_p}_"
f"chunk{_attempt + 1}of{JD_PC_SEARCH_CHUNKS_PER_LOGICAL_PAGE}"
)
url, body, seq_n = "", "", 0
rows: list[dict[str, str]] = []
s_step = 0
blocked: str | None = None
for rt in range(max_fetch_tries):
sl = sl_base if rt == 0 else f"{sl_base}_retry{rt}"
if rt > 0:
if retry_pause > 0:
time.sleep(retry_pause)
print(
f"[京东] 逻辑第{user_p}页 第{_attempt + 1}"
f"重试 {rt}/{max_fetch_tries - 1} "
f"body.page={api_page} body.s={api_s}",
file=sys.stderr,
)
url, body, seq_n = _fetch_pc_body_pw(
api_page,
api_s,
log_ctx=(
f"逻辑第{user_p}"
f"{_attempt + 1}/{JD_PC_SEARCH_CHUNKS_PER_LOGICAL_PAGE}"
),
save_label=sl,
apply_request_gap=(rt == 0),
)
if save_js_dir is not None:
save_pc_search_response_raw(
save_js_dir,
seq_n,
body,
label=sl,
req_page=api_page,
req_s=api_s,
pretty=args.pretty_raw_json,
)
blocked = _detect_blocked(body)
if blocked:
break
rows, s_step = (
parse_items_and_pc_search_s_step_from_response_body(
body,
keyword=args.q,
page=user_p,
request_api_page=api_page,
request_body_s=api_s,
)
)
if rows or s_step > 0:
break
if rt + 1 < max_fetch_tries and pc_search_should_retry_fetch(
body, has_rows=bool(rows), s_step=s_step
):
continue
break
if blocked:
print(
f"[京东] CSV第{user_p}"
f"body.page={api_page} body.s={api_s}{blocked}",
file=sys.stderr,
)
print(f" 当前 URL: {url[:160]}", file=sys.stderr)
page_aborted = True
break
if not rows and s_step <= 0:
if pc_search_response_is_empty_ware_list(body):
print(
f"[京东] CSV第{user_p}"
f"body.page={api_page} body.s={api_s}"
f"接口空 wareList无更多商品停止采集",
file=sys.stderr,
)
page_aborted = True
break
print(
f"[京东] CSV第{user_p}"
f"body.page={api_page} body.s={api_s}"
f"多次重试后仍无商品;按 Δs={last_s_step} 强推进并继续下一包",
file=sys.stderr,
)
print(f" 当前 URL: {url[:160]}", file=sys.stderr)
out_dbg = getattr(args, "out", None)
if out_dbg and args.csv:
dbg = Path(out_dbg).with_suffix(
".debug.json"
if body.lstrip().startswith("{")
else ".debug.html"
)
dbg.write_text(body, encoding="utf-8")
print(f" 已保存调试样本: {dbg}", file=sys.stderr)
api_page += 1
api_s += last_s_step
continue
if not rows and s_step > 0:
last_s_step = max(last_s_step, s_step)
api_page += 1
api_s += s_step
continue
pack_skus = {
(r.get("sku_id") or "").strip()
for r in rows
if (r.get("sku_id") or "").strip()
}
dup_vs_accumulated = len(pack_skus & seen)
n_added = 0
for r in rows:
sku = (r.get("sku_id") or "").strip()
if not sku or sku in seen:
continue
if (
_jd_row_count_for_page(all_rows, user_p)
>= JD_PC_SEARCH_ITEMS_PER_PAGE
):
break
seen.add(sku)
all_rows.append(r)
n_added += 1
slots = pc_search_ware_list_slot_count_from_body(body)
slot_s = str(slots) if slots is not None else "?"
slot_gap = ""
if slots is not None and len(rows) < slots:
slot_gap = (
f"{slots - len(rows)} 个槽为无 SKU 占位(活动/对比卡等)未入库"
)
dup_note = ""
if dup_vs_accumulated:
dup_note = (
f";本包 {dup_vs_accumulated} 个 SKU 与此前已采重复"
f"(去重后本包新增 {n_added}"
)
print(
f"[京东] 逻辑第{user_p}页 第{_attempt + 1}"
f"wareList 槽位={slot_s},本包解析 {len(rows)}"
f"{slot_gap},新增 CSV {n_added}{dup_note}",
file=sys.stderr,
)
last_s_step = max(last_s_step, s_step)
api_page += 1
api_s += s_step
if page_aborted:
run_aborted = True
break
if user_p < pe:
if cancel_check is not None and cancel_check():
raise SearchCollectionCancelled(list(all_rows))
time.sleep(max(0.0, args.page_delay))
if not run_aborted:
per_page = [
_jd_row_count_for_page(all_rows, p)
for p in range(page_start, pe + 1)
]
print(
f"[京东] 小结:逻辑页 {page_start}{pe}"
f"各页 CSV 行数(按 page 列){per_page}"
f"目标≤{JD_PC_SEARCH_ITEMS_PER_PAGE}/页;"
f"pc_search {n_api_requests} 次,全局去重合计 {len(all_rows)}"
f" 说明:每包列表槽位数以响应 wareList 长度为准(会随场景变化);"
f"一屏内多包槽位之和也不是固定值。"
f"CSV 为 SKU 全局去重行数,不必等于各包槽位之和;"
f"若「新增 CSV」远小于当包槽位多为游标重叠核对 body.s 与上文逐包日志)。",
file=sys.stderr,
)
return [jd_row_to_export(r) for r in all_rows]

View File

@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
"""从 pc_search dump .js 中按 wareList 顺序导出 sku_id、shortName每文件一屏槽位"""
from __future__ import annotations
import argparse
import csv
import json
import sys
from pathlib import Path
# 同目录导入
sys.path.insert(0, str(Path(__file__).resolve().parent))
from jd_h5_search_requests import ( # noqa: E402
JD_SKU_KEYS,
_jd_flatten_ware,
_sval_jd,
)
def _short_name(d: dict) -> str:
d0 = _jd_flatten_ware(d)
sn = d0.get("shortName")
if sn is None:
return ""
return str(sn).strip()
def _sku_id(d: dict) -> str:
return _sval_jd(_jd_flatten_ware(d), JD_SKU_KEYS).strip()
def main() -> None:
p = argparse.ArgumentParser(description="wareList 槽位 → sku_id + shortName CSV")
p.add_argument(
"js_files",
nargs="+",
type=Path,
help="按顺序的 dump .jsJSON 一行)",
)
p.add_argument(
"-o",
"--out",
type=Path,
required=True,
help="输出 CSV 路径",
)
args = p.parse_args()
rows: list[dict[str, str]] = []
seq = 0
for fi, path in enumerate(args.js_files, start=1):
text = path.read_text(encoding="utf-8")
payload = json.loads(text)
wl = (payload.get("data") or {}).get("wareList")
if not isinstance(wl, list):
raise SystemExit(f"{path}: 无 data.wareList")
for slot, w in enumerate(wl):
seq += 1
if not isinstance(w, dict):
rows.append(
{
"seq": str(seq),
"sku_id": "",
"shortName": "",
}
)
continue
rows.append(
{
"seq": str(seq),
"sku_id": _sku_id(w),
"shortName": _short_name(w),
}
)
args.out.parent.mkdir(parents=True, exist_ok=True)
with args.out.open("w", encoding="utf-8-sig", newline="") as fp:
w = csv.DictWriter(fp, fieldnames=["seq", "sku_id", "shortName"])
w.writeheader()
w.writerows(rows)
print(f"Wrote {len(rows)} rows -> {args.out}", file=sys.stderr)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,36 @@
/**
* stdout 输出一行 JSON{ url, headers } jd_search_playwright.py 使用h5st 来自 get_h5st
*
* cd crawler/jd_pc_search/search && node jd_export_search_request.js --q 低GI --page 1
* # --page / --s **API 请求体** body.page / body.s Python 里的逻辑页L逻辑页 L 的首包为 page=2L-1
* node jd_export_search_request.js --q 低GI --page 3 --s 45
* set JD_PVID=搜索页 URL 里的 pvid # 与浏览器 body.pvidReferer 一致可选
*/
const { parseSearchCliArgs, loadJdSearchAuth } = require("../common/jd_search_common.js");
const { get_h5st, build_pc_search_api_url } = require("./jd_h5st.js");
const { buildJdPcSearchWareHeaders } = require("./jd_pc_api_headers.js");
try {
const { q, page, s, pvid: pvidOpt, cookiePath } = parseSearchCliArgs();
const { cookie, uuid, xApiEidToken } = loadJdSearchAuth(cookiePath);
if (!cookie) throw new Error("Cookie 为空或文件不存在(--cookie-file 或 common/jd_cookie.txt");
if (!uuid || !xApiEidToken) throw new Error("缺少 uuid 或 x-api-eid-tokenCookie");
const pvid = pvidOpt && String(pvidOpt).trim();
const pack = get_h5st(pvid ? { page, s, pvid } : { page, s });
const url = build_pc_search_api_url(pack, {
keyword: q,
uuid,
xApiEidToken,
bodyMode: "json",
});
const headers = buildJdPcSearchWareHeaders({
cookie,
keyword: q,
pvid: pack.searchParams.pvid,
});
process.stdout.write(JSON.stringify({ url, headers }));
} catch (e) {
console.error(e.message || String(e));
process.exit(1);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,136 @@
/**
* 统一封装h5st 只通过 get_h5st() 获取内部 ParamsSign / code.js
* 流程get_h5st(opt) build_pc_search_api_url(pack, { keyword, uuid, xApiEidToken }) 直连 GET
*
* 商品评论签名见 ../comment/jd_h5st_item_comment.js不修改本文件搜索列表链路
*/
require("../common/jd_browser_env.js");
require("../common/code.js");
const CryptoJS = require("crypto-js");
const DEFAULT_PVID = "90ac040818aa42a389a880e3b119e375";
const DEFAULT_AREA = "19_1601_50258_129167";
let _psign = null;
function _ensurePsign() {
if (!_psign) {
_psign = new window.ParamsSign({
appId: "f06cc",
preRequest: false,
onSign: () => {},
onRequestTokenRemotely: () => {},
});
}
return _psign;
}
/**
* 生成 h5st 及签名侧字段与传入签名的 body SHA256(hex) 一致
*
* @param {object} [opt]
* @param {number} [opt.page=1] 请求 body.page懒加载时每次续拉后浏览器会 page+1 body.s 一起变
* @param {string} [opt.pvid]
* @param {string} [opt.area]
* @param {number} [opt.s=1] 请求 body.s续拉上一包 s + max(1, 本包自然位1) s=1 且自然位 22 次包 s=22
* @param {string|number} [opt.psort='3'] 排序 PC 搜索一致'3' 为按销量与京东前台销量选项对应
* @param {number} [opt.t] 签名字段 t默认 Date.now()
* @param {string} [opt.functionId] 默认 pc_search_searchWare
* @returns {{
* h5st: string,
* signed: object,
* bodyJson: string,
* bodySha256: string,
* searchParams: object,
* tQuerySecond: string
* }}
*/
function get_h5st(opt) {
const o = opt || {};
const page = Math.max(1, parseInt(String(o.page != null ? o.page : 1), 10) || 1);
const pvid = o.pvid != null ? String(o.pvid) : DEFAULT_PVID;
const area = o.area != null ? String(o.area) : DEFAULT_AREA;
const time = o.t != null ? Number(o.t) : Date.now();
const functionId = o.functionId || "pc_search_searchWare";
const psort = o.psort != null ? String(o.psort) : "3";
const searchParams = {
area,
concise: false,
enc: "utf-8",
hoverPictures: false,
mode: null,
newAdvRepeat: false,
new_interval: true,
page,
pvid,
s: o.s != null ? o.s : 1,
psort,
};
const bodyJson = JSON.stringify(searchParams);
const bodySha = CryptoJS.SHA256(bodyJson).toString();
const paramsH5sign = {
appid: "search-pc-java",
functionId,
client: "pc",
clientVersion: "1.0.0",
t: time,
body: bodySha,
};
const signed = _ensurePsign()._$sdnmd({ ...paramsH5sign });
const tQuerySecond = String(Date.now());
return {
h5st: signed.h5st,
signed,
bodyJson,
bodySha256: signed.body,
searchParams,
tQuerySecond,
};
}
/**
* https://api.m.jd.com/api?...query 里两个 tbody 为 JSON 或 SHA256 与签名一致)
*
* @param {object} pack get_h5st() 返回值
* @param {object} opts
* @param {string} opts.keyword
* @param {string} opts.uuid
* @param {string} opts.xApiEidToken
* @param {'json'|'sha256'} [opts.bodyMode='json']
*/
function build_pc_search_api_url(pack, opts) {
const keyword = opts.keyword != null ? String(opts.keyword) : "";
const uuid = opts.uuid != null ? String(opts.uuid) : "";
const xApiEidToken = opts.xApiEidToken != null ? String(opts.xApiEidToken) : "";
const bodyMode = opts.bodyMode === "sha256" ? "sha256" : "json";
const signed = pack.signed;
const bodyValue = bodyMode === "sha256" ? pack.bodySha256 : pack.bodyJson;
const qParts = [
["appid", signed.appid],
["t", String(signed.t)],
["client", signed.client],
["clientVersion", signed.clientVersion],
["cthr", "1"],
["uuid", uuid],
["loginType", "3"],
["keyword", keyword],
["functionId", signed.functionId],
["body", bodyValue],
["x-api-eid-token", xApiEidToken],
["h5st", signed.h5st],
["t", pack.tQuerySecond],
];
const qs = qParts
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
return `https://api.m.jd.com/api?${qs}`;
}
module.exports = {
get_h5st,
getH5st: get_h5st,
build_pc_search_api_url,
};

View File

@ -0,0 +1,45 @@
/**
* Chrome 访问 search.jd.com api.m.jd.com pc_search_searchWare 请求头对齐
*含首包懒加载第二包body page/s 会变头字段形态与 DevTools 一致
* Accept-Encoding 与常见 Chrome 一致../common/jd_https_fetch.js 会解压 gzip/deflate/br
*
* spmTag 可用环境变量 JD_SPM_TAG 覆盖
*/
function buildJdPcSearchWareHeaders(opts) {
const keyword = opts.keyword || "低GI";
const pvid = opts.pvid || "90ac040818aa42a389a880e3b119e375";
const spmTag =
opts.spmTag ||
process.env.JD_SPM_TAG ||
"YTAyMTkuYjAwMjM1Ni5jMDAwMDQ2ODkuc2VhcmNoX2NvbmZpcm1";
const encKw = encodeURIComponent(keyword);
const encPvid = encodeURIComponent(pvid);
const encSpm = encodeURIComponent(spmTag);
const referer = `https://search.jd.com/Search?keyword=${encKw}&enc=utf-8&wq=${encKw}&pvid=${encPvid}&spmTag=${encSpm}`;
const h = {
Accept: "application/json, text/plain, */*",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN,zh;q=0.9",
"Cache-Control": "no-cache",
Pragma: "no-cache",
Priority: "u=1, i",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
Referer: referer,
Origin: "https://search.jd.com",
"sec-ch-ua":
'"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
"x-referer-page": "https://search.jd.com/Search",
"x-rp-client": "h5_1.0.0",
};
if (opts.cookie) h.Cookie = opts.cookie;
return h;
}
module.exports = { buildJdPcSearchWareHeaders };

View File

@ -0,0 +1,233 @@
# -*- coding: utf-8 -*-
"""
get_h5stNode URL + HeaderPlaywright/Chromium GET浏览器 TLS避免 Node 直连 jfe 403
响应解析与 ``jd_h5_search_requests.py`` 一致``_detect_blocked``
``parse_items_and_pc_search_s_step_from_response_body``优先读响应里的下一跳 s否则启发式
多页合并去重CSV / 文件输出
依赖: pip install playwright && playwright install chromium
用法: 修改下方运行配置后执行 ``python jd_search_playwright.py``无命令行参数
多页采集逻辑与落盘辅助函数在 ``collect_pc_search_items.py``供本脚本与上级 ``jd_keyword_pipeline.py`` 共用
"""
from __future__ import annotations
import csv
import json
import sys
import time
from io import StringIO
from pathlib import Path
from types import SimpleNamespace
from playwright.sync_api import sync_playwright
from collect_pc_search_items import (
collect_pc_search_export_rows,
save_pc_request_record,
save_pc_search_response_raw,
)
from jd_h5_search_requests import CSV_FIELDS, export_pc_search_request_json, parse_request_delay_range
_JD_PC_SEARCH = Path(__file__).resolve().parents[1]
if str(_JD_PC_SEARCH) not in sys.path:
sys.path.insert(0, str(_JD_PC_SEARCH))
from _low_gi_root import low_gi_project_root # noqa: E402
# ---------------------------------------------------------------------------
# 运行配置(按需改这里)
# ---------------------------------------------------------------------------
# 路径:副本通过 LOW_GI_PROJECT_ROOT 指向「Low GI」根目录
_PROJECT_ROOT = low_gi_project_root()
_PROJECT_DATA = _PROJECT_ROOT / "data" / "JD"
# QUERY搜索关键词写入 pc_search 请求与 Referer
QUERY = "低GI"
# PVID与 search.jd.com 结果页 URL 中 pvid 一致时填入;空则用 Node 内默认值
PVID = ""
# PAGE_START起始逻辑页 L从 1 起);每逻辑页固定 2 次 pc_searchbody.page 为 2L1、2L
PAGE_START = 1
# PAGE_TO结束逻辑页None 表示只采 PAGE_START 这一逻辑页
PAGE_TO = 10
# PAGE_DELAY_SEC相邻两个逻辑页之间的休眠秒数与 REQUEST_DELAY 可叠加)
PAGE_DELAY_SEC = 1.2
# REQUEST_DELAY每次 pc_search 完成后再发起下一次前的随机等待,如 "30-60"None 关闭
REQUEST_DELAY = "30-60"
# HEADEDTrue 有头浏览器,便于调试
HEADED = False
# FORMAT"items" 解析商品列表;"raw" 仅打一包原始 JSON勿与多页组合
FORMAT = "items"
# RAW_SINGLETrue 等同只采一包原始响应(与 FORMAT=raw 类同,勿设 PAGE_TO 多页)
RAW_SINGLE = False
# CSV_OUTPUTTrue 输出 CSV 列(需 FORMAT=itemsFalse 输出 JSON 数组
CSV_OUTPUT = True
# OUT_PATH结果文件空则 CSV/JSON 打到 stdout
OUT_PATH = str(_PROJECT_DATA / "jd_p1_10_2.csv")
# SAVE_PC_SEARCH_JS_DIR非空则每次 pc_search 后把响应全文落盘到此目录(对照 Network
SAVE_PC_SEARCH_JS_DIR = str(_PROJECT_DATA / "pc_raw_p1_10_2")
# PRETTY_RAW_JSON与 SAVE 目录合用 True 时保存为缩进 .json否则单行 .js
PRETTY_RAW_JSON = True
# RECORD_REQUESTS_DIR非空则每次请求写入 URL、query、body、请求头、HTTP 状态等 JSON
RECORD_REQUESTS_DIR = str(_PROJECT_DATA / "pc_requests_p1_10_2")
# FETCH_RETRIES同一 body.page/s 遇空包或零解析时,除首次外最多再试次数
FETCH_RETRIES = 3
# FETCH_RETRY_DELAY_SEC上述重试间隔不走 REQUEST_DELAY
FETCH_RETRY_DELAY_SEC = 3.0
# ---------------------------------------------------------------------------
def _dump_out(text: str, out_path: str | None) -> None:
if out_path:
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
Path(out_path).write_text(text, encoding="utf-8")
else:
sys.stdout.write(text)
def main() -> None:
fmt = (FORMAT or "items").strip().lower()
if fmt not in ("raw", "items"):
print('FORMAT 须为 "items""raw"', file=sys.stderr)
sys.exit(2)
args = SimpleNamespace(
q=QUERY,
pvid=(PVID or "").strip(),
page=int(PAGE_START),
page_to=PAGE_TO,
page_delay=float(PAGE_DELAY_SEC),
request_delay=REQUEST_DELAY,
headed=bool(HEADED),
format=fmt,
raw=bool(RAW_SINGLE),
csv=bool(CSV_OUTPUT),
out=(OUT_PATH or "").strip() or None,
save_pc_search_js=(SAVE_PC_SEARCH_JS_DIR or "").strip() or None,
pretty_raw_json=bool(PRETTY_RAW_JSON),
record_requests=(RECORD_REQUESTS_DIR or "").strip() or None,
fetch_retries=int(FETCH_RETRIES),
fetch_retry_delay=float(FETCH_RETRY_DELAY_SEC),
)
if args.page_to is not None and args.page_to < args.page:
print("PAGE_TO 必须大于等于 PAGE_START", file=sys.stderr)
sys.exit(2)
if args.raw and args.page_to is not None:
print("RAW_SINGLE=True 时不要设置多页 PAGE_TO", file=sys.stderr)
sys.exit(2)
if args.csv and (args.format == "raw" or args.raw):
print("CSV_OUTPUT 需与 FORMAT=items 同时使用", file=sys.stderr)
sys.exit(2)
req_delay_range: tuple[float, float] | None = None
if args.request_delay:
try:
req_delay_range = parse_request_delay_range(str(args.request_delay).strip())
except ValueError as e:
print(f"[京东] REQUEST_DELAY 无效: {e}", file=sys.stderr)
sys.exit(2)
if args.fetch_retries < 0:
print("[京东] FETCH_RETRIES 不能为负", file=sys.stderr)
sys.exit(2)
page_start = max(1, args.page)
pe = args.page_to if args.page_to is not None else page_start
pe = max(page_start, pe)
want_raw = args.format == "raw" or args.raw
save_js_dir = (
Path(args.save_pc_search_js).resolve()
if args.save_pc_search_js
else None
)
record_req_dir = (
Path(args.record_requests).resolve()
if args.record_requests
else None
)
node_pvid = (args.pvid or "").strip() or None
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=not args.headed)
context = browser.new_context()
try:
if want_raw:
data = export_pc_search_request_json(
args.q, 1, s=1, pvid=node_pvid
)
url = data["url"]
headers = {str(k): str(v) for k, v in data["headers"].items()}
resp = context.request.get(url, headers=headers)
print("HTTP", resp.status, resp.status_text, file=sys.stderr)
ct = resp.headers.get("content-type", "")
if ct:
print("content-type:", ct, file=sys.stderr)
body = resp.text()
raw_seq = 1
if record_req_dir is not None:
save_pc_request_record(
record_req_dir,
raw_seq,
label="raw_single",
keyword=args.q,
api_page=1,
api_s=1,
log_ctx="--format raw",
url=url,
headers=headers,
http_status=resp.status,
status_text=resp.status_text or "",
content_type=ct,
)
if save_js_dir is not None:
save_pc_search_response_raw(
save_js_dir,
raw_seq,
body,
label="raw_single",
req_page=1,
req_s=1,
pretty=args.pretty_raw_json,
)
try:
pretty = json.dumps(json.loads(body), ensure_ascii=False, indent=2)
_dump_out(pretty + ("\n" if not pretty.endswith("\n") else ""), args.out)
except json.JSONDecodeError:
text = (body[:8000] if body else "(空)") + "\n"
_dump_out(text, args.out)
return
export_rows = collect_pc_search_export_rows(
context,
args,
page_start=page_start,
pe=pe,
req_delay_range=req_delay_range,
save_js_dir=save_js_dir,
record_req_dir=record_req_dir,
node_pvid=node_pvid,
)
if args.csv:
buf = StringIO()
w = csv.DictWriter(
buf, fieldnames=list(CSV_FIELDS), extrasaction="ignore"
)
w.writeheader()
w.writerows(export_rows)
csv_text = buf.getvalue()
if args.out:
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
Path(args.out).write_text("\ufeff" + csv_text, encoding="utf-8")
else:
sys.stdout.write(csv_text)
else:
txt = json.dumps(export_rows, ensure_ascii=False, indent=2)
_dump_out(txt + "\n", args.out)
finally:
browser.close()
if __name__ == "__main__":
main()

22
backend/manage.py Normal file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

74
backend/pipeline/admin.py Normal file
View File

@ -0,0 +1,74 @@
from django.contrib import admin
from .models import (
JdJobCommentRow,
JdJobDetailRow,
JdJobMergedRow,
JdJobSearchRow,
JdProduct,
JdProductSnapshot,
PipelineJob,
)
@admin.register(PipelineJob)
class PipelineJobAdmin(admin.ModelAdmin):
list_display = ("id", "platform", "keyword", "status", "created_at")
list_filter = ("status", "platform")
search_fields = ("keyword", "run_dir")
@admin.register(JdProduct)
class JdProductAdmin(admin.ModelAdmin):
list_display = (
"id",
"platform",
"sku_id",
"title",
"detail_brand",
"last_captured_at",
"last_job",
)
list_filter = ("platform",)
search_fields = ("sku_id", "title", "detail_brand", "ware_id")
raw_id_fields = ("last_job",)
@admin.register(JdProductSnapshot)
class JdProductSnapshotAdmin(admin.ModelAdmin):
list_display = ("id", "product", "job", "captured_at", "run_dir")
list_filter = ("captured_at",)
search_fields = ("run_dir", "product__sku_id")
raw_id_fields = ("product", "job")
@admin.register(JdJobSearchRow)
class JdJobSearchRowAdmin(admin.ModelAdmin):
list_display = ("id", "job", "row_index", "sku_id")
list_filter = ("job",)
search_fields = ("sku_id",)
raw_id_fields = ("job",)
@admin.register(JdJobDetailRow)
class JdJobDetailRowAdmin(admin.ModelAdmin):
list_display = ("id", "job", "row_index", "sku_id")
list_filter = ("job",)
search_fields = ("sku_id",)
raw_id_fields = ("job",)
@admin.register(JdJobCommentRow)
class JdJobCommentRowAdmin(admin.ModelAdmin):
list_display = ("id", "job", "row_index", "sku_id")
list_filter = ("job",)
search_fields = ("sku_id",)
raw_id_fields = ("job",)
@admin.register(JdJobMergedRow)
class JdJobMergedRowAdmin(admin.ModelAdmin):
list_display = ("id", "job", "row_index", "sku_id")
list_filter = ("job",)
search_fields = ("sku_id", "pipeline_keyword")
raw_id_fields = ("job",)

View File

@ -0,0 +1,103 @@
"""压缩 competitor-brief 供大模型输入,控制 token 体积(无 Django 依赖)。"""
from __future__ import annotations
import copy
import json
from typing import Any
def matrix_overview_for_llm(brief: dict[str, Any]) -> list[dict[str, Any]]:
"""
**完整** brief 提取矩阵分组摘要供大模型在 matrix 正文被裁剪后仍能写按细分类目分组章节
"""
mg = brief.get("matrix_by_group")
if not isinstance(mg, list) or not mg:
return []
out: list[dict[str, Any]] = []
for g in mg:
if not isinstance(g, dict):
continue
name = g.get("group") or ""
skus = g.get("skus") if isinstance(g.get("skus"), list) else []
brands: list[str] = []
for s in skus[:100]:
if isinstance(s, dict):
bb = (s.get("brand") or "").strip()
if bb and bb not in brands:
brands.append(bb)
out.append(
{
"group": name,
"sku_count": int(g.get("sku_count") or len(skus)),
"distinct_brands_sample": brands[:15],
}
)
return out
def _trim_matrix(b: dict[str, Any], *, per_group: int, max_groups: int) -> None:
mg = b.get("matrix_by_group")
if not isinstance(mg, list):
return
trimmed: list[dict[str, Any]] = []
for g in mg[:max_groups]:
if not isinstance(g, dict):
continue
g2 = dict(g)
skus = g2.get("skus")
if isinstance(skus, list):
if per_group <= 0:
g2["skus"] = []
else:
g2["skus"] = skus[:per_group]
trimmed.append(g2)
b["matrix_by_group"] = trimmed
def _trim_feedback(b: dict[str, Any], max_groups: int) -> None:
cf = b.get("consumer_feedback_by_matrix_group")
if isinstance(cf, list):
b["consumer_feedback_by_matrix_group"] = cf[:max_groups]
def _json_len(b: dict[str, Any]) -> int:
return len(json.dumps(b, ensure_ascii=False))
def compact_brief_for_llm(
brief: dict[str, Any],
*,
max_chars: int = 350_000,
) -> dict[str, Any]:
"""
深拷贝后裁剪矩阵 SKU 列表反馈组数仍超长则逐步收紧直至省略大块
始终附带 ``matrix_overview_for_llm``来自裁剪前完整 brief避免大模型漏写竞品矩阵章节
"""
matrix_ov = matrix_overview_for_llm(brief)
b = copy.deepcopy(brief)
if isinstance(b.get("run_dir"), str):
b["run_dir"] = "(已省略)"
def _finalize() -> dict[str, Any]:
b["matrix_overview_for_llm"] = matrix_ov
return b
caps = [(120, 24), (80, 20), (40, 16), (30, 12), (18, 10), (10, 8), (5, 6), (0, 6)]
for per_g, max_gr in caps:
_trim_matrix(b, per_group=per_g, max_groups=max_gr)
_trim_feedback(b, max_gr)
if _json_len(b) <= max_chars:
return _finalize()
b.pop("matrix_by_group", None)
b["matrix_by_group_omitted"] = True
_trim_feedback(b, 6)
if _json_len(b) <= max_chars:
return _finalize()
b.pop("consumer_feedback_by_matrix_group", None)
b["consumer_feedback_by_matrix_group_omitted"] = True
lv = b.get("list_visibility_proxy")
if isinstance(lv, dict) and _json_len(b) > max_chars:
b["list_visibility_proxy"] = {"_omitted": True, "keys": list(lv.keys())[:20]}
return _finalize()

View File

@ -0,0 +1,198 @@
"""一键简报包ZIP 内含完整 Markdown 报告、结构化 JSON、要点摘录。"""
from __future__ import annotations
import io
import json
import zipfile
from pathlib import Path
from typing import Any
def _pct(x: Any) -> str:
if x is None:
return ""
try:
return f"{100 * float(x):.1f}%"
except (TypeError, ValueError):
return str(x)
def _num(x: Any) -> str:
if x is None:
return ""
if isinstance(x, (int, float)):
if isinstance(x, float) and x != int(x):
return f"{x:.2f}"
return str(int(x)) if isinstance(x, float) and x == int(x) else str(x)
return str(x)
def markdown_summary_from_brief(brief: dict[str, Any]) -> str:
"""由 ``competitor-brief`` JSON 生成便于扫读的 Markdown非 LLM"""
lines: list[str] = [
"# 竞品要点摘录(机器整理)",
"",
"> 与同批 **完整报告**、**结构化 JSON** 同源;规则汇总,定稿前请人工核对。",
"",
]
kw = brief.get("keyword") or ""
batch = brief.get("batch_label") or ""
lines.extend(
[
"## 基本信息",
"",
f"- **监测词**{kw}",
f"- **批次**{batch}",
"",
]
)
scope = brief.get("scope") or {}
if scope:
lines.extend(
[
"## 样本范围",
"",
f"- **深入 SKU 数**{_num(scope.get('merged_sku_count'))}",
f"- **评价条数(扁平)**{_num(scope.get('comment_flat_rows'))}",
f"- **结构分析用列表行数**{_num(scope.get('structure_source_rows'))}",
f"- **是否含 PC 搜索全量导出**{'' if scope.get('uses_pc_search_list_export') else ''}",
"",
]
)
raw = brief.get("pc_search_raw") or {}
if raw.get("result_count_consensus") is not None:
lines.extend(
[
"## 列表侧检索规模(接口申报)",
"",
f"- **resultCount 共识值**{_num(raw.get('result_count_consensus'))}",
"",
]
)
conc = brief.get("concentration") or {}
shops = conc.get("shops_from_list") or {}
if shops.get("cr1") is not None or shops.get("top_label"):
lines.extend(
[
"## 店铺集中度(列表)",
"",
f"- **第一大店铺份额**{_pct(shops.get('cr1'))}(第一店铺:{shops.get('top_label') or ''}",
f"- **前三店铺合计份额**{_pct(shops.get('cr3'))}",
"",
]
)
dbrand = conc.get("detail_brand_among_merged") or {}
if dbrand.get("cr1") is not None or dbrand.get("top_label"):
lines.extend(
[
"## 品牌(深入样本)",
"",
f"- **第一大品牌份额(深入样本)**{_pct(dbrand.get('cr1'))}(头部:{dbrand.get('top_label') or ''}",
f"- **前三品牌合计份额**{_pct(dbrand.get('cr3'))}",
"",
]
)
pst = brief.get("price_stats") or {}
if pst.get("n"):
src = brief.get("price_stats_source") or ""
lines.extend(
[
"## 价格(展示价统计)",
"",
f"- **样本量 n**{_num(pst.get('n'))}**统计口径**{src}",
f"- **区间**{_num(pst.get('min'))} {_num(pst.get('max'))}**中位数**{_num(pst.get('median'))}",
"",
]
)
mix = brief.get("category_mix_top") or []
if mix:
lines.extend(["## 类目结构Top", ""])
for item in mix[:8]:
if isinstance(item, dict):
lines.append(
f"- {item.get('label') or ''}{_num(item.get('count'))}"
)
lines.append("")
ckw = brief.get("comment_focus_keywords") or []
if ckw:
lines.extend(["## 评价关注词Top", ""])
for item in ckw[:10]:
if isinstance(item, dict):
lines.append(
f"- **{item.get('word') or ''}**{_num(item.get('count'))}"
)
lines.append("")
usc = brief.get("usage_scenarios") or []
if usc:
lines.extend(["## 用途/场景预设词组Top", ""])
for item in usc[:8]:
if isinstance(item, dict):
lines.append(
f"- **{item.get('scenario') or ''}**{_num(item.get('count'))} 条(约 {_pct(item.get('share_of_text_units'))} 文本单元)"
)
lines.append("")
hints = brief.get("strategy_hints") or []
if hints:
lines.extend(["## 策略提示(规则)", ""])
for h in hints:
lines.append(f"- {h}")
lines.append("")
lines.extend(
[
"---",
"",
"*更细的矩阵与消费者反馈见简报包内完整分析报告;结构化字段见同包内摘要数据文件。*",
"",
]
)
return "\n".join(lines)
README_TXT = """竞品「一键简报包」说明Market-Assistant
============================================
ZIP 报告查看页一键导出内含
说明文件 本文件
完整分析报告 与任务批次中的主报告文稿一致
统计图 PNG report_assets 目录与报告内插图同源
结构化摘要数据 结构化摘要接口同源可供其它工具读取
要点摘录 由摘要自动整理的速读稿便于邮件/转发前浏览
使用建议对外发送前请核对要点摘录与完整报告中的结论数据边界见报告第一章
"""
def build_brief_pack_zip_bytes(run_dir: Path, brief: dict[str, Any]) -> bytes:
"""
生成 ZIP 字节流``run_dir`` 下须存在 ``competitor_analysis.md``
"""
run_dir = Path(run_dir).resolve()
report_path = run_dir / "competitor_analysis.md"
if not report_path.is_file():
raise FileNotFoundError("缺少已生成的分析报告文件,请先在「报告生成」中生成报告")
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("01_竞品分析报告.md", report_path.read_text(encoding="utf-8"))
zf.writestr(
"02_结构化摘要.json",
json.dumps(brief, ensure_ascii=False, indent=2),
)
zf.writestr("03_要点摘录.md", markdown_summary_from_brief(brief))
zf.writestr("00_说明.txt", README_TXT)
assets = run_dir / "report_assets"
if assets.is_dir():
for fp in sorted(assets.iterdir()):
if fp.is_file():
zf.write(fp, f"report_assets/{fp.name}")
return buf.getvalue()

View File

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
"""工作台粘贴的 Cookie 规范化(与 jd_cookie.txt 单行内容一致)。"""
def normalize_browser_cookie_paste(raw: str) -> str:
"""
- 去掉首尾空白
- 若整段以 ``Cookie:`` 开头不区分大小写常见于 DevTools 复制请求头去掉该前缀
"""
s = (raw or "").strip()
if not s:
return ""
prefix = "cookie:"
if s.lower().startswith(prefix):
return s[len(prefix) :].strip()
return s

View File

@ -0,0 +1,181 @@
"""
``jd_pc_search`` 导出 CSV 列对齐的字段名映射入库 / API / 导出共用
内部键与爬虫侧 ``JD_ITEM_CSV_FIELDS`` / ``WARE_PARSED_CSV_FIELDNAMES`` 一致便于对照源码
"""
from __future__ import annotations
# --- 搜索导出 pc_search_export.csv列名为中文与 jd_h5_search_requests.JD_EXPORT_COLUMN_HEADERS 一致)---
JD_SEARCH_INTERNAL_KEYS: tuple[str, ...] = (
"item_id",
"sku_id",
"title",
"price",
"coupon_price",
"original_price",
"selling_point",
"comment_sales_floor",
"hot_list_rank",
"comment_count",
"shop_name",
"shop_url",
"shop_info_url",
"location",
"detail_url",
"image",
"seckill_info",
"attributes",
"leaf_category",
"platform",
"keyword",
"page",
)
JD_SEARCH_CSV_HEADERS: dict[str, str] = {
"item_id": "主商品ID(wareId)",
"sku_id": "SKU(skuId)",
"title": "标题(wareName)",
"price": "标价(jdPrice,jdPriceText,realPrice)",
"coupon_price": "券后到手价(couponPrice,subsidyPrice,finalPrice.estimatedPrice,priceShow)",
"original_price": "原价(oriPrice,originalPrice,marketPrice)",
"selling_point": "卖点(sellingPoint)",
"comment_sales_floor": "销量楼层(commentSalesFloor)",
"hot_list_rank": "榜单类文案(标签/腰带/标题数组中的榜、TOP 等)",
"comment_count": "评价量(commentFuzzy)",
"shop_name": "店铺名(shopName)",
"shop_url": "店铺链接(shopUrl,shopId)",
"shop_info_url": "店铺信息链接(shopInfoUrl,brandUrl)",
"location": "地域(deliveryAddress,area,procity)",
"detail_url": "商品链接(toUrl,clickUrl,item.m.jd.com)",
"image": "主图(imageurl,imageUrl)",
"seckill_info": "秒杀(seckillInfo,secKill)",
"attributes": "规格属性(propertyList,color,catid,shortName)",
"leaf_category": "类目(leafCategory,cid3Name,catid)",
"platform": "平台(platform)",
"keyword": "搜索词(keyword)",
"page": "页码(page)",
}
# CSV 表头 -> 模型属性名
SEARCH_CSV_HEADER_TO_FIELD: dict[str, str] = {
h: k for k, h in JD_SEARCH_CSV_HEADERS.items()
}
# lean 商详子集合并宽表商详块、detail_ware_exportlean、JdJobDetailRow 共用CSV 列名与 ORM 一致)
LEAN_DETAIL_EXPORT_FIELDNAMES: tuple[str, ...] = (
"detail_brand",
"detail_price_final",
"detail_shop_name",
"detail_category_path",
"detail_product_attributes",
"detail_body_ingredients",
)
# --- 商详 detail_ware_export.csvleanskuId + 上列full 模式爬虫仍可能多列,入库只认 DETAIL_CSV_COLUMNS---
JD_DETAIL_MERGE_KEYS: tuple[str, ...] = LEAN_DETAIL_EXPORT_FIELDNAMES
DETAIL_CSV_COLUMNS: tuple[str, ...] = ("skuId", *JD_DETAIL_MERGE_KEYS)
DETAIL_CSV_TO_FIELD: dict[str, str] = {
"skuId": "sku_id",
**{k: k for k in JD_DETAIL_MERGE_KEYS},
}
# --- 评价 comments_flat.csv ---
COMMENT_CSV_COLUMNS: tuple[str, ...] = (
"sku",
"commentId",
"userNickName",
"tagCommentContent",
"commentDate",
"buyCountText",
"largePicURLs",
"commentScore",
)
COMMENT_CSV_TO_FIELD: dict[str, str] = {
"sku": "sku_id",
"commentId": "comment_id",
"userNickName": "user_nick_name",
"tagCommentContent": "tag_comment_content",
"commentDate": "comment_date",
"buyCountText": "buy_count_text",
"largePicURLs": "large_pic_urls",
"commentScore": "comment_score",
}
# --- 合并宽表 keyword_pipeline_merged.csvlean = 搜索块 + 商详块 + 评论块;改列请改对应块,勿在尾部堆列)---
MERGED_SEARCH_CSV_COLUMNS: tuple[str, ...] = (
"pipeline_keyword",
"SKU(skuId)",
"主商品ID(wareId)",
"标题(wareName)",
"标价(jdPrice,jdPriceText,realPrice)",
"券后到手价(couponPrice,subsidyPrice,finalPrice.estimatedPrice,priceShow)",
"原价(oriPrice,originalPrice,marketPrice)",
"卖点(sellingPoint)",
"榜单类文案(标签/腰带/标题数组中的榜、TOP 等)",
"评价量(commentFuzzy)",
"销量楼层(commentSalesFloor)",
"店铺名(shopName)",
"商品链接(toUrl,clickUrl,item.m.jd.com)",
"主图(imageurl,imageUrl)",
"规格属性(propertyList,color,catid,shortName)",
"类目(leafCategory,cid3Name,catid)",
"搜索词(keyword)",
"页码(page)",
)
MERGED_SEARCH_INTERNAL_KEYS: tuple[str, ...] = (