feat: 新增免费演出雷达工具与定投评估功能,优化周报提醒逻辑

1. 新增免费演出雷达工具:含秀动/猫眼/大麦平台抓取、本地代理鉴权配置
2. 新增建行定投评估模块:四大指数PE-TTM估值与定投档位建议
3. 新增推送告警白名单过滤功能,限制推送指定指数告警
4. 重构周报首页提醒逻辑,移至服务端处理并减少前端请求
5. 新增多套调试探针脚本与单元测试用例
This commit is contained in:
yangxiangyuan
2026-08-04 17:33:14 +08:00
parent af5d0f43a5
commit 6a7a2b089a
25 changed files with 3499 additions and 121 deletions
+557
View File
@@ -0,0 +1,557 @@
// 免费演出雷达(free_show)后端模块
// 独立 SQLite 缓存(data/free_show.db),不依赖任何其他 tool
// 8976:提供查询/抓取 API;8977:每日 0 点自动追加抓取
const path = require('path')
const fs = require('fs')
const https = require('https')
const Database = require('better-sqlite3')
const { logJSON } = require('./logger')
// ============ 配置 ============
const CONFIG_PATH = path.join(process.cwd(), 'config', 'free_show.json')
const readConfig = () => {
try {
if (fs.existsSync(CONFIG_PATH)) return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'))
} catch (e) {
try { logJSON('free_show.config.error', { error: String(e.message || e) }, 'free_show') } catch {}
}
return {}
}
// ============ 数据库 ============
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const db = new Database(path.join(dataDir, 'free_show.db'))
db.exec(
`CREATE TABLE IF NOT EXISTS free_show_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
event_key TEXT NOT NULL,
title TEXT,
price REAL,
price_text TEXT,
show_time TEXT,
venue TEXT,
city TEXT,
poster TEXT,
url TEXT,
fetched_at TEXT,
UNIQUE(platform, event_key)
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS free_show_meta (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);`
)
const upsertEvent = db.prepare(
`INSERT INTO free_show_events (platform, event_key, title, price, price_text, show_time, venue, city, poster, url, fetched_at)
VALUES (@platform, @event_key, @title, @price, @price_text, @show_time, @venue, @city, @poster, @url, @fetched_at)
ON CONFLICT(platform, event_key) DO UPDATE SET
title = excluded.title,
price = excluded.price,
price_text = excluded.price_text,
show_time = excluded.show_time,
venue = excluded.venue,
city = excluded.city,
poster = excluded.poster,
url = excluded.url,
fetched_at = excluded.fetched_at`
)
const deleteByPlatformCity = db.prepare(`DELETE FROM free_show_events WHERE platform = ? AND city = ?`)
const listByPlatformCity = db.prepare(`SELECT * FROM free_show_events WHERE platform = ? AND city = ?`)
const countByPlatformCity = db.prepare(`SELECT COUNT(*) AS c FROM free_show_events WHERE platform = ? AND city = ?`)
const getMeta = db.prepare(`SELECT value FROM free_show_meta WHERE key = ?`)
const setMeta = db.prepare(
`INSERT INTO free_show_meta (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`
)
const nowBeijing = () =>
new Date(Date.now() + 8 * 3600 * 1000).toISOString().replace('T', ' ').slice(0, 19)
const saveEvents = (platform, city, events, mode) => {
const tx = db.transaction(() => {
if (mode === 'overwrite') deleteByPlatformCity.run(platform, city)
const fetchedAt = nowBeijing()
for (const e of events) {
upsertEvent.run({
platform,
event_key: String(e.id || e.title || ''),
title: String(e.title || ''),
price: Number(e.price) || 0,
price_text: String(e.priceText || ''),
show_time: String(e.showTime || ''),
venue: String(e.venue || ''),
city: String(e.city || city),
poster: String(e.poster || ''),
url: String(e.url || ''),
fetched_at: fetchedAt
})
}
})
tx()
setMeta.run(`${platform}_${city}_lastFetchTime`, nowBeijing())
return countByPlatformCity.get(platform, city).c
}
const loadEvents = (platform, city) => {
return listByPlatformCity.all(platform, city).map(r => ({
platform: r.platform,
id: r.event_key,
title: r.title,
price: r.price,
priceText: r.price_text,
showTime: r.show_time,
venue: r.venue,
city: r.city,
poster: r.poster,
url: r.url
}))
}
// ============ 通用工具 ============
function httpGet(url, headers = {}) {
return new Promise((resolve, reject) => {
const mod = url.startsWith('https') ? https : require('http')
mod.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/json,*/*',
'Accept-Language': 'zh-CN,zh;q=0.9',
...headers
}
}, res => {
let d = ''
res.on('data', c => d += c)
res.on('end', () => resolve({ status: res.statusCode, body: d }))
}).on('error', reject)
})
}
function parsePrice(str) {
if (!str) return 9999
const m = String(str).replace(/[¥¥起,,]/g, '').match(/[\d.]+/)
return m ? parseFloat(m[0]) : 9999
}
// ============ 秀动 ============
// 仅保留上海(业务约定:手工拉/自动拉都只要上海)
const SHOWSTART_CITIES = {
'上海': 21
}
async function fetchShowstart(city, pages) {
const cityCode = SHOWSTART_CITIES[city]
if (!cityCode) throw new Error(`秀动不支持城市: ${city}`)
let allEvents = []
const maxPages = Math.min(Number(pages) || 3, 10)
for (let page = 1; page <= maxPages; page++) {
try {
const url = `https://www.showstart.com/event/list?cityCode=${cityCode}&pageNo=${page}`
const { body } = await httpGet(url)
const s = body.indexOf('window.__NUXT__=')
if (s === -1) break
const e = body.indexOf('</script>', s)
const data = eval(body.substring(s + 16, e))
const list = data?.data?.[0]?.listData || []
if (list.length === 0) break
allEvents = allEvents.concat(list.map(item => ({
platform: 'showstart',
id: item.id,
title: item.title,
price: parsePrice(item.price),
priceText: item.price,
showTime: item.showTime,
venue: item.siteName,
city: item.cityName,
poster: item.poster,
url: `https://www.showstart.com/event/${item.id}`
})))
if (list.length < 20) break
} catch (e) { break }
}
return allEvents
}
// ============ 猫眼 ============
// 仅保留上海(业务约定:手工拉/自动拉都只要上海)
const MAOYAN_CITIES = {
'上海': 1
}
async function fetchMaoyan(city, pages) {
const cityId = MAOYAN_CITIES[city]
if (!cityId) throw new Error(`猫眼不支持城市: ${city}`)
let allEvents = []
const maxPages = Math.min(Number(pages) || 3, 10)
for (let page = 1; page <= maxPages; page++) {
try {
const url = `https://wx.maoyan.com/maoyansh/myshow/ajax/performances/0;st=0;p=${page};s=20;tft=0?cityId=${cityId}`
const { body } = await httpGet(url, { 'Accept': 'application/json' })
const json = JSON.parse(body)
if (!json.success || !json.data || json.data.length === 0) break
allEvents = allEvents.concat(json.data.map(item => ({
platform: 'maoyan',
id: item.performanceId,
title: item.name,
price: item.lowestPrice || parsePrice(item.priceRange),
priceText: item.sellPriceDisplay?.priceDisplayText || `¥${item.lowestPrice}起`,
showTime: item.showTimeRange || '',
venue: item.shopName || '',
city: item.cityName || city,
poster: item.posterUrl || '',
url: item.jumpDetailUrl || `https://show.maoyan.com/detail/${item.performanceId}`
})))
if (!json.paging?.hasMore) break
} catch (e) { break }
}
return allEvents
}
// ============ 大麦 ============
// 仅保留上海(业务约定:手工拉/自动拉都只要上海)
const DAMAI_CITIES = {
'上海': '310100'
}
function findBrowser() {
const candidates = [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe'
]
for (const p of candidates) {
if (fs.existsSync(p)) return p
}
return null
}
let browserInstance = null
let browserLock = false
async function getBrowser() {
if (browserInstance) {
try {
await browserInstance.version()
return browserInstance
} catch (e) {
browserInstance = null
}
}
const puppeteer = require('puppeteer-core')
const execPath = findBrowser()
if (!execPath) throw new Error('未找到 Chrome/Edge 浏览器')
browserInstance = await puppeteer.launch({
executablePath: execPath,
headless: 'shell',
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
})
return browserInstance
}
async function fetchDamai(city, pages) {
const cityCode = DAMAI_CITIES[city]
if (!cityCode) throw new Error(`大麦不支持城市: ${city}`)
while (browserLock) await new Promise(r => setTimeout(r, 500))
browserLock = true
try {
const browser = await getBrowser()
const page = await browser.newPage()
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')
let antiBot = false
page.on('response', async (resp) => {
if (resp.url().includes('searchajax')) {
try {
const t = await resp.text()
if (t.includes('rgv587_flag') || t.includes('punish')) antiBot = true
} catch (e) {}
}
})
let allEvents = []
const maxPages = Math.min(Number(pages) || 3, 3) // 大麦用浏览器抓,限制页数
for (let p = 1; p <= maxPages; p++) {
const searchUrl = `https://search.damai.cn/search.htm?keyword=&city=${cityCode}&ctl=0&order=1&page=${p}`
try {
await page.goto(searchUrl, { waitUntil: 'domcontentloaded', timeout: 15000 })
let rendered = false
for (let i = 0; i < 10; i++) {
await new Promise(r => setTimeout(r, 1000))
const cnt = await page.evaluate(() =>
document.querySelectorAll('.search__itemlist a[href*="damai.cn"], .search__itemlist [class*="item"]').length
)
if (cnt > 0) { rendered = true; break }
if (antiBot) break
}
if (antiBot) break
if (!rendered) break
const items = await page.evaluate(() => {
const results = []
const cards = document.querySelectorAll('.search__itemlist > div > div, .search__itemlist [class*="item__"]')
const seen = new Set()
cards.forEach(card => {
const link = card.querySelector('a[href*="damai.cn"]')
const titleEl = card.querySelector('[class*="title"], a[title], h3, h4')
const text = card.textContent || ''
const title = (titleEl?.textContent || titleEl?.getAttribute('title') || '').trim()
if (!title || seen.has(title)) return
seen.add(title)
// 大麦列表价格常无 ¥ 符号(如 "130-2620元" / "380.0元"),多格式兜底提取
const priceMatch = text.match(/[¥¥]\s*[\d.]+(?:\s*[-~至]\s*[\d.]+)?/) || text.match(/[\d.]+(?:\s*[-~至]\s*[\d.]+)?\s*元/)
results.push({
title,
priceText: priceMatch ? priceMatch[0].replace(/\s+/g, '') : '',
time: (text.match(/\d{4}[.\-/]\d{1,2}[.\-/]\d{1,2}[^|]*/) || [''])[0].trim(),
venue: '',
url: link?.href || '',
poster: card.querySelector('img')?.src || ''
})
})
return results
})
if (items.length === 0) break
allEvents = allEvents.concat(items.map(item => ({
platform: 'damai',
id: (item.url.match(/id=(\d+)/) || [, ''])[1],
title: item.title,
price: parsePrice(item.priceText),
priceText: item.priceText || '价格待定',
showTime: item.time,
venue: item.venue,
city,
poster: item.poster,
url: item.url || 'https://www.damai.cn'
})))
} catch (e) {
try { logJSON('free_show.damai.page_error', { page: p, error: String(e.message || e) }, 'free_show') } catch {}
break
}
}
await page.close()
if (antiBot && allEvents.length === 0) {
throw new Error('大麦触发IP级风控(rgv587)。当前网络IP被阿里拉黑,请换住宅宽带网络后重试')
}
return allEvents
} finally {
browserLock = false
}
}
const FETCHERS = { showstart: fetchShowstart, maoyan: fetchMaoyan, damai: fetchDamai }
// ============ 业务逻辑 ============
function buildResponse(platform, city, maxPrice, allEvents, cacheTime, fromCache, newCount) {
const filtered = allEvents.filter(e => e.price <= maxPrice).sort((a, b) => a.price - b.price)
const cheapest = [...allEvents].sort((a, b) => a.price - b.price).slice(0, 20)
return {
success: true,
platform, city, maxPrice,
fromCache,
cacheTime,
newCount: newCount ?? 0,
totalCount: allEvents.length,
filteredCount: filtered.length,
events: filtered,
cheapest
}
}
async function runFetch(platform, city, maxPrice, mode, pages) {
const fetcher = FETCHERS[platform]
if (!fetcher) return { success: false, error: `不支持的平台: ${platform}` }
if (mode === 'cache') {
// 读缓存 = 纯本地 SQLite 读取,不碰目标网站,大麦也允许
const cached = loadEvents(platform, city)
const cacheTime = (getMeta.get(`${platform}_${city}_lastFetchTime`) || {}).value
if (cached.length > 0) {
return buildResponse(platform, city, maxPrice, cached, cacheTime, true)
}
// 缓存为空需真抓取:大麦仍受服务器禁抓限制(见下方拦截)
}
// 大麦对服务器 IP 风控极严:默认禁用服务器端抓取,走本地代理(家庭 IP)
if (platform === 'damai' && readConfig().damai_server_fetch !== true) {
return { success: false, code: 'damai_need_local', error: '服务器端不抓大麦(保护服务器 IP)。请在本机启动本地抓取代理后,用「追加合并/覆盖」模式抓取' }
}
if (mode === 'cache') {
const events = await fetcher(city, pages)
saveEvents(platform, city, events, 'overwrite')
return buildResponse(platform, city, maxPrice, loadEvents(platform, city), nowBeijing(), false, events.length)
}
const events = await fetcher(city, pages)
saveEvents(platform, city, events, mode)
const merged = loadEvents(platform, city)
return buildResponse(platform, city, maxPrice, merged, nowBeijing(), false, events.length)
}
// ============ 8977 定时调度:每日 0 点追加抓取 ============
let schedulerStarted = false
async function autoFetchAll() {
const cfg = readConfig()
const auto = cfg.auto_fetch || {}
const platforms = Array.isArray(auto.platforms) && auto.platforms.length ? auto.platforms : ['showstart', 'maoyan']
const cities = Array.isArray(auto.cities) && auto.cities.length ? auto.cities : ['上海']
const results = []
for (const platform of platforms) {
// 大麦默认不走服务器(保护服务器 IP),除非显式开启 damai_server_fetch
if (platform === 'damai' && cfg.damai_server_fetch !== true) continue
for (const city of cities) {
try {
const fetcher = FETCHERS[platform]
if (!fetcher) continue
const events = await fetcher(city, 3)
saveEvents(platform, city, events, 'append')
results.push({ platform, city, ok: true, count: events.length })
try { logJSON('free_show.auto_fetch.ok', { platform, city, count: events.length }, 'free_show') } catch {}
} catch (e) {
results.push({ platform, city, ok: false, error: String(e.message || e) })
try { logJSON('free_show.auto_fetch.error', { platform, city, error: String(e.message || e) }, 'free_show') } catch {}
}
}
}
setMeta('last_auto_run', nowBeijing())
setMeta('last_auto_result', JSON.stringify(results))
return results
}
function startScheduler() {
if (schedulerStarted) return
schedulerStarted = true
const cfg = readConfig()
const auto = cfg.auto_fetch || {}
if (auto.enabled === false) {
try { logJSON('free_show.scheduler.disabled', {}, 'free_show') } catch {}
return
}
const hour = Number(auto.hour ?? 0)
const minute = Number(auto.minute ?? 0)
try { logJSON('free_show.scheduler.start', { hour, minute }, 'free_show') } catch {}
setInterval(() => {
try {
const bj = new Date(Date.now() + 8 * 3600 * 1000)
const h = bj.getUTCHours()
const m = bj.getUTCMinutes()
const today = bj.toISOString().slice(0, 10)
if (h !== hour || m !== minute) return
const lastDay = (getMeta.get('last_auto_run_day') || {}).value
if (lastDay === today) return
setMeta('last_auto_run_day', today)
autoFetchAll().catch(e => {
try { logJSON('free_show.scheduler.error', { error: String(e.message || e) }, 'free_show') } catch {}
})
} catch (e) {
try { logJSON('free_show.scheduler.tick_error', { error: String(e.message || e) }, 'free_show') } catch {}
}
}, 30 * 1000)
}
// ============ 路由 ============
function bindRoutes(app) {
app.get('/api/free_show/config', (req, res) => {
try {
const cfg = readConfig()
res.json({
ok: true,
ui_mode: cfg.ui_mode || 'debug',
auto_fetch: cfg.auto_fetch || {},
damai_server_fetch: cfg.damai_server_fetch === true,
local_agent_port: Number((cfg.local_agent || {}).port) || 8978
})
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// 本地代理(家庭 IP)抓完后,把数据推回服务器入库
app.post('/api/free_show/ingest', (req, res) => {
try {
const body = req.body || {}
const platform = String(body.platform || '')
const city = String(body.city || '上海')
const mode = String(body.mode || 'append')
const maxPrice = Number(body.maxPrice) || 5
const events = Array.isArray(body.events) ? body.events : []
if (!['showstart', 'maoyan', 'damai'].includes(platform)) return res.status(400).json({ success: false, error: '不支持的平台' })
if (events.length === 0) return res.status(400).json({ success: false, error: '无数据可入库' })
saveEvents(platform, city, events, mode === 'overwrite' ? 'overwrite' : 'append')
res.json(buildResponse(platform, city, maxPrice, loadEvents(platform, city), nowBeijing(), false, events.length))
} catch (e) {
res.status(500).json({ success: false, error: String(e.message || e) })
}
})
app.get('/api/free_show/auto_status', (req, res) => {
try {
const lastRun = (getMeta.get('last_auto_run') || {}).value || null
let lastResult = null
try { lastResult = JSON.parse((getMeta.get('last_auto_result') || {}).value || 'null') } catch {}
res.json({ ok: true, lastRun, lastResult })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get('/api/free_show/fetch', async (req, res) => {
try {
const platform = String(req.query.platform || 'showstart')
const city = String(req.query.city || '上海')
const maxPrice = parseFloat(req.query.maxPrice) || 5
const pages = Math.min(parseInt(req.query.pages) || 3, 10)
const mode = String(req.query.mode || 'cache')
const result = await runFetch(platform, city, maxPrice, mode, pages)
res.json(result)
} catch (err) {
res.status(500).json({ success: false, error: String(err.message || err) })
}
})
app.get('/api/free_show/cities', (req, res) => {
res.json({
success: true,
showstart: Object.keys(SHOWSTART_CITIES),
maoyan: Object.keys(MAOYAN_CITIES),
damai: Object.keys(DAMAI_CITIES)
})
})
}
// 优雅退出时关闭浏览器
process.on('exit', () => {
try { if (browserInstance) browserInstance.close() } catch {}
})
module.exports = { bindRoutes, startScheduler, readConfig }
+150 -2
View File
@@ -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)
+26 -3
View File
@@ -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 }
+398
View File
@@ -0,0 +1,398 @@
/**
* 建行定投评估(markets 子模块)
*
* 用途:银行场外基金定投/赎回参考。按需抓取四大指数 PE-TTM 与近 10 年 PE 分位,
* 对照个人定投档位阈值给出建议。不做定时任务,点击页面按钮才触发。
*
* 数据源(探针验证于 dev_test_scripts/debug/debug_dip_eval_sources*.js):
* - 沪深300 / 中证500:中证指数官网 index-perf(官方日频市盈率历史,精确计算近 10 年分位),备源:蛋卷基金
* - 标普500 / 纳指100:蛋卷基金 index_eva/dj(每日更新 PE + 分位),标普备源:multpl.com
* - 东方财富 push2 / 数据中心对指数不返回 PE,故未采用
*
* ============================================================
* 个人定投档位阈值(配套我的估值阈值,档位判定的唯一依据)
* ============================================================
* 沪深300(000300):
* PE<12(分位<30%)→ 每日定投 200 元
* 12 ≤ PE < 14.5 → 每日定投 150 元
* 14.5 ≤ PE < 16 → 每日定投 100 元,可止盈 50%
* PE ≥ 16 → 暂停定投 + 止盈清仓
*
* 中证500(000905):
* PE<25 → 每日定投 200 元
* 25 ≤ PE < 32 → 每日定投 150 元
* 32 ≤ PE < 36 → 每日定投 100 元,可止盈 50%
* PE ≥ 36 → 暂停定投 + 止盈清仓
*
* 纳指100(NDX):
* PE<24 → 每日定投 200 元
* 24 ≤ PE < 30 → 每日定投 150 元
* 30 ≤ PE < 36 → 每日定投 100 元,可止盈 50%
* PE ≥ 36 → 暂停定投 + 止盈清仓
*
* 标普500(SP500):
* PE<16 → 每日定投 200 元
* 16 ≤ PE < 21 → 每日定投 150 元
* 21 ≤ PE < 25 → 每日定投 100 元,可止盈 50%
* PE ≥ 25 → 暂停定投 + 止盈清仓
* ============================================================
*/
const axios = require('axios')
const { logJSON } = require('./logger')
// 通用请求头(模拟浏览器,避免被数据源拦截)
const HTTP_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36'
}
// 四大指数的档位阈值配置(含义见文件头注释)
// bands 按 PE 从低到高排列,max 为该档上边界(不含),命中即停
const INDEX_PLAN = [
{
key: 'csi300',
name: '沪深300',
code: '000300',
market: 'A股',
danjuan_code: 'SH000300', // 蛋卷基金接口中的指数代码
csindex: true, // A股指数可走中证指数官网(官方日频PE历史)
bands: [
{ max: 12, advice: '每日定投 200 元', level: 'buy_heavy' }, // PE<12(分位<30%)
{ max: 14.5, advice: '每日定投 150 元', level: 'buy' }, // 12 ≤ PE < 14.5
{ max: 16, advice: '每日定投 100 元,可止盈 50%', level: 'take_half' }, // 14.5 ≤ PE < 16
{ max: Infinity, advice: '暂停定投 + 止盈清仓', level: 'exit' } // PE ≥ 16
]
},
{
key: 'csi500',
name: '中证500',
code: '000905',
market: 'A股',
danjuan_code: 'SH000905',
csindex: true,
bands: [
{ max: 25, advice: '每日定投 200 元', level: 'buy_heavy' }, // PE<25
{ max: 32, advice: '每日定投 150 元', level: 'buy' }, // 25 ≤ PE < 32
{ max: 36, advice: '每日定投 100 元,可止盈 50%', level: 'take_half' }, // 32 ≤ PE < 36
{ max: Infinity, advice: '暂停定投 + 止盈清仓', level: 'exit' } // PE ≥ 36
]
},
{
key: 'ndx',
name: '纳指100',
code: 'NDX',
market: '美股',
danjuan_code: 'NDX',
csindex: false, // 美股无中证官网数据,仅蛋卷主源;缺源时分位显示 —
bands: [
{ max: 24, advice: '每日定投 200 元', level: 'buy_heavy' }, // PE<24
{ max: 30, advice: '每日定投 150 元', level: 'buy' }, // 24 ≤ PE < 30
{ max: 36, advice: '每日定投 100 元,可止盈 50%', level: 'take_half' }, // 30 ≤ PE < 36
{ max: Infinity, advice: '暂停定投 + 止盈清仓', level: 'exit' } // PE ≥ 36
]
},
{
key: 'sp500',
name: '标普500',
code: 'SP500',
market: '美股',
danjuan_code: 'SP500',
csindex: false,
bands: [
{ max: 16, advice: '每日定投 200 元', level: 'buy_heavy' }, // PE<16
{ max: 21, advice: '每日定投 150 元', level: 'buy' }, // 16 ≤ PE < 21
{ max: 25, advice: '每日定投 100 元,可止盈 50%', level: 'take_half' }, // 21 ≤ PE < 25
{ max: Infinity, advice: '暂停定投 + 止盈清仓', level: 'exit' } // PE ≥ 25
]
}
]
// ---------- 工具函数 ----------
// 当前北京时间字符串:YYYY-MM-DD HH:mm:ss(项目统一北京时间)
const beijingNow = () => new Date(Date.now() + 8 * 3600 * 1000).toISOString().replace('T', ' ').slice(0, 19)
// 北京时间的 YYYYMMDD(用于中证官网日期参数)
const beijingDateCompact = offsetDays => {
const d = new Date(Date.now() + 8 * 3600 * 1000 + (offsetDays || 0) * 86400 * 1000)
return d.toISOString().slice(0, 10).replace(/-/g, '')
}
const toNum = v => {
const n = Number(v)
return Number.isFinite(n) ? n : NaN
}
// 按 PE 档位表判定建议(bands 从低到高,max 为上边界不含)
const matchBand = (bands, pe) => {
if (!Number.isFinite(pe)) return null
for (const b of bands) {
if (pe < b.max) return b
}
return bands[bands.length - 1]
}
// ---------- 数据源抓取 ----------
// 蛋卷基金指数估值:一次返回全部指数(含沪深300/中证500/标普500/纳指100)
// 返回 Map<index_code, {pe, pe_percentile(0~1), date, begin_at}>
const fetchDanjuanEva = async () => {
const url = 'https://danjuanfunds.com/djapi/index_eva/dj'
const res = await axios.get(url, { timeout: 15000, headers: HTTP_HEADERS, validateStatus: () => true })
const items = res && res.data && res.data.data && Array.isArray(res.data.data.items) ? res.data.data.items : []
if (!items.length) throw new Error(`蛋卷接口无数据(HTTP ${res ? res.status : '无响应'})`)
const map = {}
for (const it of items) {
const code = String(it.index_code || '')
if (!code) continue
map[code] = {
pe: toNum(it.pe),
pe_percentile: toNum(it.pe_percentile), // 0~1 小数
date: String(it.date || ''), // 数据日期 MM-DD
begin_at: Number(it.begin_at || 0) // 分位统计窗口起点(毫秒)
}
}
return map
}
// 中证指数官网:指数官方日频行情(含市盈率 peg 字段,实测即 PE)
// 返回按日期升序的 [{date, pe}],覆盖近 10 年
const fetchCsindexPeSeries = async code => {
const end = beijingDateCompact(0)
const start = beijingDateCompact(-3652) // 约 10 年
const url = `https://www.csindex.com.cn/csindex-home/perf/index-perf?indexCode=${encodeURIComponent(code)}&startDate=${start}&endDate=${end}`
const res = await axios.get(url, {
timeout: 20000,
headers: { ...HTTP_HEADERS, 'Referer': 'https://www.csindex.com.cn/' },
validateStatus: () => true
})
const list = res && res.data && Array.isArray(res.data.data) ? res.data.data : []
if (!list.length) throw new Error(`中证官网无数据(HTTP ${res ? res.status : '无响应'})`)
const out = []
for (const r of list) {
const pe = toNum(r.peg) // 官网字段名为 peg,实测值即市盈率(与蛋卷 PE 一致)
if (!Number.isFinite(pe) || pe <= 0) continue
out.push({ date: String(r.tradeDate || ''), pe })
}
if (!out.length) throw new Error('中证官网返回记录中无有效市盈率')
return out
}
// multpl.com:标普500 当前 PE 备源(页面文本 "Current S&P 500 PE Ratio is 29.26")
const fetchMultplSp500Pe = async () => {
const res = await axios.get('https://www.multpl.com/s-p-500-pe-ratio', { timeout: 15000, headers: HTTP_HEADERS, validateStatus: () => true })
const html = String(res && res.data ? res.data : '')
const m = html.match(/Current S&P 500 PE Ratio[^0-9]{0,40}([0-9]+(?:\.[0-9]+)?)/i)
if (!m) throw new Error('multpl 页面未匹配到 PE 数值')
const pe = toNum(m[1])
if (!Number.isFinite(pe) || pe <= 0) throw new Error('multpl PE 数值无效')
return pe
}
// ---------- 单指数评估 ----------
// A股指数:中证官网主源(精确近10年分位),蛋卷备源
const evalAShareIndex = async (plan, danjuanMap, errors) => {
// 主源:中证指数官网近 10 年日频 PE,自行计算分位
try {
const series = await fetchCsindexPeSeries(plan.code)
const latest = series[series.length - 1]
const pe = latest.pe
// 分位 = 历史中 ≤ 当前 PE 的样本占比(样本即近10年全部交易日)
let below = 0
for (const s of series) {
if (s.pe <= pe) below++
}
const percentile = Math.round((below / series.length) * 1000) / 10
const band = matchBand(plan.bands, pe)
// 双源口径校验:蛋卷与中证官网的 PE 计算口径不同(如中证500 差异可达 30%),
// 差异超过 15% 时在界面上备注,避免单一口径误导定投决策
let note = ''
const dj = danjuanMap && danjuanMap[plan.danjuan_code]
if (dj && Number.isFinite(dj.pe) && Math.abs(dj.pe - pe) / pe > 0.15) {
const djBand = matchBand(plan.bands, dj.pe)
note = `蛋卷口径 PE=${dj.pe.toFixed(2)}(对应档位:${djBand ? djBand.advice : '—'}),与官方口径差异较大,请结合自身基金跟踪口径判断`
}
return {
name: plan.name,
code: plan.code,
market: plan.market,
pe_ttm: Math.round(pe * 100) / 100,
pe_date: latest.date.replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3'),
percentile,
percentile_note: `近10年日频自算(${series.length} 个样本)`,
source: '中证指数官网',
advice: band ? band.advice : '—',
level: band ? band.level : 'unknown',
note
}
} catch (e) {
errors.push(`${plan.name}:中证官网获取失败(${String(e.message || e)}),尝试备源`)
}
// 备源:蛋卷基金(其分位窗口自 2016-04 起,约 10 年)
const dj = danjuanMap && danjuanMap[plan.danjuan_code]
if (dj && Number.isFinite(dj.pe)) {
const band = matchBand(plan.bands, dj.pe)
const beginYear = dj.begin_at ? new Date(dj.begin_at + 8 * 3600 * 1000).toISOString().slice(0, 4) : ''
return {
name: plan.name,
code: plan.code,
market: plan.market,
pe_ttm: Math.round(dj.pe * 100) / 100,
pe_date: dj.date ? `${new Date().getFullYear()}-${dj.date}` : '',
percentile: Number.isFinite(dj.pe_percentile) ? Math.round(dj.pe_percentile * 1000) / 10 : null,
percentile_note: beginYear ? `统计窗口自 ${beginYear} 年起(约10年)` : '蛋卷口径',
source: '蛋卷基金(备源)',
advice: band ? band.advice : '—',
level: band ? band.level : 'unknown'
}
}
errors.push(`${plan.name}:主源与备源均获取失败`)
return null
}
// 美股指数:蛋卷主源(PE+分位);标普500 额外有 multpl 备源(仅PE,分位显示 —)
const evalUsIndex = async (plan, danjuanMap, errors) => {
const dj = danjuanMap && danjuanMap[plan.danjuan_code]
if (dj && Number.isFinite(dj.pe)) {
const band = matchBand(plan.bands, dj.pe)
const beginYear = dj.begin_at ? new Date(dj.begin_at + 8 * 3600 * 1000).toISOString().slice(0, 4) : ''
return {
name: plan.name,
code: plan.code,
market: plan.market,
pe_ttm: Math.round(dj.pe * 100) / 100,
pe_date: dj.date ? `${new Date().getFullYear()}-${dj.date}` : '',
percentile: Number.isFinite(dj.pe_percentile) ? Math.round(dj.pe_percentile * 1000) / 10 : null,
percentile_note: beginYear ? `统计窗口自 ${beginYear} 年起(约10年)` : '蛋卷口径',
source: '蛋卷基金',
advice: band ? band.advice : '—',
level: band ? band.level : 'unknown'
}
}
errors.push(`${plan.name}:蛋卷主源获取失败,尝试备源`)
// 备源:仅标普500 有 multpl(月度更新的当前PE,无历史分位 → 分位显示 —)
if (plan.key === 'sp500') {
try {
const pe = await fetchMultplSp500Pe()
const band = matchBand(plan.bands, pe)
return {
name: plan.name,
code: plan.code,
market: plan.market,
pe_ttm: Math.round(pe * 100) / 100,
pe_date: beijingNow().slice(0, 10),
percentile: null, // 备源无历史序列,分位无法计算,显示 —
percentile_note: '备源无历史数据',
source: 'multpl.com(备源)',
advice: band ? band.advice : '—',
level: band ? band.level : 'unknown'
}
} catch (e) {
errors.push(`${plan.name}:multpl 备源也失败(${String(e.message || e)})`)
}
}
return null
}
// ---------- 控制台文本表格(对齐打印,中文按 2 列宽处理) ----------
// 计算字符串显示宽度(CJK 字符按 2 列)
const displayWidth = str => {
let w = 0
for (const ch of String(str == null ? '' : str)) {
w += /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/.test(ch) ? 2 : 1
}
return w
}
// 按显示宽度填充对齐
const padCell = (str, width) => {
const s = String(str == null ? '' : str)
const gap = width - displayWidth(s)
return s + (gap > 0 ? ' '.repeat(gap) : '')
}
// 控制台打印清晰文本表格(用户要求:运行后控制台打印)
const printConsoleTable = (rows, errors) => {
try {
const header = ['指数名称', '代码', 'PE-TTM', '近10年PE分位', '数据日期', '定投建议']
const lines = rows.map(r => [
r.name,
r.code,
Number.isFinite(r.pe_ttm) ? r.pe_ttm.toFixed(2) : '—',
Number.isFinite(r.percentile) ? `${r.percentile.toFixed(1)}%` : '—',
r.pe_date || '—',
r.advice || '—'
])
const widths = header.map((h, i) => Math.max(displayWidth(h), ...lines.map(l => displayWidth(l[i]))))
const sep = '─'.repeat(widths.reduce((a, b) => a + b, 0) + widths.length * 3 + 1)
console.log('')
console.log(`┌${sep}┐`)
console.log(`│ 【建行定投评估】 ${beijingNow()} │`)
console.log(`├${sep}┤`)
console.log(`│ ${header.map((h, i) => padCell(h, widths[i])).join(' │ ')} │`)
console.log(`├${sep}┤`)
for (const l of lines) {
console.log(`│ ${l.map((c, i) => padCell(c, widths[i])).join(' │ ')} │`)
}
console.log(`└${sep}┘`)
if (errors && errors.length) {
console.log('【异常提示】')
for (const e of errors) console.log(` ⚠ ${e}`)
}
console.log('')
} catch (e) {
// 打印失败不影响主流程
try { logJSON('markets.dip_eval.print.error', { error: String(e.message || e) }, 'markets') } catch {}
}
}
// ---------- 主入口 ----------
const getDipEvalReport = async () => {
const startedAt = Date.now()
const errors = []
// 1. 先抓蛋卷(一次覆盖四指数),失败不中断,A股还有中证官网主源
let danjuanMap = {}
try {
danjuanMap = await fetchDanjuanEva()
} catch (e) {
errors.push(`蛋卷基金接口整体失败:${String(e.message || e)}`)
}
// 2. 并行评估四个指数,单个失败不影响其他
const evalTasks = INDEX_PLAN.map(plan => {
const worker = plan.csindex ? evalAShareIndex : evalUsIndex
return worker(plan, danjuanMap, errors).catch(e => {
errors.push(`${plan.name}:评估过程异常(${String(e.message || e)})`)
return null
})
})
const results = await Promise.all(evalTasks)
const rows = []
INDEX_PLAN.forEach((plan, i) => {
if (results[i]) rows.push(results[i])
})
// 3. 控制台打印文本表格(用户要求)
printConsoleTable(rows, errors)
try {
logJSON('markets.dip_eval.report', {
ok: rows.length > 0,
rows: rows.length,
errors,
elapsed_ms: Date.now() - startedAt
}, 'markets')
} catch {}
// 全部失败才视为整体失败(前端弹窗强提示);部分失败在 errors 中逐条说明
if (!rows.length) {
return { ok: false, error: `所有数据源均获取失败:${errors.join(';')}`, rows: [], errors, captured_at: beijingNow() }
}
return { ok: true, rows, errors, captured_at: beijingNow(), elapsed_ms: Date.now() - startedAt }
}
module.exports = { getDipEvalReport }
+122
View File
@@ -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(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/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 })