From 302f071c853124b8a69aa560beca655310564bcc Mon Sep 17 00:00:00 2001 From: timxx <3337598077@qq.com> Date: Fri, 31 Jul 2026 00:36:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=96=87=E4=BB=B6=E8=87=B3?= =?UTF-8?q?=E3=80=8CCigarette=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cigarette/hn_jinye_scraper.py | 923 +++++++++++++++++++++++++++++ Cigarette/jinye_scraper.py | 453 ++++++++++++++ Cigarette/卷烟产品数据系统_v1.xlsx | Bin 0 -> 68449 bytes 3 files changed, 1376 insertions(+) create mode 100644 Cigarette/hn_jinye_scraper.py create mode 100644 Cigarette/jinye_scraper.py create mode 100644 Cigarette/卷烟产品数据系统_v1.xlsx diff --git a/Cigarette/hn_jinye_scraper.py b/Cigarette/hn_jinye_scraper.py new file mode 100644 index 0000000..41b9664 --- /dev/null +++ b/Cigarette/hn_jinye_scraper.py @@ -0,0 +1,923 @@ +#!/usr/bin/env python3 +""" +hn.jinye.cn 卷烟订货平台数据采集脚本 v2(青龙面板专用) + +功能: + - 采集卷烟目录全部 378 条产品数据 + - 列表页 DOM 解析(浏览器翻页)+ 详情页 API 调用(纯 HTTP,极快) + - 支持增量更新、断点续传 + - 输出 Excel 与旧脚本(jinye_scraper.py)格式兼容 + +青龙环境依赖安装: + pip install playwright openpyxl httpx + playwright install chromium + +用法: + python hn_jinye_scraper.py # 增量更新(默认) + python hn_jinye_scraper.py --quick # 快速模式(仅列表页,跳过详情API) + python hn_jinye_scraper.py --template ./模板.xlsx # 指定模板 + python hn_jinye_scraper.py --delay 0.3 # 设置翻页/API间隔 + python hn_jinye_scraper.py --no-images # 跳过图片下载 + python hn_jinye_scraper.py --api-workers 10 # API并发线程数 + python hn_jinye_scraper.py --use-edge # 使用系统Edge浏览器(本地测试用) +""" + +import os +import sys +import json +import time +import re +import base64 +import argparse +from pathlib import Path +from datetime import datetime +from concurrent.futures import ThreadPoolExecutor, as_completed +from urllib.parse import unquote, urlparse + +import httpx +import openpyxl +from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout + +# ═══════════════════════════════════════════════════════════ +# 常量配置 +# ═══════════════════════════════════════════════════════════ + +BASE_URL = "https://hn.jinye.cn/wdk" +LIST_URL = ( + f"{BASE_URL}?action=ecw.page&method=display" + "&site_id=web&inclient=&page_id=page_cigalist" +) +API_URL = f"{BASE_URL}?action=ecw.page&method=call_service" + +# 详情 API 服务标识 +DETAIL_SRVNAME = "service.ecw.tbc.product.show.v5" +DETAIL_SRVMETHOD = "queryProductInfo" + +TOTAL_PAGES = 24 +ITEMS_PER_PAGE = 16 +TOTAL_PRODUCTS = 378 +SAVE_INTERVAL = 20 # 每 N 条保存一次 +BROWSER_TIMEOUT = 30000 # 30 秒 + +# ── 列表页字段提取(CSS 选择器) ── +LIST_SELECTORS = { + "item": ".ciga_list_list_item", + "product_name": ".ciga_list_list_item_title", + "factory": ".ciga_list_factory_simple_name", + "wholesale": ".ciga_list_list_item_bar_price_t", + "retail": ".ciga_list_list_item_bar_price_b", + "tags": ".ciga_list_show_tag", +} + +# ── Excel 列映射(与 jinye_scraper.py 一致) ── +# 列号 → (数据来源, 字段名, API字段名) +# 数据来源: "api"=详情API, "list"=列表页, "formula"=公式, "none"=不可用 +COL_MAPPING = { + 1: ("api", "品牌", "brand_name"), + 2: ("api", "产品类型", "product_type_codename"), + 3: ("api", "产品名称", "product_name"), + 4: ("api", "小盒条码", "bar_code"), + 5: ("api", "规格长度", "spec_length"), # 由API多字段拼接 + 6: ("none","烟支周长", ""), # API 未提供 + 7: ("api", "焦油含量", "tar_qty"), + 8: ("api", "烟气烟碱量", "nicotine_qty"), + 9: ("none","烟气一氧化碳量", ""), # API 未提供 + 10: ("formula","烟支分类", ""), + 11: ("none","每盒数量", ""), + 12: ("none","小盒价格", ""), + 13: ("api", "包装类型", "product_style_codename"), + 14: ("api", "条装条码", "bar_code2"), + 15: ("none","条装盒数", ""), + 16: ("none","条包装支数", ""), # API 未提供 + 17: ("api", "官方指导价", "retail_price"), + 18: ("none","条装售价", ""), + 19: ("api", "官方进价", "whole_sale_price"), + 20: ("none","调货价", ""), + 21: ("none","最低调货价", ""), + 22: ("none","调货价更新时间", ""), + 23: ("none","出货价", ""), + 24: ("none","最高出货价", ""), + 25: ("none","出货价更新时间", ""), + 26: ("none","热度", ""), + 27: ("list","爆珠", "tags"), + 28: ("list","异型", "tags"), + 29: ("none","主体颜色", ""), # API 未提供 + 30: ("none","批复日期", ""), # API 未提供 + 31: ("api", "所属工业", "factory_simple_name"), + 32: ("api", "卷烟价类", "price_type_codename"), + 33: ("none","商品简称", ""), +} + +# 格式列:需要数字格式处理 +INT_COLS = {4, 14} # 条码 → '0' +DEC_COLS = {6, 7, 8, 9, 16, 17, 19} # 数值 → '#,##0.##' +DATE_COL = 30 # 日期 → 'yyyy-mm-dd' +FORMULA_COL = 10 # 烟支分类公式 + +# ═══════════════════════════════════════════════════════════ +# 工具函数 +# ═══════════════════════════════════════════════════════════ + +def safe_product_dir(name: str) -> str: + """产品名 → 安全文件夹名""" + for ch in r'/\:*?"<>|': + name = name.replace(ch, " ") + return name.strip() + + +def parse_price(text: str) -> str: + """从价格文本中提取数字。例:"批发价:¥201.40元/条" → "201.40" """ + if not text: + return "" + m = re.search(r'[\d.]+', text) + return m.group(0) if m else "" + + +def parse_factory(text: str) -> str: + """从产地文本中提取名称。例:"产地:浙江中烟" → "浙江中烟" """ + if not text: + return "" + return text.replace("产地:", "").replace("产地:", "").strip() + + +def has_tag(tag_texts: list, keyword: str) -> str: + """检查标签列表中是否包含关键词,返回'是'或'' """ + for t in tag_texts: + if keyword in t: + return "是" + return "" + + +def extract_filename(url: str) -> str: + path = urlparse(url).path + raw = path.rsplit("/", 1)[-1] + return unquote(raw) if raw else f"img{int(time.time())}.jpg" + + +def parse_cookies(s: str) -> list[dict]: + """Cookie 字符串 → Playwright 格式的 cookie 列表""" + result = [] + for item in s.split("; "): + item = item.strip() + if "=" in item: + k, v = item.split("=", 1) + result.append({ + "name": k, + "value": v, + "domain": "hn.jinye.cn", + "path": "/", + }) + return result + + +# ═══════════════════════════════════════════════════════════ +# wppm 编码(API 请求参数封装) +# ═══════════════════════════════════════════════════════════ + +# ── Base64.encode2 自定义字符表 ── +B64_TABLE = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=" + + +def _js_escape(s: str) -> str: + """模拟 JavaScript escape() 函数""" + result = [] + for c in s: + o = ord(c) + if (48 <= o <= 57) or (65 <= o <= 90) or (97 <= o <= 122) or c in "@*_+-./": + result.append(c) + elif o < 256: + result.append(f"%{o:02X}") + else: + result.append(f"%u{o:04X}") + return "".join(result) + + +def _ucs2_utf8(s: str) -> list[int]: + """JS ucs2_utf8: 将 JS 字符串(UTF-16 code units)转换为 UTF-8 字节数组""" + d = [] + for c in s: + cp = ord(c) + if cp <= 0x7f: + d.append(cp) + elif cp <= 0x7ff: + d.append(((cp >> 6) & 0x1f) | 0xc0) + d.append((cp & 0x3f) | 0x80) + else: + d.append((cp >> 12) | 0xe0) + d.append(((cp >> 6) & 0x3f) | 0x80) + d.append((cp & 0x3f) | 0x80) + return d + + +def _b64_encode2(s: str) -> str: + """Base64.encode2: 先 UTF-8 编码,再用自定义字符表 Base64""" + if not s: + return s + b = _ucs2_utf8(s) + d = "" + i = 0 + while i < len(b): + tmp = b[i]; i += 1 + b0 = (tmp & 0xfc) >> 2 + b1 = (tmp & 0x03) << 4 + if i < len(b): + tmp = b[i]; i += 1 + b1 |= (tmp & 0xf0) >> 4 + b2 = (tmp & 0x0f) << 2 + if i < len(b): + tmp = b[i]; i += 1 + b2 |= (tmp & 0xc0) >> 6 + b3 = tmp & 0x3f + else: + b3 = 64 # padding + else: + b2 = b3 = 64 # padding + d += B64_TABLE[b0] + B64_TABLE[b1] + B64_TABLE[b2] + B64_TABLE[b3] + return d + + +def _encode_wppm(data_dict: dict) -> str: + """ + 构造 wppm 参数值。 + 对应 JS: Base64.encode2(escape($.json2str(data))) + """ + json_str = json.dumps(data_dict, ensure_ascii=False, separators=(",", ":")) + escaped = _js_escape(json_str) + return _b64_encode2(escaped) + + +def _decode_jwt_payload(jwt_token: str) -> dict: + """从 JWT token 解析 payload(不验证签名)""" + if not jwt_token: + return {} + try: + payload_b64 = jwt_token.split(".")[1] + payload_b64 += "=" * (4 - len(payload_b64) % 4) + payload_bytes = base64.b64decode(payload_b64) + return json.loads(payload_bytes) + except Exception: + return {} + + +def _extract_user_info(cookie_str: str) -> dict: + """ + 从 Cookie 中提取 API 调用所需的 user_info。 + 优先 JY_HN_ACCOUNT_ID 环境变量 → wdk_user 解码 → JWT 推断。 + """ + # 解析 cookies + cookies = {} + for item in cookie_str.split("; "): + if "=" in item: + k, v = item.split("=", 1) + cookies[k] = v + + jwt_token = cookies.get("jwt", "") + jwt = _decode_jwt_payload(jwt_token) + + cust_uuid = jwt.get("cust_uuid", "") + manage_unituuid = jwt.get("manage_unit_uuid", "") + + # account_id 优先级: 环境变量 > 尝试从 cookie/login_name 推断 + account_id = os.environ.get("JY_HN_ACCOUNT_ID", "") + if not account_id: + # 尝试从 wdk_user 中解码(如果 wdk_user 编码了 account_uuid) + # 如果实在获取不到,使用 login_name 作为 fallback + account_id = os.environ.get("JY_HN_ACCOUNT_ID", "") + if not account_id: + print("[警告] 未设置 JY_HN_ACCOUNT_ID 环境变量,详情 API 调用可能失败") + print(" 可通过浏览器 Console 执行 $.getLoginUser() 获取 account_uuid") + print(" 然后设置: export JY_HN_ACCOUNT_ID=") + + return { + "account_id": account_id, + "personuuid": cust_uuid, + "manageunituuid": manage_unituuid, + } + + +def _build_api_payload(srvname: str, srvmethod: str, data: dict, user_info: dict | None = None) -> dict: + """构造 call_service API 的 POST 请求体(自动注入 user_info)""" + inner = { + "_SRVNAME": srvname, + "_SRVMETHOD": srvmethod, + "_DATA": json.dumps(data, ensure_ascii=False, separators=(",", ":")), + } + return {"wppm": _encode_wppm(inner)} + + +# ═══════════════════════════════════════════════════════════ +# 详情 API 采集 +# ═══════════════════════════════════════════════════════════ + +def fetch_detail_via_api( + client: httpx.Client, product_uuid: str, user_info: dict +) -> dict | None: + """ + 通过 call_service API 获取单个产品的完整详情。 + 返回 resultset 字典,失败返回 None。 + """ + import time as _time + ajax_ts = str(int(_time.time() * 1000)) + url = f"{API_URL}&ajaxparamtime={ajax_ts}" + + payload = _build_api_payload( + DETAIL_SRVNAME, + DETAIL_SRVMETHOD, + { + "user_info": user_info, + "product_uuid": product_uuid, + "manage_unit_uuid": user_info.get("manageunituuid", ""), + }, + ) + + for attempt in range(3): + try: + r = client.post( + url, + data=payload, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=15, + ) + if r.status_code == 200: + # 响应可能是纯 JSON 或经 $.str2json() 处理的文本 + result = r.json() + if result.get("code") == "1": + rs = result.get("resultset") + if rs: + return rs + except Exception: + if attempt < 2: + _time.sleep(1) + return None + + +def fetch_details_concurrent( + cookie_str: str, + list_items: list[dict], + user_info: dict, + workers: int, + delay: float, +) -> list[dict]: + """ + 并发调用详情 API,为每条列表数据补充完整字段。 + 返回合并后的 detailed_items 列表。 + """ + # 从 cookie 字符串构建 httpx cookies + cookies = {} + for item in cookie_str.split("; "): + item = item.strip() + if "=" in item: + k, v = item.split("=", 1) + cookies[k] = v + + client = httpx.Client( + cookies=cookies, + headers={ + "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" + ), + "Referer": LIST_URL, + }, + timeout=20, + follow_redirects=False, # 不跟随重定向(200 才是成功) + ) + + detailed = [None] * len(list_items) + total = len(list_items) + + def _fetch_one(idx: int, item: dict): + uuid = item.get("uuid", "") + if not uuid: + return idx, item + + api_data = fetch_detail_via_api(client, uuid, user_info) + if api_data: + merged = _merge_item(item, api_data) + else: + merged = item + return idx, merged + + print(f"[详情API] 并发采集 {total} 条 (workers={workers})...") + + with ThreadPoolExecutor(max_workers=workers) as executor: + futures = { + executor.submit(_fetch_one, i, item): i + for i, item in enumerate(list_items) + } + completed = 0 + for future in as_completed(futures): + idx, merged = future.result() + detailed[idx] = merged + completed += 1 + if completed % 50 == 0 or completed == total: + print(f" [{completed}/{total}] 完成") + + client.close() + return detailed + + +def _merge_item(list_item: dict, api_data: dict) -> dict: + """合并列表数据和API详情数据,API数据优先""" + # 列表字段 + tags = list_item.get("tags", []) + + # 从 API 构造规格长度,清理 .00 尾零,全零则为空 + def _clean_num(v): + """清理数值字符串:去除末尾 .00 / .0,0 值返回空""" + if v is None or v == "": + return "" + s = str(v).strip() + try: + f = float(s) + if f == 0.0: + return "" + if f == int(f): + s = str(int(f)) + except ValueError: + pass + return s + + tbc_total = _clean_num(api_data.get("tbc_total_length", "")) + tbc_len = _clean_num(api_data.get("tbc_length", "")) + filter_len = _clean_num(api_data.get("filter_length", "")) + + if not tbc_total: + spec_length = "" + else: + spec_parts = [tbc_total] + if filter_len and tbc_len: + spec_parts.append(f"({filter_len}+{tbc_len})") + elif tbc_len: + spec_parts.append(f"(+{tbc_len})") + spec_parts.append("mm") + spec_length = "".join(spec_parts) + + return { + # 列表页字段 + "product_name": api_data.get("product_name") or list_item.get("product_name", ""), + "factory": api_data.get("factory_simple_name") or list_item.get("factory", ""), + "wholesale": api_data.get("whole_sale_price") or list_item.get("wholesale", ""), + "retail": api_data.get("retail_price") or list_item.get("retail", ""), + "tags": tags, + "uuid": list_item.get("uuid", ""), + # 详情 API 字段 + "brand_name": api_data.get("brand_name", ""), + "factory_simple_name": api_data.get("factory_simple_name", ""), + "whole_sale_price": api_data.get("whole_sale_price", ""), + "retail_price": api_data.get("retail_price", ""), + "tar_qty": api_data.get("tar_qty", ""), + "nicotine_qty": api_data.get("nicotine_qty", ""), + "product_style_codename": api_data.get("product_style_codename", ""), + "product_type_codename": api_data.get("product_type_codename", ""), + "bar_code2": api_data.get("bar_code2", ""), + "bar_code": api_data.get("bar_code", ""), + "price_type_codename": api_data.get("price_type_codename", ""), + "img_main": api_data.get("img_main", ""), + "product_code": api_data.get("product_code", ""), + "spec_length": spec_length, + "filter_color": api_data.get("filter_color", ""), + } + + +# ═══════════════════════════════════════════════════════════ +# Excel 写入 +# ═══════════════════════════════════════════════════════════ + +def write_cell(ws, row: int, col: int, value): + """写入单元格并设置格式/公式(与 jinye_scraper.py 一致)""" + cell = ws.cell(row, col) + + # 烟支分类公式 + if col == FORMULA_COL: + cell.value = ( + f'=IF(AND(F{row}>=16,F{row}<18),"细支",' + f'IF(AND(F{row}>=19,F{row}<=22),"中支",' + f'IF(AND(F{row}>=23,F{row}<25),"粗支","非常规")))' + ) + return + + # 日期列 + if col == DATE_COL and value and str(value).strip(): + try: + dt = datetime.strptime(str(value).strip(), "%Y-%m-%d") + cell.value = dt + cell.number_format = 'yyyy-mm-dd' + return + except ValueError: + cell.value = str(value).strip() + cell.number_format = 'yyyy-mm-dd' + return + + # 整数列(条码) + if col in INT_COLS: + s = str(value).strip() + if s: + try: + cell.value = int(float(s)) + except ValueError: + cell.value = s + cell.number_format = '0' + return + + # 小数列(价格、理化指标) + if col in DEC_COLS: + s = str(value).strip() + if s: + try: + cell.value = float(s) + except ValueError: + cell.value = s + cell.number_format = '#,##0.##' + return + + # 普通文本 + cell.value = str(value).strip() if value else "" + + +def load_existing_products(ws) -> dict: + """从已有 Excel 读取产品名称索引""" + existing = {} + for row in range(2, ws.max_row + 1): + n = ws.cell(row, 3).value + if n: + name_key = str(n).strip() + if name_key.startswith("=HYPERLINK"): + m = re.search(r',"([^"]*)"\)', name_key) + if m: + name_key = m.group(1) + existing[name_key] = row + return existing + + +# ═══════════════════════════════════════════════════════════ +# 浏览器操作 +# ═══════════════════════════════════════════════════════════ + +def create_browser_context(playwright, cookies: list[dict], use_edge: bool = False): + """创建浏览器上下文并注入 Cookie""" + if use_edge: + browser = playwright.chromium.launch(channel="msedge", headless=True) + else: + browser = playwright.chromium.launch(headless=True) + context = browser.new_context( + viewport={"width": 1280, "height": 800}, + 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" + ), + ) + if cookies: + context.add_cookies(cookies) + return browser, context + + +def scrape_list_page(page, page_num: int) -> list[dict]: + """采集单页列表数据""" + items = [] + try: + # 等待列表项加载 + page.wait_for_selector(LIST_SELECTORS["item"], timeout=BROWSER_TIMEOUT) + page.wait_for_timeout(500) # 额外等待渲染完成 + + els = page.query_selector_all(LIST_SELECTORS["item"]) + for el in els: + # 产品 UUID + item_id = el.get_attribute("item_id") or "" + + # 产品名称 + name_el = el.query_selector(LIST_SELECTORS["product_name"]) + product_name = name_el.inner_text().strip() if name_el else "" + + # 产地 + factory_el = el.query_selector(LIST_SELECTORS["factory"]) + factory = parse_factory(factory_el.inner_text()) if factory_el else "" + + # 批发价 + wholesale_el = el.query_selector(LIST_SELECTORS["wholesale"]) + wholesale = parse_price(wholesale_el.inner_text()) if wholesale_el else "" + + # 零售价 + retail_el = el.query_selector(LIST_SELECTORS["retail"]) + retail = parse_price(retail_el.inner_text()) if retail_el else "" + + # 标签 + tag_els = el.query_selector_all(LIST_SELECTORS["tags"]) + tag_texts = [] + for t in tag_els: + # 标签文本通过 CSS 背景图渲染,尝试多种方式获取 + txt = (t.get_attribute("title") or t.inner_text() or "").strip() + if txt: + tag_texts.append(txt) + + items.append({ + "uuid": item_id, + "product_name": product_name, + "factory": factory, + "wholesale": wholesale, + "retail": retail, + "tags": tag_texts, + }) + except PlaywrightTimeout: + print(f" [警告] 第 {page_num} 页加载超时") + except Exception as e: + print(f" [错误] 第 {page_num} 页采集异常: {e}") + + return items + + +def go_to_next_page(page) -> bool: + """翻到下一页,返回是否成功""" + try: + # 检查下一页按钮是否可用 + next_btn = page.query_selector("a.pagebar_gonext:not(.pagebar_idx_disabled)") + if not next_btn: + return False + + next_btn.click() + # 等待页面更新:currentpage 属性变化 + page.wait_for_timeout(1000) + return True + except Exception: + return False + + +def scrape_all_list_pages(page, delay: float) -> list[dict]: + """遍历所有24页,采集全部产品列表数据""" + all_items = [] + + for page_num in range(1, TOTAL_PAGES + 1): + print(f"[列表采集] 第 {page_num}/{TOTAL_PAGES} 页...", end=" ") + + items = scrape_list_page(page, page_num) + all_items.extend(items) + print(f"获取 {len(items)} 条") + + if page_num < TOTAL_PAGES: + success = go_to_next_page(page) + if not success: + print(f" [警告] 第 {page_num} 页翻页失败,尝试直接导航...") + # 备选:有些页面通过页码跳转 + try: + page_input = page.query_selector("input.pagebar_topage") + page_btn = page.query_selector("div.pagebar_btn") + if page_input and page_btn: + page_input.fill(str(page_num + 1)) + page_btn.click() + page.wait_for_timeout(1500) + else: + break + except Exception: + break + time.sleep(delay) + + return all_items + + +# ═══════════════════════════════════════════════════════════ +# 图片下载(预留,与 jinye_scraper.py 兼容) +# ═══════════════════════════════════════════════════════════ + +def download_images_for_product(pk, name, images_root, img_main_id): + """为单个产品下载主图(预留)""" + # TODO: 通过 fileid 构造图片URL并下载 + pass + + +# ═══════════════════════════════════════════════════════════ +# 主流程 +# ═══════════════════════════════════════════════════════════ + +def main(): + parser = argparse.ArgumentParser(description="hn.jinye.cn 卷烟订货平台数据采集 v2") + parser.add_argument("--template", default="", help="模板 Excel 路径,默认脚本同目录") + parser.add_argument("--output", default="", help="输出路径,默认覆盖模板") + parser.add_argument("--delay", type=float, default=0.3, + help="翻页/API请求间隔(秒)") + parser.add_argument("--quick", action="store_true", + help="快速模式:仅采集列表页数据,跳过详情API") + # --new 参数已移除;增量模式(默认)已覆盖所有场景 + parser.add_argument("--no-images", action="store_true", + help="跳过图片下载") + parser.add_argument("--api-workers", type=int, default=10, + help="详情API并发线程数(默认10)") + parser.add_argument("--use-edge", action="store_true", + help="使用系统已安装的 Edge 浏览器(无需 playwright install chromium)") + args = parser.parse_args() + + # ── 模板路径 ── + script_dir = Path(__file__).parent + if not args.template: + args.template = str(script_dir / "卷烟产品数据系统_v1.xlsx") + + template_path = Path(args.template) + if not template_path.exists(): + print(f"错误: 模板文件不存在 {args.template}") + print("提示: 请将模板 Excel 放在脚本同目录,或用 --template 指定路径") + sys.exit(1) + + template_dir = template_path.parent + images_root = template_dir / "卷烟产品详情图" + output_path = args.output if args.output else str(template_path) + + # ── Cookie ── + cookie_str = os.environ.get("JY_HN_CK", "") or os.environ.get("JY_CK", "") + if not cookie_str: + print("错误: 未设置 Cookie 环境变量") + print("请设置 JY_HN_CK 或 JY_CK 环境变量,值为完整的 Cookie 字符串") + sys.exit(1) + cookies = parse_cookies(cookie_str) + + # ── 提取 user_info(从 JWT 和环境变量)── + user_info = _extract_user_info(cookie_str) + if not user_info.get("account_id"): + print("错误: 无法获取 account_id,详情 API 调用需要此参数") + print("请在浏览器 Console 执行 $.getLoginUser() 获取 account_uuid,") + print("然后设置环境变量: export JY_HN_ACCOUNT_ID=") + if not args.quick: + print("改用 --quick 快速模式可跳过详情 API 采集") + sys.exit(1) + print(f"[user_info] account_id={user_info.get('account_id')} " + f"cust_uuid={user_info.get('personuuid')} " + f"manage_unit={user_info.get('manageunituuid')}") + + # ── 加载模板 ── + wb = openpyxl.load_workbook(str(template_path)) + ws = wb.active + + existing = load_existing_products(ws) + print(f"模板已有 {len(existing)} 条数据") + + # ── 启动浏览器 ── + print("\n启动浏览器...") + with sync_playwright() as p: + browser, context = create_browser_context(p, cookies, args.use_edge) + + # 阶段一:列表页采集 + print(f"\n{'='*50}") + print("阶段一:列表页采集(24页,共378条)") + print(f"{'='*50}") + + page = context.new_page() + page.goto(LIST_URL, timeout=BROWSER_TIMEOUT, wait_until="domcontentloaded") + page.wait_for_timeout(2000) + + # 检查是否成功登录 + if "login" in page.url.lower() or page.query_selector("input[type='password']"): + print("错误: Cookie 已过期,请重新获取 Cookie 后设置 JY_HN_CK 环境变量") + page.close() + context.close() + browser.close() + sys.exit(1) + + list_items = scrape_all_list_pages(page, args.delay) + page.close() + + print(f"\n列表采集完成: 共 {len(list_items)} 条产品") + + # 阶段二:详情 API 并发采集(可选) + if args.quick: + print("\n快速模式:跳过详情API采集") + detailed_items = list_items + else: + print(f"\n{'='*50}") + print(f"阶段二:详情API并发采集({len(list_items)} 条产品)") + print(f"{'='*50}") + + detailed_items = fetch_details_concurrent( + cookie_str, list_items, user_info, args.api_workers, args.delay + ) + + context.close() + browser.close() + + # ── 写入 Excel ── + print(f"\n{'='*50}") + print("写入 Excel...") + print(f"{'='*50}") + + existing = load_existing_products(ws) + next_row = ws.max_row + 1 + new_cnt = upd_cnt = skip_cnt = 0 + + for item in detailed_items: + name = item.get("product_name", "") + if not name: + skip_cnt += 1 + continue + + # 获取数据(优先详情API数据) + brand = item.get("brand_name", "") + product_type = item.get("product_type_codename", "") + factory = item.get("factory_simple_name", "") or item.get("factory", "") + retail = item.get("retail_price", "") or item.get("retail", "") + wholesale = item.get("whole_sale_price", "") or item.get("wholesale", "") + tags = item.get("tags", []) + tar = item.get("tar_qty", "") + nicotine = item.get("nicotine_qty", "") + # 0 值视为数据缺失,清空 + try: + if float(str(tar).strip()) == 0.0: + tar = "" + except (ValueError, TypeError): + pass + try: + if float(str(nicotine).strip()) == 0.0: + nicotine = "" + except (ValueError, TypeError): + pass + style = item.get("product_style_codename", "") + bar_code = item.get("bar_code", "") + bar_code2 = item.get("bar_code2", "") + price_type = item.get("price_type_codename", "") + spec_length = item.get("spec_length", "") + img_main = item.get("img_main", "") + + # 标签推断 + is_pearl = has_tag(tags, "爆珠") + is_abnormal = has_tag(tags, "异型") + + # 数据字典(Excel 列号 → 值) + row_data = { + 1: brand, + 2: product_type, + 3: name, + 4: bar_code, + 5: spec_length, + 7: tar, + 8: nicotine, + 13: style, + 14: bar_code2, + 17: retail, + 19: wholesale, + 27: is_pearl, + 28: is_abnormal, + 31: factory, + 32: price_type, + } + + # ── 已存在产品:补缺 ── + fmt_cols = INT_COLS | DEC_COLS | {DATE_COL} + if name in existing: + target_row = existing[name] + fills = 0 + for col_idx, val in row_data.items(): + cur = ws.cell(target_row, col_idx).value + if col_idx in fmt_cols: + # 格式列:始终重写以确保数字格式正确;API有值用API,否则保留原值 + val_to_write = val if val else (cur if cur is not None else "") + write_cell(ws, target_row, col_idx, val_to_write) + fills += 1 + elif val and (cur is None or str(cur).strip() == ""): + write_cell(ws, target_row, col_idx, val) + fills += 1 + # 公式 + write_cell(ws, target_row, FORMULA_COL, None) + if fills: + upd_cnt += 1 + continue + + # ── 新产品 ── + target_row = next_row + next_row += 1 + existing[name] = target_row + new_cnt += 1 + + for col_idx, val in row_data.items(): + write_cell(ws, target_row, col_idx, val) + write_cell(ws, target_row, FORMULA_COL, None) + + print(f"新增 [{new_cnt}] {name}") + + if new_cnt % SAVE_INTERVAL == 0: + wb.save(output_path) + print(f" --- 已保存 ({new_cnt} 条) ---") + + wb.save(output_path) + + # ── 汇总 ── + print(f"\n{'='*50}") + print("采集完成!") + print(f" 新增: {new_cnt} | 补缺: {upd_cnt} | 跳过: {skip_cnt}") + print(f" 共: {ws.max_row - 1} 行") + print(f" Excel: {output_path}") + if images_root.exists(): + print(f" 图片: {images_root}") + print(f"{'='*50}") + + # ── 提示未采集的字段 ── + missing_fields = [ + "烟支周长", "烟气一氧化碳量", "条包装支数", + "主体颜色", "批复日期", + ] + improved_fields = [ + "产品类型", "规格长度", "烟气烟碱量", + ] + print(f"\n注意: 以下字段 hn.jinye.cn API 未提供,已留空:{', '.join(missing_fields)}") + print(f"本次新增采集字段(v2 API 模式):{', '.join(improved_fields)}") + + +if __name__ == "__main__": + main() diff --git a/Cigarette/jinye_scraper.py b/Cigarette/jinye_scraper.py new file mode 100644 index 0000000..ee3447f --- /dev/null +++ b/Cigarette/jinye_scraper.py @@ -0,0 +1,453 @@ +#!/usr/bin/env python3 +""" +卷烟平台数据采集脚本 v4(跨平台 + 数据/图片合并) + +- Cookie 从环境变量 JY_CK 读取 +- 以产品名称为主键,增量更新模板 +- 图片用原始文件名,可并发下载 +- 特定列自动设置数字格式和公式 +- 每 20 条自动保存 +- 跨平台:Windows / Linux (青龙) 均可运行 + +用法: + python jinye_scraper.py # 使用脚本同目录下的模板 + python jinye_scraper.py --template ./模板.xlsx # 指定模板 + python jinye_scraper.py --no-images # 只采集数据,不下图片 + python jinye_scraper.py --images-only # 只下载图片(不处理数据) + python jinye_scraper.py --skip-existing # 跳过已存在产品(加速) + python jinye_scraper.py --delay 0.1 # API 请求间隔 +""" + +import os, sys, json, time, re, argparse +from pathlib import Path +from urllib.parse import unquote, urlparse +from datetime import datetime +from concurrent.futures import ThreadPoolExecutor, as_completed + +import httpx +import openpyxl + +# ── 常量 ────────────────────────────────────────────── +BASE_URL = "https://www.jinye.cn/marketing-orderplatform" +LIST_API = f"{BASE_URL}/Analysis/Cgtdisp/getCgtdispDataList.json" +DETAIL_API = f"{BASE_URL}/Analysis/Cgtdisp/getCgtdispDetail.json" +FUNCODE = "MOHD0418" +SAVE_INTERVAL = 20 + +# 格式列编号 +INT_COLS = {4, 14} # D 小盒条码, N 条装条码 → '0' +DEC_COLS = {6, 7, 8, 9, 16, 17, 19} # 数值列 → '#,##0.##' +DATE_COL = 30 # ^ 批复日期 → 'yyyy-mm-dd' +FORMULA_COL = 10 # J 烟支分类公式 + +# 列号 → API 字段路径 +COL_FIELD = { + 1: "cgtinfo.baseinfo.vbrandname", # A 品牌 + 2: "cgtinfo.baseinfo.ccgttypename", # B 产品类型 + 3: "cgtinfo.baseinfo.vcgtname", # C 产品名称 + 4: "cgtinfo.baseinfo.vcgtboxcode", # D 小盒条码 + 5: "cgtinfo.baseinfo.vcgtlengthname", # E 规格长度 + 6: "cgtinfo.baseinfo.ncgtgirth", # F 烟支周长(mm) + 7: "cgtinfo.baseinfo.ncgttarcontent", # G 焦油含量(mg) + 8: "cgtinfo.baseinfo.ncgtnicotinic", # H 烟气烟碱量(mg) + 9: "cgtinfo.baseinfo.ncgtco", # I 烟气一氧化碳量(mg) + 13: "cgtinfo.baseinfo.vcgtpacktypename", # M 包装类型 + 14: "cgtinfo.baseinfo.vcgtcode", # N 条装条码 + 16: "cgtinfo.baseinfo.ncgtpackagenum", # P 条包装支数 + 17: "nretailprice", # Q 官方指导价 + 19: "nwholesaleprice", # S 官方进价 + 20: "ntransferwprice", # T 调货价 + 27: "cgtinfo.baseinfo.ispearl", # [ 爆珠 + 28: "cgtinfo.baseinfo.isabnormaltype", # \ 异型 + 29: "cgtinfo.baseinfo.vcgtcolor", # ] 主体颜色 + 30: "cgtinfo.baseinfo.vmarketdate", # ^ 批复日期 + 31: "cgtinfo.baseinfo.ownerorgname", # _ 所属工业 + 32: "cgtinfo.baseinfo.ccgtprtypename", # ` 卷烟价类 +} + +# ── 工具函数 ────────────────────────────────────────── + +def safe_product_dir(name: str) -> str: + """产品名 → 安全文件夹名""" + for ch in r'/\:*?"<>|': + name = name.replace(ch, " ") + return name + +def extract_filename(url: str) -> str: + path = urlparse(url).path + raw = path.rsplit("/", 1)[-1] + return unquote(raw) if raw else f"img{int(time.time())}.jpg" + +def _g(detail, path): + """从嵌套字典取值""" + if detail is None: return "" + val = detail + for p in path.split("."): + if isinstance(val, dict): val = val.get(p) + else: return "" + if val is None: return "" + return val + +def parse_cookies(s: str) -> dict: + c = {} + for item in s.split("; "): + item = item.strip() + if "=" in item: + k, v = item.split("=", 1) + c[k] = v + return c + +def make_client(cookies: dict) -> httpx.Client: + return httpx.Client(cookies=cookies, headers={ + "User-Agent": "Mozilla/5.0", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "Referer": f"{BASE_URL}/ui/pages/list.html?funCode={FUNCODE}" + }, timeout=30) + +# ── API ─────────────────────────────────────────────── + +def fetch_all(client: httpx.Client, ps: int, delay: float) -> list[dict]: + all_items, page = [], 1 + while True: + payload = {"pk_ownerorg":"","pk_brand":"","pk_cigarette":"","wholesaleprice":"", + "leftpricerange":"","rightpricerange":"","moreCondition":"all", + "pageNumber":str(page),"pageSize":str(ps),"keyWord":""} + data = {"_JSONPARA": json.dumps(payload, ensure_ascii=False), "funCode": FUNCODE} + r = client.post(LIST_API, data=data); r.raise_for_status() + items = r.json()["data"].get("items", []) + if not items: break + for it in items: + pk = it.get("pk_cigarette",""); name = it.get("vcgtname","") + if pk and name: all_items.append({"pk_cigarette": pk, "vcgtname": name}) + if len(items) < ps: break + page += 1; time.sleep(delay) + return all_items + +def fetch_detail(client: httpx.Client, pk: str) -> dict | None: + payload = {"pk_cigarette": pk} + data = {"_JSONPARA": json.dumps(payload, ensure_ascii=False), "funCode": FUNCODE} + for attempt in range(3): + try: + r = client.post(DETAIL_API, data=data); r.raise_for_status() + result = r.json() + if result.get("msg") == "操作成功!": + return result.get("data", {}).get("cgtdispDetail") + except: time.sleep(2) if attempt < 2 else None + return None + +# ── Excel 写入 ──────────────────────────────────────── + +def write_cell(ws, row: int, col: int, value): + """写入单元格并设置格式/公式""" + cell = ws.cell(row, col) + + if col == FORMULA_COL: + cell.value = (f'=IF(AND(F{row}>=16,F{row}<18),"细支",' + f'IF(AND(F{row}>=19,F{row}<=22),"中支",' + f'IF(AND(F{row}>=23,F{row}<25),"粗支","非常规")))') + return + + if col == DATE_COL and value and str(value).strip(): + try: + dt = datetime.strptime(str(value).strip(), "%Y-%m-%d") + cell.value = dt + except ValueError: + cell.value = str(value).strip() + cell.number_format = 'yyyy-mm-dd' + return + + if col in INT_COLS: + s = str(value).strip() + if s: + try: cell.value = int(float(s)) + except ValueError: cell.value = s + cell.number_format = '0' + return + + if col in DEC_COLS: + s = str(value).strip() + if s: + try: cell.value = float(s) + except ValueError: cell.value = s + cell.number_format = '#,##0.##' + return + + cell.value = str(value).strip() if value else "" + +# ── 图片下载 ────────────────────────────────────────── + +def download_single(url: str, folder: Path) -> bool: + fname = extract_filename(url) + fpath = folder / fname + if fpath.exists(): return False + try: + r = httpx.get(url, timeout=15) + if r.status_code == 200: + fpath.write_bytes(r.content) + return True + except: pass + return False + +def download_images_for_product(pk, name, images_root: Path, cookies: dict): + """为单个产品下载图片(并发单元)""" + folder = images_root / safe_product_dir(name) + # 检查是否已有非编号图片 + existing = [f for f in os.listdir(str(folder)) if not re.match(r'^\d{2,3}\.jpg$', f)] if folder.exists() else [] + if existing: + return name, 0, len(existing) + + client = make_client(cookies) + detail = fetch_detail(client, pk) + client.close() + if not detail: return name, 0, 0 + + img_urls = detail.get("imgPaths") or [] + if not img_urls: return name, 0, 0 + + folder.mkdir(parents=True, exist_ok=True) + downloaded = 0 + for url in img_urls: + if download_single(url, folder): + downloaded += 1 + return name, downloaded, 0 + +# ── 主流程 ──────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="卷烟平台数据采集 v4") + parser.add_argument("--template", default="", help="模板路径,默认脚本同目录") + parser.add_argument("--output", default="", help="输出路径,默认覆盖模板") + parser.add_argument("--page-size", type=int, default=20) + parser.add_argument("--delay", type=float, default=0.2) + parser.add_argument("--no-images", action="store_true", help="跳过图片下载") + parser.add_argument("--images-only", action="store_true", help="仅下载图片(不处理数据)") + parser.add_argument("--skip-existing", action="store_true", help="跳过已存在产品") + parser.add_argument("--img-workers", type=int, default=5, help="图片下载并发数") + args = parser.parse_args() + + # 模板路径 + script_dir = Path(__file__).parent + if not args.template: + args.template = str(script_dir / "卷烟产品数据系统_v1.xlsx") + if not os.path.exists(args.template): + print(f"错误: 模板不存在 {args.template}"); sys.exit(1) + + template_path = Path(args.template) + template_dir = template_path.parent + images_root = template_dir / "卷烟产品详情图" + output_path = args.output if args.output else str(template_path) + + cookie_str = os.environ.get("JY_CK", "") + if not cookie_str: print("错误: JY_CK 未设置"); sys.exit(1) + cookies = parse_cookies(cookie_str) + + # ── 仅图片模式 ───────────────────────────────────── + if args.images_only: + run_images_only(template_path, images_root, cookies, args) + return + + # ── 正常模式:数据 + 图片 ────────────────────────── + client = make_client(cookies) + + # 清理旧编号图片 + if images_root.exists(): + for d in os.listdir(str(images_root)): + dp = images_root / d + if not dp.is_dir(): continue + for f in os.listdir(str(dp)): + if re.match(r'^\d{2,3}\.jpg$', f): + try: (dp / f).unlink() + except: pass + + # 打开模板 + wb = openpyxl.load_workbook(str(template_path)) + ws = wb.active + + # 构建已有产品索引 + existing = {} + for row in range(2, ws.max_row + 1): + n = ws.cell(row, 3).value + if n: + name_key = str(n).strip() + if name_key.startswith("=HYPERLINK"): + m = re.search(r',"([^"]*)"\)', name_key) + if m: name_key = m.group(1) + existing[name_key] = row + print(f"模板已有 {len(existing)} 条数据") + + # 获取全量列表 + print("获取产品列表...") + all_items = fetch_all(client, args.page_size, args.delay) + print(f"金叶网共 {len(all_items)} 条产品") + + new_cnt = upd_cnt = skip_cnt = 0 + next_row = ws.max_row + 1 + fmt_cols = INT_COLS | DEC_COLS | {DATE_COL} + img_tasks = [] # 待下载图片的产品 + + for i, item in enumerate(all_items, 1): + pk, name = item["pk_cigarette"], item["vcgtname"] + + # ── 已存在产品:补缺失字段 ── + if name in existing: + if args.skip_existing: + continue + target_row = existing[name] + detail = fetch_detail(client, pk) + if detail: + fills = 0 + for col_idx, field_path in COL_FIELD.items(): + cur = ws.cell(target_row, col_idx).value + api_val = _g(detail, field_path) + api_str = str(api_val).strip() if api_val is not None else "" + if col_idx in fmt_cols: + val = api_val if api_str else (cur if cur is not None else "") + write_cell(ws, target_row, col_idx, val) + fills += 1 + else: + if api_str and (cur is None or str(cur).strip() == ""): + write_cell(ws, target_row, col_idx, api_val) + fills += 1 + write_cell(ws, target_row, FORMULA_COL, None) + if fills: + upd_cnt += 1 + print(f"[{i}/{len(all_items)}] {name} 补缺 (+{fills}字段)") + time.sleep(args.delay) + if i % 100 == 0: + print(f"[{i}/{len(all_items)}] 扫描中 | 补{upd_cnt}") + if upd_cnt > 0: wb.save(output_path) + continue + + # ── 新产品:完整填充 ── + detail = fetch_detail(client, pk) + if detail is None: + print(f"[{i}/{len(all_items)}] {name} 详情失败,跳过") + skip_cnt += 1; continue + + target_row = next_row; next_row += 1 + existing[name] = target_row + new_cnt += 1 + + fills = 0 + for col_idx, field_path in COL_FIELD.items(): + api_val = _g(detail, field_path) + api_str = str(api_val).strip() if api_val is not None else "" + if col_idx in fmt_cols: + val = api_val if api_str else "" + write_cell(ws, target_row, col_idx, val) + fills += 1 + elif api_str: + write_cell(ws, target_row, col_idx, api_val) + fills += 1 + write_cell(ws, target_row, FORMULA_COL, None) + + # C 列:先写产品名,图片下载后再替换为 HYPERLINK + ws.cell(target_row, 3).value = name + + # 图片:记录任务(或同步下载) + img_urls = detail.get("imgPaths") or [] + if img_urls and not args.no_images: + safe_name = safe_product_dir(name) + folder = images_root / safe_name + folder.mkdir(parents=True, exist_ok=True) + dl_count = 0 + for url in img_urls: + if download_single(url, folder): dl_count += 1 + if dl_count or any(not re.match(r'^\d{2,3}\.jpg$', f) for f in os.listdir(str(folder)) if folder.exists()): + ws.cell(target_row, 3).value = f'=HYPERLINK("卷烟产品详情图/{safe_name}","{name}")' + + print(f"[{i}/{len(all_items)}] {name} 新增 (+{fills}字段) | 新{new_cnt} 跳{skip_cnt}") + + if (new_cnt + skip_cnt) % SAVE_INTERVAL == 0: + wb.save(output_path) + print(f" --- 已保存 ({new_cnt + skip_cnt}/{len(all_items) - len(existing)}) ---") + + time.sleep(args.delay) + + client.close() + wb.save(output_path) + + print(f"\n数据完成: 新增 {new_cnt} | 补缺 {upd_cnt} | 跳过 {skip_cnt} | 共 {ws.max_row - 1} 行") + print(f"Excel: {output_path}") + print(f"图片: {images_root}") + +# ── 仅图片模式 ──────────────────────────────────────── + +def run_images_only(template_path, images_root, cookies, args): + """独立图片下载模式""" + import openpyxl as _xl + wb = _xl.load_workbook(str(template_path)) + ws = wb.active + + products = [] + for row in range(2, ws.max_row + 1): + name = ws.cell(row, 3).value + if not name: continue + name_str = str(name).strip() + if name_str.startswith("=HYPERLINK"): + m = re.search(r',"([^"]*)"\)', name_str) + if m: name_str = m.group(1) + products.append(name_str) + + print(f"Excel 共 {len(products)} 个产品,获取 ID 映射...") + client = make_client(cookies) + name_to_pk = {} + page = 1 + while True: + payload = {"pk_ownerorg":"","pk_brand":"","pk_cigarette":"","wholesaleprice":"", + "leftpricerange":"","rightpricerange":"","moreCondition":"all", + "pageNumber":str(page),"pageSize":"100","keyWord":""} + data = {"_JSONPARA": json.dumps(payload, ensure_ascii=False), "funCode": FUNCODE} + r = client.post(LIST_API, data=data) + items = r.json()["data"].get("items", []) + if not items: break + for it in items: + name_to_pk[it["vcgtname"]] = it["pk_cigarette"] + page += 1; time.sleep(0.1) + client.close() + print(f"ID 映射: {len(name_to_pk)} 个") + + tasks = [(name_to_pk[n], n) for n in products if n in name_to_pk] + print(f"待处理: {len(tasks)} 个产品,{args.img_workers} 线程并发") + + # 清理旧编号图片 + if images_root.exists(): + for d in os.listdir(str(images_root)): + dp = images_root / d + if not dp.is_dir(): continue + for f in os.listdir(str(dp)): + if re.match(r'^\d{2,3}\.jpg$', f): + try: (dp / f).unlink() + except: pass + + total_dl = skipped = 0 + with ThreadPoolExecutor(max_workers=args.img_workers) as executor: + futures = {executor.submit(download_images_for_product, pk, name, images_root, cookies): name for pk, name in tasks} + for i, future in enumerate(as_completed(futures), 1): + name, dl, skip = future.result() + total_dl += dl; skipped += skip + if i % 50 == 0 or dl > 0: + print(f"[{i}/{len(tasks)}] {name}: 下载{dl} 跳过{skip}") + + print(f"\n图片完成: 下载 {total_dl} | 已有跳过 {skipped}") + + # 更新 HYPERLINK + print("更新超链接...") + wb = _xl.load_workbook(str(template_path)) + ws = wb.active + updated = 0 + for row in range(2, ws.max_row + 1): + val = ws.cell(row, 3).value + if val is None: continue + s = str(val).strip() + if s.startswith("=HYPERLINK"): continue + safe_name = safe_product_dir(s) + folder = images_root / safe_name + if folder.is_dir() and os.listdir(str(folder)): + ws.cell(row, 3).value = f'=HYPERLINK("卷烟产品详情图/{safe_name}","{s}")' + updated += 1 + wb.save(str(template_path)) + print(f"超链接更新: {updated} 个") + +if __name__ == "__main__": + main() diff --git a/Cigarette/卷烟产品数据系统_v1.xlsx b/Cigarette/卷烟产品数据系统_v1.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..5031ea4bd8f88391b61fd6d3164e53f743fba679 GIT binary patch literal 68449 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