Files
Yangmao_Script/Cigarette/hn_jinye_scraper.py
T
2026-07-31 00:36:47 +08:00

924 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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=<account_uuid值>")
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 / .00 值返回空"""
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=<account_uuid值>")
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()