This commit is contained in:
2026-07-27 10:34:53 +08:00
commit 3527975794
74 changed files with 16405 additions and 0 deletions
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),
)