commit d8086dfdd5a31d13ab9b5d384f7429b975fe263b Author: hub-gif <2487812171@qq.com> Date: Fri Apr 10 18:03:35 2026 +0800 Initial commit: Market-Assistant standalone (Django + Vue + JD crawler copy) Made-with: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b19a7fa --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08332bd --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6813bd6 --- /dev/null +++ b/README.md @@ -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 等 diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/config/asgi.py b/backend/config/asgi.py new file mode 100644 index 0000000..ed7c431 --- /dev/null +++ b/backend/config/asgi.py @@ -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() diff --git a/backend/config/settings.py b/backend/config/settings.py new file mode 100644 index 0000000..26fe257 --- /dev/null +++ b/backend/config/settings.py @@ -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()] diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..930ba02 --- /dev/null +++ b/backend/config/urls.py @@ -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")), +] diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py new file mode 100644 index 0000000..e2fbd58 --- /dev/null +++ b/backend/config/wsgi.py @@ -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() diff --git a/backend/crawler_copy/jd_pc_search/AI_crawler.py b/backend/crawler_copy/jd_pc_search/AI_crawler.py new file mode 100644 index 0000000..1c4558f --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/AI_crawler.py @@ -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() diff --git a/backend/crawler_copy/jd_pc_search/_low_gi_root.py b/backend/crawler_copy/jd_pc_search/_low_gi_root.py new file mode 100644 index 0000000..93fc435 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/_low_gi_root.py @@ -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 diff --git a/backend/crawler_copy/jd_pc_search/comment/jd_export_item_comment_page_request.js b/backend/crawler_copy/jd_pc_search/comment/jd_export_item_comment_page_request.js new file mode 100644 index 0000000..a5e7378 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/comment/jd_export_item_comment_page_request.js @@ -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); +} diff --git a/backend/crawler_copy/jd_pc_search/comment/jd_export_item_comment_request.js b/backend/crawler_copy/jd_pc_search/comment/jd_export_item_comment_request.js new file mode 100644 index 0000000..4b7dda2 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/comment/jd_export_item_comment_request.js @@ -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); +} diff --git a/backend/crawler_copy/jd_pc_search/comment/jd_h5_item_comment_requests.py b/backend/crawler_copy/jd_pc_search/comment/jd_h5_item_comment_requests.py new file mode 100644 index 0000000..1923828 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/comment/jd_h5_item_comment_requests.py @@ -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() diff --git a/backend/crawler_copy/jd_pc_search/comment/jd_h5st_item_comment.js b/backend/crawler_copy/jd_pc_search/comment/jd_h5st_item_comment.js new file mode 100644 index 0000000..17088b5 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/comment/jd_h5st_item_comment.js @@ -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, +}; diff --git a/backend/crawler_copy/jd_pc_search/comment/jd_h5st_item_comment_page.js b/backend/crawler_copy/jd_pc_search/comment/jd_h5st_item_comment_page.js new file mode 100644 index 0000000..4a6e91c --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/comment/jd_h5st_item_comment_page.js @@ -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} */ + 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, +}; diff --git a/backend/crawler_copy/jd_pc_search/comment/jd_pc_item_comment_headers.js b/backend/crawler_copy/jd_pc_search/comment/jd_pc_item_comment_headers.js new file mode 100644 index 0000000..c6f3cb5 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/comment/jd_pc_item_comment_headers.js @@ -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 }; diff --git a/backend/crawler_copy/jd_pc_search/common/__init__.py b/backend/crawler_copy/jd_pc_search/common/__init__.py new file mode 100644 index 0000000..23c046a --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/common/__init__.py @@ -0,0 +1 @@ +# 京东 PC 爬虫共享:Cookie、签名环境、请求间隔工具等。 diff --git a/backend/crawler_copy/jd_pc_search/common/code.js b/backend/crawler_copy/jd_pc_search/common/code.js new file mode 100644 index 0000000..1976138 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/common/code.js @@ -0,0 +1,10568 @@ +var ParamsSign = function() { + 'use strict'; + function a092750F(_$b, _$F) { + var _$Q = a092750b(); + return a092750F = function(_$U, _$j) { + _$U = _$U - (-0x3 * -0x6e1 + -0x243a + 0x1 * 0x10d5); + var _$r = _$Q[_$U]; + if (a092750F.sFnleK === undefined) { + var _$x = function(_$L) { + var _$W = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/='; + var _$V = '' + , _$H = ''; + for (var _$u = 0x5c * 0x2d + -0xe34 + -0x3f * 0x8, _$M, _$c, _$O = 0x4 * 0x606 + 0x1 * -0x1fd3 + -0x1 * -0x7bb; _$c = _$L.charAt(_$O++); ~_$c && (_$M = _$u % (0x1 * -0xcaf + 0x1 * -0xe4d + -0x36 * -0x80) ? _$M * (-0x168 + 0x7e9 * -0x3 + 0x1963) + _$c : _$c, + _$u++ % (0x1 * 0x182b + -0x1b22 * 0x1 + 0x2fb)) ? _$V += String.fromCharCode(0x147 * 0x7 + -0x18da + 0x10e8 & _$M >> (-(-0x16c5 + -0x13 * 0x1a9 + 0x22 * 0x199) * _$u & -0x2b6 * -0x3 + -0x25ba + 0x1d9e * 0x1)) : 0x1 * 0x1da2 + 0x2 * 0x12f4 + -0x438a) { + _$c = _$W.indexOf(_$c); + } + for (var _$i = -0xbde + -0x12db + 0x1eb9, _$E = _$V.length; _$i < _$E; _$i++) { + _$H += '%' + ('00' + _$V.charCodeAt(_$i).toString(0xbb1 * 0x2 + -0xafa + -0xc58)).slice(-(0x5 * -0x259 + -0x254 * -0x4 + 0x26f)); + } + return decodeURIComponent(_$H); + }; + a092750F.ZRuobe = _$x, + _$b = arguments, + a092750F.sFnleK = !![]; + } + var _$X = _$Q[-0x16ee + -0x1e1f + 0x1f7 * 0x1b].substring(-0x129b + -0x55b + -0xbfb * -0x2, -0x1a * 0x127 + 0x1 * -0xbe1 + 0xdf3 * 0x3) + , _$T = _$U + _$X + , _$D = _$b[_$T]; + return !_$D ? (_$r = a092750F.ZRuobe(_$r), + _$b[_$T] = _$r) : _$r = _$D, + _$r; + } + , + a092750F(_$b, _$F); + } + function a092750b() { + var NQ = ['B3DUs2v5CW', 'lY4V', 'BM9KztPPBNrLCM5HBc8', 'DZi0', 'Bg9HzgvYlNv0AwXZi2XVywrsywnty3jPChrpBMnL', 'sw52ywXPzcb0Aw1LihzHBhvL', 'uMvNrxHW', 'C3rHDgu', 'x19Yzxf1zxn0qwXNB3jPDgHTihn0yxj0lG', 'Dg9tDhjPBMC', 'rvHux3rLEhr1CMvFzMLSDgvYx2fUAxnVDhjVCgLJ', 'DZe3', 'y29Uy2f0', 'Ahr0Chm6lY9NAxrODwiUy29Tl3PSB2LYB2nRl2nVCMuTANm', 'ANnVBG', 'qxjYyxK', 'zNvUy3rPB250B1n0CMLUzYGPE1TUyxrPDMvJB2rLxx0', 'DgHYB3C', 'zMLSDgvY', 'Bwf0y2HLCG', 'C29YDa', 'uvbptK1ms0PjseDgrurdqKeTxZK4nZy1ndmYmtb6ExH3DNv0C3jXCg9UBwXRAMLOz2zLzgnIyvPzwfDwvvrtuG', 'v1fFzhKXx3zR', 'D2vIz2XgCa', 'C2HHBq', 'AxnqCM90B3r5CgvpzG', 'CgfYC2vYzxjYB3i', 'ExL5Eu1nzgq', 'D2vIzhjPDMvY', 't2jQzwn0igfSCMvHzhKGAw5PDgLHBgL6zwq', 'suvFufjpve8', 'BM9Uzq', 'ChrFCgLU', 'w251BgXD', 'xsLB', 'x19Yzxf1zxn0rgvWCYbMCM9TignHy2HLlcbLBMqU', 'w29IAMvJDcbpyMPLy3rD', 'Bg9HzgvK', 'rgf0zq', 'zNvUy3rPB25xAw5KB3COkxTBBMf0AxzLy29Kzv19', 'CxvLDwvnAwnYB3rHC2S', 'tNvSBa', 'yNuX', 'q29UDgvUDc1uExbL', 'CMvWBgfJzufSBa', 'C3LTyM9SlxrVlxn0CMLUzY1YzwDPC3rYEq', 'DZiW', 'C3LTyM9SCW', 'rxjYB3i', 'A2v5CW', 'kf58icK', 'tM/PQPC', 'vgHLig1LDgHVzcbKB2vZBID0igfJy2vWDcbYzwD1BgfYigv4ChjLC3nPB25Z', 'x19Yzxf1zxn0rgvWCYbLBMqU', 'BwvZC2fNzq', 'AMf2yq', 'tu9Ax0vyvf90zxH0DxjLx2zPBhrLCL9HBMLZB3rYB3bPyW', 'lcbFBg9HzgvKx2nHy2HLCZO', 'yxr0CLzLCNrLEa', 'ExL5Es1nts1Kza', 'CMDIysGWlcaWlcaYmdaSidaUnsK', 'u3LTyM9SigLZig5VDcbHignVBNn0CNvJDg9Y', 'Bwv0ywrHDge', 'BgvUz3rO', 'iLX1zgyWnLX1zdGZnci', 'u3rYAw5N', 'Aw9U', 'v1fFz2f0AgvYx2n2mq', 'zw52q29SBgvJDa', 'DZeY', 'Dw5Zy29WywjSzxm', 'CMvXDwvZDcb0B2TLBIbMywLSzwqGA2v5oG', 'CxvLCNLtzwXLy3rVCG', 'Bg9JywXFA2v5xZm', 'Dw5RBM93BIbLCNjVCG', 'DZe1', 'CMvXDwvZDcbWyxjHBxmGzxjYB3iU', 'CMv2zxjZzq', 'x19TywTLu2LNBIWGCMvZDwX0oG', 'x19JB2XSzwn0igvUDKnVBgXLy3q9', 'Aw5PDa', 'yxbWBgLJyxrPB24VANnVBG', 'mtGZodC0nwrsvKfmAq', 'zw51BwvYywjSzq', 'igLZig5VDcbPDgvYywjSzq', 'qxn5BMnhzw5LCMf0B3jgDw5JDgLVBG', 'yNuZ', 'z2v0t3DUuhjVCgvYDhLoyw1LCW', 'x19Yzxf1zxn0rgvWCYbYzxf1zxn0ihrVA2vUigzHAwXLzcWGzxjYB3i6ia', 'D3v2oG', 'lcbLpq', 'iZfHm2jJmq', 'lcbZAwDUzwrtDhi6', 'ywXWAgfIzxrPyW', 'q2HYB21L', 'C3rYAw5NAwz5igrLDgvJDgLVBG', 'x19Nzw5tAwDUlcbWyxjHBxntDhi6', 'zxH0zw5K', 'CNfWB25TBgTQAwHNzMvKy2jHwLLyv1zvvfnsuvbptK1ms0PjseDgrurdqKeTxZK4nZy1ndmYmtb6ExH3DNv0CW', 'q2fUBM90ihnLDcbYzwfKig9UBhKGlMXLBMD0Aa', 'AgfZt3DUuhjVCgvYDhK', 'tM90igvUB3vNAcbHCMD1BwvUDhm', 'v0vcr0XFzgvIDwDFCMvUzgvYzxjFAw5MBW', 'twfSzM9YBwvKifvurI04igrHDge', 'lgv4ChjLC3m9', 'Bg9JywXFA2v5xW', 'x19Nzw5tAwDUrgvMyxvSDcWGCgfYyw1Zu3rYoG', 'Dg9mB2nHBgvtDhjPBMC', 'Dg9tDhjPBMDuywC', 'C3rYAw5NlxrVlxn5BwjVBc1YzwDPC3rYEq', 'r2vUzxjHDg9YrNvUy3rPB24', 'zw50CMLLCW', 'ChaX', 'qMfKifbYB21PC2uGy29UC3rYDwn0B3i', 'mhWZFdr8nxWYFde', 'x19JB3jLlwPZx3nOyxjLzf9F', 'DZiZ', 'C2LNBIbLBgfWC2vKihrPBwuH', 'B2jQzwn0', 'y29UC3rYDwn0B3i', 'qxjYyxKGsxrLCMf0B3i', 'CMvQzwn0Aw9UsgfUzgXLza', 'Dw5JDa', 'C2v0', 'mdm4ns0WnY0YnvqWnZOWnJOZos45otLA', 'D2L0Ag91DfnLDhrLCG', 'D3vYoG', 'igLZig5VDcbHBIbVyMPLy3q', 'C3bSAxq', 'DxjS', 'ue9tva', 'w14/xsO', 'xsSK', 'yxbWBgLJyxrPB24VEc13D3CTzM9YBs11CMXLBMnVzgvK', 'CgfYyw1ZigLZigvTChr5igfMDgvYigv4y2X1zgLUzYaIDw5ZywzLiIbWyxjHBxm', 'AwzYyw1L', 'u3LTyM9Ska', 'zg9JDw1LBNrfBgvTzw50', 'C2nYAxb0', 'DZiY', 'ChDKDf9Pza', 'Aw5JBhvKzxm', 'Dw5PzM9YBu9MzNnLDa', 't2jQzwn0', 'CgfYyw1ZigLZig5VDcbHihbSywLUig9IAMvJDa', 'WQKGmJaXnc0Ymdi0ierLBMLZifb1C2HRyxjLDIaOEMXVAxjVy2SUCNuP', 'x19Yzxf1zxn0qwXNB3jPDgHTt25JzsbRzxK6', 'lgTLEt0', 'DgHLBG', 'mhGXnG', 'ns4Z', 'EwvZ', 'lcbJAgvJAYbZDg9YywDLigzWoG', 'tw96AwXSys81lJaGxcGOlIO/kvWP', 'DgvZDcbLCNi', 'sw5JB3jYzwn0igLUDM9JyxrPB24', 'yNuY', 'ihrVA2vUoG', 'r0vu', 'AgvHza', 'x3n0zq', 'y2f1C2u', 'CgHHBNrVBwPZ', 'v1fFzhKXx3rRx2fSz28', 'ChvWCgv0zwvY', 'C3bLy2LLCW', 'DZe4', 'sw5JB21WyxrPyMXLihjLy2vPDMvYlca', 'qwnJzxb0', 'nhHlB3zHwq', 'CMv0DxjUia', 'lcbYzxrYEsbUzxH0ihrPBwuU', 'D3jPDgfIBgu', 'x3n0AW', 'y2rJx2fKB1fWB2fZBMzHnZzWzMnAtg1JzMXFuhjVBwLZzq', 'AdvFzMLSzv92ns4ZlJi', 'z2v0vg9Rzw5F', 'y2nU', 'C3rYAw5N', 'uhjVBwLZzs1JAgfPBIbJEwnSzq', 'AhrTBgzPBgu', 'DMfSDwvZ', 'igLZig5VDcbHigz1BMn0Aw9U', 'iLX1zgvHzci', 'uhjVBwLZzsbJyw4NDcbIzsbYzxnVBhzLzcbPDhnLBgy', 'CMvQzwn0zwq', 'y3jLyxrLigLUC3rHBMnLihDPDgGGyxbWswq9', 'y29UzMLNDxjHyMXL', 'uMvMBgvJDa', 'lcbZDg9YywDLrNa6', 'CMfUzg9T', 'q2fUBM90igrLBgv0zsbWCM9Wzxj0Esa', 'BMv4Da', 'ChvYzq', 'AxndB25JyxrtChjLywrHyMXL', 'qebPDgvYyxrVCG', 'tNvTyMvY', 'ExL5Eu1nzgrOAg1TC3ntu1m', 'Ahr0Chm6lY9ZDg9YywDLlJm2mgj1EwLTzY5JB20VD2vIy29UDgfPBMvYl21HAw4VANmTC2vJDxjPDhKTDJmTCMfJlMPZp3y9', 'C3bSAwnL', 'u3LTyM9S', 'B2jZzxj2ywjSzq', 'DZiX', 'ieL0zxjHDg9Y', 'q2fUj3qGy2fSBcbTzxrOB2qGB24G', 'CgLU', 'Dg9Rzw4GAxmGzw1WDhK', 'zgvMyxvSDa', 'vw5Oyw5KBgvKihbYB21PC2uGCMvQzwn0Aw9U', 'Dw5Oyw5KBgvKCMvQzwn0Aw9U', 'cqOlda0GWQdHMOdIGidIGihIGilIGipIGitIGixIGiBIGiFIGiJIGiNIGiRIGk/IGz/JGidIGkJIGkNVU78', 'jxrLC3rdywzLrhjPDMvYjq', 'qwDNCMvNyxrLrxjYB3i', 'BMfTzq', 'Dg9qCMLTAxrPDMu', 'x19Nzw5ezwzHDwX0s2v5igLUChv0pq', 'x19Yzxf1zxn0rgvWCYWGx19WyxjZzufSz29YAxrOBsbYzxn1Bhq6', 'mZG3ntK4AujZr0rH', 'jgnKy19HC2rQzMXHC3v0B3bMAhzJwKXTy2zSxW', 'rxzLBNq', 'BwfPBI5ZAwDUi19FCMvXDwvZDerLChm', 'ig9Mia', 'sKrZDf9IzwHHDMLVCL9MBgfN', 'BwfW', 'BM9YBwfS', 'Dg9ju09tDhjPBMC', 'DZe2', 'odmWnJqYngfzALPtDW', 'yxr0CMLIDxrLihzLyZiGyxr0CLzLCNrLEdT2yxj5Aw5NihzLyZiGDMfYEwLUvgv4q29VCMrPBMf0ztT1BMLMB3jTihzLyZiGDw5PzM9YBu9MzNnLDdT2B2LKig1HAw4OkxT2yxj5Aw5uzxHdB29YzgLUyxrLpwf0Dhjwzxj0zxGRDw5PzM9YBu9MzNnLDdTNBf9qB3nPDgLVBJ12zwm0kgf0Dhjwzxj0zxGSmcWXktT9', 'yxn5BMnjDgvYyxrVCG', 'Dgv4Dc9QyxzHC2nYAxb0', 'nda2mteWs1b5zKjS', 'q2fUj3qGy29UDMvYDcbVyMPLy3qGDg8GChjPBwL0AxzLihzHBhvL', 'x19LC01VzhvSzq', 'kd86psHBxJTDkIKPpYG7FcqP', 'CMvK', 'v2LUzg93', 'Bg9Hza', 'z2vUzxjHDguGA2v5igzHAwXLza', 'AxnxzwXSs25VD25tEw1IB2W', 'jgnOCM9Tzv9HC3LUy1nJCMLWDeLUzM8', 'u3rYAw5NieL0zxjHDg9Y', 'B25YzwfKExn0yxrLy2HHBMDL', 'AgLKzgvU', 'lcb0B2TLBJO', 'w29IAMvJDcb6xq', 'v3jVBMCGBNvTyMvYig9MihjLCgv0AxrPB25Z', 'ugHHBNrVBuPt', 'qxjNDw1LBNrZ', 'mdeYmZq1nJC4owfIy2rLzMDOAwPRBg1UB3bXCNn0Dxz3EhL6qujdrevgr0Hjvfvwv1HzwL8T', 'D2vI', 'mc4XlJC', 'CgfYyw1ZigLZigvTChr5', 'D2vIz2W', 'lcbMCdO', 'lcbHBgDVoG', 'reDcruziqunjsKS', 'ChjVDg90ExbL', 'C3vJy2vZCW', 'zxHWzxjPBwvUDgfSlxDLyMDS', 'utffz0e1', 'CgfYyw1ZignVBNrHAw5ZihjLC2vYDMvKihbHCMfTig5HBwuU', 'C3LTyM9S', 'CMr2nNm', 'uhjVBwLZzq', 'w3nPz25Dia', 'DgLTzw91Da', 'u3LTyM9SlG', 'AgfZsw5ZDgfUy2u', 'qwnJzxnZB3jZig5VDcbZDxbWB3j0zwq', 'Bwf0y2HbBgW', 'Ahr0Chm6lY9NAxrODwiUy29Tl3PSB2LYB2nRl2nVCMuTANmVyMXVyI92mY4ZnI4Xl0Xjq0vou0u', 'BgfZDeLUzgv4t2y', 'ufiGzMXHy2TZihf1AxOGz3LToIbuvIbesIbIB3GGD2HLBJ8G4PIG', 'yxn5BMneAxnWB3nL', 'y29TCgXLDgu', 'zxH0zw5ZAw9UCZO', 'x19WCM90B19F', 'nNW3Fdf8oxWXmhWYFdv8nhWWFdH8mW', 'x19Yzxf1zxn0qwXNB3jPDgHTihjLCxvLC3qGC3vJy2vZCYeSignOzwnRig1LBw9YEsbMCdO', 'yNu0', 'mdaW', 'BM9Kzq', 'iZqYztfHmG', 'zgf0ys5Yzxn1BhqGzM9YBwf0igvYCM9YlG', 'AxrLCMf0B3i', 'DxnLig5VCM1HBfrVA2vU', 'ChjVy2vZCW', 'x19Yzxf1zxn0qwXNB3jPDgHTigvUDKnVBgXLy3q9', 'mc4XlJK', 'CMvWBgfJzq', 'BNvTyMvY', 'ENHJyxnK', 'y2rJx2fKB1fWB2fZBMzHnZzWzMnAtg1JzMXFqxjYyxK', 'Ahr0Chm6lY9Jywn0DxmUAMqUy29Tl3jLCxvLC3rFywXNBW', 'ChjLy2LZAw9Uig1LzgL1BxaGzMXVyxq7DMfYEwLUzYb2zwmYihzHCNLPBLrLEenVB3jKAw5HDgu7DM9PzcbTywLUkcKGE2DSx0zYywDdB2XVCJ12zwm0khzHCNLPBLrLEenVB3jKAw5HDguSmcWXktT9', 'x19Yzxf1zxn0rgvWCYb1C2uGzNaSigzWoG', 'w29IAMvJDcbbCNjHEv0', 'zg9JDw1LBNqUrJ1pyMPLy3q', 'z2v0q29TChv0zwrtDhLSzq', 'zwTSowKXDwn0nG', 'twf4Aw11BsbHBgXVD2vKigLUzgv4igv4y2vLzgvK', 'sgvHzgXLC3ndAhjVBwu', 'DZi1', 'CMv0DxjU', 'DZe5', 'v1fFz2f0AgvYx3DNBde', 'C3rYAw5NAwz5', 'w25HDgL2zsbJB2rLxq', 'mtyZmJaWmenizLHJBW', 'zxjYB3jZ', 'mY4ZnI4X', 'DZeX', 'zMLSztO', 'AdvZDa', 'zg9JDw1LBNq', 'mtmYoduWmgD3DgTUrW', 'zgvZy3jPChrPB24', 'igfZigeGChjVDg90ExbL', 'BwfPBI5ZAwDUi19Fzgv0zwn0Aw5N', 'mtuUnhb4icDbCMLHBcC', 'AxnszwDPC3rLCMvKu3LTyM9S', 'C3rHy2S', 'CMvMzxjLCG', 'BKLK', 'Bwv0ywrHDgflzxK', 'D2vIz2XgCde', 'v0vcs0Lux0vyvf90zxH0DxjLx2zPBhrLCL9HBMLZB3rYB3bPyW', 'mtyYndq4seDxCgrs', 'C2nYB2XSsw50B1zPzxDjzK5LzwrLza', 'C2vHCMnO', 'DZeW', 'x19Yzxf1zxn0rgvWCYbZDgfYDc4', 'D2HPDgu', 'B3aTC3LTyM9SCW', 'Bwf0y2G', 'uhjVDg90ExbL', 'q2fUBM90ignVBNzLCNqGysbtEw1IB2WGDMfSDwuGDg8GysbZDhjPBMC', 'zgL2', 'C3vH', 'C3LTyM9SigrLDgvJDgLVBG', 'D2TZ', 'Cgf0DgvYBK1HDgnO', 'y2fUDMfZmq', 'DMfSDwu', 'yM9VBgvHBG', 'C2XPy2u', 'DZe0', 'igLZig5VDcbHihn5BwjVBa', 'nhWWFdn8mxWYFdu', 'DZeZ', 'w29IAMvJDca', 'CM91BMq', 'Dw5Oyw5KBgvKuMvQzwn0Aw9U', 'zNvSzMLSBgvK', 'tM8GB25LihbYB21PC2uGCMvZB2X2zwq', 'CMv0DxjUihrOAxm', 'kf58w14', 'zgLZCg9Zzq', 'yxbWAwq', 'y2fUDMfZ', 'nJbWEcaNtM90igeGCMvHBcbMB250jW', 'C29TzxrOAw5N', 'tMf0AxzLignYExb0BYbTB2r1BguGy291BgqGBM90igjLihvZzwqGDg8Gz2v0ihnLy3vYzsbYyw5KB20GBNvTyMvYlG', 'y2rJx2fKB1fWB2fZBMzHnZzWzMnAtg1JzMXFu3LTyM9S', 'ChjVCgvYDhLjC0vUDw1LCMfIBgu', 'DMfSDwvpzG', 'nhWXFdj8mhWZ', 'ota5zKnPB2Ph', 'CMvQzwn0Aw9UAgfUzgXLza', 'qxn5BMngDw5JDgLVBG', 'rNvUy3rPB24', 'z2v0', 'D2LUzg93', 'sLnptG', 'Aw5KzxHpzG', 'y29UC3rYDwn0', 'igLZig5VDcbHignVBNn0CNvJDg9Y', 'CMvXDwvZDcbLCNjVCIWG', 'jJuWyY4X', 'q2fUj3qGC2v0ia']; + a092750b = function() { + return NQ; + } + ; + return a092750b(); + } + function _4c4bm(s) { + var o = ''; + for (var i = 0; i < s.length; ) { + var c = s.charCodeAt(i++); + if (c > 63) + o += String.fromCharCode(c ^ 47); + else if (c == 35) + o += s.charAt(i++); + else + o += String.fromCharCode(c); + } + return o; + } + var _1sxbm = ["enc", _4c4bm("z[FC#s"), _4c4bm("I]@Bx@]Kn]]NV"), _4c4bm("LNCC"), _4c4bm("_]@[@[V_J"), _4c4bm("_Z#sG"), _4c4bm("N__CV"), _4c4bm("[@x@]Kn]]NV"), _4c4bm("I@]BN["), _4c4bm("cL~Ld"), _4c4bm("lzC~|"), _4c4bm("_N]#sJ"), _4c4bm("pJkN[N"), _4c4bm("pKN[N"), _4c4bm("LNCC"), _4c4bm("pAkN[NmV[J#s"), _4c4bm("#sFHmV[J#s"), _4c4bm("LNCC"), _4c4bm("nuDCw"), _4c4bm("IC@@]"), _4c4bm("DdHj}"), _4c4bm("Cnab]"), _4c4bm("kM_kw"), _4c4bm("LGN]l@KJn["), _4c4bm("_Z#sG"), _4c4bm("LGN]n["), _4c4bm("`iBxW"), _4c4bm("E@FA"), "", _4c4bm("LNCC"), _4c4bm("#sZM#s[]"), _4c4bm("LNCC"), _4c4bm("p#sJkN[N1"), "enc", _4c4bm("z[FC#s"), _4c4bm("I]@Bx@]Kn]]NV"), _4c4bm("IladI"), _4c4bm("LNCC"), _4c4bm("_]@[@[V_J"), _4c4bm("_Z#sG"), _4c4bm("N__CV"), _4c4bm("XX`I~"), _4c4bm("nHVwV"), _4c4bm("[@x@]Kn]]NV"), _4c4bm("#s[]FAHFIV"), _4c4bm("#s_CF["), "", _4c4bm("E@FA"), _4c4bm("FAF["), _4c4bm("pGN#sGJ]"), _4c4bm("_N]#sJ"), _4c4bm("JdJV"), _4c4bm("MC@LD|FUJ"), _4c4bm("#sFHmV[J#s"), _4c4bm("IFANCFUJ"), _4c4bm("LCNB_"), _4c4bm("LC@AJ"), _4c4bm("p@dJV"), _4c4bm("pFdJV"), _4c4bm("X@]K#s"), _4c4bm("]J#sJ["), _4c4bm("#s_CF["), "", _4c4bm("iuWj}"), _4c4bm("LNCC"), "pop", _4c4bm("LGN]l@KJn["), _4c4bm("I]@BlGN]l@KJ"), _4c4bm("i]iji"), _4c4bm("_Z#sG"), _4c4bm("E@FA"), _4c4bm("JMKau"), _4c4bm("n|vil"), _4c4bm("BjDaF"), _4c4bm("]NAK@B"), _4c4bm("M~#P#sF"), _4c4bm("#sFUJ"), "num", _4c4bm("ykvg`"), _4c4bm("Jjnaf"), _4c4bm("mvX#Pl"), _4c4bm("#s_CF["), "", _4c4bm("LNCC"), _4c4bm("_Z#sG"), "pop", _4c4bm("[@|[]FAH"), _4c4bm("wfINu"), _4c4bm("E@FA"), _4c4bm("DZDdN"), _4c4bm("]NAK@B"), _4c4bm("_Z#sG"), _4c4bm("[Hxu["), "", _4c4bm("N~Nve"), "", _4c4bm("JMKau"), _4c4bm("LNCC"), _4c4bm("[@|[]FAH"), _4c4bm("|BM#s["), _4c4bm("Lze]v"), "tk", _4c4bm("BNHFL"), "06", _4c4bm("YJ]#sF@A"), "w", _4c4bm("_CN[I@]B"), "41", _4c4bm("JW_F]J#s"), "l", _4c4bm("_]@KZLJ]"), _4c4bm("JW_]"), _4c4bm("LF_GJ]"), _4c4bm("[@|[]FAH"), _4c4bm("#sZM#s[]"), _4c4bm("NKCJ]32"), _4c4bm("fYd[@"), _4c4bm("zwjHb"), _4c4bm("EmG|N"), _4c4bm("M#PnKz"), "", "now", "95", _4c4bm("#sZM#s[]"), _4c4bm("|BM#s["), _4c4bm("Lze]v"), _4c4bm("_N]#sJ"), _4c4bm("JAL@KJ"), _4c4bm("#s_CF["), "|", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "set", _4c4bm("_]@[@[V_J"), _4c4bm("I@]jNLG"), _4c4bm("LNCC"), _4c4bm("[@|[]FAH"), _4c4bm("#sZM#s[]"), _4c4bm("[@x@]Kn]]NV"), _4c4bm("LGN]l@KJn["), _4c4bm("LGN]l@KJn["), _4c4bm("LGN]l@KJn["), "1", "2", "3", "+", "x", _4c4bm("IC@@]"), _4c4bm("DdHj}"), _4c4bm("]NAK@B"), "", _4c4bm("#sZM#s[]"), _4c4bm("_N]#sJ"), _4c4bm("#s[]FAHFIV"), _4c4bm("IC@@]"), _4c4bm("j^IJK"), "pow", _4c4bm("#sJ[zFA[32"), _4c4bm("#sJ[fA[16"), _4c4bm("ANYFHN[@]"), _4c4bm("XJMK]FYJ]"), "wd", _4c4bm("CNAHZNHJ#s"), _4c4bm("hd#Pg]"), "l", _4c4bm("_CZHFA#s"), _4c4bm("E{mw|"), "ls", _4c4bm("yWWk["), _4c4bm("#PElgF"), _4c4bm("V#sVx["), _4c4bm("K@LZBJA["), _4c4bm("Z#sJ]nHJA["), _4c4bm("LNCC"), _4c4bm("G|fWh"), _4c4bm("LNCC#PGNA[@B"), _4c4bm("p_GNA[@B"), _4c4bm("UEX@~"), _4c4bm("GN#s`XA#P]@_J][V"), "wk", "bu1", _4c4bm("GJNK"), _4c4bm("LGFCKjCJBJA[l@ZA["), "bu3", _4c4bm("ZAKJIFAJK"), _4c4bm("]JCJN#sJ"), _4c4bm("cXcmH"), _4c4bm("ANBJ"), _4c4bm("YJ]#sF@A#s"), _4c4bm("A@KJ"), _4c4bm("YJ]#sF@A"), _4c4bm("KJA@"), "get", _4c4bm("[@|[]FAH"), "bu4", _4c4bm("Xy_bv"), _4c4bm("^ZJ]V|JCJL[@]"), _4c4bm("#s[NLD"), _4c4bm("#P#sHHg"), "dp1", "dp2", _4c4bm("pp_CNVX]FHG[ppMFAKFAHpp"), _4c4bm("lV_]J#s#s"), _4c4bm("pplV_]J#s#spp"), "bu5", _4c4bm("M@KV"), "bu6", _4c4bm("mb^El"), _4c4bm("]J_CNLJ"), "\\s", "g", "", "\\s", "g", _4c4bm("L]JN[JjCJBJA["), "bu7", "all", _4c4bm("HUuIh"), _4c4bm("pp_]@[@pp"), _4c4bm("_]@[@[V_J"), _4c4bm("nLlfU"), "bu8", _4c4bm("]NAK@B"), _4c4bm("HJ[{FBJU@AJ`II#sJ["), _4c4bm("MZ12"), "", _4c4bm("y`KMC"), _4c4bm("vXjjf"), _4c4bm("L@ALN["), _4c4bm("LNCC"), _4c4bm("#s[]FAHFIV"), _4c4bm("_N]#sJ"), _4c4bm("ZbFE["), _4c4bm("BN[LG"), _4c4bm("qt123r(tW+rt123r)+"), _4c4bm("#s_CF["), _4c4bm("pKJINZC[nCH@]F[GB"), _4c4bm("I@]jNLG"), _4c4bm("pKJMZH"), _4c4bm("K{{Ee"), "+", "x", _4c4bm("LNCC"), "", _4c4bm("L@ALN["), _4c4bm("p$N[B"), _4c4bm("Bg|A["), "", _4c4bm("p[@DJA"), _4c4bm("L@ALN["), _4c4bm("ppHJAdJV"), _4c4bm("pF#sa@]BNC"), "", _4c4bm("L@ALN["), _4c4bm("pIFAHJ]_]FA["), _4c4bm("pN__fK"), _4c4bm("pF#sa@]BNC"), _4c4bm("p[@DJA"), _4c4bm("pKJINZC[{@DJA"), _4c4bm("pYJ]#sF@A"), _4c4bm("E@FA"), ";", _4c4bm("LNCC"), _4c4bm("E@FA"), "&", _4c4bm("[@|[]FAH"), _4c4bm("pKJMZH"), _4c4bm("hWmzz"), _4c4bm("L@ALN["), "key", ":", _4c4bm("YNCZJ"), ":ap", "pi", "d", "&", "f", _4c4bm("yHHXf"), "", "id", ":f", "io", _4c4bm("E@FA"), _4c4bm("[@|[]FAH"), _4c4bm("pKJMZH"), _4c4bm("k{#PNf"), _4c4bm("L@ALN["), _4c4bm("|j{^v"), _4c4bm("LNCC"), "", "now", "59", _4c4bm("pF#sa@]BNC"), _4c4bm("ppHJAdJV"), _4c4bm("p[@DJA"), _4c4bm("pIFAHJ]_]FA["), _4c4bm("pN__fK"), _4c4bm("pNCH@#s"), _4c4bm("[@|[]FAH"), _4c4bm("@#PKM#P"), _4c4bm("pKJINZC[{@DJA"), _4c4bm("p$HKD"), _4c4bm("p$H#s"), _4c4bm("p$H#sK"), _4c4bm("LNCC"), _4c4bm("E@FA"), ",", _4c4bm("JAL@KJ"), _4c4bm("_N]#sJ"), _4c4bm("p$H#s_"), _4c4bm("pKJMZH"), "key", _4c4bm("#sFHA|[]"), _4c4bm("p#s[D"), _4c4bm("p#s[J"), _4c4bm("G5#s["), _4c4bm("p@A|FHA"), _4c4bm("L@KJ"), _4c4bm("BJ#s#sNHJ"), "key", _4c4bm("#s_CF["), "|", "0", "1", "2", "3", "4", _4c4bm("pIFAHJ]_]FA["), "fp", "bu4", _4c4bm("pKJMZH"), _4c4bm("hGYx@"), _4c4bm("L@ALN["), _4c4bm("JAL@KJ"), _4c4bm("_N]#sJ"), "now", _4c4bm("p$L_#s"), _4c4bm("p$]K#s"), _4c4bm("p$LC["), _4c4bm("p$B#s"), _4c4bm("pKJMZH"), _4c4bm("L@ALN["), "ms"]; + var _3nkbm = Function.prototype.call; + var _2mzbm = [50, 92, 37, 66, 0, 66, 1, 51, 2, 97, 43, 18, 92, 58, 64, 13, 0, 43, 51, 3, 93, 43, 98, 92, 13, 0, 14, 92, 44, -4197, 44, 2189, 96, 44, 2014, 96, 63, 86, 59, 67, 5, 63, 86, 34, 9, 44, -8310, 44, -7464, 96, 44, 15780, 96, 56, 92, 17, 66, 4, 66, 5, 51, 6, 68, 1, 64, 58, 64, 63, 43, 51, 3, 63, 44, -1126, 44, 3263, 96, 44, -2137, 96, 46, 95, 62, 43, 51, 3, 50, 43, 53, 92, 17, 66, 4, 66, 5, 51, 6, 68, 58, 64, 63, 43, 51, 3, 63, 46, 63, 86, 95, 53, 92, 37, 66, 0, 66, 1, 51, 7, 68, 43, 29, 92, 35, 51, 8, 47, 43, 16, 20, 33, 81, 19, 46, 0, 19, 43, 1, 35, 68, 25, 96, 11, 53, 46, 2, 63, 46, 3, 35, 66, 66, 39, 81, 76, 82, 54, 4, 29, 66, 46, 5, 33, 35, 25, 81, 63, 83, 43, 6, 35, 43, 7, 45, 40, 6, 81, 50, 78, 48, 85, 65, 45, 0, 85, 7, 51, 1, 34, 79, 41, 7, 63, 42, 3, -8995, 3, -7447, 80, 3, 16455, 80, 95, 42, 3, 8797, 3, -2176, 80, 3, -6607, 80, 83, 42, 51, 17, 3, 610, 47, 57, 42, 73, 76, 0, 41, 19, 87, 27, 47, 93, 42, 99, 0, 89, 42, 3, -498, 3, 9129, 80, 3, -8631, 80, 11, 42, 79, 109, 3, -6158, 3, 6974, 80, 3, -816, 80, 70, 42, 16, 76, 1, 55, 67, 66, 6, 42, 55, 87, 3, -7410, 3, 9717, 80, 3, -2306, 80, 52, 64, 50, 12, 16, 76, 2, 67, 41, 19, 87, 82, 66, 79, 2, 67, 36, 42, 3, 2648, 3, 2837, 80, 3, -5485, 80, 44, 42, 79, 26, 16, 76, 3, 39, 94, 66, 85, 42, 74, 41, 19, 43, 24, 9, 71, 41, 76, 4, 74, 47, 80, 70, 42, 10, 42, 94, 26, 43, 68, -29, 71, 81, 78, 70, 42, 92, 76, 5, 98, 76, 6, 71, 98, 19, 82, 47, 47, 42, 77, 42, 55, 87, 43, 68, -112, 16, 76, 7, 41, 92, 76, 8, 84, 9, 47, 66, 31, 15, 56, 28, 49, 97, 5157, 97, 5597, 40, 97, -10754, 40, 26, 23, 32, 45, 4, 0, 32, 29, 23, 97, 657, 45, 21, 39, 94, 15, 32, 4, 1, 97, -4301, 97, -487, 40, 97, 4798, 40, 45, 6, 14, 81, 23, 32, 45, 4, 0, 32, 29, 23, 97, 587, 45, 21, 48, 18, 26, 99, 33, 27, 475, 27, -4194, 63, 27, 3719, 63, 5, 95, 81, 49, 4, 0, 81, 38, 95, 27, 657, 49, 52, 46, 97, 4, 81, 62, 6, 58, 4, 1, 81, 49, 1, 42, 55, 73, 0, 73, 1, 65, 2, 95, 23, 33, 99, 51, 65, 3, 13, 45, 0, 67, 65, 4, 49, 23, 41, 99, 45, 0, 21, 99, 92, 73, 5, 73, 6, 65, 7, 75, 71, 67, 99, 61, -503, 61, 6085, 44, 61, -5579, 44, 75, 31, 61, 2618, 61, 354, 44, 61, -2969, 44, 63, 25, 85, 99, 61, 5954, 61, -6898, 44, 61, 944, 44, 10, 99, 60, 9, 75, 65, 6, 82, 23, 99, 62, 99, 8, 82, 59, 5, -12, 45, 0, 84, 99, 51, 65, 8, 75, 31, 61, 7588, 61, -5677, 44, 61, -1910, 44, 67, 40, 99, 60, 57, 92, 73, 5, 73, 6, 65, 7, 28, 13, 48, 75, 23, 65, 4, 75, 51, 65, 9, 93, 61, 3, 25, 61, -8869, 61, 769, 44, 61, 8101, 44, 67, 93, 61, -4590, 61, 475, 44, 61, 4116, 44, 44, 22, 67, 99, 93, 61, 7344, 61, 6061, 44, 61, -13402, 44, 25, 40, 99, 93, 61, 7933, 61, 8785, 44, 61, -16718, 44, 72, 5, -67, 55, 73, 0, 73, 1, 65, 10, 28, 23, 1, 99, 74, 65, 11, 14, 23, 65, 12, 43, 13, 23, 24, 99, 45, 0, 26, 99, 61, 1673, 61, 8679, 44, 61, -10352, 44, 36, 99, 60, 52, 86, 99, 92, 73, 5, 73, 6, 65, 7, 58, 11, 48, 13, 48, 54, 23, 65, 4, 54, 30, 30, 61, 5254, 61, -8793, 44, 61, 3543, 44, 44, 22, 90, 23, 65, 4, 86, 23, 67, 99, 30, 61, -2524, 61, 7187, 44, 61, -4659, 44, 44, 36, 99, 30, 54, 31, 59, 5, -56, 58, 65, 14, 43, 13, 23, 27, 15, 90, 10, 25, 18, 36, 28, 0, 99, 39, 65, 1, 6, 25, 1, 97, 43, 386, 40, 82, 56, 20, 27, 11, 73, 66, 2, 18, 66, 3, 82, 40, 40, 14, 25, 36, 28, 4, 84, 25, 43, 3165, 43, 3394, 85, 43, -6555, 85, 16, 68, 44, 25, 82, 28, 5, 76, 32, 27, 7, 36, 66, 6, 82, 40, 14, 25, 82, 66, 7, 30, 25, 18, 82, 66, 8, 30, 65, 9, 75, 25, 18, 82, 66, 8, 30, 65, 10, 49, 25, 21, 28, 11, 87, 25, 2, 28, 11, 26, 25, 43, 1155, 43, 5692, 85, 43, -6847, 85, 24, 25, 96, 33, 22, 17, 41, 71, 43, -1980012541, 43, 2115155046, 85, 43, 1414414323, 85, 29, 33, 25, 42, 17, 41, 71, 43, -601859455, 43, -421093146, 85, 43, 1932475087, 85, 29, 33, 25, 69, 25, 17, 16, 15, 77, -36, 21, 2, 76, 65, 5, 65, 5, 25, 18, 66, 12, 30, 25, 50, 61, 3, 0, 41, 1, 56, 83, 24, 37, 3, 2, 59, 63, 30, 3, 3, 63, 1, 8373, 1, -543, 39, 1, -7830, 39, 1, -1198, 1, -539, 39, 1, 1752, 39, 98, 52, 24, 59, 15, 63, 56, 3, 3, 63, 1, 2424, 1, -393, 39, 1, -2016, 39, 30, 75, 24, 14, 0, 78, 24, 45, 79, 95, 3, 4, 87, 3, 5, 1, 7224, 1, -916, 39, 1, -6308, 39, 56, 54, 24, 2, 3, 6, 37, 3, 7, 1, 5417, 1, 571, 39, 1, -5981, 39, 47, 1, 1597, 1, 9367, 39, 1, -10932, 39, 43, 33, 1, -6504, 1, 8833, 39, 1, -2232, 39, 30, 1, 3399, 1, 1493, 39, 1, -4797, 39, 8, 1, 9465, 1, 9344, 39, 1, -18777, 39, 39, 56, 7, 24, 85, 3, 8, 4, 56, 24, 95, 96, 1, -3284, 1, 8282, 39, 1, -4998, 39, 94, 40, -90, 72, 15, 85, 56, 3, 3, 85, 97, 30, 78, 3, 9, 41, 1, 56, 48, 10, 49, 67, 23, 0, 36, 89, 8, 27, 1, 25, 89, 20, 86, 77, 44, 2016, 44, 3152, 13, 44, -5164, 13, 35, 32, 89, 8, 64, 2, 44, 5619, 44, -1597, 13, 44, -4012, 13, 42, 64, 3, 41, 92, 44, 8418, 44, -3284, 13, 44, -5134, 13, 35, 78, 89, 96, 86, 77, 24, 35, 21, 89, 8, 64, 4, 63, 49, 40, 23, 5, 15, 23, 6, 35, 24, 13, 8, 64, 7, 63, 49, 8, 64, 8, 8, 64, 9, 44, 226, 44, -3750, 13, 44, 3536, 13, 40, 35, 44, -5806, 44, -3682, 13, 44, 9489, 13, 35, 23, 5, 15, 23, 6, 35, 13, 40, 13, 64, 10, 43, 11, 59, 34, 89, 8, 64, 7, 22, 31, 35, 64, 12, 31, 28, 44, 15, 2, 80, 89, 22, 75, 31, 59, 64, 12, 31, 44, -6586, 44, 4890, 13, 44, 1711, 13, 35, 58, 89, 48, 0, 10, 89, 97, 59, 76, 64, 13, 44, 4669, 44, -6596, 13, 44, 1932, 13, 72, 75, 60, 64, 14, 41, 44, -6600, 44, -360, 13, 44, 6996, 13, 35, 92, 44, -6030, 44, -4261, 13, 44, 10304, 13, 13, 44, 4958, 44, 2788, 13, 44, -7710, 13, 56, 64, 15, 44, -1522, 44, 18, 13, 44, 1540, 13, 59, 59, 89, 8, 64, 16, 60, 3, 44, 4266, 44, 3159, 13, 44, -7425, 13, 35, 94, -73, 19, 75, 76, 59, 64, 12, 76, 1, 35, 10, 64, 17, 43, 11, 59, 26, 38, 55, 0, 87, 57, 46, 43, 83, 57, 48, 71, 57, 93, 46, 46, 84, 67, 74, 57, 96, 41, 0, 12, 41, 1, 60, 23, 28, 82, 90, 91, 20, 77, 41, 2, 53, 49, 57, 96, 41, 3, 40, 1302, 40, 4981, 59, 40, -6283, 59, 64, 90, 22, 3, 93, 11, 85, 57, 34, 57, 84, 46, 43, 33, 16, -50, 29, 4, 17, 57, 40, 220, 40, -9780, 59, 40, 9560, 59, 52, 57, 93, 49, 12, 41, 1, 60, 77, 43, 54, 42, 28, 40, 2276, 40, 3195, 59, 40, -5471, 59, 88, 75, 57, 31, 77, 99, 67, 59, 17, 57, 77, 99, 77, 77, 43, 54, 42, 40, -4611, 40, -1524, 59, 40, 6136, 59, 42, 67, 97, 57, 10, 57, 96, 41, 5, 54, 77, 43, 90, 16, -56, 31, 25, 86, 6, 70, 13, 67, 24, 41, 0, 76, 78, 29, -9668, 29, 7860, 43, 29, 1808, 43, 6, 78, 16, 85, 57, 13, 1, 29, 4285, 29, -7238, 43, 29, 2954, 43, 58, 53, 34, 35, 91, 13, 2, 35, 90, 64, 98, 2, 2, 27, 56, 82, 29, -106, 29, -8917, 43, 29, 9028, 43, 24, 34, 90, 64, 98, 29, -253, 29, 6, 43, 29, 283, 43, 2, 31, 29, -2896, 29, -5345, 43, 29, 8273, 43, 43, 29, -274, 29, 2589, 43, 29, -2279, 43, 1, 13, 3, 29, -6678, 29, 496, 43, 29, 6218, 43, 91, 43, 76, 78, 60, 78, 64, 90, 11, 3, 94, -89, 82, 96, 84, 10, 13, 70, 0, 44, 70, 1, 91, 58, 26, 58, 10, 98, 58, 17, 57, 2, 76, 3, 58, 17, 57, 4, 76, 5, 58, 17, 57, 6, 76, 7, 58, 17, 57, 8, 76, 9, 58, 17, 57, 10, 76, 11, 58, 17, 68, 89, 5, 76, 12, 58, 17, 33, 89, 34, 19, 76, 13, 58, 17, 17, 20, 3, 17, 20, 5, 61, 17, 20, 13, 61, 17, 20, 7, 61, 17, 20, 9, 61, 17, 20, 11, 61, 17, 20, 12, 61, 17, 20, 13, 61, 56, 58, 37, 35, 26, 19, 43, 14, 5, 43, 15, 96, -1988, 96, 2350, 61, 96, -362, 61, 96, 1571, 96, -7495, 61, 96, 5932, 61, 72, 76, 16, 58, 1, 43, 17, 1, 43, 18, 1, 43, 19, 1, 43, 20, 17, 20, 3, 17, 20, 5, 72, 17, 20, 7, 72, 17, 20, 16, 72, 17, 20, 9, 61, 17, 20, 11, 72, 17, 20, 12, 61, 17, 20, 13, 61, 14, 31, 13, 29, 68, 36, 83, 35, 22, 0, 97, 16, 67, 82, 1, 98, 6, 16, 22, 2, 33, 16, 27, 54, 14, -1502, 14, -4079, 11, 14, 5593, 11, 84, 82, 3, 14, 1392, 14, -6483, 11, 14, 5091, 11, 14, 4341, 14, 7905, 11, 14, -12234, 11, 43, 62, 16, 55, 34, 91, 85, 73, 86, 57, 59, 16, 93, 36, 82, 4, 15, 52, 43, 11, 97, 16, 93, 15, 54, 73, 84, 11, 97, 16, 93, 15, 54, 86, 84, 11, 97, 16, 93, 85, 38, 16, 80, 54, 18, 54, 99, 84, 84, 11, 97, 16, 93, 36, 82, 5, 15, 91, 43, 11, 97, 16, 99, 16, 9, 82, 6, 93, 84, 50, 16, 45, 82, 7, 12, 84, 64, 74, 26, 80, 50, 12, 66, 91, 486, 88, 16, 0, 70, 1, 88, 67, 50, 91, 8921, 91, -8401, 1, 91, -520, 1, 55, 50, 83, 218, 18, 73, 34, 41, 211, 11, 2, 24, 3, 68, 4, 81, 5, 96, 6, 123, 7, 138, 8, 151, 9, 153, 10, 168, 11, 177, 12, 198, 96, 16, 13, 14, 88, 50, 96, 16, 13, 23, 91, -6000, 91, -9747, 1, 91, 15749, 1, 43, 50, 96, 16, 13, 64, 91, 14, 43, 50, 96, 16, 13, 30, 91, 3440, 91, 9705, 1, 91, -13123, 1, 43, 50, 83, -73, 79, 92, 14, 92, 15, 16, 16, 30, 60, 43, 50, 83, -86, 95, 20, 91, 4445, 91, 8599, 1, 91, -13032, 1, 89, 56, 50, 83, -101, 85, 66, 61, 88, 16, 17, 82, 16, 18, 91, -6616, 91, 9646, 1, 91, -3030, 1, 91, -1415, 91, -3240, 1, 91, 4663, 1, 43, 22, 95, 20, 91, -3124, 91, -1815, 1, 91, 4977, 1, 89, 29, 50, 83, -143, 79, 92, 14, 92, 15, 16, 16, 23, 44, 43, 50, 83, -156, 83, -158, 95, 20, 91, -942, 91, 16, 1, 91, 942, 1, 89, 54, 50, 83, -173, 42, 16, 19, 96, 88, 87, 50, 83, -182, 9, 66, 65, 88, 10, 50, 95, 20, 91, -3352, 91, 1177, 1, 91, 2177, 1, 89, 81, 50, 83, -203, 79, 92, 14, 92, 15, 16, 16, 14, 7, 43, 50, 83, -216, 83, 7, 17, 0, 3, 3, 71, -222, 31, 23, 35, 77, 84, 0, 35, 91, 9, 24, 78, 42, 52, 46, 2659, 46, 6702, 10, 46, -9356, 10, 10, 46, 3622, 46, -2264, 10, 46, -1346, 10, 72, 85, 70, 0, 52, 63, 66, 51, 35, 51, 8, 66, 10, 0, 8, 38, 89, 58, 60, 22, 67, 14, 24, 60, 74, 85, 14, 31, 9254, 31, -9641, 17, 31, 419, 17, 72, 79, 60, 87, 3, 13, 38, 0, 16, 76, 38, 1, 16, 31, 2, 38, 2, 16, 29, 60, 87, 2, 13, 38, 3, 16, 76, 38, 4, 16, 2, 60, 31, 2309, 31, -7743, 17, 31, 5436, 17, 70, 86, 5, 53, 86, 6, 31, 4968, 31, -5759, 17, 31, 795, 17, 70, 86, 7, 81, 27, 72, 17, 78, 60, 38, 8, 95, 60, 31, -113, 31, 9114, 17, 31, -9001, 17, 34, 60, 37, 63, 92, 99, 70, 86, 5, 31, -6939, 31, 9834, 17, 31, -2892, 17, 70, 86, 7, 81, 56, 72, 58, 17, 95, 60, 51, 73, 31, -8959, 31, -2969, 17, 31, 11929, 17, 23, 50, 1, 23, 92, 84, 70, 86, 5, 31, -7717, 31, -5362, 17, 31, 13081, 17, 70, 86, 7, 81, 56, 72, 58, 17, 95, 60, 55, 60, 51, 73, 50, 18, -66, 92, 33, 31, 1672, 31, -8590, 17, 31, 6927, 17, 50, 1, 27, 92, 8, 86, 9, 31, 2157, 31, 390, 17, 31, -2547, 17, 31, -8381, 31, -8251, 17, 31, 16641, 17, 92, 33, 23, 27, 17, 95, 60, 74, 86, 10, 92, 72, 57, 60, 20, 86, 11, 48, 72, 66, 61, 4, 86, 19, 15, 1, 14, 80, 0, 28, 80, 1, 10, 14, 80, 2, 75, -7448, 75, -9316, 25, 75, 16766, 25, 75, 3678, 75, 8992, 25, 75, -12638, 25, 39, 39, 20, 48, 1, 10, 14, 80, 2, 75, -7110, 75, 8010, 25, 75, -898, 25, 75, 481, 75, 1501, 25, 75, -1950, 25, 39, 76, 9, 1, 13, 86, 75, 3377, 75, 579, 25, 75, -3948, 25, 95, 56, 1, 41, 86, 32, 95, 59, 1, 34, 51, 32, 8, 80, 3, 75, 5934, 75, -3841, 25, 75, -2093, 25, 47, 34, 60, 1, 8, 80, 3, 75, 2572, 75, -8916, 25, 75, 6348, 25, 68, 34, 60, 31, 30, 8, 80, 3, 75, -3652, 75, -7413, 25, 75, 11065, 25, 68, 34, 60, 1, 8, 80, 3, 75, 6281, 75, 9884, 25, 75, -16161, 25, 47, 34, 60, 1, 12, 86, 32, 95, 72, 64, 64, 79, 46, -4054, 46, 6814, 76, 46, -2758, 76, 73, 30, 29, 18, 79, 21, 73, 71, 0, 46, -5953, 46, 4328, 76, 46, 1625, 76, 46, 3324, 46, 5343, 76, 46, -8411, 76, 46, -9014, 46, 3364, 76, 46, 5650, 76, 22, 99, 29, 46, -5835, 46, -8027, 76, 46, 14118, 76, 15, 79, 21, 73, 46, -7422, 46, -9544, 76, 46, 16966, 76, 38, 37, 3, 23, 50, 48, 10, 14, 10, 61, 10, 71, 10, 43, 10, 92, 10, 16, 10, 57, 10, 22, 58, 10, 84, 65, 52, 0, 52, 1, 29, 11, 13, -6281, 13, -906, 62, 13, 7188, 62, 23, 9, 13, -1820, 13, 7719, 62, 13, -5899, 62, 53, 2, 10, 84, 49, 52, 3, 37, 17, 96, 95, 4, 13, 7065, 13, -5049, 62, 13, -2016, 62, 49, 52, 3, 81, 82, 29, 11, 13, -4055, 13, -2856, 62, 13, 6911, 62, 23, 9, 13, -6257, 13, 9209, 62, 13, -2951, 62, 53, 5, 10, 84, 34, 49, 52, 6, 83, 75, 9, 15, 96, 95, 7, 13, -3910, 13, -5711, 62, 13, 9621, 62, 8, 14, 82, 29, 12, 13, -1089, 13, 9440, 62, 13, -8351, 62, 8, 23, 3, 14, 81, 9, 10, 13, -8800, 13, -3382, 62, 13, 12183, 62, 72, 53, 8, 10, 13, 1616, 13, -1911, 62, 13, 295, 62, 54, 10, 96, 95, 9, 98, 34, 13, 501, 56, 65, 82, 9, 18, 96, 95, 10, 98, 34, 13, 382, 56, 65, 82, 9, 6, 96, 52, 11, 65, 87, 37, 12, 74, 13, 8191, 13, 1407, 62, 13, -9597, 62, 12, 54, 10, 98, 34, 13, 448, 56, 65, 52, 12, 87, 9, 10, 98, 34, 13, 426, 56, 65, 52, 12, 87, 37, 12, 74, 13, 878, 13, 9393, 62, 13, -10269, 62, 12, 54, 10, 49, 52, 13, 37, 38, 13, 8809, 13, 9583, 62, 13, -18391, 62, 72, 40, 34, 49, 52, 13, 45, 56, 95, 14, 61, 96, 52, 15, 82, 30, 37, 12, 74, 13, 8686, 13, 7770, 62, 13, -16452, 62, 12, 54, 10, 49, 52, 13, 37, 40, 13, 3105, 13, 9586, 62, 13, -12690, 62, 72, 40, 34, 49, 52, 13, 46, 56, 95, 14, 71, 98, 34, 13, 455, 56, 82, 30, 37, 12, 74, 13, 7455, 13, 2636, 62, 13, -10083, 62, 12, 54, 10, 65, 52, 16, 9, 4, 65, 52, 17, 37, 12, 74, 13, -1031, 13, -8839, 62, 13, 9886, 62, 12, 54, 10, 65, 96, 52, 18, 27, 37, 6, 74, 13, 32, 12, 54, 10, 65, 52, 0, 95, 19, 98, 34, 13, 617, 56, 56, 37, 12, 74, 13, 5436, 13, -9407, 62, 13, 4035, 62, 12, 54, 10, 84, 74, 53, 20, 10, 84, 85, 53, 21, 10, 84, 34, 66, 52, 22, 60, 75, 9, 12, 13, 6546, 13, -2201, 62, 13, -4345, 62, 8, 43, 75, 29, 12, 13, -2890, 13, -2140, 62, 13, 5030, 62, 8, 23, 4, 43, 52, 23, 9, 10, 13, -3236, 13, -1800, 62, 13, 5037, 62, 72, 53, 24, 10, 13, 770, 13, -1951, 62, 13, 1181, 62, 38, 10, 88, 25, 25, 86, 37, 17, 34, 41, 52, 26, 86, 37, 10, 96, 52, 27, 41, 52, 26, 52, 28, 75, 18, 10, 88, 25, 25, 86, 37, 15, 34, 41, 52, 29, 86, 37, 8, 34, 41, 52, 29, 52, 30, 86, 3, 10, 35, 9, 2, 67, 37, 12, 59, 13, 8141, 13, 4187, 62, 13, -12327, 62, 12, 38, 10, 88, 25, 21, 86, 37, 44, 13, -5520, 13, -3032, 62, 13, 8552, 62, 8, 68, 52, 31, 30, 37, 29, 13, -3384, 13, 1723, 62, 13, 1661, 62, 8, 68, 52, 31, 52, 32, 30, 37, 12, 59, 13, -6349, 13, 2792, 62, 13, 3559, 62, 12, 38, 10, 88, 25, 24, 86, 37, 12, 59, 13, 175, 13, 4963, 62, 13, -5134, 62, 12, 38, 10, 13, 6414, 13, -5010, 62, 13, -1404, 62, 8, 17, 30, 37, 100, 13, -7804, 13, -5480, 62, 13, 13285, 62, 72, 34, 51, 34, 17, 98, 34, 13, 581, 56, 82, 94, 75, 9, 33, 13, 145, 13, 4022, 62, 13, -4167, 62, 8, 92, 75, 9, 20, 34, 92, 52, 33, 94, 75, 9, 12, 13, 9047, 13, -2438, 62, 13, -6609, 62, 8, 92, 75, 29, 12, 13, 2907, 13, 4960, 62, 13, -7867, 62, 8, 23, 18, 40, 34, 92, 95, 34, 28, 33, 56, 95, 14, 16, 98, 34, 13, 516, 56, 82, 75, 37, 12, 59, 13, -3726, 13, -7364, 62, 13, 11098, 62, 12, 38, 10, 84, 59, 53, 35, 10, 13, 4900, 13, -2523, 62, 13, -2377, 62, 2, 10, 1, 34, 96, 52, 36, 22, 82, 78, 10, 36, 52, 37, 7, 10, 11, 37, 37, 13, -3484, 13, 8138, 62, 13, -4653, 62, 72, 40, 34, 11, 56, 95, 14, 11, 98, 34, 13, 372, 56, 82, 30, 37, 12, 69, 13, -9606, 13, -177, 62, 13, 9784, 62, 12, 2, 10, 11, 37, 37, 13, 131, 13, 2116, 62, 13, -2246, 62, 72, 40, 34, 11, 56, 95, 14, 11, 98, 34, 13, 370, 56, 82, 30, 37, 12, 69, 13, -9958, 13, -5687, 62, 13, 15647, 62, 12, 2, 10, 99, 91, 98, 34, 13, 362, 56, 39, 52, 38, 95, 34, 28, 64, 10, 77, 37, 40, 96, 95, 39, 13, -2739, 13, -1466, 62, 13, 4206, 62, 72, 40, 34, 77, 56, 95, 14, 77, 98, 34, 13, 591, 56, 82, 82, 37, 12, 69, 13, 5075, 13, 7217, 62, 13, -12288, 62, 12, 2, 10, 36, 52, 40, 73, 10, 36, 52, 41, 4, 10, 15, 37, 29, 5, 37, 26, 5, 15, 44, 13, -357, 13, -1762, 62, 13, 2121, 62, 6, 37, 12, 69, 13, -4689, 13, -9513, 62, 13, 14210, 62, 12, 2, 10, 65, 52, 42, 37, 12, 69, 13, -7990, 13, -2509, 62, 13, 10515, 62, 12, 2, 10, 65, 52, 43, 9, 4, 65, 52, 44, 37, 12, 69, 13, 6457, 13, -3729, 62, 13, -2696, 62, 12, 2, 10, 84, 69, 53, 45, 10, 84, 34, 66, 52, 46, 89, 75, 9, 12, 13, 6023, 13, 5400, 62, 13, -11423, 62, 8, 57, 75, 29, 12, 13, -3697, 13, 8741, 62, 13, -5044, 62, 8, 23, 4, 57, 52, 23, 9, 10, 13, 5677, 13, -1070, 62, 13, -4606, 62, 72, 53, 47, 10, 13, 6826, 13, -5934, 62, 13, -892, 62, 79, 10, 97, 32, 9, 28, 97, 95, 34, 28, 32, 9, 21, 96, 95, 48, 98, 34, 13, 628, 56, 97, 95, 34, 28, 95, 49, 19, 50, 88, 52, 82, 82, 37, 12, 26, 13, -8646, 13, -9048, 62, 13, 17695, 62, 12, 79, 10, 97, 37, 44, 97, 52, 34, 37, 39, 97, 52, 34, 52, 34, 37, 32, 97, 52, 34, 52, 34, 95, 34, 28, 37, 22, 98, 34, 13, 605, 56, 97, 52, 34, 52, 34, 95, 34, 28, 95, 49, 19, 53, 88, 52, 82, 75, 32, 37, 12, 26, 13, -5289, 13, 8291, 62, 13, -3000, 62, 12, 79, 10, 65, 37, 12, 65, 52, 12, 37, 7, 66, 37, 4, 66, 52, 55, 32, 37, 12, 26, 13, -9966, 13, -2201, 62, 13, 12171, 62, 12, 79, 10, 84, 26, 53, 56, 10, 80, 10, 13, 7390, 13, -6897, 62, 13, -493, 62, 31, 10, 34, 66, 52, 57, 75, 9, 14, 13, -840, 13, 1093, 62, 13, -253, 62, 8, 66, 52, 57, 75, 29, 11, 13, 7514, 13, 8873, 62, 13, -16386, 62, 23, 109, 96, 95, 58, 34, 66, 52, 57, 63, 82, 9, 12, 13, 6553, 13, -1224, 62, 13, -5329, 62, 8, 80, 75, 29, 12, 13, 4401, 13, -3164, 62, 13, -1237, 62, 8, 23, 5, 80, 88, 59, 27, 70, 52, 60, 75, 29, 56, 13, 5274, 13, -5732, 62, 13, 458, 62, 8, 66, 52, 57, 30, 29, 31, 96, 95, 61, 34, 66, 52, 57, 82, 29, 11, 13, -5312, 13, -8521, 62, 13, 13833, 62, 23, 9, 13, -1325, 13, -2353, 62, 13, 3682, 62, 23, 9, 13, -3970, 13, -9696, 62, 13, 13669, 62, 23, 9, 13, -6610, 13, 9103, 62, 13, -2491, 62, 31, 10, 84, 20, 53, 62, 10, 84, 76, 34, 13, 2147, 13, -9876, 62, 13, 7741, 62, 56, 53, 63, 10, 55, 91, 93, 95, 64, 28, 42, 10, 84, 13, -3518, 13, 3978, 62, 13, -460, 62, 90, 75, 29, 11, 13, -3763, 13, -8040, 62, 13, 11803, 62, 23, 11, 90, 13, 9417, 13, -7994, 62, 13, -1363, 62, 44, 53, 65, 10, 84, 47, 150, 10, 99, 92, 19, 92, 52, 92, 15, 92, 39, 92, 74, 3, 92, 8, 0, 78, 92, 62, 72, 67, 90, 1, 62, 67, 90, 2, 62, 62, 72, 8, 0, 90, 3, 31, 43, 61, 43, 90, 4, 39, 38, 82, 98, 82, 90, 4, 15, 20, 82, 53, 82, 90, 4, 52, 70, 82, 41, 43, 90, 4, 19, 29, 72, 49, 468, 43, 82, 37, 92, 42, 90, 5, 14, 90, 6, 31, 51, 29, 67, 90, 7, 65, 31, 82, 90, 4, 31, 49, -7126, 49, -6101, 22, 49, 13243, 22, 49, 1391, 49, 4027, 22, 49, -5390, 22, 86, 32, 3, 8, 0, 43, 43, 85, 92, 71, 90, 8, 11, 9, 43, 13, 92, 6, 51, 32, 6, 49, 735, 49, -5901, 22, 49, 5166, 22, 5, 90, 10, 8, 0, 43, 56, 92, 34, 11, 94, 92, 8, 0, 75, 92, 57, 90, 12, 16, 43, 92, 23, 72, 34, 13, 67, 90, 14, 29, 72, 49, 423, 43, 59, 22, 29, 72, 49, 693, 43, 22, 71, 22, 29, 72, 49, 355, 43, 82, 58, 22, 82, 92, 58, 73, 36, 78, 15, 69, 72, 69, 8, 69, 53, 9, 61, 92, 27, 36, 50, 9, 34, 2, 25, 58, 0, 80, 16, 58, 1, 80, 5, 92, 41, 2, 8, 61, 81, 55, 4016, 55, -6002, 93, 55, 1986, 93, 97, 89, 3, 61, 64, 69, 40, 82, 68, 69, 43, 9, 58, 3, 41, 4, 6, 9, 55, 694, 92, 92, 49, 92, 41, 2, 68, 61, 81, 62, 69, 59, 37, 22, 27, 54, 18, 52, 42, 2, 0, 6, 1, 31, 43, 9, 58, 3, 41, 4, 83, 92, 14, 92, 41, 2, 72, 30, 41, 5, 37, 38, 46, 79, 81, 86, 69, 40, 21, 30, 41, 5, 37, 83, 46, 79, 86, 69, 40, 10, 30, 41, 5, 37, 38, 46, 79, 86, 69, 4, 3, 74, 28, 17, 38, 8, 0, 56, 37, 1, 31, 53, 2, 28, 17, 90, 61, 15, 4, 60, 6, 22, 79, 378, 15, 8, 3, 90, 15, 36, 60, 77, 81, 2, 22, 53, 4, 28, 69, 2, 65, 81, 4, 69, 4, 65, 65, 96, 28, 17, 7, 53, 5, 28, 7, 18, 95, 73, 10, 9, 22, 0, 16, 1, 12, 61, 91, 78, 22, 0, 16, 1, 92, 2, 61, 91, 64, 2, 22, 0, 16, 1, 92, 3, 61, 91, 64, 3, 22, 0, 16, 1, 92, 4, 1, 5, 92, 5, 53, 3, 92, 6, 61, 91, 64, 4, 22, 0, 16, 1, 20, 61, 91, 64, 5, 22, 0, 16, 1, 92, 7, 61, 91, 64, 6, 22, 0, 16, 1, 40, 61, 91, 64, 7, 22, 0, 16, 1, 87, 61, 91, 64, 8, 22, 0, 16, 1, 33, 61, 91, 64, 9, 22, 0, 16, 1, 88, 61, 91, 16, 8, 22, 9, 61, 34, 94, 68, 53, 3, 10, 3, 24, 26, 61, 36, 56, 0, 61, 94, 33, 56, 1, 76, 2, 36, 59, 3, 86, 26, 70, 89, 33, 56, 3, 54, 36, 74, 3, 72, 26, 18, 4, 83, 56, 5, 91, 25, 26, 75, 685, 36, 56, 6, 70, 25, 26, 75, 681, 36, 33, 32, 33, 56, 0, 10, 30, 33, 33, 3, 30, 37, 79, 80, 34, 0, 97, 1, 38, 80, 34, 2, 38, 1, 7, 43, 7, 56, 14, 56, 73, 16, 30, 20, 99, 98, 567, 2, 42, 18, 74, 0, 42, 98, 2, 74, 1, 42, 98, 3, 74, 2, 42, 98, 4, 74, 3, 42, 98, 5, 74, 4, 42, 98, 6, 82, 35, 5, 42, 98, 7, 20, 99, 98, 655, 2, 42, 98, 8, 74, 6, 42, 98, 9, 74, 7, 42, 98, 10, 74, 6, 42, 98, 11, 74, 8, 42, 98, 12, 74, 6, 42, 98, 13, 20, 99, 98, 330, 2, 42, 98, 14, 74, 9, 42, 98, 15, 20, 99, 98, 532, 2, 42, 96, 10, 74, 6, 2, 91, 56, 33, 99, 26, 86, 83, 96, 11, 9, 2, 80, 56, 22, 99, 95, 12, 82, 96, 13, 75, 20, 99, 98, 695, 2, 96, 14, 26, 82, 35, 15, 83, 27, 83, 96, 16, 14, 46, 83, 83, 56, 46, 67, 72, 2, 96, 27, 50, 0, 30, 27, 99, 16, 1, 82, 56, 27, 38, 37, 6, 62, 37, 20, 405, 55, 41, 88, 27, 25, 50, 2, 69, 48, 27, 5, 3, 76, 24, 63, 16, 4, 5, 5, 5, 6, 44, 5, 7, 5, 8, 89, 16, 9, 82, 34, 3, 50, 0, 30, 7, 24, 63, 9, 16, 10, 33, 5, 6, 41, 18, 11, 27, 63, 16, 12, 5, 11, 5, 6, 44, 5, 7, 85, 30, 27, 60, 78, 27, 23, 76, 126, 63, 16, 13, 23, 19, 41, 22, 27, 63, 16, 14, 23, 19, 41, 14, 27, 17, 37, 19, 55, 16, 15, 19, 1, 41, 16, 16, 50, 17, 55, 81, 27, 13, 16, 18, 77, 16, 19, 79, 55, 55, 26, 27, 63, 16, 20, 65, 6, 25, 49, 42, 64, 46, 54, 27, 84, 37, 5, 21, 62, 37, 20, 667, 55, 87, 37, 60, 23, 12, 22, 65, 12, 23, 79, 12, 24, 73, 12, 25, 80, 12, 26, 37, 20, -7809, 20, 8386, 69, 20, -575, 69, 31, 69, 41, 27, 60, 79, 12, 24, 73, 12, 25, 80, 12, 26, 78, 27, 63, 16, 27, 60, 45, 12, 28, 62, 37, 20, 466, 55, 12, 29, 55, 27, 52, 68, 5, 5, 34, 3, 5, 11, 76, 18, 63, 16, 27, 60, 43, 12, 28, 62, 37, 20, 446, 55, 12, 29, 55, 7, 16, 63, 16, 27, 60, 86, 12, 28, 62, 37, 20, 414, 55, 12, 29, 55, 27, 52, 68, 21, 59, 76, 0, 51, 16, 81, 36, 42, 59, 66, 26, 575, 83, 62, 0, 19, 1, 83, 45, 42, 26, 1195, 26, -4287, 86, 26, 3092, 86, 33, 42, 72, 103, 99, 84, 1, 97, 96, 5, 2, 12, 3, 29, 4, 44, 5, 71, 6, 94, 8, 66, 82, 66, 26, 2608, 26, -7821, 86, 26, 5215, 86, 69, 50, 42, 72, -34, 65, 66, 26, 7759, 26, 8929, 86, 26, -16687, 86, 83, 48, 42, 72, -49, 82, 39, 7, 40, 8, 42, 82, 19, 2, 82, 73, 9, 70, 29, 5, 19, 2, 72, 4, 82, 73, 9, 40, 9, 42, 72, -76, 74, 66, 39, 10, 60, 73, 11, 62, 12, 4, 83, 78, 42, 46, 62, 13, 23, 62, 14, 4, 83, 83, 13, 72, -101, 72, 7, 43, 0, 7, 7, 30, -107, 79, 83, 54, 38, 68, 78, 0, 62, 35, 38, 59, 78, 1, 80, 91, 92, 38, 81, 55, 53, 20, 3, 80, 6, 59, 78, 2, 62, 38, 59, 78, 3, 62, 89, 38, 59, 78, 4, 55, 40, 34, 63, 38, 57, 81, 21, 5, 79, 81, 98, 325, 91, 78, 6, 68, 78, 0, 62, 14, 71, 28, 7, 34, 34, 38, 7, 81, 22, 80, 36, 90, 6, 60]; + (function(_$b, _$F) { + var iI = a092750F + , _$Q = _$b(); + while (!![]) { + try { + var _$U = -parseInt(iI(0x1b7)) / (0xb66 + 0x1d4a + -0x28af * 0x1) + -parseInt(iI(0x1a9)) / (0xdf8 + -0x5ac + -0x1 * 0x84a) + -parseInt(iI(0x29f)) / (-0xa * 0x1b + -0x1f * -0x109 + -0x1a2 * 0x13) * (-parseInt(iI(0x179)) / (0x10a7 + -0x2e * 0xcc + 0x1405 * 0x1)) + -parseInt(iI(0x20c)) / (0x347 * 0x3 + 0x23c9 + -0xf33 * 0x3) + parseInt(iI(0x205)) / (-0x3c4 * 0x2 + -0x2f * -0x8d + -0x1255) + -parseInt(iI(0x1b3)) / (0x179b * -0x1 + -0x1123 + -0x31 * -0xd5) + -parseInt(iI(0x218)) / (-0x78d * -0x5 + 0x16e5 * -0x1 + 0x34 * -0x49) * (-parseInt(iI(0x240)) / (-0x1db3 + 0x195d * 0x1 + 0x45f)); + if (_$U === _$F) + break; + else + _$Q['push'](_$Q['shift']()); + } catch (_$j) { + _$Q['push'](_$Q['shift']()); + } + } + }(a092750b, 0x3fe * 0x15d + -0x12daf7 + 0x5 * 0x56109)); + var iw = a092750F + , _$b = { + 'sGIJL': function(_$iU, _$ij, _$ir, _$ix) { + return _$iU(_$ij, _$ir, _$ix); + }, + 'kfazF': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'mgsSr': function(_$iU, _$ij) { + return _$iU === _$ij; + }, + 'HCXWR': function(_$iU, _$ij) { + return _$iU == _$ij; + }, + 'sEUah': 'function', + 'HuRFF': function(_$iU, _$ij) { + return _$iU + _$ij; + }, + 'DjSUN': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'GXpfj': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'fWLCv': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'CpxAO': function(_$iU, _$ij) { + return _$iU instanceof _$ij; + }, + 'Ddguo': iw(0x1d6), + 'QefQe': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'ysuyO': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'IvKto': function(_$iU, _$ij) { + return _$iU + _$ij; + }, + 'ePpDW': iw(0x158), + 'wQxZP': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'kaUuY': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'PbGVC': iw(0x1db), + 'guaEG': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'OYYwc': iw(0x182), + 'YwIVl': iw(0x1b8), + 'XQpVK': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'eAyBz': function(_$iU, _$ij) { + return _$iU !== _$ij; + }, + 'snYYn': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'PHIHO': function(_$iU, _$ij) { + return _$iU !== _$ij; + }, + 'uaZGc': function(_$iU, _$ij) { + return _$iU in _$ij; + }, + 'WkFdb': iw(0x228), + 'fUnSl': iw(0x244), + 'mmdIE': function(_$iU, _$ij) { + return _$iU in _$ij; + }, + 'kdSsH': iw(0x14b), + 'KLKST': iw(0x1dd), + 'EbRri': function(_$iU, _$ij) { + return _$iU || _$ij; + }, + 'OaylU': function(_$iU, _$ij, _$ir, _$ix) { + return _$iU(_$ij, _$ir, _$ix); + }, + 'FNqUU': iw(0x276), + 'dnQje': function(_$iU, _$ij) { + return _$iU === _$ij; + }, + 'iTEZz': iw(0x1c8), + 'pnsrn': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'ENcqk': iw(0x13e), + 'FpYpU': function(_$iU, _$ij) { + return _$iU !== _$ij; + }, + 'VEeFZ': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'Yzehu': function(_$iU, _$ij) { + return _$iU < _$ij; + }, + 'BgwIa': function(_$iU, _$ij) { + return _$iU in _$ij; + }, + 'djjtR': function(_$iU, _$ij) { + return _$iU < _$ij; + }, + 'UZfNg': function(_$iU, _$ij) { + return _$iU === _$ij; + }, + 'mhwAA': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'mxxds': function(_$iU, _$ij, _$ir, _$ix) { + return _$iU(_$ij, _$ir, _$ix); + }, + 'cdOrb': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'PxwSU': function(_$iU, _$ij) { + return _$iU > _$ij; + }, + 'TJkjs': function(_$iU, _$ij) { + return _$iU + _$ij; + }, + 'XsVMf': iw(0x26c), + 'LZbEY': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'XxGnj': iw(0x1fa), + 'oPdbP': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'QMEUG': function(_$iU, _$ij) { + return _$iU === _$ij; + }, + 'WwEuF': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'rMxeX': iw(0x2a1), + 'yeAFb': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'wHcJc': function(_$iU, _$ij) { + return _$iU + _$ij; + }, + 'SElhX': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'VPyGQ': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'AbiJO': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'tUMSG': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'IFGvy': iw(0x171), + 'mfERw': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'uXoWw': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'QdUQR': function(_$iU, _$ij, _$ir, _$ix) { + return _$iU(_$ij, _$ir, _$ix); + }, + 'BSNuj': iw(0x206), + 'sxiUJ': function(_$iU, _$ij) { + return _$iU + _$ij; + }, + 'PsggH': function(_$iU, _$ij) { + return _$iU !== _$ij; + }, + 'YIkXu': function(_$iU, _$ij, _$ir, _$ix) { + return _$iU(_$ij, _$ir, _$ix); + }, + 'SLLuw': iw(0x19b), + 'QjNEg': function(_$iU, _$ij, _$ir, _$ix, _$iX) { + return _$iU(_$ij, _$ir, _$ix, _$iX); + }, + 'eHtKM': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'omKMK': function(_$iU, _$ij) { + return _$iU in _$ij; + }, + 'GKPHr': function(_$iU, _$ij) { + return _$iU !== _$ij; + }, + 'PgndA': function(_$iU, _$ij) { + return _$iU in _$ij; + }, + 'vuBfQ': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'YhRnx': iw(0x249), + 'EXENZ': function(_$iU, _$ij) { + return _$iU === _$ij; + }, + 'RwlqE': function(_$iU, _$ij) { + return _$iU + _$ij; + }, + 'gAJli': iw(0x15a), + 'mEQpG': function(_$iU) { + return _$iU(); + }, + 'sKQTA': function(_$iU, _$ij) { + return _$iU || _$ij; + }, + 'eBnAs': function(_$iU, _$ij, _$ir, _$ix) { + return _$iU(_$ij, _$ir, _$ix); + }, + 'amVMX': iw(0x16b), + 'ODlwn': function(_$iU, _$ij) { + return _$iU === _$ij; + }, + 'QfzIs': function(_$iU, _$ij) { + return _$iU === _$ij; + }, + 'FRqAm': iw(0x1ab), + 'DTkqG': function(_$iU, _$ij, _$ir, _$ix) { + return _$iU(_$ij, _$ir, _$ix); + }, + 'wvahV': function(_$iU, _$ij, _$ir, _$ix) { + return _$iU(_$ij, _$ir, _$ix); + }, + 'nlvrP': function(_$iU, _$ij, _$ir, _$ix) { + return _$iU(_$ij, _$ir, _$ix); + }, + 'hiUNu': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'taeBB': function(_$iU, _$ij) { + return _$iU - _$ij; + }, + 'gsnmE': function(_$iU, _$ij) { + return _$iU >= _$ij; + }, + 'eCEdd': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'iAilt': function(_$iU, _$ij) { + return _$iU > _$ij; + }, + 'OmNwY': function(_$iU, _$ij) { + return _$iU <= _$ij; + }, + 'UXEgM': function(_$iU, _$ij) { + return _$iU + _$ij; + }, + 'cCJsI': iw(0x14c), + 'goGRd': iw(0x1f3), + 'WsUzz': iw(0x1b1), + 'ijcCB': function(_$iU, _$ij) { + return _$iU != _$ij; + }, + 'niKdg': function(_$iU, _$ij) { + return _$iU !== _$ij; + }, + 'kUADD': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'ioNJg': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'YToRP': function(_$iU, _$ij, _$ir, _$ix) { + return _$iU(_$ij, _$ir, _$ix); + }, + 'CybjO': iw(0x1ad), + 'kukKa': function(_$iU, _$ij) { + return _$iU < _$ij; + }, + 'kfKeH': function(_$iU, _$ij) { + return _$iU > _$ij; + }, + 'puqAg': function(_$iU, _$ij) { + return _$iU + _$ij; + }, + 'obOLx': function(_$iU, _$ij) { + return _$iU < _$ij; + }, + 'AfSLZ': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'iyrRt': function(_$iU, _$ij) { + return _$iU < _$ij; + }, + 'WxyCd': function(_$iU, _$ij) { + return _$iU - _$ij; + }, + 'LRfHW': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'uFida': function(_$iU, _$ij) { + return _$iU in _$ij; + }, + 'cokVS': function(_$iU, _$ij) { + return _$iU === _$ij; + }, + 'uCIty': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'XjZGS': function(_$iU, _$ij) { + return _$iU !== _$ij; + }, + 'NLfBt': iw(0x281), + 'jvQyT': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'onOzh': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'qWsOV': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'QWrtu': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'OTuFd': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'zOBhG': iw(0x28a), + 'VWNvp': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'sKZBy': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'STgAm': function(_$iU, _$ij) { + return _$iU === _$ij; + }, + 'tbQbV': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'POStL': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'lVDEr': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'MjizA': iw(0x22c), + 'PdQyN': function(_$iU, _$ij) { + return _$iU || _$ij; + }, + 'VjIbX': function(_$iU, _$ij) { + return _$iU === _$ij; + }, + 'xdPDB': function(_$iU, _$ij, _$ir, _$ix) { + return _$iU(_$ij, _$ir, _$ix); + }, + 'JalOw': function(_$iU, _$ij) { + return _$iU >>> _$ij; + }, + 'vluFt': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'HxMOx': function(_$iU, _$ij) { + return _$iU > _$ij; + }, + 'OuPJY': function(_$iU, _$ij) { + return _$iU - _$ij; + }, + 'LfAHK': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'DbpDX': function(_$iU, _$ij) { + return _$iU + _$ij; + }, + 'HQaKr': function(_$iU, _$ij) { + return _$iU % _$ij; + }, + 'nDruQ': function(_$iU, _$ij) { + return _$iU & _$ij; + }, + 'kKgER': function(_$iU, _$ij) { + return _$iU * _$ij; + }, + 'lANMr': function(_$iU, _$ij) { + return _$iU + _$ij; + }, + 'OFmWx': function(_$iU, _$ij) { + return _$iU + _$ij; + }, + 'IPWVl': iw(0x29d), + 'QJfEw': function(_$iU, _$ij) { + return _$iU - _$ij; + }, + 'uuLud': function(_$iU, _$ij) { + return _$iU * _$ij; + }, + 'clTpr': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'tkxAg': function(_$iU, _$ij) { + return _$iU + _$ij; + }, + 'FnlYr': function(_$iU, _$ij) { + return _$iU >>> _$ij; + }, + 'UbpYh': function(_$iU, _$ij) { + return _$iU << _$ij; + }, + 'XgRJS': function(_$iU, _$ij, _$ir, _$ix, _$iX, _$iT, _$iD, _$iL) { + return _$iU(_$ij, _$ir, _$ix, _$iX, _$iT, _$iD, _$iL); + }, + 'HkmvM': iw(0x27f), + 'KMYkn': function(_$iU, _$ij) { + return _$iU === _$ij; + }, + 'oSeLM': function(_$iU, _$ij) { + return _$iU > _$ij; + }, + 'eEANI': function(_$iU, _$ij) { + return _$iU - _$ij; + }, + 'tOYMk': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'TZyRi': function(_$iU, _$ij) { + return _$iU > _$ij; + }, + 'dvaBu': function(_$iU, _$ij) { + return _$iU - _$ij; + }, + 'nddbp': iw(0x1d9), + 'kKJKw': function(_$iU, _$ij) { + return _$iU == _$ij; + }, + 'VOdbl': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'TpmCA': function(_$iU, _$ij) { + return _$iU != _$ij; + }, + 'hnbAK': function(_$iU) { + return _$iU(); + }, + 'rnTwj': iw(0x21d), + 'gGFQj': function(_$iU, _$ij) { + return _$iU === _$ij; + }, + 'kFlSY': function(_$iU, _$ij) { + return _$iU - _$ij; + }, + 'IZIGW': function(_$iU, _$ij) { + return _$iU != _$ij; + }, + 'jTBXS': function(_$iU, _$ij) { + return _$iU === _$ij; + }, + 'dmhRd': function(_$iU, _$ij) { + return _$iU < _$ij; + }, + 'pYOMS': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'wVpMY': iw(0x20f), + 'aeZHV': iw(0x196), + 'zewkt': function(_$iU, _$ij) { + return _$iU - _$ij; + }, + 'xfaAL': function(_$iU, _$ij) { + return _$iU ^ _$ij; + }, + 'JJJRv': function(_$iU, _$ij) { + return _$iU === _$ij; + }, + 'tsQPR': iw(0x146), + 'IYQQg': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'YieAP': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'bQPsi': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'tgWZt': function(_$iU, _$ij) { + return _$iU == _$ij; + }, + 'aQaYJ': function(_$iU, _$ij) { + return _$iU < _$ij; + }, + 'ASYFC': iw(0x1fc), + 'mEkNi': function(_$iU, _$ij) { + return _$iU | _$ij; + }, + 'VDYHO': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'BYwPC': function(_$iU, _$ij) { + return _$iU - _$ij; + }, + 'XIfaZ': function(_$iU, _$ij) { + return _$iU > _$ij; + }, + 'fOmRI': function(_$iU, _$ij, _$ir, _$ix) { + return _$iU(_$ij, _$ir, _$ix); + }, + 'jBhSa': function(_$iU, _$ij) { + return _$iU + _$ij; + }, + 'bPAdU': function(_$iU, _$ij) { + return _$iU + _$ij; + }, + 'Eqfed': function(_$iU, _$ij) { + return _$iU / _$ij; + }, + 'KwoGN': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'VxxDt': function(_$iU, _$ij) { + return _$iU in _$ij; + }, + 'PjCHi': function(_$iU, _$ij) { + return _$iU in _$ij; + }, + 'ysyWt': iw(0x23c), + 'hSIxG': iw(0x1fe), + 'zjwoQ': iw(0x1a3), + 'LwLBg': iw(0x1ea), + 'BMqjC': function(_$iU, _$ij) { + return _$iU !== _$ij; + }, + 'gzZfG': function(_$iU, _$ij) { + return _$iU === _$ij; + }, + 'AcCIz': function(_$iU, _$ij) { + return _$iU == _$ij; + }, + 'QhTaF': iw(0x169), + 'bJjbF': function(_$iU, _$ij) { + return _$iU || _$ij; + }, + 'buGwE': iw(0x22e), + 'SnInm': function(_$iU, _$ij, _$ir, _$ix, _$iX) { + return _$iU(_$ij, _$ir, _$ix, _$iX); + }, + 'DLiaI': iw(0x213), + 'cfTpH': iw(0x2a3), + 'ZiuYY': function(_$iU, _$ij, _$ir, _$ix, _$iX) { + return _$iU(_$ij, _$ir, _$ix, _$iX); + }, + 'qGIvk': function(_$iU, _$ij, _$ir, _$ix, _$iX) { + return _$iU(_$ij, _$ir, _$ix, _$iX); + }, + 'jFIgH': iw(0x216), + 'XtfQd': iw(0x181), + 'cFRea': iw(0x166), + 'YwEEI': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'uMijt': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'dTTjJ': function(_$iU, _$ij) { + return _$iU + _$ij; + }, + 'mHSnt': function(_$iU, _$ij) { + return _$iU || _$ij; + }, + 'GxBUU': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'VggwI': iw(0x14a), + 'DTPaI': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'SETqY': iw(0x2a9), + 'EFHvF': iw(0x21c), + 'YHGvF': iw(0x1c4), + 'lgSOS': iw(0x282), + 'hqOTn': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'OFUZt': function(_$iU, _$ij) { + return _$iU === _$ij; + }, + 'BNcJf': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'BxlFR': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'GhvWo': iw(0x29c), + 'yyUGr': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'KdXTe': function(_$iU, _$ij) { + return _$iU == _$ij; + }, + 'htCpf': function(_$iU, _$ij) { + return _$iU != _$ij; + }, + 'TULvW': function(_$iU, _$ij) { + return _$iU && _$ij; + }, + 'azRYd': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'gWCXT': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'TsTMJ': iw(0x2b9), + 'FVity': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'kowBd': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'ofciT': iw(0x175), + 'vaqNB': iw(0x192), + 'afCuI': iw(0x25c), + 'saRAY': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'HrstY': iw(0x22a), + 'oWbZd': function(_$iU, _$ij) { + return _$iU < _$ij; + }, + 'iZMHh': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'tqPlB': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'eiJQQ': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'UeZpJ': iw(0x23e), + 'oxtog': iw(0x28c), + 'pnYXU': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'WFOst': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'gfazF': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'dUKBL': iw(0x185), + 'UmWiu': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'VLZej': iw(0x283), + 'dwuJG': function(_$iU, _$ij) { + return _$iU || _$ij; + }, + 'Xouxt': iw(0x1d8), + 'JZCvM': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'fJzlT': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'JbIDx': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'aalCm': iw(0x246), + 'CgUbU': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'yZUmy': iw(0x261), + 'rFWuw': iw(0x15d), + 'SSaPk': iw(0x28e), + 'cHbyx': iw(0x198), + 'MEyoW': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'QElQl': iw(0x21e), + 'HMdEz': function(_$iU, _$ij, _$ir, _$ix) { + return _$iU(_$ij, _$ir, _$ix); + }, + 'Btcby': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'JEbEb': iw(0x1dc), + 'XoQpA': iw(0x1ed), + 'NObDZ': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'vqtYN': iw(0x1a6), + 'bOcOu': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'ezZkr': function(_$iU, _$ij, _$ir, _$ix) { + return _$iU(_$ij, _$ir, _$ix); + }, + 'VGuNs': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'XCIui': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'ZWuyG': function(_$iU, _$ij, _$ir) { + return _$iU(_$ij, _$ir); + }, + 'DCtKd': iw(0x226), + 'ddrwB': iw(0x1e0), + 'KHbjh': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'XRnhu': iw(0x17d), + 'XhuVO': function(_$iU, _$ij) { + return _$iU(_$ij); + }, + 'hgUql': iw(0x223) + }; + var _$F = 'undefined' != typeof globalThis ? globalThis : 'undefined' != typeof window ? window : 'undefined' != typeof global ? global : _$b.IZIGW('undefined', typeof self) ? self : {}; + function _$Q(_$iU) { + var il = iw; + if (_$iU.__esModule) + return _$iU; + var _$ij = Object.defineProperty({}, il(0x1b9), { + 'value': !(0x2632 + -0x65 * 0x49 + -0x965) + }); + return Object.keys(_$iU).forEach(function(_$ir) { + var _$ix = Object.getOwnPropertyDescriptor(_$iU, _$ir); + Object.defineProperty(_$ij, _$ir, _$ix.get ? _$ix : { + 'enumerable': !(0x25 * 0xc + 0x199 * -0x13 + 0x1c9f), + 'get': function() { + return _$iU[_$ir]; + } + }); + }), + _$ij; + } + var _$U = function(_$iU) { + try { + return !!_$iU(); + } catch (_$ij) { + return !(-0x41 + -0x54f * 0x6 + 0x201b * 0x1); + } + } + , _$j = !_$U(function() { + var E0 = iw + , _$iU = function() {} + .bind(); + return 'function' != typeof _$iU || _$iU.hasOwnProperty(E0(0x1d1)); + }) + , _$r = _$j + , _$x = Function.prototype + , _$X = _$x.call + , _$T = _$r && _$x.bind.bind(_$X, _$X) + , _$D = _$r ? _$T : function(_$iU) { + return function() { + return _$X.apply(_$iU, arguments); + } + ; + } + , _$L = _$D({}.isPrototypeOf) + , _$W = function(_$iU) { + return _$iU && _$iU.Math === Math && _$iU; + } + , _$V = _$W(iw(0x146) == typeof globalThis && globalThis) || _$W(iw(0x146) == typeof window && window) || _$b.yyUGr(_$W, _$b.tsQPR == typeof self && self) || _$W(iw(0x146) == typeof _$F && _$F) || _$b.mhwAA(_$W, iw(0x146) == typeof _$F && _$F) || function() { + return this; + }() || Function(iw(0x234))() + , _$H = _$j + , _$u = Function.prototype + , _$M = _$u.apply + , _$c = _$u.call + , _$O = iw(0x146) == typeof Reflect && Reflect.apply || (_$H ? _$c.bind(_$M) : function() { + return _$c.apply(_$M, arguments); + } + ) + , _$i = _$D + , _$E = _$i({}.toString) + , _$m = _$b.kfazF(_$i, ''.slice) + , _$N = function(_$iU) { + return _$b.sGIJL(_$m, _$b.kfazF(_$E, _$iU), 0xe3 * 0x24 + -0x23e0 + 0x3fc, -(-0x166 * 0x9 + -0x145c + -0x23 * -0xf1)); + } + , _$P = _$N + , _$K = _$D + , _$B = function(_$iU) { + var E1 = iw; + if (_$b.mgsSr(E1(0x243), _$P(_$iU))) + return _$K(_$iU); + } + , _$d = _$b.KdXTe(iw(0x146), typeof document) && document.all + , _$h = void (0x175b + 0x1a23 + 0x16a * -0x23) === _$d && void (0x228a * 0x1 + -0x7ec + 0x1 * -0x1a9e) !== _$d ? function(_$iU) { + return _$b.HCXWR('function', typeof _$iU) || _$iU === _$d; + } + : function(_$iU) { + return _$b.sEUah == typeof _$iU; + } + , _$p = {} + , _$z = !_$U(function() { + return -0x27 * 0x49 + -0x1c1f + 0x2745 !== Object.defineProperty({}, 0x463 * -0x3 + 0x151 * 0x18 + -0x7 * 0x2a2, { + 'get': function() { + return -0xa * 0x3e6 + 0x1 * -0x21 + 0x2724 * 0x1; + } + })[-0x2c * -0xc1 + 0xa2 + -0x21cd * 0x1]; + }) + , _$n = _$j + , _$k = Function.prototype.call + , _$Z = _$n ? _$k.bind(_$k) : function() { + return _$k.apply(_$k, arguments); + } + , _$A = {} + , _$e = {}.propertyIsEnumerable + , _$s = Object.getOwnPropertyDescriptor + , _$a = _$s && !_$e.call({ + 0x1: 0x2 + }, -0x1 * 0x1dd3 + 0x1e6 * -0x3 + -0x1 * -0x2386); + _$A.f = _$a ? function(_$iU) { + var _$ij = _$s(this, _$iU); + return !!_$ij && _$ij.enumerable; + } + : _$e; + var _$g, _$q, _$J = function(_$iU, _$ij) { + return { + 'enumerable': !(-0x235e + 0x2b * -0x22 + 0x2915 & _$iU), + 'configurable': !(-0xffd + -0x1 * -0x22ed + -0x2 * 0x977 & _$iU), + 'writable': !(0xb69 + 0x1f95 + 0x2 * -0x157d & _$iU), + 'value': _$ij + }; + }, _$C = _$U, _$R = _$N, _$o = Object, _$y = _$D(''.split), _$Y = _$C(function() { + return !_$o('z').propertyIsEnumerable(0xa69 + 0x11d4 + -0x1c3d); + }) ? function(_$iU) { + var E2 = iw; + return E2(0x28e) === _$R(_$iU) ? _$y(_$iU, '') : _$o(_$iU); + } + : _$o, _$S = function(_$iU) { + return null == _$iU; + }, _$G = _$S, _$f = TypeError, _$v = function(_$iU) { + var E3 = iw; + if (_$G(_$iU)) + throw new _$f(_$b.HuRFF(E3(0x19c), _$iU)); + return _$iU; + }, _$t = _$Y, _$I = _$v, _$w = function(_$iU) { + return _$b.DjSUN(_$t, _$b.GXpfj(_$I, _$iU)); + }, _$l = _$h, _$b0 = function(_$iU) { + var E4 = iw; + return E4(0x146) == typeof _$iU ? null !== _$iU : _$l(_$iU); + }, _$b1 = {}, _$b2 = _$b1, _$b3 = _$V, _$b4 = _$h, _$b5 = function(_$iU) { + return _$b4(_$iU) ? _$iU : void (0xb53 + 0x449 + -0xf9c); + }, _$b6 = function(_$iU, _$ij) { + return arguments.length < 0x10fa + -0x1ef8 * 0x1 + 0x200 * 0x7 ? _$b5(_$b2[_$iU]) || _$b.fWLCv(_$b5, _$b3[_$iU]) : _$b2[_$iU] && _$b2[_$iU][_$ij] || _$b3[_$iU] && _$b3[_$iU][_$ij]; + }, _$b7 = _$b.htCpf('undefined', typeof navigator) && String(navigator.userAgent) || '', _$b8 = _$V, _$b9 = _$b7, _$bb = _$b8.process, _$bF = _$b8.Deno, _$bQ = _$bb && _$bb.versions || _$bF && _$bF.version, _$bU = _$bQ && _$bQ.v8; + _$bU && (_$q = (_$g = _$bU.split('.'))[-0x282 + 0x1bec + -0x196a] > 0x1 * 0x23b + 0x17bf + -0xcfd * 0x2 && _$g[-0x1de * 0x1 + -0x1b * 0x75 + 0xe35] < 0xc12 + 0x1b69 + -0x2777 * 0x1 ? -0xf68 + -0x1a1 * -0x11 + -0x1 * 0xc48 : +(_$g[-0x1c03 + 0x86e + 0x1 * 0x1395] + _$g[0xd * 0x23c + -0x81d + -0x14ee])), + _$b.TULvW(!_$q, _$b9) && (!(_$g = _$b9.match(/Edge\/(\d+)/)) || _$g[0x1067 + -0x2bb * -0x5 + -0x1e0d] >= 0x1 * 0x1f0c + 0x11 * -0x167 + -0x4d * 0x17) && (_$g = _$b9.match(/Chrome\/(\d+)/)) && (_$q = +_$g[-0xb03 * 0x2 + -0x1 * -0x22a + 0x2d * 0x71]); + var _$bj = _$q + , _$br = _$bj + , _$bx = _$U + , _$bX = _$V.String + , _$bT = !!Object.getOwnPropertySymbols && !_$b.pnsrn(_$bx, function() { + var E5 = iw + , _$iU = Symbol(E5(0x224)); + return !_$bX(_$iU) || !_$b.CpxAO(Object(_$iU), Symbol) || !Symbol.sham && _$br && _$br < -0x5 * 0x2de + 0x269c + -0x181d; + }) + , _$bD = _$bT && !Symbol.sham && iw(0x1d6) == typeof Symbol.iterator + , _$bL = _$b6 + , _$bW = _$h + , _$bV = _$L + , _$bH = Object + , _$bu = _$bD ? function(_$iU) { + return _$b.Ddguo == typeof _$iU; + } + : function(_$iU) { + var E6 = iw + , _$ij = _$bL(E6(0x198)); + return _$bW(_$ij) && _$bV(_$ij.prototype, _$bH(_$iU)); + } + , _$bM = String + , _$bc = function(_$iU) { + var E7 = iw; + try { + return _$bM(_$iU); + } catch (_$ij) { + return E7(0x15f); + } + } + , _$bO = _$h + , _$bi = _$bc + , _$bE = TypeError + , _$bm = function(_$iU) { + var E8 = iw; + if (_$b.DjSUN(_$bO, _$iU)) + return _$iU; + throw new _$bE(_$bi(_$iU) + E8(0x186)); + } + , _$bN = _$bm + , _$bP = _$S + , _$bK = function(_$iU, _$ij) { + var _$ir = _$iU[_$ij]; + return _$bP(_$ir) ? void (-0x38 + 0x1 * -0x815 + 0x84d) : _$b.QefQe(_$bN, _$ir); + } + , _$bB = _$Z + , _$bd = _$h + , _$bh = _$b0 + , _$bp = TypeError + , _$bz = { + 'exports': {} + } + , _$bn = _$V + , _$bk = Object.defineProperty + , _$bZ = _$V + , _$bA = function(_$iU, _$ij) { + try { + _$bk(_$bn, _$iU, { + 'value': _$ij, + 'configurable': !(-0x2 * -0xd15 + 0x10f1 * 0x1 + 0x2b1b * -0x1), + 'writable': !(-0x1cc3 + 0xa3e + 0x1 * 0x1285) + }); + } catch (_$ir) { + _$bn[_$iU] = _$ij; + } + return _$ij; + } + , _$be = iw(0x143) + , _$bs = _$bz.exports = _$bZ[_$be] || _$bA(_$be, {}); + (_$bs.versions || (_$bs.versions = [])).push({ + 'version': iw(0x207), + 'mode': iw(0x191), + 'copyright': iw(0x161), + 'license': iw(0x1df), + 'source': iw(0x25a) + }); + var _$ba = _$bz.exports + , _$bg = function(_$iU, _$ij) { + return _$ba[_$iU] || (_$ba[_$iU] = _$ij || {}); + } + , _$bq = _$v + , _$bJ = Object + , _$bC = function(_$iU) { + return _$bJ(_$b.ysuyO(_$bq, _$iU)); + } + , _$bR = _$bC + , _$bo = _$D({}.hasOwnProperty) + , _$by = Object.hasOwn || function(_$iU, _$ij) { + return _$bo(_$bR(_$iU), _$ij); + } + , _$bY = _$D + , _$bS = -0xe9a + -0x16ee + 0x2588 + , _$bG = Math.random() + , _$bf = _$bY((-0xb * -0x11 + -0x1b06 + -0x6 * -0x462).toString) + , _$bv = function(_$iU) { + return _$b.IvKto(_$b.ePpDW, void (-0x1f1a + -0x43 * -0x39 + 0x102f * 0x1) === _$iU ? '' : _$iU) + ')_' + _$b.wQxZP(_$bf, ++_$bS + _$bG, -0x82c + 0xa8a + -0x23a); + } + , _$bt = _$bg + , _$bI = _$by + , _$bw = _$bv + , _$bl = _$bT + , _$F0 = _$bD + , _$F1 = _$V.Symbol + , _$F2 = _$bt(iw(0x225)) + , _$F3 = _$F0 ? _$F1.for || _$F1 : _$F1 && _$F1.withoutSetter || _$bw + , _$F4 = function(_$iU) { + return _$b.kaUuY(_$bI, _$F2, _$iU) || (_$F2[_$iU] = _$bl && _$bI(_$F1, _$iU) ? _$F1[_$iU] : _$F3(_$b.PbGVC + _$iU)), + _$F2[_$iU]; + } + , _$F5 = _$Z + , _$F6 = _$b0 + , _$F7 = _$bu + , _$F8 = _$bK + , _$F9 = function(_$iU, _$ij) { + var E9 = iw, _$ir, _$ix; + if (E9(0x182) === _$ij && _$bd(_$ir = _$iU.toString) && !_$bh(_$ix = _$b.guaEG(_$bB, _$ir, _$iU))) + return _$ix; + if (_$bd(_$ir = _$iU.valueOf) && !_$bh(_$ix = _$bB(_$ir, _$iU))) + return _$ix; + if (_$b.OYYwc !== _$ij && _$bd(_$ir = _$iU.toString) && !_$b.kfazF(_$bh, _$ix = _$bB(_$ir, _$iU))) + return _$ix; + throw new _$bp(_$b.YwIVl); + } + , _$Fb = TypeError + , _$FF = _$b.azRYd(_$F4, iw(0x1a6)) + , _$FQ = function(_$iU, _$ij) { + var Eb = iw; + if (!_$b.XQpVK(_$F6, _$iU) || _$b.DjSUN(_$F7, _$iU)) + return _$iU; + var _$ir, _$ix = _$F8(_$iU, _$FF); + if (_$ix) { + if (void (-0x2af + 0x64 * -0x10 + 0x8ef * 0x1) === _$ij && (_$ij = Eb(0x19f)), + _$ir = _$F5(_$ix, _$iU, _$ij), + !_$F6(_$ir) || _$b.QefQe(_$F7, _$ir)) + return _$ir; + throw new _$Fb(Eb(0x1b8)); + } + return void (-0x2011 + -0x382 + 0x2393) === _$ij && (_$ij = Eb(0x1f3)), + _$F9(_$iU, _$ij); + } + , _$FU = _$FQ + , _$Fj = _$bu + , _$Fr = function(_$iU) { + var EF = iw + , _$ij = _$FU(_$iU, EF(0x182)); + return _$Fj(_$ij) ? _$ij : _$ij + ''; + } + , _$Fx = _$b0 + , _$FX = _$V.document + , _$FT = _$Fx(_$FX) && _$Fx(_$FX.createElement) + , _$FD = function(_$iU) { + return _$FT ? _$FX.createElement(_$iU) : {}; + } + , _$FL = _$FD + , _$FW = !_$z && !_$U(function() { + var EQ = iw; + return _$b.eAyBz(-0xb6e + 0x260d + -0x1a98, Object.defineProperty(_$FL(EQ(0x222)), 'a', { + 'get': function() { + return 0x1b99 * -0x1 + -0x15ac + -0x314c * -0x1; + } + }).a); + }) + , _$FV = _$z + , _$FH = _$Z + , _$Fu = _$A + , _$FM = _$J + , _$Fc = _$w + , _$FO = _$Fr + , _$Fi = _$by + , _$FE = _$FW + , _$Fm = Object.getOwnPropertyDescriptor; + _$p.f = _$FV ? _$Fm : function(_$iU, _$ij) { + if (_$iU = _$b.QefQe(_$Fc, _$iU), + _$ij = _$FO(_$ij), + _$FE) + try { + return _$Fm(_$iU, _$ij); + } catch (_$ir) {} + if (_$Fi(_$iU, _$ij)) + return _$FM(!_$FH(_$Fu.f, _$iU, _$ij), _$iU[_$ij]); + } + ; + var _$FN = _$U + , _$FP = _$h + , _$FK = /#|\.prototype\./ + , _$FB = function(_$iU, _$ij) { + var _$ir = _$Fh[_$Fd(_$iU)]; + return _$ir === _$Fz || _$ir !== _$Fp && (_$b.snYYn(_$FP, _$ij) ? _$FN(_$ij) : !!_$ij); + } + , _$Fd = _$FB.normalize = function(_$iU) { + return String(_$iU).replace(_$FK, '.').toLowerCase(); + } + , _$Fh = _$FB.data = {} + , _$Fp = _$FB.NATIVE = 'N' + , _$Fz = _$FB.POLYFILL = 'P' + , _$Fn = _$FB + , _$Fk = _$bm + , _$FZ = _$j + , _$FA = _$b.gWCXT(_$B, _$B.bind) + , _$Fe = function(_$iU, _$ij) { + return _$Fk(_$iU), + _$b.mgsSr(void (-0xa * 0x1bc + 0x21fb + -0x10a3), _$ij) ? _$iU : _$FZ ? _$FA(_$iU, _$ij) : function() { + return _$iU.apply(_$ij, arguments); + } + ; + } + , _$Fs = {} + , _$Fa = _$z && _$U(function() { + var EU = iw; + return _$b.PHIHO(0x3 * 0x8f1 + 0x1760 + -0x1 * 0x3209, Object.defineProperty(function() {}, EU(0x1d1), { + 'value': 0x2a, + 'writable': !(0x1 * 0x2527 + -0x1723 + -0xe03 * 0x1) + }).prototype); + }) + , _$Fg = _$b0 + , _$Fq = String + , _$FJ = TypeError + , _$FC = function(_$iU) { + var Ej = iw; + if (_$b.DjSUN(_$Fg, _$iU)) + return _$iU; + throw new _$FJ(_$Fq(_$iU) + Ej(0x14f)); + } + , _$FR = _$z + , _$Fo = _$FW + , _$Fy = _$Fa + , _$FY = _$FC + , _$FS = _$Fr + , _$FG = TypeError + , _$Ff = Object.defineProperty + , _$Fv = Object.getOwnPropertyDescriptor + , _$Ft = iw(0x2a0) + , _$FI = iw(0x18b) + , _$Fw = iw(0x17c); + _$Fs.f = _$FR ? _$Fy ? function(_$iU, _$ij, _$ir) { + var Er = iw; + if (_$FY(_$iU), + _$ij = _$FS(_$ij), + _$b.kfazF(_$FY, _$ir), + _$b.HCXWR('function', typeof _$iU) && Er(0x1d1) === _$ij && _$b.uaZGc(_$b.WkFdb, _$ir) && _$Fw in _$ir && !_$ir[_$Fw]) { + var _$ix = _$b.guaEG(_$Fv, _$iU, _$ij); + _$ix && _$ix[_$Fw] && (_$iU[_$ij] = _$ir.value, + _$ir = { + 'configurable': _$FI in _$ir ? _$ir[_$FI] : _$ix[_$FI], + 'enumerable': _$Ft in _$ir ? _$ir[_$Ft] : _$ix[_$Ft], + 'writable': !(0x506 + 0x9e7 + -0xbf * 0x14) + }); + } + return _$Ff(_$iU, _$ij, _$ir); + } + : _$Ff : function(_$iU, _$ij, _$ir) { + var Ex = iw; + if (_$FY(_$iU), + _$ij = _$FS(_$ij), + _$FY(_$ir), + _$Fo) + try { + return _$Ff(_$iU, _$ij, _$ir); + } catch (_$ix) {} + if (_$b.fUnSl in _$ir || _$b.mmdIE(_$b.kdSsH, _$ir)) + throw new _$FG(_$b.KLKST); + return Ex(0x228)in _$ir && (_$iU[_$ij] = _$ir.value), + _$iU; + } + ; + var _$Fl = _$Fs + , _$Q0 = _$J + , _$Q1 = _$z ? function(_$iU, _$ij, _$ir) { + return _$Fl.f(_$iU, _$ij, _$Q0(-0x1 * -0x791 + -0x20e * -0x9 + -0x1a0e, _$ir)); + } + : function(_$iU, _$ij, _$ir) { + return _$iU[_$ij] = _$ir, + _$iU; + } + , _$Q2 = _$V + , _$Q3 = _$O + , _$Q4 = _$B + , _$Q5 = _$h + , _$Q6 = _$p.f + , _$Q7 = _$Fn + , _$Q8 = _$b1 + , _$Q9 = _$Fe + , _$Qb = _$Q1 + , _$QF = _$by + , _$QQ = function(_$iU) { + var _$ij = function(_$ir, _$ix, _$iX) { + if (this instanceof _$ij) { + switch (arguments.length) { + case -0x11d6 + -0x2095 + 0x326b: + return new _$iU(); + case -0x1 * -0x1399 + 0xe72 + -0x220a: + return new _$iU(_$ir); + case -0x27 * 0xda + -0x1 * 0x17b3 + 0x38eb: + return new _$iU(_$ir,_$ix); + } + return new _$iU(_$ir,_$ix,_$iX); + } + return _$Q3(_$iU, this, arguments); + }; + return _$ij.prototype = _$iU.prototype, + _$ij; + } + , _$QU = function(_$iU, _$ij) { + var EX = iw, _$ir, _$ix, _$iX, _$iT, _$iD, _$iL, _$iW, _$iV, _$iH, _$iu = _$iU.target, _$iM = _$iU.global, _$ic = _$iU.stat, _$iO = _$iU.proto, _$ii = _$iM ? _$Q2 : _$ic ? _$Q2[_$iu] : _$Q2[_$iu] && _$Q2[_$iu].prototype, _$iE = _$iM ? _$Q8 : _$Q8[_$iu] || _$Qb(_$Q8, _$iu, {})[_$iu], _$im = _$iE.prototype; + for (_$iT in _$ij) + _$ix = !(_$ir = _$Q7(_$iM ? _$iT : _$iu + (_$ic ? '.' : '#') + _$iT, _$iU.forced)) && _$ii && _$QF(_$ii, _$iT), + _$iL = _$iE[_$iT], + _$ix && (_$iW = _$iU.dontCallGetSet ? (_$iH = _$Q6(_$ii, _$iT)) && _$iH.value : _$ii[_$iT]), + _$iD = _$ix && _$iW ? _$iW : _$ij[_$iT], + (_$b.EbRri(_$ir, _$iO) || typeof _$iL != typeof _$iD) && (_$iV = _$iU.bind && _$ix ? _$Q9(_$iD, _$Q2) : _$iU.wrap && _$ix ? _$QQ(_$iD) : _$iO && _$Q5(_$iD) ? _$Q4(_$iD) : _$iD, + (_$iU.sham || _$iD && _$iD.sham || _$iL && _$iL.sham) && _$b.OaylU(_$Qb, _$iV, EX(0x265), !(-0x99b + 0xa5b + -0xc0)), + _$Qb(_$iE, _$iT, _$iV), + _$iO && (_$QF(_$Q8, _$iX = _$iu + EX(0x220)) || _$Qb(_$Q8, _$iX, {}), + _$b.OaylU(_$Qb, _$Q8[_$iX], _$iT, _$iD), + _$iU.real && _$im && (_$ir || !_$im[_$iT]) && _$Qb(_$im, _$iT, _$iD))); + } + , _$Qj = _$N + , _$Qr = Array.isArray || function(_$iU) { + var ET = iw; + return ET(0x25c) === _$b.DjSUN(_$Qj, _$iU); + } + , _$Qx = Math.ceil + , _$QX = Math.floor + , _$QT = Math.trunc || function(_$iU) { + var _$ij = +_$iU; + return (_$ij > -0x1c13 + -0x7e6 + 0x23f9 ? _$QX : _$Qx)(_$ij); + } + , _$QD = function(_$iU) { + var _$ij = +_$iU; + return _$ij != _$ij || 0x23bb + -0x1990 + -0xa2b === _$ij ? -0x5 * -0x6fb + -0x140 * -0xa + -0x3 * 0xfcd : _$QT(_$ij); + } + , _$QL = _$QD + , _$QW = Math.min + , _$QV = function(_$iU) { + var _$ij = _$QL(_$iU); + return _$ij > 0x2 * 0xceb + -0x19b2 + -0x4 * 0x9 ? _$QW(_$ij, 0x173409 * -0xdcf765c7 + 0x9868a79000001 + -0x151 * -0x7fa6100309e + 0x18d9 * 0x149b0651897) : 0x1a9b + 0x18 + -0x1ab3; + } + , _$QH = _$QV + , _$Qu = function(_$iU) { + return _$QH(_$iU.length); + } + , _$QM = TypeError + , _$Qc = function(_$iU) { + var ED = iw; + if (_$iU > -0x163e7a5d7fffff + 0x16312321bfffff + -0x19b0000 * -0x84f40 + 0x1fffffffffffff) + throw _$QM(ED(0x1fd)); + return _$iU; + } + , _$QO = _$z + , _$Qi = _$Fs + , _$QE = _$J + , _$Qm = function(_$iU, _$ij, _$ir) { + _$QO ? _$Qi.f(_$iU, _$ij, _$QE(-0x2531 * 0x1 + -0x12fd * 0x1 + 0x382e, _$ir)) : _$iU[_$ij] = _$ir; + } + , _$QN = {}; + _$QN[_$F4(_$b.TsTMJ)] = 'z'; + var _$QP = iw(0x1c5) === String(_$QN) + , _$QK = _$QP + , _$QB = _$h + , _$Qd = _$N + , _$Qh = _$F4(iw(0x2b9)) + , _$Qp = Object + , _$Qz = _$b.iTEZz === _$Qd(function() { + return arguments; + }()) + , _$Qn = _$QK ? _$Qd : function(_$iU) { + var EL = iw, _$ij, _$ir, _$ix; + return void (-0x1c81 + 0x2 * -0xdb4 + -0x1 * -0x37e9) === _$iU ? 'Undefined' : null === _$iU ? _$b.FNqUU : EL(0x182) == typeof (_$ir = function(_$iX, _$iT) { + try { + return _$iX[_$iT]; + } catch (_$iD) {} + }(_$ij = _$Qp(_$iU), _$Qh)) ? _$ir : _$Qz ? _$Qd(_$ij) : _$b.dnQje(EL(0x15f), _$ix = _$Qd(_$ij)) && _$QB(_$ij.callee) ? _$b.iTEZz : _$ix; + } + , _$Qk = _$D + , _$QZ = _$h + , _$QA = _$bz.exports + , _$Qe = _$Qk(Function.toString); + _$b.mfERw(_$QZ, _$QA.inspectSource) || (_$QA.inspectSource = function(_$iU) { + return _$b.GXpfj(_$Qe, _$iU); + } + ); + var _$Qs = _$QA.inspectSource + , _$Qa = _$D + , _$Qg = _$U + , _$Qq = _$h + , _$QJ = _$Qn + , _$QC = _$Qs + , _$QR = function() {} + , _$Qo = _$b.FVity(_$b6, iw(0x18c), iw(0x248)) + , _$Qy = /^\s*(?:class|function)\b/ + , _$QY = _$b.kowBd(_$Qa, _$Qy.exec) + , _$QS = !_$Qy.test(_$QR) + , _$QG = function(_$iU) { + if (!_$Qq(_$iU)) + return !(-0x2190 + 0x16c9 + 0xac8); + try { + return _$b.OaylU(_$Qo, _$QR, [], _$iU), + !(0x1556 + -0x59 * -0x47 + -0x2e05); + } catch (_$ij) { + return !(0x1933 + -0x5 * 0x291 + -0xc5d); + } + } + , _$Qf = function(_$iU) { + var EW = iw; + if (!_$b.pnsrn(_$Qq, _$iU)) + return !(-0x996 + 0x17c6 + -0xe2f); + switch (_$b.QefQe(_$QJ, _$iU)) { + case EW(0x242): + case _$b.ENcqk: + case EW(0x2a2): + return !(0x3 * 0x60d + -0x1334 + -0x1 * -0x10e); + } + try { + return _$QS || !!_$b.kaUuY(_$QY, _$Qy, _$QC(_$iU)); + } catch (_$ij) { + return !(0x41e * 0x8 + 0x1 * -0x105d + 0x1093 * -0x1); + } + }; + _$Qf.sham = !(0x143d + 0x2138 + -0x11 * 0x325); + var _$Qv = !_$Qo || _$Qg(function() { + var _$iU; + return _$b.DjSUN(_$QG, _$QG.call) || !_$QG(Object) || !_$QG(function() { + _$iU = !(0x9b7 * 0x1 + -0x1db2 + 0x1d1 * 0xb); + }) || _$iU; + }) ? _$Qf : _$QG + , _$Qt = _$Qr + , _$QI = _$Qv + , _$Qw = _$b0 + , _$Ql = _$F4(_$b.ofciT) + , _$U0 = Array + , _$U1 = function(_$iU) { + var _$ij; + return _$Qt(_$iU) && (_$ij = _$iU.constructor, + (_$QI(_$ij) && (_$ij === _$U0 || _$Qt(_$ij.prototype)) || _$Qw(_$ij) && null === (_$ij = _$ij[_$Ql])) && (_$ij = void (-0x34a * -0x5 + 0x1029 + -0x209b))), + void (-0xb * 0x22 + 0x1 * 0x1c53 + -0x1add) === _$ij ? _$U0 : _$ij; + } + , _$U2 = function(_$iU, _$ij) { + return new (_$b.DjSUN(_$U1, _$iU))(-0xc44 + 0x1d7d * 0x1 + -0x1139 === _$ij ? -0xaa6 + 0x11c4 + 0x1 * -0x71e : _$ij); + } + , _$U3 = _$U + , _$U4 = _$bj + , _$U5 = _$F4(iw(0x175)) + , _$U6 = function(_$iU) { + return _$U4 >= -0x1 * 0x3ce + -0x19 * -0x187 + -0x222e || !_$U3(function() { + var _$ij = []; + return (_$ij.constructor = {})[_$U5] = function() { + return { + 'foo': 0x1 + }; + } + , + 0x1fe + -0xe33 + 0x412 * 0x3 !== _$ij[_$iU](Boolean).foo; + }); + } + , _$U7 = _$QU + , _$U8 = _$U + , _$U9 = _$Qr + , _$Ub = _$b0 + , _$UF = _$bC + , _$UQ = _$Qu + , _$UU = _$Qc + , _$Uj = _$Qm + , _$Ur = _$U2 + , _$Ux = _$U6 + , _$UX = _$bj + , _$UT = _$b.IYQQg(_$F4, _$b.vaqNB) + , _$UD = _$UX >= -0xd3f * 0x1 + 0x3 * 0xcc1 + 0x18d1 * -0x1 || !_$U8(function() { + var _$iU = []; + return _$iU[_$UT] = !(-0x3a0 * -0x8 + -0x9ec * 0x1 + -0x1313), + _$b.PHIHO(_$iU.concat()[0x2 * -0x47b + 0x2 * -0xe84 + 0x25fe], _$iU); + }) + , _$UL = function(_$iU) { + if (!_$Ub(_$iU)) + return !(0x1 * 0x15d + -0x42 + -0x11a); + var _$ij = _$iU[_$UT]; + return _$b.FpYpU(void (-0x5a2 + 0x2 * 0x29d + 0x8 * 0xd), _$ij) ? !!_$ij : _$b.VEeFZ(_$U9, _$iU); + }; + _$U7({ + 'target': _$b.afCuI, + 'proto': !(0xc19 * 0x2 + 0x2606 + -0x3e38), + 'arity': 0x1, + 'forced': !_$UD || !_$Ux(iw(0x259)) + }, { + 'concat': function(_$iU) { + var _$ij, _$ir, _$ix, _$iX, _$iT, _$iD = _$b.pnsrn(_$UF, this), _$iL = _$Ur(_$iD, 0xcf9 + 0x73a * -0x1 + -0x1 * 0x5bf), _$iW = -0x1514 + -0x1 * -0x161 + 0x13b3; + for (_$ij = -(-0x832 + 0x13 * 0xea + -0x92b), + _$ix = arguments.length; _$b.Yzehu(_$ij, _$ix); _$ij++) + if (_$UL(_$iT = -(0x11 * -0x63 + -0x40 * -0x7f + -0x192c) === _$ij ? _$iD : arguments[_$ij])) { + for (_$iX = _$UQ(_$iT), + _$UU(_$iW + _$iX), + _$ir = -0x1e99 * -0x1 + -0x13ea + -0xaaf; _$ir < _$iX; _$ir++, + _$iW++) + _$b.BgwIa(_$ir, _$iT) && _$b.OaylU(_$Uj, _$iL, _$iW, _$iT[_$ir]); + } else + _$UU(_$iW + (0x15e3 + -0xdd5 * -0x2 + 0x12e * -0x2a)), + _$Uj(_$iL, _$iW++, _$iT); + return _$iL.length = _$iW, + _$iL; + } + }); + var _$UW = _$V + , _$UV = _$b1 + , _$UH = function(_$iU, _$ij) { + var EV = iw + , _$ir = _$UV[_$iU + EV(0x220)] + , _$ix = _$ir && _$ir[_$ij]; + if (_$ix) + return _$ix; + var _$iX = _$UW[_$iU] + , _$iT = _$iX && _$iX.prototype; + return _$iT && _$iT[_$ij]; + } + , _$Uu = _$UH(iw(0x25c), iw(0x259)) + , _$UM = _$L + , _$Uc = _$Uu + , _$UO = Array.prototype + , _$Ui = function(_$iU) { + var _$ij = _$iU.concat; + return _$iU === _$UO || _$UM(_$UO, _$iU) && _$ij === _$UO.concat ? _$Uc : _$ij; + } + , _$UE = _$QD + , _$Um = Math.max + , _$UN = Math.min + , _$UP = function(_$iU, _$ij) { + var _$ir = _$UE(_$iU); + return _$b.djjtR(_$ir, 0x3ff * -0x1 + -0x2518 + 0x43 * 0x9d) ? _$b.kaUuY(_$Um, _$ir + _$ij, 0x1195 + -0x859 + -0x4 * 0x24f) : _$UN(_$ir, _$ij); + } + , _$UK = _$b.saRAY(_$D, [].slice) + , _$UB = _$QU + , _$Ud = _$Qr + , _$Uh = _$Qv + , _$Up = _$b0 + , _$Uz = _$UP + , _$Un = _$Qu + , _$Uk = _$w + , _$UZ = _$Qm + , _$UA = _$F4 + , _$Ue = _$UK + , _$Us = _$U6(_$b.HrstY) + , _$Ua = _$UA(iw(0x175)) + , _$Ug = Array + , _$Uq = Math.max; + _$UB({ + 'target': iw(0x25c), + 'proto': !(-0x1fd7 + 0x2451 + -0x17e * 0x3), + 'forced': !_$Us + }, { + 'slice': function(_$iU, _$ij) { + var _$ir, _$ix, _$iX, _$iT = _$Uk(this), _$iD = _$Un(_$iT), _$iL = _$Uz(_$iU, _$iD), _$iW = _$Uz(_$b.UZfNg(void (-0x97c + -0x1 * 0x15df + -0x17 * -0x15d), _$ij) ? _$iD : _$ij, _$iD); + if (_$b.mhwAA(_$Ud, _$iT) && (_$ir = _$iT.constructor, + (_$Uh(_$ir) && (_$ir === _$Ug || _$Ud(_$ir.prototype)) || _$Up(_$ir) && null === (_$ir = _$ir[_$Ua])) && (_$ir = void (0x20b1 * 0x1 + -0x144 * -0x5 + -0x2705 * 0x1)), + _$ir === _$Ug || void (0x9 * 0x24d + 0x1f59 + 0x6 * -0x8ad) === _$ir)) + return _$Ue(_$iT, _$iL, _$iW); + for (_$ix = new ((_$b.mgsSr(void (-0x7 * 0x133 + -0x180d + 0x2072), _$ir)) ? _$Ug : _$ir)(_$Uq(_$iW - _$iL, -0x9a6 + 0x2261 + -0x18bb)), + _$iX = 0x576 + 0x1 * -0x149d + -0x3 * -0x50d; _$iL < _$iW; _$iL++, + _$iX++) + _$iL in _$iT && _$UZ(_$ix, _$iX, _$iT[_$iL]); + return _$ix.length = _$iX, + _$ix; + } + }); + var _$UJ = _$UH(iw(0x25c), _$b.HrstY) + , _$UC = _$L + , _$UR = _$UJ + , _$Uo = Array.prototype + , _$Uy = function(_$iU) { + var _$ij = _$iU.slice; + return _$iU === _$Uo || _$UC(_$Uo, _$iU) && _$ij === _$Uo.slice ? _$UR : _$ij; + } + , _$UY = _$w + , _$US = _$UP + , _$UG = _$Qu + , _$Uf = function(_$iU) { + var _$ij = { + 'EjdxI': function(_$ir, _$ix) { + return _$ir(_$ix); + }, + 'rPCCi': function(_$ir, _$ix) { + return _$ir != _$ix; + } + }; + return function(_$ir, _$ix, _$iX) { + var _$iT = _$UY(_$ir) + , _$iD = _$ij.EjdxI(_$UG, _$iT); + if (-0x20ec + -0x1 * -0x136d + 0xd7f === _$iD) + return !_$iU && -(-0x1513 + -0x8aa + 0x1dbe); + var _$iL, _$iW = _$US(_$iX, _$iD); + if (_$iU && _$ix != _$ix) { + for (; _$iD > _$iW; ) + if (_$ij.rPCCi(_$iL = _$iT[_$iW++], _$iL)) + return !(-0x222f + 0xe * -0x12e + 0x32b3); + } else { + for (; _$iD > _$iW; _$iW++) + if ((_$iU || _$iW in _$iT) && _$iT[_$iW] === _$ix) + return _$iU || _$iW || 0x246d * 0x1 + -0x1817 + -0xc56; + } + return !_$iU && -(0x107b + -0x152d * -0x1 + 0x51 * -0x77); + } + ; + } + , _$Uv = { + 'includes': _$Uf(!(-0x1a3 * -0x1 + 0x8 * 0x67 + 0x1 * -0x4db)), + 'indexOf': _$Uf(!(-0x22c6 + 0x676 + -0x293 * -0xb)) + } + , _$Ut = _$U + , _$UI = function(_$iU, _$ij) { + var _$ir = [][_$iU]; + return !!_$ir && _$Ut(function() { + _$ir.call(null, _$ij || function() { + return 0x11d3 * -0x1 + -0x3c3 + 0x1 * 0x1597; + } + , -0x1c32 + -0x1 * 0x1523 + 0x6 * 0x839); + }); + } + , _$Uw = _$QU + , _$Ul = _$Uv.indexOf + , _$j0 = _$UI + , _$j1 = _$B([].indexOf) + , _$j2 = !!_$j1 && _$b.oWbZd((0x19d0 + 0x1 * 0x1e2c + -0x37fb) / _$j1([0x8c + -0x79 + -0x12], -0x20ca + 0xe36 + -0x1 * -0x1295, -(-0xec4 + 0x12 * -0x1c6 + -0x2eb * -0x10)), -0x2191 + -0x3b * -0x19 + 0x1bce); + _$b.iZMHh(_$Uw, { + 'target': iw(0x25c), + 'proto': !(-0x156b * 0x1 + -0x1 * -0x1fa6 + 0x1 * -0xa3b), + 'forced': _$j2 || !_$j0(iw(0x247)) + }, { + 'indexOf': function(_$iU) { + var _$ij = arguments.length > -0xd72 + -0xf66 * -0x1 + -0x1f3 ? arguments[-0x1bc2 + -0x4f * -0x7b + -0xa32] : void (-0x2552 + 0x2ff * -0x2 + -0x1f8 * -0x16); + return _$j2 ? _$j1(this, _$iU, _$ij) || -0x1e71 + 0x1613 * -0x1 + 0x3484 : _$Ul(this, _$iU, _$ij); + } + }); + var _$j3 = _$UH(_$b.afCuI, iw(0x247)) + , _$j4 = _$L + , _$j5 = _$j3 + , _$j6 = Array.prototype + , _$j7 = function(_$iU) { + var _$ij = _$iU.indexOf; + return _$iU === _$j6 || _$j4(_$j6, _$iU) && _$ij === _$j6.indexOf ? _$j5 : _$ij; + } + , _$j8 = _$Fe + , _$j9 = _$Y + , _$jb = _$bC + , _$jF = _$Qu + , _$jQ = _$U2 + , _$jU = _$b.tqPlB(_$D, [].push) + , _$jj = function(_$iU) { + var _$ij = { + 'BLdCf': function(_$iV, _$iH) { + return _$b.GXpfj(_$iV, _$iH); + }, + 'tKMfb': function(_$iV, _$iH) { + return _$iV || _$iH; + } + } + , _$ir = -0xd40 + -0x3 * -0x411 + -0x87 * -0x2 === _$iU + , _$ix = 0x1a77 * 0x1 + 0x1 * -0xccf + -0xda6 === _$iU + , _$iX = 0x2cf + 0x84a + -0x21 * 0x56 === _$iU + , _$iT = -0x20db + 0x7af + 0x1930 === _$iU + , _$iD = 0x1 * 0x1da7 + 0xdbb + -0x2b5c === _$iU + , _$iL = 0x49 * -0x1 + -0xca7 * 0x1 + -0xcf7 * -0x1 === _$iU + , _$iW = -0xb5 * 0x10 + 0x1f47 + 0x6 * -0x353 === _$iU || _$iD; + return function(_$iV, _$iH, _$iu, _$iM) { + for (var _$ic, _$iO, _$ii = _$jb(_$iV), _$iE = _$ij.BLdCf(_$j9, _$ii), _$im = _$jF(_$iE), _$iN = _$j8(_$iH, _$iu), _$iP = 0x1b96 + 0xeb1 * -0x1 + -0xce5 * 0x1, _$iK = _$iM || _$jQ, _$iB = _$ir ? _$iK(_$iV, _$im) : _$ij.tKMfb(_$ix, _$iL) ? _$iK(_$iV, -0xd81 + -0x349 * 0x1 + -0xe * -0x133) : void (-0xad0 + 0x1e43 * -0x1 + 0x2913); _$im > _$iP; _$iP++) + if ((_$iW || _$iP in _$iE) && (_$iO = _$iN(_$ic = _$iE[_$iP], _$iP, _$ii), + _$iU)) { + if (_$ir) + _$iB[_$iP] = _$iO; + else { + if (_$iO) + switch (_$iU) { + case 0x188b * 0x1 + 0xf0d + -0x2795: + return !(0x1602 + -0x7 * -0x1a1 + -0xb23 * 0x3); + case 0x78e * -0x1 + 0x23 * 0x67 + -0x682 * 0x1: + return _$ic; + case 0x18 * -0x16f + 0x8d3 * 0x1 + -0x11d * -0x17: + return _$iP; + case -0x1 * 0x7eb + -0x1591 * 0x1 + -0x5 * -0x5e6: + _$jU(_$iB, _$ic); + } + else + switch (_$iU) { + case -0x1 * 0x176 + 0x18bc + -0x1742: + return !(0x275 * -0xb + 0xfc7 + -0x43 * -0x2b); + case -0x9d9 * -0x1 + 0x4f * -0x72 + 0x195c: + _$jU(_$iB, _$ic); + } + } + } + return _$iD ? -(0x5 * 0x59e + 0x5 * 0x59e + -0x382b) : _$iX || _$iT ? _$iT : _$iB; + } + ; + } + , _$jr = { + 'forEach': _$jj(-0x11 * 0x16d + -0x3 * 0x191 + -0x8 * -0x39e), + 'map': _$jj(-0x528 + 0x1fac + -0xb * 0x269), + 'filter': _$jj(-0xb4 * -0x2f + 0x69c + -0x27a6), + 'some': _$jj(0x11 * 0x11b + -0x185f + 0x1 * 0x597), + 'every': _$jj(-0x3 * 0x91a + -0x22 * -0x6d + 0xcd8), + 'find': _$jj(0x7d3 + -0x25c4 + 0x1df6), + 'findIndex': _$b.VPyGQ(_$jj, 0x119 * 0x11 + 0x178b + -0x2 * 0x1517), + 'filterReject': _$jj(0x2 * 0x300 + -0x1935 + -0x99e * -0x2) + } + , _$jx = _$jr.map; + _$QU({ + 'target': iw(0x25c), + 'proto': !(0x432 + 0x97 * 0x13 + -0x1 * 0xf67), + 'forced': !_$U6(iw(0x1af)) + }, { + 'map': function(_$iU) { + return _$b.mxxds(_$jx, this, _$iU, arguments.length > 0x1ae * 0x13 + -0x3e * 0x1b + -0xf * 0x1b1 ? arguments[-0x1548 + 0x1099 + 0x4b0 * 0x1] : void (-0xad5 + 0xd * -0x105 + 0x1816)); + } + }); + var _$jX = _$UH(iw(0x25c), iw(0x1af)) + , _$jT = _$L + , _$jD = _$jX + , _$jL = Array.prototype + , _$jW = function(_$iU) { + var _$ij = _$iU.map; + return _$iU === _$jL || _$jT(_$jL, _$iU) && _$ij === _$jL.map ? _$jD : _$ij; + } + , _$jV = _$bv + , _$jH = _$b.eiJQQ(_$bg, iw(0x27e)) + , _$ju = function(_$iU) { + return _$jH[_$iU] || (_$jH[_$iU] = _$jV(_$iU)); + } + , _$jM = !_$b.onOzh(_$U, function() { + function _$iU() {} + return _$iU.prototype.constructor = null, + Object.getPrototypeOf(new _$iU()) !== _$iU.prototype; + }) + , _$jc = _$by + , _$jO = _$h + , _$ji = _$bC + , _$jE = _$jM + , _$jm = _$b.GXpfj(_$ju, iw(0x26b)) + , _$jN = Object + , _$jP = _$jN.prototype + , _$jK = _$jE ? _$jN.getPrototypeOf : function(_$iU) { + var _$ij = _$ji(_$iU); + if (_$jc(_$ij, _$jm)) + return _$ij[_$jm]; + var _$ir = _$ij.constructor; + return _$b.kfazF(_$jO, _$ir) && _$ij instanceof _$ir ? _$ir.prototype : _$b.CpxAO(_$ij, _$jN) ? _$jP : null; + } + , _$jB = _$D + , _$jd = _$bm + , _$jh = _$b0 + , _$jp = function(_$iU) { + return _$jh(_$iU) || _$b.mgsSr(null, _$iU); + } + , _$jz = String + , _$jn = TypeError + , _$jk = function(_$iU, _$ij, _$ir) { + try { + return _$jB(_$jd(Object.getOwnPropertyDescriptor(_$iU, _$ij)[_$ir])); + } catch (_$ix) {} + } + , _$jZ = _$b0 + , _$jA = _$v + , _$je = function(_$iU) { + var EH = iw; + if (_$b.pnsrn(_$jp, _$iU)) + return _$iU; + throw new _$jn(EH(0x24c) + _$jz(_$iU) + EH(0x20e)); + } + , _$js = Object.setPrototypeOf || (iw(0x1e5)in {} ? function() { + var Eu = iw, _$iU, _$ij = !(-0x11 * 0x41 + 0x6e8 + -0x296), _$ir = {}; + try { + (_$iU = _$jk(Object.prototype, Eu(0x1e5), Eu(0x14b)))(_$ir, []), + _$ij = _$ir instanceof Array; + } catch (_$ix) {} + return function(_$iX, _$iT) { + return _$jA(_$iX), + _$je(_$iT), + _$jZ(_$iX) ? (_$ij ? _$iU(_$iX, _$iT) : _$iX.__proto__ = _$iT, + _$iX) : _$iX; + } + ; + }() : void (-0x1 * 0x1edf + 0x3 * 0x2af + 0x17 * 0xfe)) + , _$ja = {} + , _$jg = {} + , _$jq = _$by + , _$jJ = _$w + , _$jC = _$Uv.indexOf + , _$jR = _$jg + , _$jo = _$D([].push) + , _$jy = function(_$iU, _$ij) { + var _$ir, _$ix = _$jJ(_$iU), _$iX = 0x19 * -0xd8 + 0xf * -0x236 + -0xa * -0x56d, _$iT = []; + for (_$ir in _$ix) + !_$jq(_$jR, _$ir) && _$jq(_$ix, _$ir) && _$b.wQxZP(_$jo, _$iT, _$ir); + for (; _$ij.length > _$iX; ) + _$b.wQxZP(_$jq, _$ix, _$ir = _$ij[_$iX++]) && (~_$jC(_$iT, _$ir) || _$b.kaUuY(_$jo, _$iT, _$ir)); + return _$iT; + } + , _$jY = [iw(0x147), iw(0x2b1), iw(0x266), iw(0x23d), iw(0x2b8), iw(0x256), _$b.UeZpJ] + , _$jS = _$jy + , _$jG = _$jY.concat(_$b.oxtog, iw(0x1d1)); + _$ja.f = Object.getOwnPropertyNames || function(_$iU) { + return _$jS(_$iU, _$jG); + } + ; + var _$jf = {}; + _$jf.f = Object.getOwnPropertySymbols; + var _$jv = _$b6 + , _$jt = _$ja + , _$jI = _$jf + , _$jw = _$FC + , _$jl = _$D([].concat) + , _$r0 = _$jv(iw(0x18c), iw(0x24d)) || function(_$iU) { + var _$ij = _$jt.f(_$jw(_$iU)) + , _$ir = _$jI.f; + return _$ir ? _$jl(_$ij, _$ir(_$iU)) : _$ij; + } + , _$r1 = _$by + , _$r2 = _$r0 + , _$r3 = _$p + , _$r4 = _$Fs + , _$r5 = {} + , _$r6 = _$jy + , _$r7 = _$jY + , _$r8 = Object.keys || function(_$iU) { + return _$r6(_$iU, _$r7); + } + , _$r9 = _$z + , _$rb = _$Fa + , _$rF = _$Fs + , _$rQ = _$FC + , _$rU = _$w + , _$rj = _$r8; + _$r5.f = _$r9 && !_$rb ? Object.defineProperties : function(_$iU, _$ij) { + _$rQ(_$iU); + for (var _$ir, _$ix = _$rU(_$ij), _$iX = _$b.cdOrb(_$rj, _$ij), _$iT = _$iX.length, _$iD = 0x898 + -0x2641 + 0x1da9; _$b.PxwSU(_$iT, _$iD); ) + _$rF.f(_$iU, _$ir = _$iX[_$iD++], _$ix[_$ir]); + return _$iU; + } + ; + var _$rr, _$rx = _$b6(iw(0x20b), iw(0x159)), _$rX = _$FC, _$rT = _$r5, _$rD = _$jY, _$rL = _$jg, _$rW = _$rx, _$rV = _$FD, _$rH = iw(0x1d1), _$ru = iw(0x15a), _$rM = _$ju(iw(0x26b)), _$rc = function() {}, _$rO = function(_$iU) { + return _$b.HuRFF('<' + _$ru, '>') + _$iU + ''; + }, _$ri = function(_$iU) { + _$iU.write(_$rO('')), + _$iU.close(); + var _$ij = _$iU.parentWindow.Object; + return _$iU = null, + _$ij; + }, _$rE = function() { + var EM = iw; + try { + _$rr = new ActiveXObject(EM(0x184)); + } catch (_$iX) {} + var _$iU, _$ij, _$ir; + _$rE = 'undefined' != typeof document ? document.domain && _$rr ? _$ri(_$rr) : (_$ij = _$rV(EM(0x157)), + _$ir = _$b.TJkjs(EM(0x284) + _$ru, ':'), + _$ij.style.display = _$b.XsVMf, + _$rW.appendChild(_$ij), + _$ij.src = _$b.LZbEY(String, _$ir), + (_$iU = _$ij.contentWindow.document).open(), + _$iU.write(_$rO(_$b.XxGnj)), + _$iU.close(), + _$iU.F) : _$b.oPdbP(_$ri, _$rr); + for (var _$ix = _$rD.length; _$ix--; ) + delete _$rE[_$rH][_$rD[_$ix]]; + return _$rE(); + }; + _$rL[_$rM] = !(0x1cb * 0xf + 0x4cf * -0x2 + -0x1147 * 0x1); + var _$rm = Object.create || function(_$iU, _$ij) { + var _$ir; + return null !== _$iU ? (_$rc[_$rH] = _$rX(_$iU), + _$ir = new _$rc(), + _$rc[_$rH] = null, + _$ir[_$rM] = _$iU) : _$ir = _$rE(), + void (-0x177e + 0x138b * -0x1 + 0x2b09) === _$ij ? _$ir : _$rT.f(_$ir, _$ij); + } + , _$rN = _$b0 + , _$rP = _$Q1 + , _$rK = Error + , _$rB = _$b.pnsrn(_$D, ''.replace) + , _$rd = String(new _$rK(iw(0x1f4)).stack) + , _$rh = /\n\s*at [^:]*:[^\n]*/ + , _$rp = _$rh.test(_$rd) + , _$rz = _$J + , _$rn = !_$b.pnYXU(_$U, function() { + var Ec = iw + , _$iU = new Error('a'); + return !(Ec(0x212)in _$iU) || (Object.defineProperty(_$iU, Ec(0x212), _$rz(-0x10ff + 0x207b + -0xf7b * 0x1, 0x1f13 * -0x1 + -0xe2a + 0x2 * 0x16a2)), + -0x1f4a + 0x1b3a + 0x417 !== _$iU.stack); + }) + , _$rk = _$Q1 + , _$rZ = function(_$iU, _$ij) { + if (_$rp && _$b.OYYwc == typeof _$iU && !_$rK.prepareStackTrace) { + for (; _$ij--; ) + _$iU = _$rB(_$iU, _$rh, ''); + } + return _$iU; + } + , _$rA = _$rn + , _$re = Error.captureStackTrace + , _$rs = {} + , _$ra = _$rs + , _$rg = _$F4(iw(0x1ed)) + , _$rq = Array.prototype + , _$rJ = _$Qn + , _$rC = _$bK + , _$rR = _$S + , _$ro = _$rs + , _$ry = _$F4(iw(0x1ed)) + , _$rY = function(_$iU) { + var EO = iw; + if (!_$rR(_$iU)) + return _$rC(_$iU, _$ry) || _$rC(_$iU, EO(0x193)) || _$ro[_$rJ(_$iU)]; + } + , _$rS = _$Z + , _$rG = _$bm + , _$rf = _$FC + , _$rv = _$bc + , _$rt = _$rY + , _$rI = TypeError + , _$rw = _$Z + , _$rl = _$FC + , _$x0 = _$bK + , _$x1 = _$Fe + , _$x2 = _$Z + , _$x3 = _$FC + , _$x4 = _$bc + , _$x5 = function(_$iU) { + return _$b.FpYpU(void (0x1a * 0xcd + -0x113 + -0x13bf), _$iU) && (_$ra.Array === _$iU || _$b.QMEUG(_$rq[_$rg], _$iU)); + } + , _$x6 = _$Qu + , _$x7 = _$L + , _$x8 = function(_$iU, _$ij) { + var _$ir = _$b.Yzehu(arguments.length, 0x2612 + 0x1f52 + 0xa6 * -0x6b) ? _$rt(_$iU) : _$ij; + if (_$rG(_$ir)) + return _$rf(_$b.WwEuF(_$rS, _$ir, _$iU)); + throw new _$rI(_$b.TJkjs(_$rv(_$iU), _$b.rMxeX)); + } + , _$x9 = _$rY + , _$xb = function(_$iU, _$ij, _$ir) { + var Ei = iw, _$ix, _$iX; + _$rl(_$iU); + try { + if (!(_$ix = _$b.yeAFb(_$x0, _$iU, Ei(0x200)))) { + if (Ei(0x25e) === _$ij) + throw _$ir; + return _$ir; + } + _$ix = _$rw(_$ix, _$iU); + } catch (_$iT) { + _$iX = !(-0x89f + -0x11c2 + 0x1a61), + _$ix = _$iT; + } + if (Ei(0x25e) === _$ij) + throw _$ir; + if (_$iX) + throw _$ix; + return _$b.pnsrn(_$rl, _$ix), + _$ir; + } + , _$xF = TypeError + , _$xQ = function(_$iU, _$ij) { + this.stopped = _$iU, + this.result = _$ij; + } + , _$xU = _$xQ.prototype + , _$xj = function(_$iU, _$ij, _$ir) { + var Em = iw, _$ix = { + 'wehbz': function(_$iP, _$iK, _$iB, _$id) { + return _$iP(_$iK, _$iB, _$id); + }, + 'tIhTN': function(_$iP, _$iK) { + return _$iP(_$iK); + } + }, _$iX, _$iT, _$iD, _$iL, _$iW, _$iV, _$iH, _$iu = _$ir && _$ir.that, _$iM = !(!_$ir || !_$ir.AS_ENTRIES), _$ic = !(!_$ir || !_$ir.IS_RECORD), _$iO = !(!_$ir || !_$ir.IS_ITERATOR), _$ii = !(!_$ir || !_$ir.INTERRUPTED), _$iE = _$x1(_$ij, _$iu), _$im = function(_$iP) { + var EE = a092750F; + return _$iX && _$xb(_$iX, EE(0x1b0), _$iP), + new _$xQ(!(0x5 * -0x422 + -0x209d + 0x17 * 0x251),_$iP); + }, _$iN = function(_$iP) { + return _$iM ? (_$x3(_$iP), + _$ii ? _$ix.wehbz(_$iE, _$iP[-0xddb + -0x1 * 0x1403 + 0x21de], _$iP[0x24f3 + 0x1005 * -0x2 + -0x4e8], _$im) : _$iE(_$iP[0x1 * 0xae7 + -0x2ad + -0x83a], _$iP[0x93b + 0x385 * 0x1 + -0xcbf])) : _$ii ? _$iE(_$iP, _$im) : _$ix.tIhTN(_$iE, _$iP); + }; + if (_$ic) + _$iX = _$iU.iterator; + else { + if (_$iO) + _$iX = _$iU; + else { + if (!(_$iT = _$x9(_$iU))) + throw new _$xF(_$b.wHcJc(_$x4(_$iU), _$b.rMxeX)); + if (_$b.SElhX(_$x5, _$iT)) { + for (_$iD = -0x1bff + -0x1358 + 0x2f57, + _$iL = _$x6(_$iU); _$b.PxwSU(_$iL, _$iD); _$iD++) + if ((_$iW = _$iN(_$iU[_$iD])) && _$b.kaUuY(_$x7, _$xU, _$iW)) + return _$iW; + return new _$xQ(!(0x352 + -0x6fb + -0x2 * -0x1d5)); + } + _$iX = _$x8(_$iU, _$iT); + } + } + for (_$iV = _$ic ? _$iU.next : _$iX.next; !(_$iH = _$x2(_$iV, _$iX)).done; ) { + try { + _$iW = _$iN(_$iH.value); + } catch (_$iP) { + _$xb(_$iX, Em(0x25e), _$iP); + } + if (Em(0x146) == typeof _$iW && _$iW && _$x7(_$xU, _$iW)) + return _$iW; + } + return new _$xQ(!(0xc01 + 0x1 * -0xe39 + 0x239)); + } + , _$xr = _$Qn + , _$xx = String + , _$xX = function(_$iU) { + var EN = iw; + if (EN(0x198) === _$b.VPyGQ(_$xr, _$iU)) + throw new TypeError(EN(0x221)); + return _$xx(_$iU); + } + , _$xT = _$xX + , _$xD = _$QU + , _$xL = _$L + , _$xW = _$jK + , _$xV = _$js + , _$xH = function(_$iU, _$ij, _$ir) { + for (var _$ix = _$b.AbiJO(_$r2, _$ij), _$iX = _$r4.f, _$iT = _$r3.f, _$iD = -0x373 * -0x5 + 0x165c + -0x1 * 0x279b; _$iD < _$ix.length; _$iD++) { + var _$iL = _$ix[_$iD]; + _$b.tUMSG(_$r1, _$iU, _$iL) || _$ir && _$b.guaEG(_$r1, _$ir, _$iL) || _$iX(_$iU, _$iL, _$iT(_$ij, _$iL)); + } + } + , _$xu = _$rm + , _$xM = _$Q1 + , _$xc = _$J + , _$xO = function(_$iU, _$ij) { + var EP = iw; + _$b.oPdbP(_$rN, _$ij) && _$b.IFGvy in _$ij && _$rP(_$iU, EP(0x171), _$ij.cause); + } + , _$xi = function(_$iU, _$ij, _$ir, _$ix) { + var EK = iw; + _$rA && (_$re ? _$re(_$iU, _$ij) : _$rk(_$iU, EK(0x212), _$rZ(_$ir, _$ix))); + } + , _$xE = _$xj + , _$xm = function(_$iU, _$ij) { + return void (0x20f6 * -0x1 + 0xd1b * 0x1 + -0x17 * -0xdd) === _$iU ? arguments.length < -0x1235 + -0xe0 * 0x1e + 0x2c77 ? '' : _$ij : _$xT(_$iU); + } + , _$xN = _$F4(iw(0x2b9)) + , _$xP = Error + , _$xK = [].push + , _$xB = function(_$iU, _$ij) { + var EB = iw, _$ir, _$ix = _$xL(_$xd, this); + _$xV ? _$ir = _$xV(new _$xP(), _$ix ? _$b.mfERw(_$xW, this) : _$xd) : (_$ir = _$ix ? this : _$b.uXoWw(_$xu, _$xd), + _$xM(_$ir, _$xN, EB(0x27d))), + void (-0x1e89 * -0x1 + 0x411 * 0x7 + -0x3b00) !== _$ij && _$xM(_$ir, EB(0x283), _$xm(_$ij)), + _$xi(_$ir, _$xB, _$ir.stack, 0x1d0 + 0x661 + -0x830), + arguments.length > 0x449 * 0x4 + 0x1b88 + -0x2caa && _$xO(_$ir, arguments[-0x6 * 0x60 + -0x19b4 + 0x4a9 * 0x6]); + var _$iX = []; + return _$b.OaylU(_$xE, _$iU, _$xK, { + 'that': _$iX + }), + _$b.QdUQR(_$xM, _$ir, _$b.BSNuj, _$iX), + _$ir; + }; + _$xV ? _$b.tbQbV(_$xV, _$xB, _$xP) : _$xH(_$xB, _$xP, { + 'name': !(0xa * 0x93 + 0x9e3 * 0x1 + -0xfa1) + }); + var _$xd = _$xB.prototype = _$xu(_$xP.prototype, { + 'constructor': _$b.WFOst(_$xc, 0x8b5 + -0x2207 + 0x1953, _$xB), + 'message': _$xc(0x4 * 0x86b + 0x13 * -0x7c + -0x1877, ''), + 'name': _$xc(0x7e1 + -0x184f * 0x1 + 0x259 * 0x7, iw(0x1a4)) + }); + _$xD({ + 'global': !(-0x77f + 0x15cb + 0xe4c * -0x1), + 'constructor': !(-0x5 * 0x623 + -0x18d * 0x17 + 0x161e * 0x3), + 'arity': 0x2 + }, { + 'AggregateError': _$xB + }); + var _$xh, _$xp, _$xz, _$xn = _$h, _$xk = _$V.WeakMap, _$xZ = _$b.mhwAA(_$xn, _$xk) && /native code/.test(String(_$xk)), _$xA = _$V, _$xe = _$b0, _$xs = _$Q1, _$xa = _$by, _$xg = _$bz.exports, _$xq = _$ju, _$xJ = _$jg, _$xC = iw(0x26a), _$xR = _$xA.TypeError, _$xo = _$xA.WeakMap; + if (_$xZ || _$xg.state) { + var _$xy = _$xg.state || (_$xg.state = new _$xo()); + _$xy.get = _$xy.get, + _$xy.has = _$xy.has, + _$xy.set = _$xy.set, + _$xh = function(_$iU, _$ij) { + if (_$xy.has(_$iU)) + throw new _$xR(_$xC); + return _$ij.facade = _$iU, + _$xy.set(_$iU, _$ij), + _$ij; + } + , + _$xp = function(_$iU) { + return _$xy.get(_$iU) || {}; + } + , + _$xz = function(_$iU) { + return _$xy.has(_$iU); + } + ; + } else { + var _$xY = _$xq(iw(0x254)); + _$xJ[_$xY] = !(-0x30 * 0x90 + 0x2048 + -0x548), + _$xh = function(_$iU, _$ij) { + if (_$xa(_$iU, _$xY)) + throw new _$xR(_$xC); + return _$ij.facade = _$iU, + _$xs(_$iU, _$xY, _$ij), + _$ij; + } + , + _$xp = function(_$iU) { + return _$xa(_$iU, _$xY) ? _$iU[_$xY] : {}; + } + , + _$xz = function(_$iU) { + return _$xa(_$iU, _$xY); + } + ; + } + var _$xS, _$xG, _$xf, _$xv = { + 'set': _$xh, + 'get': _$xp, + 'has': _$xz, + 'enforce': function(_$iU) { + return _$xz(_$iU) ? _$b.oPdbP(_$xp, _$iU) : _$xh(_$iU, {}); + }, + 'getterFor': function(_$iU) { + var _$ij = { + 'xBGgW': function(_$ir, _$ix) { + return _$b.sxiUJ(_$ir, _$ix); + } + }; + return function(_$ir) { + var Ed = a092750F, _$ix; + if (!_$xe(_$ir) || (_$ix = _$xp(_$ir)).type !== _$iU) + throw new _$xR(_$ij.xBGgW(Ed(0x177) + _$iU, ' required')); + return _$ix; + } + ; + } + }, _$xt = _$z, _$xI = _$by, _$xw = Function.prototype, _$xl = _$xt && Object.getOwnPropertyDescriptor, _$X0 = _$b.gfazF(_$xI, _$xw, iw(0x1a5)), _$X1 = { + 'EXISTS': _$X0, + 'PROPER': _$X0 && iw(0x23a) === function() {} + .name, + 'CONFIGURABLE': _$X0 && (!_$xt || _$xt && _$xl(_$xw, iw(0x1a5)).configurable) + }, _$X2 = _$Q1, _$X3 = function(_$iU, _$ij, _$ir, _$ix) { + return _$ix && _$ix.enumerable ? _$iU[_$ij] = _$ir : _$X2(_$iU, _$ij, _$ir), + _$iU; + }, _$X4 = _$U, _$X5 = _$h, _$X6 = _$b0, _$X7 = _$rm, _$X8 = _$jK, _$X9 = _$X3, _$Xb = _$F4(iw(0x1ed)), _$XF = !(0xca6 * 0x1 + -0x8e1 * -0x3 + 0x346 * -0xc); + [].keys && (iw(0x190)in (_$xf = [].keys()) ? (_$xG = _$X8(_$X8(_$xf))) !== Object.prototype && (_$xS = _$xG) : _$XF = !(0x3 * -0x965 + -0x7b2 * 0x4 + 0x3af7)); + var _$XQ = !_$X6(_$xS) || _$b.vuBfQ(_$X4, function() { + var _$iU = {}; + return _$b.PsggH(_$xS[_$Xb].call(_$iU), _$iU); + }); + _$X5((_$xS = _$XQ ? {} : _$X7(_$xS))[_$Xb]) || _$X9(_$xS, _$Xb, function() { + return this; + }); + var _$XU = { + 'IteratorPrototype': _$xS, + 'BUGGY_SAFARI_ITERATORS': _$XF + } + , _$Xj = _$Qn + , _$Xr = _$QP ? {}.toString : function() { + var Eh = iw; + return Eh(0x22f) + _$Xj(this) + ']'; + } + , _$Xx = _$QP + , _$XX = _$Fs.f + , _$XT = _$Q1 + , _$XD = _$by + , _$XL = _$Xr + , _$XW = _$F4(_$b.TsTMJ) + , _$XV = function(_$iU, _$ij, _$ir, _$ix) { + var Ep = iw + , _$iX = _$ir ? _$iU : _$iU && _$iU.prototype; + _$iX && (_$XD(_$iX, _$XW) || _$b.YIkXu(_$XX, _$iX, _$XW, { + 'configurable': !(-0xec * -0x12 + -0x2b4 * 0xc + 0xfd8), + 'value': _$ij + }), + _$ix && !_$Xx && _$XT(_$iX, Ep(0x256), _$XL)); + } + , _$XH = _$XU.IteratorPrototype + , _$Xu = _$rm + , _$XM = _$J + , _$Xc = _$XV + , _$XO = _$rs + , _$Xi = function() { + return this; + } + , _$XE = _$QU + , _$Xm = _$Z + , _$XN = _$X1 + , _$XP = function(_$iU, _$ij, _$ir, _$ix) { + var _$iX = _$b.IvKto(_$ij, _$b.SLLuw); + return _$iU.prototype = _$Xu(_$XH, { + 'next': _$XM(+!_$ix, _$ir) + }), + _$b.QjNEg(_$Xc, _$iU, _$iX, !(-0x233b + 0x1 * 0xe95 + 0x14a7), !(-0x20f9 + 0xc02 + 0x1 * 0x14f7)), + _$XO[_$iX] = _$Xi, + _$iU; + } + , _$XK = _$jK + , _$XB = _$XV + , _$Xd = _$X3 + , _$Xh = _$rs + , _$Xp = _$XU + , _$Xz = _$XN.PROPER + , _$Xn = _$Xp.BUGGY_SAFARI_ITERATORS + , _$Xk = _$F4(iw(0x1ed)) + , _$XZ = iw(0x27e) + , _$XA = _$b.dUKBL + , _$Xe = iw(0x13f) + , _$Xs = function() { + return this; + } + , _$Xa = function(_$iU, _$ij, _$ir, _$ix, _$iX, _$iT, _$iD) { + var Ez = iw + , _$iL = { + 'PMtrL': function(_$iN, _$iP) { + return _$b.omKMK(_$iN, _$iP); + } + }; + _$XP(_$ir, _$ij, _$ix); + var _$iW, _$iV, _$iH, _$iu = function(_$iN) { + if (_$iN === _$iX && _$iE) + return _$iE; + if (!_$Xn && _$iN && _$iL.PMtrL(_$iN, _$iO)) + return _$iO[_$iN]; + switch (_$iN) { + case _$XZ: + case _$XA: + case _$Xe: + return function() { + return new _$ir(this,_$iN); + } + ; + } + return function() { + return new _$ir(this); + } + ; + }, _$iM = _$ij + _$b.SLLuw, _$ic = !(0x21b9 + -0x59 * -0x23 + -0x2de3), _$iO = _$iU.prototype, _$ii = _$iO[_$Xk] || _$iO[Ez(0x193)] || _$iX && _$iO[_$iX], _$iE = !_$Xn && _$ii || _$b.VEeFZ(_$iu, _$iX), _$im = Ez(0x25c) === _$ij && _$iO.entries || _$ii; + if (_$im && _$b.GKPHr(_$iW = _$b.DjSUN(_$XK, _$im.call(new _$iU())), Object.prototype) && _$iW.next && (_$XB(_$iW, _$iM, !(-0x73 + 0xbf1 + -0xb7e), !(-0x2111 + -0x3 * -0x5db + 0xf80)), + _$Xh[_$iM] = _$Xs), + _$Xz && _$iX === _$XA && _$ii && _$ii.name !== _$XA && (_$ic = !(0xc23 + -0xb15 * -0x2 + -0x1 * 0x224d), + _$iE = function() { + return _$b.eHtKM(_$Xm, _$ii, this); + } + ), + _$iX) { + if (_$iV = { + 'values': _$iu(_$XA), + 'keys': _$iT ? _$iE : _$iu(_$XZ), + 'entries': _$iu(_$Xe) + }, + _$iD) { + for (_$iH in _$iV) + (_$Xn || _$ic || !_$b.PgndA(_$iH, _$iO)) && _$Xd(_$iO, _$iH, _$iV[_$iH]); + } else + _$XE({ + 'target': _$ij, + 'proto': !(0x10d * 0xe + -0x1262 * -0x2 + -0x337a), + 'forced': _$Xn || _$ic + }, _$iV); + } + return _$iD && _$iO[_$Xk] !== _$iE && _$Xd(_$iO, _$Xk, _$iE, { + 'name': _$iX + }), + _$Xh[_$ij] = _$iE, + _$iV; + } + , _$Xg = function(_$iU, _$ij) { + return { + 'value': _$iU, + 'done': _$ij + }; + } + , _$Xq = _$w + , _$XJ = function() {} + , _$XC = _$rs + , _$XR = _$xv + , _$Xo = (_$Fs.f, + _$Xa) + , _$Xy = _$Xg + , _$XY = iw(0x148) + , _$XS = _$XR.set + , _$XG = _$XR.getterFor(_$XY); + _$Xo(Array, iw(0x25c), function(_$iU, _$ij) { + _$XS(this, { + 'type': _$XY, + 'target': _$Xq(_$iU), + 'index': 0x0, + 'kind': _$ij + }); + }, function() { + var En = iw + , _$iU = _$b.vuBfQ(_$XG, this) + , _$ij = _$iU.target + , _$ir = _$iU.index++; + if (!_$ij || _$ir >= _$ij.length) + return _$iU.target = void (0x637 * -0x5 + -0x1 * 0x799 + 0x26ac), + _$b.tUMSG(_$Xy, void (-0x1a54 + -0x1087 + 0x2adb), !(-0x27 * 0x8b + 0x4a * 0x2c + 0x875)); + switch (_$iU.kind) { + case En(0x27e): + return _$Xy(_$ir, !(-0x4c * 0x61 + 0xcf2 + -0x63 * -0x29)); + case En(0x185): + return _$Xy(_$ij[_$ir], !(0x1edb + -0x1446 + -0xa94)); + } + return _$Xy([_$ir, _$ij[_$ir]], !(-0x19b2 + 0x5 * 0x2db + -0x2 * -0x5b6)); + }, _$b.dUKBL), + _$XC.Arguments = _$XC.Array, + (_$XJ(), + _$XJ(), + _$XJ()); + var _$Xf, _$Xv, _$Xt, _$XI, _$Xw = iw(0x1ef) === _$b.DjSUN(_$N, _$V.process), _$Xl = _$Fs, _$T0 = function(_$iU, _$ij, _$ir) { + return _$Xl.f(_$iU, _$ij, _$ir); + }, _$T1 = _$b6, _$T2 = _$T0, _$T3 = _$z, _$T4 = _$F4(_$b.ofciT), _$T5 = _$L, _$T6 = TypeError, _$T7 = _$Qv, _$T8 = _$bc, _$T9 = TypeError, _$Tb = _$FC, _$TF = function(_$iU) { + if (_$T7(_$iU)) + return _$iU; + throw new _$T9(_$b.QefQe(_$T8, _$iU) + _$b.YhRnx); + }, _$TQ = _$S, _$TU = _$F4(iw(0x175)), _$Tj = function(_$iU, _$ij) { + var _$ir, _$ix = _$Tb(_$iU).constructor; + return _$b.EXENZ(void (-0x20d9 * -0x1 + 0x25a4 + -0x467d), _$ix) || _$TQ(_$ir = _$b.VEeFZ(_$Tb, _$ix)[_$TU]) ? _$ij : _$TF(_$ir); + }, _$Tr = TypeError, _$Tx = /(?:ipad|iphone|ipod).*applewebkit/i.test(_$b7), _$TX = _$V, _$TT = _$O, _$TD = _$Fe, _$TL = _$h, _$TW = _$by, _$TV = _$U, _$TH = _$rx, _$Tu = _$UK, _$TM = _$FD, _$Tc = function(_$iU, _$ij) { + var Ek = iw; + if (_$iU < _$ij) + throw new _$Tr(Ek(0x2b2)); + return _$iU; + }, _$TO = _$Tx, _$Ti = _$Xw, _$TE = _$TX.setImmediate, _$Tm = _$TX.clearImmediate, _$TN = _$TX.process, _$TP = _$TX.Dispatch, _$TK = _$TX.Function, _$TB = _$TX.MessageChannel, _$Td = _$TX.String, _$Th = 0x1b05 + -0x4f5 + -0x1610, _$Tp = {}, _$Tz = iw(0x1c2); + _$TV(function() { + _$Xf = _$TX.location; + }); + var _$Tn = function(_$iU) { + if (_$TW(_$Tp, _$iU)) { + var _$ij = _$Tp[_$iU]; + delete _$Tp[_$iU], + _$ij(); + } + } + , _$Tk = function(_$iU) { + return function() { + _$b.XQpVK(_$Tn, _$iU); + } + ; + } + , _$TZ = function(_$iU) { + _$b.fWLCv(_$Tn, _$iU.data); + } + , _$TA = function(_$iU) { + _$TX.postMessage(_$Td(_$iU), _$b.RwlqE(_$Xf.protocol + '//', _$Xf.host)); + }; + _$b.TULvW(_$TE, _$Tm) || (_$TE = function(_$iU) { + _$Tc(arguments.length, -0x22ed + -0x231b + 0x1 * 0x4609); + var _$ij = _$TL(_$iU) ? _$iU : _$b.mfERw(_$TK, _$iU) + , _$ir = _$Tu(arguments, 0xcac + 0x151b + -0x21c6); + return _$Tp[++_$Th] = function() { + _$TT(_$ij, void (0xe * 0x1b1 + 0x185 * 0x19 + -0x3dab), _$ir); + } + , + _$Xv(_$Th), + _$Th; + } + , + _$Tm = function(_$iU) { + delete _$Tp[_$iU]; + } + , + _$Ti ? _$Xv = function(_$iU) { + _$TN.nextTick(_$Tk(_$iU)); + } + : _$TP && _$TP.now ? _$Xv = function(_$iU) { + _$TP.now(_$Tk(_$iU)); + } + : _$b.TULvW(_$TB, !_$TO) ? (_$XI = (_$Xt = new _$TB()).port2, + _$Xt.port1.onmessage = _$TZ, + _$Xv = _$b.UmWiu(_$TD, _$XI.postMessage, _$XI)) : _$TX.addEventListener && _$TL(_$TX.postMessage) && !_$TX.importScripts && _$Xf && iw(0x209) !== _$Xf.protocol && !_$TV(_$TA) ? (_$Xv = _$TA, + _$TX.addEventListener(_$b.VLZej, _$TZ, !(-0x1dbe + -0xc8c + 0x2a4b))) : _$Xv = _$b.BgwIa(_$Tz, _$TM(_$b.gAJli)) ? function(_$iU) { + _$TH.appendChild(_$TM(_$b.gAJli))[_$Tz] = function() { + _$TH.removeChild(this), + _$Tn(_$iU); + } + ; + } + : function(_$iU) { + setTimeout(_$Tk(_$iU), -0x1a1f + -0x4cd + -0x1eec * -0x1); + } + ); + var _$Te = { + 'set': _$TE, + 'clear': _$Tm + } + , _$Ts = _$V + , _$Ta = _$z + , _$Tg = Object.getOwnPropertyDescriptor + , _$Tq = function() { + this.head = null, + this.tail = null; + }; + _$Tq.prototype = { + 'add': function(_$iU) { + var _$ij = { + 'item': _$iU, + 'next': null + } + , _$ir = this.tail; + _$ir ? _$ir.next = _$ij : this.head = _$ij, + this.tail = _$ij; + }, + 'get': function() { + var _$iU = this.head; + if (_$iU) + return null === (this.head = _$iU.next) && (this.tail = null), + _$iU.item; + } + }; + var _$TJ, _$TC, _$TR, _$To, _$Ty, _$TY = _$Tq, _$TS = /ipad|iphone|ipod/i.test(_$b7) && 'undefined' != typeof Pebble, _$TG = /web0s(?!.*chrome)/i.test(_$b7), _$Tf = _$V, _$Tv = function(_$iU) { + if (!_$Ta) + return _$Ts[_$iU]; + var _$ij = _$Tg(_$Ts, _$iU); + return _$ij && _$ij.value; + }, _$Tt = _$Fe, _$TI = _$Te.set, _$Tw = _$TY, _$Tl = _$Tx, _$D0 = _$TS, _$D1 = _$TG, _$D2 = _$Xw, _$D3 = _$Tf.MutationObserver || _$Tf.WebKitMutationObserver, _$D4 = _$Tf.document, _$D5 = _$Tf.process, _$D6 = _$Tf.Promise, _$D7 = _$Tv(iw(0x275)); + if (!_$D7) { + var _$D8 = new _$Tw() + , _$D9 = function() { + var _$iU, _$ij; + for (_$D2 && (_$iU = _$D5.domain) && _$iU.exit(); _$ij = _$D8.get(); ) + try { + _$b.mEQpG(_$ij); + } catch (_$ir) { + throw _$D8.head && _$TJ(), + _$ir; + } + _$iU && _$iU.enter(); + }; + _$b.dwuJG(_$Tl, _$D2) || _$D1 || !_$D3 || !_$D4 ? _$b.TULvW(!_$D0, _$D6) && _$D6.resolve ? ((_$To = _$D6.resolve(void (-0x1d41 + 0x16d * 0x5 + -0xb10 * -0x2))).constructor = _$D6, + _$Ty = _$Tt(_$To.then, _$To), + _$TJ = function() { + _$Ty(_$D9); + } + ) : _$D2 ? _$TJ = function() { + _$D5.nextTick(_$D9); + } + : (_$TI = _$Tt(_$TI, _$Tf), + _$TJ = function() { + _$TI(_$D9); + } + ) : (_$TC = !(0x30d + -0x1 * -0xfa7 + -0x15 * 0xe4), + _$TR = _$D4.createTextNode(''), + new _$D3(_$D9).observe(_$TR, { + 'characterData': !(0x1 * 0xce5 + 0x2 * -0x79d + -0x3 * -0xc7) + }), + _$TJ = function() { + _$TR.data = _$TC = !_$TC; + } + ), + _$D7 = function(_$iU) { + _$D8.head || _$TJ(), + _$D8.add(_$iU); + } + ; + } + var _$Db = _$D7 + , _$DF = function(_$iU) { + try { + return { + 'error': !(0xa89 + 0x10 * 0x52 + 0x1f5 * -0x8), + 'value': _$iU() + }; + } catch (_$ij) { + return { + 'error': !(-0xd5d + 0xde7 * 0x1 + -0x8a), + 'value': _$ij + }; + } + } + , _$DQ = _$V.Promise + , _$DU = iw(0x146) == typeof Deno && Deno && iw(0x146) == typeof Deno.version + , _$Dj = !_$DU && !_$Xw && _$b.HCXWR(iw(0x146), typeof window) && iw(0x146) == typeof document + , _$Dr = _$V + , _$Dx = _$DQ + , _$DX = _$h + , _$DT = _$Fn + , _$DD = _$Qs + , _$DL = _$F4 + , _$DW = _$Dj + , _$DV = _$DU + , _$DH = _$bj + , _$Du = _$Dx && _$Dx.prototype + , _$DM = _$DL(_$b.ofciT) + , _$Dc = !(-0x25a1 + 0x1673 + -0xf2f * -0x1) + , _$DO = _$DX(_$Dr.PromiseRejectionEvent) + , _$Di = _$DT(_$b.Xouxt, function() { + var _$iU = _$DD(_$Dx) + , _$ij = _$iU !== String(_$Dx); + if (!_$ij && 0x1e7b + 0x17b4 + -0x35ed === _$DH) + return !(0x22 * 0x25 + -0xca2 + 0x26 * 0x34); + if (!_$Du.catch || !_$Du.finally) + return !(-0x13bd + -0x21a * 0x5 + 0x57 * 0x59); + if (!_$DH || _$DH < -0x4d5 * -0x2 + 0x69f * -0x2 + -0x1 * -0x3c7 || !/native code/.test(_$iU)) { + var _$ir = new _$Dx(function(_$iX) { + _$iX(0x5f * -0x1f + -0x1 * 0x977 + -0x5b * -0x3b); + } + ) + , _$ix = function(_$iX) { + _$iX(function() {}, function() {}); + }; + if ((_$ir.constructor = {})[_$DM] = _$ix, + !(_$Dc = _$ir.then(function() {})instanceof _$ix)) + return !(-0x225f * -0x1 + 0x5 * -0x1f7 + -0x188c); + } + return !_$ij && _$b.sKQTA(_$DW, _$DV) && !_$DO; + }) + , _$DE = { + 'CONSTRUCTOR': _$Di, + 'REJECTION_EVENT': _$DO, + 'SUBCLASSING': _$Dc + } + , _$Dm = {} + , _$DN = _$bm + , _$DP = TypeError + , _$DK = function(_$iU) { + var _$ij, _$ir; + this.promise = new _$iU(function(_$ix, _$iX) { + var EZ = a092750F; + if (void (0x10b2 * 0x1 + -0x17d4 + 0x16 * 0x53) !== _$ij || void (0x757 * -0x2 + 0xe54 + -0x5a * -0x1) !== _$ir) + throw new _$DP(EZ(0x141)); + _$ij = _$ix, + _$ir = _$iX; + } + ), + this.resolve = _$DN(_$ij), + this.reject = _$DN(_$ir); + }; + _$Dm.f = function(_$iU) { + return new _$DK(_$iU); + } + ; + var _$DB, _$Dd, _$Dh = _$QU, _$Dp = _$Xw, _$Dz = _$V, _$Dn = _$Z, _$Dk = _$X3, _$DZ = _$XV, _$DA = function(_$iU) { + var _$ij = _$T1(_$iU); + _$T3 && _$ij && !_$ij[_$T4] && _$b.eBnAs(_$T2, _$ij, _$T4, { + 'configurable': !(-0x1a76 + -0x35 * 0x95 + 0x394f), + 'get': function() { + return this; + } + }); + }, _$De = _$bm, _$Ds = _$h, _$Da = _$b0, _$Dg = function(_$iU, _$ij) { + if (_$T5(_$ij, _$iU)) + return _$iU; + throw new _$T6(_$b.amVMX); + }, _$Dq = _$Tj, _$DJ = _$Te.set, _$DC = _$Db, _$DR = function(_$iU, _$ij) { + try { + -0x1f28 + -0x1d0e + 0x3c37 === arguments.length ? console.error(_$iU) : console.error(_$iU, _$ij); + } catch (_$ir) {} + }, _$Do = _$DF, _$Dy = _$TY, _$DY = _$xv, _$DS = _$DQ, _$DG = _$Dm, _$Df = _$b.Xouxt, _$Dv = _$DE.CONSTRUCTOR, _$Dt = _$DE.REJECTION_EVENT, _$DI = _$DY.getterFor(_$Df), _$Dw = _$DY.set, _$Dl = _$DS && _$DS.prototype, _$L0 = _$DS, _$L1 = _$Dl, _$L2 = _$Dz.TypeError, _$L3 = _$Dz.document, _$L4 = _$Dz.process, _$L5 = _$DG.f, _$L6 = _$L5, _$L7 = !!(_$L3 && _$L3.createEvent && _$Dz.dispatchEvent), _$L8 = iw(0x1a1), _$L9 = function(_$iU) { + var _$ij; + return !(!_$Da(_$iU) || !_$Ds(_$ij = _$iU.then)) && _$ij; + }, _$Lb = function(_$iU, _$ij) { + var EA = iw, _$ir, _$ix, _$iX, _$iT = _$ij.value, _$iD = _$b.ODlwn(0x13fe + 0x2213 + -0x3610, _$ij.state), _$iL = _$iD ? _$iU.ok : _$iU.fail, _$iW = _$iU.resolve, _$iV = _$iU.reject, _$iH = _$iU.domain; + try { + _$iL ? (_$iD || (-0x1fe9 * 0x1 + 0xe2e + 0x11bd === _$ij.rejection && _$Lr(_$ij), + _$ij.rejection = -0xfb * 0x2 + -0x149b * 0x1 + -0x6b * -0x36), + _$b.QfzIs(!(0x716 + 0x21c9 + 0x1 * -0x28df), _$iL) ? _$ir = _$iT : (_$iH && _$iH.enter(), + _$ir = _$iL(_$iT), + _$iH && (_$iH.exit(), + _$iX = !(0x1dc8 + 0x1fb9 + -0x3d81))), + _$ir === _$iU.promise ? _$b.vuBfQ(_$iV, new _$L2(EA(0x183))) : (_$ix = _$L9(_$ir)) ? _$Dn(_$ix, _$ir, _$iW, _$iV) : _$iW(_$ir)) : _$iV(_$iT); + } catch (_$iu) { + _$iH && !_$iX && _$iH.exit(), + _$iV(_$iu); + } + }, _$LF = function(_$iU, _$ij) { + _$iU.notified || (_$iU.notified = !(-0x4 * 0x65b + 0x57f + 0x13ed), + _$DC(function() { + for (var _$ir, _$ix = _$iU.reactions; _$ir = _$ix.get(); ) + _$Lb(_$ir, _$iU); + _$iU.notified = !(0x1be * 0x3 + -0x166d * -0x1 + -0x1ba6), + _$ij && !_$iU.rejection && _$LU(_$iU); + })); + }, _$LQ = function(_$iU, _$ij, _$ir) { + var Ee = iw, _$ix, _$iX; + _$L7 ? ((_$ix = _$L3.createEvent(_$b.FRqAm)).promise = _$ij, + _$ix.reason = _$ir, + _$ix.initEvent(_$iU, !(-0x1942 + -0x323 * 0x1 + 0x5ae * 0x5), !(-0x24 * 0x3 + -0x1724 + 0x1790)), + _$Dz.dispatchEvent(_$ix)) : _$ix = { + 'promise': _$ij, + 'reason': _$ir + }, + !_$Dt && (_$iX = _$Dz['on' + _$iU]) ? _$iX(_$ix) : _$iU === _$L8 && _$DR(Ee(0x1a0), _$ir); + }, _$LU = function(_$iU) { + _$b.QdUQR(_$Dn, _$DJ, _$Dz, function() { + var Es = a092750F, _$ij = { + 'Kckif': Es(0x231) + }, _$ir, _$ix = _$iU.facade, _$iX = _$iU.value; + if (_$Lj(_$iU) && (_$ir = _$Do(function() { + _$Dp ? _$L4.emit(_$ij.Kckif, _$iX, _$ix) : _$LQ(_$L8, _$ix, _$iX); + }), + _$iU.rejection = _$Dp || _$Lj(_$iU) ? -0x89e * 0x1 + -0x18f + 0x21 * 0x4f : -0x1 * 0x2293 + 0x18f7 + 0x99d, + _$ir.error)) + throw _$ir.value; + }); + }, _$Lj = function(_$iU) { + return _$b.GKPHr(-0x1343 * -0x1 + -0x1011 + 0x13 * -0x2b, _$iU.rejection) && !_$iU.parent; + }, _$Lr = function(_$iU) { + _$Dn(_$DJ, _$Dz, function() { + var Ea = a092750F + , _$ij = _$iU.facade; + _$Dp ? _$L4.emit(Ea(0x149), _$ij) : _$b.sGIJL(_$LQ, Ea(0x241), _$ij, _$iU.value); + }); + }, _$Lx = function(_$iU, _$ij, _$ir) { + var _$ix = { + 'eigee': function(_$iX, _$iT, _$iD, _$iL) { + return _$b.DTkqG(_$iX, _$iT, _$iD, _$iL); + } + }; + return function(_$iX) { + _$ix.eigee(_$iU, _$ij, _$iX, _$ir); + } + ; + }, _$LX = function(_$iU, _$ij, _$ir) { + _$iU.done || (_$iU.done = !(0x2f * 0x77 + 0x42 * 0x89 + -0x392b), + _$ir && (_$iU = _$ir), + _$iU.value = _$ij, + _$iU.state = 0x5 * 0xaf + -0x2 * 0x1105 + 0x1 * 0x1ea1, + _$LF(_$iU, !(0x1 * 0x1078 + -0x2 * 0x484 + 0x88 * -0xe))); + }, _$LT = function(_$iU, _$ij, _$ir) { + var Eg = iw; + if (!_$iU.done) { + _$iU.done = !(-0x9a2 + -0xd9 * 0x1f + -0x1 * -0x23e9), + _$ir && (_$iU = _$ir); + try { + if (_$iU.facade === _$ij) + throw new _$L2(Eg(0x188)); + var _$ix = _$L9(_$ij); + _$ix ? _$DC(function() { + var _$iX = { + 'done': !(-0x12df * 0x2 + 0x38 + 0x2587) + }; + try { + _$Dn(_$ix, _$ij, _$Lx(_$LT, _$iX, _$iU), _$b.wvahV(_$Lx, _$LX, _$iX, _$iU)); + } catch (_$iT) { + _$b.nlvrP(_$LX, _$iX, _$iT, _$iU); + } + }) : (_$iU.value = _$ij, + _$iU.state = -0xc07 * 0x2 + -0x7f7 + -0x2 * -0x1003, + _$LF(_$iU, !(0x7 * 0xf4 + -0x2170 + 0x1ac5))); + } catch (_$iX) { + _$LX({ + 'done': !(0x7b * -0x43 + 0x47f + 0x1bb3) + }, _$iX, _$iU); + } + } + }; + _$Dv && (_$L1 = (_$L0 = function(_$iU) { + _$Dg(this, _$L1), + _$b.GXpfj(_$De, _$iU), + _$Dn(_$DB, this); + var _$ij = _$b.uXoWw(_$DI, this); + try { + _$iU(_$Lx(_$LT, _$ij), _$Lx(_$LX, _$ij)); + } catch (_$ir) { + _$LX(_$ij, _$ir); + } + } + ).prototype, + (_$DB = function(_$iU) { + _$b.yeAFb(_$Dw, this, { + 'type': _$Df, + 'done': !(0x108c + 0xe * -0x65 + 0xb05 * -0x1), + 'notified': !(-0x78 * 0x16 + -0x2163 + -0xaed * -0x4), + 'parent': !(-0x4a * 0x1 + 0x74 + -0x29), + 'reactions': new _$Dy(), + 'rejection': !(0x15c8 + -0x8a * -0x42 + 0x395b * -0x1), + 'state': 0x0, + 'value': void (0x26e4 + 0x1977 * 0x1 + -0x405b) + }); + } + ).prototype = _$Dk(_$L1, iw(0x164), function(_$iU, _$ij) { + var _$ir = _$b.GXpfj(_$DI, this) + , _$ix = _$L5(_$Dq(this, _$L0)); + return _$ir.parent = !(-0x2b6 * -0x2 + -0x1 * -0x26ab + -0x1 * 0x2c17), + _$ix.ok = !_$Ds(_$iU) || _$iU, + _$ix.fail = _$Ds(_$ij) && _$ij, + _$ix.domain = _$Dp ? _$L4.domain : void (-0x9 * 0x5e + -0x19b7 + 0x1d05), + 0x1ad * -0xb + 0xb2 + -0x13 * -0xef === _$ir.state ? _$ir.reactions.add(_$ix) : _$DC(function() { + _$Lb(_$ix, _$ir); + }), + _$ix.promise; + }), + _$Dd = function() { + var _$iU = new _$DB() + , _$ij = _$DI(_$iU); + this.promise = _$iU, + this.resolve = _$b.tUMSG(_$Lx, _$LT, _$ij), + this.reject = _$Lx(_$LX, _$ij); + } + , + _$DG.f = _$L5 = function(_$iU) { + return _$b.mgsSr(_$iU, _$L0) || undefined === _$iU ? new _$Dd(_$iU) : _$L6(_$iU); + } + ), + _$Dh({ + 'global': !(-0xcb1 + -0x2a9 + 0xf5a), + 'constructor': !(0x1616 + 0x1337 + -0x294d), + 'wrap': !(0x1c * -0x101 + 0x1 * 0x10f5 + 0x23b * 0x5), + 'forced': _$Dv + }, { + 'Promise': _$L0 + }), + _$DZ(_$L0, _$Df, !(-0x16c6 + -0x7f2 + 0x1eb9), !(-0x8 * -0xab + -0x1af8 + 0x15a0)), + _$b.bQPsi(_$DA, _$Df); + var _$LD = _$F4(iw(0x1ed)) + , _$LL = !(0x18bb + -0x37 * 0x4f + -0x7c1); + try { + var _$LW = -0x1398 + 0xbc * 0x1c + -0x1f * 0x8 + , _$LV = { + 'next': function() { + return { + 'done': !!_$LW++ + }; + }, + 'return': function() { + _$LL = !(-0x1857 + -0x17fe + -0x3055 * -0x1); + } + }; + _$LV[_$LD] = function() { + return this; + } + , + Array.from(_$LV, function() { + throw 0xeae + 0x107 * 0xa + -0x18f2; + }); + } catch (_$iU) {} + var _$LH = _$DQ + , _$Lu = function(_$ij, _$ir) { + try { + if (!_$ir && !_$LL) + return !(-0x1 * 0x122b + 0xf79 * -0x1 + 0x21a5); + } catch (_$iT) { + return !(0x17d7 + 0x30 * 0xa8 + -0x12 * 0x313); + } + var _$ix = !(0x62 * -0x35 + 0x1675 * 0x1 + -0x22a); + try { + var _$iX = {}; + _$iX[_$LD] = function() { + return { + 'next': function() { + return { + 'done': _$ix = !(0x40d * -0x9 + -0xaae * 0x1 + 0xb * 0x449) + }; + } + }; + } + , + _$ij(_$iX); + } catch (_$iD) {} + return _$ix; + } + , _$LM = _$DE.CONSTRUCTOR || !_$b.yyUGr(_$Lu, function(_$ij) { + _$LH.all(_$ij).then(void (0x1681 + -0xbdb + -0xaa6), function() {}); + }) + , _$Lc = _$Z + , _$LO = _$bm + , _$Li = _$Dm + , _$LE = _$DF + , _$Lm = _$xj; + _$b.kaUuY(_$QU, { + 'target': iw(0x1d8), + 'stat': !(-0x2 * -0x115a + 0x1ec0 + 0x8e * -0x76), + 'forced': _$LM + }, { + 'all': function(_$ij) { + var _$ir = this + , _$ix = _$Li.f(_$ir) + , _$iX = _$ix.resolve + , _$iT = _$ix.reject + , _$iD = _$b.vuBfQ(_$LE, function() { + var _$iL = { + 'uHHgx': function(_$iM, _$ic) { + return _$iM(_$ic); + } + } + , _$iW = _$LO(_$ir.resolve) + , _$iV = [] + , _$iH = -0x12fe + 0x640 + 0xcbe + , _$iu = -0xa * -0x70 + 0x1c4a + -0x20a9; + _$Lm(_$ij, function(_$iM) { + var _$ic = _$iH++ + , _$iO = !(0x16c8 + -0x2 * -0x4cb + -0x5 * 0x679); + _$iu++, + _$Lc(_$iW, _$ir, _$iM).then(function(_$ii) { + _$iO || (_$iO = !(-0xabe * -0x1 + -0x1d2b + -0x1 * -0x126d), + _$iV[_$ic] = _$ii, + --_$iu || _$iL.uHHgx(_$iX, _$iV)); + }, _$iT); + }), + --_$iu || _$iX(_$iV); + }); + return _$iD.error && _$iT(_$iD.value), + _$ix.promise; + } + }); + var _$LN = _$QU + , _$LP = _$DE.CONSTRUCTOR; + _$DQ && _$DQ.prototype, + _$b.JZCvM(_$LN, { + 'target': iw(0x1d8), + 'proto': !(-0xa * 0x322 + 0x2672 + -0x1 * 0x71e), + 'forced': _$LP, + 'real': !(-0xc7 * -0x1d + -0x261f * -0x1 + -0x3caa) + }, { + 'catch': function(_$ij) { + return this.then(void (0x1 * 0x1283 + -0x1cb4 + 0xa31 * 0x1), _$ij); + } + }); + var _$LK = _$Z + , _$LB = _$bm + , _$Ld = _$Dm + , _$Lh = _$DF + , _$Lp = _$xj; + _$QU({ + 'target': _$b.Xouxt, + 'stat': !(0x39 * -0x39 + -0x1096 + 0x5db * 0x5), + 'forced': _$LM + }, { + 'race': function(_$ij) { + var _$ir = this + , _$ix = _$Ld.f(_$ir) + , _$iX = _$ix.reject + , _$iT = _$Lh(function() { + var _$iD = _$LB(_$ir.resolve); + _$Lp(_$ij, function(_$iL) { + _$LK(_$iD, _$ir, _$iL).then(_$ix.resolve, _$iX); + }); + }); + return _$iT.error && _$iX(_$iT.value), + _$ix.promise; + } + }); + var _$Lz = _$Dm; + _$QU({ + 'target': iw(0x1d8), + 'stat': !(-0x14f1 + 0xff0 + -0x7 * -0xb7), + 'forced': _$DE.CONSTRUCTOR + }, { + 'reject': function(_$ij) { + var _$ir = _$Lz.f(this); + return (0x20 * 0x74 + -0x1f57 + -0x3 * -0x59d, + _$ir.reject)(_$ij), + _$ir.promise; + } + }); + var _$Ln = _$FC + , _$Lk = _$b0 + , _$LZ = _$Dm + , _$LA = function(_$ij, _$ir) { + if (_$Ln(_$ij), + _$Lk(_$ir) && _$ir.constructor === _$ij) + return _$ir; + var _$ix = _$LZ.f(_$ij); + return (-0x1253 + -0x70d * 0x3 + 0x277a, + _$ix.resolve)(_$ir), + _$ix.promise; + } + , _$Le = _$QU + , _$Ls = _$DQ + , _$La = _$DE.CONSTRUCTOR + , _$Lg = _$LA + , _$Lq = _$b6(iw(0x1d8)) + , _$LJ = !_$La; + _$Le({ + 'target': iw(0x1d8), + 'stat': !(-0x6 * -0x5bb + 0x5a3 + -0x1 * 0x2805), + 'forced': !![] + }, { + 'resolve': function(_$ij) { + return _$b.hiUNu(_$Lg, _$LJ && this === _$Lq ? _$Ls : this, _$ij); + } + }); + var _$LC = _$Z + , _$LR = _$bm + , _$Lo = _$Dm + , _$Ly = _$DF + , _$LY = _$xj; + _$QU({ + 'target': iw(0x1d8), + 'stat': !(-0x16c2 + -0x14 * -0x12e + -0xd6), + 'forced': _$LM + }, { + 'allSettled': function(_$ij) { + var _$ir = { + 'fLcqi': function(_$iW, _$iV) { + return _$iW(_$iV); + } + } + , _$ix = this + , _$iX = _$Lo.f(_$ix) + , _$iT = _$iX.resolve + , _$iD = _$iX.reject + , _$iL = _$Ly(function() { + var _$iW = _$LR(_$ix.resolve) + , _$iV = [] + , _$iH = -0x127c + 0x1 * 0x20c3 + -0x11 * 0xd7 + , _$iu = 0x611 * -0x5 + 0x36d * 0xb + -0x759; + _$LY(_$ij, function(_$iM) { + var _$ic = _$iH++ + , _$iO = !(0xf26 * -0x1 + 0x57a + -0x1 * -0x9ad); + _$iu++, + _$LC(_$iW, _$ix, _$iM).then(function(_$ii) { + var Eq = a092750F; + _$iO || (_$iO = !(0x1a37 + 0x1c5b * -0x1 + 0x89 * 0x4), + _$iV[_$ic] = { + 'status': Eq(0x232), + 'value': _$ii + }, + --_$iu || _$iT(_$iV)); + }, function(_$ii) { + var EJ = a092750F; + _$iO || (_$iO = !(0x197 * -0x5 + 0x507 + -0x2c * -0x11), + _$iV[_$ic] = { + 'status': EJ(0x189), + 'reason': _$ii + }, + --_$iu || _$iT(_$iV)); + }); + }), + --_$iu || _$ir.fLcqi(_$iT, _$iV); + }); + return _$iL.error && _$iD(_$iL.value), + _$iX.promise; + } + }); + var _$LS = _$Z + , _$LG = _$bm + , _$Lf = _$b6 + , _$Lv = _$Dm + , _$Lt = _$DF + , _$LI = _$xj + , _$Lw = iw(0x233); + _$QU({ + 'target': iw(0x1d8), + 'stat': !(0xe7b + 0x850 + -0x16cb), + 'forced': _$LM + }, { + 'any': function(_$ij) { + var EC = iw + , _$ir = { + 'HDvCK': function(_$iV, _$iH, _$iu, _$iM) { + return _$iV(_$iH, _$iu, _$iM); + } + } + , _$ix = this + , _$iX = _$Lf(EC(0x1a4)) + , _$iT = _$Lv.f(_$ix) + , _$iD = _$iT.resolve + , _$iL = _$iT.reject + , _$iW = _$b.VEeFZ(_$Lt, function() { + var _$iV = _$LG(_$ix.resolve) + , _$iH = [] + , _$iu = -0x1ccd * 0x1 + 0x2234 * 0x1 + -0x567 + , _$iM = -0x8 * -0x107 + -0x13fb + -0x3 * -0x3ec + , _$ic = !(0x2b + 0x10dd * -0x2 + 0x2190); + _$LI(_$ij, function(_$iO) { + var _$ii = { + 'eaivb': function(_$iN, _$iP) { + return _$iN(_$iP); + } + } + , _$iE = _$iu++ + , _$im = !(0x7c3 + 0x236a + -0x4cc * 0x9); + _$iM++, + _$ir.HDvCK(_$LS, _$iV, _$ix, _$iO).then(function(_$iN) { + _$im || _$ic || (_$ic = !(0x1d6e + -0x1b6c + -0x202), + _$iD(_$iN)); + }, function(_$iN) { + _$im || _$ic || (_$im = !(-0x14a5 + -0x505 * 0x1 + 0x19aa * 0x1), + _$iH[_$iE] = _$iN, + --_$iM || _$ii.eaivb(_$iL, new _$iX(_$iH,_$Lw))); + }); + }), + --_$iM || _$iL(new _$iX(_$iH,_$Lw)); + }); + return _$iW.error && _$iL(_$iW.value), + _$iT.promise; + } + }); + var _$Ll = _$Dm; + _$QU({ + 'target': iw(0x1d8), + 'stat': !(0x1 * 0x161f + 0x3f1 + -0x1a10) + }, { + 'withResolvers': function() { + var _$ij = _$Ll.f(this); + return { + 'promise': _$ij.promise, + 'resolve': _$ij.resolve, + 'reject': _$ij.reject + }; + } + }); + var _$W0 = _$QU + , _$W1 = _$DQ + , _$W2 = _$U + , _$W3 = _$b6 + , _$W4 = _$h + , _$W5 = _$Tj + , _$W6 = _$LA + , _$W7 = _$W1 && _$W1.prototype; + _$W0({ + 'target': _$b.Xouxt, + 'proto': !(-0x20b0 + -0x1ba0 + 0x608 * 0xa), + 'real': !(-0xea8 + 0x13e9 + -0x5 * 0x10d), + 'forced': !!_$W1 && _$W2(function() { + _$W7.finally.call({ + 'then': function() {} + }, function() {}); + }) + }, { + 'finally': function(_$ij) { + var ER = iw + , _$ir = _$W5(this, _$b.cdOrb(_$W3, ER(0x1d8))) + , _$ix = _$W4(_$ij); + return this.then(_$ix ? function(_$iX) { + return _$W6(_$ir, _$ij()).then(function() { + return _$iX; + }); + } + : _$ij, _$ix ? function(_$iX) { + return _$W6(_$ir, _$ij()).then(function() { + throw _$iX; + }); + } + : _$ij); + } + }); + var _$W8 = _$D + , _$W9 = _$QD + , _$Wb = _$xX + , _$WF = _$v + , _$WQ = _$W8(''.charAt) + , _$WU = _$W8(''.charCodeAt) + , _$Wj = _$b.fJzlT(_$W8, ''.slice) + , _$Wr = function(_$ij) { + var _$ir = { + 'HqVXd': function(_$ix, _$iX) { + return _$ix(_$iX); + }, + 'vMvJY': function(_$ix, _$iX) { + return _$ix < _$iX; + }, + 'jRAHA': function(_$ix, _$iX, _$iT) { + return _$ix(_$iX, _$iT); + }, + 'mrEGU': function(_$ix, _$iX) { + return _$ix + _$iX; + }, + 'votoA': function(_$ix, _$iX) { + return _$b.taeBB(_$ix, _$iX); + }, + 'xUKwL': function(_$ix, _$iX) { + return _$ix << _$iX; + } + }; + return function(_$ix, _$iX) { + var _$iT, _$iD, _$iL = _$Wb(_$ir.HqVXd(_$WF, _$ix)), _$iW = _$W9(_$iX), _$iV = _$iL.length; + return _$ir.vMvJY(_$iW, 0x25f * -0xd + 0x1d47 + -0xc6 * -0x2) || _$iW >= _$iV ? _$ij ? '' : void (-0x200 + 0xd * 0x1c + 0x94) : _$ir.vMvJY(_$iT = _$WU(_$iL, _$iW), -0xd * 0x1945 + 0xe29f + 0x5e3 * 0x36) || _$iT > -0xe3be + -0x3 * 0x44f3 + 0x28e96 || _$iW + (-0x1 * -0xc51 + -0x1 * 0x1357 + 0x101 * 0x7) === _$iV || (_$iD = _$ir.jRAHA(_$WU, _$iL, _$ir.mrEGU(_$iW, -0x243 * -0x9 + 0x2 * -0x831 + -0x4 * 0xfe))) < 0x115bb * -0x1 + 0x14da7 + 0xa414 || _$iD > -0x2 * 0x2513 + -0x41 * 0x116 + 0xcb7 * 0x1d ? _$ij ? _$WQ(_$iL, _$iW) : _$iT : _$ij ? _$Wj(_$iL, _$iW, _$iW + (0x1d * 0xef + -0x19c9 + -0x148)) : _$ir.mrEGU(_$ir.votoA(_$iD, -0x7 * 0x33d7 + 0x113dd * -0x1 + -0x1ad5f * -0x2) + _$ir.xUKwL(_$iT - (0x10410 + -0x1 * -0x8f91 + 0xbba1 * -0x1), 0x83 * -0x1 + -0xc55 * 0x1 + 0x22 * 0x61), 0x4 * -0x6040 + -0xd31 * 0xb + 0x3121b); + } + ; + } + , _$Wx = { + 'codeAt': _$Wr(!(-0x3 * -0xab + -0xbd2 + 0x9d2)), + 'charAt': _$b.kfazF(_$Wr, !(-0x1 * -0x160d + -0x687 + -0xf86)) + }.charAt + , _$WX = _$xX + , _$WT = _$xv + , _$WD = _$Xa + , _$WL = _$Xg + , _$WW = iw(0x1c1) + , _$WV = _$WT.set + , _$WH = _$WT.getterFor(_$WW); + _$WD(String, iw(0x28e), function(_$ij) { + _$WV(this, { + 'type': _$WW, + 'string': _$WX(_$ij), + 'index': 0x0 + }); + }, function() { + var _$ij, _$ir = _$WH(this), _$ix = _$ir.string, _$iX = _$ir.index; + return _$b.gsnmE(_$iX, _$ix.length) ? _$b.eCEdd(_$WL, void (0x1060 + 0x566 + 0x2 * -0xae3), !(0x89 * 0x17 + 0x1 * 0x1a09 + -0x18 * 0x199)) : (_$ij = _$Wx(_$ix, _$iX), + _$ir.index += _$ij.length, + _$WL(_$ij, !(-0x1 * 0x1c6e + -0x2168 + 0x3dd7))); + }); + var _$Wu = _$b1.Promise + , _$WM = { + 'CSSRuleList': 0x0, + 'CSSStyleDeclaration': 0x0, + 'CSSValueList': 0x0, + 'ClientRectList': 0x0, + 'DOMRectList': 0x0, + 'DOMStringList': 0x0, + 'DOMTokenList': 0x1, + 'DataTransferItemList': 0x0, + 'FileList': 0x0, + 'HTMLAllCollection': 0x0, + 'HTMLCollection': 0x0, + 'HTMLFormElement': 0x0, + 'HTMLSelectElement': 0x0, + 'MediaList': 0x0, + 'MimeTypeArray': 0x0, + 'NamedNodeMap': 0x0, + 'NodeList': 0x1, + 'PaintRequestList': 0x0, + 'Plugin': 0x0, + 'PluginArray': 0x0, + 'SVGLengthList': 0x0, + 'SVGNumberList': 0x0, + 'SVGPathSegList': 0x0, + 'SVGPointList': 0x0, + 'SVGStringList': 0x0, + 'SVGTransformList': 0x0, + 'SourceBufferList': 0x0, + 'StyleSheetList': 0x0, + 'TextTrackCueList': 0x0, + 'TextTrackList': 0x0, + 'TouchList': 0x0 + } + , _$Wc = _$V + , _$WO = _$XV + , _$Wi = _$rs; + for (var _$WE in _$WM) + _$WO(_$Wc[_$WE], _$WE), + _$Wi[_$WE] = _$Wi.Array; + var _$Wm = _$Wu + , _$WN = _$Dm + , _$WP = _$DF; + _$b.JbIDx(_$QU, { + 'target': iw(0x1d8), + 'stat': !(0x21 * 0x7f + 0xb1d * 0x1 + -0x1b7c), + 'forced': !(-0x23c4 + -0x7c7 * 0x1 + -0x2b8b * -0x1) + }, { + 'try': function(_$ij) { + var _$ir = _$WN.f(this) + , _$ix = _$WP(_$ij); + return (_$ix.error ? _$ir.reject : _$ir.resolve)(_$ix.value), + _$ir.promise; + } + }); + var _$WK = _$Wm + , _$WB = _$QD + , _$Wd = _$xX + , _$Wh = _$v + , _$Wp = RangeError + , _$Wz = _$D + , _$Wn = _$QV + , _$Wk = _$xX + , _$WZ = _$v + , _$WA = _$Wz(function(_$ij) { + var Eo = iw + , _$ir = _$Wd(_$Wh(this)) + , _$ix = '' + , _$iX = _$WB(_$ij); + if (_$iX < -0x1a * -0x161 + -0x874 * 0x1 + -0x1b66 || _$iX === (-0x1a68 + 0x1007 + -0x2 * -0x531) / (-0x70c + 0x95 * 0x1d + 0x3 * -0x347)) + throw new _$Wp(Eo(0x1c6)); + for (; _$b.iAilt(_$iX, -0x2069 + 0x264a * -0x1 + 0x46b3); (_$iX >>>= -0x25 * -0xe9 + 0x361 + -0x10f * 0x23) && (_$ir += _$ir)) + 0x1a2c + -0x1e * -0x9b + 0xd * -0x369 & _$iX && (_$ix += _$ir); + return _$ix; + }) + , _$We = _$b.cdOrb(_$Wz, ''.slice) + , _$Ws = Math.ceil + , _$Wa = function(_$ij) { + return function(_$ir, _$ix, _$iX) { + var _$iT, _$iD, _$iL = _$Wk(_$WZ(_$ir)), _$iW = _$Wn(_$ix), _$iV = _$iL.length, _$iH = void (0x1ba + -0xf0 * -0x5 + -0x66a) === _$iX ? '\x20' : _$Wk(_$iX); + return _$b.OmNwY(_$iW, _$iV) || '' === _$iH ? _$iL : ((_$iD = _$WA(_$iH, _$Ws((_$iT = _$iW - _$iV) / _$iH.length))).length > _$iT && (_$iD = _$We(_$iD, -0x894 + 0x8ab + -0x17, _$iT)), + _$ij ? _$iL + _$iD : _$b.UXEgM(_$iD, _$iL)); + } + ; + } + , _$Wg = _$D + , _$Wq = _$U + , _$WJ = { + 'start': _$Wa(!(-0x91 * 0x2f + -0x26d * 0xb + 0x354f)), + 'end': _$Wa(!(0x1b4f + 0x1e8b + -0x39da)) + }.start + , _$WC = RangeError + , _$WR = isFinite + , _$Wo = Math.abs + , _$Wy = Date.prototype + , _$WY = _$Wy.toISOString + , _$WS = _$Wg(_$Wy.getTime) + , _$WG = _$Wg(_$Wy.getUTCDate) + , _$Wf = _$Wg(_$Wy.getUTCFullYear) + , _$Wv = _$Wg(_$Wy.getUTCHours) + , _$Wt = _$Wg(_$Wy.getUTCMilliseconds) + , _$WI = _$Wg(_$Wy.getUTCMinutes) + , _$Ww = _$b.kowBd(_$Wg, _$Wy.getUTCMonth) + , _$Wl = _$Wg(_$Wy.getUTCSeconds) + , _$V0 = _$Wq(function() { + return _$b.cCJsI !== _$WY.call(new Date(-(0x3054f2d5cf7f + -0x8078e0063 * 0x1e4 + 0x8c849 * 0x1673a3e))); + }) || !_$Wq(function() { + _$WY.call(new Date(NaN)); + }) ? function() { + var Ey = iw; + if (!_$WR(_$WS(this))) + throw new _$WC(Ey(0x252)); + var _$ij = this + , _$ir = _$b.LZbEY(_$Wf, _$ij) + , _$ix = _$b.snYYn(_$Wt, _$ij) + , _$iX = _$ir < -0x3 * 0x69 + -0x21e1 + 0x231c ? '-' : _$ir > 0x27b7 + 0x1 * -0x152 + 0xaa ? '+' : ''; + return _$b.RwlqE(_$b.TJkjs(_$iX + _$WJ(_$Wo(_$ir), _$iX ? -0x17a2 + 0x1 * -0x1b55 + -0x13 * -0x2af : -0x3ac * 0x3 + -0x1 * -0x557 + 0x1f * 0x2f, -0x255b + 0xf * 0xb5 + -0x20 * -0xd6) + '-' + _$b.QdUQR(_$WJ, _$b.vuBfQ(_$Ww, _$ij) + (-0xb4a * -0x1 + -0x1 * -0x38b + -0xed4), -0x12a8 + 0x1376 + 0x33 * -0x4, -0x19cc + -0x263d + 0xd * 0x4ed), '-') + _$WJ(_$WG(_$ij), 0x14f0 + -0xb35 * 0x3 + -0x43b * -0x3, 0x266 * -0xb + 0x207 * -0xd + 0x17 * 0x24b) + 'T', _$WJ(_$Wv(_$ij), 0x8e5 * 0x2 + 0x2552 * -0x1 + 0x1 * 0x138a, -0xac8 + -0x3 * -0x6a1 + 0x103 * -0x9)) + ':' + _$WJ(_$WI(_$ij), -0x1 * 0x1304 + -0x1f0d + 0x3213 * 0x1, -0x1a6b + 0x1f * 0x10f + -0x666) + ':' + _$WJ(_$Wl(_$ij), 0x3ce + -0x723 + 0xab * 0x5, 0xa58 + 0xef * -0x22 + 0x1566) + '.' + _$WJ(_$ix, 0x1fd9 * 0x1 + 0x1e68 + -0x101 * 0x3e, 0x1 * -0x265f + -0x2070 + 0x46cf) + 'Z'; + } + : _$WY + , _$V1 = _$Z + , _$V2 = _$bC + , _$V3 = _$FQ + , _$V4 = _$V0 + , _$V5 = _$N; + _$QU({ + 'target': iw(0x273), + 'proto': !(0xbf2 + 0x1218 + -0x602 * 0x5), + 'forced': _$U(function() { + return _$b.FpYpU(null, new Date(NaN).toJSON()) || -0xa99 + 0x1 * -0x1ce4 + 0x277e !== _$V1(Date.prototype.toJSON, { + 'toISOString': function() { + return 0x2430 + -0x2db * -0x1 + -0x270a; + } + }); + }) + }, { + 'toJSON': function(_$ij) { + var EY = iw + , _$ir = _$V2(this) + , _$ix = _$V3(_$ir, _$b.goGRd); + return EY(0x1f3) != typeof _$ix || isFinite(_$ix) ? _$b.WsUzz in _$ir || _$b.PsggH(EY(0x273), _$V5(_$ir)) ? _$ir.toISOString() : _$V1(_$V4, _$ir) : null; + } + }); + var _$V6 = _$Qr + , _$V7 = _$h + , _$V8 = _$N + , _$V9 = _$xX + , _$Vb = _$b.LZbEY(_$D, [].push) + , _$VF = _$QU + , _$VQ = _$b6 + , _$VU = _$O + , _$Vj = _$Z + , _$Vr = _$D + , _$Vx = _$U + , _$VX = _$h + , _$VT = _$bu + , _$VD = _$UK + , _$VL = function(_$ij) { + var ES = iw; + if (_$b.oPdbP(_$V7, _$ij)) + return _$ij; + if (_$V6(_$ij)) { + for (var _$ir = _$ij.length, _$ix = [], _$iX = 0x229e + 0x20de + -0x437c; _$b.djjtR(_$iX, _$ir); _$iX++) { + var _$iT = _$ij[_$iX]; + ES(0x182) == typeof _$iT ? _$Vb(_$ix, _$iT) : _$b.ijcCB(ES(0x1f3), typeof _$iT) && _$b.PHIHO(ES(0x194), _$V8(_$iT)) && _$b.PsggH(ES(0x28e), _$V8(_$iT)) || _$Vb(_$ix, _$b.LZbEY(_$V9, _$iT)); + } + var _$iD = _$ix.length + , _$iL = !(0x1f41 + -0x1 * -0x1861 + 0x1 * -0x37a2); + return function(_$iW, _$iV) { + if (_$iL) + return _$iL = !(0x12ff + -0x5c1 + -0x1 * 0xd3d), + _$iV; + if (_$V6(this)) + return _$iV; + for (var _$iH = -0xfa7 + 0x3df + -0x8 * -0x179; _$iH < _$iD; _$iH++) + if (_$ix[_$iH] === _$iW) + return _$iV; + } + ; + } + } + , _$VW = _$bT + , _$VV = String + , _$VH = _$VQ(_$b.aalCm, iw(0x203)) + , _$Vu = _$Vr(/./.exec) + , _$VM = _$Vr(''.charAt) + , _$Vc = _$Vr(''.charCodeAt) + , _$VO = _$Vr(''.replace) + , _$Vi = _$Vr((-0x1843 + 0x9e4 + 0xe60).toString) + , _$VE = /[\uD800-\uDFFF]/g + , _$Vm = /^[\uD800-\uDBFF]$/ + , _$VN = /^[\uDC00-\uDFFF]$/ + , _$VP = !_$VW || _$Vx(function() { + var EG = iw + , _$ij = _$b.DjSUN(_$VQ, EG(0x198))(EG(0x2ac)); + return _$b.niKdg(EG(0x26e), _$b.kUADD(_$VH, [_$ij])) || _$b.FpYpU('{}', _$VH({ + 'a': _$ij + })) || '{}' !== _$VH(Object(_$ij)); + }) + , _$VK = _$Vx(function() { + var Ef = iw; + return Ef(0x28d) !== _$VH('\ufffd\ufffd') || Ef(0x187) !== _$VH('\ufffd'); + }) + , _$VB = function(_$ij, _$ir) { + var _$ix = _$VD(arguments) + , _$iX = _$VL(_$ir); + if (_$VX(_$iX) || void (-0x216f + -0x1d57 + 0x3ec6) !== _$ij && !_$VT(_$ij)) + return _$ix[-0x5bc * -0x1 + 0x2 * 0xa04 + 0x527 * -0x5] = function(_$iT, _$iD) { + if (_$b.ioNJg(_$VX, _$iX) && (_$iD = _$Vj(_$iX, this, _$VV(_$iT), _$iD)), + !_$VT(_$iD)) + return _$iD; + } + , + _$b.OaylU(_$VU, _$VH, null, _$ix); + } + , _$Vd = function(_$ij, _$ir, _$ix) { + var _$iX = _$VM(_$ix, _$b.taeBB(_$ir, 0x16b5 + -0x1fe1 + 0x3 * 0x30f)) + , _$iT = _$VM(_$ix, _$ir + (-0x157 * -0x15 + 0x38b * 0x3 + -0x26c3 * 0x1)); + return _$Vu(_$Vm, _$ij) && !_$Vu(_$VN, _$iT) || _$Vu(_$VN, _$ij) && !_$Vu(_$Vm, _$iX) ? '\\u' + _$b.guaEG(_$Vi, _$Vc(_$ij, 0x235a + -0x8dd * -0x1 + -0x2c37), -0x2586 + 0x57e + 0x2018) : _$ij; + }; + _$VH && _$b.tUMSG(_$VF, { + 'target': iw(0x246), + 'stat': !(-0x1337 * -0x1 + 0xe03 + -0x213a), + 'arity': 0x3, + 'forced': _$VP || _$VK + }, { + 'stringify': function(_$ij, _$ir, _$ix) { + var _$iX = _$VD(arguments) + , _$iT = _$VU(_$VP ? _$VB : _$VH, null, _$iX); + return _$VK && _$b.OYYwc == typeof _$iT ? _$b.YToRP(_$VO, _$iT, _$VE, _$Vd) : _$iT; + } + }); + var _$Vh = _$b1 + , _$Vp = _$O; + _$Vh.JSON || (_$Vh.JSON = { + 'stringify': JSON.stringify + }); + var _$Vz = function(_$ij, _$ir, _$ix) { + return _$Vp(_$Vh.JSON.stringify, null, arguments); + } + , _$Vn = _$Vz + , _$Vk = _$jr.filter; + _$b.CgUbU(_$QU, { + 'target': iw(0x25c), + 'proto': !(-0x2518 + -0x26dd * 0x1 + -0x4bf5 * -0x1), + 'forced': !_$U6(iw(0x25f)) + }, { + 'filter': function(_$ij) { + return _$Vk(this, _$ij, _$b.iAilt(arguments.length, 0x1fc + 0x14f4 + -0x13 * 0x135) ? arguments[-0x3a * -0x38 + -0xde + -0xbd1] : void (-0x235 * 0x5 + -0x35d + -0x26 * -0x61)); + } + }); + var _$VZ = _$b.KwoGN(_$UH, iw(0x25c), iw(0x25f)) + , _$VA = _$L + , _$Ve = _$VZ + , _$Vs = Array.prototype + , _$Va = function(_$ij) { + var _$ir = _$ij.filter; + return _$b.ODlwn(_$ij, _$Vs) || _$VA(_$Vs, _$ij) && _$ir === _$Vs.filter ? _$Ve : _$ir; + } + , _$Vg = _$bc + , _$Vq = TypeError + , _$VJ = function(_$ij, _$ir) { + var Ev = iw; + if (!delete _$ij[_$ir]) + throw new _$Vq(Ev(0x18f) + _$Vg(_$ir) + _$b.CybjO + _$Vg(_$ij)); + } + , _$VC = _$UK + , _$VR = Math.floor + , _$Vo = function(_$ij, _$ir) { + var _$ix = _$ij.length; + if (_$b.kukKa(_$ix, 0x1567 * -0x1 + -0x1 * -0x1159 + 0x416)) + for (var _$iX, _$iT, _$iD = -0x9b1 * 0x3 + 0xeb + 0x1c29; _$iD < _$ix; ) { + for (_$iT = _$iD, + _$iX = _$ij[_$iD]; _$iT && _$b.kfKeH(_$ir(_$ij[_$iT - (0x1d4d * 0x1 + 0x105a + -0x2da6)], _$iX), -0x696 + 0x2 * 0x821 + -0x9ac); ) + _$ij[_$iT] = _$ij[--_$iT]; + _$iT !== _$iD++ && (_$ij[_$iT] = _$iX); + } + else { + for (var _$iL = _$VR(_$ix / (0x10f * 0x3 + -0x6c3 * 0x1 + 0x398)), _$iW = _$Vo(_$VC(_$ij, 0x5 * 0x4e8 + 0x2 * -0x11 + -0x1866, _$iL), _$ir), _$iV = _$Vo(_$b.WwEuF(_$VC, _$ij, _$iL), _$ir), _$iH = _$iW.length, _$iu = _$iV.length, _$iM = 0x523 * -0x7 + 0xa * 0xd3 + 0x1bb7, _$ic = 0x438 + 0x134d + -0x1785; _$iM < _$iH || _$ic < _$iu; ) + _$ij[_$b.puqAg(_$iM, _$ic)] = _$b.obOLx(_$iM, _$iH) && _$b.kukKa(_$ic, _$iu) ? _$b.AfSLZ(_$ir, _$iW[_$iM], _$iV[_$ic]) <= 0xee1 * 0x1 + 0x1 * -0x1454 + 0x573 ? _$iW[_$iM++] : _$iV[_$ic++] : _$iM < _$iH ? _$iW[_$iM++] : _$iV[_$ic++]; + } + return _$ij; + } + , _$Vy = _$Vo + , _$VY = _$b7.match(/firefox\/(\d+)/i) + , _$VS = !!_$VY && +_$VY[0x1 * -0x1712 + -0x111d + 0x2830] + , _$VG = /MSIE|Trident/.test(_$b7) + , _$Vf = _$b7.match(/AppleWebKit\/(\d+)\./) + , _$Vv = !!_$Vf && +_$Vf[0x24c2 * 0x1 + -0x3 * -0x43 + -0x258a] + , _$Vt = _$QU + , _$VI = _$D + , _$Vw = _$bm + , _$Vl = _$bC + , _$H0 = _$Qu + , _$H1 = _$VJ + , _$H2 = _$xX + , _$H3 = _$U + , _$H4 = _$Vy + , _$H5 = _$UI + , _$H6 = _$VS + , _$H7 = _$VG + , _$H8 = _$bj + , _$H9 = _$Vv + , _$Hb = [] + , _$HF = _$VI(_$Hb.sort) + , _$HQ = _$VI(_$Hb.push) + , _$HU = _$H3(function() { + _$Hb.sort(void (-0x8e7 + 0x260e + -0x1 * 0x1d27)); + }) + , _$Hj = _$H3(function() { + _$Hb.sort(null); + }) + , _$Hr = _$H5(_$b.yZUmy) + , _$Hx = !_$H3(function() { + var Et = iw; + if (_$H8) + return _$H8 < 0x1 * 0x2d5 + 0x1f * -0x1 + -0x270; + if (!(_$H6 && _$H6 > 0x19f6 + 0x731 + -0x2124)) { + if (_$H7) + return !(-0x41c + 0x11a7 + 0xd8b * -0x1); + if (_$H9) + return _$H9 < 0x244d * 0x1 + 0x175e + -0x4 * 0xe54; + var _$ij, _$ir, _$ix, _$iX, _$iT = ''; + for (_$ij = 0x62e + 0x13ee + -0x19db; _$ij < 0xec1 * 0x1 + 0x1 * -0x3ff + -0xa76; _$ij++) { + switch (_$ir = String.fromCharCode(_$ij), + _$ij) { + case 0x5b9 + 0x47 * 0x1f + -0xe10: + case 0x2 * -0x250 + 0xc * -0x281 + -0x22f1 * -0x1: + case 0x2034 + 0x1b7 * -0x2 + -0x1c80: + case 0x21c4 + -0x17 * 0x79 + -0x33b * 0x7: + _$ix = -0x1ed * -0x8 + 0x2222 + 0x1 * -0x3187; + break; + case -0x69b + -0x1 * -0x16e3 + -0x1004: + case 0x45a + 0x1e96 + 0x13 * -0x1d3: + _$ix = 0x3b8 + -0x507 + 0x153; + break; + default: + _$ix = -0x29 * -0x7f + 0x20f7 + -0x2 * 0x1aa6; + } + for (_$iX = -0xa23 + -0x1191 * -0x1 + -0x76e; _$b.iyrRt(_$iX, -0x209 + 0x1278 + -0x1040); _$iX++) + _$Hb.push({ + 'k': _$ir + _$iX, + 'v': _$ix + }); + } + for (_$Hb.sort(function(_$iD, _$iL) { + return _$iL.v - _$iD.v; + }), + _$iX = 0x828 * -0x1 + -0x1cfc + 0x1 * 0x2524; _$iX < _$Hb.length; _$iX++) + _$ir = _$Hb[_$iX].k.charAt(-0x253a + -0x1212 + 0x374c * 0x1), + _$iT.charAt(_$b.WxyCd(_$iT.length, -0x1 * 0x2353 + -0x1ebf + -0x11 * -0x3e3)) !== _$ir && (_$iT += _$ir); + return _$b.GKPHr(Et(0x1d0), _$iT); + } + }); + _$Vt({ + 'target': _$b.afCuI, + 'proto': !(0xbbc * -0x2 + -0x60b + 0x1d83), + 'forced': _$HU || !_$Hj || !_$Hr || !_$Hx + }, { + 'sort': function(_$ij) { + void (-0x2337 * 0x1 + -0xd80 + 0x30b7) !== _$ij && _$Vw(_$ij); + var _$ir = _$Vl(this); + if (_$Hx) + return void (0x247a * 0x1 + 0x60c * -0x1 + -0x1e6e) === _$ij ? _$HF(_$ir) : _$HF(_$ir, _$ij); + var _$ix, _$iX, _$iT = [], _$iD = _$b.LRfHW(_$H0, _$ir); + for (_$iX = -0x1 * -0x1972 + 0x1cbb + -0x362d; _$iX < _$iD; _$iX++) + _$b.uFida(_$iX, _$ir) && _$HQ(_$iT, _$ir[_$iX]); + for (_$H4(_$iT, function(_$iL) { + return function(_$iW, _$iV) { + return void (-0x1 * -0x1f3d + 0x738 + -0x5 * 0x7b1) === _$iV ? -(-0x14af * 0x1 + 0x10fc + -0x13c * -0x3) : void (-0x1398 + -0x1808 + -0x8 * -0x574) === _$iW ? -0x337 * 0xb + -0xd2 * -0x23 + 0x6a8 : void (0x2e * -0xcb + -0x8 * -0x16 + 0x23ca) !== _$iL ? +_$iL(_$iW, _$iV) || -0x164 * 0x12 + -0x8f6 + 0x26 * 0xe5 : _$H2(_$iW) > _$H2(_$iV) ? 0x1 * 0xc5e + -0xd8e + 0x131 : -(-0x1d90 * 0x1 + -0xb7 * 0x29 + -0x13a * -0x30); + } + ; + }(_$ij)), + _$ix = _$b.QefQe(_$H0, _$iT), + _$iX = 0x7 * -0x26b + 0xe7 * 0xb + -0x4 * -0x1c0; _$iX < _$ix; ) + _$ir[_$iX] = _$iT[_$iX++]; + for (; _$iX < _$iD; ) + _$H1(_$ir, _$iX++); + return _$ir; + } + }); + var _$HX = _$UH(_$b.afCuI, iw(0x261)) + , _$HT = _$L + , _$HD = _$HX + , _$HL = Array.prototype + , _$HW = function(_$ij) { + var _$ir = _$ij.sort; + return _$b.cokVS(_$ij, _$HL) || _$b.uCIty(_$HT, _$HL, _$ij) && _$ir === _$HL.sort ? _$HD : _$ir; + } + , _$HV = _$bC + , _$HH = _$r8; + _$QU({ + 'target': iw(0x15f), + 'stat': !(0x1 * 0x2c4 + 0x4 * -0x1c1 + 0x440), + 'forced': _$U(function() { + _$HH(-0x13ef + 0x173f + -0x34f); + }) + }, { + 'keys': function(_$ij) { + return _$HH(_$HV(_$ij)); + } + }); + var _$Hu = _$b1.Object.keys + , _$HM = _$Uv.includes; + _$QU({ + 'target': iw(0x25c), + 'proto': !(0x1ac2 + -0x52c + -0x399 * 0x6), + 'forced': _$U(function() { + return !Array(-0x58 * 0x6d + -0x1 * 0x10e1 + -0x121e * -0x3).includes(); + }) + }, { + 'includes': function(_$ij) { + return _$HM(this, _$ij, _$b.iAilt(arguments.length, -0x1 * -0x1b2f + 0x4fa * -0x5 + 0x1c * -0x15) ? arguments[0x1868 + 0x19f0 + -0x31 * 0x107] : void (-0xf14 + 0x147 * 0xb + 0x107)); + } + }); + var _$Hc = _$UH(_$b.afCuI, iw(0x15d)) + , _$HO = _$b0 + , _$Hi = _$N + , _$HE = _$F4(iw(0x21f)) + , _$Hm = function(_$ij) { + var EI = iw, _$ir; + return _$HO(_$ij) && (_$b.XjZGS(void (0x166 * 0x1 + -0x10bd * -0x1 + -0x1223), _$ir = _$ij[_$HE]) ? !!_$ir : EI(0x253) === _$Hi(_$ij)); + } + , _$HN = TypeError + , _$HP = _$b.ioNJg(_$F4, iw(0x21f)) + , _$HK = _$QU + , _$HB = function(_$ij) { + if (_$Hm(_$ij)) + throw new _$HN(_$b.NLfBt); + return _$ij; + } + , _$Hd = _$v + , _$Hh = _$xX + , _$Hp = function(_$ij) { + var Ew = iw + , _$ir = /./; + try { + Ew(0x24e)[_$ij](_$ir); + } catch (_$ix) { + try { + return _$ir[_$HP] = !(0x19a2 + 0x2230 + -0x3bd1), + Ew(0x24e)[_$ij](_$ir); + } catch (_$iX) {} + } + return !(0x2346 + -0x1846 + -0x1 * 0xaff); + } + , _$Hz = _$D(''.indexOf); + _$HK({ + 'target': iw(0x28e), + 'proto': !(0x1b3b + 0x968 + -0x24a3 * 0x1), + 'forced': !_$Hp(_$b.rFWuw) + }, { + 'includes': function(_$ij) { + return !!~_$Hz(_$Hh(_$Hd(this)), _$Hh(_$b.VPyGQ(_$HB, _$ij)), arguments.length > -0x740 + 0xd7c + -0x37 * 0x1d ? arguments[-0x1a * 0x15b + -0x19e9 + 0x3d28] : void (0x1be8 + -0x211a + 0x532)); + } + }); + var _$Hn = _$UH(_$b.SSaPk, iw(0x15d)) + , _$Hk = _$L + , _$HZ = _$Hc + , _$HA = _$Hn + , _$He = Array.prototype + , _$Hs = String.prototype + , _$Ha = function(_$ij) { + var El = iw + , _$ir = _$ij.includes; + return _$ij === _$He || _$Hk(_$He, _$ij) && _$ir === _$He.includes ? _$HZ : El(0x182) == typeof _$ij || _$ij === _$Hs || _$Hk(_$Hs, _$ij) && _$ir === _$Hs.includes ? _$HA : _$ir; + } + , _$Hg = {} + , _$Hq = _$N + , _$HJ = _$w + , _$HC = _$ja.f + , _$HR = _$UK + , _$Ho = iw(0x146) == typeof window && window && Object.getOwnPropertyNames ? Object.getOwnPropertyNames(window) : []; + _$Hg.f = function(_$ij) { + var m0 = iw; + return _$Ho && m0(0x1bc) === _$Hq(_$ij) ? function(_$ir) { + try { + return _$HC(_$ir); + } catch (_$ix) { + return _$HR(_$Ho); + } + }(_$ij) : _$HC(_$HJ(_$ij)); + } + ; + var _$Hy = {} + , _$HY = _$F4; + _$Hy.f = _$HY; + var _$HS = _$b1 + , _$HG = _$by + , _$Hf = _$Hy + , _$Hv = _$Fs.f + , _$Ht = function(_$ij) { + var _$ir = _$HS.Symbol || (_$HS.Symbol = {}); + _$HG(_$ir, _$ij) || _$Hv(_$ir, _$ij, { + 'value': _$Hf.f(_$ij) + }); + } + , _$HI = _$Z + , _$Hw = _$b6 + , _$Hl = _$F4 + , _$u0 = _$X3 + , _$u1 = function() { + var m1 = iw + , _$ij = _$b.jvQyT(_$Hw, m1(0x198)) + , _$ir = _$ij && _$ij.prototype + , _$ix = _$ir && _$ir.valueOf + , _$iX = _$b.onOzh(_$Hl, m1(0x1a6)); + _$ir && !_$ir[_$iX] && _$u0(_$ir, _$iX, function(_$iT) { + return _$HI(_$ix, this); + }, { + 'arity': 0x1 + }); + } + , _$u2 = _$QU + , _$u3 = _$V + , _$u4 = _$Z + , _$u5 = _$D + , _$u6 = _$z + , _$u7 = _$bT + , _$u8 = _$U + , _$u9 = _$by + , _$ub = _$L + , _$uF = _$FC + , _$uQ = _$w + , _$uU = _$Fr + , _$uj = _$xX + , _$ur = _$J + , _$ux = _$rm + , _$uX = _$r8 + , _$uT = _$ja + , _$uD = _$Hg + , _$uL = _$jf + , _$uW = _$p + , _$uV = _$Fs + , _$uH = _$r5 + , _$uu = _$A + , _$uM = _$X3 + , _$uc = _$T0 + , _$uO = _$bg + , _$ui = _$jg + , _$uE = _$bv + , _$um = _$F4 + , _$uN = _$Hy + , _$uP = _$Ht + , _$uK = _$u1 + , _$uB = _$XV + , _$ud = _$xv + , _$uh = _$jr.forEach + , _$up = _$b.mhwAA(_$ju, iw(0x1c3)) + , _$uz = _$b.cHbyx + , _$un = iw(0x1d1) + , _$uk = _$ud.set + , _$uZ = _$ud.getterFor(_$uz) + , _$uA = Object[_$un] + , _$ue = _$u3.Symbol + , _$us = _$ue && _$ue[_$un] + , _$ua = _$u3.RangeError + , _$ug = _$u3.TypeError + , _$uq = _$u3.QObject + , _$uJ = _$uW.f + , _$uC = _$uV.f + , _$uR = _$uD.f + , _$uo = _$uu.f + , _$uy = _$b.MEyoW(_$u5, [].push) + , _$uY = _$uO(iw(0x27c)) + , _$uS = _$b.VEeFZ(_$uO, _$b.QElQl) + , _$uG = _$b.lVDEr(_$uO, iw(0x225)) + , _$uf = !_$uq || !_$uq[_$un] || !_$uq[_$un].findChild + , _$uv = function(_$ij, _$ir, _$ix) { + var _$iX = _$uJ(_$uA, _$ir); + _$iX && delete _$uA[_$ir], + _$uC(_$ij, _$ir, _$ix), + _$iX && _$ij !== _$uA && _$uC(_$uA, _$ir, _$iX); + } + , _$ut = _$u6 && _$u8(function() { + return 0x2070 + 0x76 * 0x5 + -0x22b7 !== _$ux(_$uC({}, 'a', { + 'get': function() { + return _$uC(this, 'a', { + 'value': 0x7 + }).a; + } + })).a; + }) ? _$uv : _$uC + , _$uI = function(_$ij, _$ir) { + var _$ix = _$uY[_$ij] = _$ux(_$us); + return _$b.hiUNu(_$uk, _$ix, { + 'type': _$uz, + 'tag': _$ij, + 'description': _$ir + }), + _$u6 || (_$ix.description = _$ir), + _$ix; + } + , _$uw = function(_$ij, _$ir, _$ix) { + _$ij === _$uA && _$uw(_$uS, _$ir, _$ix), + _$uF(_$ij); + var _$iX = _$uU(_$ir); + return _$uF(_$ix), + _$u9(_$uY, _$iX) ? (_$ix.enumerable ? (_$u9(_$ij, _$up) && _$ij[_$up][_$iX] && (_$ij[_$up][_$iX] = !(-0x1958 * 0x1 + 0x1 * -0xeb3 + 0x280c)), + _$ix = _$ux(_$ix, { + 'enumerable': _$b.qWsOV(_$ur, -0xf8e + -0x892 + 0x1820, !(0x218 * -0xd + -0x1 * 0x146d + 0x2fa6)) + })) : (_$u9(_$ij, _$up) || _$uC(_$ij, _$up, _$ur(0x3b * 0x8 + 0xbb7 * -0x1 + -0x2 * -0x4f0, _$ux(null))), + _$ij[_$up][_$iX] = !(-0x2119 + 0x42 * -0x40 + -0x1 * -0x3199)), + _$ut(_$ij, _$iX, _$ix)) : _$uC(_$ij, _$iX, _$ix); + } + , _$ul = function(_$ij, _$ir) { + _$uF(_$ij); + var _$ix = _$uQ(_$ir) + , _$iX = _$uX(_$ix).concat(_$M3(_$ix)); + return _$b.uCIty(_$uh, _$iX, function(_$iT) { + _$u6 && !_$u4(_$M0, _$ix, _$iT) || _$uw(_$ij, _$iT, _$ix[_$iT]); + }), + _$ij; + } + , _$M0 = function(_$ij) { + var _$ir = _$uU(_$ij) + , _$ix = _$u4(_$uo, this, _$ir); + return !(_$b.EXENZ(this, _$uA) && _$u9(_$uY, _$ir) && !_$u9(_$uS, _$ir)) && (!(_$ix || !_$u9(this, _$ir) || !_$b.QWrtu(_$u9, _$uY, _$ir) || _$u9(this, _$up) && this[_$up][_$ir]) || _$ix); + } + , _$M1 = function(_$ij, _$ir) { + var _$ix = _$uQ(_$ij) + , _$iX = _$uU(_$ir); + if (_$ix !== _$uA || !_$b.guaEG(_$u9, _$uY, _$iX) || _$u9(_$uS, _$iX)) { + var _$iT = _$uJ(_$ix, _$iX); + return !_$iT || !_$u9(_$uY, _$iX) || _$u9(_$ix, _$up) && _$ix[_$up][_$iX] || (_$iT.enumerable = !(-0x36f * -0x9 + -0x1a7f + -0x468)), + _$iT; + } + } + , _$M2 = function(_$ij) { + var _$ir = { + 'ONXow': function(_$iT, _$iD, _$iL) { + return _$b.tUMSG(_$iT, _$iD, _$iL); + }, + 'Qezzl': function(_$iT, _$iD, _$iL) { + return _$iT(_$iD, _$iL); + } + } + , _$ix = _$uR(_$uQ(_$ij)) + , _$iX = []; + return _$uh(_$ix, function(_$iT) { + _$u9(_$uY, _$iT) || _$ir.ONXow(_$u9, _$ui, _$iT) || _$ir.Qezzl(_$uy, _$iX, _$iT); + }), + _$iX; + } + , _$M3 = function(_$ij) { + var _$ir = _$ij === _$uA + , _$ix = _$uR(_$ir ? _$uS : _$uQ(_$ij)) + , _$iX = []; + return _$b.eCEdd(_$uh, _$ix, function(_$iT) { + !_$u9(_$uY, _$iT) || _$ir && !_$u9(_$uA, _$iT) || _$uy(_$iX, _$uY[_$iT]); + }), + _$iX; + }; + _$u7 || (_$ue = function() { + if (_$ub(_$us, this)) + throw new _$ug(_$b.zOBhG); + var _$ij = arguments.length && void (-0x4 * -0x92f + -0x3e * -0x7a + -0x4248) !== arguments[-0x10 * -0xce + 0x262e * -0x1 + 0x194e] ? _$uj(arguments[0x12ae + -0x4e * 0x2d + -0x4f8]) : void (-0x1257 + 0x138 * -0xb + -0x489 * -0x7) + , _$ir = _$uE(_$ij) + , _$ix = function(_$iX) { + var _$iT = void (-0x1212 + -0x1166 * -0x2 + -0x10ba) === this ? _$u3 : this; + _$b.dnQje(_$iT, _$uA) && _$u4(_$ix, _$uS, _$iX), + _$b.eHtKM(_$u9, _$iT, _$up) && _$u9(_$iT[_$up], _$ir) && (_$iT[_$up][_$ir] = !(-0x1 * 0x6bd + 0x2b * -0x1 + -0x1 * -0x6e9)); + var _$iD = _$b.OTuFd(_$ur, 0xca3 * -0x1 + -0x11 * -0xcd + -0xf9, _$iX); + try { + _$ut(_$iT, _$ir, _$iD); + } catch (_$iL) { + if (!(_$iL instanceof _$ua)) + throw _$iL; + _$uv(_$iT, _$ir, _$iD); + } + }; + return _$u6 && _$uf && _$ut(_$uA, _$ir, { + 'configurable': !(-0x682 * 0x1 + -0x6a1 + 0xd23), + 'set': _$ix + }), + _$uI(_$ir, _$ij); + } + , + _$uM(_$us = _$ue[_$un], iw(0x256), function() { + return _$b.VWNvp(_$uZ, this).tag; + }), + _$uM(_$ue, iw(0x14d), function(_$ij) { + return _$b.sKZBy(_$uI, _$uE(_$ij), _$ij); + }), + _$uu.f = _$M0, + _$uV.f = _$uw, + _$uH.f = _$ul, + _$uW.f = _$M1, + _$uT.f = _$uD.f = _$M2, + _$uL.f = _$M3, + _$uN.f = function(_$ij) { + return _$uI(_$um(_$ij), _$ij); + } + , + _$u6 && _$b.HMdEz(_$uc, _$us, iw(0x20d), { + 'configurable': !(-0x14e8 + -0x1d49 + 0x3231), + 'get': function() { + return _$uZ(this).description; + } + })), + _$u2({ + 'global': !(-0x275 * -0xd + 0x75f + 0x128 * -0x22), + 'constructor': !(-0x117 * 0x17 + 0x9d2 + 0xf3f), + 'wrap': !(-0x1f * 0x101 + 0xb43 * -0x1 + -0x5 * -0x87a), + 'forced': !_$u7, + 'sham': !_$u7 + }, { + 'Symbol': _$ue + }), + _$b.Btcby(_$uh, _$uX(_$uG), function(_$ij) { + _$uP(_$ij); + }), + _$u2({ + 'target': _$uz, + 'stat': !(0x15ba + -0x2645 + -0x79 * -0x23), + 'forced': !_$u7 + }, { + 'useSetter': function() { + _$uf = !(0x4 * 0x79f + -0x52d * -0x1 + -0x11 * 0x219); + }, + 'useSimple': function() { + _$uf = !(0x6c5 * -0x2 + -0x1 * -0x2443 + 0x2d7 * -0x8); + } + }), + _$u2({ + 'target': iw(0x15f), + 'stat': !(0xca + -0x789 * 0x3 + 0x15d1), + 'forced': !_$u7, + 'sham': !_$u6 + }, { + 'create': function(_$ij, _$ir) { + return _$b.STgAm(void (-0xda * -0xc + -0x6c1 + 0x1 * -0x377), _$ir) ? _$ux(_$ij) : _$ul(_$ux(_$ij), _$ir); + }, + 'defineProperty': _$uw, + 'defineProperties': _$ul, + 'getOwnPropertyDescriptor': _$M1 + }), + _$u2({ + 'target': iw(0x15f), + 'stat': !(0x26ef + 0x29 * -0x51 + -0x19f6), + 'forced': !_$u7 + }, { + 'getOwnPropertyNames': _$M2 + }), + _$uK(), + _$uB(_$ue, _$uz), + _$ui[_$up] = !(-0x1120 + 0x1849 + -0x729); + var _$M4 = _$bT && !!Symbol.for && !!Symbol.keyFor + , _$M5 = _$QU + , _$M6 = _$b6 + , _$M7 = _$by + , _$M8 = _$xX + , _$M9 = _$bg + , _$Mb = _$M4 + , _$MF = _$M9(iw(0x2ba)) + , _$MQ = _$M9(iw(0x27a)); + _$b.CgUbU(_$M5, { + 'target': _$b.cHbyx, + 'stat': !(0x3 * 0x1db + 0x1363 + -0xc7a * 0x2), + 'forced': !_$Mb + }, { + 'for': function(_$ij) { + var m2 = iw + , _$ir = _$M8(_$ij); + if (_$b.tbQbV(_$M7, _$MF, _$ir)) + return _$MF[_$ir]; + var _$ix = _$b.POStL(_$M6, m2(0x198))(_$ir); + return _$MF[_$ir] = _$ix, + _$MQ[_$ix] = _$ir, + _$ix; + } + }); + var _$MU = _$QU + , _$Mj = _$by + , _$Mr = _$bu + , _$Mx = _$bc + , _$MX = _$M4 + , _$MT = _$bg(iw(0x27a)); + _$MU({ + 'target': iw(0x198), + 'stat': !(0x1ca4 + 0x7 * -0x6d + -0x19a9), + 'forced': !_$MX + }, { + 'keyFor': function(_$ij) { + if (!_$Mr(_$ij)) + throw new TypeError(_$b.lVDEr(_$Mx, _$ij) + _$b.MjizA); + if (_$Mj(_$MT, _$ij)) + return _$MT[_$ij]; + } + }); + var _$MD = _$jf + , _$ML = _$bC; + _$b.wQxZP(_$QU, { + 'target': iw(0x15f), + 'stat': !(-0xbbe + -0x79 * -0x49 + -0x16c3 * 0x1), + 'forced': !_$bT || _$U(function() { + _$MD.f(0x6 * 0x101 + -0x213 + -0x3f2); + }) + }, { + 'getOwnPropertySymbols': function(_$ij) { + var _$ir = _$MD.f; + return _$ir ? _$ir(_$ML(_$ij)) : []; + } + }), + _$Ht(iw(0x1b5)), + _$b.SElhX(_$Ht, _$b.JEbEb), + _$Ht(_$b.vaqNB), + _$Ht(_$b.XoQpA), + _$Ht(iw(0x21f)), + _$Ht(iw(0x1de)), + _$Ht(iw(0x1f2)), + _$b.NObDZ(_$Ht, iw(0x21a)), + _$b.pnsrn(_$Ht, iw(0x175)), + _$Ht(iw(0x150)); + var _$MW = _$u1; + _$Ht(_$b.vqtYN), + _$b.hnbAK(_$MW); + var _$MV = _$b6 + , _$MH = _$XV; + _$b.bOcOu(_$Ht, iw(0x2b9)), + _$MH(_$MV(_$b.cHbyx), iw(0x198)), + _$Ht(iw(0x293)), + _$XV(_$V.JSON, iw(0x246), !(-0x1 * 0x1cc + 0x84 * -0x1d + 0x860 * 0x2)); + var _$Mu = _$b1.Symbol + , _$MM = _$F4 + , _$Mc = _$Fs.f + , _$MO = _$MM(iw(0x28b)) + , _$Mi = Function.prototype; + void (0x2c7 + -0x75a + 0x493) === _$Mi[_$MO] && _$b.ezZkr(_$Mc, _$Mi, _$MO, { + 'value': null + }), + _$Ht(iw(0x1e2)), + _$Ht(iw(0x236)), + _$b.hqOTn(_$Ht, iw(0x28b)); + var _$ME = _$Mu + , _$Mm = _$D + , _$MN = _$b6(iw(0x198)) + , _$MP = _$MN.keyFor + , _$MK = _$b.VGuNs(_$Mm, _$MN.prototype.valueOf) + , _$MB = _$MN.isRegisteredSymbol || function(_$ij) { + try { + return void (0x209 + 0x2642 + -0x284b) !== _$MP(_$MK(_$ij)); + } catch (_$ir) { + return !(-0x1f2b + 0x16e8 + 0x844); + } + } + ; + _$QU({ + 'target': iw(0x198), + 'stat': !(0x1 * -0x1549 + 0x1bb2 + 0x223 * -0x3) + }, { + 'isRegisteredSymbol': _$MB + }); + for (var _$Md = _$bg, _$Mh = _$b6, _$Mp = _$D, _$Mz = _$bu, _$Mn = _$F4, _$Mk = _$Mh(iw(0x198)), _$MZ = _$Mk.isWellKnownSymbol, _$MA = _$Mh(iw(0x15f), iw(0x2a4)), _$Me = _$Mp(_$Mk.prototype.valueOf), _$Ms = _$Md(iw(0x225)), _$Ma = -0x82 * -0x18 + -0x12f9 * 0x2 + 0x2 * 0xce1, _$Mg = _$MA(_$Mk), _$Mq = _$Mg.length; _$Ma < _$Mq; _$Ma++) + try { + var _$MJ = _$Mg[_$Ma]; + _$Mz(_$Mk[_$MJ]) && _$b.XCIui(_$Mn, _$MJ); + } catch (_$ij) {} + var _$MC = function(_$ir) { + if (_$MZ && _$MZ(_$ir)) + return !(0x1c * 0xb5 + 0x363 + 0x1 * -0x172f); + try { + for (var _$ix = _$Me(_$ir), _$iX = 0x1627 + 0x15bf + -0x2be6, _$iT = _$MA(_$Ms), _$iD = _$iT.length; _$iX < _$iD; _$iX++) + if (_$Ms[_$iT[_$iX]] == _$ix) + return !(-0x2bf + 0x52 * -0xd + -0x3d * -0x1d); + } catch (_$iL) {} + return !(0x1 * 0x65b + 0x8ee * 0x2 + -0x1836); + }; + _$b.FVity(_$QU, { + 'target': iw(0x198), + 'stat': !(0x48 * 0x32 + -0x823 + -0x5ed), + 'forced': !(0x1a55 + -0x13 * 0x76 + -0xb * 0x199) + }, { + 'isWellKnownSymbol': _$MC + }), + _$Ht(iw(0x260)), + _$Ht(iw(0x199)), + _$b.ZWuyG(_$QU, { + 'target': iw(0x198), + 'stat': !(0x1c * 0x35 + 0x195 * 0xe + -0x1bf2), + 'name': iw(0x211) + }, { + 'isRegistered': _$MB + }), + _$QU({ + 'target': iw(0x198), + 'stat': !(-0x84 * 0x14 + -0xce5 + 0x1735), + 'name': iw(0x1bf), + 'forced': !(-0x1a10 * 0x1 + -0xaa * -0x1b + 0x15b * 0x6) + }, { + 'isWellKnown': _$MC + }), + _$b.tOYMk(_$Ht, iw(0x215)), + _$b.hqOTn(_$Ht, _$b.DCtKd), + _$Ht(iw(0x279)); + var _$MR = _$ME + , _$Mo = _$Hy.f(_$b.XoQpA); + function _$My(_$ir) { + var m3 = iw; + return _$My = 'function' == typeof _$MR && m3(0x1d6) == typeof _$Mo ? function(_$ix) { + return typeof _$ix; + } + : function(_$ix) { + var m4 = m3; + return _$ix && 'function' == typeof _$MR && _$ix.constructor === _$MR && _$ix !== _$MR.prototype ? m4(0x1d6) : typeof _$ix; + } + , + _$My(_$ir); + } + var _$MY = _$O + , _$MS = _$w + , _$MG = _$QD + , _$Mf = _$Qu + , _$Mv = _$UI + , _$Mt = Math.min + , _$MI = [].lastIndexOf + , _$Mw = !!_$MI && (-0x7 * -0x3f + 0x1525 + -0x16dd) / [-0x19c2 + 0x1c * 0x135 + -0x809].lastIndexOf(-0xa95 + -0x1693 + 0x2129 * 0x1, -(0x23e0 + 0x2238 + 0x4618 * -0x1)) < 0x6cb * -0x1 + 0x1 * -0x1bdb + 0x22a6 + , _$Ml = _$Mv(_$b.ddrwB) + , _$c0 = _$b.bJjbF(_$Mw, !_$Ml) ? function(_$ir) { + var m5 = iw + , _$ix = m5(0x22d).split('|') + , _$iX = 0x23cd + -0xa15 + 0x8 * -0x337; + while (!![]) { + switch (_$ix[_$iX++]) { + case '0': + var _$iT = _$MS(this) + , _$iD = _$Mf(_$iT); + continue; + case '1': + var _$iL = _$iD - (-0x2619 * 0x1 + 0x1 * -0x251d + 0x4b37); + continue; + case '2': + for (arguments.length > -0x14d0 + -0x5 * -0x77b + -0x1096 && (_$iL = _$b.eCEdd(_$Mt, _$iL, _$MG(arguments[-0x165 + -0x21e5 + -0x1 * -0x234b]))), + _$iL < -0x214e + 0x1f2a * -0x1 + 0x101e * 0x4 && (_$iL = _$iD + _$iL); _$iL >= 0x17d0 + 0x15bb + -0x2d8b; _$iL--) + if (_$iL in _$iT && _$iT[_$iL] === _$ir) + return _$b.PdQyN(_$iL, 0x2015 + -0x1 * 0x24da + 0x4c5); + continue; + case '3': + if (-0x1 * 0x170c + -0x17ff * 0x1 + 0x2f0b === _$iD) + return -(0x55b * -0x7 + 0xd * -0x53 + 0x29b5); + continue; + case '4': + if (_$Mw) + return _$MY(_$MI, this, arguments) || -0xaf1 * 0x3 + -0xa * 0x7f + 0x25c9; + continue; + case '5': + return -(-0x3 * 0x4f + -0x1 * 0xc0b + 0x1b * 0x7b); + } + break; + } + } + : _$MI; + _$QU({ + 'target': iw(0x25c), + 'proto': !(-0x696 * -0x4 + -0x71c + -0x133c), + 'forced': _$b.BMqjC(_$c0, [].lastIndexOf) + }, { + 'lastIndexOf': _$c0 + }); + var _$c1 = _$UH(iw(0x25c), iw(0x1e0)) + , _$c2 = _$L + , _$c3 = _$c1 + , _$c4 = Array.prototype + , _$c5 = function(_$ir) { + var _$ix = _$ir.lastIndexOf; + return _$ir === _$c4 || _$c2(_$c4, _$ir) && _$ix === _$c4.lastIndexOf ? _$c3 : _$ix; + } + , _$c6 = { + 'exports': {} + } + , _$c7 = _$QU + , _$c8 = _$Qr + , _$c9 = _$D([].reverse) + , _$cb = [0x571 + 0x2 * 0xbd3 + 0x33 * -0x92, -0x4 * -0x73d + -0x7 * 0x8b + -0x1925]; + _$c7({ + 'target': iw(0x25c), + 'proto': !(-0x83a + -0x477 * 0x8 + 0x4b * 0x96), + 'forced': String(_$cb) === String(_$cb.reverse()) + }, { + 'reverse': function() { + return _$c8(this) && (this.length = this.length), + _$c9(this); + } + }); + var _$cF = _$b.JbIDx(_$UH, _$b.afCuI, iw(0x29a)) + , _$cQ = _$L + , _$cU = _$cF + , _$cj = Array.prototype + , _$cr = function(_$ir) { + var _$ix = _$ir.reverse; + return _$b.VjIbX(_$ir, _$cj) || _$b.guaEG(_$cQ, _$cj, _$ir) && _$ix === _$cj.reverse ? _$cU : _$ix; + } + , _$cx = iw(0x1a2) + , _$cX = _$v + , _$cT = _$xX + , _$cD = _$cx + , _$cL = _$D(''.replace) + , _$cW = RegExp('^[' + _$cD + ']+') + , _$cV = RegExp(iw(0x235) + _$cD + iw(0x26f) + _$cD + iw(0x154)) + , _$cH = function(_$ir) { + var _$ix = { + 'aYpIc': function(_$iX, _$iT) { + return _$iX(_$iT); + }, + 'qDJAU': function(_$iX, _$iT, _$iD, _$iL) { + return _$b.xdPDB(_$iX, _$iT, _$iD, _$iL); + } + }; + return function(_$iX) { + var _$iT = _$ix.aYpIc(_$cT, _$cX(_$iX)); + return -0x3 * 0x47 + 0x1 * 0x20e3 + -0x200d & _$ir && (_$iT = _$cL(_$iT, _$cW, '')), + -0x1cc6 + -0x2443 + -0x410b * -0x1 & _$ir && (_$iT = _$ix.qDJAU(_$cL, _$iT, _$cV, '$1')), + _$iT; + } + ; + } + , _$cu = { + 'start': _$cH(-0x36d * 0x1 + -0x7 * -0x1d + -0x87 * -0x5), + 'end': _$cH(-0x20db * 0x1 + -0xd07 * 0x2 + 0x3aeb), + 'trim': _$b.kUADD(_$cH, 0x3e6 * -0x1 + -0x4 * 0x196 + -0x69 * -0x19) + } + , _$cM = _$V + , _$cc = _$U + , _$cO = _$D + , _$ci = _$xX + , _$cE = _$cu.trim + , _$cm = _$cx + , _$cN = _$cM.parseInt + , _$cP = _$cM.Symbol + , _$cK = _$cP && _$cP.iterator + , _$cB = /^[+-]?0x/i + , _$cd = _$cO(_$cB.exec) + , _$ch = 0x1 * 0x17d3 + 0xf * -0x26d + 0xc98 !== _$cN(_$cm + '08') || -0x6e8 + 0x5 * -0x64d + -0x9 * -0x447 !== _$cN(_$cm + iw(0x165)) || _$cK && !_$b.KHbjh(_$cc, function() { + _$cN(Object(_$cK)); + }) ? function(_$ir, _$ix) { + var _$iX = _$b.mhwAA(_$cE, _$ci(_$ir)); + return _$cN(_$iX, _$b.JalOw(_$ix, 0x1b * 0x25 + 0x15d0 + -0xe3 * 0x1d) || (_$b.kaUuY(_$cd, _$cB, _$iX) ? -0x11 * -0xb5 + 0x23ae + 0xf * -0x32d : -0x91b + -0x183c + 0x2161)); + } + : _$cN; + _$QU({ + 'global': !(0x2215 + 0x9a6 + -0x2bbb), + 'forced': parseInt !== _$ch + }, { + 'parseInt': _$ch + }); + var _$cp = _$b1.parseInt + , _$cz = _$z + , _$cn = _$Qr + , _$ck = TypeError + , _$cZ = Object.getOwnPropertyDescriptor + , _$cA = _$cz && !function() { + var m6 = iw; + if (void (0xdd4 + -0x11b1 + 0x2b * 0x17) !== this) + return !(0x5c9 + 0xa3f * -0x1 + 0x476); + try { + Object.defineProperty([], m6(0x28c), { + 'writable': !(0x1f5a + -0x1 * 0x19d5 + 0x161 * -0x4) + }).length = -0xa77 + -0x7 * -0x1c9 + -0x1 * 0x207; + } catch (_$ir) { + return _$ir instanceof TypeError; + } + }() + , _$ce = _$QU + , _$cs = _$bC + , _$ca = _$UP + , _$cg = _$QD + , _$cq = _$Qu + , _$cJ = _$cA ? function(_$ir, _$ix) { + var m7 = iw; + if (_$cn(_$ir) && !_$cZ(_$ir, m7(0x28c)).writable) + throw new _$ck(m7(0x2b0)); + return _$ir.length = _$ix; + } + : function(_$ir, _$ix) { + return _$ir.length = _$ix; + } + , _$cC = _$Qc + , _$cR = _$U2 + , _$co = _$Qm + , _$cy = _$VJ + , _$cY = _$U6(iw(0x197)) + , _$cS = Math.max + , _$cG = Math.min; + _$b.Btcby(_$ce, { + 'target': iw(0x25c), + 'proto': !(0x1 * 0x1ab9 + 0xcbd * 0x3 + -0x40f0), + 'forced': !_$cY + }, { + 'splice': function(_$ir, _$ix) { + var _$iX, _$iT, _$iD, _$iL, _$iW, _$iV, _$iH = _$b.DjSUN(_$cs, this), _$iu = _$cq(_$iH), _$iM = _$ca(_$ir, _$iu), _$ic = arguments.length; + for (-0xd * 0xe5 + -0x1357 + 0x1ef8 === _$ic ? _$iX = _$iT = -0x189f + 0x3a * 0x15 + 0x13dd : _$b.QMEUG(0x24c8 + -0x59a + 0x17 * -0x15b, _$ic) ? (_$iX = 0x149 * 0x19 + -0x57a + -0x1aa7, + _$iT = _$iu - _$iM) : (_$iX = _$ic - (-0x3 * -0x7a2 + 0xf52 + -0x2636), + _$iT = _$b.sKZBy(_$cG, _$cS(_$b.LRfHW(_$cg, _$ix), -0x1e74 * 0x1 + 0x39f + -0x1ad5 * -0x1), _$iu - _$iM)), + _$cC(_$iu + _$iX - _$iT), + _$iD = _$cR(_$iH, _$iT), + _$iL = -0x15cc + 0x2 * 0x3b7 + 0x265 * 0x6; _$iL < _$iT; _$iL++) + (_$iW = _$iM + _$iL)in _$iH && _$co(_$iD, _$iL, _$iH[_$iW]); + if (_$iD.length = _$iT, + _$iX < _$iT) { + for (_$iL = _$iM; _$iL < _$iu - _$iT; _$iL++) + _$iV = _$iL + _$iX, + (_$iW = _$iL + _$iT)in _$iH ? _$iH[_$iV] = _$iH[_$iW] : _$b.vluFt(_$cy, _$iH, _$iV); + for (_$iL = _$iu; _$b.HxMOx(_$iL, _$b.HuRFF(_$b.OuPJY(_$iu, _$iT), _$iX)); _$iL--) + _$cy(_$iH, _$iL - (-0x11 * 0x135 + -0x23fe * 0x1 + -0xe21 * -0x4)); + } else { + if (_$iX > _$iT) { + for (_$iL = _$iu - _$iT; _$iL > _$iM; _$iL--) + _$iV = _$iL + _$iX - (-0x2431 + 0x60f * 0x1 + 0x1 * 0x1e23), + (_$iW = _$iL + _$iT - (-0xb3 * -0x2f + -0x1 * 0x12bd + -0x3 * 0x4b5))in _$iH ? _$iH[_$iV] = _$iH[_$iW] : _$b.LfAHK(_$cy, _$iH, _$iV); + } + } + for (_$iL = -0x23 + -0x25e8 + 0x260b; _$iL < _$iX; _$iL++) + _$iH[_$b.DbpDX(_$iL, _$iM)] = arguments[_$iL + (0x1193 * -0x2 + 0x2 * 0xcac + 0x9d0)]; + return _$cJ(_$iH, _$iu - _$iT + _$iX), + _$iD; + } + }); + var _$cf, _$cv = _$UH(_$b.afCuI, iw(0x197)), _$ct = _$L, _$cI = _$cv, _$cw = Array.prototype, _$cl = function(_$ir) { + var _$ix = _$ir.splice; + return _$ir === _$cw || _$ct(_$cw, _$ir) && _$ix === _$cw.splice ? _$cI : _$ix; + }, _$O0 = { + 'exports': {} + }, _$O1 = _$Q(Object.freeze({ + '__proto__': null, + 'default': {} + })); + _$O0.exports = (_$cf = _$cf || function(_$ir, _$ix) { + var m8 = iw, _$iX = { + 'LcQcK': function(_$iN, _$iP) { + return _$iN == _$iP; + }, + 'SsSvj': 'function', + 'FvuGk': m8(0x23b), + 'yCOuD': _$b.IPWVl, + 'DYFYC': function(_$iN, _$iP) { + return _$iN || _$iP; + }, + 'uSSvn': function(_$iN, _$iP) { + return _$iN % _$iP; + }, + 'YUjxJ': function(_$iN, _$iP) { + return _$b.QJfEw(_$iN, _$iP); + }, + 'EFZmO': function(_$iN, _$iP) { + return _$b.iyrRt(_$iN, _$iP); + }, + 'CneSf': function(_$iN) { + return _$iN(); + }, + 'WtWda': function(_$iN, _$iP, _$iK) { + return _$iN(_$iP, _$iK); + }, + 'gJpnf': function(_$iN, _$iP) { + return _$iN >>> _$iP; + }, + 'zSyZT': function(_$iN, _$iP) { + return _$b.uuLud(_$iN, _$iP); + }, + 'DustE': function(_$iN, _$iP) { + return _$b.clTpr(_$iN, _$iP); + }, + 'CUlQS': _$b.OYYwc, + 'AZklX': m8(0x24b) + }, _$iT; + if ('undefined' != typeof window && window.crypto && (_$iT = window.crypto), + !_$iT && 'undefined' != typeof window && window.msCrypto && (_$iT = window.msCrypto), + !_$iT && void (-0x1e2d + 0x1e9f + -0x72) !== _$F && _$F.crypto && (_$iT = _$F.crypto), + !_$iT) + try { + _$iT = _$O1; + } catch (_$iN) {} + var _$iD = function() { + if (_$iT) { + if (_$iX.LcQcK('function', typeof _$iT.getRandomValues)) + try { + return _$iT.getRandomValues(new Uint32Array(0x7f * -0x35 + -0x12d5 * 0x2 + 0x3ff6))[-0x7e5 + -0x7af * 0x5 + -0x3 * -0xf70]; + } catch (_$iP) {} + if (_$iX.SsSvj == typeof _$iT.randomBytes) + try { + return _$iT.randomBytes(-0x479 * -0x2 + 0x2 * -0x15d + 0x634 * -0x1).readInt32LE(); + } catch (_$iK) {} + } + throw new Error(_$iX.FvuGk); + } + , _$iL = Object.create || function() { + function _$iP() {} + return function(_$iK) { + var _$iB; + return _$iP.prototype = _$iK, + _$iB = new _$iP(), + _$iP.prototype = null, + _$iB; + } + ; + }() + , _$iW = {} + , _$iV = _$iW.lib = {} + , _$iH = _$iV.Base = { + 'extend': function(_$iP) { + var _$iK = _$iL(this); + return _$iP && _$iK.mixIn(_$iP), + _$iK.hasOwnProperty(_$iX.yCOuD) && this.init !== _$iK.init || (_$iK.init = function() { + _$iK.$super.init.apply(this, arguments); + } + ), + _$iK.init.prototype = _$iK, + _$iK.$super = this, + _$iK; + }, + 'create': function() { + var _$iP = this.extend(); + return _$iP.init.apply(_$iP, arguments), + _$iP; + }, + 'init': function() {}, + 'mixIn': function(_$iP) { + var m9 = m8; + for (var _$iK in _$iP) + _$iP.hasOwnProperty(_$iK) && (this[_$iK] = _$iP[_$iK]); + _$iP.hasOwnProperty(m9(0x256)) && (this.toString = _$iP.toString); + }, + 'clone': function() { + return this.init.prototype.extend(this); + } + } + , _$iu = _$iV.WordArray = _$iH.extend({ + 'init': function(_$iP, _$iK) { + _$iP = this.words = _$iP || [], + this.sigBytes = _$iK != _$ix ? _$iK : (0x14b7 * -0x1 + -0x1 * -0xfbc + 0x4ff * 0x1) * _$iP.length; + }, + 'toString': function(_$iP) { + return _$iX.DYFYC(_$iP, _$ic).stringify(this); + }, + 'concat': function(_$iP) { + var _$iK = this.words + , _$iB = _$iP.words + , _$id = this.sigBytes + , _$ih = _$iP.sigBytes; + if (this.clamp(), + _$id % (0x4cf * -0x5 + -0x5 * 0x709 + 0x3b3c)) + for (var _$ip = -0x5 * -0x2f1 + 0x1ec2 + 0x1 * -0x2d77; _$ip < _$ih; _$ip++) { + var _$iz = _$iB[_$ip >>> 0x11cb + -0x3 * 0x514 + 0x28d * -0x1] >>> 0x1169 + 0x147e + -0x25cf * 0x1 - _$iX.uSSvn(_$ip, 0x1 * 0x2157 + 0x2df + 0x1219 * -0x2) * (0x597 + -0x10f * 0x1 + -0x480) & -0x1101 * 0x1 + -0x1637 + -0x5 * -0x80b; + _$iK[_$id + _$ip >>> 0xe1e + -0x4 * -0x94e + -0x3354] |= _$iz << _$iX.YUjxJ(-0x1235 + 0x1015 * 0x1 + 0x238, _$iX.uSSvn(_$id + _$ip, 0x24c8 + -0x5e0 + -0x1ee4) * (-0x1d81 + 0x19e + -0x1 * -0x1beb)); + } + else { + for (_$ip = 0x247f + 0xa15 + -0x2e94; _$iX.EFZmO(_$ip, _$ih); _$ip += -0x22c7 * -0x1 + 0x49f * 0x7 + -0x431c) + _$iK[_$id + _$ip >>> 0x1cdc + 0xa1 * 0x3 + 0x1ebd * -0x1] = _$iB[_$ip >>> -0x1 * -0x50 + -0x600 + 0x5b2]; + } + return this.sigBytes += _$ih, + this; + }, + 'clamp': function() { + var _$iP = this.words + , _$iK = this.sigBytes; + _$iP[_$iK >>> 0x9d0 + -0xb8 * -0x8 + -0x2 * 0x7c7] &= -0x3f4904b4 + 0x4f1 * 0x5c5613 + -0x88fc4f30 << -0x16 * -0x37 + -0xf54 + -0xaba * -0x1 - _$iK % (0x17b + 0x25cf + 0x1c9 * -0x16) * (0xad * -0x1d + 0x5e * -0x41 + -0x11 * -0x28f), + _$iP.length = _$ir.ceil(_$iK / (0x12 * -0xed + 0x2243 + -0x1195)); + }, + 'clone': function() { + var _$iP, _$iK = _$iH.clone.call(this); + return _$iK.words = _$Uy(_$iP = this.words).call(_$iP, 0xe1d * -0x1 + -0x1aa8 + 0x5d3 * 0x7), + _$iK; + }, + 'random': function(_$iP) { + for (var _$iK = [], _$iB = -0xbb9 * -0x1 + -0x3 * -0x6de + 0x2053 * -0x1; _$iB < _$iP; _$iB += -0x1c21 + 0x6 * 0x443 + -0x1 * -0x293) + _$iK.push(_$iX.CneSf(_$iD)); + return new _$iu.init(_$iK,_$iP); + } + }) + , _$iM = _$iW.enc = {} + , _$ic = _$iM.Hex = { + 'stringify': function(_$iP) { + 'use strict'; + var j = _3nkbm; + var y = _2mzbm; + var _$iK, _$iB, _$id, _$ih, _$ip, _$iz; + var u = []; + var k = 0; + var c, w; + l0: for (; ; ) { + switch (y[k++]) { + case 1: + u.push(_$cr); + break; + case 13: + u.push(new Array(y[k++])); + break; + case 14: + _$ih = u[u.length - 1]; + break; + case 16: + return u.pop(); + break; + case 17: + u.push(Array); + break; + case 18: + _$iB = u[u.length - 1]; + break; + case 20: + return; + break; + case 29: + _$iz = u[u.length - 1]; + break; + case 34: + k += y[k]; + break; + case 35: + u.push(this); + break; + case 37: + u.push(_$cf); + break; + case 43: + if (u[u.length - 2] != null) { + u[u.length - 3] = j.call(u[u.length - 3], u[u.length - 2], u[u.length - 1]); + u.length -= 2; + } else { + c = u[u.length - 3]; + u[u.length - 3] = c(u[u.length - 1]); + u.length -= 2; + } + break; + case 44: + u.push(y[k++]); + break; + case 46: + u.push(_$ip); + break; + case 47: + u.push(_$iz); + break; + case 50: + u.push(_$iK); + break; + case 51: + u.push(u[u.length - 1]); + u[u.length - 2] = u[u.length - 2][_1sxbm[y[k++]]]; + break; + case 53: + u[u.length - 4] = j.call(u[u.length - 4], u[u.length - 3], u[u.length - 2], u[u.length - 1]); + u.length -= 3; + break; + case 56: + _$ip = u[u.length - 1]; + break; + case 58: + u.push(_$Uy); + break; + case 59: + c = u.pop(); + u[u.length - 1] = u[u.length - 1] > c; + break; + case 62: + _$iK = u[u.length - 1]; + break; + case 63: + u.push(_$id); + break; + case 64: + u.push(null); + break; + case 66: + u[u.length - 1] = u[u.length - 1][_1sxbm[y[k++]]]; + break; + case 67: + if (u.pop()) + ++k; + else + k += y[k]; + break; + case 68: + u.push(_$ih); + break; + case 86: + u[u.length - 1] = u[u.length - 1].length; + break; + case 92: + u.pop(); + break; + case 93: + u.push(_$iB); + break; + case 95: + u[u.length - 5] = j.call(u[u.length - 5], u[u.length - 4], u[u.length - 3], u[u.length - 2], u[u.length - 1]); + u.length -= 4; + break; + case 96: + c = u.pop(); + u[u.length - 1] += c; + break; + case 97: + u.push(_$iP); + break; + case 98: + _$id = u[u.length - 1]; + break; + } + } + }, + 'parse': function(_$iP) { + for (var _$iK = _$iP.length, _$iB = [], _$id = -0x227f + -0xbaf * -0x3 + -0x8e; _$id < _$iK; _$id += 0x21a5 + 0x832 + -0x29d5) + _$iB[_$id >>> 0x8cf + 0x1ad6 + -0x23a2] |= _$iX.WtWda(_$cp, _$iP.substr(_$id, 0x233 + 0x1953 + -0x1b84), -0x38c + 0x1bdd * -0x1 + 0x1f79) << 0x1f01 + 0x169f + 0x11d8 * -0x3 - _$id % (-0x1 * -0x778 + -0x18bd * 0x1 + 0x114d) * (0x57 * -0xc + 0xab9 + -0x6a1); + return new _$iu.init(_$iB,_$iK / (-0x864 + 0x1f0c + -0x16a6)); + }, + 'format': function(_$iP) { + for (var _$iK = _$iP.words, _$iB = _$iP.sigBytes, _$id = [], _$ih = 0x167 * 0x15 + 0x15fb + -0x336e; _$b.obOLx(_$ih, _$iB); _$ih++) { + var _$ip = _$iK[_$ih >>> -0x1d58 + -0x1b * -0x89 + -0x6d * -0x23] >>> 0x154d + 0x6d * 0x2a + -0x2717 - _$b.HQaKr(_$ih, 0x13 * -0x1d + 0xc64 * 0x1 + -0xa39) * (-0x2051 + 0x1da8 + -0x2b1 * -0x1) & -0xfc8 * 0x1 + 0x2 * 0x3b5 + -0x31f * -0x3; + _$id.push((_$ip >>> 0x20c * -0x8 + 0x1 * 0x1249 + 0x5 * -0x61).toString(-0x1ad3 + -0x2552 + -0x1567 * -0x3)), + _$id.push(_$b.nDruQ(0xa49 * 0x1 + -0x134f + 0x9b * 0xf, _$ip).toString(-0x5 * -0x20 + -0x24bd + 0x242d)); + } + return _$id.join(''); + } + }; + _$iM.Utils = { + 'toWordArray': function(_$iP) { + for (var _$iK = [], _$iB = 0x1e6d * 0x1 + -0x7bc + -0x16b1; _$iB < _$iP.length; _$iB++) + _$iK[_$iB >>> 0x2 * -0x7c3 + 0xd49 + -0x17 * -0x19] |= _$iP[_$iB] << 0x1bd * -0x13 + 0x1c18 + 0x507 - _$b.HQaKr(_$iB, 0x100e * -0x1 + -0x1183 * 0x1 + 0x2195) * (0x2051 + -0x2171 * 0x1 + 0x94 * 0x2); + return _$cf.lib.WordArray.create(_$iK, _$iP.length); + }, + 'fromWordArray': function(_$iP) { + for (var _$iK = new Uint8Array(_$iP.sigBytes), _$iB = 0x1d1b + -0x26ce + 0x9b3; _$iB < _$iP.sigBytes; _$iB++) + _$iK[_$iB] = _$iP.words[_$b.JalOw(_$iB, 0x22d7 + -0x1 * -0xda3 + -0x3078)] >>> 0x2b * 0x67 + 0x4d4 + -0x1609 - _$iB % (-0xab * -0xa + -0x2405 + -0x343 * -0x9) * (-0x3ce * -0x6 + 0x2fc + -0x19c8) & -0x1f48 + -0x1a6c + -0x3 * -0x1391; + return _$iK; + } + }; + var _$iO = _$iM.Latin1 = { + 'stringify': function(_$iP) { + for (var _$iK = _$iP.words, _$iB = _$iP.sigBytes, _$id = [], _$ih = 0x1b4a + -0x2f6 * 0x1 + -0x1854; _$ih < _$iB; _$ih++) { + var _$ip = _$iX.gJpnf(_$iK[_$ih >>> -0x23ea + 0x63 * 0x26 + 0x153a], _$iX.YUjxJ(0xc4a * -0x1 + 0x4 * 0x88 + -0x65 * -0x1a, _$ih % (0xcea + 0x28 * -0x92 + 0x9ea) * (0x6cb * 0x1 + 0x1d0d + -0xbf0 * 0x3))) & -0x2175 + 0x6fc + 0x1b78; + _$id.push(String.fromCharCode(_$ip)); + } + return _$id.join(''); + }, + 'parse': function(_$iP) { + for (var _$iK = _$iP.length, _$iB = [], _$id = 0x1930 + -0xedd + -0xa53; _$id < _$iK; _$id++) + _$iB[_$id >>> -0x2e * -0xd0 + 0x1f71 + -0xd * 0x54b] |= (0x1 * 0x1f2d + 0x16d5 + 0x1 * -0x3503 & _$iP.charCodeAt(_$id)) << 0xaf2 * -0x3 + 0x1e14 + 0x2da - _$iX.zSyZT(_$id % (0x4 * -0x886 + -0xbbf * -0x3 + 0x1 * -0x121), 0x244e + 0x44b + -0x5 * 0x81d); + return new _$iu.init(_$iB,_$iK); + } + } + , _$ii = _$iM.Utf8 = { + 'stringify': function(_$iP) { + var mb = m8; + try { + return _$iX.DustE(decodeURIComponent, escape(_$iO.stringify(_$iP))); + } catch (_$iK) { + throw new Error(mb(0x2b4)); + } + }, + 'parse': function(_$iP) { + return _$iO.parse(unescape(encodeURIComponent(_$iP))); + } + } + , _$iE = _$iV.BufferedBlockAlgorithm = _$iH.extend({ + 'reset': function() { + this._data = new _$iu.init(), + this._nDataBytes = -0xc * -0x2bb + -0x3 * 0x42b + -0x1443; + }, + '_append': function(_$iP) { + 'use strict'; + var g = _3nkbm; + var k = _2mzbm; + var _$iK; + var l = []; + var e = 129; + var p, x; + l1: for (; ; ) { + switch (k[e++]) { + case 19: + l.push(_$iX); + break; + case 25: + l[l.length - 4] = g.call(l[l.length - 4], l[l.length - 3], l[l.length - 2], l[l.length - 1]); + l.length -= 3; + break; + case 29: + _$iK = l[l.length - 1]; + break; + case 33: + l.push(_$iK); + break; + case 35: + l.push(_$iP); + break; + case 39: + _$iP = l[l.length - 1]; + break; + case 40: + l[l.length - 2][_1sxbm[9 + k[e++]]] = l[l.length - 1]; + l[l.length - 2] = l[l.length - 1]; + l.length--; + break; + case 43: + l[l.length - 1] = l[l.length - 1][_1sxbm[9 + k[e++]]]; + break; + case 45: + p = l.pop(); + l[l.length - 1] += p; + break; + case 46: + l.push(l[l.length - 1]); + l[l.length - 2] = l[l.length - 2][_1sxbm[9 + k[e++]]]; + break; + case 50: + return; + break; + case 53: + l.push(_$ii); + break; + case 54: + l.push(this[_1sxbm[9 + k[e++]]]); + break; + case 63: + l.push(this); + break; + case 66: + if (l[l.length - 2] != null) { + l[l.length - 3] = g.call(l[l.length - 3], l[l.length - 2], l[l.length - 1]); + l.length -= 2; + } else { + p = l[l.length - 3]; + l[l.length - 3] = p(l[l.length - 1]); + l.length -= 2; + } + break; + case 68: + l[l.length - 1] = typeof l[l.length - 1]; + break; + case 76: + l.push(_$Ui); + break; + case 81: + l.pop(); + break; + case 82: + l.push(null); + break; + case 83: + l.push(l[l.length - 1]); + break; + case 96: + if (l[l.length - 1]) { + ++e; + --l.length; + } else + e += k[e]; + break; + } + } + }, + '_process': function(_$iP) { + var _$iK, _$iB = this._data, _$id = _$iB.words, _$ih = _$iB.sigBytes, _$ip = this.blockSize, _$iz = _$ih / ((-0x147a + -0x895 * -0x3 + -0x541) * _$ip), _$in = (_$iz = _$iP ? _$ir.ceil(_$iz) : _$ir.max((0x1083 * 0x1 + 0x1ea6 + -0x2f29 | _$iz) - this._minBufferSize, -0x2232 + 0x1 * -0x24c1 + 0x46f3)) * _$ip, _$ik = _$ir.min((0x512 + -0x96d + -0x3 * -0x175) * _$in, _$ih); + if (_$in) { + for (var _$iZ = -0x1e56 + 0x2461 + -0x60b; _$iZ < _$in; _$iZ += _$ip) + this._doProcessBlock(_$id, _$iZ); + _$iK = _$cl(_$id).call(_$id, -0x1990 + -0x1740 + 0x30d0, _$in), + _$iB.sigBytes -= _$ik; + } + return new _$iu.init(_$iK,_$ik); + }, + '_eData': function(_$iP) { + 'use strict'; + var t = _3nkbm; + var e = _2mzbm; + var i = []; + var m = 177; + var b, c; + l2: for (; ; ) { + switch (e[m++]) { + case 7: + i.push(_$iX); + break; + case 34: + i[i.length - 4] = t.call(i[i.length - 4], i[i.length - 3], i[i.length - 2], i[i.length - 1]); + i.length -= 3; + break; + case 41: + return; + break; + case 45: + i.push(i[i.length - 1]); + i[i.length - 2] = i[i.length - 2][_1sxbm[17 + e[m++]]]; + break; + case 48: + i.push(null); + break; + case 51: + i[i.length - 1] = i[i.length - 1][_1sxbm[17 + e[m++]]]; + break; + case 65: + if (i[i.length - 2] != null) { + i[i.length - 3] = t.call(i[i.length - 3], i[i.length - 2], i[i.length - 1]); + i.length -= 2; + } else { + b = i[i.length - 3]; + i[i.length - 3] = b(i[i.length - 1]); + i.length -= 2; + } + break; + case 78: + i.push(_$Ui); + break; + case 79: + return i.pop(); + break; + case 85: + i.push(_$iP); + break; + } + } + }, + 'clone': function() { + var _$iP = _$iH.clone.call(this); + return _$iP._data = this._data.clone(), + _$iP; + }, + '_minBufferSize': 0x0 + }); + _$iV.Hasher = _$iE.extend({ + 'cfg': _$iH.extend(), + 'init': function(_$iP) { + this.cfg = this.cfg.extend(_$iP), + this.reset(); + }, + 'reset': function() { + _$iE.reset.call(this), + this._doReset(); + }, + 'update': function(_$iP) { + return this._append(_$iP), + this._process(), + this; + }, + 'finalize': function(_$iP) { + var mF = m8; + return _$iP && (mF(0x182) == typeof _$iP && (_$iP = this._seData(_$iP)), + this._append(_$iP)), + this._doFinalize(); + }, + '_seData': function(_$iP) { + return this._seData1(_$iP); + }, + '_seData1': function(_$iP) { + 'use strict'; + var w = _3nkbm; + var t = _2mzbm; + var mQ, _$iK, _$iB, _$id, _$ih, _$ip, _$iz, _$in, _$ik, _$iZ, _$iA, _$ie; + var q = []; + var e = 190; + var i, g; + l3: for (; ; ) { + switch (t[e++]) { + case 3: + q.push(t[e++]); + break; + case 6: + _$ik = q[q.length - 1]; + break; + case 7: + q.push(m8); + break; + case 10: + q.push(_$iA++); + break; + case 11: + _$iz = q[q.length - 1]; + break; + case 15: + return; + break; + case 16: + q.push(_$b); + break; + case 17: + q.push(null); + break; + case 19: + q[q.length - 1] = q[q.length - 1].length; + break; + case 24: + if (q[q.length - 1]) { + ++e; + --q.length; + } else + e += t[e]; + break; + case 26: + q.push(_$iZ); + break; + case 27: + i = q.pop(); + q[q.length - 1] /= i; + break; + case 31: + return q.pop(); + break; + case 36: + _$iZ = q[q.length - 1]; + break; + case 39: + q.push(_$ik); + break; + case 41: + q.push(_$iP); + break; + case 42: + q.pop(); + break; + case 43: + i = q.pop(); + q[q.length - 1] = q[q.length - 1] < i; + break; + case 44: + _$iA = q[q.length - 1]; + break; + case 47: + if (q[q.length - 2] != null) { + q[q.length - 3] = w.call(q[q.length - 3], q[q.length - 2], q[q.length - 1]); + q.length -= 2; + } else { + i = q[q.length - 3]; + q[q.length - 3] = i(q[q.length - 1]); + q.length -= 2; + } + break; + case 50: + if (q.pop()) + ++e; + else + e += t[e]; + break; + case 51: + q.push(mQ); + break; + case 52: + i = q.pop(); + q[q.length - 1] -= i; + break; + case 55: + q.push(_$iz); + break; + case 57: + _$id = q[q.length - 1]; + break; + case 63: + mQ = q[q.length - 1]; + break; + case 64: + i = q.pop(); + q[q.length - 1] = q[q.length - 1] === i; + break; + case 66: + q[q.length - 4] = w.call(q[q.length - 4], q[q.length - 3], q[q.length - 2], q[q.length - 1]); + q.length -= 3; + break; + case 67: + q.push(_$ih); + break; + case 68: + if (q.pop()) + e += t[e]; + else + ++e; + break; + case 70: + _$in = q[q.length - 1]; + break; + case 71: + q.push(_$in); + break; + case 73: + q.push(_$ir); + break; + case 74: + q.push(_$ie); + break; + case 76: + q.push(q[q.length - 1]); + q[q.length - 2] = q[q.length - 2][_1sxbm[19 + t[e++]]]; + break; + case 77: + q.push(_$iz++); + break; + case 78: + i = q.pop(); + q[q.length - 1] *= i; + break; + case 79: + e += t[e]; + break; + case 80: + i = q.pop(); + q[q.length - 1] += i; + break; + case 81: + q.push(_$iB); + break; + case 82: + i = q.pop(); + q[q.length - 1] %= i; + break; + case 83: + _$iB = q[q.length - 1]; + break; + case 84: + q.push(_1sxbm[19 + t[e++]]); + break; + case 85: + _$ie = q[q.length - 1]; + break; + case 87: + q.push(_$iK); + break; + case 89: + _$ip = q[q.length - 1]; + break; + case 92: + q.push(_$ip); + break; + case 93: + _$ih = q[q.length - 1]; + break; + case 94: + q.push(_$iA); + break; + case 95: + _$iK = q[q.length - 1]; + break; + case 98: + q.push(_$id); + break; + case 99: + q.push(new Array(t[e++])); + break; + } + } + }, + 'blockSize': 0x10, + '_createHelper': function(_$iP) { + return function(_$iK, _$iB) { + return new _$iP.init(_$iB).finalize(_$iK); + } + ; + }, + '_createHmacHelper': function(_$iP) { + return function(_$iK, _$iB) { + return new _$im.HMAC.init(_$iP,_$iB).finalize(_$iK); + } + ; + } + }); + var _$im = _$iW.algo = {}; + return _$iW; + }(Math), + _$cf), + function(_$ir, _$ix) { + var _$iX = { + 'rudEC': function(_$iT, _$iD, _$iL, _$iW, _$iV, _$iH, _$iu, _$iM) { + return _$b.XgRJS(_$iT, _$iD, _$iL, _$iW, _$iV, _$iH, _$iu, _$iM); + } + }; + _$ir.exports = function(_$iT) { + var mU = a092750F + , _$iD = { + 'tzGRM': mU(0x142), + 'VSzRl': function(_$iL, _$iW) { + return _$iL * _$iW; + }, + 'OEdpM': function(_$iL, _$iW) { + return _$iL >>> _$iW; + }, + 'glDYL': function(_$iL, _$iW) { + return _$iL & _$iW; + }, + 'KOyFQ': function(_$iL, _$iW) { + return _$iL << _$iW; + }, + 'sjMuk': function(_$iL, _$iW) { + return _$iL / _$iW; + }, + 'PdFgE': function(_$iL, _$iW) { + return _$b.tkxAg(_$iL, _$iW); + }, + 'qJheA': function(_$iL, _$iW) { + return _$b.FnlYr(_$iL, _$iW); + }, + 'koDiw': function(_$iL, _$iW) { + return _$iL | _$iW; + }, + 'VAbVB': function(_$iL, _$iW) { + return _$iL | _$iW; + }, + 'INPxq': function(_$iL, _$iW) { + return _$b.UbpYh(_$iL, _$iW); + }, + 'eUTvr': function(_$iL, _$iW) { + return _$iL + _$iW; + }, + 'fKwpf': function(_$iL, _$iW) { + return _$iL & _$iW; + }, + 'CbYEL': function(_$iL, _$iW) { + return _$iL << _$iW; + }, + 'VIhPW': function(_$iL, _$iW) { + return _$iL ^ _$iW; + }, + 'CISQe': function(_$iL, _$iW) { + return _$iL << _$iW; + } + }; + return function(_$iL) { + var _$iW = { + 'roprc': function(_$iK, _$iB) { + return _$iK | _$iB; + }, + 'nTzdF': function(_$iK, _$iB) { + return _$iK >>> _$iB; + }, + 'wDpfx': function(_$iK, _$iB) { + return _$iK + _$iB; + }, + 'zqurR': function(_$iK, _$iB) { + return _$iK + _$iB; + }, + 'bQbAp': function(_$iK, _$iB) { + return _$iK + _$iB; + }, + 'VluWv': function(_$iK, _$iB, _$id, _$ih, _$ip, _$iz, _$in, _$ik) { + return _$iK(_$iB, _$id, _$ih, _$ip, _$iz, _$in, _$ik); + }, + 'KKQDk': function(_$iK, _$iB, _$id, _$ih, _$ip, _$iz, _$in, _$ik) { + return _$iK(_$iB, _$id, _$ih, _$ip, _$iz, _$in, _$ik); + }, + 'iYgyH': function(_$iK, _$iB, _$id, _$ih, _$ip, _$iz, _$in, _$ik) { + return _$iK(_$iB, _$id, _$ih, _$ip, _$iz, _$in, _$ik); + }, + 'HWqws': function(_$iK, _$iB, _$id, _$ih, _$ip, _$iz, _$in, _$ik) { + return _$iX.rudEC(_$iK, _$iB, _$id, _$ih, _$ip, _$iz, _$in, _$ik); + }, + 'lyghc': function(_$iK, _$iB, _$id, _$ih, _$ip, _$iz, _$in, _$ik) { + return _$iK(_$iB, _$id, _$ih, _$ip, _$iz, _$in, _$ik); + }, + 'kkzeT': function(_$iK, _$iB, _$id, _$ih, _$ip, _$iz, _$in, _$ik) { + return _$iK(_$iB, _$id, _$ih, _$ip, _$iz, _$in, _$ik); + }, + 'nMdrD': function(_$iK, _$iB, _$id, _$ih, _$ip, _$iz, _$in, _$ik) { + return _$iX.rudEC(_$iK, _$iB, _$id, _$ih, _$ip, _$iz, _$in, _$ik); + }, + 'vtClc': function(_$iK, _$iB, _$id, _$ih, _$ip, _$iz, _$in, _$ik) { + return _$iK(_$iB, _$id, _$ih, _$ip, _$iz, _$in, _$ik); + }, + 'kvuHo': function(_$iK, _$iB) { + return _$iK & _$iB; + } + } + , _$iV = _$iT + , _$iH = _$iV.lib + , _$iu = _$iH.WordArray + , _$iM = _$iH.Hasher + , _$ic = _$iV.algo + , _$iO = []; + !function() { + for (var _$iK = 0x1959 + -0x3ca + 0x1 * -0x158f; _$iK < -0x275 * -0xc + -0xad4 + -0x1268; _$iK++) + _$iO[_$iK] = (0x1 * 0x5697bd4c + 0x22 * 0x369c256 + 0x355c7348) * _$iL.abs(_$iL.sin(_$iK + (-0x1259 + 0x661 + 0xbf9))) | 0xca + -0xa23 + 0x959; + }(); + var _$ii = _$ic.MD5 = _$iM.extend({ + '_doReset': function() { + this._hash = new _$iu.init([-0x94de61c7 + 0xb4b2c3dd + -0x5f0f * -0xc065, -0x1dbeceac7 * 0x1 + 0xea186050 + 0x1e1a23600, 0xe14f * -0x4492 + 0x2 * 0x6d2323e + -0x10527e * -0xc38, 0x59ca853 * 0x1 + -0xaaf5703 + 0x15450326]); + }, + '_doProcessBlock': function(_$iK, _$iB) { + for (var _$id = 0x1922 + -0x11 * 0xda + -0xaa8; _$id < -0x1044 + -0x18b8 + -0x4 * -0xa43; _$id++) { + var _$ih = _$iB + _$id + , _$ip = _$iK[_$ih]; + _$iK[_$ih] = 0x1 * -0xb66370 + 0x2317c7 * -0x3 + 0x21eabc4 & _$iW.roprc(_$ip << -0x324 + 0x1aaa + -0x61 * 0x3e, _$ip >>> 0x26f5 + 0x1aab + -0x4188) | 0x1d529e971 + -0x5d606dda + 0x2dd45 * -0x2a2b & (_$ip << -0x60b + 0x1992 + 0x5 * -0x3e3 | _$iW.nTzdF(_$ip, -0x2 * 0xc67 + -0x15ab * 0x1 + 0x2e81)); + } + var _$iz = this._hash.words + , _$in = _$iK[_$iB + (-0x397 * 0x5 + 0x3 * -0xb4c + 0x33d7)] + , _$ik = _$iK[_$iB + (0x8b * -0xf + -0x234e * -0x1 + 0x16 * -0x13c)] + , _$iZ = _$iK[_$iB + (-0xe * -0x1d7 + -0x4d1 * 0x6 + -0x326 * -0x1)] + , _$iA = _$iK[_$iB + (-0x1382 + 0x1 * -0x189f + 0x2c24)] + , _$ie = _$iK[_$iB + (0x16e4 + -0x10 * -0x266 + -0x3d40)] + , _$is = _$iK[_$iB + (-0xb55 * -0x1 + -0x1313 + 0x7c3)] + , _$ia = _$iK[_$iB + (0x199 * 0x4 + 0x14c2 + 0x6c8 * -0x4)] + , _$ig = _$iK[_$iB + (0x1a33 + -0xb * 0x89 + -0x1449)] + , _$iq = _$iK[_$iB + (0x22 * -0xed + -0x117f * 0x1 + 0x3101)] + , _$iJ = _$iK[_$iB + (-0x18ba + -0x5 * 0x379 + 0xa88 * 0x4)] + , _$iC = _$iK[_$iW.wDpfx(_$iB, -0x61d * 0x6 + -0xd3f * 0x1 + 0x31f7)] + , _$iR = _$iK[_$iW.zqurR(_$iB, 0x85 * -0x1f + -0x39 * 0x33 + 0x1b81)] + , _$io = _$iK[_$iB + (-0xb * 0x29 + 0x4 * -0x10 + 0x20f)] + , _$iy = _$iK[_$iB + (-0x1575 + -0x633 + 0x1bb5)] + , _$iY = _$iK[_$iB + (0xa23 + 0x4 * 0x49d + -0x1e7 * 0xf)] + , _$iS = _$iK[_$iW.bQbAp(_$iB, -0x1ffc + -0x1 * 0x21ec + -0x3 * -0x15fd)] + , _$iG = _$iz[-0x474 + 0x3 * 0xa19 + -0x19d7] + , _$if = _$iz[-0x1318 + 0x137c + -0x63] + , _$iv = _$iz[0x1fc6 + 0x1cf2 + 0x332 * -0x13] + , _$it = _$iz[0x1ac3 + 0x719 + 0x6c5 * -0x5]; + _$iG = _$iE(_$iG, _$if, _$iv, _$it, _$in, 0xce3 * -0x2 + -0xe87 + 0x2854, _$iO[-0xf44 + 0x25 * -0x23 + 0x79 * 0x2b]), + _$it = _$iE(_$it, _$iG, _$if, _$iv, _$ik, -0x18e8 + 0x17d0 + 0x124, _$iO[0x1323 + 0x20cc + -0x22 * 0x187]), + _$iv = _$iE(_$iv, _$it, _$iG, _$if, _$iZ, -0x628 * -0x1 + -0x1ab7 * 0x1 + 0x14a0, _$iO[-0x1cc8 + -0xd39 + 0x2a03]), + _$if = _$iE(_$if, _$iv, _$it, _$iG, _$iA, -0x1d09 + 0x52c * -0x2 + -0x2777 * -0x1, _$iO[-0x7df * -0x1 + 0xa70 + -0x124c]), + _$iG = _$iE(_$iG, _$if, _$iv, _$it, _$ie, 0x5d4 + 0xa54 + 0x1021 * -0x1, _$iO[-0x5 * 0x577 + 0x2461 + -0x1a * 0x59]), + _$it = _$iE(_$it, _$iG, _$if, _$iv, _$is, -0xb * 0x116 + -0x7e4 + 0x1fd * 0xa, _$iO[-0x985 * 0x1 + 0x134d + -0x9c3]), + _$iv = _$iE(_$iv, _$it, _$iG, _$if, _$ia, 0x1db5 + -0x824 * 0x2 + -0xd5c, _$iO[-0x376 + -0x12d9 + 0x1 * 0x1655]), + _$if = _$iE(_$if, _$iv, _$it, _$iG, _$ig, -0x5 * -0x24f + 0xb89 + -0x16fe, _$iO[0x1 * 0xcaa + 0x12b6 + -0x5 * 0x645]), + _$iG = _$iE(_$iG, _$if, _$iv, _$it, _$iq, 0x602 + 0x2219 + -0xbe * 0x36, _$iO[0x15a7 * 0x1 + 0x1679 + -0x2c18]), + _$it = _$iE(_$it, _$iG, _$if, _$iv, _$iJ, -0x19d4 + -0xbd7 + 0x25b7, _$iO[0x60 * 0x4e + -0x78f + -0x15a8]), + _$iv = _$iW.VluWv(_$iE, _$iv, _$it, _$iG, _$if, _$iC, -0x928 * 0x1 + 0x1dfe + -0xd * 0x199, _$iO[-0x1f19 + -0x1 * -0x1d20 + -0x5 * -0x67]), + _$if = _$iW.VluWv(_$iE, _$if, _$iv, _$it, _$iG, _$iR, 0x15c4 + -0x251 + -0x135d, _$iO[0xb1d * -0x2 + 0x1ebb + 0x1 * -0x876]), + _$iG = _$iE(_$iG, _$if, _$iv, _$it, _$io, -0x148a + -0x2af * 0x7 + 0x275a, _$iO[0x35 * -0x9b + 0x7df + 0x2 * 0xc22]), + _$it = _$iE(_$it, _$iG, _$if, _$iv, _$iy, 0x223b + 0x2335 + -0x4564 * 0x1, _$iO[0x1a13 + 0x1aa4 + 0xd6 * -0x3f]), + _$iv = _$iW.KKQDk(_$iE, _$iv, _$it, _$iG, _$if, _$iY, -0xdf * -0x25 + 0x21 * 0xdd + -0x3ca7, _$iO[0x92c + 0xc89 * -0x1 + 0x36b]), + _$iG = _$im(_$iG, _$if = _$iW.iYgyH(_$iE, _$if, _$iv, _$it, _$iG, _$iS, 0x3d * 0x8e + -0x3 * -0x7eb + 0x132b * -0x3, _$iO[-0x24b + 0x1d8e + -0xd9a * 0x2]), _$iv, _$it, _$ik, 0x22fa + -0x26d + -0x8 * 0x411, _$iO[0x1c3 * 0xb + -0xd * -0x1cd + -0x2aba]), + _$it = _$im(_$it, _$iG, _$if, _$iv, _$ia, 0x120f + 0x1bb4 + -0x2dba, _$iO[0xa2e + 0xdb7 * 0x1 + -0x17d4]), + _$iv = _$iW.iYgyH(_$im, _$iv, _$it, _$iG, _$if, _$iR, -0xa7e * 0x2 + 0x4ef * -0x2 + 0x1ee8, _$iO[0x1988 + 0xfad + -0x1 * 0x2923]), + _$if = _$im(_$if, _$iv, _$it, _$iG, _$in, -0x2174 + 0x4e7 * -0x1 + 0x266f * 0x1, _$iO[-0x7 * 0x4d5 + -0xd0a + -0x5de * -0x8]), + _$iG = _$im(_$iG, _$if, _$iv, _$it, _$is, -0x2d5 + 0x536 * 0x1 + -0x25c, _$iO[-0x164b + -0x1 * -0x1b28 + -0x4c9]), + _$it = _$im(_$it, _$iG, _$if, _$iv, _$iC, -0x19 * -0xcb + 0xefc + -0x1163 * 0x2, _$iO[0x1 * -0x90b + -0xcac + 0x15cc]), + _$iv = _$im(_$iv, _$it, _$iG, _$if, _$iS, -0x3 * -0xbc3 + 0x1950 + -0x3c8b, _$iO[-0x4 * 0x527 + 0x13fc * -0x1 + 0x2 * 0x1457]), + _$if = _$im(_$if, _$iv, _$it, _$iG, _$ie, -0x2434 + -0x26da + 0x4b22, _$iO[0xffd * -0x1 + 0x127 * 0x13 + -0x5d1 * 0x1]), + _$iG = _$im(_$iG, _$if, _$iv, _$it, _$iJ, -0x2e9 + 0xb92 * -0x3 + 0x25a4, _$iO[0x6 * 0x5ff + 0x767 * -0x2 + -0x1514]), + _$it = _$iW.iYgyH(_$im, _$it, _$iG, _$if, _$iv, _$iY, -0x1b1f + 0x920 + 0x1208, _$iO[-0xbe4 + 0xe35 + 0x1 * -0x238]), + _$iv = _$iW.HWqws(_$im, _$iv, _$it, _$iG, _$if, _$iA, 0xa * -0x122 + 0x4b7 + 0x6ab, _$iO[-0xd74 + 0x12d8 + -0x2 * 0x2a5]), + _$if = _$im(_$if, _$iv, _$it, _$iG, _$iq, 0x13c0 + -0xf7e + 0x42e * -0x1, _$iO[0x10dd + 0x1106 + -0x21c8]), + _$iG = _$im(_$iG, _$if, _$iv, _$it, _$iy, 0xfb9 + 0x1 * 0x12bf + -0x2273, _$iO[0x18c9 + -0x1548 + -0x1 * 0x365]), + _$it = _$im(_$it, _$iG, _$if, _$iv, _$iZ, -0x738 + -0x65 * 0x2b + 0x64 * 0x3e, _$iO[-0x4a * -0x2a + -0x16 * 0x17b + 0x148b * 0x1]), + _$iv = _$im(_$iv, _$it, _$iG, _$if, _$ig, 0xce7 + 0x1 * -0x1a21 + 0x154 * 0xa, _$iO[0x1 * -0xfcb + 0x1 * -0xe1 + -0x7 * -0x266]), + _$iG = _$iN(_$iG, _$if = _$im(_$if, _$iv, _$it, _$iG, _$io, 0x25b5 * -0x1 + -0x634 * -0x4 + -0x3 * -0x453, _$iO[0x1e24 + -0xd * 0x5 + 0x9ec * -0x3]), _$iv, _$it, _$is, -0x2035 * -0x1 + -0x232e + 0x3 * 0xff, _$iO[-0xf6a + -0xe4 * -0x25 + 0x6 * -0x2e7]), + _$it = _$iW.lyghc(_$iN, _$it, _$iG, _$if, _$iv, _$iq, -0x46c * 0x4 + 0x606 + -0x51 * -0x25, _$iO[-0xd72 + 0x869 + 0x295 * 0x2]), + _$iv = _$iN(_$iv, _$it, _$iG, _$if, _$iR, -0x3 * -0x4c1 + -0xaf4 * 0x3 + -0x119 * -0x11, _$iO[-0x31 * -0x7f + 0x17c7 + 0x174 * -0x21]), + _$if = _$iW.kkzeT(_$iN, _$if, _$iv, _$it, _$iG, _$iY, -0xe * 0x95 + 0x3 * 0xb55 + -0x1 * 0x19c2, _$iO[0x11ad + 0x1e4 + -0x136e]), + _$iG = _$iN(_$iG, _$if, _$iv, _$it, _$ik, 0x8d5 + 0xf8f + -0xa0 * 0x27, _$iO[0x725 * 0x3 + -0x2 * -0x628 + -0x219b * 0x1]), + _$it = _$iN(_$it, _$iG, _$if, _$iv, _$ie, 0xbfc + 0xd6 + 0x1 * -0xcc7, _$iO[0x193 * 0xb + 0x21de + 0x2f * -0x116]), + _$iv = _$iN(_$iv, _$it, _$iG, _$if, _$ig, 0x297 + 0xf5b * -0x1 + -0xcd4 * -0x1, _$iO[-0x25b * -0x6 + 0x3c4 * 0x9 + -0x1 * 0x2fe0]), + _$if = _$iN(_$if, _$iv, _$it, _$iG, _$iC, -0xd1f + -0x44d * 0x3 + -0x1a1d * -0x1, _$iO[-0x4bb * 0x1 + 0xc9 + -0x419 * -0x1]), + _$iG = _$iN(_$iG, _$if, _$iv, _$it, _$iy, -0x2 * 0x9eb + 0x1 * -0x1b31 + 0x2f0b, _$iO[0xb58 + 0x170b + -0x223b]), + _$it = _$iN(_$it, _$iG, _$if, _$iv, _$in, -0x4fc + -0xc85 + -0x2 * -0x8c6, _$iO[-0x1cc7 * -0x1 + 0x2274 + -0x3f12]), + _$iv = _$iN(_$iv, _$it, _$iG, _$if, _$iA, 0x1 * 0x1b1 + 0x6 * 0x419 + 0x3 * -0x8bd, _$iO[-0xd * -0xd + -0x1afb + -0x1a7c * -0x1]), + _$if = _$iN(_$if, _$iv, _$it, _$iG, _$ia, 0x1bad + 0x2 * 0x171 + -0xa28 * 0x3, _$iO[-0x181f + 0x1ad9 + 0x83 * -0x5]), + _$iG = _$iN(_$iG, _$if, _$iv, _$it, _$iJ, 0xd42 + 0x200a + 0x228 * -0x15, _$iO[0xf07 + -0x1 * -0xe63 + -0x1d3e]), + _$it = _$iN(_$it, _$iG, _$if, _$iv, _$io, -0x24b * 0x8 + -0x2f6 * 0xd + 0x38e1, _$iO[0x21d * 0xd + 0xac7 * 0x1 + 0x39 * -0xab]), + _$iv = _$iN(_$iv, _$it, _$iG, _$if, _$iS, 0x257f + 0x116e + -0x36dd, _$iO[0x1aa1 + 0xf0d * 0x1 + -0x2980 * 0x1]), + _$iG = _$iP(_$iG, _$if = _$iW.nMdrD(_$iN, _$if, _$iv, _$it, _$iG, _$iZ, -0x1bb * -0x2 + -0x1 * 0x1c1d + -0x2 * -0xc5f, _$iO[-0xd22 * -0x1 + -0x22ec + 0x15f9]), _$iv, _$it, _$in, -0x17fa + 0x1ab9 + 0x2b9 * -0x1, _$iO[-0x20c1 + -0x4a * 0x4a + 0x3655]), + _$it = _$iP(_$it, _$iG, _$if, _$iv, _$ig, 0x188b + -0x1acf * 0x1 + -0x5 * -0x76, _$iO[-0x2d5 * 0x1 + 0x1133 + -0xbf * 0x13]), + _$iv = _$iW.vtClc(_$iP, _$iv, _$it, _$iG, _$if, _$iY, -0x215c + -0x13f4 + -0xd * -0x41b, _$iO[-0x4 * -0x572 + 0x8d * -0x1b + -0x1 * 0x6b7]), + _$if = _$iP(_$if, _$iv, _$it, _$iG, _$is, 0x20da * 0x1 + -0x42 * -0x53 + 0x7 * -0x7bd, _$iO[0x22da + 0xdeb + -0x3092]), + _$iG = _$iP(_$iG, _$if, _$iv, _$it, _$io, 0x2dd * 0x9 + -0xc6b + -0xd54, _$iO[0x14 * 0x61 + -0x30 * 0x34 + 0x260]), + _$it = _$iP(_$it, _$iG, _$if, _$iv, _$iA, 0x15bf * -0x1 + -0x4 * 0x7f9 + 0x35ad, _$iO[-0x1141 + -0x209 * 0x13 + 0x1 * 0x3821]), + _$iv = _$iW.vtClc(_$iP, _$iv, _$it, _$iG, _$if, _$iC, -0x49 * -0x61 + 0x19f7 + 0x11db * -0x3, _$iO[-0x4 * 0x135 + 0x9bb + 0x1 * -0x4b1]), + _$if = _$iP(_$if, _$iv, _$it, _$iG, _$ik, 0x1350 + -0xb88 + -0x7b3, _$iO[0x6c5 * -0x2 + -0x7 * -0x4a + 0xbbb * 0x1]), + _$iG = _$iP(_$iG, _$if, _$iv, _$it, _$iq, 0xbc7 + 0x34 * -0x10 + -0x881, _$iO[-0x1 * -0x45f + -0x1 * -0xe0b + -0x919 * 0x2]), + _$it = _$iP(_$it, _$iG, _$if, _$iv, _$iS, 0x2092 + -0x2 * 0xd00 + -0x4 * 0x1a2, _$iO[0x3 * -0xb9d + -0x19 * -0x71 + 0x1807]), + _$iv = _$iP(_$iv, _$it, _$iG, _$if, _$ia, -0x11 * 0xdd + -0x229d + 0x3159, _$iO[-0x57 * -0x15 + 0x439 + 0xbe * -0xf]), + _$if = _$iP(_$if, _$iv, _$it, _$iG, _$iy, 0x1bae + 0x2b * 0xa3 + -0x36fa, _$iO[0xbec + -0x14a5 + -0x1 * -0x8f4]), + _$iG = _$iW.iYgyH(_$iP, _$iG, _$if, _$iv, _$it, _$ie, 0x1ffd + 0x44d + -0x2444, _$iO[0x2085 + 0x7 * -0x31c + 0xa85 * -0x1]), + _$it = _$iP(_$it, _$iG, _$if, _$iv, _$iR, 0x1 * -0x6e1 + -0xd * 0x11c + 0x1557, _$iO[0x7 * 0x12e + -0x24 * 0x30 + -0x145]), + _$iv = _$iP(_$iv, _$it, _$iG, _$if, _$iZ, -0x285 + -0x3 * -0xbae + -0x2076 * 0x1, _$iO[-0xcbd + -0x1e27 + -0x1591 * -0x2]), + _$if = _$iP(_$if, _$iv, _$it, _$iG, _$iJ, 0x1 * -0xf53 + -0x465 * 0x1 + 0x25 * 0x89, _$iO[-0xb * -0x16f + 0x1604 + -0x258a]), + _$iz[0xbfb + -0x104d + -0x7 * -0x9e] = _$iW.roprc(_$iz[-0x1e69 + 0x118c * 0x2 + -0x4af] + _$iG, -0x81 + 0x76 * -0x27 + 0x127b), + _$iz[-0x12ae + -0x1f31 + 0x31e0] = _$iz[-0x63d + -0x57e + -0x5de * -0x2] + _$if | 0x1 * -0xf88 + -0x464 * -0x2 + 0x9 * 0xc0, + _$iz[-0x11 * -0x21e + -0x8a5 * -0x1 + -0x2ca1] = _$iz[0x233b + 0x1f25 + -0x6a3 * 0xa] + _$iv | -0x7 * -0x115 + -0x5 * 0x47a + 0xdf * 0x11, + _$iz[-0x946 + 0x124d + 0x1 * -0x904] = _$iz[0x65 + 0xee0 + -0x117 * 0xe] + _$it | -0x1 * -0xfdf + 0x397 * 0x2 + -0x170d; + }, + '_doFinalize': function() { + var _$iK = _$iD.tzGRM.split('|') + , _$iB = -0xf37 + 0xda5 + 0x192; + while (!![]) { + switch (_$iK[_$iB++]) { + case '0': + var _$id = this._data + , _$ih = _$id.words + , _$ip = _$iD.VSzRl(0x1a7a + 0x1f5a + -0x39cc, this._nDataBytes) + , _$iz = (-0x23e7 + 0x2a1 + -0x1d * -0x126) * _$id.sigBytes; + continue; + case '1': + return _$in; + case '2': + for (var _$in = this._hash, _$ik = _$in.words, _$iZ = -0x10ec + 0x224d + -0x1161; _$iZ < -0x2477 * -0x1 + 0x13e6 + -0x5 * 0xb45; _$iZ++) { + var _$iA = _$ik[_$iZ]; + _$ik[_$iZ] = -0x1 * -0x1b7f8a2 + 0x391951 * 0x7 + -0x248a8da & (_$iA << -0x1fea + -0x1920 + 0x3912 | _$iD.OEdpM(_$iA, 0x1 * -0x610 + -0xc37 + -0x125f * -0x1)) | _$iD.glDYL(-0x834d075c * 0x1 + 0x3970aa0e + 0x1 * 0x148dd5c4e, _$iA << 0x15c7 + -0x11d1 + -0x3de | _$iA >>> 0x1307 + 0x106 * 0x10 + -0x235f); + } + continue; + case '3': + _$ih[_$iz >>> 0x774 + -0x1 * -0x1063 + -0x17d2 * 0x1] |= _$iD.KOyFQ(-0x6b * -0x44 + 0x1e29 + -0x3a15, -0x1 * -0x116e + 0x24e8 + -0x363e - _$iz % (0x1f4c + -0x205a + -0x1 * -0x12e)); + continue; + case '4': + var _$ie = _$iL.floor(_$iD.sjMuk(_$ip, -0x76 * 0x1c221a0 + 0xc * 0x215f37b7 + 0x3f04e32c)) + , _$is = _$ip; + continue; + case '5': + _$ih[_$iD.PdFgE(0x5c * -0xd + 0x10fc + -0xc41, _$iD.qJheA(_$iz + (0x1974 + 0x11a5 + -0x2ad9), -0x11b9 + -0x862 + -0x1de * -0xe) << -0x1b93 + -0x14c0 + 0xa5 * 0x4b)] = 0x3cace3 + 0x6e77d4 + -0x7f08 * -0xa9 & _$iD.koDiw(_$ie << -0x113 * 0x1d + -0x2 * 0xf27 + 0x3d7d, _$ie >>> 0xc48 * -0x2 + -0x1602 + 0x42 * 0xb5) | -0xff0d6255 + 0x12279bc0f + 0xdb94a546 * 0x1 & _$iD.VAbVB(_$ie << -0x26 * 0x61 + 0x1b3a + -0x28c * 0x5, _$iD.OEdpM(_$ie, -0x1 * 0x1558 + -0x10e1 + 0x2641)), + _$ih[-0x1518 + 0x611 * -0x4 + 0x2d6a + (_$iz + (-0x20 * 0x10e + -0x1d6d + 0x3f6d) >>> 0x31 * -0x29 + 0x6e5 + 0xfd << -0x9d1 + -0x4 * 0x491 + 0x1c19)] = _$iD.glDYL(0xc17431 + 0x174943d + 0x39 * -0x574e7, _$iD.INPxq(_$is, 0x207c + -0x3 * -0x15d + -0x248b * 0x1) | _$is >>> -0x1cc2 + -0x1 * 0x7c7 + 0x24a1 * 0x1) | 0xc4ab6483 + -0x18c91b2ec + 0x1a8b9 * 0x11231 & (_$iD.INPxq(_$is, -0x44f + -0x1 * -0x1f0d + -0x1aa6) | _$is >>> -0x1 * 0x1d0e + 0x1d19 + -0x1 * 0x3), + _$id.sigBytes = (-0xe89 + 0x1cc3 + -0xe36) * (_$ih.length + (-0x1d19 + 0x3e4 + -0x1cd * -0xe)), + this._process(); + continue; + } + break; + } + }, + '_eData': function(_$iK) { + 'use strict'; + var t = _3nkbm; + var a = _2mzbm; + var mj; + var r = []; + var k = 372; + var i, j; + l4: for (; ; ) { + switch (a[k++]) { + case 4: + r.push(r[r.length - 1]); + r[r.length - 2] = r[r.length - 2][_1sxbm[29 + a[k++]]]; + break; + case 6: + k += a[k]; + break; + case 18: + return; + break; + case 21: + r[r.length - 4] = t.call(r[r.length - 4], r[r.length - 3], r[r.length - 2], r[r.length - 1]); + r.length -= 3; + break; + case 23: + r.push(null); + break; + case 26: + r.push(_$c5); + break; + case 28: + mj = r[r.length - 1]; + break; + case 29: + r.push(mj); + break; + case 32: + r.push(_$iK); + break; + case 39: + i = r.pop(); + r[r.length - 1] = r[r.length - 1] === i; + break; + case 40: + i = r.pop(); + r[r.length - 1] += i; + break; + case 45: + if (r[r.length - 2] != null) { + r[r.length - 3] = t.call(r[r.length - 3], r[r.length - 2], r[r.length - 1]); + r.length -= 2; + } else { + i = r[r.length - 3]; + r[r.length - 3] = i(r[r.length - 1]); + r.length -= 2; + } + break; + case 48: + return r.pop(); + break; + case 49: + r.pop(); + break; + case 56: + r.push(a092750F); + break; + case 81: + r.push(_$Ui); + break; + case 94: + if (r.pop()) + ++k; + else + k += a[k]; + break; + case 97: + r.push(a[k++]); + break; + } + } + }, + 'clone': function() { + var _$iK = _$iM.clone.call(this); + return _$iK._hash = this._hash.clone(), + _$iK; + }, + '_seData': function(_$iK) { + 'use strict'; + var b = _3nkbm; + var j = _2mzbm; + var mr; + var h = []; + var t = 428; + var q, x; + l5: for (; ; ) { + switch (j[t++]) { + case 1: + return h.pop(); + break; + case 4: + h.push(h[h.length - 1]); + h[h.length - 2] = h[h.length - 2][_1sxbm[31 + j[t++]]]; + break; + case 5: + h.push(_$c5); + break; + case 26: + h.push(a092750F); + break; + case 27: + h.push(j[t++]); + break; + case 33: + h.pop(); + break; + case 38: + h.push(mr); + break; + case 42: + return; + break; + case 46: + q = h.pop(); + h[h.length - 1] = h[h.length - 1] === q; + break; + case 49: + if (h[h.length - 2] != null) { + h[h.length - 3] = b.call(h[h.length - 3], h[h.length - 2], h[h.length - 1]); + h.length -= 2; + } else { + q = h[h.length - 3]; + h[h.length - 3] = q(h[h.length - 1]); + h.length -= 2; + } + break; + case 52: + h[h.length - 4] = b.call(h[h.length - 4], h[h.length - 3], h[h.length - 2], h[h.length - 1]); + h.length -= 3; + break; + case 58: + h.push(this); + break; + case 62: + t += j[t]; + break; + case 63: + q = h.pop(); + h[h.length - 1] += q; + break; + case 81: + h.push(_$iK); + break; + case 95: + h.push(null); + break; + case 97: + if (h.pop()) + ++t; + else + t += j[t]; + break; + case 99: + mr = h[h.length - 1]; + break; + } + } + } + }); + function _$iE(_$iK, _$iB, _$id, _$ih, _$ip, _$iz, _$in) { + var _$ik = _$iK + _$iW.roprc(_$iW.kvuHo(_$iB, _$id), ~_$iB & _$ih) + _$ip + _$in; + return (_$ik << _$iz | _$ik >>> 0x2696 + -0x13e3 + -0x1293 - _$iz) + _$iB; + } + function _$im(_$iK, _$iB, _$id, _$ih, _$ip, _$iz, _$in) { + var _$ik = _$iD.eUTvr(_$iK + (_$iD.fKwpf(_$iB, _$ih) | _$id & ~_$ih) + _$ip, _$in); + return (_$iD.CbYEL(_$ik, _$iz) | _$ik >>> -0x7b * -0x3e + -0x1a9c + -0x30e - _$iz) + _$iB; + } + function _$iN(_$iK, _$iB, _$id, _$ih, _$ip, _$iz, _$in) { + var _$ik = _$iK + (_$iD.VIhPW(_$iB, _$id) ^ _$ih) + _$ip + _$in; + return (_$iD.CISQe(_$ik, _$iz) | _$ik >>> -0x1430 + -0xdd5 + 0x2225 - _$iz) + _$iB; + } + function _$iP(_$iK, _$iB, _$id, _$ih, _$ip, _$iz, _$in) { + var _$ik = _$iK + (_$id ^ (_$iB | ~_$ih)) + _$ip + _$in; + return (_$ik << _$iz | _$iD.OEdpM(_$ik, 0x1 * -0x2b4 + -0x70a + 0x1 * 0x9de - _$iz)) + _$iB; + } + _$iV.MD5 = _$iM._createHelper(_$ii), + _$iV.HmacMD5 = _$iM._createHmacHelper(_$ii); + }(Math), + _$iT.MD5; + }(_$O0.exports); + }(_$c6); + var _$O2 = _$c6.exports + , _$O3 = { + 'exports': {} + }; + !function(_$ir, _$ix) { + _$ir.exports = function(_$iX) { + return _$iX.enc.Hex; + }(_$O0.exports); + }(_$O3); + var _$O4 = _$O3.exports; + function _$O5(_$ir) { + var mx = iw + , _$ix = new RegExp(_$b.lANMr(_$b.HkmvM + _$ir, mx(0x1ba))) + , _$iX = document.cookie.match(_$ix); + if (!_$iX || !_$iX[0x1 * -0x1d6b + -0x2 * -0x1 + 0x1 * 0x1d6b]) + return ''; + var _$iT = _$iX[-0x16c1 + 0x1d51 + -0x347 * 0x2]; + try { + return /(%[0-9A-F]{2}){2,}/.test(_$iT) ? decodeURIComponent(_$iT) : unescape(_$iT); + } catch (_$iD) { + return unescape(_$iT); + } + } + function _$O6() { + var mX = iw + , _$ir = _$b.oSeLM(arguments.length, -0x27 * -0xc3 + -0xb5c + 0x7 * -0x29f) && void (0xf * -0x1c8 + 0x176 + -0xca1 * -0x2) !== arguments[0x14e2 + 0x1d6d + -0x324f] ? arguments[0x52f * -0x5 + -0x3c5 + -0x32 * -0x98] : Date.now() + , _$ix = arguments.length > 0x807 * -0x4 + 0x1f55 + 0xc8 && void (0x1 * -0x19f9 + 0x88 * 0x10 + 0x1179) !== arguments[0x1c81 * 0x1 + -0x1409 + 0x877 * -0x1] ? arguments[-0x27 * -0x20 + -0x21c * -0x2 + 0x1 * -0x917] : mX(0x288); + _$ir += -0xe06 + 0x697 + 0x1 * 0x170f; + var _$iX = new Date(_$ir) + , _$iT = _$ix + , _$iD = { + 'M+': _$iX.getMonth() + (0x26a6 + 0x1 * 0x68b + 0x3 * -0xf10), + 'd+': _$iX.getDate(), + 'D+': _$iX.getDate(), + 'h+': _$iX.getHours(), + 'H+': _$iX.getHours(), + 'm+': _$iX.getMinutes(), + 's+': _$iX.getSeconds(), + 'w+': _$iX.getDay(), + 'q+': Math.floor((_$iX.getMonth() + (0x1e0e + 0x86a * 0x3 + -0x3749 * 0x1)) / (0xe1d + 0x5e3 * 0x2 + -0x19e0)), + 'S+': _$iX.getMilliseconds() + }; + return /(y+)/i.test(_$iT) && (_$iT = _$iT.replace(RegExp.$1, ''.concat(_$iX.getFullYear()).substr(_$b.eEANI(-0xa95 + 0x10c1 * -0x1 + 0x1 * 0x1b5a, RegExp.$1.length)))), + _$b.tOYMk(_$Hu, _$iD).forEach(function(_$iL) { + var mT = mX; + if (new RegExp('('.concat(_$iL, ')')).test(_$iT)) { + var _$iW, _$iV = _$b.KMYkn('S+', _$iL) ? mT(0x1e9) : '00'; + _$iT = _$iT.replace(RegExp.$1, _$b.HCXWR(0x1 * -0x12ae + -0x2064 + -0x20b * -0x19, RegExp.$1.length) ? _$iD[_$iL] : _$Ui(_$iW = ''.concat(_$iV)).call(_$iW, _$iD[_$iL]).substr(''.concat(_$iD[_$iL]).length)); + } + }), + _$iT; + } + function _$O7(_$ir) { + var mD = iw; + return mD(0x271) === Object.prototype.toString.call(_$ir); + } + function _$O8(_$ir) { + var mL = iw; + for (var _$ix = '', _$iX = mL(0x1c9); _$ir--; ) + _$ix += _$iX[(-0x1ee2 + -0x5 * 0x52f + 0x1 * 0x3903) * Math.random() | 0x17 * -0x83 + -0xf48 + -0x5 * -0x569]; + return _$b.TZyRi(_$ix.length, 0x1e49 + -0x1 * -0x2683 + 0x1 * -0x44c6) && (_$ix = _$ix.substring(0x1032 + -0x2550 + -0x66 * -0x35, 0x1655 + 0x5 * -0x34a + -0x5dd) + '3' + _$ix.substring(-0x232d + 0x71 * 0x23 + -0x40 * -0x4f, _$b.dvaBu(_$ix.length, -0xc80 * -0x1 + 0x542 * 0x2 + -0x1703))), + _$ix; + } + function _$O9() {} + function _$Ob(_$ir) { + return 'function' == typeof _$ir; + } + var _$OF = [iw(0x20a), _$b.XRnhu, iw(0x170)]; + function _$OQ(_$ir) { + if (_$ir) { + for (var _$ix, _$iX = arguments.length, _$iT = new Array(_$iX > 0x26c7 + 0x15b * -0x1c + 0x3 * -0x46 ? _$iX - (0x1a6 * -0x6 + -0x1 * -0x25fc + 0x3 * -0x95d) : -0x128c + 0x5d5 * -0x2 + 0x2 * 0xf1b), _$iD = 0x1 * 0x1d3b + -0x181a * -0x1 + -0x3554; _$iD < _$iX; _$iD++) + _$iT[_$iD - (0x25 * -0xa3 + 0x4e4 + -0x3bc * -0x5)] = arguments[_$iD]; + var _$iL = function(_$iW, _$iV) { + _$iV = _$iV || -0x1 * -0xe89 + 0x16b4 + -0x253d; + for (var _$iH = _$iW.length - _$iV, _$iu = new Array(_$iH); _$iH--; ) + _$iu[_$iH] = _$iW[_$iH + _$iV]; + return _$iu; + }(_$iT); + console.log.apply(console, _$Ui(_$ix = [_$b.nddbp]).call(_$ix, _$iL)); + } + } + function _$OU(_$ir) { + if (_$b.kKJKw(null, _$ir)) + throw new TypeError('Cannot convert undefined or null to object'); + _$ir = _$b.VOdbl(Object, _$ir); + for (var _$ix = -0x252c + 0x17 * -0xba + -0x59 * -0x9b; _$ix < arguments.length; _$ix++) { + var _$iX = arguments[_$ix]; + if (_$b.TpmCA(null, _$iX)) { + for (var _$iT in _$iX) + Object.prototype.hasOwnProperty.call(_$iX, _$iT) && (_$ir[_$iT] = _$iX[_$iT]); + } + } + return _$ir; + } + function _$Oj(_$ir) { + var mW = iw + , _$ix = arguments.length > 0x103f + 0x109a + -0x20d8 && void (-0x1b6 * -0x6 + 0x26f5 + -0x3139) !== arguments[-0x29 * 0x25 + -0x17f8 + 0x2b * 0xb2] ? arguments[-0x223 + 0x1 * 0x2708 + 0x24e4 * -0x1] : 0x2857 * -0x1 + 0x1 * 0x347d + -0x1 * -0x2e72 + , _$iX = _$Or(mW(0x251), {}); + return _$iX[_$ir] || (_$iX[_$ir] = new _$WK(function(_$iT, _$iD) { + var mV = mW + , _$iL = { + 'KsGml': function(_$iW, _$iV) { + return _$iW(_$iV); + }, + 'zhNmW': mV(0x15a) + }; + return function(_$iW) { + var _$iV = arguments.length > -0x6 * 0x181 + -0x23 * 0x103 + 0x2c70 && void (0xca6 * -0x3 + -0x4a * 0x76 + -0x2e * -0x191) !== arguments[-0xea2 + 0x26a5 + -0xe * 0x1b7] ? arguments[0xea7 + -0x996 + 0x90 * -0x9] : 0x2ac7 * 0x2 + -0x355d + 0x8cd * 0x3; + return new _$WK(function(_$iH, _$iu) { + var mH = a092750F + , _$iM = function(_$ii) { + return function(_$iE) { + _$ii(), + clearTimeout(_$ic), + _$iO.parentNode && _$iO.parentNode.removeChild(_$iO); + } + ; + } + , _$ic = setTimeout(_$iL.KsGml(_$iM, _$iu), _$iV) + , _$iO = document.createElement(_$iL.zhNmW); + _$iO.type = mH(0x1b6), + _$iO.readyState ? _$iO.onreadystatechange = function(_$ii) { + var mu = mH; + mu(0x272) !== _$iO.readyState && mu(0x1e3) !== _$iO.readyState || _$iM(_$iH)(); + } + : _$iO.onload = _$iM(_$iH), + _$iO.onerror = _$iL.KsGml(_$iM, _$iu), + _$iO.src = _$iW, + document.getElementsByTagName(mH(0x16f))[0x2 * -0x5 + 0xe * 0x117 + 0x1e7 * -0x8].appendChild(_$iO); + } + ); + }(_$ir, _$ix).then(function(_$iW) { + _$iT(); + }).catch(function(_$iW) { + delete _$iX[_$ir], + _$iD(); + }); + } + )), + _$iX[_$ir]; + } + function _$Or(_$ir) { + var _$ix, _$iX = arguments.length > -0x180b + 0x144a + 0x3c2 * 0x1 && void (-0xab2 * 0x2 + 0x214 + 0x1350) !== arguments[-0xf40 * 0x1 + -0x9f9 + 0x193a] ? arguments[-0x39f + -0x22ee + -0x1e * -0x149] : {}; + return window.__JDWEBSIGNHELPER_$DATA__ = window.__JDWEBSIGNHELPER_$DATA__ || {}, + window.__JDWEBSIGNHELPER_$DATA__[_$ir] = window.__JDWEBSIGNHELPER_$DATA__[_$ir] || ('function' == typeof (_$ix = _$iX) ? _$b.hnbAK(_$ix) : _$ix); + } + function _$Ox() { + var mM = iw + , _$ir = document.createElement(mM(0x238)) + , _$ix = _$ir.getContext('2d'); + return _$ix.fillStyle = mM(0x1bb), + _$ix.fillRect(-0x1 * -0x1c67 + -0x1 * -0x1003 + -0x2c4c, 0x166 * 0xd + 0x1997 * -0x1 + 0x773, 0x753 + -0x5 * 0x745 + -0xee7 * -0x2, 0xe97 + -0x20bb + 0x1288), + _$ix.strokeStyle = mM(0x2a8), + _$ix.lineWidth = 0x123 * 0x1 + -0x30c + 0x1ef, + _$ix.lineCap = mM(0x230), + _$ix.arc(-0x1c * -0x142 + -0x2d * 0x31 + -0x1a69 * 0x1, -0xd7 * 0xd + -0xc42 + 0x175f, 0x21a + -0x45e * 0x2 + 0x6b6, -0x976 + 0x1 * 0x10f6 + -0x780, Math.PI, !(0x20cf + 0x2557 + 0x1 * -0x4625)), + _$ix.stroke(), + _$ix.fillStyle = mM(0x1eb), + _$ix.font = mM(0x210), + _$ix.textBaseline = mM(0x2aa), + _$ix.fillText(mM(0x1e1), -0xb8f + 0x917 * 0x1 + 0x287, 0x13 * 0xfd + 0x5 * -0x4c5 + 0x54e), + _$ix.shadowOffsetX = -0x21a * 0x1 + 0x1f15 + 0xe7d * -0x2, + _$ix.shadowOffsetY = -0x1b3f + -0xdb8 + -0x1 * -0x28f9, + _$ix.shadowColor = _$b.rnTwj, + _$ix.fillStyle = mM(0x289), + _$ix.font = mM(0x239), + _$ix.fillText(mM(0x280), -0x4 * -0x47 + 0x1f04 + -0x1ff8, -0x1d25 + -0x1 * 0xef2 + 0x2c67), + _$O4.format(_$O2(mM(0x291).concat(_$ir.toDataURL()))); + } + function _$OX(_$ir) { + var mc = iw + , _$ix = _$My(_$ir); + return null != _$ir && (mc(0x146) === _$ix || _$b.gGFQj('function', _$ix)); + } + function _$OT(_$ir, _$ix, _$iX) { + if (!_$OX(_$ir)) + return _$ir; + for (var _$iT = _$ix.length, _$iD = _$b.kFlSY(_$iT, 0x2337 * 0x1 + 0x797 + -0x2acd), _$iL = -(0x14c8 + 0x5b3 + 0x1 * -0x1a7a), _$iW = _$ir; null != _$iW && ++_$iL < _$iT; ) { + var _$iV = _$ix[_$iL]; + if (_$iL === _$iD) + return void (_$iW[_$iV] = _$iX); + var _$iH = _$iW[_$iV]; + _$OX(_$iH) || (_$iH = {}, + _$iW[_$iV] = _$iH), + _$iW = _$iH; + } + return _$ir; + } + function _$OD(_$ir, _$ix) { + for (var _$iX = _$ix.length, _$iT = 0xdec + 0xf5e + 0xea5 * -0x2; _$b.IZIGW(null, _$ir) && _$iT < _$iX; ) { + _$ir = _$ir[_$ix[_$iT++]]; + } + return _$iT && _$iT === _$iX ? _$ir : void (0x7ec + -0x223a + -0x25 * -0xb6); + } + function _$OL(_$ir, _$ix) { + if (_$OX(_$ir)) + for (var _$iX in _$ir) { + if (!(-0x6de + 0x906 + 0x1 * -0x227) === _$ix(_$ir[_$iX], _$iX, _$ir)) + return; + } + } + function _$OW(_$ir) { + return !(!_$ir || !_$ir.t || !_$ir.e || _$b.jTBXS(0x918 + -0x16a * 0x1a + 0x1bac * 0x1, _$ir.e) || Date.now() - _$ir.t >= (0x1106 + 0x21f7 + 0x2f15 * -0x1) * _$ir.e || _$b.dmhRd(Date.now() - _$ir.t, 0x1d1f + 0xa6e + -0x278d)); + } + function _$OV(_$ir, _$ix, _$iX, _$iT) { + var _$iD = _$iT.context; + _$iT.error.call(_$iD, { + 'code': { + 'timeout': 0x1f40, + 'error': 0x1388, + 'load': 0xbcc, + 'abort': 0x1389, + 'parsererror': 0xbcd + }[_$ix] || -0x982 * -0x4 + 0x2552 + -0x2832, + 'message': _$ix + }, _$iT, _$ir, _$iX); + } + function _$OH(_$ir) { + var _$ix = { + 'oxUcU': function(_$iX, _$iT) { + return _$b.snYYn(_$iX, _$iT); + } + }; + return new _$WK(function(_$iX, _$iT) { + var _$iD = { + 'bEJId': function(_$iL, _$iW) { + return _$iL(_$iW); + } + }; + _$ir ? (_$ir.success = function(_$iL) { + try { + _$iX({ + 'body': _$iL + }); + } catch (_$iW) { + _$iD.bEJId(_$iT, { + 'code': 0x3e7, + 'message': _$iW + }); + } + } + , + _$ir.error = function(_$iL) { + _$ix.oxUcU(_$iT, _$iL); + } + , + function(_$iL) { + var mO = a092750F + , _$iW = { + 'uIciy': function(_$iE, _$im) { + return _$iE === _$im; + }, + 'DlFvS': function(_$iE, _$im, _$iN, _$iP, _$iK) { + return _$iE(_$im, _$iN, _$iP, _$iK); + }, + 'KDjgt': function(_$iE, _$im, _$iN, _$iP, _$iK) { + return _$iE(_$im, _$iN, _$iP, _$iK); + }, + 'CDJHr': mO(0x1da) + }; + if (!_$iL) + return !(-0x815 + 0x12b6 + -0xaa0); + _$iL.method = _$iL.method.toUpperCase(), + _$iL.noCredentials || (_$iL.xhrFields = { + 'withCredentials': !(0xb * 0xa3 + 0x7 * 0x539 + -0x2b90) + }); + var _$iV, _$iH = {}, _$iu = function(_$iE, _$im) { + _$iH[_$iE.toLowerCase()] = [_$iE, _$im]; + }, _$iM = new window.XMLHttpRequest(), _$ic = _$iM.setRequestHeader; + if ((_$iL.contentType || !(0xb5d + 0x19 * -0x55 + -0x30f) !== _$iL.contentType && _$iL.data && mO(0x16e) !== _$iL.method) && _$iu(mO(0x278), _$iL.contentType || mO(0x155)), + _$iu(mO(0x178), mO(0x29e)), + _$iM.setRequestHeader = _$iu, + _$iM.onreadystatechange = function() { + var mi = mO; + if (_$iW.uIciy(-0x1 * 0x2345 + 0x18a3 + -0x2 * -0x553, _$iM.readyState)) { + _$iM.onreadystatechange = function() {} + , + clearTimeout(_$iV); + var _$iE, _$im = !(0x1103 + -0x159b + 0x499); + if (_$iM.status >= 0x1e52 + 0x796 + -0x2520 && _$iM.status < -0x1a6 * -0x7 + -0x8d9 + -0x1 * 0x185 || _$iW.uIciy(-0x21ae + -0x7 * 0xbd + 0x2809, _$iM.status)) { + _$iE = _$iM.responseText; + try { + _$iE = JSON.parse(_$iE); + } catch (_$iN) { + _$im = _$iN; + } + _$im ? _$iW.DlFvS(_$OV, _$im, mi(0x267), _$iM, _$iL) : function(_$iP, _$iK, _$iB) { + var mE = mi + , _$id = _$iB.context + , _$ih = mE(0x1d2); + _$iB.success.call(_$id, _$iP, _$iB, _$ih, _$iK); + }(_$iE, _$iM, _$iL); + } else + _$iW.KDjgt(_$OV, _$iM.statusText || null, mi(0x1bd), _$iM, _$iL); + } + } + , + _$iL.xhrFields) { + for (var _$iO in _$iL.xhrFields) + _$iM[_$iO] = _$iL.xhrFields[_$iO]; + } + for (var _$ii in (_$iM.open(_$iL.method, _$iL.url), + _$iH)) + _$ic.apply(_$iM, _$iH[_$ii]); + _$iL.timeout > 0x23d7 + -0x5 * 0x20b + -0x19a * 0x10 && (_$iV = setTimeout(function() { + _$iM.onreadystatechange = function() {} + , + _$iM.abort(), + _$iW.DlFvS(_$OV, null, _$iW.CDJHr, _$iM, _$iL); + }, (0x2b * -0x29 + 0x1ee6 + -0x141b) * _$iL.timeout)), + _$iM.send(_$iL.data ? _$iL.data : null); + }(_$ir)) : _$iT(); + } + ); + } + function _$Ou(_$ir) { + return function(_$ix) { + return _$ix.method = _$ir, + _$OH(_$ix); + } + ; + } + !function() { + var mm = iw, _$ir = { + 'zcerT': function(_$iu, _$iM, _$ic) { + return _$iu(_$iM, _$ic); + } + }, _$ix, _$iX; + if (!(window.__MICRO_APP_ENVIRONMENT_TEMPORARY__ || window.__MICRO_APP_ENVIRONMENT__ || (null === (_$ix = window.rawWindow) || _$b.dnQje(void (0x1f * -0x115 + 0x1ac7 + 0x6c4), _$ix) ? void (-0xf88 + -0x20d8 * 0x1 + 0x60 * 0x81) : _$ix.__MICRO_APP_ENVIRONMENT__) || window.__MICRO_APP_PROXY_WINDOW__ || window.__MICRO_APP_BASE_APPLICATION__)) { + var _$iT, _$iD, _$iL, _$iW = _$Ha(_$iT = _$Hu(window.document)).call(_$iT, mm(0x295)), _$iV = (_$iX = window.document.querySelector, + function() { + var mN = mm; + try { + var _$iu = _$Or(mN(0x20f), {}) + , _$iM = new Error(mN(0x16a)); + _$iu.querySelector = _$iM.stack.toString(); + } catch (_$ic) {} + return _$iX.apply(this, arguments); + } + ), _$iH = function() { + var mP = mm; + try { + var _$iu = _$b.pYOMS(_$Or, _$b.wVpMY, {}) + , _$iM = new Error(mP(0x16a)); + _$iu.querySelector = _$iM.stack.toString(); + } catch (_$ic) {} + return Document.prototype.querySelector.apply(this, arguments); + }; + window.document.querySelector = _$iW ? _$iV : _$iH, + _$b.QefQe(_$Ha, _$iD = _$b.DjSUN(_$Hu, Element.prototype)).call(_$iD, mm(0x219)) && (Element.prototype.scrollIntoViewIfNeeded = function(_$iu) { + return function() { + var mK = a092750F; + try { + var _$iM = _$ir.zcerT(_$Or, mK(0x20f), {}) + , _$ic = _$iM.dp1 || 0x3d * -0x26 + -0x89b * 0x2 + -0xa4 * -0x29; + _$iM.dp1 = _$ic + (0x1305 + 0xb * -0xf2 + -0x89e * 0x1); + } catch (_$iO) {} + return _$iu.apply(this, arguments); + } + ; + }(Element.prototype.scrollIntoViewIfNeeded)), + _$Ha(_$iL = _$Hu(window)).call(_$iL, mm(0x1fb)) && (window.getComputedStyle = function(_$iu) { + return function() { + var mB = a092750F; + try { + var _$iM = _$ir.zcerT(_$Or, mB(0x20f), {}) + , _$ic = _$iM.dp2 || -0x1d22 + -0x7 * -0x37f + 0x4a9; + _$iM.dp2 = _$ic + (-0x73b + -0x3d1 + 0xb0d); + } catch (_$iO) {} + return _$iu.apply(this, arguments); + } + ; + }(window.getComputedStyle)); + } + _$Oj(_$b.aeZHV + _$O6(Date.now() - (-0x4 * 0x13c4a7 + -0x672355 + -0x114d * -0xdb5) * (0x1 * -0x11a7 + 0x1 * -0x1516 + 0x26be + 0.10000000000000009), mm(0x268)), -0x17c + -0xb * 0x265 + 0x1fbb).then(function(_$iu) {}).catch(function(_$iu) {}); + }(); + var _$OM = { + 'get': _$Ou(iw(0x16e)), + 'post': _$b.XhuVO(_$Ou, iw(0x152)) + } + , _$Oc = { + 'CANVAS_FP': iw(0x290), + 'WEBGL_FP': iw(0x202), + 'STORAGE_KEY_TK': iw(0x173), + 'STORAGE_KEY_VK': iw(0x263), + 'BEHAVIOR_FLAG': iw(0x1ae) + } + , _$OO = -0x15fe + 0x223c + 0xc3d * -0x1 + , _$Oi = 0x1767 * 0x1 + -0x2477 + -0x1 * -0xd12 + , _$OE = -0x115e + 0x1481 + -0x5 * 0xa0 + , _$Om = -0x1706 + 0x1889 + -0x17f + , _$ON = -(-0x1bea + -0x1197 + 0x2d82) + , _$OP = iw(0x17f) + , _$OK = iw(0x1cb) + , _$OB = { + 'exports': {} + }; + !function(_$ir, _$ix) { + var _$iX = { + 'ysGMY': function(_$iT, _$iD, _$iL, _$iW) { + return _$iT(_$iD, _$iL, _$iW); + } + }; + _$ir.exports = function(_$iT) { + var _$iD = { + 'eaRUc': function(_$iL, _$iW) { + return _$b.JalOw(_$iL, _$iW); + }, + 'khmBS': function(_$iL, _$iW) { + return _$b.HQaKr(_$iL, _$iW); + }, + 'OnQSJ': function(_$iL, _$iW) { + return _$iL & _$iW; + }, + 'AgyXy': function(_$iL, _$iW) { + return _$iL + _$iW; + }, + 'vYwuY': function(_$iL, _$iW) { + return _$iL % _$iW; + }, + 'SSkRM': function(_$iL, _$iW) { + return _$iL * _$iW; + }, + 'xivEr': function(_$iL, _$iW) { + return _$iL >>> _$iW; + }, + 'BpBSd': function(_$iL, _$iW) { + return _$b.zewkt(_$iL, _$iW); + }, + 'fCNKf': function(_$iL, _$iW) { + return _$iL(_$iW); + }, + 'wwOfQ': function(_$iL, _$iW) { + return _$iL - _$iW; + } + }; + return function() { + var md = a092750F + , _$iL = { + 'UKfaY': function(_$iu, _$iM) { + return _$iu < _$iM; + }, + 'GoWQH': function(_$iu, _$iM) { + return _$iu % _$iM; + }, + 'mZHqg': function(_$iu, _$iM) { + return _$iu * _$iM; + }, + 'LjlZS': function(_$iu, _$iM, _$ic, _$iO) { + return _$iX.ysGMY(_$iu, _$iM, _$ic, _$iO); + } + } + , _$iW = _$iT + , _$iV = _$iW.lib.WordArray; + function _$iH(_$iu, _$iM, _$ic) { + for (var _$iO = [], _$ii = 0x1 * 0x10c9 + -0x1f85 + 0x17 * 0xa4, _$iE = 0x4 * 0x4ee + -0x28d + 0x3 * -0x5b9; _$iL.UKfaY(_$iE, _$iM); _$iE++) + if (_$iE % (0x8eb + 0x1 * -0x294 + 0x1 * -0x653)) { + var _$im = _$ic[_$iu.charCodeAt(_$iE - (0x1fba + 0x1d30 + -0x3ce9))] << _$iL.GoWQH(_$iE, -0x1 * 0x25af + -0x37b + 0x292e) * (-0x2495 + -0x427 * 0x2 + 0x4fd * 0x9) | _$ic[_$iu.charCodeAt(_$iE)] >>> -0x472 * -0x7 + 0x16be + -0x1 * 0x35d6 - _$iE % (-0x1f2a + -0x994 + -0x2 * -0x1461) * (0x818 + -0x6f2 + -0x124); + _$iO[_$ii >>> -0x1e79 * -0x1 + 0x21f5 + 0x7 * -0x934] |= _$im << 0x2197 + -0x2 * 0x22a + -0x3 * 0x9b9 - _$iL.mZHqg(_$ii % (0x554 + 0xc5 * -0x23 + -0x453 * -0x5), 0x15f7 + -0xc9a + -0x955), + _$ii++; + } + return _$iV.create(_$iO, _$ii); + } + _$iW.enc.Base64 = { + 'stringify': function(_$iu) { + var _$iM = _$iu.words + , _$ic = _$iu.sigBytes + , _$iO = this._map1; + _$iu.clamp(); + for (var _$ii = [], _$iE = 0x1 * -0x1528 + -0x1 * 0x1b22 + 0x304a; _$iE < _$ic; _$iE += -0x19 * -0x16f + 0x88f + -0x2c63) + for (var _$im = (_$iD.eaRUc(_$iM[_$iE >>> 0x1c12 * 0x1 + -0x1a11 + -0x1ff], -0x40f * -0x3 + 0x2 * -0xac4 + 0x973 * 0x1 - _$iD.khmBS(_$iE, -0x2203 * -0x1 + -0x2 * 0x311 + 0x7 * -0x3fb) * (-0x43e + 0x1 * -0x1c54 + 0x209a)) & 0x12b7 + -0x245 * 0xd + 0xbc9) << -0x263e + 0x1 * 0x950 + 0x1cfe | _$iD.OnQSJ(_$iM[_$iD.AgyXy(_$iE, -0x2 * 0x264 + -0x45 * -0x34 + -0x93b) >>> -0x20a5 + -0x224c + 0x42f3] >>> 0xf7d + 0x91c * -0x3 + 0xbef - (_$iE + (0x14de + 0x1abb + -0x2f98)) % (0x1b44 + -0x26b0 + 0xb70) * (-0x1273 + -0x149a + 0x2715), 0x1f54 + 0x1669 * -0x1 + -0x1 * 0x7ec) << 0x10c7 + 0x2680 + -0x373f | _$iM[_$iE + (0x17ed + 0x93f + -0x1095 * 0x2) >>> -0x1 * 0x4b8 + -0xeb3 + -0x1 * -0x136d] >>> -0xc1 * -0x2 + -0x253f + -0x1 * -0x23d5 - _$iD.vYwuY(_$iE + (-0x23d3 + 0xc * -0x11b + 0x3119), -0x8f * -0x31 + -0x19ae + -0x21 * 0xd) * (0x18ab * -0x1 + 0x19 * -0x2 + 0x18e5) & -0x154c + -0x19f2 + 0x1 * 0x303d, _$iN = 0x1a5e + -0xfc2 + 0x54e * -0x2; _$iN < 0x169d + 0x1bc1 + -0x325a && _$iE + _$iD.SSkRM(0x1 * 0x559 + -0x2053 * 0x1 + 0x1afa + 0.75, _$iN) < _$ic; _$iN++) + _$ii.push(_$iO.charAt(_$iD.xivEr(_$im, (-0x3e9 * 0x1 + 0x11f8 + -0xe09) * _$iD.BpBSd(0x12 * -0xa2 + -0x1f4 + 0x107 * 0xd, _$iN)) & 0x57 * -0x9 + -0x1e5e + -0x10d6 * -0x2)); + return _$ii.join(''); + }, + 'parse': function(_$iu) { + var _$iM = _$iu.length + , _$ic = this._map1 + , _$iO = this._reverseMap; + if (!_$iO) { + _$iO = this._reverseMap = []; + for (var _$ii = 0x14aa + 0x7 * -0x51 + -0x1273; _$ii < _$ic.length; _$ii++) + _$iO[_$ic.charCodeAt(_$ii)] = _$ii; + } + return _$iL.LjlZS(_$iH, _$iu, _$iM, _$iO); + }, + 'encode': function(_$iu) { + 'use strict'; + var d = _3nkbm; + var j = _2mzbm; + var _$iM, _$ic, _$iO, _$ii, _$iE, _$im, _$iN, _$iP, _$iK, _$iB, _$id, _$ih; + var t = []; + var s = 465; + var o, a; + l6: for (; ; ) { + switch (j[s++]) { + case 1: + _$iP = t[t.length - 1]; + break; + case 5: + if (t.pop()) + s += j[s]; + else + ++s; + break; + case 8: + t.push(_$iE); + break; + case 10: + _$iE = t[t.length - 1]; + break; + case 11: + t.push(_$cr); + break; + case 13: + t.push(_$Uy); + break; + case 14: + t.push(_$iP); + break; + case 15: + return; + break; + case 21: + _$iO = t[t.length - 1]; + break; + case 22: + t[t.length - 5] = d.call(t[t.length - 5], t[t.length - 4], t[t.length - 3], t[t.length - 2], t[t.length - 1]); + t.length -= 4; + break; + case 23: + if (t[t.length - 2] != null) { + t[t.length - 3] = d.call(t[t.length - 3], t[t.length - 2], t[t.length - 1]); + t.length -= 2; + } else { + o = t[t.length - 3]; + t[t.length - 3] = o(t[t.length - 1]); + t.length -= 2; + } + break; + case 24: + _$iK = t[t.length - 1]; + break; + case 25: + o = t.pop(); + t[t.length - 1] -= o; + break; + case 26: + _$iB = t[t.length - 1]; + break; + case 27: + return t.pop(); + break; + case 28: + t.push(_$im); + break; + case 30: + t.push(_$id); + break; + case 31: + t[t.length - 1] = t[t.length - 1].length; + break; + case 33: + _$iM = t[t.length - 1]; + break; + case 36: + _$id = t[t.length - 1]; + break; + case 40: + _$iN = t[t.length - 1]; + break; + case 41: + _$ic = t[t.length - 1]; + break; + case 43: + t.push(_1sxbm[33 + j[s++]]); + break; + case 44: + o = t.pop(); + t[t.length - 1] += o; + break; + case 45: + t.push(new Array(j[s++])); + break; + case 48: + t.push(null); + break; + case 49: + t.push(_$iM); + break; + case 51: + t.push(_$iD); + break; + case 54: + t.push(_$iK); + break; + case 55: + t.push(_$iT); + break; + case 58: + t.push(_$iB); + break; + case 59: + o = t.pop(); + t[t.length - 1] = t[t.length - 1] < o; + break; + case 60: + s += j[s]; + break; + case 61: + t.push(j[s++]); + break; + case 62: + t.push(_$iE++); + break; + case 63: + o = t.pop(); + t[t.length - 1] %= o; + break; + case 65: + t.push(t[t.length - 1]); + t[t.length - 2] = t[t.length - 2][_1sxbm[33 + j[s++]]]; + break; + case 67: + t[t.length - 4] = d.call(t[t.length - 4], t[t.length - 3], t[t.length - 2], t[t.length - 1]); + t.length -= 3; + break; + case 71: + t.push(_$ic); + break; + case 72: + o = t.pop(); + t[t.length - 1] = t[t.length - 1] >= o; + break; + case 73: + t[t.length - 1] = t[t.length - 1][_1sxbm[33 + j[s++]]]; + break; + case 74: + t.push(this); + break; + case 75: + t.push(_$iO); + break; + case 82: + t.push(_$ii); + break; + case 84: + _$im = t[t.length - 1]; + break; + case 85: + _$ii = t[t.length - 1]; + break; + case 86: + t.push(_$ih); + break; + case 90: + _$ih = t[t.length - 1]; + break; + case 92: + t.push(Array); + break; + case 93: + t.push(_$iN); + break; + case 95: + t.push(_$iu); + break; + case 99: + t.pop(); + break; + } + } + }, + '_map1': md(0x2af) + }; + }(), + _$iT.enc.Base64; + }(_$O0.exports); + }(_$OB); + var _$Od = _$OB.exports + , _$Oh = { + 'exports': {} + }; + !function(_$ir, _$ix) { + _$ir.exports = function(_$iX) { + return _$iX.enc.Utf8; + }(_$O0.exports); + }(_$Oh); + var _$Op = _$Oh.exports + , _$Oz = { + 'exports': {} + }; + !function(_$ir, _$ix) { + var _$iX = { + 'Yyhmd': function(_$iT, _$iD) { + return _$b.xfaAL(_$iT, _$iD); + }, + 'gCpui': function(_$iT, _$iD) { + return _$iT << _$iD; + } + }; + _$ir.exports = function(_$iT) { + return function(_$iD) { + var _$iL = { + 'WHrfe': function(_$im, _$iN) { + return _$im(_$iN); + }, + 'cuhzh': function(_$im, _$iN) { + return _$iX.Yyhmd(_$im, _$iN); + }, + 'eUvBu': function(_$im, _$iN) { + return _$im >>> _$iN; + }, + 'KOVdp': function(_$im, _$iN) { + return _$iX.gCpui(_$im, _$iN); + }, + 'vVXWA': function(_$im, _$iN) { + return _$im + _$iN; + }, + 'HqcXT': function(_$im, _$iN) { + return _$im << _$iN; + }, + 'DGaHK': function(_$im, _$iN) { + return _$im ^ _$iN; + }, + 'oiYBo': function(_$im, _$iN) { + return _$im | _$iN; + }, + 'sTfDx': function(_$im, _$iN) { + return _$im << _$iN; + }, + 'BpkIk': function(_$im, _$iN) { + return _$im ^ _$iN; + }, + 'CRvHq': function(_$im, _$iN) { + return _$im | _$iN; + }, + 'prwtD': function(_$im, _$iN) { + return _$im | _$iN; + }, + 'emIRf': function(_$im, _$iN) { + return _$im + _$iN; + }, + 'XnZkb': function(_$im, _$iN) { + return _$im >>> _$iN; + } + } + , _$iW = _$iT + , _$iV = _$iW.lib + , _$iH = _$iV.WordArray + , _$iu = _$iV.Hasher + , _$iM = _$iW.algo + , _$ic = [] + , _$iO = []; + !function() { + var _$im = { + 'KxknI': function(_$id, _$ih) { + return _$id <= _$ih; + }, + 'PQbtS': function(_$id, _$ih) { + return _$id - _$ih; + } + }; + function _$iN(_$id) { + for (var _$ih = _$iD.sqrt(_$id), _$ip = 0x213 + 0x1da5 + 0x6 * -0x549; _$im.KxknI(_$ip, _$ih); _$ip++) + if (!(_$id % _$ip)) + return !(0x10 * -0x155 + 0x4f * -0x25 + 0x20bc * 0x1); + return !(0x1202 + -0xca * 0x14 + -0x23a); + } + function _$iP(_$id) { + return (-0x9564 * -0x125d7 + -0x13f6f5f48 + 0x193f6744c) * _$im.PQbtS(_$id, 0x24c5 * 0x1 + -0x1697 + 0x6 * -0x25d | _$id) | 0x1cd5 + -0x7d * -0x7 + -0x2040; + } + for (var _$iK = 0xa83 + -0xc1 * -0xa + -0x120b, _$iB = 0x11f2 + -0x5f3 + 0x53 * -0x25; _$iB < 0x2556 + 0x4 * -0x192 + -0x1ece; ) + _$iN(_$iK) && (_$iB < 0x2156 + 0x14b7 * 0x1 + -0x3605 * 0x1 && (_$ic[_$iB] = _$iP(_$iD.pow(_$iK, 0xe90 + -0xa97 * 0x2 + 0x69e + 0.5))), + _$iO[_$iB] = _$iP(_$iD.pow(_$iK, (-0xf08 + 0x2454 + -0x45 * 0x4f) / (-0x9fd * -0x3 + 0x21eb + -0x3fdf))), + _$iB++), + _$iK++; + }(); + var _$ii = [] + , _$iE = _$iM.SHA256 = _$iu.extend({ + '_doReset': function() { + this._hash = new _$iH.init(_$iL.WHrfe(_$Uy, _$ic).call(_$ic, 0x1bda + 0x1f5e + -0x17b * 0x28)); + }, + '_doProcessBlock': function(_$im, _$iN) { + for (var _$iP = this._hash.words, _$iK = _$iP[0x61 * 0x5 + 0xe09 + -0xfee], _$iB = _$iP[0x1962 + 0x35 * -0x4f + -0x906], _$id = _$iP[0x1135 + -0x9 * -0x1d2 + -0x2195], _$ih = _$iP[0x7 * 0x3a5 + 0x25ea * 0x1 + -0x3f6a * 0x1], _$ip = _$iP[-0x131b * 0x1 + 0x2 * -0x21f + -0x175d * -0x1], _$iz = _$iP[0x204f + 0x136c + 0x1 * -0x33b6], _$in = _$iP[-0x1e15 + -0xd * -0xa3 + -0x7f * -0x2c], _$ik = _$iP[0xfbc + -0x1 * -0x1616 + -0xf * 0x285], _$iZ = 0x1 * -0xed1 + -0x711 + 0x15e2; _$iZ < -0x1 * 0x1fb7 + -0x14b5 + 0x34ac; _$iZ++) { + if (_$iZ < 0x2103 + 0x233d + -0x4430) + _$ii[_$iZ] = 0x10ae + -0xc5c + -0x452 | _$im[_$iN + _$iZ]; + else { + var _$iA = _$ii[_$iZ - (-0x1554 + 0x1 * -0x1c4f + -0x18d9 * -0x2)] + , _$ie = _$iL.cuhzh(_$iA << -0x1fdb + -0x6 * 0x285 + 0x1e2 * 0x19 | _$iL.eUvBu(_$iA, 0x3ba + 0xd3 * -0x1 + -0x2e0), _$iA << 0x96 + -0x1f57 + 0x1ecf | _$iA >>> 0x1 * 0x1ae4 + -0x1 * 0xcfa + -0xdd8) ^ _$iA >>> -0x1cf6 + -0x55 + 0xea7 * 0x2 + , _$is = _$ii[_$iZ - (-0x1f5a + 0x1e5b + 0x1 * 0x101)] + , _$ia = (_$is << -0x10c9 + 0x89d + 0x83b | _$is >>> -0x1 * -0x16d2 + -0x24d5 * 0x1 + -0x35 * -0x44) ^ (_$iL.KOVdp(_$is, -0x2b9 * 0x1 + 0x1 * -0xc4f + -0xb * -0x15f) | _$iL.eUvBu(_$is, -0x1 * -0x3fa + 0x1fe + -0x1f7 * 0x3)) ^ _$is >>> 0xd * 0x251 + -0x25c2 + -0x1 * -0x7af; + _$ii[_$iZ] = _$iL.vVXWA(_$ie, _$ii[_$iZ - (-0x1efb + -0x33f * 0x3 + 0x28bf)]) + _$ia + _$ii[_$iZ - (0x1640 + -0x1b1d + 0x4ed)]; + } + var _$ig = _$iK & _$iB ^ _$iK & _$id ^ _$iB & _$id + , _$iq = _$iL.cuhzh((_$iL.HqcXT(_$iK, -0x97 * -0x3d + -0x1 * -0x1d75 + -0x4152) | _$iL.eUvBu(_$iK, -0x1 * 0x1250 + -0x14ef * -0x1 + 0x1 * -0x29d)) ^ (_$iK << 0x6 * -0x113 + 0x44c + 0x239 | _$iK >>> -0x21bf * -0x1 + -0x1ce * -0x13 + -0x1 * 0x43fc), _$iK << -0x255 * 0x9 + -0xcf6 + 0x317 * 0xb | _$iK >>> -0x1 * -0x8aa + -0x123f + 0x9ab) + , _$iJ = _$iL.vVXWA(_$ik, _$iL.DGaHK(_$iL.oiYBo(_$iL.sTfDx(_$ip, 0xb49 * -0x1 + 0x22eb + -0x1788), _$ip >>> -0x39e + -0x2b1 * 0x1 + 0x1 * 0x655) ^ (_$ip << 0x687 + 0x11 * 0x123 + -0x9 * 0x2dd | _$iL.eUvBu(_$ip, -0x7a2 + 0xd87 + 0xd6 * -0x7)), _$iL.sTfDx(_$ip, 0x1 * -0x11e7 + -0x2647 + 0x3835) | _$iL.eUvBu(_$ip, -0x182e + -0x73a * -0x2 + 0x1f7 * 0x5))) + _$iL.BpkIk(_$ip & _$iz, ~_$ip & _$in) + _$iO[_$iZ] + _$ii[_$iZ]; + _$ik = _$in, + _$in = _$iz, + _$iz = _$ip, + _$ip = _$ih + _$iJ | 0x4de + 0x13 * -0xad + 0x7f9, + _$ih = _$id, + _$id = _$iB, + _$iB = _$iK, + _$iK = _$iL.CRvHq(_$iJ + (_$iq + _$ig), -0x1b7b + -0x1 * 0x11b4 + 0x2d2f); + } + _$iP[-0xce * 0x25 + 0x1 * 0x2426 + -0x660] = _$iP[0xb6 + 0x1 * -0xfef + 0xf39] + _$iK | -0x7 * 0x4cd + 0x1 * -0x89 + 0x2224, + _$iP[-0xa3b + 0x23d0 * -0x1 + 0xe * 0x34a] = _$iP[0x1d0 * 0x3 + 0x5 * -0x5e + 0x3 * -0x133] + _$iB | -0x1 * 0x11c8 + -0x104e + -0x2216 * -0x1, + _$iP[-0x9c * -0x13 + 0x9b2 + 0x551 * -0x4] = _$iP[-0x1291 + 0x1168 + 0x12b] + _$id | -0x11b2 * -0x1 + 0x9d9 + 0x1 * -0x1b8b, + _$iP[-0x4 * 0x587 + 0x97f + 0x65 * 0x20] = _$iP[0x241 * 0x3 + 0xc0 + 0x80 * -0xf] + _$ih | -0x3 * -0x770 + 0x1ed7 + -0x3527, + _$iP[-0x63 * 0x1 + -0x18a2 * 0x1 + -0x1d * -0xdd] = _$iL.prwtD(_$iP[-0x1 * -0x257f + 0x576 * -0x1 + -0x2005] + _$ip, 0x2 * -0x6fb + -0x21 * 0x89 + 0x1f9f), + _$iP[-0x1290 + -0x3 * 0xb98 + 0x355d] = _$iP[0x75d + 0xe * -0x1b1 + -0x6 * -0x2b9] + _$iz | 0x1 * 0x5e3 + 0x91b + 0x26 * -0x65, + _$iP[0x1dca + -0x85c * 0x3 + 0x6 * -0xc8] = _$iL.emIRf(_$iP[0xbf9 + -0x8ce + -0x325], _$in) | 0x1 * -0x151f + 0x38 * 0x2 + 0x14af, + _$iP[0xc09 * 0x3 + -0xa2a * 0x1 + -0xcf5 * 0x2] = _$iL.vVXWA(_$iP[0x261e + 0x107 + -0x271e * 0x1], _$ik) | -0x1b2 + 0x406 * 0x5 + -0x126c; + }, + '_doFinalize': function() { + var _$im = this._data + , _$iN = _$im.words + , _$iP = (-0xeb6 + 0x18f7 + 0xa39 * -0x1) * this._nDataBytes + , _$iK = (0x1 * 0x149 + -0x1 * 0x23b6 + -0x1 * -0x2275) * _$im.sigBytes; + return _$iN[_$iK >>> -0x245f + 0x68 * 0x50 + -0x3 * -0x14c] |= 0x15a8 + -0x28e + -0x129a << -0x1 * -0x19cc + 0x13 * -0x61 + -0x1281 - _$iK % (0x2254 + 0x1 * 0x2441 + -0x425 * 0x11), + _$iN[-0x1b6b + -0xe3 * 0xd + 0x4 * 0x9c0 + (_$iK + (-0x1b28 + 0x241 + 0x1927) >>> -0xe8b * -0x1 + -0x25e * -0x9 + 0x8 * -0x47a << 0x92 + 0x48d * 0x5 + -0x9 * 0x297)] = _$iD.floor(_$iP / (-0x32 * -0x70da164 + 0x926530e4 + 0x2f6d046 * -0x52)), + _$iN[-0x206c + 0x11 * 0x35 + -0x2 * -0xe7b + (_$iL.XnZkb(_$iK + (0x2f * 0x4c + -0x1d35 + 0xf81), 0x1152 + -0xf55 + -0x5 * 0x64) << 0x20bf * -0x1 + 0x71f * 0x2 + -0x1af * -0xb)] = _$iP, + _$im.sigBytes = (-0x1505 + -0x23aa + 0xb57 * 0x5) * _$iN.length, + this._process(), + this._hash; + }, + 'clone': function() { + var _$im = _$iu.clone.call(this); + return _$im._hash = this._hash.clone(), + _$im; + } + }); + _$iW.SHA256 = _$iu._createHelper(_$iE), + _$iW.HmacSHA256 = _$iu._createHmacHelper(_$iE); + }(Math), + _$iT.SHA256; + }(_$O0.exports); + }(_$Oz); + var _$On = _$Oz.exports + , _$Ok = { + 'exports': {} + } + , _$OZ = { + 'exports': {} + }; + !function(_$ir, _$ix) { + var _$iX = { + 'nwjlY': function(_$iT, _$iD) { + return _$iT(_$iD); + } + }; + _$ir.exports = function(_$iT) { + var mh = a092750F, _$iD = { + 'FZxER': function(_$iH, _$iu) { + return _$iX.nwjlY(_$iH, _$iu); + }, + 'FrFEF': function(_$iH, _$iu) { + return _$iH + _$iu; + }, + 'Vvwex': mh(0x182) + }, _$iL, _$iW, _$iV; + _$iW = (_$iL = _$iT).lib.Base, + _$iV = _$iL.enc.Utf8, + _$iL.algo.HMAC = _$iW.extend({ + 'init': function(_$iH, _$iu) { + 'use strict'; + var o = _3nkbm; + var l = _2mzbm; + var mp, _$iM, _$ic, _$iO, _$ii, _$iE, _$im, _$iN; + var s = []; + var i = 745; + var t, j; + l7: for (; ; ) { + switch (l[i++]) { + case 1: + s.push(mp); + break; + case 2: + s.push(_$ii); + break; + case 6: + _$iH = s[s.length - 1]; + break; + case 10: + mp = s[s.length - 1]; + break; + case 14: + _$iu = s[s.length - 1]; + break; + case 15: + t = s.pop(); + s[s.length - 1] = s[s.length - 1] < t; + break; + case 16: + s.push(_$iM); + break; + case 17: + s.push(_$iN); + break; + case 18: + s.push(this); + break; + case 20: + t = s.pop(); + s[s.length - 1] = s[s.length - 1] == t; + break; + case 21: + s.push(_$iO); + break; + case 22: + s.push(_$iE); + break; + case 24: + _$iN = s[s.length - 1]; + break; + case 25: + s.pop(); + break; + case 26: + _$im = s[s.length - 1]; + break; + case 27: + if (s[s.length - 1]) { + ++i; + --s.length; + } else + i += l[i]; + break; + case 28: + s[s.length - 1] = s[s.length - 1][_1sxbm[48 + l[i++]]]; + break; + case 29: + t = s.pop(); + s[s.length - 1] ^= t; + break; + case 30: + if (s[s.length - 1] != null) { + s[s.length - 2] = o.call(s[s.length - 2], s[s.length - 1]); + } else { + t = s[s.length - 2]; + s[s.length - 2] = t(); + } + s.length--; + break; + case 32: + t = s.pop(); + s[s.length - 1] = s[s.length - 1] > t; + break; + case 33: + s[s.length - 3][s[s.length - 2]] = s[s.length - 1]; + s[s.length - 3] = s[s.length - 1]; + s.length -= 2; + break; + case 36: + s.push(_$iH); + break; + case 39: + s[s.length - 2] = new s[s.length - 2](); + s.length -= 1; + break; + case 40: + if (s[s.length - 2] != null) { + s[s.length - 3] = o.call(s[s.length - 3], s[s.length - 2], s[s.length - 1]); + s.length -= 2; + } else { + t = s[s.length - 3]; + s[s.length - 3] = t(s[s.length - 1]); + s.length -= 2; + } + break; + case 41: + s.push(s[s.length - 2]); + s.push(s[s.length - 2]); + break; + case 42: + s.push(_$im); + break; + case 43: + s.push(l[i++]); + break; + case 44: + _$ic = s[s.length - 1]; + break; + case 49: + _$ii = s[s.length - 1]; + break; + case 50: + return; + break; + case 56: + s[s.length - 1] = typeof s[s.length - 1]; + break; + case 65: + s[s.length - 2][_1sxbm[48 + l[i++]]] = s[s.length - 1]; + s[s.length - 2] = s[s.length - 1]; + s.length--; + break; + case 66: + s.push(s[s.length - 1]); + s[s.length - 2] = s[s.length - 2][_1sxbm[48 + l[i++]]]; + break; + case 68: + t = s.pop(); + s[s.length - 1] *= t; + break; + case 69: + s.push(_$iN++); + break; + case 71: + s[s.length - 2] = s[s.length - 2][s[s.length - 1]]; + s.length--; + break; + case 73: + s.push(_$iV); + break; + case 75: + _$iO = s[s.length - 1]; + break; + case 76: + s.push(_$ic); + break; + case 77: + if (s.pop()) + i += l[i]; + else + ++i; + break; + case 82: + s.push(_$iu); + break; + case 84: + _$iM = s[s.length - 1]; + break; + case 85: + t = s.pop(); + s[s.length - 1] += t; + break; + case 87: + _$iE = s[s.length - 1]; + break; + case 90: + s.push(mh); + break; + case 96: + i += l[i]; + break; + case 97: + s.push(null); + break; + case 99: + s.push(undefined); + break; + } + } + }, + 'reset': function() { + var _$iH = this._hasher; + _$iH.reset(), + _$iH.update(this._iKey); + }, + 'update': function(_$iH) { + return this._hasher.update(_$iH), + this; + }, + 'eKey': function(_$iH) { + 'use strict'; + var k = _3nkbm; + var p = _2mzbm; + var _$iu, _$iM, _$ic, _$iO, _$ii, _$iE; + var b = []; + var j = 906; + var y, l; + l8: for (; ; ) { + switch (p[j++]) { + case 1: + b.push(p[j++]); + break; + case 2: + b.push(String); + break; + case 3: + b.push(b[b.length - 1]); + b[b.length - 2] = b[b.length - 2][_1sxbm[61 + p[j++]]]; + break; + case 4: + b.push(_$iE); + break; + case 7: + _$iE = b[b.length - 1]; + break; + case 8: + y = b.pop(); + b[b.length - 1] %= y; + break; + case 10: + return; + break; + case 14: + b.push(new Array(p[j++])); + break; + case 15: + b.push(null); + break; + case 24: + b.pop(); + break; + case 30: + b[b.length - 4] = k.call(b[b.length - 4], b[b.length - 3], b[b.length - 2], b[b.length - 1]); + b.length -= 3; + break; + case 33: + y = b.pop(); + b[b.length - 1] *= y; + break; + case 37: + b.push(_$iD); + break; + case 39: + y = b.pop(); + b[b.length - 1] += y; + break; + case 40: + if (b.pop()) + j += p[j]; + else + ++j; + break; + case 41: + b.push(_1sxbm[61 + p[j++]]); + break; + case 43: + y = b.pop(); + b[b.length - 1] -= y; + break; + case 45: + j += p[j]; + break; + case 47: + b.push(_$ii); + break; + case 48: + return b.pop(); + break; + case 52: + _$iM = b[b.length - 1]; + break; + case 54: + _$ii = b[b.length - 1]; + break; + case 56: + if (b[b.length - 2] != null) { + b[b.length - 3] = k.call(b[b.length - 3], b[b.length - 2], b[b.length - 1]); + b.length -= 2; + } else { + y = b[b.length - 3]; + b[b.length - 3] = y(b[b.length - 1]); + b.length -= 2; + } + break; + case 59: + b.push(_$Uy); + break; + case 61: + b.push(_$iH); + break; + case 63: + b.push(_$iu); + break; + case 72: + b.push(_$Ui); + break; + case 75: + _$ic = b[b.length - 1]; + break; + case 78: + _$iO = b[b.length - 1]; + break; + case 83: + _$iu = b[b.length - 1]; + break; + case 85: + b.push(_$iO); + break; + case 87: + if (b[b.length - 1] != null) { + b[b.length - 2] = k.call(b[b.length - 2], b[b.length - 1]); + } else { + y = b[b.length - 2]; + b[b.length - 2] = y(); + } + b.length--; + break; + case 94: + y = b.pop(); + b[b.length - 1] = b[b.length - 1] > y; + break; + case 95: + b.push(_$iM); + break; + case 96: + b[b.length - 1] = b[b.length - 1].length; + break; + case 97: + b.push(_$ic); + break; + case 98: + b[b.length - 5] = k.call(b[b.length - 5], b[b.length - 4], b[b.length - 3], b[b.length - 2], b[b.length - 1]); + b.length -= 4; + break; + } + } + }, + 'finalize': function(_$iH) { + var _$iu, _$iM = this._hasher; + if (_$iD.Vvwex == typeof _$iH) { + var _$ic = _$iM._seData(_$iH); + _$iH = _$ic.substring(-0x1fd * -0x8 + -0x1915 + -0x92d * -0x1, _$ic.length - (0x1f0e + 0x18d1 + -0x37dc)); + } + var _$iO = _$iM.finalize(_$iH); + return _$iM.reset(), + _$iM.finalize(_$Ui(_$iu = this._oKey.clone()).call(_$iu, _$iO)); + } + }); + }(_$O0.exports); + }(_$OZ), + function(_$ir, _$ix) { + _$ir.exports = function(_$iX) { + return _$iX.HmacSHA256; + }(_$O0.exports); + }(_$Ok); + var _$OA = _$Ok.exports + , _$Oe = { + 'exports': {} + }; + !function(_$ir, _$ix) { + _$ir.exports = function(_$iX) { + return _$iX.HmacMD5; + }(_$O0.exports); + }(_$Oe); + var _$Os = _$Oe.exports + , _$Oa = function() { + var _$ir = {}; + return { + 'setItem': function(_$ix, _$iX) { + _$ir[_$ix] = _$iX; + }, + 'getItem': function(_$ix) { + return _$ir[_$ix]; + } + }; + }() + , _$Og = window.localStorage + , _$Oq = { + 'get': function(_$ir) { + var _$ix = arguments.length > -0xd8c + 0x1d2 + 0xbbb && void (-0x134f * -0x1 + 0x1129 * -0x2 + -0x3d * -0x3f) !== arguments[-0x3c * -0x7f + -0x1444 + -0x97f] ? arguments[-0x2 * -0x6e9 + 0x1ac7 + -0x2898] : { + 'raw': !(-0x5 * 0x445 + -0xe87 * 0x1 + -0x1 * -0x23e1), + 'from': 0x0 + } + , _$iX = _$Oa.getItem(_$ir); + try { + _$iX && -0x1836 * -0x1 + -0xa48 + 0x9b * -0x17 !== _$ix.from || (_$iX = _$Og.getItem(_$ir)) && _$Oa.setItem(_$ir, _$iX); + } catch (_$iT) {} + if (!_$iX) + return ''; + if (_$ix.raw) + return _$iX; + try { + return JSON.parse(_$iX); + } catch (_$iD) { + return _$iX; + } + }, + 'set': function(_$ir, _$ix) { + var _$iX = _$ix; + _$b.JJJRv(_$b.tsQPR, _$My(_$iX)) && (_$iX = _$b.IYQQg(_$Vn, _$iX)), + _$Oa.setItem(_$ir, _$iX); + try { + _$Og.setItem(_$ir, _$iX); + } catch (_$iT) {} + } + } + , _$OJ = { + 'get': function(_$ir, _$ix) { + var _$iX = _$Oq.get(_$Oc.STORAGE_KEY_TK) + , _$iT = _$OD(_$O7(_$iX) ? _$iX : {}, [_$ir, _$ix]); + if (!_$b.YieAP(_$O7, _$iT)) + return null; + var _$iD = _$iT.v || '' + , _$iL = null; + try { + _$iL = JSON.parse(_$Op.stringify(_$Od.parse(_$iD))); + } catch (_$iW) { + return null; + } + return _$OW({ + 'e': _$iT.e, + 't': _$iT.t + }) ? _$iL : null; + }, + 'save': function(_$ir, _$ix, _$iX) { + var _$iT = { + 'JnWtD': function(_$iV, _$iH) { + return _$iV(_$iH); + }, + 'meHIn': function(_$iV, _$iH) { + return _$iV * _$iH; + }, + 'yWQUl': function(_$iV, _$iH, _$iu) { + return _$iV(_$iH, _$iu); + } + } + , _$iD = _$Oq.get(_$Oc.STORAGE_KEY_TK) + , _$iL = _$O7(_$iD) ? _$iD : {} + , _$iW = function(_$iV) { + var mz = a092750F; + if (_$iM = _$iV, + mz(0x182) == typeof _$iM) { + var _$iH = _$iT.JnWtD(_$Uy, _$iV).call(_$iV, -0xe6 * 0x9 + 0x1850 + -0x102d, 0x2 * -0xf29 + -0x16f1 + 0x3552) + , _$iu = _$iT.meHIn((-0x7ee + 0x17b * 0x1 + 0x6af) * _$iT.yWQUl(_$cp, _$iH, 0x14ac * -0x1 + 0x154e + -0x92), 0x6 * -0xab + 0x2 * 0xf25 + -0x4 * 0x683); + if (!isNaN(_$iu)) + return _$iu; + } + var _$iM; + return null; + }(_$iX ? _$iX.tk : ''); + _$iW && (_$OT(_$iL, [_$ir, _$ix], { + 'v': _$Od.stringify(_$Op.parse(_$b.bQPsi(_$Vn, _$iX))), + 'e': _$iW, + 't': Date.now() + }), + function(_$iV) { + if (!_$iV) + return; + var _$iH = []; + _$OL(_$iV, function(_$iM, _$ic) { + var _$iO = { + 'gtOdk': function(_$ii, _$iE) { + return _$ii(_$iE); + } + }; + _$OL(_$iM, function(_$ii, _$iE) { + _$iO.gtOdk(_$OW, _$ii) && _$iH.push({ + 'fp': _$ic, + 'appId': _$iE, + 'data': _$ii + }); + }); + }); + var _$iu = {}; + _$iH.forEach(function(_$iM) { + var _$ic = _$iM.fp + , _$iO = _$iM.appId + , _$ii = _$iM.data; + _$OT(_$iu, [_$ic, _$iO], _$ii); + }), + _$Oq.set(_$Oc.STORAGE_KEY_TK, _$iu); + }(_$iL)); + } + }; + function _$OC() { + 'use strict'; + var g = _3nkbm; + var x = _2mzbm; + var _$ir, _$ix, _$iX, _$iT, _$iD, _$iL, _$iW, _$iV, _$iH; + var k = []; + var y = 1074; + var q, c; + l9: for (; ; ) { + switch (x[y++]) { + case 1: + k.push(_$iV); + break; + case 2: + k[k.length - 5] = g.call(k[k.length - 5], k[k.length - 4], k[k.length - 3], k[k.length - 2], k[k.length - 1]); + k.length -= 4; + break; + case 3: + k[k.length - 1] = k[k.length - 1].length; + break; + case 8: + k.push(_$b); + break; + case 10: + _$iH = k[k.length - 1]; + break; + case 13: + q = k.pop(); + k[k.length - 1] += q; + break; + case 15: + k.push(_$iD); + break; + case 19: + k.push(_$Ui); + break; + case 20: + k.push(function(_$iu, _$iM) { + 'use strict'; + var e = _3nkbm; + var m = _2mzbm; + var _$ic, _$iO, _$ii, _$iE, _$im, _$iN, _$iP; + var r = []; + var u = 1323; + var l, n; + l10: for (; ; ) { + switch (m[u++]) { + case 10: + r.push(_$iN++); + break; + case 12: + r.push(Math); + break; + case 16: + if (r.pop()) + u += m[u]; + else + ++u; + break; + case 17: + _$im = r[r.length - 1]; + break; + case 22: + if (r.pop()) + ++u; + else + u += m[u]; + break; + case 23: + r.push(_$iO); + break; + case 25: + return r.pop(); + break; + case 28: + l = r.pop(); + r[r.length - 1] *= l; + break; + case 29: + r.push(_1sxbm[89 + m[u++]]); + break; + case 31: + r.push(_$im); + break; + case 33: + l = r.pop(); + r[r.length - 1] = r[r.length - 1] < l; + break; + case 34: + r.push(_$ii++); + break; + case 40: + r.push(m[u++]); + break; + case 41: + r.push(r[r.length - 1]); + r[r.length - 2] = r[r.length - 2][_1sxbm[89 + m[u++]]]; + break; + case 42: + l = r.pop(); + r[r.length - 1] -= l; + break; + case 43: + r[r.length - 1] = r[r.length - 1].length; + break; + case 46: + r.push(_$iu); + break; + case 48: + r.push(0); + break; + case 49: + if (r[r.length - 2] != null) { + r[r.length - 3] = e.call(r[r.length - 3], r[r.length - 2], r[r.length - 1]); + r.length -= 2; + } else { + l = r[r.length - 3]; + r[r.length - 3] = l(r[r.length - 1]); + r.length -= 2; + } + break; + case 52: + _$iN = r[r.length - 1]; + break; + case 53: + r.push(_$iE); + break; + case 54: + r.push(_$iN); + break; + case 55: + r.push(new Array(m[u++])); + break; + case 57: + r.pop(); + break; + case 59: + l = r.pop(); + r[r.length - 1] += l; + break; + case 60: + if (r[r.length - 1] != null) { + r[r.length - 2] = e.call(r[r.length - 2], r[r.length - 1]); + } else { + l = r[r.length - 2]; + r[r.length - 2] = l(); + } + r.length--; + break; + case 64: + r.push(--_$iM); + break; + case 67: + r[r.length - 2] = r[r.length - 2][r[r.length - 1]]; + r.length--; + break; + case 71: + _$ii = r[r.length - 1]; + break; + case 74: + _$iE = r[r.length - 1]; + break; + case 75: + _$iP = r[r.length - 1]; + break; + case 77: + r.push(_$ic); + break; + case 82: + r.push(_$iM); + break; + case 83: + _$iO = r[r.length - 1]; + break; + case 84: + r.push(_$ii); + break; + case 85: + r.push(_$iO--); + break; + case 86: + return; + break; + case 87: + _$ic = r[r.length - 1]; + break; + case 88: + l = r.pop(); + r[r.length - 1] |= l; + break; + case 90: + r[r.length - 4] = e.call(r[r.length - 4], r[r.length - 3], r[r.length - 2], r[r.length - 1]); + r.length -= 3; + break; + case 91: + if (r[r.length - 1]) { + ++u; + --r.length; + } else + u += m[u]; + break; + case 93: + u += m[u]; + break; + case 96: + r.push(_$b); + break; + case 97: + r[r.length - 3][r[r.length - 2]] = r[r.length - 1]; + r[r.length - 3] = r[r.length - 1]; + r.length -= 2; + break; + case 99: + r.push(_$iP); + break; + } + } + }); + break; + case 21: + _$iD = k[k.length - 1]; + break; + case 22: + k.push(_$Uy); + break; + case 23: + k[k.length - 2][_1sxbm[71 + x[y++]]] = k[k.length - 1]; + k.length--; + break; + case 24: + k.push(_$iX); + break; + case 25: + _$ix = k[k.length - 1]; + break; + case 26: + return k.pop(); + break; + case 27: + k[k.length - 1] = k[k.length - 1][_1sxbm[71 + x[y++]]]; + break; + case 28: + k.push(0); + break; + case 31: + k.push(_$iL); + break; + case 32: + _$iX = k[k.length - 1]; + break; + case 34: + _$iL = k[k.length - 1]; + break; + case 35: + k[k.length - 4] = g.call(k[k.length - 4], k[k.length - 3], k[k.length - 2], k[k.length - 1]); + k.length -= 3; + break; + case 36: + _$ir = k[k.length - 1]; + break; + case 38: + return; + break; + case 40: + k.push(_$iT); + break; + case 41: + if (k[k.length - 1] != null) { + k[k.length - 2] = g.call(k[k.length - 2], k[k.length - 1]); + } else { + q = k[k.length - 2]; + k[k.length - 2] = q(); + } + k.length--; + break; + case 42: + k.push(Math); + break; + case 43: + k.push(_1sxbm[71 + x[y++]]); + break; + case 44: + k.push(x[y++]); + break; + case 48: + k.push(new Array(x[y++])); + break; + case 49: + k.push({}); + break; + case 56: + q = k.pop(); + k[k.length - 1] %= q; + break; + case 58: + _$iV = k[k.length - 1]; + break; + case 59: + if (k[k.length - 2] != null) { + k[k.length - 3] = g.call(k[k.length - 3], k[k.length - 2], k[k.length - 1]); + k.length -= 2; + } else { + q = k[k.length - 3]; + k[k.length - 3] = q(k[k.length - 1]); + k.length -= 2; + } + break; + case 60: + k.push(_$iW); + break; + case 63: + k.push(_$OR); + break; + case 64: + k.push(k[k.length - 1]); + k[k.length - 2] = k[k.length - 2][_1sxbm[71 + x[y++]]]; + break; + case 67: + k.push(function(_$iu, _$iM) { + 'use strict'; + var y = _3nkbm; + var k = _2mzbm; + var b = []; + var r = 1463; + var d, x; + l11: for (; ; ) { + switch (k[r++]) { + case 6: + b.push(_$iu); + break; + case 13: + d = b.pop(); + b[b.length - 1] = b[b.length - 1] === d; + break; + case 24: + return; + break; + case 67: + return b.pop(); + break; + case 70: + b.push(_$iM); + break; + } + } + }); + break; + case 72: + k.push(_$cp); + break; + case 75: + k.push(null); + break; + case 76: + k.push(_$iH); + break; + case 77: + k.push(_$ix); + break; + case 78: + _$iT = k[k.length - 1]; + break; + case 80: + _$iW = k[k.length - 1]; + break; + case 86: + k.push(undefined); + break; + case 89: + k.pop(); + break; + case 92: + q = k.pop(); + k[k.length - 1] *= q; + break; + case 94: + if (k.pop()) + y += x[y]; + else + ++y; + break; + case 96: + k.push(function(_$iu, _$iM) { + 'use strict'; + var u = _3nkbm; + var w = _2mzbm; + var _$ic, _$iO; + var k = []; + var j = 1468; + var g, i; + l12: for (; ; ) { + switch (w[j++]) { + case 1: + g = k.pop(); + k[k.length - 1] %= g; + break; + case 2: + k[k.length - 4] = u.call(k[k.length - 4], k[k.length - 3], k[k.length - 2], k[k.length - 1]); + k.length -= 3; + break; + case 3: + g = k.pop(); + k[k.length - 1] = k[k.length - 1] < g; + break; + case 6: + _$iO = k[k.length - 1]; + break; + case 11: + k[k.length - 1] = k[k.length - 1].length; + break; + case 13: + k.push(k[k.length - 1]); + k[k.length - 2] = k[k.length - 2][_1sxbm[95 + w[j++]]]; + break; + case 16: + j += w[j]; + break; + case 24: + k.push(_$cp); + break; + case 27: + if (k[k.length - 1]) { + ++j; + --k.length; + } else + j += w[j]; + break; + case 29: + k.push(w[j++]); + break; + case 31: + g = k.pop(); + k[k.length - 1] *= g; + break; + case 34: + k.push(null); + break; + case 35: + k.push(_$iM); + break; + case 41: + k.push(_1sxbm[95 + w[j++]]); + break; + case 43: + g = k.pop(); + k[k.length - 1] += g; + break; + case 53: + k.push(_$j7); + break; + case 57: + k.push(_$ir); + break; + case 58: + k[k.length - 1] = -k[k.length - 1]; + break; + case 60: + k.push(_$iO++); + break; + case 64: + k.push(_$iO); + break; + case 76: + _$ic = k[k.length - 1]; + break; + case 78: + k.pop(); + break; + case 82: + k.push(_$ic); + break; + case 84: + return; + break; + case 90: + k.push(_$iu); + break; + case 91: + if (k[k.length - 2] != null) { + k[k.length - 3] = u.call(k[k.length - 3], k[k.length - 2], k[k.length - 1]); + k.length -= 2; + } else { + g = k[k.length - 3]; + k[k.length - 3] = g(k[k.length - 1]); + k.length -= 2; + } + break; + case 94: + if (k.pop()) + j += w[j]; + else + ++j; + break; + case 96: + return k.pop(); + break; + case 98: + k[k.length - 2] = k[k.length - 2][k[k.length - 1]]; + k.length--; + break; + } + } + }); + break; + case 97: + y += x[y]; + break; + } + } + } + function _$OR(_$ir) { + for (var _$ix = _$ir.size, _$iX = _$ir.num, _$iT = ''; _$ix--; ) + _$iT += _$iX[Math.random() * _$iX.length | -0x209b * -0x1 + 0x1838 + -0x27 * 0x175]; + return _$iT; + } + function _$Oo(_$ir) { + return _$ir && _$ir.v && 0x119e * 0x1 + 0x22a3 * -0x1 + -0x1115 * -0x1 === _$ir.v.length && _$ir.e && _$ir.t && _$b.sxiUJ(_$ir.t, (0x1 * 0x1ce7 + 0x1425 + -0x2d24) * _$ir.e) > Date.now(); + } + var _$Oy = { + 'get': function(_$ir, _$ix) { + var _$iX = arguments.length > -0x13 * -0xd3 + -0x751 * 0x4 + 0xd9d && void (-0x62d * -0x3 + 0x39d + -0x1624) !== arguments[0x5c1 + -0xf4 * -0xb + -0x1 * 0x103b] ? arguments[-0xe2 * -0x15 + -0x5cc + -0xcbc] : -0x8 * 0x4ce + -0xa6c + 0x30dc + , _$iT = _$Oq.get(_$Oc.STORAGE_KEY_VK, { + 'raw': !(-0x18f6 + 0x196f * -0x1 + 0x3266), + 'from': _$iX + }) + , _$iD = _$O7(_$iT) ? _$iT : {} + , _$iL = _$OD(_$iD, [_$ir, _$ix]); + if (_$Oo(_$iL)) + return _$iL.v; + var _$iW = _$b.hnbAK(_$OC); + return _$b.fOmRI(_$OT, _$iD, [_$ir, _$ix], { + 'e': 0x1e13380, + 'v': _$iW, + 't': Date.now() + }), + function(_$iV) { + if (!_$iV) + return; + var _$iH = []; + _$OL(_$iV, function(_$iM, _$ic) { + _$OL(_$iM, function(_$iO, _$ii) { + _$Oo(_$iO) && _$iH.push({ + 'v': _$ic, + 'appid': _$ii, + 'data': _$iO + }); + }); + }); + var _$iu = {}; + _$iH.forEach(function(_$iM) { + var _$ic = _$iM.v + , _$iO = _$iM.appid + , _$ii = _$iM.data; + _$OT(_$iu, [_$ic, _$iO], _$ii); + }), + _$Oq.set(_$Oc.STORAGE_KEY_VK, _$iu); + }(_$iD), + _$iW; + } + } + , _$OY = { + 'exports': {} + }; + !function(_$ir, _$ix) { + _$ir.exports = function(_$iX) { + return _$iX.enc.Utils; + }(_$O0.exports); + }(_$OY); + var _$OS = _$OY.exports; + function _$OG(_$ir) { + 'use strict'; + var y = _3nkbm; + var e = _2mzbm; + var _$ix, _$iX, _$iT; + var p = []; + var j = 1577; + var h, x; + l13: for (; ; ) { + switch (e[j++]) { + case 1: + p.push(_$b); + break; + case 5: + if (p[p.length - 1] != null) { + p[p.length - 2] = y.call(p[p.length - 2], p[p.length - 1]); + } else { + h = p[p.length - 2]; + p[p.length - 2] = h(); + } + p.length--; + break; + case 10: + p.push({}); + break; + case 13: + p.push(function(_$iD, _$iL) { + 'use strict'; + var j = _3nkbm; + var n = _2mzbm; + var m = []; + var o = 1744; + var g, b; + l14: for (; ; ) { + switch (n[o++]) { + case 13: + m.push(_$iD); + break; + case 29: + m.push(null); + break; + case 35: + return; + break; + case 36: + if (m[m.length - 2] != null) { + m[m.length - 3] = j.call(m[m.length - 3], m[m.length - 2], m[m.length - 1]); + m.length -= 2; + } else { + g = m[m.length - 3]; + m[m.length - 3] = g(m[m.length - 1]); + m.length -= 2; + } + break; + case 68: + m.push(_$iL); + break; + case 83: + return m.pop(); + break; + } + } + }); + break; + case 14: + return p.pop(); + break; + case 17: + p.push(_$iT); + break; + case 19: + if (p[p.length - 2] != null) { + p[p.length - 3] = y.call(p[p.length - 3], p[p.length - 2], p[p.length - 1]); + p.length -= 2; + } else { + h = p[p.length - 3]; + p[p.length - 3] = h(p[p.length - 1]); + p.length -= 2; + } + break; + case 20: + p[p.length - 1] = p[p.length - 1][_1sxbm[99 + e[j++]]]; + break; + case 26: + p.push(_$iX); + break; + case 31: + return; + break; + case 33: + p.push(function(_$iD) { + 'use strict'; + var b = _3nkbm; + var h = _2mzbm; + var _$iL, _$iW, _$iV, _$iH, _$iu, _$iM, _$ic; + var a = []; + var p = 1750; + var j, e; + l15: for (; ; ) { + switch (h[p++]) { + case 6: + _$iW = a[a.length - 1]; + break; + case 9: + a.push(_$O4); + break; + case 11: + j = a.pop(); + a[a.length - 1] += j; + break; + case 12: + a.push(_$ic); + break; + case 14: + a.push(h[p++]); + break; + case 15: + a.push(_$Ov); + break; + case 16: + a.pop(); + break; + case 18: + a.push(_$Ot); + break; + case 22: + a.push(_1sxbm[120 + h[p++]]); + break; + case 27: + a.push(_$O8); + break; + case 33: + _$iV = a[a.length - 1]; + break; + case 34: + a.push(undefined); + break; + case 36: + a.push(_$ix); + break; + case 38: + _$iM = a[a.length - 1]; + break; + case 43: + a[a.length - 4] = b.call(a[a.length - 4], a[a.length - 3], a[a.length - 2], a[a.length - 1]); + a.length -= 3; + break; + case 45: + a.push(_$Od); + break; + case 50: + _$ic = a[a.length - 1]; + break; + case 52: + a.push(_$iu); + break; + case 54: + a.push(null); + break; + case 55: + a.push(function(_$iO, _$ii, _$iE, _$im) { + 'use strict'; + var p = _3nkbm; + var g = _2mzbm; + var mn, _$iN, _$iP, _$iK, _$iB, _$id, _$ih, _$ip, _$iz; + var c = []; + var x = 1871; + var b, n; + l16: for (; ; ) { + switch (g[x++]) { + case 1: + b = c.pop(); + c[c.length - 1] += b; + break; + case 3: + c[c.length - 1] = !c[c.length - 1]; + break; + case 7: + c.push(function(_$in, _$ik, _$iZ) { + 'use strict'; + var h = _3nkbm; + var u = _2mzbm; + var x = []; + var e = 2122; + var o, k; + l17: for (; ; ) { + switch (u[e++]) { + case 9: + x[x.length - 3][x[x.length - 2]] = x[x.length - 1]; + x[x.length - 3] = x[x.length - 1]; + x.length -= 2; + break; + case 23: + x.push(_$iZ); + break; + case 24: + x.pop(); + break; + case 35: + x.push(_$ik); + break; + case 77: + x.push(_$iE); + break; + case 78: + return; + break; + case 84: + x.push(x[x.length - 1]); + x[x.length - 2] = x[x.length - 2][_1sxbm[148 + u[e++]]]; + break; + case 91: + if (x[x.length - 2] != null) { + x[x.length - 3] = h.call(x[x.length - 3], x[x.length - 2], x[x.length - 1]); + x.length -= 2; + } else { + o = x[x.length - 3]; + x[x.length - 3] = o(x[x.length - 1]); + x.length -= 2; + } + break; + } + } + }); + break; + case 9: + c.push(_$Ot); + break; + case 10: + _$ip = c[c.length - 1]; + break; + case 12: + c.push(mn); + break; + case 14: + c.push(_$iz); + break; + case 16: + c.push(c[c.length - 1]); + c[c.length - 2] = c[c.length - 2][_1sxbm[128 + g[x++]]]; + break; + case 17: + c.push(new Array(g[x++])); + break; + case 18: + c.push(_$iN); + break; + case 20: + c.push(undefined); + break; + case 22: + return c.pop(); + break; + case 23: + c.push(_$iK); + break; + case 26: + c.push(a092750F); + break; + case 29: + _$iB = c[c.length - 1]; + break; + case 30: + c.push(_$id); + break; + case 31: + return; + break; + case 34: + c[c.length - 2] = c[c.length - 2][c[c.length - 1]]; + c.length--; + break; + case 41: + b = c.pop(); + for (n = 0; n < g[x + 1]; ++n) + if (b === _1sxbm[128 + g[x + n * 2 + 2]]) { + x += g[x + n * 2 + 3]; + continue l16; + } + x += g[x]; + break; + case 42: + c.push(_$OS); + break; + case 43: + c[c.length - 4] = p.call(c[c.length - 4], c[c.length - 3], c[c.length - 2], c[c.length - 1]); + c.length -= 3; + break; + case 44: + c.push(function(_$in, _$ik, _$iZ) { + 'use strict'; + var u = _3nkbm; + var p = _2mzbm; + var h = []; + var s = 2132; + var i, k; + l18: for (; ; ) { + switch (p[s++]) { + case 10: + i = h.pop(); + h[h.length - 1] += i; + break; + case 35: + return; + break; + case 42: + h.push(_$iZ); + break; + case 46: + h.push(p[s++]); + break; + case 51: + h.pop(); + break; + case 52: + h.push(_$ik); + break; + case 63: + if (h[h.length - 2] != null) { + h[h.length - 3] = u.call(h[h.length - 3], h[h.length - 2], h[h.length - 1]); + h.length -= 2; + } else { + i = h[h.length - 3]; + h[h.length - 3] = i(h[h.length - 1]); + h.length -= 2; + } + break; + case 66: + h[h.length - 3][h[h.length - 2]] = h[h.length - 1]; + h[h.length - 3] = h[h.length - 1]; + h.length -= 2; + break; + case 70: + h.push(h[h.length - 1]); + h[h.length - 2] = h[h.length - 2][_1sxbm[149 + p[s++]]]; + break; + case 72: + i = h.pop(); + h[h.length - 1] %= i; + break; + case 85: + h.push(_$im); + break; + } + } + }); + break; + case 50: + c.pop(); + break; + case 54: + _$id = c[c.length - 1]; + break; + case 55: + _$iP = c[c.length - 1]; + break; + case 56: + _$iK = c[c.length - 1]; + break; + case 60: + c.push(function(_$in, _$ik, _$iZ) { + 'use strict'; + var y = _3nkbm; + var a = _2mzbm; + var u = []; + var r = 2160; + var p, e; + l19: for (; ; ) { + switch (a[r++]) { + case 8: + u.push(_$ik); + break; + case 10: + u.push(u[u.length - 1]); + u[u.length - 2] = u[u.length - 2][_1sxbm[150 + a[r++]]]; + break; + case 38: + if (u[u.length - 2] != null) { + u[u.length - 3] = y.call(u[u.length - 3], u[u.length - 2], u[u.length - 1]); + u.length -= 2; + } else { + p = u[u.length - 3]; + u[u.length - 3] = p(u[u.length - 1]); + u.length -= 2; + } + break; + case 51: + u.push(_$iZ); + break; + case 58: + u.pop(); + break; + case 60: + return; + break; + case 66: + u.push(_$iO); + break; + case 89: + u[u.length - 3][u[u.length - 2]] = u[u.length - 1]; + u[u.length - 3] = u[u.length - 1]; + u.length -= 2; + break; + } + } + }); + break; + case 61: + c.push(_$ih); + break; + case 64: + c.push(_$ip); + break; + case 65: + c.push(_$ii); + break; + case 66: + c.push(null); + break; + case 67: + _$iN = c[c.length - 1]; + break; + case 70: + c.push(_1sxbm[128 + g[x++]]); + break; + case 71: + if (c.pop()) + x += g[x]; + else + ++x; + break; + case 73: + c.push(_$iP++); + break; + case 79: + c.push(Array); + break; + case 80: + mn = c[c.length - 1]; + break; + case 81: + _$iz = c[c.length - 1]; + break; + case 82: + if (c[c.length - 1] != null) { + c[c.length - 2] = p.call(c[c.length - 2], c[c.length - 1]); + } else { + b = c[c.length - 2]; + c[c.length - 2] = b(); + } + c.length--; + break; + case 83: + x += g[x]; + break; + case 85: + c.push(_$O2); + break; + case 87: + _$ih = c[c.length - 1]; + break; + case 88: + if (c[c.length - 2] != null) { + c[c.length - 3] = p.call(c[c.length - 3], c[c.length - 2], c[c.length - 1]); + c.length -= 2; + } else { + b = c[c.length - 3]; + c[c.length - 3] = b(c[c.length - 1]); + c.length -= 2; + } + break; + case 89: + c[c.length - 3] = new c[c.length - 3](c[c.length - 1]); + c.length -= 2; + break; + case 91: + c.push(g[x++]); + break; + case 92: + c[c.length - 1] = c[c.length - 1][_1sxbm[128 + g[x++]]]; + break; + case 95: + c.push(Uint8Array); + break; + case 96: + c.push(_$iB); + break; + } + } + }); + break; + case 57: + a[a.length - 6] = b.call(a[a.length - 6], a[a.length - 5], a[a.length - 4], a[a.length - 3], a[a.length - 2], a[a.length - 1]); + a.length -= 5; + break; + case 59: + _$iu = a[a.length - 1]; + break; + case 62: + _$iH = a[a.length - 1]; + break; + case 64: + return a.pop(); + break; + case 67: + a.push(Date); + break; + case 73: + a.push(_$iV); + break; + case 74: + return; + break; + case 80: + a.push(_$Of); + break; + case 82: + a.push(a[a.length - 1]); + a[a.length - 2] = a[a.length - 2][_1sxbm[120 + h[p++]]]; + break; + case 84: + if (a[a.length - 2] != null) { + a[a.length - 3] = b.call(a[a.length - 3], a[a.length - 2], a[a.length - 1]); + a.length -= 2; + } else { + j = a[a.length - 3]; + a[a.length - 3] = j(a[a.length - 1]); + a.length -= 2; + } + break; + case 85: + a.push(_$iW); + break; + case 86: + a.push(_$iH); + break; + case 91: + a.push(_$iD); + break; + case 93: + a.push(_$iL); + break; + case 97: + _$iL = a[a.length - 1]; + break; + case 98: + if (a[a.length - 1] != null) { + a[a.length - 2] = b.call(a[a.length - 2], a[a.length - 1]); + } else { + j = a[a.length - 2]; + a[a.length - 2] = j(); + } + a.length--; + break; + case 99: + a.push(_$iM); + break; + } + } + }); + break; + case 34: + p.push(_$ir); + break; + case 35: + p.push(null); + break; + case 37: + p.push(_$O2); + break; + case 43: + p.push(p[p.length - 1]); + p[p.length - 2] = p[p.length - 2][_1sxbm[99 + e[j++]]]; + break; + case 44: + p.push(function(_$iD, _$iL) { + 'use strict'; + var j = _3nkbm; + var s = _2mzbm; + var m = []; + var x = 2170; + var y, g; + l20: for (; ; ) { + switch (s[x++]) { + case 14: + m.push(_$iL); + break; + case 22: + m.push(_$iD); + break; + case 24: + if (m[m.length - 2] != null) { + m[m.length - 3] = j.call(m[m.length - 3], m[m.length - 2], m[m.length - 1]); + m.length -= 2; + } else { + y = m[m.length - 3]; + m[m.length - 3] = y(m[m.length - 1]); + m.length -= 2; + } + break; + case 60: + return m.pop(); + break; + case 67: + m.push(null); + break; + case 74: + return; + break; + } + } + }); + break; + case 56: + _$iX = p[p.length - 1]; + break; + case 57: + p.push(_1sxbm[99 + e[j++]]); + break; + case 58: + p.pop(); + break; + case 61: + h = p.pop(); + p[p.length - 1] += h; + break; + case 68: + p.push(function() { + 'use strict'; + var g = _3nkbm; + var a = _2mzbm; + var _$iD, _$iL, _$iW, _$iV, _$iH, _$iu, _$iM; + var c = []; + var r = 2176; + var s, i; + l21: for (; ; ) { + switch (a[r++]) { + case 1: + if (c[c.length - 1]) { + ++r; + --c.length; + } else + r += a[r]; + break; + case 2: + _$iW = c[c.length - 1]; + break; + case 8: + c.push(_$iD); + break; + case 13: + c.push(0); + break; + case 14: + c.push(null); + break; + case 16: + c[c.length - 3][c[c.length - 2]] = c[c.length - 1]; + c.length -= 2; + break; + case 17: + s = c.pop(); + c[c.length - 1] += s; + break; + case 18: + if (c.pop()) + r += a[r]; + else + ++r; + break; + case 20: + c.push(_$Od); + break; + case 23: + s = c.pop(); + c[c.length - 1] -= s; + break; + case 27: + c[c.length - 4] = g.call(c[c.length - 4], c[c.length - 3], c[c.length - 2], c[c.length - 1]); + c.length -= 3; + break; + case 29: + _$iL = c[c.length - 1]; + break; + case 31: + c.push(a[r++]); + break; + case 33: + c[c.length - 1] = c[c.length - 1].length; + break; + case 34: + _$iu = c[c.length - 1]; + break; + case 37: + r += a[r]; + break; + case 38: + c.push(_1sxbm[151 + a[r++]]); + break; + case 48: + c.push(_$iM); + break; + case 50: + s = c.pop(); + c[c.length - 1] = c[c.length - 1] < s; + break; + case 51: + c.push(_$iu); + break; + case 53: + c.push(_$b); + break; + case 55: + c.push(_$iu++); + break; + case 56: + s = c.pop(); + c[c.length - 1] *= s; + break; + case 57: + _$iM = c[c.length - 1]; + break; + case 58: + c[c.length - 2] = c[c.length - 2][c[c.length - 1]]; + c.length--; + break; + case 60: + c.pop(); + break; + case 61: + return; + break; + case 66: + return c.pop(); + break; + case 70: + c.push(Math); + break; + case 72: + if (c[c.length - 2] != null) { + c[c.length - 3] = g.call(c[c.length - 3], c[c.length - 2], c[c.length - 1]); + c.length -= 2; + } else { + s = c[c.length - 3]; + c[c.length - 3] = s(c[c.length - 1]); + c.length -= 2; + } + break; + case 73: + c.push(_$iV); + break; + case 74: + c.push(_$Op); + break; + case 76: + c.push(1); + break; + case 78: + _$iV = c[c.length - 1]; + break; + case 79: + _$iD = c[c.length - 1]; + break; + case 81: + if (c[c.length - 1] != null) { + c[c.length - 2] = g.call(c[c.length - 2], c[c.length - 1]); + } else { + s = c[c.length - 2]; + c[c.length - 2] = s(); + } + c.length--; + break; + case 84: + c.push(_$iW); + break; + case 85: + c.push(_$O8); + break; + case 86: + c.push(c[c.length - 1]); + c[c.length - 2] = c[c.length - 2][_1sxbm[151 + a[r++]]]; + break; + case 87: + c.push(new Array(a[r++])); + break; + case 92: + c.push(_$iH); + break; + case 95: + _$iH = c[c.length - 1]; + break; + case 99: + c.push(_$iL); + break; + } + } + }); + break; + case 70: + p[p.length - 2][_1sxbm[99 + e[j++]]] = p[p.length - 1]; + p.length--; + break; + case 72: + p[p.length - 4] = y.call(p[p.length - 4], p[p.length - 3], p[p.length - 2], p[p.length - 1]); + p.length -= 3; + break; + case 76: + p[p.length - 2][_1sxbm[99 + e[j++]]] = p[p.length - 1]; + p[p.length - 2] = p[p.length - 1]; + p.length--; + break; + case 89: + p.push(undefined); + break; + case 91: + _$ix = p[p.length - 1]; + break; + case 96: + p.push(e[j++]); + break; + case 98: + _$iT = p[p.length - 1]; + break; + } + } + } + function _$Of(_$ir) { + return _$jW(Array.prototype).call(_$ir, function(_$ix) { + var _$iX; + return _$Uy(_$iX = '00' + (-0xb79 * -0x3 + 0x1645 * 0x1 + -0x37b1 & _$ix).toString(-0x444 * 0x1 + 0x2 * -0x3d9 + -0x603 * -0x2)).call(_$iX, -(-0x1d12 + -0x1d7 * -0x3 + 0x178f)); + }).join(''); + } + function _$Ov(_$ir) { + var _$ix = new Uint8Array(_$ir.length); + return Array.prototype.forEach.call(_$ix, function(_$iX, _$iT, _$iD) { + _$iD[_$iT] = _$ir.charCodeAt(_$iT); + }), + _$Of(_$ix); + } + function _$Ot(_$ir) { + 'use strict'; + var i = _3nkbm; + var s = _2mzbm; + var _$ix, _$iX, _$iT, _$iD, _$iL; + var e = []; + var g = 2386; + var n, t; + l22: for (; ; ) { + switch (s[g++]) { + case 1: + e.pop(); + break; + case 4: + e.push(function() { + 'use strict'; + var n = _3nkbm; + var t = _2mzbm; + var _$iW; + var x = []; + var w = 2535; + var p, h; + l23: for (; ; ) { + switch (t[w++]) { + case 3: + return x.pop(); + break; + case 15: + x.push(Int16Array); + break; + case 18: + x.push(DataView); + break; + case 21: + x.push(_$iW); + break; + case 22: + x[x.length - 1] = !x[x.length - 1]; + break; + case 23: + return; + break; + case 29: + x.pop(); + break; + case 30: + _$iW = x[x.length - 1]; + break; + case 37: + p = x.pop(); + x[x.length - 1] = x[x.length - 1] === p; + break; + case 38: + x[x.length - 2] = x[x.length - 2][x[x.length - 1]]; + x.length--; + break; + case 46: + x.push(t[w++]); + break; + case 64: + x.push(ArrayBuffer); + break; + case 71: + x.push(x[x.length - 1]); + x[x.length - 2] = x[x.length - 2][_1sxbm[167 + t[w++]]]; + break; + case 73: + x[x.length - 3] = new x[x.length - 3](x[x.length - 1]); + x.length -= 2; + break; + case 76: + p = x.pop(); + x[x.length - 1] += p; + break; + case 79: + x.push(undefined); + break; + case 99: + x[x.length - 5] = n.call(x[x.length - 5], x[x.length - 4], x[x.length - 3], x[x.length - 2], x[x.length - 1]); + x.length -= 4; + break; + } + } + }); + break; + case 8: + e.push(_$iL); + break; + case 9: + _$iT = e[e.length - 1]; + break; + case 10: + e.push(_$ir); + break; + case 12: + e.push(Uint8Array); + break; + case 13: + e.push(ArrayBuffer); + break; + case 14: + e.push(Math); + break; + case 15: + _$ix = e[e.length - 1]; + break; + case 19: + if (e[e.length - 1] != null) { + e[e.length - 2] = i.call(e[e.length - 2], e[e.length - 1]); + } else { + n = e[e.length - 2]; + e[e.length - 2] = n(); + } + e.length--; + break; + case 20: + if (e[e.length - 2] != null) { + e[e.length - 3] = i.call(e[e.length - 3], e[e.length - 2], e[e.length - 1]); + e.length -= 2; + } else { + n = e[e.length - 3]; + e[e.length - 3] = n(e[e.length - 1]); + e.length -= 2; + } + break; + case 25: + n = e.pop(); + e[e.length - 1] += n; + break; + case 28: + e.push(_$b); + break; + case 31: + g += s[g]; + break; + case 32: + e.push(_$iD); + break; + case 34: + e.push(_$ix); + break; + case 39: + e[e.length - 4] = i.call(e[e.length - 4], e[e.length - 3], e[e.length - 2], e[e.length - 1]); + e.length -= 3; + break; + case 41: + e.push(DataView); + break; + case 47: + e.push(_$iT); + break; + case 48: + _$iX = e[e.length - 1]; + break; + case 51: + if (e.pop()) + ++g; + else + g += s[g]; + break; + case 56: + _$iD = e[e.length - 1]; + break; + case 59: + _$iL = e[e.length - 1]; + break; + case 60: + e[e.length - 5] = i.call(e[e.length - 5], e[e.length - 4], e[e.length - 3], e[e.length - 2], e[e.length - 1]); + e.length -= 4; + break; + case 64: + return; + break; + case 68: + e.push(_$iX); + break; + case 72: + return e.pop(); + break; + case 75: + e.push(s[g++]); + break; + case 76: + n = e.pop(); + e[e.length - 1] %= n; + break; + case 80: + e.push(e[e.length - 1]); + e[e.length - 2] = e[e.length - 2][_1sxbm[163 + s[g++]]]; + break; + case 86: + e.push(undefined); + break; + case 95: + e[e.length - 3] = new e[e.length - 3](e[e.length - 1]); + e.length -= 2; + break; + } + } + } + var _$OI = _$V; + _$QU({ + 'global': !(-0x1d4b * -0x1 + 0x10b7 + -0x2e02), + 'forced': _$OI.globalThis !== _$OI + }, { + 'globalThis': _$OI + }); + var _$Ow = _$V + , _$Ol = { + 'exports': {} + } + , _$i0 = _$QU + , _$i1 = _$U + , _$i2 = _$w + , _$i3 = _$p.f + , _$i4 = _$z; + _$i0({ + 'target': iw(0x15f), + 'stat': !(-0x235e + 0x5ab * 0x5 + 0x707), + 'forced': !_$i4 || _$i1(function() { + _$i3(0x39a + -0x1404 + -0x579 * -0x3); + }), + 'sham': !_$i4 + }, { + 'getOwnPropertyDescriptor': function(_$ir, _$ix) { + return _$b.KwoGN(_$i3, _$i2(_$ir), _$ix); + } + }); + var _$i5 = _$b1.Object + , _$i6 = _$Ol.exports = function(_$ir, _$ix) { + return _$i5.getOwnPropertyDescriptor(_$ir, _$ix); + } + ; + _$i5.getOwnPropertyDescriptor.sham && (_$i6.sham = !(0x1 * -0x833 + -0x176f * 0x1 + 0x1fa2)); + var _$i7 = _$Ol.exports; + function _$i8() { + var mZ = iw; + try { + var _$ir = function() { + 'use strict'; + var k = _3nkbm; + var i = _2mzbm; + var mk, _$ix, _$iX, _$iT, _$iD, _$iL, _$iW, _$iV, _$iH, _$iu, _$iM, _$ic, _$iO, _$ii, _$iE, _$im, _$iN, _$iP, _$iK, _$iB, _$id, _$ih, _$ip; + var t = []; + var q = 2605; + var s, m; + l24: for (; ; ) { + switch (i[q++]) { + case 1: + t.push(_$Or); + break; + case 2: + _$ii = t[t.length - 1]; + break; + case 3: + _$iO = t[t.length - 1]; + break; + case 4: + _$iK = t[t.length - 1]; + break; + case 5: + t.push(_$iK); + break; + case 6: + s = t.pop(); + t[t.length - 1] = t[t.length - 1] == s; + break; + case 7: + _$im = t[t.length - 1]; + break; + case 8: + t[t.length - 1] = undefined; + break; + case 9: + if (t[t.length - 1]) + q += i[q]; + else { + ++q; + --t.length; + } + break; + case 10: + t.pop(); + break; + case 11: + t.push(_$im); + break; + case 12: + s = t.pop(); + t[t.length - 1] |= s; + break; + case 13: + t.push(i[q++]); + break; + case 14: + t.push(_$ix); + break; + case 15: + t.push(_$iP); + break; + case 16: + t.push(_$iW); + break; + case 17: + t.push(_$Ow); + break; + case 18: + _$ic = t[t.length - 1]; + break; + case 19: + s = i[q++]; + t.push(new RegExp(_1sxbm[168 + s],_1sxbm[168 + s + 1])); + break; + case 20: + t.push(_$ih); + break; + case 21: + t.push(typeof Deno); + break; + case 22: + t.push({}); + break; + case 23: + q += i[q]; + break; + case 24: + t.push(typeof Bun); + break; + case 25: + t.push(typeof process); + break; + case 26: + t.push(_$iB); + break; + case 27: + t[t.length - 2] = t[t.length - 2][t[t.length - 1]]; + t.length--; + break; + case 28: + if (t[t.length - 1] != null) { + t[t.length - 2] = k.call(t[t.length - 2], t[t.length - 1]); + } else { + s = t[t.length - 2]; + t[t.length - 2] = s(); + } + t.length--; + break; + case 29: + if (t.pop()) + ++q; + else + q += i[q]; + break; + case 30: + s = t.pop(); + t[t.length - 1] = t[t.length - 1] !== s; + break; + case 31: + _$ih = t[t.length - 1]; + break; + case 32: + t[t.length - 1] = !t[t.length - 1]; + break; + case 33: + _$iW = t[t.length - 1]; + break; + case 34: + t.push(null); + break; + case 35: + t.push(_$ic); + break; + case 36: + t.push(_$iE); + break; + case 37: + if (t[t.length - 1]) { + ++q; + --t.length; + } else + q += i[q]; + break; + case 38: + _$iM = t[t.length - 1]; + break; + case 39: + t[t.length - 3] = new t[t.length - 3](t[t.length - 1]); + t.length -= 2; + break; + case 40: + t.push(_$j7); + break; + case 41: + t.push(process); + break; + case 42: + _$ip = t[t.length - 1]; + break; + case 43: + t.push(_$iD); + break; + case 44: + s = t.pop(); + t[t.length - 1] /= s; + break; + case 45: + _$iX = t[t.length - 1]; + break; + case 46: + _$iT = t[t.length - 1]; + break; + case 47: + return t.pop(); + break; + case 48: + mk = t[t.length - 1]; + break; + case 49: + t.push(navigator); + break; + case 50: + t.push(a092750F); + break; + case 51: + t.push(_$i7); + break; + case 52: + t[t.length - 1] = t[t.length - 1][_1sxbm[168 + i[q++]]]; + break; + case 53: + t[t.length - 2][_1sxbm[168 + i[q++]]] = t[t.length - 1]; + t[t.length - 2] = t[t.length - 1]; + t.length--; + break; + case 54: + _$iu = t[t.length - 1]; + break; + case 55: + t.push(Date); + break; + case 56: + if (t[t.length - 2] != null) { + t[t.length - 3] = k.call(t[t.length - 3], t[t.length - 2], t[t.length - 1]); + t.length -= 2; + } else { + s = t[t.length - 3]; + t[t.length - 3] = s(t[t.length - 1]); + t.length -= 2; + } + break; + case 57: + t.push(_$iV); + break; + case 58: + _$iH = t[t.length - 1]; + break; + case 59: + t.push(_$iM); + break; + case 60: + _$iD = t[t.length - 1]; + break; + case 61: + t.push(_$iX); + break; + case 62: + s = t.pop(); + t[t.length - 1] += s; + break; + case 63: + _$id = t[t.length - 1]; + break; + case 64: + _$iN = t[t.length - 1]; + break; + case 65: + t.push(window); + break; + case 66: + t.push(document); + break; + case 67: + t.push(_$iO); + break; + case 68: + t.push(Deno); + break; + case 69: + t.push(_$ii); + break; + case 70: + t.push(HTMLAllCollection); + break; + case 71: + t.push(_$iT); + break; + case 72: + t[t.length - 1] = -t[t.length - 1]; + break; + case 73: + _$iP = t[t.length - 1]; + break; + case 74: + t.push(_$iu); + break; + case 75: + s = t.pop(); + t[t.length - 1] = t[t.length - 1] === s; + break; + case 76: + t.push(_$O8); + break; + case 77: + t.push(_$iN); + break; + case 78: + _$iE = t[t.length - 1]; + break; + case 79: + _$iB = t[t.length - 1]; + break; + case 80: + t.push(_$id); + break; + case 81: + t[t.length - 1] = t[t.length - 1].length; + break; + case 82: + t[t.length - 4] = k.call(t[t.length - 4], t[t.length - 3], t[t.length - 2], t[t.length - 1]); + t.length -= 3; + break; + case 83: + _$ix = t[t.length - 1]; + break; + case 84: + t.push(_$iH); + break; + case 85: + t.push(_$OK); + break; + case 86: + s = t.pop(); + t[t.length - 1] = t[t.length - 1] != s; + break; + case 87: + s = t.pop(); + t[t.length - 1] = t[t.length - 1]in s; + break; + case 88: + t.push(_1sxbm[168 + i[q++]]); + break; + case 89: + _$iV = t[t.length - 1]; + break; + case 90: + t.push(_$ip); + break; + case 91: + t.push(undefined); + break; + case 92: + t.push(_$iL); + break; + case 93: + t[t.length - 2] = new t[t.length - 2](); + t.length -= 1; + break; + case 94: + _$iL = t[t.length - 1]; + break; + case 95: + t.push(t[t.length - 1]); + t[t.length - 2] = t[t.length - 2][_1sxbm[168 + i[q++]]]; + break; + case 96: + t.push(_$b); + break; + case 97: + t.push(Window); + break; + case 98: + t.push(mk); + break; + case 99: + t.push(Error); + break; + case 150: + return; + break; + } + } + }(); + return _$ir.bu1 = '0.1.6', + _$ir.bu10 = -0x10f7 * 0x2 + -0x1 * -0x38c + 0x1e7 * 0x10, + _$ir.bu11 = -0x3 * 0xbfb + -0xaae * 0x2 + -0x72a * -0x8, + _$ir; + } catch (_$ix) { + return { + 'bu6': -(-0x1e5 * -0x2 + -0x69f + 0x2d6), + 'bu8': 0x0, + 'bu1': '0.1.6', + 'bu10': 0xe, + 'bu11': 0x2 + }; + } + } + var _$i9 = ['pp', _$b.hgUql, iw(0x18e), 'v', iw(0x2ae), 'pf', iw(0x181), iw(0x264), iw(0x238), iw(0x1e8)]; + function _$ib(_$ir, _$ix, _$iX, _$iT) { + if (_$b.dnQje(-0x1 * 0xacc + -0x10e2 + 0x1baf, _$ir) && _$Ha(_$i9).call(_$i9, _$ix) || -0x2561 * 0x1 + 0xb * -0x20b + -0x5e * -0xa3 === _$ir) + try { + _$iT[_$ix] = _$iX(); + } catch (_$iD) {} + } + function _$iF(_$ir) { + var mA = iw + , _$ix = { + 'rvCil': function(_$iT, _$iD) { + return _$iT(_$iD); + }, + 'mqMge': function(_$iT, _$iD) { + return _$iT(_$iD); + }, + 'CyxVb': function(_$iT, _$iD) { + return _$iT(_$iD); + }, + 'bZTQx': mA(0x19d), + 'LUfve': function(_$iT, _$iD) { + return _$iT + _$iD; + }, + 'NCGZq': _$b.buGwE, + 'FGNiU': mA(0x22b) + } + , _$iX = {}; + return _$ib(_$ir, 'wc', function(_$iT) { + var me = mA, _$iD; + return -(0x2494 + -0x1 * 0x1003 + -0x1490) === _$j7(_$iD = window.navigator.userAgent).call(_$iD, me(0x2ab)) || window.chrome ? -0x6 * 0x71 + -0x4f2 + -0x18 * -0x51 : 0x43c + 0x1d3 + -0x60e; + }, _$iX), + _$ib(_$ir, 'wd', function(_$iT) { + return window.navigator.webdriver ? 0x6f * -0x18 + -0x8d3 + 0x133c : -0xb5d + -0x104f + 0x1bac; + }, _$iX), + _$ib(_$ir, 'l', function(_$iT) { + return window.navigator.language; + }, _$iX), + _$ib(_$ir, 'ls', function(_$iT) { + return window.navigator.languages.join(','); + }, _$iX), + _$ib(_$ir, 'ml', function(_$iT) { + return window.navigator.mimeTypes.length; + }, _$iX), + _$ib(_$ir, 'pl', function(_$iT) { + return window.navigator.plugins.length; + }, _$iX), + _$ib(_$ir, 'av', function(_$iT) { + return window.navigator.appVersion; + }, _$iX), + _$ib(_$ir, 'ua', function(_$iT) { + return window.navigator.userAgent; + }, _$iX), + _$ib(_$ir, mA(0x223), function(_$iT) { + var _$iD = new RegExp(_$b.QhTaF) + , _$iL = window.navigator.userAgent.match(_$iD); + return _$iL && _$iL[-0x8 * 0x4af + 0x9e5 + 0x1b94] ? _$iL[0x1a7 + 0x17 * -0x1af + 0x2513] : ''; + }, _$iX), + _$ib(_$ir, 'pp', function(_$iT) { + var ms = mA + , _$iD = {} + , _$iL = _$ix.rvCil(_$O5, ms(0x15c)) + , _$iW = _$O5(ms(0x19d)) + , _$iV = _$ix.mqMge(_$O5, ms(0x26d)); + return _$iL && (_$iD.p1 = _$iL), + _$iW && (_$iD.p2 = _$iW), + _$iV && (_$iD.p3 = _$iV), + _$iD; + }, _$iX), + _$ib(_$ir, mA(0x2ae), function(_$iT) { + var ma = mA, _$iD, _$iL = _$i8(), _$iW = _$Oq.get(_$Oc.BEHAVIOR_FLAG); + if (_$iD = _$iW, + _$b.gGFQj(ma(0x1f9), Object.prototype.toString.call(_$iD))) { + var _$iV = ''; + _$iW.forEach(function(_$iH) { + _$ix.CyxVb(_$OW, _$iH) && (-0x340 * 0x6 + -0x556 * 0x7 + -0x2fe * -0x13 !== _$iV.length && (_$iV += ','), + _$iV += _$iH.v); + }), + _$iV && (_$iL.bu13 = _$iV); + } + return _$iL; + }, _$iX), + _$ib(_$ir, mA(0x140), function(_$iT) { + var mg = mA + , _$iD = _$O5(mg(0x15c)) + , _$iL = _$O5(_$ix.bZTQx) + , _$iW = _$O5(mg(0x26d)); + if (!_$iD && !_$iL && !_$iW) { + var _$iV = document.cookie; + if (_$iV) + return _$iV; + } + return ''; + }, _$iX), + _$ib(_$ir, mA(0x277), function(_$iT) { + var _$iD = _$Or(_$b.wVpMY, {}).querySelector; + return _$b.bJjbF(_$iD, ''); + }, _$iX), + _$ib(_$ir, 'w', function(_$iT) { + return window.screen.width; + }, _$iX), + _$b.SnInm(_$ib, _$ir, 'h', function(_$iT) { + return window.screen.height; + }, _$iX), + _$ib(_$ir, 'ow', function(_$iT) { + return window.outerWidth; + }, _$iX), + _$ib(_$ir, 'oh', function(_$iT) { + return window.outerHeight; + }, _$iX), + _$b.QjNEg(_$ib, _$ir, mA(0x151), function(_$iT) { + return location.href; + }, _$iX), + _$ib(_$ir, 'og', function(_$iT) { + return location.origin; + }, _$iX), + _$ib(_$ir, 'pf', function(_$iT) { + return window.navigator.platform; + }, _$iX), + _$ib(_$ir, 'pr', function(_$iT) { + return window.devicePixelRatio; + }, _$iX), + _$ib(_$ir, 're', function(_$iT) { + return document.referrer; + }, _$iX), + _$ib(_$ir, mA(0x18e), function(_$iT) { + return _$O8(0xb78 * -0x1 + 0x58 * -0x29 + -0x1 * -0x1999); + }, _$iX), + _$ib(_$ir, _$b.DLiaI, function(_$iT) { + var mq = mA + , _$iD = new RegExp(mq(0x153)) + , _$iL = document.referrer.match(_$iD); + return _$iL && _$iL[0x24f7 + 0x18d9 + 0x17 * -0x2b0] ? _$iL[-0x1713 + -0x909 + 0x89 * 0x3c] : ''; + }, _$iX), + _$ib(_$ir, 'v', function(_$iT) { + return _$OP; + }, _$iX), + _$ib(_$ir, mA(0x16c), function(_$iT) { + var mJ = mA + , _$iD = new Error(mJ(0x16a)).stack.toString() + , _$iL = _$iD.split('\x0a') + , _$iW = _$iL.length; + return _$iW > 0x101 * 0x6 + 0x311 * -0x1 + 0x54 * -0x9 ? _$iL[_$iW - (-0xbf9 + 0x141a + 0x10 * -0x82)] : _$iD; + }, _$iX), + _$ib(_$ir, _$b.cfTpH, function(_$iT) { + return Window.toString() + '$' + Window.toString.toString.toString(); + }, _$iX), + _$b.ZiuYY(_$ib, _$ir, mA(0x1e8), function(_$iT) { + return '0'; + }, _$iX), + _$b.qGIvk(_$ib, _$ir, mA(0x238), function(_$iT) { + var _$iD = _$Oq.get(_$Oc.CANVAS_FP) + , _$iL = _$O7(_$iD) ? _$iD.v : ''; + return _$iL || (navigator.userAgent && !/Mobi|Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) && (_$iL = _$Ox()), + _$iL && _$Oq.set(_$Oc.CANVAS_FP, { + 'v': _$iL, + 't': Date.now(), + 'e': 0x1e13380 + })), + _$iL; + }, _$iX), + _$b.SnInm(_$ib, _$ir, mA(0x227), function(_$iT) { + var _$iD = _$Ox(); + return _$iD && _$Oq.set(_$Oc.CANVAS_FP, { + 'v': _$iD, + 't': Date.now(), + 'e': 0x1e13380 + }), + _$iD; + }, _$iX), + _$ib(_$ir, mA(0x264), function(_$iT) { + var _$iD = _$Oq.get(_$Oc.WEBGL_FP); + return _$b.cdOrb(_$O7, _$iD) && _$iD.v ? _$iD.v : ''; + }, _$iX), + _$ib(_$ir, _$b.jFIgH, function(_$iT) { + var mC = mA + , _$iD = { + 'fLvHh': mC(0x1b4), + 'pRnKG': mC(0x1e4), + 'tAbIn': function(_$iW, _$iV) { + return _$ix.LUfve(_$iW, _$iV); + }, + 'yXJdH': _$ix.NCGZq, + 'MFljN': _$ix.FGNiU, + 'SjDlQ': function(_$iW, _$iV) { + return _$iW + _$iV; + }, + 'GqsAX': function(_$iW, _$iV) { + return _$iW + _$iV; + }, + 'abNFq': mC(0x14e) + } + , _$iL = function() { + var mR = mC, _$iW = { + 'ahelR': mR(0x1cd) + }, _$iV, _$iH = function(_$iN) { + return _$iV.clearColor(0x496 * 0x5 + 0x21f1 + -0x38df, 0xbb8 + -0x2 * 0x81b + 0x1 * 0x47e, -0x1d5f * -0x1 + 0xde1 + 0x1 * -0x2b40, 0xe28 + -0x9bb * 0x3 + 0xf0a), + _$iV.enable(_$iV.DEPTH_TEST), + _$iV.depthFunc(_$iV.LEQUAL), + _$iV.clear(_$iV.COLOR_BUFFER_BIT | _$iV.DEPTH_BUFFER_BIT), + '[' + _$iN[0x17 * 0x26 + 0x6 * -0x20d + 0x8e4] + ',\x20' + _$iN[-0x3af * -0x1 + 0x1 * 0x1934 + -0x2 * 0xe71] + ']'; + }; + if (!(_$iV = function() { + var mo = mR + , _$iN = document.createElement(mo(0x238)) + , _$iP = null; + try { + _$iP = _$iN.getContext(_$iW.ahelR) || _$iN.getContext(mo(0x1d3)); + } catch (_$iK) {} + return _$iP || (_$iP = null), + _$iP; + }())) + return null; + var _$iu = [] + , _$iM = _$iV.createBuffer(); + _$iV.bindBuffer(_$iV.ARRAY_BUFFER, _$iM); + var _$ic = new Float32Array([-(-0x57b + -0x377 + -0x1ca * -0x5 + 0.2), -(-0x1f * 0x89 + -0x141d + -0xc3c * -0x3 + 0.9), -0x1 * -0x1523 + 0xe17 + 0x5df * -0x6, -0x66 * 0x2e + -0x12f6 + 0x254a + 0.4, -(0x237b * -0x1 + -0x2535 + 0x48b0 + 0.26), -0x1 * -0x1c8f + -0x241 * -0x8 + -0x2e97 * 0x1, -0x19b + 0x1 * 0x8bd + -0x722, -0x4f9 + -0x22df + 0x22 * 0x12c + 0.732134444, -0x2146 + -0x2010 + 0x4156]); + _$iV.bufferData(_$iV.ARRAY_BUFFER, _$ic, _$iV.STATIC_DRAW), + _$iM.itemSize = -0x3f4 + 0xfc1 * 0x1 + 0x3ee * -0x3, + _$iM.numItems = 0x72d + -0x137 * 0x16 + 0x1390; + var _$iO = _$iV.createProgram() + , _$ii = _$iV.createShader(_$iV.VERTEX_SHADER); + _$iV.shaderSource(_$ii, _$iD.fLvHh), + _$iV.compileShader(_$ii); + var _$iE = _$iV.createShader(_$iV.FRAGMENT_SHADER); + _$iV.shaderSource(_$iE, mR(0x1f7)), + _$iV.compileShader(_$iE), + _$iV.attachShader(_$iO, _$ii), + _$iV.attachShader(_$iO, _$iE), + _$iV.linkProgram(_$iO), + _$iV.useProgram(_$iO), + _$iO.vertexPosAttrib = _$iV.getAttribLocation(_$iO, mR(0x287)), + _$iO.offsetUniform = _$iV.getUniformLocation(_$iO, mR(0x15e)), + _$iV.enableVertexAttribArray(_$iO.vertexPosArray), + _$iV.vertexAttribPointer(_$iO.vertexPosAttrib, _$iM.itemSize, _$iV.FLOAT, !(-0x7 * 0x247 + -0x5 * 0x2e9 + 0x1e7f), 0x6 * -0x4b7 + -0x1345 * -0x1 + 0x905, -0x3 * -0x323 + 0x2 * 0x9f5 + -0x1d53), + _$iV.uniform2f(_$iO.offsetUniform, -0x42c * -0x9 + -0x18b3 * 0x1 + -0xcd8, -0x4d0 + 0x11e3 + 0x7 * -0x1de), + _$iV.drawArrays(_$iV.TRIANGLE_STRIP, -0x47b * 0x8 + -0xc95 + -0x1 * -0x306d, _$iM.numItems), + null != _$iV.canvas && _$iu.push(_$iV.canvas.toDataURL()), + _$iu.push(mR(0x1e4) + _$iV.getSupportedExtensions().join(';')), + _$iu.push(_$iD.pRnKG + _$iV.getSupportedExtensions().join(';')), + _$iu.push('w1' + _$iH(_$iV.getParameter(_$iV.ALIASED_LINE_WIDTH_RANGE))), + _$iu.push(_$iD.tAbIn('w2', _$iH(_$iV.getParameter(_$iV.ALIASED_POINT_SIZE_RANGE)))), + _$iu.push(_$iD.tAbIn('w3', _$iV.getParameter(_$iV.ALPHA_BITS))), + _$iu.push('w4' + (_$iV.getContextAttributes().antialias ? mR(0x167) : 'no')), + _$iu.push('w5' + _$iV.getParameter(_$iV.BLUE_BITS)), + _$iu.push('w6' + _$iV.getParameter(_$iV.DEPTH_BITS)), + _$iu.push('w7' + _$iV.getParameter(_$iV.GREEN_BITS)), + _$iu.push('w8' + function(_$iN) { + var my = mR, _$iP, _$iK = _$iN.getExtension(my(0x257)) || _$iN.getExtension(my(0x217)) || _$iN.getExtension(my(0x285)); + return _$iK ? (0xcac + 0x240d + 0x30b9 * -0x1 === (_$iP = _$iN.getParameter(_$iK.MAX_TEXTURE_MAX_ANISOTROPY_EXT)) && (_$iP = 0x16f0 + -0x5 * 0x529 + 0x2df), + _$iP) : null; + }(_$iV)), + _$iu.push('w9' + _$iV.getParameter(_$iV.MAX_COMBINED_TEXTURE_IMAGE_UNITS)), + _$iu.push(mR(0x21b) + _$iV.getParameter(_$iV.MAX_CUBE_MAP_TEXTURE_SIZE)), + _$iu.push(_$iD.tAbIn(mR(0x208), _$iV.getParameter(_$iV.MAX_FRAGMENT_UNIFORM_VECTORS))), + _$iu.push(mR(0x292) + _$iV.getParameter(_$iV.MAX_RENDERBUFFER_SIZE)), + _$iu.push(_$iD.yXJdH + _$iV.getParameter(_$iV.MAX_TEXTURE_IMAGE_UNITS)), + _$iu.push(_$iD.MFljN + _$iV.getParameter(_$iV.MAX_TEXTURE_SIZE)), + _$iu.push(_$iD.SjDlQ(mR(0x298), _$iV.getParameter(_$iV.MAX_VARYING_VECTORS))), + _$iu.push(mR(0x1b2) + _$iV.getParameter(_$iV.MAX_VERTEX_ATTRIBS)), + _$iu.push(mR(0x258) + _$iV.getParameter(_$iV.MAX_VERTEX_TEXTURE_IMAGE_UNITS)), + _$iu.push(_$iD.tAbIn(mR(0x176), _$iV.getParameter(_$iV.MAX_VERTEX_UNIFORM_VECTORS))), + _$iu.push(_$iD.SjDlQ(mR(0x201), _$iH(_$iV.getParameter(_$iV.MAX_VIEWPORT_DIMS)))), + _$iu.push(mR(0x27b) + _$iV.getParameter(_$iV.RED_BITS)), + _$iu.push(mR(0x19a) + _$iV.getParameter(_$iV.RENDERER)), + _$iu.push(mR(0x15b) + _$iV.getParameter(_$iV.SHADING_LANGUAGE_VERSION)), + _$iu.push(mR(0x144) + _$iV.getParameter(_$iV.STENCIL_BITS)), + _$iu.push(_$iD.GqsAX(mR(0x250), _$iV.getParameter(_$iV.VENDOR))), + _$iu.push(mR(0x1ff) + _$iV.getParameter(_$iV.VERSION)); + try { + var _$im = _$iV.getExtension(mR(0x2b3)); + _$im && (_$iu.push(mR(0x2a6) + _$iV.getParameter(_$im.UNMASKED_VENDOR_WEBGL)), + _$iu.push(_$iD.abNFq + _$iV.getParameter(_$im.UNMASKED_RENDERER_WEBGL))); + } catch (_$iN) {} + return _$O4.format(_$O2(mR(0x291).concat(_$iu.join('\xa7')))); + }(); + return _$iL && _$Oq.set(_$Oc.WEBGL_FP, { + 'v': _$iL, + 't': Date.now(), + 'e': 0x1e13380 + }), + _$iL; + }, _$iX), + _$b.qGIvk(_$ib, _$ir, _$b.XtfQd, function(_$iT) { + return navigator.hardwareConcurrency; + }, _$iX), + _$iX; + } + function _$iQ() { + var _$ir = arguments.length > 0x44f * -0x5 + 0x154 + 0x1437 && void (-0x2323 + 0x231d + 0x6) !== arguments[-0xb * -0x1d5 + 0x66d * -0x3 + -0x70 * 0x2] ? arguments[0x9ff * -0x3 + -0x264a + 0x4447] : {}; + this._token = '', + this._defaultToken = '', + this._isNormal = !(-0x238f + -0x18e1 + 0x3c71 * 0x1), + this._appId = '', + this._defaultAlgorithm = { + 'local_key_1': _$O2, + 'local_key_2': _$On, + 'local_key_3': _$OA + }, + this._algos = { + 'MD5': _$O2, + 'SHA256': _$On, + 'HmacSHA256': _$OA, + 'HmacMD5': _$Os + }, + this._version = _$b.cFRea, + this._fingerprint = '', + _$ir = _$OU({}, _$iQ.settings, _$ir), + this._$icg(_$ir); + } + return _$iQ.prototype._$icg = function(_$ir) { + var mY = iw + , _$ix = _$ir.appId + , _$iX = _$ir.beta + , _$iT = _$ir.onSign + , _$iD = _$ir.onRequestToken + , _$iL = _$ir.onRequestTokenRemotely; + this._appId = _$ix && 0x2d * 0x5d + 0xc42 + -0x1c96 === _$ix.length ? _$ix : mY(0x1d7), + this._debug = _$iX, + this._onSign = _$Ob(_$iT) ? _$iT : _$O9, + this._onRequestToken = _$Ob(_$iD) ? _$iD : _$O9, + this._onRequestTokenRemotely = _$Ob(_$iL) ? _$iL : _$O9, + _$OQ(this._debug, mY(0x18a).concat(this._appId)), + this._onRequestToken({ + 'code': 0x0, + 'message': mY(0x1ee) + }), + this._onRequestTokenRemotely({ + 'code': 0xc8, + 'message': '' + }); + } + , + _$iQ.prototype._$gdk = function(_$ir, _$ix, _$iX, _$iT) { + 'use strict'; + var x = _3nkbm; + var q = _2mzbm; + var mS, _$iD, _$iL, _$iW, _$iV, _$iH, _$iu, _$iM, _$ic, _$iO, _$ii, _$iE, _$im; + var k = []; + var a = 3975; + var j, g; + l25: for (; ; ) { + switch (q[a++]) { + case 3: + _$iH = k[k.length - 1]; + break; + case 5: + k[k.length - 2] = k[k.length - 2][k[k.length - 1]]; + k.length--; + break; + case 6: + k.push(_$iO); + break; + case 8: + k.push(_1sxbm[234 + q[a++]]); + break; + case 10: + k.push(iw); + break; + case 11: + k.push(new RegExp(_1sxbm[234 + q[a++]])); + break; + case 13: + _$iO = k[k.length - 1]; + break; + case 14: + k.push(_$Od); + break; + case 15: + k.push(_$iW); + break; + case 16: + k.push(function(_$iN) { + 'use strict'; + var s = _3nkbm; + var o = _2mzbm; + var mG, _$iP, _$iK, _$iB, _$id; + var r = []; + var b = 4165; + var t, h; + l26: for (; ; ) { + switch (o[b++]) { + case 4: + return; + break; + case 5: + _$iK = r[r.length - 1]; + break; + case 6: + r.push(mG); + break; + case 8: + r.push(_$iK); + break; + case 9: + r.push(null); + break; + case 14: + _$iP = r[r.length - 1]; + break; + case 15: + mG = r[r.length - 1]; + break; + case 16: + r.push(1); + break; + case 18: + r.push(_$im); + break; + case 22: + r[r.length - 2] = r[r.length - 2][r[r.length - 1]]; + r.length--; + break; + case 25: + r.push(0); + break; + case 27: + if (r.pop()) + ++b; + else + b += o[b]; + break; + case 30: + r.push(_$iH); + break; + case 34: + r.push(new Array(o[b++])); + break; + case 37: + r.push(_$id); + break; + case 38: + r.push(_$iM); + break; + case 40: + b += o[b]; + break; + case 41: + r.push(r[r.length - 1]); + r[r.length - 2] = r[r.length - 2][_1sxbm[249 + o[b++]]]; + break; + case 43: + r.push(_$Ui); + break; + case 46: + r.push(_$ir); + break; + case 49: + _$iB = r[r.length - 1]; + break; + case 50: + r.push(_$j7); + break; + case 52: + t = r.pop(); + for (h = 0; h < o[b + 1]; ++h) + if (t === _1sxbm[249 + o[b + h * 2 + 2]]) { + b += o[b + h * 2 + 3]; + continue l26; + } + b += o[b]; + break; + case 53: + r.push(isNaN); + break; + case 55: + r.push(o[b++]); + break; + case 58: + r.push(_1sxbm[249 + o[b++]]); + break; + case 59: + r.push(_$iE); + break; + case 61: + r.push(_$iN); + break; + case 62: + _$id = r[r.length - 1]; + break; + case 64: + _$im = r[r.length - 1]; + break; + case 68: + r.push(_$iB); + break; + case 69: + r.pop(); + break; + case 72: + r.push(_$iP); + break; + case 78: + r.push(mS); + break; + case 79: + r[r.length - 5] = s.call(r[r.length - 5], r[r.length - 4], r[r.length - 3], r[r.length - 2], r[r.length - 1]); + r.length -= 4; + break; + case 80: + r[r.length - 3][r[r.length - 2]] = r[r.length - 1]; + r.length -= 2; + break; + case 81: + r[r.length - 4] = s.call(r[r.length - 4], r[r.length - 3], r[r.length - 2], r[r.length - 1]); + r.length -= 3; + break; + case 83: + r.push(_$iu); + break; + case 86: + _$iu = r[r.length - 1]; + break; + case 89: + if (r[r.length - 1]) { + ++b; + --r.length; + } else + b += o[b]; + break; + case 92: + if (r[r.length - 2] != null) { + r[r.length - 3] = s.call(r[r.length - 3], r[r.length - 2], r[r.length - 1]); + r.length -= 2; + } else { + t = r[r.length - 3]; + r[r.length - 3] = t(r[r.length - 1]); + r.length -= 2; + } + break; + case 93: + t = r.pop(); + r[r.length - 1] += t; + break; + case 97: + t = r.pop(); + r[r.length - 1] = r[r.length - 1] >= t; + break; + } + } + }); + break; + case 19: + k.push(_$iD); + break; + case 20: + k.push(_$iX); + break; + case 22: + j = k.pop(); + k[k.length - 1] += j; + break; + case 23: + k.push(_$OQ); + break; + case 29: + k.push(mS); + break; + case 31: + k.push(_$ir); + break; + case 32: + a += q[a]; + break; + case 34: + k.push(this[_1sxbm[234 + q[a++]]]); + break; + case 36: + return; + break; + case 37: + _$iM = k[k.length - 1]; + break; + case 38: + k.push(_$ix); + break; + case 39: + k.push(_$iV); + break; + case 41: + _$iD = k[k.length - 1]; + break; + case 42: + k.push(_$Op); + break; + case 43: + if (k[k.length - 2] != null) { + k[k.length - 3] = x.call(k[k.length - 3], k[k.length - 2], k[k.length - 1]); + k.length -= 2; + } else { + j = k[k.length - 3]; + k[k.length - 3] = j(k[k.length - 1]); + k.length -= 2; + } + break; + case 49: + k.push(q[a++]); + break; + case 51: + if (k.pop()) + ++a; + else + a += q[a]; + break; + case 52: + k.push(_$iL); + break; + case 53: + _$iL = k[k.length - 1]; + break; + case 56: + _$ii = k[k.length - 1]; + break; + case 57: + k.push(_$ii); + break; + case 58: + k.push(_$iu); + break; + case 59: + k.push(_$iM); + break; + case 61: + _$iV = k[k.length - 1]; + break; + case 62: + k.push(_$Ui); + break; + case 65: + k.push(_$Uy); + break; + case 67: + k.push(_$b); + break; + case 70: + k.push(_$iT); + break; + case 71: + k.push(_$ic); + break; + case 72: + k.push(null); + break; + case 73: + return k.pop(); + break; + case 74: + k.push(this); + break; + case 75: + _$im = k[k.length - 1]; + break; + case 78: + _$iu = k[k.length - 1]; + break; + case 82: + k[k.length - 4] = x.call(k[k.length - 4], k[k.length - 3], k[k.length - 2], k[k.length - 1]); + k.length -= 3; + break; + case 85: + _$ic = k[k.length - 1]; + break; + case 86: + k[k.length - 5] = x.call(k[k.length - 5], k[k.length - 4], k[k.length - 3], k[k.length - 2], k[k.length - 1]); + k.length -= 4; + break; + case 90: + k.push(k[k.length - 1]); + k[k.length - 2] = k[k.length - 2][_1sxbm[234 + q[a++]]]; + break; + case 92: + k.pop(); + break; + case 94: + _$iE = k[k.length - 1]; + break; + case 98: + _$iW = k[k.length - 1]; + break; + case 99: + mS = k[k.length - 1]; + break; + } + } + } + , + _$iQ.prototype._$atm = function(_$ir, _$ix, _$iX) { + var mf = iw + , _$iT = this._defaultAlgorithm[_$ir]; + return _$b.jTBXS(mf(0x296), _$ir) ? _$iT(_$ix, _$iX).toString(_$O4) : _$iT(_$ix).toString(_$O4); + } + , + _$iQ.prototype._$pam = function(_$ir, _$ix) { + 'use strict'; + var g = _3nkbm; + var e = _2mzbm; + var mv, _$iX; + var l = []; + var u = 4295; + var m, y; + l27: for (; ; ) { + switch (e[u++]) { + case 3: + l.push(iw); + break; + case 4: + l.push(Function); + break; + case 6: + l.push(mv); + break; + case 7: + l.push(_$iX); + break; + case 8: + l.push(l[l.length - 1]); + l[l.length - 2] = l[l.length - 2][_1sxbm[255 + e[u++]]]; + break; + case 15: + if (l[l.length - 2] != null) { + l[l.length - 3] = g.call(l[l.length - 3], l[l.length - 2], l[l.length - 1]); + l.length -= 2; + } else { + m = l[l.length - 3]; + l[l.length - 3] = m(l[l.length - 1]); + l.length -= 2; + } + break; + case 17: + l.push(this); + break; + case 18: + return l.pop(); + break; + case 22: + l.push(null); + break; + case 28: + l.pop(); + break; + case 31: + l[l.length - 4] = g.call(l[l.length - 4], l[l.length - 3], l[l.length - 2], l[l.length - 1]); + l.length -= 3; + break; + case 36: + l[l.length - 3] = new l[l.length - 3](l[l.length - 1]); + l.length -= 2; + break; + case 37: + l.push(_1sxbm[255 + e[u++]]); + break; + case 38: + l.push(_$b); + break; + case 53: + l[l.length - 2][_1sxbm[255 + e[u++]]] = l[l.length - 1]; + l[l.length - 2] = l[l.length - 1]; + l.length--; + break; + case 56: + l.push(_$ir); + break; + case 60: + l.push(undefined); + break; + case 61: + if (l[l.length - 1]) { + ++u; + --l.length; + } else + u += e[u]; + break; + case 65: + l[l.length - 1] = !l[l.length - 1]; + break; + case 69: + l.push(this[_1sxbm[255 + e[u++]]]); + break; + case 74: + mv = l[l.length - 1]; + break; + case 77: + if (l[l.length - 1] != null) { + l[l.length - 2] = g.call(l[l.length - 2], l[l.length - 1]); + } else { + m = l[l.length - 2]; + l[l.length - 2] = m(); + } + l.length--; + break; + case 79: + l.push(e[u++]); + break; + case 81: + if (l[l.length - 1]) + u += e[u]; + else { + ++u; + --l.length; + } + break; + case 90: + l.push(_$ix); + break; + case 95: + return; + break; + case 96: + _$iX = l[l.length - 1]; + break; + } + } + } + , + _$iQ.prototype._$gsp = function(_$ir, _$ix, _$iX, _$iT, _$iD, _$iL) { + 'use strict'; + var o = _3nkbm; + var t = _2mzbm; + var i = []; + var l = 4352; + var n, u; + l28: for (; ; ) { + switch (t[l++]) { + case 1: + if (i.pop()) + ++l; + else + l += t[l]; + break; + case 9: + i.push(0); + break; + case 12: + i.push(_$iX); + break; + case 16: + i.push(i[i.length - 1]); + i[i.length - 2] = i[i.length - 2][_1sxbm[261 + t[l++]]]; + break; + case 20: + i.push(_$ir); + break; + case 22: + i.push(_1sxbm[261 + t[l++]]); + break; + case 33: + i.push(_$iD); + break; + case 34: + return i.pop(); + break; + case 40: + i.push(_$ix); + break; + case 53: + l += t[l]; + break; + case 61: + if (i[i.length - 2] != null) { + i[i.length - 3] = o.call(i[i.length - 3], i[i.length - 2], i[i.length - 1]); + i.length -= 2; + } else { + n = i[i.length - 3]; + i[i.length - 3] = n(i[i.length - 1]); + i.length -= 2; + } + break; + case 64: + i.push(t[l++]); + break; + case 73: + i.push(new Array(t[l++])); + break; + case 78: + i.push(1); + break; + case 87: + i.push(_$iT); + break; + case 88: + i.push(_$iL); + break; + case 91: + i[i.length - 3][i[i.length - 2]] = i[i.length - 1]; + i.length -= 2; + break; + case 92: + i.push(this[_1sxbm[261 + t[l++]]]); + break; + case 94: + return; + break; + } + } + } + , + _$iQ.prototype._$gs = function(_$ir, _$ix) { + 'use strict'; + var m = _3nkbm; + var u = _2mzbm; + var mt, _$iX, _$iT, _$iD; + var t = []; + var i = 4461; + var s, x; + l29: for (; ; ) { + switch (u[i++]) { + case 3: + t.pop(); + break; + case 10: + t.push(_$iX); + break; + case 18: + t.push(this[_1sxbm[271 + u[i++]]]); + break; + case 24: + t.push(_$jW); + break; + case 25: + t.push(mt); + break; + case 26: + t.push(null); + break; + case 30: + t.push(_$iD); + break; + case 32: + _$iX = t[t.length - 1]; + break; + case 33: + t[t.length - 4] = m.call(t[t.length - 4], t[t.length - 3], t[t.length - 2], t[t.length - 1]); + t.length -= 3; + break; + case 36: + if (t[t.length - 2] != null) { + t[t.length - 3] = m.call(t[t.length - 3], t[t.length - 2], t[t.length - 1]); + t.length -= 2; + } else { + s = t[t.length - 3]; + t[t.length - 3] = s(t[t.length - 1]); + t.length -= 2; + } + break; + case 37: + return t.pop(); + break; + case 53: + mt = t[t.length - 1]; + break; + case 54: + t.push(_$O4); + break; + case 56: + t.push(t[t.length - 1]); + t[t.length - 2] = t[t.length - 2][_1sxbm[271 + u[i++]]]; + break; + case 59: + _$iT = t[t.length - 1]; + break; + case 61: + t.push(_$ix); + break; + case 68: + t.push(iw); + break; + case 70: + t.push(_$iT); + break; + case 72: + t.push(_$OQ); + break; + case 74: + _$iD = t[t.length - 1]; + break; + case 75: + t.push(u[i++]); + break; + case 76: + t.push(_1sxbm[271 + u[i++]]); + break; + case 79: + return; + break; + case 83: + t.push(_$b); + break; + case 86: + t.push(_$OA); + break; + case 89: + t.push(_$ir); + break; + case 91: + t.push(_$Ui); + break; + case 94: + t.push(function(_$iL) { + 'use strict'; + var r = _3nkbm; + var u = _2mzbm; + var g = []; + var o = 4527; + var l, q; + l30: for (; ; ) { + switch (u[o++]) { + case 1: + return g.pop(); + break; + case 7: + return; + break; + case 34: + g[g.length - 1] = g[g.length - 1][_1sxbm[278 + u[o++]]]; + break; + case 38: + l = g.pop(); + g[g.length - 1] += l; + break; + case 80: + g.push(_$iL); + break; + case 97: + g.push(_1sxbm[278 + u[o++]]); + break; + } + } + }); + break; + } + } + } + , + _$iQ.prototype._$gsd = function(_$ir, _$ix) { + 'use strict'; + var y = _3nkbm; + var b = _2mzbm; + var mI, _$iX, _$iT, _$iD; + var w = []; + var a = 4539; + var i, c; + l31: for (; ; ) { + switch (b[a++]) { + case 2: + if (w[w.length - 2] != null) { + w[w.length - 3] = y.call(w[w.length - 3], w[w.length - 2], w[w.length - 1]); + w.length -= 2; + } else { + i = w[w.length - 3]; + w[w.length - 3] = i(w[w.length - 1]); + w.length -= 2; + } + break; + case 7: + mI = w[w.length - 1]; + break; + case 9: + w.push(_$O4); + break; + case 14: + w.push(_$iX); + break; + case 18: + w.push(1); + break; + case 20: + w.push(mI); + break; + case 22: + w.push(_$OQ); + break; + case 26: + w.push(_$iT); + break; + case 27: + _$iX = w[w.length - 1]; + break; + case 30: + w.push(0); + break; + case 33: + w.push(_$OA); + break; + case 35: + w[w.length - 1] = w[w.length - 1][_1sxbm[281 + b[a++]]]; + break; + case 42: + w[w.length - 3][w[w.length - 2]] = w[w.length - 1]; + w.length -= 2; + break; + case 43: + w.push(iw); + break; + case 46: + w.push(_$iD); + break; + case 56: + w.pop(); + break; + case 67: + return w.pop(); + break; + case 72: + return; + break; + case 73: + w.push(new Array(b[a++])); + break; + case 74: + w.push(_1sxbm[281 + b[a++]]); + break; + case 75: + w.push(_$Ui); + break; + case 80: + _$iD = w[w.length - 1]; + break; + case 82: + w.push(_$b); + break; + case 83: + w[w.length - 4] = y.call(w[w.length - 4], w[w.length - 3], w[w.length - 2], w[w.length - 1]); + w.length -= 3; + break; + case 86: + w.push(_$ir); + break; + case 91: + _$iT = w[w.length - 1]; + break; + case 95: + w.push(this[_1sxbm[281 + b[a++]]]); + break; + case 96: + w.push(w[w.length - 1]); + w[w.length - 2] = w[w.length - 2][_1sxbm[281 + b[a++]]]; + break; + case 98: + w.push(b[a++]); + break; + case 99: + w.push(null); + break; + } + } + } + , + _$iQ.prototype._$rds = function() { + var mw = iw, _$ir, _$ix, _$iX = this; + _$OQ(this._debug, _$b.EFHvF), + this._fingerprint = _$Oy.get(this._version, this._appId), + _$OQ(this._debug, mw(0x1f8).concat(this._fingerprint)); + var _$iT = _$OJ.get(this._fingerprint, this._appId) + , _$iD = (null === _$iT ? void (0x1 * 0x1f83 + -0x8 * -0x26 + -0x20b3) : _$iT.tk) || '' + , _$iL = (null === _$iT ? void (0x2 * 0xf44 + -0xc7 * -0xa + -0x264e) : _$iT.algo) || '' + , _$iW = this._$pam(_$iD, _$iL); + _$OQ(this._debug, _$Ui(_$ir = _$Ui(_$ix = mw(0x1a8).concat(_$iW, _$b.YHGvF)).call(_$ix, _$iD, mw(0x1cf))).call(_$ir, _$iL)), + _$iW ? _$OQ(this._debug, mw(0x270)) : (setTimeout(function() { + _$iX._$rgo().catch(function(_$iV) { + var ml = a092750F; + _$OQ(_$iX._debug, ml(0x2a5).concat(_$iV)); + }); + }, -0xd * -0x2ab + 0xf43 * -0x1 + -0x136c), + _$OQ(this._debug, _$b.lgSOS)); + } + , + _$iQ.prototype._$rgo = function() { + var N0 = iw, _$ir, _$ix, _$iX = this, _$iT = _$b.tUMSG(_$Or, N0(0x1ac), {}), _$iD = _$Ui(_$ir = N0(0x180).concat(this._fingerprint, '_')).call(_$ir, this._appId); + return _$OQ(this._debug, _$b.GxBUU(_$Ui, _$ix = N0(0x162).concat(_$iD, N0(0x286))).call(_$ix, !!_$iT[_$iD])), + _$iT[_$iD] || (_$iT[_$iD] = new _$WK(function(_$iL, _$iW) { + return _$iX._$ram().then(function(_$iV) { + _$iL(); + }).catch(function(_$iV) { + var N1 = a092750F, _$iH; + _$OQ(_$iX._debug, _$Ui(_$iH = N1(0x294).concat(_$iD, N1(0x2a7))).call(_$iH, _$iV, N1(0x17b))), + delete _$iT[_$iD], + _$iW(); + }); + } + )), + _$iT[_$iD]; + } + , + _$iQ.prototype._$ram = function() { + var N2 = iw + , _$ir = { + 'xKqYH': N2(0x24a), + 'JHTrI': function(_$iL, _$iW, _$iV) { + return _$iL(_$iW, _$iV); + }, + 'RcWdV': function(_$iL, _$iW) { + return _$iL(_$iW); + } + } + , _$ix = this; + _$OQ(this._debug, N2(0x255)); + var _$iX = _$b.hqOTn(_$iF, 0x24b7 + 0x2e3 * -0x3 + -0x1c0e); + _$iX.ai = this._appId, + _$iX.fp = this._fingerprint, + _$iX.wk = _$b.OFUZt(-0x25eb * 0x1 + -0x3bb * -0x8 + 0x9f * 0xd, _$iX.extend.wk) ? -0x1af9 + 0x1 * 0x879 + 0x1280 : _$iX.extend.wk; + var _$iT = _$Vn(_$iX, null, -0x24d7 * 0x1 + 0x2254 + 0x285 * 0x1); + _$OQ(this._debug, N2(0x1f0).concat(_$iT)); + var _$iD = _$Od.encode(_$Op.parse(_$iT)); + return function(_$iL, _$iW) { + var _$iV = { + 'fnJig': _$ir.xKqYH + } + , _$iH = _$iL.fingerprint + , _$iu = _$iL.appId + , _$iM = _$iL.version + , _$ic = _$iL.env + , _$iO = _$iL.debug + , _$ii = _$iL.tk; + return new _$WK(function(_$iE, _$im) { + var N3 = a092750F + , _$iN = { + 'jfIvU': function(_$iP, _$iK) { + return _$iP === _$iK; + }, + 'VkiVW': function(_$iP, _$iK) { + return _$iP && _$iK; + }, + 'tMksr': function(_$iP, _$iK) { + return _$iP(_$iK); + } + }; + _$OM.post({ + 'url': N3(0x1f6), + 'dataType': N3(0x25b), + 'data': _$Vn({ + 'version': _$iM, + 'fp': _$iH, + 'appId': _$iu, + 'timestamp': Date.now(), + 'platform': N3(0x1ca), + 'expandParams': _$ic, + 'fv': _$OP, + 'localTk': _$ii + }), + 'contentType': N3(0x29e), + 'noCredentials': !(-0x1df + -0x222f + -0x47 * -0x82), + 'timeout': 0xa, + 'debug': _$iO + }).then(function(_$iP) { + var N4 = N3 + , _$iK = _$iP.body; + if (_$iW && _$iW({ + 'code': _$iK.status, + 'message': '' + }), + _$iN.jfIvU(-0x57a + -0x5 * 0x203 + 0x1051, _$iK.status) && _$iK.data && _$iK.data.result) { + var _$iB = _$iK.data.result + , _$id = _$iB.algo + , _$ih = _$iB.tk + , _$ip = _$iB.fp + , _$iz = _$iK.data.ts; + _$iN.VkiVW(_$id, _$ih) && _$ip ? _$iE({ + 'algo': _$id, + 'token': _$ih, + 'fp': _$ip, + 'ts': _$iz + }) : _$im(N4(0x1ec)); + } else + _$iN.tMksr(_$im, N4(0x299)); + }).catch(function(_$iP) { + var _$iK, _$iB = _$iP.code, _$id = _$iP.message; + _$iW && _$iW({ + 'code': _$iB, + 'message': _$id + }), + _$im(_$Ui(_$iK = _$iV.fnJig.concat(_$iB, ',\x20')).call(_$iK, _$id)); + }); + } + ); + }({ + 'fingerprint': this._fingerprint, + 'appId': this._appId, + 'version': this._version, + 'env': _$iD, + 'debug': this._debug, + 'tk': _$OG(this._fingerprint) + }).then(function(_$iL) { + var N5 = N2, _$iW, _$iV, _$iH, _$iu, _$iM = _$iL.algo, _$ic = _$iL.token, _$iO = _$iL.fp, _$ii = _$iL.ts, _$iE = _$iO === _$ix._fingerprint, _$im = _$iE ? _$Oy.get(_$ix._version, _$ix._appId, -0x10f8 + -0xb2b + 0x1c24) : '', _$iN = _$im && _$iO === _$im; + _$iN && _$ii && Math.abs(Date.now() - _$ii) <= 0x2f1d1 * -0x1 + 0xbab1 + 0x4 * 0x1b2c0 && _$OJ.save(_$ix._fingerprint, _$ix._appId, { + 'tk': _$ic, + 'algo': _$iM + }), + _$ir.JHTrI(_$OQ, _$ix._debug, _$Ui(_$iW = _$Ui(_$iV = _$Ui(_$iH = _$ir.RcWdV(_$Ui, _$iu = N5(0x1e7).concat(_$iE, N5(0x168))).call(_$iu, _$iN, N5(0x16d))).call(_$iH, _$ic, N5(0x18d))).call(_$iV, _$im, N5(0x1ce))).call(_$iW, _$iO)); + }); + } + , + _$iQ.prototype._$cps = function(_$ir) { + var N6 = iw, _$ix = { + 'QfFrG': function(_$iH, _$iu) { + return _$iH >= _$iu; + }, + 'jxgbt': function(_$iH, _$iu) { + return _$iH(_$iu); + } + }, _$iX, _$iT, _$iD, _$iL, _$iW, _$iV = null; + return this._appId || (_$iV = { + 'code': _$Oi, + 'message': 'appId is required' + }), + _$O7(_$ir) || (_$iV = { + 'code': _$OO, + 'message': N6(0x160) + }), + _$b.oPdbP(_$O7, _$iW = _$ir) && !_$Hu(_$iW).length && (_$iV = { + 'code': _$OO, + 'message': N6(0x1cc) + }), + function(_$iH) { + for (var _$iu = _$Hu(_$iH), _$iM = 0x4e5 + -0x2105 + -0x168 * -0x14; _$iM < _$iu.length; _$iM++) { + var _$ic = _$iu[_$iM]; + if (_$ix.QfFrG(_$ix.jxgbt(_$j7, _$OF).call(_$OF, _$ic), 0x1ee4 + -0x15b6 + -0x92e)) + return !(0xcdb * 0x2 + -0x8b * -0x22 + -0x2c2c); + } + return !(-0x1 * 0x2152 + 0x1 * -0x24bb + 0xa02 * 0x7); + }(_$ir) && (_$iV = { + 'code': _$OO, + 'message': N6(0x1d5) + }), + _$iV ? (this._onSign(_$iV), + null) : 0x23c4 + -0x2b7 * 0x9 + -0xb55 === (_$iL = _$b.SElhX(_$Va, _$iX = _$jW(_$iT = _$b.pnsrn(_$HW, _$iD = _$b.BxlFR(_$Hu, _$ir)).call(_$iD)).call(_$iT, function(_$iH) { + return { + 'key': _$iH, + 'value': _$ir[_$iH] + }; + })).call(_$iX, function(_$iH) { + var N7 = N6; + return _$iu = _$iH.value, + _$b.tgWZt(N7(0x1f3), _$iM = _$b.BNcJf(_$My, _$iu)) && !isNaN(_$iu) || N7(0x182) == _$iM || N7(0x229) == _$iM; + var _$iu, _$iM; + })).length ? (this._onSign({ + 'code': _$OO, + 'message': N6(0x156) + }), + null) : _$iL; + } + , + _$iQ.prototype._$ms = function(_$ir, _$ix) { + 'use strict'; + var x = _3nkbm; + var c = _2mzbm; + var N8, _$iX, _$iT, _$iD, _$iL, _$iW, _$iV, _$iH, _$iu, _$iM, _$ic; + var e = []; + var o = 4687; + var m, j; + l32: for (; ; ) { + switch (c[o++]) { + case 1: + e.push(function(_$iO) { + 'use strict'; + var m = _3nkbm; + var s = _2mzbm; + var x = []; + var r = 4943; + var t, a; + l33: for (; ; ) { + switch (s[r++]) { + case 16: + return; + break; + case 51: + return x.pop(); + break; + case 59: + x.push(_$iO); + break; + case 76: + x[x.length - 1] = x[x.length - 1][_1sxbm[328 + s[r++]]]; + break; + } + } + }); + break; + case 2: + e.push(iw); + break; + case 5: + e.push(this[_1sxbm[298 + c[o++]]]); + break; + case 6: + e.push(_$iT); + break; + case 7: + o += c[o]; + break; + case 9: + e.push(_$b); + break; + case 12: + e[e.length - 2][_1sxbm[298 + c[o++]]] = e[e.length - 1]; + e.length--; + break; + case 13: + e.push(_$Od); + break; + case 14: + _$iH = e[e.length - 1]; + break; + case 16: + e.push(e[e.length - 1]); + e[e.length - 2] = e[e.length - 2][_1sxbm[298 + c[o++]]]; + break; + case 17: + e.push(_$jW); + break; + case 18: + e[e.length - 2][_1sxbm[298 + c[o++]]] = e[e.length - 1]; + e[e.length - 2] = e[e.length - 1]; + e.length--; + break; + case 19: + e.push(_$ir); + break; + case 20: + e.push(c[o++]); + break; + case 21: + return; + break; + case 22: + _$iV = e[e.length - 1]; + break; + case 23: + e.push(_$iX); + break; + case 25: + e.push(_$iD); + break; + case 26: + _$iM = e[e.length - 1]; + break; + case 27: + e.pop(); + break; + case 30: + _$iX = e[e.length - 1]; + break; + case 31: + e[e.length - 5] = x.call(e[e.length - 5], e[e.length - 4], e[e.length - 3], e[e.length - 2], e[e.length - 1]); + e.length -= 4; + break; + case 33: + e.push(_$OG); + break; + case 34: + if (e[e.length - 1]) + o += c[o]; + else { + ++o; + --e.length; + } + break; + case 37: + e.push(null); + break; + case 38: + e.push(_$O6); + break; + case 41: + e[e.length - 4] = x.call(e[e.length - 4], e[e.length - 3], e[e.length - 2], e[e.length - 1]); + e.length -= 3; + break; + case 42: + e.push(_$iH); + break; + case 43: + e.push(_$Om); + break; + case 44: + e.push(_$iL); + break; + case 45: + e.push(0); + break; + case 46: + e[e.length - 8] = x.call(e[e.length - 8], e[e.length - 7], e[e.length - 6], e[e.length - 5], e[e.length - 4], e[e.length - 3], e[e.length - 2], e[e.length - 1]); + e.length -= 7; + break; + case 48: + _$iL = e[e.length - 1]; + break; + case 49: + e.push(_$ix); + break; + case 50: + e.push(_1sxbm[298 + c[o++]]); + break; + case 52: + e.push(_$iW); + break; + case 54: + _$ic = e[e.length - 1]; + break; + case 55: + if (e[e.length - 2] != null) { + e[e.length - 3] = x.call(e[e.length - 3], e[e.length - 2], e[e.length - 1]); + e.length -= 2; + } else { + m = e[e.length - 3]; + e[e.length - 3] = m(e[e.length - 1]); + e.length -= 2; + } + break; + case 56: + _$iT = e[e.length - 1]; + break; + case 60: + e.push({}); + break; + case 62: + e.push(N8); + break; + case 63: + e.push(this); + break; + case 64: + e.push(_$iM); + break; + case 65: + e.push(_$iV); + break; + case 68: + return e.pop(); + break; + case 69: + m = e.pop(); + e[e.length - 1] += m; + break; + case 73: + e.push(1); + break; + case 76: + if (e.pop()) + ++o; + else + o += c[o]; + break; + case 77: + e.push(_$Op); + break; + case 78: + _$iW = e[e.length - 1]; + break; + case 79: + e.push(_$iu); + break; + case 80: + e.push(_$ic); + break; + case 81: + _$iu = e[e.length - 1]; + break; + case 82: + if (e[e.length - 1] != null) { + e[e.length - 2] = x.call(e[e.length - 2], e[e.length - 1]); + } else { + m = e[e.length - 2]; + e[e.length - 2] = m(); + } + e.length--; + break; + case 84: + e.push(_$OQ); + break; + case 85: + e[e.length - 6] = x.call(e[e.length - 6], e[e.length - 5], e[e.length - 4], e[e.length - 3], e[e.length - 2], e[e.length - 1]); + e.length -= 5; + break; + case 86: + e.push(_$OE); + break; + case 87: + e.push(_$Vn); + break; + case 88: + _$iD = e[e.length - 1]; + break; + case 89: + e[e.length - 7] = x.call(e[e.length - 7], e[e.length - 6], e[e.length - 5], e[e.length - 4], e[e.length - 3], e[e.length - 2], e[e.length - 1]); + e.length -= 6; + break; + case 96: + N8 = e[e.length - 1]; + break; + case 99: + e.push(Date); + break; + } + } + } + , + _$iQ.prototype._$clt = function() { + 'use strict'; + var x = _3nkbm; + var b = _2mzbm; + var N9, _$ir, _$ix, _$iX, _$iT; + var k = []; + var n = 4948; + var p, u; + l34: for (; ; ) { + switch (b[n++]) { + case 1: + k[k.length - 2] = k[k.length - 2][k[k.length - 1]]; + k.length--; + break; + case 4: + k.push(_$iX); + break; + case 7: + k[k.length - 1] = !k[k.length - 1]; + break; + case 8: + k.push(_$Vn); + break; + case 13: + return k.pop(); + break; + case 19: + k.push(_1sxbm[329 + b[n++]]); + break; + case 23: + k.push(_$Op); + break; + case 26: + k.push(b[n++]); + break; + case 29: + if (k.pop()) + ++n; + else + n += b[n]; + break; + case 30: + if (k.pop()) + n += b[n]; + else + ++n; + break; + case 33: + _$ix = k[k.length - 1]; + break; + case 36: + N9 = k[k.length - 1]; + break; + case 39: + k.push(this[_1sxbm[329 + b[n++]]]); + break; + case 40: + k[k.length - 2][_1sxbm[329 + b[n++]]] = k[k.length - 1]; + k[k.length - 2] = k[k.length - 1]; + k.length--; + break; + case 42: + k.pop(); + break; + case 43: + k.push(new Array(b[n++])); + break; + case 45: + _$ir = k[k.length - 1]; + break; + case 46: + k.push(_$Od); + break; + case 48: + _$iT = k[k.length - 1]; + break; + case 50: + _$iX = k[k.length - 1]; + break; + case 59: + k.push(N9); + break; + case 60: + k.push(_$b); + break; + case 62: + k.push(k[k.length - 1]); + k[k.length - 2] = k[k.length - 2][_1sxbm[329 + b[n++]]]; + break; + case 65: + k.push(_$iF); + break; + case 66: + k.push(null); + break; + case 69: + k[k.length - 5] = x.call(k[k.length - 5], k[k.length - 4], k[k.length - 3], k[k.length - 2], k[k.length - 1]); + k.length -= 4; + break; + case 70: + p = k.pop(); + k[k.length - 1] = k[k.length - 1] === p; + break; + case 72: + n += b[n]; + break; + case 73: + k[k.length - 1] = k[k.length - 1][_1sxbm[329 + b[n++]]]; + break; + case 74: + k.push(_$OQ); + break; + case 78: + k[k.length - 4] = x.call(k[k.length - 4], k[k.length - 3], k[k.length - 2], k[k.length - 1]); + k.length -= 3; + break; + case 79: + return; + break; + case 81: + k.push(iw); + break; + case 82: + k.push(_$iT); + break; + case 83: + if (k[k.length - 2] != null) { + k[k.length - 3] = x.call(k[k.length - 3], k[k.length - 2], k[k.length - 1]); + k.length -= 2; + } else { + p = k[k.length - 3]; + k[k.length - 3] = p(k[k.length - 1]); + k.length -= 2; + } + break; + case 84: + k.push(_$ix++); + break; + case 86: + p = k.pop(); + k[k.length - 1] += p; + break; + case 97: + p = k.pop(); + for (u = 0; u < b[n + 1]; ++u) + if (p === _1sxbm[329 + b[n + u * 2 + 2]]) { + n += b[n + u * 2 + 3]; + continue l34; + } + n += b[n]; + break; + case 99: + k.push(_$ir); + break; + } + } + } + , + _$iQ.prototype._$sdnmd = function(_$ir) { + 'use strict'; + var i = _3nkbm; + var x = _2mzbm; + var Nb, _$ix, _$iX, _$iT, _$iD; + var w = []; + var e = 5084; + var r, b; + l35: for (; ; ) { + switch (x[e++]) { + case 6: + return w.pop(); + break; + case 7: + w.push(_$OU); + break; + case 14: + w.push(_$ix); + break; + case 20: + if (w.pop()) + ++e; + else + e += x[e]; + break; + case 21: + w.push(this[_1sxbm[344 + x[e++]]]); + break; + case 22: + w.push({}); + break; + case 28: + w.push(_1sxbm[344 + x[e++]]); + break; + case 34: + w[w.length - 4] = i.call(w[w.length - 4], w[w.length - 3], w[w.length - 2], w[w.length - 1]); + w.length -= 3; + break; + case 35: + _$ix = w[w.length - 1]; + break; + case 36: + w.push(_$iD); + break; + case 38: + w.pop(); + break; + case 40: + w.push(_$iT); + break; + case 53: + r = w.pop(); + w[w.length - 1] = w[w.length - 1] == r; + break; + case 54: + Nb = w[w.length - 1]; + break; + case 55: + w.push(_$iX); + break; + case 57: + w.push(_$OQ); + break; + case 59: + w.push(this); + break; + case 60: + return; + break; + case 62: + if (w[w.length - 1] != null) { + w[w.length - 2] = i.call(w[w.length - 2], w[w.length - 1]); + } else { + r = w[w.length - 2]; + w[w.length - 2] = r(); + } + w.length--; + break; + case 63: + _$iD = w[w.length - 1]; + break; + case 68: + w.push(Date); + break; + case 71: + r = w.pop(); + w[w.length - 1] -= r; + break; + case 78: + w.push(w[w.length - 1]); + w[w.length - 2] = w[w.length - 2][_1sxbm[344 + x[e++]]]; + break; + case 79: + w.push(Nb); + break; + case 80: + w.push(_$ir); + break; + case 81: + w.push(null); + break; + case 83: + w.push(iw); + break; + case 89: + _$iT = w[w.length - 1]; + break; + case 90: + w[w.length - 5] = i.call(w[w.length - 5], w[w.length - 4], w[w.length - 3], w[w.length - 2], w[w.length - 1]); + w.length -= 4; + break; + case 91: + if (w[w.length - 2] != null) { + w[w.length - 3] = i.call(w[w.length - 3], w[w.length - 2], w[w.length - 1]); + w.length -= 2; + } else { + r = w[w.length - 3]; + w[w.length - 3] = r(w[w.length - 1]); + w.length -= 2; + } + break; + case 92: + _$iX = w[w.length - 1]; + break; + case 98: + w.push(x[e++]); + break; + } + } + } + , + _$iQ.prototype.sign = function(_$ir) { + return _$WK.resolve(this.signSync(_$ir)); + } + , + _$iQ.prototype.signSync = function(_$ir) { + var NF = iw; + try { + return this._$sdnmd(_$ir); + } catch (_$ix) { + return this._onSign({ + 'code': _$ON, + 'message': NF(0x297) + }), + _$ir; + } + } + , + _$iQ.settings = { + 'beta': !(0x1b * -0x92 + -0x194b + 0x2 * 0x1459) + }, + window.ParamsSign = _$iQ, + _$iQ; +}(); diff --git a/backend/crawler_copy/jd_pc_search/common/jd_browser_env.js b/backend/crawler_copy/jd_pc_search/common/jd_browser_env.js new file mode 100644 index 0000000..8ccf198 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/common/jd_browser_env.js @@ -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 = {}; diff --git a/backend/crawler_copy/jd_pc_search/common/jd_delay_utils.py b/backend/crawler_copy/jd_pc_search/common/jd_delay_utils.py new file mode 100644 index 0000000..6116ad8 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/common/jd_delay_utils.py @@ -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) diff --git a/backend/crawler_copy/jd_pc_search/common/jd_https_fetch.js b/backend/crawler_copy/jd_pc_search/common/jd_https_fetch.js new file mode 100644 index 0000000..ec6be46 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/common/jd_https_fetch.js @@ -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 }; diff --git a/backend/crawler_copy/jd_pc_search/common/jd_search_common.js b/backend/crawler_copy/jd_pc_search/common/jd_search_common.js new file mode 100644 index 0000000..24f61b5 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/common/jd_search_common.js @@ -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, +}; diff --git a/backend/crawler_copy/jd_pc_search/detail/jd_detail_ware_business_requests.py b/backend/crawler_copy/jd_pc_search/detail/jd_detail_ware_business_requests.py new file mode 100644 index 0000000..c874cb6 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/detail/jd_detail_ware_business_requests.py @@ -0,0 +1,1435 @@ +# -*- coding: utf-8 -*- +""" +京东 PC 详情 ``pc_detailpage_wareBusiness``(appid=pc-item-soa)。 + +**唯一链路**:打开 ``item.jd.com/{sku}.html``,可选点击底部锚点栏「商品详情」 +(``#SPXQ-tab-column``,SPXQ=商品详情),再拦截页面发起的 ``api.m.jd.com`` 请求 +(不经 Node 拼 ``h5st``,与真实浏览器一致)。开关见 ``CLICK_PRODUCT_DETAIL_TAB``。 + +**定位 tab 相关 JSON**:切到「商品详情」后,左侧 ``left-tabs-content`` 里 ``#SPXQ-title`` 下方常见 **整块 HTML 注入**(参数表 ``_scoped_*``、SSD 图文 ``ssd-module-wrap``、``#detail-main`` / ``#related-layout-head`` 等), +随后 Network 里会多出大量 **图片/CSS**(如 ``img30.360buyimg.com``、``.avif`` / ``background-image``),这些才是图文详情本体,**不是**一条可替代的「详情 JSON」。 +页内脚本可能对 ``api.m.jd.com/client.action`` 发 ``mGetsByColor``(搭配 ``h5st``)等,用于模块内 **凑单/关联 SKU 价签**,与主图长页是两类数据。 +结构化字段(品牌、编号、类目等)仍以本脚本拦截的 ``pc_detailpage_wareBusiness`` 为主;若要对齐 DevTools,设 ``LOG_API_M_JD_TRACE=True`` 看各阶段 ``functionId`` 与 URL。 + +未命中接口、HTTP 非 200、正文空、无法解析 JSON 或解析后业务字段全空时,按 ``FETCH_MAX_ATTEMPTS`` / +``FETCH_RETRY_DELAY_SEC`` 自动重试(``fetch_ware_business`` 的 ``max_attempts`` / ``retry_delay_sec``)。 + +落盘 / 单 SKU 标准输出时,可对 JSON **缩进 + 递归键排序**(``NORMALIZE_WARE_JSON`` / ``SORT_JSON_KEYS``),便于阅读与 diff。 + +**输出路径(均在文件顶部用变量配置)**: +- 设 ``OUTPUT_SKU_AND_BODY_IMAGES_ONLY=True``(默认)时,**仅搜集** ``skuId``、``detail_body_ingredients``、``detail_body_ingredients_source_url``(视觉命中时的图源):不写 ware 原始 JSON;``OUT_PARSED`` / ``OUT_PARSED_DIR`` / ``OUT_PARSED_CSV`` 含上述列;批量时可不配 ``OUT_DIR``(配 ``OUT_PARSED_CSV`` 或 ``OUT_PARSED_DIR`` 即可)。 +- 若 ``OUTPUT_SKU_AND_BODY_IMAGES_ONLY=False``:**原始接口 JSON** 单 SKU ``OUT``、批量 ``OUT_DIR`` / ``ware_{sku}.json``;解析扁平含全部 ``detail_*``;汇总表 ``OUT_PARSED_CSV``。 + +**解析 API**:``flatten_ware_business`` / ``parse_ware_business_response_text`` / ``ware_parsed_row``, +列 ``detail_body_ingredients`` 为配料表文本(由 ``#detail-main`` 长图经 ``AI_crawler`` 自后向前多模态识别);列 ``detail_body_ingredients_source_url`` 为**实际用于识别**的那张长图 URL(命中即停)。未配置 API 或识别失败时配料列为原因说明、图源列为空。内部 ``meta["detail_body_image_urls"]`` 仍为全部长图 URL 串,仅供解析用。 + +Cookie:``../common/jd_cookie.txt``(或配置项 ``COOKIE_FILE`` / ``COOKIE_OVERRIDE``),经 ``add_cookies`` 注入。 + +依赖: pip install playwright && playwright install chromium(``USE_CHROME=True`` 时用本机 Chrome) + +用法: 改下方「运行配置」后执行 ``python jd_detail_ware_business_requests.py``(无命令行参数)。 +""" + +from __future__ import annotations + +import csv +import json +import re +import sys +import time +from pathlib import Path +from typing import Any, Callable +from urllib.parse import parse_qs, urlparse + +from playwright.sync_api import sync_playwright + +# --------------------------------------------------------------------------- +# 运行配置(按需改这里) +# --------------------------------------------------------------------------- +# 路径:副本通过 LOW_GI_PROJECT_ROOT 指向「Low GI」根目录(与搜索 / 流水线一致) +_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 + +_PROJECT_ROOT = low_gi_project_root() +_PROJECT_DATA = _PROJECT_ROOT / "data" / "JD" + +# SKU:单个商品 ID(item.jd.com/{SKU}.html);与 SKU_FILE 二选一,不可都填或都空 +SKU = "100307995056" +# SKU_FILE:每行一个 SKU,# 开头为注释;启用时须清空 SKU,并设置 OUT_DIR +SKU_FILE = "" +# OUT:单 SKU 时 **原始** 接口 JSON;OUTPUT_SKU_AND_BODY_IMAGES_ONLY=True 时不写入。空则 stdout 见下方 +OUT = "" +# OUT_DIR:批量写 ware_{sku}.json;极简模式下可不配(配合 OUT_PARSED_CSV / OUT_PARSED_DIR) +OUT_DIR = "" +# OUT_PARSED:单 SKU 时 **解析扁平** 结果;写入 JSON(含 skuId、http_status 与 WARE_BUSINESS_MERGE_FIELDNAMES); +# 空字符串表示不写解析文件(仅写 OUT 或仅打印) +OUT_PARSED = str(_PROJECT_DATA / "ware_out_parsed.json") +# OUT_PARSED_DIR:批量时目录,每个 SKU 写 ``ware_{sku}_parsed.json``;空表示批量也不落盘解析结果 +# 常用:str(_PROJECT_DATA / "ware_parsed") +OUT_PARSED_DIR = "" +# OUT_PARSED_CSV:解析结果汇总表(UTF-8 BOM);每次运行结束时写入,行数=本次请求的 SKU 数 +# (单 SKU、批量均适用);空表示不写。常用:str(_PROJECT_DATA / "ware_detail_summary.csv") +OUT_PARSED_CSV = str(_PROJECT_DATA / "ware_detail_summary.csv") +# COOKIE_FILE:Cookie 文本路径;空则使用 jd_pc_search/common/jd_cookie.txt +COOKIE_FILE = "" +# COOKIE_OVERRIDE:非空时覆盖文件中的整段 Cookie 请求头 +COOKIE_OVERRIDE = "" +# TIMEOUT_SEC:打开商品页与等待拦截的总超时(秒) +TIMEOUT_SEC = 45.0 +# GOTO_WAIT_UNTIL:``page.goto`` 的 wait_until。京东详情页常因长连接/埋点迟迟不触发 ``load``,默认用 ``domcontentloaded``;若需严格等全部资源可改 ``load``。 +GOTO_WAIT_UNTIL = "domcontentloaded" +# FETCH_MAX_ATTEMPTS:未捕获接口、非 200、正文空或解析后无有效字段时最多重试次数 +FETCH_MAX_ATTEMPTS = 3 +# FETCH_RETRY_DELAY_SEC:相邻两次尝试之间的休眠(秒) +FETCH_RETRY_DELAY_SEC = 2.0 +# CLICK_PRODUCT_DETAIL_TAB:True 时在进入商品页后点击「商品详情」tab(#SPXQ-tab-column),便于滚到详情区并触发与 tab 相关的请求 +CLICK_PRODUCT_DETAIL_TAB = True +# LOG_API_M_JD_TRACE:True 时在 stderr 列出本次打开商品页过程中每条 api.m.jd.com 响应(阶段+functionId+URL),用于对照 DevTools 找 tab 对应接口 +LOG_API_M_JD_TRACE = False +# COLLECT_DETAIL_MAIN_IMAGE_URLS:True 时在点击商品详情 tab 并等待后,从 #detail-main 抽取图文 URL(style 中 background-image、img src、zbViewWeChatMiniImages), +# 补全为 https 后写入 meta(见 DETAIL_BODY_IMAGE_URL_SEPARATOR);再经多模态写入列 detail_body_ingredients(配料文本)与 detail_body_ingredients_source_url(命中图源) +COLLECT_DETAIL_MAIN_IMAGE_URLS = True +# EXTRACT_INGREDIENTS_FROM_DETAIL_BODY_IMAGES:True 时 detail_body_ingredients 为配料表文本、detail_body_ingredients_source_url 为命中图源;False 时配料列恒为空、图源列恒为空。需 .env 中 OPENAI_* / LLM_*(见上级目录 AI_crawler.py) +EXTRACT_INGREDIENTS_FROM_DETAIL_BODY_IMAGES = True +# DETAIL_BODY_IMAGE_URL_SEPARATOR:写入 CSV/JSON 单单元格时的分隔符 +DETAIL_BODY_IMAGE_URL_SEPARATOR = "; " +# DETAIL_BODY_IMAGE_URLS_MAX_CHARS:单单元格最大字符数(兼顾 Excel 单元格上限) +DETAIL_BODY_IMAGE_URLS_MAX_CHARS = 31000 +# OUTPUT_SKU_AND_BODY_IMAGES_ONLY:True 时落盘/stdout 为 skuId + detail_body_ingredients + detail_body_ingredients_source_url(不写 ware 原始 JSON;CSV 三列);仍为抓 wareBusiness 打开页面,若已抽到图文 URL 则不再因接口扁平为空而重试 +OUTPUT_SKU_AND_BODY_IMAGES_ONLY = True +# HEADED:True 显示浏览器窗口(调页面/登录态) +HEADED = False +# USE_CHROME:True 使用本机已安装的 Google Chrome(channel=chrome),否则用内置 Chromium +USE_CHROME = True +# DEBUG_PAUSE:True 时请求结束后终端按回车再关浏览器 +DEBUG_PAUSE = False +# PRETTY_STDOUT:单 SKU 且无 OUT、且 NORMALIZE_WARE_JSON=False 时,是否在终端缩进打印(不排序键) +PRETTY_STDOUT = True +# VERBOSE_HTTP:True 在 stderr 打印本次拦截的 URL/状态/响应体摘要(Cookie 会截断) +VERBOSE_HTTP = False +# HTTP_LOG:非空路径则写入完整 request/response JSON(多 SKU 时为数组);对照 Network 用 +HTTP_LOG = "" # 例:str(_PROJECT_DATA / "ware_http_log.json") +# VERBOSE_HTTP_BODY_LIMIT:与 VERBOSE_HTTP 合用时,stderr 中响应体最多字符数 +VERBOSE_HTTP_BODY_LIMIT = 8000 +# NORMALIZE_WARE_JSON:True 时对成功解析的 JSON 规整后写出(缩进 + 可选键排序),失败则仍写原文 +NORMALIZE_WARE_JSON = True +# SORT_JSON_KEYS:True 时递归按字典键名排序,便于 diff 与浏览;False 保留接口原始字段顺序 +SORT_JSON_KEYS = True +# JSON_INDENT:规整时的缩进空格数;0 表示紧凑单行(仍可能已排序) +JSON_INDENT = 2 +# --------------------------------------------------------------------------- + +_JD_DIR = Path(__file__).resolve().parent +_JD_PC_SEARCH_DIR = _JD_DIR.parent +if str(_JD_PC_SEARCH_DIR) not in sys.path: + sys.path.insert(0, str(_JD_PC_SEARCH_DIR)) +_DEFAULT_COOKIE_PATH = (_JD_DIR.parent / "common" / "jd_cookie.txt").resolve() + +_JD_DETAIL_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" +) +_JD_DETAIL_CONTEXT_EXTRA_HEADERS: dict[str, str] = { + "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"', +} + +# PC 详情页底部锚点:商品详情(与页面 id 一致,改版时需对照 DOM) +_JD_TAB_PRODUCT_DETAIL_SELECTOR = "#SPXQ-tab-column" + + +def _jd_function_id_from_api_url(url: str) -> str: + """从 ``api.m.jd.com?...`` 的 query 取 ``functionId``;无则空串。""" + try: + q = parse_qs(urlparse(url).query) + v = q.get("functionId") or q.get("functionid") + if not v: + return "" + return str(v[0]).strip() + except Exception: + return "" + + +def _print_api_m_jd_trace(rows: list[dict[str, Any]], *, sku_id: str) -> None: + if not rows: + print( + f"[京东] api.m.jd.com 轨迹 sku={sku_id}:未观察到该域名任何响应(Cookie/拦截或接口已迁域)。", + file=sys.stderr, + ) + return + print( + f"[京东] api.m.jd.com 轨迹 sku={sku_id} 共 {len(rows)} 条(按时间顺序;" + "after_tab 内无新行则点击 tab 未触发该域名 XHR)", + file=sys.stderr, + ) + for i, r in enumerate(rows, 1): + fid = r.get("functionId") or "(无 query functionId)" + print( + f" {i}. [{r.get('phase')}] HTTP {r.get('status')} {fid}", + file=sys.stderr, + ) + u = (r.get("url") or "")[:900] + print(f" {u}", file=sys.stderr) + print( + "[京东] 提示:若仍对不上 DevTools,请看 POST 请求的 form/body 里的 functionId;" + "图文详情也可能在首屏 HTML、iframe 或非 api.m.jd.com 的静态资源。", + file=sys.stderr, + ) + + +def _click_jd_product_detail_tab(page: Any, *, timeout_ms: int) -> None: + """点击「商品详情」锚点 tab;失败仅打日志,不中断(部分模板无此节点)。""" + cap = max(2_000, min(12_000, int(timeout_ms))) + try: + loc = page.locator(_JD_TAB_PRODUCT_DETAIL_SELECTOR) + loc.wait_for(state="visible", timeout=cap) + loc.click(timeout=cap) + except Exception as e: + print( + f"[京东] 未点击商品详情 tab({_JD_TAB_PRODUCT_DETAIL_SELECTOR}): {e}", + file=sys.stderr, + ) + + +# #detail-main 内 style 块中的 background-image:url(...) +_CSS_BG_URL_RE = re.compile(r"url\s*\(\s*([^)]+)\s*\)", re.I) +# zbViewWeChatMiniImages 的 value="a.jpg,b.jpg,..." +_ZB_MINI_VALUE_RE = re.compile( + r"zbViewWeChatMiniImages[^>]*\bvalue\s*=\s*[\"']([^\"']+)[\"']", + re.I | re.DOTALL, +) + + +def _normalize_jd_detail_asset_url(raw: str) -> str: + """将 //、/sku/jfs、/cms/jfs 等补全为可访问的 https URL。""" + s = (raw or "").strip() + if len(s) >= 2 and s[0] in "\"'" and s[-1] == s[0]: + s = s[1:-1].strip() + if not s or s.lower().startswith("data:"): + return "" + if s.startswith("//"): + return ("https:" + s)[:900] + if s.startswith("http://"): + return ("https://" + s[7:])[:900] + if s.startswith("https://"): + return s[:900] + if s.startswith("/sku/jfs/"): + return ("https://img30.360buyimg.com" + s)[:900] + if s.startswith("/cms/jfs/"): + return ("https://img12.360buyimg.com" + s)[:900] + if s.startswith("/jfs/"): + return ("https://img30.360buyimg.com/sku/jfs" + s[4:])[:900] + if s.startswith("jfs/"): + return ("https://img30.360buyimg.com/sku/" + s)[:900] + return s[:900] + + +def _dedupe_urls_preserve_order(urls: list[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for u in urls: + u = (u or "").strip() + if not u or u in seen: + continue + seen.add(u) + out.append(u) + return out + + +def _urls_from_detail_main_inner_html(html: str) -> list[str]: + raw: list[str] = [] + if not (html or "").strip(): + return raw + for m in _CSS_BG_URL_RE.finditer(html): + inner = (m.group(1) or "").strip().strip("\"'") + if inner: + raw.append(inner) + zm = _ZB_MINI_VALUE_RE.search(html) + if zm: + for part in (zm.group(1) or "").split(","): + p = part.strip() + if p: + raw.append(p) + for m in re.finditer( + r"""]*\bsrc\s*=\s*(['"])(?P.*?)\1""", + html, + re.I | re.DOTALL, + ): + u = (m.group("u") or "").strip() + if u: + raw.append(u) + for m in re.finditer(r"""]*\bsrc\s*=\s*([^\s>'"]+)""", html, re.I): + u = (m.group(1) or "").strip() + if u: + raw.append(u) + return raw + + +def scrape_detail_main_body_urls_joined( + page: Any, + *, + wait_ms: int = 8_000, + separator: str | None = None, + max_chars: int | None = None, +) -> str: + """ + 从当前页 ``#detail-main`` 收集图文资源 URL(SSD 背景图、img、zbViewWeChatMiniImages), + 补全为 https,去重保序后用 ``separator`` 拼成一段字符串(供 CSV 单格)。 + 页面须已展示商品详情区(通常需先点 ``#SPXQ-tab-column``)。 + """ + if not COLLECT_DETAIL_MAIN_IMAGE_URLS: + return "" + sep = ( + separator + if separator is not None + else (DETAIL_BODY_IMAGE_URL_SEPARATOR or "; ") + ) + cap = max(2000, min(15_000, int(wait_ms))) + loc = page.locator("#detail-main") + try: + loc.wait_for(state="attached", timeout=cap) + except Exception: + return "" + try: + html = loc.inner_html(timeout=min(5_000, cap)) + except Exception: + html = "" + dom_srcs: list[str] = [] + try: + dom_srcs = page.evaluate( + """() => { + const r = document.querySelector('#detail-main'); + if (!r) return []; + return Array.from(r.querySelectorAll('img')) + .map(i => (i.currentSrc || i.src || '').trim()) + .filter(Boolean); + }""" + ) + except Exception: + dom_srcs = [] + if not isinstance(dom_srcs, list): + dom_srcs = [] + + candidates = _urls_from_detail_main_inner_html(html) + [str(x) for x in dom_srcs] + normalized: list[str] = [] + for c in candidates: + n = _normalize_jd_detail_asset_url(c) + if not n: + continue + low = n.lower() + if "sku-market-gw.jd.com" in low and low.endswith(".css"): + continue + if "list.jd.com" in low or "item.jd.com" in low or "mall.jd.com" in low: + if not any( + low.endswith(ext) + for ext in (".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif", ".dpg") + ): + continue + normalized.append(n) + + uniq = _dedupe_urls_preserve_order(normalized) + joined = sep.join(uniq) + mxc = ( + int(max_chars) + if max_chars is not None + else int(DETAIL_BODY_IMAGE_URLS_MAX_CHARS) + ) + if mxc > 0 and len(joined) > mxc: + joined = joined[: mxc - 3] + "..." + return joined + + +def _normalize_ware_json_tree(obj: Any, *, sort_keys: bool) -> Any: + """递归规整:字典可选按键名排序,列表保持元素顺序。""" + if isinstance(obj, dict): + pairs = [ + (k, _normalize_ware_json_tree(v, sort_keys=sort_keys)) + for k, v in obj.items() + ] + if sort_keys: + pairs.sort(key=lambda kv: kv[0]) + return dict(pairs) + if isinstance(obj, list): + return [_normalize_ware_json_tree(x, sort_keys=sort_keys) for x in obj] + return obj + + +def _format_ware_response_text( + text: str, + *, + normalize: bool, + sort_keys: bool, + indent: int, +) -> tuple[str, bool]: + """ + 尝试将接口 body 规整为可读 JSON。 + 返回 (输出文本, 是否已成功按 JSON 处理)。 + """ + if not normalize or not (text or "").strip(): + return text, False + try: + obj = json.loads(text) + except json.JSONDecodeError: + return text, False + obj = _normalize_ware_json_tree(obj, sort_keys=sort_keys) + if indent > 0: + out = json.dumps(obj, ensure_ascii=False, indent=indent) + "\n" + else: + out = json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + "\n" + return out, True + + +# 与 pc_detailpage_wareBusiness 响应对应的扁平字段(字符串,缺失为空),顺序即 CSV 建议列序 +WARE_BUSINESS_MERGE_FIELDNAMES: tuple[str, ...] = ( + "detail_sku_title", + "detail_price_final", + "detail_price_original", + "detail_purchase_price", + "detail_shop_name", + "detail_shop_id", + "detail_shop_url", + "detail_vender_id", + "detail_stock_text", + "detail_delivery_promise", + "detail_sku_name", + "detail_product_id", + "detail_main_sku_id", + "detail_page_sku_id", + "detail_brand", + "detail_category_path", + "detail_main_image", + "detail_product_attributes", + "detail_belt_banner", + "detail_csfh_text", + # 来自 DOM #detail-main(非 wareBusiness JSON);列语义为「详情长图衍生信息」,常为 URL 串,流水线可替换为配料表文本 + "detail_body_ingredients", + # 视觉识别命中时:实际用于解析配料的那张详情长图 URL(自后向前首次通过校验) + "detail_body_ingredients_source_url", +) + + +def _empty_ware_flat() -> dict[str, str]: + return {k: "" for k in WARE_BUSINESS_MERGE_FIELDNAMES} + + +def _strip_htmlish(s: str, *, max_len: int) -> str: + if not s: + return "" + t = re.sub(r"<[^>]+>", " ", s) + t = " ".join(t.split()).strip() + return t[:max_len] if max_len > 0 else t + + +def _s(obj: Any) -> str: + if obj is None: + return "" + return str(obj).strip() + + +def _join_url(u: str) -> str: + u = u.strip() + if not u: + return "" + if u.startswith("//"): + return "https:" + u + return u + + +def _jd_product_image_url(path: str) -> str: + """与搜索侧一致:jfs/ 相对路径补全为可访问 URL。""" + p = (path or "").strip() + if not p: + return "" + if p.startswith("//"): + return "https:" + p + if p.startswith("http://"): + return "https://" + p[7:] + if p.startswith("https://"): + return p[:800] + if p.startswith("jfs/"): + return "https://img13.360buyimg.com/n2/s480x480_" + p[:700] + return p[:800] + + +def flatten_ware_business(obj: Any) -> dict[str, str]: + """ + 将 ``pc_detailpage_wareBusiness`` 的 JSON 根对象压成扁平字符串字典。 + 非 dict 或字段缺失时对应值为空串。 + """ + out = _empty_ware_flat() + if not isinstance(obj, dict): + return out + + sh = obj.get("skuHeadVO") + sh = sh if isinstance(sh, dict) else {} + out["detail_sku_title"] = _s(sh.get("skuTitle"))[:2000] + + price = obj.get("price") + price = price if isinstance(price, dict) else {} + fp = price.get("finalPrice") + fp = fp if isinstance(fp, dict) else {} + out["detail_price_final"] = _s(fp.get("price")) or _s(price.get("p"))[:64] + out["detail_price_original"] = _s(price.get("op")) or _s(price.get("p"))[:64] + + bp = obj.get("bestPromotion") + bp = bp if isinstance(bp, dict) else {} + out["detail_purchase_price"] = _s(bp.get("purchasePrice"))[:64] + + ishop = obj.get("itemShopInfo") + ishop = ishop if isinstance(ishop, dict) else {} + out["detail_shop_name"] = _s(ishop.get("shopName"))[:500] + out["detail_shop_id"] = _s(ishop.get("shopId"))[:32] + out["detail_shop_url"] = _join_url(_s(ishop.get("shopUrl")))[:500] + + pc = obj.get("pageConfigVO") + pc = pc if isinstance(pc, dict) else {} + out["detail_vender_id"] = _s(pc.get("venderId"))[:32] + sk = pc.get("skuid") + out["detail_page_sku_id"] = _s(sk)[:32] + cats = pc.get("catName") + if isinstance(cats, list): + parts = [_s(x) for x in cats if _s(x)] + out["detail_category_path"] = " > ".join(parts)[:500] + src = _s(pc.get("src")) + if src: + out["detail_main_image"] = _jd_product_image_url(src) + + mi = obj.get("mainImageVO") + if isinstance(mi, dict) and not out["detail_main_image"]: + mia = mi.get("mainImageArea") + if isinstance(mia, dict): + iu = _s(mia.get("imageUrl")) + if iu: + out["detail_main_image"] = _jd_product_image_url(iu) + + si = obj.get("stockInfo") + si = si if isinstance(si, dict) else {} + out["detail_stock_text"] = _strip_htmlish(_s(si.get("stockDesc")), max_len=500) + if not out["detail_stock_text"]: + out["detail_stock_text"] = _strip_htmlish(_s(si.get("promiseResult")), max_len=500) + out["detail_delivery_promise"] = _strip_htmlish( + _s(si.get("promiseResult") or si.get("promiseInfoText")), max_len=800 + ) + + wim = obj.get("wareInfoReadMap") + wim = wim if isinstance(wim, dict) else {} + out["detail_sku_name"] = _s(wim.get("sku_name"))[:2000] + out["detail_product_id"] = _s(wim.get("product_id"))[:32] + out["detail_main_sku_id"] = _s(wim.get("main_sku_id"))[:32] + out["detail_brand"] = _s(wim.get("cn_brand"))[:200] + if not out["detail_brand"]: + pav = obj.get("productAttributeVO") + if isinstance(pav, dict): + for it in pav.get("attributes") or []: + if not isinstance(it, dict): + continue + if _s(it.get("labelName")) == "品牌": + out["detail_brand"] = _s(it.get("labelValue"))[:200] + break + + pav = obj.get("productAttributeVO") + attrs: list[str] = [] + if isinstance(pav, dict): + for it in pav.get("attributes") or []: + if not isinstance(it, dict): + continue + ln, lv = _s(it.get("labelName")), _s(it.get("labelValue")) + if ln and lv: + attrs.append(f"{ln}:{lv}") + out["detail_product_attributes"] = "; ".join(attrs)[:4000] + + out["detail_belt_banner"] = _join_url(_s(obj.get("beltBanner")))[:800] + out["detail_csfh_text"] = _s(obj.get("csfhText"))[:200] + + return out + + +# 与 OUT_PARSED_CSV / 流水线 detail CSV 列一致 +WARE_PARSED_CSV_FIELDNAMES: tuple[str, ...] = ( + "skuId", + "http_status", + *WARE_BUSINESS_MERGE_FIELDNAMES, +) + +# 仅 sku + 配料(本脚本 OUTPUT_SKU_AND_BODY_IMAGES_ONLY 时 main 写 CSV/解析 JSON 用) +SKU_BODY_IMAGES_ONLY_FIELDNAMES: tuple[str, ...] = ( + "skuId", + "detail_body_ingredients", +) + +# 与合并表 lean 商详块一致 + skuId;keyword_pipeline DETAIL_WARE_CSV_MODE=lean 写 detail_ware_export.csv +DETAIL_WARE_LEAN_CSV_FIELDNAMES: tuple[str, ...] = ( + "skuId", + "detail_brand", + "detail_price_final", + "detail_shop_name", + "detail_category_path", + "detail_product_attributes", + "detail_body_ingredients", +) + + +def detail_ware_lean_csv_row( + sku: str, + http_status: int, + response_text: str, + *, + detail_body_ingredients: str = "", + detail_body_ingredients_source_url: str = "", +) -> dict[str, str]: + """lean 详情汇总表一行(无 http_status);字段来自 ``ware_parsed_row`` 子集。""" + full = ware_parsed_row( + sku, + http_status, + response_text, + detail_body_ingredients=detail_body_ingredients, + detail_body_ingredients_source_url=detail_body_ingredients_source_url, + ) + return {k: str(full.get(k) or "") for k in DETAIL_WARE_LEAN_CSV_FIELDNAMES} + + +def minimal_sku_body_images_row( + sku: str, + detail_body_ingredients: str, + *, + detail_body_ingredients_source_url: str = "", +) -> dict[str, str]: + """``skuId``、配料文本(图源仅内部流程使用,不写入极简 CSV)。""" + _ = detail_body_ingredients_source_url # 保留参数供调用方兼容 + u = (detail_body_ingredients or "").strip() + mxc = max(0, int(DETAIL_BODY_IMAGE_URLS_MAX_CHARS)) + if mxc and len(u) > mxc: + u = u[:mxc] + return { + "skuId": str(sku).strip(), + "detail_body_ingredients": u, + } + + +def _write_minimal_body_images_json( + path: Path, + sku: str, + detail_body_ingredients: str, + *, + detail_body_ingredients_source_url: str = "", +) -> None: + row = minimal_sku_body_images_row( + sku, + detail_body_ingredients, + detail_body_ingredients_source_url=detail_body_ingredients_source_url, + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(row, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"[京东] 已写图文 URL JSON:{path}", file=sys.stderr) + + +def parse_ware_business_response_text(text: str) -> tuple[dict[str, str], bool]: + """ + 解析响应体字符串。 + 返回 ``(扁平字典, 是否成功解析为 JSON 对象)``;失败时扁平字典各键均为 ``""``。 + """ + raw = (text or "").strip() + if not raw: + return _empty_ware_flat(), False + try: + obj = json.loads(raw) + except json.JSONDecodeError: + return _empty_ware_flat(), False + if not isinstance(obj, dict): + return _empty_ware_flat(), False + return flatten_ware_business(obj), True + + +def ware_parsed_row( + sku: str, + http_status: int, + response_text: str, + *, + detail_body_ingredients: str = "", + detail_body_ingredients_source_url: str = "", +) -> dict[str, str]: + """单行扁平结果:``skuId``、``http_status`` 与 ``WARE_BUSINESS_MERGE_FIELDNAMES``(含 DOM 长图 URL 或配料文本)。""" + flat, _ = parse_ware_business_response_text( + response_text if http_status == 200 else "" + ) + row: dict[str, str] = { + "skuId": str(sku).strip(), + "http_status": str(http_status), + **flat, + } + u = (detail_body_ingredients or "").strip() + if u: + mxc = max(0, int(DETAIL_BODY_IMAGE_URLS_MAX_CHARS)) + row["detail_body_ingredients"] = u[:mxc] if mxc else u + src = (detail_body_ingredients_source_url or "").strip() + if src: + mxc = max(0, int(DETAIL_BODY_IMAGE_URLS_MAX_CHARS)) + row["detail_body_ingredients_source_url"] = src[:mxc] if mxc else src + return row + + +def ware_fetch_should_retry(http_status: int, response_text: str) -> bool: + """ + True 表示应重试:未命中接口、HTTP 非 200、正文空、非 JSON、或解析后业务字段全空。 + """ + if int(http_status) != 200: + return True + raw = (response_text or "").strip() + if not raw: + return True + flat, ok = parse_ware_business_response_text(raw) + if not ok: + return True + return not any((v or "").strip() for v in flat.values()) + + +def format_ware_response_for_save( + text: str, + *, + normalize: bool = True, + sort_keys: bool = True, + indent: int = 2, +) -> str: + """流水线等落盘用:尽量输出缩进 + 可选键排序的 JSON 文本(失败则保留原文)。""" + body, _ok = _format_ware_response_text( + text or "", + normalize=normalize, + sort_keys=sort_keys, + indent=max(0, int(indent)), + ) + return body + + +def _write_ware_parsed_json( + path: Path, + sku: str, + http_status: int, + response_text: str, + *, + detail_body_ingredients: str = "", + detail_body_ingredients_source_url: str = "", +) -> None: + row = ware_parsed_row( + sku, + http_status, + response_text, + detail_body_ingredients=detail_body_ingredients, + detail_body_ingredients_source_url=detail_body_ingredients_source_url, + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(row, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"[京东] 已写解析 JSON:{path}", file=sys.stderr) + + +def _read_jd_cookie_file_raw(cookie_file: str | None) -> str: + """多行合并为 ``; `` 分隔(与 Node ``readCookieFile`` 一致)。""" + path = Path(cookie_file) if (cookie_file or "").strip() else _DEFAULT_COOKIE_PATH + path = path.resolve() + if not path.is_file(): + return "" + chunks: list[str] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + t = line.strip() + if t and not t.startswith("#"): + chunks.append(t) + return "; ".join(chunks).strip() + + +def _cookie_header_to_playwright(cookie_header: str) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for part in cookie_header.split(";"): + part = part.strip() + if not part or "=" not in part: + continue + name, _, value = part.partition("=") + name, value = name.strip(), value.strip() + if not name: + continue + rows.append( + { + "name": name, + "value": value, + "domain": ".jd.com", + "path": "/", + "secure": True, + } + ) + return rows + + +def _headers_for_verbose(h: dict[str, str], *, cookie_preview: int = 96) -> dict[str, str]: + out: dict[str, str] = {} + for k, v in h.items(): + if k.lower() == "cookie" and len(v) > cookie_preview: + out[k] = ( + v[:cookie_preview] + + f"...(共 {len(v)} 字符,完整值请用 --http-log)" + ) + else: + out[k] = v + return out + + +def _print_http_verbose(meta: dict[str, Any], *, body_max: int) -> None: + req = meta["request"] + res = meta["response"] + body = res.get("body") or "" + if len(body) > body_max: + body_show = ( + body[:body_max] + + f"\n...(stderr 已截断,响应共 {len(body)} 字符;完整见 --http-log)" + ) + else: + body_show = body + req_block: dict[str, Any] = {"method": req.get("method", "GET")} + if req.get("url"): + req_block["url"] = req["url"] + for k in ("via", "item_page", "referrer_document", "note"): + if k in req: + req_block[k] = req[k] + hdrs = req.get("headers") + if hdrs: + req_block["headers"] = _headers_for_verbose(hdrs) + block = { + "skuId": meta.get("skuId"), + "request": req_block, + "response": { + "url": res.get("url"), + "status": res.get("status"), + "status_text": res.get("status_text"), + "headers": res.get("headers"), + "body": body_show, + }, + } + sys.stderr.write( + "[京东] HTTP 详情(stderr):\n" + + json.dumps(block, ensure_ascii=False, indent=2) + + "\n" + ) + + +def _fetch_ware_business_once( + context: Any, + page: Any, + sku_id: str, + *, + cookie_file: str | None = None, + timeout_ms: int = 30_000, + cookie_override: str = "", +) -> tuple[int, str, dict[str, Any]]: + """单次打开商品页并拦截 ``pc_detailpage_wareBusiness`` 响应(无重试)。""" + raw_cookie = (cookie_override or "").strip() or _read_jd_cookie_file_raw(cookie_file) + if not raw_cookie: + print( + "[京东] 需要 Cookie:--cookie 或 jd_cookie.txt(--cookie-file)", + file=sys.stderr, + ) + sys.exit(2) + context.clear_cookies() + try: + context.add_cookies(_cookie_header_to_playwright(raw_cookie)) + except Exception as e: + print(f"[京东] add_cookies 警告: {e}", file=sys.stderr) + item_url = f"https://item.jd.com/{str(sku_id).strip()}.html" + matches: list[tuple[int, str, str, dict[str, str]]] = [] + trace_rows: list[dict[str, Any]] = [] + trace_phase = "opening" + + def _on_response(response: Any) -> None: + try: + u = response.url + if LOG_API_M_JD_TRACE and "api.m.jd.com" in u: + ct = "" + try: + ct = (response.headers.get("content-type") or "").strip() + except Exception: + pass + trace_rows.append( + { + "phase": trace_phase, + "status": response.status, + "functionId": _jd_function_id_from_api_url(u), + "content_type": ct[:160], + "url": u[:2000], + } + ) + if ( + "api.m.jd.com" in u + and "pc_detailpage_wareBusiness" in u + and "functionId=" in u + ): + st = response.status + body = response.text() + matches.append((st, body, u, dict(response.headers))) + except Exception: + pass + + detail_joined = "" + page.on("response", _on_response) + try: + _wu = (GOTO_WAIT_UNTIL or "domcontentloaded").strip() + page.goto(item_url, wait_until=_wu, timeout=timeout_ms) + trace_phase = "loaded" + if CLICK_PRODUCT_DETAIL_TAB: + _click_jd_product_detail_tab(page, timeout_ms=timeout_ms) + trace_phase = "after_tab" + extra = min(12_000, max(3_000, timeout_ms // 2)) + page.wait_for_timeout(extra) + if COLLECT_DETAIL_MAIN_IMAGE_URLS: + try: + detail_joined = scrape_detail_main_body_urls_joined( + page, + wait_ms=min(timeout_ms, 12_000), + ) + except Exception as e: + print( + f"[京东] 抽取 #detail-main 图文 URL 失败: {e}", + file=sys.stderr, + ) + finally: + try: + page.remove_listener("response", _on_response) + except Exception: + pass + if LOG_API_M_JD_TRACE: + _print_api_m_jd_trace(trace_rows, sku_id=str(sku_id).strip()) + if not matches: + meta: dict[str, Any] = { + "skuId": str(sku_id).strip(), + "request": { + "method": "GET", + "via": "capture_page_navigation", + "item_page": item_url, + "note": "未捕获到 pc_detailpage_wareBusiness(页面可能改版或 Cookie 未登录)", + }, + "response": {"status": 0, "status_text": "", "headers": {}, "body": ""}, + } + if LOG_API_M_JD_TRACE: + meta["api_m_jd_trace"] = trace_rows + meta["detail_body_image_urls"] = detail_joined + return 0, "", meta + ok_rows = [m for m in matches if m[0] == 200] + st, body, u, rh = ok_rows[-1] if ok_rows else matches[-1] + meta = { + "skuId": str(sku_id).strip(), + "request": { + "method": "GET", + "url": u, + "via": "capture_page_navigation", + "item_page": item_url, + "captures_count": len(matches), + }, + "response": { + "url": u, + "status": st, + "status_text": "", + "headers": rh, + "body": body, + }, + } + if LOG_API_M_JD_TRACE: + meta["api_m_jd_trace"] = trace_rows + meta["detail_body_image_urls"] = detail_joined + return st, body, meta + + +def fetch_ware_business( + context: Any, + page: Any, + sku_id: str, + *, + cookie_file: str | None = None, + timeout_ms: int = 30_000, + cookie_override: str = "", + max_attempts: int = 1, + retry_delay_sec: float = 2.0, + cancel_check: Callable[[], bool] | None = None, +) -> tuple[int, str, dict[str, Any]]: + """ + 打开商品页并拦截 ``pc_detailpage_wareBusiness``。 + ``max_attempts``>1 时,在结果为空或失败时按 ``retry_delay_sec`` 间隔重试。 + """ + sid = str(sku_id).strip() + n = max(1, int(max_attempts)) + last: tuple[int, str, dict[str, Any]] = (0, "", {}) + for i in range(n): + if cancel_check is not None and cancel_check(): + return last + if i > 0: + delay = max(0.0, float(retry_delay_sec)) + if delay > 0: + time.sleep(delay) + if cancel_check is not None and cancel_check(): + return last + code, text, meta = _fetch_ware_business_once( + context, + page, + sid, + cookie_file=cookie_file, + timeout_ms=timeout_ms, + cookie_override=cookie_override, + ) + last = (code, text, meta) + if OUTPUT_SKU_AND_BODY_IMAGES_ONLY and ( + meta.get("detail_body_image_urls") or "" + ).strip(): + if i > 0: + print( + f"[京东] sku={sid} 第 {i + 1} 次尝试已成功(已抽到 #detail-main 图文 URL)", + file=sys.stderr, + ) + break + if not ware_fetch_should_retry(code, text): + if i > 0: + print( + f"[京东] sku={sid} 第 {i + 1} 次尝试已成功", + file=sys.stderr, + ) + break + print( + f"[京东] sku={sid} 详情结果为空或无效 (HTTP {code})," + f"重试 {i + 1}/{n}…", + file=sys.stderr, + ) + return last + + +def _detail_body_ingredients_column_value( + urls_joined: str, + *, + vision_mod: Any, + vision_ok: bool, +) -> tuple[str, str]: + """ + 返回 ``(detail_body_ingredients, detail_body_ingredients_source_url)``。 + 配料列为文本;失败时为 ``【未识别到配料】…``。图源列仅在视觉成功命中配料时非空。 + """ + if not EXTRACT_INGREDIENTS_FROM_DETAIL_BODY_IMAGES: + return "", "" + if not vision_ok or vision_mod is None: + return ( + "【未识别到配料】未配置或无效的多模态 API(见 market_assistant/.env 中 OPENAI_* / LLM_*)。", + "", + ) + raw = (urls_joined or "").strip() + try: + fn = getattr( + vision_mod, + "extract_ingredients_from_body_image_urls_reversed_with_source", + None, + ) + if callable(fn): + text, src_url = fn(raw) + return str(text or "").strip(), (str(src_url).strip() if src_url else "") + text = str( + vision_mod.extract_ingredients_from_body_image_urls_reversed(raw) + ).strip() + return text, "" + except Exception as e: + print(f"[京东] 配料视觉提取异常: {e}", file=sys.stderr) + return f"【未识别到配料】识别异常:{e}"[:800], "" + + +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 + + sku = (SKU or "").strip() + sku_file = (SKU_FILE or "").strip() + out_path = (OUT or "").strip() + out_dir = (OUT_DIR or "").strip() + if bool(sku) == bool(sku_file): + print("请在文件顶部配置 SKU 或 SKU_FILE(二选一)", file=sys.stderr) + sys.exit(2) + if sku_file and out_path: + print("使用 SKU_FILE 时不要配置 OUT,请用 OUT_DIR", file=sys.stderr) + sys.exit(2) + + out_parsed = (OUT_PARSED or "").strip() + out_parsed_dir = (OUT_PARSED_DIR or "").strip() + out_parsed_csv = (OUT_PARSED_CSV or "").strip() + output_minimal = bool(OUTPUT_SKU_AND_BODY_IMAGES_ONLY) + if sku_file and not out_dir: + if not ( + output_minimal + and (out_parsed_csv or out_parsed_dir) + ): + print("使用 SKU_FILE 时请配置 OUT_DIR", file=sys.stderr) + sys.exit(2) + if sku_file and out_parsed and not out_parsed_dir: + print( + "[京东] 批量模式请配置 OUT_PARSED_DIR(每 SKU 写 ware_{sku}_parsed.json);" + "已忽略 OUT_PARSED", + file=sys.stderr, + ) + out_parsed = "" + + cookie_file_cli = (COOKIE_FILE or "").strip() + if cookie_file_cli: + cookie_file_cli = str(Path(cookie_file_cli).resolve()) + cf = cookie_file_cli or None + cookie_override = (COOKIE_OVERRIDE or "").strip() + timeout_ms = max(1000, int(float(TIMEOUT_SEC) * 1000)) + + skus = [sku] if sku else [] + if sku_file: + raw = Path(sku_file).read_text(encoding="utf-8") + for line in raw.splitlines(): + t = line.strip() + if not t or t.startswith("#"): + continue + skus.append(t) + if not skus: + print("无有效 SKU", file=sys.stderr) + sys.exit(2) + + http_log_path = (HTTP_LOG or "").strip() + http_records: list[dict[str, Any]] = [] + pretty = bool(PRETTY_STDOUT) + verbose_http = bool(VERBOSE_HTTP) + verbose_body_lim = int(VERBOSE_HTTP_BODY_LIMIT) + normalize_json = bool(NORMALIZE_WARE_JSON) + sort_keys = bool(SORT_JSON_KEYS) + json_indent = max(0, int(JSON_INDENT)) + parsed_csv_rows: list[dict[str, str]] = [] + + vision_mod: Any = None + vision_ok = False + if EXTRACT_INGREDIENTS_FROM_DETAIL_BODY_IMAGES: + try: + import AI_crawler as vision_mod # noqa: WPS433 + + vision_mod._resolve_credentials(None, None, None) + vision_ok = True + except Exception as e: + print( + f"[京东] 已开启配料视觉提取但未就绪({e})," + f"列 detail_body_ingredients / detail_body_ingredients_source_url 将按未就绪处理", + file=sys.stderr, + ) + + def emit( + code: int, + text: str, + s: str, + detail_body_ingredients: str = "", + *, + detail_body_ingredients_source_url: str = "", + ) -> None: + if code == 403: + print( + f"[京东] SKU {s}:接口 403,请更新 Cookie 或设 USE_CHROME=True。", + file=sys.stderr, + ) + frag = (text or "").strip().replace("\n", " ")[:400] + if frag: + print(f"[京东] 响应片段:{frag}", file=sys.stderr) + if code != 200: + print(f"[京东] SKU {s} HTTP {code}", file=sys.stderr) + + if output_minimal: + row_m = minimal_sku_body_images_row( + s, + detail_body_ingredients, + detail_body_ingredients_source_url=detail_body_ingredients_source_url, + ) + if out_dir: + if out_parsed_dir: + pd = Path(out_parsed_dir).resolve() / f"ware_{s}_parsed.json" + _write_minimal_body_images_json( + pd, + s, + detail_body_ingredients, + detail_body_ingredients_source_url=detail_body_ingredients_source_url, + ) + if out_parsed_csv: + parsed_csv_rows.append(dict(row_m)) + return + if out_path: + if out_parsed: + _write_minimal_body_images_json( + Path(out_parsed).resolve(), + s, + detail_body_ingredients, + detail_body_ingredients_source_url=detail_body_ingredients_source_url, + ) + if out_parsed_csv: + parsed_csv_rows.append(dict(row_m)) + return + if len(skus) != 1: + if out_parsed_csv: + parsed_csv_rows.append(dict(row_m)) + return + if out_parsed: + _write_minimal_body_images_json( + Path(out_parsed).resolve(), + s, + detail_body_ingredients, + detail_body_ingredients_source_url=detail_body_ingredients_source_url, + ) + sys.stdout.write( + json.dumps(row_m, ensure_ascii=False, indent=2) + "\n" + ) + if out_parsed_csv: + parsed_csv_rows.append(dict(row_m)) + return + + if out_dir: + out_p = Path(out_dir).resolve() / f"ware_{s}.json" + out_p.parent.mkdir(parents=True, exist_ok=True) + body, _ok = _format_ware_response_text( + text, + normalize=normalize_json, + sort_keys=sort_keys, + indent=json_indent, + ) + out_p.write_text(body, encoding="utf-8") + print(f"[京东] 已写 {out_p}", file=sys.stderr) + if out_parsed_dir: + pd = Path(out_parsed_dir).resolve() / f"ware_{s}_parsed.json" + _write_ware_parsed_json( + pd, + s, + code, + text, + detail_body_ingredients=detail_body_ingredients, + detail_body_ingredients_source_url=detail_body_ingredients_source_url, + ) + if out_parsed_csv: + parsed_csv_rows.append( + ware_parsed_row( + s, + code, + text, + detail_body_ingredients=detail_body_ingredients, + detail_body_ingredients_source_url=detail_body_ingredients_source_url, + ) + ) + return + if out_path: + Path(out_path).parent.mkdir(parents=True, exist_ok=True) + if normalize_json: + body, _ok = _format_ware_response_text( + text, + normalize=True, + sort_keys=sort_keys, + indent=json_indent, + ) + Path(out_path).write_text(body, encoding="utf-8") + elif pretty: + try: + obj = json.loads(text) + Path(out_path).write_text( + json.dumps(obj, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + except json.JSONDecodeError: + Path(out_path).write_text(text, encoding="utf-8") + else: + Path(out_path).write_text(text, encoding="utf-8") + print(f"[京东] 已写 {out_path}", file=sys.stderr) + if out_parsed: + _write_ware_parsed_json( + Path(out_parsed).resolve(), + s, + code, + text, + detail_body_ingredients=detail_body_ingredients, + detail_body_ingredients_source_url=detail_body_ingredients_source_url, + ) + if out_parsed_csv: + parsed_csv_rows.append( + ware_parsed_row( + s, + code, + text, + detail_body_ingredients=detail_body_ingredients, + detail_body_ingredients_source_url=detail_body_ingredients_source_url, + ) + ) + return + if len(skus) != 1: + sys.stdout.write(text + ("\n" if text and not text.endswith("\n") else "")) + if out_parsed_csv: + parsed_csv_rows.append( + ware_parsed_row( + s, + code, + text, + detail_body_ingredients=detail_body_ingredients, + detail_body_ingredients_source_url=detail_body_ingredients_source_url, + ) + ) + return + if out_parsed: + _write_ware_parsed_json( + Path(out_parsed).resolve(), + s, + code, + text, + detail_body_ingredients=detail_body_ingredients, + detail_body_ingredients_source_url=detail_body_ingredients_source_url, + ) + if normalize_json: + body, ok = _format_ware_response_text( + text, + normalize=True, + sort_keys=sort_keys, + indent=json_indent, + ) + sys.stdout.write( + body + if ok + else text + ("\n" if text and not text.endswith("\n") else "") + ) + elif pretty: + try: + obj = json.loads(text) + sys.stdout.write(json.dumps(obj, ensure_ascii=False, indent=2) + "\n") + except json.JSONDecodeError: + sys.stdout.write(text + ("\n" if not text.endswith("\n") else "")) + else: + sys.stdout.write(text + ("\n" if text and not text.endswith("\n") else "")) + if out_parsed_csv: + parsed_csv_rows.append( + ware_parsed_row( + s, + code, + text, + detail_body_ingredients=detail_body_ingredients, + detail_body_ingredients_source_url=detail_body_ingredients_source_url, + ) + ) + + max_att = max(1, int(FETCH_MAX_ATTEMPTS)) + retry_sec = max(0.0, float(FETCH_RETRY_DELAY_SEC)) + + def run_one(page: Any, s: str) -> None: + code, text, meta = fetch_ware_business( + page.context, + page, + s, + cookie_file=cf, + timeout_ms=timeout_ms, + cookie_override=cookie_override, + max_attempts=max_att, + retry_delay_sec=retry_sec, + ) + if verbose_http: + _print_http_verbose(meta, body_max=max(500, verbose_body_lim)) + if http_log_path: + http_records.append(meta) + print(f"[京东] sku={s} HTTP {code}", file=sys.stderr) + if code == 0 and meta.get("request", {}).get("note"): + print(f"[京东] {meta['request']['note']}", file=sys.stderr) + body_urls_meta = str(meta.get("detail_body_image_urls") or "").strip() + body_col, body_src = _detail_body_ingredients_column_value( + body_urls_meta, + vision_mod=vision_mod, + vision_ok=vision_ok, + ) + if EXTRACT_INGREDIENTS_FROM_DETAIL_BODY_IMAGES and body_col: + if str(body_col).startswith("【未识别"): + print(f"[京东] sku={s} {body_col}", file=sys.stderr) + elif body_src: + print( + f"[京东] sku={s} 已自详情长图解析配料表(首次命中即停)图源: {body_src}", + file=sys.stderr, + ) + else: + print(f"[京东] sku={s} 已自详情长图解析配料表(首次命中即停)", file=sys.stderr) + emit( + code, + text, + s, + body_col, + detail_body_ingredients_source_url=body_src, + ) + + headed = bool(HEADED or DEBUG_PAUSE) + use_chrome = bool(USE_CHROME or DEBUG_PAUSE) + launch_kw: dict[str, Any] = {"headless": not headed} + if use_chrome: + launch_kw["channel"] = "chrome" + + with sync_playwright() as pw: + browser = pw.chromium.launch(**launch_kw) + context = 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 = context.new_page() + if output_minimal: + print( + "[京东] 极简输出:skuId + detail_body_ingredients" + "(仍打开页抓接口以加载 DOM)", + file=sys.stderr, + ) + else: + print( + "[京东] 加载商品页并拦截 pc_detailpage_wareBusiness", + file=sys.stderr, + ) + try: + for s in skus: + run_one(page, s) + finally: + if DEBUG_PAUSE: + print( + "[京东] 调试:浏览器仍将保持打开;在此终端按回车后再关闭…", + file=sys.stderr, + ) + try: + input() + except EOFError: + pass + try: + page.close() + except Exception: + pass + browser.close() + + if parsed_csv_rows and out_parsed_csv: + csv_fields = list( + SKU_BODY_IMAGES_ONLY_FIELDNAMES + if output_minimal + else WARE_PARSED_CSV_FIELDNAMES + ) + cpp = Path(out_parsed_csv).resolve() + cpp.parent.mkdir(parents=True, exist_ok=True) + with cpp.open("w", encoding="utf-8-sig", newline="") as cf: + w = csv.DictWriter(cf, fieldnames=csv_fields, extrasaction="ignore") + w.writeheader() + w.writerows(parsed_csv_rows) + print( + f"[京东] 已写解析 CSV:{cpp}({len(parsed_csv_rows)} 行)", + file=sys.stderr, + ) + + if http_log_path: + log_p = Path(http_log_path).resolve() + log_p.parent.mkdir(parents=True, exist_ok=True) + payload: Any = http_records[0] if len(http_records) == 1 else http_records + log_p.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print(f"[京东] 已写 HTTP 往返记录:{log_p}", file=sys.stderr) + + +if __name__ == "__main__": + main() + + diff --git a/backend/crawler_copy/jd_pc_search/detail/jd_export_detail_ware_business_request.js b/backend/crawler_copy/jd_pc_search/detail/jd_export_detail_ware_business_request.js new file mode 100644 index 0000000..b9be045 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/detail/jd_export_detail_ware_business_request.js @@ -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); +} diff --git a/backend/crawler_copy/jd_pc_search/detail/jd_h5st_detail_ware_business.js b/backend/crawler_copy/jd_pc_search/detail/jd_h5st_detail_ware_business.js new file mode 100644 index 0000000..e4bd7e5 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/detail/jd_h5st_detail_ware_business.js @@ -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, +}; diff --git a/backend/crawler_copy/jd_pc_search/detail/jd_pc_detail_ware_business_headers.js b/backend/crawler_copy/jd_pc_search/detail/jd_pc_detail_ware_business_headers.js new file mode 100644 index 0000000..62826a0 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/detail/jd_pc_detail_ware_business_headers.js @@ -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 }; diff --git a/backend/crawler_copy/jd_pc_search/jd_competitor_report.py b/backend/crawler_copy/jd_pc_search/jd_competitor_report.py new file mode 100644 index 0000000..ee1f9ba --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/jd_competitor_report.py @@ -0,0 +1,2312 @@ +# -*- coding: utf-8 -*- +""" +关键词 → 调用 ``jd_keyword_pipeline`` 全链路采集 → 生成 **标准化竞品分析报告**(Markdown)。 + +报告结构对齐常见竞品分析框架:研究范围与方法、执行摘要、**整体市场观察(列表可见度 proxy)**、 +市场与竞争结构、**按细分类目分组的竞品对比矩阵**、价格分析、产品与宣称、**按细分类目的消费者反馈与用户画像**、策略提示与附录;并明确数据边界。 +若运行配置中提供了外部市场规模摘录(``EXTERNAL_MARKET_TABLE_ROWS``),则追加对应表格小节;否则不输出占位行。 + +依赖:全量抓取时与 ``jd_keyword_pipeline.py`` 相同(Node、h5st、Playwright、``common/jd_cookie.txt``)。 +**仅复用已有目录生成报告时**不需要跑浏览器,只需该目录下已有 CSV / ``run_meta.json``。 + +用法: + +- **重新抓取并出报告**:``EXISTING_RUN_DIR = None``,配置 ``KEYWORD``(及可选 ``OVERRIDE_*``),执行 ``python jd_competitor_report.py``。 +- **只分析已有批次**:将 ``EXISTING_RUN_DIR`` 设为 ``pipeline_runs/<时间戳>_<关键词>/`` 的绝对或相对路径(相对当前工作目录), + 再执行同一命令;**不重新抓取**。关键词优先用本文件 ``KEYWORD``,否则读 ``run_meta.json`` 的 ``keyword``,再否则从目录名 + ``YYYYMMDD_HHMMSS_<词>`` 推断。 + +流水线其余参数(评论分页、延迟等)仍在 ``jd_keyword_pipeline.py`` 顶部配置。 + +输出:在对应运行目录下覆盖写入 ``competitor_analysis.md``。 +""" + +from __future__ import annotations + +import csv +import json +import math +import re +import statistics +import sys +from collections import Counter +from pathlib import Path +from typing import Any + +_ROOT = Path(__file__).resolve().parent +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) + +import jd_keyword_pipeline as kpl # noqa: E402 + +# --------------------------------------------------------------------------- +# 运行配置(按需改这里) +# --------------------------------------------------------------------------- +# KEYWORD:京东 PC 搜索词;全量抓取时必填。「仅已有目录」模式下可留空,改从 run_meta / 目录名推断。 +KEYWORD = "低GI" +# 已有流水线目录(含 keyword_pipeline_merged.csv 等)时设为路径则**不重新抓取**,只生成 competitor_analysis.md。 +EXISTING_RUN_DIR = None +# EXISTING_RUN_DIR = r"data\JD\pipeline_runs\20260408_144606_低GI" # 相对数据根或绝对路径 +# 以下非 None 时仅本次运行临时覆盖 jd_keyword_pipeline 中同名变量(不改 pipeline 文件) +OVERRIDE_MAX_SKUS: int | None = None +OVERRIDE_PAGE_START: int | None = None +OVERRIDE_PAGE_TO: int | None = None + +# 评价/预览文本中可统计的「低 GI / 控糖」语境词(命中次数供侧写,非严谨 NLP) +# 可选:第三方市场规模 / 行业增速等(每行四列:指标 | 数值与口径 | 来源 | 年份)。留空则不生成该小节。 +EXTERNAL_MARKET_TABLE_ROWS: tuple[tuple[str, str, str, str], ...] = () + +COMMENT_FOCUS_WORDS: tuple[str, ...] = ( + "口感", + "甜", + "糖", + "血糖", + "控糖", + "低糖", + "无糖", + "饱腹", + "升糖", + "GI", + "gi", + "孕妇", + "老人", + "糖尿病", + "价格", + "贵", + "便宜", + "回购", + "包装", + "物流", +) + +# 用途/场景:每组 (展示名, 触发子串…)。每条评价若命中组内任一子串则该组 +1;同一条可属多组。 +COMMENT_SCENARIO_GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("早餐/代餐", ("早餐", "代餐", "早饭", "当早餐", "当早饭", "早上吃", "晨起")), + ("零食/加餐/解馋", ("零食", "加餐", "嘴馋", "小零食", "解馋", "垫肚子", "饿了", "肚子饿", "两餐之间", "间食")), + ("控糖/血糖相关", ("控糖", "血糖高", "升糖", "糖友", "糖尿病", "孕期控糖", "妊娠糖", "血糖")), + ("孕期/育儿", ("孕期", "孕妇", "怀孕", "产妇", "坐月子", "哺乳", "给宝宝", "给娃", "孩子吃", "小孩吃", "宝宝吃")), + ("健身/减脂", ("减肥", "减脂", "瘦身", "健身", "卡路里", "热量低", "低脂")), + ("长辈/家庭", ("老人", "爸妈", "父母", "长辈", "爷爷奶奶", "给家里")), + ("办公/外出", ("办公室", "上班吃", "出门", "外出", "随身带", "包里", "便携")), + ("送礼/囤货", ("送礼", "送人", "囤货", "年货")), + ("夜宵/熬夜", ("夜宵", "熬夜", "晚上饿")), +) + + +def _normalize_focus_words(raw: Any) -> tuple[str, ...]: + if not isinstance(raw, list) or not raw: + return COMMENT_FOCUS_WORDS + out: list[str] = [] + for x in raw[:120]: + s = str(x).strip() + if len(s) > 48: + s = s[:48] + if s: + out.append(s) + return tuple(out) if out else COMMENT_FOCUS_WORDS + + +def _normalize_scenario_groups( + raw: Any, +) -> tuple[tuple[str, tuple[str, ...]], ...]: + if not isinstance(raw, list) or not raw: + return COMMENT_SCENARIO_GROUPS + parsed: list[tuple[str, tuple[str, ...]]] = [] + for item in raw[:40]: + label = "" + triggers: list[str] = [] + if isinstance(item, dict): + label = str(item.get("label") or "").strip()[:80] + tr = item.get("triggers") + if isinstance(tr, list): + for t in tr[:48]: + s = str(t).strip() + if len(s) > 48: + s = s[:48] + if s: + triggers.append(s) + elif isinstance(item, (list, tuple)) and len(item) >= 2: + label = str(item[0]).strip()[:80] + tr = item[1] + if isinstance(tr, (list, tuple)): + for t in tr[:48]: + s = str(t).strip() + if len(s) > 48: + s = s[:48] + if s: + triggers.append(s) + if label and triggers: + parsed.append((label, tuple(triggers))) + return tuple(parsed) if parsed else COMMENT_SCENARIO_GROUPS + + +def _normalize_external_market_rows( + raw: Any, +) -> tuple[tuple[str, str, str, str], ...]: + if not isinstance(raw, list) or not raw: + return EXTERNAL_MARKET_TABLE_ROWS + rows: list[tuple[str, str, str, str]] = [] + + def _four_cells(x: Any) -> tuple[str, str, str, str] | None: + if isinstance(x, (list, tuple)) and len(x) >= 4: + return tuple(str(c)[:500] for c in x[:4]) + if isinstance(x, dict): + a = str(x.get("indicator") or x.get("a") or "").strip()[:500] + b = str(x.get("value_and_scope") or x.get("b") or "").strip()[:500] + c = str(x.get("source") or x.get("c") or "").strip()[:500] + d = str(x.get("year") or x.get("d") or "").strip()[:500] + if any((a, b, c, d)): + return (a, b, c, d) + return None + + for item in raw[:24]: + r = _four_cells(item) + if r: + rows.append(r) + return tuple(rows) if rows else EXTERNAL_MARKET_TABLE_ROWS + + +def resolve_report_tuning( + report_config: dict[str, Any] | None, +) -> tuple[ + tuple[str, ...], + tuple[tuple[str, tuple[str, ...]], ...], + tuple[tuple[str, str, str, str], ...], +]: + if not report_config: + return COMMENT_FOCUS_WORDS, COMMENT_SCENARIO_GROUPS, EXTERNAL_MARKET_TABLE_ROWS + return ( + _normalize_focus_words(report_config.get("comment_focus_words")), + _normalize_scenario_groups(report_config.get("comment_scenario_groups")), + _normalize_external_market_rows( + report_config.get("external_market_table_rows") + ), + ) + + +def _cell(row: dict[str, str], *keys: str) -> str: + for k in keys: + v = str(row.get(k) or "").strip() + if v: + return v + return "" + + +# 合并表列名:lean 仅有搜索侧「类目(...)」;full 或历史文件可能含商详「detail_category_path」。勿用内部键 leaf_category(CSV 中不存在)。 +_MERGED_CATEGORY_KEYS: tuple[str, ...] = ( + "detail_category_path", + "leaf_category", + "类目(leafCategory,cid3Name,catid)", +) +_K_CAT_COL = "类目(leafCategory,cid3Name,catid)" +_K_PROP_COL = "规格属性(propertyList,color,catid,shortName)" + + +def _shortname_from_prop(prop: str) -> str: + m = re.search(r"简称[::]\s*([^|]+)", prop or "") + return m.group(1).strip()[:120] if m else "" + + +def _category_cell(row: dict[str, str]) -> str: + c = _cell(row, *_MERGED_CATEGORY_KEYS) + if c: + return c + prop = _cell(row, _K_PROP_COL) + sn = _shortname_from_prop(prop) + if re.search(r"类目[::]\s*\d+", prop): + if sn: + return sn + return "" + return "" + + +def _search_export_catid_to_shortname_map(rows: list[dict[str, str]]) -> dict[str, str]: + """列表导出中叶子类目列常为纯数字 ID:用同行规格属性「简称」映射为可读名称。""" + m: dict[str, str] = {} + for r in rows: + cid = _cell(r, _K_CAT_COL).strip() + if not cid.isdigit(): + continue + if cid in m: + continue + sn = _shortname_from_prop(_cell(r, _K_PROP_COL)) + if sn: + m[cid] = sn + return m + + +def _md_cell(s: str, max_len: int = 120) -> str: + t = (s or "").replace("\r\n", " ").replace("\n", " ").replace("|", "/") + t = " ".join(t.split()) + return (t[:max_len] + "…") if max_len > 0 and len(t) > max_len else t + + +def _read_csv_rows(path: Path) -> tuple[list[str], list[dict[str, str]]]: + if not path.is_file(): + return [], [] + raw = path.read_text(encoding="utf-8-sig") + lines = raw.splitlines() + if not lines: + return [], [] + rdr = csv.DictReader(lines) + fn = rdr.fieldnames or [] + return list(fn), list(rdr) + + +def _float_price(s: str) -> float | None: + if not (s or "").strip(): + return None + m = re.search(r"(\d+(?:\.\d+)?)", str(s).replace(",", "")) + if not m: + return None + try: + return float(m.group(1)) + except ValueError: + return None + + +def _collect_prices(rows: list[dict[str, str]]) -> list[float]: + out: list[float] = [] + price_keys = ( + "detail_price_final", + "标价(jdPrice,jdPriceText,realPrice)", + "券后到手价(couponPrice,subsidyPrice,finalPrice.estimatedPrice,priceShow)", + ) + for row in rows: + for k in price_keys: + p = _float_price(_cell(row, k)) + if p is not None and 0 < p < 1_000_000: + out.append(p) + break + return out + + +def _comment_keyword_hits( + rows: list[dict[str, str]], + focus_words: tuple[str, ...], +) -> Counter[str]: + c: Counter[str] = Counter() + texts: list[str] = [] + for row in rows: + t = _cell(row, "tagCommentContent") + if t: + texts.append(t) + blob = "\n".join(texts) + for w in focus_words: + if len(w) == 1: + continue + n = blob.count(w) + if n: + c[w] += n + return c + + +def _merge_comment_previews(merged_rows: list[dict[str, str]]) -> str: + parts: list[str] = [] + for row in merged_rows: + p = _cell(row, "comment_preview") + if p: + parts.append(p) + return "\n".join(parts) + + +def _iter_comment_text_units( + comment_rows: list[dict[str, str]], + merged_rows: list[dict[str, str]], +) -> list[str]: + """逐条评价正文;无 flat 评论时用合并表 comment_preview 按行兜底。""" + out: list[str] = [] + for row in comment_rows: + t = _cell(row, "tagCommentContent") + if t: + out.append(t) + if out: + return out + for row in merged_rows: + p = _cell(row, "comment_preview") + if p: + out.append(p) + return out + + +_POS_LEX = ( + "好", + "赞", + "满意", + "回购", + "推荐", + "不错", + "喜欢", + "香", + "实惠", + "值得", + "棒", + "鲜嫩", + "好吃", + "划算", + "正品", + "好评", +) +_NEG_LEX = ( + "差", + "烂", + "难吃", + "失望", + "假", + "骗", + "退货", + "不建议", + "硬", + "糟糕", + "难用", + "臭", + "差评", + "不好", + "难喝", + "发霉", +) +# 条形图/摘要用:多字短语优先,避免只显示「硬、差」等单字 +_POS_LEXEME_DETAIL = ( + "已经回购很多次", + "还会再买", + "值得回购", + "推荐购买", + "性价比很高", + "性价比不错", + "物美价廉", + "物流很快", + "包装很用心", + "包装完好", + "口感很好", + "味道不错", + "很好吃", + "香而不腻", + "饱腹感不错", + "控糖很友好", + "低糖很适合", + "代餐很方便", + "品质很稳定", + "值得信赖", +) +_NEG_LEXEME_DETAIL = ( + "口感偏硬", + "口感很硬", + "口感发粘", + "太甜了", + "甜得发腻", + "甜度过高", + "不太好吃", + "很难吃", + "味道很奇怪", + "有股怪味", + "一股异味", + "包装破损", + "漏气受潮", + "日期不新鲜", + "临期产品", + "质量很差", + "不值这个价", + "与描述不符", + "疑似假货", + "发货特别慢", + "物流太慢了", + "售后很差", + "退款很麻烦", + "不建议购买", + "不会再买", +) + + +def _lex_tuple_classify(*parts: tuple[str, ...]) -> tuple[str, ...]: + seen: set[str] = set() + out: list[str] = [] + for tup in parts: + for w in tup: + w = (w or "").strip() + if w and w not in seen: + seen.add(w) + out.append(w) + out.sort(key=len, reverse=True) + return tuple(out) + + +_POS_CLASS = _lex_tuple_classify(_POS_LEX, _POS_LEXEME_DETAIL) +_NEG_CLASS = _lex_tuple_classify(_NEG_LEX, _NEG_LEXEME_DETAIL) +_POS_LEX_HITS = tuple(sorted(_POS_LEXEME_DETAIL, key=len, reverse=True)) +_NEG_LEX_HITS = tuple(sorted(_NEG_LEXEME_DETAIL, key=len, reverse=True)) + + +def _lexeme_hits_in_texts( + texts: list[str], lexemes: tuple[str, ...] +) -> list[dict[str, Any]]: + """每条文本内同一短语只计 1 次;``lexemes`` 宜按长度降序以优先匹配更长表述。""" + c: Counter[str] = Counter() + for raw in texts: + s = (raw or "").strip() + if not s: + continue + seen_line: set[str] = set() + for k in lexemes: + if not k or k not in s: + continue + if k in seen_line: + continue + seen_line.add(k) + c[k] += 1 + return [{"word": w, "texts_matched": n} for w, n in c.most_common(18)] + + +def _comment_sentiment_lexicon(texts: list[str]) -> dict[str, Any]: + """ + 基于预设词表对每条文本做正/负向粗判(非深度学习);同条同时含正负词时计为「混合」。 + 短语级词频仅在对应语境下统计(条形图用 ``_POS_LEX_HITS`` / ``_NEG_LEX_HITS``)。 + """ + pos_only = neg_only = mixed = neutral = 0 + corpus_pos_mixed: list[str] = [] + corpus_neg_mixed: list[str] = [] + for t in texts: + s = (t or "").strip() + if not s: + neutral += 1 + continue + hp = any(k in s for k in _POS_CLASS) + hn = any(k in s for k in _NEG_CLASS) + if hp and hn: + mixed += 1 + corpus_pos_mixed.append(s) + corpus_neg_mixed.append(s) + elif hp: + pos_only += 1 + corpus_pos_mixed.append(s) + elif hn: + neg_only += 1 + corpus_neg_mixed.append(s) + else: + neutral += 1 + total = len(texts) + pos_lex = _lexeme_hits_in_texts(corpus_pos_mixed, _POS_LEX_HITS) + neg_lex = _lexeme_hits_in_texts(corpus_neg_mixed, _NEG_LEX_HITS) + return { + "method": "keyword_lexicon", + "text_units": total, + "positive_only": pos_only, + "negative_only": neg_only, + "mixed_positive_and_negative": mixed, + "neutral_or_empty": neutral, + "positive_lexicon_sample": list(_POS_LEX[:10]) + list(_POS_LEXEME_DETAIL[:5]), + "negative_lexicon_sample": list(_NEG_LEX[:10]) + list(_NEG_LEXEME_DETAIL[:5]), + "positive_tone_lexeme_hits": pos_lex, + "negative_tone_lexeme_hits": neg_lex, + "lexeme_scope_note": ( + "「正向短语」仅在命中正向词表的评价条内统计(含混合条);" + "「负向短语」仅在命中负向词表的评价条内统计(含混合条);每条每短语最多计 1 次;" + "统计的是预设口语片段,非分词模型。" + ), + } + + +def build_comment_sentiment_llm_payload( + texts: list[str], + *, + max_samples_per_tone: int = 14, + max_chars_per_review: int = 240, +) -> dict[str, Any]: + """ + 供大模型做正/负向语义归纳:附规则统计与**去重后的评价原文抽样**(与 §8.2 词表分桶一致)。 + """ + pos_only_texts: list[str] = [] + neg_only_texts: list[str] = [] + mixed_texts: list[str] = [] + for t in texts: + s = (t or "").strip() + if not s: + continue + hp = any(k in s for k in _POS_CLASS) + hn = any(k in s for k in _NEG_CLASS) + if hp and hn: + mixed_texts.append(s) + elif hp: + pos_only_texts.append(s) + elif hn: + neg_only_texts.append(s) + + def _sample(seq: list[str]) -> list[str]: + out: list[str] = [] + seen: set[str] = set() + for raw in seq: + if raw in seen: + continue + seen.add(raw) + if len(raw) > max_chars_per_review: + out.append(raw[:max_chars_per_review] + "…") + else: + out.append(raw) + if len(out) >= max_samples_per_tone: + break + return out + + lex = _comment_sentiment_lexicon(texts) + return { + "comment_sentiment_lexicon": lex, + "sample_reviews_positive_biased": _sample(pos_only_texts), + "sample_reviews_negative_biased": _sample(neg_only_texts), + "sample_reviews_mixed_tone": _sample(mixed_texts)[:8], + } + + +def _mermaid_pie_focus_keywords(hits: Counter[str], *, top_k: int = 8) -> str: + """关注词全局 Top 的 Mermaid pie(便于渲染或导出工具识别)。""" + top = hits.most_common(top_k) + if not top: + return "" + rest = list(hits.most_common()) + if len(rest) > top_k: + others_n = sum(n for _, n in rest[top_k:]) + else: + others_n = 0 + lines = ["```mermaid", 'pie title 关注词命中次数(全局 Top,子串计数)'] + for w, n in top: + if n <= 0: + continue + label = (w or "?").replace('"', "'").replace("\n", " ")[:18] + lines.append(f' "{label}({n})" : {n}') + if others_n > 0: + lines.append(f' "其余词合计" : {others_n}') + lines.append("```") + return "\n".join(lines) + + +def _comment_scenario_counts( + texts: list[str], + scenario_groups: tuple[tuple[str, tuple[str, ...]], ...], +) -> tuple[Counter[str], int]: + """每组统计「至少命中一个触发词」的条数。返回 (各组条数, 有效文本条数)。""" + c: Counter[str] = Counter() + n = len(texts) + for blob in texts: + for label, triggers in scenario_groups: + if any(t in blob for t in triggers): + c[label] += 1 + return c, n + + +def _scenario_summary_bullets(counter: Counter[str], n_texts: int, top_k: int = 5) -> list[str]: + if n_texts <= 0 or not counter: + return [] + ordered = counter.most_common() + lines: list[str] = [] + head = ordered[:top_k] + parts = [] + for label, cnt in head: + pct = 100.0 * cnt / n_texts + parts.append(f"「{label}」约 **{cnt}** 条(占有效文本 **{pct:.0f}%**)") + lines.append( + "用户自述的用途/场景(基于预设词组,**非语义分类**):" + ";".join(parts) + "。" + ) + tail = [lbl for lbl, n in ordered[top_k:] if n > 0] + if tail: + lines.append(f"另有提及较少的场景标签:{'、'.join(tail)}。") + return lines + + +def _sku_to_matrix_group_map( + merged_rows: list[dict[str, str]], sku_header: str +) -> dict[str, str]: + m: dict[str, str] = {} + for row in merged_rows: + sku = _cell(row, sku_header).strip() + if sku: + m[sku] = _competitor_matrix_group_key(row) + return m + + +def _comment_text_units_for_matrix_group( + gname: str, + merged_rows: list[dict[str, str]], + comment_rows_in_group: list[dict[str, str]], + sku_header: str, +) -> list[str]: + """某细类下的评价正文列表;无 flat 时用该细类合并行的 comment_preview。""" + texts: list[str] = [] + for row in comment_rows_in_group: + t = _cell(row, "tagCommentContent") + if t: + texts.append(t) + if texts: + return texts + for row in merged_rows: + if _competitor_matrix_group_key(row) != gname: + continue + p = _cell(row, "comment_preview") + if p: + texts.append(p) + return texts + + +def _group_keyword_hits( + comment_rows_in_group: list[dict[str, str]], + texts_fallback: list[str], + *, + focus_words: tuple[str, ...], +) -> Counter[str]: + h = _comment_keyword_hits(comment_rows_in_group, focus_words) + if h: + return h + if not texts_fallback: + return Counter() + blob = "\n".join(texts_fallback) + c: Counter[str] = Counter() + for w in focus_words: + if len(w) < 2: + continue + n = blob.count(w) + if n: + c[w] += n + return c + + +def _consumer_feedback_by_matrix_group( + *, + merged_rows: list[dict[str, str]], + comment_rows: list[dict[str, str]], + sku_header: str, +) -> list[tuple[str, list[dict[str, str]], list[str]]]: + """ + 与 §5 矩阵同序的细类列表;每项为 (细类名, 该类的 comments_flat 行, 用于场景统计的文本单元)。 + 评价 SKU 不在深入样本时归入「未归类(评价 SKU 无对应深入样本)」。 + """ + if not merged_rows: + if not comment_rows: + return [] + texts = _iter_comment_text_units(comment_rows, []) + return [ + ( + "未归类(无深入合并表)", + list(comment_rows), + texts, + ) + ] + + sku_map = _sku_to_matrix_group_map(merged_rows, sku_header) + by_g: dict[str, list[dict[str, str]]] = {} + for row in comment_rows: + sku = _cell(row, "sku").strip() + g = sku_map.get(sku, "未归类(评价 SKU 无对应深入样本)") + by_g.setdefault(g, []).append(row) + + out: list[tuple[str, list[dict[str, str]], list[str]]] = [] + used: set[str] = set() + for gname, _ in _merged_rows_grouped_for_matrix(merged_rows): + cr = by_g.get(gname, []) + tu = _comment_text_units_for_matrix_group( + gname, merged_rows, cr, sku_header + ) + out.append((gname, cr, tu)) + used.add(gname) + for gname, cr in sorted(by_g.items(), key=lambda x: (-len(x[1]), x[0])): + if gname in used: + continue + tu = _comment_text_units_for_matrix_group( + gname, merged_rows, cr, sku_header + ) + out.append((gname, cr, tu)) + return out + + +def _run_batch_label(run_dir: Path) -> str: + name = run_dir.name + m = re.match(r"^(\d{8})_(\d{6})_", name) + if m: + return f"{m.group(1)} {m.group(2)}" + return name + + +def _resolve_existing_run_dir(raw: str | Path | None) -> Path | None: + if raw is None: + return None + s = str(raw).strip() + if not s: + return None + p = Path(s).expanduser() + if not p.is_absolute(): + p = (Path.cwd() / p).resolve() + else: + p = p.resolve() + return p + + +def _infer_keyword(run_dir: Path, meta: dict[str, Any] | None) -> str: + if meta: + k = str(meta.get("keyword") or "").strip() + if k: + return k + m = re.match(r"^\d{8}_\d{6}_(.+)$", run_dir.name) + if m: + return m.group(1).strip() + return "" + + +def _pc_search_result_count_from_raw( + run_dir: Path, +) -> tuple[int | None, str, list[int], int, int]: + """ + 从 ``pc_search_raw/*.json`` 读取 ``data.resultCount``(京东 PC 搜索接口返回的检索命中规模)。 + 多文件时取众数;返回 (众数值, data.listKeyWord 首见值, 出现过的不同取值升序, 解析到的样本文件数)。 + """ + raw_dir = run_dir / "pc_search_raw" + if not raw_dir.is_dir(): + return None, "", [], 0, 0 + counts: list[int] = [] + list_kw = "" + n_files = 0 + for p in sorted(raw_dir.glob("*.json")): + try: + obj = json.loads(p.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError, UnicodeError): + continue + n_files += 1 + if not isinstance(obj, dict): + continue + data = obj.get("data") + if not isinstance(data, dict): + continue + rc = data.get("resultCount") + val: int | None = None + if isinstance(rc, int) and not isinstance(rc, bool) and rc >= 0: + val = rc + elif isinstance(rc, str) and rc.strip().isdigit(): + val = int(rc.strip()) + if val is not None: + counts.append(val) + lk = data.get("listKeyWord") + if isinstance(lk, str) and lk.strip() and not list_kw: + list_kw = lk.strip() + if not counts: + return None, list_kw, [], n_files, 0 + consensus_rc, _freq = Counter(counts).most_common(1)[0] + uniques = sorted(set(counts)) + return consensus_rc, list_kw, uniques, n_files, len(counts) + + +def _brand_cr(cnames: list[str]) -> tuple[float | None, float | None, str, str]: + """按名称计数返回 (第一大主体份额, 前三合计份额, 头部标签, 头部占比展示字符串)。""" + if not cnames: + return None, None, "", "" + cnt = Counter(cnames) + total = sum(cnt.values()) + if total <= 0: + return None, None, "", "" + mc = cnt.most_common() + top1_n = mc[0][1] if mc else 0 + top1 = mc[0][0] if mc else "" + cr1 = top1_n / total + top3_n = sum(n for _, n in mc[:3]) + cr3 = top3_n / total + return cr1, cr3, top1, f"{100.0 * top1_n / total:.1f}%" + + +def _price_stats_extended(prices: list[float]) -> dict[str, Any]: + if not prices: + return {} + out: dict[str, Any] = { + "min": min(prices), + "max": max(prices), + "mean": statistics.mean(prices), + "n": len(prices), + } + if len(prices) >= 2: + out["stdev"] = statistics.stdev(prices) + if len(prices) >= 2: + out["median"] = statistics.median(prices) + if len(prices) >= 4: + s = sorted(prices) + n = len(s) + mid = n // 2 + lower = s[:mid] if n % 2 else s[:mid] + upper = s[mid + 1 :] if n % 2 else s[mid:] + out["q1"] = statistics.median(lower) if lower else s[0] + out["q3"] = statistics.median(upper) if upper else s[-1] + return out + + +def _search_list_proxies(rows: list[dict[str, str]]) -> dict[str, Any]: + """ + 基于 pc_search_export 的「列表可见度」指标,**不是**全渠道零售额或 TAM。 + """ + sku_k = "SKU(skuId)" + shop_k = "店铺名(shopName)" + page_k = "页码(page)" + cat_k = "类目(leafCategory,cid3Name,catid)" + skus: set[str] = set() + shops: set[str] = set() + pages: set[str] = set() + cats: set[str] = set() + for r in rows: + s = _cell(r, sku_k) + if s: + skus.add(s) + sh = _cell(r, shop_k) + if sh: + shops.add(sh) + pg = _cell(r, page_k) + if pg: + pages.add(pg) + c = _cell(r, cat_k) + if c: + cats.add(c) + prices = _collect_prices(rows) + pst = _price_stats_extended(prices) + return { + "total_rows": len(rows), + "unique_skus": len(skus), + "unique_shops": len(shops), + "unique_pages": len(pages), + "page_span": (min((int(p) for p in pages if p.isdigit()), default=None), max((int(p) for p in pages if p.isdigit()), default=None)), + "unique_leaf_cats": len(cats), + "list_price_stats": pst, + } + + +def _category_mix(rows: list[dict[str, str]]) -> list[tuple[str, int]]: + cats: list[str] = [] + for r in rows: + c = _category_cell(r) + if c: + cats.append(c.split(">")[0].strip() if ">" in c else c[:80]) + return Counter(cats).most_common(8) + + +def _category_mix_search_export(rows: list[dict[str, str]]) -> list[tuple[str, int]]: + """PC 搜索导出:优先可读类目名;纯数字 ID 时用「简称」聚合,避免展示无意义类目码。""" + id_names = _search_export_catid_to_shortname_map(rows) + labels: list[str] = [] + for r in rows: + c = _cell(r, _K_CAT_COL).strip() + p = _cell(r, _K_PROP_COL) + if c and not c.isdigit(): + labels.append(c[:80]) + continue + if c.isdigit(): + labels.append( + id_names.get(c) + or _shortname_from_prop(p) + or "未解析类目(列表仅有内部编码且无简称)" + ) + continue + m = re.search(r"类目[::]\s*(\d+)", p) + if m: + cid = m.group(1) + labels.append( + id_names.get(cid) + or _shortname_from_prop(p) + or "未解析类目(列表仅有内部编码且无简称)" + ) + else: + sn = _shortname_from_prop(p) + if sn: + labels.append(sn) + return Counter(labels).most_common(12) + + +def _structure_shops(rows: list[dict[str, str]], *, list_export: bool) -> list[str]: + if list_export: + return [_cell(r, "店铺名(shopName)") for r in rows if _cell(r, "店铺名(shopName)")] + out: list[str] = [] + for r in rows: + s = _cell(r, "detail_shop_name") or _cell(r, "店铺名(shopName)") + if s: + out.append(s) + return out + + +def _structure_brands(rows: list[dict[str, str]], *, list_export: bool) -> list[str]: + if list_export: + k = "店铺信息标题(shopInfoTitle,brandName)" + return [_cell(r, k) for r in rows if _cell(r, k)] + return [_cell(r, "detail_brand") for r in rows if _cell(r, "detail_brand")] + + +def _structure_category_mix( + rows: list[dict[str, str]], *, list_export: bool +) -> list[tuple[str, int]]: + if list_export: + return _category_mix_search_export(rows) + return _category_mix(rows) + + +def _competitor_matrix_group_key(row: dict[str, str]) -> str: + """ + 竞品矩阵分组:使「饼干」「面条」等同细类同表。 + - 路径 ≥4 段:取倒数第二段(如 … > 面条 > 挂面 → 面条)。 + - 路径 3 段:取中间段(如 休闲食品 > 饼干 > 粗粮饼干 → 饼干)。 + - 路径 2 段:取第二段;1 段:取该段。 + """ + c = _category_cell(row) + if not c: + return "未归类(无类目路径)" + parts = [p.strip() for p in c.replace(">", ">").split(">") if p.strip()] + if not parts: + return "未归类(无类目路径)" + if len(parts) >= 4: + return parts[-2] + if len(parts) >= 3: + return parts[1] + if len(parts) >= 2: + return parts[1] + return parts[0] + + +def _merged_rows_grouped_for_matrix( + merged_rows: list[dict[str, str]], +) -> list[tuple[str, list[dict[str, str]]]]: + buckets: dict[str, list[dict[str, str]]] = {} + for row in merged_rows: + k = _competitor_matrix_group_key(row) + buckets.setdefault(k, []).append(row) + + def sort_key(item: tuple[str, list[dict[str, str]]]) -> tuple[int, int, str]: + name, rows = item + miss = name.startswith("未归类") + return (1 if miss else 0, -len(rows), name) + + return sorted(buckets.items(), key=sort_key) + + +def _is_ingredient_url_blob(s: str) -> bool: + """详情主图 URL 串(分号分隔)或单列以 http 开头。""" + t = (s or "").strip() + if not t: + return False + if t.startswith(("http://", "https://")): + return True + head = t[:400] + if ("https://" in head or "http://" in head) and ( + ";" in t or len(t) > 180 or t.count("http") >= 2 + ): + return True + return False + + +def _ingredients_from_product_attributes(attrs: str) -> str: + m = re.search(r"配料(?:表)?[::]\s*([^;;]+)", attrs or "") + return m.group(1).strip() if m else "" + + +def _ingredients_single_line(s: str) -> str: + """与 ``AI_crawler.normalize_ingredients_text_for_csv`` 一致:多行配料压成一行(行间 ``;``),便于表格/CSV。""" + t = (s 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 _matrix_ingredients_cell(row: dict[str, str], *, max_len: int = 420) -> str: + """ + 优先 ``detail_body_ingredients``(配料 OCR/文本);旧合并表可能为 ``detail_body_image_urls``。 + 若为 URL 串则尝试 ``detail_product_attributes`` 中的「配料/配料表:」片段。 + """ + raw = _cell(row, "detail_body_ingredients", "detail_body_image_urls") + if raw and not _is_ingredient_url_blob(raw): + return _md_cell(_ingredients_single_line(raw), max_len) + from_attr = _ingredients_from_product_attributes( + _cell(row, "detail_product_attributes") + ) + if from_attr: + return _md_cell(from_attr, max_len) + if raw and _is_ingredient_url_blob(raw): + return _md_cell( + "(详情长图链接,无配料正文;可在采集侧开启配料识别后重新跑批次)", + max_len, + ) + return "—" + + +def _competitor_matrix_md_line( + row: dict[str, str], *, sku_header: str, title_h: str +) -> str: + sku = _md_cell(_cell(row, sku_header), 14) + title = _md_cell(_cell(row, title_h), 56) + brand = _md_cell(_cell(row, "detail_brand"), 16) + pj = _md_cell(_cell(row, "标价(jdPrice,jdPriceText,realPrice)"), 10) + df = _md_cell(_cell(row, "detail_price_final"), 10) + shop = _md_cell(_cell(row, "店铺名(shopName)", "detail_shop_name"), 22) + sell = _md_cell(_cell(row, "卖点(sellingPoint)"), 36) + rank = _md_cell( + _cell(row, "榜单类文案(标签/腰带/标题数组中的榜、TOP 等)"), 28 + ) + cat = _md_cell(_category_cell(row), 24) + ing = _matrix_ingredients_cell(row) + cc = _md_cell(_cell(row, "评价量(commentFuzzy)"), 10) + prev = _md_cell(_cell(row, "comment_preview"), 72) + return ( + f"| {sku} | {title} | {brand} | {pj} | {df} | {shop} | {sell} | {rank} | " + f"{cat} | {ing} | {cc} | {prev} |" + ) + + +def _strategy_hints( + *, + cr1: float | None, + pst: dict[str, Any], + hits: Counter[str], + n_comments: int, + scen_counts: Counter[str], + scen_n_texts: int, +) -> list[str]: + """基于规则的「提示性」结论,均标注待验证。""" + hints: list[str] = [] + if cr1 is not None and cr1 >= 0.45: + hints.append( + "样本内品牌集中度较高(第一大品牌份额偏高),头部玩家占据显著曝光;新原料/解决方案宜明确差异化价值主张(**需线下渠道与招商信息交叉验证**)。" + ) + elif cr1 is not None and cr1 < 0.25: + hints.append( + "样本内品牌较分散,品类或关键词下竞争格局未固化,存在定位与叙事空间(**需扩大样本页数与关键词矩阵验证**)。" + ) + if pst.get("stdev") and pst.get("mean") and pst["mean"] > 0: + cv = pst["stdev"] / pst["mean"] + if cv > 0.35: + hints.append( + "价格离散度较高,同时存在偏低价与偏高价陈列,可分别对标「性价比带」与「品质/功能带」竞品(**终端到手价受促销影响,非成本结构**)。" + ) + if hits: + top = hits.most_common(3) + top_s = "、".join(w for w, _ in top) + hints.append( + f"评价文本中「{top_s}」等主题出现较多,可作为消费者沟通与产品卖点的假设输入(**非严格主题模型,建议人工抽样复核**)。" + ) + if n_comments < 5: + hints.append( + "有效评价样本偏少,消费者洞察部分仅作方向参考,正式结论建议加大 SKU 数或评论分页。" + ) + if scen_n_texts >= 5 and scen_counts: + top_lbl, top_n = scen_counts.most_common(1)[0] + share = top_n / scen_n_texts + if share >= 0.25: + hints.append( + f"用途/场景中「{top_lbl}」在约 {100 * share:.0f}% 的有效评价自述中出现,可作为沟通场景与卖点的优先假设(**词组规则,建议抽样核对原句**)。" + ) + if not hints: + hints.append( + "当前样本下自动规则未触发强信号;请结合业务目标人工解读对比矩阵与原始 CSV。" + ) + return hints + + +def _embed_chart(run_dir: Path, filename: str, caption: str = "") -> list[str]: + """若 ``report_assets/`` 存在则返回插图 Markdown 片段。""" + if not (run_dir / "report_assets" / filename).is_file(): + return [] + cap = (caption or "").strip() + out: list[str] = [] + if cap: + out.append(f"*{cap}*") + out.append("") + out.append(f"![](report_assets/{filename})") + out.append("") + return out + + +def _scenario_group_asset_slug(group: str, index: int) -> str: + """与 ``pipeline.report_charts`` 中场景分组图文件名规则一致(勿改格式)。""" + raw = (group or "").strip() + core = re.sub(r"[^\w\u4e00-\u9fff-]", "", raw)[:20] + if not core: + core = "group" + return f"i{index:02d}_{core}" + + +def _scenario_group_usage_bar_filename(group: str, index: int) -> str: + """场景/用途按细类条形图(横轴为占有效文本比例 %,与报告表格口径一致)。""" + slug = _scenario_group_asset_slug(group, index) + return f"chart_usage_scenarios_bar__{slug}.png" + + +def _focus_keywords_group_bar_filename(group: str, index: int) -> str: + slug = _scenario_group_asset_slug(group, index) + return f"chart_focus_keywords_bar__{slug}.png" + + +def _lines_4_reading_brand( + *, + cr1: float | None, + cr3: float | None, + top: str, + brand_rows_n: int, + n_structure: int, +) -> list[str]: + if cr1 is None or not (top or "").strip(): + return [] + lines = [ + "", + "**数据解读(规则摘要)**:", + "", + f"- 在含品牌字段的 **{brand_rows_n}** 条列表行(占本章结构样本 **{n_structure}** 行)中," + f"「{_md_cell(top.strip(), 36)}」曝光约占 **{100 * cr1:.1f}%**(按行计,同一 SKU 多行会重复计)。", + ] + if cr3 is not None: + lines.append( + f"- 前三品牌合计约 **{100 * cr3:.1f}%**;若该比例偏高,说明搜索页品牌集中度高," + "新品需搭配清晰的差异定位与资源投放,避免与头部在泛词下正面撞车。" + ) + lines.append("") + return lines + + +def _lines_4_reading_shop( + *, + cr1: float | None, + cr3: float | None, + top: str, + shop_rows_n: int, + n_structure: int, +) -> list[str]: + if not shop_rows_n: + return [] + lines = [ + "", + "**数据解读(规则摘要)**:", + "", + f"- 含店铺名的列表行共 **{shop_rows_n}** 条(结构样本 **{n_structure}** 行),反映搜索曝光下的店铺格局。", + ] + if cr1 is not None and (top or "").strip(): + lines.append( + f"- 第一大店铺「{_md_cell(top.strip(), 40)}」约占 **{100 * cr1:.1f}%**;" + "该指标刻画的是**列表可见度**而非销量,适合用于判断货架被哪些店铺占据。" + ) + if cr3 is not None: + lines.append( + f"- 前三店铺合计约 **{100 * cr3:.1f}%**;若集中度高,可考虑从店铺矩阵、旗舰店/专营店布局等角度拆解竞争。" + ) + lines.append("") + return lines + + +def _lines_4_reading_category( + cm_structure: list[tuple[str, int]], +) -> list[str]: + if not cm_structure: + return [] + total = sum(c for _, c in cm_structure) + if total <= 0: + return [] + top_lbl, top_c = cm_structure[0] + share = top_c / total + lines = [ + "", + "**数据解读(规则摘要)**:", + "", + f"- 列表侧可读类目/简称共 **{len(cm_structure)}** 种取值,合计 **{total}** 行;" + f"其中「{_md_cell(top_lbl, 40)}」行数最多,约占 **{100 * share:.1f}%**。", + "- 若头部类目占比极高,说明当前关键词下货架被少数品类定义;跨品类机会需结合商详矩阵(§5)再核对。", + "", + "| 类目/简称(Top 5) | 列表行数 | 占本章结构样本 |", + "| --- | ---: | ---: |", + ] + for lbl, cnt in cm_structure[:5]: + lines.append( + f"| {_md_cell(lbl, 36)} | {cnt} | {100 * cnt / total:.1f}% |" + ) + lines.append("") + return lines + + +def build_competitor_markdown( + *, + run_dir: Path, + keyword: str, + merged_rows: list[dict[str, str]], + search_export_rows: list[dict[str, str]], + comment_rows: list[dict[str, str]], + meta: dict[str, Any] | None, + report_config: dict[str, Any] | None = None, + llm_sentiment_section_md: str | None = None, +) -> str: + focus_words, scenario_groups, external_rows = resolve_report_tuning(report_config) + sku_header = "SKU(skuId)" + title_h = "标题(wareName)" + batch = _run_batch_label(run_dir) + n_sku = len(merged_rows) + n_cmt = len(comment_rows) + + list_export = len(search_export_rows) > 0 + structure_rows = search_export_rows if list_export else merged_rows + n_structure = len(structure_rows) + shops_s = _structure_shops(structure_rows, list_export=list_export) + brands_s = _structure_brands(structure_rows, list_export=list_export) + cr1_shop, cr3_shop, top_shop_s, _ = _brand_cr(shops_s) + cr1_list_brand, cr3_list_brand, top_list_brand, _ = _brand_cr(brands_s) + cm_structure = _structure_category_mix(structure_rows, list_export=list_export) + min_brand_rows = max(5, int(0.02 * n_structure)) if n_structure else 5 + + brands_deep = [_cell(r, "detail_brand") for r in merged_rows if _cell(r, "detail_brand")] + cr1_deep, cr3_deep, top_brand_deep, _top_share_deep = _brand_cr(brands_deep) + cr1_hints = ( + cr1_shop if list_export and cr1_shop is not None else cr1_deep + ) + + pst_merged = _price_stats_extended(_collect_prices(merged_rows)) + pst_list = ( + _price_stats_extended(_collect_prices(search_export_rows)) + if list_export + else {} + ) + # 价格分析(§2 要点、§6、策略提示):优先「列表全量」;无列表或无解析价时再用合并表深入样本 + pst = ( + pst_list + if list_export and pst_list.get("n", 0) > 0 + else pst_merged + ) + price_analysis_basis_cn = ( + f"PC 搜索列表导出共 **{len(search_export_rows)}** 行中的展示价(标价/券后等)" + if list_export and pst_list.get("n", 0) > 0 + else f"已深入抓取的 **{n_sku}** 个 SKU 合并数据中的展示价" + ) + + hits = _comment_keyword_hits(comment_rows, focus_words) + if not hits: + blob = _merge_comment_previews(merged_rows) + for w in focus_words: + if len(w) < 2: + continue + n = blob.count(w) + if n: + hits[w] += n + + comment_texts = _iter_comment_text_units(comment_rows, merged_rows) + sentiment_lex = _comment_sentiment_lexicon(comment_texts) + scen_counts, scen_n_texts = _comment_scenario_counts( + comment_texts, scenario_groups + ) + + feedback_groups = _consumer_feedback_by_matrix_group( + merged_rows=merged_rows, + comment_rows=comment_rows, + sku_header=sku_header, + ) + matrix_groups_for_exec = _merged_rows_grouped_for_matrix(merged_rows) + multi_feedback_cat = len(matrix_groups_for_exec) >= 2 + + ( + api_rc, + api_list_kw, + api_rc_uniques, + api_raw_json_n, + api_rc_n_values, + ) = _pc_search_result_count_from_raw(run_dir) + + has_external_market = bool(external_rows) + + lines: list[str] = [ + f"# 竞品分析报告(京东 PC 渠道)", + "", + f"> **监测主题**:{keyword} ", + f"> **数据批次**:{batch} ", + f"> **报告生成**:自动化草稿,**仅供内部研讨**,不构成市场承诺或投资建议。", + "", + "---", + "", + "## 一、研究范围、数据来源与局限", + "", + "### 1.1 研究范围", + "", + f"- **搜索关键词**:「{keyword}」", + f"- **分析对象**:流水线拉取的 **{n_sku}** 个 SKU(搜索排序靠前子样本,非全站普查)。", + ] + if meta: + lines.append( + f"- **搜索列表页**:逻辑第 **{meta.get('page_start')}** 页至第 **{meta.get('page_to')}** 页;" + f"搜索导出共 **{meta.get('pc_search_export_rows', '—')}** 行(含未深入拉详情的商品)。" + ) + lines.extend( + [ + "", + "### 1.2 数据来源", + "", + "- **渠道**:京东 PC 端公开商品列表、商详与评价等可访问数据。", + "- **可追溯**:原始表格与接口响应保存在本批次任务输出目录,供内部复核;对外分享请脱敏。", + "", + "### 1.3 方法说明(指标含义)", + "", + "- **价格**:自页面「标价 / 券后价 / 详情价」等抽取的**展示价**,含促销与规格差异,**不等于**出厂价或成本。**第六章** 在具备可用的搜索列表导出时,优先以**列表全量**统计;否则使用**已深入 SKU** 的合并数据。", + "- **品牌/店铺集中度(第四章)**:有列表全量时按列表行计店铺与品牌占比;无列表导出时按深入 SKU 合并表估算。", + "- **评价主题词**:对评价正文做**预设词表子串计数**,非分词主题模型,适合扫方向,**需抽样人工验证**。", + "- **用途/场景**:对每条评价独立判断是否命中预设场景词;一条可计入多个场景,统计的是「提及该场景的评价条数」而非用户数。", + "- **用户画像(第八章)**:正负面粗判含**口语短语**级摘录;关注词与场景**仅按细类**以条形图展示(场景图为**占该细类有效文本比例 %**);见 §8.3~8.4。", + "- **检索结果规模**:来自京东 PC 搜索返回的「结果条数」类指标,表示平台侧申报的匹配数量级,**不等于**动销、库存或独立 SKU 数。", + "", + "### 1.4 主要局限", + "", + "- 仅覆盖 **京东 PC**,不含天猫、抖音、线下、B2B 原料端。", + "- 样本量由本次抓取上限与搜索页数决定,**结论外推需谨慎**。", + "- 详情配料与宣称以页面展示为准,**与真实配方可能不一致**(合规与实测另议)。", + ( + "- **行业零售额、TAM、CAGR 等**:无法从本批次数据推导;本报告已纳入任务中配置的第三方摘录,见 **§3.5**。" + if has_external_market + else "- **行业零售额、TAM、CAGR 等**:无法从本批次数据推导;本报告未纳入外部摘录(可在任务报告调参中维护市场信息表)。" + ), + "", + "---", + "", + "## 二、执行摘要(要点)", + "", + ] + ) + + exec_bullets: list[str] = [] + exec_bullets.append( + f"在关键词「{keyword}」下,本次深入分析 **{n_sku}** 个 SKU,关联评价文本 **{n_cmt}** 条。" + ) + if list_export and cr1_shop is not None and top_shop_s: + src = f"列表全量 **{n_structure}** 行" + if cr3_shop is not None: + exec_bullets.append( + f"竞争结构({src},§4):**店铺** 第一大店铺份额 ≈ **{100 * cr1_shop:.1f}%**(「{top_shop_s}」)," + f"前三店铺合计份额 ≈ **{100 * cr3_shop:.1f}%**(按列表行计,同一 SKU 多行会重复计)。" + ) + else: + exec_bullets.append( + f"竞争结构({src},§4):**店铺** 第一大店铺份额 ≈ **{100 * cr1_shop:.1f}%**(「{top_shop_s}」)。" + ) + elif not list_export and cr1_deep is not None and top_brand_deep: + if cr3_deep is not None: + exec_bullets.append( + f"竞争结构(无列表导出,§4 用深入合并表):**品牌** 第一大品牌份额 ≈ **{100 * cr1_deep:.1f}%**(「{top_brand_deep}」)," + f"前三品牌合计份额 ≈ **{100 * cr3_deep:.1f}%**。" + ) + else: + exec_bullets.append( + f"竞争结构(无列表导出,§4 用深入合并表):**品牌** 第一大品牌份额 ≈ **{100 * cr1_deep:.1f}%**(「{top_brand_deep}」)。" + ) + if ( + list_export + and len(brands_s) >= min_brand_rows + and cr1_list_brand is not None + and top_list_brand + ): + if cr3_list_brand is not None: + exec_bullets.append( + f"同批列表中**品牌信息有效** **{len(brands_s)}** 条:**品牌** 第一大品牌份额 ≈ **{100 * cr1_list_brand:.1f}%**(「{top_list_brand}」)," + f"前三品牌合计份额 ≈ **{100 * cr3_list_brand:.1f}%**。" + ) + else: + exec_bullets.append( + f"同批列表中**品牌信息有效** **{len(brands_s)}** 条:**品牌** 第一大品牌份额 ≈ **{100 * cr1_list_brand:.1f}%**(「{top_list_brand}」)。" + ) + elif list_export and cr1_deep is not None and top_brand_deep and not brands_s: + exec_bullets.append( + f"列表导出缺少品牌标题字段,**深入 {n_sku} SKU** 商详品牌第一大品牌份额 ≈ **{100 * cr1_deep:.1f}%**(「{top_brand_deep}」),供与 §5 矩阵对照。" + ) + if pst: + price_src_short = ( + "(列表全量)" + if list_export and pst_list.get("n", 0) > 0 + else "(深入样本)" + ) + exec_bullets.append( + f"展示价格{price_src_short}:可解析价格 **{pst['n']}** 个观测,区间约 **{pst['min']:.2f}~{pst['max']:.2f}** 元," + f"中位数 **{pst.get('median', pst['mean']):.2f}** 元。" + ) + if multi_feedback_cat and (hits or scen_n_texts > 0): + exec_bullets.append( + "评价侧写(关注词、用途/场景)已按 **§5 同款细类** 分节,见 **§8.3~8.4**。" + ) + elif hits: + top3 = "、".join(f"「{w}」({n})" for w, n in hits.most_common(3)) + exec_bullets.append(f"评价侧写(词频):{top3}。") + if scen_n_texts > 0 and scen_counts and not multi_feedback_cat: + top_s = scen_counts.most_common(4) + frag = ";".join(f"{lbl} **{n}** 条" for lbl, n in top_s) + exec_bullets.append(f"用途/场景(评价自述,可多选):{frag}(有效文本 **{scen_n_texts}** 条)。") + if api_rc is not None: + exec_bullets.append( + f"PC 搜索返回的检索结果规模约 **{api_rc:,}**(站内匹配条数量级,见 §3.2;非零售额口径)。" + ) + for b in exec_bullets: + lines.append(f"- {b}") + if not exec_bullets: + lines.append("- 当前批次可汇总要点较少(以正文各节实际输出为准)。") + + proxy = _search_list_proxies(search_export_rows) if search_export_rows else {} + lines.extend(["", "---", "", "## 三、整体市场观察(渠道可见度 proxy · 非官方规模)", ""]) + lines.extend( + [ + "### 3.1 与「市场规模」的区别", + "", + "- **官方/行业市场规模**(如全国零售额、品类增速、渗透率)通常来自 **Euromonitor、行业协会、上市公司年报、券商研报** 等;**不能**用京东搜索返回条数或 SKU 数直接等同。", + "- **§3.2** 使用搜索接口返回的**检索结果规模**字段;**§3.3~3.4** 描述本次导出的列表行、去重 SKU/店铺及列表价,用作 **proxy(参照)**,外推全市场需谨慎。", + "", + "### 3.2 接口返回的检索规模", + "", + ] + ) + if api_rc is not None: + lines.append( + f"- 根据本批次保存的搜索原始响应解析:监测词「**{keyword}**」下,平台申报的检索匹配规模约 **{api_rc:,}**。" + ) + if api_list_kw: + lines.append( + f"- 同批响应中的列表关键词:**{api_list_kw}**(可与监测词对照是否一致)。" + ) + if len(api_rc_uniques) > 1: + nums = "、".join(f"{u:,}" for u in api_rc_uniques) + lines.append( + f"- 注:多份原始响应中该规模字段曾出现不同取值({nums}),正文取**众数** **{api_rc:,}**(共 {api_rc_n_values} 次有效读取)。" + ) + elif api_raw_json_n > 0: + lines.append( + f"- 已扫描 **{api_raw_json_n}** 份原始响应并完成读取。" + ) + lines.extend( + [ + "- **含义**:平台对该关键词给出的**检索匹配条数量级**,用于感受站内商品池「宽度」;可能含不同类目/规格条目,**不等于**独立 SKU 数、动销或 GMV,且会随索引与运营策略变化。", + "", + ] + ) + else: + lines.append( + "*未能从本批次搜索原始响应中解析到有效的检索规模字段(目录缺失、无可用响应或字段为空)。*" + ) + lines.append("") + + lines.extend(["### 3.3 搜索列表规模(本次抓取范围内的可见 SKU / 店铺)", ""]) + if proxy.get("total_rows", 0) > 0: + pmin, pmax = proxy.get("page_span") or (None, None) + span_txt = ( + f"页码(去重)约 **{pmin}~{pmax}** 页" + if pmin is not None and pmax is not None + else "页码字段缺失或无法解析" + ) + lines.extend( + [ + f"- **列表导出行数**:**{proxy['total_rows']}** 行。", + f"- **去重 SKU 数**:**{proxy['unique_skus']}**;**去重店铺数**:**{proxy['unique_shops']}**;{span_txt}。", + f"- **列表中去重叶子类目代码/片段数**(粗略):**{proxy['unique_leaf_cats']}**(同一关键词下品类宽度 proxy)。", + "", + ] + ) + lpst = proxy.get("list_price_stats") or {} + lines.extend(["### 3.4 列表端展示价(全导出,非仅深入样本)", ""]) + if lpst: + lines.extend( + [ + f"- 自列表「标价 / 券后价」解析到 **{lpst['n']}** 个数值价;" + f"区间约 **{lpst['min']:.2f}~{lpst['max']:.2f}** 元," + f"中位数 **{float(lpst.get('median', lpst['mean'])):.2f}** 元。", + "- **说明**:第六章价格统计表已与上表同源(均为列表全量,条件满足时);若正文第六章标注为合并表样本,则因无可用列表价而退化。深入 SKU 的详情价可与列表价对照。", + "", + ] + ) + else: + lines.append("*列表导出中未能解析出数值价格。*") + lines.append("") + else: + lines.append( + "*未读到可用的搜索列表导出或文件为空;§3.3~3.4 无列表侧数据。*" + ) + lines.append("") + lines.extend(["### 3.4 列表端展示价(全导出)", "", "*无列表数据。*", ""]) + + if external_rows: + lines.extend( + [ + "### 3.5 外部市场规模与行业信息(运行配置摘录)", + "", + "以下为本次任务报告调参中维护的**第三方市场摘录**,可与 §3.2 检索规模及 §3.3~3.4 列表参照对照使用;口径与真实性以原出处为准。", + "", + "| 指标 | 数值与口径 | 来源 | 年份 |", + "| --- | --- | --- | --- |", + ] + ) + for a, b, c, d in external_rows: + lines.append( + f"| {_md_cell(a, 40)} | {_md_cell(b, 48)} | {_md_cell(c, 36)} | {_md_cell(d, 12)} |" + ) + lines.append("") + + ch4_heading = ( + "## 四、市场与竞争结构(PC 搜索列表全量)" + if list_export + else "## 四、市场与竞争结构(深入合并表 · 无列表导出)" + ) + lines.extend(["", "---", "", ch4_heading, ""]) + if list_export: + lines.append( + f"基于**搜索列表导出**共 **{n_structure}** 行,与 §3.3 一致;" + f"集中度按**列表行**计数(同一 SKU 多次曝光则重复计)。" + ) + else: + lines.append( + f"*未读到可用列表全量行,以下退化为**深入 SKU 合并样本** **{n_structure}** 行。*" + ) + lines.append("") + + lines.extend(["### 4.1 品牌分布与集中度", ""]) + brand_rows_n = len(brands_s) + show_list_brand_cr = list_export and brand_rows_n >= min_brand_rows + show_merged_brand_cr = not list_export and brand_rows_n > 0 + if (show_list_brand_cr or show_merged_brand_cr) and cr1_list_brand is not None: + lines.append("| 指标 | 数值 |") + lines.append("| --- | --- |") + lines.append(f"| 含品牌字段的列表行数 | {brand_rows_n} |") + lines.append( + f"| 第一大品牌份额(按行计) | {100 * cr1_list_brand:.1f}%({_md_cell(top_list_brand, 36)}) |" + ) + if cr3_list_brand is not None: + lines.append(f"| 前三品牌合计份额(按行计) | {100 * cr3_list_brand:.1f}% |") + lines.append("") + lines.extend( + _embed_chart( + run_dir, + "chart_brand_rows_pie.png", + "品牌列表曝光占比(扇形图,Top 段合并为「其他」;与上表集中度同源)", + ) + ) + lines.extend( + _lines_4_reading_brand( + cr1=cr1_list_brand, + cr3=cr3_list_brand, + top=top_list_brand or "", + brand_rows_n=brand_rows_n, + n_structure=n_structure, + ) + ) + lines.append( + "*更细行数分布见结构化摘要 JSON 的 ``list_brand_mix_top``。*" + ) + elif list_export: + lines.append( + f"*列表导出中店铺/品牌标题有效 **{brand_rows_n}** 条," + f"低于建议阈值(≥{min_brand_rows}),品牌集中度未展开。**店铺结构见 §4.2**;" + f"商详品牌在 **§5**。*" + ) + else: + lines.append("*深入子样本无可用品牌字段。*") + lines.append("") + + lines.extend(["### 4.2 店铺分布与集中度", ""]) + shop_rows_n = len(shops_s) + if shops_s: + lines.append("| 指标 | 数值 |") + lines.append("| --- | --- |") + lines.append(f"| 含店铺名的行数 | {shop_rows_n} |") + if cr1_shop is not None and top_shop_s: + lines.append( + f"| 第一大店铺份额(按行计) | {100 * cr1_shop:.1f}%({_md_cell(top_shop_s, 40)}) |" + ) + if cr3_shop is not None: + lines.append(f"| 前三店铺合计份额(按行计) | {100 * cr3_shop:.1f}% |") + lines.append("") + lines.extend( + _embed_chart( + run_dir, + "chart_shop_rows_pie.png", + "店铺列表曝光占比(扇形图;与上表同源)", + ) + ) + lines.extend( + _lines_4_reading_shop( + cr1=cr1_shop, + cr3=cr3_shop, + top=top_shop_s or "", + shop_rows_n=shop_rows_n, + n_structure=n_structure, + ) + ) + lines.append( + "*更细店铺行数分布见结构化摘要 ``list_shop_mix_top``。*" + ) + else: + lines.append("*无店铺字段。*") + lines.append("") + + lines.extend(["### 4.3 类目/叶子类目(列表字段 Top)", ""]) + if cm_structure: + lines.extend( + _embed_chart( + run_dir, + "chart_category_mix_pie.png", + "类目/可读名称分布(扇形图;已用列表「简称」替代裸类目码)", + ) + ) + lines.extend(_lines_4_reading_category(cm_structure)) + lines.append( + "*完整类目行数见结构化摘要 ``category_mix_top``。*" + ) + else: + lines.append("*无类目列或无法解析。*") + lines.append("") + + lines.extend( + [ + "---", + "", + "## 五、竞品对比矩阵(按细分类目分组)", + "", + "优先按商详**类目路径**列分组:**三级路径**取中间一段(如 … > **饼干** > 粗粮饼干)," + "**四级及以上**取倒数第二段(如 … > **面条** > 挂面)。若该列为空,退化为搜索列表中的类目或规格属性;仍无则「未归类」。全量合并模式下另有更多商详字段可供核对。", + "", + "维度说明:**产品**(标题/规格)、**价格**(列表展示)、**渠道**(京东店铺)、**推广**(卖点/榜单文案)、" + "**类目**、**配料表**(见下)、**声量**(评价量与摘要)。", + "", + "**配料表**:优先使用配料正文列(开启配料视觉解析时为识别出的文字);" + "仅有详情长图链接时列内会提示;若商详参数含「配料/配料表:」则摘录该段。" + "均为页面信息摘录,**以包装实物与法规标签为准**。", + "", + ] + ) + matrix_header = [ + "| SKU | 产品(标题) | 品牌 | 标价 | 详情价 | 渠道(店铺) | 推广(卖点) | 榜单/标签 | 类目 | 配料表 | 评价量(搜索) | 消费者反馈摘要 |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + grouped_matrix = _merged_rows_grouped_for_matrix(merged_rows) + if not grouped_matrix: + lines.append("*无合并表 SKU。*") + lines.append("") + for gname, grows in grouped_matrix: + lines.append(f"### {gname}(**{len(grows)}** 款)") + lines.append("") + lines.extend(matrix_header) + grows_sorted = sorted(grows, key=lambda r: _cell(r, sku_header) or "") + for row in grows_sorted: + lines.append( + _competitor_matrix_md_line( + row, sku_header=sku_header, title_h=title_h + ) + ) + lines.append("") + + ch6_price_title = ( + "## 六、价格分析(PC 搜索列表全量)" + if list_export and pst_list.get("n", 0) > 0 + else "## 六、价格分析(深入 SKU 合并表 · 无可用列表价或未导出列表)" + ) + lines.extend(["---", "", ch6_price_title, ""]) + lines.append(f"- **统计基础**:{price_analysis_basis_cn}。") + if ( + list_export + and pst_list.get("n", 0) > 0 + and pst_merged.get("n", 0) > 0 + ): + lines.append( + f"- **对照**:合并表深入样本可解析价 **{pst_merged['n']}** 个观测,中位数约 **{float(pst_merged.get('median', pst_merged['mean'])):.2f}** 元(与上表样本范围不同,仅作对照)。" + ) + lines.append("") + if pst: + price_tbl = [ + "| 统计量 | 数值(元) | 说明 |", + "| --- | --- | --- |", + f"| 样本量 | {pst['n']} | 与统计基础一致 |", + f"| 最小值 | {pst['min']:.2f} | |", + ] + if "q1" in pst: + price_tbl.append(f"| 下四分位 Q1 | {float(pst['q1']):.2f} | |") + else: + price_tbl.append("| 下四分位 Q1 | — | 样本不足 4 个 |") + price_tbl.append( + f"| 中位数 | {float(pst.get('median', pst['mean'])):.2f} | |" + ) + if "q3" in pst: + price_tbl.append(f"| 上四分位 Q3 | {float(pst['q3']):.2f} | |") + else: + price_tbl.append("| 上四分位 Q3 | — | 样本不足 4 个 |") + price_tbl.extend( + [ + f"| 最大值 | {pst['max']:.2f} | |", + f"| 均值 | {pst['mean']:.2f} | |", + ] + ) + if "stdev" in pst: + price_tbl.append(f"| 标准差 | {pst['stdev']:.2f} | 离散程度 |") + lines.extend(price_tbl) + lines.append("") + lines.append( + "**解读提示**:价差大通常反映规格、组合装、品牌溢价或促销差异;B 端定价策略需结合成本与渠道单独建模。" + ) + else: + lines.append("*当前样本无可用数值价格,本节不展开统计表。*") + lines.append("") + + attrs: list[str] = [] + for row in merged_rows: + a = _cell(row, "detail_product_attributes") + if a and a not in attrs: + attrs.append(a) + lines.extend(["---", "", "## 七、产品与宣称(商详参数摘要)", ""]) + if attrs: + for i, a in enumerate(attrs[:12], 1): + lines.append(f"{i}. {_md_cell(a, 600)}") + else: + lines.append("*无可用商详参数摘要。*") + lines.append("") + + lines.extend( + [ + "---", + "", + "## 八、消费者反馈与用户画像(按细分类目)", + "", + "### 8.1 方法", + "", + "- **细类划分**:与 **§5 竞品矩阵** 相同,依据商详类目路径解析为「饼干 / 西式糕点 / …」等(规则见 §5 章首说明)。", + "- **归因**:每条评价按其 SKU 对应到深入样本,再映射到该 SKU 所属细类;SKU 不在合并表中的评价单独归入说明性分组。", + "- **正负面粗判(§8.2)**:先以关键词规则与图表做粗分;若任务开启 **llm_comment_sentiment**,可附**大模型对抽样原文的语义归纳**(与规则统计互补)。", + "- **关注词按细类(§8.3)**:对组内评价正文做子串计数并出条形图;若无逐条正文则用该细类下评价摘要列拼接兜底;与配置关注词及联想扩展同源。", + "- **用途/场景按细类(§8.4)**:对组内每条有效文本独立扫描**本次任务生效的场景词组**(来自报告调参或系统默认),一条可属多场景;条形图横轴为**占该细类有效文本比例 %**(多标签下各比例可相加大于 100%)。", + "", + "### 8.2 评价正负面粗判(关键词规则)", + "", + f"- **有效文本条数**:{sentiment_lex.get('text_units', 0)}(与 §8.1 归因口径一致)。", + f"- **偏正向(仅命中正向词表)**:{sentiment_lex.get('positive_only', 0)} 条;" + f"**偏负向(仅命中负向词表)**:{sentiment_lex.get('negative_only', 0)} 条;" + f"**混合(同条兼含正/负词)**:{sentiment_lex.get('mixed_positive_and_negative', 0)} 条;" + f"**中性或空文本**:{sentiment_lex.get('neutral_or_empty', 0)} 条。", + "- **说明**:词表为方向性粗判,讽刺、省略与错别字会导致误判;正式结论请**人工抽样**阅读原文。", + ] + ) + _scope = (sentiment_lex.get("lexeme_scope_note") or "").strip() + if _scope: + lines.append(f"- **词根统计口径**:{_scope}") + lines.extend(["", ""]) + lines.extend( + _embed_chart( + run_dir, + "chart_sentiment_overview_pie.png", + "评价语气四象限占比(扇形图;与上表条数一致)", + ) + ) + lines.extend( + _embed_chart( + run_dir, + "chart_positive_lexemes_bar.png", + "正向评价里**最常出现的口语短语**(在偏正向或混合评价条内统计;条形图)", + ) + ) + lines.extend( + _embed_chart( + run_dir, + "chart_negative_lexemes_bar.png", + "负向评价里**最常出现的口语短语**(在偏负向或混合评价条内统计;条形图)", + ) + ) + pos_h = sentiment_lex.get("positive_tone_lexeme_hits") or [] + neg_h = sentiment_lex.get("negative_tone_lexeme_hits") or [] + if pos_h: + frag = ";".join( + f"「{x.get('word', '')}」{x.get('texts_matched', 0)} 条" + for x in pos_h[:6] + if isinstance(x, dict) + ) + lines.append(f"- **正向语境高频短语(摘要)**:{frag}。") + if neg_h: + frag_n = ";".join( + f"「{x.get('word', '')}」{x.get('texts_matched', 0)} 条" + for x in neg_h[:6] + if isinstance(x, dict) + ) + lines.append(f"- **负向语境高频短语(摘要)**:{frag_n}。") + _llm_s = (llm_sentiment_section_md or "").strip() + if _llm_s: + lines.extend( + [ + "", + "#### 大模型解读(正/负向评价要点)", + "", + "> **说明**:基于与上节**同一分桶规则**抽样的评价原文,由大模型做语义归纳,与关键词条数、条形图**互补**;具体措辞以原评论为准。", + "", + _llm_s, + ] + ) + lines.append("") + lines.extend( + [ + "### 8.3 关注词频次(按细类)", + "", + ] + ) + if not feedback_groups: + lines.append("*无评价数据可归组。*") + lines.append("") + else: + for gi, (gname, cr_g, texts_g) in enumerate(feedback_groups): + n_flat = len(cr_g) + lines.append(f"#### {gname}") + lines.append("") + lines.append( + f"- **本细类逐条评价**:{n_flat} 条;**用于统计的有效文本条数**:{len(texts_g)}。" + ) + lines.append("") + hits_g = _group_keyword_hits(cr_g, texts_g, focus_words=focus_words) + if hits_g: + lines.extend( + _embed_chart( + run_dir, + _focus_keywords_group_bar_filename(gname, gi), + f"「{_md_cell(gname, 24)}」细类 · 关注词子串命中次数(条形图;同一评价可出现多次,为次数而非去重条数)", + ) + ) + else: + lines.append("*该细类无命中或无数文本。*") + lines.append("") + + lines.extend( + [ + "### 8.4 用途与使用场景(按细类)", + "", + "每条评价文本(或兜底预览)独立扫描场景词组;若命中某组内**任一**关键词则该组 +1,同一条可计入多组;" + "**不等于**购买动机调研结论,建议结合原句抽样阅读。", + "", + ] + ) + if not feedback_groups: + lines.append("*无评价数据可归组。*") + lines.append("") + else: + for gi, (gname, cr_g, texts_g) in enumerate(feedback_groups): + scen_g, scen_ng = _comment_scenario_counts(texts_g, scenario_groups) + lines.append(f"#### {gname}") + lines.append("") + if scen_ng <= 0: + lines.append("*该细类下无可用评价正文。*") + lines.append("") + continue + lines.append(f"- **有效评价文本条数**:{scen_ng}") + lines.append("") + if scen_g: + lines.extend( + _embed_chart( + run_dir, + _scenario_group_usage_bar_filename(gname, gi), + f"「{_md_cell(gname, 24)}」细类 · 场景/用途(条形图:**横轴 = 提及条数 ÷ 本细类有效文本条数**," + f"柱尾标注「条数 · 占比」;有效文本 **{scen_ng}** 条)", + ) + ) + for para in _scenario_summary_bullets(scen_g, scen_ng): + lines.append(para) + lines.append("") + else: + lines.append("*未命中预设场景词组。*") + lines.append("") + + lines.extend( + [ + "---", + "", + "## 九、策略与机会提示(假设清单,待验证)", + "", + "以下为基于本批次数据的**规则化提示**,用于内部脑暴与假设生成,**不可替代**定性访谈与渠道调研。", + "", + ] + ) + for h in _strategy_hints( + cr1=cr1_hints, + pst=pst, + hits=hits, + n_comments=n_cmt, + scen_counts=scen_counts, + scen_n_texts=scen_n_texts, + ): + lines.append(f"- {h}") + lines.append("") + + lines.extend( + [ + "---", + "", + "## 附录 A:数据留存说明", + "", + "- 本批次**任务输出目录**内保存:搜索列表导出、深入 SKU 合并表、商详与评价相关表格,以及搜索/商详原始响应与运行参数快照,供内部复核与复算。", + "- 对外演示或转发前请按公司规范做**脱敏**处理。", + "", + "---", + "", + "*本报告由系统自动汇总生成;定稿前请业务交叉核对数据与结论。*", + "", + ] + ) + return "\n".join(lines) + + +def _sanitize_json_numbers(obj: Any) -> Any: + """浮点 NaN/Inf 无法 JSON 序列化,统一转 None 或圆角。""" + if isinstance(obj, float): + if math.isnan(obj) or math.isinf(obj): + return None + return round(obj, 6) + if isinstance(obj, dict): + return {k: _sanitize_json_numbers(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_sanitize_json_numbers(x) for x in obj] + return obj + + +def build_competitor_brief( + *, + run_dir: Path, + keyword: str, + merged_rows: list[dict[str, str]], + search_export_rows: list[dict[str, str]], + comment_rows: list[dict[str, str]], + meta: dict[str, Any] | None, + report_config: dict[str, Any] | None = None, +) -> dict[str, Any]: + """ + 与 ``build_competitor_markdown`` 共用统计口径,输出可 JSON 序列化的结构化竞品摘要(**规则驱动**,无 LLM)。 + """ + focus_words, scenario_groups, _ext = resolve_report_tuning(report_config) + sku_header = "SKU(skuId)" + title_h = "标题(wareName)" + batch = _run_batch_label(run_dir) + n_sku = len(merged_rows) + n_cmt = len(comment_rows) + + list_export = len(search_export_rows) > 0 + structure_rows = search_export_rows if list_export else merged_rows + n_structure = len(structure_rows) + shops_s = _structure_shops(structure_rows, list_export=list_export) + brands_s = _structure_brands(structure_rows, list_export=list_export) + cr1_shop, cr3_shop, top_shop_s, top_shop_share = _brand_cr(shops_s) + cr1_list_brand, cr3_list_brand, top_list_brand, _ = _brand_cr(brands_s) + cm_structure = _structure_category_mix(structure_rows, list_export=list_export) + min_brand_rows = max(5, int(0.02 * n_structure)) if n_structure else 5 + + brands_deep = [ + _cell(r, "detail_brand") for r in merged_rows if _cell(r, "detail_brand") + ] + cr1_deep, cr3_deep, top_brand_deep, top_brand_deep_share = _brand_cr( + brands_deep + ) + cr1_hints = cr1_shop if list_export and cr1_shop is not None else cr1_deep + + pst_merged = _price_stats_extended(_collect_prices(merged_rows)) + pst_list = ( + _price_stats_extended(_collect_prices(search_export_rows)) + if list_export + else {} + ) + pst = ( + pst_list + if list_export and pst_list.get("n", 0) > 0 + else pst_merged + ) + price_stats_source = ( + "pc_search_export_all_rows" + if list_export and pst_list.get("n", 0) > 0 + else "keyword_pipeline_merged" + ) + + hits = _comment_keyword_hits(comment_rows, focus_words) + if not hits: + blob = _merge_comment_previews(merged_rows) + for w in focus_words: + if len(w) < 2: + continue + n = blob.count(w) + if n: + hits[w] += n + + comment_texts = _iter_comment_text_units(comment_rows, merged_rows) + comment_sentiment_lexicon = _comment_sentiment_lexicon(comment_texts) + scen_counts, scen_n_texts = _comment_scenario_counts( + comment_texts, scenario_groups + ) + + ( + api_rc, + api_list_kw, + api_rc_uniques, + api_raw_json_n, + _api_rc_n_values, + ) = _pc_search_result_count_from_raw(run_dir) + + proxy = _search_list_proxies(search_export_rows) if search_export_rows else {} + + hints = _strategy_hints( + cr1=cr1_hints, + pst=pst, + hits=hits, + n_comments=n_cmt, + scen_counts=scen_counts, + scen_n_texts=scen_n_texts, + ) + + matrix_groups: list[dict[str, Any]] = [] + for gname, mrows in _merged_rows_grouped_for_matrix(merged_rows): + items: list[dict[str, str]] = [] + for row in mrows: + items.append( + { + "sku_id": _cell(row, sku_header), + "title": _cell(row, title_h), + "brand": _cell(row, "detail_brand"), + "list_price_show": _cell( + row, "标价(jdPrice,jdPriceText,realPrice)" + ), + "coupon_or_detail_price": _cell( + row, + "券后到手价(couponPrice,subsidyPrice,finalPrice.estimatedPrice,priceShow)", + ), + "detail_price_final": _cell(row, "detail_price_final"), + "shop": _cell( + row, "店铺名(shopName)", "detail_shop_name" + ), + "category": _category_cell(row), + "selling_point": _cell(row, "卖点(sellingPoint)")[:240], + "comment_fuzzy": _cell(row, "评价量(commentFuzzy)"), + } + ) + matrix_groups.append( + {"group": gname, "sku_count": len(items), "skus": items} + ) + + feedback_by_group: list[dict[str, Any]] = [] + usage_scenarios_by_matrix_group: list[dict[str, Any]] = [] + for gi, (gname, cr, tu) in enumerate( + _consumer_feedback_by_matrix_group( + merged_rows=merged_rows, + comment_rows=comment_rows, + sku_header=sku_header, + ) + ): + gh = _group_keyword_hits(cr, tu, focus_words=focus_words) + scen_g, scen_n_g = _comment_scenario_counts(tu, scenario_groups) + slug_fb = _scenario_group_asset_slug(gname, gi) + feedback_by_group.append( + { + "group": gname, + "matrix_group_index": gi, + "chart_slug": slug_fb, + "comment_rows": len(cr), + "effective_comment_text_units": len(tu), + "focus_keyword_hits": [ + {"word": w, "count": n} for w, n in gh.most_common(24) + ], + "scenarios_top": [ + { + "scenario": s, + "count": n, + "share_of_text_units": ( + n / scen_n_g if scen_n_g else 0.0 + ), + } + for s, n in scen_g.most_common(6) + ] + if scen_n_g + else [], + } + ) + if scen_n_g > 0 and scen_g: + usage_scenarios_by_matrix_group.append( + { + "group": gname, + "matrix_group_index": gi, + "chart_slug": slug_fb, + "effective_text_units": scen_n_g, + "scenarios": [ + { + "scenario": s, + "count": int(n), + "share_of_text_units": ( + float(n) / scen_n_g if scen_n_g else 0.0 + ), + } + for s, n in scen_g.most_common() + if n > 0 + ], + } + ) + + meta_slice: dict[str, Any] = {} + if meta: + for k in ( + "page_start", + "page_to", + "max_skus_config", + "pc_search_export_rows", + "merged_rows", + "scenario_filter_enabled", + "merged_csv_mode", + ): + if k in meta: + meta_slice[k] = meta[k] + + list_brand_block: dict[str, Any] | None + if len(brands_s) >= min_brand_rows: + list_brand_block = { + "cr1": cr1_list_brand, + "cr3": cr3_list_brand, + "top_label": top_list_brand, + } + else: + list_brand_block = None + + out: dict[str, Any] = { + "schema_version": 1, + "keyword": keyword, + "batch_label": batch, + "run_dir": str(run_dir.resolve()), + "scope": { + "merged_sku_count": n_sku, + "comment_flat_rows": n_cmt, + "structure_source_rows": n_structure, + "uses_pc_search_list_export": list_export, + }, + "meta": meta_slice or None, + "pc_search_raw": { + "result_count_consensus": api_rc, + "list_keyword": api_list_kw or None, + "result_count_uniques": api_rc_uniques, + "raw_json_files_scanned": api_raw_json_n, + }, + "list_visibility_proxy": proxy, + "concentration": { + "shops_from_list": { + "cr1": cr1_shop, + "cr3": cr3_shop, + "top_label": top_shop_s, + "top_share_pct": top_shop_share, + }, + "list_brand_field": list_brand_block, + "detail_brand_among_merged": { + "cr1": cr1_deep, + "cr3": cr3_deep, + "top_label": top_brand_deep, + "top_share_pct": top_brand_deep_share, + }, + }, + "category_mix_top": [ + {"label": lbl, "count": cnt} for lbl, cnt in cm_structure + ], + "list_brand_mix_top": [ + {"label": k, "count": v} + for k, v in Counter( + b for b in brands_s if (b or "").strip() + ).most_common(24) + ], + "list_shop_mix_top": [ + {"label": k, "count": v} + for k, v in Counter( + s for s in shops_s if (s or "").strip() + ).most_common(24) + ], + "price_stats": pst, + "price_stats_source": price_stats_source, + "price_stats_merged_sample": pst_merged, + "price_stats_list_export": pst_list if list_export else {}, + "comment_focus_keywords": [ + {"word": w, "count": n} for w, n in hits.most_common(24) + ], + "usage_scenarios": [ + { + "scenario": lbl, + "count": n, + "share_of_text_units": ( + n / scen_n_texts if scen_n_texts else 0.0 + ), + } + for lbl, n in scen_counts.most_common(16) + ], + "usage_scenarios_denominator": scen_n_texts, + "usage_scenarios_by_matrix_group": usage_scenarios_by_matrix_group, + "strategy_hints": hints, + "matrix_by_group": matrix_groups, + "consumer_feedback_by_matrix_group": feedback_by_group, + "comment_sentiment_lexicon": comment_sentiment_lexicon, + "notes": [ + "与在线分析报告各章统计口径一致;主题词与场景为预设词表,非 NLP 主题模型。", + "价格来自页面展示字段抽取,含促销与规格差异。", + "comment_sentiment_lexicon 为关键词粗判,非深度学习情感模型。", + ], + } + return _sanitize_json_numbers(out) + + +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 + + existing = _resolve_existing_run_dir(EXISTING_RUN_DIR) + meta_path_early = (existing / kpl.FILE_RUN_META_JSON) if existing else None + meta_early: dict[str, Any] | None = None + if meta_path_early and meta_path_early.is_file(): + try: + meta_early = json.loads(meta_path_early.read_text(encoding="utf-8")) + except json.JSONDecodeError: + meta_early = None + + if existing: + if not existing.is_dir(): + print(f"[竞品报告] EXISTING_RUN_DIR 不是目录: {existing}", file=sys.stderr) + sys.exit(2) + kw = (KEYWORD or "").strip() or _infer_keyword(existing, meta_early) + if not kw: + print( + "[竞品报告] 仅分析已有目录时,请配置 KEYWORD,或保留 run_meta.json 的 keyword," + "或使目录名为 YYYYMMDD_HHMMSS_关键词", + file=sys.stderr, + ) + sys.exit(2) + run_dir = existing + print(f"[竞品报告] 使用已有目录(不抓取): {run_dir}", file=sys.stderr) + else: + kw = (KEYWORD or "").strip() + if not kw: + print("[竞品报告] 全量抓取时请在本文件顶部配置 KEYWORD", file=sys.stderr) + sys.exit(2) + + backup: dict[str, Any] = {} + try: + if OVERRIDE_MAX_SKUS is not None: + backup["MAX_SKUS"] = kpl.MAX_SKUS + kpl.MAX_SKUS = max(1, int(OVERRIDE_MAX_SKUS)) + if OVERRIDE_PAGE_START is not None: + backup["PAGE_START"] = kpl.PAGE_START + kpl.PAGE_START = max(1, int(OVERRIDE_PAGE_START)) + if OVERRIDE_PAGE_TO is not None: + backup["PAGE_TO"] = kpl.PAGE_TO + kpl.PAGE_TO = max(1, int(OVERRIDE_PAGE_TO)) + + print(f"[竞品报告] 关键词={kw!r},开始流水线…", file=sys.stderr) + run_dir = kpl.main(keyword=kw) + finally: + for name, val in backup.items(): + setattr(kpl, name, val) + + merged_path = run_dir / kpl.FILE_MERGED_CSV + comments_path = run_dir / kpl.FILE_COMMENTS_FLAT_CSV + meta_path = run_dir / kpl.FILE_RUN_META_JSON + + _, merged_rows = _read_csv_rows(merged_path) + _, search_export_rows = _read_csv_rows(run_dir / kpl.FILE_PC_SEARCH_CSV) + _, comment_rows = _read_csv_rows(comments_path) + meta: dict[str, Any] | None = meta_early if existing else None + if meta is None and meta_path.is_file(): + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + meta = None + + md = build_competitor_markdown( + run_dir=run_dir, + keyword=kw, + merged_rows=merged_rows, + search_export_rows=search_export_rows, + comment_rows=comment_rows, + meta=meta, + ) + out_md = run_dir / "competitor_analysis.md" + out_md.write_text(md, encoding="utf-8") + print(f"[竞品报告] 运行目录: {run_dir}", file=sys.stderr) + print(f"[竞品报告] 已写: {out_md}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/backend/crawler_copy/jd_pc_search/jd_keyword_pipeline.py b/backend/crawler_copy/jd_pc_search/jd_keyword_pipeline.py new file mode 100644 index 0000000..6ec95f1 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/jd_keyword_pipeline.py @@ -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/_<关键词>/``;非空则用该路径(相对路径相对 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() diff --git a/backend/crawler_copy/jd_pc_search/package-lock.json b/backend/crawler_copy/jd_pc_search/package-lock.json new file mode 100644 index 0000000..8c38fa4 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/package-lock.json @@ -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" + } + } +} diff --git a/backend/crawler_copy/jd_pc_search/package.json b/backend/crawler_copy/jd_pc_search/package.json new file mode 100644 index 0000000..9bd309c --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "crypto-js": "^4.2.0" + } +} diff --git a/backend/crawler_copy/jd_pc_search/scenario_filter.py b/backend/crawler_copy/jd_pc_search/scenario_filter.py new file mode 100644 index 0000000..0f83258 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/scenario_filter.py @@ -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 diff --git a/backend/crawler_copy/jd_pc_search/search/collect_pc_search_items.py b/backend/crawler_copy/jd_pc_search/search/collect_pc_search_items.py new file mode 100644 index 0000000..36e2e6e --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/search/collect_pc_search_items.py @@ -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] diff --git a/backend/crawler_copy/jd_pc_search/search/dump_warelist_skus_to_csv.py b/backend/crawler_copy/jd_pc_search/search/dump_warelist_skus_to_csv.py new file mode 100644 index 0000000..c5ba5b6 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/search/dump_warelist_skus_to_csv.py @@ -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() diff --git a/backend/crawler_copy/jd_pc_search/search/jd_export_search_request.js b/backend/crawler_copy/jd_pc_search/search/jd_export_search_request.js new file mode 100644 index 0000000..bc8b26d --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/search/jd_export_search_request.js @@ -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); +} diff --git a/backend/crawler_copy/jd_pc_search/search/jd_h5_search_requests.py b/backend/crawler_copy/jd_pc_search/search/jd_h5_search_requests.py new file mode 100644 index 0000000..e6a9ee4 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/search/jd_h5_search_requests.py @@ -0,0 +1,2230 @@ +# -*- coding: utf-8 -*- +""" +京东搜索 — requests 版(批量/HTML/粘贴 URL)。 + +与 ``jd_search_playwright.py`` 对齐的请求行 +---------------------------------------- +- **默认**(仅 ``--q``、``--format items``、且无 ``--url`` / ``--url-file`` / ``--api-params-file``): + 与同目录 ``jd_search_playwright.py`` 一样,由 Node ``jd_export_search_request.js`` 生成 + **url + headers**(含 ``get_h5st``),再用 **requests** 发 GET。 + 区别仅 TLS:本脚本为 urllib3,Playwright 为 Chromium;若遇 jfe 403 请改用 ``jd_search_playwright.py``。 +- Cookie:Node ``jd_export_search_request.js`` 默认读 **../common/jd_cookie.txt**;若 Python 传入 ``cookie_file`` 或 CLI 指定 ``--cookie-file``,则 Node 使用对应文件。仅 ``--cookie`` 粘贴字符串时仍由 **requests** 路径使用,与 Node 签包无关。 +- 需要 **search.action HTML** 抽商品时加 ``--h5-html``。 + +与淘宝的差异(重要) +------------------- +- 淘宝列表数据走 **mtop JSON/JSONP**,必须 **t + sign**。 +- H5 **search.action** 易风控;pc 搜索 API 在 **api.m.jd.com**(h5st 由 Node 生成)。 + 默认路径下按抓包规则推进 **body.page** 与 **body.s**(同逻辑页内两包:**page** 为 **2L-1、2L**(L 为 CLI 逻辑页); + **s** 按上一包响应 **max(1, 自然位-1)** 累加,自然位 = ``wareList`` 非显式广告条数)。 + **每逻辑页固定 2 次** pc_search;CSV 列 ``page`` 仍为**逻辑页**;单页入库 SKU 仍可在约 **60** 条处封顶(第二包照常请求以同步游标)。 + ``--page N`` 会先按每逻辑页 2 包跳过前 N-1 页再采当前页。 +- ``--csv`` 列名在淘宝 ``CANONICAL_FIELDS`` 基础上去掉 ``features`` / ``promotion_tags``,另含末尾 ``platform`` / ``keyword`` / ``page``; + 卖点、评价量(``commentFuzzy``)、``commentSalesFloor`` 等为独立列,字段随接口结构略有差异。 + +功能 +---- +- --url / --url-file / --api-params-file:三选一(与仅 --q 的默认 pc API 路径互斥) +- --q + items(默认):pc API(同 Playwright 的 URL/Header) +- --q + --h5-html:so.m.jd.com search.action HTML + +用法 +---- + cd crawler/jd_pc_search/search + python jd_h5_search_requests.py --q 低GI --page 1 --csv --out ../../data/jd_h5_p1.csv + + python jd_h5_search_requests.py --url "https://api.m.jd.com/..." +""" + +from __future__ import annotations + +import argparse +import csv +import html as html_module +import json +import os +import random +import re +import subprocess +import sys +import time +from io import StringIO +from pathlib import Path +from typing import Any +from urllib.parse import urlencode, urlparse, parse_qs, urlunparse + +import requests + +_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, sleep_pc_search_request_gap + +_JD_PC_SEARCH_DIR = Path(__file__).resolve().parent + +# PC 搜索:与浏览器一致,**每逻辑页固定 2 包** pc_search(body.page 连续 +1:第 L 页为 2L-1 与 2L)。 +# **Δs = max(1, 自然位条数-1)**(wareList 非广告计自然位)。跳过前序屏时同样每逻辑页 2 包推进游标。 +# CLI 的 --page 是**逻辑页**,不是请求 body.page(避免混淆)。 +JD_PC_SEARCH_ITEMS_PER_PAGE = 60 +JD_PC_SEARCH_CHUNKS_PER_LOGICAL_PAGE = 2 +# 重试仍失败时推进 s 的兜底(与常见两包 Δs≈21~22 一致) +JD_PC_SEARCH_FALLBACK_S_STEP = 22 + + +def pc_search_response_is_empty_ware_list(body: str) -> bool: + """合法 JSON 且 ``data.wareList`` 为明确空数组时视为「已无更多商品」,不宜再强推游标。""" + p = _loads_json_or_jsonp((body or "").strip()) + if not isinstance(p, dict): + return False + data = p.get("data") + return isinstance(data, dict) and data.get("wareList") == [] + + +def pc_search_should_retry_fetch( + body: str, *, has_rows: bool, s_step: int +) -> bool: + """ + 是否应对同一 body.page / s 重发请求。 + 空体、非 JSON 短包等视为瞬时故障;明确的 ``data.wareList == []`` 不重试。 + """ + if has_rows or s_step > 0: + return False + s = (body or "").strip() + if not s: + return True + p = _loads_json_or_jsonp(s) + if p is None: + return len(s) < 12000 + if not isinstance(p, dict): + return False + data = p.get("data") + if isinstance(data, dict) and data.get("wareList") == []: + return False + return True + + +def jd_pc_api_body_page_first_pack(logical_page_1based: int) -> int: + """逻辑页 L(从 1 起)第一包请求里的 body.page = 2L - 1。""" + L = max(1, int(logical_page_1based)) + return 2 * L - 1 + + +def _jd_row_count_for_page(rows: list[dict[str, str]], page: int) -> int: + ps = str(page) + return sum(1 for r in rows if (r.get("page") or "").strip() == ps) + + +def export_pc_search_request_json( + keyword: str, + api_body_page: int, + *, + s: int = 1, + pvid: str | None = None, + cookie_file: str | None = None, +) -> dict[str, Any]: + """Node 输出 {url, headers}。api_body_page / s 为请求 body 里的 page、s(与抓包一致,非 CSV 页码)。""" + cmd = [ + "node", + str(_JD_PC_SEARCH_DIR / "jd_export_search_request.js"), + "--q", + keyword, + "--page", + str(api_body_page), + "--s", + str(max(1, int(s))), + ] + pv = (pvid or "").strip() + if pv: + cmd.extend(["--pvid", pv]) + cf = (cookie_file or "").strip() + if cf: + cmd.extend(["--cookie-file", cf]) + r = subprocess.run( + cmd, + cwd=str(_JD_PC_SEARCH_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) + + +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" +) + +# --ua-preset:H5 search.action 遇「去 APP」风控时可换移动端 UA 试一次(不保证有效)。 +UA_PRESETS: dict[str, str] = { + "chrome_win": USER_AGENT, + "iphone_m": ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " + "(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" + ), + "android_m": ( + "Mozilla/5.0 (Linux; Android 13; Pixel 6) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36" + ), +} + +# 在此粘贴浏览器请求头里的完整 Cookie(一整行)。也可改用 ../common/jd_cookie.txt。 +MY_COOKIE = r""" + +""".strip() + + +def _is_h5_ware_search_url(url: str) -> bool: + u = urlparse(url.strip()) + return "so.m.jd.com" in (u.netloc or "") and "search.action" in (u.path or "") + + +def _is_jd_api_url(url: str) -> bool: + u = urlparse(url.strip()) + n, p = u.netloc or "", u.path or "" + return "api.m.jd.com" in n or ("jd.com" in n and "client.action" in p) + + +def build_request_headers( + cookie: str, + *, + request_url: str, + ua_preset: str, + referer_override: str | None = None, +) -> dict[str, str]: + ua = UA_PRESETS.get(ua_preset, UA_PRESETS["chrome_win"]) + is_mobile = ua_preset in ("iphone_m", "android_m") + is_api = _is_jd_api_url(request_url) + url_l = request_url.lower() + # 你抓包这类属于 search.jd.com 触发的 pc_search_searchWare + is_search_pc_api = ("appid=search-pc-java" in url_l) or ("client=pc" in url_l) or ( + "functionid=pc_search_searchware" in url_l + ) + + h: dict[str, str] = { + "User-Agent": ua, + "Accept-Language": "zh-CN,zh;q=0.9", + "Accept-Encoding": "gzip, deflate", + "Cache-Control": "no-cache", + "Pragma": "no-cache", + } + if is_api: + # XHR / client.action 常见头(与桌面 HTML 导航略有区别) + h["Accept"] = "application/json, text/plain, */*" + if is_search_pc_api: + ref = (referer_override or "").strip() + if not ref: + ref = "https://search.jd.com/Search" + h["Referer"] = ref + h["Origin"] = "https://search.jd.com" + h["x-referer-page"] = "https://search.jd.com/Search" + h["x-rp-client"] = "h5_1.0.0" + h["Priority"] = "u=1, i" + else: + h["Referer"] = "https://so.m.jd.com/" + h["Origin"] = "https://so.m.jd.com" + h["sec-fetch-dest"] = "empty" + h["sec-fetch-mode"] = "cors" + h["sec-fetch-site"] = "same-site" + else: + h["Accept"] = ( + "text/html,application/xhtml+xml,application/xml;q=0.9," + "image/avif,image/webp,image/apng,*/*;q=0.8" + ) + h["Upgrade-Insecure-Requests"] = "1" + h["Referer"] = "https://so.m.jd.com/" + h["sec-fetch-dest"] = "document" + h["sec-fetch-mode"] = "navigate" + h["sec-fetch-site"] = "same-site" + h["sec-fetch-user"] = "?1" + + if is_mobile: + h["sec-ch-ua-mobile"] = "?1" + h["sec-ch-ua-platform"] = '"Android"' if ua_preset == "android_m" else '"iOS"' + else: + h["sec-ch-ua"] = '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"' + h["sec-ch-ua-mobile"] = "?0" + h["sec-ch-ua-platform"] = '"Windows"' + + if cookie.strip(): + h["Cookie"] = cookie.strip() + return h + + +def _read_cookie_file(path: str) -> str: + """读取 Cookie 文件:忽略空行与 # 注释行;多行非注释内容用 '; ' 拼接。""" + raw = Path(path).read_text(encoding="utf-8") + chunks: list[str] = [] + for line in raw.splitlines(): + s = line.strip() + if not s or s.startswith("#"): + continue + chunks.append(s) + return "; ".join(chunks).strip() + + +def load_cookie(args: argparse.Namespace) -> str: + if getattr(args, "cookie", None) and str(args.cookie).strip(): + return str(args.cookie).strip() + if getattr(args, "cookie_file", None): + path = args.cookie_file + if not os.path.isfile(path): + print(f"Cookie 文件不存在: {path}", file=sys.stderr) + sys.exit(1) + return _read_cookie_file(path) + if MY_COOKIE.strip(): + return MY_COOKIE.strip() + default_txt = Path(__file__).resolve().parent.parent / "common" / "jd_cookie.txt" + if default_txt.is_file(): + return _read_cookie_file(str(default_txt)) + return os.environ.get("JD_COOKIE", "").strip() + + +def _noncomment_lines(text: str) -> list[str]: + out: list[str] = [] + for line in text.splitlines(): + s = line.strip() + if not s or s.startswith("#"): + continue + out.append(s) + return out + + +def parse_jd_api_params_text(text: str) -> list[tuple[str, str]]: + """ + 用于把 DevTools「Query String Parameters」粘成文件后读取。 + + 支持两种格式(同一文件只用一种): + - 每行 key=value(value 可含 =;支持重复键如两个 t) + - 键一行、值一行(偶数行;与部分工具导出格式一致) + """ + lines = _noncomment_lines(text) + if not lines: + return [] + if any("=" in ln for ln in lines): + pairs: list[tuple[str, str]] = [] + for ln in lines: + if "=" not in ln: + raise ValueError(f"混用格式:本行应有 '=': {ln[:80]}") + k, _, v = ln.partition("=") + k, v = k.strip(), v.strip() + if not k: + raise ValueError(f"空的参数名: {ln[:80]}") + pairs.append((k, v)) + return pairs + if len(lines) % 2: + raise ValueError("交替键值格式需要偶数行(键一行、值一行)") + return [(lines[i].strip(), lines[i + 1].strip()) for i in range(0, len(lines), 2)] + + +def build_jd_api_url_from_param_pairs(pairs: list[tuple[str, str]]) -> str: + return "https://api.m.jd.com/api?" + urlencode(pairs, doseq=True) + + +def read_url_list_file(path: str) -> list[str]: + return _noncomment_lines(Path(path).read_text(encoding="utf-8")) + + +# 与淘宝 CANONICAL_FIELDS 基本一致,但不导出 features / promotion_tags(用户侧去重简化); +# 末尾追加 platform / keyword / page。 +JD_ITEM_CSV_FIELDS = ( + "item_id", + "sku_id", + "title", + # "title_plain", + "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", + # "same_count", + # "relation_score", + # "is_p4p", + "platform", + "keyword", + "page", +) + +# 导出列名:中文说明(JSON 中主要原始字段名),便于对照接口 +JD_EXPORT_COLUMN_HEADERS: dict[str, str] = { + "item_id": "主商品ID(wareId)", + "sku_id": "SKU(skuId)", + "title": "标题(wareName)", + # "title_plain": "标题纯文本(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)", + # "video_cover": "视频封面(videoImage,videoPic)", + # "video_dimension": "视频比例(videoRatio)", + "seckill_info": "秒杀(seckillInfo,secKill)", + "attributes": "规格属性(propertyList,color,catid,shortName)", + "leaf_category": "类目(leafCategory,cid3Name,catid)", + # "same_count": "同款数(sameStyleCount,sameCount)", + # "relation_score": "相关度(relationScore,score)", + # "is_p4p": "广告位(isAdv,isAd,extensionId)", + "platform": "平台(platform)", + "keyword": "搜索词(keyword)", + "page": "页码(page)", +} + +CSV_FIELDS = tuple(JD_EXPORT_COLUMN_HEADERS[k] for k in JD_ITEM_CSV_FIELDS) + + +def jd_row_to_export(row: dict[str, str]) -> dict[str, str]: + """内部键 → 导出列名;无内容则不写入该键(JSON 稀疏对象;CSV 缺列按空单元格写出)。""" + out: dict[str, str] = {} + for k in JD_ITEM_CSV_FIELDS: + v = str(row.get(k, "") or "").strip() + if not v: + continue + out[JD_EXPORT_COLUMN_HEADERS[k]] = v + return out + + +def _jd_empty_export_row() -> dict[str, str]: + return {k: "" for k in JD_ITEM_CSV_FIELDS} + + +def _human_text(s: str, max_len: int = 2000) -> str: + if not s: + return "" + t = re.sub(r"<[^>]+>", " ", s) + t = html_module.unescape(t) + t = " ".join(t.split()).strip() + return t[:max_len] + + +def _safe_url(u: str) -> str: + u = (u or "").strip() + if not u: + return "" + if u.startswith("//"): + return "https:" + u + if u.startswith("/"): + return "https://so.m.jd.com" + u + if u.startswith("http://"): + return "https://" + u[7:] + return u + + +# 搜索接口里常见仅返回 jfs/...,需拼到 360buyimg 下(与 PC 列表 n2/s480x480 规格一致;img10–img14 等 CDN 可互换) +_JD_PRODUCT_IMG_HOST = "https://img13.360buyimg.com" + + +def _jd_product_image_url(u: str) -> str: + """ + 主图补全为可访问 URL。 + 例:jfs/t1/402762/.../xxx.jpg → https://img13.360buyimg.com/n2/s480x480_jfs/t1/402762/.../xxx.jpg + """ + u = (u or "").strip() + if not u: + return "" + if u.startswith("//"): + return "https:" + u + if u.startswith("http://"): + return "https://" + u[7:] + if u.startswith("https://"): + return u + low = u.lower() + if "360buyimg.com" in low and not re.match(r"^https?://", u, re.I): + return "https://" + u.lstrip("/") + p = u.lstrip("/") + if p.startswith("jfs/"): + return f"{_JD_PRODUCT_IMG_HOST}/n2/s480x480_{p}" + if re.match(r"^n[0-9]+/", p): + return f"{_JD_PRODUCT_IMG_HOST}/{p}" + return u + + +def build_h5_search_url(keyword: str, page: int) -> str: + """ + 京东 H5 搜索 URL(可从你的示例 URL 还原出默认参数)。 + 分页参数在不同实验中可能是 page / pageNo / p。这里优先使用 page。 + 若你抓包发现分页字段不同,建议直接用 --url 指定每页 URL,或在本函数内改参数名。 + """ + base = "https://so.m.jd.com/ware/search.action" + q = { + "keyword": keyword, + "searchFrom": "home", + "sf": "14", + "as": "0", + "sourceType": "H5_home_page_search", + "page": str(page), + } + return base + "?" + urlencode(q, safe=":%/,+") + + +def _detect_blocked(html_text: str) -> str | None: + if not (html_text and html_text.strip()): + return None + t = html_text.lower() + if "当前人数过多" in html_text or "前往京东app" in t or "前往京东APP" in html_text: + return ( + "服务端限流/风控提示(纯文本)。search.action 易风控;" + "含 h5st 的 api 请用 search/jd_search_playwright.py,或粘贴完整 URL(--url);" + "或 jd_low_gi_playwright.py 整页抓取。" + ) + if "plogin.m.jd.com" in t or "passport.jd.com" in t: + return "疑似被要求登录(跳转到登录域)。" + # pc_search 正常 JSON 里字段名常含 risk/verify 等子串,宽松匹配会误判。 + payload = _loads_json_or_jsonp(html_text) + if payload is not None: + raw_probe: list[dict[str, Any]] = [] + _walk_collect_jd_wares(payload, raw_probe) + if raw_probe: + return None + if re.search( + r"risk\.jd\.com|riskhandler|/risk/|sev\.jd|verify\.jd\.com|" + r"sliderverify|slide_verify|securityverify", + html_text, + re.I, + ): + return "疑似进入风控/验证页(risk/verify)。" + elif "risk" in t and ("handler" in t or "verify" in t): + return "疑似进入风控/验证页(risk/verify)。" + if "验证码" in html_text or "安全验证" in html_text: + return "疑似需要验证码/安全验证。" + return None + + +def strip_jsonp(body: str) -> Any: + """去掉 JSONP 包裹,解析为 Python 对象(与 taobao strip_jsonp 思路一致)。""" + text = body.strip().rstrip(";") + m = re.match(r"^[a-zA-Z_$][a-zA-Z0-9_$]*\((.*)\)\s*$", text, re.DOTALL) + if not m: + raise ValueError("响应不是预期的 JSONP 格式") + return json.loads(m.group(1)) + + +def _loads_json_or_jsonp(text: str) -> Any | None: + s = text.strip() + if not s: + return None + if s.startswith("{") or s.startswith("["): + try: + return json.loads(s) + except json.JSONDecodeError: + return None + try: + return strip_jsonp(s) + except (json.JSONDecodeError, ValueError): + return None + + +JD_SKU_KEYS = ("wareId", "skuId", "itemId", "wid", "productId") +JD_TITLE_KEYS = ("wareName", "wname", "name", "title", "skuName") +# pc_search_searchWare 常见:jdPrice / jdPriceText / realPrice +JD_PRICE_KEYS = ( + "jdPrice", + "jdPriceText", + "realPrice", + "price", + "priceText", + "p", + "salePrice", + "purchasePrice", +) +JD_IMG_KEYS = ("imageurl", "imageUrl", "imgUrl", "picUrl", "image") +JD_ORIGINAL_PRICE_KEYS = ( + "oriPrice", + "originalPrice", + "marketPrice", + "linePrice", + "mPrice", + "maoPrice", + "strikePrice", +) +# finalPrice 为对象,见 _jd_parse_final_price;勿在此放 dict 键名 +JD_COUPON_KEYS = ("couponPrice", "subsidyPrice", "promoPrice") +JD_DETAIL_URL_KEYS = ( + "toUrl", + "clickUrl", + "wareUrl", + "href", + "itemUrl", + "detailUrl", + "skuUrl", + "pUrl", +) +JD_SHOP_URL_KEYS = ("shopUrl", "storeUrl", "shopURL", "jumpUrl") +JD_LOC_KEYS = ("deliveryAddress", "area", "procity", "stockAddress", "sendAddr") +JD_TAG_LIST_KEYS = ( + "wareBeltList", + "beltList", + "labelList", + "tagList", + "wareTags", + "tags", + "icons", + "serviceIcons", + "iconList", + "iconList1", + "iconList2", + "iconList3", + "iconList4", + "textTagList", + "beltAttrs", + "serviceTags", + "benefitList", + "sellPoints", + "commonTagList", +) + +# 单块对象里含 title:[str,…](如 newRegionFloor) +JD_TAG_NEST_DICT_KEYS = ( + "newRegionFloor", + "midTagList", + "paragraphInfo", + "floatLayerInfo", + "drawer", +) + +# JSON 里常见 title:["高膳食纤维","低GI食品"]、title:["礼盒装…热卖榜第1名"] 等字符串数组,需整树收集 +JD_LIST_STRING_TEXT_KEYS = frozenset( + { + "title", + "subtitle", + "subtitles", + "sellpoint", + "sellpoints", + "usp", + "usps", + "textlist", + "textlists", + "wordlist", + "keywords", + "highlight", + "highlights", + "sellingpoint", + "sellingpoints", + "featurelist", + "pointlist", + "points", + "shorttitle", + "shorttitles", + "recommendreason", + "reasonlist", + "icontexts", + "icontext", + "tagtexts", + "descs", + "labels", + } +) + + +def _sval_jd(d: dict[str, Any], keys: tuple[str, ...]) -> str: + for k in keys: + v = d.get(k) + if v is None or isinstance(v, (dict, list)): + continue + t = str(v).strip() + if t: + return t + return "" + + +def _jd_flatten_ware(d: dict[str, Any]) -> dict[str, Any]: + out = dict(d) + for nest_key in ("wareInfo", "product", "skuInfo", "item", "main", "content", "base"): + inner = out.get(nest_key) + if isinstance(inner, dict): + for k, v in inner.items(): + if k not in out or out[k] in (None, "", [], {}): + out[k] = v + ex = out.get("exContent") or out.get("excontent") + if isinstance(ex, dict): + for k, v in ex.items(): + if k not in out or out[k] in (None, "", [], {}): + out[k] = v + return out + + +def _jd_norm_key(s: str) -> str: + """去重用:压缩空白,便于合并「相同文案、空白不同」的重复。""" + return " ".join(str(s).split()).strip() + + +def _jd_unique_ordered(strings: list[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for x in strings: + t = _jd_norm_key(x) + if len(t) < 2: + continue + k = t.casefold() + if k in seen: + continue + seen.add(k) + out.append(t) + return out + + +def _jd_benefit_list_title_lines(d: dict[str, Any]) -> list[str]: + """benefitList[].title:PC 搜索里 sellingPoint 常为 null,卖点多在此。""" + bl = d.get("benefitList") + if not isinstance(bl, list): + return [] + lines: list[str] = [] + for it in bl[:30]: + if not isinstance(it, dict): + continue + t = it.get("title") + if isinstance(t, list): + for x in t[:25]: + if isinstance(x, str) and x.strip(): + lines.append(_jd_norm_key(x)) + elif isinstance(t, str) and t.strip(): + lines.append(_jd_norm_key(t)) + return lines + + +def _jd_sell_points_lines(d: dict[str, Any]) -> list[str]: + """sellPoints:字符串列表或对象列表。""" + sp = d.get("sellPoints") + if not isinstance(sp, list): + return [] + out: list[str] = [] + for x in sp[:25]: + if isinstance(x, str) and x.strip(): + out.append(_jd_norm_key(x)) + elif isinstance(x, dict): + t = _sval_jd(x, ("text", "title", "name", "desc")) + if t: + out.append(_jd_norm_key(t)) + return out + + +def _jd_selling_point_norm_set(d: dict[str, Any]) -> set[str]: + """sellingPoint、benefitList.title、sellPoints 规范化键,用于树遍历去重。""" + out: set[str] = set() + sp = d.get("sellingPoint") + if isinstance(sp, list): + for x in sp: + if isinstance(x, str) and x.strip(): + out.add(_jd_norm_key(x).casefold()) + for t in _jd_benefit_list_title_lines(d): + out.add(t.casefold()) + for t in _jd_sell_points_lines(d): + out.add(t.casefold()) + return out + + +def _jd_iter_tag_strings(d: dict[str, Any]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + sp_dup = _jd_selling_point_norm_set(d) + + def add(s: str, *, skip_if_selling_dup: bool = False) -> None: + t = _jd_norm_key(s) + if len(t) < 2: + return + if skip_if_selling_dup and sp_dup and t.casefold() in sp_dup: + return + k = t.casefold() + if k in seen: + return + seen.add(k) + out.append(t) + + sub_keys = ( + "text", + "name", + "title", + "desc", + "msg", + "beltMsg", + "labelName", + "typeName", + "showName", + "content", + "beltTitle", + ) + for key in JD_TAG_LIST_KEYS: + v = d.get(key) + is_benefit = key == "benefitList" + if isinstance(v, list): + for it in v[:30]: + if isinstance(it, str): + add(it) + elif isinstance(it, dict): + for sk in sub_keys: + t = it.get(sk) + sk_skip = is_benefit and sk == "title" + if isinstance(t, list): + for x in t[:25]: + if isinstance(x, str) and x.strip(): + add(x.strip(), skip_if_selling_dup=sk_skip) + elif isinstance(t, str) and t.strip(): + add(t.strip(), skip_if_selling_dup=sk_skip) + break + elif isinstance(v, dict): + for sk in sub_keys: + t = v.get(sk) + if isinstance(t, list): + for x in t[:25]: + if isinstance(x, str) and x.strip(): + add(x.strip()) + elif isinstance(t, str) and t.strip(): + add(t.strip()) + break + + for key in JD_TAG_NEST_DICT_KEYS: + v = d.get(key) + if not isinstance(v, dict): + continue + tl = v.get("title") + if isinstance(tl, list): + for x in tl[:25]: + if isinstance(x, str) and x.strip(): + add(x.strip(), skip_if_selling_dup=True) + elif isinstance(tl, str) and tl.strip(): + add(tl.strip(), skip_if_selling_dup=True) + + mtl = d.get("midTagList") + if isinstance(mtl, list): + for it in mtl[:25]: + if not isinstance(it, dict): + continue + for sk in ("text", "name", "title", "desc", "msg"): + t = it.get(sk) + if isinstance(t, str) and t.strip(): + add(t.strip()) + break + return out + + +def _jd_collect_list_string_fragments( + root: Any, + *, + selling_norm_skip: set[str] | frozenset[str] | None = None, + max_depth: int = 14, + max_list_len: int = 40, + max_branch_lists: int = 80, +) -> tuple[list[str], list[str]]: + """ + 从整棵 JSON 子树收集「键在 JD_LIST_STRING_TEXT_KEYS 且值为字符串数组」的文案。 + + 返回 (逐条短句, 每组用 · 拼接的整组),供 hot_list_rank 等使用。 + """ + skip_sp = selling_norm_skip or set() + individuals: list[str] = [] + groups: list[str] = [] + seen_ind: set[str] = set() + seen_grp: set[str] = set() + list_walk_count = 0 + + def add_ind(s: str) -> None: + t = _jd_norm_key(s) + if len(t) < 2: + return + k = t.casefold() + if skip_sp and k in skip_sp: + return + if k in seen_ind: + return + seen_ind.add(k) + individuals.append(t) + + def walk(obj: Any, depth: int) -> None: + nonlocal list_walk_count + if depth > max_depth or obj is None: + return + if isinstance(obj, dict): + for k, v in obj.items(): + lk = str(k).lower() + if ( + isinstance(v, list) + and lk in JD_LIST_STRING_TEXT_KEYS + and list_walk_count < max_branch_lists + ): + list_walk_count += 1 + elems: list[str] = [] + for x in v[:max_list_len]: + if isinstance(x, str) and x.strip(): + t = _jd_norm_key(x) + if skip_sp and t.casefold() in skip_sp: + continue + elems.append(t) + add_ind(t) + elif isinstance(x, dict): + t = _sval_jd( + x, + ("text", "name", "title", "desc", "value", "content"), + ) + if t: + t = _jd_norm_key(t) + if skip_sp and t.casefold() in skip_sp: + continue + elems.append(t) + add_ind(t) + if elems: + gline = " · ".join(elems) + gk = gline.casefold() + if gk not in seen_grp: + seen_grp.add(gk) + groups.append(gline) + walk(v, depth + 1) + elif isinstance(obj, list): + for x in obj[:90]: + walk(x, depth + 1) + + walk(root, 0) + return individuals, groups + + +# commentSalesFloor 等 attr+text 格式化时跳过明显非文案类 attr +JD_ATTR_TEXT_SKIP_ATTRS = frozenset( + { + "wareid", + "skuid", + "itemid", + "imageurl", + "imgurl", + "href", + "url", + "cid", + "shopid", + "venderid", + } +) + + +def _jd_is_rank_text(s: str) -> bool: + t = s.strip() + if not t: + return False + if "榜" in t: + return True + if "TOP" in t.upper() and len(t) <= 48: + return True + if re.search(r"第\s*\d+\s*名", t): + return True + if "热销" in t and len(t) <= 36: + return True + return False + + +def _jd_format_price_show_dict(ps: dict[str, Any]) -> tuple[str, str]: + parts: list[str] = [] + coupon = "" + for k, v in ps.items(): + if isinstance(v, str) and v.strip(): + parts.append(f"{k}={v.strip()[:100]}") + elif isinstance(v, (int, float)) and not isinstance(v, bool): + parts.append(f"{k}={v}") + line = " | ".join(parts)[:500] + for ck in ("couponPrice", "purchasePrice", "finalPrice", "subsidyPrice"): + v = ps.get(ck) + if isinstance(v, str) and v.strip(): + coupon = v.strip()[:80] + break + if isinstance(v, (int, float)) and not isinstance(v, bool): + coupon = str(v) + break + return line, coupon + + +def _jd_parse_final_price(d: dict[str, Any]) -> tuple[str, str]: + """ + pc 搜索 ware.finalPrice:到手价 + estimatedPrice。 + 返回 (estimatedPrice 字符串, 展示用「到手价:25.5」)。 + """ + for fk in ("finalPrice", "mockFinalPrice", "intervalNewPrice"): + fp = d.get(fk) + if not isinstance(fp, dict): + continue + est = fp.get("estimatedPrice") + if est is None: + est = fp.get("price") + tit = fp.get("title") + tit_s = str(tit).strip() if tit is not None else "" + if not tit_s: + tit_s = "到手价" + es = str(est).strip() if est is not None else "" + if not es: + continue + return es, f"{tit_s}:{es}" + return "", "" + + +def _jd_selling_point_text(d: dict[str, Any]) -> str: + """优先根字段 sellingPoint;为空时用 benefitList / sellPoints(接口常返回 sellingPoint=null)。""" + lines: list[str] = [] + sp = d.get("sellingPoint") + if isinstance(sp, list): + lines = [ + _jd_norm_key(x) for x in sp[:25] if isinstance(x, str) and x.strip() + ] + if not lines: + lines = _jd_benefit_list_title_lines(d) + _jd_sell_points_lines(d) + return _human_text(" | ".join(_jd_unique_ordered(lines)), 1200) + + +def _jd_format_comment_sales_floor(d: dict[str, Any]) -> str: + """仅根字段 commentSalesFloor:[{attr,text},…]。""" + csf = d.get("commentSalesFloor") + if not isinstance(csf, list): + return "" + parts: list[str] = [] + for el in csf[:30]: + if not isinstance(el, dict): + continue + text = el.get("text") or el.get("msg") or el.get("desc") + if not isinstance(text, str) or not text.strip(): + continue + t = _jd_norm_key(text) + attr = el.get("attr") or el.get("type") or el.get("key") or "" + if isinstance(attr, str) and attr.strip(): + lk = attr.strip().lower() + if lk in JD_ATTR_TEXT_SKIP_ATTRS: + parts.append(t) + else: + parts.append(f"{attr.strip()}:{t}") + else: + parts.append(t) + return _human_text(" | ".join(parts), 800) + + +def _jd_seckill_text(d: dict[str, Any]) -> str: + for k in ("seckillInfo", "secKill", "secKillInfo", "miaosha", "flashSale"): + v = d.get(k) + if isinstance(v, str) and v.strip(): + return v.strip()[:400] + if isinstance(v, dict): + t = _sval_jd(v, ("text", "title", "name", "status")) + if t: + return t[:400] + return "" + + +def _jd_attributes_line(d: dict[str, Any]) -> str: + chunks: list[str] = [] + for pl_key in ("propertyList", "properties", "wareProps", "props"): + pl = d.get(pl_key) + if not isinstance(pl, list): + continue + for el in pl[:25]: + if isinstance(el, dict): + k = str(el.get("name") or el.get("key") or "").strip() + v = str(el.get("value") or el.get("text") or "").strip() + if k and v: + chunks.append(f"{k}:{v}") + elif isinstance(el, str) and el.strip(): + chunks.append(el.strip()[:120]) + if chunks: + break + return " | ".join(chunks)[:4000] + + +def _jd_attributes_line_full(d: dict[str, Any]) -> str: + """属性行 + pc 搜索常见 color / catid / shortName。""" + base = _jd_attributes_line(d) + extra: list[str] = [] + c = d.get("color") + if isinstance(c, str) and c.strip(): + extra.append(f"颜色规格:{c.strip()}") + cid = d.get("catid") or d.get("cid3") or d.get("cid3Name") + if cid is not None and str(cid).strip(): + extra.append(f"类目:{str(cid).strip()}") + sn = d.get("shortName") + if isinstance(sn, str) and sn.strip(): + extra.append(f"简称:{sn.strip()}") + parts = [p for p in extra if p] + if base: + parts.append(base) + return " | ".join(parts)[:4000] + + +def _jd_is_p4p_flag(d: dict[str, Any]) -> str: + for k in ("isAdv", "isAd", "isP4p", "is_p4p", "adFlag"): + v = d.get(k) + if v is None: + continue + return str(v).strip()[:20] + if d.get("extension_id") or d.get("extensionId") or d.get("adLog"): + return "true" + return "" + + +def _jd_is_explicit_pc_search_ad_ware(d0: dict[str, Any]) -> bool: + """ + 仅显式广告标记。extensionId/adLog 在自然商品上也常见,不能用来扣减 body.s。 + """ + for k in ("isAdv", "isAd", "isP4p", "is_p4p", "adFlag"): + v = d0.get(k) + if v is None: + continue + s = str(v).strip().lower() + if s in ("1", "true", "yes", "y"): + return True + return False + + +def _pc_search_s_delta_from_natural_slot_count(n_natural: int, slot_total: int) -> int: + """ + pc_search 两包满屏:下一包 ``body.s = 上一包 s + Δ``,抓包为 **Δ = 自然位条数 - 1**。 + 例:首包 ``s=1``、``wareList`` 非广告 22 条 → 次包 ``s=22``(即 ``1+21``)。 + """ + cnt = n_natural if n_natural > 0 else max(0, slot_total) + return max(1, cnt - 1) + + +def _pc_search_body_s_step_from_raw_ware_list(raw_list: list[dict[str, Any]]) -> int: + """ + 无 ``wareList`` 时的兜底:树遍历去重 SKU + 去显式广告得自然位数,再套 ``Δ=自然位-1``。 + """ + seen_sku: set[str] = set() + n = 0 + for obj in raw_list: + d0 = _jd_flatten_ware(obj) + if _jd_is_explicit_pc_search_ad_ware(d0): + continue + sku = _sval_jd(d0, JD_SKU_KEYS) + if sku.isdigit() and len(sku) >= 5: + if sku in seen_sku: + continue + seen_sku.add(sku) + n += 1 + return _pc_search_s_delta_from_natural_slot_count(n, len(raw_list)) + + +def _pc_search_s_step_from_payload_data_ware_list(payload: Any) -> int | None: + """ + 与抓包 ``response1.js`` + 第二包请求(``page=2,s=22``)一致: + ``data.wareList`` 内非广告条数为 ``N`` 时,**``s`` 增量为 ``max(1, N-1)``**。 + """ + if not isinstance(payload, dict): + return None + data = payload.get("data") + if not isinstance(data, dict): + return None + wl = data.get("wareList") + if not isinstance(wl, list) or len(wl) == 0: + return None + if not all(isinstance(x, dict) for x in wl): + return None + n = 0 + for w in wl: + d0 = _jd_flatten_ware(w) + if _jd_is_explicit_pc_search_ad_ware(d0): + continue + n += 1 + return _pc_search_s_delta_from_natural_slot_count(n, len(wl)) + + +def pc_search_ware_list_slot_count_from_body(text: str) -> int | None: + """Return len(data.wareList) from pc_search JSON/JSONP body, or None.""" + payload = _loads_json_or_jsonp(text) + if not isinstance(payload, dict): + return None + data = payload.get("data") + if not isinstance(data, dict): + return None + wl = data.get("wareList") + return len(wl) if isinstance(wl, list) else None + + +def _jd_coerce_int(v: Any) -> int | None: + if v is None or isinstance(v, bool): + return None + if isinstance(v, int): + return v + if isinstance(v, str) and v.strip(): + t = v.strip() + if t.lstrip("-").isdigit(): + try: + return int(t) + except ValueError: + return None + return None + + +def _extract_pc_search_next_s_from_payload( + payload: Any, *, request_page: int, request_s: int +) -> int | None: + want_p = request_page + 1 + cands: list[int] = [] + + def walk(o: Any) -> None: + if isinstance(o, dict): + p = _jd_coerce_int(o.get("page")) + if p is None: + p = _jd_coerce_int(o.get("pageNo")) or _jd_coerce_int( + o.get("pageIndex") + ) + s_v = _jd_coerce_int(o.get("s")) + if p == want_p and s_v is not None and s_v > request_s: + cands.append(s_v) + for v in o.values(): + walk(v) + elif isinstance(o, list): + for x in o: + walk(x) + + walk(payload) + return min(cands) if cands else None + + +def _looks_like_jd_ware(d: dict[str, Any]) -> bool: + d0 = _jd_flatten_ware(d) + sku = _sval_jd(d0, JD_SKU_KEYS) + if not (sku.isdigit() and len(sku) >= 5): + return False + title = _sval_jd(d0, JD_TITLE_KEYS) + return len(title) >= 2 + + +def _normalize_jd_api_row(d: dict[str, Any], *, keyword: str, page: int) -> dict[str, str]: + """ + 将 pc_search_searchWare 单条 ware(及同类结构)规整为与淘宝 CSV 对齐的宽表。 + + 主路径字段:wareId/skuId、wareName、jdPrice/jdPriceText、oriPrice、finalPrice(到手价)、 + sellingPoint、commentFuzzy(评价量)、commentSalesFloor、benefitList/newRegionFloor、 + iconList1–4、promotionSet、wareBuried、shopId/shopName、color/catid、isAdv/extensionId 等; + 仍保留树遍历兜底以兼容字段变更。 + """ + d = _jd_flatten_ware(d) + sku = _sval_jd(d, JD_SKU_KEYS) + ware = _sval_jd(d, ("wareId", "wid")) + item_id = ware if ware else sku + + title = _human_text(_sval_jd(d, JD_TITLE_KEYS), 2000) + price = _human_text(_sval_jd(d, JD_PRICE_KEYS), 120) + orig = _human_text(_sval_jd(d, JD_ORIGINAL_PRICE_KEYS), 120) + coupon_p = _human_text(_sval_jd(d, JD_COUPON_KEYS), 80) + fp_coupon, _ = _jd_parse_final_price(d) + if fp_coupon: + if not coupon_p: + coupon_p = fp_coupon + elif fp_coupon not in coupon_p: + coupon_p = _human_text(f"{coupon_p}/{fp_coupon}", 120) + + ps = d.get("priceShow") + _, ps_coupon = ( + _jd_format_price_show_dict(ps) if isinstance(ps, dict) else ("", "") + ) + if not coupon_p and ps_coupon: + coupon_p = ps_coupon + + selling_point = _jd_selling_point_text(d) + comment_count = _human_text( + _sval_jd( + d, + ("commentFuzzy", "comment_fuzzy", "cmtFuzzy", "evaluationFuzzy"), + ), + 120, + ) + comment_sales_floor = _jd_format_comment_sales_floor(d) + + tag_strs = _jd_iter_tag_strings(d) + arr_ind, _arr_groups = _jd_collect_list_string_fragments( + d, selling_norm_skip=_jd_selling_point_norm_set(d) + ) + tag_seen_norm = {t.casefold() for t in tag_strs} + extra_from_arrays = [ + x for x in arr_ind if _jd_norm_key(x).casefold() not in tag_seen_norm + ] + + merged_for_signals = _jd_unique_ordered(tag_strs + extra_from_arrays) + rank_parts = _jd_unique_ordered( + [t for t in merged_for_signals if _jd_is_rank_text(t)] + ) + + hot_list_rank = _human_text(" | ".join(rank_parts), 600) + + shop = _human_text( + _sval_jd(d, ("shopName", "venderName", "storeName", "shop_name")), 200 + ) + shop_url = _safe_url(_sval_jd(d, JD_SHOP_URL_KEYS))[:2000] + if not shop_url: + sid = d.get("shopId") + if sid is not None and str(sid).strip(): + shop_url = f"https://mall.jd.com/index-{str(sid).strip()}.html"[:2000] + shop_info_url = _safe_url(_sval_jd(d, ("shopInfoUrl", "brandUrl")))[:2000] + + loc = _human_text(_sval_jd(d, JD_LOC_KEYS), 200) + + detail_raw = _sval_jd(d, JD_DETAIL_URL_KEYS) + if detail_raw: + detail_url = _safe_url(detail_raw)[:2500] + else: + detail_url = ( + f"https://item.m.jd.com/product/{sku}.html" if sku else "" + ) + + img_raw = _sval_jd( + d, + JD_IMG_KEYS + ("squareImage", "squarePic", "imgDfsUrl"), + ) + image = _jd_product_image_url(img_raw)[:1200] if img_raw else "" + + seckill = _jd_seckill_text(d) + attributes = _jd_attributes_line_full(d) + leaf = str( + d.get("leafCategory") or d.get("cid3Name") or d.get("catid") or "" + ).strip()[:80] + samec = str(d.get("sameStyleCount") or d.get("sameCount") or "").strip()[:40] + rel = str(d.get("relationScore") or d.get("score") or "").strip()[:40] + is_p4p = _jd_is_p4p_flag(d) + + out = _jd_empty_export_row() + out["item_id"] = item_id[:80] + out["sku_id"] = sku[:80] + out["title"] = title + out["title_plain"] = title + out["price"] = price + out["coupon_price"] = coupon_p + out["original_price"] = orig + out["selling_point"] = selling_point + out["comment_sales_floor"] = comment_sales_floor + out["hot_list_rank"] = hot_list_rank + out["comment_count"] = comment_count + out["shop_name"] = shop + out["shop_url"] = shop_url + out["shop_info_url"] = shop_info_url + out["location"] = loc + out["detail_url"] = detail_url + out["image"] = image + out["seckill_info"] = _human_text(seckill, 400) + out["attributes"] = attributes + out["leaf_category"] = leaf + out["same_count"] = samec + out["relation_score"] = rel + out["is_p4p"] = is_p4p + out["platform"] = "京东" + out["keyword"] = keyword + out["page"] = str(page) + return out + + +def _walk_collect_jd_wares(obj: Any, acc: list[dict[str, Any]]) -> None: + if isinstance(obj, dict): + if _looks_like_jd_ware(obj): + acc.append(obj) + for v in obj.values(): + _walk_collect_jd_wares(v, acc) + elif isinstance(obj, list): + for x in obj: + _walk_collect_jd_wares(x, acc) + + +def _parse_jd_json_payload_rows_and_ware_slots( + payload: Any, *, keyword: str, page: int +) -> tuple[list[dict[str, str]], int, int]: + """ + 一次遍历:导出商品行、树遍历 ware 数(兜底)、以及 **body.s 用的自然位步长**。 + """ + raw_list: list[dict[str, Any]] = [] + _walk_collect_jd_wares(payload, raw_list) + seen: set[str] = set() + rows: list[dict[str, str]] = [] + for d in raw_list: + row = _normalize_jd_api_row(d, keyword=keyword, page=page) + sku = row.get("sku_id", "") + if not sku or sku in seen: + continue + seen.add(sku) + rows.append(row) + s_slots = len(raw_list) + s_scroll = _pc_search_body_s_step_from_raw_ware_list(raw_list) + return rows, s_slots, s_scroll + + +def parse_items_from_jd_json_payload(payload: Any, *, keyword: str, page: int) -> list[dict[str, str]]: + rows, _, _ = _parse_jd_json_payload_rows_and_ware_slots( + payload, keyword=keyword, page=page + ) + return rows + + +def parse_items_and_pc_search_s_step_from_response_body( + text: str, + *, + keyword: str, + page: int, + request_api_page: int | None = None, + request_body_s: int | None = None, +) -> tuple[list[dict[str, str]], int]: + """ + 解析商品列表,并给出本次响应对应的 **body.s 游标增量**。 + + JSON:优先顺序:① 响应内嵌下一跳 ``s``;② ``data.wareList`` 得自然位 ``N`` → **Δs=max(1,N-1)**; + ③ 树遍历兜底,同上 Δ 规则;无 ``wareList`` 时对 ``len(rows)`` 亦用 Δ。 + HTML:增量仍为解析行数(非 pc_search 两包逻辑)。 + """ + payload = _loads_json_or_jsonp(text) + if payload is not None: + rows, s_slots, s_scroll = _parse_jd_json_payload_rows_and_ware_slots( + payload, keyword=keyword, page=page + ) + step_api: int | None = None + if request_api_page is not None and request_body_s is not None: + next_s = _extract_pc_search_next_s_from_payload( + payload, + request_page=request_api_page, + request_s=request_body_s, + ) + if next_s is not None: + d = next_s - request_body_s + if d > 0: + step_api = d + step_wl = _pc_search_s_step_from_payload_data_ware_list(payload) + if rows: + if step_api is not None: + step = step_api + elif step_wl is not None: + step = step_wl + else: + step = s_scroll if s_scroll > 0 else _pc_search_s_delta_from_natural_slot_count( + len(rows), len(rows) + ) + return rows, max(1, step) + if s_slots > 0: + if step_api is not None: + step = step_api + elif step_wl is not None: + step = step_wl + else: + step = ( + s_scroll + if s_scroll > 0 + else _pc_search_s_delta_from_natural_slot_count(0, s_slots) + ) + return [], max(1, step) + # 结构与 ware 识别不匹配时,与 parse_items_from_response_body 一样再试 HTML + rows = parse_items_from_html(text, keyword=keyword, page=page) + return rows, (len(rows) if rows else 0) + + +def parse_items_from_response_body(text: str, *, keyword: str, page: int) -> list[dict[str, str]]: + """先尝试 JSON/JSONP(client.action),失败再按 HTML 解析。""" + payload = _loads_json_or_jsonp(text) + if payload is not None: + rows = parse_items_from_jd_json_payload(payload, keyword=keyword, page=page) + if rows: + return rows + return parse_items_from_html(text, keyword=keyword, page=page) + + +def _jd_minimal_html_row( + *, + keyword: str, + page: int, + sku_id: str, + title: str, + price: str, + detail_url: str, + shop_name: str, + comment_count: str, + image: str, +) -> dict[str, str]: + out = _jd_empty_export_row() + out["item_id"] = sku_id + out["sku_id"] = sku_id + out["title"] = title + out["title_plain"] = title + out["price"] = price + out["detail_url"] = detail_url + out["shop_name"] = shop_name + out["comment_count"] = comment_count + out["image"] = image + out["platform"] = "京东" + out["keyword"] = keyword + out["page"] = str(page) + return out + + +def _collect_items_from_json_like( + html_text: str, keyword: str, page: int +) -> list[dict[str, str]]: + """ + 策略 A:从内嵌 JSON/脚本片段中用正则抓取 wareId/wareName/price 等。 + 该策略对结构变更相对鲁棒,但字段名可能变化,因此做多套模板。 + """ + items: list[dict[str, str]] = [] + + # 常见字段组合:wareId + wareName + jdPrice / price / priceText + patterns: list[re.Pattern[str]] = [ + re.compile( + r'"wareId"\s*:\s*"?(?P\d{5,20})"?' + r'[\s\S]{0,1200}?' + r'"wareName"\s*:\s*"(?P[^"]{2,300})"' + r'[\s\S]{0,1200}?' + r'"(?:jdPrice|price|priceText|mainPrice)"\s*:\s*"?(?P<price>[\d.]{1,12})"?', + re.I, + ), + re.compile( + r'"skuId"\s*:\s*"?(?P<sku>\d{5,20})"?' + r'[\s\S]{0,1200}?' + r'"title"\s*:\s*"(?P<title>[^"]{2,300})"' + r'[\s\S]{0,1200}?' + r'"(?:price|priceText|jdPrice)"\s*:\s*"?(?P<price>[\d.]{1,12})"?', + re.I, + ), + # 兜底:只要 sku + title,价格缺失也接受 + re.compile( + r'"(?:wareId|skuId)"\s*:\s*"?(?P<sku>\d{5,20})"?' + r'[\s\S]{0,1200}?' + r'"(?:wareName|title|name)"\s*:\s*"(?P<title>[^"]{2,300})"', + re.I, + ), + ] + + seen: set[str] = set() + for pat in patterns: + for m in pat.finditer(html_text): + sku = (m.groupdict().get("sku") or "").strip() + title = _human_text(m.groupdict().get("title") or "", 300) + price = (m.groupdict().get("price") or "").strip() + if not sku or not title: + continue + if sku in seen: + continue + seen.add(sku) + detail = f"https://item.m.jd.com/product/{sku}.html" + items.append( + _jd_minimal_html_row( + keyword=keyword, + page=page, + sku_id=sku, + title=title, + price=price, + detail_url=detail, + shop_name="", + comment_count="", + image="", + ) + ) + if items: + # 命中一套 pattern 后就不再叠加下一套,避免重复/误配 + break + return items + + +def _collect_items_from_dom(html_text: str, keyword: str, page: int) -> list[dict[str, str]]: + """ + 策略 B:从 DOM/属性中抓取 data-sku + title/price/href。 + 不依赖 BeautifulSoup(零依赖),但对结构变化更敏感。 + """ + items: list[dict[str, str]] = [] + seen: set[str] = set() + + # 以 data-sku 为锚点,截取一个窗口做二次抽取 + for m in re.finditer(r'data-sku\s*=\s*"(?P<sku>\d{5,20})"', html_text, re.I): + sku = (m.group("sku") or "").strip() + if not sku or sku in seen: + continue + seen.add(sku) + + win = html_text[m.start() : m.start() + 4500] + + # 链接 + href = "" + mh = re.search(r'href\s*=\s*"([^"]+)"', win, re.I) + if mh: + href = _safe_url(mh.group(1)) + if not href: + href = f"https://item.m.jd.com/product/{sku}.html" + + # 标题 + title = "" + for tp in ( + r'title\s*=\s*"([^"]{2,300})"', + r'alt\s*=\s*"([^"]{2,300})"', + r'data-name\s*=\s*"([^"]{2,300})"', + ): + mt = re.search(tp, win, re.I) + if mt: + title = _human_text(mt.group(1), 300) + break + + # 价格(HTML 上可能是 ¥xx.xx / ¥xx.xx) + price = "" + mp = re.search(r"(?:¥|¥)\s*([\d.]{1,12})", win) + if mp: + price = mp.group(1).strip() + + # 店铺(H5 列表可能没有) + shop = "" + ms = re.search(r'data-shopname\s*=\s*"([^"]{2,80})"', win, re.I) + if ms: + shop = _human_text(ms.group(1), 80) + + # 图片 + image = "" + mi = re.search(r'(?:data-lazy-img|data-img|src)\s*=\s*"([^"]+\.(?:jpg|jpeg|png|webp)[^"]*)"', win, re.I) + if mi: + image = _jd_product_image_url(mi.group(1))[:1200] + + if not title: + # 标题拿不到时宁可跳过,避免充斥“空标题” + continue + + items.append( + _jd_minimal_html_row( + keyword=keyword, + page=page, + sku_id=sku, + title=title, + price=price, + detail_url=href[:2000], + shop_name=shop, + comment_count="", + image=image, + ) + ) + + return items + + +def parse_items_from_html(html_text: str, *, keyword: str, page: int) -> list[dict[str, str]]: + # 优先尝试 JSON-like(更稳),再退回 DOM + parsed = _collect_items_from_json_like(html_text, keyword, page) + if not parsed: + parsed = _collect_items_from_dom(html_text, keyword, page) + + seen: set[str] = set() + rows: list[dict[str, str]] = [] + for row in parsed: + sku = (row.get("sku_id") or "").strip() + if not sku or sku in seen: + continue + seen.add(sku) + rows.append(row) + return rows + + +def _normalize_url_keep_query(url: str, **override: str) -> str: + """ + 用于 --url 模式下“覆写” keyword/page 等参数(若用户希望用同一 URL 只改 page)。 + 这里较保守:只在 query 中覆盖字段,不碰 path/host。 + """ + u = urlparse(url) + q = parse_qs(u.query, keep_blank_values=True) + for k, v in override.items(): + q[k] = [v] + new_query = urlencode({k: v[0] for k, v in q.items()}, doseq=False, safe=":%/,+") + return urlunparse((u.scheme, u.netloc, u.path, u.params, new_query, u.fragment)) + + +def iter_request_urls(args: argparse.Namespace) -> list[tuple[int, str]]: + """ + 返回 [(page_idx, url), ...]。 + client.action 等与 sign 绑定的 URL 禁止改 query(否则会破坏 sign),故只请求一次。 + """ + out: list[tuple[int, str]] = [] + if getattr(args, "url_file", None): + path = args.url_file + if not os.path.isfile(path): + print(f"--url-file 不存在: {path}", file=sys.stderr) + sys.exit(2) + urls = read_url_list_file(path) + if not urls: + print("--url-file 中没有有效 URL", file=sys.stderr) + sys.exit(2) + for i, u in enumerate(urls): + out.append((i + 1, u.strip())) + return out + if getattr(args, "api_params_file", None): + path = args.api_params_file + if not os.path.isfile(path): + print(f"--api-params-file 不存在: {path}", file=sys.stderr) + sys.exit(2) + try: + pairs = parse_jd_api_params_text(Path(path).read_text(encoding="utf-8")) + except ValueError as e: + print(f"解析 --api-params-file 失败: {e}", file=sys.stderr) + sys.exit(2) + if not pairs: + print("--api-params-file 解析结果为空", file=sys.stderr) + sys.exit(2) + out.append((args.page, build_jd_api_url_from_param_pairs(pairs))) + return out + if args.url: + u0 = args.url.strip() + if _is_h5_ware_search_url(u0): + pe = args.page_to if args.page_to is not None else args.page + for p in range(args.page, pe + 1): + out.append((p, _normalize_url_keep_query(u0, keyword=args.q, page=str(p)))) + else: + out.append((args.page, u0)) + else: + pe = args.page_to if args.page_to is not None else args.page + for p in range(args.page, pe + 1): + out.append((p, build_h5_search_url(args.q, p))) + return out + + +def fetch_html(session: requests.Session, url: str, headers: dict[str, str], timeout: float) -> str: + r = session.get(url, headers=headers, timeout=timeout) + if r.status_code == 403 and _is_jd_api_url(url): + print( + "[京东] api.m.jd.com 返回 HTTP 403(请核对 Cookie、h5st 与 query 是否一致)。", + file=sys.stderr, + ) + print( + f" 响应头 server={r.headers.get('server')!r} content-length={r.headers.get('content-length')!r}", + file=sys.stderr, + ) + r.raise_for_status() + return r.text + + +def main() -> None: + p = argparse.ArgumentParser(description="京东搜索 GET(requests):H5 HTML 或 api JSON") + p.add_argument("--url", help="浏览器里复制的完整请求 URL") + p.add_argument( + "--url-file", + metavar="PATH", + help="每行一条完整 URL(多页各复制一条;# 行为注释)", + ) + p.add_argument( + "--api-params-file", + metavar="PATH", + help="粘贴 Query 参数为文本:每行 key=value,或键一行/值一行;拼成 https://api.m.jd.com/api?...", + ) + p.add_argument( + "--search-referer", + default="", + metavar="URL", + help="可选:api.m.jd.com 请求的 Referer 覆盖", + ) + p.add_argument("--cookie", default="", help="Cookie 请求头(优先于 MY_COOKIE / jd_cookie.txt)") + p.add_argument( + "--cookie-file", + help="从指定文件读取 Cookie;默认会尝试 ../common/jd_cookie.txt", + ) + p.add_argument("--timeout", type=float, default=30.0, help="HTTP 超时秒数") + p.add_argument("--raw", action="store_true", help="仅打印原始响应体(便于排查)") + p.add_argument( + "--ua-preset", + choices=tuple(UA_PRESETS.keys()), + default="chrome_win", + help="User-Agent 预设;search.action 被「去 APP」拦截时可试 iphone_m / android_m", + ) + p.add_argument( + "--q", + default="低GI", + help="搜索词:pc API(默认)或 --h5-html 时 search.action;或覆盖 --url 里 search.action 的 keyword", + ) + p.add_argument( + "--h5-html", + action="store_true", + help="仅 --q 且无 --url 等时:从 so.m.jd.com search.action 拉 HTML 解析商品;默认改为与 jd_search_playwright 相同的 pc API(Node 出 url+headers)", + ) + p.add_argument( + "--page", + type=int, + default=1, + help="起始逻辑页(从 1 起;不是请求 body.page。第 L 页对应两次 API:body.page=2L-1 与 2L)", + ) + p.add_argument( + "--page-to", + type=int, + default=None, + metavar="N", + help="结束逻辑页(含);与 --page 搭配;每逻辑页固定 2 包 API。", + ) + p.add_argument( + "--page-delay", + type=float, + default=1.2, + help="多页时逻辑页之间的额外间隔秒数(与 --request-delay 可叠加)", + ) + p.add_argument( + "--request-delay", + default=None, + metavar="MIN-MAX", + help="每次 pc_search 完成后到下一次之间的随机等待(秒),如 30-60;不设则仅逻辑页间用 --page-delay", + ) + p.add_argument( + "--fetch-retries", + type=int, + default=3, + metavar="N", + help="同一 body.page/s 遇空包或零解析时,除首次外最多再试 N 次(默认 3,即最多共 4 次请求)", + ) + p.add_argument( + "--fetch-retry-delay", + type=float, + default=2.0, + help="上述重试之间的间隔秒数(不走 --request-delay 长间隔)", + ) + p.add_argument( + "--pvid", + default="", + metavar="ID", + help="pc_search body 与 Referer 的 pvid(与搜索页 URL 一致);空则 Node 用默认 pvid", + ) + p.add_argument( + "--format", + choices=("raw", "items"), + default="items", + help="raw=原始 HTML;items=规整商品列表(JSON 或 CSV)", + ) + p.add_argument("--csv", action="store_true", help="仅与 --format items 合用:输出 CSV(--out 写文件)") + p.add_argument("--out", help="raw 时写 HTML;items 时写商品 JSON 或 CSV(见 --csv)") + args = p.parse_args() + + mode_count = sum( + 1 + for x in ( + bool(args.url and str(args.url).strip()), + bool(getattr(args, "url_file", None)), + bool(getattr(args, "api_params_file", None)), + ) + if x + ) + if mode_count > 1: + print("--url、--url-file、--api-params-file 只能三选一", file=sys.stderr) + sys.exit(2) + + if args.page_to is not None and args.page_to < args.page: + print("--page-to 必须大于等于 --page", file=sys.stderr) + sys.exit(2) + if args.raw and args.page_to is not None: + print("--raw 与 --page-to 请分开使用", file=sys.stderr) + sys.exit(2) + if args.csv and args.format != "items": + print("--csv 需与 --format items 同时使用", file=sys.stderr) + sys.exit(2) + if getattr(args, "url_file", None) and args.page_to is not None: + print("--url-file 已可写多行 URL,请勿再使用 --page-to", file=sys.stderr) + sys.exit(2) + if ( + getattr(args, "api_params_file", None) + and args.page_to is not None + and args.page_to > args.page + ): + print( + "--api-params-file 仅生成单条 URL;翻页请更新 body/h5st 后换新文件,或改用 --url-file", + file=sys.stderr, + ) + sys.exit(2) + if ( + args.url + and not _is_h5_ware_search_url(args.url) + and args.page_to is not None + and args.page_to > args.page + ): + print( + "非 search.action 的 --url(例如带 h5st 的 api)与参数绑定,不能使用 --page-to;" + "请每页在浏览器 Network 中分别复制完整 URL,或使用 --url-file。", + file=sys.stderr, + ) + sys.exit(2) + + req_delay_range: tuple[float, float] | None = None + if getattr(args, "request_delay", None): + try: + req_delay_range = parse_request_delay_range(args.request_delay) + except ValueError as e: + print(f"[京东] --request-delay 无效: {e}", file=sys.stderr) + sys.exit(2) + if getattr(args, "fetch_retries", 0) < 0: + print("[京东] --fetch-retries 不能为负", file=sys.stderr) + sys.exit(2) + + cookie = load_cookie(args) + session = requests.Session() + session.trust_env = False + + referer_ov = (args.search_referer or "").strip() or None + + use_pc_api_like_playwright = ( + mode_count == 0 + and not args.h5_html + and args.format == "items" + ) + _node_cookie_file: str | None = None + _cfa = getattr(args, "cookie_file", None) + if _cfa and str(_cfa).strip(): + _p = Path(str(_cfa).strip()).expanduser().resolve() + if _p.is_file(): + _node_cookie_file = str(_p) + if use_pc_api_like_playwright and str(args.cookie or "").strip() and not _node_cookie_file: + print( + "[京东] 提示:默认 pc API 路径下 h5st 由 Node 生成,Cookie 来自 common/jd_cookie.txt 或 --cookie-file;" + "仅 --cookie 字符串不会传给 Node。", + file=sys.stderr, + ) + + def dump_out(text: str) -> None: + if args.out: + Path(args.out).parent.mkdir(parents=True, exist_ok=True) + Path(args.out).write_text(text, encoding="utf-8") + else: + sys.stdout.write(text) + + kw_for_row = args.q + apf = getattr(args, "api_params_file", None) + if apf: + try: + for k, v in parse_jd_api_params_text(Path(apf).read_text(encoding="utf-8")): + if k == "keyword": + kw_for_row = v + break + except ValueError: + pass + + # --- raw:仅输出第一页 URL 的响应(避免与 --page-to 语义冲突)--- + if args.format == "raw" or args.raw: + plan_raw = list(iter_request_urls(args)) + if len(plan_raw) > 1: + print("提示:--raw 仅输出 plan 中第一条 URL 的响应体。", file=sys.stderr) + _, url = plan_raw[0] + headers = build_request_headers( + cookie, + request_url=url, + ua_preset=args.ua_preset, + referer_override=referer_ov, + ) + body = fetch_html(session, url, headers, args.timeout) + dump_out(body) + return + + # --- items:多 URL 合并去重(默认与 jd_search_playwright 相同 url+headers)--- + all_rows: list[dict[str, str]] = [] + seen: set[str] = set() + + if use_pc_api_like_playwright: + 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) + n_pc_pages = pe - page_start + 1 + + api_page = 1 + api_s = 1 + run_aborted = False + node_pvid = (args.pvid or "").strip() or None + + def _fetch_pc_body(ap: int, as_: int) -> tuple[str, str]: + data = export_pc_search_request_json( + args.q, + ap, + s=as_, + pvid=node_pvid, + cookie_file=_node_cookie_file, + ) + u = data["url"] + hdrs = {str(k): str(v) for k, v in data["headers"].items()} + return u, fetch_html(session, u, hdrs, args.timeout) + + _pc_fetch_n = 0 + + def _fetch_pc_body_spaced(ap: int, as_: int) -> tuple[str, str]: + nonlocal _pc_fetch_n + if _pc_fetch_n > 0: + sleep_pc_search_request_gap(req_delay_range) + u, b = _fetch_pc_body(ap, as_) + _pc_fetch_n += 1 + return u, b + + 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): + url, body = "", "" + _skip_rows: list[dict[str, str]] = [] + s_step = 0 + blocked: str | None = None + for rt in range(max_fetch_tries): + if rt == 0: + url, body = _fetch_pc_body_spaced(api_page, api_s) + else: + 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 = _fetch_pc_body(api_page, api_s) + blocked = _detect_blocked(body) + if blocked: + break + _skip_rows, s_step = ( + parse_items_and_pc_search_s_step_from_response_body( + body, + keyword=kw_for_row, + 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): + url, body = "", "" + rows: list[dict[str, str]] = [] + s_step = 0 + blocked: str | None = None + for rt in range(max_fetch_tries): + if rt == 0: + url, body = _fetch_pc_body_spaced(api_page, api_s) + else: + 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 = _fetch_pc_body(api_page, api_s) + blocked = _detect_blocked(body) + if blocked: + break + rows, s_step = ( + parse_items_and_pc_search_s_step_from_response_body( + body, + keyword=kw_for_row, + 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) + print( + " 建议:jd_pc_search/search/jd_search_playwright.py;" + "或 DevTools 复制 api URL 作 --url;" + "或 jd_low_gi_playwright.py --headed。", + 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) + if args.out and args.csv: + dbg = Path(args.out).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 + + 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 + + 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: + time.sleep(max(0.0, args.page_delay)) + + if n_pc_pages > 1 or any( + _jd_row_count_for_page(all_rows, p) > 30 + for p in range(page_start, pe + 1) + ): + print( + f"(PC 搜索:每逻辑页 2 包 body.page 连续 +1;CSV 列 page=逻辑页、" + f"单页约≤{JD_PC_SEARCH_ITEMS_PER_PAGE} 条;合并后共 {len(all_rows)} 条)", + file=sys.stderr, + ) + else: + plan = [] + for page_idx, url in iter_request_urls(args): + h = build_request_headers( + cookie, + request_url=url, + ua_preset=args.ua_preset, + referer_override=referer_ov, + ) + plan.append((page_idx, url, h)) + + for idx, (page_idx, url, headers) in enumerate(plan): + body = fetch_html(session, url, headers, args.timeout) + + blocked = _detect_blocked(body) + if blocked: + print(f"[京东] 第{page_idx}页:{blocked}", file=sys.stderr) + print(f" 当前 URL: {url[:160]}…", file=sys.stderr) + print( + " 建议:jd_pc_search/search/jd_search_playwright.py;或 DevTools 复制 api URL 作 --url;" + "或 jd_low_gi_playwright.py --headed。", + file=sys.stderr, + ) + break + + rows = parse_items_from_response_body( + body, keyword=kw_for_row, page=page_idx + ) + if not rows: + print( + f"[京东] 第{page_idx}页:未解析到商品(或 JSON 结构变化)。", + file=sys.stderr, + ) + print(f" 当前 URL: {url[:160]}…", file=sys.stderr) + if args.out and args.csv: + dbg = Path(args.out).with_suffix( + ".debug.json" + if body.lstrip().startswith("{") + else ".debug.html" + ) + dbg.write_text(body, encoding="utf-8") + print(f" 已保存调试文件: {dbg}", file=sys.stderr) + break + + for r in rows: + sku = (r.get("sku_id") or "").strip() + if not sku or sku in seen: + continue + seen.add(sku) + all_rows.append(r) + + if idx < len(plan) - 1: + time.sleep(max(0.0, args.page_delay)) + + if args.page_to is not None and len(plan) > 1: + print(f"(已按计划请求多页,合并后 {len(all_rows)} 条)", file=sys.stderr) + + export_rows = [jd_row_to_export(r) for r in all_rows] + if args.csv: + buf = StringIO() + w = csv.DictWriter(buf, fieldnames=list(CSV_FIELDS), extrasaction="ignore") + w.writeheader() + w.writerows(export_rows) + body = buf.getvalue() + if args.out: + Path(args.out).parent.mkdir(parents=True, exist_ok=True) + Path(args.out).write_text("\ufeff" + body, encoding="utf-8") + else: + sys.stdout.write(body) + else: + txt = json.dumps(export_rows, ensure_ascii=False, indent=2) + dump_out(txt) + + +if __name__ == "__main__": + main() + diff --git a/backend/crawler_copy/jd_pc_search/search/jd_h5st.js b/backend/crawler_copy/jd_pc_search/search/jd_h5st.js new file mode 100644 index 0000000..03f0f32 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/search/jd_h5st.js @@ -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, +}; + diff --git a/backend/crawler_copy/jd_pc_search/search/jd_pc_api_headers.js b/backend/crawler_copy/jd_pc_search/search/jd_pc_api_headers.js new file mode 100644 index 0000000..fb670c3 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/search/jd_pc_api_headers.js @@ -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 }; diff --git a/backend/crawler_copy/jd_pc_search/search/jd_search_playwright.py b/backend/crawler_copy/jd_pc_search/search/jd_search_playwright.py new file mode 100644 index 0000000..9ed1b62 --- /dev/null +++ b/backend/crawler_copy/jd_pc_search/search/jd_search_playwright.py @@ -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() diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..8e7ac79 --- /dev/null +++ b/backend/manage.py @@ -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() diff --git a/backend/pipeline/admin.py b/backend/pipeline/admin.py new file mode 100644 index 0000000..69b6442 --- /dev/null +++ b/backend/pipeline/admin.py @@ -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",) diff --git a/backend/pipeline/brief_compact.py b/backend/pipeline/brief_compact.py new file mode 100644 index 0000000..50a312f --- /dev/null +++ b/backend/pipeline/brief_compact.py @@ -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() diff --git a/backend/pipeline/brief_pack.py b/backend/pipeline/brief_pack.py new file mode 100644 index 0000000..daf4081 --- /dev/null +++ b/backend/pipeline/brief_pack.py @@ -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() diff --git a/backend/pipeline/cookie_paste.py b/backend/pipeline/cookie_paste.py new file mode 100644 index 0000000..092a32d --- /dev/null +++ b/backend/pipeline/cookie_paste.py @@ -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 diff --git a/backend/pipeline/csv_schema.py b/backend/pipeline/csv_schema.py new file mode 100644 index 0000000..6a8c2db --- /dev/null +++ b/backend/pipeline/csv_schema.py @@ -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, ...] = ( + "pipeline_keyword", + "sku_id", + "ware_id", + "title", + "price", + "coupon_price", + "original_price", + "selling_point", + "hot_list_rank", + "comment_fuzzy", + "comment_sales_floor", + "shop_name", + "detail_url", + "image", + "attributes", + "leaf_category", + "keyword", + "page", +) + +# 商详块:列名与 ORM 属性同名;与 LEAN_DETAIL_EXPORT_FIELDNAMES / 流水线 lean 一致 +MERGED_LEAN_DETAIL_KEYS: tuple[str, ...] = LEAN_DETAIL_EXPORT_FIELDNAMES + +MERGED_COMMENT_CSV_COLUMNS: tuple[str, ...] = ( + "comment_count", + "comment_preview", +) + +MERGED_COMMENT_INTERNAL_KEYS: tuple[str, ...] = ( + "pipeline_comment_count", + "comment_preview", +) + +MERGED_CSV_COLUMNS: tuple[str, ...] = ( + *MERGED_SEARCH_CSV_COLUMNS, + *MERGED_LEAN_DETAIL_KEYS, + *MERGED_COMMENT_CSV_COLUMNS, +) + +MERGED_INTERNAL_KEYS: tuple[str, ...] = ( + *MERGED_SEARCH_INTERNAL_KEYS, + *MERGED_LEAN_DETAIL_KEYS, + *MERGED_COMMENT_INTERNAL_KEYS, +) + +assert len(MERGED_CSV_COLUMNS) == len(MERGED_INTERNAL_KEYS) + +MERGED_CSV_TO_FIELD: dict[str, str] = dict(zip(MERGED_CSV_COLUMNS, MERGED_INTERNAL_KEYS)) + +MERGED_FIELD_TO_CSV_HEADER: dict[str, str] = { + internal: csv_h for csv_h, internal in MERGED_CSV_TO_FIELD.items() +} diff --git a/backend/pipeline/dataset_nonempty.py b/backend/pipeline/dataset_nonempty.py new file mode 100644 index 0000000..844c8dc --- /dev/null +++ b/backend/pipeline/dataset_nonempty.py @@ -0,0 +1,122 @@ +"""按任务扫描库内行,得到「全表至少一格非空」的列,供摘要 / 浏览 / 导出一致裁剪。""" +from __future__ import annotations + +from .csv_schema import ( + COMMENT_CSV_COLUMNS, + COMMENT_CSV_TO_FIELD, + DETAIL_CSV_COLUMNS, + DETAIL_CSV_TO_FIELD, + JD_SEARCH_CSV_HEADERS, + JD_SEARCH_INTERNAL_KEYS, + MERGED_FIELD_TO_CSV_HEADER, + MERGED_INTERNAL_KEYS, +) +from .models import JdJobCommentRow, JdJobDetailRow, JdJobMergedRow, JdJobSearchRow, PipelineJob +from .row_serialize import COMMENT_FIELDS_ORDER, DETAIL_FIELDS_ORDER + + +def _is_nonempty(val) -> bool: + if val is None: + return False + return str(val).strip() != "" + + +def nonempty_search_keys_for_job(job: PipelineJob) -> list[str]: + qs = JdJobSearchRow.objects.filter(job=job) + present = {k: False for k in JD_SEARCH_INTERNAL_KEYS} + for row in qs.iterator(chunk_size=400): + for k in JD_SEARCH_INTERNAL_KEYS: + if not present[k] and _is_nonempty(getattr(row, k, None)): + present[k] = True + return [k for k in JD_SEARCH_INTERNAL_KEYS if present[k]] + + +def nonempty_detail_fields_for_job(job: PipelineJob) -> list[str]: + qs = JdJobDetailRow.objects.filter(job=job) + present = {k: False for k in DETAIL_FIELDS_ORDER} + for row in qs.iterator(chunk_size=400): + for k in DETAIL_FIELDS_ORDER: + if not present[k] and _is_nonempty(getattr(row, k, None)): + present[k] = True + return [k for k in DETAIL_FIELDS_ORDER if present[k]] + + +def nonempty_comment_fields_for_job(job: PipelineJob) -> list[str]: + qs = JdJobCommentRow.objects.filter(job=job) + present = {k: False for k in COMMENT_FIELDS_ORDER} + for row in qs.iterator(chunk_size=400): + for k in COMMENT_FIELDS_ORDER: + if not present[k] and _is_nonempty(getattr(row, k, None)): + present[k] = True + return [k for k in COMMENT_FIELDS_ORDER if present[k]] + + +def nonempty_merged_fields_for_job(job: PipelineJob) -> list[str]: + qs = JdJobMergedRow.objects.filter(job=job) + present = {k: False for k in MERGED_INTERNAL_KEYS} + for row in qs.iterator(chunk_size=400): + for k in MERGED_INTERNAL_KEYS: + if not present[k] and _is_nonempty(getattr(row, k, None)): + present[k] = True + return [k for k in MERGED_INTERNAL_KEYS if present[k]] + + +def search_columns_for_api(job: PipelineJob) -> list[dict[str, str]]: + return [{"key": k, "label": JD_SEARCH_CSV_HEADERS[k]} for k in nonempty_search_keys_for_job(job)] + + +def _detail_field_to_csv_col(field: str) -> str: + for col, fn in DETAIL_CSV_TO_FIELD.items(): + if fn == field: + return col + return field + + +def detail_columns_for_api(job: PipelineJob) -> list[dict[str, str]]: + return [ + {"key": f, "label": _detail_field_to_csv_col(f)} + for f in nonempty_detail_fields_for_job(job) + ] + + +def _comment_field_to_csv_col(field: str) -> str: + for col, fn in COMMENT_CSV_TO_FIELD.items(): + if fn == field: + return col + return field + + +def comment_columns_for_api(job: PipelineJob) -> list[dict[str, str]]: + return [ + {"key": f, "label": _comment_field_to_csv_col(f)} + for f in nonempty_comment_fields_for_job(job) + ] + + +def merged_columns_for_api(job: PipelineJob) -> list[dict[str, str]]: + return [ + {"key": k, "label": MERGED_FIELD_TO_CSV_HEADER[k]} + for k in nonempty_merged_fields_for_job(job) + ] + + +def search_export_headers(job: PipelineJob) -> list[str]: + keys = nonempty_search_keys_for_job(job) + return ["id", "row_index"] + [JD_SEARCH_CSV_HEADERS[k] for k in keys] + + +def detail_export_headers(job: PipelineJob) -> list[str]: + fields = set(nonempty_detail_fields_for_job(job)) + cols = [c for c in DETAIL_CSV_COLUMNS if DETAIL_CSV_TO_FIELD[c] in fields] + return ["id", "row_index"] + cols + + +def comment_export_headers(job: PipelineJob) -> list[str]: + fields = set(nonempty_comment_fields_for_job(job)) + cols = [c for c in COMMENT_CSV_COLUMNS if COMMENT_CSV_TO_FIELD[c] in fields] + return ["id", "row_index"] + cols + + +def merged_export_headers(job: PipelineJob) -> list[str]: + keys = nonempty_merged_fields_for_job(job) + return ["id", "row_index"] + [MERGED_FIELD_TO_CSV_HEADER[k] for k in keys] diff --git a/backend/pipeline/detail_ware_regen.py b/backend/pipeline/detail_ware_regen.py new file mode 100644 index 0000000..0aed5f3 --- /dev/null +++ b/backend/pipeline/detail_ware_regen.py @@ -0,0 +1,74 @@ +"""从 ``run_dir/detail/ware_*_response.json`` 与 ``keyword_pipeline_merged.csv`` 重写 ``detail_ware_export.csv``(lean 列集,不重新抓接口)。""" +from __future__ import annotations + +import csv +import sys +from pathlib import Path + +from .ingest import FILE_DETAIL_WARE_CSV, FILE_MERGED_CSV, SKU_FIELD_MERGED + + +def _ensure_crawler_copy_path() -> None: + root = Path(__file__).resolve().parent.parent / "crawler_copy" / "jd_pc_search" + for sub in ("detail", ""): + p = root / sub if sub else root + s = str(p.resolve()) + if s not in sys.path: + sys.path.insert(0, s) + + +def regenerate_detail_ware_rows(run_dir: Path) -> list[dict[str, str]]: + _ensure_crawler_copy_path() + from jd_detail_ware_business_requests import ( # noqa: WPS433 + DETAIL_WARE_LEAN_CSV_FIELDNAMES, + detail_ware_lean_csv_row, + ) + + run_dir = run_dir.expanduser().resolve() + merged_path = run_dir / FILE_MERGED_CSV + detail_dir = run_dir / "detail" + if not merged_path.is_file(): + raise FileNotFoundError(f"缺少合并表: {merged_path}") + if not detail_dir.is_dir(): + raise FileNotFoundError(f"缺少 detail 目录: {detail_dir}") + + rows_out: list[dict[str, str]] = [] + with merged_path.open(encoding="utf-8-sig", newline="") as f: + reader = csv.DictReader(f) + for row in reader: + sku = (row.get(SKU_FIELD_MERGED) or "").strip() + if not sku: + continue + jp = detail_dir / f"ware_{sku}_response.json" + if not jp.is_file(): + continue + text = jp.read_text(encoding="utf-8") + ing = (row.get("detail_body_ingredients") or "").strip() + rows_out.append( + detail_ware_lean_csv_row( + sku, + 200, + text, + detail_body_ingredients=ing, + detail_body_ingredients_source_url="", + ) + ) + return rows_out + + +def write_detail_ware_export_csv(run_dir: Path) -> tuple[int, Path]: + rows = regenerate_detail_ware_rows(run_dir) + _ensure_crawler_copy_path() + from jd_detail_ware_business_requests import DETAIL_WARE_LEAN_CSV_FIELDNAMES # noqa: WPS433 + + out = run_dir.expanduser().resolve() / FILE_DETAIL_WARE_CSV + out.parent.mkdir(parents=True, exist_ok=True) + with out.open("w", encoding="utf-8-sig", newline="") as f: + w = csv.DictWriter( + f, + fieldnames=list(DETAIL_WARE_LEAN_CSV_FIELDNAMES), + extrasaction="ignore", + ) + w.writeheader() + w.writerows(rows) + return len(rows), out diff --git a/backend/pipeline/export_job.py b/backend/pipeline/export_job.py new file mode 100644 index 0000000..5f0be75 --- /dev/null +++ b/backend/pipeline/export_job.py @@ -0,0 +1,344 @@ +"""任务维度数据集导出:JSON / CSV(UTF-8 BOM)/ xlsx。列与源 CSV 对齐。""" +from __future__ import annotations + +import csv +import json +from io import BytesIO, StringIO +from typing import Any + +from django.db.models import QuerySet +from openpyxl import Workbook + +from .csv_schema import ( + COMMENT_CSV_TO_FIELD, + DETAIL_CSV_TO_FIELD, + JD_SEARCH_CSV_HEADERS, + MERGED_FIELD_TO_CSV_HEADER, +) +from .dataset_nonempty import ( + comment_export_headers, + detail_export_headers, + merged_export_headers, + nonempty_comment_fields_for_job, + nonempty_detail_fields_for_job, + nonempty_merged_fields_for_job, + nonempty_search_keys_for_job, + search_export_headers, +) +from .models import JdJobCommentRow, JdJobDetailRow, JdJobMergedRow, JdJobSearchRow, PipelineJob +from .row_serialize import ( + comment_row_to_dict, + detail_row_to_dict, + merged_row_to_dict, + search_row_to_dict, +) + + +def _search_row_csv_dict(r: JdJobSearchRow, internal_keys: list[str]) -> dict[str, Any]: + d = search_row_to_dict(r) + out: dict[str, Any] = {"id": d["id"], "row_index": d["row_index"]} + for k in internal_keys: + out[JD_SEARCH_CSV_HEADERS[k]] = d.get(k, "") + return out + + +def _detail_row_csv_dict(r: JdJobDetailRow, csv_cols: list[str]) -> dict[str, Any]: + d = detail_row_to_dict(r) + out: dict[str, Any] = {"id": d["id"], "row_index": d["row_index"]} + for col in csv_cols: + fn = DETAIL_CSV_TO_FIELD[col] + out[col] = d.get(fn, "") + return out + + +def _comment_row_csv_dict(r: JdJobCommentRow, csv_cols: list[str]) -> dict[str, Any]: + d = comment_row_to_dict(r) + out: dict[str, Any] = {"id": d["id"], "row_index": d["row_index"]} + for col in csv_cols: + fn = COMMENT_CSV_TO_FIELD[col] + out[col] = d.get(fn, "") + return out + + +def _merged_row_csv_dict(r: JdJobMergedRow, internal_keys: list[str]) -> dict[str, Any]: + d = merged_row_to_dict(r) + out: dict[str, Any] = {"id": d["id"], "row_index": d["row_index"]} + for k in internal_keys: + out[MERGED_FIELD_TO_CSV_HEADER[k]] = d.get(k, "") + return out + + +def _prune_search_dict(d: dict[str, Any], internal_keys: list[str]) -> dict[str, Any]: + out = {"id": d["id"], "row_index": d["row_index"]} + for k in internal_keys: + out[k] = d.get(k, "") + return out + + +def _prune_detail_dict(d: dict[str, Any], fields: list[str]) -> dict[str, Any]: + out = {"id": d["id"], "row_index": d["row_index"]} + for k in fields: + out[k] = d.get(k, "") + return out + + +def _prune_comment_dict(d: dict[str, Any], fields: list[str]) -> dict[str, Any]: + out = {"id": d["id"], "row_index": d["row_index"]} + for k in fields: + out[k] = d.get(k, "") + return out + + +def _prune_merged_dict(d: dict[str, Any], fields: list[str]) -> dict[str, Any]: + out = {"id": d["id"], "row_index": d["row_index"]} + for k in fields: + out[k] = d.get(k, "") + return out + + +def _rows_as_list_search(job: PipelineJob) -> list[dict[str, Any]]: + keys = nonempty_search_keys_for_job(job) + qs = JdJobSearchRow.objects.filter(job=job) + return [ + _prune_search_dict(search_row_to_dict(obj), keys) + for obj in qs.order_by("row_index").iterator(chunk_size=400) + ] + + +def _rows_as_list_detail(job: PipelineJob) -> list[dict[str, Any]]: + fields = nonempty_detail_fields_for_job(job) + qs = JdJobDetailRow.objects.filter(job=job) + return [ + _prune_detail_dict(detail_row_to_dict(obj), fields) + for obj in qs.order_by("row_index").iterator(chunk_size=400) + ] + + +def _rows_as_list_comment(job: PipelineJob) -> list[dict[str, Any]]: + fields = nonempty_comment_fields_for_job(job) + qs = JdJobCommentRow.objects.filter(job=job) + return [ + _prune_comment_dict(comment_row_to_dict(obj), fields) + for obj in qs.order_by("row_index").iterator(chunk_size=400) + ] + + +def _rows_as_list_merged(job: PipelineJob) -> list[dict[str, Any]]: + fields = nonempty_merged_fields_for_job(job) + qs = JdJobMergedRow.objects.filter(job=job) + return [ + _prune_merged_dict(merged_row_to_dict(obj), fields) + for obj in qs.order_by("row_index").iterator(chunk_size=400) + ] + + +def build_json_bytes(*, job: PipelineJob, kind: str) -> tuple[bytes, str]: + if kind == "search": + data = _rows_as_list_search(job) + name = f"job_{job.id}_search.json" + elif kind == "detail": + data = _rows_as_list_detail(job) + name = f"job_{job.id}_detail.json" + elif kind == "comments": + data = _rows_as_list_comment(job) + name = f"job_{job.id}_comments.json" + elif kind == "all": + data = { + "job_id": job.id, + "keyword": job.keyword, + "search": _rows_as_list_search(job), + "detail": _rows_as_list_detail(job), + "comments": _rows_as_list_comment(job), + "merged": _rows_as_list_merged(job), + } + name = f"job_{job.id}_all.json" + elif kind == "merged": + data = _rows_as_list_merged(job) + name = f"job_{job.id}_merged.json" + else: + raise ValueError(f"unknown kind: {kind}") + raw = json.dumps(data, ensure_ascii=False, indent=2) + return raw.encode("utf-8"), name + + +def _write_csv_from_qs( + *, + qs: QuerySet, + headers: list[str], + row_fn: Any, +) -> str: + buf = StringIO() + w = csv.DictWriter(buf, fieldnames=headers, extrasaction="ignore") + w.writeheader() + for obj in qs.order_by("row_index").iterator(chunk_size=400): + w.writerow(row_fn(obj)) + return buf.getvalue() + + +def build_csv_bytes(*, job: PipelineJob, kind: str) -> tuple[bytes, str]: + if kind == "search": + sk = nonempty_search_keys_for_job(job) + text = _write_csv_from_qs( + qs=JdJobSearchRow.objects.filter(job=job), + headers=search_export_headers(job), + row_fn=lambda o, _sk=sk: _search_row_csv_dict(o, _sk), + ) + name = f"job_{job.id}_search.csv" + elif kind == "detail": + dcols = [c for c in detail_export_headers(job) if c not in ("id", "row_index")] + text = _write_csv_from_qs( + qs=JdJobDetailRow.objects.filter(job=job), + headers=detail_export_headers(job), + row_fn=lambda o, _dc=dcols: _detail_row_csv_dict(o, _dc), + ) + name = f"job_{job.id}_detail.csv" + elif kind == "comments": + ccols = [c for c in comment_export_headers(job) if c not in ("id", "row_index")] + text = _write_csv_from_qs( + qs=JdJobCommentRow.objects.filter(job=job), + headers=comment_export_headers(job), + row_fn=lambda o, _cc=ccols: _comment_row_csv_dict(o, _cc), + ) + name = f"job_{job.id}_comments.csv" + elif kind == "all": + sk = nonempty_search_keys_for_job(job) + dcols = [c for c in detail_export_headers(job) if c not in ("id", "row_index")] + ccols = [c for c in comment_export_headers(job) if c not in ("id", "row_index")] + mk = nonempty_merged_fields_for_job(job) + parts = [ + "# search", + _write_csv_from_qs( + qs=JdJobSearchRow.objects.filter(job=job), + headers=search_export_headers(job), + row_fn=lambda o, _sk=sk: _search_row_csv_dict(o, _sk), + ), + "", + "# detail", + _write_csv_from_qs( + qs=JdJobDetailRow.objects.filter(job=job), + headers=detail_export_headers(job), + row_fn=lambda o, _dc=dcols: _detail_row_csv_dict(o, _dc), + ), + "", + "# comments", + _write_csv_from_qs( + qs=JdJobCommentRow.objects.filter(job=job), + headers=comment_export_headers(job), + row_fn=lambda o, _cc=ccols: _comment_row_csv_dict(o, _cc), + ), + "", + "# merged", + _write_csv_from_qs( + qs=JdJobMergedRow.objects.filter(job=job), + headers=merged_export_headers(job), + row_fn=lambda o, _mk=mk: _merged_row_csv_dict(o, _mk), + ), + ] + text = "\n".join(parts) + name = f"job_{job.id}_all.csv" + elif kind == "merged": + mk = nonempty_merged_fields_for_job(job) + text = _write_csv_from_qs( + qs=JdJobMergedRow.objects.filter(job=job), + headers=merged_export_headers(job), + row_fn=lambda o, _mk=mk: _merged_row_csv_dict(o, _mk), + ) + name = f"job_{job.id}_merged.csv" + else: + raise ValueError(f"unknown kind: {kind}") + return ("\ufeff" + text).encode("utf-8"), name + + +def _append_sheet(ws, headers: list[str], qs: QuerySet, row_fn: Any) -> None: + ws.append(headers) + for obj in qs.order_by("row_index").iterator(chunk_size=400): + rowd = row_fn(obj) + ws.append([rowd.get(h, "") for h in headers]) + + +def build_xlsx_bytes(*, job: PipelineJob, kind: str) -> tuple[bytes, str]: + wb = Workbook() + if kind == "search": + ws = wb.active + ws.title = "search"[:31] + sk = nonempty_search_keys_for_job(job) + _append_sheet( + ws, + search_export_headers(job), + JdJobSearchRow.objects.filter(job=job), + lambda o, _sk=sk: _search_row_csv_dict(o, _sk), + ) + name = f"job_{job.id}_search.xlsx" + elif kind == "detail": + ws = wb.active + ws.title = "detail"[:31] + dcols = [c for c in detail_export_headers(job) if c not in ("id", "row_index")] + _append_sheet( + ws, + detail_export_headers(job), + JdJobDetailRow.objects.filter(job=job), + lambda o, _dc=dcols: _detail_row_csv_dict(o, _dc), + ) + name = f"job_{job.id}_detail.xlsx" + elif kind == "comments": + ws = wb.active + ws.title = "comments"[:31] + ccols = [c for c in comment_export_headers(job) if c not in ("id", "row_index")] + _append_sheet( + ws, + comment_export_headers(job), + JdJobCommentRow.objects.filter(job=job), + lambda o, _cc=ccols: _comment_row_csv_dict(o, _cc), + ) + name = f"job_{job.id}_comments.xlsx" + elif kind == "all": + sk = nonempty_search_keys_for_job(job) + dcols = [c for c in detail_export_headers(job) if c not in ("id", "row_index")] + ccols = [c for c in comment_export_headers(job) if c not in ("id", "row_index")] + mk = nonempty_merged_fields_for_job(job) + ws1 = wb.active + ws1.title = "search"[:31] + _append_sheet( + ws1, + search_export_headers(job), + JdJobSearchRow.objects.filter(job=job), + lambda o, _sk=sk: _search_row_csv_dict(o, _sk), + ) + ws2 = wb.create_sheet("detail"[:31]) + _append_sheet( + ws2, + detail_export_headers(job), + JdJobDetailRow.objects.filter(job=job), + lambda o, _dc=dcols: _detail_row_csv_dict(o, _dc), + ) + ws3 = wb.create_sheet("comments"[:31]) + _append_sheet( + ws3, + comment_export_headers(job), + JdJobCommentRow.objects.filter(job=job), + lambda o, _cc=ccols: _comment_row_csv_dict(o, _cc), + ) + ws4 = wb.create_sheet("merged"[:31]) + _append_sheet( + ws4, + merged_export_headers(job), + JdJobMergedRow.objects.filter(job=job), + lambda o, _mk=mk: _merged_row_csv_dict(o, _mk), + ) + name = f"job_{job.id}_all.xlsx" + elif kind == "merged": + mk = nonempty_merged_fields_for_job(job) + ws = wb.active + ws.title = "merged"[:31] + _append_sheet( + ws, + merged_export_headers(job), + JdJobMergedRow.objects.filter(job=job), + lambda o, _mk=mk: _merged_row_csv_dict(o, _mk), + ) + name = f"job_{job.id}_merged.xlsx" + else: + raise ValueError(f"unknown kind: {kind}") + bio = BytesIO() + wb.save(bio) + return bio.getvalue(), name diff --git a/backend/pipeline/ingest.py b/backend/pipeline/ingest.py new file mode 100644 index 0000000..3de93cd --- /dev/null +++ b/backend/pipeline/ingest.py @@ -0,0 +1,278 @@ +""" +任务成功后入库: +- 搜索导出 / 商详导出 / 评价扁平:按任务分表存储,便于分页与导出; +- 合并表:更新全局 ``JdProduct`` + 任务维度 ``JdProductSnapshot``。 +""" +from __future__ import annotations + +import csv +import logging +from pathlib import Path +from typing import Any + +from django.db import transaction +from django.utils import timezone + +from .csv_schema import ( + COMMENT_CSV_COLUMNS, + COMMENT_CSV_TO_FIELD, + DETAIL_CSV_COLUMNS, + DETAIL_CSV_TO_FIELD, + JD_SEARCH_CSV_HEADERS, + JD_SEARCH_INTERNAL_KEYS, + MERGED_CSV_COLUMNS, + MERGED_CSV_TO_FIELD, + SEARCH_CSV_HEADER_TO_FIELD, +) +from .models import ( + JdJobCommentRow, + JdJobDetailRow, + JdJobMergedRow, + JdJobSearchRow, + JdProduct, + JdProductSnapshot, + PipelineJob, +) + +logger = logging.getLogger(__name__) + +FILE_MERGED_CSV = "keyword_pipeline_merged.csv" +FILE_PC_SEARCH_CSV = "pc_search_export.csv" +FILE_DETAIL_WARE_CSV = "detail_ware_export.csv" +FILE_COMMENTS_FLAT_CSV = "comments_flat.csv" + +SKU_FIELD_MERGED = "SKU(skuId)" +WARE_FIELD = "主商品ID(wareId)" +TITLE_FIELD = "标题(wareName)" + +BULK_CHUNK = 400 + + +def _read_csv_rows(path: Path) -> list[dict[str, str]]: + if not path.is_file(): + return [] + raw = path.read_text(encoding="utf-8-sig") + lines = raw.splitlines() + if not lines: + return [] + return list(csv.DictReader(lines)) + + +def _payload_as_json(row: dict[str, str]) -> dict[str, str]: + return {str(k): str(v) if v is not None else "" for k, v in row.items()} + + +def _search_row_kwargs(row: dict[str, str]) -> dict[str, str]: + vals = {k: "" for k in JD_SEARCH_INTERNAL_KEYS} + for csv_header, cell in row.items(): + h = (csv_header or "").strip() + fn = SEARCH_CSV_HEADER_TO_FIELD.get(h) + if fn: + vals[fn] = str(cell or "").strip() + return vals + + +def _detail_row_kwargs(row: dict[str, str]) -> dict[str, str]: + return { + DETAIL_CSV_TO_FIELD[col]: str(row.get(col) or "").strip() for col in DETAIL_CSV_COLUMNS + } + + +def _comment_row_kwargs(row: dict[str, str]) -> dict[str, str]: + return { + COMMENT_CSV_TO_FIELD[col]: str(row.get(col) or "").strip() for col in COMMENT_CSV_COLUMNS + } + + +def _merged_row_kwargs(row: dict[str, str]) -> dict[str, str]: + return { + MERGED_CSV_TO_FIELD[col]: str(row.get(col) or "").strip() for col in MERGED_CSV_COLUMNS + } + + +def _bulk_create_in_chunks(model, objects: list[Any]) -> None: + for i in range(0, len(objects), BULK_CHUNK): + model.objects.bulk_create(objects[i : i + BULK_CHUNK]) + + +def _run_dir(job: PipelineJob) -> Path: + return Path(job.run_dir or "").expanduser().resolve() + + +def ingest_job_dataset_rows(job: PipelineJob) -> dict[str, Any]: + """ + 删除该任务旧数据后,将 ``pc_search_export`` / ``detail_ware_export`` / ``comments_flat`` 全量写入数据库。 + """ + if not (job.run_dir or "").strip(): + raise FileNotFoundError("任务无 run_dir") + + run_dir = _run_dir(job) + stats: dict[str, Any] = { + "search_rows": 0, + "detail_rows": 0, + "comment_rows": 0, + "merged_table_rows": 0, + } + + JdJobSearchRow.objects.filter(job=job).delete() + JdJobDetailRow.objects.filter(job=job).delete() + JdJobCommentRow.objects.filter(job=job).delete() + JdJobMergedRow.objects.filter(job=job).delete() + + search_path = run_dir / FILE_PC_SEARCH_CSV + search_rows = _read_csv_rows(search_path) + if not search_rows and search_path.is_file() is False: + pass + s_objs: list[JdJobSearchRow] = [] + for i, row in enumerate(search_rows): + kw = _search_row_kwargs(row) + s_objs.append(JdJobSearchRow(job=job, row_index=i, **kw)) + _bulk_create_in_chunks(JdJobSearchRow, s_objs) + stats["search_rows"] = len(s_objs) + + detail_path = run_dir / FILE_DETAIL_WARE_CSV + detail_rows = _read_csv_rows(detail_path) + d_objs: list[JdJobDetailRow] = [] + for i, row in enumerate(detail_rows): + kw = _detail_row_kwargs(row) + d_objs.append(JdJobDetailRow(job=job, row_index=i, **kw)) + _bulk_create_in_chunks(JdJobDetailRow, d_objs) + stats["detail_rows"] = len(d_objs) + + comment_path = run_dir / FILE_COMMENTS_FLAT_CSV + comment_rows = _read_csv_rows(comment_path) + c_objs: list[JdJobCommentRow] = [] + for i, row in enumerate(comment_rows): + kw = _comment_row_kwargs(row) + c_objs.append(JdJobCommentRow(job=job, row_index=i, **kw)) + _bulk_create_in_chunks(JdJobCommentRow, c_objs) + stats["comment_rows"] = len(c_objs) + + merged_path = run_dir / FILE_MERGED_CSV + merged_rows = _read_csv_rows(merged_path) if merged_path.is_file() else [] + m_objs: list[JdJobMergedRow] = [] + for i, row in enumerate(merged_rows): + kw = _merged_row_kwargs(row) + m_objs.append(JdJobMergedRow(job=job, row_index=i, **kw)) + _bulk_create_in_chunks(JdJobMergedRow, m_objs) + stats["merged_table_rows"] = len(m_objs) + + return stats + + +def ingest_job_merged_csv(job: PipelineJob) -> dict[str, Any]: + """ + 读取合并表,upsert ``JdProduct``,并按 (商品, 任务) 写入 ``JdProductSnapshot``。 + """ + run_dir = _run_dir(job) + path = run_dir / FILE_MERGED_CSV + if not path.is_file(): + raise FileNotFoundError(f"合并表不存在: {path}") + + rows = _read_csv_rows(path) + captured_at = job.updated_at or timezone.now() + stats = { + "merged_file": str(path), + "rows_in_csv": len(rows), + "rows_ingested": 0, + "products_created": 0, + "snapshots_upserted": 0, + } + + platform = (job.platform or "jd").strip() or "jd" + + for row in rows: + sku = (row.get(SKU_FIELD_MERGED) or "").strip() + if not sku: + continue + payload = _payload_as_json(row) + title = (row.get(TITLE_FIELD) or "")[:2000] + ware = (row.get(WARE_FIELD) or "").strip()[:64] + brand = (row.get("detail_brand") or "").strip()[:512] + price = ( + (row.get("detail_price_final") or "").strip() + or (row.get(JD_SEARCH_CSV_HEADERS["coupon_price"]) or "").strip() + or (row.get(JD_SEARCH_CSV_HEADERS["price"]) or "").strip() + )[:128] + cat = ( + (row.get("detail_category_path") or "").strip() + or (row.get(JD_SEARCH_CSV_HEADERS["leaf_category"]) or "").strip() + )[:2000] + + product, created = JdProduct.objects.get_or_create( + platform=platform, + sku_id=sku, + defaults={ + "ware_id": ware, + "title": title, + "detail_brand": brand, + "detail_price_final": price, + "detail_category_path": cat, + "current_payload": payload, + "last_job": job, + "last_captured_at": captured_at, + }, + ) + if created: + stats["products_created"] += 1 + else: + product.ware_id = ware or product.ware_id + product.title = title or product.title + product.detail_brand = brand + product.detail_price_final = price + product.detail_category_path = cat + product.current_payload = payload + product.last_job = job + product.last_captured_at = captured_at + product.save( + update_fields=[ + "ware_id", + "title", + "detail_brand", + "detail_price_final", + "detail_category_path", + "current_payload", + "last_job", + "last_captured_at", + "updated_at", + ] + ) + + JdProductSnapshot.objects.update_or_create( + product=product, + job=job, + defaults={ + "run_dir": job.run_dir or "", + "captured_at": captured_at, + "payload": payload, + }, + ) + stats["snapshots_upserted"] += 1 + stats["rows_ingested"] += 1 + + return stats + + +def ingest_job_full(job: PipelineJob) -> dict[str, Any]: + """ + 先提交搜索/详情/评论(与 CSV 行一一对应),再单独提交合并表主档与快照。 + 合并表缺失时仍保留前三类数据,便于仅用列表/评价做回顾。 + """ + out: dict[str, Any] = {} + with transaction.atomic(): + out["dataset"] = ingest_job_dataset_rows(job) + try: + with transaction.atomic(): + out["merged"] = ingest_job_merged_csv(job) + except FileNotFoundError as e: + logger.warning("ingest merged skipped job=%s: %s", job.id, e) + out["merged"] = {"error": str(e), "rows_ingested": 0, "snapshots_upserted": 0} + return out + + +def try_ingest_job_full(job: PipelineJob) -> None: + try: + stats = ingest_job_full(job) + logger.info("ingest_job_full job=%s %s", job.id, stats) + except Exception: + logger.exception("ingest_job_full failed job=%s", job.id) diff --git a/backend/pipeline/jd_runner.py b/backend/pipeline/jd_runner.py new file mode 100644 index 0000000..ad34844 --- /dev/null +++ b/backend/pipeline/jd_runner.py @@ -0,0 +1,490 @@ +""" +使用 ``crawler_copy/jd_pc_search`` 中的副本脚本执行流水线并生成竞品 Markdown。 +依赖环境变量 ``LOW_GI_PROJECT_ROOT``(由 Django settings 从 ``market_assistant/.env`` 注入)。 +""" +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path +from typing import Any + +from django.conf import settings + +from .models import PipelineJob + + +def merge_llm_supplement_with_rules_report(llm_md: str, rules_md: str) -> str: + """ + **以规则引擎全文为正文**(含 §5 完整竞品矩阵、各章内嵌统计图与表格)。 + + 大模型稿仅作为开篇「速读/策略补充」插入在「## 一、」之前,**不得**再用纯 LLM 稿 + 覆盖规则正文(否则会丢失矩阵与章节结构)。 + """ + body = (rules_md or "").strip() + sup = (llm_md or "").strip() + if not sup: + return body + if not body: + return sup + block = ( + "---\n\n" + "## 大模型速读与策略要点(补充)\n\n" + "> **说明**:以下由大模型依据结构化摘要生成,便于速览;**完整竞品对比矩阵、全部表格、" + "统计图与定量口径以正文各章(尤其 §5)为准**,请勿仅依据本段理解 SKU 明细。\n\n" + f"{sup}\n" + ) + marker = "\n---\n\n## 一、研究范围、数据来源与局限" + if marker in body: + return body.replace(marker, "\n" + block + marker, 1) + return block + "\n---\n\n" + body + + +def merge_llm_report_with_rules_charts(llm_md: str, rules_md: str) -> str: + """兼容旧名:等价于 ``merge_llm_supplement_with_rules_report``。""" + return merge_llm_supplement_with_rules_report(llm_md, rules_md) + + +def _flat_comment_texts(comment_rows: list[dict[str, str]]) -> list[str]: + """全部非空评价正文(与报告统计同源)。""" + out: list[str] = [] + for row in comment_rows: + t = (row.get("tagCommentContent") or "").strip() + if t: + out.append(t) + return out + + +def _safe_dir_segment_for_job(s: str, max_len: int = 48) -> str: + """与 ``jd_keyword_pipeline._safe_dir_segment`` 一致,避免多线程下改模块全局。""" + 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_directory_for_job(job: PipelineJob) -> Path: + """ + 在拉起子进程前固定本次 ``run_dir``(与 ``jd_keyword_pipeline._resolve_pipeline_run_dir`` 同口径)。 + 调用方负责 ``mkdir``。 + """ + root = (settings.LOW_GI_PROJECT_ROOT or "").strip() + if not root: + raise RuntimeError("LOW_GI_PROJECT_ROOT 未配置") + project_data = Path(root).resolve() / "data" / "JD" + prd = (job.pipeline_run_dir or "").strip() + kw = (job.keyword or "").strip() + if prd: + p = Path(prd).expanduser() + if not p.is_absolute(): + p = project_data / p + return p.resolve() + import time + + stamp = time.strftime("%Y%m%d_%H%M%S") + seg = _safe_dir_segment_for_job(kw) + return (project_data / "pipeline_runs" / f"{stamp}_{seg}").resolve() + + +def try_write_competitor_report_if_merged_exists( + run_dir: Path, + keyword: str, + *, + report_config: dict[str, Any] | None = None, +) -> None: + """若已有合并表则补写竞品 Markdown(用于子进程被 terminate 后的部分产物)。""" + _, kpl = _jd_crawler_modules() + base = Path(run_dir).resolve() + merged = base / kpl.FILE_MERGED_CSV + if not merged.is_file(): + return + try: + write_competitor_analysis_for_run_dir( + base, keyword, report_config=report_config + ) + except Exception: + pass + + +def _jd_crawler_modules(): + root = Path(settings.CRAWLER_JD_ROOT) + if not root.is_dir(): + raise FileNotFoundError(f"爬虫副本目录不存在: {root}") + root_s = str(root.resolve()) + if root_s not in sys.path: + sys.path.insert(0, root_s) + import jd_competitor_report as jcr # noqa: WPS433 + import jd_keyword_pipeline as kpl # noqa: WPS433 + + return jcr, kpl + + +def get_default_report_config() -> dict[str, Any]: + """与 ``jd_competitor_report`` 模块常量一致的默认报告调参(供前端回填)。""" + jcr, _ = _jd_crawler_modules() + return { + "llm_comment_sentiment": False, + "comment_focus_words": list(jcr.COMMENT_FOCUS_WORDS), + "comment_scenario_groups": [ + {"label": lbl, "triggers": list(trs)} + for lbl, trs in jcr.COMMENT_SCENARIO_GROUPS + ], + "external_market_table_rows": [ + {"indicator": a, "value_and_scope": b, "source": c, "year": d} + for a, b, c, d in jcr.EXTERNAL_MARKET_TABLE_ROWS + ], + } + + +def write_competitor_analysis_for_run_dir( + run_dir: Path, + keyword: str, + *, + report_config: dict[str, Any] | None = None, +) -> Path: + """ + 在已有流水线目录上读取 CSV / meta,写入 ``competitor_analysis.md``(不重新爬取)。 + """ + jcr, kpl = _jd_crawler_modules() + kw = (keyword or "").strip() + if not kw: + raise ValueError("keyword 不能为空") + + run_dir = Path(run_dir).resolve() + merged_path = run_dir / kpl.FILE_MERGED_CSV + if not merged_path.is_file(): + raise FileNotFoundError(f"缺少合并表,无法生成报告: {merged_path.name}") + + _, merged_rows = jcr._read_csv_rows(merged_path) + _, search_export_rows = jcr._read_csv_rows(run_dir / kpl.FILE_PC_SEARCH_CSV) + _, comment_rows = jcr._read_csv_rows(run_dir / kpl.FILE_COMMENTS_FLAT_CSV) + + meta_path = run_dir / kpl.FILE_RUN_META_JSON + meta: dict[str, Any] | None = None + if meta_path.is_file(): + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + meta = None + + eff_rc: dict[str, Any] = ( + dict(report_config) if isinstance(report_config, dict) else {} + ) + all_tx = _flat_comment_texts(comment_rows) + suggest_path = run_dir / "keyword_suggest_llm.json" + suggest_record: dict[str, Any] = { + "schema_version": 2, + "total_comment_texts": len(all_tx), + } + skip_kw = os.environ.get("MA_SKIP_LLM_KEYWORD_SUGGEST", "").strip().lower() in ( + "1", + "true", + "yes", + ) + if not skip_kw: + try: + from .llm_keyword_suggest import suggest_focus_keywords_from_all_comments + + brief_pre = jcr.build_competitor_brief( + run_dir=run_dir, + keyword=kw, + merged_rows=merged_rows, + search_export_rows=search_export_rows, + comment_rows=comment_rows, + meta=meta, + report_config=eff_rc, + ) + brief_slice = { + "keyword": brief_pre.get("keyword"), + "comment_focus_keywords": ( + brief_pre.get("comment_focus_keywords") or [] + )[:20], + "usage_scenarios": (brief_pre.get("usage_scenarios") or [])[:8], + "category_mix_top": (brief_pre.get("category_mix_top") or [])[:6], + "scope": brief_pre.get("scope"), + } + sug = suggest_focus_keywords_from_all_comments( + keyword=kw, + brief_slice=brief_slice, + all_comment_texts=all_tx, + ) + suggest_record.update(sug) + base_words = list(eff_rc.get("comment_focus_words") or []) + for w in sug.get("suggested_focus_keywords") or []: + if isinstance(w, str): + t = w.strip() + if t and t not in base_words: + base_words.append(t) + eff_rc["comment_focus_words"] = base_words[:80] + except Exception as e: + suggest_record["error"] = str(e) + suggest_record["suggested_focus_keywords"] = [] + else: + suggest_record["skipped"] = True + suggest_record["suggested_focus_keywords"] = [] + + suggest_path.write_text( + json.dumps(suggest_record, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + (run_dir / "effective_report_config.json").write_text( + json.dumps(eff_rc, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + brief_final = jcr.build_competitor_brief( + run_dir=run_dir, + keyword=kw, + merged_rows=merged_rows, + search_export_rows=search_export_rows, + comment_rows=comment_rows, + meta=meta, + report_config=eff_rc, + ) + from .report_charts import generate_report_charts + + generate_report_charts(run_dir, brief_final) + + llm_sentiment_md = "" + sentiment_llm_record: dict[str, Any] = { + "schema_version": 1, + "attempted": False, + } + skip_sent = os.environ.get( + "MA_SKIP_LLM_COMMENT_SENTIMENT", "" + ).strip().lower() in ("1", "true", "yes") + env_on = os.environ.get("MA_ENABLE_LLM_COMMENT_SENTIMENT", "").strip().lower() in ( + "1", + "true", + "yes", + ) + want_sent = bool(eff_rc.get("llm_comment_sentiment")) or env_on + if want_sent and not skip_sent: + comment_units = jcr._iter_comment_text_units(comment_rows, merged_rows) + if len(comment_units) >= 2: + sentiment_llm_record["attempted"] = True + try: + from .llm_generate import generate_comment_sentiment_analysis_llm + + pl = jcr.build_comment_sentiment_llm_payload(comment_units) + pl["keyword"] = kw + llm_sentiment_md = generate_comment_sentiment_analysis_llm(pl) + sentiment_llm_record["ok"] = True + sentiment_llm_record["chars"] = len(llm_sentiment_md) + except Exception as e: + sentiment_llm_record["ok"] = False + sentiment_llm_record["error"] = str(e) + else: + sentiment_llm_record["skipped"] = "insufficient_comment_texts" + elif skip_sent: + sentiment_llm_record["skipped"] = "MA_SKIP_LLM_COMMENT_SENTIMENT" + elif not want_sent: + sentiment_llm_record["skipped"] = "not_enabled" + + (run_dir / "comment_sentiment_llm.json").write_text( + json.dumps(sentiment_llm_record, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + md = jcr.build_competitor_markdown( + run_dir=run_dir, + keyword=kw, + merged_rows=merged_rows, + search_export_rows=search_export_rows, + comment_rows=comment_rows, + meta=meta, + report_config=eff_rc, + llm_sentiment_section_md=llm_sentiment_md or None, + ) + out_md = run_dir / "competitor_analysis.md" + out_md.write_text(md, encoding="utf-8") + return run_dir + + +def regenerate_competitor_report( + run_dir_str: str, + keyword: str, + *, + report_config: dict[str, Any] | None = None, +) -> Path: + """校验 ``run_dir`` 位于 ``LOW_GI_PROJECT_ROOT/data/JD`` 下后,重写竞品 Markdown。""" + low_root = (settings.LOW_GI_PROJECT_ROOT or "").strip() + if not low_root: + raise RuntimeError("LOW_GI_PROJECT_ROOT 未配置") + base = Path(run_dir_str).expanduser().resolve() + jd_root = (Path(low_root) / "data" / "JD").resolve() + try: + base.relative_to(jd_root) + except ValueError as e: + raise ValueError("run_dir 不在京东数据目录下") from e + return write_competitor_analysis_for_run_dir( + base, keyword, report_config=report_config + ) + + +def write_competitor_analysis_markdown(run_dir_str: str, markdown: str) -> Path: + """将已生成的 Markdown 正文写入 ``run_dir/competitor_analysis.md``(与规则重生成同路径)。""" + low_root = (settings.LOW_GI_PROJECT_ROOT or "").strip() + if not low_root: + raise RuntimeError("LOW_GI_PROJECT_ROOT 未配置") + base = Path(run_dir_str).expanduser().resolve() + jd_root = (Path(low_root) / "data" / "JD").resolve() + try: + base.relative_to(jd_root) + except ValueError as e: + raise ValueError("run_dir 不在京东数据目录下") from e + out = base / "competitor_analysis.md" + out.write_text(markdown or "", encoding="utf-8") + return out + + +def build_competitor_brief_for_job( + run_dir_str: str, + keyword: str, + *, + report_config: dict[str, Any] | None = None, +) -> dict[str, Any]: + """ + 读取 ``run_dir`` 下合并表 / 搜索导出 / 评价 / meta,返回与 Markdown 报告同口径的 **JSON 结构化摘要**(规则驱动)。 + ``run_dir`` 须位于 ``LOW_GI_PROJECT_ROOT/data/JD`` 下。 + """ + low_root = (settings.LOW_GI_PROJECT_ROOT or "").strip() + if not low_root: + raise RuntimeError("LOW_GI_PROJECT_ROOT 未配置") + base = Path(run_dir_str).expanduser().resolve() + jd_root = (Path(low_root) / "data" / "JD").resolve() + try: + base.relative_to(jd_root) + except ValueError as e: + raise ValueError("run_dir 不在京东数据目录下") from e + + jcr, kpl = _jd_crawler_modules() + kw = (keyword or "").strip() + if not kw: + raise ValueError("keyword 不能为空") + + merged_path = base / kpl.FILE_MERGED_CSV + if not merged_path.is_file(): + raise FileNotFoundError(f"缺少合并表,无法生成摘要: {merged_path.name}") + + _, merged_rows = jcr._read_csv_rows(merged_path) + _, search_export_rows = jcr._read_csv_rows(base / kpl.FILE_PC_SEARCH_CSV) + _, comment_rows = jcr._read_csv_rows(base / kpl.FILE_COMMENTS_FLAT_CSV) + + meta_path = base / kpl.FILE_RUN_META_JSON + meta: dict[str, Any] | None = None + if meta_path.is_file(): + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + meta = None + + eff: dict[str, Any] | None = None + if isinstance(report_config, dict): + eff = dict(report_config) + eff_path = base / "effective_report_config.json" + if eff_path.is_file(): + try: + loaded = json.loads(eff_path.read_text(encoding="utf-8")) + if isinstance(loaded, dict) and loaded: + eff = loaded + except json.JSONDecodeError: + pass + + return jcr.build_competitor_brief( + run_dir=base, + keyword=kw, + merged_rows=merged_rows, + search_export_rows=search_export_rows, + comment_rows=comment_rows, + meta=meta, + report_config=eff, + ) + + +def run_jd_keyword_and_report( + keyword: str, + *, + max_skus: int | None = None, + page_start: int | None = None, + page_to: int | None = None, + pipeline_run_dir: str | None = None, + cookie_file_path: str | None = None, + pvid: str | None = None, + request_delay: str | None = None, + list_pages: str | None = None, + scenario_filter_enabled: bool | None = None, + report_config: dict[str, Any] | None = None, + cancel_check: Any | None = None, +) -> Path: + _, kpl = _jd_crawler_modules() + + kw = (keyword or "").strip() + if not kw: + raise ValueError("keyword 不能为空") + + backup: dict[str, Any] = {} + if cancel_check is not None: + backup["PIPELINE_CANCEL_CHECK"] = getattr(kpl, "PIPELINE_CANCEL_CHECK", None) + kpl.PIPELINE_CANCEL_CHECK = cancel_check + try: + if max_skus is not None: + backup["MAX_SKUS"] = kpl.MAX_SKUS + kpl.MAX_SKUS = max(1, int(max_skus)) + if page_start is not None: + backup["PAGE_START"] = kpl.PAGE_START + kpl.PAGE_START = max(1, int(page_start)) + if page_to is not None: + backup["PAGE_TO"] = kpl.PAGE_TO + kpl.PAGE_TO = max(1, int(page_to)) + + prd = (pipeline_run_dir or "").strip() + if prd: + backup["PIPELINE_RUN_DIR"] = kpl.PIPELINE_RUN_DIR + kpl.PIPELINE_RUN_DIR = prd + + cf = (cookie_file_path or "").strip() + if cf: + backup["PIPELINE_COOKIE_FILE"] = kpl.PIPELINE_COOKIE_FILE + kpl.PIPELINE_COOKIE_FILE = cf + + pv = (pvid or "").strip() + if pv: + backup["PVID"] = kpl.PVID + kpl.PVID = pv + + rd = (request_delay or "").strip() + if rd: + backup["REQUEST_DELAY"] = kpl.REQUEST_DELAY + kpl.REQUEST_DELAY = rd + + lp = (list_pages or "").strip() + if lp: + backup["LIST_PAGES"] = kpl.LIST_PAGES + kpl.LIST_PAGES = lp + + if scenario_filter_enabled is not None: + backup["SCENARIO_FILTER_ENABLED"] = kpl.SCENARIO_FILTER_ENABLED + kpl.SCENARIO_FILTER_ENABLED = bool(scenario_filter_enabled) + + run_dir = kpl.main(keyword=kw) + except kpl.PipelineCancelled as e: + run_dir_path = Path(e.run_dir).resolve() + merged = run_dir_path / kpl.FILE_MERGED_CSV + if merged.is_file(): + try: + write_competitor_analysis_for_run_dir( + run_dir_path, kw, report_config=report_config + ) + except Exception: + pass + raise + finally: + for name, val in backup.items(): + setattr(kpl, name, val) + + return write_competitor_analysis_for_run_dir( + Path(run_dir).resolve(), kw, report_config=report_config + ) diff --git a/backend/pipeline/llm_generate.py b/backend/pipeline/llm_generate.py new file mode 100644 index 0000000..09093f4 --- /dev/null +++ b/backend/pipeline/llm_generate.py @@ -0,0 +1,150 @@ +""" +竞品报告 / 策略稿的**大模型生成**:通过 ``crawler_copy/jd_pc_search/AI_crawler`` 的 +``chat_completion_text`` 调用,与配料识别共用网关与密钥。 +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +from django.conf import settings + +from .brief_compact import compact_brief_for_llm +from .strategy_draft import build_strategy_draft_markdown + + +def _ensure_ai_crawler_path() -> None: + root = Path(settings.CRAWLER_JD_ROOT).resolve() + if not root.is_dir(): + raise FileNotFoundError(f"爬虫副本目录不存在: {root}") + rs = str(root) + if rs not in sys.path: + sys.path.insert(0, rs) + + +def _call_llm(system_prompt: str, user_prompt: str) -> str: + _ensure_ai_crawler_path() + import AI_crawler as ac # noqa: WPS433 + + raw = ac.chat_completion_text( + system_prompt=system_prompt, + user_prompt=user_prompt, + ) + return ac.strip_outer_markdown_fence(raw) + + +REPORT_SYSTEM = """你撰写一段**短小的「速读与策略补充」**,插在完整规则报告**之前**供读者扫读。读者为业务与产品。 + +**输入**:JSON 含 `keyword`、`competitor_brief`(可能经裁剪)、`matrix_overview_for_llm`(按细分类目的 SKU 数与品牌样本)。 +所有数字、占比、条数、品牌名、价格区间等**必须严格来自输入 JSON**,禁止编造未在输入中出现的定量结论。 + +**硬性禁止**: +- **不要**输出完整报告目录或重复「研究范围与方法」等长章结构; +- **不要**撰写 Markdown 表格版「竞品对比矩阵」或罗列 SKU 明细——**正文报告已含完整矩阵**,此处仅可概括分组级结论(细类名、SKU 数、主要品牌来自 `matrix_overview_for_llm` / brief); +- **不要**写「matrix_by_group 已省略」「仅保留代表性品牌」等免责声明,也不要引导读者认为明细缺失; +- **不要使用** CR1、CR3 等英文缩写;集中度请用「第一大品牌份额」「前三品牌合计份额」。 + +**请输出**(仅输出正文,不要前言后语): +- 使用 **Markdown**,控制在约 **800~1500 字**; +- 建议小节标题(二级):**执行摘要要点**、**竞争与价盘速读**、**用户声量与关注点**、**策略提示与数据边界**; +- 若有 `comment_sentiment_lexicon`,概括正/负向粗判与局限(非深度学习); +- 语气专业、中文;缺失项写「本摘要未提供该项」而非猜测。""" + +REPORT_USER_PREFIX = """请根据以下 JSON 撰写完整竞品分析报告(Markdown 正文)。\n\n""" + + +def generate_competitor_report_markdown_llm(brief: dict[str, Any], keyword: str) -> str: + compact = compact_brief_for_llm(brief) + payload = { + "keyword": keyword, + "competitor_brief": compact, + "matrix_overview_for_llm": compact.get("matrix_overview_for_llm") or [], + } + user = REPORT_USER_PREFIX + json.dumps(payload, ensure_ascii=False) + return _call_llm(REPORT_SYSTEM, user) + + +SENTIMENT_LLM_SYSTEM = """你是电商/食品类用户研究助手。输入 JSON 含: +- ``comment_sentiment_lexicon``:关键词规则下的条数与短语命中(粗判,非深度学习); +- ``sample_reviews_*``:按同一规则从评价中抽样的短文(已截断),**仅可依据这些原文与 lexicon 数字归纳**。 + +**硬性要求**: +- **仅输出 Markdown 正文**(不要用 ``` 围栏包裹全文); +- **不要编造**样本中未出现的具体事实、品牌、价格、医学功效; +- 条数、占比等**定量表述须与** ``comment_sentiment_lexicon`` **一致**,勿与样本矛盾。 + +**建议结构**(使用四级标题 ``####``): +1. ``#### 正向要点归纳``:3~6 条要点,概括满意点(口感、甜度、包装、物流、性价比等); +2. ``#### 负向与风险点归纳``:3~6 条要点; +3. ``#### 使用注意``:1~2 句说明样本量、抽样局限、与关键词规则可能不一致之处。 + +总字数约 **400~900 字**,简体中文,语气客观。""" + + +def generate_comment_sentiment_analysis_llm(payload: dict[str, Any]) -> str: + """基于规则分桶抽样评价 + lexicon 统计,生成 §8.2 大模型解读段落(Markdown)。""" + p = dict(payload) + raw = json.dumps(p, ensure_ascii=False) + if len(raw) > 88_000: + for k in ( + "sample_reviews_positive_biased", + "sample_reviews_negative_biased", + "sample_reviews_mixed_tone", + ): + lst = p.get(k) + if isinstance(lst, list): + p[k] = [str(x)[:140] for x in lst[:8]] + raw = json.dumps(p, ensure_ascii=False) + if len(raw) > 88_000: + raw = raw[:82_000] + "\n\n…(输入过长已截断,请勿编造截断外内容)\n" + user = "请根据以下 JSON 按系统说明输出 Markdown:\n\n" + raw + return _call_llm(SENTIMENT_LLM_SYSTEM, user) + + +STRATEGY_SYSTEM = """你是市场策略顾问,根据**结构化监测摘要**与业务侧填写的**决策字段**,把「规则底稿」润色为可读的策略 Markdown。 + +**规则**: +- 输入 JSON 含 `rules_draft_markdown`(规则引擎生成的底稿,与同任务数据一致)、`structured_brief`(摘要子集)、`strategy_decisions`、`business_notes` 等; +- **不得编造**输入中不存在的销量、占比、价格数字;若底稿与摘要中有数字,须保持一致;表述集中度时用「第一大品牌份额」等中文,**不要用** CR1、CR3 缩写; +- 若 `structured_brief` 含 `matrix_overview_for_llm` 或矩阵相关字段,策略中应**呼应**细分类目分组与竞品矩阵结论,不得无故删光矩阵相关建议; +- 可调整段落衔接、标题层级、列表与表格呈现,使更易读;可补充「建议」「待业务确认」类表述,但不虚构竞品名称或数据; +- **仅输出** Markdown 正文(不要 ``` 围栏包裹全文)。""" + +STRATEGY_USER_PREFIX = """请基于以下 JSON 输出最终策略稿(Markdown)。\n\n""" + + +def generate_strategy_draft_markdown_llm( + *, + job_id: int, + keyword: str, + brief: dict[str, Any], + business_notes: str, + generated_at_iso: str, + strategy_decisions: dict[str, Any], +) -> str: + rules_md = build_strategy_draft_markdown( + job_id=job_id, + keyword=keyword, + brief=brief, + business_notes=business_notes, + generated_at_iso=generated_at_iso, + strategy_decisions=strategy_decisions, + ) + compact = compact_brief_for_llm(brief, max_chars=80_000) + payload = { + "job_id": job_id, + "keyword": keyword, + "generated_at_iso": generated_at_iso, + "strategy_decisions": strategy_decisions, + "business_notes": business_notes, + "structured_brief": compact, + "rules_draft_markdown": rules_md, + } + raw = json.dumps(payload, ensure_ascii=False) + if len(raw) > 500_000: + payload["rules_draft_markdown"] = rules_md[:200_000] + "\n\n…(底稿过长已截断,请勿编造截断后内容)\n" + raw = json.dumps(payload, ensure_ascii=False) + user = STRATEGY_USER_PREFIX + raw + return _call_llm(STRATEGY_SYSTEM, user) diff --git a/backend/pipeline/llm_keyword_suggest.py b/backend/pipeline/llm_keyword_suggest.py new file mode 100644 index 0000000..c26574c --- /dev/null +++ b/backend/pipeline/llm_keyword_suggest.py @@ -0,0 +1,164 @@ +"""在报告生成前:基于**全量**评价文本分块调用大模型,联想补充关注词(参与后续统计与报告)。""" +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Any + +from django.conf import settings + +MAX_CHUNK_CHARS = 24_000 +MAX_CHUNKS = 12 + +_CHUNK_SYSTEM = """你是电商评价挖掘助手。输入 JSON 含 keyword、excerpt_index、excerpts(一段用户评价正文合集)。 +任务:从 excerpts 中抽取值得纳入「关注词/卖点监测」的**中文短语**(2~12 字为主,可为词组)。 + +硬性规则: +- 仅输出一段 JSON:{"phrases": ["短语1", ...]},短语共 6~20 条。 +- 不要医疗功效、治愈、降血糖承诺;不要完整复制整句评价。 +- 不要输出与常见停用词无信息量的单字。 +- 不要输出 JSON 以外的文字。""" + + +def _ensure_ai_crawler_path() -> None: + root = Path(settings.CRAWLER_JD_ROOT).resolve() + if not root.is_dir(): + raise FileNotFoundError(f"爬虫副本目录不存在: {root}") + rs = str(root) + if rs not in sys.path: + sys.path.insert(0, rs) + + +def _call_llm(system_prompt: str, user_prompt: str) -> str: + _ensure_ai_crawler_path() + import AI_crawler as ac # noqa: WPS433 + + return ac.chat_completion_text( + system_prompt=system_prompt, + user_prompt=user_prompt, + ) + + +def _chunk_comment_texts(texts: list[str]) -> list[str]: + """将全量评价划为若干段,控制单段字符量与最大段数。""" + parts: list[str] = [] + cur: list[str] = [] + cur_len = 0 + for t in texts: + s = (t or "").strip() + if not s: + continue + extra = len(s) + 1 + if cur and cur_len + extra > MAX_CHUNK_CHARS: + parts.append("\n".join(cur)) + cur = [] + cur_len = 0 + cur.append(s) + cur_len += extra + if cur: + parts.append("\n".join(cur)) + if not parts: + return [] + if len(parts) <= MAX_CHUNKS: + return parts + idxs = sorted( + {min(int(i * len(parts) / MAX_CHUNKS), len(parts) - 1) for i in range(MAX_CHUNKS)} + ) + return [parts[i] for i in idxs] + + +def _parse_phrases_object(raw: str) -> list[str]: + t = raw.strip() + t = re.sub(r"^```(?:json)?\s*", "", t) + t = re.sub(r"\s*```$", "", t) + try: + obj = json.loads(t) + if isinstance(obj, dict) and isinstance(obj.get("phrases"), list): + return [str(x).strip() for x in obj["phrases"] if str(x).strip()] + except json.JSONDecodeError: + pass + m = re.search(r"\{[\s\S]*\}", t) + if m: + try: + obj = json.loads(m.group(0)) + if isinstance(obj, dict) and isinstance(obj.get("phrases"), list): + return [str(x).strip() for x in obj["phrases"] if str(x).strip()] + except json.JSONDecodeError: + pass + return [] + + +def suggest_focus_keywords_from_all_comments( + *, + keyword: str, + brief_slice: dict[str, Any], + all_comment_texts: list[str], +) -> dict[str, Any]: + """ + 读取全量评价(在服务端分块),每块调用模型抽取短语,合并去重后返回 ``suggested_focus_keywords``。 + """ + if not all_comment_texts: + return { + "suggested_focus_keywords": [], + "suggested_scenario_hints": [], + "rationale": "无评价正文可分析。", + "chunks_processed": 0, + "total_comment_texts": 0, + } + + existing: set[str] = set() + for x in brief_slice.get("comment_focus_keywords") or []: + if isinstance(x, dict): + w = str(x.get("word") or "").strip() + if w: + existing.add(w) + + chunks = _chunk_comment_texts(all_comment_texts) + collected: list[str] = [] + for i, ch in enumerate(chunks): + payload = { + "keyword": keyword, + "excerpt_index": i + 1, + "excerpts": ch, + } + raw = _call_llm(_CHUNK_SYSTEM, json.dumps(payload, ensure_ascii=False)) + collected.extend(_parse_phrases_object(raw)) + + seen: set[str] = set() + merged: list[str] = [] + for p in collected: + t = p.strip() + if len(t) < 2 or len(t) > 24: + continue + if t in seen or t in existing: + continue + seen.add(t) + merged.append(t) + + out_kw = merged[:22] + return { + "suggested_focus_keywords": out_kw, + "suggested_scenario_hints": [], + "rationale": ( + f"基于全量 {len(all_comment_texts)} 条评价文本,分 {len(chunks)} 段调用模型抽取短语并去重;" + f"已排除与当前关注词统计表完全相同的词。" + ), + "chunks_processed": len(chunks), + "total_comment_texts": len(all_comment_texts), + } + + +# 兼容旧接口名(若仍有调用) +def suggest_comment_keywords_llm( + *, + keyword: str, + brief_slice: dict[str, Any], + comment_samples: list[str], +) -> dict[str, Any]: + return suggest_focus_keywords_from_all_comments( + keyword=keyword, + brief_slice=brief_slice, + all_comment_texts=list(comment_samples), + ) diff --git a/backend/pipeline/management/__init__.py b/backend/pipeline/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/pipeline/management/commands/__init__.py b/backend/pipeline/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/pipeline/management/commands/run_pipeline_job.py b/backend/pipeline/management/commands/run_pipeline_job.py new file mode 100644 index 0000000..7622a4e --- /dev/null +++ b/backend/pipeline/management/commands/run_pipeline_job.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +"""由 ``execute_job`` 在子进程中调用,勿直接用于交互调试。""" +from __future__ import annotations + +import os +from pathlib import Path + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError + +from pipeline.cookie_paste import normalize_browser_cookie_paste +from pipeline.jd_runner import run_jd_keyword_and_report +from pipeline.models import PipelineJob + + +class Command(BaseCommand): + help = "内部命令:在独立进程中执行京东流水线(便于父进程 terminate 模拟 Ctrl+C)。" + + def add_arguments(self, parser) -> None: + parser.add_argument("job_id", type=int) + + def handle(self, *args, **options) -> None: + job_id = int(options["job_id"]) + run_dir = (os.environ.get("PIPELINE_JOB_RUN_DIR") or "").strip() + if not run_dir: + raise CommandError("缺少环境变量 PIPELINE_JOB_RUN_DIR") + + job = PipelineJob.objects.filter(pk=job_id).first() + if not job: + raise CommandError(f"任务不存在: {job_id}") + + cookie_path = (os.environ.get("PIPELINE_JOB_COOKIE_PATH") or "").strip() or None + if cookie_path and not Path(cookie_path).is_file(): + cookie_path = None + # 与父进程 env 双保险:粘贴 Cookie 仍以 DB 为准再落盘(避免 Windows 等环境下 env 未传到子进程) + _from_db = normalize_browser_cookie_paste(job.cookie_text or "") + if not cookie_path and _from_db: + runtime_dir = Path(settings.BASE_DIR) / "runtime_cookies" + runtime_dir.mkdir(parents=True, exist_ok=True) + worker_cookie = (runtime_dir / f"job_{job_id}_cookie_worker.txt").resolve() + worker_cookie.write_text(_from_db, encoding="utf-8") + cookie_path = str(worker_cookie) + + rc = job.report_config if isinstance(job.report_config, dict) else {} + + run_jd_keyword_and_report( + job.keyword, + max_skus=job.max_skus, + page_start=job.page_start, + page_to=job.page_to, + pipeline_run_dir=run_dir, + cookie_file_path=cookie_path, + pvid=(job.pvid or "").strip() or None, + request_delay=(job.request_delay or "").strip() or None, + list_pages=(job.list_pages or "").strip() or None, + scenario_filter_enabled=job.scenario_filter_enabled, + report_config=rc or None, + cancel_check=None, + ) diff --git a/backend/pipeline/md_document_export.py b/backend/pipeline/md_document_export.py new file mode 100644 index 0000000..4396ed1 --- /dev/null +++ b/backend/pipeline/md_document_export.py @@ -0,0 +1,250 @@ +"""Markdown → Word(.docx)/ 简易 PDF;供任务报告与策略稿导出。""" +from __future__ import annotations + +import os +import re +from io import BytesIO +from pathlib import Path +from typing import Any +from xml.sax.saxutils import escape as xml_escape + + +def _strip_inline_md(s: str) -> str: + s = re.sub(r"\*\*(.+?)\*\*", r"\1", s) + s = re.sub(r"`([^`]+)`", r"\1", s) + return s + + +def _is_table_sep(line: str) -> bool: + t = line.strip() + if not t.startswith("|"): + return False + inner = t.strip("|").replace(" ", "") + return bool(inner) and all(p in ("", "---", ":---", "---:", ":---:") for p in t.split("|")) + + +_img_line = re.compile(r"^!\[([^\]]*)\]\(([^)]+)\)\s*$") + + +def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes: + from docx import Document + from docx.enum.text import WD_PARAGRAPH_ALIGNMENT + from docx.shared import Inches, Pt + + doc = Document() + try: + style = doc.styles["Normal"] + style.font.name = "Microsoft YaHei" + style.font.size = Pt(10.5) + except Exception: + pass + + lines = (md or "").replace("\r\n", "\n").split("\n") + i = 0 + in_fence = False + while i < len(lines): + raw = lines[i] + if raw.strip().startswith("```"): + in_fence = not in_fence + i += 1 + continue + if in_fence: + p = doc.add_paragraph(xml_escape(raw) or " ") + p.style = doc.styles["Normal"] + for run in p.runs: + run.font.name = "Consolas" + run.font.size = Pt(9) + i += 1 + continue + + line = raw.rstrip() + if not line.strip(): + doc.add_paragraph("") + i += 1 + continue + if line.startswith("# "): + doc.add_heading(_strip_inline_md(line[2:].strip()), level=0) + i += 1 + continue + if line.startswith("## "): + doc.add_heading(_strip_inline_md(line[3:].strip()), level=1) + i += 1 + continue + if line.startswith("### "): + doc.add_heading(_strip_inline_md(line[4:].strip()), level=2) + i += 1 + continue + if line.startswith("#### "): + doc.add_heading(_strip_inline_md(line[5:].strip()), level=3) + i += 1 + continue + mimg = _img_line.match(line.strip()) + if mimg and asset_root is not None: + rel = mimg.group(2).strip() + if not (rel.startswith("http://") or rel.startswith("https://")): + img_path = (asset_root / rel).resolve() + try: + img_path.relative_to(asset_root.resolve()) + except ValueError: + i += 1 + continue + if img_path.is_file(): + doc.add_picture(str(img_path), width=Inches(5.9)) + i += 1 + continue + if line.strip().startswith("|"): + rows: list[list[str]] = [] + while i < len(lines) and lines[i].strip().startswith("|"): + row_line = lines[i].strip() + if _is_table_sep(row_line): + i += 1 + continue + cells = [c.strip() for c in row_line.strip("|").split("|")] + rows.append([_strip_inline_md(c) for c in cells]) + i += 1 + if rows: + max_cols = max(len(r) for r in rows) + pad_rows = [r + [""] * (max_cols - len(r)) for r in rows] + tbl = doc.add_table(rows=len(pad_rows), cols=max_cols) + tbl.style = "Table Grid" + for ri, row in enumerate(pad_rows): + for ci, cell in enumerate(row): + tbl.rows[ri].cells[ci].text = cell + continue + + p = doc.add_paragraph() + p.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT + text = _strip_inline_md(line) + p.add_run(text) + + bio = BytesIO() + doc.save(bio) + return bio.getvalue() + + +def _pdf_font_candidates() -> list[Path]: + raw = (os.environ.get("MA_PDF_FONT") or "").strip() + out: list[Path] = [] + if raw: + out.append(Path(raw)) + windir = os.environ.get("WINDIR", r"C:\Windows") + out.extend( + [ + Path(windir) / "Fonts" / "simhei.ttf", + Path(windir) / "Fonts" / "simsun.ttc", + Path(windir) / "Fonts" / "msyh.ttf", + ] + ) + return out + + +def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes: + """简易纯文本流式 PDF;需本机 .ttf 中文字体或环境变量 MA_PDF_FONT。""" + from reportlab.lib.pagesizes import A4 + from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet + from reportlab.lib.units import cm + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + from reportlab.platypus import Image as RLImage + from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer + + font_name = "MaExportCJK" + registered = False + for p in _pdf_font_candidates(): + if not p.is_file(): + continue + try: + if p.suffix.lower() == ".ttc": + try: + pdfmetrics.registerFont( + TTFont(font_name, str(p), subfontIndex=0) + ) + except TypeError: + pdfmetrics.registerFont(TTFont(font_name, str(p))) + else: + pdfmetrics.registerFont(TTFont(font_name, str(p))) + registered = True + break + except Exception: + continue + if not registered: + raise ValueError( + "未找到可用的中文字体文件。请在 Windows 上安装黑体/宋体," + "或设置环境变量 MA_PDF_FONT 指向 .ttf 文件路径。" + ) + + styles = getSampleStyleSheet() + body = ParagraphStyle( + name="BodyCJK", + parent=styles["Normal"], + fontName=font_name, + fontSize=10, + leading=14, + ) + h1s = ParagraphStyle( + name="H1CJK", + parent=body, + fontSize=16, + leading=20, + spaceAfter=8, + ) + h2s = ParagraphStyle( + name="H2CJK", + parent=body, + fontSize=13, + leading=17, + spaceAfter=6, + ) + + story: list[Any] = [] + lines = (md or "").replace("\r\n", "\n").split("\n") + in_fence = False + for raw in lines: + if raw.strip().startswith("```"): + in_fence = not in_fence + continue + s = raw.rstrip() + if in_fence: + story.append(Paragraph(xml_escape(s or " "), body)) + story.append(Spacer(1, 0.1 * cm)) + continue + if not s.strip(): + story.append(Spacer(1, 0.15 * cm)) + continue + mimg = _img_line.match(s.strip()) + if mimg and asset_root is not None: + rel = mimg.group(2).strip() + if not (rel.startswith("http://") or rel.startswith("https://")): + img_path = (asset_root / rel).resolve() + try: + img_path.relative_to(asset_root.resolve()) + except ValueError: + continue + if img_path.is_file(): + story.append(RLImage(str(img_path), width=13 * cm)) + story.append(Spacer(1, 0.2 * cm)) + continue + plain = _strip_inline_md(s) + text = xml_escape(plain) + if s.startswith("# "): + story.append(Paragraph(xml_escape(plain[2:]), h1s)) + elif s.startswith("## "): + story.append(Paragraph(xml_escape(plain[3:]), h2s)) + elif s.startswith("### "): + story.append(Paragraph(xml_escape(plain[4:]), body)) + elif s.strip().startswith("|"): + story.append(Paragraph(text.replace("|", " │ "), body)) + else: + story.append(Paragraph(text, body)) + + buf = BytesIO() + doc = SimpleDocTemplate( + buf, + pagesize=A4, + leftMargin=2 * cm, + rightMargin=2 * cm, + topMargin=2 * cm, + bottomMargin=2 * cm, + ) + doc.build(story) + return buf.getvalue() diff --git a/backend/pipeline/migrations/0001_initial.py b/backend/pipeline/migrations/0001_initial.py new file mode 100644 index 0000000..f142181 --- /dev/null +++ b/backend/pipeline/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.13 on 2026-04-08 08:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='PipelineJob', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('platform', models.CharField(db_index=True, default='jd', max_length=32)), + ('keyword', models.CharField(max_length=256)), + ('max_skus', models.PositiveIntegerField(blank=True, null=True)), + ('page_start', models.PositiveIntegerField(blank=True, null=True)), + ('page_to', models.PositiveIntegerField(blank=True, null=True)), + ('status', models.CharField(choices=[('pending', '待执行'), ('running', '执行中'), ('success', '成功'), ('failed', '失败')], db_index=True, default='pending', max_length=16)), + ('run_dir', models.TextField(blank=True, default='')), + ('error_message', models.TextField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/pipeline/migrations/0002_job_options.py b/backend/pipeline/migrations/0002_job_options.py new file mode 100644 index 0000000..8376ddd --- /dev/null +++ b/backend/pipeline/migrations/0002_job_options.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.13 on 2026-04-08 08:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pipeline', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='pipelinejob', + name='cookie_file_path', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='pipelinejob', + name='cookie_text', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='pipelinejob', + name='list_pages', + field=models.CharField(blank=True, default='', help_text='评论分页,如 "1-2";空则沿用副本默认', max_length=64), + ), + migrations.AddField( + model_name='pipelinejob', + name='pipeline_run_dir', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='pipelinejob', + name='pvid', + field=models.CharField(blank=True, default='', max_length=128), + ), + migrations.AddField( + model_name='pipelinejob', + name='request_delay', + field=models.CharField(blank=True, default='', help_text='如 "30-60";空则沿用副本默认', max_length=64), + ), + migrations.AddField( + model_name='pipelinejob', + name='scenario_filter_enabled', + field=models.BooleanField(blank=True, null=True), + ), + ] diff --git a/backend/pipeline/migrations/0003_jd_job_dataset_and_products.py b/backend/pipeline/migrations/0003_jd_job_dataset_and_products.py new file mode 100644 index 0000000..46df610 --- /dev/null +++ b/backend/pipeline/migrations/0003_jd_job_dataset_and_products.py @@ -0,0 +1,102 @@ +# Generated by Django 5.2.13 on 2026-04-09 02:24 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pipeline', '0002_job_options'), + ] + + operations = [ + migrations.CreateModel( + name='JdProduct', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('platform', models.CharField(db_index=True, default='jd', max_length=16)), + ('sku_id', models.CharField(db_index=True, max_length=64)), + ('ware_id', models.CharField(blank=True, default='', max_length=64)), + ('title', models.TextField(blank=True, default='')), + ('detail_brand', models.CharField(blank=True, default='', max_length=512)), + ('detail_price_final', models.CharField(blank=True, default='', max_length=128)), + ('detail_category_path', models.TextField(blank=True, default='')), + ('current_payload', models.JSONField(blank=True, default=dict)), + ('last_captured_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('last_job', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='touched_products', to='pipeline.pipelinejob')), + ], + options={ + 'ordering': ['-updated_at'], + }, + ), + migrations.CreateModel( + name='JdProductSnapshot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('run_dir', models.TextField(blank=True, default='')), + ('captured_at', models.DateTimeField(db_index=True)), + ('payload', models.JSONField(blank=True, default=dict)), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='product_snapshots', to='pipeline.pipelinejob')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='snapshots', to='pipeline.jdproduct')), + ], + options={ + 'ordering': ['-captured_at'], + }, + ), + migrations.CreateModel( + name='JdJobCommentRow', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('row_index', models.PositiveIntegerField()), + ('sku_id', models.CharField(blank=True, db_index=True, default='', max_length=64)), + ('payload', models.JSONField(blank=True, default=dict)), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_comment_rows', to='pipeline.pipelinejob')), + ], + options={ + 'ordering': ['row_index'], + 'indexes': [models.Index(fields=['job', 'sku_id'], name='pipeline_jd_job_id_57eaf8_idx')], + 'constraints': [models.UniqueConstraint(fields=('job', 'row_index'), name='uniq_pipeline_jdjobcommentrow_job_idx')], + }, + ), + migrations.CreateModel( + name='JdJobDetailRow', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('row_index', models.PositiveIntegerField()), + ('sku_id', models.CharField(blank=True, db_index=True, default='', max_length=64)), + ('payload', models.JSONField(blank=True, default=dict)), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_detail_rows', to='pipeline.pipelinejob')), + ], + options={ + 'ordering': ['row_index'], + 'indexes': [models.Index(fields=['job', 'sku_id'], name='pipeline_jd_job_id_d9112a_idx')], + 'constraints': [models.UniqueConstraint(fields=('job', 'row_index'), name='uniq_pipeline_jdjobdetailrow_job_idx')], + }, + ), + migrations.CreateModel( + name='JdJobSearchRow', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('row_index', models.PositiveIntegerField()), + ('sku_id', models.CharField(blank=True, db_index=True, default='', max_length=64)), + ('payload', models.JSONField(blank=True, default=dict)), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_search_rows', to='pipeline.pipelinejob')), + ], + options={ + 'ordering': ['row_index'], + 'indexes': [models.Index(fields=['job', 'sku_id'], name='pipeline_jd_job_id_28447c_idx')], + 'constraints': [models.UniqueConstraint(fields=('job', 'row_index'), name='uniq_pipeline_jdjobsearchrow_job_idx')], + }, + ), + migrations.AddConstraint( + model_name='jdproduct', + constraint=models.UniqueConstraint(fields=('platform', 'sku_id'), name='uniq_pipeline_jdproduct_platform_sku'), + ), + migrations.AddConstraint( + model_name='jdproductsnapshot', + constraint=models.UniqueConstraint(fields=('product', 'job'), name='uniq_pipeline_jdproductsnapshot_product_job'), + ), + ] diff --git a/backend/pipeline/migrations/0004_job_rows_split_csv_fields.py b/backend/pipeline/migrations/0004_job_rows_split_csv_fields.py new file mode 100644 index 0000000..73830ed --- /dev/null +++ b/backend/pipeline/migrations/0004_job_rows_split_csv_fields.py @@ -0,0 +1,315 @@ +# Generated by Django 5.2.13 on 2026-04-09 02:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pipeline', '0003_jd_job_dataset_and_products'), + ] + + operations = [ + migrations.RemoveField( + model_name='jdjobcommentrow', + name='payload', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='payload', + ), + migrations.RemoveField( + model_name='jdjobsearchrow', + name='payload', + ), + migrations.AddField( + model_name='jdjobcommentrow', + name='buy_count_text', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobcommentrow', + name='comment_date', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobcommentrow', + name='comment_id', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobcommentrow', + name='comment_score', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobcommentrow', + name='large_pic_urls', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobcommentrow', + name='tag_comment_content', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobcommentrow', + name='user_nick_name', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_belt_banner', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_body_ingredients', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_body_ingredients_source_url', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_brand', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_category_path', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_csfh_text', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_delivery_promise', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_main_image', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_main_sku_id', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_page_sku_id', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_price_final', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_price_original', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_product_attributes', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_product_id', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_purchase_price', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_shop_id', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_shop_name', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_shop_url', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_sku_name', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_sku_title', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_stock_text', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_vender_id', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='http_status', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='attributes', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='comment_count', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='comment_sales_floor', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='coupon_price', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='detail_url', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='hot_list_rank', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='image', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='item_id', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='keyword', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='leaf_category', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='location', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='original_price', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='page', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='platform', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='price', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='seckill_info', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='selling_point', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='shop_info_title', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='shop_info_url', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='shop_logo', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='shop_name', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='shop_url', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='title', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='uniqpid', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobsearchrow', + name='video_url', + field=models.TextField(blank=True, default=''), + ), + migrations.AlterField( + model_name='jdjobcommentrow', + name='sku_id', + field=models.TextField(blank=True, db_index=True, default=''), + ), + migrations.AlterField( + model_name='jdjobdetailrow', + name='sku_id', + field=models.TextField(blank=True, db_index=True, default=''), + ), + migrations.AlterField( + model_name='jdjobsearchrow', + name='sku_id', + field=models.TextField(blank=True, db_index=True, default=''), + ), + ] diff --git a/backend/pipeline/migrations/0005_jd_job_merged_row.py b/backend/pipeline/migrations/0005_jd_job_merged_row.py new file mode 100644 index 0000000..7ca7777 --- /dev/null +++ b/backend/pipeline/migrations/0005_jd_job_merged_row.py @@ -0,0 +1,61 @@ +# Generated by Django 5.2.13 on 2026-04-09 06:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pipeline', '0004_job_rows_split_csv_fields'), + ] + + operations = [ + migrations.CreateModel( + name='JdJobMergedRow', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('row_index', models.PositiveIntegerField()), + ('pipeline_keyword', models.TextField(blank=True, default='')), + ('sku_id', models.TextField(blank=True, db_index=True, default='')), + ('ware_id', models.TextField(blank=True, default='')), + ('title', models.TextField(blank=True, default='')), + ('price', models.TextField(blank=True, default='')), + ('coupon_price', models.TextField(blank=True, default='')), + ('original_price', models.TextField(blank=True, default='')), + ('selling_point', models.TextField(blank=True, default='')), + ('hot_list_rank', models.TextField(blank=True, default='')), + ('comment_fuzzy', models.TextField(blank=True, default='')), + ('comment_sales_floor', models.TextField(blank=True, default='')), + ('shop_name', models.TextField(blank=True, default='')), + ('detail_url', models.TextField(blank=True, default='')), + ('image', models.TextField(blank=True, default='')), + ('attributes', models.TextField(blank=True, default='')), + ('leaf_category', models.TextField(blank=True, default='')), + ('keyword', models.TextField(blank=True, default='')), + ('page', models.TextField(blank=True, default='')), + ('detail_http_status', models.TextField(blank=True, default='')), + ('detail_brand', models.TextField(blank=True, default='')), + ('detail_sku_title', models.TextField(blank=True, default='')), + ('detail_price_final', models.TextField(blank=True, default='')), + ('detail_price_original', models.TextField(blank=True, default='')), + ('detail_shop_name', models.TextField(blank=True, default='')), + ('detail_category_path', models.TextField(blank=True, default='')), + ('detail_product_attributes', models.TextField(blank=True, default='')), + ('detail_main_image', models.TextField(blank=True, default='')), + ('detail_body_ingredients', models.TextField(blank=True, default='')), + ('detail_body_ingredients_source_url', models.TextField(blank=True, default='')), + ('detail_stock_text', models.TextField(blank=True, default='')), + ('detail_delivery_promise', models.TextField(blank=True, default='')), + ('comment_http_status', models.TextField(blank=True, default='')), + ('pipeline_comment_count', models.TextField(blank=True, default='')), + ('comment_preview', models.TextField(blank=True, default='')), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_merged_rows', to='pipeline.pipelinejob')), + ], + options={ + 'ordering': ['row_index'], + 'indexes': [models.Index(fields=['job', 'sku_id'], name='pipeline_jd_job_id_830248_idx')], + 'constraints': [models.UniqueConstraint(fields=('job', 'row_index'), name='uniq_pipeline_jdjobmergedrow_job_idx')], + }, + ), + ] diff --git a/backend/pipeline/migrations/0006_trim_search_detail_merged_fields.py b/backend/pipeline/migrations/0006_trim_search_detail_merged_fields.py new file mode 100644 index 0000000..c6f6165 --- /dev/null +++ b/backend/pipeline/migrations/0006_trim_search_detail_merged_fields.py @@ -0,0 +1,142 @@ +# Generated by Django 5.2.13 on 2026-04-09 06:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('pipeline', '0005_jd_job_merged_row'), + ] + + operations = [ + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_belt_banner', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_body_ingredients_source_url', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_brand', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_category_path', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_csfh_text', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_delivery_promise', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_main_image', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_main_sku_id', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_page_sku_id', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_price_final', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_price_original', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_product_attributes', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_product_id', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_purchase_price', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_shop_id', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_shop_name', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_shop_url', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_sku_name', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_sku_title', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_stock_text', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='detail_vender_id', + ), + migrations.RemoveField( + model_name='jdjobdetailrow', + name='http_status', + ), + # JdJobMergedRow:裁剪商详列,保留与 lean 合并表一致的子集(非「先删再在后续迁移加回」) + migrations.RemoveField( + model_name='jdjobmergedrow', + name='detail_body_ingredients_source_url', + ), + migrations.RemoveField( + model_name='jdjobmergedrow', + name='detail_delivery_promise', + ), + migrations.RemoveField( + model_name='jdjobmergedrow', + name='detail_main_image', + ), + migrations.RemoveField( + model_name='jdjobmergedrow', + name='detail_price_original', + ), + migrations.RemoveField( + model_name='jdjobmergedrow', + name='detail_sku_title', + ), + migrations.RemoveField( + model_name='jdjobmergedrow', + name='detail_stock_text', + ), + migrations.RemoveField( + model_name='jdjobsearchrow', + name='shop_info_title', + ), + migrations.RemoveField( + model_name='jdjobsearchrow', + name='shop_logo', + ), + migrations.RemoveField( + model_name='jdjobsearchrow', + name='uniqpid', + ), + migrations.RemoveField( + model_name='jdjobsearchrow', + name='video_url', + ), + ] diff --git a/backend/pipeline/migrations/0007_drop_merged_http_status_columns.py b/backend/pipeline/migrations/0007_drop_merged_http_status_columns.py new file mode 100644 index 0000000..d9f0e2e --- /dev/null +++ b/backend/pipeline/migrations/0007_drop_merged_http_status_columns.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2.13 on 2026-04-09 07:37 + +from django.db import migrations + + +def _drop_if_column_exists(apps, schema_editor) -> None: + """列已不存在时跳过(例如旧版 0006 已删过),避免数据库报错。""" + JdJobMergedRow = apps.get_model("pipeline", "JdJobMergedRow") + table = JdJobMergedRow._meta.db_table + conn = schema_editor.connection + for fname in ("detail_http_status", "comment_http_status"): + with conn.cursor() as cursor: + desc = conn.introspection.get_table_description(cursor, table) + col_names = {d.name for d in desc} + if fname not in col_names: + continue + field = JdJobMergedRow._meta.get_field(fname) + schema_editor.remove_field(JdJobMergedRow, field) + + +class Migration(migrations.Migration): + + dependencies = [ + ("pipeline", "0006_trim_search_detail_merged_fields"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.RemoveField( + model_name="jdjobmergedrow", + name="comment_http_status", + ), + migrations.RemoveField( + model_name="jdjobmergedrow", + name="detail_http_status", + ), + ], + database_operations=[ + migrations.RunPython( + _drop_if_column_exists, + migrations.RunPython.noop, + ), + ], + ), + ] diff --git a/backend/pipeline/migrations/0008_repair_jdjobmergedrow_detail_columns.py b/backend/pipeline/migrations/0008_repair_jdjobmergedrow_detail_columns.py new file mode 100644 index 0000000..eef7d4f --- /dev/null +++ b/backend/pipeline/migrations/0008_repair_jdjobmergedrow_detail_columns.py @@ -0,0 +1,39 @@ +# SQLite 上曾出现「迁移已记录但表缺少 lean 商详列」的不一致;补全缺失列以便与模型一致。 + +from django.db import migrations + + +def _repair(apps, schema_editor) -> None: + conn = schema_editor.connection + if conn.vendor != "sqlite": + return + JdJobMergedRow = apps.get_model("pipeline", "JdJobMergedRow") + table = JdJobMergedRow._meta.db_table + # 与 models.JdJobMergedRow 商详块一致 + additions: dict[str, str] = { + "detail_brand": "TEXT NOT NULL DEFAULT ''", + "detail_price_final": "TEXT NOT NULL DEFAULT ''", + "detail_shop_name": "TEXT NOT NULL DEFAULT ''", + "detail_category_path": "TEXT NOT NULL DEFAULT ''", + "detail_product_attributes": "TEXT NOT NULL DEFAULT ''", + } + qn = conn.ops.quote_name + t = qn(table) + with conn.cursor() as cursor: + desc = conn.introspection.get_table_description(cursor, table) + have = {d.name for d in desc} + for col, ddl in additions.items(): + if col in have: + continue + cursor.execute(f"ALTER TABLE {t} ADD COLUMN {qn(col)} {ddl}") + + +class Migration(migrations.Migration): + + dependencies = [ + ("pipeline", "0007_drop_merged_http_status_columns"), + ] + + operations = [ + migrations.RunPython(_repair, migrations.RunPython.noop), + ] diff --git a/backend/pipeline/migrations/0009_detail_row_lean_fields.py b/backend/pipeline/migrations/0009_detail_row_lean_fields.py new file mode 100644 index 0000000..4e3d199 --- /dev/null +++ b/backend/pipeline/migrations/0009_detail_row_lean_fields.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.13 on 2026-04-09 08:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pipeline', '0008_repair_jdjobmergedrow_detail_columns'), + ] + + operations = [ + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_brand', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_category_path', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_price_final', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_product_attributes', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='jdjobdetailrow', + name='detail_shop_name', + field=models.TextField(blank=True, default=''), + ), + ] diff --git a/backend/pipeline/migrations/0010_pipelinejob_report_config.py b/backend/pipeline/migrations/0010_pipelinejob_report_config.py new file mode 100644 index 0000000..8e849b7 --- /dev/null +++ b/backend/pipeline/migrations/0010_pipelinejob_report_config.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.13 on 2026-04-09 08:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pipeline', '0009_detail_row_lean_fields'), + ] + + operations = [ + migrations.AddField( + model_name='pipelinejob', + name='report_config', + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/backend/pipeline/migrations/0011_job_cancel_and_status.py b/backend/pipeline/migrations/0011_job_cancel_and_status.py new file mode 100644 index 0000000..ddc2ff6 --- /dev/null +++ b/backend/pipeline/migrations/0011_job_cancel_and_status.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.13 on 2026-04-10 04:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pipeline', '0010_pipelinejob_report_config'), + ] + + operations = [ + migrations.AddField( + model_name='pipelinejob', + name='cancellation_requested', + field=models.BooleanField(db_index=True, default=False), + ), + migrations.AlterField( + model_name='pipelinejob', + name='status', + field=models.CharField(choices=[('pending', '待执行'), ('running', '执行中'), ('success', '成功'), ('failed', '失败'), ('cancelled', '已终止')], db_index=True, default='pending', max_length=16), + ), + ] diff --git a/backend/pipeline/migrations/__init__.py b/backend/pipeline/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/pipeline/models.py b/backend/pipeline/models.py new file mode 100644 index 0000000..52b7796 --- /dev/null +++ b/backend/pipeline/models.py @@ -0,0 +1,287 @@ +from django.db import models + + +class JobStatus(models.TextChoices): + PENDING = "pending", "待执行" + RUNNING = "running", "执行中" + SUCCESS = "success", "成功" + FAILED = "failed", "失败" + CANCELLED = "cancelled", "已终止" + + +class PipelineJob(models.Model): + platform = models.CharField(max_length=32, default="jd", db_index=True) + keyword = models.CharField(max_length=256) + max_skus = models.PositiveIntegerField(null=True, blank=True) + page_start = models.PositiveIntegerField(null=True, blank=True) + page_to = models.PositiveIntegerField(null=True, blank=True) + # 相对 data/JD 的子路径,或绝对路径(须在 Low GI/data/JD 下);空则时间戳_关键词 + pipeline_run_dir = models.TextField(blank=True, default="") + # Cookie:二选一优先 cookie_text(任务内写入临时文件再跑流水线) + cookie_file_path = models.TextField(blank=True, default="") + cookie_text = models.TextField(blank=True, default="") + pvid = models.CharField(max_length=128, blank=True, default="") + request_delay = models.CharField( + max_length=64, + blank=True, + default="", + help_text='如 "30-60";空则沿用副本默认', + ) + list_pages = models.CharField( + max_length=64, + blank=True, + default="", + help_text='评论分页,如 "1-2";空则沿用副本默认', + ) + scenario_filter_enabled = models.BooleanField(null=True, blank=True) + # 竞品报告 / competitor-brief:关注词、场景词组、外部市场表等(JSON,空对象=用爬虫脚本默认) + report_config = models.JSONField(default=dict, blank=True) + status = models.CharField( + max_length=16, + choices=JobStatus.choices, + default=JobStatus.PENDING, + db_index=True, + ) + cancellation_requested = models.BooleanField(default=False, db_index=True) + run_dir = models.TextField(blank=True, default="") + error_message = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"[{self.platform}] {self.keyword} ({self.status})" + + +class JdProduct(models.Model): + """京东 SKU 主档:同一 ``platform`` + ``sku_id`` 唯一,多次抓取时覆盖为最新一行合并表数据。""" + + platform = models.CharField(max_length=16, default="jd", db_index=True) + sku_id = models.CharField(max_length=64, db_index=True) + ware_id = models.CharField(max_length=64, blank=True, default="") + title = models.TextField(blank=True, default="") + detail_brand = models.CharField(max_length=512, blank=True, default="") + detail_price_final = models.CharField(max_length=128, blank=True, default="") + detail_category_path = models.TextField(blank=True, default="") + current_payload = models.JSONField(default=dict, blank=True) + last_job = models.ForeignKey( + PipelineJob, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="touched_products", + ) + last_captured_at = models.DateTimeField(null=True, blank=True, db_index=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-updated_at"] + constraints = [ + models.UniqueConstraint( + fields=["platform", "sku_id"], + name="uniq_pipeline_jdproduct_platform_sku", + ), + ] + + def __str__(self) -> str: + return f"{self.platform}:{self.sku_id}" + + +class JdProductSnapshot(models.Model): + """某次流水线任务下该 SKU 的整行快照,用于历史对比与回放。""" + + product = models.ForeignKey( + JdProduct, + on_delete=models.CASCADE, + related_name="snapshots", + ) + job = models.ForeignKey( + PipelineJob, + on_delete=models.CASCADE, + related_name="product_snapshots", + ) + run_dir = models.TextField(blank=True, default="") + captured_at = models.DateTimeField(db_index=True) + payload = models.JSONField(default=dict, blank=True) + + class Meta: + ordering = ["-captured_at"] + constraints = [ + models.UniqueConstraint( + fields=["product", "job"], + name="uniq_pipeline_jdproductsnapshot_product_job", + ), + ] + + def __str__(self) -> str: + return f"{self.product_id} @ job {self.job_id}" + + +class JdJobSearchRow(models.Model): + """单次任务下 PC 搜索导出表一行,字段与 ``pc_search_export.csv`` 列一一对应(内部英文属性名)。""" + + job = models.ForeignKey( + PipelineJob, + on_delete=models.CASCADE, + related_name="job_search_rows", + ) + row_index = models.PositiveIntegerField() + item_id = models.TextField(blank=True, default="") + sku_id = models.TextField(blank=True, default="", db_index=True) + title = models.TextField(blank=True, default="") + price = models.TextField(blank=True, default="") + coupon_price = models.TextField(blank=True, default="") + original_price = models.TextField(blank=True, default="") + selling_point = models.TextField(blank=True, default="") + comment_sales_floor = models.TextField(blank=True, default="") + hot_list_rank = models.TextField(blank=True, default="") + comment_count = models.TextField(blank=True, default="") + shop_name = models.TextField(blank=True, default="") + shop_url = models.TextField(blank=True, default="") + shop_info_url = models.TextField(blank=True, default="") + location = models.TextField(blank=True, default="") + detail_url = models.TextField(blank=True, default="") + image = models.TextField(blank=True, default="") + seckill_info = models.TextField(blank=True, default="") + attributes = models.TextField(blank=True, default="") + leaf_category = models.TextField(blank=True, default="") + platform = models.TextField(blank=True, default="") + keyword = models.TextField(blank=True, default="") + page = models.TextField(blank=True, default="") + + class Meta: + ordering = ["row_index"] + constraints = [ + models.UniqueConstraint( + fields=["job", "row_index"], + name="uniq_pipeline_jdjobsearchrow_job_idx", + ), + ] + indexes = [ + models.Index(fields=["job", "sku_id"]), + ] + + def __str__(self) -> str: + return f"job{self.job_id} search#{self.row_index}" + + +class JdJobDetailRow(models.Model): + """单次任务下 ``detail_ware_export.csv`` 一行(lean:skuId + 与合并表一致的商详子集)。""" + + job = models.ForeignKey( + PipelineJob, + on_delete=models.CASCADE, + related_name="job_detail_rows", + ) + row_index = models.PositiveIntegerField() + sku_id = models.TextField(blank=True, default="", db_index=True) + detail_brand = models.TextField(blank=True, default="") + detail_price_final = models.TextField(blank=True, default="") + detail_shop_name = models.TextField(blank=True, default="") + detail_category_path = models.TextField(blank=True, default="") + detail_product_attributes = models.TextField(blank=True, default="") + detail_body_ingredients = models.TextField(blank=True, default="") + + class Meta: + ordering = ["row_index"] + constraints = [ + models.UniqueConstraint( + fields=["job", "row_index"], + name="uniq_pipeline_jdjobdetailrow_job_idx", + ), + ] + indexes = [ + models.Index(fields=["job", "sku_id"]), + ] + + def __str__(self) -> str: + return f"job{self.job_id} detail#{self.row_index}" + + +class JdJobCommentRow(models.Model): + """单次任务下 ``comments_flat.csv`` 一行。""" + + job = models.ForeignKey( + PipelineJob, + on_delete=models.CASCADE, + related_name="job_comment_rows", + ) + row_index = models.PositiveIntegerField() + sku_id = models.TextField(blank=True, default="", db_index=True) + comment_id = models.TextField(blank=True, default="") + user_nick_name = models.TextField(blank=True, default="") + tag_comment_content = models.TextField(blank=True, default="") + comment_date = models.TextField(blank=True, default="") + buy_count_text = models.TextField(blank=True, default="") + large_pic_urls = models.TextField(blank=True, default="") + comment_score = models.TextField(blank=True, default="") + + class Meta: + ordering = ["row_index"] + constraints = [ + models.UniqueConstraint( + fields=["job", "row_index"], + name="uniq_pipeline_jdjobcommentrow_job_idx", + ), + ] + indexes = [ + models.Index(fields=["job", "sku_id"]), + ] + + def __str__(self) -> str: + return f"job{self.job_id} cmt#{self.row_index}" + + +class JdJobMergedRow(models.Model): + """单次任务下合并宽表一行(lean:搜索列 + 商详子集 + 评论摘要),与 ``keyword_pipeline_merged.csv`` 列一一对应。""" + + job = models.ForeignKey( + PipelineJob, + on_delete=models.CASCADE, + related_name="job_merged_rows", + ) + row_index = models.PositiveIntegerField() + pipeline_keyword = models.TextField(blank=True, default="") + sku_id = models.TextField(blank=True, default="", db_index=True) + ware_id = models.TextField(blank=True, default="") + title = models.TextField(blank=True, default="") + price = models.TextField(blank=True, default="") + coupon_price = models.TextField(blank=True, default="") + original_price = models.TextField(blank=True, default="") + selling_point = models.TextField(blank=True, default="") + hot_list_rank = models.TextField(blank=True, default="") + comment_fuzzy = models.TextField(blank=True, default="") + comment_sales_floor = models.TextField(blank=True, default="") + shop_name = models.TextField(blank=True, default="") + detail_url = models.TextField(blank=True, default="") + image = models.TextField(blank=True, default="") + attributes = models.TextField(blank=True, default="") + leaf_category = models.TextField(blank=True, default="") + keyword = models.TextField(blank=True, default="") + page = models.TextField(blank=True, default="") + detail_brand = models.TextField(blank=True, default="") + detail_price_final = models.TextField(blank=True, default="") + detail_shop_name = models.TextField(blank=True, default="") + detail_category_path = models.TextField(blank=True, default="") + detail_product_attributes = models.TextField(blank=True, default="") + detail_body_ingredients = models.TextField(blank=True, default="") + pipeline_comment_count = models.TextField(blank=True, default="") + comment_preview = models.TextField(blank=True, default="") + + class Meta: + ordering = ["row_index"] + constraints = [ + models.UniqueConstraint( + fields=["job", "row_index"], + name="uniq_pipeline_jdjobmergedrow_job_idx", + ), + ] + indexes = [ + models.Index(fields=["job", "sku_id"]), + ] + + def __str__(self) -> str: + return f"job{self.job_id} merged#{self.row_index}" diff --git a/backend/pipeline/report_charts.py b/backend/pipeline/report_charts.py new file mode 100644 index 0000000..cabc2a6 --- /dev/null +++ b/backend/pipeline/report_charts.py @@ -0,0 +1,378 @@ +"""根据结构化 brief 生成报告用 PNG 统计图(matplotlib),写入 ``run_dir/report_assets/``。""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from typing import Any + + +def _setup_matplotlib_cjk() -> None: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + from matplotlib import font_manager + + windir = os.environ.get("WINDIR", r"C:\Windows") + for name in ("simhei.ttf", "msyh.ttc", "simsun.ttc"): + fp = Path(windir) / "Fonts" / name + if fp.is_file(): + try: + font_manager.fontManager.addfont(str(fp)) + fam = font_manager.FontProperties(fname=str(fp)).get_name() + plt.rcParams["font.family"] = [fam] + break + except Exception: + continue + plt.rcParams["axes.unicode_minus"] = False + + +def _label_count_pairs( + items: Any, + *, + key_label: str = "label", + key_count: str = "count", + cap: int = 40, +) -> tuple[list[str], list[float]]: + labs: list[str] = [] + vals: list[float] = [] + if not isinstance(items, list): + return labs, vals + for item in items[:cap]: + if not isinstance(item, dict): + continue + lbl = str(item.get(key_label) or "").strip()[:48] + cnt = item.get(key_count) + if lbl and isinstance(cnt, (int, float)) and cnt > 0: + labs.append(lbl) + vals.append(float(cnt)) + return labs, vals + + +def _merge_labeled_counts_tail( + pairs: list[tuple[str, float]], *, max_items: int +) -> list[tuple[str, float]]: + if len(pairs) <= max_items: + return pairs + head = pairs[: max_items - 1] + rest = sum(c for _, c in pairs[max_items - 1 :]) + if rest > 0: + head.append(("其他", rest)) + return head + + +def _merge_tail_as_other( + labels: list[str], values: list[float], *, max_slices: int +) -> tuple[list[str], list[float]]: + pairs = [(l, v) for l, v in zip(labels, values) if v > 0] + if not pairs: + return [], [] + if len(pairs) <= max_slices: + return [p[0] for p in pairs], [p[1] for p in pairs] + head = pairs[: max_slices - 1] + rest = sum(v for _, v in pairs[max_slices - 1 :]) + labs = [p[0] for p in head] + vals = [p[1] for p in head] + if rest > 0: + labs.append("其他") + vals.append(rest) + return labs, vals + + +# 已不再写入报告正文的旧版图,避免 run_dir 里残留误导性 PNG +_OBSOLETE_REPORT_ASSETS: frozenset[str] = frozenset( + { + "chart_focus_keywords_bar.png", + "chart_usage_scenarios.png", + "chart_usage_scenarios_pie.png", + "chart_focus_keywords_pie.png", + } +) + + +def _cleanup_obsolete_report_assets(out_dir: Path) -> None: + """删除历史版本生成的、当前报告不再引用的插图文件。""" + if not out_dir.is_dir(): + return + for name in _OBSOLETE_REPORT_ASSETS: + fp = out_dir / name + if fp.is_file(): + try: + fp.unlink() + except OSError: + pass + for fp in out_dir.glob("chart_usage_scenarios_pie__*.png"): + try: + fp.unlink() + except OSError: + pass + + +def generate_report_charts(run_dir: Path, brief: dict[str, Any]) -> list[str]: + """生成扇形/条形 PNG。返回已写入的文件名列表(不含路径)。""" + _setup_matplotlib_cjk() + import matplotlib.pyplot as plt + + out_dir = Path(run_dir).resolve() / "report_assets" + out_dir.mkdir(parents=True, exist_ok=True) + _cleanup_obsolete_report_assets(out_dir) + created: list[str] = [] + + def save_bar_h( + labels: list[str], + values: list[float], + title: str, + fname: str, + xlabel: str = "", + ) -> None: + if not labels or not values or max(values) <= 0: + return + n = len(labels) + fig_h = max(3.2, min(14.0, 0.38 * n + 1.5)) + fig, ax = plt.subplots(figsize=(8.2, fig_h)) + y_pos = range(n) + ax.barh(list(y_pos), values, color="#2563eb", height=0.65) + ax.set_yticks(list(y_pos)) + ax.set_yticklabels(labels, fontsize=9) + ax.invert_yaxis() + ax.set_title(title, fontsize=12, pad=10) + if xlabel: + ax.set_xlabel(xlabel, fontsize=9) + fig.tight_layout() + path = out_dir / fname + fig.savefig(path, dpi=130, bbox_inches="tight") + plt.close(fig) + created.append(fname) + + def save_bar_h_share_of_text( + labels: list[str], + counts: list[float], + n_texts: int, + title: str, + fname: str, + ) -> None: + """ + 横轴 = count / n_texts * 100,与报告表格「占有效文本比例」一致(多标签下各柱比例可相加 >100%)。 + """ + if not labels or not counts or n_texts <= 0 or max(counts) <= 0: + return + pcts = [100.0 * c / n_texts for c in counts] + n_b = len(labels) + fig_h = max(3.2, min(14.0, 0.38 * n_b + 1.8)) + fig, ax = plt.subplots(figsize=(8.8, fig_h)) + y_pos = range(n_b) + bars = ax.barh(list(y_pos), pcts, color="#2563eb", height=0.65) + ax.set_yticks(list(y_pos)) + ax.set_yticklabels(labels, fontsize=9) + ax.invert_yaxis() + ax.set_title(title, fontsize=12, pad=10) + ax.set_xlabel("占有效评价文本比例(%)", fontsize=9) + xmax = max(pcts) * 1.12 + 4.0 + ax.set_xlim(0, max(xmax, max(pcts) + 10.0, 24.0)) + for bar, c, p in zip(bars, counts, pcts): + ax.text( + min(bar.get_width() + 0.6, ax.get_xlim()[1] * 0.97), + bar.get_y() + bar.get_height() / 2, + f"{int(c)}条 · {p:.1f}%", + va="center", + fontsize=8, + ) + fig.tight_layout() + path = out_dir / fname + fig.savefig(path, dpi=130, bbox_inches="tight") + plt.close(fig) + created.append(fname) + + def save_pie( + labels: list[str], + values: list[float], + title: str, + fname: str, + *, + max_slices: int = 8, + ) -> None: + labs, vals = _merge_tail_as_other(labels, values, max_slices=max_slices) + if not labs or not vals or sum(vals) <= 0: + return + fig, ax = plt.subplots(figsize=(7.2, 5.4)) + colors = plt.cm.Set3(range(len(labs))) + wedges, _t, autotexts = ax.pie( + vals, + labels=None, + autopct=lambda p: f"{p:.1f}%" if p >= 3.5 else "", + pctdistance=0.72, + colors=colors, + startangle=90, + ) + for t in autotexts: + t.set_fontsize(8) + ax.legend( + wedges, + labs, + loc="center left", + bbox_to_anchor=(1.02, 0.5), + fontsize=8, + frameon=False, + ) + ax.set_title(title, fontsize=12, pad=12) + fig.tight_layout() + path = out_dir / fname + fig.savefig(path, dpi=130, bbox_inches="tight") + plt.close(fig) + created.append(fname) + + mix = brief.get("category_mix_top") or [] + labs_m, vals_m = _label_count_pairs(mix) + save_pie( + labs_m, + vals_m, + "类目/可读名称分布(列表行占比)", + "chart_category_mix_pie.png", + ) + save_bar_h( + labs_m[:15], + vals_m[:15], + "类目分布(行数,Top)", + "chart_category_mix.png", + "行数", + ) + + brand_mix = brief.get("list_brand_mix_top") or [] + lb, vb = _label_count_pairs(brand_mix, key_label="label") + save_pie( + lb, + vb, + "品牌列表曝光占比", + "chart_brand_rows_pie.png", + ) + + shop_mix = brief.get("list_shop_mix_top") or [] + ls, vs = _label_count_pairs(shop_mix, key_label="label") + save_pie( + ls, + vs, + "店铺列表曝光占比", + "chart_shop_rows_pie.png", + ) + + def scenario_group_asset_slug(group: str, index: int) -> str: + """与 ``jd_competitor_report._scenario_group_asset_slug`` 保持一致。""" + raw = (group or "").strip() + core = re.sub(r"[^\w\u4e00-\u9fff-]", "", raw)[:20] + if not core: + core = "group" + return f"i{index:02d}_{core}" + + by_grp = brief.get("usage_scenarios_by_matrix_group") or [] + if isinstance(by_grp, list): + for item in by_grp: + if not isinstance(item, dict): + continue + slug = (item.get("chart_slug") or "").strip() + gname = str(item.get("group") or "").strip()[:24] + idx = item.get("matrix_group_index") + if not slug and gname != "" and isinstance(idx, int): + slug = scenario_group_asset_slug(gname, idx) + if not slug: + continue + scen_rows = item.get("scenarios") or [] + n_unit = int(item.get("effective_text_units") or 0) + gpairs: list[tuple[str, float]] = [] + if isinstance(scen_rows, list): + for r in scen_rows: + if not isinstance(r, dict): + continue + lb = str(r.get("scenario") or "").strip()[:48] + c = r.get("count") + if lb and isinstance(c, (int, float)) and c > 0: + gpairs.append((lb, float(c))) + gpairs = _merge_labeled_counts_tail(gpairs, max_items=14) + if gpairs and n_unit > 0: + gl = [p[0] for p in gpairs] + gv = [p[1] for p in gpairs] + title_base = f"「{gname}」· 场景/用途" if gname else "细类 · 场景/用途" + save_bar_h_share_of_text( + gl, + gv, + n_unit, + f"{title_base}(占有效评价文本比例)", + f"chart_usage_scenarios_bar__{slug}.png", + ) + + fb = brief.get("consumer_feedback_by_matrix_group") or [] + if isinstance(fb, list): + for item in fb: + if not isinstance(item, dict): + continue + slug = (item.get("chart_slug") or "").strip() + gname = str(item.get("group") or "").strip()[:24] + idx = item.get("matrix_group_index") + if not slug and gname != "" and isinstance(idx, int): + slug = scenario_group_asset_slug(gname, idx) + if not slug: + continue + hk = item.get("focus_keyword_hits") or [] + wl: list[str] = [] + vl: list[float] = [] + if isinstance(hk, list): + for row in hk[:20]: + if not isinstance(row, dict): + continue + w = str(row.get("word") or "").strip()[:32] + c = row.get("count") + if w and isinstance(c, (int, float)) and c > 0: + wl.append(w) + vl.append(float(c)) + wl = wl[:18] + vl = vl[:18] + if not wl: + continue + tkw = f"「{gname}」· 关注词命中次数" if gname else "细类 · 关注词命中次数" + save_bar_h(wl, vl, tkw, f"chart_focus_keywords_bar__{slug}.png", "命中次数") + + sent = brief.get("comment_sentiment_lexicon") or {} + if isinstance(sent, dict): + pie_labs = ["偏正向", "偏负向", "正负混合", "中性/空"] + pie_vals = [ + float(sent.get("positive_only") or 0), + float(sent.get("negative_only") or 0), + float(sent.get("mixed_positive_and_negative") or 0), + float(sent.get("neutral_or_empty") or 0), + ] + pl = [a for a, b in zip(pie_labs, pie_vals) if b > 0] + pv = [b for b in pie_vals if b > 0] + save_pie(pl, pv, "评价语气四象限占比", "chart_sentiment_overview_pie.png") + save_bar_h( + pl, + pv, + "评价正负面粗判(条数)", + "chart_sentiment.png", + "条数", + ) + + pos_h = sent.get("positive_tone_lexeme_hits") or [] + neg_h = sent.get("negative_tone_lexeme_hits") or [] + plx, pvx = _label_count_pairs( + pos_h, key_label="word", key_count="texts_matched", cap=16 + ) + save_bar_h( + plx, + pvx, + "正向/混合语境 · 正向口语短语命中条数", + "chart_positive_lexemes_bar.png", + "条数", + ) + nlx, nvx = _label_count_pairs( + neg_h, key_label="word", key_count="texts_matched", cap=16 + ) + save_bar_h( + nlx, + nvx, + "负向/混合语境 · 负向口语短语命中条数", + "chart_negative_lexemes_bar.png", + "条数", + ) + + return created diff --git a/backend/pipeline/row_serialize.py b/backend/pipeline/row_serialize.py new file mode 100644 index 0000000..d6ec855 --- /dev/null +++ b/backend/pipeline/row_serialize.py @@ -0,0 +1,46 @@ +"""任务数据集 ORM 行 ↔ API / 导出用的扁平字典。""" +from __future__ import annotations + +from typing import Any + +from .csv_schema import ( + COMMENT_CSV_COLUMNS, + COMMENT_CSV_TO_FIELD, + DETAIL_CSV_COLUMNS, + DETAIL_CSV_TO_FIELD, + JD_SEARCH_INTERNAL_KEYS, + MERGED_INTERNAL_KEYS, +) +from .models import JdJobCommentRow, JdJobDetailRow, JdJobMergedRow, JdJobSearchRow + +DETAIL_FIELDS_ORDER: tuple[str, ...] = tuple(DETAIL_CSV_TO_FIELD[c] for c in DETAIL_CSV_COLUMNS) +COMMENT_FIELDS_ORDER: tuple[str, ...] = tuple(COMMENT_CSV_TO_FIELD[c] for c in COMMENT_CSV_COLUMNS) +MERGED_FIELDS_ORDER: tuple[str, ...] = MERGED_INTERNAL_KEYS + + +def search_row_to_dict(r: JdJobSearchRow) -> dict[str, Any]: + out: dict[str, Any] = {"id": r.id, "row_index": r.row_index} + for k in JD_SEARCH_INTERNAL_KEYS: + out[k] = getattr(r, k) or "" + return out + + +def detail_row_to_dict(r: JdJobDetailRow) -> dict[str, Any]: + out: dict[str, Any] = {"id": r.id, "row_index": r.row_index} + for k in DETAIL_FIELDS_ORDER: + out[k] = getattr(r, k) or "" + return out + + +def comment_row_to_dict(r: JdJobCommentRow) -> dict[str, Any]: + out: dict[str, Any] = {"id": r.id, "row_index": r.row_index} + for k in COMMENT_FIELDS_ORDER: + out[k] = getattr(r, k) or "" + return out + + +def merged_row_to_dict(r: JdJobMergedRow) -> dict[str, Any]: + out: dict[str, Any] = {"id": r.id, "row_index": r.row_index} + for k in MERGED_FIELDS_ORDER: + out[k] = getattr(r, k) or "" + return out diff --git a/backend/pipeline/serializers.py b/backend/pipeline/serializers.py new file mode 100644 index 0000000..d7aa05f --- /dev/null +++ b/backend/pipeline/serializers.py @@ -0,0 +1,329 @@ +import json +from pathlib import Path + +from django.conf import settings +from rest_framework import serializers + +from .cookie_paste import normalize_browser_cookie_paste +from .models import JdProduct, JdProductSnapshot, JobStatus, PipelineJob + +# 与 views._safe_file_for_job 中 mapping 一致,供前端展示「数据源是否就绪」 +_REPORT_CONFIG_ALLOWED_KEYS = frozenset( + { + "llm_comment_sentiment", + "comment_focus_words", + "comment_scenario_groups", + "external_market_table_rows", + } +) + + +def validate_report_config_body(value: dict) -> dict: + if not isinstance(value, dict): + raise serializers.ValidationError("须为 JSON 对象") + extra = set(value.keys()) - _REPORT_CONFIG_ALLOWED_KEYS + if extra: + raise serializers.ValidationError( + f"未知字段:{', '.join(sorted(extra))}" + ) + if "llm_comment_sentiment" in value and value["llm_comment_sentiment"] is not None: + if not isinstance(value["llm_comment_sentiment"], bool): + raise serializers.ValidationError("llm_comment_sentiment 须为 true 或 false") + raw = json.dumps(value, ensure_ascii=False) + if len(raw) > 120_000: + raise serializers.ValidationError("报告配置体积过大") + return value + + +_ARTIFACT_FILES: tuple[tuple[str, str], ...] = ( + ("merged", "keyword_pipeline_merged.csv"), + ("pc_search", "pc_search_export.csv"), + ("comments", "comments_flat.csv"), + ("detail_ware", "detail_ware_export.csv"), + ("report", "competitor_analysis.md"), +) + + +class PipelineJobSerializer(serializers.ModelSerializer): + """列表/详情不返回 cookie 正文。""" + + inline_cookie_used = serializers.SerializerMethodField() + analysis_artifacts = serializers.SerializerMethodField() + + class Meta: + model = PipelineJob + fields = [ + "id", + "platform", + "keyword", + "max_skus", + "page_start", + "page_to", + "pipeline_run_dir", + "cookie_file_path", + "inline_cookie_used", + "pvid", + "request_delay", + "list_pages", + "scenario_filter_enabled", + "report_config", + "status", + "cancellation_requested", + "run_dir", + "error_message", + "analysis_artifacts", + "created_at", + "updated_at", + ] + read_only_fields = [ + "id", + "inline_cookie_used", + "analysis_artifacts", + "status", + "cancellation_requested", + "run_dir", + "error_message", + "created_at", + "updated_at", + "report_config", + ] + + def get_inline_cookie_used(self, obj: PipelineJob) -> bool: + return bool((obj.cookie_text or "").strip()) + + def get_analysis_artifacts(self, obj: PipelineJob) -> dict[str, bool] | None: + if obj.status not in (JobStatus.SUCCESS, JobStatus.CANCELLED) or not ( + obj.run_dir or "" + ).strip(): + return None + try: + base = Path(obj.run_dir).expanduser().resolve() + return { key: (base / name).is_file() for key, name in _ARTIFACT_FILES } + except (OSError, ValueError, RuntimeError): + return None + + +class JdProductListSerializer(serializers.ModelSerializer): + """列表:不含整包 payload,减少流量。""" + + snapshot_count = serializers.IntegerField(read_only=True, required=False) + + class Meta: + model = JdProduct + fields = [ + "id", + "platform", + "sku_id", + "ware_id", + "title", + "detail_brand", + "detail_price_final", + "last_captured_at", + "last_job", + "snapshot_count", + ] + + +class JdProductDetailSerializer(serializers.ModelSerializer): + snapshot_count = serializers.IntegerField(read_only=True, required=False) + + class Meta: + model = JdProduct + fields = [ + "id", + "platform", + "sku_id", + "ware_id", + "title", + "detail_brand", + "detail_price_final", + "detail_category_path", + "current_payload", + "last_job", + "last_captured_at", + "snapshot_count", + "created_at", + "updated_at", + ] + + +class JdProductSnapshotBriefSerializer(serializers.ModelSerializer): + job_keyword = serializers.CharField(source="job.keyword", read_only=True) + + class Meta: + model = JdProductSnapshot + fields = ["id", "job", "job_keyword", "run_dir", "captured_at"] + + +class JdProductSnapshotDetailSerializer(serializers.ModelSerializer): + job_keyword = serializers.CharField(source="job.keyword", read_only=True) + sku_id = serializers.CharField(source="product.sku_id", read_only=True) + platform = serializers.CharField(source="product.platform", read_only=True) + + class Meta: + model = JdProductSnapshot + fields = [ + "id", + "platform", + "sku_id", + "job", + "job_keyword", + "run_dir", + "captured_at", + "payload", + ] + + +def _jd_data_root() -> Path: + root = (settings.LOW_GI_PROJECT_ROOT or "").strip() + if not root: + raise serializers.ValidationError("服务器未配置 LOW_GI_PROJECT_ROOT") + return (Path(root) / "data" / "JD").resolve() + + +class CreatePipelineJobSerializer(serializers.Serializer): + keyword = serializers.CharField(max_length=256, trim_whitespace=True) + platform = serializers.ChoiceField(choices=["jd"], default="jd") + max_skus = serializers.IntegerField(required=False, min_value=1, allow_null=True) + page_start = serializers.IntegerField(required=False, min_value=1, allow_null=True) + page_to = serializers.IntegerField(required=False, min_value=1, allow_null=True) + pipeline_run_dir = serializers.CharField( + required=False, allow_blank=True, max_length=1024, default="" + ) + cookie_file_path = serializers.CharField( + required=False, allow_blank=True, max_length=2048, default="" + ) + cookie_text = serializers.CharField( + required=False, + allow_blank=True, + default="", + max_length=500_000, + ) + pvid = serializers.CharField(required=False, allow_blank=True, max_length=128, default="") + request_delay = serializers.CharField( + required=False, allow_blank=True, max_length=64, default="" + ) + list_pages = serializers.CharField( + required=False, allow_blank=True, max_length=64, default="" + ) + scenario_filter_enabled = serializers.BooleanField(required=False, allow_null=True) + report_config = serializers.JSONField(required=False, default=dict) + + def validate_report_config(self, value): + if not value: + return {} + return validate_report_config_body(value) + + def validate_cookie_text(self, value: str) -> str: + return normalize_browser_cookie_paste(value or "") + + def validate_pipeline_run_dir(self, value: str) -> str: + v = (value or "").strip() + if not v: + return "" + p = Path(v).expanduser() + jd_root = _jd_data_root() + if p.is_absolute(): + try: + p.resolve().relative_to(jd_root) + except ValueError: + raise serializers.ValidationError( + f"绝对路径须位于京东数据目录下:{jd_root}" + ) + else: + bad = ("..",) + if any(part in bad for part in p.parts): + raise serializers.ValidationError("路径不能包含 ..") + return v + + def validate_cookie_file_path(self, value: str) -> str: + v = (value or "").strip() + if not v: + return "" + p = Path(v).expanduser().resolve() + if not p.is_file(): + raise serializers.ValidationError(f"Cookie 文件不存在:{p}") + low = Path(settings.LOW_GI_PROJECT_ROOT).resolve() + try: + p.relative_to(low) + except ValueError: + raise serializers.ValidationError( + "Cookie 文件路径须位于 LOW_GI_PROJECT_ROOT 目录之下" + ) + return str(p) + + +class JobReportConfigPatchSerializer(serializers.Serializer): + report_config = serializers.JSONField() + + def validate_report_config(self, value): + if not isinstance(value, dict): + raise serializers.ValidationError("须为 JSON 对象") + return validate_report_config_body(value) + + +class RegenerateReportRequestSerializer(serializers.Serializer): + """重新生成竞品报告:规则引擎或大模型(与 ``AI_crawler.chat_completion_text`` 同一网关)。""" + + generator = serializers.ChoiceField( + choices=["rules", "llm"], + default="rules", + required=False, + ) + + +class StrategyDraftRequestSerializer(serializers.Serializer): + """市场策略制定:业务备注 + 可选「决策填空/勾选」,与 competitor-brief 合并为策略向 Markdown。""" + + business_notes = serializers.CharField( + required=False, + allow_blank=True, + default="", + max_length=20_000, + trim_whitespace=False, + ) + product_role = serializers.CharField( + required=False, allow_blank=True, default="", max_length=500, trim_whitespace=False + ) + time_horizon = serializers.CharField( + required=False, allow_blank=True, default="", max_length=200, trim_whitespace=False + ) + success_criteria = serializers.CharField( + required=False, allow_blank=True, default="", max_length=2000, trim_whitespace=False + ) + non_goals = serializers.CharField( + required=False, allow_blank=True, default="", max_length=1000, trim_whitespace=False + ) + battlefield_one_line = serializers.CharField( + required=False, allow_blank=True, default="", max_length=1000, trim_whitespace=False + ) + positioning_choice = serializers.ChoiceField( + choices=["", "top", "mid", "entry", "different"], + default="", + required=False, + ) + competitive_stance = serializers.ChoiceField( + choices=["", "flank", "head_on", "both", "undecided"], + default="", + required=False, + ) + pillar_product = serializers.CharField( + required=False, allow_blank=True, default="", max_length=800, trim_whitespace=False + ) + pillar_price = serializers.CharField( + required=False, allow_blank=True, default="", max_length=800, trim_whitespace=False + ) + pillar_channel = serializers.CharField( + required=False, allow_blank=True, default="", max_length=800, trim_whitespace=False + ) + pillar_comm = serializers.CharField( + required=False, allow_blank=True, default="", max_length=800, trim_whitespace=False + ) + ack_risk_keywords = serializers.BooleanField(required=False, default=False) + ack_risk_price = serializers.BooleanField(required=False, default=False) + ack_risk_concentration = serializers.BooleanField(required=False, default=False) + generator = serializers.ChoiceField( + choices=["rules", "llm"], + default="rules", + required=False, + ) diff --git a/backend/pipeline/strategy_draft.py b/backend/pipeline/strategy_draft.py new file mode 100644 index 0000000..09e65c5 --- /dev/null +++ b/backend/pipeline/strategy_draft.py @@ -0,0 +1,363 @@ +""" +市场策略 Markdown 草稿:侧重**策略制定框架**(目标、战场、定位、支柱、行动), +基于同任务结构化摘要与可选业务备注规则生成;附录为关键数据速览。 + +后续可接 LLM 润色;当前无模型调用,便于验收与追溯。 +""" +from __future__ import annotations + +import math +from typing import Any + + +def _esc(s: Any) -> str: + t = "" if s is None else str(s).strip() + return t.replace("\r\n", "\n").replace("\r", "\n") + + +def _pct(x: Any) -> str: + if x is None: + return "—" + try: + v = float(x) + if math.isnan(v) or math.isinf(v): + return "—" + return f"{100 * v:.1f}%" + except (TypeError, ValueError): + return "—" + + +def _num(x: Any) -> str: + if x is None: + return "—" + if isinstance(x, bool): + return str(x) + if isinstance(x, int): + return str(x) + if isinstance(x, float): + if math.isnan(x) or math.isinf(x): + return "—" + if x == int(x): + return str(int(x)) + return f"{x:.2f}" + return str(x) + + +def _cr_narrative(label: str, cr1: Any, cr3: Any, top: Any) -> str | None: + """从集中度生成一句策略向描述,无数据则返回 None。""" + try: + c1 = float(cr1) if cr1 is not None else None + except (TypeError, ValueError): + c1 = None + if c1 is None and not (top or "").strip(): + return None + top_s = _esc(top) or "—" + if c1 is not None: + if c1 >= 0.4: + tone = "偏高,头部资源集中" + elif c1 >= 0.25: + tone = "中等,存在可争夺空间" + else: + tone = "相对分散,差异化切入点可能更多" + return f"- **{label}**:第一大品牌/店份额 ≈ {_pct(cr1)},前三合计份额 ≈ {_pct(cr3)};头部为「{top_s}」。*粗判:{tone}。*" + return f"- **{label}**:头部标签「{top_s}」(缺少份额指标时可结合列表/商详数据补全)。" + + +def _goal_bullet(label: str, user_val: str, placeholder: str) -> str: + v = _esc(user_val).strip() + if v: + return f"- **{label}**:{v}" + return f"- **{label}**:*({placeholder})*" + + +def _pillar_cell(user_val: str) -> str: + v = _esc(user_val).strip() + return v if v else "*待填*" + + +def _pos_mark(choice: str, key: str) -> str: + return "[x]" if choice == key else "[ ]" + + +def _risk_line(checked: bool, text: str) -> str: + mark = "[x]" if checked else "[ ]" + return f"- {mark} {text}" + + +def build_strategy_draft_markdown( + *, + job_id: int, + keyword: str, + brief: dict[str, Any], + business_notes: str = "", + generated_at_iso: str = "", + strategy_decisions: dict[str, Any] | None = None, +) -> str: + """生成可下载的 Markdown:策略框架为主,附录为数据速览。""" + d = strategy_decisions or {} + pos = _esc(d.get("positioning_choice") or "").strip() + kw = _esc(brief.get("keyword")) or _esc(keyword) or "—" + lines: list[str] = [ + f"# 市场策略制定草稿 · 「{kw}」", + "", + "> 本稿用于**辅助制定市场策略**;由规则根据本批次结构化摘要与业务备注生成,**非大模型自由发挥**,定稿前请业务修订。", + "", + ] + if generated_at_iso: + lines.append(f"> **生成时间**:{_esc(generated_at_iso)} · **任务 ID**:{job_id}") + lines.append("") + + lines.extend( + [ + "---", + "", + "## 一、战略背景与目标(请业务补全)", + "", + _goal_bullet("本品角色", str(d.get("product_role") or ""), "新品 / 追赶 / 防守 / 拓品类 …"), + _goal_bullet("时间范围", str(d.get("time_horizon") or ""), "如:本季度 / 未来 12 周"), + _goal_bullet( + "成功标准(可量化)", + str(d.get("success_criteria") or ""), + "如:搜索位次、转化率、声量、复购 …", + ), + _goal_bullet("非目标(明确不做什么)", str(d.get("non_goals") or ""), "可选"), + "", + ] + ) + + scope = brief.get("scope") or {} + merged_n = scope.get("merged_sku_count") + comm_n = scope.get("comment_flat_rows") + lines.extend( + [ + "## 二、战场界定(监测语境)", + "", + f"- **监测关键词 / 货架语境**:{kw}", + f"- **批次**:{_esc(brief.get('batch_label')) or '—'}", + ] + ) + if merged_n is not None or comm_n is not None: + lines.append( + f"- **深入样本规模**:深入 SKU ≈ {_num(merged_n)};评价扁平条数 ≈ {_num(comm_n)}。" + "*策略含义:样本越大,以下「假设」越需抽样复核原评论。*" + ) + bf = _esc(d.get("battlefield_one_line") or "").strip() + if bf: + lines.append(f"- **一句话战场**:{bf}") + else: + lines.append( + "- **一句话战场**:*(请用业务语言写:我们在哪个需求场景、与谁抢同一批用户?)*" + ) + lines.append("") + + conc = brief.get("concentration") or {} + shops = conc.get("shops_from_list") or {} + dbrand = conc.get("detail_brand_among_merged") or {} + lines.extend(["## 三、竞争格局 → 策略含义", ""]) + n_shop = _cr_narrative("列表侧店铺集中度", shops.get("cr1"), shops.get("cr3"), shops.get("top_label")) + n_brand = _cr_narrative("深入样本内品牌集中度", dbrand.get("cr1"), dbrand.get("cr3"), dbrand.get("top_label")) + if n_shop: + lines.append(n_shop) + if n_brand: + lines.append(n_brand) + if not n_shop and not n_brand: + lines.append("*本摘要未含集中度指标,请结合本批次竞争结构数据补全后再写判断。*") + lines.extend( + [ + "", + "**可下判断的提问(自测)**", + "", + "- 若头部已占稳心智,本品是**侧翼**还是**正面替代**?", + "- 店铺/品牌分散时,是否适合用**细分场景**或**内容教育**切入?", + "", + ] + ) + stance = _esc(d.get("competitive_stance") or "").strip() + stance_line = { + "flank": "- **本品倾向**:倾向**侧翼切入**,避免与头部正面硬碰。", + "head_on": "- **本品倾向**:倾向**正面替代**,对标头部主战场。", + "both": "- **本品倾向**:计划**分层推进**(部分场景侧翼、部分场景正面)。", + "undecided": "- **本品倾向**:**尚未拍板**,需在会议中对齐后再定主战场叙事。", + }.get(stance) + if stance_line: + lines.append(stance_line) + lines.append("") + + mix = brief.get("category_mix_top") or [] + if mix: + lines.append("### 类目结构提示(Top)") + lines.append("") + lines.append("*以下仅作「货架长什么样」的速记。*") + for item in mix[:6]: + if isinstance(item, dict): + lines.append(f"- {_esc(item.get('label'))}:{_num(item.get('count'))}") + lines.append("") + + pst = brief.get("price_stats") or {} + lines.extend(["## 四、价格带与定位选项(启发式)", ""]) + if pst.get("n"): + src = _esc(brief.get("price_stats_source")) or "—" + lines.extend( + [ + f"- **统计口径**:{src},有效价样本 n = {_num(pst.get('n'))}。", + f"- **展示价区间**:{_num(pst.get('min'))} ~ {_num(pst.get('max'))};**中位数** {_num(pst.get('median'))}。", + "", + "**定位选项(请勾一条或改写,并写明理由)**", + "", + f"- {_pos_mark(pos, 'top')} **贴顶**:对标中高位或头部价位带,强调品质/成分/背书。", + f"- {_pos_mark(pos, 'mid')} **卡腰**:围绕中位数一带,强调性价比与场景匹配。", + f"- {_pos_mark(pos, 'entry')} **下探**:贴近区间下限,强调入门与拉新(注意毛利与品牌调性)。", + f"- {_pos_mark(pos, 'different')} **另起带**:刻意避开主价格带,用规格/组合/服务差异化。", + "", + ] + ) + else: + lines.append("*摘要中无价带统计,请结合本批次价格相关数据补全后再填上表。*") + lines.append("") + lines.extend( + [ + "**定位选项(请勾一条或改写,并写明理由)**", + "", + f"- {_pos_mark(pos, 'top')} **贴顶**:对标中高位或头部价位带,强调品质/成分/背书。", + f"- {_pos_mark(pos, 'mid')} **卡腰**:围绕中位数一带,强调性价比与场景匹配。", + f"- {_pos_mark(pos, 'entry')} **下探**:贴近区间下限,强调入门与拉新(注意毛利与品牌调性)。", + f"- {_pos_mark(pos, 'different')} **另起带**:刻意避开主价格带,用规格/组合/服务差异化。", + "", + ] + ) + + ckw = brief.get("comment_focus_keywords") or [] + usc = brief.get("usage_scenarios") or [] + lines.extend(["## 五、用户需求与场景 — 可写成策略的假设", ""]) + lines.append( + "*下列由关注词/场景**计数**转化而来,是「待验证假设」而非结论;请结合评价原文抽样修订。*" + ) + lines.append("") + if ckw: + for item in ckw[:8]: + if isinstance(item, dict): + w = _esc(item.get("word")) + c = _num(item.get("count")) + lines.append( + f"- **假设**:用户决策中「{w}」被频繁提及(约 {c} 次统计命中)—— " + f"*可追问:本品故事是否正面回应?传播关键词是否覆盖?*" + ) + if usc: + for item in usc[:6]: + if isinstance(item, dict): + sc = _esc(item.get("scenario")) + cn = _num(item.get("count")) + sh = _pct(item.get("share_of_text_units")) + lines.append( + f"- **场景命题**:「{sc}」在预设场景中约 {cn} 条、约占 {sh} 文本单元—— " + f"*可追问:主图/详情/客服话术是否对齐该场景?*" + ) + if not ckw and not usc: + lines.append("*摘要中无关注词/场景组结果,请补全评论侧分析后再写本节。*") + lines.append("") + + hints = brief.get("strategy_hints") or [] + lines.extend( + [ + "## 六、机会方向与策略支柱(草案)", + "", + "### 规则引擎提示(来自摘要 `strategy_hints`)", + "", + ] + ) + if hints: + for h in hints: + lines.append(f"- {_esc(h)}") + else: + lines.append("*(当前无自动线索,请结合本批次结论手写 3~5 条机会)*") + pp = str(d.get("pillar_product") or "") + pr = str(d.get("pillar_price") or "") + pch = str(d.get("pillar_channel") or "") + pcm = str(d.get("pillar_comm") or "") + lines.extend( + [ + "", + "### 策略支柱 — 请业务逐项填空", + "", + "| 支柱 | 本品打算怎么做 | 与头部差异 | 证据 / 出处 |", + "|------|----------------|------------|-------------|", + f"| 产品 | {_pillar_cell(pp)} | *待填* | *§* |", + f"| 价格 | {_pillar_cell(pr)} | *待填* | *§* |", + f"| 渠道/触点 | {_pillar_cell(pch)} | *待填* | *§* |", + f"| 传播与内容 | {_pillar_cell(pcm)} | *待填* | *§* |", + "", + ] + ) + + rk = bool(d.get("ack_risk_keywords")) + rp = bool(d.get("ack_risk_price")) + rc = bool(d.get("ack_risk_concentration")) + lines.extend( + [ + "## 七、风险与待证伪", + "", + _risk_line(rk, "关注词/场景是否**以偏概全**?(需原评论抽样)"), + _risk_line(rp, "价格带是否含大促/异常挂价?(需核对清洗口径)"), + _risk_line(rc, "列表集中度与深入样本品牌是否**矛盾**?(需解释渠道差异)"), + "", + ] + ) + + notes = _esc(business_notes) + lines.extend( + [ + "## 八、业务约束与内部判断", + "", + (notes if notes else "*(未填写。建议补充:渠道红线、价位策略、竞品对标名单、预算量级等。)*"), + "", + ] + ) + + lines.extend( + [ + "## 九、建议下一步(策略向)", + "", + "- [ ] 开会对齐:**§一** 目标与 **§八** 约束,确认 1~2 条主策略命题。", + "- [ ] 为 **§六** 策略支柱表格每一行各找 **1 条数据证据**(注明出处)。", + "- [ ] 产出 **12 周节奏表**(里程碑 + 负责人),与本品排期挂钩。", + "- [ ] 定义 **3 个可观测指标**(周或双周复盘)。", + "", + "---", + "", + "## 附录 · 本任务关键数据速览", + "", + f"- **关键词**:{kw} · **摘要版本**:v{_num(brief.get('schema_version'))}", + ] + ) + meta = brief.get("meta") + meta_labels = { + "page_start": "起始页", + "page_to": "采集至页", + "max_skus_config": "SKU 上限", + "scenario_filter_enabled": "场景筛选", + } + if isinstance(meta, dict) and meta: + bits = [] + for k in ("page_start", "page_to", "max_skus_config", "scenario_filter_enabled"): + if k in meta: + label = meta_labels.get(k, k) + bits.append(f"{label}={_esc(meta.get(k))}") + if bits: + lines.append(f"- **采集参数快照**:{'; '.join(bits)}") + raw = brief.get("pc_search_raw") or {} + if raw.get("result_count_consensus") is not None: + lines.append( + f"- **列表申报规模(resultCount)**:{_num(raw.get('result_count_consensus'))}" + ) + lines.extend( + [ + "", + "*同目录含本批次 CSV 与分析产出,可对照使用。*", + "", + "---", + "", + "*本稿由工作台「市场策略制定」生成;与同任务结构化分析数据一致。*", + "", + ] + ) + return "\n".join(lines) diff --git a/backend/pipeline/tasks.py b/backend/pipeline/tasks.py new file mode 100644 index 0000000..1ad8a5c --- /dev/null +++ b/backend/pipeline/tasks.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import os +import subprocess +import sys +import time +import traceback +from pathlib import Path + +from django.conf import settings +from django.utils import timezone + +from .cookie_paste import normalize_browser_cookie_paste +from .ingest import try_ingest_job_full +from .jd_runner import ( + resolve_pipeline_run_directory_for_job, + try_write_competitor_report_if_merged_exists, +) +from .models import JobStatus, PipelineJob + + +def execute_job(job_id: int) -> None: + job = PipelineJob.objects.filter(pk=job_id).first() + if not job: + return + + job.status = JobStatus.RUNNING + job.error_message = "" + job.save(update_fields=["status", "error_message", "updated_at"]) + + job.refresh_from_db() + if job.cancellation_requested: + job.status = JobStatus.CANCELLED + job.cancellation_requested = False + job.error_message = "已终止(任务开始后立即收到终止请求)。" + job.updated_at = timezone.now() + job.save( + update_fields=[ + "status", + "cancellation_requested", + "error_message", + "updated_at", + ], + ) + PipelineJob.objects.filter(pk=job_id).update(cookie_text="") + return + + cookie_temp: Path | None = None + try: + if job.platform != "jd": + raise ValueError(f"暂不支持平台: {job.platform}") + + cookie_path_for_pipeline: str | None = None + _cookie_body = normalize_browser_cookie_paste(job.cookie_text or "") + if _cookie_body: + runtime_dir = Path(settings.BASE_DIR) / "runtime_cookies" + runtime_dir.mkdir(parents=True, exist_ok=True) + cookie_temp = (runtime_dir / f"job_{job_id}_cookie.txt").resolve() + cookie_temp.write_text(_cookie_body, encoding="utf-8") + cookie_path_for_pipeline = str(cookie_temp) + elif (job.cookie_file_path or "").strip(): + cookie_path_for_pipeline = job.cookie_file_path.strip() + + rc_cfg = job.report_config if isinstance(job.report_config, dict) else {} + run_dir_path = resolve_pipeline_run_directory_for_job(job) + run_dir_path.mkdir(parents=True, exist_ok=True) + + manage_py = Path(settings.BASE_DIR) / "manage.py" + env = os.environ.copy() + env["PIPELINE_JOB_RUN_DIR"] = str(run_dir_path.resolve()) + if cookie_path_for_pipeline: + env["PIPELINE_JOB_COOKIE_PATH"] = str(cookie_path_for_pipeline) + + proc = subprocess.Popen( + [sys.executable, str(manage_py), "run_pipeline_job", str(job_id)], + cwd=str(Path(settings.BASE_DIR).resolve()), + env=env, + stdin=subprocess.DEVNULL, + ) + user_terminated = False + while True: + if proc.poll() is not None: + break + time.sleep(0.25) + if PipelineJob.objects.filter( + pk=job_id, cancellation_requested=True + ).exists(): + user_terminated = True + proc.terminate() + break + + if proc.poll() is None: + try: + proc.wait(timeout=25) + except subprocess.TimeoutExpired: + proc.kill() + try: + proc.wait(timeout=15) + except subprocess.TimeoutExpired: + pass + else: + proc.wait() + + returncode = proc.returncode + if returncode is None: + returncode = -1 + + if user_terminated: + job.status = JobStatus.CANCELLED + job.run_dir = str(run_dir_path.resolve()) + job.cancellation_requested = False + job.error_message = ( + "已终止:已结束采集子进程(与在终端对脚本按 Ctrl+C 类似,可能留下部分文件)。" + ) + try_write_competitor_report_if_merged_exists( + run_dir_path, + (job.keyword or "").strip(), + report_config=rc_cfg or None, + ) + elif returncode == 0: + job.status = JobStatus.SUCCESS + job.run_dir = str(run_dir_path.resolve()) + job.error_message = "" + job.cancellation_requested = False + else: + job.status = JobStatus.FAILED + job.run_dir = str(run_dir_path.resolve()) + job.cancellation_requested = False + job.error_message = f"流水线子进程异常退出(exit {returncode})。" + except Exception as e: + job.status = JobStatus.FAILED + job.cancellation_requested = False + job.error_message = f"{e}\n\n{traceback.format_exc()}" + job.updated_at = timezone.now() + job.save( + update_fields=[ + "status", + "run_dir", + "error_message", + "cancellation_requested", + "updated_at", + ], + ) + if job.status == JobStatus.SUCCESS: + try_ingest_job_full(PipelineJob.objects.get(pk=job_id)) + if cookie_temp is not None and cookie_temp.is_file(): + try: + cookie_temp.unlink() + except OSError: + pass + PipelineJob.objects.filter(pk=job_id).update(cookie_text="") diff --git a/backend/pipeline/tests/__init__.py b/backend/pipeline/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/pipeline/tests/test_brief_compact.py b/backend/pipeline/tests/test_brief_compact.py new file mode 100644 index 0000000..97031ef --- /dev/null +++ b/backend/pipeline/tests/test_brief_compact.py @@ -0,0 +1,49 @@ +"""brief_compact:矩阵裁剪后仍保留 matrix_overview_for_llm。""" +from __future__ import annotations + +from django.test import SimpleTestCase + +from pipeline.brief_compact import compact_brief_for_llm, matrix_overview_for_llm + + +class BriefCompactTests(SimpleTestCase): + def test_matrix_overview_always_in_compact_output(self) -> None: + brief = { + "keyword": "测", + "matrix_by_group": [ + { + "group": "饼干", + "sku_count": 2, + "skus": [ + {"brand": "A牌", "sku_id": "1"}, + {"brand": "B牌", "sku_id": "2"}, + ], + } + ], + } + out = compact_brief_for_llm(brief, max_chars=120_000) + self.assertEqual(len(out["matrix_overview_for_llm"]), 1) + self.assertEqual(out["matrix_overview_for_llm"][0]["group"], "饼干") + self.assertIn("A牌", out["matrix_overview_for_llm"][0]["distinct_brands_sample"]) + + def test_overview_preserved_when_matrix_omitted(self) -> None: + brief = { + "matrix_by_group": [ + { + "group": f"G{i}", + "sku_count": 2, + "skus": [ + {"brand": "B", "sku_id": str(j)} + for j in range(2) + ], + } + for i in range(40) + ], + "consumer_feedback_by_matrix_group": [], + } + out = compact_brief_for_llm(brief, max_chars=200) + self.assertTrue(out.get("matrix_by_group_omitted")) + self.assertEqual(len(out["matrix_overview_for_llm"]), 40) + + def test_matrix_overview_for_llm_empty_without_matrix(self) -> None: + self.assertEqual(matrix_overview_for_llm({}), []) diff --git a/backend/pipeline/tests/test_brief_pack.py b/backend/pipeline/tests/test_brief_pack.py new file mode 100644 index 0000000..b545fc3 --- /dev/null +++ b/backend/pipeline/tests/test_brief_pack.py @@ -0,0 +1,51 @@ +"""简报包 ZIP 与要点摘录 Markdown。""" +from __future__ import annotations + +import json +import zipfile +from io import BytesIO +from pathlib import Path +from tempfile import TemporaryDirectory + +from django.test import SimpleTestCase + +from pipeline.brief_pack import ( + build_brief_pack_zip_bytes, + markdown_summary_from_brief, +) + + +class BriefPackTests(SimpleTestCase): + def test_markdown_summary_contains_keyword_and_hints(self) -> None: + md = markdown_summary_from_brief( + { + "keyword": "测试词", + "batch_label": "batch1", + "scope": {"merged_sku_count": 3, "comment_flat_rows": 10}, + "strategy_hints": ["提示一行"], + } + ) + self.assertIn("测试词", md) + self.assertIn("提示一行", md) + self.assertIn("深入 SKU 数", md) + + def test_zip_contains_expected_entries(self) -> None: + brief = {"keyword": "k", "schema_version": 1} + with TemporaryDirectory() as td: + p = Path(td) / "competitor_analysis.md" + p.write_text("# 报告\n", encoding="utf-8") + raw = build_brief_pack_zip_bytes(Path(td), brief) + buf = BytesIO(raw) + with zipfile.ZipFile(buf, "r") as zf: + names = set(zf.namelist()) + data = json.loads(zf.read("02_结构化摘要.json").decode()) + self.assertIn("01_竞品分析报告.md", names) + self.assertIn("02_结构化摘要.json", names) + self.assertIn("03_要点摘录.md", names) + self.assertIn("00_说明.txt", names) + self.assertEqual(data["keyword"], "k") + + def test_zip_raises_without_report_file(self) -> None: + with TemporaryDirectory() as td: + with self.assertRaises(FileNotFoundError): + build_brief_pack_zip_bytes(Path(td), {}) diff --git a/backend/pipeline/tests/test_competitor_brief.py b/backend/pipeline/tests/test_competitor_brief.py new file mode 100644 index 0000000..6dccb96 --- /dev/null +++ b/backend/pipeline/tests/test_competitor_brief.py @@ -0,0 +1,67 @@ +"""结构化竞品摘要:空样本烟测(不依赖真实 run_dir CSV)。""" +from __future__ import annotations + +import sys +import tempfile +from pathlib import Path + +from django.conf import settings +from django.test import SimpleTestCase + + +class BuildCompetitorBriefTests(SimpleTestCase): + def test_empty_merged_json_safe(self) -> None: + root = Path(settings.CRAWLER_JD_ROOT).resolve() + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + import jd_competitor_report as jcr # noqa: WPS433 + + with tempfile.TemporaryDirectory() as td: + run_dir = Path(td) + (run_dir / "pc_search_raw").mkdir(parents=True) + + out = jcr.build_competitor_brief( + run_dir=run_dir, + keyword="测试", + merged_rows=[], + search_export_rows=[], + comment_rows=[], + meta=None, + ) + + self.assertEqual(out["schema_version"], 1) + self.assertEqual(out["scope"]["merged_sku_count"], 0) + self.assertIsInstance(out["strategy_hints"], list) + self.assertEqual(out["matrix_by_group"], []) + self.assertIn("comment_sentiment_lexicon", out) + self.assertEqual(out["comment_sentiment_lexicon"].get("text_units"), 0) + import json + + json.dumps(out) + + def test_custom_focus_words_in_report_config(self) -> None: + root = Path(settings.CRAWLER_JD_ROOT).resolve() + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + import jd_competitor_report as jcr # noqa: WPS433 + + with tempfile.TemporaryDirectory() as td: + run_dir = Path(td) + (run_dir / "pc_search_raw").mkdir(parents=True) + + out = jcr.build_competitor_brief( + run_dir=run_dir, + keyword="测试", + merged_rows=[], + search_export_rows=[], + comment_rows=[ + { + "tagCommentContent": "自定义词阿尔法出现两次 自定义词阿尔法", + } + ], + meta=None, + report_config={"comment_focus_words": ["自定义词阿尔法"]}, + ) + + words = {x["word"] for x in out["comment_focus_keywords"]} + self.assertIn("自定义词阿尔法", words) diff --git a/backend/pipeline/tests/test_strategy_draft.py b/backend/pipeline/tests/test_strategy_draft.py new file mode 100644 index 0000000..e853fde --- /dev/null +++ b/backend/pipeline/tests/test_strategy_draft.py @@ -0,0 +1,56 @@ +"""市场策略草稿 Markdown(规则,无 LLM)。""" +from __future__ import annotations + +from django.test import SimpleTestCase + +from pipeline.strategy_draft import build_strategy_draft_markdown + + +class StrategyDraftTests(SimpleTestCase): + def test_build_contains_sections_and_notes(self) -> None: + brief = { + "schema_version": 1, + "keyword": "测试K", + "batch_label": "b1", + "scope": {"merged_sku_count": 2, "comment_flat_rows": 5}, + "strategy_hints": ["假设A"], + } + md = build_strategy_draft_markdown( + job_id=99, + keyword="测试K", + brief=brief, + business_notes="重点:华东", + generated_at_iso="2026-04-09T12:00:00", + ) + self.assertIn("测试K", md) + self.assertIn("任务 ID**:99", md) + self.assertIn("假设A", md) + self.assertIn("重点:华东", md) + self.assertIn("战略背景与目标", md) + self.assertIn("市场策略制定草稿", md) + + def test_strategy_decisions_merge(self) -> None: + brief = {"schema_version": 1, "keyword": "K", "batch_label": "b"} + decisions = { + "product_role": "追赶型", + "positioning_choice": "mid", + "competitive_stance": "flank", + "pillar_product": "做低糖配方", + "ack_risk_keywords": True, + "ack_risk_price": False, + "ack_risk_concentration": True, + } + md = build_strategy_draft_markdown( + job_id=1, + keyword="K", + brief=brief, + strategy_decisions=decisions, + ) + self.assertIn("**本品角色**:追赶型", md) + self.assertIn("- [x] **卡腰**", md) + self.assertIn("- [ ] **贴顶**", md) + self.assertIn("侧翼切入", md) + self.assertIn("| 产品 | 做低糖配方 |", md) + self.assertIn("- [x] 关注词/场景是否**以偏概全**", md) + self.assertIn("- [ ] 价格带是否含大促", md) + self.assertIn("- [x] 列表集中度与深入样本品牌是否**矛盾**", md) diff --git a/backend/pipeline/urls.py b/backend/pipeline/urls.py new file mode 100644 index 0000000..14d09e9 --- /dev/null +++ b/backend/pipeline/urls.py @@ -0,0 +1,101 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path( + "report-config-defaults/", + views.ReportConfigDefaultsView.as_view(), + name="report-config-defaults", + ), + path("jobs/", views.JobListCreateView.as_view(), name="job-list-create"), + path("jobs/<int:pk>/", views.JobDetailView.as_view(), name="job-detail"), + path( + "jobs/<int:pk>/cancel/", + views.JobCancelView.as_view(), + name="job-cancel", + ), + path("jobs/<int:pk>/download/", views.JobDownloadView.as_view(), name="job-download"), + path("jobs/<int:pk>/preview/", views.JobPreviewView.as_view(), name="job-preview"), + path( + "jobs/<int:pk>/dataset/summary/", + views.JobDatasetSummaryView.as_view(), + name="job-dataset-summary", + ), + path( + "jobs/<int:pk>/dataset/search/", + views.JobDatasetSearchView.as_view(), + name="job-dataset-search", + ), + path( + "jobs/<int:pk>/dataset/detail/", + views.JobDatasetDetailView.as_view(), + name="job-dataset-detail", + ), + path( + "jobs/<int:pk>/dataset/comments/", + views.JobDatasetCommentsView.as_view(), + name="job-dataset-comments", + ), + path( + "jobs/<int:pk>/dataset/merged/", + views.JobDatasetMergedView.as_view(), + name="job-dataset-merged", + ), + path( + "jobs/<int:pk>/export/", + views.JobDatasetExportView.as_view(), + name="job-dataset-export", + ), + path( + "jobs/<int:pk>/regenerate-report/", + views.JobRegenerateReportView.as_view(), + name="job-regenerate-report", + ), + path( + "jobs/<int:pk>/competitor-brief/", + views.JobCompetitorBriefView.as_view(), + name="job-competitor-brief", + ), + path( + "jobs/<int:pk>/competitor-brief-pack/", + views.JobCompetitorBriefPackView.as_view(), + name="job-competitor-brief-pack", + ), + path( + "jobs/<int:pk>/strategy-draft/", + views.JobStrategyDraftView.as_view(), + name="job-strategy-draft", + ), + path( + "jobs/<int:pk>/export-document/", + views.JobExportDocumentView.as_view(), + name="job-export-document", + ), + path( + "jobs/<int:pk>/report-asset/", + views.JobReportAssetView.as_view(), + name="job-report-asset", + ), + path( + "jobs/<int:pk>/ingest-merged/", + views.JobImportMergedView.as_view(), + name="job-ingest-merged", + ), + path("jd/products/", views.JdProductListView.as_view(), name="jd-product-list"), + path( + "jd/products/<str:sku_id>/", + views.JdProductDetailView.as_view(), + name="jd-product-detail", + ), + path( + "jd/products/<str:sku_id>/snapshots/", + views.JdProductSnapshotListView.as_view(), + name="jd-product-snapshots", + ), + path( + "jd/snapshots/<int:pk>/", + views.JdProductSnapshotDetailView.as_view(), + name="jd-snapshot-detail", + ), +] diff --git a/backend/pipeline/views.py b/backend/pipeline/views.py new file mode 100644 index 0000000..2bdc923 --- /dev/null +++ b/backend/pipeline/views.py @@ -0,0 +1,858 @@ +from __future__ import annotations + +import mimetypes +import threading +from pathlib import Path +from typing import Any + +import requests +from django.conf import settings +from django.db.models import Count, Q +from django.http import FileResponse, Http404, HttpResponse +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from .dataset_nonempty import ( + comment_columns_for_api, + detail_columns_for_api, + merged_columns_for_api, + search_columns_for_api, +) +from .export_job import build_csv_bytes, build_json_bytes, build_xlsx_bytes +from .row_serialize import ( + comment_row_to_dict, + detail_row_to_dict, + merged_row_to_dict, + search_row_to_dict, +) +from .brief_pack import build_brief_pack_zip_bytes +from .strategy_draft import build_strategy_draft_markdown +from .ingest import ingest_job_full +from .jd_runner import ( + build_competitor_brief_for_job, + get_default_report_config, + merge_llm_supplement_with_rules_report, + regenerate_competitor_report, + write_competitor_analysis_markdown, +) +from .llm_generate import ( + generate_competitor_report_markdown_llm, + generate_strategy_draft_markdown_llm, +) +from .md_document_export import markdown_to_docx_bytes, markdown_to_pdf_bytes +from .models import ( + JdJobCommentRow, + JdJobDetailRow, + JdJobMergedRow, + JdJobSearchRow, + JdProduct, + JdProductSnapshot, + JobStatus, + PipelineJob, +) +from .serializers import ( + CreatePipelineJobSerializer, + JdProductDetailSerializer, + JdProductListSerializer, + JdProductSnapshotBriefSerializer, + JdProductSnapshotDetailSerializer, + JobReportConfigPatchSerializer, + PipelineJobSerializer, + RegenerateReportRequestSerializer, + StrategyDraftRequestSerializer, +) +from .tasks import execute_job + +# 在线预览最大字节(超出则截断并提示下载) +_PREVIEW_MAX_BYTES = 2 * 1024 * 1024 + +# 允许下载的相对文件名(均在 run_dir 下) +_DOWNLOAD_NAMES = frozenset( + { + "merged", + "pc_search", + "comments", + "detail_ware", + "report", + } +) + + +def _jd_data_root() -> Path: + root = (settings.LOW_GI_PROJECT_ROOT or "").strip() + if not root: + raise RuntimeError("LOW_GI_PROJECT_ROOT 未配置") + return (Path(root) / "data" / "JD").resolve() + + +def _safe_file_for_job(run_dir_str: str, name: str) -> Path: + if name not in _DOWNLOAD_NAMES: + raise Http404("unknown file") + base = Path(run_dir_str).resolve() + jd_root = _jd_data_root().resolve() + try: + base.relative_to(jd_root) + except ValueError: + raise Http404("invalid run_dir") + + mapping = { + "merged": "keyword_pipeline_merged.csv", + "pc_search": "pc_search_export.csv", + "comments": "comments_flat.csv", + "detail_ware": "detail_ware_export.csv", + "report": "competitor_analysis.md", + } + f = base / mapping[name] + if not f.is_file(): + raise Http404("file not found") + return f + + +def _job_run_dir_usable(job: PipelineJob) -> bool: + """成功或已终止但已写入 run_dir 时,可预览/下载批次文件。""" + return bool((job.run_dir or "").strip()) and job.status in ( + JobStatus.SUCCESS, + JobStatus.CANCELLED, + ) + + +@method_decorator(csrf_exempt, name="dispatch") +class JobListCreateView(APIView): + def get(self, request): + qs = PipelineJob.objects.all()[:200] + return Response(PipelineJobSerializer(qs, many=True).data) + + def post(self, request): + if not (settings.LOW_GI_PROJECT_ROOT or "").strip(): + return Response( + {"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + ser = CreatePipelineJobSerializer(data=request.data) + ser.is_valid(raise_exception=True) + data = ser.validated_data + job = PipelineJob.objects.create( + platform=data["platform"], + keyword=data["keyword"], + max_skus=data.get("max_skus"), + page_start=data.get("page_start"), + page_to=data.get("page_to"), + pipeline_run_dir=data.get("pipeline_run_dir") or "", + cookie_file_path=data.get("cookie_file_path") or "", + cookie_text=data.get("cookie_text") or "", + pvid=data.get("pvid") or "", + request_delay=data.get("request_delay") or "", + list_pages=data.get("list_pages") or "", + scenario_filter_enabled=data.get("scenario_filter_enabled"), + report_config=data.get("report_config") or {}, + status=JobStatus.PENDING, + ) + t = threading.Thread(target=execute_job, args=(job.id,), daemon=True) + t.start() + return Response( + PipelineJobSerializer(job).data, + status=status.HTTP_201_CREATED, + ) + + +@method_decorator(csrf_exempt, name="dispatch") +class JobDetailView(APIView): + def get(self, request, pk: int): + job = PipelineJob.objects.filter(pk=pk).first() + if not job: + raise Http404() + return Response(PipelineJobSerializer(job).data) + + def patch(self, request, pk: int): + job = PipelineJob.objects.filter(pk=pk).first() + if not job: + raise Http404() + ser = JobReportConfigPatchSerializer(data=request.data) + ser.is_valid(raise_exception=True) + job.report_config = ser.validated_data["report_config"] + job.save(update_fields=["report_config", "updated_at"]) + return Response(PipelineJobSerializer(job).data) + + +@method_decorator(csrf_exempt, name="dispatch") +class JobCancelView(APIView): + """ + 终止:将 ``cancellation_requested`` 置位后,执行线程会尽快 ``terminate`` 采集子进程 + (效果接近在终端对脚本按 Ctrl+C),并保留已写入运行目录的文件。 + """ + + def post(self, request, pk: int): + job = PipelineJob.objects.filter(pk=pk).first() + if not job: + raise Http404() + if job.status not in (JobStatus.PENDING, JobStatus.RUNNING): + return Response( + {"detail": "仅待执行或执行中的任务可终止"}, + status=status.HTTP_400_BAD_REQUEST, + ) + job.cancellation_requested = True + job.save(update_fields=["cancellation_requested", "updated_at"]) + return Response(PipelineJobSerializer(job).data) + + +class ReportConfigDefaultsView(APIView): + """返回 ``jd_competitor_report`` 中与脚本常量一致的默认报告调参 JSON。""" + + def get(self, request): + try: + return Response(get_default_report_config()) + except FileNotFoundError as e: + return Response( + {"detail": str(e)}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + +class JobDownloadView(APIView): + def get(self, request, pk: int): + job = PipelineJob.objects.filter(pk=pk).first() + if not job or not _job_run_dir_usable(job): + raise Http404() + name = (request.query_params.get("name") or "").strip().lower() + path = _safe_file_for_job(job.run_dir, name) + return FileResponse( + path.open("rb"), + as_attachment=True, + filename=path.name, + ) + + +class JobPreviewView(APIView): + """浏览器内联查看产出(CSV / Markdown 文本),大文件截断。""" + + def get(self, request, pk: int): + job = PipelineJob.objects.filter(pk=pk).first() + if not job or not _job_run_dir_usable(job): + raise Http404() + name = (request.query_params.get("name") or "").strip().lower() + fpath = _safe_file_for_job(job.run_dir, name) + raw = fpath.read_bytes() + truncated = len(raw) > _PREVIEW_MAX_BYTES + if truncated: + raw = raw[:_PREVIEW_MAX_BYTES] + text = raw.decode("utf-8-sig", errors="replace") + if truncated: + text += "\n\n... [内容已截断,完整文件请使用下载]\n" + + if name == "report": + ctype = "text/markdown; charset=utf-8" + else: + ctype = "text/csv; charset=utf-8" + resp = HttpResponse(text, content_type=ctype) + resp["X-Preview-Truncated"] = "1" if truncated else "0" + resp["X-Preview-Filename"] = fpath.name + return resp + + +@method_decorator(csrf_exempt, name="dispatch") +class JobRegenerateReportView(APIView): + """基于任务已有 ``run_dir`` 内 CSV 重新生成 ``competitor_analysis.md``(不重新爬取)。""" + + def post(self, request, pk: int): + if not (settings.LOW_GI_PROJECT_ROOT or "").strip(): + return Response( + {"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + job = PipelineJob.objects.filter(pk=pk).first() + if not job: + raise Http404() + if job.status != JobStatus.SUCCESS or not (job.run_dir or "").strip(): + return Response( + {"detail": "仅可对已成功且已写入 run_dir 的任务重新生成报告"}, + status=status.HTTP_400_BAD_REQUEST, + ) + ser = RegenerateReportRequestSerializer(data=request.data or {}) + ser.is_valid(raise_exception=True) + generator = ser.validated_data.get("generator") or "rules" + rc = job.report_config if isinstance(job.report_config, dict) else None + if generator == "llm": + try: + regenerate_competitor_report( + job.run_dir, job.keyword, report_config=rc + ) + rules_md = ( + Path(job.run_dir) / "competitor_analysis.md" + ).read_text(encoding="utf-8") + brief = build_competitor_brief_for_job( + job.run_dir, job.keyword, report_config=rc + ) + md = generate_competitor_report_markdown_llm(brief, job.keyword) + md = merge_llm_supplement_with_rules_report(md, rules_md) + write_competitor_analysis_markdown(job.run_dir, md) + except FileNotFoundError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except ValueError as e: + return Response({"detail": str(e)}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + except requests.RequestException as e: + return Response( + {"detail": f"大模型网关错误:{e}"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + else: + try: + regenerate_competitor_report(job.run_dir, job.keyword, report_config=rc) + except FileNotFoundError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except ValueError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response(PipelineJobSerializer(job).data) + + +class JobCompetitorBriefView(APIView): + """单次任务的结构化竞品摘要(JSON,与 ``competitor_analysis.md`` 统计口径一致,规则驱动无 LLM)。""" + + def get(self, request, pk: int): + if not (settings.LOW_GI_PROJECT_ROOT or "").strip(): + return Response( + {"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + job = PipelineJob.objects.filter(pk=pk).first() + if not job: + raise Http404() + if job.status != JobStatus.SUCCESS or not (job.run_dir or "").strip(): + return Response( + {"detail": "仅可对已成功且含 run_dir 的任务获取竞品摘要"}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + data = build_competitor_brief_for_job( + job.run_dir, + job.keyword, + report_config=job.report_config + if isinstance(job.report_config, dict) + else None, + ) + except FileNotFoundError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except ValueError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response(data) + + +class JobCompetitorBriefPackView(APIView): + """ZIP:完整 Markdown 报告 + 结构化 JSON + 要点摘录 Markdown + 说明文本。""" + + def get(self, request, pk: int): + if not (settings.LOW_GI_PROJECT_ROOT or "").strip(): + return Response( + {"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + job = PipelineJob.objects.filter(pk=pk).first() + if not job: + raise Http404() + if job.status != JobStatus.SUCCESS or not (job.run_dir or "").strip(): + return Response( + {"detail": "仅可对已成功且含 run_dir 的任务导出简报包"}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + brief = build_competitor_brief_for_job( + job.run_dir, + job.keyword, + report_config=job.report_config + if isinstance(job.report_config, dict) + else None, + ) + zip_bytes = build_brief_pack_zip_bytes(Path(job.run_dir), brief) + except FileNotFoundError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except ValueError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + filename_ascii = f"job_{pk}_competitor_brief_pack.zip" + resp = HttpResponse(zip_bytes, content_type="application/zip") + resp["Content-Disposition"] = f'attachment; filename="{filename_ascii}"' + return resp + + +@method_decorator(csrf_exempt, name="dispatch") +class JobStrategyDraftView(APIView): + """ + 市场策略制定 Markdown:策略框架 + 附录;默认规则生成,可选 ``generator=llm``(``AI_crawler.chat_completion_text``)。 + """ + + def post(self, request, pk: int): + if not (settings.LOW_GI_PROJECT_ROOT or "").strip(): + return Response( + {"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + job = PipelineJob.objects.filter(pk=pk).first() + if not job: + raise Http404() + if job.status != JobStatus.SUCCESS or not (job.run_dir or "").strip(): + return Response( + {"detail": "仅可对已成功且含 run_dir 的任务生成策略制定稿"}, + status=status.HTTP_400_BAD_REQUEST, + ) + ser = StrategyDraftRequestSerializer(data=request.data or {}) + ser.is_valid(raise_exception=True) + vd = ser.validated_data + notes = (vd.get("business_notes") or "").strip() + strategy_decisions = { + "product_role": vd.get("product_role") or "", + "time_horizon": vd.get("time_horizon") or "", + "success_criteria": vd.get("success_criteria") or "", + "non_goals": vd.get("non_goals") or "", + "battlefield_one_line": vd.get("battlefield_one_line") or "", + "positioning_choice": vd.get("positioning_choice") or "", + "competitive_stance": vd.get("competitive_stance") or "", + "pillar_product": vd.get("pillar_product") or "", + "pillar_price": vd.get("pillar_price") or "", + "pillar_channel": vd.get("pillar_channel") or "", + "pillar_comm": vd.get("pillar_comm") or "", + "ack_risk_keywords": bool(vd.get("ack_risk_keywords")), + "ack_risk_price": bool(vd.get("ack_risk_price")), + "ack_risk_concentration": bool(vd.get("ack_risk_concentration")), + } + try: + brief = build_competitor_brief_for_job( + job.run_dir, + job.keyword, + report_config=job.report_config + if isinstance(job.report_config, dict) + else None, + ) + except FileNotFoundError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except ValueError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + gen_at = timezone.now().isoformat() + generator = (vd.get("generator") or "rules").strip() + try: + if generator == "llm": + md = generate_strategy_draft_markdown_llm( + job_id=job.id, + keyword=job.keyword, + brief=brief, + business_notes=notes, + generated_at_iso=gen_at, + strategy_decisions=strategy_decisions, + ) + src = "llm_text_ai_crawler_v1" + else: + md = build_strategy_draft_markdown( + job_id=job.id, + keyword=job.keyword, + brief=brief, + business_notes=notes, + generated_at_iso=gen_at, + strategy_decisions=strategy_decisions, + ) + src = "structured_summary_rules_v1" + except ValueError as e: + return Response({"detail": str(e)}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + except requests.RequestException as e: + return Response( + {"detail": f"大模型网关错误:{e}"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + return Response( + { + "schema_version": 1, + "job_id": job.id, + "keyword": job.keyword, + "generated_at": gen_at, + "source": src, + "markdown": md, + } + ) + + +@method_decorator(csrf_exempt, name="dispatch") +class JobExportDocumentView(APIView): + """ + 将 Markdown 导出为 Word(.docx)或简易 PDF。 + - GET:``kind=report``,读取 ``run_dir/competitor_analysis.md``。 + - POST:``kind=strategy``,请求体 JSON 字段 ``markdown`` 为策略稿正文(与前端 sessionStorage 一致)。 + PDF 依赖本机中文字体或环境变量 ``MA_PDF_FONT`` 指向 .ttf。 + """ + + def get(self, request, pk: int): + if not (settings.LOW_GI_PROJECT_ROOT or "").strip(): + return Response( + {"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + job = PipelineJob.objects.filter(pk=pk).first() + if not job: + raise Http404() + if not _job_run_dir_usable(job): + return Response( + {"detail": "仅可对已成功或已终止且含 run_dir 的任务导出"}, + status=status.HTTP_400_BAD_REQUEST, + ) + fmt = (request.query_params.get("fmt") or "docx").strip().lower() + kind = (request.query_params.get("kind") or "report").strip().lower() + if kind != "report": + return Response( + {"detail": "GET 仅支持 kind=report;策略稿请用 POST 提交 markdown"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if fmt not in ("docx", "pdf"): + return Response( + {"detail": "fmt 须为 docx 或 pdf"}, + status=status.HTTP_400_BAD_REQUEST, + ) + path = Path(job.run_dir) / "competitor_analysis.md" + if not path.is_file(): + return Response( + {"detail": "报告文件不存在,请先在「报告生成」重新生成"}, + status=status.HTTP_404_NOT_FOUND, + ) + md = path.read_text(encoding="utf-8") + asset_root = Path(job.run_dir).resolve() + try: + if fmt == "docx": + data = markdown_to_docx_bytes(md, asset_root=asset_root) + ct = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + fn = f"job_{pk}_competitor_report.docx" + else: + data = markdown_to_pdf_bytes(md, asset_root=asset_root) + ct = "application/pdf" + fn = f"job_{pk}_competitor_report.pdf" + except ValueError as e: + return Response({"detail": str(e)}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + resp = HttpResponse(data, content_type=ct) + resp["Content-Disposition"] = f'attachment; filename="{fn}"' + return resp + + def post(self, request, pk: int): + if not (settings.LOW_GI_PROJECT_ROOT or "").strip(): + return Response( + {"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + job = PipelineJob.objects.filter(pk=pk).first() + if not job: + raise Http404() + if not _job_run_dir_usable(job): + return Response( + {"detail": "仅可对已成功或已终止且含 run_dir 的任务导出"}, + status=status.HTTP_400_BAD_REQUEST, + ) + body = request.data if isinstance(request.data, dict) else {} + kind = (body.get("kind") or "strategy").strip().lower() + fmt = (body.get("fmt") or "docx").strip().lower() + md = (body.get("markdown") or "").strip() + if kind != "strategy": + return Response( + {"detail": "POST 仅支持 kind=strategy"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not md: + return Response( + {"detail": "markdown 不能为空"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if fmt not in ("docx", "pdf"): + return Response( + {"detail": "fmt 须为 docx 或 pdf"}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + if fmt == "docx": + data = markdown_to_docx_bytes(md) + ct = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + fn = f"job_{pk}_strategy_draft.docx" + else: + data = markdown_to_pdf_bytes(md) + ct = "application/pdf" + fn = f"job_{pk}_strategy_draft.pdf" + except ValueError as e: + return Response({"detail": str(e)}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + resp = HttpResponse(data, content_type=ct) + resp["Content-Disposition"] = f'attachment; filename="{fn}"' + return resp + + +class JobReportAssetView(APIView): + """安全读取 ``run_dir/report_assets/*`` 下的 PNG 等(供 Markdown 预览插图)。""" + + def get(self, request, pk: int): + job = PipelineJob.objects.filter(pk=pk).first() + if not job or not _job_run_dir_usable(job): + raise Http404() + rel = (request.query_params.get("path") or "").strip().replace("\\", "/") + if not rel or ".." in Path(rel).parts: + return Response( + {"detail": "path 非法"}, + status=status.HTTP_400_BAD_REQUEST, + ) + base = Path(job.run_dir).resolve() + assets_root = (base / "report_assets").resolve() + target = (base / rel).resolve() + try: + target.relative_to(assets_root) + except ValueError: + raise Http404() + if not target.is_file(): + raise Http404() + ctype, _ = mimetypes.guess_type(str(target)) + return FileResponse( + target.open("rb"), + content_type=ctype or "application/octet-stream", + ) + + +def _dataset_job(pk: int) -> PipelineJob: + job = PipelineJob.objects.filter(pk=pk).first() + if not job: + raise Http404() + return job + + +def _read_page_params(request) -> tuple[int, int]: + page_size = min(max(int(request.query_params.get("page_size", 50)), 1), 200) + page = max(int(request.query_params.get("page", 1)), 1) + return page, page_size + + +class JobDatasetSummaryView(APIView): + """任务在库中的搜索/详情/评价行数(入库后可用)。""" + + def get(self, request, pk: int): + job = _dataset_job(pk) + return Response( + { + "job_id": job.id, + "keyword": job.keyword, + "status": job.status, + "search_rows": JdJobSearchRow.objects.filter(job=job).count(), + "detail_rows": JdJobDetailRow.objects.filter(job=job).count(), + "comment_rows": JdJobCommentRow.objects.filter(job=job).count(), + "merged_rows": JdJobMergedRow.objects.filter(job=job).count(), + "search_columns": search_columns_for_api(job), + "detail_columns": detail_columns_for_api(job), + "comment_columns": comment_columns_for_api(job), + "merged_columns": merged_columns_for_api(job), + } + ) + + +class JobDatasetSearchView(APIView): + def get(self, request, pk: int): + job = _dataset_job(pk) + page, page_size = _read_page_params(request) + qs = JdJobSearchRow.objects.filter(job=job) + total = qs.count() + start = (page - 1) * page_size + rows = qs.order_by("row_index")[start : start + page_size] + return Response( + { + "total": total, + "page": page, + "page_size": page_size, + "results": [search_row_to_dict(r) for r in rows], + } + ) + + +class JobDatasetDetailView(APIView): + def get(self, request, pk: int): + job = _dataset_job(pk) + page, page_size = _read_page_params(request) + qs = JdJobDetailRow.objects.filter(job=job) + total = qs.count() + start = (page - 1) * page_size + rows = qs.order_by("row_index")[start : start + page_size] + return Response( + { + "total": total, + "page": page, + "page_size": page_size, + "results": [detail_row_to_dict(r) for r in rows], + } + ) + + +class JobDatasetCommentsView(APIView): + def get(self, request, pk: int): + job = _dataset_job(pk) + page, page_size = _read_page_params(request) + sku_id = (request.query_params.get("sku_id") or "").strip() + qs = JdJobCommentRow.objects.filter(job=job) + if sku_id: + qs = qs.filter(sku_id=sku_id) + total = qs.count() + start = (page - 1) * page_size + rows = qs.order_by("row_index")[start : start + page_size] + return Response( + { + "total": total, + "page": page, + "page_size": page_size, + "sku_filter": sku_id or None, + "results": [comment_row_to_dict(r) for r in rows], + } + ) + + +class JobDatasetMergedView(APIView): + def get(self, request, pk: int): + job = _dataset_job(pk) + page, page_size = _read_page_params(request) + qs = JdJobMergedRow.objects.filter(job=job) + total = qs.count() + start = (page - 1) * page_size + rows = qs.order_by("row_index")[start : start + page_size] + return Response( + { + "total": total, + "page": page, + "page_size": page_size, + "results": [merged_row_to_dict(r) for r in rows], + } + ) + + +class JobDatasetExportView(APIView): + """下载:kind=search|detail|comments|merged|all,export_fmt=json|csv|xlsx。 + + ``merged``:库内合并宽表行(与 lean 合并 CSV 列一致,入库后导出)。 + 注意:勿使用查询参数名 ``format``,DRF 会将其用于内容协商,非 json 时易在进视图前 404。 + """ + + def get(self, request, pk: int): + job = _dataset_job(pk) + kind = (request.query_params.get("kind") or "search").strip().lower() + fmt = (request.query_params.get("export_fmt") or "json").strip().lower() + if kind not in ("search", "detail", "comments", "all", "merged"): + return Response( + {"detail": "kind 须为 search / detail / comments / all / merged"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if fmt not in ("json", "csv", "xlsx"): + return Response( + {"detail": "export_fmt 须为 json / csv / xlsx"}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + if fmt == "json": + data, filename = build_json_bytes(job=job, kind=kind) + resp = HttpResponse(data, content_type="application/json; charset=utf-8") + elif fmt == "csv": + data, filename = build_csv_bytes(job=job, kind=kind) + resp = HttpResponse(data, content_type="text/csv; charset=utf-8") + else: + data, filename = build_xlsx_bytes(job=job, kind=kind) + resp = HttpResponse( + data, + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + except ValueError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + resp["Content-Disposition"] = f'attachment; filename="{filename}"' + return resp + + +class JdProductListView(APIView): + """已入库 SKU 分页列表;支持按标题/SKU/品牌模糊搜、按合并表中的 pipeline_keyword 精确筛。""" + + def get(self, request): + limit = min(max(int(request.query_params.get("limit", 50)), 1), 200) + offset = max(int(request.query_params.get("offset", 0)), 0) + q = (request.query_params.get("q") or "").strip() + kw = (request.query_params.get("keyword") or "").strip() + qs = JdProduct.objects.annotate(snapshot_count=Count("snapshots")) + if q: + qs = qs.filter( + Q(sku_id__icontains=q) + | Q(title__icontains=q) + | Q(detail_brand__icontains=q) + ) + if kw: + qs = qs.filter(current_payload__pipeline_keyword=kw) + total = qs.count() + page = qs.order_by("-updated_at")[offset : offset + limit] + return Response( + { + "total": total, + "limit": limit, + "offset": offset, + "results": JdProductListSerializer(page, many=True).data, + } + ) + + +class JdProductDetailView(APIView): + def get(self, request, sku_id: str): + platform = (request.query_params.get("platform") or "jd").strip() or "jd" + obj = ( + JdProduct.objects.annotate(snapshot_count=Count("snapshots")) + .filter(platform=platform, sku_id=sku_id) + .first() + ) + if not obj: + raise Http404() + return Response(JdProductDetailSerializer(obj).data) + + +class JdProductSnapshotListView(APIView): + """某 SKU 的历史快照列表(不含整包 payload,便于时间线)。""" + + def get(self, request, sku_id: str): + platform = (request.query_params.get("platform") or "jd").strip() or "jd" + product = JdProduct.objects.filter(platform=platform, sku_id=sku_id).first() + if not product: + raise Http404() + snaps = ( + product.snapshots.select_related("job") + .order_by("-captured_at") + .all() + ) + return Response( + { + "platform": platform, + "sku_id": sku_id, + "count": snaps.count(), + "results": JdProductSnapshotBriefSerializer(snaps, many=True).data, + } + ) + + +class JdProductSnapshotDetailView(APIView): + """单条快照完整 payload,用于历史回放与字段级对比。""" + + def get(self, request, pk: int): + snap = ( + JdProductSnapshot.objects.select_related("product", "job") + .filter(pk=pk) + .first() + ) + if not snap: + raise Http404() + return Response(JdProductSnapshotDetailSerializer(snap).data) + + +@method_decorator(csrf_exempt, name="dispatch") +class JobImportMergedView(APIView): + """将指定任务目录下搜索/详情/评价 CSV 与合并表重新写入数据库(幂等:先清空该任务三类行再全量插入)。""" + + def post(self, request, pk: int): + job = PipelineJob.objects.filter(pk=pk).first() + if not job: + raise Http404() + if job.status != JobStatus.SUCCESS or not (job.run_dir or "").strip(): + return Response( + {"detail": "仅可对已成功且含 run_dir 的任务执行入库"}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + stats = ingest_job_full(job) + except FileNotFoundError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response(stats) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..c295b50 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,9 @@ +django>=5.0,<6 +djangorestframework>=3.15 +django-cors-headers>=4.3 +python-dotenv>=1.0 +openpyxl>=3.1 +requests>=2.31 +python-docx>=1.1 +reportlab>=4.0 +matplotlib>=3.8 diff --git a/docs/DEPLOY_AND_GIT.md b/docs/DEPLOY_AND_GIT.md new file mode 100644 index 0000000..78533ba --- /dev/null +++ b/docs/DEPLOY_AND_GIT.md @@ -0,0 +1,101 @@ +# 部署与 Git 仓库整理 + +## 1. 环境变量:仅一份 `.env` + +| 文件 | 说明 | +|------|------| +| `market_assistant/.env` | 本地与服务器上的**唯一**配置(密钥、路径、Django、LLM) | +| `market_assistant/.env.example` | 模板,可提交仓库 | + +已移除「仓库根目录 `.env`」与「`backend/.env`」第二加载源;Django 与 `crawler_copy/jd_pc_search/AI_crawler.py` 均从 `market_assistant/.env` 读取(`AI_crawler` 在导入时先加载该文件再解析 `LOW_GI_PROJECT_ROOT`)。 + +部署到新机器: + +1. 复制 `market_assistant/.env.example` → `market_assistant/.env` +2. **可选**:设置 `LOW_GI_PROJECT_ROOT` 为单独数据盘的绝对路径;不设置时默认为**本仓库根目录**,数据写在 `./data/JD/`(启动 Django 时会自动创建该目录) +3. 设置 `DJANGO_SECRET_KEY`、`DJANGO_DEBUG=False`、生产域名下的 `DJANGO_ALLOWED_HOSTS` / `CORS_*` / `CSRF_*` +4. 若使用 LLM,填写 `OPENAI_*` 或 `LLM_*` + +## 2. 功能是否只需本目录? + +**是。** `market_assistant` 内含: + +- Django API、流水线任务、入库与导出 +- 前端 Vue 工作台 +- 京东采集脚本副本 `backend/crawler_copy/jd_pc_search`(含 Node/Playwright 子进程调用) + +**不要求**仓库外仍存在旧的 `crawler/jd_pc_search`。 + +**运行时另需**(部分不在 Git 中): + +- 默认可写目录为仓库根下 `data/JD/`(`.gitignore` 已忽略);若配置了 `LOW_GI_PROJECT_ROOT` 则数据在该路径下 +- 按任务配置放置 Cookie(路径须在有效数据根之下,如 `common/jd_cookie.txt`) +- 本机已装 Node、流水线所需的 Playwright 等(与现有一致) + +## 3. 远程 Git 只维护 `market_assistant`(推荐两种做法) + +> **先备份仓库**,再在副本上操作;改写历史后需与团队约定 **`git push --force`**。 + +### 方案 A:保留历史,把子目录提成仓库根(git filter-repo) + +适用于「当前仓库在上一级 `Low GI/`,只想提交 `market_assistant/` 里的内容且路径变为仓库根」。 + +1. 安装 [git-filter-repo](https://github.com/newren/git-filter-repo)(需单独安装,不是 Git 自带)。 +2. 在**原仓库克隆的副本**中执行: + +```bash +cd /path/to/Low-GI-repo-copy +git filter-repo --path market_assistant/ --path-rename market_assistant/: +``` + +3. 此时仓库根目录即为原 `market_assistant` 下的 `backend/`、`frontend/`、`docs/` 等。 +4. 将 `origin` 改为新远程或清空原远程后强制推送: + +```bash +git remote add origin <你的新仓库 URL> +git branch -M main +git push -u origin main --force +``` + +5. 旧远程若废弃,在 Git 平台将旧库归档或删除,避免误用。 + +### 方案 B:新仓库,不保留旧历史 + +适用于「从零起一个干净远程,只装当前代码」。 + +```bash +cd market_assistant +git init +git add . +git commit -m "chore: initial standalone market_assistant" +git remote add origin <新仓库 URL> +git branch -M main +git push -u origin main +``` + +之后本地开发只在 `market_assistant` 目录内 `git pull` / `git push`。 + +### 拆库后目录约定 + +- 克隆下来的**仓库根** = 现在的 `market_assistant`(含 `backend/`、`frontend/`、`docs/`)。 +- 文档中的路径仍写 `market_assistant/.env` 时,在「已拆库」情形下指**仓库根目录下的** `.env`(即 `.env` 在 clone 下来的根上)。 + +### 已从跟踪中移除误提交文件(旧 monorepo 根目录上执行) + +若仍暂时保留大仓库,可在**原根目录**执行: + +```bash +git rm -r --cached venv/ 2>/dev/null || true +git rm -r --cached data/ 2>/dev/null || true +git rm -r --cached .idea/ 2>/dev/null || true +git rm --cached .env 2>/dev/null || true +``` + +## 4. 生产构建(简要) + +- **后端**:`pip install -r backend/requirements.txt`,`migrate`,用 gunicorn/uwsgi 等托管 WSGI,前面 Nginx 反代。 +- **前端**:`cd frontend && npm ci && npm run build`,将 `dist/` 由 Nginx 托管静态资源,并把 `/api` 反代到 Django;同时把生产环境的 CORS/CSRF 与 `vite.config.js` 开发代理区分配置(生产一般同源或显式写 API 域名)。 + +## 5. 与 `LOW_GI_PROJECT_ROOT` 的关系 + +流水线 CSV、跑批目录默认写在 `LOW_GI_PROJECT_ROOT/data/JD/...`。该路径**可以**在服务器上位于 Web 代码库之外(例如单独数据盘),只要在 `.env` 中指向正确绝对路径即可。 diff --git a/docs/Market-Assistant-项目进展与里程碑.md b/docs/Market-Assistant-项目进展与里程碑.md new file mode 100644 index 0000000..7da734c --- /dev/null +++ b/docs/Market-Assistant-项目进展与里程碑.md @@ -0,0 +1,172 @@ +# Market-Assistant(前台事业部 AI 增强启动器)— 项目进展与里程碑 + +> 文档版本:2026-04-09(末次修订:**阶段 2.1~2.2 MVP** 市场策略制定已接入工作台;阶段 1.4 仍待业务侧脱敏截图/录屏) +> 目标:市场策略、竞品分析、文档体系、营销内容一键生成;手工作业投入降低 **≥50%**(需先定义基线与度量方式)。 + +--- + +## 一、总体目标与成功标准 + +| 维度 | 说明 | +|------|------| +| **产品范围** | 面向前台事业部的统一入口:采集/分析 → 策略与文档 → 营销素材,尽量少切换工具。 | +| **效率目标** | 在约定场景下(如:单次品类竞品简报、周报型材料),端到端耗时与人工步骤较基线下降 **≥50%**。 | +| **质量目标** | 输出可追溯(数据来源、生成时间、模型/规则版本);关键结论可人工复核。 | +| **工程目标** | 每个里程碑结束:**代码提交并推送到远程仓库**(见文末 Git 检查清单)。 | + +--- + +## 二、现状盘点(已有什么) + +以下基于 **Market-Assistant** 子项目当前能力整理。 + +| 模块 | 已有能力 | 备注 / 缺口 | +|------|-----------|-------------| +| **后端** | Web 框架 + 任务创建、详情、下载、在线预览;后台线程执行任务 | 需明确生产部署、队列/进程模型是否从 Thread 演进 | +| **配置** | 环境变量;数据根目录指向低 GI 项目数据区 | 文档化部署步骤与必填项 | +| **京东链路** | 搜索/商详/评论等采集脚本及可选 AI 辅助 | 与「策略/文档/营销」产品化能力可继续加深 | +| **前端** | 京东工作台:**搜索采集**、**任务列表**、**库内数据浏览**、**报告生成**、**报告查看**、**市场策略制定**;报告在线预览;报告统计项表单化 | 淘宝/天猫为占位页;缺少「一键 Word/PPT」与「营销内容」独立工作流 | +| **数据落盘** | 任务产物含合并表、搜索导出、评价、商详、报告等 | lean 合并表与商详导出与库表对齐;全量归档规范仍可加强 | + +**京东 / 流水线数据层(2026-04-09 前后已落地)** + +- **合并宽表(lean)**:搜索全列 + 固定商详子集(品牌、到手价、店铺、类目路径、参数、配料)+ 评论摘要;已去掉入库与导出不需要的 HTTP 状态类字段。 +- **商详导出(lean)**:与合并表商详子集一致;支持离线重写商详表再入库。 +- **库表迁移**:已做合并表与商详行相关迁移;对旧库尽量可安全执行。 +- **采集解析**:PC 搜索卖点等兜底;竞品报告类目读中文类目列与路径。 + +**结论**:**数据采集与部分分析骨架已在**,数据层在京东单次任务链路上 **schema 与库表已收敛一层**;下一步重点是 **产品化工作流**、**模板与一键生成**、**基线度量** 与 **多平台/多场景扩展**。 + +**进展摘要(2026-04-09 前后已落地,竞品分析子闭环)** + +| 方向 | 内容 | +|------|------| +| **任务模型** | 任务上可配置报告调参(关注词、场景组、外部市场表);创建时可带入,成功后也可单独修改再重生成。 | +| **报告与摘要** | 按任务配置与内置默认合并后生成在线分析报告与结构化摘要;重新生成报告与拉取摘要均读当前任务配置。 | +| **前端信息架构** | 菜单拆分:**报告生成**与**报告查看**;查看页 **一键下载简报包**(ZIP)。 | +| **可用性** | 搜索采集页配置采集参数,与报告统计解耦;报告侧为中文表单 + 可选高级项;外部市场表列宽已约束。 | +| **工程** | 含报告调参相关迁移与自动化测试(关注词等烟测)。 | +| **演示与基线(文档)** | 演示走查、脱敏说明、手工基线表模板;工程说明含线程执行任务与生产演进提示。 | +| **市场策略制定(阶段 2 MVP)** | 中文策略输出模板;工作台一键生成策略框架稿 + 附录速览(可选业务备注);文档体系轻量说明 | Word 导出、LLM 润色、完整向导式多步表单为后续项 | + +--- + +## 三、差距与下一步(要做什么) + +按目标拆成四条主线,可并行但建议优先级如下。 + +1. **竞品分析闭环**:从「能跑任务」到「固定输入 → 固定输出(简报/PPT 大纲/对比表)」+ 引用数据源。 +2. **市场策略**:在竞品与内部假设输入之上,结构化输出(定位、人群、渠道、节奏)并可导出 Word 等文稿。 +3. **文档体系**:目录模板、命名规范、版本记录;与仓库或对象存储对齐(可选)。 +4. **营销内容一键生成**:基于同一事实层生成多体裁(标题包、详情页卖点、短视频脚本提纲等),并支持人工编辑再导出。 + +**阶段 1 当前建议的「下一步」(优先序)** + +1. **对齐阶段 0**:若尚未完成,补 **0.1~0.3**(标准场景、手工基线、远程仓库首次 push)。 +2. **阶段 1.4 收口(业务侧)**:按演示走查打勾并附 **2~3 张脱敏截图** 或 **短录屏**(仓库可不存大 ZIP;脱敏见同目录说明)。 +3. **阶段 2 持续推进**:模板与规则草稿已可用;可补 Word 导出、变更日志、向导增强(见下表阶段 2)。 +4. **可选增强**:简报包内 **打印友好 HTML** 或 **Word 导出**。 +5. **工程债**:线程模型已记在工程说明中;生产部署再扩写部署文档。 + +--- + +## 四、阶段规划、小任务与时间节点 + +> 起始参考日:**2026-04-09**。若启动日晚于该日,整体顺延;**每个阶段结束日 = 必须完成「提交 + 推送」的截止日**。 + +### 阶段 0:启动与基线(约 1 周) + +| 序号 | 任务 | 产出 | 建议完成日 | +|------|------|------|------------| +| 0.1 | 与业务方确认 2~3 个**标准场景**(如:低 GI 品类京东竞品周报) | 场景说明文档(可放本目录或 Confluence) | **2026-04-11** | +| 0.2 | 为每个场景记录**当前手工步骤**与平均耗时 | 基线表(步骤数、分钟数、责任人) | **2026-04-14** | +| 0.3 | 远程仓库初始化:忽略规则、分支策略、最小运行说明 | 可克隆可运行说明 + **首次 push** | **2026-04-16** | + +**阶段 0 里程碑 Git**:合并「基线说明 + README」等到主分支并 **push**。 + +--- + +### 阶段 1:竞品分析 MVP(约 2~3 周) + +| 序号 | 任务 | 产出 | 建议完成日 | +|------|------|------|------------| +| 1.1 | 统一单次任务的**输出规范**(字段、报告结构、失败重试) | 流水线输出说明 + OpenAPI 子集 + 示例 JSON(**已落盘**;含报告调参、更新配置、默认模板等) | **2026-04-23** | +| 1.2 | 后端:从任务结果生成**结构化竞品摘要**(规则 + 可选 LLM) | 可查询的结构化摘要能力(**规则版已可用**;LLM 润色为后续项) | **2026-04-30** | +| 1.3 | 前端:竞品页一键「生成简报」+ 在线阅读/下载 | UI 联调 | **2026-05-07**(**已交付**:报告查看页「一键下载简报包」ZIP) | +| 1.4 | 联调与演示数据集(脱敏) | 演示录屏或截图 + 样例数据 | **2026-05-09**(**文档已备**;**待业务补**:脱敏截图/录屏;大样本勿提交 Git) | + +**阶段 1 里程碑 Git**:打 tag 可选,如 `v0.1-competitor-mvp`,并 **push**。 + +--- + +### 阶段 2:市场策略 + 文档体系(约 2~3 周) + +| 序号 | 任务 | 产出 | 建议完成日 | +|------|------|------|------------| +| 2.1 | 定义「市场策略」**输出模板**(章节、必填项、可选 AI 补全) | 中文策略模板(**已落盘**;Word 版可选后续) | **2026-05-16** | +| 2.2 | 后端:策略生成能力(输入:结构化摘要 + 业务约束) | 策略制定接口(**已落盘**,规则无 LLM) | **2026-05-23** | +| 2.3 | 文档体系:文件夹模板、命名规则、变更日志或版本页 | 文档体系轻量说明(**已落盘**);变更日志待团队约定 | **2026-05-28** | +| 2.4 | 前端:「市场策略制定」向导式表单 + 预览导出 | 工作台任务选择 + 备注 + 预览/下载(**已落盘**);多步向导可后续增强 | **2026-05-30** | + +**阶段 2 里程碑 Git**:**push**;建议特性分支合并后主分支可演示。 + +--- + +### 阶段 3:营销内容一键生成(约 2~3 周) + +| 序号 | 任务 | 产出 | 建议完成日 | +|------|------|------|------------| +| 3.1 | 定义营销**体裁清单**(标题、卖点、FAQ、短视频提纲等)与字数约束 | 提示词/规则配置 | **2026-06-06** | +| 3.2 | 后端:批量生成接口(同一事实层 → 多体裁) | API + 限流/缓存策略 | **2026-06-13** | +| 3.3 | 前端:一键生成 + 分 tab 展示 + 复制/导出 | UI | **2026-06-18** | +| 3.4 | 与阶段 0 基线对比,粗测耗时下降比例 | 内部复盘表 | **2026-06-20** | + +**阶段 3 里程碑 Git**:**push**;若达到 ≥50% 目标则记录证据(前后对比表)。 + +--- + +### 阶段 4:硬化与推广(约 1~2 周) + +| 序号 | 任务 | 产出 | 建议完成日 | +|------|------|------|------------| +| 4.1 | 权限、密钥、日志与审计(按公司规范) | 配置说明 | **2026-06-25** | +| 4.2 | 部署文档(Docker/主机二选一) | README 或单独部署说明 | **2026-06-27** | +| 4.3 | 前台事业部试用反馈一轮 | 问题清单 + 下一迭代 backlog | **2026-06-30** | + +**阶段 4 里程碑 Git**:**push**;可选 `v1.0-internal-release`。 + +--- + +## 五、每个节点:提交与推送仓库(必做) + +建议在每次「任务行」完成或「阶段结束日」执行: + +1. 确认工作区仅包含预期变更。 +2. 提交并写清说明(团队统一前缀更好)。 +3. 推送到远程约定分支。 +4. 若使用 PR:合并后再 **push** 更新后的主分支。 +5. 重要里程碑可在远程打 **tag** 便于回溯。 + +**原则**:不以「本地只有」为完成标准;**远程仓库可见**才算里程碑闭合。 + +--- + +## 六、风险与依赖 + +| 风险 | 应对 | +|------|------| +| 爬虫稳定性 / 合规 | 明确使用范围;失败降级为「人工上传 CSV」 | +| LLM 成本与质量波动 | 规则优先 + LLM 增强;版本化 prompt;抽样质检 | +| 「50%」无基线 | 阶段 0 必须完成基线表,否则目标不可验收 | +| 多人协作冲突 | 按阶段切分支;文档与接口先行 | + +--- + +## 七、文档维护 + +- 每次阶段结束后更新本文件「二、现状盘点」与「四、阶段规划」中的**实际完成日**与**偏差说明**;数据层/爬虫有结构变更时在「京东 / 流水线数据层」小节补一句即可。 +- 负责人、会议纪要与更细任务可链接到内部 wiki,本文件保持**一页纸可读完**。 + +--- + +*项目进展与里程碑(Market-Assistant)* diff --git a/docs/demo/README.md b/docs/demo/README.md new file mode 100644 index 0000000..f71d44e --- /dev/null +++ b/docs/demo/README.md @@ -0,0 +1,9 @@ +# 演示与基线(阶段 0 / 1.4) + +| 材料 | 用途 | +|------|------| +| 竞品分析演示走查 | 按菜单逐步验收采集、报告、简报包 | +| 脱敏与样例说明 | 对外分享前须处理的敏感项与仓库边界 | +| 手工基线表模板 | 阶段 0.2:记录当前手工耗时,便于算「50%」 | + +工程约束(线程执行任务)见同目录上一级的 **工程说明**。 diff --git a/docs/demo/手工基线表模板.md b/docs/demo/手工基线表模板.md new file mode 100644 index 0000000..163a436 --- /dev/null +++ b/docs/demo/手工基线表模板.md @@ -0,0 +1,32 @@ +# 手工基线表模板(阶段 0.2) + +> 与业务确认「标准场景」后,为**每个场景**填一张,用于后续验收「效率提升 ≥50%」。 +> 仅模板,**不含真实数据**;可打印或复制到 Excel。 + +## 场景名称 + +(例如:低 GI 品类 · 京东竞品周报) + +## 当前手工流程 + +| 序号 | 步骤简述 | 责任人角色 | 单次耗时(分钟) | 工具 | +|------|----------|------------|------------------|------| +| 1 | | | | | +| 2 | | | | | +| 3 | | | | | +| … | | | | | + +**合计单次总耗时(分钟)**:_____ +**每月大致次数**:_____ + +## 痛点(可选) + +- + +## 备注 + +- 与 Market-Assistant 自动化流程对照时,用「步骤数、总分钟数」做前后对比。 + +--- + +*手工基线表模板* diff --git a/docs/demo/竞品分析演示走查.md b/docs/demo/竞品分析演示走查.md new file mode 100644 index 0000000..fccb884 --- /dev/null +++ b/docs/demo/竞品分析演示走查.md @@ -0,0 +1,38 @@ +# 竞品分析能力 — 演示走查(内部) + +> 用途:业务或新同事**不依赖录屏**也能按步骤验收「采集 → 报告 → 简报包」。 +> 数据须**脱敏**:勿在共享环境使用真实 Cookie、真实手机号/地址;关键词可用「测试词」类替代。 + +## 前置 + +- 已在环境变量中配置**数据根目录**,且本机可写入京东数据区。 +- 后端已迁移并启动开发服务。 +- 前端开发服务或构建页可打开 Market-Assistant 京东工作台。 + +## 走查步骤(建议顺序) + +| 步骤 | 菜单 / 页面 | 操作 | 预期 | +|------|-------------|------|------| +| 1 | **搜索采集** | 填搜索词;按需配置登录与请求节奏(测试用 Cookie) | 提交成功,可进入任务列表 | +| 2 | **任务列表** | 刷新,观察状态直至成功或失败(失败则查看错误说明) | 成功后可见本批输出目录 | +| 3 | **报告查看** | 选该任务 → **重新加载报告** | 出现报告正文预览(若无文件则按提示去报告生成) | +| 4 | **报告生成**(若 3 无文件) | 同一任务 → **重新生成报告** | 返回报告查看再加载,应有预览 | +| 5 | **报告查看** | **一键下载简报包** | 浏览器得到 ZIP 压缩包 | +| 6 | 解压 ZIP | 打开说明、要点摘录、分析报告 | 内容完整、中文可读;结构化摘要可被 JSON 工具打开 | +| 7 | **库内数据浏览**(可选) | 选同一任务,浏览合并表 / 搜索 / 评价等 | 行数与列说明与任务一致 | +| 8 | **报告生成**(可选) | 改「关注词 / 场景」→ 保存 → 再 **重新生成报告** → 报告查看对比 | 统计与报告正文随配置变化(规则一致) | + +## 失败时快速对照 + +- **服务不可用**:未配置数据根目录或采集副本路径不可用。 +- **报告不存在**:分析报告文件尚未生成 → 到 **报告生成** 点「重新生成报告」。 +- **简报包失败**:同上,须先生成分析报告。 + +## 演示交付物建议(阶段 1.4) + +- 本走查表打勾截图 **2~3 张**(任务成功、报告预览、ZIP 目录列表即可)。 +- 若对外分享:仅使用**脱敏**批次或打码后的截图,**不要**提交含 Cookie 的仓库文件。 + +--- + +*竞品分析演示走查* diff --git a/docs/demo/脱敏与样例说明.md b/docs/demo/脱敏与样例说明.md new file mode 100644 index 0000000..b0eca02 --- /dev/null +++ b/docs/demo/脱敏与样例说明.md @@ -0,0 +1,25 @@ +# 脱敏与对外样例说明 + +## 哪些数据敏感 + +| 类型 | 说明 | 处理建议 | +|------|------|----------| +| **Cookie / Token** | 等同登录凭证 | 永不入库到 Git;演示用临时 Cookie 或本地专用账号 | +| **用户地址、电话、真实姓名** | 可能出现在评价正文 | 对外导出前抽样删改或不用含 PII 的批次 | +| **内部绝对路径** | 批次目录可能含本机用户名 | 对外截图或文档中可打码或改为「…」示意 | +| **未公开商业数据** | 若关键词、品类属保密 | 演示关键词改为「测试品类 A」等 | + +## 「样例数据」在仓库中的边界 + +- **推荐**:仓库内仅保留 **小规模、无隐私的结构化示例** 与 **字段说明**,不提交完整跑批结果目录。 +- **若必须附小样本 CSV**:行数 ≤ 20,且对 `tagCommentContent`、`comment_preview` 等列做**虚构替换**或清空。 +- **简报包 ZIP**:默认不提交;走查时各人本地生成即可。 + +## 对外演示话术(可选) + +- 「数据来自京东 PC 公开列表与商详,统计规则见报告第一章方法说明。」 +- 「要点摘录与 JSON 为规则引擎生成,定稿需业务复核。」 + +--- + +*脱敏与样例说明* diff --git a/docs/doc-system/README.md b/docs/doc-system/README.md new file mode 100644 index 0000000..f6731ee --- /dev/null +++ b/docs/doc-system/README.md @@ -0,0 +1,34 @@ +# 文档体系(轻量说明) + +> 面向协作:哪些材料放哪、命名习惯、与里程碑如何对齐。**不**替代研发内部接口契约(后者单独维护)。 + +## 推荐阅读顺序 + +| 文档 | 用途 | +|------|------| +| 项目进展与里程碑 | 一页纸:目标、阶段、优先级 | +| 流水线单次任务输出说明 | 任务产物、状态、接口能力(偏研发) | +| OpenAPI 子集 | 可导入 API 调试工具 | +| 示例数据 | JSON 形状参考 | +| 演示与脱敏 | 走查步骤、基线模板、样例约束 | +| 策略类模板 | 人工撰写或对照自动草稿章节 | +| 工程说明 | 实现上的约束(如任务执行方式) | + +## 命名与存放 + +- 说明类:文本文档,中文文件名可接受。 +- 大体积数据、含隐私的跑批结果:**勿提交** Git;用内部网盘或对象存储,并在说明里写清获取方式。 +- 对外演示:只用脱敏截图/录屏 + 小规模样例。 + +## 版本与变更 + +- 产品级大变更:在里程碑文档中记一句;若团队使用变更日志,可在仓库根后续追加(当前未强制)。 + +## 常见产出形态 + +- **市场策略制定稿**:策略框架与待填命题;下载或复制后由业务放入 Confluence / Word 再定稿。 +- **竞品简报包**:ZIP,内含报告、结构化摘要、要点摘录等,详见流水线输出说明。 + +--- + +*文档体系说明(Market-Assistant)* diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md new file mode 100644 index 0000000..08f66f7 --- /dev/null +++ b/docs/engineering-notes.md @@ -0,0 +1,16 @@ +# 工程说明(单机演示 vs 生产) + +## 任务执行模型 + +- **当前实现**:创建京东流水线任务后,后端在 **后台线程** 中执行采集与报告步骤。 +- **含义**:适合**本机或单进程演示**;进程重启会中断未完成任务。 +- **生产演进建议**:改为 **Celery / RQ / Django-Q** 等任务队列 + 独立 worker 进程;并配置 **超时、重试、并发上限** 与日志采集。 + +## 与里程碑文档的关系 + +- 阶段 1「竞品分析 MVP」在单机环境下可完整演示。 +- 对外部试点或 SLA 承诺前,应补充 **部署文档**(进程守护、环境变量、静态资源)与 **队列化** 方案。 + +--- + +*工程说明(Market-Assistant)* diff --git a/docs/examples/competitor_brief_schema_v1.json b/docs/examples/competitor_brief_schema_v1.json new file mode 100644 index 0000000..dfe6303 --- /dev/null +++ b/docs/examples/competitor_brief_schema_v1.json @@ -0,0 +1,108 @@ +{ + "schema_version": 1, + "keyword": "低GI", + "batch_label": "20260409 154041", + "run_dir": "<本机批次输出目录,示例略>", + "scope": { + "merged_sku_count": 5, + "comment_flat_rows": 112, + "structure_source_rows": 117, + "uses_pc_search_list_export": true + }, + "meta": { + "page_start": 1, + "page_to": 2, + "merged_csv_mode": "lean" + }, + "pc_search_raw": { + "result_count_consensus": 12345, + "list_keyword": "低GI", + "result_count_uniques": [12340, 12345], + "raw_json_files_scanned": 2 + }, + "list_visibility_proxy": { + "total_rows": 117, + "unique_skus": 95 + }, + "concentration": { + "shops_from_list": { + "cr1": 0.12, + "cr3": 0.35, + "top_label": "某旗舰店", + "top_share_pct": "12.0%" + }, + "list_brand_field": null, + "detail_brand_among_merged": { + "cr1": 0.2, + "cr3": 0.6, + "top_label": "品牌A", + "top_share_pct": "20.0%" + } + }, + "category_mix_top": [{ "label": "休闲食品", "count": 40 }], + "price_stats": { + "min": 14.9, + "max": 39.9, + "mean": 21.5, + "n": 117, + "median": 16.9 + }, + "price_stats_source": "pc_search_export_all_rows", + "price_stats_merged_sample": { + "min": 14.9, + "max": 34.9, + "mean": 22.1, + "n": 5, + "median": 16.9 + }, + "price_stats_list_export": { + "min": 14.9, + "max": 39.9, + "mean": 21.5, + "n": 117, + "median": 16.9 + }, + "comment_focus_keywords": [{ "word": "口感", "count": 48 }], + "usage_scenarios": [ + { "scenario": "控糖/血糖相关", "count": 12, "share_of_text_units": 0.15 } + ], + "strategy_hints": ["样本内…(待验证)"], + "matrix_by_group": [ + { + "group": "饼干", + "sku_count": 3, + "skus": [ + { + "sku_id": "100065199809", + "title": "示例标题", + "brand": "示例品牌", + "list_price_show": "16.90", + "coupon_or_detail_price": "", + "detail_price_final": "16.41", + "shop": "示例店", + "category": "休闲食品 > 饼干 > 粗粮饼干", + "selling_point": "", + "comment_fuzzy": "20万+" + } + ] + } + ], + "consumer_feedback_by_matrix_group": [ + { + "group": "饼干", + "comment_rows": 40, + "focus_keyword_hits": [{ "word": "口感", "count": 10 }], + "scenarios_top": [ + { + "scenario": "早餐/代餐", + "count": 5, + "share_of_text_units": 0.2 + } + ] + } + ], + "notes": [ + "与在线分析报告统计口径一致;主题词与场景为预设词表,非 NLP 主题模型。", + "价格来自展示字段抽取,含促销与规格差异。" + ] +} diff --git a/docs/examples/dataset_summary.json b/docs/examples/dataset_summary.json new file mode 100644 index 0000000..40b0bad --- /dev/null +++ b/docs/examples/dataset_summary.json @@ -0,0 +1,31 @@ +{ + "job_id": 5, + "keyword": "低GI", + "status": "success", + "search_rows": 117, + "detail_rows": 5, + "comment_rows": 112, + "merged_rows": 5, + "search_columns": [ + { "key": "sku_id", "label": "SKU(skuId)" }, + { "key": "title", "label": "标题(wareName)" } + ], + "detail_columns": [ + { "key": "sku_id", "label": "skuId" }, + { "key": "detail_brand", "label": "detail_brand" }, + { "key": "detail_price_final", "label": "detail_price_final" }, + { "key": "detail_shop_name", "label": "detail_shop_name" }, + { "key": "detail_category_path", "label": "detail_category_path" }, + { "key": "detail_product_attributes", "label": "detail_product_attributes" }, + { "key": "detail_body_ingredients", "label": "detail_body_ingredients" } + ], + "comment_columns": [ + { "key": "sku_id", "label": "sku" }, + { "key": "tag_comment_content", "label": "tagCommentContent" } + ], + "merged_columns": [ + { "key": "pipeline_keyword", "label": "pipeline_keyword" }, + { "key": "sku_id", "label": "SKU(skuId)" }, + { "key": "detail_brand", "label": "detail_brand" } + ] +} diff --git a/docs/examples/pipeline_job_success.json b/docs/examples/pipeline_job_success.json new file mode 100644 index 0000000..614ed62 --- /dev/null +++ b/docs/examples/pipeline_job_success.json @@ -0,0 +1,27 @@ +{ + "id": 5, + "platform": "jd", + "keyword": "低GI", + "max_skus": 5, + "page_start": 1, + "page_to": 2, + "pipeline_run_dir": "", + "cookie_file_path": "", + "inline_cookie_used": false, + "pvid": "", + "request_delay": "30-60", + "list_pages": "1-2", + "scenario_filter_enabled": true, + "status": "success", + "run_dir": "<本机批次输出目录,示例略>", + "error_message": "", + "analysis_artifacts": { + "merged": true, + "pc_search": true, + "comments": true, + "detail_ware": true, + "report": true + }, + "created_at": "2026-04-09T12:00:00+08:00", + "updated_at": "2026-04-09T12:45:00+08:00" +} diff --git a/docs/openapi/pipeline-jobs.openapi.yaml b/docs/openapi/pipeline-jobs.openapi.yaml new file mode 100644 index 0000000..b0846d0 --- /dev/null +++ b/docs/openapi/pipeline-jobs.openapi.yaml @@ -0,0 +1,587 @@ +openapi: 3.0.3 +info: + title: Market-Assistant — Pipeline & JD 数据集 API(子集) + description: | + 与 `docs/pipeline-job-output-spec.md` 一致的核心路径;完整行为以 `pipeline/views.py` 为准。 + version: "1.0.0" + +servers: + - url: http://127.0.0.1:8000 + description: 本地 Django 默认端口 + +tags: + - name: jobs + description: 京东流水线任务 + - name: dataset + description: 任务入库后的分页与导出 + - name: jd + description: 全局 SKU 主档与快照 + +paths: + /api/jobs/: + get: + tags: [jobs] + summary: 任务列表(最近 200 条) + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/PipelineJob" + post: + tags: [jobs] + summary: 创建任务并异步执行 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreatePipelineJob" + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: "#/components/schemas/PipelineJob" + "503": + description: LOW_GI_PROJECT_ROOT 未配置 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorDetail" + + /api/jobs/{id}/: + get: + tags: [jobs] + summary: 任务详情 + parameters: + - $ref: "#/components/parameters/JobId" + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PipelineJob" + "404": + description: Not found + patch: + tags: [jobs] + summary: 仅更新 report_config(报告关注词/场景/外部市场表等) + parameters: + - $ref: "#/components/parameters/JobId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PatchReportConfig" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/PipelineJob" + "400": + description: 校验失败(未知键或体积过大等) + "404": + description: Not found + + /api/jobs/{id}/cancel/: + post: + tags: [jobs] + summary: 请求终止待执行/执行中的采集任务(协作式,在可停点结束) + parameters: + - $ref: "#/components/parameters/JobId" + responses: + "200": + description: OK(cancellation_requested=true,随后状态变为 cancelled) + content: + application/json: + schema: + $ref: "#/components/schemas/PipelineJob" + "400": + description: 任务已结束,不可再终止 + "404": + description: Not found + + /api/report-config-defaults/: + get: + tags: [jobs] + summary: 报告调参默认模板(与 jd_competitor_report 脚本常量一致) + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ReportConfig" + "503": + description: 爬虫副本路径不可用等 + + /api/jobs/{id}/download/: + get: + tags: [jobs] + summary: 下载 run_dir 内产物 + parameters: + - $ref: "#/components/parameters/JobId" + - name: name + in: query + required: true + schema: + type: string + enum: [merged, pc_search, comments, detail_ware, report] + responses: + "200": + description: 文件流 + "404": + description: 任务未成功或文件不存在 + + /api/jobs/{id}/dataset/summary/: + get: + tags: [dataset] + summary: 库内行数与列说明 + parameters: + - $ref: "#/components/parameters/JobId" + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DatasetSummary" + + /api/jobs/{id}/dataset/merged/: + get: + tags: [dataset] + summary: 合并宽表分页 + parameters: + - $ref: "#/components/parameters/JobId" + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PageSize" + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PaginatedMergedRows" + + /api/jobs/{id}/export/: + get: + tags: [dataset] + summary: 导出 JSON / CSV / xlsx + parameters: + - $ref: "#/components/parameters/JobId" + - name: kind + in: query + schema: + type: string + enum: [search, detail, comments, merged, all] + - name: export_fmt + in: query + schema: + type: string + enum: [json, csv, xlsx] + responses: + "200": + description: 文件下载 + + /api/jobs/{id}/regenerate-report/: + post: + tags: [jobs] + summary: 根据已有 CSV 重写 competitor_analysis.md(不重新爬取);可选大模型生成(AI_crawler 同网关) + parameters: + - $ref: "#/components/parameters/JobId" + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/RegenerateReportRequest" + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PipelineJob" + "400": + description: 任务状态或路径无效 + "502": + description: 大模型网关 HTTP 错误 + "503": + description: 未配置 OPENAI_* / LLM_* 等(generator=llm 时) + + /api/jobs/{id}/competitor-brief/: + get: + tags: [jobs] + summary: 结构化竞品摘要 JSON(与 Markdown 报告统计口径一致) + parameters: + - $ref: "#/components/parameters/JobId" + responses: + "200": + description: schema_version=1 的规则摘要 + content: + application/json: + schema: + type: object + additionalProperties: true + "400": + description: 任务未成功或缺少 run_dir / 合并表 + + /api/jobs/{id}/competitor-brief-pack/: + get: + tags: [jobs] + summary: 一键简报包(ZIP:报告 md + 摘要 json + 要点摘录 md + 说明) + parameters: + - $ref: "#/components/parameters/JobId" + responses: + "200": + description: application/zip 附件 + content: + application/zip: + schema: + type: string + format: binary + "400": + description: 缺少 competitor_analysis.md 等 + "503": + description: LOW_GI_PROJECT_ROOT 未配置 + + /api/jobs/{id}/strategy-draft/: + post: + tags: [jobs] + summary: 市场策略 Markdown 草稿(规则或可选大模型润色,网关同 AI_crawler) + parameters: + - $ref: "#/components/parameters/JobId" + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/StrategyDraftRequest" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/StrategyDraftResponse" + "400": + description: 任务未成功、缺少 run_dir,或 brief 构建失败 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorDetail" + "404": + description: Not found + "502": + description: 大模型网关 HTTP 错误 + "503": + description: LOW_GI_PROJECT_ROOT 未配置,或大模型凭证未配置(generator=llm) + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorDetail" + + /api/jobs/{id}/ingest-merged/: + post: + tags: [dataset] + summary: 从 run_dir 将 CSV 全量写入库(幂等:先清空该任务四类行) + parameters: + - $ref: "#/components/parameters/JobId" + responses: + "200": + description: 统计信息(dataset + merged 块) + + /api/jd/products/: + get: + tags: [jd] + summary: SKU 主档列表 + responses: + "200": + description: OK + +components: + parameters: + JobId: + name: id + in: path + required: true + schema: + type: integer + Page: + name: page + in: query + schema: + type: integer + default: 1 + PageSize: + name: page_size + in: query + schema: + type: integer + default: 20 + + schemas: + ErrorDetail: + type: object + properties: + detail: + type: string + + CreatePipelineJob: + type: object + required: [platform, keyword] + properties: + platform: + type: string + example: jd + keyword: + type: string + max_skus: + type: integer + page_start: + type: integer + page_to: + type: integer + pipeline_run_dir: + type: string + cookie_file_path: + type: string + cookie_text: + type: string + pvid: + type: string + request_delay: + type: string + list_pages: + type: string + scenario_filter_enabled: + type: boolean + report_config: + $ref: "#/components/schemas/ReportConfig" + + ReportConfig: + type: object + description: | + 允许键仅限下列三项;可全部省略或 `{}` 表示使用脚本内置默认。 + properties: + comment_focus_words: + type: array + items: + type: string + comment_scenario_groups: + type: array + items: + type: object + properties: + label: + type: string + triggers: + type: array + items: + type: string + external_market_table_rows: + type: array + items: + type: object + properties: + indicator: + type: string + value_and_scope: + type: string + source: + type: string + year: + type: string + + PatchReportConfig: + type: object + required: [report_config] + properties: + report_config: + $ref: "#/components/schemas/ReportConfig" + + PipelineJob: + type: object + properties: + id: + type: integer + platform: + type: string + keyword: + type: string + status: + type: string + enum: [pending, running, success, failed, cancelled] + cancellation_requested: + type: boolean + run_dir: + type: string + error_message: + type: string + analysis_artifacts: + type: object + additionalProperties: + type: boolean + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + report_config: + $ref: "#/components/schemas/ReportConfig" + + ColumnMeta: + type: object + properties: + key: + type: string + label: + type: string + + DatasetSummary: + type: object + properties: + job_id: + type: integer + keyword: + type: string + status: + type: string + search_rows: + type: integer + detail_rows: + type: integer + comment_rows: + type: integer + merged_rows: + type: integer + search_columns: + type: array + items: + $ref: "#/components/schemas/ColumnMeta" + detail_columns: + type: array + items: + $ref: "#/components/schemas/ColumnMeta" + comment_columns: + type: array + items: + $ref: "#/components/schemas/ColumnMeta" + merged_columns: + type: array + items: + $ref: "#/components/schemas/ColumnMeta" + + PaginatedMergedRows: + type: object + properties: + total: + type: integer + page: + type: integer + page_size: + type: integer + results: + type: array + items: + type: object + additionalProperties: true + + RegenerateReportRequest: + type: object + properties: + generator: + type: string + enum: [rules, llm] + default: rules + description: rules=jd_competitor_report 规则引擎;llm=结构化摘要 JSON + AI_crawler 文本接口 + + StrategyDraftRequest: + type: object + properties: + generator: + type: string + enum: [rules, llm] + default: rules + description: llm 时在规则底稿与同任务摘要基础上由大模型润色全文 + business_notes: + type: string + maxLength: 20000 + description: 可选业务约束/假设,写入 §八 + product_role: + type: string + maxLength: 500 + time_horizon: + type: string + maxLength: 200 + success_criteria: + type: string + maxLength: 2000 + non_goals: + type: string + maxLength: 1000 + battlefield_one_line: + type: string + maxLength: 1000 + positioning_choice: + type: string + enum: ['', 'top', 'mid', 'entry', 'different'] + description: 价格带主定位;空字符串表示文稿中四项均为未勾选 + competitive_stance: + type: string + enum: ['', 'flank', 'head_on', 'both', 'undecided'] + pillar_product: + type: string + maxLength: 800 + pillar_price: + type: string + maxLength: 800 + pillar_channel: + type: string + maxLength: 800 + pillar_comm: + type: string + maxLength: 800 + ack_risk_keywords: + type: boolean + default: false + ack_risk_price: + type: boolean + default: false + ack_risk_concentration: + type: boolean + default: false + + StrategyDraftResponse: + type: object + required: + - schema_version + - job_id + - keyword + - generated_at + - source + - markdown + properties: + schema_version: + type: integer + example: 1 + job_id: + type: integer + keyword: + type: string + generated_at: + type: string + format: date-time + source: + type: string + description: structured_summary_rules_v1 或 llm_text_ai_crawler_v1 + example: structured_summary_rules_v1 + markdown: + type: string diff --git a/docs/pipeline-job-output-spec.md b/docs/pipeline-job-output-spec.md new file mode 100644 index 0000000..414988d --- /dev/null +++ b/docs/pipeline-job-output-spec.md @@ -0,0 +1,92 @@ +# 京东流水线单次任务 — 输出规范(v1) + +> 与阶段 1「竞品分析 MVP」任务 **1.1** 对齐:约定任务产物、入库数据与接口能力。 +> **列与字段**:与任务产物入库及导出一致,以后端当前版本为准(研发可查内部说明)。 + +--- + +## 1. 任务生命周期与状态 + +| 状态 | 含义 | +|------|------| +| 待执行 | 已创建,等待运行 | +| 执行中 | 正在跑采集与报告 | +| 成功 | 本批次输出已写入指定目录 | +| 失败 | 可阅读错误说明排查 | + +- **创建任务**:通过任务创建接口提交;执行在后台进行,需轮询任务详情直至结束。 +- **失败重试**:当前为「新建任务」模式;同一关键词可再次提交,**不会**对同一任务 ID 自动重试。 +- **环境**:数据根目录未配置时,创建任务会返回服务不可用类错误。 + +### 1.1 报告调参(可选) + +任务可附带一份 **报告调参配置**(缺省为空对象)。为空时表示生成**在线分析报告**与**结构化摘要**时一律使用**系统内置默认**(关注词、场景组、外部市场表等)。 + +| 能力 | 说明 | +|------|------| +| **创建时带入** | 创建任务时可一并提交调参(仅允许约定字段;体积与未知字段由后端校验)。 | +| **成功后修改** | 可在任务成功后更新调参;更新后需再执行「重新生成报告」或刷新结构化摘要,才会反映到新文稿或接口结果。 | +| **默认模板** | 「报告调参默认」接口返回与内置规则一致的示例,供界面「填入推荐示例」。 | + +可调字段包括:评价关注词列表、用途/场景分组、外部市场表行等,具体键名与结构以实现为准。 + +--- + +## 2. 本批次输出目录与可下载内容 + +成功任务会在数据根下生成**本批次输出目录**(绝对路径,由系统分配)。 + +可下载/预览的**内容类型**包括(名称以实现为准): + +| 类型 | 说明 | +|------|------| +| 合并宽表 | 搜索 + 商详子集 + 评论摘要(精简列) | +| 搜索列表导出 | 列表侧表格数据 | +| 商详导出 | 与合并表对齐的商详子集 | +| 评价扁平 | 评价明细表 | +| **分析报告** | 在线阅读用的主报告文稿 | + +- **下载 / 预览**:通过任务对应的下载、预览能力获取(仅成功且文件已生成时可用)。 +- **重新生成报告**(不重新爬取):仅用本批次已有表格与元数据刷新**分析报告**正文。 +- **结构化竞品摘要**:规则生成的 JSON,与在线分析报告**统计口径一致**;价格等指标在能解析列表价时以列表为准,否则以深入样本为准,响应内会标明口径。 +- **一键简报包**:ZIP,内含完整分析报告稿、结构化摘要、要点摘录与说明;须已生成主报告。 +- **市场策略制定**:提交可选业务备注后返回策略向文稿(策略框架 + 附录数据速览),数据与同任务结构化摘要一致;仅成功且数据齐全时可用。 +- **商详表离线修正再入库**:由运维/研发在侧链完成后再走全量入库能力。 + +--- + +## 3. 宽表与商详导出(精简列) + +- 合并表列顺序与内部键由系统定义。 +- 商详表为 SKU 及与合并表对齐的商详子集。 +- 精简导出**不含** HTTP 状态类字段。 + +--- + +## 4. 入库后的浏览与导出 + +任务成功后自动入库(也可手动全量入库)。支持: + +- **数据集摘要**:各表行数与列说明。 +- **分页浏览**:搜索 / 商详 / 评价 / 合并表等。 +- **导出**:按种类与格式(如 JSON、CSV、表格);导出时**勿使用**名为「format」的查询参数(与框架保留字冲突)。 + +分页结果中的字段名与库表一致。 + +--- + +## 5. 全局商品与快照 + +商品主档、单 SKU、按任务的历史快照等能力,与 OpenAPI 中路径一致。 + +--- + +## 6. 机器可读约定 + +- **OpenAPI**:仓库内提供核心路径子集,可导入调试工具。 +- **示例数据**:仓库内提供 JSON 形状示例。 +- 与线上行为不一致时,**以当前部署版本为准**,并回写说明与 OpenAPI。 + +--- + +*流水线单次任务输出规范(v1)* diff --git a/docs/templates/market-strategy-brief.zh.md b/docs/templates/market-strategy-brief.zh.md new file mode 100644 index 0000000..c276b3c --- /dev/null +++ b/docs/templates/market-strategy-brief.zh.md @@ -0,0 +1,33 @@ +# 市场策略输出模板(中文) + +> 本文件供**人工撰写**或**对照**工作台「市场策略制定」自动草稿使用。 +> 自动草稿在**同一任务的 Web 工作台**中一键生成,与同任务结构化分析数据一致;**规则拼接、非 LLM**,定稿须业务修订。 + +## 自动草稿章节 + +| 章节 | 作用 | +|------|------| +| 一、战略背景与目标 | 待业务补全(角色、周期、成功标准) | +| 二、战场界定 | 关键词、样本规模、一句话战场(待填) | +| 三、竞争格局 → 策略含义 | 集中度粗判 + 自测提问;类目 Top 仅作提示 | +| 四、价格带与定位选项 | 区间/中位数 + 定位勾选框架 | +| 五、用户需求与场景假设 | 由关注词/场景转为**待验证命题** | +| 六、机会与策略支柱 | 规则提示 + 四支柱表格(待填) | +| 七、风险与待证伪 | 抽样与数据质量自检清单 | +| 八、业务约束与判断 | 工作台 **业务备注** | +| 九、建议下一步 | 会议对齐、证据挂钩、节奏与指标 | +| 附录 | 本任务关键数据速览 | + +## 可选:完全手写时的建议目录 + +1. 背景与目标(本品、时间范围、成功标准) +2. 市场与用户(细分、痛点、购买路径) +3. 竞争格局(价位、渠道、头部玩法) +4. 机会与风险 +5. 策略支柱(产品 / 价格 / 渠道 / 传播) +6. 节奏与资源(12 周视图) +7. 指标与复盘 + +--- + +*策略输出模板(中文)* diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..5c710bc --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,19 @@ +# Market-Assistant 前端(Vue 3 + Vite) + +## 启动命令 + +```bash +npm install +npm run dev +``` + +默认本地开发端口见终端提示。请先启动后端开发服务,以便 API 代理可用。 + +## 其他脚本 + +- **npm run build** — 生产构建 +- **npm run preview** — 本地预览构建结果 + +## 项目说明 + +完整说明见上一级目录的 **README**。 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d7c516a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ +<!doctype html> +<html lang="zh-CN"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Market-Assistant + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..ee93f9d --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1282 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "dompurify": "^3.2.2", + "github-markdown-css": "^5.8.1", + "marked": "^15.0.7", + "papaparse": "^5.5.2", + "vue": "^3.5.13", + "vue-router": "^4.4.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "vite": "^5.4.11" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/github-markdown-css": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/github-markdown-css/-/github-markdown-css-5.9.0.tgz", + "integrity": "sha512-tmT5sY+zvg2302XLYEfH2mtkViIM1SWf2nvYoF5N1ZsO0V6B2qZTiw3GOzw4vpjLygK/KG35qRlPFweHqfzz5w==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8dfcafd --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "dompurify": "^3.2.2", + "github-markdown-css": "^5.8.1", + "marked": "^15.0.7", + "papaparse": "^5.5.2", + "vue": "^3.5.13", + "vue-router": "^4.4.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "vite": "^5.4.11" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..13ee9ea --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..cc51a3d Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/frontend/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue new file mode 100644 index 0000000..3a7b808 --- /dev/null +++ b/frontend/src/components/HelloWorld.vue @@ -0,0 +1,93 @@ + + + diff --git a/frontend/src/components/JobDatasetModal.vue b/frontend/src/components/JobDatasetModal.vue new file mode 100644 index 0000000..3eaed9e --- /dev/null +++ b/frontend/src/components/JobDatasetModal.vue @@ -0,0 +1,713 @@ + + + + + diff --git a/frontend/src/components/MarkdownPreview.vue b/frontend/src/components/MarkdownPreview.vue new file mode 100644 index 0000000..8640d3d --- /dev/null +++ b/frontend/src/components/MarkdownPreview.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/frontend/src/components/ReportConfigFormFields.vue b/frontend/src/components/ReportConfigFormFields.vue new file mode 100644 index 0000000..e24557d --- /dev/null +++ b/frontend/src/components/ReportConfigFormFields.vue @@ -0,0 +1,228 @@ + + +