拆分抓取与交易服务
把需要账号登录态的链路从抓取服务里拆出成独立进程。分界线不是「要不要登录」, 而是抓取无状态、幂等、可多开实例,而交易的写操作不可逆、登录态全局唯一、 订单监控是常驻轮询——同进程时抓取一扩容就会复制出 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>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
"""ラクマ 搜索页 / 店铺商品列表 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,
|
||||
)
|
||||
Reference in New Issue
Block a user