Init
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
"""ラクマ(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))
|
||||
Reference in New Issue
Block a user