feat: 新增免费演出雷达工具与定投评估功能,优化周报提醒逻辑
1. 新增免费演出雷达工具:含秀动/猫眼/大麦平台抓取、本地代理鉴权配置 2. 新增建行定投评估模块:四大指数PE-TTM估值与定投档位建议 3. 新增推送告警白名单过滤功能,限制推送指定指数告警 4. 重构周报首页提醒逻辑,移至服务端处理并减少前端请求 5. 新增多套调试探针脚本与单元测试用例
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"_comment": "免费演出雷达的鉴权配置",
|
||||
"enable_auth_comment": "true:启用 tool 外层鉴权;false:完全放开访问",
|
||||
"enable_auth": true,
|
||||
"max_age_days_comment": "鉴权 cookie 最大有效天数(1~365)",
|
||||
"max_age_days": 30,
|
||||
"cookieName_comment": "鉴权 Cookie 名称,默认格式 <tool>_gate",
|
||||
"cookieName": "free_show_gate",
|
||||
"requireGuid_comment": "true:强制 GUID 参数才能访问;false:允许仅凭 token 或已有会话访问",
|
||||
"requireGuid": false,
|
||||
"guidParam_comment": "URL 中的 GUID 参数名,建议使用小写 guid",
|
||||
"guidParam": "guid",
|
||||
"guidValue_comment": "GUID 参数值,建议使用高强度随机长串并定期更换(留空表示不启用 GUID)",
|
||||
"guidValue": ""
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
/* 免费演出雷达(free_show)样式 */
|
||||
:root {
|
||||
--bg-deep: #0d0d13;
|
||||
--bg-card: #1a1a24;
|
||||
--bg-elevated: #2a2a38;
|
||||
--border: #3d3d52;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #b8b8cc;
|
||||
--text-muted: #9090aa;
|
||||
--showstart: #ff4757;
|
||||
--showstart-glow: rgba(255,71,87,0.15);
|
||||
--maoyan: #ffc312;
|
||||
--maoyan-glow: rgba(255,195,18,0.12);
|
||||
--damai: #f97316;
|
||||
--damai-glow: rgba(249,115,22,0.12);
|
||||
--free: #00e676;
|
||||
--free-glow: rgba(0,230,118,0.2);
|
||||
}
|
||||
|
||||
/* 页面底色:本工具为暗黑主题,必须自带 body 背景/文字色,
|
||||
否则浏览器默认白底 + 黑字会导致深色卡片上标题不可读 */
|
||||
html, body { background: var(--bg-deep); }
|
||||
body { color: var(--text-primary); }
|
||||
|
||||
.fs-app { position: relative; z-index: 1; max-width: 960px; margin: 0 auto; padding: 0 16px; }
|
||||
|
||||
/* Header */
|
||||
.fs-header {
|
||||
padding: 32px 0 20px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
.fs-header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.fs-header .tagline {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Tab bar */
|
||||
.fs-tab-bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
margin-bottom: 20px;
|
||||
position: relative;
|
||||
}
|
||||
.fs-tab-btn {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 9px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.fs-tab-btn .dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.fs-tab-btn[data-tab="showstart"] .dot { background: var(--showstart); }
|
||||
.fs-tab-btn[data-tab="maoyan"] .dot { background: var(--maoyan); }
|
||||
.fs-tab-btn[data-tab="damai"] .dot { background: var(--damai); }
|
||||
.fs-tab-btn:hover { color: var(--text-primary); }
|
||||
.fs-tab-btn.active {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-elevated);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
.fs-tab-btn.active[data-tab="showstart"] { box-shadow: 0 2px 12px var(--showstart-glow); }
|
||||
.fs-tab-btn.active[data-tab="maoyan"] { box-shadow: 0 2px 12px var(--maoyan-glow); }
|
||||
.fs-tab-btn.active[data-tab="damai"] { box-shadow: 0 2px 12px var(--damai-glow); }
|
||||
|
||||
/* Controls */
|
||||
.fs-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.fs-controls select, .fs-controls input {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.fs-controls select:focus, .fs-controls input:focus { border-color: var(--text-muted); }
|
||||
.fs-controls input[type="number"] { width: 70px; }
|
||||
.fs-controls label { font-size: 12px; color: var(--text-muted); letter-spacing: 0.5px; }
|
||||
.fs-btn-fetch {
|
||||
margin-left: auto;
|
||||
padding: 9px 28px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
transition: all 0.2s;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.fs-btn-fetch[data-platform="showstart"] { background: var(--showstart); }
|
||||
.fs-btn-fetch[data-platform="maoyan"] { background: var(--maoyan); color: #111; }
|
||||
.fs-btn-fetch[data-platform="damai"] { background: var(--damai); }
|
||||
.fs-btn-fetch:hover { transform: translateY(-1px); filter: brightness(1.1); }
|
||||
.fs-btn-fetch:active { transform: translateY(0); }
|
||||
.fs-btn-fetch:disabled { opacity: 0.4; cursor: not-allowed; transform: none; filter: none; }
|
||||
|
||||
/* Status bar */
|
||||
.fs-status-bar {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
padding: 8px 0;
|
||||
min-height: 30px;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
.fs-status-bar.error { color: var(--showstart); }
|
||||
.fs-status-bar .count { color: var(--free); font-weight: 700; font-family: monospace; }
|
||||
|
||||
/* Event list */
|
||||
.fs-section-label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text-muted);
|
||||
margin: 24px 0 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.fs-section-label .num {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.fs-event-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.fs-event-card {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fs-event-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.fs-event-card[data-platform="showstart"]::before { background: linear-gradient(135deg, var(--showstart-glow), transparent 60%); }
|
||||
.fs-event-card[data-platform="maoyan"]::before { background: linear-gradient(135deg, var(--maoyan-glow), transparent 60%); }
|
||||
.fs-event-card[data-platform="damai"]::before { background: linear-gradient(135deg, var(--damai-glow), transparent 60%); }
|
||||
.fs-event-card:hover { border-color: var(--text-muted); transform: translateX(4px); }
|
||||
.fs-event-card:hover::before { opacity: 1; }
|
||||
|
||||
.fs-event-poster {
|
||||
width: 64px; height: 84px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
background: var(--bg-elevated);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.fs-event-body { flex: 1; min-width: 0; position: relative; z-index: 1; }
|
||||
.fs-event-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 6px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fs-event-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.fs-event-meta span {
|
||||
padding: 2px 7px;
|
||||
background: var(--bg-elevated);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.fs-event-price {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
top: 14px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
z-index: 1;
|
||||
}
|
||||
.fs-event-price.free {
|
||||
color: var(--free);
|
||||
text-shadow: 0 0 20px var(--free-glow);
|
||||
}
|
||||
.fs-event-price.cheap { color: #4ecdc4; }
|
||||
.fs-event-price.normal { color: var(--text-secondary); }
|
||||
|
||||
/* Loading */
|
||||
.fs-loading-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 60px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
.fs-spinner {
|
||||
width: 32px; height: 32px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--text-secondary);
|
||||
border-radius: 50%;
|
||||
animation: fs-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes fs-spin { to { transform: rotate(360deg); } }
|
||||
.fs-loading-text { font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
/* Empty state */
|
||||
.fs-empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.fs-empty-state .icon { font-size: 40px; margin-bottom: 12px; opacity: 0.5; }
|
||||
.fs-empty-state p { font-size: 14px; line-height: 1.8; }
|
||||
|
||||
/* Animations */
|
||||
@keyframes fs-fadeUp {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.fs-event-card { animation: fs-fadeUp 0.4s ease both; }
|
||||
.fs-event-card:nth-child(1) { animation-delay: 0.02s; }
|
||||
.fs-event-card:nth-child(2) { animation-delay: 0.06s; }
|
||||
.fs-event-card:nth-child(3) { animation-delay: 0.10s; }
|
||||
.fs-event-card:nth-child(4) { animation-delay: 0.14s; }
|
||||
.fs-event-card:nth-child(5) { animation-delay: 0.18s; }
|
||||
.fs-event-card:nth-child(6) { animation-delay: 0.22s; }
|
||||
.fs-event-card:nth-child(7) { animation-delay: 0.26s; }
|
||||
.fs-event-card:nth-child(8) { animation-delay: 0.30s; }
|
||||
|
||||
/* Platform badge */
|
||||
.fs-platform-badge {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
bottom: 14px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.5px;
|
||||
z-index: 1;
|
||||
}
|
||||
.fs-platform-badge.showstart { background: rgba(255,71,87,0.2); color: var(--showstart); }
|
||||
.fs-platform-badge.maoyan { background: rgba(255,195,18,0.2); color: var(--maoyan); }
|
||||
.fs-platform-badge.damai { background: rgba(249,115,22,0.2); color: var(--damai); }
|
||||
|
||||
/* Footer */
|
||||
.fs-footer {
|
||||
margin-top: 40px;
|
||||
padding: 20px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.fs-footer a { color: var(--maoyan); text-decoration: none; }
|
||||
.fs-footer a:hover { text-decoration: underline; }
|
||||
.fs-footer code {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* 交互详情弹窗(debug 模式) */
|
||||
.fs-dialog-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.55);
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.fs-dialog {
|
||||
width: min(680px, 92vw);
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.fs-dialog h3 {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.fs-dialog .fs-dialog-close {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
padding: 2px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.fs-dialog pre {
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
font-size: 11px;
|
||||
}
|
||||
.fs-dialog .fs-dialog-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.fs-dialog .fs-dialog-countdown { color: var(--text-muted); margin-left: auto; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.fs-header h1 { font-size: 22px; }
|
||||
.fs-controls { flex-direction: column; align-items: stretch; }
|
||||
.fs-btn-fetch { margin-left: 0; }
|
||||
.fs-event-price { position: static; margin-top: 8px; }
|
||||
}
|
||||
@@ -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)
|
||||
})()
|
||||
@@ -0,0 +1,486 @@
|
||||
// ============================================================
|
||||
// 免费演出雷达 · 本地抓取代理(零依赖单文件 · v2 常驻浏览器版)
|
||||
//
|
||||
// 用途:大麦对服务器 IP(尤其阿里云 ECS)风控极严,
|
||||
// 本代理在家里电脑运行,用家庭宽带 IP 抓大麦,
|
||||
// 工具箱页面(127.0.0.1 探测)自动把大麦请求路由到这里。
|
||||
//
|
||||
// 用法:node free_show_local_agent.js [端口,默认 8978] [--headless] [--cdp <端口,默认 9222>]
|
||||
// 依赖:仅 Node 内置模块 + 本机已安装的 Chrome 或 Edge(无需 npm install)
|
||||
//
|
||||
// v2 工作方式(解决 headless 被大麦风控识别的问题):
|
||||
// ① 启动代理 → 自动唤起一个【可视化】原生 Edge 窗口(默认不 headless),
|
||||
// 使用持久化用户配置目录(Cookie / 登录态 / 过验证状态落盘保存);
|
||||
// ② 首次使用请人工在该窗口完成滑块验证 / 登录(只需一次,会话自动保存);
|
||||
// ③ 代理通过 CDP(remote-debugging-port)复用【同一个浏览器实例】执行抓取,
|
||||
// 每次任务只开/关标签页,绝不新建空白浏览器环境、不重启浏览器进程;
|
||||
// ④ 抓取前等待异步列表渲染完成;若检测到滑块弹窗,保留标签页并明确提示
|
||||
// 「需要人工完成滑块验证」,由你在可见窗口处理后重试。
|
||||
//
|
||||
// 反自动化特征参数(内置):
|
||||
// --disable-blink-features=AutomationControlled
|
||||
// --exclude-switches=enable-automation
|
||||
// ============================================================
|
||||
'use strict'
|
||||
|
||||
const http = require('http')
|
||||
const net = require('net')
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
const crypto = require('crypto')
|
||||
const { spawn } = require('child_process')
|
||||
|
||||
// ============ CLI 参数 ============
|
||||
const argv = process.argv.slice(2)
|
||||
let PORT = 8978
|
||||
let CDP_PORT = 9222
|
||||
let HEADLESS = false // 默认可视化窗口;传 --headless 才无头(兼容旧模式)
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]
|
||||
if (a === '--headless') HEADLESS = true
|
||||
else if (a === '--cdp' && argv[i + 1]) CDP_PORT = Number(argv[++i]) || 9222
|
||||
else if (/^\d+$/.test(a)) PORT = Number(a)
|
||||
}
|
||||
|
||||
const DAMAI_CITY_CODE = '310100' // 仅上海(业务约定)
|
||||
const START_URL = 'https://search.damai.cn/search.htm?city=' + DAMAI_CITY_CODE
|
||||
// 持久化配置目录:放在脚本同级,Cookie/登录态/验证状态长期保存
|
||||
const PROFILE_DIR = path.join(__dirname, 'edge_profile_free_show')
|
||||
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
// ============ 浏览器探测 ============
|
||||
|
||||
function findBrowser() {
|
||||
const candidates = [
|
||||
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'
|
||||
]
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ============ CDP HTTP 端点 ============
|
||||
|
||||
function cdpHttp(method, pathAndQuery, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({
|
||||
host: '127.0.0.1', port: CDP_PORT, path: pathAndQuery, method,
|
||||
timeout: timeoutMs || 5000
|
||||
}, res => {
|
||||
let body = ''
|
||||
res.on('data', c => { body += c })
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(body)) } catch (e) { reject(new Error('CDP 响应解析失败')) }
|
||||
})
|
||||
})
|
||||
req.on('timeout', () => req.destroy(new Error('CDP 连接超时')))
|
||||
req.on('error', reject)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
const cdpVersion = () => cdpHttp('GET', '/json/version').catch(() => null)
|
||||
|
||||
// ============ 浏览器生命周期:只拉起一次,长期复用 ============
|
||||
|
||||
async function ensureBrowser() {
|
||||
const ver = await cdpVersion()
|
||||
if (ver) return { up: true, reused: true }
|
||||
|
||||
const browserPath = findBrowser()
|
||||
if (!browserPath) throw new Error('未找到本机 Chrome/Edge,无法启动抓取浏览器')
|
||||
|
||||
const args = [
|
||||
'--remote-debugging-port=' + CDP_PORT,
|
||||
'--user-data-dir=' + PROFILE_DIR,
|
||||
'--disable-blink-features=AutomationControlled', // 反自动化特征
|
||||
'--exclude-switches=enable-automation', // 去掉"受自动化控制"提示条
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check'
|
||||
]
|
||||
if (HEADLESS) args.push('--headless=new', '--disable-gpu')
|
||||
args.push(START_URL) // 首次直接打开大麦上海列表页,方便人工过滑块/登录
|
||||
|
||||
const child = spawn(browserPath, args, { detached: true, stdio: 'ignore' })
|
||||
child.unref() // 代理退出后浏览器继续活着,会话不丢
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await sleep(500)
|
||||
const v = await cdpVersion()
|
||||
if (v) return { up: true, reused: false }
|
||||
}
|
||||
throw new Error('浏览器已拉起但调试端口未就绪(' + CDP_PORT + ' 被占用或启动失败)')
|
||||
}
|
||||
|
||||
async function openTab(url) {
|
||||
const p = '/json/new?' + encodeURIComponent(url)
|
||||
try { return await cdpHttp('PUT', p) } catch (e) { return await cdpHttp('GET', p) }
|
||||
}
|
||||
|
||||
const closeTab = id => cdpHttp('GET', '/json/close/' + id).catch(() => null)
|
||||
|
||||
// ============ 最小 WebSocket 客户端(Node 内置 net/crypto 手写,RFC6455) ============
|
||||
|
||||
function parseWsFrame(buf) {
|
||||
if (buf.length < 2) return null
|
||||
const fin = (buf[0] & 0x80) !== 0
|
||||
const opcode = buf[0] & 0x0f
|
||||
const masked = (buf[1] & 0x80) !== 0
|
||||
let len = buf[1] & 0x7f
|
||||
let off = 2
|
||||
if (len === 126) {
|
||||
if (buf.length < 4) return null
|
||||
len = buf.readUInt16BE(2); off = 4
|
||||
} else if (len === 127) {
|
||||
if (buf.length < 10) return null
|
||||
len = Number(buf.readBigUInt64BE(2)); off = 10
|
||||
}
|
||||
let mask = null
|
||||
if (masked) {
|
||||
if (buf.length < off + 4) return null
|
||||
mask = buf.slice(off, off + 4); off += 4
|
||||
}
|
||||
if (buf.length < off + len) return null
|
||||
let payload = buf.slice(off, off + len)
|
||||
if (mask) {
|
||||
payload = Buffer.from(payload)
|
||||
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4]
|
||||
}
|
||||
return { fin, opcode, payload, total: off + len }
|
||||
}
|
||||
|
||||
function encodeWsFrame(text) {
|
||||
const payload = Buffer.from(text, 'utf8')
|
||||
const len = payload.length
|
||||
let header
|
||||
if (len < 126) header = Buffer.from([0x81, 0x80 | len])
|
||||
else if (len < 65536) { header = Buffer.from([0x81, 0x80 | 126, 0, 0]); header.writeUInt16BE(len, 2) }
|
||||
else { header = Buffer.from([0x81, 0x80 | 127, 0, 0, 0, 0, 0, 0, 0, 0]); header.writeBigUInt64BE(BigInt(len), 2) }
|
||||
const mask = crypto.randomBytes(4)
|
||||
const masked = Buffer.from(payload)
|
||||
for (let i = 0; i < masked.length; i++) masked[i] ^= mask[i % 4]
|
||||
return Buffer.concat([header, mask, masked])
|
||||
}
|
||||
|
||||
function wsConnect(wsUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(wsUrl)
|
||||
const sock = net.connect(Number(u.port) || 80, u.hostname || '127.0.0.1')
|
||||
const key = crypto.randomBytes(16).toString('base64')
|
||||
const handshake = [
|
||||
'GET ' + (u.pathname || '/') + (u.search || '') + ' HTTP/1.1',
|
||||
'Host: ' + u.host,
|
||||
'Upgrade: websocket',
|
||||
'Connection: Upgrade',
|
||||
'Sec-WebSocket-Key: ' + key,
|
||||
'Sec-WebSocket-Version: 13',
|
||||
'', ''
|
||||
].join('\r\n')
|
||||
|
||||
let upgraded = false
|
||||
let buf = Buffer.alloc(0)
|
||||
let fragBuf = null
|
||||
const handlers = { message: null, close: null }
|
||||
const client = {
|
||||
send(text) { try { sock.write(encodeWsFrame(text)) } catch (e) {} },
|
||||
close() { try { sock.end() } catch (e) {} },
|
||||
onMessage(fn) { handlers.message = fn },
|
||||
onClose(fn) { handlers.close = fn }
|
||||
}
|
||||
|
||||
sock.on('connect', () => sock.write(handshake))
|
||||
sock.on('data', chunk => {
|
||||
buf = Buffer.concat([buf, chunk])
|
||||
if (!upgraded) {
|
||||
const idx = buf.indexOf('\r\n\r\n')
|
||||
if (idx < 0) return
|
||||
const head = buf.slice(0, idx).toString()
|
||||
if (!/^HTTP\/1\.1\s+101/.test(head)) {
|
||||
reject(new Error('WebSocket 握手失败'))
|
||||
sock.end()
|
||||
return
|
||||
}
|
||||
upgraded = true
|
||||
buf = buf.slice(idx + 4)
|
||||
resolve(client)
|
||||
}
|
||||
while (true) {
|
||||
const frame = parseWsFrame(buf)
|
||||
if (!frame) break
|
||||
buf = buf.slice(frame.total)
|
||||
if (frame.opcode === 0x8) { if (handlers.close) handlers.close(); return }
|
||||
if (frame.opcode === 0x9) { sock.write(Buffer.from([0x8a, 0x80, 0, 0, 0, 0])); continue } // ping→pong
|
||||
if (frame.opcode === 0xA) continue // pong
|
||||
if (frame.opcode === 0x1 || frame.opcode === 0x2) {
|
||||
if (frame.fin) {
|
||||
const full = fragBuf ? Buffer.concat([fragBuf, frame.payload]) : frame.payload
|
||||
fragBuf = null
|
||||
if (handlers.message) handlers.message(full.toString('utf8'))
|
||||
} else fragBuf = frame.payload
|
||||
} else if (frame.opcode === 0x0) {
|
||||
fragBuf = fragBuf ? Buffer.concat([fragBuf, frame.payload]) : frame.payload
|
||||
if (frame.fin) {
|
||||
const full = fragBuf
|
||||
fragBuf = null
|
||||
if (handlers.message) handlers.message(full.toString('utf8'))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
sock.on('error', e => { if (!upgraded) reject(e); else if (handlers.close) handlers.close() })
|
||||
sock.on('close', () => { if (handlers.close) handlers.close() })
|
||||
})
|
||||
}
|
||||
|
||||
// ============ CDP 会话封装 ============
|
||||
|
||||
function makeCdp(ws) {
|
||||
let nextId = 1
|
||||
const pending = new Map()
|
||||
ws.onMessage(text => {
|
||||
let msg = null
|
||||
try { msg = JSON.parse(text) } catch (e) { return }
|
||||
if (msg && msg.id && pending.has(msg.id)) {
|
||||
const cb = pending.get(msg.id)
|
||||
pending.delete(msg.id)
|
||||
cb(msg)
|
||||
}
|
||||
})
|
||||
return {
|
||||
send(method, params, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = nextId++
|
||||
const timer = setTimeout(() => {
|
||||
if (pending.has(id)) { pending.delete(id); reject(new Error('CDP 超时: ' + method)) }
|
||||
}, timeoutMs || 30000)
|
||||
pending.set(id, msg => { clearTimeout(timer); resolve(msg) })
|
||||
ws.send(JSON.stringify({ id, method, params: params || {} }))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 页面状态探测:滑块特征 / 列表渲染完成特征
|
||||
const CHECK_EXPR = '(() => { try { const txt = (document.body && document.body.innerText) ? document.body.innerText.slice(0, 3000) : ""; const slider = !!(document.querySelector("#nc_1_wrapper, .nc-container, #baxia-dialog-content, #nocaptcha, .slidetounlock")) || /拖动滑块|请按照说明拖动滑块|滑动验证/.test(txt) || /rgv587|punish/.test(location.href); const ready = document.querySelectorAll("a[href*=\'detail.damai.cn\']").length > 0 || /共\\s*\\d+\\s*个商品/.test(txt); return { slider: slider, ready: ready }; } catch (e) { return { slider: false, ready: false }; } })()'
|
||||
|
||||
async function waitForListOrSlider(cdp, timeoutMs) {
|
||||
const t0 = Date.now()
|
||||
while (Date.now() - t0 < timeoutMs) {
|
||||
try {
|
||||
const r = await cdp.send('Runtime.evaluate', { expression: CHECK_EXPR, returnByValue: true }, 5000)
|
||||
const v = r && r.result && r.result.result ? r.result.result.value : null
|
||||
if (v) {
|
||||
if (v.slider) return { slider: true }
|
||||
if (v.ready) return { ready: true }
|
||||
}
|
||||
} catch (e) { /* 页面导航中 evaluate 可能短暂失败,忽略后重试 */ }
|
||||
await sleep(600)
|
||||
}
|
||||
return { timeout: true }
|
||||
}
|
||||
|
||||
// ============ 大麦 DOM 解析(字符串版,无 DOM 库) ============
|
||||
|
||||
// 价格提取:大麦列表价格形如 "130-2620元" / "380.0元" / "¥130"(无 ¥ 符号是常态)
|
||||
// 区间价取下限用于 ≤ 过滤;返回 { price, priceText },提取不到返回 null
|
||||
function extractPrice(win) {
|
||||
let text = ''
|
||||
const blockM = win.match(/class="[^"]*price[^"]*"[^>]*>([\s\S]{0,120}?)<\/(?:div|span|p|em|b|i)>/i)
|
||||
if (blockM) text = blockM[1].replace(/<[^>]+>/g, '').trim()
|
||||
if (!text) {
|
||||
const symM = win.match(/[¥¥]\s*[\d.]+(?:\s*[-~至]\s*[\d.]+)?/)
|
||||
if (symM) text = symM[0]
|
||||
}
|
||||
if (!text) {
|
||||
const yuanM = win.match(/[\d.]+(?:\s*[-~至]\s*[\d.]+)?\s*元/)
|
||||
if (yuanM) text = yuanM[0]
|
||||
}
|
||||
if (!text) return null
|
||||
const rangeM = text.match(/([\d.]+)\s*[-~至]\s*[\d.]+/)
|
||||
const singleM = text.match(/([\d.]+)/)
|
||||
const low = rangeM ? parseFloat(rangeM[1]) : (singleM ? parseFloat(singleM[1]) : NaN)
|
||||
if (!isFinite(low)) return null
|
||||
return { price: low, priceText: text.replace(/\s+/g, '') }
|
||||
}
|
||||
|
||||
function parseDamaiHtml(html, city) {
|
||||
if (/rgv587|punish|captcha|滑动验证|拖动滑块/i.test(html)) {
|
||||
throw new Error('需要人工完成滑块验证:请在自动打开的 Edge 窗口中完成滑块/登录(会话将自动保存),完成后重试')
|
||||
}
|
||||
const events = []
|
||||
const seen = new Set()
|
||||
const anchorRe = /<a\b([^>]*)>/g
|
||||
let m
|
||||
while ((m = anchorRe.exec(html))) {
|
||||
const attrs = m[1]
|
||||
const hrefM = attrs.match(/href="([^"]+)"/)
|
||||
if (!hrefM) continue
|
||||
let href = hrefM[1]
|
||||
const idM = href.match(/id=(\d+)/)
|
||||
if (!/damai\.cn/.test(href) || !idM) continue
|
||||
if (href.startsWith('//')) href = 'https:' + href
|
||||
|
||||
const titleM = attrs.match(/title="([^"]+)"/)
|
||||
let title = titleM ? titleM[1] : ''
|
||||
const win = html.substr(m.index, 3000)
|
||||
if (!title) {
|
||||
const t2 = win.match(/<[^>]*class="[^"]*title[^"]*"[^>]*>([\s\S]*?)<\/[^>]+>/)
|
||||
if (t2) title = t2[1].replace(/<[^>]+>/g, '').trim()
|
||||
}
|
||||
if (!title || seen.has(title)) continue
|
||||
seen.add(title)
|
||||
|
||||
const priceInfo = extractPrice(win)
|
||||
const timeM = win.match(/\d{4}[.\-/]\d{1,2}[.\-/]\d{1,2}[^<|]*/)
|
||||
const imgM = win.match(/<img[^>]*src="([^"]+)"/)
|
||||
|
||||
events.push({
|
||||
platform: 'damai',
|
||||
id: idM[1],
|
||||
title,
|
||||
price: priceInfo ? priceInfo.price : 9999,
|
||||
priceText: priceInfo ? priceInfo.priceText : '价格待定',
|
||||
showTime: timeM ? timeM[0].trim() : '',
|
||||
venue: '',
|
||||
city,
|
||||
poster: imgM ? imgM[1] : '',
|
||||
url: href
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// ============ 抓取主流程:复用常驻浏览器,只开/关标签页 ============
|
||||
|
||||
async function fetchDamai(city, pages) {
|
||||
await ensureBrowser()
|
||||
|
||||
const maxPages = Math.min(Number(pages) || 2, 3)
|
||||
let allEvents = []
|
||||
for (let p = 1; p <= maxPages; p++) {
|
||||
const url = `https://search.damai.cn/search.htm?keyword=&city=${DAMAI_CITY_CODE}&ctl=0&order=1&page=${p}`
|
||||
// 先开空白标签再 Page.navigate,避免对大麦页面发起两次请求
|
||||
const tab = await openTab('about:blank')
|
||||
if (!tab || !tab.webSocketDebuggerUrl) throw new Error('无法在常驻浏览器中打开新标签页')
|
||||
|
||||
let ws = null
|
||||
try {
|
||||
ws = await wsConnect(tab.webSocketDebuggerUrl)
|
||||
const cdp = makeCdp(ws)
|
||||
await cdp.send('Page.enable').catch(() => {})
|
||||
await cdp.send('Runtime.enable').catch(() => {})
|
||||
await cdp.send('Page.navigate', { url })
|
||||
|
||||
// 等待异步列表渲染完成(单页最多 20s)
|
||||
const state = await waitForListOrSlider(cdp, 20000)
|
||||
if (state.slider) {
|
||||
// 标签页保留在可见窗口,交给人工拖滑块;会话过验证后自动落盘
|
||||
try { ws.close() } catch (e) {}
|
||||
return { events: allEvents, sliderNeeded: true, sliderPage: p }
|
||||
}
|
||||
|
||||
const htmlRes = await cdp.send('Runtime.evaluate', { expression: 'document.documentElement.outerHTML', returnByValue: true })
|
||||
const html = htmlRes && htmlRes.result && htmlRes.result.result ? String(htmlRes.result.result.value || '') : ''
|
||||
try { ws.close() } catch (e) {}
|
||||
await closeTab(tab.id)
|
||||
|
||||
if (!html) { if (p === 1) throw new Error('页面 DOM 为空(可能仍在加载或被拦截)'); break }
|
||||
const items = parseDamaiHtml(html, city)
|
||||
if (items.length === 0) break
|
||||
allEvents = allEvents.concat(items)
|
||||
} catch (e) {
|
||||
try { if (ws) ws.close() } catch (e2) {}
|
||||
await closeTab(tab.id)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
return { events: allEvents, sliderNeeded: false, sliderPage: 0 }
|
||||
}
|
||||
|
||||
// ============ HTTP 服务(仅 127.0.0.1) ============
|
||||
|
||||
function sendJson(res, code, obj) {
|
||||
const body = JSON.stringify(obj)
|
||||
res.writeHead(code, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type'
|
||||
})
|
||||
res.end(body)
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const u = new URL(req.url, `http://127.0.0.1:${PORT}`)
|
||||
|
||||
if (req.method === 'OPTIONS') return sendJson(res, 204, {})
|
||||
|
||||
if (u.pathname === '/ping') {
|
||||
const ver = await cdpVersion()
|
||||
return sendJson(res, 200, {
|
||||
ok: true,
|
||||
agent: 'free_show_local_agent',
|
||||
version: 2,
|
||||
browser: !!findBrowser(),
|
||||
browser_up: !!ver,
|
||||
headless: HEADLESS,
|
||||
cdp_port: CDP_PORT,
|
||||
profile_dir: PROFILE_DIR
|
||||
})
|
||||
}
|
||||
|
||||
if (u.pathname === '/fetch' && req.method === 'GET') {
|
||||
const city = String(u.searchParams.get('city') || '上海')
|
||||
const pages = u.searchParams.get('pages') || 2
|
||||
if (city !== '上海') return sendJson(res, 400, { success: false, error: '本地代理仅支持上海' })
|
||||
try {
|
||||
const result = await fetchDamai(city, pages)
|
||||
if (result.sliderNeeded && result.events.length === 0) {
|
||||
return sendJson(res, 200, {
|
||||
success: false,
|
||||
code: 'need_manual_slider',
|
||||
error: '需要人工完成滑块验证:请在自动打开的 Edge 窗口中完成滑块/登录(会话将自动保存),完成后重试'
|
||||
})
|
||||
}
|
||||
const warning = result.sliderNeeded
|
||||
? ('第 ' + result.sliderPage + ' 页起触发滑块验证,该标签页已保留在 Edge 窗口,请人工处理后重试以补齐后续页')
|
||||
: ''
|
||||
return sendJson(res, 200, {
|
||||
success: true, platform: 'damai', city,
|
||||
newCount: result.events.length, events: result.events, warning
|
||||
})
|
||||
} catch (e) {
|
||||
return sendJson(res, 500, { success: false, error: String(e.message || e) })
|
||||
}
|
||||
}
|
||||
|
||||
return sendJson(res, 404, { ok: false, error: 'not_found' })
|
||||
})
|
||||
|
||||
server.listen(PORT, '127.0.0.1', () => {
|
||||
console.log('==================================================')
|
||||
console.log(' 免费演出雷达 · 本地抓取代理 v2(常驻浏览器版)')
|
||||
console.log(` 监听: http://127.0.0.1:${PORT} (仅本机)`)
|
||||
console.log(` 浏览器调试端口: ${CDP_PORT} | 模式: ${HEADLESS ? 'headless' : '可视化窗口'}`)
|
||||
console.log(' 浏览器: ' + (findBrowser() || '未找到 Chrome/Edge!'))
|
||||
console.log(' 配置目录: ' + PROFILE_DIR)
|
||||
console.log(' ------------------------------------------------')
|
||||
console.log(' 正在自动唤起 Edge 窗口...')
|
||||
console.log(' 首次使用请在该窗口人工完成滑块验证/登录(一次即可,会话自动保存)。')
|
||||
console.log(' 之后回工具箱页面点「Go」,代理复用同一浏览器抓取。')
|
||||
console.log(' 保持本窗口开启;Ctrl+C 退出(浏览器会继续保留会话)')
|
||||
console.log('==================================================')
|
||||
// 启动即拉起浏览器(用户要求①:启动代理自动唤起可视化 Edge),
|
||||
// 失败不阻塞 HTTP 服务,首次 /fetch 时会再重试
|
||||
ensureBrowser()
|
||||
.then(info => console.log(info.reused
|
||||
? ' 已复用正在运行的 Edge 实例(调试端口 ' + CDP_PORT + ')'
|
||||
: ' Edge 窗口已打开,可在其中人工过滑块/登录'))
|
||||
.catch(e => console.log(' [警告] 自动唤起浏览器失败: ' + (e.message || e) + '(首次抓取时会重试)'))
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>免费演出雷达</title>
|
||||
<link rel="icon" href="/icons/free_show.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/common/common.css" />
|
||||
<link rel="stylesheet" href="/tools/free_show/free_show.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="fs-app">
|
||||
<div class="fs-header">
|
||||
<h1>FREE SHOW</h1>
|
||||
<span class="tagline">免费演出雷达</span>
|
||||
</div>
|
||||
|
||||
<div class="fs-tab-bar">
|
||||
<button class="fs-tab-btn active" data-tab="showstart" id="tab-showstart">
|
||||
<span class="dot"></span>秀动
|
||||
</button>
|
||||
<button class="fs-tab-btn" data-tab="maoyan" id="tab-maoyan">
|
||||
<span class="dot"></span>猫眼
|
||||
</button>
|
||||
<button class="fs-tab-btn" data-tab="damai" id="tab-damai">
|
||||
<span class="dot"></span>大麦
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="fs-controls">
|
||||
<label>城市</label>
|
||||
<select id="city">
|
||||
<option value="上海">上海</option>
|
||||
</select>
|
||||
<label>≤ ¥</label>
|
||||
<input type="number" id="maxPrice" value="5" min="0" step="1">
|
||||
<label>页数</label>
|
||||
<select id="pages">
|
||||
<option value="2">2</option>
|
||||
<option value="3" selected>3</option>
|
||||
<option value="5">5</option>
|
||||
<option value="10">10</option>
|
||||
</select>
|
||||
<label>拉取</label>
|
||||
<select id="mode">
|
||||
<option value="cache" selected>读缓存</option>
|
||||
<option value="append">追加合并</option>
|
||||
<option value="overwrite">覆盖</option>
|
||||
</select>
|
||||
<button class="fs-btn-fetch" id="fetchBtn" data-platform="showstart">Go...</button>
|
||||
</div>
|
||||
|
||||
<div class="fs-status-bar" id="status">选择平台,点击抓取</div>
|
||||
<div id="content"></div>
|
||||
|
||||
<div class="fs-footer">
|
||||
数据来源:秀动 / 猫眼演出 / 大麦 · 仅供个人参考<br>
|
||||
大麦走家庭 IP:<a href="/tools/free_show/free_show_local_agent.js" download="free_show_local_agent.js">下载本地抓取代理</a>,本机运行 <code>node free_show_local_agent.js</code> 后保持窗口开启<br>
|
||||
代理会自动打开一个可见 Edge 窗口;首次使用请在该窗口人工完成滑块/登录(一次即可,会话自动保存),之后自动复用<br>
|
||||
免费演出名额有限,发现后请尽快预约
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="fs-dialog-root"></div>
|
||||
<script src="/tools/free_show/free_show.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -27,6 +27,7 @@
|
||||
<button id="m-tab-wisdom" class="subtab">理论与实践</button>
|
||||
<button id="m-tab-zhizi" class="subtab">知资札记</button>
|
||||
<button id="m-tab-tools" class="subtab">工具箱</button>
|
||||
<button id="m-tab-dip" class="subtab">建行定投评估</button>
|
||||
<div id="m-status" class="status status-inline" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
@@ -189,6 +190,79 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 建行定投评估:场外基金定投/赎回参考,点击按钮按需抓取(无定时任务) -->
|
||||
<section id="m-panel-dip" class="panel" style="display:none">
|
||||
<div class="bar">
|
||||
<button id="dip-btn-run" class="btn">开始评估</button>
|
||||
<span id="dip-status" class="status status-inline" style="display:none"></span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table" id="dip-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:90px">指数名称</th>
|
||||
<th style="width:70px">代码</th>
|
||||
<th style="width:60px">市场</th>
|
||||
<th style="width:80px">PE-TTM</th>
|
||||
<th style="width:110px">近10年PE分位</th>
|
||||
<th style="width:95px">数据日期</th>
|
||||
<th style="width:130px">数据源</th>
|
||||
<th>定投建议</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dip-list">
|
||||
<tr><td colspan="8" class="muted" style="text-align:center;padding:30px">请点击「开始评估」获取四大指数最新 PE 估值与定投档位建议</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="dip-errors" style="display:none;margin-top:10px"></div>
|
||||
<div class="dip-thresholds" style="margin-top:14px;padding:12px 14px;border:1px solid #e2e8f0;border-radius:10px;background:#f8fafc;font-size:13px;line-height:1.9;color:#334155">
|
||||
<div style="font-weight:700;color:#111827;margin-bottom:4px">我的定投档位阈值(定投/止盈依据)</div>
|
||||
<div><b>沪深300</b>:PE<12(分位<30%)定投200元/日 | 12~14.5 定投150元/日 | 14.5~16 定投100元/日·可止盈50% | PE>16 暂停定投+止盈清仓</div>
|
||||
<div><b>中证500</b>:PE<25 定投200元/日 | 25~32 定投150元/日 | 32~36 定投100元/日·可止盈50% | PE>36 暂停定投+止盈清仓</div>
|
||||
<div><b>纳指100</b>:PE<24 定投200元/日 | 24~30 定投150元/日 | 30~36 定投100元/日·可止盈50% | PE>36 暂停定投+止盈清仓</div>
|
||||
<div><b>标普500</b>:PE<16 定投200元/日 | 16~21 定投150元/日 | 21~25 定投100元/日·可止盈50% | PE>25 暂停定投+止盈清仓</div>
|
||||
<div style="margin-top:6px;color:#64748b;font-size:12px">数据源:A股为中证指数官网(近10年日频自算分位),美股为蛋卷基金(分位窗口自2016年,约10年);标普500备源 multpl.com。点击按钮按需抓取,非实时行情,仅供参考。</div>
|
||||
</div>
|
||||
<!-- 富国中证红利备忘卡片:纯静态提醒,无数据源,实际盈亏以建行APP为准 -->
|
||||
<div style="margin-top:14px;padding:12px 14px;border:1px solid #fde68a;border-radius:10px;background:#fffbeb;font-size:13px;line-height:1.9;color:#334155">
|
||||
<div style="font-weight:700;color:#92400e;margin-bottom:4px">富国中证红利(100033)持仓提醒(备忘 · 无数据源 · 以建行APP实际盈亏为准)</div>
|
||||
<div><b>收益率提醒</b>:降至 <span style="color:#15803d;font-weight:700">-6%</span> → 大跌后分批加仓信号 | 升至 <span style="color:#b91c1c;font-weight:700">+7%</span> → 维持1000元定投</div>
|
||||
<div><b>净值精准提醒</b>(参考锚点净值 <b>3.282</b>):降至 <span style="color:#15803d;font-weight:700">3.05</span>(跌幅约7%)→ 提醒加仓 | 升至 <span style="color:#b91c1c;font-weight:700">3.52</span>(涨幅约7%)→ 维持1000元定投</div>
|
||||
</div>
|
||||
<!-- 投资纪律座右铭:红字黄底圆角标签 -->
|
||||
<div style="margin-top:14px;text-align:center">
|
||||
<span style="display:inline-block;padding:8px 18px;border-radius:999px;background:#fde047;color:#b91c1c;font-weight:700;font-size:14px;letter-spacing:0.5px">现金是未来的期权,不要把弹药消耗在普通波动里。一年真正的大机会没有几次。</span>
|
||||
</div>
|
||||
<!-- 六种盈利投资者自我提醒:黑底绿环编号样式,两栏布局,行高=字体120% -->
|
||||
<div style="margin-top:14px;padding:12px 14px;border-radius:12px;background:#0b0f0a;border:1px solid #24301a;display:grid;grid-template-columns:1fr 1fr;gap:0 22px">
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid rgba(163,230,53,0.3)">
|
||||
<div style="flex:0 0 32px;width:32px;height:32px;border-radius:50%;border:3px solid #a3e635;background:#131a10;color:#fff;font-weight:800;font-size:14px;display:flex;align-items:center;justify-content:center">1.</div>
|
||||
<div style="color:#d9f99d;font-size:13px;line-height:1.2"><b style="color:#fff">猎人型:</b>一年出手没几次,80%的时间都在等。不开枪则已,<b style="color:#bef264">一开枪就带着利润离场。</b></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid rgba(163,230,53,0.3)">
|
||||
<div style="flex:0 0 32px;width:32px;height:32px;border-radius:50%;border:3px solid #a3e635;background:#131a10;color:#fff;font-weight:800;font-size:14px;display:flex;align-items:center;justify-content:center">2.</div>
|
||||
<div style="color:#d9f99d;font-size:13px;line-height:1.2"><b style="color:#fff">守株型:</b>认准一只好股票,拿三五年。眼里没有分时,<b style="color:#bef264">只有趋势。</b></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid rgba(163,230,53,0.3)">
|
||||
<div style="flex:0 0 32px;width:32px;height:32px;border-radius:50%;border:3px solid #a3e635;background:#131a10;color:#fff;font-weight:800;font-size:14px;display:flex;align-items:center;justify-content:center">3.</div>
|
||||
<div style="color:#d9f99d;font-size:13px;line-height:1.2"><b style="color:#fff">工匠型:</b>反复做同一只票,高抛低吸、不断做T,<b style="color:#bef264">把成本一点点磨到接近零。</b></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid rgba(163,230,53,0.3)">
|
||||
<div style="flex:0 0 32px;width:32px;height:32px;border-radius:50%;border:3px solid #a3e635;background:#131a10;color:#fff;font-weight:800;font-size:14px;display:flex;align-items:center;justify-content:center">4.</div>
|
||||
<div style="color:#d9f99d;font-size:13px;line-height:1.2"><b style="color:#fff">龙头型:</b>只做主线,只追最强,不碰杂毛。<b style="color:#bef264">纪律比胆子更大,复盘比睡觉还认真。</b></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:8px 4px">
|
||||
<div style="flex:0 0 32px;width:32px;height:32px;border-radius:50%;border:3px solid #a3e635;background:#131a10;color:#fff;font-weight:800;font-size:14px;display:flex;align-items:center;justify-content:center">5.</div>
|
||||
<div style="color:#d9f99d;font-size:13px;line-height:1.2"><b style="color:#fff">系统型:</b>只信交易系统,不信消息,不猜新闻。<b style="color:#bef264">信号一到,机械执行,毫不犹豫。</b></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:8px 4px">
|
||||
<div style="flex:0 0 32px;width:32px;height:32px;border-radius:50%;border:3px solid #a3e635;background:#131a10;color:#fff;font-weight:800;font-size:14px;display:flex;align-items:center;justify-content:center">6.</div>
|
||||
<div style="color:#d9f99d;font-size:13px;line-height:1.2"><b style="color:#fff">清醒型:</b>懂得见好就收,<b style="color:#bef264">从不幻想卖在最高点。</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -343,6 +417,7 @@
|
||||
<script src="/common/common.js"></script>
|
||||
<script src="/tools/markets/markets.js?v=3"></script>
|
||||
<script src="/tools/markets/markets_tools.js?v=1"></script>
|
||||
<script src="/tools/markets/markets_dip_eval.js?v=1"></script>
|
||||
<script>
|
||||
(async function(){
|
||||
try {
|
||||
|
||||
@@ -1712,6 +1712,7 @@ const switchPanel = which => {
|
||||
const wisdomTab = el('m-tab-wisdom')
|
||||
const zhiziTab = el('m-tab-zhizi')
|
||||
const toolsTab = el('m-tab-tools')
|
||||
const dipTab = el('m-tab-dip')
|
||||
const rt = el('m-panel-realtime')
|
||||
const ar = el('m-panel-archive')
|
||||
const cal = el('m-panel-calendar')
|
||||
@@ -1719,25 +1720,27 @@ const switchPanel = which => {
|
||||
const news = el('m-panel-news')
|
||||
const wisdom = el('m-panel-wisdom')
|
||||
const tools = el('m-panel-tools')
|
||||
const dip = el('m-panel-dip')
|
||||
const hideDip = () => { if (dipTab) dipTab.classList.remove('active'); if (dip) dip.style.display = 'none' }
|
||||
if (!rtTab || !arTab || !calTab || !wkTab || !rt || !ar || !cal || !wk) return
|
||||
if (which === 'archive') {
|
||||
rtTab.classList.remove('active'); arTab.classList.add('active'); calTab.classList.remove('active'); wkTab.classList.remove('active'); if (newsTab) newsTab.classList.remove('active'); if (wisdomTab) wisdomTab.classList.remove('active'); if (zhiziTab) zhiziTab.classList.remove('active'); if (toolsTab) toolsTab.classList.remove('active')
|
||||
rt.style.display = 'none'; ar.style.display = ''; cal.style.display = 'none'; wk.style.display = 'none'; if (news) news.style.display = 'none'; if (wisdom) wisdom.style.display = 'none'; if (tools) tools.style.display = 'none'
|
||||
rt.style.display = 'none'; ar.style.display = ''; cal.style.display = 'none'; wk.style.display = 'none'; if (news) news.style.display = 'none'; if (wisdom) wisdom.style.display = 'none'; if (tools) tools.style.display = 'none'; hideDip()
|
||||
loadArchive()
|
||||
} else if (which === 'calendar') {
|
||||
rtTab.classList.remove('active'); arTab.classList.remove('active'); calTab.classList.add('active'); wkTab.classList.remove('active'); if (newsTab) newsTab.classList.remove('active'); if (wisdomTab) wisdomTab.classList.remove('active'); if (zhiziTab) zhiziTab.classList.remove('active'); if (toolsTab) toolsTab.classList.remove('active')
|
||||
rt.style.display = 'none'; ar.style.display = 'none'; cal.style.display = ''; wk.style.display = 'none'; if (news) news.style.display = 'none'; if (wisdom) wisdom.style.display = 'none'; if (tools) tools.style.display = 'none'
|
||||
rt.style.display = 'none'; ar.style.display = 'none'; cal.style.display = ''; wk.style.display = 'none'; if (news) news.style.display = 'none'; if (wisdom) wisdom.style.display = 'none'; if (tools) tools.style.display = 'none'; hideDip()
|
||||
if (!calendarState.month || !Array.isArray(calendarState.days) || calendarState.days.length === 0) loadSecurityCalendar(false)
|
||||
} else if (which === 'weekly') {
|
||||
rtTab.classList.remove('active'); arTab.classList.remove('active'); calTab.classList.remove('active'); wkTab.classList.add('active'); if (newsTab) newsTab.classList.remove('active'); if (wisdomTab) wisdomTab.classList.remove('active'); if (zhiziTab) zhiziTab.classList.remove('active'); if (toolsTab) toolsTab.classList.remove('active')
|
||||
rt.style.display = 'none'; ar.style.display = 'none'; cal.style.display = 'none'; wk.style.display = ''; if (news) news.style.display = 'none'; if (wisdom) wisdom.style.display = 'none'; if (tools) tools.style.display = 'none'
|
||||
rt.style.display = 'none'; ar.style.display = 'none'; cal.style.display = 'none'; wk.style.display = ''; if (news) news.style.display = 'none'; if (wisdom) wisdom.style.display = 'none'; if (tools) tools.style.display = 'none'; hideDip()
|
||||
if (!weeklyState.weekStart || !Array.isArray(weeklyState.rows) || weeklyState.rows.length === 0) {
|
||||
setWeeklyIdleState('请选择周后点击“加载”读取历史,或点“刷新并补存”主动更新。', { clearData: false })
|
||||
}
|
||||
} else if (which === 'news') {
|
||||
rtTab.classList.remove('active'); arTab.classList.remove('active'); calTab.classList.remove('active'); wkTab.classList.remove('active'); if (wisdomTab) wisdomTab.classList.remove('active'); if (zhiziTab) zhiziTab.classList.remove('active'); if (toolsTab) toolsTab.classList.remove('active')
|
||||
if (newsTab) newsTab.classList.add('active')
|
||||
rt.style.display = 'none'; ar.style.display = 'none'; cal.style.display = 'none'; wk.style.display = 'none'; if (news) news.style.display = ''; if (wisdom) wisdom.style.display = 'none'; if (tools) tools.style.display = 'none'
|
||||
rt.style.display = 'none'; ar.style.display = 'none'; cal.style.display = 'none'; wk.style.display = 'none'; if (news) news.style.display = ''; if (wisdom) wisdom.style.display = 'none'; if (tools) tools.style.display = 'none'; hideDip()
|
||||
loadGlobalNews(false)
|
||||
} else if (which === 'wisdom') {
|
||||
window.open('https://traesite.umersoft.com:8975/tools/markets/wallstreet_wisdom.html', '_blank')
|
||||
@@ -1755,6 +1758,19 @@ const switchPanel = which => {
|
||||
if (news) news.style.display = 'none'
|
||||
if (wisdom) wisdom.style.display = 'none'
|
||||
if (tools) tools.style.display = ''
|
||||
hideDip()
|
||||
} else if (which === 'dip') {
|
||||
rtTab.classList.remove('active'); arTab.classList.remove('active'); calTab.classList.remove('active'); wkTab.classList.remove('active')
|
||||
if (newsTab) newsTab.classList.remove('active')
|
||||
if (wisdomTab) wisdomTab.classList.remove('active')
|
||||
if (zhiziTab) zhiziTab.classList.remove('active')
|
||||
if (toolsTab) toolsTab.classList.remove('active')
|
||||
if (dipTab) dipTab.classList.add('active')
|
||||
rt.style.display = 'none'; ar.style.display = 'none'; cal.style.display = 'none'; wk.style.display = 'none'
|
||||
if (news) news.style.display = 'none'
|
||||
if (wisdom) wisdom.style.display = 'none'
|
||||
if (tools) tools.style.display = 'none'
|
||||
if (dip) dip.style.display = ''
|
||||
} else {
|
||||
rtTab.classList.add('active'); arTab.classList.remove('active'); calTab.classList.remove('active'); wkTab.classList.remove('active'); if (newsTab) newsTab.classList.remove('active')
|
||||
if (wisdomTab) wisdomTab.classList.remove('active')
|
||||
@@ -1763,6 +1779,7 @@ const switchPanel = which => {
|
||||
rt.style.display = ''; ar.style.display = 'none'; cal.style.display = 'none'; wk.style.display = 'none'; if (news) news.style.display = 'none'
|
||||
if (wisdom) wisdom.style.display = 'none'
|
||||
if (tools) tools.style.display = 'none'
|
||||
hideDip()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1793,6 +1810,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const stWisdom = el('m-tab-wisdom'); if (stWisdom) stWisdom.addEventListener('click', () => switchPanel('wisdom'))
|
||||
const stZhizi = el('m-tab-zhizi'); if (stZhizi) stZhizi.addEventListener('click', () => switchPanel('zhizi'))
|
||||
const stTools = el('m-tab-tools'); if (stTools) stTools.addEventListener('click', () => switchPanel('tools'))
|
||||
const stDip = el('m-tab-dip'); if (stDip) stDip.addEventListener('click', () => switchPanel('dip'))
|
||||
const cbp = el('cal-btn-prev'); if (cbp) cbp.addEventListener('click', () => navigateMonthCalendar(-1))
|
||||
const cbc = el('cal-btn-current'); if (cbc) cbc.addEventListener('click', () => { const i = el('cal-month'); if (i) i.value = todayMonthLocal(); loadSecurityCalendar(false) })
|
||||
const cbn = el('cal-btn-next'); if (cbn) cbn.addEventListener('click', () => navigateMonthCalendar(1))
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* ===== 建行定投评估(前端逻辑) =====
|
||||
* 银行场外基金定投/赎回参考:点击「开始评估」按需抓取四大指数 PE-TTM 与近10年分位,无定时任务。
|
||||
*
|
||||
* 档位阈值与后端 src/server/markets_dip_eval.js 保持一致:
|
||||
* 沪深300:PE<12 定投200元/日 | 12~14.5 定投150元/日 | 14.5~16 定投100元/日·可止盈50% | PE>16 暂停定投+止盈清仓
|
||||
* 中证500:PE<25 定投200元/日 | 25~32 定投150元/日 | 32~36 定投100元/日·可止盈50% | PE>36 暂停定投+止盈清仓
|
||||
* 纳指100:PE<24 定投200元/日 | 24~30 定投150元/日 | 30~36 定投100元/日·可止盈50% | PE>36 暂停定投+止盈清仓
|
||||
* 标普500:PE<16 定投200元/日 | 16~21 定投150元/日 | 21~25 定投100元/日·可止盈50% | PE>25 暂停定投+止盈清仓
|
||||
*
|
||||
* 依赖 markets.js 提供的全局函数:el / runTrackedInteraction / setButtonBusy
|
||||
*/
|
||||
|
||||
// 档位 → 徽章样式(绿=定投加仓、蓝=轻定投、黄=可止盈一半、红=暂停+清仓)
|
||||
const DIP_LEVEL_STYLE = {
|
||||
buy_heavy: { bg: '#dcfce7', color: '#15803d', label: '低估档' },
|
||||
buy: { bg: '#dcfce7', color: '#16a34a', label: '定投档' },
|
||||
buy_light: { bg: '#e0f2fe', color: '#0369a1', label: '轻定投档' },
|
||||
take_half: { bg: '#fef3c7', color: '#b45309', label: '止盈档' },
|
||||
exit: { bg: '#fee2e2', color: '#b91c1c', label: '清仓档' },
|
||||
unknown: { bg: '#f1f5f9', color: '#64748b', label: '未知' }
|
||||
}
|
||||
|
||||
// HTML 转义,防止数据源返回内容注入
|
||||
const dipEscapeHtml = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[c]))
|
||||
|
||||
// 渲染评估结果表格
|
||||
const renderDipReport = data => {
|
||||
const list = el('dip-list')
|
||||
const status = el('dip-status')
|
||||
const errorsBox = el('dip-errors')
|
||||
if (!list) return
|
||||
const rows = Array.isArray(data.rows) ? data.rows : []
|
||||
|
||||
if (!rows.length) {
|
||||
list.innerHTML = '<tr><td colspan="8" class="muted" style="text-align:center;padding:30px">未获取到有效数据,请查看下方异常提示</td></tr>'
|
||||
} else {
|
||||
list.innerHTML = rows.map(r => {
|
||||
const st = DIP_LEVEL_STYLE[r.level] || DIP_LEVEL_STYLE.unknown
|
||||
const pe = Number.isFinite(r.pe_ttm) ? r.pe_ttm.toFixed(2) : '—'
|
||||
const pct = Number.isFinite(r.percentile) ? `${r.percentile.toFixed(1)}%` : '—'
|
||||
const note = r.note ? `<div style="margin-top:6px;font-size:12px;color:#b45309;line-height:1.6">⚠ ${dipEscapeHtml(r.note)}</div>` : ''
|
||||
return `<tr>
|
||||
<td><b>${dipEscapeHtml(r.name)}</b></td>
|
||||
<td>${dipEscapeHtml(r.code)}</td>
|
||||
<td>${dipEscapeHtml(r.market || '—')}</td>
|
||||
<td style="font-weight:700">${pe}</td>
|
||||
<td title="${dipEscapeHtml(r.percentile_note || '')}">${pct}</td>
|
||||
<td>${dipEscapeHtml(r.pe_date || '—')}</td>
|
||||
<td>${dipEscapeHtml(r.source || '—')}</td>
|
||||
<td>
|
||||
<span style="display:inline-block;padding:3px 10px;border-radius:999px;background:${st.bg};color:${st.color};font-weight:700;font-size:13px">${st.label}:${dipEscapeHtml(r.advice || '—')}</span>
|
||||
${note}
|
||||
</td>
|
||||
</tr>`
|
||||
}).join('')
|
||||
}
|
||||
|
||||
if (status) {
|
||||
status.style.display = ''
|
||||
status.textContent = `评估时间 ${data.captured_at || '—'} | 指数 ${rows.length} 个 | 异常 ${(Array.isArray(data.errors) ? data.errors.length : 0)} 条`
|
||||
}
|
||||
|
||||
// 异常提示区:单指数失败不影响其他指数展示
|
||||
if (errorsBox) {
|
||||
const errs = Array.isArray(data.errors) ? data.errors : []
|
||||
if (errs.length) {
|
||||
errorsBox.style.display = ''
|
||||
errorsBox.innerHTML = errs.map(e => `<div style="font-size:13px;color:#b91c1c;line-height:1.9">⚠ ${dipEscapeHtml(e)}</div>`).join('')
|
||||
} else {
|
||||
errorsBox.style.display = 'none'
|
||||
errorsBox.innerHTML = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 点击「开始评估」:走 markets 统一交互详情弹窗(双模式由 runTrackedInteraction 内部处理)
|
||||
const runDipEval = async () => {
|
||||
const btn = el('dip-btn-run')
|
||||
try {
|
||||
const data = await runTrackedInteraction({
|
||||
title: '建行定投评估',
|
||||
action: '抓取四大指数 PE-TTM 与近10年分位并判档',
|
||||
summary: '按需抓取:中证指数官网 / 蛋卷基金(备源 multpl.com)...',
|
||||
button: btn,
|
||||
loadingText: '评估中…',
|
||||
request: { url: '/api/markets/dip_eval', method: 'GET' },
|
||||
successText: d => `评估完成:${(d && Array.isArray(d.rows)) ? d.rows.length : 0} 个指数,${((d && d.errors) || []).length} 条异常`
|
||||
}, async ctx => {
|
||||
return await ctx.fetchJson('/api/markets/dip_eval', {}, { 说明: '按需抓取,无定时任务' })
|
||||
})
|
||||
renderDipReport(data || {})
|
||||
} catch (e) {
|
||||
// 交互弹窗已展示错误详情(异常时窗口停留),这里给表格区一个兜底提示
|
||||
const list = el('dip-list')
|
||||
if (list) {
|
||||
list.innerHTML = `<tr><td colspan="8" style="text-align:center;padding:30px;color:#b91c1c">评估失败:${dipEscapeHtml(String((e && e.message) || e))}(网络波动或数据源不可用,可稍后重试)</td></tr>`
|
||||
}
|
||||
const status = el('dip-status')
|
||||
if (status) status.style.display = 'none'
|
||||
const errorsBox = el('dip-errors')
|
||||
if (errorsBox) { errorsBox.style.display = 'none'; errorsBox.innerHTML = '' }
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const btn = el('dip-btn-run')
|
||||
if (btn) btn.addEventListener('click', runDipEval)
|
||||
})
|
||||
Reference in New Issue
Block a user