"""ラクマ 搜索页 / 店铺商品列表 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.scraping.core import rakuma_site as site from app.shared.errors import ScrapeParseError from app.scraping.models.scrape import RakumaSearchItem, RakumaSearchResultData from app.scraping.parsers.rakuma.base import ( attr, event_payload, image_url, node_text, parse_int, parse_total_count, ) from app.scraping.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, )