diff --git a/dev_test_scripts/debug/debug_agent_cdp.js b/dev_test_scripts/debug/debug_agent_cdp.js new file mode 100644 index 0000000..5806ef9 --- /dev/null +++ b/dev_test_scripts/debug/debug_agent_cdp.js @@ -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) }) diff --git a/dev_test_scripts/debug/debug_dip_eval_sources.js b/dev_test_scripts/debug/debug_dip_eval_sources.js new file mode 100644 index 0000000..075eaeb --- /dev/null +++ b/dev_test_scripts/debug/debug_dip_eval_sources.js @@ -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(/[\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探针结束') +})() diff --git a/dev_test_scripts/debug/debug_dip_eval_sources2.js b/dev_test_scripts/debug/debug_dip_eval_sources2.js new file mode 100644 index 0000000..affe41c --- /dev/null +++ b/dev_test_scripts/debug/debug_dip_eval_sources2.js @@ -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(/([\s\S]*?)<\/tbody>/) + const rows = tbody ? (tbody[1].match(/[\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探针结束') +})() diff --git a/dev_test_scripts/debug/debug_dip_eval_sources3.js b/dev_test_scripts/debug/debug_dip_eval_sources3.js new file mode 100644 index 0000000..3a82fa4 --- /dev/null +++ b/dev_test_scripts/debug/debug_dip_eval_sources3.js @@ -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) + } +})() diff --git a/dev_test_scripts/debug/debug_dip_eval_sources4.js b/dev_test_scripts/debug/debug_dip_eval_sources4.js new file mode 100644 index 0000000..d5b1dcf --- /dev/null +++ b/dev_test_scripts/debug/debug_dip_eval_sources4.js @@ -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}`) + } + } +})() diff --git a/dev_test_scripts/debug/debug_dip_eval_sources5.js b/dev_test_scripts/debug/debug_dip_eval_sources5.js new file mode 100644 index 0000000..93dba30 --- /dev/null +++ b/dev_test_scripts/debug/debug_dip_eval_sources5.js @@ -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') +})() diff --git a/dev_test_scripts/debug/debug_price_extract.js b/dev_test_scripts/debug/debug_price_extract.js new file mode 100644 index 0000000..eef7a3d --- /dev/null +++ b/dev_test_scripts/debug/debug_price_extract.js @@ -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 = [ + ['
130-2620元
', 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) diff --git a/dev_test_scripts/debug/debug_weekly_reminder.js b/dev_test_scripts/debug/debug_weekly_reminder.js new file mode 100644 index 0000000..647e720 --- /dev/null +++ b/dev_test_scripts/debug/debug_weekly_reminder.js @@ -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: '标题仍为自动标题(含「自动新增」或等于「 周报」)' } + 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) } diff --git a/dev_test_scripts/unit/test_markets_dip_eval.js b/dev_test_scripts/unit/test_markets_dip_eval.js new file mode 100644 index 0000000..f6401ef --- /dev/null +++ b/dev_test_scripts/unit/test_markets_dip_eval.js @@ -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) + } +})() diff --git a/dev_test_scripts/unit/test_markets_notify_allowlist.js b/dev_test_scripts/unit/test_markets_notify_allowlist.js new file mode 100644 index 0000000..b7c8c75 --- /dev/null +++ b/dev_test_scripts/unit/test_markets_notify_allowlist.js @@ -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) diff --git a/public/icons/free_show.svg b/public/icons/free_show.svg new file mode 100644 index 0000000..3a2e3e5 --- /dev/null +++ b/public/icons/free_show.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/index.js b/public/index.js index 160c3c0..c693b65 100644 --- a/public/index.js +++ b/public/index.js @@ -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) { if (!this.fitnessDiaryReminder || !this.fitnessDiaryReminderText || !this.fitnessDiaryReminderDates) return; const missingCount = Number(summary && summary.missingCount ? summary.missingCount : 0); @@ -1148,30 +1059,17 @@ class ToolboxHome { async loadWeeklyReportReminder() { try { - const weeks = await this.getRecentWeeklyWindows(3); - if (!Array.isArray(weeks) || weeks.length === 0) return; - const reportResults = await Promise.all( - weeks.map(async (week) => { - const res = await fetch(`/api/weekly/report?week_id=${encodeURIComponent(week.week_id)}&t=${Date.now()}`, { - cache: 'no-store' - }); - 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); + // 【首页提醒专用】直接调后端聚合接口 /api/weekly/reminder/status。 + // 判定由服务端读库完成,不依赖周报 weekly_auth 登录态(仅需 weekly_gate 门禁); + // 相对路径同源调用:本机访问即 localhost:8976,外网访问自动走 8975 反代,接口本身未授权一律 401。 + const res = await fetch(`/api/weekly/reminder/status?t=${Date.now()}`, { cache: 'no-store' }); + if (!res.ok) return; // 无门禁 / 接口异常:静默隐藏提醒 + const data = await res.json(); + if (!data || data.ok !== true) return; this.renderWeeklyReportReminder({ - weeks: 3, - missingCount: missingWeeks.length, - missingWeeks + weeks: Number(data.weeks || 3), + missingCount: Number(data.missingCount || 0), + missingWeeks: Array.isArray(data.missingWeeks) ? data.missingWeeks : [] }); } catch (err) { console.warn('加载周报提醒失败:', err); diff --git a/public/tools/free_show/auth_config.json b/public/tools/free_show/auth_config.json new file mode 100644 index 0000000..60238fe --- /dev/null +++ b/public/tools/free_show/auth_config.json @@ -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 名称,默认格式 _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": "" +} diff --git a/public/tools/free_show/free_show.css b/public/tools/free_show/free_show.css new file mode 100644 index 0000000..cf1cd7b --- /dev/null +++ b/public/tools/free_show/free_show.css @@ -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; } +} diff --git a/public/tools/free_show/free_show.js b/public/tools/free_show/free_show.js new file mode 100644 index 0000000..dd42a5f --- /dev/null +++ b/public/tools/free_show/free_show.js @@ -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 = + '
' + + '
' + + '

交互详情

' + + '
' +
+      '    
' + + ' ' + + ' ' + + '
' + + '
' + + '
' + $('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 = '
' + + (isCacheMode ? '读取缓存,秒开' : (currentTab === 'damai' ? '探测本地抓取代理(家庭 IP)...' : '正在获取数据...')) + + '
' + + 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 + '] 数据时间 ' + data.cacheTime + ' · 库存 ' + data.totalCount + ' 条 · 符合 ≤¥' + maxPrice + ': ' + data.filteredCount + ' 条' + + let html = '' + if (data.events.length > 0) { + html += '' + html += '
' + html += data.events.map(e => renderCard(e)).join('') + html += '
' + } else { + html += '
◇

扫描 ' + data.totalCount + ' 条演出,暂无 ≤¥' + maxPrice + ' 的结果
试试调高票价或增加页数

' + } + + 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 += '' + html += '
' + html += others.map(e => renderCard(e)).join('') + html += '
' + } + } + + 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 = '
✕

' + esc(err.message) + '

' + 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 '' + + '' + + '
' + + '
' + esc(e.title) + '
' + + '
' + + (e.showTime ? '' + esc(e.showTime) + '' : '') + + (e.venue ? '' + esc(e.venue) + '' : '') + + (e.city ? '' + esc(e.city) + '' : '') + + '
' + + '
' + + '
' + priceLabel + '
' + + '' + tabName(e.platform) + '' + + '
' + } + + function esc(s) { return (s || '').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) +})() diff --git a/public/tools/free_show/free_show_local_agent.js b/public/tools/free_show/free_show_local_agent.js new file mode 100644 index 0000000..28b156b --- /dev/null +++ b/public/tools/free_show/free_show_local_agent.js @@ -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 = /]*)>/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(/]*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) + '(首次抓取时会重试)')) +}) diff --git a/public/tools/free_show/index.html b/public/tools/free_show/index.html new file mode 100644 index 0000000..06826bf --- /dev/null +++ b/public/tools/free_show/index.html @@ -0,0 +1,67 @@ + + + + + + 免费演出雷达 + + + + + +
+
+

FREE SHOW

+ 免费演出雷达 +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ +
选择平台,点击抓取
+
+ + +
+ +
+ + + diff --git a/public/tools/markets/index.html b/public/tools/markets/index.html index 8e1e84c..1116af5 100644 --- a/public/tools/markets/index.html +++ b/public/tools/markets/index.html @@ -27,6 +27,7 @@ + @@ -189,6 +190,79 @@ + + + @@ -343,6 +417,7 @@ + ', 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 } diff --git a/src/server/index.js b/src/server/index.js index cb4f06e..05d69cb 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -51,10 +51,12 @@ const marketSkills = require('./market_skills/manager') const securityCalendar = require('./security_calendar') const securityCalendarBridge = require('./security_calendar_calendar_bridge') const marketsWeeklyReport = require('./markets_weekly_report') +const marketsDipEval = require('./markets_dip_eval') const { upsertMarketArchive, getMarketArchive } = require('./db') const { getConfig: getWallCfg, setConfig: setWallCfg, listCards: listWallCards, createCard: createWallCard, updateCardById: updateWallCardById, deleteCardById: deleteWallCardById } = require('./wall') const fundsGuoxin = require('./funds_guoxin') const fundsHuatai = require('./funds_huatai') +const freeShow = require('./free_show') const ccbPrivateFunds = require('./ccb_private_funds') const cloud = require('./cloud') 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/markets/security_calendar') || req.path.startsWith('/api/markets/weekly_report') || + req.path.startsWith('/api/markets/dip_eval') || req.path.startsWith('/api/global_news') || req.path.startsWith('/api/box_backup') ) setNoCache(res) @@ -1212,7 +1215,7 @@ if (!fs.existsSync(settingsPath)) fs.writeFileSync(settingsPath, JSON.stringify( const flagsPath = path.join(process.cwd(), 'config', 'flags.json') if (!fs.existsSync(flagsPath)) fs.writeFileSync(flagsPath, JSON.stringify({ 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 } }, null, 2)) @@ -1611,6 +1614,7 @@ const mapSystemIdToUrl = (systemId) => { 'Tools-wall-': '/tools/wall', 'Tools-funds_guoxin': '/tools/funds_guoxin', 'Tools-funds_huatai': '/tools/funds_huatai', + 'Tools-free_show': '/tools/free_show', 'Tools-ccb_private_funds': '/tools/ccb_private_funds', 'Tools-cloud': '/tools/cloud', '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' }) }) +// ============ 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 readCcbPrivateFundsJwk = () => { try { @@ -4104,6 +4238,7 @@ app.get('/api/debug/jwt/verify', (req, res) => { 'cloud': () => readCloudJwk(), 'funds_guoxin': () => readFundsGuoxinJwk(), 'funds_huatai': () => readFundsHuataiJwk(), + 'free_show': () => readFreeShowJwk(), 'markets': () => readMarketsJwk(), 'wall': () => readWallJwk(), 'weekly': () => readWeeklyJwk(), @@ -4257,7 +4392,7 @@ app.post('/api/token_lab/nav/verify', (req, res) => { app.get('/api/flags', (req, res) => { 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) => { @@ -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) => { const body = req.body || {} const cfg = getMarketsCfg() @@ -6838,6 +6984,7 @@ app.get('/api/short_link/auth/hint', (req, res) => { // Short Link 业务路由(/s/:code 公开跳转 + /api/short_link/* 管理接口) shortLink.bindRoutes(app) +freeShow.bindRoutes(app) const marketsCfgAtBoot = getMarketsCfg() const currentPort = Number(process.env.PORT || '8976') || 8976 @@ -6845,6 +6992,7 @@ const marketsAutomationPort = Number(marketsCfgAtBoot.automation_port || 8977) | if (currentPort === marketsAutomationPort) { startMarketsScheduler() marketSkills.start() + freeShow.startScheduler() } securityCalendar.startScheduler() securityCalendarBridge.startScheduler(securityCalendar) diff --git a/src/server/markets.js b/src/server/markets.js index f8b968d..6d4c6a6 100644 --- a/src/server/markets.js +++ b/src/server/markets.js @@ -51,6 +51,8 @@ if (!fs.existsSync(cfgPath)) { }, watchlist: [], 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_max_price: 5500, email: { enabled: false, from: "", to: [], smtp: { host: "", port: 465, secure: true, auth: { user: "", pass: "" } } }, @@ -512,13 +514,34 @@ const isComexGold = q => { 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 cfg = getConfig() 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 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(q => typeof q.price === 'number' && (q.price <= goldMin || q.price >= goldMax)) 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 } diff --git a/src/server/markets_dip_eval.js b/src/server/markets_dip_eval.js new file mode 100644 index 0000000..85c6219 --- /dev/null +++ b/src/server/markets_dip_eval.js @@ -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 +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 } diff --git a/src/server/weekly.js b/src/server/weekly.js index 55cf5b6..21d1da6 100644 --- a/src/server/weekly.js +++ b/src/server/weekly.js @@ -236,6 +236,128 @@ const bindRoutes = (app) => { 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(//gi, ' ') + .replace(//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 app.get('/api/weekly/report', (req, res) => { if (!mustAuth(req, res)) return res.status(401).json({ ok: false })