feat(lr): 懒人听书平台接入——App 通道搜索、web 章节列表、账密登录取流

This commit is contained in:
snowy-yang committed 2026-10-02 19:43:21 +08:00
1 parent 83d3eb3b49
commit 6fb866e343
6 files changed
+243

No files matched your search

+35
View File
@@ -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<string, string>) => {
const q: Record<string, string> = { 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<string, string>): Promise<any> => {
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 }
+51
View File
@@ -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 }
+54
View File
@@ -0,0 +1,54 @@
// 懒人听书(lrts.me)平台模块
// 匿名可用面(2026-10-02 实测):搜索(App 通道游客态)/ 章节列表(web 免签)。
// 取流需登录(web getPlayPath,免费章节也要会话):POST /login/lr 账号密码登录。
// 章节 songmid 为复合 id "<bookId>:<section>";有声内容不参与聚合(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
+21
View File
@@ -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 }
+41
View File
@@ -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 }