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:
1 parent
800871ef6e
commit
dfc06bf4de
182 files changed
+26620
No files matched your search
@@ -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,
|
||||
)
|
||||
@@ -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 {}
|
||||
@@ -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"))}
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
Reference in new issue
Block a user