Files
gold-worm-icbc/GoldPrice.js

579 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const cheerio = require('cheerio');
const https = require('https');
const crypto = require('crypto');
const TARGET_URL = 'https://mybank.icbc.com.cn/icbc/newperbank/perbank3/gold/goldaccrual_query_out.jsp';
const PRICE_SELECTOR = '#activeprice_080020000521';
const DATA_FILE = path.join(__dirname, 'gold_data.json');
// 自定义 HTTPS Agent允许不安全证书并启用旧版 SSL 重协商兼容
const insecureHttpsAgent = new https.Agent({
rejectUnauthorized: false,
secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT || 0,
});
// ServerChan Key 配置:优先使用直接设置,否则使用环境变量
// 支持多个key使用分号;)分割
const SERVER_CHAN_KEY_DIRECT = ''; // 直接设置 ServerChan Key有值时禁用环境变量多个key用分号分割
const SERVER_CHAN_KEY_RAW = SERVER_CHAN_KEY_DIRECT || process.env.PUSH_KEYT || '';
const SERVER_CHAN_KEYS = SERVER_CHAN_KEY_RAW.split(';').map(k => k.trim()).filter(k => k.length > 0);
// 可自定义的特殊提醒价格数组:[价格1,价格2,价格3,价格4,价格5,价格6,价格7]
const SPECIAL_PRICE_TARGETS = [1000,1010,1020,1030,1040,1050,1060,1070,1080,1090,1100,1110,1120,1130,1140,1150,1160,1170,1180,1190,1200,1210,1220,1230,1240,1250,1260,1270,1280,1290,1300,1310,1320,1330,1340,1350,1360,1370,1380,1390,1400,1410,1420,1430,1440,1450,1460,1470,1480,1490,1500];
// 可配置项:变化量阈值(元/克)、变化计算窗口(分钟)、采样间隔(分钟)、价格精度(小数位数)
const CONFIG = {
changeThresholdYuan: 1.0,
changeWindowMinutes: 60,
sampleIntervalMinutes: 1,
pricePrecisionDigits: 2,
enableTimedPush: false, // 定时推送开关(变化量触发和收盘推送)
enableSpecialAlert: true, // 特殊价格提醒开关
pushIntervalMinutes: 60, // 推送间隔(分钟),防止频繁推送
forcePushTest: false, // 测试强制推送:为 true 时每次运行必推送一次并立即退出
// 禁用推送时间段,格式为 8 位数字 [HHMMHHMM],如 10301230 表示 10:30-12:30
disabledWindows: [12001500],
// 特殊价格强制推送:为 true 时命中特殊价将忽略禁用时间段
enableSpecialForcePush: true,
// 特殊价格忽略推送间隔:为 true 时命中特殊价将忽略 pushIntervalMinutes 限制,即忽略普通推送时间限制
enableSpecialBypassInterval: true,
// 单个价格冷却时间的默认值(分钟),当某个价格未在 specialPriceCoolDownMinutes 中单独配置时使用
coolDownMinutesForSinglePrice: 90,
// 命中某个特殊价格时,是否清零其他价格的冷却时间(价格 a 冷却中,如果价格 b 触发,则清零 a 的冷却)
resetOtherSpecialCooldownOnHit: true,
// 每个特殊价格的单独冷却时间分钟key 为价格value 为分钟数;未配置则使用 coolDownMinutesForSinglePrice 作为默认值
specialPriceCoolDownMinutes: {
},
};
function getSpecialPriceCoolDownMinutes(price) {
if (!price && price !== 0) return 0;
const map = CONFIG.specialPriceCoolDownMinutes || {};
const key = String(price);
if (Object.prototype.hasOwnProperty.call(map, key)) {
return Number(map[key]) || 0;
}
return CONFIG.coolDownMinutesForSinglePrice || 0;
}
function readData() {
try {
if (!fs.existsSync(DATA_FILE)) return { priceHistory: [], closingPrices: [], openingPrices: [], specialTargets: SPECIAL_PRICE_TARGETS, lastPushTs: 0, lastNormalPushTs: 0, lastSpecialPricePush: {} };
const raw = fs.readFileSync(DATA_FILE, 'utf-8');
const data = JSON.parse(raw || '{}');
if (!Array.isArray(data.priceHistory)) data.priceHistory = [];
if (!Array.isArray(data.closingPrices)) data.closingPrices = [];
if (!Array.isArray(data.openingPrices)) data.openingPrices = [];
if (!Array.isArray(data.specialTargets)) data.specialTargets = SPECIAL_PRICE_TARGETS;
if (typeof data.lastPushTs !== 'number') data.lastPushTs = 0;
if (typeof data.lastNormalPushTs !== 'number') data.lastNormalPushTs = 0;
if (!data.lastSpecialPricePush || typeof data.lastSpecialPricePush !== 'object') data.lastSpecialPricePush = {};
return data;
} catch {
return { priceHistory: [], closingPrices: [], openingPrices: [], specialTargets: SPECIAL_PRICE_TARGETS, lastPushTs: 0, lastNormalPushTs: 0, lastSpecialPricePush: {} };
}
}
function writeData(data) {
const safe = {
priceHistory: Array.isArray(data.priceHistory) ? data.priceHistory : [],
closingPrices: Array.isArray(data.closingPrices) ? data.closingPrices : [],
openingPrices: Array.isArray(data.openingPrices) ? data.openingPrices : [],
specialTargets: Array.isArray(data.specialTargets) ? data.specialTargets : SPECIAL_PRICE_TARGETS,
lastPushTs: typeof data.lastPushTs === 'number' ? data.lastPushTs : 0,
lastNormalPushTs: typeof data.lastNormalPushTs === 'number' ? data.lastNormalPushTs : 0,
lastSpecialPricePush: data.lastSpecialPricePush && typeof data.lastSpecialPricePush === 'object' ? data.lastSpecialPricePush : {},
};
fs.writeFileSync(DATA_FILE, JSON.stringify(safe, null, 2), 'utf-8');
}
function nowTs() {
return Date.now();
}
function toCNDate(ts = Date.now()) {
const d = new Date(ts + 8 * 60 * 60 * 1000);
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function toCNTimeParts(ts = Date.now()) {
const d = new Date(ts + 8 * 60 * 60 * 1000);
const hh = d.getUTCHours();
const mm = d.getUTCMinutes();
return { hh, mm };
}
function isInDisabledWindow(ts = Date.now()) {
if (!Array.isArray(CONFIG.disabledWindows) || CONFIG.disabledWindows.length === 0) return false;
const { hh, mm } = toCNTimeParts(ts);
const hhmm = hh * 100 + mm; // e.g., 10:30 -> 1030
for (const win of CONFIG.disabledWindows) {
const num = Number(win);
if (!Number.isFinite(num) || String(Math.abs(num)).length < 7) continue;
const start = Math.floor(num / 10000); // 前四位 HHMM
const end = num % 10000; // 后四位 HHMM
if (start <= hhmm && hhmm <= end) return true;
}
return false;
}
async function fetchCurrentPrice() {
console.log('[gold] fetching html to parse price...');
const resp = await axios.get(TARGET_URL, {
timeout: 60000,
httpsAgent: insecureHttpsAgent,
headers: {
// 部分站点会根据 UA 返回不同内容,给一个常见 UA 更稳
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
},
// 某些服务端会返回非 200 但仍有 HTML这里保持默认即可若后续遇到问题再放开 validateStatus
});
const html = resp && resp.data ? String(resp.data) : '';
if (!html) throw new Error('目标页面返回空内容');
const $ = cheerio.load(html);
const text = $(PRICE_SELECTOR).text().trim();
const numeric = parseFloat(String(text).replace(/[^0-9.\-]/g, ''));
if (!isFinite(numeric)) {
throw new Error(`无法解析价格selector=${PRICE_SELECTOR}text=${JSON.stringify(text)}`);
}
console.log(`[gold] fetched current price: ${numeric}`);
return numeric;
}
function findDeltaWithin(history, minutes) {
if (!history.length) return null;
const now = nowTs();
const targetMs = minutes * 60 * 1000;
const cutoff = now - targetMs;
let candidate = null;
for (let i = history.length - 1; i >= 0; i--) {
const item = history[i];
if (item.ts <= cutoff) {
candidate = item;
break;
}
}
return candidate;
}
function computeTrendType(values) {
if (values.length < 2) return '数据不足';
const n = values.length;
let sumX = 0;
let sumY = 0;
let sumXY = 0;
let sumXX = 0;
for (let i = 0; i < n; i++) {
const x = i + 1;
const y = values[i];
sumX += x;
sumY += y;
sumXY += x * y;
sumXX += x * x;
}
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
const eps = 0.02; // 趋势阈值(元/克/天)
if (slope > eps) return '上涨';
if (slope < -eps) return '下跌';
return '持平';
}
function computeChangeAmount(series, days) {
if (series.length < days + 1) return { price7DaysAgo: null, change: null };
const last = series[series.length - 1];
const prev = series[series.length - 1 - days];
return {
price7DaysAgo: Number(prev.toFixed(CONFIG.pricePrecisionDigits)),
change: Number((last - prev).toFixed(CONFIG.pricePrecisionDigits))
};
}
async function sendServerChan(title, desp, tags = '黄金价格') {
const keys = SERVER_CHAN_KEYS;
if (!keys || keys.length === 0) throw new Error('PUSH_KEYT 未配置');
const mod = await import('serverchan-sdk');
const scSend = mod.scSend || (mod.default && mod.default.scSend);
if (!scSend) throw new Error('serverchan-sdk 未找到 scSend');
// 向所有key推送
const results = [];
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
try {
const response = await scSend(key, title, desp, { tags });
results.push({ keyIndex: i + 1, success: true, response });
console.log(`[gold] push sent to key ${i + 1}/${keys.length}. status:`, response);
} catch (e) {
results.push({ keyIndex: i + 1, success: false, error: e && e.message ? e.message : e });
console.log(`[gold] push failed for key ${i + 1}/${keys.length}:`, e && e.message ? e.message : e);
}
}
// 如果所有key都失败抛出错误
const allFailed = results.every(r => !r.success);
if (allFailed) {
throw new Error(`所有推送都失败: ${results.map(r => r.error || '未知错误').join('; ')}`);
}
return { statusCode: 200, body: JSON.stringify({ results, totalKeys: keys.length, successCount: results.filter(r => r.success).length }) };
}
function formatNumber(n) {
return Number(n).toFixed(CONFIG.pricePrecisionDigits);
}
function withinClosingWindow(ts = Date.now()) {
const { hh, mm } = toCNTimeParts(ts);
return hh === 22 && mm >= 30 && mm <= 59;
}
function upsertClosingPrice(data, price, ts = Date.now()) {
const dateStr = toCNDate(ts);
const existsIdx = data.closingPrices.findIndex(x => x.date === dateStr);
if (existsIdx >= 0) {
data.closingPrices[existsIdx].price = price;
} else {
data.closingPrices.push({ date: dateStr, price });
}
while (data.closingPrices.length > 30) data.closingPrices.shift();
}
function upsertOpeningPrice(data, price, ts = Date.now()) {
const dateStr = toCNDate(ts);
const existsIdx = data.openingPrices.findIndex(x => x.date === dateStr);
if (existsIdx >= 0) {
// 如果已存在,不更新(开盘价只记录一次)
return false;
} else {
data.openingPrices.push({ date: dateStr, price });
while (data.openingPrices.length > 30) data.openingPrices.shift();
return true;
}
}
function getYesterdayClosingPrice(data) {
const now = Date.now();
const yesterdayTs = now - 24 * 60 * 60 * 1000; // 减去一天的毫秒数
const yesterdayStr = toCNDate(yesterdayTs);
const yesterdayRecord = data.closingPrices.find(x => x.date === yesterdayStr);
return yesterdayRecord ? yesterdayRecord.price : null;
}
function getTodayOpeningPrice(data) {
const today = toCNDate();
const todayRecord = data.openingPrices.find(x => x.date === today);
return todayRecord ? todayRecord.price : null;
}
function extractSeries(data) {
return data.closingPrices.map(x => x.price);
}
function checkSpecialTargets(data, lastPrice, currentPrice) {
const targets = Array.isArray(data.specialTargets) && data.specialTargets.length ? data.specialTargets : SPECIAL_PRICE_TARGETS;
if (!targets || !targets.length) return null;
// 如果没有上一笔价格,则退化为接近检测,避免首次运行完全不触发
if (lastPrice === undefined || lastPrice === null) {
const tol = 0.2; // 首次检测仍保留一个很小的容差建议范围0.1-0.5
for (const t of targets) {
if (Math.abs(currentPrice - t) <= tol) return t;
}
return null;
}
// 穿过监测:上一笔与当前价覆盖到目标价就算“穿过”
// 说明:用 <= / >= 包含“刚好等于目标价”的边界,避免 prev===t 时离开 t 不触发的问题
for (const t of targets) {
const prev = lastPrice;
const curr = currentPrice;
if ((prev <= t && curr >= t) || (prev >= t && curr <= t)) {
return t;
}
}
return null;
}
function buildPushPayload({
currentPrice,
delta1h,
change7,
trend7,
trend14,
trend28,
yesterdayClosingPrice,
todayOpeningPrice,
isSpecial,
isTest,
}) {
const title = isSpecial
? `特殊提醒:黄金价格达到${formatNumber(currentPrice)}元/克`
: (isTest ? '黄金价格监控(测试)' : '黄金价格监控');
const tags = isTest ? '黄金价格|TEST' : '黄金价格';
const parts = [];
if (isSpecial) parts.push(`特殊提醒:黄金价格达到${formatNumber(currentPrice)}元/克;七日价格趋势:${trend7}`);
parts.push(`- 当前黄金价格为:${formatNumber(currentPrice)}元/克`);
parts.push(`- 一小时变化量为:${delta1h !== null ? formatNumber(delta1h) : '数据不足'}元/克`);
parts.push(`- 七日前价格为:${change7.price7DaysAgo !== null ? formatNumber(change7.price7DaysAgo) : '数据不足'}元/克;七日价格变化量为:${change7.change !== null ? formatNumber(change7.change) : '数据不足'}元/克`);
parts.push(`- 七日价格趋势为:${trend7}`);
parts.push(`- 十四日价格趋势为:${trend14}`);
parts.push(`- 二十八日价格趋势为:${trend28}`);
parts.push(`- 昨日收盘价格为:${yesterdayClosingPrice !== null ? formatNumber(yesterdayClosingPrice) : '数据不足'}元/克`);
parts.push(`- 今日开盘价格为:${todayOpeningPrice !== null ? formatNumber(todayOpeningPrice) : '数据不足'}元/克`);
return { title, desp: parts.join(';\n'), tags };
}
async function runCycle() {
console.log('[gold] service starting...');
console.log('[gold] config:', {
changeThresholdYuan: CONFIG.changeThresholdYuan,
changeWindowMinutes: CONFIG.changeWindowMinutes,
sampleIntervalMinutes: CONFIG.sampleIntervalMinutes,
pricePrecisionDigits: CONFIG.pricePrecisionDigits,
enableTimedPush: CONFIG.enableTimedPush,
enableSpecialAlert: CONFIG.enableSpecialAlert,
pushIntervalMinutes: CONFIG.pushIntervalMinutes,
forcePushTest: CONFIG.forcePushTest,
disabledWindows: CONFIG.disabledWindows,
enableSpecialForcePush: CONFIG.enableSpecialForcePush,
enableSpecialBypassInterval: CONFIG.enableSpecialBypassInterval,
coolDownMinutesForSinglePrice: CONFIG.coolDownMinutesForSinglePrice,
specialPriceCoolDownMinutes: CONFIG.specialPriceCoolDownMinutes,
serverChanKeySource: SERVER_CHAN_KEY_DIRECT ? 'direct' : 'env',
serverChanKeyCount: SERVER_CHAN_KEYS.length,
});
const data = readData();
console.log('[gold] data loaded:', {
priceHistoryCount: Array.isArray(data.priceHistory) ? data.priceHistory.length : 0,
closingPricesCount: Array.isArray(data.closingPrices) ? data.closingPrices.length : 0,
openingPricesCount: Array.isArray(data.openingPrices) ? data.openingPrices.length : 0,
specialTargetsCount: Array.isArray(data.specialTargets) ? data.specialTargets.length : 0,
});
const currentPrice = await fetchCurrentPrice();
const ts = nowTs();
// 记录今日开盘价格(如果是今天第一次运行)
const openingPriceRecorded = upsertOpeningPrice(data, currentPrice, ts);
if (openingPriceRecorded) {
console.log('[gold] today opening price recorded:', currentPrice);
}
const last = data.priceHistory[data.priceHistory.length - 1];
if (!last || ts - last.ts >= CONFIG.sampleIntervalMinutes * 60 * 1000) {
data.priceHistory.push({ ts, price: currentPrice });
while (data.priceHistory.length > 2000) data.priceHistory.shift();
console.log('[gold] appended to priceHistory. total:', data.priceHistory.length);
}
if (withinClosingWindow(ts)) {
upsertClosingPrice(data, currentPrice, ts);
console.log('[gold] within closing window. closing price upserted. total days:', data.closingPrices.length);
}
writeData(data);
console.log('[gold] data saved to', DATA_FILE);
const oneHourAgo = findDeltaWithin(data.priceHistory, CONFIG.changeWindowMinutes);
const delta1h = oneHourAgo ? Number((currentPrice - oneHourAgo.price).toFixed(CONFIG.pricePrecisionDigits)) : null;
console.log('[gold] compute deltas:', { windowMinutes: CONFIG.changeWindowMinutes, delta: delta1h });
const series = extractSeries(data);
const change7 = computeChangeAmount(series, 7);
const trend7 = series.length >= 7 ? computeTrendType(series.slice(-7)) : '数据不足';
const trend14 = series.length >= 14 ? computeTrendType(series.slice(-14)) : '数据不足';
const trend28 = series.length >= 28 ? computeTrendType(series.slice(-28)) : '数据不足';
console.log('[gold] trends:', { change7: change7.change, price7DaysAgo: change7.price7DaysAgo, trend7, trend14, trend28 });
// 获取昨日收盘价格和今日开盘价格
const yesterdayClosingPrice = getYesterdayClosingPrice(data);
const todayOpeningPrice = getTodayOpeningPrice(data);
console.log('[gold] daily prices:', { yesterdayClosingPrice, todayOpeningPrice });
const specialHit = checkSpecialTargets(data, last ? last.price : null, currentPrice);
if (specialHit !== null) console.log('[gold] special target hit:', specialHit);
// 检查特殊价格冷却期(每个价格单独的冷却时间)
let isSpecialInCoolDown = false;
if (specialHit !== null) {
const now = nowTs();
const lastPushTsForThisPrice = data.lastSpecialPricePush[specialHit];
const coolDownMinutesForPrice = getSpecialPriceCoolDownMinutes(specialHit);
if (coolDownMinutesForPrice > 0 && lastPushTsForThisPrice) {
const elapsed = now - lastPushTsForThisPrice;
const coolDownMs = coolDownMinutesForPrice * 60 * 1000;
if (elapsed < coolDownMs) {
isSpecialInCoolDown = true;
const minutesPassed = Math.round(elapsed / 60000);
console.log(`[gold] special price ${specialHit} in cool down: ${minutesPassed}/${coolDownMinutesForPrice} minutes`);
}
}
}
// 测试强制推送:无视触发条件与间隔限制,推送一次后退出
if (CONFIG.forcePushTest) {
console.log('[gold] forcePushTest enabled: will push once and exit.');
const payload = buildPushPayload({
currentPrice,
delta1h,
change7,
trend7,
trend14,
trend28,
yesterdayClosingPrice,
todayOpeningPrice,
isSpecial: false,
isTest: true,
});
try {
const resp = await sendServerChan(payload.title, payload.desp, payload.tags);
console.log('[gold] [TEST] push sent. status:', resp && resp.statusCode);
data.lastPushTs = nowTs();
writeData(data);
} catch (e) {
console.log('[gold] [TEST] push failed:', e && e.message ? e.message : e);
}
return; // 立即结束本次运行
}
const shouldPushByDelta = CONFIG.enableTimedPush && delta1h !== null && Math.abs(delta1h) >= CONFIG.changeThresholdYuan;
const shouldPushBySpecial = CONFIG.enableSpecialAlert && specialHit !== null && !isSpecialInCoolDown;
const shouldPushByClosing = CONFIG.enableTimedPush && withinClosingWindow(ts);
// 检查推送间隔限制
const now = nowTs();
// 普通推送(非特殊推送)使用独立的时间戳
const timeSinceLastNormalPush = now - data.lastNormalPushTs;
const pushIntervalMs = CONFIG.pushIntervalMinutes * 60 * 1000;
const canPushByInterval = timeSinceLastNormalPush >= pushIntervalMs;
const inDisabledWindow = isInDisabledWindow(ts);
const bypassDisabledBySpecial = shouldPushBySpecial && CONFIG.enableSpecialForcePush;
const bypassIntervalBySpecial = shouldPushBySpecial && CONFIG.enableSpecialBypassInterval;
console.log('[gold] push decisions:', {
shouldPushByDelta,
shouldPushBySpecial,
shouldPushByClosing,
canPushByInterval,
inDisabledWindow,
bypassDisabledBySpecial,
bypassIntervalBySpecial,
timeSinceLastNormalPushMinutes: Math.round(timeSinceLastNormalPush / 60000)
});
const anyTrigger = shouldPushByDelta || shouldPushBySpecial || shouldPushByClosing;
if (anyTrigger && (canPushByInterval || bypassIntervalBySpecial) && (!inDisabledWindow || bypassDisabledBySpecial)) {
const payload = buildPushPayload({
currentPrice,
delta1h,
change7,
trend7,
trend14,
trend28,
yesterdayClosingPrice,
todayOpeningPrice,
isSpecial: shouldPushBySpecial,
isTest: false,
});
try {
const resp = await sendServerChan(payload.title, payload.desp, payload.tags);
console.log('[gold] push sent. status:', resp && resp.statusCode);
// 更新最后推送时间:特殊推送不更新普通推送时间戳
data.lastPushTs = now;
if (!shouldPushBySpecial || !CONFIG.enableSpecialBypassInterval) {
data.lastNormalPushTs = now;
}
// 特殊推送时记录该价格的推送时间用于冷却期判断
if (shouldPushBySpecial && specialHit !== null) {
data.lastSpecialPricePush[specialHit] = now;
// 命中某个特殊价格时,可选地清零其他价格的冷却时间
if (CONFIG.resetOtherSpecialCooldownOnHit && data.lastSpecialPricePush && typeof data.lastSpecialPricePush === 'object') {
const hitKey = String(specialHit);
for (const k of Object.keys(data.lastSpecialPricePush)) {
if (k !== hitKey) {
// 归零表示立即失效,下次再次触发时可立即推送
delete data.lastSpecialPricePush[k];
}
}
}
}
writeData(data);
} catch (e) {
console.log('[gold] push failed:', e && e.message ? e.message : e);
// 推送失败不影响数据持久化
}
} else if (anyTrigger && !canPushByInterval && !bypassIntervalBySpecial) {
console.log('[gold] push skipped by interval throttle:', {
pushIntervalMinutes: CONFIG.pushIntervalMinutes,
minutesSinceLast: Math.round(timeSinceLastNormalPush / 60000),
specialBypassIntervalEnabled: CONFIG.enableSpecialBypassInterval,
shouldPushBySpecial,
});
} else if (anyTrigger && inDisabledWindow) {
console.log('[gold] push skipped by disabled window:', {
disabledWindows: CONFIG.disabledWindows,
specialForcePushEnabled: CONFIG.enableSpecialForcePush,
shouldPushBySpecial,
});
} else if (!anyTrigger) {
console.log('[gold] push skipped: no trigger matched', {
delta1h,
changeThresholdYuan: CONFIG.changeThresholdYuan,
enableTimedPush: CONFIG.enableTimedPush,
enableSpecialAlert: CONFIG.enableSpecialAlert,
withinClosingWindow: withinClosingWindow(ts),
});
}
}
let __isRunning = false;
let __timer = null;
function startScheduler() {
console.log('[gold] scheduler starting: will run every', CONFIG.sampleIntervalMinutes, 'minute(s)');
const safeRun = async () => {
if (__isRunning) {
console.log('[gold] previous cycle still running, skip this tick.');
return;
}
__isRunning = true;
try {
await runCycle();
const now = nowTs();
if (withinClosingWindow(now)) {
console.log('[gold] within closing window, stopping scheduler and exiting.');
if (__timer) clearInterval(__timer);
process.exit(0);
}
} catch (e) {
console.log('[gold] cycle error:', e && e.message ? e.message : e);
} finally {
__isRunning = false;
}
};
// run immediately once
safeRun();
// then schedule
__timer = setInterval(safeRun, CONFIG.sampleIntervalMinutes * 60 * 1000);
}
process.on('SIGINT', () => { console.log('[gold] SIGINT received, exiting.'); if (__timer) clearInterval(__timer); process.exit(0); });
process.on('SIGTERM', () => { console.log('[gold] SIGTERM received, exiting.'); if (__timer) clearInterval(__timer); process.exit(0); });
startScheduler();