feat: 初始提交(仅核心代码,已排除大文件)

This commit is contained in:
yangxiangyuan
2026-07-08 10:29:50 +08:00
commit be7c5842f1
623 changed files with 332897 additions and 0 deletions
@@ -0,0 +1,94 @@
{
"name_comment": "显示的名",
"name": "美国战争指数",
"card_order": 14,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "us_war_pizzint",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://fred.stlouisfed.org/series/VIXCLS",
"stable_url_comment": "稳定数据源地址(优先)",
"stable_url": "https://fred.stlouisfed.org/graph/fredgraph.csv?id=VIXCLS",
"pizzint_url_comment": "备用数据源地址(兜底)",
"pizzint_url": "https://www.pizzint.watch/",
"fail_alert_consecutive_comment": "连续失败达到该次数才触发异常告警",
"fail_alert_consecutive": 3,
"source_comment": "数据来源",
"source": "fred_vix",
"alerts_comment": "预警阈值配",
"alerts": {
"doughcon_critical_max_comment": "DOUGHCON 小于等于该值触发高风险预警",
"doughcon_critical_max": 2,
"doughcon_warning_max_comment": "DOUGHCON 小于等于该值触发风险抬升预",
"doughcon_warning_max": 3,
"vix_warning_min_comment": "VIX 大于等于该值触发风险抬升预",
"vix_warning_min": 22,
"vix_critical_min_comment": "VIX 大于等于该值触发高风险预警",
"vix_critical_min": 30,
"index_jump_threshold_comment": "战争指数单次上跳达到该值触发快速升温预",
"index_jump_threshold": 20,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": false,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": false,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": false,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": false,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": false
}
],
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "00:00",
"end": "23:59"
}
],
"display_label": "FRED VIX"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1,382 @@
const fetchText = async (axios, url, headers = {}) => {
try {
const res = await axios.get(url, {
timeout: 10000,
validateStatus: () => true,
headers
})
if (!res || res.status >= 400) return null
return String(res.data || '')
} catch {
return null
}
}
const fetchPizzint = (axios, url) => fetchText(axios, url, {
'User-Agent': 'Mozilla/5.0',
'Referer': 'https://www.pizzint.watch/',
'Accept-Language': 'en-US,en;q=0.9'
})
const fetchFredVix = (axios, url) => fetchText(axios, url, {
'User-Agent': 'Mozilla/5.0',
'Accept': 'text/csv,text/plain;q=0.9,*/*;q=0.8'
})
const buildCandidateUrls = (url) => {
const primary = String(url || '').trim() || 'https://www.pizzint.watch/'
const list = [primary]
try {
const u = new URL(primary)
const host = String(u.hostname || '').toLowerCase()
if (host === 'www.pizzint.watch') {
list.push('https://pizzint.watch/')
} else if (host === 'pizzint.watch') {
list.push('https://www.pizzint.watch/')
}
} catch {}
list.push('https://r.jina.ai/http://www.pizzint.watch/')
list.push('https://r.jina.ai/http://pizzint.watch/')
return [...new Set(list)]
}
const stripHtmlToText = (raw) => String(raw || '')
.replace(/<script[\s\S]*?<\/script>/ig, ' ')
.replace(/<style[\s\S]*?<\/style>/ig, ' ')
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/\s+/g, ' ')
.trim()
const parsePizzint = (raw) => {
const html = String(raw || '')
const text = stripHtmlToText(html)
const doughconMatch =
text.match(/DOUGHCON\s*([1-5])/i) ||
html.match(/DOUGHCON\s*([1-5])/i) ||
text.match(/"doughcon"\s*:\s*([1-5])/i) ||
text.match(/"level"\s*:\s*([1-5])/i)
if (!doughconMatch) return null
const doughcon = Number(doughconMatch[1])
if (!Number.isFinite(doughcon)) return null
const statusMatch =
text.match(/STATUS:\s*([A-Z_]+)/i) ||
text.match(/\bSTATUS\b\s*:?\s*([A-Z][A-Z _-]{2,24})\b/i) ||
html.match(/STATUS:\s*(?:<\/[^>]+>\s*<[^>]+>\s*)*([A-Z][A-Z _-]{2,24})\s*</i) ||
text.match(/"status"\s*:\s*"([A-Z_]+)"/i)
const status = statusMatch ? String(statusMatch[1]).trim().replace(/\s+/g, '_').toUpperCase() : 'UNKNOWN'
const locationsMatch =
text.match(/(\d+)\s*LOCATIONS?\s*MONITORED/i) ||
text.match(/"locationsMonitored"\s*:\s*(\d+)/i) ||
text.match(/"locations"\s*:\s*(\d+)/i)
const locations = locationsMatch ? Number(locationsMatch[1]) : null
const timeMatch =
text.match(/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/) ||
text.match(/"timestamp"\s*:\s*"([0-9T:\-.Z]+)"/i)
const capturedAt = timeMatch
? String(timeMatch[1]).replace('T', ' ').replace('Z', '').replace(/\s+/, ' ')
: new Date().toISOString().replace('T', ' ').slice(0, 19)
const feedFailed = /CONNECTION FAILED|Data fetch error|ERROR:\s*Failed to fetch|Failed to fetch/i.test(text)
const warIndex = Math.max(0, Math.min(100, (6 - doughcon) * 20))
return { doughcon, status, locations, capturedAt, feedFailed, warIndex }
}
const clamp = (n, min, max) => Math.max(min, Math.min(max, n))
const vixToWarIndex = (vix) => {
const base = ((vix - 12) / 38) * 100
return clamp(base, 0, 100)
}
const classifyVixStatus = (vix) => {
if (vix >= 30) return 'STRESSED'
if (vix >= 22) return 'ELEVATED'
return 'CALM'
}
const parseFredVix = (raw) => {
const lines = String(raw || '').split(/\r?\n/).map(x => x.trim()).filter(Boolean)
if (lines.length < 2) return null
const points = []
for (let i = 1; i < lines.length; i += 1) {
const row = lines[i]
const idx = row.indexOf(',')
if (idx < 0) continue
const date = row.slice(0, idx).trim()
const valueRaw = row.slice(idx + 1).trim()
const value = Number(valueRaw)
if (!date || !Number.isFinite(value)) continue
points.push({ date, value })
}
if (!points.length) return null
const last = points[points.length - 1]
const prev = points.length > 1 ? points[points.length - 2] : null
const warIndex = vixToWarIndex(last.value)
const prevWarIndex = prev ? vixToWarIndex(prev.value) : warIndex
return {
provider: 'fred_vix',
vix: last.value,
prevVix: prev ? prev.value : last.value,
warIndex,
prevWarIndex,
status: classifyVixStatus(last.value),
capturedAt: `${last.date} 00:00:00`
}
}
const getWarRiskBand = (warIndex) => {
if (!Number.isFinite(warIndex)) return '未知'
if (warIndex < 30) return '低风险'
if (warIndex < 60) return '中风险'
return '高风险'
}
const buildExplainText = (snapshot, sourceLabel) => {
const warIndex = Number(snapshot && snapshot.warIndex)
const band = getWarRiskBand(warIndex)
if (snapshot && snapshot.provider === 'fred_vix') {
const vix = Number(snapshot.vix)
return `指数说明:0-100 越高风险越高;当前 ${warIndex.toFixed(2)}(${band})。数据源 ${sourceLabel},VIX ${vix.toFixed(2)} 代表市场波动预期。`
}
const doughcon = Number(snapshot && snapshot.doughcon)
const status = String((snapshot && snapshot.status) || 'UNKNOWN')
return `指数说明:0-100 越高风险越高;当前 ${warIndex.toFixed(2)}(${band})。数据源 ${sourceLabel},DOUGHCON ${doughcon} / 状态 ${status}。`
}
const buildAlerts = (snapshot, prevIndex, config) => {
const alerts = []
const cfg = config && config.alerts ? config.alerts : {}
const jumpThreshold = Number.isFinite(cfg.index_jump_threshold) ? cfg.index_jump_threshold : 20
const warIndex = snapshot.warIndex
const delta = Number.isFinite(prevIndex) ? warIndex - prevIndex : 0
if (snapshot.provider === 'fred_vix') {
const vixWarningMin = Number.isFinite(cfg.vix_warning_min) ? cfg.vix_warning_min : 22
const vixCriticalMin = Number.isFinite(cfg.vix_critical_min) ? cfg.vix_critical_min : 30
if (snapshot.vix >= vixCriticalMin) {
alerts.push({
id: `vix_critical_${Math.floor(snapshot.vix)}`,
title: `${config.name}高风险预警`,
message: `VIX ${snapshot.vix.toFixed(2)},战争指数 ${warIndex.toFixed(2)},进入高警戒区`,
level: 'critical'
})
} else if (snapshot.vix >= vixWarningMin) {
alerts.push({
id: `vix_warning_${Math.floor(snapshot.vix)}`,
title: `${config.name}风险抬升预警`,
message: `VIX ${snapshot.vix.toFixed(2)},战争指数 ${warIndex.toFixed(2)},建议关注地缘冲突动态`,
level: 'warning'
})
}
if (Number.isFinite(prevIndex) && delta >= jumpThreshold) {
alerts.push({
id: `jump_${Math.floor(delta)}`,
title: `${config.name}快速升温预警`,
message: `指数单次上跳 ${delta.toFixed(2)},当前 ${warIndex.toFixed(2)},上一值 ${prevIndex.toFixed(2)}`,
level: 'critical'
})
}
return alerts
}
const criticalMax = Number.isFinite(cfg.doughcon_critical_max) ? cfg.doughcon_critical_max : 2
const warningMax = Number.isFinite(cfg.doughcon_warning_max) ? cfg.doughcon_warning_max : 3
const doughcon = snapshot.doughcon
const status = snapshot.status
if (snapshot.feedFailed) {
alerts.push({
id: 'feed_failed',
title: `${config.name}数据源异常`,
message: `PizzINT 页面出现数据抓取失败信号,请人工复核。当前 DOUGHCON ${doughcon}`,
level: 'warning'
})
}
if (status && status !== 'OPERATIONAL' && status !== 'UNKNOWN') {
alerts.push({
id: `status_${status.toLowerCase()}`,
title: `${config.name}运行状态异常`,
message: `当前状态 ${status},DOUGHCON ${doughcon},指数 ${warIndex.toFixed(2)}`,
level: 'warning'
})
}
if (doughcon <= criticalMax) {
alerts.push({
id: `doughcon_critical_${doughcon}`,
title: `${config.name}高风险预警`,
message: `DOUGHCON ${doughcon},战争指数 ${warIndex.toFixed(2)},进入高警戒区`,
level: 'critical'
})
} else if (doughcon <= warningMax) {
alerts.push({
id: `doughcon_warning_${doughcon}`,
title: `${config.name}风险抬升预警`,
message: `DOUGHCON ${doughcon},战争指数 ${warIndex.toFixed(2)},建议关注地缘冲突动态`,
level: 'warning'
})
}
if (Number.isFinite(prevIndex) && delta >= jumpThreshold) {
alerts.push({
id: `jump_${Math.floor(delta)}`,
title: `${config.name}快速升温预警`,
message: `指数单次上跳 ${delta.toFixed(2)},当前 ${warIndex.toFixed(2)},上一值 ${prevIndex.toFixed(2)}`,
level: 'critical'
})
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, db, logJSON } = context
const stableUrl = config.stable_url || 'https://fred.stlouisfed.org/graph/fredgraph.csv?id=VIXCLS'
const pizzintUrl = config.pizzint_url || 'https://www.pizzint.watch/'
const stateKey = `skill_state:${String(config.symbol || 'us_war_pizzint')}:war_index`
const failCountKey = `${stateKey}:fail_count`
const failAlertThreshold = Number.isFinite(Number(config && config.fail_alert_consecutive))
? Math.max(1, Number(config.fail_alert_consecutive))
: 3
const getState = db.prepare(`SELECT value FROM market_state WHERE key = ?`)
const upsertState = db.prepare(
`INSERT INTO market_state (key, value, updated_at)
VALUES (@key, @value, datetime('now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = datetime('now')`
)
const stateRow = getState.get(stateKey)
const failCountRow = getState.get(failCountKey)
const prevIndex = stateRow && Number.isFinite(Number(stateRow.value)) ? Number(stateRow.value) : NaN
const failCount = failCountRow && Number.isFinite(Number(failCountRow.value)) ? Math.max(0, Number(failCountRow.value)) : 0
const fallbackPrice = Number.isFinite(prevIndex) ? prevIndex : 20
const buildFallback = (status, message) => {
const nextFailCount = failCount + 1
upsertState.run({ key: failCountKey, value: String(nextFailCount) })
const explain = `指数说明:0-100 越高风险越高;当前为兜底值 ${fallbackPrice.toFixed(2)}。稳定源与备用源连续失败时会回退。`
const data = [{
label: config.name,
symbol: config.symbol,
source: 'war_index_fallback',
name: `DOUGHCON N/A · ${status}`,
price: fallbackPrice,
prevClose: fallbackPrice,
change: 0,
change_percent: 0,
captured_at: new Date().toISOString().replace('T', ' ').slice(0, 19),
market_status: config.market_status,
pizzint_doughcon: null,
pizzint_status: status,
pizzint_locations: null,
pizzint_feed_failed: 1,
explain
}]
const alerts = nextFailCount >= failAlertThreshold
? [{
id: status.toLowerCase(),
title: `${config.name}数据异常`,
message: `${message}(连续失败 ${nextFailCount} 次)。${explain}`,
level: 'warning'
}]
: []
return { data, alerts }
}
const stableRaw = await fetchFredVix(axios, stableUrl)
const stableSnapshot = stableRaw ? parseFredVix(stableRaw) : null
if (stableSnapshot) {
if (failCount > 0) upsertState.run({ key: failCountKey, value: '0' })
const warIndex = stableSnapshot.warIndex
const prevClose = Number.isFinite(prevIndex) ? prevIndex : stableSnapshot.prevWarIndex
const change = warIndex - prevClose
const changePercent = prevClose ? (change / prevClose) * 100 : 0
const explain = buildExplainText(stableSnapshot, 'FRED VIX')
upsertState.run({ key: stateKey, value: String(warIndex) })
const data = [{
label: config.name,
symbol: config.symbol,
source: 'fred_vix',
name: `VIX ${stableSnapshot.vix.toFixed(2)} · ${stableSnapshot.status}`,
price: warIndex,
prevClose,
change,
change_percent: changePercent,
captured_at: stableSnapshot.capturedAt,
market_status: config.market_status,
pizzint_doughcon: null,
pizzint_status: null,
pizzint_locations: null,
pizzint_feed_failed: 0,
explain
}]
const alerts = buildAlerts(stableSnapshot, Number.isFinite(prevIndex) ? prevIndex : null, config)
return { data, alerts }
}
const candidates = buildCandidateUrls(pizzintUrl)
let snapshot = null
let fetchedCount = 0
let parseFailedCount = 0
let resolvedFrom = ''
for (const candidate of candidates) {
const html = await fetchPizzint(axios, candidate)
if (!html) continue
fetchedCount += 1
const parsed = parsePizzint(html)
if (parsed) {
snapshot = parsed
resolvedFrom = candidate
break
}
parseFailedCount += 1
}
if (!snapshot && fetchedCount === 0) {
logJSON('skill.fetch.error', { skill: config.name, message: 'All sources fetch failed', candidates: candidates.length, stableUrl }, 'market_skills')
return buildFallback('FETCH_FAILED', '稳定源 FRED VIX 与备用源 PizzINT 均抓取失败,已回退到兜底数值')
}
if (!snapshot) {
logJSON('skill.parse.error', { skill: config.name, message: 'PizzINT parse failed after stable source failure', fetchedCount, parseFailedCount, stableUrl }, 'market_skills')
return buildFallback('PARSE_FAILED', '稳定源不可用且备用源解析失败,已回退到兜底数值')
}
if (resolvedFrom && String(resolvedFrom) !== String(pizzintUrl)) {
logJSON('skill.fetch.fallback', { skill: config.name, resolvedFrom }, 'market_skills')
}
if (failCount > 0) upsertState.run({ key: failCountKey, value: '0' })
const warIndex = snapshot.warIndex
const prevClose = Number.isFinite(prevIndex) ? prevIndex : warIndex
const change = warIndex - prevClose
const changePercent = prevClose ? (change / prevClose) * 100 : 0
const explain = buildExplainText(snapshot, 'PizzINT')
upsertState.run({ key: stateKey, value: String(warIndex) })
const data = [{
label: config.name,
symbol: config.symbol,
source: 'pizzint',
name: `DOUGHCON ${snapshot.doughcon} · ${snapshot.status}`,
price: warIndex,
prevClose,
change,
change_percent: changePercent,
captured_at: snapshot.capturedAt,
market_status: config.market_status,
pizzint_doughcon: snapshot.doughcon,
pizzint_status: snapshot.status,
pizzint_locations: snapshot.locations,
pizzint_feed_failed: snapshot.feedFailed ? 1 : 0,
explain
}]
const alerts = buildAlerts(snapshot, Number.isFinite(prevIndex) ? prevIndex : null, config)
return { data, alerts }
}
}