"""ラクマ(fril.jp)页面解析的公共工具 站点是服务端渲染的 HTML,没有 `window.__INITIAL_STATE__` 之类的内联状态, 因此全部走 DOM 解析。好在页面上挂了成套的埋点属性(`data-rat-*` 与 `onclick` 里的 dataLayer JSON),它们比可见文案稳定得多,也带有可见 DOM 上没有的字段(商品数值 ID、卖家 ID、分类 ID、品牌 ID),所以优先取这些。 """ from __future__ import annotations import html import json import re from typing import Any from selectolax.parser import Node # 「約1,190,000件中 1 - 40件」里的总数与区间 _COUNT_RE = re.compile(r"([\d,]+)\s*件中\s*([\d,]+)\s*[-−–]\s*([\d,]+)\s*件") _DIGITS_RE = re.compile(r"-?\d+") # 页面级埋点属性 data-rat-cp-{key}="{value}" _RAT_PARAM_RE = re.compile(r'data-rat-cp-([\w]+)="([^"]*)"') def parse_int(text: str | int | float | None) -> int: """从 "¥6,299" / "6399" / 6399 这类值里取出整数金额或计数""" if isinstance(text, bool) or text is None: return 0 if isinstance(text, (int, float)): return int(text) digits = _DIGITS_RE.findall(text.replace(",", "")) return int(digits[0]) if digits else 0 def parse_float(text: str | int | float | None) -> float: """从 "5.0" 这类文本里取出评分""" if isinstance(text, bool) or text is None: return 0.0 if isinstance(text, (int, float)): return float(text) match = re.search(r"\d+(?:\.\d+)?", text.replace(",", "")) return float(match.group()) if match else 0.0 def node_text(node: Node | None) -> str: """取节点的可见文本,压掉多余空白;节点不存在时返回空串""" if node is None: return "" return re.sub(r"\s+", " ", node.text(strip=True)).strip() def attr(node: Node | None, name: str) -> str: """取节点属性,缺失时返回空串""" if node is None: return "" return (node.attributes.get(name) or "").strip() def image_url(node: Node | None) -> str: """取图片地址:站点用 lazy load,真实地址在 data-original 上,src 是占位图""" if node is None: return "" return attr(node, "data-original") or attr(node, "src") def parse_total_count(text: str) -> tuple[int, int, int]: """解析「N件中 X - Y件」,返回 (总数, 起, 止);解析不出时全为 0 注意搜索页这里的总数是四舍五入后的展示值(約1,190,000件), 精确值要从埋点属性 data-rat-cp-totalresults 取;店铺页则是精确值。 """ match = _COUNT_RE.search(text.replace("\xa0", " ")) if match is None: return 0, 0, 0 return ( parse_int(match.group(1)), parse_int(match.group(2)), parse_int(match.group(3)), ) def event_payload(node: Node | None) -> dict[str, Any]: """从埋点里取出商品参数字典 商品链接的 onclick / data-gtm-click 上挂着一段 dataLayer JSON,形如: {"event":"fireEvent","eventData":{"event_parameter":{ "item_id":"844649627","seller_user_id":"12073120", "category_id":"788","brand_id":"5296","price":6299, ...}}} 这里面有可见 DOM 上没有的数值 ID,是搜索结果里最可靠的数据来源。 """ if node is None: return {} for source in (node.attributes.get("data-gtm-click"), node.attributes.get("onclick")): if not source: continue for raw in _iter_json_objects(html.unescape(source)): parameter = ( raw.get("eventData", {}).get("event_parameter") if isinstance(raw.get("eventData"), dict) else None ) if isinstance(parameter, dict) and "item_id" in parameter: return parameter return {} def find_item_payload(tree: Any) -> dict[str, Any]: """在整页里找出第一段带 item_id 的埋点参数 详情页的这段 JSON 挂在哪个元素上并不固定(在售商品挂在品牌链接上, 已售商品的页面结构不同),因此按属性扫描而不是写死选择器。 """ for node in tree.css("[data-gtm-click], [onclick]"): payload = event_payload(node) if payload: return payload return {} def rat_params(html_text: str) -> dict[str, str]: """取出页面级埋点属性 `data-rat-cp-*` 详情页把成色、运费负担、发货地等信息也写在这组属性里。已售出商品的 页面会换成另一套布局、规格表消失,但这组属性仍在,可用作兜底。 """ return { match.group(1): html.unescape(match.group(2)) for match in _RAT_PARAM_RE.finditer(html_text) } def _iter_json_objects(text: str): """从一段掺杂着 JS 代码的文本里增量解析出所有顶层 JSON 对象""" decoder = json.JSONDecoder() index = text.find("{") while index >= 0: try: value, end = decoder.raw_decode(text, index) except ValueError: index = text.find("{", index + 1) continue if isinstance(value, dict): yield value index = text.find("{", max(end, index + 1))