Compare commits

...
4 Commits
Author SHA1 Message Date
q792602257andClaude Opus 4.6 91bfab7196 忽略 .claude/ 本地配置目录
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-27 15:54:02 +08:00
q792602257andClaude Opus 4.6 3ef3f0275b 简化 Jenkins 推送:构建即推 + 固定 latest
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-27 15:53:56 +08:00
q792602257andClaude Opus 4.6 2b7db521c4 接入 OTel 链路追踪 + 镜像默认启用
抓取-解析链路原本只有日志,出现"抓回内容但解析不出预期字段"时定位慢。
接入 OpenTelemetry traces(FastAPI/httpx 自动 + 手写 fetch/parse span),
解析失败时把页面 HTML 作为 span event 上报,便于事后复现。
Dockerfile 默认开启,镜像一启动即导出到自建 OTLP endpoint。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-27 15:43:19 +08:00
q792602257 5376ced511 补下单任务网关规格
抓取在服务器、下单在本地,本地在 NAT 后没有公网入口,因此下单请求由本地长轮询
主动领取,而不是从服务器推进来。docs/order-gateway.md 给出可直接实现的规格:
任务队列与状态镜像的表结构、四个接口契约、任务状态与订单状态的分层、证据留痕约定、
配置项与验收清单。

其中一条是安全关键:租约过期**不自动重投**。本地可能已经下单成功只是回报断网,
自动重投等于再买一次;过期任务转 stale 告警,恢复时必须先查站点订单列表核对。

