feat(python): 完成 Python 全量迁移——剩余平台、登录通道与公共层修复

- 新增 5sing/apple/xm/me/qt/lr 平台包与取流实现(含 xm-sign、MissEvan 签名、蜻蜓 HMAC、Apple developer token)
- 新增 wy/kg/tx/lr 登录:扫码、手机号、账密(kg 20028 风控指纹)、cookie 绑定、kg/tx 续期
- request 层:保留多值 Set-Cookie;JSON body 紧凑化(修复 kg 网关验签);直连不经系统代理(对齐 Node)
- 登录路由统一 Body(embed=True),修复扫码轮询 422;lifespan 接入登录续期调度
- ruff/format/pyright 全绿;14 平台真实联网与 HTTP 路由验证通过
This commit is contained in:
snowy-yang committed 2026-10-23 21:55:45 +08:00
1 parent 800871ef6e
commit dfc06bf4de
182 files changed
+26620

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
"""懒人听书(lrts.me)平台模块(对应 sdk/lr/index.ts)。
匿名可用面(2026-10-02 实测):搜索(App 通道游客态)/ 章节列表(web 免签)。
取流需登录(web getPlayPath,免费章节也要会话):POST /login/lr 账号密码登录。
章节 songmid 为复合 id ``<bookId>:<section>``;有声内容不参与聚合(noAggregate)。
"""
from __future__ import annotations
import math
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.lr import menu, music_search
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
from app.sdk.apis.lr import get_music_url
return await get_music_url(song_info, quality, retry_num)
async def get_pic(_song_info: dict[str, Any]) -> None:
"""书籍封面无独立免签接口;搜索结果的 img 字段已带封面(这里返回 None 由调用方回退)。"""
return None
async def get_album_detail(book_id: str, page: int = 1, limit: int = 30) -> dict[str, Any]:
result = await menu.get_book_menu(book_id, page, limit)
total = result["total"]
songs = []
for item in result["list"][:limit]:
if not isinstance(item, dict):
continue
section = item.get("section")
songs.append(
{
"songmid": f"{book_id}:{section}",
"name": item.get("name") or f"第{section}节",
"singer": "",
"albumId": str(book_id),
"albumName": "",
"interval": 0,
"payPlay": str(item.get("listenPrice")) != "0",
"img": None,
"lrc": "",
"lrcx": "",
"types": [],
"_types": {},
"disabled": False,
}
)
return {
"info": {"name": f"书籍 {book_id}", "total": total},
"list": songs,
"total": total,
"limit": limit,
"allPage": math.ceil(total / 30) or page,
"source": "lr",
}
class _LrAlbum:
get_album_detail = staticmethod(get_album_detail)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
book_id = str(song_info.get("songmid") or "").split(":")[0]
return f"https://m.lrts.me/book/{book_id}"
MODULE = Platform(
id="lr",
name="懒人听书",
music_search=music_search,
album=_LrAlbum,
get_music_url=_get_music_url,
get_pic=get_pic,
get_music_detail_page_url=get_music_detail_page_url,
)
+47
View File
@@ -0,0 +1,47 @@
"""懒人听书 App API(对应 sdk/lr/appApi.ts,dapis.mting.info)—— 搜索通道(游客 token 匿名可用)。
签名(移植 loyalflower/lrts_download download.go):
sc = md5(urlPath + "?" + <按 key 排序的 query(不含 sc) 拼接> + secret),secret = "iJ0DgxmdC83#I&j@iwg"
头:UA Android13/yyting/... / Referer yytingting.com / ClientVersion 6.3.4.0
端点:/yyting/search/searchBatch.action(q=2150)
注意:章节/取流 App 端点(q=1930/2224)游客 token 已失效(482 请登录),取流走 web 通道(见 apis/lr.py)。
"""
from __future__ import annotations
import hashlib
from typing import Any
from urllib.parse import quote
from app.sdk.common.request import http_fetch
DOMAIN = "http://dapis.mting.info"
SECRET = "iJ0DgxmdC83#I&j@iwg"
GUEST_TOKEN = "OqzlvCxt2i_P1SZKF6GjFg**_lK0uCQpm5tN-P6XdFZYawCDKSgeC4anU"
GUEST_IMEI = "MDI6MDA6MDA6MDA6MDA6DDA="
APP_HEADERS = {
"User-Agent": "Android13/yyting/vivo/V2133A/ch_uc_beta/167/Android",
"Referer": "yytingting.com",
"ClientVersion": "6.3.4.0",
}
def encode_uri_component(value: Any) -> str:
return quote(str(value), safe="!~*'()")
def signed(path: str, params: dict[str, str]) -> str:
query: dict[str, str] = {"token": GUEST_TOKEN, "imei": GUEST_IMEI, "nwt": "1", **params}
qs = "&".join(f"{key}={query[key]}" for key in sorted(query))
query["sc"] = hashlib.md5(f"{path}?{qs}{SECRET}".encode()).hexdigest()
return f"{DOMAIN}{path}?" + "&".join(f"{key}={encode_uri_component(value)}" for key, value in query.items())
async def app_api_get(path: str, params: dict[str, str]) -> dict[str, Any]:
"""App API GET(游客态,仅搜索可用;章节/取流会 482 需登录)。"""
resp = await http_fetch(signed(path, params), headers=APP_HEADERS, timeout=15_000)
if resp.status_code != 200:
raise RuntimeError(f"懒人 App API HTTP {resp.status_code}")
return resp.body if isinstance(resp.body, dict) else {}
+33
View File
@@ -0,0 +1,33 @@
"""懒人听书章节列表(对应 sdk/lr/menu.ts,web 通道,匿名免签)。
接口: https://m.lrts.me/ajax/getBookMenu?bookId=&pageNum=&pageSize=&sortType=0
实测(2026-10-02)匿名可用;章节条目 {section, name, listenPrice, state}
注意:专辑类内容用 getAlbumAudios(entityType=2),本实现仅收书(entityType=3)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict, as_list
UA_MI = (
"Mozilla/5.0 (Linux; U; Android 4.1.1; zh-cn; MI2 Build/JRO03L) AppleWebKit/534.30 "
"(KHTML, like Gecko) Version/4.0 Mobile Safari/534.30 XiaoMi/MiuiBrowser/1.0"
)
MENU_URL = "https://m.lrts.me/ajax/getBookMenu"
async def get_book_menu(book_id: str, page: int = 1, limit: int = 30) -> dict[str, Any]:
resp = await http_fetch(
f"{MENU_URL}?bookId={book_id}&pageNum={page}&pageSize={max(30, limit)}&sortType=0",
headers={"User-Agent": UA_MI, "Referer": "https://m.lrts.me/"},
timeout=15_000,
)
if resp.status_code != 200:
raise RuntimeError(f"获取章节失败(HTTP {resp.status_code})")
body = as_dict(resp.body)
if body.get("status") == 1 or not body.get("list"):
raise RuntimeError(str(body.get("msg") or "获取章节失败"))
return {"total": int(body.get("sections") or 0), "list": as_list(body.get("list"))}
+58
View File
@@ -0,0 +1,58 @@
"""懒人听书搜索(对应 sdk/lr/musicSearch.ts,App 通道,游客匿名)。
接口: /yyting/search/searchBatch.action?keyWord=&searchOption=1&pageSize=&type=0&q=2150
实测(2026-10-02)匿名可用;结果分 data.bookResult/albumResult/folderResult/...
书籍条目字段:id(baseEntityId 同值)/name/announcer/cover
"""
from __future__ import annotations
import math
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.lr.app_api import app_api_get
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,游客搜索无重试语义
body = await app_api_get(
"/yyting/search/searchBatch.action",
{
"keyWord": keyword,
"searchOption": "1",
"pageSize": str(limit),
"type": str(page - 1),
"q": "2150",
},
)
api_status = body.get("apiStatus")
if api_status is not None and api_status != 0:
raise RuntimeError(str(body.get("msg") or f"搜索失败(code {api_status})"))
book_result = as_dict(as_dict(body.get("data")).get("bookResult"))
books = [item for item in as_list(book_result.get("list")) if isinstance(item, dict)]
total = int(book_result.get("count") or len(books))
return {
"allPage": math.ceil(total / limit) or page,
"total": total,
"limit": limit,
"source": "lr",
"list": [
{
"songmid": str(item.get("id", item.get("baseEntityId"))),
"name": item.get("name") or "",
"singer": item.get("announcer") or item.get("author") or "",
"albumId": "",
"albumName": "",
"interval": 0,
"payPlay": False,
"img": item.get("cover") or None,
"lrc": "",
"lrcx": "",
"types": [],
"_types": {},
"disabled": False,
}
for item in books
],
}