commit 3527975794a7895046afba6c7a65cb460bca68df Author: Jerry Yan <792602257@qq.com> Date: Mon Jul 27 10:34:53 2026 +0800 Init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5aceb2c --- /dev/null +++ b/.env.example @@ -0,0 +1,46 @@ +# 服务监听地址,通常本地用 127.0.0.1,容器/服务器用 0.0.0.0 +RAKUTEN_APP_HOST=0.0.0.0 +# 服务监听端口 +RAKUTEN_APP_PORT=31107 +# 运行环境:dev / prod / test +RAKUTEN_APP_ENV=dev + +# 日志级别:DEBUG / INFO / WARNING / ERROR +RAKUTEN_LOG_LEVEL=INFO +# 是否将日志写入文件(dev 默认 false,prod 默认 true;可显式覆盖) +RAKUTEN_LOG_TO_FILE= +# 日志目录(相对项目根目录) +RAKUTEN_LOG_DIR=logs +# 日志切割策略(Loguru 语法):例如 100 MB / 1 day / 00:00 +RAKUTEN_LOG_ROTATION=100 MB +# 日志清理策略(Loguru 语法):例如 14 days / 30 days +RAKUTEN_LOG_RETENTION=14 days +# 日志压缩格式:zip / gz / tar.gz;留空表示不压缩 +RAKUTEN_LOG_COMPRESSION=zip + +# Bearer Token 鉴权密钥:请求需携带 Authorization: Bearer +RAKUTEN_BEARER_TOKEN=REPLACE_WITH_TOKEN_32CHARS + +# 单次抓取超时(秒) +RAKUTEN_REQUEST_TIMEOUT_SECONDS=30 +# 对乐天站点的最大并发请求数 +RAKUTEN_MAX_SITE_CONCURRENCY=8 +# 单次抓取的最大尝试次数(含首次):1 次直发,2 次换 cookie 重试,3 次起动用浏览器兜底 +RAKUTEN_HTTP_MAX_ATTEMPTS=3 +# Akamai cookie 最长复用时长(秒),超时后重新预热 +RAKUTEN_SESSION_TTL_SECONDS=1800 + +# 浏览器兜底:纯 HTTP 被反爬拦截时用 Playwright 取回 cookie 再回灌重试。 +# 日常流量不会触发;未安装 playwright 时自动降级为不兜底(主链路不受影响)。 +RAKUTEN_BROWSER_FALLBACK_ENABLED=true +# 浏览器无头模式(留空表示 dev 有头、其他环境无头) +RAKUTEN_BROWSER_HEADLESS= +# 使用的浏览器通道(Windows 可填 chrome;留空使用 bundled chromium) +RAKUTEN_BROWSER_CHANNEL= + +# 代理地址(需要日本 IP 时配置,例如 http://127.0.0.1:7890) +RAKUTEN_PROXY_SERVER= +# 代理用户名(如代理需要认证则填写) +RAKUTEN_PROXY_USERNAME= +# 代理密码(如代理需要认证则填写) +RAKUTEN_PROXY_PASSWORD= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b377f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.venv/ +__pycache__/ +*.py[cod] +.env +logs/ +.pytest_cache/ +.idea/ +.probe/ + +.mcp.json +openapi.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..142e92b --- /dev/null +++ b/README.md @@ -0,0 +1,487 @@ +# Rakuten Scraper Service + +面向乐天集团两个购物站点的抓取 HTTP API 服务: + +| 站点 | 域名 | 形态 | +| --- | --- | --- | +| **乐天市场**(楽天市場) | `rakuten.co.jp` | B2C 商城:店铺 × 商品 × SKU | +| **ラクマ**(Rakuma) | `fril.jp` | C2C 二手集市:个人卖家 × 单件商品 | + +两站均支持**搜索**、**商品详情**、**商家信息**与**商家名下商品**。 + +## 抓取原理 + +两站的页面形态与防护完全不同,因此各走一条独立链路。 + +### 乐天市场:内联 JSON + Akamai + +搜索页与(手机版)商品详情页都把整页数据以 JSON 形式内联在 `window.__INITIAL_STATE__` 里, +因此不需要解析 DOM,直接取这段 JSON 即可拿到完整结构化数据。 + +站点前置 **Akamai Bot Manager**,**不需要真浏览器交互**: + +| 请求方式 | 实测耗时 | +| --- | --- | +| 请求头不完整、无 cookie | ~11s(与响应体大小无关,是限速而非封禁) | +| 完整浏览器请求头 + 复用 Akamai cookie | ~0.6–0.9s | + +所以主链路是纯 httpx,Playwright 仅作为被拦截时的兜底(取 cookie 回灌后重试)。 + +服务按「指纹画像」维护两条独立通道,各自持有独立 cookie 罐: + +- **PC 通道** → 搜索页 `search.rakuten.co.jp`、店铺页 `www.rakuten.co.jp` +- **手机通道** → 商品详情页 `item.rakuten.co.jp` + (详情页只有手机 UA 才返回带 `__INITIAL_STATE__` 的统一模板;PC UA 返回的是各店铺自定义的 EUC-JP 老页面) + +### ラクマ:服务端渲染 HTML,无限速 + +ラクマ 是 Rails 服务端渲染的传统 HTML,**没有** `__INITIAL_STATE__` 这类内联状态, +只能解析 DOM。好在页面上挂了成套的埋点属性,比可见文案稳定得多,也带有 DOM 上 +没有的字段(商品数值 ID、卖家 ID、分类/品牌 ID),解析优先取这些: + +- `data-gtm-click` / `onclick` 里的 dataLayer JSON — 商品卡片的结构化字段 +- 页面级 `data-rat-cp-*` 属性 — 成色、运费负担、发货地(已售出商品的规格表会消失,靠它兜底) +- `', re.S +) +# 商品主图:站点用 slider 展示,主图挂在 .sp-image 上(推荐位的图不在其中) +_MAIN_IMAGE_SELECTOR = ".sp-slide img.sp-image, .item-photos img, .slider img.sp-image" + +# 规格表里 th 图标 class 上的稳定键 → 模型字段 +_SPEC_KEYS = { + "item-status-status": "condition", + "item-status-size": "size", + "item-status-carriage": "shipping_payer", + "item-status-delivery_method": "shipping_method", + "item-status-delivery_date": "shipping_date_estimate", + "item-status-delivery_area": "shipping_from", +} + +# 站点上表示「没有填写该项」的占位文案 +_SPEC_EMPTY_VALUES = ("なし", "未定", "指定なし", "-", "―") + +# 规格表缺失时的兜底:页面级埋点属性 data-rat-cp-{key} → 模型字段。 +# 注意这组值的措辞与规格表不完全一致(如运费负担规格表写「送料込」, +# 埋点写「出品者」),原样透出,不做归一。 +_RAT_SPEC_KEYS = { + "condition": "item_condition", + "shipping_payer": "shipping_cost_payer", + "shipping_date_estimate": "shipping_date_estimate", + "shipping_from": "shipping_from", +} + + +def _parse_ld_product(html: str) -> dict[str, Any]: + """取出 ld+json 里的 Product 节点""" + for match in _LD_JSON_RE.finditer(html): + try: + data = json.loads(match.group(1)) + except ValueError: + continue + if isinstance(data, dict) and data.get("@type") == "Product": + return data + return {} + + +def _parse_specs(tree: HTMLParser, rat: dict[str, str]) -> dict[str, str]: + """解析商品情報规格表 + + 每行的 th 里有个 ``, + 这个 key 比日文标签稳定,用它做映射。 + + 已售出商品的页面会换成另一套布局、规格表整体消失,此时退回页面级埋点 + 属性——它给的项少一些(没有配送方法与尺码),但成色、运费负担与发货地 + 仍在,好过整片留空。 + """ + specs: dict[str, str] = {} + for row in tree.css("table.item__details tr"): + icon = row.css_first("th i") + value_node = row.css_first("td") + if icon is None or value_node is None: + continue + classes = attr(icon, "class").split() + field = next((_SPEC_KEYS[name] for name in classes if name in _SPEC_KEYS), None) + if field is None: + continue + value = node_text(value_node) + specs[field] = "" if value in _SPEC_EMPTY_VALUES else value + + for field, key in _RAT_SPEC_KEYS.items(): + if not specs.get(field) and rat.get(key): + specs[field] = rat[key] + return specs + + +def _parse_breadcrumbs(tree: HTMLParser) -> tuple[list[Breadcrumb], str]: + """解析分类面包屑,并返回最具体的一级分类 ID + + 取规格表里的分类行而非页头面包屑:页头那条会把品牌也混进来, + 规格表里的是纯分类链。 + """ + crumbs: list[Breadcrumb] = [] + category_id = "" + for row in tree.css("table.item__details tr"): + icon = row.css_first("th i") + if icon is None or "item-status-category" not in attr(icon, "class"): + continue + for link in row.css("td a"): + url = attr(link, "href") + crumbs.append(Breadcrumb(name=node_text(link), url=url)) + segments = [segment for segment in url.split("/") if segment] + if segments: + category_id = segments[-1] + break + return crumbs, category_id + + +def _parse_seller(tree: HTMLParser, payload: dict[str, Any]) -> RakumaSeller: + """解析出品者信息块""" + link = tree.css_first("a.shop_link, a[href*='/shop/']") + shop_url = attr(link, "href") + shop_id = "" + if shop_url: + try: + shop_id = split_shop_url(shop_url) + except Exception: + shop_id = "" + + seller_user_id = payload.get("seller_user_id") + return RakumaSeller( + shop_id=shop_id, + user_id=str(seller_user_id) if seller_user_id is not None else "", + shop_name=node_text(tree.css_first(".header-shopinfo__shop-name")), + user_name=node_text(tree.css_first(".header-shopinfo__user-name")), + shop_url=shop_url, + icon_url=image_url(tree.css_first(".header-shopinfo__user-icon img")), + seller_type=str(payload.get("seller_user_type") or ""), + review_score=parse_float(node_text(tree.css_first(".shop_score__score"))), + # 商品页只给评分不给评价数,需要评价数请调 /api/rakuma/shop_detail + is_verified=tree.css_first(".header-shopinfo__verified-badge-item") is not None, + ) + + +def parse_item_detail(html: str, *, item_id: str, item_url: str) -> RakumaItemDetailData: + """把商品详情页 HTML 解析为商品详情 + + Raises: + ScrapeParseError: 页面不是商品详情页 + """ + tree = HTMLParser(html) + info = tree.css_first(f".{site.ITEM_PAGE_MARKER}") + if info is None: + raise ScrapeParseError("页面不是商品详情页(缺少商品信息区块)") + + product = _parse_ld_product(html) + # 这段埋点挂在哪个元素上因页面状态而异,按属性全页扫描 + payload = find_item_payload(tree) + rat = rat_params(html) + specs = _parse_specs(tree, rat) + breadcrumbs, category_id = _parse_breadcrumbs(tree) + + images = [ + url + for url in dict.fromkeys(image_url(node) for node in tree.css(_MAIN_IMAGE_SELECTOR)) + if url and "img.fril.jp" in url + ] + if not images and isinstance(product.get("image"), str): + images = [product["image"]] + + # ld+json 的 availability 对已售商品仍写 InStock,只能按页面标记判断 + page_text = info.text() + is_sold_out = any(marker in page_text for marker in site.SOLD_OUT_MARKERS) + + brand = product.get("brand") if isinstance(product.get("brand"), dict) else {} + offers = product.get("offers") if isinstance(product.get("offers"), dict) else {} + + return RakumaItemDetailData( + item_id=item_id, + item_number=str(payload.get("item_id") or ""), + item_name=str(product.get("name") or "") or node_text(tree.css_first("h1.item__name")), + description=str(product.get("description") or "") + or node_text(tree.css_first(".item__description__line-limited")), + item_url=item_url, + price=parse_int(offers.get("price")) or parse_int(node_text(tree.css_first(".item__price"))), + is_sold_out=is_sold_out, + images=images, + condition=specs.get("condition", ""), + size=specs.get("size", ""), + brand_id=str(payload.get("brand_id") or "") or rat.get("brand_id", ""), + brand_name=str(brand.get("name") or "") or str(payload.get("brand_name") or ""), + category_id=category_id or str(payload.get("category_id") or ""), + breadcrumbs=breadcrumbs, + shipping_payer=specs.get("shipping_payer", ""), + shipping_method=specs.get("shipping_method", ""), + shipping_date_estimate=specs.get("shipping_date_estimate", ""), + shipping_from=specs.get("shipping_from", ""), + is_anonymous_shipping=tree.css_first(".item__icon.anonymous") is not None, + like_count=parse_int(node_text(tree.css_first(".like_button_set"))), + comment_count=parse_int(node_text(tree.css_first(".go-to-comment-button"))), + posted_at=node_text(tree.css_first(".time_ago")), + seller=_parse_seller(tree, payload), + ) diff --git a/app/parsers/rakuma/search.py b/app/parsers/rakuma/search.py new file mode 100644 index 0000000..fab289b --- /dev/null +++ b/app/parsers/rakuma/search.py @@ -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.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, + ) diff --git a/app/parsers/rakuma/shop.py b/app/parsers/rakuma/shop.py new file mode 100644 index 0000000..1d627e4 --- /dev/null +++ b/app/parsers/rakuma/shop.py @@ -0,0 +1,223 @@ +"""ラクマ 店铺页 HTML → 卖家详情与卖家商品列表 + +C2C 集市里的「商家」就是个人卖家,页面在 fril.jp/shop/{hash}: +- 店铺页本身:卖家资料 + 该卖家的商品分页列表(含已售出) +- /review 子页:评价明细与好评/普通/差评分档计数 + +商品卡片与搜索页共用 `.item-box` 结构,直接复用 search 里的解析。 +""" +from __future__ import annotations + +import json +import re + +from selectolax.parser import HTMLParser + +from app.core import rakuma_site as site +from app.core.errors import ScrapeParseError +from app.models.scrape import ( + RakumaRatingBreakdown, + RakumaReview, + RakumaShopDetailData, + RakumaShopItemsData, +) +from app.parsers.rakuma.base import ( + attr, + event_payload, + image_url, + node_text, + parse_float, + parse_int, + parse_total_count, +) +from app.parsers.rakuma.search import parse_item_cards + +# 评价条目标题左侧的图标 class → 评价档位 +_RATING_ICONS = { + "icon_review_sun": "good", # よい + "icon_review_cloud": "normal", # ふつう + "icon_review_rain": "bad", # わるい +} + +# /review 页上三组分档计数的容器 id 前缀 +_ALL_RATINGS_PREFIX = "all" +_SELLER_RATINGS_PREFIX = "seller" + +_LD_JSON_RE = re.compile( + r']*type="application/ld\+json"[^>]*>(.*?)', re.S +) + + +def _require_shop_page(html: str) -> HTMLParser: + tree = HTMLParser(html) + if tree.css_first(f".{site.SHOP_PAGE_MARKER}") is None: + raise ScrapeParseError("页面不是店铺页(缺少店铺资料区块)") + return tree + + +def _parse_rating_breakdown(tree: HTMLParser, prefix: str) -> RakumaRatingBreakdown: + """解析一组好评/普通/差评计数 + + 页面用 `