<?php/** * Tvheadend 扫描器 V9.5 - 手动输入省份版 * 修复:1. 下拉菜单改为手动输入;2. 自动解决中文乱码;3. 直接输出 M3U 源码 */set_time_limit(0);ini_set('memory_limit', '512M');if (isset($_GET['action']) && $_GET['action'] == 'scan') { header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('X-Accel-Buffering: no'); $data = json_decode(file_get_contents('php://input'), true); $apiKey = $data['key'] ?? ''; $query = $data['query'] ?? ''; $size = (int)($data['size'] ?? 10); // 1. 请求 Quake 接口 $ch = curl_init("https://quake.360.net/api/v3/search/quake_service"); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, ["X-QuakeToken: $apiKey", "Content-Type: application/json"]); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["query" => $query, "start" => 0, "size" => $size])); $response = curl_exec($ch); $result = json_decode($response, true); curl_close($ch); $items = $result['data'] ?? []; $targets = []; foreach ($items as $item) { if (isset($item['ip']) && isset($item['port'])) { $targets[] = $item['ip'] . ":" . $item['port']; } } $targets = array_unique($targets); // 2. 并发探测 $mh = curl_multi_init(); $ch_list = []; foreach ($targets as $addr) { $url = "http://$addr/playlist/channels"; $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 12); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_multi_add_handle($mh, $ch); $ch_list[$addr] = $ch; } $active = null; do { $mrc = curl_multi_exec($mh, $active); while ($done = curl_multi_info_read($mh)) { $ch = $done['handle']; $addr = array_search($ch, $ch_list); $content = curl_multi_getcontent($ch); $info = curl_getinfo($ch); if ($info['http_code'] == 200 && stripos($content, '#EXTM3U') !== false) { // 处理编码防止乱码 $encode = mb_detect_encoding($content, array("ASCII", "UTF-8", "GB2312", "GBK", "BIG5")); if ($encode !== "UTF-8") { $content = mb_convert_encoding($content, "UTF-8", $encode); } echo "####URL:http://$addr/playlist/channels\n"; echo "####M3U_DATA:" . base64_encode($content) . "\n"; } ob_flush(); flush(); curl_multi_remove_handle($mh, $ch); curl_close($ch); } } while ($active && $mrc == CURLM_OK); curl_multi_close($mh); exit;}?><!DOCTYPE html><html><head> <metacharset="UTF-8"> <metaname="viewport"content="width=device-width, initial-scale=1.0"> <title>Tvheadend 源码扫描 V9.5</title> <style> body { font-family: -apple-system, sans-serif; background: #f4f7f9; padding: 10px; margin: 0; } .card { background: #fff; border-radius: 12px; padding: 15px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-width: 900px; margin: auto; } input, button { width: 100%; padding: 12px; margin: 5px 0; border: 1px solid #ddd; border-radius: 8px; box-sizing: border-box; font-size: 15px; } .btn-primary { background: #007bff; color: white; border: none; font-weight: bold; cursor: pointer; height: 50px; margin-top: 10px; } .btn-copy { background: #28a745; color: white; border: none; font-weight: bold; width: auto; padding: 6px 15px; border-radius: 6px; font-size: 13px; cursor: pointer; display: none; } .results-header { display: none; justify-content: space-between; align-items: center; margin-top: 20px; padding-bottom: 10px; border-bottom: 2px solid #eee; } .res-card { background: #fff; border: 1px solid #e0e6ed; border-left: 6px solid #007bff; border-radius: 10px; margin-bottom: 20px; padding: 12px; } .res-link { display: block; color: #007bff; text-decoration: none; font-size: 14px; font-weight: bold; word-break: break-all; margin-bottom: 10px; } .m3u-content { background: #272822; color: #f8f8f2; padding: 12px; border-radius: 6px; font-family: monospace; font-size: 11px; white-space: pre; overflow: auto; max-height: 250px; line-height: 1.4; border: 1px solid #3e3e3e; } .label-text { font-size: 12px; color: #666; margin-left: 5px; margin-top: 10px; display: block; font-weight: bold; } #status { text-align: center; color: #007bff; font-weight: bold; margin: 10px 0; font-size: 14px; } </style></head><body> <divclass="card"> <h3style="text-align:center; margin:0 0 15px 0; color:#333;">📺 火锅版Tvheadend扫描</h3> <labelclass="label-text">Quake API KEY:</label> <inputtype="text"id="key"placeholder="在此输入 API KEY"> <divstyle="display:flex; gap:8px;"> <divstyle="flex:1;"> <labelclass="label-text">输入省份 (可选):</label> <inputtype="text"id="province_input"placeholder="如: 广东 / Hong Kong"> </div> <divstyle="flex:1;"> <labelclass="label-text">固定词:</label> <inputtype="text"id="query_keyword"value='title: "Tvheadend"'> </div> <divstyle="width: 70px;"> <labelclass="label-text">数量:</label> <inputtype="number"id="size"value="10"> </div> </div> <buttonid="btn"class="btn-primary"onclick="startScan()">立即开始抓取</button> <divid="status"></div> <divid="results-header"class="results-header"> <bstyle="font-size:15px; color: #333;">📊 辉煌结果:</b> <buttonid="copyBtn"class="btn-copy"onclick="copyAll()">复制所有有效链接</button> </div> <divid="results"></div> </div> <script> let allUrls = []; async function startScan() { const btn = document.getElementById('btn'); const status = document.getElementById('status'); const resultsDiv = document.getElementById('results'); const header = document.getElementById('results-header'); const copyBtn = document.getElementById('copyBtn'); const provValue = document.getElementById('province_input').value.trim(); const keyword = document.getElementById('query_keyword').value; // 构建查询语句 let finalQuery = provValue ? `province: "${provValue}" AND ${keyword}` : `country: "China" AND ${keyword}`; btn.disabled = true; allUrls = []; resultsDiv.innerHTML = ''; header.style.display = 'none'; status.innerText = '🚀 正在检索...'; try { const response = await fetch('?action=scan', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ key: document.getElementById('key').value, query: finalQuery, size: document.getElementById('size').value }) }); const reader = response.body.getReader(); const decoder = new TextDecoder(); let lastCard = null; while (true) { const {done, value} = await reader.read(); if (done) break; const text = decoder.decode(value); const lines = text.split('\n'); for (let line of lines) { if (line.startsWith('####URL:')) { header.style.display = 'flex'; copyBtn.style.display = 'block'; const url = line.replace('####URL:', '').trim(); allUrls.push(url); lastCard = document.createElement('div'); lastCard.className = 'res-card'; lastCard.innerHTML = `<a href="${url}" target="_blank" class="res-link">🔗 播放源: ${url}</a>`; resultsDiv.appendChild(lastCard); } else if (line.startsWith('####M3U_DATA:') && lastCard) { // 使用兼容中文的 Base64 解码 const m3uContent = decodeURIComponent(escape(atob(line.replace('####M3U_DATA:', '').trim()))); const codeBox = document.createElement('div'); codeBox.className = 'm3u-content'; codeBox.innerText = m3uContent; lastCard.appendChild(codeBox); } } } } catch (e) { status.innerText = '❌ 错误: ' + e; } finally { btn.disabled = false; status.innerText = allUrls.length > 0 ? '✅ 任务结束' : '未发现匹配的地址。'; } } function copyAll() { if (allUrls.length === 0) return; navigator.clipboard.writeText(allUrls.join('\n')).then(() => { const cb = document.getElementById('copyBtn'); cb.innerText = '✅ 已复制'; setTimeout(() => { cb.innerText = '复制所有有效链接'; }, 2000); }); } </script></body></html>