支持其他店铺

This commit is contained in:
2026-07-08 21:53:20 +08:00
parent af8d3a859f
commit 7ddb41cea1
10 changed files with 932 additions and 432 deletions
+24
View File
@@ -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]],
+26
View File
@@ -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
+135 -7
View File
@@ -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]:
"""解析搜索结果列表 """解析搜索结果列表
+104
View File
@@ -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
}
```
+1 -1
View File
@@ -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}"
+8
View File
@@ -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 (
+139
View File
@@ -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="{&quot;shinaban&quot;:&quot;220035970&quot;,&quot;branch_number&quot;:&quot;0003&quot;,&quot;tenpo_cd&quot;:&quot;400451&quot;,&quot;baika&quot;:&quot;1370&quot;}">
</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="{&quot;shinaban&quot;:&quot;220035970&quot;,&quot;branch_number&quot;:&quot;0001&quot;,&quot;tenpo_cd&quot;:&quot;&quot;,&quot;baika&quot;:&quot;2000&quot;}">
</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="{&quot;shinaban&quot;:&quot;220035970&quot;,&quot;branch_number&quot;:&quot;9001&quot;,&quot;tenpo_cd&quot;:&quot;410118&quot;,&quot;baika&quot;:&quot;2744&quot;}">
</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) == []
+61
View File
@@ -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))
Generated
+421 -421
View File
File diff suppressed because it is too large Load Diff