From 3fe044bcb7268952fa487e4e4b2d40ca4fce640e Mon Sep 17 00:00:00 2001 From: huangxd- <649924757@qq.com> Date: Sat, 17 Jan 2026 00:03:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=B9=90=E8=A7=86=E6=BA=90?= =?UTF-8?q?=20(#155)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- config/.env.example | 10 +- danmu_api/apis/dandan-api.js | 17 +- danmu_api/configs/envs.js | 8 +- danmu_api/configs/globals.js | 2 +- danmu_api/sources/leshi.js | 765 +++++++++++++++++++++++++++++++++++ danmu_api/worker.test.js | 24 ++ forward/forward-widget.js | 6 +- 8 files changed, 822 insertions(+), 18 deletions(-) create mode 100644 danmu_api/sources/leshi.js diff --git a/README.md b/README.md index 41ab91b..aac14e8 100644 --- a/README.md +++ b/README.md @@ -370,9 +370,9 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | VOD_REQUEST_TIMEOUT | 【可选】VOD服务器单个请求超时时间(毫秒),防止慢速或失效的采集站阻塞搜索,默认为`10000`(10秒),建议值:`5000-15000`。由于`fastest`模式只返回最快响应的站点,可以设置较大的超时时间给慢速站点更多机会 | | BILIBILI_COOKIE | 【可选】b站cookie(填入后能抓取完整弹幕和启用港澳台App接口),如 `buvid3=E2BCA ... eao6; theme-avatar-tip-show=SHOWED`,请自行通过浏览器或抓包工具抓取,热心网友测试后,弹幕获取实际最少只需取 `SESSDATA=xxxx` 字段,但如果需要使用港澳台区域稳定的App搜索接口还需要`bili_jct=xxxx`或`access_key=xxxx` 字段,不知道怎么获取cookie的,可以从工具 [cookie-butler](https://cookie-butler.do-u.me) 获取 | | YOUKU_CONCURRENCY | 【可选】youku弹幕请求并发数,用于加快youku弹幕请求速度,不填默认为`8`,最高`16` | -| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`360,vod,renren,hanjutv`,表示360数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `360,vod,tmdb,douban,tencent,youku,iqiyi,imgo,bilibili,sohu,renren,hanjutv,bahamut,dandan,animeko,custom` | -| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, sohu, renren, hanjutv, bahamut, dandan, animeko, custom` | -| MERGE_SOURCE_PAIRS | 【可选】源合并配置,配置后将对应源合并同时一起获取弹幕返回,默认为空,格式是`源字段&源字段`,示例:`imgo&iqiyi,dandan&animeko,dandan&bahamut`, 允许多组、允许同时存在、一组配置一对,目前允许合并的源字段有`qiyi, bilibili1, imgo, youku, qq, sohu, renren, hanjutv, bahamut, dandan, animeko` | +| SOURCE_ORDER | 【可选】源排序,用于按源对返回资源的排序(注意:先后顺序会影响自动匹配最终的返回),默认是`360,vod,renren,hanjutv`,表示360数据排在最前,hanjutv数据排在最后,示例:`360,renren`:只返回360数据和renren数据,且360数据靠前;当前可选择的源字段有 `360,vod,tmdb,douban,tencent,youku,iqiyi,imgo,bilibili,sohu,leshi,renren,hanjutv,bahamut,dandan,animeko,custom` | +| PLATFORM_ORDER | 【可选】自动匹配优选平台,按顺序优先返回指定平台弹幕,默认为空,即返回第一个满足条件的平台,示例:`bilibili1,qq`,表示如果有b站的播放源,则优先返回b站的弹幕,否则就返回腾讯的弹幕,两者都没有,则返回第一个满足条件的平台;当前可选择的平台字段有 `qiyi, bilibili1, imgo, youku, qq, sohu, leshi, renren, hanjutv, bahamut, dandan, animeko, custom` | +| MERGE_SOURCE_PAIRS | 【可选】源合并配置,配置后将对应源合并同时一起获取弹幕返回,默认为空,格式是`源字段&源字段`,示例:`imgo&iqiyi,dandan&animeko,dandan&bahamut`, 允许多组、允许同时存在、一组配置一对,目前允许合并的源字段有`qiyi, bilibili1, imgo, youku, qq, sohu, leshi, renren, hanjutv, bahamut, dandan, animeko` | | EPISODE_TITLE_FILTER | 【可选】剧集标题正则过滤,按正则关键字对剧集或综艺的集标题进行过滤,适用于过滤一些预告或综艺非正式集,只支持match自动匹配,默认值如下 | | ENABLE_EPISODE_FILTER | 【可选】是否在手动选择接口中启用集标题过滤,默认为`false`(禁用),启用后 GET /api/v2/bangumi/{id} 和 GET /api/v2/search/anime 接口会过滤掉预告、花絮等特殊集,以及名称包含特殊关键词的动漫。 | | STRICT_TITLE_MATCH | 【可选】是否启用严格标题匹配模式,默认为`false`(宽松模糊匹配),启用后只匹配标题开头或完全匹配的结果。例如:搜索"遮天"时,`false`会匹配"古惑仔3之只手遮天",`true`只匹配"遮天"、"遮天 第一季"等。可选值:`true`、`false` | @@ -444,6 +444,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | imgo | imgo | | bilibili | bilibili1 | | sohu | sohu | +| leshi | leshi | | renren | renren | | hanjutv | hanjutv | | bahamut | bahamut | @@ -498,6 +499,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 │ │ ├── hanjutv.js # 韩剧TV源 │ │ ├── iqiyi.js # 爱奇艺源 │ │ ├── kan360.js # 360看源 +│ │ ├── leshi.js # 乐视视频源 │ │ ├── mango.js # 芒果TV源 │ │ ├── other.js # 第三方弹幕服务器 │ │ ├── renren.js # 人人视频源 diff --git a/config/.env.example b/config/.env.example index a6d5989..0ae10e0 100644 --- a/config/.env.example +++ b/config/.env.example @@ -68,9 +68,9 @@ YOUKU_CONCURRENCY=8 # ==================== 源排序配置 ==================== -# 数据源查询顺序(可选值:360, vod, tmdb, douban, tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut, dandan, sohu, animeko, custom) -# 默认值:360,vod,renren,hanjutv(不包含 tencent, youku, iqiyi, imgo, bilibili 和 sohu) -# 如需使用腾讯源、优酷源、爱奇艺源、芒果源、B站源或搜狐源,请手动添加:SOURCE_ORDER=360,vod,tencent,youku,iqiyi,imgo,bilibili,ren,hanjutv,sohu +# 数据源查询顺序(可选值:360, vod, tmdb, douban, tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut, dandan, sohu, leshi, animeko, custom) +# 默认值:360,vod,renren,hanjutv(不包含 tencent, youku, iqiyi, imgo, bilibili, sohu 和 leshi) +# 如需使用腾讯源、优酷源、爱奇艺源、芒果源、B站源、搜狐源或乐视源,请手动添加:SOURCE_ORDER=360,vod,tencent,youku,iqiyi,imgo,bilibili,ren,hanjutv,sohu,leshi # 如需使用巴哈姆特源,请手动添加:SOURCE_ORDER=360,vod,renren,hanjutv,bahamut # 如需使用弹弹play源,请手动添加:SOURCE_ORDER=360,vod,renren,hanjutv,dandan # 如需使用豆瓣源,请手动添加:SOURCE_ORDER=360,vod,douban,renren,hanjutv @@ -81,11 +81,11 @@ SOURCE_ORDER=360,vod,renren,hanjutv # 平台优先级排序(用于自动匹配) # 默认值:空(自动匹配) -# 可选值:qiyi, bilibili1, imgo, youku, qq, sohu, renren, hanjutv, bahamut, dandan, animeko, custom +# 可选值:qiyi, bilibili1, imgo, youku, qq, sohu, leshi, renren, hanjutv, bahamut, dandan, animeko, custom # PLATFORM_ORDER= # ==================== 源合并配置 ==================== -# 数据源合并配置(可选值:tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut, dandan, sohu, animeko) +# 数据源合并配置(可选值:tencent, youku, iqiyi, imgo, bilibili, renren, hanjutv, bahamut, dandan, sohu, leshi, animeko) # 默认值:空 # 说明:配置后将对应源合并同时一起获取弹幕返回,允许多组、允许一个源同时存在、一组配置一对 # 格式:源字段&源字段 diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js index 85f57dc..bfd6830 100644 --- a/danmu_api/apis/dandan-api.js +++ b/danmu_api/apis/dandan-api.js @@ -26,6 +26,7 @@ import MangoSource from "../sources/mango.js"; import BilibiliSource from "../sources/bilibili.js"; import YoukuSource from "../sources/youku.js"; import SohuSource from "../sources/sohu.js"; +import LeshiSource from "../sources/leshi.js"; import AnimekoSource from "../sources/animeko.js"; import OtherSource from "../sources/other.js"; import { Anime, AnimeMatch, Episodes, Bangumi } from "../models/dandan-model.js"; @@ -47,6 +48,7 @@ const iqiyiSource = new IqiyiSource(); const mangoSource = new MangoSource(); const bilibiliSource = new BilibiliSource(); const sohuSource = new SohuSource(); +const leshiSource = new LeshiSource(); const animekoSource = new AnimekoSource(); const otherSource = new OtherSource(); const doubanSource = new DoubanSource(tencentSource, iqiyiSource, youkuSource, bilibiliSource); @@ -201,6 +203,7 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null if (source === "imgo") return mangoSource.search(queryTitle); if (source === "bilibili") return bilibiliSource.search(queryTitle); if (source === "sohu") return sohuSource.search(queryTitle); + if (source === "leshi") return leshiSource.search(queryTitle); if (source === "animeko") return animekoSource.search(queryTitle); }); @@ -220,7 +223,7 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null vod: animesVodResults, 360: animes360, tmdb: animesTmdb, douban: animesDouban, renren: animesRenren, hanjutv: animesHanjutv, bahamut: animesBahamut, dandan: animesDandan, custom: animesCustom, tencent: animesTencent, youku: animesYouku, iqiyi: animesIqiyi, imgo: animesImgo, bilibili: animesBilibili, - sohu: animesSohu, animeko: animesAnimeko + sohu: animesSohu, leshi: animesLeshi, animeko: animesAnimeko } = resultData; // 按顺序处理每个来源的结果 @@ -276,6 +279,9 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null } else if (key === 'sohu') { // 等待处理Sohu来源 await sohuSource.handleAnimes(animesSohu, queryTitle, curAnimes); + } else if (key === 'leshi') { + // 等待处理Leshi来源 + await leshiSource.handleAnimes(animesLeshi, queryTitle, curAnimes); } else if (key === 'animeko') { // 等待处理Animeko来源 await animekoSource.handleAnimes(animesAnimeko, queryTitle, curAnimes); @@ -989,6 +995,7 @@ async function fetchMergedComments(url) { else if (sourceName === 'imgo') sourceInstance = mangoSource; else if (sourceName === 'bilibili') sourceInstance = bilibiliSource; else if (sourceName === 'sohu') sourceInstance = sohuSource; + else if (sourceName === 'leshi') sourceInstance = leshiSource; else if (sourceName === 'animeko') sourceInstance = animekoSource; // 如有新增允许的源合并,在此处添加 @@ -1079,6 +1086,8 @@ export async function getComment(path, queryFormat, segmentFlag) { danmus = await youkuSource.getComments(url, plat, segmentFlag); } else if (url.includes('.sohu.com')) { danmus = await sohuSource.getComments(url, plat, segmentFlag); + } else if (url.includes('.le.com')) { + danmus = await leshiSource.getComments(url, plat, segmentFlag); } // 请求其他平台弹幕 @@ -1188,6 +1197,8 @@ export async function getCommentByUrl(videoUrl, queryFormat, segmentFlag) { danmus = await youkuSource.getComments(url, "youku", segmentFlag); } else if (url.includes('.sohu.com')) { danmus = await sohuSource.getComments(url, "sohu", segmentFlag); + } else if (url.includes('.le.com')) { + danmus = await leshiSource.getComments(url, "leshi", segmentFlag); } else { // 如果不是已知平台,尝试第三方弹幕服务器 const urlPattern = /^(https?:\/\/)?([\w.-]+)\.([a-z]{2,})(\/.*)?$/i; @@ -1269,6 +1280,8 @@ export async function getSegmentComment(segment, queryFormat) { danmus = await youkuSource.getSegmentComments(segment); } else if (platform === "sohu") { danmus = await sohuSource.getSegmentComments(segment); + } else if (platform === "leshi") { + danmus = await leshiSource.getSegmentComments(segment); } else if (platform === "hanjutv") { danmus = await hanjutvSource.getSegmentComments(segment); } else if (platform === "bahamut") { @@ -1277,7 +1290,7 @@ export async function getSegmentComment(segment, queryFormat) { danmus = await renrenSource.getSegmentComments(segment); } else if (platform === "dandan") { danmus = await dandanSource.getSegmentComments(segment); - } else if (platform === "animeko") { + } else if (platform === "animeko") { danmus = await animekoSource.getSegmentComments(segment); } else if (platform === "custom") { danmus = await customSource.getSegmentComments(segment); diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js index f39c73f..abae485 100644 --- a/danmu_api/configs/envs.js +++ b/danmu_api/configs/envs.js @@ -9,10 +9,10 @@ export class Envs { static originalEnvVars = new Map(); static accessedEnvVars = new Map(); - static VOD_ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'sohu']; // vod允许的播放平台 - static ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'animeko', 'custom']; // 全部源允许的播放平台 - static ALLOWED_SOURCES = ['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'animeko', 'custom']; // 允许的源 - static MERGE_ALLOWED_SOURCES = ['tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'animeko']; // 允许的源合并 + static VOD_ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'sohu', 'leshi']; // vod允许的播放平台 + static ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'animeko', 'custom']; // 全部源允许的播放平台 + static ALLOWED_SOURCES = ['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'animeko', 'custom']; // 允许的源 + static MERGE_ALLOWED_SOURCES = ['tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'renren', 'hanjutv', 'bahamut', 'dandan', 'sohu', 'leshi', 'animeko']; // 允许的源合并 /** * 获取环境变量 diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index a22d014..1eddb2b 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -13,7 +13,7 @@ export const Globals = { accessedEnvVars: {}, // 静态常量 - VERSION: '1.12.0', + VERSION: '1.12.1', MAX_LOGS: 500, // 日志存储,最多保存 500 行 MAX_ANIMES: 100, diff --git a/danmu_api/sources/leshi.js b/danmu_api/sources/leshi.js new file mode 100644 index 0000000..5731b55 --- /dev/null +++ b/danmu_api/sources/leshi.js @@ -0,0 +1,765 @@ +import BaseSource from './base.js'; +import { globals } from '../configs/globals.js'; +import { log } from "../utils/log-util.js"; +import { httpGet, buildQueryString } from "../utils/http-util.js"; +import { convertToAsciiSum } from "../utils/codec-util.js"; +import { generateValidStartDate } from "../utils/time-util.js"; +import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; +import { printFirst200Chars, titleMatches } from "../utils/common-util.js"; +import { SegmentListResponse } from '../models/dandan-model.js'; + +// 媒体类型映射 +const typeMap = { + 'tv': '电视剧', + 'movie': '电影', + 'cartoon': '动漫', + 'comic': '动漫' +}; + +// ===================== +// 获取乐视网弹幕 +// ===================== +export default class LeshiSource extends BaseSource { + constructor() { + super(); + + // 弹幕位置映射: + this.positionMap = { + 4: 1, // 滚动弹幕 + 3: 4, // 底部弹幕 + 1: 5, // 顶部弹幕 + 2: 1, // 其他 -> 滚动 + }; + } + + /** + * 过滤乐视网搜索项 + * @param {Object} item - 搜索项 + * @param {string} keyword - 搜索关键词 + * @returns {Object|null} 过滤后的结果 + */ + filterLeshiSearchItem(item, keyword) { + if (!item.pid || !item.title) { + return null; + } + + // 清理标题中的高亮标记 + let title = item.title; + + // 映射类型 + const resultType = typeMap[item.type] || '电视剧'; + + return { + mediaId: String(item.pid), + title: title, + type: resultType, + year: item.year || null, + imageUrl: item.imageUrl || null, + episodeCount: item.episodeCount || 0 + }; + } + + async search(keyword) { + try { + log("info", `[Leshi] 开始搜索: ${keyword}`); + + // 构造搜索URL + const params = { + 'wd': keyword, + 'from': 'pc', + 'ref': 'click', + 'click_area': 'search_button', + 'query': keyword, + 'is_default_query': '0', + 'module': 'search_rst_page' + }; + + // 设置请求头,模拟浏览器 + const headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'Accept-Encoding': 'gzip, deflate, br', + 'Referer': 'https://so.le.com/', + 'Connection': 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'same-origin' + }; + + const searchUrl = `https://so.le.com/s?${buildQueryString(params)}`; + + const response = await httpGet(searchUrl, { headers, timeout: 15000 }); + + if (!response || !response.data) { + log("info", "[Leshi] 搜索响应为空"); + return []; + } + + const htmlContent = response.data; + + log("debug", `[Leshi] 搜索请求成功,响应长度: ${htmlContent.length} 字符`); + + // 解析HTML,提取data-info属性 + const results = []; + + // 使用正则表达式提取所有 data-info 属性 + // 格式:data-info="{pid:'10026580',type:'tv',...}" + const pattern = /
]*data-info="({.*?})"[^>]*>/g; + let match; + const matches = []; + + while ((match = pattern.exec(htmlContent)) !== null) { + matches.push(match); + } + + log("debug", `[Leshi] 从HTML中找到 ${matches.length} 个 data-info 块`); + + for (const match of matches) { + try { + let dataInfoStr = match[1]; + + log("debug", `[Leshi] 提取到 data-info 原始字符串: ${dataInfoStr.substring(0, 200)}...`); + + // 解析JavaScript对象字面量为JSON + // 1. 先将所有单引号替换为双引号 + dataInfoStr = dataInfoStr.replace(/'/g, '"'); + // 2. 然后为没有引号的键添加引号 + dataInfoStr = dataInfoStr.replace(/([{,])(\w+):/g, '$1"$2":'); + + const dataInfo = JSON.parse(dataInfoStr); + + log("debug", `[Leshi] 成功解析 data-info,pid=${dataInfo.pid}, type=${dataInfo.type}`); + + // 提取基本信息 + let pid = dataInfo.pid || ''; + const mediaTypeStr = dataInfo.type || ''; + const total = dataInfo.total || '0'; + + if (!pid) { + continue; + } + + // 从HTML中提取标题和其他信息 + const start_pos = match.index; + // 查找结束标签,尝试多种可能的结束模式 + const endPatterns = ['
\n\t', '\n', '']; + let end_pos = -1; + for (const endPattern of endPatterns) { + const pos = htmlContent.indexOf(endPattern, start_pos); + if (pos !== -1) { + end_pos = pos; + break; + } + } + + if (end_pos === -1) { + // 如果找不到结束标签,尝试查找下一个 So-detail + const nextMatch = htmlContent.indexOf('
]*>/.exec(htmlBlock); + if (h1TitleMatch && h1TitleMatch[1]) { + title = h1TitleMatch[1].trim(); + } + + // 方法2: 如果 h1 中没有找到,则尝试从 data-info 的 keyWord 字段提取 + if (!title && dataInfo.keyWord) { + // 从关键词中提取主要标题部分(去掉年份等信息) + const keywordMatch = /(.*?)(?:\d{4})?(?:电影|电视剧|综艺)?$/.exec(dataInfo.keyWord); + if (keywordMatch && keywordMatch[1]) { + title = keywordMatch[1].replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, '').trim(); // 移除特殊字符 + } + } + + // 方法3: 尝试从链接的 title 属性中提取 + if (!title) { + const linkTitleMatch = /]*title="([^"]*?)[^0-9\u4e00-\u9fa5][^"]*"/.exec(htmlBlock); + if (linkTitleMatch && linkTitleMatch[1]) { + title = linkTitleMatch[1].trim(); + } + } + + if (!title) { + log("info", `[Leshi] 未找到标题,尝试从其他来源获取`); + } + + // 提取海报 + const imgMatch = /]*(?:src|data-src|alt)="([^"]+)"/.exec(htmlBlock); + let imageUrl = imgMatch ? imgMatch[1] : ''; + + // 提取年份 - 支持多种格式 + let year = null; + // 方法1: 从年份标签中提取 年份:2016 + let yearMatch = /年份:<\/b>.*?>(\d{4})<\/a>/.exec(htmlBlock); + if (!yearMatch) { + // 方法2: 从上映时间标签中提取 + yearMatch = /上映时间:<\/b>.*?>(\d{4})<\/a>/.exec(htmlBlock); + } + if (!yearMatch) { + // 方法3: 从年份链接的href中提取 (y2016) + yearMatch = /_y(\d{4})_/.exec(htmlBlock); + } + if (!yearMatch) { + // 方法4: 从 data-info 的 keyWord 中提取 + yearMatch = /(\d{4})/.exec(dataInfo.keyWord || ''); + } + + if (yearMatch) { + year = parseInt(yearMatch[1]); + } + + // 映射媒体类型 + const resultType = typeMap[mediaTypeStr] || '电视剧'; + + // 解析集数 + const episodeCount = total && /^\d+$/.test(total) ? parseInt(total) : 0; + + // 创建搜索结果 + const result = { + mediaId: pid, + title: title, + type: resultType, + year: year, + imageUrl: imageUrl && imageUrl.startsWith('http') ? imageUrl : imageUrl ? `https:${imageUrl}` : null, + episodeCount: episodeCount + }; + + results.push(result); + log("debug", `[Leshi] 解析成功 - ${title} (pid=${pid}, type=${resultType}, episodes=${episodeCount})`); + + } catch (e) { + log("warning", `[Leshi] 解析搜索结果项失败: ${e}`); + continue; + } + } + + if (results.length > 0) { + log("info", `[Leshi] 网络搜索 '${keyword}' 完成,找到 ${results.length} 个有效结果。`); + log("info", `[Leshi] 搜索结果列表:`); + for (const r of results) { + log("info", ` - ${r.title} (ID: ${r.mediaId}, 类型: ${r.type}, 年份: ${r.year})`); + } + } else { + log("info", `[Leshi] 网络搜索 '${keyword}' 完成,找到 0 个结果。`); + } + + return results; + + } catch (error) { + log("error", "[Leshi] 搜索出错:", error.message); + return []; + } + } + + async getEpisodes(id) { + try { + log("info", `[Leshi] 获取分集列表: media_id=${id}`); + + // 构造作品页面URL(需要根据类型判断) + const urlsToTry = [ + `https://www.le.com/tv/${id}.html`, + `https://www.le.com/comic/${id}.html`, + `https://www.le.com/playlet/${id}.html`, + `https://www.le.com/movie/${id}.html` + ]; + + let htmlContent = null; + for (const url of urlsToTry) { + try { + const response = await httpGet(url, { timeout: 10000 }); + if (response && response.data && response.status === 200) { + htmlContent = response.data; + log("debug", `成功获取页面: ${url}`); + break; + } + } catch (e) { + log("debug", `尝试URL失败 ${url}: ${e}`); + continue; + } + } + + if (!htmlContent) { + log("error", `无法获取作品页面: media_id=${id}`); + return []; + } + + // 尝试新的解析方式:直接从HTML中解析剧集列表 + // 查找剧集列表容器,根据提供的HTML示例 + + // 首先尝试匹配图文选集和数字选集两种模式 + const twxjContainerMatch = /
[\s\S]*?<\/div>\s*<\/div>\s*<\/div>/.exec(htmlContent); + + if (twxjContainerMatch) { + log("debug", `找到图文选集容器`); + return this.parseEpisodesFromHtml(twxjContainerMatch[0], id); + } + + // 如果没有找到图文选集,尝试数字选集 + const sjxjContainerMatch = /
[\s\S]*?<\/div>\s*<\/div>\s*<\/div>/.exec(htmlContent); + + if (sjxjContainerMatch) { + log("debug", `找到数字选集容器`); + return this.parseEpisodesFromHtml(sjxjContainerMatch[0], id); + } + + // 如果以上都没找到,尝试查找整个剧集区域 + log("debug", `未找到特定选集容器,尝试查找第一集视频列表...`); + + const firstVideoListMatch = /
0) { + log("info", `[Leshi] 从HTML内容中匹配到 ${episodes.length} 个剧集链接`); + return episodes; + } + + return []; + + } catch (error) { + log("error", "[Leshi] 获取分集出错:", error.message); + return []; + } + } + + parseEpisodesFromHtml(htmlContent, mediaId) { + try { + // 根据提供的HTML结构,首先查找所有包含剧集的div.col_4元素 + // 每个div.col_4包含一个dl.dl_temp剧集元素 + const episodeContainerRegex = /
]*>[\s\S]*?<\/div>/g; + const containerMatches = htmlContent.match(episodeContainerRegex); + + if (!containerMatches || containerMatches.length === 0) { + log("debug", `在HTML中未找到剧集容器div.col_4,尝试查找dl.dl_temp元素`); + // 如果没找到div.col_4,直接查找dl.dl_temp + const episodeRegex = /
[\s\S]*?<\/dl>/g; + const matches = htmlContent.match(episodeRegex); + + if (!matches || matches.length === 0) { + log("debug", `在HTML中未找到剧集元素,尝试更广泛的选择器`); + // 尝试更广泛的匹配 + const broaderRegex = /]*class="[^"]*dl_temp[^"]*"[^>]*>[\s\S]*?<\/dl>/g; + const broaderMatches = htmlContent.match(broaderRegex); + + if (!broaderMatches || broaderMatches.length === 0) { + log("error", `无法从HTML中解析到任何剧集: media_id=${mediaId}`); + return []; + } + + return this.extractEpisodes(broaderMatches, mediaId); + } + + return this.extractEpisodes(matches, mediaId); + } + + // 从每个div.col_4中提取内部的dl.dl_temp元素 + const dlElements = []; + for (const container of containerMatches) { + const dlMatch = /
[\s\S]*?<\/dl>/.exec(container); + if (dlMatch) { + dlElements.push(dlMatch[0]); + } + } + + if (dlElements.length === 0) { + log("error", `从容器中未能提取到任何dl.dl_temp元素: media_id=${mediaId}`); + return []; + } + + return this.extractEpisodes(dlElements, mediaId); + } catch (error) { + log("error", `[Leshi] 解析剧集HTML失败: ${error.message}`); + return []; + } + } + + extractEpisodes(episodeElements, mediaId) { + const episodes = []; + + for (const element of episodeElements) { + try { + // 提取链接 - 优先匹配 vplay 链接 + const linkMatch = /]+href="(\/\/www\.le\.com\/ptv\/vplay\/(\d+)\.html)"[^>]*>/.exec(element); + if (!linkMatch) { + log("debug", `跳过无法解析链接的剧集元素`); + continue; + } + + const fullUrl = linkMatch[1]; + const videoId = linkMatch[2]; + const absoluteUrl = `https:${fullUrl}`; + + // 提取标题 - 更精确的匹配 + let title = ''; + + // 优先从 dt.d_tit 中获取标题 + const titleMatch = /
[\s\n\r]*]*title="([^"]*)"[^>]*>([^<]*)<\/a>/.exec(element); + if (titleMatch && titleMatch[1]) { + title = titleMatch[1].trim(); // 使用title属性 + } else if (titleMatch && titleMatch[2]) { + title = titleMatch[2].trim(); // 使用链接文本 + } else { + // 如果dt.d_tit中没有找到,尝试其他方式 + const altTitleMatch = /]*title="([^"]*)"[^>]*>[\s\S]*?
/.exec(element) || + /
[\s\S]*?]*title="([^"]*)"/.exec(element); + if (altTitleMatch && altTitleMatch[1]) { + title = altTitleMatch[1].trim(); + } else { + // 再尝试从dd.d_cnt中获取 + const ddMatch = /
]*>([^<]*)/.exec(element); + if (ddMatch && ddMatch[1]) { + title = ddMatch[1].trim(); + } else { + // 最后尝试从链接本身获取文本 + const linkTextMatch = /]*title="[^"]*"[^>]*>([^<]*)/.exec(element); + if (linkTextMatch && linkTextMatch[1]) { + title = linkTextMatch[1].trim(); + } + } + } + } + + // 如果仍然没有标题,尝试从剧集编号获取 + if (!title) { + const episodeNumMatch = /第(\d+)集|(\d+)(?:\s*预告)?/.exec(element); + if (episodeNumMatch) { + const num = episodeNumMatch[1] || episodeNumMatch[2]; + title = `第${num}集`; + } else { + title = `第${episodes.length + 1}集`; + } + } + + // 过滤掉预告片或其他非正常剧集(可选) + const isPreview = /预告|Preview|preview/.test(title); + if (isPreview) { + log("debug", `跳过预告片: ${title}`); + continue; + } + + const episode = { + vid: videoId, + title: title, + url: absoluteUrl, + episodeId: `${videoId}:${mediaId}` // vid:aid + }; + + episodes.push(episode); + + } catch (e) { + log("warning", `[Leshi] 解析单个剧集失败: ${e.message}`); + continue; + } + } + + log("info", `[Leshi] 成功解析剧集列表: media_id=${mediaId}, 共 ${episodes.length} 集`); + return episodes; + } + + async handleAnimes(sourceAnimes, queryTitle, curAnimes) { + const tmpAnimes = []; + + // 添加错误处理,确保sourceAnimes是数组 + if (!sourceAnimes || !Array.isArray(sourceAnimes)) { + log("error", "[Leshi] sourceAnimes is not a valid array"); + return []; + } + + // 使用 map 和 async 时需要返回 Promise 数组,并等待所有 Promise 完成 + const processLeshiAnimes = await Promise.all(sourceAnimes + .filter(s => titleMatches(s.title, queryTitle)) + .map(async (anime) => { + try { + const eps = await this.getEpisodes(anime.mediaId); + let links = []; + + for (let i = 0; i < eps.length; i++) { + const ep = eps[i]; + const epTitle = ep.title || `第${i + 1}集`; + links.push({ + "name": (i + 1).toString(), + "url": `${ep.url}`, + "title": `【leshi】 ${epTitle}` + }); + } + + if (links.length > 0) { + // 将字符串mediaId转换为数字ID (使用哈希函数) + const numericAnimeId = convertToAsciiSum(anime.mediaId); + let transformedAnime = { + animeId: numericAnimeId, + bangumiId: anime.mediaId, + animeTitle: `${anime.title}(${anime.year || new Date().getFullYear()})【${anime.type}】from leshi`, + type: anime.type, + typeDescription: anime.type, + imageUrl: anime.imageUrl, + startDate: generateValidStartDate(anime.year || new Date().getFullYear()), + episodeCount: links.length, + rating: 0, + isFavorited: true, + source: "leshi", + }; + + tmpAnimes.push(transformedAnime); + + addAnime({...transformedAnime, links: links}); + + if (globals.animes.length > globals.MAX_ANIMES) removeEarliestAnime(); + } + } catch (error) { + log("error", `[Leshi] Error processing anime: ${error.message}`); + } + }) + ); + + this.sortAndPushAnimesByYear(tmpAnimes, curAnimes); + + return processLeshiAnimes; + } + + async getEpisodeDanmu(id) { + log("info", "开始从本地请求乐视网弹幕...", id); + + // 获取弹幕分段数据 + const segmentResult = await this.getEpisodeDanmuSegments(id); + if (!segmentResult || !segmentResult.segmentList || segmentResult.segmentList.length === 0) { + return []; + } + + const segmentList = segmentResult.segmentList; + log("info", `弹幕分段数量: ${segmentList.length}`); + + // 并发请求所有弹幕段,限制并发数量为5 + const MAX_CONCURRENT = 10; + const allComments = []; + + // 将segmentList分批处理,每批最多MAX_CONCURRENT个请求 + for (let i = 0; i < segmentList.length; i += MAX_CONCURRENT) { + const batch = segmentList.slice(i, i + MAX_CONCURRENT); + + // 并发处理当前批次的请求 + const batchPromises = batch.map(segment => this.getDanmuSegment(segment)); + const batchResults = await Promise.allSettled(batchPromises); + + // 处理结果 + for (let j = 0; j < batchResults.length; j++) { + const result = batchResults[j]; + const segment = batch[j]; + const start = segment.segment_start; + const end = segment.segment_end; + + if (result.status === 'fulfilled') { + const comments = result.value; + + if (comments && comments.length > 0) { + allComments.push(...comments); + } else if (start > 600) { // 10分钟后无数据可能到末尾 + // 如果某个分段超过10分钟且没有数据,可以提前结束 + // 但需要确保当前批次的所有请求都完成 + break; + } + } else { + log("error", `获取弹幕段失败 (${start}-${end}s):`, result.reason.message); + } + } + + // 批次之间稍作延迟,避免过于频繁的请求 + if (i + MAX_CONCURRENT < segmentList.length) { + await new Promise(resolve => setTimeout(resolve, 200)); + } + } + + if (allComments.length === 0) { + log("info", `乐视网: 该视频暂无弹幕数据 (vid=${id})`); + return []; + } + + printFirst200Chars(allComments); + + return allComments; + } + + async getDanmuSegment(segment) { + try { + const response = await httpGet(segment.url, { + headers: { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + }, + retries: 1, + }); + + // 处理响应数据并返回 contents 格式的弹幕 + let contents = []; + if (response && response.data) { + const data = typeof response.data === "string" ? response.data : JSON.stringify(response.data); + + // 解析JSONP响应 + const jsonMatch = /vjs_\d+\((.*)\)/.exec(data); + if (jsonMatch) { + const jsonData = JSON.parse(jsonMatch[1]); + if (jsonData.code === 200 && jsonData.data) { + contents.push(...(jsonData.data.list || [])); + } + } + } + + return contents; + } catch (error) { + log("error", "请求分片弹幕失败:", error); + return []; + } + } + + async getEpisodeDanmuSegments(id) { + log("info", "获取乐视网弹幕分段列表...", id); + + // 从ID中提取video_id + let videoId = id; + const match = id.match(/\/vplay\/(\d+)\.html/); + if (match) { + videoId = match[1]; + } + + // 获取视频时长 + const duration = await this.getVideoDuration(videoId); + + // 计算需要请求的时间段(每段5分钟) + const segments = []; + for (let i = 0; i < Math.ceil(duration / 300); i++) { + const startTime = i * 300; + const endTime = Math.min((i + 1) * 300, duration); + segments.push({ + "type": "leshi", + "segment_start": startTime, + "segment_end": endTime, + "url": `https://hd-my.le.com/danmu/list?vid=${videoId}&start=${startTime}&end=${endTime}&callback=vjs_${Date.now()}` + }); + } + + log("info", `乐视网: 视频时长 ${duration}秒,分为 ${segments.length} 个时间段`); + + return new SegmentListResponse({ + "type": "leshi", + "segmentList": segments + }); + } + + async getEpisodeSegmentDanmu(segment) { + return this.getDanmuSegment(segment); + } + + async getVideoDuration(videoId) { + try { + const response = await httpGet(`https://www.le.com/ptv/vplay/${videoId}.html`, { timeout: 10000 }); + + if (!response || !response.data) { + log("warning", `乐视网: 获取视频时长失败,使用默认值2400秒`); + return 2400; + } + + // 从页面中提取时长信息 - 支持 HH:MM:SS, MM:SS 或纯数字格式 + // 匹配格式如: duration:'02:23:58', duration: "02:23:58", duration:02:23:58, duration:12345 + const durationMatch = /duration['"]?\s*:\s*['"]?(\d{2}):(\d{2}):(\d{2})['"]?|duration['"]?\s*:\s*['"]?(\d{2}):(\d{2})['"]?|duration['"]?\s*:\s*(\d+)['"]?/.exec(response.data); + if (durationMatch) { + // 检查是否匹配到 HH:MM:SS 格式 (第1-3捕获组) + if (durationMatch[1] && durationMatch[2] && durationMatch[3]) { + const hours = parseInt(durationMatch[1]); + const minutes = parseInt(durationMatch[2]); + const seconds = parseInt(durationMatch[3]); + return hours * 3600 + minutes * 60 + seconds; + } + // 检查是否匹配到 MM:SS 格式 (第4-5捕获组) + else if (durationMatch[4] && durationMatch[5]) { + const minutes = parseInt(durationMatch[4]); + const seconds = parseInt(durationMatch[5]); + return minutes * 60 + seconds; + } + // 检查是否匹配到纯数字格式 (第6捕获组) + else if (durationMatch[6]) { + const seconds = parseInt(durationMatch[6]); + return seconds; + } + } + + // 默认返回40分钟 + return 2400; + } catch (e) { + log("warning", `获取视频时长失败: ${e},使用默认值2400秒`); + return 2400; + } + } + + formatComments(comments) { + return comments.map(comment => { + try { + // 位置转换 + const position = this.positionMap[parseInt(comment.position)] || 1; + + // 时间(秒) + const timeVal = parseFloat(comment.start || 0); + + // 颜色(十六进制转十进制) + const colorHex = comment.color || 'FFFFFF'; + const color = parseInt(colorHex, 16); + + // 弹幕ID + const danmuId = comment.id || comment._id || ''; + + // 弹幕内容 + const content = comment.txt || ''; + + // 构造p属性:时间,模式,字体大小,颜色,时间戳,池,用户ID,弹幕ID + const pString = `${timeVal.toFixed(2)},${position},25,${color},[${this.constructor.name.toLowerCase()}]`; + + return { + cid: String(danmuId), + p: pString, + m: content, + t: Math.round(timeVal * 100) / 100 + }; + } catch (error) { + log("error", `格式化弹幕失败: ${error.message}, 弹幕数据:`, comment); + return null; + } + }).filter(comment => comment !== null); + } +} diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index 634b083..8110f96 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -19,6 +19,7 @@ import MangoSource from "./sources/mango.js"; import BilibiliSource from "./sources/bilibili.js"; import YoukuSource from "./sources/youku.js"; import SohuSource from "./sources/sohu.js"; +import LeshiSource from "./sources/leshi.js"; import AnimekoSource from "./sources/animeko.js"; import OtherSource from "./sources/other.js"; import { NodeHandler } from "./configs/handlers/node-handler.js"; @@ -61,6 +62,7 @@ test('worker.js API endpoints', async (t) => { const bilibiliSource = new BilibiliSource(); const youkuSource = new YoukuSource(); const sohuSource = new SohuSource(); + const leshiSource = new LeshiSource(); const animekoSource = new AnimekoSource(); const otherSource = new OtherSource(); @@ -229,6 +231,28 @@ test('worker.js API endpoints', async (t) => { // assert(res.length >= 0, `Expected res.length >= 0, but got ${res.length}`); // }); + // await t.test('GET leshi danmu', async () => { + // const res = await leshiSource.getComments("https://www.le.com/ptv/vplay/1578861.html"); + // assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`); + // }); + + // await t.test('GET leshi danmu segments', async () => { + // const res = await leshiSource.getComments("https://www.le.com/ptv/vplay/1578861.html", "leshi", true); + // assert(res.type === "leshi", `Expected res.type === "leshi", but got ${res.type === "leshi"}`); + // assert(res.segmentList.length >= 0, `Expected res.segmentList.length >= 0, but got ${res.segmentList.length}`); + // }); + + // await t.test('GET leshi segment danmu', async () => { + // const segment = Segment.fromJson({ + // type: 'leshi', + // segment_start: 1800, + // segment_end: 2100, + // url: 'https://hd-my.le.com/danmu/list?vid=1578861&start=1800&end=2100&callback=vjs_1768494351290', + // }); + // const res = await leshiSource.getSegmentComments(segment); + // assert(res.length >= 0, `Expected res.length >= 0, but got ${res.length}`); + // }); + // await t.test('GET other_server danmu', async () => { // const res = await otherSource.getComments("https://www.bilibili.com/bangumi/play/ep1231564"); // assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`); diff --git a/forward/forward-widget.js b/forward/forward-widget.js index 2eb8fe4..b8e0dec 100644 --- a/forward/forward-widget.js +++ b/forward/forward-widget.js @@ -16,7 +16,7 @@ WidgetMetadata = { // 源配置 { name: "sourceOrder", - title: "源排序配置,默认'360,vod,renren,hanjutv',可选['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'sohu', 'renren', 'hanjutv', 'bahamut', 'dandan', 'custom']", + title: "源排序配置,默认'360,vod,renren,hanjutv',可选['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'sohu', 'leshi', 'renren', 'hanjutv', 'bahamut', 'dandan', 'custom']", type: "input", placeholders: [ { @@ -151,12 +151,12 @@ WidgetMetadata = { // 匹配配置 { name: "platformOrder", - title: "平台优选配置,可选['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'sohu', 'renren', 'hanjutv', 'bahamut', 'dandan', 'custom']", + title: "平台优选配置,可选['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'sohu', 'leshi', 'renren', 'hanjutv', 'bahamut', 'dandan', 'custom']", type: "input", placeholders: [ { title: "配置1", - value: "qq,qiyi,imgo,bilibili1,youku,sohu,renren,hanjutv,bahamut,dandan,custom", + value: "qq,qiyi,imgo,bilibili1,youku,sohu,leshi,renren,hanjutv,bahamut,dandan,custom", }, { title: "配置2",