feat(huya): 支持四段式账号导入接码及旋转/文本点选多级验证码闭环过验

- 账号导入:支持'账号----密码----手机号----接码链接'四段式解析与持久化存储
- 旋转验证码 (rotate_auth / xz):逆向分析官方协议,基于圆周边界采样互相关算法秒级计算旋转还原角度
- 文本点选验证码 (dx_auth):内置成语与古诗名句语料,支持题干菜名提取与古诗/成语挖空正则求解及小图动态合成
- 短信二次验证 (sms_auth2):结合 sms_url 自动轮询接码链接并 Hex 编码提交官方过验
- 前端交互:增加接码标签、在线查询短信验证码、一键自动短信登录与测试接码链接功能
- 回归测试:新增旋转验证、短信接码流与文本点选测试用例(173 passed)
This commit is contained in:
yml2213
2026-09-06 13:56:44 +08:00
parent 4f9a6864eb
commit d3005478a9
15 changed files with 31411 additions and 24 deletions
+16 -2
View File
@@ -230,8 +230,9 @@ def solve_safe_auth(
max_retry: int = 3,
device_info: Mapping[str, object] | None = None,
account: str = "",
sms_url: str = "",
) -> dict:
"""解 safe_auth 风控滑块/点选。"""
"""解 safe_auth 风控滑块/点选/短信。"""
from .verification.solver import (
HuyaQrAuthRequiredError,
HuyaSmsAuthRequiredError,
@@ -254,6 +255,7 @@ def solve_safe_auth(
page_url=risk_url,
use_touch_events=True,
account=account,
sms_url=sms_url,
)
try:
result = solver.solve(risk_url)
@@ -298,6 +300,7 @@ def login_cred_with_flow(
device_info: dict | None = None,
proxies: dict | None = None,
include_device_token: bool = False,
sms_url: str = "",
) -> tuple[bytes, int] | tuple[bytes, int, str]:
"""新注册设备后登录,返回 ``(新鲜 cred, 真实 uid)``。
@@ -351,7 +354,13 @@ def login_cred_with_flow(
raise HuyaAppQrAuthRequiredError(
"该账号 App 渠道要求扫码验证(qr_auth),请先在手机虎牙 App 上正常登录一次建立设备信任。"
)
solve_safe_auth(risk_url, proxies=proxies, device_info=dev, account=account)
solve_safe_auth(
risk_url,
proxies=proxies,
device_info=dev,
account=account,
sms_url=sms_url,
)
logger.info(f"[huya-app] safe_auth ({kind}) 过验成功,重发 WUP 登录...")
continue
markers = _response_markers(resp)
@@ -464,12 +473,14 @@ class HuyaAppPasswordLogin:
timeout: tuple[float, float] | None = None,
force_new_device: bool = False,
device_info: dict | None = None,
sms_url: str = "",
):
self.username = username.strip()
self.password = password
self.proxies = dict(proxies) if proxies else None
self.timeout = timeout or (10.0, 25.0)
self.force_new_device = force_new_device
self.sms_url = sms_url.strip()
if force_new_device:
reset_account_state(self.username)
self.device_info = device_info or get_profile(
@@ -649,6 +660,7 @@ class HuyaAppPasswordLogin:
device_info=self.device_info,
proxies=self.proxies,
include_device_token=True,
sms_url=self.sms_url,
)
except HuyaAppQrAuthRequiredError as exc:
logger.warning(f"[huya-app] 账号 {acct} 登录受阻: {exc}")
@@ -936,6 +948,7 @@ def login_huya_app_password(
proxies: Mapping[str, str] | None = None,
timeout: tuple[float, float] | None = None,
force_new_device: bool = False,
sms_url: str = "",
) -> HuyaLoginResult:
"""函数式入口:使用 App 协议执行虎牙密码登录并获取全套 Cookie。"""
return HuyaAppPasswordLogin(
@@ -944,4 +957,5 @@ def login_huya_app_password(
proxies=proxies,
timeout=timeout,
force_new_device=force_new_device,
sms_url=sms_url,
).login()
+96 -5
View File
@@ -48,12 +48,16 @@ class CaptchaCaptureRecord:
"点选验证 (dx_auth)"
if "dx" in auth_type
else (
"短信二次验证 (sms_auth)"
if "sms" in auth_type
"旋转验证 (rotate_auth)"
if "rotate" in auth_type or "xz" in auth_type
else (
"扫码验证 (qr_auth)"
if "qr" in auth_type
else "滑块验证 (pt_auth)"
"短信二次验证 (sms_auth)"
if "sms" in auth_type
else (
"扫码验证 (qr_auth)"
if "qr" in auth_type
else "滑块验证 (pt_auth)"
)
)
)
),
@@ -161,6 +165,93 @@ class CaptchaCaptureRecord:
self._log_summary()
return folder
def capture_rotate_data(
self,
*,
code: str,
dtu_bytes: bytes,
puzzle_bytes: bytes,
restored_bg_bytes: bytes | None = None,
angle: int | float | None = None,
raw_data: dict[str, Any] | None = None,
) -> Path:
"""捕获旋转验证码的所有图片和元数据。"""
folder = self._ensure_target_dir(code)
raw = raw_data or {}
self.meta["code"] = code
self.meta["prompts"] = {
"title": "安全验证 (旋转验证 rotate_auth)",
"default_instruction": "请拖动滑块,将图片旋转至正确位置",
"description": raw.get("description") or raw.get("message") or "",
"tip": raw.get("tip") or "",
}
images_info = {}
if dtu_bytes:
(folder / "bg_scrambled.jpg").write_bytes(dtu_bytes)
images_info["bg_scrambled"] = "bg_scrambled.jpg"
if puzzle_bytes:
(folder / "puzzle.png").write_bytes(puzzle_bytes)
images_info["puzzle"] = "puzzle.png"
if restored_bg_bytes:
(folder / "bg_restored.jpg").write_bytes(restored_bg_bytes)
images_info["bg_restored"] = "bg_restored.jpg"
# 绘制旋转合成标注图
if restored_bg_bytes and puzzle_bytes and angle is not None:
try:
bg = cv2.imdecode(
np.frombuffer(restored_bg_bytes, np.uint8), cv2.IMREAD_COLOR
)
puzzle_raw = cv2.imdecode(
np.frombuffer(puzzle_bytes, np.uint8), cv2.IMREAD_UNCHANGED
)
if bg is not None and puzzle_raw is not None:
M = cv2.getRotationMatrix2D((90.0, 90.0), -float(angle), 1.0)
rotated = cv2.warpAffine(puzzle_raw, M, (180, 180))
alpha = rotated[:, :, 3] / 255.0
composite = bg.copy()
for c in range(3):
composite[0:180, 105:285, c] = (
rotated[:, :, c] * alpha
+ composite[0:180, 105:285, c] * (1 - alpha)
).astype(np.uint8)
cv2.putText(
composite,
f"Angle={int(angle)} deg",
(15, 30),
cv2.FONT_HERSHEY_SIMPLEX,
0.8,
(0, 255, 0),
2,
)
success, buffer = cv2.imencode(".jpg", composite)
if success:
(folder / "annotated.jpg").write_bytes(buffer.tobytes())
images_info["annotated"] = "annotated.jpg"
except Exception as exc: # noqa: BLE001
logger.debug(f"[captcha_capture] 标注旋转图异常: {exc}")
self.meta["images"] = images_info
if angle is not None:
self.meta["solution"] = {"type": "rotate", "angle": int(angle)}
if raw_data:
clean_raw = {
k: v for k, v in raw_data.items() if not str(v).startswith("data:image")
}
(folder / "raw_data.json").write_text(
json.dumps(clean_raw, ensure_ascii=False, indent=2),
encoding="utf-8",
)
self._flush_meta()
self._log_summary()
return folder
def capture_click_data(
self,
*,
File diff suppressed because it is too large Load Diff
+986
View File
@@ -0,0 +1,986 @@
一声何满子
一夜征人尽望乡
一宿行人自可愁
一岁一枯荣
一弦一柱思华年
一时回首月中看
一望关山泪满巾
一权寒梅白玉条
一树梨花落晚风
一树碧无情
一水中分白鹭洲
一片冰心在玉壶
一片降幡出石头
一行白鹭上青天
一览众山小
一路经行处
一道残阳铺水中
万径人踪灭
万户捣衣声
万方多难此登临
万条垂下绿丝绦
万里云罗一雁飞
万里悲秋常作客
万里长征人未还
万里黄河绕黑山
三山半落青天外
三峡星河影动摇
三日入厨下
三春白雪归青冢
上有黄鹂深树鸣
下拂波纹破
不及石尤风
不堪人事日萧条
不寝听金钥
不尽长江滚滚来
不得到辽西
不才明主弃
不把双眉斗画长
不教胡马度阴山
不敢过临洮
不敢问来人
不望入口复上身
不着心源傍古人
不知何处吹芦管
不知心恨谁
不知明镜里
不知细叶谁裁出
不知转入此中来
不知近水花先发
不见县门身即乐
不见沙场愁杀人
不解藏踪迹
不论平地与山尖
与汝救凶年
且免向城卖黄犊
世事波上舟
世事茫茫难自料
业复五铢钱
东边日出西边雨
东风不与周郎便
两三星火是瓜洲
两个黄鹂鸣翠柳
两岸猿声啼不住
两鬓苍苍十指黑
中军置酒饮归客
临行密密缝
为他人作嫁衣裳
为谁辛苦为谁甜
乌衣巷口夕阳斜
乍见翻疑梦
乐游原上望昭陵
九曲黄河万里沙
乡书不可寄
乡书何处达
乡泪客中尽
乱山残雪夜
乾坤日夜浮
二十一人同入蜀
二十四桥明月夜
二月春风似剪刀
云峰隔水深
云开远见洛阳城
云母屏风烛影深
五千貂锦丧胡尘
五日雪纷纷
五更疏欲断
五更鼓角声悲壮
五月虽热麦风清
亲朋无一字
人世几回伤往事
人事音书漫寂寥
人家不怨言语别
人归山郭暗
人归暮雪时
人闲桂花落
人间四月芳菲尽
人间能得几回闻
今上岳阳楼
今夜偏知春气暖
今夜未知何处宿
今年开州杀刺吏
今日水犹寒
今日花开又一年
今朝此为别
今来县宰加朱绂
从今四海为家日
从来幽并客
他乡复行役
他年锦里经祠庙
仙人垂两足
以我独沉久
伐薪烧炭南山中
会当凌绝顶
估客昼眠知浪静
但使龙城飞将在
但恐贼路多
但见泪痕湿
但见群鸥日日来
但闻人语响
低空有断云
何处得秋霜
何处还相遇
何年致此身
何当重相见
何当金络脑
何日平胡虏
何物如人意
余亦能高咏
依旧烟笼十里堤
便是生灵血染成
借问酒家何处有
偏在洛城东
停舫临孤驿
停车坐爱枫林晚
偷采白莲回
先遣小姑尝
八年十二月
八月萧关道
六朝如梦鸟空啼
共怜时世俭梳妆
关山万里远征人
关山征戍远
关张无命欲何如
关月冷相随
农夫犹饿死
农饥足旱田
冥冥日沉夕
冥冥花正开
冥冥鸟去迟
冰簟银床梦不成
决眦入归鸟
况彼无衣民
况是蔡家亲
凄凄去亲爱
凄凉宝剑篇
凄凉蜀故伎
凝情自悄然
几度隔山川
几行归塞尽
凤凰台上凤凰游
凤去台空江自流
凭君传语报平安
凭轩涕泗流
出塞复入塞
初日郢门山
前不见古人
前年戍月支
前年渝州杀刺史
劝君更尽一杯酒
功盖三分国
势分三足鼎
北斗七星高
北斗阑干南斗斜
北极朝廷终不改
北阙休上书
北风利如剑
北风卷地白草折
北风吹雁雪纷纷
北风江上寒
十二楼中月自明
十五即乘潮
十室八九贫
十年一觉扬中梦
千寻铁锁沉江底
千山鸟飞绝
千朵万朵压枝低
千树万树梨花开
千秋尚凛然
千里江陵一日还
千里莺啼绿映红
千里黄云白日曛
半入江风半入云
半匹红纱一丈绫
半卷红旗临易水
半江瑟瑟半江红
卖炭得钱何所营
南山归敝庐
南朝四百八十寺
南浦清江万里桥
卧龙跃马终黄土
危樯独夜舟
却喜晒谷天晴
去岁曾经此县城
去年花里逢君别
去时雪满天山路
县民无口不冤声
参差连曲陌
又疑瑶台镜
双泪落君前
双袖龙钟泪不干
受降城外月如霜
古台摇落后
古寺残灯石壁间
只在芦花浅水边
只是当时已惘然
只是近黄昏
可怜九月初三夜
可怜后主还祠庙
可怜无定河边骨
可怜身上衣正单
叶间扑扑秋蛾生
同到牵牛织女家
名岂文章著
名成八阵图
后不见来者
向晚意不适
君看似花处
含情欲说宫中事
听钟未眠客
吴宫花草埋幽径
吴楚东南坼
吹沙复喷石
呼作白玉盘
呼儿登山收橡实
咸言意气高
哀哀夜哭向秋云
哥舒夜带刀
唯烧蒿棘火
唯留一湖水
唯见林花落
商女不知亡国恨
啼时惊妾梦
啾啾栖鸟过
嗟君此别意何如
四海无闲田
回乐烽前沙似雪
回头却向秦云器
回看天际下中流
回观村闾间
回车叱牛牵向北
因风想玉珂
圣代即今多雨露
坐看牵牛织女星
垂死病中惊坐起
城下没全师
城中增暮寒
城里月明时
城阙夜千重
城高遮得贼
塞上燕脂凝夜紫
塞外征行无尽日
壮发上冲冠
壶浆满别筵
处处共芦草
复照青苔上
夕阳下通津
夕阳依旧垒
夕阳无限好
多少楼台烟雨中
多病故人疏
多难识君迟
夜半钟声到客船
夜来城外一尺雪
夜泊秦淮近酒家
夜深还过女墙来
夜长人自起
夜静春山空
大漠无兵阻
大漠沙如雪
天下谁人不识君
天地一沙鸥
天地英雄气
天寒白屋贫
天山雪后海风寒
天涯哭此时
天涯孤棹还
天涯涕泪一身遥
天涯霜雪霁寒宵
天秋月又满
天街小雨润如酥
天阶夜色凉如水
夷歌数处起渔樵
如今直上银河去
妇女多在官军中
始见沙上鸟
姑苏城外寒山寺
委曲结绳文
嫁与弄潮儿
嫁得瞿塘贾
嫦娥应悔偷灵药
孤壁野僧邻
孤帆天际看
孤帆远影碧空尽
孤灯寒照雨
孤灯闻楚角
孤独异乡人
孤舟蓑笠翁
孤飞自可疑
官应老病休
客从东方来
客舍青青柳色新
客路青山外
宫使驱将惜不得
宫花寂寞红
家书到隔年
寂寂花时闭院门
寄卧郊扉久
寒云夜夜自飞还
寒塘欲下迟
寒天古木悲
寒灯思旧事
寒灯独夜人
寒磬满空林
寒衣不要宽
寒雨连江夜入吴
寒食东风御柳斜
寥落古行宫
对棋陪谢傅
将军夜引弓
将军角弓不得控
将牛何处去
尊酒慰离颜
小园花乱飞
小娃撑小艇
小时不识月
少孤为客早
屈子怨何深
山中相送罢
山回路转不见君
山围故国周遭在
山寺桃花始盛开
山店门前一妇人
山形依旧枕寒流
山色有无中
山风吹空林
岁岁金河复玉关
岁暮锄犁傍空室
岁暮阴阳催短景
岐王宅里寻常见
岩上无心云相逐
岭外音书绝
岱宗夫如何
崔九堂前几度闻
川原杳何极
巫峡啼猿数行泪
市南门外泥中歇
布絮不蔽身
帝乡明日到
平明寻白羽
平明送客楚江孤
平沙万里绝人烟
平沙日未没
平海夕漫漫
平生自有分
年年移帐雪中天
幽人应未眠
庄生晓梦迷蝴蝶
应傍战场开
庭树不知人去尽
建业暮钟时
开轩面场圃
弹琴复长啸
强欲登高去
归时每日斜
归棹洛阳人
归雁洛阳边
归马识残旗
征人倚戍楼
待到重阳日
徒令上将挥神笔
徒劳恨费声
得相能开国
徘徊乱绕空
心忧炭贱愿天寒
心断新丰酒
快走踏清秋
念天地之悠悠
念尔独何之
忽如一夜春风来
怀君属秋夜
怅卧新春白袷衣
怜渠直道当时语
总为浮云能蔽日
总是玉关情
悠扬绿萝影
惆怅南朝事
惊秋风水凉
惊飞远映碧山去
惟将迟暮供多病
惟残一人出骆谷
惟见长江天际流
愁云惨淡万里凝
愁坐夜待晨
意恐迟迟归
愧君相见频
愿君多采撷
慈母手中线
戎马关山北
我亦举家清
我家襄水曲
所得是沾衣
手把文书口称敕
打起黄莺儿
扣门无犬吠
把剑觅徐君
把酒话桑麻
折戟沉沙铁未销
报君黄金台上意
报得三春晖
报道山中去
拟托良媒益自伤
掩泣空相向
提携玉龙为君死
故乡杳无际
故人具鸡黍
故人殊未来
故人江海别
故人西辞黄鹤楼
故关衰草遍
故园东望路漫漫
故园芜已平
故国三千里
故垒萧萧芦荻秋
敢将十指夸针巧
散入珠帘湿罗幕
散步咏凉天
数问夜如何
断雁警愁眠
斯人不可闻
新妆宜面下朱楼
新知遭薄俗
旅馆无良伴
无人收废帐
无人送酒来
无将故人酒
无情最是台城柳
无边落木萧萧下
无限风光尽被占
日暮东风怨啼鸟
日暮乡关何处是
日暮客愁新
日暮掩柴扉
日暮欲何之
日暮汉宫传蜡烛
日暮秋风起
日暮苍山远
日暮飞鸟还
旧好隔良缘
旧时王谢堂前燕
早知潮有信
早雁忽为双
早雁拂金河
时时误拂弦
时鸣春涧中
明日岁华新
明月来相照
明朝挂帆席
明朝有封事
昔人已乘黄鹤去
昔日经行人尽去
昔日长城战
昔时人已没
昔闻洞庭水
星临万户动
星垂平野阔
星月满空江
春城无处不飞花
春愁黯黯独成眠
春来发几枝
春来还发旧时花
春潮带雨晚来急
春种一粒粟
春草明年绿
春风吹又生
春风对青冢
昨别今已春
晋代衣冠成古丘
晓汲清湘燃楚竹
晓驾炭车辗冰辙
晚来天欲雪
晚见雁行频
晴川历历汉阳树
暂时分手莫踌躇
暗风吹雨入寒窗
暮雨相呼失
曲折仍圆旋
更有明朝恨
更深月色半人家
曾驱十万师
最是一年春好处
月傍九霄多
月出惊山鸟
月殿影开闻夜漏
月涌大江流
月落乌啼霜满天
有城遮不得
望帝春心托杜鹃
朝朝误妾期
朝朝马策与刀环
朝来逢着旧将军
朝辞白帝彩云间
木落雁南度
未必逢矰缴
未有涓埃答圣朝
未谙姑食性
本以高难饱
朱雀桥边野草花
杜甫天才颇绝伦
来舞魏宫前
杨柳青青江水平
杯粘落花片
杳杳种声晚
松月夜窗虚
板桥人渡泉声
极目箫条三两家
林暗草惊风
林表明霁色
枫叶落纷纷
柴门闻犬吠
树色随关迥
桂树何团团
梁园日暮乱飞鸦
梁父吟成恨有余
楚塞三湘接
楚江微雨里
楚腰纤细掌中轻
樽酒家贫只旧醅
檐头索索缲车鸣
欲去问西家
欲得周郎顾
欲把一麾江海去
欲祭疑君在
欸乃一声山水绿
正是江南好风景
此地别燕丹
此地空余黄鹤楼
此夕闻君谪九江
此情可待成追忆
此曲只应天上有
此物最相思
死生长别离
残云归太华
残宵犹得梦依稀
残月下章台
残灯无焰影幢幢
残萤西玉露
残钟广陵树
殿前兵马虽骁雄
每寻诗卷似情亲
每逢佳节倍思亲
水寒风似刀
水村山郭酒旗风
水精帘卷近秋河
永怀愁不寐
汗滴禾下土
江上几人在
江春入旧年
江村月落正堪眠
江枫渔火对愁眠
江流天地外
江流石不转
江清月近人
江皋闻曙钟
江雨霏霏江草齐
沅湘流不尽
没在石棱中
沧江好烟月
沧海月明珠有泪
河声入海遥
沾襟比散丝
沿洄安得住
泛泛入烟雾
波澜动远空
洗手作羹汤
洛阳亲友如相问
流水无情草自春
流落征南将
浦树远含滋
浩浩风起波
浩然离故关
浪淘风簸自天涯
浮萍一道开
海内风尘诸弟隔
海日生残夜
海门深不见
淮南一叶下
淮水东边旧时月
深坐颦蛾眉
深宫二十年
深林人不知
深竹暗浮烟
深锁春光一院愁
清明时节雨纷纷
清明有味是无能
清瑟怨遥夜
渐与骨肉远
渔翁夜傍西岩宿
渚云低暗度
渚清沙白鸟飞回
渭城朝雨浥轻尘
游子身上衣
湖光秋月两相和
湛湛碧涟漪
溪花与禅意
满面尘灰烟火色
漠漠帆来重
潦倒新停浊酒杯
潭面无风镜未磨
潮平两岸阔
潮打空城寂寞回
潮落夜江斜月里
瀚海阑干百丈冰
灞原风雨定
灯下白头人
烟波江上使人愁
烟笼寒水月笼沙
烟花三月下扬州
烟销日出不见人
烦君最相警
燕山月似钩
牛困人饥日已高
牛渚西江夜
牛马千群逐暖川
牧童遥指杏花村
犹似造书时
犹埋云外峰
犹是春闺梦里人
犹自梦渔樵
犹见孤帆一日程
狐裘不暖锦衾薄
独在异乡为异客
独坐幽篁里
独夜忆秦关
独怆然而涕下
独怜幽草涧边生
独立三边静
独钓寒江雪
猿鸟犹疑畏简书
玉人何处教吹箫
玉垒浮云变古今
玉楼天半起笙歌
玉铛缄札何由达
王孙归不归
王浚楼船下益州
珠箔飘灯独自归
甘棠一无树
生儿不象贤
田家衣食无厚薄
甲光向日金鳞开
男声欣欣女颜悦
留连戏蝶时时舞
留醉与山翁
疏雨过中条
疑是经冬雪未销
疲马卧长坂
登舟望秋月
白云依静渚
白云千载空悠悠
白云生处有人家
白兔捣药成
白发三千丈
白发催年老
白头宫女在
白帝城边古木疏
白日落梁州
白银盘里一青螺
白门寥落意多违
白骨乱蓬蒿
百年多病独登台
的知输得官家足
皆共尘沙老
盘飧市远无兼味
相伴有田父
相对亦忘言
相悲各问年
相欢惟牧童
相留畏晓钟
相送临高台
相送情无限
眼穿仍仍欲归
矜夸紫骝好
知有前期在
碛里征人三十万
碧天如水夜云轻
碧海青天夜夜心
碧玉妆成一树高
离别正堪悲
离披草书字
离杯惜共传
离离原上草
秋入望乡心
秋尽江南草木凋
秋收万颗子
秋来未著花
秋雁又南回
秋风吹不尽
秋风落叶满空山
秦时明月汉时关
积雪浮云端
移家虽带郭
移舟泊烟渚
税重多贫户
穷边有客游
空园白露滴
空山不见人
空山无鸟迹
空山松子落
空忆谢将军
空阶有鸟迹
窗含西岭千秋雪
竹柏皆冻死
筑城须努力
管乐有才真不忝
粒粒皆辛苦
精灵自来去
系向牛头充炭值
素手玉房前
繁华事散逐香尘
红叶晚萧萧
红楼隔雨相望冷
红泥小火炉
红豆生南国
纵暴略与羌浑同
纵然一夜风吹去
纷纷暮雪下辕门
细草微风岸
终南阴岭秀
终见降王走传车
经冬复立春
绕弦风雨哀
绝胜烟柳满皇都
绿树村边合
绿蚁新醅酒
缘愁似个长
罢归无旧业
羁危万里身
羁旅长堪醉
羁泊欲穷年
美人卷珠帘
美人相并立琼轩
群捕鱼儿溪影中
群盗相随剧虎狼
翩翩两骑来是谁
翻疑梦里逢
老农家贫在山住
老去恋明时
老松欹侧卧
老病有孤舟
耆老遮归路
耕彼故城东
耕种山田三四亩
肠断未忍扫
肯与邻翁相对饮
胡天八月即飞雪
胡琴琵琶与羌笛
胡风冻合鸊鹈泉
能饮一杯无
自在娇莺恰恰啼
自将磨洗认前朝
自觉洞庭波
自说二女啮臂时
自说夫因征战死
至今窥牧马
舍南舍北皆春水
舟人夜语觉潮生
船中养犬长食肉
良人罢远征
艰难苦恨繁霜鬓
花径不曾缘客扫
花近高楼伤客心
花隐掖垣暮
芳心向春尽
芳草已云暮
芳草萋萋鹦鹉洲
芳草闭闲门
苍苍竹林寺
苗疏税多不得食
苜蓿峰边逢立春
苦恨年年压金线
苦战应憔悴
茅檐日午鸡鸣
茫茫江汉上
荆歌艳楚腰
荆门九派通
草没城边路
草色遥看近却无
荒居旧业贫
荒戍落黄叶
荡胸生层云
荷笠带斜阳
莓苔见屐痕
莫嗔焙茶烟暗
莫学游侠儿
莫愁前路无知己
莫教枝上啼
莺啼送客闻
萧萧枫树林
落叶他乡树
落帆逗淮镇
落日清江里
落花时节又逢君
落花犹似坠楼人
落魄江湖载酒行
葫芦河上泪沾巾
蓝田日暖玉生烟
蓬门今始为君开
蓬门未识绮罗香
蕃情似此水
蕃汉断消息
薄宦梗犹泛
虫声新透绿窗纱
蜻蜓飞上玉搔头
蝉鸣空桑林
行人去不息
行到中庭数花朵
行舟绿水前
衡阳归雁几封书
衣上灞陵雨
襄阳好风日
西出阳关无故人
西山寇盗莫相侵
西山白雪三城戍
西楼望月几回圆
西江贾客珠百斛
西风生翠萝
角声满天秋色里
誓扫匈奴不顾身
谁爱风流高格调
谁知盘中餐
谁言寸草心
调角断清秋
赢得青楼薄幸名
走马西来欲到天
跨马出郊时极目
路上行人欲断魂
路出寒云外
身上衣裳口中食
身多疾病思田里
转于童仆亲
轮台东门送君去
轻烟散入五侯家
轻生一剑知
轻罗小扇扑流萤
轻舟已过万重山
输入官仓化为土
辞家见月两回圆
过雨看松色
近乡情更怯
近泪无干土
近种篱边菊
返影入深林
还作江南会
还来就菊花
远山晴更多
远梦归侵晓
远目寒山石径斜
远路应悲春晼晚
迢递三巴路
迢递送斜晖
迥临村路傍溪桥
迷津欲有问
造化钟神秀
遍插茱萸少一人
道是无晴还有晴
遗恨失吞吴
遥夜泛清瑟
遥怜故园菊
遥望洞庭山水翠
遥知兄弟登高处
遥隔楚云端
邀我至田家
邑有流亡愧俸钱
那堪正漂泊
那得泪潸然
郡邑浮前浦
都护铁衣冷难著
采山因买斧
采得百花成蜜后
采莲从小惯
野哭几家闻战伐
野客漱流时
野寺来人少
野寺荒台晚
野径入桑麻
野旷天低树
野渡无人舟自横
野火烧不尽
野蚕作茧人不取
金陵津渡小山楼
金陵王气黯然收
钓罢归来不系船
铜雀春深锁二乔
银烛秋光冷画屏
销愁又几千
锄禾日当午
锦城丝管日纷纷
锦江春色来天地
锦瑟无端五十弦
长亭酒一瓢
长安一片月
长安不见使人愁
长恨春归无觅处
长愿向南流
长江独至今
长河渐落晓星沉
门泊东吴万里船
门系钓鱼船
问客何为来
问言与谁餐
闲坐说玄宗
闲爱孤云静爱僧
闺中只是空相忆
闺阁别离难
闻道杀人汉水上
闻道欲来相问讯
闻郎江上唱歌声
阴阳割昏晓
随山到水源
隔江犹唱后庭花
隔篱呼取尽馀杯
难分此夜中
雁下芦洲白
雁声远过潇湘去
雨中黄叶树
雪上空留马行处
雪衣雪发青玉嘴
霜叶红于二月花
霜重鼓寒声不起
露似珍珠月似弓
露草泣寒虫
青天无片云
青山独归远
青山郭外斜
青山隐隐水迢迢
青枫江上秋帆远
青楼自管弦
青海戍头空有月
青阳逼岁除
静夜四无邻
风云长为护储胥
风吹城上树
风尘何所期
风急天高猿啸哀
风掣红旗冻不翻
风枝惊暗鹊
风正一帆悬
风送宫嫔笑语和
风雪夜归人
飏飏燕新乳
飒飒如有人
飘飘何所似
飞入寻常百姓家
飞在青云端
飞雪带春风
食人更肯留妻子
饮马渡秋水
马上相逢无纸笔
驱车登古原
驻马别孤坟
驻马衔杯问谪居
高树晓还密
高阁客竟去
高风汉阳渡
鬓丝生几缕
鸣筝金粟柱
鹦鹉前头不敢言
麦收上场绢在轴
黄叶仍风雨
黄四娘家花满蹊
黄尘足今古
黄沙碛里本无春
黄衣使者白衫儿
黄鹤一去不复返
黑云压城城欲催
黯黯见临洮
齐鲁青未了
+343 -10
View File
@@ -9,17 +9,19 @@ import random
import time
import uuid
from collections.abc import Mapping
from datetime import UTC, datetime
from http.cookies import SimpleCookie
from pathlib import Path
from typing import Any, cast
from urllib.parse import parse_qs, quote, urlparse
import re
import cv2
import execjs
import numpy as np
import requests
from loguru import logger
from PIL import Image
from PIL import Image, ImageDraw, ImageFont
from .capture import CaptchaCaptureRecord
from .ocr import HuyaCaptchaOcr, default_ocr
@@ -69,6 +71,8 @@ def _cookie_mapping(cookie: Mapping[str, str] | str | None) -> dict[str, str]:
class HuyaVerificationSolver:
"""处理虎牙 returnCode=10030/10039 后的滑块或点选验证。"""
_cloze_corpus: set[str] | None = None
def __init__(
self,
cookie: Mapping[str, str] | str | None = None,
@@ -84,6 +88,7 @@ class HuyaVerificationSolver:
account: str = "",
capture_dir: str | Path | None = None,
enable_capture: bool = True,
sms_url: str = "",
):
self.cookie = _cookie_mapping(cookie)
self.ua = ua or DEFAULT_UA
@@ -113,6 +118,7 @@ class HuyaVerificationSolver:
self.account = account
self.capture_dir = capture_dir
self.enable_capture = enable_capture
self.sms_url = sms_url
self.current_capture: CaptchaCaptureRecord | None = None
self.auth_type = ""
@@ -135,7 +141,7 @@ class HuyaVerificationSolver:
@staticmethod
def _decode_image(base64_string: str | None) -> bytes:
if not base64_string:
raise HuyaVerificationError("验证码图片为空")
return b""
if ";base64," in base64_string:
base64_string = base64_string.split(";base64,")[-1]
return base64.b64decode(base64_string)
@@ -210,6 +216,162 @@ class HuyaVerificationSolver:
_, _, _, max_loc = cv2.minMaxLoc(result)
return int(max_loc[0])
@staticmethod
def _opencv_rotate_angle(
background_bytes: bytes,
puzzle_bytes: bytes,
default_cx: float = 195.0,
default_cy: float = 90.0,
) -> int:
"""通过圆周边界颜色与边缘连续性计算旋转验证码的最佳旋转角度 (0~359度)。"""
bg = cv2.imdecode(np.frombuffer(background_bytes, np.uint8), cv2.IMREAD_COLOR)
puzzle_raw = cv2.imdecode(
np.frombuffer(puzzle_bytes, np.uint8), cv2.IMREAD_UNCHANGED
)
if bg is None or puzzle_raw is None:
raise HuyaVerificationError("旋转验证码图片解码失败")
bg_gray = cv2.cvtColor(bg, cv2.COLOR_BGR2GRAY).astype(np.float32)
p_gray = cv2.cvtColor(puzzle_raw[:, :, :3], cv2.COLOR_BGR2GRAY).astype(
np.float32
)
gray_u8 = cv2.cvtColor(bg, cv2.COLOR_BGR2GRAY)
black_mask = (gray_u8 < 25).astype(np.uint8) * 255
contours, _ = cv2.findContours(
black_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
cx, cy, r_hole = default_cx, default_cy, 58.0
for c in contours:
if cv2.contourArea(c) > 3000:
(tcx, tcy), tr = cv2.minEnclosingCircle(c)
if abs(tcx - default_cx) < 35 and abs(tcy - default_cy) < 25:
cx, cy, r_hole = tcx, tcy, tr
break
ph, pw = puzzle_raw.shape[:2]
pcx, pcy = pw / 2.0, ph / 2.0
angles = np.linspace(0, 2 * np.pi, 360, endpoint=False)
r_out_list = [r_hole + 2.0, r_hole + 4.0]
r_in_list = [r_hole - 5.0, r_hole - 7.0]
bg_outer_vals_list = []
for ro in r_out_list:
bx = cx + ro * np.cos(angles)
by = cy + ro * np.sin(angles)
vals = cv2.remap(
bg_gray,
bx.astype(np.float32),
by.astype(np.float32),
cv2.INTER_LINEAR,
).flatten()
bg_outer_vals_list.append(vals)
bg_outer_vals = np.mean(bg_outer_vals_list, axis=0)
best_deg = 0
best_corr = -1.0
for deg in range(360):
rad = np.radians(deg)
p_inner_vals_list = []
for ri in r_in_list:
px = pcx + ri * np.cos(angles - rad)
py = pcy + ri * np.sin(angles - rad)
pvals = cv2.remap(
p_gray,
px.astype(np.float32),
py.astype(np.float32),
cv2.INTER_LINEAR,
).flatten()
p_inner_vals_list.append(pvals)
p_inner_vals = np.mean(p_inner_vals_list, axis=0)
corr = np.corrcoef(bg_outer_vals, p_inner_vals)[0, 1]
if corr > best_corr:
best_corr = corr
best_deg = deg
return best_deg
@classmethod
def _get_cloze_corpus(cls) -> set[str]:
if cls._cloze_corpus is None:
corpus: set[str] = set()
base_dir = Path(__file__).resolve().parent
for filename in ("idioms.txt", "poems.txt"):
file_path = base_dir / filename
if file_path.is_file():
try:
corpus.update(
file_path.read_text(encoding="utf-8").splitlines()
)
except Exception as exc: # noqa: BLE001
logger.debug(f"[solver] 加载语料 {filename} 失败: {exc}")
cls._cloze_corpus = corpus
return cls._cloze_corpus
@classmethod
def _find_chinese_font(
cls, size: int = 30
) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
font_candidates = [
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/STHeiti Medium.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/Supplemental/Songti.ttc",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
"C:/Windows/Fonts/msyh.ttc",
"C:/Windows/Fonts/simhei.ttf",
]
for font_path in font_candidates:
if Path(font_path).is_file():
try:
return ImageFont.truetype(font_path, size)
except Exception:
continue
return ImageFont.load_default()
@classmethod
def _synthesize_text_tip(cls, chars: list[str]) -> bytes:
"""为没有提示小图的文本点选题目动态合成 44px 高度的提示图像。"""
if not chars:
return b""
font = cls._find_chinese_font(30)
char_box_size = 44
total_width = len(chars) * char_box_size
image = Image.new("RGB", (total_width, char_box_size), (255, 255, 255))
draw = ImageDraw.Draw(image)
for i, ch in enumerate(chars):
offset_x = i * char_box_size + 7
offset_y = 5
draw.text((offset_x, offset_y), ch, fill=(0, 0, 0), font=font)
buf = io.BytesIO()
image.save(buf, format="PNG")
return buf.getvalue()
@classmethod
def _extract_target_chars_from_question(cls, question: str) -> list[str]:
"""从点选题目中提取需要点击的目标汉字(支持完整成语/菜名及古诗/成语填空题)。"""
if not question:
return []
m = re.search(r'["“]([^"”]+)["”]', question)
if not m:
return []
text = m.group(1).strip()
if "_" in text:
regex = "^" + text.replace("_", "(.)") + "$"
compiled = re.compile(regex)
for phrase in cls._get_cloze_corpus():
matched = compiled.match(phrase)
if matched:
return list(matched.groups())
return []
return [ch for ch in text if "\u4e00" <= ch <= "\u9fa5"]
def _request_json(self, method: str, url: str, **kwargs) -> dict:
response = self.session.request(method, url, timeout=self.timeout, **kwargs)
response.raise_for_status()
@@ -367,6 +529,13 @@ class HuyaVerificationSolver:
step=step,
raw_params=self.url_param_map,
)
if self.sms_url:
logger.info(
"[solver] 虎牙风控在第 {} 步要求短信验证 (sms_auth),已配置接码链接,准备自动发码并接码过验",
step,
)
self.auth_type = "sms_auth"
return
logger.warning(
"[solver] 虎牙风控在第 {} 步要求短信验证 (sms_auth),手机号: {}",
step,
@@ -377,7 +546,12 @@ class HuyaVerificationSolver:
f"虎牙风控要求短信验证(sms_auth),手机号: {mobile}",
mobile=mobile,
)
self.auth_type = "dx_auth" if "dx_auth" in strategy_url_lower else "pt_auth"
if "rotate_auth" in strategy_url_lower or "/auth/xz/" in strategy_url_lower:
self.auth_type = "rotate_auth"
elif "dx_auth" in strategy_url_lower or "/auth/dx/" in strategy_url_lower:
self.auth_type = "dx_auth"
else:
self.auth_type = "pt_auth"
if self.enable_capture:
self.current_capture = CaptchaCaptureRecord(
auth_type=self.auth_type,
@@ -412,9 +586,39 @@ class HuyaVerificationSolver:
self.code = data["code"]
self.huyapk = data["huyapk"]
segment = data.get("segment")
is_rotate = (
self.auth_type == "rotate_auth"
or "/auth/xz/" in url
or "rotate" in self.strategy_url
)
if is_rotate:
logger.info("虎牙验证类型: 旋转验证码 (rotate_auth / xz)")
bg_bytes = self._decode_image(data.get("dtuStr") or data.get("dTuStr"))
tip_bytes = self._decode_image(data.get("puzzleStr"))
restored_bg = (
self._restore_slider_background(bg_bytes, segment, self.huyapk)
if segment
else bg_bytes
)
angle = self._opencv_rotate_angle(restored_bg, tip_bytes)
logger.info(f"虎牙旋转验证码识别角度: {angle}°")
if self.current_capture:
try:
self.current_capture.capture_rotate_data(
code=self.code,
dtu_bytes=bg_bytes,
puzzle_bytes=tip_bytes,
restored_bg_bytes=restored_bg,
angle=angle,
raw_data=data,
)
except Exception as exc: # noqa: BLE001
logger.debug(f"[solver] 记录旋转验证码捕获数据异常: {exc}")
return angle
if segment:
logger.info("虎牙验证类型: 滑块")
bg_bytes = self._decode_image(data.get("dtuStr"))
bg_bytes = self._decode_image(data.get("dtuStr") or data.get("dTuStr"))
tip_bytes = self._decode_image(data.get("puzzleStr"))
restored_bg = self._restore_slider_background(
bg_bytes, segment, self.huyapk
@@ -451,11 +655,31 @@ class HuyaVerificationSolver:
return final_x
logger.info("虎牙验证类型: 点选 (dx_auth)")
bg_bytes = self._decode_image(data.get("dTuStr"))
tip_bytes = self._decode_image(data.get("hZiStr"))
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]
bg_raw = data.get("dTuStr") or data.get("dtuStr") or data.get("bgStr")
bg_bytes = self._decode_image(bg_raw)
if not bg_bytes:
raise HuyaVerificationError(
f"点选验证码背景图为空: keys={list(data.keys())}"
)
tip_raw = data.get("hZiStr") or data.get("hziStr") or data.get("puzzleStr")
tip_bytes = self._decode_image(tip_raw) if tip_raw else b""
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]
else:
question = str(data.get("question") or "")
target_chars = self._extract_target_chars_from_question(question)
if target_chars:
logger.info(f"虎牙点选从题目解析出目标字: {target_chars}")
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]
tip_bytes = synth_tip
else:
logger.info(f"虎牙点选题目未能提取明确目标字: {question}")
final_points = []
if self.current_capture:
try:
@@ -471,6 +695,34 @@ class HuyaVerificationSolver:
return final_points
def _send_rotate_verify(self, url: str, angle: float) -> dict:
drag_distance = max(5, int((angle / 360.0) * 260))
track_points = generate_slide_track(drag_distance)
act_data = {
"point": int(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]),
}
act = self.js_ctx.call("get_act", act_data, self.huyapk)
self.behavior_events.append(
{
"action": self._pointer_action("mousedown"),
"id": "",
"txt": "",
"clientX": track_points[0][0],
"clientY": track_points[0][1],
"clientWidth": 350,
"clientHeight": 268,
"isTrusted": True,
"timeStamp": random.randint(1800, 5500),
"nowTime": int(time.time() * 1000),
}
)
payload = self._build_common_payload({"act": act, "ver": 5})
return self._submit_verify(url, payload)
def _send_slider_verify(self, url: str, point: int) -> dict:
track_points = generate_slide_track(point)
act_data = {
@@ -559,8 +811,81 @@ class HuyaVerificationSolver:
logger.debug(f"[solver] 记录提交结果异常: {exc}")
return data
def _solve_sms_strategy(self, wait_seconds: float = 90.0) -> dict:
"""自动通过 sms_url 接码并提交虎牙 sms_auth 验证。"""
from core.sms_provider import SmsLine, SmsProviderClient, detect_provider
mobile = self.url_param_map.get("mobile", "?")
logger.info(
f"[solver] 开始自动接码流程 (手机: {mobile}, 接码URL: {self.sms_url[:40]}...)"
)
# 1. 触发虎牙官方发送短信验证码
send_url = "https://udbrtt.huya.com/auth/sms/send-sms-code"
send_payload = {
"data": {
"urlParamMap": self.url_param_map,
"ver": 5,
}
}
sent_at = datetime.now(UTC)
try:
body = json.dumps(send_payload, separators=(",", ":"))
send_res = self._request_json("post", send_url, data=body)
logger.info(f"[solver] 虎牙短信验证码发送结果: {send_res}")
except Exception as exc:
logger.warning(f"[solver] 虎牙安全验证发送短信失败: {exc}")
raise HuyaVerificationError(f"发送虎牙短信验证码失败: {exc}") from exc
# 2. 轮询接码链接获取验证码
client = SmsProviderClient(timeout=(8, 15))
item = SmsLine(
phone=mobile,
url=self.sms_url,
provider=detect_provider(self.sms_url),
raw=f"{mobile}----{self.sms_url}",
)
deadline = time.monotonic() + wait_seconds
poll_interval = 2.5
sms_code = ""
while time.monotonic() < deadline:
poll_result = client.poll(item, after_time=sent_at)
if poll_result.has_code:
sms_code = poll_result.code
logger.info(f"[solver] 成功从接码链接收到验证码: {sms_code}")
break
time.sleep(poll_interval)
if not sms_code:
raise HuyaSmsAuthRequiredError(
self.strategy_url,
f"接码超时({int(wait_seconds)}秒内未从接码链接获取到新验证码)",
mobile=mobile,
)
# 3. 提交验证码给虎牙官方
verify_url = "https://udbrtt.huya.com/auth/sms/verify-sms-code"
sms_code_hex = sms_code.encode("utf-8").hex()
verify_payload = {
"encrypt": 1,
"data": {
"urlParamMap": self.url_param_map,
"behavior": self._encode_behavior(),
"info": self.sdid,
"page": self.page_url,
"smsCode": sms_code_hex,
"wupData": "",
"ver": 5,
},
}
logger.debug(f"[solver] 提交虎牙短信验证码: {sms_code} (hex={sms_code_hex})")
return self._submit_verify(verify_url, verify_payload)
def _solve_current_strategy(self) -> dict:
"""提交当前 strategy_url 对应的验证。"""
if self.auth_type == "sms_auth":
return self._solve_sms_strategy()
config_payload = self._build_common_payload({"cTuIndex": 0})
config = {}
gurl = ""
@@ -628,7 +953,13 @@ class HuyaVerificationSolver:
point = self._get_track_point(gurl, point_payload)
logger.debug(f"虎牙验证识别结果: {point}")
if isinstance(point, list):
if (
self.auth_type == "rotate_auth"
or "/auth/xz/" in vurl
or "rotate" in self.strategy_url
):
last_result = self._send_rotate_verify(vurl, float(point))
elif isinstance(point, list):
last_result = self._send_click_verify(vurl, point)
else:
last_result = self._send_slider_verify(vurl, int(point))
@@ -682,6 +1013,7 @@ def solve_huya_verification(
account: str = "",
capture_dir: str | Path | None = None,
enable_capture: bool = True,
sms_url: str = "",
) -> dict:
"""函数式入口,便于登录流程直接调用。"""
solver = HuyaVerificationSolver(
@@ -696,5 +1028,6 @@ def solve_huya_verification(
account=account,
capture_dir=capture_dir,
enable_capture=enable_capture,
sms_url=sms_url,
)
return solver.solve(challenge_payload)