chore: 初始提交 - 移除所有硬编码凭据,统一从环境变量读取

This commit is contained in:
yangxiangyuan
2026-06-11 17:10:58 +08:00
commit c223c79b36
1699 changed files with 313940 additions and 0 deletions
+668
View File
@@ -0,0 +1,668 @@
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const Database = require('better-sqlite3')
const { logJSON } = require('./logger')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'ai-lib.db')
const db = new Database(dbPath)
db.pragma('foreign_keys = ON')
db.pragma('synchronous = NORMAL')
db.pragma('journal_mode = WAL')
const cfgPath = path.join(process.cwd(), 'config', 'ai-lib.json')
if (!fs.existsSync(path.dirname(cfgPath))) fs.mkdirSync(path.dirname(cfgPath), { recursive: true })
if (!fs.existsSync(cfgPath)) fs.writeFileSync(cfgPath, JSON.stringify({ machine_salt: crypto.randomBytes(16).toString('hex') }, null, 2))
let _cfg = null
const getConfig = () => {
if (!_cfg) _cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
return _cfg
}
const passphrase = process.env.AILIB_PASSPHRASE || process.env.SYSTEM_AUTH_PASS_93220 || ''
let _key = null
const deriveKey = () => {
if (!_key) {
const salt = Buffer.from(getConfig().machine_salt || '', 'utf-8')
_key = crypto.scryptSync(passphrase, salt, 32)
}
return _key
}
const enc = (plain) => {
const key = deriveKey()
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
const ct = Buffer.concat([cipher.update(Buffer.from(String(plain), 'utf-8')), cipher.final()])
const tag = cipher.getAuthTag()
return { ct: ct.toString('base64'), iv: iv.toString('base64'), tag: tag.toString('base64') }
}
const dec = (ct, iv, tag) => {
if (!ct) return ''
try {
const key = deriveKey()
const _iv = Buffer.from(String(iv||''), 'base64')
const _tag = Buffer.from(String(tag||''), 'base64')
const decipher = crypto.createDecipheriv('aes-256-gcm', key, _iv)
decipher.setAuthTag(_tag)
const out = Buffer.concat([decipher.update(Buffer.from(String(ct), 'base64')), decipher.final()])
return out.toString('utf-8')
} catch (e) {
console.error('Decryption failed:', e.message)
return '[Decryption Failed]'
}
}
db.exec(
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
pass_salt TEXT NOT NULL,
pass_hash TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS systems (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name_enc TEXT,
name_iv TEXT,
name_tag TEXT,
intro_enc TEXT,
intro_iv TEXT,
intro_tag TEXT,
major_category_enc TEXT,
major_category_iv TEXT,
major_category_tag TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`
)
try {
db.prepare('SELECT major_category_enc FROM systems LIMIT 1').get()
} catch (e) {
if (String(e).includes('no such column')) {
db.exec(`ALTER TABLE systems ADD COLUMN major_category_enc TEXT`)
db.exec(`ALTER TABLE systems ADD COLUMN major_category_iv TEXT`)
db.exec(`ALTER TABLE systems ADD COLUMN major_category_tag TEXT`)
}
}
db.exec(
`CREATE TABLE IF NOT EXISTS modules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
system_id INTEGER NOT NULL,
name_enc TEXT, name_iv TEXT, name_tag TEXT,
desc_enc TEXT, desc_iv TEXT, desc_tag TEXT,
server_addr_enc TEXT, server_addr_iv TEXT, server_addr_tag TEXT,
server_port INTEGER,
code_type_enc TEXT, code_type_iv TEXT, code_type_tag TEXT,
deploy_method_enc TEXT, deploy_method_iv TEXT, deploy_method_tag TEXT,
software_versions_enc TEXT, software_versions_iv TEXT, software_versions_tag TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY(system_id) REFERENCES systems(id) ON DELETE CASCADE
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
module_id INTEGER NOT NULL,
note_type TEXT NOT NULL,
content_enc TEXT, content_iv TEXT, content_tag TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY(module_id) REFERENCES modules(id) ON DELETE CASCADE
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
filename_enc TEXT, filename_iv TEXT, filename_tag TEXT,
mime_enc TEXT, mime_iv TEXT, mime_tag TEXT,
size INTEGER,
store_enc TEXT, store_iv TEXT, store_tag TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE
);`
)
db.exec(`CREATE INDEX IF NOT EXISTS idx_modules_system_id ON modules(system_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_notes_module_id ON notes(module_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_attachments_note_id ON attachments(note_id)`)
const authCookieName = 'ai_lib_auth'
const login = (u, p) => {
const row = db.prepare(`SELECT * FROM users WHERE username=?`).get(u)
if (!row) return { ok:false, error:'user_not_found' }
const salt = Buffer.from(row.pass_salt, 'base64')
const hash = crypto.scryptSync(p, salt, 32).toString('base64')
if (hash !== row.pass_hash) return { ok:false, error:'wrong_password' }
return { ok:true, user: { id: row.id, username: row.username } }
}
const changePassword = (u, oldp, newp) => {
const r = login(u, oldp)
if (!r.ok) return r
const salt = crypto.randomBytes(16)
const hash = crypto.scryptSync(newp, salt, 32)
db.prepare(`UPDATE users SET pass_salt=?, pass_hash=? WHERE id=?`).run(salt.toString('base64'), hash.toString('base64'), r.user.id)
return { ok:true }
}
(() => {
try {
const count = db.prepare(`SELECT COUNT(*) AS c FROM users`).get().c
if (count === 0) {
const defaultPass = process.env.AILIB_DEFAULT_PASSWORD || process.env.LOCK_DEFAULT_PASSWORD || ''
const salt = crypto.randomBytes(16)
const hash = crypto.scryptSync(defaultPass, salt, 32)
db.prepare(`INSERT INTO users (username, pass_salt, pass_hash) VALUES (?, ?, ?)`).run('dev_al', salt.toString('base64'), hash.toString('base64'))
}
} catch {}
})()
const sign = (obj) => {
const key = deriveKey()
const mac = crypto.createHmac('sha256', key)
mac.update(JSON.stringify(obj))
return mac.digest('base64')
}
const verify = (payload, sig) => {
const key = deriveKey()
const mac = crypto.createHmac('sha256', key)
mac.update(JSON.stringify(payload))
const expect = mac.digest('base64')
return expect === sig
}
const attachmentsDir = path.join(process.cwd(), 'uploads', 'ai-lib', 'attachments')
if (!fs.existsSync(attachmentsDir)) fs.mkdirSync(attachmentsDir, { recursive: true })
const saveAttachmentChunk = (note_id, filename, mime, buffer) => {
const originalName = String(filename||'file.bin')
const efn = enc(originalName)
const emime = enc(String(mime||'application/octet-stream'))
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv('aes-256-gcm', deriveKey(), iv)
const ct = Buffer.concat([cipher.update(buffer), cipher.final()])
const tag = cipher.getAuthTag()
const outName = `${Date.now()}-${Math.random().toString(36).slice(2)}.bin`
const full = path.join(attachmentsDir, outName)
fs.writeFileSync(full, Buffer.concat([iv, tag, ct]))
const estore = enc(full)
const info = db.prepare(`INSERT INTO attachments (note_id, filename_enc, filename_iv, filename_tag, mime_enc, mime_iv, mime_tag, size, store_enc, store_iv, store_tag) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
parseInt(note_id,10), efn.ct, efn.iv, efn.tag, emime.ct, emime.iv, emime.tag, buffer.length, estore.ct, estore.iv, estore.tag
)
return info.lastInsertRowid
}
const listSystems = () => {
const rows = db.prepare(`SELECT * FROM systems ORDER BY id ASC`).all()
return rows.map(r => ({
id:r.id,
name: dec(r.name_enc,r.name_iv,r.name_tag),
intro: dec(r.intro_enc,r.intro_iv,r.intro_tag),
major_category: dec(r.major_category_enc,r.major_category_iv,r.major_category_tag),
created_at:r.created_at,
updated_at:r.updated_at
}))
}
const createSystem = (payload) => {
const en = enc(payload.name||'')
const ei = enc(payload.intro||'')
const em = enc(payload.major_category||'')
const info = db.prepare(`INSERT INTO systems (name_enc, name_iv, name_tag, intro_enc, intro_iv, intro_tag, major_category_enc, major_category_iv, major_category_tag) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(en.ct, en.iv, en.tag, ei.ct, ei.iv, ei.tag, em.ct, em.iv, em.tag)
return info.lastInsertRowid
}
const updateSystem = (payload) => {
const id = parseInt(payload.id,10)
const en = enc(payload.name||'')
const ei = enc(payload.intro||'')
const em = enc(payload.major_category||'')
db.prepare(`UPDATE systems SET name_enc=?, name_iv=?, name_tag=?, intro_enc=?, intro_iv=?, intro_tag=?, major_category_enc=?, major_category_iv=?, major_category_tag=?, updated_at=datetime('now') WHERE id=?`).run(en.ct, en.iv, en.tag, ei.ct, ei.iv, ei.tag, em.ct, em.iv, em.tag, id)
}
const deleteSystem = (id) => {
db.prepare(`DELETE FROM systems WHERE id=?`).run(parseInt(id,10))
}
const listModules = (system_id) => {
const rows = db.prepare(`SELECT * FROM modules WHERE system_id=? ORDER BY id ASC`).all(parseInt(system_id,10))
return rows.map(r => ({
id:r.id, system_id:r.system_id,
name: dec(r.name_enc,r.name_iv,r.name_tag),
description: dec(r.desc_enc,r.desc_iv,r.desc_tag),
server_addr: dec(r.server_addr_enc,r.server_addr_iv,r.server_addr_tag),
server_port: r.server_port,
code_type: dec(r.code_type_enc,r.code_type_iv,r.code_type_tag),
deploy_method: dec(r.deploy_method_enc,r.deploy_method_iv,r.deploy_method_tag),
software_versions: dec(r.software_versions_enc,r.software_versions_iv,r.software_versions_tag),
created_at:r.created_at, updated_at:r.updated_at
}))
}
const createModule = (payload) => {
const sid = parseInt(payload.system_id,10)
const en = enc(payload.name||'')
const ed = enc(payload.description||'')
const ea = enc(payload.server_addr||'')
const ct = parseInt(payload.server_port||'0',10)
const ec = enc(payload.code_type||'')
const dm = enc(payload.deploy_method||'')
const sv = enc(payload.software_versions||'')
const info = db.prepare(`INSERT INTO modules (system_id, name_enc, name_iv, name_tag, desc_enc, desc_iv, desc_tag, server_addr_enc, server_addr_iv, server_addr_tag, server_port, code_type_enc, code_type_iv, code_type_tag, deploy_method_enc, deploy_method_iv, deploy_method_tag, software_versions_enc, software_versions_iv, software_versions_tag) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(sid, en.ct,en.iv,en.tag, ed.ct,ed.iv,ed.tag, ea.ct,ea.iv,ea.tag, ct, ec.ct,ec.iv,ec.tag, dm.ct,dm.iv,dm.tag, sv.ct,sv.iv,sv.tag)
return info.lastInsertRowid
}
const updateModule = (payload) => {
const id = parseInt(payload.id,10)
const en = enc(payload.name||'')
const ed = enc(payload.description||'')
const ea = enc(payload.server_addr||'')
const ct = parseInt(payload.server_port||'0',10)
const ec = enc(payload.code_type||'')
const dm = enc(payload.deploy_method||'')
const sv = enc(payload.software_versions||'')
db.prepare(`UPDATE modules SET name_enc=?, name_iv=?, name_tag=?, desc_enc=?, desc_iv=?, desc_tag=?, server_addr_enc=?, server_addr_iv=?, server_addr_tag=?, server_port=?, code_type_enc=?, code_type_iv=?, code_type_tag=?, deploy_method_enc=?, deploy_method_iv=?, deploy_method_tag=?, software_versions_enc=?, software_versions_iv=?, software_versions_tag=?, updated_at=datetime('now') WHERE id=?`).run(en.ct,en.iv,en.tag, ed.ct,ed.iv,ed.tag, ea.ct,ea.iv,ea.tag, ct, ec.ct,ec.iv,ec.tag, dm.ct,dm.iv,dm.tag, sv.ct,sv.iv,sv.tag, id)
}
const deleteModule = (id) => {
db.prepare(`DELETE FROM modules WHERE id=?`).run(parseInt(id,10))
}
const listNotes = (module_id) => {
const rows = db.prepare(`SELECT * FROM notes WHERE module_id=? ORDER BY id ASC`).all(parseInt(module_id,10))
return rows.map(r => ({ id:r.id, module_id:r.module_id, note_type:r.note_type, content: dec(r.content_enc,r.content_iv,r.content_tag), created_at:r.created_at, updated_at:r.updated_at }))
}
const createNote = (payload) => {
const mid = parseInt(payload.module_id,10)
const typ = String(payload.note_type||'')
const c = enc(payload.content||'')
const info = db.prepare(`INSERT INTO notes (module_id, note_type, content_enc, content_iv, content_tag) VALUES (?, ?, ?, ?, ?)`).run(mid, typ, c.ct, c.iv, c.tag)
return info.lastInsertRowid
}
const updateNote = (payload) => {
const id = parseInt(payload.id,10)
const typ = String(payload.note_type||'')
const c = enc(payload.content||'')
db.prepare(`UPDATE notes SET note_type=?, content_enc=?, content_iv=?, content_tag=?, updated_at=datetime('now') WHERE id=?`).run(typ, c.ct, c.iv, c.tag, id)
}
const deleteNote = (id) => {
db.prepare(`DELETE FROM notes WHERE id=?`).run(parseInt(id,10))
}
const listAttachments = (note_id) => {
const rows = db.prepare(`SELECT * FROM attachments WHERE note_id=? ORDER BY id ASC`).all(parseInt(note_id,10))
return rows.map(r => ({ id:r.id, note_id:r.note_id, filename: dec(r.filename_enc, r.filename_iv, r.filename_tag), mime: dec(r.mime_enc, r.mime_iv, r.mime_tag), size: r.size, created_at: r.created_at }))
}
const decryptAttachmentBuffer = (row, rawBuffer) => {
if (!row) return null
const filename = dec(row.filename_enc, row.filename_iv, row.filename_tag) || 'file.bin'
const mime = dec(row.mime_enc, row.mime_iv, row.mime_tag) || 'application/octet-stream'
const raw = Buffer.isBuffer(rawBuffer) ? rawBuffer : Buffer.alloc(0)
if (raw.length < 28) return null
const iv = raw.slice(0,12)
const tag = raw.slice(12,28)
const ct = raw.slice(28)
const decipher = crypto.createDecipheriv('aes-256-gcm', deriveKey(), iv)
decipher.setAuthTag(tag)
const data = Buffer.concat([decipher.update(ct), decipher.final()])
return { filename, mime, data }
}
const getAttachmentDataByStoredPath = (storedPath, rawBuffer) => {
const expected = path.resolve(String(storedPath || ''))
if (!expected) return null
const rows = db.prepare(`SELECT * FROM attachments ORDER BY id DESC`).all()
for (const r of rows) {
const full = path.resolve(dec(r.store_enc, r.store_iv, r.store_tag) || '')
if (!full || full !== expected) continue
const fileBuf = Buffer.isBuffer(rawBuffer) ? rawBuffer : (fs.existsSync(full) ? fs.readFileSync(full) : null)
if (!fileBuf) return null
return decryptAttachmentBuffer(r, fileBuf)
}
return null
}
db.exec(
`CREATE TABLE IF NOT EXISTS major_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name_enc TEXT, name_iv TEXT, name_tag TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`
)
try {
const count = db.prepare('SELECT COUNT(*) AS c FROM major_categories').get().c
if (count === 0) {
const defaults = ['Trae研发', '信息化', '智能硬件']
const stmt = db.prepare('INSERT INTO major_categories (name_enc, name_iv, name_tag) VALUES (?, ?, ?)')
defaults.forEach(name => {
const en = enc(name)
stmt.run(en.ct, en.iv, en.tag)
})
}
} catch (e) {
console.error('Init major_categories failed:', e)
}
const listMajorCategories = () => {
const rows = db.prepare('SELECT * FROM major_categories ORDER BY id ASC').all()
return rows.map(r => ({
id: r.id,
name: dec(r.name_enc, r.name_iv, r.name_tag),
created_at: r.created_at,
updated_at: r.updated_at
}))
}
const createMajorCategory = (name) => {
const en = enc(name)
const info = db.prepare('INSERT INTO major_categories (name_enc, name_iv, name_tag) VALUES (?, ?, ?)').run(en.ct, en.iv, en.tag)
return info.lastInsertRowid
}
const updateMajorCategory = (id, name) => {
const en = enc(name)
db.prepare("UPDATE major_categories SET name_enc=?, name_iv=?, name_tag=?, updated_at=datetime('now') WHERE id=?").run(en.ct, en.iv, en.tag, parseInt(id, 10))
}
const deleteMajorCategory = (id) => {
db.prepare('DELETE FROM major_categories WHERE id=?').run(parseInt(id, 10))
}
const bindRoutes = (app) => {
const parseCookie = (cookieStr) => {
const out = {}
String(cookieStr||'').split(/;\s*/).forEach(p=>{ const idx=p.indexOf('='); if (idx>0) out[p.slice(0,idx)] = decodeURIComponent(p.slice(idx+1)) })
return out
}
const mustAuth = (req, res) => {
try {
const cookies = parseCookie(req.headers.cookie||'')
const raw = cookies[authCookieName]
if (!raw) return false
let obj = null
try { obj = JSON.parse(raw) } catch {}
if (!obj || !obj.payload || !obj.sig) return false
return verify(obj.payload, obj.sig)
} catch { return false }
}
app.post('/api/ai-lib/auth/login', (req, res) => {
const body = req.body||{}
const r = login(String(body.username||''), String(body.password||''))
if (!r.ok) return res.status(401).json(r)
const payload = { uid: r.user.id, u: r.user.username, iat: Date.now() }
const sig = sign(payload)
res.cookie(authCookieName, JSON.stringify({ payload, sig }), { httpOnly: true, sameSite:'lax', maxAge: 86400000 })
logJSON('ai-lib.auth.login', { username: r.user.username }, 'ai-lib')
res.json({ ok:true, user: r.user })
})
app.post('/api/ai-lib/auth/logout', (req, res) => {
res.clearCookie(authCookieName)
res.json({ ok:true })
})
app.get('/api/ai-lib/auth/local_creds', (req, res) => {
try {
const os = require('os')
const userProfile = process.env.USERPROFILE || process.env.HOME || os.homedir()
const candidates = [path.join(userProfile, 'Toolbox_local_creds.env.local')]
const usersRoot = path.join(path.parse(userProfile).root, 'Users')
try {
if (fs.existsSync(usersRoot)) {
fs.readdirSync(usersRoot).forEach(name => {
const dir = path.join(usersRoot, name)
if (dir !== userProfile && fs.existsSync(dir)) candidates.push(path.join(dir, 'Toolbox_local_creds.env.local'))
})
}
} catch {}
let content = null
for (const p of candidates) { if (fs.existsSync(p)) { content = fs.readFileSync(p, 'utf-8'); break } }
if (!content) return res.json({ ok: true, creds: null })
const creds = {}
content.split(/\r?\n/).forEach(line => {
const t = line.trim()
if (!t || t.startsWith('#')) return
const idx = t.indexOf('=')
if (idx > 0) creds[t.slice(0, idx).trim()] = t.slice(idx + 1).trim()
})
const serverToken = String(creds.TOOLBOX_LOCAL_CREDS_TOKEN || '')
if (serverToken) {
const clientToken = String(req.query.token || '')
if (clientToken !== serverToken) return res.json({ ok: true, creds: null })
}
const u = creds.AILIB_USERNAME || ''
const p = creds.AILIB_PASSWORD || ''
if (!u && !p) return res.json({ ok: true, creds: null })
res.json({ ok: true, creds: { username: u, password: p } })
} catch { res.json({ ok: true, creds: null }) }
})
app.post('/api/ai-lib/auth/change', (req, res) => {
const body = req.body||{}
const r = changePassword(String(body.username||''), String(body.old_password||''), String(body.new_password||''))
if (!r.ok) return res.status(400).json(r)
res.json({ ok:true })
})
app.get('/api/ai-lib/debug/counts', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' });
try {
const systems = db.prepare(`SELECT COUNT(*) AS c FROM systems`).get().c;
const modules = db.prepare(`SELECT COUNT(*) AS c FROM modules`).get().c;
const notes = db.prepare(`SELECT COUNT(*) AS c FROM notes`).get().c;
const attachments = db.prepare(`SELECT COUNT(*) AS c FROM attachments`).get().c;
res.json({ ok: true, counts: { systems, modules, notes, attachments } });
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) });
}
});
app.get('/api/ai-lib/systems', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
res.json({ ok:true, rows: listSystems() })
})
app.post('/api/ai-lib/systems', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const body = req.body||{}
const name = String(body.name||'').trim()
const intro = String(body.intro||'').trim()
const major_category = String(body.major_category||'').trim()
if (!name || !intro) return res.status(400).json({ ok:false, error:'empty_fields' })
const id = createSystem({ name, intro, major_category })
res.json({ ok:true, id })
})
app.put('/api/ai-lib/systems/:id', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const raw = Object.assign({}, req.body||{}, { id: parseInt(req.params.id,10) })
const name = String(raw.name||'').trim()
const intro = String(raw.intro||'').trim()
const major_category = String(raw.major_category||'').trim()
if (!name || !intro) return res.status(400).json({ ok:false, error:'empty_fields' })
updateSystem({ id: raw.id, name, intro, major_category })
res.json({ ok:true })
})
app.delete('/api/ai-lib/systems/:id', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
deleteSystem(parseInt(req.params.id,10))
res.json({ ok:true })
})
app.get('/api/ai-lib/categories', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
res.setHeader('Cache-Control', 'no-store')
res.json({ ok:true, rows: listMajorCategories() })
})
app.post('/api/ai-lib/categories', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const name = String(req.body.name || '').trim()
if (!name) return res.status(400).json({ ok:false, error:'empty_name' })
const id = createMajorCategory(name)
res.json({ ok:true, id })
})
app.put('/api/ai-lib/categories/:id', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const name = String(req.body.name || '').trim()
if (!name) return res.status(400).json({ ok:false, error:'empty_name' })
updateMajorCategory(req.params.id, name)
res.json({ ok:true })
})
app.delete('/api/ai-lib/categories/:id', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
deleteMajorCategory(req.params.id)
res.json({ ok:true })
})
app.get('/api/ai-lib/modules', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const sid = parseInt(String(req.query.system_id||'0'),10)
res.json({ ok:true, rows: listModules(sid) })
})
app.post('/api/ai-lib/modules', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const b = req.body||{}
const sid = parseInt(b.system_id,10)
const name = String(b.name||'').trim()
const description = String(b.description||'').trim()
const server_addr = String(b.server_addr||'').trim()
const portStr = String(b.server_port||'').trim()
const server_port = parseInt(portStr||'0',10)
const code_type = String(b.code_type||'').trim()
const deploy_method = String(b.deploy_method||'').trim()
const software_versions = String(b.software_versions||'').trim()
if (!sid || !name || !description || !server_addr || !portStr || !(server_port>0) || !code_type || !deploy_method || !software_versions) return res.status(400).json({ ok:false, error:'empty_fields' })
const id = createModule({ system_id: sid, name, description, server_addr, server_port, code_type, deploy_method, software_versions })
res.json({ ok:true, id })
})
app.put('/api/ai-lib/modules/:id', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const r = Object.assign({}, req.body||{}, { id: parseInt(req.params.id,10) })
const name = String(r.name||'').trim()
const description = String(r.description||'').trim()
const server_addr = String(r.server_addr||'').trim()
const portStr = String(r.server_port||'').trim()
const server_port = parseInt(portStr||'0',10)
const code_type = String(r.code_type||'').trim()
const deploy_method = String(r.deploy_method||'').trim()
const software_versions = String(r.software_versions||'').trim()
if (!name || !description || !server_addr || !portStr || !(server_port>0) || !code_type || !deploy_method || !software_versions) return res.status(400).json({ ok:false, error:'empty_fields' })
updateModule({ id: r.id, name, description, server_addr, server_port, code_type, deploy_method, software_versions })
res.json({ ok:true })
})
app.delete('/api/ai-lib/modules/:id', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
deleteModule(parseInt(req.params.id,10))
res.json({ ok:true })
})
app.get('/api/ai-lib/notes', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const mid = parseInt(String(req.query.module_id||'0'),10)
res.json({ ok:true, rows: listNotes(mid) })
})
app.post('/api/ai-lib/notes', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const b = req.body||{}
const mid = parseInt(b.module_id,10)
const note_type = String(b.note_type||'').trim()
const content = String(b.content||'').trim()
if (!mid || !note_type || !content) return res.status(400).json({ ok:false, error:'empty_fields' })
const id = createNote({ module_id: mid, note_type, content })
res.json({ ok:true, id })
})
app.put('/api/ai-lib/notes/:id', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const r = Object.assign({}, req.body||{}, { id: parseInt(req.params.id,10) })
const note_type = String(r.note_type||'').trim()
const content = String(r.content||'').trim()
if (!note_type || !content) return res.status(400).json({ ok:false, error:'empty_fields' })
updateNote({ id: r.id, note_type, content })
res.json({ ok:true })
})
app.delete('/api/ai-lib/notes/:id', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
deleteNote(parseInt(req.params.id,10))
res.json({ ok:true })
})
app.get('/api/ai-lib/attachments', (req, res) => {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const nid = parseInt(String(req.query.note_id||'0'),10)
res.json({ ok:true, rows: listAttachments(nid) })
})
app.post('/api/ai-lib/attachments/upload', require('express').raw({ type:'application/octet-stream', limit:'200mb' }), (req, res) => {
try {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const note_id = parseInt(String(req.query.note_id||'0'),10)
const filename = String(req.query.filename||'file.bin')
const mime = String(req.query.mime||'application/octet-stream')
const raw = req.body || Buffer.alloc(0)
const id = saveAttachmentChunk(note_id, filename, mime, raw)
res.json({ ok:true, id })
} catch (e) {
res.status(500).json({ ok:false, error: String(e.message||e) })
}
})
app.post('/api/ai-lib/attachments/chunk', require('express').raw({ type:'application/octet-stream', limit:'200mb' }), (req, res) => {
try {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const note_id = parseInt(String(req.query.note_id||'0'),10)
const filename = String(req.query.filename||'file.bin')
const mime = String(req.query.mime||'application/octet-stream')
const idx = parseInt(String(req.query.index||'0'),10)
const total = parseInt(String(req.query.total||'1'),10)
const uploadId = String(req.query.upload_id||'') || `${Date.now()}-${Math.random().toString(36).slice(2)}`
const chunkDir = path.join(process.cwd(), 'uploads', 'ai-lib', 'chunks', uploadId)
if (!fs.existsSync(chunkDir)) fs.mkdirSync(chunkDir, { recursive: true })
const raw = req.body || Buffer.alloc(0)
const out = path.join(chunkDir, `${String(idx).padStart(6,'0')}.part`)
fs.writeFileSync(out, raw)
const metaPath = path.join(chunkDir, 'meta.json')
let meta = { note_id, filename, mime, total, size: 0 }
try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')) } catch {}
meta.note_id = note_id; meta.filename = filename; meta.mime = mime; meta.total = total; meta.size += raw.length
fs.writeFileSync(metaPath, JSON.stringify(meta))
logJSON('ai-lib.upload.chunk', { uploadId, idx, total, size: raw.length, filename }, 'ai-lib')
if (idx+1 === total) {
const parts = fs.readdirSync(chunkDir).filter(n=>/\.part$/.test(n)).sort()
const buffers = []
for (const p of parts) buffers.push(fs.readFileSync(path.join(chunkDir, p)))
const combined = Buffer.concat(buffers)
const id = saveAttachmentChunk(note_id, filename, mime, combined)
fs.rmSync(chunkDir, { recursive: true, force: true })
logJSON('ai-lib.upload.finalize', { uploadId, parts: parts.length, totalSize: combined.length, attachment_id: id }, 'ai-lib')
return res.json({ ok:true, done:true, id })
}
res.json({ ok:true, upload_id: uploadId, index: idx })
} catch (e) {
res.status(500).json({ ok:false, error:String(e.message||e) })
}
})
app.get('/api/ai-lib/attachments/:id/download', (req, res) => {
try {
if (!mustAuth(req,res)) return res.status(401).json({ ok:false, error:'unauthorized' })
const id = parseInt(req.params.id,10)
const r = db.prepare(`SELECT * FROM attachments WHERE id=?`).get(id)
if (!r) return res.status(404).json({ ok:false, error:'not_found' })
const filename = dec(r.filename_enc, r.filename_iv, r.filename_tag) || 'file.bin'
const mime = dec(r.mime_enc, r.mime_iv, r.mime_tag) || 'application/octet-stream'
const full = dec(r.store_enc, r.store_iv, r.store_tag)
const raw = fs.readFileSync(full)
const iv = raw.slice(0,12)
const tag = raw.slice(12,28)
const ct = raw.slice(28)
const decipher = crypto.createDecipheriv('aes-256-gcm', deriveKey(), iv)
decipher.setAuthTag(tag)
const buf = Buffer.concat([decipher.update(ct), decipher.final()])
res.setHeader('Content-Type', mime)
const ascii = filename.replace(/[^a-zA-Z0-9._-]/g,'_')
const utf8 = encodeURIComponent(filename)
res.setHeader('Content-Disposition', `attachment; filename="${ascii}"; filename*=UTF-8''${utf8}`)
res.send(buf)
} catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) }
})
}
module.exports = { bindRoutes, getAttachmentDataByStoredPath }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
const express = require('express')
const path = require('path')
const fs = require('fs')
const Database = require('better-sqlite3')
const app = express()
app.use(express.json({ type: ['application/json','text/json'], limit: '2mb' }))
app.use(express.text({ type: ['application/x-www-form-urlencoded','text/plain'], limit: '2mb' }))
app.use((req,res,next)=>{ res.setHeader('Access-Control-Allow-Origin','*'); res.setHeader('Access-Control-Allow-Methods','GET,POST,PUT,DELETE,OPTIONS'); res.setHeader('Access-Control-Allow-Headers','Content-Type'); if (req.method==='OPTIONS'){ res.status(200).send(''); } else next() })
const dbPath = path.join(process.cwd(), 'CalendarReminder_ServerApi', 'App_Data', 'calendar.db')
fs.mkdirSync(path.dirname(dbPath), { recursive: true })
const db = new Database(dbPath)
db.exec(`CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
start TEXT NOT NULL,
end TEXT,
allDay INTEGER NOT NULL DEFAULT 0,
remindAt TEXT,
recurrence TEXT,
createdAt TEXT,
updatedAt TEXT,
deleted INTEGER NOT NULL DEFAULT 0
)`)
const rowToObj = (r)=>({ id:r.id, title:r.title, start:r.start, end:r.end, allDay: !!r.allDay, remindAt:r.remindAt, recurrence:r.recurrence, createdAt:r.createdAt, updatedAt:r.updatedAt, deleted: !!r.deleted })
app.get('/Api/Events.ashx', (req,res)=>{
try {
const since = req.query.since
const year = req.query.year
const month = req.query.month
let rows
if (since) rows = db.prepare('SELECT * FROM events WHERE updatedAt >= ?').all(since)
else if (year && month) {
const y = parseInt(year,10), m = parseInt(month,10)
const s = new Date(Date.UTC(y, m-1, 1, 0,0,0)).toISOString()
const e = new Date(Date.UTC(y, m-1, new Date(y, m, 0).getUTCDate(), 23,59,59)).toISOString()
rows = db.prepare('SELECT * FROM events WHERE start >= ? AND start <= ?').all(s, e)
} else rows = db.prepare('SELECT * FROM events').all()
res.json({ ok:true, data: rows.map(rowToObj) })
} catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) }
})
app.post('/Api/Events.ashx', (req,res)=>{
try {
const ev = typeof req.body==='string' ? JSON.parse(req.body||'{}') : (req.body||{})
if (!ev || !ev.id) return res.status(400).json({ ok:false, error:'invalid payload' })
const stmt = db.prepare(`INSERT OR REPLACE INTO events (id,title,start,end,allDay,remindAt,recurrence,createdAt,updatedAt,deleted) VALUES (?,?,?,?,?,?,?,?,?,?)`)
stmt.run(String(ev.id), String(ev.title||''), String(ev.start||''), String(ev.end||''), ev.allDay?1:0, String(ev.remindAt||''), String(ev.recurrence||''), String(ev.createdAt||new Date().toISOString()), String(ev.updatedAt||new Date().toISOString()), ev.deleted?1:0)
res.json({ ok:true })
} catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) }
})
app.put('/Api/Events.ashx', (req,res)=>{
try {
const ev = typeof req.body==='string' ? JSON.parse(req.body||'{}') : (req.body||{})
const id = String(req.query.id||ev.id||'')
if (!id) return res.status(400).json({ ok:false, error:'missing id' })
const stmt = db.prepare(`UPDATE events SET title=?, start=?, end=?, allDay=?, remindAt=?, recurrence=?, updatedAt=?, deleted=? WHERE id=?`)
stmt.run(String(ev.title||''), String(ev.start||''), String(ev.end||''), ev.allDay?1:0, String(ev.remindAt||''), String(ev.recurrence||''), String(ev.updatedAt||new Date().toISOString()), ev.deleted?1:0, id)
res.json({ ok:true })
} catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) }
})
app.delete('/Api/Events.ashx', (req,res)=>{
try {
const id = String(req.query.id||'')
if (!id) return res.status(400).json({ ok:false, error:'missing id' })
db.prepare('UPDATE events SET deleted=1, updatedAt=? WHERE id=?').run(new Date().toISOString(), id)
res.json({ ok:true })
} catch (e) { res.status(500).json({ ok:false, error:String(e.message||e) }) }
})
const port = process.env.CALENDAR_PORT || 8976
app.listen(port, ()=>{ console.log(`CalendarReminder local API on http://localhost:${port}/Api/Events.ashx`) })
+437
View File
@@ -0,0 +1,437 @@
const express = require('express')
const Database = require('better-sqlite3')
const path = require('path')
const axios = require('axios')
const fs = require('fs')
const readCookieCfg = () => {
const cfgPath = path.join(process.cwd(), 'public', 'tools', 'calendar_reminder', 'auth_config.json')
let name = 'calendar_reminder_gate'
let maxAge = 90 * 24 * 3600 * 1000
let enable = true
try {
if (fs.existsSync(cfgPath)) {
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
if (cfg.cookieName) name = String(cfg.cookieName)
const days = Number(cfg.max_age_days || 0)
if (Number.isFinite(days) && days > 0) maxAge = Math.min(days, 365) * 24 * 3600 * 1000
if (cfg.enable_auth === false) enable = false
}
} catch {}
return { name, maxAge, enable }
}
const DB_PATH = path.join(process.cwd(), 'data', 'calendar_reminder.db')
let db = null
// Config
const EXT_API_URL = `http://127.0.0.1:${process.env.PORT || 8976}/api/weekly/ext/event`
const EXT_API_KEY = process.env.CALENDAR_REMINDER_API_KEY || process.env.SYSTEM_AUTH_PASS_93220 || ''
const EXT_API_SOURCE = 'crm' // Kept from C# default
const readPublicApiKey = () => {
try {
const cfgPath = path.join(process.cwd(), 'config', 'calendar_reminder.json')
if (fs.existsSync(cfgPath)) {
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
const k = String(cfg.PUBLIC_API_KEY || cfg.public_api_key || '').trim()
if (k) return k
}
} catch {}
return '099239923'
}
const PUBLIC_API_KEY = readPublicApiKey()
const parseCookie = (str) => {
const list = {}
if (!str) return list
str.split(';').forEach((cookie) => {
const parts = cookie.split('=')
list[parts.shift().trim()] = decodeURI(parts.join('='))
})
return list
}
const checkAuth = (req, res, next) => {
const cookies = parseCookie(req.headers.cookie || '')
const { name, enable } = readCookieCfg()
if (enable === false) return next()
if (cookies[name] === '1') return next()
try {
const ref = String(req.headers['referer'] || req.headers['referrer'] || '')
if (ref.includes('/tools/calendar_voice/')) return next()
} catch {}
const hasOrigin = !!req.headers['origin']
const hasSecFetch = !!req.headers['sec-fetch-mode']
if (!hasOrigin && !hasSecFetch) return next()
return res.status(401).json({ error: 'unauthorized', ok: false })
}
const ensureDb = () => {
if (db) return
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
// Create table if not exists (migrated from C#)
db.exec(`CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT,
start TEXT NOT NULL,
end TEXT,
allDay INTEGER NOT NULL DEFAULT 0,
remindAt TEXT,
recurrence TEXT,
createdAt TEXT,
updatedAt TEXT,
completed INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0
);`)
// Ensure columns
const cols = new Set(db.prepare("PRAGMA table_info(events)").all().map(c => c.name))
if (!cols.has('content')) db.exec("ALTER TABLE events ADD COLUMN content TEXT;")
if (!cols.has('completed')) db.exec("ALTER TABLE events ADD COLUMN completed INTEGER NOT NULL DEFAULT 0;")
// Create indices to speed up queries
db.exec(`CREATE INDEX IF NOT EXISTS idx_events_start ON events(start);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_events_updatedAt ON events(updatedAt);`)
}
// Helpers
const getUUID = () => {
try { return require('crypto').randomUUID() } catch { return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2) }
}
const syncExternalUpsert = async (ev) => {
try {
const title = (ev.title || '').trim()
const contentRaw = (ev.content || '').trim()
const start = ev.start
const end = ev.end
const remindAt = ev.remindAt
// Logic from C# BuildExternalContent
let contentBuilt = contentRaw
if (remindAt) contentBuilt = `[提醒: ${remindAt}]\n${contentBuilt}`
if (end) contentBuilt = `[结束: ${end}]\n${contentBuilt}`
if (ev.recurrence) contentBuilt = `[循环: ${ev.recurrence}]\n${contentBuilt}`
contentBuilt = contentBuilt.trim()
// Date logic
const sDate = start ? start.slice(0, 10) : new Date().toISOString().slice(0, 10)
const payload = {
key: EXT_API_KEY,
source: EXT_API_SOURCE,
external_id: ev.id,
date: sDate,
title: title,
content: contentBuilt,
status_code: ev.completed ? 'done' : (ev.deleted ? 'deleted' : 'new'),
status_label: ev.completed ? '✅已完成' : (ev.deleted ? '🗑已删除' : '📅日程'),
deadline: remindAt || end || start
}
if (ev.deleted) payload.action = 'delete'
await axios.post(EXT_API_URL, payload, { timeout: 8000, headers: { 'Content-Type': 'application/json' } }).catch(err => console.error('Sync failed', err.message))
} catch (e) {
console.error('Sync error', e)
}
}
const syncExternalDelete = async (id) => {
try {
const payload = {
key: EXT_API_KEY,
source: EXT_API_SOURCE,
external_id: id,
action: 'delete',
deleted: true
}
await axios.post(EXT_API_URL, payload, { timeout: 8000, headers: { 'Content-Type': 'application/json' } }).catch(err => console.error('Sync delete failed', err.message))
} catch (e) {
console.error('Sync delete error', e)
}
}
const handleGet = (req, res) => {
const { since, year, month } = req.query
let rows = []
if (since) {
rows = db.prepare("SELECT * FROM events WHERE updatedAt >= ?").all(since)
} else if (year && month) {
const y = parseInt(year)
const m = parseInt(month)
// Construct simplified date strings for comparison (sqlite stores text ISO)
// C# used DateTime comparison. Here we rely on ISO string comparison which works for YYYY-MM-DD
// But events.start might be YYYY-MM-DDTHH:mm:ss.
// Construct range:
const startStr = new Date(Date.UTC(y, m - 1, 1, 0, 0, 0)).toISOString()
// End of month
const endStr = new Date(Date.UTC(y, m, 0, 23, 59, 59, 999)).toISOString()
rows = db.prepare("SELECT * FROM events WHERE start >= ? AND start <= ?").all(startStr, endStr)
} else {
rows = db.prepare("SELECT * FROM events").all()
}
try {
const qInfo = since ? `since=${since}` : (year ? `year=${year},month=${month}` : 'ALL');
console.log(`[CalendarReminder] GetEvents ${qInfo} -> ${rows.length} rows`);
} catch(e){}
const data = rows.map(r => ({
id: r.id,
title: r.title,
content: r.content || '',
start: r.start,
end: r.end,
allDay: !!r.allDay,
remindAt: r.remindAt,
recurrence: r.recurrence,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
completed: !!r.completed,
deleted: !!r.deleted
}))
res.json({ ok: true, data })
}
const handlePost = async (req, res) => {
const ev = req.body || {}
// Deduplication: if title and start match an existing active event, update it instead of creating new
if (ev.title && ev.start) {
try {
const existing = db.prepare("SELECT id FROM events WHERE title = ? AND start = ? AND deleted = 0 LIMIT 1").get(ev.title, ev.start)
if (existing) {
ev.id = existing.id
}
} catch (e) { console.error('Deduplication check failed', e) }
}
if (!ev.id) ev.id = getUUID()
// Defaults
ev.content = ev.content || ''
ev.createdAt = ev.createdAt || new Date().toISOString()
ev.updatedAt = ev.updatedAt || new Date().toISOString()
ev.completed = ev.completed ? 1 : 0
ev.deleted = ev.deleted ? 1 : 0
ev.allDay = ev.allDay ? 1 : 0
const stmt = db.prepare(`INSERT OR REPLACE INTO events
(id,title,content,start,end,allDay,remindAt,recurrence,createdAt,updatedAt,completed,deleted)
VALUES (@id,@title,@content,@start,@end,@allDay,@remindAt,@recurrence,@createdAt,@updatedAt,@completed,@deleted)`)
stmt.run({
id: ev.id,
title: ev.title,
content: ev.content,
start: ev.start,
end: ev.end || null,
allDay: ev.allDay,
remindAt: ev.remindAt || null,
recurrence: ev.recurrence || null,
createdAt: ev.createdAt,
updatedAt: ev.updatedAt,
completed: ev.completed,
deleted: ev.deleted
})
await syncExternalUpsert(ev)
res.json({ ok: true })
}
const handlePut = async (req, res) => {
// C# logic: UPDATE specific fields
// Node logic: Similar
let ev = req.body || {}
const id = req.query.id || ev.id
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
ev.id = id
ev.updatedAt = new Date().toISOString()
// Need to read existing event to merge?
// C# HandlePut updates ALL fields provided in the query/body.
// It runs an UPDATE statement setting all fields.
// Since we might not have all fields in body, we should probably merge or expect full object?
// C# HandlePut SQL: UPDATE events SET title=@title, ... WHERE id=@id.
// It expects the body to contain the fields.
// Frontend sends full object usually.
// Let's assume body contains fields to update.
// However, simple SQL UPDATE requires all params.
// We can use COALESCE or read-modify-write.
const existing = db.prepare("SELECT * FROM events WHERE id = ?").get(id)
if (!existing) return res.status(404).json({ ok: false, error: 'not found' })
const merged = { ...existing, ...ev }
merged.completed = merged.completed ? 1 : 0
merged.deleted = merged.deleted ? 1 : 0
merged.allDay = merged.allDay ? 1 : 0
const stmt = db.prepare(`UPDATE events SET
title=@title,content=@content,start=@start,end=@end,allDay=@allDay,remindAt=@remindAt,recurrence=@recurrence,
updatedAt=@updatedAt,completed=@completed,deleted=@deleted WHERE id=@id`)
stmt.run({
id: merged.id,
title: merged.title,
content: merged.content,
start: merged.start,
end: merged.end,
allDay: merged.allDay,
remindAt: merged.remindAt,
recurrence: merged.recurrence,
updatedAt: merged.updatedAt,
completed: merged.completed,
deleted: merged.deleted
})
await syncExternalUpsert(merged)
res.json({ ok: true })
}
const handleDelete = async (req, res) => {
const id = req.query.id
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const updatedAt = new Date().toISOString()
db.prepare("UPDATE events SET deleted=1, updatedAt=? WHERE id=?").run(updatedAt, id)
await syncExternalDelete(id)
res.json({ ok: true })
}
const bindRoutes = (app) => {
ensureDb()
app.get('/api/calendar/sys-config', (req, res) => {
try {
const cfgPath = path.join(process.cwd(), 'config', 'calendar_reminder.json')
let cfg = {}
if (fs.existsSync(cfgPath)) {
try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) } catch {}
}
res.json({ ok: true, config: cfg })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get('/api/calendar/auth', checkAuth, (req, res) => {
res.json({ ok: true, enabled: true })
})
// Public (no-auth) endpoints
app.get('/api/calendar/public/auth', (req, res) => {
res.json({ ok: true, enabled: true, public: true })
})
app.all('/api/calendar/public/events', express.json({ limit: '10mb' }), express.text({ type: 'text/plain' }), async (req, res) => {
const key = String(req.query.key || '')
if (!key || key !== PUBLIC_API_KEY) {
return res.status(403).json({ ok: false, error: 'forbidden' })
}
if (typeof req.body === 'string') {
try { req.body = JSON.parse(req.body) } catch {}
}
try {
let method = req.method
if (req.query._method) method = req.query._method.toUpperCase()
if (method === 'GET') await handleGet(req, res)
else if (method === 'POST') await handlePost(req, res)
else if (method === 'PUT') await handlePut(req, res)
else if (method === 'DELETE') await handleDelete(req, res)
else res.status(405).json({ ok: false, error: 'Method Not Allowed' })
} catch (e) {
res.status(500).json({ ok: false, error: e.message })
}
})
// 专门为外部程序(如WinForm)提供的完全开放接口
app.all('/api/calendar/external/events', express.json({ limit: '10mb' }), express.text({ type: 'text/plain' }), async (req, res) => {
// 设置CORS头,允许所有来源访问
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
if (req.method === 'OPTIONS') {
return res.status(200).end()
}
if (typeof req.body === 'string') {
try { req.body = JSON.parse(req.body) } catch {}
}
try {
let method = req.method
if (req.query._method) method = req.query._method.toUpperCase()
if (method === 'GET') await handleGet(req, res)
else if (method === 'POST') await handlePost(req, res)
else if (method === 'PUT') await handlePut(req, res)
else if (method === 'DELETE') await handleDelete(req, res)
else res.status(405).json({ ok: false, error: 'Method Not Allowed' })
} catch (e) {
res.status(500).json({ ok: false, error: e.message })
}
})
// Legacy route compatibility for CalendarReminder_WebApp
app.all('/Api/Events.ashx', express.json({ limit: '10mb' }), express.text({ type: 'text/plain' }), async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
if (req.method === 'OPTIONS') return res.status(200).end()
if (typeof req.body === 'string') {
try { req.body = JSON.parse(req.body) } catch {}
}
try {
let method = req.method
if (req.query._method) method = req.query._method.toUpperCase()
if (method === 'GET') await handleGet(req, res)
else if (method === 'POST') await handlePost(req, res)
else if (method === 'PUT') await handlePut(req, res)
else if (method === 'DELETE') await handleDelete(req, res)
else res.status(405).json({ ok: false, error: 'Method Not Allowed' })
} catch (e) {
res.status(500).json({ ok: false, error: e.message })
}
})
// Events
app.all('/api/calendar/events', checkAuth, express.json({ limit: '10mb' }), express.text({ type: 'text/plain' }), async (req, res) => {
// Note: C# supported text/plain body to avoid CORS preflight.
// We handle text body if json parse fails or if type is text.
if (typeof req.body === 'string') {
try { req.body = JSON.parse(req.body) } catch {}
}
try {
let method = req.method
if (req.query._method) method = req.query._method.toUpperCase()
if (method === 'GET') await handleGet(req, res)
else if (method === 'POST') await handlePost(req, res)
else if (method === 'PUT') await handlePut(req, res)
else if (method === 'DELETE') await handleDelete(req, res)
else res.status(405).json({ ok: false, error: 'Method Not Allowed' })
} catch (e) {
res.status(500).json({ ok: false, error: e.message })
}
})
}
module.exports = { bindRoutes }
+180
View File
@@ -0,0 +1,180 @@
const express = require('express')
const axios = require('axios')
const multer = require('multer')
const Core = require('@alicloud/pop-core')
// 阿里云配置(从环境变量读取,见 ~/Toolbox_local_creds.env.local)
const ALIYUN_CONFIG = {
accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID || 'LTAI5tNBSh1JXpFGXVRubNhK',
accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET || 'G0X8aOIA4bgUkSl1fgdgZv1GKFBJAd',
appKey: process.env.ALIYUN_APPKEY || '33kbhbwH6vYUdDIG',
endpoint: 'https://nls-meta.cn-shanghai.aliyuncs.com',
apiVersion: '2019-02-28'
}
// 缓存 Token
let cachedToken = null
let tokenExpire = 0
const getToken = async () => {
const now = Date.now() / 1000
if (cachedToken && now < tokenExpire) {
return cachedToken
}
try {
const client = new Core({
accessKeyId: ALIYUN_CONFIG.accessKeyId,
accessKeySecret: ALIYUN_CONFIG.accessKeySecret,
endpoint: ALIYUN_CONFIG.endpoint,
apiVersion: ALIYUN_CONFIG.apiVersion
})
const params = {}
const requestOption = {
method: 'POST',
formatParams: false
}
const result = await client.request('CreateToken', params, requestOption)
if (result.Token && result.Token.Id) {
cachedToken = result.Token.Id
tokenExpire = result.Token.ExpireTime
console.log('[CalendarVoice] Got new NLS token, expires:', tokenExpire)
return cachedToken
}
} catch (e) {
console.error('[CalendarVoice] Get token failed:', e)
}
return null
}
// 内存存储上传文件
const upload = multer({ storage: multer.memoryStorage() })
const bindRoutes = (app) => {
// 语音识别接口
app.post('/api/calendar_voice/recognize', upload.single('audio'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ ok: false, error: 'No audio file' })
const token = await getToken()
if (!token) return res.status(500).json({ ok: false, error: 'Failed to get NLS token' })
// 调用阿里云一句话识别 RESTful API
// 文档: https://help.aliyun.com/document_detail/84435.html
const url = `http://nls-gateway.cn-shanghai.aliyuncs.com/stream/v1/asr?appkey=${ALIYUN_CONFIG.appKey}&format=pcm&sample_rate=16000&enable_punctuation_prediction=true&enable_inverse_text_normalization=true`
const response = await axios.post(url, req.file.buffer, {
headers: {
'X-NLS-Token': token,
'Content-Type': 'application/octet-stream'
},
maxBodyLength: Infinity,
maxContentLength: Infinity
})
if (response.status === 200 && response.data.result) {
console.log('[CalendarVoice] Recognized:', response.data.result)
res.json({ ok: true, text: response.data.result })
} else {
console.error('[CalendarVoice] API Error:', response.data)
res.status(500).json({ ok: false, error: response.data.message || 'Recognition failed' })
}
} catch (e) {
console.error('[CalendarVoice] Error:', e.message)
res.status(500).json({ ok: false, error: e.message })
}
})
// AI 总结接口 (DeepSeek via DashScope)
app.post('/api/calendar_voice/summarize', express.json(), async (req, res) => {
const { text } = req.body
if (!text) return res.status(400).json({ ok: false, error: 'No text provided' })
const apiKey = process.env.DASHSCOPE_API_KEY || ''
// 使用 OpenAI 兼容接口调用 DeepSeek-V3
const url = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions'
const systemPrompt = `# Role
你是一名资深的时间管理专家和高效秘书,擅长从冗杂的口语录音中快速提炼核心日程主题。
# Goal
分析用户输入的【语音转写文本】,将其概括为一个简短、精准的日程标题(Event Title)。
# Constraints
1. **字数限制**:标题长度严格控制在 **5-15个字** 之间。
2. **去口语化**:坚决去除“那个”、“就是”、“我想说”、“嗯啊”等无意义的口语词汇。
3. **语法结构**:优先使用“**动词 + 名词**”的结构(例如:提交季度报表、参加家长会)。
4. **去时间词**:不需要在标题中包含具体的日期和时间(如“明天下午”、“三点钟”),重点描述“做什么”。
5. **输出纯净**:**只输出标题文字本身**,严禁包含任何标点符号、解释、前缀(如“标题:”)或引号。
# Scenarios & Examples
**场景1:工作会议**
输入:哎你好,就是明天下午三点钟,我要去那个科技园参加一个关于人工智能的研讨会,记得提醒我带电脑。
输出:参加人工智能研讨会
**场景2:生活琐事**
输入:下班后记得去超市买点鸡蛋和牛奶,还有那个洗洁精也没了,顺便买一下。
输出:超市购买生活用品
**场景3:个人安排**
输入:这周末打算把家里那个储藏室整理一下,太乱了。
输出:整理家中储藏室`
try {
const response = await axios.post(url, {
model: 'qwen-plus-latest',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: text }
],
temperature: 0.1 // 低温度以保证稳定输出
}, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
})
if (response.data && response.data.choices && response.data.choices.length > 0) {
const title = response.data.choices[0].message.content.trim()
console.log('[CalendarVoice] Summarized:', title)
res.json({ ok: true, title })
} else {
console.error('[CalendarVoice] AI Response Error:', response.data)
res.status(500).json({ ok: false, error: 'AI processing failed' })
}
} catch (e) {
console.error('[CalendarVoice] AI Error:', e.response ? e.response.data : e.message)
// 降级重试
if (e.response && (e.response.status === 404 || e.response.status === 400)) {
console.log('Retrying with qwen-plus-latest...')
try {
const retryRes = await axios.post(url, {
model: 'qwen-plus-latest',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: text }
]
}, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
})
const title = retryRes.data.choices[0].message.content.trim()
res.json({ ok: true, title })
return
} catch (err2) {
console.error('Retry failed:', err2.message)
}
}
res.status(500).json({ ok: false, error: 'AI service unavailable' })
}
})
}
module.exports = { bindRoutes }
+318
View File
@@ -0,0 +1,318 @@
const Database = require('better-sqlite3')
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const { logJSON } = require('./logger')
const axios = require('axios')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'ccb_private_funds.db')
const db = new Database(dbPath)
try { db.pragma('journal_mode = WAL') } catch {}
try { db.pragma('synchronous = NORMAL') } catch {}
const uploadsDir = path.join(process.cwd(), 'uploads', 'ccb_private_funds', 'attachments')
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true })
const cfgPath = path.join(process.cwd(), 'config', 'ccb_private_funds.json')
if (!fs.existsSync(path.dirname(cfgPath))) fs.mkdirSync(path.dirname(cfgPath), { recursive: true })
if (!fs.existsSync(cfgPath)) {
const defaultPort = process.env.PORT || 5050
const defaultBase = `http://localhost:${defaultPort}/api/weekly`
fs.writeFileSync(cfgPath, JSON.stringify({ machine_salt: crypto.randomBytes(16).toString('hex'), display: { currency: 'CNY' }, weekly_api_base: defaultBase, weekly_key: '', weekly_sync_enabled: true }, null, 2))
}
const getConfig = () => {
if (_cachedCfg) return _cachedCfg
_cachedCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
return _cachedCfg
}
const setConfig = cfg => {
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2))
_cachedCfg = null
_cachedKey = null
}
let _cachedCfg = null
let _cachedKey = null
const deriveKey = () => {
if (_cachedKey) return _cachedKey
const cfg = getConfig()
const salt = Buffer.from(String(cfg.machine_salt || 'default'), 'utf-8')
const passphrase = process.env.CCB_PRIVATE_FUNDS_PASSPHRASE || process.env.SYSTEM_AUTH_PASS_93220 || ''
_cachedKey = crypto.scryptSync(passphrase + String(cfg.machine_salt || ''), salt, 32)
return _cachedKey
}
const enc = plain => {
const key = deriveKey()
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
const ct = Buffer.concat([cipher.update(Buffer.from(String(plain || ''), 'utf-8')), cipher.final()])
const tag = cipher.getAuthTag()
return { ct, iv, tag }
}
const dec = (ct, iv, tag) => {
try {
const key = deriveKey()
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(tag)
const pt = Buffer.concat([decipher.update(ct), decipher.final()])
return pt.toString('utf-8')
} catch { return '' }
}
db.exec(
`CREATE TABLE IF NOT EXISTS fund_flows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
type TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
ending_cents INTEGER NOT NULL,
bank_status_enc BLOB,
bank_status_iv BLOB,
bank_status_tag BLOB,
remark_enc BLOB,
remark_iv BLOB,
remark_tag BLOB,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS fund_flow_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
flow_id INTEGER NOT NULL,
filename TEXT NOT NULL,
path TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
mime TEXT DEFAULT 'application/octet-stream',
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY(flow_id) REFERENCES fund_flows(id) ON DELETE CASCADE
);`
)
const insertFlow = db.prepare(
`INSERT INTO fund_flows (date, type, amount_cents, ending_cents, bank_status_enc, bank_status_iv, bank_status_tag, remark_enc, remark_iv, remark_tag)
VALUES (@date, @type, @amount_cents, 0, @bank_status_enc, @bank_status_iv, @bank_status_tag, @remark_enc, @remark_iv, @remark_tag)`
)
const updateFlow = db.prepare(
`UPDATE fund_flows SET date=@date, type=@type, amount_cents=@amount_cents, bank_status_enc=@bank_status_enc, bank_status_iv=@bank_status_iv, bank_status_tag=@bank_status_tag, remark_enc=@remark_enc, remark_iv=@remark_iv, remark_tag=@remark_tag, updated_at=datetime('now') WHERE id=@id`
)
const deleteFlowStmt = db.prepare(`DELETE FROM fund_flows WHERE id = ?`)
const listFlowsStmt = db.prepare(`SELECT id, date, type, amount_cents, ending_cents, bank_status_enc, bank_status_iv, bank_status_tag, remark_enc, remark_iv, remark_tag FROM fund_flows ORDER BY date ASC, id ASC`)
const listRangeStmt = db.prepare(`SELECT id, date, type, amount_cents, ending_cents, bank_status_enc, bank_status_iv, bank_status_tag, remark_enc, remark_iv, remark_tag FROM fund_flows WHERE date>=? AND date<=? ORDER BY date ASC, id ASC`)
const listByDateStmt = db.prepare(`SELECT id, date, type, amount_cents, ending_cents, bank_status_enc, bank_status_iv, bank_status_tag, remark_enc, remark_iv, remark_tag FROM fund_flows WHERE date=? ORDER BY id ASC`)
const sumByDateStmt = db.prepare(`SELECT SUM(amount_cents) AS s FROM fund_flows WHERE date=?`)
const updateEndingStmt = db.prepare(`UPDATE fund_flows SET ending_cents=? WHERE id=?`)
const countAttachmentsByFlowStmt = db.prepare(`SELECT COUNT(1) AS c FROM fund_flow_attachments WHERE flow_id=?`)
const listAttachmentsByFlowStmt = db.prepare(`SELECT id, flow_id, filename, path, size_bytes, mime, created_at FROM fund_flow_attachments WHERE flow_id=? ORDER BY id ASC`)
const getAttachmentByIdStmt = db.prepare(`SELECT id, flow_id, filename, path, size_bytes, mime, created_at FROM fund_flow_attachments WHERE id=?`)
const insertAttachmentStmt = db.prepare(`INSERT INTO fund_flow_attachments (flow_id, filename, path, size_bytes, mime) VALUES (@flow_id, @filename, @path, @size_bytes, @mime)`)
const deleteAttachmentStmt = db.prepare(`DELETE FROM fund_flow_attachments WHERE id=?`)
const deleteAttachmentsByFlowStmt = db.prepare(`DELETE FROM fund_flow_attachments WHERE flow_id=?`)
const recalcEndingFrom = db.transaction(startDate => {
const all = listFlowsStmt.all()
let balance = 0
for (const r of all) {
if (startDate) {
if (r.date < startDate) { balance += (r.amount_cents || 0); continue }
}
balance += (r.amount_cents || 0)
updateEndingStmt.run(balance, r.id)
}
})
const listFlows = (start, end, type, kw) => {
const rows = (start && end) ? listRangeStmt.all(start, end) : listFlowsStmt.all()
const mapRow = r => {
const bank = r.bank_status_enc ? dec(r.bank_status_enc, r.bank_status_iv, r.bank_status_tag) : ''
const remark = r.remark_enc ? dec(r.remark_enc, r.remark_iv, r.remark_tag) : ''
const c = countAttachmentsByFlowStmt.get(r.id)
return { id: r.id, date: r.date, type: r.type, amount_cents: r.amount_cents, ending_cents: r.ending_cents, bank_status: bank, remark, attachments_count: Number((c && c.c) || 0) }
}
let filtered = rows.map(mapRow)
if (type && type !== 'all') filtered = filtered.filter(r => r.type === type)
if (kw) { const reg = new RegExp(String(kw).trim(), 'i'); filtered = filtered.filter(r => reg.test(r.bank_status || '') || reg.test(r.remark || '')) }
return filtered
}
const formatCNY = cents => {
try { return (Number(cents || 0) / 100).toFixed(2) } catch { return '0.00' }
}
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 pushWeeklyDaily = async date => {
try {
const api = getWeeklyCfg()
if (!api.enabled) return
const rows = listByDateStmt.all(date)
const total = (sumByDateStmt.get(date).s || 0)
const title = `建行私募理财 ${date}(净变动:${formatCNY(total)})`
const items = rows.map((r, idx) => ({
idx: idx + 1,
type: r.type,
amount: formatCNY(r.amount_cents),
bank: r.bank_status_enc ? dec(r.bank_status_enc, r.bank_status_iv, r.bank_status_tag) : '',
remark: r.remark_enc ? dec(r.remark_enc, r.remark_iv, r.remark_tag) : ''
}))
const trs = items.map(it => `<tr><td>${it.idx}</td><td>${it.type === 'in' ? '汇入' : '汇出'}</td><td>${it.amount}</td><td>${(it.bank || '').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</td><td>${(it.remark || '').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</td></tr>`).join('')
const content = `
<div>
<h4>当日净变动</h4>
<p>人民币:${formatCNY(total)}</p>
<h4>明细</h4>
<table border="1" cellspacing="0" cellpadding="4">
<thead><tr><th>序号</th><th>类型</th><th>金额(元)</th><th>银行方/状态</th><th>备注</th></tr></thead>
<tbody>${trs}</tbody>
</table>
</div>
`.trim()
const payload = { key: api.key, source: 'ccb_private_funds', external_id: `ccb-private-funds-daily-${date}`, title, content, date, status_code: 'new', status_label: '📅本周新增' }
const url = `${api.base}/ext/event`
logJSON('ccb_private_funds.weekly.push.try', { date, url, base: api.base, rows: rows.length, total_cents: total, title_len: title.length, content_len: content.length }, 'ccb_private_funds')
const resp = await axios.post(url, payload, { timeout: 15000, headers: { 'Content-Type': 'application/json' } })
logJSON('ccb_private_funds.weekly.push.resp', { date, status: resp && resp.status, data_ok: !!(resp && resp.data && resp.data.ok), data: brief((resp && resp.data) ? JSON.stringify(resp.data) : '') }, 'ccb_private_funds')
} catch (e) {
const status = e && e.response ? e.response.status : null
const data = e && e.response ? e.response.data : null
logJSON('ccb_private_funds.weekly.push.error', { date, url: (getWeeklyCfg().base + '/ext/event'), status, code: e && e.code, message: String(e.message || e), data: typeof data === 'string' ? brief(data) : brief(data ? JSON.stringify(data) : '') }, 'ccb_private_funds')
}
}
const deleteOrUpdateWeeklyDaily = async date => {
try {
const api = getWeeklyCfg()
if (!api.enabled) return
const rows = listByDateStmt.all(date)
if (!rows || rows.length === 0) {
const url = `${api.base}/ext/event`
const payload = { key: api.key, source: 'ccb_private_funds', external_id: `ccb-private-funds-daily-${date}`, date, deleted: true }
logJSON('ccb_private_funds.weekly.delete.try', { date, url, base: api.base }, 'ccb_private_funds')
const resp = await axios.post(url, payload, { timeout: 15000, headers: { 'Content-Type': 'application/json' } })
logJSON('ccb_private_funds.weekly.delete.resp', { date, status: resp && resp.status, data_ok: !!(resp && resp.data && resp.data.ok), data: brief((resp && resp.data) ? JSON.stringify(resp.data) : '') }, 'ccb_private_funds')
} else {
await pushWeeklyDaily(date)
}
} catch (e) {
const status = e && e.response ? e.response.status : null
const data = e && e.response ? e.response.data : null
logJSON('ccb_private_funds.weekly.delete.error', { date, url: (getWeeklyCfg().base + '/ext/event'), status, code: e && e.code, message: String(e.message || e), data: typeof data === 'string' ? brief(data) : brief(data ? JSON.stringify(data) : '') }, 'ccb_private_funds')
}
}
const createFlow = payload => {
const { date, type, amount_cents, bank_status, remark } = payload
const bs = enc(bank_status || '')
const rm = enc(remark || '')
const info = insertFlow.run({ date, type, amount_cents, bank_status_enc: bs.ct, bank_status_iv: bs.iv, bank_status_tag: bs.tag, remark_enc: rm.ct, remark_iv: rm.iv, remark_tag: rm.tag })
recalcEndingFrom(null)
logJSON('ccb_private_funds.create', { id: info.lastInsertRowid, date, type, amount_cents }, 'ccb_private_funds')
Promise.resolve().then(() => pushWeeklyDaily(date)).catch(() => {})
return Number(info.lastInsertRowid || 0)
}
const updateFlowById = (id, payload) => {
const { date, type, amount_cents, bank_status, remark } = payload
const bs = enc(bank_status || '')
const rm = enc(remark || '')
updateFlow.run({ id, date, type, amount_cents, bank_status_enc: bs.ct, bank_status_iv: bs.iv, bank_status_tag: bs.tag, remark_enc: rm.ct, remark_iv: rm.iv, remark_tag: rm.tag })
recalcEndingFrom(null)
logJSON('ccb_private_funds.update', { id, date, type, amount_cents }, 'ccb_private_funds')
Promise.resolve().then(() => pushWeeklyDaily(date)).catch(() => {})
}
const deleteFlow = id => {
try {
const row = db.prepare(`SELECT date FROM fund_flows WHERE id=?`).get(id)
deleteAllAttachmentsByFlowId(id)
deleteFlowStmt.run(id)
recalcEndingFrom(null)
logJSON('ccb_private_funds.delete', { id }, 'ccb_private_funds')
if (row && row.date) Promise.resolve().then(() => deleteOrUpdateWeeklyDaily(row.date)).catch(() => {})
} catch (e) {
deleteAllAttachmentsByFlowId(id)
deleteFlowStmt.run(id)
recalcEndingFrom(null)
logJSON('ccb_private_funds.delete', { id, error: String(e.message || e) }, 'ccb_private_funds')
}
}
const normalizeRelPath = v => String(v || '').replace(/^\/+/, '').replace(/\\/g, '/')
const toPublicUrl = rel => `/uploads/${normalizeRelPath(rel)}`
const listAttachmentsByFlowId = flowId => {
const id = parseInt(flowId, 10)
if (!id) return []
return listAttachmentsByFlowStmt.all(id).map(r => ({
id: r.id,
flow_id: r.flow_id,
filename: r.filename,
path: r.path,
size_bytes: r.size_bytes,
mime: r.mime || '',
created_at: r.created_at,
url: toPublicUrl(r.path)
}))
}
const addAttachment = payload => {
const flow_id = parseInt(payload.flow_id, 10)
if (!flow_id) throw new Error('bad_flow_id')
const filename = String(payload.filename || 'file.bin').trim() || 'file.bin'
const p = normalizeRelPath(payload.path || '')
if (!p) throw new Error('bad_path')
const size_bytes = Math.max(0, parseInt(payload.size_bytes, 10) || 0)
const mime = String(payload.mime || 'application/octet-stream')
const info = insertAttachmentStmt.run({ flow_id, filename, path: p, size_bytes, mime })
const id = Number(info.lastInsertRowid || 0)
logJSON('ccb_private_funds.attachment.create', { id, flow_id, filename, path: p, size_bytes, mime }, 'ccb_private_funds')
return id
}
const deleteAttachmentById = id => {
const aid = parseInt(id, 10)
if (!aid) return
const row = getAttachmentByIdStmt.get(aid)
if (row && row.path) {
const abs = path.join(process.cwd(), 'uploads', normalizeRelPath(row.path))
try { fs.unlinkSync(abs) } catch {}
}
deleteAttachmentStmt.run(aid)
logJSON('ccb_private_funds.attachment.delete', { id: aid }, 'ccb_private_funds')
}
const deleteAllAttachmentsByFlowId = flowId => {
const fid = parseInt(flowId, 10)
if (!fid) return
const rows = listAttachmentsByFlowStmt.all(fid)
for (const r of rows) {
if (r && r.path) {
const abs = path.join(process.cwd(), 'uploads', normalizeRelPath(r.path))
try { fs.unlinkSync(abs) } catch {}
}
}
deleteAttachmentsByFlowStmt.run(fid)
logJSON('ccb_private_funds.attachment.delete_all', { flow_id: fid, count: rows.length }, 'ccb_private_funds')
}
module.exports = {
getConfig,
setConfig,
listFlows,
createFlow,
updateFlowById,
deleteFlow,
listAttachmentsByFlowId,
addAttachment,
deleteAttachmentById
}
+235
View File
@@ -0,0 +1,235 @@
const { db } = require('./db')
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const { logJSON } = require('./logger')
const cfgPath = path.join(process.cwd(), 'config', 'cloud.json')
if (!fs.existsSync(path.dirname(cfgPath))) fs.mkdirSync(path.dirname(cfgPath), { recursive: true })
if (!fs.existsSync(cfgPath)) fs.writeFileSync(cfgPath, JSON.stringify({ machine_salt: crypto.randomBytes(16).toString('hex') }, null, 2))
const getConfig = () => JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
const deriveKey = () => {
const cfg = getConfig()
const salt = Buffer.from(String(cfg.machine_salt || 'default'), 'utf-8')
const passphrase = process.env.CLOUD_PASSPHRASE || process.env.SYSTEM_AUTH_PASS_93220 || ''
return crypto.scryptSync(passphrase + String(cfg.machine_salt || ''), salt, 32)
}
const enc = plain => {
const key = deriveKey()
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
const ct = Buffer.concat([cipher.update(Buffer.from(String(plain||''), 'utf-8')), cipher.final()])
const tag = cipher.getAuthTag()
return { ct, iv, tag }
}
const dec = (ct, iv, tag) => {
try {
const key = deriveKey()
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(tag)
const pt = Buffer.concat([decipher.update(ct), decipher.final()])
return pt.toString('utf-8')
} catch { return '' }
}
db.exec(`CREATE TABLE IF NOT EXISTS cloud_operators (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
login_account TEXT,
login_password TEXT,
mobile TEXT,
contact_person TEXT,
remark TEXT,
is_company_verified INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS cloud_ecs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operator_id INTEGER NOT NULL,
server_name TEXT,
spec_text TEXT,
region TEXT,
zone TEXT,
rdp_ip TEXT,
rdp_user TEXT,
rdp_password TEXT,
open_ports_text TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS cloud_vas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operator_id INTEGER NOT NULL,
name TEXT NOT NULL,
config_text TEXT,
access_text TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS cloud_renew (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject_type TEXT NOT NULL,
subject_id INTEGER NOT NULL,
paid_date TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
invoice_path TEXT,
payer_company TEXT,
expire_date TEXT,
next_expected_amount_cents INTEGER,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS cloud_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject_type TEXT NOT NULL,
subject_id INTEGER NOT NULL,
issue_text TEXT,
solution_text TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS cloud_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
filename TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY(note_id) REFERENCES cloud_notes(id) ON DELETE CASCADE
);`)
const ensureCols = () => {
const hasCol = (table, name) => db.prepare(`PRAGMA table_info(${table})`).all().some(r => r.name === name)
if (!hasCol('cloud_operators','login_password_enc')) db.exec(`ALTER TABLE cloud_operators ADD COLUMN login_password_enc BLOB`)
if (!hasCol('cloud_operators','login_password_iv')) db.exec(`ALTER TABLE cloud_operators ADD COLUMN login_password_iv BLOB`)
if (!hasCol('cloud_operators','login_password_tag')) db.exec(`ALTER TABLE cloud_operators ADD COLUMN login_password_tag BLOB`)
if (!hasCol('cloud_operators','remark')) db.exec(`ALTER TABLE cloud_operators ADD COLUMN remark TEXT`)
if (!hasCol('cloud_ecs','rdp_password_enc')) db.exec(`ALTER TABLE cloud_ecs ADD COLUMN rdp_password_enc BLOB`)
if (!hasCol('cloud_ecs','rdp_password_iv')) db.exec(`ALTER TABLE cloud_ecs ADD COLUMN rdp_password_iv BLOB`)
if (!hasCol('cloud_ecs','rdp_password_tag')) db.exec(`ALTER TABLE cloud_ecs ADD COLUMN rdp_password_tag BLOB`)
if (!hasCol('cloud_ecs','server_name')) db.exec(`ALTER TABLE cloud_ecs ADD COLUMN server_name TEXT`)
if (!hasCol('cloud_vas','access_text_enc')) db.exec(`ALTER TABLE cloud_vas ADD COLUMN access_text_enc BLOB`)
if (!hasCol('cloud_vas','access_text_iv')) db.exec(`ALTER TABLE cloud_vas ADD COLUMN access_text_iv BLOB`)
if (!hasCol('cloud_vas','access_text_tag')) db.exec(`ALTER TABLE cloud_vas ADD COLUMN access_text_tag BLOB`)
}
ensureCols()
const migrateExisting = () => {
const ops = db.prepare(`SELECT id, login_password, login_password_enc FROM cloud_operators`).all()
for (const r of ops) {
if (r.login_password && !r.login_password_enc) {
const e = enc(r.login_password)
db.prepare(`UPDATE cloud_operators SET login_password='', login_password_enc=?, login_password_iv=?, login_password_tag=?, updated_at=datetime('now') WHERE id=?`).run(e.ct, e.iv, e.tag, r.id)
}
}
const ecs = db.prepare(`SELECT id, rdp_password, rdp_password_enc FROM cloud_ecs`).all()
for (const r of ecs) {
if (r.rdp_password && !r.rdp_password_enc) {
const e = enc(r.rdp_password)
db.prepare(`UPDATE cloud_ecs SET rdp_password='', rdp_password_enc=?, rdp_password_iv=?, rdp_password_tag=?, updated_at=datetime('now') WHERE id=?`).run(e.ct, e.iv, e.tag, r.id)
}
}
const vas = db.prepare(`SELECT id, access_text, access_text_enc FROM cloud_vas`).all()
for (const r of vas) {
if (r.access_text && !r.access_text_enc) {
const e = enc(r.access_text)
db.prepare(`UPDATE cloud_vas SET access_text='', access_text_enc=?, access_text_iv=?, access_text_tag=?, updated_at=datetime('now') WHERE id=?`).run(e.ct, e.iv, e.tag, r.id)
}
}
}
migrateExisting()
const listOperators = db.prepare(`SELECT * FROM cloud_operators ORDER BY id DESC`)
const createOperatorStmt = db.prepare(`INSERT INTO cloud_operators (name, login_account, login_password, mobile, contact_person, remark, is_company_verified, login_password_enc, login_password_iv, login_password_tag) VALUES (@name, @login_account, '', @mobile, @contact_person, @remark, @is_company_verified, @login_password_enc, @login_password_iv, @login_password_tag)`)
const updateOperatorStmt = db.prepare(`UPDATE cloud_operators SET name=@name, login_account=@login_account, login_password='', mobile=@mobile, contact_person=@contact_person, remark=@remark, is_company_verified=@is_company_verified, login_password_enc=@login_password_enc, login_password_iv=@login_password_iv, login_password_tag=@login_password_tag, updated_at=datetime('now') WHERE id=@id`)
const deleteOperatorStmt = db.prepare(`DELETE FROM cloud_operators WHERE id=?`)
const listEcs = db.prepare(`SELECT * FROM cloud_ecs ORDER BY id DESC`)
const listEcsByOperator = db.prepare(`SELECT * FROM cloud_ecs WHERE operator_id=? ORDER BY id DESC`)
const createEcsStmt = db.prepare(`INSERT INTO cloud_ecs (operator_id, server_name, spec_text, region, zone, rdp_ip, rdp_user, rdp_password, open_ports_text, rdp_password_enc, rdp_password_iv, rdp_password_tag) VALUES (@operator_id, @server_name, @spec_text, @region, @zone, @rdp_ip, @rdp_user, '', @open_ports_text, @rdp_password_enc, @rdp_password_iv, @rdp_password_tag)`)
const updateEcsStmt = db.prepare(`UPDATE cloud_ecs SET operator_id=@operator_id, server_name=@server_name, spec_text=@spec_text, region=@region, zone=@zone, rdp_ip=@rdp_ip, rdp_user=@rdp_user, rdp_password='', open_ports_text=@open_ports_text, rdp_password_enc=@rdp_password_enc, rdp_password_iv=@rdp_password_iv, rdp_password_tag=@rdp_password_tag, updated_at=datetime('now') WHERE id=@id`)
const deleteEcsStmt = db.prepare(`DELETE FROM cloud_ecs WHERE id=?`)
const listVas = db.prepare(`SELECT * FROM cloud_vas ORDER BY id DESC`)
const listVasByOperator = db.prepare(`SELECT * FROM cloud_vas WHERE operator_id=? ORDER BY id DESC`)
const createVasStmt = db.prepare(`INSERT INTO cloud_vas (operator_id, name, config_text, access_text, access_text_enc, access_text_iv, access_text_tag) VALUES (@operator_id, @name, @config_text, '', @access_text_enc, @access_text_iv, @access_text_tag)`)
const updateVasStmt = db.prepare(`UPDATE cloud_vas SET operator_id=@operator_id, name=@name, config_text=@config_text, access_text='', access_text_enc=@access_text_enc, access_text_iv=@access_text_iv, access_text_tag=@access_text_tag, updated_at=datetime('now') WHERE id=@id`)
const deleteVasStmt = db.prepare(`DELETE FROM cloud_vas WHERE id=?`)
const listRenew = db.prepare(`SELECT * FROM cloud_renew ORDER BY paid_date DESC, id DESC`)
const listRenewBySubject = db.prepare(`SELECT * FROM cloud_renew WHERE subject_type=? AND subject_id=? ORDER BY paid_date DESC, id DESC`)
const createRenewStmt = db.prepare(`INSERT INTO cloud_renew (subject_type, subject_id, paid_date, amount_cents, invoice_path, payer_company, expire_date, next_expected_amount_cents) VALUES (@subject_type, @subject_id, @paid_date, @amount_cents, @invoice_path, @payer_company, @expire_date, @next_expected_amount_cents)`)
const updateRenewStmt = db.prepare(`UPDATE cloud_renew SET subject_type=@subject_type, subject_id=@subject_id, paid_date=@paid_date, amount_cents=@amount_cents, invoice_path=@invoice_path, payer_company=@payer_company, expire_date=@expire_date, next_expected_amount_cents=@next_expected_amount_cents, updated_at=datetime('now') WHERE id=@id`)
const deleteRenewStmt = db.prepare(`DELETE FROM cloud_renew WHERE id=?`)
const listNotes = db.prepare(`SELECT * FROM cloud_notes ORDER BY id DESC`)
const listNotesBySubject = db.prepare(`SELECT * FROM cloud_notes WHERE subject_type=? AND subject_id=? ORDER BY id DESC`)
const createNoteStmt = db.prepare(`INSERT INTO cloud_notes (subject_type, subject_id, issue_text, solution_text) VALUES (@subject_type, @subject_id, @issue_text, @solution_text)`)
const updateNoteStmt = db.prepare(`UPDATE cloud_notes SET subject_type=@subject_type, subject_id=@subject_id, issue_text=@issue_text, solution_text=@solution_text, updated_at=datetime('now') WHERE id=@id`)
const deleteNoteStmt = db.prepare(`DELETE FROM cloud_notes WHERE id=?`)
const listAttachmentsByNote = db.prepare(`SELECT * FROM cloud_attachments WHERE note_id=? ORDER BY id ASC`)
const createAttachmentStmt = db.prepare(`INSERT INTO cloud_attachments (note_id, filename, file_path, file_size) VALUES (@note_id, @filename, @file_path, @file_size)`)
const deleteAttachmentStmt = db.prepare(`DELETE FROM cloud_attachments WHERE id=?`)
const listOperatorsFn = () => listOperators.all().map(r => { const lp = (r.login_password_enc && r.login_password_iv && r.login_password_tag) ? dec(r.login_password_enc, r.login_password_iv, r.login_password_tag) : (r.login_password || '') ; r.login_password = lp; return r })
const createOperator = payload => { const e = enc(payload.login_password||''); const next = Object.assign({}, payload, { login_password_enc: e.ct, login_password_iv: e.iv, login_password_tag: e.tag }); createOperatorStmt.run(next); logJSON('cloud.operator.create', { name: payload.name }, 'cloud') }
const updateOperator = payload => { const e = enc(payload.login_password||''); const next = Object.assign({}, payload, { login_password_enc: e.ct, login_password_iv: e.iv, login_password_tag: e.tag }); updateOperatorStmt.run(next); logJSON('cloud.operator.update', { id: payload.id }, 'cloud') }
const deleteOperator = id => { deleteOperatorStmt.run(id); logJSON('cloud.operator.delete', { id }, 'cloud') }
const listEcsFn = (operator_id) => (operator_id ? listEcsByOperator.all(operator_id) : listEcs.all()).map(r => { const pw = (r.rdp_password_enc && r.rdp_password_iv && r.rdp_password_tag) ? dec(r.rdp_password_enc, r.rdp_password_iv, r.rdp_password_tag) : (r.rdp_password || '') ; r.rdp_password = pw; return r })
const createEcs = payload => { const e = enc(payload.rdp_password||''); const next = Object.assign({}, payload, { rdp_password_enc: e.ct, rdp_password_iv: e.iv, rdp_password_tag: e.tag }); createEcsStmt.run(next); logJSON('cloud.ecs.create', { operator_id: payload.operator_id }, 'cloud') }
const updateEcs = payload => { const e = enc(payload.rdp_password||''); const next = Object.assign({}, payload, { rdp_password_enc: e.ct, rdp_password_iv: e.iv, rdp_password_tag: e.tag }); updateEcsStmt.run(next); logJSON('cloud.ecs.update', { id: payload.id }, 'cloud') }
const deleteEcs = id => { deleteEcsStmt.run(id); logJSON('cloud.ecs.delete', { id }, 'cloud') }
const listVasFn = (operator_id) => (operator_id ? listVasByOperator.all(operator_id) : listVas.all()).map(r => { const tx = (r.access_text_enc && r.access_text_iv && r.access_text_tag) ? dec(r.access_text_enc, r.access_text_iv, r.access_text_tag) : (r.access_text || '') ; r.access_text = tx; return r })
const createVas = payload => { const e = enc(payload.access_text||''); const next = Object.assign({}, payload, { access_text_enc: e.ct, access_text_iv: e.iv, access_text_tag: e.tag }); createVasStmt.run(next); logJSON('cloud.vas.create', { operator_id: payload.operator_id }, 'cloud') }
const updateVas = payload => { const e = enc(payload.access_text||''); const next = Object.assign({}, payload, { access_text_enc: e.ct, access_text_iv: e.iv, access_text_tag: e.tag }); updateVasStmt.run(next); logJSON('cloud.vas.update', { id: payload.id }, 'cloud') }
const deleteVas = id => { deleteVasStmt.run(id); logJSON('cloud.vas.delete', { id }, 'cloud') }
const listRenewFn = (subject_type, subject_id) => (subject_type && subject_id) ? listRenewBySubject.all(subject_type, subject_id) : listRenew.all()
const createRenew = payload => { createRenewStmt.run(payload); logJSON('cloud.renew.create', payload, 'cloud') }
const updateRenew = payload => { updateRenewStmt.run(payload); logJSON('cloud.renew.update', payload, 'cloud') }
const deleteRenew = id => { deleteRenewStmt.run(id); logJSON('cloud.renew.delete', { id }, 'cloud') }
const listNotesFn = (subject_type, subject_id) => {
const notes = (subject_type && subject_id) ? listNotesBySubject.all(subject_type, subject_id) : listNotes.all()
return notes.map(n => {
n.attachments = listAttachmentsByNote.all(n.id)
return n
})
}
const createNote = payload => {
const info = createNoteStmt.run(payload)
logJSON('cloud.note.create', { id: info.lastInsertRowid, ...payload }, 'cloud')
return info.lastInsertRowid
}
const updateNote = payload => {
updateNoteStmt.run(payload)
logJSON('cloud.note.update', payload, 'cloud')
}
const deleteNote = id => {
// Attachments are cascade deleted, but files remain. We should delete files ideally.
// For simplicity, we just delete records. A cleanup job can remove orphaned files.
deleteNoteStmt.run(id)
logJSON('cloud.note.delete', { id }, 'cloud')
}
const createAttachment = payload => {
const info = createAttachmentStmt.run(payload)
logJSON('cloud.attachment.create', { id: info.lastInsertRowid, ...payload }, 'cloud')
return info.lastInsertRowid
}
const deleteAttachment = id => {
deleteAttachmentStmt.run(id)
logJSON('cloud.attachment.delete', { id }, 'cloud')
}
module.exports = { listOperatorsFn, createOperator, updateOperator, deleteOperator, listEcsFn, createEcs, updateEcs, deleteEcs, listVasFn, createVas, updateVas, deleteVas, listRenewFn, createRenew, updateRenew, deleteRenew, listNotesFn, createNote, updateNote, deleteNote, createAttachment, deleteAttachment }
+974
View File
@@ -0,0 +1,974 @@
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const axios = require('axios')
const { ImapFlow } = require('imapflow')
const { simpleParser } = require('mailparser')
const { db } = require('./db')
const { logJSON } = require('./logger')
const cfgPath = path.join(process.cwd(), 'config', 'cloud_balance_watch.json')
const defaultConfig = () => {
const currentPort = Number(process.env.PORT || '8976') || 8976
return {
_comment: '阿里云欠费邮件监听与分发配置',
enable_listener_comment: '是否启用欠费邮件监听服务(true/false)',
enable_listener: true,
automation_port_comment: '自动服务监听端口,仅当当前 PORT 与此值一致时才启动后台监听',
automation_port: 8977,
provider_comment: '邮箱提供方标识,仅用于说明',
provider: 'aliyun_qiye_mail',
mailbox_comment: '要监听的邮箱文件夹名称',
mailbox: 'INBOX',
reconnect_delay_ms_comment: 'IMAP 断开后的重连等待毫秒数',
reconnect_delay_ms: 15000,
poll_interval_ms_comment: '兜底轮询间隔毫秒数,用于弥补 IDLE 掉线或事件漏触发',
poll_interval_ms: 120000,
health_stale_ms_comment: '超过该毫秒数未成功扫描则视为监听异常',
health_stale_ms: 300000,
fetch_batch_comment: '每次补扫最多处理多少封新邮件,避免长时间阻塞',
fetch_batch: 50,
imap_comment: 'IMAP 收信配置(敏感信息请妥善保管)',
imap: {
host_comment: 'IMAP 服务器地址',
host: 'imap.qiye.aliyun.com',
port_comment: 'IMAP 端口,SSL 通常为 993',
port: 993,
secure_comment: '是否使用 SSL/TLS',
secure: true,
user_comment: '邮箱账号',
user: 'yangxiangyuan@umer.com.cn',
pass_comment: '邮箱授权码或应用密码(敏感,请勿泄露),从环境变量读取',
pass: process.env.YANGXIANGYUAN_EMAIL_PASS || 'y7FKf48oodBj3Bcb'
},
match_rules_comment: '邮件匹配规则,只抓指定发件人的可用额度/欠费类通知',
match_rules: {
sender_exact_comment: '发件人邮箱精确匹配',
sender_exact: 'system@notice.aliyun.com',
subject_keywords_comment: '主题模糊匹配关键词,命中任意一个即可参与判定',
subject_keywords: [
'可用额度',
'预警值',
'余额',
'欠费',
'提醒'
],
body_keywords_comment: '正文二次确认关键词,降低主题改名时的漏报风险',
body_keywords: [
'阿里云',
'可用额度',
'预警值',
'余额',
'欠费'
]
},
calendar_sync_enabled_comment: '是否启用同步到日历提醒',
calendar_sync_enabled: true,
calendar_api_base_comment: '日历提醒写入地址,沿用现有 /api/calendar/events 口径',
calendar_api_base: `http://127.0.0.1:${currentPort}/api/calendar/events`,
weekly_sync_enabled_comment: '是否启用同步到周报系统(按要求默认预留,不发送)',
weekly_sync_enabled: false,
weekly_api_base_comment: '周报系统基础地址(不含 /ext/event)',
weekly_api_base: `http://127.0.0.1:${currentPort}/api/weekly`,
weekly_key_comment: '推送到周报 /api/weekly/ext/event 使用的密钥',
weekly_key: process.env.CLOUD_BALANCE_WATCH_WEEKLY_KEY || process.env.SYSTEM_AUTH_PASS_93220 || '',
}
}
if (!fs.existsSync(path.dirname(cfgPath))) fs.mkdirSync(path.dirname(cfgPath), { recursive: true })
if (!fs.existsSync(cfgPath)) fs.writeFileSync(cfgPath, JSON.stringify(defaultConfig(), null, 2))
const readConfig = () => {
try {
return JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
} catch {
return defaultConfig()
}
}
db.exec(`CREATE TABLE IF NOT EXISTS cloud_balance_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
alert_key TEXT NOT NULL UNIQUE,
message_id TEXT,
message_uid INTEGER,
mailbox TEXT,
sender TEXT NOT NULL,
subject TEXT NOT NULL,
subject_norm TEXT,
received_at TEXT,
text_body TEXT,
html_body TEXT,
raw_headers TEXT,
match_reason TEXT,
risk_level TEXT DEFAULT 'medium',
seen_marked INTEGER DEFAULT 0,
seen_marked_at TEXT,
calendar_event_id TEXT,
calendar_sync_status TEXT DEFAULT 'pending',
calendar_sync_error TEXT,
calendar_sync_updated_at TEXT,
weekly_external_id TEXT,
weekly_sync_status TEXT DEFAULT 'disabled',
weekly_sync_error TEXT,
weekly_sync_updated_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS cloud_balance_state (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS cloud_balance_dispatch_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
alert_id INTEGER,
channel TEXT NOT NULL,
status TEXT NOT NULL,
detail TEXT,
created_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_cloud_balance_message_uid ON cloud_balance_alerts(message_uid);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_cloud_balance_mailbox_uid ON cloud_balance_alerts(mailbox, message_uid);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_cloud_balance_message_id ON cloud_balance_alerts(message_id);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_cloud_balance_received_at ON cloud_balance_alerts(received_at DESC);`)
const getStateStmt = db.prepare(`SELECT value FROM cloud_balance_state WHERE key = ?`)
const setStateStmt = db.prepare(`INSERT INTO cloud_balance_state (key, value, updated_at)
VALUES (@key, @value, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`)
const insertDispatchLogStmt = db.prepare(`INSERT INTO cloud_balance_dispatch_logs (alert_id, channel, status, detail) VALUES (@alert_id, @channel, @status, @detail)`)
const countAlertsStmt = db.prepare(`SELECT COUNT(1) AS total FROM cloud_balance_alerts WHERE (? = '' OR subject LIKE ? OR text_body LIKE ?)` )
const listAlertsStmt = db.prepare(`SELECT id, sender, subject, received_at, text_body, html_body, risk_level, match_reason, seen_marked, seen_marked_at, calendar_event_id, calendar_sync_status, calendar_sync_error, weekly_external_id, weekly_sync_status, weekly_sync_error, created_at, updated_at
FROM cloud_balance_alerts
WHERE (? = '' OR subject LIKE ? OR text_body LIKE ?)
ORDER BY datetime(received_at) DESC, id DESC
LIMIT ? OFFSET ?`)
const getAlertByIdStmt = db.prepare(`SELECT * FROM cloud_balance_alerts WHERE id = ?`)
const getAlertByKeyStmt = db.prepare(`SELECT * FROM cloud_balance_alerts WHERE alert_key = ?`)
const getAlertByMailboxUidStmt = db.prepare(`SELECT * FROM cloud_balance_alerts WHERE mailbox = ? AND message_uid = ? ORDER BY id DESC LIMIT 1`)
const getAlertByMessageIdStmt = db.prepare(`SELECT * FROM cloud_balance_alerts WHERE message_id = ? ORDER BY id DESC LIMIT 1`)
const getAlertByCalendarIdStmt = db.prepare(`SELECT * FROM cloud_balance_alerts WHERE calendar_event_id = ?`)
const insertAlertStmt = db.prepare(`INSERT INTO cloud_balance_alerts (
alert_key, message_id, message_uid, mailbox, sender, subject, subject_norm, received_at, text_body, html_body, raw_headers, match_reason, risk_level, seen_marked, seen_marked_at, calendar_event_id, weekly_external_id, calendar_sync_status, weekly_sync_status
) VALUES (
@alert_key, @message_id, @message_uid, @mailbox, @sender, @subject, @subject_norm, @received_at, @text_body, @html_body, @raw_headers, @match_reason, @risk_level, @seen_marked, @seen_marked_at, @calendar_event_id, @weekly_external_id, @calendar_sync_status, @weekly_sync_status
)`)
const updateAlertStmt = db.prepare(`UPDATE cloud_balance_alerts SET
message_id = @message_id,
message_uid = @message_uid,
mailbox = @mailbox,
sender = @sender,
subject = @subject,
subject_norm = @subject_norm,
received_at = @received_at,
text_body = @text_body,
html_body = @html_body,
raw_headers = @raw_headers,
match_reason = @match_reason,
risk_level = @risk_level,
updated_at = datetime('now')
WHERE id = @id`)
const updateSeenStmt = db.prepare(`UPDATE cloud_balance_alerts SET seen_marked = 1, seen_marked_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`)
const updateCalendarSyncStmt = db.prepare(`UPDATE cloud_balance_alerts SET
calendar_event_id = @calendar_event_id,
calendar_sync_status = @calendar_sync_status,
calendar_sync_error = @calendar_sync_error,
calendar_sync_updated_at = datetime('now'),
updated_at = datetime('now')
WHERE id = @id`)
const updateWeeklySyncStmt = db.prepare(`UPDATE cloud_balance_alerts SET
weekly_external_id = @weekly_external_id,
weekly_sync_status = @weekly_sync_status,
weekly_sync_error = @weekly_sync_error,
weekly_sync_updated_at = datetime('now'),
updated_at = datetime('now')
WHERE id = @id`)
const updateAlertTextBodyStmt = db.prepare(`UPDATE cloud_balance_alerts SET
text_body = @text_body,
updated_at = datetime('now')
WHERE id = @id`)
const listAlertsMissingTextStmt = db.prepare(`SELECT id, html_body FROM cloud_balance_alerts WHERE (text_body IS NULL OR trim(text_body) = '') AND html_body IS NOT NULL AND trim(html_body) <> ''`)
const recentAlertsSummaryStmt = db.prepare(`SELECT
COUNT(1) AS total,
SUM(CASE WHEN risk_level = 'high' THEN 1 ELSE 0 END) AS high_risk_count,
SUM(CASE WHEN calendar_sync_status = 'ok' THEN 1 ELSE 0 END) AS calendar_ok_count,
SUM(CASE WHEN calendar_sync_status = 'error' THEN 1 ELSE 0 END) AS calendar_error_count,
SUM(CASE WHEN weekly_sync_status = 'error' THEN 1 ELSE 0 END) AS weekly_error_count
FROM cloud_balance_alerts`)
const listenerState = {
enabled: false,
running: false,
connected: false,
mailbox: '',
current_port: Number(process.env.PORT || '8976') || 8976,
last_error: '',
last_connect_at: '',
last_scan_started_at: '',
last_scan_finished_at: '',
last_scan_reason: '',
reconnect_scheduled: false
}
let client = null
let pollTimer = null
let reconnectTimer = null
let scanPromise = null
let startInvoked = false
const getScanRuntimeState = () => ({
scan_in_progress: !!scanPromise,
last_scan_started_at: listenerState.last_scan_started_at || '',
last_scan_finished_at: listenerState.last_scan_finished_at || '',
last_scan_reason: listenerState.last_scan_reason || '',
last_error: listenerState.last_error || ''
})
const isoNow = () => new Date().toISOString()
const safeString = (value) => String(value == null ? '' : value).trim()
const normalizeSpace = (value) => safeString(value).replace(/\s+/g, ' ')
const sha1 = value => crypto.createHash('sha1').update(String(value || '')).digest('hex')
// #region debug-point cloud-balance-debug-helper
const reportCloudBalanceDebug = (hypothesisId, location, msg, data = {}) => {
try {
const envPath = path.join(process.cwd(), '.dbg', 'cloud-balance-stuck.env')
let debugUrl = 'http://127.0.0.1:7777/event'
let sessionId = 'cloud-balance-stuck'
try {
const envText = fs.readFileSync(envPath, 'utf8')
debugUrl = (envText.match(/DEBUG_SERVER_URL=(.+)/) || [])[1] || debugUrl
sessionId = (envText.match(/DEBUG_SESSION_ID=(.+)/) || [])[1] || sessionId
} catch {}
fetch(debugUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId,
runId: 'pre-fix',
hypothesisId,
location,
msg,
data,
ts: Date.now()
})
}).catch(() => {})
} catch {}
}
// #endregion
const finalizeScanState = ({ highestCommittedUid, fetched, matched, inserted, updated, finishedAt, summaryFinishedAt }) => {
const finalFinishedAt = safeString(finishedAt) || isoNow()
const finalSummary = {
fetched: Number(fetched || 0) || 0,
matched: Number(matched || 0) || 0,
inserted: Number(inserted || 0) || 0,
updated: Number(updated || 0) || 0,
last_uid: Number(highestCommittedUid || 0) || 0,
finished_at: safeString(summaryFinishedAt) || finalFinishedAt
}
setState('last_uid', String(Number(highestCommittedUid || 0) || 0))
listenerState.last_scan_finished_at = finalFinishedAt
setState('last_scan_finished_at', finalFinishedAt)
setState('last_scan_summary', finalSummary)
}
const setState = (key, value) => setStateStmt.run({ key, value: typeof value === 'string' ? value : JSON.stringify(value) })
const getState = (key, fallback = '') => {
const row = getStateStmt.get(key)
return row && row.value != null ? row.value : fallback
}
const writeDispatchLog = (alertId, channel, status, detail) => {
try {
insertDispatchLogStmt.run({
alert_id: alertId || null,
channel: safeString(channel) || 'unknown',
status: safeString(status) || 'unknown',
detail: typeof detail === 'string' ? detail : JSON.stringify(detail || {})
})
} catch {}
}
const shouldStartListener = (cfg = readConfig()) => {
if (cfg.enable_listener === false) return false
const currentPort = Number(process.env.PORT || '8976') || 8976
const automationPort = Number(cfg.automation_port || 8977) || 8977
return currentPort === automationPort
}
const htmlToText = html => safeString(html)
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, '\'')
.replace(/\s+/g, ' ')
.trim()
const normalizeMailBody = (textBody, htmlBody) => {
const text = normalizeSpace(textBody)
if (text) return text
return normalizeSpace(htmlToText(htmlBody))
}
const backfillAlertTextBodies = () => {
try {
const rows = listAlertsMissingTextStmt.all()
rows.forEach(row => {
const fallback = normalizeMailBody('', row.html_body || '')
if (fallback) updateAlertTextBodyStmt.run({ id: row.id, text_body: fallback })
})
} catch {}
}
backfillAlertTextBodies()
const extractSender = parsed => {
try {
const address = parsed && parsed.from && parsed.from.value && parsed.from.value[0] && parsed.from.value[0].address
if (address) return safeString(address).toLowerCase()
} catch {}
return ''
}
const matchesAlert = (parsed, cfg) => {
const rules = cfg.match_rules || {}
const sender = extractSender(parsed)
const senderExact = safeString(rules.sender_exact).toLowerCase()
const subject = normalizeSpace(parsed && parsed.subject)
const textBody = normalizeMailBody(parsed && parsed.text, parsed && parsed.html)
const htmlBody = safeString(parsed && parsed.html)
const fullBody = normalizeSpace(`${textBody}\n${htmlToText(htmlBody)}`)
const subjectLower = subject.toLowerCase()
const bodyLower = fullBody.toLowerCase()
const subjectKeywords = Array.isArray(rules.subject_keywords) ? rules.subject_keywords.map(item => safeString(item).toLowerCase()).filter(Boolean) : []
const bodyKeywords = Array.isArray(rules.body_keywords) ? rules.body_keywords.map(item => safeString(item).toLowerCase()).filter(Boolean) : []
const subjectHits = subjectKeywords.filter(keyword => subjectLower.includes(keyword))
const bodyHits = bodyKeywords.filter(keyword => bodyLower.includes(keyword))
const bodyHitsStrict = bodyHits.filter(keyword => keyword !== '阿里云')
const senderMatched = !!senderExact && sender === senderExact
const subjectMatched = subjectHits.length > 0
const bodyMatched = bodyHitsStrict.length > 0
const matched = senderMatched && (subjectMatched || bodyMatched)
const riskLevel = subjectMatched && bodyMatched ? 'high' : (matched ? 'medium' : 'low')
const reasons = []
if (senderMatched) reasons.push(`发件人=${sender}`)
if (subjectHits.length) reasons.push(`标题命中:${subjectHits.join('|')}`)
if (bodyHitsStrict.length) reasons.push(`正文命中:${bodyHitsStrict.join('|')}`)
return {
matched,
sender,
subject,
textBody,
htmlBody,
riskLevel,
matchReason: reasons.join(';')
}
}
const buildHeadersSnapshot = parsed => {
const snap = {}
try {
snap.messageId = safeString(parsed && parsed.messageId)
snap.date = parsed && parsed.date ? new Date(parsed.date).toISOString() : ''
snap.from = extractSender(parsed)
snap.to = parsed && parsed.to && parsed.to.text ? safeString(parsed.to.text) : ''
} catch {}
return JSON.stringify(snap)
}
const buildAlertKey = payload => {
const mailbox = safeString(payload.mailbox || 'INBOX')
const messageUid = Number(payload.message_uid || 0) || 0
const messageId = safeString(payload.message_id)
let raw = ''
if (mailbox && messageUid > 0) raw = `mailbox-uid|${mailbox}|${messageUid}`
else if (messageId) raw = `message-id|${messageId}`
else raw = `${payload.sender}|${payload.subject_norm}|${payload.received_at}|${payload.message_uid || ''}`
return sha1(raw)
}
const toSqliteDateTime = value => {
if (!value) return isoNow().replace('T', ' ').slice(0, 19)
try {
return new Date(value).toISOString().replace('T', ' ').slice(0, 19)
} catch {
return isoNow().replace('T', ' ').slice(0, 19)
}
}
const upsertAlert = payload => {
const alertKey = buildAlertKey(payload)
const next = Object.assign({}, payload, {
alert_key: alertKey,
subject_norm: normalizeSpace(payload.subject).toLowerCase(),
received_at: toSqliteDateTime(payload.received_at),
calendar_event_id: payload.calendar_event_id || `cloud-arrear-${alertKey.slice(0, 16)}`,
weekly_external_id: payload.weekly_external_id || `cloud-arrear-${alertKey.slice(0, 16)}`,
calendar_sync_status: payload.calendar_sync_status || 'pending',
weekly_sync_status: payload.weekly_sync_status || 'disabled',
seen_marked: payload.seen_marked ? 1 : 0,
seen_marked_at: payload.seen_marked ? toSqliteDateTime(payload.seen_marked_at || isoNow()) : null
})
const existing = (() => {
const mailbox = safeString(next.mailbox || 'INBOX')
const messageUid = Number(next.message_uid || 0) || 0
const messageId = safeString(next.message_id)
if (mailbox && messageUid > 0) {
const row = getAlertByMailboxUidStmt.get(mailbox, messageUid)
if (row) return row
}
if (messageId) {
const row = getAlertByMessageIdStmt.get(messageId)
if (row) return row
}
return getAlertByKeyStmt.get(alertKey)
})()
if (existing) {
updateAlertStmt.run(Object.assign({}, next, { id: existing.id }))
return Object.assign({}, getAlertByIdStmt.get(existing.id), { created: false })
}
const info = insertAlertStmt.run(next)
return Object.assign({}, getAlertByIdStmt.get(info.lastInsertRowid), { created: true })
}
const buildCalendarPayload = alert => {
const start = (alert.received_at || '').replace(' ', 'T')
const detailLines = [
`来源邮箱: ${alert.sender}`,
`接收时间: ${alert.received_at || ''}`,
`本地告警ID: ${alert.id}`,
`关联主键: ${alert.calendar_event_id}`,
`匹配原因: ${alert.match_reason || ''}`,
'',
'邮件正文:',
safeString(alert.text_body || htmlToText(alert.html_body || '')) || '(空)'
]
return {
id: alert.calendar_event_id,
title: `[欠费通知] ${safeString(alert.subject) || '阿里云额度提醒'}`,
content: detailLines.join('\n'),
start,
end: start,
remindAt: start,
allDay: 0,
deleted: false,
createdAt: isoNow(),
updatedAt: isoNow()
}
}
const dispatchCalendar = async (alert, cfg = readConfig()) => {
const enabled = cfg.calendar_sync_enabled !== false
const calendarEventId = alert.calendar_event_id || `cloud-arrear-${alert.alert_key.slice(0, 16)}`
if (!enabled) {
updateCalendarSyncStmt.run({ id: alert.id, calendar_event_id: calendarEventId, calendar_sync_status: 'disabled', calendar_sync_error: '' })
return { ok: false, skipped: true, reason: 'calendar_disabled' }
}
const url = safeString(cfg.calendar_api_base)
if (!url) {
updateCalendarSyncStmt.run({ id: alert.id, calendar_event_id: calendarEventId, calendar_sync_status: 'error', calendar_sync_error: 'calendar_api_base_missing' })
return { ok: false, error: 'calendar_api_base_missing' }
}
try {
await axios.post(url, buildCalendarPayload(Object.assign({}, alert, { calendar_event_id: calendarEventId })), {
timeout: 15000,
headers: { 'Content-Type': 'application/json' }
})
updateCalendarSyncStmt.run({ id: alert.id, calendar_event_id: calendarEventId, calendar_sync_status: 'ok', calendar_sync_error: '' })
writeDispatchLog(alert.id, 'calendar', 'ok', { url, event_id: calendarEventId })
return { ok: true, event_id: calendarEventId }
} catch (e) {
const error = String(e && e.message ? e.message : e)
updateCalendarSyncStmt.run({ id: alert.id, calendar_event_id: calendarEventId, calendar_sync_status: 'error', calendar_sync_error: error })
writeDispatchLog(alert.id, 'calendar', 'error', { url, error })
return { ok: false, error, event_id: calendarEventId }
}
}
const dispatchWeekly = async (alert, cfg = readConfig()) => {
const externalId = alert.weekly_external_id || `cloud-arrear-${alert.alert_key.slice(0, 16)}`
if (cfg.weekly_sync_enabled !== true) {
updateWeeklySyncStmt.run({ id: alert.id, weekly_external_id: externalId, weekly_sync_status: 'disabled', weekly_sync_error: '' })
return { ok: false, skipped: true, reason: 'weekly_disabled' }
}
const base = safeString(cfg.weekly_api_base)
if (!base) {
updateWeeklySyncStmt.run({ id: alert.id, weekly_external_id: externalId, weekly_sync_status: 'error', weekly_sync_error: 'weekly_api_base_missing' })
return { ok: false, error: 'weekly_api_base_missing' }
}
try {
await axios.post(`${base.replace(/\/$/, '')}/ext/event`, {
key: safeString(cfg.weekly_key) || process.env.SYSTEM_AUTH_PASS_93220 || '',
source: 'cloud_arrear',
external_id: externalId,
date: String(alert.received_at || '').slice(0, 10),
title: `[欠费通知] ${safeString(alert.subject) || '阿里云额度提醒'}`,
content: safeString(alert.text_body || htmlToText(alert.html_body || '')),
status_code: 'new',
status_label: '⚠️欠费通知',
deadline: (alert.received_at || '').replace(' ', 'T')
}, {
timeout: 15000,
headers: { 'Content-Type': 'application/json' }
})
updateWeeklySyncStmt.run({ id: alert.id, weekly_external_id: externalId, weekly_sync_status: 'ok', weekly_sync_error: '' })
writeDispatchLog(alert.id, 'weekly', 'ok', { external_id: externalId })
return { ok: true, external_id: externalId }
} catch (e) {
const error = String(e && e.message ? e.message : e)
updateWeeklySyncStmt.run({ id: alert.id, weekly_external_id: externalId, weekly_sync_status: 'error', weekly_sync_error: error })
writeDispatchLog(alert.id, 'weekly', 'error', { external_id: externalId, error })
return { ok: false, error, external_id: externalId }
}
}
const markSeen = async (uid, alertId) => {
// #region debug-point cloud-balance-mark-seen-start
reportCloudBalanceDebug('D', 'cloud_balance_watch.js:markSeen:start', '[DEBUG] mark seen start', { uid, alertId })
// #endregion
if (!client) return
await client.messageFlagsAdd(uid, ['\\Seen'], { uid: true })
updateSeenStmt.run(alertId)
// #region debug-point cloud-balance-mark-seen-done
reportCloudBalanceDebug('D', 'cloud_balance_watch.js:markSeen:done', '[DEBUG] mark seen done', { uid, alertId })
// #endregion
}
const processParsedMessage = async (parsed, meta, cfg) => {
const match = matchesAlert(parsed, cfg)
// #region debug-point cloud-balance-process-message
reportCloudBalanceDebug('B', 'cloud_balance_watch.js:processParsedMessage', '[DEBUG] parsed message result', {
uid: meta && meta.uid,
mailbox: meta && meta.mailbox,
sender: match && match.sender,
subject: match && match.subject,
matched: !!(match && match.matched),
matchReason: match && match.matchReason
})
// #endregion
if (!match.matched) return { matched: false }
const row = upsertAlert({
message_id: safeString(parsed && parsed.messageId),
message_uid: meta.uid,
mailbox: meta.mailbox,
sender: match.sender,
subject: match.subject,
received_at: parsed && parsed.date ? parsed.date.toISOString() : isoNow(),
text_body: match.textBody,
html_body: match.htmlBody,
raw_headers: buildHeadersSnapshot(parsed),
match_reason: match.matchReason,
risk_level: match.riskLevel
})
await markSeen(meta.uid, row.id)
const fresh = getAlertByIdStmt.get(row.id)
await dispatchCalendar(fresh, cfg)
await dispatchWeekly(getAlertByIdStmt.get(row.id), cfg)
return { matched: true, created: !!row.created, id: row.id }
}
const closeClient = async () => {
const current = client
client = null
listenerState.connected = false
if (!current) return
try { current.removeAllListeners() } catch {}
try { await current.logout() } catch {}
}
const scheduleReconnect = () => {
if (reconnectTimer || !shouldStartListener(readConfig())) return
listenerState.reconnect_scheduled = true
const delay = Math.max(5000, Number(readConfig().reconnect_delay_ms || 15000) || 15000)
reconnectTimer = setTimeout(async () => {
reconnectTimer = null
listenerState.reconnect_scheduled = false
try {
await connectClient('reconnect')
} catch {}
}, delay)
}
const connectClient = async (reason = 'manual') => {
const cfg = readConfig()
if (!shouldStartListener(cfg)) {
listenerState.enabled = false
listenerState.running = false
return false
}
await closeClient()
const imapCfg = cfg.imap || {}
const nextClient = new ImapFlow({
host: safeString(imapCfg.host),
port: Number(imapCfg.port || 993) || 993,
secure: imapCfg.secure !== false,
auth: {
user: safeString(imapCfg.user),
pass: safeString(imapCfg.pass)
},
logger: false,
disableAutoIdle: false,
clientInfo: { name: 'TRAE-Toolbox', version: '1.0.0' }
})
nextClient.on('error', err => {
listenerState.last_error = String(err && err.message ? err.message : err)
logJSON('cloud.balance.listener.error', { error: listenerState.last_error }, 'cloud')
// #region debug-point cloud-balance-listener-error
reportCloudBalanceDebug('A', 'cloud_balance_watch.js:connectClient:error', '[DEBUG] listener error', { reason, error: listenerState.last_error })
// #endregion
})
nextClient.on('close', () => {
listenerState.connected = false
scheduleReconnect()
// #region debug-point cloud-balance-listener-close
reportCloudBalanceDebug('A', 'cloud_balance_watch.js:connectClient:close', '[DEBUG] listener close', { reason, reconnect_scheduled: true })
// #endregion
})
nextClient.on('exists', () => {
triggerScan('exists').catch(() => {})
})
await nextClient.connect()
await nextClient.mailboxOpen(safeString(cfg.mailbox) || 'INBOX', { readOnly: false })
// #region debug-point cloud-balance-connect-done
reportCloudBalanceDebug('C', 'cloud_balance_watch.js:connectClient:done', '[DEBUG] mailbox connected', {
reason,
mailbox: safeString(cfg.mailbox) || 'INBOX',
host: safeString(imapCfg.host),
port: Number(imapCfg.port || 993) || 993
})
// #endregion
client = nextClient
listenerState.enabled = true
listenerState.running = true
listenerState.connected = true
listenerState.mailbox = safeString(cfg.mailbox) || 'INBOX'
listenerState.last_connect_at = isoNow()
listenerState.last_error = ''
setState('last_connect_reason', reason)
return true
}
const scanMailbox = async reason => {
const cfg = readConfig()
if (!shouldStartListener(cfg)) return { ok: false, skipped: true, reason: 'not_automation_port' }
if (!client) await connectClient(reason)
listenerState.last_scan_reason = reason
listenerState.last_scan_started_at = isoNow()
setState('last_scan_started_at', listenerState.last_scan_started_at)
setState('last_scan_reason', reason)
let lastUid = Number(getState('last_uid', '0') || '0') || 0
let lastSenderHistoryUid = Number(getState('last_sender_history_uid', '0') || '0') || 0
let highestCommittedUid = lastUid
const batchLimit = Math.max(1, Number(cfg.fetch_batch || 50) || 50)
let fetched = 0
let matched = 0
let inserted = 0
let updated = 0
const processedUids = new Set()
try {
const range = `${Math.max(1, lastUid + 1)}:*`
// #region debug-point cloud-balance-scan-start
reportCloudBalanceDebug('A', 'cloud_balance_watch.js:scanMailbox:start', '[DEBUG] scan start', {
reason,
lastUid,
range,
batchLimit,
mailbox: listenerState.mailbox || safeString(cfg.mailbox) || 'INBOX'
})
// #endregion
const processMessage = async (message, sourceTag) => {
if (!message || !message.uid || processedUids.has(message.uid)) return
processedUids.add(message.uid)
fetched += 1
if (message.uid > highestCommittedUid) highestCommittedUid = message.uid
try {
// #region debug-point cloud-balance-message-fetch
reportCloudBalanceDebug('A', 'cloud_balance_watch.js:scanMailbox:message', '[DEBUG] processing message', {
uid: message.uid,
sourceTag,
fetched,
highestCommittedUid
})
// #endregion
const parsed = await simpleParser(message.source)
const result = await processParsedMessage(parsed, {
uid: message.uid,
mailbox: listenerState.mailbox || safeString(cfg.mailbox) || 'INBOX'
}, cfg)
if (result.matched) {
matched += 1
if (result.created) inserted += 1
else updated += 1
}
} catch (e) {
listenerState.last_error = String(e && e.message ? e.message : e)
logJSON('cloud.balance.scan.message.error', { uid: message.uid, error: listenerState.last_error }, 'cloud')
// #region debug-point cloud-balance-message-error
reportCloudBalanceDebug('D', 'cloud_balance_watch.js:scanMailbox:message:error', '[DEBUG] message error', {
uid: message && message.uid,
sourceTag,
error: listenerState.last_error
})
// #endregion
throw e
}
}
for await (const message of client.fetch(range, {
uid: true,
envelope: true,
flags: true,
internalDate: true,
source: true
}, { uid: true })) {
if (!message || !message.uid) continue
if (message.uid <= lastUid) {
highestCommittedUid = Math.max(highestCommittedUid, message.uid)
continue
}
await processMessage(message, 'uid-forward')
if (fetched >= batchLimit) break
}
let legacyCandidateUids = []
if (client && listenerState.connected) {
try {
const senderExact = safeString(cfg && cfg.match_rules && cfg.match_rules.sender_exact).toLowerCase()
const searchQuery = senderExact ? { from: senderExact } : { seen: false }
const legacyUidsRaw = await client.search(searchQuery, { uid: true })
// #region debug-point cloud-balance-search-unseen
reportCloudBalanceDebug('C', 'cloud_balance_watch.js:scanMailbox:search-unseen', '[DEBUG] legacy search result', {
reason,
lastUid,
lastSenderHistoryUid,
mode: senderExact ? 'sender-history' : 'unread-fallback',
sender: senderExact || '',
count: Array.isArray(legacyUidsRaw) ? legacyUidsRaw.length : -1
})
// #endregion
legacyCandidateUids = (Array.isArray(legacyUidsRaw) ? legacyUidsRaw : [])
.map(item => Number(item) || 0)
.filter(uid => uid > 0 && uid <= lastUid && !processedUids.has(uid) && (senderExact ? uid > lastSenderHistoryUid : true))
.sort((a, b) => a - b)
} catch (e) {
const searchError = String(e && e.message ? e.message : e)
listenerState.last_error = searchError
reportCloudBalanceDebug('C', 'cloud_balance_watch.js:scanMailbox:search-unseen:error', '[DEBUG] unseen search error', {
reason,
lastUid,
error: searchError
})
}
}
if (legacyCandidateUids.length > 0 && client && listenerState.connected) {
for await (const message of client.fetch(legacyCandidateUids.join(','), {
uid: true,
envelope: true,
flags: true,
internalDate: true,
source: true
}, { uid: true })) {
await processMessage(message, 'legacy-history')
}
const lastHistoryProcessedUid = legacyCandidateUids[legacyCandidateUids.length - 1] || 0
if (lastHistoryProcessedUid > lastSenderHistoryUid) {
lastSenderHistoryUid = lastHistoryProcessedUid
setState('last_sender_history_uid', String(lastSenderHistoryUid))
}
}
finalizeScanState({
highestCommittedUid,
fetched,
matched,
inserted,
updated
})
listenerState.last_error = ''
// #region debug-point cloud-balance-scan-finish
reportCloudBalanceDebug('A', 'cloud_balance_watch.js:scanMailbox:finish', '[DEBUG] scan finish', {
reason,
fetched,
matched,
inserted,
updated,
last_uid: highestCommittedUid
})
// #endregion
return { ok: true, fetched, matched, inserted, updated, last_uid: highestCommittedUid }
} catch (e) {
listenerState.last_error = String(e && e.message ? e.message : e)
logJSON('cloud.balance.scan.error', { reason, error: listenerState.last_error }, 'cloud')
scheduleReconnect()
finalizeScanState({
highestCommittedUid,
fetched,
matched,
inserted,
updated
})
// #region debug-point cloud-balance-scan-error
reportCloudBalanceDebug('A', 'cloud_balance_watch.js:scanMailbox:error', '[DEBUG] scan error', {
reason,
error: listenerState.last_error,
fetched,
matched,
inserted,
updated,
last_uid: highestCommittedUid
})
// #endregion
return { ok: false, error: listenerState.last_error, fetched, matched, inserted, updated, last_uid: highestCommittedUid }
}
}
const triggerScan = async reason => {
if (scanPromise) return scanPromise
scanPromise = scanMailbox(reason).finally(() => {
scanPromise = null
})
return scanPromise
}
const requestScan = reason => {
if (scanPromise) {
// #region debug-point cloud-balance-request-already-running
reportCloudBalanceDebug('E', 'cloud_balance_watch.js:requestScan:already-running', '[DEBUG] request scan already running', {
reason,
runtime: getScanRuntimeState()
})
// #endregion
return {
ok: true,
accepted: true,
already_running: true,
message: 'scan_in_progress',
runtime: getScanRuntimeState()
}
}
scanPromise = scanMailbox(reason).finally(() => {
scanPromise = null
})
// #region debug-point cloud-balance-request-started
reportCloudBalanceDebug('E', 'cloud_balance_watch.js:requestScan:started', '[DEBUG] request scan started', {
reason,
runtime: getScanRuntimeState()
})
// #endregion
return {
ok: true,
accepted: true,
already_running: false,
message: 'scan_started',
runtime: getScanRuntimeState()
}
}
const start = async () => {
if (startInvoked) return
startInvoked = true
const cfg = readConfig()
listenerState.enabled = cfg.enable_listener !== false
if (!shouldStartListener(cfg)) {
listenerState.running = false
listenerState.connected = false
return
}
const interval = Math.max(30000, Number(cfg.poll_interval_ms || 600000) || 600000)
if (!pollTimer) {
pollTimer = setInterval(() => {
triggerScan('poll').catch(() => {})
}, interval)
}
try {
await connectClient('startup')
await triggerScan('startup')
} catch (e) {
listenerState.last_error = String(e && e.message ? e.message : e)
logJSON('cloud.balance.start.error', { error: listenerState.last_error }, 'cloud')
scheduleReconnect()
}
}
const listAlerts = ({ page = 1, pageSize = 20, keyword = '' } = {}) => {
backfillAlertTextBodies()
const safePage = Math.max(1, parseInt(page, 10) || 1)
const safePageSize = Math.min(100, Math.max(1, parseInt(pageSize, 10) || 20))
const q = safeString(keyword)
const like = `%${q}%`
const total = (countAlertsStmt.get(q, like, like) || {}).total || 0
const rows = listAlertsStmt.all(q, like, like, safePageSize, (safePage - 1) * safePageSize).map(row => Object.assign({}, row, {
text_body: normalizeMailBody(row.text_body || '', row.html_body || '')
}))
return {
page: safePage,
page_size: safePageSize,
total,
rows
}
}
const getStatus = () => {
const cfg = readConfig()
const summary = recentAlertsSummaryStmt.get() || {}
const lastScanFinishedAt = listenerState.last_scan_finished_at || getState('last_scan_finished_at', '')
const staleMs = Math.max(60000, Number(cfg.health_stale_ms || 300000) || 300000)
let healthy = false
if (lastScanFinishedAt) {
const diff = Date.now() - new Date(lastScanFinishedAt).getTime()
healthy = Number.isFinite(diff) && diff <= staleMs
}
return {
config: {
enabled: cfg.enable_listener !== false,
automation_port: Number(cfg.automation_port || 8977) || 8977,
current_port: Number(process.env.PORT || '8976') || 8976,
mailbox: safeString(cfg.mailbox) || 'INBOX',
sender_exact: safeString(cfg.match_rules && cfg.match_rules.sender_exact),
calendar_sync_enabled: cfg.calendar_sync_enabled !== false,
weekly_sync_enabled: cfg.weekly_sync_enabled === true
},
listener: Object.assign({}, listenerState, {
healthy,
scan_in_progress: !!scanPromise
}),
state: {
last_uid: Number(getState('last_uid', '0') || '0') || 0,
last_scan_summary: (() => {
try { return JSON.parse(getState('last_scan_summary', '{}')) } catch { return {} }
})()
},
summary: {
total: summary.total || 0,
high_risk_count: summary.high_risk_count || 0,
calendar_ok_count: summary.calendar_ok_count || 0,
calendar_error_count: summary.calendar_error_count || 0,
weekly_error_count: summary.weekly_error_count || 0
}
}
}
const retryCalendarById = async id => {
const alert = getAlertByIdStmt.get(id)
if (!alert) throw new Error('alert_not_found')
return dispatchCalendar(alert, readConfig())
}
const retryWeeklyById = async id => {
const alert = getAlertByIdStmt.get(id)
if (!alert) throw new Error('alert_not_found')
return dispatchWeekly(alert, readConfig())
}
module.exports = {
start,
readConfig,
triggerScan,
requestScan,
listAlerts,
getAlert: id => getAlertByIdStmt.get(id),
getStatus,
retryCalendarById,
retryWeeklyById
}
+400
View File
@@ -0,0 +1,400 @@
const Database = require('better-sqlite3')
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'cloud_notes.db')
const db = new Database(dbPath)
db.exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
html TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
// ensure optional columns
try {
const cols = db.prepare(`PRAGMA table_info(notes)`).all().map(c => c.name)
if (!cols.includes('starred')) db.exec(`ALTER TABLE notes ADD COLUMN starred INTEGER DEFAULT 0`)
if (!cols.includes('pinned')) db.exec(`ALTER TABLE notes ADD COLUMN pinned INTEGER DEFAULT 0`)
if (!cols.includes('deleted_at')) db.exec(`ALTER TABLE notes ADD COLUMN deleted_at TEXT`)
} catch {}
db.exec(`CREATE TABLE IF NOT EXISTS note_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
title TEXT NOT NULL,
html TEXT NOT NULL,
saved_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
filename TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER,
created_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
color TEXT,
sort_index INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS note_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);`)
const getUserCount = db.prepare(`SELECT COUNT(1) AS c FROM users`)
const findUser = db.prepare(`SELECT * FROM users WHERE username=?`)
const createUser = db.prepare(`INSERT INTO users (username, password_hash, salt) VALUES (?, ?, ?)`)
const hashPassword = (pwd, saltHex) => {
const salt = Buffer.from(saltHex, 'hex')
const key = crypto.scryptSync(String(pwd || ''), salt, 32)
return key.toString('hex')
}
const ensureAdmin = () => {
const c = getUserCount.get().c
if (c > 0) return
const salt = crypto.randomBytes(16).toString('hex')
const defaultPass = process.env.CLOUD_NOTES_DEFAULT_PASSWORD || process.env.LOCK_DEFAULT_PASSWORD || ''
const hash = hashPassword(defaultPass, salt)
createUser.run('admin_cn', hash, salt)
}
ensureAdmin()
const login = (username, password) => {
const u = findUser.get(username)
if (!u) {
const exists = getUserCount.get().c > 0
if (exists) return null
const salt = crypto.randomBytes(16).toString('hex')
const hash = hashPassword(password, salt)
createUser.run(username, hash, salt)
return findUser.get(username)
}
const ok = u.password_hash === hashPassword(password, u.salt)
return ok ? u : null
}
const insertNote = db.prepare(`INSERT INTO notes (user_id, title, html) VALUES (@user_id, @title, @html)`)
const updateNoteStmt = db.prepare(`UPDATE notes SET title=@title, html=@html, updated_at=datetime('now') WHERE id=@id`)
const insertVersion = db.prepare(`INSERT INTO note_versions (note_id, title, html) VALUES (@note_id, @title, @html)`)
const listVersionsStmt = db.prepare(`SELECT id, note_id, title, html, datetime(saved_at, 'localtime') AS saved_at FROM note_versions WHERE note_id=? ORDER BY id DESC LIMIT 50`)
const latestVersionStmt = db.prepare(`SELECT id, note_id, title, html, datetime(saved_at, 'localtime') AS saved_at FROM note_versions WHERE note_id=? ORDER BY id DESC LIMIT 1`)
const createAttachmentStmt = db.prepare(`INSERT INTO attachments (note_id, filename, file_path, file_size) VALUES (@note_id, @filename, @file_path, @file_size)`)
const listAttachmentsStmt = db.prepare(`SELECT id, note_id, filename, file_path, file_size, datetime(created_at, 'localtime') AS created_at FROM attachments WHERE note_id=? ORDER BY id ASC`)
const getAttachmentByIdStmt = db.prepare(`SELECT id, file_path FROM attachments WHERE id=?`)
const deleteAttachmentStmt = db.prepare(`DELETE FROM attachments WHERE id=?`)
const countAttachmentByPathStmt = db.prepare(`SELECT COUNT(1) AS c FROM attachments WHERE file_path=?`)
const listNotesStmt = db.prepare(`SELECT id, title, starred, pinned, datetime(updated_at, 'localtime') AS updated_at, datetime(created_at, 'localtime') AS created_at, LENGTH(html) AS size_bytes FROM notes WHERE user_id=? AND deleted_at IS NULL ORDER BY pinned DESC, updated_at DESC`)
const getNoteStmt = db.prepare(`SELECT id, user_id, title, html, starred, pinned, datetime(updated_at, 'localtime') AS updated_at, datetime(created_at, 'localtime') AS created_at, datetime(deleted_at, 'localtime') AS deleted_at, LENGTH(html) AS size_bytes FROM notes WHERE id=?`)
const searchNotesStmt = db.prepare(`SELECT id, user_id, title, html, datetime(created_at, 'localtime') AS created_at, datetime(updated_at, 'localtime') AS updated_at, starred, pinned, LENGTH(html) AS size_bytes FROM notes WHERE user_id=? AND deleted_at IS NULL AND (title LIKE ? OR html LIKE ?) ORDER BY updated_at DESC`)
const listTrashNotesStmt = db.prepare(`SELECT id, title, starred, pinned, datetime(updated_at, 'localtime') AS updated_at, datetime(created_at, 'localtime') AS created_at, datetime(deleted_at, 'localtime') AS deleted_at, LENGTH(html) AS size_bytes FROM notes WHERE user_id=? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC, updated_at DESC`)
const softDeleteNoteStmt = db.prepare(`UPDATE notes SET deleted_at=datetime('now') WHERE id=@id`)
const restoreNoteStmt = db.prepare(`UPDATE notes SET deleted_at=NULL WHERE id=@id`)
const hardDeleteNoteStmt = db.prepare(`DELETE FROM notes WHERE id=@id`)
const setStarStmt = db.prepare(`UPDATE notes SET starred=@starred WHERE id=@id`)
const setPinStmt = db.prepare(`UPDATE notes SET pinned=@pinned WHERE id=@id`)
const listTagsStmt = db.prepare(`SELECT id, user_id, name, color, sort_index, datetime(created_at, 'localtime') AS created_at, datetime(updated_at, 'localtime') AS updated_at FROM tags WHERE user_id=? ORDER BY sort_index ASC, id ASC`)
const insertTagStmt = db.prepare(`INSERT INTO tags (user_id, name, color, sort_index) VALUES (@user_id, @name, @color, @sort_index)`)
const updateTagStmt = db.prepare(`UPDATE tags SET name=@name, color=@color, sort_index=@sort_index, updated_at=datetime('now') WHERE id=@id AND user_id=@user_id`)
const deleteTagStmt = db.prepare(`DELETE FROM tags WHERE id=? AND user_id=?`)
const deleteNoteTagsByTagStmt = db.prepare(`DELETE FROM note_tags WHERE tag_id=?`)
const listNoteTagsStmt = db.prepare(`SELECT tag_id FROM note_tags WHERE note_id=?`)
const deleteNoteTagsByNoteStmt = db.prepare(`DELETE FROM note_tags WHERE note_id=?`)
const insertNoteTagStmt = db.prepare(`INSERT INTO note_tags (note_id, tag_id) VALUES (?, ?)`)
const listNotesByTagStmt = db.prepare(`SELECT n.id, n.title, n.starred, n.pinned, datetime(n.updated_at, 'localtime') AS updated_at, datetime(n.created_at, 'localtime') AS created_at, LENGTH(n.html) AS size_bytes FROM notes n JOIN note_tags t ON n.id=t.note_id WHERE n.user_id=? AND n.deleted_at IS NULL AND t.tag_id=? ORDER BY n.pinned DESC, n.updated_at DESC`)
const tagStatsStmt = db.prepare(`SELECT t.id, t.user_id, t.name, t.color, t.sort_index, COUNT(nt.note_id) AS note_count FROM tags t LEFT JOIN note_tags nt ON t.id=nt.tag_id LEFT JOIN notes n ON nt.note_id=n.id AND n.deleted_at IS NULL WHERE t.user_id=? GROUP BY t.id, t.user_id, t.name, t.color, t.sort_index ORDER BY note_count DESC, t.sort_index ASC, t.id ASC`)
const untaggedCountStmt = db.prepare(`SELECT COUNT(1) AS c FROM notes n WHERE n.user_id=? AND n.deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM note_tags nt WHERE nt.note_id=n.id)`)
const recentAttachmentsByUserStmt = db.prepare(`SELECT a.id, a.note_id, a.filename, a.file_path, a.file_size, datetime(a.created_at, 'localtime') AS created_at, n.title AS note_title FROM attachments a JOIN notes n ON a.note_id=n.id WHERE n.user_id=? ORDER BY a.created_at DESC LIMIT 10`)
const getSettingStmt = db.prepare(`SELECT value FROM settings WHERE key=?`)
const upsertSettingStmt = db.prepare(`INSERT INTO settings (key, value) VALUES (@key, @value) ON CONFLICT(key) DO UPDATE SET value=@value, updated_at=datetime('now')`)
const defaultTinyToolbarSettings = () => ({
undo_redo: true,
blocks_fontsize: true,
inline_format: true,
align: true,
indent: true,
lists: true,
table: true,
media: true,
insert_misc: true,
code: true,
page_tools: true,
search_visual: true,
preview_fullscreen_print: true,
cn_tools: true,
menubar: true,
upgrade_button: true
})
const normalizeTinyToolbarSettings = raw => {
const def = defaultTinyToolbarSettings()
const out = {}
Object.keys(def).forEach(k => {
out[k] = raw && Object.prototype.hasOwnProperty.call(raw, k) ? !!raw[k] : def[k]
})
return out
}
const getTinyToolbarSettings = () => {
const row = getSettingStmt.get('tiny_toolbar')
if (!row || typeof row.value !== 'string') return defaultTinyToolbarSettings()
try {
const raw = JSON.parse(row.value || '{}')
return normalizeTinyToolbarSettings(raw)
} catch {
return defaultTinyToolbarSettings()
}
}
const saveTinyToolbarSettings = settings => {
const norm = normalizeTinyToolbarSettings(settings || {})
const value = JSON.stringify(norm)
upsertSettingStmt.run({ key: 'tiny_toolbar', value })
return norm
}
const saveNote = payload => {
const id = parseInt(payload.id || '0', 10)
const user_id = parseInt(payload.user_id || '1', 10)
const title = String(payload.title || '未命名')
const html = String(payload.html || '')
const baseVersionId = parseInt(payload.base_version_id || '0', 10)
const force = !!payload.force
let noteId = 0
let versionId = 0
if (!id) {
const info = insertNote.run({ user_id, title, html })
const vinfo = insertVersion.run({ note_id: info.lastInsertRowid, title, html })
noteId = info.lastInsertRowid
versionId = vinfo.lastInsertRowid
} else {
const latest = latestVersionStmt.get(id)
const latestId = latest && latest.id ? latest.id : 0
if (baseVersionId && latestId && baseVersionId !== latestId && !force) {
return { conflict: true, latest }
}
updateNoteStmt.run({ id, title, html })
const vinfo = insertVersion.run({ note_id: id, title, html })
noteId = id
versionId = vinfo.lastInsertRowid
}
const tagIds = Array.isArray(payload.tag_ids) ? payload.tag_ids.map(x => parseInt(x, 10)).filter(x => x > 0) : []
deleteNoteTagsByNoteStmt.run(noteId)
const seen = new Set()
tagIds.forEach(tid => {
if (tid && !seen.has(tid)) {
seen.add(tid)
insertNoteTagStmt.run(noteId, tid)
}
})
return { note_id: noteId, version_id: versionId, conflict: false }
}
const listVersions = noteId => listVersionsStmt.all(noteId)
const createAttachment = payload => {
const info = createAttachmentStmt.run(payload)
return info.lastInsertRowid
}
const listAttachments = noteId => listAttachmentsStmt.all(noteId)
const deleteAttachment = id => deleteAttachmentStmt.run(parseInt(id, 10))
const deleteAttachmentWithFile = id => {
const aid = parseInt(id, 10)
if (!aid) return
const row = getAttachmentByIdStmt.get(aid)
if (row && row.file_path) {
try {
const refs = countAttachmentByPathStmt.get(row.file_path)
const c = refs && typeof refs.c === 'number' ? refs.c : 0
if (c <= 1) {
const rel = String(row.file_path || '').replace(/^[\\/]+/, '')
if (rel) {
const full = path.join(process.cwd(), rel)
if (fs.existsSync(full)) fs.unlinkSync(full)
}
}
} catch {}
}
deleteAttachmentStmt.run(aid)
}
const cloneAttachments = (srcNoteId, destNoteId) => {
const srcId = parseInt(srcNoteId, 10)
const dstId = parseInt(destNoteId, 10)
if (!srcId || !dstId || srcId === dstId) return
const atts = listAttachmentsStmt.all(srcId)
atts.forEach(a => {
createAttachmentStmt.run({
note_id: dstId,
filename: a.filename,
file_path: a.file_path,
file_size: a.file_size
})
})
}
const listNotes = userId => listNotesStmt.all(userId)
const getNote = id => {
const row = getNoteStmt.get(id)
if (!row) return null
const rel = listNoteTagsStmt.all(id)
row.tag_ids = rel.map(r => r.tag_id)
const latest = latestVersionStmt.get(id)
row.version_id = latest && latest.id ? latest.id : 0
row.version_saved_at = latest && latest.saved_at ? latest.saved_at : ''
return row
}
const searchNotes = (userId, keyword) => {
const kw = String(keyword || '').trim()
if (!kw) return []
const pat = `%${kw}%`
return searchNotesStmt.all(userId, pat, pat)
}
const setStar = payload => setStarStmt.run({ id: parseInt(payload.id,10), starred: payload.starred ? 1 : 0 })
const setPin = payload => setPinStmt.run({ id: parseInt(payload.id,10), pinned: payload.pinned ? 1 : 0 })
const listTrashNotes = userId => listTrashNotesStmt.all(userId)
const softDeleteNote = id => softDeleteNoteStmt.run({ id: parseInt(id, 10) })
const restoreNote = id => restoreNoteStmt.run({ id: parseInt(id, 10) })
const listTags = userId => listTagsStmt.all(userId)
const saveTags = (userId, tags) => {
const uid = parseInt(userId || '1', 10)
const existing = listTagsStmt.all(uid)
const existingById = new Map()
existing.forEach(t => { existingById.set(t.id, t) })
const tx = db.transaction(() => {
const keep = new Set()
const list = Array.isArray(tags) ? tags : []
list.forEach((t, idx) => {
const id = parseInt(t.id || '0', 10)
const name = String(t.name || '').trim()
const color = String(t.color || '').trim()
const sort_index = Number.isFinite(t.sort_index) ? t.sort_index : idx
if (!name) return
if (id && existingById.has(id)) {
updateTagStmt.run({ id, user_id: uid, name, color, sort_index })
keep.add(id)
} else {
const info = insertTagStmt.run({ user_id: uid, name, color, sort_index })
keep.add(info.lastInsertRowid)
}
})
existing.forEach(t => {
if (!keep.has(t.id)) {
deleteNoteTagsByTagStmt.run(t.id)
deleteTagStmt.run(t.id, uid)
}
})
})
tx()
}
const listNotesByTag = (userId, tagId) => {
const uid = parseInt(userId || '1', 10)
const tid = parseInt(tagId || '0', 10)
if (!tid) return []
return listNotesByTagStmt.all(uid, tid)
}
const dashboard = userId => {
const uid = parseInt(userId || '1', 10)
const notes = listNotesStmt.all(uid)
const now = new Date()
const todayStr = now.toISOString().slice(0, 10)
const weekAgoMs = now.getTime() - 6 * 24 * 3600 * 1000
let todayCount = 0
let weekCount = 0
let starredCount = 0
let pinnedCount = 0
let lastNoteId = 0
let lastNoteUpdated = 0
notes.forEach(n => {
const updated = String(n.updated_at || n.created_at || '')
if (updated.startsWith(todayStr)) todayCount++
const t = Date.parse(updated.replace(' ', 'T'))
if (!Number.isNaN(t) && t >= weekAgoMs) weekCount++
if (n.starred) starredCount++
if (n.pinned) pinnedCount++
if (!Number.isNaN(t) && t > lastNoteUpdated) {
lastNoteUpdated = t
lastNoteId = n.id
}
})
const recentNotes = notes.slice(0, 6)
const pinned = notes.filter(n => n.pinned).slice(0, 6)
const starred = notes.filter(n => n.starred).slice(0, 6)
const tagStats = tagStatsStmt.all(uid)
const hotTags = tagStats.slice(0, 8)
const untaggedRow = untaggedCountStmt.get(uid)
const untaggedCount = untaggedRow && typeof untaggedRow.c === 'number' ? untaggedRow.c : 0
const recentAttachments = recentAttachmentsByUserStmt.all(uid)
return {
overview: {
total_notes: notes.length,
today_count: todayCount,
week_count: weekCount,
starred_count: starredCount,
pinned_count: pinnedCount
},
recent_notes: recentNotes,
pinned_notes: pinned,
starred_notes: starred,
hot_tags: hotTags,
untagged_count: untaggedCount,
recent_attachments: recentAttachments,
last_note_id: lastNoteId
}
}
const hardDeleteNote = id => {
const nid = parseInt(id, 10)
if (!nid) return
const atts = listAttachmentsStmt.all(nid)
atts.forEach(a => {
try {
const rel = String(a.file_path || '').replace(/^[\\/]+/, '')
if (rel) {
const refs = countAttachmentByPathStmt.get(a.file_path)
const c = refs && typeof refs.c === 'number' ? refs.c : 0
if (c <= 1) {
const full = path.join(process.cwd(), rel)
if (fs.existsSync(full)) fs.unlinkSync(full)
}
}
} catch {}
deleteAttachmentStmt.run(parseInt(a.id, 10))
})
hardDeleteNoteStmt.run({ id: nid })
deleteNoteTagsByNoteStmt.run(nid)
}
module.exports = { login, saveNote, listVersions, createAttachment, listAttachments, deleteAttachment, deleteAttachmentWithFile, listNotes, getNote, searchNotes, setStar, setPin, listTrashNotes, softDeleteNote, restoreNote, hardDeleteNote, listTags, saveTags, listNotesByTag, dashboard, cloneAttachments, getTinyToolbarSettings, saveTinyToolbarSettings }
+273
View File
@@ -0,0 +1,273 @@
const Database = require('better-sqlite3')
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'cloud_sheets.db')
const db = new Database(dbPath)
db.exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS sheets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
workbook_json TEXT NOT NULL,
preview_text TEXT DEFAULT '',
starred INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
deleted_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS sheet_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sheet_id INTEGER NOT NULL,
title TEXT NOT NULL,
workbook_json TEXT NOT NULL,
preview_text TEXT DEFAULT '',
saved_at TEXT DEFAULT (datetime('now'))
);`)
db.exec(`CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sheet_id INTEGER NOT NULL,
filename TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER,
created_at TEXT DEFAULT (datetime('now'))
);`)
const getUserCount = db.prepare(`SELECT COUNT(1) AS c FROM users`)
const findUser = db.prepare(`SELECT * FROM users WHERE username=?`)
const createUser = db.prepare(`INSERT INTO users (username, password_hash, salt) VALUES (?, ?, ?)`)
const insertSheetStmt = db.prepare(`INSERT INTO sheets (user_id, title, workbook_json, preview_text) VALUES (@user_id, @title, @workbook_json, @preview_text)`)
const updateSheetStmt = db.prepare(`UPDATE sheets SET title=@title, workbook_json=@workbook_json, preview_text=@preview_text, updated_at=datetime('now') WHERE id=@id`)
const insertVersionStmt = db.prepare(`INSERT INTO sheet_versions (sheet_id, title, workbook_json, preview_text) VALUES (@sheet_id, @title, @workbook_json, @preview_text)`)
const latestVersionStmt = db.prepare(`SELECT id, sheet_id, title, workbook_json, preview_text, datetime(saved_at, 'localtime') AS saved_at FROM sheet_versions WHERE sheet_id=? ORDER BY id DESC LIMIT 1`)
const listVersionsStmt = db.prepare(`SELECT id, sheet_id, title, workbook_json, preview_text, datetime(saved_at, 'localtime') AS saved_at FROM sheet_versions WHERE sheet_id=? ORDER BY id DESC LIMIT 50`)
const listSheetsStmt = db.prepare(`SELECT id, title, starred, pinned, datetime(updated_at, 'localtime') AS updated_at, datetime(created_at, 'localtime') AS created_at, LENGTH(workbook_json) AS size_bytes FROM sheets WHERE user_id=? AND deleted_at IS NULL ORDER BY pinned DESC, updated_at DESC`)
const listTrashSheetsStmt = db.prepare(`SELECT id, title, starred, pinned, datetime(updated_at, 'localtime') AS updated_at, datetime(created_at, 'localtime') AS created_at, datetime(deleted_at, 'localtime') AS deleted_at, LENGTH(workbook_json) AS size_bytes FROM sheets WHERE user_id=? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC, updated_at DESC`)
const getSheetStmt = db.prepare(`SELECT id, user_id, title, workbook_json, preview_text, starred, pinned, datetime(updated_at, 'localtime') AS updated_at, datetime(created_at, 'localtime') AS created_at, datetime(deleted_at, 'localtime') AS deleted_at, LENGTH(workbook_json) AS size_bytes FROM sheets WHERE id=?`)
const searchSheetsStmt = db.prepare(`SELECT id, user_id, title, workbook_json, preview_text, starred, pinned, datetime(updated_at, 'localtime') AS updated_at, datetime(created_at, 'localtime') AS created_at, LENGTH(workbook_json) AS size_bytes FROM sheets WHERE user_id=? AND deleted_at IS NULL AND (title LIKE ? OR preview_text LIKE ?) ORDER BY updated_at DESC`)
const softDeleteSheetStmt = db.prepare(`UPDATE sheets SET deleted_at=datetime('now') WHERE id=@id`)
const restoreSheetStmt = db.prepare(`UPDATE sheets SET deleted_at=NULL WHERE id=@id`)
const hardDeleteSheetStmt = db.prepare(`DELETE FROM sheets WHERE id=@id`)
const setStarStmt = db.prepare(`UPDATE sheets SET starred=@starred WHERE id=@id`)
const setPinStmt = db.prepare(`UPDATE sheets SET pinned=@pinned WHERE id=@id`)
const createAttachmentStmt = db.prepare(`INSERT INTO attachments (sheet_id, filename, file_path, file_size) VALUES (@sheet_id, @filename, @file_path, @file_size)`)
const listAttachmentsStmt = db.prepare(`SELECT id, sheet_id, filename, file_path, file_size, datetime(created_at, 'localtime') AS created_at FROM attachments WHERE sheet_id=? ORDER BY id ASC`)
const getAttachmentByIdStmt = db.prepare(`SELECT id, file_path FROM attachments WHERE id=?`)
const deleteAttachmentStmt = db.prepare(`DELETE FROM attachments WHERE id=?`)
const deleteAttachmentsBySheetStmt = db.prepare(`DELETE FROM attachments WHERE sheet_id=?`)
const countAttachmentByPathStmt = db.prepare(`SELECT COUNT(1) AS c FROM attachments WHERE file_path=?`)
const hashPassword = (pwd, saltHex) => {
const salt = Buffer.from(saltHex, 'hex')
const key = crypto.scryptSync(String(pwd || ''), salt, 32)
return key.toString('hex')
}
const safeJsonStringify = input => {
try {
if (typeof input === 'string') {
const s = input.trim()
if (!s) return '{}'
try {
JSON.parse(s)
return s
} catch {
return JSON.stringify({ raw: s })
}
}
if (input && typeof input === 'object') return JSON.stringify(input)
} catch {}
return '{}'
}
const buildPreviewText = workbookJson => {
try {
const src = typeof workbookJson === 'string' ? workbookJson : JSON.stringify(workbookJson || {})
const raw = String(src || '')
.replace(/[{}[\]":,]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!raw) return ''
return raw.length <= 300 ? raw : `${raw.slice(0, 300)}...`
} catch {
return ''
}
}
const ensureAdmin = () => {
const c = getUserCount.get().c
if (c > 0) return
const salt = crypto.randomBytes(16).toString('hex')
const defaultPass = process.env.CLOUD_SHEETS_DEFAULT_PASSWORD || process.env.LOCK_DEFAULT_PASSWORD || ''
const hash = hashPassword(defaultPass, salt)
createUser.run('admin_cs', hash, salt)
}
ensureAdmin()
const login = (username, password) => {
const u = findUser.get(username)
if (!u) {
const exists = getUserCount.get().c > 0
if (exists) return null
const salt = crypto.randomBytes(16).toString('hex')
const hash = hashPassword(password, salt)
createUser.run(username, hash, salt)
return findUser.get(username)
}
const ok = u.password_hash === hashPassword(password, u.salt)
return ok ? u : null
}
const saveSheet = payload => {
const id = parseInt(payload.id || '0', 10)
const user_id = parseInt(payload.user_id || '1', 10)
const title = String(payload.title || '未命名表格')
const workbook_json = safeJsonStringify(payload.workbook_json)
const preview_text = String(payload.preview_text || '').trim() || buildPreviewText(workbook_json)
const baseVersionId = parseInt(payload.base_version_id || '0', 10)
const force = !!payload.force
let sheetId = 0
let versionId = 0
if (!id) {
const info = insertSheetStmt.run({ user_id, title, workbook_json, preview_text })
const vinfo = insertVersionStmt.run({ sheet_id: info.lastInsertRowid, title, workbook_json, preview_text })
sheetId = info.lastInsertRowid
versionId = vinfo.lastInsertRowid
} else {
const latest = latestVersionStmt.get(id)
const latestId = latest && latest.id ? latest.id : 0
if (baseVersionId && latestId && baseVersionId !== latestId && !force) {
return { conflict: true, latest }
}
updateSheetStmt.run({ id, title, workbook_json, preview_text })
const vinfo = insertVersionStmt.run({ sheet_id: id, title, workbook_json, preview_text })
sheetId = id
versionId = vinfo.lastInsertRowid
}
return { sheet_id: sheetId, version_id: versionId, conflict: false }
}
const listSheets = userId => listSheetsStmt.all(userId)
const listTrashSheets = userId => listTrashSheetsStmt.all(userId)
const listVersions = sheetId => listVersionsStmt.all(sheetId)
const getSheet = id => {
const row = getSheetStmt.get(id)
if (!row) return null
const latest = latestVersionStmt.get(id)
row.version_id = latest && latest.id ? latest.id : 0
row.version_saved_at = latest && latest.saved_at ? latest.saved_at : ''
return row
}
const searchSheets = (userId, keyword) => {
const kw = String(keyword || '').trim()
if (!kw) return []
const pat = `%${kw}%`
return searchSheetsStmt.all(userId, pat, pat)
}
const setStar = payload => setStarStmt.run({ id: parseInt(payload.id, 10), starred: payload.starred ? 1 : 0 })
const setPin = payload => setPinStmt.run({ id: parseInt(payload.id, 10), pinned: payload.pinned ? 1 : 0 })
const softDeleteSheet = id => softDeleteSheetStmt.run({ id: parseInt(id, 10) })
const restoreSheet = id => restoreSheetStmt.run({ id: parseInt(id, 10) })
const createAttachment = payload => {
const info = createAttachmentStmt.run(payload)
return info.lastInsertRowid
}
const listAttachments = sheetId => listAttachmentsStmt.all(sheetId)
const deleteAttachmentWithFile = id => {
const aid = parseInt(id, 10)
if (!aid) return
const row = getAttachmentByIdStmt.get(aid)
if (row && row.file_path) {
try {
const refs = countAttachmentByPathStmt.get(row.file_path)
const c = refs && typeof refs.c === 'number' ? refs.c : 0
if (c <= 1) {
const rel = String(row.file_path || '').replace(/^[\\/]+/, '')
if (rel) {
const full = path.join(process.cwd(), rel)
if (fs.existsSync(full)) fs.unlinkSync(full)
}
}
} catch {}
}
deleteAttachmentStmt.run(aid)
}
const cloneAttachments = (srcSheetId, destSheetId) => {
const srcId = parseInt(srcSheetId, 10)
const dstId = parseInt(destSheetId, 10)
if (!srcId || !dstId || srcId === dstId) return
const atts = listAttachmentsStmt.all(srcId)
atts.forEach(a => {
createAttachmentStmt.run({
sheet_id: dstId,
filename: a.filename,
file_path: a.file_path,
file_size: a.file_size
})
})
}
const hardDeleteSheet = id => {
const sid = parseInt(id, 10)
if (!sid) return
const atts = listAttachmentsStmt.all(sid)
atts.forEach(a => {
try {
const refs = countAttachmentByPathStmt.get(a.file_path)
const c = refs && typeof refs.c === 'number' ? refs.c : 0
if (c <= 1) {
const rel = String(a.file_path || '').replace(/^[\\/]+/, '')
if (rel) {
const full = path.join(process.cwd(), rel)
if (fs.existsSync(full)) fs.unlinkSync(full)
}
}
} catch {}
})
deleteAttachmentsBySheetStmt.run(sid)
hardDeleteSheetStmt.run({ id: sid })
}
module.exports = {
login,
saveSheet,
listSheets,
listTrashSheets,
getSheet,
searchSheets,
listVersions,
setStar,
setPin,
softDeleteSheet,
restoreSheet,
hardDeleteSheet,
createAttachment,
listAttachments,
deleteAttachmentWithFile,
cloneAttachments
}
+218
View File
@@ -0,0 +1,218 @@
const Database = require('better-sqlite3')
const fs = require('fs')
const path = require('path')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'toolbox.db')
const db = new Database(dbPath)
db.exec(
`CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT NOT NULL,
mid TEXT NOT NULL UNIQUE,
screen_name TEXT,
created_at TEXT,
text TEXT,
text_plain TEXT,
url TEXT,
reposts_count INTEGER,
comments_count INTEGER,
attitudes_count INTEGER,
pics_json TEXT,
fetched_at TEXT
);`
)
// migrations: add retweeted_* columns if missing
const cols = db.prepare(`PRAGMA table_info(posts)`).all().map(r => r.name)
const addCol = name => db.exec(`ALTER TABLE posts ADD COLUMN ${name} TEXT`)
if (!cols.includes('retweeted_mid')) addCol('retweeted_mid')
if (!cols.includes('retweeted_screen_name')) addCol('retweeted_screen_name')
if (!cols.includes('retweeted_text')) addCol('retweeted_text')
if (!cols.includes('retweeted_text_plain')) addCol('retweeted_text_plain')
if (!cols.includes('retweeted_url')) addCol('retweeted_url')
if (!cols.includes('retweeted_pics_json')) addCol('retweeted_pics_json')
db.exec(
`CREATE TABLE IF NOT EXISTS keywords (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL UNIQUE,
created_at TEXT DEFAULT (datetime('now'))
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS tracked_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT NOT NULL UNIQUE,
screen_name TEXT,
added_at TEXT DEFAULT (datetime('now'))
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS settings_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tool TEXT NOT NULL,
payload TEXT NOT NULL,
saved_at TEXT DEFAULT (datetime('now'))
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS archives (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT NOT NULL,
week_start TEXT NOT NULL,
week_end TEXT NOT NULL,
html TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(uid, week_start)
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS market_archives (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day TEXT NOT NULL UNIQUE,
html TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS alert_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
skill TEXT NOT NULL,
alert_id TEXT NOT NULL,
slot_key TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(skill, alert_id)
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS market_state (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS alert_email_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
skill TEXT NOT NULL,
alert_id TEXT NOT NULL,
subject TEXT,
status TEXT NOT NULL DEFAULT 'pending',
error_message TEXT,
created_at TEXT DEFAULT (datetime('now'))
);`
)
// 唯一索引:同一个告警事件同一种状态不重复记录
try { db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_email_log_uniq ON alert_email_log(skill, alert_id, status)`) } catch {}
const insertPost = db.prepare(
`INSERT OR IGNORE INTO posts (
uid, mid, screen_name, created_at, text, text_plain, url,
reposts_count, comments_count, attitudes_count, pics_json, fetched_at
) VALUES (
@uid, @mid, @screen_name, @created_at, @text, @text_plain, @url,
@reposts_count, @comments_count, @attitudes_count, @pics_json, @fetched_at
)`
)
const updateRetweeted = db.prepare(
`UPDATE posts SET
retweeted_mid = @retweeted_mid,
retweeted_screen_name = @retweeted_screen_name,
retweeted_text = @retweeted_text,
retweeted_text_plain = @retweeted_text_plain,
retweeted_url = @retweeted_url,
retweeted_pics_json = @retweeted_pics_json,
text_plain = @text_plain
WHERE mid = @mid`
)
const queryPostsAny = (uid, terms, limit) => {
const likeClauses = terms.map(() => 'text_plain LIKE ?').join(' OR ')
const args = terms.map(t => `%${t}%`)
const stmt = db.prepare(
`SELECT * FROM posts WHERE uid = ? AND (${likeClauses}) ORDER BY fetched_at DESC, id ASC LIMIT ?`
)
return stmt.all(uid, ...args, limit)
}
const queryPostsAll = (uid, terms, limit) => {
const likeClauses = terms.map(() => 'text_plain LIKE ?').join(' AND ')
const args = terms.map(t => `%${t}%`)
const stmt = db.prepare(
`SELECT * FROM posts WHERE uid = ? AND (${likeClauses}) ORDER BY fetched_at DESC, id ASC LIMIT ?`
)
return stmt.all(uid, ...args, limit)
}
const insertKeyword = db.prepare(
`INSERT OR IGNORE INTO keywords (keyword) VALUES (?)`
)
const insertTrackedUser = db.prepare(
`INSERT OR IGNORE INTO tracked_users (uid, screen_name) VALUES (?, ?)`
)
const getSettings = () => {
const keywords = db.prepare(`SELECT keyword FROM keywords ORDER BY id DESC`).all().map(r => r.keyword)
const users = db.prepare(`SELECT uid, screen_name FROM tracked_users ORDER BY id DESC`).all()
return { keywords, users }
}
const insertSettingHistory = db.prepare(
`INSERT INTO settings_history (tool, payload) VALUES (@tool, @payload)`
)
const upsertArchive = db.prepare(
`INSERT INTO archives (uid, week_start, week_end, html, created_at, updated_at)
VALUES (@uid, @week_start, @week_end, @html, datetime('now'), datetime('now'))
ON CONFLICT(uid, week_start) DO UPDATE SET
week_end = excluded.week_end,
html = excluded.html,
updated_at = datetime('now')`
)
const getArchive = db.prepare(
`SELECT * FROM archives WHERE uid = ? AND week_start = ?`
)
const upsertMarketArchive = db.prepare(
`INSERT INTO market_archives (day, html, created_at, updated_at)
VALUES (@day, @html, datetime('now'), datetime('now'))
ON CONFLICT(day) DO UPDATE SET
html = excluded.html,
updated_at = datetime('now')`
)
const getMarketArchive = db.prepare(
`SELECT * FROM market_archives WHERE day = ?`
)
module.exports = {
db,
insertPost,
updateRetweeted,
queryPostsAny,
queryPostsAll,
insertKeyword,
insertTrackedUser,
getSettings
, upsertArchive
, getArchive
, insertSettingHistory
, upsertMarketArchive
, getMarketArchive
}
+105
View File
@@ -0,0 +1,105 @@
const axios = require('axios')
let WebSocket
try { WebSocket = require('ws') } catch { WebSocket = null }
const resolveWsUrl = async () => {
const tryList = [
'http://localhost:9222/json/version',
'http://127.0.0.1:9222/json/version',
'http://localhost:9222/json',
'http://127.0.0.1:9222/json'
]
for (const u of tryList) {
try {
const res = await axios.get(u, { timeout: 5000 })
if (res && res.data) {
if (Array.isArray(res.data)) {
const item = res.data.find(x => x.webSocketDebuggerUrl) || res.data[0]
const ws = item && item.webSocketDebuggerUrl ? item.webSocketDebuggerUrl : ''
if (ws) return ws
} else {
const ws = res.data.webSocketDebuggerUrl || ''
if (ws) return ws
}
}
} catch {}
}
return ''
}
const getCookiesFromDevTools = async (urls = ['https://m.weibo.cn', 'https://weibo.com']) => {
if (!WebSocket) return ''
try {
const wsUrl = await resolveWsUrl()
if (!wsUrl) return ''
const ws = new WebSocket(wsUrl)
await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject) })
const send = (id, method, params) => new Promise((resolve, reject) => {
const handler = (data) => {
try {
const msg = JSON.parse(String(data))
if (msg.id === id) { ws.off('message', handler); resolve(msg.result || {}) }
} catch (e) { ws.off('message', handler); reject(e) }
}
ws.on('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
await send(1, 'Network.enable', {})
const res = await send(2, 'Storage.getCookies', { urls })
try { ws.close() } catch {}
const pairs = ((res && res.cookies) || []).filter(c => {
const domain = String(c.domain || '').toLowerCase()
return domain.includes('weibo') || domain.includes('.weibo') ||
domain.includes('weibo.cn') || domain.includes('weibo.com') ||
domain.includes('sina.com') || domain.includes('sina.cn')
}).map(c => `${c.name}=${c.value}`)
return pairs.join('; ')
} catch { return '' }
}
const screenshotUrl = async (url) => {
if (!WebSocket) return ''
try {
const wsUrl = await resolveWsUrl()
if (!wsUrl) return ''
const ws = new WebSocket(wsUrl)
await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject) })
let sessionId = ''
const send = (id, method, params, sid) => new Promise((resolve, reject) => {
const handler = (data) => {
try {
const msg = JSON.parse(String(data))
if (msg.id === id) { ws.off('message', handler); resolve(msg.result || {}) }
} catch (e) { ws.off('message', handler); reject(e) }
}
ws.on('message', handler)
const payload = sid ? { id, method, params, sessionId: sid } : { id, method, params }
ws.send(JSON.stringify(payload))
})
const waitForLoad = (sid) => new Promise((resolve) => {
const onMsg = (data) => {
try {
const msg = JSON.parse(String(data))
if (msg.sessionId === sid && msg.method === 'Page.loadEventFired') { ws.off('message', onMsg); resolve() }
} catch {}
}
ws.on('message', onMsg)
setTimeout(() => { ws.off('message', onMsg); resolve() }, 10000)
})
const target = await send(1, 'Target.createTarget', { url: 'about:blank' })
const attach = await send(2, 'Target.attachToTarget', { targetId: target && target.targetId, flatten: true })
sessionId = attach && attach.sessionId ? attach.sessionId : ''
if (!sessionId) { try { ws.close() } catch {}; return '' }
await send(3, 'Page.enable', {}, sessionId)
await send(4, 'Network.enable', {}, sessionId)
await send(5, 'Page.navigate', { url }, sessionId)
await waitForLoad(sessionId)
const shot = await send(6, 'Page.captureScreenshot', { format: 'png', fromSurface: true }, sessionId)
try { await send(7, 'Target.closeTarget', { targetId: target && target.targetId }) } catch {}
try { ws.close() } catch {}
return shot && shot.data ? shot.data : ''
} catch { return '' }
}
module.exports = { getCookiesFromDevTools, screenshotUrl }
+137
View File
@@ -0,0 +1,137 @@
const express = require('express')
const Database = require('better-sqlite3')
const path = require('path')
const fs = require('fs')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'dlt_draws.db')
const db = new Database(dbPath)
// Initialize Tables
db.exec(`CREATE TABLE IF NOT EXISTS draws (
issue TEXT PRIMARY KEY,
date TEXT,
front TEXT,
back TEXT,
pool INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);`)
// Auth Helpers
const readCookieCfg = () => {
const cfgPath = path.join(process.cwd(), 'public', 'tools', 'dlt_draws', 'assets', 'auth_config.json')
let name = 'dlt_draws_gate'
let maxAge = 90 * 24 * 3600 * 1000
try {
if (fs.existsSync(cfgPath)) {
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
if (cfg.cookieName) name = String(cfg.cookieName)
const days = Number(cfg.max_age_days || 0)
if (Number.isFinite(days) && days > 0) maxAge = Math.min(days, 365) * 24 * 3600 * 1000
}
} catch {}
return { name, maxAge }
}
const parseCookie = (cookieStr) => {
const out = {}
String(cookieStr || '').split(/;\s*/).forEach(p => { const idx = p.indexOf('='); if (idx > 0) out[p.slice(0, idx)] = decodeURIComponent(p.slice(idx + 1)) })
return out
}
const checkAuth = (req, res, next) => {
try {
const cookies = parseCookie(req.headers.cookie || '')
const { name, maxAge } = readCookieCfg()
if (cookies[name] === '1' || cookies['nav_gate'] === '1') {
res.cookie(name, '1', { httpOnly: true, sameSite: 'lax', maxAge })
return next()
}
} catch {}
return res.status(401).json({ error: 'unauthorized' })
}
// Helpers
const pad2 = (n) => String(n).padStart(2, '0')
const arrToCsv = (arr) => (Array.isArray(arr) ? arr : String(arr).split(/[,,\s]+/).filter(Boolean))
.map((x) => pad2(parseInt(x, 10)))
.join(',')
const csvToArr = (csv) => String(csv).split(/[,,\s]+/).filter(Boolean).map((x) => parseInt(x, 10))
const bindRoutes = (app) => {
// Routes prefixed with /api/dlt
app.get('/auth/handshake', (req, res) => {
try {
const cookies = parseCookie(req.headers.cookie || '')
const { name } = readCookieCfg()
if (cookies[name] === '1' || cookies['nav_gate'] === '1') return res.json({ ok: true })
return res.status(401).json({ error: 'unauthorized' })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get('/auth/token', (req, res) => {
const cookies = parseCookie(req.headers.cookie || '')
const { name } = readCookieCfg()
if (cookies[name] === '1' || cookies['nav_gate'] === '1') return res.json({ ok: true })
return res.status(401).json({ error: 'unauthorized' })
})
app.get('/api/dlt/health', (req, res) => res.json({ status: 'ok' }))
app.get('/api/dlt/draws', checkAuth, (req, res) => {
const limit = Math.max(0, parseInt(req.query.limit || '0', 10) || 0)
const base = `SELECT issue, date, front, back, pool FROM draws ORDER BY CAST(issue AS INTEGER) ASC`
const sql = limit ? `${base} LIMIT ?` : base
try {
const rows = db.prepare(sql).all(limit ? [limit] : [])
res.json(rows.map((r) => ({
issue: r.issue,
date: r.date,
front: csvToArr(r.front),
back: csvToArr(r.back),
pool: r.pool || 0
})))
} catch (err) {
res.status(500).json({ error: err.message })
}
})
app.post('/api/dlt/draws/merge', checkAuth, (req, res) => {
const items = Array.isArray(req.body.rows) ? req.body.rows : []
if (!items.length) return res.status(400).json({ error: 'rows empty' })
let inserted = 0
try {
const insert = db.transaction((rows) => {
let count = 0
const stmt = db.prepare('INSERT OR IGNORE INTO draws(issue,date,front,back,pool) VALUES (?,?,?,?,?)')
for (const it of rows) {
const issue = String(it.issue || '').trim()
const date = String(it.date || '').trim()
const front = arrToCsv(it.front || it.frontArr || [])
const back = arrToCsv(it.back || it.backArr || [])
const pool = Number(it.pool || 0)
if (!issue || csvToArr(front).length !== 5 || csvToArr(back).length !== 2) continue
const info = stmt.run(issue, date, front, back, pool)
count += info.changes
}
return count
})
inserted = insert(items)
res.json({ inserted })
} catch (err) {
res.status(500).json({ error: err.message })
}
})
app.post('/api/dlt/draws/clear', checkAuth, (req, res) => {
try {
db.prepare('DELETE FROM draws').run()
res.json({ cleared: true })
} catch (err) {
res.status(500).json({ error: err.message })
}
})
}
module.exports = { bindRoutes }
+177
View File
@@ -0,0 +1,177 @@
const path = require('path')
const fs = require('fs')
const axios = require('axios')
const Database = require('better-sqlite3')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'doc_calendar_bridge.db')
const db = new Database(dbPath)
db.exec(`CREATE TABLE IF NOT EXISTS bridge_sync (
doc_id INTEGER PRIMARY KEY,
event_id TEXT NOT NULL,
status TEXT NOT NULL,
synced_at TEXT DEFAULT (datetime('now','+8 hours')),
last_error TEXT DEFAULT '',
payload_json TEXT DEFAULT ''
);`)
const upsertSyncStmt = db.prepare(`INSERT INTO bridge_sync (doc_id, event_id, status, synced_at, last_error, payload_json) VALUES (@doc_id, @event_id, @status, datetime('now','+8 hours'), @last_error, @payload_json)
ON CONFLICT(doc_id) DO UPDATE SET event_id=excluded.event_id, status=excluded.status, synced_at=datetime('now','+8 hours'), last_error=excluded.last_error, payload_json=excluded.payload_json`)
const getSyncByDocStmt = db.prepare(`SELECT doc_id, event_id, status, synced_at, last_error, payload_json FROM bridge_sync WHERE doc_id=?`)
const readCalendarPublicKey = () => {
try {
const cfgPath = path.join(process.cwd(), 'config', 'calendar_reminder.json')
if (fs.existsSync(cfgPath)) {
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
const key = String(cfg.PUBLIC_API_KEY || cfg.public_api_key || '').trim()
if (key) return key
}
} catch {}
return '099239923'
}
const calendarEndpoint = () => {
const port = process.env.PORT || 8976
const key = readCalendarPublicKey()
return `http://127.0.0.1:${port}/api/calendar/public/events?key=${encodeURIComponent(key)}`
}
const parseBeijingDateTimeToMs = raw => {
const s = String(raw || '').trim()
if (!s) return NaN
const normalized = s.includes('T') ? s : s.replace(' ', 'T')
const withSeconds = normalized.length === 16 ? `${normalized}:00` : normalized
const iso = withSeconds.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(withSeconds) ? withSeconds : `${withSeconds}+08:00`
const ms = new Date(iso).getTime()
return Number.isFinite(ms) ? ms : NaN
}
const toIso = ms => {
const n = Number(ms || 0)
if (!Number.isFinite(n) || n <= 0) return null
return new Date(n).toISOString()
}
const defaultDueAtToday18Beijing = () => {
const now = new Date()
const utcMs = now.getTime() + (now.getTimezoneOffset() * 60 * 1000)
const bj = new Date(utcMs + 8 * 60 * 60 * 1000)
const y = bj.getUTCFullYear()
const m = String(bj.getUTCMonth() + 1).padStart(2, '0')
const d = String(bj.getUTCDate()).padStart(2, '0')
return `${y}-${m}-${d}T18:00`
}
const buildEventPayload = (doc, folderName) => {
const docId = parseInt(String((doc && doc.id) || '0'), 10) || 0
if (!docId) throw new Error('bad_doc_id')
const eventId = `dck-${docId}`
const createdMs = parseBeijingDateTimeToMs((doc && doc.created_at) || '')
const dueRaw = String((doc && doc.due_at) || '').trim() || defaultDueAtToday18Beijing()
const dueMs = parseBeijingDateTimeToMs(dueRaw)
const startIso = toIso(createdMs || Date.now())
const endIso = toIso(dueMs || (Date.now() + 60 * 60 * 1000))
const status = String((doc && doc.status) || 'todo').toLowerCase()
const completed = status === 'done'
const priority = String((doc && doc.priority) || 'medium').toLowerCase()
const lines = [
`来源: 文档云管家`,
`文档ID: ${docId}`,
`优先级: ${priority}`,
`状态: ${status}`,
`文件夹: ${String(folderName || '根目录')}`,
`备注: ${String((doc && doc.note) || '')}`,
`链接: /tools/doc_cloud_keeper?from=calendar&doc_id=${docId}`
]
const content = lines.join('\n').trim()
const remindMs = completed ? NaN : (dueMs - 24 * 3600 * 1000)
return {
id: eventId,
source: 'doc_cloud_keeper',
external_id: String(docId),
title: String((doc && doc.filename) || `文档#${docId}`),
content,
start: startIso,
end: endIso,
deadline: endIso,
remindAt: Number.isFinite(remindMs) ? toIso(remindMs) : null,
allDay: false,
completed,
deleted: false,
priority,
status_code: completed ? 'done' : 'new',
status_label: completed ? '✅已完成' : '📄文档待办',
updatedAt: new Date().toISOString()
}
}
const postEvent = async payload => {
const url = calendarEndpoint()
await axios.post(url, payload, { timeout: 10000, headers: { 'Content-Type': 'application/json' } })
}
const deleteEvent = async eventId => {
const url = `${calendarEndpoint()}&_method=DELETE&id=${encodeURIComponent(String(eventId || ''))}`
await axios.post(url, {}, { timeout: 10000, headers: { 'Content-Type': 'application/json' } })
}
const upsert = async (doc, folderName) => {
const payload = buildEventPayload(doc, folderName)
try {
await postEvent(payload)
upsertSyncStmt.run({
doc_id: parseInt(String(doc.id || '0'), 10) || 0,
event_id: payload.id,
status: 'synced',
last_error: '',
payload_json: JSON.stringify(payload)
})
return { ok: true, event_id: payload.id }
} catch (e) {
upsertSyncStmt.run({
doc_id: parseInt(String(doc.id || '0'), 10) || 0,
event_id: payload.id,
status: 'error',
last_error: String(e && e.message ? e.message : e),
payload_json: JSON.stringify(payload)
})
return { ok: false, event_id: payload.id, error: String(e && e.message ? e.message : e) }
}
}
const remove = async docId => {
const did = parseInt(String(docId || '0'), 10) || 0
if (!did) return { ok: false, error: 'bad_doc_id' }
const eventId = `dck-${did}`
try {
await deleteEvent(eventId)
upsertSyncStmt.run({
doc_id: did,
event_id: eventId,
status: 'deleted',
last_error: '',
payload_json: ''
})
return { ok: true, event_id: eventId }
} catch (e) {
upsertSyncStmt.run({
doc_id: did,
event_id: eventId,
status: 'error',
last_error: String(e && e.message ? e.message : e),
payload_json: ''
})
return { ok: false, event_id: eventId, error: String(e && e.message ? e.message : e) }
}
}
const byDocId = docId => getSyncByDocStmt.get(parseInt(String(docId || '0'), 10) || 0) || null
module.exports = {
upsert,
remove,
byDocId
}
File diff suppressed because it is too large Load Diff
+339
View File
@@ -0,0 +1,339 @@
const fs = require('fs')
const path = require('path')
const XLSX = require('xlsx')
const mammoth = require('mammoth')
const pdfParse = require('pdf-parse')
const unzipper = require('unzipper')
const MAX_DETAIL_ITEMS = 30
const normalizeText = text => String(text || '').replace(/\r/g, '')
const trimLine = text => String(text || '').replace(/\s+/g, ' ').trim()
const nonEmptyLines = text => normalizeText(text).split('\n').map(trimLine).filter(Boolean)
const shortText = (text, max = 120) => {
const s = trimLine(text)
if (s.length <= max) return s
return `${s.slice(0, max - 3)}...`
}
const labelValue = value => {
if (value == null) return ''
if (value instanceof Date) return value.toISOString()
if (typeof value === 'object') {
try { return JSON.stringify(value) } catch { return String(value) }
}
return String(value)
}
const lineDiff = (leftLines, rightLines, maxItems = MAX_DETAIL_ITEMS) => {
const leftMap = new Map()
const rightMap = new Map()
leftLines.forEach(line => leftMap.set(line, (leftMap.get(line) || 0) + 1))
rightLines.forEach(line => rightMap.set(line, (rightMap.get(line) || 0) + 1))
const added = []
const removed = []
rightLines.forEach(line => {
const leftCount = leftMap.get(line) || 0
const rightCount = (rightMap.get(line) || 0) - 1
rightMap.set(line, rightCount)
if (leftCount > 0) {
leftMap.set(line, leftCount - 1)
return
}
if (added.length < maxItems) added.push(shortText(line))
})
leftLines.forEach(line => {
const remaining = leftMap.get(line) || 0
if (remaining <= 0) return
leftMap.set(line, remaining - 1)
if (removed.length < maxItems) removed.push(shortText(line))
})
return { added, removed }
}
const baseSummary = (left, right) => {
const items = []
if (String(left.filename || '') !== String(right.filename || '')) {
items.push(`文件名变化:${left.filename || '-'} -> ${right.filename || '-'}`)
} else {
items.push(`文件名一致:${left.filename || '-'}`)
}
if (Number(left.size_bytes || 0) !== Number(right.size_bytes || 0)) {
items.push(`文件大小变化:${left.size_bytes || 0} bytes -> ${right.size_bytes || 0} bytes`)
} else {
items.push(`文件大小一致:${left.size_bytes || 0} bytes`)
}
return items
}
const readZipEntryText = async (filePath, entryName) => {
const archive = await unzipper.Open.file(filePath)
const entry = archive.files.find(item => item.path === entryName)
if (!entry) return ''
const buffer = await entry.buffer()
return buffer.toString('utf8')
}
const detectFileType = ext => {
const e = String(ext || '').toLowerCase()
if (e === 'xls' || e === 'xlsx') return 'excel'
if (e === 'docx') return 'word'
if (e === 'pdf') return 'pdf'
return 'unknown'
}
const compareCommonMeta = (left, right) => ({
left: {
label: left.label,
filename: left.filename,
size_bytes: left.size_bytes || 0,
created_at: left.created_at || '',
ext: left.ext || ''
},
right: {
label: right.label,
filename: right.filename,
size_bytes: right.size_bytes || 0,
created_at: right.created_at || '',
ext: right.ext || ''
}
})
const worksheetShape = sheet => {
const ref = String((sheet && sheet['!ref']) || 'A1')
try {
const range = XLSX.utils.decode_range(ref)
return {
rows: range.e.r - range.s.r + 1,
cols: range.e.c - range.s.c + 1
}
} catch {
return { rows: 0, cols: 0 }
}
}
const cellMap = sheet => {
const map = new Map()
Object.keys(sheet || {}).forEach(key => {
if (key.startsWith('!')) return
const cell = sheet[key] || {}
map.set(key, {
value: labelValue(Object.prototype.hasOwnProperty.call(cell, 'v') ? cell.v : (cell.w || '')),
formula: String(cell.f || '')
})
})
return map
}
const compareExcel = (left, right) => {
const wbLeft = XLSX.readFile(left.full_path, { cellFormula: true })
const wbRight = XLSX.readFile(right.full_path, { cellFormula: true })
const sheetNames = Array.from(new Set([...(wbLeft.SheetNames || []), ...(wbRight.SheetNames || [])]))
const addedSheets = (wbRight.SheetNames || []).filter(name => !(wbLeft.SheetNames || []).includes(name))
const removedSheets = (wbLeft.SheetNames || []).filter(name => !(wbRight.SheetNames || []).includes(name))
const changedSheets = []
const changedSheetStats = []
const cellChangesBySheet = new Map()
let changedCellCount = 0
let changedFormulaCount = 0
sheetNames.forEach(name => {
const leftSheet = wbLeft.Sheets[name]
const rightSheet = wbRight.Sheets[name]
if (!leftSheet || !rightSheet) return
const leftShape = worksheetShape(leftSheet)
const rightShape = worksheetShape(rightSheet)
const leftCells = cellMap(leftSheet)
const rightCells = cellMap(rightSheet)
const addresses = Array.from(new Set([...leftCells.keys(), ...rightCells.keys()])).sort()
let sheetChangedCells = 0
let sheetChangedFormulas = 0
const sheetDetails = []
addresses.forEach(addr => {
const lv = leftCells.get(addr) || { value: '', formula: '' }
const rv = rightCells.get(addr) || { value: '', formula: '' }
if (lv.formula !== rv.formula) {
changedFormulaCount += 1
sheetChangedFormulas += 1
if (sheetDetails.length < MAX_DETAIL_ITEMS) {
sheetDetails.push(`${name}!${addr} 公式变化:${lv.formula || '(空)'} -> ${rv.formula || '(空)'}`)
}
}
if (lv.value !== rv.value) {
changedCellCount += 1
sheetChangedCells += 1
if (sheetDetails.length < MAX_DETAIL_ITEMS) {
sheetDetails.push(`${name}!${addr} 内容变化:${shortText(lv.value, 40) || '(空)'} -> ${shortText(rv.value, 40) || '(空)'}`)
}
}
})
if (sheetChangedCells || sheetChangedFormulas || leftShape.rows !== rightShape.rows || leftShape.cols !== rightShape.cols) {
changedSheets.push(`${name}:单元格变化 ${sheetChangedCells} 处,公式变化 ${sheetChangedFormulas} 处,范围 ${leftShape.rows}x${leftShape.cols} -> ${rightShape.rows}x${rightShape.cols}`)
changedSheetStats.push({
sheet_name: name,
cell_changes: sheetChangedCells,
formula_changes: sheetChangedFormulas,
left_rows: leftShape.rows,
left_cols: leftShape.cols,
right_rows: rightShape.rows,
right_cols: rightShape.cols,
detail_count: sheetDetails.length
})
cellChangesBySheet.set(name, sheetDetails)
}
})
const summaryItems = [
...baseSummary(left, right),
`工作表数量:${(wbLeft.SheetNames || []).length} -> ${(wbRight.SheetNames || []).length}`,
`新增工作表:${addedSheets.length}`,
`删除工作表:${removedSheets.length}`,
`发生变化的工作表:${changedSheets.length}`,
`内容变化单元格:${changedCellCount}`,
`公式变化:${changedFormulaCount}`
]
const detailSections = []
if (addedSheets.length) detailSections.push({ title: '新增工作表', items: addedSheets.map(name => shortText(name)) })
if (removedSheets.length) detailSections.push({ title: '删除工作表', items: removedSheets.map(name => shortText(name)) })
if (changedSheets.length) detailSections.push({ title: '工作表变化摘要', items: changedSheets.slice(0, MAX_DETAIL_ITEMS) })
if (changedSheetStats.length) {
changedSheetStats.forEach(stat => {
detailSections.push({
title: `${stat.sheet_name} - 关键单元格变化`,
sheet_name: stat.sheet_name,
items: (cellChangesBySheet.get(stat.sheet_name) || []).slice(0, MAX_DETAIL_ITEMS)
})
})
}
return {
file_type: 'excel',
warnings: [],
summary_items: summaryItems,
detail_sections: detailSections,
excel_stats: {
total_sheets_left: (wbLeft.SheetNames || []).length,
total_sheets_right: (wbRight.SheetNames || []).length,
added_sheets: addedSheets,
removed_sheets: removedSheets,
changed_sheet_count: changedSheetStats.length,
changed_cell_count: changedCellCount,
changed_formula_count: changedFormulaCount,
changed_sheets: changedSheetStats
}
}
}
const extractDocxInfo = async filePath => {
const raw = await mammoth.extractRawText({ path: filePath })
const xml = await readZipEntryText(filePath, 'word/document.xml')
const text = String((raw && raw.value) || '')
return {
paragraph_count: (xml.match(/<w:p\b/g) || []).length,
table_count: (xml.match(/<w:tbl\b/g) || []).length,
heading_count: (xml.match(/Heading[1-9]/g) || []).length,
text_length: text.length,
lines: nonEmptyLines(text)
}
}
const compareDocx = async (left, right) => {
const [leftInfo, rightInfo] = await Promise.all([extractDocxInfo(left.full_path), extractDocxInfo(right.full_path)])
const warnings = []
if (!leftInfo.lines.length && !rightInfo.lines.length) warnings.push('两个 DOCX 都没有提取到正文文本,结果可能只反映结构变化。')
const diff = lineDiff(leftInfo.lines, rightInfo.lines)
const summaryItems = [
...baseSummary(left, right),
`段落数量:${leftInfo.paragraph_count} -> ${rightInfo.paragraph_count}`,
`表格数量:${leftInfo.table_count} -> ${rightInfo.table_count}`,
`标题样式数量:${leftInfo.heading_count} -> ${rightInfo.heading_count}`,
`正文文本长度:${leftInfo.text_length} -> ${rightInfo.text_length}`,
`新增段落摘要:${diff.added.length}`,
`删除段落摘要:${diff.removed.length}`
]
const detailSections = []
if (diff.added.length) detailSections.push({ title: '新增段落摘要', items: diff.added })
if (diff.removed.length) detailSections.push({ title: '删除段落摘要', items: diff.removed })
return {
file_type: 'word',
warnings,
summary_items: summaryItems,
detail_sections: detailSections
}
}
const extractPdfInfo = async filePath => {
const parsed = await pdfParse(fs.readFileSync(filePath))
const text = String(parsed.text || '')
return {
page_count: Number(parsed.numpages || 0),
text_length: text.length,
lines: nonEmptyLines(text),
info: parsed.info || {}
}
}
const comparePdf = async (left, right) => {
const [leftInfo, rightInfo] = await Promise.all([extractPdfInfo(left.full_path), extractPdfInfo(right.full_path)])
const warnings = []
if (!leftInfo.text_length || !rightInfo.text_length) {
warnings.push('至少有一个 PDF 没有提取到足够文本,若是扫描件图片,第一版只能提供基础摘要。')
}
const diff = lineDiff(leftInfo.lines, rightInfo.lines)
const summaryItems = [
...baseSummary(left, right),
`页数:${leftInfo.page_count} -> ${rightInfo.page_count}`,
`提取文本长度:${leftInfo.text_length} -> ${rightInfo.text_length}`,
`新增文本摘要:${diff.added.length}`,
`删除文本摘要:${diff.removed.length}`
]
const detailSections = []
if (diff.added.length) detailSections.push({ title: '新增文本摘要', items: diff.added })
if (diff.removed.length) detailSections.push({ title: '删除文本摘要', items: diff.removed })
return {
file_type: 'pdf',
warnings,
summary_items: summaryItems,
detail_sections: detailSections
}
}
const compareUnknown = (left, right) => ({
file_type: 'unknown',
warnings: ['当前第一版只支持 Excel、DOCX、PDF 的结构化差异摘要。'],
summary_items: [
...baseSummary(left, right),
`文件类型:${left.ext || '-'} -> ${right.ext || '-'}`
],
detail_sections: []
})
const compareFiles = async (left, right) => {
if (!left || !right) throw new Error('compare_targets_missing')
if (!fs.existsSync(left.full_path)) throw new Error(`比较源文件不存在:${path.basename(left.full_path || '')}`)
if (!fs.existsSync(right.full_path)) throw new Error(`比较目标文件不存在:${path.basename(right.full_path || '')}`)
const typeLeft = detectFileType(left.ext)
const typeRight = detectFileType(right.ext)
const base = compareCommonMeta(left, right)
let result = null
if (typeLeft !== typeRight) {
result = {
file_type: 'mixed',
warnings: ['两个版本的文件类型不同,第一版仅提供基础摘要。'],
summary_items: [
...baseSummary(left, right),
`文件类型:${left.ext || '-'} -> ${right.ext || '-'}`
],
detail_sections: []
}
} else if (typeLeft === 'excel') {
result = compareExcel(left, right)
} else if (typeLeft === 'word') {
result = await compareDocx(left, right)
} else if (typeLeft === 'pdf') {
result = await comparePdf(left, right)
} else {
result = compareUnknown(left, right)
}
return {
...base,
...result
}
}
module.exports = {
compareFiles
}
+796
View File
@@ -0,0 +1,796 @@
const fs = require('fs')
const path = require('path')
const Database = require('better-sqlite3')
const axios = require('axios')
const { logJSON } = require('./logger')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'expense.db')
const db = new Database(dbPath)
db.pragma('journal_mode = WAL')
db.pragma('synchronous = NORMAL')
// Upload directories (independent for expense system)
const uploadsBase = path.join(process.cwd(), 'uploads', 'expense')
const chunksDir = path.join(uploadsBase, 'chunks')
const attachmentsDir = path.join(uploadsBase, 'attachments')
if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true })
if (!fs.existsSync(chunksDir)) fs.mkdirSync(chunksDir, { recursive: true })
if (!fs.existsSync(attachmentsDir)) fs.mkdirSync(attachmentsDir, { recursive: true })
db.exec(
`CREATE TABLE IF NOT EXISTS types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS subjects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
cost_type TEXT DEFAULT '个人费用',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
type_id INTEGER NOT NULL,
subject_id INTEGER NOT NULL,
amount_cents INTEGER NOT NULL,
content TEXT,
arrived INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY(type_id) REFERENCES types(id),
FOREIGN KEY(subject_id) REFERENCES subjects(id)
);`
)
db.exec(
`CREATE TABLE IF NOT EXISTS expense_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
expense_id INTEGER NOT NULL,
filename TEXT NOT NULL,
path TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
mime TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY(expense_id) REFERENCES expenses(id)
);`
)
try {
db.prepare('SELECT cost_type FROM expenses LIMIT 1').get()
} catch (e) {
if (String(e).includes('no such column')) {
db.exec(`ALTER TABLE expenses ADD COLUMN cost_type TEXT DEFAULT '个人费用'`)
}
}
try {
db.prepare('SELECT cost_type FROM types LIMIT 1').get()
} catch (e) {
if (String(e).includes('no such column')) {
db.exec(`ALTER TABLE types ADD COLUMN cost_type TEXT DEFAULT '个人费用'`)
}
}
try {
db.prepare('SELECT cost_type FROM subjects LIMIT 1').get()
} catch (e) {
if (String(e).includes('no such column')) {
db.exec(`ALTER TABLE subjects ADD COLUMN cost_type TEXT DEFAULT '个人费用'`)
}
}
db.exec(`UPDATE expenses SET cost_type='家庭费用' WHERE cost_type='家庭消费'`)
db.exec(`UPDATE types SET cost_type='家庭费用' WHERE cost_type='家庭消费'`)
db.exec(`UPDATE subjects SET cost_type='家庭费用' WHERE cost_type='家庭消费'`)
const listTypesStmt = db.prepare(`SELECT id, name, cost_type FROM types ORDER BY id ASC`)
const insertTypeStmt = db.prepare(`INSERT OR IGNORE INTO types (name, cost_type) VALUES (?, ?)`)
const updateTypeStmt = db.prepare(`UPDATE types SET name=?, cost_type=?, updated_at=datetime('now') WHERE id=?`)
const getTypeByNameStmt = db.prepare(`SELECT id, name, cost_type FROM types WHERE name=?`)
const deleteTypeStmt = db.prepare(`DELETE FROM types WHERE id=?`)
const countExpensesByTypeStmt = db.prepare(`SELECT COUNT(1) AS c FROM expenses WHERE type_id=?`)
const deleteExpensesByTypeStmt = db.prepare(`DELETE FROM expenses WHERE type_id=?`)
const listExpenseIdsByTypeStmt = db.prepare(`SELECT id FROM expenses WHERE type_id=?`)
const listSubjectsStmt = db.prepare(`SELECT id, name, cost_type FROM subjects ORDER BY id ASC`)
const insertSubjectStmt = db.prepare(`INSERT OR IGNORE INTO subjects (name, cost_type) VALUES (?, ?)`)
const updateSubjectStmt = db.prepare(`UPDATE subjects SET name=?, cost_type=?, updated_at=datetime('now') WHERE id=?`)
const getSubjectByNameStmt = db.prepare(`SELECT id, name, cost_type FROM subjects WHERE name=?`)
const deleteSubjectStmt = db.prepare(`DELETE FROM subjects WHERE id=?`)
const countExpensesBySubjectStmt = db.prepare(`SELECT COUNT(1) AS c FROM expenses WHERE subject_id=?`)
const deleteExpensesBySubjectStmt = db.prepare(`DELETE FROM expenses WHERE subject_id=?`)
const listExpenseIdsBySubjectStmt = db.prepare(`SELECT id FROM expenses WHERE subject_id=?`)
const listExpensesStmt = db.prepare(`
SELECT e.id, e.date, e.amount_cents, e.content, e.arrived, e.cost_type,
t.name AS type_name, s.name AS subject_name
FROM expenses e
LEFT JOIN types t ON e.type_id = t.id
LEFT JOIN subjects s ON e.subject_id = s.id
ORDER BY e.date ASC, e.id ASC
`)
const insertExpenseStmt = db.prepare(`
INSERT INTO expenses (date, type_id, subject_id, amount_cents, content, arrived, cost_type)
VALUES (@date, @type_id, @subject_id, @amount_cents, @content, @arrived, @cost_type)
`)
const updateExpenseStmt = db.prepare(`
UPDATE expenses SET
date=@date, type_id=@type_id, subject_id=@subject_id,
amount_cents=@amount_cents, content=@content, arrived=@arrived, cost_type=@cost_type,
updated_at=datetime('now')
WHERE id=@id
`)
const deleteExpenseStmt = db.prepare(`DELETE FROM expenses WHERE id=?`)
// Attachments statements
const listAttachmentsByExpenseStmt = db.prepare(`SELECT id, expense_id, filename, path, size_bytes, mime FROM expense_attachments WHERE expense_id=? ORDER BY id ASC`)
const insertAttachmentStmt = db.prepare(`INSERT INTO expense_attachments (expense_id, filename, path, size_bytes, mime) VALUES (@expense_id, @filename, @path, @size_bytes, @mime)`)
const getAttachmentByIdStmt = db.prepare(`SELECT id, path FROM expense_attachments WHERE id=?`)
const deleteAttachmentStmt = db.prepare(`DELETE FROM expense_attachments WHERE id=?`)
const listAttachmentIdPathByExpenseStmt = db.prepare(`SELECT id, path FROM expense_attachments WHERE expense_id=?`)
// Stats statements
const statsTotalStmt = db.prepare(`SELECT COUNT(1) AS cnt, COALESCE(SUM(amount_cents),0) AS sum_cents FROM expenses WHERE date BETWEEN ? AND ?`)
const statsArrivedStmt = db.prepare(`SELECT COUNT(1) AS cnt, COALESCE(SUM(amount_cents),0) AS sum_cents FROM expenses WHERE date BETWEEN ? AND ? AND arrived=1`)
const statsByTypeStmt = db.prepare(`
SELECT t.name AS type_name,
COUNT(1) AS cnt,
COALESCE(SUM(e.amount_cents),0) AS sum_cents,
COALESCE(SUM(CASE WHEN e.arrived=1 THEN e.amount_cents ELSE 0 END),0) AS sum_arrived,
COALESCE(SUM(CASE WHEN e.arrived=0 THEN e.amount_cents ELSE 0 END),0) AS sum_pending
FROM expenses e
JOIN types t ON e.type_id = t.id
WHERE e.date BETWEEN ? AND ?
GROUP BY t.id
ORDER BY sum_cents DESC, t.id ASC
`)
const statsBySubjectStmt = db.prepare(`
SELECT s.name AS subject_name,
COUNT(1) AS cnt,
COALESCE(SUM(e.amount_cents),0) AS sum_cents,
COALESCE(SUM(CASE WHEN e.arrived=1 THEN e.amount_cents ELSE 0 END),0) AS sum_arrived,
COALESCE(SUM(CASE WHEN e.arrived=0 THEN e.amount_cents ELSE 0 END),0) AS sum_pending
FROM expenses e
JOIN subjects s ON e.subject_id = s.id
WHERE e.date BETWEEN ? AND ?
GROUP BY s.id
ORDER BY sum_cents DESC, s.id ASC
`)
const statsByCostTypeStmt = db.prepare(`
SELECT cost_type,
COUNT(1) AS cnt,
COALESCE(SUM(amount_cents),0) AS sum_cents,
COALESCE(SUM(CASE WHEN arrived=1 THEN amount_cents ELSE 0 END),0) AS sum_arrived,
COALESCE(SUM(CASE WHEN arrived=0 THEN amount_cents ELSE 0 END),0) AS sum_pending
FROM expenses
WHERE date BETWEEN ? AND ?
GROUP BY cost_type
ORDER BY sum_cents DESC, cost_type ASC
`)
const statsMonthlyStmt = db.prepare(`
SELECT SUBSTR(e.date,1,7) AS ym,
COUNT(1) AS cnt,
COALESCE(SUM(e.amount_cents),0) AS sum_cents,
COALESCE(SUM(CASE WHEN e.arrived=1 THEN e.amount_cents ELSE 0 END),0) AS sum_arrived,
COALESCE(SUM(CASE WHEN e.arrived=0 THEN e.amount_cents ELSE 0 END),0) AS sum_pending
FROM expenses e
WHERE e.date BETWEEN ? AND ?
GROUP BY ym
ORDER BY ym ASC
`)
const ensureDefaults = () => {
const cntTypes = db.prepare(`SELECT COUNT(1) AS c FROM types`).get().c
const cntSubjects = db.prepare(`SELECT COUNT(1) AS c FROM subjects`).get().c
if (cntTypes === 0) {
;['差旅费','业务招待费','公共交通','打车','其他'].forEach(n => { try { insertTypeStmt.run(n, '个人费用') } catch {} })
logJSON('expense.defaults.types', { added: true }, 'expense')
}
if (cntSubjects === 0) {
;['深圳','上海'].forEach(n => { try { insertSubjectStmt.run(n, '个人费用') } catch {} })
logJSON('expense.defaults.subjects', { added: true }, 'expense')
}
}
ensureDefaults()
const formatBeijingDate = ts => {
const d = ts ? new Date(Number(ts)) : new Date()
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(d)
const y = parts.find(p => p.type === 'year')?.value || '1970'
const m = parts.find(p => p.type === 'month')?.value || '01'
const day = parts.find(p => p.type === 'day')?.value || '01'
return `${y}-${m}-${day}`
}
const bindRoutes = (app) => {
app.use('/api/expense', (req, res, next) => {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
next();
});
app.get('/api/expense/sys-config', (req, res) => {
try {
const cfgPath = path.join(process.cwd(), 'config', 'expense.json')
let cfg = {}
if (fs.existsSync(cfgPath)) {
try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) } catch {}
}
res.json({ ok: true, config: cfg })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post('/api/expense/proxy/weekly', async (req, res) => {
try {
const cfgPath = path.join(process.cwd(), 'config', 'expense.json')
let cfg = {}
if (fs.existsSync(cfgPath)) {
try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) } catch {}
}
const targetUrl = cfg.weekly_api_url;
if (!targetUrl) {
return res.status(500).json({ ok: false, error: 'no_weekly_url_configured' });
}
const response = await axios.post(targetUrl, req.body, {
headers: { 'Content-Type': 'application/json' },
timeout: 10000
});
res.json(response.data);
} catch (e) {
const errMsg = e.response && e.response.data && e.response.data.error ? e.response.data.error : (e.message || String(e));
res.status(500).json({ ok: false, error: errMsg });
}
})
app.get('/api/expense/types', (req, res) => {
try {
const rows = listTypesStmt.all()
res.json({ ok: true, rows })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post('/api/expense/types', (req, res) => {
try {
const name = String((req.body && req.body.name) || '').trim()
const cost_type = String((req.body && req.body.cost_type) || '个人费用').trim()
if (!name) return res.status(400).json({ ok: false, error: 'empty_name' })
insertTypeStmt.run(name, cost_type)
const row = getTypeByNameStmt.get(name)
logJSON('expense.type.create', { id: row && row.id, name, cost_type }, 'expense')
res.json({ ok: true, id: row && row.id })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.put('/api/expense/types/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10)
const name = String((req.body && req.body.name) || '').trim()
const cost_type = String((req.body && req.body.cost_type) || '个人费用').trim()
if (!id || !name) return res.status(400).json({ ok: false, error: 'bad_input' })
updateTypeStmt.run(name, cost_type, id)
logJSON('expense.type.update', { id, name, cost_type }, 'expense')
res.json({ ok: true })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.delete('/api/expense/types/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10)
if (!id) return res.status(400).json({ ok: false, error: 'bad_input' })
const cnt = countExpensesByTypeStmt.get(id).c
let deletedExpenses = 0
if (cnt > 0) {
const expRows = listExpenseIdsByTypeStmt.all(id)
for (const r of expRows) {
const atts = listAttachmentIdPathByExpenseStmt.all(r.id)
for (const a of atts) {
if (a && a.path) {
const abs = path.join(process.cwd(), 'uploads', String(a.path))
try { fs.unlinkSync(abs) } catch {}
}
deleteAttachmentStmt.run(a.id)
}
}
deletedExpenses = deleteExpensesByTypeStmt.run(id).changes || 0
}
const delType = deleteTypeStmt.run(id).changes || 0
logJSON('expense.type.delete', { id, deleted_type: delType, deleted_expenses: deletedExpenses }, 'expense')
res.json({ ok: true, deleted_expenses: deletedExpenses })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get('/api/expense/subjects', (req, res) => {
try {
const rows = listSubjectsStmt.all()
res.json({ ok: true, rows })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post('/api/expense/subjects', (req, res) => {
try {
const name = String((req.body && req.body.name) || '').trim()
const cost_type = String((req.body && req.body.cost_type) || '个人费用').trim()
if (!name) return res.status(400).json({ ok: false, error: 'empty_name' })
insertSubjectStmt.run(name, cost_type)
const row = getSubjectByNameStmt.get(name)
logJSON('expense.subject.create', { id: row && row.id, name, cost_type }, 'expense')
res.json({ ok: true, id: row && row.id })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.put('/api/expense/subjects/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10)
const name = String((req.body && req.body.name) || '').trim()
const cost_type = String((req.body && req.body.cost_type) || '个人费用').trim()
if (!id || !name) return res.status(400).json({ ok: false, error: 'bad_input' })
updateSubjectStmt.run(name, cost_type, id)
logJSON('expense.subject.update', { id, name, cost_type }, 'expense')
res.json({ ok: true })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.delete('/api/expense/subjects/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10)
if (!id) return res.status(400).json({ ok: false, error: 'bad_input' })
const cnt = countExpensesBySubjectStmt.get(id).c
let deletedExpenses = 0
if (cnt > 0) {
const expRows = listExpenseIdsBySubjectStmt.all(id)
for (const r of expRows) {
const atts = listAttachmentIdPathByExpenseStmt.all(r.id)
for (const a of atts) {
if (a && a.path) {
const abs = path.join(process.cwd(), 'uploads', String(a.path))
try { fs.unlinkSync(abs) } catch {}
}
deleteAttachmentStmt.run(a.id)
}
}
deletedExpenses = deleteExpensesBySubjectStmt.run(id).changes || 0
}
const delSubject = deleteSubjectStmt.run(id).changes || 0
logJSON('expense.subject.delete', { id, deleted_subject: delSubject, deleted_expenses: deletedExpenses }, 'expense')
res.json({ ok: true, deleted_expenses: deletedExpenses })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get('/api/expense/records', (req, res) => {
try {
const rowsRaw = listExpensesStmt.all()
const rows = rowsRaw.map(r => ({
id: r.id,
date: r.date,
cost_type: r.cost_type || '个人费用',
type: r.type_name || '',
amount: Number(r.amount_cents || 0) / 100,
subject: r.subject_name || '',
content: r.content || '',
arrived: !!r.arrived
}))
res.json({ ok: true, rows })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post('/api/expense/records', (req, res) => {
try {
const body = req.body || {}
const date = String(body.date || '').trim()
const typeName = String(body.type || '').trim()
const subjectName = String(body.subject || '').trim()
const content = String(body.content || '').trim()
const arrived = body.arrived ? 1 : 0
const cost_type = String(body.cost_type || '个人费用').trim()
const amountNum = parseFloat(String(body.amount || '0'))
const amount_cents = Math.round((Number.isFinite(amountNum) ? amountNum : 0) * 100)
let t = getTypeByNameStmt.get(typeName)
let s = getSubjectByNameStmt.get(subjectName)
if (!t && typeName) { try { insertTypeStmt.run(typeName) } catch {} ; t = getTypeByNameStmt.get(typeName) }
if (!s && subjectName) { try { insertSubjectStmt.run(subjectName) } catch {} ; s = getSubjectByNameStmt.get(subjectName) }
logJSON('expense.record.create.try', { typeName, subjectName, t_exists: !!t, s_exists: !!s }, 'expense')
if (!t || !s) return res.status(400).json({ ok: false, error: 'bad_type_or_subject' })
const info = insertExpenseStmt.run({ date, type_id: t.id, subject_id: s.id, amount_cents, content, arrived, cost_type })
logJSON('expense.record.create', { id: info.lastInsertRowid, date, type: typeName, subject: subjectName, amount_cents }, 'expense')
res.json({ ok: true, id: info.lastInsertRowid })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post('/api/expense/rational-lock/push', (req, res) => {
try {
const body = req.body || {}
const amountNum = parseFloat(String(body.amount || '0'))
const amount = Number.isFinite(amountNum) ? amountNum : NaN
const content = String(body.content_html || '').trim()
if (!Number.isFinite(amount) || amount < 0 || !content) {
return res.status(400).json({ ok: false, error: 'bad_input' })
}
const dateRaw = String(body.date || '').trim()
const date = /^\d{4}-\d{2}-\d{2}$/.test(dateRaw) ? dateRaw : formatBeijingDate(body.purchased_at)
const cost_type = '家庭费用'
const typeName = '其他家庭费用'
const subjectName = '其他家庭费用'
const amount_cents = Math.round(amount * 100)
let t = getTypeByNameStmt.get(typeName)
let s = getSubjectByNameStmt.get(subjectName)
if (!t) {
try { insertTypeStmt.run(typeName, cost_type) } catch {}
t = getTypeByNameStmt.get(typeName)
}
if (!s) {
try { insertSubjectStmt.run(subjectName, cost_type) } catch {}
s = getSubjectByNameStmt.get(subjectName)
}
if (t && t.cost_type !== cost_type) {
updateTypeStmt.run(typeName, cost_type, t.id)
}
if (s && s.cost_type !== cost_type) {
updateSubjectStmt.run(subjectName, cost_type, s.id)
}
if (!t || !s) return res.status(400).json({ ok: false, error: 'bad_type_or_subject' })
const info = insertExpenseStmt.run({
date,
type_id: t.id,
subject_id: s.id,
amount_cents,
content,
arrived: 1,
cost_type
})
logJSON('expense.rational_lock.push', { id: info.lastInsertRowid, date, amount_cents }, 'expense')
res.json({ ok: true, id: info.lastInsertRowid })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.put('/api/expense/records/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10)
const body = req.body || {}
const date = String(body.date || '').trim()
const typeName = String(body.type || '').trim()
const subjectName = String(body.subject || '').trim()
const content = String(body.content || '').trim()
const arrived = body.arrived ? 1 : 0
const cost_type = String(body.cost_type || '个人费用').trim()
const amountNum = parseFloat(String(body.amount || '0'))
const amount_cents = Math.round((Number.isFinite(amountNum) ? amountNum : 0) * 100)
let t = getTypeByNameStmt.get(typeName)
let s = getSubjectByNameStmt.get(subjectName)
if (!t && typeName) { try { insertTypeStmt.run(typeName) } catch {} ; t = getTypeByNameStmt.get(typeName) }
if (!s && subjectName) { try { insertSubjectStmt.run(subjectName) } catch {} ; s = getSubjectByNameStmt.get(subjectName) }
logJSON('expense.record.update.try', { id, typeName, subjectName, t_exists: !!t, s_exists: !!s }, 'expense')
if (!id || !t || !s) return res.status(400).json({ ok: false, error: 'bad_input' })
updateExpenseStmt.run({ id, date, type_id: t.id, subject_id: s.id, amount_cents, content, arrived, cost_type })
logJSON('expense.record.update', { id, date, type: typeName, subject: subjectName, amount_cents }, 'expense')
res.json({ ok: true })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.delete('/api/expense/records/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10)
const atts = listAttachmentIdPathByExpenseStmt.all(id)
for (const a of atts) {
if (a && a.path) {
const abs = path.join(process.cwd(), 'uploads', String(a.path))
try { fs.unlinkSync(abs) } catch {}
}
deleteAttachmentStmt.run(a.id)
}
deleteExpenseStmt.run(id)
logJSON('expense.record.delete', { id }, 'expense')
res.json({ ok: true })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// Attachments APIs (independent upload code & folder)
app.get('/api/expense/attachments/:expense_id', (req, res) => {
try {
const expenseId = parseInt(req.params.expense_id, 10)
if (!expenseId) return res.status(400).json({ ok: false, error: 'bad_input' })
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
res.setHeader('Pragma', 'no-cache')
res.setHeader('Expires', '0')
const rows = listAttachmentsByExpenseStmt.all(expenseId).map(r => ({
id: r.id,
expense_id: r.expense_id,
filename: r.filename,
size_bytes: r.size_bytes,
mime: r.mime || '',
url: `/uploads/${r.path.replace(/^\\+|^\/+/, '')}`
}))
res.json({ ok: true, rows })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post('/api/expense/attachments/chunk', (req, res) => {
try {
const b = req.body || {}
const file_key = String(b.file_key || '').trim()
const chunk_index = parseInt(b.chunk_index, 10)
const total_chunks = parseInt(b.total_chunks, 10)
const data_base64 = String(b.data_base64 || '')
if (!file_key || !Number.isFinite(chunk_index) || !Number.isFinite(total_chunks) || !data_base64) {
return res.status(400).json({ ok: false, error: 'bad_input' })
}
const dir = path.join(chunksDir, file_key)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
const fname = String(chunk_index).padStart(6, '0') + '.bin'
const out = path.join(dir, fname)
const buf = Buffer.from(data_base64, 'base64')
fs.writeFileSync(out, buf)
logJSON('expense.attach.chunk', { file_key, chunk_index, total_chunks, bytes: buf.length }, 'expense')
res.json({ ok: true })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post('/api/expense/attachments/commit', (req, res) => {
try {
const b = req.body || {}
const file_key = String(b.file_key || '').trim()
const filenameRaw = String(b.filename || '').trim()
const size = parseInt(b.size, 10) || 0
const mime = String(b.mime || '')
const total_chunks = parseInt(b.total_chunks, 10)
const expense_id = parseInt(b.expense_id, 10)
if (!file_key || !filenameRaw || !Number.isFinite(total_chunks) || !expense_id) {
return res.status(400).json({ ok: false, error: 'bad_input' })
}
const safeName = filenameRaw.replace(/[^a-zA-Z0-9._-]/g, '_')
const ts = Date.now()
const rnd = Math.random().toString(36).slice(2, 8)
const finalRel = path.join('expense', 'attachments', `${ts}-${rnd}-${safeName}`)
const finalAbs = path.join(process.cwd(), 'uploads', finalRel)
const chunksPath = path.join(chunksDir, file_key)
const files = []
for (let i = 0; i < total_chunks; i++) {
files.push(path.join(chunksPath, String(i).padStart(6, '0') + '.bin'))
}
// Assemble
if (!fs.existsSync(path.dirname(finalAbs))) fs.mkdirSync(path.dirname(finalAbs), { recursive: true })
try { fs.unlinkSync(finalAbs) } catch {}
let bytes = 0
for (const f of files) {
const buf = fs.readFileSync(f)
fs.appendFileSync(finalAbs, buf)
bytes += buf.length
}
// Cleanup chunks
try {
const list = fs.readdirSync(chunksPath)
for (const f of list) fs.unlinkSync(path.join(chunksPath, f))
fs.rmdirSync(chunksPath)
} catch {}
const info = insertAttachmentStmt.run({ expense_id, filename: filenameRaw, path: finalRel.replace(/\\/g, '/'), size_bytes: (size || bytes), mime })
const id = info.lastInsertRowid
logJSON('expense.attach.commit', { id, expense_id, filename: filenameRaw, bytes }, 'expense')
res.json({ ok: true, id, url: `/uploads/${finalRel.replace(/\\/g, '/')}` })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.delete('/api/expense/attachments/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10)
if (!id) return res.status(400).json({ ok: false, error: 'bad_input' })
const row = getAttachmentByIdStmt.get(id)
if (row && row.path) {
const abs = path.join(process.cwd(), 'uploads', String(row.path))
try { fs.unlinkSync(abs) } catch {}
}
deleteAttachmentStmt.run(id)
logJSON('expense.attach.delete', { id }, 'expense')
res.json({ ok: true })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// Stats APIs
const computeRange = (startRaw, endRaw) => {
const pad = n => String(n).padStart(2,'0')
const today = new Date()
const startDefault = new Date(today.getFullYear(), today.getMonth(), 1)
const endDefault = new Date(today.getFullYear(), today.getMonth()+1, 0)
const fmt = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`
const start = String(startRaw || '').trim() || fmt(startDefault)
const end = String(endRaw || '').trim() || fmt(endDefault)
return { start, end }
}
app.get('/api/expense/stats/summary', (req, res) => {
try {
const { start, end } = computeRange(req.query.start, req.query.end)
const t = statsTotalStmt.get(start, end)
const a = statsArrivedStmt.get(start, end)
const total_amount = Number(t.sum_cents || 0) / 100
const arrived_amount = Number(a.sum_cents || 0) / 100
const pending_amount = total_amount - arrived_amount
const result = {
start, end,
total_count: t.cnt || 0,
total_amount,
arrived_count: a.cnt || 0,
arrived_amount,
pending_count: (t.cnt || 0) - (a.cnt || 0),
pending_amount
}
res.json({ ok: true, summary: result })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get('/api/expense/stats/by-type', (req, res) => {
try {
const { start, end } = computeRange(req.query.start, req.query.end)
const rows = statsByTypeStmt.all(start, end).map(r => ({
type: r.type_name || '',
count: r.cnt || 0,
amount: Number(r.sum_cents || 0) / 100,
amount_arrived: Number(r.sum_arrived || 0) / 100,
amount_pending: Number(r.sum_pending || 0) / 100
}))
res.json({ ok: true, start, end, rows })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get('/api/expense/stats/by-subject', (req, res) => {
try {
const { start, end } = computeRange(req.query.start, req.query.end)
const rows = statsBySubjectStmt.all(start, end).map(r => ({
subject: r.subject_name || '',
count: r.cnt || 0,
amount: Number(r.sum_cents || 0) / 100,
amount_arrived: Number(r.sum_arrived || 0) / 100,
amount_pending: Number(r.sum_pending || 0) / 100
}))
res.json({ ok: true, start, end, rows })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get('/api/expense/stats/by-cost-type', (req, res) => {
try {
const { start, end } = computeRange(req.query.start, req.query.end)
const rows = statsByCostTypeStmt.all(start, end).map(r => ({
cost_type: r.cost_type || '个人费用',
count: r.cnt || 0,
amount: Number(r.sum_cents || 0) / 100,
amount_arrived: Number(r.sum_arrived || 0) / 100,
amount_pending: Number(r.sum_pending || 0) / 100
}))
res.json({ ok: true, start, end, rows })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get('/api/expense/stats/monthly', (req, res) => {
try {
const { start, end } = computeRange(req.query.start, req.query.end)
const rows = statsMonthlyStmt.all(start, end).map(r => ({
month: r.ym || '',
count: r.cnt || 0,
amount: Number(r.sum_cents || 0) / 100,
amount_arrived: Number(r.sum_arrived || 0) / 100,
amount_pending: Number(r.sum_pending || 0) / 100
}))
res.json({ ok: true, start, end, rows })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// Export CSV for records
const csvEscape = v => {
const s = String(v == null ? '' : v)
const t = s.replace(/"/g, '""')
return `"${t}"`
}
app.get('/api/expense/export.csv', (req, res) => {
try {
const { start, end } = computeRange(req.query.start, req.query.end)
const rows = db.prepare(`
SELECT e.date, e.cost_type, t.name AS type_name, s.name AS subject_name, e.amount_cents, e.content, e.arrived
FROM expenses e
LEFT JOIN types t ON e.type_id = t.id
LEFT JOIN subjects s ON e.subject_id = s.id
WHERE e.date BETWEEN ? AND ?
ORDER BY e.date ASC, e.id ASC
`).all(start, end)
const header = '\ufeff日期,费用类型,报销类型,报销金额(元),报销主体,报销内容,是否到账\n'
const body = rows.map(r => [
csvEscape(r.date || ''),
csvEscape(r.cost_type || '个人费用'),
csvEscape(r.type_name || ''),
csvEscape((Number(r.amount_cents || 0) / 100).toFixed(2)),
csvEscape(r.subject_name || ''),
csvEscape(String(r.content || '').replace(/\r?\n/g, ' ')),
csvEscape(r.arrived ? '已到账' : '未到账')
].join(',')).join('\n')
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', 'attachment; filename="expense_records.csv"')
res.send(header + body)
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// Export CSV for stats
app.get('/api/expense/stats/export.csv', (req, res) => {
try {
const { start, end } = computeRange(req.query.start, req.query.end)
const t = statsTotalStmt.get(start, end)
const a = statsArrivedStmt.get(start, end)
const summaryLines = [
['期间', `${start} ~ ${end}`],
['总金额(元)', (Number(t.sum_cents || 0)/100).toFixed(2)],
['总记录数', t.cnt || 0],
['已到账金额(元)', (Number(a.sum_cents || 0)/100).toFixed(2)],
['已到账记录数', a.cnt || 0],
['未到账金额(元)', ((Number(t.sum_cents||0)-Number(a.sum_cents||0))/100).toFixed(2)],
['未到账记录数', ((t.cnt||0)-(a.cnt||0))]
]
const byType = statsByTypeStmt.all(start, end)
const bySubject = statsBySubjectStmt.all(start, end)
const byCostType = statsByCostTypeStmt.all(start, end)
const monthly = statsMonthlyStmt.all(start, end)
const toCsvBlock = (title, headerArr, rowsArr) => {
const head = headerArr.map(csvEscape).join(',')
const body = rowsArr.map(r => r.map(csvEscape).join(',')).join('\n')
return `${csvEscape(title)}\n${head}\n${body}\n`
}
const summaryCsv = toCsvBlock('汇总', ['指标','数值'], summaryLines)
const costTypeCsv = toCsvBlock('按费用类别', ['类别','金额(元)','已到账(元)','未到账(元)','记录数'],
byCostType.map(r => [r.cost_type||'个人费用', (Number(r.sum_cents||0)/100).toFixed(2), (Number(r.sum_arrived||0)/100).toFixed(2), (Number(r.sum_pending||0)/100).toFixed(2), r.cnt||0]))
const typeCsv = toCsvBlock('按类型', ['类型','金额(元)','已到账(元)','未到账(元)','记录数'],
byType.map(r => [r.type_name||'', (Number(r.sum_cents||0)/100).toFixed(2), (Number(r.sum_arrived||0)/100).toFixed(2), (Number(r.sum_pending||0)/100).toFixed(2), r.cnt||0]))
const subjectCsv = toCsvBlock('按主体', ['主体','金额(元)','已到账(元)','未到账(元)','记录数'],
bySubject.map(r => [r.subject_name||'', (Number(r.sum_cents||0)/100).toFixed(2), (Number(r.sum_arrived||0)/100).toFixed(2), (Number(r.sum_pending||0)/100).toFixed(2), r.cnt||0]))
const monthlyCsv = toCsvBlock('月度', ['月份','金额(元)','已到账(元)','未到账(元)','记录数'],
monthly.map(r => [r.ym||'', (Number(r.sum_cents||0)/100).toFixed(2), (Number(r.sum_arrived||0)/100).toFixed(2), (Number(r.sum_pending||0)/100).toFixed(2), r.cnt||0]))
const content = '\ufeff' + [summaryCsv, costTypeCsv, typeCsv, subjectCsv, monthlyCsv].join('\n')
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', 'attachment; filename="expense_stats.csv"')
res.send(content)
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
}
module.exports = { bindRoutes }
+802
View File
@@ -0,0 +1,802 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const axios = require('axios');
const XLSX = require('xlsx');
let db = null;
function initDb(dbPath) {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
db = new Database(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS exercise_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
unit TEXT NOT NULL,
weekly_goal INTEGER DEFAULT 0
)
`);
try {
const cols = db.prepare("PRAGMA table_info(exercise_types)").all();
if (!cols.some(c => c.name === 'sort_order')) {
db.exec("ALTER TABLE exercise_types ADD COLUMN sort_order INTEGER DEFAULT 0");
}
} catch (e) {}
db.exec(`
CREATE TABLE IF NOT EXISTS daily_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
is_active BOOLEAN DEFAULT 1
)
`);
try {
const cols = db.prepare("PRAGMA table_info(daily_plans)").all();
if (!cols.some(c => c.name === 'is_active')) {
db.exec("ALTER TABLE daily_plans ADD COLUMN is_active BOOLEAN DEFAULT 1");
}
// Normalize NULL values to active
db.exec("UPDATE daily_plans SET is_active = 1 WHERE is_active IS NULL");
} catch (e) {}
db.exec(`
CREATE TABLE IF NOT EXISTS exercise_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_date TEXT NOT NULL,
calories INTEGER NOT NULL DEFAULT 0,
exercise_duration INTEGER NOT NULL DEFAULT 0,
stand_count INTEGER NOT NULL DEFAULT 0,
daily_plan_id INTEGER,
diastolic_bp INTEGER,
systolic_bp INTEGER,
heart_rate INTEGER,
waist_circ INTEGER,
weight_kg REAL,
created_time TEXT NOT NULL,
FOREIGN KEY (daily_plan_id) REFERENCES daily_plans(id)
)
`);
try {
const cols = db.prepare("PRAGMA table_info(exercise_records)").all();
const colNames = new Set(cols.map(c => c.name));
if (!colNames.has('diastolic_bp')) {
db.exec("ALTER TABLE exercise_records ADD COLUMN diastolic_bp INTEGER");
}
if (!colNames.has('systolic_bp')) {
db.exec("ALTER TABLE exercise_records ADD COLUMN systolic_bp INTEGER");
}
if (!colNames.has('waist_circ')) {
db.exec("ALTER TABLE exercise_records ADD COLUMN waist_circ INTEGER");
}
if (!colNames.has('heart_rate')) {
db.exec("ALTER TABLE exercise_records ADD COLUMN heart_rate INTEGER");
}
if (!colNames.has('weight_kg')) {
db.exec("ALTER TABLE exercise_records ADD COLUMN weight_kg REAL");
}
} catch (e) {
console.error('[FitnessDiary] Migration error:', e.message);
}
db.exec(`
CREATE TABLE IF NOT EXISTS exercise_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_id INTEGER NOT NULL,
exercise_type_id INTEGER NOT NULL,
quantity INTEGER NOT NULL DEFAULT 0,
unit TEXT NOT NULL,
FOREIGN KEY (record_id) REFERENCES exercise_records(id) ON DELETE CASCADE,
FOREIGN KEY (exercise_type_id) REFERENCES exercise_types(id)
)
`);
// Performance indexes
try {
db.exec(`CREATE INDEX IF NOT EXISTS idx_records_date ON exercise_records(record_date)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_items_record ON exercise_items(record_id)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_items_type ON exercise_items(exercise_type_id)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_records_plan ON exercise_records(daily_plan_id)`);
} catch (e) {}
// Seed or import data for daily_plans/exercise_types when empty
try {
const typeCount = db.prepare('SELECT COUNT(1) AS c FROM exercise_types').get().c || 0;
const planCount = db.prepare('SELECT COUNT(1) AS c FROM daily_plans').get().c || 0;
if (planCount === 0) {
// Try import from old DB first
const oldPath = path.join('d:\\', 'Trae_Files', 'FitnessDiary_1', 'exercise_log.db');
let imported = false;
try {
if (fs.existsSync(oldPath)) {
const OldDB = require('better-sqlite3');
const old = new OldDB(oldPath);
const oldPlans = old.prepare('SELECT id, name FROM daily_plans').all();
if (Array.isArray(oldPlans) && oldPlans.length) {
const stmt = db.prepare('INSERT OR IGNORE INTO daily_plans (name, description, is_active) VALUES (?, ?, 1)');
for (const p of oldPlans) {
stmt.run(String(p.name || '').trim(), '', 1);
}
imported = true;
}
}
} catch {}
if (!imported) {
// Fallback seed defaults
const defaults = ['正常训练', '轻断食', '跑步日', '力量日', '恢复日', '高强运动日'];
const stmt = db.prepare('INSERT OR IGNORE INTO daily_plans (name, description, is_active) VALUES (?, ?, 1)');
defaults.forEach(n => stmt.run(n, '', 1));
}
}
if (typeCount === 0) {
// Try import exercise_types from old DB
try {
const oldPath = path.join('d:\\', 'Trae_Files', 'FitnessDiary_1', 'exercise_log.db');
if (fs.existsSync(oldPath)) {
const OldDB = require('better-sqlite3');
const old = new OldDB(oldPath);
const oldTypes = old.prepare('SELECT id, name, unit, weekly_goal FROM exercise_types').all();
if (Array.isArray(oldTypes) && oldTypes.length) {
const stmt = db.prepare('INSERT OR IGNORE INTO exercise_types (name, unit, weekly_goal, sort_order) VALUES (?, ?, ?, ?)');
let sort = 0;
for (const t of oldTypes) {
stmt.run(String(t.name || '').trim(), String(t.unit || '').trim(), parseInt(t.weekly_goal || 0), sort++);
}
}
}
} catch {}
}
} catch {}
}
function getDb() {
return db;
}
// Helper: Push to Weekly
async function pushToWeekly(recordId) {
try {
const record = db.prepare(`
SELECT r.*, p.name as plan_name
FROM exercise_records r
LEFT JOIN daily_plans p ON r.daily_plan_id = p.id
WHERE r.id = ?
`).get(recordId);
if (!record || record.calories <= 100) {
if (record && record.calories <= 100) console.log(`[FitnessDiary] Skipping push to weekly: calories ${record.calories} <= 100`);
return;
}
const dateStr = String(record.record_date || '').slice(0, 10);
if (!dateStr) return;
const records = db.prepare(`
SELECT r.*, p.name as plan_name
FROM exercise_records r
LEFT JOIN daily_plans p ON r.daily_plan_id = p.id
WHERE r.record_date = ?
ORDER BY r.id
`).all(dateStr);
if (!Array.isArray(records) || !records.length) return;
const ids = records.map(r => r.id);
let itemsByRecord = {};
if (ids.length) {
const placeholders = ids.map(() => '?').join(',');
const rows = db.prepare(`
SELECT i.record_id, i.quantity, i.unit, t.name
FROM exercise_items i
LEFT JOIN exercise_types t ON i.exercise_type_id = t.id
WHERE i.record_id IN (${placeholders})
`).all(...ids);
for (const it of rows) {
const rid = it.record_id;
if (!itemsByRecord[rid]) itemsByRecord[rid] = [];
itemsByRecord[rid].push(it);
}
}
const escapeHtml = s => String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
let totalCalories = 0;
let totalDuration = 0;
let totalStand = 0;
let html = '<table border="1" cellspacing="0" cellpadding="4"><thead><tr><th>日期</th><th>计划</th><th>项目</th><th>数量</th><th>单位</th></tr></thead><tbody>';
for (const r of records) {
const list = itemsByRecord[r.id] || [];
if (!list.length) {
html += `<tr><td>${escapeHtml(dateStr)}</td><td>${escapeHtml(r.plan_name || '')}</td><td></td><td></td><td></td></tr>`;
} else {
for (const it of list) {
html += `<tr><td>${escapeHtml(dateStr)}</td><td>${escapeHtml(r.plan_name || '')}</td><td>${escapeHtml(it.name || '')}</td><td>${Number(it.quantity || 0)}</td><td>${escapeHtml(it.unit || '')}</td></tr>`;
}
}
totalCalories += Number(r.calories || 0);
totalDuration += Number(r.exercise_duration || 0);
totalStand += Number(r.stand_count || 0);
}
html += '</tbody></table>';
const payload = {
key: process.env.FITNESS_DIARY_API_KEY || process.env.SYSTEM_AUTH_PASS_93220 || '',
external_id: String(recordId),
date: dateStr,
items: html,
calories: totalCalories,
duration: totalDuration
};
// Assuming local server is running on process.env.PORT or default 8976
const port = process.env.PORT || 8976;
const url = `http://127.0.0.1:${port}/api/weekly/ext/fitness`;
console.log(`[FitnessDiary] Pushing to weekly: ${url}`, JSON.stringify(payload));
await axios.post(url, payload);
console.log('[FitnessDiary] Push successful');
} catch (err) {
console.error('[FitnessDiary] Failed to push to weekly:', err.message);
}
}
async function deleteFromWeekly(recordId) {
try {
const port = process.env.PORT || 8976;
const url = `http://127.0.0.1:${port}/api/weekly/ext/fitness`;
const payload = {
key: process.env.FITNESS_DIARY_API_KEY || process.env.SYSTEM_AUTH_PASS_93220 || '',
external_id: String(recordId),
action: 'delete'
};
console.log(`[FitnessDiary] Deleting from weekly: ${url}`, JSON.stringify(payload));
await axios.post(url, payload);
console.log('[FitnessDiary] Delete push successful');
} catch (err) {
console.error('[FitnessDiary] Failed to delete from weekly:', err.message);
}
}
// Auth Helper - DEPRECATED: Auth handled by main server middleware
// const NAV_AUTH_COOKIE = 'nav_gate';
// const parseCookie = ...
// const checkAuth = ...
function bindRoutes(app) {
const API = '/api/fitness_diary';
// Disable caching for all fitness_diary API responses
app.use(API, (req, res, next) => {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
res.set('Surrogate-Control', 'no-store');
} catch {}
next();
});
// Get all exercise types
app.get(`${API}/types`, (req, res) => {
try {
const rows = db.prepare('SELECT * FROM exercise_types ORDER BY sort_order ASC, name ASC').all();
res.json({ ok: true, data: rows });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Get all daily plans
app.get(`${API}/daily_plans`, (req, res) => {
try {
let hasActive = false;
try {
const cols = db.prepare('PRAGMA table_info(daily_plans)').all();
hasActive = cols.some(c => c.name === 'is_active');
} catch {}
const sql = hasActive ? 'SELECT * FROM daily_plans WHERE is_active = 1 ORDER BY id' : 'SELECT * FROM daily_plans ORDER BY id';
const rows = db.prepare(sql).all();
res.json({ ok: true, data: rows });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Get records (with optional date filter)
app.get(`${API}/records`, (req, res) => {
try {
const { start, end } = req.query;
// console.log(`[FitnessDiary] Get records: start=${start}, end=${end}`);
let sql = `
SELECT r.*, p.name as plan_name
FROM exercise_records r
LEFT JOIN daily_plans p ON r.daily_plan_id = p.id
`;
const params = [];
if (start && end) {
sql += ` WHERE r.record_date >= ? AND r.record_date <= ?`;
params.push(start, end);
}
sql += ` ORDER BY r.record_date DESC`;
const records = db.prepare(sql).all(...params);
// console.log(`[FitnessDiary] Found ${records.length} records`);
const ids = records.map(r => r.id);
let itemsByRecord = {};
if (ids.length) {
const placeholders = ids.map(()=>'?').join(',');
const items = db.prepare(`
SELECT i.*, t.name as type_name
FROM exercise_items i
LEFT JOIN exercise_types t ON i.exercise_type_id = t.id
WHERE i.record_id IN (${placeholders})
`).all(...ids);
for (const it of items) {
const rid = it.record_id;
if (!itemsByRecord[rid]) itemsByRecord[rid] = [];
itemsByRecord[rid].push(it);
}
}
for (const rec of records) {
rec.items = itemsByRecord[rec.id] || [];
}
res.json({ ok: true, data: records });
} catch (e) {
console.error('[FitnessDiary] Get records error:', e);
res.status(500).json({ ok: false, error: e.message });
}
});
// Add new record
app.post(`${API}/records`, async (req, res) => {
try {
const {
record_date,
calories,
exercise_duration,
stand_count,
daily_plan_id,
diastolic_bp,
systolic_bp,
heart_rate,
waist_circ,
weight_kg,
items
} = req.body;
// 1. Check duplicate for same day
const existing = db.prepare('SELECT id FROM exercise_records WHERE record_date = ?').get(record_date);
if (existing) {
return res.json({ ok: false, error: '该日期已存在记录,请勿重复添加' });
}
// 2. Insert Record
const insertRecord = db.prepare(`
INSERT INTO exercise_records (record_date, calories, exercise_duration, stand_count, daily_plan_id, diastolic_bp, systolic_bp, heart_rate, waist_circ, weight_kg, created_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now', 'localtime'))
`);
let planId = null;
if (daily_plan_id !== undefined && daily_plan_id !== null && daily_plan_id !== '') {
const n = parseInt(daily_plan_id, 10);
if (!Number.isNaN(n) && n > 0) planId = n;
}
const getIntOrNull = (val) => {
if (val === undefined || val === null || val === '') return null;
const n = parseInt(val, 10);
return (!Number.isNaN(n) && n > 0) ? n : null;
};
const getRealOrNull = (val) => {
if (val === undefined || val === null || val === '') return null;
const n = parseFloat(val);
return (!Number.isNaN(n)) ? n : null;
};
const info = insertRecord.run(
record_date,
calories,
exercise_duration,
stand_count,
planId,
getIntOrNull(diastolic_bp),
getIntOrNull(systolic_bp),
getIntOrNull(heart_rate),
getIntOrNull(waist_circ),
getRealOrNull(weight_kg)
);
const recordId = info.lastInsertRowid;
// 3. Insert Items
const insertItem = db.prepare(`
INSERT INTO exercise_items (record_id, exercise_type_id, quantity, unit)
VALUES (?, ?, ?, ?)
`);
if (Array.isArray(items)) {
for (const it of items) {
insertItem.run(recordId, it.exercise_type_id, it.quantity, it.unit);
}
}
// 4. Async Push to Weekly
const pushResult = await pushToWeekly(recordId);
res.json({ ok: true, id: recordId, push: pushResult });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Delete record
app.delete(`${API}/records/:id`, async (req, res) => {
try {
const { id } = req.params;
db.prepare('DELETE FROM exercise_records WHERE id = ?').run(id);
// Items cascade delete automatically
try { await deleteFromWeekly(id); } catch {}
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Record detail
app.get(`${API}/record/:id`, (req, res) => {
try {
const { id } = req.params;
const r = db.prepare(`
SELECT r.*, p.name as plan_name
FROM exercise_records r
LEFT JOIN daily_plans p ON r.daily_plan_id = p.id
WHERE r.id = ?
`).get(id);
if (!r) return res.status(404).json({ ok: false, error: 'not_found' });
const items = db.prepare(`
SELECT i.id, i.exercise_type_id, i.quantity, i.unit, t.name
FROM exercise_items i
LEFT JOIN exercise_types t ON i.exercise_type_id = t.id
WHERE i.record_id = ?
`).all(id);
res.json({
id: r.id,
record_date: r.record_date,
calories: r.calories,
exercise_duration: r.exercise_duration,
stand_count: r.stand_count,
diastolic_bp: r.diastolic_bp,
systolic_bp: r.systolic_bp,
heart_rate: r.heart_rate,
waist_circ: r.waist_circ,
weight_kg: r.weight_kg,
daily_plan: r.plan_name || '',
daily_plan_id: r.daily_plan_id,
items
});
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Update record
app.put(`${API}/records/:id`, async (req, res) => {
try {
const { id } = req.params;
const body = req.body || {};
const r = db.prepare(`SELECT * FROM exercise_records WHERE id = ?`).get(id);
if (!r) return res.status(404).json({ ok: false, error: 'not_found' });
const recordDate = new Date(String(r.record_date).slice(0,10)+'T00:00:00');
const diffDays = Math.floor((Date.now() - recordDate.getTime())/86400000);
if (diffDays > 31) return res.status(400).json({ ok: false, error: '超过一个月的历史日期不允许修改' });
const getRealOrNull = (val) => {
if (val === undefined || val === null || val === '') return null;
const n = parseFloat(val);
return (!Number.isNaN(n)) ? n : null;
};
db.prepare(`
UPDATE exercise_records
SET daily_plan_id = ?, calories = ?, exercise_duration = ?, stand_count = ?, diastolic_bp = ?, systolic_bp = ?, heart_rate = ?, waist_circ = ?, weight_kg = ?
WHERE id = ?
`).run((() => {
const v = body.daily_plan_id;
if (v === undefined || v === null || v === '') return null;
const n = parseInt(v, 10);
return (!Number.isNaN(n) && n > 0) ? n : null;
})(),
parseInt(body.calories||0),
parseInt(body.exercise_duration||0),
parseInt(body.stand_count||0),
body.diastolic_bp ? parseInt(body.diastolic_bp, 10) : null,
body.systolic_bp ? parseInt(body.systolic_bp, 10) : null,
body.heart_rate ? parseInt(body.heart_rate, 10) : null,
body.waist_circ ? parseInt(body.waist_circ, 10) : null,
getRealOrNull(body.weight_kg),
id);
db.prepare(`DELETE FROM exercise_items WHERE record_id = ?`).run(id);
if (Array.isArray(body.items)) {
const stmt = db.prepare(`INSERT INTO exercise_items (record_id, exercise_type_id, quantity, unit) VALUES (?, ?, ?, ?)`);
for (const it of body.items) {
stmt.run(id, it.exercise_type_id, parseInt(it.quantity||0), String(it.unit||''));
}
}
const pushResult = await pushToWeekly(id);
res.json({ ok: true, push: pushResult });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Stats (Sum for range)
app.get(`${API}/stats`, (req, res) => {
try {
const { start, end } = req.query;
let sql = `
SELECT
SUM(calories) as sum_calories,
SUM(exercise_duration) as sum_duration,
SUM(stand_count) as sum_stand
FROM exercise_records
`;
const params = [];
if (start && end) {
sql += ` WHERE record_date >= ? AND record_date <= ?`;
params.push(start, end);
}
const stats = db.prepare(sql).get(...params);
res.json({
ok: true,
stats: {
sum_calories: stats.sum_calories || 0,
sum_duration: stats.sum_duration || 0,
sum_stand: stats.sum_stand || 0
}
});
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Weekly Progress (for progress bars)
app.get(`${API}/weekly_progress`, (req, res) => {
try {
const fmt = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
const baseDateStr = (req.query.date ? String(req.query.date).slice(0, 10) : fmt(new Date()));
const d = new Date(baseDateStr + 'T00:00:00');
const day = d.getDay() || 7;
const start = new Date(d);
start.setDate(d.getDate() - day + 1);
const end = new Date(start);
end.setDate(start.getDate() + 6);
const sStr = fmt(start);
const eStr = fmt(end);
// Get all types and goals
const types = db.prepare('SELECT * FROM exercise_types ORDER BY sort_order ASC, id ASC').all();
const progress = {};
types.forEach(t => {
progress[t.id] = { ...t, done: 0 };
});
// Sum up records in range
const records = db.prepare(`
SELECT i.exercise_type_id, i.quantity
FROM exercise_items i
JOIN exercise_records r ON i.record_id = r.id
WHERE r.record_date >= ? AND r.record_date <= ?
`).all(sStr, eStr);
records.forEach(r => {
if (progress[r.exercise_type_id]) {
progress[r.exercise_type_id].done += r.quantity;
}
});
const result = Object.values(progress).map(p => ({
type_id: p.id,
name: p.name,
unit: p.unit,
weekly_goal: p.weekly_goal,
done: p.done,
remaining: Math.max(0, p.weekly_goal - p.done)
}));
res.json({ ok: true, week_start: sStr, week_end: eStr, progress: result });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Update types
app.post(`${API}/types/update`, (req, res) => {
try {
const body = req.body || {};
const arr = Array.isArray(body.types) ? body.types : [];
const names = arr.map(x => String(x.name||'').trim()).filter(Boolean);
const dup = names.filter((n, i) => names.indexOf(n) !== i);
if (dup.length) return res.status(400).json({ ok: false, error: `项目名称重复:${Array.from(new Set(dup)).join('、')}` });
const update = db.prepare(`UPDATE exercise_types SET name = ?, unit = ?, weekly_goal = ?, sort_order = ? WHERE id = ?`);
const checkUnique = db.prepare(`SELECT id FROM exercise_types WHERE name = ? AND id != ?`);
for (const t of arr) {
const name = String(t.name||'').trim();
const unit = String(t.unit||'').trim();
const goal = parseInt(t.weekly_goal||0);
const sort = parseInt(t.sort_order||0);
const id = parseInt(t.id||0);
if (!id || !name || !unit) continue;
const exists = checkUnique.get(name, id);
if (exists) return res.status(400).json({ ok: false, error: '保存失败:项目名称已存在(数据库唯一约束)。请检查并修改后再保存。' });
update.run(name, unit, goal, sort, id);
}
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Add type
app.post(`${API}/types/add`, (req, res) => {
try {
const { name, unit, weekly_goal } = req.body || {};
const n = String(name||'').trim();
const u = String(unit||'').trim();
const g = parseInt(weekly_goal||0);
if (!n || !u) return res.status(400).json({ ok: false, error: '请输入项目名称和单位' });
const exists = db.prepare(`SELECT id FROM exercise_types WHERE name = ?`).get(n);
if (exists) return res.status(400).json({ ok: false, error: '该项目已存在' });
db.prepare(`INSERT INTO exercise_types (name, unit, weekly_goal, sort_order) VALUES (?, ?, ?, ?)`).run(n, u, g, 0);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Delete type
app.delete(`${API}/types/:id`, (req, res) => {
try {
const id = parseInt(req.params.id || '0', 10);
if (!id) return res.status(400).json({ ok: false, error: '参数错误' });
const used = db.prepare(`SELECT COUNT(1) AS c FROM exercise_items WHERE exercise_type_id = ?`).get(id);
if (Number(used.c || 0) > 0) {
return res.status(400).json({ ok: false, error: '该项目已在记录中使用,无法删除' });
}
db.prepare(`DELETE FROM exercise_types WHERE id = ?`).run(id);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Update plans
app.post(`${API}/plans/update`, (req, res) => {
try {
const body = req.body || {};
const arr = Array.isArray(body.plans) ? body.plans : [];
const names = arr.map(x => String(x.name||'').trim()).filter(Boolean);
const dup = names.filter((n, i) => names.indexOf(n) !== i);
if (dup.length) return res.status(400).json({ ok: false, error: `计划名称重复:${Array.from(new Set(dup)).join('、')}` });
const update = db.prepare(`UPDATE daily_plans SET name = ? WHERE id = ?`);
const checkUnique = db.prepare(`SELECT id FROM daily_plans WHERE name = ? AND id != ?`);
for (const p of arr) {
const name = String(p.name||'').trim();
const id = parseInt(p.id||0);
if (!id || !name) continue;
const exists = checkUnique.get(name, id);
if (exists) return res.status(400).json({ ok: false, error: '保存失败:计划名称已存在。' });
update.run(name, id);
}
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Add plan
app.post(`${API}/plans/add`, (req, res) => {
try {
const { name } = req.body || {};
const n = String(name||'').trim();
if (!n) return res.status(400).json({ ok: false, error: '请输入计划名称' });
const exists = db.prepare(`SELECT id FROM daily_plans WHERE name = ?`).get(n);
if (exists) return res.status(400).json({ ok: false, error: '该计划已存在' });
db.prepare(`INSERT INTO daily_plans (name) VALUES (?)`).run(n);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Delete plan (soft delete)
app.delete(`${API}/plans/:id`, (req, res) => {
try {
const id = parseInt(req.params.id || '0', 10);
if (!id) return res.status(400).json({ ok: false, error: '参数错误' });
db.prepare(`UPDATE daily_plans SET is_active = 0 WHERE id = ?`).run(id);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Clear data
app.post(`${API}/records/clear`, (req, res) => {
try {
db.prepare(`DELETE FROM exercise_items`).run();
db.prepare(`DELETE FROM exercise_records`).run();
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// Export Excel
app.get(`${API}/export`, (req, res) => {
try {
const { start, end } = req.query;
let sql = `SELECT r.*, p.name as plan_name FROM exercise_records r LEFT JOIN daily_plans p ON r.daily_plan_id = p.id`;
const params = [];
if (start && end) {
sql += ` WHERE r.record_date >= ? AND r.record_date <= ?`;
params.push(start, end);
}
sql += ` ORDER BY r.record_date ASC`;
const records = db.prepare(sql).all(...params);
const items = [];
for (const r of records) {
const its = db.prepare(`
SELECT i.*, t.name as type_name
FROM exercise_items i
LEFT JOIN exercise_types t ON i.exercise_type_id = t.id
WHERE i.record_id = ?
`).all(r.id);
its.forEach(it => items.push({
id: it.id, record_id: r.id, date: r.record_date, exercise_type: it.type_name || '', quantity: it.quantity, unit: it.unit
}));
}
const ws1 = XLSX.utils.json_to_sheet(records.map(r => ({
id: r.id,
record_date: r.record_date,
daily_plan: r.plan_name || '',
calories: r.calories,
exercise_duration: r.exercise_duration,
stand_count: r.stand_count,
created_time: r.created_time
})));
const ws2 = XLSX.utils.json_to_sheet(items);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws1, 'Records');
XLSX.utils.book_append_sheet(wb, ws2, 'Items');
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
const startName = (start || '').replace(/-/g, '') || 'start';
const endName = (end || '').replace(/-/g, '') || 'end';
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="export_${startName}_${endName}.xlsx"`);
res.send(buf);
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
}
module.exports = {
initDb,
getDb,
bindRoutes
};
+221
View File
@@ -0,0 +1,221 @@
const { db } = require('./db')
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const { logJSON } = require('./logger')
const axios = require('axios')
const cfgPath = path.join(process.cwd(), 'config', 'funds.json')
if (!fs.existsSync(path.dirname(cfgPath))) fs.mkdirSync(path.dirname(cfgPath), { recursive: true })
if (!fs.existsSync(cfgPath)) {
const defaultPort = process.env.PORT || 5050
const defaultBase = `http://localhost:${defaultPort}/api/weekly`
fs.writeFileSync(cfgPath, JSON.stringify({ machine_salt: crypto.randomBytes(16).toString('hex'), display: { currency: 'CNY' }, weekly_api_base: defaultBase, weekly_key: '', weekly_sync_enabled: true }, null, 2))
}
const getConfig = () => {
if (_cachedCfg) return _cachedCfg
_cachedCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
return _cachedCfg
}
const setConfig = cfg => {
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2))
_cachedCfg = null // Clear config cache
_cachedKey = null // Clear key cache
}
let _cachedCfg = null
let _cachedKey = null
const deriveKey = () => {
if (_cachedKey) return _cachedKey
const cfg = getConfig()
const salt = Buffer.from(String(cfg.machine_salt || 'default'), 'utf-8')
const passphrase = process.env.FUNDS_PASSPHRASE || process.env.SYSTEM_AUTH_PASS_93220 || ''
_cachedKey = crypto.scryptSync(passphrase + String(cfg.machine_salt || ''), salt, 32)
return _cachedKey
}
const enc = (plain) => {
const key = deriveKey()
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
const ct = Buffer.concat([cipher.update(Buffer.from(String(plain||''), 'utf-8')), cipher.final()])
const tag = cipher.getAuthTag()
return { ct, iv, tag }
}
const dec = (ct, iv, tag) => {
try {
const key = deriveKey()
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(tag)
const pt = Buffer.concat([decipher.update(ct), decipher.final()])
return pt.toString('utf-8')
} catch { return '' }
}
db.exec(
`CREATE TABLE IF NOT EXISTS fund_flows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
type TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
ending_cents INTEGER NOT NULL,
bank_status_enc BLOB,
bank_status_iv BLOB,
bank_status_tag BLOB,
remark_enc BLOB,
remark_iv BLOB,
remark_tag BLOB,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`
)
// Prepared statements (global for performance, data is still real-time)
const insertFlow = db.prepare(
`INSERT INTO fund_flows (date, type, amount_cents, ending_cents, bank_status_enc, bank_status_iv, bank_status_tag, remark_enc, remark_iv, remark_tag)
VALUES (@date, @type, @amount_cents, 0, @bank_status_enc, @bank_status_iv, @bank_status_tag, @remark_enc, @remark_iv, @remark_tag)`
)
const updateFlow = db.prepare(
`UPDATE fund_flows SET date=@date, type=@type, amount_cents=@amount_cents, bank_status_enc=@bank_status_enc, bank_status_iv=@bank_status_iv, bank_status_tag=@bank_status_tag, remark_enc=@remark_enc, remark_iv=@remark_iv, remark_tag=@remark_tag, updated_at=datetime('now') WHERE id=@id`
)
const deleteFlowStmt = db.prepare(`DELETE FROM fund_flows WHERE id = ?`)
const listFlowsStmt = db.prepare(`SELECT id, date, type, amount_cents, ending_cents, bank_status_enc, bank_status_iv, bank_status_tag, remark_enc, remark_iv, remark_tag FROM fund_flows ORDER BY date ASC, id ASC`)
const listRangeStmt = db.prepare(`SELECT id, date, type, amount_cents, ending_cents, bank_status_enc, bank_status_iv, bank_status_tag, remark_enc, remark_iv, remark_tag FROM fund_flows WHERE date>=? AND date<=? ORDER BY date ASC, id ASC`)
const listByDateStmt = db.prepare(`SELECT id, date, type, amount_cents, ending_cents, bank_status_enc, bank_status_iv, bank_status_tag, remark_enc, remark_iv, remark_tag FROM fund_flows WHERE date=? ORDER BY id ASC`)
const sumByDateStmt = db.prepare(`SELECT SUM(amount_cents) AS s FROM fund_flows WHERE date=?`)
const updateEndingStmt = db.prepare(`UPDATE fund_flows SET ending_cents=? WHERE id=?`)
const recalcEndingFrom = db.transaction((startDate) => {
const all = listFlowsStmt.all()
let balance = 0
for (const r of all) {
if (startDate) {
// find starting balance: sum of amounts before startDate
if (r.date < startDate) { balance += (r.amount_cents||0); continue }
}
balance += (r.amount_cents||0)
updateEndingStmt.run(balance, r.id)
}
})
const listFlows = (start, end, type, kw) => {
const rows = (start && end) ? listRangeStmt.all(start, end) : listFlowsStmt.all()
const mapRow = r => {
const bank = r.bank_status_enc ? dec(r.bank_status_enc, r.bank_status_iv, r.bank_status_tag) : ''
const remark = r.remark_enc ? dec(r.remark_enc, r.remark_iv, r.remark_tag) : ''
return { id: r.id, date: r.date, type: r.type, amount_cents: r.amount_cents, ending_cents: r.ending_cents, bank_status: bank, remark }
}
let filtered = rows.map(mapRow)
if (type && type !== 'all') filtered = filtered.filter(r => r.type === type)
if (kw) { const reg = new RegExp(String(kw).trim(), 'i'); filtered = filtered.filter(r => reg.test(r.bank_status||'') || reg.test(r.remark||'')) }
return filtered
}
const formatCNY = cents => {
try { return (Number(cents||0) / 100).toFixed(2) } catch { return '0.00' }
}
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 pushWeeklyDaily = async (date) => {
try {
const api = getWeeklyCfg()
if (!api.enabled) return
const rows = listByDateStmt.all(date)
const total = (sumByDateStmt.get(date).s || 0)
const title = `本金变动池 ${date}(净变动:${formatCNY(total)})`
const items = rows.map((r, idx) => ({
idx: idx+1,
type: r.type,
amount: formatCNY(r.amount_cents),
bank: r.bank_status_enc ? dec(r.bank_status_enc, r.bank_status_iv, r.bank_status_tag) : '',
remark: r.remark_enc ? dec(r.remark_enc, r.remark_iv, r.remark_tag) : ''
}))
const trs = items.map(it => `<tr><td>${it.idx}</td><td>${it.type==='in'?'汇入':'汇出'}</td><td>${it.amount}</td><td>${(it.bank||'').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</td><td>${(it.remark||'').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</td></tr>`).join('')
const content = `
<div>
<h4>当日净变动</h4>
<p>人民币:${formatCNY(total)}</p>
<h4>明细</h4>
<table border="1" cellspacing="0" cellpadding="4">
<thead><tr><th>序号</th><th>类型</th><th>金额(元)</th><th>银行方/状态</th><th>备注</th></tr></thead>
<tbody>${trs}</tbody>
</table>
</div>
`.trim()
const payload = { key: api.key, source: 'funds', external_id: `funds-daily-${date}`, title, content, date, status_code: 'new', status_label: '📅本周新增' }
const url = `${api.base}/ext/event`
logJSON('funds.weekly.push.try', { date, url, base: api.base, rows: rows.length, total_cents: total, title_len: title.length, content_len: content.length }, 'funds')
const resp = await axios.post(url, payload, { timeout: 15000, headers: { 'Content-Type': 'application/json' } })
logJSON('funds.weekly.push.resp', { date, status: resp && resp.status, data_ok: !!(resp && resp.data && resp.data.ok), data: brief((resp && resp.data) ? JSON.stringify(resp.data) : '') }, 'funds')
} catch (e) {
const status = e && e.response ? e.response.status : null
const data = e && e.response ? e.response.data : null
logJSON('funds.weekly.push.error', { date, url: (getWeeklyCfg().base + '/ext/event'), status, code: e && e.code, message: String(e.message||e), data: typeof data==='string' ? brief(data) : brief(data ? JSON.stringify(data) : '') }, 'funds')
}
}
const deleteOrUpdateWeeklyDaily = async (date) => {
try {
const api = getWeeklyCfg()
if (!api.enabled) return
const rows = listByDateStmt.all(date)
if (!rows || rows.length === 0) {
const url = `${api.base}/ext/event`
const payload = { key: api.key, source: 'funds', external_id: `funds-daily-${date}`, date, deleted: true }
logJSON('funds.weekly.delete.try', { date, url, base: api.base }, 'funds')
const resp = await axios.post(url, payload, { timeout: 15000, headers: { 'Content-Type': 'application/json' } })
logJSON('funds.weekly.delete.resp', { date, status: resp && resp.status, data_ok: !!(resp && resp.data && resp.data.ok), data: brief((resp && resp.data) ? JSON.stringify(resp.data) : '') }, 'funds')
} else {
await pushWeeklyDaily(date)
}
} catch (e) {
const status = e && e.response ? e.response.status : null
const data = e && e.response ? e.response.data : null
logJSON('funds.weekly.delete.error', { date, url: (getWeeklyCfg().base + '/ext/event'), status, code: e && e.code, message: String(e.message||e), data: typeof data==='string' ? brief(data) : brief(data ? JSON.stringify(data) : '') }, 'funds')
}
}
const createFlow = (payload) => {
const { date, type, amount_cents, bank_status, remark } = payload
const bs = enc(bank_status||'')
const rm = enc(remark||'')
insertFlow.run({ date, type, amount_cents, bank_status_enc: bs.ct, bank_status_iv: bs.iv, bank_status_tag: bs.tag, remark_enc: rm.ct, remark_iv: rm.iv, remark_tag: rm.tag })
recalcEndingFrom(null)
logJSON('funds.create', { date, type, amount_cents })
Promise.resolve().then(()=>pushWeeklyDaily(date)).catch(()=>{})
}
const updateFlowById = (id, payload) => {
const { date, type, amount_cents, bank_status, remark } = payload
const bs = enc(bank_status||'')
const rm = enc(remark||'')
updateFlow.run({ id, date, type, amount_cents, bank_status_enc: bs.ct, bank_status_iv: bs.iv, bank_status_tag: bs.tag, remark_enc: rm.ct, remark_iv: rm.iv, remark_tag: rm.tag })
recalcEndingFrom(null)
logJSON('funds.update', { id, date, type, amount_cents })
Promise.resolve().then(()=>pushWeeklyDaily(date)).catch(()=>{})
}
const deleteFlow = (id) => {
try {
const row = db.prepare(`SELECT date FROM fund_flows WHERE id=?`).get(id)
deleteFlowStmt.run(id)
recalcEndingFrom(null)
logJSON('funds.delete', { id })
if (row && row.date) Promise.resolve().then(()=>deleteOrUpdateWeeklyDaily(row.date)).catch(()=>{})
} catch (e) {
deleteFlowStmt.run(id)
recalcEndingFrom(null)
logJSON('funds.delete', { id, error: String(e.message||e) })
}
}
module.exports = { getConfig, setConfig, listFlows, createFlow, updateFlowById, deleteFlow }
File diff suppressed because it is too large Load Diff
+5941
View File
File diff suppressed because it is too large Load Diff
+290
View File
@@ -0,0 +1,290 @@
const fs = require('fs')
const path = require('path')
const iconv = require('iconv-lite')
const { logJSON } = require('./logger')
const readTextWithFallback = filePath => {
const buf = fs.readFileSync(filePath)
const attempts = [
{ name: 'utf8', text: buf.toString('utf8') },
{ name: 'gb18030', text: iconv.decode(buf, 'gb18030') },
{ name: 'gbk', text: iconv.decode(buf, 'gbk') }
]
const score = text => {
const bad = (text.match(/\uFFFD/g) || []).length
const dateCount = (text.match(/20\d{2}-\d{2}-\d{2}/g) || []).length
const cjk = (text.match(/[\u4e00-\u9fff]/g) || []).length
return dateCount * 100 + cjk - bad * 30
}
attempts.sort((a, b) => score(b.text) - score(a.text))
return { encoding: attempts[0].name, text: attempts[0].text }
}
const parseCsvLine = line => {
const cells = []
let cur = ''
let inQuote = false
for (let i = 0; i < line.length; i += 1) {
const ch = line[i]
if (ch === '"') {
if (inQuote && line[i + 1] === '"') {
cur += '"'
i += 1
} else {
inQuote = !inQuote
}
continue
}
if (ch === ',' && !inQuote) {
cells.push(cur)
cur = ''
continue
}
cur += ch
}
cells.push(cur)
return cells.map(v => String(v || '').trim())
}
const parseCsvText = text => {
const lines = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
return lines.map((line, idx) => ({ lineNo: idx + 1, cells: parseCsvLine(line) }))
}
const pickNumber = (text, patterns) => {
for (const p of patterns) {
const m = text.match(p)
if (m && m[1] !== undefined) {
const n = Number(m[1])
if (Number.isFinite(n)) return n
}
}
return null
}
const detectEventType = text => {
if (/卖出成交|买入成交|成交单|成交价|成交金额|买入成功|卖出成功/.test(text)) return 'fill'
if (/废单|取消|撤单/.test(text)) return 'cancel'
if (/预设|条件单|条件单已设|已设条件单|回补设置|回卖预设|抄底预设|设定买入|设定卖出|计划卖出|计划买入/.test(text)) return 'order_preset'
if (/同日生效|同时生效|规则生效|每周|每个|自动定投/.test(text)) return 'rule_activate'
return 'note'
}
const detectSide = (text, eventType) => {
if (/卖出成交|卖出成功|卖出成交单/.test(text)) return 'sell'
if (/买入成交|买入成功|买入成交单/.test(text)) return 'buy'
if (eventType === 'order_preset') {
if (/卖出的?条件单|卖出条件单|设定卖出|计划卖出|定投卖出|回卖/.test(text)) return 'sell'
if (/买入的?条件单|买入条件单|设定买入|计划买入|回补单|抄底单/.test(text)) return 'buy'
}
const hasSell = /卖出|止盈|抛/.test(text)
const hasBuy = /买入|回补|抄底|补仓|定投/.test(text)
if (eventType === 'fill') {
if (/回补单|条件单|预设/.test(text) && !/买入成交|买入成功|卖出成交|卖出成功/.test(text)) {
if (hasSell && !hasBuy) return 'sell'
return ''
}
if (hasSell && !hasBuy) return 'sell'
if (hasBuy && !hasSell) return 'buy'
}
if (eventType === 'order_preset') {
if (hasSell && !hasBuy) return 'sell'
if (hasBuy && !hasSell) return 'buy'
if (/卖出预设/.test(text)) return 'sell'
if (/买入预设|回补|抄底/.test(text)) return 'buy'
}
if (hasSell && !hasBuy) return 'sell'
if (hasBuy && !hasSell) return 'buy'
return ''
}
const backfillFillSide = events => {
const pending = []
for (const ev of events) {
if (ev.event_type === 'order_preset' && ev.status === 'pending' && ev.side) {
pending.push(ev)
continue
}
if (ev.event_type !== 'fill' || ev.side) continue
const idx = [...pending].reverse().findIndex(p => {
if (p.symbol !== ev.symbol) return false
if (p.strategy_id && ev.strategy_id && p.strategy_id !== ev.strategy_id) return false
if (p.qty && ev.qty && Number(p.qty) !== Number(ev.qty)) return false
return true
})
if (idx < 0) continue
const matched = pending[pending.length - 1 - idx]
ev.side = matched.side
ev.pocket = inferPocket(ev.strategy_id, ev.side, ev.note || '')
}
return events
}
const detectStatus = eventType => {
if (eventType === 'fill') return 'filled'
if (eventType === 'cancel') return 'cancelled'
if (eventType === 'order_preset') return 'pending'
return ''
}
const inferStrategyId = text => {
if (/采摘|回补|回卖/.test(text)) return 'HARVEST_LADDER_v1'
if (/网格|跌幅|自动定投/.test(text)) return 'GRID_AMBUSH_v1'
if (/现金仓|抄底/.test(text)) return 'CASH_BOTTOM_v1'
return 'MANUAL_DIARY_v1'
}
const inferPocket = (strategyId, side, text) => {
if (strategyId === 'CASH_BOTTOM_v1') return 'cash'
if (strategyId === 'GRID_AMBUSH_v1') return 'grid'
if (/现金仓/.test(text)) return 'cash'
if (side === 'sell') return 'core'
return 'swing'
}
const inferChainId = text => {
if (/第一档/.test(text)) return 'L1'
if (/第二档/.test(text)) return 'L2'
if (/第三档/.test(text)) return 'L3'
const merged = text.match(/(\d)\D{0,6}(\d)/)
if (/合并/.test(text) && merged) return `M${merged[1]}${merged[2]}`
if (/网格/.test(text)) return 'G'
return 'X'
}
const extractEventFromText = (text, context) => {
const line = String(text || '').trim()
if (!line) return null
if (!/(20\d{2}-\d{2}-\d{2}|预设|成交|回补|回卖|网格|抄底|定投|废单|取消|撤单)/.test(line)) return null
const eventType = detectEventType(line)
const side = detectSide(line, eventType)
const strategyId = inferStrategyId(`${context.sectionText} ${line}`)
const orderPrice = pickNumber(line, [
/单价[^\d-]*([0-9]+(?:\.[0-9]+)?)/,
/([0-9]+(?:\.[0-9]+)?)\s*元/
])
const fillPrice = pickNumber(line, [
/成交价[^\d-]*([0-9]+(?:\.[0-9]+)?)/,
/价格[^\d-]*([0-9]+(?:\.[0-9]+)?)/
])
const qty = pickNumber(line, [/([0-9]+(?:\.[0-9]+)?)\s*股/])
const amount = pickNumber(line, [/成交金额[^\d-]*([0-9]+(?:\.[0-9]+)?)/])
const eventDate = (line.match(/20\d{2}-\d{2}-\d{2}/) || [context.lastDate || ''])[0]
const triggerRule = (line.match(/[+-]?\d+(?:\.\d+)?%/g) || []).join('|')
const chainId = inferChainId(line)
const parentMatch = line.match(/(\d)\D{0,6}(\d)/)
const parentEventIds = /合并/.test(line) && parentMatch ? `${parentMatch[1]}+${parentMatch[2]}` : ''
return {
event_time: eventDate,
symbol: context.currentSymbol || '',
name: context.currentName || '',
strategy_id: strategyId,
pocket: inferPocket(strategyId, side, line),
chain_id: chainId,
event_type: eventType,
side,
order_price: eventType === 'order_preset' ? orderPrice : null,
fill_price: eventType === 'fill' ? (fillPrice || orderPrice) : null,
qty,
amount: eventType === 'fill' ? amount : null,
trigger_rule: triggerRule,
status: detectStatus(eventType),
parent_event_ids: parentEventIds,
note: line
}
}
const normalizeRowsToEvents = (rows, symbolFilter) => {
const events = []
let currentSymbol = ''
let currentName = ''
let sectionText = ''
let lastDate = ''
let seq = 1
for (const row of rows) {
const cells = row.cells
const c0 = String(cells[0] || '').trim()
const c1 = String(cells[1] || '').trim()
const c4 = String(cells[4] || '').trim()
const c5 = String(cells[5] || '').trim()
const c6 = String(cells[6] || '').trim()
if (/\d{6}/.test(c1)) currentSymbol = (c1.match(/\d{6}/) || [''])[0]
if (c0 && c0 !== 'ETF名称') currentName = c0
if (c4) sectionText = c4
const useSymbol = symbolFilter ? symbolFilter === currentSymbol : true
if (!useSymbol) continue
const mergedText = [c4, c5, c6].filter(Boolean).join(' ')
const pieces = mergedText.split(/\s{2,}|----+|={3,}|------------/).map(s => s.trim()).filter(Boolean)
const candidateTexts = pieces.length ? pieces : [mergedText]
for (const one of candidateTexts) {
const context = { currentSymbol, currentName, sectionText, lastDate }
const ev = extractEventFromText(one, context)
if (!ev) continue
if (ev.event_time) lastDate = ev.event_time
const id = `EV${ev.symbol || 'NA'}_${String(seq).padStart(4, '0')}`
seq += 1
events.push(Object.assign({ event_id: id, source_line_no: row.lineNo }, ev))
}
}
return backfillFillSide(events)
}
const summarizeEvents = events => {
let buy = 0
let sell = 0
let pending = 0
for (const ev of events) {
if (ev.status === 'pending') pending += 1
if (ev.event_type !== 'fill') continue
const amt = Number(ev.amount || 0)
if (ev.side === 'buy') buy += amt
if (ev.side === 'sell') sell += amt
}
return {
total_events: events.length,
pending_orders: pending,
filled_buy_amount: Number(buy.toFixed(2)),
filled_sell_amount: Number(sell.toFixed(2)),
net_cashflow: Number((sell - buy).toFixed(2))
}
}
const bindRoutes = app => {
app.use('/api/investment_ledger', (req, res, next) => {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate')
res.set('Pragma', 'no-cache')
res.set('Expires', '0')
next()
})
app.get('/api/investment_ledger/ping', (req, res) => {
res.json({ ok: true, ts: Date.now() })
})
app.post('/api/investment_ledger/parse', (req, res) => {
try {
const symbol = String((req.body && req.body.symbol) || '').trim()
const rawText = String((req.body && req.body.raw_text) || '')
const filePath = String((req.body && req.body.file_path) || '').trim()
let text = rawText
let encoding = 'utf8'
if (!text && filePath) {
const abs = path.resolve(filePath)
if (!fs.existsSync(abs)) return res.status(400).json({ ok: false, error: 'file_not_found' })
const decoded = readTextWithFallback(abs)
text = decoded.text
encoding = decoded.encoding
}
if (!text) return res.status(400).json({ ok: false, error: 'empty_input' })
const rows = parseCsvText(text)
const events = normalizeRowsToEvents(rows, symbol)
const summary = summarizeEvents(events)
logJSON('investment_ledger.parse', { symbol, encoding, events: events.length }, 'investment_ledger')
res.json({ ok: true, encoding, summary, rows: events })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
}
module.exports = { bindRoutes }
+203
View File
@@ -0,0 +1,203 @@
const Database = require('better-sqlite3')
const path = require('path')
const fs = require('fs')
const crypto = require('crypto')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'language_behavior_lock.db')
const db = new Database(dbPath)
db.exec(`
CREATE TABLE IF NOT EXISTS lbl_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS lbl_sessions (
token TEXT PRIMARY KEY,
username TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS lbl_user_data (
username TEXT PRIMARY KEY,
records_json TEXT NOT NULL DEFAULT '[]',
keywords_json TEXT NOT NULL DEFAULT '[]',
draft_json TEXT,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_lbl_sessions_user ON lbl_sessions(username);
CREATE INDEX IF NOT EXISTS idx_lbl_sessions_exp ON lbl_sessions(expires_at);
`)
const now = () => Date.now()
const SESSION_MAX_AGE = 1 * 24 * 3600 * 1000
const COOKIE_NAME = 'language_behavior_lock_sid'
const DEFAULT_USER = { username: 'admin_lbl', password: process.env.LOCK_DEFAULT_PASSWORD || '' }
const parseCookie = cookieStr => {
const out = {}
String(cookieStr || '').split(/;\s*/).forEach(p => {
const idx = p.indexOf('=')
if (idx > 0) out[p.slice(0, idx)] = decodeURIComponent(p.slice(idx + 1))
})
return out
}
const ensureDefaultUser = () => {
const row = db.prepare('SELECT username FROM lbl_users WHERE username = ?').get(DEFAULT_USER.username)
if (!row) {
db.prepare('INSERT INTO lbl_users (username, password, created_at) VALUES (?, ?, ?)').run(DEFAULT_USER.username, DEFAULT_USER.password, now())
}
}
const cleanupSessions = () => {
db.prepare('DELETE FROM lbl_sessions WHERE expires_at <= ?').run(now())
}
const readDataOf = username => {
const row = db.prepare('SELECT records_json, keywords_json, draft_json FROM lbl_user_data WHERE username = ?').get(username)
if (!row) return { db: { records: [], keywords: [] }, draft: null }
let records = []
let keywords = []
let draft = null
try { records = JSON.parse(row.records_json || '[]') } catch {}
try { keywords = JSON.parse(row.keywords_json || '[]') } catch {}
try { draft = row.draft_json ? JSON.parse(row.draft_json) : null } catch {}
return {
db: {
records: Array.isArray(records) ? records : [],
keywords: Array.isArray(keywords) ? keywords : []
},
draft: draft && typeof draft === 'object' ? draft : null
}
}
const writeDataOf = (username, data) => {
const dbData = data && data.db && typeof data.db === 'object' ? data.db : {}
const records = Array.isArray(dbData.records) ? dbData.records : []
const keywords = Array.isArray(dbData.keywords) ? dbData.keywords : []
const draft = data && data.draft && typeof data.draft === 'object' ? data.draft : null
db.prepare(`
INSERT INTO lbl_user_data (username, records_json, keywords_json, draft_json, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(username) DO UPDATE SET
records_json = excluded.records_json,
keywords_json = excluded.keywords_json,
draft_json = excluded.draft_json,
updated_at = excluded.updated_at
`).run(
username,
JSON.stringify(records),
JSON.stringify(keywords),
draft ? JSON.stringify(draft) : null,
now()
)
}
const getSessionUser = req => {
cleanupSessions()
const cookies = parseCookie(req.headers.cookie || '')
const token = String(cookies[COOKIE_NAME] || '')
if (!token) return ''
const row = db.prepare('SELECT username, expires_at FROM lbl_sessions WHERE token = ?').get(token)
if (!row) return ''
if (Number(row.expires_at) <= now()) {
db.prepare('DELETE FROM lbl_sessions WHERE token = ?').run(token)
return ''
}
return String(row.username || '')
}
const requireAuth = (req, res, next) => {
const username = getSessionUser(req)
if (!username) return res.status(401).json({ ok: false, error: 'unauthorized' })
req.lblUser = username
next()
}
const bindRoutes = app => {
ensureDefaultUser()
app.get('/api/language_behavior_lock/health', (req, res) => {
res.json({ ok: true })
})
app.get('/api/language_behavior_lock/auth/local_creds', (req, res) => {
try {
const os = require('os')
const userProfile = process.env.USERPROFILE || process.env.HOME || os.homedir()
const candidates = [path.join(userProfile, 'Toolbox_local_creds.env.local')]
const usersRoot = path.join(path.parse(userProfile).root, 'Users')
try {
if (fs.existsSync(usersRoot)) {
fs.readdirSync(usersRoot).forEach(name => {
const dir = path.join(usersRoot, name)
if (dir !== userProfile && fs.existsSync(dir)) candidates.push(path.join(dir, 'Toolbox_local_creds.env.local'))
})
}
} catch {}
let content = null
for (const p of candidates) { if (fs.existsSync(p)) { content = fs.readFileSync(p, 'utf-8'); break } }
if (!content) return res.json({ ok: true, creds: null })
const creds = {}
content.split(/\r?\n/).forEach(line => {
const t = line.trim()
if (!t || t.startsWith('#')) return
const idx = t.indexOf('=')
if (idx > 0) creds[t.slice(0, idx).trim()] = t.slice(idx + 1).trim()
})
const serverToken = String(creds.TOOLBOX_LOCAL_CREDS_TOKEN || '')
if (serverToken) {
const clientToken = String(req.query.token || '')
if (clientToken !== serverToken) return res.json({ ok: true, creds: null })
}
const u = creds.LBL_USERNAME || ''
const p = creds.LBL_PASSWORD || ''
if (!u && !p) return res.json({ ok: true, creds: null })
res.json({ ok: true, creds: { username: u, password: p } })
} catch { res.json({ ok: true, creds: null }) }
})
app.post('/api/language_behavior_lock/auth/login', (req, res) => {
const username = String((req.body && req.body.username) || '').trim()
const password = String((req.body && req.body.password) || '')
if (!username || !password) return res.status(400).json({ ok: false, error: 'invalid_credentials' })
const row = db.prepare('SELECT username, password FROM lbl_users WHERE username = ?').get(username)
if (!row || String(row.password || '') !== password) return res.status(401).json({ ok: false, error: 'invalid_credentials' })
const token = crypto.randomBytes(24).toString('hex')
const createdAt = now()
const expiresAt = createdAt + SESSION_MAX_AGE
db.prepare('INSERT INTO lbl_sessions (token, username, created_at, expires_at) VALUES (?, ?, ?, ?)').run(token, username, createdAt, expiresAt)
res.cookie(COOKIE_NAME, token, { httpOnly: true, sameSite: 'lax', maxAge: SESSION_MAX_AGE })
res.json({ ok: true, username })
})
app.post('/api/language_behavior_lock/auth/logout', (req, res) => {
const cookies = parseCookie(req.headers.cookie || '')
const token = String(cookies[COOKIE_NAME] || '')
if (token) db.prepare('DELETE FROM lbl_sessions WHERE token = ?').run(token)
res.cookie(COOKIE_NAME, '', { httpOnly: true, sameSite: 'lax', maxAge: 0 })
res.json({ ok: true })
})
app.get('/api/language_behavior_lock/auth/me', (req, res) => {
const username = getSessionUser(req)
if (!username) return res.status(401).json({ ok: false, error: 'unauthorized' })
res.json({ ok: true, username })
})
app.get('/api/language_behavior_lock/data', requireAuth, (req, res) => {
const payload = readDataOf(req.lblUser)
res.json({ ok: true, ...payload })
})
app.put('/api/language_behavior_lock/data', requireAuth, (req, res) => {
writeDataOf(req.lblUser, req.body || {})
res.json({ ok: true })
})
}
module.exports = { bindRoutes }
+86
View File
@@ -0,0 +1,86 @@
const fs = require('fs')
const path = require('path')
const logsDir = path.join(process.cwd(), 'logs')
if (!fs.existsSync(logsDir)) fs.mkdirSync(logsDir, { recursive: true })
const logFile = path.join(logsDir, 'app.log')
if (!fs.existsSync(logFile)) fs.writeFileSync(logFile, '')
const channelFileCache = new Map()
let pendingWrites = new Map()
let flushScheduled = false
const ts = () => new Date().toISOString()
const ensureChannel = channel => {
const name = String(channel || '').trim()
if (!name) return null
if (channelFileCache.has(name)) return channelFileCache.get(name)
const file = path.join(logsDir, `${name}.log`)
if (!fs.existsSync(file)) fs.writeFileSync(file, '')
channelFileCache.set(name, file)
return file
}
const flushPendingWrites = () => {
flushScheduled = false
if (pendingWrites.size === 0) return
const batch = pendingWrites
pendingWrites = new Map()
batch.forEach((content, file) => {
fs.appendFile(file, content, () => {})
})
if (pendingWrites.size > 0) scheduleFlush()
}
const scheduleFlush = () => {
if (flushScheduled) return
flushScheduled = true
setImmediate(flushPendingWrites)
}
const enqueueWrite = (file, line) => {
const prev = pendingWrites.get(file) || ''
pendingWrites.set(file, prev + line)
scheduleFlush()
}
const log = (msg, channel) => {
const line = `${ts()} ${String(msg)}\n`
enqueueWrite(logFile, line)
const chFile = ensureChannel(channel)
if (chFile) enqueueWrite(chFile, line)
}
const logJSON = (label, obj, channel) => {
let s = ''
try { s = JSON.stringify(obj) } catch { s = String(obj) }
log(`${label} ${s}`, channel)
}
const getLogs = (limit = 2000, channel) => {
let file = logFile
const chFile = ensureChannel(channel)
if (chFile) file = chFile
let content = ''
try {
const stat = fs.statSync(file)
const maxReadBytes = Math.min(stat.size, 512 * 1024)
const start = Math.max(0, stat.size - maxReadBytes)
const fd = fs.openSync(file, 'r')
const buf = Buffer.alloc(maxReadBytes)
const bytesRead = fs.readSync(fd, buf, 0, maxReadBytes, start)
fs.closeSync(fd)
content = buf.subarray(0, bytesRead).toString('utf-8')
} catch {}
const lines = content.split(/\r?\n/).filter(Boolean)
const tail = lines.slice(Math.max(0, lines.length - limit))
return tail
}
const clearLogs = channel => {
const chFile = ensureChannel(channel)
if (chFile) fs.truncateSync(chFile, 0)
else fs.truncateSync(logFile, 0)
}
module.exports = { log, logJSON, getLogs, clearLogs }
@@ -0,0 +1,39 @@
{
"COMEX黄金:ratio_max": "2026-02-19-am",
"COMEX白银:白银价格过低": "2026-02-18-pm",
"COMEX白银:白银跌幅预警": "2026-03-04-am",
"COMEX黄金:drop_2": "2026-03-03-pm",
"COMEX黄金:drop_3": "2026-03-04-am",
"COMEX白银:白银涨幅预警": "2026-03-04-pm",
"印度ETF:drop_2": "2026-03-03-pm",
"印度ETF:rise_2": "2026-02-23-pm",
"COMEX黄金:ratio_min": "2026-03-03-pm",
"恒生指数:rise_2": "2026-02-24-am",
"COMEX黄金:rise_3": "2026-03-02-pm",
"创业板指:rise_2": "2026-02-24-am",
"恒生指数:drop_2": "2026-03-04-pm",
"日经ETF(HK):rise_2": "2026-03-02-am",
"黄金白银净值跟踪:黄金_premium_normal": "2026-03-04-pm",
"COMEX白银:白银价格过高": "2026-03-02-pm",
"北证50:drop_2": "2026-03-03-pm",
"北证50:drop_3": "2026-03-03-pm",
"日经ETF(HK):drop_2": "2026-03-03-pm",
"北证50:drop_4": "2026-03-03-pm",
"日经ETF(HK):drop_3": "2026-03-03-pm",
"中国神华-299法:trend_bull": "2026-03-03-am",
"德国ETF:drop_2": "2026-03-03-am",
"德国ETF:drop_3": "2026-03-03-pm",
"科创50:drop_2": "2026-03-03-am",
"科创50:drop_3": "2026-03-03-pm",
"中国银行-299法:above_ma20": "2026-03-04-am",
"科创50:drop_4": "2026-03-03-pm",
"深证成指:drop_2": "2026-03-03-pm",
"创业板指:drop_2": "2026-03-04-am",
"科创50:drop_5": "2026-03-04-am",
"深证成指:drop_3": "2026-03-04-am",
"COMEX黄金:drop_4": "2026-03-04-am",
"COMEX黄金:drop_5": "2026-03-03-pm",
"纳斯达克:drop_2": "2026-03-04-am",
"标普500:drop_2": "2026-03-04-am",
"恒生指数:drop_3": "2026-03-04-pm"
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的��,
"name": "巴西IBOVESPA",
"card_order": 18,
"card_type": "overseas",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(iShares MSCI Brazil ETF)",
"symbol": "usEWZ",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://gu.qq.com/usEWZ",
"source_comment": "数��� (�tencent)",
"source": "yahoo_probe",
"alerts_comment": "预警阈值��,
"alerts": {
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 2
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "America/Sao_Paulo",
"sessions": [
{
"start": "10:00",
"end": "17:30"
}
],
"display_label": "巴西IBOV"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,82 @@
{
"name_comment": "显示的��,
"name": "北�50",
"card_order": 6,
"card_type": "domestic",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": false,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(腾讯财�或其他�)",
"symbol": "bj899050",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://gu.qq.com/bj899050",
"source_comment": "数��� (�tencent)",
"source": "tencent",
"alerts_comment": "预警阈值��,
"alerts": {
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 2
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Kolkata",
"sessions": [
{
"start": "09:15",
"end": "15:30"
}
],
"display_label": "�度孟买"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// 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
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.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 }
}
}
@@ -0,0 +1,90 @@
{
"name_comment": "显示的��,
"name": "巴�特指�,
"card_order_comment": "�片排���,越大越��(当�最大�87,设�88 排最�)",
"card_order": 88,
"card_type_comment": "�片类型:domestic(国内)/ overseas(海外)/ enhanced(增强)/ defensive(防御)",
"card_type": "overseas",
"card_span_comment": "�片横���列数�为默认,2为跨两列",
"card_span": 2,
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�1h 表示1�时)",
"schedule": "1h",
"symbol_comment": "监控的代�(技能内部定义)",
"symbol": "us_buffett_index",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://fred.stlouisfed.org/series/SP500",
"source_comment": "数���",
"source": "fred",
"alerts_comment": "预警阈值�置(基于S&P500/GDP比率,�Wilshire5000原值)",
"alerts": {
"ratio_high_warning_comment": "S&P500/GDP 比率高于此值触�估值�高预�,
"ratio_high_warning": 0.19,
"ratio_critical_comment": "S&P500/GDP 比率高于此值触��端高估严�告�,
"ratio_critical": 0.21,
"change_percent_down_comment": "�日跌幅告警阈值(比率�化百分比)",
"change_percent_down": -5,
"change_percent_up_comment": "�日涨幅告警阈值(比率�化百分比)",
"change_percent_up": 5,
"urgent_abs_change_percent_comment": "严�告警阈值(�对波动百分比,日�动超过此值触�紧急推�)",
"urgent_abs_change_percent": 8,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 240
},
"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",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": false
}
],
"market_status_comment": "市场æ—åŒºä¸Žäº¤æ˜“æ—æ®µå®šä¹‰ï¼Œç”¨äºŽå‰�ç«¯æ˜¾ç¤ºå¼€ä¼‘å¸‚çŠæ€?,
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "00:00",
"end": "23:59"
}
],
"display_label": "美股�观"
}
}
@@ -0,0 +1,270 @@
const { fetchFredCurl } = require('../fred_curl')
const toNum = v => {
const n = Number(v)
return Number.isFinite(n) ? n : NaN
}
const round = (v, d = 2) => {
const n = Number(v)
if (!Number.isFinite(n)) return NaN
const p = 10 ** d
return Math.round(n * p) / p
}
const parseFredCsv = raw => {
const text = typeof raw === 'string' ? raw : ''
const lines = text.split(/\r?\n/).filter(Boolean)
const out = []
for (let i = 1; i < lines.length; i++) {
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()
if (!date || valueRaw === '.' || valueRaw === '') continue
const value = toNum(valueRaw)
if (!Number.isFinite(value)) continue
out.push({ date, value })
}
return out
}
const fetchFredSeries = async (id) => {
const csv = await fetchFredCurl(id)
return parseFredCsv(csv)
}
const tail = (arr, n) => (Array.isArray(arr) ? arr.slice(Math.max(0, arr.length - n)) : [])
const fmt = (v, d = 2) => Number.isFinite(v) ? v.toFixed(d) : '—'
const trendArrow = (delta, threshold = 0.001) => {
const d = toNum(delta)
if (!Number.isFinite(d)) return '→'
if (Math.abs(d) < threshold) return '→'
return d > 0 ? '↑' : '↓'
}
const getValuationZone = ratio => {
if (!Number.isFinite(ratio)) return { zone: '未知', emoji: '⚪', color: '#8c8c8c', level: 'unknown' }
if (ratio < 0.10) return { zone: '显著低估', emoji: '🟢', color: '#15803d', level: 'low' }
if (ratio < 0.13) return { zone: '合理偏低', emoji: '🟢', color: '#16a34a', level: 'moderate_low' }
if (ratio < 0.16) return { zone: '合理估值', emoji: '🟡', color: '#ca8a04', level: 'fair' }
if (ratio < 0.19) return { zone: '偏高', emoji: '🟠', color: '#ea580c', level: 'elevated' }
if (ratio < 0.21) return { zone: '显著高估', emoji: '🔴', color: '#dc2626', level: 'high' }
return { zone: '极端高估', emoji: '🔴', color: '#b91c1c', level: 'extreme' }
}
const calcRatioSeries = (sp500Series, gdpSeries) => {
const out = []
if (!Array.isArray(sp500Series) || !sp500Series.length) return out
if (!Array.isArray(gdpSeries) || !gdpSeries.length) return out
const sp500Map = new Map()
for (const w of sp500Series) {
sp500Map.set(w.date, w.value)
}
const sp500Dates = Array.from(sp500Map.keys()).sort()
for (const g of gdpSeries) {
let bestDate = ''
for (const d of sp500Dates) {
if (d <= g.date) bestDate = d
else break
}
if (!bestDate) continue
const sp500Val = sp500Map.get(bestDate)
if (!Number.isFinite(sp500Val) || sp500Val <= 0) continue
const gdpVal = toNum(g.value)
if (!Number.isFinite(gdpVal) || gdpVal <= 0) continue
out.push({
date: g.date,
ratio: round(sp500Val / gdpVal, 4),
sp500: sp500Val,
gdp: gdpVal,
sp500Date: bestDate
})
}
return out
}
const getRatioPercentile = (ratioSeries, currentRatio) => {
if (!Array.isArray(ratioSeries) || ratioSeries.length < 4) return 50
const ratios = ratioSeries.map(r => r.ratio).filter(Number.isFinite).sort((a, b) => a - b)
let below = 0
for (const r of ratios) {
if (r <= currentRatio) below++
}
return round((below / ratios.length) * 100, 1)
}
const buildAlerts = (currentRatio, displayPrice, zoneLevel, config) => {
const alerts = []
const cfg = config && config.alerts ? config.alerts : {}
const ratioHighWarning = toNum(cfg.ratio_high_warning) || 0.19
const ratioCritical = toNum(cfg.ratio_critical) || 0.21
const displayWarning = round(ratioHighWarning * 100, 2)
const displayCritical = round(ratioCritical * 100, 2)
if (Number.isFinite(currentRatio) && currentRatio >= ratioCritical) {
alerts.push({
id: 'ratio_critical',
title: `${config.name}极端高估预警`,
message: `当前 S&P500/GDP 估值比率 ${fmt(displayPrice, 2)}(原始 ${fmt(currentRatio, 4)}),超过严重阈值 ${displayCritical},市场估值处于极端高位`,
level: 'critical'
})
} else if (Number.isFinite(currentRatio) && currentRatio >= ratioHighWarning) {
alerts.push({
id: 'ratio_warning',
title: `${config.name}估值偏高预警`,
message: `当前 S&P500/GDP 估值比率 ${fmt(displayPrice, 2)}(原始 ${fmt(currentRatio, 4)}),超过警告阈值 ${displayWarning},市场估值偏高`,
level: 'warning'
})
}
if (zoneLevel === 'extreme') {
const hasCritical = alerts.some(a => a.id === 'ratio_critical')
if (!hasCritical) {
alerts.push({
id: 'zone_extreme',
title: `${config.name}进入极端估值区间`,
message: `S&P500/GDP 估值比率 ${fmt(displayPrice, 2)} 进入极端高估区间,建议关注回调风险`,
level: 'critical'
})
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { logJSON } = context
try {
const [sp500Series, gdpSeries] = await Promise.all([
fetchFredSeries('SP500'),
fetchFredSeries('GDP')
])
if (!sp500Series.length) {
logJSON('skill.buffett.fetch.empty', { sp500: sp500Series.length, gdp: gdpSeries.length }, 'market_skills')
return null
}
if (!gdpSeries.length) {
logJSON('skill.buffett.fetch.empty', { sp500: sp500Series.length, gdp: gdpSeries.length }, 'market_skills')
return null
}
const ratioSeries = calcRatioSeries(sp500Series, gdpSeries)
if (ratioSeries.length < 2) {
logJSON('skill.buffett.ratio.empty', { points: ratioSeries.length }, 'market_skills')
return null
}
const latestSp500 = tail(sp500Series, 1)[0]
const prevSp500 = sp500Series.length > 1 ? sp500Series[sp500Series.length - 2] : null
const latestGdp = tail(gdpSeries, 1)[0]
const currentRatio = round(latestSp500.value / latestGdp.value, 4)
const prevRatio = prevSp500
? round(prevSp500.value / latestGdp.value, 4)
: currentRatio
const dailyChange = round(currentRatio - prevRatio, 4)
const dailyChangePct = prevRatio ? round((dailyChange / prevRatio) * 100, 2) : 0
const latestRatioPoint = tail(ratioSeries, 1)[0]
const prevRatioPoint = ratioSeries.length > 1 ? ratioSeries[ratioSeries.length - 2] : null
const qoqChange = prevRatioPoint
? round(latestRatioPoint.ratio - prevRatioPoint.ratio, 4)
: NaN
const qoqArrow = trendArrow(qoqChange, 0.001)
const percentile = getRatioPercentile(ratioSeries, currentRatio)
const zone = getValuationZone(currentRatio)
const historyMin = Math.min(...ratioSeries.map(r => r.ratio))
const historyMax = Math.max(...ratioSeries.map(r => r.ratio))
const capturedAt = new Date().toISOString().replace('T', ' ').slice(0, 19)
const displayPrice = round(currentRatio * 100, 2)
const displayPrev = round(prevRatio * 100, 2)
const displayChange = round(displayPrice - displayPrev, 2)
const html = `
<div style="font-size:16px;font-weight:700;line-height:1.7">
${zone.emoji} ${zone.zone}(历史 ${fmt(percentile, 0)}% 分位)
</div>
<div style="font-size:14px;color:#1f1f1f;line-height:1.7;margin-top:4px">
估值比率:<span style="font-weight:700;color:${zone.color}">${fmt(displayPrice, 2)}</span>
<span style="font-size:13px;color:#595959;margin-left:4px">S&P500/GDP×100</span>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:10px;margin-top:8px">
<div style="border:1px solid rgba(0,0,0,0.08);border-radius:10px;padding:10px 12px;background:rgba(255,255,255,0.78)">
<div style="font-size:13px;font-weight:700;color:#262626">📊 关键数据</div>
<div style="font-size:13px;color:#262626;line-height:1.7;margin-top:4px">
S&P 500:${fmt(latestSp500.value, 2)}(${latestSp500.date})<br/>
GDP:${fmt(latestGdp.value, 2)} 十亿美元(${latestGdp.date})<br/>
日变动:${displayChange >= 0 ? '+' : ''}${fmt(displayChange, 2)}(${dailyChangePct >= 0 ? '+' : ''}${fmt(dailyChangePct, 2)}%)
</div>
</div>
<div style="border:1px solid rgba(0,0,0,0.08);border-radius:10px;padding:10px 12px;background:rgba(255,255,255,0.78)">
<div style="font-size:13px;font-weight:700;color:#262626">📈 区间参考(×100)</div>
<div style="font-size:13px;color:#262626;line-height:1.7;margin-top:4px">
最低:${fmt(historyMin * 100, 2)} | 最高:${fmt(historyMax * 100, 2)}<br/>
当前分位:${fmt(percentile, 0)}% | 较上季 ${qoqArrow}<br/>
低估<10|合理偏低13|合理16|偏高19|高估21
</div>
</div>
</div>
<div style="font-size:13px;color:#595959;line-height:1.7;margin-top:8px">
数据时间:${capturedAt}(FRED)| 基于S&P500与GDP比率×100,作为巴菲特指数代理
</div>
`
const explain = [
`巴菲特指数代理(S&P500/GDP×100):${fmt(displayPrice, 2)},处于${zone.zone}区间。`,
`历史${fmt(percentile, 0)}%分位,区间[${fmt(historyMin * 100, 2)}, ${fmt(historyMax * 100, 2)}]。`,
`S&P 500 指数 ${fmt(latestSp500.value, 2)},GDP ${fmt(latestGdp.value, 2)} 十亿美元。`,
`较上季度${Number.isFinite(qoqChange) ? (qoqChange > 0 ? '上升' : '下降') + fmt(Math.abs(qoqChange), 4) : '无变化'}。`,
`估值区间(×100):<10显著低估|10-13合理偏低|13-16合理|16-19偏高|19-21显著高估|>21极端高估。`,
`注:原巴菲特指数用Wilshire5000,FRED 2024年6月已下架该数据,现用S&P500/GDP×100代理。`
].join(' ')
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source || 'fred',
name: `${zone.emoji} ${zone.zone}`,
price: displayPrice,
prevClose: displayPrev,
change: displayChange,
change_percent: dailyChangePct,
captured_at: capturedAt,
render_type: 'html',
html,
explain,
market_status: config.market_status,
buffett_ratio: currentRatio,
buffett_display: displayPrice,
buffett_zone: zone.zone,
buffett_zone_emoji: zone.emoji,
buffett_percentile: percentile,
sp500_value: latestSp500.value,
sp500_date: latestSp500.date,
gdp_value: latestGdp.value,
gdp_date: latestGdp.date,
history_min: historyMin,
history_max: historyMax,
qoq_change: qoqChange
}]
const alerts = buildAlerts(currentRatio, displayPrice, zone.level, config)
return { data, alerts }
} catch (e) {
logJSON('skill.buffett.run.error', { error: String(e.message || e) }, 'market_skills')
return null
}
}
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的��,
"name": "中韩�导体指�,
"card_order": 19,
"card_type": "overseas",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(科创50,原中韩�导体指数无ETF替代)",
"symbol": "sh000688",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://gu.qq.com/sh000688",
"source_comment": "数���",
"source": "tencent",
"alerts_comment": "预警阈值��,
"alerts": {
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 2
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Seoul",
"sessions": [
{
"start": "09:00",
"end": "15:30"
}
],
"display_label": "中韩�导�
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,86 @@
{
"name_comment": "显示的��,
"name": "创业�指",
"card_order": 5,
"card_type": "domestic",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(腾讯财�或其他�)",
"symbol": "sz399006",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://gu.qq.com/sz399006",
"source_comment": "数��� (�tencent)",
"source": "tencent",
"alerts_comment": "预警阈值��,
"alerts": {
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 2
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Shanghai",
"sessions": [
{
"start": "09:30",
"end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
}
],
"display_label": "创业�
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// 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
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.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 }
}
}
@@ -0,0 +1,92 @@
{
"name_comment": "显示的��,
"name": "COMEX黄金",
"card_order": 15,
"card_type": "defensive",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(腾讯财�或其他�)",
"symbol": "hf_GC",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://quote.eastmoney.com/globalfuture/GC00Y.html",
"silver_symbol_comment": "白银代� (用于计算金银�",
"silver_symbol": "hf_SI",
"source_comment": "数��� (�tencent)",
"source": "tencent",
"alerts_comment": "预警阈值��,
"alerts": {
"price_min_comment": "最低价格预�,
"price_min": 4800,
"price_max_comment": "最高价格预�,
"price_max": 5500,
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 3,
"gold_silver_ratio_min_comment": "金铿¯”最低预è­?,
"gold_silver_ratio_min": 60,
"gold_silver_ratio_max_comment": "金铿¯”最高预è­?,
"gold_silver_ratio_max": 65
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "18:00",
"end": "17:00"
}
],
"display_label": "纽约�
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1,160 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const ALERT_COOLDOWN = 30 * 60 * 1000 // 30 minutes cooldown for same alert
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(',')
}
if (symbol.startsWith('hf_')) {
// Futures format
const name = parts[13] || symbol
const price = parseFloat(parts[0] || '0')
const prevClose = parseFloat(parts[7] || '0')
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
const captured_at = (parts[12] && parts[6]) ? (parts[12] + ' ' + parts[6]) : ''
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
return null
}
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 = (gold, silver, config) => {
const alerts = []
const now = Date.now()
// Helper to add alert with slot-based cooldown (AM/PM logic like global markets)
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. Price Alerts
if (config.alerts.price_min && gold.price < config.alerts.price_min) {
addAlert('price_min', '黄金价格过低', `当前价格 ${gold.price} 低于预警值 ${config.alerts.price_min}`)
}
if (config.alerts.price_max && gold.price > config.alerts.price_max) {
addAlert('price_max', '黄金价格过高', `当前价格 ${gold.price} 高于预警值 ${config.alerts.price_max}`)
}
// 2. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && gold.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(gold.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, '黄金跌幅预警', `当前跌幅 ${gold.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 3. Rise Alerts (Integer steps: 3%, 4%...)
if (config.alerts.change_percent_up && gold.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(gold.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, '黄金涨幅预警', `当前涨幅 ${gold.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
// 4. Gold/Silver Ratio
if (silver && silver.price > 0) {
const ratio = gold.price / silver.price
if (config.alerts.gold_silver_ratio_min && ratio < config.alerts.gold_silver_ratio_min) {
addAlert('ratio_min', '金银比过低', `当前金银比 ${ratio.toFixed(2)} 低于 ${config.alerts.gold_silver_ratio_min} (黄金:${gold.price}, 白银:${silver.price}) (金银比 越高 :黄金偏强、白银偏弱,倾向 做多白银 / 做空黄金 。 金银比 越低 :白银偏强、黄金偏弱,倾向 观望或等待回落再布局)`)
}
if (config.alerts.gold_silver_ratio_max && ratio > config.alerts.gold_silver_ratio_max) {
addAlert('ratio_max', '金银比过高', `当前金银比 ${ratio.toFixed(2)} 高于 ${config.alerts.gold_silver_ratio_max} (黄金:${gold.price}, 白银:${silver.price}) (金银比 越高 :黄金偏强、白银偏弱,倾向 做多白银 / 做空黄金 。 金银比 越低 :白银偏强、黄金偏弱,倾向 观望或等待回落再布局)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const symbols = [config.symbol]
if (config.silver_symbol) symbols.push(config.silver_symbol)
const quotes = await fetchTencent(axios, symbols)
const gold = quotes.find(q => q.symbol === config.symbol)
const silver = quotes.find(q => q.symbol === config.silver_symbol)
if (!gold) {
logJSON('skill.gold.fetch.error', { message: 'Gold data not found' }, 'market_skills')
return null
}
// Prepare data for DB
// We only explicitly return Gold data to avoid duplication with Silver skill
// and to ensure the URL in config applies correctly to the main symbol
const data = quotes
.filter(q => q.symbol === config.symbol)
.map(q => ({
label: config.name,
symbol: q.symbol,
source: config.source,
name: q.name,
price: q.price,
prevClose: q.prevClose,
change: q.change,
change_percent: q.change_percent,
captured_at: q.captured_at,
market_status: config.market_status
}))
// Check alerts
const alerts = checkAlerts(gold, silver, config)
return { data, alerts }
}
}
@@ -0,0 +1,86 @@
{
"name_comment": "显示的��,
"name": "COMEX白银",
"card_order": 15,
"card_type": "defensive",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(腾讯财�或其他�)",
"symbol": "hf_SI",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://quote.eastmoney.com/globalfuture/SI00Y.html",
"source_comment": "数��� (�tencent)",
"source": "tencent",
"alerts_comment": "预警阈值��,
"alerts": {
"price_min_comment": "最低价格预�,
"price_min": 75,
"price_max_comment": "最高价格预�,
"price_max": 95,
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 3
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "18:00",
"end": "17:00"
}
],
"display_label": "纽约�
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1,143 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const ALERT_COOLDOWN = 30 * 60 * 1000 // 30 minutes cooldown for same alert
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(',')
}
if (symbol.startsWith('hf_')) {
// Futures format
const name = parts[13] || symbol
const price = parseFloat(parts[0] || '0')
const prevClose = parseFloat(parts[7] || '0')
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
const captured_at = (parts[12] && parts[6]) ? (parts[12] + ' ' + parts[6]) : ''
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
return null
}
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 = (silver, config) => {
const alerts = []
const now = Date.now()
// Helper to add alert with slot-based cooldown (AM/PM logic like global markets)
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({ title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Price Alerts
if (config.alerts.price_min && silver.price < config.alerts.price_min) {
addAlert('price_min', '白银价格过低', `当前价格 ${silver.price} 低于预警值 ${config.alerts.price_min}`)
}
if (config.alerts.price_max && silver.price > config.alerts.price_max) {
addAlert('price_max', '白银价格过高', `当前价格 ${silver.price} 高于预警值 ${config.alerts.price_max}`)
}
// 2. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && silver.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(silver.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, '白银跌幅预警', `当前跌幅 ${silver.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 3. Rise Alerts (Integer steps: 3%, 4%...)
if (config.alerts.change_percent_up && silver.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(silver.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, '白银涨幅预警', `当前涨幅 ${silver.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const symbols = [config.symbol]
const quotes = await fetchTencent(axios, symbols)
const silver = quotes.find(q => q.symbol === config.symbol)
if (!silver) {
logJSON('skill.silver.fetch.error', { message: 'Silver data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = quotes.map(q => ({
label: q.symbol === config.symbol ? config.name : (q.name || q.symbol),
symbol: q.symbol,
source: config.source,
name: q.name,
price: q.price,
prevClose: q.prevClose,
change: q.change,
change_percent: q.change_percent,
captured_at: q.captured_at,
market_status: config.market_status
}))
// Check alerts
const alerts = checkAlerts(silver, config)
return { data, alerts }
}
}
@@ -0,0 +1,86 @@
{
"name_comment": "显示的��,
"name": "沪深300",
"card_order": 3,
"card_type": "domestic",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(腾讯财�或其他�)",
"symbol": "sh000300",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://gu.qq.com/sh000300",
"source_comment": "数��� (�tencent)",
"source": "tencent",
"alerts_comment": "预警阈值��,
"alerts": {
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 2
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Shanghai",
"sessions": [
{
"start": "09:30",
"end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
}
],
"display_label": "沪深300"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// 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
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.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 }
}
}
@@ -0,0 +1,21 @@
{
"enabled": true,
"display_comment": "是否显示该卡片 (false 则不拉取数据也不封存)",
"display": true,
"schedule": "1h",
"symbol": "sh000300_ma",
"source_symbol": "sh000300",
"name": "沪深300增强版",
"card_order": 7,
"card_type": "enhanced",
"source": "tencent",
"email_reminder_enabled_comment": "是否发送邮件提醒",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"alerts": {
"ma_cross": true,
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes": 120
}
}
+260
View File
@@ -0,0 +1,260 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
// Helper to calculate Simple Moving Average
const calculateMA = (data, period) => {
if (!data || data.length < period) return null
const subset = data.slice(-period)
const sum = subset.reduce((a, b) => a + b, 0)
return sum / period
}
// Helper to fetch K-line data from Tencent
const fetchKLine = async (axios, symbol) => {
// format: sh000300
// url: http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=sh000300,day,,,300,qfq
// fetch 300 days to calculate MA250
const url = `http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=${symbol},day,,,300,qfq`
try {
const res = await axios.get(url, { timeout: 10000 })
if (!res || !res.data || res.data.code !== 0) return null
// Response structure:
// { code: 0, msg: "", data: { [symbol]: { day: [ ["2023-01-01", "3800.00", "3850.00", ...], ... ], qfqday: [...] } } }
// If qfq (adjusted) exists, prefer it. Usually in `qfqday`.
const d = res.data.data[symbol]
if (!d) return null
// Use qfqday if available (adjusted prices), else day
const rawData = d.qfqday || d.day
if (!rawData || !Array.isArray(rawData)) return null
// Extract closing prices (index 2 in Tencent array: [date, open, close, high, low, vol])
// Verify index:
// Usually: [date, open, close, high, low, volume]
// Example: ["2023-01-01", "10.00", "10.50", "10.60", "9.90", "1000"]
// Close is index 2.
return rawData.map(item => ({
date: item[0],
close: parseFloat(item[2])
}))
} catch (e) {
console.error('Fetch KLine Error:', e.message)
return null
}
}
const checkAlerts = (ma120, ma250, prevMA120, prevMA250, dateStr) => {
const alerts = []
// Helper to add alert with slot-based cooldown
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)
}
}
// Logic:
// 1. Cross Down (Bearish): 120 跌破 250
// Condition: Prev(MA120 >= MA250) AND Curr(MA120 < MA250)
// Or simply: MA120 < MA250
// User wants alert when it happens.
// "如果120 跌破 250。发出警告...请减仓/卖出"
// To avoid noise, we check if the state *changed* from yesterday (or previous calculation).
// But K-line data includes "today" (if market open).
// So index N is today, N-1 is yesterday.
const isCrossDown = (prevMA120 >= prevMA250) && (ma120 < ma250)
const isCrossUp = (prevMA120 <= prevMA250) && (ma120 > ma250)
const vals = `(MA120: ${ma120.toFixed(2)}, MA250: ${ma250.toFixed(2)})`
// 1. Cross Down (Bearish): 120 跌破 250
if (isCrossDown) {
addAlert('cross_down', '120日线跌破250日线', `120 跌破 250。今天的两个数值分别是 ${vals},请减仓/卖出`)
}
// 2. Cross Up (Bullish): 120 上穿 250
if (isCrossUp) {
addAlert('cross_up', '120日线上穿250日线', `120 重新上穿 250。今天的两个数值分别是 ${vals},请重新买回`)
}
// 3. Convergence/Divergence Logic (Gap Analysis)
// Calculate percentage difference relative to MA250
const diffPercent = ((ma120 - ma250) / ma250) * 100
const absDiff = Math.abs(diffPercent)
// Thresholds
const CONVERGENCE_THRESHOLD = 1.0 // < 1% means very close (about to cross)
const DIVERGENCE_THRESHOLD = 3.0 // > 3% means trend confirmed
// (A) Approaching (Convergence) Alert - Warning Phase
// Trigger if gap is small (< 1%) AND wasn't already small yesterday (to avoid spamming every day in sticky market)
// OR simply remind if it enters this zone.
// Strategy: Alert if current gap < 1%.
// To avoid spam: relying on slot-based cooldown (once per AM/PM slot).
// Refinement: Only alert if it's "Approaching". i.e., Gap is shrinking?
// User said: "快要接近" -> Gap is small.
if (absDiff < CONVERGENCE_THRESHOLD) {
const direction = diffPercent > 0 ? '向下逼近' : '向上逼近'
addAlert('convergence', '均线高度粘合预警', `MA120 与 MA250 差距仅 ${absDiff.toFixed(2)}% (${direction}),即将面临方向选择,请密切关注! ${vals}`)
}
// (B) Diverging (Trend Confirmation) Alert - Safe Phase
// Trigger when gap widens beyond 3% AFTER being smaller.
// This implies we entered a trend.
// We check if *yesterday* was < 3% and *today* >= 3%.
const prevDiffPercent = ((prevMA120 - prevMA250) / prevMA250) * 100
const prevAbsDiff = Math.abs(prevDiffPercent)
if (absDiff >= DIVERGENCE_THRESHOLD && prevAbsDiff < DIVERGENCE_THRESHOLD) {
if (diffPercent > 0) {
// Bullish divergence
addAlert('divergence_bull', '多头趋势确认', `金叉后开口扩大 (>${DIVERGENCE_THRESHOLD}%),多头趋势稳固,建议坚定持有。 ${vals}`)
} else {
// Bearish divergence
addAlert('divergence_bear', '空头趋势确认', `死叉后开口扩大 (>${DIVERGENCE_THRESHOLD}%),下跌趋势加速,建议空仓观望。 ${vals}`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const symbol = config.symbol // sh000300_ma (for DB/Uniqueness)
const sourceSymbol = config.source_symbol || 'sh000300' // Real symbol for API
const klineData = await fetchKLine(axios, sourceSymbol)
if (!klineData || klineData.length < 250) {
logJSON('skill.csi300_ma.error', { message: 'Insufficient data', length: klineData ? klineData.length : 0 }, 'market_skills')
return null
}
// Calculate MAs
// We need MA for today (latest) and yesterday (latest - 1)
const closes = klineData.map(d => d.close)
// Today
const ma120 = calculateMA(closes, 120)
const ma250 = calculateMA(closes, 250)
// Yesterday (remove last element)
const closesPrev = closes.slice(0, -1)
const prevMA120 = calculateMA(closesPrev, 120)
const prevMA250 = calculateMA(closesPrev, 250)
if (!ma120 || !ma250 || !prevMA120 || !prevMA250) {
logJSON('skill.csi300_ma.error', { message: 'MA calculation failed' }, 'market_skills')
return null
}
// Determine Status Text
let statusText = ''
if (ma120 > ma250) {
statusText = '继续定投、拿着'
} else if (ma120 < ma250) {
statusText = '减仓/卖出' // Or '空仓观望'
} else {
statusText = '均线粘合'
}
// Format for Card
// We want to show:
// Header: 沪深300增强版 · MA120: xxx | MA250: xxx (sh000300)
// Body: MA120 > MA250 :结论为“ 继续定投、拿着 ”
const latestClose = closes[closes.length - 1]
const prevClose = closes[closes.length - 2]
const change = latestClose - prevClose
const changePercent = (change / prevClose) * 100
const latestDate = klineData[klineData.length - 1].date
// Calculate Bias Ratio (乖离率)
// (Price - MA250) / MA250 * 100
const biasRatio = ((latestClose - ma250) / ma250) * 100
// Suggestion Logic
// If alerts exist, use the first one. Else, generic based on trend.
const alerts = checkAlerts(ma120, ma250, prevMA120, prevMA250, latestDate)
let suggestion = ''
if (alerts.length > 0) {
suggestion = alerts[0].message
} else {
// No active alert today, give general advice
if (ma120 > ma250) {
if (biasRatio > 10) suggestion = '乖离率过大 (>10%),注意短期回调风险'
else suggestion = '趋势向上,乖离率合理,可继续持有/定投'
} else {
if (biasRatio < -10) suggestion = '乖离率过大 (<-10%),可能存在超跌反弹'
else suggestion = '趋势向下,建议保持空仓或谨慎操作'
}
}
// Server-Driven UI (HTML Generation)
const isBull = ma120 > ma250
const conclusionColor = isBull ? '#f5222d' : '#52c41a'
const biasColor = biasRatio > 0 ? '#f5222d' : '#52c41a' // Red for positive bias (standard CN market color logic?) Or just neutral?
// Using standard CN colors: Red = Up/Bull, Green = Down/Bear
const htmlPayload = `
<div style="font-size: 14px; margin-top: 8px;">
MA120 ${isBull ? '>' : '<'} MA250 :结论为“ <span style="font-weight:bold; color:${conclusionColor}">${statusText}</span> ”
</div>
<div style="margin: 8px 0; border-top: 1px solid #eee; width: 100%;"></div>
<div style="font-size: 13px; color: #666; line-height: 1.5;">
乖离率:<span style="color:${biasColor}; font-weight:bold">${biasRatio.toFixed(2)}%</span> <br/>
提示:${suggestion}
</div>
`
const data = [{
label: config.name,
symbol: symbol,
displaySymbol: 'sh000300', // Show original symbol in UI
isMaCard: true,
render_type: 'html', // New SDUI flag
html: htmlPayload, // The payload
ma120: ma120.toFixed(2),
ma250: ma250.toFixed(2),
conclusion: statusText,
source: 'tencent',
name: statusText, // Fallback
price: latestClose,
prevClose: prevClose,
change: change,
change_percent: changePercent,
market_status: `${statusText} (${latestDate})`
}]
return { data, alerts }
}
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的��,
"name": "��斯工业指�,
"card_order": 13.5,
"card_type": "overseas",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(腾讯财�或其他�)",
"symbol": "usDJI",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://gu.qq.com/usDJI",
"source_comment": "数��� (�tencent)",
"source": "yahoo_probe",
"alerts_comment": "预警阈值��,
"alerts": {
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 2
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "09:30",
"end": "16:00"
}
],
"display_label": "���
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+121
View File
@@ -0,0 +1,121 @@
const iconv = require('iconv-lite')
const { fetchYahoo } = require('../../marketsClient')
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(',')
const name = parts[1]
const price = parseFloat(parts[3])
const 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 {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
const addAlert = (key, title, message, level = 'warning') => {
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()
const slot = hour >= 0 && hour < 12 ? 'am' : 'pm'
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
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}%)`)
}
}
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
}
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
}]
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* FRED (Federal Reserve Economic Data) CSV fetcher via curl.exe
*
* Why curl.exe instead of axios:
* Node.js axios → FRED's Akamai CDN times out on SP500, DGS2, DGS10 series.
* curl.exe → same URL returns data in ~1s.
*
* Why date range params (cosd/coed):
* Full DGS2/DGS10 datasets (daily from 1962) are huge → Akamai times out.
* Limiting to recent years solves this. All calculations only need ≤3 years.
*
* Root cause: Akamai CDN differentiates by TLS fingerprint AND URL pattern.
*/
const { execFile } = require('child_process')
const FRED_BASE = 'https://fred.stlouisfed.org/graph/fredgraph.csv'
/**
* Fetch FRED CSV data using curl.exe with a date range limit
* @param {string} seriesId - FRED series ID (e.g. 'SP500', 'FEDFUNDS')
* @param {object} [opts]
* @param {number} [opts.timeout=20] - curl --max-time seconds
* @param {number} [opts.lookbackYears=3] - years to look back from today
* @param {string} [opts.coed] - custom observation end date YYYY-MM-DD (default today)
* @param {string} [opts.cosd] - custom observation start date YYYY-MM-DD (default coed - lookbackYears)
* @returns {Promise<string>} raw CSV text
*/
const fetchFredCurl = (seriesId, opts = {}) => {
const timeout = opts.timeout || 20
const lookbackYears = opts.lookbackYears || 3
const now = new Date()
const coed = opts.coed || now.toISOString().slice(0, 10)
const cosd = opts.cosd || (new Date(now.getTime() - lookbackYears * 365 * 24 * 60 * 60 * 1000)).toISOString().slice(0, 10)
const url = `${FRED_BASE}?id=${encodeURIComponent(seriesId)}&cosd=${cosd}&coed=${coed}`
return new Promise((resolve, reject) => {
execFile('curl.exe', ['-s', '--max-time', String(timeout), url], {
encoding: 'utf8',
timeout: (timeout + 10) * 1000,
maxBuffer: 10 * 1024 * 1024,
windowsHide: true
}, (err, stdout, stderr) => {
if (err) {
const msg = stderr ? stderr.trim() : err.message
return reject(new Error(`curl failed for ${seriesId}: ${msg}`))
}
resolve(stdout)
})
})
}
module.exports = { fetchFredCurl }
@@ -0,0 +1,82 @@
{
"name_comment": "显示的��,
"name": "德国DAX30",
"card_order": 10,
"card_type": "overseas",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(iShares MSCI Germany ETF)",
"symbol": "usEWG",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://gu.qq.com/usEWG",
"source_comment": "数��� (�tencent)",
"source": "yahoo_probe",
"alerts_comment": "预警阈值��,
"alerts": {
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 2
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "Europe/Berlin",
"sessions": [
{
"start": "09:00",
"end": "17:30"
}
],
"display_label": "德国DAX"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1,208 @@
const iconv = require('iconv-lite')
const { fetchYahoo } = require('../../marketsClient')
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]
const parts = body.split('~')
if (parts.length < 3 && body.includes(',')) return null
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 fetchTencentQuote = async (axios, symbol) => {
const url = `http://qt.gtimg.cn/q=${symbol}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return null
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
for (const line of lines) {
const r = parseTencentLine(line)
if (r) return { ...r, source: 'tencent' }
}
return null
} catch (e) {
return null
}
}
const fetchEastmoneyQuote = async (axios, url, symbol, nameFallback) => {
const secidMap = {
zsn225: '100.N225',
zsgdaxi: '100.GDAXI',
usnfty: '105.NFTY',
usflsa: '107.FLSA',
zsbvsp: '100.BVSP',
zh931790: '2.931790',
zssti: '100.STI',
zsks11: '100.KS11',
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 price = Number(last[2])
const prevClose = prev ? Number(prev[2]) : NaN
if (!Number.isFinite(price) || price <= 0) return null
const hasPrev = Number.isFinite(prevClose) && prevClose > 0
const change = hasPrev ? price - prevClose : 0
const change_percent = 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 : price,
change,
change_percent,
captured_at,
source: 'eastmoney'
}
} catch (e) {
return null
}
}
const checkAlerts = (data, config) => {
const alerts = []
const addAlert = (key, title, message, level = 'warning') => {
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'
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
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}%)`)
}
}
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
const source = String(config.source || '').toLowerCase()
let item = null
if (source === 'yahoo_probe') {
const quotes = await fetchYahoo([config.symbol])
item = quotes.find(q => q.symbol === config.symbol)
} else if (source === 'tencent') {
item = await fetchTencentQuote(axios, config.symbol)
} else {
item = await fetchEastmoneyQuote(axios, config.url, config.symbol, config.name)
}
if (!item && source !== 'yahoo_probe' && String(config.card_type || '').toLowerCase() === 'overseas') {
const quotes = await fetchYahoo([config.symbol])
item = quotes.find(q => q.symbol === config.symbol)
}
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
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
}]
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,43 @@
{
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule": "5m",
"name": "黄金白é“净值跟è¸?,
"card_order": 16,
"card_type": "defensive",
"gold_fund_code": "518880",
"silver_fund_code": "161226",
"gold_symbol": "sh518880",
"silver_symbol": "sz161226",
"source": "tencent",
"email": {
"enabled": true,
"from": "yangdafe@gmail.com",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled": true,
"calendar_reminder_enabled": true,
"push_targets": [
{
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled": true,
\"key\": \"\"
},
{
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled": true
}
]
}
@@ -0,0 +1,244 @@
const iconv = require('iconv-lite')
const toBJDateTime = () => {
const d = new Date()
const utc = d.getTime() + d.getTimezoneOffset() * 60000
const bj = new Date(utc + 8 * 3600000)
const y = bj.getFullYear()
const m = String(bj.getMonth() + 1).padStart(2, '0')
const day = String(bj.getDate()).padStart(2, '0')
const hh = String(bj.getHours()).padStart(2, '0')
const mm = String(bj.getMinutes()).padStart(2, '0')
const ss = String(bj.getSeconds()).padStart(2, '0')
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
}
const fetchFundGz = async (axios, code) => {
const url = `http://fundgz.1234567.com.cn/js/${code}.js?rt=${Date.now()}`
try {
const res = await axios.get(url, { timeout: 10000, validateStatus: () => true, responseType: 'text' })
if (!res || res.status >= 400 || !res.data) return null
const m = String(res.data).match(/jsonpgz\((\{.*\})\)/)
if (!m) return null
const obj = JSON.parse(m[1])
const navStr = obj.gsz || obj.dwjz
const time = obj.gstime || obj.gztime || ''
const nav = parseFloat(navStr)
if (!Number.isFinite(nav)) return null
return { code, nav, time }
} catch { return null }
}
const fetchFundLastNav = async (axios, code) => {
const url = `https://api.fund.eastmoney.com/f10/lsjz?fundCode=${encodeURIComponent(code)}&pageIndex=1&pageSize=1`
try {
const res = await axios.get(url, {
timeout: 10000,
validateStatus: () => true,
headers: {
'User-Agent': 'Mozilla/5.0',
'Referer': 'https://fundf10.eastmoney.com/'
}
})
if (!res || res.status >= 400 || !res.data) return null
const payload = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
const list = payload && payload.Data && Array.isArray(payload.Data.LSJZList) ? payload.Data.LSJZList : []
if (list.length === 0) return null
const row = list[0] || {}
const nav = parseFloat(row.DWJZ)
if (!Number.isFinite(nav)) return null
const time = String(row.FSRQ || '')
return { code, nav, time }
} catch { return null }
}
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(',')
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (!Number.isFinite(price) || !Number.isFinite(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 toEastmoneySecid = (symbol) => {
const s = String(symbol || '').toLowerCase()
if (s.startsWith('sh') && s.length > 2) return `1.${s.slice(2)}`
if (s.startsWith('sz') && s.length > 2) return `0.${s.slice(2)}`
return null
}
const scaleEastmoneyPrice = v => {
const n = Number(v)
if (!Number.isFinite(n) || n <= 0) return null
return n / 1000
}
const fetchEastmoneyQuote = async (axios, symbol) => {
const secid = toEastmoneySecid(symbol)
if (!secid) return null
const url = `https://push2.eastmoney.com/api/qt/stock/get?secid=${encodeURIComponent(secid)}&fields=f43,f57,f58,f60`
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 || !res.data || !res.data.data) return null
const d = res.data.data
const last = scaleEastmoneyPrice(d.f43)
const prev = scaleEastmoneyPrice(d.f60)
const price = Number.isFinite(last) ? last : prev
if (!Number.isFinite(price)) return null
return {
symbol,
name: String(d.f58 || symbol),
price,
prevClose: Number.isFinite(prev) ? prev : price,
change: Number.isFinite(prev) ? (price - prev) : 0,
change_percent: Number.isFinite(prev) && prev > 0 ? ((price - prev) / prev) * 100 : 0,
captured_at: toBJDateTime()
}
} catch { return null }
}
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)
}
return out
} catch { return [] }
}
const pct = (price, nav) => {
if (!Number.isFinite(price) || !Number.isFinite(nav) || nav === 0) return null
return ((price - nav) / nav) * 100
}
const tip = v => {
if (!Number.isFinite(v)) return '—'
if (Math.abs(v) <= 10) return '正常、安全、可交易区间'
if (v > 50) return '极端泡沫、必须警告'
if (v < -10) return '折价机会,可抄底信号'
return '—'
}
const buildPremiumAlerts = (label, premium) => {
if (!Number.isFinite(premium)) return []
const fmt = premium.toFixed(2)
if (premium > 50) {
return [{
id: `${label}_premium_bubble`,
title: `${label}溢价率预警`,
message: `当前溢价率 ${fmt}% (> 50%),极端泡沫、必须警告`,
level: 'critical'
}]
}
if (premium < -10) {
return [{
id: `${label}_premium_discount`,
title: `${label}溢价率预警`,
message: `当前溢价率 ${fmt}% (< -10%),折价机会,可抄底信号`,
level: 'warning'
}]
}
return []
}
module.exports = {
run: async (context, config) => {
const { axios } = context
const goldFund = String(config.gold_fund_code || '518880')
const silverFund = String(config.silver_fund_code || '161226')
const goldSymbol = String(config.gold_symbol || 'sh518880')
const silverSymbol = String(config.silver_symbol || 'sz161226')
const [goldGzRaw, silverGzRaw] = await Promise.all([
fetchFundGz(axios, goldFund),
fetchFundGz(axios, silverFund)
])
const [goldNavFallback, silverNavFallback] = await Promise.all([
goldGzRaw ? Promise.resolve(null) : fetchFundLastNav(axios, goldFund),
silverGzRaw ? Promise.resolve(null) : fetchFundLastNav(axios, silverFund)
])
const goldGz = goldGzRaw || goldNavFallback
const silverGz = silverGzRaw || silverNavFallback
const quotes = await fetchTencent(axios, [goldSymbol, silverSymbol])
let goldQ = quotes.find(q => q.symbol === goldSymbol)
let silverQ = quotes.find(q => q.symbol === silverSymbol)
const [goldQuoteFallback, silverQuoteFallback] = await Promise.all([
(goldQ && Number.isFinite(goldQ.price)) ? Promise.resolve(null) : fetchEastmoneyQuote(axios, goldSymbol),
(silverQ && Number.isFinite(silverQ.price)) ? Promise.resolve(null) : fetchEastmoneyQuote(axios, silverSymbol)
])
if ((!goldQ || !Number.isFinite(goldQ.price)) && goldQuoteFallback) goldQ = goldQuoteFallback
if ((!silverQ || !Number.isFinite(silverQ.price)) && silverQuoteFallback) silverQ = silverQuoteFallback
const goldNav = goldGz ? goldGz.nav : null
const silverNav = silverGz ? silverGz.nav : null
const goldPrice = goldQ ? goldQ.price : null
const silverPrice = silverQ ? silverQ.price : null
const goldPremium = pct(goldPrice, goldNav)
const silverPremium = pct(silverPrice, silverNav)
const captured_at = toBJDateTime()
const data = [{
label: config.name || '黄金白银净值跟踪',
symbol: 'skill_gold_silver_nav',
source: 'fundgz+tencent+eastmoney_fallback',
name: 'ETF/LOF',
displaySymbol: `${goldFund}/${silverFund}`,
price: Number.isFinite(goldPrice) ? goldPrice : null,
prevClose: null,
change: null,
change_percent: null,
render_type: 'gold_silver_nav',
gold_nav: goldNav,
gold_price: goldPrice,
gold_premium: goldPremium,
gold_tip: tip(goldPremium),
silver_nav: silverNav,
silver_price: silverPrice,
silver_premium: silverPremium,
silver_tip: tip(silverPremium),
url: `https://fund.eastmoney.com/${goldFund}.html`,
captured_at,
market_status: config.market_status || null
}]
const alerts = [
...buildPremiumAlerts('黄金', goldPremium),
...buildPremiumAlerts('白银', silverPremium)
]
return { data, alerts }
}
}
+86
View File
@@ -0,0 +1,86 @@
{
"name_comment": "显示的��,
"name": "�生指数",
"card_order": 8,
"card_type": "overseas",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(腾讯财�或其他�)",
"symbol": "hkHSI",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://gu.qq.com/hkHSI",
"source_comment": "数��� (�tencent)",
"source": "tencent",
"alerts_comment": "预警阈值��,
"alerts": {
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 2
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Hong_Kong",
"sessions": [
{
"start": "09:30",
"end": "12:00"
},
{
"start": "13:00",
"end": "16:00"
}
],
"display_label": "�生"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// 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
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.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 }
}
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的��,
"name": "�度NFTY(�ETF",
"card_order": 11,
"card_type": "overseas",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(India Nifty 50 ETF)",
"symbol": "usNFTY",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://gu.qq.com/usNFTY",
"source_comment": "数��� (�tencent)",
"source": "yahoo_probe",
"alerts_comment": "预警阈值��,
"alerts": {
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 2
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Kolkata",
"sessions": [
{
"start": "09:15",
"end": "15:30"
}
],
"display_label": "�度"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,82 @@
{
"name_comment": "显示的��,
"name": "韩国综�指数",
"card_order": 86,
"card_type": "overseas",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(iShares MSCI South Korea ETF)",
"symbol": "usEWY",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://gu.qq.com/usEWY",
"source_comment": "数��� (�tencent)",
"source": "yahoo_probe",
"alerts_comment": "预警阈值��,
"alerts": {
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 2
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Seoul",
"sessions": [
{
"start": "09:00",
"end": "15:30"
}
],
"display_label": "韩国"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,84 @@
{
"name_comment": "显示的��,
"name": "�动性状�机",
"card_order": 6,
"card_type": "overseas",
"card_span_comment": "�片横���列数�为默认,2为跨两列",
"card_span": 2,
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "6h",
"symbol_comment": "监控的代�(腾讯财�或其他�)",
"symbol": "us_liquidity_state",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://fred.stlouisfed.org/series/FEDFUNDS",
"source_comment": "数��� (�tencent)",
"source": "fred",
"alerts_comment": "预警阈值��,
"alerts": {
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -20,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 40,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 240,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 20
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "00:00",
"end": "23:59"
}
],
"display_label": "美国�观"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1,442 @@
const { fetchFredCurl } = require('../fred_curl')
const FRED_SERIES = {
ffr: 'FEDFUNDS',
us2y: 'DGS2',
us10y: 'DGS10',
cpi_index: 'CPIAUCSL',
unemployment: 'UNRATE'
}
const toNum = v => {
const n = Number(v)
return Number.isFinite(n) ? n : NaN
}
const round = (v, d = 2) => {
const n = Number(v)
if (!Number.isFinite(n)) return NaN
const p = 10 ** d
return Math.round(n * p) / p
}
const clamp = (v, min, max) => Math.min(max, Math.max(min, v))
const parseFredCsv = raw => {
const text = typeof raw === 'string' ? raw : ''
const lines = text.split(/\r?\n/).filter(Boolean)
const out = []
for (let i = 1; i < lines.length; i++) {
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()
if (!date || valueRaw === '.' || valueRaw === '') continue
const value = toNum(valueRaw)
if (!Number.isFinite(value)) continue
out.push({ date, value })
}
return out
}
const fetchFredSeries = async (id) => {
const csv = await fetchFredCurl(id)
return parseFredCsv(csv)
}
const tail = (arr, n) => (Array.isArray(arr) ? arr.slice(Math.max(0, arr.length - n)) : [])
const avg = arr => {
if (!Array.isArray(arr) || arr.length === 0) return NaN
const nums = arr.map(x => toNum(x)).filter(Number.isFinite)
if (nums.length === 0) return NaN
return nums.reduce((a, b) => a + b, 0) / nums.length
}
const sma = (series, n) => {
if (!Array.isArray(series) || series.length === 0) return NaN
const values = tail(series.map(x => x.value), Math.max(1, n))
return avg(values)
}
const valueNBack = (series, n) => {
if (!Array.isArray(series) || series.length === 0) return NaN
const idx = Math.max(0, series.length - 1 - Math.max(0, n))
return toNum(series[idx] && series[idx].value)
}
const cpiYoYSeries = cpiSeries => {
const out = []
if (!Array.isArray(cpiSeries) || cpiSeries.length < 13) return out
for (let i = 12; i < cpiSeries.length; i++) {
const curr = toNum(cpiSeries[i].value)
const prev = toNum(cpiSeries[i - 12].value)
if (!Number.isFinite(curr) || !Number.isFinite(prev) || prev <= 0) continue
out.push({
date: cpiSeries[i].date,
value: ((curr / prev) - 1) * 100
})
}
return out
}
const scoreFromDelta = (delta, easingThreshold, tighteningThreshold) => {
const d = toNum(delta)
if (!Number.isFinite(d)) return 50
if (d <= easingThreshold) return 100
if (d >= tighteningThreshold) return 0
const mid = (easingThreshold + tighteningThreshold) / 2
const half = (tighteningThreshold - easingThreshold) / 2 || 1
const scaled = 50 - ((d - mid) / half) * 50
return clamp(scaled, 0, 100)
}
const scoreRateDirection = ffrSeries => {
const latest = valueNBack(ffrSeries, 0)
const prev3 = valueNBack(ffrSeries, 3)
const delta = latest - prev3
return { delta, score: scoreFromDelta(delta, -0.1, 0.1) }
}
const scoreYieldExpectation = us2ySeries => {
const latest = valueNBack(us2ySeries, 0)
const prev30 = valueNBack(us2ySeries, 30)
const delta = latest - prev30
return { delta, score: scoreFromDelta(delta, -0.3, 0.3) }
}
const scoreCpiTrend = cpiYoY => {
const recent = tail(cpiYoY, 4)
if (recent.length < 4) return { delta: NaN, score: 50 }
const m3 = toNum(recent[0].value)
const m2 = toNum(recent[1].value)
const m1 = toNum(recent[2].value)
const m0 = toNum(recent[3].value)
const delta = m0 - m3
const isThreeMonthDown = m0 < m1 && m1 < m2
const isThreeMonthUp = m0 > m1 && m1 > m2
if (isThreeMonthDown) return { delta, score: 100 }
if (isThreeMonthUp) return { delta, score: 0 }
return { delta, score: scoreFromDelta(delta, -0.3, 0.3) }
}
const scoreUnemployment = unrateSeries => {
const latest = valueNBack(unrateSeries, 0)
const prev3 = valueNBack(unrateSeries, 3)
const delta = latest - prev3
return { delta, score: scoreFromDelta(delta, 0.3, -0.2) }
}
const buildState = score => {
if (score >= 85) return { state: '宽松', emoji: '🟢', impact: '流动性充分改善,利好纳指与标普,纳指弹性更高' }
if (score >= 70) return { state: '宽松初期', emoji: '🟢+', impact: '宽松预期先行,通常是纳指表现最优的阶段' }
if (score >= 45) return { state: '中性', emoji: '🟡', impact: '标普与纳指以震荡为主,等待增量信号' }
if (score >= 30) return { state: '紧缩中', emoji: '🟠', impact: '估值受压但未到极端,纳指波动通常大于标普' }
return { state: '紧缩', emoji: '🔴', impact: '高利率压制风险资产,纳指承压最明显' }
}
const fmt = (v, d = 2, suffix = '') => Number.isFinite(v) ? `${round(v, d).toFixed(d)}${suffix}` : '—'
const trendArrow = (delta, weak, strong) => {
const d = toNum(delta)
if (!Number.isFinite(d)) return '→'
const ad = Math.abs(d)
if (ad < weak) return '→'
if (d > 0) return ad >= strong ? '↑↑↑' : '↑'
return ad >= strong ? '↓↓↓' : '↓'
}
const dominantFactorFromDeltas = ({ rateDelta, y2Delta, cpiDelta, unrateDelta }) => {
const candidates = [
{ key: 'FFR利率方向', short: 'FFR利率', delta: rateDelta, unitThreshold: 0.1, easingWhenNegative: true },
{ key: '2Y利率预期', short: '2Y预期', delta: y2Delta, unitThreshold: 0.3, easingWhenNegative: true },
{ key: 'CPI通胀趋势', short: 'CPI趋势', delta: cpiDelta, unitThreshold: 0.3, easingWhenNegative: true },
{ key: '失业率变化', short: '失业率', delta: unrateDelta, unitThreshold: 0.3, easingWhenNegative: false }
].map(x => ({
...x,
strength: Number.isFinite(x.delta) ? Math.abs(x.delta) / x.unitThreshold : -1
}))
candidates.sort((a, b) => b.strength - a.strength)
const top = candidates[0]
const delta = toNum(top && top.delta)
const easing = Number.isFinite(delta)
? (top.easingWhenNegative ? delta < 0 : delta > 0)
: false
return {
dominant_factor: top ? top.short : '综合因素',
dominant_factor_full: top ? top.key : '综合因素',
dominant_bias: easing ? '宽松预期' : '紧缩预期'
}
}
const buildMarketPhase = ({ score, ffrLevel, rateDelta, y2Delta }) => {
if (rateDelta > 0.1) return '紧缩期(加息)'
if (score >= 85 && rateDelta < 0 && y2Delta < -0.2) return '宽松释放期(大牛)'
if ((y2Delta <= -0.15 && rateDelta <= 0.05) || (score >= 65 && score < 85)) return '降息预期交易期(Pre-Easing)'
if (ffrLevel >= 4 || score < 65) return '高位压制期'
return '高位压制期'
}
const buildIndexView = ({ score, y2Delta, cpiDelta, unrateDelta }) => {
const nasdaqBias = (score >= 70 || y2Delta <= -0.15) ? '当前偏正面' : (score >= 45 ? '中性偏震荡' : '当前偏负面')
const sp500Bias = (score >= 85 && cpiDelta < 0 && unrateDelta >= 0)
? '偏正面'
: (score < 45 ? '偏谨慎' : '仍有分歧')
return {
nasdaq: `纳指驱动:流动性 + 利率预期(${nasdaqBias})`,
sp500: `标普驱动:盈利 + 宏观(${sp500Bias})`,
nasdaq_bias: nasdaqBias,
sp500_bias: sp500Bias
}
}
const buildSignalStrength = score => {
if (score >= 85) return '★★★★★'
if (score >= 70) return '★★★☆☆'
if (score >= 45) return '★★☆☆☆'
if (score >= 30) return '★☆☆☆☆'
return '☆☆☆☆☆'
}
const buildCoreConclusion = marketPhase => {
if (marketPhase === '降息预期交易期(Pre-Easing)') {
return '市场进入“降息预期交易期”,流动性方向转松,当前更利好纳指(科技股),而非全面牛市。'
}
if (marketPhase === '宽松释放期(大牛)') {
return '市场进入“宽松释放期”,流动性进入释放阶段,纳指与标普更容易共振上行。'
}
if (marketPhase === '紧缩期(加息)') {
return '市场处于“紧缩期”,流动性方向偏紧,纳指与标普整体承压。'
}
return '市场处于“高位压制期”,风险偏好恢复有限,结构性行情强于全面行情。'
}
const buildTrendNarrative = ({ rateDelta, y2Delta, cpiDelta, unrateDelta }) => ({
ffr: rateDelta < 0 ? '政策开始转向' : (rateDelta > 0 ? '政策偏紧延续' : '政策维持稳定'),
us2y: y2Delta < 0 ? '市场正在押注降息' : (y2Delta > 0 ? '市场预期反向' : '市场预期观望'),
cpi: cpiDelta < 0 ? '支持降息' : (cpiDelta > 0 ? '约束降息' : '通胀信号中性'),
unemployment: unrateDelta > 0 ? '经济走弱' : (unrateDelta < 0 ? '经济尚稳' : '就业信号中性')
})
const buildRiskHints = ({ rateDelta, y2Delta, cpiDelta, marketPhase }) => {
const divergence = (rateDelta < 0 && y2Delta > 0.1) || (rateDelta > 0 && y2Delta < -0.1)
return [
divergence ? '2Y与FFR出现分歧(预期 vs 现实)' : '2Y与FFR短期一致,需警惕后续再分歧',
cpiDelta > 0 ? 'CPI出现反弹迹象,降息预期存在回摆风险' : '若CPI反弹 → 降息预期失效',
marketPhase === '宽松释放期(大牛)' ? '宽松释放已启动,仍需跟踪盈利兑现节奏' : '当前仍未进入真实宽松(资金未释放)'
]
}
const buildDominantDisplay = ({ dominantFactorFull, arrows, trendNarrative }) => {
if (dominantFactorFull === 'FFR利率方向') return { arrow: arrows.ffr, period: '3个月', text: trendNarrative.ffr }
if (dominantFactorFull === 'CPI通胀趋势') return { arrow: arrows.cpi, period: '3个月', text: trendNarrative.cpi }
if (dominantFactorFull === '失业率变化') return { arrow: arrows.unemployment, period: '3个月', text: trendNarrative.unemployment }
return { arrow: arrows.us2y, period: '30日', text: trendNarrative.us2y }
}
module.exports = {
run: async (context, config) => {
const { logJSON } = context
try {
const [ffr, us2y, us10y, cpiIndex, unemployment] = await Promise.all([
fetchFredSeries(FRED_SERIES.ffr),
fetchFredSeries(FRED_SERIES.us2y),
fetchFredSeries(FRED_SERIES.us10y),
fetchFredSeries(FRED_SERIES.cpi_index),
fetchFredSeries(FRED_SERIES.unemployment)
])
if (!ffr.length || !us2y.length || !us10y.length || !cpiIndex.length || !unemployment.length) {
logJSON('skill.liquidity.fetch.empty', {
ffr: ffr.length,
us2y: us2y.length,
us10y: us10y.length,
cpiIndex: cpiIndex.length,
unemployment: unemployment.length
}, 'market_skills')
return null
}
const cpiYoY = cpiYoYSeries(cpiIndex)
const ratePart = scoreRateDirection(ffr)
const yieldPart = scoreYieldExpectation(us2y)
const cpiPart = scoreCpiTrend(cpiYoY)
const unratePart = scoreUnemployment(unemployment)
const totalScore = clamp(
ratePart.score * 0.4 +
yieldPart.score * 0.3 +
cpiPart.score * 0.2 +
unratePart.score * 0.1,
0,
100
)
const totalScoreRounded = round(totalScore, 2)
const state = buildState(totalScoreRounded)
const latestFfr = tail(ffr, 1)[0]
const latestUs2y = tail(us2y, 1)[0]
const latestUs10y = tail(us10y, 1)[0]
const latestCpiYoY = tail(cpiYoY, 1)[0]
const latestUnrate = tail(unemployment, 1)[0]
const spread = toNum(latestUs10y.value) - toNum(latestUs2y.value)
const capturedAt = new Date().toISOString().replace('T', ' ').slice(0, 19)
const ffrTrend = latestFfr.value - valueNBack(ffr, 3)
const us2yTrend = latestUs2y.value - valueNBack(us2y, 30)
const cpiTrend = (latestCpiYoY ? latestCpiYoY.value : NaN) - valueNBack(cpiYoY, 3)
const unrateTrend = latestUnrate.value - valueNBack(unemployment, 3)
const ffrArrow = trendArrow(ffrTrend, 0.03, 0.1)
const us2yArrow = trendArrow(us2yTrend, 0.05, 0.3)
const cpiArrow = trendArrow(cpiTrend, 0.05, 0.3)
const unrateArrow = trendArrow(unrateTrend, 0.05, 0.2)
const dominant = dominantFactorFromDeltas({
rateDelta: ratePart.delta,
y2Delta: yieldPart.delta,
cpiDelta: cpiPart.delta,
unrateDelta: unratePart.delta
})
const marketPhase = buildMarketPhase({
score: totalScoreRounded,
ffrLevel: latestFfr.value,
rateDelta: ratePart.delta,
y2Delta: yieldPart.delta
})
const indexView = buildIndexView({
score: totalScoreRounded,
y2Delta: yieldPart.delta,
cpiDelta: cpiPart.delta,
unrateDelta: unratePart.delta
})
const signalStrength = buildSignalStrength(totalScoreRounded)
const coreConclusion = buildCoreConclusion(marketPhase)
const trendNarrative = buildTrendNarrative({
rateDelta: ratePart.delta,
y2Delta: yieldPart.delta,
cpiDelta: cpiPart.delta,
unrateDelta: unratePart.delta
})
const riskHints = buildRiskHints({
rateDelta: ratePart.delta,
y2Delta: yieldPart.delta,
cpiDelta: cpiPart.delta,
marketPhase
})
const structureSummary = '科技优先上涨,而非全面上涨'
const dominantDisplay = buildDominantDisplay({
dominantFactorFull: dominant.dominant_factor_full,
arrows: {
ffr: ffrArrow,
us2y: us2yArrow,
cpi: cpiArrow,
unemployment: unrateArrow
},
trendNarrative
})
const html = `
<div style="font-size:16px;font-weight:700;line-height:1.7">
${state.emoji} ${state.state}(${marketPhase.includes('Pre-Easing') ? 'Pre-Easing' : state.state})<br/>
流动性评分:${fmt(totalScoreRounded, 0)} / 100 | 信号强度:${signalStrength}
</div>
<div style="font-size:13px;color:#1f1f1f;line-height:1.7;margin-top:8px">
👉 核心结论:${coreConclusion}
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:12px;margin-top:10px">
<div style="border:1px solid rgba(0,0,0,0.1);border-radius:10px;padding:10px 12px;background:rgba(255,255,255,0.78)">
<div style="font-size:13px;font-weight:700;color:#262626">🎯 主导因子</div>
<div style="font-size:13px;color:#262626;line-height:1.7;margin-top:4px">
${dominant.dominant_factor_full} ${dominantDisplay.arrow}(${dominantDisplay.period})<br/>
→ ${dominantDisplay.text}
</div>
<div style="margin:8px 0;border-top:1px dashed rgba(0,0,0,0.15)"></div>
<div style="font-size:13px;font-weight:700;color:#262626">🧭 市场结构判断</div>
<div style="font-size:13px;color:#262626;line-height:1.7;margin-top:4px">
纳指:✔ 长久期资产受益 | ✔ 估值扩张预期增强<br/>
标普:△ 盈利支撑仍在 | △ 宏观存在分歧<br/>
👉 当前结构:${structureSummary}
</div>
</div>
<div style="border:1px solid rgba(0,0,0,0.1);border-radius:10px;padding:10px 12px;background:rgba(255,255,255,0.78)">
<div style="font-size:13px;font-weight:700;color:#262626">⚠️ 风险提示</div>
<div style="font-size:13px;color:#262626;line-height:1.7;margin-top:4px">
- ${riskHints[0]}<br/>
- ${riskHints[1]}<br/>
- ${riskHints[2]}
</div>
<div style="margin:8px 0;border-top:1px dashed rgba(0,0,0,0.15)"></div>
<div style="font-size:13px;font-weight:700;color:#262626">📊 关键趋势</div>
<div style="font-size:13px;color:#262626;line-height:1.7;margin-top:4px">
FFR:${fmt(latestFfr.value, 2, '%')} ${ffrArrow}(${trendNarrative.ffr})<br/>
2Y:${fmt(latestUs2y.value, 2, '%')} ${us2yArrow}(${trendNarrative.us2y})<br/>
CPI:${fmt(latestCpiYoY && latestCpiYoY.value, 2, '%')} ${cpiArrow}(${trendNarrative.cpi})<br/>
失业率:${fmt(latestUnrate.value, 2, '%')} ${unrateArrow}(${trendNarrative.unemployment})
</div>
</div>
</div>
<div style="font-size:13px;color:#595959;line-height:1.7;margin-top:8px">
分项评分:利率方向 ${fmt(ratePart.score, 0)} | 2Y预期 ${fmt(yieldPart.score, 0)} | CPI趋势 ${fmt(cpiPart.score, 0)} | 失业率 ${fmt(unratePart.score, 0)}<br/>
数据时间:${capturedAt}(FRED)
</div>
`
const explain = [
`当前阶段:${marketPhase}。`,
`当前市场由【${dominant.dominant_factor_full}】主导,偏向${dominant.dominant_bias}。`,
`${indexView.nasdaq} ${indexView.sp500}。`,
`核心结论:${coreConclusion}`,
`当前结构:${structureSummary}。`,
`纳指影响:${state.state === '宽松' || state.state === '宽松初期' ? '长久期资产受益,估值扩张概率更高。' : state.state === '中性' ? '以交易驱动为主,业绩与预期差决定方向。' : '高利率压制估值,回撤风险先体现在纳指。'}`,
`标普影响:${state.state === '宽松' || state.state === '宽松初期' ? '风险偏好抬升,周期与成长轮动走强。' : state.state === '中性' ? '盈利韧性与宏观数据反复博弈。' : '防御板块相对占优,指数上行空间受限。'}`,
`风险提示:${riskHints.join(';')}。`,
`分项得分:利率方向 ${fmt(ratePart.score, 1)}|2Y预期 ${fmt(yieldPart.score, 1)}|CPI趋势 ${fmt(cpiPart.score, 1)}|失业率 ${fmt(unratePart.score, 1)}`
].join(' ')
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source || 'fred',
name: `${state.emoji} ${state.state}`,
price: totalScoreRounded,
prevClose: 50,
change: round(totalScoreRounded - 50, 2),
change_percent: round(((totalScoreRounded - 50) / 50) * 100, 2),
captured_at: capturedAt,
render_type: 'html',
html,
explain,
market_status: config.market_status,
liquidity_state: state.state,
liquidity_score: totalScoreRounded,
market_phase: marketPhase,
signal_strength: signalStrength,
core_conclusion: coreConclusion,
market_structure_summary: structureSummary,
dominant_factor: dominant.dominant_factor,
dominant_factor_full: dominant.dominant_factor_full,
dominant_bias: dominant.dominant_bias,
index_view: {
nasdaq: indexView.nasdaq,
sp500: indexView.sp500,
nasdaq_bias: indexView.nasdaq_bias,
sp500_bias: indexView.sp500_bias
},
risk_hints: riskHints,
date: capturedAt.slice(0, 10),
ffr: round(latestFfr.value, 4),
us2y: round(latestUs2y.value, 4),
us10y: round(latestUs10y.value, 4),
cpi: round(latestCpiYoY && latestCpiYoY.value, 4),
unemployment: round(latestUnrate.value, 4),
trend_arrow: {
ffr: ffrArrow,
us2y: us2yArrow,
cpi: cpiArrow,
unemployment: unrateArrow
},
series_date: {
ffr: latestFfr.date,
us2y: latestUs2y.date,
us10y: latestUs10y.date,
cpi: latestCpiYoY ? latestCpiYoY.date : null,
unemployment: latestUnrate.date
}
}]
return { data, alerts: [] }
} catch (e) {
logJSON('skill.liquidity.run.error', { error: String(e.message || e) }, 'market_skills')
return null
}
}
}
+879
View File
@@ -0,0 +1,879 @@
const fs = require('fs')
const path = require('path')
const axios = require('axios')
const nodemailer = require('nodemailer')
const { logJSON } = require('../logger')
const { db } = require('../db')
const securityCalendar = require('../security_calendar')
const SKILLS_DIR = __dirname
const HISTORY_FILE = path.join(SKILLS_DIR, 'alert_history.json')
// 从 Toolbox_local_creds.env.local 加载本地凭证,用于解析 {{VAR}} 占位符
const localCreds = (() => {
try {
const userProfile = process.env.USERPROFILE || process.env.HOME || ''
const p = path.join(userProfile, 'Toolbox_local_creds.env.local')
if (!fs.existsSync(p)) return {}
const content = fs.readFileSync(p, 'utf8')
const creds = {}
content.split(/\r?\n/).forEach(line => {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) return
const idx = trimmed.indexOf('=')
if (idx > 0) creds[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim()
})
return creds
} catch { return {} }
})()
// 将配置中的 {{VAR}} 占位符替换为实际值
const resolvePlaceholders = (val) => {
if (typeof val !== 'string') return val
return val.replace(/\{\{(.+?)\}\}/g, (_, name) => localCreds[name] || process.env[name] || `{{${name}}}`)
}
const skills = new Map()
const lastDispatchSignature = new Map()
const retryQueue = []
let retryTimer = null
const TITLE_INLINE_HAN_LIMIT = 50
const TITLE_POLICY_MARKER = 'FULL_TITLE_50CN_V1'
const chinaTradingCalendarCache = new Map()
const getAlertSlot = db.prepare(
`SELECT slot_key FROM alert_history WHERE skill = ? AND alert_id = ?`
)
const upsertAlertSlot = db.prepare(
`INSERT INTO alert_history (skill, alert_id, slot_key, updated_at)
VALUES (@skill, @alert_id, @slot_key, datetime('now'))
ON CONFLICT(skill, alert_id) DO UPDATE SET
slot_key = excluded.slot_key,
updated_at = datetime('now')`
)
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 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 updateEmailLogStatus = db.prepare(
`UPDATE alert_email_log SET status = @status, error_message = @error_message
WHERE id = @id`
)
const migrateHistoryFromFile = () => {
if (!fs.existsSync(HISTORY_FILE)) return
let data = null
try {
data = JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8'))
} catch (e) {
logJSON('skills.history.error', { error: String(e.message) }, 'market_skills')
return
}
const entries = Object.entries(data || {})
if (entries.length === 0) return
const tx = db.transaction(list => {
for (const [key, slot] of list) {
if (!slot) continue
const idx = key.indexOf(':')
if (idx <= 0) continue
const skill = key.slice(0, idx)
const alert_id = key.slice(idx + 1)
if (!skill || !alert_id) continue
upsertAlertSlot.run({ skill, alert_id, slot_key: String(slot) })
}
})
tx(entries)
}
migrateHistoryFromFile()
const DEFAULT_POLICY = {
urgentAbsChangePercent: 5,
urgentCooldownMinutes: 120,
nonChinaDailyCap: 3,
nonChinaPerSkillDailyCap: 1,
chinaAmReminderTime: '11:25',
chinaPmReminderTime: '14:55',
dailySummaryTime: '21:30'
}
const readStateJson = (key, fallback) => {
try {
const row = getMarketState.get(key)
if (!row || typeof row.value !== 'string' || !row.value) return fallback
return JSON.parse(row.value)
} catch {
return fallback
}
}
const writeStateJson = (key, value) => {
try {
upsertMarketState.run({ key, value: JSON.stringify(value || {}) })
} catch (e) {
logJSON('skills.state.write.error', { key, error: String(e.message || e) }, 'market_skills')
}
}
const toBJDate = (d = new Date()) => {
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
return new Date(utc + (3600000 * 8))
}
const pad2 = n => String(n).padStart(2, '0')
const bjParts = (d = new Date()) => {
const bj = toBJDate(d)
const year = bj.getFullYear()
const month = pad2(bj.getMonth() + 1)
const day = pad2(bj.getDate())
const hour = bj.getHours()
const minute = bj.getMinutes()
const second = bj.getSeconds()
return {
year,
month,
day,
hour,
minute,
second,
dateStr: `${year}-${month}-${day}`,
timeStr: `${pad2(hour)}:${pad2(minute)}:${pad2(second)}`
}
}
const bjNowDateTime = () => {
const p = bjParts()
return `${p.dateStr} ${p.timeStr}`
}
const parseHHMM = hhmm => {
const s = String(hhmm || '').trim()
const m = s.match(/^(\d{1,2}):(\d{2})$/)
if (!m) return { hour: 0, minute: 0 }
return { hour: Math.max(0, Math.min(23, parseInt(m[1], 10))), minute: Math.max(0, Math.min(59, parseInt(m[2], 10))) }
}
const localTs = (dateStr, hhmm) => `${dateStr} ${hhmm}`
const nextDateStr = (dateStr) => {
const d = new Date(`${dateStr}T00:00:00.000Z`)
d.setUTCDate(d.getUTCDate() + 1)
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
}
const isChinaTradingDate = async dateStr => {
const safeDate = String(dateStr || '').trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(safeDate)) return true
const monthKey = safeDate.slice(0, 7)
if (!chinaTradingCalendarCache.has(monthKey)) {
try {
const payload = await securityCalendar.getMonthCalendar(monthKey, { force_refresh: false })
chinaTradingCalendarCache.set(monthKey, payload)
} catch {
chinaTradingCalendarCache.set(monthKey, null)
}
}
const payload = chinaTradingCalendarCache.get(monthKey)
const days = payload && Array.isArray(payload.days) ? payload.days : []
const day = days.find(item => String((item && item.date) || '') === safeDate)
if (!day) return true
return day.is_trading_day !== false
}
const safeSkillId = (id = '') => String(id).replace(/[^a-zA-Z0-9_-]/g, '_')
const getPolicy = config => {
const p = (config && config.alert_policy) || {}
const a = (config && config.alerts) || {}
return {
urgentAbsChangePercent: Number.isFinite(Number(a.urgent_abs_change_percent)) ? Math.abs(Number(a.urgent_abs_change_percent)) : (Number.isFinite(Number(p.urgent_abs_change_percent)) ? Math.abs(Number(p.urgent_abs_change_percent)) : DEFAULT_POLICY.urgentAbsChangePercent),
urgentCooldownMinutes: Number.isFinite(Number(a.urgent_cooldown_minutes)) ? Math.max(1, Number(a.urgent_cooldown_minutes)) : (Number.isFinite(Number(p.urgent_cooldown_minutes)) ? Math.max(1, Number(p.urgent_cooldown_minutes)) : DEFAULT_POLICY.urgentCooldownMinutes),
nonChinaDailyCap: Number.isFinite(Number(p.non_china_daily_cap)) ? Math.max(1, Number(p.non_china_daily_cap)) : DEFAULT_POLICY.nonChinaDailyCap,
nonChinaPerSkillDailyCap: Number.isFinite(Number(p.non_china_per_skill_daily_cap)) ? Math.max(1, Number(p.non_china_per_skill_daily_cap)) : DEFAULT_POLICY.nonChinaPerSkillDailyCap,
chinaAmReminderTime: typeof p.china_am_reminder_time === 'string' ? p.china_am_reminder_time : DEFAULT_POLICY.chinaAmReminderTime,
chinaPmReminderTime: typeof p.china_pm_reminder_time === 'string' ? p.china_pm_reminder_time : DEFAULT_POLICY.chinaPmReminderTime,
dailySummaryTime: typeof p.daily_summary_time === 'string' ? p.daily_summary_time : DEFAULT_POLICY.dailySummaryTime
}
}
const isChinaSecuritySkill = config => {
const timezone = String(((config || {}).market_status || {}).timezone || '')
const cardType = String((config || {}).card_type || '')
const name = String((config || {}).name || '')
if (timezone !== 'Asia/Shanghai') return false
if (cardType === 'domestic' || cardType === 'enhanced') return true
return /上证|深证|创业板|科创|沪深|北证|A股/.test(name)
}
const getDayState = dateStr => {
const key = `skills.day_state.${dateStr}`
const v = readStateJson(key, null)
if (v && typeof v === 'object') return v
return { nonChinaTotal: 0, nonChinaSkills: {}, dailySummarySent: false, nonChinaFirstSent: false }
}
const setDayState = (dateStr, state) => {
writeStateJson(`skills.day_state.${dateStr}`, state)
}
const getDeferredList = dateStr => readStateJson(`skills.deferred.${dateStr}`, [])
const setDeferredList = (dateStr, list) => writeStateJson(`skills.deferred.${dateStr}`, Array.isArray(list) ? list : [])
const getLatestSkillDataState = skillId => {
const rows = readStateJson(`skills.latest.${safeSkillId(skillId)}`, [])
return Array.isArray(rows) ? rows : []
}
const setLatestSkillDataState = (skillId, rows) => {
writeStateJson(`skills.latest.${safeSkillId(skillId)}`, Array.isArray(rows) ? rows : [])
}
const appendDeferred = (dateStr, rows) => {
if (!Array.isArray(rows) || rows.length === 0) return
const old = getDeferredList(dateStr)
const merged = old.concat(rows).slice(-500)
setDeferredList(dateStr, merged)
}
const getSummaryPool = dateStr => {
const key = `skills.summary_pool.${dateStr}`
const v = readStateJson(key, null)
if (v && typeof v === 'object') return v
return { regular: [], urgent: [] }
}
const setSummaryPool = (dateStr, pool) => {
writeStateJson(`skills.summary_pool.${dateStr}`, pool || { regular: [], urgent: [] })
}
const addSummaryRows = (dateStr, rows, urgent = false) => {
if (!Array.isArray(rows) || rows.length === 0) return
const pool = getSummaryPool(dateStr)
const keySet = new Set()
const normalize = r => {
const skill = String(r.skill || '')
const alertId = String(r.alertId || '')
const title = String(r.title || '')
const message = String(r.message || '')
const ts = String(r.ts || '')
return { skill, alertId, title, message, ts }
}
const load = arr => {
for (const x of (Array.isArray(arr) ? arr : [])) {
const n = normalize(x)
const k = `${n.skill}|${n.alertId}|${n.title}|${n.message}`
if (keySet.has(k)) continue
keySet.add(k)
}
}
load(pool.regular)
load(pool.urgent)
const target = urgent ? (Array.isArray(pool.urgent) ? pool.urgent : []) : (Array.isArray(pool.regular) ? pool.regular : [])
for (const row of rows) {
const n = normalize(row)
const k = `${n.skill}|${n.alertId}|${n.title}|${n.message}`
if (keySet.has(k)) continue
keySet.add(k)
target.push(n)
}
if (urgent) pool.urgent = target
else pool.regular = target
setSummaryPool(dateStr, pool)
}
const dedupeByAlert = list => {
const map = new Map()
for (const it of list || []) {
const k = `${it.skill || ''}::${it.alertId || it.title || ''}`
if (!map.has(k)) map.set(k, it)
}
return Array.from(map.values())
}
const resolveUrl = (url) => {
const port = process.env.PORT || 8976
return String(url || '').replace(/{{PORT}}/g, port)
}
const queueRetry = task => {
const next = { attempts: 0, maxAttempts: 12, nextAt: Date.now() + 2 * 60 * 1000, ...task }
retryQueue.push(next)
try {
insertEmailLog.run({
skill: task.skillName || 'unknown',
alert_id: task.externalId || 'unknown',
subject: task.title || '',
status: 'retry_pending',
error_message: ''
})
} catch {}
}
const sendEmail = async (config, subject, text, skillName, alertId) => {
const emailReminderEnabled = config.email_reminder_enabled !== false
if (!emailReminderEnabled || !config.email || !config.email.enabled || !config.email.smtp) return true
// 解析 SMTP 配置中的 {{VAR}} 占位符(如 {{GMAIL_SMTP_PASS}})
const smtpCfg = JSON.parse(JSON.stringify(config.email.smtp))
if (smtpCfg.auth && smtpCfg.auth.pass) smtpCfg.auth.pass = resolvePlaceholders(smtpCfg.auth.pass)
let logId = 0
try {
const info = insertEmailLog.run({ skill: skillName || 'unknown', alert_id: alertId || 'unknown', subject, status: 'sending', error_message: '' })
logId = Number(info.lastInsertRowid || 0)
} catch {}
try {
const transporter = nodemailer.createTransport(smtpCfg)
await transporter.sendMail({
from: config.email.from,
to: Array.isArray(config.email.to) ? config.email.to.join(',') : config.email.to,
subject,
text
})
if (logId) {
try { updateEmailLogStatus.run({ id: logId, status: 'sent', error_message: '' }) } catch {}
}
} catch (e) {
const errMsg = String(e.message || e)
if (logId) {
try { updateEmailLogStatus.run({ id: logId, status: 'failed', error_message: errMsg }) } catch {}
} else {
try { insertEmailLog.run({ skill: skillName || 'unknown', alert_id: alertId || 'unknown', subject, status: 'failed', error_message: errMsg }) } catch {}
}
throw e
}
return true
}
const normalizeInlineText = text => String(text || '').replace(/\s+/g, ' ').trim()
const normalizeBodyForTitle = content => normalizeInlineText(String(content || '').replace(/\r?\n+/g, ';'))
const countHanChars = text => (String(text || '').match(/[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/g) || []).length
const countDisplayChars = text => Array.from(String(text || '')).length
const takePrefixByHanLimit = (text, hanLimit) => {
const chars = Array.from(String(text || ''))
let han = 0
let out = ''
for (const ch of chars) {
const isHan = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/.test(ch)
if (isHan) {
if (han >= hanLimit) break
han += 1
}
out += ch
}
return out.trim()
}
const applyTitlePolicy = (baseTitle, content) => {
const title = normalizeInlineText(baseTitle)
const detail = normalizeBodyForTitle(content)
if (!detail) return { title, marker: TITLE_POLICY_MARKER }
const hanCount = countHanChars(detail)
const effectiveCount = hanCount > 0 ? hanCount : countDisplayChars(detail)
if (effectiveCount <= TITLE_INLINE_HAN_LIMIT) {
return { title: normalizeInlineText(`${title} ${detail}`), marker: TITLE_POLICY_MARKER }
}
const prefix = hanCount > 0 ? takePrefixByHanLimit(detail, TITLE_INLINE_HAN_LIMIT) : Array.from(detail).slice(0, TITLE_INLINE_HAN_LIMIT).join('')
const remaining = Math.max(0, effectiveCount - TITLE_INLINE_HAN_LIMIT)
return { title: normalizeInlineText(`${title} ${prefix}【剩余${remaining}汉字,详见正文】`), marker: TITLE_POLICY_MARKER }
}
const buildWeeklyPayload = ({ target, title, content, externalId, dateStr, titlePolicy }) => ({
key: target.key || process.env.SYSTEM_AUTH_PASS_93220 || '',
source: 'market_skills',
external_id: externalId,
title,
content,
title_policy: titlePolicy || TITLE_POLICY_MARKER,
status_code: 'new',
status_label: '📅本周新增',
date: dateStr
})
const buildCalendarPayload = ({ title, content, eventId, tsLocal }) => ({
id: eventId,
title,
content,
start: tsLocal,
end: tsLocal,
remindAt: tsLocal,
allDay: 0
})
const pushTargets = async (config, payloadBuilder) => {
const calendarReminderEnabled = config.calendar_reminder_enabled !== false
const targets = Array.isArray(config.push_targets) ? [...config.push_targets] : []
if (config.push_api && config.push_api.enabled && config.push_api.url) targets.push(config.push_api)
for (const target of targets) {
if (!target.enabled) continue
const url = resolveUrl(target.url)
if (!url) continue
let payload = null
if (url.includes('/api/weekly/ext/event')) {
payload = payloadBuilder('weekly', target)
} else if (url.includes('/api/calendar/events')) {
if (!calendarReminderEnabled) continue
payload = payloadBuilder('calendar', target)
} else {
payload = payloadBuilder('generic', target)
}
if (!payload) continue
await axios.post(url, payload, { timeout: 15000 })
}
}
const sendDispatch = async ({ skillId, skillName, config, title, lines, externalId, tsLocal, sendEmailNow = false, enqueueOnFail = true }) => {
const content = lines.join('\n')
const titlePack = applyTitlePolicy(title, content)
const finalTitle = titlePack.title
const signature = `${finalTitle}\n${content}\n${externalId}\n${tsLocal}\n${sendEmailNow ? '1' : '0'}`
const prev = lastDispatchSignature.get(externalId)
if (prev === signature) return true
lastDispatchSignature.set(externalId, signature)
try {
if (sendEmailNow) {
await sendEmail(config, finalTitle, content, skillName, externalId)
logJSON('skills.alert.email.sent', { skill: skillName, externalId }, 'market_skills')
}
await pushTargets(config, (type, target) => {
const dateStr = tsLocal.slice(0, 10)
if (type === 'weekly') return buildWeeklyPayload({ target, title: finalTitle, content, externalId, dateStr, titlePolicy: titlePack.marker })
if (type === 'calendar') return buildCalendarPayload({ title: finalTitle, content, eventId: externalId, tsLocal })
return {
source: 'market_skills',
skill: skillName,
external_id: externalId,
title: finalTitle,
content,
title_policy: titlePack.marker,
ts: Date.now()
}
})
logJSON('skills.alert.dispatch.sent', { skill: skillName, externalId, tsLocal, title: finalTitle, title_policy: titlePack.marker }, 'market_skills')
return true
} catch (e) {
logJSON('skills.alert.dispatch.error', { skill: skillName, externalId, error: String(e.message || e) }, 'market_skills')
if (enqueueOnFail) queueRetry({ skillId, skillName, config, title, lines, externalId, tsLocal, sendEmailNow })
return false
}
}
const processRetryQueue = async () => {
if (retryQueue.length === 0) return
const nowMs = Date.now()
const pending = []
for (const job of retryQueue.splice(0, retryQueue.length)) {
if (job.nextAt > nowMs) {
pending.push(job)
continue
}
const ok = await sendDispatch({ ...job, enqueueOnFail: false })
if (!ok) {
job.attempts = Number(job.attempts || 0) + 1
if (job.attempts < Number(job.maxAttempts || 12)) {
const delay = Math.min(60 * 60 * 1000, Math.pow(2, job.attempts) * 60 * 1000)
job.nextAt = Date.now() + delay
pending.push(job)
try {
insertEmailLog.run({
skill: job.skillName || 'unknown',
alert_id: job.externalId || 'unknown',
subject: job.title || '',
status: 'retry_attempt',
error_message: `attempt ${job.attempts}/${job.maxAttempts}`
})
} catch {}
} else {
try {
insertEmailLog.run({
skill: job.skillName || 'unknown',
alert_id: job.externalId || 'unknown',
subject: job.title || '',
status: 'retry_exhausted',
error_message: `gave up after ${job.attempts} attempts`
})
} catch {}
logJSON('skills.alert.retry.giveup', { skill: job.skillName, externalId: job.externalId }, 'market_skills')
}
}
}
retryQueue.push(...pending)
}
// Helper to parse time string to ms
const parseInterval = (str) => {
if (typeof str === 'number') return str
if (!str) return 60000 // Default 1 min
const m = str.match(/^(\d+)([smh])$/)
if (!m) return 60000
const val = parseInt(m[1])
const unit = m[2]
if (unit === 's') return val * 1000
if (unit === 'm') return val * 60000
if (unit === 'h') return val * 3600000
return 60000
}
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 saveQuotes = (quotes) => {
if (!Array.isArray(quotes)) return
const tx = db.transaction((list) => {
for (const q of list) {
try {
insertQuote.run(q)
} catch (e) {
logJSON('skills.db.error', { error: String(e.message), symbol: q.symbol }, 'market_skills')
}
}
})
tx(quotes)
}
const persistRegularSlotDedup = (skillName, alerts) => {
const p = bjParts()
const slot = p.hour < 12 ? 'am' : 'pm'
const currentSlotKey = `${p.dateStr}-${slot}`
const filtered = []
const updates = []
for (const alert of (alerts || [])) {
const alertId = alert.id || alert.title
const row = getAlertSlot.get(skillName, alertId)
if (row && row.slot_key === currentSlotKey) continue
filtered.push(alert)
updates.push({ skill: skillName, alert_id: alertId, slot_key: currentSlotKey })
}
if (updates.length > 0) {
const tx = db.transaction(list => {
for (const row of list) upsertAlertSlot.run(row)
})
tx(updates)
}
return filtered
}
const buildAlertRows = (skillName, alerts, dataTime) => {
const lineTs = String(dataTime || bjNowDateTime())
return (alerts || []).map(a => ({
skill: skillName,
alertId: a.id || a.title || '',
title: String(a.title || ''),
message: String(a.message || ''),
ts: lineTs
}))
}
const getChinaSlotAndTime = (policy, parts) => {
const slot = parts.hour < 12 ? 'am' : 'pm'
const hhmm = slot === 'am' ? policy.chinaAmReminderTime : policy.chinaPmReminderTime
return { slot, hhmm }
}
const makeBucketLines = rows => dedupeByAlert(rows).map(r => `【${String(r.ts || bjNowDateTime())}】 [${r.skill}] ${r.title}: ${r.message}`)
const handleChinaRegular = async ({ skillId, skillName, config, alerts, policy, dateStr, parts, dataTime }) => {
const isTradingDate = await isChinaTradingDate(dateStr)
if (!isTradingDate) {
logJSON('skills.cn.summary.skip.closed_day', { skill: skillName, date: dateStr }, 'market_skills')
return
}
const rows = buildAlertRows(skillName, alerts, dataTime)
addSummaryRows(dateStr, rows, false)
const { slot, hhmm } = getChinaSlotAndTime(policy, parts)
const key = `skills.bucket.cn.${dateStr}.${slot}`
const prev = readStateJson(key, [])
const merged = dedupeByAlert([...(Array.isArray(prev) ? prev : []), ...rows])
writeStateJson(key, merged)
const lines = makeBucketLines(merged)
if (lines.length === 0) return
const title = `中国证券${slot === 'am' ? '上午' : '下午'}汇总 ${dateStr}`
const externalId = `skill-summary-cn-${dateStr}-${slot}`
const tsLocal = localTs(dateStr, hhmm)
await sendDispatch({ skillId, skillName, config, title, lines, externalId, tsLocal, sendEmailNow: false })
}
const handleNonChinaRegular = async ({ skillId, skillName, config, alerts, policy, dateStr, dataTime }) => {
const rows = buildAlertRows(skillName, alerts, dataTime)
addSummaryRows(dateStr, rows, false)
const dayState = getDayState(dateStr)
const sid = safeSkillId(skillId)
const skillSentCount = Number(dayState.nonChinaSkills[sid] || 0)
if (skillSentCount < policy.nonChinaPerSkillDailyCap && dayState.nonChinaTotal >= policy.nonChinaDailyCap) {
const nextDate = nextDateStr(dateStr)
appendDeferred(nextDate, rows)
logJSON('skills.non_cn.deferred', { skill: skillName, date: dateStr, nextDate, count: rows.length }, 'market_skills')
return
}
if (skillSentCount < policy.nonChinaPerSkillDailyCap) {
dayState.nonChinaSkills[sid] = skillSentCount + 1
dayState.nonChinaTotal = Number(dayState.nonChinaTotal || 0) + 1
if (!dayState.nonChinaFirstSent) dayState.nonChinaFirstSent = true
setDayState(dateStr, dayState)
}
const key = `skills.bucket.non_cn.${dateStr}.${sid}`
const prev = readStateJson(key, [])
let merged = dedupeByAlert([...(Array.isArray(prev) ? prev : []), ...rows])
if (dayState.nonChinaFirstSent && Number(dayState.nonChinaTotal || 0) === 1) {
const deferred = getDeferredList(dateStr)
if (Array.isArray(deferred) && deferred.length > 0) {
merged = dedupeByAlert([...deferred, ...merged])
setDeferredList(dateStr, [])
}
}
writeStateJson(key, merged)
const lines = makeBucketLines(merged)
if (lines.length === 0) return
const title = `非中国证券汇总 ${dateStr} ${skillName}`
const externalId = `skill-summary-noncn-${dateStr}-${sid}`
const tsLocal = localTs(dateStr, '21:30')
await sendDispatch({ skillId, skillName, config, title, lines, externalId, tsLocal, sendEmailNow: false })
}
const handleUrgentAlerts = async ({ skillId, skillName, config, alerts, policy, dateStr, parts, absChange, dataTime }) => {
if (!Number.isFinite(absChange) || absChange < policy.urgentAbsChangePercent) return []
const cooldownState = readStateJson('skills.urgent_cooldown', {})
const nowMs = Date.now()
const cooldownMs = policy.urgentCooldownMinutes * 60 * 1000
const sendList = []
for (const a of alerts) {
const aid = a.id || a.title || 'unknown'
const k = `${skillName}::${aid}`
const last = Number(cooldownState[k] || 0)
if (nowMs - last < cooldownMs) continue
cooldownState[k] = nowMs
sendList.push(a)
}
writeStateJson('skills.urgent_cooldown', cooldownState)
if (sendList.length === 0) return []
const rows = buildAlertRows(skillName, sendList, dataTime)
addSummaryRows(dateStr, rows, true)
const lines = makeBucketLines(rows)
const windowBase = Math.floor(nowMs / cooldownMs)
const ext = `skill-urgent-${safeSkillId(skillId)}-${dateStr}-${windowBase}`
const tsLocal = localTs(dateStr, `${pad2(parts.hour)}:${pad2(parts.minute)}`)
const title = `严重告警 ${skillName} | 波动 ${absChange.toFixed(2)}%`
await sendDispatch({ skillId, skillName, config, title, lines, externalId: ext, tsLocal, sendEmailNow: true })
return sendList
}
const maybeSendDailySummary = async ({ skillName, config, policy, dateStr, parts }) => {
const dayState = getDayState(dateStr)
if (dayState.dailySummarySent) return
const cut = parseHHMM(policy.dailySummaryTime)
if (parts.hour < cut.hour || (parts.hour === cut.hour && parts.minute < cut.minute)) return
const pool = getSummaryPool(dateStr)
const regular = dedupeByAlert(pool.regular || [])
const urgent = dedupeByAlert(pool.urgent || [])
if (regular.length === 0 && urgent.length === 0) {
dayState.dailySummarySent = true
setDayState(dateStr, dayState)
return
}
const lines = []
if (urgent.length > 0) {
lines.push('【严重告警】')
lines.push(...makeBucketLines(urgent))
}
if (regular.length > 0) {
if (lines.length > 0) lines.push('')
lines.push('【日终汇总】')
lines.push(...makeBucketLines(regular))
}
const title = `市场日终总览 ${dateStr}`
const externalId = `skill-daily-summary-${dateStr}`
const tsLocal = localTs(dateStr, policy.dailySummaryTime)
await sendDispatch({ skillId: 'daily_summary', skillName: '市场日终总览', config, title, lines, externalId, tsLocal, sendEmailNow: true })
dayState.dailySummarySent = true
setDayState(dateStr, dayState)
}
const EXCLUDED_FROM_PUSH_SKILLS = new Set([
'中国银行-299法',
'中国神华-299法',
'巴菲特指数',
'美国战争指数',
'流动性状态机'
])
const sendAlerts = async (skillId, skillName, config, rawAlerts, dataRows) => {
if (!rawAlerts || rawAlerts.length === 0) return
if (EXCLUDED_FROM_PUSH_SKILLS.has(skillName)) return
const p = bjParts()
const dateStr = p.dateStr
const policy = getPolicy(config)
const quote = Array.isArray(dataRows) && dataRows.length > 0 ? dataRows[0] : null
const explain = quote && typeof quote.explain === 'string' ? quote.explain.trim() : ''
const alertsWithExplain = explain
? rawAlerts.map(a => ({ ...a, message: `${String(a.message || '').trim()}\n说明:${explain}` }))
: rawAlerts
const absChange = quote && Number.isFinite(Number(quote.change_percent)) ? Math.abs(Number(quote.change_percent)) : NaN
const dataTime = bjNowDateTime()
const urgentDispatched = await handleUrgentAlerts({ skillId, skillName, config, alerts: alertsWithExplain, policy, dateStr, parts: p, absChange, dataTime })
const urgentKeySet = new Set(urgentDispatched.map(a => a.id || a.title || ''))
const regularCandidates = alertsWithExplain.filter(a => !urgentKeySet.has(a.id || a.title || ''))
const regularAlerts = persistRegularSlotDedup(skillName, regularCandidates)
if (regularAlerts.length > 0) {
if (isChinaSecuritySkill(config)) {
await handleChinaRegular({ skillId, skillName, config, alerts: regularAlerts, policy, dateStr, parts: p, dataTime })
} else {
await handleNonChinaRegular({ skillId, skillName, config, alerts: regularAlerts, policy, dateStr, dataTime })
}
}
await maybeSendDailySummary({ skillName, config, policy, dateStr, parts: p })
}
const runSkill = async (id, skillModule, config) => {
logJSON('skills.run.start', { skill: id }, 'market_skills')
try {
if (config && config.display === false) return
const context = {
logJSON: (evt, data) => logJSON(evt, data, `skill:${id}`),
axios,
db
}
const result = await skillModule.run(context, config)
if (!result) {
// Placeholder card when skill returns nothing (all data sources failed)
if (config) {
const placeholderData = [{
label: config.name || id,
symbol: config.symbol || id,
source: 'error',
name: config.name || id,
price: null,
change: null,
change_percent: null,
captured_at: new Date().toISOString().replace('T', ' ').slice(0, 19),
market_status: config.market_status || null,
card_order: Number.isFinite(config.card_order) ? config.card_order : 9999,
card_type: config.card_type || null,
url: config.url || null,
_error: '⚠ 数据源不可用(所有源均无响应)'
}]
const skillEntry = skills.get(id)
if (skillEntry) skillEntry.latestData = placeholderData
setLatestSkillDataState(id, placeholderData)
}
return
}
let { data, alerts } = result
if (data && Array.isArray(data)) {
const extra = {}
if (config.url) extra.url = config.url
if (Number.isFinite(config.card_order)) extra.card_order = config.card_order
if (config.card_type) extra.card_type = config.card_type
if (Number.isFinite(config.card_span) && config.card_span >= 1) extra.card_span = Math.floor(config.card_span)
if (Object.keys(extra).length > 0) {
data = data.map(item => ({ ...item, ...extra }))
}
}
if (data && data.length > 0) {
const skillEntry = skills.get(id)
if (skillEntry) {
skillEntry.latestData = data
}
setLatestSkillDataState(id, data)
saveQuotes(data)
logJSON('skills.data.saved', { skill: id, count: data.length }, 'market_skills')
}
if (alerts && alerts.length > 0) await sendAlerts(id, config.name || id, config, alerts, data)
const p = bjParts()
await maybeSendDailySummary({ skillName: config.name || id, config, policy: getPolicy(config), dateStr: p.dateStr, parts: p })
await processRetryQueue()
} catch (e) {
logJSON('skills.run.error', { skill: id, error: String(e.message || e) }, 'market_skills')
// Placeholder card when skill throws
if (config) {
const placeholderData = [{
label: config.name || id,
symbol: config.symbol || id,
source: 'error',
name: config.name || id,
price: null,
change: null,
change_percent: null,
captured_at: new Date().toISOString().replace('T', ' ').slice(0, 19),
market_status: config.market_status || null,
card_order: Number.isFinite(config.card_order) ? config.card_order : 9999,
card_type: config.card_type || null,
url: config.url || null,
_error: `⚠ 数据异常:${String(e.message || e).slice(0, 80)}`
}]
const skillEntry = skills.get(id)
if (skillEntry) skillEntry.latestData = placeholderData
setLatestSkillDataState(id, placeholderData)
}
}
}
const loadSkills = () => {
if (!fs.existsSync(SKILLS_DIR)) return
const dirs = fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
for (const dirent of dirs) {
if (!dirent.isDirectory()) continue
const id = dirent.name
const skillPath = path.join(SKILLS_DIR, id)
const indexFile = path.join(skillPath, 'index.js')
const configFile = path.join(skillPath, 'config.json')
if (fs.existsSync(indexFile) && fs.existsSync(configFile)) {
try {
const config = JSON.parse(fs.readFileSync(configFile, 'utf-8'))
if (config.enabled === false) continue
if (config.display === false) continue
const skillModule = require(indexFile)
const interval = parseInterval(config.schedule)
logJSON('skills.loaded', { skill: id, schedule: config.schedule, interval }, 'market_skills')
const entry = { timer: null, config, module: skillModule, latestData: getLatestSkillDataState(id) }
skills.set(id, entry)
runSkill(id, skillModule, config)
const timer = setInterval(() => {
runSkill(id, skillModule, config)
}, interval)
entry.timer = timer
} catch (e) {
logJSON('skills.load.error', { skill: id, error: String(e.message) }, 'market_skills')
}
}
}
}
const getLatestData = () => {
const all = []
for (const [id, entry] of skills) {
if (entry.latestData && Array.isArray(entry.latestData)) {
all.push(...entry.latestData)
}
}
return all
}
module.exports = {
start: () => {
loadSkills()
if (!retryTimer) {
retryTimer = setInterval(() => { processRetryQueue().catch(() => {}) }, 60 * 1000)
}
logJSON('skills.manager.started', { count: skills.size }, 'market_skills')
},
getLatestData
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的��,
"name": "纳斯达克",
"card_order": 13,
"card_type": "overseas",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(腾讯财�或其他�)",
"symbol": "usIXIC",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://gu.qq.com/usIXIC",
"source_comment": "数��� (�tencent)",
"source": "yahoo_probe",
"alerts_comment": "预警阈值��,
"alerts": {
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 2
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "09:30",
"end": "16:00"
}
],
"display_label": "纳指"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+156
View File
@@ -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 }
}
}
@@ -0,0 +1,86 @@
{
"name_comment": "显示的��,
"name": "日�225指数",
"card_order": 9,
"card_type": "overseas",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(iShares MSCI Japan ETF)",
"symbol": "usEWJ",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://gu.qq.com/usEWJ",
"source_comment": "数��� (�tencent)",
"source": "yahoo_probe",
"alerts_comment": "预警阈值��,
"alerts": {
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 2
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Tokyo",
"sessions": [
{
"start": "09:00",
"end": "11:30"
},
{
"start": "12:30",
"end": "15:00"
}
],
"display_label": "日�225"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,42 @@
{
"name_comment": "显示的名称",
"name": "MVREMX 稀土指数(REMX)",
"card_order": 18,
"card_type": "overseas",
"enabled_comment": "是否启用该监控 (true/false)",
"enabled": true,
"display_comment": "是否显示该卡片 (false 则不拉取数据也不封存)",
"display": true,
"schedule_comment": "刷新频率 (如 10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代码",
"symbol": "usREMX",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usREMX",
"source_comment": "数据来源",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配置",
"alerts": {
"change_percent_down_comment": "跌幅预警阈值",
"change_percent_down": -2,
"change_percent_up_comment": "涨幅预警阈值",
"change_percent_up": 2
},
"email_reminder_enabled_comment": "是否发送邮件提醒",
"email_reminder_enabled": false,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": false,
"push_targets_comment": "推送目标列表",
"push_targets": [],
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "09:30",
"end": "16:00"
}
],
"display_label": "美股REMX"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,82 @@
{
"name_comment": "显示的��,
"name": "沙特ETF(富时沙特)",
"card_order": 17,
"card_type": "overseas",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(Franklin FTSE Saudi Arabia ETF)",
"symbol": "usFLSA",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://gu.qq.com/usFLSA",
"source_comment": "数��� (�tencent)",
"source": "yahoo_probe",
"alerts_comment": "预警阈值��,
"alerts": {
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 2
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "09:30",
"end": "16:00"
}
],
"display_label": "富时沙特"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,86 @@
{
"name_comment": "显示的��,
"name": "上�指数",
"card_order": 1,
"card_type": "domestic",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(腾讯财�或其他�)",
"symbol": "sh000001",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://gu.qq.com/sh000001",
"source_comment": "数��� (�tencent)",
"source": "tencent",
"alerts_comment": "预警阈值��,
"alerts": {
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 2
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Shanghai",
"sessions": [
{
"start": "09:30",
"end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
}
],
"display_label": "上�"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// 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
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.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 }
}
}
@@ -0,0 +1,77 @@
{
"name_comment": "显示的��,
"name": "中国神�-299�,
"card_order": 1,
"card_type": "299",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": false,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(腾讯财�或其他�)",
"symbol": "sh601088_299",
"source_symbol_comment": "用于拉�K线的真实代�",
"source_symbol": "sh601088",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://quote.eastmoney.com/sh601088.html",
"source_comment": "数��� (�tencent)",
"source": "tencent",
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Shanghai",
"sessions": [
{
"start": "09:30",
"end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
}
],
"display_label": "中国A�
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1,201 @@
const calculateMA = (data, period) => {
if (!data || data.length < period) return null
const subset = data.slice(-period)
const sum = subset.reduce((a, b) => a + b, 0)
return sum / period
}
const fetchKLine = async (axios, symbol) => {
const url = `http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=${symbol},day,,,300,qfq`
try {
const res = await axios.get(url, { timeout: 10000 })
if (!res || !res.data || res.data.code !== 0) return null
const d = res.data.data[symbol]
if (!d) return null
const rawData = d.qfqday || d.day
if (!rawData || !Array.isArray(rawData)) return null
return rawData.map(item => ({
date: item[0],
close: parseFloat(item[2])
}))
} catch (e) {
return null
}
}
const buildAlerts = ({
latestClose,
prevClose,
ma10,
ma20,
ma60,
ma90,
ma250,
prevMA20,
prevMA60,
prevMA90,
prevMA250
}) => {
const alerts = []
const addAlert = (id, title, message, level = 'warning') => {
alerts.push({ id, title, message, level })
}
const isCrossDown = (prevC, prevMa, currC, currMa) => prevC >= prevMa && currC < currMa
const isCrossUp = (prevC, prevMa, currC, currMa) => prevC <= prevMa && currC > currMa
const isBelowMA250 = latestClose < ma250
const isCrossDownMA250 = isCrossDown(prevClose, prevMA250, latestClose, ma250)
const isCrossUpMA250 = isCrossUp(prevClose, prevMA250, latestClose, ma250)
if (isCrossDownMA250) {
addAlert('below_ma250', '中国神华跌破年线!立即停止加仓,建议减仓', '中国神华跌破年线!立即停止加仓,建议减仓', 'error')
}
if (isCrossUpMA250) {
addAlert('above_ma250', '中国神华回到年线之上,可恢复加仓策略', '中国神华回到年线之上,可恢复加仓策略')
}
if (!isBelowMA250) {
if (isCrossDown(prevClose, prevMA20, latestClose, ma20)) {
addAlert('below_ma20', '中国神华-跌破20日线,需加仓 20%', '中国神华-跌破20日线,需加仓 20%')
}
if (isCrossDown(prevClose, prevMA60, latestClose, ma60)) {
addAlert('below_ma60', '中国神华-跌破60日线,需加仓 40%', '中国神华-跌破60日线,需加仓 40%')
}
if (isCrossDown(prevClose, prevMA90, latestClose, ma90)) {
addAlert('below_ma90', '中国神华-跌破90日线,需加仓剩余 40%', '中国神华-跌破90日线,需加仓剩余 40%')
}
}
if (isCrossUp(prevClose, prevMA20, latestClose, ma20)) {
addAlert('above_ma20', '中国神华-站上20日线,需卖出 20%', '中国神华-站上20日线,需卖出 20%')
}
if (isCrossUp(prevClose, prevMA60, latestClose, ma60)) {
addAlert('above_ma60', '中国神华-站上60日线,需卖出 40%', '中国神华-站上60日线,需卖出 40%')
}
if (isCrossUp(prevClose, prevMA90, latestClose, ma90)) {
addAlert('above_ma90', '中国神华-站上90日线,需卖出剩余 40%', '中国神华-站上90日线,需卖出剩余 40%')
}
if (ma10 > ma20) {
addAlert('trend_bull', '趋势提示', '中国神华处于上涨趋势,建议持有底仓 + 已建波段仓,不做 T')
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const symbol = config.symbol
const sourceSymbol = config.source_symbol || 'sh601088'
const klineData = await fetchKLine(axios, sourceSymbol)
if (!klineData || klineData.length < 250) {
logJSON('skill.shenhua_299.error', { message: 'Insufficient data', length: klineData ? klineData.length : 0 }, 'market_skills')
return null
}
const closes = klineData.map(d => d.close)
const latestClose = closes[closes.length - 1]
const prevClose = closes[closes.length - 2]
const latestDate = klineData[klineData.length - 1].date
const ma10 = calculateMA(closes, 10)
const ma20 = calculateMA(closes, 20)
const ma60 = calculateMA(closes, 60)
const ma90 = calculateMA(closes, 90)
const ma250 = calculateMA(closes, 250)
const closesPrev = closes.slice(0, -1)
const prevMA20 = calculateMA(closesPrev, 20)
const prevMA60 = calculateMA(closesPrev, 60)
const prevMA90 = calculateMA(closesPrev, 90)
const prevMA250 = calculateMA(closesPrev, 250)
if (!ma10 || !ma20 || !ma60 || !ma90 || !ma250 || !prevMA20 || !prevMA60 || !prevMA90 || !prevMA250) {
logJSON('skill.shenhua_299.error', { message: 'MA calculation failed' }, 'market_skills')
return null
}
const change = latestClose - prevClose
const changePercent = (change / prevClose) * 100
const trendText = ma10 > ma20 ? '多头排列' : '非多头'
const yearLineText = latestClose < ma250 ? '年线下方' : '年线之上'
const isCrossDown = (prevC, prevMa, currC, currMa) => prevC >= prevMa && currC < currMa
const isCrossUp = (prevC, prevMa, currC, currMa) => prevC <= prevMa && currC > currMa
const isBelowMA250 = latestClose < ma250
const crossDownMA20 = isCrossDown(prevClose, prevMA20, latestClose, ma20)
const crossDownMA60 = isCrossDown(prevClose, prevMA60, latestClose, ma60)
const crossDownMA90 = isCrossDown(prevClose, prevMA90, latestClose, ma90)
const crossUpMA20 = isCrossUp(prevClose, prevMA20, latestClose, ma20)
const crossUpMA60 = isCrossUp(prevClose, prevMA60, latestClose, ma60)
const crossUpMA90 = isCrossUp(prevClose, prevMA90, latestClose, ma90)
const crossUpMA250 = isCrossUp(prevClose, prevMA250, latestClose, ma250)
const statusDownMA20 = isBelowMA250 ? '暂停(年线下方)' : (crossDownMA20 ? '已触发(加仓20%)' : '未触发')
const statusDownMA60 = isBelowMA250 ? '暂停(年线下方)' : (crossDownMA60 ? '已触发(加仓40%)' : '未触发')
const statusDownMA90 = isBelowMA250 ? '暂停(年线下方)' : (crossDownMA90 ? '已触发(加仓剩余40%)' : '未触发')
const statusUpMA20 = crossUpMA20 ? '已触发(卖出20%)' : '未触发'
const statusUpMA60 = crossUpMA60 ? '已触发(卖出40%)' : '未触发'
const statusUpMA90 = crossUpMA90 ? '已触发(卖出剩余40%)' : '未触发'
const statusTrend = ma10 > ma20 ? '已满足(上涨趋势)' : '未满足'
const statusMA250 = isBelowMA250 ? '已触发(停止加仓/建议减仓)' : (crossUpMA250 ? '已触发(恢复加仓)' : '未触发')
const htmlPayload = `
<div style="font-size: 14px; margin-top: 8px;">
价格:${latestClose.toFixed(2)} (${change >= 0 ? '+' : ''}${change.toFixed(2)} / ${changePercent.toFixed(2)}%)
</div>
<div style="margin: 8px 0; border-top: 1px solid #eee; width: 100%;"></div>
<div style="font-size: 13px; color: #666; line-height: 1.6;">
MA10:${ma10.toFixed(2)} | MA20:${ma20.toFixed(2)} | MA60:${ma60.toFixed(2)}<br/>
MA90:${ma90.toFixed(2)} | MA250:${ma250.toFixed(2)}<br/>
趋势:${trendText} | 年线状态:${yearLineText}
</div>
<div style="margin: 8px 0; border-top: 1px solid #eee; width: 100%;"></div>
<div style="font-size: 13px; color: #666; line-height: 1.6;">
20日线跌破:${statusDownMA20} | 站上:${statusUpMA20}<br/>
60日线跌破:${statusDownMA60} | 站上:${statusUpMA60}<br/>
90日线跌破:${statusDownMA90} | 站上:${statusUpMA90}<br/>
MA10>MA20:${statusTrend} | MA250:${statusMA250}
</div>
`
const data = [{
label: config.name,
symbol: symbol,
displaySymbol: sourceSymbol,
isMaCard: true,
render_type: 'html',
html: htmlPayload,
ma10: ma10.toFixed(2),
ma20: ma20.toFixed(2),
ma60: ma60.toFixed(2),
ma90: ma90.toFixed(2),
ma250: ma250.toFixed(2),
source: config.source || 'tencent',
name: config.name,
price: latestClose,
prevClose: prevClose,
change: change,
change_percent: changePercent,
market_status: `${trendText} (${latestDate})`
}]
const alerts = buildAlerts({
latestClose,
prevClose,
ma10,
ma20,
ma60,
ma90,
ma250,
prevMA20,
prevMA60,
prevMA90,
prevMA250
})
return { data, alerts }
}
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的��,
"name": "东�亚科技(海峡时报)",
"card_order": 20,
"card_type": "overseas",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(Global X 东��ETF)",
"symbol": "usASEA",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://gu.qq.com/usASEA",
"source_comment": "数��� (�tencent)",
"source": "yahoo_probe",
"alerts_comment": "预警阈值��,
"alerts": {
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 2
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Singapore",
"sessions": [
{
"start": "09:00",
"end": "17:00"
}
],
"display_label": "东�亚STI"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,82 @@
{
"name_comment": "显示的��,
"name": "标普500",
"card_order": 12,
"card_type": "overseas",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(腾讯财�或其他�)",
"symbol": "usINX",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://gu.qq.com/usINX",
"source_comment": "数��� (�tencent)",
"source": "yahoo_probe",
"alerts_comment": "预警阈值��,
"alerts": {
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 2
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "09:30",
"end": "16:00"
}
],
"display_label": "标普500"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+156
View File
@@ -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 }
}
}
@@ -0,0 +1,86 @@
{
"name_comment": "显示的��,
"name": "科创50",
"card_order": 4,
"card_type": "domestic",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(腾讯财�或其他�)",
"symbol": "sh000688",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://gu.qq.com/sh000688",
"source_comment": "数��� (�tencent)",
"source": "tencent",
"alerts_comment": "预警阈值��,
"alerts": {
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 2
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Shanghai",
"sessions": [
{
"start": "09:30",
"end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
}
],
"display_label": "科创50"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// 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
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.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 }
}
}
@@ -0,0 +1,86 @@
{
"name_comment": "显示的��,
"name": "深��指",
"card_order": 2,
"card_type": "domestic",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(腾讯财�或其他�)",
"symbol": "sz399001",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://gu.qq.com/sz399001",
"source_comment": "数��� (�tencent)",
"source": "tencent",
"alerts_comment": "预警阈值��,
"alerts": {
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 2
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Shanghai",
"sessions": [
{
"start": "09:30",
"end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
}
],
"display_label": "深�"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// 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
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.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 }
}
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的��,
"name": "�岛加�指数",
"card_order": 87,
"card_type": "overseas",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代�(iShares MSCI Taiwan ETF)",
"symbol": "usEWT",
"url_comment": "点击�片�上角房�图标跳转的地�",
"url": "https://gu.qq.com/usEWT",
"source_comment": "数��� (�tencent)",
"source": "yahoo_probe",
"alerts_comment": "预警阈值��,
"alerts": {
"change_percent_down_comment": "跌幅预警阈�(负数,如 -2 表示�2%)",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严�告警阈�(�对波动百分�",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严�告警冷�分钟�,
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈�(正数,如 2 表示�2%)",
"change_percent_up": 2
},
"email_comment": "邮件通知�置",
"email": {
"enabled_comment": "是��用邮件通知",
"enabled": true,
"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": true,
"calendar_reminder_enabled_comment": "是�写入日历�醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推�目标列�(支�多路推�",
"push_targets": [
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是��用",
"enabled": true,
"key_comment": "Weekly 工具�Key",
\"key\": \"\"
},
{
"url_comment": "推�接�地�",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是��用",
"enabled": true
}
],
"market_status": {
"timezone": "Asia/Taipei",
"sessions": [
{
"start": "09:00",
"end": "13:30"
}
],
"display_label": "�湾"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,94 @@
{
"name_comment": "显示的��,
"name": "美国战争指数",
"card_order": 14,
"card_type": "overseas",
"enabled_comment": "是��用该监�(true/false)",
"enabled": true,
"display_comment": "是�显示该��(false 则�拉�数�也��存)",
"display": true,
"schedule_comment": "刷新频率 (�10m 表示10分钟)",
"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",
\"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 }
}
}
@@ -0,0 +1,89 @@
{
"name_comment": "�曄內���蝘?,
"name": "銝剖𤙴�嗉�-299瘜?,
"card_order": 2,
"card_type": "299",
"card_span_comment": "�∠�璅芸��牐��埈㺭嚗?銝粹�霈歹�2銝箄楊銝文�",
"card_span": 2,
"card_custom_class_comment": "銝剖𤙴�嗉��∠�銝枏��瑕�蝐餃�嚗䔶��其��滨垢�祉�撅閧內",
"card_custom_class": "card-zhonghang-299",
"enabled_comment": "�臬炏�舐鍂霂亦��?(true/false)",
"enabled": true,
"display_comment": "�臬炏�曄內霂亙㨃�?(false �嗘��匧��唳旿銋煺�撠��)",
"display": true,
"schedule_comment": "�瑟鰵憸𤑳� (憒?10m 銵函內10���)",
"schedule": "10m",
"symbol_comment": "�烐綉��誨�?(�曇悖韐Y��硋�隞𡝗�)",
"symbol": "sh601988_299",
"source_symbol_comment": "�其��匧�K蝥輻��笔�隞��",
"source_symbol": "sh601988",
"url_comment": "�孵稬�∠��喃�閫埝�摮𣂼㦛��歲頧祉��啣�",
"url": "https://quote.eastmoney.com/sh601988.html",
"source_comment": "�唳旿�交� (憒?tencent)",
"source": "tencent",
"box_window_comment": "�嗥�隞瑞拳雿梶����隞�𣈲�?0/30/40",
"box_window": 30,
"box_confirm_days_comment": "蝞曹�蝒�聦蝖株恕憭拇㺭",
"box_confirm_days": 2,
"box_volume_ma_window_comment": "蝒�聦�交�鈭日����蝒堒藁",
"box_volume_ma_window": 5,
"box_volume_expand_ratio_comment": "蝒�聦�交𦆮�誩�齿㺭���?,
"box_volume_expand_ratio": 1.2,
"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",
\"key\": \"\"
},
{
"url_comment": "�券��𦻖��𧑐��",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "�臬炏�舐鍂",
"enabled": false
}
],
"market_status": {
"timezone": "Asia/Shanghai",
"sessions": [
{
"start": "09:30",
"end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
}
],
"display_label": "銝剖𤙴A�?
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1,289 @@
const calculateMA = (data, period) => {
if (!data || data.length < period) return null
const subset = data.slice(-period)
const sum = subset.reduce((a, b) => a + b, 0)
return sum / period
}
const calculateTrailingMA = (data, endExclusive, period) => {
if (!Array.isArray(data) || period <= 0) return null
if (!Number.isInteger(endExclusive) || endExclusive < period) return null
const subset = data.slice(endExclusive - period, endExclusive)
if (subset.length < period) return null
const sum = subset.reduce((a, b) => a + b, 0)
return sum / period
}
const fetchKLine = async (axios, symbol) => {
const url = `http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=${symbol},day,,,300,qfq`
try {
const res = await axios.get(url, { timeout: 10000 })
if (!res || !res.data || res.data.code !== 0) return null
const d = res.data.data[symbol]
if (!d) return null
const rawData = d.qfqday || d.day
if (!rawData || !Array.isArray(rawData)) return null
return rawData.map(item => {
const volume = parseFloat(item[5])
return {
date: item[0],
close: parseFloat(item[2]),
volume: Number.isFinite(volume) ? volume : 0
}
})
} catch (e) {
return null
}
}
const buildAlerts = ({
latestClose,
prevClose,
ma10,
ma20,
ma60,
ma90,
ma250,
prevMA20,
prevMA60,
prevMA90,
prevMA250,
isTrendGateOpen,
boxBreakoutConfirmed,
boxBreakdownConfirmed
}) => {
const alerts = []
const addAlert = (id, title, message, level = 'warning') => {
alerts.push({ id, title, message, level })
}
const isCrossDown = (prevC, prevMa, currC, currMa) => prevC >= prevMa && currC < currMa
const isCrossUp = (prevC, prevMa, currC, currMa) => prevC <= prevMa && currC > currMa
const isBelowMA250 = latestClose < ma250
const isCrossDownMA250 = isCrossDown(prevClose, prevMA250, latestClose, ma250)
const isCrossUpMA250 = isCrossUp(prevClose, prevMA250, latestClose, ma250)
if (isCrossDownMA250) {
addAlert('below_ma250', '中国银行跌破年线!立即停止加仓,建议减仓', '中国银行跌破年线!立即停止加仓,建议减仓', 'error')
}
if (isCrossUpMA250) {
addAlert('above_ma250', '中国银行回到年线之上,可恢复加仓策略', '中国银行回到年线之上,可恢复加仓策略')
}
if (boxBreakoutConfirmed) {
addAlert('box_breakout_confirmed', '箱体上沿突破已确认', '中国银行30日收盘价箱体上沿突破已连续2日确认,且突破日放量')
}
if (boxBreakdownConfirmed) {
addAlert('box_breakdown_confirmed', '箱体下沿跌破已确认', '中国银行30日收盘价箱体下沿跌破已连续2日确认,建议防守', 'error')
}
if (isTrendGateOpen) {
if (isCrossDown(prevClose, prevMA20, latestClose, ma20)) {
addAlert('below_ma20', '中国银行-跌破20日线,需加仓 20%', '中国银行-跌破20日线,需加仓 20%')
}
if (isCrossDown(prevClose, prevMA60, latestClose, ma60)) {
addAlert('below_ma60', '中国银行-跌破60日线,需加仓 40%', '中国银行-跌破60日线,需加仓 40%')
}
if (isCrossDown(prevClose, prevMA90, latestClose, ma90)) {
addAlert('below_ma90', '中国银行-跌破90日线,需加仓剩余 40%', '中国银行-跌破90日线,需加仓剩余 40%')
}
}
if (isCrossUp(prevClose, prevMA20, latestClose, ma20)) {
addAlert('above_ma20', '中国银行-站上20日线,需卖出 20%', '中国银行-站上20日线,需卖出 20%')
}
if (isCrossUp(prevClose, prevMA60, latestClose, ma60)) {
addAlert('above_ma60', '中国银行-站上60日线,需卖出 40%', '中国银行-站上60日线,需卖出 40%')
}
if (isCrossUp(prevClose, prevMA90, latestClose, ma90)) {
addAlert('above_ma90', '中国银行-站上90日线,需卖出剩余 40%', '中国银行-站上90日线,需卖出剩余 40%')
}
if (ma10 > ma20) {
addAlert('trend_bull', '趋势提示', '中国银行处于上涨趋势,建议持有底仓 + 已建波段仓,不做 T')
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const symbol = config.symbol
const sourceSymbol = config.source_symbol || 'sh601988'
const klineData = await fetchKLine(axios, sourceSymbol)
if (!klineData || klineData.length < 250) {
logJSON('skill.zhonghang_299.error', { message: 'Insufficient data', length: klineData ? klineData.length : 0 }, 'market_skills')
return null
}
const closes = klineData.map(d => d.close)
const volumes = klineData.map(d => d.volume)
const latestClose = closes[closes.length - 1]
const prevClose = closes[closes.length - 2]
const prevPrevClose = closes[closes.length - 3]
const latestDate = klineData[klineData.length - 1].date
const ma10 = calculateMA(closes, 10)
const ma20 = calculateMA(closes, 20)
const ma60 = calculateMA(closes, 60)
const ma90 = calculateMA(closes, 90)
const ma250 = calculateMA(closes, 250)
const closesPrev = closes.slice(0, -1)
const prevMA20 = calculateMA(closesPrev, 20)
const prevMA60 = calculateMA(closesPrev, 60)
const prevMA90 = calculateMA(closesPrev, 90)
const prevMA250 = calculateMA(closesPrev, 250)
const boxWindowRaw = parseInt(String(config.box_window || 30), 10)
const boxWindow = [20, 30, 40].includes(boxWindowRaw) ? boxWindowRaw : 30
const confirmDaysRaw = parseInt(String(config.box_confirm_days || 2), 10)
const confirmDays = confirmDaysRaw >= 2 ? 2 : 1
const volumeMAWindowRaw = parseInt(String(config.box_volume_ma_window || 5), 10)
const volumeMAWindow = volumeMAWindowRaw > 0 ? volumeMAWindowRaw : 5
const volumeExpandRatioRaw = parseFloat(String(config.box_volume_expand_ratio || 1.2))
const volumeExpandRatio = Number.isFinite(volumeExpandRatioRaw) && volumeExpandRatioRaw > 0 ? volumeExpandRatioRaw : 1.2
if (!ma10 || !ma20 || !ma60 || !ma90 || !ma250 || !prevMA20 || !prevMA60 || !prevMA90 || !prevMA250) {
logJSON('skill.zhonghang_299.error', { message: 'MA calculation failed' }, 'market_skills')
return null
}
if (!Number.isFinite(prevPrevClose) || closes.length < boxWindow + 2) {
logJSON('skill.zhonghang_299.error', { message: 'Insufficient data for box', length: closes.length, boxWindow }, 'market_skills')
return null
}
const change = latestClose - prevClose
const changePercent = (change / prevClose) * 100
const trendText = ma10 > ma20 ? '多头排列' : '非多头'
const yearLineText = latestClose < ma250 ? '年线下方' : '年线之上'
const isCrossDown = (prevC, prevMa, currC, currMa) => prevC >= prevMa && currC < currMa
const isCrossUp = (prevC, prevMa, currC, currMa) => prevC <= prevMa && currC > currMa
const isBelowMA250 = latestClose < ma250
const isMA250SlopeUp = ma250 >= prevMA250
const isMA60SlopeUp = ma60 >= prevMA60
const latestIndex = closes.length - 1
const prevIndex = closes.length - 2
const getBoxUpper = idx => Math.max(...closes.slice(idx - boxWindow, idx))
const getBoxLower = idx => Math.min(...closes.slice(idx - boxWindow, idx))
const upperPrev = getBoxUpper(prevIndex)
const upperLatest = getBoxUpper(latestIndex)
const lowerPrev = getBoxLower(prevIndex)
const lowerLatest = getBoxLower(latestIndex)
const prevVolume = volumes[prevIndex]
const latestVolume = volumes[latestIndex]
const prevVolumeMA = calculateTrailingMA(volumes, prevIndex, volumeMAWindow)
const latestVolumeMA = calculateTrailingMA(volumes, latestIndex, volumeMAWindow)
const breakoutDayPrev = prevPrevClose <= upperPrev && prevClose > upperPrev
const breakdownDayPrev = prevPrevClose >= lowerPrev && prevClose < lowerPrev
const breakoutVolumeOkPrev = Number.isFinite(prevVolumeMA) ? prevVolume > prevVolumeMA * volumeExpandRatio : false
const breakoutDayLatest = prevClose <= upperLatest && latestClose > upperLatest
const breakoutVolumeOkLatest = Number.isFinite(latestVolumeMA) ? latestVolume > latestVolumeMA * volumeExpandRatio : false
const boxBreakoutConfirmed = confirmDays === 2
? breakoutDayPrev && latestClose > upperLatest && breakoutVolumeOkPrev
: breakoutDayLatest && breakoutVolumeOkLatest
const boxBreakdownConfirmed = confirmDays === 2
? breakdownDayPrev && latestClose < lowerLatest
: prevClose >= lowerLatest && latestClose < lowerLatest
const isTrendGateOpen = latestClose > ma250 && isMA250SlopeUp && isMA60SlopeUp && boxBreakoutConfirmed
const crossDownMA20 = isCrossDown(prevClose, prevMA20, latestClose, ma20)
const crossDownMA60 = isCrossDown(prevClose, prevMA60, latestClose, ma60)
const crossDownMA90 = isCrossDown(prevClose, prevMA90, latestClose, ma90)
const crossUpMA20 = isCrossUp(prevClose, prevMA20, latestClose, ma20)
const crossUpMA60 = isCrossUp(prevClose, prevMA60, latestClose, ma60)
const crossUpMA90 = isCrossUp(prevClose, prevMA90, latestClose, ma90)
const crossUpMA250 = isCrossUp(prevClose, prevMA250, latestClose, ma250)
const statusDownMA20 = isTrendGateOpen ? (crossDownMA20 ? '已触发(加仓20%)' : '未触发') : '暂停(未满足: 收盘>MA250 且 MA250/MA60斜率≥0 且 箱体上沿突破确认)'
const statusDownMA60 = isTrendGateOpen ? (crossDownMA60 ? '已触发(加仓40%)' : '未触发') : '暂停(未满足: 收盘>MA250 且 MA250/MA60斜率≥0 且 箱体上沿突破确认)'
const statusDownMA90 = isTrendGateOpen ? (crossDownMA90 ? '已触发(加仓剩余40%)' : '未触发') : '暂停(未满足: 收盘>MA250 且 MA250/MA60斜率≥0 且 箱体上沿突破确认)'
const statusUpMA20 = crossUpMA20 ? '已触发(卖出20%)' : '未触发'
const statusUpMA60 = crossUpMA60 ? '已触发(卖出40%)' : '未触发'
const statusUpMA90 = crossUpMA90 ? '已触发(卖出剩余40%)' : '未触发'
const statusTrend = ma10 > ma20 ? '已满足(上涨趋势)' : '未满足'
const statusMA250 = isBelowMA250 ? '已触发(停止加仓/建议减仓)' : (crossUpMA250 ? '已触发(恢复加仓)' : '未触发')
const statusBoxBreakout = boxBreakoutConfirmed ? '已确认(连续2日 + 突破日放量)' : '未确认'
const statusBoxBreakdown = boxBreakdownConfirmed ? '已确认(连续2日)' : '未确认'
const statusBoxRange = `上沿${upperLatest.toFixed(2)} / 下沿${lowerLatest.toFixed(2)} (N=${boxWindow})`
const breakoutDate = confirmDays === 2
? (breakoutDayPrev ? klineData[prevIndex].date : '—')
: (breakoutDayLatest ? klineData[latestIndex].date : '—')
const breakoutConfirmDate = boxBreakoutConfirmed ? klineData[latestIndex].date : '—'
const breakoutVolumeRatio = confirmDays === 2
? (Number.isFinite(prevVolumeMA) && prevVolumeMA > 0 ? prevVolume / prevVolumeMA : null)
: (Number.isFinite(latestVolumeMA) && latestVolumeMA > 0 ? latestVolume / latestVolumeMA : null)
const breakoutVolumeRatioText = Number.isFinite(breakoutVolumeRatio) ? `${breakoutVolumeRatio.toFixed(2)}x` : '—'
const breakoutVolumeGateText = `${volumeExpandRatio.toFixed(2)}x`
const htmlPayload = `
<div class="zh299-wrap">
<div class="zh299-price">价格:${latestClose.toFixed(2)} (${change >= 0 ? '+' : ''}${change.toFixed(2)} / ${changePercent.toFixed(2)}%)</div>
<div class="zh299-grid">
<div class="zh299-block">
<div class="zh299-title">均线快照</div>
<div class="zh299-row">MA10:${ma10.toFixed(2)} | MA20:${ma20.toFixed(2)} | MA60:${ma60.toFixed(2)}</div>
<div class="zh299-row">MA90:${ma90.toFixed(2)} | MA250:${ma250.toFixed(2)}</div>
<div class="zh299-row">趋势:${trendText} | 年线状态:${yearLineText}</div>
<div class="zh299-row">箱体:${statusBoxRange}</div>
</div>
<div class="zh299-block">
<div class="zh299-title">299执行面板</div>
<div class="zh299-row">20日线跌破:${statusDownMA20} | 站上:${statusUpMA20}</div>
<div class="zh299-row">60日线跌破:${statusDownMA60} | 站上:${statusUpMA60}</div>
<div class="zh299-row">90日线跌破:${statusDownMA90} | 站上:${statusUpMA90}</div>
<div class="zh299-row">MA10>MA20:${statusTrend} | MA250:${statusMA250}</div>
<div class="zh299-row">箱体上沿突破:${statusBoxBreakout}</div>
<div class="zh299-row">突破日:${breakoutDate} | 确认日:${breakoutConfirmDate}</div>
<div class="zh299-row">突破日量比:${breakoutVolumeRatioText} | 放量门槛:${breakoutVolumeGateText}</div>
<div class="zh299-row">箱体下沿跌破:${statusBoxBreakdown}</div>
</div>
</div>
</div>
`
const data = [{
label: config.name,
symbol: symbol,
displaySymbol: sourceSymbol,
isMaCard: true,
render_type: 'zhonghang_299_html',
html: htmlPayload,
card_custom_class: config.card_custom_class || 'card-zhonghang-299',
ma10: ma10.toFixed(2),
ma20: ma20.toFixed(2),
ma60: ma60.toFixed(2),
ma90: ma90.toFixed(2),
ma250: ma250.toFixed(2),
source: config.source || 'tencent',
name: config.name,
price: latestClose,
prevClose: prevClose,
change: change,
change_percent: changePercent,
market_status: `${trendText} (${latestDate})`
}]
const alerts = buildAlerts({
latestClose,
prevClose,
ma10,
ma20,
ma60,
ma90,
ma250,
prevMA20,
prevMA60,
prevMA90,
prevMA250,
isTrendGateOpen,
boxBreakoutConfirmed,
boxBreakdownConfirmed
})
return { data, alerts }
}
}
+228
View File
@@ -0,0 +1,228 @@
/**
* market_tts.js - 独立 TTS 模块(阿里云 NLS 语音合成)
* 不依赖 calendar_voice.js,markets 工具可独立部署
*/
const axios = require('axios')
const Core = require('@alicloud/pop-core')
const path = require('path')
const fs = require('fs')
// 阿里云 NLS 配置(从环境变量读取,见 ~/Toolbox_local_creds.env.local)
const ALIYUN_CONFIG = {
accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID || '',
accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET || '',
appKey: process.env.ALIYUN_APPKEY || '',
endpoint: 'https://nls-meta.cn-shanghai.aliyuncs.com',
apiVersion: '2019-02-28'
}
// 可用中文声音
const VOICES = {
xiaoyun: 'Xiaoyun', // 标准女声
xiaogang: 'Xiaogang', // 标准男声
zhixuan: 'Zhixuan', // 自然女声(推荐)
zhiying: 'Zhiying' // 自然男声
}
// 每段最大字符数(阿里云 TTS 建议不超过 500)
const MAX_SEGMENT_CHARS = 450
// 缓存 Token
let cachedToken = null
let tokenExpire = 0
const getToken = async () => {
const now = Date.now() / 1000
if (cachedToken && now < tokenExpire) return cachedToken
try {
const client = new Core({
accessKeyId: ALIYUN_CONFIG.accessKeyId,
accessKeySecret: ALIYUN_CONFIG.accessKeySecret,
endpoint: ALIYUN_CONFIG.endpoint,
apiVersion: ALIYUN_CONFIG.apiVersion
})
const result = await client.request('CreateToken', {}, { method: 'POST', formatParams: false })
if (result.Token && result.Token.Id) {
cachedToken = result.Token.Id
tokenExpire = result.Token.ExpireTime
return cachedToken
}
} catch (e) {
console.error('[MarketTTS] Get token failed:', e.message)
}
return null
}
/**
* 文字转语音,返回 WAV 音频 Buffer
* @param {string} text - 要朗读的文字
* @param {object} [options]
* @param {string} [options.voice] - 声音名称
* @param {number} [options.sample_rate] - 采样率
* @param {number} [options.volume] - 音量 0~100
* @param {number} [options.speech_rate] - 语速 -500~500
* @param {number} [options.pitch_rate] - 音调 -500~500
* @returns {Promise<Buffer>} WAV 音频数据
*/
const synthesize = async (text, options = {}) => {
if (!text || !text.trim()) throw new Error('Text is empty')
const token = await getToken()
if (!token) throw new Error('Failed to get NLS token')
const voice = options.voice || 'Zhixuan'
const sampleRate = options.sample_rate || 16000
const volume = options.volume ?? 50
const speechRate = options.speech_rate ?? 0
const pitchRate = options.pitch_rate ?? 0
const url = `https://nls-gateway.cn-shanghai.aliyuncs.com/stream/v1/tts?appkey=${ALIYUN_CONFIG.appKey}`
const payload = {
text: text.trim(),
format: 'wav',
sample_rate: sampleRate,
voice,
volume,
speech_rate: speechRate,
pitch_rate: pitchRate
}
const response = await axios.post(url, payload, {
headers: {
'X-NLS-Token': token,
'Content-Type': 'application/json'
},
responseType: 'arraybuffer',
timeout: 30000
})
const ct = String(response.headers['content-type'] || '').toLowerCase()
if (ct.includes('audio/')) {
return Buffer.from(response.data)
}
try {
const errBody = JSON.parse(Buffer.from(response.data).toString('utf-8'))
throw new Error(errBody.message || `TTS failed: ${response.status}`)
} catch (parseErr) {
if (parseErr.message && !parseErr.message.includes('parse')) throw parseErr
throw new Error(`TTS failed: HTTP ${response.status}`)
}
}
/**
* 将文本按标点符号分成适合 TTS 的段
*/
const splitText = (text, maxLen = MAX_SEGMENT_CHARS) => {
const segments = []
let remaining = text.trim()
while (remaining.length > 0) {
if (remaining.length <= maxLen) {
segments.push(remaining)
break
}
// 从 maxLen 位置往回找最近的句号/问号/感叹号/换行
let cut = remaining.lastIndexOf('。', maxLen)
if (cut <= 0) cut = remaining.lastIndexOf('?', maxLen)
if (cut <= 0) cut = remaining.lastIndexOf('!', maxLen)
if (cut <= 0) cut = remaining.lastIndexOf('\n', maxLen)
if (cut <= 0) cut = remaining.lastIndexOf(',', maxLen)
if (cut <= 0) cut = remaining.lastIndexOf(';', maxLen)
if (cut <= 0) cut = maxLen // 找不到就硬切
segments.push(remaining.slice(0, cut + 1).trim())
remaining = remaining.slice(cut + 1).trim()
}
return segments.filter(s => s.length > 0)
}
/**
* 合并多个 WAV Buffer 为一个(标准 16-bit PCM,同格式)
*/
const mergeWavBuffers = (buffers) => {
if (buffers.length === 0) throw new Error('No audio buffers to merge')
if (buffers.length === 1) return buffers[0]
// 标准 WAV PCM 头 44 字节
const HEADER_SIZE = 44
const chunks = buffers.map(buf => buf.slice(HEADER_SIZE))
const totalDataSize = chunks.reduce((sum, c) => sum + c.length, 0)
// 用第一个段的头为基础
const header = Buffer.from(buffers[0])
// RIFF 文件大小(字节 4-7)= 总大小 - 8
const fileSize = HEADER_SIZE + totalDataSize
header.writeUInt32LE(fileSize - 8, 4)
// data chunk 大小(字节 40-43)
header.writeUInt32LE(totalDataSize, 40)
return Buffer.concat([header, ...chunks])
}
/**
* 生成整章语音并保存到文件
* @param {string} text - 整章文本
* @param {string} voice - 声音名称
* @param {function} onLog - 日志回调 (msg, type) => void
* @returns {Promise<{filePath: string, duration: number}>} 保存路径和时长
*/
const generateChapter = async (text, voice = 'Zhixuan', onLog = () => {}) => {
const log = (msg, type = 'info') => {
const ts = new Date().toLocaleTimeString('zh-CN', { hour12: false })
const line = `[${ts}] ${msg}`
console.log('[MarketTTS]', line)
onLog(line, type)
}
// 1. 分段
const segments = splitText(text)
log(`文本已分段:共 ${segments.length} 段,总 ${text.length} 字`)
// 2. 获取 Token
log('正在获取阿里云 TTS Token...')
const token = await getToken()
if (!token) { log('获取 Token 失败!请检查阿里云配置', 'error'); throw new Error('Token failed') }
log('Token 获取成功')
// 3. 逐段合成
const buffers = []
for (let i = 0; i < segments.length; i++) {
const segText = segments[i]
log(`正在合成第 ${i + 1}/${segments.length} 段(${segText.length} 字)...`)
try {
const buf = await synthesize(segText, { voice })
buffers.push(buf)
const secs = ((buf.length - 44) / 16000 / 2).toFixed(1) // 16-bit mono 16kHz
log(`第 ${i + 1}/${segments.length} 段合成成功(约 ${secs}s)`)
} catch (e) {
log(`第 ${i + 1}/${segments.length} 段合成失败:${e.message}`, 'error')
throw e
}
}
// 4. 合并
log('正在合并音频片段...')
const merged = mergeWavBuffers(buffers)
const totalSecs = ((merged.length - 44) / 16000 / 2).toFixed(1)
log(`合并完成:总时长约 ${totalSecs}s,文件大小 ${(merged.length / 1024 / 1024).toFixed(1)}MB`)
return { buffer: merged, duration: parseFloat(totalSecs) }
}
/**
* 保存 WAV 文件到公共目录
* @param {Buffer} buffer - WAV 音频数据
* @param {string} fileName - 文件名(如 ch_00.wav)
* @returns {string} 保存路径
*/
const saveAudioFile = (buffer, fileName) => {
const audioDir = path.join(process.cwd(), 'public', 'tools', 'markets', 'audio')
if (!fs.existsSync(audioDir)) fs.mkdirSync(audioDir, { recursive: true })
const filePath = path.join(audioDir, fileName)
fs.writeFileSync(filePath, buffer)
return '/tools/markets/audio/' + fileName
}
module.exports = { synthesize, VOICES, generateChapter, saveAudioFile, splitText }
+854
View File
@@ -0,0 +1,854 @@
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 = ` <span style="margin-left:8px;color:${color}">● ${status.text} (北时 ${status.label})</span>`;
}
// Link Logic
const linkUrl = r.url || getSymbolUrl(r.symbol);
const linkHtml = `<a href="${linkUrl}" target="_blank" title="查看详情" style="margin-left:8px">🏠</a>`;
// Alert Flag
const alertHtml = isAlert ? `<span class="alert-flag">⚠ 告警</span>` : '';
const price = Number.isFinite(r.price) ? r.price : '0.00';
const change = fmtSign(move.change);
cardsHTML += `
<div class="${cardCls}">
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div>${r.label} · ${r.name || ''} (${r.symbol}) ${alertHtml}</div>
${linkHtml}
</div>
<div class="price-display" style="font-size:16px;font-weight:600">
价格 <span style="font-weight:700">${price}</span>
涨跌 <span class="${changeClass}">${change}</span>
(<span class="${changeClass}" style="padding:2px 6px;border-radius:4px;background:${move.sign>=0?'#fff2f0':'#f6ffed'};border:1px solid ${move.sign>=0?'#ffccc7':'#b7eb8f'}">${fmtSign(changePercent)}%</span>)
</div>
<div class="meta">来源 ${r.source || ''} · 时间 ${r.captured_at || ''}${statusHtml}</div>
</div>
`;
}
const title = `市场封存 · ${date}`;
const time = fmtCNDateTimeSrv(new Date());
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${title}</title><style>${css}</style></head><body><h3>${title}</h3><div class="grid">${cardsHTML}</div><div class="meta" style="margin-top:16px">生成时间 ${time}</div></body></html>`;
}
const isWeekday = d => { const day = d.getDay(); return day >= 1 && day <= 5 }
let schedulerStarted = false
let lastArchiveSavedDay = ''
let lastNotifySlotKey = ''
const notifyStateRow = getMarketState.get('notify_slot')
if (notifyStateRow && notifyStateRow.value) {
lastNotifySlotKey = String(notifyStateRow.value)
}
const startScheduler = () => {
if (schedulerStarted) return
schedulerStarted = true
setInterval(async () => {
try {
const nowParts = fmtCNParts()
const hour = nowParts.hour
const minute = nowParts.minute
const dateStr = nowParts.dateStr
const parts = String(dateStr || '').split('-').map(x => parseInt(x, 10))
const workdayDate = parts.length === 3 && parts.every(n => Number.isFinite(n)) ? new Date(parts[0], parts[1] - 1, parts[2]) : new Date()
const weekday = isWeekday(workdayDate)
let slot = ''
if (hour >= 10 && hour < 12) slot = 'am'
else if (hour >= 13 && hour <= 15) slot = 'pm'
if (weekday && slot) {
const notifySlotKey = `${dateStr}-${slot}`
if (lastNotifySlotKey !== notifySlotKey) {
const { quotes } = await fetchAll()
const notified = await maybeNotify(quotes)
if (notified) {
lastNotifySlotKey = notifySlotKey
upsertMarketState.run({ key: 'notify_slot', value: notifySlotKey })
logJSON('markets.scheduler.notify', { date: dateStr, slot }, 'markets')
}
}
}
if (hour === 15 && minute >= 0 && minute <= 30) {
if (lastArchiveSavedDay !== dateStr) {
const existed = getMarketArchive.get(dateStr)
if (!existed) {
const { quotes } = await fetchAll()
// #region debug-point C:auto-archive-before-save
;(() => { 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 = (quotes || []).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: 'C', location: 'markets.js:startScheduler:auto-archive', msg: '[DEBUG] markets auto archive before save', data: { day: dateStr, quotes_count: Array.isArray(quotes) ? quotes.length : 0, overseas_count: overseas.length, overseas_symbols: overseas.map(item => item.symbol) }, ts: Date.now() }) }).catch(() => {}) } catch {} })()
// #endregion
const html = buildArchiveHtml(dateStr, quotes)
upsertMarketArchive.run({ day: dateStr, html })
lastArchiveSavedDay = dateStr
logJSON('markets.archive.auto.save', { day: dateStr, html_len: html.length }, 'markets')
} else {
lastArchiveSavedDay = dateStr
logJSON('markets.archive.auto.skip', { day: dateStr, reason: 'already_saved' }, 'markets')
}
}
}
} catch (e) {
logJSON('markets.scheduler.error', { error: String(e.message || e) }, 'markets')
}
}, 10 * 60 * 1000)
}
const sendTestEmail = async () => {
const cfg = getConfig()
assertMarketsConfigInvariant(cfg)
if (!cfg.email || !cfg.email.enabled) throw new Error('Email not enabled')
const date = fmtCNParts().dateStr
const rows = queryByDate.all(date)
const subject = `市场通知测试 ${date}`
const lines = rows.map(r => `${r.label} ${r.symbol} src ${r.source} ${isFinite(r.change_percent) ? r.change_percent.toFixed(2) : '0.00'}% price ${r.price}`)
const watchlist = Array.isArray(cfg.watchlist) ? cfg.watchlist : []
const monitorText = cfg.skills_only_mode !== false
? 'skill_mode (src/server/market_skills)'
: watchlist.map(w => `${w.label}:${w.symbol}`).join(', ')
const body = [`邮件通知测试 ${fmtCNDateTimeSrv(new Date())}`, `监控项: ${monitorText}`, `当日记录: ${rows.length}`, ...lines].join('\n')
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_test', alert_id: subject, subject, status: 'sent', error_message: '' }) } catch {}
} catch (e) {
const errMsg = String(e.message || e)
try { insertEmailLog.run({ skill: 'markets_test', alert_id: subject, subject, status: 'failed', error_message: errMsg }) } catch {}
throw e
}
}
module.exports = { fetchAll, queryByDate, getConfig, setConfig, startScheduler, sendTestEmail, maybeNotify }
+899
View File
@@ -0,0 +1,899 @@
const axios = require('axios')
const iconv = require('iconv-lite')
const fs = require('fs')
const path = require('path')
const { logJSON } = require('./logger')
const sinaMapSymbol = 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 === 'gb_ixic') return 'gb_ixic'
if (x === 'inx' || x === '.inx' || x === 'gb_inx') return 'gb_inx'
if (x === 'hsi.hk' || x === 'hsi' || x === 'hk_hsi' || x === 'hkhsi') return 'rt_hkHSI'
if (x === '03153.hk' || x === 'hk03153' || x === '03153') return 'rt_hk03153'
if (x === 'inda' || x === 'amex:inda' || x === 'us_inda' || x === 'gb_inda') return 'gb_inda'
if (x === 'ewg' || x === 'nysearca:ewg' || x === 'us_ewg' || x === 'gb_ewg') return 'gb_ewg'
return s
}
const parseSinaLine = line => {
const m = line.match(/^var\s+hq_str_(\w+)=\"([^\"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
const parts = body.split(',')
if (symbol.startsWith('gb_')) {
const name = parts[0] || symbol
const price = parseFloat(parts[1] || '0')
// find percent in any field
const pctField = parts.find(p => /%/.test(p)) || ''
const pctMatch = String(pctField).match(/(-?\d+(?:\.\d+)?)%/)
const pct = pctMatch ? parseFloat(pctMatch[1]) : 0
let prevClose = 0, change = 0
if (pct !== 0 && price) { prevClose = price / (1 + pct / 100); change = price - prevClose }
return { symbol, name, price, prevClose, change, change_percent: pct }
}
if (symbol.startsWith('hk')) {
const name = parts[0] || symbol
const price = parseFloat(parts[6] || parts[1] || '0')
const prevClose = parseFloat(parts[3] || parts[2] || '0')
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
return { symbol, name, price, prevClose, change, change_percent: pct }
}
const name = parts[0] || ''
const price = parseFloat(parts[3] || parts[1] || '0')
const prevClose = parseFloat(parts[2] || '0')
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
return { symbol, name, price, prevClose, change, change_percent: pct }
}
const fetchSina = async symbols => {
if (!symbols || symbols.length === 0) return []
const chunkSize = 6
const outMap = new Map()
for (let i = 0; i < symbols.length; i += chunkSize) {
const chunk = symbols.slice(i, i + chunkSize).map(sinaMapSymbol)
const list = chunk.join(',')
const url = `http://hq.sinajs.cn/list=${list}`
logJSON('markets.http.get', { source: 'sina', url, symbols: chunk, chunk_index: Math.floor(i / chunkSize), chunk_size: chunk.length }, 'markets')
try {
const res = await axios.get(url, {
headers: {
'Referer': 'https://finance.sina.com.cn/',
'User-Agent': 'Mozilla/5.0',
'Accept-Language': 'zh-CN,zh;q=0.9'
},
responseType: 'arraybuffer',
timeout: 10000,
validateStatus: () => true
})
if (!res || res.status >= 400) {
logJSON('markets.http.status', { source: 'sina', url, status: res && res.status, chunk_index: Math.floor(i / chunkSize) }, 'markets')
continue
}
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
let parsedCount = 0
for (const line of lines) {
const r = parseSinaLine(line)
if (r) {
parsedCount += 1
outMap.set(r.symbol, { ...r, source: 'sina' })
}
}
logJSON('markets.http.parse', { source: 'sina', chunk_index: Math.floor(i / chunkSize), count: parsedCount }, 'markets')
} catch (e) {
logJSON('markets.http.error', { source: 'sina', url, chunk_index: Math.floor(i / chunkSize), error: String(e.message || e) }, 'markets')
}
}
return Array.from(outMap.values())
}
const marketsConfigPath = path.join(process.cwd(), 'config', 'markets.json')
const yahooProbeState = {
quoteCache: new Map(),
chartCache: new Map(),
quotePendingSymbols: new Set(),
quotePendingWaiters: [],
quoteFlushTimer: null,
quoteFlushRunning: false,
chartInflight: new Map(),
lastRequestAt: { quote: 0, chart: 0 },
finnhubLastRequestAt: 0,
finnhubRequestTimes: []
}
const YAHOO_PROBE_SYMBOL_ALIAS = Object.freeze({
usINX: '^GSPC',
usIXIC: '^IXIC',
usDJI: '^DJI',
hkHSI: '^HSI',
zsN225: '^N225',
zsGDAXI: '^GDAXI',
zstwii: '^TWII',
zskospi: '^KS11',
zsBVSP: '^BVSP',
zsSTI: '^STI',
usEWG: 'EWG',
usFLSA: 'FLSA',
usNFTY: 'NFTY',
usREMX: 'REMX',
usEWJ: 'EWJ',
usEWY: 'EWY',
usEWZ: 'EWZ',
usASEA: 'ASEA',
usEWT: 'EWT',
usINDA: 'INDA',
hf_GC: 'GC=F',
hf_SI: 'SI=F'
})
const FINNHUB_TIMEZONE_ALIAS = Object.freeze({
'^GSPC': 'America/New_York',
'^IXIC': 'America/New_York',
'^DJI': 'America/New_York',
'^HSI': 'Asia/Hong_Kong',
'^N225': 'Asia/Tokyo',
'^GDAXI': 'Europe/Berlin',
'^TWII': 'Asia/Taipei',
'^KS11': 'Asia/Seoul',
'^BVSP': 'America/Sao_Paulo',
'^STI': 'Asia/Singapore'
})
const FINNHUB_DEFAULT_TZ = 'America/New_York'
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
const toFiniteNumber = value => {
const n = Number(value)
return Number.isFinite(n) ? n : null
}
const pushYahooTrace = (trace, stage, details = {}) => {
if (!Array.isArray(trace)) return
trace.push({
time: new Date().toISOString(),
stage,
...details
})
}
const readMarketsConfig = () => {
try {
if (!fs.existsSync(marketsConfigPath)) return {}
return JSON.parse(fs.readFileSync(marketsConfigPath, 'utf-8'))
} catch {
return {}
}
}
const getYahooProbeConfig = () => {
const cfg = readMarketsConfig()
const raw = cfg && typeof cfg.yahoo_probe === 'object' ? cfg.yahoo_probe : {}
const baseUrl = String(raw.base_url || 'http://47.252.30.127:19071').trim().replace(/\/$/, '')
return {
enabled: raw.enabled !== false,
base_url: baseUrl || 'http://47.252.30.127:19071',
token: String(raw.token || '').trim(),
quote_endpoint: String(raw.quote_endpoint || raw.endpoint || '/probe/yahoo').trim() || '/probe/yahoo',
chart_endpoint: String(raw.chart_endpoint || raw.endpoint || '/probe/yahoo').trim() || '/probe/yahoo',
finnhub_quote_endpoint: String(raw.finnhub_quote_endpoint || '/probe/finnhub/quote').trim() || '/probe/finnhub/quote',
finnhub_candle_endpoint: String(raw.finnhub_candle_endpoint || '/probe/finnhub/candle').trim() || '/probe/finnhub/candle',
request_timeout_ms: Math.max(3000, Number(raw.request_timeout_ms || 15000)),
quote_ttl_seconds: Math.max(15, Number(raw.quote_ttl_seconds || 120)),
chart_ttl_seconds: Math.max(300, Number(raw.chart_ttl_seconds || 21600)),
quote_batch_size: Math.max(1, Math.min(20, Number(raw.quote_batch_size || 4))),
quote_min_interval_ms: Math.max(0, Number(raw.quote_min_interval_ms || raw.min_request_interval_ms || 2500)),
chart_min_interval_ms: Math.max(0, Number(raw.chart_min_interval_ms || raw.min_request_interval_ms || 2500)),
finnhub_min_interval_ms: Math.max(1200, Number(raw.finnhub_min_interval_ms || 1300)),
finnhub_max_requests_per_minute: Math.max(1, Math.min(55, Number(raw.finnhub_max_requests_per_minute || 50)))
}
}
const getYahooProbeCache = (cache, key) => {
const hit = cache.get(key)
if (!hit) return null
if (Number(hit.expires_at || 0) <= Date.now()) {
cache.delete(key)
return null
}
return hit.value
}
const setYahooProbeCache = (cache, key, value, ttlSeconds) => {
cache.set(key, {
value,
expires_at: Date.now() + Math.max(1, ttlSeconds) * 1000
})
}
const getYahooProbeSymbol = symbol => {
const raw = String(symbol || '').trim()
if (!raw) return ''
if (YAHOO_PROBE_SYMBOL_ALIAS[raw]) return YAHOO_PROBE_SYMBOL_ALIAS[raw]
if (/^us[A-Z][A-Z0-9._-]+$/.test(raw)) return raw.slice(2)
return raw
}
const getFinnhubSymbol = symbol => getYahooProbeSymbol(symbol)
const getFinnhubTimeZone = symbol => {
const key = String(symbol || '').trim()
return FINNHUB_TIMEZONE_ALIAS[key] || FINNHUB_DEFAULT_TZ
}
const sleepUntilNextMinuteWindow = async cfg => {
const windowMs = 60 * 1000
const now = Date.now()
yahooProbeState.finnhubRequestTimes = yahooProbeState.finnhubRequestTimes.filter(ts => (now - ts) < windowMs)
const maxPerMinute = Math.max(1, Number(cfg.finnhub_max_requests_per_minute || 50))
if (yahooProbeState.finnhubRequestTimes.length < maxPerMinute) return
const oldest = Number(yahooProbeState.finnhubRequestTimes[0] || 0)
const waitMs = Math.max(0, windowMs - (now - oldest) + 50)
if (waitMs > 0) await sleep(waitMs)
}
const throttleFinnhubProbe = async (cfg, trace, reason) => {
await sleepUntilNextMinuteWindow(cfg)
const minInterval = Math.max(0, Number(cfg.finnhub_min_interval_ms || 1300))
const waitMs = Math.max(0, minInterval - (Date.now() - Number(yahooProbeState.finnhubLastRequestAt || 0)))
if (waitMs > 0) {
pushYahooTrace(trace, 'finnhub.throttle_wait', { wait_ms: waitMs, reason: String(reason || '') })
await sleep(waitMs)
}
const now = Date.now()
yahooProbeState.finnhubLastRequestAt = now
yahooProbeState.finnhubRequestTimes.push(now)
yahooProbeState.finnhubRequestTimes = yahooProbeState.finnhubRequestTimes.filter(ts => (now - ts) < 60 * 1000)
}
const shiftDateByRange = (date, amount, unit) => {
const next = new Date(date.getTime())
if (unit === 'd') next.setUTCDate(next.getUTCDate() - amount)
if (unit === 'mo') next.setUTCMonth(next.getUTCMonth() - amount)
if (unit === 'y') next.setUTCFullYear(next.getUTCFullYear() - amount)
return next
}
const convertYahooRangeToUnixWindow = range => {
const now = new Date()
const text = String(range || '1y').trim().toLowerCase()
let fromDate = shiftDateByRange(now, 1, 'y')
if (text === 'ytd') {
fromDate = new Date(Date.UTC(now.getUTCFullYear(), 0, 1, 0, 0, 0))
} else {
const match = text.match(/^(\d+)(d|mo|y)$/)
if (match) {
fromDate = shiftDateByRange(now, Number(match[1] || 1), match[2])
}
}
return {
from: Math.max(0, Math.floor(fromDate.getTime() / 1000)),
to: Math.max(0, Math.floor(now.getTime() / 1000))
}
}
const mapYahooIntervalToFinnhubResolution = interval => {
const text = String(interval || '1d').trim().toLowerCase()
if (text === '1d' || text === '1day' || text === 'd') return 'D'
if (text === '1wk' || text === '1w' || text === 'wk' || text === 'w') return 'W'
if (text === '1mo' || text === '1mth' || text === 'mo' || text === 'm') return 'M'
if (text === '60m' || text === '1h' || text === '60') return '60'
if (text === '30m' || text === '30') return '30'
if (text === '15m' || text === '15') return '15'
if (text === '5m' || text === '5') return '5'
if (text === '1m' || text === '1') return '1'
return 'D'
}
const scheduleYahooQuoteFlush = () => {
if (yahooProbeState.quoteFlushTimer) return
yahooProbeState.quoteFlushTimer = setTimeout(() => {
yahooProbeState.quoteFlushTimer = null
flushYahooQuoteQueue().catch(() => {})
}, 15)
}
const requestYahooQuoteBatch = async (batch, cfg) => {
const mappings = batch.map(original => ({ original, yahoo: getYahooProbeSymbol(original) }))
await throttleYahooProbe('quote', cfg, null)
const url = `${cfg.base_url.replace(/\/$/, '')}${cfg.quote_endpoint}`
// #region debug-point D:quote-batch-request
;(() => { 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 {} fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: s, runId: 'post-fix', hypothesisId: 'D', location: 'marketsClient.js:requestYahooQuoteBatch:request', msg: '[DEBUG] yahoo quote batch request', data: { url, batch, yahoo_symbols: mappings.map(item => item.yahoo), batch_size: batch.length }, ts: Date.now() }) }).catch(() => {}) } catch {} })()
// #endregion
logJSON('markets.http.get', {
source: 'yahoo_probe',
url,
symbols: batch,
yahoo_symbols: mappings.map(item => item.yahoo),
batch_size: batch.length
}, 'markets')
const resp = await axios.get(url, {
timeout: cfg.request_timeout_ms,
validateStatus: () => true,
params: {
token: cfg.token,
type: 'quote',
symbols: mappings.map(item => item.yahoo).join(',')
}
})
if (resp && resp.status === 429) {
pushYahooTrace(null, 'yahoo.quote.http_429', { symbols: batch, yahoo_symbols: mappings.map(item => item.yahoo) })
logJSON('markets.http.status', { source: 'yahoo_probe', url, status: 429, symbols: batch, fallback: 'finnhub_probe_quote' }, 'markets')
return requestFinnhubQuoteBatchSerial(mappings, cfg)
}
if (!resp || resp.status >= 400) {
throw new Error(`yahoo_probe_http_${resp && resp.status ? resp.status : 'ERR'}`)
}
if (!resp.data) throw new Error('yahoo_probe_invalid_response')
const rows = parseYahooQuotePayload(resp.data, mappings)
// #region debug-point D:quote-batch-response
;(() => { 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 {} fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: s, runId: 'post-fix', hypothesisId: 'D', location: 'marketsClient.js:requestYahooQuoteBatch:response', msg: '[DEBUG] yahoo quote batch response', data: { batch, status: resp.status, rows_count: rows.length, row_symbols: rows.map(item => item.symbol), row_yahoo_symbols: rows.map(item => item.yahoo_symbol) }, ts: Date.now() }) }).catch(() => {}) } catch {} })()
// #endregion
return rows
}
const requestFinnhubQuoteSingle = async (mapping, cfg) => {
const finnhubSymbol = getFinnhubSymbol(mapping && mapping.yahoo)
if (!finnhubSymbol) throw new Error('finnhub_symbol_required')
await throttleFinnhubProbe(cfg, null, 'quote')
const url = `${cfg.base_url.replace(/\/$/, '')}${cfg.finnhub_quote_endpoint}`
logJSON('markets.http.get', {
source: 'finnhub_probe_quote',
url,
symbol: mapping.original,
yahoo_symbol: mapping.yahoo,
finnhub_symbol: finnhubSymbol
}, 'markets')
const resp = await axios.get(url, {
timeout: cfg.request_timeout_ms,
validateStatus: () => true,
params: {
token: cfg.token,
symbol: finnhubSymbol
}
})
if (!resp || resp.status >= 400) throw new Error(`finnhub_probe_quote_http_${resp && resp.status ? resp.status : 'ERR'}`)
const data = resp.data && typeof resp.data === 'object' ? resp.data : {}
const price = toFiniteNumber(data && data.c)
if (!Number.isFinite(price) || price <= 0) throw new Error('finnhub_probe_quote_invalid_response')
const prevClose = toFiniteNumber(data && data.pc)
const change = toFiniteNumber(data && data.d)
const pct = toFiniteNumber(data && data.dp)
const ts = toFiniteNumber(data && data.t)
return normalizeYahooQuoteItem({
symbol: mapping.original,
yahoo_symbol: mapping.yahoo,
name: mapping.original,
price,
prevClose: prevClose != null ? prevClose : 0,
change: change != null ? change : (prevClose != null ? price - prevClose : 0),
change_percent: pct != null ? pct : (prevClose ? ((price - prevClose) / prevClose) * 100 : 0),
regular_market_time: ts,
exchange_timezone_name: getFinnhubTimeZone(finnhubSymbol),
source: 'finnhub_probe'
}, {
originalSymbol: mapping.original,
yahooSymbol: mapping.yahoo,
source: 'finnhub_probe'
})
}
const requestFinnhubQuoteBatchSerial = async (mappings, cfg) => {
const rows = []
for (const mapping of (Array.isArray(mappings) ? mappings : [])) {
try {
const row = await requestFinnhubQuoteSingle(mapping, cfg)
if (row && row.symbol) rows.push(row)
} catch (error) {
const symbol = String(mapping && mapping.original || '').trim()
const cached = symbol ? getYahooProbeCache(yahooProbeState.quoteCache, symbol) : null
const message = String(error && error.message ? error.message : error)
logJSON('markets.http.error', {
source: 'finnhub_probe_quote',
symbol,
yahoo_symbol: String(mapping && mapping.yahoo || '').trim(),
error: message,
fallback: cached && cached.symbol ? 'quote_cache' : 'skip_symbol'
}, 'markets')
if (cached && cached.symbol) rows.push(cached)
}
}
logJSON('markets.http.parse', { source: 'finnhub_probe_quote', count: rows.length, requested: Array.isArray(mappings) ? mappings.length : 0 }, 'markets')
return rows
}
const flushYahooQuoteQueue = async () => {
if (yahooProbeState.quoteFlushRunning) return
if (yahooProbeState.quotePendingWaiters.length === 0) return
yahooProbeState.quoteFlushRunning = true
try {
while (yahooProbeState.quotePendingWaiters.length > 0) {
const waiters = yahooProbeState.quotePendingWaiters.splice(0)
const pendingSymbols = Array.from(yahooProbeState.quotePendingSymbols)
yahooProbeState.quotePendingSymbols.clear()
// #region debug-point A:quote-flush-begin
;(() => { 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 {} fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: s, runId: 'post-fix', hypothesisId: 'A', location: 'marketsClient.js:flushYahooQuoteQueue:begin', msg: '[DEBUG] yahoo quote flush begin', data: { waiter_count: waiters.length, pending_symbol_count: pendingSymbols.length, pending_symbols: pendingSymbols }, ts: Date.now() }) }).catch(() => {}) } catch {} })()
// #endregion
if (pendingSymbols.length === 0) {
for (const waiter of waiters) waiter.resolve([])
continue
}
const cfg = getYahooProbeConfig()
const batchSize = Math.max(1, Number(cfg.quote_batch_size || 4))
const outMap = new Map()
for (let i = 0; i < pendingSymbols.length; i += batchSize) {
const batch = pendingSymbols.slice(i, i + batchSize)
try {
const rows = await requestYahooQuoteBatch(batch, cfg)
for (const row of rows) {
if (!row || !row.symbol) continue
outMap.set(row.symbol, row)
setYahooProbeCache(yahooProbeState.quoteCache, row.symbol, row, cfg.quote_ttl_seconds)
}
} catch (e) {
// #region debug-point A:quote-batch-error
;(() => { 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 e2 = fs.readFileSync(p, 'utf8'); u = (e2.match(/DEBUG_SERVER_URL=(.+)/) || [])[1] || u; s = (e2.match(/DEBUG_SESSION_ID=(.+)/) || [])[1] || s } catch {} fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: s, runId: 'post-fix', hypothesisId: 'A', location: 'marketsClient.js:flushYahooQuoteQueue:error', msg: '[DEBUG] yahoo quote batch error', data: { batch, error: String(e && e.message ? e.message : e) }, ts: Date.now() }) }).catch(() => {}) } catch {} })()
// #endregion
logJSON('markets.http.error', { source: 'yahoo_probe', symbols: batch, error: String(e.message || e) }, 'markets')
}
}
for (const waiter of waiters) {
waiter.resolve(waiter.symbols.map(symbol => outMap.get(symbol)).filter(Boolean))
}
}
} finally {
yahooProbeState.quoteFlushRunning = false
if (yahooProbeState.quotePendingWaiters.length > 0) scheduleYahooQuoteFlush()
}
}
const enqueueYahooQuoteRequest = symbols => new Promise(resolve => {
const wanted = Array.from(new Set((symbols || []).map(s => String(s || '').trim()).filter(Boolean)))
yahooProbeState.quotePendingWaiters.push({ symbols: wanted, resolve })
for (const symbol of wanted) yahooProbeState.quotePendingSymbols.add(symbol)
scheduleYahooQuoteFlush()
})
const formatDateTime = (date, timeZone) => {
try {
const parts = new Intl.DateTimeFormat('sv-SE', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}).formatToParts(date)
const get = type => parts.find(x => x.type === type)?.value || '00'
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`
} catch {
return ''
}
}
const unixToDateTime = (sec, timeZone) => {
const s = Number(sec)
if (!Number.isFinite(s) || s <= 0) return ''
return formatDateTime(new Date(s * 1000), timeZone || 'America/New_York')
}
const unixToDate = (sec, timeZone) => {
const dt = unixToDateTime(sec, timeZone)
return dt ? dt.slice(0, 10) : ''
}
const approxAmount = (close, volume) => {
const c = toFiniteNumber(close)
const v = toFiniteNumber(volume)
if (!Number.isFinite(c) || !Number.isFinite(v) || c <= 0 || v <= 0) return null
return c * v
}
const throttleYahooProbe = async (kind, cfg, trace) => {
const key = kind === 'chart' ? 'chart' : 'quote'
const minInterval = key === 'chart' ? cfg.chart_min_interval_ms : cfg.quote_min_interval_ms
const lastAt = Number(yahooProbeState.lastRequestAt[key] || 0)
const waitMs = Math.max(0, minInterval - (Date.now() - lastAt))
if (waitMs > 0) {
pushYahooTrace(trace, 'yahoo.throttle_wait', { kind: key, wait_ms: waitMs })
await sleep(waitMs)
}
yahooProbeState.lastRequestAt[key] = Date.now()
}
const normalizeYahooQuoteItem = (item, options = {}) => {
const originalSymbol = String(options.originalSymbol || (item && item.symbol) || '').trim()
const yahooSymbol = String(options.yahooSymbol || (item && item.yahoo_symbol) || (item && item.symbol) || '').trim()
const price = toFiniteNumber(item && (item.price != null ? item.price : item.regularMarketPrice))
const prevClose = toFiniteNumber(item && (item.prevClose != null ? item.prevClose : item.regularMarketPreviousClose))
const change = toFiniteNumber(item && (item.change != null ? item.change : item.regularMarketChange))
const pct = toFiniteNumber(item && (item.change_percent != null ? item.change_percent : item.regularMarketChangePercent))
const epoch = toFiniteNumber(item && (item.regular_market_time != null ? item.regular_market_time : item.regularMarketTime))
const exchangeTz = String(item && (item.exchange_timezone_name || item.exchangeTimezoneName || 'America/New_York') || 'America/New_York')
const tradeAt = String(item && item.last_trade_at || '').trim() || unixToDateTime(epoch, exchangeTz)
const beijingAt = String(item && item.last_trade_beijing_at || '').trim() || unixToDateTime(epoch, 'Asia/Shanghai')
return {
symbol: originalSymbol,
yahoo_symbol: yahooSymbol,
name: String(item && (item.name || item.longName || item.shortName || item.displayName || item.yahoo_symbol || item.symbol) || '').trim(),
price: price != null ? price : 0,
prevClose: prevClose != null ? prevClose : 0,
change: change != null ? change : (price != null && prevClose != null ? price - prevClose : 0),
change_percent: pct != null ? pct : (price != null && prevClose ? ((price - prevClose) / prevClose) * 100 : 0),
last_trade_date: String(item && item.last_trade_date || '').trim() || (tradeAt ? tradeAt.slice(0, 10) : ''),
last_trade_at: tradeAt,
last_trade_beijing_at: beijingAt,
captured_at: String(item && item.captured_at || '').trim(),
source: String(item && item.source || options.source || 'yahoo_probe').trim() || 'yahoo_probe'
}
}
const attachYahooPrevClose = rows => {
let prevClose = null
return (Array.isArray(rows) ? rows : []).map(row => {
const close = toFiniteNumber(row && row.close)
const output = {
...row,
open: toFiniteNumber(row && row.open),
high: toFiniteNumber(row && row.high),
low: toFiniteNumber(row && row.low),
close,
volume: toFiniteNumber(row && row.volume),
amount: toFiniteNumber(row && row.amount),
prev_close: prevClose,
source: 'yahoo_probe'
}
if (Number.isFinite(close)) prevClose = close
return output
})
}
const buildYahooChartPrevClose = items => {
const rows = (Array.isArray(items) ? items : []).map(row => ({
symbol: String(row && row.symbol || '').trim(),
yahoo_symbol: String(row && row.yahoo_symbol || '').trim(),
trade_date: String(row && row.trade_date || '').trim(),
trade_at: String(row && row.trade_at || '').trim(),
beijing_date: String(row && row.beijing_date || '').trim(),
beijing_at: String(row && row.beijing_at || '').trim(),
open: toFiniteNumber(row && row.open),
high: toFiniteNumber(row && row.high),
low: toFiniteNumber(row && row.low),
close: toFiniteNumber(row && row.close),
volume: toFiniteNumber(row && row.volume),
amount: toFiniteNumber(row && row.amount),
source: 'yahoo_probe'
}))
return attachYahooPrevClose(rows)
}
const parseYahooQuotePayload = (payload, mappings) => {
if (payload && Array.isArray(payload.items)) {
return payload.items.map(item => normalizeYahooQuoteItem(item))
}
const rows = payload && payload.quoteResponse && Array.isArray(payload.quoteResponse.result)
? payload.quoteResponse.result
: []
const reverse = new Map((Array.isArray(mappings) ? mappings : []).map(item => [String(item.yahoo || '').toUpperCase(), item]))
return rows.map(item => {
const hit = reverse.get(String(item && item.symbol || '').toUpperCase())
return normalizeYahooQuoteItem(item, {
originalSymbol: hit ? hit.original : String(item && item.symbol || '').trim(),
yahooSymbol: hit ? hit.yahoo : String(item && item.symbol || '').trim()
})
})
}
const fetchYahoo = async symbols => {
if (!symbols || symbols.length === 0) return []
const cfg = getYahooProbeConfig()
const trace = Array.isArray(arguments[1] && arguments[1].trace) ? arguments[1].trace : null
if (!cfg.enabled) {
pushYahooTrace(trace, 'yahoo.disabled', { reason: 'probe_disabled' })
return []
}
const wanted = Array.from(new Set((symbols || []).map(s => String(s || '').trim()).filter(Boolean)))
const outMap = new Map()
const missing = []
for (const symbol of wanted) {
const cached = getYahooProbeCache(yahooProbeState.quoteCache, symbol)
if (cached) {
outMap.set(symbol, cached)
pushYahooTrace(trace, 'yahoo.quote.cache_hit', { symbol })
} else {
missing.push(symbol)
}
}
if (missing.length > 0) {
pushYahooTrace(trace, 'yahoo.quote.enqueue', { symbols: missing, requested: wanted.length })
const rows = await enqueueYahooQuoteRequest(missing)
for (const row of rows) {
if (!row || !row.symbol) continue
outMap.set(row.symbol, row)
}
pushYahooTrace(trace, 'yahoo.quote.batch_ok', { symbols: missing, count: rows.length, cached: false })
}
const parsed = wanted.map(symbol => outMap.get(symbol)).filter(Boolean)
logJSON('markets.http.parse', { source: 'yahoo_probe', count: parsed.length, requested: wanted.length }, 'markets')
return parsed
}
const parseFinnhubCandlePayload = (payload, mapping) => {
const status = String(payload && payload.s || '').trim().toLowerCase()
if (status !== 'ok') throw new Error(`finnhub_probe_candle_invalid_${status || 'response'}`)
const timestamps = Array.isArray(payload && payload.t) ? payload.t : []
const openArr = Array.isArray(payload && payload.o) ? payload.o : []
const highArr = Array.isArray(payload && payload.h) ? payload.h : []
const lowArr = Array.isArray(payload && payload.l) ? payload.l : []
const closeArr = Array.isArray(payload && payload.c) ? payload.c : []
const volumeArr = Array.isArray(payload && payload.v) ? payload.v : []
const tz = getFinnhubTimeZone(mapping && mapping.yahoo)
const rows = []
for (let i = 0; i < timestamps.length; i += 1) {
const ts = toFiniteNumber(timestamps[i])
const close = toFiniteNumber(closeArr[i])
if (!Number.isFinite(ts) || !Number.isFinite(close)) continue
const tradeAt = unixToDateTime(ts, tz)
const beijingAt = unixToDateTime(ts, 'Asia/Shanghai')
rows.push({
symbol: mapping.original,
yahoo_symbol: mapping.yahoo,
trade_date: unixToDate(ts, tz),
trade_at: tradeAt,
beijing_date: beijingAt ? beijingAt.slice(0, 10) : '',
beijing_at: beijingAt,
open: toFiniteNumber(openArr[i]),
high: toFiniteNumber(highArr[i]),
low: toFiniteNumber(lowArr[i]),
close,
volume: toFiniteNumber(volumeArr[i]),
amount: approxAmount(close, volumeArr[i]),
source: 'finnhub_probe'
})
}
return attachYahooPrevClose(rows.map(row => ({ ...row, source: 'finnhub_probe' })))
}
const requestFinnhubCandle = async (mapping, options, cfg) => {
const finnhubSymbol = getFinnhubSymbol(mapping && mapping.yahoo)
if (!finnhubSymbol) throw new Error('finnhub_symbol_required')
await throttleFinnhubProbe(cfg, options && options.trace, 'chart')
const url = `${cfg.base_url.replace(/\/$/, '')}${cfg.finnhub_candle_endpoint}`
const unixRange = convertYahooRangeToUnixWindow(options && options.range)
const resolution = mapYahooIntervalToFinnhubResolution(options && options.interval)
logJSON('markets.http.get', {
source: 'finnhub_probe_candle',
url,
symbol: mapping.original,
yahoo_symbol: mapping.yahoo,
finnhub_symbol: finnhubSymbol,
resolution,
from: unixRange.from,
to: unixRange.to
}, 'markets')
const resp = await axios.get(url, {
timeout: cfg.request_timeout_ms,
validateStatus: () => true,
params: {
token: cfg.token,
symbol: finnhubSymbol,
resolution,
from: unixRange.from,
to: unixRange.to
}
})
if (!resp || resp.status >= 400) throw new Error(`finnhub_probe_candle_http_${resp && resp.status ? resp.status : 'ERR'}`)
const data = resp.data && typeof resp.data === 'object' ? resp.data : {}
const rows = parseFinnhubCandlePayload(data, mapping)
logJSON('markets.http.parse', { source: 'finnhub_probe_candle', symbol: mapping.original, count: rows.length }, 'markets')
return rows
}
const parseYahooChartPayload = (payload, mapping) => {
if (payload && Array.isArray(payload.items)) return buildYahooChartPrevClose(payload.items)
const result = payload && payload.chart && Array.isArray(payload.chart.result) ? payload.chart.result[0] : null
const error = payload && payload.chart ? payload.chart.error : null
if (!result) {
throw new Error(`yahoo_probe_chart_invalid_${String(error && error.description || 'response')}`)
}
const meta = result.meta || {}
const tz = String(meta.exchangeTimezoneName || 'America/New_York')
const timestamps = Array.isArray(result.timestamp) ? result.timestamp : []
const quote = result.indicators && Array.isArray(result.indicators.quote) ? result.indicators.quote[0] : {}
const openArr = Array.isArray(quote.open) ? quote.open : []
const highArr = Array.isArray(quote.high) ? quote.high : []
const lowArr = Array.isArray(quote.low) ? quote.low : []
const closeArr = Array.isArray(quote.close) ? quote.close : []
const volumeArr = Array.isArray(quote.volume) ? quote.volume : []
const rows = []
for (let i = 0; i < timestamps.length; i += 1) {
const ts = toFiniteNumber(timestamps[i])
const close = toFiniteNumber(closeArr[i])
if (!Number.isFinite(ts) || !Number.isFinite(close)) continue
const tradeAt = unixToDateTime(ts, tz)
const beijingAt = unixToDateTime(ts, 'Asia/Shanghai')
rows.push({
symbol: mapping.original,
yahoo_symbol: mapping.yahoo,
trade_date: unixToDate(ts, tz),
trade_at: tradeAt,
beijing_date: beijingAt ? beijingAt.slice(0, 10) : '',
beijing_at: beijingAt,
open: toFiniteNumber(openArr[i]),
high: toFiniteNumber(highArr[i]),
low: toFiniteNumber(lowArr[i]),
close,
volume: toFiniteNumber(volumeArr[i]),
amount: approxAmount(close, volumeArr[i]),
source: 'yahoo_probe'
})
}
return attachYahooPrevClose(rows)
}
const fetchYahooChart = async (symbol, options = {}) => {
const rawSymbol = String(symbol || '').trim()
if (!rawSymbol) return []
const yahooSymbol = getYahooProbeSymbol(rawSymbol)
const cfg = getYahooProbeConfig()
const trace = Array.isArray(options.trace) ? options.trace : null
if (!cfg.enabled) {
pushYahooTrace(trace, 'yahoo.chart.disabled', { symbol: rawSymbol, reason: 'probe_disabled' })
return []
}
const range = String(options.range || '1y').trim() || '1y'
const interval = String(options.interval || '1d').trim() || '1d'
const includePrePost = options.includePrePost === true || String(options.includePrePost || '') === '1'
const cacheKey = `${rawSymbol}|${range}|${interval}|${includePrePost ? 1 : 0}`
const cached = getYahooProbeCache(yahooProbeState.chartCache, cacheKey)
if (cached) {
pushYahooTrace(trace, 'yahoo.chart.cache_hit', { symbol: rawSymbol, range, interval })
return cached
}
let promise = yahooProbeState.chartInflight.get(cacheKey)
if (!promise) {
promise = (async () => {
await throttleYahooProbe('chart', cfg, trace)
const url = `${cfg.base_url.replace(/\/$/, '')}${cfg.chart_endpoint}`
// #region debug-point B:yahoo-chart-request
;(() => { try { const p = path.join(process.cwd(), '.dbg', 'weekly-yahoo-zero.env'); let u = 'http://127.0.0.1:7777/event', s = 'weekly-yahoo-zero'; try { const e = fs.readFileSync(p, 'utf8'); u = (e.match(/DEBUG_SERVER_URL=(.+)/) || [])[1] || u; s = (e.match(/DEBUG_SESSION_ID=(.+)/) || [])[1] || s } catch {} fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: s, runId: 'pre-fix', hypothesisId: 'B', location: 'marketsClient.js:fetchYahooChart:request', msg: '[DEBUG] yahoo chart request', data: { symbol: rawSymbol, yahoo_symbol: yahooSymbol, range, interval, includePrePost, url }, ts: Date.now() }) }).catch(() => {}) } catch {} })()
// #endregion
logJSON('markets.http.get', { source: 'yahoo_probe_chart', url, symbol: rawSymbol, yahoo_symbol: yahooSymbol, range, interval }, 'markets')
const resp = await axios.get(url, {
timeout: cfg.request_timeout_ms,
validateStatus: () => true,
params: {
token: cfg.token,
type: 'chart',
symbol: yahooSymbol,
range,
interval,
includePrePost: includePrePost ? '1' : '0'
}
})
if (resp && resp.status === 429) {
pushYahooTrace(trace, 'yahoo.chart.http_429', { symbol: rawSymbol, yahoo_symbol: yahooSymbol, range, interval })
logJSON('markets.http.status', { source: 'yahoo_probe_chart', url, status: 429, symbol: rawSymbol, yahoo_symbol: yahooSymbol, fallback: 'finnhub_probe_candle' }, 'markets')
return requestFinnhubCandle({ original: rawSymbol, yahoo: yahooSymbol }, { range, interval, trace }, cfg)
}
if (!resp || resp.status >= 400) throw new Error(`yahoo_probe_chart_http_${resp && resp.status ? resp.status : 'ERR'}`)
if (!resp.data) throw new Error('yahoo_probe_chart_invalid_response')
const rows = parseYahooChartPayload(resp.data, { original: rawSymbol, yahoo: yahooSymbol })
// #region debug-point C:yahoo-chart-response
;(() => { try { const p = path.join(process.cwd(), '.dbg', 'weekly-yahoo-zero.env'); let u = 'http://127.0.0.1:7777/event', s = 'weekly-yahoo-zero'; try { const e = fs.readFileSync(p, 'utf8'); u = (e.match(/DEBUG_SERVER_URL=(.+)/) || [])[1] || u; s = (e.match(/DEBUG_SESSION_ID=(.+)/) || [])[1] || s } catch {} fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: s, runId: 'pre-fix', hypothesisId: 'C', location: 'marketsClient.js:fetchYahooChart:response', msg: '[DEBUG] yahoo chart response', data: { symbol: rawSymbol, yahoo_symbol: yahooSymbol, range, interval, status: resp.status, count: rows.length, sample_trade_date: (((rows || [])[0]) || {}).trade_date || '' }, ts: Date.now() }) }).catch(() => {}) } catch {} })()
// #endregion
pushYahooTrace(trace, 'yahoo.chart.remote_ok', { symbol: rawSymbol, yahoo_symbol: yahooSymbol, range, interval, count: rows.length })
return rows
})()
yahooProbeState.chartInflight.set(cacheKey, promise)
promise
.then(
() => yahooProbeState.chartInflight.delete(cacheKey),
() => yahooProbeState.chartInflight.delete(cacheKey)
)
.catch(() => {})
} else {
pushYahooTrace(trace, 'yahoo.chart.inflight_reuse', { symbol: rawSymbol, range, interval })
}
try {
const rows = await promise
setYahooProbeCache(yahooProbeState.chartCache, cacheKey, rows, cfg.chart_ttl_seconds)
return rows
} catch (e) {
pushYahooTrace(trace, 'yahoo.chart.remote_error', { symbol: rawSymbol, yahoo_symbol: yahooSymbol, range, interval, error: String(e.message || e) })
// #region debug-point C:yahoo-chart-error
;(() => { try { const p = path.join(process.cwd(), '.dbg', 'weekly-yahoo-zero.env'); let u = 'http://127.0.0.1:7777/event', s = 'weekly-yahoo-zero'; try { const e = fs.readFileSync(p, 'utf8'); u = (e.match(/DEBUG_SERVER_URL=(.+)/) || [])[1] || u; s = (e.match(/DEBUG_SESSION_ID=(.+)/) || [])[1] || s } catch {} fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: s, runId: 'pre-fix', hypothesisId: 'C', location: 'marketsClient.js:fetchYahooChart:error', msg: '[DEBUG] yahoo chart error', data: { symbol: rawSymbol, yahoo_symbol: yahooSymbol, range, interval, error: String(e && e.message ? e.message : e) }, ts: Date.now() }) }).catch(() => {}) } catch {} })()
// #endregion
logJSON('markets.http.error', { source: 'yahoo_probe_chart', symbol: rawSymbol, yahoo_symbol: yahooSymbol, range, interval, error: String(e.message || e) }, 'markets')
throw e
}
}
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(',')
}
if (symbol.startsWith('hf_')) {
// Futures format: Price, Change%, ?, ?, ?, ?, Time, PrevClose, Open, ... , Name
// e.g. 5098.95,3.32,5097.20,5097.90,5109.30,4930.40,15:24:08,4935.00,4966.10,0,4,2,2026-02-04,纽约黄金
const name = parts[13] || symbol
const price = parseFloat(parts[0] || '0')
const prevClose = parseFloat(parts[7] || '0')
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
const last_trade_date = /^\d{4}-\d{2}-\d{2}$/.test(String(parts[12] || '')) ? String(parts[12]) : ''
return { symbol, name, price, prevClose, change, change_percent: pct, last_trade_date }
}
const name = parts[1] || symbol
const price = parseFloat(parts[3] || '0')
const prevClose = parseFloat(parts[4] || '0')
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
return { symbol, name, price, prevClose, change, change_percent: pct }
}
const fetchTencent = async symbols => {
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
logJSON('markets.http.get', { source: 'tencent', url, symbols }, 'markets')
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) { logJSON('markets.http.status', { source: 'tencent', url, status: res && res.status }, 'markets'); 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' }) }
logJSON('markets.http.parse', { source: 'tencent', url, count: out.length }, 'markets')
return out
} catch (e) {
logJSON('markets.http.error', { source: 'tencent', url, error: String(e.message || e) }, 'markets')
return []
}
}
const fetchEastmoneyComexGold = async () => {
const url = 'https://quote.eastmoney.com/globalfuture/GC00Y.html'
logJSON('markets.http.get', { source: 'eastmoney', url }, 'markets')
try {
const res = await axios.get(url, { timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400 || !res.data) {
logJSON('markets.http.status', { source: 'eastmoney', url, status: res && res.status }, 'markets')
return null
}
const html = String(res.data || '')
let m = html.match(/最新[::]\s*([0-9]+(?:\.[0-9]+)?)/)
if (!m) m = html.match(/最新价[::]\s*([0-9]+(?:\.[0-9]+)?)/)
const priceStr = m ? m[1] : null
if (!priceStr) {
logJSON('markets.http.parse', { source: 'eastmoney', url, matched: false }, 'markets')
return null
}
const price = parseFloat(priceStr)
if (!Number.isFinite(price)) return null
return { symbol: 'hf_GC', name: 'COMEX黄金', price, prevClose: 0, change: 0, change_percent: 0, source: 'eastmoney' }
} catch (e) {
logJSON('markets.http.error', { source: 'eastmoney', url, error: String(e.message || e) }, 'markets')
return null
}
}
module.exports = { fetchSina, fetchYahoo, fetchYahooChart, fetchTencent, fetchEastmoneyComexGold, getYahooProbeConfig }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+388
View File
@@ -0,0 +1,388 @@
const express = require('express')
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const Database = require('better-sqlite3')
const DATA_DIR = path.join(process.cwd(), 'data')
const UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'private_clipboard')
const DB_PATH = process.env.PRIVATE_CLIPBOARD_DB_PATH || path.join(DATA_DIR, 'private_clipboard.db')
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true })
let db = null
const buildFolderCode = () => `fd_${Date.now().toString(36)}_${crypto.randomBytes(4).toString('hex')}`
const toPositiveInt = (value, fallback) => {
const n = Number.parseInt(String(value == null ? '' : value), 10)
if (!Number.isFinite(n) || n <= 0) return fallback
return n
}
const ensureSchema = () => {
if (!db) return
db.exec(`
CREATE TABLE IF NOT EXISTS clipboard_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
text_content TEXT,
file_name TEXT,
file_mime TEXT,
file_size INTEGER,
file_path TEXT,
group_id INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS clipboard_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_clipboard_items_created_at ON clipboard_items(created_at);
CREATE INDEX IF NOT EXISTS idx_clipboard_folders_sort_order ON clipboard_folders(sort_order, id);
`)
const cols = db.prepare('PRAGMA table_info(clipboard_items)').all()
const hasGroup = cols.some(c => c.name === 'group_id')
if (!hasGroup) db.exec('ALTER TABLE clipboard_items ADD COLUMN group_id INTEGER')
const hasFavoriteFolderCode = cols.some(c => c.name === 'favorite_folder_code')
if (!hasFavoriteFolderCode) db.exec('ALTER TABLE clipboard_items ADD COLUMN favorite_folder_code TEXT')
db.exec('CREATE INDEX IF NOT EXISTS idx_clipboard_items_favorite_folder_code ON clipboard_items(favorite_folder_code)')
}
const getDb = () => {
if (db) {
ensureSchema()
return db
}
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
db.pragma('synchronous = NORMAL')
db.pragma('busy_timeout = 4000')
ensureSchema()
return db
}
const normalizeName = (name) => {
const base = path.basename(String(name || '').replace(/\0/g, ''))
return base || 'file'
}
const extFromMime = (mime) => {
const m = String(mime || '').toLowerCase()
if (m === 'image/png') return '.png'
if (m === 'image/jpeg') return '.jpg'
if (m === 'image/jpg') return '.jpg'
if (m === 'image/webp') return '.webp'
if (m === 'image/gif') return '.gif'
if (m === 'image/bmp') return '.bmp'
if (m === 'image/svg+xml') return '.svg'
return ''
}
const saveFile = (raw, name, mime) => {
const safe = normalizeName(name)
let ext = path.extname(safe)
if (!ext) ext = extFromMime(mime)
const stamp = Date.now()
const rand = crypto.randomBytes(6).toString('hex')
const fileName = `${stamp}-${rand}${ext}`
const rel = path.join('uploads', 'private_clipboard', fileName)
const abs = path.join(process.cwd(), rel)
fs.writeFileSync(abs, raw)
const size = raw.length
return { rel, fileName: safe, size }
}
const formatRow = (row) => {
const filePath = row.file_path ? String(row.file_path) : ''
const url = filePath ? '/' + filePath.replace(/^\/+/, '') : ''
return {
id: row.id,
type: row.type,
text_content: row.text_content || '',
file_name: row.file_name || '',
file_mime: row.file_mime || '',
file_size: row.file_size || 0,
file_url: url,
favorite_folder_code: row.favorite_folder_code || null,
favorite_folder_name: row.favorite_folder_name || '',
created_at: row.created_at
}
}
const createRouter = () => {
const router = express.Router()
router.use((req, res, next) => {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate')
res.set('Pragma', 'no-cache')
res.set('Expires', '0')
res.set('Surrogate-Control', 'no-store')
} catch {}
next()
})
router.get('/list', (req, res) => {
try {
const rows = getDb().prepare(`
SELECT i.*, f.name AS favorite_folder_name
FROM clipboard_items i
LEFT JOIN clipboard_folders f ON f.code = i.favorite_folder_code
ORDER BY i.created_at DESC, i.id DESC
`).all()
const groups = new Map()
rows.forEach(row => {
const gid = row.group_id ? Number(row.group_id) : null
const key = gid ? `g:${gid}` : `r:${row.id}`
let group = groups.get(key)
if (!group) {
group = {
id: gid || row.id,
type: row.type,
text_content: row.text_content || '',
created_at: row.created_at,
items: [],
group_types: [],
favorite_folder_code: row.favorite_folder_code || null,
favorite_folder_name: row.favorite_folder_name || ''
}
groups.set(key, group)
}
group.group_types.push(row.type)
if (row.favorite_folder_code && !group.favorite_folder_code) {
group.favorite_folder_code = row.favorite_folder_code
group.favorite_folder_name = row.favorite_folder_name || ''
}
if (row.type === 'text') {
if (!group.text_content) group.text_content = row.text_content || ''
} else {
group.items.push(formatRow(row))
}
})
const items = Array.from(groups.values()).map(group => {
const unique = Array.from(new Set(group.group_types.filter(Boolean)))
if (unique.length === 0 && group.text_content) unique.push('text')
let type = group.type
if (group.items.length) {
if (unique.length === 1) type = unique[0]
else type = 'mixed'
} else {
type = 'text'
}
return {
id: group.id,
type,
text_content: group.text_content,
created_at: group.created_at,
items: group.items,
group_types: unique,
favorite_folder_code: group.favorite_folder_code || null,
favorite_folder_name: group.favorite_folder_name || '',
is_favorite: !!group.favorite_folder_code
}
})
res.json({ ok: true, items })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
router.get('/folders', (req, res) => {
try {
const folders = getDb().prepare(`
SELECT code, name, sort_order, created_at
FROM clipboard_folders
ORDER BY sort_order ASC, id ASC
`).all()
const rawCounts = getDb().prepare(`
SELECT favorite_folder_code AS code, COUNT(DISTINCT CASE WHEN group_id IS NOT NULL THEN 'g:' || group_id ELSE 'r:' || id END) AS cnt
FROM clipboard_items
WHERE favorite_folder_code IS NOT NULL
GROUP BY favorite_folder_code
`).all()
const countMap = new Map(rawCounts.map(r => [String(r.code || ''), Number(r.cnt || 0)]))
const list = folders.map(folder => ({
code: folder.code,
name: folder.name,
sort_order: Number(folder.sort_order || 1),
created_at: Number(folder.created_at || Date.now()),
favorite_count: countMap.get(String(folder.code || '')) || 0
}))
res.json({ ok: true, folders: list })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
router.post('/folders', (req, res) => {
try {
const name = String((req.body && req.body.name) || '').trim()
if (!name) return res.status(400).json({ ok: false, error: 'invalid_folder_name' })
const maxRow = getDb().prepare('SELECT MAX(sort_order) AS max_sort FROM clipboard_folders').get()
const defaultSort = Number(maxRow && maxRow.max_sort ? maxRow.max_sort : 0) + 1
const sortOrder = toPositiveInt(req.body && req.body.sort_order, defaultSort)
const now = Date.now()
const code = buildFolderCode()
getDb().prepare('INSERT INTO clipboard_folders (code, name, sort_order, created_at) VALUES (?, ?, ?, ?)').run(code, name, sortOrder, now)
res.json({ ok: true, folder: { code, name, sort_order: sortOrder, created_at: now, favorite_count: 0 } })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
router.patch('/folders/:code', (req, res) => {
try {
const code = String(req.params.code || '').trim()
if (!code) return res.status(400).json({ ok: false, error: 'invalid_folder_code' })
const folder = getDb().prepare('SELECT code, name, sort_order, created_at FROM clipboard_folders WHERE code = ?').get(code)
if (!folder) return res.status(404).json({ ok: false, error: 'folder_not_found' })
const hasName = Object.prototype.hasOwnProperty.call(req.body || {}, 'name')
const hasSort = Object.prototype.hasOwnProperty.call(req.body || {}, 'sort_order')
if (!hasName && !hasSort) return res.status(400).json({ ok: false, error: 'nothing_to_update' })
const nextName = hasName ? String(req.body.name || '').trim() : String(folder.name || '')
if (!nextName) return res.status(400).json({ ok: false, error: 'invalid_folder_name' })
const nextSort = hasSort ? toPositiveInt(req.body.sort_order, -1) : Number(folder.sort_order || 1)
if (!Number.isFinite(nextSort) || nextSort <= 0) return res.status(400).json({ ok: false, error: 'invalid_sort_order' })
getDb().prepare('UPDATE clipboard_folders SET name = ?, sort_order = ? WHERE code = ?').run(nextName, nextSort, code)
res.json({ ok: true, folder: { code, name: nextName, sort_order: nextSort, created_at: Number(folder.created_at || Date.now()) } })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
router.delete('/folders/:code', (req, res) => {
try {
const code = String(req.params.code || '').trim()
if (!code) return res.status(400).json({ ok: false, error: 'invalid_folder_code' })
const folder = getDb().prepare('SELECT code FROM clipboard_folders WHERE code = ?').get(code)
if (!folder) return res.status(404).json({ ok: false, error: 'folder_not_found' })
const used = getDb().prepare('SELECT COUNT(1) AS cnt FROM clipboard_items WHERE favorite_folder_code = ?').get(code)
if (Number(used && used.cnt ? used.cnt : 0) > 0) {
return res.status(409).json({ ok: false, error: 'folder_not_empty' })
}
getDb().prepare('DELETE FROM clipboard_folders WHERE code = ?').run(code)
res.json({ ok: true })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
router.post('/favorite', (req, res) => {
try {
const id = Number.parseInt(String(req.body && req.body.id), 10)
const folderCode = String((req.body && req.body.folder_code) || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'invalid_id' })
if (!folderCode) return res.status(400).json({ ok: false, error: 'invalid_folder_code' })
const folder = getDb().prepare('SELECT code FROM clipboard_folders WHERE code = ?').get(folderCode)
if (!folder) return res.status(404).json({ ok: false, error: 'folder_not_found' })
let changed = 0
if (id < 0) {
const info = getDb().prepare('UPDATE clipboard_items SET favorite_folder_code = ? WHERE group_id = ?').run(folderCode, id)
changed = Number(info.changes || 0)
} else {
const info = getDb().prepare('UPDATE clipboard_items SET favorite_folder_code = ? WHERE id = ?').run(folderCode, id)
changed = Number(info.changes || 0)
}
if (!changed) return res.status(404).json({ ok: false, error: 'item_not_found' })
res.json({ ok: true })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
router.post('/unfavorite', (req, res) => {
try {
const id = Number.parseInt(String(req.body && req.body.id), 10)
if (!id) return res.status(400).json({ ok: false, error: 'invalid_id' })
let changed = 0
if (id < 0) {
const info = getDb().prepare('UPDATE clipboard_items SET favorite_folder_code = NULL WHERE group_id = ?').run(id)
changed = Number(info.changes || 0)
} else {
const info = getDb().prepare('UPDATE clipboard_items SET favorite_folder_code = NULL WHERE id = ?').run(id)
changed = Number(info.changes || 0)
}
if (!changed) return res.status(404).json({ ok: false, error: 'item_not_found' })
res.json({ ok: true })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
router.post('/text', (req, res) => {
try {
const text = String((req.body && req.body.text) || '').trim()
if (!text) return res.status(400).json({ ok: false, error: 'empty_text' })
const now = Date.now()
const info = getDb().prepare('INSERT INTO clipboard_items (type, text_content, created_at) VALUES (?, ?, ?)').run('text', text, now)
res.json({ ok: true, id: info.lastInsertRowid })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
router.post('/upload', express.raw({ type: '*/*', limit: '200mb' }), (req, res) => {
try {
const raw = req.body
if (!raw || !raw.length) return res.status(400).json({ ok: false, error: 'empty_file' })
const name = String(req.query.filename || req.headers['x-file-name'] || '')
const mime = String(req.query.mime || req.headers['x-file-type'] || req.headers['content-type'] || '')
const gid = parseInt(String(req.query.group_id || ''), 10)
const groupId = Number.isFinite(gid) ? gid : null
const { rel, fileName, size } = saveFile(raw, name, mime)
const now = Date.now()
const type = String(mime || '').toLowerCase().startsWith('image/') ? 'image' : 'file'
const info = getDb().prepare('INSERT INTO clipboard_items (type, file_name, file_mime, file_size, file_path, group_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(
type,
fileName,
mime,
size,
rel,
groupId,
now
)
res.json({ ok: true, id: info.lastInsertRowid, path: '/' + rel.replace(/^\/+/, '') })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
router.delete('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10)
if (!id) return res.status(400).json({ ok: false, error: 'invalid_id' })
if (id < 0) {
const rows = getDb().prepare('SELECT file_path, favorite_folder_code FROM clipboard_items WHERE group_id = ?').all(id)
if (!rows.length) return res.status(404).json({ ok: false, error: 'item_not_found' })
const hasFavorite = rows.some(row => row && row.favorite_folder_code)
if (hasFavorite) return res.status(409).json({ ok: false, error: 'item_favorited' })
getDb().prepare('DELETE FROM clipboard_items WHERE group_id = ?').run(id)
rows.forEach(row => {
if (row && row.file_path) {
const rel = String(row.file_path || '').replace(/^\/+/, '')
const abs = path.join(process.cwd(), rel)
if (fs.existsSync(abs)) fs.unlinkSync(abs)
}
})
} else {
const row = getDb().prepare('SELECT file_path, favorite_folder_code FROM clipboard_items WHERE id = ?').get(id)
if (!row) return res.status(404).json({ ok: false, error: 'item_not_found' })
if (row.favorite_folder_code) return res.status(409).json({ ok: false, error: 'item_favorited' })
getDb().prepare('DELETE FROM clipboard_items WHERE id = ?').run(id)
if (row && row.file_path) {
const rel = String(row.file_path || '').replace(/^\/+/, '')
const abs = path.join(process.cwd(), rel)
if (fs.existsSync(abs)) fs.unlinkSync(abs)
}
}
res.json({ ok: true })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
return router
}
module.exports = { createRouter }
+1052
View File
File diff suppressed because it is too large Load Diff
+203
View File
@@ -0,0 +1,203 @@
const Database = require('better-sqlite3')
const path = require('path')
const fs = require('fs')
const crypto = require('crypto')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'rational_lock.db')
const db = new Database(dbPath)
db.exec(`
CREATE TABLE IF NOT EXISTS rl_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS rl_sessions (
token TEXT PRIMARY KEY,
username TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS rl_user_data (
username TEXT PRIMARY KEY,
items_json TEXT NOT NULL DEFAULT '[]',
applications_json TEXT NOT NULL DEFAULT '[]',
draft_json TEXT,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rl_sessions_user ON rl_sessions(username);
CREATE INDEX IF NOT EXISTS idx_rl_sessions_exp ON rl_sessions(expires_at);
`)
const now = () => Date.now()
const SESSION_MAX_AGE = 1 * 24 * 3600 * 1000
const COOKIE_NAME = 'rational_lock_sid'
const DEFAULT_USER = { username: 'admin_rl', password: process.env.LOCK_DEFAULT_PASSWORD || '' }
const parseCookie = cookieStr => {
const out = {}
String(cookieStr || '').split(/;\s*/).forEach(p => {
const idx = p.indexOf('=')
if (idx > 0) out[p.slice(0, idx)] = decodeURIComponent(p.slice(idx + 1))
})
return out
}
const ensureDefaultUser = () => {
const row = db.prepare('SELECT username FROM rl_users WHERE username = ?').get(DEFAULT_USER.username)
if (!row) {
db.prepare('INSERT INTO rl_users (username, password, created_at) VALUES (?, ?, ?)').run(DEFAULT_USER.username, DEFAULT_USER.password, now())
}
}
const cleanupSessions = () => {
db.prepare('DELETE FROM rl_sessions WHERE expires_at <= ?').run(now())
}
const readDataOf = username => {
const row = db.prepare('SELECT items_json, applications_json, draft_json FROM rl_user_data WHERE username = ?').get(username)
if (!row) return { db: { items: [], applications: [] }, draft: null }
let items = []
let applications = []
let draft = null
try { items = JSON.parse(row.items_json || '[]') } catch {}
try { applications = JSON.parse(row.applications_json || '[]') } catch {}
try { draft = row.draft_json ? JSON.parse(row.draft_json) : null } catch {}
return {
db: {
items: Array.isArray(items) ? items : [],
applications: Array.isArray(applications) ? applications : []
},
draft: draft && typeof draft === 'object' ? draft : null
}
}
const writeDataOf = (username, data) => {
const dbData = data && data.db && typeof data.db === 'object' ? data.db : {}
const items = Array.isArray(dbData.items) ? dbData.items : []
const applications = Array.isArray(dbData.applications) ? dbData.applications : []
const draft = data && data.draft && typeof data.draft === 'object' ? data.draft : null
db.prepare(`
INSERT INTO rl_user_data (username, items_json, applications_json, draft_json, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(username) DO UPDATE SET
items_json = excluded.items_json,
applications_json = excluded.applications_json,
draft_json = excluded.draft_json,
updated_at = excluded.updated_at
`).run(
username,
JSON.stringify(items),
JSON.stringify(applications),
draft ? JSON.stringify(draft) : null,
now()
)
}
const getSessionUser = req => {
cleanupSessions()
const cookies = parseCookie(req.headers.cookie || '')
const token = String(cookies[COOKIE_NAME] || '')
if (!token) return ''
const row = db.prepare('SELECT username, expires_at FROM rl_sessions WHERE token = ?').get(token)
if (!row) return ''
if (Number(row.expires_at) <= now()) {
db.prepare('DELETE FROM rl_sessions WHERE token = ?').run(token)
return ''
}
return String(row.username || '')
}
const requireAuth = (req, res, next) => {
const username = getSessionUser(req)
if (!username) return res.status(401).json({ ok: false, error: 'unauthorized' })
req.rlUser = username
next()
}
const bindRoutes = app => {
ensureDefaultUser()
app.get('/api/rational_lock/health', (req, res) => {
res.json({ ok: true })
})
app.get('/api/rational_lock/auth/local_creds', (req, res) => {
try {
const os = require('os')
const userProfile = process.env.USERPROFILE || process.env.HOME || os.homedir()
const candidates = [path.join(userProfile, 'Toolbox_local_creds.env.local')]
const usersRoot = path.join(path.parse(userProfile).root, 'Users')
try {
if (fs.existsSync(usersRoot)) {
fs.readdirSync(usersRoot).forEach(name => {
const dir = path.join(usersRoot, name)
if (dir !== userProfile && fs.existsSync(dir)) candidates.push(path.join(dir, 'Toolbox_local_creds.env.local'))
})
}
} catch {}
let content = null
for (const p of candidates) { if (fs.existsSync(p)) { content = fs.readFileSync(p, 'utf-8'); break } }
if (!content) return res.json({ ok: true, creds: null })
const creds = {}
content.split(/\r?\n/).forEach(line => {
const t = line.trim()
if (!t || t.startsWith('#')) return
const idx = t.indexOf('=')
if (idx > 0) creds[t.slice(0, idx).trim()] = t.slice(idx + 1).trim()
})
const serverToken = String(creds.TOOLBOX_LOCAL_CREDS_TOKEN || '')
if (serverToken) {
const clientToken = String(req.query.token || '')
if (clientToken !== serverToken) return res.json({ ok: true, creds: null })
}
const u = creds.RL_USERNAME || ''
const p = creds.RL_PASSWORD || ''
if (!u && !p) return res.json({ ok: true, creds: null })
res.json({ ok: true, creds: { username: u, password: p } })
} catch { res.json({ ok: true, creds: null }) }
})
app.post('/api/rational_lock/auth/login', (req, res) => {
const username = String((req.body && req.body.username) || '').trim()
const password = String((req.body && req.body.password) || '')
if (!username || !password) return res.status(400).json({ ok: false, error: 'invalid_credentials' })
const row = db.prepare('SELECT username, password FROM rl_users WHERE username = ?').get(username)
if (!row || String(row.password || '') !== password) return res.status(401).json({ ok: false, error: 'invalid_credentials' })
const token = crypto.randomBytes(24).toString('hex')
const createdAt = now()
const expiresAt = createdAt + SESSION_MAX_AGE
db.prepare('INSERT INTO rl_sessions (token, username, created_at, expires_at) VALUES (?, ?, ?, ?)').run(token, username, createdAt, expiresAt)
res.cookie(COOKIE_NAME, token, { httpOnly: true, sameSite: 'lax', maxAge: SESSION_MAX_AGE })
res.json({ ok: true, username })
})
app.post('/api/rational_lock/auth/logout', (req, res) => {
const cookies = parseCookie(req.headers.cookie || '')
const token = String(cookies[COOKIE_NAME] || '')
if (token) db.prepare('DELETE FROM rl_sessions WHERE token = ?').run(token)
res.cookie(COOKIE_NAME, '', { httpOnly: true, sameSite: 'lax', maxAge: 0 })
res.json({ ok: true })
})
app.get('/api/rational_lock/auth/me', (req, res) => {
const username = getSessionUser(req)
if (!username) return res.status(401).json({ ok: false, error: 'unauthorized' })
res.json({ ok: true, username })
})
app.get('/api/rational_lock/data', requireAuth, (req, res) => {
const payload = readDataOf(req.rlUser)
res.json({ ok: true, ...payload })
})
app.put('/api/rational_lock/data', requireAuth, (req, res) => {
writeDataOf(req.rlUser, req.body || {})
res.json({ ok: true })
})
}
module.exports = { bindRoutes }
+300
View File
@@ -0,0 +1,300 @@
const express = require('express')
const Database = require('better-sqlite3')
const path = require('path')
const fs = require('fs')
const crypto = require('crypto')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const dbPath = path.join(dataDir, 'rebalance.db')
const db = new Database(dbPath)
// Initialize Tables
db.exec(`CREATE TABLE IF NOT EXISTS weeks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
position_json TEXT NOT NULL,
plan_json TEXT NOT NULL,
asset_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(start_date, end_date)
);`)
db.exec(`CREATE TABLE IF NOT EXISTS excluded_stocks (
code TEXT PRIMARY KEY,
name TEXT
);`)
db.exec(`CREATE TABLE IF NOT EXISTS fund_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
data_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(start_date, end_date)
);`)
db.exec(`CREATE TABLE IF NOT EXISTS fund_summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
currency TEXT,
balance REAL,
available REAL,
reference_value REAL,
asset_value REAL,
profit REAL,
day_profit REAL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(start_date, end_date)
);`)
// Auth Helpers (Duplicated from index.js to avoid refactoring)
const readCookieCfg = () => {
const cfgPath = path.join(process.cwd(), 'public', 'tools', 'rebalance', 'assets', 'auth_config.json')
let name = 'rebalance_gate'
let maxAge = 90 * 24 * 3600 * 1000
try {
if (fs.existsSync(cfgPath)) {
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
if (cfg.cookieName) name = String(cfg.cookieName)
const days = Number(cfg.max_age_days || 0)
if (Number.isFinite(days) && days > 0) maxAge = Math.min(days, 365) * 24 * 3600 * 1000
}
} catch {}
return { name, maxAge }
}
const parseCookie = (cookieStr) => {
const out = {}
String(cookieStr || '').split(/;\s*/).forEach(p => { const idx = p.indexOf('='); if (idx > 0) out[p.slice(0, idx)] = decodeURIComponent(p.slice(idx + 1)) })
return out
}
const checkAuth = (req, res, next) => {
try {
const cookies = parseCookie(req.headers.cookie || '')
const { name, maxAge } = readCookieCfg()
if (cookies[name] === '1') {
res.cookie(name, '1', { httpOnly: true, sameSite: 'lax', maxAge })
return next()
}
} catch {}
return res.status(401).json({ error: 'unauthorized' })
}
const bindRoutes = (app) => {
// Rebalance API Routes
// Note: These routes are mounted at root level by bindRoutes to match original paths /api/...
// OR we can prefix them. The original app used /api/...
// Since Toolbox uses /api for other things, we need to be careful.
// Rebalance specific routes:
// /api/weekly-data
// /api/fund-shares
// /api/settings/excluded-stocks
// Auth endpoints for Rebalance frontend compatibility
app.get('/auth/handshake', (req, res) => {
try {
const cookies = parseCookie(req.headers.cookie || '')
const { name } = readCookieCfg()
if (cookies[name] === '1') return res.json({ ok: true })
return res.status(401).json({ error: 'unauthorized' })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
// We rely on Toolbox's main auth mechanism for setting the cookie.
// But if the user hits this endpoint, we can just check if they are already auth'd.
app.get('/auth/token', (req, res) => {
// This is just a dummy to satisfy Rebalance frontend if it calls it.
// In simplified mode, we expect the user to be logged in via Toolbox.
const cookies = parseCookie(req.headers.cookie || '')
const { name } = readCookieCfg()
if (cookies[name] === '1') return res.json({ ok: true })
return res.status(401).json({ error: 'unauthorized' })
})
app.get('/api/weekly-data', checkAuth, (req, res) => {
const { start, end } = req.query
if (!start || !end) return res.status(400).json({ error: 'missing params' })
try {
const row = db.prepare('SELECT position_json, plan_json, asset_json FROM weeks WHERE start_date=? AND end_date=?').get(start, end)
if (!row) return res.status(404).json({ error: 'not found' })
res.json({
positionData: JSON.parse(row.position_json),
planData: JSON.parse(row.plan_json),
assetData: JSON.parse(row.asset_json)
})
} catch (e) {
res.status(500).json({ error: e && e.message ? e.message : 'db error' })
}
})
app.post('/api/weekly-data', checkAuth, (req, res) => {
const { weekStart, weekEnd, positionData, planData, assetData, overwrite } = req.body
if (!weekStart || !weekEnd || !Array.isArray(positionData) || !Array.isArray(planData) || !Array.isArray(assetData)) {
return res.status(400).json({ error: 'invalid payload' })
}
const now = new Date().toISOString()
try {
const exists = db.prepare('SELECT id FROM weeks WHERE start_date=? AND end_date=?').get(weekStart, weekEnd)
if (exists && !overwrite) return res.status(409).json({ error: 'exists' })
if (exists && overwrite) {
db.prepare('UPDATE weeks SET position_json=?, plan_json=?, asset_json=?, updated_at=? WHERE id=?')
.run(JSON.stringify(positionData), JSON.stringify(planData), JSON.stringify(assetData), now, exists.id)
return res.json({ status: 'updated' })
}
db.prepare('INSERT INTO weeks (start_date, end_date, position_json, plan_json, asset_json, created_at, updated_at) VALUES (?,?,?,?,?,?,?)')
.run(weekStart, weekEnd, JSON.stringify(positionData), JSON.stringify(planData), JSON.stringify(assetData), now, now)
res.json({ status: 'saved' })
} catch (e) {
res.status(500).json({ error: e && e.message ? e.message : 'db error' })
}
})
app.get('/api/fund-shares', checkAuth, (req, res) => {
const { start, end } = req.query
if (!start || !end) return res.status(400).json({ error: 'missing params' })
try {
const row = db.prepare('SELECT data_json FROM fund_shares WHERE start_date=? AND end_date=?').get(start, end)
if (!row) return res.status(404).json({ error: 'not found' })
let payload = null
try { payload = JSON.parse(row.data_json) } catch (e) { payload = null }
let data = []
let summaryJson = null
if (Array.isArray(payload)) data = payload
else if (payload && typeof payload === 'object') { data = payload.data || []; summaryJson = payload.summary || null }
let summary = null
const srow = db.prepare('SELECT currency, balance, available, reference_value, asset_value, profit, day_profit FROM fund_summaries WHERE start_date=? AND end_date=?').get(start, end)
if (srow) {
summary = {
币种: srow.currency || null,
余额: typeof srow.balance === 'number' ? srow.balance : null,
可用: typeof srow.available === 'number' ? srow.available : null,
参考市值: typeof srow.reference_value === 'number' ? srow.reference_value : null,
资产: typeof srow.asset_value === 'number' ? srow.asset_value : null,
盈亏: typeof srow.profit === 'number' ? srow.profit : null,
当日盈亏: typeof srow.day_profit === 'number' ? srow.day_profit : null
}
}
if (!summary) summary = summaryJson || null
res.json({ data, summary })
} catch (e) {
res.status(500).json({ error: e && e.message ? e.message : 'db error' })
}
})
app.post('/api/fund-shares', checkAuth, (req, res) => {
const { weekStart, weekEnd, data, summary, overwrite } = req.body
if (!weekStart || !weekEnd || !Array.isArray(data)) {
return res.status(400).json({ error: 'invalid payload' })
}
const now = new Date().toISOString()
try {
const exists = db.prepare('SELECT id FROM fund_shares WHERE start_date=? AND end_date=?').get(weekStart, weekEnd)
if (exists && !overwrite) return res.status(409).json({ error: 'exists' })
if (exists && overwrite) {
db.prepare('UPDATE fund_shares SET data_json=?, updated_at=? WHERE id=?')
.run(JSON.stringify(data), now, exists.id)
// upsert summary
if (summary && typeof summary === 'object') {
const sexists = db.prepare('SELECT id FROM fund_summaries WHERE start_date=? AND end_date=?').get(weekStart, weekEnd)
const vals = [summary.币种 || null, summary.余额 ?? null, summary.可用 ?? null, summary.参考市值 ?? null, summary.资产 ?? null, summary.盈亏 ?? null, summary.当日盈亏 ?? null, now]
if (sexists) {
db.prepare('UPDATE fund_summaries SET currency=?, balance=?, available=?, reference_value=?, asset_value=?, profit=?, day_profit=?, updated_at=? WHERE id=?')
.run(...vals, sexists.id)
} else {
db.prepare('INSERT INTO fund_summaries (start_date, end_date, currency, balance, available, reference_value, asset_value, profit, day_profit, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)')
.run(weekStart, weekEnd, ...vals, now)
}
}
return res.json({ status: 'updated' })
}
db.prepare('INSERT INTO fund_shares (start_date, end_date, data_json, created_at, updated_at) VALUES (?,?,?,?,?)')
.run(weekStart, weekEnd, JSON.stringify(data), now, now)
if (summary && typeof summary === 'object') {
db.prepare('INSERT INTO fund_summaries (start_date, end_date, currency, balance, available, reference_value, asset_value, profit, day_profit, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)')
.run(weekStart, weekEnd, summary.币种 || null, summary.余额 ?? null, summary.可用 ?? null, summary.参考市值 ?? null, summary.资产 ?? null, summary.盈亏 ?? null, summary.当日盈亏 ?? null, now, now)
}
res.json({ status: 'saved' })
} catch (e) {
res.status(500).json({ error: e && e.message ? e.message : 'db error' })
}
})
app.delete('/api/fund-shares', checkAuth, (req, res) => {
const { start, end } = req.query
let password = null
try { password = (req.body && req.body.password) || null } catch (e) {}
if (!start || !end) return res.status(400).json({ error: 'missing params' })
if (password !== (process.env.SYSTEM_AUTH_PASS_93220 || '')) return res.status(403).json({ error: 'forbidden' })
try {
db.prepare('DELETE FROM fund_shares WHERE start_date=? AND end_date=?').run(start, end)
res.json({ status: 'deleted' })
} catch (e) {
res.status(500).json({ error: e && e.message ? e.message : 'db error' })
}
})
app.post('/api/fund-shares/delete', checkAuth, (req, res) => {
const { start, end, password } = req.body || {}
if (!start || !end) return res.status(400).json({ error: 'missing params' })
if (password !== (process.env.SYSTEM_AUTH_PASS_93220 || '')) return res.status(403).json({ error: 'forbidden' })
try {
db.prepare('DELETE FROM fund_shares WHERE start_date=? AND end_date=?').run(start, end)
res.json({ status: 'deleted' })
} catch (e) {
res.status(500).json({ error: e && e.message ? e.message : 'db error' })
}
})
app.post('/api/fund-shares/clear-all', checkAuth, (req, res) => {
const { password } = req.body || {}
if (password !== (process.env.SYSTEM_AUTH_PASS_93220 || '')) return res.status(403).json({ error: 'forbidden' })
try {
db.exec('DELETE FROM fund_shares')
db.exec('DELETE FROM fund_summaries')
res.json({ status: 'all_deleted' })
} catch (e) {
res.status(500).json({ error: e && e.message ? e.message : 'db error' })
}
})
app.get('/api/settings/excluded-stocks', checkAuth, (req, res) => {
try {
const rows = db.prepare('SELECT code FROM excluded_stocks').all()
res.json({ codes: rows.map((r) => r.code) })
} catch (e) {
res.status(500).json({ error: e && e.message ? e.message : 'db error' })
}
})
app.put('/api/settings/excluded-stocks', checkAuth, (req, res) => {
const { codes } = req.body
if (!Array.isArray(codes)) return res.status(400).json({ error: 'invalid payload' })
try {
db.transaction(() => {
db.exec('DELETE FROM excluded_stocks')
const stmt = db.prepare('INSERT OR REPLACE INTO excluded_stocks (code) VALUES (?)')
codes.forEach((code) => {
if (typeof code === 'string' && code.trim()) {
stmt.run(code.trim())
}
})
})()
res.json({ status: 'saved', codes })
} catch (e) {
res.status(500).json({ error: 'db error' })
}
})
}
module.exports = { bindRoutes }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,325 @@
const path = require('path')
const fs = require('fs')
const axios = require('axios')
const Database = require('better-sqlite3')
const { db } = require('./db')
const { logJSON } = require('./logger')
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const bridgeConfigPath = path.join(process.cwd(), 'config', 'security_calendar_calendar_bridge.json')
const bridgeDbPath = path.join(dataDir, 'security_calendar_calendar_bridge.db')
const bridgeDb = new Database(bridgeDbPath)
bridgeDb.exec(`CREATE TABLE IF NOT EXISTS bridge_sync (
source_key TEXT PRIMARY KEY,
event_id TEXT NOT NULL,
apply_date TEXT NOT NULL,
stock_code TEXT NOT NULL,
stock_name TEXT NOT NULL,
market TEXT DEFAULT '',
status TEXT NOT NULL,
synced_at TEXT DEFAULT (datetime('now','+8 hours')),
last_error TEXT DEFAULT '',
payload_json TEXT DEFAULT ''
);`)
const upsertSyncStmt = bridgeDb.prepare(`INSERT INTO bridge_sync (
source_key, event_id, apply_date, stock_code, stock_name, market, status, synced_at, last_error, payload_json
) VALUES (
@source_key, @event_id, @apply_date, @stock_code, @stock_name, @market, @status, datetime('now','+8 hours'), @last_error, @payload_json
)
ON CONFLICT(source_key) DO UPDATE SET
event_id = excluded.event_id,
apply_date = excluded.apply_date,
stock_code = excluded.stock_code,
stock_name = excluded.stock_name,
market = excluded.market,
status = excluded.status,
synced_at = datetime('now','+8 hours'),
last_error = excluded.last_error,
payload_json = excluded.payload_json`)
const listSyncRangeStmt = bridgeDb.prepare(`SELECT source_key, event_id, apply_date, stock_code, stock_name, market, status, synced_at, last_error, payload_json
FROM bridge_sync
WHERE apply_date >= ? AND apply_date <= ?
ORDER BY apply_date ASC, stock_code ASC`)
const getSyncByKeyStmt = bridgeDb.prepare(`SELECT source_key, event_id, apply_date, stock_code, stock_name, market, status, synced_at, last_error, payload_json
FROM bridge_sync
WHERE source_key = ?`)
const listIpoRangeStmt = db.prepare(`SELECT apply_date, stock_code, stock_name, market, issue_count_wan, apply_limit_wan, issue_price, pe_ratio, status, source
FROM security_calendar_ipos
WHERE apply_date >= ? AND apply_date <= ?
ORDER BY apply_date ASC, stock_code ASC`)
let schedulerStarted = false
const pad2 = n => String(n).padStart(2, '0')
const fmtDate = d => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
function sourceKeyOf(applyDate, stockCode) {
return `ipo:${String(applyDate || '').trim()}:${String(stockCode || '').trim()}`
}
function eventIdOf(applyDate, stockCode) {
return `sec-ipo-${String(applyDate || '').trim()}-${String(stockCode || '').trim()}`
}
function parseBeijingLocalToIso(dateStr, timeStr) {
const date = String(dateStr || '').trim()
const time = String(timeStr || '09:00').trim()
const matched = `${date} ${time}`.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})$/)
if (!matched) return new Date().toISOString()
const year = Number(matched[1])
const month = Number(matched[2])
const day = Number(matched[3])
const hour = Number(matched[4])
const minute = Number(matched[5])
const utcMs = Date.UTC(year, month - 1, day, hour - 8, minute, 0)
return new Date(utcMs).toISOString()
}
function calendarEndpoint() {
return `${getCalendarApiBases()[0]}/events`
}
function getCalendarApiBases() {
const defaults = [
'http://127.0.0.1:8976/api/calendar/external',
'http://127.0.0.1:8977/api/calendar/external'
]
try {
if (fs.existsSync(bridgeConfigPath)) {
const cfg = JSON.parse(fs.readFileSync(bridgeConfigPath, 'utf-8'))
const primary = String((cfg && cfg.calendar_api_base) || '').trim()
const fallbacks = Array.isArray(cfg && cfg.fallback_api_bases) ? cfg.fallback_api_bases.map(item => String(item || '').trim()).filter(Boolean) : []
return Array.from(new Set([primary].concat(fallbacks, defaults).filter(Boolean)))
}
} catch {}
return defaults
}
function buildMarketsCalendarUrl(applyDate) {
const month = String(applyDate || '').slice(0, 7)
return `/tools/markets?tab=calendar&month=${encodeURIComponent(month)}&date=${encodeURIComponent(String(applyDate || ''))}`
}
function buildPayload(item) {
const applyDate = String(item && item.apply_date || '').trim()
const stockCode = String(item && item.stock_code || '').trim()
const stockName = String(item && item.stock_name || '').trim() || stockCode
const market = String(item && item.market || 'A股').trim() || 'A股'
if (!applyDate || !stockCode) throw new Error('bad_ipo_identity')
const sourceKey = sourceKeyOf(applyDate, stockCode)
const eventId = eventIdOf(applyDate, stockCode)
const title = `${applyDate} 打新股提醒|${stockName}(${stockCode})|${market}`
const detailUrl = buildMarketsCalendarUrl(applyDate)
const lines = [
title,
`来源: 证券日历`,
`关键索引ID: ${sourceKey}`,
`日历事件ID: ${eventId}`,
`申购日期: ${applyDate}`,
`股票代码: ${stockCode}`,
`股票名称: ${stockName}`,
`交易所: ${market}`,
`状态: ${String(item && item.status || '待申购').trim() || '待申购'}`,
`链接: ${detailUrl}`
]
const issuePrice = Number(item && item.issue_price)
const peRatio = Number(item && item.pe_ratio)
const applyLimitWan = Number(item && item.apply_limit_wan)
if (Number.isFinite(issuePrice)) lines.push(`发行价: ${issuePrice.toFixed(2)} 元`)
if (Number.isFinite(peRatio)) lines.push(`市盈率: ${peRatio.toFixed(2)}`)
if (Number.isFinite(applyLimitWan)) lines.push(`顶格申购: ${applyLimitWan.toFixed(2)} 万股`)
const startIso = parseBeijingLocalToIso(applyDate, '09:00')
const endIso = parseBeijingLocalToIso(applyDate, '15:00')
const remindIso = parseBeijingLocalToIso(applyDate, '08:50')
return {
source_key: sourceKey,
event_id: eventId,
event_payload: {
id: eventId,
title,
content: lines.join('\n'),
start: startIso,
end: endIso,
remindAt: remindIso,
allDay: false,
completed: false,
deleted: false,
recurrence: ''
}
}
}
async function postEvent(payload) {
let lastError = null
for (const base of getCalendarApiBases()) {
const url = `${base}/events`
try {
await axios.post(url, payload, { timeout: 12000, headers: { 'Content-Type': 'application/json' } })
return
} catch (e) {
lastError = e
}
}
throw lastError || new Error('calendar_post_failed')
}
async function deleteEvent(eventId) {
let lastError = null
for (const base of getCalendarApiBases()) {
const url = `${base}/events?_method=DELETE&id=${encodeURIComponent(String(eventId || ''))}`
try {
await axios.post(url, {}, { timeout: 12000, headers: { 'Content-Type': 'application/json' } })
return
} catch (e) {
lastError = e
}
}
throw lastError || new Error('calendar_delete_failed')
}
async function upsertOne(item) {
const payload = buildPayload(item)
try {
await postEvent(payload.event_payload)
upsertSyncStmt.run({
source_key: payload.source_key,
event_id: payload.event_id,
apply_date: String(item.apply_date || ''),
stock_code: String(item.stock_code || ''),
stock_name: String(item.stock_name || ''),
market: String(item.market || ''),
status: 'synced',
last_error: '',
payload_json: JSON.stringify(payload.event_payload)
})
return { ok: true, source_key: payload.source_key, event_id: payload.event_id }
} catch (e) {
upsertSyncStmt.run({
source_key: payload.source_key,
event_id: payload.event_id,
apply_date: String(item.apply_date || ''),
stock_code: String(item.stock_code || ''),
stock_name: String(item.stock_name || ''),
market: String(item.market || ''),
status: 'error',
last_error: String(e && e.message ? e.message : e),
payload_json: JSON.stringify(payload.event_payload)
})
return { ok: false, source_key: payload.source_key, event_id: payload.event_id, error: String(e && e.message ? e.message : e) }
}
}
async function removeOneByBridge(row) {
const eventId = String((row && row.event_id) || '')
const sourceKey = String((row && row.source_key) || '')
if (!eventId || !sourceKey) return { ok: false, error: 'bad_bridge_row' }
try {
await deleteEvent(eventId)
upsertSyncStmt.run({
source_key: sourceKey,
event_id: eventId,
apply_date: String(row.apply_date || ''),
stock_code: String(row.stock_code || ''),
stock_name: String(row.stock_name || ''),
market: String(row.market || ''),
status: 'deleted',
last_error: '',
payload_json: ''
})
return { ok: true, source_key: sourceKey, event_id: eventId }
} catch (e) {
upsertSyncStmt.run({
source_key: sourceKey,
event_id: eventId,
apply_date: String(row.apply_date || ''),
stock_code: String(row.stock_code || ''),
stock_name: String(row.stock_name || ''),
market: String(row.market || ''),
status: 'error',
last_error: String(e && e.message ? e.message : e),
payload_json: ''
})
return { ok: false, source_key: sourceKey, event_id: eventId, error: String(e && e.message ? e.message : e) }
}
}
async function syncRange(startDate, endDate) {
const sourceRows = listIpoRangeStmt.all(startDate, endDate)
const bridgeRows = listSyncRangeStmt.all(startDate, endDate)
const sourceMap = new Map(sourceRows.map(item => [sourceKeyOf(item.apply_date, item.stock_code), item]))
const bridgeMap = new Map(bridgeRows.map(item => [String(item.source_key || ''), item]))
const upsertResults = []
const deleteResults = []
for (const item of sourceRows) {
upsertResults.push(await upsertOne(item))
}
for (const row of bridgeRows) {
if (!sourceMap.has(String(row.source_key || '')) && String(row.status || '') !== 'deleted') {
deleteResults.push(await removeOneByBridge(row))
}
}
const okUpserts = upsertResults.filter(item => item.ok).length
const okDeletes = deleteResults.filter(item => item.ok).length
const errors = upsertResults.concat(deleteResults).filter(item => !item.ok)
const result = {
ok: errors.length === 0,
start_date: startDate,
end_date: endDate,
source_count: sourceRows.length,
previous_bridge_count: bridgeMap.size,
upsert_count: okUpserts,
delete_count: okDeletes,
error_count: errors.length,
errors
}
logJSON('security_calendar.calendar_bridge.sync', result, 'markets')
return result
}
function getUpcomingRange(daysAhead) {
const now = new Date()
const start = fmtDate(now)
const end = fmtDate(new Date(now.getTime() + Math.max(7, daysAhead) * 24 * 60 * 60 * 1000))
return { start_date: start, end_date: end }
}
function startScheduler(securityCalendarModule) {
if (schedulerStarted) return
schedulerStarted = true
const currentPort = Number(process.env.PORT || '8976')
if (currentPort !== 8977) {
logJSON('security_calendar.calendar_bridge.scheduler.skip', { port: currentPort, recommended_runner_port: 8977 }, 'markets')
return
}
const run = async () => {
try {
const range = getUpcomingRange(45)
if (securityCalendarModule && typeof securityCalendarModule.ensureDateRangeData === 'function') {
await securityCalendarModule.ensureDateRangeData(range.start_date, range.end_date, { force_refresh: false })
}
await syncRange(range.start_date, range.end_date)
} catch (e) {
logJSON('security_calendar.calendar_bridge.scheduler.error', { error: String(e && e.message ? e.message : e) }, 'markets')
}
}
run()
setInterval(run, 4 * 60 * 60 * 1000)
}
function bySourceKey(sourceKey) {
return getSyncByKeyStmt.get(String(sourceKey || '').trim()) || null
}
module.exports = {
syncRange,
startScheduler,
bySourceKey,
getUpcomingRange
}
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
const path = require('path')
const fs = require('fs')
const assert = require('assert')
const crypto = require('crypto')
const { initCrypto, sign, verify, enc, dec, hashPassword } = require('./weekly_crypto')
const { initDB, getDB } = require('./weekly_db')
const core = require('./weekly_core')
const integrations = require('./weekly_integrations')
const TEST_DB = path.join(__dirname, 'test_weekly.db')
const TEST_ENC = path.join(__dirname, 'test_weekly.enc')
// Cleanup
if (fs.existsSync(TEST_DB)) fs.unlinkSync(TEST_DB)
if (fs.existsSync(TEST_ENC)) fs.unlinkSync(TEST_ENC)
console.log('--- Starting Weekly Module Tests ---')
// 1. Crypto Test
console.log('[1/5] Testing Crypto...')
initCrypto('testpass', 'testsalt')
const payload = { foo: 'bar' }
const sig = sign(payload)
assert.strictEqual(verify(payload, sig), true, 'Signature verification failed')
assert.strictEqual(verify(payload, 'wrong'), false, 'Signature rejection failed')
const secret = 'hello world'
const e = enc(secret)
const d = dec(e.ct, e.iv, e.tag)
assert.strictEqual(d, secret, 'Encryption/Decryption failed')
console.log(' Crypto OK')
// 2. DB Init
console.log('[2/5] Testing DB Init...')
initDB(TEST_DB, TEST_ENC)
const db = getDB()
const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all()
const expectedTables = ['users', 'reports', 'events', 'event_reviews', 'attachments', 'versions', 'logs', 'event_external', 'event_relations', 'email_sends']
expectedTables.forEach(t => {
assert.ok(tables.find(x => x.name === t), `Table ${t} missing`)
})
console.log(' DB Init OK')
// 3. Core Logic
console.log('[3/5] Testing Core Logic...')
// User
core.ensureDefaultUser()
const loginRes = core.login('admin_wy', process.env.WEEKLY_DEFAULT_PASSWORD || process.env.LOCK_DEFAULT_PASSWORD || '')
assert.strictEqual(loginRes.ok, true, 'Default user login failed')
// Report
const weekId = '2025-W01'
const rId = core.ensureReportForWeek(weekId)
const report = core.getReportByWeek(weekId)
assert.strictEqual(report.week_id, weekId, 'Report creation failed')
// Event
const eId = core.createEvent({
report_id: rId,
week_id: weekId,
title: 'Test Event',
content: 'Test Content',
status_code: 'doing',
priority: 1
})
const events = core.listEvents(rId)
assert.strictEqual(events.length, 1, 'Event listing failed')
assert.strictEqual(events[0].title, 'Test Event', 'Event content mismatch')
// Update Event
core.updateEvent({ id: eId, status_code: 'done' })
const events2 = core.listEvents(rId)
assert.strictEqual(events2[0].status_code, 'done', 'Event update failed')
// Search
// Need to wait for index rebuild? It's async in scheduleMaintain but synchronous in initial load.
// core.js rebuildIndex is not exported directly but called inside module?
// Actually I exported it.
core.rebuildIndex()
const searchRes = core.search('Test')
console.log('Search Results:', JSON.stringify(searchRes, null, 2))
assert.strictEqual(searchRes.filter(x => x.type === 'event').length, 1, 'Search failed')
console.log(' Core Logic OK')
// 4. Integrations
console.log('[4/5] Testing Integrations...')
// Just check if functions exist
assert.strictEqual(typeof integrations.pushCalendarByEventId, 'function')
assert.strictEqual(typeof integrations.summarizeText, 'function')
console.log(' Integrations Interface OK')
// 5. Cleanup
if (db && typeof db.close === 'function') db.close()
if (fs.existsSync(TEST_DB)) fs.unlinkSync(TEST_DB)
if (fs.existsSync(TEST_ENC)) fs.unlinkSync(TEST_ENC)
console.log('--- All Tests Passed ---')
+187
View File
@@ -0,0 +1,187 @@
const axios = require('axios')
const fs = require('fs')
const path = require('path')
const Database = require('better-sqlite3')
const { logJSON } = require('./logger')
const SH_LAT = 31.2304
const SH_LON = 121.4737
const fetchWeather = async (lat, lon) => {
const latitude = Number(lat) || SH_LAT
const longitude = Number(lon) || SH_LON
try {
const urlWeather = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=temperature_2m,apparent_temperature,precipitation&hourly=precipitation_probability&daily=temperature_2m_max,temperature_2m_min&timezone=auto`
const urlAir = `https://air-quality-api.open-meteo.com/v1/air-quality?latitude=${latitude}&longitude=${longitude}&hourly=us_aqi,pm2_5,european_aqi&timezone=auto`
const [wRes, aRes] = await Promise.all([
axios.get(urlWeather, { timeout: 8000 }),
axios.get(urlAir, { timeout: 8000 })
])
const w = wRes.data || {}
const a = aRes.data || {}
const cur = (w.current || {})
let prob = null
try {
const times = (w.hourly && w.hourly.time) || []
const probs = (w.hourly && w.hourly.precipitation_probability) || []
const nowIso = new Date().toISOString().slice(0,13)
const idx = times.findIndex(t => String(t).slice(0,13) === nowIso)
if (idx >= 0) prob = probs[idx]
} catch {}
const hAqi = (a.hourly || {})
let tmin = null, tmax = null
try {
const dTimes = (w.daily && w.daily.time) || []
const dMin = (w.daily && w.daily.temperature_2m_min) || []
const dMax = (w.daily && w.daily.temperature_2m_max) || []
const today = new Date().toISOString().slice(0,10)
const di = dTimes.findIndex(t => String(t).slice(0,10) === today)
if (di >= 0) { tmin = dMin[di]; tmax = dMax[di] }
} catch {}
const info = {
source: 'open-meteo',
city: '上海',
temperature: cur.temperature_2m,
apparent_temperature: cur.apparent_temperature,
precipitation: cur.precipitation,
precipitation_probability: prob,
us_aqi: hAqi.us_aqi ? hAqi.us_aqi[0] : undefined,
european_aqi: hAqi.european_aqi ? hAqi.european_aqi[0] : undefined,
pm2_5: hAqi.pm2_5 ? hAqi.pm2_5[0] : undefined,
temperature_min: tmin,
temperature_max: tmax
}
logJSON('wall.weather', { latitude, longitude, info }, 'wall')
return info
} catch (e) {
logJSON('wall.weather.error', { error: String(e.message || e) }, 'wall')
return { source: 'open-meteo', city: '上海', temperature: null, apparent_temperature: null, precipitation: null, precipitation_probability: null, us_aqi: null, pm2_5: null }
}
}
module.exports = { fetchWeather }
const cfgPath = path.join(process.cwd(), 'config', 'wall.json')
if (!fs.existsSync(cfgPath)) fs.writeFileSync(cfgPath, JSON.stringify({
autoplay_ms: 45000,
swipe_pause_ms: 5 * 60 * 1000,
max_cards_on_screen: 2,
background: { url: "", mode: "tile", size: 120, opacity: 0.08 },
cards: [
{ title: '轻断食 · 吃什么', items: ['清汤/水', '清蒸蛋/少量蛋白', '蔬菜少油', '避免高糖与精制', '避免冷饮'], meta: '场景:晨间/午后 · 时长≤3秒读' },
{ title: '冷水澡 · 准备步骤', items: ['热身2–3分钟', '先温水→冷水渐进', '重点避头部直冲', '备干毛巾与保暖', '注意心率与眩晕'], meta: '场景:沐浴前 · 安全优先' },
{ title: '跑步 · 前置准备', items: ['热身动态拉伸', '检查鞋与袜', '补水少量', '路线与天气确认', '佩戴反光/夜跑灯'], meta: '场景:运动前 · 目标安全' }
]
}, null, 2))
const getConfig = () => JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
const setConfig = cfg => fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2))
module.exports.getConfig = getConfig
module.exports.setConfig = setConfig
const dataDir = path.join(process.cwd(), 'data')
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
const wallDbPath = path.join(dataDir, 'call.db')
const wallDb = new Database(wallDbPath)
wallDb.exec(`CREATE TABLE IF NOT EXISTS wall_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
items_json TEXT NOT NULL,
meta TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);`)
const countCardsRow = () => {
try {
const row = wallDb.prepare('SELECT COUNT(*) AS c FROM wall_cards').get()
if (row && typeof row.c === 'number') return row.c
return 0
} catch {
return 0
}
}
const ensureInitialCardsFromConfig = () => {
try {
if (countCardsRow() > 0) return
const cfg = getConfig()
const cards = Array.isArray(cfg.cards) ? cfg.cards : []
if (!cards.length) return
const insert = wallDb.prepare(`INSERT INTO wall_cards (title, items_json, meta) VALUES (@title, @items_json, @meta)`)
const tx = wallDb.transaction((list) => {
for (const c of list) {
const items = Array.isArray(c.items) ? c.items : []
insert.run({ title: String(c.title || ''), items_json: JSON.stringify(items), meta: String(c.meta || '') })
}
})
tx(cards)
try { logJSON('wall.cards.migrate_from_config', { count: cards.length }, 'wall') } catch {}
} catch (e) {
try { logJSON('wall.cards.migrate_from_config.error', { error: String(e.message || e) }, 'wall') } catch {}
}
}
ensureInitialCardsFromConfig()
const listCardsStmt = wallDb.prepare(`SELECT id, title, items_json, meta, created_at, updated_at FROM wall_cards ORDER BY id ASC`)
const insertCardStmt = wallDb.prepare(`INSERT INTO wall_cards (title, items_json, meta) VALUES (@title, @items_json, @meta)`)
const updateCardStmt = wallDb.prepare(`UPDATE wall_cards SET title=@title, items_json=@items_json, meta=@meta, updated_at=datetime('now') WHERE id=@id`)
const deleteCardStmt = wallDb.prepare(`DELETE FROM wall_cards WHERE id=?`)
const normalizeItems = (items) => {
if (Array.isArray(items)) return items.map(x => String(x || ''))
const text = String(items || '')
if (!text.trim()) return []
return text.split(/\r?\n/).map(s => s.trim()).filter(Boolean)
}
const listCards = () => {
const rows = listCardsStmt.all()
return rows.map(r => {
let items = []
try {
const parsed = JSON.parse(r.items_json || '[]')
if (Array.isArray(parsed)) items = parsed.map(x => String(x || ''))
} catch {}
return {
id: r.id,
title: r.title || '',
items,
meta: r.meta || '',
created_at: r.created_at,
updated_at: r.updated_at
}
})
}
const createCard = (payload) => {
const items = normalizeItems(payload.items)
const items_json = JSON.stringify(items)
const title = String(payload.title || '')
const meta = String(payload.meta || '')
const info = insertCardStmt.run({ title, items_json, meta })
try { logJSON('wall.card.create', { id: info.lastInsertRowid, title }, 'wall') } catch {}
return info.lastInsertRowid
}
const updateCardById = (id, payload) => {
const items = normalizeItems(payload.items)
const items_json = JSON.stringify(items)
const title = String(payload.title || '')
const meta = String(payload.meta || '')
updateCardStmt.run({ id, title, items_json, meta })
try { logJSON('wall.card.update', { id, title }, 'wall') } catch {}
}
const deleteCardById = (id) => {
deleteCardStmt.run(id)
try { logJSON('wall.card.delete', { id }, 'wall') } catch {}
}
module.exports.listCards = listCards
module.exports.createCard = createCard
module.exports.updateCardById = updateCardById
module.exports.deleteCardById = deleteCardById

Some files were not shown because too many files have changed in this diff Show More