站点交互(加购/下单/付款)尚未实测,文档里明确留成接口缝并列出待实测项。
2026-07-27 15:20:11 +08:00
17 changed files with 1363 additions and 233 deletions
+13
View File
@@ -63,3 +63,16 @@ RAKUTEN_AUTH_STATE_DIR=.auth
# 下单金额上限(日元):实际应付超过该值直接拒绝提交,防止解析出错或页面改版
# 导致买到远超预期的订单。设为 0 表示不设上限(不建议)。
RAKUTEN_ORDER_MAX_TOTAL_YEN=30000
# ---- OpenTelemetry traces(可选;默认关闭)----
# 启用后把抓取-解析链路以 span 导出到 OTLP/HTTP endpoint,
# 用于排查"抓到的内容为什么解析不出预期字段"。
# 解析失败时会把页面 HTML 作为 span event 上报(上限可配),便于事后复现。
RAKUTEN_OTEL_ENABLED=false
RAKUTEN_OTEL_ENDPOINT=https://oltp.jerryyan.top/v1/traces
# 服务名按服务覆盖;不设时两侧 main.py 分别按 rakuten-scraping / rakuten-trading 落地
RAKUTEN_OTEL_SERVICE_NAME=
# OTLP 鉴权头(形如 key=val,key=val);当前 endpoint 裸跑,留空
RAKUTEN_OTEL_HEADERS=
# 失败 HTML 快照上限字节(默认 2MB,超出截断并标注 truncated=true)
RAKUTEN_OTEL_SNAPSHOT_MAX_BYTES=2000000
+1
View File
@@ -12,3 +12,4 @@ openapi.json
# 登录态目录:含可冒充账号的 cookie,绝不可提交
.auth/
.claude
+11 -1
View File
@@ -7,6 +7,11 @@
# 镜像默认包含 Playwright 浏览器兜底(与 .env.example 的
# RAKUTEN_BROWSER_FALLBACK_ENABLED=true 对齐),首次构建多约 400MB。
# 若要裁剪,可改为 uv sync --no-dev(去掉 --extra browser)。
#
# 镜像默认开启 OpenTelemetry traces:RAKUTEN_OTEL_ENABLED=true +
# RAKUTEN_OTEL_ENDPOINT=https://oltp.jerryyan.top/v1/traces 写入 ENV。
# 容器一启动就把抓取-解析链路导出到自建 OTLP 后端,便于排查"抓回的内容
# 为什么解析不出预期字段"。endpoint 裸跑(无鉴权);需要时运行时覆盖。
# ----------------------------- builder -----------------------------
FROM registry.jerryyan.top/library/python:3.13-slim AS builder
@@ -46,7 +51,12 @@ ENV PYTHONUNBUFFERED=1 \
# Playwright 浏览器二进制走 npmmirror 镜像,避免从 azureedge 拉取缓慢
PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright \
RAKUTEN_APP_HOST=0.0.0.0 \
RAKUTEN_APP_PORT=31107
RAKUTEN_APP_PORT=31107 \
# OpenTelemetry traces 默认开启:镜像一启动就把抓取-解析链路导出到自建 OTLP endpoint。
# endpoint 裸跑(无鉴权);如部署到需要鉴权的网关,运行时覆盖 RAKUTEN_OTEL_HEADERS 即可。
# 服务名(rakuten-scraping / rakuten-trading)由两侧 main.py 显式设定,此处不覆盖。
RAKUTEN_OTEL_ENABLED=true \
RAKUTEN_OTEL_ENDPOINT=https://oltp.jerryyan.top/v1/traces
# Chromium 运行所需的最小系统依赖。
# 这里显式列出而不是用 `playwright install-deps`,是因为后者要求 root + apt
Vendored
+9 -30
View File
@@ -25,9 +25,6 @@ pipeline {
booleanParam(name: 'SKIP_TEST',
defaultValue: false,
description: '跳过单测(紧急发版用,正常构建不要勾)')
booleanParam(name: 'PUSH_LATEST',
defaultValue: true,
description: '同时打 :latest 并推送')
}
environment {
@@ -87,32 +84,18 @@ pipeline {
stage('Build image') {
steps {
script {
// 同时打两个 tag:精确 tag + 可选 latest
def tags = ["-t ${env.IMAGE_NAME}:${env.IMAGE_TAG}"]
if (params.PUSH_LATEST) {
tags << "-t ${env.IMAGE_NAME}:latest"
}
sh """
docker build ${tags.join(' ')} \
--label org.opencontainers.image.revision=${env.GIT_SHA} \
--label org.opencontainers.image.version=${env.IMAGE_TAG} \
-f Dockerfile .
"""
}
sh """
docker build \
-t ${env.IMAGE_NAME}:${env.IMAGE_TAG} \
-t ${env.IMAGE_NAME}:latest \
--label org.opencontainers.image.revision=${env.GIT_SHA} \
--label org.opencontainers.image.version=${env.IMAGE_TAG} \
-f Dockerfile .
"""
}
}
stage('Push image') {
// 只在 master / 手动构建时推送;PR 构建只产镜像不推
when {
anyOf {
branch 'master'
branch 'main'
buildingTag()
expression { return !params.IMAGE_TAG?.trim().isEmpty() && params.IMAGE_TAG != "${env.BUILD_NUMBER}-${env.GIT_SHA}" }
}
}
steps {
withCredentials([usernamePassword(
credentialsId: 'gitea-registry',
@@ -122,12 +105,8 @@ pipeline {
sh """
echo "\$REGISTRY_PASS" | docker login git.jerryyan.net -u "\$REGISTRY_USER" --password-stdin
docker push ${env.IMAGE_NAME}:${env.IMAGE_TAG}
docker push ${env.IMAGE_NAME}:latest
"""
script {
if (params.PUSH_LATEST) {
sh "docker push ${env.IMAGE_NAME}:latest"
}
}
}
}
}
+4
View File
@@ -30,6 +30,10 @@
`tests/test_architecture.py` 会守着)。交易侧需要商品信息时,走抓取服务的 HTTP 接口——
下单要用的 `purchase` 块本来就是抓取服务的对外契约。
抓取服务部署在服务器,交易服务部署在本地(便于管理账号、排查支付问题),本地在 NAT 后
没有公网入口,因此下单请求不是推进来的,而是由本地长轮询主动领取。
任务网关与本地 worker 的规格见 [docs/order-gateway.md](docs/order-gateway.md)(待实现)。
```
app/
shared/ 配置、错误码、日志、响应信封与鉴权(两侧共用,不认识两侧)
+6
View File
@@ -27,6 +27,7 @@ from app.scraping.services.site_session import SiteSession
from app.shared.api import register_exception_handlers
from app.shared.config import get_settings
from app.shared.logging_setup import configure_logging
from app.shared.telemetry import instrument_app, setup_telemetry, shutdown_telemetry
logger = logging.getLogger(__name__)
@@ -56,6 +57,9 @@ async def lifespan(app: FastAPI):
app.state.container = container
configure_logging(container.settings)
# telemetry 必须在创建任何 httpx.AsyncClient 之前完成 setup,否则 httpx
# 自动 instrumentation 抓不到现有客户端的请求。
setup_telemetry(container.settings, service_name="rakuten-scraping")
logger.info("抓取服务启动:%s:%s", container.settings.app_host, container.settings.app_port)
logger.info("日志级别:%s", container.settings.log_level)
logger.info("当前环境:%s", container.settings.app_env)
@@ -67,6 +71,7 @@ async def lifespan(app: FastAPI):
await container.rakuma_session.close()
await container.site_session.close()
await container.browser_fallback.close()
shutdown_telemetry()
def create_app() -> FastAPI:
@@ -76,6 +81,7 @@ def create_app() -> FastAPI:
app.include_router(scrape_router)
app.include_router(rakuma_router)
register_exception_handlers(app)
instrument_app(app)
return app
+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:
+62 -36
View File
@@ -18,6 +18,7 @@ import logging
from typing import Any
import httpx
from opentelemetry import trace
from app.scraping.core import rakuma_site as site
from app.shared.config import Settings
@@ -27,6 +28,7 @@ from app.shared.errors import (
UpstreamBlockedError,
UpstreamRequestError,
)
from app.shared.telemetry import snapshot
logger = logging.getLogger(__name__)
@@ -89,47 +91,71 @@ class RakumaSession:
if client is None:
raise UpstreamRequestError("ラクマ 会话尚未初始化")
tracer = trace.get_tracer(__name__)
max_attempts = max(1, self._settings.http_max_attempts)
last_error = "unknown error"
last_text: str | None = None
try:
await asyncio.wait_for(
self._semaphore.acquire(),
timeout=self._settings.request_timeout_seconds,
)
except TimeoutError as exc:
raise ResourceBusyError() from exc
with tracer.start_as_current_span("scrape.fetch") as span:
span.set_attribute("scrape.url", url)
span.set_attribute("scrape.profile", "rakuma")
try:
for attempt in range(1, max_attempts + 1):
try:
response = await client.get(url)
except httpx.HTTPError as exc:
last_error = f"{type(exc).__name__}: {exc}"
try:
await asyncio.wait_for(
self._semaphore.acquire(),
timeout=self._settings.request_timeout_seconds,
)
except TimeoutError as exc:
err = ResourceBusyError()
span.record_exception(err)
span.set_attribute("scrape.fail_reason", "ResourceBusyError")
raise err from exc
try:
for attempt in range(1, max_attempts + 1):
span.set_attribute("scrape.attempts", attempt)
try:
response = await client.get(url)
except httpx.HTTPError as exc:
last_error = f"{type(exc).__name__}: {exc}"
logger.warning(
"ラクマ 抓取请求异常:url=%s attempt=%s/%s err=%s",
url, attempt, max_attempts, last_error,
)
continue
if response.status_code == 404:
err = ItemNotFoundError(f"Page not found: {url}")
span.record_exception(err)
span.set_attribute("scrape.fail_reason", "ItemNotFoundError")
raise err
if response.status_code < 400:
span.set_attribute("scrape.last_status_code", response.status_code)
span.set_attribute("scrape.final_url", str(response.url))
span.set_attribute("scrape.html_bytes", len(response.text))
return response.text
last_error = (
f"upstream status {response.status_code}"
if response.status_code >= 500
else f"status {response.status_code}"
)
last_text = response.text
span.set_attribute("scrape.last_status_code", response.status_code)
span.set_attribute("scrape.html_bytes", len(response.text))
logger.warning(
"ラクマ 抓取请求异常:url=%s attempt=%s/%s err=%s",
"ラクマ 抓取结果异常:url=%s attempt=%s/%s %s",
url, attempt, max_attempts, last_error,
)
continue
if response.status_code == 404:
raise ItemNotFoundError(f"Page not found: {url}")
if response.status_code < 400:
return response.text
last_error = (
f"upstream status {response.status_code}"
if response.status_code >= 500
else f"status {response.status_code}"
)
logger.warning(
"ラクマ 抓取结果异常:url=%s attempt=%s/%s %s",
url, attempt, max_attempts, last_error,
)
if last_error.startswith("upstream status") or ":" in last_error:
raise UpstreamRequestError(f"Upstream request failed: {last_error}")
raise UpstreamBlockedError(f"Failed to fetch {url}: {last_error}")
finally:
self._semaphore.release()
if last_error.startswith("upstream status") or ":" in last_error:
err = UpstreamRequestError(f"Upstream request failed: {last_error}")
else:
err = UpstreamBlockedError(f"Failed to fetch {url}: {last_error}")
span.record_exception(err)
span.set_attribute("scrape.fail_reason", type(err).__name__)
snapshot(span, "scrape.failed_html", last_text, self._settings.otel_snapshot_max_bytes)
raise err
finally:
self._semaphore.release()
+128 -64
View File
@@ -8,6 +8,8 @@ from __future__ import annotations
import logging
from opentelemetry import trace
from app.scraping.core import site
from app.shared.config import Settings
from app.shared.errors import ScrapeParseError
@@ -43,8 +45,10 @@ from app.scraping.utils.urls import (
split_item_url,
split_shop_url,
)
from app.shared.telemetry import snapshot
logger = logging.getLogger(__name__)
tracer = trace.get_tracer(__name__)
class RakutenClient:
@@ -62,19 +66,32 @@ class RakutenClient:
url = build_search_url(payload)
logger.info("抓取搜索页:url=%s", url)
html = await self._session.fetch_html(url, mobile=False)
state = extract_initial_state(html)
result = parse_search(
state,
request_url=url,
page=payload.page,
exclude_ads=payload.exclude_ads,
)
logger.info(
"搜索完成:url=%s items=%s ads=%s total=%s",
url, len(result.items), result.ad_count, result.total_count,
)
return result
html: str | None = None
with tracer.start_as_current_span("parse.rakuten.search") as span:
span.set_attribute("parse.site", "rakuten")
span.set_attribute("parse.op", "search")
try:
html = await self._session.fetch_html(url, mobile=False)
state = extract_initial_state(html)
result = parse_search(
state,
request_url=url,
page=payload.page,
exclude_ads=payload.exclude_ads,
)
span.set_attribute("parse.items", len(result.items))
span.set_attribute("parse.ads", result.ad_count)
span.set_attribute("parse.total", result.total_count)
logger.info(
"搜索完成:url=%s items=%s ads=%s total=%s",
url, len(result.items), result.ad_count, 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 genres(self, payload: GenreRequest) -> GenreData:
"""抓取分类树:顶层分类列表,或指定分类的信息与直接子分类"""
@@ -82,14 +99,25 @@ class RakutenClient:
url = build_genre_url(genre_id)
logger.info("抓取分类树:url=%s genre_id=%s", url, genre_id)
html = await self._session.fetch_html(url, mobile=False)
state = extract_initial_state(html)
result = parse_genres(state, genre_id=genre_id)
logger.info(
"分类树完成:genre_id=%s name=%s children=%s",
result.genre_id or "root", result.name, len(result.children),
)
return result
html: str | None = None
with tracer.start_as_current_span("parse.rakuten.genres") as span:
span.set_attribute("parse.site", "rakuten")
span.set_attribute("parse.op", "genres")
try:
html = await self._session.fetch_html(url, mobile=False)
state = extract_initial_state(html)
result = parse_genres(state, genre_id=genre_id)
span.set_attribute("parse.children", len(result.children))
logger.info(
"分类树完成:genre_id=%s name=%s children=%s",
result.genre_id or "root", result.name, len(result.children),
)
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 shop_detail(self, payload: ShopDetailRequest) -> ShopDetailData:
"""抓取商家详情"""
@@ -100,14 +128,25 @@ class RakutenClient:
url = build_shop_url(shop_code)
logger.info("抓取店铺页:url=%s", url)
html = await self._session.fetch_html(url, mobile=False)
state = extract_initial_state(html)
result = parse_shop_detail(state, shop_code=shop_code, html=html)
logger.info(
"店铺详情完成:shop_code=%s shop_id=%s name=%s reviews=%s",
result.shop_code, result.shop_id, result.shop_name, result.review_count,
)
return result
html: str | None = None
with tracer.start_as_current_span("parse.rakuten.shop_detail") as span:
span.set_attribute("parse.site", "rakuten")
span.set_attribute("parse.op", "shop_detail")
try:
html = await self._session.fetch_html(url, mobile=False)
state = extract_initial_state(html)
result = parse_shop_detail(state, shop_code=shop_code, html=html)
span.set_attribute("parse.reviews", result.review_count)
logger.info(
"店铺详情完成:shop_code=%s shop_id=%s name=%s reviews=%s",
result.shop_code, result.shop_id, result.shop_name, result.review_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 shop_items(self, payload: ShopItemsRequest) -> SearchResultData:
"""抓取商家名下的商品列表
@@ -116,14 +155,25 @@ class RakutenClient:
`sid=` 限定店铺后的产物,因此这里转成一次搜索。只给 shop_code 时
需要先取一次店铺详情换出 shop_id。
"""
shop_id = payload.shop_id
if shop_id is None:
detail = await self.shop_detail(ShopDetailRequest(shop_code=payload.shop_code))
if detail.shop_id is None:
raise ScrapeParseError(f"未能取得店铺 {payload.shop_code} 的 shop_id")
shop_id = detail.shop_id
with tracer.start_as_current_span("parse.rakuten.shop_items") as span:
span.set_attribute("parse.site", "rakuten")
span.set_attribute("parse.op", "shop_items")
try:
shop_id = payload.shop_id
if shop_id is None:
detail = await self.shop_detail(ShopDetailRequest(shop_code=payload.shop_code))
if detail.shop_id is None:
raise ScrapeParseError(f"未能取得店铺 {payload.shop_code} 的 shop_id")
shop_id = detail.shop_id
return await self.search(payload.to_search_request(shop_id))
result = await self.search(payload.to_search_request(shop_id))
span.set_attribute("parse.items", len(result.items))
span.set_attribute("parse.total", result.total_count)
return result
except Exception:
span.record_exception()
span.set_attribute("parse.fail_reason", "parse_error")
raise
async def item_detail(self, payload: ItemDetailRequest) -> ItemDetailData:
"""抓取商品详情"""
@@ -137,33 +187,47 @@ class RakutenClient:
url = build_item_url(shop_code, item_code)
logger.info("抓取商品详情:url=%s", url)
# 部分官方旗舰店的商品页会跳出市场域名,落到各自的独立站点;
# 校验与解析都按最终落地域名分派,落到未登记站点时由校验器抛错。
page = await self._session.fetch(
url, mobile=True, validator=build_item_page_validator(url)
)
if host_of(page.url) == site.ITEM_HOST:
detail = parse_item_detail(
extract_initial_state(page.html),
item_url=url,
shop_code=shop_code,
include_sku_variants=payload.include_sku_variants,
)
else:
detail = parse_subsite_item(
SubsitePage(
html=page.html,
requested_url=url,
final_url=page.url,
shop_code=shop_code,
item_code=item_code,
include_sku_variants=payload.include_sku_variants,
html: str | None = None
with tracer.start_as_current_span("parse.rakuten.item_detail") as span:
span.set_attribute("parse.site", "rakuten")
span.set_attribute("parse.op", "item_detail")
try:
# 部分官方旗舰店的商品页会跳出市场域名,落到各自的独立站点;
# 校验与解析都按最终落地域名分派,落到未登记站点时由校验器抛错。
page = await self._session.fetch(
url, mobile=True, validator=build_item_page_validator(url)
)
)
html = page.html
logger.info(
"详情完成:url=%s source=%s name=%s price=%s skus=%s",
url, detail.source, detail.item_name[:40], detail.price, detail.sku.variant_count,
)
return detail
if host_of(page.url) == site.ITEM_HOST:
detail = parse_item_detail(
extract_initial_state(page.html),
item_url=url,
shop_code=shop_code,
include_sku_variants=payload.include_sku_variants,
)
else:
detail = parse_subsite_item(
SubsitePage(
html=page.html,
requested_url=url,
final_url=page.url,
shop_code=shop_code,
item_code=item_code,
include_sku_variants=payload.include_sku_variants,
)
)
span.set_attribute("parse.source", detail.source)
span.set_attribute("parse.price", detail.price)
span.set_attribute("parse.variant_count", detail.sku.variant_count)
logger.info(
"详情完成:url=%s source=%s name=%s price=%s skus=%s",
url, detail.source, detail.item_name[:40], detail.price, detail.sku.variant_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
+78 -48
View File
@@ -19,6 +19,7 @@ from dataclasses import dataclass, field
from typing import Any
import httpx
from opentelemetry import trace
from app.scraping.core import site
from app.shared.config import Settings
@@ -30,6 +31,7 @@ from app.shared.errors import (
)
from app.scraping.parsers.state import PageValidator, require_state_marker
from app.scraping.services.browser_fallback import BrowserFallback
from app.shared.telemetry import snapshot
logger = logging.getLogger(__name__)
@@ -152,66 +154,94 @@ class SiteSession:
ResourceBusyError: 等待并发槽位超时
AppError: 校验器判定为不可重试的失败(如落到不支持的站点)
"""
tracer = trace.get_tracer(__name__)
profile = self._profiles["sp" if mobile else "pc"]
max_attempts = max(1, self._settings.http_max_attempts)
validate = validator or require_state_marker
last_error: str = "unknown error"
# 失败时供 trace snapshot 上报:保留最后一次拿到的响应体(含挑战页 / 5xx 响应)。
last_text: str | None = None
try:
await asyncio.wait_for(
self._semaphore.acquire(),
timeout=self._settings.request_timeout_seconds,
)
except TimeoutError as exc:
raise ResourceBusyError() from exc
with tracer.start_as_current_span("scrape.fetch") as span:
span.set_attribute("scrape.url", url)
span.set_attribute("scrape.profile", profile.name)
try:
for attempt in range(1, max_attempts + 1):
await self._ensure_warm(profile)
try:
response = await profile.client.get(url)
except httpx.HTTPError as exc:
last_error = f"{type(exc).__name__}: {exc}"
try:
await asyncio.wait_for(
self._semaphore.acquire(),
timeout=self._settings.request_timeout_seconds,
)
except TimeoutError as exc:
err = ResourceBusyError()
span.record_exception(err)
span.set_attribute("scrape.fail_reason", "ResourceBusyError")
raise err from exc
try:
for attempt in range(1, max_attempts + 1):
span.set_attribute("scrape.attempts", attempt)
await self._ensure_warm(profile)
try:
response = await profile.client.get(url)
except httpx.HTTPError as exc:
last_error = f"{type(exc).__name__}: {exc}"
logger.warning(
"抓取请求异常:url=%s profile=%s attempt=%s/%s err=%s",
url, profile.name, attempt, max_attempts, last_error,
)
continue
if response.status_code == 404:
err = ItemNotFoundError(f"Page not found: {url}")
span.record_exception(err)
span.set_attribute("scrape.fail_reason", "ItemNotFoundError")
raise err
text = response.text
final_url = str(response.url)
span.set_attribute("scrape.last_status_code", response.status_code)
span.set_attribute("scrape.final_url", final_url)
span.set_attribute("scrape.html_bytes", len(text))
if response.status_code < 400:
# 校验器可能直接抛 AppError 表示「重试也没用」,此处不拦截
reason = validate(text, final_url)
if reason is None:
return FetchedPage(html=text, url=final_url)
last_error = self._refine_failure(reason, text)
if last_error.startswith("challenge page detected"):
span.set_attribute("scrape.challenge_detected", True)
else:
last_error = self._describe_error_status(response.status_code)
last_text = text
logger.warning(
"抓取请求异常:url=%s profile=%s attempt=%s/%s err=%s",
"抓取结果异常:url=%s profile=%s attempt=%s/%s %s",
url, profile.name, attempt, max_attempts, last_error,
)
continue
if response.status_code == 404:
raise ItemNotFoundError(f"Page not found: {url}")
if attempt >= max_attempts:
break
text = response.text
final_url = str(response.url)
if response.status_code < 400:
# 校验器可能直接抛 AppError 表示「重试也没用」,此处不拦截
reason = validate(text, final_url)
if reason is None:
return FetchedPage(html=text, url=final_url)
last_error = self._refine_failure(reason, text)
# 第一次失败先便宜地换一套 cookie;仍失败才动用浏览器
if attempt == 1:
await self._invalidate(profile)
else:
page = await self._escalate_to_browser(profile, url, validate)
if page is not None:
span.set_attribute("scrape.fell_back_to_browser", True)
span.set_attribute("scrape.final_url", page.url)
return page
if self._is_server_error(last_error):
err = UpstreamRequestError(f"Upstream request failed: {last_error}")
else:
last_error = self._describe_error_status(response.status_code)
logger.warning(
"抓取结果异常:url=%s profile=%s attempt=%s/%s %s",
url, profile.name, attempt, max_attempts, last_error,
)
if attempt >= max_attempts:
break
# 第一次失败先便宜地换一套 cookie;仍失败才动用浏览器
if attempt == 1:
await self._invalidate(profile)
else:
page = await self._escalate_to_browser(profile, url, validate)
if page is not None:
return page
if self._is_server_error(last_error):
raise UpstreamRequestError(f"Upstream request failed: {last_error}")
raise UpstreamBlockedError(f"Blocked while fetching {url}: {last_error}")
finally:
self._semaphore.release()
err = UpstreamBlockedError(f"Blocked while fetching {url}: {last_error}")
span.record_exception(err)
span.set_attribute("scrape.fail_reason", type(err).__name__)
snapshot(span, "scrape.failed_html", last_text, self._settings.otel_snapshot_max_bytes)
raise err
finally:
self._semaphore.release()
# ---- 内部 ----
+12
View File
@@ -82,6 +82,18 @@ class Settings(BaseSettings):
proxy_username: str | None = None
proxy_password: str | None = None
# ---- OpenTelemetry traces(通用,可选;默认关闭)----
# 启用后把抓取-解析链路以 span 导出到 OTLP/HTTP endpoint,用于排查"抓到
# 内容能否被解析"。两侧 main.py 在 lifespan 启动时按服务名分别初始化。
otel_enabled: bool = False
otel_endpoint: str | None = None # OTLP/HTTP traces endpoint,例如 https://oltp.jerryyan.top/v1/traces
otel_service_name: str = "rakuten" # 仅作兜底;实际值由两侧 main.py 显式覆盖
otel_headers: str | None = None # OTLP 鉴权头,形如 "k=v,k=v";当前 endpoint 裸跑,留空
otel_export_interval_ms: int = 5000
# 解析失败时把页面 HTML 作为 span event 上报的上限字节;超出截断并标注。
# 单个搜索页 HTML 可达 200KB-2MB,调高时同步关注 OTLP 单次请求大小限制。
otel_snapshot_max_bytes: int = 2_000_000
# ---- 登录态与下单配置(仅交易服务)----
# 人工登录一次后落盘的 Playwright storage_state 目录(相对项目根目录)。
# 目录里是可直接冒充账号的 cookie,务必不要提交到版本库。
+145
View File
@@ -0,0 +1,145 @@
"""OpenTelemetry traces 接入:仅在 otel_enabled=true 时初始化,否则全 noop
抓取与交易两个进程在 lifespan 启动时各自调用 `setup_telemetry(settings,
service_name=...)`:注册 TracerProvider + OTLP/HTTP exporter + 自动
instrumentation(FastAPI、httpx)。失败时(如 endpoint 不可达)不阻断主流程,
仅打日志;traces 是辅助观测,不应让进程起不来。
`shutdown_telemetry` 在 lifespan 关闭时 force_flush 后再 shutdown,确保缓冲区
里的 span 都已上报。
OTel SDK 默认的 ProxyTracerProvider 在 setup 之前就能用(noop span),所以
其它代码里直接 `trace.get_tracer(__name__)` + `start_as_current_span` 即可,
不必关心 telemetry 是否启用——禁用时 span 不会真正产生与上报。
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.trace.sampling import ALWAYS_ON
from opentelemetry.trace import Span
from opentelemetry.util.types import AttributeValue
from app.shared.config import Settings
if TYPE_CHECKING:
from fastapi import FastAPI
logger = logging.getLogger(__name__)
# 全局 provider 引用,用于 instrument_app / shutdown 时判断当前是否已初始化。
# 显式持有比依赖 trace.get_tracer_provider() 的类型判断更稳——后者在测试场景
# 下可能被其它用例改动全局状态。
_provider: TracerProvider | None = None
def setup_telemetry(settings: Settings, *, service_name: str) -> None:
"""初始化 OTel:TracerProvider + OTLP exporter + httpx 自动 instrumentation。
- `otel_enabled=False` 或 endpoint 未配时仅打日志,不做任何事。
- 必须在创建任何 httpx.AsyncClient 之前调用,否则 httpx 不会被打桩。
两侧 main.py 的 lifespan 已把 setup 放在 site_session.start() 之前。
- 重复调用安全(_provider 已设时直接返回)。
"""
global _provider
if _provider is not None:
return
if not settings.otel_enabled or not settings.otel_endpoint:
logger.info("OpenTelemetry 未启用(endpoint 或 otel_enabled 未配置)")
return
resource = Resource.create({SERVICE_NAME: service_name})
provider = TracerProvider(resource=resource, sampler=ALWAYS_ON)
exporter = OTLPSpanExporter(
endpoint=settings.otel_endpoint,
headers=_parse_headers(settings.otel_headers),
timeout=10,
)
provider.add_span_processor(
BatchSpanProcessor(
exporter,
schedule_delay_millis=settings.otel_export_interval_ms,
)
)
trace.set_tracer_provider(provider)
_provider = provider
# httpx 是抓取/交易两侧唯一的外部 HTTP 客户端,打桩后所有 AsyncClient 请求
# 自动产生 CLIENT span。失败不影响主链路:进程仍可运行,只是看不到 span。
try:
HTTPXClientInstrumentor().instrument()
except Exception:
logger.warning("httpx 自动 instrumentation 失败", exc_info=True)
logger.info(
"OpenTelemetry 已启用:endpoint=%s service=%s",
settings.otel_endpoint,
service_name,
)
def instrument_app(app: "FastAPI") -> None:
"""FastAPI 应用打桩;未初始化时 noop,调用顺序无要求。"""
if _provider is None:
return
FastAPIInstrumentor.instrument_app(app)
def shutdown_telemetry() -> None:
"""flush + shutdown;幂等,未初始化时直接返回。"""
global _provider
if _provider is None:
return
try:
_provider.force_flush()
_provider.shutdown()
except Exception:
logger.debug("关闭 OpenTelemetry provider 失败", exc_info=True)
_provider = None
def snapshot(span: Span, name: str, html: str | None, max_bytes: int) -> None:
"""把 HTML 作为 span event 上报,超 max_bytes 截断并标注。
用于解析失败时复现页面:span 自身只放结构化指标(items 数、source 等),
完整 HTML 体量大、含商品/价格内容,仅在失败分支通过 event 携带。
"""
if html is None or not html:
return
original_bytes = len(html)
truncated = original_bytes > max_bytes
payload = html if not truncated else html[:max_bytes]
attributes: dict[str, AttributeValue] = {
"snapshot.html": payload,
"snapshot.original_bytes": original_bytes,
}
if truncated:
attributes["snapshot.truncated"] = True
span.add_event(name, attributes=attributes)
def _parse_headers(raw: str | None) -> list[tuple[str, str]] | None:
"""解析 "k1=v1,k2=v2" 形式的 header 配置;空输入返回 None。"""
if not raw or not raw.strip():
return None
pairs: list[tuple[str, str]] = []
for chunk in raw.split(","):
key, sep, value = chunk.partition("=")
if not sep or not key.strip():
continue
pairs.append((key.strip(), value.strip()))
return pairs or None
def is_initialized() -> bool:
"""供测试断言使用。"""
return _provider is not None
+5
View File
@@ -22,6 +22,7 @@ from fastapi import FastAPI
from app.shared.api import register_exception_handlers
from app.shared.config import get_settings
from app.shared.logging_setup import configure_logging
from app.shared.telemetry import instrument_app, setup_telemetry, shutdown_telemetry
from app.trading.api.routes.auth import router as auth_router
from app.trading.api.routes.health import router as health_router
from app.trading.container import TradingContainer
@@ -43,6 +44,8 @@ async def lifespan(app: FastAPI):
app.state.container = container
configure_logging(container.settings)
# 与抓取侧同样:必须在创建 httpx 客户端之前 setup。
setup_telemetry(container.settings, service_name="rakuten-trading")
logger.info(
"交易服务启动:%s:%s",
container.settings.trading_host,
@@ -54,6 +57,7 @@ async def lifespan(app: FastAPI):
yield
finally:
await container.auth_session.close()
shutdown_telemetry()
def create_app() -> FastAPI:
@@ -62,6 +66,7 @@ def create_app() -> FastAPI:
app.include_router(health_router)
app.include_router(auth_router)
register_exception_handlers(app)
instrument_app(app)
return app
+328
View File
@@ -0,0 +1,328 @@
# 下单任务网关(order-gateway)与本地 worker 规格
本文是交付给实现方的规格。目标读者是写代码的人或工具,不是使用者。
## 1. 背景与硬约束
抓取服务部署在服务器(有公网入口),下单服务部署在本地(便于管理账号、排查支付问题),
本地机在 NAT 后面**没有公网入口**。因此下单请求不能从服务器「推」进来,只能由本地
「拉」出去取。
四条不可协商的约束,实现时任何取舍都不得违反:
1. **下单是不可逆的写操作**。任何自动重试、自动重投都可能变成重复下单。
2. **同一账号的写操作必须串行**。全局同时最多允许一个任务处于执行中。
3. **本地不开任何入站端口**。所有跨机通信都由本地发起出站请求。
4. **证据留在本地**。HTML 快照与截图不回传,服务器只存摘要与本地相对路径。
## 2. 拓扑
```
上游业务系统(服务器)
│ ① POST /api/orders 下单意图 → task_id
order-gateway(服务器:任务队列 + 状态镜像,本文要新建的服务)
▲ ② GET /api/orders/lease 本地长轮询领取(出站)
│ ③ POST /api/orders/{id}/report 本地回报状态(出站)
trading worker(本地,app/trading 内,零入站端口)
│ ④ 加购 → 下单 → 付款,每步落证据到本地磁盘
乐天 / ラクマ
```
抓取服务(:31107)不参与这条链路的编排。本地需要商品数据时,直接出站请求抓取服务的
`POST /api/item_detail``purchase` 块(见 README「加购与下单」)。
**order-gateway 必须是独立部署单元,不能塞进抓取服务**:抓取服务已定为无状态、可多开实例,
而任务队列有状态,多实例会抢同一批任务。
## 3. 两层状态,不要混为一谈
| | 任务状态(task.status) | 订单状态(order state) |
| --- | --- | --- |
| 含义 | 这个任务被谁领了、做完没有 | 这笔订单在站点上走到哪一步 |
| 权威方 | order-gateway | 本地 trading(gateway 只存镜像) |
| 取值 | `queued` `leased` `running` `succeeded` `failed` `needs_human` `stale` | `created` `in_cart` `ordered` `awaiting_payment` `paid` `shipped` `delivered` `cancelled` |
任务状态迁移:
```
queued ──lease──> leased ──首次 report──> running ──terminal report──> succeeded
▲ │ │ └─> failed
│ └──── 租约过期 ──────────┴──> stale └─> needs_human
└── 只有人工介入才能从 stale 回到 queued(见 §5)
```
## 4. order-gateway
技术栈与现有仓库保持一致:Python 3.13 + FastAPI + pydantic-settings + SQLite,
**不引入 Redis / MQ**。响应信封、鉴权与错误码沿用现有约定(见 §8)。
职责边界——gateway **不做**这些事:不解析站点页面、不接触账号 cookie、不存证据原文、
不判断订单业务规则。它只是一个带租约的任务队列 + 状态镜像 + 查询入口。
### 4.1 数据模型
```sql
CREATE TABLE tasks (
task_id TEXT PRIMARY KEY, -- 幂等键,见 §4.2
site TEXT NOT NULL, -- rakuten | rakuma
intent_json TEXT NOT NULL, -- 下单意图原文,gateway 不解释内容
status TEXT NOT NULL, -- 见 §3
lease_owner TEXT, -- worker_id
lease_expires_at TEXT, -- ISO8601 UTC
lease_count INTEGER NOT NULL DEFAULT 0, -- 被领取过几次,>1 即发生过恢复
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_tasks_pending ON tasks(status, created_at);
-- 状态镜像:append-only,最新一条即当前状态。不做 UPDATE,便于事后复盘。
CREATE TABLE task_reports (
task_id TEXT NOT NULL,
state TEXT NOT NULL, -- 订单状态,见 §3
payable_yen INTEGER, -- 实际应付金额
pay_deadline TEXT, -- 付款期限 ISO8601,コンビニ払い 常见三天
site_order_id TEXT, -- 站点侧订单号
evidence_ref TEXT, -- 本地相对路径,如 20260727-abc123/03-order-confirm
detail TEXT,
reported_at TEXT NOT NULL,
PRIMARY KEY (task_id, state) -- 同一状态重复上报视为同一次,见 §4.5
);
CREATE TABLE workers (
worker_id TEXT PRIMARY KEY,
last_seen_at TEXT NOT NULL -- 每次 lease 请求刷新,兼作心跳
);
```
### 4.2 POST /api/orders — 上游提交下单意图
请求:
```jsonc
{
"task_id": "po-20260727-0001", // 可选,上游自带的幂等键;不传则服务端生成
"site": "rakuten",
"intent": { // gateway 原样透传,结构由 trading 侧定义
"item_url": "https://item.rakuten.co.jp/shop/code/",
"quantity": 1,
"variant_id": "...", // 多规格商品必填
"options": {},
"max_total_yen": 30000 // 可选,覆盖本次的金额上限
}
}
```
响应 `data``{"task_id": "...", "status": "queued", "created": true}`
**幂等**:同一个 `task_id` 重复提交不新建任务,返回既有任务且 `created=false`
上游重发不会变成两单。
### 4.3 GET /api/orders/lease — 本地长轮询领取
查询参数:`worker_id`(必填)、`wait`(可选,默认 30,最大 60 秒)、`site`(可选,限定站点)。
行为:
- 刷新 `workers.last_seen_at`(这就是心跳,不另建心跳接口)。
- **全局并发度 1**:若已存在 `leased``running` 的任务,直接返回空,绝不发第二个任务。
按站点隔离时可放宽为「每 site 最多 1 个」,但同一 site 内必须串行。
- 有可领任务时,在一个事务里 `queued → leased`、写入 `lease_owner`
`lease_expires_at = now + lease_ttl`(建议 300 秒),`lease_count += 1`
- 无任务时**挂起**到 `wait` 秒后返回 `data: null`,HTTP 仍为 200。
实现可用轮询数据库(间隔 ≤1 秒)或条件变量,不要求真正的推送。
响应 `data`(有任务时):
```jsonc
{
"task_id": "po-20260727-0001",
"site": "rakuten",
"intent": { },
"lease_expires_at": "2026-07-27T09:15:00Z",
"lease_count": 1, // >1 表示这是恢复领取,worker 必须先核对,见 §5
"known_state": "ordered" // 之前上报过的最新订单状态;首次领取为 null
}
```
### 4.4 POST /api/orders/{task_id}/renew — 续租
参数 `worker_id`。执行时间可能超过租约 TTL(下单页面慢、付款要等),worker
每 60 秒续一次。校验 `lease_owner` 匹配且任务未终结,否则报 `6002`
### 4.5 POST /api/orders/{task_id}/report — 本地回报
请求:
```jsonc
{
"worker_id": "local-01",
"state": "awaiting_payment",
"payable_yen": 12800,
"pay_deadline": "2026-07-30T14:59:00Z",
"site_order_id": "266123-20260727-0001234",
"evidence_ref": "po-20260727-0001/03-order-confirm",
"detail": "コンビニ払い,付款单号 12345678",
"terminal": false // true 表示这个任务到此结束
}
```
行为:
- 校验 `worker_id == lease_owner`,否则 `6002`
- 首次 report 把任务从 `leased` 推到 `running`
- 写入 `task_reports`。**同一 `(task_id, state)` 重复上报是幂等的**——网络抖动导致 worker
重发时覆盖同一行,不产生第二条记录。
- `terminal=true` 时释放租约,任务置为:`state=paid`(或按 §6 的完成定义)→ `succeeded`
worker 明确失败 → `failed`;worker 报告需要人介入(如弹了 3DS)→ `needs_human`
### 4.6 查询接口
- `GET /api/orders/{task_id}` — 任务 + 最新状态 + 完整状态历史。
- `GET /api/orders?status=&site=&limit=&offset=` — 列表,运维与上游对账用。
- `GET /health` — 含 `queued` 数量、当前 `leased/running` 任务、各 worker 的
`last_seen_seconds`
### 4.7 告警(gateway 侧只做这两条兜底)
1. **worker 失联**`now - last_seen_at > 5 分钟`(正常每 30 秒会来一次 lease)。
2. **任务长时间无人领**`queued``now - created_at > 10 分钟`
付款期限监控**不在 gateway**:本地机 7×24 在线,那套逻辑放本地(§6),
gateway 不重复实现一遍定时器。
## 5. 租约过期:绝不自动重投(本文最关键的一条)
常规任务队列在租约超时后会把任务放回队列重新分发。**这里必须禁止**:本地可能已经
下单成功,只是回报那一步断网了;自动重投等于再买一次。
正确行为:
- 租约过期 → 任务置 `stale` 并告警,**不回 `queued`**,不会被 §4.3 的正常领取取到。
- 恢复只能显式发起:`POST /api/orders/{task_id}/reclaim`(参数 `worker_id`),把
`stale` 重新租给 worker,返回体里 `lease_count > 1` 且带上 `known_state`
- worker 收到 `lease_count > 1` 的任务时,**执行前必须先查站点的订单列表**,
确认这笔到底下没下(用 intent 里的商品 + 时间窗口比对,能拿到 `site_order_id`
最好)。确认已下单则直接补报状态,绝不重新提交。
- 核对不出结论时,上报 `needs_human` 交给人,不猜。
宁可卡住等人看一眼,也不赌一次重复下单。
## 6. 本地 worker(在 app/trading 内新增)
单个常驻 asyncio 任务,**串行**,不并发。主循环:
```
while running:
task = await gateway.lease(worker_id, wait=30) # 出站长轮询
if task is None:
continue
if local_db.has_finished(task.task_id): # 本地幂等闸门
await gateway.report(task, local_db.final_state(task), terminal=True)
continue
if task.lease_count > 1: # 恢复领取,见 §5
verdict = await verify_on_site(task) # 先查站点订单列表
if verdict.already_ordered:
await gateway.report(...); continue
if verdict.unknown:
await gateway.report(state="needs_human", terminal=True); continue
async with renew_lease_every(60):
await execute(task) # 见下
```
`execute` 的每一步都遵循同一个模式:**动作 → 落证据 → 写本地 SQLite → 回报 gateway**。
顺序不能颠倒,先落证据再回报,保证服务器上看到的状态一定有本地证据可查。
步骤:`加购 → 校验购物车 → 进入下单确认页 → 金额守卫 → 提交下单 → 付款 → 监控`
- **金额守卫**:从确认页解析实际应付,超过 `intent.max_total_yen`(缺省用
`RAKUTEN_ORDER_MAX_TOTAL_YEN`)直接中止并上报 `needs_human`,错误码 `5004`
- **付款**:按已定方案走自动付款;一旦检测到 3DS / 短信验证等人工环节,
立即上报 `needs_human` 并保留现场,不尝试绕过。
- **付款后监控**:本地常驻轮询订单状态与付款期限,状态变化时继续 report
(任务已 `terminal` 的仍可上报,gateway 追加到 `task_reports`)。
### 6.1 证据留痕约定
```
{RAKUTEN_EVIDENCE_DIR}/{task_id}/
01-cart-add.html 01-cart-add.png 01-cart-add.meta.json
02-cart-check.html ...
03-order-confirm.*
04-order-submit.*
05-payment.*
```
`meta.json` 至少含:请求 URL、方法、HTTP 状态码、时间戳、本地订单状态。
report 里的 `evidence_ref``{task_id}/{序号}-{步骤名}`,不含扩展名。
本地 SQLite 存订单主表 + 状态迁移事件表 + 证据索引;**它是执行事实的权威记录**,
gateway 上的镜像仅供上游查询。
## 7. 配置项
沿用 `RAKUTEN_` 前缀与现有 `.env` 机制。
gateway 侧:
- `RAKUTEN_GATEWAY_HOST` / `RAKUTEN_GATEWAY_PORT`(建议 31109)
- `RAKUTEN_GATEWAY_DB_PATH`(默认 `data/gateway.db`
- `RAKUTEN_LEASE_TTL_SECONDS`(默认 300)
- `RAKUTEN_LEASE_MAX_WAIT_SECONDS`(默认 60)
- `RAKUTEN_WORKER_OFFLINE_ALERT_SECONDS`(默认 300)
trading 侧新增:
- `RAKUTEN_ORDER_GATEWAY_URL`(留空则不启动 worker,只跑登录态接口)
- `RAKUTEN_WORKER_ID`(默认取主机名)
- `RAKUTEN_TRADING_DB_PATH`(默认 `data/trading.db`
- `RAKUTEN_EVIDENCE_DIR`(默认 `data/evidence`
- `RAKUTEN_SCRAPER_BASE_URL`(取 `purchase` 块用,如 `https://<服务器>:31107`
## 8. 对外契约沿用现有约定
- 响应信封:`{"success": bool, "msg": str, "data": T|null, "code": int}`
- 鉴权:`Authorization: Bearer <token>`,与现有服务同一个 token
- 错误码沿用 README 的表,新增 `6xxx` 段(任务编排):
| code | 含义 | HTTP |
| --- | --- | --- |
| 6001 | 任务不存在 | 404 |
| 6002 | 租约无效:不是持有者、已过期或任务已终结 | 409 |
| 6003 | 任务状态不允许该操作(如对已终结任务 reclaim) | 409 |
| 6004 | 已有任务在执行中,本次不发放(正常返回空即可,仅诊断用) | 200 |
## 9. 验收清单
实现方自测这些,全部离线可测(用假的站点交互桩):
- [ ]`task_id` 提交两次只产生一个任务
- [ ] 两个 worker 同时 lease,只有一个拿到任务
- [ ] 有任务在 `leased`/`running` 时,lease 一律返回空
- [ ] 无任务时 lease 挂起到 `wait` 秒才返回,且返回 200 + `data: null`
- [ ] 同一 `(task_id, state)` 重复 report 不产生第二条记录
- [ ] 租约过期后任务变 `stale`,**且不会被普通 lease 取到**
- [ ] `reclaim` 领回的任务 `lease_count > 1` 且带 `known_state`
- [ ] worker 收到 `lease_count > 1` 时,走核对分支而不是直接执行
- [ ] 本地已完成的任务再次被领取时,直接补报而不重新下单
- [ ] 金额超过上限时中止并上报 `needs_human`,站点侧无提交动作
- [ ] worker 超过阈值没来 lease 时 `/health` 报异常
- [ ] 每一步的证据文件在 report 之前就已落盘
## 10. 尚未确定的部分
**加购 → 下单 → 付款的实际站点交互没有实测过**,本文不给这一段的具体表单与端点。
实现时把它留成明确的接口缝,未实测前调用直接抛「未实现」,不要写猜测的提交逻辑。
已知需要实测确认的点:
1. 自动付款是否触发 3D Secure 或短信验证。若触发,这条路走不通,付款环节改为
「下单到 `awaiting_payment` + 上报 `needs_human` 交人工」,其余环节不变。
2. 下单确认页的实际应付金额、付款方式、付款期限、站点订单号各自在哪个字段。
3. 订单列表页能否按商品 + 时间窗口可靠地反查出「这单下没下」(§5 的恢复核对依赖它)。
4. ラクマ 侧尚无加购契约(README 已注明未实现),首版只做乐天市场。
+5
View File
@@ -7,6 +7,11 @@ dependencies = [
"fastapi>=0.115.0,<1.0.0",
"httpx[http2]>=0.27.0,<1.0.0",
"loguru>=0.7.0,<1.0.0",
"opentelemetry-api>=1.27.0,<2.0.0",
"opentelemetry-exporter-otlp>=1.27.0,<2.0.0",
"opentelemetry-instrumentation-fastapi>=0.48b0,<1.0.0",
"opentelemetry-instrumentation-httpx>=0.48b0,<1.0.0",
"opentelemetry-sdk>=1.27.0,<2.0.0",
"pydantic>=2.0.0,<3.0.0",
"pydantic-settings>=2.4.0,<3.0.0",
"selectolax>=0.3.21,<1.0.0",
+48
View File
@@ -0,0 +1,48 @@
"""telemetry 模块测试
验证两件事:
1. 默认配置(otel_enabled=False)下 setup 是 noop,不初始化任何 provider。
2. enabled=true + endpoint 时 setup 注册真实 TracerProvider;shutdown 复位。
不打真实网络:OTLPSpanExporter 创建时不发请求,BatchSpanProcessor 异步批量
上报在没有 span 产生时也不会触发。
"""
from __future__ import annotations
from opentelemetry import trace
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.sdk.trace import TracerProvider
from app.shared.config import Settings
from app.shared.telemetry import is_initialized, setup_telemetry, shutdown_telemetry
def test_disabled_is_noop():
"""otel_enabled=False 时 setup/instrument/shutdown 都不初始化 provider"""
settings = Settings(_env_file=None) # 默认 otel_enabled=False
setup_telemetry(settings, service_name="test-disabled")
assert not is_initialized()
def test_enabled_initializes_and_shutdown_releases():
"""enabled=true 时 setup 注册 TracerProvider,shutdown 后 _provider 复位"""
settings = Settings(
_env_file=None,
otel_enabled=True,
otel_endpoint="http://localhost:4318/v1/traces",
)
try:
setup_telemetry(settings, service_name="test-enabled")
assert is_initialized()
provider = trace.get_tracer_provider()
# 初始化后是真实 SDK provider(class 名是 TracerProvider);
# SDK 启动前的占位 provider 不属于这个类型
assert isinstance(provider, TracerProvider)
finally:
shutdown_telemetry()
# 解除 httpx 全局 monkey-patch,避免污染其它测试
try:
HTTPXClientInstrumentor().uninstrument()
except Exception:
pass
assert not is_initialized()
Generated
+390
View File
@@ -1,6 +1,10 @@
version = 1
revision = 3
requires-python = ">=3.13"
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version < '3.14'",
]
[[package]]
name = "annotated-doc"
@@ -32,6 +36,15 @@ wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494" },
]
[[package]]
name = "asgiref"
version = "3.12.1"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
@@ -41,6 +54,54 @@ wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.9"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5" },
]
[[package]]
name = "click"
version = "8.4.2"
@@ -78,6 +139,18 @@ wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/eb/76/6d9e25ad88da9d3ff744bcdbec4736e38c2288611d43f673a5d9bfa27c07/fastapi-0.140.0-py3-none-any.whl", hash = "sha256:e951c0a0d9540bf5d9a2a9e078fd415da2ab7e312d435139e7d9e2e7fe9f0b23" },
]
[[package]]
name = "googleapis-common-protos"
version = "1.75.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed" },
]
[[package]]
name = "greenlet"
version = "3.5.4"
@@ -135,6 +208,37 @@ wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994" },
]
[[package]]
name = "grpcio"
version = "1.83.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9" },
]
[[package]]
name = "h11"
version = "0.16.0"
@@ -268,6 +372,190 @@ wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c" },
]
[[package]]
name = "opentelemetry-api"
version = "1.44.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef" },
]
[[package]]
name = "opentelemetry-exporter-otlp"
version = "1.44.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "opentelemetry-exporter-otlp-proto-grpc" },
{ name = "opentelemetry-exporter-otlp-proto-http" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f2/45/7af37fe54e5d3e66e7dcd7ba8b8aeee73f202bfac909cc94b8c4e428f9ac/opentelemetry_exporter_otlp-1.44.0.tar.gz", hash = "sha256:af1cde7c33ea8ed624bf04ac49a885730fe44c1f1ad698656e592c38f70ce106" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/18/c3/7b466a9463944e70b37b744072a0c1b88a425dade3fff0631adec66c9bcc/opentelemetry_exporter_otlp-1.44.0-py3-none-any.whl", hash = "sha256:4a498fa8d8fd8be9e8e2d175fe5524a3fe581ccffadd8509db86526a5fb97051" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.44.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "opentelemetry-proto" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/61/09/4d717852c1cf3f854b76c7110a5d00883bc3c99288b9b0dbcbeb9e306eb6/opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/5e/71/65fd9d54c10b860f87c045ccee1264cab7011268895d3528818a29c1172a/opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-grpc"
version = "1.44.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
{ name = "grpcio" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-common" },
{ name = "opentelemetry-proto" },
{ name = "opentelemetry-sdk" },
{ name = "typing-extensions" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/1f/47/80d9e9d468dc5de3af5096f5ccdb065fa4dd1470f74495cc53e59e397f47/opentelemetry_exporter_otlp_proto_grpc-1.44.0.tar.gz", hash = "sha256:40d1ae9e03fcc36de3cbac610cc99f35894938bff9cfd90fc4ec68bd85448463" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/54/29/6ae42ba32b153ae0a44ae125f0caff2188bbe62d99c82d1768da30864e72/opentelemetry_exporter_otlp_proto_grpc-1.44.0-py3-none-any.whl", hash = "sha256:6a1a645ea182a2f59440c51fa8301d309f3324a8f9d65f8395584b064b67ee4e" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.44.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-common" },
{ name = "opentelemetry-proto" },
{ name = "opentelemetry-sdk" },
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/1a/87/95e2a5aaa795b4e2260d74e16df2d5541deb2ea9de010bcd615f4dee2654/opentelemetry_exporter_otlp_proto_http-1.44.0.tar.gz", hash = "sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/cd/d0/fdeb1a98d8d3a6205f5f297c51b4a9bfe65126ab60339669bbe3dd54c2e2/opentelemetry_exporter_otlp_proto_http-1.44.0-py3-none-any.whl", hash = "sha256:838592fce774c1c8bb7b9a0a7facbfa82e17be5a8a4e94cef10cb84ae026bae3" },
]
[[package]]
name = "opentelemetry-instrumentation"
version = "0.65b0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "packaging" },
{ name = "wrapt" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/13/91/3c58961cb0360cd60509064734f0be4275383c8681d73c580a40ca83ddce/opentelemetry_instrumentation-0.65b0.tar.gz", hash = "sha256:071d9d9eced9bd6460444ec3b0c77229870ed05a881c22c84fdede58e4eed09b" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/40/7b/85eab1215f72adf0e68d3dc4a679b9bff993fa679ff34cd8dd378e2659fd/opentelemetry_instrumentation-0.65b0-py3-none-any.whl", hash = "sha256:ea967a72b9939b5fcfdad572753b4306c59dcb99e3f382d95dae04286805e137" },
]
[[package]]
name = "opentelemetry-instrumentation-asgi"
version = "0.65b0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "opentelemetry-util-http" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/17/83/8e8e83b7ac285281687c7be2fd305213ccccbb8c0a2dd4fb45a8ccaf12c7/opentelemetry_instrumentation_asgi-0.65b0.tar.gz", hash = "sha256:892bca67c56522ffa85a8a83cf934d7b50b3be2132e45cbee705825f0a5ba426" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0b/9c/376962840b619d2d55fe8ee2285f8c70971c090e5fff614516fc654a6f3a/opentelemetry_instrumentation_asgi-0.65b0-py3-none-any.whl", hash = "sha256:3a845a8ebd1c4ef0d8263401e6545f5b219b2feee612090d50f578a87e71fd65" },
]
[[package]]
name = "opentelemetry-instrumentation-fastapi"
version = "0.65b0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-instrumentation-asgi" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "opentelemetry-util-http" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/30/23/b057f8196d06efdc1b50e3ff11fbc499a7d96b35c87f217eb7885542f4ea/opentelemetry_instrumentation_fastapi-0.65b0.tar.gz", hash = "sha256:10a3a95486036230413a58fe4fdf4a83fa6bba46918407e527476994bd92bd97" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/fa/b0/c9b0300d33349ecc3dfd2362516eaffc44877e90970e6a52178ff953fec3/opentelemetry_instrumentation_fastapi-0.65b0-py3-none-any.whl", hash = "sha256:cda2610a0ec1b22d19886f33e4d861e9f5dbb886aeaa3a1263b47aff82c36943" },
]
[[package]]
name = "opentelemetry-instrumentation-httpx"
version = "0.65b0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "opentelemetry-util-http" },
{ name = "wrapt" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/61/03/a529140241addd4d0acc73bafbd6f74691651b92fc0ae9b4513cf80f07fa/opentelemetry_instrumentation_httpx-0.65b0.tar.gz", hash = "sha256:4627aa9c6bb99bf4462c8b565b0ef6aeb9ffad95c6c92868be1ef7895de112ee" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/9d/0f/c6144096b4914bbf44b43ba21c962e8f333ff045770b50a3e79ed8bd455f/opentelemetry_instrumentation_httpx-0.65b0-py3-none-any.whl", hash = "sha256:400f1b78afa4ee2332b5debe58e1ed1b317913d58812c952576be76660aeadb1" },
]
[[package]]
name = "opentelemetry-proto"
version = "1.44.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/64/01/40ac4ae9a149263cc52c2cee200ddd80cb6d8db1a4610abf8eabce0fe771/opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d1/7c/8be563d68e93bbefa5c8affb82ddcff91b3ad858ce49957ba7b16fd3e0ab/opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56" },
]
[[package]]
name = "opentelemetry-sdk"
version = "1.44.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "typing-extensions" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/5d/77/a6592cbc7c8d9bcc9d6757a9df45e04a7c585e3e6e7a13456da522b21109/opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e7/23/ff077e61886ee020a17ce9c8b6fa11c601c8d8345b09ea24f605445df62a/opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad" },
]
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.65b0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "typing-extensions" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/8f/73/0cbdebcb4cf545fdd328da14f5137e37d0770c3f26185e478b0d15d94f50/opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb" },
]
[[package]]
name = "opentelemetry-util-http"
version = "0.65b0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/32/a9/d7525a59fdd240e69b5af4a6338e78fafa1b4203394122cbd6701fb5f84a/opentelemetry_util_http-0.65b0.tar.gz", hash = "sha256:84f82d826978bba416ab453460ff6a7391cdc3534c93a786595e4068680016b7" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/23/3f/ab8d29df207ce5f470a07fa96ebb48af4e95b7fab7e7635311b9a32f2fab/opentelemetry_util_http-0.65b0-py3-none-any.whl", hash = "sha256:7553b606f963097cb190536dc30556cce85090692e471a422fff30ca29b04348" },
]
[[package]]
name = "packaging"
version = "26.2"
@@ -305,6 +593,21 @@ wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" },
]
[[package]]
name = "protobuf"
version = "7.35.1"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
@@ -492,6 +795,11 @@ dependencies = [
{ name = "fastapi" },
{ name = "httpx", extra = ["http2"] },
{ name = "loguru" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp" },
{ name = "opentelemetry-instrumentation-fastapi" },
{ name = "opentelemetry-instrumentation-httpx" },
{ name = "opentelemetry-sdk" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "selectolax" },
@@ -512,6 +820,11 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
{ name = "httpx", extras = ["http2"], specifier = ">=0.27.0,<1.0.0" },
{ name = "loguru", specifier = ">=0.7.0,<1.0.0" },
{ name = "opentelemetry-api", specifier = ">=1.27.0,<2.0.0" },
{ name = "opentelemetry-exporter-otlp", specifier = ">=1.27.0,<2.0.0" },
{ name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.48b0,<1.0.0" },
{ name = "opentelemetry-instrumentation-httpx", specifier = ">=0.48b0,<1.0.0" },
{ name = "opentelemetry-sdk", specifier = ">=1.27.0,<2.0.0" },
{ name = "playwright", marker = "extra == 'browser'", specifier = ">=1.47.0,<2.0.0" },
{ name = "pydantic", specifier = ">=2.0.0,<3.0.0" },
{ name = "pydantic-settings", specifier = ">=2.4.0,<3.0.0" },
@@ -522,6 +835,21 @@ requires-dist = [
]
provides-extras = ["browser", "dev"]
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" },
]
[[package]]
name = "selectolax"
version = "0.4.11"
@@ -590,6 +918,15 @@ wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7" },
]
[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897" },
]
[[package]]
name = "uvicorn"
version = "0.51.0"
@@ -779,3 +1116,56 @@ sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b3/8f/
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390" },
]
[[package]]
name = "wrapt"
version = "2.2.2"
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302" }
wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/43/fc/f32f4b22c6511173c11d9e541ab4e7d8467a0f1b3455acaf784115d31ff8/wrapt-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e8b648270c613720a202d9a45ebabc33261b22c3a839b115ac5bce8c0bb0d69" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/72/06/4d117d5d77a9344776c0248b24dae3d3dd2f58e5f765fa08cf887072e719/wrapt-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6fb7e94e8fe3e4c3067bb1653a91cce7c5e83acc119fdd41501b1bf74654617" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/15/ff/63ad96f98eb58a742b1a20d80f21da88924405910149950b912368150468/wrapt-2.2.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/20/1f/8bb62d8933df7acf3247194e6e9fc68edf9d2fa203252c89c94b319dd472/wrapt-2.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b00b00f806eb3ef2abe9049ed45994a81ee9284884d96e6b8314927c6cea3d" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/17/09/8789dcb09ee1de715727db7521aabbb68ffa68dfade3a49468440cfced49/wrapt-2.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62415fd095bc590b842b6d092f2b5d9ccbaeb7e0b28535c03dcea2718b48636b" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/9c/20/66e02562d53ee67d841f175e38e3c993c2d78a3e104c576cad61c028b43c/wrapt-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a41e758d80dc0ab8c210f641ac892009d356cf1f955d97db544c8dd317b4d14c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/bd/a3/832ac4e41222fb263b3042d42c2f08d305db7d0f0c9b1d3a271a9eede8f6/wrapt-2.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b84cd4058001c9727b0e9980b7a9e66325b5ca748b1b578e822cade1bc6b304f" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b7/01/1bd5e4d2df9c0178989ac8da9186543465388588ee2ef153e2591accebef/wrapt-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26fc73a1b15e0946d2942b9a4426d162b51676338327dc067ccd8d2d76385f94" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/1c/69/583ed25291ab53e1ec117135fb1c33425e2f46d2bc8f29c17f7a94cf4274/wrapt-2.2.2-cp313-cp313-win32.whl", hash = "sha256:3c4095803491f6ef72128914c28ec05bbad9758433bb35f6715a3e9c8e46fb2d" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/29/68/e69fc6d06e1523c68e0d00f95c9aed1158ce9908ee41603f7f2eae3d5db6/wrapt-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:2cb07f414fab25dbe6b5c7398e1491423a5c81a6209533639969a6c928d474a4" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/55/21/fe7a393d9e5dc0923bed8f5d857e9dcff210f1fa0888c02cc8f3ffaa55aa/wrapt-2.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:1fc7691f070220215cccb2a20836b9adbaecb8ff22ad47abe63de5f110994fac" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b6/e5/c120d13bf5091164f68c3c1657e84f16f57e71d978421b626393ac5bd7eb/wrapt-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ec8f83949028366531383603139403cac7a826e4011955813cdd640017845ce5" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d3/b0/d4a1eb97e0e286625bdf21bc7f702637f9607787ffbbdb5ec14d50c79dbf/wrapt-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b481fb0c40d9fd90a5809911208da700987d373a20a4709dc9e3944af7a6bec" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/18/1e/f060df47755e87b57684cee7bfc1362b204df55fac96ffebc0631b697b79/wrapt-2.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0065a3b657cec06813b4241d2462ccec287f6863103d7445b725fb3a889736f9" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/c4/de/2316a757a1abb6453700b79d83e532146dcef2611348282d4d8889792161/wrapt-2.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f7424af5c5c345b7f26490e097f74a2ef45b3d08b664dc33571aee3bd3b56c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ed/29/d1160785ae18ca2495a6d82a21154103d74f656c9fd457fb35f6b11b965a/wrapt-2.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07fdcb012821859168641acf68afad61ef9783cf37100af85f152550e9677194" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/f5/2d/7caa9598ae61a9cf0989cc501739cbeeb7d650ab3193cca1407b9af0c6ab/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f90038ab58fafb584801ca62d72384d7d5225d93c76f7b773c22fae545bd8066" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ac/02/281ea1088b8650d865f311b35cf86fd21df89128e2909714f1161e01c9d0/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c5d7825491bfa2d08b97e9557768987952c7b9ae687d06c3320b40a37ccb7f20" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/be/7d/976e2d5b4b5c5babda40974edd54d0a5585cb60132ed86b46f4b80239b16/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ad520e6daa9bbf136f14de735474dbec7dcc0891f718e1d274ce8dc92e645af" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/59/b7/e47651797c097f75a37e2ce86dcf04048ff576f3a674f7c558df7b5e9622/wrapt-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:25904acb9475f46c24fe0423dbc8fda8cc5fbc282ab3dc6e72e919748c53f4e9" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/d1/6f/9fa5d59fb06d890defb5a8f727ce6a14d2932c8760153f96956628559fee/wrapt-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:305d4c247d61c4115794a169141823c62f719525ddb90b23aa332741c77d2c28" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/15/80/4c7bd9873d1f9f7d138d93556b500469dbe24f42710b877519c2b9eb380d/wrapt-2.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c20279cd1a29800815d7b2d6338b60a6c6e78263f9d6e62e0eda251ba9cae2d0" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/24/05/7fd9c3f83b2c74cbfc572a0b88aa37431e04bd8aed70d2c0efd3464206de/wrapt-2.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0e64826f920c42d9d9f87e8cc09ffae66c51ede12d59061a5a426deb9aa71745" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/4b/68/1bfa43100dd90d4ef74a05897b86275cf57e1313ca14aae2545bc9f872c9/wrapt-2.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcaa5e1451bd8751d7bd1568dfa3321c78092a52a7ecb5d1a0f18a5791e1fd00" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/74/eb/df7b7f0b631dbbc750f39be27d8b55f65777d8ac86da80e12be41a644c4b/wrapt-2.2.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0abfd648dac9ac9c5b3aa9b523d27f1789046640b58dcd5652a720ddb325e1fc" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/4d/9a/d1bd36f6d088c8e652a9383cabbd49af30b8c576302a7eccddbab6963e3f/wrapt-2.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4bfd8d1eb438153eff8b8cfe87f032ba65731e1ce06138b5090f745a33f6f95" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/4c/ae/24ffacd4187fac2740a1972093929e836dea092d42c87d728cd98fee11a6/wrapt-2.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c427c9d06d859848a69f0d928fe28b5c33a941b2265d10a0e1f15cd244f1ee33" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a3/ed/974427668249a356051e8d67d47fa54ef6c777f0fcf3bae9d292c047d4b6/wrapt-2.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4250b43d1a129d947e083c4dc6baf333c9bb34edd26f912d5b0457841fc858ab" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/fb/5f/e1d7c6e4523f78db2fbd7826babd0348da1d5e0834c4f918b9ab5757dfae/wrapt-2.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:173e5bb5ca350a6e0abab60b7ec7cdd7992a814cb14b4de670a28f067f105663" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/1e/c1/7ebd1027f00700c0b0233b20aceef2b4784294ed64971424c4a78e069e34/wrapt-2.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa14b01804bce36c6d63d7b6a4f55df390f29f8648cc13a1f40b166f4d54680d" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/99/eb/974e471a6a978b8180186b8a9dc5ae3361ce269a967190b709b8ce17abfb/wrapt-2.2.2-cp314-cp314-win32.whl", hash = "sha256:58f9f8d637c9a6e245c6ef5b109b67ec187d2faed23d1405656b51d96e0a5b56" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/49/ec/e1281156cdc7a66693838ad7a0865ad641c74abd337a957d668b575aaffb/wrapt-2.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:385cb1866f20479e83299af585375bfa0a4b0c6c9907a981483ea782ea8ae406" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/45/7d/1b6b5ddd94005a2dac97a4490c9838f3154977850d633abcb65b30089437/wrapt-2.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:8ffbeaea6771a6eba6e6eeb09767864995726bc8240bb54baf88a9bb1db34d5c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b0/33/9ebcf8aafe91c601127cbd93708c16aa8f688f34a10bf004046803ecdc4f/wrapt-2.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09f811d43f6f33ec7515f0be76b159569f4057ab54d3e079c3204dddb90afa2a" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/39/38/ec45b635153327b52e52732a0ea980e5f00b7efba65f9e018828f1e69daa/wrapt-2.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a795d3c06e5fbf9ea2f13196180b77aeab1b4685917256ee0d014cc163d90063" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/4e/ea/1a89e6d3b7a83c3affe5c09cde77792c947e63e4bc85ad84cd5bb9abb0d8/wrapt-2.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45c2f2768e790c9f8db90f239ef23a2af8e7570f25a35619ef902df4a738447f" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/19/d8/3b58763d9863b5a73771c0d97110f9595d248db454009e07e1535ee905a4/wrapt-2.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbf00ee0cb55ec24e2b0995a71942b85b21a066db8f3f46e1dbfdb9433ffba81" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/2d/6f/17fd9e053103d8be148d20d5d7505facc72d5fe1f9127973904ceaed79cf/wrapt-2.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2252f77663651b89255895f58cc6ac08fcb206d4371813e5af61bb62d4f7689c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/ef/04/d0d1ccaaa12cb7dccf28a23f0279a608ba498f71e81d949d5ed54bcfd5c1/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cd7181ab1c31192ff5219269830744b5a62020b3a6d433588c4f1c95b8f8bff" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/44/b3/e8aa07b619890a2aa6cde1931b1887abb08820721b564a5f80b7ca3f3aa0/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6fe35fd51b74867d8b80174c277bd6bbf6a73e443f908129dc531c4b688a20d5" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b7/f0/1819fb50f0d3c9bd758d8a83b56f1b470dee8b5b8eac8702b7c137cea9d4/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11d95fc2fbad3163596c39d440e6f21ca9fccece74b56e30a37ac2fca786a07c" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/67/7c/e88313f16a99930b899ef970d91c281544a470749a359decad994483bbda/wrapt-2.2.2-cp314-cp314t-win32.whl", hash = "sha256:d8a15813215f33fa83667bfc978b300e35669ea8bb424e970a1426bcb7bc6cca" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/a0/4f/ac12fda57a55068a094ec42851fb0a40e8489d8941863d517452de62e507/wrapt-2.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d09db0f7e8357060d3c38fc22a018aba683a796bf184360fd1a58f6fc180dc77" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/48/a7/df732dac86d9b2027c56bd163dbc883e037b16c3469614752e148d219c61/wrapt-2.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f32fe639c39561ccc187bcae17e9271be0eb45f1c2952510d2f29b33ab577347" },
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048" },
]