把需要账号登录态的链路从抓取服务里拆出成独立进程。分界线不是「要不要登录」, 而是抓取无状态、幂等、可多开实例,而交易的写操作不可逆、登录态全局唯一、 订单监控是常驻轮询——同进程时抓取一扩容就会复制出 N 份登录态与 N 个轮询, 同一账号会被并发操作。 - app/shared:配置、错误码、日志、ApiResponse 信封 + Bearer 鉴权 + 异常处理器、 导航请求头构造器 - app/scraping:站点常量、会话、解析器与 10 个抓取接口,:31107,可多开 - app/trading:登录态查询/重载与健康检查,:31108,只能单实例 - 依赖方向锁为 scraping→shared、trading→shared,两侧互不 import; tests/test_architecture.py 用 AST 检查 import 并校验两个 app 的路径不串 - 登录态 UA 在 trading 独立持有:与抓取 UA 值相同但变更理由不同,抓取 UA 为绕 反爬可随时调整,登录 UA 一改可能触发设备校验使已落盘 cookie 失效 - scripts/login.py 与 AuthSession 共用 auth_site.PROFILES 与 is_logged_in,判据只写一遍 - 同一镜像两个启动命令,交易容器覆盖 command 并设 RAKUTEN_HEALTH_PORT 同时带上此前未提交的 ラクマ 分类接口与登录态基础设施。 验证:239 个离线用例全绿;两个入口真实启动,/health 与鉴权正常。 未验证:真实探测登录态(当前开发机无外网,对站点的连接全部超时)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
145 lines
5.1 KiB
Python
145 lines
5.1 KiB
Python
"""ラクマ(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))
|