const iconv = require('iconv-lite') const { fetchYahoo } = require('../../marketsClient') // State to prevent alert spam // Map const lastAlertTimes = new Map() const parseTencentLine = (line) => { const m = line.match(/^v_(\w+)="([^"]*)/) if (!m) return null const symbol = m[1] const body = m[2] let parts = body.split('~') if (parts.length < 3 && body.includes(',')) { parts = body.split(',') } // Standard Tencent Format (verified for sh/sz/bj/us/hk): // 1: Name // 3: Current Price // 4: Prev Close let name = parts[1] let price = parseFloat(parts[3]) let prevClose = parseFloat(parts[4]) if (isNaN(price) || isNaN(prevClose)) return null const change = price - prevClose const pct = prevClose ? (change / prevClose) * 100 : 0 let captured_at = parts[30] || '' if (captured_at.length === 14 && !captured_at.includes('-')) { captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6') } return { symbol, name, price, prevClose, change, change_percent: pct, captured_at } } const fetchTencent = async (axios, symbols) => { if (!symbols || symbols.length === 0) return [] const url = `http://qt.gtimg.cn/q=${symbols.join(',')}` try { const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true }) if (!res || res.status >= 400) return [] const text = iconv.decode(Buffer.from(res.data), 'gb18030') const lines = String(text || '').split(/;\s*/).filter(Boolean) const out = [] for (const line of lines) { const r = parseTencentLine(line) if (r) out.push({ ...r, source: 'tencent' }) } return out } catch (e) { return [] } } const checkAlerts = (data, config) => { const alerts = [] // Helper to add alert with slot-based cooldown (AM/PM logic) const addAlert = (key, title, message, level = 'warning') => { // Get current Beijing time const d = new Date() const utc = d.getTime() + (d.getTimezoneOffset() * 60000) const bjDate = new Date(utc + (3600000 * 8)) const year = bjDate.getFullYear() const month = String(bjDate.getMonth() + 1).padStart(2, '0') const day = String(bjDate.getDate()).padStart(2, '0') const hour = bjDate.getHours() let slot = '' if (hour >= 0 && hour < 12) slot = 'am' else slot = 'pm' // 12:00 - 23:59 const currentSlotKey = `${year}-${month}-${day}-${slot}` const lastSlotKey = lastAlertTimes.get(key) // Only alert if we haven't alerted in this slot for this specific key if (lastSlotKey !== currentSlotKey) { alerts.push({ id: key, title, message, level }) lastAlertTimes.set(key, currentSlotKey) } } // 1. Drop Alerts (Integer steps: 2%, 3%, 4%...) if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) { // Check integer steps const currentDrop = Math.floor(Math.abs(data.change_percent)) const key = `drop_${currentDrop}` if (currentDrop >= Math.abs(config.alerts.change_percent_down)) { addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`) } } // 2. Rise Alerts (Integer steps: 2%, 3%, 4%...) if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) { const currentRise = Math.floor(data.change_percent) const key = `rise_${currentRise}` if (currentRise >= config.alerts.change_percent_up) { addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`) } } return alerts } module.exports = { run: async (context, config) => { const { axios, logJSON } = context let item = null if (String(config.source || '').toLowerCase() === 'yahoo_probe') { const quotes = await fetchYahoo([config.symbol]) item = quotes.find(q => q.symbol === config.symbol) } else { const quotes = await fetchTencent(axios, [config.symbol]) item = quotes.find(q => q.symbol === config.symbol) } // Fallback: try Tencent if Yahoo failed, or vice versa if (!item) { if (String(config.source || '').toLowerCase() === 'yahoo_probe') { const fb = await fetchTencent(axios, [config.symbol]) item = fb.find(q => q.symbol === config.symbol) } else { const fb = await fetchYahoo([config.symbol]) item = fb.find(q => q.symbol === config.symbol) } } if (!item) { logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills') return null } // Prepare data for DB const data = [{ label: config.name, symbol: config.symbol, source: config.source, name: item.name, price: item.price, prevClose: item.prevClose, change: item.change, change_percent: item.change_percent, captured_at: item.captured_at, market_status: config.market_status }] // Check alerts const alerts = checkAlerts(item, config) return { data, alerts } } }