Files
rakuten-api/README.md
T
2026-07-27 10:34:53 +08:00

488 lines
21 KiB
Markdown

# 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-*` 属性 — 成色、运费负担、发货地(已售出商品的规格表会消失,靠它兜底)
- `<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 / ビックカメラ 等官方旗舰子站
## 接口
| 接口 | 站点 | 说明 |
| --- | --- | --- |
| `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/item_detail` | ラクマ | 商品详情 |
| `POST /api/rakuma/shop_detail` | ラクマ | 卖家详情 |
| `POST /api/rakuma/shop_items` | ラクマ | 卖家名下商品 |
接口按站点分前缀而非合并加 `site` 参数,因为两站的筛选体系差异很大
(乐天有 `genre_id` / 成色 / SuperDEAL,ラクマ 有 `category_id` / `brand_id` / 匿名配送 / 鉴定服务),
合并会让大半字段对另一站无效。
启动后可访问 `http://127.0.0.1:31107/docs` 查看完整 OpenAPI 文档。
## 安装
```bash
uv sync --extra dev
# 可选:启用浏览器兜底(不装也能正常跑,只是失去兜底能力)
uv sync --extra dev --extra browser
.venv/Scripts/python.exe -m playwright install chromium
```
## 启动
默认监听 `0.0.0.0:31107`。启动前建议先按 `.env.example` 配置 `.env`
```bash
.venv/Scripts/python.exe -m app.main
# 或
uvicorn app.main:app --host 0.0.0.0 --port 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/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`(无分类树接口) |
| 品牌 | — | `brand_id` |
| 成色 | 3 档(`new`/`used`/`rental`) | 6 档卖家申告 |
| 规格 | `sku.axis` + `sku.variants` | 无(单件商品,仅一个 `size` 字段) |
| 每页条数 | 45(搜索) | 40(搜索)/ 36(店铺页) |
| 翻页上限 | `reachable_count`,随查询变化(405~6750) | 固定 100 页 |
| 加购标识 | `purchase` 块 | 无(未实现) |
## 加购与下单(仅乐天市场)
详情响应里的 `purchase` 块给出构造「加入购物车」请求所需的标识。
**本服务只提供数据,不执行加购**——加购需要已登录的乐天账号会话,由上游采购流程持有。
```jsonc
"purchase": {
"cart_url": "https://sp.basket.step.rakuten.co.jp/rms/mall/bss/cartadd/set",
"cart_method": "POST",
"form_fields": { // 原样提交的固定字段
"shop_bid": "231431",
"item_id": "10008065",
"inventory_flag": "2", // 1=单一库存,2=多规格
"__event": "ES01_003_001"
},
"quantity_field": "units", // 购买件数填这里
"variant_field": "variant_id", // 选中的 SKU 填这里
"options_field": "choice", // 商品选项填这里
"has_required_options": true, // 有必填选项,缺失会被站点拒绝
"options": [
{ "id": 1, "name": "名入れ", "type": "select", "is_required": true,
"values": [{ "name": "希望する【次の項目で入力】" }] },
{ "id": 2, "name": "【お名前】", "type": "text", "is_required": false, "values": [] }
]
}
```
拼装规则:
- `form_fields` 原样带上,再按 `quantity_field` 填件数。
- `inventory_flag=1``variant_id` 已在 `form_fields` 里预填好;`inventory_flag=2` 时**必须**由调用方从 `sku.variants[].variant_id` 选一个,填到 `variant_field`
- 字段名为空字符串表示该站不支持该项(如 `books` 不能指定件数与规格)。
- `options``type=select``values` 中选,`type=text` 由买家填写;`is_required=true` 的项不能省。
几点差异值得留意:
- **`cart_url` 逐商品不同**,不要写死。不同店铺落在不同 basket 集群(实测有 `sp.basket…``ts.sp.basket…`),`brandavenue` 的端点还要再经一层编号映射。
- **`books``item_id` 与 URL 上的商品编号不是同一个值**(如 URL `17065211` 对应 `item_id` `20600328`)。下单请用 `purchase.form_fields.item_id``item_code` 只是市场侧编号。
- **`biccamera` 没有 `shop_bid`**,走自己的 JSON 接口;它的选项取值结构未取到样本验证,因此只用 `has_required_options` 如实回报「有无选项」而不给出选项定义,这类商品需另行处理。
## 错误码
| code | 含义 | HTTP |
| --- | --- | --- |
| 0 | 成功 | 200 |
| 1001 | 鉴权失败 | 401 |
| 1002 | 请求参数校验失败 | 422 |
| 1003 | 请求参数不合法(如 URL 不属于目标站点) | 400 |
| 2002 | 抓取并发槽位等待超时 | 400 |
| 3001 | 上游网络异常 / 5xx | 400 |
| 3002 | 被反爬阻断 | 400 |
| 4001 | 页面解析失败(ラクマ 传了站点不认的筛选取值时也归此类) | 400 |
| 4002 | 商品页跳转至未登记的乐天子站 | 400 |
| 4004 | 商品/店铺不存在或已下架 | 404 |
错误码在两站间通用。ラクマ 链路不会出现 `3002`(无反爬拦截行为)与 `4002`(无子站跳转)。
## 常用环境变量
完整列表见 [.env.example](.env.example)。配置项前缀统一为 `RAKUTEN_`,两站共用同一套抓取参数
(并发数、超时、重试次数);`SESSION_TTL` 与浏览器兜底只对乐天链路生效。
- 服务:`RAKUTEN_APP_HOST``RAKUTEN_APP_PORT`(默认 `31107`)、`RAKUTEN_APP_ENV`
- 鉴权:`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_SERVER` 留空即可。