支持其他店铺
This commit is contained in:
@@ -18,6 +18,7 @@ from app.models.scrape import (
|
|||||||
DetailRequest,
|
DetailRequest,
|
||||||
OrderShippingFeeData,
|
OrderShippingFeeData,
|
||||||
OrderShippingFeeRequest,
|
OrderShippingFeeRequest,
|
||||||
|
OtherShopListData,
|
||||||
ProductDetailData,
|
ProductDetailData,
|
||||||
PurchaseTaskRequest,
|
PurchaseTaskRequest,
|
||||||
SearchRequest,
|
SearchRequest,
|
||||||
@@ -122,6 +123,29 @@ async def item_detail(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/item_other_shops",
|
||||||
|
response_model=ApiResponse[OtherShopListData],
|
||||||
|
dependencies=[Depends(require_bearer_token)],
|
||||||
|
)
|
||||||
|
async def item_other_shops(
|
||||||
|
payload: DetailRequest,
|
||||||
|
container: ServiceContainer = Depends(get_container),
|
||||||
|
) -> ApiResponse[OtherShopListData]:
|
||||||
|
"""获取商品「他のショップ」全部店铺报价列表
|
||||||
|
|
||||||
|
根据商品 ID 抓取骏河屋 /product/other/{id} 页面,返回全部店铺(含官方自营+加盟店/市场店铺)报价列表;
|
||||||
|
列表中每项的 detail_url 已带上对应 tenpo_cd/branch_number,可直接用于 /api/item_detail 跳转查看该店铺报价。
|
||||||
|
"""
|
||||||
|
data = await container.surugaya_client.fetch_other_shops(payload)
|
||||||
|
return ApiResponse[OtherShopListData](
|
||||||
|
success=True,
|
||||||
|
msg="success",
|
||||||
|
data=data,
|
||||||
|
code=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/categories",
|
"/categories",
|
||||||
response_model=ApiResponse[list[CategoryData]],
|
response_model=ApiResponse[list[CategoryData]],
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ class DetailRequest(BaseModel):
|
|||||||
"""商品详情请求参数,支持 id 或 product_url"""
|
"""商品详情请求参数,支持 id 或 product_url"""
|
||||||
id: str | None = Field(default=None, min_length=3, max_length=40)
|
id: str | None = Field(default=None, min_length=3, max_length=40)
|
||||||
product_url: HttpUrl | None = None
|
product_url: HttpUrl | None = None
|
||||||
|
tenpo_cd: str | None = None # 指定店铺代码;跳转查看某个加盟店/市场店铺的报价时传入
|
||||||
|
branch_number: str | None = None # 指定分支编号,与 tenpo_cd 搭配使用
|
||||||
|
|
||||||
|
|
||||||
class ProductSummary(BaseModel):
|
class ProductSummary(BaseModel):
|
||||||
@@ -67,6 +69,24 @@ class SearchResultData(BaseModel):
|
|||||||
items: list[ProductSummary]
|
items: list[ProductSummary]
|
||||||
|
|
||||||
|
|
||||||
|
class OtherShopItem(BaseModel):
|
||||||
|
"""商品「他のショップ」列表中的单个店铺报价
|
||||||
|
|
||||||
|
来自 /product/other/{goods_id} 页面,含官方自营与加盟店/市场店铺。
|
||||||
|
"""
|
||||||
|
tenpo_cd: str = "" # 店铺代码;空串 = 官方自营
|
||||||
|
shop_name: str = "" # 店铺名称;官方自营固定为"駿河屋"
|
||||||
|
shop_url: str = "" # 店铺主页链接
|
||||||
|
is_official: bool = False # 是否为官方自营(tenpo_cd 为空)
|
||||||
|
condition: str = "" # 成色/规格描述,如"中古"
|
||||||
|
price: int = 0 # 该店铺售价(日元)
|
||||||
|
branch_number: str = "" # 分支编号
|
||||||
|
rating_score: str = "" # 店铺评分,如"5.0"
|
||||||
|
rating_count: int = 0 # 评价数量
|
||||||
|
shipping_note: str = "" # 发货时效描述,如"1日〜3日以内に発送します"
|
||||||
|
detail_url: str = "" # 该报价对应的商品详情页链接(含 tenpo_cd/branch_number)
|
||||||
|
|
||||||
|
|
||||||
class ProductDetailData(BaseModel):
|
class ProductDetailData(BaseModel):
|
||||||
"""商品详情数据"""
|
"""商品详情数据"""
|
||||||
goods_id: str
|
goods_id: str
|
||||||
@@ -86,6 +106,12 @@ class ProductDetailData(BaseModel):
|
|||||||
breadcrumb_list: list[dict[str, Any]] = [] # 面包屑
|
breadcrumb_list: list[dict[str, Any]] = [] # 面包屑
|
||||||
|
|
||||||
|
|
||||||
|
class OtherShopListData(BaseModel):
|
||||||
|
"""商品「他のショップ」全部店铺(含官方自营+加盟店/市场店铺)报价列表"""
|
||||||
|
goods_id: str
|
||||||
|
other_shop_list: list[OtherShopItem] = []
|
||||||
|
|
||||||
|
|
||||||
class HealthData(BaseModel):
|
class HealthData(BaseModel):
|
||||||
"""健康检查响应数据"""
|
"""健康检查响应数据"""
|
||||||
status: str
|
status: str
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ from selectolax.parser import HTMLParser
|
|||||||
|
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
from app.core.errors import ScrapeParseError, UpstreamBlockedError
|
from app.core.errors import ScrapeParseError, UpstreamBlockedError
|
||||||
from app.models.scrape import CategoryData, DetailRequest, ProductDetailData, ProductSummary, SearchRequest, \
|
from app.models.scrape import CategoryData, DetailRequest, OtherShopItem, OtherShopListData, ProductDetailData, \
|
||||||
SearchResultData
|
ProductSummary, SearchRequest, SearchResultData
|
||||||
from app.services.browser_pool import BrowserPool
|
from app.services.browser_pool import BrowserPool
|
||||||
from app.services.cloudflare_session import CloudflareSessionManager
|
from app.services.cloudflare_session import CloudflareSessionManager
|
||||||
from app.services.session_store import SessionStore
|
from app.services.session_store import SessionStore
|
||||||
from app.utils.parse_util import format_price, get_shipping_fee, surugaya_photo_url_to_cdn
|
from app.utils.parse_util import format_price, get_shipping_fee, surugaya_photo_url_to_cdn
|
||||||
from surugaya_common.app_utils import is_empty_str
|
from surugaya_common.app_utils import is_empty_str
|
||||||
from surugaya_common.urls import BASE_URL
|
from surugaya_common.urls import BASE_URL, product_other_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ class SurugayaClient:
|
|||||||
if is_empty_str(goods_id):
|
if is_empty_str(goods_id):
|
||||||
raise ScrapeParseError("id is empty")
|
raise ScrapeParseError("id is empty")
|
||||||
|
|
||||||
url = self._build_product_url(goods_id)
|
url = self._build_product_url(goods_id, tenpo_cd=payload.tenpo_cd, branch_number=payload.branch_number)
|
||||||
|
|
||||||
logger.info("开始抓取详情页:goods_id=%s", goods_id)
|
logger.info("开始抓取详情页:goods_id=%s", goods_id)
|
||||||
logger.debug("详情页URL:url=%s", url)
|
logger.debug("详情页URL:url=%s", url)
|
||||||
@@ -283,6 +283,121 @@ class SurugayaClient:
|
|||||||
logger.debug("详情页解析完成:goods_id=%s goods_name=%s ", detail.goods_id, detail.goods_name)
|
logger.debug("详情页解析完成:goods_id=%s goods_name=%s ", detail.goods_id, detail.goods_name)
|
||||||
return detail
|
return detail
|
||||||
|
|
||||||
|
async def fetch_other_shops(self, payload: DetailRequest) -> OtherShopListData:
|
||||||
|
"""获取商品「他のショップ」全部店铺报价列表
|
||||||
|
|
||||||
|
独立于 fetch_detail,仅在需要展示/跳转其他店铺时按需调用,
|
||||||
|
避免每次详情请求都多付出一次 Cloudflare 保护页面抓取的成本。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: 详情请求参数,仅使用其中的 id
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OtherShopListData: 全部店铺(含官方自营+加盟店/市场店铺)报价列表
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ScrapeParseError: 当 id 未提供时
|
||||||
|
"""
|
||||||
|
goods_id = payload.id or ""
|
||||||
|
if is_empty_str(goods_id):
|
||||||
|
raise ScrapeParseError("id is empty")
|
||||||
|
|
||||||
|
other_shop_list = await self._fetch_other_shop_list(goods_id)
|
||||||
|
return OtherShopListData(goods_id=goods_id, other_shop_list=other_shop_list)
|
||||||
|
|
||||||
|
async def _fetch_other_shop_list(self, goods_id: str) -> list[OtherShopItem]:
|
||||||
|
"""抓取并解析商品「他のショップ」页面,返回全部店铺(含官方自营)报价列表
|
||||||
|
|
||||||
|
该页面独立于详情页,需额外一次 Cloudflare 保护页面请求;
|
||||||
|
解析失败或抓取异常时降级为空列表,不影响详情页主流程。
|
||||||
|
"""
|
||||||
|
other_url = product_other_url(goods_id)
|
||||||
|
try:
|
||||||
|
other_html, _ = await self._session_manager.fetch_html(other_url)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("抓取他のショップ页面失败:goods_id=%s err=%s", goods_id, exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self._parse_other_shop_list(HTMLParser(other_html))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("解析他のショップ页面失败:goods_id=%s", goods_id)
|
||||||
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_other_shop_list(tree: HTMLParser) -> list[OtherShopItem]:
|
||||||
|
"""解析「他のショップ」页面的全部店铺(tbl_all 标签页)报价列表
|
||||||
|
|
||||||
|
每行对应一个店铺报价;tenpo_cd 为空表示官方自营(駿河屋本店)。
|
||||||
|
"""
|
||||||
|
items: list[OtherShopItem] = []
|
||||||
|
for row in tree.css("#tbl_all tr.item"):
|
||||||
|
tds = row.css("td")
|
||||||
|
if len(tds) < 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
price_node = tds[0].css_first("strong.text-red")
|
||||||
|
price = format_price(price_node.text().strip()) or 0 if price_node else 0
|
||||||
|
|
||||||
|
condition_node = tds[1].css_first("h2.title_product")
|
||||||
|
condition = condition_node.text().strip() if condition_node else ""
|
||||||
|
|
||||||
|
detail_link_node = tds[1].css_first("a[href*='/product/detail/']")
|
||||||
|
detail_href = detail_link_node.attributes.get("href") if detail_link_node else ""
|
||||||
|
detail_url = f"{BASE_URL}{detail_href}" if detail_href else ""
|
||||||
|
|
||||||
|
shop_col = tds[2]
|
||||||
|
shop_link_node = shop_col.css_first("a[href^='/shop/']")
|
||||||
|
tenpo_cd = ""
|
||||||
|
shop_name = "駿河屋"
|
||||||
|
shop_url = f"{BASE_URL}/"
|
||||||
|
if shop_link_node:
|
||||||
|
shop_href = shop_link_node.attributes.get("href") or ""
|
||||||
|
tenpo_cd = shop_href.rsplit("/", 1)[-1]
|
||||||
|
shop_name = shop_link_node.text().strip()
|
||||||
|
shop_url = f"{BASE_URL}{shop_href}"
|
||||||
|
|
||||||
|
rating_score = ""
|
||||||
|
rating_count = 0
|
||||||
|
rating_node = shop_col.css_first("div:not([class])")
|
||||||
|
if rating_node:
|
||||||
|
rating_text = rating_node.text().strip()
|
||||||
|
rating_match = re.match(r"([\d.]+)\((\d+)", rating_text)
|
||||||
|
if rating_match:
|
||||||
|
rating_score = rating_match.group(1)
|
||||||
|
rating_count = int(rating_match.group(2))
|
||||||
|
|
||||||
|
shipping_note_node = row.css_first("ul.shipping_campaing_info li.padT5")
|
||||||
|
shipping_note = shipping_note_node.text().strip() if shipping_note_node else ""
|
||||||
|
|
||||||
|
branch_number = ""
|
||||||
|
campaign_node = row.css_first("li.ajax-campaign-placeholder")
|
||||||
|
if campaign_node:
|
||||||
|
zaiko_raw = campaign_node.attributes.get("data-zaiko_data") or ""
|
||||||
|
if zaiko_raw:
|
||||||
|
try:
|
||||||
|
zaiko = json.loads(zaiko_raw)
|
||||||
|
branch_number = str(zaiko.get("branch_number", ""))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
items.append(
|
||||||
|
OtherShopItem(
|
||||||
|
tenpo_cd=tenpo_cd,
|
||||||
|
shop_name=shop_name,
|
||||||
|
shop_url=shop_url,
|
||||||
|
is_official=tenpo_cd == "",
|
||||||
|
condition=condition,
|
||||||
|
price=price,
|
||||||
|
branch_number=branch_number,
|
||||||
|
rating_score=rating_score,
|
||||||
|
rating_count=rating_count,
|
||||||
|
shipping_note=shipping_note,
|
||||||
|
detail_url=detail_url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
async def fetch_categories(self) -> list[CategoryData]:
|
async def fetch_categories(self) -> list[CategoryData]:
|
||||||
"""获取商品分类(带缓存)
|
"""获取商品分类(带缓存)
|
||||||
|
|
||||||
@@ -470,12 +585,25 @@ class SurugayaClient:
|
|||||||
return 1
|
return 1
|
||||||
return current_page if current_page >= 1 else 1
|
return current_page if current_page >= 1 else 1
|
||||||
|
|
||||||
def _build_product_url(self, product_id: str | None) -> str:
|
def _build_product_url(
|
||||||
"""根据商品 ID 构建详情页 URL"""
|
self,
|
||||||
|
product_id: str | None,
|
||||||
|
tenpo_cd: str | None = None,
|
||||||
|
branch_number: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""根据商品 ID 构建详情页 URL;tenpo_cd/branch_number 用于跳转查看指定店铺的报价"""
|
||||||
if not product_id:
|
if not product_id:
|
||||||
raise ScrapeParseError("Either product_id or product_url must be provided")
|
raise ScrapeParseError("Either product_id or product_url must be provided")
|
||||||
base_url = self._settings.base_url.rstrip("/")
|
base_url = self._settings.base_url.rstrip("/")
|
||||||
return f"{base_url}/product/detail/{product_id}"
|
url = f"{base_url}/product/detail/{product_id}"
|
||||||
|
params = []
|
||||||
|
if tenpo_cd:
|
||||||
|
params.append(f"tenpo_cd={tenpo_cd}")
|
||||||
|
if branch_number:
|
||||||
|
params.append(f"branch_number={branch_number}")
|
||||||
|
if params:
|
||||||
|
url += "?" + "&".join(params)
|
||||||
|
return url
|
||||||
|
|
||||||
def _parse_search_items(self, tree: HTMLParser) -> list[ProductSummary]:
|
def _parse_search_items(self, tree: HTMLParser) -> list[ProductSummary]:
|
||||||
"""解析搜索结果列表
|
"""解析搜索结果列表
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# 通知(お知らせ)查询接口
|
||||||
|
|
||||||
|
读取 worker 定时抓取的骏河屋「お知らせ一覧」(action_news) 通知。worker 翻页抓取后按
|
||||||
|
`data_id` 去重写入 Redis,本接口只读返回,不删除、不消费,可重复拉取。
|
||||||
|
|
||||||
|
## 请求
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
| --- | --- |
|
||||||
|
| 方法 | `GET` |
|
||||||
|
| 路径 | `/api/news` |
|
||||||
|
| 鉴权 | 请求头 `Authorization: Bearer <token>`(token 由服务端 `SURUGAYA_BEARER_TOKEN` 配置) |
|
||||||
|
|
||||||
|
### Query 参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `limit` | int | 否 | 返回条数上限,范围 `1`~`500`;省略则返回全部。结果按 `data_id` 倒序(新→旧),`limit` 取最新的若干条 |
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/news?limit=20
|
||||||
|
Authorization: Bearer NilsO8Il8yoT1wazJFs2eeOmlPRfWuSx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 成功响应 `200`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"msg": "success",
|
||||||
|
"code": 0,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"data_id": "869431007",
|
||||||
|
"date": "2026/06/12",
|
||||||
|
"message": "【販売】(取引番号:S2606115133)発送準備中です。",
|
||||||
|
"data_type": "2",
|
||||||
|
"data_url": "/pcmypage/action_sell_search/detail?trade_code=S2606115133",
|
||||||
|
"trade_code": "S2606115133",
|
||||||
|
"account_id": "trdian023@tokugen.jp",
|
||||||
|
"fetched_at": 1781348570
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data_id": "868997716",
|
||||||
|
"date": "2026/06/05",
|
||||||
|
"message": "【販売】(取引番号:M2606056999)商品の注文を受け付けました。",
|
||||||
|
"data_type": "2",
|
||||||
|
"data_url": "/pcmypage/action_sell_search/detail?trade_code=M2606056999",
|
||||||
|
"trade_code": "M2606056999",
|
||||||
|
"account_id": "trdian023@tokugen.jp",
|
||||||
|
"fetched_at": 1781348570
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data_id": "860001234",
|
||||||
|
"date": "2026/01/01",
|
||||||
|
"message": "【お知らせ】メンテナンスのお知らせ",
|
||||||
|
"data_type": "1",
|
||||||
|
"data_url": "/pcmypage/action_news_detail?id=860001234",
|
||||||
|
"trade_code": null,
|
||||||
|
"account_id": "trdian023@tokugen.jp",
|
||||||
|
"fetched_at": 1781348570
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### data 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `data_id` | string | 骏河屋侧通知唯一标识(单调递增),去重与排序依据 |
|
||||||
|
| `date` | string | 通知日期,格式 `YYYY/MM/DD` |
|
||||||
|
| `message` | string | 通知文案原文(日文) |
|
||||||
|
| `data_type` | string | 骏河屋侧 `data-type` 原值(如 `2`=交易类通知) |
|
||||||
|
| `data_url` | string \| null | 骏河屋侧 `data-url`(详情相对路径) |
|
||||||
|
| `trade_code` | string \| null | 从 `data_url` 解析的交易单号;非交易类通知为 `null` |
|
||||||
|
| `account_id` | string | 该通知所属账号(骏河屋登录邮箱) |
|
||||||
|
| `fetched_at` | int | worker 抓取写入时间(Unix epoch 秒) |
|
||||||
|
|
||||||
|
> 说明
|
||||||
|
> - 列表按 `data_id` 数值倒序返回(最新在前)。
|
||||||
|
> - 同一 `data_id` 只保留一条,重复抓取会以最新内容覆盖,调用方可安全重复拉取。
|
||||||
|
> - 需要区分账号时按 `account_id` 过滤即可(当前为登录邮箱)。
|
||||||
|
|
||||||
|
## 错误响应
|
||||||
|
|
||||||
|
统一响应体结构:`success=false`,`code` 为业务错误码,`data=null`。
|
||||||
|
|
||||||
|
| HTTP | code | 场景 | 示例 msg |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 401 | 1001 | 缺少/无效 Bearer Token | `Invalid token` |
|
||||||
|
| 422 | 1002 | `limit` 越界(不在 1~500) | `limit: Input should be less than or equal to 500` |
|
||||||
|
| 500 | 500 | 服务端未配置 Redis,通知不可读 | `Redis 未配置` |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"msg": "Redis 未配置",
|
||||||
|
"code": 500,
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "surugaya-common"
|
name = "surugaya-common"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
description = "Shared contracts and browser utilities (Redis keys, models, parsers, fingerprint profile) for jp_surugaya services"
|
description = "Shared contracts and browser utilities (Redis keys, models, parsers, fingerprint profile) for jp_surugaya services"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -9,14 +9,24 @@ CARGO_DETAIL_URL = f"{BASE_URL}/cargo/detail"
|
|||||||
MYPAGE_URL = f"{BASE_URL}/pcmypage"
|
MYPAGE_URL = f"{BASE_URL}/pcmypage"
|
||||||
|
|
||||||
|
|
||||||
def product_detail_url(product_id: str, tenpo_cd: str | None = None) -> str:
|
def product_detail_url(product_id: str, tenpo_cd: str | None = None, branch_number: str | None = None) -> str:
|
||||||
"""商品详情页地址;tenpo_cd 用于加盟店商品。"""
|
"""商品详情页地址;tenpo_cd/branch_number 用于指定加盟店/市场店铺的具体报价。"""
|
||||||
url = f"{BASE_URL}/product/detail/{product_id}"
|
url = f"{BASE_URL}/product/detail/{product_id}"
|
||||||
|
params = []
|
||||||
if tenpo_cd:
|
if tenpo_cd:
|
||||||
url += f"?tenpo_cd={tenpo_cd}"
|
params.append(f"tenpo_cd={tenpo_cd}")
|
||||||
|
if branch_number:
|
||||||
|
params.append(f"branch_number={branch_number}")
|
||||||
|
if params:
|
||||||
|
url += "?" + "&".join(params)
|
||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def product_other_url(product_id: str) -> str:
|
||||||
|
"""商品「他のショップ」页地址:列出该商品全部店铺(官方自营+加盟店/市场店铺)报价。"""
|
||||||
|
return f"{BASE_URL}/product/other/{product_id}"
|
||||||
|
|
||||||
|
|
||||||
def trade_detail_url(trade_code: str) -> str:
|
def trade_detail_url(trade_code: str) -> str:
|
||||||
"""交易(订单)详情页地址。"""
|
"""交易(订单)详情页地址。"""
|
||||||
return f"{BASE_URL}/pcmypage/action_sell_search/detail?trade_code={trade_code}"
|
return f"{BASE_URL}/pcmypage/action_sell_search/detail?trade_code={trade_code}"
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ class TestUrls:
|
|||||||
)
|
)
|
||||||
# 空字符串 tenpo_cd 与未提供等价(与迁移前 if tenpo_cd 判断一致)
|
# 空字符串 tenpo_cd 与未提供等价(与迁移前 if tenpo_cd 判断一致)
|
||||||
assert product_detail_url("128001234", tenpo_cd="") == "https://www.suruga-ya.jp/product/detail/128001234"
|
assert product_detail_url("128001234", tenpo_cd="") == "https://www.suruga-ya.jp/product/detail/128001234"
|
||||||
|
assert (
|
||||||
|
product_detail_url("128001234", tenpo_cd="400496", branch_number="0003")
|
||||||
|
== "https://www.suruga-ya.jp/product/detail/128001234?tenpo_cd=400496&branch_number=0003"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
product_detail_url("128001234", branch_number="0003")
|
||||||
|
== "https://www.suruga-ya.jp/product/detail/128001234?branch_number=0003"
|
||||||
|
)
|
||||||
|
|
||||||
def test_trade_detail_url(self):
|
def test_trade_detail_url(self):
|
||||||
assert (
|
assert (
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""SurugayaClient._parse_other_shop_list 的解析单元测试。
|
||||||
|
|
||||||
|
固定 HTML 节选自真实的 /product/other/{goods_id} 页面结构(tbl_all 标签页),
|
||||||
|
覆盖官方自营行(无 tenpo_cd/无评分)与加盟店/市场店铺行(含 tenpo_cd/评分)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from selectolax.parser import HTMLParser
|
||||||
|
|
||||||
|
from app.services.surugaya_client import SurugayaClient
|
||||||
|
|
||||||
|
OTHER_SHOP_HTML = """
|
||||||
|
<div class="tab-item" id="tabs-all">
|
||||||
|
<table id="tbl_all">
|
||||||
|
<tr class="thead"><th>头</th></tr>
|
||||||
|
<tr class="item">
|
||||||
|
<td><p><strong class="text-red text-bold mgnL10">1,370円</strong></p></td>
|
||||||
|
<td>
|
||||||
|
<a href="/product/detail/220035970?tenpo_cd=400451&branch_number=0003">
|
||||||
|
<h2 class="title_product">中古箱・ジャケット・ケース不備(中)</h2>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="space_text_1 mgnB5"><strong><a href="/shop/400451">駿河屋 山口大学前店</a></strong></div>
|
||||||
|
<div class="star_group mgnB5"></div>
|
||||||
|
<div>5.0(1334件)</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul class="shipping_campaing_info">
|
||||||
|
<li class="padT5">1日〜3日以内に発送します</li>
|
||||||
|
<li class="all ajax-campaign-placeholder"
|
||||||
|
data-zaiko_data="{"shinaban":"220035970","branch_number":"0003","tenpo_cd":"400451","baika":"1370"}">
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="item">
|
||||||
|
<td><p><strong class="text-red text-bold mgnL10">2,000円</strong></p></td>
|
||||||
|
<td>
|
||||||
|
<a href="/product/detail/220035970?branch_number=0001">
|
||||||
|
<h2 class="title_product">中古</h2>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div><a href="/"><img src="/database/images/top-logo.png" class="w100"></a></div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul class="shipping_campaing_info">
|
||||||
|
<li class="padT5">1日〜7日以内に発送します</li>
|
||||||
|
<li class="all ajax-campaign-placeholder"
|
||||||
|
data-zaiko_data="{"shinaban":"220035970","branch_number":"0001","tenpo_cd":"","baika":"2000"}">
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="item">
|
||||||
|
<td><p><strong class="text-red text-bold mgnL10">2,744円</strong></p></td>
|
||||||
|
<td>
|
||||||
|
<a href="/product/detail/220035970?tenpo_cd=410118&branch_number=9001">
|
||||||
|
<h2 class="title_product">新品新古品(店頭在庫品) ※ショップ情報をご確認下さい</h2>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="space_text_1 mgnB5"><strong><a href="/shop/410118">玉光堂 駿河屋マーケットプレイス店</a></strong></div>
|
||||||
|
<div class="star_group mgnB5"></div>
|
||||||
|
<div>5.0(46件)</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul class="shipping_campaing_info">
|
||||||
|
<li class="padT5">2日〜7日以内に発送します</li>
|
||||||
|
<li class="all ajax-campaign-placeholder"
|
||||||
|
data-zaiko_data="{"shinaban":"220035970","branch_number":"9001","tenpo_cd":"410118","baika":"2744"}">
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_other_shop_list_extracts_all_rows() -> None:
|
||||||
|
"""三行分别为:加盟店、官方自营、市场店铺,均应被正确解析。"""
|
||||||
|
tree = HTMLParser(OTHER_SHOP_HTML)
|
||||||
|
items = SurugayaClient._parse_other_shop_list(tree)
|
||||||
|
|
||||||
|
assert len(items) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_other_shop_list_franchise_row() -> None:
|
||||||
|
"""加盟店行:tenpo_cd/店名/评分/价格/发货时效均应正确填充。"""
|
||||||
|
tree = HTMLParser(OTHER_SHOP_HTML)
|
||||||
|
item = SurugayaClient._parse_other_shop_list(tree)[0]
|
||||||
|
|
||||||
|
assert item.tenpo_cd == "400451"
|
||||||
|
assert item.is_official is False
|
||||||
|
assert item.shop_name == "駿河屋 山口大学前店"
|
||||||
|
assert item.shop_url == "https://www.suruga-ya.jp/shop/400451"
|
||||||
|
assert item.price == 1370
|
||||||
|
assert item.condition == "中古箱・ジャケット・ケース不備(中)"
|
||||||
|
assert item.branch_number == "0003"
|
||||||
|
assert item.rating_score == "5.0"
|
||||||
|
assert item.rating_count == 1334
|
||||||
|
assert item.shipping_note == "1日〜3日以内に発送します"
|
||||||
|
assert item.detail_url == (
|
||||||
|
"https://www.suruga-ya.jp/product/detail/220035970?tenpo_cd=400451&branch_number=0003"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_other_shop_list_official_row_has_no_tenpo_cd() -> None:
|
||||||
|
"""官方自营行:无 /shop/ 链接、tenpo_cd 为空、is_official 应为 True,且无评分。"""
|
||||||
|
tree = HTMLParser(OTHER_SHOP_HTML)
|
||||||
|
item = SurugayaClient._parse_other_shop_list(tree)[1]
|
||||||
|
|
||||||
|
assert item.tenpo_cd == ""
|
||||||
|
assert item.is_official is True
|
||||||
|
assert item.shop_name == "駿河屋"
|
||||||
|
assert item.price == 2000
|
||||||
|
assert item.rating_score == ""
|
||||||
|
assert item.rating_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_other_shop_list_marketplace_row() -> None:
|
||||||
|
"""市场店铺行:与加盟店行结构一致,同样应正确解析。"""
|
||||||
|
tree = HTMLParser(OTHER_SHOP_HTML)
|
||||||
|
item = SurugayaClient._parse_other_shop_list(tree)[2]
|
||||||
|
|
||||||
|
assert item.tenpo_cd == "410118"
|
||||||
|
assert item.is_official is False
|
||||||
|
assert item.shop_name == "玉光堂 駿河屋マーケットプレイス店"
|
||||||
|
assert item.price == 2744
|
||||||
|
assert item.rating_score == "5.0"
|
||||||
|
assert item.rating_count == 46
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_other_shop_list_empty_html_returns_empty_list() -> None:
|
||||||
|
"""页面结构不包含 tbl_all 时(如解析失败/改版),应返回空列表而非抛异常。"""
|
||||||
|
tree = HTMLParser("<html><body>no table here</body></html>")
|
||||||
|
assert SurugayaClient._parse_other_shop_list(tree) == []
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""SurugayaClient 中「指定店铺」相关能力的单元测试:
|
||||||
|
|
||||||
|
- _build_product_url 携带 tenpo_cd/branch_number 构造跳转到指定店铺报价的详情页 URL
|
||||||
|
- fetch_other_shops 独立于 fetch_detail,仅返回 other_shop_list(解耦双倍抓取成本)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.models.scrape import DetailRequest, OtherShopItem
|
||||||
|
from app.services.surugaya_client import SurugayaClient
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client() -> SurugayaClient:
|
||||||
|
return SurugayaClient(
|
||||||
|
settings=Settings(_env_file=None),
|
||||||
|
browser_pool=None,
|
||||||
|
session_manager=None,
|
||||||
|
session_store=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildProductUrl:
|
||||||
|
def test_without_shop_params(self) -> None:
|
||||||
|
client = _make_client()
|
||||||
|
assert client._build_product_url("220035970") == "https://www.suruga-ya.jp/product/detail/220035970"
|
||||||
|
|
||||||
|
def test_with_tenpo_cd_and_branch_number(self) -> None:
|
||||||
|
client = _make_client()
|
||||||
|
url = client._build_product_url("220035970", tenpo_cd="400451", branch_number="0003")
|
||||||
|
assert url == "https://www.suruga-ya.jp/product/detail/220035970?tenpo_cd=400451&branch_number=0003"
|
||||||
|
|
||||||
|
def test_missing_product_id_raises(self) -> None:
|
||||||
|
client = _make_client()
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
client._build_product_url(None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchOtherShops:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_goods_id_and_shop_list(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
client = _make_client()
|
||||||
|
fake_items = [OtherShopItem(tenpo_cd="400451", shop_name="駿河屋 山口大学前店", price=1370)]
|
||||||
|
|
||||||
|
async def fake_fetch_other_shop_list(goods_id: str) -> list[OtherShopItem]:
|
||||||
|
assert goods_id == "220035970"
|
||||||
|
return fake_items
|
||||||
|
|
||||||
|
monkeypatch.setattr(client, "_fetch_other_shop_list", fake_fetch_other_shop_list)
|
||||||
|
|
||||||
|
result = await client.fetch_other_shops(DetailRequest(id="220035970"))
|
||||||
|
|
||||||
|
assert result.goods_id == "220035970"
|
||||||
|
assert result.other_shop_list == fake_items
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_id_raises(self) -> None:
|
||||||
|
client = _make_client()
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
await client.fetch_other_shops(DetailRequest(id=None))
|
||||||
Reference in New Issue
Block a user