feat(http): use httpx with pooled connections and browser headers
Add httpx and route Bilibili and Telegram requests through pooled clients with keepalive and explicit timeouts. Align Bilibili headers with data/raw-request.txt: Chrome 155 UA, sec-ch-ua, space.bilibili.com origin, folder-specific referer, and priority header. Close replaced clients when credentials change. Add transport contract tests and live read-only validation for nav, folder sync, WBI search, and Telegram getUpdates.
This commit is contained in:
1 parent
71a0e8c1d6
commit
5b22ed1c5d
12 files changed
+430
-25
No files matched your search
+14
-3
@@ -15,19 +15,19 @@
|
|||||||
| 开发语言 | Python 3.14 | 与现有 `favsort.py` 一致,复用 Cookie、扫码授权与收藏夹排序能力。 |
|
| 开发语言 | Python 3.14 | 与现有 `favsort.py` 一致,复用 Cookie、扫码授权与收藏夹排序能力。 |
|
||||||
| 包与环境管理 | `uv` | 管理 Python 版本、`.venv`、依赖同步及统一命令。 |
|
| 包与环境管理 | `uv` | 管理 Python 版本、`.venv`、依赖同步及统一命令。 |
|
||||||
| 后端服务 | Python 标准库 `ThreadingHTTPServer` | 个人单用户看板,接口与并发规模有限,不引入 Web 框架。 |
|
| 后端服务 | Python 标准库 `ThreadingHTTPServer` | 个人单用户看板,接口与并发规模有限,不引入 Web 框架。 |
|
||||||
| HTTP 客户端 | `urllib.request` | 现有 B 站请求代码已使用;无需额外依赖。 |
|
| HTTP 客户端 | `httpx` | 连接池、keepalive、明确的 connect/read 超时;B 站请求头与 UA 对齐 `data/raw-request.txt`。 |
|
||||||
| 数据库 | SQLite(WAL) | 单用户、单机、低并发写入;备份和迁移成本低。 |
|
| 数据库 | SQLite(WAL) | 单用户、单机、低并发写入;备份和迁移成本低。 |
|
||||||
| 调度 | `threading`、`queue.Queue`、标准库时间工具 | 单进程内运行发现、同步、快照和清理任务。 |
|
| 调度 | `threading`、`queue.Queue`、标准库时间工具 | 单进程内运行发现、同步、快照和清理任务。 |
|
||||||
| 前端 | 原生 HTML/CSS/JavaScript | 无 Node.js、无构建链;前端仅渲染 `/api/*` JSON。 |
|
| 前端 | 原生 HTML/CSS/JavaScript | 无 Node.js、无构建链;前端仅渲染 `/api/*` JSON。 |
|
||||||
| 图表 | 仓库内置 ECharts | 离线静态资源,Apache-2.0 许可。 |
|
| 图表 | 仓库内置 ECharts | 离线静态资源,Apache-2.0 许可。 |
|
||||||
| 通知 | Telegram Bot long polling | 具备按钮回调能力,开发和本机运行不需要公网 URL。 |
|
| 通知 | Telegram Bot long polling + `httpx` | 具备按钮回调能力,开发和本机运行不需要公网 URL。 |
|
||||||
| 容器化 | 可选的后续部署方式 | 本地开发、调试和日常运行均不要求 Docker。 |
|
| 容器化 | 可选的后续部署方式 | 本地开发、调试和日常运行均不要求 Docker。 |
|
||||||
|
|
||||||
### 1.1 选型原则
|
### 1.1 选型原则
|
||||||
|
|
||||||
1. **复用现有能力。** 当前仓库的 `favsort.py` 已实现 B 站设备指纹 Cookie、扫码登录、收藏夹读取和排序逻辑;新功能应拆分复用,而不是另起技术栈。
|
1. **复用现有能力。** 当前仓库的 `favsort.py` 已实现 B 站设备指纹 Cookie、扫码登录、收藏夹读取和排序逻辑;新功能应拆分复用,而不是另起技术栈。
|
||||||
2. **保持单体。** 当前规模约数百到数千视频,每日采集量约千级请求;单进程、SQLite 和内置调度足够。
|
2. **保持单体。** 当前规模约数百到数千视频,每日采集量约千级请求;单进程、SQLite 和内置调度足够。
|
||||||
3. **尽量标准库。** 当前没有第三方 Python 依赖,第一期不因便利性引入 Web 框架、ORM、任务队列或 Redis。
|
3. **标准库优先,成熟依赖可用。** 当依赖能明确改善可靠性、性能或可维护性时允许引入;必须锁定在 `pyproject.toml` / `uv.lock`,隔离在适配层,并用契约测试覆盖。
|
||||||
4. **本地优先。** 可在 Windows 上通过 `uv` 直接运行,不要求 WSL、Docker、数据库服务或宿主机 cron。
|
4. **本地优先。** 可在 Windows 上通过 `uv` 直接运行,不要求 WSL、Docker、数据库服务或宿主机 cron。
|
||||||
5. **未来可部署。** 服务保持无状态代码与可挂载数据目录分离,未来可选用 Docker 与 Caddy/Nginx 部署,但不是开发前置条件。
|
5. **未来可部署。** 服务保持无状态代码与可挂载数据目录分离,未来可选用 Docker 与 Caddy/Nginx 部署,但不是开发前置条件。
|
||||||
|
|
||||||
@@ -344,6 +344,17 @@ PRAGMA synchronous = NORMAL;
|
|||||||
| 加入收藏 | `x/v3/fav/resource/deal` | 确认候选后收藏 |
|
| 加入收藏 | `x/v3/fav/resource/deal` | 确认候选后收藏 |
|
||||||
| 收藏夹排序 | `x/v3/fav/resource/sort` | 定位至同 UP 分组 |
|
| 收藏夹排序 | `x/v3/fav/resource/sort` | 定位至同 UP 分组 |
|
||||||
|
|
||||||
|
B 站请求统一使用 `httpx` 连接池,并按 `data/raw-request.txt` 对齐浏览器特征:
|
||||||
|
|
||||||
|
- `user-agent`:Chrome 155 / Windows;
|
||||||
|
- `sec-ch-ua`、`sec-ch-ua-mobile`、`sec-ch-ua-platform`;
|
||||||
|
- `origin: https://space.bilibili.com`;
|
||||||
|
- `referer`:收藏夹请求指向 `https://space.bilibili.com/<mid>/favlist?fid=<media_id>&ftype=create`;
|
||||||
|
- `priority: u=1, i`;
|
||||||
|
- 匿名请求仍剥离账号身份 Cookie,仅保留设备指纹。
|
||||||
|
|
||||||
|
请求头集中在 `bilibili/auth.py::make_headers`,不要在业务层散落自定义 Header。
|
||||||
|
|
||||||
### 7.2 发现任务
|
### 7.2 发现任务
|
||||||
|
|
||||||
每小时扫描两个关键词、每个关键词前两页:
|
每小时扫描两个关键词、每个关键词前两页:
|
||||||
|
|||||||
@@ -258,6 +258,8 @@ uv run python app.py auth --account read --timeout 180
|
|||||||
|
|
||||||
只读客户端对 `ids` / `infos` 默认剥离 `read` 身份 Cookie,仅保留该账号设备指纹;如果失败才使用完整 `read` Cookie 重试。写操作一律使用完整 `write` Cookie 与 CSRF。
|
只读客户端对 `ids` / `infos` 默认剥离 `read` 身份 Cookie,仅保留该账号设备指纹;如果失败才使用完整 `read` Cookie 重试。写操作一律使用完整 `write` Cookie 与 CSRF。
|
||||||
|
|
||||||
|
B 站请求通过 `httpx` 连接池发送,并按 `data/raw-request.txt` 对齐 Chrome 155 的 UA、`sec-ch-ua`、`origin`、`referer` 和 `priority` 等请求头;收藏夹请求的 `referer` 会带上目标 `media_id`。
|
||||||
|
|
||||||
`x/v3/fav/resource/sort` 的实测语义为:
|
`x/v3/fav/resource/sort` 的实测语义为:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -278,6 +280,7 @@ uv run python tests/test_migrations.py # 迁移、备份、回滚与保留
|
|||||||
uv run python tests/test_database.py # 单写队列、事务与并发
|
uv run python tests/test_database.py # 单写队列、事务与并发
|
||||||
uv run python tests/test_repository.py # 收藏夹/视频状态机与黑名单
|
uv run python tests/test_repository.py # 收藏夹/视频状态机与黑名单
|
||||||
uv run python tests/test_bilibili_client.py # ids/infos/view/fans/nav 契约与重试
|
uv run python tests/test_bilibili_client.py # ids/infos/view/fans/nav 契约与重试
|
||||||
|
uv run python tests/test_httpx_transport.py # httpx B 站/Telegram 传输层
|
||||||
uv run python tests/test_classify.py # 标题归一化、时长解析与打分
|
uv run python tests/test_classify.py # 标题归一化、时长解析与打分
|
||||||
uv run python tests/test_search.py # WBI 签名、搜索解析与降级
|
uv run python tests/test_search.py # WBI 签名、搜索解析与降级
|
||||||
uv run python tests/test_candidates.py # 候选池、去重、通知与 fav_action
|
uv run python tests/test_candidates.py # 候选池、去重、通知与 fav_action
|
||||||
|
|||||||
+3
-1
@@ -10,7 +10,9 @@ readme = "README.md"
|
|||||||
requires-python = ">=3.14,<3.15"
|
requires-python = ">=3.14,<3.15"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
authors = [{ name = "bili-fav contributors" }]
|
authors = [{ name = "bili-fav contributors" }]
|
||||||
dependencies = []
|
dependencies = [
|
||||||
|
"httpx>=0.28.1",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
bili-fav = "favsort:main"
|
bili-fav = "favsort:main"
|
||||||
|
|||||||
@@ -45,15 +45,16 @@ def strip_identity(cookie: str) -> str:
|
|||||||
return "; ".join(kept)
|
return "; ".join(kept)
|
||||||
|
|
||||||
|
|
||||||
def make_headers(cfg: dict, auth: bool = False) -> dict[str, str]:
|
def make_headers(cfg: dict, auth: bool = False, *, referer: str | None = None) -> dict[str, str]:
|
||||||
"""构造 B 站 Web 请求头;``auth=False`` 时去掉账号身份 cookie。"""
|
"""构造与 ``data/raw-request.txt`` 对齐的 B 站 Web 请求头。"""
|
||||||
return {
|
return {
|
||||||
"accept": "*/*",
|
"accept": "*/*",
|
||||||
"accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
|
"accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||||
"cookie": cfg["cookie"] if auth else strip_identity(cfg["cookie"]),
|
"cookie": cfg["cookie"] if auth else strip_identity(cfg["cookie"]),
|
||||||
"dnt": "1",
|
"dnt": "1",
|
||||||
"origin": "https://www.bilibili.com",
|
"origin": "https://space.bilibili.com",
|
||||||
"referer": "https://www.bilibili.com/",
|
"priority": "u=1, i",
|
||||||
|
"referer": referer or "https://space.bilibili.com/",
|
||||||
"sec-ch-ua": '"Google Chrome";v="155", "Chromium";v="155", "Not(A:Brand";v="24"',
|
"sec-ch-ua": '"Google Chrome";v="155", "Chromium";v="155", "Not(A:Brand";v="24"',
|
||||||
"sec-ch-ua-mobile": "?0",
|
"sec-ch-ua-mobile": "?0",
|
||||||
"sec-ch-ua-platform": '"Windows"',
|
"sec-ch-ua-platform": '"Windows"',
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import urllib.request
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Mapping, Protocol, Sequence
|
from typing import Any, Mapping, Protocol, Sequence
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from .auth import make_headers
|
from .auth import make_headers
|
||||||
from .models import (
|
from .models import (
|
||||||
FanStat,
|
FanStat,
|
||||||
@@ -129,6 +131,54 @@ class UrllibTransport:
|
|||||||
raise TransportError(str(exc)) from exc
|
raise TransportError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
class HttpxTransport:
|
||||||
|
"""基于 httpx 的连接池传输,默认用于 B 站请求。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
timeout: float = 30,
|
||||||
|
client: httpx.Client | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.timeout = timeout
|
||||||
|
self._client = client or httpx.Client(
|
||||||
|
timeout=httpx.Timeout(timeout, connect=10),
|
||||||
|
limits=httpx.Limits(
|
||||||
|
max_keepalive_connections=10,
|
||||||
|
keepalive_expiry=30,
|
||||||
|
),
|
||||||
|
follow_redirects=False,
|
||||||
|
trust_env=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
headers: Mapping[str, str],
|
||||||
|
data: bytes | None = None,
|
||||||
|
timeout: float = 30,
|
||||||
|
) -> HTTPResponse:
|
||||||
|
try:
|
||||||
|
response = self._client.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers=dict(headers),
|
||||||
|
content=data,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except httpx.TransportError as exc:
|
||||||
|
raise TransportError(str(exc)) from exc
|
||||||
|
return HTTPResponse(
|
||||||
|
status=int(response.status_code),
|
||||||
|
headers=dict(response.headers),
|
||||||
|
body=response.content,
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._client.close()
|
||||||
|
|
||||||
|
|
||||||
class Pacer:
|
class Pacer:
|
||||||
"""请求间随机等待;第一个请求不等待。"""
|
"""请求间随机等待;第一个请求不等待。"""
|
||||||
|
|
||||||
@@ -185,7 +235,7 @@ class BilibiliClient:
|
|||||||
self.cookie = cookie or ""
|
self.cookie = cookie or ""
|
||||||
self.csrf = csrf or ""
|
self.csrf = csrf or ""
|
||||||
self.mid = mid
|
self.mid = mid
|
||||||
self.transport = transport or UrllibTransport()
|
self.transport = transport or HttpxTransport()
|
||||||
self.pacer = pacer or Pacer(sleep=sleeper)
|
self.pacer = pacer or Pacer(sleep=sleeper)
|
||||||
self._sleeper = sleeper
|
self._sleeper = sleeper
|
||||||
self.retries = retries
|
self.retries = retries
|
||||||
@@ -193,15 +243,22 @@ class BilibiliClient:
|
|||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
close = getattr(self.transport, "close", None)
|
||||||
|
if callable(close):
|
||||||
|
close()
|
||||||
|
|
||||||
# ------------------------------------------------------------ core
|
# ------------------------------------------------------------ core
|
||||||
|
|
||||||
def _headers(self, auth: bool) -> dict[str, str]:
|
def _headers(self, auth: bool, referer: str | None = None) -> dict[str, str]:
|
||||||
return make_headers({"cookie": self.cookie}, auth=auth)
|
return make_headers({"cookie": self.cookie}, auth=auth, referer=referer)
|
||||||
|
|
||||||
def _request_json(self, url: str, *, auth: bool) -> dict[str, Any]:
|
def _request_json(
|
||||||
|
self, url: str, *, auth: bool, referer: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
self.pacer.wait()
|
self.pacer.wait()
|
||||||
resp = self.transport.request(
|
resp = self.transport.request(
|
||||||
"GET", url, self._headers(auth), data=None, timeout=self.timeout
|
"GET", url, self._headers(auth, referer), data=None, timeout=self.timeout
|
||||||
)
|
)
|
||||||
if resp.status >= 400:
|
if resp.status >= 400:
|
||||||
raise BilibiliHTTPError(resp.status, resp.body.decode("utf-8", "replace")[:200])
|
raise BilibiliHTTPError(resp.status, resp.body.decode("utf-8", "replace")[:200])
|
||||||
@@ -210,11 +267,13 @@ class BilibiliClient:
|
|||||||
raise BilibiliProtocolError("接口响应顶层不是 JSON 对象")
|
raise BilibiliProtocolError("接口响应顶层不是 JSON 对象")
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
def _request_with_retry(self, url: str, *, auth: bool) -> dict[str, Any]:
|
def _request_with_retry(
|
||||||
|
self, url: str, *, auth: bool, referer: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
last: BilibiliError | None = None
|
last: BilibiliError | None = None
|
||||||
for attempt in range(self.retries):
|
for attempt in range(self.retries):
|
||||||
try:
|
try:
|
||||||
return self._request_json(url, auth=auth)
|
return self._request_json(url, auth=auth, referer=referer)
|
||||||
except (TransportError, BilibiliHTTPError) as exc:
|
except (TransportError, BilibiliHTTPError) as exc:
|
||||||
last = exc
|
last = exc
|
||||||
if not exc.retryable or attempt >= self.retries - 1:
|
if not exc.retryable or attempt >= self.retries - 1:
|
||||||
@@ -238,17 +297,19 @@ class BilibiliClient:
|
|||||||
raise BilibiliProtocolError("接口响应缺少整数 code")
|
raise BilibiliProtocolError("接口响应缺少整数 code")
|
||||||
return BilibiliAPIError(code, str(payload.get("message") or ""))
|
return BilibiliAPIError(code, str(payload.get("message") or ""))
|
||||||
|
|
||||||
def get_json(self, url: str, *, auth: bool = False) -> dict[str, Any]:
|
def get_json(
|
||||||
|
self, url: str, *, auth: bool = False, referer: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""GET JSON;匿名失败时自动带完整 Cookie 重试一次(与 favsort 一致)。"""
|
"""GET JSON;匿名失败时自动带完整 Cookie 重试一次(与 favsort 一致)。"""
|
||||||
if auth:
|
if auth:
|
||||||
payload = self._request_with_retry(url, auth=True)
|
payload = self._request_with_retry(url, auth=True, referer=referer)
|
||||||
if payload.get("code") != 0:
|
if payload.get("code") != 0:
|
||||||
raise self._api_error(payload)
|
raise self._api_error(payload)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
anon_error: BilibiliError | None = None
|
anon_error: BilibiliError | None = None
|
||||||
try:
|
try:
|
||||||
payload = self._request_with_retry(url, auth=False)
|
payload = self._request_with_retry(url, auth=False, referer=referer)
|
||||||
if payload.get("code") == 0:
|
if payload.get("code") == 0:
|
||||||
return payload
|
return payload
|
||||||
anon_error = self._api_error(payload)
|
anon_error = self._api_error(payload)
|
||||||
@@ -257,7 +318,7 @@ class BilibiliClient:
|
|||||||
|
|
||||||
if self.logger is not None and anon_error is not None:
|
if self.logger is not None and anon_error is not None:
|
||||||
self.logger.debug("bili.anon_fallback", error=str(anon_error))
|
self.logger.debug("bili.anon_fallback", error=str(anon_error))
|
||||||
payload = self._request_with_retry(url, auth=True)
|
payload = self._request_with_retry(url, auth=True, referer=referer)
|
||||||
if payload.get("code") != 0:
|
if payload.get("code") != 0:
|
||||||
raise self._api_error(payload)
|
raise self._api_error(payload)
|
||||||
return payload
|
return payload
|
||||||
@@ -282,7 +343,11 @@ class BilibiliClient:
|
|||||||
url = (
|
url = (
|
||||||
f"{BASE}/x/v3/fav/resource/ids?media_id={int(media_id)}&platform=web"
|
f"{BASE}/x/v3/fav/resource/ids?media_id={int(media_id)}&platform=web"
|
||||||
)
|
)
|
||||||
payload = self.get_json(url)
|
referer = (
|
||||||
|
f"https://space.bilibili.com/{self.mid}/favlist"
|
||||||
|
f"?fid={int(media_id)}&ftype=create"
|
||||||
|
)
|
||||||
|
payload = self.get_json(url, referer=referer)
|
||||||
data = self._data_list(payload, "fav/resource/ids")
|
data = self._data_list(payload, "fav/resource/ids")
|
||||||
out: list[FavId] = []
|
out: list[FavId] = []
|
||||||
for index, item in enumerate(data):
|
for index, item in enumerate(data):
|
||||||
@@ -321,6 +386,10 @@ class BilibiliClient:
|
|||||||
return InfosResult(requested=(), items={}, missing=())
|
return InfosResult(requested=(), items={}, missing=())
|
||||||
|
|
||||||
fmid = self.mid if folder_mid is None else int(folder_mid)
|
fmid = self.mid if folder_mid is None else int(folder_mid)
|
||||||
|
referer = (
|
||||||
|
f"https://space.bilibili.com/{fmid}/favlist"
|
||||||
|
f"?fid={int(media_id)}&ftype=create"
|
||||||
|
)
|
||||||
items: dict[int, FavResource] = {}
|
items: dict[int, FavResource] = {}
|
||||||
for chunk in _chunks(requested, chunk_size):
|
for chunk in _chunks(requested, chunk_size):
|
||||||
resources = ",".join(f"{aid}:2" for aid in chunk)
|
resources = ",".join(f"{aid}:2" for aid in chunk)
|
||||||
@@ -329,7 +398,7 @@ class BilibiliClient:
|
|||||||
f"?resources={urllib.parse.quote(resources)}"
|
f"?resources={urllib.parse.quote(resources)}"
|
||||||
f"&platform=web&folder_mid={fmid}&folder_id={int(media_id)}"
|
f"&platform=web&folder_mid={fmid}&folder_id={int(media_id)}"
|
||||||
)
|
)
|
||||||
payload = self.get_json(url)
|
payload = self.get_json(url, referer=referer)
|
||||||
data = self._data_list(payload, "fav/resource/infos")
|
data = self._data_list(payload, "fav/resource/infos")
|
||||||
chunk_set = set(chunk)
|
chunk_set = set(chunk)
|
||||||
for item in data:
|
for item in data:
|
||||||
@@ -351,7 +420,11 @@ class BilibiliClient:
|
|||||||
def fav_folders(self, up_mid: int | None = None) -> list[FolderInfo]:
|
def fav_folders(self, up_mid: int | None = None) -> list[FolderInfo]:
|
||||||
mid = self.mid if up_mid is None else int(up_mid)
|
mid = self.mid if up_mid is None else int(up_mid)
|
||||||
url = f"{BASE}/x/v3/fav/folder/created/list-all?up_mid={mid}&jsonp=jsonp"
|
url = f"{BASE}/x/v3/fav/folder/created/list-all?up_mid={mid}&jsonp=jsonp"
|
||||||
payload = self.get_json(url, auth=True)
|
payload = self.get_json(
|
||||||
|
url,
|
||||||
|
auth=True,
|
||||||
|
referer=f"https://space.bilibili.com/{mid}/favlist",
|
||||||
|
)
|
||||||
data = payload.get("data") or {}
|
data = payload.get("data") or {}
|
||||||
if not isinstance(data, Mapping):
|
if not isinstance(data, Mapping):
|
||||||
raise BilibiliProtocolError("fav/folder/created/list-all data 不是对象")
|
raise BilibiliProtocolError("fav/folder/created/list-all data 不是对象")
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ class ClientProvider:
|
|||||||
credentials = getattr(self.store.get().bilibili, self.role)
|
credentials = getattr(self.store.get().bilibili, self.role)
|
||||||
key = (credentials.mid, credentials.cookie, credentials.csrf)
|
key = (credentials.mid, credentials.cookie, credentials.csrf)
|
||||||
if self._client is None or key != self._key:
|
if self._client is None or key != self._key:
|
||||||
|
if self._client is not None:
|
||||||
|
close = getattr(self._client, "close", None)
|
||||||
|
if callable(close):
|
||||||
|
close()
|
||||||
self._client = self.factory(credentials)
|
self._client = self.factory(credentials)
|
||||||
self._key = key
|
self._key = key
|
||||||
return self._client
|
return self._client
|
||||||
@@ -18,9 +18,11 @@ from .client import (
|
|||||||
|
|
||||||
|
|
||||||
class BilibiliWriteClient(BilibiliClient):
|
class BilibiliWriteClient(BilibiliClient):
|
||||||
def post_form(self, url: str, data: Mapping[str, Any]) -> dict[str, Any]:
|
def post_form(
|
||||||
|
self, url: str, data: Mapping[str, Any], *, referer: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
self.pacer.wait()
|
self.pacer.wait()
|
||||||
headers = self._headers(auth=True)
|
headers = self._headers(auth=True, referer=referer)
|
||||||
headers["content-type"] = "application/x-www-form-urlencoded"
|
headers["content-type"] = "application/x-www-form-urlencoded"
|
||||||
body = urllib.parse.urlencode(data).encode("utf-8")
|
body = urllib.parse.urlencode(data).encode("utf-8")
|
||||||
response = self.transport.request(
|
response = self.transport.request(
|
||||||
@@ -47,6 +49,10 @@ class BilibiliWriteClient(BilibiliClient):
|
|||||||
"del_media_ids": "",
|
"del_media_ids": "",
|
||||||
"csrf": self.csrf,
|
"csrf": self.csrf,
|
||||||
},
|
},
|
||||||
|
referer=(
|
||||||
|
f"https://space.bilibili.com/{self.mid}/favlist"
|
||||||
|
f"?fid={int(folder_id)}&ftype=create"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def sort(self, media_id: int, sort_spec: str) -> dict[str, Any]:
|
def sort(self, media_id: int, sort_spec: str) -> dict[str, Any]:
|
||||||
@@ -57,4 +63,8 @@ class BilibiliWriteClient(BilibiliClient):
|
|||||||
"sort": sort_spec,
|
"sort": sort_spec,
|
||||||
"csrf": self.csrf,
|
"csrf": self.csrf,
|
||||||
},
|
},
|
||||||
|
referer=(
|
||||||
|
f"https://space.bilibili.com/{self.mid}/favlist"
|
||||||
|
f"?fid={int(media_id)}&ftype=create"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@@ -10,6 +10,8 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from typing import Any, Mapping, Protocol, Sequence
|
from typing import Any, Mapping, Protocol, Sequence
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
MAX_CANDIDATES_PER_MESSAGE = 3
|
MAX_CANDIDATES_PER_MESSAGE = 3
|
||||||
|
|
||||||
|
|
||||||
@@ -64,6 +66,49 @@ def format_candidate(candidate: Mapping[str, Any]) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HttpxTelegramTransport:
|
||||||
|
"""基于 httpx 连接池的 Telegram 传输。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
timeout: float = 30,
|
||||||
|
client: httpx.Client | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.timeout = timeout
|
||||||
|
self._client = client or httpx.Client(
|
||||||
|
timeout=httpx.Timeout(timeout, connect=10),
|
||||||
|
limits=httpx.Limits(
|
||||||
|
max_keepalive_connections=5,
|
||||||
|
keepalive_expiry=30,
|
||||||
|
),
|
||||||
|
follow_redirects=False,
|
||||||
|
trust_env=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def post(self, url: str, data: Mapping[str, Any], timeout: float = 30) -> Any:
|
||||||
|
try:
|
||||||
|
response = self._client.post(url, data=dict(data), timeout=timeout)
|
||||||
|
except httpx.TransportError as exc:
|
||||||
|
raise TelegramError(str(exc)) from exc
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise TelegramError(
|
||||||
|
f"Telegram HTTP {response.status_code}: {response.text[:200]}"
|
||||||
|
) from exc
|
||||||
|
if response.status_code >= 400 and not (
|
||||||
|
isinstance(payload, Mapping) and "ok" in payload
|
||||||
|
):
|
||||||
|
raise TelegramError(
|
||||||
|
f"Telegram HTTP {response.status_code}: {str(payload)[:200]}"
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._client.close()
|
||||||
|
|
||||||
|
|
||||||
class TelegramClient:
|
class TelegramClient:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -81,11 +126,16 @@ class TelegramClient:
|
|||||||
raise ValueError("telegram.chat_id 不能为空")
|
raise ValueError("telegram.chat_id 不能为空")
|
||||||
self.token = token
|
self.token = token
|
||||||
self.chat_id = int(chat_id)
|
self.chat_id = int(chat_id)
|
||||||
self.transport = transport or UrllibTelegramTransport()
|
self.transport = transport or HttpxTelegramTransport()
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.poll_timeout = poll_timeout
|
self.poll_timeout = poll_timeout
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
close = getattr(self.transport, "close", None)
|
||||||
|
if callable(close):
|
||||||
|
close()
|
||||||
|
|
||||||
def _call(
|
def _call(
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ sys.stdout.reconfigure(encoding="utf-8")
|
|||||||
from reaction_board.bilibili.auth import ( # noqa: E402
|
from reaction_board.bilibili.auth import ( # noqa: E402
|
||||||
cookie_value,
|
cookie_value,
|
||||||
cookies_from_set_cookie,
|
cookies_from_set_cookie,
|
||||||
|
make_headers,
|
||||||
merge_cookie,
|
merge_cookie,
|
||||||
parse_cookie,
|
parse_cookie,
|
||||||
sessdata_expiry,
|
sessdata_expiry,
|
||||||
@@ -78,10 +79,30 @@ def test_qr_png():
|
|||||||
assert path.read_bytes().startswith(b"\x89PNG\r\n\x1a\n")
|
assert path.read_bytes().startswith(b"\x89PNG\r\n\x1a\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_headers_matches_raw_request():
|
||||||
|
headers = make_headers({"cookie": "SESSDATA=x; buvid3=y"}, auth=False)
|
||||||
|
assert headers["origin"] == "https://space.bilibili.com"
|
||||||
|
assert headers["referer"] == "https://space.bilibili.com/"
|
||||||
|
assert headers["priority"] == "u=1, i"
|
||||||
|
assert headers["user-agent"].endswith("Chrome/155.0.0.0 Safari/537.36")
|
||||||
|
assert headers["sec-ch-ua"].startswith('"Google Chrome";v="155"')
|
||||||
|
assert "SESSDATA" not in headers["cookie"]
|
||||||
|
assert "buvid3=y" in headers["cookie"]
|
||||||
|
|
||||||
|
full = make_headers({"cookie": "SESSDATA=x"}, auth=True)
|
||||||
|
assert full["cookie"] == "SESSDATA=x"
|
||||||
|
|
||||||
|
custom = make_headers(
|
||||||
|
{"cookie": ""}, referer="https://space.bilibili.com/1/favlist?fid=2"
|
||||||
|
)
|
||||||
|
assert custom["referer"] == "https://space.bilibili.com/1/favlist?fid=2"
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
test_cookie_helpers()
|
test_cookie_helpers()
|
||||||
test_finish_updates_only_selected_account_and_rolls_back()
|
test_finish_updates_only_selected_account_and_rolls_back()
|
||||||
test_qr_png()
|
test_qr_png()
|
||||||
|
test_make_headers_matches_raw_request()
|
||||||
print("OK: 双账号 auth Cookie 合并、写回隔离与回滚测试全部通过")
|
print("OK: 双账号 auth Cookie 合并、写回隔离与回滚测试全部通过")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ def test_fav_ids_parse_and_anonymous_header():
|
|||||||
assert "media_id=3700023351" in call["url"]
|
assert "media_id=3700023351" in call["url"]
|
||||||
assert "SESSDATA" not in call["headers"]["cookie"]
|
assert "SESSDATA" not in call["headers"]["cookie"]
|
||||||
assert "buvid3=device" in call["headers"]["cookie"]
|
assert "buvid3=device" in call["headers"]["cookie"]
|
||||||
|
assert call["headers"]["origin"] == "https://space.bilibili.com"
|
||||||
|
assert call["headers"]["priority"] == "u=1, i"
|
||||||
|
assert "Chrome/155.0.0.0" in call["headers"]["user-agent"]
|
||||||
|
assert "fid=3700023351" in call["headers"]["referer"]
|
||||||
|
|
||||||
|
|
||||||
def test_anonymous_failure_falls_back_to_full_cookie():
|
def test_anonymous_failure_falls_back_to_full_cookie():
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""httpx 传输层契约测试(不联网): uv run python tests/test_httpx_transport.py"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
from reaction_board.bilibili.client import ( # noqa: E402
|
||||||
|
HttpxTransport,
|
||||||
|
TransportError,
|
||||||
|
)
|
||||||
|
from reaction_board.notify.telegram import ( # noqa: E402
|
||||||
|
HttpxTelegramTransport,
|
||||||
|
TelegramError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bilibili_httpx_transport():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
captured["method"] = request.method
|
||||||
|
captured["url"] = str(request.url)
|
||||||
|
captured["ua"] = request.headers["user-agent"]
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"code": 0, "message": "OK", "data": []},
|
||||||
|
headers={"content-type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||||
|
transport = HttpxTransport(client=client)
|
||||||
|
try:
|
||||||
|
response = transport.request(
|
||||||
|
"GET",
|
||||||
|
"https://api.bilibili.com/x/test",
|
||||||
|
{"user-agent": "UA-TEST"},
|
||||||
|
data=None,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
transport.close()
|
||||||
|
assert response.status == 200
|
||||||
|
assert response.json()["code"] == 0
|
||||||
|
assert captured == {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "https://api.bilibili.com/x/test",
|
||||||
|
"ua": "UA-TEST",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bilibili_httpx_transport_maps_errors():
|
||||||
|
def handler(request):
|
||||||
|
raise httpx.ConnectError("boom")
|
||||||
|
|
||||||
|
transport = HttpxTransport(
|
||||||
|
client=httpx.Client(transport=httpx.MockTransport(handler))
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
transport.request("GET", "https://api.bilibili.com/x/test", {})
|
||||||
|
except TransportError as exc:
|
||||||
|
assert "boom" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("httpx 连接错误应映射为 TransportError")
|
||||||
|
finally:
|
||||||
|
transport.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_httpx_transport():
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json={"ok": True, "result": [{"update_id": 1}]})
|
||||||
|
|
||||||
|
transport = HttpxTelegramTransport(
|
||||||
|
client=httpx.Client(transport=httpx.MockTransport(handler))
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = transport.post(
|
||||||
|
"https://api.telegram.org/botx/getUpdates", {"timeout": 0}, 30
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
transport.close()
|
||||||
|
assert payload["ok"] is True
|
||||||
|
assert payload["result"][0]["update_id"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_httpx_transport_error_shapes():
|
||||||
|
def bad_json(request):
|
||||||
|
return httpx.Response(
|
||||||
|
400, json={"ok": False, "description": "query is too old"}
|
||||||
|
)
|
||||||
|
|
||||||
|
transport = HttpxTelegramTransport(
|
||||||
|
client=httpx.Client(transport=httpx.MockTransport(bad_json))
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = transport.post("https://api.telegram.org/botx/x", {}, 30)
|
||||||
|
finally:
|
||||||
|
transport.close()
|
||||||
|
assert payload["description"] == "query is too old"
|
||||||
|
|
||||||
|
def html_error(request):
|
||||||
|
return httpx.Response(500, text="bad gateway")
|
||||||
|
|
||||||
|
transport = HttpxTelegramTransport(
|
||||||
|
client=httpx.Client(transport=httpx.MockTransport(html_error))
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
transport.post("https://api.telegram.org/botx/x", {}, 30)
|
||||||
|
except TelegramError as exc:
|
||||||
|
assert "500" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("非 JSON 错误应抛出 TelegramError")
|
||||||
|
finally:
|
||||||
|
transport.close()
|
||||||
|
|
||||||
|
def network_error(request):
|
||||||
|
raise httpx.ConnectError("offline")
|
||||||
|
|
||||||
|
transport = HttpxTelegramTransport(
|
||||||
|
client=httpx.Client(transport=httpx.MockTransport(network_error))
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
transport.post("https://api.telegram.org/botx/x", {}, 30)
|
||||||
|
except TelegramError as exc:
|
||||||
|
assert "offline" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("网络错误应抛出 TelegramError")
|
||||||
|
finally:
|
||||||
|
transport.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
test_bilibili_httpx_transport()
|
||||||
|
test_bilibili_httpx_transport_maps_errors()
|
||||||
|
test_telegram_httpx_transport()
|
||||||
|
test_telegram_httpx_transport_error_shapes()
|
||||||
|
print("OK: httpx B 站与 Telegram 传输层测试全部通过")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -2,12 +2,93 @@ version = 1
|
|||||||
revision = 3
|
revision = 3
|
||||||
requires-python = "==3.14.*"
|
requires-python = "==3.14.*"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.15.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bili-fav"
|
name = "bili-fav"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "httpx" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
|
requires-dist = [{ name = "httpx", specifier = ">=0.28.1" }]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = []
|
dev = []
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.10.23"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.20"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||||
|
]
|
||||||
Reference in new issue
Block a user