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