Files
rakuten-api/app/scraping/parsers/rakuma/search.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

136 lines
4.8 KiB
Python

"""ラクマ 搜索页 / 店铺商品列表 HTML → 商品列表
搜索页与店铺页的商品卡片是同一套 `.item-box` 结构(只有链接的 class 前缀
不同:搜索页 link_search_image、店铺页 link_shop_image),因此共用一个卡片
解析函数。
页面上的可见总数是四舍五入的展示值(約1,190,000件),精确值在埋点属性
`data-rat-cp-totalresults` 上,优先取后者。
"""
from __future__ import annotations
import re
from selectolax.parser import HTMLParser, Node
from app.scraping.core import rakuma_site as site
from app.shared.errors import ScrapeParseError
from app.scraping.models.scrape import RakumaSearchItem, RakumaSearchResultData
from app.scraping.parsers.rakuma.base import (
attr,
event_payload,
image_url,
node_text,
parse_int,
parse_total_count,
)
from app.scraping.utils.rakuma_urls import item_id_from_url
# 埋点属性里的精确命中总数
_TOTAL_RESULTS_RE = re.compile(r'data-rat-cp-totalresults="(\d+)"')
def _as_str(value: object) -> str:
"""埋点 JSON 里的值可能是数字、字符串或 null,统一收敛为字符串"""
if value is None or isinstance(value, bool):
return ""
if isinstance(value, (int, float)):
return str(int(value))
return value.strip() if isinstance(value, str) else ""
def parse_item_card(card: Node) -> RakumaSearchItem:
"""解析一张商品卡片
优先从埋点 JSON 取结构化字段(数值 ID、分类、品牌、价格),
可见 DOM 只用于取图片与售罄标记。
"""
link = (
card.css_first("a.link_search_image")
or card.css_first("a.link_shop_image")
or card.css_first("a[href*='item.fril.jp']")
)
payload = event_payload(link)
item_url = attr(link, "href")
category_names = [
name
for name in (
_as_str(payload.get("first_category")),
_as_str(payload.get("second_category")),
_as_str(payload.get("third_category")),
)
if name
]
# 价格优先取埋点里的数值,回退到卡片上的展示价
price = parse_int(payload.get("price")) or parse_int(
node_text(card.css_first(".item-box__item-price"))
)
return RakumaSearchItem(
item_id=item_id_from_url(item_url),
item_number=_as_str(payload.get("item_id")),
item_name=_as_str(payload.get("item_name"))
or node_text(card.css_first(".item-box__item-name, .item-box__item-name__limited-three-lines")),
item_url=item_url,
price=price,
image_url=image_url(card.css_first("img")),
is_sold_out=card.css_first(".item-box__soldout_ribbon") is not None,
brand_id=_as_str(payload.get("brand_id")),
brand_name=_as_str(payload.get("brand_name"))
or node_text(card.css_first(".item-box__item-sub-name")),
category_id=_as_str(payload.get("category_id")),
category_names=category_names,
seller_user_id=_as_str(payload.get("seller_user_id")),
seller_type=_as_str(payload.get("seller_user_type")),
)
def parse_item_cards(tree: HTMLParser) -> list[RakumaSearchItem]:
"""解析页面上的全部商品卡片
只取有真实商品链接的卡片:页面上还有一批用于占位的骨架卡片
(懒加载的推荐位),它们没有 item.fril.jp 链接。
"""
items: list[RakumaSearchItem] = []
for card in tree.css(".item-box"):
link = card.css_first("a[href*='item.fril.jp']")
if link is None:
continue
items.append(parse_item_card(card))
return items
def parse_search(html: str, *, request_url: str, page: int, keyword: str) -> RakumaSearchResultData:
"""把搜索页 HTML 解析为搜索结果
Raises:
ScrapeParseError: 页面不是搜索结果页(站点对无法识别的参数值会静默返回首页)
"""
tree = HTMLParser(html)
count_node = tree.css_first(f".{site.SEARCH_PAGE_MARKER}")
if count_node is None:
raise ScrapeParseError(
"页面不是搜索结果页(缺少命中数区块);"
"站点对无法识别的筛选取值会静默返回首页,请检查筛选参数"
)
items = parse_item_cards(tree)
# 展示值是四舍五入过的(約1,190,000件),埋点里才是精确命中数
display_total, start, end = parse_total_count(node_text(count_node))
match = _TOTAL_RESULTS_RE.search(html)
total_count = int(match.group(1)) if match else display_total
return RakumaSearchResultData(
keyword=keyword,
page=page,
page_size=len(items),
total_count=total_count,
# 站点 page>100 直接 404,超出可达窗口时没有下一页
has_more=bool(items) and page < site.MAX_PAGE and (end or start + len(items) - 1) < total_count,
request_url=request_url,
items=items,
)