feat: 新增免费演出雷达工具与定投评估功能,优化周报提醒逻辑
1. 新增免费演出雷达工具:含秀动/猫眼/大麦平台抓取、本地代理鉴权配置 2. 新增建行定投评估模块:四大指数PE-TTM估值与定投档位建议 3. 新增推送告警白名单过滤功能,限制推送指定指数告警 4. 重构周报首页提醒逻辑,移至服务端处理并减少前端请求 5. 新增多套调试探针脚本与单元测试用例
This commit is contained in:
@@ -0,0 +1,180 @@
|
|||||||
|
// 调试:验证 free_show_local_agent v2 的 CDP + 手写 WebSocket 管道
|
||||||
|
// 仅用 about:blank 等无害 URL,不访问大麦(保护服务器 IP)
|
||||||
|
// 用法:node dev_test_scripts/debug/debug_agent_cdp.js
|
||||||
|
'use strict'
|
||||||
|
const http = require('http')
|
||||||
|
const net = require('net')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const { spawn } = require('child_process')
|
||||||
|
|
||||||
|
const CDP_PORT = 9322 // 避开代理默认 9222
|
||||||
|
const PROFILE = path.join(__dirname, '.edge_debug_profile')
|
||||||
|
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'
|
||||||
|
]
|
||||||
|
for (const p of candidates) if (fs.existsSync(p)) return p
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function cdpHttp(method, p) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request({ host: '127.0.0.1', port: CDP_PORT, path: p, method, timeout: 5000 }, res => {
|
||||||
|
let body = ''
|
||||||
|
res.on('data', c => { body += c })
|
||||||
|
res.on('end', () => { try { resolve(JSON.parse(body)) } catch (e) { reject(new Error('parse fail')) } })
|
||||||
|
})
|
||||||
|
req.on('timeout', () => req.destroy(new Error('timeout')))
|
||||||
|
req.on('error', reject)
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 与代理同款最小 WS 客户端(复制自 free_show_local_agent.js) ----
|
||||||
|
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(t) { try { sock.write(encodeWsFrame(t)) } 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
|
||||||
|
if (!/^HTTP\/1\.1\s+101/.test(buf.slice(0, idx).toString())) { reject(new Error('握手失败')); 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 }
|
||||||
|
if (frame.opcode === 0xA) continue
|
||||||
|
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() })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const id = nextId++
|
||||||
|
const timer = setTimeout(() => { if (pending.has(id)) { pending.delete(id); reject(new Error('CDP 超时: ' + method)) } }, 15000)
|
||||||
|
pending.set(id, msg => { clearTimeout(timer); resolve(msg) })
|
||||||
|
ws.send(JSON.stringify({ id, method, params: params || {} }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const browser = findBrowser()
|
||||||
|
if (!browser) { console.log('FAIL: 未找到浏览器'); return }
|
||||||
|
console.log('浏览器:', browser)
|
||||||
|
const child = spawn(browser, [
|
||||||
|
'--headless=new', '--disable-gpu',
|
||||||
|
'--remote-debugging-port=' + CDP_PORT,
|
||||||
|
'--user-data-dir=' + PROFILE,
|
||||||
|
'--disable-blink-features=AutomationControlled',
|
||||||
|
'--exclude-switches=enable-automation',
|
||||||
|
'--no-first-run', '--no-default-browser-check',
|
||||||
|
'about:blank'
|
||||||
|
], { detached: true, stdio: 'ignore' })
|
||||||
|
child.unref()
|
||||||
|
|
||||||
|
let ver = null
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
await sleep(500)
|
||||||
|
ver = await cdpHttp('GET', '/json/version').catch(() => null)
|
||||||
|
if (ver) break
|
||||||
|
}
|
||||||
|
if (!ver) { console.log('FAIL: CDP 未就绪'); try { child.kill() } catch (e) {}; return }
|
||||||
|
console.log('PASS: /json/version =>', ver.Browser || ver['User-Agent'] || 'ok')
|
||||||
|
|
||||||
|
let tab = null
|
||||||
|
try { tab = await cdpHttp('PUT', '/json/new?' + encodeURIComponent('about:blank')) }
|
||||||
|
catch (e) { tab = await cdpHttp('GET', '/json/new?' + encodeURIComponent('about:blank')).catch(() => null) }
|
||||||
|
if (!tab || !tab.webSocketDebuggerUrl) { console.log('FAIL: 开标签失败'); try { child.kill() } catch (e) {}; return }
|
||||||
|
console.log('PASS: /json/new =>', tab.id)
|
||||||
|
|
||||||
|
const ws = await wsConnect(tab.webSocketDebuggerUrl)
|
||||||
|
const cdp = makeCdp(ws)
|
||||||
|
const r1 = await cdp.send('Runtime.evaluate', { expression: '1+1', returnByValue: true })
|
||||||
|
console.log('PASS: evaluate 1+1 =>', r1 && r1.result && r1.result.result ? r1.result.result.value : 'null')
|
||||||
|
const r2 = await cdp.send('Runtime.evaluate', { expression: 'navigator.webdriver', returnByValue: true })
|
||||||
|
console.log('INFO: navigator.webdriver =>', r2 && r2.result && r2.result.result ? r2.result.result.value : 'null')
|
||||||
|
ws.close()
|
||||||
|
await cdpHttp('GET', '/json/close/' + tab.id).catch(() => null)
|
||||||
|
console.log('PASS: 标签已关闭')
|
||||||
|
|
||||||
|
try { child.kill() } catch (e) {}
|
||||||
|
console.log('DONE')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.log('FAIL:', e.message || e); process.exit(1) })
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* 探针脚本:验证「建行定投评估」所需数据源
|
||||||
|
* 目标:
|
||||||
|
* - 沪深300 / 中证500:PE-TTM + 近10年PE历史(算分位)
|
||||||
|
* - 标普500 / 纳指100:实时 PE-TTM(尽力找10年历史)
|
||||||
|
* 用法:node dev_test_scripts/debug/debug_dip_eval_sources.js
|
||||||
|
*/
|
||||||
|
const axios = require('axios')
|
||||||
|
|
||||||
|
const UA = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', 'Referer': 'https://quote.eastmoney.com/' }
|
||||||
|
|
||||||
|
const cut = (s, n = 500) => String(s == null ? '' : s).replace(/\s+/g, ' ').slice(0, n)
|
||||||
|
|
||||||
|
// ---------- 1. 东方财富数据中心「估值分析」(A股指数每日 PE_TTM 历史) ----------
|
||||||
|
const probeEmValuation = async (code, pageSize = 3) => {
|
||||||
|
const url = `https://datacenter-web.eastmoney.com/api/data/v1/get?reportName=RPT_VALUEANALYSIS_DET&columns=ALL&filter=(SECURITY_CODE%3D%22${code}%22)&pageNumber=1&pageSize=${pageSize}&sortTypes=-1&sortColumns=TRADE_DATE&source=WEB&client=WEB`
|
||||||
|
try {
|
||||||
|
const res = await axios.get(url, { timeout: 10000, headers: UA, validateStatus: () => true })
|
||||||
|
const rows = res.data && res.data.result && res.data.result.data
|
||||||
|
if (!Array.isArray(rows) || !rows.length) return console.log(` [东财估值分析 ${code}] 无数据 status=${res.status} body=${cut(JSON.stringify(res.data), 300)}`)
|
||||||
|
const r = rows[0]
|
||||||
|
console.log(` [东财估值分析 ${code}] OK 最新=${r.TRADE_DATE} PE_TTM=${r.PE_TTM} PE静态=${r.PE_LAR} PE动=${r.PE_TTM_RATIO || r.DYNAMIC_PE} 字段=${Object.keys(r).join(',')}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` [东财估值分析 ${code}] 异常: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 2. 东方财富 push2 实时字段(f162/f163/f164/f167) ----------
|
||||||
|
const probePush2 = async (secid) => {
|
||||||
|
const url = `https://push2.eastmoney.com/api/qt/stock/get?secid=${secid}&fields=f43,f57,f58,f162,f163,f164,f167&invt=2&fltt=2`
|
||||||
|
try {
|
||||||
|
const res = await axios.get(url, { timeout: 10000, headers: UA, validateStatus: () => true })
|
||||||
|
const d = res.data && res.data.data
|
||||||
|
if (!d) return console.log(` [push2 ${secid}] 无数据 body=${cut(JSON.stringify(res.data), 300)}`)
|
||||||
|
console.log(` [push2 ${secid}] OK 名称=${d.f58} 点位=${d.f43} f162=${d.f162} f163=${d.f163} f164=${d.f164} f167=${d.f167}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` [push2 ${secid}] 异常: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 3. 腾讯美股指数 ----------
|
||||||
|
const probeTencent = async (symbols) => {
|
||||||
|
const iconv = require('iconv-lite')
|
||||||
|
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
|
||||||
|
try {
|
||||||
|
const res = await axios.get(url, { timeout: 10000, responseType: 'arraybuffer', validateStatus: () => true })
|
||||||
|
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
|
||||||
|
for (const line of String(text).split(/;\s*/).filter(Boolean)) {
|
||||||
|
console.log(` [腾讯] ${cut(line, 600)}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` [腾讯 ${symbols}] 异常: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 4. 新浪美股指数 ----------
|
||||||
|
const probeSina = async (symbols) => {
|
||||||
|
const iconv = require('iconv-lite')
|
||||||
|
const url = `https://hq.sinajs.cn/list=${symbols.join(',')}`
|
||||||
|
try {
|
||||||
|
const res = await axios.get(url, { timeout: 10000, responseType: 'arraybuffer', headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://finance.sina.com.cn' }, validateStatus: () => true })
|
||||||
|
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
|
||||||
|
for (const line of String(text).split(/\r?\n/).filter(Boolean)) {
|
||||||
|
console.log(` [新浪] ${cut(line, 600)}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` [新浪 ${symbols}] 异常: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 5. multpl.com 标普500 PE(当前 + 月度历史) ----------
|
||||||
|
const probeMultpl = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('https://www.multpl.com/s-p-500-pe-ratio', { timeout: 15000, headers: { 'User-Agent': UA['User-Agent'] }, validateStatus: () => true })
|
||||||
|
const m = String(res.data).match(/Current S&P 500 PE Ratio[\s\S]{0,400}?([0-9]+\.[0-9]+)/i)
|
||||||
|
console.log(` [multpl 当前PE] status=${res.status} 匹配=${m ? m[1] : '未匹配'} 片段=${cut(res.data.match(/Current S&P 500 PE Ratio[\s\S]{0,200}/i), 260)}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` [multpl 当前PE] 异常: ${e.message}`)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await axios.get('https://www.multpl.com/s-p-500-pe-ratio/table/by-month', { timeout: 15000, headers: { 'User-Agent': UA['User-Agent'] }, validateStatus: () => true })
|
||||||
|
const html = String(res.data)
|
||||||
|
const rows = html.match(/<tr>[\s\S]*?<\/tr>/g) || []
|
||||||
|
console.log(` [multpl 月度历史] status=${res.status} 表格行数=${rows.length} 示例=${cut(rows.slice(1, 4).join(' | '), 300)}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` [multpl 月度历史] 异常: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 6. WSJ 指数页(NDX / SPX) ----------
|
||||||
|
const probeWsj = async (path) => {
|
||||||
|
const url = `https://www.wsj.com/market-data/quotes/index/${path}`
|
||||||
|
try {
|
||||||
|
const res = await axios.get(url, { timeout: 15000, headers: { 'User-Agent': UA['User-Agent'], 'Accept': 'text/html' }, validateStatus: () => true })
|
||||||
|
const html = String(res.data)
|
||||||
|
const m = html.match(/P\/E[\s\S]{0,120}?([0-9][0-9,]*\.?[0-9]*)/i)
|
||||||
|
console.log(` [WSJ ${path}] status=${res.status} len=${html.length} PE匹配=${m ? m[1] : '未匹配'}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` [WSJ ${path}] 异常: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 7. 东财全球指数 push2(美股 secid=100.*) ----------
|
||||||
|
const probeEmGlobal = async () => {
|
||||||
|
await probePush2('100.INX') // 标普500
|
||||||
|
await probePush2('100.NDX') // 纳指100(猜测代码)
|
||||||
|
await probePush2('100.DJIA') // 道指对照
|
||||||
|
}
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
console.log('=== A股指数:东财数据中心估值分析(近10年每日PE_TTM) ===')
|
||||||
|
await probeEmValuation('000300')
|
||||||
|
await probeEmValuation('000905')
|
||||||
|
|
||||||
|
console.log('\n=== A股指数:push2 实时字段 ===')
|
||||||
|
await probePush2('1.000300')
|
||||||
|
await probePush2('1.000905')
|
||||||
|
|
||||||
|
console.log('\n=== 美股指数:腾讯 ===')
|
||||||
|
await probeTencent(['usINX', 'usIXIC', 'usDJI', 'us.NDX'])
|
||||||
|
|
||||||
|
console.log('\n=== 美股指数:新浪 ===')
|
||||||
|
await probeSina(['gb_$inx', 'gb_$ixic', 'gb_$dji', 'gb_$ndx'])
|
||||||
|
|
||||||
|
console.log('\n=== 美股指数:东财全球指数 push2 ===')
|
||||||
|
await probeEmGlobal()
|
||||||
|
|
||||||
|
console.log('\n=== 标普500:multpl.com ===')
|
||||||
|
await probeMultpl()
|
||||||
|
|
||||||
|
console.log('\n=== 美股指数:WSJ ===')
|
||||||
|
await probeWsj('NDX')
|
||||||
|
await probeWsj('SPX')
|
||||||
|
|
||||||
|
console.log('\n探针结束')
|
||||||
|
})()
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* 探针脚本(第二轮):
|
||||||
|
* - A股指数 PE 历史:中证指数官网 csindex / 蛋卷基金 djapi
|
||||||
|
* - 美股指数 PE:Yahoo quoteSummary / CNBC / multpl 历史表格修正
|
||||||
|
* 用法:node dev_test_scripts/debug/debug_dip_eval_sources2.js
|
||||||
|
*/
|
||||||
|
const axios = require('axios')
|
||||||
|
const UA = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36' }
|
||||||
|
const cut = (s, n = 400) => String(s == null ? '' : s).replace(/\s+/g, ' ').slice(0, n)
|
||||||
|
|
||||||
|
// ---------- 1. 中证指数官网(指数官方每日数据,含市盈率) ----------
|
||||||
|
const probeCsindex = async (code) => {
|
||||||
|
const url = `https://www.csindex.com.cn/csindex-home/perf/index-perf?indexCode=${code}&startDate=20150701&endDate=20260804`
|
||||||
|
try {
|
||||||
|
const res = await axios.get(url, { timeout: 15000, headers: { ...UA, 'Referer': 'https://www.csindex.com.cn/' }, validateStatus: () => true })
|
||||||
|
const d = res.data && res.data.data
|
||||||
|
if (!Array.isArray(d) || !d.length) return console.log(` [中证官网 ${code}] 无数据 status=${res.status} body=${cut(JSON.stringify(res.data), 300)}`)
|
||||||
|
const first = d[0], last = d[d.length - 1]
|
||||||
|
console.log(` [中证官网 ${code}] OK 记录数=${d.length} 首=${JSON.stringify(first).slice(0, 200)}`)
|
||||||
|
console.log(` [中证官网 ${code}] 尾=${JSON.stringify(last).slice(0, 200)}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` [中证官网 ${code}] 异常: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 2. 蛋卷基金指数估值 ----------
|
||||||
|
const probeDanjuan = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('https://danjuanfunds.com/djapi/index_eva/dj', { timeout: 15000, headers: UA, validateStatus: () => true })
|
||||||
|
const items = res.data && res.data.data && res.data.data.items
|
||||||
|
if (!Array.isArray(items)) return console.log(` [蛋卷 index_eva] 无items status=${res.status} body=${cut(JSON.stringify(res.data), 300)}`)
|
||||||
|
const targets = items.filter(i => /000300|000905|沪深300|中证500|标普|纳/i.test(JSON.stringify(i)))
|
||||||
|
console.log(` [蛋卷 index_eva] OK 总数=${items.length} 相关=${targets.length}`)
|
||||||
|
for (const t of targets.slice(0, 8)) {
|
||||||
|
console.log(` ${cut(JSON.stringify(t), 350)}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` [蛋卷 index_eva] 异常: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 3. Yahoo quoteSummary(trailingPE) ----------
|
||||||
|
const probeYahoo = async (symbol) => {
|
||||||
|
const url = `https://query1.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(symbol)}?modules=summaryDetail,price`
|
||||||
|
try {
|
||||||
|
const res = await axios.get(url, { timeout: 15000, headers: UA, validateStatus: () => true })
|
||||||
|
const r = res.data && res.data.quoteSummary && res.data.quoteSummary.result && res.data.quoteSummary.result[0]
|
||||||
|
if (!r) return console.log(` [Yahoo ${symbol}] 无数据 status=${res.status} body=${cut(JSON.stringify(res.data), 250)}`)
|
||||||
|
const sd = r.summaryDetail || {}
|
||||||
|
const pr = r.price || {}
|
||||||
|
console.log(` [Yahoo ${symbol}] OK 名称=${pr.shortName} 价格=${pr.regularMarketPrice && pr.regularMarketPrice.raw} trailingPE=${sd.trailingPE && sd.trailingPE.raw} forwardPE=${sd.forwardPE && sd.forwardPE.raw}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` [Yahoo ${symbol}] 异常: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 4. CNBC quote ----------
|
||||||
|
const probeCnbc = async () => {
|
||||||
|
const url = 'https://quote.cnbc.com/quote-html-webservice/restQuote/symbolType/symbol?symbols=.NDX|.INX&requestMethod=itv&noform=1&partnerId=2&fund=1&exthrs=1&output=json'
|
||||||
|
try {
|
||||||
|
const res = await axios.get(url, { timeout: 15000, headers: UA, validateStatus: () => true })
|
||||||
|
const quotes = res.data && res.data.FormattedQuoteResult && res.data.FormattedQuoteResult.formattedQuote
|
||||||
|
if (!Array.isArray(quotes)) return console.log(` [CNBC] 无数据 status=${res.status} body=${cut(JSON.stringify(res.data), 300)}`)
|
||||||
|
for (const q of quotes) {
|
||||||
|
console.log(` [CNBC ${q.symbol}] 名称=${q.name} 价格=${q.last} PE=${q.Pe} pe=${q.pe} 全字段=${cut(Object.keys(q).join(','), 300)}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` [CNBC] 异常: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 5. multpl 历史表格(修正解析) ----------
|
||||||
|
const probeMultplHistory = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('https://www.multpl.com/s-p-500-pe-ratio/table/by-month', { timeout: 15000, headers: UA, validateStatus: () => true })
|
||||||
|
const html = String(res.data)
|
||||||
|
const tbody = html.match(/<tbody>([\s\S]*?)<\/tbody>/)
|
||||||
|
const rows = tbody ? (tbody[1].match(/<tr>[\s\S]*?<\/tr>/g) || []) : []
|
||||||
|
console.log(` [multpl 历史] status=${res.status} tbody行数=${rows.length}`)
|
||||||
|
for (const r of rows.slice(0, 3)) console.log(` ${cut(r, 150)}`)
|
||||||
|
if (!rows.length) {
|
||||||
|
const i = html.indexOf('datatable')
|
||||||
|
console.log(` datatable 附近片段: ${cut(html.slice(Math.max(0, i - 100), i + 500), 550)}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` [multpl 历史] 异常: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 6. 乐咕乐股(公开接口尝试) ----------
|
||||||
|
const probeLegu = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('https://legulegu.com/api/stockdata/index-basic-info?indexCode=000300', { timeout: 15000, headers: { ...UA, 'Referer': 'https://legulegu.com/stockdata/shanghaiPE' }, validateStatus: () => true })
|
||||||
|
console.log(` [乐咕 index-basic-info] status=${res.status} body=${cut(JSON.stringify(res.data), 300)}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` [乐咕] 异常: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
console.log('=== 中证指数官网(A股指数官方数据,含市盈率) ===')
|
||||||
|
await probeCsindex('000300')
|
||||||
|
await probeCsindex('000905')
|
||||||
|
|
||||||
|
console.log('\n=== 蛋卷基金指数估值 ===')
|
||||||
|
await probeDanjuan()
|
||||||
|
|
||||||
|
console.log('\n=== Yahoo quoteSummary(美股指数 PE) ===')
|
||||||
|
await probeYahoo('^GSPC')
|
||||||
|
await probeYahoo('^NDX')
|
||||||
|
|
||||||
|
console.log('\n=== CNBC(美股指数 PE) ===')
|
||||||
|
await probeCnbc()
|
||||||
|
|
||||||
|
console.log('\n=== multpl 标普500 月度PE历史 ===')
|
||||||
|
await probeMultplHistory()
|
||||||
|
|
||||||
|
console.log('\n=== 乐咕乐股 ===')
|
||||||
|
await probeLegu()
|
||||||
|
|
||||||
|
console.log('\n探针结束')
|
||||||
|
})()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 探针脚本(第三轮):
|
||||||
|
* - 中证官网 index-perf 完整字段结构(确认是否含市盈率字段)
|
||||||
|
* - 蛋卷 pe_percentile 的统计窗口确认
|
||||||
|
* 用法:node dev_test_scripts/debug/debug_dip_eval_sources3.js
|
||||||
|
*/
|
||||||
|
const axios = require('axios')
|
||||||
|
const UA = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36' }
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
// 中证官网:打印最新一条记录的完整字段
|
||||||
|
try {
|
||||||
|
const res = await axios.get('https://www.csindex.com.cn/csindex-home/perf/index-perf?indexCode=000300&startDate=20260701&endDate=20260804', {
|
||||||
|
timeout: 15000, headers: { ...UA, 'Referer': 'https://www.csindex.com.cn/' }, validateStatus: () => true
|
||||||
|
})
|
||||||
|
const d = res.data && res.data.data
|
||||||
|
if (Array.isArray(d) && d.length) {
|
||||||
|
const last = d[d.length - 1]
|
||||||
|
console.log('[中证官网 000300 最新记录全字段]')
|
||||||
|
console.log(JSON.stringify(last, null, 2))
|
||||||
|
console.log('\n字段列表:', Object.keys(last).join(', '))
|
||||||
|
} else {
|
||||||
|
console.log('[中证官网] 无数据', JSON.stringify(res.data).slice(0, 300))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[中证官网] 异常:', e.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 蛋卷:SH000300 完整字段
|
||||||
|
try {
|
||||||
|
const res = await axios.get('https://danjuanfunds.com/djapi/index_eva/dj', { timeout: 15000, headers: UA, validateStatus: () => true })
|
||||||
|
const items = (res.data && res.data.data && res.data.data.items) || []
|
||||||
|
const t = items.find(i => i.index_code === 'SH000300')
|
||||||
|
if (t) {
|
||||||
|
console.log('\n[蛋卷 SH000300 全字段]')
|
||||||
|
console.log(JSON.stringify(t, null, 2))
|
||||||
|
console.log('ts 对应北京时间:', new Date(t.ts + 8 * 3600 * 1000).toISOString().replace('T', ' ').slice(0, 19))
|
||||||
|
console.log('begin_at 对应北京时间:', new Date(t.begin_at + 8 * 3600 * 1000).toISOString().replace('T', ' ').slice(0, 19))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[蛋卷] 异常:', e.message)
|
||||||
|
}
|
||||||
|
})()
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* 探针脚本(第四轮):确认中证官网 PE 口径(静态 vs 滚动 TTM)
|
||||||
|
* 中证500:index-perf 的 peg=26.53,蛋卷 pe=34.75,需确认 peg 是哪种口径
|
||||||
|
* 思路:
|
||||||
|
* 1. 中证官网每日 xls 文件(含 市盈率1/市盈率2 两个口径)
|
||||||
|
* 2. 中证官网其他含估值字段的接口
|
||||||
|
* 用法:node dev_test_scripts/debug/debug_dip_eval_sources4.js
|
||||||
|
*/
|
||||||
|
const axios = require('axios')
|
||||||
|
const UA = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36' }
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
// 1. 中证官网 xls 每日文件(二进制,先看响应头与体积,再尝试文本化找市盈率列)
|
||||||
|
for (const code of ['000300', '000905']) {
|
||||||
|
try {
|
||||||
|
const url = `https://csi-web-dev.oss-cn-shanghai-finance-1-pub.aliyuncs.com/static/html/csindex/public/uploads/file/autofile/perf/${code}perf.xls`
|
||||||
|
const res = await axios.get(url, { timeout: 20000, responseType: 'arraybuffer', headers: UA, validateStatus: () => true })
|
||||||
|
const buf = Buffer.from(res.data)
|
||||||
|
console.log(`[xls ${code}] status=${res.status} bytes=${buf.length}`)
|
||||||
|
// xls 内文本片段(GBK),找“市盈率”附近的数字
|
||||||
|
let text = ''
|
||||||
|
try { text = require('iconv-lite').decode(buf, 'gb18030') } catch { text = buf.toString('latin1') }
|
||||||
|
const idx = text.indexOf('市盈率')
|
||||||
|
if (idx >= 0) {
|
||||||
|
console.log(`[xls ${code}] 表头附近: ${text.slice(Math.max(0, idx - 60), idx + 120).replace(/[^\x20-\x7E\u4e00-\u9fff,。.\-\r\n]/g, ' ')}`)
|
||||||
|
// 最后一行数据(最新日期行)
|
||||||
|
const lines = text.split(/\r?\n/).filter(l => /\d{4}\/\d{1,2}\/\d{1,2}|\d{8}/.test(l))
|
||||||
|
if (lines.length) console.log(`[xls ${code}] 最新数据行: ${lines[lines.length - 1].replace(/[^\x20-\x7E\u4e00-\u9fff,。.\-\r\n]/g, ' ').slice(0, 300)}`)
|
||||||
|
} else {
|
||||||
|
console.log(`[xls ${code}] 未找到“市盈率”字样,前200字节hex=${buf.slice(0, 100).toString('hex')}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[xls ${code}] 异常: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 中证官网其他可能的估值接口
|
||||||
|
const candidates = [
|
||||||
|
'https://www.csindex.com.cn/csindex-home/perf/index-perf-detail?indexCode=000905',
|
||||||
|
'https://www.csindex.com.cn/csindex-home/index/valuation?indexCode=000905',
|
||||||
|
'https://www.csindex.com.cn/csindex-home/index/index-detail?indexCode=000905'
|
||||||
|
]
|
||||||
|
for (const url of candidates) {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(url, { timeout: 15000, headers: { ...UA, 'Referer': 'https://www.csindex.com.cn/' }, validateStatus: () => true })
|
||||||
|
console.log(`[接口探测] ${url}\n status=${res.status} body=${JSON.stringify(res.data).slice(0, 400)}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[接口探测] ${url} 异常: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* 探针脚本(第五轮):带 Referer 重试中证官网 xls(含 市盈率1/市盈率2 双口径)
|
||||||
|
* 用法:node dev_test_scripts/debug/debug_dip_eval_sources5.js
|
||||||
|
*/
|
||||||
|
const axios = require('axios')
|
||||||
|
const iconv = require('iconv-lite')
|
||||||
|
const UA = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36', 'Referer': 'https://www.csindex.com.cn/' }
|
||||||
|
|
||||||
|
const tryXls = async (url, label) => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(url, { timeout: 25000, responseType: 'arraybuffer', headers: UA, validateStatus: () => true })
|
||||||
|
const buf = Buffer.from(res.data)
|
||||||
|
if (res.status >= 400) return console.log(`[${label}] status=${res.status} bytes=${buf.length}`)
|
||||||
|
const text = iconv.decode(buf, 'gb18030')
|
||||||
|
console.log(`[${label}] status=${res.status} bytes=${buf.length}`)
|
||||||
|
const idx = text.indexOf('市盈率')
|
||||||
|
if (idx >= 0) {
|
||||||
|
const clean = s => s.replace(/[^\x20-\x7E\u4e00-\u9fff,。.·\-\/]/g, ' ').replace(/\s+/g, ' ')
|
||||||
|
console.log(` 表头: ${clean(text.slice(Math.max(0, idx - 80), idx + 140))}`)
|
||||||
|
// 数据行通常以日期开头,取含“日期格式”的最后几行
|
||||||
|
const lines = text.split(/[^\x20-\x7E\u4e00-\u9fff,。.·\-\/\t]{3,}/).filter(l => /\d{4}[\/-]\d{1,2}[\/-]\d{1,2}/.test(l))
|
||||||
|
if (lines.length) {
|
||||||
|
console.log(` 最新行: ${clean(lines[lines.length - 1]).slice(0, 320)}`)
|
||||||
|
console.log(` 次新行: ${clean(lines[lines.length - 2] || '').slice(0, 320)}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(` 未找到“市盈率”,magic=${buf.slice(0, 8).toString('hex')}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[${label}] 异常: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
await tryXls('https://csi-web-dev.oss-cn-shanghai-finance-1-pub.aliyuncs.com/static/html/csindex/public/uploads/file/autofile/perf/000905perf.xls', 'oss-xls-000905')
|
||||||
|
await tryXls('https://www.csindex.com.cn/uploads/file/autofile/perf/000905perf.xls', 'www-xls-000905')
|
||||||
|
await tryXls('https://www.csindex.com.cn/csindex-home/exportExcel/downloadIndexHistoryExcel?indexCode=000905', 'history-xls-000905')
|
||||||
|
})()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// 调试:验证 free_show_local_agent.js 的 extractPrice 价格提取逻辑
|
||||||
|
// 用法:node dev_test_scripts/debug/debug_price_extract.js
|
||||||
|
'use strict'
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'public', 'tools', 'free_show', 'free_show_local_agent.js'), 'utf-8')
|
||||||
|
const m = src.match(/function extractPrice[\s\S]*?\n}/)
|
||||||
|
if (!m) { console.log('FAIL: 未找到 extractPrice'); process.exit(1) }
|
||||||
|
const extractPrice = new Function('return ' + m[0])()
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
['<div class="itemprice">130-2620<span>元</span></div>', 130, '130-2620元'],
|
||||||
|
['... 380.0元 起 ...', 380, '380.0元'],
|
||||||
|
['¥130起', 130, '¥130'],
|
||||||
|
['2026.08.03-08.30 上海', null, null], // 日期不应误判为价格
|
||||||
|
['价格待定', null, null]
|
||||||
|
]
|
||||||
|
let fail = 0
|
||||||
|
for (const [input, wantPrice, wantText] of cases) {
|
||||||
|
const got = extractPrice(input)
|
||||||
|
const okPrice = wantPrice === null ? got === null : got && got.price === wantPrice
|
||||||
|
const okText = wantText === null ? got === null : got && got.priceText === wantText
|
||||||
|
if (okPrice && okText) console.log(`PASS: ${JSON.stringify(input)} => ${JSON.stringify(got)}`)
|
||||||
|
else { fail++; console.log(`FAIL: ${JSON.stringify(input)} => ${JSON.stringify(got)} (want ${wantPrice}/${wantText})`) }
|
||||||
|
}
|
||||||
|
process.exit(fail ? 1 : 0)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// 调试:排查首页「每周周报提醒」的判定依据
|
||||||
|
// 读取最近 3 周(W30/W31/W32)周报的真实标题/内容,并模拟前端 isWeeklyMeaningfulReport 判定
|
||||||
|
// 用法:node dev_test_scripts/debug/debug_weekly_reminder.js
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
|
const os = require('os')
|
||||||
|
|
||||||
|
// 复刻 index.js 的 env 加载逻辑(凭据零硬编码)
|
||||||
|
const userProfile = process.env.USERPROFILE || process.env.HOME || os.homedir()
|
||||||
|
const candidates = [path.join(userProfile, 'Toolbox_local_creds.env.local')]
|
||||||
|
try {
|
||||||
|
const usersRoot = path.join(path.parse(userProfile).root, 'Users')
|
||||||
|
if (fs.existsSync(usersRoot)) {
|
||||||
|
fs.readdirSync(usersRoot).forEach(name => {
|
||||||
|
const dir = path.join(usersRoot, name)
|
||||||
|
if (dir !== userProfile && fs.existsSync(dir)) candidates.push(path.join(dir, 'Toolbox_local_creds.env.local'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
for (const p of candidates) {
|
||||||
|
if (!fs.existsSync(p)) continue
|
||||||
|
fs.readFileSync(p, 'utf-8').split(/\r?\n/).forEach(line => {
|
||||||
|
const t = line.trim()
|
||||||
|
if (!t || t.startsWith('#')) return
|
||||||
|
const i = t.indexOf('=')
|
||||||
|
if (i > 0) {
|
||||||
|
const k = t.slice(0, i).trim()
|
||||||
|
const v = t.slice(i + 1).trim()
|
||||||
|
if (!process.env[k]) process.env[k] = v
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
process.chdir(path.join(__dirname, '..', '..'))
|
||||||
|
|
||||||
|
// 按 weekly.js 的启动顺序初始化
|
||||||
|
const { initCrypto } = require('../../src/server/weekly_crypto')
|
||||||
|
const { initDB } = require('../../src/server/weekly_db')
|
||||||
|
const cfgPath = path.join(process.cwd(), 'config', 'weekly.json')
|
||||||
|
let machineSalt = 'salt'
|
||||||
|
try { machineSalt = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')).machine_salt || 'salt' } catch {}
|
||||||
|
initCrypto(process.env.SYSTEM_AUTH_PASS_93220 || process.env.WEEKLY_PASSWORD || '', machineSalt)
|
||||||
|
initDB(path.join(process.cwd(), 'data', 'weekly.db'), path.join(process.cwd(), 'data', 'weekly.enc'))
|
||||||
|
const core = require('../../src/server/weekly_core')
|
||||||
|
|
||||||
|
// 与 public/index.js 的 isWeeklyAutoTitle / isWeeklyMeaningfulReport 保持一致
|
||||||
|
const TEMPLATE_TEXT = '' // 前端 getWeeklyTemplatePlainText 的纯文本,这里只做关键项判断
|
||||||
|
const isAutoTitle = (title, weekId) => {
|
||||||
|
const t = String(title || '').trim()
|
||||||
|
if (!t) return true
|
||||||
|
if (t.includes('(自动新增)')) return true
|
||||||
|
if (weekId && t === `${weekId} 周报`) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const isMeaningful = (report, weekId) => {
|
||||||
|
if (!report) return { ok: false, reason: '无周报记录' }
|
||||||
|
const title = String(report.title || '').trim()
|
||||||
|
const content = String(report.content || '').trim()
|
||||||
|
if (!title || !content) return { ok: false, reason: '标题或内容为空' }
|
||||||
|
if (isAutoTitle(title, weekId)) return { ok: false, reason: '标题仍为自动标题(含「自动新增」或等于「<weekId> 周报」)' }
|
||||||
|
if (content === '这是自动新增的周报。你可以修改。') return { ok: false, reason: '内容仍为自动占位文本' }
|
||||||
|
return { ok: true, reason: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const w of ['2026-W30', '2026-W31', '2026-W32']) {
|
||||||
|
const r = core.getReportByWeek(w)
|
||||||
|
if (!r) {
|
||||||
|
console.log(`\n[${w}] 无周报记录 => 提醒判定:缺`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const judge = isMeaningful(r, w)
|
||||||
|
console.log(`\n[${w}] id=${r.id} updated_at=${r.updated_at}`)
|
||||||
|
console.log(` 标题: ${JSON.stringify(r.title)}`)
|
||||||
|
console.log(` 内容前80字: ${JSON.stringify(String(r.content || '').replace(/\s+/g, ' ').slice(0, 80))}`)
|
||||||
|
console.log(` 内容长度: ${String(r.content || '').length}`)
|
||||||
|
console.log(` 提醒判定: ${judge.ok ? '已写(不算缺)' : '缺(' + judge.reason + ')'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 追加排查:reports 全表时间线 + W31/W32 下的 events + 相关日志 ----
|
||||||
|
const { getDB } = require('../../src/server/weekly_db')
|
||||||
|
const { dec } = require('../../src/server/weekly_crypto')
|
||||||
|
const db = getDB()
|
||||||
|
|
||||||
|
console.log('\n===== reports 时间线(最近 10 条) =====')
|
||||||
|
db.prepare(`SELECT id, week_id, created_at, updated_at FROM reports ORDER BY id DESC LIMIT 10`).all()
|
||||||
|
.forEach(r => console.log(` id=${r.id} week=${r.week_id} created=${r.created_at} updated=${r.updated_at}`))
|
||||||
|
|
||||||
|
for (const rid of [149, 150]) {
|
||||||
|
console.log(`\n===== report ${rid} 下的 events =====`)
|
||||||
|
const evs = db.prepare(`SELECT id, title_enc, title_iv, title_tag, deadline, created_at, updated_at FROM events WHERE report_id=? ORDER BY id`).all(rid)
|
||||||
|
if (evs.length === 0) console.log(' (无 events)')
|
||||||
|
evs.forEach(e => console.log(` ev_id=${e.id} title=${JSON.stringify(dec(e.title_enc, e.title_iv, e.title_tag))} deadline=${e.deadline} created=${e.created_at} updated=${e.updated_at}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n===== logs 中 fitness/ext 相关(最近 15 条) =====')
|
||||||
|
try {
|
||||||
|
db.prepare(`SELECT action, details, created_at FROM logs WHERE action LIKE '%ext%' OR action LIKE '%fitness%' ORDER BY id DESC LIMIT 15`).all()
|
||||||
|
.forEach(l => console.log(` ${l.created_at} ${l.action} ${String(l.details || '').slice(0, 120)}`))
|
||||||
|
} catch (e) { console.log(' logs 查询失败:', e.message) }
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 单元测试:markets_dip_eval 建行定投评估模块
|
||||||
|
* 用法:node dev_test_scripts/unit/test_markets_dip_eval.js
|
||||||
|
*/
|
||||||
|
process.chdir(require('path').join(__dirname, '..', '..'))
|
||||||
|
|
||||||
|
const { getDipEvalReport } = require('../../src/server/markets_dip_eval')
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const payload = await getDipEvalReport()
|
||||||
|
console.log('=== 返回 JSON ===')
|
||||||
|
console.log(JSON.stringify(payload, null, 2))
|
||||||
|
if (!payload || payload.ok === false) {
|
||||||
|
console.log('\n[测试结果] 整体失败(可能是网络问题,见 errors)')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const expectKeys = ['name', 'code', 'market', 'pe_ttm', 'pe_date', 'percentile', 'source', 'advice', 'level']
|
||||||
|
let bad = 0
|
||||||
|
for (const row of payload.rows) {
|
||||||
|
for (const k of expectKeys) {
|
||||||
|
if (!(k in row)) { console.log(`[字段缺失] ${row.name} 缺少 ${k}`); bad++ }
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(row.pe_ttm)) { console.log(`[PE无效] ${row.name} pe_ttm=${row.pe_ttm}`); bad++ }
|
||||||
|
}
|
||||||
|
console.log(bad === 0 ? '\n[测试结果] PASS:行数=' + payload.rows.length + ',字段完整,PE 有效' : `\n[测试结果] FAIL:${bad} 处问题`)
|
||||||
|
process.exit(bad === 0 ? 0 : 1)
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[测试结果] 异常崩溃:', e)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
})()
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* 单元测试:markets 推送告警白名单过滤 applyNotifyAllowlist
|
||||||
|
* 背景:日历/邮件推送只保留 8 个指定 symbol,其余卡片只展示不推送
|
||||||
|
* 运行:node dev_test_scripts/unit/test_markets_notify_allowlist.js
|
||||||
|
*/
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
// 切到仓库根目录,保证 markets.js 内 process.cwd() 相对路径正确
|
||||||
|
process.chdir(path.join(__dirname, '..', '..'))
|
||||||
|
|
||||||
|
const { applyNotifyAllowlist } = require(path.join(process.cwd(), 'src', 'server', 'markets.js'))
|
||||||
|
|
||||||
|
const ALLOWLIST = ['sh000001', 'sz399001', 'sh000300', 'sh000688', 'sz399006', 'usINX', 'usIXIC', 'hf_GC']
|
||||||
|
|
||||||
|
// 模拟全量 quotes:8 个保留项 + 若干应被排除的卡片
|
||||||
|
const mockQuotes = [
|
||||||
|
{ symbol: 'sh000001', label: '上证指数' },
|
||||||
|
{ symbol: 'sz399001', label: '深证成指' },
|
||||||
|
{ symbol: 'sh000300', label: '沪深300' },
|
||||||
|
{ symbol: 'sh000688', label: '科创50' },
|
||||||
|
{ symbol: 'sz399006', label: '创业板指' },
|
||||||
|
{ symbol: 'usINX', label: '标普500' },
|
||||||
|
{ symbol: 'usIXIC', label: '纳斯达克' },
|
||||||
|
{ symbol: 'hf_GC', label: 'COMEX黄金' },
|
||||||
|
// 以下应被排除
|
||||||
|
{ symbol: 'hf_SI', label: 'COMEX白银' },
|
||||||
|
{ symbol: 'usDJI', label: '道琼斯' },
|
||||||
|
{ symbol: 'hkHSI', label: '恒生指数' },
|
||||||
|
{ symbol: 'bj899050', label: '北证50' },
|
||||||
|
{ symbol: 'usEWG', label: '德国DAX30' }
|
||||||
|
]
|
||||||
|
|
||||||
|
let failed = 0
|
||||||
|
const assert = (cond, name) => {
|
||||||
|
if (cond) {
|
||||||
|
console.log(` PASS ${name}`)
|
||||||
|
} else {
|
||||||
|
failed += 1
|
||||||
|
console.log(` FAIL ${name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('== test: applyNotifyAllowlist ==')
|
||||||
|
|
||||||
|
// 1. 白名单生效:只保留 8 个
|
||||||
|
const r1 = applyNotifyAllowlist(mockQuotes, { notify_symbol_allowlist: ALLOWLIST })
|
||||||
|
assert(r1.kept.length === 8, '白名单模式保留 8 项')
|
||||||
|
assert(r1.excluded.length === 5, '白名单模式排除 5 项')
|
||||||
|
assert(r1.kept.every(q => ALLOWLIST.map(s => s.toLowerCase()).includes(String(q.symbol).toLowerCase())), '保留项全部在白名单内')
|
||||||
|
assert(r1.excluded.every(q => !ALLOWLIST.map(s => s.toLowerCase()).includes(String(q.symbol).toLowerCase())), '排除项全部不在白名单内')
|
||||||
|
|
||||||
|
// 2. 大小写不敏感
|
||||||
|
const r2 = applyNotifyAllowlist([{ symbol: 'USINX' }, { symbol: 'hf_gc' }], { notify_symbol_allowlist: ALLOWLIST })
|
||||||
|
assert(r2.kept.length === 2, 'symbol 匹配大小写不敏感')
|
||||||
|
|
||||||
|
// 3. 空白名单 = 不限制(兼容旧配置)
|
||||||
|
const r3 = applyNotifyAllowlist(mockQuotes, { notify_symbol_allowlist: [] })
|
||||||
|
assert(r3.kept.length === mockQuotes.length && r3.excluded.length === 0, '空白名单不过滤')
|
||||||
|
|
||||||
|
// 4. 缺省字段 = 不限制
|
||||||
|
const r4 = applyNotifyAllowlist(mockQuotes, {})
|
||||||
|
assert(r4.kept.length === mockQuotes.length, '缺省字段不过滤')
|
||||||
|
|
||||||
|
// 5. 异常输入不崩溃
|
||||||
|
const r5 = applyNotifyAllowlist(null, { notify_symbol_allowlist: ALLOWLIST })
|
||||||
|
assert(Array.isArray(r5.kept) && r5.kept.length === 0, 'quotes 为 null 时不崩溃')
|
||||||
|
|
||||||
|
console.log(failed === 0 ? '\nALL TESTS PASSED' : `\n${failed} TEST(S) FAILED`)
|
||||||
|
process.exit(failed === 0 ? 0 : 1)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="6" fill="#1a1a24"/>
|
||||||
|
<path d="M8 22 L16 8 L24 22 Z" fill="none" stroke="#ffc312" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<circle cx="16" cy="19" r="3" fill="#ff4757"/>
|
||||||
|
<path d="M6 25 h20" stroke="#9090aa" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 358 B |
+10
-112
@@ -997,95 +997,6 @@ class ToolboxHome {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
htmlToPlainText(html) {
|
|
||||||
const temp = document.createElement('div');
|
|
||||||
temp.innerHTML = String(html || '');
|
|
||||||
return String(temp.textContent || temp.innerText || '')
|
|
||||||
.replace(/\u00a0/g, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
getWeeklyTemplatePlainText() {
|
|
||||||
return [
|
|
||||||
'1、本周核心重点事项(置顶)',
|
|
||||||
'①置顶优先级,避免杂事分散精力',
|
|
||||||
'②本周 1-3 件最高优先级事(工作 / 家庭各列,如公司项目节点、孩子升学事宜)',
|
|
||||||
'③必做底线项(如固定会议、家庭日常、记账 / 复盘打卡)',
|
|
||||||
'2、上周复盘(工作)',
|
|
||||||
'-----',
|
|
||||||
'①公司工作完成项(标注是否达标、核心成果)',
|
|
||||||
'②未完成项 + 原因(客观 / 主观,如时间不足、需求变更)',
|
|
||||||
'③规则事务完成情况(重点记未完成的关键项)',
|
|
||||||
'3、上周复盘(个人 + 家庭)',
|
|
||||||
'-----',
|
|
||||||
'①个人 / 家庭事务完成情况(重点记未完成的关键项)',
|
|
||||||
'4、公司工作(计划)',
|
|
||||||
'-----',
|
|
||||||
'①核心任务(按重要 / 紧急排序,标注截止时间、协作人)',
|
|
||||||
'②常规工作(日常运维、对接、汇报等固定事项)',
|
|
||||||
'③待协调 / 需支持项(如需要同事配合、领导审批的内容)',
|
|
||||||
'5、个人和家庭 事务(计划,含理财)',
|
|
||||||
'-----',
|
|
||||||
'①自我提升(学习、运动、习惯打卡,如读 1 章书、运动 3 次)',
|
|
||||||
'②个人琐事(办事、缴费、社交等,标注时间节点)',
|
|
||||||
'③投资理财(盯盘节点、条件单调整、复盘上周收益 / 操作)',
|
|
||||||
'④家人相关(孩子学习 / 活动、老人照料、家庭聚餐等)',
|
|
||||||
'⑤家庭琐事(家务、采购、物业对接等,可标注分工)',
|
|
||||||
'⑥家庭规划(短期家庭事项,如周末出游、家电维修)',
|
|
||||||
'6、待解决问题 / 待协调项',
|
|
||||||
'-----',
|
|
||||||
'①工作中可能遇到的问题(如项目延期风险、资源不足)',
|
|
||||||
'②个人 / 家庭的待处理问题(如时间冲突、资金安排)',
|
|
||||||
'③初步应对思路(简单标注,避免临时慌乱)',
|
|
||||||
'7、本周小目标 / 打卡项(轻量化激励,易落地)',
|
|
||||||
'①工作小目标(如高效完成 1 个核心任务、减少无效沟通)',
|
|
||||||
'②个人 / 家庭小目标(如坚持记账、周末全家运动 1 次)'
|
|
||||||
].join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
isWeeklyAutoTitle(title, weekId = '') {
|
|
||||||
const normalizedTitle = String(title || '').trim();
|
|
||||||
if (!normalizedTitle) return true;
|
|
||||||
if (normalizedTitle.includes('(自动新增)')) return true;
|
|
||||||
if (weekId && normalizedTitle === `${weekId} 周报`) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
isWeeklyMeaningfulReport(report, expectedWeekId = '') {
|
|
||||||
if (!report || typeof report !== 'object') return false;
|
|
||||||
const weekId = String(report.week_id || expectedWeekId || '').trim();
|
|
||||||
const title = String(report.title || '').trim();
|
|
||||||
const contentText = this.htmlToPlainText(report.content || '');
|
|
||||||
if (!title || !contentText) return false;
|
|
||||||
if (this.isWeeklyAutoTitle(title, weekId)) return false;
|
|
||||||
if (contentText === '这是自动新增的周报。你可以修改。') return false;
|
|
||||||
if (contentText === this.getWeeklyTemplatePlainText()) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRecentWeeklyWindows(count = 3) {
|
|
||||||
const today = this.formatDate(new Date());
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
const years = [currentYear - 1, currentYear];
|
|
||||||
const results = await Promise.all(
|
|
||||||
years.map(async (year) => {
|
|
||||||
const res = await fetch(`/api/weekly/weeks?year=${year}&t=${Date.now()}`, { cache: 'no-store' });
|
|
||||||
if (!res.ok) throw new Error(`weekly_weeks_${res.status}`);
|
|
||||||
const data = await res.json();
|
|
||||||
return Array.isArray(data && data.weeks) ? data.weeks : [];
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const allWeeks = results
|
|
||||||
.flat()
|
|
||||||
.filter(item => item && item.week_id && item.start && item.end)
|
|
||||||
.sort((a, b) => String(a.start).localeCompare(String(b.start)));
|
|
||||||
if (allWeeks.length === 0) return [];
|
|
||||||
const currentIndex = allWeeks.findIndex(item => String(item.start) <= today && today <= String(item.end));
|
|
||||||
if (currentIndex < 0) return allWeeks.slice(-count);
|
|
||||||
return allWeeks.slice(Math.max(0, currentIndex - count + 1), currentIndex + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderFitnessDiaryReminder(summary) {
|
renderFitnessDiaryReminder(summary) {
|
||||||
if (!this.fitnessDiaryReminder || !this.fitnessDiaryReminderText || !this.fitnessDiaryReminderDates) return;
|
if (!this.fitnessDiaryReminder || !this.fitnessDiaryReminderText || !this.fitnessDiaryReminderDates) return;
|
||||||
const missingCount = Number(summary && summary.missingCount ? summary.missingCount : 0);
|
const missingCount = Number(summary && summary.missingCount ? summary.missingCount : 0);
|
||||||
@@ -1148,30 +1059,17 @@ class ToolboxHome {
|
|||||||
|
|
||||||
async loadWeeklyReportReminder() {
|
async loadWeeklyReportReminder() {
|
||||||
try {
|
try {
|
||||||
const weeks = await this.getRecentWeeklyWindows(3);
|
// 【首页提醒专用】直接调后端聚合接口 /api/weekly/reminder/status。
|
||||||
if (!Array.isArray(weeks) || weeks.length === 0) return;
|
// 判定由服务端读库完成,不依赖周报 weekly_auth 登录态(仅需 weekly_gate 门禁);
|
||||||
const reportResults = await Promise.all(
|
// 相对路径同源调用:本机访问即 localhost:8976,外网访问自动走 8975 反代,接口本身未授权一律 401。
|
||||||
weeks.map(async (week) => {
|
const res = await fetch(`/api/weekly/reminder/status?t=${Date.now()}`, { cache: 'no-store' });
|
||||||
const res = await fetch(`/api/weekly/report?week_id=${encodeURIComponent(week.week_id)}&t=${Date.now()}`, {
|
if (!res.ok) return; // 无门禁 / 接口异常:静默隐藏提醒
|
||||||
cache: 'no-store'
|
const data = await res.json();
|
||||||
});
|
if (!data || data.ok !== true) return;
|
||||||
if (!res.ok) {
|
|
||||||
if (res.status === 401) return { unauthorized: true, week };
|
|
||||||
return { week, report: null };
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
return { week, report: data && data.ok === true ? data.report : null };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
if (reportResults.some(item => item && item.unauthorized)) return;
|
|
||||||
const missingWeeks = reportResults
|
|
||||||
.filter(item => !this.isWeeklyMeaningfulReport(item.report, item.week && item.week.week_id))
|
|
||||||
.map(item => item.week)
|
|
||||||
.filter(Boolean);
|
|
||||||
this.renderWeeklyReportReminder({
|
this.renderWeeklyReportReminder({
|
||||||
weeks: 3,
|
weeks: Number(data.weeks || 3),
|
||||||
missingCount: missingWeeks.length,
|
missingCount: Number(data.missingCount || 0),
|
||||||
missingWeeks
|
missingWeeks: Array.isArray(data.missingWeeks) ? data.missingWeeks : []
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('加载周报提醒失败:', err);
|
console.warn('加载周报提醒失败:', err);
|
||||||
|
|||||||
@@ -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-wisdom" class="subtab">理论与实践</button>
|
||||||
<button id="m-tab-zhizi" class="subtab">知资札记</button>
|
<button id="m-tab-zhizi" class="subtab">知资札记</button>
|
||||||
<button id="m-tab-tools" 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 id="m-status" class="status status-inline" style="display:none"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -189,6 +190,79 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -343,6 +417,7 @@
|
|||||||
<script src="/common/common.js"></script>
|
<script src="/common/common.js"></script>
|
||||||
<script src="/tools/markets/markets.js?v=3"></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_tools.js?v=1"></script>
|
||||||
|
<script src="/tools/markets/markets_dip_eval.js?v=1"></script>
|
||||||
<script>
|
<script>
|
||||||
(async function(){
|
(async function(){
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1712,6 +1712,7 @@ const switchPanel = which => {
|
|||||||
const wisdomTab = el('m-tab-wisdom')
|
const wisdomTab = el('m-tab-wisdom')
|
||||||
const zhiziTab = el('m-tab-zhizi')
|
const zhiziTab = el('m-tab-zhizi')
|
||||||
const toolsTab = el('m-tab-tools')
|
const toolsTab = el('m-tab-tools')
|
||||||
|
const dipTab = el('m-tab-dip')
|
||||||
const rt = el('m-panel-realtime')
|
const rt = el('m-panel-realtime')
|
||||||
const ar = el('m-panel-archive')
|
const ar = el('m-panel-archive')
|
||||||
const cal = el('m-panel-calendar')
|
const cal = el('m-panel-calendar')
|
||||||
@@ -1719,25 +1720,27 @@ const switchPanel = which => {
|
|||||||
const news = el('m-panel-news')
|
const news = el('m-panel-news')
|
||||||
const wisdom = el('m-panel-wisdom')
|
const wisdom = el('m-panel-wisdom')
|
||||||
const tools = el('m-panel-tools')
|
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 (!rtTab || !arTab || !calTab || !wkTab || !rt || !ar || !cal || !wk) return
|
||||||
if (which === 'archive') {
|
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')
|
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()
|
loadArchive()
|
||||||
} else if (which === 'calendar') {
|
} 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')
|
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)
|
if (!calendarState.month || !Array.isArray(calendarState.days) || calendarState.days.length === 0) loadSecurityCalendar(false)
|
||||||
} else if (which === 'weekly') {
|
} 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')
|
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) {
|
if (!weeklyState.weekStart || !Array.isArray(weeklyState.rows) || weeklyState.rows.length === 0) {
|
||||||
setWeeklyIdleState('请选择周后点击“加载”读取历史,或点“刷新并补存”主动更新。', { clearData: false })
|
setWeeklyIdleState('请选择周后点击“加载”读取历史,或点“刷新并补存”主动更新。', { clearData: false })
|
||||||
}
|
}
|
||||||
} else if (which === 'news') {
|
} 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')
|
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')
|
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)
|
loadGlobalNews(false)
|
||||||
} else if (which === 'wisdom') {
|
} else if (which === 'wisdom') {
|
||||||
window.open('https://traesite.umersoft.com:8975/tools/markets/wallstreet_wisdom.html', '_blank')
|
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 (news) news.style.display = 'none'
|
||||||
if (wisdom) wisdom.style.display = 'none'
|
if (wisdom) wisdom.style.display = 'none'
|
||||||
if (tools) tools.style.display = ''
|
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 {
|
} else {
|
||||||
rtTab.classList.add('active'); arTab.classList.remove('active'); calTab.classList.remove('active'); wkTab.classList.remove('active'); if (newsTab) newsTab.classList.remove('active')
|
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')
|
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'
|
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 (wisdom) wisdom.style.display = 'none'
|
||||||
if (tools) tools.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 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 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 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 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 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))
|
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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
// 免费演出雷达(free_show)后端模块
|
||||||
|
// 独立 SQLite 缓存(data/free_show.db),不依赖任何其他 tool
|
||||||
|
// 8976:提供查询/抓取 API;8977:每日 0 点自动追加抓取
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
|
const https = require('https')
|
||||||
|
const Database = require('better-sqlite3')
|
||||||
|
const { logJSON } = require('./logger')
|
||||||
|
|
||||||
|
// ============ 配置 ============
|
||||||
|
|
||||||
|
const CONFIG_PATH = path.join(process.cwd(), 'config', 'free_show.json')
|
||||||
|
|
||||||
|
const readConfig = () => {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(CONFIG_PATH)) return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'))
|
||||||
|
} catch (e) {
|
||||||
|
try { logJSON('free_show.config.error', { error: String(e.message || e) }, 'free_show') } catch {}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 数据库 ============
|
||||||
|
|
||||||
|
const dataDir = path.join(process.cwd(), 'data')
|
||||||
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
|
||||||
|
const db = new Database(path.join(dataDir, 'free_show.db'))
|
||||||
|
|
||||||
|
db.exec(
|
||||||
|
`CREATE TABLE IF NOT EXISTS free_show_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
event_key TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
price REAL,
|
||||||
|
price_text TEXT,
|
||||||
|
show_time TEXT,
|
||||||
|
venue TEXT,
|
||||||
|
city TEXT,
|
||||||
|
poster TEXT,
|
||||||
|
url TEXT,
|
||||||
|
fetched_at TEXT,
|
||||||
|
UNIQUE(platform, event_key)
|
||||||
|
);`
|
||||||
|
)
|
||||||
|
db.exec(
|
||||||
|
`CREATE TABLE IF NOT EXISTS free_show_meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);`
|
||||||
|
)
|
||||||
|
|
||||||
|
const upsertEvent = db.prepare(
|
||||||
|
`INSERT INTO free_show_events (platform, event_key, title, price, price_text, show_time, venue, city, poster, url, fetched_at)
|
||||||
|
VALUES (@platform, @event_key, @title, @price, @price_text, @show_time, @venue, @city, @poster, @url, @fetched_at)
|
||||||
|
ON CONFLICT(platform, event_key) DO UPDATE SET
|
||||||
|
title = excluded.title,
|
||||||
|
price = excluded.price,
|
||||||
|
price_text = excluded.price_text,
|
||||||
|
show_time = excluded.show_time,
|
||||||
|
venue = excluded.venue,
|
||||||
|
city = excluded.city,
|
||||||
|
poster = excluded.poster,
|
||||||
|
url = excluded.url,
|
||||||
|
fetched_at = excluded.fetched_at`
|
||||||
|
)
|
||||||
|
const deleteByPlatformCity = db.prepare(`DELETE FROM free_show_events WHERE platform = ? AND city = ?`)
|
||||||
|
const listByPlatformCity = db.prepare(`SELECT * FROM free_show_events WHERE platform = ? AND city = ?`)
|
||||||
|
const countByPlatformCity = db.prepare(`SELECT COUNT(*) AS c FROM free_show_events WHERE platform = ? AND city = ?`)
|
||||||
|
const getMeta = db.prepare(`SELECT value FROM free_show_meta WHERE key = ?`)
|
||||||
|
const setMeta = db.prepare(
|
||||||
|
`INSERT INTO free_show_meta (key, value, updated_at) VALUES (?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`
|
||||||
|
)
|
||||||
|
|
||||||
|
const nowBeijing = () =>
|
||||||
|
new Date(Date.now() + 8 * 3600 * 1000).toISOString().replace('T', ' ').slice(0, 19)
|
||||||
|
|
||||||
|
const saveEvents = (platform, city, events, mode) => {
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
if (mode === 'overwrite') deleteByPlatformCity.run(platform, city)
|
||||||
|
const fetchedAt = nowBeijing()
|
||||||
|
for (const e of events) {
|
||||||
|
upsertEvent.run({
|
||||||
|
platform,
|
||||||
|
event_key: String(e.id || e.title || ''),
|
||||||
|
title: String(e.title || ''),
|
||||||
|
price: Number(e.price) || 0,
|
||||||
|
price_text: String(e.priceText || ''),
|
||||||
|
show_time: String(e.showTime || ''),
|
||||||
|
venue: String(e.venue || ''),
|
||||||
|
city: String(e.city || city),
|
||||||
|
poster: String(e.poster || ''),
|
||||||
|
url: String(e.url || ''),
|
||||||
|
fetched_at: fetchedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
tx()
|
||||||
|
setMeta.run(`${platform}_${city}_lastFetchTime`, nowBeijing())
|
||||||
|
return countByPlatformCity.get(platform, city).c
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadEvents = (platform, city) => {
|
||||||
|
return listByPlatformCity.all(platform, city).map(r => ({
|
||||||
|
platform: r.platform,
|
||||||
|
id: r.event_key,
|
||||||
|
title: r.title,
|
||||||
|
price: r.price,
|
||||||
|
priceText: r.price_text,
|
||||||
|
showTime: r.show_time,
|
||||||
|
venue: r.venue,
|
||||||
|
city: r.city,
|
||||||
|
poster: r.poster,
|
||||||
|
url: r.url
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 通用工具 ============
|
||||||
|
|
||||||
|
function httpGet(url, headers = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const mod = url.startsWith('https') ? https : require('http')
|
||||||
|
mod.get(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'text/html,application/json,*/*',
|
||||||
|
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||||
|
...headers
|
||||||
|
}
|
||||||
|
}, res => {
|
||||||
|
let d = ''
|
||||||
|
res.on('data', c => d += c)
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, body: d }))
|
||||||
|
}).on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePrice(str) {
|
||||||
|
if (!str) return 9999
|
||||||
|
const m = String(str).replace(/[¥¥起,,]/g, '').match(/[\d.]+/)
|
||||||
|
return m ? parseFloat(m[0]) : 9999
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 秀动 ============
|
||||||
|
|
||||||
|
// 仅保留上海(业务约定:手工拉/自动拉都只要上海)
|
||||||
|
const SHOWSTART_CITIES = {
|
||||||
|
'上海': 21
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchShowstart(city, pages) {
|
||||||
|
const cityCode = SHOWSTART_CITIES[city]
|
||||||
|
if (!cityCode) throw new Error(`秀动不支持城市: ${city}`)
|
||||||
|
|
||||||
|
let allEvents = []
|
||||||
|
const maxPages = Math.min(Number(pages) || 3, 10)
|
||||||
|
for (let page = 1; page <= maxPages; page++) {
|
||||||
|
try {
|
||||||
|
const url = `https://www.showstart.com/event/list?cityCode=${cityCode}&pageNo=${page}`
|
||||||
|
const { body } = await httpGet(url)
|
||||||
|
const s = body.indexOf('window.__NUXT__=')
|
||||||
|
if (s === -1) break
|
||||||
|
const e = body.indexOf('</script>', s)
|
||||||
|
const data = eval(body.substring(s + 16, e))
|
||||||
|
const list = data?.data?.[0]?.listData || []
|
||||||
|
if (list.length === 0) break
|
||||||
|
allEvents = allEvents.concat(list.map(item => ({
|
||||||
|
platform: 'showstart',
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
price: parsePrice(item.price),
|
||||||
|
priceText: item.price,
|
||||||
|
showTime: item.showTime,
|
||||||
|
venue: item.siteName,
|
||||||
|
city: item.cityName,
|
||||||
|
poster: item.poster,
|
||||||
|
url: `https://www.showstart.com/event/${item.id}`
|
||||||
|
})))
|
||||||
|
if (list.length < 20) break
|
||||||
|
} catch (e) { break }
|
||||||
|
}
|
||||||
|
return allEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 猫眼 ============
|
||||||
|
|
||||||
|
// 仅保留上海(业务约定:手工拉/自动拉都只要上海)
|
||||||
|
const MAOYAN_CITIES = {
|
||||||
|
'上海': 1
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMaoyan(city, pages) {
|
||||||
|
const cityId = MAOYAN_CITIES[city]
|
||||||
|
if (!cityId) throw new Error(`猫眼不支持城市: ${city}`)
|
||||||
|
|
||||||
|
let allEvents = []
|
||||||
|
const maxPages = Math.min(Number(pages) || 3, 10)
|
||||||
|
for (let page = 1; page <= maxPages; page++) {
|
||||||
|
try {
|
||||||
|
const url = `https://wx.maoyan.com/maoyansh/myshow/ajax/performances/0;st=0;p=${page};s=20;tft=0?cityId=${cityId}`
|
||||||
|
const { body } = await httpGet(url, { 'Accept': 'application/json' })
|
||||||
|
const json = JSON.parse(body)
|
||||||
|
if (!json.success || !json.data || json.data.length === 0) break
|
||||||
|
allEvents = allEvents.concat(json.data.map(item => ({
|
||||||
|
platform: 'maoyan',
|
||||||
|
id: item.performanceId,
|
||||||
|
title: item.name,
|
||||||
|
price: item.lowestPrice || parsePrice(item.priceRange),
|
||||||
|
priceText: item.sellPriceDisplay?.priceDisplayText || `¥${item.lowestPrice}起`,
|
||||||
|
showTime: item.showTimeRange || '',
|
||||||
|
venue: item.shopName || '',
|
||||||
|
city: item.cityName || city,
|
||||||
|
poster: item.posterUrl || '',
|
||||||
|
url: item.jumpDetailUrl || `https://show.maoyan.com/detail/${item.performanceId}`
|
||||||
|
})))
|
||||||
|
if (!json.paging?.hasMore) break
|
||||||
|
} catch (e) { break }
|
||||||
|
}
|
||||||
|
return allEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 大麦 ============
|
||||||
|
|
||||||
|
// 仅保留上海(业务约定:手工拉/自动拉都只要上海)
|
||||||
|
const DAMAI_CITIES = {
|
||||||
|
'上海': '310100'
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBrowser() {
|
||||||
|
const candidates = [
|
||||||
|
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||||
|
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||||
|
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||||
|
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe'
|
||||||
|
]
|
||||||
|
for (const p of candidates) {
|
||||||
|
if (fs.existsSync(p)) return p
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let browserInstance = null
|
||||||
|
let browserLock = false
|
||||||
|
|
||||||
|
async function getBrowser() {
|
||||||
|
if (browserInstance) {
|
||||||
|
try {
|
||||||
|
await browserInstance.version()
|
||||||
|
return browserInstance
|
||||||
|
} catch (e) {
|
||||||
|
browserInstance = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const puppeteer = require('puppeteer-core')
|
||||||
|
const execPath = findBrowser()
|
||||||
|
if (!execPath) throw new Error('未找到 Chrome/Edge 浏览器')
|
||||||
|
browserInstance = await puppeteer.launch({
|
||||||
|
executablePath: execPath,
|
||||||
|
headless: 'shell',
|
||||||
|
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
|
||||||
|
})
|
||||||
|
return browserInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDamai(city, pages) {
|
||||||
|
const cityCode = DAMAI_CITIES[city]
|
||||||
|
if (!cityCode) throw new Error(`大麦不支持城市: ${city}`)
|
||||||
|
|
||||||
|
while (browserLock) await new Promise(r => setTimeout(r, 500))
|
||||||
|
browserLock = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const browser = await getBrowser()
|
||||||
|
const page = await browser.newPage()
|
||||||
|
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')
|
||||||
|
|
||||||
|
let antiBot = false
|
||||||
|
page.on('response', async (resp) => {
|
||||||
|
if (resp.url().includes('searchajax')) {
|
||||||
|
try {
|
||||||
|
const t = await resp.text()
|
||||||
|
if (t.includes('rgv587_flag') || t.includes('punish')) antiBot = true
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let allEvents = []
|
||||||
|
const maxPages = Math.min(Number(pages) || 3, 3) // 大麦用浏览器抓,限制页数
|
||||||
|
for (let p = 1; p <= maxPages; p++) {
|
||||||
|
const searchUrl = `https://search.damai.cn/search.htm?keyword=&city=${cityCode}&ctl=0&order=1&page=${p}`
|
||||||
|
try {
|
||||||
|
await page.goto(searchUrl, { waitUntil: 'domcontentloaded', timeout: 15000 })
|
||||||
|
|
||||||
|
let rendered = false
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await new Promise(r => setTimeout(r, 1000))
|
||||||
|
const cnt = await page.evaluate(() =>
|
||||||
|
document.querySelectorAll('.search__itemlist a[href*="damai.cn"], .search__itemlist [class*="item"]').length
|
||||||
|
)
|
||||||
|
if (cnt > 0) { rendered = true; break }
|
||||||
|
if (antiBot) break
|
||||||
|
}
|
||||||
|
if (antiBot) break
|
||||||
|
if (!rendered) break
|
||||||
|
|
||||||
|
const items = await page.evaluate(() => {
|
||||||
|
const results = []
|
||||||
|
const cards = document.querySelectorAll('.search__itemlist > div > div, .search__itemlist [class*="item__"]')
|
||||||
|
const seen = new Set()
|
||||||
|
cards.forEach(card => {
|
||||||
|
const link = card.querySelector('a[href*="damai.cn"]')
|
||||||
|
const titleEl = card.querySelector('[class*="title"], a[title], h3, h4')
|
||||||
|
const text = card.textContent || ''
|
||||||
|
|
||||||
|
const title = (titleEl?.textContent || titleEl?.getAttribute('title') || '').trim()
|
||||||
|
if (!title || seen.has(title)) return
|
||||||
|
seen.add(title)
|
||||||
|
|
||||||
|
// 大麦列表价格常无 ¥ 符号(如 "130-2620元" / "380.0元"),多格式兜底提取
|
||||||
|
const priceMatch = text.match(/[¥¥]\s*[\d.]+(?:\s*[-~至]\s*[\d.]+)?/) || text.match(/[\d.]+(?:\s*[-~至]\s*[\d.]+)?\s*元/)
|
||||||
|
results.push({
|
||||||
|
title,
|
||||||
|
priceText: priceMatch ? priceMatch[0].replace(/\s+/g, '') : '',
|
||||||
|
time: (text.match(/\d{4}[.\-/]\d{1,2}[.\-/]\d{1,2}[^|]*/) || [''])[0].trim(),
|
||||||
|
venue: '',
|
||||||
|
url: link?.href || '',
|
||||||
|
poster: card.querySelector('img')?.src || ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
})
|
||||||
|
|
||||||
|
if (items.length === 0) break
|
||||||
|
|
||||||
|
allEvents = allEvents.concat(items.map(item => ({
|
||||||
|
platform: 'damai',
|
||||||
|
id: (item.url.match(/id=(\d+)/) || [, ''])[1],
|
||||||
|
title: item.title,
|
||||||
|
price: parsePrice(item.priceText),
|
||||||
|
priceText: item.priceText || '价格待定',
|
||||||
|
showTime: item.time,
|
||||||
|
venue: item.venue,
|
||||||
|
city,
|
||||||
|
poster: item.poster,
|
||||||
|
url: item.url || 'https://www.damai.cn'
|
||||||
|
})))
|
||||||
|
} catch (e) {
|
||||||
|
try { logJSON('free_show.damai.page_error', { page: p, error: String(e.message || e) }, 'free_show') } catch {}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.close()
|
||||||
|
|
||||||
|
if (antiBot && allEvents.length === 0) {
|
||||||
|
throw new Error('大麦触发IP级风控(rgv587)。当前网络IP被阿里拉黑,请换住宅宽带网络后重试')
|
||||||
|
}
|
||||||
|
return allEvents
|
||||||
|
} finally {
|
||||||
|
browserLock = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FETCHERS = { showstart: fetchShowstart, maoyan: fetchMaoyan, damai: fetchDamai }
|
||||||
|
|
||||||
|
// ============ 业务逻辑 ============
|
||||||
|
|
||||||
|
function buildResponse(platform, city, maxPrice, allEvents, cacheTime, fromCache, newCount) {
|
||||||
|
const filtered = allEvents.filter(e => e.price <= maxPrice).sort((a, b) => a.price - b.price)
|
||||||
|
const cheapest = [...allEvents].sort((a, b) => a.price - b.price).slice(0, 20)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
platform, city, maxPrice,
|
||||||
|
fromCache,
|
||||||
|
cacheTime,
|
||||||
|
newCount: newCount ?? 0,
|
||||||
|
totalCount: allEvents.length,
|
||||||
|
filteredCount: filtered.length,
|
||||||
|
events: filtered,
|
||||||
|
cheapest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runFetch(platform, city, maxPrice, mode, pages) {
|
||||||
|
const fetcher = FETCHERS[platform]
|
||||||
|
if (!fetcher) return { success: false, error: `不支持的平台: ${platform}` }
|
||||||
|
|
||||||
|
if (mode === 'cache') {
|
||||||
|
// 读缓存 = 纯本地 SQLite 读取,不碰目标网站,大麦也允许
|
||||||
|
const cached = loadEvents(platform, city)
|
||||||
|
const cacheTime = (getMeta.get(`${platform}_${city}_lastFetchTime`) || {}).value
|
||||||
|
if (cached.length > 0) {
|
||||||
|
return buildResponse(platform, city, maxPrice, cached, cacheTime, true)
|
||||||
|
}
|
||||||
|
// 缓存为空需真抓取:大麦仍受服务器禁抓限制(见下方拦截)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 大麦对服务器 IP 风控极严:默认禁用服务器端抓取,走本地代理(家庭 IP)
|
||||||
|
if (platform === 'damai' && readConfig().damai_server_fetch !== true) {
|
||||||
|
return { success: false, code: 'damai_need_local', error: '服务器端不抓大麦(保护服务器 IP)。请在本机启动本地抓取代理后,用「追加合并/覆盖」模式抓取' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'cache') {
|
||||||
|
const events = await fetcher(city, pages)
|
||||||
|
saveEvents(platform, city, events, 'overwrite')
|
||||||
|
return buildResponse(platform, city, maxPrice, loadEvents(platform, city), nowBeijing(), false, events.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await fetcher(city, pages)
|
||||||
|
saveEvents(platform, city, events, mode)
|
||||||
|
const merged = loadEvents(platform, city)
|
||||||
|
return buildResponse(platform, city, maxPrice, merged, nowBeijing(), false, events.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 8977 定时调度:每日 0 点追加抓取 ============
|
||||||
|
|
||||||
|
let schedulerStarted = false
|
||||||
|
|
||||||
|
async function autoFetchAll() {
|
||||||
|
const cfg = readConfig()
|
||||||
|
const auto = cfg.auto_fetch || {}
|
||||||
|
const platforms = Array.isArray(auto.platforms) && auto.platforms.length ? auto.platforms : ['showstart', 'maoyan']
|
||||||
|
const cities = Array.isArray(auto.cities) && auto.cities.length ? auto.cities : ['上海']
|
||||||
|
const results = []
|
||||||
|
for (const platform of platforms) {
|
||||||
|
// 大麦默认不走服务器(保护服务器 IP),除非显式开启 damai_server_fetch
|
||||||
|
if (platform === 'damai' && cfg.damai_server_fetch !== true) continue
|
||||||
|
for (const city of cities) {
|
||||||
|
try {
|
||||||
|
const fetcher = FETCHERS[platform]
|
||||||
|
if (!fetcher) continue
|
||||||
|
const events = await fetcher(city, 3)
|
||||||
|
saveEvents(platform, city, events, 'append')
|
||||||
|
results.push({ platform, city, ok: true, count: events.length })
|
||||||
|
try { logJSON('free_show.auto_fetch.ok', { platform, city, count: events.length }, 'free_show') } catch {}
|
||||||
|
} catch (e) {
|
||||||
|
results.push({ platform, city, ok: false, error: String(e.message || e) })
|
||||||
|
try { logJSON('free_show.auto_fetch.error', { platform, city, error: String(e.message || e) }, 'free_show') } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMeta('last_auto_run', nowBeijing())
|
||||||
|
setMeta('last_auto_result', JSON.stringify(results))
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
function startScheduler() {
|
||||||
|
if (schedulerStarted) return
|
||||||
|
schedulerStarted = true
|
||||||
|
const cfg = readConfig()
|
||||||
|
const auto = cfg.auto_fetch || {}
|
||||||
|
if (auto.enabled === false) {
|
||||||
|
try { logJSON('free_show.scheduler.disabled', {}, 'free_show') } catch {}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const hour = Number(auto.hour ?? 0)
|
||||||
|
const minute = Number(auto.minute ?? 0)
|
||||||
|
try { logJSON('free_show.scheduler.start', { hour, minute }, 'free_show') } catch {}
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
try {
|
||||||
|
const bj = new Date(Date.now() + 8 * 3600 * 1000)
|
||||||
|
const h = bj.getUTCHours()
|
||||||
|
const m = bj.getUTCMinutes()
|
||||||
|
const today = bj.toISOString().slice(0, 10)
|
||||||
|
if (h !== hour || m !== minute) return
|
||||||
|
const lastDay = (getMeta.get('last_auto_run_day') || {}).value
|
||||||
|
if (lastDay === today) return
|
||||||
|
setMeta('last_auto_run_day', today)
|
||||||
|
autoFetchAll().catch(e => {
|
||||||
|
try { logJSON('free_show.scheduler.error', { error: String(e.message || e) }, 'free_show') } catch {}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
try { logJSON('free_show.scheduler.tick_error', { error: String(e.message || e) }, 'free_show') } catch {}
|
||||||
|
}
|
||||||
|
}, 30 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 路由 ============
|
||||||
|
|
||||||
|
function bindRoutes(app) {
|
||||||
|
app.get('/api/free_show/config', (req, res) => {
|
||||||
|
try {
|
||||||
|
const cfg = readConfig()
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
ui_mode: cfg.ui_mode || 'debug',
|
||||||
|
auto_fetch: cfg.auto_fetch || {},
|
||||||
|
damai_server_fetch: cfg.damai_server_fetch === true,
|
||||||
|
local_agent_port: Number((cfg.local_agent || {}).port) || 8978
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 本地代理(家庭 IP)抓完后,把数据推回服务器入库
|
||||||
|
app.post('/api/free_show/ingest', (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = req.body || {}
|
||||||
|
const platform = String(body.platform || '')
|
||||||
|
const city = String(body.city || '上海')
|
||||||
|
const mode = String(body.mode || 'append')
|
||||||
|
const maxPrice = Number(body.maxPrice) || 5
|
||||||
|
const events = Array.isArray(body.events) ? body.events : []
|
||||||
|
if (!['showstart', 'maoyan', 'damai'].includes(platform)) return res.status(400).json({ success: false, error: '不支持的平台' })
|
||||||
|
if (events.length === 0) return res.status(400).json({ success: false, error: '无数据可入库' })
|
||||||
|
saveEvents(platform, city, events, mode === 'overwrite' ? 'overwrite' : 'append')
|
||||||
|
res.json(buildResponse(platform, city, maxPrice, loadEvents(platform, city), nowBeijing(), false, events.length))
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ success: false, error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/free_show/auto_status', (req, res) => {
|
||||||
|
try {
|
||||||
|
const lastRun = (getMeta.get('last_auto_run') || {}).value || null
|
||||||
|
let lastResult = null
|
||||||
|
try { lastResult = JSON.parse((getMeta.get('last_auto_result') || {}).value || 'null') } catch {}
|
||||||
|
res.json({ ok: true, lastRun, lastResult })
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/free_show/fetch', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const platform = String(req.query.platform || 'showstart')
|
||||||
|
const city = String(req.query.city || '上海')
|
||||||
|
const maxPrice = parseFloat(req.query.maxPrice) || 5
|
||||||
|
const pages = Math.min(parseInt(req.query.pages) || 3, 10)
|
||||||
|
const mode = String(req.query.mode || 'cache')
|
||||||
|
const result = await runFetch(platform, city, maxPrice, mode, pages)
|
||||||
|
res.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: String(err.message || err) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/free_show/cities', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
showstart: Object.keys(SHOWSTART_CITIES),
|
||||||
|
maoyan: Object.keys(MAOYAN_CITIES),
|
||||||
|
damai: Object.keys(DAMAI_CITIES)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优雅退出时关闭浏览器
|
||||||
|
process.on('exit', () => {
|
||||||
|
try { if (browserInstance) browserInstance.close() } catch {}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = { bindRoutes, startScheduler, readConfig }
|
||||||
+150
-2
@@ -51,10 +51,12 @@ const marketSkills = require('./market_skills/manager')
|
|||||||
const securityCalendar = require('./security_calendar')
|
const securityCalendar = require('./security_calendar')
|
||||||
const securityCalendarBridge = require('./security_calendar_calendar_bridge')
|
const securityCalendarBridge = require('./security_calendar_calendar_bridge')
|
||||||
const marketsWeeklyReport = require('./markets_weekly_report')
|
const marketsWeeklyReport = require('./markets_weekly_report')
|
||||||
|
const marketsDipEval = require('./markets_dip_eval')
|
||||||
const { upsertMarketArchive, getMarketArchive } = require('./db')
|
const { upsertMarketArchive, getMarketArchive } = require('./db')
|
||||||
const { getConfig: getWallCfg, setConfig: setWallCfg, listCards: listWallCards, createCard: createWallCard, updateCardById: updateWallCardById, deleteCardById: deleteWallCardById } = require('./wall')
|
const { getConfig: getWallCfg, setConfig: setWallCfg, listCards: listWallCards, createCard: createWallCard, updateCardById: updateWallCardById, deleteCardById: deleteWallCardById } = require('./wall')
|
||||||
const fundsGuoxin = require('./funds_guoxin')
|
const fundsGuoxin = require('./funds_guoxin')
|
||||||
const fundsHuatai = require('./funds_huatai')
|
const fundsHuatai = require('./funds_huatai')
|
||||||
|
const freeShow = require('./free_show')
|
||||||
const ccbPrivateFunds = require('./ccb_private_funds')
|
const ccbPrivateFunds = require('./ccb_private_funds')
|
||||||
const cloud = require('./cloud')
|
const cloud = require('./cloud')
|
||||||
const cloudBalanceWatch = require('./cloud_balance_watch')
|
const cloudBalanceWatch = require('./cloud_balance_watch')
|
||||||
@@ -223,6 +225,7 @@ app.use((req, res, next) => {
|
|||||||
req.path.startsWith('/api/doc_calendar_bridge') ||
|
req.path.startsWith('/api/doc_calendar_bridge') ||
|
||||||
req.path.startsWith('/api/markets/security_calendar') ||
|
req.path.startsWith('/api/markets/security_calendar') ||
|
||||||
req.path.startsWith('/api/markets/weekly_report') ||
|
req.path.startsWith('/api/markets/weekly_report') ||
|
||||||
|
req.path.startsWith('/api/markets/dip_eval') ||
|
||||||
req.path.startsWith('/api/global_news') ||
|
req.path.startsWith('/api/global_news') ||
|
||||||
req.path.startsWith('/api/box_backup')
|
req.path.startsWith('/api/box_backup')
|
||||||
) setNoCache(res)
|
) setNoCache(res)
|
||||||
@@ -1212,7 +1215,7 @@ if (!fs.existsSync(settingsPath)) fs.writeFileSync(settingsPath, JSON.stringify(
|
|||||||
const flagsPath = path.join(process.cwd(), 'config', 'flags.json')
|
const flagsPath = path.join(process.cwd(), 'config', 'flags.json')
|
||||||
if (!fs.existsSync(flagsPath)) fs.writeFileSync(flagsPath, JSON.stringify({
|
if (!fs.existsSync(flagsPath)) fs.writeFileSync(flagsPath, JSON.stringify({
|
||||||
debugTool: { enabled: true, launchDate: null, autoHideAfterDays: 30 },
|
debugTool: { enabled: true, launchDate: null, autoHideAfterDays: 30 },
|
||||||
debug: { weibo: true, markets: true, wall: true, funds_guoxin: true, funds_huatai: true },
|
debug: { weibo: true, markets: true, wall: true, funds_guoxin: true, funds_huatai: true, free_show: true },
|
||||||
navAuth: { enable_auth: true, iss: 'TRAE-NAV', iss_strict: false }
|
navAuth: { enable_auth: true, iss: 'TRAE-NAV', iss_strict: false }
|
||||||
}, null, 2))
|
}, null, 2))
|
||||||
|
|
||||||
@@ -1611,6 +1614,7 @@ const mapSystemIdToUrl = (systemId) => {
|
|||||||
'Tools-wall-': '/tools/wall',
|
'Tools-wall-': '/tools/wall',
|
||||||
'Tools-funds_guoxin': '/tools/funds_guoxin',
|
'Tools-funds_guoxin': '/tools/funds_guoxin',
|
||||||
'Tools-funds_huatai': '/tools/funds_huatai',
|
'Tools-funds_huatai': '/tools/funds_huatai',
|
||||||
|
'Tools-free_show': '/tools/free_show',
|
||||||
'Tools-ccb_private_funds': '/tools/ccb_private_funds',
|
'Tools-ccb_private_funds': '/tools/ccb_private_funds',
|
||||||
'Tools-cloud': '/tools/cloud',
|
'Tools-cloud': '/tools/cloud',
|
||||||
'Tools-weekly': '/tools/weekly',
|
'Tools-weekly': '/tools/weekly',
|
||||||
@@ -2047,6 +2051,136 @@ app.use('/api/funds_huatai', (req, res, next) => {
|
|||||||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ============ free_show(免费演出雷达)鉴权 ============
|
||||||
|
const FREE_SHOW_AUTH_COOKIE = 'free_show_gate'
|
||||||
|
const readFreeShowAuthConfig = () => {
|
||||||
|
try {
|
||||||
|
const p = path.join(process.cwd(), 'public', 'tools', 'free_show', 'auth_config.json')
|
||||||
|
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||||
|
} catch {}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
const readFreeShowJwk = () => {
|
||||||
|
try {
|
||||||
|
const p = path.join(process.cwd(), 'config', 'free_show.jwk.json')
|
||||||
|
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||||
|
} catch {}
|
||||||
|
return { kty: 'RSA', n: '', e: '' }
|
||||||
|
}
|
||||||
|
const getFreeShowCookieName = () => {
|
||||||
|
try { const n = String(readFreeShowAuthConfig().cookieName || ''); if (n) return n } catch {}
|
||||||
|
return FREE_SHOW_AUTH_COOKIE
|
||||||
|
}
|
||||||
|
const getFreeShowMaxAge = () => {
|
||||||
|
try {
|
||||||
|
const days = Number(readFreeShowAuthConfig().max_age_days || 0)
|
||||||
|
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
|
||||||
|
} catch {}
|
||||||
|
return 30 * 24 * 3600 * 1000
|
||||||
|
}
|
||||||
|
const getFreeShowGuidConfig = () => {
|
||||||
|
try {
|
||||||
|
const cfg = readFreeShowAuthConfig()
|
||||||
|
const keyRaw = String(cfg.guidParam || cfg.guid_param || 'guid').trim()
|
||||||
|
const valueRaw = String(cfg.guidValue || cfg.guid_value || '').trim()
|
||||||
|
return { key: keyRaw || 'guid', value: valueRaw }
|
||||||
|
} catch { return { key: 'guid', value: '' } }
|
||||||
|
}
|
||||||
|
const hasValidFreeShowGuid = req => {
|
||||||
|
try {
|
||||||
|
const cfg = readFreeShowAuthConfig()
|
||||||
|
if (cfg.enable_auth === false) return false
|
||||||
|
const { key, value } = getFreeShowGuidConfig()
|
||||||
|
if (!value) return false
|
||||||
|
const q = req && req.query ? req.query : {}
|
||||||
|
const raw = q[key]
|
||||||
|
if (raw === undefined || raw === null) return false
|
||||||
|
const incoming = Array.isArray(raw) ? String(raw[0] || '') : String(raw)
|
||||||
|
return incoming === value
|
||||||
|
} catch { return false }
|
||||||
|
}
|
||||||
|
const isFreeShowForceGuid = () => {
|
||||||
|
try {
|
||||||
|
const cfg = readFreeShowAuthConfig()
|
||||||
|
if (cfg.enable_auth === false) return false
|
||||||
|
if (Object.prototype.hasOwnProperty.call(cfg, 'requireGuid')) return !!cfg.requireGuid
|
||||||
|
if (Object.prototype.hasOwnProperty.call(cfg, 'require_guid')) return !!cfg.require_guid
|
||||||
|
return false
|
||||||
|
} catch { return false }
|
||||||
|
}
|
||||||
|
const hasFreeShowAuth = req => {
|
||||||
|
try {
|
||||||
|
const config = readFreeShowAuthConfig()
|
||||||
|
if (config.enable_auth === false) return true
|
||||||
|
const cookies = parseCookie(req.headers.cookie || '')
|
||||||
|
return cookies[getFreeShowCookieName()] === '1'
|
||||||
|
} catch { return false }
|
||||||
|
}
|
||||||
|
app.get('/tools/free_show', (req, res, next) => {
|
||||||
|
setNoCache(res)
|
||||||
|
try {
|
||||||
|
const forceGuid = isFreeShowForceGuid()
|
||||||
|
if (hasFreeShowAuth(req)) return next()
|
||||||
|
const guidKey = getFreeShowGuidConfig().key
|
||||||
|
const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
|
||||||
|
if (hasValidFreeShowGuid(req)) {
|
||||||
|
const maxAge = getFreeShowMaxAge()
|
||||||
|
const cookieName = getFreeShowCookieName()
|
||||||
|
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||||
|
return res.redirect('/tools/free_show/index.html')
|
||||||
|
}
|
||||||
|
if (hasGuidInput) return res.status(401).send('未授权')
|
||||||
|
const token = String(req.query.token || '')
|
||||||
|
if (!token) {
|
||||||
|
if (forceGuid) return res.status(401).send('未授权')
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
const payload = verifyJwtWithKeys(token, jwkKeys(readFreeShowJwk()))
|
||||||
|
if (!payload) return res.status(401).send('未授权')
|
||||||
|
{
|
||||||
|
const iss = String(payload.iss || '')
|
||||||
|
const navIss = getNavIssFromFlags()
|
||||||
|
if (iss === navIss && !audMatch(payload, 'Tools-free_show')) return res.status(401).send('未授权')
|
||||||
|
}
|
||||||
|
const maxAge = getFreeShowMaxAge()
|
||||||
|
const cookieName = getFreeShowCookieName()
|
||||||
|
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||||
|
return res.redirect('/tools/free_show/index.html')
|
||||||
|
} catch (e) { return res.status(401).send('未授权') }
|
||||||
|
})
|
||||||
|
app.use('/tools/free_show', (req, res, next) => {
|
||||||
|
const forceGuid = isFreeShowForceGuid()
|
||||||
|
if (hasFreeShowAuth(req)) return next()
|
||||||
|
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html')
|
||||||
|
const guidKey = getFreeShowGuidConfig().key
|
||||||
|
const hasGuidInput = isHandshake && req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
|
||||||
|
if (isHandshake && hasValidFreeShowGuid(req)) {
|
||||||
|
const maxAge = getFreeShowMaxAge()
|
||||||
|
const cookieName = getFreeShowCookieName()
|
||||||
|
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||||
|
return res.redirect('/tools/free_show/index.html')
|
||||||
|
}
|
||||||
|
if (hasGuidInput) return res.status(401).send('未授权')
|
||||||
|
if (isHandshake && String(req.query.token || '')) {
|
||||||
|
const payload = verifyJwtWithKeys(String(req.query.token), jwkKeys(readFreeShowJwk()))
|
||||||
|
if (payload) {
|
||||||
|
const iss = String(payload.iss || '')
|
||||||
|
const navIss = getNavIssFromFlags()
|
||||||
|
if (iss === navIss && !audMatch(payload, 'Tools-free_show')) return res.status(401).send('未授权')
|
||||||
|
const maxAge = getFreeShowMaxAge()
|
||||||
|
const cookieName = getFreeShowCookieName()
|
||||||
|
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||||
|
return res.redirect('/tools/free_show/index.html')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isHandshake && forceGuid) return res.status(401).send('未授权')
|
||||||
|
return res.status(401).send('未授权')
|
||||||
|
})
|
||||||
|
app.use('/api/free_show', (req, res, next) => {
|
||||||
|
if (hasFreeShowAuth(req)) return next()
|
||||||
|
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||||||
|
})
|
||||||
|
|
||||||
const CCB_PRIVATE_FUNDS_AUTH_COOKIE = 'ccb_private_funds_gate'
|
const CCB_PRIVATE_FUNDS_AUTH_COOKIE = 'ccb_private_funds_gate'
|
||||||
const readCcbPrivateFundsJwk = () => {
|
const readCcbPrivateFundsJwk = () => {
|
||||||
try {
|
try {
|
||||||
@@ -4104,6 +4238,7 @@ app.get('/api/debug/jwt/verify', (req, res) => {
|
|||||||
'cloud': () => readCloudJwk(),
|
'cloud': () => readCloudJwk(),
|
||||||
'funds_guoxin': () => readFundsGuoxinJwk(),
|
'funds_guoxin': () => readFundsGuoxinJwk(),
|
||||||
'funds_huatai': () => readFundsHuataiJwk(),
|
'funds_huatai': () => readFundsHuataiJwk(),
|
||||||
|
'free_show': () => readFreeShowJwk(),
|
||||||
'markets': () => readMarketsJwk(),
|
'markets': () => readMarketsJwk(),
|
||||||
'wall': () => readWallJwk(),
|
'wall': () => readWallJwk(),
|
||||||
'weekly': () => readWeeklyJwk(),
|
'weekly': () => readWeeklyJwk(),
|
||||||
@@ -4257,7 +4392,7 @@ app.post('/api/token_lab/nav/verify', (req, res) => {
|
|||||||
|
|
||||||
app.get('/api/flags', (req, res) => {
|
app.get('/api/flags', (req, res) => {
|
||||||
const enabled = computeDebugEnabled()
|
const enabled = computeDebugEnabled()
|
||||||
res.json({ ok: true, debugToolEnabled: enabled, debug: { weibo: computeToolDebug('weibo'), markets: computeToolDebug('markets'), wall: computeToolDebug('wall'), funds_guoxin: computeToolDebug('funds_guoxin'), funds_huatai: computeToolDebug('funds_huatai'), weekly: computeToolDebug('weekly') } })
|
res.json({ ok: true, debugToolEnabled: enabled, debug: { weibo: computeToolDebug('weibo'), markets: computeToolDebug('markets'), wall: computeToolDebug('wall'), funds_guoxin: computeToolDebug('funds_guoxin'), funds_huatai: computeToolDebug('funds_huatai'), free_show: computeToolDebug('free_show'), weekly: computeToolDebug('weekly') } })
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/api/weibo/devtools/status', async (req, res) => {
|
app.get('/api/weibo/devtools/status', async (req, res) => {
|
||||||
@@ -4625,6 +4760,17 @@ app.get('/api/markets/weekly_report', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 建行定投评估:按需抓取四大指数 PE-TTM 与近10年分位(点击页面按钮触发,无定时任务)
|
||||||
|
app.get('/api/markets/dip_eval', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const payload = await marketsDipEval.getDipEvalReport()
|
||||||
|
// 统一 200 + ok 标志:整体失败时 ok=false,前端交互弹窗读取 error 文案
|
||||||
|
res.json(payload || { ok: false, error: '评估失败' })
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.post('/api/markets/config', (req, res) => {
|
app.post('/api/markets/config', (req, res) => {
|
||||||
const body = req.body || {}
|
const body = req.body || {}
|
||||||
const cfg = getMarketsCfg()
|
const cfg = getMarketsCfg()
|
||||||
@@ -6838,6 +6984,7 @@ app.get('/api/short_link/auth/hint', (req, res) => {
|
|||||||
|
|
||||||
// Short Link 业务路由(/s/:code 公开跳转 + /api/short_link/* 管理接口)
|
// Short Link 业务路由(/s/:code 公开跳转 + /api/short_link/* 管理接口)
|
||||||
shortLink.bindRoutes(app)
|
shortLink.bindRoutes(app)
|
||||||
|
freeShow.bindRoutes(app)
|
||||||
|
|
||||||
const marketsCfgAtBoot = getMarketsCfg()
|
const marketsCfgAtBoot = getMarketsCfg()
|
||||||
const currentPort = Number(process.env.PORT || '8976') || 8976
|
const currentPort = Number(process.env.PORT || '8976') || 8976
|
||||||
@@ -6845,6 +6992,7 @@ const marketsAutomationPort = Number(marketsCfgAtBoot.automation_port || 8977) |
|
|||||||
if (currentPort === marketsAutomationPort) {
|
if (currentPort === marketsAutomationPort) {
|
||||||
startMarketsScheduler()
|
startMarketsScheduler()
|
||||||
marketSkills.start()
|
marketSkills.start()
|
||||||
|
freeShow.startScheduler()
|
||||||
}
|
}
|
||||||
securityCalendar.startScheduler()
|
securityCalendar.startScheduler()
|
||||||
securityCalendarBridge.startScheduler(securityCalendar)
|
securityCalendarBridge.startScheduler(securityCalendar)
|
||||||
|
|||||||
+26
-3
@@ -51,6 +51,8 @@ if (!fs.existsSync(cfgPath)) {
|
|||||||
},
|
},
|
||||||
watchlist: [],
|
watchlist: [],
|
||||||
threshold_percent: -2,
|
threshold_percent: -2,
|
||||||
|
notify_symbol_allowlist_comment: '推送告警白名单(邮件/周报/日历):仅以下 symbol 参与告警推送,其余卡片只展示不推送;空数组表示不限制',
|
||||||
|
notify_symbol_allowlist: ['sh000001', 'sz399001', 'sh000300', 'sh000688', 'sz399006', 'usINX', 'usIXIC', 'hf_GC'],
|
||||||
comex_gold_min_price: 4800,
|
comex_gold_min_price: 4800,
|
||||||
comex_gold_max_price: 5500,
|
comex_gold_max_price: 5500,
|
||||||
email: { enabled: false, from: "", to: [], smtp: { host: "", port: 465, secure: true, auth: { user: "", pass: "" } } },
|
email: { enabled: false, from: "", to: [], smtp: { host: "", port: 465, secure: true, auth: { user: "", pass: "" } } },
|
||||||
@@ -512,13 +514,34 @@ const isComexGold = q => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 推送告警白名单过滤:仅 cfg.notify_symbol_allowlist 中的 symbol 参与邮件/周报/日历推送
|
||||||
|
// 白名单为空或非数组时不限制(兼容旧配置);只影响推送,不影响 web 页面展示
|
||||||
|
const applyNotifyAllowlist = (quotes, cfg) => {
|
||||||
|
const list = cfg && Array.isArray(cfg.notify_symbol_allowlist) ? cfg.notify_symbol_allowlist : []
|
||||||
|
const allowed = list.map(s => String(s || '').trim().toLowerCase()).filter(Boolean)
|
||||||
|
if (allowed.length === 0) return { kept: quotes || [], excluded: [] }
|
||||||
|
const kept = []
|
||||||
|
const excluded = []
|
||||||
|
for (const q of quotes || []) {
|
||||||
|
const sym = String((q && q.symbol) || '').trim().toLowerCase()
|
||||||
|
if (allowed.includes(sym)) kept.push(q)
|
||||||
|
else excluded.push({ symbol: q && q.symbol, label: q && q.label })
|
||||||
|
}
|
||||||
|
return { kept, excluded }
|
||||||
|
}
|
||||||
|
|
||||||
const maybeNotify = async quotes => {
|
const maybeNotify = async quotes => {
|
||||||
const cfg = getConfig()
|
const cfg = getConfig()
|
||||||
const threshold = typeof cfg.threshold_percent === 'number' ? cfg.threshold_percent : -2
|
const threshold = typeof cfg.threshold_percent === 'number' ? cfg.threshold_percent : -2
|
||||||
const goldMin = typeof cfg.comex_gold_min_price === 'number' ? cfg.comex_gold_min_price : 4800
|
const goldMin = typeof cfg.comex_gold_min_price === 'number' ? cfg.comex_gold_min_price : 4800
|
||||||
const goldMax = typeof cfg.comex_gold_max_price === 'number' ? cfg.comex_gold_max_price : 5500
|
const goldMax = typeof cfg.comex_gold_max_price === 'number' ? cfg.comex_gold_max_price : 5500
|
||||||
const badCommon = quotes.filter(q => typeof q.change_percent === 'number' && q.change_percent <= threshold)
|
// 白名单过滤:其余卡片不再参与任何推送(邮件/周报/日历)
|
||||||
const badGoldPrice = quotes
|
const { kept: allowedQuotes, excluded } = applyNotifyAllowlist(quotes, cfg)
|
||||||
|
if (excluded.length > 0) {
|
||||||
|
logJSON('markets.notify.allowlist.filter', { total: (quotes || []).length, kept: allowedQuotes.length, excluded_count: excluded.length, excluded_symbols: excluded.map(e => e.symbol) }, 'markets')
|
||||||
|
}
|
||||||
|
const badCommon = allowedQuotes.filter(q => typeof q.change_percent === 'number' && q.change_percent <= threshold)
|
||||||
|
const badGoldPrice = allowedQuotes
|
||||||
.filter(isComexGold)
|
.filter(isComexGold)
|
||||||
.filter(q => typeof q.price === 'number' && (q.price <= goldMin || q.price >= goldMax))
|
.filter(q => typeof q.price === 'number' && (q.price <= goldMin || q.price >= goldMax))
|
||||||
const badMap = new Map()
|
const badMap = new Map()
|
||||||
@@ -851,4 +874,4 @@ const sendTestEmail = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { fetchAll, queryByDate, getConfig, setConfig, startScheduler, sendTestEmail, maybeNotify }
|
module.exports = { fetchAll, queryByDate, getConfig, setConfig, startScheduler, sendTestEmail, maybeNotify, applyNotifyAllowlist }
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
/**
|
||||||
|
* 建行定投评估(markets 子模块)
|
||||||
|
*
|
||||||
|
* 用途:银行场外基金定投/赎回参考。按需抓取四大指数 PE-TTM 与近 10 年 PE 分位,
|
||||||
|
* 对照个人定投档位阈值给出建议。不做定时任务,点击页面按钮才触发。
|
||||||
|
*
|
||||||
|
* 数据源(探针验证于 dev_test_scripts/debug/debug_dip_eval_sources*.js):
|
||||||
|
* - 沪深300 / 中证500:中证指数官网 index-perf(官方日频市盈率历史,精确计算近 10 年分位),备源:蛋卷基金
|
||||||
|
* - 标普500 / 纳指100:蛋卷基金 index_eva/dj(每日更新 PE + 分位),标普备源:multpl.com
|
||||||
|
* - 东方财富 push2 / 数据中心对指数不返回 PE,故未采用
|
||||||
|
*
|
||||||
|
* ============================================================
|
||||||
|
* 个人定投档位阈值(配套我的估值阈值,档位判定的唯一依据)
|
||||||
|
* ============================================================
|
||||||
|
* 沪深300(000300):
|
||||||
|
* PE<12(分位<30%)→ 每日定投 200 元
|
||||||
|
* 12 ≤ PE < 14.5 → 每日定投 150 元
|
||||||
|
* 14.5 ≤ PE < 16 → 每日定投 100 元,可止盈 50%
|
||||||
|
* PE ≥ 16 → 暂停定投 + 止盈清仓
|
||||||
|
*
|
||||||
|
* 中证500(000905):
|
||||||
|
* PE<25 → 每日定投 200 元
|
||||||
|
* 25 ≤ PE < 32 → 每日定投 150 元
|
||||||
|
* 32 ≤ PE < 36 → 每日定投 100 元,可止盈 50%
|
||||||
|
* PE ≥ 36 → 暂停定投 + 止盈清仓
|
||||||
|
*
|
||||||
|
* 纳指100(NDX):
|
||||||
|
* PE<24 → 每日定投 200 元
|
||||||
|
* 24 ≤ PE < 30 → 每日定投 150 元
|
||||||
|
* 30 ≤ PE < 36 → 每日定投 100 元,可止盈 50%
|
||||||
|
* PE ≥ 36 → 暂停定投 + 止盈清仓
|
||||||
|
*
|
||||||
|
* 标普500(SP500):
|
||||||
|
* PE<16 → 每日定投 200 元
|
||||||
|
* 16 ≤ PE < 21 → 每日定投 150 元
|
||||||
|
* 21 ≤ PE < 25 → 每日定投 100 元,可止盈 50%
|
||||||
|
* PE ≥ 25 → 暂停定投 + 止盈清仓
|
||||||
|
* ============================================================
|
||||||
|
*/
|
||||||
|
const axios = require('axios')
|
||||||
|
const { logJSON } = require('./logger')
|
||||||
|
|
||||||
|
// 通用请求头(模拟浏览器,避免被数据源拦截)
|
||||||
|
const HTTP_HEADERS = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 四大指数的档位阈值配置(含义见文件头注释)
|
||||||
|
// bands 按 PE 从低到高排列,max 为该档上边界(不含),命中即停
|
||||||
|
const INDEX_PLAN = [
|
||||||
|
{
|
||||||
|
key: 'csi300',
|
||||||
|
name: '沪深300',
|
||||||
|
code: '000300',
|
||||||
|
market: 'A股',
|
||||||
|
danjuan_code: 'SH000300', // 蛋卷基金接口中的指数代码
|
||||||
|
csindex: true, // A股指数可走中证指数官网(官方日频PE历史)
|
||||||
|
bands: [
|
||||||
|
{ max: 12, advice: '每日定投 200 元', level: 'buy_heavy' }, // PE<12(分位<30%)
|
||||||
|
{ max: 14.5, advice: '每日定投 150 元', level: 'buy' }, // 12 ≤ PE < 14.5
|
||||||
|
{ max: 16, advice: '每日定投 100 元,可止盈 50%', level: 'take_half' }, // 14.5 ≤ PE < 16
|
||||||
|
{ max: Infinity, advice: '暂停定投 + 止盈清仓', level: 'exit' } // PE ≥ 16
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'csi500',
|
||||||
|
name: '中证500',
|
||||||
|
code: '000905',
|
||||||
|
market: 'A股',
|
||||||
|
danjuan_code: 'SH000905',
|
||||||
|
csindex: true,
|
||||||
|
bands: [
|
||||||
|
{ max: 25, advice: '每日定投 200 元', level: 'buy_heavy' }, // PE<25
|
||||||
|
{ max: 32, advice: '每日定投 150 元', level: 'buy' }, // 25 ≤ PE < 32
|
||||||
|
{ max: 36, advice: '每日定投 100 元,可止盈 50%', level: 'take_half' }, // 32 ≤ PE < 36
|
||||||
|
{ max: Infinity, advice: '暂停定投 + 止盈清仓', level: 'exit' } // PE ≥ 36
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ndx',
|
||||||
|
name: '纳指100',
|
||||||
|
code: 'NDX',
|
||||||
|
market: '美股',
|
||||||
|
danjuan_code: 'NDX',
|
||||||
|
csindex: false, // 美股无中证官网数据,仅蛋卷主源;缺源时分位显示 —
|
||||||
|
bands: [
|
||||||
|
{ max: 24, advice: '每日定投 200 元', level: 'buy_heavy' }, // PE<24
|
||||||
|
{ max: 30, advice: '每日定投 150 元', level: 'buy' }, // 24 ≤ PE < 30
|
||||||
|
{ max: 36, advice: '每日定投 100 元,可止盈 50%', level: 'take_half' }, // 30 ≤ PE < 36
|
||||||
|
{ max: Infinity, advice: '暂停定投 + 止盈清仓', level: 'exit' } // PE ≥ 36
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sp500',
|
||||||
|
name: '标普500',
|
||||||
|
code: 'SP500',
|
||||||
|
market: '美股',
|
||||||
|
danjuan_code: 'SP500',
|
||||||
|
csindex: false,
|
||||||
|
bands: [
|
||||||
|
{ max: 16, advice: '每日定投 200 元', level: 'buy_heavy' }, // PE<16
|
||||||
|
{ max: 21, advice: '每日定投 150 元', level: 'buy' }, // 16 ≤ PE < 21
|
||||||
|
{ max: 25, advice: '每日定投 100 元,可止盈 50%', level: 'take_half' }, // 21 ≤ PE < 25
|
||||||
|
{ max: Infinity, advice: '暂停定投 + 止盈清仓', level: 'exit' } // PE ≥ 25
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------- 工具函数 ----------
|
||||||
|
|
||||||
|
// 当前北京时间字符串:YYYY-MM-DD HH:mm:ss(项目统一北京时间)
|
||||||
|
const beijingNow = () => new Date(Date.now() + 8 * 3600 * 1000).toISOString().replace('T', ' ').slice(0, 19)
|
||||||
|
|
||||||
|
// 北京时间的 YYYYMMDD(用于中证官网日期参数)
|
||||||
|
const beijingDateCompact = offsetDays => {
|
||||||
|
const d = new Date(Date.now() + 8 * 3600 * 1000 + (offsetDays || 0) * 86400 * 1000)
|
||||||
|
return d.toISOString().slice(0, 10).replace(/-/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const toNum = v => {
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? n : NaN
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 PE 档位表判定建议(bands 从低到高,max 为上边界不含)
|
||||||
|
const matchBand = (bands, pe) => {
|
||||||
|
if (!Number.isFinite(pe)) return null
|
||||||
|
for (const b of bands) {
|
||||||
|
if (pe < b.max) return b
|
||||||
|
}
|
||||||
|
return bands[bands.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 数据源抓取 ----------
|
||||||
|
|
||||||
|
// 蛋卷基金指数估值:一次返回全部指数(含沪深300/中证500/标普500/纳指100)
|
||||||
|
// 返回 Map<index_code, {pe, pe_percentile(0~1), date, begin_at}>
|
||||||
|
const fetchDanjuanEva = async () => {
|
||||||
|
const url = 'https://danjuanfunds.com/djapi/index_eva/dj'
|
||||||
|
const res = await axios.get(url, { timeout: 15000, headers: HTTP_HEADERS, validateStatus: () => true })
|
||||||
|
const items = res && res.data && res.data.data && Array.isArray(res.data.data.items) ? res.data.data.items : []
|
||||||
|
if (!items.length) throw new Error(`蛋卷接口无数据(HTTP ${res ? res.status : '无响应'})`)
|
||||||
|
const map = {}
|
||||||
|
for (const it of items) {
|
||||||
|
const code = String(it.index_code || '')
|
||||||
|
if (!code) continue
|
||||||
|
map[code] = {
|
||||||
|
pe: toNum(it.pe),
|
||||||
|
pe_percentile: toNum(it.pe_percentile), // 0~1 小数
|
||||||
|
date: String(it.date || ''), // 数据日期 MM-DD
|
||||||
|
begin_at: Number(it.begin_at || 0) // 分位统计窗口起点(毫秒)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中证指数官网:指数官方日频行情(含市盈率 peg 字段,实测即 PE)
|
||||||
|
// 返回按日期升序的 [{date, pe}],覆盖近 10 年
|
||||||
|
const fetchCsindexPeSeries = async code => {
|
||||||
|
const end = beijingDateCompact(0)
|
||||||
|
const start = beijingDateCompact(-3652) // 约 10 年
|
||||||
|
const url = `https://www.csindex.com.cn/csindex-home/perf/index-perf?indexCode=${encodeURIComponent(code)}&startDate=${start}&endDate=${end}`
|
||||||
|
const res = await axios.get(url, {
|
||||||
|
timeout: 20000,
|
||||||
|
headers: { ...HTTP_HEADERS, 'Referer': 'https://www.csindex.com.cn/' },
|
||||||
|
validateStatus: () => true
|
||||||
|
})
|
||||||
|
const list = res && res.data && Array.isArray(res.data.data) ? res.data.data : []
|
||||||
|
if (!list.length) throw new Error(`中证官网无数据(HTTP ${res ? res.status : '无响应'})`)
|
||||||
|
const out = []
|
||||||
|
for (const r of list) {
|
||||||
|
const pe = toNum(r.peg) // 官网字段名为 peg,实测值即市盈率(与蛋卷 PE 一致)
|
||||||
|
if (!Number.isFinite(pe) || pe <= 0) continue
|
||||||
|
out.push({ date: String(r.tradeDate || ''), pe })
|
||||||
|
}
|
||||||
|
if (!out.length) throw new Error('中证官网返回记录中无有效市盈率')
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// multpl.com:标普500 当前 PE 备源(页面文本 "Current S&P 500 PE Ratio is 29.26")
|
||||||
|
const fetchMultplSp500Pe = async () => {
|
||||||
|
const res = await axios.get('https://www.multpl.com/s-p-500-pe-ratio', { timeout: 15000, headers: HTTP_HEADERS, validateStatus: () => true })
|
||||||
|
const html = String(res && res.data ? res.data : '')
|
||||||
|
const m = html.match(/Current S&P 500 PE Ratio[^0-9]{0,40}([0-9]+(?:\.[0-9]+)?)/i)
|
||||||
|
if (!m) throw new Error('multpl 页面未匹配到 PE 数值')
|
||||||
|
const pe = toNum(m[1])
|
||||||
|
if (!Number.isFinite(pe) || pe <= 0) throw new Error('multpl PE 数值无效')
|
||||||
|
return pe
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 单指数评估 ----------
|
||||||
|
|
||||||
|
// A股指数:中证官网主源(精确近10年分位),蛋卷备源
|
||||||
|
const evalAShareIndex = async (plan, danjuanMap, errors) => {
|
||||||
|
// 主源:中证指数官网近 10 年日频 PE,自行计算分位
|
||||||
|
try {
|
||||||
|
const series = await fetchCsindexPeSeries(plan.code)
|
||||||
|
const latest = series[series.length - 1]
|
||||||
|
const pe = latest.pe
|
||||||
|
// 分位 = 历史中 ≤ 当前 PE 的样本占比(样本即近10年全部交易日)
|
||||||
|
let below = 0
|
||||||
|
for (const s of series) {
|
||||||
|
if (s.pe <= pe) below++
|
||||||
|
}
|
||||||
|
const percentile = Math.round((below / series.length) * 1000) / 10
|
||||||
|
const band = matchBand(plan.bands, pe)
|
||||||
|
// 双源口径校验:蛋卷与中证官网的 PE 计算口径不同(如中证500 差异可达 30%),
|
||||||
|
// 差异超过 15% 时在界面上备注,避免单一口径误导定投决策
|
||||||
|
let note = ''
|
||||||
|
const dj = danjuanMap && danjuanMap[plan.danjuan_code]
|
||||||
|
if (dj && Number.isFinite(dj.pe) && Math.abs(dj.pe - pe) / pe > 0.15) {
|
||||||
|
const djBand = matchBand(plan.bands, dj.pe)
|
||||||
|
note = `蛋卷口径 PE=${dj.pe.toFixed(2)}(对应档位:${djBand ? djBand.advice : '—'}),与官方口径差异较大,请结合自身基金跟踪口径判断`
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: plan.name,
|
||||||
|
code: plan.code,
|
||||||
|
market: plan.market,
|
||||||
|
pe_ttm: Math.round(pe * 100) / 100,
|
||||||
|
pe_date: latest.date.replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3'),
|
||||||
|
percentile,
|
||||||
|
percentile_note: `近10年日频自算(${series.length} 个样本)`,
|
||||||
|
source: '中证指数官网',
|
||||||
|
advice: band ? band.advice : '—',
|
||||||
|
level: band ? band.level : 'unknown',
|
||||||
|
note
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`${plan.name}:中证官网获取失败(${String(e.message || e)}),尝试备源`)
|
||||||
|
}
|
||||||
|
// 备源:蛋卷基金(其分位窗口自 2016-04 起,约 10 年)
|
||||||
|
const dj = danjuanMap && danjuanMap[plan.danjuan_code]
|
||||||
|
if (dj && Number.isFinite(dj.pe)) {
|
||||||
|
const band = matchBand(plan.bands, dj.pe)
|
||||||
|
const beginYear = dj.begin_at ? new Date(dj.begin_at + 8 * 3600 * 1000).toISOString().slice(0, 4) : ''
|
||||||
|
return {
|
||||||
|
name: plan.name,
|
||||||
|
code: plan.code,
|
||||||
|
market: plan.market,
|
||||||
|
pe_ttm: Math.round(dj.pe * 100) / 100,
|
||||||
|
pe_date: dj.date ? `${new Date().getFullYear()}-${dj.date}` : '',
|
||||||
|
percentile: Number.isFinite(dj.pe_percentile) ? Math.round(dj.pe_percentile * 1000) / 10 : null,
|
||||||
|
percentile_note: beginYear ? `统计窗口自 ${beginYear} 年起(约10年)` : '蛋卷口径',
|
||||||
|
source: '蛋卷基金(备源)',
|
||||||
|
advice: band ? band.advice : '—',
|
||||||
|
level: band ? band.level : 'unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errors.push(`${plan.name}:主源与备源均获取失败`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 美股指数:蛋卷主源(PE+分位);标普500 额外有 multpl 备源(仅PE,分位显示 —)
|
||||||
|
const evalUsIndex = async (plan, danjuanMap, errors) => {
|
||||||
|
const dj = danjuanMap && danjuanMap[plan.danjuan_code]
|
||||||
|
if (dj && Number.isFinite(dj.pe)) {
|
||||||
|
const band = matchBand(plan.bands, dj.pe)
|
||||||
|
const beginYear = dj.begin_at ? new Date(dj.begin_at + 8 * 3600 * 1000).toISOString().slice(0, 4) : ''
|
||||||
|
return {
|
||||||
|
name: plan.name,
|
||||||
|
code: plan.code,
|
||||||
|
market: plan.market,
|
||||||
|
pe_ttm: Math.round(dj.pe * 100) / 100,
|
||||||
|
pe_date: dj.date ? `${new Date().getFullYear()}-${dj.date}` : '',
|
||||||
|
percentile: Number.isFinite(dj.pe_percentile) ? Math.round(dj.pe_percentile * 1000) / 10 : null,
|
||||||
|
percentile_note: beginYear ? `统计窗口自 ${beginYear} 年起(约10年)` : '蛋卷口径',
|
||||||
|
source: '蛋卷基金',
|
||||||
|
advice: band ? band.advice : '—',
|
||||||
|
level: band ? band.level : 'unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errors.push(`${plan.name}:蛋卷主源获取失败,尝试备源`)
|
||||||
|
// 备源:仅标普500 有 multpl(月度更新的当前PE,无历史分位 → 分位显示 —)
|
||||||
|
if (plan.key === 'sp500') {
|
||||||
|
try {
|
||||||
|
const pe = await fetchMultplSp500Pe()
|
||||||
|
const band = matchBand(plan.bands, pe)
|
||||||
|
return {
|
||||||
|
name: plan.name,
|
||||||
|
code: plan.code,
|
||||||
|
market: plan.market,
|
||||||
|
pe_ttm: Math.round(pe * 100) / 100,
|
||||||
|
pe_date: beijingNow().slice(0, 10),
|
||||||
|
percentile: null, // 备源无历史序列,分位无法计算,显示 —
|
||||||
|
percentile_note: '备源无历史数据',
|
||||||
|
source: 'multpl.com(备源)',
|
||||||
|
advice: band ? band.advice : '—',
|
||||||
|
level: band ? band.level : 'unknown'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`${plan.name}:multpl 备源也失败(${String(e.message || e)})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 控制台文本表格(对齐打印,中文按 2 列宽处理) ----------
|
||||||
|
|
||||||
|
// 计算字符串显示宽度(CJK 字符按 2 列)
|
||||||
|
const displayWidth = str => {
|
||||||
|
let w = 0
|
||||||
|
for (const ch of String(str == null ? '' : str)) {
|
||||||
|
w += /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/.test(ch) ? 2 : 1
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按显示宽度填充对齐
|
||||||
|
const padCell = (str, width) => {
|
||||||
|
const s = String(str == null ? '' : str)
|
||||||
|
const gap = width - displayWidth(s)
|
||||||
|
return s + (gap > 0 ? ' '.repeat(gap) : '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 控制台打印清晰文本表格(用户要求:运行后控制台打印)
|
||||||
|
const printConsoleTable = (rows, errors) => {
|
||||||
|
try {
|
||||||
|
const header = ['指数名称', '代码', 'PE-TTM', '近10年PE分位', '数据日期', '定投建议']
|
||||||
|
const lines = rows.map(r => [
|
||||||
|
r.name,
|
||||||
|
r.code,
|
||||||
|
Number.isFinite(r.pe_ttm) ? r.pe_ttm.toFixed(2) : '—',
|
||||||
|
Number.isFinite(r.percentile) ? `${r.percentile.toFixed(1)}%` : '—',
|
||||||
|
r.pe_date || '—',
|
||||||
|
r.advice || '—'
|
||||||
|
])
|
||||||
|
const widths = header.map((h, i) => Math.max(displayWidth(h), ...lines.map(l => displayWidth(l[i]))))
|
||||||
|
const sep = '─'.repeat(widths.reduce((a, b) => a + b, 0) + widths.length * 3 + 1)
|
||||||
|
console.log('')
|
||||||
|
console.log(`┌${sep}┐`)
|
||||||
|
console.log(`│ 【建行定投评估】 ${beijingNow()} │`)
|
||||||
|
console.log(`├${sep}┤`)
|
||||||
|
console.log(`│ ${header.map((h, i) => padCell(h, widths[i])).join(' │ ')} │`)
|
||||||
|
console.log(`├${sep}┤`)
|
||||||
|
for (const l of lines) {
|
||||||
|
console.log(`│ ${l.map((c, i) => padCell(c, widths[i])).join(' │ ')} │`)
|
||||||
|
}
|
||||||
|
console.log(`└${sep}┘`)
|
||||||
|
if (errors && errors.length) {
|
||||||
|
console.log('【异常提示】')
|
||||||
|
for (const e of errors) console.log(` ⚠ ${e}`)
|
||||||
|
}
|
||||||
|
console.log('')
|
||||||
|
} catch (e) {
|
||||||
|
// 打印失败不影响主流程
|
||||||
|
try { logJSON('markets.dip_eval.print.error', { error: String(e.message || e) }, 'markets') } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 主入口 ----------
|
||||||
|
|
||||||
|
const getDipEvalReport = async () => {
|
||||||
|
const startedAt = Date.now()
|
||||||
|
const errors = []
|
||||||
|
|
||||||
|
// 1. 先抓蛋卷(一次覆盖四指数),失败不中断,A股还有中证官网主源
|
||||||
|
let danjuanMap = {}
|
||||||
|
try {
|
||||||
|
danjuanMap = await fetchDanjuanEva()
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`蛋卷基金接口整体失败:${String(e.message || e)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 并行评估四个指数,单个失败不影响其他
|
||||||
|
const evalTasks = INDEX_PLAN.map(plan => {
|
||||||
|
const worker = plan.csindex ? evalAShareIndex : evalUsIndex
|
||||||
|
return worker(plan, danjuanMap, errors).catch(e => {
|
||||||
|
errors.push(`${plan.name}:评估过程异常(${String(e.message || e)})`)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const results = await Promise.all(evalTasks)
|
||||||
|
|
||||||
|
const rows = []
|
||||||
|
INDEX_PLAN.forEach((plan, i) => {
|
||||||
|
if (results[i]) rows.push(results[i])
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. 控制台打印文本表格(用户要求)
|
||||||
|
printConsoleTable(rows, errors)
|
||||||
|
|
||||||
|
try {
|
||||||
|
logJSON('markets.dip_eval.report', {
|
||||||
|
ok: rows.length > 0,
|
||||||
|
rows: rows.length,
|
||||||
|
errors,
|
||||||
|
elapsed_ms: Date.now() - startedAt
|
||||||
|
}, 'markets')
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 全部失败才视为整体失败(前端弹窗强提示);部分失败在 errors 中逐条说明
|
||||||
|
if (!rows.length) {
|
||||||
|
return { ok: false, error: `所有数据源均获取失败:${errors.join(';')}`, rows: [], errors, captured_at: beijingNow() }
|
||||||
|
}
|
||||||
|
return { ok: true, rows, errors, captured_at: beijingNow(), elapsed_ms: Date.now() - startedAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getDipEvalReport }
|
||||||
@@ -236,6 +236,128 @@ const bindRoutes = (app) => {
|
|||||||
res.json({ ok: true, weeks: core.weeksForYear(year) })
|
res.json({ ok: true, weeks: core.weeksForYear(year) })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 【首页提醒专用】GET /api/weekly/reminder/status
|
||||||
|
// 用途:首页导航「每周周报提醒」卡片用此接口判定缺报情况。
|
||||||
|
// 特点:
|
||||||
|
// 1. 不校验 weekly_auth(周报业务登录态)—— 首页提醒不依赖登录;
|
||||||
|
// 仍受 index.js 中 /api/weekly 外层 weekly_gate 门禁保护,
|
||||||
|
// 无门禁 cookie 一律 401,toolbox 之外不可用。
|
||||||
|
// 2. 服务端直接读库判定「最近 3 周(含本周)」,只返回缺失数量与周号,
|
||||||
|
// 不返回周报标题 / 摘要 / 正文,不泄露业务内容。
|
||||||
|
// 3. 此接口仅供首页提醒使用,勿挪作他用。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// 首页提醒辅助:周报正文为 HTML,服务端判定时需转纯文本(去标签与常见实体,口径与旧前端一致)
|
||||||
|
const weeklyHtmlToPlainText = (html) => {
|
||||||
|
try {
|
||||||
|
return String(html || '')
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /gi, ' ')
|
||||||
|
.replace(/&/gi, '&')
|
||||||
|
.replace(/</gi, '<')
|
||||||
|
.replace(/>/gi, '>')
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/'/gi, "'")
|
||||||
|
.replace(/\u00a0/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
} catch { return '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首页提醒辅助:周报模板默认文(纯文本归一化后),与周报编辑器模板保持一致
|
||||||
|
const weeklyTemplatePlainText = () => [
|
||||||
|
'1、本周核心重点事项(置顶)',
|
||||||
|
'①置顶优先级,避免杂事分散精力',
|
||||||
|
'②本周 1-3 件最高优先级事(工作 / 家庭各列,如公司项目节点、孩子升学事宜)',
|
||||||
|
'③必做底线项(如固定会议、家庭日常、记账 / 复盘打卡)',
|
||||||
|
'2、上周复盘(工作)',
|
||||||
|
'-----',
|
||||||
|
'①公司工作完成项(标注是否达标、核心成果)',
|
||||||
|
'②未完成项 + 原因(客观 / 主观,如时间不足、需求变更)',
|
||||||
|
'③规则事务完成情况(重点记未完成的关键项)',
|
||||||
|
'3、上周复盘(个人 + 家庭)',
|
||||||
|
'-----',
|
||||||
|
'①个人 / 家庭事务完成情况(重点记未完成的关键项)',
|
||||||
|
'4、公司工作(计划)',
|
||||||
|
'-----',
|
||||||
|
'①核心任务(按重要 / 紧急排序,标注截止时间、协作人)',
|
||||||
|
'②常规工作(日常运维、对接、汇报等固定事项)',
|
||||||
|
'③待协调 / 需支持项(如需要同事配合、领导审批的内容)',
|
||||||
|
'5、个人和家庭 事务(计划,含理财)',
|
||||||
|
'-----',
|
||||||
|
'①自我提升(学习、运动、习惯打卡,如读 1 章书、运动 3 次)',
|
||||||
|
'②个人琐事(办事、缴费、社交等,标注时间节点)',
|
||||||
|
'③投资理财(盯盘节点、条件单调整、复盘上周收益 / 操作)',
|
||||||
|
'④家人相关(孩子学习 / 活动、老人照料、家庭聚餐等)',
|
||||||
|
'⑤家庭琐事(家务、采购、物业对接等,可标注分工)',
|
||||||
|
'⑥家庭规划(短期家庭事项,如周末出游、家电维修)',
|
||||||
|
'6、待解决问题 / 待协调项',
|
||||||
|
'-----',
|
||||||
|
'①工作中可能遇到的问题(如项目延期风险、资源不足)',
|
||||||
|
'②个人 / 家庭的待处理问题(如时间冲突、资金安排)',
|
||||||
|
'③初步应对思路(简单标注,避免临时慌乱)',
|
||||||
|
'7、本周小目标 / 打卡项(轻量化激励,易落地)',
|
||||||
|
'①工作小目标(如高效完成 1 个核心任务、减少无效沟通)',
|
||||||
|
'②个人 / 家庭小目标(如坚持记账、周末全家运动 1 次)'
|
||||||
|
].join(' ')
|
||||||
|
|
||||||
|
// 首页提醒辅助:判定标题是否为「自动占位」
|
||||||
|
const isWeeklyReminderAutoTitle = (title, weekId = '') => {
|
||||||
|
const t = String(title || '').trim()
|
||||||
|
if (!t) return true
|
||||||
|
if (t.includes('(自动新增)')) return true
|
||||||
|
if (weekId && t === `${weekId} 周报`) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首页提醒辅助:判定该周周报是否算「已认真填写」
|
||||||
|
const isWeeklyReminderMeaningful = (report, expectedWeekId = '') => {
|
||||||
|
if (!report || typeof report !== 'object') return false
|
||||||
|
const weekId = String(report.week_id || expectedWeekId || '').trim()
|
||||||
|
const title = String(report.title || '').trim()
|
||||||
|
const contentText = weeklyHtmlToPlainText(report.content || '')
|
||||||
|
if (!title || !contentText) return false
|
||||||
|
if (isWeeklyReminderAutoTitle(title, weekId)) return false
|
||||||
|
if (contentText === '这是自动新增的周报。你可以修改。') return false
|
||||||
|
if (contentText === weeklyTemplatePlainText()) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首页提醒辅助:计算「最近 count 周(含本周)」,今天按北京时间(Asia/Shanghai)
|
||||||
|
const getRecentWeeklyWindowsServer = (count = 3) => {
|
||||||
|
const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date())
|
||||||
|
const currentYear = Number(String(today).slice(0, 4))
|
||||||
|
const allWeeks = [currentYear - 1, currentYear]
|
||||||
|
.map(y => core.weeksForYear(y))
|
||||||
|
.flat()
|
||||||
|
.filter(item => item && item.week_id && item.start && item.end)
|
||||||
|
.sort((a, b) => String(a.start).localeCompare(String(b.start)))
|
||||||
|
if (allWeeks.length === 0) return []
|
||||||
|
const currentIndex = allWeeks.findIndex(item => String(item.start) <= today && today <= String(item.end))
|
||||||
|
if (currentIndex < 0) return allWeeks.slice(-count)
|
||||||
|
return allWeeks.slice(Math.max(0, currentIndex - count + 1), currentIndex + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/weekly/reminder/status', (req, res) => {
|
||||||
|
try {
|
||||||
|
const weeks = getRecentWeeklyWindowsServer(3)
|
||||||
|
const missingWeeks = []
|
||||||
|
for (const week of weeks) {
|
||||||
|
const report = core.getReportByWeek(week.week_id)
|
||||||
|
if (!isWeeklyReminderMeaningful(report, week.week_id)) {
|
||||||
|
missingWeeks.push({ week_id: week.week_id, start: week.start, end: week.end })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json({ ok: true, weeks: weeks.length, missingCount: missingWeeks.length, missingWeeks })
|
||||||
|
} catch (e) {
|
||||||
|
try { logJSON('weekly.reminder.status.error', { error: String(e && e.message ? e.message : e) }, 'weekly') } catch {}
|
||||||
|
res.status(500).json({ ok: false, error: 'reminder_status_failed' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Core Routes
|
// Core Routes
|
||||||
app.get('/api/weekly/report', (req, res) => {
|
app.get('/api/weekly/report', (req, res) => {
|
||||||
if (!mustAuth(req, res)) return res.status(401).json({ ok: false })
|
if (!mustAuth(req, res)) return res.status(401).json({ ok: false })
|
||||||
|
|||||||
Reference in New Issue
Block a user