支持其他店铺
This commit is contained in:
@@ -19,14 +19,14 @@ from selectolax.parser import HTMLParser
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.errors import ScrapeParseError, UpstreamBlockedError
|
||||
from app.models.scrape import CategoryData, DetailRequest, ProductDetailData, ProductSummary, SearchRequest, \
|
||||
SearchResultData
|
||||
from app.models.scrape import CategoryData, DetailRequest, OtherShopItem, OtherShopListData, ProductDetailData, \
|
||||
ProductSummary, SearchRequest, SearchResultData
|
||||
from app.services.browser_pool import BrowserPool
|
||||
from app.services.cloudflare_session import CloudflareSessionManager
|
||||
from app.services.session_store import SessionStore
|
||||
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.urls import BASE_URL
|
||||
from surugaya_common.urls import BASE_URL, product_other_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -147,7 +147,7 @@ class SurugayaClient:
|
||||
if is_empty_str(goods_id):
|
||||
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.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)
|
||||
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]:
|
||||
"""获取商品分类(带缓存)
|
||||
|
||||
@@ -470,12 +585,25 @@ class SurugayaClient:
|
||||
return 1
|
||||
return current_page if current_page >= 1 else 1
|
||||
|
||||
def _build_product_url(self, product_id: str | None) -> str:
|
||||
"""根据商品 ID 构建详情页 URL"""
|
||||
def _build_product_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:
|
||||
raise ScrapeParseError("Either product_id or product_url must be provided")
|
||||
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]:
|
||||
"""解析搜索结果列表
|
||||
|
||||
Reference in New Issue
Block a user