// 免费演出雷达(free_show)前端逻辑 // 双模式弹窗(Rule 10):debug 显示完整交互详情,normal 仅状态栏 (function () { 'use strict' let currentTab = 'showstart' let UI_MODE = 'debug' let AGENT_PORT = 8978 let DAMAI_SERVER_FETCH = false const $ = id => document.getElementById(id) // ============ 双模式交互详情弹窗 ============ function closeDialog() { const root = $('fs-dialog-root') if (root) root.innerHTML = '' } function showInteractionDialog(info, ok) { // normal 模式:不弹窗,状态栏已反馈 if (UI_MODE !== 'debug') return closeDialog() const time = new Date().toLocaleString('zh-CN', { hour12: false }) const lines = [ '触发动作: ' + info.action, '时间: ' + time, '接口地址: ' + info.url, 'HTTP 方法: ' + (info.method || 'GET'), '请求参数: ' + JSON.stringify(info.params || {}), '耗时: ' + info.duration + 'ms', '当前状态: ' + info.status ] if (ok) lines.push('返回结果: ' + info.result) else lines.push('错误信息: ' + info.error) const detail = lines.join('\n') const root = $('fs-dialog-root') root.innerHTML = '
' + '
' + '

交互详情

' + '
' +
      '    
' + ' ' + ' ' + '
' + '
' + '
' $('fs-dialog-body').textContent = detail $('fs-dialog-close').onclick = closeDialog $('fs-dialog-copy').onclick = () => { try { navigator.clipboard.writeText(detail) } catch (e) {} } if (ok) { // 查看类成功:3s 后自动关闭 let left = 3 const cd = $('fs-dialog-countdown') cd.textContent = left + 's 后自动关闭' const timer = setInterval(() => { left-- if (left <= 0 || !$('fs-dialog-countdown')) { clearInterval(timer); closeDialog(); return } cd.textContent = left + 's 后自动关闭' }, 1000) } else { // 异常:中断自动关闭,停留排查 $('fs-dialog-countdown').textContent = '异常,已中断自动关闭' } } // ============ 业务逻辑 ============ // 大麦专用:优先本地代理(家庭 IP),代理未启动且服务端开关开启时才回退服务器 async function fetchDamaiViaAgentOrServer(city, pages, mode, maxPrice, status) { let agentOk = false try { const pingCtl = new AbortController() const pingTimer = setTimeout(() => pingCtl.abort(), 1500) const pingRes = await fetch('http://127.0.0.1:' + AGENT_PORT + '/ping', { signal: pingCtl.signal }) clearTimeout(pingTimer) agentOk = pingRes.ok } catch (e) { agentOk = false } if (!agentOk) { if (!DAMAI_SERVER_FETCH) { throw new Error('本地抓取代理未启动。请在本机运行 node free_show_local_agent.js(页面底部可下载),再重试。服务器不抓大麦(保护服务器 IP)') } status.textContent = '本地代理未启动,回退服务器抓大麦(风险自担)...' const res = await fetch('/api/free_show/fetch?platform=damai&city=' + encodeURIComponent(city) + '&maxPrice=' + maxPrice + '&pages=' + pages + '&mode=' + mode) if (res.status === 401) throw new Error('未授权:请通过首页九宫格进入,或使用正确的访问链接') const data = await res.json() if (!data.success) throw new Error(data.error || '抓取失败') return data } status.textContent = '已连上本地代理,用家庭 IP 抓大麦中(约10-40秒)...' const ctl = new AbortController() const timer = setTimeout(() => ctl.abort(), 150000) let aData try { const aRes = await fetch('http://127.0.0.1:' + AGENT_PORT + '/fetch?city=' + encodeURIComponent(city) + '&pages=' + pages, { signal: ctl.signal }) aData = await aRes.json() } finally { clearTimeout(timer) } if (!aData.success) throw new Error(aData.error || '本地代理抓取失败') if (!aData.events || aData.events.length === 0) throw new Error('本地代理没抓到数据(可能触发风控或页面结构变化)') status.textContent = '本地抓得 ' + aData.events.length + ' 条,入库服务器...' const ingRes = await fetch('/api/free_show/ingest', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ platform: 'damai', city, mode, maxPrice: Number(maxPrice), events: aData.events }) }) if (ingRes.status === 401) throw new Error('未授权:请通过首页九宫格进入,或使用正确的访问链接') const data = await ingRes.json() if (!data.success) throw new Error(data.error || '入库失败') if (aData.warning) data.warning = aData.warning // 部分页触发滑块时的人工处理提示,随结果带出 return data } function tabName(t) { return { showstart: '秀动', maoyan: '猫眼', damai: '大麦' }[t] || t } function switchTab(tab) { currentTab = tab document.querySelectorAll('.fs-tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tab)) $('fetchBtn').dataset.platform = tab $('content').innerHTML = '' $('status').textContent = '已切换到' + tabName(tab) + ',点击抓取' $('status').className = 'fs-status-bar' } async function doFetch() { const btn = $('fetchBtn') const status = $('status') const content = $('content') const city = $('city').value const maxPrice = $('maxPrice').value || 5 const pages = $('pages').value const mode = $('mode').value btn.disabled = true const origText = btn.textContent const isCacheMode = mode === 'cache' btn.textContent = isCacheMode ? '读取中...' : '抓取中...' status.className = 'fs-status-bar' status.textContent = isCacheMode ? '正在读取 ' + tabName(currentTab) + '·' + city + ' 缓存...' : '正在从' + tabName(currentTab) + '抓取 ' + city + ' 演出(' + (mode === 'append' ? '追加合并' : '覆盖') + ')...' content.innerHTML = '
' + (isCacheMode ? '读取缓存,秒开' : (currentTab === 'damai' ? '探测本地抓取代理(家庭 IP)...' : '正在获取数据...')) + '
' const t0 = Date.now() let apiUrl = '/api/free_show/fetch?platform=' + currentTab + '&city=' + encodeURIComponent(city) + '&maxPrice=' + maxPrice + '&pages=' + pages + '&mode=' + mode try { let data if (currentTab === 'damai' && mode !== 'cache') { // 真抓取才走本地代理(家庭 IP);读缓存直接走服务器 API(纯本地 SQLite 读取,不碰大麦网站) data = await fetchDamaiViaAgentOrServer(city, pages, mode, maxPrice, status) apiUrl = 'http://127.0.0.1:' + AGENT_PORT + '/fetch -> /api/free_show/ingest' } else { const res = await fetch(apiUrl) if (res.status === 401) throw new Error('未授权:请通过首页九宫格进入,或使用正确的访问链接') data = await res.json() if (!data.success) throw new Error(data.error || '抓取失败') } const duration = Date.now() - t0 const srcTag = data.fromCache ? '缓存' : (mode === 'append' ? '新抓' + data.newCount + '条·合并' : '新抓取') status.innerHTML = '[' + srcTag + '] 数据时间 ' + data.cacheTime + ' · 库存 ' + data.totalCount + ' 条 · 符合 ≤¥' + maxPrice + ': ' + data.filteredCount + ' 条' let html = '' if (data.events.length > 0) { html += '
符合条件 ' + data.events.length + '
' html += '
' html += data.events.map(e => renderCard(e)).join('') html += '
' } else { html += '
◇

