commit 766e6678b7725a091f9e846c3696f93436379a78 Author: LiuEnder Date: Mon Mar 9 19:46:30 2026 +0800 初始提交:添加各项基础配置 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..119c2e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +gold_data.json +.env +*.log \ No newline at end of file diff --git a/GoldPriceV6cooldown.js b/GoldPriceV6cooldown.js new file mode 100644 index 0000000..11a9edd --- /dev/null +++ b/GoldPriceV6cooldown.js @@ -0,0 +1,557 @@ +//我需要一个定时获取浏览器中黄金价格并推送到微信的程序 +//网站网址: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(); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..b50032f --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "gold-price-monitor", + "version": "1.0.0", + "description": "A script to monitor ICBC gold price and push notifications via ServerChan.", + "main": "GoldPriceV6cooldown.js", + "scripts": { + "start": "node GoldPriceV6cooldown.js" + }, + "dependencies": { + "puppeteer-core": "^21.0.0", + "serverchan-sdk": "^1.0.0" + } +} \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..ae6195b --- /dev/null +++ b/readme.md @@ -0,0 +1,171 @@ +# 工商银行黄金价格监控与推送脚本 + +这是一个基于 Node.js 的自动化脚本,用于定时监控中国工商银行(ICBC)官网的黄金递延(T+D)价格,并通过 ServerChan(方糖服务)将价格提醒推送至微信。脚本支持价格突破报警、多维度趋势分析、防骚扰冷却机制以及收盘价记录等功能。 + +## ✨ 核心功能 + +* **定时抓取**:自动访问工商银行黄金价格页面,并使用 Puppeteer 解析实时价格。 +* **智能推送**: + * **变化触发**:当价格在设定时间窗口内波动超过阈值时推送。 + * **特殊价位**:可设置一组目标价位,当价格“穿过”任一目标价时立即推送特殊提醒。 + * **收盘推送**:在每日收盘时段(可配置)自动记录并推送收盘价。 +* **趋势分析**:自动计算并报告一小时变化量、七日变化量,以及七日、十四日、二十八日的价格趋势(上涨/下跌/持平)。 +* **灵活配置**:所有运行参数(如采样间隔、价格阈值、禁用时段、冷却时间等)均在脚本内 `CONFIG` 对象中集中管理,易于修改。 +* **数据持久化**:自动将价格历史、收盘价、开盘价及推送状态保存至本地 JSON 文件。 +* **多Key推送**:支持配置多个 ServerChan SendKey,向多个微信终端同时推送消息。 + +## 🚀 快速开始 + +### 1. 获取项目 +```bash +git clone http://43.135.34.133:53000/LiuEnder/goldworm_js +cd gold-price-monitor +``` + +### 2. 安装项目依赖 +```bash +npm install +``` +此步骤将安装 `puppeteer-core` 和 `serverchan-sdk`。 + +### 3. 安装 Chrome/Chromium 浏览器 +**本脚本使用 `puppeteer-core`,它需要系统中已安装一个兼容的 Chrome 或 Chromium 浏览器,但不会自动下载。** + +* **在 Ubuntu/Debian 系统上安装 Chromium**: + ```bash + sudo apt update + sudo apt install -y chromium-browser + ``` +* **在 CentOS/RHEL 系统上安装 Chromium**: + ```bash + sudo yum install -y epel-release + sudo yum install -y chromium + ``` +* 对于其他操作系统,请从 https://www.google.com/chrome/ 或相应包管理器安装。 + +安装后,需要找到浏览器的可执行文件路径。通常位于: +- `/usr/bin/chromium-browser` (Chromium on Linux) +- `/usr/bin/google-chrome` (Chrome on Linux) +- `C:\Program Files\Google\Chrome\Application\chrome.exe` (Windows) + +### 4. 配置环境变量 +脚本运行需要两个关键环境变量: + +1. **ServerChan 推送密钥 (`PUSH_KEYT`)**: + * 前往 https://sc3.ft07.com/ 登录并获取您的 `SendKey`。 + * **设置环境变量**(以下为Linux/macOS示例,永久生效请添加到 `~/.bashrc` 或 `~/.zshrc`): + ```bash + export PUSH_KEYT="SCTxxxxxx...你的SendKey" + # 如果需要配置多个Key,用分号(;)分隔 + # export PUSH_KEYT="Key1;Key2;Key3" + ``` + +2. **Chrome 浏览器路径 (`CHROME_PATH`)**(可选但推荐): + * 如果您安装的浏览器不在脚本默认的 `/usr/bin/chromium-browser`,则必须设置此变量。 + ```bash + export CHROME_PATH="/usr/bin/chromium-browser" # 或您的实际路径 + # 例如在macOS上可能是:`export CHROME_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"` + ``` + +### 5. 运行脚本 +```bash +# 直接运行一次(测试用) +node GoldPriceV6cooldown.js + +# 或使用 npm 脚本 +npm start +``` +首次运行会创建数据文件 `gold_data.json` 并开始监控。检查控制台输出以确认运行成功。 + +## ⚙️ 详细配置 + +脚本的主要行为由文件顶部的 `CONFIG` 对象控制。您可以根据需要修改: + +```javascript +const CONFIG = { + changeThresholdYuan: 1.0, // 触发普通推送的1小时价格变化阈值(元/克) + changeWindowMinutes: 60, // 计算变化量的时间窗口(分钟) + sampleIntervalMinutes: 2, // 抓取价格的采样间隔(分钟) + pricePrecisionDigits: 2, // 价格显示和计算的小数位数 + enableTimedPush: true, // 是否启用定时推送(变化触发和收盘推送) + enableSpecialAlert: true, // 是否启用特殊价格提醒 + pushIntervalMinutes: 60, // 普通推送的最小间隔(分钟),防骚扰 + forcePushTest: false, // 设为 true 可测试推送功能,运行一次即退出 + disabledWindows: [12001500], // 禁止推送的时间段,格式 [开始时间结束时间],如 12001500 表示 12:00-15:00 + enableSpecialForcePush: true, // 特殊价格提醒是否忽略禁用时间段 + enableSpecialBypassInterval: true, // 特殊价格提醒是否忽略推送间隔限制 + coolDownMinutesForSinglePrice: 90, // 单个特殊价位的默认冷却时间(分钟) + resetOtherSpecialCooldownOnHit: true, // 触发一个特殊价位时,是否重置其他价位的冷却 + // 为特定价位设置独立的冷却时间(分钟),未配置的价位使用默认值 + specialPriceCoolDownMinutes: { + // “600”: 120, // 例如,当价格达到600元时,冷却时间设为120分钟 + }, +}; +``` + +**特殊价格目标设置**: +在 `CONFIG` 对象下方,找到 `SPECIAL_PRICE_TARGETS` 数组,将您关心的价位填入。 +```javascript +const SPECIAL_PRICE_TARGETS = [500, 520, 550]; // 例如,设置500、520、550元为目标价位 +``` + +## 🏃 生产环境部署 + +建议使用进程管理工具(如 **PM2**)来保证脚本持续运行。 + +1. **全局安装 PM2**: + ```bash + npm install -g pm2 + ``` + +2. **使用 PM2 启动服务**(确保已在项目目录中): + ```bash + # 启动并命名进程 + pm2 start GoldPriceV6cooldown.js --name gold-monitor + # 设置开机自启(根据PM2提示操作) + pm2 startup + pm2 save + ``` + +3. **常用 PM2 命令**: + ```bash + pm2 logs gold-monitor # 查看实时日志 + pm2 status # 查看所有进程状态 + pm2 stop gold-monitor # 停止服务 + pm2 restart gold-monitor # 重启服务 + pm2 monit # 打开监控面板 + ``` + +**通过 Crontab 或青龙面板调度**: +如果您希望脚本在特定时间运行(而非持续后台运行),可以配置定时任务。例如,每天在交易时间运行: +```bash +# 每天9点到22点,每30分钟运行一次 +*/30 9-22 * * * cd /path/to/your/project && node GoldPriceV6cooldown.js >> /tmp/gold_monitor.log 2>&1 +``` +请注意,脚本内部已包含定时循环逻辑 (`sampleIntervalMinutes`),如果通过外部Cron调用,通常只需调用一次,脚本会自行循环并在收盘时间退出。 + +## 📁 文件说明 + +- `GoldPriceV6cooldown.js` - 主脚本文件 +- `gold_data.json` - 运行时自动生成的数据文件(记录价格历史、推送状态等) +- `package.json` - 项目依赖定义 +- `.gitignore` - Git忽略文件配置 +- `README.md` - 本说明文件 + +## ⚠️ 重要注意事项 + +1. **合规使用**:本工具仅用于个人学习与技术研究。请合理设置请求频率,避免对工商银行服务器造成不必要的压力。使用请遵守网站相关规定。 +2. **选择器更新**:如果目标网页结构改版,可能需要更新脚本中的 `PRICE_SELECTOR` 变量。您可以使用浏览器的开发者工具检查元素。 +3. **推送频率限制**:免费版 ServerChan 有推送次数限制,请合理配置 `pushIntervalMinutes` 和 `coolDownMinutesForSinglePrice` 等参数。 +4. **环境变量安全**:务必通过环境变量设置 `PUSH_KEYT`,切勿将其直接写入代码或提交至版本库。 +5. **浏览器路径**:在非标准路径安装 Chrome/Chromium 时,**必须**正确设置 `CHROME_PATH` 环境变量,否则脚本将无法启动浏览器。 + +## 🔧 故障排查 + +- **错误:无法启动浏览器**:确认 `CHROME_PATH` 环境变量已设置且路径正确。也可尝试在脚本中直接修改 `puppeteer.launch` 的 `executablePath` 参数。 +- **无推送**:检查 `PUSH_KEYT` 环境变量是否设置正确;查看控制台日志确认价格是否成功抓取及触发条件是否满足。 +- **推送过于频繁**:调整 `CONFIG.pushIntervalMinutes` 和 `CONFIG.changeThresholdYuan` 参数。 + +## 📄 开源协议 + +本项目基于 MIT 协议开源。 \ No newline at end of file