557 lines
26 KiB
JavaScript
557 lines
26 KiB
JavaScript
//我需要一个定时获取浏览器中黄金价格并推送到微信的程序
|
||
//网站网址:https://mybank.icbc.com.cn/icbc/newperbank/perbank3/gold/goldaccrual_query_out.jsp
|
||
//黄金价格在页面中,需要获取到黄金价格并推送到微信
|
||
//黄金价格的selector为:#activeprice_080020000521
|
||
//黄金价格的单位为:元/克
|
||
//黄金价格的更新时间为:每半小时检测一次,变化量小于1元/克则不推送。
|
||
//根据你的判断使用合适的算法计算价格趋势。
|
||
//根据你的判断使用外部json储存或其他方式储存数据。
|
||
//一个数组,记录三十天的收盘价格,用于计算七日价格变化量,七天价格趋势,十四天价格趋势,二十八天价格趋势,趋势类型:1.上涨 2.下跌 3.持平。
|
||
//推送格式为:当前黄金价格为:xx元/克;一小时变化量为:xx元/克;七日价格变化量为:xx元/克;七日价格趋势为:xx;十四日价格趋势为:xx;二十八日价格趋势为:xx。
|
||
//该脚本需要使用NodeJS编写,并使用Puppeteer库来获取页面内容。同时留出一个数组,用作特殊价格时的设置,当黄金价格达到该价格时,推送提醒。数组格式为:[价格1,价格2,价格3,价格4,价格5,价格6,价格7]
|
||
//特殊提醒格式为:特殊提醒:黄金价格达到xx元/克;七日价格趋势:xx。
|
||
//需要使用ServerChan来推送信息,ServerChan的sendkey使用环境变量,变量名为:PUSH_KEYT
|
||
//程序每天由crontab定时启动,启动时间由外部的青龙面板设置,设置为每天晚上22:30后关闭。
|
||
|
||
// 此下为信息推送用程序例子
|
||
// import {scSend} from 'serverchan-sdk';
|
||
// const response = await scSend('sendkey', 'title', 'desp', { tags: '黄金价格' });
|
||
// console.log('Response:', response);
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const puppeteer = require('puppeteer-core');
|
||
|
||
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');
|
||
|
||
// 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 = [];
|
||
|
||
// 可配置项:变化量阈值(元/克)、变化计算窗口(分钟)、采样间隔(分钟)、价格精度(小数位数)
|
||
const CONFIG = {
|
||
changeThresholdYuan: 1.0,
|
||
changeWindowMinutes: 60,
|
||
sampleIntervalMinutes: 2,
|
||
pricePrecisionDigits: 2,
|
||
enableTimedPush: true, // 定时推送开关(变化量触发和收盘推送)
|
||
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] launching browser to fetch price...');
|
||
const browser = await puppeteer.launch({
|
||
headless: 'new',
|
||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||
executablePath: process.env.CHROME_PATH || '/usr/bin/chromium-browser' // 默认路径,可通过环境变量覆盖
|
||
});
|
||
try {
|
||
const page = await browser.newPage();
|
||
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||
await page.waitForSelector(PRICE_SELECTOR, { timeout: 60000 });
|
||
const text = await page.$eval(PRICE_SELECTOR, el => el.textContent || el.innerText || '');
|
||
const numeric = parseFloat(String(text).replace(/[^0-9.\-]/g, ''));
|
||
if (!isFinite(numeric)) throw new Error('无法解析价格');
|
||
console.log(`[gold] fetched current price: ${numeric}`);
|
||
return numeric;
|
||
} finally {
|
||
await browser.close();
|
||
}
|
||
}
|
||
|
||
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; // 首次检测仍保留一个很小的容差
|
||
for (const t of targets) {
|
||
if (Math.abs(currentPrice - t) <= tol) return t;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 穿过监测:上一笔和当前价格在目标价两侧,就认为“穿过”该价格
|
||
for (const t of targets) {
|
||
const prev = lastPrice;
|
||
const curr = currentPrice;
|
||
if ((prev < t && curr >= t) || (prev > t && curr <= t)) {
|
||
return t;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
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 title = `黄金价格监控(测试)`;
|
||
const parts = [];
|
||
parts.push(`[TEST] 当前黄金价格为:${formatNumber(currentPrice)}元/克`);
|
||
const oneHourAgoForTest = findDeltaWithin(data.priceHistory, CONFIG.changeWindowMinutes);
|
||
const delta1hForTest = oneHourAgoForTest ? Number((currentPrice - oneHourAgoForTest.price).toFixed(CONFIG.pricePrecisionDigits)) : null;
|
||
parts.push(`[TEST] 一小时变化量为:${delta1hForTest !== null ? formatNumber(delta1hForTest) : '数据不足'}元/克`);
|
||
const seriesForTest = extractSeries(data);
|
||
const change7ForTest = computeChangeAmount(seriesForTest, 7);
|
||
const trend7ForTest = seriesForTest.length >= 7 ? computeTrendType(seriesForTest.slice(-7)) : '数据不足';
|
||
const trend14ForTest = seriesForTest.length >= 14 ? computeTrendType(seriesForTest.slice(-14)) : '数据不足';
|
||
const trend28ForTest = seriesForTest.length >= 28 ? computeTrendType(seriesForTest.slice(-28)) : '数据不足';
|
||
parts.push(`[TEST] 七日前价格为:${change7ForTest.price7DaysAgo !== null ? formatNumber(change7ForTest.price7DaysAgo) : '数据不足'}元/克`);
|
||
parts.push(`[TEST] 七日价格变化量为:${change7ForTest.change !== null ? formatNumber(change7ForTest.change) : '数据不足'}元/克`);
|
||
parts.push(`[TEST] 七日价格趋势为:${trend7ForTest}`);
|
||
parts.push(`[TEST] 十四日价格趋势为:${trend14ForTest}`);
|
||
parts.push(`[TEST] 二十八日价格趋势为:${trend28ForTest}`);
|
||
const yesterdayClosingPriceForTest = getYesterdayClosingPrice(data);
|
||
const todayOpeningPriceForTest = getTodayOpeningPrice(data);
|
||
parts.push(`[TEST] 昨日收盘价格为:${yesterdayClosingPriceForTest !== null ? formatNumber(yesterdayClosingPriceForTest) : '数据不足'}元/克`);
|
||
parts.push(`[TEST] 今日开盘价格为:${todayOpeningPriceForTest !== null ? formatNumber(todayOpeningPriceForTest) : '数据不足'}元/克`);
|
||
const desp = parts.join(';\n');
|
||
try {
|
||
const resp = await sendServerChan(title, desp, '黄金价格|TEST');
|
||
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 title = shouldPushBySpecial ? `特殊提醒:黄金价格达到${formatNumber(currentPrice)}元/克` : `黄金价格监控`;
|
||
const parts = [];
|
||
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) : '数据不足'}元/克`);
|
||
if (shouldPushBySpecial) parts.unshift(`特殊提醒:黄金价格达到${formatNumber(currentPrice)}元/克;七日价格趋势:${trend7}`);
|
||
const desp = parts.join(';\n');
|
||
try {
|
||
const resp = await sendServerChan(title, desp, '黄金价格');
|
||
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(); |