Files
rakuten-api/tests/test_search_url.py
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

147 lines
4.7 KiB
Python

"""搜索/商品 URL 构建与解析的单元测试"""
from urllib.parse import parse_qsl, urlsplit
import pytest
from app.shared.errors import InvalidRequestError
from app.scraping.models.scrape import ItemCondition, SearchRequest, SortOption
from app.scraping.utils.urls import (
build_item_url,
build_search_url,
item_url_parts,
normalize_search_url,
split_item_url,
)
def query_of(url: str) -> list[tuple[str, str]]:
return parse_qsl(urlsplit(url).query, keep_blank_values=True)
def test_keyword_only_uses_default_sort_without_params():
url = build_search_url(SearchRequest(keyword="nintendo switch"))
assert url == "https://search.rakuten.co.jp/search/mall/nintendo%20switch/"
def test_genre_without_keyword_uses_placeholder_segment():
url = build_search_url(SearchRequest(genre_id="565950"))
assert urlsplit(url).path == "/search/mall/-/565950/"
def test_keyword_and_genre_are_both_path_segments():
url = build_search_url(SearchRequest(keyword="switch", genre_id="565950"))
assert urlsplit(url).path == "/search/mall/switch/565950/"
def test_page_one_is_omitted_but_later_pages_are_explicit():
assert "p=" not in build_search_url(SearchRequest(keyword="a", page=1))
assert ("p", "3") in query_of(build_search_url(SearchRequest(keyword="a", page=3)))
@pytest.mark.parametrize(
("sort", "expected"),
[
(SortOption.STANDARD, None),
(SortOption.PRICE_ASC, "2"),
(SortOption.PRICE_DESC, "3"),
(SortOption.NEWEST, "4"),
(SortOption.REVIEW_COUNT, "5"),
(SortOption.REVIEW_SCORE, "6"),
(SortOption.PRICE_WITH_SHIPPING_ASC, "11"),
(SortOption.PRICE_WITH_SHIPPING_DESC, "12"),
],
)
def test_sort_maps_to_site_codes(sort, expected):
"""排序码取自站点前端 bundle 的枚举,改动会直接影响返回顺序"""
query = dict(query_of(build_search_url(SearchRequest(keyword="a", sort=sort))))
assert query.get("s") == expected
def test_condition_and_bool_filters_share_repeatable_f_param():
url = build_search_url(
SearchRequest(
keyword="a",
condition=ItemCondition.USED,
free_shipping=True,
include_sold_out=True,
has_review=True,
next_day_delivery=True,
super_deal=True,
)
)
f_values = sorted(value for key, value in query_of(url) if key == "f")
assert f_values == sorted(["100", "0", "2", "4", "12", "13"])
def test_advanced_filters_are_mapped_to_site_param_names():
url = build_search_url(
SearchRequest(
keyword="a",
min_price=1000,
max_price=5000,
shop_id=272415,
exclude_keyword="中古",
title_only=True,
or_query=True,
min_review_score=4,
tags=["1001", "1002"],
)
)
query = dict(query_of(url))
assert query["min"] == "1000"
assert query["max"] == "5000"
assert query["sid"] == "272415"
assert query["nitem"] == "中古"
assert query["sf"] == "1"
assert query["st"] == "O"
assert query["review"] == "4"
assert query["tg"] == "1001,1002"
def test_search_request_requires_a_target():
with pytest.raises(ValueError):
SearchRequest()
def test_search_request_rejects_inverted_price_range():
with pytest.raises(ValueError):
SearchRequest(keyword="a", min_price=5000, max_price=1000)
def test_normalize_search_url_keeps_url_untouched_on_first_page():
raw = "https://search.rakuten.co.jp/search/mall/switch/?s=2&f=2"
assert normalize_search_url(raw, 1) == raw
def test_normalize_search_url_overrides_existing_page():
raw = "https://search.rakuten.co.jp/search/mall/switch/?p=9&s=2"
query = dict(query_of(normalize_search_url(raw, 4)))
assert query["p"] == "4"
assert query["s"] == "2"
def test_normalize_search_url_rejects_foreign_host():
with pytest.raises(InvalidRequestError):
normalize_search_url("https://example.com/search/mall/switch/", 1)
def test_build_item_url_joins_shop_and_item_code():
assert build_item_url("edion", "4902370549263") == "https://item.rakuten.co.jp/edion/4902370549263/"
def test_split_item_url_extracts_shop_and_item_code():
assert split_item_url("https://item.rakuten.co.jp/edion/4902370549263/?variantId=x") == (
"edion",
"4902370549263",
)
def test_split_item_url_rejects_foreign_host():
with pytest.raises(InvalidRequestError):
split_item_url("https://www.rakuten.co.jp/edion/4902370549263/")
def test_item_url_parts_returns_empty_for_ad_redirect_links():
"""广告位链接指向跳转域,解析不出来应静默返回空串而不是抛异常"""
assert item_url_parts("https://grp07.ias.rakuten.co.jp/redirect_rpp/?s=abc") == ("", "")