拆分抓取与交易服务

把需要账号登录态的链路从抓取服务里拆出成独立进程。分界线不是「要不要登录」,
而是抓取无状态、幂等、可多开实例,而交易的写操作不可逆、登录态全局唯一、
订单监控是常驻轮询——同进程时抓取一扩容就会复制出 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>
This commit is contained in:
2026-07-27 15:05:01 +08:00
co-authored by Claude Opus 5
parent 4250388762
commit 104d7fef6b
80 changed files with 2330 additions and 402 deletions
+69 -7
View File
@@ -7,19 +7,20 @@ from pathlib import Path
import pytest
from app.core.errors import InvalidRequestError, ScrapeParseError
from app.models.scrape import (
from app.shared.errors import InvalidRequestError, ItemNotFoundError, ScrapeParseError
from app.scraping.models.scrape import (
RakumaAuthenticity,
RakumaCondition,
RakumaSearchRequest,
RakumaSortOption,
RakumaTransaction,
)
from app.parsers.rakuma.base import parse_int, parse_total_count
from app.parsers.rakuma.item import parse_item_detail
from app.parsers.rakuma.search import parse_search
from app.parsers.rakuma.shop import parse_shop_detail, parse_shop_items
from app.utils.rakuma_urls import (
from app.scraping.parsers.rakuma.base import parse_int, parse_total_count
from app.scraping.parsers.rakuma.category import parse_categories
from app.scraping.parsers.rakuma.item import parse_item_detail
from app.scraping.parsers.rakuma.search import parse_search
from app.scraping.parsers.rakuma.shop import parse_shop_detail, parse_shop_items
from app.scraping.utils.rakuma_urls import (
build_item_url,
build_search_url,
build_shop_url,
@@ -269,6 +270,67 @@ def test_parse_shop_pages_reject_non_shop_page():
parse_shop_items("<html><body>x</body></html>", shop_id="S", request_url="u", page=1)
# ---- 分类树解析 ----
def test_parse_categories_returns_top_level_by_default():
data = parse_categories(
fixture("rakuma_category.html"), category_id=None, include_descendants=False
)
assert data.category_id == ""
assert len(data.children) == 14 # 站点顶层分类固定 14 个
top = data.children[0]
assert top.category_id == "10001"
assert top.name == "レディース"
assert top.parent_id == "0"
assert top.is_leaf is False
assert top.url == "https://fril.jp/category/10001"
assert top.children == [] # 未要求子树时不展开
def test_parse_categories_reads_ancestors_and_children():
data = parse_categories(
fixture("rakuma_category.html"), category_id="786", include_descendants=False
)
assert data.name == "ゲームソフト/ゲーム機本体"
assert [node.category_id for node in data.ancestors] == ["10007"]
assert data.full_name == "エンタメ/ホビー / ゲームソフト/ゲーム機本体"
assert [node.category_id for node in data.children] == ["787", "788", "789"]
assert all(node.is_leaf for node in data.children)
assert data.is_leaf is False
def test_parse_categories_marks_leaf_without_children():
data = parse_categories(
fixture("rakuma_category.html"), category_id="788", include_descendants=False
)
assert data.is_leaf is True
assert data.children == []
assert data.full_name == "エンタメ/ホビー / ゲームソフト/ゲーム機本体 / 家庭用ゲームソフト"
def test_parse_categories_can_expand_full_subtree():
"""整棵树本来就在一次响应里,展开子树不需要多打请求"""
data = parse_categories(
fixture("rakuma_category.html"), category_id="10007", include_descendants=True
)
branch = data.children[0]
assert branch.category_id == "786"
assert [node.category_id for node in branch.children] == ["787", "788", "789"]
def test_parse_categories_rejects_unknown_category():
"""无效分类要报 404,而不是当成「没有子分类」返回空结果"""
with pytest.raises(ItemNotFoundError):
parse_categories(
fixture("rakuma_category.html"), category_id="99999999", include_descendants=False
)
def test_parse_categories_rejects_page_without_tree():
with pytest.raises(ScrapeParseError):
parse_categories("<html><body>home</body></html>", category_id=None, include_descendants=False)
# ---- 取值工具 ----
@pytest.mark.parametrize(