From 6fb866e3431a6bf0edc9ea5c6b71e2a331fe8d0d Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:43:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(lr):=20=E6=87=92=E4=BA=BA=E5=90=AC?= =?UTF-8?q?=E4=B9=A6=E5=B9=B3=E5=8F=B0=E6=8E=A5=E5=85=A5=E2=80=94=E2=80=94?= =?UTF-8?q?App=20=E9=80=9A=E9=81=93=E6=90=9C=E7=B4=A2=E3=80=81web=20?= =?UTF-8?q?=E7=AB=A0=E8=8A=82=E5=88=97=E8=A1=A8=E3=80=81=E8=B4=A6=E5=AF=86?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=8F=96=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sdk/apis/lr.ts | 41 ++++++++++++++++++++++++++++++++ sdk/lr/appApi.ts | 35 ++++++++++++++++++++++++++++ sdk/lr/auth.ts | 51 ++++++++++++++++++++++++++++++++++++++++ sdk/lr/index.ts | 54 +++++++++++++++++++++++++++++++++++++++++++ sdk/lr/menu.ts | 21 +++++++++++++++++ sdk/lr/musicSearch.ts | 41 ++++++++++++++++++++++++++++++++ 6 files changed, 243 insertions(+) create mode 100644 sdk/apis/lr.ts create mode 100644 sdk/lr/appApi.ts create mode 100644 sdk/lr/auth.ts create mode 100644 sdk/lr/index.ts create mode 100644 sdk/lr/menu.ts create mode 100644 sdk/lr/musicSearch.ts diff --git a/sdk/apis/lr.ts b/sdk/apis/lr.ts new file mode 100644 index 0000000..d1836c4 --- /dev/null +++ b/sdk/apis/lr.ts @@ -0,0 +1,41 @@ +// 懒人听书 URL 获取(web 通道 getPlayPath,需登录) +// 接口: https://m.lrts.me/ajax/getPlayPath?entityId=&entityType=3&opType=1§ions=[
]&type=0 +// 实测(2026-10-02):免费章节匿名也返回 status=2 "收费章节未购买"——必须携带 /login/lr 登录会话; +// 成功响应 list[0].path 为音频直链。 +// songmid 为复合 id ":
"(/album/lr 章节列表产出);纯 bookId 视为第一章 +import { httpFetch } from '../request.ts' +import { getLrCookie } from '../lr/auth.ts' + +const 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' +const PLAY_URL = 'https://m.lrts.me/ajax/getPlayPath' + +export const supportQualitys = ['64k', '128k'] + +export const getMusicUrl = (songInfo, type) => { + const requestObj = { cancelHttp() {}, promise: null as any } + const songmid = String(songInfo.songmid ?? '') + const [bookId, section = '1'] = songmid.split(':') + + requestObj.promise = (async() => { + if (!bookId || !/^\d+$/.test(bookId)) throw new Error('懒人 id 需为 [:
](见 /album/lr 章节列表)') + const cookie = getLrCookie() + if (!cookie) throw new Error('懒人听书取流需要登录(POST /login/lr 绑定账号密码)') + + const { statusCode, body } = await httpFetch( + `${PLAY_URL}?entityId=${bookId}&entityType=3&opType=1§ions=[${section}]&type=0`, + { headers: { 'User-Agent': UA_MI, Referer: 'https://m.lrts.me/', Cookie: cookie }, timeout: 15000 }, + ).promise as any + const b = body ?? {} + if (statusCode != 200) throw new Error(`取流失败(HTTP ${statusCode})`) + const path = b?.list?.[0]?.path + if (!path) { + if (b.status === 2) throw new Error(b.msg || '收费章节未购买(需账号已购/会员)') + if (String(b.msg || '').includes('登录')) throw new Error('登录态已失效,请重新 POST /login/lr') + throw new Error(b.msg || '取流失败') + } + return { type: type === '128k' ? '128k' : '64k', url: String(path) } + })() + return requestObj +} + +export default { getMusicUrl } diff --git a/sdk/lr/appApi.ts b/sdk/lr/appApi.ts new file mode 100644 index 0000000..5d658a0 --- /dev/null +++ b/sdk/lr/appApi.ts @@ -0,0 +1,35 @@ +// 懒人听书 App API(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.ts) +import crypto from 'node:crypto' +import { httpFetch } from '../request.ts' + +const DOMAIN = 'http://dapis.mting.info' +const SECRET = 'iJ0DgxmdC83#I&j@iwg' +const GUEST_TOKEN = 'OqzlvCxt2i_P1SZKF6GjFg**_lK0uCQpm5tN-P6XdFZYawCDKSgeC4anU' +const GUEST_IMEI = 'MDI6MDA6MDA6MDA6MDA6DDA=' + +const APP_HEADERS = { + 'User-Agent': 'Android13/yyting/vivo/V2133A/ch_uc_beta/167/Android', + Referer: 'yytingting.com', + ClientVersion: '6.3.4.0', +} + +const signed = (path: string, params: Record) => { + const q: Record = { token: GUEST_TOKEN, imei: GUEST_IMEI, nwt: '1', ...params } + const qs = Object.keys(q).sort().map(k => `${k}=${q[k]}`).join('&') + q.sc = crypto.createHash('md5').update(path + '?' + qs + SECRET).digest('hex') + return `${DOMAIN}${path}?${Object.entries(q).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')}` +} + +/** App API GET(游客态,仅搜索可用;章节/取流会 482 需登录) */ +export const appApiGet = async(path: string, params: Record): Promise => { + const { statusCode, body } = await httpFetch(signed(path, params), { headers: APP_HEADERS, timeout: 15000 }).promise as any + if (statusCode != 200) throw new Error(`懒人 App API HTTP ${statusCode}`) + return body ?? {} +} + +export default { appApiGet } diff --git a/sdk/lr/auth.ts b/sdk/lr/auth.ts new file mode 100644 index 0000000..d8ae0ab --- /dev/null +++ b/sdk/lr/auth.ts @@ -0,0 +1,51 @@ +// 懒人听书登录(web 通道 m.lrts.me/ajax/logon) +// 参考社区下载器(yoval/lrts 等):POST form {account, pwd: base64(password)}, +// 成功响应 json.phone 非空,Set-Cookie 种下会话;取流接口必须携带该会话 +// (免费章节匿名也返回"收费章节未购买",2026-10-02 实测)。 +import { httpFetch } from '../request.ts' +import { getCredentials, setCredentials } from '../auth/store.ts' + +const 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' +const LOGON_URL = 'https://m.lrts.me/ajax/logon' + +/** 账号密码登录(成功保存会话 Cookie) */ +export const login = async({ account, password }: { account: string, password: string }) => { + const form = `account=${encodeURIComponent(account)}&pwd=${Buffer.from(String(password), 'utf8').toString('base64')}` + const { statusCode, body, headers } = await httpFetch(LOGON_URL, { + method: 'post', + headers: { + 'User-Agent': UA_MI, + Referer: 'https://m.lrts.me/', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: form, + timeout: 15000, + }).promise as any + const b = body ?? {} + if (statusCode != 200 || !b.phone) { + const err = new Error(b.msg || '登录失败(账号或密码错误)') + ;(err as any).status = 401 + throw err + } + const setCookies = (headers as any)?.['set-cookie'] ?? [] + const cookie = (Array.isArray(setCookies) ? setCookies : [setCookies]) + .map((c: string) => String(c).split(';')[0].trim()) + .filter(Boolean) + .join('; ') + if (!cookie) throw new Error('登录成功但未返回会话 Cookie') + setCredentials('lr', { cookie, phone: String(b.phone) }) + return { logged: true, phone: String(b.phone) } +} + +/** 登录会话 Cookie(未登录返回空串) */ +export const getLrCookie = (): string => (getCredentials('lr') as any)?.cookie || '' + +/** 登录状态 */ +export const status = () => { + const cred = getCredentials('lr') as { cookie?: string, phone?: string, updatedAt?: string } | null + return cred?.cookie + ? { logged: true, phone: cred.phone, updatedAt: cred.updatedAt } + : { logged: false } +} + +export default { login, getLrCookie, status } diff --git a/sdk/lr/index.ts b/sdk/lr/index.ts new file mode 100644 index 0000000..3ed9307 --- /dev/null +++ b/sdk/lr/index.ts @@ -0,0 +1,54 @@ +// 懒人听书(lrts.me)平台模块 +// 匿名可用面(2026-10-02 实测):搜索(App 通道游客态)/ 章节列表(web 免签)。 +// 取流需登录(web getPlayPath,免费章节也要会话):POST /login/lr 账号密码登录。 +// 章节 songmid 为复合 id ":
";有声内容不参与聚合(noAggregate)。 +import lrApi from '../apis/lr.ts' +import musicSearch from './musicSearch.ts' +import { getBookMenu } from './menu.ts' +import { getLrCookie } from './auth.ts' + +const lr = { + musicSearch, + getMusicUrl(songInfo, type) { + return lrApi.getMusicUrl(songInfo, type) + }, + getPic(songInfo) { + const bookId = String(songInfo.songmid ?? '').split(':')[0] + // 书籍封面无独立免签接口;搜索结果的 img 字段已带封面(这里返回空由调用方回退) + return Promise.resolve(null) + }, + album: { + async getAlbumDetail(bookId: string, page = 1, limit = 30) { + const { total, list } = await getBookMenu(bookId, page, limit) + return { + info: { name: `书籍 ${bookId}`, total }, + list: list.slice(0, limit).map((item: any) => ({ + songmid: `${bookId}:${item.section}`, + name: item.name || `第${item.section}节`, + singer: '', + albumId: String(bookId), + albumName: '', + interval: 0, + payPlay: String(item.listenPrice) !== '0', + img: null, + lrc: '', + lrcx: '', + types: [], + _types: {}, + disabled: false, + })), + total, + limit, + allPage: Math.ceil(total / 30) || page, + source: 'lr' as const, + } + }, + }, + getMusicDetailPageUrl(songInfo) { + const bookId = String(songInfo.songmid ?? '').split(':')[0] + return `https://m.lrts.me/book/${bookId}` + }, +} + +export { status } from './auth.ts' +export default lr diff --git a/sdk/lr/menu.ts b/sdk/lr/menu.ts new file mode 100644 index 0000000..b692d8c --- /dev/null +++ b/sdk/lr/menu.ts @@ -0,0 +1,21 @@ +// 懒人听书章节列表(web 通道,匿名免签) +// 接口: https://m.lrts.me/ajax/getBookMenu?bookId=&pageNum=&pageSize=&sortType=0 +// 实测(2026-10-02)匿名可用;章节条目 {section, name, listenPrice, state} +// 注意:专辑类内容用 getAlbumAudios(entityType=2),本实现仅收书(entityType=3) +import { httpFetch } from '../request.ts' + +const 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' +const MENU_URL = 'https://m.lrts.me/ajax/getBookMenu' + +export const getBookMenu = async(bookId: string, page = 1, limit = 30) => { + const { statusCode, body } = await httpFetch(`${MENU_URL}?bookId=${bookId}&pageNum=${page}&pageSize=${Math.max(30, limit)}&sortType=0`, { + headers: { 'User-Agent': UA_MI, Referer: 'https://m.lrts.me/' }, + timeout: 15000, + }).promise as any + if (statusCode != 200) throw new Error(`获取章节失败(HTTP ${statusCode})`) + const b = body ?? {} + if (b.status === 1 || !b.list) throw new Error(b.msg || '获取章节失败') + return { total: Number(b.sections ?? 0), list: b.list } +} + +export default { getBookMenu } diff --git a/sdk/lr/musicSearch.ts b/sdk/lr/musicSearch.ts new file mode 100644 index 0000000..da10ea5 --- /dev/null +++ b/sdk/lr/musicSearch.ts @@ -0,0 +1,41 @@ +// 懒人听书搜索(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 +import { appApiGet } from './appApi.ts' + +export const search = async(keyword: string, page = 1, limit = 20) => { + const body = await appApiGet('/yyting/search/searchBatch.action', { + keyWord: keyword, + searchOption: '1', + pageSize: String(limit), + type: String(page - 1), + q: '2150', + }) + if (body.apiStatus != null && body.apiStatus !== 0) throw new Error(body.msg || `搜索失败(code ${body.apiStatus})`) + const books = body?.data?.bookResult?.list ?? [] + const total = Number(body?.data?.bookResult?.count ?? books.length) + return { + allPage: Math.ceil(total / limit) || page, + total, + limit, + source: 'lr' as const, + list: books.map((item: any) => ({ + songmid: String(item.id ?? item.baseEntityId), + name: item.name || '', + singer: item.announcer || item.author || '', + albumId: '', + albumName: '', + interval: 0, + payPlay: false, + img: item.cover || null, + lrc: '', + lrcx: '', + types: [], + _types: {}, + disabled: false, + })), + } +} + +export default { search }