feat(lr): 懒人听书平台接入——App 通道搜索、web 章节列表、账密登录取流
This commit is contained in:
1 parent
83d3eb3b49
commit
6fb866e343
6 files changed
+243
No files matched your search
@@ -0,0 +1,41 @@
|
||||
// 懒人听书 URL 获取(web 通道 getPlayPath,需登录)
|
||||
// 接口: https://m.lrts.me/ajax/getPlayPath?entityId=<bookId>&entityType=3&opType=1§ions=[<section>]&type=0
|
||||
// 实测(2026-10-02):免费章节匿名也返回 status=2 "收费章节未购买"——必须携带 /login/lr 登录会话;
|
||||
// 成功响应 list[0].path 为音频直链。
|
||||
// songmid 为复合 id "<bookId>:<section>"(/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 需为 <bookId>[:<section>](见 /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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
Reference in new issue
Block a user