mirror of
https://github.com/primedigitaltech/market-assistant.git
synced 2026-09-18 15:53:20 +08:00
Initial commit: Market-Assistant standalone (Django + Vue + JD crawler copy)
Made-with: Cursor
This commit is contained in:
commit
d8086dfdd5
35
.env.example
Normal file
35
.env.example
Normal 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
34
.gitignore
vendored
Normal 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
120
README.md
Normal 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 Framework,SQLite(可配置路径) |
|
||||
| 前端 | 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 等
|
||||
0
backend/config/__init__.py
Normal file
0
backend/config/__init__.py
Normal file
16
backend/config/asgi.py
Normal file
16
backend/config/asgi.py
Normal 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
112
backend/config/settings.py
Normal 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
7
backend/config/urls.py
Normal 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
16
backend/config/wsgi.py
Normal 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()
|
||||
804
backend/crawler_copy/jd_pc_search/AI_crawler.py
Normal file
804
backend/crawler_copy/jd_pc_search/AI_crawler.py
Normal 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
|
||||
|
||||
# 规范化后的「A;B;C;…」若多段都很短且多段含数字,多为做法用料枚举
|
||||
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\w·.\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()
|
||||
27
backend/crawler_copy/jd_pc_search/_low_gi_root.py
Normal file
27
backend/crawler_copy/jd_pc_search/_low_gi_root.py
Normal 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
|
||||
@ -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("缺少 uuid(Cookie 中 __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);
|
||||
}
|
||||
@ -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-token(Cookie 中 __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);
|
||||
}
|
||||
@ -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_TYPE:body.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 时是否缩进打印业务 JSON(parsed)
|
||||
PRETTY = False
|
||||
# RAISE_HTTP:True 时 HTTP 非 2xx 直接退出进程
|
||||
RAISE_HTTP = False
|
||||
# HEADED:True 有头浏览器
|
||||
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] 取 category(maidianInfo 前缀)与 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 深度遍历抽取评价:
|
||||
- getLegoWareDetailComment:commentInfoList / lastCommentInfoList
|
||||
- getCommentListPage:result.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()
|
||||
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 商品详情 getLegoWareDetailComment 的 h5st(ParamsSign 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,
|
||||
};
|
||||
@ -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)
|
||||
* - 表单字段:appid、body、client、clientVersion、functionId、h5st、loginType、t、uuid
|
||||
*
|
||||
* 首包 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,
|
||||
};
|
||||
@ -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 };
|
||||
1
backend/crawler_copy/jd_pc_search/common/__init__.py
Normal file
1
backend/crawler_copy/jd_pc_search/common/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# 京东 PC 爬虫共享:Cookie、签名环境、请求间隔工具等。
|
||||
10568
backend/crawler_copy/jd_pc_search/common/code.js
Normal file
10568
backend/crawler_copy/jd_pc_search/common/code.js
Normal file
File diff suppressed because one or more lines are too long
131
backend/crawler_copy/jd_pc_search/common/jd_browser_env.js
Normal file
131
backend/crawler_copy/jd_pc_search/common/jd_browser_env.js
Normal file
@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 在 Node 下为 crawler/code.js(ParamsSign / 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 = {};
|
||||
40
backend/crawler_copy/jd_pc_search/common/jd_delay_utils.py
Normal file
40
backend/crawler_copy/jd_pc_search/common/jd_delay_utils.py
Normal 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]:
|
||||
"""
|
||||
解析 CLI「MIN-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)
|
||||
60
backend/crawler_copy/jd_pc_search/common/jd_https_fetch.js
Normal file
60
backend/crawler_copy/jd_pc_search/common/jd_https_fetch.js
Normal 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 };
|
||||
110
backend/crawler_copy/jd_pc_search/common/jd_search_common.js
Normal file
110
backend/crawler_copy/jd_pc_search/common/jd_search_common.js
Normal 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
@ -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_UUID、JD_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-token(Cookie 中 __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);
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 商品详情 pc_detailpage_wareBusiness(appid=pc-item-soa)的 h5st。
|
||||
* 与 jd_h5st.js(搜索)、jd_h5st_item_comment.js(评论)分离。
|
||||
* ParamsSign 使用抓包第二段 appId:fb5df(与 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,
|
||||
};
|
||||
@ -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 };
|
||||
2312
backend/crawler_copy/jd_pc_search/jd_competitor_report.py
Normal file
2312
backend/crawler_copy/jd_pc_search/jd_competitor_report.py
Normal file
File diff suppressed because it is too large
Load Diff
886
backend/crawler_copy/jd_pc_search/jd_keyword_pipeline.py
Normal file
886
backend/crawler_copy/jd_pc_search/jd_keyword_pipeline.py
Normal file
@ -0,0 +1,886 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
关键词 → 京东 PC 搜索 → 对若干 SKU 拉取详情(pc_detailpage_wareBusiness)与评论(首屏 Lego,
|
||||
可选继续 ``getCommentListPage`` 分页,与同目录 ``jd_h5_item_comment_requests`` 一致),
|
||||
合并为一行 CSV(搜索列 + 详情摘要 + 评价摘要)。
|
||||
|
||||
依赖:Node(搜索/评论签 h5st)、Playwright、本仓库 ``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_DELAY:pc_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_NUM:Lego 接口 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_CHROME:True 使用本机 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()
|
||||
18
backend/crawler_copy/jd_pc_search/package-lock.json
generated
Normal file
18
backend/crawler_copy/jd_pc_search/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
5
backend/crawler_copy/jd_pc_search/package.json
Normal file
5
backend/crawler_copy/jd_pc_search/package.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"crypto-js": "^4.2.0"
|
||||
}
|
||||
}
|
||||
152
backend/crawler_copy/jd_pc_search/scenario_filter.py
Normal file
152
backend/crawler_copy/jd_pc_search/scenario_filter.py
Normal 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
|
||||
@ -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]
|
||||
@ -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 .js(JSON 一行)",
|
||||
)
|
||||
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()
|
||||
@ -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.pvid、Referer 一致(可选)
|
||||
*/
|
||||
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-token(Cookie)");
|
||||
|
||||
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);
|
||||
}
|
||||
2230
backend/crawler_copy/jd_pc_search/search/jd_h5_search_requests.py
Normal file
2230
backend/crawler_copy/jd_pc_search/search/jd_h5_search_requests.py
Normal file
File diff suppressed because it is too large
Load Diff
136
backend/crawler_copy/jd_pc_search/search/jd_h5st.js
Normal file
136
backend/crawler_copy/jd_pc_search/search/jd_h5st.js
Normal 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 里两个 t,body 为 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,
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
233
backend/crawler_copy/jd_pc_search/search/jd_search_playwright.py
Normal file
233
backend/crawler_copy/jd_pc_search/search/jd_search_playwright.py
Normal file
@ -0,0 +1,233 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_h5st(Node)拼 URL + Header,Playwright/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_search(body.page 为 2L−1、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"
|
||||
# HEADED:True 有头浏览器,便于调试
|
||||
HEADED = False
|
||||
# FORMAT:"items" 解析商品列表;"raw" 仅打一包原始 JSON(勿与多页组合)
|
||||
FORMAT = "items"
|
||||
# RAW_SINGLE:True 等同只采一包原始响应(与 FORMAT=raw 类同,勿设 PAGE_TO 多页)
|
||||
RAW_SINGLE = False
|
||||
# CSV_OUTPUT:True 输出 CSV 列(需 FORMAT=items);False 输出 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
22
backend/manage.py
Normal 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
74
backend/pipeline/admin.py
Normal 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",)
|
||||
103
backend/pipeline/brief_compact.py
Normal file
103
backend/pipeline/brief_compact.py
Normal 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()
|
||||
198
backend/pipeline/brief_pack.py
Normal file
198
backend/pipeline/brief_pack.py
Normal 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()
|
||||
16
backend/pipeline/cookie_paste.py
Normal file
16
backend/pipeline/cookie_paste.py
Normal 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
|
||||
129
backend/pipeline/csv_schema.py
Normal file
129
backend/pipeline/csv_schema.py
Normal 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_export(lean)、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.csv(lean:skuId + 上列;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.csv(lean = 搜索块 + 商详块 + 评论块;改列请改对应块,勿在尾部堆列)---
|
||||
|
||||
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, ...] = (
|
||||
Loading…
x
Reference in New Issue
Block a user