feat: 初始提交(仅核心代码,已排除大文件)
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user