SiteInteractor 的 Chromium 是进程级单例,此前启动后即假定永远活着:全仓唯一的 is_connected() 探活在 scraping 侧,交易侧既不探活也不重启。容器里 Chromium 崩溃 是有真实前提的(/dev/shm 不足、OOM kill、seccomp 挡 sandbox,docker-compose.yml 里已有相关注释),一旦发生,进程还活着但之后每一单都会失败,且 /health 恒返回 ok,restart: unless-stopped 永远不会被触发。 更隐蔽的一条:clear_cart / enter_checkout / pay 等处的 new_page()、context.request 写在 try 之外,掉线抛的 TargetClosedError 不是 AppError,会穿过 runner 的 except AppError 落到主循环那个只记日志的兜底里——任务一次都不上报,网关侧要干等 整个 lease_ttl(默认 300s)才被 sweep 置 stale。clear_cart 是 execute() 的 step 0, 浏览器死时最先撞上的正是它。 分三层处理: - 任务边界自愈。_launch() 从 start() 抽出,_context_options() 统一 context 参数 (重建必须与启动完全一致,指纹漂移就是一次风控事件);_refresh_context_if_stale 改名 _ensure_context_ready(),先探活重建再做原有的 storage_state mtime 检查 (顺序不可换,mtime 重建要用 self._browser)。重建前丢弃 _checkout_pages 里的 残留确认页并记 warning——那些 Page 已随浏览器一起没了。 - 中途掉线不重建,抛 BrowserDeadError(新增,5006)。新增 _new_page() 与 _request() 两个壳收口裸异常;_request() 只在确认浏览器真死了时才改写异常, 站点 5xx 这类正常业务失败原样抛出。submit_order / pay 入口用 _require_live_browser() 直接拒绝:这两步复用 enter_checkout 留存的 Page, 重建救不回服务端订单草稿,而 pay 跑的时候订单已经真的提交了。顺带修掉一个 误诊——浏览器死时 submit_order 原先报「未找到确认按钮」,把「浏览器崩了」 说成「站点改版了」,两者的处置方式完全不同。 - runner 把 BrowserDeadError 转 needs_human 而非 failed,except 分支排在 except AppError 之前(子类,顺序反了就报 failed)。掉线发生在动作中途, 站点侧生效与否无从判断,不能给上游「明确失败」的结论。 /health 暴露 browser 状态,掉线时 degraded + HTTP 503 + code 5006,让 Dockerfile.trading 的 HEALTHCHECK 探到并重启容器。重启不会导致重复下单:网关侧 任务绝不自动重投,租约过期只置 stale 等人工 reclaim(docs/order-gateway.md §5), 重启只是恢复领新任务的能力。启动窗口期 started=False 不算掉线。 README 错误码表补 5005(此前遗漏)与 5006。 新增 15 个用例覆盖探活三态、is_connected() 自身抛错、边界重建/不重建/丢弃残留页/ 重建失败、两个包装壳的分支、submit/pay 拒绝、runner 转 needs_human、/health 503。 真实 Chromium 崩溃无法在离线测试里制造,用例模拟的是 is_connected() 返回 False 这个唯一可观测信号,覆盖的是代码对该信号的反应而非崩溃本身;容器 HEALTHCHECK 真的触发重启这条链路尚未实跑验证。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
682 lines
34 KiB
Markdown
682 lines
34 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 自动预热与复用,过期自动重新预热(仅乐天链路)
|
|
- 失败逐级升级:重新预热 → 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` | 上游提交下单意图(幂等) |
|
|
| `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` 可直接用作本接口入参。
|
|
|
|
部分官方店的商品页会跳转到独立子站,响应里的 `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 上 | ❌ |
|
|
| `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 加购多规格时按此选择) |
|
|
|
|
交易服务对外接口(: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 模型解耦)。
|
|
trading 加购时的字段选择策略:多规格挑第一个非售罄的 variant;必填选项拼「名:值」。
|
|
站点端点 `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-账号只读查询通道))。
|
|
|
|
## 常用环境变量
|
|
|
|
完整列表见 [.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_PROXY_SERVER` 留空即可。设置后,所有面向
|
|
> 外部站点的 HTTPX 与 Playwright 流量都会经代理;本机和 Docker 服务间地址由
|
|
> `RAKUTEN_PROXY_BYPASS` 直连。该设置不影响 OTel 上报。
|