feat: 新增免费演出雷达工具与定投评估功能,优化周报提醒逻辑
1. 新增免费演出雷达工具:含秀动/猫眼/大麦平台抓取、本地代理鉴权配置 2. 新增建行定投评估模块:四大指数PE-TTM估值与定投档位建议 3. 新增推送告警白名单过滤功能,限制推送指定指数告警 4. 重构周报首页提醒逻辑,移至服务端处理并减少前端请求 5. 新增多套调试探针脚本与单元测试用例
This commit is contained in:
@@ -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, '&').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)
|
||||
})()
|
||||
Reference in New Issue
Block a user