扫描 ' + data.totalCount + ' 条演出,暂无 ≤¥' + maxPrice + ' 的结果
试试调高票价或增加页数

' } if (data.cheapest && data.cheapest.length > 0) { const others = data.cheapest.filter(e => !data.events.find(f => f.id === e.id && f.platform === e.platform)) if (others.length > 0) { html += '
最低价排行 ' + others.length + '
' html += '
' html += others.map(e => renderCard(e)).join('') html += '
' } } content.innerHTML = html showInteractionDialog({ action: '抓取演出(' + tabName(currentTab) + '·' + city + '·' + mode + ')', url: apiUrl, method: 'GET', params: { platform: currentTab, city, maxPrice, pages, mode }, duration, status: '成功', result: 'totalCount=' + data.totalCount + ', filteredCount=' + data.filteredCount + ', fromCache=' + data.fromCache + (data.warning ? ' | 提示: ' + data.warning : '') }, true) } catch (err) { const duration = Date.now() - t0 status.className = 'fs-status-bar error' status.textContent = '失败: ' + err.message content.innerHTML = '
✕

' + esc(err.message) + '

' showInteractionDialog({ action: '抓取演出(' + tabName(currentTab) + '·' + city + '·' + mode + ')', url: apiUrl, method: 'GET', params: { platform: currentTab, city, maxPrice, pages, mode }, duration, status: '失败', error: String(err.message || err) }, false) } finally { btn.disabled = false btn.textContent = origText } } function renderCard(e) { const priceClass = e.price === 0 ? 'free' : (e.price <= 5 ? 'cheap' : 'normal') const priceLabel = e.price === 0 ? 'FREE' : e.priceText return '' + '' + '
' + '
' + esc(e.title) + '
' + '
' + (e.showTime ? '' + esc(e.showTime) + '' : '') + (e.venue ? '' + esc(e.venue) + '' : '') + (e.city ? '' + esc(e.city) + '' : '') + '
' + '
' + '
' + priceLabel + '
' + '' + tabName(e.platform) + '' + '
' } function esc(s) { return (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') } // ============ 初始化 ============ async function init() { // 读取 ui_mode(双模式控制) try { const res = await fetch('/api/free_show/config') if (res.ok) { const cfg = await res.json() if (cfg && cfg.ui_mode) UI_MODE = cfg.ui_mode if (cfg && cfg.local_agent_port) AGENT_PORT = cfg.local_agent_port if (cfg && cfg.damai_server_fetch === true) DAMAI_SERVER_FETCH = true } } catch (e) {} document.querySelectorAll('.fs-tab-btn').forEach(b => { b.addEventListener('click', () => switchTab(b.dataset.tab)) }) $('fetchBtn').addEventListener('click', doFetch) setTimeout(doFetch, 400) } window.addEventListener('load', init) })()