接入 OTel 链路追踪 + 镜像默认启用

抓取-解析链路原本只有日志,出现"抓回内容但解析不出预期字段"时定位慢。
接入 OpenTelemetry traces(FastAPI/httpx 自动 + 手写 fetch/parse span),
解析失败时把页面 HTML 作为 span event 上报,便于事后复现。
Dockerfile 默认开启,镜像一启动即导出到自建 OTLP endpoint。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 15:43:19 +08:00
co-authored by Claude Opus 4.6
parent 5376ced511
commit 2b7db521c4
13 changed files with 1021 additions and 203 deletions
+118 -54
View File
@@ -8,6 +8,8 @@ from __future__ import annotations
import asyncio
import logging
from opentelemetry import trace
from app.scraping.core import rakuma_site as site
from app.shared.config import Settings
from app.scraping.models.scrape import (
@@ -35,8 +37,10 @@ from app.scraping.utils.rakuma_urls import (
split_item_url,
split_shop_url,
)
from app.shared.telemetry import snapshot
logger = logging.getLogger(__name__)
tracer = trace.get_tracer(__name__)
class RakumaClient:
@@ -54,18 +58,30 @@ class RakumaClient:
url = build_search_url(payload)
logger.info("抓取 ラクマ 搜索页:url=%s", url)
html = await self._session.fetch_html(url)
result = parse_search(
html,
request_url=url,
page=payload.page,
keyword=payload.keyword.strip(),
)
logger.info(
"ラクマ 搜索完成:url=%s items=%s total=%s",
url, len(result.items), result.total_count,
)
return result
html: str | None = None
with tracer.start_as_current_span("parse.rakuma.search") as span:
span.set_attribute("parse.site", "rakuma")
span.set_attribute("parse.op", "search")
try:
html = await self._session.fetch_html(url)
result = parse_search(
html,
request_url=url,
page=payload.page,
keyword=payload.keyword.strip(),
)
span.set_attribute("parse.items", len(result.items))
span.set_attribute("parse.total", result.total_count)
logger.info(
"ラクマ 搜索完成:url=%s items=%s total=%s",
url, len(result.items), result.total_count,
)
return result
except Exception:
span.record_exception()
span.set_attribute("parse.fail_reason", "parse_error")
snapshot(span, "parse.failed_html", html, self._settings.otel_snapshot_max_bytes)
raise
async def categories(self, payload: RakumaCategoryRequest) -> RakumaCategoryData:
"""抓取分类树
@@ -76,17 +92,29 @@ class RakumaClient:
url = site.CATEGORY_LIST_URL
logger.info("抓取 ラクマ 分类页:category_id=%s", payload.category_id)
html = await self._session.fetch_html(url)
data = parse_categories(
html,
category_id=payload.category_id,
include_descendants=payload.include_descendants,
)
logger.info(
"ラクマ 分类完成:category_id=%s name=%s children=%s tree=%s",
data.category_id, data.name, len(data.children), data.total_count,
)
return data
html: str | None = None
with tracer.start_as_current_span("parse.rakuma.categories") as span:
span.set_attribute("parse.site", "rakuma")
span.set_attribute("parse.op", "categories")
try:
html = await self._session.fetch_html(url)
data = parse_categories(
html,
category_id=payload.category_id,
include_descendants=payload.include_descendants,
)
span.set_attribute("parse.children", len(data.children))
span.set_attribute("parse.tree", data.total_count)
logger.info(
"ラクマ 分类完成:category_id=%s name=%s children=%s tree=%s",
data.category_id, data.name, len(data.children), data.total_count,
)
return data
except Exception:
span.record_exception()
span.set_attribute("parse.fail_reason", "parse_error")
snapshot(span, "parse.failed_html", html, self._settings.otel_snapshot_max_bytes)
raise
async def item_detail(self, payload: RakumaItemDetailRequest) -> RakumaItemDetailData:
"""抓取商品详情"""
@@ -97,13 +125,25 @@ class RakumaClient:
url = build_item_url(item_id)
logger.info("抓取 ラクマ 商品详情:url=%s", url)
html = await self._session.fetch_html(url)
detail = parse_item_detail(html, item_id=item_id, item_url=url)
logger.info(
"ラクマ 详情完成:url=%s name=%s price=%s sold_out=%s",
url, detail.item_name[:40], detail.price, detail.is_sold_out,
)
return detail
html: str | None = None
with tracer.start_as_current_span("parse.rakuma.item_detail") as span:
span.set_attribute("parse.site", "rakuma")
span.set_attribute("parse.op", "item_detail")
try:
html = await self._session.fetch_html(url)
detail = parse_item_detail(html, item_id=item_id, item_url=url)
span.set_attribute("parse.price", detail.price)
span.set_attribute("parse.sold_out", detail.is_sold_out)
logger.info(
"ラクマ 详情完成:url=%s name=%s price=%s sold_out=%s",
url, detail.item_name[:40], detail.price, detail.is_sold_out,
)
return detail
except Exception:
span.record_exception()
span.set_attribute("parse.fail_reason", "parse_error")
snapshot(span, "parse.failed_html", html, self._settings.otel_snapshot_max_bytes)
raise
async def shop_detail(self, payload: RakumaShopDetailRequest) -> RakumaShopDetailData:
"""抓取卖家详情
@@ -114,22 +154,34 @@ class RakumaClient:
url = build_shop_url(shop_id)
logger.info("抓取 ラクマ 卖家详情:url=%s reviews=%s", url, payload.include_reviews)
if payload.include_reviews:
html, review_html = await asyncio.gather(
self._session.fetch_html(url),
self._session.fetch_html(build_shop_url(shop_id, review=True)),
)
else:
html, review_html = await self._session.fetch_html(url), None
html: str | None = None
with tracer.start_as_current_span("parse.rakuma.shop_detail") as span:
span.set_attribute("parse.site", "rakuma")
span.set_attribute("parse.op", "shop_detail")
try:
if payload.include_reviews:
html, review_html = await asyncio.gather(
self._session.fetch_html(url),
self._session.fetch_html(build_shop_url(shop_id, review=True)),
)
else:
html, review_html = await self._session.fetch_html(url), None
detail = parse_shop_detail(
html, shop_id=shop_id, shop_url=url, review_html=review_html
)
logger.info(
"ラクマ 卖家详情完成:shop_id=%s name=%s items=%s reviews=%s",
shop_id, detail.shop_name, detail.item_count, detail.review_count,
)
return detail
detail = parse_shop_detail(
html, shop_id=shop_id, shop_url=url, review_html=review_html
)
span.set_attribute("parse.items", detail.item_count)
span.set_attribute("parse.reviews", detail.review_count)
logger.info(
"ラクマ 卖家详情完成:shop_id=%s name=%s items=%s reviews=%s",
shop_id, detail.shop_name, detail.item_count, detail.review_count,
)
return detail
except Exception:
span.record_exception()
span.set_attribute("parse.fail_reason", "parse_error")
snapshot(span, "parse.failed_html", html, self._settings.otel_snapshot_max_bytes)
raise
async def shop_items(self, payload: RakumaShopItemsRequest) -> RakumaShopItemsData:
"""抓取卖家的商品列表"""
@@ -137,15 +189,27 @@ class RakumaClient:
url = build_shop_url(shop_id, page=payload.page)
logger.info("抓取 ラクマ 卖家商品:url=%s", url)
html = await self._session.fetch_html(url)
result = parse_shop_items(
html, shop_id=shop_id, request_url=url, page=payload.page
)
logger.info(
"ラクマ 卖家商品完成:shop_id=%s items=%s total=%s",
shop_id, len(result.items), result.total_count,
)
return result
html: str | None = None
with tracer.start_as_current_span("parse.rakuma.shop_items") as span:
span.set_attribute("parse.site", "rakuma")
span.set_attribute("parse.op", "shop_items")
try:
html = await self._session.fetch_html(url)
result = parse_shop_items(
html, shop_id=shop_id, request_url=url, page=payload.page
)
span.set_attribute("parse.items", len(result.items))
span.set_attribute("parse.total", result.total_count)
logger.info(
"ラクマ 卖家商品完成:shop_id=%s items=%s total=%s",
shop_id, len(result.items), result.total_count,
)
return result
except Exception:
span.record_exception()
span.set_attribute("parse.fail_reason", "parse_error")
snapshot(span, "parse.failed_html", html, self._settings.otel_snapshot_max_bytes)
raise
@staticmethod
def _resolve_shop_id(shop_url: object, shop_id: str | None) -> str: