const { db, upsertMarketArchive, getMarketArchive } = require('./db') const { fetchSina, fetchYahoo, fetchTencent, fetchEastmoneyComexGold } = require('./marketsClient') const { logJSON } = require('./logger') const fs = require('fs') const path = require('path') const nodemailer = require('nodemailer') const axios = require('axios') const { getLatestData } = require('./market_skills/manager') const securityCalendar = require('./security_calendar') const insertEmailLog = db.prepare( `INSERT OR IGNORE INTO alert_email_log (skill, alert_id, subject, status, error_message, created_at) VALUES (@skill, @alert_id, @subject, @status, @error_message, datetime('now'))` ) const cfgPath = path.join(process.cwd(), 'config', 'markets.json') if (!fs.existsSync(cfgPath)) { const defaultPort = process.env.PORT || 5050 const defaultBase = `http://localhost:${defaultPort}/api/weekly` const defaultCalendar = `http://localhost:${defaultPort}/api/calendar/public` fs.writeFileSync(cfgPath, JSON.stringify({ ui_mode_comment: '界面模式(debug=显示完整交互弹窗,normal=成功操作更快自动关闭)', ui_mode: 'debug', skills_only_mode: true, yahoo_probe_comment: 'Yahoo 探针配置(海外行情与历史日线通过海外探针中转)', yahoo_probe: { enabled_comment: '是否启用 Yahoo 探针', enabled: true, base_url_comment: 'Yahoo 探针基础地址(不带结尾斜杠)', base_url: 'http://47.252.30.127:19071', token_comment: 'Yahoo 探针 token,仅后端使用,需在 config/markets.json 中配置', token: '', endpoint_comment: 'Yahoo 探针统一路由', endpoint: '/probe/yahoo', quote_endpoint_comment: '实时报价路由,默认与 endpoint 一致', quote_endpoint: '/probe/yahoo', chart_endpoint_comment: '历史K线路由,默认与 endpoint 一致', chart_endpoint: '/probe/yahoo', request_timeout_ms_comment: '请求超时毫秒数', request_timeout_ms: 15000, quote_ttl_seconds_comment: '实时报价缓存秒数,降低 Yahoo 访问频率', quote_ttl_seconds: 120, chart_ttl_seconds_comment: '历史K线缓存秒数,降低 Yahoo 访问频率', chart_ttl_seconds: 21600, quote_batch_size_comment: '单次报价批量 symbol 数,建议 3~5', quote_batch_size: 4, quote_min_interval_ms_comment: '报价请求最小间隔毫秒,避免探针触发限频', quote_min_interval_ms: 2500, chart_min_interval_ms_comment: 'K线请求最小间隔毫秒,避免探针触发限频', chart_min_interval_ms: 2500 }, watchlist: [], threshold_percent: -2, comex_gold_min_price: 4800, comex_gold_max_price: 5500, email: { enabled: false, from: "", to: [], smtp: { host: "", port: 465, secure: true, auth: { user: "", pass: "" } } }, weekly_api_base: defaultBase, weekly_key: "", weekly_sync_enabled: true, calendar_api_base: defaultCalendar, calendar_sync_enabled: true }, null, 2)) } db.exec( `CREATE TABLE IF NOT EXISTS market_quotes ( id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT, symbol TEXT, source TEXT, name TEXT, price REAL, prev_close REAL, change REAL, change_percent REAL, captured_at TEXT DEFAULT (datetime('now')) );` ) const insertQuote = db.prepare( `INSERT INTO market_quotes (label, symbol, source, name, price, prev_close, change, change_percent) VALUES (@label, @symbol, @source, @name, @price, @prevClose, @change, @change_percent)` ) const queryByDate = db.prepare( `SELECT * FROM market_quotes WHERE date(captured_at)=date(?) ORDER BY label` ) const getMarketState = db.prepare( `SELECT value FROM market_state WHERE key = ?` ) const upsertMarketState = 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 getConfig = () => JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) const SKILLS_ONLY_WATCHLIST_ERROR = 'markets 配置错误:skills_only_mode=true 时禁止使用 watchlist,请在 src/server/market_skills 下新增或修改 skill' const assertMarketsConfigInvariant = cfg => { const skillsOnlyMode = cfg && cfg.skills_only_mode !== false const watchlist = cfg && Array.isArray(cfg.watchlist) ? cfg.watchlist : [] if (skillsOnlyMode && watchlist.length > 0) throw new Error(SKILLS_ONLY_WATCHLIST_ERROR) } const setConfig = cfg => { assertMarketsConfigInvariant(cfg) fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2)) } const normCode = s => { const x = String(s).toLowerCase() if (x === '000001' || x === 'sh000001') return 'sh000001' if (x === '399001' || x === 'sz399001') return 'sz399001' if (x === '399006' || x === 'sz399006') return 'sz399006' if (x === '000300' || x === 'sh000300') return 'sh000300' if (x === '899050' || x.startsWith('bj899050')) return 'bj899050' if (x === '000688' || x === 'sh000688') return 'sh000688' if (x === '^ixic' || x === '.ixic' || x === 'ixic' || x === 'gb_ixic') return 'gb_ixic' if (x === '^gspc' || x === '.inx' || x === 'inx' || x === 'gb_inx') return 'gb_inx' return s } const normalizeWatchlist = list => (list || []).map(w => ({ label: w.label || w.symbol, symbol: normCode(w.symbol || w.code || ''), source: w.source || 'sina', url: w.url, displaySymbol: w.displaySymbol, card_type: w.card_type, card_order: Number.isFinite(Number(w.card_order)) ? Number(w.card_order) : undefined, market_status: w.market_status })) const resolveEastmoneyUrl = (symbol, url) => { if (url) return url const s = String(symbol || '') const lower = s.toLowerCase() if (lower.startsWith('zs')) return `https://quote.eastmoney.com/gb/${s}.html` if (lower.startsWith('us')) return `https://quote.eastmoney.com/us/${s.slice(2)}.html` if (lower.startsWith('hk')) return `https://quote.eastmoney.com/hk/${s}.html` return `https://quote.eastmoney.com/${s}.html` } const fetchEastmoneyQuote = async (url, symbol, nameFallback) => { const secidMap = { zsn225: '100.N225', zsgdaxi: '100.GDAXI', usnfty: '105.NFTY', zskospi: '100.KS11', zstwii: '100.TWII' } let secid = secidMap[String(symbol || '').toLowerCase()] || null if (!secid && url) { let html = '' try { const res = await axios.get(url, { timeout: 10000, validateStatus: () => true, headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://quote.eastmoney.com/' } }) if (!res || res.status >= 400) return null html = String(res.data || '') } catch (e) { return null } let m = html.match(/"secid"\s*:\s*"([0-9]+\.[^"]+)"/) || html.match(/secid\s*=\s*["']([0-9]+\.[^"']+)["']/) if (!secid) { const m2 = html.match(/quoteId\s*[:=]\s*["']([0-9]+\.[^"']+)["']/i) || html.match(/securityId\s*[:=]\s*["']([0-9]+\.[^"']+)["']/i) if (m2) secid = m2[1] } if (!secid) { const mMarket = html.match(/"market"\s*:\s*(\d+)/) || html.match(/market\s*=\s*(\d+)/) const mCode = html.match(/"code"\s*:\s*"([^"]+)"/) || html.match(/code\s*=\s*["']([^"']+)["']/) if (mMarket && mCode) secid = `${mMarket[1]}.${mCode[1]}` } } if (!secid) return null try { const api = `https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=${encodeURIComponent(secid)}&fields1=f1,f2,f3&fields2=f51,f52,f53,f54,f55,f56,f57,f58&klt=101&fqt=0&end=20500101&lmt=2` const resp = await axios.get(api, { timeout: 10000, validateStatus: () => true, headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://quote.eastmoney.com/' } }) const data = resp && resp.data && resp.data.data const klines = data && Array.isArray(data.klines) ? data.klines : [] if (!klines.length) return null const parseLine = (line) => String(line || '').split(',') const last = parseLine(klines[klines.length - 1]) const prev = klines.length > 1 ? parseLine(klines[klines.length - 2]) : null const last_trade_date = String(last[0] || '').trim() const price = Number(last[2]) const prevClose = prev ? Number(prev[2]) : NaN const pct = Number(last[7]) if (!Number.isFinite(price) || price <= 0) return null const hasPrev = Number.isFinite(prevClose) && prevClose > 0 const change = hasPrev ? price - prevClose : (Number.isFinite(pct) ? (price * pct) / (100 + pct) : 0) const change_percent = Number.isFinite(pct) ? pct : (hasPrev ? (change / prevClose) * 100 : 0) const captured_at = new Date().toISOString().replace('T', ' ').slice(0, 19) return { symbol, name: data.name || nameFallback || symbol, price, prevClose: hasPrev ? prevClose : (change_percent ? price / (1 + change_percent / 100) : 0), change, change_percent, last_trade_date, captured_at, source: 'eastmoney' } } catch (e) { return null } } const fetchAll = async () => { const trace = [] const pushTrace = (stage, detail = {}) => { trace.push({ time: new Date().toISOString(), stage, ...detail }) } const cfgRaw = getConfig() assertMarketsConfigInvariant(cfgRaw) const skillsOnlyMode = cfgRaw.skills_only_mode !== false const rawWatchlist = Array.isArray(cfgRaw.watchlist) ? cfgRaw.watchlist : [] const watchlist = skillsOnlyMode ? [] : normalizeWatchlist(rawWatchlist) pushTrace('markets.fetch.begin', { skills_only_mode: skillsOnlyMode, watchlist_count: watchlist.length }) const eastmoneyItems = watchlist.filter(w => String(w.source || '').toLowerCase() === 'eastmoney') const tencentMap = { 'sh000001': 'sh000001', 'sz399001': 'sz399001', 'sz399006': 'sz399006', 'sh000300': 'sh000300', 'sh000688': 'sh000688', 'bj899050': 'bj899050', 'rt_hkHSI': 'hkHSI', 'rt_hk03153': 'hk03153', 'gb_ixic': 'usIXIC', 'gb_inx': 'usINX', 'gb_inda': 'usINDA', 'gb_ewg': 'usEWG', 'hf_GC': 'hf_GC', } let tencent = [] const symbolsTencent = watchlist.filter(w => tencentMap[w.symbol]).map(w => tencentMap[w.symbol]) if (symbolsTencent.length > 0) { try { const tq = await fetchTencent(symbolsTencent) const rev = Object.entries(tencentMap).reduce((acc,[k,v]) => { acc[v]=k; return acc }, {}) tencent = tq.map(q => ({ symbol: rev[q.symbol] || q.symbol, name: q.name, price: q.price, prevClose: q.prevClose, change: q.change, change_percent: q.change_percent, last_trade_date: q.last_trade_date, source: 'tencent' })) logJSON('markets.source.status', { tencent: tencent.length }, 'markets') pushTrace('markets.fetch.tencent.ok', { requested: symbolsTencent.length, count: tencent.length }) } catch (e) { logJSON('markets.source.error', { source: 'tencent', error: String(e.message || e) }, 'markets') } } const sinaSymbols = watchlist.map(w => w.symbol) let sina = [] try { sina = await fetchSina(sinaSymbols) } catch (e) { logJSON('markets.source.error', { source: 'sina', error: String(e.message || e) }, 'markets') } logJSON('markets.source.status', { sina: sina.length }, 'markets') pushTrace('markets.fetch.sina.done', { requested: sinaSymbols.length, count: sina.length }) // Fallback for US indices/ETF via Yahoo when Sina blocked const yahooMap = { 'gb_inda': 'INDA', 'gb_ewg': 'EWG' } const needYahoo = watchlist .filter(w => yahooMap[w.symbol]) .filter(w => !sina.find(r => r.symbol === w.symbol) && !tencent.find(r => r.symbol === w.symbol)) let yahoo = [] if (needYahoo.length > 0) { try { const symbolsYahoo = needYahoo.map(w => yahooMap[w.symbol]) const yq = await fetchYahoo(symbolsYahoo, { trace }) yahoo = yq.map(q => { // reverse-map to our symbol keys const pair = Object.entries(yahooMap).find(([k, v]) => v === q.symbol) const ourSymbol = pair ? pair[0] : q.symbol return { symbol: ourSymbol, name: q.name, price: q.price, prevClose: q.prevClose, change: q.change, change_percent: q.change_percent, last_trade_date: q.last_trade_date, source: q.source || 'yahoo_probe' } }) logJSON('markets.source.status', { yahoo: yahoo.length }, 'markets') pushTrace('markets.fetch.yahoo.done', { requested: symbolsYahoo.length, count: yahoo.length }) } catch (e) { pushTrace('markets.fetch.yahoo.error', { error: String(e.message || e) }) logJSON('markets.source.error', { source: 'yahoo', error: String(e.message || e) }, 'markets') } } let eastmoney = [] if (eastmoneyItems.length > 0) { for (const w of eastmoneyItems) { const url = resolveEastmoneyUrl(w.symbol, w.url) const item = await fetchEastmoneyQuote(url, w.symbol, w.label || w.symbol) if (item) eastmoney.push(item) } } const merged = [] for (const w of watchlist) { let q = null if (String(w.source || '').toLowerCase() === 'eastmoney') { q = eastmoney.find(r => r.symbol === w.symbol) } if (!q) q = tencent.find(r => r.symbol === w.symbol) if (!q) q = sina.find(r => r.symbol === w.symbol) if (!q) q = yahoo.find(r => r.symbol === w.symbol) if (w.symbol === 'hf_GC' && (!q || !Number.isFinite(q.price) || q.price <= 0)) { try { const em = await fetchEastmoneyComexGold() if (em) q = em } catch (e) { logJSON('markets.source.error', { source: 'eastmoney', error: String(e.message || e) }, 'markets') } } if (q) merged.push({ label: w.label, ...q, url: w.url || resolveEastmoneyUrl(w.symbol, w.url), displaySymbol: w.displaySymbol || w.symbol, card_type: w.card_type, card_order: w.card_order, market_status: w.market_status || q.market_status }) } for (const m of merged) insertQuote.run(m) // Merge with Skill Manager data const skillData = getLatestData() const skillMap = new Map() for (const s of skillData) { if (s && s.symbol) skillMap.set(String(s.symbol).toLowerCase(), s) } const final = [] const seen = new Set() // Process global items (replace with skill data if available) for (const m of merged) { const k = String(m.symbol).toLowerCase() if (skillMap.has(k)) { final.push(skillMap.get(k)) } else { final.push(m) } seen.add(k) } // Append remaining skill items for (const [k, s] of skillMap) { if (!seen.has(k)) { final.push(s) seen.add(k) } } const sourceCounts = final.reduce((acc, item) => { const key = String(item && item.source || 'unknown') acc[key] = (acc[key] || 0) + 1 return acc }, {}) // #region debug-point E:fetchall-final ;(() => { try { const p = path.join(process.cwd(), '.dbg', 'markets-yahoo-429.env'); let u = 'http://127.0.0.1:7777/event', s = 'markets-yahoo-429'; try { const e = fs.readFileSync(p, 'utf8'); u = (e.match(/DEBUG_SERVER_URL=(.+)/) || [])[1] || u; s = (e.match(/DEBUG_SESSION_ID=(.+)/) || [])[1] || s } catch {} const overseas = final.filter(item => String(item && item.card_type || '').toLowerCase() === 'overseas'); fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: s, runId: 'post-fix', hypothesisId: 'E', location: 'markets.js:fetchAll:final', msg: '[DEBUG] markets fetchAll final summary', data: { merged_count: merged.length, final_count: final.length, skill_count: skillData.length, overseas_count: overseas.length, overseas_symbols: overseas.map(item => item.symbol), source_counts: sourceCounts }, ts: Date.now() }) }).catch(() => {}) } catch {} })() // #endregion pushTrace('markets.fetch.end', { merged_count: merged.length, final_count: final.length, source_counts: sourceCounts, skill_count: skillData.length }) return { quotes: final, trace: { events: trace, summary: { skills_only_mode: skillsOnlyMode, watchlist_count: watchlist.length, skill_count: skillData.length, merged_count: merged.length, final_count: final.length, source_counts: sourceCounts } } } } const brief = s => { try { return String(s||'').replace(/\s+/g,' ').slice(0,500) } catch { return '' } } const getWeeklyCfg = () => { const cfg = getConfig() const port = process.env.PORT || 5050 const base = cfg.weekly_api_base || `http://localhost:${port}/api/weekly` const key = String(cfg.weekly_key || process.env.SYSTEM_AUTH_PASS_93220 || '') const enabled = cfg.weekly_sync_enabled !== false return { base, key, enabled } } const getCalendarCfg = () => { const cfg = getConfig() const port = process.env.PORT || 5050 const base = cfg.calendar_api_base || `http://localhost:${port}/api/calendar/public` const enabled = cfg.calendar_sync_enabled !== false return { base, enabled } } const normalizeTradeDate = value => { const text = String(value || '').trim() const match = text.match(/^(\d{4}-\d{2}-\d{2})/) return match ? match[1] : '' } const formatDateInTimeZone = (date, timeZone) => { try { const parts = new Intl.DateTimeFormat('sv-SE', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(date) const pick = type => parts.find(item => item.type === type)?.value || '00' return `${pick('year')}-${pick('month')}-${pick('day')}` } catch { return '' } } const addDaysToDateStr = (dateStr, offsetDays) => { const m = String(dateStr || '').match(/^(\d{4})-(\d{2})-(\d{2})$/) if (!m) return '' const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))) d.setUTCDate(d.getUTCDate() + offsetDays) const pad = n => String(n).padStart(2, '0') return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}` } const getMarketLocalYesterday = q => { const timezone = String((((q || {}).market_status) || {}).timezone || '').trim() if (!timezone) return '' const localToday = formatDateInTimeZone(new Date(), timezone) if (!localToday) return '' return addDaysToDateStr(localToday, -1) } const isChinaSecurityQuote = q => { const sym = String((q && q.symbol) || '').toLowerCase() const timezone = String((((q || {}).market_status) || {}).timezone || '').trim() if (timezone !== 'Asia/Shanghai') return false return /^(sh|sz|bj)\d{6}$/.test(sym) || /^sz399\d{3}$/.test(sym) } const getChinaCalendarDay = async (dateStr, calendarCache) => { const safeDate = normalizeTradeDate(dateStr) if (!safeDate) return null const monthKey = safeDate.slice(0, 7) if (!monthKey) return null if (!calendarCache.has(monthKey)) { try { const payload = await securityCalendar.getMonthCalendar(monthKey, { force_refresh: false }) calendarCache.set(monthKey, payload) } catch { calendarCache.set(monthKey, null) } } const payload = calendarCache.get(monthKey) const days = payload && Array.isArray(payload.days) ? payload.days : [] return days.find(day => String(day && day.date || '') === safeDate) || null } const shouldNotifyByLastTradeDate = async (q, calendarCache) => { const lastTradeDate = normalizeTradeDate(q && q.last_trade_date) if (!lastTradeDate) return true const marketYesterday = getMarketLocalYesterday(q) if (!marketYesterday) return true return lastTradeDate === marketYesterday } const filterNotifyCandidates = async quotes => { const passed = [] const skipped = [] const calendarCache = new Map() for (const q of quotes || []) { const lastTradeDate = normalizeTradeDate(q && q.last_trade_date) const marketYesterday = getMarketLocalYesterday(q) let allow = true let skipReason = '' if (lastTradeDate) { allow = !marketYesterday || lastTradeDate === marketYesterday if (!allow) skipReason = 'last_trade_date_not_yesterday' } else if (isChinaSecurityQuote(q) && marketYesterday) { const calendarDay = await getChinaCalendarDay(marketYesterday, calendarCache) if (calendarDay && calendarDay.is_trading_day === false) { allow = false skipReason = 'china_security_closed_yesterday' } } if (allow) passed.push(q) else skipped.push({ symbol: q && q.symbol, label: q && q.label, last_trade_date: lastTradeDate, market_yesterday: marketYesterday, reason: skipReason }) } return { passed, skipped } } const isComexGold = q => { if (!q) return false const sym = String(q.symbol || '').toLowerCase() const label = String(q.label || '') const name = String(q.name || '') if (sym === 'hf_gc') return true if (label.includes('COMEX黄金')) return true if (/comex/i.test(name) && /gold|黄金/.test(name)) return true return false } 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 .filter(isComexGold) .filter(q => typeof q.price === 'number' && (q.price <= goldMin || q.price >= goldMax)) const badMap = new Map() for (const q of badCommon) badMap.set(q.symbol, q) for (const q of badGoldPrice) badMap.set(q.symbol, q) const rawBad = Array.from(badMap.values()) const { passed: bad, skipped } = await filterNotifyCandidates(rawBad) if (skipped.length > 0) { logJSON('markets.notify.skip.closed_yesterday', { count: skipped.length, items: skipped }, 'markets') } if (bad.length === 0) return false const subject = `市场下跌预警 ${bad.map(b => b.label).join(', ')}` const lineTs = fmtCNDateTimeSrv(new Date()) const body = bad.map(b => `【${lineTs}】 ${b.label} ${b.symbol} src ${b.source} ${Number.isFinite(b.change_percent) ? b.change_percent.toFixed(2) : '0.00'}% price ${b.price}`).join('\n') if (cfg.email && cfg.email.enabled) { try { const t = nodemailer.createTransport(cfg.email.smtp) await t.sendMail({ from: cfg.email.from, to: cfg.email.to.join(','), subject, text: body }) try { insertEmailLog.run({ skill: 'markets_notify', alert_id: subject, subject, status: 'sent', error_message: '' }) } catch {} } catch (e) { const errMsg = String(e.message || e) try { insertEmailLog.run({ skill: 'markets_notify', alert_id: subject, subject, status: 'failed', error_message: errMsg }) } catch {} logJSON('markets.email.send.error', { code: e && e.code, message: errMsg }, 'markets') } } try { const api = getWeeklyCfg() if (api.enabled) { const now = fmtCNParts() const date = now.dateStr const idKey = `${date}-${String(now.hour).padStart(2,'0')}` const payload = { key: api.key, source: 'markets', external_id: `markets-alert-${idKey}`, title: subject, content: body, status_code: 'new', status_label: '📅本周新增', date } const url = `${api.base}/ext/event` logJSON('markets.weekly.push.try', { url, date, count: bad.length }, 'markets') const resp = await axios.post(url, payload, { timeout: 15000, headers: { 'Content-Type': 'application/json' } }) logJSON('markets.weekly.push.resp', { status: resp && resp.status, data_ok: !!(resp && resp.data && resp.data.ok), data: brief((resp && resp.data) ? JSON.stringify(resp.data) : '') }, 'markets') } } catch (e) { const status = e && e.response ? e.response.status : null const data = e && e.response ? e.response.data : null logJSON('markets.weekly.push.error', { status, code: e && e.code, message: String(e.message||e), data: typeof data==='string' ? brief(data) : brief(data ? JSON.stringify(data) : '') }, 'markets') } try { const cal = getCalendarCfg() if (cal.enabled) { const now = fmtCNParts() const dateStr = now.dateStr const hhmm = '15:00' const tsLocal = `${dateStr} ${hhmm}` const calendarUrl = `${cal.base}/events` const ev = { title: subject, content: body, start: tsLocal, end: tsLocal, remindAt: tsLocal, allDay: 0 } logJSON('markets.calendar.push.try', { url: calendarUrl, start: tsLocal }, 'markets') const cresp = await axios.post(calendarUrl, ev, { timeout: 15000, headers: { 'Content-Type': 'application/json' } }) logJSON('markets.calendar.push.resp', { status: cresp && cresp.status, ok: !!(cresp && cresp.data && cresp.data.ok) }, 'markets') } } catch (e) { const status = e && e.response ? e.response.status : null const data = e && e.response ? e.response.data : null logJSON('markets.calendar.push.error', { status, code: e && e.code, message: String(e.message || e), data: typeof data==='string' ? brief(data) : brief(data ? JSON.stringify(data) : '') }, 'markets') } return true } const fmtCNDateTimeSrv = (d) => { const date = d instanceof Date ? d : new Date() try { const s = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date) return s.replace(/\//g, '-').replace(/\u200E/g, '') } catch { const pad = n => String(n).padStart(2, '0') const y = date.getFullYear(), m = pad(date.getMonth()+1), day = pad(date.getDate()), hh = pad(date.getHours()), mm = pad(date.getMinutes()), ss = pad(date.getSeconds()) return `${y}-${m}-${day} ${hh}:${mm}:${ss}` } } const fmtCNParts = () => { try { const d = new Date() const parts = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', hour12:false }).formatToParts(d) const get = t => parts.find(p => p.type === t)?.value || '00' const y = get('year'), m = get('month'), day = get('day'), hour = get('hour'), minute = get('minute') return { dateStr: `${y}-${m}-${day}`, hour: parseInt(hour,10), minute: parseInt(minute,10) } } catch { const d = new Date(); return { dateStr: d.toISOString().slice(0,10), hour: d.getHours(), minute: d.getMinutes() } } } const getTZOffset = (timeZone) => { try { const now = new Date(); const iso = now.toLocaleString('en-US', { timeZone, timeZoneName: 'longOffset' }); const match = iso.match(/GMT([+-]\d{2}):?(\d{2})?/); if (!match) return 0; const sign = match[1][0] === '-' ? -1 : 1; const h = parseInt(match[1].substring(1)); const m = match[2] ? parseInt(match[2]) : 0; return sign * (h + m / 60); } catch (e) { return 0; } }; const getMarketStatus = (config) => { if (!config || !config.timezone || !config.sessions) return null; const now = new Date(); const targetOffset = getTZOffset(config.timezone); const bjOffset = 8; // Beijing is UTC+8 const diffHours = bjOffset - targetOffset; // Check if Open (using target local time) const localStr = now.toLocaleString('en-US', { timeZone: config.timezone, hour12: false, hour: '2-digit', minute: '2-digit' }); const [h, m] = localStr.split(':').map(Number); const currentMins = h * 60 + m; let isOpen = false; let rangesBJ = []; for (const session of config.sessions) { const [sh, sm] = session.start.split(':').map(Number); const [eh, em] = session.end.split(':').map(Number); let startMins = sh * 60 + sm; let endMins = eh * 60 + em; // Check Status if (endMins < startMins) { // Cross midnight if (currentMins >= startMins || currentMins < endMins) isOpen = true; } else { if (currentMins >= startMins && currentMins < endMins) isOpen = true; } // Convert to BJ Time const toBJ = (hh, mm) => { let totalMins = (hh * 60 + mm) + (diffHours * 60); while (totalMins >= 1440) totalMins -= 1440; while (totalMins < 0) totalMins += 1440; const h = Math.floor(totalMins / 60); const m = Math.floor(totalMins % 60); return `${h}:${String(m).padStart(2, '0')}`; }; rangesBJ.push(`${toBJ(sh, sm)}-${toBJ(eh, em)}`); } return { isOpen, label: rangesBJ.join(', '), text: isOpen ? '开市' : '休市' }; }; const getSymbolUrl = (symbol) => { if (!symbol) return 'https://gu.qq.com/' if (symbol.startsWith('us.')) return `https://gu.qq.com/${symbol.replace('.', '')}` return `https://gu.qq.com/${symbol}` } const toFiniteNumber = v => { const n = Number(v) return Number.isFinite(n) ? n : null } const normalizeMove = (row) => { const changeRaw = toFiniteNumber(row && row.change) const prevClose = toFiniteNumber(row && row.prevClose) const changePercentRaw = toFiniteNumber(row && row.change_percent) const hasDerivedPercent = changeRaw !== null && prevClose !== null && prevClose !== 0 const changePercent = hasDerivedPercent ? (changeRaw / prevClose) * 100 : (changePercentRaw !== null ? changePercentRaw : 0) const basis = changeRaw !== null ? changeRaw : changePercent const sign = basis >= 0 ? 1 : -1 const change = changeRaw !== null ? changeRaw : 0 return { change, changePercent, sign } } const fmtSign = n => `${n >= 0 ? '+' : '-'}${Math.abs(n).toFixed(2)}` const buildArchiveHtml = (date, quotes) => { const css = `body{font-family:system-ui,Segoe UI,Arial,sans-serif;margin:0;padding:16px;box-sizing:border-box;width:100%;max-width:none} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;width:100%;margin:0} .card{border:1px solid #e8e8e8;border-radius:12px;padding:12px;display:grid;gap:8px;background:var(--card-bg,#ffffff);box-shadow:0 1px 4px rgba(0,0,0,0.08)} .card-overseas{--card-bg:#FAE5E5} .card-domestic{--card-bg:#F8E0FA} .card-enhanced{--card-bg:#FDF9E2} .card-defensive{--card-bg:#DADAF8} .meta{font-size:12px;color:#8c8c8c} .up{color:#f5222d;font-weight:600} .down{color:#52c41a;font-weight:600} .card-alert{border-color:#ff4d4f} .alert-flag{display:inline-block;padding:2px 8px;font-size:12px;border-radius:12px;background:#ff4d4f;color:#fff;margin-left:8px} a{text-decoration:none}`; const cfg = getConfig(); const threshold = typeof cfg.threshold_percent === 'number' ? cfg.threshold_percent : -2; let cardsHTML = ''; const sorted = [...quotes].sort((a, b) => { const ao = Number.isFinite(a.card_order) ? a.card_order : 9999 const bo = Number.isFinite(b.card_order) ? b.card_order : 9999 if (ao !== bo) return ao - bo const aKey = `${a.label || ''}${a.name || ''}${a.symbol || ''}` const bKey = `${b.label || ''}${b.name || ''}${b.symbol || ''}` return aKey.localeCompare(bKey) }) for (const r of sorted) { let cardCls = 'card'; let isAlert = false; if (r.card_type === 'overseas') cardCls += ' card-overseas'; if (r.card_type === 'domestic') cardCls += ' card-domestic'; if (r.card_type === 'enhanced') cardCls += ' card-enhanced'; if (r.card_type === 'defensive') cardCls += ' card-defensive'; const move = normalizeMove(r) if (move.changePercent <= threshold) { cardCls += ' card-alert'; isAlert = true; } const changePercent = move.changePercent; const changeClass = move.sign >= 0 ? 'up' : 'down'; // Status Logic let statusHtml = ''; const status = getMarketStatus(r.market_status); if (status) { const color = status.isOpen ? '#52c41a' : '#8c8c8c'; statusHtml = ` ● ${status.text} (北时 ${status.label})`; } // Link Logic const linkUrl = r.url || getSymbolUrl(r.symbol); const linkHtml = `🏠`; // Alert Flag const alertHtml = isAlert ? `⚠ 告警` : ''; const price = Number.isFinite(r.price) ? r.price : '0.00'; const change = fmtSign(move.change); cardsHTML += `