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 表示不设上限(不建议)。 # 导致买到远超预期的订单。设为 0 表示不设上限(不建议)。
RAKUTEN_ORDER_MAX_TOTAL_YEN=30000 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,绝不可提交 # 登录态目录:含可冒充账号的 cookie,绝不可提交
.auth/ .auth/
.claude
+11 -1
View File
@@ -7,6 +7,11 @@
# 镜像默认包含 Playwright 浏览器兜底(与 .env.example 的 # 镜像默认包含 Playwright 浏览器兜底(与 .env.example 的
# RAKUTEN_BROWSER_FALLBACK_ENABLED=true 对齐),首次构建多约 400MB。 # RAKUTEN_BROWSER_FALLBACK_ENABLED=true 对齐),首次构建多约 400MB。
# 若要裁剪,可改为 uv sync --no-dev(去掉 --extra browser)。 # 若要裁剪,可改为 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 ----------------------------- # ----------------------------- builder -----------------------------
FROM registry.jerryyan.top/library/python:3.13-slim AS builder FROM registry.jerryyan.top/library/python:3.13-slim AS builder
@@ -46,7 +51,12 @@ ENV PYTHONUNBUFFERED=1 \
# Playwright 浏览器二进制走 npmmirror 镜像,避免从 azureedge 拉取缓慢 # Playwright 浏览器二进制走 npmmirror 镜像,避免从 azureedge 拉取缓慢
PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright \ PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright \
RAKUTEN_APP_HOST=0.0.0.0 \ 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 运行所需的最小系统依赖。 # Chromium 运行所需的最小系统依赖。
# 这里显式列出而不是用 `playwright install-deps`,是因为后者要求 root + apt # 这里显式列出而不是用 `playwright install-deps`,是因为后者要求 root + apt
Vendored
+9 -30
View File
@@ -25,9 +25,6 @@ pipeline {
booleanParam(name: 'SKIP_TEST', booleanParam(name: 'SKIP_TEST',
defaultValue: false, defaultValue: false,
description: '跳过单测(紧急发版用,正常构建不要勾)') description: '跳过单测(紧急发版用,正常构建不要勾)')
booleanParam(name: 'PUSH_LATEST',
defaultValue: true,
description: '同时打 :latest 并推送')
} }
environment { environment {
@@ -87,32 +84,18 @@ pipeline {
stage('Build image') { stage('Build image') {
steps { steps {
script { sh """
// 同时打两个 tag:精确 tag + 可选 latest docker build \
def tags = ["-t ${env.IMAGE_NAME}:${env.IMAGE_TAG}"] -t ${env.IMAGE_NAME}:${env.IMAGE_TAG} \
if (params.PUSH_LATEST) { -t ${env.IMAGE_NAME}:latest \
tags << "-t ${env.IMAGE_NAME}:latest" --label org.opencontainers.image.revision=${env.GIT_SHA} \
} --label org.opencontainers.image.version=${env.IMAGE_TAG} \
sh """ -f Dockerfile .
docker build ${tags.join(' ')} \ """
--label org.opencontainers.image.revision=${env.GIT_SHA} \
--label org.opencontainers.image.version=${env.IMAGE_TAG} \
-f Dockerfile .
"""
}
} }
} }
stage('Push image') { 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 { steps {
withCredentials([usernamePassword( withCredentials([usernamePassword(
credentialsId: 'gitea-registry', credentialsId: 'gitea-registry',
@@ -122,12 +105,8 @@ pipeline {
sh """ sh """
echo "\$REGISTRY_PASS" | docker login git.jerryyan.net -u "\$REGISTRY_USER" --password-stdin 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}:${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 接口—— `tests/test_architecture.py` 会守着)。交易侧需要商品信息时,走抓取服务的 HTTP 接口——
下单要用的 `purchase` 块本来就是抓取服务的对外契约。 下单要用的 `purchase` 块本来就是抓取服务的对外契约。
抓取服务部署在服务器,交易服务部署在本地(便于管理账号、排查支付问题),本地在 NAT 后
没有公网入口,因此下单请求不是推进来的,而是由本地长轮询主动领取。
任务网关与本地 worker 的规格见 [docs/order-gateway.md](docs/order-gateway.md)(待实现)。
``` ```
app/ app/
shared/ 配置、错误码、日志、响应信封与鉴权(两侧共用,不认识两侧) 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.api import register_exception_handlers
from app.shared.config import get_settings from app.shared.config import get_settings
from app.shared.logging_setup import configure_logging from app.shared.logging_setup import configure_logging
from app.shared.telemetry import instrument_app, setup_telemetry, shutdown_telemetry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -56,6 +57,9 @@ async def lifespan(app: FastAPI):
app.state.container = container app.state.container = container
configure_logging(container.settings) 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:%s", container.settings.app_host, container.settings.app_port)
logger.info("日志级别:%s", container.settings.log_level) logger.info("日志级别:%s", container.settings.log_level)
logger.info("当前环境:%s", container.settings.app_env) logger.info("当前环境:%s", container.settings.app_env)
@@ -67,6 +71,7 @@ async def lifespan(app: FastAPI):
await container.rakuma_session.close() await container.rakuma_session.close()
await container.site_session.close() await container.site_session.close()
await container.browser_fallback.close() await container.browser_fallback.close()
shutdown_telemetry()
def create_app() -> FastAPI: def create_app() -> FastAPI:
@@ -76,6 +81,7 @@ def create_app() -> FastAPI:
app.include_router(scrape_router) app.include_router(scrape_router)
app.include_router(rakuma_router) app.include_router(rakuma_router)
register_exception_handlers(app) register_exception_handlers(app)
instrument_app(app)
return app return app
+118 -54
View File
@@ -8,6 +8,8 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from opentelemetry import trace
from app.scraping.core import rakuma_site as site from app.scraping.core import rakuma_site as site
from app.shared.config import Settings from app.shared.config import Settings
from app.scraping.models.scrape import ( from app.scraping.models.scrape import (
@@ -35,8 +37,10 @@ from app.scraping.utils.rakuma_urls import (
split_item_url, split_item_url,
split_shop_url, split_shop_url,
) )
from app.shared.telemetry import snapshot
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
tracer = trace.get_tracer(__name__)
class RakumaClient: class RakumaClient:
@@ -54,18 +58,30 @@ class RakumaClient:
url = build_search_url(payload) url = build_search_url(payload)
logger.info("抓取 ラクマ 搜索页:url=%s", url) logger.info("抓取 ラクマ 搜索页:url=%s", url)
html = await self._session.fetch_html(url) html: str | None = None
result = parse_search( with tracer.start_as_current_span("parse.rakuma.search") as span:
html, span.set_attribute("parse.site", "rakuma")
request_url=url, span.set_attribute("parse.op", "search")
page=payload.page, try:
keyword=payload.keyword.strip(), html = await self._session.fetch_html(url)
) result = parse_search(
logger.info( html,
"ラクマ 搜索完成:url=%s items=%s total=%s", request_url=url,
url, len(result.items), result.total_count, page=payload.page,
) keyword=payload.keyword.strip(),
return result )
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: async def categories(self, payload: RakumaCategoryRequest) -> RakumaCategoryData:
"""抓取分类树 """抓取分类树
@@ -76,17 +92,29 @@ class RakumaClient:
url = site.CATEGORY_LIST_URL url = site.CATEGORY_LIST_URL
logger.info("抓取 ラクマ 分类页:category_id=%s", payload.category_id) logger.info("抓取 ラクマ 分类页:category_id=%s", payload.category_id)
html = await self._session.fetch_html(url) html: str | None = None
data = parse_categories( with tracer.start_as_current_span("parse.rakuma.categories") as span:
html, span.set_attribute("parse.site", "rakuma")
category_id=payload.category_id, span.set_attribute("parse.op", "categories")
include_descendants=payload.include_descendants, try:
) html = await self._session.fetch_html(url)
logger.info( data = parse_categories(
"ラクマ 分类完成:category_id=%s name=%s children=%s tree=%s", html,
data.category_id, data.name, len(data.children), data.total_count, category_id=payload.category_id,
) include_descendants=payload.include_descendants,
return data )
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: async def item_detail(self, payload: RakumaItemDetailRequest) -> RakumaItemDetailData:
"""抓取商品详情""" """抓取商品详情"""
@@ -97,13 +125,25 @@ class RakumaClient:
url = build_item_url(item_id) url = build_item_url(item_id)
logger.info("抓取 ラクマ 商品详情:url=%s", url) logger.info("抓取 ラクマ 商品详情:url=%s", url)
html = await self._session.fetch_html(url) html: str | None = None
detail = parse_item_detail(html, item_id=item_id, item_url=url) with tracer.start_as_current_span("parse.rakuma.item_detail") as span:
logger.info( span.set_attribute("parse.site", "rakuma")
"ラクマ 详情完成:url=%s name=%s price=%s sold_out=%s", span.set_attribute("parse.op", "item_detail")
url, detail.item_name[:40], detail.price, detail.is_sold_out, try:
) html = await self._session.fetch_html(url)
return detail 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: async def shop_detail(self, payload: RakumaShopDetailRequest) -> RakumaShopDetailData:
"""抓取卖家详情 """抓取卖家详情
@@ -114,22 +154,34 @@ class RakumaClient:
url = build_shop_url(shop_id) url = build_shop_url(shop_id)
logger.info("抓取 ラクマ 卖家详情:url=%s reviews=%s", url, payload.include_reviews) logger.info("抓取 ラクマ 卖家详情:url=%s reviews=%s", url, payload.include_reviews)
if payload.include_reviews: html: str | None = None
html, review_html = await asyncio.gather( with tracer.start_as_current_span("parse.rakuma.shop_detail") as span:
self._session.fetch_html(url), span.set_attribute("parse.site", "rakuma")
self._session.fetch_html(build_shop_url(shop_id, review=True)), span.set_attribute("parse.op", "shop_detail")
) try:
else: if payload.include_reviews:
html, review_html = await self._session.fetch_html(url), None 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( detail = parse_shop_detail(
html, shop_id=shop_id, shop_url=url, review_html=review_html html, shop_id=shop_id, shop_url=url, review_html=review_html
) )
logger.info( span.set_attribute("parse.items", detail.item_count)
"ラクマ 卖家详情完成:shop_id=%s name=%s items=%s reviews=%s", span.set_attribute("parse.reviews", detail.review_count)
shop_id, detail.shop_name, detail.item_count, detail.review_count, logger.info(
) "ラクマ 卖家详情完成:shop_id=%s name=%s items=%s reviews=%s",
return detail 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: async def shop_items(self, payload: RakumaShopItemsRequest) -> RakumaShopItemsData:
"""抓取卖家的商品列表""" """抓取卖家的商品列表"""
@@ -137,15 +189,27 @@ class RakumaClient:
url = build_shop_url(shop_id, page=payload.page) url = build_shop_url(shop_id, page=payload.page)
logger.info("抓取 ラクマ 卖家商品:url=%s", url) logger.info("抓取 ラクマ 卖家商品:url=%s", url)
html = await self._session.fetch_html(url) html: str | None = None
result = parse_shop_items( with tracer.start_as_current_span("parse.rakuma.shop_items") as span:
html, shop_id=shop_id, request_url=url, page=payload.page span.set_attribute("parse.site", "rakuma")
) span.set_attribute("parse.op", "shop_items")
logger.info( try:
"ラクマ 卖家商品完成:shop_id=%s items=%s total=%s", html = await self._session.fetch_html(url)
shop_id, len(result.items), result.total_count, result = parse_shop_items(
) html, shop_id=shop_id, request_url=url, page=payload.page
return result )
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 @staticmethod
def _resolve_shop_id(shop_url: object, shop_id: str | None) -> str: 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 from typing import Any
import httpx import httpx
from opentelemetry import trace
from app.scraping.core import rakuma_site as site from app.scraping.core import rakuma_site as site
from app.shared.config import Settings from app.shared.config import Settings
@@ -27,6 +28,7 @@ from app.shared.errors import (
UpstreamBlockedError, UpstreamBlockedError,
UpstreamRequestError, UpstreamRequestError,
) )
from app.shared.telemetry import snapshot
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -89,47 +91,71 @@ class RakumaSession:
if client is None: if client is None:
raise UpstreamRequestError("ラクマ 会话尚未初始化") raise UpstreamRequestError("ラクマ 会话尚未初始化")
tracer = trace.get_tracer(__name__)
max_attempts = max(1, self._settings.http_max_attempts) max_attempts = max(1, self._settings.http_max_attempts)
last_error = "unknown error" last_error = "unknown error"
last_text: str | None = None
try: with tracer.start_as_current_span("scrape.fetch") as span:
await asyncio.wait_for( span.set_attribute("scrape.url", url)
self._semaphore.acquire(), span.set_attribute("scrape.profile", "rakuma")
timeout=self._settings.request_timeout_seconds,
)
except TimeoutError as exc:
raise ResourceBusyError() from exc
try: try:
for attempt in range(1, max_attempts + 1): await asyncio.wait_for(
try: self._semaphore.acquire(),
response = await client.get(url) timeout=self._settings.request_timeout_seconds,
except httpx.HTTPError as exc: )
last_error = f"{type(exc).__name__}: {exc}" 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( logger.warning(
"ラクマ 抓取请求异常:url=%s attempt=%s/%s err=%s", "ラクマ 抓取结果异常:url=%s attempt=%s/%s %s",
url, attempt, max_attempts, last_error, url, attempt, max_attempts, last_error,
) )
continue
if response.status_code == 404: if last_error.startswith("upstream status") or ":" in last_error:
raise ItemNotFoundError(f"Page not found: {url}") err = UpstreamRequestError(f"Upstream request failed: {last_error}")
else:
if response.status_code < 400: err = UpstreamBlockedError(f"Failed to fetch {url}: {last_error}")
return response.text span.record_exception(err)
span.set_attribute("scrape.fail_reason", type(err).__name__)
last_error = ( snapshot(span, "scrape.failed_html", last_text, self._settings.otel_snapshot_max_bytes)
f"upstream status {response.status_code}" raise err
if response.status_code >= 500 finally:
else f"status {response.status_code}" self._semaphore.release()
)
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()
+128 -64
View File
@@ -8,6 +8,8 @@ from __future__ import annotations
import logging import logging
from opentelemetry import trace
from app.scraping.core import site from app.scraping.core import site
from app.shared.config import Settings from app.shared.config import Settings
from app.shared.errors import ScrapeParseError from app.shared.errors import ScrapeParseError
@@ -43,8 +45,10 @@ from app.scraping.utils.urls import (
split_item_url, split_item_url,
split_shop_url, split_shop_url,
) )
from app.shared.telemetry import snapshot
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
tracer = trace.get_tracer(__name__)
class RakutenClient: class RakutenClient:
@@ -62,19 +66,32 @@ class RakutenClient:
url = build_search_url(payload) url = build_search_url(payload)
logger.info("抓取搜索页:url=%s", url) logger.info("抓取搜索页:url=%s", url)
html = await self._session.fetch_html(url, mobile=False) html: str | None = None
state = extract_initial_state(html) with tracer.start_as_current_span("parse.rakuten.search") as span:
result = parse_search( span.set_attribute("parse.site", "rakuten")
state, span.set_attribute("parse.op", "search")
request_url=url, try:
page=payload.page, html = await self._session.fetch_html(url, mobile=False)
exclude_ads=payload.exclude_ads, state = extract_initial_state(html)
) result = parse_search(
logger.info( state,
"搜索完成:url=%s items=%s ads=%s total=%s", request_url=url,
url, len(result.items), result.ad_count, result.total_count, page=payload.page,
) exclude_ads=payload.exclude_ads,
return result )
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: async def genres(self, payload: GenreRequest) -> GenreData:
"""抓取分类树:顶层分类列表,或指定分类的信息与直接子分类""" """抓取分类树:顶层分类列表,或指定分类的信息与直接子分类"""
@@ -82,14 +99,25 @@ class RakutenClient:
url = build_genre_url(genre_id) url = build_genre_url(genre_id)
logger.info("抓取分类树:url=%s genre_id=%s", url, genre_id) logger.info("抓取分类树:url=%s genre_id=%s", url, genre_id)
html = await self._session.fetch_html(url, mobile=False) html: str | None = None
state = extract_initial_state(html) with tracer.start_as_current_span("parse.rakuten.genres") as span:
result = parse_genres(state, genre_id=genre_id) span.set_attribute("parse.site", "rakuten")
logger.info( span.set_attribute("parse.op", "genres")
"分类树完成:genre_id=%s name=%s children=%s", try:
result.genre_id or "root", result.name, len(result.children), html = await self._session.fetch_html(url, mobile=False)
) state = extract_initial_state(html)
return result 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: async def shop_detail(self, payload: ShopDetailRequest) -> ShopDetailData:
"""抓取商家详情""" """抓取商家详情"""
@@ -100,14 +128,25 @@ class RakutenClient:
url = build_shop_url(shop_code) url = build_shop_url(shop_code)
logger.info("抓取店铺页:url=%s", url) logger.info("抓取店铺页:url=%s", url)
html = await self._session.fetch_html(url, mobile=False) html: str | None = None
state = extract_initial_state(html) with tracer.start_as_current_span("parse.rakuten.shop_detail") as span:
result = parse_shop_detail(state, shop_code=shop_code, html=html) span.set_attribute("parse.site", "rakuten")
logger.info( span.set_attribute("parse.op", "shop_detail")
"店铺详情完成:shop_code=%s shop_id=%s name=%s reviews=%s", try:
result.shop_code, result.shop_id, result.shop_name, result.review_count, html = await self._session.fetch_html(url, mobile=False)
) state = extract_initial_state(html)
return result 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: async def shop_items(self, payload: ShopItemsRequest) -> SearchResultData:
"""抓取商家名下的商品列表 """抓取商家名下的商品列表
@@ -116,14 +155,25 @@ class RakutenClient:
`sid=` 限定店铺后的产物,因此这里转成一次搜索。只给 shop_code 时 `sid=` 限定店铺后的产物,因此这里转成一次搜索。只给 shop_code 时
需要先取一次店铺详情换出 shop_id。 需要先取一次店铺详情换出 shop_id。
""" """
shop_id = payload.shop_id with tracer.start_as_current_span("parse.rakuten.shop_items") as span:
if shop_id is None: span.set_attribute("parse.site", "rakuten")
detail = await self.shop_detail(ShopDetailRequest(shop_code=payload.shop_code)) span.set_attribute("parse.op", "shop_items")
if detail.shop_id is None: try:
raise ScrapeParseError(f"未能取得店铺 {payload.shop_code} 的 shop_id") shop_id = payload.shop_id
shop_id = detail.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: async def item_detail(self, payload: ItemDetailRequest) -> ItemDetailData:
"""抓取商品详情""" """抓取商品详情"""
@@ -137,33 +187,47 @@ class RakutenClient:
url = build_item_url(shop_code, item_code) url = build_item_url(shop_code, item_code)
logger.info("抓取商品详情:url=%s", url) logger.info("抓取商品详情:url=%s", url)
# 部分官方旗舰店的商品页会跳出市场域名,落到各自的独立站点; html: str | None = None
# 校验与解析都按最终落地域名分派,落到未登记站点时由校验器抛错。 with tracer.start_as_current_span("parse.rakuten.item_detail") as span:
page = await self._session.fetch( span.set_attribute("parse.site", "rakuten")
url, mobile=True, validator=build_item_page_validator(url) span.set_attribute("parse.op", "item_detail")
) try:
# 部分官方旗舰店的商品页会跳出市场域名,落到各自的独立站点;
if host_of(page.url) == site.ITEM_HOST: # 校验与解析都按最终落地域名分派,落到未登记站点时由校验器抛错。
detail = parse_item_detail( page = await self._session.fetch(
extract_initial_state(page.html), url, mobile=True, validator=build_item_page_validator(url)
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 = page.html
logger.info( if host_of(page.url) == site.ITEM_HOST:
"详情完成:url=%s source=%s name=%s price=%s skus=%s", detail = parse_item_detail(
url, detail.source, detail.item_name[:40], detail.price, detail.sku.variant_count, extract_initial_state(page.html),
) item_url=url,
return detail 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 from typing import Any
import httpx import httpx
from opentelemetry import trace
from app.scraping.core import site from app.scraping.core import site
from app.shared.config import Settings 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.parsers.state import PageValidator, require_state_marker
from app.scraping.services.browser_fallback import BrowserFallback from app.scraping.services.browser_fallback import BrowserFallback
from app.shared.telemetry import snapshot
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -152,66 +154,94 @@ class SiteSession:
ResourceBusyError: 等待并发槽位超时 ResourceBusyError: 等待并发槽位超时
AppError: 校验器判定为不可重试的失败(如落到不支持的站点) AppError: 校验器判定为不可重试的失败(如落到不支持的站点)
""" """
tracer = trace.get_tracer(__name__)
profile = self._profiles["sp" if mobile else "pc"] profile = self._profiles["sp" if mobile else "pc"]
max_attempts = max(1, self._settings.http_max_attempts) max_attempts = max(1, self._settings.http_max_attempts)
validate = validator or require_state_marker validate = validator or require_state_marker
last_error: str = "unknown error" last_error: str = "unknown error"
# 失败时供 trace snapshot 上报:保留最后一次拿到的响应体(含挑战页 / 5xx 响应)。
last_text: str | None = None
try: with tracer.start_as_current_span("scrape.fetch") as span:
await asyncio.wait_for( span.set_attribute("scrape.url", url)
self._semaphore.acquire(), span.set_attribute("scrape.profile", profile.name)
timeout=self._settings.request_timeout_seconds,
)
except TimeoutError as exc:
raise ResourceBusyError() from exc
try: try:
for attempt in range(1, max_attempts + 1): await asyncio.wait_for(
await self._ensure_warm(profile) self._semaphore.acquire(),
try: timeout=self._settings.request_timeout_seconds,
response = await profile.client.get(url) )
except httpx.HTTPError as exc: except TimeoutError as exc:
last_error = f"{type(exc).__name__}: {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( 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, url, profile.name, attempt, max_attempts, last_error,
) )
continue
if response.status_code == 404: if attempt >= max_attempts:
raise ItemNotFoundError(f"Page not found: {url}") break
text = response.text # 第一次失败先便宜地换一套 cookie;仍失败才动用浏览器
final_url = str(response.url) if attempt == 1:
if response.status_code < 400: await self._invalidate(profile)
# 校验器可能直接抛 AppError 表示「重试也没用」,此处不拦截 else:
reason = validate(text, final_url) page = await self._escalate_to_browser(profile, url, validate)
if reason is None: if page is not None:
return FetchedPage(html=text, url=final_url) span.set_attribute("scrape.fell_back_to_browser", True)
last_error = self._refine_failure(reason, text) 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: else:
last_error = self._describe_error_status(response.status_code) err = UpstreamBlockedError(f"Blocked while fetching {url}: {last_error}")
logger.warning( span.record_exception(err)
"抓取结果异常:url=%s profile=%s attempt=%s/%s %s", span.set_attribute("scrape.fail_reason", type(err).__name__)
url, profile.name, attempt, max_attempts, last_error, snapshot(span, "scrape.failed_html", last_text, self._settings.otel_snapshot_max_bytes)
) raise err
finally:
if attempt >= max_attempts: self._semaphore.release()
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()
# ---- 内部 ---- # ---- 内部 ----
+12
View File
@@ -82,6 +82,18 @@ class Settings(BaseSettings):
proxy_username: str | None = None proxy_username: str | None = None
proxy_password: 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 目录(相对项目根目录)。 # 人工登录一次后落盘的 Playwright storage_state 目录(相对项目根目录)。
# 目录里是可直接冒充账号的 cookie,务必不要提交到版本库。 # 目录里是可直接冒充账号的 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.api import register_exception_handlers
from app.shared.config import get_settings from app.shared.config import get_settings
from app.shared.logging_setup import configure_logging 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.auth import router as auth_router
from app.trading.api.routes.health import router as health_router from app.trading.api.routes.health import router as health_router
from app.trading.container import TradingContainer from app.trading.container import TradingContainer
@@ -43,6 +44,8 @@ async def lifespan(app: FastAPI):
app.state.container = container app.state.container = container
configure_logging(container.settings) configure_logging(container.settings)
# 与抓取侧同样:必须在创建 httpx 客户端之前 setup。
setup_telemetry(container.settings, service_name="rakuten-trading")
logger.info( logger.info(
"交易服务启动:%s:%s", "交易服务启动:%s:%s",
container.settings.trading_host, container.settings.trading_host,
@@ -54,6 +57,7 @@ async def lifespan(app: FastAPI):
yield yield
finally: finally:
await container.auth_session.close() await container.auth_session.close()
shutdown_telemetry()
def create_app() -> FastAPI: def create_app() -> FastAPI:
@@ -62,6 +66,7 @@ def create_app() -> FastAPI:
app.include_router(health_router) app.include_router(health_router)
app.include_router(auth_router) app.include_router(auth_router)
register_exception_handlers(app) register_exception_handlers(app)
instrument_app(app)
return 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", "fastapi>=0.115.0,<1.0.0",
"httpx[http2]>=0.27.0,<1.0.0", "httpx[http2]>=0.27.0,<1.0.0",
"loguru>=0.7.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>=2.0.0,<3.0.0",
"pydantic-settings>=2.4.0,<3.0.0", "pydantic-settings>=2.4.0,<3.0.0",
"selectolax>=0.3.21,<1.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 version = 1
revision = 3 revision = 3
requires-python = ">=3.13" requires-python = ">=3.13"
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version < '3.14'",
]
[[package]] [[package]]
name = "annotated-doc" 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" }, { 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]] [[package]]
name = "certifi" name = "certifi"
version = "2026.7.22" 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" }, { 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]] [[package]]
name = "click" name = "click"
version = "8.4.2" 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" }, { 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]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.5.4" 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" }, { 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]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" 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" }, { 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]] [[package]]
name = "packaging" name = "packaging"
version = "26.2" 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" }, { 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]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.4" version = "2.13.4"
@@ -492,6 +795,11 @@ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx", extra = ["http2"] }, { name = "httpx", extra = ["http2"] },
{ name = "loguru" }, { name = "loguru" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp" },
{ name = "opentelemetry-instrumentation-fastapi" },
{ name = "opentelemetry-instrumentation-httpx" },
{ name = "opentelemetry-sdk" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "selectolax" }, { name = "selectolax" },
@@ -512,6 +820,11 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" }, { name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
{ name = "httpx", extras = ["http2"], specifier = ">=0.27.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 = "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 = "playwright", marker = "extra == 'browser'", specifier = ">=1.47.0,<2.0.0" },
{ name = "pydantic", specifier = ">=2.0.0,<3.0.0" }, { name = "pydantic", specifier = ">=2.0.0,<3.0.0" },
{ name = "pydantic-settings", specifier = ">=2.4.0,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.4.0,<3.0.0" },
@@ -522,6 +835,21 @@ requires-dist = [
] ]
provides-extras = ["browser", "dev"] 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]] [[package]]
name = "selectolax" name = "selectolax"
version = "0.4.11" 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" }, { 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]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.51.0" version = "0.51.0"
@@ -779,3 +1116,56 @@ sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/b3/8f/
wheels = [ wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390" }, { 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" },
]