6a7a2b089a
1. 新增免费演出雷达工具:含秀动/猫眼/大麦平台抓取、本地代理鉴权配置 2. 新增建行定投评估模块:四大指数PE-TTM估值与定投档位建议 3. 新增推送告警白名单过滤功能,限制推送指定指数告警 4. 重构周报首页提醒逻辑,移至服务端处理并减少前端请求 5. 新增多套调试探针脚本与单元测试用例
270 lines
12 KiB
JavaScript
270 lines
12 KiB
JavaScript
// 免费演出雷达(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 =
|
||
'<div class="fs-dialog-mask">' +
|
||
' <div class="fs-dialog">' +
|
||
' <h3><span>交互详情</span><button class="fs-dialog-close" id="fs-dialog-close">关闭</button></h3>' +
|
||
' <pre id="fs-dialog-body"></pre>' +
|
||
' <div class="fs-dialog-actions">' +
|
||
' <button class="fs-dialog-close" id="fs-dialog-copy">复制</button>' +
|
||
' <span class="fs-dialog-countdown" id="fs-dialog-countdown"></span>' +
|
||
' </div>' +
|
||
' </div>' +
|
||
'</div>'
|
||
$('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 = '<div class="fs-loading-wrap"><div class="fs-spinner"></div><div class="fs-loading-text">' +
|
||
(isCacheMode ? '读取缓存,秒开' : (currentTab === 'damai' ? '探测本地抓取代理(家庭 IP)...' : '正在获取数据...')) +
|
||
'</div></div>'
|
||
|
||
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 + '] 数据时间 <span class="count">' + data.cacheTime + '</span> · 库存 <span class="count">' + data.totalCount + '</span> 条 · 符合 ≤¥' + maxPrice + ': <span class="count">' + data.filteredCount + '</span> 条'
|
||
|
||
let html = ''
|
||
if (data.events.length > 0) {
|
||
html += '<div class="fs-section-label">符合条件 <span class="num">' + data.events.length + '</span></div>'
|
||
html += '<div class="fs-event-list">'
|
||
html += data.events.map(e => renderCard(e)).join('')
|
||
html += '</div>'
|
||
} else {
|
||
html += '<div class="fs-empty-state"><div class="icon">◇</div><p>扫描 ' + data.totalCount + ' 条演出,暂无 ≤¥' + maxPrice + ' 的结果<br>试试调高票价或增加页数</p></div>'
|
||
}
|
||
|
||
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 += '<div class="fs-section-label">最低价排行 <span class="num">' + others.length + '</span></div>'
|
||
html += '<div class="fs-event-list">'
|
||
html += others.map(e => renderCard(e)).join('')
|
||
html += '</div>'
|
||
}
|
||
}
|
||
|
||
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 = '<div class="fs-empty-state"><div class="icon">✕</div><p>' + esc(err.message) + '</p></div>'
|
||
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 '<a class="fs-event-card" data-platform="' + e.platform + '" href="' + e.url + '" target="_blank" rel="noopener">' +
|
||
'<img class="fs-event-poster" src="' + e.poster + '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">' +
|
||
'<div class="fs-event-body">' +
|
||
'<div class="fs-event-title">' + esc(e.title) + '</div>' +
|
||
'<div class="fs-event-meta">' +
|
||
(e.showTime ? '<span>' + esc(e.showTime) + '</span>' : '') +
|
||
(e.venue ? '<span>' + esc(e.venue) + '</span>' : '') +
|
||
(e.city ? '<span>' + esc(e.city) + '</span>' : '') +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="fs-event-price ' + priceClass + '">' + priceLabel + '</div>' +
|
||
'<span class="fs-platform-badge ' + e.platform + '">' + tabName(e.platform) + '</span>' +
|
||
'</a>'
|
||
}
|
||
|
||
function esc(s) { return (s || '').replace(/&/g, '&').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)
|
||
})()
|