This commit is contained in:
2026-07-27 10:34:53 +08:00
commit 3527975794
74 changed files with 16405 additions and 0 deletions
+46
View File
@@ -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 <token>
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=
+11
View File
@@ -0,0 +1,11 @@
.venv/
__pycache__/
*.py[cod]
.env
logs/
.pytest_cache/
.idea/
.probe/
.mcp.json
openapi.json
+487
View File
@@ -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-*` 属性 — 成色、运费负担、发货地(已售出商品的规格表会消失,靠它兜底)
- `<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` 留空即可。
View File
View File
+42
View File
@@ -0,0 +1,42 @@
"""FastAPI 依赖注入:提供容器获取和鉴权校验"""
import logging
import secrets
from fastapi import Depends, Request
from app.core.container import ServiceContainer
from app.core.errors import AuthenticationError
logger = logging.getLogger(__name__)
def get_container(request: Request) -> ServiceContainer:
"""从请求中获取服务容器"""
return request.app.state.container
def require_bearer_token(
request: Request,
container: ServiceContainer = Depends(get_container),
) -> None:
"""Bearer Token 鉴权依赖
从请求头 Authorization 中提取 Bearer Token,
与服务端配置的 token 做安全比较(使用 secrets.compare_digest 防止时序攻击)。
"""
authorization = request.headers.get("Authorization")
if not authorization:
logger.warning("鉴权失败:缺少 Authorization 请求头")
raise AuthenticationError("Missing Authorization header")
token = authorization.replace("Bearer ", "", 1).strip()
if token == authorization:
logger.warning("鉴权失败:Authorization scheme 非 Bearer")
raise AuthenticationError("Invalid Authorization scheme")
if not token:
logger.warning("鉴权失败:Bearer token 为空")
raise AuthenticationError("Invalid token")
if not secrets.compare_digest(token, container.settings.bearer_token):
logger.warning("鉴权失败:token 不匹配")
raise AuthenticationError("Invalid token")
View File
+34
View File
@@ -0,0 +1,34 @@
"""健康检查路由"""
from fastapi import APIRouter, Depends
from app.api.dependencies import get_container
from app.core.container import ServiceContainer
from app.models.scrape import ApiResponse, HealthData
router = APIRouter(tags=["health"])
@router.get("/health", response_model=ApiResponse[HealthData])
async def health(container: ServiceContainer = Depends(get_container)) -> ApiResponse[HealthData]:
"""服务健康状态
sessions 里给出乐天的 PC / 手机两条抓取通道的 cookie 预热情况,以及
ラクマ 通道的就绪状态(ラクマ 无需预热,只报是否已初始化);
browser_fallback_* 反映浏览器兜底当前是否可用(未安装 playwright 时为不可用,
属于预期降级,不影响主链路)。
"""
return ApiResponse[HealthData](
success=True,
msg="success",
data=HealthData(
status="ok",
browser_fallback_enabled=container.browser_fallback.enabled,
browser_fallback_ready=container.browser_fallback.ready,
browser_fallback_error=container.browser_fallback.unavailable_reason,
sessions={
**container.site_session.profile_status(),
"rakuma": container.rakuma_session.status(),
},
),
code=0,
)
+128
View File
@@ -0,0 +1,128 @@
"""抓取路由:ラクマ(fril.jp)的搜索、商品详情与卖家
单独挂在 /api/rakuma 前缀下,不与乐天市场的接口合并:两站的筛选参数体系
差异很大(乐天有 genre_id / 成色 / SuperDEAL,ラクマ 有 category_id /
brand_id / 匿名配送 / 鉴定服务),合并会让大半字段对另一站无效。
"""
from fastapi import APIRouter, Depends
from app.api.dependencies import get_container, require_bearer_token
from app.core.container import ServiceContainer
from app.models.scrape import (
ApiResponse,
RakumaItemDetailData,
RakumaItemDetailRequest,
RakumaSearchRequest,
RakumaSearchResultData,
RakumaShopDetailData,
RakumaShopDetailRequest,
RakumaShopItemsData,
RakumaShopItemsRequest,
)
router = APIRouter(prefix="/api/rakuma", tags=["rakuma"])
@router.post(
"/search",
response_model=ApiResponse[RakumaSearchResultData],
dependencies=[Depends(require_bearer_token)],
)
async def search(
payload: RakumaSearchRequest,
container: ServiceContainer = Depends(get_container),
) -> ApiResponse[RakumaSearchResultData]:
"""搜索 ラクマ 商品列表
支持关键词、分类、品牌、价格区间、商品状态(6 档成色,可多选)、
在售/售罄、免运费、匿名配送等筛选;也可以直接传 search_url 透传一条
fril.jp 搜索页地址。
每页固定 40 条,站点侧最多翻到第 100 页(page > 100 直接 404)。
"""
data = await container.rakuma_client.search(payload)
return ApiResponse[RakumaSearchResultData](
success=True,
msg="success",
data=data,
code=0,
)
@router.post(
"/item_detail",
response_model=ApiResponse[RakumaItemDetailData],
dependencies=[Depends(require_bearer_token)],
)
async def item_detail(
payload: RakumaItemDetailRequest,
container: ServiceContainer = Depends(get_container),
) -> ApiResponse[RakumaItemDetailData]:
"""获取 ラクマ 商品详情
传 item_id(商品页 URL 的最后一段 hash),或直接传 item_url。
返回名称、价格、描述、图片、成色、配送信息与出品者摘要。
ラクマ 是 C2C 集市,每件商品都是单件的:没有 SKU 组合,也没有库存数量。
响应里的 seller.shop_id 可直接用于 /api/rakuma/shop_detail。
"""
data = await container.rakuma_client.item_detail(payload)
return ApiResponse[RakumaItemDetailData](
success=True,
msg="success",
data=data,
code=0,
)
@router.post(
"/shop_detail",
response_model=ApiResponse[RakumaShopDetailData],
dependencies=[Depends(require_bearer_token)],
)
async def shop_detail(
payload: RakumaShopDetailRequest,
container: ServiceContainer = Depends(get_container),
) -> ApiResponse[RakumaShopDetailData]:
"""获取 ラクマ 卖家(出品者)详情
传 shop_id(店铺页 URL 的最后一段 hash),或直接传 shop_url。
返回店铺名、昵称、头像与封面、简介、评分与评价数、本人确认状态、
以及该卖家的商品总数。
评价明细(最新 100 条)与好评/普通/差评分档计数在站点的单独子页上,
需要多打一次请求,把 include_reviews 置为 true 才会返回。
"""
data = await container.rakuma_client.shop_detail(payload)
return ApiResponse[RakumaShopDetailData](
success=True,
msg="success",
data=data,
code=0,
)
@router.post(
"/shop_items",
response_model=ApiResponse[RakumaShopItemsData],
dependencies=[Depends(require_bearer_token)],
)
async def shop_items(
payload: RakumaShopItemsRequest,
container: ServiceContainer = Depends(get_container),
) -> ApiResponse[RakumaShopItemsData]:
"""获取 ラクマ 卖家名下的商品列表
传 shop_id 或 shop_url,按页取该卖家的全部商品(含已售出,
用每条的 is_sold_out 区分)。
站点在店铺页不提供排序与筛选参数,因此这里只有页码;需要筛选请改用
/api/rakuma/search。
"""
data = await container.rakuma_client.shop_items(payload)
return ApiResponse[RakumaShopItemsData](
success=True,
msg="success",
data=data,
code=0,
)
+147
View File
@@ -0,0 +1,147 @@
"""抓取路由:乐天市场的搜索、分类、商品详情与商家"""
from fastapi import APIRouter, Depends
from app.api.dependencies import get_container, require_bearer_token
from app.core.container import ServiceContainer
from app.models.scrape import (
ApiResponse,
GenreData,
GenreRequest,
ItemDetailData,
ItemDetailRequest,
SearchRequest,
SearchResultData,
ShopDetailData,
ShopDetailRequest,
ShopItemsRequest,
)
router = APIRouter(prefix="/api", tags=["scrape"])
@router.post(
"/search",
response_model=ApiResponse[SearchResultData],
dependencies=[Depends(require_bearer_token)],
)
async def search(
payload: SearchRequest,
container: ServiceContainer = Depends(get_container),
) -> ApiResponse[SearchResultData]:
"""搜索商品列表
支持关键词、分类、排序、价格区间、店铺、成色、免运费等筛选;也可以直接传
search_url 透传一条乐天搜索页地址。返回结果默认已剔除混入的 CPC 广告位
(剔除数量见 ad_count)。
翻页上限受站点限制:最多只能取到 reachable_count 条(通常 6750)。
"""
data = await container.rakuten_client.search(payload)
return ApiResponse[SearchResultData](
success=True,
msg="success",
data=data,
code=0,
)
@router.post(
"/genres",
response_model=ApiResponse[GenreData],
dependencies=[Depends(require_bearer_token)],
)
async def genres(
payload: GenreRequest,
container: ServiceContainer = Depends(get_container),
) -> ApiResponse[GenreData]:
"""获取乐天分类(genre)树,用于取得 /api/search 需要的 genre_id
不传 genre_id 返回 39 个顶层分类;传入后返回该分类的名称、描述、祖先路径
与直接子分类,逐层下钻即可定位到叶子分类。
子分类的 item_count 是该分类下的商品数;顶层列表不返回该值,因为站点给出的
是「当前查询在该分类下的命中数」,并非分类自身的商品总量。
"""
data = await container.rakuten_client.genres(payload)
return ApiResponse[GenreData](
success=True,
msg="success",
data=data,
code=0,
)
@router.post(
"/item_detail",
response_model=ApiResponse[ItemDetailData],
dependencies=[Depends(require_bearer_token)],
)
async def item_detail(
payload: ItemDetailRequest,
container: ServiceContainer = Depends(get_container),
) -> ApiResponse[ItemDetailData]:
"""获取商品详情
传 shop_code + item_code(即商品 URL 的两段路径),或直接传 item_url。
返回名称、价格、图片、店铺、评价、配送与全部 SKU 组合;SKU 组合可能多达
数百条,不需要时可将 include_sku_variants 置为 false。
"""
data = await container.rakuten_client.item_detail(payload)
return ApiResponse[ItemDetailData](
success=True,
msg="success",
data=data,
code=0,
)
@router.post(
"/shop_detail",
response_model=ApiResponse[ShopDetailData],
dependencies=[Depends(require_bearer_token)],
)
async def shop_detail(
payload: ShopDetailRequest,
container: ServiceContainer = Depends(get_container),
) -> ApiResponse[ShopDetailData]:
"""获取乐天商家(店铺)详情
传 shop_code(店铺首页 URL 的路径段,如 edion),或直接传 shop_url。
返回店铺名称、简介、评分与评价数、招牌图、是否 39ショップ 与休息日。
评价数过少时站点不展示评分,此时 review_displayed 为 false,
review_score 不可信。
"""
data = await container.rakuten_client.shop_detail(payload)
return ApiResponse[ShopDetailData](
success=True,
msg="success",
data=data,
code=0,
)
@router.post(
"/shop_items",
response_model=ApiResponse[SearchResultData],
dependencies=[Depends(require_bearer_token)],
)
async def shop_items(
payload: ShopItemsRequest,
container: ServiceContainer = Depends(get_container),
) -> ApiResponse[SearchResultData]:
"""获取乐天商家名下的商品列表
传 shop_id(取自搜索结果或 /api/shop_detail)或 shop_code;只给 shop_code
时服务端会先取一次店铺详情换出 shop_id,多花一次请求。
支持在店铺内按关键词、分类、价格、成色等继续筛选,翻页与返回结构同
/api/search。
"""
data = await container.rakuten_client.shop_items(payload)
return ApiResponse[SearchResultData](
success=True,
msg="success",
data=data,
code=0,
)
View File
+118
View File
@@ -0,0 +1,118 @@
"""应用配置:通过环境变量和 .env 文件加载所有配置项
配置项统一使用 RAKUTEN_ 前缀,例如 RAKUTEN_APP_PORT=31107。
支持 .env 文件自动加载。
"""
from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict
from app.core import site
BASE_DIR = Path(__file__).resolve().parent.parent.parent
class Settings(BaseSettings):
"""应用全局配置
所有配置项均可通过环境变量覆盖,前缀为 RAKUTEN_。
例如:RAKUTEN_APP_PORT=31107 对应 app_port 配置项。
"""
model_config = SettingsConfigDict(
env_file=str(BASE_DIR / ".env"),
env_file_encoding="utf-8",
env_prefix="RAKUTEN_",
extra="ignore",
)
# ---- 服务基本配置 ----
app_name: str = "Rakuten Scraper Service"
app_env: Literal["dev", "prod", "test"] = "dev"
app_host: str = "0.0.0.0"
app_port: int = 31107
# ---- 日志配置 ----
log_level: str = "INFO"
log_to_file: bool | None = None # None 表示根据环境自动决定
log_dir: str = "logs"
log_rotation: str = "100 MB"
log_retention: str = "14 days"
log_compression: str = "zip"
log_format: str = "{time:YYYY-MM-DD HH:mm:ss} {level} {message}"
log_enqueue: bool = True
# ---- 鉴权配置 ----
bearer_token: str = "REPLACE_WITH_TOKEN_32CHARS"
# ---- HTTP 抓取配置 ----
request_timeout_seconds: float = 30.0
max_site_concurrency: int = 8 # 对站点的最大并发请求数
http_max_attempts: int = 3 # 单次抓取的最大尝试次数(含首次)
session_ttl_seconds: float = 1800.0 # Akamai cookie 会话最长复用时长,超时后重新预热
# ---- 浏览器兜底配置 ----
# 纯 HTTP 被 Akamai 拦截时,用 Playwright 打开页面取回 cookie 再回灌给
# HTTP 客户端重试。日常流量不会触发;未安装 playwright 时自动降级为不兜底。
browser_fallback_enabled: bool = True
browser_headless: bool | None = None # None 表示根据环境自动决定
browser_channel: str | None = None # 例如 chrome;留空使用 bundled chromium
browser_launch_timeout_seconds: float = 60.0
browser_nav_timeout_seconds: float = 60.0
# ---- 代理配置(可选,用于日本 IP)----
proxy_server: str | None = None
proxy_username: str | None = None
proxy_password: str | None = None
# ---- 目标站点 ----
home_url: str = site.HOME_URL
@property
def browser_headless_effective(self) -> bool:
"""浏览器无头模式:显式配置优先,否则开发环境使用有头模式方便调试"""
if self.browser_headless is not None:
return self.browser_headless
return self.app_env != "dev"
@property
def log_to_file_effective(self) -> bool:
"""是否写日志文件:显式配置优先,否则生产环境默认写文件"""
if self.log_to_file is not None:
return self.log_to_file
return self.app_env == "prod"
@property
def playwright_proxy(self) -> dict[str, str] | None:
"""构建 Playwright 代理配置字典"""
if not self.proxy_server:
return None
proxy: dict[str, str] = {"server": self.proxy_server}
if self.proxy_username:
proxy["username"] = self.proxy_username
if self.proxy_password:
proxy["password"] = self.proxy_password
return proxy
@property
def httpx_proxy(self) -> str | None:
"""构建 httpx 代理 URL(含认证信息)"""
if not self.proxy_server:
return None
if not self.proxy_username:
return self.proxy_server
scheme, _, rest = self.proxy_server.partition("://")
if not rest:
return self.proxy_server
credentials = f"{self.proxy_username}:{self.proxy_password or ''}"
return f"{scheme}://{credentials}@{rest}"
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""获取全局配置单例"""
return Settings()
+28
View File
@@ -0,0 +1,28 @@
"""服务容器:集中管理所有服务实例,用于依赖注入"""
from dataclasses import dataclass
from app.core.config import Settings
from app.services.browser_fallback import BrowserFallback
from app.services.rakuma_client import RakumaClient
from app.services.rakuma_session import RakumaSession
from app.services.rakuten_client import RakutenClient
from app.services.site_session import SiteSession
@dataclass(slots=True)
class ServiceContainer:
"""服务容器,持有所有核心服务实例
通过 FastAPI 的 app.state.container 在请求间共享,
各路由通过依赖注入获取容器中的服务。
两个站点各自持有独立的会话与客户端:乐天需要 Akamai cookie 预热与双指纹
通道,ラクマ 不需要,抓取前提不同不便合并。
"""
settings: Settings
browser_fallback: BrowserFallback
site_session: SiteSession
rakuten_client: RakutenClient
rakuma_session: RakumaSession
rakuma_client: RakumaClient
+115
View File
@@ -0,0 +1,115 @@
"""应用异常定义:所有业务异常均继承自 AppError
错误码规范:
- 1xxx: 请求/鉴权错误
- 2xxx: 抓取资源错误
- 3xxx: 反爬/上游阻断相关错误
- 4xxx: 页面解析错误
"""
class AppError(Exception):
"""应用基础异常,携带错误码、HTTP 状态码和是否可重试信息"""
def __init__(
self,
message: str,
code: str,
err_code: int,
retryable: bool = False,
status_code: int = 400,
headers: dict[str, str] | None = None,
):
super().__init__(message)
self.message = message
self.code = code
self.err_code = err_code
self.retryable = retryable
self.status_code = status_code
self.headers = headers
class AuthenticationError(AppError):
"""鉴权失败(Bearer Token 无效或缺失)"""
def __init__(self, message: str = "Invalid credentials"):
super().__init__(
message=message,
code="AUTH_INVALID",
err_code=1001,
retryable=False,
status_code=401,
headers={"WWW-Authenticate": "Bearer"},
)
class InvalidRequestError(AppError):
"""请求参数不合法(如 URL 非乐天站点、缺少必填标识)"""
def __init__(self, message: str = "Invalid request"):
super().__init__(message=message, code="INVALID_REQUEST", err_code=1003, retryable=False)
class ResourceBusyError(AppError):
"""抓取资源繁忙(等待并发槽位超时)"""
def __init__(self, message: str = "Timed out while waiting for a scrape slot"):
super().__init__(message=message, code="RESOURCE_BUSY", err_code=2002, retryable=True)
class UpstreamRequestError(AppError):
"""上游请求失败(网络异常、超时、5xx)"""
def __init__(self, message: str = "The upstream request failed"):
super().__init__(message=message, code="UPSTREAM_ERROR", err_code=3001, retryable=True)
class UpstreamBlockedError(AppError):
"""上游请求被反爬阻断(Akamai 挑战页 / 403 / 页面缺少渲染数据)"""
def __init__(self, message: str = "The upstream request was blocked"):
super().__init__(message=message, code="UPSTREAM_BLOCKED", err_code=3002, retryable=True)
class ItemNotFoundError(AppError):
"""商品不存在或已下架(详情页 404)"""
def __init__(self, message: str = "Item not found"):
super().__init__(
message=message,
code="ITEM_NOT_FOUND",
err_code=4004,
retryable=False,
status_code=404,
)
class ScrapeParseError(AppError):
"""页面解析失败"""
def __init__(self, message: str = "Failed to parse the target page"):
super().__init__(message=message, code="PARSE_ERROR", err_code=4001, retryable=False)
class OffIchibaRedirectError(AppError):
"""商品页跳转到了市场之外的乐天官方子站
楽天ブックス(book → books.rakuten.co.jp)、ビックカメラ
(biccamera → biccamera.rakuten.co.jp)等官方旗舰店有各自独立的站点与
页面结构,不返回市场统一模板,本服务的详情解析不适用。
单独成一类错误是为了让上游能把这些商品路由到别处,而不是当成被反爬拦截去重试。
"""
def __init__(self, requested_url: str, final_url: str):
super().__init__(
message=(
f"商品页跳转至乐天市场以外的站点,当前不支持解析:"
f"{requested_url} -> {final_url}"
),
code="OFF_ICHIBA_REDIRECT",
err_code=4002,
retryable=False,
)
self.requested_url = requested_url
self.final_url = final_url
+86
View File
@@ -0,0 +1,86 @@
"""日志配置:使用 loguru 替代标准 logging,统一管理日志输出
- 控制台输出:始终启用
- 文件输出:生产环境默认启用,支持日志轮转、压缩和保留策略
- 拦截标准 logging:将 uvicorn 等第三方库的日志转发到 loguru
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
from types import FrameType
from loguru import logger as loguru_logger
from app.core.config import Settings
class InterceptHandler(logging.Handler):
"""拦截标准 logging 日志,转发到 loguru"""
def emit(self, record: logging.LogRecord) -> None:
try:
level: str | int = loguru_logger.level(record.levelname).name
except ValueError:
level = record.levelno
frame: FrameType | None = logging.currentframe()
depth = 2
while frame and frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
loguru_logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
def configure_logging(settings: Settings) -> None:
"""根据配置初始化日志系统
- 移除 loguru 默认 handler
- 添加控制台输出
- 生产环境添加文件输出(轮转、压缩、保留策略)
- 拦截标准 logging,统一走 loguru 输出
"""
loguru_logger.remove()
loguru_logger.add(
sys.stdout,
level=settings.log_level.upper(),
format=settings.log_format,
enqueue=settings.log_enqueue,
backtrace=False,
diagnose=False,
)
if settings.log_to_file_effective:
log_dir = Path(settings.log_dir)
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"app_{settings.app_env}.log"
loguru_logger.add(
str(log_path),
level=settings.log_level.upper(),
format=settings.log_format,
rotation=settings.log_rotation,
retention=settings.log_retention,
compression=settings.log_compression,
enqueue=settings.log_enqueue,
encoding="utf-8",
backtrace=False,
diagnose=False,
)
intercept = InterceptHandler()
logging.basicConfig(
handlers=[intercept],
level=getattr(logging, settings.log_level.upper(), logging.INFO),
force=True,
)
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
logger = logging.getLogger(name)
logger.handlers = [intercept]
logger.propagate = False
logging.getLogger("asyncio").setLevel(logging.WARNING)
# httpx 每次请求都会打一条 INFO,抓取服务下噪音过大
logging.getLogger("httpx").setLevel(logging.WARNING)
+110
View File
@@ -0,0 +1,110 @@
"""ラクマ(fril.jp)站点常量
集中维护站点入口 URL、浏览器指纹参数,以及搜索页 URL 的排序码 / 筛选码映射。
排序与筛选参数并非猜测,而是从站点前端 bundle
(asset.fril.jp/assets/v2/application-*.js)中 SearchPanel 组件的 `_url()`
方法里提取的——那段代码逐条拼出搜索 URL,因此参数名与取值与站点行为一一对应。
与乐天市场(app/core/site.py)的关键差异:
- 页面是服务端渲染的 HTML,**没有** `window.__INITIAL_STATE__`,只能解析 DOM
- 前置的不是 Akamai Bot Manager,无限速行为,冷请求即 ~0.6-1.1s,不需要 cookie 预热
- 商品详情页用 PC UA 即可,不需要像乐天那样切手机 UA
"""
from __future__ import annotations
from typing import Final
# ---- 站点入口 ----
HOME_URL: Final = "https://fril.jp/"
SEARCH_BASE_URL: Final = "https://fril.jp/s"
ITEM_BASE_URL: Final = "https://item.fril.jp/"
SHOP_BASE_URL: Final = "https://fril.jp/shop/"
CATEGORY_BASE_URL: Final = "https://fril.jp/category/"
BRAND_BASE_URL: Final = "https://fril.jp/brand/"
SEARCH_HOST: Final = "fril.jp"
ITEM_HOST: Final = "item.fril.jp"
# 站点每页固定返回 40 条(实测第 1 页与深翻页均为 40)
PAGE_SIZE: Final = 40
# 站点侧翻页上限:page=100 正常返回,page=101 起直接 404。
MAX_PAGE: Final = 100
# ---- 浏览器指纹 ----
# 两类页面都用 PC UA:详情页 PC 版即为完整模板,无需像乐天那样切手机 UA。
USER_AGENT: Final = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)
ACCEPT_LANGUAGE: Final = "ja,en-US;q=0.9,en;q=0.8"
def default_headers() -> dict[str, str]:
"""构造一套完整的浏览器导航请求头"""
return {
"User-Agent": USER_AGENT,
"Accept": (
"text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,image/apng,*/*;q=0.8,"
"application/signed-exchange;v=b3;q=0.7"
),
"Accept-Language": ACCEPT_LANGUAGE,
"sec-ch-ua": '"Chromium";v="131", "Not_A Brand";v="24", "Google Chrome";v="131"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
}
# ---- 排序(搜索页 `sort=` + `order=` 两个参数)----
# 键为对外暴露的语义化排序名,值为 (sort, order)。
# 站点把排序字段与升降序拆成两个参数,因此这里成对给出。
SORT_CODES: Final[dict[str, tuple[str, str]]] = {
"standard": ("relevance", "desc"), # 站点默认的おすすめ順
"newest": ("created_at", "desc"),
"price_asc": ("sell_price", "asc"),
"price_desc": ("sell_price", "desc"),
"like_count": ("like_count", "desc"), # いいね数順
}
# ---- 商品状态(搜索页 `statuses=`,逗号分隔可多选)----
# 取值来自 SearchPanel 组件的 statusOptions 定义。
CONDITION_CODES: Final[dict[str, str]] = {
"new": "5", # 新品、未使用
"almost_new": "4", # 未使用に近い
"no_damage": "6", # 目立った傷や汚れなし
"slight_damage": "3", # やや傷や汚れあり
"damaged": "2", # 傷や汚れあり
"poor": "1", # 全体的に状態が悪い
}
# ---- 售卖状态(搜索页 `transaction=`)----
# 站点默认 all(不带该参数),仅在筛选时下发。
TRANSACTION_CODES: Final[dict[str, str]] = {
"on_sale": "selling", # 販売中のみ
"sold_out": "soldout", # 売切れのみ
}
# ---- 鉴定服务(搜索页 `authenticity_types=`)----
AUTHENTICITY_CODES: Final[dict[str, str]] = {
"before_delivery": "pre", # お届け前鑑定
"after_delivery": "post", # 後から鑑定
}
# 运费负担:站点用 carriage=1 表示「送料込みのみ」(卖家承担),0 表示不限
CARRIAGE_INCLUDED: Final = "1"
# 商品详情页上表示已售出的标记文案
SOLD_OUT_MARKERS: Final = ("SOLD OUT", "SOLDOUT", "売り切れました")
# 页面校验用的结构标记:这几类页面各自必须出现的 DOM 特征
SEARCH_PAGE_MARKER: Final = "page-count"
ITEM_PAGE_MARKER: Final = "item-info"
SHOP_PAGE_MARKER: Final = "profile-area__shop-name"
+107
View File
@@ -0,0 +1,107 @@
"""乐天市场(rakuten.co.jp)站点常量
集中维护站点入口 URL、浏览器指纹参数,以及搜索页 URL 的排序码 / 筛选码映射。
排序码(`s=`)与筛选码(`f=`)并非猜测所得,而是从搜索页前端 bundle
(r.r10s.jp/com/assets/app/pages/search/javascript/pc-*.bundle.js)中的
URL→UiQuestion 转换逻辑里提取的枚举,与站点行为一一对应。
"""
from __future__ import annotations
from typing import Final
# ---- 站点入口 ----
HOME_URL: Final = "https://www.rakuten.co.jp/"
SEARCH_BASE_URL: Final = "https://search.rakuten.co.jp/search/mall/"
ITEM_BASE_URL: Final = "https://item.rakuten.co.jp/"
CATEGORY_BASE_URL: Final = "https://www.rakuten.co.jp/category/"
# 店铺首页形如 https://www.rakuten.co.jp/edion/
WWW_BASE_URL: Final = "https://www.rakuten.co.jp/"
SEARCH_HOST: Final = "search.rakuten.co.jp"
ITEM_HOST: Final = "item.rakuten.co.jp"
WWW_HOST: Final = "www.rakuten.co.jp"
# 取顶层分类列表用的哨兵关键词。
# 站点的分类分面(genreTree)与查询内容无关,任何关键词都会返回同一套 39 个顶层
# 分类;刻意用一个搜不到东西的词,可以拿到 count 全为 null 的干净列表,避免把
# 「某个关键词下的命中数」误当成分类的商品总数返回。
# 注意不能用单字母(如 a),站点对过短的拉丁关键词直接返回 503。
GENRE_FACET_PROBE_KEYWORD: Final = "zzzqqqxyz123"
# 站点侧限制:搜索结果最多只能翻到 pagination.subset 条(实测 6750),
# 再往后翻页返回空列表。用于计算 has_more,避免上游无意义地深翻。
DEFAULT_SUBSET_LIMIT: Final = 6750
# ---- 浏览器指纹 ----
# 搜索页用 PC UA;商品详情页必须用手机 UA,否则返回的是各店铺自定义的
# EUC-JP 老模板(无 __INITIAL_STATE__,只有面包屑 JSON-LD,无法结构化解析)。
PC_USER_AGENT: Final = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)
SP_USER_AGENT: Final = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
)
ACCEPT_LANGUAGE: Final = "ja,en-US;q=0.9,en;q=0.8"
# 乐天前置 Akamai Bot Manager。请求头不完整时不会直接封禁,而是把响应
# 拖到 ~11s(实测与响应体大小无关,22 字节的响应同样耗时 11s);补齐
# 下列头并复用 Akamai 下发的 cookie 后,稳定在 ~0.6-0.9s。
def default_headers(*, mobile: bool) -> dict[str, str]:
"""构造一套完整的浏览器导航请求头。"""
return {
"User-Agent": SP_USER_AGENT if mobile else PC_USER_AGENT,
"Accept": (
"text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,image/apng,*/*;q=0.8,"
"application/signed-exchange;v=b3;q=0.7"
),
"Accept-Language": ACCEPT_LANGUAGE,
"sec-ch-ua": '"Chromium";v="131", "Not_A Brand";v="24", "Google Chrome";v="131"',
"sec-ch-ua-mobile": "?1" if mobile else "?0",
"sec-ch-ua-platform": '"iOS"' if mobile else '"Windows"',
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
}
# Akamai Bot Manager 下发的 cookie:判断会话是否已预热完成的依据
AKAMAI_COOKIE_NAMES: Final = ("ak_bmsc", "bm_sv", "bm_mi", "_abck")
# ---- 排序(搜索页 `s=` 参数)----
# 键为对外暴露的语义化排序名,值为站点侧排序码;standard 不带 s 参数。
SORT_CODES: Final[dict[str, str | None]] = {
"standard": None, # 站点默认的相关度排序(relevancy)
"price_asc": "2",
"price_desc": "3",
"newest": "4",
"review_count": "5",
"review_score": "6",
"price_with_shipping_asc": "11",
"price_with_shipping_desc": "12",
}
# ---- 成色(搜索页 `f=` 参数中的 condition 段)----
CONDITION_CODES: Final[dict[str, str]] = {
"new": "101",
"used": "100",
"rental": "102",
}
# ---- 布尔筛选(搜索页 `f=` 参数,可重复出现)----
# 字段名 -> 筛选码
BOOL_FILTER_CODES: Final[dict[str, str]] = {
"include_sold_out": "0",
"free_shipping": "2",
"has_review": "4",
"next_day_delivery": "12",
"super_deal": "13",
}
+189
View File
@@ -0,0 +1,189 @@
"""应用入口:FastAPI 应用创建与生命周期管理
职责:
- 构建服务容器(依赖注入)
- 管理应用生命周期(启动/关闭抓取会话与兜底浏览器)
- 注册路由和全局异常处理器
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.api.routes.health import router as health_router
from app.api.routes.rakuma import router as rakuma_router
from app.api.routes.scrape import router as scrape_router
from app.core.config import get_settings
from app.core.container import ServiceContainer
from app.core.errors import AppError
from app.core.logging_setup import configure_logging
from app.models.scrape import ApiResponse
from app.services.browser_fallback import BrowserFallback
from app.services.rakuma_client import RakumaClient
from app.services.rakuma_session import RakumaSession
from app.services.rakuten_client import RakutenClient
from app.services.site_session import SiteSession
logger = logging.getLogger(__name__)
def _format_validation_msg(errors: list[dict]) -> str:
"""将校验错误整理为便于前端展示的消息。"""
if not errors:
return "Validation error"
messages: list[str] = []
for error in errors:
loc = ".".join(str(part) for part in error.get("loc", []) if part != "body")
msg = str(error.get("msg", "Validation error"))
messages.append(f"{loc}: {msg}" if loc else msg)
return "; ".join(messages)
def build_container() -> ServiceContainer:
"""构建服务容器,组装所有依赖"""
settings = get_settings()
browser_fallback = BrowserFallback(settings)
site_session = SiteSession(settings, browser_fallback)
rakuten_client = RakutenClient(settings, site_session)
rakuma_session = RakumaSession(settings)
rakuma_client = RakumaClient(settings, rakuma_session)
return ServiceContainer(
settings=settings,
browser_fallback=browser_fallback,
site_session=site_session,
rakuten_client=rakuten_client,
rakuma_session=rakuma_session,
rakuma_client=rakuma_client,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理:启动时初始化各服务,关闭时释放资源"""
container = build_container()
app.state.container = container
configure_logging(container.settings)
logger.info("应用启动:%s:%s", container.settings.app_host, container.settings.app_port)
logger.info("日志级别:%s", container.settings.log_level)
logger.info("当前环境:%s", container.settings.app_env)
await container.site_session.start()
await container.rakuma_session.start()
try:
yield
finally:
await container.rakuma_session.close()
await container.site_session.close()
await container.browser_fallback.close()
def create_app() -> FastAPI:
"""创建 FastAPI 应用实例,注册路由和异常处理器"""
app = FastAPI(title="Rakuten Scraper Service", lifespan=lifespan)
app.include_router(health_router)
app.include_router(scrape_router)
app.include_router(rakuma_router)
@app.exception_handler(AppError)
async def app_error_handler(_: Request, exc: AppError) -> JSONResponse:
"""业务异常处理器:返回结构化的错误响应"""
return JSONResponse(
status_code=exc.status_code,
content=ApiResponse[None](
success=False,
code=exc.err_code,
msg=exc.message,
data=None,
).model_dump(),
headers=exc.headers,
)
@app.exception_handler(RequestValidationError)
async def validation_error_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
"""请求参数校验异常处理器"""
errors = exc.errors()
return JSONResponse(
status_code=422,
content=ApiResponse[object](
success=False,
msg=_format_validation_msg(errors),
data=jsonable_errors(errors),
code=1002,
).model_dump(),
)
@app.exception_handler(ValidationError)
async def pydantic_validation_error_handler(_: Request, exc: ValidationError) -> JSONResponse:
"""Pydantic 模型校验异常处理器"""
errors = exc.errors()
return JSONResponse(
status_code=422,
content=ApiResponse[object](
success=False,
msg=_format_validation_msg(errors),
data=jsonable_errors(errors),
code=1002,
).model_dump(),
)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(_: Request, exc: StarletteHTTPException) -> JSONResponse:
"""HTTP 异常处理器(404、500 等)"""
status_code = int(getattr(exc, "status_code", 500) or 500)
err_code = 1404 if status_code == 404 else 1500
return JSONResponse(
status_code=status_code,
content=ApiResponse[None](
success=False,
msg=str(getattr(exc, "detail", "HTTP error")),
data=None,
code=err_code,
).model_dump(),
headers=getattr(exc, "headers", None),
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(_: Request, exc: Exception) -> JSONResponse:
"""兜底异常处理器:捕获所有未处理的异常"""
logger.exception("未处理异常:%s", exc)
return JSONResponse(
status_code=500,
content=ApiResponse[None](
success=False,
msg="Internal server error",
data=None,
code=1500,
).model_dump(),
)
return app
def jsonable_errors(errors: list[dict]) -> list[dict]:
"""剔除校验错误里不可 JSON 序列化的 ctx(如原始异常对象)"""
return [{key: value for key, value in error.items() if key != "ctx"} for error in errors]
app = create_app()
if __name__ == "__main__":
import uvicorn
settings = get_settings()
configure_logging(settings)
uvicorn.run(
"app.main:app",
host=settings.app_host,
port=settings.app_port,
log_config=None,
timeout_keep_alive=120,
)
View File
+701
View File
@@ -0,0 +1,701 @@
"""API 数据模型:请求体和响应体定义
字段命名贴合乐天站点自身的语义(item_code / shop_code / genre_id / sku 等),
不做跨站点的字段名归一,避免解析层与对外契约之间反复翻译。
"""
from __future__ import annotations
from enum import StrEnum
from typing import Any, Generic, TypeVar
from pydantic import BaseModel, Field, HttpUrl, model_validator
T = TypeVar("T")
class ApiResponse(BaseModel, Generic[T]):
"""统一 API 响应格式"""
success: bool
msg: str
data: T | None = None
code: int
class SortOption(StrEnum):
"""搜索排序方式,对应搜索页 `s=` 参数"""
STANDARD = "standard" # 站点默认相关度排序
PRICE_ASC = "price_asc"
PRICE_DESC = "price_desc"
NEWEST = "newest"
REVIEW_COUNT = "review_count"
REVIEW_SCORE = "review_score"
PRICE_WITH_SHIPPING_ASC = "price_with_shipping_asc"
PRICE_WITH_SHIPPING_DESC = "price_with_shipping_desc"
class ItemCondition(StrEnum):
"""商品成色筛选"""
NEW = "new"
USED = "used"
RENTAL = "rental"
class SearchRequest(BaseModel):
"""搜索请求参数
三种用法(优先级从高到低):
1. 传 search_url:直接透传一条乐天搜索页 URL,服务端原样抓取,
此时除 page 与 exclude_ads 外的筛选字段全部忽略;
page 若显式指定(>1),会覆盖 URL 中的页码。
2. 传 keyword(可叠加任意筛选字段)
3. 只传 genre_id:抓取该分类下的商品
"""
keyword: str = ""
page: int = Field(default=1, ge=1, le=150) # 站点侧最多约 150 页(subset 6750 / 45)
sort: SortOption = SortOption.STANDARD
genre_id: str | None = None # 乐天分类 ID,如 565950
min_price: int | None = Field(default=None, ge=0)
max_price: int | None = Field(default=None, ge=0)
shop_id: int | None = None # 限定店铺(对应 `sid` 参数,取搜索结果的 shop.shop_id)
exclude_keyword: str | None = None # 排除词(`nitem`)
title_only: bool = False # 仅在商品标题中匹配(`sf=1`)
or_query: bool = False # 关键词之间用 OR 而非 AND(`st=O`)
min_review_score: int | None = Field(default=None, ge=1, le=5) # 最低评分
condition: ItemCondition | None = None # 新品 / 中古 / 租赁
include_sold_out: bool = False # 包含售罄商品
free_shipping: bool = False # 仅免运费
has_review: bool = False # 仅有评论
next_day_delivery: bool = False # 仅次日达
super_deal: bool = False # 仅 SuperDEAL
tags: list[str] = Field(default_factory=list) # 站点标签 ID(`tg`)
search_url: HttpUrl | None = None
exclude_ads: bool = True # 剔除搜索结果中混入的 CPC 广告位
@model_validator(mode="after")
def check_search_target(self) -> SearchRequest:
if (
not self.search_url
and not self.keyword.strip()
and not self.genre_id
and self.shop_id is None
):
raise ValueError("keyword、genre_id、shop_id、search_url 至少需要提供一个")
if self.min_price is not None and self.max_price is not None and self.min_price > self.max_price:
raise ValueError("min_price 不能大于 max_price")
return self
class ShopDetailRequest(BaseModel):
"""乐天商家详情请求参数:传店铺代码(店铺 URL 的路径段),或直接传店铺页 URL"""
shop_code: str | None = None # 店铺代码,如 edion
shop_url: HttpUrl | None = None
@model_validator(mode="after")
def check_shop_target(self) -> ShopDetailRequest:
if not self.shop_url and not (self.shop_code or "").strip():
raise ValueError("需要提供 shop_code 或 shop_url")
return self
class ShopItemsRequest(BaseModel):
"""乐天商家商品列表请求参数
站点没有单独的「店铺内商品」接口,本服务转成一次限定店铺的搜索
(搜索页的 `sid` 参数),因此支持与 /api/search 相同的排序与筛选。
shop_id 与 shop_code 至少提供一个;只给 shop_code 时会先取一次店铺详情
换出 shop_id,多花一次请求,能直接给 shop_id 时优先给。
"""
shop_id: int | None = None # 取自搜索结果或商家详情的 shop.shop_id
shop_code: str | None = None # 店铺代码,如 edion
keyword: str = "" # 在店铺内按关键词过滤
page: int = Field(default=1, ge=1, le=150)
sort: SortOption = SortOption.STANDARD
genre_id: str | None = None
min_price: int | None = Field(default=None, ge=0)
max_price: int | None = Field(default=None, ge=0)
condition: ItemCondition | None = None
include_sold_out: bool = False
free_shipping: bool = False
exclude_ads: bool = True
@model_validator(mode="after")
def check_shop_target(self) -> ShopItemsRequest:
if self.shop_id is None and not (self.shop_code or "").strip():
raise ValueError("需要提供 shop_id 或 shop_code")
if self.min_price is not None and self.max_price is not None and self.min_price > self.max_price:
raise ValueError("min_price 不能大于 max_price")
return self
def to_search_request(self, shop_id: int) -> SearchRequest:
"""转成一次限定店铺的搜索请求
站点没有独立的「店铺内商品」页可供分页抓取,店铺商品实际就是
`sid=` 限定后的搜索结果,因此这里复用同一条抓取链路。
"""
return SearchRequest(
keyword=self.keyword,
page=self.page,
sort=self.sort,
genre_id=self.genre_id,
min_price=self.min_price,
max_price=self.max_price,
shop_id=shop_id,
condition=self.condition,
include_sold_out=self.include_sold_out,
free_shipping=self.free_shipping,
exclude_ads=self.exclude_ads,
)
class ShopDetailData(BaseModel):
"""乐天商家详情数据"""
shop_id: int | None = None
shop_code: str = ""
shop_name: str = ""
shop_url: str = ""
introduction: str = "" # 店铺简介
signboard_url: str = "" # 店铺招牌图
logo_url: str = ""
review_score: float = 0.0
review_count: int = 0
# 站点在评价数过少时不展示评分;此时 review_score 不可信
review_displayed: bool = False
is_39_shop: bool = False # 39ショップ(满 3980 日元免运费)
age_verification_required: bool = False
status: int | None = None # 站点店铺状态码,1 = 营业中
holidays: list[str] = Field(default_factory=list) # 店铺休息日
class ItemDetailRequest(BaseModel):
"""商品详情请求参数:传 shop_code + item_code,或直接传商品页 URL"""
shop_code: str | None = None # 店铺代码,如 edion(商品 URL 的第一段)
item_code: str | None = None # 店铺内商品编号,如 4902370549263(商品 URL 的第二段)
item_url: HttpUrl | None = None
include_sku_variants: bool = True # SKU 组合可能多达数百条,不需要时可关闭
@model_validator(mode="after")
def check_item_target(self) -> ItemDetailRequest:
if not self.item_url and not (self.shop_code and self.item_code):
raise ValueError("需要提供 item_url,或同时提供 shop_code 与 item_code")
return self
class GenreRequest(BaseModel):
"""分类查询参数
不传 genre_id 时返回 39 个顶层分类;传入时返回该分类的信息、祖先路径与直接子分类。
"""
genre_id: str | None = None
class GenreNode(BaseModel):
"""分类树上的一个节点"""
genre_id: str = ""
name: str = ""
# 该分类下的商品数。顶层列表不返回该值:站点给出的是「当前查询在该分类下的
# 命中数」,与分类自身的商品总量不是一回事,避免误用。
item_count: int | None = None
shortcut: str = "" # 站点分类短代码,如 game / flower
is_leaf: bool = False # 叶子分类,没有下级
url: str = "" # 分类页地址
class GenreData(BaseModel):
"""分类查询结果"""
genre_id: str = "" # 空串表示顶层
name: str = ""
full_name: str = "" # 站点给出的完整分类名,仅分类页有
description: str = "" # 站点分类描述,仅分类页有
is_leaf: bool = False
url: str = ""
ancestors: list[GenreNode] = Field(default_factory=list) # 从顶层到父级,不含自身
children: list[GenreNode] = Field(default_factory=list) # 直接子分类
class ShopSummary(BaseModel):
"""店铺信息"""
shop_id: int | None = None
shop_code: str = "" # 店铺 URL 代码,如 edion;与 item_code 一起可定位商品
shop_name: str = ""
shop_url: str = ""
review_score: float = 0.0
review_count: int = 0
class ReviewSummary(BaseModel):
"""评价信息"""
score: float = 0.0
count: int = 0
url: str = ""
class SearchItem(BaseModel):
"""搜索结果中的单个商品"""
item_id: str = "" # 乐天内部商品 ID(搜索结果的 code 字段)
item_code: str = "" # 商品 URL 第二段,调详情接口用
item_name: str = ""
item_url: str = "" # 真实商品页地址;广告位已还原为 originalItemUrl
catch_copy: str = "" # 商品副标题
price: int = 0
price_range: str = "" # 多 SKU 时的价格区间,如 "1000~2000"
has_price_range: bool = False
image_url: str = ""
image_urls: list[str] = Field(default_factory=list)
shop: ShopSummary = Field(default_factory=ShopSummary)
review: ReviewSummary = Field(default_factory=ReviewSummary)
genre_id: str = ""
genre_path: str = "" # 形如 /0/101205/565950/566404
genre_names: list[str] = Field(default_factory=list)
shipping_fee: int | None = None # 站点未给出时为 null
delivery_message: str = ""
point_count: int = 0
is_sold_out: bool = False
is_ad: bool = False # CPC 广告位
has_multi_sku: bool = False
variant_id: str = ""
item_options: dict[str, Any] = Field(default_factory=dict) # 站点 itemOptions 原样透出
class SearchResultData(BaseModel):
"""搜索结果数据"""
keyword: str = ""
page: int = 1
page_size: int = 0
total_count: int = 0 # 站点声明的命中总数
reachable_count: int = 0 # 实际可翻页取到的上限(站点 subset,随查询条件变化)
has_more: bool = False
# 请求页码超出 reachable_count 对应的页数。站点此时不会返回空列表,而是
# 静默回绕到第 1 页;这里识别出来并把 items 置空,避免上游把重复数据当新数据。
out_of_range: bool = False
ad_count: int = 0 # 本页被识别出的广告位数量(exclude_ads=true 时已从 items 剔除)
request_url: str = "" # 实际抓取的乐天页面地址,便于排查
items: list[SearchItem] = Field(default_factory=list)
class SkuAttribute(BaseModel):
"""SKU 属性项"""
title: str = ""
value: str = ""
class SkuAxisValue(BaseModel):
"""SKU 选择轴上的一个取值"""
value: str = ""
label: str = ""
is_sold_out: bool = False
class SkuAxis(BaseModel):
"""SKU 选择轴,如「颜色」「尺码」"""
key: str = ""
label: str = ""
values: list[SkuAxisValue] = Field(default_factory=list)
class SkuVariant(BaseModel):
"""一个具体的 SKU 组合"""
variant_id: str = ""
selector_values: list[str] = Field(default_factory=list) # 与 axis 顺序对应的取值
price: int = 0
quantity: int = 0
is_sold_out: bool = False
delivery_message: str = ""
attributes: list[SkuAttribute] = Field(default_factory=list)
class SkuInfo(BaseModel):
"""商品 SKU 信息"""
inventory_type: str = "" # single / multiple
quantity: int = 0
show_inventory: bool = False
delivery_message: str = ""
attributes: list[SkuAttribute] = Field(default_factory=list)
axis: list[SkuAxis] = Field(default_factory=list)
variants: list[SkuVariant] = Field(default_factory=list) # include_sku_variants=false 时为空
variant_count: int = 0 # 不受 include_sku_variants 影响,始终为真实组合数
class ShippingInfo(BaseModel):
"""配送与运费信息"""
shipping_fee: int | None = None
is_shipping_free: bool = False
is_asuraku: bool = False # あす楽(次日达)
is_next_day_delivery: bool = False
free_shipping_threshold: int | None = None
prefecture_id: int | None = None # 站点默认收货地(13 = 东京都)
delivery_message: str = ""
class Breadcrumb(BaseModel):
"""分类面包屑"""
name: str = ""
url: str = ""
class PurchaseOptionValue(BaseModel):
"""商品选项的一个可选值"""
value_id: str = ""
name: str = ""
class PurchaseOption(BaseModel):
"""商品选项(選択肢),如「名入れ」「ラッピング」
加购时需要按 `名称:取值` 的形式拼进 options_field 指定的字段。
"""
option_id: str = ""
name: str = ""
type: str = "" # select 单选 / check 多选 / text 自由文本
is_required: bool = False
values: list[PurchaseOptionValue] = Field(default_factory=list) # type=text 时为空
class PurchaseInfo(BaseModel):
"""构造加购请求所需的信息
本服务只提供数据、不执行加购——加购需要已登录的乐天账号会话,由持有登录态的
下游负责。四个来源的加购端点与字段名各不相同,因此这里不写死字段,而是把
「提交到哪里、固定字段是什么、数量/规格/选项各该用哪个字段名」显式描述出来:
payload = {**form_fields}
payload[quantity_field] = 数量 # quantity_field 为空表示不支持指定数量
payload[variant_field] = 选中的 sku.variants[].variant_id # variant_field 为空表示无规格
payload[options_field] = ["选项名:取值", ...] # options_field 为空表示无选项
然后以 cart_method 提交到 cart_url。
"""
cart_url: str = ""
cart_method: str = "POST"
form_fields: dict[str, str] = Field(default_factory=dict)
quantity_field: str = ""
variant_field: str = ""
options_field: str = ""
options: list[PurchaseOption] = Field(default_factory=list)
has_required_options: bool = False
class ItemDetailData(BaseModel):
"""商品详情数据
部分乐天官方店的商品页会跳转到独立子站,各子站页面结构不同、可提供的字段也
不同。source 标明这条数据由哪个站点解析而来,字段覆盖差异见 README。
"""
source: str = "ichiba" # ichiba / books / brandavenue / biccamera
source_url: str = "" # 实际解析的页面地址;跳转时与 item_url 不同
item_id: str = ""
item_code: str = ""
item_name: str = ""
catch_copy: str = ""
description: str = "" # 店铺自填的商品说明,含 HTML
item_url: str = ""
price: int = 0 # 最低售价(含税,多 SKU 时为最低价)
pre_tax_price: int = 0
tax_flag: bool = False
tax_rate: float = 0.0
purchase_condition: str = "" # 站点原值,enabled 表示可购买
is_sold_out: bool = False
purchase_unit: int = 0 # 起订单位
images: list[str] = Field(default_factory=list)
shop: ShopSummary = Field(default_factory=ShopSummary)
review: ReviewSummary = Field(default_factory=ReviewSummary)
genre_id: str = ""
breadcrumbs: list[Breadcrumb] = Field(default_factory=list)
shipping: ShippingInfo = Field(default_factory=ShippingInfo)
sku: SkuInfo = Field(default_factory=SkuInfo)
purchase: PurchaseInfo = Field(default_factory=PurchaseInfo)
class HealthData(BaseModel):
"""健康检查响应数据"""
status: str
browser_fallback_enabled: bool
browser_fallback_ready: bool
browser_fallback_error: str | None = None
sessions: dict[str, Any] = Field(default_factory=dict)
# ==========================================================================
# ラクマ(fril.jp)
#
# 乐天市场是 B2C 商城(店铺 × 商品 × SKU),ラクマ 是 C2C 二手集市:
# 每件商品都是独一无二的一件,没有 SKU、没有库存数量、没有店铺代码,
# 卖家用一串 hash 标识。字段因此单独建模,不与市场侧强行合并。
# ==========================================================================
class RakumaSortOption(StrEnum):
"""ラクマ 搜索排序方式,对应搜索页 `sort=` + `order=` 两个参数"""
STANDARD = "standard" # おすすめ順(站点默认)
NEWEST = "newest" # 新着順
PRICE_ASC = "price_asc"
PRICE_DESC = "price_desc"
LIKE_COUNT = "like_count" # いいね数順
class RakumaCondition(StrEnum):
"""ラクマ 商品状态(出品者自己申告的成色,6 档)"""
NEW = "new" # 新品、未使用
ALMOST_NEW = "almost_new" # 未使用に近い
NO_DAMAGE = "no_damage" # 目立った傷や汚れなし
SLIGHT_DAMAGE = "slight_damage" # やや傷や汚れあり
DAMAGED = "damaged" # 傷や汚れあり
POOR = "poor" # 全体的に状態が悪い
class RakumaTransaction(StrEnum):
"""ラクマ 售卖状态筛选"""
ON_SALE = "on_sale" # 販売中のみ
SOLD_OUT = "sold_out" # 売切れのみ
class RakumaAuthenticity(StrEnum):
"""ラクマ 正品鉴定服务类型"""
BEFORE_DELIVERY = "before_delivery" # お届け前鑑定
AFTER_DELIVERY = "after_delivery" # 後から鑑定
class RakumaSearchRequest(BaseModel):
"""ラクマ 搜索请求参数
两种用法(优先级从高到低):
1. 传 search_url:透传一条 fril.jp 搜索页 URL,此时除 page 外的筛选字段全部忽略
2. 传 keyword / category_id / brand_id(可叠加任意筛选字段)
keyword、category_id、brand_id、search_url 四者至少提供一个。
"""
keyword: str = ""
page: int = Field(default=1, ge=1, le=100) # 站点侧 page>100 直接 404
sort: RakumaSortOption = RakumaSortOption.STANDARD
category_id: str | None = None # ラクマ 分类 ID,如 788
brand_id: str | None = None # ラクマ 品牌 ID,如 5296
min_price: int | None = Field(default=None, ge=0)
max_price: int | None = Field(default=None, ge=0)
exclude_keyword: str | None = None # 排除词(站点 `excluded_query`,需与 keyword 同时使用)
conditions: list[RakumaCondition] = Field(default_factory=list) # 可多选
transaction: RakumaTransaction | None = None # 不传表示不限
free_shipping: bool = False # 仅「送料込み」(卖家承担运费)
anonymous_shipping: bool = False # 仅匿名配送
except_for_no_brand: bool = False # 排除无品牌商品;与 brand_id 互斥
authenticity_types: list[RakumaAuthenticity] = Field(default_factory=list)
search_url: HttpUrl | None = None
@model_validator(mode="after")
def check_search_target(self) -> RakumaSearchRequest:
if not self.search_url and not self.keyword.strip() and not self.category_id and not self.brand_id:
raise ValueError("keyword、category_id、brand_id、search_url 至少需要提供一个")
if self.min_price is not None and self.max_price is not None and self.min_price > self.max_price:
raise ValueError("min_price 不能大于 max_price")
# 站点前端在无关键词时会拒绝下发 excluded_query,服务端也不认,这里提前拦下
if self.exclude_keyword and not self.keyword.strip():
raise ValueError("exclude_keyword 必须与 keyword 同时使用")
return self
class RakumaItemDetailRequest(BaseModel):
"""ラクマ 商品详情请求参数:传 item_id(商品 URL 的最后一段),或直接传商品页 URL"""
item_id: str | None = None # 商品页 hash,如 4aca1d6db3e422f3a251a8a8b61e1eff
item_url: HttpUrl | None = None
@model_validator(mode="after")
def check_item_target(self) -> RakumaItemDetailRequest:
if not self.item_url and not (self.item_id or "").strip():
raise ValueError("需要提供 item_id 或 item_url")
return self
class RakumaShopDetailRequest(BaseModel):
"""ラクマ 卖家详情请求参数:传 shop_id(店铺 URL 的最后一段),或直接传店铺页 URL"""
shop_id: str | None = None # 店铺页 hash,如 422750cb7921557bc8dba2416915d968
shop_url: HttpUrl | None = None
# 评价明细在单独的 /review 页上,需要多打一次请求,默认不取
include_reviews: bool = False
@model_validator(mode="after")
def check_shop_target(self) -> RakumaShopDetailRequest:
if not self.shop_url and not (self.shop_id or "").strip():
raise ValueError("需要提供 shop_id 或 shop_url")
return self
class RakumaShopItemsRequest(BaseModel):
"""ラクマ 卖家商品列表请求参数
店铺页按上架顺序分页展示该卖家的全部商品(含已售出),站点不提供
排序与筛选参数,因此这里只有页码。
"""
shop_id: str | None = None
shop_url: HttpUrl | None = None
page: int = Field(default=1, ge=1)
@model_validator(mode="after")
def check_shop_target(self) -> RakumaShopItemsRequest:
if not self.shop_url and not (self.shop_id or "").strip():
raise ValueError("需要提供 shop_id 或 shop_url")
return self
class RakumaSeller(BaseModel):
"""ラクマ 卖家(出品者)摘要"""
shop_id: str = "" # 店铺页 hash,可直接用于 /api/rakuma/shop_detail
user_id: str = "" # 站点内部数值用户 ID
shop_name: str = "" # 店铺名,卖家可自定义
user_name: str = "" # 用户昵称
shop_url: str = ""
icon_url: str = ""
seller_type: str = "" # 站点原值,如 一般 / 事業者
review_score: float = 0.0
review_count: int = 0
is_verified: bool = False # 本人確認済
class RakumaSearchItem(BaseModel):
"""ラクマ 搜索结果中的单个商品"""
item_id: str = "" # 商品页 hash,调详情接口用
item_number: str = "" # 站点内部数值商品 ID
item_name: str = ""
item_url: str = ""
price: int = 0
image_url: str = ""
is_sold_out: bool = False
brand_id: str = ""
brand_name: str = ""
category_id: str = ""
category_names: list[str] = Field(default_factory=list)
seller_user_id: str = "" # 卖家数值 ID;店铺 hash 需从详情页取
seller_type: str = ""
class RakumaSearchResultData(BaseModel):
"""ラクマ 搜索结果数据"""
keyword: str = ""
page: int = 1
page_size: int = 0
# 站点声明的命中总数。页面上展示为「約1,190,000件」的四舍五入值,
# 这里取的是埋点属性里的精确值。
total_count: int = 0
has_more: bool = False
request_url: str = ""
items: list[RakumaSearchItem] = Field(default_factory=list)
class RakumaItemDetailData(BaseModel):
"""ラクマ 商品详情数据
C2C 集市的商品是单件的:没有 SKU 组合,没有库存数量,
「規格」在站点上只体现为一个可选的尺码字段。
"""
item_id: str = ""
item_number: str = ""
item_name: str = ""
description: str = ""
item_url: str = ""
price: int = 0
is_sold_out: bool = False
images: list[str] = Field(default_factory=list)
condition: str = "" # 商品の状態,站点原文如「目立った傷や汚れなし」
size: str = "" # サイズ,无尺码时为空
brand_id: str = ""
brand_name: str = ""
category_id: str = "" # 最具体的一级分类 ID
breadcrumbs: list[Breadcrumb] = Field(default_factory=list)
shipping_payer: str = "" # 配送料の負担,如「送料込」
shipping_method: str = "" # 配送方法
shipping_date_estimate: str = "" # 発送日の目安
shipping_from: str = "" # 発送元の地域
is_anonymous_shipping: bool = False # 匿名配送
like_count: int = 0 # いいね数
comment_count: int = 0
posted_at: str = "" # 站点展示的相对时间,如「約1時間前」
seller: RakumaSeller = Field(default_factory=RakumaSeller)
class RakumaReview(BaseModel):
"""ラクマ 卖家的一条交易评价"""
rating: str = "" # good / normal / bad
title: str = "" # 站点原文,如「よい出品者です」
comment: str = ""
reviewer_name: str = ""
reviewed_at: str = "" # 站点展示的日期,如 2026/05/04
class RakumaRatingBreakdown(BaseModel):
"""评价数量分档"""
good: int = 0
normal: int = 0
bad: int = 0
class RakumaShopDetailData(BaseModel):
"""ラクマ 卖家详情数据"""
shop_id: str = ""
user_id: str = ""
shop_name: str = ""
user_name: str = ""
shop_url: str = ""
icon_url: str = ""
cover_url: str = ""
introduction: str = "" # プロフィール文
review_score: float = 0.0
review_count: int = 0
is_verified: bool = False # 本人確認済
verification_label: str = "" # 站点原文,如「本人確認済」/「本人確認未完了」
item_count: int = 0 # 该卖家在售 + 已售商品总数
# 以下三项需 include_reviews=true 才会填充
rating_breakdown: RakumaRatingBreakdown = Field(default_factory=RakumaRatingBreakdown)
seller_rating_breakdown: RakumaRatingBreakdown = Field(default_factory=RakumaRatingBreakdown)
reviews: list[RakumaReview] = Field(default_factory=list)
class RakumaShopItemsData(BaseModel):
"""ラクマ 卖家商品列表数据"""
shop_id: str = ""
shop_name: str = ""
page: int = 1
total_count: int = 0 # 该卖家的商品总数
has_more: bool = False
request_url: str = ""
items: list[RakumaSearchItem] = Field(default_factory=list)
View File
+103
View File
@@ -0,0 +1,103 @@
"""分类页/搜索页 __INITIAL_STATE__ → GenreData
分类数据来自 state.data.genreTree.parent_category。它不是完整分类树,而是一条
「从根一路展开到目标分类」的链:每层只保留通往目标的那一个子节点,目标分类自身
则挂着它的全部直接子分类。
不带分类查询时,根节点直接挂着 39 个顶层分类。
"""
from __future__ import annotations
from typing import Any
from app.core import site
from app.core.errors import ScrapeParseError
from app.models.scrape import GenreData, GenreNode
from app.utils.coerce import as_dict, as_int, as_list, as_str
# 站点用 id=0 表示分类树的虚拟根,它不是一个真实分类
ROOT_GENRE_ID = 0
def _genre_url(genre_id: str) -> str:
return f"{site.CATEGORY_BASE_URL}{genre_id}/" if genre_id else ""
def _to_node(raw: dict[str, Any], *, with_count: bool) -> GenreNode:
genre_id = str(as_int(raw.get("id")))
count = raw.get("count")
return GenreNode(
genre_id=genre_id,
name=as_str(raw.get("name")),
item_count=as_int(count) if with_count and count is not None else None,
shortcut=as_str(raw.get("shortcut")),
is_leaf=bool(raw.get("leaf")),
url=_genre_url(genre_id),
)
def _find_path(node: dict[str, Any], genre_id: str) -> list[dict[str, Any]] | None:
"""在分类链中定位目标分类,返回从根到它的节点路径(含自身)"""
if str(as_int(node.get("id"))) == genre_id:
return [node]
for child in as_list(node.get("children")):
if not isinstance(child, dict):
continue
found = _find_path(child, genre_id)
if found is not None:
return [node, *found]
return None
def parse_genres(state: dict[str, Any], *, genre_id: str | None) -> GenreData:
"""解析分类树
Args:
genre_id: 目标分类;None 表示取顶层分类列表
Raises:
ScrapeParseError: 页面里没有分类树,或目标分类不在返回的链上
"""
data = as_dict(as_dict(state.get("state")).get("data"))
root = as_dict(as_dict(data.get("genreTree")).get("parent_category"))
if not root:
raise ScrapeParseError("页面中缺少 genreTree.parent_category 节点")
if genre_id is None:
children = [
_to_node(child, with_count=False)
for child in as_list(root.get("children"))
if isinstance(child, dict)
]
if not children:
raise ScrapeParseError("未能取到顶层分类列表")
return GenreData(children=children)
path = _find_path(root, genre_id)
if path is None:
raise ScrapeParseError(f"分类树中未找到分类 {genre_id}")
target = path[-1]
ancestors = [
_to_node(node, with_count=False)
for node in path[:-1]
if as_int(node.get("id")) != ROOT_GENRE_ID
]
children = [
_to_node(child, with_count=True)
for child in as_list(target.get("children"))
if isinstance(child, dict)
]
genre_info = as_dict(data.get("genreInfo"))
resolved_id = str(as_int(target.get("id")))
return GenreData(
genre_id=resolved_id,
name=as_str(target.get("name")),
full_name=as_str(genre_info.get("fullGenreName")),
description=as_str(genre_info.get("description")),
is_leaf=bool(target.get("leaf")),
url=_genre_url(resolved_id),
ancestors=ancestors,
children=children,
)
+281
View File
@@ -0,0 +1,281 @@
"""商品详情页 __INITIAL_STATE__ → ItemDetailData
与搜索页不同,详情页的状态没有 `state` 包裹层,各业务块直接挂在顶层:
item / purchase / shop / review / shipping / breadcrumbs。
注意:只有手机 UA 才会拿到这套统一模板;PC UA 返回的是各店铺自定义的 EUC-JP
老页面,里面没有 __INITIAL_STATE__。UA 的选择由 RakutenClient 负责。
"""
from __future__ import annotations
from typing import Any
from app.core.errors import ScrapeParseError
from app.models.scrape import (
Breadcrumb,
ItemDetailData,
PurchaseInfo,
PurchaseOption,
PurchaseOptionValue,
ReviewSummary,
ShippingInfo,
ShopSummary,
SkuAttribute,
SkuAxis,
SkuAxisValue,
SkuInfo,
SkuVariant,
)
from app.utils.coerce import as_dict, as_float, as_int, as_list, as_str
# purchase.sellType 下表示「可正常购买」的状态值
_PURCHASABLE_CONDITION = "enabled"
# 普通购买的事件标识,站点前端构造加购表单时固定带上
_NORMAL_PURCHASE_EVENT = "ES01_003_001"
# 库存类型 → 加购表单里的 inventory_flag
_INVENTORY_FLAG = {"multiple": 2}
_DEFAULT_INVENTORY_FLAG = 1
def _parse_attributes(raw: Any) -> list[SkuAttribute]:
return [
SkuAttribute(title=as_str(attr.get("title")), value=as_str(attr.get("value")))
for attr in as_list(raw)
if isinstance(attr, dict)
]
def _parse_axis(raw: Any) -> list[SkuAxis]:
axes: list[SkuAxis] = []
for axis in as_list(raw):
if not isinstance(axis, dict):
continue
axes.append(
SkuAxis(
key=as_str(axis.get("key")),
label=as_str(axis.get("label")),
values=[
SkuAxisValue(
value=as_str(value.get("value")),
label=as_str(value.get("label")),
is_sold_out=bool(value.get("isSoldOut")),
)
for value in as_list(axis.get("values"))
if isinstance(value, dict)
],
)
)
return axes
def _parse_variants(raw: Any) -> list[SkuVariant]:
variants: list[SkuVariant] = []
for variant in as_list(raw):
if not isinstance(variant, dict):
continue
quantity = as_int(variant.get("quantity"))
variants.append(
SkuVariant(
variant_id=as_str(variant.get("variantId")),
selector_values=[as_str(value) for value in as_list(variant.get("selectorValues"))],
price=as_int(variant.get("price")),
quantity=quantity,
# 站点未给 SKU 级的售罄标记,库存为 0 即视为该组合不可购买
is_sold_out=quantity <= 0,
delivery_message=as_str(variant.get("deliveryMessageRMS")),
attributes=_parse_attributes(variant.get("attributes")),
)
)
return variants
def parse_purchase_options(raw: Any) -> list[PurchaseOption]:
"""解析商品选项(選択肢)
结构为 {id, name, type: select|check|text, isRequired, values:[{id, name}]};
type=text 的选项没有候选值,由买家自由填写(如刻字内容)。
"""
options: list[PurchaseOption] = []
for option in as_list(raw):
if not isinstance(option, dict):
continue
options.append(
PurchaseOption(
option_id=str(as_int(option.get("id"))) if option.get("id") is not None else "",
name=as_str(option.get("name")),
type=as_str(option.get("type")),
is_required=bool(option.get("isRequired")),
values=[
PurchaseOptionValue(
value_id=str(as_int(value.get("id"))) if value.get("id") is not None else "",
name=as_str(value.get("name")),
)
for value in as_list(option.get("values"))
if isinstance(value, dict)
],
)
)
return options
def _purchase_info(
*,
sell_type: dict[str, Any],
raw_sku: dict[str, Any],
information: dict[str, Any],
shop_id: int,
item_id: str,
item_variant_id: str,
) -> PurchaseInfo:
"""组装加购所需的端点与字段
字段名与取值来自站点前端构造加购表单的逻辑(getPurchaseFormData)。
basketDomain 逐商品不同(不同店铺落在不同的 basket 集群),不能写死。
"""
inventory_flag = _INVENTORY_FLAG.get(as_str(raw_sku.get("inventoryType")), _DEFAULT_INVENTORY_FLAG)
form_fields = {
"shop_bid": str(shop_id),
"item_id": item_id,
"inventory_flag": str(inventory_flag),
"__event": _NORMAL_PURCHASE_EVENT,
}
# 单一库存商品的规格是固定的,直接填好,调用方无需再选
if inventory_flag == _DEFAULT_INVENTORY_FLAG and item_variant_id:
form_fields["variant_id"] = item_variant_id
options = parse_purchase_options(information.get("options"))
return PurchaseInfo(
cart_url=as_str(sell_type.get("basketDomain")),
form_fields=form_fields,
quantity_field="units",
variant_field="variant_id",
options_field="choice" if options else "",
options=options,
has_required_options=any(option.is_required for option in options),
)
def _pick_sell_type(sell_type: dict[str, Any]) -> dict[str, Any]:
"""取售卖方式信息,优先普通购买,其次任意一种带价格的方式(如定期购)"""
normal = as_dict(sell_type.get("normalPurchase"))
if normal:
return normal
for value in sell_type.values():
candidate = as_dict(value)
if "minPrice" in candidate:
return candidate
return {}
def parse_item_detail(
state: dict[str, Any],
*,
item_url: str,
shop_code: str,
include_sku_variants: bool,
) -> ItemDetailData:
"""把商品详情页状态解析为商品详情
Raises:
ScrapeParseError: 状态中不存在 item 节点
"""
item = state.get("item")
if not isinstance(item, dict) or not item:
raise ScrapeParseError("商品详情缺少 item 节点")
purchase = as_dict(state.get("purchase"))
sell_type = _pick_sell_type(as_dict(purchase.get("sellType")))
purchase_condition = as_str(sell_type.get("purchaseCondition"))
raw_sku = as_dict(purchase.get("sku"))
variants = _parse_variants(raw_sku.get("variants"))
sku = SkuInfo(
inventory_type=as_str(raw_sku.get("inventoryType")),
quantity=as_int(raw_sku.get("quantity")),
show_inventory=bool(raw_sku.get("showInventory")),
delivery_message=as_str(raw_sku.get("deliveryMessageRMS")),
attributes=_parse_attributes(raw_sku.get("attributes")),
axis=_parse_axis(raw_sku.get("axis")),
variants=variants if include_sku_variants else [],
variant_count=len(variants),
)
shop_information = as_dict(as_dict(state.get("shop")).get("information"))
shop_review = as_dict(shop_information.get("shopReview"))
resolved_shop_code = as_str(shop_information.get("shopUrl")) or shop_code
shop = ShopSummary(
shop_id=as_int(shop_information.get("shopId")) or None,
shop_code=resolved_shop_code,
shop_name=as_str(shop_information.get("shopName")),
shop_url=f"https://www.rakuten.co.jp/{resolved_shop_code}/" if resolved_shop_code else "",
review_score=as_float(shop_review.get("rating")),
review_count=as_int(shop_review.get("total")),
)
item_review = as_dict(as_dict(state.get("review")).get("item"))
review = ReviewSummary(
score=as_float(item_review.get("totalRating")),
count=as_int(item_review.get("count")),
)
shipping_raw = as_dict(as_dict(state.get("shipping")).get("informationFromServer"))
shipping_fee = shipping_raw.get("shippingFee")
threshold = shipping_raw.get("freeShippingThreshold")
shipping = ShippingInfo(
shipping_fee=as_int(shipping_fee) if shipping_fee is not None else None,
is_shipping_free=bool(shipping_raw.get("isShippingFree")),
is_asuraku=bool(shipping_raw.get("isAsuraku")),
is_next_day_delivery=bool(shipping_raw.get("isNextDayDelivery")),
free_shipping_threshold=as_int(threshold) if threshold is not None else None,
prefecture_id=as_int(shipping_raw.get("prefectureId")) or None,
delivery_message=as_str(raw_sku.get("deliveryMessageRMS")),
)
breadcrumbs = [
Breadcrumb(name=as_str(crumb.get("name")), url=as_str(crumb.get("url")))
for crumb in as_list(as_dict(state.get("breadcrumbs")).get("genreBreadcrumbs"))
if isinstance(crumb, dict)
]
images = [
as_str(image.get("imageUrl"))
for image in as_list(as_dict(item.get("media")).get("images"))
if isinstance(image, dict) and as_str(image.get("imageUrl"))
]
item_id = str(as_int(item.get("itemId"))) if item.get("itemId") is not None else ""
return ItemDetailData(
source="ichiba",
source_url=item_url, # 市场页无跳转,解析地址即商品地址
item_id=item_id,
item_code=as_str(item.get("itemNumber")),
item_name=as_str(item.get("itemName")),
catch_copy=as_str(item.get("catchCopy")),
description=as_str(item.get("description")),
item_url=item_url,
price=as_int(sell_type.get("minPrice")),
pre_tax_price=as_int(sell_type.get("preTaxPrice")),
tax_flag=bool(item.get("taxFlag")),
tax_rate=as_float(shop_information.get("taxRate")),
purchase_condition=purchase_condition,
# purchaseCondition 是站点判定能否下单的直接依据;缺失时不臆断为售罄
is_sold_out=bool(purchase_condition) and purchase_condition != _PURCHASABLE_CONDITION,
purchase_unit=as_int(as_dict(purchase.get("information")).get("unit")),
images=images,
shop=shop,
review=review,
genre_id=str(as_int(item.get("genreId"))) if item.get("genreId") is not None else "",
breadcrumbs=breadcrumbs,
shipping=shipping,
sku=sku,
purchase=_purchase_info(
sell_type=sell_type,
raw_sku=raw_sku,
information=as_dict(purchase.get("information")),
shop_id=as_int(shop_information.get("shopId")),
item_id=item_id,
item_variant_id=as_str(item.get("variantId")),
),
)
+8
View File
@@ -0,0 +1,8 @@
"""ラクマ(fril.jp)页面解析器
站点是服务端渲染的 HTML,没有内联状态 JSON,因此各模块都走 DOM 解析:
- base — 埋点属性与文本取值的公共工具
- search — 搜索页(商品卡片解析同时被店铺页复用)
- item — 商品详情页
- shop — 店铺页与评价页
"""
+144
View File
@@ -0,0 +1,144 @@
"""ラクマ(fril.jp)页面解析的公共工具
站点是服务端渲染的 HTML,没有 `window.__INITIAL_STATE__` 之类的内联状态,
因此全部走 DOM 解析。好在页面上挂了成套的埋点属性(`data-rat-*` 与
`onclick` 里的 dataLayer JSON),它们比可见文案稳定得多,也带有可见 DOM
上没有的字段(商品数值 ID、卖家 ID、分类 ID、品牌 ID),所以优先取这些。
"""
from __future__ import annotations
import html
import json
import re
from typing import Any
from selectolax.parser import Node
# 「約1,190,000件中 1 - 40件」里的总数与区间
_COUNT_RE = re.compile(r"([\d,]+)\s*件中\s*([\d,]+)\s*[-−–]\s*([\d,]+)\s*件")
_DIGITS_RE = re.compile(r"-?\d+")
# 页面级埋点属性 data-rat-cp-{key}="{value}"
_RAT_PARAM_RE = re.compile(r'data-rat-cp-([\w]+)="([^"]*)"')
def parse_int(text: str | int | float | None) -> int:
""""¥6,299" / "6399" / 6399 这类值里取出整数金额或计数"""
if isinstance(text, bool) or text is None:
return 0
if isinstance(text, (int, float)):
return int(text)
digits = _DIGITS_RE.findall(text.replace(",", ""))
return int(digits[0]) if digits else 0
def parse_float(text: str | int | float | None) -> float:
""""5.0" 这类文本里取出评分"""
if isinstance(text, bool) or text is None:
return 0.0
if isinstance(text, (int, float)):
return float(text)
match = re.search(r"\d+(?:\.\d+)?", text.replace(",", ""))
return float(match.group()) if match else 0.0
def node_text(node: Node | None) -> str:
"""取节点的可见文本,压掉多余空白;节点不存在时返回空串"""
if node is None:
return ""
return re.sub(r"\s+", " ", node.text(strip=True)).strip()
def attr(node: Node | None, name: str) -> str:
"""取节点属性,缺失时返回空串"""
if node is None:
return ""
return (node.attributes.get(name) or "").strip()
def image_url(node: Node | None) -> str:
"""取图片地址:站点用 lazy load,真实地址在 data-original 上,src 是占位图"""
if node is None:
return ""
return attr(node, "data-original") or attr(node, "src")
def parse_total_count(text: str) -> tuple[int, int, int]:
"""解析「N件中 X - Y件」,返回 (总数, 起, 止);解析不出时全为 0
注意搜索页这里的总数是四舍五入后的展示值(約1,190,000件),
精确值要从埋点属性 data-rat-cp-totalresults 取;店铺页则是精确值。
"""
match = _COUNT_RE.search(text.replace("\xa0", " "))
if match is None:
return 0, 0, 0
return (
parse_int(match.group(1)),
parse_int(match.group(2)),
parse_int(match.group(3)),
)
def event_payload(node: Node | None) -> dict[str, Any]:
"""从埋点里取出商品参数字典
商品链接的 onclick / data-gtm-click 上挂着一段 dataLayer JSON,形如:
{"event":"fireEvent","eventData":{"event_parameter":{
"item_id":"844649627","seller_user_id":"12073120",
"category_id":"788","brand_id":"5296","price":6299, ...}}}
这里面有可见 DOM 上没有的数值 ID,是搜索结果里最可靠的数据来源。
"""
if node is None:
return {}
for source in (node.attributes.get("data-gtm-click"), node.attributes.get("onclick")):
if not source:
continue
for raw in _iter_json_objects(html.unescape(source)):
parameter = (
raw.get("eventData", {}).get("event_parameter")
if isinstance(raw.get("eventData"), dict)
else None
)
if isinstance(parameter, dict) and "item_id" in parameter:
return parameter
return {}
def find_item_payload(tree: Any) -> dict[str, Any]:
"""在整页里找出第一段带 item_id 的埋点参数
详情页的这段 JSON 挂在哪个元素上并不固定(在售商品挂在品牌链接上,
已售商品的页面结构不同),因此按属性扫描而不是写死选择器。
"""
for node in tree.css("[data-gtm-click], [onclick]"):
payload = event_payload(node)
if payload:
return payload
return {}
def rat_params(html_text: str) -> dict[str, str]:
"""取出页面级埋点属性 `data-rat-cp-*`
详情页把成色、运费负担、发货地等信息也写在这组属性里。已售出商品的
页面会换成另一套布局、规格表消失,但这组属性仍在,可用作兜底。
"""
return {
match.group(1): html.unescape(match.group(2))
for match in _RAT_PARAM_RE.finditer(html_text)
}
def _iter_json_objects(text: str):
"""从一段掺杂着 JS 代码的文本里增量解析出所有顶层 JSON 对象"""
decoder = json.JSONDecoder()
index = text.find("{")
while index >= 0:
try:
value, end = decoder.raw_decode(text, index)
except ValueError:
index = text.find("{", index + 1)
continue
if isinstance(value, dict):
yield value
index = text.find("{", max(end, index + 1))
+211
View File
@@ -0,0 +1,211 @@
"""ラクマ 商品详情页 HTML → RakumaItemDetailData
页面数据分三处,各取所长:
- `<script type="application/ld+json">` 的 Product 微数据:名称、价格、描述、图片
- `.item__details` 规格表:成色、尺码、配送方式与地区(每行 th 上的
`item-status-{key}` class 是稳定键,比日文标签文案可靠)
- 埋点属性 `data-rat-cp-*` 与 dataLayer JSON:数值 ID、分类 ID、品牌 ID
售罄判定用页面上的 SOLD OUT 标记,而不是 ld+json 的 availability——
实测已售出商品的 ld+json 仍写 InStock,不可信。
"""
from __future__ import annotations
import json
import re
from typing import Any
from selectolax.parser import HTMLParser
from app.core import rakuma_site as site
from app.core.errors import ScrapeParseError
from app.models.scrape import Breadcrumb, RakumaItemDetailData, RakumaSeller
from app.parsers.rakuma.base import (
attr,
find_item_payload,
image_url,
node_text,
parse_float,
parse_int,
rat_params,
)
from app.utils.rakuma_urls import split_shop_url
_LD_JSON_RE = re.compile(
r'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>', 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 里有个 `<i class="icon-status ... item-status-{key}">`,
这个 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),
)
+135
View File
@@ -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,
)
+223
View File
@@ -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'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>', 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:
"""解析一组好评/普通/差评计数
页面用 `<ul class="nav-pills">` 里的三个链接展示,锚点形如
`#all-good` / `#seller-normal`,按锚点前缀区分「全部」与「出品」两组。
"""
counts = {"good": 0, "normal": 0, "bad": 0}
for link in tree.css("ul.nav-pills a"):
href = attr(link, "href")
for rating in counts:
if href == f"#{prefix}-{rating}":
counts[rating] = parse_int(node_text(link))
return RakumaRatingBreakdown(**counts)
def _parse_reviews(tree: HTMLParser) -> list[RakumaReview]:
"""解析评价列表
站点在「すべての評価」标签页里最多展示最新 100 条,且三个标签页
(全部/出品/购入)的条目在 DOM 里重复出现,这里只取第一个激活面板。
"""
panel = tree.css_first("#all-all") or tree.css_first(".tab-pane.active")
if panel is None:
return []
reviews: list[RakumaReview] = []
for article in panel.css("article.review-item"):
title_node = article.css_first(".review-item-title")
icon = title_node.css_first("i") if title_node else None
classes = attr(icon, "class").split() if icon else []
rating = next((_RATING_ICONS[name] for name in classes if name in _RATING_ICONS), "")
reviews.append(
RakumaReview(
rating=rating,
title=node_text(title_node),
comment=node_text(article.css_first(".review-item-text")),
reviewer_name=node_text(article.css_first(".review-item-name")),
reviewed_at=node_text(article.css_first(".review-item-date")),
)
)
return reviews
def _parse_store_rating(html: str) -> tuple[float, int]:
"""从店铺页的 ld+json Store 节点取评分与评价数
评价数只有这里给得出来——可见 DOM 上只有星级和分数,没有条数。
"""
for match in _LD_JSON_RE.finditer(html):
try:
data = json.loads(match.group(1))
except ValueError:
continue
if not isinstance(data, dict) or data.get("@type") != "Store":
continue
rating = data.get("aggregateRating")
if isinstance(rating, dict):
return parse_float(rating.get("ratingValue")), parse_int(rating.get("ratingCount"))
return 0.0, 0
def parse_shop_detail(
html: str, *, shop_id: str, shop_url: str, review_html: str | None = None
) -> RakumaShopDetailData:
"""把店铺页 HTML 解析为卖家详情
Args:
review_html: /review 子页的 HTML;给出时才填充评价明细与分档计数
Raises:
ScrapeParseError: 页面不是店铺页
"""
tree = _require_shop_page(html)
total_count, _, _ = parse_total_count(node_text(tree.css_first(".page-count")))
badge = tree.css_first(".badge-status")
verification_label = node_text(badge)
# 简介在侧栏「プロフィール」区块;卖家未填写时站点会写一句占位文案
introduction = node_text(tree.css_first("[data-test=profile-text-top]"))
if "設定されていません" in introduction:
introduction = ""
score, review_count = _parse_store_rating(html)
detail = RakumaShopDetailData(
shop_id=shop_id,
shop_name=node_text(tree.css_first(".profile-area__shop-name")),
user_name=node_text(tree.css_first("[data-test=profile_user_name], .profile-area__user-name")),
shop_url=shop_url,
icon_url=image_url(tree.css_first(".profile-area__user-icon img")),
cover_url=_cover_url(tree),
introduction=introduction,
review_score=score or parse_float(node_text(tree.css_first(".shop_score__score"))),
review_count=review_count,
is_verified="未完了" not in verification_label and bool(verification_label),
verification_label=verification_label,
item_count=total_count,
)
# 用户数值 ID:优先取商品卡片埋点里的 seller_user_id(店铺页所有商品都属于
# 该卖家),卖家未设头像时头像地址是站点默认图,取不到 ID。
detail.user_id = _seller_user_id(tree) or _user_id_from_icon(detail.icon_url)
if review_html is not None:
review_tree = HTMLParser(review_html)
detail.rating_breakdown = _parse_rating_breakdown(review_tree, _ALL_RATINGS_PREFIX)
detail.seller_rating_breakdown = _parse_rating_breakdown(review_tree, _SELLER_RATINGS_PREFIX)
detail.reviews = _parse_reviews(review_tree)
return detail
def _cover_url(tree: HTMLParser) -> str:
"""封面图挂在 inline style 的 background url() 里"""
cover = tree.css_first(".profile-area__shop-cover")
style = attr(cover, "style")
start = style.find("url(")
if start < 0:
return ""
end = style.find(")", start)
return style[start + 4 : end].strip("'\" ") if end > start else ""
def _seller_user_id(tree: HTMLParser) -> str:
"""从店铺页商品卡片的埋点里取卖家数值 ID"""
for node in tree.css("[data-gtm-click], [onclick]"):
payload = event_payload(node)
user_id = payload.get("seller_user_id")
if user_id:
return str(user_id)
return ""
def _user_id_from_icon(icon_url: str) -> str:
"""从头像地址 https://img.fril.jp/user/{id}/s/{id}.jpg 里取用户数值 ID"""
marker = "/user/"
start = icon_url.find(marker)
if start < 0:
return ""
rest = icon_url[start + len(marker) :]
user_id = rest.split("/", 1)[0]
return user_id if user_id.isdigit() else ""
def parse_shop_items(
html: str, *, shop_id: str, request_url: str, page: int
) -> RakumaShopItemsData:
"""把店铺页 HTML 解析为该卖家的商品列表
Raises:
ScrapeParseError: 页面不是店铺页
"""
tree = _require_shop_page(html)
items = parse_item_cards(tree)
total_count, _, end = parse_total_count(node_text(tree.css_first(".page-count")))
return RakumaShopItemsData(
shop_id=shop_id,
shop_name=node_text(tree.css_first(".profile-area__shop-name")),
page=page,
total_count=total_count,
has_more=bool(items) and bool(end) and end < total_count,
request_url=request_url,
items=items,
)
+164
View File
@@ -0,0 +1,164 @@
"""搜索页 __INITIAL_STATE__ → SearchResultData
数据位于 state.data.ichibaSearch,含 pagination 与 items 两部分。
需要注意的两个站点行为:
- items 里会混入 CPC 广告位,其 url 指向 grp07.ias.rakuten.co.jp 跳转域,
真实商品地址在 originalItemUrl;判定依据是 itemOptions.cpc 非空。
- pagination.numFound 是命中总数,但实际只能翻到 pagination.subset 条为止。
"""
from __future__ import annotations
from typing import Any
from app.core import site
from app.core.errors import ScrapeParseError
from app.models.scrape import ReviewSummary, SearchItem, SearchResultData, ShopSummary
from app.utils.coerce import as_dict, as_float, as_int, as_list, as_str
from app.utils.urls import item_url_parts
def parse_search_item(raw: dict[str, Any]) -> SearchItem:
"""解析单个搜索结果条目"""
item_options = as_dict(raw.get("itemOptions"))
is_ad = bool(item_options.get("cpc"))
# 广告位的 url 是跳转链接,真实商品地址只在 originalItemUrl 里
original_url = as_str(raw.get("originalItemUrl"))
fallback_url = as_str(raw.get("url"))
item_url = original_url or fallback_url
shop_code, item_code = item_url_parts(item_url)
if not item_code and fallback_url and fallback_url != item_url:
shop_code, item_code = item_url_parts(fallback_url)
images = [
as_str(image.get("url"))
for image in as_list(raw.get("images"))
if isinstance(image, dict) and as_str(image.get("url"))
]
raw_shop = as_dict(raw.get("shop"))
shop_review = as_dict(raw_shop.get("review"))
shop = ShopSummary(
shop_id=as_int(raw_shop.get("id")) or None,
shop_code=as_str(raw_shop.get("urlCode")) or shop_code,
shop_name=as_str(raw_shop.get("name")),
shop_url=as_str(raw_shop.get("url")),
review_score=as_float(shop_review.get("score")),
review_count=as_int(shop_review.get("count")),
)
raw_review = as_dict(raw.get("review"))
review = ReviewSummary(
score=as_float(raw_review.get("score")),
count=as_int(raw_review.get("numReviews")),
url=as_str(raw_review.get("url")),
)
genre_path = as_str(raw.get("genreIdList"))
genre_names = [
as_str(genre.get("name"))
for genre in as_list(raw.get("genres"))
if isinstance(genre, dict) and as_str(genre.get("name"))
]
# genreIdList 形如 /0/101205/565950/566404,末段为最具体的分类;根节点 0 不算
genre_id = next(
(segment for segment in reversed(genre_path.split("/")) if segment and segment != "0"),
"",
)
raw_shipping = as_dict(raw.get("shipping"))
shipping_price = raw_shipping.get("price")
sku_info = as_dict(raw.get("skuInfo"))
return SearchItem(
item_id=as_str(raw.get("code")),
item_code=item_code,
item_name=as_str(raw.get("name")),
item_url=item_url,
catch_copy=as_str(raw.get("subtitle")),
price=as_int(raw.get("price")),
price_range=as_str(sku_info.get("priceRange")),
has_price_range=bool(raw.get("hasPriceRange")),
image_url=images[0] if images else "",
image_urls=images,
shop=shop,
review=review,
genre_id=genre_id,
genre_path=genre_path,
genre_names=genre_names,
shipping_fee=as_int(shipping_price) if shipping_price is not None else None,
delivery_message=as_str(raw_shipping.get("estimateDeliveryDay")),
point_count=as_int(as_dict(raw.get("point")).get("count")),
is_sold_out=bool(raw.get("isSoldOut")),
is_ad=is_ad,
has_multi_sku=bool(sku_info.get("hasMultiSku")),
variant_id=as_str(raw.get("variantId")),
item_options=item_options,
)
def parse_search(
state: dict[str, Any],
*,
request_url: str,
page: int,
exclude_ads: bool,
) -> SearchResultData:
"""把搜索页状态解析为搜索结果
Raises:
ScrapeParseError: 状态中不存在 ichibaSearch 节点,或站点返回了错误
"""
data = as_dict(as_dict(state.get("state")).get("data"))
search = data.get("ichibaSearch")
if not isinstance(search, dict):
raise ScrapeParseError("搜索结果缺少 ichibaSearch 节点")
error = search.get("error")
if error:
raise ScrapeParseError(f"站点返回搜索错误:{error}")
pagination = as_dict(search.get("pagination"))
ui_question = as_dict(data.get("effectiveUiQuestion"))
# 站点声明的每页条数;items 实际长度会因为混入广告位而略大于它
page_size = as_int(pagination.get("pageSize"))
total_count = as_int(pagination.get("numFound"))
subset = as_int(pagination.get("subset")) or site.DEFAULT_SUBSET_LIMIT
reachable_count = min(total_count, subset) if total_count else 0
start = as_int(pagination.get("start"))
# 请求页超出可达窗口时,站点会静默回绕到第 1 页并把 start/page 重置为 0/1。
# 拿站点回报的页码与请求页码对账即可识别,比自行按 subset 推算更可靠。
site_page = as_int(ui_question.get("page"), page)
out_of_range = page > 1 and site_page != page
raw_items = [item for item in as_list(search.get("items")) if isinstance(item, dict)]
items = [parse_search_item(item) for item in raw_items]
ad_count = sum(1 for item in items if item.is_ad)
if exclude_ads:
items = [item for item in items if not item.is_ad]
if out_of_range:
# 这一页的内容是第 1 页的副本,交给上游只会造成重复入库
items = []
ad_count = 0
has_more = (
not out_of_range
and bool(raw_items)
and (start + (page_size or len(raw_items))) < reachable_count
)
return SearchResultData(
keyword=as_str(ui_question.get("keywords")),
page=page,
page_size=page_size,
total_count=total_count,
reachable_count=reachable_count,
has_more=has_more,
out_of_range=out_of_range,
ad_count=ad_count,
request_url=request_url,
items=items,
)
+79
View File
@@ -0,0 +1,79 @@
"""乐天店铺页 __INITIAL_STATE__ → ShopDetailData
店铺首页(www.rakuten.co.jp/{店铺代码}/)与搜索页一样把状态内联在
`window.__INITIAL_STATE__` 里,店铺信息在顶层的 `shop` 节点下,结构比商品
详情页里的 shop.information 更完整(多出招牌图、39ショップ标记、休息日)。
店铺 logo 不在 state 里,只出现在页面的 ld+json AggregateRating 微数据中。
"""
from __future__ import annotations
import json
import re
from typing import Any
from app.core import site
from app.core.errors import ScrapeParseError
from app.models.scrape import ShopDetailData
from app.utils.coerce import as_dict, as_float, as_int, as_list, as_str
_LD_JSON_RE = re.compile(
r'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>', re.S
)
def _parse_logo_url(html: str) -> str:
"""从 ld+json 的 AggregateRating.itemReviewed 里取店铺 logo"""
for match in _LD_JSON_RE.finditer(html):
try:
data = json.loads(match.group(1))
except ValueError:
continue
if not isinstance(data, dict) or data.get("@type") != "AggregateRating":
continue
reviewed = data.get("itemReviewed")
if isinstance(reviewed, dict):
return as_str(reviewed.get("logo"))
return ""
def parse_shop_detail(
state: dict[str, Any], *, shop_code: str, html: str = ""
) -> ShopDetailData:
"""把店铺页状态解析为商家详情
Raises:
ScrapeParseError: 状态中不存在 shop 节点
"""
shop = state.get("shop")
if not isinstance(shop, dict) or not shop:
raise ScrapeParseError("店铺页缺少 shop 节点")
review = as_dict(shop.get("shopReview"))
resolved_code = as_str(shop.get("shopCode")) or shop_code
# state 里的 shopUrl 是 http 的且不带尾斜杠,统一按规范形式给出
shop_url = f"{site.WWW_BASE_URL}{resolved_code}/" if resolved_code else ""
holidays = [
as_str(day)
for day in as_list(as_dict(as_dict(shop.get("shopCalendar")).get("eventDates")).get("holiday"))
if as_str(day)
]
return ShopDetailData(
shop_id=as_int(shop.get("shopId")) or None,
shop_code=resolved_code,
shop_name=as_str(shop.get("shopName")),
shop_url=shop_url,
introduction=as_str(shop.get("shopIntroduction")),
signboard_url=as_str(shop.get("signboardUrl")),
logo_url=_parse_logo_url(html),
review_score=as_float(review.get("average")),
review_count=as_int(review.get("total")),
# 评价数过少时站点不展示评分,此时 average 不可信
review_displayed=bool(review.get("didMeetDisplayRequirements")),
is_39_shop=bool(shop.get("is39Shop")),
age_verification_required=bool(shop.get("ageVerificationFlag")),
status=as_int(shop.get("status")) if shop.get("status") is not None else None,
holidays=holidays,
)
+55
View File
@@ -0,0 +1,55 @@
"""从页面 HTML 中抽取服务端渲染的 `window.__INITIAL_STATE__`
搜索页与(手机版)商品详情页都会把整页数据以 JSON 形式内联到这个全局变量里,
因此不需要解析 DOM——直接把 JSON 取出来即可拿到结构化数据。
该赋值语句后面紧跟着其他脚本代码,不能按行或按分号切分,只能用增量 JSON
解码器从 `=` 之后开始解析,读到一个完整对象为止。
"""
from __future__ import annotations
import json
import re
from collections.abc import Callable
from typing import Any
from app.core.errors import ScrapeParseError
# 页面必须包含的服务端渲染数据标记;缺失说明拿到的不是正常页面
STATE_MARKER = "window.__INITIAL_STATE__"
# 页面校验器:接收 (页面 HTML, 最终落地 URL),返回 None 表示通过,返回字符串表示
# 失败原因(会触发抓取层换 cookie / 浏览器兜底重试)。遇到重试也无济于事的情况
# (例如落到了不支持的站点),校验器可直接抛 AppError 快速失败。
PageValidator = Callable[[str, str], str | None]
_ASSIGNMENT_RE = re.compile(r"window\.__INITIAL_STATE__\s*=\s*")
_DECODER = json.JSONDecoder()
def require_state_marker(html: str, _final_url: str) -> str | None:
"""默认页面校验:必须含服务端渲染数据"""
if STATE_MARKER in html:
return None
return f"missing {STATE_MARKER} (body {len(html)} bytes)"
def extract_initial_state(html: str) -> dict[str, Any]:
"""抽取并解析 `window.__INITIAL_STATE__`
Raises:
ScrapeParseError: 页面中没有该变量,或其内容不是合法 JSON 对象
"""
match = _ASSIGNMENT_RE.search(html)
if match is None:
raise ScrapeParseError("页面中未找到 window.__INITIAL_STATE__")
try:
state, _ = _DECODER.raw_decode(html, match.end())
except ValueError as exc:
raise ScrapeParseError(f"window.__INITIAL_STATE__ 解析失败:{exc}") from exc
if not isinstance(state, dict):
raise ScrapeParseError("window.__INITIAL_STATE__ 不是 JSON 对象")
return state
+57
View File
@@ -0,0 +1,57 @@
"""子站解析器注册表与商品页分派
乐天部分官方旗舰店的商品页会 302 跳出 item.rakuten.co.jp,落到各自独立的站点。
这里按落地域名把页面分派给对应解析器;落到未登记的站点时抛 OffIchibaRedirectError,
让上游能明确区分「站点不支持」与「被反爬拦截」,而不是白白重试。
"""
from __future__ import annotations
from urllib.parse import urlsplit
from app.core import site
from app.core.errors import OffIchibaRedirectError, ScrapeParseError
from app.models.scrape import ItemDetailData
from app.parsers.state import PageValidator, require_state_marker
from app.parsers.subsites import biccamera, books, brandavenue
from app.parsers.subsites.base import SubsitePage, SubsiteParser
SUBSITE_PARSERS: dict[str, SubsiteParser] = {
module.HOST: SubsiteParser(
host=module.HOST,
source=module.SOURCE,
shop_name=module.SHOP_NAME,
validate=module.validate,
parse=module.parse,
)
for module in (books, brandavenue, biccamera)
}
def host_of(url: str) -> str:
return urlsplit(url).hostname or ""
def build_item_page_validator(requested_url: str) -> PageValidator:
"""构造商品页校验器:按落地域名选用对应的页面校验规则
落到未登记的站点时直接抛错——换 cookie 或上浏览器都改变不了页面归属。
"""
def validate(html: str, final_url: str) -> str | None:
host = host_of(final_url)
if host == site.ITEM_HOST:
return require_state_marker(html, final_url)
parser = SUBSITE_PARSERS.get(host)
if parser is None:
raise OffIchibaRedirectError(requested_url, final_url)
return parser.validate(html)
return validate
def parse_subsite_item(page: SubsitePage) -> ItemDetailData:
"""把子站页面交给对应解析器"""
parser = SUBSITE_PARSERS.get(host_of(page.final_url))
if parser is None:
raise ScrapeParseError(f"没有匹配的子站解析器:{page.final_url}")
return parser.parse(page)
+56
View File
@@ -0,0 +1,56 @@
"""子站解析器的公共契约与工具
乐天部分官方旗舰店的商品页会跳出市场域名,落到各自独立的站点上。这些站点
技术栈各不相同(微数据 / Vue SSR state / Nuxt state),但对外要收敛成同一个
ItemDetailData,因此这里定义统一的输入结构与注册契约。
"""
from __future__ import annotations
import re
from collections.abc import Callable
from dataclasses import dataclass
from app.models.scrape import ItemDetailData
# 售罄判定关键词:日文页面上表示不可购买的常见措辞
SOLD_OUT_MARKERS = ("在庫なし", "在庫切れ", "品切れ", "入荷未定", "販売終了", "取扱終了")
_DIGITS_RE = re.compile(r"\d+")
@dataclass(slots=True)
class SubsitePage:
"""一个待解析的子站页面及其上下文"""
html: str
requested_url: str # 请求时的 item.rakuten.co.jp 地址
final_url: str # 跳转后的实际地址
shop_code: str # 市场侧店铺代码,如 book / stylife / biccamera
item_code: str # 市场侧商品编号
include_sku_variants: bool
@dataclass(frozen=True, slots=True)
class SubsiteParser:
"""一个子站的解析能力"""
host: str # 落地域名
source: str # 写入 ItemDetailData.source 的标识
shop_name: str
validate: Callable[[str], str | None] # 返回 None 表示页面正常
parse: Callable[[SubsitePage], ItemDetailData]
def parse_price(text: str | int | float | None) -> int:
""""3,091円" / "1760" / 1760 这类值里取出整数日元金额"""
if isinstance(text, bool) or text is None:
return 0
if isinstance(text, (int, float)):
return int(text)
digits = _DIGITS_RE.findall(text.replace(",", ""))
return int(digits[0]) if digits else 0
def looks_sold_out(status_text: str) -> bool:
"""按页面上的库存措辞判断是否不可购买"""
return any(marker in status_text for marker in SOLD_OUT_MARKERS)
+132
View File
@@ -0,0 +1,132 @@
"""ビックカメラ楽天市場店(biccamera.rakuten.co.jp)商品页解析
Nuxt 应用,整页数据内联在 `window.__NUXT__`(纯 JSON 对象,可直接增量解析)。
商品主体在 `state.item`,字段命名已经很接近市场侧语义,且直接给出市场的
shop_id / genre_id / 分类路径与库存数。
站点不提供:SKU 组合(该店商品都是单一规格)、商品评分(异步加载)。
"""
from __future__ import annotations
import json
import re
from app.core.errors import ScrapeParseError
from app.models.scrape import (
Breadcrumb,
ItemDetailData,
PurchaseInfo,
ShippingInfo,
ShopSummary,
SkuInfo,
)
from app.parsers.subsites.base import SubsitePage
from app.utils.coerce import as_dict, as_int, as_list, as_str
HOST = "biccamera.rakuten.co.jp"
SOURCE = "biccamera"
SHOP_NAME = "ビックカメラ楽天市場店"
_NUXT_RE = re.compile(r"window\.__NUXT__\s*=\s*")
_CATEGORY_URL = "https://www.rakuten.co.jp/category/{}/"
def validate(html: str) -> str | None:
if _NUXT_RE.search(html):
return None
return f"biccamera nuxt state not found (body {len(html)} bytes)"
def _extract_nuxt(html: str) -> dict:
match = _NUXT_RE.search(html)
if match is None:
raise ScrapeParseError("ビックカメラ页面未找到 window.__NUXT__")
try:
state, _ = json.JSONDecoder().raw_decode(html, match.end())
except ValueError as exc:
raise ScrapeParseError(f"window.__NUXT__ 解析失败:{exc}") from exc
if not isinstance(state, dict):
raise ScrapeParseError("window.__NUXT__ 不是 JSON 对象")
return state
def _purchase_info(state: dict, item: dict) -> PurchaseInfo:
"""该站加购走自己的 JSON 接口,字段与市场完全不同(没有 shop_bid)
选项(choices)的取值结构未取到样本验证,因此只如实回报「有没有选项」,
不给出可能不准确的选项定义——带选项的商品需要调用方另行处理。
"""
choices = as_list(state.get("choices"))
return PurchaseInfo(
cart_url=as_str(item.get("add_cart_api_url")),
form_fields={"item_id": str(as_int(item.get("item_id")))},
quantity_field="units",
options_field="choice" if choices else "",
has_required_options=bool(choices),
)
def parse(page: SubsitePage) -> ItemDetailData:
"""解析ビックカメラ商品页"""
nuxt = _extract_nuxt(page.html)
item = as_dict(as_dict(nuxt.get("state")).get("item"))
if not item or not as_str(item.get("item_name")):
raise ScrapeParseError("ビックカメラ页面缺少 state.item")
sold_out = bool(item.get("sold_out_flag"))
inventory = as_int(item.get("inventory"))
delivery = as_str(item.get("delivery_schedule_text"))
breadcrumbs = [
Breadcrumb(
name=as_str(genre.get("genre_name")),
url=_CATEGORY_URL.format(as_str(genre.get("genre_id"))),
)
for genre in as_list(item.get("genres"))
if isinstance(genre, dict) and as_str(genre.get("genre_name"))
]
images = [
as_str(image.get("url"))
for image in as_list(item.get("images"))
if isinstance(image, dict) and as_str(image.get("url"))
]
shop_code = as_str(item.get("shop_url")) or page.shop_code
return ItemDetailData(
source=SOURCE,
source_url=page.final_url,
item_id=str(as_int(item.get("item_id"))) if item.get("item_id") is not None else "",
item_code=as_str(item.get("item_number")) or page.item_code,
item_name=as_str(item.get("item_name")),
catch_copy=as_str(item.get("catch_copy")),
description=as_str(item.get("caption")),
item_url=page.requested_url,
price=as_int(item.get("price_with_tax")),
pre_tax_price=as_int(item.get("original_price")),
tax_flag=bool(item.get("included_tax_flag")),
purchase_condition="soldOut" if sold_out else "enabled",
is_sold_out=sold_out,
purchase_unit=as_int(item.get("units")),
images=images,
shop=ShopSummary(
shop_id=as_int(item.get("shop_id")) or None,
shop_code=shop_code,
shop_name=SHOP_NAME,
shop_url=f"https://www.rakuten.co.jp/{shop_code}/",
),
genre_id=str(as_int(item.get("genre_id"))) if item.get("genre_id") is not None else "",
breadcrumbs=breadcrumbs,
shipping=ShippingInfo(
# 该店商品价格含运费时站点会置位此标记,不再单独给运费金额
is_shipping_free=bool(item.get("included_shipping_fee_flag")),
delivery_message=delivery,
),
sku=SkuInfo(
inventory_type="single",
quantity=inventory,
show_inventory=inventory > 0,
delivery_message=delivery,
),
purchase=_purchase_info(as_dict(nuxt.get("state")), item),
)
+187
View File
@@ -0,0 +1,187 @@
"""楽天ブックス(books.rakuten.co.jp)商品页解析
该站是传统服务端渲染页面,商品数据以 schema.org 微数据标注(Product / Offer /
AggregateRating),规格与简介在 `.sec-item` 分节里,分类路径则通过页面内联的
`var data_genres` 给出——其中的 rmsGenreId 就是市场侧的 genre_id。
站点不提供:SKU 组合(图书没有规格轴)、运费明细、店铺评分。
"""
from __future__ import annotations
import json
import re
from selectolax.parser import HTMLParser
from app.core.errors import ScrapeParseError
from app.models.scrape import (
Breadcrumb,
ItemDetailData,
PurchaseInfo,
ReviewSummary,
ShippingInfo,
ShopSummary,
SkuAttribute,
SkuInfo,
)
from app.parsers.subsites.base import SubsitePage, looks_sold_out, parse_price
HOST = "books.rakuten.co.jp"
SOURCE = "books"
SHOP_NAME = "楽天ブックス"
_GENRES_RE = re.compile(r"var\s+data_genres\s*=\s*")
# 折叠正文里由展开控件(checkbox/label)留下的连续空行
_BLANK_LINES_RE = re.compile(r"\n{2,}")
_IMAGE_RE = re.compile(r"//tshop\.r10s\.jp/book/cabinet/[^\"'\s?]+\.(?:jpg|jpeg|png)", re.I)
_CATEGORY_URL = "https://www.rakuten.co.jp/category/{}/"
def validate(html: str) -> str | None:
"""页面须带 Product 微数据,否则不是正常的商品页"""
if 'itemprop="price"' in html and "schema.org/Product" in html:
return None
return f"books item page markup not found (body {len(html)} bytes)"
def _attr(tree: HTMLParser, selector: str, name: str) -> str:
node = tree.css_first(selector)
return (node.attributes.get(name) or "") if node else ""
def _text(tree: HTMLParser, selector: str) -> str:
node = tree.css_first(selector)
return node.text(strip=True) if node else ""
def _parse_spec(tree: HTMLParser) -> list[SkuAttribute]:
"""商品情報分节:每个 ul 内首个 li.product-title 是字段名,其后是取值"""
attributes: list[SkuAttribute] = []
for row in tree.css(".sec-item__identifier__list ul"):
cells = row.css("li")
if len(cells) < 2:
continue
title = cells[0]
if "product-title" not in (title.attributes.get("class") or ""):
continue
value = " ".join(cell.text(strip=True) for cell in cells[1:] if cell.text(strip=True))
if value:
attributes.append(SkuAttribute(title=title.text(strip=True), value=value))
return attributes
def _parse_description(tree: HTMLParser) -> str:
"""商品説明分节:把「内容紹介」「目次」等若干小节拼起来
这些小节在 DOM 里是平铺的——标题 h3 与正文 div 互为兄弟节点而非父子,
所以要从标题往后找同级的正文块,不能直接按容器取。
"""
parts: list[str] = []
for title in tree.css(".sec-item__extra__title"):
node = title.next
while node is not None:
classes = node.attributes.get("class") or "" if node.tag != "-text" else ""
if "sec-item__extra__content" in classes:
body = _BLANK_LINES_RE.sub("\n", node.text(separator="\n", strip=True)).strip()
if body:
parts.append(f"{title.text(strip=True)}\n{body}")
break
# 只在紧邻的兄弟里找,遇到下一个标题就说明这一节没有正文
if node.tag != "-text" and "sec-item__extra__title" in classes:
break
node = node.next
return "\n\n".join(parts)
def _parse_genres(html: str) -> tuple[str, list[Breadcrumb]]:
"""内联的 data_genres 给出分类路径,其中 rmsGenreId 对应市场侧 genre_id"""
match = _GENRES_RE.search(html)
if match is None:
return "", []
try:
raw, _ = json.JSONDecoder().raw_decode(html, match.end())
except ValueError:
return "", []
# 结构是 [[{...}, {...}]],取第一条路径
path = raw[0] if isinstance(raw, list) and raw and isinstance(raw[0], list) else raw
crumbs: list[Breadcrumb] = []
genre_id = ""
for node in path if isinstance(path, list) else []:
if not isinstance(node, dict):
continue
rms_id = str(node.get("rmsGenreId") or "")
name = str(node.get("genreName") or "")
if not name:
continue
crumbs.append(Breadcrumb(name=name, url=_CATEGORY_URL.format(rms_id) if rms_id else ""))
if rms_id:
genre_id = rms_id
return genre_id, crumbs
def _parse_purchase(tree: HTMLParser) -> PurchaseInfo:
"""加购信息直接取页面上的购物车表单
注意表单里的 item_id 与商品 URL 上的编号不是一回事,加购必须用表单里的值。
该表单没有数量字段,无法在加购时指定件数。
"""
for form in tree.css("form"):
action = form.attributes.get("action") or ""
if "/bs/Cart" not in action:
continue
fields = {
name: inp.attributes.get("value") or ""
for inp in form.css("input")
if (name := inp.attributes.get("name"))
}
return PurchaseInfo(
cart_url=action,
cart_method=(form.attributes.get("method") or "POST").upper(),
form_fields=fields,
)
return PurchaseInfo()
def parse(page: SubsitePage) -> ItemDetailData:
"""解析楽天ブックス商品页"""
tree = HTMLParser(page.html)
name = _text(tree, "#productTitle") or _text(tree, '[itemprop="name"]')
if not name:
raise ScrapeParseError("楽天ブックス页面未找到商品名")
images = ["https:" + url if url.startswith("//") else url for url in _IMAGE_RE.findall(page.html)]
# 同一张图可能带不同裁剪参数重复出现,去重但保留出现顺序
images = list(dict.fromkeys(images))
status = _text(tree, ".status")
genre_id, breadcrumbs = _parse_genres(page.html)
review_count = _text(tree, '[itemprop="reviewCount"]')
purchase = _parse_purchase(tree)
return ItemDetailData(
source=SOURCE,
source_url=page.final_url,
# 站内商品 ID 与 URL 上的编号不同,以购物车表单里的为准(下单要用它)
item_id=purchase.form_fields.get("item_id", "") or page.item_code,
item_code=page.item_code,
item_name=name,
description=_parse_description(tree),
item_url=page.requested_url,
price=parse_price(_attr(tree, '[itemprop="price"]', "content")),
purchase_condition=status,
is_sold_out=looks_sold_out(status),
images=images,
shop=ShopSummary(shop_code=page.shop_code, shop_name=SHOP_NAME),
review=ReviewSummary(
score=float(_attr(tree, '[itemprop="ratingValue"]', "content") or 0) or 0.0,
count=parse_price(review_count),
),
genre_id=genre_id,
breadcrumbs=breadcrumbs,
# 图书统一由楽天ブックス发货,页面只给库存措辞,不给运费明细
shipping=ShippingInfo(delivery_message=status),
sku=SkuInfo(inventory_type="single", attributes=_parse_spec(tree), delivery_message=status),
purchase=purchase,
)
+210
View File
@@ -0,0 +1,210 @@
"""Rakuten Fashion / BRAND AVENUE(brandavenue.rakuten.co.jp)商品页解析
该站同样把整页数据内联在 `window.__INITIAL_STATE__`,但结构与市场页完全不同:
商品主体在 `itemDetail.data.product`,店铺信息在 `env`,市场侧的 genre_id 与
商品 ID 则藏在 `product.rms_info` 里。
SKU 有两个轴(颜色 / 尺码):product_sku 给出可售组合与售价,rms_info.inventory_list
给出各组合库存,两者按「尺码 + 颜色名」对齐。
站点不提供:商品评分(异步加载)、运费明细。面包屑用的是站内分类编码而非市场
genre_id,因此只给分类名不给链接。
"""
from __future__ import annotations
import re
from app.core.errors import ScrapeParseError
from app.models.scrape import (
Breadcrumb,
ItemDetailData,
PurchaseInfo,
ShopSummary,
SkuAttribute,
SkuAxis,
SkuAxisValue,
SkuInfo,
SkuVariant,
)
from app.parsers.state import extract_initial_state
from app.parsers.subsites.base import SubsitePage, parse_price
from app.utils.coerce import as_dict, as_int, as_list, as_str
HOST = "brandavenue.rakuten.co.jp"
SOURCE = "brandavenue"
SHOP_NAME = "Rakuten Fashion"
_COLOR_AXIS = "カラー"
_SIZE_AXIS = "サイズ"
# cart_info.cart_url_type → 加购端点。站点前端用同名映射表(resolveCartUrl)解析;
# 若该字段本身已经是一个 URL,则直接使用。
_CART_URL_BY_TYPE = {
"1": "https://ts.basket.step.rakuten.co.jp/rms/mall/bs/cartadd/set",
"2": "https://ts.sp.basket.step.rakuten.co.jp/rms/mall/bss/cartadd/set",
"3": "https://t2.basket.step.rakuten.co.jp/rms/mall/bs/cartadd/set",
"4": "https://t2.sp.basket.step.rakuten.co.jp/rms/mall/bss/cartadd/set",
"5": "https://basket.step.rakuten.co.jp/rms/mall/bs/cartadd/set",
"6": "https://sp.basket.step.rakuten.co.jp/rms/mall/bss/cartadd/set",
}
_DEFAULT_PURCHASE_EVENT = "ES01_003_001"
def _resolve_cart_url(cart_url_type: str) -> str:
if cart_url_type.startswith("http"):
return cart_url_type
return _CART_URL_BY_TYPE.get(cart_url_type, "")
def _purchase_info(product: dict) -> PurchaseInfo:
"""加购字段与市场是同一套契约,差别只在端点由 cart_url_type 映射得到"""
cart_info = as_dict(as_dict(product.get("rms_info")).get("cart_info"))
if not cart_info:
return PurchaseInfo()
return PurchaseInfo(
cart_url=_resolve_cart_url(as_str(cart_info.get("cart_url_type"))),
form_fields={
"shop_bid": as_str(cart_info.get("shop_bid")),
"item_id": as_str(cart_info.get("item_id")),
"inventory_flag": as_str(cart_info.get("inventory_type")),
"__event": as_str(cart_info.get("event")) or _DEFAULT_PURCHASE_EVENT,
"encode": "utf8",
},
quantity_field="units",
variant_field="variant_id",
)
def validate(html: str) -> str | None:
if "window.__INITIAL_STATE__" in html:
return None
return f"brandavenue state not found (body {len(html)} bytes)"
def _images(html: str, image_folder: str, main_filename: str) -> list[str]:
"""从页面直出的图片地址中收集商品图
图片按商品编号做了目录分片,分片规则不对外暴露,所以直接取页面里已经渲染好的
地址,而不是自行拼接,避免规则变化导致图片全错。
"""
if not image_folder:
return []
pattern = re.compile(
rf"https://[a-z0-9.\-]+/{re.escape(image_folder)}/[^\"'\s]+\.(?:jpg|jpeg|png)", re.I
)
urls = list(dict.fromkeys(pattern.findall(html)))
if not main_filename:
return urls
# 主图排在最前,便于调用方直接取 images[0] 当封面
main = main_filename.lower()
urls.sort(key=lambda url: 0 if url.lower().endswith("/" + main) else 1)
return urls
def _breadcrumbs(product: dict) -> list[Breadcrumb]:
"""category_l_m_cd_name 是 [大类ID, 大类名, 中类ID, 中类名] 的扁平数组"""
flat = [as_str(value) for value in as_list(product.get("category_l_m_cd_name"))]
crumbs: list[Breadcrumb] = []
for index in range(0, len(flat) - 1, 2):
name = flat[index + 1]
if name:
crumbs.append(Breadcrumb(name=name))
return crumbs
def _sku(product: dict, *, include_variants: bool) -> SkuInfo:
entries = [entry for entry in as_list(product.get("product_sku")) if isinstance(entry, dict)]
inventory = {
(as_str(row.get("size")), as_str(row.get("color_name"))): row
for row in as_list(as_dict(product.get("rms_info")).get("inventory_list"))
if isinstance(row, dict)
}
variants: list[SkuVariant] = []
colors: dict[str, bool] = {}
sizes: dict[str, bool] = {}
for entry in entries:
color = as_str(entry.get("product_color_name"))
size = as_str(entry.get("product_size_name"))
in_stock = as_str(entry.get("inventory_exist_flg")) == "1"
row = as_dict(inventory.get((size, color)))
attributes = [
SkuAttribute(title=title, value=as_str(entry.get(key)))
for title, key in (("素材", "material"), ("お手入れ", "cleaning"), ("お届け目安", "inventory_status_message"))
if as_str(entry.get(key))
]
variants.append(
SkuVariant(
variant_id=as_str(row.get("variant_id")),
selector_values=[color, size],
price=parse_price(entry.get("selling_price")),
quantity=as_int(row.get("stock")),
is_sold_out=not in_stock,
delivery_message=as_str(entry.get("inventory_status_message")),
attributes=attributes,
)
)
# 任一组合可售即认为该取值可选
colors[color] = colors.get(color, False) or in_stock
sizes[size] = sizes.get(size, False) or in_stock
axis = [
SkuAxis(
key=key,
label=key,
values=[
SkuAxisValue(value=value, label=value, is_sold_out=not available)
for value, available in mapping.items()
if value
],
)
for key, mapping in ((_COLOR_AXIS, colors), (_SIZE_AXIS, sizes))
if any(mapping)
]
return SkuInfo(
inventory_type="multiple" if len(variants) > 1 else "single",
quantity=sum(variant.quantity for variant in variants),
delivery_message=as_str(entries[0].get("inventory_status_message")) if entries else "",
axis=axis,
variants=variants if include_variants else [],
variant_count=len(variants),
)
def parse(page: SubsitePage) -> ItemDetailData:
"""解析 Rakuten Fashion 商品页"""
state = extract_initial_state(page.html)
product = as_dict(as_dict(as_dict(state.get("itemDetail")).get("data")).get("product"))
if not product:
raise ScrapeParseError("Rakuten Fashion 页面缺少 itemDetail.data.product")
env = as_dict(state.get("env"))
rms = as_dict(product.get("rms_info"))
sold_out = as_int(product.get("soldout_flg")) == 1
return ItemDetailData(
source=SOURCE,
source_url=page.final_url,
item_id=as_str(rms.get("rms_item_id")),
item_code=page.item_code,
item_name=as_str(product.get("product_name")),
catch_copy=as_str(product.get("brand_name")),
description=as_str(product.get("product_exp")),
item_url=page.requested_url,
price=parse_price(product.get("selling_price_no_format")),
pre_tax_price=parse_price(product.get("fixed_price_no_format")),
purchase_condition="soldOut" if sold_out else "enabled",
is_sold_out=sold_out,
images=_images(page.html, as_str(env.get("product_image_folder")), as_str(product.get("product_img_path"))),
shop=ShopSummary(
shop_id=as_int(env.get("shop_id")) or None,
shop_code=as_str(env.get("shop_url")) or page.shop_code,
shop_name=as_str(env.get("shop_name")) or SHOP_NAME,
shop_url=f"https://www.rakuten.co.jp/{as_str(env.get('shop_url')) or page.shop_code}/",
),
genre_id=as_str(rms.get("genre_id")),
breadcrumbs=_breadcrumbs(product),
sku=_sku(product, include_variants=page.include_sku_variants),
purchase=_purchase_info(product),
)
View File
+185
View File
@@ -0,0 +1,185 @@
"""浏览器兜底:纯 HTTP 被 Akamai 拦截时,用 Playwright 取回一套可用 cookie
为什么只是「兜底」而不是主路径:乐天的反爬是 Akamai Bot Manager,正常携带
完整浏览器请求头 + 复用其下发的 cookie 就能通过,不像骏河屋的 Cloudflare
Turnstile 必须真浏览器交互。因此这里的浏览器是冷路径——按需惰性启动,
长时间不用会被回收,未安装 playwright 时整体降级为「不兜底」而非报错。
导航结果里的 HTML 会一并返回:既然浏览器已经把页面取到了,就没必要让调用方
再发一次 HTTP 请求。
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from typing import Any
from app.core.config import Settings
from app.core import site
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class BrowserVisit:
"""一次浏览器导航的产出:cookie 快照 + 页面 HTML"""
cookies: list[dict[str, Any]] = field(default_factory=list)
html: str = ""
class BrowserFallback:
"""按需启动的 Playwright 实例,用于取回通过反爬校验的 cookie
仅在 HTTP 抓取被判定为拦截后才会被调用;调用之间浏览器保持存活,
由 close() 在应用关闭时释放。
"""
def __init__(self, settings: Settings):
self._settings = settings
self._playwright: Any | None = None
self._browser: Any | None = None
self._lock = asyncio.Lock()
self._unavailable_reason: str | None = None
@property
def enabled(self) -> bool:
"""配置是否允许使用浏览器兜底"""
return self._settings.browser_fallback_enabled
@property
def unavailable_reason(self) -> str | None:
"""浏览器不可用的原因(未启用 / 未安装 / 启动失败)"""
if not self.enabled:
return "browser fallback is disabled"
return self._unavailable_reason
@property
def ready(self) -> bool:
"""浏览器当前是否已启动且连接正常"""
browser = self._browser
if browser is None:
return False
is_connected = getattr(browser, "is_connected", None)
if callable(is_connected):
try:
return bool(is_connected())
except Exception:
return False
return True
async def _ensure_browser(self) -> Any | None:
"""惰性启动浏览器;不可用时返回 None 并记录原因,不抛异常。"""
if not self.enabled:
return None
if self.ready:
return self._browser
async with self._lock:
if self.ready:
return self._browser
# 上一轮已经启动失败过,直接沿用结论,避免每次请求都吃一次启动超时
if self._unavailable_reason is not None and self._playwright is None:
return None
try:
from playwright.async_api import async_playwright
except ImportError as exc:
self._unavailable_reason = (
f"playwright is not installed: {exc}; "
'install it with pip install ".[browser]" && python -m playwright install chromium'
)
logger.warning("浏览器兜底不可用:%s", self._unavailable_reason)
return None
await self._close_locked()
try:
self._playwright = await async_playwright().start()
self._browser = await self._playwright.chromium.launch(
headless=self._settings.browser_headless_effective,
channel=self._settings.browser_channel or None,
proxy=self._settings.playwright_proxy,
timeout=self._settings.browser_launch_timeout_seconds * 1000,
args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
)
self._unavailable_reason = None
logger.info(
"浏览器兜底已启动:headless=%s channel=%s proxy=%s",
self._settings.browser_headless_effective,
self._settings.browser_channel,
bool(self._settings.proxy_server),
)
except Exception as exc:
self._unavailable_reason = str(exc)
logger.exception("浏览器兜底启动失败:%s", self._unavailable_reason)
await self._close_locked()
return None
return self._browser
async def visit(self, url: str, *, mobile: bool) -> BrowserVisit | None:
"""用浏览器访问 url,返回 cookie 与页面 HTML;不可用时返回 None。
UA 与请求头必须和后续 HTTP 客户端使用的那一套保持一致——Akamai 会把
cookie 与指纹绑定,用 PC 指纹拿到的 cookie 拿去发手机 UA 请求可能失效。
"""
browser = await self._ensure_browser()
if browser is None:
return None
headers = site.default_headers(mobile=mobile)
context = None
try:
context = await browser.new_context(
user_agent=headers["User-Agent"],
locale="ja-JP",
timezone_id="Asia/Tokyo",
viewport={"width": 390, "height": 844} if mobile else {"width": 1440, "height": 900},
is_mobile=mobile,
has_touch=mobile,
extra_http_headers={"Accept-Language": site.ACCEPT_LANGUAGE},
)
page = await context.new_page()
await page.goto(
url,
wait_until="domcontentloaded",
timeout=self._settings.browser_nav_timeout_seconds * 1000,
)
html = await page.content()
cookies = await context.cookies()
logger.info("浏览器兜底导航完成:url=%s cookies=%s html_len=%s", url, len(cookies), len(html))
return BrowserVisit(cookies=cookies, html=html)
except Exception as exc:
logger.warning("浏览器兜底导航失败:url=%s err=%s", url, exc)
# 导航失败常常意味着浏览器已掉线,标记后由下次调用重建
if not self.ready:
self._unavailable_reason = str(exc)
return None
finally:
if context is not None:
try:
await context.close()
except Exception:
logger.debug("关闭兜底浏览器上下文失败", exc_info=True)
async def _close_locked(self) -> None:
"""释放浏览器与 Playwright 运行时(调用方需已持锁或处于关闭流程)"""
if self._browser is not None:
try:
await self._browser.close()
except Exception:
logger.debug("关闭兜底浏览器失败", exc_info=True)
self._browser = None
if self._playwright is not None:
try:
await self._playwright.stop()
except Exception:
logger.debug("停止兜底 Playwright 运行时失败", exc_info=True)
self._playwright = None
async def close(self) -> None:
"""应用关闭时释放浏览器资源"""
async with self._lock:
await self._close_locked()
+130
View File
@@ -0,0 +1,130 @@
"""ラクマ 抓取客户端:把请求参数翻译成站点 URL,抓取后解析为结构化数据
四个接口都走同一条 HTTP 通道(ラクマ 无 Akamai 限速,不需要分指纹通道):
搜索、商品详情、卖家详情、卖家商品列表。
"""
from __future__ import annotations
import asyncio
import logging
from app.core.config import Settings
from app.models.scrape import (
RakumaItemDetailData,
RakumaItemDetailRequest,
RakumaSearchRequest,
RakumaSearchResultData,
RakumaShopDetailData,
RakumaShopDetailRequest,
RakumaShopItemsData,
RakumaShopItemsRequest,
)
from app.parsers.rakuma.item import parse_item_detail
from app.parsers.rakuma.search import parse_search
from app.parsers.rakuma.shop import parse_shop_detail, parse_shop_items
from app.services.rakuma_session import RakumaSession
from app.utils.rakuma_urls import (
build_item_url,
build_search_url,
build_shop_url,
normalize_search_url,
split_item_url,
split_shop_url,
)
logger = logging.getLogger(__name__)
class RakumaClient:
"""ラクマ(fril.jp)抓取客户端"""
def __init__(self, settings: Settings, session: RakumaSession):
self._settings = settings
self._session = session
async def search(self, payload: RakumaSearchRequest) -> RakumaSearchResultData:
"""抓取搜索结果列表"""
if payload.search_url is not None:
url = normalize_search_url(str(payload.search_url), payload.page)
else:
url = build_search_url(payload)
logger.info("抓取 ラクマ 搜索页:url=%s", url)
html = await self._session.fetch_html(url)
result = parse_search(
html,
request_url=url,
page=payload.page,
keyword=payload.keyword.strip(),
)
logger.info(
"ラクマ 搜索完成:url=%s items=%s total=%s",
url, len(result.items), result.total_count,
)
return result
async def item_detail(self, payload: RakumaItemDetailRequest) -> RakumaItemDetailData:
"""抓取商品详情"""
if payload.item_url is not None:
item_id = split_item_url(str(payload.item_url))
else:
item_id = (payload.item_id or "").strip()
url = build_item_url(item_id)
logger.info("抓取 ラクマ 商品详情:url=%s", url)
html = await self._session.fetch_html(url)
detail = parse_item_detail(html, item_id=item_id, item_url=url)
logger.info(
"ラクマ 详情完成:url=%s name=%s price=%s sold_out=%s",
url, detail.item_name[:40], detail.price, detail.is_sold_out,
)
return detail
async def shop_detail(self, payload: RakumaShopDetailRequest) -> RakumaShopDetailData:
"""抓取卖家详情
评价明细在单独的 /review 页上,仅在 include_reviews=true 时并发多取一次。
"""
shop_id = self._resolve_shop_id(payload.shop_url, payload.shop_id)
url = build_shop_url(shop_id)
logger.info("抓取 ラクマ 卖家详情:url=%s reviews=%s", url, payload.include_reviews)
if payload.include_reviews:
html, review_html = await asyncio.gather(
self._session.fetch_html(url),
self._session.fetch_html(build_shop_url(shop_id, review=True)),
)
else:
html, review_html = await self._session.fetch_html(url), None
detail = parse_shop_detail(
html, shop_id=shop_id, shop_url=url, review_html=review_html
)
logger.info(
"ラクマ 卖家详情完成:shop_id=%s name=%s items=%s reviews=%s",
shop_id, detail.shop_name, detail.item_count, detail.review_count,
)
return detail
async def shop_items(self, payload: RakumaShopItemsRequest) -> RakumaShopItemsData:
"""抓取卖家的商品列表"""
shop_id = self._resolve_shop_id(payload.shop_url, payload.shop_id)
url = build_shop_url(shop_id, page=payload.page)
logger.info("抓取 ラクマ 卖家商品:url=%s", url)
html = await self._session.fetch_html(url)
result = parse_shop_items(
html, shop_id=shop_id, request_url=url, page=payload.page
)
logger.info(
"ラクマ 卖家商品完成:shop_id=%s items=%s total=%s",
shop_id, len(result.items), result.total_count,
)
return result
@staticmethod
def _resolve_shop_id(shop_url: object, shop_id: str | None) -> str:
"""从 shop_url 或 shop_id 里取出店铺 hash(模型校验已保证两者不同时为空)"""
if shop_url is not None:
return split_shop_url(str(shop_url))
return (shop_id or "").strip()
+135
View File
@@ -0,0 +1,135 @@
"""ラクマ(fril.jp)站点会话:单通道 HTTP 抓取
与乐天市场那条链路(app/services/site_session.py)分开维护,因为两站的
抓取前提完全不同:
- 乐天前置 Akamai Bot Manager,无 cookie 时每个响应被拖到 ~11s,必须先访问
首页预热再复用 cookie;且搜索页与详情页要用不同 UA,需要两条指纹通道。
- ラクマ 实测没有这类限速:冷请求(无 cookie、无预热)即 0.5-1.1s,与预热后
持平;PC UA 在搜索页、详情页、店铺页上都能拿到完整模板。
因此这里只有一条通道、不做预热,也不接浏览器兜底——没有需要兜底的拦截行为。
若将来站点加了防护,再按乐天那套补预热与升级链路。
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
import httpx
from app.core import rakuma_site as site
from app.core.config import Settings
from app.core.errors import (
ItemNotFoundError,
ResourceBusyError,
UpstreamBlockedError,
UpstreamRequestError,
)
logger = logging.getLogger(__name__)
class RakumaSession:
"""ラクマ 站点抓取会话,管理并发限流与失败重试"""
def __init__(self, settings: Settings):
self._settings = settings
self._semaphore = asyncio.Semaphore(settings.max_site_concurrency)
self._client: httpx.AsyncClient | None = None
# ---- 生命周期 ----
async def start(self) -> None:
"""创建 HTTP 客户端"""
if self._client is not None:
return
self._client = httpx.AsyncClient(
headers=site.default_headers(),
timeout=self._settings.request_timeout_seconds,
follow_redirects=True,
proxy=self._settings.httpx_proxy,
http2=True,
)
logger.info(
"ラクマ 会话已就绪:concurrency=%s proxy=%s",
self._settings.max_site_concurrency,
bool(self._settings.proxy_server),
)
async def close(self) -> None:
"""关闭 HTTP 客户端"""
if self._client is None:
return
try:
await self._client.aclose()
except Exception:
logger.debug("关闭 ラクマ HTTP 客户端失败", exc_info=True)
self._client = None
# ---- 状态 ----
def status(self) -> dict[str, Any]:
"""会话状态,供健康检查展示"""
return {"ready": self._client is not None}
# ---- 抓取 ----
async def fetch_html(self, url: str) -> str:
"""抓取页面 HTML
Raises:
ItemNotFoundError: 目标页面 404(商品已下架或 ID 不存在)
UpstreamRequestError: 网络异常或上游 5xx
UpstreamBlockedError: 反复取不到正常页面
ResourceBusyError: 等待并发槽位超时
"""
client = self._client
if client is None:
raise UpstreamRequestError("ラクマ 会话尚未初始化")
max_attempts = max(1, self._settings.http_max_attempts)
last_error = "unknown error"
try:
await asyncio.wait_for(
self._semaphore.acquire(),
timeout=self._settings.request_timeout_seconds,
)
except TimeoutError as exc:
raise ResourceBusyError() from exc
try:
for attempt in range(1, max_attempts + 1):
try:
response = await client.get(url)
except httpx.HTTPError as exc:
last_error = f"{type(exc).__name__}: {exc}"
logger.warning(
"ラクマ 抓取请求异常:url=%s attempt=%s/%s err=%s",
url, attempt, max_attempts, last_error,
)
continue
if response.status_code == 404:
raise ItemNotFoundError(f"Page not found: {url}")
if response.status_code < 400:
return response.text
last_error = (
f"upstream status {response.status_code}"
if response.status_code >= 500
else f"status {response.status_code}"
)
logger.warning(
"ラクマ 抓取结果异常:url=%s attempt=%s/%s %s",
url, attempt, max_attempts, last_error,
)
if last_error.startswith("upstream status") or ":" in last_error:
raise UpstreamRequestError(f"Upstream request failed: {last_error}")
raise UpstreamBlockedError(f"Failed to fetch {url}: {last_error}")
finally:
self._semaphore.release()
+169
View File
@@ -0,0 +1,169 @@
"""乐天抓取客户端:把请求参数翻译成站点 URL,抓取后解析为结构化数据
两个接口分别走不同的指纹通道:
- 搜索:PC 通道(search.rakuten.co.jp 的搜索页)
- 详情:手机通道(item.rakuten.co.jp 只有手机 UA 才返回统一模板)
"""
from __future__ import annotations
import logging
from app.core import site
from app.core.config import Settings
from app.core.errors import ScrapeParseError
from app.models.scrape import (
GenreData,
GenreRequest,
ItemDetailData,
ItemDetailRequest,
SearchRequest,
SearchResultData,
ShopDetailData,
ShopDetailRequest,
ShopItemsRequest,
)
from app.parsers.genre import parse_genres
from app.parsers.item import parse_item_detail
from app.parsers.search import parse_search
from app.parsers.shop import parse_shop_detail
from app.parsers.state import extract_initial_state
from app.parsers.subsites import (
SubsitePage,
build_item_page_validator,
host_of,
parse_subsite_item,
)
from app.services.site_session import SiteSession
from app.utils.urls import (
build_genre_url,
build_item_url,
build_search_url,
build_shop_url,
normalize_search_url,
split_item_url,
split_shop_url,
)
logger = logging.getLogger(__name__)
class RakutenClient:
"""乐天市场抓取客户端"""
def __init__(self, settings: Settings, session: SiteSession):
self._settings = settings
self._session = session
async def search(self, payload: SearchRequest) -> SearchResultData:
"""抓取搜索结果列表"""
if payload.search_url is not None:
url = normalize_search_url(str(payload.search_url), payload.page)
else:
url = build_search_url(payload)
logger.info("抓取搜索页:url=%s", url)
html = await self._session.fetch_html(url, mobile=False)
state = extract_initial_state(html)
result = parse_search(
state,
request_url=url,
page=payload.page,
exclude_ads=payload.exclude_ads,
)
logger.info(
"搜索完成:url=%s items=%s ads=%s total=%s",
url, len(result.items), result.ad_count, result.total_count,
)
return result
async def genres(self, payload: GenreRequest) -> GenreData:
"""抓取分类树:顶层分类列表,或指定分类的信息与直接子分类"""
genre_id = (payload.genre_id or "").strip() or None
url = build_genre_url(genre_id)
logger.info("抓取分类树:url=%s genre_id=%s", url, genre_id)
html = await self._session.fetch_html(url, mobile=False)
state = extract_initial_state(html)
result = parse_genres(state, genre_id=genre_id)
logger.info(
"分类树完成:genre_id=%s name=%s children=%s",
result.genre_id or "root", result.name, len(result.children),
)
return result
async def shop_detail(self, payload: ShopDetailRequest) -> ShopDetailData:
"""抓取商家详情"""
if payload.shop_url is not None:
shop_code = split_shop_url(str(payload.shop_url))
else:
shop_code = (payload.shop_code or "").strip()
url = build_shop_url(shop_code)
logger.info("抓取店铺页:url=%s", url)
html = await self._session.fetch_html(url, mobile=False)
state = extract_initial_state(html)
result = parse_shop_detail(state, shop_code=shop_code, html=html)
logger.info(
"店铺详情完成:shop_code=%s shop_id=%s name=%s reviews=%s",
result.shop_code, result.shop_id, result.shop_name, result.review_count,
)
return result
async def shop_items(self, payload: ShopItemsRequest) -> SearchResultData:
"""抓取商家名下的商品列表
站点没有可分页抓取的「店铺内商品」页,店铺商品实际就是搜索结果按
`sid=` 限定店铺后的产物,因此这里转成一次搜索。只给 shop_code 时
需要先取一次店铺详情换出 shop_id。
"""
shop_id = payload.shop_id
if shop_id is None:
detail = await self.shop_detail(ShopDetailRequest(shop_code=payload.shop_code))
if detail.shop_id is None:
raise ScrapeParseError(f"未能取得店铺 {payload.shop_code} 的 shop_id")
shop_id = detail.shop_id
return await self.search(payload.to_search_request(shop_id))
async def item_detail(self, payload: ItemDetailRequest) -> ItemDetailData:
"""抓取商品详情"""
if payload.item_url is not None:
shop_code, item_code = split_item_url(str(payload.item_url))
else:
# 模型校验已保证此分支下两者都非空
shop_code, item_code = payload.shop_code or "", payload.item_code or ""
# 统一收敛到规范地址,去掉 variantId 等查询参数——所有 SKU 组合都会随详情返回
url = build_item_url(shop_code, item_code)
logger.info("抓取商品详情:url=%s", url)
# 部分官方旗舰店的商品页会跳出市场域名,落到各自的独立站点;
# 校验与解析都按最终落地域名分派,落到未登记站点时由校验器抛错。
page = await self._session.fetch(
url, mobile=True, validator=build_item_page_validator(url)
)
if host_of(page.url) == site.ITEM_HOST:
detail = parse_item_detail(
extract_initial_state(page.html),
item_url=url,
shop_code=shop_code,
include_sku_variants=payload.include_sku_variants,
)
else:
detail = parse_subsite_item(
SubsitePage(
html=page.html,
requested_url=url,
final_url=page.url,
shop_code=shop_code,
item_code=item_code,
include_sku_variants=payload.include_sku_variants,
)
)
logger.info(
"详情完成:url=%s source=%s name=%s price=%s skus=%s",
url, detail.source, detail.item_name[:40], detail.price, detail.sku.variant_count,
)
return detail
+310
View File
@@ -0,0 +1,310 @@
"""站点会话:带 Akamai cookie 复用的 HTTP 抓取通道,附带浏览器兜底
乐天前置 Akamai Bot Manager,行为特征(实测):
- 请求头不完整、无 cookie 时不封禁,而是把每个响应拖到 ~11s(与响应体大小无关)
- 补齐浏览器导航请求头并复用 Akamai 下发的 cookie 后,稳定在 ~0.6-0.9s
因此这里按「指纹画像」维护两条独立通道:搜索页用 PC 画像,商品详情页用手机
画像(详情页只有手机 UA 才返回带 __INITIAL_STATE__ 的统一模板)。两条通道
各自持有独立 cookie 罐,避免把 PC 指纹拿到的 cookie 混用到手机请求上。
抓取失败时的升级路径:重新预热 → 浏览器兜底取 cookie → 放弃。
"""
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Any
import httpx
from app.core import site
from app.core.config import Settings
from app.core.errors import (
ItemNotFoundError,
ResourceBusyError,
UpstreamBlockedError,
UpstreamRequestError,
)
from app.parsers.state import PageValidator, require_state_marker
from app.services.browser_fallback import BrowserFallback
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class FetchedPage:
"""一次成功抓取的产物"""
html: str
url: str # 最终落地 URL;发生跳转时与请求地址不同
# Akamai 拦截页/挑战页的特征串
_BLOCK_MARKERS = (
"access denied",
"pardon our interruption",
"reference #",
"errors.edgesuite.net",
"/_sec/cp_challenge/",
)
@dataclass(slots=True)
class _Profile:
"""一条指纹通道:独立的 httpx 客户端、cookie 罐与预热状态"""
name: str
mobile: bool
client: httpx.AsyncClient
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
warmed_at: float = 0.0
@property
def cookie_names(self) -> set[str]:
return {cookie.name for cookie in self.client.cookies.jar}
class SiteSession:
"""乐天站点抓取会话,管理 cookie 预热、并发限流与失败升级"""
def __init__(self, settings: Settings, browser_fallback: BrowserFallback):
self._settings = settings
self._browser = browser_fallback
self._semaphore = asyncio.Semaphore(settings.max_site_concurrency)
self._profiles: dict[str, _Profile] = {}
# ---- 生命周期 ----
async def start(self) -> None:
"""创建两条指纹通道的 HTTP 客户端"""
for name, mobile in (("pc", False), ("sp", True)):
if name in self._profiles:
continue
self._profiles[name] = _Profile(
name=name,
mobile=mobile,
client=httpx.AsyncClient(
headers=site.default_headers(mobile=mobile),
timeout=self._settings.request_timeout_seconds,
follow_redirects=True,
proxy=self._settings.httpx_proxy,
http2=True,
),
)
logger.info(
"站点会话已就绪:profiles=%s concurrency=%s proxy=%s",
list(self._profiles),
self._settings.max_site_concurrency,
bool(self._settings.proxy_server),
)
async def close(self) -> None:
"""关闭所有 HTTP 客户端"""
for profile in self._profiles.values():
try:
await profile.client.aclose()
except Exception:
logger.debug("关闭 HTTP 客户端失败:profile=%s", profile.name, exc_info=True)
self._profiles.clear()
# ---- 状态 ----
def profile_status(self) -> dict[str, dict[str, Any]]:
"""各通道的预热状态,供健康检查展示"""
now = time.monotonic()
return {
name: {
"warmed": profile.warmed_at > 0,
"age_seconds": round(now - profile.warmed_at, 1) if profile.warmed_at else None,
"cookies": sorted(profile.cookie_names & set(site.AKAMAI_COOKIE_NAMES)),
}
for name, profile in self._profiles.items()
}
# ---- 抓取 ----
async def fetch_html(self, url: str, *, mobile: bool) -> str:
"""抓取要求含 __INITIAL_STATE__ 的页面,只取 HTML"""
page = await self.fetch(url, mobile=mobile)
return page.html
async def fetch(
self,
url: str,
*,
mobile: bool,
validator: PageValidator | None = None,
) -> FetchedPage:
"""抓取页面,返回 HTML 与最终落地地址
失败时按 重新预热 → 浏览器兜底 的顺序逐级升级重试。
Args:
validator: 页面校验器,默认要求页面含 __INITIAL_STATE__。跨站抓取时
由调用方注入按落地域名分派的校验逻辑。
Raises:
ItemNotFoundError: 目标页面 404
UpstreamBlockedError: 反复被反爬阻断
UpstreamRequestError: 网络异常或上游 5xx
ResourceBusyError: 等待并发槽位超时
AppError: 校验器判定为不可重试的失败(如落到不支持的站点)
"""
profile = self._profiles["sp" if mobile else "pc"]
max_attempts = max(1, self._settings.http_max_attempts)
validate = validator or require_state_marker
last_error: str = "unknown error"
try:
await asyncio.wait_for(
self._semaphore.acquire(),
timeout=self._settings.request_timeout_seconds,
)
except TimeoutError as exc:
raise ResourceBusyError() from exc
try:
for attempt in range(1, max_attempts + 1):
await self._ensure_warm(profile)
try:
response = await profile.client.get(url)
except httpx.HTTPError as exc:
last_error = f"{type(exc).__name__}: {exc}"
logger.warning(
"抓取请求异常:url=%s profile=%s attempt=%s/%s err=%s",
url, profile.name, attempt, max_attempts, last_error,
)
continue
if response.status_code == 404:
raise ItemNotFoundError(f"Page not found: {url}")
text = response.text
final_url = str(response.url)
if response.status_code < 400:
# 校验器可能直接抛 AppError 表示「重试也没用」,此处不拦截
reason = validate(text, final_url)
if reason is None:
return FetchedPage(html=text, url=final_url)
last_error = self._refine_failure(reason, text)
else:
last_error = self._describe_error_status(response.status_code)
logger.warning(
"抓取结果异常:url=%s profile=%s attempt=%s/%s %s",
url, profile.name, attempt, max_attempts, last_error,
)
if attempt >= max_attempts:
break
# 第一次失败先便宜地换一套 cookie;仍失败才动用浏览器
if attempt == 1:
await self._invalidate(profile)
else:
page = await self._escalate_to_browser(profile, url, validate)
if page is not None:
return page
if self._is_server_error(last_error):
raise UpstreamRequestError(f"Upstream request failed: {last_error}")
raise UpstreamBlockedError(f"Blocked while fetching {url}: {last_error}")
finally:
self._semaphore.release()
# ---- 内部 ----
@staticmethod
def _describe_error_status(status_code: int) -> str:
return (
f"upstream status {status_code}"
if status_code >= 500
else f"status {status_code}"
)
@staticmethod
def _refine_failure(reason: str, text: str) -> str:
"""页面内容校验失败时,优先报出更具体的反爬挑战页特征"""
lowered = text[:4000].lower()
matched = next((marker for marker in _BLOCK_MARKERS if marker in lowered), None)
return f"challenge page detected ({matched})" if matched else reason
@staticmethod
def _is_server_error(reason: str) -> bool:
return reason.startswith("upstream status") or reason.startswith("httpx") or "Error:" in reason
async def _ensure_warm(self, profile: _Profile) -> None:
"""确保通道持有新鲜的 Akamai cookie;过期或缺失时访问首页预热"""
if self._is_warm(profile):
return
async with profile.lock:
if self._is_warm(profile):
return
try:
response = await profile.client.get(self._settings.home_url)
profile.warmed_at = time.monotonic()
logger.info(
"会话预热完成:profile=%s status=%s cookies=%s",
profile.name,
response.status_code,
sorted(profile.cookie_names & set(site.AKAMAI_COOKIE_NAMES)),
)
except httpx.HTTPError as exc:
# 预热失败不阻断本次抓取:直连目标页仍可能成功,只是慢
logger.warning("会话预热失败:profile=%s err=%s", profile.name, exc)
profile.warmed_at = time.monotonic()
def _is_warm(self, profile: _Profile) -> bool:
if not profile.warmed_at:
return False
if time.monotonic() - profile.warmed_at > self._settings.session_ttl_seconds:
return False
return bool(profile.cookie_names & set(site.AKAMAI_COOKIE_NAMES))
async def _invalidate(self, profile: _Profile) -> None:
"""清空通道 cookie 并强制下次重新预热"""
async with profile.lock:
profile.client.cookies.clear()
profile.warmed_at = 0.0
logger.info("已清空会话 cookie,将重新预热:profile=%s", profile.name)
async def _escalate_to_browser(
self, profile: _Profile, url: str, validate: PageValidator
) -> FetchedPage | None:
"""用浏览器访问目标页,把 cookie 回灌给 HTTP 客户端
浏览器已经拿到合格页面时直接返回,省掉一次重复请求。
"""
visit = await self._browser.visit(url, mobile=profile.mobile)
if visit is None:
logger.warning(
"浏览器兜底不可用,放弃升级:profile=%s reason=%s",
profile.name,
self._browser.unavailable_reason,
)
return None
async with profile.lock:
for cookie in visit.cookies:
name = cookie.get("name")
value = cookie.get("value")
if not name or value is None:
continue
profile.client.cookies.set(
name,
value,
domain=cookie.get("domain") or "",
path=cookie.get("path") or "/",
)
profile.warmed_at = time.monotonic()
# 浏览器不回报最终 URL,这里以请求地址为准;跨站跳转场景下 HTTP 通道已先行
# 报错,走不到这一步。
if validate(visit.html, url) is None:
logger.info("浏览器兜底直接取回页面:url=%s profile=%s", url, profile.name)
return FetchedPage(html=visit.html, url=url)
logger.warning("浏览器兜底页面仍未通过校验:url=%s profile=%s", url, profile.name)
return None
View File
+44
View File
@@ -0,0 +1,44 @@
"""站点 JSON 取值的类型收敛工具
`__INITIAL_STATE__` 里同一字段在不同商品/店铺上可能是 null、数字或字符串
(例如运费既可能是 0 也可能是 null,评分既可能是 4.5 也可能是 "4.5"),
统一在这里做容错转换,避免解析器里到处写 isinstance 判断。
"""
from __future__ import annotations
from typing import Any
def as_dict(value: Any) -> dict[str, Any]:
"""非 dict(含 None)一律收敛为空 dict"""
return value if isinstance(value, dict) else {}
def as_list(value: Any) -> list[Any]:
"""非 list(含 None)一律收敛为空 list"""
return value if isinstance(value, list) else []
def as_str(value: Any) -> str:
"""非字符串一律收敛为空串"""
return value if isinstance(value, str) else ""
def as_int(value: Any, default: int = 0) -> int:
"""尽力转成 int;布尔值不视为数字,避免 True 被当成 1"""
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
return default
try:
return int(float(value))
except (TypeError, ValueError):
return default
def as_float(value: Any, default: float = 0.0) -> float:
"""尽力转成 float;布尔值不视为数字"""
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
return default
try:
return float(value)
except (TypeError, ValueError):
return default
+157
View File
@@ -0,0 +1,157 @@
"""ラクマ(fril.jp)页面 URL 的构建与解析
搜索页 URL 形如:
https://fril.jp/s?query=switch&category_id=788&statuses=5,4&min=1000&transaction=selling
参数名与取值取自站点前端 bundle 中 SearchPanel 组件的 `_url()` 方法,
不是猜测。注意站点对无法识别的参数值不会报错,而是**静默返回首页**
(如 statuses=99 返回 75KB 的首页 HTML),因此所有枚举值都必须来自码表。
商品页 URL 形如:https://item.fril.jp/{商品hash}
店铺页 URL 形如:https://fril.jp/shop/{店铺hash}
"""
from __future__ import annotations
from urllib.parse import quote, urlencode, urlsplit, urlunsplit, parse_qsl
from app.core import rakuma_site as site
from app.core.errors import InvalidRequestError
from app.models.scrape import RakumaSearchRequest
def build_search_url(payload: RakumaSearchRequest) -> str:
"""根据搜索请求参数构建 ラクマ 搜索页 URL"""
params: list[tuple[str, str]] = []
keyword = payload.keyword.strip()
if keyword:
params.append(("query", keyword))
if payload.exclude_keyword:
params.append(("excluded_query", payload.exclude_keyword))
if payload.category_id:
params.append(("category_id", str(payload.category_id).strip()))
# 站点前端把这两项做成互斥分支:勾选「排除无品牌」时不下发 brand_id
if payload.except_for_no_brand:
params.append(("except_for_no_brand", "true"))
elif payload.brand_id:
params.append(("brand_id", str(payload.brand_id).strip()))
if payload.min_price is not None:
params.append(("min", str(payload.min_price)))
if payload.max_price is not None:
params.append(("max", str(payload.max_price)))
if payload.authenticity_types:
codes = [site.AUTHENTICITY_CODES[value.value] for value in payload.authenticity_types]
params.append(("authenticity_types", ",".join(codes)))
if payload.conditions:
codes = [site.CONDITION_CODES[value.value] for value in payload.conditions]
params.append(("statuses", ",".join(codes)))
if payload.free_shipping:
params.append(("carriage", site.CARRIAGE_INCLUDED))
if payload.transaction is not None:
params.append(("transaction", site.TRANSACTION_CODES[payload.transaction.value]))
if payload.anonymous_shipping:
params.append(("anonymous_shipping", "true"))
sort_code, order_code = site.SORT_CODES[payload.sort.value]
params.append(("sort", sort_code))
params.append(("order", order_code))
if payload.page > 1:
params.append(("page", str(payload.page)))
return f"{site.SEARCH_BASE_URL}?{urlencode(params)}"
def normalize_search_url(raw_url: str, page: int) -> str:
"""校验透传的搜索 URL,并在需要时覆盖其中的页码
page 大于 1 时以入参为准覆盖 URL 中的 `page`,这样上游可以复用同一条 URL 翻页。
"""
parsed = urlsplit(raw_url)
if parsed.hostname != site.SEARCH_HOST:
raise InvalidRequestError(f"search_url 必须是 {site.SEARCH_HOST} 下的搜索页地址")
if page <= 1:
return raw_url
query = [
(key, value)
for key, value in parse_qsl(parsed.query, keep_blank_values=True)
if key != "page"
]
query.append(("page", str(page)))
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment))
def build_item_url(item_id: str) -> str:
"""由商品 hash 拼出商品详情页 URL"""
item = quote(item_id.strip().strip("/"), safe="")
if not item:
raise InvalidRequestError("item_id 不能为空")
return f"{site.ITEM_BASE_URL}{item}"
def split_item_url(raw_url: str) -> str:
"""从商品页 URL 中解析出商品 hash
Raises:
InvalidRequestError: 不是 item.fril.jp 下的商品页地址
"""
parsed = urlsplit(raw_url)
if parsed.hostname != site.ITEM_HOST:
raise InvalidRequestError(f"item_url 必须是 {site.ITEM_HOST} 下的商品页地址")
segments = [segment for segment in parsed.path.split("/") if segment]
if not segments:
raise InvalidRequestError(f"无法从 item_url 中解析商品编号:{raw_url}")
return segments[0]
def build_shop_url(shop_id: str, *, page: int = 1, review: bool = False) -> str:
"""由店铺 hash 拼出店铺页 URL
Args:
page: 商品列表页码,1 时不带参数
review: 取评价页(/review)而非商品列表页
"""
shop = quote(shop_id.strip().strip("/"), safe="")
if not shop:
raise InvalidRequestError("shop_id 不能为空")
url = f"{site.SHOP_BASE_URL}{shop}"
if review:
return f"{url}/review"
return f"{url}?page={page}" if page > 1 else url
def split_shop_url(raw_url: str) -> str:
"""从店铺页 URL 中解析出店铺 hash
Raises:
InvalidRequestError: 不是 fril.jp/shop/ 下的店铺页地址
"""
parsed = urlsplit(raw_url)
if parsed.hostname != site.SEARCH_HOST:
raise InvalidRequestError(f"shop_url 必须是 {site.SEARCH_HOST} 下的店铺页地址")
segments = [segment for segment in parsed.path.split("/") if segment]
if len(segments) < 2 or segments[0] != "shop":
raise InvalidRequestError(f"无法从 shop_url 中解析店铺编号:{raw_url}")
return segments[1]
def item_id_from_url(url: str) -> str:
"""尽力从任意商品链接中解析商品 hash,失败时返回空串
用于列表页解析——单条链接解析不出来不应中断整页解析。
"""
try:
parsed = urlsplit(url)
except ValueError:
return ""
if parsed.hostname != site.ITEM_HOST:
return ""
segments = [segment for segment in parsed.path.split("/") if segment]
return segments[0] if segments else ""
+164
View File
@@ -0,0 +1,164 @@
"""乐天页面 URL 的构建与解析
搜索页 URL 形如:
https://search.rakuten.co.jp/search/mall/{关键词}/{分类ID}/?p=2&s=2&min=1000&f=2&f=101
其中关键词段在只按分类检索时用 `-` 占位;`f` 可重复出现,每个值代表一项筛选。
商品页 URL 形如:
https://item.rakuten.co.jp/{店铺代码}/{商品编号}/
"""
from __future__ import annotations
from urllib.parse import quote, urlencode, urlparse, urlsplit, urlunsplit, parse_qsl
from app.core import site
from app.core.errors import InvalidRequestError
from app.models.scrape import SearchRequest
# 只按分类检索(无关键词)时,关键词段的占位符
_KEYWORD_PLACEHOLDER = "-"
# www.rakuten.co.jp 下已被站点自身占用、不可能是店铺代码的一级路径
_RESERVED_WWW_PATHS = frozenset({"category", "event", "search", "rms", "info", "help"})
def build_search_url(payload: SearchRequest) -> str:
"""根据搜索请求参数构建乐天搜索页 URL"""
keyword = payload.keyword.strip()
path = quote(keyword, safe="") if keyword else _KEYWORD_PLACEHOLDER
url = f"{site.SEARCH_BASE_URL}{path}/"
if payload.genre_id:
url = f"{url}{quote(str(payload.genre_id), safe='')}/"
params: list[tuple[str, str]] = []
if payload.page > 1:
params.append(("p", str(payload.page)))
sort_code = site.SORT_CODES.get(payload.sort.value)
if sort_code:
params.append(("s", sort_code))
if payload.min_price is not None:
params.append(("min", str(payload.min_price)))
if payload.max_price is not None:
params.append(("max", str(payload.max_price)))
if payload.shop_id is not None:
params.append(("sid", str(payload.shop_id)))
if payload.exclude_keyword:
params.append(("nitem", payload.exclude_keyword))
if payload.title_only:
params.append(("sf", "1"))
if payload.or_query:
params.append(("st", "O"))
if payload.min_review_score is not None:
params.append(("review", str(payload.min_review_score)))
if payload.tags:
params.append(("tg", ",".join(str(tag) for tag in payload.tags)))
# 成色与各布尔筛选共用可重复的 `f` 参数
if payload.condition is not None:
params.append(("f", site.CONDITION_CODES[payload.condition.value]))
for field_name, code in site.BOOL_FILTER_CODES.items():
if getattr(payload, field_name):
params.append(("f", code))
if not params:
return url
return f"{url}?{urlencode(params)}"
def normalize_search_url(raw_url: str, page: int) -> str:
"""校验透传的搜索 URL,并在需要时覆盖其中的页码
page 大于 1 时以入参为准覆盖 URL 中的 `p`,这样上游可以复用同一条 URL 翻页。
"""
parsed = urlsplit(raw_url)
if parsed.hostname != site.SEARCH_HOST:
raise InvalidRequestError(f"search_url 必须是 {site.SEARCH_HOST} 下的搜索页地址")
if page <= 1:
return raw_url
query = [(key, value) for key, value in parse_qsl(parsed.query, keep_blank_values=True) if key != "p"]
query.append(("p", str(page)))
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment))
def build_genre_url(genre_id: str | None) -> str:
"""构建取分类树用的页面地址
指定分类时用分类页——它比搜索页多给 genreInfo(完整分类名与描述)。
不指定时退回搜索页:分类分面与查询内容无关,用哨兵关键词能拿到干净的顶层列表。
"""
if genre_id:
return f"{site.CATEGORY_BASE_URL}{quote(str(genre_id).strip().strip('/'), safe='')}/"
return f"{site.SEARCH_BASE_URL}{site.GENRE_FACET_PROBE_KEYWORD}/"
def build_item_url(shop_code: str, item_code: str) -> str:
"""由店铺代码与商品编号拼出商品详情页 URL"""
shop = quote(shop_code.strip().strip("/"), safe="")
item = quote(item_code.strip().strip("/"), safe="")
if not shop or not item:
raise InvalidRequestError("shop_code 与 item_code 均不能为空")
return f"{site.ITEM_BASE_URL}{shop}/{item}/"
def split_item_url(raw_url: str) -> tuple[str, str]:
"""从商品页 URL 中解析出 (店铺代码, 商品编号)
Raises:
InvalidRequestError: 不是 item.rakuten.co.jp 下的商品页地址
"""
parsed = urlsplit(raw_url)
if parsed.hostname != site.ITEM_HOST:
raise InvalidRequestError(f"item_url 必须是 {site.ITEM_HOST} 下的商品页地址")
segments = [segment for segment in parsed.path.split("/") if segment]
if len(segments) < 2:
raise InvalidRequestError(f"无法从 item_url 中解析店铺代码与商品编号:{raw_url}")
return segments[0], segments[1]
def build_shop_url(shop_code: str) -> str:
"""由店铺代码拼出店铺首页 URL"""
shop = quote(shop_code.strip().strip("/"), safe="")
if not shop:
raise InvalidRequestError("shop_code 不能为空")
return f"{site.WWW_BASE_URL}{shop}/"
def split_shop_url(raw_url: str) -> str:
"""从店铺页 URL 中解析出店铺代码
Raises:
InvalidRequestError: 不是 www.rakuten.co.jp 下的店铺页地址
"""
parsed = urlsplit(raw_url)
if parsed.hostname != site.WWW_HOST:
raise InvalidRequestError(f"shop_url 必须是 {site.WWW_HOST} 下的店铺页地址")
segments = [segment for segment in parsed.path.split("/") if segment]
if not segments:
raise InvalidRequestError(f"无法从 shop_url 中解析店铺代码:{raw_url}")
# 排除分类页等非店铺路径,避免把 /category/101205/ 当成店铺代码 category
if segments[0] in _RESERVED_WWW_PATHS:
raise InvalidRequestError(f"shop_url 不是店铺首页地址:{raw_url}")
return segments[0]
def item_url_parts(url: str) -> tuple[str, str]:
"""尽力从任意商品链接中解析 (店铺代码, 商品编号),失败时返回空串
用于搜索结果——广告位链接可能指向跳转域名,解析不出来不应中断整页解析。
"""
try:
parsed = urlparse(url)
except ValueError:
return "", ""
if parsed.hostname != site.ITEM_HOST:
return "", ""
segments = [segment for segment in parsed.path.split("/") if segment]
if len(segments) < 2:
return "", ""
return segments[0], segments[1]
+32
View File
@@ -0,0 +1,32 @@
[project]
name = "rakuten-scraper-service"
version = "0.1.0"
description = "API service for Rakuten Ichiba scraping tasks"
requires-python = ">=3.13"
dependencies = [
"fastapi>=0.115.0,<1.0.0",
"httpx[http2]>=0.27.0,<1.0.0",
"loguru>=0.7.0,<1.0.0",
"pydantic>=2.0.0,<3.0.0",
"pydantic-settings>=2.4.0,<3.0.0",
"selectolax>=0.3.21,<1.0.0",
"uvicorn[standard]>=0.30.0,<1.0.0",
]
[project.optional-dependencies]
# 浏览器兜底:仅在纯 HTTP 被 Akamai 拦截时使用,日常运行不需要
browser = [
"playwright>=1.47.0,<2.0.0",
]
dev = [
"pytest>=8.3.0,<9.0.0",
"pytest-asyncio>=0.24.0,<1.0.0",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = ["tests"]
[tool.uv]
package = false
+44
View File
@@ -0,0 +1,44 @@
"""测试夹具:加载从真实站点抓取并裁剪后的页面状态样本"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
FIXTURES_DIR = Path(__file__).parent / "fixtures"
def load_fixture(name: str) -> dict[str, Any]:
return json.loads((FIXTURES_DIR / name).read_text(encoding="utf-8"))
@pytest.fixture
def search_state() -> dict[str, Any]:
"""搜索页 __INITIAL_STATE__ 样本(保留 1 条广告位 + 2 条普通商品)"""
return load_fixture("search_state.json")
@pytest.fixture
def item_state() -> dict[str, Any]:
"""商品详情页 __INITIAL_STATE__ 样本(多 SKU 商品,variants 裁剪为 3 条)"""
return load_fixture("item_state.json")
@pytest.fixture
def item_with_options_state() -> dict[str, Any]:
"""带商品选项(名入れギフト)的详情页样本,含必填 select 与自由文本 text"""
return load_fixture("item_with_options_state.json")
@pytest.fixture
def genre_root_state() -> dict[str, Any]:
"""哨兵关键词搜索页样本,根节点挂着全部顶层分类"""
return load_fixture("genre_root_state.json")
@pytest.fixture
def genre_node_state() -> dict[str, Any]:
"""分类页样本(テレビゲーム 101205,顶层分类,带 genreInfo)"""
return load_fixture("genre_node_state.json")
File diff suppressed because one or more lines are too long
+2115
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+248
View File
@@ -0,0 +1,248 @@
{
"state": {
"data": {
"genreTree": {
"parent_category": {
"id": 0,
"name": "",
"count": null,
"shortcut": null,
"leaf": false,
"children": [
{
"id": 101205,
"name": "テレビゲーム",
"count": null,
"shortcut": "game",
"leaf": false,
"children": [
{
"id": 568771,
"name": "Nintendo Switch 2",
"count": 8253,
"shortcut": "game_nintendo_switch_two",
"leaf": false,
"children": []
},
{
"id": 565950,
"name": "Nintendo Switch",
"count": 132279,
"shortcut": "game_nintendo_switch",
"leaf": false,
"children": []
},
{
"id": 568375,
"name": "プレイステーション5",
"count": 28407,
"shortcut": "game_playstation_five",
"leaf": false,
"children": []
},
{
"id": 568382,
"name": "Xbox Series X/S",
"count": 1366,
"shortcut": "game_xbox_series_x",
"leaf": false,
"children": []
},
{
"id": 563544,
"name": "プレイステーション4",
"count": 44066,
"shortcut": "game_playstation_four",
"leaf": false,
"children": []
},
{
"id": 553848,
"name": "Nintendo 3DS・2DS",
"count": 43115,
"shortcut": "game_nintendo_threeds_twods",
"leaf": false,
"children": []
},
{
"id": 112317,
"name": "スーパーファミコン",
"count": 37896,
"shortcut": "game_super_nintendo_entertainment_system",
"leaf": false,
"children": []
},
{
"id": 112243,
"name": "プレイステーション2",
"count": 32723,
"shortcut": "game_playstation_two",
"leaf": false,
"children": []
},
{
"id": 206062,
"name": "プレイステーション・ポータブル",
"count": 26661,
"shortcut": "game_playstation_portable",
"leaf": false,
"children": []
},
{
"id": 500481,
"name": "プレイステーション3",
"count": 21800,
"shortcut": "game_playstation_three",
"leaf": false,
"children": []
},
{
"id": 112257,
"name": "プレイステーション",
"count": 21211,
"shortcut": "game_playstation",
"leaf": false,
"children": []
},
{
"id": 206076,
"name": "Nintendo DS",
"count": 20226,
"shortcut": "game_nintendo_ds",
"leaf": false,
"children": []
},
{
"id": 562921,
"name": "プレイステーション・ヴィータ",
"count": 14626,
"shortcut": "game_playstation_vita",
"leaf": false,
"children": []
},
{
"id": 500739,
"name": "Wii",
"count": 9188,
"shortcut": "game_wii",
"leaf": false,
"children": []
},
{
"id": 200592,
"name": "ゲームボーイアドバンス",
"count": 8825,
"shortcut": "game_game_boy_advance",
"leaf": false,
"children": []
},
{
"id": 300955,
"name": "Xbox360",
"count": 7140,
"shortcut": "game_xbox_three_sixty",
"leaf": false,
"children": []
},
{
"id": 200589,
"name": "ファミコン",
"count": 6287,
"shortcut": "game_nintendo_entertainment_system",
"leaf": false,
"children": []
},
{
"id": 200192,
"name": "ゲームキューブ",
"count": 5921,
"shortcut": "game_gamecube",
"leaf": false,
"children": []
},
{
"id": 563545,
"name": "XboxOne",
"count": 5299,
"shortcut": "game_xboxone",
"leaf": false,
"children": []
},
{
"id": 112303,
"name": "ゲームボーイ",
"count": 4542,
"shortcut": "game_game_boy",
"leaf": true,
"children": []
},
{
"id": 563543,
"name": "WiiU",
"count": 3904,
"shortcut": "game_wiiu",
"leaf": false,
"children": []
},
{
"id": 112285,
"name": "セガサターン",
"count": 3351,
"shortcut": "game_sega_saturn",
"leaf": true,
"children": []
},
{
"id": 200193,
"name": "Xbox",
"count": 2743,
"shortcut": "game_xbox",
"leaf": true,
"children": []
},
{
"id": 112271,
"name": "ドリームキャスト",
"count": 1768,
"shortcut": "game_dreamcast",
"leaf": true,
"children": []
},
{
"id": 112289,
"name": "NINTENDO 64",
"count": 1689,
"shortcut": "game_nintendo_sixty_four",
"leaf": true,
"children": []
},
{
"id": 200594,
"name": "アーケードゲーム",
"count": 1153,
"shortcut": "game_arcade_games",
"leaf": true,
"children": []
},
{
"id": 101915,
"name": "その他",
"count": 194697,
"shortcut": "game_others",
"leaf": true,
"children": []
}
]
}
]
},
"error": null
},
"genreInfo": {
"fullGenreName": "テレビゲーム",
"description": "最新のテレビゲームのランキングに基づいた話題のテレビゲーム用ソフトやゲーム機など充実した品揃えです。ゲーム速報に名が上がる新着ゲームはもちろんのこと、中古の懐かしいゲームソフトも勢揃い!ゲームボーイやファミコン、スーパーファミコンなどの少し",
"cmsFormId": null
}
}
}
}
+331
View File
@@ -0,0 +1,331 @@
{
"state": {
"data": {
"genreTree": {
"parent_category": {
"id": 0,
"name": "",
"count": null,
"shortcut": null,
"leaf": false,
"children": [
{
"id": 100000,
"name": "百貨店・総合通販・ギフト",
"count": null,
"shortcut": "department",
"leaf": false,
"children": []
},
{
"id": 100005,
"name": "花・ガーデン・DIY",
"count": null,
"shortcut": "flower",
"leaf": false,
"children": []
},
{
"id": 100026,
"name": "パソコン・周辺機器",
"count": null,
"shortcut": "computer",
"leaf": false,
"children": []
},
{
"id": 100227,
"name": "食品",
"count": null,
"shortcut": "food",
"leaf": false,
"children": []
},
{
"id": 100316,
"name": "水・ソフトドリンク",
"count": null,
"shortcut": "drink",
"leaf": false,
"children": []
},
{
"id": 100371,
"name": "レディースファッション",
"count": null,
"shortcut": "ladiesfashion",
"leaf": false,
"children": []
},
{
"id": 100433,
"name": "インナー・下着・ナイトウェア",
"count": null,
"shortcut": "inner",
"leaf": false,
"children": []
},
{
"id": 100533,
"name": "キッズ・ベビー・マタニティ",
"count": null,
"shortcut": "baby",
"leaf": false,
"children": []
},
{
"id": 100804,
"name": "インテリア・寝具・収納",
"count": null,
"shortcut": "interior",
"leaf": false,
"children": []
},
{
"id": 100938,
"name": "ダイエット・健康",
"count": null,
"shortcut": "health",
"leaf": false,
"children": []
},
{
"id": 100939,
"name": "美容・コスメ・香水",
"count": null,
"shortcut": "beauty",
"leaf": false,
"children": []
},
{
"id": 101070,
"name": "スポーツ・アウトドア",
"count": null,
"shortcut": "sports",
"leaf": false,
"children": []
},
{
"id": 101114,
"name": "車・バイク",
"count": null,
"shortcut": "auto",
"leaf": false,
"children": []
},
{
"id": 101164,
"name": "ホビー",
"count": null,
"shortcut": "hobby",
"leaf": false,
"children": []
},
{
"id": 101205,
"name": "テレビゲーム",
"count": null,
"shortcut": "game",
"leaf": false,
"children": []
},
{
"id": 101213,
"name": "ペット・ペットグッズ",
"count": null,
"shortcut": "pet",
"leaf": false,
"children": []
},
{
"id": 101240,
"name": "CD・DVD",
"count": null,
"shortcut": "media",
"leaf": false,
"children": []
},
{
"id": 101381,
"name": "カタログギフト・チケット",
"count": null,
"shortcut": "ticket",
"leaf": false,
"children": []
},
{
"id": 101438,
"name": "サービス・リフォーム",
"count": null,
"shortcut": "service",
"leaf": false,
"children": []
},
{
"id": 111427,
"name": "住宅・不動産",
"count": null,
"shortcut": "realestates",
"leaf": false,
"children": []
},
{
"id": 112493,
"name": "楽器・音響機器",
"count": null,
"shortcut": "instrument",
"leaf": false,
"children": []
},
{
"id": 200162,
"name": "本・雑誌・コミック",
"count": null,
"shortcut": "book",
"leaf": false,
"children": []
},
{
"id": 211742,
"name": "TV・オーディオ・カメラ",
"count": null,
"shortcut": "electronics",
"leaf": false,
"children": []
},
{
"id": 215783,
"name": "日用品雑貨・文房具・手芸",
"count": null,
"shortcut": "daily",
"leaf": false,
"children": []
},
{
"id": 216129,
"name": "ジュエリー・アクセサリー",
"count": null,
"shortcut": "accessories",
"leaf": false,
"children": []
},
{
"id": 216131,
"name": "バッグ・小物・ブランド雑貨",
"count": null,
"shortcut": "fashiongoods",
"leaf": false,
"children": []
},
{
"id": 503190,
"name": "車用品・バイク用品",
"count": null,
"shortcut": "autogoods",
"leaf": false,
"children": []
},
{
"id": 510901,
"name": "日本酒・焼酎",
"count": null,
"shortcut": "sake",
"leaf": false,
"children": []
},
{
"id": 510915,
"name": "ビール・洋酒",
"count": null,
"shortcut": "liquor",
"leaf": false,
"children": []
},
{
"id": 551167,
"name": "スイーツ・お菓子",
"count": null,
"shortcut": "sweets",
"leaf": false,
"children": []
},
{
"id": 551169,
"name": "医薬品・コンタクト・介護",
"count": null,
"shortcut": "medicine",
"leaf": false,
"children": []
},
{
"id": 551177,
"name": "メンズファッション",
"count": null,
"shortcut": "mensfashion",
"leaf": false,
"children": []
},
{
"id": 558885,
"name": "靴",
"count": null,
"shortcut": "shoes",
"leaf": false,
"children": []
},
{
"id": 558929,
"name": "腕時計",
"count": null,
"shortcut": "watch",
"leaf": false,
"children": []
},
{
"id": 558944,
"name": "キッチン用品・食器・調理器具",
"count": null,
"shortcut": "kitchen",
"leaf": false,
"children": []
},
{
"id": 562637,
"name": "家電",
"count": null,
"shortcut": "appliance",
"leaf": false,
"children": []
},
{
"id": 564500,
"name": "スマートフォン・タブレット",
"count": null,
"shortcut": "smartdevice",
"leaf": false,
"children": []
},
{
"id": 565004,
"name": "光回線・モバイル通信",
"count": null,
"shortcut": "telecommunication",
"leaf": false,
"children": []
},
{
"id": 566382,
"name": "おもちゃ",
"count": null,
"shortcut": "toy",
"leaf": false,
"children": []
}
]
},
"error": null
},
"genreInfo": null
}
}
}
+766
View File
@@ -0,0 +1,766 @@
{
"item": {
"itemId": 10000033,
"itemNumber": "TX-300",
"itemName": "★期間限定セール!40%OFF!3650円→2190円!★Tシャツ 半袖 EU製 Emmauela イタリア ミラノ発 エジプト綿100% 通気 フラワーベア クマ柄 可愛い トップス カットソー スウェット プルオーバー シャツ ユニセックス レディース メンズ カップル ペアルック 通学 通勤 夏",
"itemNameEnc": "%A1%FA%B4%FC%B4%D6%B8%C2%C4%EA%A5%BB%A1%BC%A5%EB%A1%AA40%25OFF%A1%AA3650%B1%DF%A2%AA2190%B1%DF%A1%AA%A1%FAT%A5%B7%A5%E3%A5%C4+%C8%BE%C2%B5+EU%C0%BD+Emmauela+%A5%A4%A5%BF%A5%EA%A5%A2+%A5%DF%A5%E9%A5%CE%C8%AF+%A5%A8%A5%B8%A5%D7%A5%C8%CC%CA100%A1%F3+%C4%CC%B5%A4+%A5%D5%A5%E9%A5%EF%A1%BC%A5%D9%A5%A2+%A5%AF%A5%DE%CA%C1+%B2%C4%B0%A6%A4%A4+%A5%C8%A5%C3%A5%D7%A5%B9+%A5%AB%A5%C3%A5%C8%A5%BD%A1%BC+%A5%B9%A5%A6%A5%A7%A5%C3%A5%C8+%A5%D7%A5%EB%A5%AA%A1%BC%A5%D0%A1%BC+%A5%B7%A5%E3%A5%C4+%A5%E6%A5%CB%A5%BB%A5%C3%A5%AF%A5%B9+%A5%EC%A5%C7%A5%A3%A1%BC%A5%B9+%A5%E1%A5%F3%A5%BA+%A5%AB%A5%C3%A5%D7%A5%EB+%A5%DA%A5%A2%A5%EB%A5%C3%A5%AF+%C4%CC%B3%D8+%C4%CC%B6%D0+%B2%C6",
"manageNo": "tx-300",
"genreId": 551180,
"sellType": "normal",
"sellTypeId": 0,
"catchCopy": "Tシャツ 半袖 EU製 Emmauela イタリア ミラノ発 柔らか オーバーサイズ ビッグシルエット カジュアル レジャー スポーツ 日常 定番 ユニセックス カップル ペアルック 通学 通勤 夏",
"description": "<img src=\"https://image.rakuten.co.jp/fafachai/cabinet/13512838/001.jpg\" width=\"100%\"><a href=\"https://item.rakuten.co.jp/fafachai/tx-300/\"><img src=\"https://image.rakuten.co.jp/fafachai/cabinet/13512",
"isMnoFlag": false,
"isShippingFree": false,
"isTelecom": false,
"isSuperSaleDiscount": false,
"taxFlag": true,
"catchCopyEnc": "T%A5%B7%A5%E3%A5%C4+%C8%BE%C2%B5+EU%C0%BD+Emmauela+%A5%A4%A5%BF%A5%EA%A5%A2+%A5%DF%A5%E9%A5%CE%C8%AF+%BD%C0%A4%E9%A4%AB+%A5%AA%A1%BC%A5%D0%A1%BC%A5%B5%A5%A4%A5%BA+%A5%D3%A5%C3%A5%B0%A5%B7%A5%EB%A5%A8%A5%C3%A5%C8+%A5%AB%A5%B8%A5%E5%A5%A2%A5%EB+%A5%EC%A5%B8%A5%E3%A1%BC+%A5%B9%A5%DD%A1%BC%A5%C4+%C6%FC%BE%EF+%C4%EA%C8%D6+%A5%E6%A5%CB%A5%BB%A5%C3%A5%AF%A5%B9+%A5%AB%A5%C3%A5%D7%A5%EB+%A5%DA%A5%A2%A5%EB%A5%C3%A5%AF+%C4%CC%B3%D8+%C4%CC%B6%D0+%B2%C6",
"genreKaimawari": {
"isKaimawariCampaignPeriod": false
},
"installPaymentInfo": {
"installPaymentEnabled": false
},
"timeSale": {
"startDateTime": "2026/07/23 18:39:00",
"endDateTime": "2026/07/27 15:00:59"
},
"media": {
"images": [
{
"imageUrl": "https://tshop.r10s.jp/fafachai/cabinet/12147800/12220974/12895888/tx3000140.jpg",
"alt": "Tシャツ半袖EU製Emmauelaイタリアミラノ発エジプト綿100%柔らか通気フラワーベアクマ柄可愛いトップスカットソースウェットプルオーバーシャツユニセックスレディースメンズカップルペアルック通学通勤夏服"
},
{
"imageUrl": "https://tshop.r10s.jp/fafachai/cabinet/12147800/12220974/12895888/tx3000002.jpg",
"alt": "バレンタインバレンタインデー母の日父の日ギフトプレゼント贈り物彼氏彼女女性男性職場入学卒業卒園入園進学就職祝い新生活応援学生中学生高校生大学生新社会人記念品お祝いお返し団体服運動服運動会トレッキングサイクリングキャンプ"
},
{
"imageUrl": "https://tshop.r10s.jp/fafachai/cabinet/12147800/12220974/12895888/tx3000003.jpg",
"alt": "エジプト綿100%半袖Tシャツメンズレディース男女兼用シンプルデザインコットン100%半袖Tシャツユニセックス通勤通学デイリーウェアTシャツメンズレディース快適な着心地夏コーデ半袖コットンTシャツ男女兼用大人からシニアまで着用可能"
},
{
"imageUrl": "https://tshop.r10s.jp/fafachai/cabinet/12147800/12220974/12895888/tx3000004.jpg",
"alt": "シンプル半袖Tシャツメンズレディースカジュアルファッション柔らかな肌触り春夏コーデユニセックスTシャツ通学通勤休日のお出かけにおすすめ旅行レジャー散歩に最適"
},
{
"imageUrl": "https://tshop.r10s.jp/fafachai/cabinet/12147800/12220974/12895888/tx3000005.jpg",
"alt": "男女兼用Tシャツシンプルで個性的なデザイン日常使い夏休み旅行リラックススタイルメンズレディースゴールデンウィークのお出かけコーデコットンTシャツユニセックスカフェ巡りショッピングコーデ"
},
{
"imageUrl": "https://tshop.r10s.jp/fafachai/cabinet/12147800/12220974/12895888/tx3000006.jpg",
"alt": "半袖Tシャツ父の日ギフトプレゼントにおすすめ母の日ギフトシンプルで着やすい男女兼用半袖Tシャツ敬老の日プレゼントにもおすすめ家族旅行カジュアルスタイル"
},
{
"imageUrl": "https://tshop.r10s.jp/fafachai/cabinet/12147800/12220974/12895888/tx3000007.jpg",
"alt": "アウトドアキャンプBBQコーデユニセックスTシャツ春夏秋対応ベーシックアイテム在宅ワークルームウェアにも最適シンプルTシャツメンズレディーススポーツ観戦お出かけコーデ軽やかで快適な着心地"
},
{
"imageUrl": "https://tshop.r10s.jp/fafachai/cabinet/12147800/12220974/12895888/tx3000008.jpg",
"alt": "Tシャツシニア世代にも人気のベーシックデザイン男女兼用通気性の良い快適トップスユニセックスコットンTシャツデート休日コーデにおすすめ半袖Tシャツ街歩き旅行散策スタイル"
},
{
"imageUrl": "https://tshop.r10s.jp/fafachai/cabinet/12147800/12220974/12895888/tx3000009.jpg",
"alt": "コットンTシャツメンズレディース春のお花見コーデ夏祭り花火大会カジュアルファッション海辺の散歩リゾートスタイル男女兼用コットンTシャツ家族で楽しむ休日コーデ"
},
{
"imageUrl": "https://tshop.r10s.jp/fafachai/cabinet/12147800/12220974/12895888/tx3000010.jpg",
"alt": "シンプル半袖Tシャツメンズレディース年齢を問わず着用可能柔らかく上質な素材感半袖コットンTシャツカップルコーデペアスタイルにもおすすめ通勤通学オフィスカジュアル対応"
},
{
"imageUrl": "https://tshop.r10s.jp/fafachai/cabinet/12147800/12220974/12895888/tx3000011.jpg",
"alt": "半袖Tシャツ春夏の定番アイテムレジャードライブ旅行スタイル男女兼用Tシャツ父の日母の日敬老の日ギフト対応シンプルながら個性を演出"
},
{
"imageUrl": "https://tshop.r10s.jp/fafachai/cabinet/12147800/12220974/12895888/tx3000012.jpg",
"alt": "コットンTシャツメンズレディースオールシーズン活躍ユニセックス半袖Tシャツショッピングデートお出かけ向けエジプト綿100%Tシャツ快適でナチュラルな着心地"
},
{
"imageUrl": "https://tshop.r10s.jp/fafachai/cabinet/12147800/12220974/12895888/tx3000013.jpg",
"alt": "Tシャツ半袖EU製Emmauelaイタリアミラノ発エジプト綿100%トップススウェットプルオーバーシャツカジュアルレジャースポーツ日常定番ユニセックスレディースメンズカップルペアルック通学通勤夏服"
},
{
"imageUrl": "https://tshop.r10s.jp/fafachai/cabinet/12147800/12220974/12895888/tx3000014.jpg",
"alt": "Tシャツ半袖EU製Emmauelaイタリアミラノ発エジプト綿100%トップススウェットプルオーバーシャツカジュアルレジャースポーツ日常定番ユニセックスレディースメンズカップルペアルック通学通勤夏服"
},
{
"imageUrl": "https://tshop.r10s.jp/fafachai/cabinet/12147800/12220974/12895888/tx3000015.jpg",
"alt": "Tシャツ半袖EU製Emmauelaイタリアミラノ発エジプト綿100%トップススウェットプルオーバーシャツカジュアルレジャースポーツ日常定番ユニセックスレディースメンズカップルペアルック通学通勤夏服"
},
{
"imageUrl": "https://tshop.r10s.jp/fafachai/cabinet/12147800/12220974/12895888/tx3000016.jpg",
"alt": "Tシャツ半袖EU製Emmauelaイタリアミラノ発エジプト綿100%トップススウェットプルオーバーシャツカジュアルレジャースポーツ日常定番ユニセックスレディースメンズカップルペアルック通学通勤夏服"
},
{
"imageUrl": "https://tshop.r10s.jp/fafachai/cabinet/12147800/12220974/12895888/tx3000017.jpg",
"alt": "Tシャツ半袖EU製Emmauelaイタリアミラノ発エジプト綿100%トップススウェットプルオーバーシャツカジュアルレジャースポーツ日常定番ユニセックスレディースメンズカップルペアルック通学通勤夏服"
},
{
"imageUrl": "https://tshop.r10s.jp/fafachai/cabinet/12147800/12220974/12895888/tx3000018.jpg",
"alt": "Tシャツ半袖EU製Emmauelaイタリアミラノ発エジプト綿100%トップススウェットプルオーバーシャツカジュアルレジャースポーツ日常定番ユニセックスレディースメンズカップルペアルック通学通勤夏服"
},
{
"imageUrl": "https://tshop.r10s.jp/fafachai/cabinet/13525956/t0626.jpg",
"alt": "Tシャツ半袖EU製Emmauelaイタリアミラノ発エジプト綿100%トップススウェットプルオーバーシャツカジュアルレジャースポーツ日常定番ユニセックスレディースメンズカップルペアルック通学通勤夏服"
},
{
"imageUrl": "https://tshop.r10s.jp/fafachai/cabinet/12147904/101501.jpg",
"alt": "Tシャツ半袖EU製Emmauelaイタリアミラノ発エジプト綿100%トップススウェットプルオーバーシャツカジュアルレジャースポーツ日常定番ユニセックスレディースメンズカップルペアルック通学通勤夏服"
}
]
},
"oldImage": "https://tshop.r10s.jp/fafachai/cabinet/12147800/12220974/12895888/tx3000140.jpg",
"picImageUrl": "@0_mall/fafachai/cabinet/12147800/12220974/12895888/tx3000140.jpg",
"mobileFlag": 1,
"isRestricted": 0
},
"purchase": {
"information": {
"unit": 0,
"showDeliveryExcuse": false
},
"sku": {
"inventoryType": "multiple",
"inventoryTypeId": 2,
"variants": [
{
"variantId": "r-sku00000129",
"selectorValues": [
"D",
"黒",
"M-46"
],
"attributes": [
{
"title": "カラー",
"value": "黒"
},
{
"title": "ブランド名",
"value": "Emmauela"
},
{
"title": "メーカー型番",
"value": "なし"
},
{
"title": "素材(生地・毛糸)",
"value": "綿100%"
},
{
"title": "代表カラー",
"value": "ブラック"
},
{
"title": "サイズ(S/M/L)",
"value": "M"
},
{
"title": "シーズン",
"value": "春夏秋"
}
],
"shippingXRequestQueryInfo": {
"inStockDelvTemplateId": 1,
"includedPostage": false,
"overseasShipping": false,
"individualShipping": false,
"postageSegment1": 0,
"postageSegment2": 0,
"inStockOltTemplateId": 198966,
"shipFromTemplateId": 88485
},
"price": 2190,
"unit": 0,
"deliveryMessageRMS": "1-2日以内に発送・売れ筋商品",
"deliveryMessageShippingX": "12:00までの注文で最短7/28にお届け(あす楽)",
"showDeliveryExcuse": false,
"quantity": 500,
"noshi": false
},
{
"variantId": "r-sku00000128",
"selectorValues": [
"D",
"黒",
"S-38"
],
"attributes": [
{
"title": "カラー",
"value": "黒"
},
{
"title": "ブランド名",
"value": "Emmauela"
},
{
"title": "メーカー型番",
"value": "なし"
},
{
"title": "素材(生地・毛糸)",
"value": "綿100%"
},
{
"title": "代表カラー",
"value": "ブラック"
},
{
"title": "サイズ(S/M/L)",
"value": "S"
},
{
"title": "シーズン",
"value": "春夏秋"
}
],
"shippingXRequestQueryInfo": {
"inStockDelvTemplateId": 1,
"includedPostage": false,
"overseasShipping": false,
"individualShipping": false,
"postageSegment1": 0,
"postageSegment2": 0,
"inStockOltTemplateId": 198966,
"shipFromTemplateId": 88485
},
"price": 2190,
"unit": 0,
"deliveryMessageRMS": "1-2日以内に発送・売れ筋商品",
"deliveryMessageShippingX": "12:00までの注文で最短7/28にお届け(あす楽)",
"showDeliveryExcuse": false,
"quantity": 500,
"noshi": false
},
{
"variantId": "r-sku00000127",
"selectorValues": [
"D",
"黒",
"XS-36"
],
"attributes": [
{
"title": "カラー",
"value": "黒"
},
{
"title": "ブランド名",
"value": "Emmauela"
},
{
"title": "メーカー型番",
"value": "なし"
},
{
"title": "素材(生地・毛糸)",
"value": "綿100%"
},
{
"title": "代表カラー",
"value": "ブラック"
},
{
"title": "サイズ(S/M/L)",
"value": "SS"
},
{
"title": "シーズン",
"value": "春夏秋"
}
],
"shippingXRequestQueryInfo": {
"inStockDelvTemplateId": 1,
"includedPostage": false,
"overseasShipping": false,
"individualShipping": false,
"postageSegment1": 0,
"postageSegment2": 0,
"inStockOltTemplateId": 198966,
"shipFromTemplateId": 88485
},
"price": 2190,
"unit": 0,
"deliveryMessageRMS": "1-2日以内に発送・売れ筋商品",
"deliveryMessageShippingX": "12:00までの注文で最短7/28にお届け(あす楽)",
"showDeliveryExcuse": false,
"quantity": 500,
"noshi": false
}
],
"showInventory": false,
"quantity": 1,
"shippingIdentical": true,
"isShippingFeeIdentical": true,
"isDcpIdentical": true,
"axis": [
{
"key": "Key2",
"label": "タイプ",
"values": [
{
"value": "A",
"label": "A",
"isSoldOut": false
},
{
"value": "B",
"label": "B",
"isSoldOut": false
},
{
"value": "C",
"label": "C",
"isSoldOut": false
},
{
"value": "D",
"label": "D",
"isSoldOut": false
},
{
"value": "E",
"label": "E",
"isSoldOut": false
},
{
"value": "F",
"label": "F",
"isSoldOut": false
}
]
},
{
"key": "Key0",
"label": "カラー",
"values": [
{
"value": "白",
"label": "白",
"isSoldOut": false
},
{
"value": "黒",
"label": "黒",
"isSoldOut": false
},
{
"value": "ピンク",
"label": "ピンク",
"isSoldOut": false
},
{
"value": "グリーン",
"label": "グリーン",
"isSoldOut": false
},
{
"value": "グレー",
"label": "グレー",
"isSoldOut": false
}
]
},
{
"key": "Key1",
"label": "サイズ",
"values": [
{
"value": "XS-36",
"label": "XS-36",
"isSoldOut": false
},
{
"value": "S-38",
"label": "S-38",
"isSoldOut": false
},
{
"value": "M-46",
"label": "M-46",
"isSoldOut": false
},
{
"value": "L-48",
"label": "L-48",
"isSoldOut": false
},
{
"value": "XL-50",
"label": "XL-50",
"isSoldOut": false
},
{
"value": "2XL-52",
"label": "2XL-52",
"isSoldOut": false
}
]
}
],
"deliveryMessageRMS": "1-2日以内に発送・売れ筋商品",
"deliveryMessageShippingX": "12:00までの注文で最短7/28にお届け(あす楽)",
"identicalVariants": {
"backOrderFlag": true,
"doublePrice": true,
"orderQuantityLimit": true,
"shipping": true,
"standardPrice": true,
"tags": true,
"normalDeliveryTime": true,
"dltSenderZipCodeId": true,
"normalDeliveryDateId": true,
"backOrderDeliveryDateId": true,
"noshi": true
}
},
"sellType": {
"normalPurchase": {
"minPrice": 2190,
"preTaxPrice": 2190,
"basketDomain": "https://ts.sp.basket.step.rakuten.co.jp/rms/mall/bss/cartadd/set",
"directDomain": "https://ts.direct.step.rakuten.co.jp/rms/mall/cartAdd/",
"purchaseCondition": "enabled"
}
}
},
"shop": {
"information": {
"shopId": 434651,
"shopUrl": "fafachai",
"shopName": "チャチャショップ",
"shopNameEnc": "%A5%C1%A5%E3%A5%C1%A5%E3%A5%B7%A5%E7%A5%C3%A5%D7",
"taxPriceRoundType": "floor",
"taxRate": 0.1,
"calendar": {
"showShopCalendar": 1,
"eventDates": {
"holiday": [],
"orderOnly": [],
"shippingOnly": []
},
"currentJapanDate": "2026-07-27"
},
"categories": [
{
"name": "トップス",
"url": "https://item.rakuten.co.jp/fafachai/c/0000000002"
},
{
"name": "ボトムス",
"url": "https://item.rakuten.co.jp/fafachai/c/0000000006"
},
{
"name": "ジャケット・アウター",
"url": "https://item.rakuten.co.jp/fafachai/c/0000000007"
},
{
"name": "レザージャケット・ライダース",
"url": "https://item.rakuten.co.jp/fafachai/c/0000000008"
},
{
"name": "上下セット",
"url": "https://item.rakuten.co.jp/fafachai/c/0000000009"
},
{
"name": "バッグ・小物",
"url": "https://item.rakuten.co.jp/fafachai/c/0000000018"
},
{
"name": "その他",
"url": "https://item.rakuten.co.jp/fafachai/c/0000000001"
}
],
"shopReview": {
"rating": 4.68,
"total": 167
},
"is39Shop": true
},
"bundle": {
"state": "waiting"
},
"cart": {
"state": "waiting"
},
"ranking": {
"state": "waiting"
}
},
"review": {
"shop": {
"rating": 4.68,
"total": 167
},
"item": {
"totalRating": 4.79,
"count": 29,
"writeReviewFlag": true,
"isReviewList": true,
"reviews": [
{
"rating": 4,
"comment": "170センチ62キロMサイズ購入、いつものオーバーサイズの少し大きめに感じますが、生地もしっかりしていますし、何よりプリントが気に入り購入させて頂きました。今度は違うパターンを欲しくなってます。",
"user": "かつどん7503さん",
"date": "2026/07/24",
"shopReply": "この度は当店をご利用いただき、また丁寧なレビューをお寄せいただき誠にありがとうございます。\nサイズ感や生地について詳しくご感想をお聞かせいただき、大変参考になります。生地のしっかりとした品質や、プリントデザインをお気に召していただけたとのこと、スタッフ一同大変嬉しく拝見いたしました。\nまた、「今度は違うパターンも欲しい」とのお言葉をいただき、大変光栄に存じます。ぜひお好みに合うデザインを見つけていただけましたら幸いです。\nこれからもご満足いただける商品をお届けできるよう努めてまいりますので、今後ともどうぞよろしくお願いいたします。\nまたのご利用を心よりお待ちしております。",
"shopReplyDate": "2026/07/24"
},
{
"rating": 5,
"comment": "以前黒のTシャツを購入して肌触りが良く可愛いいデザインだったので再度白で購入しました?",
"user": "一服太郎さん",
"date": "2026/07/13",
"shopReply": "この度はリピートでのご購入、そして嬉しいレビューをご投稿いただき、誠にありがとうございます。\n以前ご購入いただいたブラックに続き、今回はホワイトもお選びいただけたとのこと、大変嬉しく思っております。\n肌触りやデザインをお気に召していただき、再び当店の商品をお選びいただけたことは、スタッフ一同にとって何よりの励みです。\nこれからもご満足いただける商品とサービスをお届けできるよう努めてまいります。\n今後とも当店をどうぞよろしくお願いいたします。",
"shopReplyDate": "2026/07/13"
},
{
"rating": 4,
"comment": "このシリーズが私も子供もお気に入りでして、パーカーも持っています。生地もしっかりしてるし、着心地もいい、絵もかわいくて、大事にしたいと思います。",
"user": "購入者さん",
"date": "2026/06/29",
"shopReply": "この度は当店をご利用いただき、また心温まるレビューをお寄せいただきまして誠にありがとうございます。\nこちらのシリーズをお子様とご一緒にお気に召していただき、パーカーもご愛用いただいているとのこと、大変嬉しく拝見いたしました。\n生地感や着心地、デザインにつきましてもご満足いただけているようで、スタッフ一同大変励みになります。大切にご愛用いただけるとのお言葉、心より感謝申し上げます。\n今後もご期待に添える商品をご提供できるよう努めてまいります。\nまたのご利用を心よりお待ちしております。誠にありがとうございました。",
"shopReplyDate": "2026/07/02"
}
]
}
},
"shipping": {
"shippingByDestination": null,
"informationFromServer": {
"prefectureId": 13,
"methodId": 1,
"carrierId": "01",
"startDate": "2026-07-28T08:00:00.000+09:00",
"cutOffDateTime": "2026-07-27T12:00:00.000+09:00",
"isNextDayDelivery": true,
"isCutOffToday": true,
"carrierNonSupportedMethods": [],
"shippingFee": 0,
"isIndividualShipping": false,
"isAsuraku": false,
"isShippingFree": true,
"is39Shop": false,
"isDcp": true,
"isRslItem": false,
"isRmsV1": false,
"freeShippingThreshold": 3980,
"requestQuery": {
"marketplaceId": "JP",
"calculationMode": "CHEAPEST",
"resultType": "DATE_FEE",
"shippingUnits": {
"request": {
"shipTo": {
"level1": "JP",
"level2": "13",
"postalCode": null
},
"shopShippingUnits": {
"shippingRequest": {
"shopId": "434651",
"items": {
"10000033:r-sku00000129": {
"data": {
"price": 2190,
"inventory": 1,
"includedPostage": false,
"deliverySetId": null,
"customShipping": {
"fixedFee": null,
"postageSegment1": 0,
"postageSegment2": 0,
"customTariffId": null
},
"individualShipping": false,
"asuraku": null,
"handlingTime": {
"inStockOltTemplateId": 198966,
"noStockOltTemplateId": null,
"shipFromTemplateId": 88485,
"inStockDelvTemplateId": 1,
"noStockDelvTemplateId": null
}
},
"quantity": 1
}
}
}
}
}
},
"calculationSettings": {
"deferredProcessingRequired": false,
"showAvailableThresholdDiscounts": true,
"showCalculationGroups": true
}
},
"overseasShipping": false
},
"shippingXRequestQueryInfo": {
"prefectureId": 13,
"is39Shop": false,
"includedPostage": false,
"individualShipping": false,
"postageSegment1": 0,
"postageSegment2": 0,
"overseasShipping": false,
"inStockOltTemplateId": 198966,
"inStockDelvTemplateId": 1,
"shipFromTemplateId": 88485
},
"information": null,
"bulkShipping": null,
"freeShipping": null,
"shippingCarriers": {
"10": {
"ja-JP": "新潟運輸",
"en-US": "Niigata Transport"
},
"11": {
"ja-JP": "中越運送",
"en-US": "Chuetsu Transport"
},
"12": {
"ja-JP": "岡山県貨物運送",
"en-US": "Okayamaken Freight Transport"
},
"13": {
"ja-JP": "久留米運送",
"en-US": "Kurume Transport"
},
"14": {
"ja-JP": "山陽自動車運送",
"en-US": "Sanyo Jidosha Transport"
},
"15": {
"ja-JP": "NXトランスポート",
"en-US": "NX Transport"
},
"16": {
"ja-JP": "エコ配",
"en-US": "Ecohai"
},
"17": {
"ja-JP": "EMS",
"en-US": "EMS"
},
"18": {
"ja-JP": "DHL",
"en-US": "DHL"
},
"19": {
"ja-JP": "FedEx",
"en-US": "FedEx"
},
"20": {
"ja-JP": "UPS",
"en-US": "UPS"
},
"21": {
"ja-JP": "日本通運",
"en-US": "Nippon Express"
},
"22": {
"ja-JP": "TNT",
"en-US": "TNT"
},
"23": {
"ja-JP": "OCS",
"en-US": "OCS"
},
"24": {
"ja-JP": "USPS",
"en-US": "USPS"
},
"25": {
"ja-JP": "SFエクスプレス",
"en-US": "SF Express"
},
"26": {
"ja-JP": "Aramex",
"en-US": "Aramex"
},
"27": {
"ja-JP": "SGHグローバル・ジャパン",
"en-US": "SGH Global Japan"
},
"28": {
"ja-JP": "Rakuten EXPRESS",
"en-US": "Rakuten EXPRESS"
},
"29": {
"ja-JP": "日本郵便 楽天倉庫出荷",
"en-US": "Japan Post from Rakuten Warehouse"
},
"30": {
"ja-JP": "ヤマト運輸 クロネコゆうパケット",
"en-US": "Yamato Kuroneko Yu-Packet"
},
"31": {
"ja-JP": "名鉄NX運輸",
"en-US": "Meitetsu NX Transport"
},
"00": {
"ja-JP": "その他",
"en-US": "Other"
},
"01": {
"ja-JP": "ヤマト運輸",
"en-US": "Yamato Transport"
},
"02": {
"ja-JP": "佐川急便",
"en-US": "Sagawa Express"
},
"03": {
"ja-JP": "日本郵便",
"en-US": "Japan Post"
},
"04": {
"ja-JP": "西濃運輸",
"en-US": "Seino Transportation"
},
"05": {
"ja-JP": "セイノースーパーエクスプレス",
"en-US": "Seino Super Express"
},
"06": {
"ja-JP": "福山通運",
"en-US": "Fukuyama Transporting"
},
"07": {
"ja-JP": "名鉄運輸",
"en-US": "Meitetsu Transport"
},
"08": {
"ja-JP": "トナミ運輸",
"en-US": "Tonami Transport"
},
"09": {
"ja-JP": "第一貨物",
"en-US": "Daiichi Freight System"
}
},
"identicalShippingFee": null,
"identicalDcp": null
},
"breadcrumbs": {
"genreBreadcrumbs": [
{
"name": "メンズファッション",
"url": "https://www.rakuten.co.jp/category/551177/"
},
{
"name": "トップス",
"url": "https://www.rakuten.co.jp/category/110765/"
},
{
"name": "Tシャツ・カットソー",
"url": "https://www.rakuten.co.jp/category/551180/"
}
]
}
}
File diff suppressed because it is too large Load Diff
+478
View File
@@ -0,0 +1,478 @@
<!doctype html><html><head><meta charset='utf-8'><script type="application/ld+json">
{"@context":"http://schema.org/","@type":"Product","name":"NintendoSwitch ONE PIECE 海賊無双4","image":"https://img.fril.jp/img/844877033/m/2914810081.jpg?1785071831","description":"初めに大変恐縮では御座いますが、他のサイトでも出品していますので、購入希望の方は先にコメントにて在庫の確認をお願い致します。\n\n「ONE PIECE 海賊無双4」\n\nバンダイナムコエンターテインメント\n型番:HAC-P-ATLZA\n\n本作は、「リアルなONE PIECEの戦場体験」をコンセプトに、アクション中に破壊される建物や土煙が漂う空気感など「ONE PIECEの戦場体験」を追及! 従来の「海賊無双」では出来なかった「破壊アクション」「空中無双アクション」「フォルムチェンジ」などの新要素を盛り込むことで、これまで以上に爽快な…\n\n#バンダイナムコエンターテインメント\n#HAC-P-ATLZA\n#エンタメ/ホビー\n#ゲームソフト/ゲーム機本体\n#家庭用ゲームソフト\n#NintendoSwitchワンピース海賊無双4\n#ONEPIECE海賊無双4","offers":{"@type":"Offer","priceCurrency":"JPY","price":2460,"priceValidUntil":"2027-01-27","itemCondition":"http://schema.org/UsedCondition","availability":"http://schema.org/InStock","seller":{"@type":"Organization","name":"naonao"}},"brand":{"@type":"Thing","name":"BANDAI NAMCO Entertainment"}}
</script></head><body><a data-rat-cp-item_condition="目立った傷や汚れなし" data-rat-cp-shipping_cost_payer="出品者" data-rat-cp-shipping_date_estimate="1~2日" data-rat-cp-shipping_from="北海道" data-rat-cp-brand_id="6454"></a><div class="sp-slide"><img src="https://img.fril.jp/img/844877033/l/2914810081.jpg?1785071831" class="sp-image" alt="BANDAI NAMCO Entertainment(バンダイナムコエンターテインメント)のNintendoSwitch ONE PIECE 海賊無双4 エンタメ/ホビーのゲームソフト/ゲーム機本体(家庭用ゲームソフト)の商品写真"><img src="https://img.fril.jp/img/844877033/l/2914810083.jpg?1785071831" class="sp-image" alt="BANDAI NAMCO Entertainment(バンダイナムコエンターテインメント)のNintendoSwitch ONE PIECE 海賊無双4 エンタメ/ホビーのゲームソフト/ゲーム機本体(家庭用ゲームソフト)の商品写真"></div><section class="item-info row">
<div class="item-info__header">
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<div class="item-info__icons clearfix">
<span class="item__icon postage-included"><i class="icon_item_tag"></i>送料込</span>
<span class="item__icon anonymous">匿名配送</span>
<span class="item__icon request-required">すぐに購入可</span>
</div>
<h1 class="item__name">NintendoSwitch ONE PIECE 海賊無双4</h1>
<p><a onclick="ga('send', 'event', 'ui_action', 'search', 'click_item_brand', {'dimension1': '6454'});" data-gtm-click="{&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_item_brand&quot;,&quot;item_id&quot;:&quot;844877033&quot;,&quot;item_name&quot;:&quot;NintendoSwitch ONE PIECE 海賊無双4&quot;,&quot;seller_user_id&quot;:&quot;3850223&quot;,&quot;category_id&quot;:&quot;788&quot;,&quot;first_category&quot;:&quot;エンタメ/ホビー&quot;,&quot;second_category&quot;:&quot;ゲームソフト/ゲーム機本体&quot;,&quot;third_category&quot;:&quot;家庭用ゲームソフト&quot;,&quot;brand_id&quot;:&quot;6454&quot;,&quot;brand_name&quot;:&quot;BANDAI NAMCO Entertainment&quot;,&quot;price&quot;:2460,&quot;seller_user_type&quot;:&quot;一般&quot;},&quot;commerce_data&quot;:null}}" href="https://fril.jp/brand/6454">BANDAI NAMCO Entertainment</a></p>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<!-- 通常価格 -->
<p class="item__value_area">
<span class="item__price"><span class="item__currency-symbol">¥</span>2,460</span>
<span class="item__postage">送料込</span>
</p>
</div>
</div>
</div>
<!-- 選択サイズ表示 PC用 -->
<!-- 選択サイズ表示 PC用ここまで -->
<div class="reduction-status ft-new-position">
</div>
<section class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<div class="btn-buy-fixed clearfix">
<p class="sell-price-fixed">
<span class="item__value"><span class="currency-symbol">¥</span>2,460</span>
<span class="item__icon postage-included">送料込</span>
</p>
<div class="reduction__section mbt__8 pc__only">
</div>
<!-- pc用 -->
<p class="sell-btn-fixed">
<a class="ga-class ga-item-buybutton btn btn-primary btn-large btn_buy" href="https://fril.jp/users/sign_up?l-id=cmn_oth_p_buy_b_measure&amp;ref_action=btn_buy">購入に進む</a>
</p>
</div>
<div class="buttons-box">
<div class="buttons-box-cell">
<div class="like_button_set">
<div class="ga-class ga-item-likebutton like_button">
<a class="list-icon-like list-icon-button" href="#" data-toggle="modal" data-target="#directSignupModal">
<i class="list-icon-like icon_tl_heart_line"></i> <span>いいね 1件</span>
</a>
</div>
</div>
</div>
<div class="buttons-box-cell sp">
<a class="go-to-comment-button" href="#" data-toggle="modal" data-target="#directSignupModal">
<i class="icon_tl_comment_line"></i>
<span>コメント 0件</span>
</a>
</div>
</div>
</div>
</div>
<div class="only__pc">
<div class="safety-badges" data-test="safety-badges">
<ul class="safety-badges__top">
<li class="safety-badges__badge-box">
<img class="safety-badges__badge" alt="" src="https://asset.fril.jp/assets/safety_badges/identification-user-outline-c1ea65e83d0b6221d759eefa62133ae75dcfb5cb67f8d9a875d9758017430076.svg">
<span class="safety-badges__caption safety-badges__caption--normal">本人確認済</span>
</li>
<li class="safety-badges__badge-box">
<img class="safety-badges__badge" alt="" src="https://asset.fril.jp/assets/safety_badges/support-agent-a6db00f6a0de9b3279109b0ed5aa1d18a92ee5dba689782938628a788cd4f07a.svg">
<span class="safety-badges__caption safety-badges__caption--normal">紛失補償有</span>
</li>
</ul>
<div class="safety-badges__bottom">
<a target="_blank" rel="noopener noreferrer" class="ato-kantei__text-link ga-class ga-safety" href="https://fril.jp/safety">
ラクマ安心・安全への取り組み
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.207 1.5L4.5 2.207L10.293 8L4.5 13.793L5.207 14.5L11.707 8L5.207 1.5Z" fill="currentColor"></path>
</svg>
</a> </div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const clickItemAuthenticityLpTarget = document.querySelector('[data-click-item-authenticity-lp]');
const clickItemAuthenticityLpParams = {"event":"fireEvent","eventData":{"event_name":"","event_parameter":{"view":0,"event_label":"click_item_authenticity_lp"},"commerce_data":null}};
clickItemAuthenticityLpTarget?.addEventListener('click', () => {
if (!clickItemAuthenticityLpTarget.dataset.listenerAdded) {
window.dataLayer.push(clickItemAuthenticityLpParams);
clickItemAuthenticityLpTarget.dataset.listenerAdded = true;
};
});
});
</script>
</div>
<div class="hidden-xs">
<div class="item__description only__pc">
<h4>商品説明</h4>
<div class="item__description__line-limited">
<span> 初めに大変恐縮では御座いますが、他のサイトでも出品していますので、購入希望の方は先にコメントにて在庫の確認をお願い致します。<br><br>「ONE&nbsp;PIECE&nbsp;海賊無双4」<br><br>バンダイナムコエンターテインメント<br>型番:HAC-P-ATLZA<br><br>本作は、「リアルなONE&nbsp;PIECEの戦場体験」をコンセプトに、アクション中に破壊される建物や土煙が漂う空気感など「ONE&nbsp;PIECEの戦場体験」を追及!&nbsp;&nbsp;従来の「海賊無双」では出来なかった「破壊アクション」「空中無双アクション」「フォルムチェンジ」などの新要素を盛り込むことで、これまで以上に爽快な…<br><br>#バンダイナムコエンターテインメント<br>#HAC-P-ATLZA<br>#エンタメ/ホビー<br>#ゲームソフト/ゲーム機本体<br>#家庭用ゲームソフト<br>#NintendoSwitchワンピース海賊無双4<br>#ONEPIECE海賊無双4 </span>
</div>
<div class="time_ago">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 14C4.6915 14 2 11.3085 2 8C2 4.6915 4.6915 2 8 2C11.3085 2 14 4.6915 14 8C14 11.3085 11.3085 14 8 14ZM8 1C4.134 1 1 4.134 1 8C1 11.866 4.134 15 8 15C11.866 15 15 11.866 15 8C15 4.134 11.866 1 8 1Z" fill="#757575"></path>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.3032 9.95944L7.50018 8.32944V3.49994H8.50018V7.67044L11.6967 9.04044L11.3032 9.95944Z" fill="#757575"></path>
</svg>
約4時間前
</div>
</div>
</div>
</div>
</section>
<div class="reduction__section mtp__16 sp__only">
</div>
<!-- sp用 -->
<div class="only__sp">
<div class="safety-badges" data-test="safety-badges">
<ul class="safety-badges__top">
<li class="safety-badges__badge-box">
<img class="safety-badges__badge" alt="" src="https://asset.fril.jp/assets/safety_badges/identification-user-outline-c1ea65e83d0b6221d759eefa62133ae75dcfb5cb67f8d9a875d9758017430076.svg">
<span class="safety-badges__caption safety-badges__caption--normal">本人確認済</span>
</li>
<li class="safety-badges__badge-box">
<img class="safety-badges__badge" alt="" src="https://asset.fril.jp/assets/safety_badges/support-agent-a6db00f6a0de9b3279109b0ed5aa1d18a92ee5dba689782938628a788cd4f07a.svg">
<span class="safety-badges__caption safety-badges__caption--normal">紛失補償有</span>
</li>
</ul>
<div class="safety-badges__bottom">
<a target="_blank" rel="noopener noreferrer" class="ato-kantei__text-link ga-class ga-safety" href="https://fril.jp/safety">
ラクマ安心・安全への取り組み
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.207 1.5L4.5 2.207L10.293 8L4.5 13.793L5.207 14.5L11.707 8L5.207 1.5Z" fill="currentColor"></path>
</svg>
</a> </div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const clickItemAuthenticityLpTarget = document.querySelector('[data-click-item-authenticity-lp]');
const clickItemAuthenticityLpParams = {"event":"fireEvent","eventData":{"event_name":"","event_parameter":{"view":0,"event_label":"click_item_authenticity_lp"},"commerce_data":null}};
clickItemAuthenticityLpTarget?.addEventListener('click', () => {
if (!clickItemAuthenticityLpTarget.dataset.listenerAdded) {
window.dataLayer.push(clickItemAuthenticityLpParams);
clickItemAuthenticityLpTarget.dataset.listenerAdded = true;
};
});
});
</script>
</div>
<section class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<div class="visible-xs">
<div class="item__description only__sp">
<h4 class="mtp__0">商品説明</h4>
<div class="item__description__line-limited">
<span> 初めに大変恐縮では御座いますが、他のサイトでも出品していますので、購入希望の方は先にコメントにて在庫の確認をお願い致します。<br><br>「ONE&nbsp;PIECE&nbsp;海賊無双4」<br><br>バンダイナムコエンターテインメント<br>型番:HAC-P-ATLZA<br><br>本作は、「リアルなONE&nbsp;PIECEの戦場体験」をコンセプトに、アクション中に破壊される建物や土煙が漂う空気感など「ONE&nbsp;PIECEの戦場体験」を追及!&nbsp;&nbsp;従来の「海賊無双」では出来なかった「破壊アクション」「空中無双アクション」「フォルムチェンジ」などの新要素を盛り込むことで、これまで以上に爽快な…<br><br>#バンダイナムコエンターテインメント<br>#HAC-P-ATLZA<br>#エンタメ/ホビー<br>#ゲームソフト/ゲーム機本体<br>#家庭用ゲームソフト<br>#NintendoSwitchワンピース海賊無双4<br>#ONEPIECE海賊無双4 </span>
</div>
<div class="time_ago">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 14C4.6915 14 2 11.3085 2 8C2 4.6915 4.6915 2 8 2C11.3085 2 14 4.6915 14 8C14 11.3085 11.3085 14 8 14ZM8 1C4.134 1 1 4.134 1 8C1 11.866 4.134 15 8 15C11.866 15 15 11.866 15 8C15 4.134 11.866 1 8 1Z" fill="#757575"></path>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.3032 9.95944L7.50018 8.32944V3.49994H8.50018V7.67044L11.6967 9.04044L11.3032 9.95944Z" fill="#757575"></path>
</svg>
約4時間前
</div>
</div>
</div>
</div>
</section>
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<div class="item-status ft-new-position">
<h4 class="mtp__0">商品情報</h4>
<table class="item__details">
<tbody><tr>
<th><i class="icon-status icon_top_category_etc item-status-category"></i> カテゴリ</th>
<td>
<a href="https://fril.jp/category/10007">エンタメ/ホビー</a><br>
<a href="https://fril.jp/category/786">ゲームソフト/ゲーム機本体</a><br>
<a href="https://fril.jp/category/788">家庭用ゲームソフト</a>
</td>
</tr>
<tr>
<th><i class="icon-status icon_category_tops item-status-size"></i> サイズ</th>
<td>なし</td>
</tr>
<tr>
<th><i class="icon-status icon_item_brand item-status-brand"></i> ブランド</th>
<td>
<a href="https://fril.jp/brand/6454">BANDAI NAMCO Entertainment</a>
</td>
</tr>
<tr>
<th><i class="icon-status icon_item_status item-status-status"></i> 商品の状態</th>
<td>目立った傷や汚れなし</td>
</tr>
<tr>
<th><i class="icon-status icon_item_tag item-status-carriage"></i> 配送料の負担</th>
<td>送料込</td>
</tr>
<tr>
<th><i class="icon-status icon_item_trac item-status-delivery_method"></i> 配送方法</th>
<td>かんたんラクマパック(ヤマト運輸)</td>
</tr>
<tr>
<th><i class="icon-status icon_item_calendar item-status-delivery_date"></i> 発送日の目安</th>
<td>支払い後、1~2日で発送</td>
</tr>
<tr>
<th><i class="icon-status icon_item_pin item-status-delivery_area"></i> 発送元の地域</th>
<td>北海道</td>
</tr>
</tbody></table>
<div class="text-right mbt__24 mr__16"><a href="https://fril.jp/guide/howto" target="_blank" id="btn_guide" class="text--link text--b" onclick="ga('send', 'event', 'ui_action', 'search', 'click_item_purchase_flow');" data-gtm-click="{&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_item_purchase_flow&quot;},&quot;commerce_data&quot;:null}}">購入の流れを確認する</a></div>
<div class="row sharebuttons">
<div class="col-lg-4 col-md-4 col-sm-4 col-xs-4">
<a href="https://twitter.com/intent/tweet?hashtags=BANDAINAMCOEntertainment%2C%E3%83%90%E3%83%B3%E3%83%80%E3%82%A4%E3%83%8A%E3%83%A0%E3%82%B3%E3%82%A8%E3%83%B3%E3%82%BF%E3%83%BC%E3%83%86%E3%82%A4%E3%83%B3%E3%83%A1%E3%83%B3%E3%83%88%2C%E3%82%B2%E3%83%BC%E3%83%A0%E3%82%BD%E3%83%95%E3%83%88%2F%E3%82%B2%E3%83%BC%E3%83%A0%E6%A9%9F%E6%9C%AC%E4%BD%93%2C%E5%AE%B6%E5%BA%AD%E7%94%A8%E3%82%B2%E3%83%BC%E3%83%A0%E3%82%BD%E3%83%95%E3%83%88%2C%E3%83%A9%E3%82%AF%E3%83%9E%2C%E3%83%95%E3%83%AA%E3%83%9E%E3%82%A2%E3%83%97%E3%83%AA&amp;text=%E3%80%8ENintendoSwitch+ONE+PIECE+%E6%B5%B7%E8%B3%8A%E7%84%A1%E5%8F%8C4%28%C2%A52%2C460%29%E3%80%8F%E3%82%92%E3%83%A9%E3%82%AF%E3%83%9E%E3%81%A7%E3%83%81%E3%82%A7%E3%83%83%E3%82%AF%EF%BC%81&amp;url=https%3A%2F%2Fitem.fril.jp%2F4aca1d6db3e422f3a251a8a8b61e1eff" class="ga-class ga-item-sharebutton btn btn-block x-twitter" onclick="ga('send', 'event', 'ui_action', 'search', 'click_item_share_twitter', {'dimension1': '844877033'});" data-gtm-click="{&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_item_share_twitter&quot;,&quot;item_id&quot;:&quot;844877033&quot;,&quot;item_name&quot;:&quot;NintendoSwitch ONE PIECE 海賊無双4&quot;,&quot;seller_user_id&quot;:&quot;3850223&quot;,&quot;category_id&quot;:&quot;788&quot;,&quot;first_category&quot;:&quot;エンタメ/ホビー&quot;,&quot;second_category&quot;:&quot;ゲームソフト/ゲーム機本体&quot;,&quot;third_category&quot;:&quot;家庭用ゲームソフト&quot;,&quot;brand_id&quot;:&quot;6454&quot;,&quot;brand_name&quot;:&quot;BANDAI NAMCO Entertainment&quot;,&quot;price&quot;:2460,&quot;seller_user_type&quot;:&quot;一般&quot;},&quot;commerce_data&quot;:null}}"><img alt="" class="lazy" data-original="https://asset.fril.jp/assets/new_web/icon-x-166df5e59867c52d69f791e2566140922a1e521dc281b69eb59e7438165e3dcc.png" src="https://asset.fril.jp/assets/new_web/item_square_dummy-e093d031b2a9738d2115341c1aa868b76ba6b0aa513492bd0068e673e7a6d3f6.png"> ポスト</a>
<noscript><img alt="" src="https://asset.fril.jp/assets/new_web/icon-x-166df5e59867c52d69f791e2566140922a1e521dc281b69eb59e7438165e3dcc.png"></noscript>
</div>
<div class="col-lg-4 col-md-4 col-sm-4 col-xs-4">
<a href="https://www.facebook.com/share.php?u=https://item.fril.jp/4aca1d6db3e422f3a251a8a8b61e1eff" onclick="window.open(this.href, 'FBwindow', 'width=650, height=450, menubar=no, toolbar=no, scrollbars=yes'); ga('send', 'event', 'ui_action', 'search', 'click_item_share_facebook', {'dimension1': '844877033'}); return false;" data-gtm-click="{&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_item_share_facebook&quot;,&quot;item_id&quot;:&quot;844877033&quot;,&quot;item_name&quot;:&quot;NintendoSwitch ONE PIECE 海賊無双4&quot;,&quot;seller_user_id&quot;:&quot;3850223&quot;,&quot;category_id&quot;:&quot;788&quot;,&quot;first_category&quot;:&quot;エンタメ/ホビー&quot;,&quot;second_category&quot;:&quot;ゲームソフト/ゲーム機本体&quot;,&quot;third_category&quot;:&quot;家庭用ゲームソフト&quot;,&quot;brand_id&quot;:&quot;6454&quot;,&quot;brand_name&quot;:&quot;BANDAI NAMCO Entertainment&quot;,&quot;price&quot;:2460,&quot;seller_user_type&quot;:&quot;一般&quot;},&quot;commerce_data&quot;:null}}" class="ga-class ga-item-sharebutton btn btn-block facebook"><img class="lazy" alt="" data-original="https://asset.fril.jp/assets/new_web/icon-facebook-1a25d5b26a65896fb90c6b38503ad2bfcde84f78b66d670347d212018781b2b5.png" src="https://asset.fril.jp/assets/new_web/item_square_dummy-e093d031b2a9738d2115341c1aa868b76ba6b0aa513492bd0068e673e7a6d3f6.png"> シェア</a>
<noscript><img alt="" src="https://asset.fril.jp/assets/new_web/icon-facebook-1a25d5b26a65896fb90c6b38503ad2bfcde84f78b66d670347d212018781b2b5.png"></noscript>
</div>
<div class="col-lg-4 col-md-4 col-sm-4 col-xs-4">
<a target="_blank" href="https://line.me/R/msg/text/?NintendoSwitch%20ONE%20PIECE%20%E6%B5%B7%E8%B3%8A%E7%84%A1%E5%8F%8C4%20%7C%20https%3A%2F%2Fitem.fril.jp%2F4aca1d6db3e422f3a251a8a8b61e1eff" rel="noreferrer" class="ga-class ga-item-sharebutton btn btn-block line" onclick="ga('send', 'event', 'ui_action', 'search', 'click_item_share_line', {'dimension1': '844877033'});" data-gtm-click="{&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_item_share_line&quot;,&quot;item_id&quot;:&quot;844877033&quot;,&quot;item_name&quot;:&quot;NintendoSwitch ONE PIECE 海賊無双4&quot;,&quot;seller_user_id&quot;:&quot;3850223&quot;,&quot;category_id&quot;:&quot;788&quot;,&quot;first_category&quot;:&quot;エンタメ/ホビー&quot;,&quot;second_category&quot;:&quot;ゲームソフト/ゲーム機本体&quot;,&quot;third_category&quot;:&quot;家庭用ゲームソフト&quot;,&quot;brand_id&quot;:&quot;6454&quot;,&quot;brand_name&quot;:&quot;BANDAI NAMCO Entertainment&quot;,&quot;price&quot;:2460,&quot;seller_user_type&quot;:&quot;一般&quot;},&quot;commerce_data&quot;:null}}"><img alt="" class="lazy" data-original="https://asset.fril.jp/assets/new_web/icon-line-6db6ecdc05f67f632ec40c964574e0a98bbbd6739e69beeffd13c3ac6385f2d7.png" src="https://asset.fril.jp/assets/new_web/item_square_dummy-e093d031b2a9738d2115341c1aa868b76ba6b0aa513492bd0068e673e7a6d3f6.png"> LINEで送る</a>
<noscript><img alt="" src="https://asset.fril.jp/assets/new_web/icon-line-6db6ecdc05f67f632ec40c964574e0a98bbbd6739e69beeffd13c3ac6385f2d7.png"></noscript>
</div>
</div>
<!--PC C--><!-- /7727/Rakuma/Item/MiddleRect1 -->
<div class="googleAdsense-box hidden-xs hidden-sm" data-test-g-ad-md-react1-pc="">
<div id="div-gpt-ad-1566432529257-0">
<script>
googletag.cmd.push(function() { googletag.display('div-gpt-ad-1566432529257-0'); });
</script>
</div>
</div>
<h4>出品者情報</h4>
<!-- ショップ情報 -->
<div class="row header-shopinfo shopinfo-area">
<div class="icon_and_shop_info_and_verifed-badge">
<a class="shopinfo-wrapverified-badge-item shop_link clearfix" href="https://fril.jp/shop/83b56a796a72011dc58b6e469270dfb7" onclick="ga('send', 'event', 'ui_action', 'search', 'click_item_shop'); window.dataLayer.push({&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_item_shop&quot;},&quot;commerce_data&quot;:null}});" data-ratid="click_item_shop" data-ratevent="click" data-ratparam="all" data-rat-cp-content_group="商品詳細" data-rat-cp-pagetitle="BANDAI NAMCO Entertainment - NintendoSwitch ONE PIECE 海賊無双4の通販 by naonao's shop | バンダイナムコエンターテインメントならラクマ" data-rat-cp-title="商品詳細画面(通常・ディスプレイ)" data-rat-cp-screen_url="https://fril.jp/item/4aca1d6db3e422f3a251a8a8b61e1eff" data-rat-cp-authenticity_item_status="not_covered" data-rat-cp-brand_id="6454" data-rat-cp-item_condition="目立った傷や汚れなし" data-rat-cp-shipping_cost_payer="出品者" data-rat-cp-shipping_date_estimate="1~2日" data-rat-cp-shipping_from="北海道" data-rat-cp-item_type="1" data-rat-cp-return_amount="0" data-rat-cp-return_rate="0" data-rat-cp-return_code_list="N/A"></a>
<div class="col-lg-2 col-md-2 col-sm-1 col-xs-2 header-shopinfo__user-icon user-icon-fit">
<img class="user img-circle img-responsive lazy" alt="" data-original="https://img.fril.jp/user/3850223/s/3850223.jpg?1545873036" src="https://asset.fril.jp/assets/new_web/icon_user-9e49e957def667b1abdaae57b9e81d66c31b27f8559d80e2eb682cfbb24e18cd.png">
<noscript><img alt="" src="https://img.fril.jp/user/3850223/s/3850223.jpg?1545873036"></noscript>
</div>
<div class="col-lg-9 col-md-9 col-sm-10 col-xs-9 header-shopinfo__shop_name_and_user_name">
<p class="header-shopinfo__shop-name clearfix"><span>naonao's shop</span></p>
<p class="header-shopinfo__user-name">naonao</p>
<div class="header-shopinfo__verified-badge-item">
<img class="header-shopinfo--iverified-icon" data-original="https://asset.fril.jp/assets/new_web/icon-verified-badge-178e2665b2afa925c984e980a73993ad82ab44ac8575378962c1c600cf30b6bc.png" src="https://asset.fril.jp/assets/new_web/icon-verified-badge-178e2665b2afa925c984e980a73993ad82ab44ac8575378962c1c600cf30b6bc.png">
本人確認済
<a href="https://faq.fril.jp/17420/" target="_blank" rel="noopener noreferrer" data-test="verified_badge_shop" onclick="gtmClickBadgeStatus('本人確認済')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.11101 2.17971C5.26216 1.41054 6.61553 1 8 1C9.85652 1 11.637 1.7375 12.9497 3.05025C14.2625 4.36301 15 6.14348 15 8C15 9.38447 14.5895 10.7378 13.8203 11.889C13.0511 13.0401 11.9579 13.9373 10.6788 14.4672C9.3997 14.997 7.99224 15.1356 6.63437 14.8655C5.2765 14.5954 4.02922 13.9287 3.05026 12.9497C2.07129 11.9708 1.4046 10.7235 1.13451 9.36563C0.86441 8.00776 1.00303 6.6003 1.53285 5.32122C2.06266 4.04213 2.95987 2.94888 4.11101 2.17971ZM4.66658 12.9888C5.65328 13.6481 6.81332 14 8 14C9.5913 14 11.1174 13.3679 12.2426 12.2426C13.3679 11.1174 14 9.5913 14 8C14 6.81331 13.6481 5.65327 12.9888 4.66658C12.3295 3.67988 11.3925 2.91085 10.2961 2.45672C9.19975 2.0026 7.99335 1.88378 6.82946 2.11529C5.66558 2.3468 4.59648 2.91824 3.75736 3.75736C2.91825 4.59647 2.3468 5.66557 2.11529 6.82946C1.88378 7.99334 2.0026 9.19974 2.45673 10.2961C2.91085 11.3925 3.67989 12.3295 4.66658 12.9888ZM7.17999 8.77002V9.11502L8.80999 9.13002V8.84002C8.80878 8.66485 8.85361 8.49242 8.93999 8.34002C9.09209 8.12732 9.2819 7.9443 9.49999 7.80002C9.81046 7.58848 10.0784 7.3205 10.29 7.01002C10.4803 6.712 10.5762 6.36345 10.565 6.01002C10.5729 5.63781 10.4684 5.27186 10.265 4.96002C10.0494 4.65554 9.75817 4.41254 9.41999 4.25502C9.0291 4.07854 8.60378 3.99142 8.17499 4.00002C7.67506 3.98766 7.17897 4.0903 6.72499 4.30002C6.33811 4.49475 5.98926 4.75723 5.69499 5.07502L6.45499 6.30002C6.63675 6.05331 6.86009 5.84012 7.11499 5.67002C7.37663 5.49526 7.68539 5.40455 7.99999 5.41002C8.23263 5.38767 8.4662 5.44211 8.66499 5.56502C8.74383 5.62176 8.80744 5.69708 8.85018 5.7843C8.89292 5.87153 8.91346 5.96795 8.90999 6.06502C8.91542 6.24353 8.86279 6.41898 8.75999 6.56502C8.59368 6.77098 8.39631 6.94979 8.17499 7.09502C7.85377 7.29967 7.58356 7.57501 7.38499 7.90002C7.24695 8.16905 7.17659 8.46767 7.17999 8.77002ZM7.58634 11.9257C7.7178 11.9781 7.8585 12.0034 7.99999 12C8.14303 12.0039 8.28538 11.9788 8.41854 11.9264C8.55169 11.874 8.67292 11.7953 8.77499 11.695C8.87865 11.5989 8.96077 11.482 9.01593 11.3518C9.07109 11.2217 9.09804 11.0813 9.09499 10.94C9.09875 10.7979 9.07215 10.6566 9.01697 10.5255C8.96179 10.3944 8.87929 10.2767 8.77499 10.18C8.56721 9.97978 8.28851 9.87009 7.99999 9.87502C7.85868 9.87308 7.71839 9.89902 7.58712 9.95136C7.45585 10.0037 7.33619 10.0814 7.23499 10.18C7.13068 10.2767 7.04818 10.3944 6.993 10.5255C6.93782 10.6566 6.91122 10.7979 6.91499 10.94C6.91194 11.0813 6.93888 11.2217 6.99404 11.3518C7.0492 11.482 7.13132 11.5989 7.23499 11.695C7.33536 11.7948 7.45487 11.8732 7.58634 11.9257Z"></path>
</svg>
</a>
</div>
</div>
<div class="col-lg-1 col-md-1 col-sm-1 col-xs-1 header-shopinfo__chevron">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.207 1.5L4.5 2.207L10.293 8L4.5 13.793L5.207 14.5L11.707 8L5.207 1.5Z" fill="#757575"></path>
</svg>
</div>
</div>
<div class="header-shopinfo__review_counts user-review-wrap">
<a href="https://fril.jp/shop/83b56a796a72011dc58b6e469270dfb7/review" onclick="ga('send', 'event', 'ui_action', 'profile', 'click_shop_review');" data-gtm-click="{&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_item_shop_review&quot;,&quot;screen_title&quot;:&quot;商品詳細画面(通常・ディスプレイ)&quot;,&quot;screen_name&quot;:&quot;item_detail&quot;},&quot;commerce_data&quot;:null}}">
<span>取引の評価</span>
<div class="review-list">
<ul class="list-inline">
<li>
<i class="icon-sun icon_review_sun"></i><span>456</span>
</li>
<li>
<i class="icon-cloud icon_review_cloud"></i><span>5</span>
</li>
<li>
<i class="icon-rain icon_review_rain"></i><span>0</span>
</li>
</ul>
<div class="review-chevron">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.207 1.5L4.5 2.207L10.293 8L4.5 13.793L5.207 14.5L11.707 8L5.207 1.5Z" fill="#757575"></path>
</svg>
</div>
</div>
</a>
</div>
<div class="user-bio-wrap seller__detail--link shop__profile">
<div class="shop__profile__line-limited">
★数あるショップの中からご覧頂き、誠に有難うございます。<br><br>★★★‼️フォロー大歓迎‼️★★★<br><br>★基本的に送料無料・匿名配送で、ネコポス、小~中(コンパクト)中~大(宅急便)での発送の為、お値下げ出来ない商品も御座いますので、その旨、ご了承下さい。<br><br>★フィギュアに着きましては、購入・獲得してから未開封の物が多いので、開封後の初期不良に着きましては責任を負いかねますので何卒ご理解頂きますようお願い致します。<br><br>★洋服等は写真の写り方によってお色の違いがある事が御座いますので、ご心配の方はコメントにて最初にお色の確認をお願い致します。到着後の思っていたのと色が違う等の返品はご遠慮願います。<br><br>★評価の悪い方、マナーの悪い方はコメントなく購入の場合、トラブル防止の為、お取引をお断りさせて頂く場合が御座います。ご理解・ご了承願います。<br><br>★出品させて頂いています商品に関しましては到着後にすり替え防止のため、すべての商品に付きましてNC.NRでお願い致します。<br><br>★カード類に付きましては、すり替え防止と致しまして、お届け後の返品・返金はご遠慮願います。<br>ご不安な方は購入をお控え下さい。<br>初期傷等、素人ですので見落し有る場合が御座います。ご不安な方は御遠慮下さい。<br><br>★発送後のトラブルに関しては責任は負いかねます。<br><br>★購入後は速やかにご入金お願い致します<br><br>★商品を出来るだけお安くお出しさせて頂くため、大きいもの等はコンパクトに圧縮等をさせて頂きます。<br><br>★コンビニ支払い等をご利用の方で期日内までにご入金頂けないかたはトラブル防止の為、購入をご遠慮願います。支払期日を過ぎましたら、キャンセル申請をさせて頂きます。<br><br>★出品しています商品に関しましては基本的に先に購入した方優先とさせて頂きますので、ご了承下さい。<br><br>★商品受け取り後は速やかにご評価願います。<br><br>★★突然不定期にてお値下げ致します。又、突然元の値段に戻すことも御座いますがご了承下さい。
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<div id="comments" class="comment-area-wrap">
<h4>コメント</h4>
<div class="comment-input">
<p><span id="btn_comment" class="comment-input__btn comment-input__btn--submit" data-toggle="modal" data-target="#directSignupModal">商品について質問する</span></p>
</div>
</div>
<div class="comment-area">
<div class="comment-area-inner">
<div id="comment-list">
</div>
<p class="commentmore-box">
<a href="#comment-list">すべてのコメントを見る<i class="icon_tl_cursor_down"></i></a>
</p>
</div>
</div>
</div>
</div>
</section><div class="item-status ft-new-position">
<h4 class="mtp__0">商品情報</h4>
<table class="item__details">
<tbody><tr>
<th><i class="icon-status icon_top_category_etc item-status-category"></i> カテゴリ</th>
<td>
<a href="https://fril.jp/category/10007">エンタメ/ホビー</a><br>
<a href="https://fril.jp/category/786">ゲームソフト/ゲーム機本体</a><br>
<a href="https://fril.jp/category/788">家庭用ゲームソフト</a>
</td>
</tr>
<tr>
<th><i class="icon-status icon_category_tops item-status-size"></i> サイズ</th>
<td>なし</td>
</tr>
<tr>
<th><i class="icon-status icon_item_brand item-status-brand"></i> ブランド</th>
<td>
<a href="https://fril.jp/brand/6454">BANDAI NAMCO Entertainment</a>
</td>
</tr>
<tr>
<th><i class="icon-status icon_item_status item-status-status"></i> 商品の状態</th>
<td>目立った傷や汚れなし</td>
</tr>
<tr>
<th><i class="icon-status icon_item_tag item-status-carriage"></i> 配送料の負担</th>
<td>送料込</td>
</tr>
<tr>
<th><i class="icon-status icon_item_trac item-status-delivery_method"></i> 配送方法</th>
<td>かんたんラクマパック(ヤマト運輸)</td>
</tr>
<tr>
<th><i class="icon-status icon_item_calendar item-status-delivery_date"></i> 発送日の目安</th>
<td>支払い後、1~2日で発送</td>
</tr>
<tr>
<th><i class="icon-status icon_item_pin item-status-delivery_area"></i> 発送元の地域</th>
<td>北海道</td>
</tr>
</tbody></table>
<div class="text-right mbt__24 mr__16"><a href="https://fril.jp/guide/howto" target="_blank" id="btn_guide" class="text--link text--b" onclick="ga('send', 'event', 'ui_action', 'search', 'click_item_purchase_flow');" data-gtm-click="{&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_item_purchase_flow&quot;},&quot;commerce_data&quot;:null}}">購入の流れを確認する</a></div>
<div class="row sharebuttons">
<div class="col-lg-4 col-md-4 col-sm-4 col-xs-4">
<a href="https://twitter.com/intent/tweet?hashtags=BANDAINAMCOEntertainment%2C%E3%83%90%E3%83%B3%E3%83%80%E3%82%A4%E3%83%8A%E3%83%A0%E3%82%B3%E3%82%A8%E3%83%B3%E3%82%BF%E3%83%BC%E3%83%86%E3%82%A4%E3%83%B3%E3%83%A1%E3%83%B3%E3%83%88%2C%E3%82%B2%E3%83%BC%E3%83%A0%E3%82%BD%E3%83%95%E3%83%88%2F%E3%82%B2%E3%83%BC%E3%83%A0%E6%A9%9F%E6%9C%AC%E4%BD%93%2C%E5%AE%B6%E5%BA%AD%E7%94%A8%E3%82%B2%E3%83%BC%E3%83%A0%E3%82%BD%E3%83%95%E3%83%88%2C%E3%83%A9%E3%82%AF%E3%83%9E%2C%E3%83%95%E3%83%AA%E3%83%9E%E3%82%A2%E3%83%97%E3%83%AA&amp;text=%E3%80%8ENintendoSwitch+ONE+PIECE+%E6%B5%B7%E8%B3%8A%E7%84%A1%E5%8F%8C4%28%C2%A52%2C460%29%E3%80%8F%E3%82%92%E3%83%A9%E3%82%AF%E3%83%9E%E3%81%A7%E3%83%81%E3%82%A7%E3%83%83%E3%82%AF%EF%BC%81&amp;url=https%3A%2F%2Fitem.fril.jp%2F4aca1d6db3e422f3a251a8a8b61e1eff" class="ga-class ga-item-sharebutton btn btn-block x-twitter" onclick="ga('send', 'event', 'ui_action', 'search', 'click_item_share_twitter', {'dimension1': '844877033'});" data-gtm-click="{&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_item_share_twitter&quot;,&quot;item_id&quot;:&quot;844877033&quot;,&quot;item_name&quot;:&quot;NintendoSwitch ONE PIECE 海賊無双4&quot;,&quot;seller_user_id&quot;:&quot;3850223&quot;,&quot;category_id&quot;:&quot;788&quot;,&quot;first_category&quot;:&quot;エンタメ/ホビー&quot;,&quot;second_category&quot;:&quot;ゲームソフト/ゲーム機本体&quot;,&quot;third_category&quot;:&quot;家庭用ゲームソフト&quot;,&quot;brand_id&quot;:&quot;6454&quot;,&quot;brand_name&quot;:&quot;BANDAI NAMCO Entertainment&quot;,&quot;price&quot;:2460,&quot;seller_user_type&quot;:&quot;一般&quot;},&quot;commerce_data&quot;:null}}"><img alt="" class="lazy" data-original="https://asset.fril.jp/assets/new_web/icon-x-166df5e59867c52d69f791e2566140922a1e521dc281b69eb59e7438165e3dcc.png" src="https://asset.fril.jp/assets/new_web/item_square_dummy-e093d031b2a9738d2115341c1aa868b76ba6b0aa513492bd0068e673e7a6d3f6.png"> ポスト</a>
<noscript><img alt="" src="https://asset.fril.jp/assets/new_web/icon-x-166df5e59867c52d69f791e2566140922a1e521dc281b69eb59e7438165e3dcc.png"></noscript>
</div>
<div class="col-lg-4 col-md-4 col-sm-4 col-xs-4">
<a href="https://www.facebook.com/share.php?u=https://item.fril.jp/4aca1d6db3e422f3a251a8a8b61e1eff" onclick="window.open(this.href, 'FBwindow', 'width=650, height=450, menubar=no, toolbar=no, scrollbars=yes'); ga('send', 'event', 'ui_action', 'search', 'click_item_share_facebook', {'dimension1': '844877033'}); return false;" data-gtm-click="{&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_item_share_facebook&quot;,&quot;item_id&quot;:&quot;844877033&quot;,&quot;item_name&quot;:&quot;NintendoSwitch ONE PIECE 海賊無双4&quot;,&quot;seller_user_id&quot;:&quot;3850223&quot;,&quot;category_id&quot;:&quot;788&quot;,&quot;first_category&quot;:&quot;エンタメ/ホビー&quot;,&quot;second_category&quot;:&quot;ゲームソフト/ゲーム機本体&quot;,&quot;third_category&quot;:&quot;家庭用ゲームソフト&quot;,&quot;brand_id&quot;:&quot;6454&quot;,&quot;brand_name&quot;:&quot;BANDAI NAMCO Entertainment&quot;,&quot;price&quot;:2460,&quot;seller_user_type&quot;:&quot;一般&quot;},&quot;commerce_data&quot;:null}}" class="ga-class ga-item-sharebutton btn btn-block facebook"><img class="lazy" alt="" data-original="https://asset.fril.jp/assets/new_web/icon-facebook-1a25d5b26a65896fb90c6b38503ad2bfcde84f78b66d670347d212018781b2b5.png" src="https://asset.fril.jp/assets/new_web/item_square_dummy-e093d031b2a9738d2115341c1aa868b76ba6b0aa513492bd0068e673e7a6d3f6.png"> シェア</a>
<noscript><img alt="" src="https://asset.fril.jp/assets/new_web/icon-facebook-1a25d5b26a65896fb90c6b38503ad2bfcde84f78b66d670347d212018781b2b5.png"></noscript>
</div>
<div class="col-lg-4 col-md-4 col-sm-4 col-xs-4">
<a target="_blank" href="https://line.me/R/msg/text/?NintendoSwitch%20ONE%20PIECE%20%E6%B5%B7%E8%B3%8A%E7%84%A1%E5%8F%8C4%20%7C%20https%3A%2F%2Fitem.fril.jp%2F4aca1d6db3e422f3a251a8a8b61e1eff" rel="noreferrer" class="ga-class ga-item-sharebutton btn btn-block line" onclick="ga('send', 'event', 'ui_action', 'search', 'click_item_share_line', {'dimension1': '844877033'});" data-gtm-click="{&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_item_share_line&quot;,&quot;item_id&quot;:&quot;844877033&quot;,&quot;item_name&quot;:&quot;NintendoSwitch ONE PIECE 海賊無双4&quot;,&quot;seller_user_id&quot;:&quot;3850223&quot;,&quot;category_id&quot;:&quot;788&quot;,&quot;first_category&quot;:&quot;エンタメ/ホビー&quot;,&quot;second_category&quot;:&quot;ゲームソフト/ゲーム機本体&quot;,&quot;third_category&quot;:&quot;家庭用ゲームソフト&quot;,&quot;brand_id&quot;:&quot;6454&quot;,&quot;brand_name&quot;:&quot;BANDAI NAMCO Entertainment&quot;,&quot;price&quot;:2460,&quot;seller_user_type&quot;:&quot;一般&quot;},&quot;commerce_data&quot;:null}}"><img alt="" class="lazy" data-original="https://asset.fril.jp/assets/new_web/icon-line-6db6ecdc05f67f632ec40c964574e0a98bbbd6739e69beeffd13c3ac6385f2d7.png" src="https://asset.fril.jp/assets/new_web/item_square_dummy-e093d031b2a9738d2115341c1aa868b76ba6b0aa513492bd0068e673e7a6d3f6.png"> LINEで送る</a>
<noscript><img alt="" src="https://asset.fril.jp/assets/new_web/icon-line-6db6ecdc05f67f632ec40c964574e0a98bbbd6739e69beeffd13c3ac6385f2d7.png"></noscript>
</div>
</div>
<!--PC C--><!-- /7727/Rakuma/Item/MiddleRect1 -->
<div class="googleAdsense-box hidden-xs hidden-sm" data-test-g-ad-md-react1-pc="">
<div id="div-gpt-ad-1566432529257-0">
<script>
googletag.cmd.push(function() { googletag.display('div-gpt-ad-1566432529257-0'); });
</script>
</div>
</div>
<h4>出品者情報</h4>
<!-- ショップ情報 -->
<div class="row header-shopinfo shopinfo-area">
<div class="icon_and_shop_info_and_verifed-badge">
<a class="shopinfo-wrapverified-badge-item shop_link clearfix" href="https://fril.jp/shop/83b56a796a72011dc58b6e469270dfb7" onclick="ga('send', 'event', 'ui_action', 'search', 'click_item_shop'); window.dataLayer.push({&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_item_shop&quot;},&quot;commerce_data&quot;:null}});" data-ratid="click_item_shop" data-ratevent="click" data-ratparam="all" data-rat-cp-content_group="商品詳細" data-rat-cp-pagetitle="BANDAI NAMCO Entertainment - NintendoSwitch ONE PIECE 海賊無双4の通販 by naonao's shop | バンダイナムコエンターテインメントならラクマ" data-rat-cp-title="商品詳細画面(通常・ディスプレイ)" data-rat-cp-screen_url="https://fril.jp/item/4aca1d6db3e422f3a251a8a8b61e1eff" data-rat-cp-authenticity_item_status="not_covered" data-rat-cp-brand_id="6454" data-rat-cp-item_condition="目立った傷や汚れなし" data-rat-cp-shipping_cost_payer="出品者" data-rat-cp-shipping_date_estimate="1~2日" data-rat-cp-shipping_from="北海道" data-rat-cp-item_type="1" data-rat-cp-return_amount="0" data-rat-cp-return_rate="0" data-rat-cp-return_code_list="N/A"></a>
<div class="col-lg-2 col-md-2 col-sm-1 col-xs-2 header-shopinfo__user-icon user-icon-fit">
<img class="user img-circle img-responsive lazy" alt="" data-original="https://img.fril.jp/user/3850223/s/3850223.jpg?1545873036" src="https://asset.fril.jp/assets/new_web/icon_user-9e49e957def667b1abdaae57b9e81d66c31b27f8559d80e2eb682cfbb24e18cd.png">
<noscript><img alt="" src="https://img.fril.jp/user/3850223/s/3850223.jpg?1545873036"></noscript>
</div>
<div class="col-lg-9 col-md-9 col-sm-10 col-xs-9 header-shopinfo__shop_name_and_user_name">
<p class="header-shopinfo__shop-name clearfix"><span>naonao's shop</span></p>
<p class="header-shopinfo__user-name">naonao</p>
<div class="header-shopinfo__verified-badge-item">
<img class="header-shopinfo--iverified-icon" data-original="https://asset.fril.jp/assets/new_web/icon-verified-badge-178e2665b2afa925c984e980a73993ad82ab44ac8575378962c1c600cf30b6bc.png" src="https://asset.fril.jp/assets/new_web/icon-verified-badge-178e2665b2afa925c984e980a73993ad82ab44ac8575378962c1c600cf30b6bc.png">
本人確認済
<a href="https://faq.fril.jp/17420/" target="_blank" rel="noopener noreferrer" data-test="verified_badge_shop" onclick="gtmClickBadgeStatus('本人確認済')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.11101 2.17971C5.26216 1.41054 6.61553 1 8 1C9.85652 1 11.637 1.7375 12.9497 3.05025C14.2625 4.36301 15 6.14348 15 8C15 9.38447 14.5895 10.7378 13.8203 11.889C13.0511 13.0401 11.9579 13.9373 10.6788 14.4672C9.3997 14.997 7.99224 15.1356 6.63437 14.8655C5.2765 14.5954 4.02922 13.9287 3.05026 12.9497C2.07129 11.9708 1.4046 10.7235 1.13451 9.36563C0.86441 8.00776 1.00303 6.6003 1.53285 5.32122C2.06266 4.04213 2.95987 2.94888 4.11101 2.17971ZM4.66658 12.9888C5.65328 13.6481 6.81332 14 8 14C9.5913 14 11.1174 13.3679 12.2426 12.2426C13.3679 11.1174 14 9.5913 14 8C14 6.81331 13.6481 5.65327 12.9888 4.66658C12.3295 3.67988 11.3925 2.91085 10.2961 2.45672C9.19975 2.0026 7.99335 1.88378 6.82946 2.11529C5.66558 2.3468 4.59648 2.91824 3.75736 3.75736C2.91825 4.59647 2.3468 5.66557 2.11529 6.82946C1.88378 7.99334 2.0026 9.19974 2.45673 10.2961C2.91085 11.3925 3.67989 12.3295 4.66658 12.9888ZM7.17999 8.77002V9.11502L8.80999 9.13002V8.84002C8.80878 8.66485 8.85361 8.49242 8.93999 8.34002C9.09209 8.12732 9.2819 7.9443 9.49999 7.80002C9.81046 7.58848 10.0784 7.3205 10.29 7.01002C10.4803 6.712 10.5762 6.36345 10.565 6.01002C10.5729 5.63781 10.4684 5.27186 10.265 4.96002C10.0494 4.65554 9.75817 4.41254 9.41999 4.25502C9.0291 4.07854 8.60378 3.99142 8.17499 4.00002C7.67506 3.98766 7.17897 4.0903 6.72499 4.30002C6.33811 4.49475 5.98926 4.75723 5.69499 5.07502L6.45499 6.30002C6.63675 6.05331 6.86009 5.84012 7.11499 5.67002C7.37663 5.49526 7.68539 5.40455 7.99999 5.41002C8.23263 5.38767 8.4662 5.44211 8.66499 5.56502C8.74383 5.62176 8.80744 5.69708 8.85018 5.7843C8.89292 5.87153 8.91346 5.96795 8.90999 6.06502C8.91542 6.24353 8.86279 6.41898 8.75999 6.56502C8.59368 6.77098 8.39631 6.94979 8.17499 7.09502C7.85377 7.29967 7.58356 7.57501 7.38499 7.90002C7.24695 8.16905 7.17659 8.46767 7.17999 8.77002ZM7.58634 11.9257C7.7178 11.9781 7.8585 12.0034 7.99999 12C8.14303 12.0039 8.28538 11.9788 8.41854 11.9264C8.55169 11.874 8.67292 11.7953 8.77499 11.695C8.87865 11.5989 8.96077 11.482 9.01593 11.3518C9.07109 11.2217 9.09804 11.0813 9.09499 10.94C9.09875 10.7979 9.07215 10.6566 9.01697 10.5255C8.96179 10.3944 8.87929 10.2767 8.77499 10.18C8.56721 9.97978 8.28851 9.87009 7.99999 9.87502C7.85868 9.87308 7.71839 9.89902 7.58712 9.95136C7.45585 10.0037 7.33619 10.0814 7.23499 10.18C7.13068 10.2767 7.04818 10.3944 6.993 10.5255C6.93782 10.6566 6.91122 10.7979 6.91499 10.94C6.91194 11.0813 6.93888 11.2217 6.99404 11.3518C7.0492 11.482 7.13132 11.5989 7.23499 11.695C7.33536 11.7948 7.45487 11.8732 7.58634 11.9257Z"></path>
</svg>
</a>
</div>
</div>
<div class="col-lg-1 col-md-1 col-sm-1 col-xs-1 header-shopinfo__chevron">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.207 1.5L4.5 2.207L10.293 8L4.5 13.793L5.207 14.5L11.707 8L5.207 1.5Z" fill="#757575"></path>
</svg>
</div>
</div>
<div class="header-shopinfo__review_counts user-review-wrap">
<a href="https://fril.jp/shop/83b56a796a72011dc58b6e469270dfb7/review" onclick="ga('send', 'event', 'ui_action', 'profile', 'click_shop_review');" data-gtm-click="{&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_item_shop_review&quot;,&quot;screen_title&quot;:&quot;商品詳細画面(通常・ディスプレイ)&quot;,&quot;screen_name&quot;:&quot;item_detail&quot;},&quot;commerce_data&quot;:null}}">
<span>取引の評価</span>
<div class="review-list">
<ul class="list-inline">
<li>
<i class="icon-sun icon_review_sun"></i><span>456</span>
</li>
<li>
<i class="icon-cloud icon_review_cloud"></i><span>5</span>
</li>
<li>
<i class="icon-rain icon_review_rain"></i><span>0</span>
</li>
</ul>
<div class="review-chevron">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.207 1.5L4.5 2.207L10.293 8L4.5 13.793L5.207 14.5L11.707 8L5.207 1.5Z" fill="#757575"></path>
</svg>
</div>
</div>
</a>
</div>
<div class="user-bio-wrap seller__detail--link shop__profile">
<div class="shop__profile__line-limited">
★数あるショップの中からご覧頂き、誠に有難うございます。<br><br>★★★‼️フォロー大歓迎‼️★★★<br><br>★基本的に送料無料・匿名配送で、ネコポス、小~中(コンパクト)中~大(宅急便)での発送の為、お値下げ出来ない商品も御座いますので、その旨、ご了承下さい。<br><br>★フィギュアに着きましては、購入・獲得してから未開封の物が多いので、開封後の初期不良に着きましては責任を負いかねますので何卒ご理解頂きますようお願い致します。<br><br>★洋服等は写真の写り方によってお色の違いがある事が御座いますので、ご心配の方はコメントにて最初にお色の確認をお願い致します。到着後の思っていたのと色が違う等の返品はご遠慮願います。<br><br>★評価の悪い方、マナーの悪い方はコメントなく購入の場合、トラブル防止の為、お取引をお断りさせて頂く場合が御座います。ご理解・ご了承願います。<br><br>★出品させて頂いています商品に関しましては到着後にすり替え防止のため、すべての商品に付きましてNC.NRでお願い致します。<br><br>★カード類に付きましては、すり替え防止と致しまして、お届け後の返品・返金はご遠慮願います。<br>ご不安な方は購入をお控え下さい。<br>初期傷等、素人ですので見落し有る場合が御座います。ご不安な方は御遠慮下さい。<br><br>★発送後のトラブルに関しては責任は負いかねます。<br><br>★購入後は速やかにご入金お願い致します<br><br>★商品を出来るだけお安くお出しさせて頂くため、大きいもの等はコンパクトに圧縮等をさせて頂きます。<br><br>★コンビニ支払い等をご利用の方で期日内までにご入金頂けないかたはトラブル防止の為、購入をご遠慮願います。支払期日を過ぎましたら、キャンセル申請をさせて頂きます。<br><br>★出品しています商品に関しましては基本的に先に購入した方優先とさせて頂きますので、ご了承下さい。<br><br>★商品受け取り後は速やかにご評価願います。<br><br>★★突然不定期にてお値下げ致します。又、突然元の値段に戻すことも御座いますがご了承下さい。
</div>
</div>
</div>
</div></body></html>
+364
View File
@@ -0,0 +1,364 @@
<!doctype html><html><head><meta charset='utf-8'><script type="application/ld+json">
{"@context":"http://schema.org/","@type":"Product","name":"スプラトゥーン レイダース スイッチ switch 2 ソフト 新品 未開封","image":"https://img.fril.jp/img/844649627/m/2913695594.jpg?1784942799","description":"★値下げ交渉、取り置き不可★\n値下げコメントお返事しません。\n\n★到着後→評価まで時間がかかる方はご購入をお控えください。よろしくお願い致します。\n\n★ 14時までのお支払いで当日発送手配します!14時以降は翌日の発送になります。ご了承ください。\n\n「スプラトゥーン レイダース」\n\n新品未使用、透明のパッケージフィルムも剥がしてません。未開封です。ネコポスで発送します。\n\n★すり替え防止のため、返品交換できません。\nご了承頂ける方のみ、ご購入ください。よろしくお願い致します。\n\n\n#エンタメ/ホビー\n#ゲームソフト/ゲーム機本体\n#家庭用ゲームソフト","offers":{"@type":"Offer","priceCurrency":"JPY","price":6299,"priceValidUntil":"2027-01-27","itemCondition":"http://schema.org/UsedCondition","availability":"http://schema.org/InStock","seller":{"@type":"Organization","name":"(^.^)"}},"brand":{"@type":"Thing","name":"Nintendo Switch"}}
</script></head><body><a data-rat-cp-item_condition="新品、未使用" data-rat-cp-shipping_cost_payer="出品者" data-rat-cp-shipping_date_estimate="1~2日" data-rat-cp-shipping_from="千葉県" data-rat-cp-brand_id="5296"></a><div class="sp-slide"><img src="https://img.fril.jp/img/844649627/l/2913695594.jpg?1784942799" class="sp-image" alt="Nintendo Switch(ニンテンドースイッチ)のスプラトゥーン レイダース スイッチ switch 2 ソフト 新品 未開封 エンタメ/ホビーのゲームソフト/ゲーム機本体(家庭用ゲームソフト)の商品写真"><img src="https://img.fril.jp/img/844649627/l/2913695611.jpg?1784942802" class="sp-image" alt="Nintendo Switch(ニンテンドースイッチ)のスプラトゥーン レイダース スイッチ switch 2 ソフト 新品 未開封 エンタメ/ホビーのゲームソフト/ゲーム機本体(家庭用ゲームソフト)の商品写真"></div><section class="item-info row">
<div class="item-info__header">
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<div class="item-info__icons clearfix">
<span class="item__icon postage-included"><i class="icon_item_tag"></i>送料込</span>
<span class="item__icon anonymous">匿名配送</span>
<span class="item__icon request-required">すぐに購入可</span>
</div>
<h1 class="item__name">スプラトゥーン レイダース スイッチ switch 2 ソフト 新品 未開封</h1>
<p><a onclick="ga('send', 'event', 'ui_action', 'search', 'click_item_brand', {'dimension1': '5296'});" data-gtm-click="{&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_item_brand&quot;,&quot;item_id&quot;:&quot;844649627&quot;,&quot;item_name&quot;:&quot;スプラトゥーン レイダース スイッチ switch 2 ソフト 新品 未開封&quot;,&quot;seller_user_id&quot;:&quot;12073120&quot;,&quot;category_id&quot;:&quot;788&quot;,&quot;first_category&quot;:&quot;エンタメ/ホビー&quot;,&quot;second_category&quot;:&quot;ゲームソフト/ゲーム機本体&quot;,&quot;third_category&quot;:&quot;家庭用ゲームソフト&quot;,&quot;brand_id&quot;:&quot;5296&quot;,&quot;brand_name&quot;:&quot;Nintendo Switch&quot;,&quot;price&quot;:6299,&quot;seller_user_type&quot;:&quot;一般&quot;},&quot;commerce_data&quot;:null}}" href="https://fril.jp/brand/5296">Nintendo Switch</a></p>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<!-- 通常価格 -->
<p class="item__value_area">
<span class="item__price"><span class="item__currency-symbol">¥</span>6,299</span>
<span class="item__postage">送料込</span>
</p>
</div>
</div>
</div>
<!-- 選択サイズ表示 PC用 -->
<!-- 選択サイズ表示 PC用ここまで -->
<section class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<p class="mtp__8"><span class="type-modal__contents--button--sold">SOLD OUT</span></p>
</div>
</div>
<div class="only__pc">
<div class="safety-badges" data-test="safety-badges">
<ul class="safety-badges__top">
<li class="safety-badges__badge-box">
<img class="safety-badges__badge" alt="" src="https://asset.fril.jp/assets/safety_badges/identification-user-outline-c1ea65e83d0b6221d759eefa62133ae75dcfb5cb67f8d9a875d9758017430076.svg">
<span class="safety-badges__caption safety-badges__caption--normal">本人確認済</span>
</li>
<li class="safety-badges__badge-box">
<img class="safety-badges__badge" alt="" src="https://asset.fril.jp/assets/safety_badges/support-agent-a6db00f6a0de9b3279109b0ed5aa1d18a92ee5dba689782938628a788cd4f07a.svg">
<span class="safety-badges__caption safety-badges__caption--normal">紛失補償有</span>
</li>
</ul>
<div class="safety-badges__bottom">
<a target="_blank" rel="noopener noreferrer" class="ato-kantei__text-link ga-class ga-safety" href="https://fril.jp/safety">
ラクマ安心・安全への取り組み
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.207 1.5L4.5 2.207L10.293 8L4.5 13.793L5.207 14.5L11.707 8L5.207 1.5Z" fill="currentColor"></path>
</svg>
</a> </div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const clickItemAuthenticityLpTarget = document.querySelector('[data-click-item-authenticity-lp]');
const clickItemAuthenticityLpParams = {"event":"fireEvent","eventData":{"event_name":"","event_parameter":{"view":0,"event_label":"click_item_authenticity_lp"},"commerce_data":null}};
clickItemAuthenticityLpTarget?.addEventListener('click', () => {
if (!clickItemAuthenticityLpTarget.dataset.listenerAdded) {
window.dataLayer.push(clickItemAuthenticityLpParams);
clickItemAuthenticityLpTarget.dataset.listenerAdded = true;
};
});
});
</script>
</div>
<div class="hidden-xs">
<div class="item__description only__pc">
<h4>商品説明</h4>
<div class="item__description__line-limited">
<span> ★値下げ交渉、取り置き不可★<br>値下げコメントお返事しません。<br><br>★到着後→評価まで時間がかかる方はご購入をお控えください。よろしくお願い致します。<br><br>&nbsp;14時までのお支払いで当日発送手配します!14時以降は翌日の発送になります。ご了承ください。<br><br>「スプラトゥーン&nbsp;レイダース」<br><br>新品未使用、透明のパッケージフィルムも剥がしてません。未開封です。ネコポスで発送します。<br><br>★すり替え防止のため、返品交換できません。<br>ご了承頂ける方のみ、ご購入ください。よろしくお願い致します。<br><br><br>#エンタメ/ホビー<br>#ゲームソフト/ゲーム機本体<br>#家庭用ゲームソフト </span>
</div>
<div class="time_ago">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 14C4.6915 14 2 11.3085 2 8C2 4.6915 4.6915 2 8 2C11.3085 2 14 4.6915 14 8C14 11.3085 11.3085 14 8 14ZM8 1C4.134 1 1 4.134 1 8C1 11.866 4.134 15 8 15C11.866 15 15 11.866 15 8C15 4.134 11.866 1 8 1Z" fill="#757575"></path>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.3032 9.95944L7.50018 8.32944V3.49994H8.50018V7.67044L11.6967 9.04044L11.3032 9.95944Z" fill="#757575"></path>
</svg>
約1時間前
</div>
</div>
</div>
</div>
</section>
<div class="reduction__section mtp__16 sp__only">
</div>
<!-- sp用 -->
<div class="only__sp">
<div class="safety-badges" data-test="safety-badges">
<ul class="safety-badges__top">
<li class="safety-badges__badge-box">
<img class="safety-badges__badge" alt="" src="https://asset.fril.jp/assets/safety_badges/identification-user-outline-c1ea65e83d0b6221d759eefa62133ae75dcfb5cb67f8d9a875d9758017430076.svg">
<span class="safety-badges__caption safety-badges__caption--normal">本人確認済</span>
</li>
<li class="safety-badges__badge-box">
<img class="safety-badges__badge" alt="" src="https://asset.fril.jp/assets/safety_badges/support-agent-a6db00f6a0de9b3279109b0ed5aa1d18a92ee5dba689782938628a788cd4f07a.svg">
<span class="safety-badges__caption safety-badges__caption--normal">紛失補償有</span>
</li>
</ul>
<div class="safety-badges__bottom">
<a target="_blank" rel="noopener noreferrer" class="ato-kantei__text-link ga-class ga-safety" href="https://fril.jp/safety">
ラクマ安心・安全への取り組み
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.207 1.5L4.5 2.207L10.293 8L4.5 13.793L5.207 14.5L11.707 8L5.207 1.5Z" fill="currentColor"></path>
</svg>
</a> </div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const clickItemAuthenticityLpTarget = document.querySelector('[data-click-item-authenticity-lp]');
const clickItemAuthenticityLpParams = {"event":"fireEvent","eventData":{"event_name":"","event_parameter":{"view":0,"event_label":"click_item_authenticity_lp"},"commerce_data":null}};
clickItemAuthenticityLpTarget?.addEventListener('click', () => {
if (!clickItemAuthenticityLpTarget.dataset.listenerAdded) {
window.dataLayer.push(clickItemAuthenticityLpParams);
clickItemAuthenticityLpTarget.dataset.listenerAdded = true;
};
});
});
</script>
</div>
<section class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<div class="visible-xs">
<div class="item__description only__sp">
<h4 class="mtp__0">商品説明</h4>
<div class="item__description__line-limited">
<span> ★値下げ交渉、取り置き不可★<br>値下げコメントお返事しません。<br><br>★到着後→評価まで時間がかかる方はご購入をお控えください。よろしくお願い致します。<br><br>&nbsp;14時までのお支払いで当日発送手配します!14時以降は翌日の発送になります。ご了承ください。<br><br>「スプラトゥーン&nbsp;レイダース」<br><br>新品未使用、透明のパッケージフィルムも剥がしてません。未開封です。ネコポスで発送します。<br><br>★すり替え防止のため、返品交換できません。<br>ご了承頂ける方のみ、ご購入ください。よろしくお願い致します。<br><br><br>#エンタメ/ホビー<br>#ゲームソフト/ゲーム機本体<br>#家庭用ゲームソフト </span>
</div>
<div class="time_ago">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 14C4.6915 14 2 11.3085 2 8C2 4.6915 4.6915 2 8 2C11.3085 2 14 4.6915 14 8C14 11.3085 11.3085 14 8 14ZM8 1C4.134 1 1 4.134 1 8C1 11.866 4.134 15 8 15C11.866 15 15 11.866 15 8C15 4.134 11.866 1 8 1Z" fill="#757575"></path>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.3032 9.95944L7.50018 8.32944V3.49994H8.50018V7.67044L11.6967 9.04044L11.3032 9.95944Z" fill="#757575"></path>
</svg>
約1時間前
</div>
</div>
</div>
</div>
</section>
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<div class="item-status ft-new-position">
<h4 class="mtp__0">商品情報</h4>
<table class="item__details">
<tbody><tr>
<th><i class="icon-status icon_top_category_etc item-status-category"></i> カテゴリ</th>
<td>
<a href="https://fril.jp/category/10007">エンタメ/ホビー</a><br>
<a href="https://fril.jp/category/786">ゲームソフト/ゲーム機本体</a><br>
<a href="https://fril.jp/category/788">家庭用ゲームソフト</a>
</td>
</tr>
<tr>
<th><i class="icon-status icon_item_brand item-status-brand"></i> ブランド</th>
<td>
<a href="https://fril.jp/brand/5296">Nintendo Switch</a>
</td>
</tr>
</tbody></table>
<!--PC C--><!-- /7727/Rakuma/Item/MiddleRect1 -->
<div class="googleAdsense-box hidden-xs hidden-sm" data-test-g-ad-md-react1-pc="">
<div id="div-gpt-ad-1566432529257-0">
<script>
googletag.cmd.push(function() { googletag.display('div-gpt-ad-1566432529257-0'); });
</script>
</div>
</div>
<h4>出品者情報</h4>
<!-- ショップ情報 -->
<div class="row header-shopinfo shopinfo-area">
<div class="icon_and_shop_info_and_verifed-badge">
<a class="shopinfo-wrapverified-badge-item shop_link clearfix" href="https://fril.jp/shop/422750cb7921557bc8dba2416915d968" onclick="ga('send', 'event', 'ui_action', 'search', 'click_item_shop'); window.dataLayer.push({&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_item_shop&quot;},&quot;commerce_data&quot;:null}});" data-ratid="click_item_shop" data-ratevent="click" data-ratparam="all" data-rat-cp-content_group="商品詳細" data-rat-cp-pagetitle="Nintendo Switch - スプラトゥーン レイダース スイッチ switch 2 ソフト 新品 未開封の通販 by (^.^) | ニンテンドースイッチならラクマ" data-rat-cp-title="商品詳細画面(通常・ディスプレイ)" data-rat-cp-screen_url="https://fril.jp/item/61b174d42b58216828a24b86b7903ced" data-rat-cp-authenticity_item_status="not_covered" data-rat-cp-brand_id="5296" data-rat-cp-item_condition="新品、未使用" data-rat-cp-shipping_cost_payer="出品者" data-rat-cp-shipping_date_estimate="1~2日" data-rat-cp-shipping_from="千葉県" data-rat-cp-item_type="1" data-rat-cp-return_amount="0" data-rat-cp-return_rate="0" data-rat-cp-return_code_list="N/A"></a>
<div class="col-lg-2 col-md-2 col-sm-1 col-xs-2 header-shopinfo__user-icon user-icon-fit">
<img class="user img-circle img-responsive lazy" alt="" data-original="https://img.fril.jp/user/12073120/s/12073120.jpg?1609027502" src="https://asset.fril.jp/assets/new_web/icon_user-9e49e957def667b1abdaae57b9e81d66c31b27f8559d80e2eb682cfbb24e18cd.png">
<noscript><img alt="" src="https://img.fril.jp/user/12073120/s/12073120.jpg?1609027502"></noscript>
</div>
<div class="col-lg-9 col-md-9 col-sm-10 col-xs-9 header-shopinfo__shop_name_and_user_name">
<p class="header-shopinfo__shop-name clearfix"><span>(^.^)</span></p>
<p class="header-shopinfo__user-name">(^.^)</p>
<div class="header-shopinfo__verified-badge-item">
<img class="header-shopinfo--iverified-icon" data-original="https://asset.fril.jp/assets/new_web/icon-verified-badge-178e2665b2afa925c984e980a73993ad82ab44ac8575378962c1c600cf30b6bc.png" src="https://asset.fril.jp/assets/new_web/icon-verified-badge-178e2665b2afa925c984e980a73993ad82ab44ac8575378962c1c600cf30b6bc.png">
本人確認済
<a href="https://faq.fril.jp/17420/" target="_blank" rel="noopener noreferrer" data-test="verified_badge_shop" onclick="gtmClickBadgeStatus('本人確認済')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.11101 2.17971C5.26216 1.41054 6.61553 1 8 1C9.85652 1 11.637 1.7375 12.9497 3.05025C14.2625 4.36301 15 6.14348 15 8C15 9.38447 14.5895 10.7378 13.8203 11.889C13.0511 13.0401 11.9579 13.9373 10.6788 14.4672C9.3997 14.997 7.99224 15.1356 6.63437 14.8655C5.2765 14.5954 4.02922 13.9287 3.05026 12.9497C2.07129 11.9708 1.4046 10.7235 1.13451 9.36563C0.86441 8.00776 1.00303 6.6003 1.53285 5.32122C2.06266 4.04213 2.95987 2.94888 4.11101 2.17971ZM4.66658 12.9888C5.65328 13.6481 6.81332 14 8 14C9.5913 14 11.1174 13.3679 12.2426 12.2426C13.3679 11.1174 14 9.5913 14 8C14 6.81331 13.6481 5.65327 12.9888 4.66658C12.3295 3.67988 11.3925 2.91085 10.2961 2.45672C9.19975 2.0026 7.99335 1.88378 6.82946 2.11529C5.66558 2.3468 4.59648 2.91824 3.75736 3.75736C2.91825 4.59647 2.3468 5.66557 2.11529 6.82946C1.88378 7.99334 2.0026 9.19974 2.45673 10.2961C2.91085 11.3925 3.67989 12.3295 4.66658 12.9888ZM7.17999 8.77002V9.11502L8.80999 9.13002V8.84002C8.80878 8.66485 8.85361 8.49242 8.93999 8.34002C9.09209 8.12732 9.2819 7.9443 9.49999 7.80002C9.81046 7.58848 10.0784 7.3205 10.29 7.01002C10.4803 6.712 10.5762 6.36345 10.565 6.01002C10.5729 5.63781 10.4684 5.27186 10.265 4.96002C10.0494 4.65554 9.75817 4.41254 9.41999 4.25502C9.0291 4.07854 8.60378 3.99142 8.17499 4.00002C7.67506 3.98766 7.17897 4.0903 6.72499 4.30002C6.33811 4.49475 5.98926 4.75723 5.69499 5.07502L6.45499 6.30002C6.63675 6.05331 6.86009 5.84012 7.11499 5.67002C7.37663 5.49526 7.68539 5.40455 7.99999 5.41002C8.23263 5.38767 8.4662 5.44211 8.66499 5.56502C8.74383 5.62176 8.80744 5.69708 8.85018 5.7843C8.89292 5.87153 8.91346 5.96795 8.90999 6.06502C8.91542 6.24353 8.86279 6.41898 8.75999 6.56502C8.59368 6.77098 8.39631 6.94979 8.17499 7.09502C7.85377 7.29967 7.58356 7.57501 7.38499 7.90002C7.24695 8.16905 7.17659 8.46767 7.17999 8.77002ZM7.58634 11.9257C7.7178 11.9781 7.8585 12.0034 7.99999 12C8.14303 12.0039 8.28538 11.9788 8.41854 11.9264C8.55169 11.874 8.67292 11.7953 8.77499 11.695C8.87865 11.5989 8.96077 11.482 9.01593 11.3518C9.07109 11.2217 9.09804 11.0813 9.09499 10.94C9.09875 10.7979 9.07215 10.6566 9.01697 10.5255C8.96179 10.3944 8.87929 10.2767 8.77499 10.18C8.56721 9.97978 8.28851 9.87009 7.99999 9.87502C7.85868 9.87308 7.71839 9.89902 7.58712 9.95136C7.45585 10.0037 7.33619 10.0814 7.23499 10.18C7.13068 10.2767 7.04818 10.3944 6.993 10.5255C6.93782 10.6566 6.91122 10.7979 6.91499 10.94C6.91194 11.0813 6.93888 11.2217 6.99404 11.3518C7.0492 11.482 7.13132 11.5989 7.23499 11.695C7.33536 11.7948 7.45487 11.8732 7.58634 11.9257Z"></path>
</svg>
</a>
</div>
</div>
<div class="col-lg-1 col-md-1 col-sm-1 col-xs-1 header-shopinfo__chevron">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.207 1.5L4.5 2.207L10.293 8L4.5 13.793L5.207 14.5L11.707 8L5.207 1.5Z" fill="#757575"></path>
</svg>
</div>
</div>
<div class="header-shopinfo__review_counts user-review-wrap">
<a href="https://fril.jp/shop/422750cb7921557bc8dba2416915d968/review" onclick="ga('send', 'event', 'ui_action', 'profile', 'click_shop_review');" data-gtm-click="{&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_item_shop_review&quot;,&quot;screen_title&quot;:&quot;商品詳細画面(通常・ディスプレイ)&quot;,&quot;screen_name&quot;:&quot;item_detail&quot;},&quot;commerce_data&quot;:null}}">
<span>取引の評価</span>
<div class="review-list">
<ul class="list-inline">
<li>
<i class="icon-sun icon_review_sun"></i><span>1,883</span>
</li>
<li>
<i class="icon-cloud icon_review_cloud"></i><span>3</span>
</li>
<li>
<i class="icon-rain icon_review_rain"></i><span>0</span>
</li>
</ul>
<div class="review-chevron">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.207 1.5L4.5 2.207L10.293 8L4.5 13.793L5.207 14.5L11.707 8L5.207 1.5Z" fill="#757575"></path>
</svg>
</div>
</div>
</a>
</div>
<div class="user-bio-wrap seller__detail--link shop__profile">
<div class="shop__profile__line-limited">
★評価に悪いがたくさんある方、大幅な値下げ交渉をされる方、言葉遣い等、不快に感じた場合ブロックします。<br><br>★取り置き不可。コメント中でも即決してくださる方優先です。<br><br>★ 返品できませんので、ご納得頂けるまで、お気軽にコメントでご質問ください☺︎︎<br>素人の自宅保管品になりますので、完璧な状態でお探しの方、少しの見逃しも許せない方はお店で新品をご購入ください。<br><br>★小さな子供がいる為、お急ぎの方、急かされる方はご購入をお控えくださいm(_ _)m<br>夜は子供と早く寝てしまうので、朝お返事する事が多いです。<br><br>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<div id="comments" class="comment-area-wrap">
<h4>コメント</h4>
<div class="comment-input">
<p><span id="btn_comment" class="comment-input__btn comment-input__btn--submit" data-toggle="modal" data-target="#directSignupModal">商品について質問する</span></p>
</div>
</div>
<div class="comment-area">
<div class="comment-area-inner">
<div id="comment-list">
</div>
<p class="commentmore-box">
<a href="#comment-list">すべてのコメントを見る<i class="icon_tl_cursor_down"></i></a>
</p>
</div>
</div>
</div>
</div>
</section><div class="item-status ft-new-position">
<h4 class="mtp__0">商品情報</h4>
<table class="item__details">
<tbody><tr>
<th><i class="icon-status icon_top_category_etc item-status-category"></i> カテゴリ</th>
<td>
<a href="https://fril.jp/category/10007">エンタメ/ホビー</a><br>
<a href="https://fril.jp/category/786">ゲームソフト/ゲーム機本体</a><br>
<a href="https://fril.jp/category/788">家庭用ゲームソフト</a>
</td>
</tr>
<tr>
<th><i class="icon-status icon_item_brand item-status-brand"></i> ブランド</th>
<td>
<a href="https://fril.jp/brand/5296">Nintendo Switch</a>
</td>
</tr>
</tbody></table>
<!--PC C--><!-- /7727/Rakuma/Item/MiddleRect1 -->
<div class="googleAdsense-box hidden-xs hidden-sm" data-test-g-ad-md-react1-pc="">
<div id="div-gpt-ad-1566432529257-0">
<script>
googletag.cmd.push(function() { googletag.display('div-gpt-ad-1566432529257-0'); });
</script>
</div>
</div>
<h4>出品者情報</h4>
<!-- ショップ情報 -->
<div class="row header-shopinfo shopinfo-area">
<div class="icon_and_shop_info_and_verifed-badge">
<a class="shopinfo-wrapverified-badge-item shop_link clearfix" href="https://fril.jp/shop/422750cb7921557bc8dba2416915d968" onclick="ga('send', 'event', 'ui_action', 'search', 'click_item_shop'); window.dataLayer.push({&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_item_shop&quot;},&quot;commerce_data&quot;:null}});" data-ratid="click_item_shop" data-ratevent="click" data-ratparam="all" data-rat-cp-content_group="商品詳細" data-rat-cp-pagetitle="Nintendo Switch - スプラトゥーン レイダース スイッチ switch 2 ソフト 新品 未開封の通販 by (^.^) | ニンテンドースイッチならラクマ" data-rat-cp-title="商品詳細画面(通常・ディスプレイ)" data-rat-cp-screen_url="https://fril.jp/item/61b174d42b58216828a24b86b7903ced" data-rat-cp-authenticity_item_status="not_covered" data-rat-cp-brand_id="5296" data-rat-cp-item_condition="新品、未使用" data-rat-cp-shipping_cost_payer="出品者" data-rat-cp-shipping_date_estimate="1~2日" data-rat-cp-shipping_from="千葉県" data-rat-cp-item_type="1" data-rat-cp-return_amount="0" data-rat-cp-return_rate="0" data-rat-cp-return_code_list="N/A"></a>
<div class="col-lg-2 col-md-2 col-sm-1 col-xs-2 header-shopinfo__user-icon user-icon-fit">
<img class="user img-circle img-responsive lazy" alt="" data-original="https://img.fril.jp/user/12073120/s/12073120.jpg?1609027502" src="https://asset.fril.jp/assets/new_web/icon_user-9e49e957def667b1abdaae57b9e81d66c31b27f8559d80e2eb682cfbb24e18cd.png">
<noscript><img alt="" src="https://img.fril.jp/user/12073120/s/12073120.jpg?1609027502"></noscript>
</div>
<div class="col-lg-9 col-md-9 col-sm-10 col-xs-9 header-shopinfo__shop_name_and_user_name">
<p class="header-shopinfo__shop-name clearfix"><span>(^.^)</span></p>
<p class="header-shopinfo__user-name">(^.^)</p>
<div class="header-shopinfo__verified-badge-item">
<img class="header-shopinfo--iverified-icon" data-original="https://asset.fril.jp/assets/new_web/icon-verified-badge-178e2665b2afa925c984e980a73993ad82ab44ac8575378962c1c600cf30b6bc.png" src="https://asset.fril.jp/assets/new_web/icon-verified-badge-178e2665b2afa925c984e980a73993ad82ab44ac8575378962c1c600cf30b6bc.png">
本人確認済
<a href="https://faq.fril.jp/17420/" target="_blank" rel="noopener noreferrer" data-test="verified_badge_shop" onclick="gtmClickBadgeStatus('本人確認済')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.11101 2.17971C5.26216 1.41054 6.61553 1 8 1C9.85652 1 11.637 1.7375 12.9497 3.05025C14.2625 4.36301 15 6.14348 15 8C15 9.38447 14.5895 10.7378 13.8203 11.889C13.0511 13.0401 11.9579 13.9373 10.6788 14.4672C9.3997 14.997 7.99224 15.1356 6.63437 14.8655C5.2765 14.5954 4.02922 13.9287 3.05026 12.9497C2.07129 11.9708 1.4046 10.7235 1.13451 9.36563C0.86441 8.00776 1.00303 6.6003 1.53285 5.32122C2.06266 4.04213 2.95987 2.94888 4.11101 2.17971ZM4.66658 12.9888C5.65328 13.6481 6.81332 14 8 14C9.5913 14 11.1174 13.3679 12.2426 12.2426C13.3679 11.1174 14 9.5913 14 8C14 6.81331 13.6481 5.65327 12.9888 4.66658C12.3295 3.67988 11.3925 2.91085 10.2961 2.45672C9.19975 2.0026 7.99335 1.88378 6.82946 2.11529C5.66558 2.3468 4.59648 2.91824 3.75736 3.75736C2.91825 4.59647 2.3468 5.66557 2.11529 6.82946C1.88378 7.99334 2.0026 9.19974 2.45673 10.2961C2.91085 11.3925 3.67989 12.3295 4.66658 12.9888ZM7.17999 8.77002V9.11502L8.80999 9.13002V8.84002C8.80878 8.66485 8.85361 8.49242 8.93999 8.34002C9.09209 8.12732 9.2819 7.9443 9.49999 7.80002C9.81046 7.58848 10.0784 7.3205 10.29 7.01002C10.4803 6.712 10.5762 6.36345 10.565 6.01002C10.5729 5.63781 10.4684 5.27186 10.265 4.96002C10.0494 4.65554 9.75817 4.41254 9.41999 4.25502C9.0291 4.07854 8.60378 3.99142 8.17499 4.00002C7.67506 3.98766 7.17897 4.0903 6.72499 4.30002C6.33811 4.49475 5.98926 4.75723 5.69499 5.07502L6.45499 6.30002C6.63675 6.05331 6.86009 5.84012 7.11499 5.67002C7.37663 5.49526 7.68539 5.40455 7.99999 5.41002C8.23263 5.38767 8.4662 5.44211 8.66499 5.56502C8.74383 5.62176 8.80744 5.69708 8.85018 5.7843C8.89292 5.87153 8.91346 5.96795 8.90999 6.06502C8.91542 6.24353 8.86279 6.41898 8.75999 6.56502C8.59368 6.77098 8.39631 6.94979 8.17499 7.09502C7.85377 7.29967 7.58356 7.57501 7.38499 7.90002C7.24695 8.16905 7.17659 8.46767 7.17999 8.77002ZM7.58634 11.9257C7.7178 11.9781 7.8585 12.0034 7.99999 12C8.14303 12.0039 8.28538 11.9788 8.41854 11.9264C8.55169 11.874 8.67292 11.7953 8.77499 11.695C8.87865 11.5989 8.96077 11.482 9.01593 11.3518C9.07109 11.2217 9.09804 11.0813 9.09499 10.94C9.09875 10.7979 9.07215 10.6566 9.01697 10.5255C8.96179 10.3944 8.87929 10.2767 8.77499 10.18C8.56721 9.97978 8.28851 9.87009 7.99999 9.87502C7.85868 9.87308 7.71839 9.89902 7.58712 9.95136C7.45585 10.0037 7.33619 10.0814 7.23499 10.18C7.13068 10.2767 7.04818 10.3944 6.993 10.5255C6.93782 10.6566 6.91122 10.7979 6.91499 10.94C6.91194 11.0813 6.93888 11.2217 6.99404 11.3518C7.0492 11.482 7.13132 11.5989 7.23499 11.695C7.33536 11.7948 7.45487 11.8732 7.58634 11.9257Z"></path>
</svg>
</a>
</div>
</div>
<div class="col-lg-1 col-md-1 col-sm-1 col-xs-1 header-shopinfo__chevron">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.207 1.5L4.5 2.207L10.293 8L4.5 13.793L5.207 14.5L11.707 8L5.207 1.5Z" fill="#757575"></path>
</svg>
</div>
</div>
<div class="header-shopinfo__review_counts user-review-wrap">
<a href="https://fril.jp/shop/422750cb7921557bc8dba2416915d968/review" onclick="ga('send', 'event', 'ui_action', 'profile', 'click_shop_review');" data-gtm-click="{&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_item_shop_review&quot;,&quot;screen_title&quot;:&quot;商品詳細画面(通常・ディスプレイ)&quot;,&quot;screen_name&quot;:&quot;item_detail&quot;},&quot;commerce_data&quot;:null}}">
<span>取引の評価</span>
<div class="review-list">
<ul class="list-inline">
<li>
<i class="icon-sun icon_review_sun"></i><span>1,883</span>
</li>
<li>
<i class="icon-cloud icon_review_cloud"></i><span>3</span>
</li>
<li>
<i class="icon-rain icon_review_rain"></i><span>0</span>
</li>
</ul>
<div class="review-chevron">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.207 1.5L4.5 2.207L10.293 8L4.5 13.793L5.207 14.5L11.707 8L5.207 1.5Z" fill="#757575"></path>
</svg>
</div>
</div>
</a>
</div>
<div class="user-bio-wrap seller__detail--link shop__profile">
<div class="shop__profile__line-limited">
★評価に悪いがたくさんある方、大幅な値下げ交渉をされる方、言葉遣い等、不快に感じた場合ブロックします。<br><br>★取り置き不可。コメント中でも即決してくださる方優先です。<br><br>★ 返品できませんので、ご納得頂けるまで、お気軽にコメントでご質問ください☺︎︎<br>素人の自宅保管品になりますので、完璧な状態でお探しの方、少しの見逃しも許せない方はお店で新品をご購入ください。<br><br>★小さな子供がいる為、お急ぎの方、急かされる方はご購入をお控えくださいm(_ _)m<br>夜は子供と早く寝てしまうので、朝お返事する事が多いです。<br><br>
</div>
</div>
</div>
</div></body></html>
+94
View File
@@ -0,0 +1,94 @@
<!doctype html><html><head><meta charset='utf-8'></head><body><div class="page-count">約1,190,000件中 1 - 40件</div><a data-rat-cp-totalresults="1196774"></a><div class="item"><div class="item-box">
<div class="item-box__image-wrapper">
<a href="https://item.fril.jp/61b174d42b58216828a24b86b7903ced" class="link_search_image" title="スプラトゥーン レイダース スイッチ switch 2 ソフト 新品 未開封 ニンテンドースイッチ(Nintendo Switch)のエンタメ/ホビーのゲームソフト/ゲーム機本体(家庭用ゲームソフト)の商品詳細ページへのリンク" onclick="ga('send', 'event', 'ui_action', 'search', 'click_search_item', {'dimension1': '844649627' }); dataLayer.push({&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_search_item&quot;,&quot;item_id&quot;:&quot;844649627&quot;,&quot;item_name&quot;:&quot;スプラトゥーン レイダース スイッチ switch 2 ソフト 新品 未開封&quot;,&quot;seller_user_id&quot;:&quot;12073120&quot;,&quot;category_id&quot;:&quot;788&quot;,&quot;first_category&quot;:null,&quot;second_category&quot;:null,&quot;third_category&quot;:null,&quot;brand_id&quot;:&quot;5296&quot;,&quot;brand_name&quot;:&quot;Nintendo Switch&quot;,&quot;price&quot;:6299,&quot;seller_user_type&quot;:&quot;一般&quot;},&quot;commerce_data&quot;:null}});" data-ratid="click_search_item" data-ratevent="click" data-ratparam="all" data-rat-itemid="12073120/844649627" data-rat-item_name="スプラトゥーン レイダース スイッチ switch 2 ソフト 新品 未開封" data-rat-igenre="788" data-rat-brand="Nintendo Switch" data-rat-price="6299" data-rat-sgenre="一般" data-rat-cp-content_group="商品検索>検索結果" data-rat-cp-title="検索結果画面" data-rat-cp-pagetitle="switchのフリマアイテム一覧" data-rat-cp-screen_url="https://fril.jp/s?query=switch" data-rat-cp-search_keyword="switch" data-rat-cp-search_return_code_list="指定なし" data-rat-cp-search_order="おすすめ順" data-rat-cp-gsp_var="" data-rat-cp-gsp_reqid="" data-rat-cp-totalresults="1196774" data-rat-cp-hits="40" data-rat-cp-sort="1" data-rat-cp-rpgn="1" data-rat-cp-fa="2" data-rat-cp-search_keyword="switch" data-rat-cp-search_exclude_keyword="" data-rat-cp-brand_id="5296" data-rat-cp-location="1" data-rat-cp-price="6299" data-rat-cp-event_gp1="organic" data-rat-cp-return_amount="0" data-rat-cp-return_rate="0" data-rat-cp-return_code_list="N/A">
<img src="https://asset.fril.jp/assets/new_web/item_square_dummy-e093d031b2a9738d2115341c1aa868b76ba6b0aa513492bd0068e673e7a6d3f6.png" data-original="https://img.fril.jp/img/844649627/m/2913695594.jpg?1784942799" class="img-responsive lazy" alt="ニンテンドースイッチ(Nintendo Switch)のスプラトゥーン レイダース スイッチ switch 2 ソフト 新品 未開封(家庭用ゲームソフト)">
<noscript><img src="https://img.fril.jp/img/844649627/m/2913695594.jpg?1784942799" alt="ニンテンドースイッチ(Nintendo Switch)のスプラトゥーン レイダース スイッチ switch 2 ソフト 新品 未開封(家庭用ゲームソフト)"></noscript>
<div class="item-box__soldout_ribbon">SOLD OUT</div>
<div class="item-box__icon-section">
</div>
</a>
</div>
<div class="item-box__text-wrapper">
<div class="item-box__item-sub-name">
<a href="/brand/5296" class="brand-name" title="Nintendo Switch(ニンテンドースイッチ)の商品一覧ページへのリンク">Nintendo Switch</a>
</div>
<p class="item-box__item-name">
<a href="https://item.fril.jp/61b174d42b58216828a24b86b7903ced" class="link_search_title" title="スプラトゥーン レイダース スイッチ switch 2 ソフト 新品 未開封 ニンテンドースイッチ(Nintendo Switch)のエンタメ/ホビーのゲームソフト/ゲーム機本体(家庭用ゲームソフト)の商品詳細ページへのリンク" onclick="ga('send', 'event', 'ui_action', 'search', 'click_search_item', {'dimension1': '844649627' }); dataLayer.push({&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_search_item&quot;,&quot;item_id&quot;:&quot;844649627&quot;,&quot;item_name&quot;:&quot;スプラトゥーン レイダース スイッチ switch 2 ソフト 新品 未開封&quot;,&quot;seller_user_id&quot;:&quot;12073120&quot;,&quot;category_id&quot;:&quot;788&quot;,&quot;first_category&quot;:null,&quot;second_category&quot;:null,&quot;third_category&quot;:null,&quot;brand_id&quot;:&quot;5296&quot;,&quot;brand_name&quot;:&quot;Nintendo Switch&quot;,&quot;price&quot;:6299,&quot;seller_user_type&quot;:&quot;一般&quot;},&quot;commerce_data&quot;:null}});" data-ratid="click_search_item" data-ratevent="click" data-ratparam="all" data-rat-itemid="12073120/844649627" data-rat-item_name="スプラトゥーン レイダース スイッチ switch 2 ソフト 新品 未開封" data-rat-igenre="788" data-rat-brand="Nintendo Switch" data-rat-price="6299" data-rat-sgenre="一般" data-rat-cp-content_group="商品検索>検索結果" data-rat-cp-title="検索結果画面" data-rat-cp-pagetitle="switchのフリマアイテム一覧" data-rat-cp-screen_url="https://fril.jp/s?query=switch" data-rat-cp-search_keyword="switch" data-rat-cp-search_return_code_list="指定なし" data-rat-cp-search_order="おすすめ順" data-rat-cp-gsp_var="" data-rat-cp-gsp_reqid="" data-rat-cp-totalresults="1196774" data-rat-cp-hits="40" data-rat-cp-sort="1" data-rat-cp-rpgn="1" data-rat-cp-fa="2" data-rat-cp-search_keyword="switch" data-rat-cp-search_exclude_keyword="" data-rat-cp-brand_id="5296" data-rat-cp-location="1" data-rat-cp-price="6299" data-rat-cp-event_gp1="organic" data-rat-cp-return_amount="0" data-rat-cp-return_rate="0" data-rat-cp-return_code_list="N/A">
<span>スプラトゥーン レイダース スイッチ switch 2 ソフト 新品 未開封</span>
</a>
</p>
<div class="price-status__wrap">
<div class="price-status__price">
<!-- 二重価格表示時用 class price-status__price--down が付与される事で赤文字表記になります。出し分けの設定をお願いします。 -->
<p class="item-box__item-price"><span data-content="JPY">¥</span><span data-content="6299">6,299</span></p>
</div>
<div>
<div class="like_item_off">
<a class="small" href="#" data-toggle="modal" data-target="#directSignupModal"><i class="ga-class ga-items-likebutton list-icon-like icon_tl_heart"></i></a>
</div>
</div>
</div>
</div>
</div></div><div class="item"><div class="item-box">
<div class="item-box__image-wrapper">
<a href="https://item.fril.jp/f00d05890d49e1e5c9abc4fae20a97a4" class="link_search_image" title="HYKE:Northern Light(s) 輸入版 switch ニンテンドースイッチ ニンテンドースイッチ(Nintendo Switch)のエンタメ/ホビーのゲームソフト/ゲーム機本体(家庭用ゲームソフト)の商品詳細ページへのリンク" onclick="ga('send', 'event', 'ui_action', 'search', 'click_search_item', {'dimension1': '844908440' }); dataLayer.push({&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_search_item&quot;,&quot;item_id&quot;:&quot;844908440&quot;,&quot;item_name&quot;:&quot;HYKE:Northern Light(s) 輸入版 switch ニンテンドースイッチ&quot;,&quot;seller_user_id&quot;:&quot;14691076&quot;,&quot;category_id&quot;:&quot;788&quot;,&quot;first_category&quot;:null,&quot;second_category&quot;:null,&quot;third_category&quot;:null,&quot;brand_id&quot;:&quot;5296&quot;,&quot;brand_name&quot;:&quot;Nintendo Switch&quot;,&quot;price&quot;:7280,&quot;seller_user_type&quot;:&quot;一般&quot;},&quot;commerce_data&quot;:null}});" data-ratid="click_search_item" data-ratevent="click" data-ratparam="all" data-rat-itemid="14691076/844908440" data-rat-item_name="HYKE:Northern Light(s) 輸入版 switch ニンテンドースイッチ" data-rat-igenre="788" data-rat-brand="Nintendo Switch" data-rat-price="7280" data-rat-sgenre="一般" data-rat-cp-content_group="商品検索>検索結果" data-rat-cp-title="検索結果画面" data-rat-cp-pagetitle="switchのフリマアイテム一覧" data-rat-cp-screen_url="https://fril.jp/s?query=switch" data-rat-cp-search_keyword="switch" data-rat-cp-search_return_code_list="指定なし" data-rat-cp-search_order="おすすめ順" data-rat-cp-gsp_var="" data-rat-cp-gsp_reqid="" data-rat-cp-totalresults="1196774" data-rat-cp-hits="40" data-rat-cp-sort="1" data-rat-cp-rpgn="1" data-rat-cp-fa="2" data-rat-cp-search_keyword="switch" data-rat-cp-search_exclude_keyword="" data-rat-cp-brand_id="5296" data-rat-cp-location="2" data-rat-cp-price="7280" data-rat-cp-event_gp1="organic" data-rat-cp-return_amount="0" data-rat-cp-return_rate="0" data-rat-cp-return_code_list="N/A">
<img src="https://asset.fril.jp/assets/new_web/item_square_dummy-e093d031b2a9738d2115341c1aa868b76ba6b0aa513492bd0068e673e7a6d3f6.png" data-original="https://img.fril.jp/img/844908440/m/2914810873.jpg?1785103049" class="img-responsive lazy" alt="ニンテンドースイッチ(Nintendo Switch)のHYKE:Northern Light(s) 輸入版 switch ニンテンドースイッチ(家庭用ゲームソフト)">
<noscript><img src="https://img.fril.jp/img/844908440/m/2914810873.jpg?1785103049" alt="ニンテンドースイッチ(Nintendo Switch)のHYKE:Northern Light(s) 輸入版 switch ニンテンドースイッチ(家庭用ゲームソフト)"></noscript>
<div class="item-box__icon-section">
</div>
</a>
</div>
<div class="item-box__text-wrapper">
<div class="item-box__item-sub-name">
<a href="/brand/5296" class="brand-name" title="Nintendo Switch(ニンテンドースイッチ)の商品一覧ページへのリンク">Nintendo Switch</a>
</div>
<p class="item-box__item-name">
<a href="https://item.fril.jp/f00d05890d49e1e5c9abc4fae20a97a4" class="link_search_title" title="HYKE:Northern Light(s) 輸入版 switch ニンテンドースイッチ ニンテンドースイッチ(Nintendo Switch)のエンタメ/ホビーのゲームソフト/ゲーム機本体(家庭用ゲームソフト)の商品詳細ページへのリンク" onclick="ga('send', 'event', 'ui_action', 'search', 'click_search_item', {'dimension1': '844908440' }); dataLayer.push({&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_search_item&quot;,&quot;item_id&quot;:&quot;844908440&quot;,&quot;item_name&quot;:&quot;HYKE:Northern Light(s) 輸入版 switch ニンテンドースイッチ&quot;,&quot;seller_user_id&quot;:&quot;14691076&quot;,&quot;category_id&quot;:&quot;788&quot;,&quot;first_category&quot;:null,&quot;second_category&quot;:null,&quot;third_category&quot;:null,&quot;brand_id&quot;:&quot;5296&quot;,&quot;brand_name&quot;:&quot;Nintendo Switch&quot;,&quot;price&quot;:7280,&quot;seller_user_type&quot;:&quot;一般&quot;},&quot;commerce_data&quot;:null}});" data-ratid="click_search_item" data-ratevent="click" data-ratparam="all" data-rat-itemid="14691076/844908440" data-rat-item_name="HYKE:Northern Light(s) 輸入版 switch ニンテンドースイッチ" data-rat-igenre="788" data-rat-brand="Nintendo Switch" data-rat-price="7280" data-rat-sgenre="一般" data-rat-cp-content_group="商品検索>検索結果" data-rat-cp-title="検索結果画面" data-rat-cp-pagetitle="switchのフリマアイテム一覧" data-rat-cp-screen_url="https://fril.jp/s?query=switch" data-rat-cp-search_keyword="switch" data-rat-cp-search_return_code_list="指定なし" data-rat-cp-search_order="おすすめ順" data-rat-cp-gsp_var="" data-rat-cp-gsp_reqid="" data-rat-cp-totalresults="1196774" data-rat-cp-hits="40" data-rat-cp-sort="1" data-rat-cp-rpgn="1" data-rat-cp-fa="2" data-rat-cp-search_keyword="switch" data-rat-cp-search_exclude_keyword="" data-rat-cp-brand_id="5296" data-rat-cp-location="2" data-rat-cp-price="7280" data-rat-cp-event_gp1="organic" data-rat-cp-return_amount="0" data-rat-cp-return_rate="0" data-rat-cp-return_code_list="N/A">
<span>HYKE:Northern Light(s) 輸入版 switch ニンテンドースイッチ</span>
</a>
</p>
<div class="price-status__wrap">
<div class="price-status__price">
<!-- 二重価格表示時用 class price-status__price--down が付与される事で赤文字表記になります。出し分けの設定をお願いします。 -->
<p class="item-box__item-price"><span data-content="JPY">¥</span><span data-content="7280">7,280</span></p>
</div>
<div>
<div class="like_item_off">
<a class="small" href="#" data-toggle="modal" data-target="#directSignupModal"><i class="ga-class ga-items-likebutton list-icon-like icon_tl_heart"></i></a>
</div>
</div>
</div>
</div>
</div></div><div class="item"><div class="item-box">
<div class="item-box__image-wrapper">
<a href="https://item.fril.jp/15f7297584e2c61a39981994837412f6" class="link_search_image" title="Nintendo Switch ニンテンドースイッチ Joy-Con ネオンブルー ネオンレッド ニンテンドースイッチ(Nintendo Switch)のエンタメ/ホビーのゲームソフト/ゲーム機本体(家庭用ゲーム機本体)の商品詳細ページへのリンク" onclick="ga('send', 'event', 'ui_action', 'search', 'click_search_item', {'dimension1': '844891307' }); dataLayer.push({&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_search_item&quot;,&quot;item_id&quot;:&quot;844891307&quot;,&quot;item_name&quot;:&quot;Nintendo Switch ニンテンドースイッチ Joy-Con ネオンブルー ネオンレッド&quot;,&quot;seller_user_id&quot;:&quot;18993214&quot;,&quot;category_id&quot;:&quot;787&quot;,&quot;first_category&quot;:null,&quot;second_category&quot;:null,&quot;third_category&quot;:null,&quot;brand_id&quot;:&quot;5296&quot;,&quot;brand_name&quot;:&quot;Nintendo Switch&quot;,&quot;price&quot;:19000,&quot;seller_user_type&quot;:&quot;一般&quot;},&quot;commerce_data&quot;:null}});" data-ratid="click_search_item" data-ratevent="click" data-ratparam="all" data-rat-itemid="18993214/844891307" data-rat-item_name="Nintendo Switch ニンテンドースイッチ Joy-Con ネオンブルー ネオンレッド" data-rat-igenre="787" data-rat-brand="Nintendo Switch" data-rat-price="19000" data-rat-sgenre="一般" data-rat-cp-content_group="商品検索>検索結果" data-rat-cp-title="検索結果画面" data-rat-cp-pagetitle="switchのフリマアイテム一覧" data-rat-cp-screen_url="https://fril.jp/s?query=switch" data-rat-cp-search_keyword="switch" data-rat-cp-search_return_code_list="指定なし" data-rat-cp-search_order="おすすめ順" data-rat-cp-gsp_var="" data-rat-cp-gsp_reqid="" data-rat-cp-totalresults="1196774" data-rat-cp-hits="40" data-rat-cp-sort="1" data-rat-cp-rpgn="1" data-rat-cp-fa="2" data-rat-cp-search_keyword="switch" data-rat-cp-search_exclude_keyword="" data-rat-cp-brand_id="5296" data-rat-cp-location="3" data-rat-cp-price="19000" data-rat-cp-event_gp1="organic" data-rat-cp-return_amount="0" data-rat-cp-return_rate="0" data-rat-cp-return_code_list="N/A">
<img src="https://asset.fril.jp/assets/new_web/item_square_dummy-e093d031b2a9738d2115341c1aa868b76ba6b0aa513492bd0068e673e7a6d3f6.png" data-original="https://img.fril.jp/img/844891307/m/2914752086.jpg?1785081164" class="img-responsive lazy" alt="ニンテンドースイッチ(Nintendo Switch)のNintendo Switch ニンテンドースイッチ Joy-Con ネオンブルー ネオンレッド(家庭用ゲーム機本体)">
<noscript><img src="https://img.fril.jp/img/844891307/m/2914752086.jpg?1785081164" alt="ニンテンドースイッチ(Nintendo Switch)のNintendo Switch ニンテンドースイッチ Joy-Con ネオンブルー ネオンレッド(家庭用ゲーム機本体)"></noscript>
<div class="item-box__icon-section">
</div>
</a>
</div>
<div class="item-box__text-wrapper">
<div class="item-box__item-sub-name">
<a href="/brand/5296" class="brand-name" title="Nintendo Switch(ニンテンドースイッチ)の商品一覧ページへのリンク">Nintendo Switch</a>
</div>
<p class="item-box__item-name">
<a href="https://item.fril.jp/15f7297584e2c61a39981994837412f6" class="link_search_title" title="Nintendo Switch ニンテンドースイッチ Joy-Con ネオンブルー ネオンレッド ニンテンドースイッチ(Nintendo Switch)のエンタメ/ホビーのゲームソフト/ゲーム機本体(家庭用ゲーム機本体)の商品詳細ページへのリンク" onclick="ga('send', 'event', 'ui_action', 'search', 'click_search_item', {'dimension1': '844891307' }); dataLayer.push({&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_search_item&quot;,&quot;item_id&quot;:&quot;844891307&quot;,&quot;item_name&quot;:&quot;Nintendo Switch ニンテンドースイッチ Joy-Con ネオンブルー ネオンレッド&quot;,&quot;seller_user_id&quot;:&quot;18993214&quot;,&quot;category_id&quot;:&quot;787&quot;,&quot;first_category&quot;:null,&quot;second_category&quot;:null,&quot;third_category&quot;:null,&quot;brand_id&quot;:&quot;5296&quot;,&quot;brand_name&quot;:&quot;Nintendo Switch&quot;,&quot;price&quot;:19000,&quot;seller_user_type&quot;:&quot;一般&quot;},&quot;commerce_data&quot;:null}});" data-ratid="click_search_item" data-ratevent="click" data-ratparam="all" data-rat-itemid="18993214/844891307" data-rat-item_name="Nintendo Switch ニンテンドースイッチ Joy-Con ネオンブルー ネオンレッド" data-rat-igenre="787" data-rat-brand="Nintendo Switch" data-rat-price="19000" data-rat-sgenre="一般" data-rat-cp-content_group="商品検索>検索結果" data-rat-cp-title="検索結果画面" data-rat-cp-pagetitle="switchのフリマアイテム一覧" data-rat-cp-screen_url="https://fril.jp/s?query=switch" data-rat-cp-search_keyword="switch" data-rat-cp-search_return_code_list="指定なし" data-rat-cp-search_order="おすすめ順" data-rat-cp-gsp_var="" data-rat-cp-gsp_reqid="" data-rat-cp-totalresults="1196774" data-rat-cp-hits="40" data-rat-cp-sort="1" data-rat-cp-rpgn="1" data-rat-cp-fa="2" data-rat-cp-search_keyword="switch" data-rat-cp-search_exclude_keyword="" data-rat-cp-brand_id="5296" data-rat-cp-location="3" data-rat-cp-price="19000" data-rat-cp-event_gp1="organic" data-rat-cp-return_amount="0" data-rat-cp-return_rate="0" data-rat-cp-return_code_list="N/A">
<span>Nintendo Switch ニンテンドースイッチ Joy-Con ネオンブルー ネオンレッド</span>
</a>
</p>
<div class="price-status__wrap">
<div class="price-status__price">
<!-- 二重価格表示時用 class price-status__price--down が付与される事で赤文字表記になります。出し分けの設定をお願いします。 -->
<p class="item-box__item-price"><span data-content="JPY">¥</span><span data-content="19000">19,000</span></p>
</div>
<div>
<div class="like_item_off">
<a class="small" href="#" data-toggle="modal" data-target="#directSignupModal"><i class="ga-class ga-items-likebutton list-icon-like icon_tl_heart"></i></a>
</div>
</div>
</div>
</div>
</div></div></body></html>
+120
View File
@@ -0,0 +1,120 @@
<!doctype html><html><head><meta charset='utf-8'><script type="application/ld+json">
{
"@context": "http://schema.org/",
"@type": "Store",
"name": "(^.^)",
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "5.0",
"ratingCount": "1886",
"worstRating": "0",
"bestRating": "5"
}
}
</script></head><body><div class="profile-area__shop-cover" style="background: url(https://asset.fril.jp/assets/new_web/bg_default_shop_cover-dcc4c6a370f958789c2c8215a44e7433a68e101b96b725a887221e5c8526fa38.png) center center; background-size: 100% auto;">
<div class="profile-area__text-cover">
<div class="col-lg-push-2 col-md-push-2 col-sm-push-3 col-xs-push-3 col-lg-10 col-md-10 col-sm-8 col-xs-7">
<h1 class="profile-area__shop-name">(^.^)</h1>
</div>
</div>
</div><div class="profile-area__name-space">
<div class="col-lg-2 col-md-2 col-xs-3 profile-area__user-icon user-icon-fit">
<img src="https://img.fril.jp/user/12073120/s/12073120.jpg?1609027502" class="user img-circle img-responsive">
</div>
<div class="col-lg-10 col-md-10 col-xs-9">
<p class="profile-area__user verified-badge-shop"><span class="profile-area__user-name" data-test="profile_user_name">(^.^)</span></p>
<div class="header-shopinfo__verified-badge-shop badge-status">
<img class="header-shopinfo--iverified-icon" data-original="https://asset.fril.jp/assets/new_web/icon-verified-badge-178e2665b2afa925c984e980a73993ad82ab44ac8575378962c1c600cf30b6bc.png" src="https://asset.fril.jp/assets/new_web/icon-verified-badge-178e2665b2afa925c984e980a73993ad82ab44ac8575378962c1c600cf30b6bc.png">
本人確認済
<a href="https://faq.fril.jp/17420/" target="_blank" rel="noopener noreferrer" data-test="verified_badge_shop" onclick="gtmClickBadgeStatus('本人確認済')">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.11101 2.17971C5.26216 1.41054 6.61553 1 8 1C9.85652 1 11.637 1.7375 12.9497 3.05025C14.2625 4.36301 15 6.14348 15 8C15 9.38447 14.5895 10.7378 13.8203 11.889C13.0511 13.0401 11.9579 13.9373 10.6788 14.4672C9.3997 14.997 7.99224 15.1356 6.63437 14.8655C5.2765 14.5954 4.02922 13.9287 3.05026 12.9497C2.07129 11.9708 1.4046 10.7235 1.13451 9.36563C0.86441 8.00776 1.00303 6.6003 1.53285 5.32122C2.06266 4.04213 2.95987 2.94888 4.11101 2.17971ZM4.66658 12.9888C5.65328 13.6481 6.81332 14 8 14C9.5913 14 11.1174 13.3679 12.2426 12.2426C13.3679 11.1174 14 9.5913 14 8C14 6.81331 13.6481 5.65327 12.9888 4.66658C12.3295 3.67988 11.3925 2.91085 10.2961 2.45672C9.19975 2.0026 7.99335 1.88378 6.82946 2.11529C5.66558 2.3468 4.59648 2.91824 3.75736 3.75736C2.91825 4.59647 2.3468 5.66557 2.11529 6.82946C1.88378 7.99334 2.0026 9.19974 2.45673 10.2961C2.91085 11.3925 3.67989 12.3295 4.66658 12.9888ZM7.17999 8.77002V9.11502L8.80999 9.13002V8.84002C8.80878 8.66485 8.85361 8.49242 8.93999 8.34002C9.09209 8.12732 9.2819 7.9443 9.49999 7.80002C9.81046 7.58848 10.0784 7.3205 10.29 7.01002C10.4803 6.712 10.5762 6.36345 10.565 6.01002C10.5729 5.63781 10.4684 5.27186 10.265 4.96002C10.0494 4.65554 9.75817 4.41254 9.41999 4.25502C9.0291 4.07854 8.60378 3.99142 8.17499 4.00002C7.67506 3.98766 7.17897 4.0903 6.72499 4.30002C6.33811 4.49475 5.98926 4.75723 5.69499 5.07502L6.45499 6.30002C6.63675 6.05331 6.86009 5.84012 7.11499 5.67002C7.37663 5.49526 7.68539 5.40455 7.99999 5.41002C8.23263 5.38767 8.4662 5.44211 8.66499 5.56502C8.74383 5.62176 8.80744 5.69708 8.85018 5.7843C8.89292 5.87153 8.91346 5.96795 8.90999 6.06502C8.91542 6.24353 8.86279 6.41898 8.75999 6.56502C8.59368 6.77098 8.39631 6.94979 8.17499 7.09502C7.85377 7.29967 7.58356 7.57501 7.38499 7.90002C7.24695 8.16905 7.17659 8.46767 7.17999 8.77002ZM7.58634 11.9257C7.7178 11.9781 7.8585 12.0034 7.99999 12C8.14303 12.0039 8.28538 11.9788 8.41854 11.9264C8.55169 11.874 8.67292 11.7953 8.77499 11.695C8.87865 11.5989 8.96077 11.482 9.01593 11.3518C9.07109 11.2217 9.09804 11.0813 9.09499 10.94C9.09875 10.7979 9.07215 10.6566 9.01697 10.5255C8.96179 10.3944 8.87929 10.2767 8.77499 10.18C8.56721 9.97978 8.28851 9.87009 7.99999 9.87502C7.85868 9.87308 7.71839 9.89902 7.58712 9.95136C7.45585 10.0037 7.33619 10.0814 7.23499 10.18C7.13068 10.2767 7.04818 10.3944 6.993 10.5255C6.93782 10.6566 6.91122 10.7979 6.91499 10.94C6.91194 11.0813 6.93888 11.2217 6.99404 11.3518C7.0492 11.482 7.13132 11.5989 7.23499 11.695C7.33536 11.7948 7.45487 11.8732 7.58634 11.9257Z"></path>
</svg>
</a>
</div>
<div class="profile-area__share">
<div class="social_item">
<a id="social_twitter" class="twitter" onclick=" ga('send', 'event', 'ui_action', 'share', 'click_shop_share_twitter', {'dimension1': '12073120'});window.dataLayer.push({&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_shop_share_twitter&quot;,&quot;screen_title&quot;:&quot;ショップページ,レビューページ&quot;,&quot;screen_name&quot;:&quot;shop_profile&quot;},&quot;commerce_data&quot;:null}});" href="https://twitter.com/intent/tweet?url=https://fril.jp/shop/422750cb7921557bc8dba2416915d968&amp;text=(^.^)のショップをラクマでチェック!&amp;via=rakuma_official" target="_blank"><img src="https://asset.fril.jp/assets/new_web/icon_x-1506ba8bfddbba6fbf4e8545ca96ef45101a17d91be0851d6b7484c1a59dfd7b.png" class="img-circle img-responsive" alt="xでシェア"></a>
</div>
<div class="social_item">
<a id="social_facebook" class="facebook" href="https://www.facebook.com/share.php?u=https://fril.jp/shop/422750cb7921557bc8dba2416915d968" onclick="window.open(this.href, 'FBwindow', 'width=650, height=450, menubar=no, toolbar=no, scrollbars=yes'); ga('send', 'event', 'ui_action', 'share', 'click_shop_share_facebook', {'dimension1': '12073120'}); window.dataLayer.push({&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_shop_share_facebook&quot;,&quot;screen_title&quot;:&quot;ショップページ,レビューページ&quot;,&quot;screen_name&quot;:&quot;shop_profile&quot;},&quot;commerce_data&quot;:null}}); return false"><img src="https://asset.fril.jp/assets/new_web/icon_facebook-42fdc43bfb82c28356543ae044972aa56722d9ab1fb024063c40c66c8900e859.png" class="img-circle img-responsive" alt="Facebookでシェア"></a>
</div>
<div class="social_item">
<a id="social_line" class="line" onclick="ga('send', 'event', 'ui_action', 'share', 'click_shop_share_line', {'dimension1': '12073120'});window.dataLayer.push({&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_shop_share_line&quot;,&quot;screen_title&quot;:&quot;ショップページ,レビューページ&quot;,&quot;screen_name&quot;:&quot;shop_profile&quot;},&quot;commerce_data&quot;:null}});" href="https://line.me/R/msg/text/?%28%5E.%5E%29%E3%81%AE%E3%82%B7%E3%83%A7%E3%83%83%E3%83%97%20%7C%20https%3A%2F%2Ffril.jp%2Fshop%2F422750cb7921557bc8dba2416915d968" rel="noreferrer"><img src="https://asset.fril.jp/assets/new_web/icon_line-9298ce0f2493206b9be4af506443266b12cc56cbc66b9adecf33e4ed241ff87f.png" class="img-circle img-responsive" alt="LINEで送る"></a>
</div>
</div>
</div>
</div><div class="content-group__text" data-test="profile-text-top">
<p>★評価に悪いがたくさんある方、大幅な値下げ交渉をされる方、言葉遣い等、不快に感じた場合ブロックします。</p>
<p>★取り置き不可。コメント中でも即決してくださる方優先です。</p>
<p>★ 返品できませんので、ご納得頂けるまで、お気軽にコメントでご質問ください☺︎︎
<br>素人の自宅保管品になりますので、完璧な状態でお探しの方、少しの見逃しも許せない方はお店で新品をご購入ください。</p>
<p>★小さな子供がいる為、お急ぎの方、急かされる方はご購入をお控えくださいm(_ _)m
<br>夜は子供と早く寝てしまうので、朝お返事する事が多いです。</p>
</div><div class="page-count">21件中 1 - 21件</div><div class="item"><div class="item-box">
<div class="item-box__image-wrapper">
<a href="https://item.fril.jp/61546447a7f13c34a5d015f37a7f3319" class="link_shop_image" title="ヨッシー フカシギ 図鑑 スイッチ switch ソフト 新品 未使用 マリオ ニンテンドースイッチ(Nintendo Switch)のエンタメ/ホビーのゲームソフト/ゲーム機本体(家庭用ゲームソフト)の商品詳細ページへのリンク" onclick="ga('send', 'event', 'ui_action', 'profile', 'click_shop_item', {'dimension1': '844851001' });window.dataLayer.push({&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_shop_item&quot;,&quot;item_id&quot;:&quot;844851001&quot;,&quot;item_name&quot;:&quot;ヨッシー フカシギ 図鑑 スイッチ switch ソフト 新品 未使用 マリオ&quot;,&quot;seller_user_id&quot;:&quot;12073120&quot;,&quot;category_id&quot;:&quot;788&quot;,&quot;first_category&quot;:&quot;エンタメ/ホビー&quot;,&quot;second_category&quot;:&quot;ゲームソフト/ゲーム機本体&quot;,&quot;third_category&quot;:&quot;家庭用ゲームソフト&quot;,&quot;brand_id&quot;:&quot;5296&quot;,&quot;brand_name&quot;:&quot;Nintendo Switch&quot;,&quot;price&quot;:6399,&quot;seller_user_type&quot;:&quot;一般&quot;},&quot;commerce_data&quot;:null}});" data-ratid="click_shop_item" data-ratevent="click" data-ratparam="all" data-rat-itemid="12073120/844851001" data-rat-item_name="ヨッシー フカシギ 図鑑 スイッチ switch ソフト 新品 未使用 マリオ" data-rat-igenre="[788]" data-rat-igenrenamepath="エンタメ/ホビー/ゲームソフト/ゲーム機本体/家庭用ゲームソフト" data-rat-brand="[&quot;Nintendo Switch&quot;]" data-rat-price="[6399]" data-rat-sgenre="一般" data-rat-cp-brand_id="5296">
<img src="https://asset.fril.jp/assets/new_web/item_square_dummy-e093d031b2a9738d2115341c1aa868b76ba6b0aa513492bd0068e673e7a6d3f6.png" data-original="https://img.fril.jp/img/844851001/m/2914570137.jpg?1785056298" class="img-responsive lazy" data-test="img_responsive" alt="ニンテンドースイッチ(Nintendo Switch)のヨッシー フカシギ 図鑑 スイッチ switch ソフト 新品 未使用 マリオ(家庭用ゲームソフト)">
<noscript><img src="https://img.fril.jp/img/844851001/m/2914570137.jpg?1785056298" alt="ニンテンドースイッチ(Nintendo Switch)のヨッシー フカシギ 図鑑 スイッチ switch ソフト 新品 未使用 マリオ(家庭用ゲームソフト)"></noscript>
</a>
</div>
<div class="item-box__text-wrapper">
<div class="item-box__item-sub-name">
<a href="/brand/5296/category/788" class="brand-name" data-test="brand_name" title="Nintendo Switch(ニンテンドースイッチ)の家庭用ゲームソフトの商品一覧ページへのリンク">Nintendo Switch</a>
</div>
<p class="item-box__item-name">
<a href="https://item.fril.jp/61546447a7f13c34a5d015f37a7f3319" class="link_shop_title" title="ヨッシー フカシギ 図鑑 スイッチ switch ソフト 新品 未使用 マリオ ニンテンドースイッチ(Nintendo Switch)のエンタメ/ホビーのゲームソフト/ゲーム機本体(家庭用ゲームソフト)の商品詳細ページへのリンク" onclick="ga('send', 'event', 'ui_action', 'profile', 'click_shop_item', {'dimension1': '844851001' });window.dataLayer.push({&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_shop_item&quot;,&quot;item_id&quot;:&quot;844851001&quot;,&quot;item_name&quot;:&quot;ヨッシー フカシギ 図鑑 スイッチ switch ソフト 新品 未使用 マリオ&quot;,&quot;seller_user_id&quot;:&quot;12073120&quot;,&quot;category_id&quot;:&quot;788&quot;,&quot;first_category&quot;:&quot;エンタメ/ホビー&quot;,&quot;second_category&quot;:&quot;ゲームソフト/ゲーム機本体&quot;,&quot;third_category&quot;:&quot;家庭用ゲームソフト&quot;,&quot;brand_id&quot;:&quot;5296&quot;,&quot;brand_name&quot;:&quot;Nintendo Switch&quot;,&quot;price&quot;:6399,&quot;seller_user_type&quot;:&quot;一般&quot;},&quot;commerce_data&quot;:null}});" data-ratid="click_shop_item" data-ratevent="click" data-ratparam="all" data-rat-itemid="12073120/844851001" data-rat-item_name="ヨッシー フカシギ 図鑑 スイッチ switch ソフト 新品 未使用 マリオ" data-rat-igenre="[788]" data-rat-igenrenamepath="エンタメ/ホビー/ゲームソフト/ゲーム機本体/家庭用ゲームソフト" data-rat-brand="[&quot;Nintendo Switch&quot;]" data-rat-price="[6399]" data-rat-sgenre="一般" data-rat-cp-brand_id="5296">
<span data-test="item_name">ヨッシー フカシギ 図鑑 スイッチ switch ソフト 新品 未使用 マリオ</span>
</a>
</p>
<div class="price-status__wrap">
<div class="price-status__price">
<p class="item-box__item-price">
<span data-content="JPY">¥</span>
<span data-test="item_price" data-content="6399">6,399
</span>
</p>
</div>
<div>
<div class="like_item_off">
<a class="small" href="#" data-toggle="modal" data-target="#directSignupModal"><i class="ga-class ga-items-likebutton list-icon-like icon_tl_heart"></i></a>
</div>
</div>
</div>
</div>
</div></div><div class="item"><div class="item-box">
<div class="item-box__image-wrapper">
<a href="https://item.fril.jp/46271f7e8b2db8f31e653d515463e492" class="link_shop_image" title="リズム天国 ミラクルスターズ スイッチ 新品 未使用 未開封 ニンテンドースイッチ(Nintendo Switch)のエンタメ/ホビーのゲームソフト/ゲーム機本体(家庭用ゲームソフト)の商品詳細ページへのリンク" onclick="ga('send', 'event', 'ui_action', 'profile', 'click_shop_item', {'dimension1': '844825502' });window.dataLayer.push({&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_shop_item&quot;,&quot;item_id&quot;:&quot;844825502&quot;,&quot;item_name&quot;:&quot;リズム天国 ミラクルスターズ スイッチ 新品 未使用 未開封 &quot;,&quot;seller_user_id&quot;:&quot;12073120&quot;,&quot;category_id&quot;:&quot;788&quot;,&quot;first_category&quot;:&quot;エンタメ/ホビー&quot;,&quot;second_category&quot;:&quot;ゲームソフト/ゲーム機本体&quot;,&quot;third_category&quot;:&quot;家庭用ゲームソフト&quot;,&quot;brand_id&quot;:&quot;5296&quot;,&quot;brand_name&quot;:&quot;Nintendo Switch&quot;,&quot;price&quot;:5450,&quot;seller_user_type&quot;:&quot;一般&quot;},&quot;commerce_data&quot;:null}});" data-ratid="click_shop_item" data-ratevent="click" data-ratparam="all" data-rat-itemid="12073120/844825502" data-rat-item_name="リズム天国 ミラクルスターズ スイッチ 新品 未使用 未開封 " data-rat-igenre="[788]" data-rat-igenrenamepath="エンタメ/ホビー/ゲームソフト/ゲーム機本体/家庭用ゲームソフト" data-rat-brand="[&quot;Nintendo Switch&quot;]" data-rat-price="[5450]" data-rat-sgenre="一般" data-rat-cp-brand_id="5296">
<img src="https://asset.fril.jp/assets/new_web/item_square_dummy-e093d031b2a9738d2115341c1aa868b76ba6b0aa513492bd0068e673e7a6d3f6.png" data-original="https://img.fril.jp/img/844825502/m/2914435187.jpg?1785040941" class="img-responsive lazy" data-test="img_responsive" alt="ニンテンドースイッチ(Nintendo Switch)のリズム天国 ミラクルスターズ スイッチ 新品 未使用 未開封 (家庭用ゲームソフト)">
<noscript><img src="https://img.fril.jp/img/844825502/m/2914435187.jpg?1785040941" alt="ニンテンドースイッチ(Nintendo Switch)のリズム天国 ミラクルスターズ スイッチ 新品 未使用 未開封 (家庭用ゲームソフト)"></noscript>
<div class="item-box__soldout_ribbon">SOLD OUT</div>
</a>
</div>
<div class="item-box__text-wrapper">
<div class="item-box__item-sub-name">
<a href="/brand/5296/category/788" class="brand-name" data-test="brand_name" title="Nintendo Switch(ニンテンドースイッチ)の家庭用ゲームソフトの商品一覧ページへのリンク">Nintendo Switch</a>
</div>
<p class="item-box__item-name">
<a href="https://item.fril.jp/46271f7e8b2db8f31e653d515463e492" class="link_shop_title" title="リズム天国 ミラクルスターズ スイッチ 新品 未使用 未開封 ニンテンドースイッチ(Nintendo Switch)のエンタメ/ホビーのゲームソフト/ゲーム機本体(家庭用ゲームソフト)の商品詳細ページへのリンク" onclick="ga('send', 'event', 'ui_action', 'profile', 'click_shop_item', {'dimension1': '844825502' });window.dataLayer.push({&quot;event&quot;:&quot;fireEvent&quot;,&quot;eventData&quot;:{&quot;event_name&quot;:&quot;&quot;,&quot;event_parameter&quot;:{&quot;view&quot;:0,&quot;event_label&quot;:&quot;click_shop_item&quot;,&quot;item_id&quot;:&quot;844825502&quot;,&quot;item_name&quot;:&quot;リズム天国 ミラクルスターズ スイッチ 新品 未使用 未開封 &quot;,&quot;seller_user_id&quot;:&quot;12073120&quot;,&quot;category_id&quot;:&quot;788&quot;,&quot;first_category&quot;:&quot;エンタメ/ホビー&quot;,&quot;second_category&quot;:&quot;ゲームソフト/ゲーム機本体&quot;,&quot;third_category&quot;:&quot;家庭用ゲームソフト&quot;,&quot;brand_id&quot;:&quot;5296&quot;,&quot;brand_name&quot;:&quot;Nintendo Switch&quot;,&quot;price&quot;:5450,&quot;seller_user_type&quot;:&quot;一般&quot;},&quot;commerce_data&quot;:null}});" data-ratid="click_shop_item" data-ratevent="click" data-ratparam="all" data-rat-itemid="12073120/844825502" data-rat-item_name="リズム天国 ミラクルスターズ スイッチ 新品 未使用 未開封 " data-rat-igenre="[788]" data-rat-igenrenamepath="エンタメ/ホビー/ゲームソフト/ゲーム機本体/家庭用ゲームソフト" data-rat-brand="[&quot;Nintendo Switch&quot;]" data-rat-price="[5450]" data-rat-sgenre="一般" data-rat-cp-brand_id="5296">
<span data-test="item_name">リズム天国 ミラクルスターズ スイッチ 新品 未使用 未開封 </span>
</a>
</p>
<div class="price-status__wrap">
<div class="price-status__price">
<p class="item-box__item-price">
<span data-content="JPY">¥</span>
<span data-test="item_price" data-content="5450">5,450
</span>
</p>
</div>
<div>
<div class="like_item_off">
<a class="small" href="#" data-toggle="modal" data-target="#directSignupModal"><i class="ga-class ga-items-likebutton list-icon-like icon_tl_heart"></i></a>
</div>
</div>
</div>
</div>
</div></div></body></html>
+122
View File
@@ -0,0 +1,122 @@
<!doctype html><html><head><meta charset='utf-8'></head><body><ul class="nav nav-pills nav-justified">
<li role="presentation" class="active">
<a href="#all-all" aria-controls="good" role="tab" data-toggle="tab">
すべて
</a>
</li>
<li role="presentation">
<a href="#all-good" aria-controls="all-good" role="tab" data-toggle="tab">
<i class="icon-sun icon_review_sun"></i>
118
</a>
</li>
<li role="presentation">
<a href="#all-normal" aria-controls="all-normal" role="tab" data-toggle="tab">
<i class="icon-cloud icon_review_cloud"></i>
0
</a>
</li>
<li role="presentation">
<a href="#all-bad" aria-controls="all-bad" role="tab" data-toggle="tab">
<i class="icon-rain icon_review_rain"></i>
0
</a>
</li>
</ul><ul class="nav nav-pills nav-justified">
<li role="presentation" class="active">
<a href="#seller-all" aria-controls="good" role="tab" data-toggle="tab">
すべて
</a>
</li>
<li role="presentation">
<a href="#seller-good" aria-controls="all-good" role="tab" data-toggle="tab">
<i class="icon-sun icon_review_sun"></i>
110
</a>
</li>
<li role="presentation">
<a href="#seller-normal" aria-controls="all-normal" role="tab" data-toggle="tab">
<i class="icon-cloud icon_review_cloud"></i>
0
</a>
</li>
<li role="presentation">
<a href="#seller-bad" aria-controls="all-bad" role="tab" data-toggle="tab">
<i class="icon-rain icon_review_rain"></i>
0
</a>
</li>
</ul><ul class="nav nav-pills nav-justified">
<li role="presentation" class="active">
<a href="#buyer-all" aria-controls="good" role="tab" data-toggle="tab">
すべて
</a>
</li>
<li role="presentation">
<a href="#buyer-good" aria-controls="all-good" role="tab" data-toggle="tab">
<i class="icon-sun icon_review_sun"></i>
8
</a>
</li>
<li role="presentation">
<a href="#buyer-normal" aria-controls="all-normal" role="tab" data-toggle="tab">
<i class="icon-cloud icon_review_cloud"></i>
0
</a>
</li>
<li role="presentation">
<a href="#buyer-bad" aria-controls="all-bad" role="tab" data-toggle="tab">
<i class="icon-rain icon_review_rain"></i>
0
</a>
</li>
</ul><div class="tab-pane active" id="all-all"><article class="row review-item" style="display:">
<div class="col-lg-1 col-md-1 col-sm-1 col-xs-2">
<img class="user img-circle img-responsive" alt="" src="https://asset.fril.jp/assets/new_web/icon_user-9e49e957def667b1abdaae57b9e81d66c31b27f8559d80e2eb682cfbb24e18cd.png">
</div>
<div class="col-lg-11 col-md-11 col-sm-11 col-xs-10">
<p class="review-item-title"><i class="icon-sun icon_review_sun"></i>よい出品者です</p>
<div class="review-item-text"><p></p></div>
<div class="row">
<div class="review-item-name col-lg-6 col-md-6 col-sm-6 col-xs-6">
トシちゃん
</div>
<div class="col-lg-6 col-md-6 col-sm-6 col-xs-6">
<p class="review-item-date text-right">2026/05/04</p>
</div>
</div>
</div>
</article><article class="row review-item" style="display:">
<div class="col-lg-1 col-md-1 col-sm-1 col-xs-2">
<img class="user img-circle img-responsive" alt="" src="https://asset.fril.jp/assets/new_web/icon_user-9e49e957def667b1abdaae57b9e81d66c31b27f8559d80e2eb682cfbb24e18cd.png">
</div>
<div class="col-lg-11 col-md-11 col-sm-11 col-xs-10">
<p class="review-item-title"><i class="icon-sun icon_review_sun"></i>よい出品者です</p>
<div class="review-item-text"><p>無事商品受け取りました。
<br>ありがとうございました。</p></div>
<div class="row">
<div class="review-item-name col-lg-6 col-md-6 col-sm-6 col-xs-6">
はる
</div>
<div class="col-lg-6 col-md-6 col-sm-6 col-xs-6">
<p class="review-item-date text-right">2023/05/16</p>
</div>
</div>
</div>
</article><article class="row review-item" style="display:">
<div class="col-lg-1 col-md-1 col-sm-1 col-xs-2">
<img class="user img-circle img-responsive" alt="" src="https://img.fril.jp/user/4552835/s/4552835.jpg?1510868311">
</div>
<div class="col-lg-11 col-md-11 col-sm-11 col-xs-10">
<p class="review-item-title"><i class="icon-sun icon_review_sun"></i>よい出品者です</p>
<div class="review-item-text"><p>また、ご縁がありましたら宜しくお願いします!</p></div>
<div class="row">
<div class="review-item-name col-lg-6 col-md-6 col-sm-6 col-xs-6">
rasta.mama
</div>
<div class="col-lg-6 col-md-6 col-sm-6 col-xs-6">
<p class="review-item-date text-right">2023/04/10</p>
</div>
</div>
</div>
</article></div></body></html>
+1583
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<!doctype html><html><head><meta charset='utf-8'><script type="application/ld+json">{"@context":"https://schema.org/","@type":"AggregateRating","itemReviewed":{"@type":"Organization","logo":"https://thumbnail.image.rakuten.co.jp/@0_mall/edion/logo/logo1.jpg","name":"エディオン 楽天市場店"},"ratingValue":4.51,"ratingCount":128136}</script></head><body></body></html>
+28
View File
@@ -0,0 +1,28 @@
{
"shop": {
"shopCode": "edion",
"ageVerificationFlag": false,
"shopIntroduction": "エディオン楽天市場店は「家電と暮らしのエディオン」のECショップです",
"shopName": "エディオン 楽天市場店",
"shopId": 272415,
"shopUrl": "http://www.rakuten.co.jp/edion",
"is39Shop": true,
"signboardUrl": "https://shop.r10s.jp/edion/cabinet/header/kanban_pc2.jpg",
"shopPoint": [],
"shopReview": {
"didMeetDisplayRequirements": true,
"total": 128136,
"average": 4.51
},
"couponIds": [],
"status": 1,
"shopCalendar": {
"eventDates": {
"holiday": [],
"orderOnly": [],
"shippingOnly": []
},
"currentJapanDate": "2026-07-27"
}
}
}
+455
View File
@@ -0,0 +1,455 @@
"""API 层测试:鉴权、参数校验、错误映射与响应包装
抓取客户端被替换为桩实现,不触达真实站点。
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.core.config import get_settings
from app.core.errors import ItemNotFoundError, OffIchibaRedirectError, UpstreamBlockedError
from app.main import create_app
from app.models.scrape import (
GenreData,
GenreNode,
ItemDetailData,
RakumaItemDetailData,
RakumaSearchItem,
RakumaSearchResultData,
RakumaShopDetailData,
RakumaShopItemsData,
SearchItem,
SearchResultData,
ShopDetailData,
)
TOKEN = get_settings().bearer_token
AUTH = {"Authorization": f"Bearer {TOKEN}"}
class StubClient:
"""记录入参并返回固定结果的抓取客户端桩"""
def __init__(self) -> None:
self.search_payload = None
self.detail_payload = None
self.genre_payload = None
self.shop_detail_payload = None
self.shop_items_payload = None
self.raise_on_search: Exception | None = None
self.raise_on_detail: Exception | None = None
async def search(self, payload) -> SearchResultData:
self.search_payload = payload
if self.raise_on_search:
raise self.raise_on_search
return SearchResultData(
keyword=payload.keyword,
page=payload.page,
page_size=45,
total_count=1234,
reachable_count=1234,
has_more=True,
ad_count=2,
request_url="https://search.rakuten.co.jp/search/mall/x/",
items=[SearchItem(item_id="1", item_code="c1", item_name="商品", price=100)],
)
async def item_detail(self, payload) -> ItemDetailData:
self.detail_payload = payload
if self.raise_on_detail:
raise self.raise_on_detail
return ItemDetailData(item_id="1", item_code="c1", item_name="商品", price=100)
async def genres(self, payload) -> GenreData:
self.genre_payload = payload
return GenreData(
genre_id=payload.genre_id or "",
name="テレビゲーム" if payload.genre_id else "",
children=[GenreNode(genre_id="565950", name="Nintendo Switch")],
)
async def shop_detail(self, payload) -> ShopDetailData:
self.shop_detail_payload = payload
return ShopDetailData(
shop_id=272415,
shop_code=payload.shop_code or "edion",
shop_name="エディオン 楽天市場店",
review_score=4.51,
review_count=128136,
)
async def shop_items(self, payload) -> SearchResultData:
self.shop_items_payload = payload
return await self.search(payload.to_search_request(payload.shop_id or 272415))
class StubRakumaClient:
"""ラクマ 抓取客户端桩"""
def __init__(self) -> None:
self.search_payload = None
self.detail_payload = None
self.shop_detail_payload = None
self.shop_items_payload = None
self.raise_on_detail: Exception | None = None
async def search(self, payload) -> RakumaSearchResultData:
self.search_payload = payload
return RakumaSearchResultData(
keyword=payload.keyword,
page=payload.page,
page_size=40,
total_count=1196774,
has_more=True,
request_url="https://fril.jp/s?query=x",
items=[RakumaSearchItem(item_id="abc", item_name="商品", price=6299)],
)
async def item_detail(self, payload) -> RakumaItemDetailData:
self.detail_payload = payload
if self.raise_on_detail:
raise self.raise_on_detail
return RakumaItemDetailData(item_id="abc", item_name="商品", price=6299)
async def shop_detail(self, payload) -> RakumaShopDetailData:
self.shop_detail_payload = payload
return RakumaShopDetailData(shop_id="s1", shop_name="出品者", review_count=118)
async def shop_items(self, payload) -> RakumaShopItemsData:
self.shop_items_payload = payload
return RakumaShopItemsData(
shop_id="s1",
shop_name="出品者",
page=payload.page,
total_count=21,
items=[RakumaSearchItem(item_id="abc", item_name="商品", price=6299)],
)
@pytest.fixture
def client_and_stub():
app = create_app()
with TestClient(app) as client:
stub = StubClient()
rakuma_stub = StubRakumaClient()
app.state.container.rakuten_client = stub
app.state.container.rakuma_client = rakuma_stub
yield client, stub, rakuma_stub
@pytest.fixture
def client(client_and_stub):
return client_and_stub[0]
@pytest.fixture
def stub(client_and_stub):
return client_and_stub[1]
@pytest.fixture
def rakuma_stub(client_and_stub):
return client_and_stub[2]
# ---- 鉴权 ----
@pytest.mark.parametrize("path", ["/api/search", "/api/item_detail", "/api/genres"])
def test_endpoints_reject_missing_token(client, path):
response = client.post(path, json={"keyword": "a", "item_url": "https://item.rakuten.co.jp/s/c/"})
assert response.status_code == 401
body = response.json()
assert body["success"] is False
assert body["code"] == 1001
def test_endpoints_reject_wrong_token(client):
response = client.post(
"/api/search", json={"keyword": "a"}, headers={"Authorization": "Bearer wrong-token"}
)
assert response.status_code == 401
def test_endpoints_reject_non_bearer_scheme(client):
response = client.post("/api/search", json={"keyword": "a"}, headers={"Authorization": TOKEN})
assert response.status_code == 401
def test_health_needs_no_token(client):
response = client.get("/health")
assert response.status_code == 200
body = response.json()
assert body["data"]["status"] == "ok"
assert set(body["data"]["sessions"]) == {"pc", "sp", "rakuma"}
# ---- 参数校验 ----
def test_search_requires_a_target(client):
response = client.post("/api/search", json={}, headers=AUTH)
assert response.status_code == 422
assert response.json()["code"] == 1002
def test_search_rejects_page_beyond_site_limit(client):
response = client.post("/api/search", json={"keyword": "a", "page": 151}, headers=AUTH)
assert response.status_code == 422
def test_search_rejects_unknown_sort(client):
response = client.post("/api/search", json={"keyword": "a", "sort": "cheapest"}, headers=AUTH)
assert response.status_code == 422
def test_item_detail_requires_url_or_code_pair(client):
response = client.post("/api/item_detail", json={"shop_code": "edion"}, headers=AUTH)
assert response.status_code == 422
# ---- 正常响应 ----
def test_search_passes_filters_through_to_client(client, stub):
response = client.post(
"/api/search",
json={
"keyword": "switch",
"page": 2,
"sort": "price_asc",
"min_price": 1000,
"condition": "used",
"free_shipping": True,
"exclude_ads": False,
},
headers=AUTH,
)
assert response.status_code == 200
payload = stub.search_payload
assert payload.keyword == "switch"
assert payload.page == 2
assert payload.sort.value == "price_asc"
assert payload.min_price == 1000
assert payload.condition.value == "used"
assert payload.free_shipping is True
assert payload.exclude_ads is False
body = response.json()
assert body["success"] is True
assert body["code"] == 0
assert body["data"]["total_count"] == 1234
assert body["data"]["items"][0]["item_code"] == "c1"
def test_genres_accepts_empty_body_for_top_level(client, stub):
"""顶层分类不需要任何入参"""
response = client.post("/api/genres", json={}, headers=AUTH)
assert response.status_code == 200
assert stub.genre_payload.genre_id is None
assert response.json()["data"]["children"][0]["genre_id"] == "565950"
def test_genres_passes_genre_id_through(client, stub):
response = client.post("/api/genres", json={"genre_id": "101205"}, headers=AUTH)
assert response.status_code == 200
assert stub.genre_payload.genre_id == "101205"
assert response.json()["data"]["name"] == "テレビゲーム"
def test_item_detail_defaults_to_including_sku_variants(client, stub):
response = client.post(
"/api/item_detail",
json={"shop_code": "edion", "item_code": "4902370549263"},
headers=AUTH,
)
assert response.status_code == 200
assert stub.detail_payload.include_sku_variants is True
assert response.json()["data"]["item_name"] == "商品"
# ---- 抓取异常映射 ----
def test_blocked_upstream_maps_to_structured_error(client, stub):
stub.raise_on_search = UpstreamBlockedError("blocked by akamai")
response = client.post("/api/search", json={"keyword": "a"}, headers=AUTH)
assert response.status_code == 400
body = response.json()
assert body["success"] is False
assert body["code"] == 3002
assert "blocked" in body["msg"]
def test_off_ichiba_redirect_gets_its_own_error_code(client, stub):
"""楽天ブックス等官方子站的商品不该被当成反爬拦截,上游要能据此改走别的通道"""
stub.raise_on_detail = OffIchibaRedirectError(
"https://item.rakuten.co.jp/book/16033028/",
"https://books.rakuten.co.jp/rb/16033028/",
)
response = client.post(
"/api/item_detail", json={"shop_code": "book", "item_code": "16033028"}, headers=AUTH
)
assert response.status_code == 400
body = response.json()
assert body["code"] == 4002
assert "books.rakuten.co.jp" in body["msg"]
def test_missing_item_maps_to_404(client, stub):
stub.raise_on_detail = ItemNotFoundError("no such item")
response = client.post(
"/api/item_detail", json={"shop_code": "s", "item_code": "c"}, headers=AUTH
)
assert response.status_code == 404
assert response.json()["code"] == 4004
# ---- 乐天商家 ----
def test_shop_detail_returns_store_profile(client, stub):
response = client.post("/api/shop_detail", json={"shop_code": "edion"}, headers=AUTH)
assert response.status_code == 200
assert stub.shop_detail_payload.shop_code == "edion"
data = response.json()["data"]
assert data["shop_id"] == 272415
assert data["review_count"] == 128136
def test_shop_detail_requires_an_identifier(client):
response = client.post("/api/shop_detail", json={}, headers=AUTH)
assert response.status_code == 422
def test_shop_items_scopes_the_search_to_the_shop(client, stub):
"""商家商品实际是一次 sid 限定的搜索,筛选字段要原样传下去"""
response = client.post(
"/api/shop_items",
json={"shop_id": 272415, "page": 2, "keyword": "テレビ", "sort": "price_asc"},
headers=AUTH,
)
assert response.status_code == 200
assert stub.shop_items_payload.shop_id == 272415
# 转换后的搜索请求必须带上店铺限定
assert stub.search_payload.shop_id == 272415
assert stub.search_payload.keyword == "テレビ"
assert stub.search_payload.page == 2
assert response.json()["data"]["total_count"] == 1234
def test_shop_items_requires_an_identifier(client):
response = client.post("/api/shop_items", json={"page": 2}, headers=AUTH)
assert response.status_code == 422
# ---- ラクマ ----
@pytest.mark.parametrize(
"path",
[
"/api/rakuma/search",
"/api/rakuma/item_detail",
"/api/rakuma/shop_detail",
"/api/rakuma/shop_items",
],
)
def test_rakuma_endpoints_reject_missing_token(client, path):
response = client.post(path, json={"keyword": "a", "item_id": "x", "shop_id": "s"})
assert response.status_code == 401
assert response.json()["code"] == 1001
def test_rakuma_search_passes_filters_through(client, rakuma_stub):
response = client.post(
"/api/rakuma/search",
json={
"keyword": "switch",
"page": 2,
"sort": "price_asc",
"conditions": ["new", "almost_new"],
"transaction": "on_sale",
"free_shipping": True,
"brand_id": "5296",
},
headers=AUTH,
)
assert response.status_code == 200
payload = rakuma_stub.search_payload
assert payload.keyword == "switch"
assert payload.page == 2
assert payload.sort.value == "price_asc"
assert [c.value for c in payload.conditions] == ["new", "almost_new"]
assert payload.transaction.value == "on_sale"
assert payload.free_shipping is True
assert payload.brand_id == "5296"
assert response.json()["data"]["total_count"] == 1196774
def test_rakuma_search_requires_a_target(client):
response = client.post("/api/rakuma/search", json={}, headers=AUTH)
assert response.status_code == 422
assert response.json()["code"] == 1002
def test_rakuma_search_rejects_page_beyond_site_limit(client):
"""站点 page>100 直接 404,不必打出去才知道"""
response = client.post(
"/api/rakuma/search", json={"keyword": "a", "page": 101}, headers=AUTH
)
assert response.status_code == 422
def test_rakuma_search_rejects_rakuten_only_sort(client):
"""两站排序枚举不同,乐天的 review_count 在 ラクマ 上不存在"""
response = client.post(
"/api/rakuma/search", json={"keyword": "a", "sort": "review_count"}, headers=AUTH
)
assert response.status_code == 422
def test_rakuma_item_detail_accepts_item_id(client, rakuma_stub):
response = client.post("/api/rakuma/item_detail", json={"item_id": "abc"}, headers=AUTH)
assert response.status_code == 200
assert rakuma_stub.detail_payload.item_id == "abc"
assert response.json()["data"]["price"] == 6299
def test_rakuma_item_detail_requires_an_identifier(client):
response = client.post("/api/rakuma/item_detail", json={}, headers=AUTH)
assert response.status_code == 422
def test_rakuma_missing_item_maps_to_404(client, rakuma_stub):
rakuma_stub.raise_on_detail = ItemNotFoundError("no such item")
response = client.post("/api/rakuma/item_detail", json={"item_id": "x"}, headers=AUTH)
assert response.status_code == 404
assert response.json()["code"] == 4004
def test_rakuma_shop_detail_skips_reviews_by_default(client, rakuma_stub):
"""评价明细要多打一次请求,默认不取"""
response = client.post("/api/rakuma/shop_detail", json={"shop_id": "s1"}, headers=AUTH)
assert response.status_code == 200
assert rakuma_stub.shop_detail_payload.include_reviews is False
assert response.json()["data"]["review_count"] == 118
def test_rakuma_shop_detail_can_request_reviews(client, rakuma_stub):
response = client.post(
"/api/rakuma/shop_detail", json={"shop_id": "s1", "include_reviews": True}, headers=AUTH
)
assert response.status_code == 200
assert rakuma_stub.shop_detail_payload.include_reviews is True
def test_rakuma_shop_items_passes_page_through(client, rakuma_stub):
response = client.post(
"/api/rakuma/shop_items", json={"shop_id": "s1", "page": 3}, headers=AUTH
)
assert response.status_code == 200
assert rakuma_stub.shop_items_payload.page == 3
assert response.json()["data"]["total_count"] == 21
@pytest.mark.parametrize("path", ["/api/rakuma/shop_detail", "/api/rakuma/shop_items"])
def test_rakuma_shop_endpoints_require_an_identifier(client, path):
response = client.post(path, json={}, headers=AUTH)
assert response.status_code == 422
+109
View File
@@ -0,0 +1,109 @@
"""分类树解析测试:基于真实页面状态样本"""
import pytest
from app.core import site
from app.core.errors import ScrapeParseError
from app.parsers.genre import parse_genres
from app.utils.urls import build_genre_url
# ---- URL 构建 ----
def test_genre_url_uses_category_page_when_id_given():
assert build_genre_url("101205") == "https://www.rakuten.co.jp/category/101205/"
def test_genre_url_falls_back_to_sentinel_search_for_top_level():
"""顶层列表没有对应的分类页,改用查不到东西的哨兵关键词取干净的分类分面"""
url = build_genre_url(None)
assert url == f"https://search.rakuten.co.jp/search/mall/{site.GENRE_FACET_PROBE_KEYWORD}/"
# ---- 顶层分类 ----
def test_root_returns_all_top_level_genres(genre_root_state):
result = parse_genres(genre_root_state, genre_id=None)
assert result.genre_id == ""
assert result.ancestors == []
assert len(result.children) == 39
names = {child.name for child in result.children}
assert "テレビゲーム" in names
assert "レディースファッション" in names
def test_root_children_expose_id_shortcut_and_url(genre_root_state):
result = parse_genres(genre_root_state, genre_id=None)
game = next(child for child in result.children if child.name == "テレビゲーム")
assert game.genre_id == "101205"
assert game.shortcut == "game"
assert game.url == "https://www.rakuten.co.jp/category/101205/"
def test_root_omits_item_count_because_it_is_query_scoped(genre_root_state):
"""站点给的是「当前查询在该分类下的命中数」,不是分类商品总量,不能透出"""
result = parse_genres(genre_root_state, genre_id=None)
assert all(child.item_count is None for child in result.children)
# ---- 指定分类 ----
def test_genre_node_returns_itself_and_direct_children(genre_node_state):
result = parse_genres(genre_node_state, genre_id="101205")
assert result.genre_id == "101205"
assert result.name == "テレビゲーム"
assert result.is_leaf is False
assert result.url == "https://www.rakuten.co.jp/category/101205/"
assert len(result.children) == 27
child_names = {child.name for child in result.children}
assert "Nintendo Switch" in child_names
def test_genre_children_carry_item_count(genre_node_state):
result = parse_genres(genre_node_state, genre_id="101205")
switch = next(child for child in result.children if child.name == "Nintendo Switch")
assert switch.genre_id == "565950"
assert switch.item_count and switch.item_count > 0
def test_top_level_genre_has_no_ancestors_and_carries_genre_info(genre_node_state):
result = parse_genres(genre_node_state, genre_id="101205")
# 虚拟根 id=0 不是真实分类,不应出现在祖先里
assert result.ancestors == []
assert result.full_name == "テレビゲーム"
assert result.description
def test_nested_genre_reports_its_ancestor_chain(genre_node_state):
"""深层分类要能回报从顶层到父级的路径,方便上游拼面包屑"""
root = genre_node_state["state"]["data"]["genreTree"]["parent_category"]
game = root["children"][0]
switch = next(child for child in game["children"] if child["id"] == 565950)
# 模拟站点返回深层分类页时的链式结构:只保留通往目标的那条分支
game["children"] = [switch]
switch["children"] = [{"id": 566404, "name": "ソフト", "count": 100, "leaf": True, "children": []}]
result = parse_genres(genre_node_state, genre_id="565950")
assert result.genre_id == "565950"
assert [(a.genre_id, a.name) for a in result.ancestors] == [("101205", "テレビゲーム")]
assert [c.genre_id for c in result.children] == ["566404"]
def test_leaf_genre_reports_no_children(genre_node_state):
root = genre_node_state["state"]["data"]["genreTree"]["parent_category"]
root["children"] = [{"id": 999999, "name": "", "count": 5, "leaf": True, "children": []}]
result = parse_genres(genre_node_state, genre_id="999999")
assert result.is_leaf is True
assert result.children == []
# ---- 错误处理 ----
def test_missing_genre_tree_is_a_parse_error():
with pytest.raises(ScrapeParseError):
parse_genres({"state": {"data": {}}}, genre_id=None)
def test_unknown_genre_id_is_a_parse_error(genre_node_state):
with pytest.raises(ScrapeParseError):
parse_genres(genre_node_state, genre_id="1")
+147
View File
@@ -0,0 +1,147 @@
"""解析器测试:基于真实页面状态样本"""
import pytest
from app.core.errors import ScrapeParseError
from app.parsers.item import parse_item_detail
from app.parsers.search import parse_search
from app.parsers.state import extract_initial_state
# ---- __INITIAL_STATE__ 抽取 ----
def test_extract_initial_state_stops_at_end_of_json_object():
"""赋值语句后面紧跟其他脚本代码,不能整段吃掉"""
html = '<script>window.__INITIAL_STATE__ = {"a": {"b": 1}};window.__LOCALE__="ja";</script>'
assert extract_initial_state(html) == {"a": {"b": 1}}
def test_extract_initial_state_reports_missing_marker():
with pytest.raises(ScrapeParseError):
extract_initial_state("<html><body>blocked</body></html>")
def test_extract_initial_state_reports_broken_json():
with pytest.raises(ScrapeParseError):
extract_initial_state("window.__INITIAL_STATE__ = {oops")
# ---- 搜索结果 ----
def test_search_excludes_ads_by_default_but_still_counts_them(search_state):
result = parse_search(search_state, request_url="https://x", page=1, exclude_ads=True)
assert result.ad_count == 1
assert all(not item.is_ad for item in result.items)
assert len(result.items) == 2
def test_search_can_keep_ads_and_resolves_their_real_item_url(search_state):
result = parse_search(search_state, request_url="https://x", page=1, exclude_ads=False)
ads = [item for item in result.items if item.is_ad]
assert len(ads) == 1
# 广告位的 url 是跳转域,必须还原成 originalItemUrl 才能拿到商品编号
assert ads[0].item_url.startswith("https://item.rakuten.co.jp/")
assert ads[0].shop.shop_code and ads[0].item_code
def test_search_item_carries_fields_needed_to_call_item_detail(search_state):
result = parse_search(search_state, request_url="https://x", page=1, exclude_ads=True)
item = result.items[0]
assert item.item_name
assert item.price > 0
# shop.shop_code + item_code 就是 /api/item_detail 的入参
assert item.shop.shop_code and item.item_code
assert item.image_url.startswith("https://")
assert item.genre_id.isdigit()
def test_search_pagination_is_capped_by_site_subset(search_state):
result = parse_search(search_state, request_url="https://x", page=1, exclude_ads=True)
assert result.total_count > 0
# 站点声明命中数很大,但实际只能翻到 subset 条为止
assert result.reachable_count <= result.total_count
assert result.reachable_count <= 6750
def test_search_reports_request_url(search_state):
result = parse_search(search_state, request_url="https://search.rakuten.co.jp/x", page=1, exclude_ads=True)
assert result.request_url == "https://search.rakuten.co.jp/x"
def test_search_flags_out_of_range_page_and_drops_wrapped_items(search_state):
"""越过可达窗口时站点会静默回绕到第 1 页,必须识别出来而不是把重复数据交上去"""
assert search_state["state"]["data"]["effectiveUiQuestion"]["page"] == 1
result = parse_search(search_state, request_url="https://x", page=10, exclude_ads=True)
assert result.out_of_range is True
assert result.items == []
assert result.has_more is False
assert result.page == 10 # 回报请求页码,而不是站点回绕后的 1
def test_search_within_range_is_not_flagged_out_of_range(search_state):
search_state["state"]["data"]["effectiveUiQuestion"]["page"] = 3
result = parse_search(search_state, request_url="https://x", page=3, exclude_ads=True)
assert result.out_of_range is False
assert result.items
def test_search_rejects_state_without_search_node():
with pytest.raises(ScrapeParseError):
parse_search({"state": {"data": {}}}, request_url="https://x", page=1, exclude_ads=True)
# ---- 商品详情 ----
def test_item_detail_extracts_core_fields(item_state):
detail = parse_item_detail(
item_state, item_url="https://item.rakuten.co.jp/fafachai/tx-300/",
shop_code="fafachai", include_sku_variants=True,
)
assert detail.source == "ichiba"
assert detail.source_url == "https://item.rakuten.co.jp/fafachai/tx-300/"
assert detail.item_name
assert detail.item_code
assert detail.price > 0
assert detail.images and all(url.startswith("https://") for url in detail.images)
assert detail.shop.shop_id and detail.shop.shop_code
assert detail.genre_id.isdigit()
assert detail.breadcrumbs
def test_item_detail_purchasable_item_is_not_sold_out(item_state):
detail = parse_item_detail(
item_state, item_url="https://x", shop_code="fafachai", include_sku_variants=True,
)
assert detail.purchase_condition == "enabled"
assert detail.is_sold_out is False
def test_item_detail_parses_sku_axis_and_variants(item_state):
detail = parse_item_detail(
item_state, item_url="https://x", shop_code="fafachai", include_sku_variants=True,
)
assert detail.sku.inventory_type == "multiple"
assert detail.sku.axis and detail.sku.axis[0].values
assert detail.sku.variants
variant = detail.sku.variants[0]
assert variant.variant_id and variant.price > 0
assert len(variant.selector_values) == len(detail.sku.axis)
def test_item_detail_can_omit_variants_but_keeps_their_count(item_state):
"""SKU 组合可能上百条,关闭后应只省略明细、不丢失规模信息"""
full = parse_item_detail(item_state, item_url="https://x", shop_code="s", include_sku_variants=True)
slim = parse_item_detail(item_state, item_url="https://x", shop_code="s", include_sku_variants=False)
assert slim.sku.variants == []
assert slim.sku.variant_count == full.sku.variant_count > 0
assert slim.sku.axis == full.sku.axis
def test_item_detail_falls_back_to_request_shop_code_when_state_lacks_it(item_state):
item_state["shop"]["information"]["shopUrl"] = ""
detail = parse_item_detail(item_state, item_url="https://x", shop_code="fallback", include_sku_variants=False)
assert detail.shop.shop_code == "fallback"
def test_item_detail_rejects_state_without_item_node():
with pytest.raises(ScrapeParseError):
parse_item_detail({}, item_url="https://x", shop_code="s", include_sku_variants=True)
+187
View File
@@ -0,0 +1,187 @@
"""加购信息(purchase 块)解析测试
契约取自各站点前端构造加购请求的逻辑,四个来源的端点与字段名互不相同,
因此这里逐来源验证「提交到哪里、带哪些固定字段、数量/规格/选项各用哪个字段名」。
本服务只提供这些数据,不执行加购。
"""
from pathlib import Path
import pytest
from app.parsers.item import parse_item_detail, parse_purchase_options
from app.parsers.subsites import parse_subsite_item
from app.parsers.subsites.base import SubsitePage
from app.parsers.subsites.brandavenue import _resolve_cart_url
FIXTURES = Path(__file__).parent / "fixtures"
ICHIBA_URL = "https://item.rakuten.co.jp/fafachai/tx-300/"
def subsite(filename: str, host: str, shop_code: str, item_code: str):
return parse_subsite_item(
SubsitePage(
html=(FIXTURES / filename).read_text(encoding="utf-8"),
requested_url=f"https://item.rakuten.co.jp/{shop_code}/{item_code}/",
final_url=f"https://{host}/item/{item_code}/",
shop_code=shop_code,
item_code=item_code,
include_sku_variants=True,
)
)
# ---- 市场 ----
def test_ichiba_cart_url_comes_from_item_not_hardcoded(item_state):
"""basketDomain 逐商品不同(不同店铺落在不同 basket 集群),不能写死"""
d = parse_item_detail(item_state, item_url=ICHIBA_URL, shop_code="fafachai", include_sku_variants=True)
assert d.purchase.cart_url == "https://ts.sp.basket.step.rakuten.co.jp/rms/mall/bss/cartadd/set"
def test_ichiba_form_fields_match_site_contract(item_state):
d = parse_item_detail(item_state, item_url=ICHIBA_URL, shop_code="fafachai", include_sku_variants=True)
fields = d.purchase.form_fields
assert fields["shop_bid"] == str(d.shop.shop_id)
assert fields["item_id"] == d.item_id
assert fields["__event"] == "ES01_003_001"
assert d.purchase.quantity_field == "units"
assert d.purchase.variant_field == "variant_id"
def test_ichiba_multi_sku_reports_inventory_flag_2_without_prefilled_variant(item_state):
"""多规格商品必须由调用方选定 SKU,不能预填"""
d = parse_item_detail(item_state, item_url=ICHIBA_URL, shop_code="fafachai", include_sku_variants=True)
assert d.sku.inventory_type == "multiple"
assert d.purchase.form_fields["inventory_flag"] == "2"
assert "variant_id" not in d.purchase.form_fields
def test_ichiba_single_sku_prefills_its_only_variant(item_state):
"""单一库存商品规格固定,直接填好,调用方无需再选"""
item_state["purchase"]["sku"]["inventoryType"] = "single"
item_state["item"]["variantId"] = "4902370549263"
d = parse_item_detail(item_state, item_url=ICHIBA_URL, shop_code="fafachai", include_sku_variants=True)
assert d.purchase.form_fields["inventory_flag"] == "1"
assert d.purchase.form_fields["variant_id"] == "4902370549263"
def test_ichiba_selected_variant_id_is_available_from_sku(item_state):
"""调用方要往 variant_field 里填的值,来自 sku.variants[].variant_id"""
d = parse_item_detail(item_state, item_url=ICHIBA_URL, shop_code="fafachai", include_sku_variants=True)
assert d.sku.variants[0].variant_id
def test_item_without_options_has_no_options_field(item_state):
d = parse_item_detail(item_state, item_url=ICHIBA_URL, shop_code="fafachai", include_sku_variants=True)
assert d.purchase.options == []
assert d.purchase.options_field == ""
assert d.purchase.has_required_options is False
# ---- 商品选项 ----
def test_options_are_parsed_with_types_and_required_flag(item_with_options_state):
d = parse_item_detail(
item_with_options_state,
item_url="https://item.rakuten.co.jp/kizamu/set-beermug-031/",
shop_code="kizamu",
include_sku_variants=True,
)
assert d.purchase.options_field == "choice"
assert d.purchase.has_required_options is True
types = {o.type for o in d.purchase.options}
assert {"select", "text"} <= types
required = [o for o in d.purchase.options if o.is_required]
assert required and required[0].name
def test_select_option_carries_candidate_values(item_with_options_state):
d = parse_item_detail(
item_with_options_state, item_url="https://x", shop_code="kizamu", include_sku_variants=True
)
select = next(o for o in d.purchase.options if o.type == "select")
assert select.values
assert all(v.name for v in select.values)
def test_text_option_has_no_candidate_values(item_with_options_state):
"""自由文本选项由买家填写,没有候选值"""
d = parse_item_detail(
item_with_options_state, item_url="https://x", shop_code="kizamu", include_sku_variants=True
)
text = next(o for o in d.purchase.options if o.type == "text")
assert text.values == []
def test_parse_purchase_options_tolerates_malformed_input():
assert parse_purchase_options(None) == []
assert parse_purchase_options([None, "x", 1]) == []
assert parse_purchase_options([{"id": 1, "name": "a", "type": "select", "values": [None]}])[0].values == []
# ---- 楽天ブックス ----
def test_books_item_id_comes_from_cart_form_not_url():
"""加购要用表单里的 item_id;URL 上的编号是市场侧商品编号,拿去下单会失败"""
d = subsite("books_item.html", "books.rakuten.co.jp", "book", "17065211")
assert d.item_code == "17065211"
assert d.item_id == "20600328"
assert d.purchase.form_fields["item_id"] == "20600328"
def test_books_cart_endpoint_and_shop_bid():
d = subsite("books_item.html", "books.rakuten.co.jp", "book", "17065211")
assert d.purchase.cart_url == "https://sp.books.step.rakuten.co.jp/rms/mall/book/bs/Cart"
assert d.purchase.form_fields["shop_bid"] == "213310"
def test_books_cart_form_has_no_quantity_or_variant_field():
"""该站表单不支持指定件数与规格,字段名留空以示不可用"""
d = subsite("books_item.html", "books.rakuten.co.jp", "book", "17065211")
assert d.purchase.quantity_field == ""
assert d.purchase.variant_field == ""
# ---- Rakuten Fashion ----
@pytest.mark.parametrize(
("cart_url_type", "expected"),
[
("6", "https://sp.basket.step.rakuten.co.jp/rms/mall/bss/cartadd/set"),
("5", "https://basket.step.rakuten.co.jp/rms/mall/bs/cartadd/set"),
("1", "https://ts.basket.step.rakuten.co.jp/rms/mall/bs/cartadd/set"),
("https://custom.example.com/cart", "https://custom.example.com/cart"),
("99", ""),
("", ""),
],
)
def test_brandavenue_cart_url_type_mapping(cart_url_type, expected):
"""端点由 cart_url_type 编号映射得到;已经是 URL 时原样使用"""
assert _resolve_cart_url(cart_url_type) == expected
def test_brandavenue_uses_same_contract_as_ichiba():
d = subsite("brandavenue_item.html", "brandavenue.rakuten.co.jp", "stylife", "md7606")
assert d.purchase.cart_url == "https://sp.basket.step.rakuten.co.jp/rms/mall/bss/cartadd/set"
assert d.purchase.form_fields["shop_bid"] == "279405"
assert d.purchase.form_fields["item_id"] == "14674748"
assert d.purchase.form_fields["inventory_flag"] == "2"
assert d.purchase.quantity_field == "units"
assert d.purchase.variant_field == "variant_id"
def test_brandavenue_variant_ids_are_usable_for_cart():
"""variant_field 要填的值就是 sku.variants[].variant_id(来自 inventory_list)"""
d = subsite("brandavenue_item.html", "brandavenue.rakuten.co.jp", "stylife", "md7606")
assert all(v.variant_id for v in d.sku.variants if v.quantity)
# ---- ビックカメラ ----
def test_biccamera_uses_its_own_json_endpoint_without_shop_bid():
d = subsite("biccamera_item.html", "biccamera.rakuten.co.jp", "biccamera", "4548736147041")
assert d.purchase.cart_url == "https://biccamera.step.rakuten.co.jp/cart/item"
assert d.purchase.form_fields == {"item_id": "13873289"}
assert d.purchase.quantity_field == "units"
assert "shop_bid" not in d.purchase.form_fields
+285
View File
@@ -0,0 +1,285 @@
"""ラクマ(fril.jp)解析测试:基于真实页面裁剪出的夹具
夹具保留了解析所依赖的结构微数据规格表埋点属性商品卡片
去掉了广告位与推荐位等噪音全部离线运行
"""
from pathlib import Path
import pytest
from app.core.errors import InvalidRequestError, ScrapeParseError
from app.models.scrape import (
RakumaAuthenticity,
RakumaCondition,
RakumaSearchRequest,
RakumaSortOption,
RakumaTransaction,
)
from app.parsers.rakuma.base import parse_int, parse_total_count
from app.parsers.rakuma.item import parse_item_detail
from app.parsers.rakuma.search import parse_search
from app.parsers.rakuma.shop import parse_shop_detail, parse_shop_items
from app.utils.rakuma_urls import (
build_item_url,
build_search_url,
build_shop_url,
normalize_search_url,
split_item_url,
split_shop_url,
)
FIXTURES = Path(__file__).parent / "fixtures"
def fixture(name: str) -> str:
return (FIXTURES / name).read_text(encoding="utf-8")
# ---- URL 构建 ----
def test_search_url_carries_keyword_and_default_sort():
url = build_search_url(RakumaSearchRequest(keyword="switch"))
assert "query=switch" in url
# 站点的排序由 sort + order 两个参数共同决定,默认是相关度降序
assert "sort=relevance" in url and "order=desc" in url
def test_search_url_maps_filters_to_site_codes():
"""筛选码必须与站点码表一致——站点对不认识的取值会静默返回首页"""
url = build_search_url(
RakumaSearchRequest(
keyword="switch",
category_id="788",
brand_id="5296",
min_price=3000,
max_price=20000,
conditions=[RakumaCondition.NEW, RakumaCondition.ALMOST_NEW],
transaction=RakumaTransaction.ON_SALE,
free_shipping=True,
anonymous_shipping=True,
authenticity_types=[RakumaAuthenticity.BEFORE_DELIVERY],
sort=RakumaSortOption.PRICE_ASC,
page=3,
)
)
assert "category_id=788" in url
assert "brand_id=5296" in url
assert "min=3000" in url and "max=20000" in url
assert "statuses=5%2C4" in url # 多选成色用逗号连接
assert "transaction=selling" in url
assert "carriage=1" in url
assert "anonymous_shipping=true" in url
assert "authenticity_types=pre" in url
assert "sort=sell_price" in url and "order=asc" in url
assert "page=3" in url
def test_search_url_except_for_no_brand_overrides_brand_id():
"""站点前端把两者做成互斥分支,勾选排除无品牌时不下发 brand_id"""
url = build_search_url(
RakumaSearchRequest(keyword="a", brand_id="5296", except_for_no_brand=True)
)
assert "except_for_no_brand=true" in url
assert "brand_id" not in url
def test_search_url_omits_page_on_first_page():
assert "page=" not in build_search_url(RakumaSearchRequest(keyword="switch"))
def test_search_requires_a_target():
with pytest.raises(ValueError):
RakumaSearchRequest()
def test_exclude_keyword_requires_a_keyword():
"""站点在无关键词时不接受排除词,提前拦下而不是打一次无效请求"""
with pytest.raises(ValueError):
RakumaSearchRequest(category_id="788", exclude_keyword="本体")
def test_normalize_search_url_overrides_page():
url = normalize_search_url("https://fril.jp/s?query=switch&page=2", 5)
assert "page=5" in url and "page=2" not in url
def test_normalize_search_url_rejects_foreign_host():
with pytest.raises(InvalidRequestError):
normalize_search_url("https://example.com/s?query=x", 1)
def test_item_and_shop_url_round_trip():
item_id = "4aca1d6db3e422f3a251a8a8b61e1eff"
assert split_item_url(build_item_url(item_id)) == item_id
shop_id = "422750cb7921557bc8dba2416915d968"
assert split_shop_url(build_shop_url(shop_id)) == shop_id
def test_shop_url_supports_paging_and_review_page():
shop_id = "abc123"
assert build_shop_url(shop_id, page=2).endswith("?page=2")
assert build_shop_url(shop_id, review=True).endswith("/review")
def test_split_urls_reject_wrong_host_or_path():
with pytest.raises(InvalidRequestError):
split_item_url("https://fril.jp/item/abc") # 商品页在 item.fril.jp 子域
with pytest.raises(InvalidRequestError):
split_shop_url("https://fril.jp/brand/5296")
# ---- 搜索页解析 ----
def test_parse_search_reads_items_and_exact_total():
result = parse_search(
fixture("rakuma_search.html"), request_url="u", page=1, keyword="switch"
)
assert len(result.items) == 3
# 页面上展示的是四舍五入值(約1,190,000件),精确值取自埋点属性
assert result.total_count == 1196774
assert result.request_url == "u"
item = result.items[0]
assert item.item_id == "61b174d42b58216828a24b86b7903ced"
assert item.item_number == "844649627"
assert item.price == 6299
assert item.is_sold_out is True
assert item.brand_id == "5296"
assert item.brand_name == "Nintendo Switch"
assert item.category_id == "788"
assert item.seller_user_id == "12073120"
assert item.image_url.startswith("https://img.fril.jp/")
def test_parse_search_rejects_non_search_page():
"""站点对无法识别的筛选取值会静默返回首页,必须识别出来而不是当成空结果"""
with pytest.raises(ScrapeParseError):
parse_search("<html><body>home</body></html>", request_url="u", page=1, keyword="x")
def test_parse_search_has_more_stops_at_site_page_limit():
"""站点 page>100 直接 404,第 100 页不应再报 has_more"""
result = parse_search(
fixture("rakuma_search.html"), request_url="u", page=100, keyword="switch"
)
assert result.has_more is False
# ---- 商品详情解析 ----
def test_parse_item_detail_reads_all_blocks():
detail = parse_item_detail(fixture("rakuma_item.html"), item_id="X", item_url="u")
assert detail.item_id == "X"
assert detail.item_number == "844877033"
assert detail.item_name == "NintendoSwitch ONE PIECE 海賊無双4"
assert detail.price == 2460
assert detail.is_sold_out is False
assert detail.condition == "目立った傷や汚れなし"
assert detail.brand_id == "6454"
assert detail.category_id == "788"
assert detail.shipping_from == "北海道"
assert detail.shipping_method == "かんたんラクマパック(ヤマト運輸)"
assert detail.is_anonymous_shipping is True
assert [crumb.name for crumb in detail.breadcrumbs] == [
"エンタメ/ホビー",
"ゲームソフト/ゲーム機本体",
"家庭用ゲームソフト",
]
assert detail.images and all("img.fril.jp" in url for url in detail.images)
assert detail.seller.shop_id == "83b56a796a72011dc58b6e469270dfb7"
assert detail.seller.user_id == "3850223"
assert detail.seller.is_verified is True
def test_parse_item_detail_falls_back_to_tracking_attrs_when_sold_out():
"""已售出商品的页面换了套布局、规格表消失,需退回页面级埋点属性取值"""
detail = parse_item_detail(fixture("rakuma_item_sold.html"), item_id="Y", item_url="u")
assert detail.is_sold_out is True
assert detail.item_number == "844649627"
assert detail.condition == "新品、未使用" # 规格表已消失,取自埋点
assert detail.shipping_from == "千葉県"
assert detail.brand_id == "5296"
# 价格与名称仍由 ld+json 提供,不受布局变化影响
assert detail.price == 6299
assert detail.item_name.startswith("スプラトゥーン")
def test_parse_item_detail_rejects_non_item_page():
with pytest.raises(ScrapeParseError):
parse_item_detail("<html><body>nope</body></html>", item_id="X", item_url="u")
# ---- 店铺解析 ----
def test_parse_shop_detail_reads_profile_and_rating():
detail = parse_shop_detail(
fixture("rakuma_shop.html"), shop_id="S", shop_url="u"
)
assert detail.shop_name == "(^.^)"
assert detail.user_id == "12073120"
# 评价数只有 ld+json 的 Store 节点给得出来,可见 DOM 上没有
assert detail.review_score == 5.0
assert detail.review_count == 1886
assert detail.item_count == 21
assert detail.is_verified is True
assert detail.verification_label == "本人確認済"
assert detail.introduction
def test_parse_shop_detail_reads_reviews_when_supplied():
detail = parse_shop_detail(
fixture("rakuma_shop.html"),
shop_id="S",
shop_url="u",
review_html=fixture("rakuma_shop_review.html"),
)
assert detail.rating_breakdown.good == 118
assert detail.rating_breakdown.bad == 0
assert detail.seller_rating_breakdown.good == 110
assert len(detail.reviews) == 3
first = detail.reviews[0]
assert first.rating == "good"
assert first.reviewer_name == "トシちゃん"
assert first.reviewed_at == "2026/05/04"
def test_parse_shop_detail_leaves_reviews_empty_by_default():
"""不传评价页时不应臆造分档数据"""
detail = parse_shop_detail(fixture("rakuma_shop.html"), shop_id="S", shop_url="u")
assert detail.reviews == []
assert detail.rating_breakdown.good == 0
def test_parse_shop_items_reads_cards_and_total():
result = parse_shop_items(
fixture("rakuma_shop.html"), shop_id="S", request_url="u", page=1
)
assert result.shop_id == "S"
assert result.shop_name == "(^.^)"
assert result.total_count == 21
assert len(result.items) == 2
assert result.items[0].item_id == "61546447a7f13c34a5d015f37a7f3319"
assert result.items[0].price == 6399
def test_parse_shop_pages_reject_non_shop_page():
with pytest.raises(ScrapeParseError):
parse_shop_detail("<html><body>x</body></html>", shop_id="S", shop_url="u")
with pytest.raises(ScrapeParseError):
parse_shop_items("<html><body>x</body></html>", shop_id="S", request_url="u", page=1)
# ---- 取值工具 ----
@pytest.mark.parametrize(
"text,expected",
[("¥6,299", 6299), ("いいね 12件", 12), ("6399", 6399), ("", 0), (None, 0)],
)
def test_parse_int_handles_site_formats(text, expected):
assert parse_int(text) == expected
def test_parse_total_count_handles_nbsp_and_rounding_prefix():
assert parse_total_count("約1,190,000件中 1\xa0-\xa040件") == (1190000, 1, 40)
assert parse_total_count("21件中 1 - 21件") == (21, 1, 21)
assert parse_total_count("no numbers here") == (0, 0, 0)
+116
View File
@@ -0,0 +1,116 @@
"""ラクマ 会话层测试:并发限流下的重试与错误映射
全部用 httpx.MockTransport 拦截不触达真实站点
"""
from __future__ import annotations
import httpx
import pytest
from app.core import rakuma_site as site
from app.core.config import Settings
from app.core.errors import ItemNotFoundError, UpstreamRequestError
from app.services.rakuma_session import RakumaSession
TARGET = "https://fril.jp/s?query=switch"
GOOD_PAGE = '<html><body><div class="page-count">21件中 1 - 21件</div></body></html>'
def make_settings(**overrides) -> Settings:
base = {
"http_max_attempts": 3,
"request_timeout_seconds": 5.0,
"max_site_concurrency": 4,
}
base.update(overrides)
return Settings(**base)
async def build_session(handler, *, settings=None) -> RakumaSession:
"""构建 RakumaSession 并把 client 换成 MockTransport 版本"""
session = RakumaSession(settings or make_settings())
await session.start()
await session._client.aclose()
session._client = httpx.AsyncClient(
headers=site.default_headers(),
transport=httpx.MockTransport(handler),
follow_redirects=True,
)
return session
async def test_fetch_returns_page_without_warmup():
"""ラクマ 无 Akamai 限速,不应像乐天那样先打一次首页预热"""
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(str(request.url))
return httpx.Response(200, text=GOOD_PAGE)
session = await build_session(handler)
try:
assert await session.fetch_html(TARGET) == GOOD_PAGE
finally:
await session.close()
assert seen == [TARGET]
async def test_404_fails_fast_without_retrying():
"""商品下架或 ID 不存在时重试没有意义"""
attempts = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
attempts["n"] += 1
return httpx.Response(404, text="not found")
session = await build_session(handler)
try:
with pytest.raises(ItemNotFoundError):
await session.fetch_html("https://item.fril.jp/deadbeef")
finally:
await session.close()
assert attempts["n"] == 1
async def test_retry_recovers_when_a_later_attempt_succeeds():
attempts = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
attempts["n"] += 1
if attempts["n"] == 1:
return httpx.Response(503, text="unavailable")
return httpx.Response(200, text=GOOD_PAGE)
session = await build_session(handler)
try:
assert await session.fetch_html(TARGET) == GOOD_PAGE
finally:
await session.close()
assert attempts["n"] == 2
async def test_persistent_5xx_surfaces_as_request_error():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(503, text="unavailable")
session = await build_session(handler)
try:
with pytest.raises(UpstreamRequestError):
await session.fetch_html(TARGET)
finally:
await session.close()
async def test_status_reports_readiness():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text=GOOD_PAGE)
session = RakumaSession(make_settings())
assert session.status()["ready"] is False
session = await build_session(handler)
try:
assert session.status()["ready"] is True
finally:
await session.close()
assert session.status()["ready"] is False
+146
View File
@@ -0,0 +1,146 @@
"""搜索/商品 URL 构建与解析的单元测试"""
from urllib.parse import parse_qsl, urlsplit
import pytest
from app.core.errors import InvalidRequestError
from app.models.scrape import ItemCondition, SearchRequest, SortOption
from app.utils.urls import (
build_item_url,
build_search_url,
item_url_parts,
normalize_search_url,
split_item_url,
)
def query_of(url: str) -> list[tuple[str, str]]:
return parse_qsl(urlsplit(url).query, keep_blank_values=True)
def test_keyword_only_uses_default_sort_without_params():
url = build_search_url(SearchRequest(keyword="nintendo switch"))
assert url == "https://search.rakuten.co.jp/search/mall/nintendo%20switch/"
def test_genre_without_keyword_uses_placeholder_segment():
url = build_search_url(SearchRequest(genre_id="565950"))
assert urlsplit(url).path == "/search/mall/-/565950/"
def test_keyword_and_genre_are_both_path_segments():
url = build_search_url(SearchRequest(keyword="switch", genre_id="565950"))
assert urlsplit(url).path == "/search/mall/switch/565950/"
def test_page_one_is_omitted_but_later_pages_are_explicit():
assert "p=" not in build_search_url(SearchRequest(keyword="a", page=1))
assert ("p", "3") in query_of(build_search_url(SearchRequest(keyword="a", page=3)))
@pytest.mark.parametrize(
("sort", "expected"),
[
(SortOption.STANDARD, None),
(SortOption.PRICE_ASC, "2"),
(SortOption.PRICE_DESC, "3"),
(SortOption.NEWEST, "4"),
(SortOption.REVIEW_COUNT, "5"),
(SortOption.REVIEW_SCORE, "6"),
(SortOption.PRICE_WITH_SHIPPING_ASC, "11"),
(SortOption.PRICE_WITH_SHIPPING_DESC, "12"),
],
)
def test_sort_maps_to_site_codes(sort, expected):
"""排序码取自站点前端 bundle 的枚举,改动会直接影响返回顺序"""
query = dict(query_of(build_search_url(SearchRequest(keyword="a", sort=sort))))
assert query.get("s") == expected
def test_condition_and_bool_filters_share_repeatable_f_param():
url = build_search_url(
SearchRequest(
keyword="a",
condition=ItemCondition.USED,
free_shipping=True,
include_sold_out=True,
has_review=True,
next_day_delivery=True,
super_deal=True,
)
)
f_values = sorted(value for key, value in query_of(url) if key == "f")
assert f_values == sorted(["100", "0", "2", "4", "12", "13"])
def test_advanced_filters_are_mapped_to_site_param_names():
url = build_search_url(
SearchRequest(
keyword="a",
min_price=1000,
max_price=5000,
shop_id=272415,
exclude_keyword="中古",
title_only=True,
or_query=True,
min_review_score=4,
tags=["1001", "1002"],
)
)
query = dict(query_of(url))
assert query["min"] == "1000"
assert query["max"] == "5000"
assert query["sid"] == "272415"
assert query["nitem"] == "中古"
assert query["sf"] == "1"
assert query["st"] == "O"
assert query["review"] == "4"
assert query["tg"] == "1001,1002"
def test_search_request_requires_a_target():
with pytest.raises(ValueError):
SearchRequest()
def test_search_request_rejects_inverted_price_range():
with pytest.raises(ValueError):
SearchRequest(keyword="a", min_price=5000, max_price=1000)
def test_normalize_search_url_keeps_url_untouched_on_first_page():
raw = "https://search.rakuten.co.jp/search/mall/switch/?s=2&f=2"
assert normalize_search_url(raw, 1) == raw
def test_normalize_search_url_overrides_existing_page():
raw = "https://search.rakuten.co.jp/search/mall/switch/?p=9&s=2"
query = dict(query_of(normalize_search_url(raw, 4)))
assert query["p"] == "4"
assert query["s"] == "2"
def test_normalize_search_url_rejects_foreign_host():
with pytest.raises(InvalidRequestError):
normalize_search_url("https://example.com/search/mall/switch/", 1)
def test_build_item_url_joins_shop_and_item_code():
assert build_item_url("edion", "4902370549263") == "https://item.rakuten.co.jp/edion/4902370549263/"
def test_split_item_url_extracts_shop_and_item_code():
assert split_item_url("https://item.rakuten.co.jp/edion/4902370549263/?variantId=x") == (
"edion",
"4902370549263",
)
def test_split_item_url_rejects_foreign_host():
with pytest.raises(InvalidRequestError):
split_item_url("https://www.rakuten.co.jp/edion/4902370549263/")
def test_item_url_parts_returns_empty_for_ad_redirect_links():
"""广告位链接指向跳转域,解析不出来应静默返回空串而不是抛异常"""
assert item_url_parts("https://grp07.ias.rakuten.co.jp/redirect_rpp/?s=abc") == ("", "")
+101
View File
@@ -0,0 +1,101 @@
"""乐天商家(店铺)解析与请求转换测试"""
import json
from pathlib import Path
import pytest
from app.core.errors import InvalidRequestError, ScrapeParseError
from app.models.scrape import ShopItemsRequest, SortOption
from app.parsers.shop import parse_shop_detail
from app.utils.urls import build_search_url, build_shop_url, split_shop_url
FIXTURES = Path(__file__).parent / "fixtures"
@pytest.fixture
def shop_state() -> dict:
return json.loads((FIXTURES / "shop_state.json").read_text(encoding="utf-8"))
@pytest.fixture
def shop_html() -> str:
return (FIXTURES / "shop_page.html").read_text(encoding="utf-8")
# ---- 解析 ----
def test_parse_shop_detail_reads_state_and_logo(shop_state, shop_html):
detail = parse_shop_detail(shop_state, shop_code="edion", html=shop_html)
assert detail.shop_id == 272415
assert detail.shop_code == "edion"
assert detail.shop_name == "エディオン 楽天市場店"
assert detail.review_score == 4.51
assert detail.review_count == 128136
assert detail.review_displayed is True
assert detail.is_39_shop is True
assert detail.status == 1
assert detail.introduction
assert detail.signboard_url.startswith("https://")
# logo 不在 state 里,只能从页面的 ld+json 微数据取
assert detail.logo_url.startswith("https://thumbnail.image.rakuten.co.jp/")
def test_parse_shop_detail_normalizes_shop_url(shop_state):
"""state 里的 shopUrl 是 http 且不带尾斜杠,对外统一成规范形式"""
detail = parse_shop_detail(shop_state, shop_code="edion")
assert detail.shop_url == "https://www.rakuten.co.jp/edion/"
def test_parse_shop_detail_without_html_leaves_logo_empty(shop_state):
assert parse_shop_detail(shop_state, shop_code="edion").logo_url == ""
def test_parse_shop_detail_rejects_page_without_shop_node():
with pytest.raises(ScrapeParseError):
parse_shop_detail({"state": {}}, shop_code="edion")
# ---- URL ----
def test_shop_url_round_trip():
assert split_shop_url(build_shop_url("edion")) == "edion"
def test_split_shop_url_rejects_reserved_paths():
"""/category/ 之类的站点自有路径不是店铺页,别把 category 当成店铺代码"""
with pytest.raises(InvalidRequestError):
split_shop_url("https://www.rakuten.co.jp/category/101205/")
def test_split_shop_url_rejects_foreign_host():
with pytest.raises(InvalidRequestError):
split_shop_url("https://item.rakuten.co.jp/edion/123/")
# ---- 商家商品:转成限定店铺的搜索 ----
def test_shop_items_request_converts_to_shop_scoped_search():
payload = ShopItemsRequest(
shop_code="edion", page=2, keyword="テレビ", sort=SortOption.PRICE_ASC, max_price=50000
)
search = payload.to_search_request(272415)
assert search.shop_id == 272415
assert search.keyword == "テレビ"
assert search.page == 2
url = build_search_url(search)
assert "sid=272415" in url
assert "p=2" in url
assert "s=2" in url # price_asc
assert "max=50000" in url
def test_shop_items_request_needs_an_identifier():
with pytest.raises(ValueError):
ShopItemsRequest()
def test_search_accepts_shop_id_alone_as_target():
"""只按店铺取商品是合法查询:站点的 sid-only 搜索能正常返回"""
url = build_search_url(ShopItemsRequest(shop_id=272415).to_search_request(272415))
assert "sid=272415" in url
+280
View File
@@ -0,0 +1,280 @@
"""会话层测试:cookie 预热、失败升级、浏览器兜底降级
全部用 httpx.MockTransport 拦截不触达真实站点
"""
from __future__ import annotations
import httpx
import pytest
from app.core import site
from app.core.config import Settings
from app.core.errors import (
ItemNotFoundError,
OffIchibaRedirectError,
UpstreamBlockedError,
UpstreamRequestError,
)
from app.parsers.subsites import build_item_page_validator
from app.services.browser_fallback import BrowserVisit
from app.services.site_session import SiteSession
GOOD_PAGE = '<html><script>window.__INITIAL_STATE__ = {"ok":1};</script></html>'
BLOCK_PAGE = "<html><body>Access Denied. Reference #18.abc</body></html>"
TARGET = "https://search.rakuten.co.jp/search/mall/x/"
class FakeBrowser:
"""可控的浏览器兜底替身"""
def __init__(self, visit: BrowserVisit | None = None):
self.visit_result = visit
self.calls: list[tuple[str, bool]] = []
self.enabled = True
self.unavailable_reason = None if visit else "playwright is not installed"
self.ready = visit is not None
async def visit(self, url: str, *, mobile: bool) -> BrowserVisit | None:
self.calls.append((url, mobile))
return self.visit_result
async def close(self) -> None:
pass
def make_settings(**overrides) -> Settings:
base = {
"http_max_attempts": 3,
"request_timeout_seconds": 5.0,
"max_site_concurrency": 4,
"browser_fallback_enabled": True,
}
base.update(overrides)
return Settings(**base)
async def build_session(handler, *, browser=None, settings=None) -> SiteSession:
"""构建 SiteSession 并把两条通道的 client 换成 MockTransport 版本"""
session = SiteSession(settings or make_settings(), browser or FakeBrowser())
await session.start()
for profile in session._profiles.values():
await profile.client.aclose()
profile.client = httpx.AsyncClient(
headers=site.default_headers(mobile=profile.mobile),
transport=httpx.MockTransport(handler),
follow_redirects=True,
)
return session
async def test_warmup_happens_before_first_fetch_and_is_reused():
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(str(request.url))
headers = {}
if request.url.host == "www.rakuten.co.jp":
headers["set-cookie"] = "ak_bmsc=abc; Domain=.rakuten.co.jp; Path=/"
return httpx.Response(200, text=GOOD_PAGE, headers=headers)
session = await build_session(handler)
try:
await session.fetch_html(TARGET, mobile=False)
await session.fetch_html(TARGET, mobile=False)
finally:
await session.close()
# 首页预热只做一次,第二次抓取直接复用 cookie
assert seen.count("https://www.rakuten.co.jp/") == 1
assert seen.count(TARGET) == 2
async def test_missing_state_marker_is_treated_as_blocked():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text="<html>no state here</html>")
session = await build_session(handler)
try:
with pytest.raises(UpstreamBlockedError):
await session.fetch_html(TARGET, mobile=False)
finally:
await session.close()
async def test_retry_recovers_when_a_later_attempt_succeeds():
attempts = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
if request.url.host == "www.rakuten.co.jp":
return httpx.Response(200, text="home")
attempts["n"] += 1
if attempts["n"] == 1:
return httpx.Response(200, text=BLOCK_PAGE)
return httpx.Response(200, text=GOOD_PAGE)
session = await build_session(handler)
try:
assert await session.fetch_html(TARGET, mobile=False) == GOOD_PAGE
finally:
await session.close()
assert attempts["n"] == 2
async def test_browser_fallback_supplies_cookies_and_page_on_persistent_block():
"""浏览器已经取到页面时应直接采用,不再多打一次 HTTP"""
browser = FakeBrowser(
BrowserVisit(
cookies=[{"name": "bm_sv", "value": "xyz", "domain": ".rakuten.co.jp", "path": "/"}],
html=GOOD_PAGE,
)
)
def handler(request: httpx.Request) -> httpx.Response:
if request.url.host == "www.rakuten.co.jp":
return httpx.Response(200, text="home")
return httpx.Response(200, text=BLOCK_PAGE)
session = await build_session(handler, browser=browser)
try:
assert await session.fetch_html(TARGET, mobile=False) == GOOD_PAGE
finally:
cookie_names = {cookie.name for cookie in session._profiles["pc"].client.cookies.jar}
await session.close()
assert browser.calls == [(TARGET, False)]
assert "bm_sv" in cookie_names # cookie 已回灌,后续请求可复用
async def test_missing_playwright_degrades_to_blocked_error_not_crash():
browser = FakeBrowser(None) # 模拟未安装 playwright
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text=BLOCK_PAGE)
session = await build_session(handler, browser=browser)
try:
with pytest.raises(UpstreamBlockedError):
await session.fetch_html(TARGET, mobile=False)
finally:
await session.close()
assert browser.calls # 尝试过兜底
async def test_404_fails_fast_without_retrying():
attempts = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
if request.url.host == "www.rakuten.co.jp":
return httpx.Response(200, text="home")
attempts["n"] += 1
return httpx.Response(404, text="not found")
session = await build_session(handler)
try:
with pytest.raises(ItemNotFoundError):
await session.fetch_html("https://item.rakuten.co.jp/s/c/", mobile=True)
finally:
await session.close()
assert attempts["n"] == 1
async def test_unsupported_subsite_redirect_fails_fast_without_retrying():
"""跳到未登记的乐天子站时重试没有意义,应立刻以专门的错误返回"""
attempts = {"n": 0}
requested = "https://item.rakuten.co.jp/somewhere/1/"
def handler(request: httpx.Request) -> httpx.Response:
if request.url.host == "www.rakuten.co.jp":
return httpx.Response(200, text="home")
if request.url.host == "item.rakuten.co.jp":
attempts["n"] += 1
return httpx.Response(302, headers={"location": "https://unknown.rakuten.co.jp/x/1/"})
return httpx.Response(200, text="<html>unknown site</html>")
session = await build_session(handler)
try:
with pytest.raises(OffIchibaRedirectError) as exc:
await session.fetch(
requested, mobile=True, validator=build_item_page_validator(requested)
)
finally:
await session.close()
assert attempts["n"] == 1
assert exc.value.final_url == "https://unknown.rakuten.co.jp/x/1/"
async def test_known_subsite_redirect_is_accepted_and_reports_final_url():
"""已登记的子站应正常通过校验,并把落地地址回报给调用方用于分派"""
requested = "https://item.rakuten.co.jp/biccamera/1/"
landed = "https://biccamera.rakuten.co.jp/item/1/"
def handler(request: httpx.Request) -> httpx.Response:
if request.url.host == "www.rakuten.co.jp":
return httpx.Response(200, text="home")
if request.url.host == "item.rakuten.co.jp":
return httpx.Response(302, headers={"location": landed})
return httpx.Response(200, text='<html><script>window.__NUXT__={"state":{}}</script></html>')
session = await build_session(handler)
try:
page = await session.fetch(
requested, mobile=True, validator=build_item_page_validator(requested)
)
finally:
await session.close()
assert page.url == landed
assert "__NUXT__" in page.html
async def test_upstream_5xx_surfaces_as_request_error():
def handler(request: httpx.Request) -> httpx.Response:
if request.url.host == "www.rakuten.co.jp":
return httpx.Response(200, text="home")
return httpx.Response(503, text="service unavailable")
session = await build_session(handler)
try:
with pytest.raises(UpstreamRequestError):
await session.fetch_html(TARGET, mobile=False)
finally:
await session.close()
async def test_search_and_detail_use_separate_cookie_jars():
"""PC 与手机两条通道的 cookie 不能互相污染"""
def handler(request: httpx.Request) -> httpx.Response:
mobile = request.headers.get("sec-ch-ua-mobile") == "?1"
headers = {}
if request.url.host == "www.rakuten.co.jp":
headers["set-cookie"] = f"ak_bmsc={'sp' if mobile else 'pc'}; Domain=.rakuten.co.jp; Path=/"
return httpx.Response(200, text=GOOD_PAGE, headers=headers)
session = await build_session(handler)
try:
await session.fetch_html(TARGET, mobile=False)
await session.fetch_html("https://item.rakuten.co.jp/s/c/", mobile=True)
pc_cookie = session._profiles["pc"].client.cookies.get("ak_bmsc")
sp_cookie = session._profiles["sp"].client.cookies.get("ak_bmsc")
finally:
await session.close()
assert pc_cookie == "pc"
assert sp_cookie == "sp"
async def test_profile_status_reports_warmup_state():
def handler(request: httpx.Request) -> httpx.Response:
headers = {"set-cookie": "ak_bmsc=abc; Domain=.rakuten.co.jp; Path=/"}
return httpx.Response(200, text=GOOD_PAGE, headers=headers)
session = await build_session(handler)
try:
assert session.profile_status()["pc"]["warmed"] is False
await session.fetch_html(TARGET, mobile=False)
status = session.profile_status()
assert status["pc"]["warmed"] is True
assert "ak_bmsc" in status["pc"]["cookies"]
assert status["sp"]["warmed"] is False
finally:
await session.close()
+290
View File
@@ -0,0 +1,290 @@
"""子站商品页解析测试:基于真实页面样本
夹具说明books 的解析依赖 DOM 结构保留了完整真实页面brandavenue
biccamera 只读页面内联的 JSON夹具做了裁剪以免把上百 KB 的噪音带进仓库
"""
from pathlib import Path
import pytest
from app.core.errors import OffIchibaRedirectError, ScrapeParseError
from app.parsers.subsites import (
SUBSITE_PARSERS,
build_item_page_validator,
host_of,
parse_subsite_item,
)
from app.parsers.subsites.base import SubsitePage, looks_sold_out, parse_price
FIXTURES = Path(__file__).parent / "fixtures"
def make_page(filename: str, *, host: str, shop_code: str, item_code: str, variants: bool = True):
return SubsitePage(
html=(FIXTURES / filename).read_text(encoding="utf-8"),
requested_url=f"https://item.rakuten.co.jp/{shop_code}/{item_code}/",
final_url=f"https://{host}/item/{item_code}/",
shop_code=shop_code,
item_code=item_code,
include_sku_variants=variants,
)
@pytest.fixture
def books_page():
return make_page("books_item.html", host="books.rakuten.co.jp", shop_code="book", item_code="17065211")
@pytest.fixture
def brandavenue_page():
return make_page(
"brandavenue_item.html", host="brandavenue.rakuten.co.jp", shop_code="stylife", item_code="md7606"
)
@pytest.fixture
def biccamera_page():
return make_page(
"biccamera_item.html", host="biccamera.rakuten.co.jp", shop_code="biccamera", item_code="4548736147041"
)
# ---- 通用工具 ----
@pytest.mark.parametrize(
("raw", "expected"),
[("3,091円", 3091), ("1760", 1760), (1760, 1760), ("", 0), (None, 0), ("価格未定", 0)],
)
def test_parse_price_handles_site_formats(raw, expected):
assert parse_price(raw) == expected
@pytest.mark.parametrize(
("status", "expected"),
[("在庫あり", False), ("在庫なし", True), ("品切れ", True), ("販売終了", True), ("", False)],
)
def test_looks_sold_out_reads_japanese_stock_wording(status, expected):
assert looks_sold_out(status) is expected
# ---- 分派 ----
def test_registry_covers_the_three_known_subsites():
assert set(SUBSITE_PARSERS) == {
"books.rakuten.co.jp",
"brandavenue.rakuten.co.jp",
"biccamera.rakuten.co.jp",
}
def test_validator_accepts_ichiba_page_with_state():
validate = build_item_page_validator("https://item.rakuten.co.jp/s/c/")
ok = validate("window.__INITIAL_STATE__ = {}", "https://item.rakuten.co.jp/s/c/")
assert ok is None
def test_validator_rejects_ichiba_page_without_state_as_retryable():
validate = build_item_page_validator("https://item.rakuten.co.jp/s/c/")
reason = validate("<html>blocked</html>", "https://item.rakuten.co.jp/s/c/")
assert reason and "INITIAL_STATE" in reason
def test_validator_raises_for_unregistered_subsite():
"""未登记的站点是「不支持」而非「抓取失败」,不能走重试"""
validate = build_item_page_validator("https://item.rakuten.co.jp/s/c/")
with pytest.raises(OffIchibaRedirectError):
validate("<html>whatever</html>", "https://unknown.rakuten.co.jp/x/")
def test_validator_delegates_to_subsite_rules(books_page):
validate = build_item_page_validator(books_page.requested_url)
assert validate(books_page.html, books_page.final_url) is None
assert validate("<html>empty</html>", books_page.final_url) is not None
def test_parse_subsite_item_rejects_unknown_host(books_page):
books_page.final_url = "https://unknown.rakuten.co.jp/x/"
with pytest.raises(ScrapeParseError):
parse_subsite_item(books_page)
def test_host_of_extracts_hostname():
assert host_of("https://books.rakuten.co.jp/rb/1/?a=b") == "books.rakuten.co.jp"
# ---- 楽天ブックス ----
def test_books_extracts_core_fields(books_page):
d = parse_subsite_item(books_page)
assert d.source == "books"
assert d.source_url == books_page.final_url
assert d.item_url == books_page.requested_url
assert d.item_name == "12歳までに知っておきたい語彙力図鑑"
assert d.price == 1760
assert d.shop.shop_code == "book"
assert d.shop.shop_name == "楽天ブックス"
def test_books_maps_review_and_stock(books_page):
d = parse_subsite_item(books_page)
assert d.review.score == pytest.approx(4.54)
assert d.review.count == 218
assert d.purchase_condition == "在庫あり"
assert d.is_sold_out is False
def test_books_uses_rms_genre_ids_for_breadcrumbs(books_page):
"""站内分类 ID 对上游没用,要取 data_genres 里的 rmsGenreId(市场侧 genre_id)"""
d = parse_subsite_item(books_page)
assert [b.name for b in d.breadcrumbs] == ["", "絵本・児童書・図鑑", "図鑑・ちしき"]
assert d.genre_id == "208880"
assert d.breadcrumbs[0].url == "https://www.rakuten.co.jp/category/200162/"
def test_books_spec_table_becomes_sku_attributes(books_page):
d = parse_subsite_item(books_page)
spec = {a.title: a.value for a in d.sku.attributes}
assert spec["ISBN"] == "9784800590039"
assert spec["出版社"] == "日本能率協会マネジメントセンター"
assert "齋藤 孝" in spec["著者/編集"]
def test_books_description_collects_flat_sibling_sections(books_page):
"""标题与正文是兄弟节点而非父子,按容器取会漏掉绝大部分内容"""
d = parse_subsite_item(books_page)
assert len(d.description) > 1000
assert "内容紹介(JPROより)" in d.description
assert "目次" in d.description
def test_books_images_are_absolute_and_deduplicated(books_page):
d = parse_subsite_item(books_page)
assert d.images
assert all(url.startswith("https://") for url in d.images)
assert len(d.images) == len(set(d.images))
def test_books_has_no_sku_variants(books_page):
"""图书没有规格轴,SKU 只承载规格表"""
d = parse_subsite_item(books_page)
assert d.sku.inventory_type == "single"
assert d.sku.variant_count == 0
assert d.sku.axis == []
# ---- Rakuten Fashion ----
def test_brandavenue_extracts_core_fields(brandavenue_page):
d = parse_subsite_item(brandavenue_page)
assert d.source == "brandavenue"
assert d.item_name == "プリント ロゴ 刺繍 エンブロイダリー 半袖 Tシャツ"
assert d.price == 3091
assert d.catch_copy == "andme" # 品牌名
assert d.is_sold_out is False
def test_brandavenue_reads_ichiba_ids_from_rms_info(brandavenue_page):
"""市场侧的商品 ID 与 genre_id 藏在 rms_info 里,不是站内编码"""
d = parse_subsite_item(brandavenue_page)
assert d.item_id == "14674748"
assert d.genre_id == "303656"
assert d.shop.shop_id == 279405
assert d.shop.shop_code == "stylife"
def test_brandavenue_builds_color_and_size_axes(brandavenue_page):
d = parse_subsite_item(brandavenue_page)
assert [a.label for a in d.sku.axis] == ["カラー", "サイズ"]
assert d.sku.inventory_type == "multiple"
assert d.sku.variant_count == len(d.sku.variants) > 1
def test_brandavenue_variant_joins_stock_from_inventory_list(brandavenue_page):
"""售价来自 product_sku,库存与 variant_id 来自 rms_info.inventory_list,按尺码+颜色对齐"""
d = parse_subsite_item(brandavenue_page)
by_selector = {tuple(v.selector_values): v for v in d.sku.variants}
variant = by_selector[("オフホワイト×レッド", "Sサイズ")]
assert variant.price == 2810
assert variant.quantity == 90
assert variant.variant_id
def test_brandavenue_variant_without_matching_inventory_stays_purchasable(brandavenue_page):
"""站点两侧的颜色命名偶有出入导致对不上;此时库存未知,但不能据此判成售罄"""
import json
import re
html = brandavenue_page.html
state = json.loads(re.search(r"__INITIAL_STATE__ = (\{.*?\});window", html, re.S).group(1))
state["itemDetail"]["data"]["product"]["rms_info"]["inventory_list"] = []
brandavenue_page.html = re.sub(
r"__INITIAL_STATE__ = \{.*?\};window",
lambda _: f"__INITIAL_STATE__ = {json.dumps(state, ensure_ascii=False)};window",
html,
flags=re.S,
)
d = parse_subsite_item(brandavenue_page)
variant = d.sku.variants[0]
assert variant.quantity == 0
assert variant.variant_id == ""
assert variant.is_sold_out is False # 以 inventory_exist_flg 为准
def test_brandavenue_can_omit_variants_but_keeps_count(brandavenue_page):
brandavenue_page.include_sku_variants = False
d = parse_subsite_item(brandavenue_page)
assert d.sku.variants == []
assert d.sku.variant_count > 0
assert d.sku.axis
def test_brandavenue_takes_images_rendered_in_page(brandavenue_page):
"""图片按商品编号做了目录分片,分片规则不外露,只能取页面已渲染的地址"""
d = parse_subsite_item(brandavenue_page)
assert d.images
assert all("stylife/cabinet/item" in url for url in d.images)
# 主图应排在最前
assert d.images[0].lower().endswith("md7606-13_1.jpg")
def test_brandavenue_breadcrumbs_have_names_without_urls(brandavenue_page):
"""站内分类编码不是市场 genre_id,拼不出可用链接,只给名称"""
d = parse_subsite_item(brandavenue_page)
assert [b.name for b in d.breadcrumbs] == ["トップス", "カットソー・Tシャツ"]
assert all(b.url == "" for b in d.breadcrumbs)
# ---- ビックカメラ ----
def test_biccamera_extracts_core_fields(biccamera_page):
d = parse_subsite_item(biccamera_page)
assert d.source == "biccamera"
assert d.item_name.startswith("SONY")
assert d.price == 73760
assert d.item_id == "13873289"
assert d.item_code == "4548736147041"
assert d.shop.shop_id == 269553
def test_biccamera_maps_inventory_and_delivery(biccamera_page):
d = parse_subsite_item(biccamera_page)
assert d.is_sold_out is False
assert d.purchase_condition == "enabled"
assert d.sku.quantity == 28
assert d.sku.show_inventory is True
assert "出荷予定" in d.shipping.delivery_message
assert d.shipping.is_shipping_free is True
def test_biccamera_breadcrumbs_use_ichiba_genre_ids(biccamera_page):
d = parse_subsite_item(biccamera_page)
assert [b.name for b in d.breadcrumbs][0] == "TV・オーディオ・カメラ"
assert d.breadcrumbs[0].url == "https://www.rakuten.co.jp/category/211742/"
assert d.genre_id == "110110"
def test_biccamera_rejects_page_without_nuxt_state(biccamera_page):
biccamera_page.html = "<html><script>window.__NUXT__={\"state\":{}}</script></html>"
with pytest.raises(ScrapeParseError):
parse_subsite_item(biccamera_page)
Generated
+781
View File
@@ -0,0 +1,781 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320" },
]
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0" },
]
[[package]]
name = "anyio"
version = "4.14.2"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775" },
]
[[package]]
name = "click"
version = "8.4.2"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" },
]
[[package]]
name = "fastapi"
version = "0.140.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0d/fb/fd7671137d9fa3df1d93a2f5111eb982709201724b29f211e4beb2d58688/fastapi-0.140.0.tar.gz", hash = "sha256:f338951b82fd74ca8f843163aec43ea1a1ce84d515415a50fa98fa25572a5544" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/eb/76/6d9e25ad88da9d3ff744bcdbec4736e38c2288611d43f673a5d9bfa27c07/fastapi-0.140.0-py3-none-any.whl", hash = "sha256:e951c0a0d9540bf5d9a2a9e078fd415da2ab7e312d435139e7d9e2e7fe9f0b23" },
]
[[package]]
name = "greenlet"
version = "3.5.4"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/1b/80/fb4d4788bbc8e54761f1fc88533af9523a6e86299fa113d6e8a8503ed9fc/greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/42/e3/6086fa578ebb72772722cdc4bcd628459814b42e0c2db1e3cbd6552b3271/greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/9c/bf/250c2921c7b585dde12f5239e313ca2dcbc464d161ecca36e4e6ef21762d/greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/18/40/10bfcf6513558d82f7b95dd728001c63bd388259fe27d3e30ae01f103430/greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ae/db/24a10af12bf8e639cec46c38b9ce1a282543ba42ff4fb0b31a970f1ab603/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f4/60/44a2eca7b9fd71ae0fae7ff184da1cd3169d176652b97aa1cffcbb0ef961/greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/51/a7/dafc7415d430b0a43a16396eb49ecb3b62fd720877fb259cc4dcfaf5f31e/greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/2e/d9/6298f3432de301d4718766cf934bd73c418c73f81fbb77247319364b0d96/greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/bb/08/9dd4ae635da93d41dc268bc34bd62a9d711ed8b8825c5d22ac910c7d6e6d/greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/88/15/0b167aeea95285b0e654ddce651922f666c089363c2ec528ca8b9a9ba74f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/de/90/c023ec337f32ff505be7db759c80d98f0532bb94d0c6fa13645efe9bee2e/greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" },
]
[[package]]
name = "h2"
version = "4.4.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/30/d4/a7d6fb3f58be99d65cbf2d3f766896217a2921d0f3ab10711c45dc1519ee/h2-4.4.0.tar.gz", hash = "sha256:46b551bdcdc7e83cf5c04d0bf93badb8a939bd2287d9fee1abb23a445b9e0580" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f6/df/5b14a118322d6097cb9bb30ec6bacad268e546a8ecfcb1f6d0de618dac2f/h2-4.4.0-py3-none-any.whl", hash = "sha256:6acffe1aeab79098d7eb0f8385c1add11f2c7a94815f6fa2b7060eeddee3d87c" },
]
[[package]]
name = "hpack"
version = "4.2.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" },
]
[[package]]
name = "httptools"
version = "0.8.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" },
]
[package.optional-dependencies]
http2 = [
{ name = "h2" },
]
[[package]]
name = "hyperframe"
version = "6.1.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" },
]
[[package]]
name = "loguru"
version = "0.7.3"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" },
]
[[package]]
name = "playwright"
version = "1.61.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/44/ee/31e4e0db36588b817a10b299a0285082545fde7d36543c2abe498bb3d61a/playwright-1.61.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:ff138c3a604f69911e9d42fd036e55c2a171e5616edf04c1e7f60a2a285540b0" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/42/35/71395dd3ecc798965be4a3ef8c443217d4abca168e7cb34536304f9489e6/playwright-1.61.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:009588c2a7e499bc5a8b425b61fa65490968bbda9cd69e0cf2cff10f8304659a" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f4/44/323164cf5cd1647bdefce76ffce27651aadb959d089b48f53ea40918276e/playwright-1.61.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:9f7de4536088d12037c13a52b7ea34b59270b78926bb56935070597ffac6b1af" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ab/f8/a35bf179e4ba2522c1893635094a64e407572547bd61528820fc0abc87fe/playwright-1.61.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:54f3b39f6eab832e33458c1dd7da0b5682aedab3b09ae731b5c59fa12fd2024e" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b7/eb/e3f922348ec17c315f98c463f72faa1181a1c3de0bfe31a8d2edf6561723/playwright-1.61.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93454322ade8c11d5d6c211bfd91bdfb9ffb4810e3e026371bcbc4bec1b7ee4c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/c2/a6/5be4e52b40a9c0c8a073e7c5b0785c05cf5a9ea8f8a7b5b260e32d970342/playwright-1.61.0-py3-none-win32.whl", hash = "sha256:372d55a6f1248fa1dd47599686980cb8fb5bbe6fcda59eab793eb657c11d8a9b" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/6c/fd/2b78036e5fbe9d5f5645bbe08a1eac7160c51243c0093963edbcf67c35d9/playwright-1.61.0-py3-none-win_amd64.whl", hash = "sha256:35c6cc4589a5d00964a59d7b3e59641e0aac0c02f15479a7af77d20f6bc79597" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/27/0d/1b0f3c4ee4eb0514bc805b5c2f9a223e5b6de4f11a926f5235d51d0fc81b/playwright-1.61.0-py3-none-win_arm64.whl", hash = "sha256:e9fcbffcf557a8620fdedd92491eb59a32d18e23d6f3b4f6214b952be324fe51" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596" },
]
[[package]]
name = "pydantic-settings"
version = "2.14.2"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440" },
]
[[package]]
name = "pyee"
version = "13.0.1"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" },
]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79" },
]
[[package]]
name = "pytest-asyncio"
version = "0.26.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/8e/c4/453c52c659521066969523e87d85d54139bbd17b78f09532fb8eb8cdb58e/pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b" },
]
[[package]]
name = "rakuten-scraper-service"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "fastapi" },
{ name = "httpx", extra = ["http2"] },
{ name = "loguru" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "selectolax" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.optional-dependencies]
browser = [
{ name = "playwright" },
]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
{ name = "httpx", extras = ["http2"], specifier = ">=0.27.0,<1.0.0" },
{ name = "loguru", specifier = ">=0.7.0,<1.0.0" },
{ name = "playwright", marker = "extra == 'browser'", specifier = ">=1.47.0,<2.0.0" },
{ name = "pydantic", specifier = ">=2.0.0,<3.0.0" },
{ name = "pydantic-settings", specifier = ">=2.4.0,<3.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0,<9.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0,<1.0.0" },
{ name = "selectolax", specifier = ">=0.3.21,<1.0.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<1.0.0" },
]
provides-extras = ["browser", "dev"]
[[package]]
name = "selectolax"
version = "0.4.11"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/39/6c/aec38dfee314a38cb7c0940fe055b22f22627b3e0a216772c24372eef3a9/selectolax-0.4.11.tar.gz", hash = "sha256:2b565ddabce6c9a7b73fa28a39acf8f411a084fa2f169234ec2470f552d4421d" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/4e/b5/6e0653d45b8d138b3fc37b37780b989761fb486e7c002aa413eb89d3ad64/selectolax-0.4.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5153157ed60d968ed303acbfd2c8762fa0c0462e2663bd04466471c565deb88a" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/53/c5/c367cf0583799d8c32555c4fa3b900b1e8de1aef07fb009c488a615b6ed0/selectolax-0.4.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:386494779e5464e587ed4dc076e1c48c24ebaf2da1e3a249690551d1f97fe8ed" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a7/61/956974dc429e3df99814d1ba5629a324eef366e2116b030fdd5354713402/selectolax-0.4.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47be0e591125484de14ff0c9aaaa814dd4a4019de35eabe360e88169a263a2b5" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/51/f6/626716e2730f396bd81b853b37e9eeddd3a847730efff7548ad6d695c6e8/selectolax-0.4.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8f014f328f6f79a364293bca54d43af1cec776dc10a5302054a54b5fb2d8675" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/48/f6/acb03eb9e468f74fab17c655761179022fed57bfb1b25ff741e8c0c6a06c/selectolax-0.4.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3f6fac361b5f415c409dfd1a07dd0e9a5899d10daba8d88ce16bd552b0e06f2" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/5a/08/e242e5785e049499771ac5e560112396d244e6142348eaf1c70849f83a66/selectolax-0.4.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8121f4cbfe870d9ad24ae418a735d918d55844e905c3270077e97f4e579770f" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/15/5d/b5dfbde64d622cc94d2136edb0caaafb5779ebbd884f6ae9c041d8dfa669/selectolax-0.4.11-cp313-cp313-win32.whl", hash = "sha256:bde121202b33e6041e9d2db1d62e7466b5883fd1c441eb96ff68d3ea015cfcbd" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/6e/90/2888c831ebd473b6c17486d805a16925187c743964bbf895ec421c1cf2ab/selectolax-0.4.11-cp313-cp313-win_amd64.whl", hash = "sha256:5c7a91fbe1a94849d85228897c416ab9b4518bea6b04dce8ef8acd825ec80e9d" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/83/ea/e78be8710bf162b43d6336ee354fbe21ea712284bd0bf58c67e15264862d/selectolax-0.4.11-cp313-cp313-win_arm64.whl", hash = "sha256:597b8e065978be200c598ae6d682496d96fbce14d34b5d519e93cf5b6be5fb60" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/08/5a/ba94f50ca5a6a0af65e8d47147bbe9f6ad11c408fd03c832ea737836d3eb/selectolax-0.4.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:663ff792f92ed749cfcf452ac19aff5da74b05521e7daacb3b74388deb14d117" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/12/fe/f4d7d554cd7db415c831c8fb5a2b6bbbe3bdf5a49c8f417a6093d4618d6c/selectolax-0.4.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d5ce592a92fceeca2694b369a83ad72891a9c356f668718fe7e1c83eea407bb4" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/96/d6/9d702075634c1a38517a8af4242346bf0e65f206703037b56cf8da114eec/selectolax-0.4.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0f56c49161b18621ac452e42e02b0c5c61ba4c21095cfff3990e040bd9a043c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/84/c3/f541806ec7bdd0ce8ec69351572d2f2b3919264818cd5bb792482684d492/selectolax-0.4.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:224682039ca13eb822be626e49a03592ee2b8557bcdc6381e49417a995170c94" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/70/81/533fa254be8e63b1c0fbe261ba4e2c1ca86357a4844b0830a0d7ae0985f9/selectolax-0.4.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bd843540a197a33049a08fd80e59bfeafbaa688e632d53a05a9b65af5e88296f" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/25/5a/3fc3de5bfdc70af07d55bdc17837b5fd4ae6229444868f057085addd9a18/selectolax-0.4.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2b842c829f916fecb51f0f55882eca3e2ad49e85388178f14ae6fe0912be0a57" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f2/42/62c66067cbd3c360f762ac6964793091ea0371b3527ca2bf90955fb0b6f3/selectolax-0.4.11-cp314-cp314-win32.whl", hash = "sha256:d33e2ed75cc33e7af3fd50521c33e7d8634fae23bc197a6cee6a5015e056eef6" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/14/b5/6d9ed39e909752645798c1469fb9443c0880ede999e63241ee89e91c7a54/selectolax-0.4.11-cp314-cp314-win_amd64.whl", hash = "sha256:e5929cbe3eedfaf51a09ec89642ab5355b703486d43bcf3c8f0c27d6043a488d" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/49/f9/f172cfe8c29e295b9d7bc79e5b071937470f74311cd04dc3090d4166520a/selectolax-0.4.11-cp314-cp314-win_arm64.whl", hash = "sha256:466daca0599408c9d2cad7658a68490facc5c9b8d0f41ac5d17948914f57306f" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/97/e9/6289d23fa4e5ccd5570a31c9180616a2e3c87ec565f7887bcfbca6204b6d/selectolax-0.4.11-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:086ca6f7e4c475bfff871ec1448ae5d342d43d6a2ca2cea65160d01b3a6a75ec" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/06/c4/1fbf3624f9e52dadda8471dfb68eaf6021e819b827cdb62ce878fa28f469/selectolax-0.4.11-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b530a2c4fad7400af27b2b7e0333c1318ecb5f5dc38e8a141dbe3bd81b398fdf" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b6/ba/25710a259ecb2b66b9168956b768a2651533c8ea813da9decb0e0f3ee39a/selectolax-0.4.11-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3637d21f7fe60fbd6ca3dbc67a1747f6a55a9389114d72f06b5d69ba2beddf01" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/bc/73/331f83e64e3a17478e832308248345d5224957eb7a62dad2e7fc5daa15b3/selectolax-0.4.11-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fad5b1065f73eeaa07ea343cbc548aaa9f9a5c359c3bdd8d98f5d80b61550d1c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d0/33/ab29a558dc65d3a1e28c217b62605b5135123ad89f1f825c8b741366e0fc/selectolax-0.4.11-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1da54e42ab99b9191269306e13c0fd67ada1c6654e8dc8d74fac615931dd3c62" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ae/b6/e774ec9179d7524adf47d7187b3e4e630104e149b2fbcbfe06088a3f4847/selectolax-0.4.11-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:28915b8fa90c1c3cb585858a6d24d433a3f38ea514aea59013bdb0930d9f6025" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/97/14/0b4865125e777c9d852c9e388c1165e2ef4d7f1fb46596b13a1c02153fe7/selectolax-0.4.11-cp314-cp314t-win32.whl", hash = "sha256:1a6deb4464198ac67f32e56c4463aedf3e1d834b458eaac5b5b5b1ef02dcf15e" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/40/1a/88db3237f2fb357119164c4f5a33a659615e3d10dd0f773d092341ee0cc4/selectolax-0.4.11-cp314-cp314t-win_amd64.whl", hash = "sha256:41f388c26304c1d840f5ee5e07c06bb9388ec834d10fec60dc148f22f98efd38" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/37/03/193913c0f3d37c1e8d66ebfa0f139b2f286f70ec285907aa98b44a620447/selectolax-0.4.11-cp314-cp314t-win_arm64.whl", hash = "sha256:9077fa36e99ef4bb801194ff8f492f67279c0562e7cdfa9b4d06f5c010131969" },
]
[[package]]
name = "starlette"
version = "1.3.1"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7" },
]
[[package]]
name = "uvicorn"
version = "0.51.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b" },
]
[package.optional-dependencies]
standard = [
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e" },
]
[[package]]
name = "watchfiles"
version = "1.2.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377" },
]
[[package]]
name = "websockets"
version = "16.1.1"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3" },
]
[[package]]
name = "win32-setctime"
version = "1.2.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390" },
]