Files
rakuten-api/tests/test_rakuma.py
T
2026-07-27 10:34:53 +08:00

286 lines
10 KiB
Python

"""ラクマ(fril.jp)解析测试:基于真实页面裁剪出的夹具
夹具保留了解析所依赖的结构(微数据、规格表、埋点属性、商品卡片),
去掉了广告位与推荐位等噪音,全部离线运行。
"""
from pathlib import Path
import pytest
from app.core.errors import InvalidRequestError, ScrapeParseError
from app.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 (
build_item_url,
build_search_url,
build_shop_url,
normalize_search_url,
split_item_url,
split_shop_url,
)
FIXTURES = Path(__file__).parent / "fixtures"
def fixture(name: str) -> str:
return (FIXTURES / name).read_text(encoding="utf-8")
# ---- URL 构建 ----
def test_search_url_carries_keyword_and_default_sort():
url = build_search_url(RakumaSearchRequest(keyword="switch"))
assert "query=switch" in url
# 站点的排序由 sort + order 两个参数共同决定,默认是相关度降序
assert "sort=relevance" in url and "order=desc" in url
def test_search_url_maps_filters_to_site_codes():
"""筛选码必须与站点码表一致——站点对不认识的取值会静默返回首页"""
url = build_search_url(
RakumaSearchRequest(
keyword="switch",
category_id="788",
brand_id="5296",
min_price=3000,
max_price=20000,
conditions=[RakumaCondition.NEW, RakumaCondition.ALMOST_NEW],
transaction=RakumaTransaction.ON_SALE,
free_shipping=True,
anonymous_shipping=True,
authenticity_types=[RakumaAuthenticity.BEFORE_DELIVERY],
sort=RakumaSortOption.PRICE_ASC,
page=3,
)
)
assert "category_id=788" in url
assert "brand_id=5296" in url
assert "min=3000" in url and "max=20000" in url
assert "statuses=5%2C4" in url # 多选成色用逗号连接
assert "transaction=selling" in url
assert "carriage=1" in url
assert "anonymous_shipping=true" in url
assert "authenticity_types=pre" in url
assert "sort=sell_price" in url and "order=asc" in url
assert "page=3" in url
def test_search_url_except_for_no_brand_overrides_brand_id():
"""站点前端把两者做成互斥分支,勾选排除无品牌时不下发 brand_id"""
url = build_search_url(
RakumaSearchRequest(keyword="a", brand_id="5296", except_for_no_brand=True)
)
assert "except_for_no_brand=true" in url
assert "brand_id" not in url
def test_search_url_omits_page_on_first_page():
assert "page=" not in build_search_url(RakumaSearchRequest(keyword="switch"))
def test_search_requires_a_target():
with pytest.raises(ValueError):
RakumaSearchRequest()
def test_exclude_keyword_requires_a_keyword():
"""站点在无关键词时不接受排除词,提前拦下而不是打一次无效请求"""
with pytest.raises(ValueError):
RakumaSearchRequest(category_id="788", exclude_keyword="本体")
def test_normalize_search_url_overrides_page():
url = normalize_search_url("https://fril.jp/s?query=switch&page=2", 5)
assert "page=5" in url and "page=2" not in url
def test_normalize_search_url_rejects_foreign_host():
with pytest.raises(InvalidRequestError):
normalize_search_url("https://example.com/s?query=x", 1)
def test_item_and_shop_url_round_trip():
item_id = "4aca1d6db3e422f3a251a8a8b61e1eff"
assert split_item_url(build_item_url(item_id)) == item_id
shop_id = "422750cb7921557bc8dba2416915d968"
assert split_shop_url(build_shop_url(shop_id)) == shop_id
def test_shop_url_supports_paging_and_review_page():
shop_id = "abc123"
assert build_shop_url(shop_id, page=2).endswith("?page=2")
assert build_shop_url(shop_id, review=True).endswith("/review")
def test_split_urls_reject_wrong_host_or_path():
with pytest.raises(InvalidRequestError):
split_item_url("https://fril.jp/item/abc") # 商品页在 item.fril.jp 子域
with pytest.raises(InvalidRequestError):
split_shop_url("https://fril.jp/brand/5296")
# ---- 搜索页解析 ----
def test_parse_search_reads_items_and_exact_total():
result = parse_search(
fixture("rakuma_search.html"), request_url="u", page=1, keyword="switch"
)
assert len(result.items) == 3
# 页面上展示的是四舍五入值(約1,190,000件),精确值取自埋点属性
assert result.total_count == 1196774
assert result.request_url == "u"
item = result.items[0]
assert item.item_id == "61b174d42b58216828a24b86b7903ced"
assert item.item_number == "844649627"
assert item.price == 6299
assert item.is_sold_out is True
assert item.brand_id == "5296"
assert item.brand_name == "Nintendo Switch"
assert item.category_id == "788"
assert item.seller_user_id == "12073120"
assert item.image_url.startswith("https://img.fril.jp/")
def test_parse_search_rejects_non_search_page():
"""站点对无法识别的筛选取值会静默返回首页,必须识别出来而不是当成空结果"""
with pytest.raises(ScrapeParseError):
parse_search("<html><body>home</body></html>", request_url="u", page=1, keyword="x")
def test_parse_search_has_more_stops_at_site_page_limit():
"""站点 page>100 直接 404,第 100 页不应再报 has_more"""
result = parse_search(
fixture("rakuma_search.html"), request_url="u", page=100, keyword="switch"
)
assert result.has_more is False
# ---- 商品详情解析 ----
def test_parse_item_detail_reads_all_blocks():
detail = parse_item_detail(fixture("rakuma_item.html"), item_id="X", item_url="u")
assert detail.item_id == "X"
assert detail.item_number == "844877033"
assert detail.item_name == "NintendoSwitch ONE PIECE 海賊無双4"
assert detail.price == 2460
assert detail.is_sold_out is False
assert detail.condition == "目立った傷や汚れなし"
assert detail.brand_id == "6454"
assert detail.category_id == "788"
assert detail.shipping_from == "北海道"
assert detail.shipping_method == "かんたんラクマパック(ヤマト運輸)"
assert detail.is_anonymous_shipping is True
assert [crumb.name for crumb in detail.breadcrumbs] == [
"エンタメ/ホビー",
"ゲームソフト/ゲーム機本体",
"家庭用ゲームソフト",
]
assert detail.images and all("img.fril.jp" in url for url in detail.images)
assert detail.seller.shop_id == "83b56a796a72011dc58b6e469270dfb7"
assert detail.seller.user_id == "3850223"
assert detail.seller.is_verified is True
def test_parse_item_detail_falls_back_to_tracking_attrs_when_sold_out():
"""已售出商品的页面换了套布局、规格表消失,需退回页面级埋点属性取值"""
detail = parse_item_detail(fixture("rakuma_item_sold.html"), item_id="Y", item_url="u")
assert detail.is_sold_out is True
assert detail.item_number == "844649627"
assert detail.condition == "新品、未使用" # 规格表已消失,取自埋点
assert detail.shipping_from == "千葉県"
assert detail.brand_id == "5296"
# 价格与名称仍由 ld+json 提供,不受布局变化影响
assert detail.price == 6299
assert detail.item_name.startswith("スプラトゥーン")
def test_parse_item_detail_rejects_non_item_page():
with pytest.raises(ScrapeParseError):
parse_item_detail("<html><body>nope</body></html>", item_id="X", item_url="u")
# ---- 店铺解析 ----
def test_parse_shop_detail_reads_profile_and_rating():
detail = parse_shop_detail(
fixture("rakuma_shop.html"), shop_id="S", shop_url="u"
)
assert detail.shop_name == "(^.^)"
assert detail.user_id == "12073120"
# 评价数只有 ld+json 的 Store 节点给得出来,可见 DOM 上没有
assert detail.review_score == 5.0
assert detail.review_count == 1886
assert detail.item_count == 21
assert detail.is_verified is True
assert detail.verification_label == "本人確認済"
assert detail.introduction
def test_parse_shop_detail_reads_reviews_when_supplied():
detail = parse_shop_detail(
fixture("rakuma_shop.html"),
shop_id="S",
shop_url="u",
review_html=fixture("rakuma_shop_review.html"),
)
assert detail.rating_breakdown.good == 118
assert detail.rating_breakdown.bad == 0
assert detail.seller_rating_breakdown.good == 110
assert len(detail.reviews) == 3
first = detail.reviews[0]
assert first.rating == "good"
assert first.reviewer_name == "トシちゃん"
assert first.reviewed_at == "2026/05/04"
def test_parse_shop_detail_leaves_reviews_empty_by_default():
"""不传评价页时不应臆造分档数据"""
detail = parse_shop_detail(fixture("rakuma_shop.html"), shop_id="S", shop_url="u")
assert detail.reviews == []
assert detail.rating_breakdown.good == 0
def test_parse_shop_items_reads_cards_and_total():
result = parse_shop_items(
fixture("rakuma_shop.html"), shop_id="S", request_url="u", page=1
)
assert result.shop_id == "S"
assert result.shop_name == "(^.^)"
assert result.total_count == 21
assert len(result.items) == 2
assert result.items[0].item_id == "61546447a7f13c34a5d015f37a7f3319"
assert result.items[0].price == 6399
def test_parse_shop_pages_reject_non_shop_page():
with pytest.raises(ScrapeParseError):
parse_shop_detail("<html><body>x</body></html>", shop_id="S", shop_url="u")
with pytest.raises(ScrapeParseError):
parse_shop_items("<html><body>x</body></html>", shop_id="S", request_url="u", page=1)
# ---- 取值工具 ----
@pytest.mark.parametrize(
"text,expected",
[("¥6,299", 6299), ("いいね 12件", 12), ("6399", 6399), ("", 0), (None, 0)],
)
def test_parse_int_handles_site_formats(text, expected):
assert parse_int(text) == expected
def test_parse_total_count_handles_nbsp_and_rounding_prefix():
assert parse_total_count("約1,190,000件中 1\xa0-\xa040件") == (1190000, 1, 40)
assert parse_total_count("21件中 1 - 21件") == (21, 1, 21)
assert parse_total_count("no numbers here") == (0, 0, 0)