"""抓取侧 API 数据模型:请求体和响应体定义 字段命名贴合乐天站点自身的语义(item_code / shop_code / genre_id / sku 等), 不做跨站点的字段名归一,避免解析层与对外契约之间反复翻译。 响应信封 `ApiResponse` 在 app/shared/api.py,与交易侧共用;登录态、订单等需要 账号的模型在 app/trading/models.py,不在这里。 """ from __future__ import annotations from enum import StrEnum from typing import Any from pydantic import BaseModel, Field, HttpUrl, model_validator 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 = Field(default="", description="搜索关键词") page: int = Field(default=1, ge=1, le=150, description="页码,站点侧最多约 150 页(subset 6750 / 45)") sort: SortOption = Field(default=SortOption.STANDARD, description="排序方式,对应搜索页 `s=` 参数") genre_id: str | None = Field(default=None, description="乐天分类 ID,如 565950") min_price: int | None = Field(default=None, ge=0, description="最低价格(日元)") max_price: int | None = Field(default=None, ge=0, description="最高价格(日元)") shop_id: int | None = Field(default=None, description="限定店铺(对应 `sid` 参数,取搜索结果的 shop.shop_id)") exclude_keyword: str | None = Field(default=None, description="排除词(`nitem`)") title_only: bool = Field(default=False, description="仅在商品标题中匹配(`sf=1`)") or_query: bool = Field(default=False, description="关键词之间用 OR 而非 AND(`st=O`)") min_review_score: int | None = Field(default=None, ge=1, le=5, description="最低评分") condition: ItemCondition | None = Field(default=None, description="商品成色筛选:新品 / 中古 / 租赁") include_sold_out: bool = Field(default=False, description="包含售罄商品") free_shipping: bool = Field(default=False, description="仅免运费") has_review: bool = Field(default=False, description="仅有评论") next_day_delivery: bool = Field(default=False, description="仅次日达") super_deal: bool = Field(default=False, description="仅 SuperDEAL") tags: list[str] = Field(default_factory=list, description="站点标签 ID(`tg`)") search_url: HttpUrl | None = Field( default=None, description="直接透传一条乐天搜索页 URL,服务端原样抓取;此时除 page 与 exclude_ads 外的筛选字段全部忽略", ) exclude_ads: bool = Field(default=True, description="剔除搜索结果中混入的 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 = Field(default=None, description="店铺代码,如 edion") shop_url: HttpUrl | None = Field(default=None, description="店铺页 URL;与 shop_code 二选一") @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 = Field(default=None, description="店铺 ID,取自搜索结果或商家详情的 shop.shop_id") shop_code: str | None = Field(default=None, description="店铺代码,如 edion") keyword: str = Field(default="", description="在店铺内按关键词过滤") page: int = Field(default=1, ge=1, le=150, description="页码") sort: SortOption = Field(default=SortOption.STANDARD, description="排序方式") genre_id: str | None = Field(default=None, description="乐天分类 ID,如 565950") min_price: int | None = Field(default=None, ge=0, description="最低价格(日元)") max_price: int | None = Field(default=None, ge=0, description="最高价格(日元)") condition: ItemCondition | None = Field(default=None, description="商品成色筛选") include_sold_out: bool = Field(default=False, description="包含售罄商品") free_shipping: bool = Field(default=False, description="仅免运费") exclude_ads: bool = Field(default=True, description="剔除搜索结果中混入的 CPC 广告位") @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 = Field(default=None, description="店铺 ID") shop_code: str = Field(default="", description="店铺代码,如 edion") shop_name: str = Field(default="", description="店铺名") shop_url: str = Field(default="", description="店铺页地址") introduction: str = Field(default="", description="店铺简介") signboard_url: str = Field(default="", description="店铺招牌图地址") logo_url: str = Field(default="", description="店铺 Logo 图地址") review_score: float = Field(default=0.0, description="综合评分") review_count: int = Field(default=0, description="评价数") # 站点在评价数过少时不展示评分;此时 review_score 不可信 review_displayed: bool = Field(default=False, description="站点是否展示了评分;未展示时 review_score 不可信") is_39_shop: bool = Field(default=False, description="39ショップ(满 3980 日元免运费)") age_verification_required: bool = Field(default=False, description="购买该店铺商品需要年龄确认") status: int | None = Field(default=None, description="站点店铺状态码,1 = 营业中") holidays: list[str] = Field(default_factory=list, description="店铺休息日") class ItemDetailRequest(BaseModel): """商品详情请求参数:传 shop_code + item_code,或直接传商品页 URL""" shop_code: str | None = Field(default=None, description="店铺代码,如 edion(商品 URL 的第一段)") item_code: str | None = Field(default=None, description="店铺内商品编号,如 4902370549263(商品 URL 的第二段)") item_url: HttpUrl | None = Field(default=None, description="商品页 URL;不传时需同时提供 shop_code 与 item_code") include_sku_variants: bool = Field( default=True, description="是否返回 SKU 组合明细;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 = Field( default=None, description="分类 ID。不传返回 39 个顶层分类;传入返回该分类的信息、祖先路径与直接子分类", ) class GenreNode(BaseModel): """分类树上的一个节点""" genre_id: str = Field(default="", description="分类 ID") name: str = Field(default="", description="分类名") # 该分类下的商品数。顶层列表不返回该值:站点给出的是「当前查询在该分类下的 # 命中数」,与分类自身的商品总量不是一回事,避免误用。 item_count: int | None = Field(default=None, description="该分类下的商品数;顶层列表不返回该值") shortcut: str = Field(default="", description="站点分类短代码,如 game / flower") is_leaf: bool = Field(default=False, description="叶子分类,没有下级") url: str = Field(default="", description="分类页地址") class GenreData(BaseModel): """分类查询结果""" genre_id: str = Field(default="", description="分类 ID,空串表示顶层") name: str = Field(default="", description="分类名") full_name: str = Field(default="", description="站点给出的完整分类名,仅分类页有") description: str = Field(default="", description="站点分类描述,仅分类页有") is_leaf: bool = Field(default=False, description="叶子分类,没有下级") url: str = Field(default="", description="分类页地址") ancestors: list[GenreNode] = Field(default_factory=list, description="祖先分类路径,从顶层到父级,不含自身") children: list[GenreNode] = Field(default_factory=list, description="直接子分类") class ShopSummary(BaseModel): """店铺信息""" shop_id: int | None = Field(default=None, description="店铺 ID") shop_code: str = Field(default="", description="店铺 URL 代码,如 edion;与 item_code 一起可定位商品") shop_name: str = Field(default="", description="店铺名") shop_url: str = Field(default="", description="店铺页地址") review_score: float = Field(default=0.0, description="综合评分") review_count: int = Field(default=0, description="评价数") class ReviewSummary(BaseModel): """评价信息""" score: float = Field(default=0.0, description="评分") count: int = Field(default=0, description="评价数") url: str = Field(default="", description="评价页地址") class SearchItem(BaseModel): """搜索结果中的单个商品""" item_id: str = Field(default="", description="乐天内部商品 ID(搜索结果的 code 字段)") item_code: str = Field(default="", description="商品 URL 第二段,调详情接口用") item_name: str = Field(default="", description="商品名") item_url: str = Field(default="", description="真实商品页地址;广告位已还原为 originalItemUrl") catch_copy: str = Field(default="", description="商品副标题") price: int = Field(default=0, description="价格(日元)") price_range: str = Field(default="", description="多 SKU 时的价格区间,如「1000~2000」") has_price_range: bool = Field(default=False, description="是否为多 SKU 价格区间") image_url: str = Field(default="", description="主图地址") image_urls: list[str] = Field(default_factory=list, description="图片地址列表") shop: ShopSummary = Field(default_factory=ShopSummary, description="所属店铺信息") review: ReviewSummary = Field(default_factory=ReviewSummary, description="商品评价信息") genre_id: str = Field(default="", description="分类 ID") genre_path: str = Field(default="", description="分类路径,形如 /0/101205/565950/566404") genre_names: list[str] = Field(default_factory=list, description="分类名列表") shipping_fee: int | None = Field(default=None, description="运费(日元);站点未给出时为 null") delivery_message: str = Field(default="", description="配送说明") point_count: int = Field(default=0, description="乐天积分倍率(站点 point.count 原值)") is_sold_out: bool = Field(default=False, description="已售罄") is_ad: bool = Field(default=False, description="CPC 广告位") has_multi_sku: bool = Field(default=False, description="有多个 SKU") variant_id: str = Field(default="", description="SKU 组合 ID(站点 variantId 原值)") item_options: dict[str, Any] = Field(default_factory=dict, description="站点 itemOptions 原样透出") class SearchResultData(BaseModel): """搜索结果数据""" keyword: str = Field(default="", description="搜索关键词") page: int = Field(default=1, description="当前页码") page_size: int = Field(default=0, description="每页条数") total_count: int = Field(default=0, description="站点声明的命中总数") reachable_count: int = Field(default=0, description="实际可翻页取到的上限(站点 subset,随查询条件变化)") has_more: bool = Field(default=False, description="是否还有下一页") # 请求页码超出 reachable_count 对应的页数。站点此时不会返回空列表,而是 # 静默回绕到第 1 页;这里识别出来并把 items 置空,避免上游把重复数据当新数据。 out_of_range: bool = Field( default=False, description="请求页码超出可翻页范围;此时 items 置空(站点会静默回绕到第 1 页,已识别)" ) ad_count: int = Field(default=0, description="本页被识别出的广告位数量(exclude_ads=true 时已从 items 剔除)") request_url: str = Field(default="", description="实际抓取的乐天页面地址,便于排查") items: list[SearchItem] = Field(default_factory=list, description="本页商品列表") class SkuAttribute(BaseModel): """SKU 属性项""" title: str = Field(default="", description="属性名") value: str = Field(default="", description="属性值") class SkuAxisValue(BaseModel): """SKU 选择轴上的一个取值""" value: str = Field(default="", description="取值原值") label: str = Field(default="", description="取值展示名") is_sold_out: bool = Field(default=False, description="该取值已售罄") class SkuAxis(BaseModel): """SKU 选择轴,如「颜色」「尺码」""" key: str = Field(default="", description="轴标识") label: str = Field(default="", description="轴展示名") values: list[SkuAxisValue] = Field(default_factory=list, description="可选取值列表") class SkuVariant(BaseModel): """一个具体的 SKU 组合""" variant_id: str = Field(default="", description="SKU 组合 ID") selector_values: list[str] = Field(default_factory=list, description="与 axis 顺序对应的取值") price: int = Field(default=0, description="价格(日元)") quantity: int = Field(default=0, description="库存数量") is_sold_out: bool = Field(default=False, description="已售罄") delivery_message: str = Field(default="", description="配送说明") attributes: list[SkuAttribute] = Field(default_factory=list, description="SKU 属性列表") class SkuInfo(BaseModel): """商品 SKU 信息""" inventory_type: str = Field(default="", description="库存类型:single / multiple") quantity: int = Field(default=0, description="库存数量") show_inventory: bool = Field(default=False, description="站点是否展示库存") delivery_message: str = Field(default="", description="配送说明") attributes: list[SkuAttribute] = Field(default_factory=list, description="SKU 属性列表") axis: list[SkuAxis] = Field(default_factory=list, description="SKU 选择轴,如「颜色」「尺码」") variants: list[SkuVariant] = Field(default_factory=list, description="SKU 组合明细;include_sku_variants=false 时为空") variant_count: int = Field(default=0, description="SKU 组合总数;不受 include_sku_variants 影响,始终为真实组合数") class ItemOptionValue(BaseModel): """店铺自定义选项的一个候选取值""" value_id: int | None = Field(default=None, description="站点 values[].id 原值;给不出数字时为 null") name: str = Field(default="", description="取值展示名,下单时 choice 里要用这个原文") is_placeholder: bool = Field( default=False, description="是否为「選択してください」这类占位项。占位项不是合法取值," "提交它等于没选,构造 choice 时必须跳过", ) class ItemOption(BaseModel): """一个店铺自定义选项(下单必填项的来源) 与 SKU 规格不是一回事:规格走 `sku.variants[].variant_id`,选项走下单接口的 `choice` 字段。店铺用它承载「名入れ文字」「配送方式确认」「レビュー依頼」等, `is_required=true` 的选项不给值时站点会直接拒绝加购。 """ option_id: int | None = Field(default=None, description="站点 options[].id 原值") name: str = Field(default="", description="选项名,下单时 choice 的「名」部分要用这个原文") type: str = Field( default="", description="站点原值:select(下拉,看 values)/ text(自由文本,values 为空)", ) is_required: bool = Field(default=False, description="是否必填;必填项不给值时站点拒绝加购") values: list[ItemOptionValue] = Field( default_factory=list, description="候选取值;type=text 时为空" ) selectable_value_count: int = Field( default=0, description="剔除占位项后真正可提交的候选数。为 0 且 is_required=true 时" "无法自动选值,必须由调用方在 choice 里显式给出", ) class ShippingInfo(BaseModel): """配送与运费信息""" shipping_fee: int | None = Field(default=None, description="运费(日元);站点未给出时为 null") is_shipping_free: bool = Field(default=False, description="免运费") is_asuraku: bool = Field(default=False, description="あす楽(次日达)") is_next_day_delivery: bool = Field(default=False, description="次日达") free_shipping_threshold: int | None = Field(default=None, description="免运费门槛(日元)") prefecture_id: int | None = Field(default=None, description="站点默认收货地(13 = 东京都)") delivery_message: str = Field(default="", description="配送说明") class Breadcrumb(BaseModel): """分类面包屑""" name: str = Field(default="", description="分类名") url: str = Field(default="", description="分类页地址") class ItemDetailData(BaseModel): """商品详情数据 部分乐天官方店的商品页会跳转到独立子站,各子站页面结构不同、可提供的字段也 不同。source 标明这条数据由哪个站点解析而来,字段覆盖差异见 README。 加购(构造 cart 请求)不在本服务范围内——加购需要已登录的乐天账号会话, 归 trading 服务(app.trading)。本响应只描述「商品状态」:能不能买 (purchase_condition / is_sold_out)、规格(sku.variants)、起订单位、 店铺自定义选项(options)等,即「下单前需要先决定哪些参数」。 """ source: str = Field(default="ichiba", description="数据来源站点:ichiba / books / brandavenue / biccamera") source_url: str = Field(default="", description="实际解析的页面地址;跳转时与 item_url 不同") item_id: str = Field(default="", description="乐天内部商品 ID") item_code: str = Field(default="", description="店铺内商品编号(商品 URL 第二段)") item_name: str = Field(default="", description="商品名") catch_copy: str = Field(default="", description="商品副标题") description: str = Field(default="", description="店铺自填的商品说明,含 HTML") item_url: str = Field(default="", description="商品页地址") price: int = Field(default=0, description="最低售价(日元,含税;多 SKU 时为最低价)") pre_tax_price: int = Field(default=0, description="税前价格(日元)") tax_flag: bool = Field(default=False, description="价格是否含税(站点 taxFlag 原值)") tax_rate: float = Field(default=0.0, description="税率") purchase_condition: str = Field(default="", description="站点原值,enabled 表示可购买") is_sold_out: bool = Field(default=False, description="已售罄") purchase_unit: int = Field(default=0, description="起订单位") images: list[str] = Field(default_factory=list, description="商品图片地址列表") shop: ShopSummary = Field(default_factory=ShopSummary, description="所属店铺信息") review: ReviewSummary = Field(default_factory=ReviewSummary, description="商品评价信息") genre_id: str = Field(default="", description="分类 ID") breadcrumbs: list[Breadcrumb] = Field(default_factory=list, description="分类面包屑") shipping: ShippingInfo = Field(default_factory=ShippingInfo, description="该商品的配送与运费信息") sku: SkuInfo = Field(default_factory=SkuInfo, description="SKU 信息") options: list[ItemOption] = Field( default_factory=list, description="店铺自定义选项(站点 purchase.information.options)。与 SKU 规格不同:" "规格选 sku.variants[].variant_id,选项走下单接口的 choice 字段。" "空列表表示该商品页没有选项;子站(books / brandavenue / biccamera)暂不解析", ) has_required_options: bool = Field( default=False, description="是否存在必填选项。为 true 时下单必须给 choice,否则站点拒绝加购", ) unfillable_required_options: list[str] = Field( default_factory=list, description="必填但无法自动选值的选项名(自由文本项,或候选值只有占位项)。" "非空时**必须**由调用方在下单 intent.choice 里显式给出这些项的取值", ) class HealthData(BaseModel): """抓取服务健康检查响应数据 这里不含登录态——登录态属于交易服务,查它请打交易服务的 /health。 """ status: str = Field(description="服务状态,正常为 ok") browser_fallback_enabled: bool = Field(description="是否启用浏览器兜底抓取") browser_fallback_ready: bool = Field(description="浏览器兜底是否就绪") browser_fallback_error: str | None = Field(default=None, description="浏览器兜底不可用的原因;正常为 null") sessions: dict[str, Any] = Field(default_factory=dict, description="各站点抓取会话状态(rakuten / rakuma)") # ========================================================================== # ラクマ(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 = Field(default="", description="搜索关键词") page: int = Field(default=1, ge=1, le=100, description="页码,站点侧 page>100 直接 404") sort: RakumaSortOption = Field(default=RakumaSortOption.STANDARD, description="排序方式") category_id: str | None = Field(default=None, description="ラクマ 分类 ID,如 788") brand_id: str | None = Field(default=None, description="ラクマ 品牌 ID,如 5296") min_price: int | None = Field(default=None, ge=0, description="最低价格(日元)") max_price: int | None = Field(default=None, ge=0, description="最高价格(日元)") exclude_keyword: str | None = Field( default=None, description="排除词(站点 `excluded_query`,需与 keyword 同时使用)" ) conditions: list[RakumaCondition] = Field(default_factory=list, description="商品状态筛选,可多选") transaction: RakumaTransaction | None = Field(default=None, description="售卖状态筛选,不传表示不限") free_shipping: bool = Field(default=False, description="仅「送料込み」(卖家承担运费)") anonymous_shipping: bool = Field(default=False, description="仅匿名配送") except_for_no_brand: bool = Field(default=False, description="排除无品牌商品;与 brand_id 互斥") authenticity_types: list[RakumaAuthenticity] = Field(default_factory=list, description="正品鉴定服务类型筛选,可多选") search_url: HttpUrl | None = Field( default=None, description="直接透传一条 fril.jp 搜索页 URL;此时除 page 外的筛选字段全部忽略" ) @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 = Field(default=None, description="商品页 hash,如 4aca1d6db3e422f3a251a8a8b61e1eff") item_url: HttpUrl | None = Field(default=None, description="商品页 URL;与 item_id 二选一") @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 = Field(default=None, description="店铺页 hash,如 422750cb7921557bc8dba2416915d968") shop_url: HttpUrl | None = Field(default=None, description="店铺页 URL;与 shop_id 二选一") # 评价明细在单独的 /review 页上,需要多打一次请求,默认不取 include_reviews: bool = Field( default=False, description="是否返回评价明细;评价在单独的 /review 页,会多打一次请求" ) @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 = Field(default=None, description="店铺页 hash") shop_url: HttpUrl | None = Field(default=None, description="店铺页 URL;与 shop_id 二选一") page: int = Field(default=1, ge=1, description="页码") @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 RakumaCategoryRequest(BaseModel): """ラクマ 分类查询参数 不传 category_id 时返回 14 个顶层分类;传入时返回该分类的名称、祖先路径与 直接子分类。站点一次请求就返回整棵树,因此 include_descendants 只是换一种 组织方式,不会多打请求。 """ category_id: str | None = Field( default=None, description="分类 ID。不传返回 14 个顶层分类;传入返回该分类的名称、祖先路径与直接子分类", ) include_descendants: bool = Field( default=False, description="返回该分类下的完整子树(不止直接子级);分类树共三层,顶层分类的子树可达上百条", ) class RakumaCategoryNode(BaseModel): """ラクマ 分类树上的一个节点 站点只给 id / parentId / name / hasChild 四个字段,没有商品数—— 商品数要逐个分类去抓 `/category/{id}` 页面,成本过高,本接口不提供。 """ category_id: str = Field(default="", description="分类 ID") name: str = Field(default="", description="分类名") parent_id: str = Field(default="", description="父分类 ID,「0」表示顶层分类") is_leaf: bool = Field(default=False, description="叶子分类,没有下级") url: str = Field(default="", description="分类页地址") children: list[RakumaCategoryNode] = Field( default_factory=list, description="下级分类;include_descendants=true 时填充,否则始终为空" ) class RakumaCategoryData(BaseModel): """ラクマ 分类查询结果""" category_id: str = Field(default="", description="分类 ID,空串表示顶层") name: str = Field(default="", description="分类名") full_name: str = Field( default="", description="从顶层拼到自身的路径名,如「エンタメ/ホビー / ゲームソフト/ゲーム機本体 / 家庭用ゲームソフト」", ) is_leaf: bool = Field(default=False, description="叶子分类,没有下级") url: str = Field(default="", description="分类页地址") total_count: int = Field(default=0, description="站点分类树的节点总数,用于确认取到的是全量树") ancestors: list[RakumaCategoryNode] = Field( default_factory=list, description="祖先分类路径,从顶层到父级,不含自身" ) children: list[RakumaCategoryNode] = Field( default_factory=list, description="直接子分类;include_descendants=true 时带子树" ) class RakumaSeller(BaseModel): """ラクマ 卖家(出品者)摘要""" shop_id: str = Field(default="", description="店铺页 hash,可直接用于 /api/rakuma/shop_detail") user_id: str = Field(default="", description="站点内部数值用户 ID") shop_name: str = Field(default="", description="店铺名,卖家可自定义") user_name: str = Field(default="", description="用户昵称") shop_url: str = Field(default="", description="店铺页地址") icon_url: str = Field(default="", description="头像地址") seller_type: str = Field(default="", description="卖家类型(站点原值),如 一般 / 事業者") review_score: float = Field(default=0.0, description="综合评分") review_count: int = Field(default=0, description="评价数") is_verified: bool = Field(default=False, description="本人確認済") class RakumaSearchItem(BaseModel): """ラクマ 搜索结果中的单个商品""" item_id: str = Field(default="", description="商品页 hash,调详情接口用") item_number: str = Field(default="", description="站点内部数值商品 ID") item_name: str = Field(default="", description="商品名") item_url: str = Field(default="", description="商品页地址") price: int = Field(default=0, description="价格(日元)") image_url: str = Field(default="", description="主图地址") is_sold_out: bool = Field(default=False, description="已售出") brand_id: str = Field(default="", description="品牌 ID") brand_name: str = Field(default="", description="品牌名") category_id: str = Field(default="", description="分类 ID") category_names: list[str] = Field(default_factory=list, description="分类名列表") seller_user_id: str = Field(default="", description="卖家数值 ID;店铺 hash 需从详情页取") seller_type: str = Field(default="", description="卖家类型(站点原值),如 一般 / 事業者") class RakumaSearchResultData(BaseModel): """ラクマ 搜索结果数据""" keyword: str = Field(default="", description="搜索关键词") page: int = Field(default=1, description="当前页码") page_size: int = Field(default=0, description="每页条数") # 站点声明的命中总数。页面上展示为「約1,190,000件」的四舍五入值, # 这里取的是埋点属性里的精确值。 total_count: int = Field(default=0, description="站点声明的命中总数(埋点属性里的精确值)") has_more: bool = Field(default=False, description="是否还有下一页") request_url: str = Field(default="", description="实际抓取的页面地址,便于排查") items: list[RakumaSearchItem] = Field(default_factory=list, description="本页商品列表") class RakumaItemDetailData(BaseModel): """ラクマ 商品详情数据 C2C 集市的商品是单件的:没有 SKU 组合,没有库存数量, 「規格」在站点上只体现为一个可选的尺码字段。 """ item_id: str = Field(default="", description="商品页 hash") item_number: str = Field(default="", description="站点内部数值商品 ID") item_name: str = Field(default="", description="商品名") description: str = Field(default="", description="商品说明(卖家自填)") item_url: str = Field(default="", description="商品页地址") price: int = Field(default=0, description="价格(日元)") is_sold_out: bool = Field(default=False, description="已售出") images: list[str] = Field(default_factory=list, description="商品图片地址列表") condition: str = Field(default="", description="商品の状態,站点原文如「目立った傷や汚れなし」") size: str = Field(default="", description="サイズ,无尺码时为空") brand_id: str = Field(default="", description="品牌 ID") brand_name: str = Field(default="", description="品牌名") category_id: str = Field(default="", description="最具体的一级分类 ID") breadcrumbs: list[Breadcrumb] = Field(default_factory=list, description="分类面包屑") shipping_payer: str = Field(default="", description="配送料の負担,如「送料込」") shipping_method: str = Field(default="", description="配送方法") shipping_date_estimate: str = Field(default="", description="発送日の目安") shipping_from: str = Field(default="", description="発送元の地域") is_anonymous_shipping: bool = Field(default=False, description="匿名配送") like_count: int = Field(default=0, description="いいね数") comment_count: int = Field(default=0, description="评论数") posted_at: str = Field(default="", description="站点展示的相对时间,如「約1時間前」") seller: RakumaSeller = Field(default_factory=RakumaSeller, description="卖家(出品者)信息") class RakumaReview(BaseModel): """ラクマ 卖家的一条交易评价""" rating: str = Field(default="", description="评价档位:good / normal / bad") title: str = Field(default="", description="站点原文标题,如「よい出品者です」") comment: str = Field(default="", description="评价内容") reviewer_name: str = Field(default="", description="评价者昵称") reviewed_at: str = Field(default="", description="站点展示的日期,如 2026/05/04") class RakumaRatingBreakdown(BaseModel): """评价数量分档""" good: int = Field(default=0, description="好评数") normal: int = Field(default=0, description="中评数") bad: int = Field(default=0, description="差评数") class RakumaShopDetailData(BaseModel): """ラクマ 卖家详情数据""" shop_id: str = Field(default="", description="店铺页 hash") user_id: str = Field(default="", description="站点内部数值用户 ID") shop_name: str = Field(default="", description="店铺名,卖家可自定义") user_name: str = Field(default="", description="用户昵称") shop_url: str = Field(default="", description="店铺页地址") icon_url: str = Field(default="", description="头像地址") cover_url: str = Field(default="", description="封面图地址") introduction: str = Field(default="", description="プロフィール文(自我介绍)") review_score: float = Field(default=0.0, description="综合评分") review_count: int = Field(default=0, description="评价数") is_verified: bool = Field(default=False, description="本人確認済") verification_label: str = Field(default="", description="站点原文,如「本人確認済」/「本人確認未完了」") item_count: int = Field(default=0, description="该卖家在售 + 已售商品总数") # 以下三项需 include_reviews=true 才会填充 rating_breakdown: RakumaRatingBreakdown = Field( default_factory=RakumaRatingBreakdown, description="评价数量分档(全部评价)" ) seller_rating_breakdown: RakumaRatingBreakdown = Field( default_factory=RakumaRatingBreakdown, description="评价数量分档(仅作为卖家收到的评价)" ) reviews: list[RakumaReview] = Field(default_factory=list, description="评价明细列表") class RakumaShopItemsData(BaseModel): """ラクマ 卖家商品列表数据""" shop_id: str = Field(default="", description="店铺页 hash") shop_name: str = Field(default="", description="店铺名") page: int = Field(default=1, description="当前页码") total_count: int = Field(default=0, description="该卖家的商品总数") has_more: bool = Field(default=False, description="是否还有下一页") request_url: str = Field(default="", description="实际抓取的页面地址,便于排查") items: list[RakumaSearchItem] = Field(default_factory=list, description="本页商品列表")