// ==UserScript== // @name XyPlayer 便携版 · 云播放聚合 // @namespace https://github.com/xyplayer-portable // @version 1.5.1 // @description XyPlayer 便携版:多资源站并行搜索影视(ArtPlayer+hls.js 播放 m3u8/mp4)、第三方 VIP 解析线路(iframe)、音乐解析(聚合音源搜索/320K直链/歌词同步)、剧集连播与历史记录;播放源排除 feifan。无需 PHP 服务器。 // @author WorkBuddy // @match *://*/* // @exclude *://*.google.com/* // @connect * // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @run-at document-idle // @noframes // @license MIT // ==/UserScript== (function () { 'use strict'; /* ====================================================================== * XyPlayer 便携版 * 原理:复刻 xyplayer-main/upload/video/class.yun.php 的云播放逻辑 * - 搜索: {api}?wd=关键词 → 苹果CMS JSON,list[] 含 vod_id * - 详情: {api}?ac=detail&ids=ID → vod_play_from / vod_play_url * - 分组: vod_play_note(默认$$$) 拆多播放源,# 拆集,名称$地址 拆条目 * - 播放: m3u8 → hls.js,mp4 → 原生 * 优势:GM_xmlhttpRequest 无跨域限制,完全替代 PHP 中转与缓存。 * ==================================================================== */ const SCRIPT_NAME = 'XyPlayer 便携版'; const STORE_SITES = 'xyp_sites_v1'; const STORE_HIST = 'xyp_history_v1'; const STORE_LAST = 'xyp_last_v1'; const STORE_JX = 'xyp_jx_v1'; const STORE_ADBLK = 'xyp_adblock_v1'; const STORE_STREAMAD = 'xyp_streamad_v1'; const STORE_EXCLUDED = 'xyp_excluded_v1'; const STORE_PROGRESS = 'xyp_progress_v1'; const STORE_RESUME = 'xyp_resume_v1'; const MAX_HISTORY = 60; // 默认资源站(苹果CMS 采集API)。2026-08-30 复核: // ffzy.tv / huyazy.com —— curl 返回标准 JSON,搜「庆余年」各 8 条,确定可用 // 量子 / collect.wolongzyw —— 历史验证可用 // 其余 9 个(yyzy/酷云/最大/新浪/OK/华为/锁你/淘片/wolongzy.com)—— 浏览器内能否搜到取决于各站 CF/指纹配置, // 搜不到的站会在并行搜索里自然失败,不影响其他站结果;用不起可在「资源站」设置里关掉。 // ⚠️ bdzy.com 已排除:实测返回劫持跳转脚本(hao123 跳转 ssl.hw301.xyz),疑似钓鱼站,不应作为搜索源。 const DEFAULT_SITES = [ { name: '非凡资源', api: 'http://www.ffzy.tv/api.php/provide/vod', on: true }, { name: '量子资源', api: 'https://cj.lziapi.com/api.php/provide/vod', on: true }, { name: '卧龙资源', api: 'https://collect.wolongzyw.com/api.php/provide/vod', on: true }, { name: 'yyzy资源', api: 'https://yzzy.tv/api.php/provide/vod', on: true }, { name: '酷云资源', api: 'https://2kuyun.com/api.php/provide/vod', on: true }, { name: '最大资源', api: 'https://zuidazy4.com/api.php/provide/vod', on: true }, { name: '新浪资源', api: 'https://xinlangzy.com/api.php/provide/vod', on: true }, { name: '虎牙资源', api: 'https://huyazy.com/api.php/provide/vod', on: true }, { name: 'OK资源', api: 'https://okzyw.cc/api.php/provide/vod', on: true }, { name: '华为资源', api: 'https://huawei8.live/api.php/provide/vod', on: true }, { name: '锁你资源', api: 'https://suonizy.net/api.php/provide/vod', on: true }, { name: '淘片资源', api: 'https://taopianzy.com/api.php/provide/vod', on: true }, { name: '卧龙资源(新)', api: 'https://wolongzy.com/api.php/provide/vod', on: true }, ]; // 播放源偏好过滤:只保留这些 flag(播放组) // 例:['ffm3u8'] 表示去掉 feifan、lz 等其他线路,只留 ffm3u8;空数组表示不过滤。 const PLAY_PREFERRED = []; // 默认排除的播放源 flag:这些 flag 会被过滤掉(用户讨厌 feifan 广告源) const PLAY_EXCLUDED_DEFAULT = ['feifan']; function getExcluded() { const v = GM_getValue(STORE_EXCLUDED); if (Array.isArray(v)) return v; return PLAY_EXCLUDED_DEFAULT.slice(); } function setExcluded(list) { GM_setValue(STORE_EXCLUDED, list); } // VIP 解析线路(移植 CONFIG["parse"]/jx_url:前缀 + 视频页URL,iframe 加载,2026-08-27 存活验证) const DEFAULT_JX = [ { name: '剧集线路', url: 'https://im1907.top/?jx=', on: true }, { name: '高清线路', url: 'https://jx.m3u8.tv/jiexi/?url=', on: true }, { name: '备用线路', url: 'https://jx.xmflv.com/?url=', on: true }, ]; // 视频平台域名 → 用于自动预填片名 const VIDEO_HOSTS = [ /(^|\.)v\.qq\.com$/i, /(^|\.)m\.v\.qq\.com$/i, /(^|\.)iqiyi\.com$/i, /(^|\.)youku\.com$/i, /(^|\.)mgtv\.com$/i, /(^|\.)sohu\.com$/i, /(^|\.)tv\.sohu\.com$/i, /(^|\.)bilibili\.com$/i, ]; /* ---------------------------- 存储工具 ---------------------------- */ function getSites() { const v = GM_getValue(STORE_SITES); if (Array.isArray(v) && v.length) return v; return JSON.parse(JSON.stringify(DEFAULT_SITES)); } function setSites(list) { GM_setValue(STORE_SITES, list); } function getJx() { const v = GM_getValue(STORE_JX); if (Array.isArray(v) && v.length) return v; return JSON.parse(JSON.stringify(DEFAULT_JX)); } function setJx(list) { GM_setValue(STORE_JX, list); } function getHistory() { try { return JSON.parse(GM_getValue(STORE_HIST) || '[]'); } catch (e) { return []; } } function pushHistory(item) { let h = getHistory().filter(x => !(x.site === item.site && x.id === item.id)); h.unshift(item); if (h.length > MAX_HISTORY) h = h.slice(0, MAX_HISTORY); GM_setValue(STORE_HIST, JSON.stringify(h)); } // 秒 -> mm:ss / h:mm:ss function fmtTime(s) { s = Math.max(0, Math.floor(s || 0)); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60; const p = (n) => (n < 10 ? '0' : '') + n; return h > 0 ? h + ':' + p(m) + ':' + p(ss) : p(m) + ':' + p(ss); } /* ----------------- 续播记忆(每集播放进度) ----------------- 结构:{ "site|id|group|ep": { pos, dur, ts } } pos/dur 为秒;ts 为最后更新时间,用于裁剪最旧记录。 */ function _progressAll() { try { return JSON.parse(GM_getValue(STORE_PROGRESS, '{}')) || {}; } catch (e) { return {}; } } function getProgress(key) { return _progressAll()[key] || null; } function setProgress(key, pos, dur) { const all = _progressAll(); all[key] = { pos: Math.floor(pos || 0), dur: Math.floor(dur || 0), ts: Date.now() }; let keys = Object.keys(all); if (keys.length > 300) { keys.sort((a, b) => (all[a].ts || 0) - (all[b].ts || 0)); delete all[keys[0]]; } GM_setValue(STORE_PROGRESS, JSON.stringify(all)); } function clearProgress(key) { const all = _progressAll(); if (all[key]) { delete all[key]; GM_setValue(STORE_PROGRESS, JSON.stringify(all)); } } /* ---------------------------- 网络请求 ---------------------------- */ function gmFetch(url, timeout = 15000) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, timeout, headers: { 'User-Agent': navigator.userAgent, 'Accept': '*/*' }, onload: (res) => { if (res.status >= 200 && res.status < 400) resolve(res.responseText); else reject(new Error('HTTP ' + res.status)); }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')), }); }); } async function searchSite(site, keyword, page = 1) { const url = site.api + '?wd=' + encodeURIComponent(keyword) + (page > 1 ? '&pg=' + page : ''); const text = await gmFetch(url); const json = JSON.parse(text); const list = Array.isArray(json.list) ? json.list : []; return list.map(v => ({ site: site.name, api: site.api, id: v.vod_id, title: v.vod_name || '未知', type: v.type_name || v.class || '', remarks: v.vod_remarks || '', pic: v.vod_pic || '', })).filter(v => v.id !== undefined && v.id !== ''); } async function fetchDetail(api, id) { const url = api.replace(/\/$/, '') + '?ac=detail&ids=' + encodeURIComponent(id); const text = await gmFetch(url); const json = JSON.parse(text); if (!json.list || !json.list.length) throw new Error('未找到剧集数据'); const v = json.list[0]; // 复刻 class.yun.php::getvideobyid 的分组解析 const note = v.vod_play_note || '$$$'; const froms = String(v.vod_play_from || '').split(note).filter(Boolean); const urls = String(v.vod_play_url || '').split(note); const groups0 = froms.map((flag, i) => { const eps = String(urls[i] || '').split('#').filter(Boolean).map(seg => { const parts = seg.split('$'); return { name: parts[0] || ('第' + (i + 1) + '集'), url: (parts[1] || '').trim() }; }).filter(e => e.url && /^https?:\/\//i.test(e.url)); return { flag, episodes: eps }; }).filter(g => g.episodes.length); // 播放源偏好过滤:先排除 EXCLUDED,再应用 PREFERRED const excluded = getExcluded().map(s => s.toLowerCase()).filter(Boolean); const preferred = PLAY_PREFERRED.map(s => s.toLowerCase()).filter(Boolean); let groups = groups0; if (excluded.length) { groups = groups.filter(g => !excluded.some(p => String(g.flag).toLowerCase().includes(p))); } if (preferred.length && groups.some(g => preferred.some(p => String(g.flag).toLowerCase().includes(p)))) { groups = groups.filter(g => preferred.some(p => String(g.flag).toLowerCase().includes(p))); } return { ...v, groups }; } /* ---------------------------- 播放器加载 ---------------------------- */ const CDN = { hls: 'https://cdn.jsdelivr.net/npm/hls.js@1.5.17/dist/hls.min.js', art: 'https://cdn.jsdelivr.net/npm/artplayer@5.2.3/dist/artplayer.js', }; const loaded = {}; function loadScript(src) { if (loaded[src] !== undefined) return loaded[src]; loaded[src] = new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = src; s.async = true; s.onload = () => resolve(true); s.onerror = () => reject(new Error('CDN 加载失败: ' + src)); (document.head || document.documentElement).appendChild(s); }); return loaded[src]; } function detectType(url) { const clean = url.split('#')[0].split('?')[0].toLowerCase(); if (clean.endsWith('.m3u8')) return 'hls'; if (/\.(mp4|webm|ogg)$/.test(clean)) return 'video'; return 'hls'; // 资源站基本都是 m3u8 直链,兜底按 hls 处理 } // ============ m3u8 流内贴片广告过滤 ============ // 原理:拦截 hls.js 对 manifest/level(m3u8 播放列表)的 XHR 响应,改写 playlist 文本, // 剔除广告 TS 片段(按 URI 关键词 / 短插播 discontinuity 块判定),让播放器直接“跳过”广告时段。 // 仅作用于 m3u8 流,不影响 mp4 直链;正常无广告的 playlist 原样返回。 function _adUriDomain(uri) { try { return new URL(uri, location.href).hostname.toLowerCase(); } catch (e) { return ''; } } // 广告 TS 片段 URI 黑名单(持续扩充:通用广告路径 + 国内常见赌博/贴片特征) const AD_URI_PATTERNS = [ /\/ads?[\/_.\-]/i, /\/advert/i, /\/preroll/i, /\/midroll/i, /\/postroll/i, /\/creative/i, /\/spot\b/i, /\/commercial/i, /\/promo/i, /\/trailer/i, /\/filler/i, /[_.-]ad[_.-]/i, /advertisement/i, /\/gg\//i, /\/guanggao\//i, /\/guangg\//i, /\/pian\//i, /\/pian\d/i, /801801|801080|801890|812801|806801|807801|808801|809801|800801|828801|818801/i, /xinguanying|xinpujing|pujing|dafa|dafabet|bet365|kaiyuan|jdb|cq9|pg|bbin|mg|pt|sg|sw|jili|fc|pp|ps|relax|hacksaw|netent|playngo|pragmatic|evolution/i, /slot|casino|macau|lottery|poker|fishing|arcade|bingo|sportsbook|jackpot|free.?spin|bonus/i, /laohuji|laohu|tiger|mahjong|baijiale|buyu|buqi|doudizhu|paijiu|shishicai|keno/i, /澳门|新葡京|威尼斯|金沙|永利|美高梅|皇冠|太阳城|银河|永利|赌场|老虎机|百家乐|棋牌|捕鱼|电竞|彩票|斗地主|麻将|胡了|爆奖|大满贯|免费旋转|热门/i ]; function _isAdUri(uri) { if (!uri || uri.charAt(0) === '#') return false; let path = uri, host = ''; try { const u = new URL(uri, location.href); path = u.pathname + u.search; host = u.hostname.toLowerCase(); } catch (e) {} const text = path + ' ' + host; return AD_URI_PATTERNS.some(rx => rx.test(text)); } // 返回 { text, removed, log } // 把 m3u8 文本解析为片段数组(含每段的起止时间、discontinuity 标记、域名、广告词命中) function parseM3u8Frags(text) { const lines = text.split(/\r?\n/); const frags = []; for (let i = 0; i < lines.length; i++) { const dl = /^#EXT-X-DISCONTINUITY/.test(lines[i]); const m = /^#EXTINF:([\d.]+)/.exec(lines[i]); if (!m) continue; let j = i + 1; while (j < lines.length && lines[j].startsWith('#') && !/^#EXTINF:/.test(lines[j])) j++; const uri = (j < lines.length && !lines[j].startsWith('#')) ? lines[j] : ''; frags.push({ start: i, uriLine: j, dur: parseFloat(m[1]) || 0, uri, ad: _isAdUri(uri), domain: _adUriDomain(uri), disc: dl }); } if (!frags.length) return { frags: [], lines, discLines: [] }; let acc = 0; frags.forEach(f => { f.t0 = acc; acc += f.dur; f.t1 = acc; }); const discLines = []; lines.forEach((l, idx) => { if (/^#EXT-X-DISCONTINUITY/.test(l)) discLines.push(idx); }); return { frags, lines, discLines }; } // 过滤 playlist 中的广告片段 // opts.manual: [{t0,t1}] 用户手动标记的时间区间(来自诊断面板),命中即删 function filterM3u8Playlist(text, opts) { opts = opts || {}; const manual = opts.manual || []; const parsed = parseM3u8Frags(text); const { frags, lines, discLines } = parsed; if (!frags.length) return { text, removed: 0, log: [], frags }; // 按 #EXT-X-DISCONTINUITY 切块 const edges = [-1].concat(discLines).concat(lines.length); const groups = []; for (let g = 0; g < edges.length - 1; g++) { const lo = edges[g] + 1, hi = edges[g + 1] - 1; const gs = frags.filter(f => f.start >= lo && f.start <= hi); if (gs.length) groups.push({ frags: gs, discBefore: edges[g], discAfter: edges[g + 1] < lines.length ? edges[g + 1] : -1 }); } // 主域名(剧集 CDN),用于识别整块来自不同广告服务器的插播 const domCount = {}; frags.forEach(f => { if (f.domain && !f.ad) domCount[f.domain] = (domCount[f.domain] || 0) + 1; }); const mainDomain = Object.keys(domCount).sort((a, b) => domCount[b] - domCount[a])[0] || ''; const avgDur = frags.reduce((s, f) => s + f.dur, 0) / frags.length || 0; const drop = new Set(); let removed = 0; const log = []; const fmtDur = (s) => (Math.round(s * 10) / 10) + 's'; const fmtTime = (s) => { s = Math.floor(s); const m = Math.floor(s / 60); const ss = s % 60; return (m < 10 ? '0' : '') + m + ':' + (ss < 10 ? '0' : '') + ss; }; const inManual = (f) => manual.some(rg => { const mid = (f.t0 + f.t1) / 2; return mid >= rg.t0 && mid <= rg.t1; }); const mark = (f, reason) => { for (let k = f.start; k <= f.uriLine; k++) drop.add(k); removed++; const tag = '[' + fmtTime(f.t0) + '-' + fmtTime(f.t1) + '] ' + reason; if (log.indexOf(tag) < 0) log.push(tag); }; groups.forEach(gp => { const adFrags = gp.frags.filter(f => f.ad); const totalDur = gp.frags.reduce((s, f) => s + f.dur, 0); const doms = new Set(gp.frags.map(f => f.domain).filter(Boolean)); const interior = gp.discBefore >= 0 && gp.discAfter >= 0; let isAd = false, reason = ''; if (adFrags.length > 0) { isAd = true; reason = 'URI广告词'; } // ① 中间 discontinuity 内的短插播块(放宽到 60s;赌博贴片常为 30~50s) else if (interior && gp.frags.length > 0 && gp.frags.length <= 16 && totalDur > 0 && totalDur < 60) { isAd = true; reason = '中间插播块(' + fmtDur(totalDur) + ')'; } // ② 整块来自不同广告服务器 else if (interior && doms.size === 1 && mainDomain && gp.frags.every(f => f.domain !== mainDomain)) { isAd = true; reason = '异域广告服务器'; } // ③ 孤立短片段簇:即使无 discontinuity,连续片段明显短于均值 else if (!isAd && gp.frags.length >= 1 && gp.frags.length <= 10 && totalDur > 0 && totalDur < 30 && gp.frags.every(f => f.dur < avgDur * 0.45)) { isAd = true; reason = '孤立短片段簇'; } // ④ 片头/片尾独立短块:整体很短、占全集比例极小、且片段偏短(很多资源站把贴片压在开头) else if (!isAd && !interior) { const t0 = gp.frags[0].t0, t1 = gp.frags[gp.frags.length - 1].t1; const total = frags[frags.length - 1].t1; const ratio = (t1 - t0) / (total || 1); const atEdge = t0 < 3 || t1 > total - 3; if (atEdge && totalDur < 60 && ratio < 0.06 && gp.frags.every(f => f.dur < avgDur * 0.6)) { isAd = true; reason = '片头/片尾短块(' + fmtDur(totalDur) + ')'; } } if (isAd) { gp.frags.forEach(f => mark(f, reason)); // 删除内部插播块后一并去掉两端 discontinuity 标记,避免留下空 discontinuity 破坏播放 if (gp.discBefore >= 0) drop.add(gp.discBefore); if (gp.discAfter >= 0) drop.add(gp.discAfter); } }); // 手动标记的时间区间(来自诊断面板)一律删除 frags.forEach(f => { if (inManual(f) && !drop.has(f.start)) mark(f, '手动标记'); }); if (!removed) return { text, removed: 0, log: [], frags }; return { text: lines.filter((_, idx) => !drop.has(idx)).join('\n'), removed, log, frags }; } // 包装 hls.js 默认 loader,在 manifest/level 响应层改写 playlist function makeAdFilterLoader(counter) { const Base = (window.Hls && Hls.DefaultConfig && Hls.DefaultConfig.loader) || null; return class AdFilterLoader { constructor(config) { this._loader = Base ? new Base(config) : null; } load(context, config, callbacks) { if (!this._loader) { return; } const orig = callbacks.onSuccess; if (context.type === 'manifest' || context.type === 'level') { callbacks.onSuccess = (response, stats, ctx) => { try { let data = response.data; if (typeof data !== 'string') { try { data = new TextDecoder('utf-8').decode(data); } catch (e2) { data = null; } } if (typeof data === 'string' && data.indexOf('#EXTINF') !== -1) { window.__xypRawPlaylist = data; // 缓存原始 playlist 供诊断面板使用 const _host = location.hostname || ''; let _myRanges = []; try { const _all = JSON.parse(GM_getValue('xyp_manual_ad_v1', '[]')); _myRanges = (_all.find(x => x.host === _host) || {}).ranges || []; } catch (e2) {} const r = filterM3u8Playlist(data, { manual: _myRanges }); if (r.removed > 0) { response.data = r.text; counter.removed += r.removed; try { console.log('[XyPlayer] m3u8 过滤 ' + r.removed + ' 个广告片段:' + r.log.join(' | ')); } catch (e2) {} } } } catch (e) { } orig(response, stats, ctx); }; } this._loader.load(context, config, callbacks); } abort() { try { this._loader.abort(); } catch (e) { } } destroy() { try { this._loader.destroy(); } catch (e) { } } }; } // ============ m3u8 流内广告诊断面板(v1.5.0)============ // 把用户手动勾选的片段时间区间按源 host 持久化,重载时由 loader 自动剔除 function saveManualRange(host, range) { let all = []; try { all = JSON.parse(GM_getValue('xyp_manual_ad_v1', '[]')); } catch (e) {} let entry = all.find(x => x.host === host); if (!entry) { entry = { host, ranges: [] }; all.push(entry); } entry.ranges.push(range); entry.ranges.sort((a, b) => a.t0 - b.t0); const merged = []; entry.ranges.forEach(rg => { if (merged.length && rg.t0 <= merged[merged.length - 1].t1 + 1) merged[merged.length - 1].t1 = Math.max(merged[merged.length - 1].t1, rg.t1); else merged.push({ t0: rg.t0, t1: rg.t1 }); }); entry.ranges = merged; GM_setValue('xyp_manual_ad_v1', JSON.stringify(all)); } function openPlaylistDiag() { try { const raw = window.__xypRawPlaylist; if (!raw) { toast('请先播放一次该 m3u8 源,再打开诊断面板'); return; } const parsed = parseM3u8Frags(raw); const frags = parsed.frags; const discLines = parsed.discLines || []; const plLines = parsed.lines || []; if (!frags.length) { toast('该源不是分片 m3u8,无法诊断'); return; } const host = location.hostname || ''; let existing = []; try { const all = JSON.parse(GM_getValue('xyp_manual_ad_v1', '[]')); existing = (all.find(x => x.host === host) || {}).ranges || []; } catch (e) {} const inManual = (f) => existing.some(rg => { const mid = (f.t0 + f.t1) / 2; return mid >= rg.t0 && mid <= rg.t1; }); let box = document.getElementById('xyp-diag'); if (box) box.remove(); box = document.createElement('div'); box.id = 'xyp-diag'; box.style.cssText = 'position:fixed;right:0;top:0;bottom:0;width:440px;max-width:94vw;z-index:2147483647;' + 'background:rgba(16,18,26,.97);color:#e7eaf0;font:12px/1.5 -apple-system,Segoe UI,Microsoft YaHei,sans-serif;' + 'overflow:auto;box-shadow:-4px 0 24px rgba(0,0,0,.6);padding:12px 12px 70px;'; const fmtTime = (s) => { s = Math.floor(s); const m = Math.floor(s / 60); const ss = s % 60; return (m < 10 ? '0' : '') + m + ':' + (ss < 10 ? '0' : '') + ss; }; const total = frags[frags.length - 1].t1; const btn = 'background:#ff5f6d;color:#fff;border:none;border-radius:6px;padding:6px 10px;font-size:12px;cursor:pointer;'; const btn2 = 'background:#2b2f3a;color:#e7eaf0;border:none;border-radius:6px;padding:6px 10px;font-size:12px;cursor:pointer;'; let html = '