Files
yangxiangyuan 6a7a2b089a feat: 新增免费演出雷达工具与定投评估功能,优化周报提醒逻辑
1. 新增免费演出雷达工具:含秀动/猫眼/大麦平台抓取、本地代理鉴权配置
2. 新增建行定投评估模块:四大指数PE-TTM估值与定投档位建议
3. 新增推送告警白名单过滤功能,限制推送指定指数告警
4. 重构周报首页提醒逻辑,移至服务端处理并减少前端请求
5. 新增多套调试探针脚本与单元测试用例
2026-08-04 17:33:14 +08:00

270 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 免费演出雷达(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)
})()