"""ラクマ(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))