Files
rakuten-api/app/parsers/rakuma/search.py
T
2026-07-27 10:34:53 +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.core import rakuma_site as site
from app.core.errors import ScrapeParseError
from app.models.scrape import RakumaSearchItem, RakumaSearchResultData
from app.parsers.rakuma.base import (
attr,
event_payload,
image_url,
node_text,
parse_int,
parse_total_count,
)
from app.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,
)