Files

731 lines
38 KiB
Markdown

# Rakuten Scraper Service
面向乐天集团两个购物站点的 HTTP API 服务:
| 站点 | 域名 | 形态 |
| --- | --- | --- |
| **乐天市场**(楽天市場) | `rakuten.co.jp` | B2C 商城:店铺 × 商品 × SKU |
| **ラクマ**(Rakuma) | `fril.jp` | C2C 二手集市:个人卖家 × 单件商品 |
两站均支持**搜索**、**商品详情**、**商家信息**与**商家名下商品**。
## 三个部署单元
同一个仓库出**三个服务**,分进程运行:
| | 抓取服务 `app.scraping` | 交易服务 `app.trading` | 下单任务网关 `app.gateway` |
| --- | --- | --- | --- |
| 启动 | `python -m app.scraping.main`(:31107) | `python -m app.trading.main`(:31108) | `python -m app.gateway.main`(:31109) |
| 部署位置 | 服务器 | 本地(NAT 后) | 服务器 |
| 账号 | 全程匿名 | 必须带登录态 cookie | 不接触账号 |
| 状态 | 无状态,请求-响应 | 有状态:订单、页面证据、付款进度 | 有状态:任务队列 + 状态镜像 |
| 失败重试 | 幂等,重试无代价 | **不可逆**,重复提交即重复下单 | 任务编排侧,**不自动重投** |
| 实例数 | 想开几个开几个 | **只能一个**(或按账号分片) | **只能一个**(SQLite + 全局并发度 1) |
| 出口 IP | 被限速换掉即可 | 频繁漂移会触发风控 | 不出站到站点 |
抓取与交易拆开的决定性理由是「实例数」那一行,而不是「要不要登录」:登录态 cookie
全局唯一、订单监控是常驻轮询,一旦与抓取同进程,抓取横向扩容就会把登录态和轮询任务
复制 N 份,让同一个账号被多个进程并发操作。
网关单独成第三个部署单元,是因为它**有状态**(任务队列),不能塞进可多开的抓取服务,
也不能塞进在本地、零入站端口的交易服务——本地通过出站长轮询从这里领任务。
完整规格见 [docs/order-gateway.md](docs/order-gateway.md)。
依赖方向固定为 `scraping → shared``trading → shared``gateway → shared`,三方互不 import
`tests/test_architecture.py` 会守着)。交易侧需要商品信息时走抓取服务的 HTTP 接口,
需要任务调度时走网关的 HTTP 接口——下单要用的 `purchase` 块本来就是抓取服务的对外契约。
抓取服务部署在服务器,交易服务部署在本地(便于管理账号、排查支付问题),本地在 NAT 后
没有公网入口,因此下单请求不是推进来的,而是由本地长轮询主动领取。
任务网关与本地 worker 的规格见 [docs/order-gateway.md](docs/order-gateway.md)。
```
app/
shared/ 配置、错误码、日志、响应信封与鉴权、任务/订单状态枚举(三方共用,不认识任何一侧)
scraping/ 站点常量 / 会话 / 解析器 / 抓取路由(本 README 的绝大部分)
trading/ 登录态、加购下单付款(在建)与本地下单 worker(领任务、留证据)
gateway/ 下单任务队列 + 状态镜像 + 长轮询领取接口
```
## 抓取原理
两站的页面形态与防护完全不同,因此各走一条独立链路。
### 乐天市场:内联 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-*` 属性 — 成色、运费负担、发货地(已售出商品的规格表会消失,靠它兜底)
- `<script type="application/ld+json">` — 商品的 Product、店铺的 Store 微数据(评价数只有这里给)
实测**没有 Akamai 那类限速**:冷请求(无 cookie、无预热)即 0.5–1.1s,与预热后持平;
PC UA 在搜索页、详情页、店铺页上都能拿到完整模板。因此 ラクマ 只有一条通道、
不做 cookie 预热,也不接浏览器兜底。
> ⚠️ ラクマ 对**无法识别的筛选取值不会报错**,而是静默返回首页(如 `statuses=99` 返回 75KB 的首页 HTML)。
> 本服务的枚举值全部取自站点前端 bundle 里 `SearchPanel._url()` 的构造逻辑,并逐个实测过;
> 解析器也会校验页面确实是结果页,识别不出时报 4001 而不是当成「0 条结果」。
## 功能概览
- FastAPI 提供 HTTP API
- Bearer Token 接口鉴权(`Authorization: Bearer <token>`
- Akamai cookie 随目标页响应自动建立与复用,过期自动清空重建(仅乐天链路;正常路径不额外访问首页)
- 失败逐级升级:首页换一套 cookie → Playwright 兜底取 cookie → 结构化报错
- 搜索结果自动剔除混入的 CPC 广告位(乐天)
- 识别站点深翻页「静默回绕到第 1 页」的行为,避免上游重复入库(乐天)
- 商品详情按落地域名分派解析,覆盖楽天ブックス / Rakuten Fashion / ビックカメラ 等官方旗舰子站
## 接口
抓取服务(:31107):
| 接口 | 站点 | 说明 |
| --- | --- | --- |
| `GET /health` | — | 健康检查,含各抓取通道状态 |
| `POST /api/search` | 乐天 | 商品搜索 |
| `POST /api/genres` | 乐天 | 分类树 |
| `POST /api/item_detail` | 乐天 | 商品详情 |
| `POST /api/shop_detail` | 乐天 | 商家详情 |
| `POST /api/shop_items` | 乐天 | 商家名下商品 |
| `POST /api/rakuma/search` | ラクマ | 商品搜索 |
| `POST /api/rakuma/categories` | ラクマ | 分类树 |
| `POST /api/rakuma/item_detail` | ラクマ | 商品详情 |
| `POST /api/rakuma/shop_detail` | ラクマ | 卖家详情 |
| `POST /api/rakuma/shop_items` | ラクマ | 卖家名下商品 |
接口按站点分前缀而非合并加 `site` 参数,因为两站的筛选体系差异很大
(乐天有 `genre_id` / 成色 / SuperDEAL,ラクマ 有 `category_id` / `brand_id` / 匿名配送 / 鉴定服务),
合并会让大半字段对另一站无效。
交易服务(:31108):
| 接口 | 说明 |
| --- | --- |
| `GET /health` | 健康检查,含乐天账号登录态(只读缓存,不打站点)与浏览器连接状态;浏览器掉线时返回 **503** |
| `POST /api/auth/status` | 查询登录态,默认真实探测一次 |
| `POST /api/auth/login` | 按 `account.yaml` 自动登录(已登录则跳过;撞验证码要人工接管) |
| `POST /api/auth/reload` | 人工重新登录后免重启换上新 cookie |
| `POST /api/cart/add` | 加购(`item_url` + 数量/规格/选项) |
| `POST /api/cart/status` | 购物车件数与登录态(轻量,不渲染整页) |
| `POST /api/cart/clear` | 清空购物车 |
| `POST /api/cart/remove` | 删除指定 `item_id` |
下单任务网关(:31109):
| 接口 | 说明 |
| --- | --- |
| `GET /health` | 健康检查,含 worker 心跳、长时间无人领任务告警 |
| `POST /api/orders` | 上游提交下单意图(幂等);`intent.items` 支持一次购买多个商品,旧版 `intent.item_url` 仍兼容 |
| `GET /api/orders/lease` | 本地 worker 长轮询领取(全局并发度 1) |
| `POST /api/orders/{id}/renew` | 续租(worker 在长任务里每 60s 调一次) |
| `POST /api/orders/{id}/report` | 本地回报订单状态(同 state 重复上报幂等) |
| `POST /api/orders/{id}/reclaim` | 把 stale 任务重新租给 worker(**绝不自动重投**) |
| `GET /api/orders/{id}` | 任务详情 + 完整状态历史 |
| `GET /api/orders` | 任务列表(运维与上游对账用) |
| `POST /api/account/queries` | 提交**账号只读查询**(订单列表 / 单笔详情,幂等) |
| `GET /api/account/queries/lease` | 本地 worker 领取查询单(可安全重投,与下单任务相反) |
| `POST /api/account/queries/{id}/result` | 本地回报查询结果(站点真实订单数据) |
| `GET /api/account/queries/{id}` | 取查询单状态与结果 |
| `GET /api/account/queries` | 查询单列表(运维排查) |
网关的契约与状态机详见 [docs/order-gateway.md](docs/order-gateway.md)
(账号只读查询通道见该文档 §11,用于「从已登录账号拉取真实订单」)。
三个服务共用同一个 Bearer Token,错误码表也是同一份。
启动后分别在 `http://127.0.0.1:31107/docs``:31108/docs``:31109/docs` 查看 OpenAPI 文档。
要一份能直接导入 Apifox / Postman 的合并文档,用仓库根的 `openapi.json`:三个服务的接口都在里面,
每条接口带 operation 级 `servers`(导入后不必手动切端口)与 Bearer 鉴权声明。它是 gitignore 的本地
生成物(不进版本库),由脚本产出,别手改:
```bash
.venv/Scripts/python.exe scripts/export_openapi.py # 重新导出
.venv/Scripts/python.exe scripts/export_openapi.py --check # 校验是否已最新
```
**改了接口记得手动重新导出**:因为文件不在版本库里,单测无法校验它是否过期(`tests/test_openapi_export.py`
只在内存里检查合并逻辑本身:三服务覆盖、operation 级 servers、鉴权标注、operationId 唯一、`$ref` 可解析)。
## 安装
```bash
uv sync --extra dev
# 可选:启用浏览器兜底(不装也能正常跑,只是失去兜底能力)
uv sync --extra dev --extra browser
.venv/Scripts/python.exe -m playwright install chromium
```
## 启动
启动前建议先按 `.env.example` 配置 `.env`,三个服务共用这一份。
```bash
# 抓取服务,默认 0.0.0.0:31107;可多开实例
.venv/Scripts/python.exe -m app.scraping.main
# 交易服务,默认 0.0.0.0:31108;只能起一个实例
.venv/Scripts/python.exe -m app.trading.main
# 下单任务网关,默认 0.0.0.0:31109;只能起一个实例(SQLite + 全局并发度 1)
.venv/Scripts/python.exe -m app.gateway.main
```
只需要抓取时不必起交易/网关服务。交易服务启动前要先人工登录一次:
```bash
.venv/Scripts/python.exe scripts/login.py --site all
```
浏览器窗口打开后手动完成登录(账号密码只在浏览器与站点之间传递,脚本不读取),
cookie 落在 `.auth/`(已 gitignore,内含可直接冒充账号的凭据,不要提交或外传)。
后续重新登录后调 `POST /api/auth/reload` 换上新 cookie,不必重启服务。
配了 `account.yaml` 的话不用每次手动登:`RAKUTEN_RELOGIN_ENABLED=true`(默认)时登录态失效会自动重登,
`RAKUTEN_AUTO_LOGIN_ON_START=true` 时服务启动就自己登一次,也可以随时调 `POST /api/auth/login` 触发。
撞 reCAPTCHA / 设备验证时流程会停在有头浏览器上等人工完成,等不到就超时失败——自动登录只是代填
`account.yaml` 里的凭据,不绕过站点校验。
## 容器部署
两个镜像,用途不能互换:
| 镜像 | Dockerfile | 跑什么 |
| --- | --- | --- |
| `rakuten-api` | `Dockerfile` | 抓取服务(无状态,可多开) |
| `rakuten-trading` | `Dockerfile.trading` | 有状态端:交易服务(默认)与下单网关(换 `command`) |
有状态端单独出镜像不是为了整洁:下单/结算必须用**有头** Chromium(`headless=True` 会让结算 SPA 失灵),
镜像里要带 Xvfb 虚拟显示、日文字体,以及给人工接管用的可选 x11vnc,抓取镜像没有这些。
```bash
# 同机联调:起抓取 API 与交易服务
docker compose up -d
# 正式拓扑:服务器侧只起抓取 API,本地机侧只起交易服务
docker compose up -d scraping
docker compose up -d trading
# 额外起下单网关(正式拓扑里它在服务器侧)
docker compose --profile gateway up -d
```
部署前置与挂载说明写在 `docker-compose.yml` 文件头:至少要有 `.env``account.yaml`
`.auth/``data/``logs/` 走挂载持久化。Jenkins 上两个镜像由同一条流水线产出
`BUILD_SCRAPING` / `BUILD_TRADING` 两个开关控制)。
抓取服务默认使用 `git.jerryyan.net/jp/rakuten-api:latest`,可通过
`RAKUTEN_SCRAPING_IMAGE``RAKUTEN_SCRAPING_TAG``RAKUTEN_SCRAPING_BIND`
分别覆盖镜像名、tag 和宿主机监听地址;默认只绑定 `127.0.0.1:31107`
## 测试
```bash
.venv/Scripts/python.exe -m pytest -q
```
单测全部离线运行:解析器用的是 `tests/fixtures/` 下从真实页面抓取并裁剪后的状态样本。
## 请求示例
以下 `/api/*` 为乐天市场接口,`/api/rakuma/*` 为 ラクマ 接口。
### 搜索(乐天)
`POST /api/search`
三种用法,优先级从高到低:
**1. 透传原始搜索 URL**`page` > 1 时会覆盖 URL 里的页码)
```json
{ "search_url": "https://search.rakuten.co.jp/search/mall/カメラ/?s=3&f=101", "page": 3 }
```
**2. 关键词 + 筛选**
```json
{
"keyword": "nintendo switch",
"page": 2,
"sort": "price_asc",
"min_price": 3000,
"max_price": 30000,
"free_shipping": true,
"condition": "used"
}
```
**3. 仅按分类**
```json
{ "genre_id": "565950" }
```
`keyword``genre_id``search_url` 三者至少提供一个。
#### 搜索参数
| 参数 | 类型 | 说明 |
| --- | --- | --- |
| `keyword` | string | 关键词 |
| `genre_id` | string | 乐天分类 ID,可与关键词叠加 |
| `page` | int | 页码,1–150 |
| `sort` | enum | `standard`(默认)/ `price_asc` / `price_desc` / `newest` / `review_count` / `review_score` / `price_with_shipping_asc` / `price_with_shipping_desc` |
| `min_price` / `max_price` | int | 价格区间(日元) |
| `shop_id` | int | 限定店铺,取搜索结果里的 `shop.shop_id` |
| `condition` | enum | `new` / `used` / `rental` |
| `free_shipping` | bool | 仅免运费 |
| `include_sold_out` | bool | 包含售罄商品 |
| `has_review` | bool | 仅有评论 |
| `next_day_delivery` | bool | 仅次日达 |
| `super_deal` | bool | 仅 SuperDEAL |
| `exclude_keyword` | string | 排除词 |
| `title_only` | bool | 仅在商品标题中匹配 |
| `or_query` | bool | 关键词之间用 OR 而非 AND |
| `min_review_score` | int | 最低评分 1–5 |
| `tags` | string[] | 站点标签 ID |
| `exclude_ads` | bool | 剔除 CPC 广告位,默认 `true` |
#### 分页注意事项
站点声明的命中总数(`total_count`)远大于实际可翻到的条数(`reachable_count`,随查询条件变化,
实测从 405 到 6750 不等)。**超出可达窗口时站点不会返回空列表,而是静默回绕到第 1 页**——
本服务会识别这种情况,把 `out_of_range` 置为 `true` 并清空 `items`,避免上游把重复数据当新数据入库。
翻页时以 `has_more` 为准即可。
### 分类(乐天)
`POST /api/genres`
用于取得 `/api/search` 需要的 `genre_id`,逐层下钻即可定位到叶子分类。
**顶层分类**(39 个):
```json
{}
```
**下钻某个分类**
```json
{ "genre_id": "101205" }
```
返回该分类自身(`name` / `full_name` / `description` / `is_leaf`)、祖先路径 `ancestors`
(从顶层到父级,不含自身,可直接拼面包屑)以及直接子分类 `children`
```
{} → 39 个顶层分类
101205 テレビゲーム → ancestors=[] children=27
565950 Nintendo Switch → ancestors=[テレビゲーム] children=3
566404 ソフト → ancestors=[テレビゲーム, Nintendo Switch] children=0, is_leaf=true
```
两点注意:
- 子分类的 `item_count` 是该分类下的商品数;**顶层列表不返回该值**,因为站点在那一层
给的是「当前查询在该分类下的命中数」,并非分类自身的商品总量。
- `full_name``description` 只有顶层分类页才提供,下级分类为空串。
### 商品详情(乐天)
`POST /api/item_detail`
`shop_code` + `item_code`(即商品 URL 的两段路径),或直接传 `item_url`
```json
{ "shop_code": "edion", "item_code": "4902370549263" }
```
```json
{ "item_url": "https://item.rakuten.co.jp/edion/4902370549263/" }
```
`include_sku_variants` 默认 `true`;SKU 组合可能多达数百条(实测有 180 条的商品),
不需要明细时置为 `false`,此时仍会保留 `sku.axis``sku.variant_count`
搜索结果中的 `shop.shop_code` + `item_code` 可直接用作本接口入参。
**下单前要决定的参数全在响应里**(规格与选项是两套东西,下单时走不同字段):
| 字段 | 下单时对应 | 说明 |
| --- | --- | --- |
| `sku.variants[].variant_id` | `intent.variant_id` | 规格组合(颜色 × 尺码等) |
| `options[]` | `intent.choice` | 店铺自定义选项,格式「选项名:取值名」 |
| `has_required_options` | — | `true` 时不给 `choice` 会被站点拒绝加购 |
| `unfillable_required_options` | — | 必填但无法自动选值的选项名,**必须**由调用方给值 |
`options[].values[].is_placeholder` 标出「選択してください」这类占位项——它们不是
合法取值,拼 `choice` 时要跳过(`selectable_value_count` 已是剔除占位项后的数量)。
`type="text"` 的选项是自由文本(如「【お名前】4文字まで」),站点不给候选值,
必填时只能由调用方给值。
部分官方店的商品页会跳转到独立子站,响应里的 `source` / `source_url` 会标明数据来源,
字段覆盖差异见下一节。
### 商家(乐天)
**商家详情** `POST /api/shop_detail`
`shop_code`(店铺首页 URL 的路径段),或直接传 `shop_url`
```json
{ "shop_code": "edion" }
```
返回店铺名、简介、评分与评价数、招牌图与 logo、是否 39ショップ、休息日等。
> 评价数过少时站点不展示评分,此时 `review_displayed` 为 `false`,`review_score` 不可信。
**商家名下商品** `POST /api/shop_items`
```json
{ "shop_id": 272415, "page": 2, "keyword": "テレビ", "sort": "price_asc" }
```
站点没有可分页抓取的「店铺内商品」页——店铺商品实际就是搜索结果按 `sid=` 限定店铺后的产物,
因此本接口内部转成一次搜索,**返回结构与 `/api/search` 完全一致**,也支持在店铺内继续按
关键词、分类、价格、成色筛选。
`shop_id``shop_code` 至少给一个;只给 `shop_code` 时服务端会先取一次店铺详情换出
`shop_id`(多一次请求),能直接给 `shop_id` 时优先给。`shop_id` 可从搜索结果的
`shop.shop_id``/api/shop_detail` 取得。
## 官方旗舰子站
部分乐天官方店的商品页会 302 跳出 `item.rakuten.co.jp`,落到各自独立的站点上。
这些站点技术栈各不相同,服务按落地域名分派到对应解析器,统一收敛成同一份
`ItemDetailData`;响应里的 `source` 标明数据来自哪个站点,`source_url` 是实际解析的地址。
| `source` | 店铺代码 | 落地域名 | 页面数据格式 |
| --- | --- | --- | --- |
| `ichiba` | 其余全部 | `item.rakuten.co.jp` | `__INITIAL_STATE__` |
| `books` | `book`(楽天ブックス) | `books.rakuten.co.jp` | schema.org 微数据 + DOM |
| `brandavenue` | `stylife`(Rakuten Fashion) | `brandavenue.rakuten.co.jp` | `__INITIAL_STATE__`(结构与市场页不同) |
| `biccamera` | `biccamera` | `biccamera.rakuten.co.jp` | `__NUXT__` |
落到**未登记**的站点时返回错误码 `4002``OFF_ICHIBA_REDIRECT`)并附上跳转目标地址,
便于上游区分「站点不支持」与「被反爬拦截」,而不是白白重试。
### 各来源字段覆盖差异
子站页面能提供的信息不同,以下字段并非各处都有:
| 字段 | `ichiba` | `books` | `brandavenue` | `biccamera` |
| --- | --- | --- | --- | --- |
| 名称 / 价格 / 图片 / 简介 | ✅ | ✅ | ✅ | ✅ |
| `genre_id` / `breadcrumbs` | ✅ | ✅ | ✅ | ✅ |
| `is_sold_out` | ✅ | ✅ | ✅ | ✅ |
| `review` | ✅ | ✅ | ❌ 异步加载 | ❌ 异步加载 |
| `sku.variants`(规格组合) | ✅ | — 图书无规格 | ✅ 颜色 × 尺码 | — 单一规格 |
| `sku.attributes`(规格表) | ✅ | ✅ 出版社/ISBN 等 | 挂在各 variant 上 | ❌ |
| `options`(店铺自定义选项) | ✅ | ❌ 未解析 | ❌ 未解析 | ❌ 未解析 |
| `shipping` 运费明细 | ✅ | ❌ 仅库存措辞 | ❌ | 仅「是否含运费」 |
| `shop.shop_id` | ✅ | ❌ | ✅ | ✅ |
| `breadcrumbs[].url` | ✅ | ✅ | ❌ 站内分类编码,拼不出链接 | ✅ |
| `purchase`(加购标识) | ✅ | ✅ 无件数/规格 | ✅ | ✅ 无 `shop_bid` |
抽样实测(6 个关键词 × 各 20 条,共 120 条)**整体 99% 的商品可正常解析详情**
(唯一失败是一条已下架商品,返回 4004):
| 类目关键词 | 可解析 |
| --- | --- |
| switch | 20/20 |
| カメラ | 20/20 |
| Tシャツ | 20/20 |
| 本 | 20/20 |
| 化粧品 | 20/20 |
| iphone | 19/20 |
来源分布:`ichiba` 91、`books` 19、`brandavenue` 8、`biccamera` 1。
## ラクマ 接口
ラクマ 是 C2C 二手集市,与乐天市场的商业形态不同,字段语义也不同,因此**模型不与市场侧合并**:
- 每件商品都是**独一无二的一件**:没有 SKU 组合、没有库存数量、没有起订量
- 「商家」就是**个人卖家**:没有店铺代码,用一串 hash 标识;没有运费门槛、店铺日历这些商城概念
- 成色由卖家自己申告,分 6 档(市场侧只有新品/中古/租赁 3 档)
### 搜索(ラクマ)
`POST /api/rakuma/search`
```json
{
"keyword": "switch",
"page": 2,
"sort": "price_asc",
"conditions": ["new", "almost_new"],
"transaction": "on_sale",
"min_price": 3000,
"max_price": 20000,
"free_shipping": true
}
```
也可以直接透传 `search_url``page` > 1 时覆盖 URL 里的页码)。
`keyword``category_id``brand_id``search_url` 四者至少提供一个。
| 参数 | 类型 | 说明 |
| --- | --- | --- |
| `keyword` | string | 关键词 |
| `category_id` | string | ラクマ 分类 ID(与乐天的 `genre_id` 是两套体系) |
| `brand_id` | string | ラクマ 品牌 ID |
| `page` | int | 页码,1–100(站点 `page > 100` 直接 404) |
| `sort` | enum | `standard`(默认)/ `newest` / `price_asc` / `price_desc` / `like_count` |
| `min_price` / `max_price` | int | 价格区间(日元) |
| `conditions` | enum[] | 成色,可多选:`new` / `almost_new` / `no_damage` / `slight_damage` / `damaged` / `poor` |
| `transaction` | enum | `on_sale` 仅在售 / `sold_out` 仅售罄;不传为不限 |
| `free_shipping` | bool | 仅「送料込み」(卖家承担运费) |
| `anonymous_shipping` | bool | 仅匿名配送 |
| `except_for_no_brand` | bool | 排除无品牌商品;与 `brand_id` 互斥 |
| `authenticity_types` | enum[] | 鉴定服务:`before_delivery` / `after_delivery` |
| `exclude_keyword` | string | 排除词,**必须与 `keyword` 同时使用**(站点在无关键词时不认该参数) |
每页固定 40 条。`total_count` 取自页面埋点里的精确值——页面上可见的「約1,190,000件」
是四舍五入后的展示值,不要拿它做分页计算;翻页以 `has_more` 为准。
### 分类(ラクマ)
`POST /api/rakuma/categories`
```json
{ "category_id": "10007", "include_descendants": true }
```
不传 `category_id` 返回 14 个顶层分类;传入后返回该分类的名称、`full_name` 路径名、
`ancestors` 祖先链与 `children` 直接子分类。分类共三层(14 顶层 / 169 二级 / 1503 三级),
拿到的 `category_id` 可直接用于 `/api/rakuma/search`
| 参数 | 类型 | 说明 |
| --- | --- | --- |
| `category_id` | string | 目标分类;不传取顶层列表。无效 ID 返回 404 |
| `include_descendants` | bool | `children` 里带完整子树而非只有直接子级,默认 `false` |
站点的分类一览页一次就把整棵树写进页面(与 URL 上的 `category_id` 无关),
因此不论查哪一层、要不要子树,服务端都只打一次请求——不像乐天 `/api/genres` 需要逐层下钻。
响应里的 `total_count` 是站点分类树的节点总数(当前 1686),可用于确认取到的是全量树。
> **本接口不返回商品数。** 站点的分类数据里没有这一项,只有 `/category/{id}` 列表页的埋点上有,
> 要逐个分类多打一次请求,成本与收益不匹配。需要某分类的商品数时,用该 `category_id`
> 调一次 `/api/rakuma/search` 取 `total_count`。
### 商品详情(ラクマ)
`POST /api/rakuma/item_detail`
```json
{ "item_id": "4aca1d6db3e422f3a251a8a8b61e1eff" }
```
`item_id`(商品 URL 的最后一段 hash)或 `item_url`。搜索结果里的 `item_id` 可直接用作入参。
返回名称、价格、描述、图片、成色、配送信息(负担方/方式/发货日/发货地)、
点赞与评论数,以及 `seller` 出品者摘要——其中的 `seller.shop_id` 可直接用于 `/api/rakuma/shop_detail`
> **已售出商品的页面会换一套布局,规格表整体消失。** 此时服务改从页面级埋点属性取值,
> 成色、运费负担、发货地仍能拿到,但**配送方式与尺码会为空**;价格与名称来自 ld+json,不受影响。
### 卖家(ラクマ)
**卖家详情** `POST /api/rakuma/shop_detail`
```json
{ "shop_id": "422750cb7921557bc8dba2416915d968", "include_reviews": true }
```
返回店铺名、昵称、头像与封面、简介、评分与评价数、本人确认状态(`is_verified`)、
以及该卖家的商品总数 `item_count`
`include_reviews` 默认 `false`;置为 `true` 时会并发多抓一次站点的评价子页,
额外返回最新 100 条评价 `reviews`,以及好评/普通/差评分档计数:
- `rating_breakdown` — 全部评价(出品 + 购入)
- `seller_rating_breakdown` — 仅出品方评价
**卖家名下商品** `POST /api/rakuma/shop_items`
```json
{ "shop_id": "422750cb7921557bc8dba2416915d968", "page": 2 }
```
按页返回该卖家的全部商品(**含已售出**,用每条的 `is_sold_out` 区分)。
店铺页每页 36 条(与搜索页的 40 条不同)。
站点在店铺页不提供排序与筛选参数,因此本接口**只有页码**;需要筛选请改用 `/api/rakuma/search`
### 两站字段对照
| 概念 | 乐天市场 | ラクマ |
| --- | --- | --- |
| 商品标识 | `shop_code` + `item_code` 两段 | `item_id` 单个 hash |
| 商家标识 | `shop_code`(如 `edion`)/ `shop_id`(数值) | `shop_id`(hash) |
| 分类 | `genre_id` + `/api/genres` 分类树(逐层下钻,带商品数) | `category_id` + `/api/rakuma/categories` 分类树(一次取全,无商品数) |
| 品牌 | — | `brand_id` |
| 成色 | 3 档(`new`/`used`/`rental`) | 6 档卖家申告 |
| 规格 | `sku.axis` + `sku.variants` | 无(单件商品,仅一个 `size` 字段) |
| 每页条数 | 45(搜索) | 40(搜索)/ 36(店铺页) |
| 翻页上限 | `reachable_count`,随查询变化(405~6750) | 固定 100 页 |
| 加购 | 由 trading 服务完成(见下) | 无(未实现) |
## 加购与下单(仅乐天市场)
加购与下单**完全在交易服务(`app/trading/`,:31108)内部完成**——交易服务用 Playwright
打开商品页(带账号 cookie),从 `__INITIAL_STATE__.purchase``basketDomain` 与字段,
构造表单 POST 到加购端点。抓取服务**不参与**这条链路,`/api/item_detail`
不再返回 `purchase` 块。
理由:httpx 在带账号的写操作上被 Rakuten TLS 指纹拦死(探针实测),必须用 Playwright;
既然 Playwright 已经打开商品页(为了发加购 POST 拿到 cookie 上下文),本地抽
`__INITIAL_STATE__.purchase` 比再发 HTTP 到抓取服务更快、数据更新鲜。
抓取服务只暴露**商品状态字段**,不暴露加购指令:
| 字段 | 含义 |
| --- | --- |
| `purchase_condition` | 站点原值,`enabled` 表示可购买 |
| `is_sold_out` | `purchase_condition != "enabled"` 即售罄 |
| `purchase_unit` | 起订单位 |
| `sku.inventory_type` | `multiple` 表示多规格,要看 `sku.variants[]` |
| `sku.variants[].variant_id` | 多规格商品的规格 ID(trading 加购多规格时按此选择) |
| `options[]` | 店铺自定义选项(必填项不给值站点拒绝加购),加购时走 `choice` 字段 |
| `unfillable_required_options` | 必填但无法自动选值的选项名,必须由调用方显式给值 |
交易服务对外接口(:31108,全部需要 Bearer token):
- `POST /api/cart/add` — 加购,入参 `{item_url, quantity?, variant_id?, choice?}`
- `POST /api/cart/status` — 调 cart count API,返回购物车商品件数
(空车是正常结果:`count=0, raw_status="101"`;获取失败才报错 5002)
- `POST /api/cart/clear` — 清空购物车(UI 点击 `button[aria-label="削除"]`
- `POST /api/cart/remove` — 删除指定 `item_id`
底层共用 `app/shared/purchase_contract.py` 的常量、字段构造与**选项解析**(与 scraping
模型解耦)——`/api/item_detail` 对外暴露的 `options[]` 和 trading 自动填 `choice` 用的
是同一份解析与占位项判定,避免「接口说能选的值」与「下单实际填的值」不一致。
trading 加购时的字段选择策略:多规格挑第一个非售罄的 variant;必填选项拼「名:值」,
取第一个**非占位**候选值(`values[0]` 往往是「選択してください」,填它等于没选)。
必填项自动填不出来(自由文本项、候选值只剩占位项)且调用方没给 `choice` 时当场报错
并点名是哪几项,不拿占位值凑数去撞站点。
站点端点 `basketDomain` 逐商品不同(实测有 `sp.basket…``ts.sp.basket…`),不能写死。
几点子站差异(仅信息,trading 不覆盖子站加购):
- **`books``item_id` 与 URL 上的商品编号不是同一个值**(如 URL `17065211` 对应 `item_id` `20600328`)。`/api/item_detail` 已经把表单里的 `item_id` 抽到顶层,调用方直接用即可。
- **`biccamera` 没有 `shop_bid`**,走自己的 JSON 接口;选项取值结构未取到样本验证。
- **`brandavenue` 的 cart 端点由 `cart_url_type` 编号映射得到**。
## 错误码
| code | 含义 | HTTP |
| --- | --- | --- |
| 0 | 成功 | 200 |
| 1001 | 鉴权失败 | 401 |
| 1002 | 请求参数校验失败 | 422 |
| 1003 | 请求参数不合法(如 URL 不属于目标站点) | 400 |
| 2002 | 抓取并发槽位等待超时 | 400 |
| 3001 | 上游网络异常 / 5xx | 400 |
| 3002 | 被反爬阻断 | 400 |
| 4001 | 页面解析失败(ラクマ 传了站点不认的筛选取值时也归此类) | 400 |
| 4002 | 商品页跳转至未登记的乐天子站 | 400 |
| 4004 | 商品/店铺不存在或已下架 | 404 |
| 5001 | 账号未登录或登录态失效(交易服务) | 401 |
| 5002 | 加购失败(交易服务) | 400 |
| 5003 | 下单失败(交易服务) | 400 |
| 5004 | 下单安全闸门未通过:未显式确认或金额超上限(交易服务) | 400 |
| 5005 | 结算被站点风控拦截:session upgrade / 3DS 等需人工验证(交易服务) | 400 |
| 5006 | Playwright 浏览器已掉线(交易服务;也是 `/health` degraded 时的 code) | 400 / 503 |
| 6001 | 任务不存在(网关) | 404 |
| 6002 | 租约无效:不是持有者、已过期或任务已终结(网关) | 409 |
| 6003 | 任务状态不允许该操作(如对已终结任务 reclaim)(网关) | 409 |
| 6004 | 已有任务在执行中,本次不发放(正常返回空,仅诊断用) | 200 |
| 6005 | 查询单不存在(网关,账号只读查询通道) | 404 |
| 6006 | 查询单租约无效(网关,账号只读查询通道) | 409 |
错误码在两站、三个服务之间通用。ラクマ 链路不会出现 `3002`(无反爬拦截行为)
`4002`(无子站跳转);`5xxx` 只会来自交易服务——抓取服务全程匿名,不会有登录态问题。
`5001``5004` 都标记为不可重试:前者要人工重新登录,后者要调用方改入参。
`5005``5006` 同样不可重试,且都转 `needs_human`:前者是站点主动要求人工验证,
后者是浏览器在动作中途没了、站点侧生效与否无从判断——下单不可逆,这种时候必须
停下来等人核对,不能赌。浏览器在**任务边界**掉线不会产生 `5006`,交易服务会就地
重建一套继续跑;重建失败或掉线发生在一次调用途中,才抛这个码。
`6xxx` 只会来自网关:`6001``6004`(下单任务通道)全部标记为不可重试——任务编排侧
重试无意义,部分场景(如租约过期)重试可能变成重复下单;`6005``6006`
(账号只读查询通道)**可以重试**——查询只读,重发没有副作用
(见 [docs/order-gateway.md §11](docs/order-gateway.md#11-账号只读查询通道))。
## 链路追踪
可选,默认关闭。`RAKUTEN_OTEL_ENABLED=true` + `RAKUTEN_OTEL_ENDPOINT` 指向 OTLP/HTTP
端点后,三个服务分别以 `rakuten-scraping` / `rakuten-trading` / `rakuten-gateway` 上报。
FastAPI 与 httpx 走自动埋点,但那只覆盖「收到 HTTP 请求」与「发出 httpx 请求」两类
边界。交易侧的实际工作两者都不是——站点交互走 Playwright,worker 主循环是后台任务,
所以这一侧手工埋点:
- `order.task`:一笔下单任务的**根 span**(一个 `task_id` 一条 trace),带
`order.route``execute` / `recovery` / `already_finished`)与终态
`order.terminal_status`;被闸门或风控拦下时置 ERROR 并带 `error.code`
- `order.step.*`:清车 → 加购 → 校验 → 确认页 → 提交 → 付款,每步一个子 span,
`order.evidence_ref`(可直接定位落盘证据)
- `site.*`:Playwright 站点交互(`site.add_to_cart``site.enter_checkout`
`site.submit_order``site.pay` 等)
- `account_query`:一张只读查询单的根 span,带 `query.outcome`
worker 空转的长轮询(每 30 秒问一次网关有没有活干)刻意不埋点——它们没有信息量,
量却极大,会把观测后台刷满。`/health` 同理:容器 HEALTHCHECK 每 30 秒探一次、上游
也在轮询,默认由 `RAKUTEN_OTEL_EXCLUDED_URLS`(默认 `/health$`)挡在 server span
之外。该项是逗号分隔的正则、按 search 匹配完整 URL,留空则不排除任何路径。
> 解析失败时页面 HTML 会作为 span event 上报(`RAKUTEN_OTEL_SNAPSHOT_MAX_BYTES`
> 控制上限,默认 2MB),用于事后复现「抓到的内容为什么解析不出预期字段」。
## 常用环境变量
完整列表见 [.env.example](.env.example)。配置项前缀统一为 `RAKUTEN_`,三个服务共用同一份
配置文件、各读各的那部分;两站共用同一套抓取参数(并发数、超时、重试次数),
`SESSION_TTL` 与浏览器兜底只对乐天链路生效。
- 抓取服务:`RAKUTEN_APP_HOST``RAKUTEN_APP_PORT`(默认 `31107`)、`RAKUTEN_APP_ENV`
- 交易服务:`RAKUTEN_TRADING_HOST``RAKUTEN_TRADING_PORT`(默认 `31108`)、`RAKUTEN_AUTH_STATE_DIR`(默认 `.auth`)、`RAKUTEN_ORDER_MAX_TOTAL_YEN`(默认 `30000`
- 下单任务网关:`RAKUTEN_GATEWAY_HOST``RAKUTEN_GATEWAY_PORT`(默认 `31109`)、`RAKUTEN_GATEWAY_DB_PATH`(默认 `data/gateway.db`)、`RAKUTEN_LEASE_TTL_SECONDS`(默认 `300`)、`RAKUTEN_WORKER_OFFLINE_ALERT_SECONDS`(默认 `300`
- 账号只读查询通道(网关 + 本地 worker 两侧共用):`RAKUTEN_QUERY_LEASE_TTL_SECONDS`(默认 `180`)、`RAKUTEN_QUERY_TTL_SECONDS`(默认 `900`)、`RAKUTEN_QUERY_MAX_ATTEMPTS`(默认 `3`)、`RAKUTEN_QUERY_RETENTION_SECONDS`(默认 `604800`)、`RAKUTEN_ACCOUNT_QUERY_TIMEOUT_SECONDS`(默认 `120`)、`RAKUTEN_ACCOUNT_QUERY_DEFAULT_MAX_PAGES`(默认 `3`)、`RAKUTEN_QUERY_RESULT_MAX_BYTES`(默认 `1048576`
- 本地下单 worker(在交易服务内,按 `RAKUTEN_ORDER_GATEWAY_URL` 是否配置决定是否启动):`RAKUTEN_ORDER_GATEWAY_URL``RAKUTEN_WORKER_ID``RAKUTEN_TRADING_DB_PATH`(默认 `data/trading.db`)、`RAKUTEN_EVIDENCE_DIR`(默认 `data/evidence`)、`RAKUTEN_SCRAPER_BASE_URL`
- 鉴权:`RAKUTEN_BEARER_TOKEN`(三个服务共用)
- 抓取:`RAKUTEN_MAX_SITE_CONCURRENCY`(默认 `8`,两站各自独立计数)、`RAKUTEN_HTTP_MAX_ATTEMPTS`(默认 `3`)、`RAKUTEN_SESSION_TTL_SECONDS`(默认 `1800`,仅乐天)
- 浏览器兜底(仅乐天):`RAKUTEN_BROWSER_FALLBACK_ENABLED``RAKUTEN_BROWSER_HEADLESS``RAKUTEN_BROWSER_CHANNEL`
- 代理(需日本 IP 时):`RAKUTEN_PROXY_SERVER``RAKUTEN_PROXY_USERNAME``RAKUTEN_PROXY_PASSWORD``RAKUTEN_PROXY_BYPASS`
- 链路追踪(可选,默认关闭;三个服务共用):`RAKUTEN_OTEL_ENABLED``RAKUTEN_OTEL_ENDPOINT``RAKUTEN_OTEL_HEADERS``RAKUTEN_OTEL_SNAPSHOT_MAX_BYTES``RAKUTEN_OTEL_EXCLUDED_URLS`(默认 `/health$`
> 从中国大陆直连实测可用、无需代理;`RAKUTEN_PROXY_SERVER` 留空即可。设置后,所有面向
> 外部站点的 HTTPX 与 Playwright 流量都会经代理;本机和 Docker 服务间地址由
> `RAKUTEN_PROXY_BYPASS` 直连。该设置不影响 OTel 上报。