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

104 lines
3.5 KiB
Python

"""分类页/搜索页 __INITIAL_STATE__ → GenreData
分类数据来自 state.data.genreTree.parent_category。它不是完整分类树,而是一条
「从根一路展开到目标分类」的链:每层只保留通往目标的那一个子节点,目标分类自身
则挂着它的全部直接子分类。
不带分类查询时,根节点直接挂着 39 个顶层分类。
"""
from __future__ import annotations
from typing import Any
from app.scraping.core import site
from app.shared.errors import ScrapeParseError
from app.scraping.models.scrape import GenreData, GenreNode
from app.scraping.utils.coerce import as_dict, as_int, as_list, as_str
# 站点用 id=0 表示分类树的虚拟根,它不是一个真实分类
ROOT_GENRE_ID = 0
def _genre_url(genre_id: str) -> str:
return f"{site.CATEGORY_BASE_URL}{genre_id}/" if genre_id else ""
def _to_node(raw: dict[str, Any], *, with_count: bool) -> GenreNode:
genre_id = str(as_int(raw.get("id")))
count = raw.get("count")
return GenreNode(
genre_id=genre_id,
name=as_str(raw.get("name")),
item_count=as_int(count) if with_count and count is not None else None,
shortcut=as_str(raw.get("shortcut")),
is_leaf=bool(raw.get("leaf")),
url=_genre_url(genre_id),
)
def _find_path(node: dict[str, Any], genre_id: str) -> list[dict[str, Any]] | None:
"""在分类链中定位目标分类,返回从根到它的节点路径(含自身)"""
if str(as_int(node.get("id"))) == genre_id:
return [node]
for child in as_list(node.get("children")):
if not isinstance(child, dict):
continue
found = _find_path(child, genre_id)
if found is not None:
return [node, *found]
return None
def parse_genres(state: dict[str, Any], *, genre_id: str | None) -> GenreData:
"""解析分类树
Args:
genre_id: 目标分类;None 表示取顶层分类列表
Raises:
ScrapeParseError: 页面里没有分类树,或目标分类不在返回的链上
"""
data = as_dict(as_dict(state.get("state")).get("data"))
root = as_dict(as_dict(data.get("genreTree")).get("parent_category"))
if not root:
raise ScrapeParseError("页面中缺少 genreTree.parent_category 节点")
if genre_id is None:
children = [
_to_node(child, with_count=False)
for child in as_list(root.get("children"))
if isinstance(child, dict)
]
if not children:
raise ScrapeParseError("未能取到顶层分类列表")
return GenreData(children=children)
path = _find_path(root, genre_id)
if path is None:
raise ScrapeParseError(f"分类树中未找到分类 {genre_id}")
target = path[-1]
ancestors = [
_to_node(node, with_count=False)
for node in path[:-1]
if as_int(node.get("id")) != ROOT_GENRE_ID
]
children = [
_to_node(child, with_count=True)
for child in as_list(target.get("children"))
if isinstance(child, dict)
]
genre_info = as_dict(data.get("genreInfo"))
resolved_id = str(as_int(target.get("id")))
return GenreData(
genre_id=resolved_id,
name=as_str(target.get("name")),
full_name=as_str(genre_info.get("fullGenreName")),
description=as_str(genre_info.get("description")),
is_leaf=bool(target.get("leaf")),
url=_genre_url(resolved_id),
ancestors=ancestors,
children=children,
)