Files
rakuten-api/app/scraping/parsers/rakuma/base.py
T
q792602257andClaude Opus 5 104d7fef6b 拆分抓取与交易服务
把需要账号登录态的链路从抓取服务里拆出成独立进程。分界线不是「要不要登录」,
而是抓取无状态、幂等、可多开实例,而交易的写操作不可逆、登录态全局唯一、
订单监控是常驻轮询——同进程时抓取一扩容就会复制出 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>
2026-07-27 15:05:01 +08:00

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))