oil_notify.py
8.1 KB| 1 | #!/usr/bin/env python3 |
| 2 | # -*- coding: utf-8 -*- |
| 3 | # oil_notify.py —— 调价日油价提醒(QQ 推送 + 动态 cron 调度) |
| 4 | # 只在调价窗口日由动态 cron 调度运行:窗口日当天检查油价调整通知是否已发布, |
| 5 | # 已发布则推送 QQ(新价+涨幅+下次窗口日),并把 cron 重写到下一次窗口日。 |
| 6 | # 用法:oil_notify.py [--dry-run] (--dry-run:只打印不推送不改 cron) |
| 7 | import json, os, re, subprocess, sys |
| 8 | from datetime import date, datetime, timedelta |
| 9 | import urllib.request |
| 10 | |
| 11 | WORKDIR = os.path.dirname(os.path.abspath(__file__)) |
| 12 | CONFIG_FILE = os.path.join(WORKDIR, 'oil_notify_config.json') |
| 13 | |
| 14 | |
| 15 | def load_cfg(): |
| 16 | try: |
| 17 | with open(CONFIG_FILE) as f: |
| 18 | return json.load(f) |
| 19 | except Exception: |
| 20 | return {} |
| 21 | |
| 22 | |
| 23 | CFG = load_cfg() |
| 24 | OWNER_QQ = os.environ.get('QQ_USER_ID') or str(CFG.get('qq_user_id') or '') |
| 25 | ONE_BOT_URL = os.environ.get('ONE_BOT_URL') or CFG.get('one_bot_url') or 'http://127.0.0.1:3000/send_private_msg' |
| 26 | ONE_BOT_TOKEN = os.environ.get('ONE_BOT_TOKEN') or CFG.get('one_bot_token') or '' |
| 27 | HOME_URL = 'https://www.soyoujia.cn/' |
| 28 | HUNAN_URL = 'https://www.soyoujia.cn/hunan' |
| 29 | LOG = os.path.join(WORKDIR, 'oil_notify.log') |
| 30 | CRON_MARKER = 'oil_notify' # crontab 里由本脚本管理的行 |
| 31 | CRON_BAK = os.path.join(WORKDIR, 'crontab.bak') |
| 32 | UA = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'} |
| 33 | |
| 34 | DRY = '--dry-run' in sys.argv |
| 35 | SIM_DATE = None |
| 36 | if '--sim-date' in sys.argv: |
| 37 | i = sys.argv.index('--sim-date') |
| 38 | SIM_DATE = date.fromisoformat(sys.argv[i + 1]) |
| 39 | |
| 40 | |
| 41 | def log(msg): |
| 42 | line = f'{datetime.now().strftime("%Y-%m-%d %H:%M:%S")} {msg}' |
| 43 | print(line, flush=True) |
| 44 | if not DRY: |
| 45 | try: |
| 46 | with open(LOG, 'a') as f: |
| 47 | f.write(line + '\n') |
| 48 | except Exception: |
| 49 | pass |
| 50 | |
| 51 | |
| 52 | def fetch(url): |
| 53 | req = urllib.request.Request(url, headers=UA) |
| 54 | with urllib.request.urlopen(req, timeout=30) as r: |
| 55 | return r.read().decode('utf-8', 'replace') |
| 56 | |
| 57 | |
| 58 | def send_qq(text): |
| 59 | if DRY: |
| 60 | log(f'[dry-run] 将推送:\n{text}') |
| 61 | return 'dry-run' |
| 62 | body = json.dumps({'user_id': OWNER_QQ, 'message': text}).encode() |
| 63 | req = urllib.request.Request(ONE_BOT_URL, data=body, |
| 64 | headers={'Content-Type': 'application/json', |
| 65 | 'Authorization': f'Bearer {ONE_BOT_TOKEN}'}) |
| 66 | try: |
| 67 | with urllib.request.urlopen(req, timeout=15) as r: |
| 68 | return r.read().decode() |
| 69 | except Exception as e: |
| 70 | log(f'推送失败: {e}') |
| 71 | return '' |
| 72 | |
| 73 | |
| 74 | def parse_next_effect(html): |
| 75 | """首页倒计时:下次调整生效日。返回 date 或 None。 |
| 76 | '距下次调整还有 <b>14</b> 天(<span>11月13日</span> <span>周五</span>)'""" |
| 77 | m = re.search(r'距下次调整还有\s*<b>\d+</b>\s*天(<span>(\d+)月(\d+)日</span>', html) |
| 78 | if not m: |
| 79 | return None |
| 80 | month, day = int(m.group(1)), int(m.group(2)) |
| 81 | y = date.today().year |
| 82 | d = date(y, month, day) |
| 83 | if d <= date.today(): |
| 84 | d = date(y + 1, month, day) # 跨年 |
| 85 | return d |
| 86 | |
| 87 | |
| 88 | def parse_hunan_latest(html): |
| 89 | """湖南页近 3 个月调价表第一行(最新一次调价):(生效日期, [(油品名, 新价, 涨幅)])。 |
| 90 | soyoujia 新版布局为 div(无 table):数据行 = w-[140px] 日期 div + 每油品 flex 列(价格 span + 涨跌幅 span)。""" |
| 91 | m = re.search(r'class="w-\[140px\] shrink-0[^"]*">\s*(20\d{2}-\d{2}-\d{2})\s*</div>', html) |
| 92 | if not m: |
| 93 | return None, [] |
| 94 | adj_date = m.group(1) |
| 95 | seg = html[m.end():] |
| 96 | prices = re.findall(r'text-\[14px\] font-semibold text-ink font-display">([\d.]+)</span>', seg) |
| 97 | deltas = re.findall(r'text-\[12px\] font-bold font-body">\s*(?:<span[^>]*>)?([+-]?)<span>([\d.]+)</span>', seg) |
| 98 | names = ['89汽油', '92汽油', '95汽油', '98汽油', '0柴油'] |
| 99 | out = [] |
| 100 | for i, name in enumerate(names): |
| 101 | if i < len(prices): |
| 102 | delta = '' |
| 103 | if i < len(deltas): |
| 104 | delta = deltas[i][0] + deltas[i][1] # '+0.28' / '-0.30' |
| 105 | out.append((name, prices[i], delta)) |
| 106 | return adj_date, out |
| 107 | |
| 108 | |
| 109 | def set_cron(day, month, hour=17, minute=0): |
| 110 | """把 crontab 里的 oil_notify 行重写为指定日期 hour:00(动态调度)。 |
| 111 | 时间依据:发改委调价日 17:00 左右发布调价通知(要调价时;搁浅时 15:00 左右), |
| 112 | 17:00 查询通知刚发布;万一页面还没更新有次日 08:00 补查兜底。 |
| 113 | 推送参数(QQ 号、OneBot 地址与 token)写在 oil_notify_config.json 里,cron 行只带路径。""" |
| 114 | line = (f'{minute} {hour} {day} {month} * cd {WORKDIR} && ' |
| 115 | f'python3 oil_notify.py >> oil_notify.log 2>&1') |
| 116 | if DRY: |
| 117 | log(f'[dry-run] 将重写 cron 为: {line}') |
| 118 | return |
| 119 | try: |
| 120 | cur = subprocess.run(['crontab', '-l'], capture_output=True, text=True).stdout |
| 121 | keep = [l for l in cur.splitlines() if l.strip() and CRON_MARKER not in l] |
| 122 | with open(CRON_BAK, 'w') as f: |
| 123 | f.write(cur) |
| 124 | new = '\n'.join(keep + [line]) + '\n' |
| 125 | p = subprocess.run(['crontab', '-'], input=new, text=True, capture_output=True) |
| 126 | if p.returncode != 0: |
| 127 | log(f'crontab 重写失败: {p.stderr.strip()}') |
| 128 | else: |
| 129 | log(f'cron 已设为 {month}月{day}日 {hour}:{minute:02d}') |
| 130 | except Exception as e: |
| 131 | log(f'crontab 操作异常: {e}') |
| 132 | |
| 133 | |
| 134 | def main(): |
| 135 | today = SIM_DATE or date.today() |
| 136 | try: |
| 137 | home = fetch(HOME_URL) |
| 138 | next_effect = parse_next_effect(home) # 下次调整生效日(窗口日 24 时后) |
| 139 | except Exception as e: |
| 140 | log(f'首页抓取失败: {e}') |
| 141 | return |
| 142 | if next_effect is None: |
| 143 | log('解析不到下次调价日期,跳过(保留下次 cron 不再动)') |
| 144 | return |
| 145 | window = next_effect - timedelta(days=1) # 窗口日(发通知那天,24 时生效) |
| 146 | log(f'今天={today} 下次生效日={next_effect} 窗口日={window}') |
| 147 | |
| 148 | try: |
| 149 | hunan_html = fetch(HUNAN_URL) |
| 150 | adj_date, items = parse_hunan_latest(hunan_html) |
| 151 | except Exception as e: |
| 152 | log(f'湖南页抓取失败: {e}') |
| 153 | adj_date, items = None, [] |
| 154 | |
| 155 | # 今天是否该检查:窗口日当天,或生效日补查 |
| 156 | should_check = today in (window, next_effect) |
| 157 | if should_check: |
| 158 | notified = False |
| 159 | if adj_date: |
| 160 | try: |
| 161 | adj = date.fromisoformat(adj_date) |
| 162 | # 页面最新调价行日期在 今天±1 天内都算通知已出(窗口日晚可能已显示次日生效) |
| 163 | notified = abs((today - adj).days) <= 1 |
| 164 | except ValueError: |
| 165 | notified = False |
| 166 | if notified and items: |
| 167 | lines = [f'📢 今日油价调整通知(湖南,{adj_date} 生效)'] |
| 168 | for name, price, delta in items: |
| 169 | if price == '—' or not price: |
| 170 | continue |
| 171 | d = f'(较上次 {delta})' if delta else '' |
| 172 | lines.append(f'・{name} {price} 元/升 {d}') |
| 173 | lines.append(f'下次调价窗口:{window2_fmt(next_effect)},爱丽丝那天再来看。') |
| 174 | text = '\n'.join(lines) |
| 175 | log('调价通知已出,推送 QQ') |
| 176 | resp = send_qq(text) |
| 177 | log(f'推送响应: {resp}') |
| 178 | # 下个窗口日:粗算 生效日+13 天(约 10 个工作日),下次运行时会用首页倒计时重新校准 |
| 179 | nxt_window = next_effect + timedelta(days=13) |
| 180 | set_cron(nxt_window.day, nxt_window.month) |
| 181 | else: |
| 182 | # 窗口日到了但页面还没更新(通知未出/滞后)→ 明天早上补查 |
| 183 | retry = today + timedelta(days=1) |
| 184 | log(f'窗口日已到但通知还没出(最新调价行={adj_date}),明天 {retry} 08:00 补查') |
| 185 | set_cron(retry.day, retry.month, hour=8) |
| 186 | else: |
| 187 | # 非窗口日被误触发:把 cron 校准回正确窗口日,不推送 |
| 188 | log('今天不是调价日,不推送;把 cron 校准到窗口日') |
| 189 | set_cron(window.day, window.month) |
| 190 | |
| 191 | |
| 192 | def window2_fmt(effect): |
| 193 | w = effect - timedelta(days=1) |
| 194 | return f'{w.month}月{w.day}日24时' |
| 195 | |
| 196 | |
| 197 | if __name__ == '__main__': |
| 198 | main() |
文件内容是平台检查时读出的文本,只供查看。平台不会运行包里的任何脚本或程序。

