Merge branch 'fix/pyright-env-and-type-guards' into dev

This commit is contained in:
yml2213
2026-10-08 12:34:11 +08:00
31 changed files with 599 additions and 96 deletions
+5 -1
View File
@@ -29,7 +29,11 @@ done <<< "$staged"
if [ "${#python_files[@]}" -gt 0 ]; then
echo "[pre-commit] 检查 Python 格式..."
uv run --group dev ruff format --check "${python_files[@]}"
echo "[pre-commit] Python 格式通过"
# ⚠️ 只跑 format 不够:format 不管「未使用导入 / 未排序导入 / 宽 except」这类
# 问题,之前全仓攒了 6 条(含一个 F401 死导入)没人拦。两条一起跑才算校验。
echo "[pre-commit] 检查 Python lint..."
uv run --group dev ruff check "${python_files[@]}"
echo "[pre-commit] Python 格式 + lint 通过"
fi
if [ "$frontend_changed" -eq 1 ]; then
+3 -1
View File
@@ -44,7 +44,9 @@ done <<< "$push_lines"
if [ "${#python_files[@]}" -gt 0 ]; then
echo "[pre-push] 检查 Python 格式..."
uv run --group dev ruff format --check "${python_files[@]}"
echo "[pre-push] Python 格式通过"
echo "[pre-push] 检查 Python lint..."
uv run --group dev ruff check "${python_files[@]}"
echo "[pre-push] Python 格式 + lint 通过"
fi
if [ "$frontend_changed" -eq 1 ]; then
+9 -4
View File
@@ -35,11 +35,14 @@
关键是**反向验证** —— 把 bug 注入回去,确认用例真的会变红,再还原。
「跑了一下没报错」不算验证。
- **提交/推送会跑钩子**,所以本地必须按同样标准自查:
- `pre-commit`:`ruff format --check`(**只跑 `ruff check` 不够**,格式不对会直接拦下提交)
- `pre-commit`:`ruff format --check` **和** `ruff check`(两个都要跑,见坑 #8)
+ 前端 `tsc -b`;
- `pre-push`:上面这些 + **全量 pytest**。
- `pre-push`:上面这些 + **全量 pytest**(`TYPECHECK=1` 时额外跑 Pyright)。
- **断言要看 `model_dump()`(序列化结果)**,只看属性会假绿(原因见坑 #2)。
- **别信「测试通过」** —— 用例可能是假绿的(原因见坑 #3)。
- **Pyright 只查 `core`/`utils`/`web`,`tests` 有意排除**(原因和重新打开的方法都写在
`pyrightconfig.json` 的注释里)。别为了让它闭嘴去给 FastAPI 路由补返回值注解 ——
那等于加了 `response_model`,会真的裁剪响应体。
## 4. 数据库与迁移
@@ -100,8 +103,10 @@ docker compose up -d douyu-login
| 5 | **`docker compose run` 默认读 stdin**:用 `ssh 'bash -s' < 脚本` 喂进去时,它会把脚本剩下的行吃掉 | 用 `docker compose run -T --rm ... </dev/null` |
| 6 | **镜像同步脚本会白等 15 分钟**:`pgrep -f 'mysqldump.*douyu_login'` 匹配到了自己所在的 shell,等待循环永不提前退出 | 判定远端进程存活别用会自匹配的 `pgrep -f` 模式 |
| 7 | **`set -e` + 输出过滤(管道接 `grep -v`)会半途静默中止**:过滤把输出全吃掉时 grep 返回 1,被 `set -e` 当成失败(曾在「已删库、还没导入」处中止,镜像库一度是空的) | 过滤输出后别让 `set -e` 接管退出码;关键步骤显式检查成功 |
| 8 | **`ruff check` 通过 ≠ 能提交**:钩子查的是 `ruff format` | 提交前本地跑一遍 `.githooks/pre-commit` |
| 8 | **格式检查和 lint 检查,只跑一个都会漏**:`ruff format --check` 对「未使用导入 / 未排序导入 / 宽 except」全部放过(实测同一个文件、同一处未使用 `import json`:format 退出码 0,check 退出码 1),而 `ruff check` 又完全不管格式 | 钩子现在两条都跑;本地自查也两条都跑(`./dev.sh format-check` + `./dev.sh lint-check`) |
| 9 | **偷换概念把「账号级状态」当成「全局字段」**:斗鱼商城 `storehome` 的 `count`(库存)确实是全局的,但 **`status` 字段是强账号维度的!`status=1` 就是「已达兑换上限」(限量 1 件已满/已兑换)**。曾把两者混为一谈,误以为商品列表全不可信,跑去依赖早已被官方清空的 `exchangeList`,导致已兑换的账号被错误打上「两个都可兑换」绿标 | 判定高价值资产准入:**以 `storehome.status==1` 为最高裁判**;`exchangeList` 仅做历史展示兜底,绝不可当作否定已拥有的依据 |
| 10 | **Pyright 的报错数量取决于「它怎么找到解释器」**:`pyrightconfig.json` 原本没钉解释器,直接跑 `.venv/bin/pyright` 时它会去 PATH 摸 `/usr/bin/python3`(macOS 自带 3.9),进而读用户级 `~/Library/Python/3.9/site-packages` 里的 **SQLAlchemy 1.4.54** 和 3.9 语义 → 全仓报 **2926** 条(`Mapped[int]` 说「不是泛型」、`int \| None` 说「需要 3.10+」、`UTC` 说不认识),而同一个仓库 `uv run pyright` 只有 **273** 条。**90% 的「错误」是环境假象** | 必须在配置里显式写 `venvPath`/`venv`/`pythonVersion`;IDE 里出现成片这类报错先查解释器。自查命令:`pyright --verbose` 看 `Assuming Python version` 和 Search paths 里有不有 3.9 |
| 11 | **Pyright 不认 `Field()` 的位置参数默认值**:`x: int = Field(3, ge=1, le=10)` 会被判成「必填」,凡是不传该参数的调用点全报 `Arguments missing for parameters`;`Field(default=3, ...)` 才认。运行期 pydantic 两边等价(否则测试早红了),纯静态判定差异 | schema 里一律写 `Field(default=..., ...)`;排查手法是写最小复现(一个 pydantic 模型 + 一次构造)确认到底是哪边的问题 |
## 8. 仓库事实(速查)
@@ -112,6 +117,6 @@ docker compose up -d douyu-login
| 本地服务 | `./dev.sh`(8800 端口,`--reload`);**启动时会自动跑迁移** |
| 本地 MySQL | 容器 `live-hub-py-mysql-1`,端口 3307;库见 §4 |
| 生产镜像库 | `douyu_login_prod`(`scripts/refresh_local_prod_mirror.sh` 同步,只读用) |
| 迁移 head | `main` = `20261004_0051`;`dev` = `20261007_0052`(**两侧不一致,切分支前先对齐库**) |
| 迁移 head | `main` = `20261008_0052`;`dev` = `20261008_0053`(**两侧不一致,切分支前先对齐库**)。这行会过期,动手前用 `git ls-tree -r --name-only <ref> web/backend/migrations/versions/` 自己算一遍 head(没被任何 `down_revision` 引用的那个) |
| 前端 | `tsc -b` 开了 `noUnusedLocals`,死代码/未用导入直接报 TS6133;镜像构建时会 `npm run build` |
| 测试 | `pytest tests/ -q`(用 `douyu_login_test`);`ruff format` + `ruff check` |
+5 -1
View File
@@ -145,6 +145,7 @@ uv run --group dev pytest # 或 ./dev.sh test
uv run --group dev pytest tests/test_cookie_operations.py
./dev.sh format tests conftest.py
./dev.sh format-check tests conftest.py
./dev.sh lint-check tests conftest.py
./dev.sh type-check # 渐进式 Pyright
docker compose --profile test run --rm test
```
@@ -168,9 +169,12 @@ docker compose --profile test run --rm test
./dev.sh frontend-check # 单独跑前端 TypeScript
```
- **pre-commit**:暂存的 Python 文件做 Ruff 格式检查;改了 `web/frontend/` 则跑 `tsc -b`
- **pre-commit**:暂存的 Python 文件做 Ruff 格式检查 **+ `ruff check` lint**;改了 `web/frontend/` 则跑 `tsc -b`
- **pre-push**:上述检查 + 全量 pytest
- Pyright 默认不开(噪音大):`TYPECHECK=1 git push`
—— 注意 `pyrightconfig.json` 已钉死 `.venv`/Python 3.12;**没有这段配置时 pyright 会去
摸 macOS 自带的 Python 3.9**,读成 SQLAlchemy 1.4 并凭空多报约 2600 条假错(2926 vs 273)。
若在 IDE 里看到成片「Mapped 不是泛型 / 联合类型需要 3.10+」,先查解释器有没有选对。
- 紧急绕过:`git push --no-verify` 或 `SKIP_TESTS=1 git push`
## 技术栈
+47 -7
View File
@@ -31,6 +31,7 @@ import struct
import time
import uuid
from collections.abc import Mapping
from typing import Literal, overload
from urllib.parse import parse_qs, quote, urlparse
import requests
@@ -245,7 +246,7 @@ def solve_safe_auth(
k: v[0]
for k, v in parse_qs(urlparse(risk_url).query, keep_blank_values=True).items()
}
app_id = str(q.get("appId") or "5002")
app_id = q.get("appId") or "5002"
last_err: Exception | None = None
for attempt in range(max_retry):
solver = HuyaVerificationSolver(
@@ -293,6 +294,42 @@ def parse_real_uid(resp: bytes) -> int:
raise HuyaAppLoginError("无法从登录响应解析真实 UID")
@overload
def login_cred_with_flow(
account: str,
password: str,
max_rounds: int = ...,
device_info: dict | None = ...,
proxies: dict | None = ...,
include_device_token: Literal[False] = ...,
sms_url: str = ...,
) -> tuple[bytes, int]: ...
@overload
def login_cred_with_flow(
account: str,
password: str,
max_rounds: int = ...,
device_info: dict | None = ...,
proxies: dict | None = ...,
include_device_token: Literal[True] = ...,
sms_url: str = ...,
) -> tuple[bytes, int, str]: ...
@overload
def login_cred_with_flow(
account: str,
password: str,
max_rounds: int = ...,
device_info: dict | None = ...,
proxies: dict | None = ...,
include_device_token: bool = ...,
sms_url: str = ...,
) -> tuple[bytes, int] | tuple[bytes, int, str]: ...
def login_cred_with_flow(
account: str,
password: str,
@@ -405,7 +442,11 @@ class QrRole:
self.req_counter = random.randint(40_000_000, 41_000_000)
self.s.headers.update(
{
"User-Agent": UA_PC if pc else mobile_user_agent(device_info),
"User-Agent": (
UA_PC
if pc or device_info is None
else mobile_user_agent(device_info)
),
"Origin": UDB_BASE,
"content-type": "application/json;charset=UTF-8",
"Accept": "*/*",
@@ -550,8 +591,7 @@ class HuyaAppPasswordLogin:
jar = session.cookies
if hasattr(jar, "items"):
pairs = [
(str(key), "" if value is None else str(value))
for key, value in jar.items()
(key, "" if value is None else value) for key, value in jar.items()
]
else:
pairs = [
@@ -576,13 +616,13 @@ class HuyaAppPasswordLogin:
raw_cred = (
base64.urlsafe_b64encode(cred).decode("ascii").rstrip("=")
if isinstance(cred, bytes)
else str(cred or "")
else (cred or "")
)
# 本次登录/指纹响应明确返回的字段,必须覆盖旧 Session 值。
set_value("udb_cred", raw_cred, overwrite=True)
if uid:
set_value("udb_uid", int(uid), overwrite=True)
set_value("yyuid", int(uid), overwrite=True)
set_value("udb_uid", uid, overwrite=True)
set_value("yyuid", uid, overwrite=True)
set_value("udb_biztoken", biztoken, overwrite=True)
set_value("udb_version", "1.0", overwrite=True)
set_value("udb_origin", "0", overwrite=True)
+23 -6
View File
@@ -136,11 +136,18 @@ def launch(
proc = subprocess.Popen(
args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True
)
# 上面用的是 stderr=PIPE,所以 proc.stderr 一定不是 None(静态类型是
# IO[str] | None)。收一次窄,顺便在真拿不到 stderr 时给出明确报错。
stderr = proc.stderr
if stderr is None:
proc.kill()
raise RuntimeError("Chrome stderr 未接通,无法读取调试端口")
ws_url = None
deadline = time.time() + 20
last_probe_error: Exception | None = None
while time.time() < deadline:
# 1) 从 DevTools 输出的 ws url(browser 级)
line = proc.stderr.readline()
line = stderr.readline()
if "DevTools listening on" in line:
browser_ws = line.strip().split(" ")[-1]
else:
@@ -151,23 +158,33 @@ def launch(
f"http://127.0.0.1:{port}/json/list", timeout=2
) as resp:
targets = json.load(resp)
pages = {
# 必须是 list 而不是 set:下面要按下标取「第一个」page target,
# set 不可下标。这里曾写成集合推导式,pages[0] 抛出的 TypeError
# 被下面的宽 except 吞掉,于是每次都静默退回 browser 级 endpoint,
# 页面级探测长期失效却无人察觉。回归用例:
# tests/test_huya_browser_harvest.py::test_launch_prefers_page_target_over_browser_endpoint
pages = [
t
for t in targets
if t.get("type") == "page" and t.get("webSocketDebuggerUrl")
}
]
if pages:
ws_url = pages[0]["webSocketDebuggerUrl"]
break
except Exception: # noqa: BLE001, S110 - 端口未就绪时轮询重试
pass
except Exception as exc: # noqa: BLE001 - 端口未就绪时轮询重试
# 不静默:记下最后一次探测异常,端口最终没起来时一并报出来,
# 避免「代码 bug」被读成「端口没起来」。
last_probe_error = exc
# 3) 兜底:browser 级 endpoint
if browser_ws:
ws_url = browser_ws
break
if not ws_url:
proc.kill()
raise RuntimeError("Chrome 调试端口未就绪")
detail = (
f";最后一次 CDP 探测异常: {last_probe_error!r}" if last_probe_error else ""
)
raise RuntimeError(f"Chrome 调试端口未就绪{detail}")
return proc, ws_url
+7 -1
View File
@@ -35,7 +35,13 @@ class HuyaEliteWssSession:
self.logger = logger or (lambda _message: None)
self.http = HuyaHttpClient(logger=self.logger)
self.loop = asyncio.new_event_loop()
self.client: HuyaWssClient | None = None
# 会话内**始终**持有 client:下面(本函数的 try 之前)无条件创建,
# close() 只置 self._closed,从不把它改回 None。原先标成 `| None = None`
# 是陈旧声明,代价是 20 处 `self.client.xxx(...)` 全被报「可能为 None」。
# 顺带说明:_run() 里 `self.client is None` 这条兜底因此是死代码 ——
# 而且它本来就被求值顺序绕过(`self.client.xxx()` 作为实参先于 _run 执行),
# 保留它只为防御未来有人真的把 client 置空。
self.client: HuyaWssClient
self._closed = False
if kind == "activity":
+23 -13
View File
@@ -65,7 +65,7 @@ def _cookie_mapping(cookie: Mapping[str, str] | str | None) -> dict[str, str]:
parsed = SimpleCookie()
parsed.load(cookie)
return {key: morsel.value for key, morsel in parsed.items()}
return {str(key): str(value) for key, value in cookie.items()}
return {key: value for key, value in cookie.items()}
class HuyaVerificationSolver:
@@ -214,7 +214,7 @@ class HuyaVerificationSolver:
background = cv2.cvtColor(cv2.Canny(background, 100, 200), cv2.COLOR_GRAY2RGB)
result = cv2.matchTemplate(background, target, cv2.TM_CCOEFF_NORMED)
_, _, _, max_loc = cv2.minMaxLoc(result)
return int(max_loc[0])
return max_loc[0]
@staticmethod
def _opencv_rotate_angle(
@@ -331,7 +331,10 @@ class HuyaVerificationSolver:
if Path(font_path).is_file():
try:
return ImageFont.truetype(font_path, size)
except Exception:
except OSError:
# 字体文件在、但 FreeType 打不开或格式不对(实测「cannot open
# resource」和「unknown file format」都是 OSError)—— 换下一个候选。
# 收窄异常类型:宽 except 会把参数写错之类的真 bug 一起吞掉。
continue
return ImageFont.load_default()
@@ -627,7 +630,7 @@ class HuyaVerificationSolver:
restored_bg, tip_bytes, int(data.get("location_y") or 0)
)
slider_box = self.ocr.ocr_slider_box(composite)
yolo_x = int(slider_box[0] - 13)
yolo_x = slider_box[0] - 13
opencv_x = self._opencv_slider_x(tip_bytes, restored_bg)
if abs(opencv_x - yolo_x) < 10:
logger.debug("虎牙滑块识别距离: yolo={}, opencv={}", yolo_x, opencv_x)
@@ -666,7 +669,7 @@ class HuyaVerificationSolver:
if tip_bytes:
merged = self._vertical_concat_align_left(bg_bytes, tip_bytes)
points = self.ocr.ocr_click_points(merged)
final_points = [[int(x), int(y) - 44] for x, y in points]
final_points = [[x, y - 44] for x, y in points]
else:
question = str(data.get("question") or "")
target_chars = self._extract_target_chars_from_question(question)
@@ -675,7 +678,7 @@ class HuyaVerificationSolver:
synth_tip = self._synthesize_text_tip(target_chars)
merged = self._vertical_concat_align_left(bg_bytes, synth_tip)
points = self.ocr.ocr_click_points(merged)
final_points = [[int(x), int(y) - 44] for x, y in points]
final_points = [[x, y - 44] for x, y in points]
tip_bytes = synth_tip
else:
logger.info(f"虎牙点选题目未能提取明确目标字: {question}")
@@ -699,11 +702,11 @@ class HuyaVerificationSolver:
drag_distance = max(5, int((angle / 360.0) * 260))
track_points = generate_slide_track(drag_distance)
act_data = {
"point": int(round(angle)),
"point": round(angle),
"travel": format_track(track_points),
"code": self.code,
"sceneType": self.scene_type,
"endTime": int(track_points[-1][-1]) - int(track_points[0][-1]),
"endTime": track_points[-1][-1] - track_points[0][-1],
}
act = self.js_ctx.call("get_act", act_data, self.huyapk)
self.behavior_events.append(
@@ -726,11 +729,11 @@ class HuyaVerificationSolver:
def _send_slider_verify(self, url: str, point: int) -> dict:
track_points = generate_slide_track(point)
act_data = {
"point": int(point),
"point": point,
"travel": format_track(track_points),
"code": self.code,
"sceneType": self.scene_type,
"endTime": int(track_points[-1][-1]) - int(track_points[0][-1]),
"endTime": track_points[-1][-1] - track_points[0][-1],
}
act = self.js_ctx.call("get_act", act_data, self.huyapk)
self.behavior_events.append(
@@ -759,7 +762,7 @@ class HuyaVerificationSolver:
current_time = start_time
for point in points:
current_time += random.randint(1000, 2000)
click_data.append([int(point[0]), int(point[1]), current_time])
click_data.append([point[0], point[1], current_time])
interval_time_list = [item[-1] for item in click_data]
act_data = {
@@ -958,11 +961,18 @@ class HuyaVerificationSolver:
or "/auth/xz/" in vurl
or "rotate" in self.strategy_url
):
last_result = self._send_rotate_verify(vurl, float(point))
if isinstance(point, (int, float)):
last_result = self._send_rotate_verify(vurl, float(point))
else:
logger.warning(f"旋转验证识别返回非数值结果: {point}")
continue
elif isinstance(point, list):
last_result = self._send_click_verify(vurl, point)
elif point is not None:
last_result = self._send_slider_verify(vurl, point)
else:
last_result = self._send_slider_verify(vurl, int(point))
logger.warning("验证码识别未返回有效结果")
continue
if isinstance(last_result, dict) and (
last_result.get("authId") or last_result.get("nextAuthUrl")
+1 -1
View File
@@ -56,7 +56,7 @@ class WupRequest:
os.write_struct(0, struct_data)
elif isinstance(struct_data, dict):
os.write_struct_begin(0)
for tag_id, (field_name, field_value) in enumerate(struct_data.items()):
for tag_id, (_, field_value) in enumerate(struct_data.items()):
self._write_field(os, tag_id, field_value)
os.write_struct_end()
elif isinstance(struct_data, (list, tuple)):
+6
View File
@@ -96,6 +96,12 @@ if [ "${1:-}" = "format-check" ]; then
exec uv run --group dev python -m ruff format --check "$@"
fi
# 与 format-check 分开:ruff format 不查未使用导入/未排序导入/宽 except 这类问题。
if [ "${1:-}" = "lint-check" ]; then
shift
exec uv run --group dev python -m ruff check "$@"
fi
if [ "${1:-}" = "type-check" ]; then
shift
exec uv run --group dev python -m pyright "$@"
+4
View File
@@ -41,6 +41,10 @@ packages = ["core", "utils", "web"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "B", "I", "BLE"]
ignore = ["E402", "E731", "E741", "B904", "B905"]
[tool.ruff.lint.per-file-ignores]
# FastAPI evaluates dependency metadata in route parameter defaults by design.
"web/backend/**/*.py" = ["B008"]
+22 -2
View File
@@ -1,5 +1,6 @@
{
"include": ["core", "utils", "web", "tests"],
// tests/ 有意**不**做类型检查(见下方说明),所以不放进 include。
"include": ["core", "utils", "web"],
"exclude": [
"**/__pycache__",
"artifacts",
@@ -10,5 +11,24 @@
],
"typeCheckingMode": "basic",
"reportMissingImports": "none",
"reportMissingModuleSource": "none"
"reportMissingModuleSource": "none",
// 必须显式钉死解释器:否则 pyright 会去 PATH 上摸 /usr/bin/python3(macOS 自带 3.9),
// 读到用户级 ~/Library/Python/3.9 里的 SQLAlchemy 1.4 + 把语义版本当日 3.9,
// 凭空多出约 2600 条假错(详见 pyright --verbose 的 "Assuming Python version")。
"venvPath": ".",
"venv": ".venv",
"pythonVersion": "3.12",
// ── 为什么 tests/ 不做类型检查 ───────────────────────────────────────────
// 打开 tests 会多出约 119 条,且**全部是同一类噪音**,不是测试写错了:
// 1) FastAPI 路由函数没有(也不该有)返回值注解,pyright 只能从 return
// 字面量反推成一个很窄的 dict 类型,于是 `result["items"][0]` 报
// 「No overloads for __getitem__」;给路由补注解=给 FastAPI 加了
// response_model,会真的裁剪响应体,绝不能为了消警告去加。
// 2) 测试故意传 `db=None` 走 mock 分支,而形参注解是 Session。
// 3) `resp.json()` 静态类型是 object,下标取值必然报错。
// 这三类要么得改生产签名、要么得在测试里撒 cast/ignore,属于 AGENTS.md 说的
//「没人要的重构」。测试的正确性本来就由 pytest **真跑一遍**来保证,而
// 未定义名/未用导入已由 `ruff check`(含 F 系列)覆盖。
// 要临时把 tests 也检查一遍:把 "tests" 加回上面的 include 再跑
// `./dev.sh type-check`,看完记得改回来。
}
+120
View File
@@ -0,0 +1,120 @@
"""虎牙「短信接码一键登录」端点:调用契约与成功/失败两条分支。
背景(这是本文件的**存在理由**,别删):该端点曾引用一个从来不存在的
``core.huya.auto_register.auto_register_huya_phone``(git 全历史都没有 ``def``),
所以每次调用都在 import 阶段抛 ``ImportError`` → HTTP 500,且因为导入写在函数体
内部,静态检查之外没有任何东西拦得住它。用例锁死「端点必须调用真实存在的
``register_huya_with_sms_line``」这个契约,名字再被改就不会静默回归。
"""
import pytest
from fastapi import HTTPException
from core.huya.auto_register import HuyaAutoRegisterResult
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import HuyaAccount, User
from web.backend.routers.huya.login import auto_sms_login_account
# 能被 parse_huya_cookie_line 认出来的最小虎牙 CK(含 yyuid= / udb_uid=)。
LOGIN_COOKIE = "yyuid=100; udb_uid=100"
def _ok_result(item, **kwargs):
return HuyaAutoRegisterResult(
phone=item.phone,
provider=item.provider,
success=True,
status="success",
message="接码成功",
cookie=LOGIN_COOKIE,
)
class TestAutoSmsLoginAccount:
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.user = User(username="operator", password_hash="hash", role="super_admin")
self.account = HuyaAccount(
uid="",
yyuid="",
cookie="",
game_phone="13800000000",
sms_url="https://sms.example.com/get?p=1",
)
self.session.add_all([self.user, self.account])
self.session.commit()
def teardown_method(self):
self.session.close()
def _call(self):
return auto_sms_login_account(
self.account.id, db=self.session, current=self.user
)
def test_calls_existing_register_helper_and_saves_cookie(self, monkeypatch):
"""核心契约:必须调用真实存在的注册函数,并把返回的 CK 回填落库。"""
calls: dict = {}
def fake_register(item, **kwargs):
calls["item"] = item
calls["kwargs"] = kwargs
return _ok_result(item, **kwargs)
# 用字符串形式 patch 目标模块属性:端点内部是 `from ... import ...`,
# 每次调用都会重新取模块属性,所以这层 patch 必须生效。
monkeypatch.setattr(
"core.huya.auto_register.register_huya_with_sms_line", fake_register
)
out = self._call()
assert out["success"] is True
# 断言序列化结果,不看属性(AGENTS.md 坑 #2)
assert out["account"].model_dump()["uid"] == "100"
assert calls["item"].phone == "13800000000"
assert calls["item"].url == "https://sms.example.com/get?p=1"
# 超时/轮询间隔是端点的既有行为,不能被顺手改掉
assert calls["kwargs"]["wait_seconds"] == 90
assert calls["kwargs"]["poll_interval"] == 2
self.session.refresh(self.account)
assert self.account.status == "active"
assert self.account.login_channel == "sms"
assert "udb_uid=100" in self.account.cookie
def test_failure_marks_account_login_failed(self, monkeypatch):
"""注册失败要落库成 login_failed 并返回 502,而不是假装成功。"""
def fake_register(item, **kwargs):
return HuyaAutoRegisterResult(
phone=item.phone,
provider=item.provider,
success=False,
status="failed",
message="接码平台超时",
)
monkeypatch.setattr(
"core.huya.auto_register.register_huya_with_sms_line", fake_register
)
with pytest.raises(HTTPException) as excinfo:
self._call()
assert excinfo.value.status_code == 502
assert "接码平台超时" in str(excinfo.value.detail)
self.session.refresh(self.account)
assert self.account.status == "login_failed"
def test_missing_phone_or_sms_url_is_rejected_before_import(self):
"""缺手机号/接码链接要在调用注册前 400,不能先崩在 import 上。"""
self.account.game_phone = ""
self.session.commit()
with pytest.raises(HTTPException) as excinfo:
self._call()
assert excinfo.value.status_code == 400
+49
View File
@@ -1,3 +1,5 @@
import io
import json
from pathlib import Path
from core.huya import browser_harvest
@@ -51,3 +53,50 @@ def test_launch_can_show_window_when_requested(monkeypatch):
browser_harvest.launch(Path("/tmp/test-huya-profile"), 9556, headless=False)
assert "--headless=new" not in calls[0]
def test_launch_prefers_page_target_over_browser_endpoint(monkeypatch):
"""页面级 ws 必须优先于 browser 级(Network 域只能挂在 page target 上)。
这里原本写的是 ``pages[0]``,而 ``pages`` 是集合推导出来的 **set** ——
``set`` 不可下标,抛出的 TypeError 又被外层 ``except Exception: pass``
吞掉,于是每次都退回 browser 级 endpoint:页面级探测**从来没有成功过一次**,
而且没有任何日志能看出来。这就是本用例存在的意义。
"""
page_ws = "ws://127.0.0.1/devtools/page/PAGE"
browser_ws = "ws://127.0.0.1/devtools/browser/test"
payload = json.dumps([{"type": "page", "webSocketDebuggerUrl": page_ws}])
monkeypatch.setattr(
browser_harvest.subprocess, "Popen", lambda args, **kwargs: _FakeProcess()
)
# _FakeProcess.stderr 会给出 browser 级 endpoint,用来证明「选中了页面级」
monkeypatch.setattr(
browser_harvest.urllib.request,
"urlopen",
lambda *args, **kwargs: io.StringIO(payload),
)
_proc, ws_url = browser_harvest.launch(Path("/tmp/test-huya-profile"), 9557)
assert ws_url == page_ws
assert ws_url != browser_ws
def test_launch_falls_back_to_browser_endpoint_without_page_target(monkeypatch):
"""没有 page target 时才允许退回 browser 级 endpoint。"""
browser_ws = "ws://127.0.0.1/devtools/browser/test"
payload = json.dumps([{"type": "service_worker", "webSocketDebuggerUrl": "ws://x"}])
monkeypatch.setattr(
browser_harvest.subprocess, "Popen", lambda args, **kwargs: _FakeProcess()
)
monkeypatch.setattr(
browser_harvest.urllib.request,
"urlopen",
lambda *args, **kwargs: io.StringIO(payload),
)
_proc, ws_url = browser_harvest.launch(Path("/tmp/test-huya-profile"), 9558)
assert ws_url == browser_ws
@@ -10,6 +10,7 @@ from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from web.backend.services.login_failure import classify_failure
revision: str = "20261008_0052"
-1
View File
@@ -57,7 +57,6 @@ from ..services.account_lifecycle import (
set_manual_state,
)
from ..services.account_service import (
cookie_account_ids_query,
parse_and_build_accounts,
)
from ..services.account_value import (
+5 -3
View File
@@ -58,10 +58,12 @@ def _audit_log_out(db: Session, row: AuditLog) -> dict:
for task, account in tasks_and_accounts:
task_result = task.result if isinstance(task.result, dict) else {}
channel = str(task_result.get("recharge_channel") or "")
# 先落到局部变量再判 isinstance:直接写
# `x.get(k) if isinstance(x.get(k), dict) else {}` 是两次独立取值,
# 类型检查器无法据此收窄,会报「可能为 None」。
raw_supplier_order = task_result.get("supplier_order")
supplier_order = (
task_result.get("supplier_order")
if isinstance(task_result.get("supplier_order"), dict)
else {}
raw_supplier_order if isinstance(raw_supplier_order, dict) else {}
)
supplier_order_id = str(
supplier_order.get("order_id")
+13 -1
View File
@@ -544,4 +544,16 @@ def _unbind_device_accounts(accounts: list[str]) -> tuple[list[str], list[str]]:
return removed, missing
__all__ = [name for name in globals() if not name.startswith("__")]
# 运行期导出清单:显式列出「非 dunder」的全部模块级名字(**含下划线开头的内部
# helper**),因为 python 的 `import *` 只要模块定义了 `__all__` 就照单全收。
#
# ⚠️ 六个 huya 路由模块(accounts/cookies/register/workbench/login/devices)原本
# 用 `from ._common import *`,全部已改为**显式导入**。原因不是风格洁癖:pyright
# 不会执行上面的推导式,只能按 PEP 8 保守假设「下划线名不导出」,于是那 108 处
# helper 调用全被报成「未定义」—— 更糟的是,它让这 6 个模块的类型检查彻底失明
# (helper 改名/删除时静态检查一声不吭,只能等运行期 500;曾因此漏掉一个
# `ImportError` 死端点)。新增代码请一律用显式导入。
#
# 这里保留计算式 `__all__` 是为了不改动任何运行期行为:万一还有别处 `import *`,
# 行为与改造前完全一致。pyright 无法静态求值它,故就地忽略该诊断。
__all__ = [name for name in globals() if not name.startswith("__")] # pyright: ignore[reportUnsupportedDunderAll]
+47 -1
View File
@@ -2,7 +2,53 @@
from fastapi import APIRouter
from ._common import *
from ._common import (
DEFAULT_EXCHANGE_TARGET,
AccountAssign,
AccountBulkSelection,
AccountBulkTag,
AccountTag,
BatchAssign,
Depends,
HTTPException,
HuyaAccount,
HuyaBrowserExchangeRequest,
HuyaCookieImport,
HuyaPasswordAccountImport,
HuyaTask,
HuyaWorkbenchAccount,
Query,
Session,
User,
_account_out,
_account_out_light,
_can_view_huya_all,
_can_view_huya_assigned,
_can_view_huya_cookie,
_clear_huya_account_references,
_filter_huya_accounts_query,
_huya_cookie_state_map,
_huya_password_state_map,
_require_huya_perm,
_require_huya_tag_permission,
_selected_huya_account_ids,
_visible_huya_accounts_query,
browser_login_script,
cookie_pairs,
defer,
ensure_huya_config,
exchange_target_options,
exchange_target_url,
func,
get_current_user,
get_db,
huya_config_value,
import_huya_cookies,
import_huya_password_accounts,
normalize_huya_cookie,
or_,
record_audit,
)
router = APIRouter()
+24 -1
View File
@@ -2,7 +2,30 @@
from fastapi import APIRouter
from ._common import *
from ._common import (
Depends,
HTTPException,
HuyaAccount,
Query,
Session,
StreamingResponse,
User,
_can_view_huya_cookie,
_cookie_out,
_cookie_out_light,
_has_huya_perm,
_require_huya_perm,
_visible_huya_accounts_query,
csv,
defer,
get_current_user,
get_db,
io,
normalize_huya_cookie,
or_,
rebuild_web_cookie_from_raw,
record_audit,
)
router = APIRouter()
+19 -1
View File
@@ -2,7 +2,25 @@
from fastapi import APIRouter
from ._common import *
from ._common import (
_DEVICE_BINDINGS_PERMS_VIEW,
Depends,
HTTPException,
HuyaAccount,
HuyaDeviceBindingsBatchDeleteRequest,
Session,
User,
_fp_state_dir,
_load_profile_db,
_mask_hex,
_require_huya_perm,
_save_profile_db,
_unbind_device_accounts,
get_current_user,
get_db,
shutil,
user_has_permission,
)
router = APIRouter()
+42 -3
View File
@@ -4,7 +4,41 @@ import time
from fastapi import APIRouter
from ._common import *
from ._common import (
UTC,
AccountBulkSelection,
Depends,
HTTPException,
HuyaAccount,
HuyaAppLoginError,
HuyaAppPasswordLoginRequest,
HuyaAppQrAuthRequiredError,
HuyaCredentialError,
HuyaLoginError,
HuyaPasswordLoginRequest,
HuyaPasswordLoginSelectedRequest,
HuyaSmsCodeRequest,
HuyaSmsLoginRequest,
HuyaTestSmsUrlRequest,
Session,
User,
_account_out,
_can_view_huya_cookie,
_require_huya_perm,
_selected_huya_account_ids,
_visible_huya_accounts_query,
cast,
datetime,
get_current_user,
get_db,
logger,
login_huya_app_password,
login_huya_password,
login_huya_sms,
save_huya_login_cookie_to_account,
send_huya_sms_code,
upsert_huya_cookie,
)
router = APIRouter()
@@ -287,7 +321,12 @@ def auto_sms_login_account(
sms_url = (account.sms_url or "").strip()
if not phone or not sms_url:
raise HTTPException(status_code=400, detail="该账号缺少手机号或接码链接")
from core.huya.auto_register import auto_register_huya_phone
# ⚠️ 这里原来是 `auto_register_huya_phone` —— 该名字在 core.huya.auto_register
# 里**从来没存在过**(2026-09-06 起就是死的),每次调用都在 import 阶段抛
# ImportError → 500。真实函数名是 register_huya_with_sms_line,返回的
# HuyaAutoRegisterResult 同样有 .success / .cookie / .message。
# 回归用例:tests/test_huya_auto_sms_login.py
from core.huya.auto_register import register_huya_with_sms_line
from core.sms_provider import SmsLine, detect_provider
item = SmsLine(
@@ -296,7 +335,7 @@ def auto_sms_login_account(
provider=detect_provider(sms_url),
raw=f"{phone}----{sms_url}",
)
res = auto_register_huya_phone(item, wait_seconds=90, poll_interval=2)
res = register_huya_with_sms_line(item, wait_seconds=90, poll_interval=2)
if not res.success or not res.cookie:
account.status = "login_failed"
account.updated_at = datetime.now(UTC)
+21 -1
View File
@@ -2,7 +2,27 @@
from fastapi import APIRouter
from ._common import *
from ._common import (
Depends,
HTTPException,
HuyaAutoRegisterBatchOut,
HuyaAutoRegisterRequest,
HuyaAutoRegisterRetryRequest,
HuyaRegisterSuccessLogOut,
ProxyConfig,
Query,
Session,
StreamingResponse,
User,
_require_huya_perm,
export_success_logs_text,
get_current_user,
get_db,
huya_register_registry,
list_success_logs,
parse_sms_lines,
threading,
)
router = APIRouter()
+46 -1
View File
@@ -2,7 +2,52 @@
from fastapi import APIRouter
from ._common import *
from ._common import (
HUYA_CONFIG_FIELDS,
SUPPORTED_TASK_TYPES,
UTC,
Depends,
HTTPException,
HuyaBatchRunner,
HuyaConfigOut,
HuyaConfigUpdate,
HuyaGoodsOut,
HuyaGoodsSnapshot,
HuyaRechargeGoodsOut,
HuyaRechargeGoodsSnapshot,
HuyaTask,
HuyaTaskBatchRequest,
HuyaTaskOut,
HuyaWorkbench,
HuyaWorkbenchAccount,
HuyaWorkbenchAccountsUpdate,
Query,
Session,
SessionLocal,
User,
WebSocket,
WebSocketDisconnect,
_config_out,
_has_huya_perm,
_huya_task_summary,
_require_huya_batch_owner,
_require_huya_task_account_access,
_task_out,
_visible_huya_tasks_query,
_workbench_account_ids,
apply_huya_config_defaults,
asyncio,
authenticate_websocket,
cleanup_orphan_huya_tasks,
create_planned_tasks,
datetime,
ensure_huya_config,
get_db,
huya_batch_registry,
record_audit,
require_permission,
threading,
)
router = APIRouter()
+31 -31
View File
@@ -20,7 +20,7 @@ class YybTaskCreateRequest(BaseModel):
class YybLoginRequest(BaseModel):
provider: str = Field(..., pattern="^(qq|wechat)$")
timeout: int = Field(600, ge=60, le=1800)
timeout: int = Field(default=600, ge=60, le=1800)
class YybSelectionRequest(BaseModel):
@@ -28,7 +28,7 @@ class YybSelectionRequest(BaseModel):
points: int = Field(..., gt=0)
product_id: str = Field(..., min_length=1, max_length=128)
zone_id: str = Field(..., min_length=1, max_length=64)
zone_name: str = Field("", max_length=128)
zone_name: str = Field(default="", max_length=128)
role_id: str = Field(..., min_length=1, max_length=64)
role_name: str = Field(..., min_length=1, max_length=128)
@@ -91,7 +91,7 @@ class UserInfo(BaseModel):
class UserCreate(BaseModel):
username: str = Field(..., min_length=2, max_length=64)
password: str = Field(..., min_length=6, max_length=128)
role: str = Field("support", pattern="^(super_admin|operation|support)$")
role: str = Field(default="support", pattern="^(super_admin|operation|support)$")
remark: str = ""
@@ -153,7 +153,7 @@ class AccountBulkSelection(BaseModel):
class AccountExportRequest(AccountBulkSelection):
"""账号导出请求:支持纯账号名单(txt)或结构化明细(csv)。"""
format: str = Field("txt", pattern="^(txt|csv)$")
format: str = Field(default="txt", pattern="^(txt|csv)$")
class BatchAssign(AccountBulkSelection):
@@ -295,7 +295,7 @@ class LoginBatchRequest(BaseModel):
concurrency: int = 3 # 并发数,1-10
api_strategy: str = "wgapi" # 接口策略: wgapi(新版)或 iframe(旧版备选)
mode: str = Field(
"login", pattern="^(login|check)$"
default="login", pattern="^(login|check)$"
) # login=登录取CK,check=账号状态检测
@@ -352,9 +352,9 @@ class AccountCheckBatchRequest(BaseModel):
status: str = ""
ck_status: str = ""
bind_status: str = ""
concurrency: int = Field(3, ge=1, le=10)
max_login_retries: int = Field(0, ge=0, le=50)
max_total_time: float = Field(300, ge=0, le=3600)
concurrency: int = Field(default=3, ge=1, le=10)
max_login_retries: int = Field(default=0, ge=0, le=50)
max_total_time: float = Field(default=300, ge=0, le=3600)
use_proxy: bool = False
# 登录接口策略,与登录任务同一套取值:wgapi(默认,保持原行为)或 iframe。
# 有些人只有其中一套能收到邮箱验证码,所以要能选。
@@ -446,11 +446,11 @@ class HuyaAutoRegisterRequest(BaseModel):
text: str = Field(..., min_length=1)
tag: str = ""
concurrency: int = Field(1, ge=1, le=5)
wait_seconds: float = Field(180, ge=15, le=600)
poll_interval: float = Field(5, ge=1, le=30)
password_prefix: str = Field("hy", max_length=8)
fixed_password: str = Field("", max_length=64)
concurrency: int = Field(default=1, ge=1, le=5)
wait_seconds: float = Field(default=180, ge=15, le=600)
poll_interval: float = Field(default=5, ge=1, le=30)
password_prefix: str = Field(default="hy", max_length=8)
fixed_password: str = Field(default="", max_length=64)
use_proxy: bool = False
@@ -463,12 +463,12 @@ class HuyaAutoRegisterRetryRequest(BaseModel):
- all_unfinished: 失败+未完成都跑
"""
mode: str = Field("continue", max_length=32)
concurrency: int | None = Field(None, ge=1, le=5)
wait_seconds: float | None = Field(None, ge=15, le=600)
poll_interval: float | None = Field(None, ge=1, le=30)
password_prefix: str | None = Field(None, max_length=8)
fixed_password: str | None = Field(None, max_length=64)
mode: str = Field(default="continue", max_length=32)
concurrency: int | None = Field(default=None, ge=1, le=5)
wait_seconds: float | None = Field(default=None, ge=15, le=600)
poll_interval: float | None = Field(default=None, ge=1, le=30)
password_prefix: str | None = Field(default=None, max_length=8)
fixed_password: str | None = Field(default=None, max_length=64)
use_proxy: bool | None = None
@@ -550,9 +550,9 @@ class HuyaPasswordLoginSelectedRequest(BaseModel):
assigned_only: bool = False
has_cookie: bool = False
force_new_device: bool = False
max_attempts: int = Field(2, ge=1, le=3)
interval_seconds: float = Field(8, ge=0, le=60)
retry_delay_seconds: float = Field(3, ge=0, le=30)
max_attempts: int = Field(default=2, ge=1, le=3)
interval_seconds: float = Field(default=8, ge=0, le=60)
retry_delay_seconds: float = Field(default=3, ge=0, le=30)
class HuyaDeviceBindingsBatchDeleteRequest(BaseModel):
@@ -647,7 +647,7 @@ class HuyaConfigUpdate(BaseModel):
class HuyaTaskBatchRequest(BaseModel):
account_ids: list[int]
task_type: str
handbook_scope: str = Field("legacy", pattern="^(legacy|elite)$")
handbook_scope: str = Field(default="legacy", pattern="^(legacy|elite)$")
concurrency: int = 3
payload: dict[str, Any] = Field(default_factory=dict)
@@ -660,7 +660,7 @@ class HuyaWorkbenchAccountsUpdate(BaseModel):
class HuyaBrowserExchangeRequest(BaseModel):
"""浏览器兑换的目标页选择(常量见 services/huya_exchange_targets.py)。"""
target: str | None = Field(None, pattern="^(live_room|elite|esports)$")
target: str | None = Field(default=None, pattern="^(live_room|elite|esports)$")
class HuyaTaskOut(BaseModel):
@@ -833,10 +833,10 @@ class DouyuConfigUpdate(BaseModel):
confirm_act_alias: str | None = None
legacy_act_alias: str | None = None
room_id: str | None = None
elite_amount: int | None = Field(None, ge=1)
elite_amount: int | None = Field(default=None, ge=1)
esports_manual_id: str | None = None
esports_act_alias: str | None = None
esports_amount: int | None = Field(None, ge=1)
esports_amount: int | None = Field(default=None, ge=1)
esports_chicken_gift_id: str | None = None
esports_chicken_skin_id: str | None = None
esports_firework_gift_id: str | None = None
@@ -845,12 +845,12 @@ class DouyuConfigUpdate(BaseModel):
xpd_act_alias: str | None = None
xpd_act_id: str | None = None
xpd_rid: str | None = None
gold_pay_type: int | None = Field(None, ge=1, le=9)
gold_pay_type: int | None = Field(default=None, ge=1, le=9)
gold_recharge_channel: str | None = Field(
None, pattern="^(wechat_qr|supplier_api)$"
default=None, pattern="^(wechat_qr|supplier_api)$"
)
gold_api_product_id: str | None = Field(None, max_length=128)
gold_api_account_template_name: str | None = Field(None, max_length=64)
gold_api_product_id: str | None = Field(default=None, max_length=128)
gold_api_account_template_name: str | None = Field(default=None, max_length=64)
gift_id: str | None = None
skin_id: str | None = None
@@ -859,7 +859,7 @@ class DouyuTaskBatchRequest(BaseModel):
account_ids: list[int]
task_type: str
handbook_scope: str = Field(..., pattern="^(elite|esports|peace)$")
concurrency: int = Field(3, ge=1, le=10)
concurrency: int = Field(default=3, ge=1, le=10)
payload: dict[str, Any] = Field(default_factory=dict)
@@ -41,10 +41,10 @@ from __future__ import annotations
import json
from datetime import UTC, datetime, timedelta
from typing import Any
from typing import Any, cast
from loguru import logger
from sqlalchemy import text
from sqlalchemy import CursorResult, text
from sqlalchemy.orm import Session
from ....models import AutomationJob
@@ -133,7 +133,9 @@ def _delete_batch(
),
params,
)
return int(result.rowcount or 0)
# Session.execute 的静态返回类型是 Result[Any],但 DML 语句实际拿到的是
# CursorResult(rowcount 只定义在它上面)。运行期行为不变,只是把这一点写实。
return int(cast(CursorResult[Any], result).rowcount or 0)
def run_douyu_tasks_retention(db: Session, job: AutomationJob) -> dict[str, Any]:
+5 -3
View File
@@ -221,9 +221,11 @@ class ManualMixin:
account.esports_opened = manual_type is not None and manual_type >= 1
# 到期时间也落库 —— 否则界面只能显示"已开通",看不到什么时候到期
expire_raw = result.get("expire_time")
expire_int = (
int(expire_raw) if str(expire_raw or "").strip().isdigit() else None
)
# 先规整成字符串再判 isdigit:原来写 `int(expire_raw) if str(expire_raw
# or "").strip().isdigit()` —— 判的是 A、转的是 B,类型检查器收窄不了,
# 而且同一份值取了两遍。规整到一个局部变量后两个问题一起没了。
expire_text = str(expire_raw or "").strip()
expire_int = int(expire_text) if expire_text.isdigit() else None
# ⚠️ 斗鱼用 0 表示「没有到期时间」。直接存 0 的话界面会显示 1970/1/1
# (这个坑在积分到期上踩过一次,这里别再踩)。
account.esports_expire_time = (
@@ -67,9 +67,12 @@ def _match_suit_item(want: str, rows: list[dict[str, Any]]) -> dict[str, Any] |
# 优先找带「套装」前缀的
for it in rows:
cname = str(it.get("commodityName") or "").strip()
if core in cname and not any(k in cname for k in SUIT_EXCLUDE_KEYWORDS):
if "套装" in cname:
return it
if (
core in cname
and not any(k in cname for k in SUIT_EXCLUDE_KEYWORDS)
and "套装" in cname
):
return it
# 3. 兜底匹配不带排除词的项
for it in rows:
+4 -2
View File
@@ -7,7 +7,7 @@ import copy
import threading
import time
from datetime import UTC, datetime
from typing import Any
from typing import Any, Literal
from loguru import logger
from sqlalchemy.orm import Session
@@ -51,7 +51,9 @@ class HuyaBatchRunnerCore:
def stop(self):
self._stop.set()
def _open_wss_session(self, uid: int, cookie: str, kind: str):
def _open_wss_session(
self, uid: int, cookie: str, kind: Literal["activity", "shop"]
):
"""在当前任务线程创建并登记 WSS 会话,统一由 finally 回收。"""
from core.huya.elite_session import HuyaEliteWssSession
+4 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import time
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal
from sqlalchemy.orm import Session
@@ -40,7 +40,9 @@ class RechargeMixin:
def _push_log(self, level: str, message: str) -> None: ...
def _mark_task(self, *args: Any, **kwargs: Any) -> None: ...
def _update_task_progress(self, *args: Any, **kwargs: Any) -> None: ...
def _open_wss_session(self, uid: int, cookie: str, kind: str): ...
def _open_wss_session(
self, uid: int, cookie: str, kind: Literal["activity", "shop"]
): ...
def _shop_client(self, uid: int, cookie: str): ...
@staticmethod
@@ -71,9 +71,9 @@ full = gradient_rounded(S, RADIUS)
draw_gift(full)
base = "web/extension/huya-exchange-helper/"
for size in (128, 48):
full.resize((size, size), Image.LANCZOS).save(f"{base}icon{size}.png")
full.resize((size, size), Image.Resampling.LANCZOS).save(f"{base}icon{size}.png")
simple = gradient_rounded(512, 100)
draw_gift_simple(simple)
simple.resize((16, 16), Image.LANCZOS).save(f"{base}icon16.png")
simple.resize((16, 16), Image.Resampling.LANCZOS).save(f"{base}icon16.png")
print("icons regenerated")