rakuten 用户是 useradd --no-create-home 建的,HOME 指向不存在的目录。 Chromium 启动时要写 $HOME/.config、~/.pki,写不了就 CHECK 失败触发 int3(SIGTRAP)自杀,Playwright 侧表现为 BrowserType.launch: Target closed。 - Dockerfile / Dockerfile.trading:ENV 增加 HOME=/home/rakuten, mkdir + chown 补上 /home/rakuten - 抓取镜像同样修:浏览器兜底(browser_fallback)也是非 root 跑 Chromium, 属同一隐患 实测验证:docker run 加 -e HOME=/tmp 后 chrome 可正常 --dump-dom 输出
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。
依赖方向固定为 scraping → shared、trading → shared、gateway → shared,三方互不 import
(tests/test_architecture.py 会守着)。交易侧需要商品信息时走抓取服务的 HTTP 接口,
需要任务调度时走网关的 HTTP 接口——下单要用的 purchase 块本来就是抓取服务的对外契约。
抓取服务部署在服务器,交易服务部署在本地(便于管理账号、排查支付问题),本地在 NAT 后 没有公网入口,因此下单请求不是推进来的,而是由本地长轮询主动领取。 任务网关与本地 worker 的规格见 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 |
健康检查,含乐天账号登录态(只读缓存,不打站点) |
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 |
任务列表(运维与上游对账用) |
网关的契约与状态机详见 docs/order-gateway.md。
三个服务共用同一个 Bearer Token,错误码表也是同一份。
启动后分别在 http://127.0.0.1:31107/docs、:31108/docs、:31109/docs 查看 OpenAPI 文档。
要一份能直接导入 Apifox / Postman 的合并文档,用仓库根的 openapi.json:三个服务的接口都在里面,
每条接口带 operation 级 servers(导入后不必手动切端口)与 Bearer 鉴权声明。它是 gitignore 的本地
生成物(不进版本库),由脚本产出,别手改:
.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 可解析)。
安装
uv sync --extra dev
# 可选:启用浏览器兜底(不装也能正常跑,只是失去兜底能力)
uv sync --extra dev --extra browser
.venv/Scripts/python.exe -m playwright install chromium
启动
启动前建议先按 .env.example 配置 .env,三个服务共用这一份。
# 抓取服务,默认 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
只需要抓取时不必起交易/网关服务。交易服务启动前要先人工登录一次:
.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,抓取镜像没有这些。
docker compose up -d # 起交易服务(本地机侧)
docker compose --profile gateway up -d # 额外起下单网关(正式拓扑里它在服务器侧)
部署前置与挂载说明写在 docker-compose.yml 文件头:至少要有 .env 与 account.yaml,
.auth/、data/、logs/ 走挂载持久化。Jenkins 上两个镜像由同一条流水线产出
(BUILD_SCRAPING / BUILD_TRADING 两个开关控制)。
测试
.venv/Scripts/python.exe -m pytest -q
单测全部离线运行:解析器用的是 tests/fixtures/ 下从真实页面抓取并裁剪后的状态样本。
请求示例
以下 /api/* 为乐天市场接口,/api/rakuma/* 为 ラクマ 接口。
搜索(乐天)
POST /api/search
三种用法,优先级从高到低:
1. 透传原始搜索 URL(page > 1 时会覆盖 URL 里的页码)
{ "search_url": "https://search.rakuten.co.jp/search/mall/カメラ/?s=3&f=101", "page": 3 }
2. 关键词 + 筛选
{
"keyword": "nintendo switch",
"page": 2,
"sort": "price_asc",
"min_price": 3000,
"max_price": 30000,
"free_shipping": true,
"condition": "used"
}
3. 仅按分类
{ "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 个):
{}
下钻某个分类:
{ "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:
{ "shop_code": "edion", "item_code": "4902370549263" }
{ "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:
{ "shop_code": "edion" }
返回店铺名、简介、评分与评价数、招牌图与 logo、是否 39ショップ、休息日等。
评价数过少时站点不展示评分,此时
review_displayed为false,review_score不可信。
商家名下商品 POST /api/shop_items
{ "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
{
"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
{ "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
{ "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
{ "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
{ "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,返回购物车商品件数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 上的商品编号不是同一个值(如 URL17065211对应item_id20600328)。/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 |
| 6001 | 任务不存在(网关) | 404 |
| 6002 | 租约无效:不是持有者、已过期或任务已终结(网关) | 409 |
| 6003 | 任务状态不允许该操作(如对已终结任务 reclaim)(网关) | 409 |
| 6004 | 已有任务在执行中,本次不发放(正常返回空,仅诊断用) | 200 |
错误码在两站、三个服务之间通用。ラクマ 链路不会出现 3002(无反爬拦截行为)
与 4002(无子站跳转);5xxx 只会来自交易服务——抓取服务全程匿名,不会有登录态问题。
5001 与 5004 都标记为不可重试:前者要人工重新登录,后者要调用方改入参。
6xxx 只会来自网关,全部标记为不可重试——任务编排侧重试无意义,部分场景
(如租约过期)重试可能变成重复下单。
常用环境变量
完整列表见 .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_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 上报。