713 lines
31 KiB
HTML
713 lines
31 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-TW">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>TVBox 片源編輯器</title>
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Noto+Sans+TC:wght@300;400;500;700&display=swap');
|
||
|
||
:root {
|
||
--bg: #0b0d12;
|
||
--s1: #12151e;
|
||
--s2: #181c28;
|
||
--s3: #1f2336;
|
||
--s4: #262b40;
|
||
--border: #272c42;
|
||
--border2: #323856;
|
||
--accent: #4f7cff;
|
||
--accent2: #7c5cfc;
|
||
--success: #1fd6a0;
|
||
--warn: #f0a500;
|
||
--danger: #f04060;
|
||
--text: #dde1f0;
|
||
--text2: #7880a0;
|
||
--text3: #404668;
|
||
--mono: 'JetBrains Mono', monospace;
|
||
--sans: 'Noto Sans TC', sans-serif;
|
||
--r: 8px;
|
||
}
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
font-family: var(--sans);
|
||
font-size: 13px;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
|
||
|
||
/* HEADER */
|
||
header {
|
||
height: 50px;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 20px;
|
||
gap: 14px;
|
||
background: var(--s1);
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
}
|
||
.logo { font-family: var(--mono); font-size: 14px; font-weight: 700; color: var(--accent); display: flex; align-items: center; gap: 8px; }
|
||
.logo em { font-style: normal; font-weight: 400; color: var(--text2); font-size: 12px; }
|
||
.hspace { flex: 1; }
|
||
.merge-status { font-size: 11px; color: var(--text3); font-family: var(--mono); }
|
||
|
||
/* BUTTONS */
|
||
.btn {
|
||
border: none; border-radius: 6px; cursor: pointer;
|
||
font-family: var(--sans); font-size: 12px; font-weight: 500;
|
||
padding: 6px 14px; display: inline-flex; align-items: center; justify-content: center; gap: 5px;
|
||
transition: all .15s; white-space: nowrap;
|
||
}
|
||
.btn-primary { background: var(--accent); color: #fff; }
|
||
.btn-primary:hover { background: #3d6af0; }
|
||
.btn-ghost { background: transparent; color: var(--text2); border: 1px solid var(--border2); }
|
||
.btn-ghost:hover { background: var(--s3); color: var(--text); }
|
||
.btn-success { background: transparent; color: var(--success); border: 1px solid var(--success); }
|
||
.btn-success:hover { background: rgba(31,214,160,.1); }
|
||
.btn-success:disabled { opacity: .35; cursor: not-allowed; }
|
||
.btn-warn { background: transparent; color: var(--warn); border: 1px solid var(--warn); }
|
||
.btn-warn:hover { background: rgba(240,165,0,.1); }
|
||
.btn-warn:disabled { opacity: .35; cursor: not-allowed; }
|
||
.btn-danger { background: transparent; color: var(--danger); border: 1px solid var(--danger); }
|
||
.btn-danger:hover { background: rgba(240,64,96,.1); }
|
||
.btn-danger:disabled { opacity: .35; cursor: not-allowed; }
|
||
.btn-sm { font-size: 11px; padding: 4px 9px; }
|
||
|
||
/* LAYOUT */
|
||
.workspace { display: flex; flex: 1; overflow: hidden; }
|
||
.panel { display: flex; flex-direction: column; overflow: hidden; border-right: 1px solid var(--border); }
|
||
.panel:last-child { border-right: none; }
|
||
|
||
.phead {
|
||
padding: 10px 14px; border-bottom: 1px solid var(--border);
|
||
font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase;
|
||
color: var(--text2); display: flex; align-items: center; gap: 8px;
|
||
flex-shrink: 0; background: var(--s1);
|
||
}
|
||
.pill { background: var(--s4); color: var(--text3); font-size: 10px; font-family: var(--mono); padding: 1px 7px; border-radius: 10px; }
|
||
.pill.green { background: rgba(31,214,160,.12); color: var(--success); }
|
||
.pml { margin-left: auto; }
|
||
|
||
/* PANEL 1: JSON INPUT */
|
||
.panel-json { width: 280px; flex-shrink: 0; background: var(--s1); }
|
||
#jsonInput {
|
||
flex: 1; background: var(--bg); border: none; outline: none;
|
||
color: var(--text2); font-family: var(--mono); font-size: 11px;
|
||
line-height: 1.65; padding: 14px; resize: none; overflow-y: auto;
|
||
}
|
||
.json-footer { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; border-top: 1px solid var(--border); background: var(--s1); flex-shrink: 0; }
|
||
.btn-group { display: flex; gap: 8px; }
|
||
|
||
/* PANEL 2: FETCH */
|
||
.panel-fetch { width: 300px; flex-shrink: 0; background: var(--s2); }
|
||
.fetch-form { padding: 14px; display: flex; flex-direction: column; gap: 12px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||
.flabel { font-size: 11px; font-weight: 600; color: var(--text2); letter-spacing: .06em; margin-bottom: 4px; display: flex; align-items: center; gap: 6px; }
|
||
.fhint { font-size: 10px; color: var(--text3); font-weight: 400; }
|
||
.field { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 7px 10px; color: var(--text); font-family: var(--mono); font-size: 11.5px; outline: none; width: 100%; transition: border-color .15s; }
|
||
.field:focus { border-color: var(--accent); }
|
||
.field::placeholder { color: var(--text3); }
|
||
.field-row { display: flex; gap: 6px; }
|
||
.field-row .field { flex: 1; }
|
||
|
||
.progress-bar { height: 2px; background: var(--border); margin: 0 14px 6px; border-radius: 1px; overflow: hidden; display: none; flex-shrink: 0; }
|
||
.progress-bar.show { display: block; }
|
||
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent2)); border-radius: 1px; transition: width .3s; }
|
||
|
||
.fetch-log { flex: 1; overflow-y: auto; padding: 10px 12px; display: flex; flex-direction: column; gap: 4px; }
|
||
.log-item { font-family: var(--mono); font-size: 11px; padding: 5px 8px; border-radius: 5px; background: var(--s3); display: flex; align-items: flex-start; gap: 6px; animation: fin .2s ease; }
|
||
@keyframes fin { from{opacity:0;transform:translateY(3px)} to{opacity:1;transform:none} }
|
||
.li-ic { flex-shrink: 0; font-size: 12px; margin-top: 1px; }
|
||
.li-bd { flex: 1; line-height: 1.5; color: var(--text); }
|
||
.li-sub { color: var(--text3); font-size: 10px; display: block; word-break: break-all; }
|
||
.log-item.ok { border-left: 2px solid var(--success); } .log-item.ok .li-ic { color: var(--success); }
|
||
.log-item.err { border-left: 2px solid var(--danger); } .log-item.err .li-ic { color: var(--danger); }
|
||
.log-item.info{ border-left: 2px solid var(--accent); } .log-item.info .li-ic { color: var(--accent); }
|
||
.log-item.warn{ border-left: 2px solid var(--warn); } .log-item.warn .li-ic { color: var(--warn); }
|
||
|
||
/* PANEL 3: EDITOR (TABS & MASTER-DETAIL) */
|
||
.panel-editor { flex: 1; background: var(--bg); display: flex; flex-direction: column; }
|
||
.tab-btn { font-size: 11px; padding: 4px 10px; border-radius: 5px; border: 1px solid transparent; cursor: pointer; background: transparent; color: var(--text2); font-family: var(--sans); transition: all .15s; }
|
||
.tab-btn.active { background: var(--s3); color: var(--text); border-color: var(--border2); }
|
||
.tab-btn:hover:not(.active) { color: var(--text); }
|
||
|
||
#editorBody { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||
|
||
/* MASTER-DETAIL VIEW */
|
||
.editor-wrap { display: flex; flex: 1; overflow: hidden; }
|
||
.editor-sidebar { width: 160px; border-right: 1px solid var(--border); background: var(--s1); overflow-y: auto; display: flex; flex-direction: column; flex-shrink: 0; }
|
||
.s-item { padding: 12px 14px; font-size: 12px; cursor: pointer; border-bottom: 1px solid var(--border); color: var(--text2); display: flex; flex-direction: column; gap: 4px; transition: background .15s; }
|
||
.s-item:hover { background: var(--s3); color: var(--text); }
|
||
.s-item.active { background: rgba(79,124,255,.1); color: #fff; border-left: 3px solid var(--accent); padding-left: 11px; }
|
||
.s-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 500; }
|
||
.s-meta { font-size: 10px; font-family: var(--mono); color: var(--text3); }
|
||
.s-meta.has-cat { color: var(--success); }
|
||
|
||
.editor-main { flex: 1; display: flex; flex-direction: column; background: var(--bg); overflow: hidden; }
|
||
.editor-toolbar { padding: 8px 14px; background: var(--s2); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; gap: 10px;}
|
||
.editor-textarea { flex: 1; background: transparent; border: none; outline: none; color: var(--text); font-family: var(--mono); font-size: 12px; line-height: 1.6; padding: 14px; resize: none; overflow-y: auto; white-space: pre; }
|
||
|
||
/* EMPTY */
|
||
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; color: var(--text3); text-align: center; padding: 40px; }
|
||
.empty .eico { font-size: 36px; }
|
||
|
||
/* TOAST */
|
||
#toast { position: fixed; bottom: 20px; right: 20px; background: var(--s3); border: 1px solid var(--border2); border-radius: 8px; padding: 10px 16px; font-size: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.5); opacity: 0; transform: translateY(6px); transition: all .2s; pointer-events: none; z-index: 1000; max-width: 300px; }
|
||
#toast.show { opacity: 1; transform: translateY(0); }
|
||
#toast.ok { border-color: var(--success); color: var(--success); }
|
||
#toast.err { border-color: var(--danger); color: var(--danger); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<div class="logo">📺 TVBox <em>片源編輯器</em></div>
|
||
<div class="hspace"></div>
|
||
<span class="merge-status" id="mergeStatus"></span>
|
||
<button class="btn btn-ghost" onclick="copyOutput()">📋 複製 JSON</button>
|
||
<button class="btn btn-primary" onclick="downloadOutput()">⬇ 匯出 JSON</button>
|
||
</header>
|
||
|
||
<div class="workspace">
|
||
|
||
<div class="panel panel-json">
|
||
<div class="phead">
|
||
<span>① 原始 JSON</span>
|
||
<span class="pill" id="rawPill">未載入</span>
|
||
</div>
|
||
<textarea id="jsonInput" placeholder="貼入 TVBox JSON… 支援帶 // 注釋的格式 貼入後自動解析,或按 Ctrl+Enter"></textarea>
|
||
|
||
<div class="json-footer">
|
||
<input type="file" id="fileInput" accept=".json,.txt" style="display:none">
|
||
<div class="btn-group">
|
||
<button class="btn btn-ghost" style="flex:1" onclick="document.getElementById('fileInput').click()">📁 選擇檔案</button>
|
||
<button class="btn btn-primary" style="flex:1" onclick="loadJSON()">✦ 解析載入</button>
|
||
</div>
|
||
<button class="btn btn-ghost" onclick="clearAll()">清除全部</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel panel-fetch">
|
||
<div class="phead"><span>② 抓取分類並合併</span></div>
|
||
|
||
<div class="fetch-form">
|
||
<div>
|
||
<div class="flabel">API 網址 <span class="fhint">苹果CMS 格式</span></div>
|
||
<div class="field-row" style="margin-bottom:6px">
|
||
<input class="field" id="apiUrl" placeholder="https://example.com/api.php/provide/vod/" onkeydown="if(event.key==='Enter')fetchOne()">
|
||
</div>
|
||
<div class="field-row">
|
||
<input class="field" id="mergeKey" placeholder="site key(留空自動比對)">
|
||
<input class="field" id="mergeName" placeholder="名稱(新增時用)">
|
||
</div>
|
||
<div style="margin-top:6px">
|
||
<button class="btn btn-success" style="width:100%" onclick="fetchOne()" id="fetchOneBtn">↻ 抓取並合併</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="flabel">CORS Proxy</div>
|
||
<div class="field-row" style="flex-wrap:wrap;gap:5px">
|
||
<input class="field" id="proxyInput" value="https://corsproxy.io/?" style="flex:1;min-width:0">
|
||
</div>
|
||
<div style="display:flex;gap:5px;margin-top:5px">
|
||
<button class="btn btn-ghost btn-sm" style="flex:1" onclick="setProxy('https://corsproxy.io/?')">corsproxy</button>
|
||
<button class="btn btn-ghost btn-sm" style="flex:1" onclick="setProxy('https://api.allorigins.win/raw?url=')">allorigins</button>
|
||
<button class="btn btn-ghost btn-sm" style="flex:1" onclick="setProxy('')">直連</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display:flex;gap:6px">
|
||
<button class="btn btn-warn" style="flex:1" id="fetchAllBtn" onclick="fetchAllSites()">⚡ 批量抓取</button>
|
||
<button class="btn btn-danger" style="flex:1" id="removeInvalidBtn" onclick="removeInvalidSites()">🗑️ 清理失效 API</button>
|
||
</div>
|
||
<div style="margin-top:2px; display:none" id="stopWrap">
|
||
<button class="btn btn-ghost btn-sm" style="width:100%" id="stopBtn" onclick="stopFetch()">停止操作</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="progress-bar" id="progressBar"><div class="progress-fill" id="progressFill" style="width:0%"></div></div>
|
||
<div class="fetch-log" id="fetchLog"><div style="color:var(--text3);font-size:11px;font-family:var(--mono);padding:4px">等待操作…</div></div>
|
||
</div>
|
||
|
||
<div class="panel panel-editor">
|
||
<div class="phead">
|
||
<span>③ 編輯區</span>
|
||
<div class="pml" style="display:flex; gap:8px; align-items:center;">
|
||
<button class="btn btn-danger btn-sm" onclick="removeNonType1()" title="移除所有非 type: 1 的站點 (Sources)">🗑️ 移除非 Type 1</button>
|
||
<div style="width:1px; height:12px; background:var(--border2);"></div>
|
||
<div style="display:flex; gap:4px;">
|
||
<button class="tab-btn active" id="btn_view_tab" onclick="setViewMode('tab')">Tab 模式</button>
|
||
<button class="tab-btn" id="btn_view_source" onclick="setViewMode('source')">Source 模式</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="editorBody">
|
||
<div class="empty"><div class="eico">📭</div><p>請先載入 JSON 或開始抓取</p></div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
<div id="toast"></div>
|
||
|
||
<script>
|
||
// STATE
|
||
let tvbox = null;
|
||
let abortFlag = false;
|
||
|
||
// 視圖與資料模式
|
||
let viewMode = 'tab'; // 'tab' (清單), 'source' (完整原始碼)
|
||
let currentEditMode = 'sites'; // 'sites', 'parses'
|
||
let selectedItemIndex = 0;
|
||
|
||
// ── FILE UPLOAD ──
|
||
document.getElementById('fileInput').addEventListener('change', function(e) {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = function(evt) {
|
||
document.getElementById('jsonInput').value = evt.target.result;
|
||
loadJSON();
|
||
};
|
||
reader.readAsText(file);
|
||
e.target.value = '';
|
||
});
|
||
|
||
// ── LOAD JSON ──
|
||
function stripComments(s) { return s.split('\n').filter(l => !l.trim().startsWith('//')).join('\n'); }
|
||
function loadJSON() {
|
||
const raw = document.getElementById('jsonInput').value.trim();
|
||
if (!raw) { toast('沒有可解析的 JSON','err'); return; }
|
||
try {
|
||
tvbox = JSON.parse(stripComments(raw));
|
||
if (!tvbox.sites) tvbox.sites = [];
|
||
if (!tvbox.parses) tvbox.parses = [];
|
||
|
||
document.getElementById('rawPill').textContent = `${tvbox.sites.length}S / ${tvbox.parses.length}P`;
|
||
document.getElementById('rawPill').className = 'pill green';
|
||
|
||
selectedItemIndex = 0;
|
||
renderEditor(); updateStatus();
|
||
toast(`✓ 載入成功:${tvbox.sites.length} Sites`, 'ok');
|
||
} catch(e) { toast('解析失敗:' + e.message, 'err'); }
|
||
}
|
||
function clearAll() {
|
||
tvbox = null;
|
||
document.getElementById('jsonInput').value = '';
|
||
document.getElementById('rawPill').textContent = '未載入';
|
||
document.getElementById('rawPill').className = 'pill';
|
||
document.getElementById('mergeStatus').textContent = '';
|
||
clearLog();
|
||
renderEditor();
|
||
}
|
||
|
||
// ── BUILD URL ──
|
||
function buildClassUrl(api) {
|
||
let u = api.replace(/[?&]ac=(list|detail|videolist|video)\b/g, '').replace(/\/from\/[^/?&]*/g, '').replace(/\/+$/, '');
|
||
return u + (u.includes('?') ? '&' : '?') + 'ac=class';
|
||
}
|
||
function wrapProxy(url) {
|
||
const p = document.getElementById('proxyInput').value.trim();
|
||
return p ? p + encodeURIComponent(url) : url;
|
||
}
|
||
function getBaseApi(urlStr) {
|
||
if (!urlStr) return '';
|
||
try {
|
||
const u = new URL(urlStr); u.searchParams.delete('ac');
|
||
return u.toString().replace(/\/+$/, '');
|
||
} catch(e) { return urlStr.replace(/[?&]ac=[^&]*/g, '').replace(/[?&]$/, '').replace(/\/+$/, ''); }
|
||
}
|
||
|
||
// ── FETCH CATS ──
|
||
async function doFetch(apiUrl, timeoutMs = 13000) {
|
||
const final = wrapProxy(buildClassUrl(apiUrl));
|
||
const r = await fetch(final, { signal: AbortSignal.timeout(timeoutMs) });
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
const d = await r.json();
|
||
if (d.class && Array.isArray(d.class) && d.class.length) return d.class.map(c => c.type_name).filter(Boolean);
|
||
for (const k of ['list','data','result','categories']) {
|
||
if (Array.isArray(d[k]) && d[k].length && d[k][0]?.type_name) return d[k].map(c => c.type_name).filter(Boolean);
|
||
}
|
||
throw new Error('無法解析分類');
|
||
}
|
||
|
||
// ── MERGE ──
|
||
function mergeCategories(apiUrl, cats, key, name) {
|
||
let idx = -1;
|
||
if (key) idx = tvbox.sites.findIndex(s => s.key === key);
|
||
if (idx === -1) {
|
||
const targetBase = getBaseApi(apiUrl);
|
||
idx = tvbox.sites.findIndex(s => getBaseApi(s.api) === targetBase);
|
||
}
|
||
if (idx === -1) {
|
||
try {
|
||
const host = new URL(apiUrl).hostname;
|
||
idx = tvbox.sites.findIndex(s => s.api && new URL(s.api).hostname === host);
|
||
} catch(e) {}
|
||
}
|
||
if (idx !== -1) {
|
||
tvbox.sites[idx].categories = cats;
|
||
return { action: 'updated', site: tvbox.sites[idx], index: idx };
|
||
}
|
||
const newSite = {
|
||
key: key || (() => { try { return new URL(apiUrl).hostname; } catch(e) { return apiUrl; } })(),
|
||
name: name || (() => { try { return new URL(apiUrl).hostname; } catch(e) { return apiUrl; } })(),
|
||
type: 1, api: apiUrl, searchable: 1, quickSearch: 1, filterable: 1, categories: cats
|
||
};
|
||
tvbox.sites.push(newSite);
|
||
return { action: 'added', site: newSite, index: tvbox.sites.length - 1 };
|
||
}
|
||
|
||
// ── FETCH ONE ──
|
||
async function fetchOne() {
|
||
const apiUrl = document.getElementById('apiUrl').value.trim();
|
||
if (!apiUrl) { toast('請輸入 API 網址','err'); return; }
|
||
|
||
if (!tvbox) { tvbox = { sites: [], parses: [] }; toast('已自動建立新的 JSON 結構', 'info'); }
|
||
|
||
const key = document.getElementById('mergeKey').value.trim();
|
||
const name = document.getElementById('mergeName').value.trim();
|
||
const btn = document.getElementById('fetchOneBtn');
|
||
btn.disabled = true; btn.textContent = '抓取中…';
|
||
log('info','⟳', '抓取 ' + apiUrl);
|
||
|
||
try {
|
||
const cats = await doFetch(apiUrl);
|
||
const result = mergeCategories(apiUrl, cats, key, name);
|
||
const act = result.action === 'added' ? '新增' : '更新';
|
||
log('ok','✓', `${act}「${result.site.name}」→ ${cats.length} 個分類`, apiUrl);
|
||
|
||
if (viewMode === 'tab') {
|
||
switchEditMode('sites', false);
|
||
selectedItemIndex = result.index;
|
||
}
|
||
|
||
renderEditor(); updateStatus();
|
||
toast(`✓ ${act} ${cats.length} 個分類`, 'ok');
|
||
} catch(e) {
|
||
log('err','✗', e.message, apiUrl); toast('失敗:' + e.message, 'err');
|
||
} finally { btn.disabled = false; btn.textContent = '↻ 抓取並合併'; }
|
||
}
|
||
|
||
// ── FETCH ALL ──
|
||
async function fetchAllSites() {
|
||
if (!tvbox) { toast('尚未載入 JSON,無法批量抓取','err'); return; }
|
||
const httpSites = tvbox.sites.filter(s => s.api?.startsWith('http'));
|
||
if (!httpSites.length) { toast('沒有找到 HTTP API 的站點','err'); return; }
|
||
|
||
abortFlag = false;
|
||
document.getElementById('fetchAllBtn').disabled = true;
|
||
document.getElementById('removeInvalidBtn').disabled = true;
|
||
document.getElementById('stopWrap').style.display = 'block';
|
||
document.getElementById('progressBar').classList.add('show');
|
||
clearLog(); log('info','⚡', `批量抓取 ${httpSites.length} 個 Sites…`);
|
||
|
||
let done = 0, ok = 0, fail = 0;
|
||
for (const site of httpSites) {
|
||
if (abortFlag) { log('warn','⊘','已停止'); break; }
|
||
const lbl = site.name || site.key || site.api;
|
||
log('info','⟳', `[${done+1}/${httpSites.length}] ${lbl}`);
|
||
try {
|
||
const cats = await doFetch(site.api);
|
||
site.categories = cats; ok++;
|
||
log('ok','✓', `${lbl} → ${cats.length} 個分類`);
|
||
} catch(e) { fail++; log('err','✗', `${lbl}:${e.message}`); }
|
||
done++;
|
||
document.getElementById('progressFill').style.width = (done/httpSites.length*100) + '%';
|
||
scrollLog(); await sleep(700);
|
||
}
|
||
log(fail===0?'ok':'warn','■', `完成:${ok} 成功,${fail} 失敗`);
|
||
renderEditor(); updateStatus();
|
||
document.getElementById('fetchAllBtn').disabled = false;
|
||
document.getElementById('removeInvalidBtn').disabled = false;
|
||
document.getElementById('stopWrap').style.display = 'none';
|
||
setTimeout(() => document.getElementById('progressBar').classList.remove('show'), 2000);
|
||
toast(`批量完成:${ok} 成功,${fail} 失敗`, ok>0?'ok':'err');
|
||
}
|
||
|
||
// ── REMOVE INVALID API SITES ──
|
||
async function removeInvalidSites() {
|
||
if (!tvbox) { toast('尚未載入 JSON', 'err'); return; }
|
||
const httpSites = tvbox.sites.filter(s => s.api?.startsWith('http'));
|
||
if (!httpSites.length) { toast('沒有找到 HTTP API 的站點', 'err'); return; }
|
||
|
||
const msg = `警告:系統將測試 ${httpSites.length} 個 HTTP 站點,並「自動刪除」無法連線或失效的 API!\n\n如果您的 CORS Proxy 暫時失效,可能會導致所有站點被誤判刪除,建議先手動測試一個站點。\n\n確定要開始清理嗎?`;
|
||
if (!confirm(msg)) return;
|
||
|
||
abortFlag = false;
|
||
document.getElementById('fetchAllBtn').disabled = true;
|
||
document.getElementById('removeInvalidBtn').disabled = true;
|
||
document.getElementById('stopWrap').style.display = 'block';
|
||
document.getElementById('progressBar').classList.add('show');
|
||
clearLog();
|
||
log('warn', '🗑️', `開始檢測並清理失效站點 (共 ${httpSites.length} 個)…`);
|
||
|
||
let done = 0, ok = 0, fail = 0;
|
||
let validSites = [];
|
||
// 保留所有非 http 開頭的特殊來源 (例如 clan://, file://)
|
||
const nonHttpSites = tvbox.sites.filter(s => !s.api?.startsWith('http'));
|
||
|
||
for (const site of httpSites) {
|
||
if (abortFlag) {
|
||
log('warn','⊘','已中斷清理,將保留剩餘未檢測之站點。');
|
||
// 把剩下的加回去
|
||
const remainingIndex = httpSites.indexOf(site);
|
||
validSites = validSites.concat(httpSites.slice(remainingIndex));
|
||
break;
|
||
}
|
||
const lbl = site.name || site.key || site.api;
|
||
log('info','⟳', `[${done+1}/${httpSites.length}] 測試 ${lbl}`);
|
||
try {
|
||
// 這裡採用較短的 timeout(8秒),讓失效判定更快
|
||
const cats = await doFetch(site.api, 8000);
|
||
site.categories = cats;
|
||
validSites.push(site); // 測試成功,保留此站點
|
||
ok++;
|
||
log('ok','✓', `${lbl} 正常 (${cats.length} 分類)`);
|
||
} catch(e) {
|
||
fail++;
|
||
log('err','✗', `已移除 ${lbl}:${e.message}`);
|
||
}
|
||
done++;
|
||
document.getElementById('progressFill').style.width = (done/httpSites.length*100) + '%';
|
||
scrollLog(); await sleep(500);
|
||
}
|
||
|
||
// 將保留下來的合併寫回
|
||
tvbox.sites = [...nonHttpSites, ...validSites];
|
||
|
||
log(fail>0?'warn':'ok','■', `檢測完成:保留 ${ok} 個,自動移除了 ${fail} 個失效站點`);
|
||
renderEditor();
|
||
updateStatus();
|
||
document.getElementById('rawPill').textContent = `${tvbox.sites.length}S / ${tvbox.parses.length}P`;
|
||
|
||
document.getElementById('fetchAllBtn').disabled = false;
|
||
document.getElementById('removeInvalidBtn').disabled = false;
|
||
document.getElementById('stopWrap').style.display = 'none';
|
||
setTimeout(() => document.getElementById('progressBar').classList.remove('show'), 2000);
|
||
toast(`清理完成:移除了 ${fail} 個失效站點`, fail > 0 ? 'ok' : 'info');
|
||
}
|
||
|
||
function stopFetch() { abortFlag = true; document.getElementById('stopWrap').style.display = 'none'; }
|
||
|
||
// ── DATA TOOLS ──
|
||
function removeNonType1() {
|
||
if (!tvbox || !tvbox.sites) {
|
||
toast('尚未載入 JSON', 'err');
|
||
return;
|
||
}
|
||
const oldLen = tvbox.sites.length;
|
||
// 過濾保留 type 為 1 或 "1" 的來源
|
||
tvbox.sites = tvbox.sites.filter(s => s.type == 1);
|
||
const diff = oldLen - tvbox.sites.length;
|
||
|
||
toast(`✓ 已清理 ${diff} 個非 Type 1 站點`, 'ok');
|
||
|
||
document.getElementById('rawPill').textContent = `${tvbox.sites.length}S / ${tvbox.parses.length}P`;
|
||
updateStatus();
|
||
|
||
if (currentEditMode === 'sites' && selectedItemIndex >= tvbox.sites.length) {
|
||
selectedItemIndex = Math.max(0, tvbox.sites.length - 1);
|
||
}
|
||
renderEditor();
|
||
}
|
||
|
||
// ── LOG ──
|
||
function log(type, icon, msg, sub) {
|
||
const el = document.getElementById('fetchLog');
|
||
if (el.querySelector('div[style]')) el.innerHTML = '';
|
||
const d = document.createElement('div'); d.className = 'log-item ' + type;
|
||
d.innerHTML = `<span class="li-ic">${icon}</span><span class="li-bd">${esc(msg)}${sub?`<span class="li-sub">${esc(sub)}</span>`:''}</span>`;
|
||
el.appendChild(d); scrollLog();
|
||
}
|
||
function clearLog() { document.getElementById('fetchLog').innerHTML = '<div style="color:var(--text3);font-size:11px;font-family:var(--mono);padding:4px">等待操作…</div>'; }
|
||
function scrollLog() { const l = document.getElementById('fetchLog'); l.scrollTop = l.scrollHeight; }
|
||
|
||
// ── EDITOR & VIEW MODES ──
|
||
function setViewMode(mode) {
|
||
viewMode = mode;
|
||
document.getElementById('btn_view_tab').classList.toggle('active', mode === 'tab');
|
||
document.getElementById('btn_view_source').classList.toggle('active', mode === 'source');
|
||
renderEditor();
|
||
}
|
||
|
||
function switchEditMode(mode, doRender = true) {
|
||
currentEditMode = mode;
|
||
selectedItemIndex = 0;
|
||
if (doRender) renderEditor();
|
||
}
|
||
|
||
function selectItem(index) {
|
||
selectedItemIndex = index;
|
||
renderEditor();
|
||
}
|
||
|
||
function renderEditor() {
|
||
const body = document.getElementById('editorBody');
|
||
if (!tvbox) {
|
||
body.innerHTML = '<div class="empty"><div class="eico">📭</div><p>請先載入 JSON 或開始抓取</p></div>';
|
||
return;
|
||
}
|
||
|
||
// ==== Source Mode ====
|
||
if (viewMode === 'source') {
|
||
body.innerHTML = `
|
||
<div class="editor-main">
|
||
<div class="editor-toolbar">
|
||
<span style="font-size:11px;color:var(--text2)">此處為 Source 模式 (完整 JSON 原始碼)</span>
|
||
<button class="btn btn-primary btn-sm" onclick="saveFullJson()">💾 套用全部修改</button>
|
||
</div>
|
||
<textarea class="editor-textarea" id="fullJsonArea">${esc(JSON.stringify(tvbox, null, 2))}</textarea>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
// ==== Tab Mode ====
|
||
const isSites = currentEditMode === 'sites';
|
||
const items = isSites ? tvbox.sites : tvbox.parses;
|
||
|
||
let topNavHtml = `
|
||
<div style="padding: 6px 14px; background: var(--s2); border-bottom: 1px solid var(--border); display:flex; gap:4px; flex-shrink: 0;">
|
||
<button class="tab-btn ${isSites ? 'active' : ''}" onclick="switchEditMode('sites')">Sites (片源)</button>
|
||
<button class="tab-btn ${!isSites ? 'active' : ''}" onclick="switchEditMode('parses')">Parses (解析)</button>
|
||
</div>
|
||
`;
|
||
|
||
if (!items || items.length === 0) {
|
||
body.innerHTML = topNavHtml + `<div class="empty"><div class="eico">🔍</div><p>此分類無任何資料</p></div>`;
|
||
return;
|
||
}
|
||
|
||
if (selectedItemIndex >= items.length) selectedItemIndex = Math.max(0, items.length - 1);
|
||
|
||
// Build Sidebar
|
||
let sidebarHtml = '<div class="editor-sidebar">';
|
||
items.forEach((it, i) => {
|
||
const name = esc(it.name || it.key || `Item ${i}`);
|
||
const cls = i === selectedItemIndex ? 's-item active' : 's-item';
|
||
const catCount = it.categories ? it.categories.length : 0;
|
||
const catText = catCount > 0 ? `${catCount} 個分類` : '無分類';
|
||
const catClass = catCount > 0 ? 's-meta has-cat' : 's-meta';
|
||
|
||
sidebarHtml += `
|
||
<div class="${cls}" onclick="selectItem(${i})" title="${name}">
|
||
<span class="s-name">${name}</span>
|
||
${isSites ? `<span class="${catClass}">${catText}</span>` : ''}
|
||
</div>`;
|
||
});
|
||
sidebarHtml += '</div>';
|
||
|
||
// Build Main Detail
|
||
const activeItem = items[selectedItemIndex];
|
||
const detailHtml = `
|
||
<div class="editor-main">
|
||
<div class="editor-toolbar">
|
||
<span style="font-size:11px;color:var(--text2)">
|
||
目前編輯:<strong style="color:var(--text)">${esc(activeItem.name || activeItem.key || '未命名')}</strong>
|
||
<span style="margin-left:8px;color:var(--text3)">(清空並儲存即可刪除)</span>
|
||
</span>
|
||
<button class="btn btn-primary btn-sm" onclick="saveNodeJson()">💾 儲存節點修改</button>
|
||
</div>
|
||
<textarea class="editor-textarea" id="nodeJsonArea">${esc(JSON.stringify(activeItem, null, 2))}</textarea>
|
||
</div>`;
|
||
|
||
body.innerHTML = topNavHtml + `<div class="editor-wrap">${sidebarHtml}${detailHtml}</div>`;
|
||
|
||
// Scroll sidebar to active item
|
||
setTimeout(() => {
|
||
const activeEl = document.querySelector('.s-item.active');
|
||
if (activeEl) activeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
}, 10);
|
||
}
|
||
|
||
function saveNodeJson() {
|
||
const ta = document.getElementById('nodeJsonArea');
|
||
const val = ta.value.trim();
|
||
const items = currentEditMode === 'sites' ? tvbox.sites : tvbox.parses;
|
||
|
||
// 如果使用者清空了文字框,執行刪除邏輯
|
||
if (!val) {
|
||
items.splice(selectedItemIndex, 1);
|
||
toast('✓ 已刪除該節點', 'ok');
|
||
|
||
// 調整索引避免越界
|
||
if (selectedItemIndex >= items.length) {
|
||
selectedItemIndex = Math.max(0, items.length - 1);
|
||
}
|
||
|
||
document.getElementById('rawPill').textContent = `${tvbox.sites.length}S / ${tvbox.parses.length}P`;
|
||
renderEditor();
|
||
updateStatus();
|
||
return;
|
||
}
|
||
|
||
// 正常儲存邏輯
|
||
try {
|
||
const parsed = JSON.parse(val);
|
||
items[selectedItemIndex] = parsed;
|
||
toast('✓ 節點儲存成功', 'ok');
|
||
renderEditor(); // Re-render to update sidebar info
|
||
updateStatus();
|
||
} catch(e) {
|
||
toast('JSON 格式錯誤:' + e.message, 'err');
|
||
}
|
||
}
|
||
|
||
function saveFullJson() {
|
||
const ta = document.getElementById('fullJsonArea');
|
||
try {
|
||
tvbox = JSON.parse(ta.value);
|
||
if (!tvbox.sites) tvbox.sites = [];
|
||
if (!tvbox.parses) tvbox.parses = [];
|
||
document.getElementById('rawPill').textContent = `${tvbox.sites.length}S / ${tvbox.parses.length}P`;
|
||
toast('✓ 全部修改已套用', 'ok');
|
||
updateStatus();
|
||
} catch(e) { toast('套用失敗,JSON 格式錯誤:' + e.message, 'err'); }
|
||
}
|
||
|
||
// ── OUTPUT ──
|
||
function getOutput() {
|
||
if (!tvbox) { toast('目前沒有資料可以輸出','err'); return null; }
|
||
return JSON.stringify(tvbox, null, 2);
|
||
}
|
||
function copyOutput() {
|
||
const j = getOutput(); if(!j) return;
|
||
navigator.clipboard.writeText(j).then(()=>toast('✓ 已複製完整 JSON','ok'));
|
||
}
|
||
function downloadOutput() {
|
||
const j = getOutput(); if(!j) return;
|
||
const a = Object.assign(document.createElement('a'), {
|
||
href: URL.createObjectURL(new Blob([j],{type:'application/json'})), download: 'tvbox_updated.json'
|
||
}); a.click(); toast('✓ JSON 檔案已下載','ok');
|
||
}
|
||
function updateStatus() {
|
||
if(!tvbox) return;
|
||
const n = (tvbox.sites || []).filter(s => s.categories?.length > 0).length;
|
||
document.getElementById('mergeStatus').textContent = `${n}/${tvbox.sites.length} sites 有分類`;
|
||
}
|
||
|
||
// ── UTILS ──
|
||
function esc(s){ return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
function setProxy(v){ document.getElementById('proxyInput').value=v; }
|
||
function sleep(ms){ return new Promise(r=>setTimeout(r,ms)); }
|
||
let _t; function toast(msg, type='') {
|
||
const el = document.getElementById('toast');
|
||
el.textContent = msg; el.className = 'show ' + type;
|
||
clearTimeout(_t); _t = setTimeout(()=>el.className='', 3000);
|
||
}
|
||
|
||
// ── SHORTCUTS ──
|
||
document.addEventListener('keydown', e => { if((e.ctrlKey||e.metaKey)&&e.key==='Enter') loadJSON(); });
|
||
document.getElementById('jsonInput').addEventListener('paste', () => {
|
||
setTimeout(()=>{ const v=document.getElementById('jsonInput').value.trim(); if(v.startsWith('{')) setTimeout(loadJSON,80); },50);
|
||
});
|
||
</script>
|
||
</body>
|
||
</html> |