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)
|
||||
Reference in New Issue
Block a user