把需要账号登录态的链路从抓取服务里拆出成独立进程。分界线不是「要不要登录」, 而是抓取无状态、幂等、可多开实例,而交易的写操作不可逆、登录态全局唯一、 订单监控是常驻轮询——同进程时抓取一扩容就会复制出 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>
136 lines
4.8 KiB
Python
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.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,
|
|
)
|