157 lines
5.1 KiB
JavaScript
157 lines
5.1 KiB
JavaScript
const iconv = require('iconv-lite')
|
|
const { fetchYahoo } = require('../../marketsClient')
|
|
|
|
// State to prevent alert spam
|
|
// Map<alertKey, lastAlertTimestamp>
|
|
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 }
|
|
}
|
|
}
|