// ==UserScript== // @name 常看VIP视频全网解析 (Beta版) // @namespace https://scriptcat.org/zh-CN/script-show-page/6457 // @version 2.1.2 // @description 💎VIP视频解析 | 🎬爱奇艺解析 | 🔍腾讯VIP去广 | 📺优酷独播解锁 | ⚡芒果TV大会员 | 🎥B站番剧解析 | 🔗搜狐视频直连 | ✦乐视超清播放 | ◆PPTV聚力解析 | ●咪咕体育直播 | ★西瓜VIP解锁 | 🔍抖音短剧解析 | 🎬快手短视频解析 | 📺1905电影网 | ⚡AcFun弹幕解析 | 🔗土豆/风行/暴风兼容 | ✦全网影视通解 | ◆4K蓝光画质 | ●HDR杜比音效 | ★智能线路优选 | 💎免登录免费看 | 🎞️热播剧/最新电影/独家综艺/国漫日番 | ⚡极速评估 | 🔄智能缓存 | 🚀DNS预解析 | 🚀24并发加速 // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48Y2lyY2xlIGN4PSI1MCIgY3k9IjUwIiByPSI1MCIgZmlsbD0idXJsKCNncmFkKSIvPjxkZWZzPjxsaW5lYXJHcmFkaWVudCBpZD0iZ3JhZCIgeDE9IjAlIiB5MT0iMCUiIHgyPSIxMDAlIiB5Mj0iMTAwJSI+PHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iIzAwMzM5OSIvPjxzdG9wIG9mZnNldD0iMTAwJSIgc3RvcC1jb2xvcj0iIzAwNjZjYyIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjx0ZXh0IHg9IjUwIiB5PSI3MCIgZm9udC1zaXplPSI1MCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZmlsbD0id2hpdGUiIGZvbnQtZmFtaWx5PSJzYW5zLXNlcmlmIiBmb250LXdlaWdodD0ibjkwMCI+VklQPC90ZXh0Pjwvc3ZnPg== // @author lsym // @noframes // @match *://*/* // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @require https://cdn.jsdelivr.net/gh/Tampermonkey/utils@3b32b826e84ccc99a0a3e3d8d6e5ce0fa9834f23/requires/gh_2215_make_GM_xhr_more_parallel_again.js // @run-at document-end // @connect 360zy.com // @connect api.1080zyku.com // @connect api.apibdzy.com // @connect api.ffzyapi.com // @connect api.guangsuapi.com // @connect api.maoyanapi.top // @connect api.niuniuzy.me // @connect api.okzyw.net // @connect api.tiankongapi.com // @connect api.ukuapi.com // @connect api.ukuapi88.com // @connect api.wujinapi.me // @connect api.xinlangapi.com // @connect api.yparse.com // @connect api.yzzy-api.com // @connect api.zuidapi.com // @connect caiji.dyttzyapi.com // @connect caiji.kczyapi.com // @connect caiji.kuaichezy.org // @connect caiji.moduapi.cc // @connect cj.ffzyapi.com // @connect cj.lziapi.com // @connect cj.rycjapi.com // @connect cj.vodimg.top // @connect cj.yayazy.net // @connect ckzy.me // @connect ddmf.net // @connect hhzyapi.com // @connect ikunzyapi.com // @connect iqiyizyapi.com // @connect jszyapi.com // @connect jyzyapi.com // @connect lz.118318.xyz // @connect m3u8.apiyhzy.com // @connect p2100.net // @connect savviuux.hk3.345888.xyz.cdn.cloudflare.net // @connect sdzyapi.com // @connect slapibf.com // @connect subocaiji.com // @connect suboziyuan.net // @connect suoniapi.com // @connect taopianapi.com // @connect tyyszyapi.com // @connect www.39kan.com // @connect www.hongniuzy2.com // @connect www.huyaapi.com // @connect www.lovedan.net // @connect www.lzzy.tv // @connect www.mdzyapi.com // @connect www.qilinzyz.com // @connect www.ryzyw.com // @connect www.seacms.org // @connect www.wyvod.com // @connect xkanzy.com // @connect xsd.sdzyapi.com // @connect fastly.jsdelivr.net // @connect cdn.jsdelivr.net // @antifeature piracy // @license MIT // ==/UserScript== (function () { 'use strict'; /* 防止重复初始化(SPA 场景) */ if (window.hasInitVipScript) return; window.hasInitVipScript = true; /* ============================================================== * 模块 1:配置常量 * 集中管理所有可调参数,便于维护与调优 * ============================================================== */ const CONFIG = { /** API 请求超时(ms) */ API_TIMEOUT: 2000, /** 播放卡顿自动切源超时(ms) */ STUCK_CHECK_TIMEOUT: 7000, /** 普通源搜索并发数 */ SEARCH_CONCURRENCY: 24, /** 优先源搜索并发数 */ PRIORITY_CONCURRENCY: 12, /** 源质量评估超时(ms) */ FAST_EVAL_TIMEOUT: 500, /** 是否启用智能排序(基于历史成功率/延迟) */ SMART_SORTING: true, /** 自动播放下一集延迟(ms) */ AUTOPLAY_NEXT_DELAY: 100, /** 鼠标离开面板后自动关闭延迟(ms) */ PANEL_LEAVE_CLOSE_DELAY: 2000, /** 剧集列表展示后自动隐藏延迟(ms) */ PANEL_EPISODE_GRACE_PERIOD: 2000, /** SPA URL 变化去抖延迟(ms) */ SPA_DEBOUNCE: 500, /** 图标位置存储 key */ STORAGE_KEY_ICON_POSITION: 'tm_icon_position_v6', /** URL 解析缓存版本与有效期 */ URL_CACHE_VERSION: 'v1', URL_CACHE_EXPIRY: 60 * 60 * 1000, // 60 分钟 /** 剧集 URL 预解析批量大小 */ PREFETCH_BATCH_SIZE: 8, /** 是否预加载下一集 URL */ PREFETCH_NEXT_EPISODE: true, /** 视频站点 URL 匹配模式 */ VIDEO_URL_PATTERNS: [ /iqiyi\.com\/[vwa]_/, /iq\.com\/play\//, /youku\.com\/v_show\/id_/, /v\.youku\.com\/v_show\/id_/, /v\.qq\.com\/(x\/cover|x\/page|tv)\//, /mgtv\.com\/b\//, /mgtv\.com\/s\//, /bilibili\.com\/(video|bangumi\/play)\//, /b23\.tv\//, /le\.com\/ptv\/vplay\//, /tv\.sohu\.com\/v\//, /film\.sohu\.com\/album\//, /pptv\.com\/show\//, /acfun\.cn\/v\/ac/, /1905\.com\/play\//, /ixigua\.com\/video\//, /ixigua\.com\/play\//, /tudou\.com\/(listplay|albumplay|programs\/view)\//, /fun\.tv\/vod-play\//, /baofeng\.com\/play\//, /migumovie\.hcs\.cmvideo\.cn\/movie/, /miguvideo\.com\/detail/, /douyin\.com\/video\//, /kuaishou\.com\/short-video\//, /hanju\.koudaibaobao\.com\//, /maiduidui\.com\/play\//, /rrsp\.tv\/play\//, /vas\.hiaiabc\.com\/play/ ], /** 跨 iframe 消息类型 */ MESSAGES: { VIDEO_ENDED: 'tm_video_ended', PLAY_SUCCESS: 'tm_play_success', PLAY_ERROR: 'tm_play_error', SWITCH_URL: 'tm_switch_url', // 父→子:切换播放地址 PAUSE_PLAYER: 'tm_pause_player', // 父→子:暂停 DESTROY_PLAYER: 'tm_destroy_player' // 父→子:销毁 }, /** 页面 DOM 选择器配置 */ SELECTORS: { PLAYER_ELEMENTS: [ '.txp_player_root', '#player-container', '#player', '.container-player', '#tenvideo_player', '#sohuplayer', '#flashbox', '.iqp-player', '#bilibili-player', '.bpx-player-container', '#mgtv-player-wrap', '#le_player', '#player_swf', '#pp-player', '#ACPlayer', '#video-player', '#xigua-player', '.video-area', '.player-container' ], QUICK_TITLE: ['meta[property="og:title"]', 'h1', '.video-title', '.title', '.vod_title'], PRECISE_TITLE: { 'iqiyi.com': '.qy-episode-item[class*="is-active"] a, .album-list .is-active .title-content, #text[style*="IQYHT-Bold"]', 'youku.com': '.anthology-wrap li.active span', 'v.qq.com': '.episode-item--select, .playlist-item--current, [class*="selected"] [class*="episode-item-text"], [class*="episode-item"][class*="selected"] .episode-item-text, [class*="episode-item"][class*="current"] .episode-item-text, [class*="episode-item"][class*="active"] .episode-item-text, [class*="numberListItem_select"] [class*="numberListItem_title"], [class*="ele_select"] .episode-item-text', 'bilibili.com': '[class*="numberListItem_select"] [class*="numberListItem_title"], .ep-list-item.on .ep-item-title, [class*="episode_list"] [class*="selected"]', 'mgtv.com': '.episode-list .current a', 'sohu.com': '.player-album-list .on a', 'le.com': '.js-episode-item.on', 'pptv.com': '.episode-list .current', 'acfun.cn': '.active .title-wenzi' }, PRECISE_MAIN_TITLE: { 'qq.com': '.intro-title[title]', 'iqiyi.com': '[data-ai-entity="主标题"]', 'iq.com': '[data-ai-entity="主标题"]', 'youku.com': '[data-spm-anchor-id*="introduction"] .title, .title[style*="max-width"]', 'bilibili.com': '[class*="mediaTitle"][title], [class*="mediaTitle"]', 'b23.tv': '[class*="mediaTitle"][title], [class*="mediaTitle"]' } }, /** 电影版本关键词与优先级 */ MOVIE_KEYWORDS: /^(HD|超清|高清|正片|国语|HD国语|720P|1080P|蓝光|4K|BD|TC|TS|DVD|抢先|高清版|HD高清|国语高清|HD中字)$/i, MOVIE_PRIORITY: ['蓝光','4K','1080P','超清','HD国语','HD','国语','高清','720P','BD','正片','HD高清','国语高清','HD中字','TC','TS','DVD','抢先','高清版'] }; /* ============================================================== * 模块 2:正则常量 * 预编译正则提升匹配性能,避免重复创建 * ============================================================== */ const RE_VIDEO_URL = CONFIG.VIDEO_URL_PATTERNS; const RE_MEDIA_EXT = /\.m3u8|\.mp4|\.flv/i; const RE_TITLE_SPLIT = /[-_\s((]/; const RE_EP_REMOVE = /第.+[集季部]/; const RE_NUM_ONLY = /^\d+$/; const RE_EP_PATTERN = /(?:第|EP|Ep|ep|E)\s*(\d+)(?:集|话|期|部|季|章)?/i; const RE_EP_PREFIX = /^(\d+)\s*(?:集|话|期|部|季|章)/; const RE_EP_SUFFIX = /(\d+)\s*(?:集|话|期|部|季|章)(?:\D|$)/; const RE_EP_EP = /(?:EP|Ep|ep)(\d+)/i; const RE_EP_SEASON = /第\s*\d+\s*[季部]\s*第\s*(\d+)\s*[集话]/; const RE_EP_FALLBACK = /(?:\D|^)(\d{1,4})(?:\D|$)/; const RE_HTML_LT = //g; const RE_HTML_SQ = /'/g; const RE_JSONP = /^[a-zA-Z_][a-zA-Z0-9_]*\(([\s\S]+)\)$/; const RE_M3U8_URL = /https?:\/\/[^\s"']+\.m3u8[^\s"']*/gi; const RE_M3U8_EXTRACT = /(https?:\/\/[^\s"']+\.m3u8[^\s"']*)/i; const RE_M3U8_QUOTED = /["']([^"']*\.m3u8[^"']*)["']/i; const RE_SCRIPT_TAG = /]*>([\s\S]*?)<\/script>/gi; const RE_IFRAME_SRC = /]+src=["']([^"']+)['"]/i; /** 搜索缓存版本与有效期 */ const CACHE_VERSION = 'v3'; const CACHE_EXPIRY = 30 * 60 * 1000; // 30 分钟 /* ============================================================== * 模块 3:CSS 样式 * ============================================================== */ const STYLES = ` #t{position:fixed;z-index:2147483647;width:36px;height:36px;cursor:grab;user-select:none} #tb{width:100%;height:100%;border:none;border-radius:50%;background:#0066cc;color:#fff;cursor:pointer; box-shadow:0 0 20px rgba(0,102,204,.5),inset 0 0 15px rgba(255,255,255,.3),0 0 0 5px rgba(0,0,0,0.6),0 0 0 6px rgba(255,255,255,0.15); transition:transform .25s,box-shadow .25s;padding:0;display:flex;align-items:center;justify-content:center;overflow:hidden} #tb:hover{transform:scale(1.08);box-shadow:0 0 28px rgba(0,102,204,.7),inset 0 0 14px rgba(255,255,255,.2)} #tb::before{content:"VIP";font-size:14px;font-weight:900; background:linear-gradient(to right,#60a5fa 0%,#fff 50%,#a5c8ff 100%); -webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent; text-shadow:0 1px 3px rgba(0,0,0,0.5);filter:contrast(1.3)} #tb.loading{background:#0066cc!important;box-shadow:0 0 28px rgba(0,102,204,.7)} #tb.loading::before{font-size:0;content:"";width:3px;height:16px;background:#fff;border-radius:3px;animation:pulse 1s infinite ease-in-out} @keyframes pulse{0%,100%{transform:scaleY(.35);opacity:.6}50%{transform:scaleY(1);opacity:1}} #tp{display:none;position:absolute;left:44px;top:0;max-width:420px;background:rgba(15,15,30,.72);border-radius:14px; padding:10px;max-height:70vh;box-shadow:0 12px 40px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.06); font-size:12px;backdrop-filter:blur(28px);-webkit-backdrop-filter:blur(28px); border:1px solid rgba(255,255,255,.08);flex-direction:column} #ts{display:flex;align-items:center;justify-content:center;flex-shrink:0;width:100%;box-sizing:border-box; padding:7px 12px;margin-bottom:6px;background:rgba(59,130,246,.3);border-radius:10px;font-weight:500; color:#c4b8f0;font-size:12px;height:28px;border:1px solid rgba(59,130,246,.25); box-shadow:0 2px 6px rgba(0,0,0,.2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .tb{display:flex;align-items:center;justify-content:center;width:100%;padding:7px 10px;margin-bottom:4px; background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.06);border-radius:9px;cursor:pointer; text-align:center;font-weight:500;color:#d1d5db;font-size:12px; transition:background .18s,transform .18s,border-color .18s;white-space:nowrap;box-sizing:border-box} .tb:hover{background:rgba(90,79,207,.18);transform:translateY(-1px);border-color:rgba(90,79,207,.35)} .tb.on{background:#3b82f6!important;color:#fff!important;border-color:rgba(255,255,255,.18)!important; box-shadow:0 4px 14px rgba(59,130,246,.4)} #tc{display:grid;grid-template-columns:1fr;gap:4px;flex:1 1 auto;min-height:0;overflow-y:auto; padding-right:6px;box-sizing:border-box} #tc.ep{grid-template-columns:repeat(2,1fr)}#tc.ep .tb{width:100%} #tc.sl{grid-template-columns:repeat(2,1fr)}#tp.sl-open{width:210px!important} #tc.sl .tb{width:100%;height:28px;min-width:0;overflow:hidden} #tc::-webkit-scrollbar{width:5px} #tc::-webkit-scrollbar-track{background:rgba(10,15,30,.2);border-radius:3px} #tc::-webkit-scrollbar-thumb{background:rgba(90,79,207,.3);border-radius:3px} #tc::-webkit-scrollbar-thumb:hover{background:rgba(90,79,207,.6)} #to{position:absolute;background:#000;z-index:2147483646;display:none;border-radius:2px;overflow:hidden} #ti{width:100%;height:100%;border:none} #tx{position:absolute;top:8px;right:8px;z-index:2147483647;width:30px;height:30px;border-radius:50%; background:rgba(15,15,30,.65);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px); border:1px solid rgba(255,255,255,.08);color:#e5e7eb;cursor:pointer;font-size:16px; display:flex;align-items:center;justify-content:center;transition:all .25s} #tx:hover{background:rgba(239,68,68,.85);border-color:transparent;color:#fff; transform:rotate(90deg);box-shadow:0 4px 12px rgba(239,68,68,.4)} #ttf{position:fixed;top:20px;left:50%;transform:translateX(-50%);background:rgba(15,15,30,.88); backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);color:#c4b8f0;font-size:13px; padding:10px 24px;border-radius:20px;z-index:2147483647;border:1px solid rgba(90,79,207,.35); box-shadow:0 6px 24px rgba(0,0,0,.55);transition:opacity .35s;pointer-events:none;opacity:0;display:none; max-width:90vw;text-align:center} #ttf.show{display:block;opacity:1} `; /* ============================================================== * 模块 4:API 源列表 * 使用 atob 解码 base64 编码的 URL,避免明文暴露 * ============================================================== */ const RAW_APIS = [ { n: "360", u: atob("aHR0cHM6Ly8zNjB6eS5jb20vYXBpLnBocC9wcm92aWRlL3ZvZC8=") }, { n: "iqiyi", u: atob("aHR0cHM6Ly9pcWl5aXp5YXBpLmNvbS9hcGkucGhwL3Byb3ZpZGUvdm9kLw==") }, { n: "百度", u: atob("aHR0cDovL2FwaS5hcGliZHp5LmNvbS9hcGkucGhwL3Byb3ZpZGUvdm9kLw==") }, { n: "艾旦", u: atob("aHR0cDovL3d3dy5sb3ZlZGFuLm5ldC9hcGkucGhwL3Byb3ZpZGUvdm9kLw==") }, { n: "蛋蛋", u: atob("aHR0cDovL2RkbWYubmV0L2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "新浪", u: atob("aHR0cHM6Ly9hcGkueGlubGFuZ2FwaS5jb20veGlubGFuZ2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "享看", u: atob("aHR0cHM6Ly94a2FuenkuY29tL2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "魔都", u: atob("aHR0cHM6Ly9jYWlqaS5tb2R1YXBpLmNjL2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "天堂2", u: atob("aHR0cDovL2NhaWppLmR5dHR6eWFwaS5jb20vYXBpLnBocC9wcm92aWRlL3ZvZC8=") }, { n: "快龙", u: atob("aHR0cHM6Ly9sei4xMTgzMTgueHl6L2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "猫眼", u: atob("aHR0cHM6Ly9hcGkubWFveWFuYXBpLnRvcC9hcGkucGhwL3Byb3ZpZGUvdm9kLw==") }, { n: "森林", u: atob("aHR0cHM6Ly9zbGFwaWJmLmNvbS9hcGkucGhwL3Byb3ZpZGUvdm9kLw==") }, { n: "花旗", u: atob("aHR0cHM6Ly93d3cuc2VhY21zLm9yZy9hcGkucGhwL3Byb3ZpZGUvdm9kLw==") }, { n: "量子", u: atob("aHR0cHM6Ly9jai5semlhcGkuY29tL2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "量子2", u: atob("aHR0cDovL3d3dy5senp5LnR2L2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "索尼", u: atob("aHR0cHM6Ly9zdW9uaWFwaS5jb20vYXBpLnBocC9wcm92aWRlL3ZvZC8=") }, { n: "快车", u: atob("aHR0cHM6Ly9jYWlqaS5rdWFpY2hlenkub3JnL2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "最大", u: atob("aHR0cHM6Ly9hcGkuenVpZGFwaS5jb20vYXBpLnBocC9wcm92aWRlL3ZvZC8=") }, { n: "牛牛", u: atob("aHR0cHM6Ly9hcGkubml1bml1enkubWUvYXBpLnBocC9wcm92aWRlL3ZvZC8=") }, { n: "OK", u: atob("aHR0cHM6Ly9hcGkub2t6eXcubmV0L2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "闪电3", u: atob("aHR0cHM6Ly94c2Quc2R6eWFwaS5jb20vYXBpLnBocC9wcm92aWRlL3ZvZC8=") }, { n: "闪电", u: atob("aHR0cDovL3NkenlhcGkuY29tL2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "鸭鸭", u: atob("aHR0cHM6Ly9jai55YXlhenkubmV0L2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "无尽", u: atob("aHR0cHM6Ly9hcGkud3VqaW5hcGkubWUvYXBpLnBocC9wcm92aWRlL3ZvZC8=") }, { n: "光速", u: atob("aHR0cHM6Ly9hcGkuZ3VhbmdzdWFwaS5jb20vYXBpLnBocC9wcm92aWRlL3ZvZC8=") }, { n: "速播", u: atob("aHR0cHM6Ly9zdWJvY2FpamkuY29tL2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "速播2", u: atob("aHR0cDovL3N1Ym96aXl1YW4ubmV0L2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "金鹰2", u: atob("aHR0cHM6Ly9qeXp5YXBpLmNvbS9hcGkucGhwL3Byb3ZpZGUvdm9kLw==") }, { n: "金鹰", u: atob("aHR0cHM6Ly9qeXp5YXBpLmNvbS9hcGkucGhwL3Byb3ZpZGUvdm9kLw==") }, { n: "红牛", u: atob("aHR0cHM6Ly93d3cuaG9uZ25pdXp5Mi5jb20vYXBpLnBocC9wcm92aWRlL3ZvZC8=") }, { n: "豪华", u: atob("aHR0cHM6Ly9oaHp5YXBpLmNvbS9hcGkucGhwL3Byb3ZpZGUvdm9kLw==") }, { n: "虎牙", u: atob("aHR0cHM6Ly93d3cuaHV5YWFwaS5jb20vYXBpLnBocC9wcm92aWRlL3ZvZC8=") }, { n: "极速", u: atob("aHR0cHM6Ly9qc3p5YXBpLmNvbS9hcGkucGhwL3Byb3ZpZGUvdm9kLw==") }, { n: "樱花", u: atob("aHR0cHM6Ly9tM3U4LmFwaXloenkuY29tL2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "非凡2", u: atob("aHR0cDovL2NqLmZmenlhcGkuY29tL2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "非凡", u: atob("aHR0cHM6Ly9hcGkuZmZ6eWFwaS5jb20vYXBpLnBocC9wcm92aWRlL3ZvZC8=") }, { n: "飘零2", u: atob("aHR0cHM6Ly9wMjEwMC5uZXQvYXBpLnBocC9wcm92aWRlL3ZvZC8=") }, { n: "一零", u: atob("aHR0cDovL2FwaS4xMDgwenlrdS5jb20vaW5jL2FwaV9tYWMxMC5waHAvcHJvdmlkZS92b2Qv") }, { n: "优质", u: atob("aHR0cHM6Ly9hcGkueXp6eS1hcGkuY29tL2luYy9hcGlqc29uLnBocC9wcm92aWRlL3ZvZC8=") }, { n: "1080", u: atob("aHR0cHM6Ly9hcGkuMTA4MHp5a3UuY29tL2luYy9hcGlqc29uLnBocC9wcm92aWRlL3ZvZC8=") }, { n: "魔都2", u: atob("aHR0cHM6Ly93d3cubWR6eWFwaS5jb20vYXBpLnBocC9wcm92aWRlL3ZvZC8=") }, { n: "天堂", u: atob("aHR0cHM6Ly9jYWlqaS5keXR0enlhcGkuY29tL2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "ikun", u: atob("aHR0cHM6Ly9pa3VuenlhcGkuY29tL2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "天涯", u: atob("aHR0cHM6Ly90eXlzenlhcGkuY29tL2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "U酷2", u: atob("aHR0cHM6Ly9hcGkudWt1YXBpODguY29tL2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "U酷", u: atob("aHR0cHM6Ly9hcGkudWt1YXBpLmNvbS9hcGkucGhwL3Byb3ZpZGUvdm9kLw==") }, { n: "无忧", u: atob("aHR0cHM6Ly93d3cud3l2b2QuY29tL2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "CK", u: atob("aHR0cHM6Ly9ja3p5Lm1lL2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "天空", u: atob("aHR0cHM6Ly9hcGkudGlhbmtvbmdhcGkuY29tL2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "麒麟", u: atob("aHR0cHM6Ly93d3cucWlsaW56eXouY29tL2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "快鹰", u: atob("aHR0cDovL3NhdnZpdXV4LmhrMy4zNDU4ODgueHl6LmNkbi5jbG91ZGZsYXJlLm5ldC9hcGkucGhwL3Byb3ZpZGUvdm9kLw==") }, { n: "快车2", u: atob("aHR0cHM6Ly9jYWlqaS5rY3p5YXBpLmNvbS9hcGkucGhwL3Byb3ZpZGUvdm9kLw==") }, { n: "淘片", u: atob("aHR0cHM6Ly90YW9waWFuYXBpLmNvbS9jamFwaS9tYzEwL3ZvZC8=") }, { n: "影图", u: atob("aHR0cHM6Ly9jai52b2RpbWcudG9wL2FwaS5waHAvcHJvdmlkZS92b2Qv") }, { n: "云解", u: atob("aHR0cHM6Ly9hcGkueXBhcnNlLmNvbS9hcGkvcHJvdmlkZS92b2Qv") }, { n: "39影", u: atob("aHR0cHM6Ly93d3cuMzlrYW4uY29tL2FwaS5waHAvcHJvdmlkZS92b2Qv") } ]; /** 优先源排序(命中后给予最高评分) */ const PRIORITY_ORDER = ['iqiy', '天堂', '360', '猫眼', '魔都', '1080', '红牛', 'ikun', '速播2', 'U酷', '非凡', '豪华']; const PRIORITY_SET = new Set(PRIORITY_ORDER); const LOW_PRIORITY = new Set(['量子', '最大']); /* ============================================================== * 模块 5:全局状态 * 集中管理运行时状态,避免散落的全局变量 * ============================================================== */ const State = { /* UI 状态 */ panelOpen: false, // 面板是否展开 closed: false, // 播放器是否已关闭 dom: {}, // DOM 元素引用缓存 timers: {}, // 定时器句柄集合 /* 搜索状态 */ searchId: 0, // 当前搜索会话 ID(用于取消过期搜索) cache: { key: null, results: [] }, // 当前页搜索结果 _savedCache: null, // 关闭后保留的缓存(便于重开) isPageLoad: true, // 是否首次加载 /* 播放状态 */ curUrl: '', // 当前播放地址 curEp: null, // 当前集数 curPageUrl: location.href, eps: [], // 当前源的剧集列表 activeName: null, // 当前激活的源名 playing: false, // 是否正在播放 hiddenEl: null, // 被覆盖的原始播放器元素 stuckPending: false, // 是否等待播放成功确认 switching: false, // 是否正在自动切源 lastSwitchTime: 0, failedUrls: new Set(), // 失败的 URL 集合 failedSources: new Set(),// 失败的源名集合 switchCount: 0, totalSources: 0, firstAuto: false, // 是否已自动播放首个匹配源 /* 持久化 iframe 状态(新增) */ playerReady: false, // iframe 内 artplayer 是否已初始化 pendingUrl: null // 待播放 URL(iframe 未就绪时缓存) }; /* ============================================================== * 模块 6:通用工具函数 * ============================================================== */ /** 判断当前页面是否为支持的播放页 */ const isVideoPage = () => RE_VIDEO_URL.some(p => p.test(location.href)); /** 安全 querySelector,返回单个元素 */ const querySelector = (selector, parent = document) => parent.querySelector(selector); /** querySelectorAll 转 Array,便于使用数组方法 */ const querySelectorAll = (selector, parent = document) => Array.from(parent.querySelectorAll(selector)); /** * 清除指定定时器(同时处理 interval/timeout) * @param {string} key - 定时器键 */ function clearTimer(key) { clearInterval(State.timers[key]); clearTimeout(State.timers[key]); State.timers[key] = null; } /** 清除所有定时器 */ function clearAllTimers() { for (const key in State.timers) clearTimer(key); } /** * 创建 DOM 元素并批量设置属性 * @param {string} tag - 标签名 * @param {Object} props - 属性对象 * @returns {HTMLElement} */ function createElement(tag, props) { return Object.assign(document.createElement(tag), props); } /* ============================================================== * 模块 7:搜索结果缓存 * 持久化到 GM_setValue,跨会话复用,TTL 30 分钟 * ============================================================== */ const SearchCache = { /** * 读取缓存 * @param {string} key - 缓存键(标题_集数) * @returns {Array|null} */ get(key) { try { const cached = JSON.parse(GM_getValue('cache_' + CACHE_VERSION + '_' + key)); if (cached && Date.now() - cached.ts < CACHE_EXPIRY) return cached.data; } catch (e) { /* ignore */ } return null; }, /** * 写入缓存 * @param {string} key - 缓存键 * @param {*} data - 数据 */ set(key, data) { try { GM_setValue('cache_' + CACHE_VERSION + '_' + key, JSON.stringify({ data, ts: Date.now() })); } catch (e) { /* ignore */ } } }; /* ============================================================== * 模块 8:URL 解析缓存(新增) * 持久化解析后的 m3u8 直链,避免重复网络请求 * 这是"秒开"的关键之一:点击剧集时命中缓存则跳过 resolveUrl * ============================================================== */ const UrlCache = { /** 内存层缓存(Map,会话内最快) */ _mem: new Map(), /** 内存缓存顺序(用于 LRU 淘汰) */ _order: [], _maxMem: 300, /** * 读取解析结果:优先内存,其次 GM 存储 * @param {string} originalUrl - 原始 URL * @returns {string|null} 解析后的直链 */ get(originalUrl) { if (this._mem.has(originalUrl)) { // LRU 更新顺序 const idx = this._order.indexOf(originalUrl); if (idx > -1) this._order.splice(idx, 1); this._order.push(originalUrl); return this._mem.get(originalUrl); } // 回退到持久化存储 try { const cached = JSON.parse(GM_getValue('urlc_' + CONFIG.URL_CACHE_VERSION + '_' + originalUrl)); if (cached && Date.now() - cached.ts < CONFIG.URL_CACHE_EXPIRY) { this._setMem(originalUrl, cached.url); return cached.url; } } catch (e) { /* ignore */ } return null; }, /** * 写入解析结果(同时写内存与持久化) * @param {string} originalUrl * @param {string} resolvedUrl */ set(originalUrl, resolvedUrl) { this._setMem(originalUrl, resolvedUrl); try { GM_setValue('urlc_' + CONFIG.URL_CACHE_VERSION + '_' + originalUrl, JSON.stringify({ url: resolvedUrl, ts: Date.now() })); } catch (e) { /* ignore */ } }, _setMem(key, value) { if (this._mem.size >= this._maxMem) { const oldest = this._order.shift(); if (oldest) this._mem.delete(oldest); } this._mem.set(key, value); this._order.push(key); }, /** 清空内存缓存 */ clearMem() { this._mem.clear(); this._order.length = 0; } }; /* ============================================================== * 模块 9:API 统计模块 * 记录每个源的成功率/延迟,用于智能排序 * ============================================================== */ const ApiStats = { _stats: {}, _timer: null, get(name) { return this._stats[name] || GM_getValue(`api_stats_${name}`) || { success: 0, fail: 0, latency: 0, total: 0 }; }, recordSuccess(name, latency) { const stats = this.get(name); stats.success++; stats.latency += latency; stats.total++; this._stats[name] = stats; this._scheduleFlush(); }, recordFail(name) { const stats = this.get(name); stats.fail++; stats.total++; this._stats[name] = stats; this._scheduleFlush(); }, /** * 计算源评分:成功率 × 10000 - 平均延迟 * @returns {number} 分数越高越优先 */ score(stats, name) { if (name && PRIORITY_SET.has(name)) return 99999; if (stats.total < 3) return 1000; const successRate = stats.success / stats.total; if (successRate < 0.5) return -1000; return successRate * 10000 - (stats.success ? stats.latency / stats.success : CONFIG.API_TIMEOUT); }, /** 延迟批量落盘,避免频繁写 GM 存储 */ _scheduleFlush() { if (!this._timer) { this._timer = setTimeout(() => { const stats = this._stats; this._stats = {}; this._timer = null; for (const [key, value] of Object.entries(stats)) { GM_setValue(`api_stats_${key}`, value); } }, 2000); } } }; /** * 处理原始 API 列表:去重、按优先级与历史评分排序 * @param {Array} rawList - 原始 API 列表 * @returns {Array} 排序后的 API 列表 */ function processApis(rawList) { const uniqueMap = new Map(); rawList.forEach(api => { if (!uniqueMap.has(api.u)) { uniqueMap.set(api.u, { name: api.n, url: api.u, shortName: api.n.substring(0, 4) }); } }); let apis = Array.from(uniqueMap.values()); const priorityApis = apis.filter(a => PRIORITY_SET.has(a.shortName)); const lowPriorityApis = apis.filter(a => LOW_PRIORITY.has(a.shortName)); const normalApis = apis.filter(a => !PRIORITY_SET.has(a.shortName) && !LOW_PRIORITY.has(a.shortName)); priorityApis.sort((a, b) => { const ai = PRIORITY_ORDER.indexOf(a.shortName); const bi = PRIORITY_ORDER.indexOf(b.shortName); return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi); }); if (CONFIG.SMART_SORTING) { normalApis.sort((a, b) => ApiStats.score(ApiStats.get(b.shortName), b.shortName) - ApiStats.score(ApiStats.get(a.shortName), a.shortName) ); } return [...priorityApis, ...normalApis, ...lowPriorityApis]; } const UNIQUE_APIS = processApis(RAW_APIS); /* ============================================================== * 模块 10:剧集解析工具 * 处理 vod_play_url 字段的拆分、匹配、电影版本优选 * ============================================================== */ const EpisodeUtils = { /** * 统计剧集数量(不解析全部,仅计数,性能更优) * @param {string} playUrl - 原始播放地址串 * @returns {number} */ countEpisodes(playUrl) { if (!playUrl) return 0; if (playUrl.includes('$$$')) return playUrl.split('$$$').pop().split('#').length; if (playUrl.includes('#')) return playUrl.split('#').length; if (playUrl.includes('$')) return playUrl.split('$').length; return 1; }, /** * 解析剧集列表 * 格式说明: * - 多线路用 $$$ 分隔,取最后一条 * - 每集用 # 分隔 * - 集名与地址用 $ 分隔 * @param {string} playUrl * @returns {Array<{name:string,url:string}>} */ parseEpisodes(playUrl) { if (!playUrl) return []; const segments = playUrl.includes('$$$') ? playUrl.split('$$$').pop().split('#') : playUrl.includes('#') ? playUrl.split('#') : [playUrl]; return segments .filter(seg => seg && seg.trim()) .map(seg => { const [name, url] = seg.split('$'); return { name: (name || '').trim(), url: (url || name || '').trim() }; }); }, /** * 在剧集列表中查找匹配当前集数的剧集 * 匹配顺序:精确 → 模糊 → 数值 * @param {Array} episodes - 剧集列表 * @param {string|number} currentEp - 当前集数 * @returns {Object|null} */ findMatchingEpisode(episodes, currentEp) { if (!currentEp || !episodes.length) return null; const targetNum = String(parseInt(currentEp, 10)); // 精确匹配 const exact = episodes.find(ep => { const num = extractEpisodeNumber(ep.name); return num && String(parseInt(num, 10)) === targetNum; }); if (exact) return exact; // 模糊匹配 const fuzzy = episodes.find(ep => ep.name && ep.name.includes(targetNum)); if (fuzzy) return fuzzy; // 数值匹配 return episodes.find(ep => { const num = extractEpisodeNumber(ep.name); return num && parseInt(num, 10) === parseInt(currentEp, 10); }); }, /** * 选择最佳电影版本(蓝光 > 4K > 1080P > ...) * @param {Array} episodes * @returns {Object|null} */ selectBestMovieVersion(episodes) { if (!episodes.length) return null; const movieVersions = []; const otherVersions = []; for (const ep of episodes) { if (ep.name && CONFIG.MOVIE_KEYWORDS.test(ep.name.trim())) { movieVersions.push(ep); } else { otherVersions.push(ep); } } if (movieVersions.length >= 1 && otherVersions.length === 0) { return pickBestMovie(movieVersions); } return otherVersions[0] || episodes[0]; } }; /** * 从电影版本列表中按优先级选最佳 * @param {Array} eps * @returns {Object} */ function pickBestMovie(eps) { let best = eps[0], bestIndex = Infinity; for (const ep of eps) { for (let i = 0; i < CONFIG.MOVIE_PRIORITY.length; i++) { if (ep.name === CONFIG.MOVIE_PRIORITY[i] || ep.name.includes(CONFIG.MOVIE_PRIORITY[i])) { if (i < bestIndex) { bestIndex = i; best = ep; } break; } } } return best; } /** * 从字符串中提取集数编号 * 支持多种格式:第3集、EP03、3、第1季第3集 等 * @param {string} str * @returns {string|null} */ function extractEpisodeNumber(str) { if (!str) return null; const trimmed = str.trim(); if (RE_NUM_ONLY.test(trimmed)) return trimmed; let m; if ((m = trimmed.match(RE_EP_PATTERN))) return m[1]; if ((m = trimmed.match(RE_EP_PREFIX))) return m[1]; if ((m = trimmed.match(RE_EP_SUFFIX))) return m[1]; if ((m = trimmed.match(RE_EP_EP))) return m[1]; if ((m = trimmed.match(RE_EP_SEASON))) return m[1]; if ((m = trimmed.match(RE_EP_FALLBACK))) return m[1]; const allNumbers = trimmed.match(/\d+/g); return allNumbers ? allNumbers[allNumbers.length - 1] : null; } /* ============================================================== * 模块 11:UI 模块 * 管理悬浮按钮、面板、剧集列表的渲染与交互 * ============================================================== */ const UI = { /** 初始化 UI:注入样式、创建 DOM、绑定事件 */ init() { GM_addStyle(STYLES); /* 创建主容器与按钮 */ State.dom.container = createElement('div', { id: 't' }); State.dom.button = createElement('button', { id: 'tb' }); State.dom.panel = createElement('div', { id: 'tp' }); State.dom.overlay = createElement('div', { id: 'to' }); State.dom.overlay.innerHTML = '' + ''; document.body.append(State.dom.container, State.dom.overlay); State.dom.container.append(State.dom.button, State.dom.panel); State.dom.iframe = document.getElementById('ti'); State.dom.closeBtn = document.getElementById('tx'); State.dom.toast = createElement('div', { id: 'ttf' }); document.body.append(State.dom.toast); this._initDrag(); State.dom.closeBtn.onclick = () => Player.close(); State.dom.panel.onmouseenter = () => { clearTimer('pc'); clearTimer('eg'); }; State.dom.panel.onmouseleave = () => { State.timers.pc = setTimeout(() => hidePanel(), CONFIG.PANEL_LEAVE_CLOSE_DELAY); }; /* ESC 键关闭播放器 */ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && State.playing) Player.close(); }, { passive: true }); /* 窗口尺寸变化时重新定位播放器 */ window.addEventListener('resize', () => { clearTimer('rs'); State.timers.rs = setTimeout(() => Player.reposition(), 150); }, { passive: true }); /* 接收 iframe 内的播放事件消息 */ window.addEventListener('message', (event) => Player.onMessage(event), { passive: true }); }, /* ---- 拖拽实现 ---- */ _initDrag() { let isDragging = false, hasMoved = false, offsetX, offsetY; const container = State.dom.container; const savedPos = GM_getValue(CONFIG.STORAGE_KEY_ICON_POSITION, { l: '20px', t: '250px' }); container.style.left = savedPos.l; container.style.top = savedPos.t; const onMouseMove = (e) => { if (!isDragging) return; hasMoved = true; container.style.left = Math.max(0, Math.min(e.clientX - offsetX, innerWidth - 36)) + 'px'; container.style.top = Math.max(0, Math.min(e.clientY - offsetY, innerHeight - 36)) + 'px'; }; const onMouseUp = () => { if (!isDragging) return; isDragging = false; document.body.style.userSelect = ''; container.style.cursor = 'grab'; if (hasMoved) { GM_setValue(CONFIG.STORAGE_KEY_ICON_POSITION, { l: container.style.left, t: container.style.top }); } removeEventListener('mousemove', onMouseMove, true); removeEventListener('mouseup', onMouseUp, true); removeEventListener('blur', onMouseUp, true); }; container.addEventListener('mousedown', (e) => { e.stopPropagation(); if (e.button !== 0) return; hasMoved = false; isDragging = true; document.body.style.userSelect = 'none'; container.style.cursor = 'grabbing'; const rect = container.getBoundingClientRect(); offsetX = e.clientX - rect.left; offsetY = e.clientY - rect.top; addEventListener('mousemove', onMouseMove, true); addEventListener('mouseup', onMouseUp, true); addEventListener('blur', onMouseUp, true); }); /* 按钮点击:切换面板显示/触发搜索 */ State.dom.button.onclick = (e) => { e.stopPropagation(); setTimeout(() => { if (State.panelOpen) { hidePanel(); } else { const currentUrl = location.href; if (State.cache.key === currentUrl && State.cache.results.length) { State.closed = false; showPanel(); renderSourceList(); } else if (State._savedCache && State._savedCache.results.length) { State.cache = { key: currentUrl, results: State._savedCache.results }; State.closed = false; showPanel(); renderSourceList(); } else { showPanel(); Search.start(); } } }, 60); }; }, /** * 动态添加单个源按钮(搜索过程中增量添加) * @param {Object} result - 搜索结果 */ addSourceButton(result) { const container = querySelector('#tc', State.dom.panel); if (!container || !State.panelOpen || container.classList.contains('ep')) return; if (!container.children.length) { State.dom.panel.style.cssText = 'display:flex;flex-direction:column;width:210px'; flipPanel(); container.style.display = ''; State.dom.panel.classList.add('sl-open'); } const existing = querySelector(`button[data-n="${result.name}"]`, container); if (existing) existing.remove(); const playUrl = result.data.vod_play_url || ''; const count = EpisodeUtils.countEpisodes(playUrl); const btn = createElement('button', { textContent: `${result.name} (${count}集)`, className: 'tb', onclick() { State.activeName = result.name; UI.showEpisodeList(result, false, State.curEp); } }); btn.dataset.n = result.name; State.dom.panel.classList.add('sl-open'); if (result.name === State.activeName) btn.classList.add('on'); container.appendChild(btn); }, /** * 显示剧集列表 * 优化点:使用事件委托替代每个按钮的 onclick;触发 URL 预解析 * @param {Object} source - 源对象 * @param {boolean} autoPlay - 是否自动播放匹配集 * @param {string|null} currentEp - 当前集数 * @param {boolean} clearFailed - 是否清空失败记录 */ showEpisodeList(source, autoPlay = false, currentEp = null, clearFailed = true) { if (State.closed) return; clearAllTimers(); const container = querySelector('#tc', State.dom.panel); if (!container) return; const fragment = document.createDocumentFragment(); container.innerHTML = ''; State.dom.panel.style.cssText = 'display:flex;flex-direction:column'; flipPanel(); State.dom.panel.classList.remove('sl-open'); container.className = 'ep'; setStatusText('\u2039 返回源列表', false); const statusBar = querySelector('#ts', State.dom.panel); statusBar.style.cursor = 'pointer'; statusBar.onclick = () => renderSourceList(); State.eps = []; const playUrl = source.data.vod_play_url || ''; if (!playUrl) { setStatusText('该源无可播放地址', true); scheduleAutoHide(); return; } const episodes = EpisodeUtils.parseEpisodes(playUrl); if (!episodes.length) { setStatusText('该源剧集数据为空', true); scheduleAutoHide(); return; } let episodeIndex = 0; episodes.forEach(ep => { if (!ep.name && !ep.url) return; episodeIndex++; const name = ep.name || '集' + episodeIndex; const url = ep.url; State.eps.push({ name, url }); const btn = createElement('button', { textContent: name, className: 'tb' }); btn.dataset.url = url; btn.dataset.name = name; fragment.appendChild(btn); }); container.appendChild(fragment); autoResizePanel(container); /* 事件委托:单一 click 处理所有剧集按钮 */ container.onclick = (ev) => { const btn = ev.target.closest('.tb'); if (!btn || !container.contains(btn)) return; const url = btn.dataset.url; const name = btn.dataset.name; if (!url) return; const num = extractEpisodeNumber(name); if (num) State.curEp = num; if (clearFailed) { State.switchCount = 0; State.failedUrls.clear(); State.failedSources.clear(); } Player.start(url); }; /* 后台预解析可见剧集 URL(不阻塞 UI) */ prefetchEpisodeUrls(State.eps); if (!currentEp) { if (autoPlay && State.eps.length) { const best = EpisodeUtils.selectBestMovieVersion(State.eps); if (best) { const btn = querySelectorAll('.tb', container).find(b => b.dataset.url === best.url); if (btn) btn.classList.add('on'); setStatusText('正在播放: ' + best.name); if (clearFailed) { State.switchCount = 0; State.failedUrls.clear(); State.failedSources.clear(); } Player.start(best.url); } } scheduleAutoHide(); return; } const matched = EpisodeUtils.findMatchingEpisode(State.eps, currentEp); if (matched) { const btn = querySelectorAll('.tb', container).find(b => b.dataset.url === matched.url); if (btn) { btn.classList.add('on'); setTimeout(() => btn.scrollIntoView({ behavior: 'smooth', block: 'center' }), 100); if (autoPlay) { setStatusText('正在播放: ' + btn.textContent); if (clearFailed) { State.switchCount = 0; State.failedUrls.clear(); State.failedSources.clear(); } Player.start(btn.dataset.url); } else { setStatusText('已匹配剧集: ' + btn.textContent); } } } scheduleAutoHide(); }, /** * 高亮当前播放的剧集按钮 * @param {string} url */ highlightPlayingEpisode(url) { const container = querySelector('#tc', State.dom.panel); if (!container) return; querySelectorAll('.tb.on', container).forEach(b => b.classList.remove('on')); const btn = querySelectorAll('.tb', container).find(b => b.dataset.url === url); if (btn) { btn.classList.add('on'); btn.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, /** 切换加载动画 */ toggleLoading: (show) => State.dom.button.classList.toggle('loading', show), /** * 显示评估进度 * @param {number} current * @param {number} total * @param {string} apiName */ showEvalProgress(current, total, apiName) { const statusBar = querySelector('#ts', State.dom.panel); if (!statusBar) return; const percent = Math.round((current / total) * 100); const icon = current < total ? '⏳' : '✅'; let text = `${icon} 评估资源${State.curEp ? ' 第' + State.curEp + '集' : ''} (${current}/${total}) ${percent}%`; if (apiName) text += ` - ${apiName}`; statusBar.textContent = text; }, /** * 显示 Toast 提示 * @param {string} message * @param {string} type - info | success | warning | error */ showToast(message, type = 'info') { const toast = State.dom.toast; if (!toast) return; const icons = { info: 'ℹ️', success: '✅', warning: '⚠️', error: '❌' }; toast.textContent = (icons[type] || 'ℹ️') + ' ' + message; toast.classList.add('show'); if (type !== 'warning') { setTimeout(() => toast.classList.remove('show'), 3000); } }, hideToast() { const toast = State.dom.toast; if (toast) toast.classList.remove('show'); } }; /* ---- UI 辅助函数 ---- */ /** * 设置状态栏文本 * @param {string} text * @param {boolean} isError - 是否为错误样式 */ function setStatusText(text, isError = false) { const statusBar = querySelector('#ts', State.dom.panel); if (!statusBar) return; statusBar.textContent = text; statusBar.style.cssText = isError ? 'color:#fca5a5;background:rgba(90,79,207,.2);border-color:rgba(90,79,207,.25)' : ''; } /** 根据按钮位置自动调整面板展开方向(避免溢出右侧) */ function flipPanel() { const rect = State.dom.container.getBoundingClientRect(); if (rect.right + 460 > innerWidth) { State.dom.panel.style.left = 'auto'; State.dom.panel.style.right = (innerWidth - rect.left + 8) + 'px'; } else { State.dom.panel.style.left = '44px'; State.dom.panel.style.right = 'auto'; } } /** 显示面板 */ function showPanel() { State.dom.panel.style.display = 'flex'; State.panelOpen = true; if (State.curUrl) { UI.highlightPlayingEpisode(State.curUrl); const ep = State.eps.find(e => e.url === State.curUrl); if (ep) setStatusText('已匹配剧集: ' + ep.name); } } /** 隐藏面板 */ function hidePanel() { State.dom.panel.style.display = 'none'; State.panelOpen = false; clearAllTimers(); } /** 安排面板自动隐藏(鼠标未悬停时) */ function scheduleAutoHide() { if (!State.panelOpen) return; clearTimer('eg'); if (State.dom.panel.matches(':hover')) return; State.timers.eg = setTimeout(() => hidePanel(), CONFIG.PANEL_EPISODE_GRACE_PERIOD); } /** * 根据按钮内容自动调整面板宽度 * @param {HTMLElement} container */ function autoResizePanel(container) { setTimeout(() => { if (!State.panelOpen || !container) return; const buttons = querySelectorAll('.tb', container); if (!buttons.length) return; let maxButtonWidth = 0; for (const btn of buttons) { const w = btn.scrollWidth + 6; if (w > maxButtonWidth) maxButtonWidth = w; } const needed = Math.max(maxButtonWidth * 2 + 20, 170); const available = Math.min(420, innerWidth - State.dom.container.getBoundingClientRect().right - 20); State.dom.panel.style.width = Math.min(needed, available) + 'px'; }, 10); } /** * 渲染源列表 * 优化点:使用事件委托 */ function renderSourceList() { State.dom.panel.style.cssText = 'display:flex;flex-direction:column;width:210px'; flipPanel(); State.dom.panel.classList.add('sl-open'); State.dom.panel.innerHTML = '
'; setStatusText('共 ' + State.cache.results.length + ' 个资源', false); const container = querySelector('#tc', State.dom.panel); const fragment = document.createDocumentFragment(); State.cache.results.forEach(result => { const playUrl = result.data.vod_play_url || ''; const count = EpisodeUtils.countEpisodes(playUrl); const btn = createElement('button', { textContent: `${result.name} (${count}集)`, className: 'tb' }); btn.dataset.n = result.name; if (result.name === State.activeName) btn.classList.add('on'); fragment.appendChild(btn); }); container.appendChild(fragment); /* 事件委托 */ container.onclick = (ev) => { const btn = ev.target.closest('.tb'); if (!btn || !container.contains(btn)) return; const name = btn.dataset.n; if (!name) return; const result = State.cache.results.find(r => r.name === name); if (!result) return; State.activeName = name; UI.showEpisodeList(result, false, State.curEp); }; } /* ============================================================== * 模块 12:URL 预解析(新增) * 在剧集列表渲染后后台并发解析所有 URL,存入 UrlCache * 点击剧集时若命中缓存则跳过 resolveUrl 网络请求,实现秒开 * ============================================================== */ /** * 后台批量预解析剧集 URL * 仅解析非直链(m3u8/mp4/flv)的 URL * @param {Array<{name:string,url:string}>} episodes */ function prefetchEpisodeUrls(episodes) { if (!episodes || !episodes.length) return; const tasks = episodes .filter(ep => ep.url && !RE_MEDIA_EXT.test(ep.url) && !UrlCache.get(ep.url)) .map(ep => ep.url); if (!tasks.length) return; /* 分批并发解析,避免一次性发太多请求 */ const batchSize = CONFIG.PREFETCH_BATCH_SIZE; let index = 0; const runBatch = async () => { const batch = tasks.slice(index, index + batchSize); index += batchSize; if (!batch.length) return; await Promise.all(batch.map(async (url) => { try { const resolved = await Utils.resolveUrl(url); if (resolved && resolved !== url) { UrlCache.set(url, resolved); } } catch (e) { /* ignore prefetch errors */ } })); /* 继续下一批 */ if (index < tasks.length && !State.closed) { if (typeof window.requestIdleCallback === 'function') { window.requestIdleCallback(runBatch); } else { setTimeout(runBatch, 50); } } }; runBatch(); } /* ============================================================== * 模块 13:搜索模块 * 并发搜索所有 API 源,评估质量,自动播放首个匹配源 * ============================================================== */ const Search = { /** 启动搜索流程 */ async start() { clearAllTimers(); const panel = State.dom.panel; panel.style.display = 'flex'; panel.style.flexDirection = 'row'; panel.style.alignItems = 'center'; panel.style.justifyContent = 'center'; panel.style.padding = '8px 15px'; panel.style.width = 'auto'; panel.style.top = '50%'; panel.style.transform = 'translateY(-50%)'; panel.innerHTML = '
'; UI.toggleLoading(true); const title = Utils.extractTitle(); if (!title) { setStatusText('无法获取视频标题', true); UI.toggleLoading(false); return; } const currentEp = await Utils.detectCurrentEpisode(); /* 命中持久化缓存则直接展示 */ if (!State.isPageLoad) { const cacheKey = title + '_' + (currentEp || 'any'); const cached = SearchCache.get(cacheKey); if (cached && cached.length) { setStatusText('命中缓存,共 ' + cached.length + ' 个资源'); State.cache = { key: location.href, results: cached }; State._savedCache = null; State.activeName = null; State.firstAuto = false; State.closed = false; State.switchCount = 0; State.failedUrls.clear(); State.failedSources.clear(); const bestSource = cached.find(r => r.score >= 50); if (bestSource) { State.activeName = bestSource.name; const episodes = EpisodeUtils.parseEpisodes(bestSource.data.vod_play_url); const matched = EpisodeUtils.findMatchingEpisode(episodes, currentEp); if (matched) { UI.showEpisodeList(bestSource, true, currentEp); hidePanel(); } else { UI.showEpisodeList(bestSource, false, currentEp); showPanel(); } } else { showPanel(); renderSourceList(); } UI.toggleLoading(false); return; } } State.isPageLoad = false; setStatusText('评估资源' + (currentEp ? ' 第' + currentEp + '集' : '') + ' (0/' + UNIQUE_APIS.length + ')'); State.cache = { key: location.href, results: [] }; State._savedCache = null; State.activeName = null; State.firstAuto = false; State.failedUrls = new Set(); State.closed = false; const searchId = Date.now(); State.searchId = searchId; State.curEp = currentEp; setTimeout(() => { if (State.searchId !== searchId) return; this._searchAll(title, currentEp, searchId); }, 100); }, /** * 并发搜索所有 API * @param {string} title - 视频标题 * @param {string|null} currentEp - 当前集数 * @param {number} searchId - 搜索会话 ID */ async _searchAll(title, currentEp, searchId) { let completedCount = 0; /** * 评估单个 API 源 * @param {Object} api */ const evaluateApi = async (api) => { if (State.closed || State.searchId !== searchId) return; const result = await this._queryApi(api, title); completedCount++; if (State.panelOpen) UI.showEvalProgress(completedCount, UNIQUE_APIS.length, result ? result.name : null); if (!result) { ApiStats.recordFail(api.name); return; } ApiStats.recordSuccess(api.name, result.latency); if (State.closed || State.searchId !== searchId) return; const episodes = EpisodeUtils.parseEpisodes(result.data.vod_play_url); if (!episodes.length) return; let targetUrl = null, isMatch = false, isMovie = false; const targetNum = currentEp ? String(parseInt(currentEp, 10)) : null; const bestEp = EpisodeUtils.selectBestMovieVersion(episodes); if (bestEp && bestEp.url) { isMovie = true; targetUrl = bestEp.url; } else if (targetNum) { const matched = EpisodeUtils.findMatchingEpisode(episodes, targetNum); if (matched) { targetUrl = matched.url; isMatch = true; } } else if (episodes.length) { targetUrl = episodes[0].url; } if (!targetUrl) return; const evaluation = await Utils.evaluateSource(targetUrl); if (!evaluation) { addResult({ ...result, score: 50, resolution: 0, latency: 0 }); return; } const finalResult = { ...result, score: Math.max(0, Math.min(100, evaluation.score)), resolution: evaluation.resolution, latency: evaluation.latency, evaluatedUrl: evaluation.url }; const shouldAutoPlay = isMovie || isMatch; if (!State.firstAuto && shouldAutoPlay) { State.firstAuto = true; State.activeName = finalResult.name; State.switchCount = 0; State.failedUrls.clear(); UI.showEpisodeList(finalResult, true, isMovie ? null : State.curEp); hidePanel(); } addResult(finalResult); }; /** * 添加搜索结果(去重 + 按评分排序) * @param {Object} result */ function addResult(result) { const existingIndex = State.cache.results.findIndex(x => x.name === result.name); if (existingIndex > -1) State.cache.results[existingIndex] = result; else State.cache.results.push(result); State.cache.results.sort((a, b) => b.score - a.score); UI.addSourceButton(result); } /* 优先源先评估,提升首屏可用速度 */ const priorityApis = UNIQUE_APIS.filter(a => PRIORITY_SET.has(a.shortName)); const normalApis = UNIQUE_APIS.filter(a => !PRIORITY_SET.has(a.shortName)); await Utils.pool(CONFIG.PRIORITY_CONCURRENCY, priorityApis, evaluateApi); Utils.pool(CONFIG.SEARCH_CONCURRENCY, normalApis, evaluateApi).then(() => { if (State.searchId !== searchId || State.closed) return; const available = State.cache.results.filter(r => r.score >= 10); UI.toggleLoading(false); if (available.length > 0) { State.totalSources = available.length; UI.showToast('找到 ' + available.length + ' 个可用源', 'success'); const cacheKey = title + '_' + (currentEp || 'any'); SearchCache.set(cacheKey, State.cache.results); } }); }, /** * 查询单个 API:列表接口 → 详情接口 → 直接搜索 * @param {Object} api - API 源对象 * @param {string} title - 搜索标题 * @returns {Promise} */ async _queryApi(api, title) { try { const encodedTitle = encodeURIComponent(title); const listResult = await Utils.httpGet('ac=list&wd=' + encodedTitle, api); const items = listResult.data?.list; if (items && items.length > 0) { const firstItem = items[0]; if (firstItem.vod_play_url) { return { name: api.name, data: firstItem, latency: listResult.latency }; } /* 列表接口无播放地址,尝试详情接口 */ try { const detailResult = await Utils.httpGet('ac=detail&ids=' + firstItem.vod_id, api); const detailData = detailResult.data?.list?.[0]; if (detailData?.vod_play_url) { return { name: api.name, data: detailData, latency: listResult.latency + detailResult.latency }; } } catch (e) { /* 详情接口失败不影响后续 */ } } /* 直接搜索详情 */ try { const searchResult = await Utils.httpGet('ac=detail&wd=' + encodedTitle, api); if (searchResult.data?.list?.[0]?.vod_play_url) { return { name: api.name, data: searchResult.data.list[0], latency: searchResult.latency }; } } catch (e) { /* ignore */ } return null; } catch (e) { return null; } } }; /* ============================================================== * 模块 14:播放器模块(核心优化:持久化 iframe) * * 原实现:每次切换剧集都重建 iframe.srcdoc,导致 hls.js (~150KB) * 与 artplayer.js (~200KB) 重新下载/解析/执行,耗时 300~1200ms * * 新实现: * 1. 首次播放时通过 srcdoc 注入含 hls.js + artplayer 的 HTML, * iframe 内创建一个持久化的 Artplayer 实例 * 2. 后续切换 URL 通过 postMessage 通知 iframe, * iframe 内调用 art.switchUrl() 即时切换,省去脚本重复加载 * 3. iframe 内监听消息:tm_switch_url / tm_pause_player / tm_destroy_player * ============================================================== */ const Player = { /** 播放器 iframe 是否已初始化(含 artplayer 实例) */ _initialized: false, /** * 初始化持久化播放器 iframe * 仅执行一次,后续切换 URL 通过 postMessage */ init() { if (State.closed) return; if (this._initialized) return; /* 注入播放器 HTML(包含 hls.js + artplayer + 消息监听) */ State.dom.iframe.srcdoc = buildPlayerHtml(); this._initialized = true; State.dom.iframe.onload = () => { if (State.closed) return; State.playerReady = true; /* 若有待播放 URL,立即切换 */ if (State.pendingUrl) { this._switchUrl(State.pendingUrl); State.pendingUrl = null; } setTimeout(() => this.reposition(), 200); }; this.reposition(0); }, /** * 调整播放器覆盖层位置,对齐原始播放器 * @param {number} attempt - 重试次数(播放器元素可能延迟出现) */ reposition(attempt = 0) { if (State.closed || attempt > 12) return; let rect = null; if (!State.hiddenEl) State.hiddenEl = Utils.findPlayerElement(); if (!State.hiddenEl) { const video = document.querySelector('video'); if (video) { let parent = video.parentElement; while (parent && parent.tagName !== 'BODY' && parent.offsetHeight < 300) { parent = parent.parentElement; } if (parent && parent.offsetHeight >= 300) { State.hiddenEl = parent; State.hiddenEl.style.opacity = '0'; } } } if (State.hiddenEl) { try { rect = State.hiddenEl.getBoundingClientRect(); } catch (e) { /* ignore */ } } if (rect && rect.width > 200 && rect.height > 100) { State.dom.overlay.style.cssText = `position:absolute;top:${rect.top + scrollY}px;left:${rect.left}px;width:${rect.width}px;height:${rect.height}px;display:block;z-index:2147483646`; } else if (State.curUrl && attempt > 6) { State.dom.overlay.style.cssText = 'position:fixed;top:50%;left:50%;width:80%;height:80%;transform:translate(-50%,-50%);display:block;z-index:2147483646'; } else if (attempt <= 12) { requestAnimationFrame(() => this.reposition(attempt + 1)); } }, /** * 开始播放指定 URL * 优化流程: * 1. 优先查 UrlCache,命中则跳过 resolveUrl 网络请求 * 2. 若 iframe 未就绪,先 init() 并缓存 URL * 3. iframe 就绪后 postMessage 切换 URL(无需重建 iframe) * 4. 启动卡顿检测与下一集预加载 * @param {string} url - 原始 URL(可能需要解析) */ async start(url) { if (State.closed) return; clearAllTimers(); hidePanel(); State.curUrl = url; UI.highlightPlayingEpisode(url); State.playing = true; this._pauseOriginalPlayer(); this.reposition(0); State.dom.overlay.style.display = 'block'; UI.showToast('解析播放地址中...', 'info'); /* 优先命中 URL 缓存(秒开关键) */ let resolvedUrl = UrlCache.get(url); if (!resolvedUrl) { resolvedUrl = await Utils.resolveUrl(url); if (resolvedUrl && resolvedUrl !== url) { UrlCache.set(url, resolvedUrl); } } /* 确保持久化 iframe 已初始化 */ if (!this._initialized) { State.pendingUrl = resolvedUrl; this.init(); } else if (State.playerReady) { this._switchUrl(resolvedUrl); } else { /* iframe 加载中,缓存待播放 URL */ State.pendingUrl = resolvedUrl; } /* 预加载下一集 URL(连播秒切) */ if (CONFIG.PREFETCH_NEXT_EPISODE) { this._prefetchNextEpisode(); } }, /** * 通过 postMessage 切换播放 URL(无需重建 iframe) * @param {string} url */ _switchUrl(url) { if (State.closed || !url) return; clearTimer('sk'); State.dom.iframe.contentWindow?.postMessage({ type: CONFIG.MESSAGES.SWITCH_URL, url: url }, '*'); /* 启动卡顿检测:超时未播放成功则切源 */ State.stuckPending = true; State.timers.sk = setTimeout(() => { if (State.stuckPending && !State.closed) { State.switching = false; this._switchToNextSource(); } }, CONFIG.STUCK_CHECK_TIMEOUT); }, /** * 预加载下一集 URL * 在当前播放期间后台解析下一集,连播时直接命中缓存 */ _prefetchNextEpisode() { try { if (!State.curUrl || !State.eps.length) return; const currentIndex = State.eps.findIndex(e => e.url === State.curUrl); if (currentIndex < 0 || currentIndex >= State.eps.length - 1) return; const nextEp = State.eps[currentIndex + 1]; if (!nextEp || !nextEp.url) return; if (RE_MEDIA_EXT.test(nextEp.url) || UrlCache.get(nextEp.url)) return; /* 后台解析,不阻塞当前播放 */ Utils.resolveUrl(nextEp.url).then(resolved => { if (resolved && resolved !== nextEp.url) { UrlCache.set(nextEp.url, resolved); } }).catch(() => { /* ignore */ }); } catch (e) { /* ignore */ } }, /** * 自动切换到下一个可用源 * 当播放卡顿或失败时遍历其他源 */ _switchToNextSource() { if (State.switching) return; State.switching = true; try { if (!State.cache.results.length) { State.switching = false; return; } State.stuckPending = false; clearTimer('sk'); UI.showToast('正在自动切换可用源\u2026', 'info'); State.switchCount++; State.failedUrls.add(State.curUrl); if (State.activeName) State.failedSources.add(State.activeName); const totalSources = State.cache.results.length; const failedCount = State.failedSources.size; const remaining = totalSources - failedCount; if (remaining <= 0) { UI.hideToast(); UI.showToast('已遍历所有可用源', 'error'); setStatusText('所有源均无法播放该集数,已尝试 ' + totalSources + ' 个源', true); this.close(); State.switching = false; return; } const targetEp = parseInt(State.curEp, 10); let found = false; for (const result of State.cache.results) { if (State.failedSources.has(result.name) || !result.data?.vod_play_url) continue; const episodes = EpisodeUtils.parseEpisodes(result.data.vod_play_url); const availableEp = episodes.find(ep => { if (!ep.url || State.failedUrls.has(ep.url)) return false; if (isNaN(targetEp)) return true; const num = extractEpisodeNumber(ep.name); return num && String(parseInt(num, 10)) === String(targetEp); }); if (availableEp) { State.activeName = result.name; found = true; UI.showToast(`切换到源: ${result.name} (${failedCount}/${totalSources})`, 'info'); UI.showEpisodeList(result, true, isNaN(targetEp) ? null : State.curEp, false); break; } } if (!found) { UI.hideToast(); UI.showToast(`无匹配集数源,已遍历所有源 (${totalSources}个)`, 'error'); setStatusText('无匹配集数源,已尝试 ' + totalSources + ' 个源', true); State.switching = false; } } catch (e) { State.switching = false; } }, /** * 处理 iframe 内发来的消息 * @param {MessageEvent} event */ onMessage(event) { if (State.closed || !event.data?.type) return; const msg = event.data; if (msg.type === CONFIG.MESSAGES.VIDEO_ENDED) { /* 自动播放下一集 */ if (!State.curUrl || !State.eps.length) return; const currentIndex = State.eps.findIndex(e => e.url === State.curUrl); if (currentIndex > -1 && currentIndex < State.eps.length - 1) { const next = State.eps[currentIndex + 1]; const num = extractEpisodeNumber(next.name); if (num) State.curEp = num; setTimeout(() => this.start(next.url), CONFIG.AUTOPLAY_NEXT_DELAY); } else { setTimeout(() => this.close(), 3000); } } else if (msg.type === CONFIG.MESSAGES.PLAY_SUCCESS) { /* 播放成功:取消卡顿检测 */ clearTimer('sk'); State.stuckPending = false; State.switching = false; UI.hideToast(); const ep = State.eps.find(e => e.url === State.curUrl); if (ep) UI.showToast('正在播放: ' + ep.name, 'success'); } else if (msg.type === CONFIG.MESSAGES.PLAY_ERROR) { /* 播放失败:提示并准备切源 */ clearTimer('sk'); State.stuckPending = false; State.switching = false; UI.showToast('播放失败,请手动选择其他源', 'warning'); } }, /** 关闭播放器 */ close() { UI.hideToast(); clearAllTimers(); UI.toggleLoading(false); State._savedCache = { key: State.cache.key, results: State.cache.results }; /* 通知 iframe 销毁播放器实例但保留脚本环境 */ if (this._initialized && State.dom.iframe.contentWindow) { State.dom.iframe.contentWindow.postMessage({ type: CONFIG.MESSAGES.DESTROY_PLAYER }, '*'); } State.dom.overlay.style.cssText = 'display:none;pointer-events:none'; if (State.hiddenEl) { State.hiddenEl.style.opacity = ''; State.hiddenEl = null; } State.curUrl = ''; State.firstAuto = true; State.playing = false; State.switchCount = 0; State.totalSources = 0; State.closed = true; State.playerReady = false; State.pendingUrl = null; this._initialized = false; Utils.clearCaches(); }, /** 暂停原始页面播放器 */ _pauseOriginalPlayer() { document.querySelectorAll('audio, video').forEach(media => { if (!media.paused) media.pause(); media.muted = true; }); State.hiddenEl = Utils.findPlayerElement(); if (State.hiddenEl) State.hiddenEl.style.opacity = '0'; } }; /* ============================================================== * 模块 15:播放器 HTML 模板(核心优化) * * 关键改进: * 1. hls.js + artplayer 仅加载一次(脚本标签在 ) * 2. 创建一个持久化的 Artplayer 实例,初始 URL 为空 * 3. 监听 postMessage('tm_switch_url'),调用 art.switchUrl() 即时切换 * 4. 切换时复用已加载的 hls.js 与 Artplayer,省去 300~1200ms 重复加载 * ============================================================== */ function buildPlayerHtml() { return `VIP Play