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