feat: 新增免费演出雷达工具与定投评估功能,优化周报提醒逻辑

1. 新增免费演出雷达工具:含秀动/猫眼/大麦平台抓取、本地代理鉴权配置
2. 新增建行定投评估模块:四大指数PE-TTM估值与定投档位建议
3. 新增推送告警白名单过滤功能,限制推送指定指数告警
4. 重构周报首页提醒逻辑,移至服务端处理并减少前端请求
5. 新增多套调试探针脚本与单元测试用例
This commit is contained in:
yangxiangyuan
2026-08-04 17:33:14 +08:00
parent af5d0f43a5
commit 6a7a2b089a
25 changed files with 3499 additions and 121 deletions
+269
View File
@@ -0,0 +1,269 @@
// 免费演出雷达(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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') }
// ============ 初始化 ============
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)
})()