Python+GitHub Actions:从零搭建零成本上新监控提醒系统
2026/9/7 1:42:36 网站建设 项目流程

魔卡少女樱衣架扭蛋第六弹上新,隐藏款居然是小可。这个消息放到 IP 周边圈子里,基本等于“快去看”。但放到技术人眼里,它背后其实是一个非常经典的工程问题:有一个页面,内容随时可能变化,变化时机完全不确定,你怎么在变化发生后的第一时间知道?

手动刷页面当然可以,但热门扭蛋的上新窗口往往很短,人肉盯着既不现实也容易漏。更合理的做法,是给自己搭一个小小的“上新监控提醒系统”:定时抓取目标页面,对页面内容做检测,一旦发生变化就推一条消息到手机上。这个套路不只能盯扭蛋,还能扩展到商品补货监控、活动页面巡检、接口变更通知、资料页面更新提醒等场景。

这篇文章会从零开始,把这个系统的完整搭建过程讲清楚。内容包括:监控系统运行的原理、Python 抓取与解析、基于哈希的变化检测、钉钉机器人通知,以及基于 GitHub Actions 的免费定时运行方案。如果你已经掌握 Python 基础语法,跟着本文操作,很快就能跑通一个最小可用版本。

1. 这篇文章真正要解决的问题

先说痛点。魔卡少女樱这个 IP 的周边产品热度一直不低,衣架扭蛋做到第六弹,说明前面几弹的持续关注度是比较高的。当一个新弹次上线时,玩家和收藏者最关心的通常是两个信息:第一,什么时候开始卖;第二,隐藏款是什么。本文开头提到的第六弹里,隐藏款是“小可”,这属于重要卖点。

听起来很简单,但实际体验并不好。因为绝大多数平台不会在上新那一刻主动打电话告诉你。你能做的只有:

  • 每天自己打开页面看一眼;
  • 在各个群里等信息,但群消息容易被淹没;
  • 等电商平台的“上新提醒”,但有些店铺根本不做这个功能;
  • 等大V和博主发动态,这个往往已经滞后了。

对一个热门 IP 来说,滞后意味着抢不到热门款,也意味着你可能要和溢价黄牛市场打交道。所以,“第一时间知道”在周边圈子里是实打实的刚需。

把这个问题拆开看,它其实由三个子问题组成:

  1. 如何定时获取目标页面的当前状态?
  2. 如何判断当前状态和上一次状态是否不同?
  3. 状态发生变化时,如何把结果通知到人?

对应到技术方案上,就是三个模块:抓取模块、变化检测模块、通知模块。把它们串起来的,则是一个定时调度器。

很多开发者会误以为这种需求必须用到消息队列、爬虫框架、Redis 之类的东西,其实完全不是。对于一个监控频率不高的个人项目,Python 脚本加一个 GitHub Actions 定时任务就够了。本文的方案不引入消息中间件,不用数据库,也不需要额外服务器,属于最轻量的一套实现。

这里也把边界说清楚:本文做的是“只监控、不抢购”。自动下单、自动绕过验证码、绕过平台风控这类操作都不在讨论范围内。监控的目的只是让用户及时知道页面发生了变化,最终购买行为仍然需要用户自己在合规渠道完成。

2. 上新监控系统的核心概念与基本原理

在写代码之前,先把四个核心概念讲透。如果不理解这几个概念,代码写出来也是大概能跑,但遇到问题不知道从哪里排查。

2.1 轮询与 Webhook

监控一个外部页面时,最常用的两种信息获取方式:

  • 轮询:按照固定时间间隔,主动向目标服务器发起请求,询问“你变化了吗”。
  • Webhook:由目标服务器在数据变化时,主动向你的接口推送消息。

Webhook 在体验上是最好的,因为它是准实时的。但问题在于:我们自己通常是监控方,而不是服务提供方,第三方电商平台并不会因为我们想监控就给我们开放 Webhook。所以,在“监控外部网站”这个场景下,轮询是唯一普适的选择。

轮询的优点是实现简单,不依赖目标平台提供任何接口;缺点是存在延迟和一定的服务器压力。延迟取决于轮询间隔,间隔越短越及时,但越容易被反爬机制盯上。对扭蛋上新监控来说,间隔 10 分钟已经比较合理。

2.2 页面签名与变化检测

判断页面是否变化,最直接的办法是把两次抓到的页面内容做比较。但页面里的实时部分太多,比如广告位、时间戳、访问计数,这些都可能造成误报。所以更推荐的做法是:先缩小比较范围,只比较你关心的页面区域。

本文采用“区域签名”方案。思路是:

  1. 用 CSS 选择器选中目标页面里的商品列表区域;
  2. 把该区域对应的 HTML 字符串取出来;
  3. 对这个字符串做 SHA-256 哈希,得到一个签名;
  4. 把签名保存下来;
  5. 下次运行再算一次签名,两者对比,不一致就认为页面发生了变化。

这个方案的好处是代码量少,而且只要购物车区域里多了一个新商品、改了一个文案、价格发生了变化,签名都会变,不容易漏报。代价是:它只能告诉你“变了”,不能直接告诉你“变了什么”。如果想知道变化细节,需要进一步解析出商品名称、价格等结构化字段,实现成本会高一些。

如果只想做一个最简版本,可以先只做签名比较。等确认真的有上新需求之后,再升级为结构化解析。

2.3 状态持久化

这里有一个新手很容易忽略的点:每次执行 Python 脚本,都是一次全新的进程启动。上一次算出来的签名存在内存里,进程结束后就没有了。下次启动时如果没有外部存储,程序就不知道“上一次签名”是什么,也就没办法做比较。

所以必须把上一次的签名持久化到磁盘上。对小型任务来说,一个 JSON 文件就足够了。文件里主要存放两个字段:上次签名和上次检查时间。

{ "last_signature": "xxx", "last_check_time": "2024-01-01 10:00:00" }

这个状态文件虽然简单,但它是整个监控系统能够正确运行的关键。没有它,程序每次运行都会当作第一次运行,也就永远不会触发通知。

2.4 通知渠道选型

变化检测之后,需要把结果通知到人。常见的免费渠道有:

渠道请求方式创建成本优点注意点
钉钉群机器人HTTP POST低,建群后添加机器人即可支持自定义关键词,国内可达官方对消息频率有限制
飞书群机器人HTTP POST交互卡片比较丰富配置略复杂
企业微信群机器人HTTP POST与企业微信一体化只有群成员能接收
Server酱HTTP POST可以直接推送到微信依赖第三方服务
SMTP 邮件SMTP通用性强容易被归入垃圾邮件

对于个人监控项目,钉钉群机器人是上手最快的一种:建一个只有自己的群,添加一个自定义机器人,拿到 Webhook 地址,然后发一个 POST 请求就能收到消息。

3. 环境准备与前置条件

本文示例代码使用 Python 3。实际操作时请以本机环境为准,建议使用 3.9 及以上版本,示例代码用到的标准库和第三方库在较新的 Python 版本中都没有兼容性问题。

3.1 本地环境

需要准备以下内容:

  • Python 3.9 及以上版本;
  • pip 包管理工具;
  • 一个趁手的编辑器,比如 VS Code;
  • 一个用于接收通知的钉钉群,以及该群自定义机器人的 Webhook 地址。

3.2 创建虚拟环境并安装依赖

建议在项目目录下创建虚拟环境,避免污染全局 Python 环境。

mkdir toy-monitor cd toy-monitor python -m venv .venv

Windows 环境下激活虚拟环境:

.venv\Scripts\activate

macOS / Linux 环境下激活虚拟环境:

source .venv/bin/activate

然后安装依赖:

pip install requests beautifulsoup4

这里用到的两个第三方库说明:

  • requests:用来发送 HTTP 请求并获取页面内容;
  • beautifulsoup4:用来解析 HTML,提取页面中的目标区域。

如果后续要解析速度更快,可以额外安装lxml解析器,但对于本文的示例来说不是必需的。

3.3 准备目标页面

本文代码中使用的目标 URL 是https://example.com/products,仅作为演示地址。实际项目中,请替换为你想要监控的页面地址,并且需要确认该地址是你的权限范围内可以访问的地址,也要遵守目标网站的服务协议与 robots.txt 约定。

4. 核心流程拆解

下面把流程拆成五个步骤。每一步都会给出关键代码片段,完整可运行的代码会在第 5 章统一呈现。

4.1 确定监控目标与数据源

开始之前先明确下面几个问题:

  • 目标页面 URL 是什么?
  • 你关心的内容在页面哪个区域?是商品列表、标题、价格,还是整页?
  • 目标页面允许的访问频率大概是多少?

这些问题不需要非常精确,但能帮你决定代码里SELECTOR变量和调度间隔怎么设置。

4.2 抓取页面

抓取页面的第一步是发起 HTTP 请求。多数网站会检查请求的User-Agent,如果请求头像默认爬虫,很容易被拒绝。所以至少要设置一个看起来正常的User-Agent,并加上超时时间。

import requests URL = "https://example.com/products" HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36" ) } def fetch_page() -> str: resp = requests.get(URL, headers=HEADERS, timeout=10) resp.raise_for_status() return resp.text

这里调用resp.raise_for_status()是为了在 HTTP 状态码不是 200 时直接抛出异常,避免把错误页面当作正常内容传给后面的解析逻辑。

4.3 解析目标区域

拿到 HTML 之后,用 BeautifulSoup 定位商品列表区域。本文示例采用一个假设的页面结构,实际使用时需要根据真实页面的 HTML 修改选择器。

from bs4 import BeautifulSoup SELECTOR = ".product-list" def extract_region(html: str) -> str: soup = BeautifulSoup(html, "html.parser") region = soup.select_one(SELECTOR) if region is None: return "" return str(region)

关于选择器,有两点要特别提醒:

  • 如果页面改版,选择器可能会失效,这时extract_region会返回空字符串。如果空字符串的签名和上一次不一样,就会触发一次假通知。所以需要在代码里判断区域是否为空,为空时直接跳过本轮检查。
  • 如果目标区域太大,比如包含了大量脚本标签,误报概率会升高。可以缩小选择器的范围,只选择商品容器的子节点区域。

4.4 生成签名并持久化

对提取出的区域字符串做 SHA-256 哈希,得到一个固定长度的签名。这个签名会和上一次保存的签名进行比较。

import hashlib import json import os STATE_FILE = "state.json" def calc_signature(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() def load_state() -> dict: if os.path.exists(STATE_FILE): with open(STATE_FILE, "r", encoding="utf-8") as f: return json.load(f) return {} def save_state(state: dict) -> None: with open(STATE_FILE, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2)

使用哈希而不是直接保存 HTML 原文的好处有两点:第一,签名体积小,状态文件只有几行;第二,比较起来非常快,不涉及大段文本的内容比对。缺点是它无法告诉你具体变化,但作为第一版已经足够。

4.5 接入通知

检测到变化后,我们需要发一条钉钉消息。钉钉自定义机器人的 Webhook 地址形如https://oapi.dingtalk.com/robot/send?access_token=xxx,具体以你在钉钉群里创建机器人时获得的信息为准。

def send_dingtalk(webhook: str, content: str) -> None: payload = { "msgtype": "text", "text": { "content": content } } resp = requests.post(webhook, json=payload, timeout=10) resp.raise_for_status() result = resp.json() if result.get("errcode") != 0: raise RuntimeError(result.get("errmsg"))

需要注意的是,钉钉自定义机器人在创建时可以设置自定义关键词。如果设置了关键词,发送内容必须包含该关键词,否则消息会被拒绝。比如机器人要求内容包含“上新”二字,那么上面content文案中就要带上“上新”,否则接口会返回错误码。

4.6 定时调度

核心循环写好后,还需要一个定时器。最简单的方案是 Linux 系统的 cron,但前提是你有一台长期运行的服务器。如果没有服务器,可以用 GitHub Actions 的定时任务,既免费又不需要维护机器。

cron 的写法:

*/10 * * * * cd /path/to/toy-monitor && .venv/bin/python monitor.py >> monitor.log 2>&1

GitHub Actions 的写法会在第 5 章完整呈现。它的思路是把监控脚本放到 GitHub 仓库里,由 GitHub 的服务器每隔 10 分钟自动执行一次。

5. 完整示例与代码实现

下面把所有模块整合成一个完整脚本。脚本文件放在项目根目录下,命名为monitor.py

5.1 完整监控脚本

# 文件路径:monitor.py import hashlib import json import logging import os import sys import requests from bs4 import BeautifulSoup logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", ) logger = logging.getLogger(__name__) # 示例 URL,实际使用请替换为目标页面地址 URL = os.getenv("MONITOR_URL", "https://example.com/products") # 示例选择器,实际使用请根据页面结构调整 SELECTOR = os.getenv("MONITOR_SELECTOR", ".product-list") STATE_FILE = os.getenv("STATE_FILE", "state.json") # 钉钉机器人 Webhook,建议通过环境变量注入,不要硬编码在代码里 DINGTALK_WEBHOOK = os.getenv("DINGTALK_WEBHOOK", "") HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36" ) } def fetch_page() -> str: resp = requests.get(URL, headers=HEADERS, timeout=10) resp.raise_for_status() return resp.text def extract_region(html: str) -> str: soup = BeautifulSoup(html, "html.parser") region = soup.select_one(SELECTOR) if region is None: return "" return str(region) def calc_signature(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() def load_state() -> dict: if os.path.exists(STATE_FILE): with open(STATE_FILE, "r", encoding="utf-8") as f: return json.load(f) return {} def save_state(state: dict) -> None: with open(STATE_FILE, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2) def send_dingtalk(webhook: str, content: str) -> None: if not webhook: logger.warning("DINGTALK_WEBHOOK is empty, skip notify") return payload = { "msgtype": "text", "text": { "content": content } } resp = requests.post(webhook, json=payload, timeout=10) resp.raise_for_status() result = resp.json() if result.get("errcode") != 0: raise RuntimeError(result.get("errmsg")) def main() -> None: try: html = fetch_page() except Exception as exc: logger.error("fetch page failed: %s", exc) sys.exit(1) region_text = extract_region(html) if not region_text: logger.warning("target region not found, skip this round") return current_signature = calc_signature(region_text) state = load_state() last_signature = state.get("last_signature") if last_signature is None: logger.info("no previous state, save initial signature") elif last_signature != current_signature: logger.info("content changed, send notify") send_dingtalk( DINGTALK_WEBHOOK, "目标页面发生变化,请及时查看!可能是有新品上架。", ) else: logger.info("content unchanged, skip notify") state["last_signature"] = current_signature state["last_check_time"] = datetime_now() save_state(state) def datetime_now() -> str: from datetime import datetime return datetime.now().strftime("%Y-%m-%d %H:%M:%S") if __name__ == "__main__": main()

脚本执行流程如下:

  1. 请求目标页面;
  2. 提取目标区域文本;
  3. 计算区域签名;
  4. 和上次签名做比较;
  5. 如果不同,发钉钉消息;
  6. 保存当前签名和检查时间。

这里有一处需要留意:第一次运行的时候,由于没有历史签名,脚本只做初始化保存,不会发送通知。这是正确行为。从第二次运行开始,签名比较才有意义。

5.2 依赖清单

建议在项目根目录下创建requirements.txt,方便复现环境:

requests==2.31.0 beautifulsoup4==4.12.2

版本号请以实际安装为准,这里只是一个参考。如果不想锁定版本,也可以写成:

requests>=2.28,<3.0 beautifulsoup4>=4.11,<5.0

5.3 GitHub Actions 定时运行配置

在项目根目录创建.github/workflows/monitor.yml文件:

name: toy-monitor on: schedule: - cron: "*/10 * * * *" workflow_dispatch: jobs: monitor: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v5 with: python-version: "3.11" - name: Install dependencies run: | pip install -r requirements.txt - name: Run monitor run: python monitor.py env: DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }} MONITOR_URL: ${{ secrets.MONITOR_URL }} MONITOR_SELECTOR: ${{ secrets.MONITOR_SELECTOR }}

配置里有两个要点:

  • schedule中的 cron 表达式表示每 10 分钟运行一次。GitHub Actions 对计划任务的执行时间不保证精确到秒,但对监控场景足够。
  • Webhook、目标 URL 等敏感信息通过secrets注入,而不是直接写在代码或配置文件中。在 GitHub 仓库的 Settings -> Secrets and variables -> Actions 中添加DINGTALK_WEBHOOK即可。

6. 运行结果与效果验证

先在本机验证一遍,再考虑部署到 GitHub Actions。

6.1 第一次运行

设置环境变量:

export DINGTALK_WEBHOOK="https://oapi.dingtalk.com/robot/send?access_token=你的token" export MONITOR_URL="https://example.com/products" export MONITOR_SELECTOR=".product-list"

Windows 环境使用set命令:

set DINGTALK_WEBHOOK=https://oapi.dingtalk.com/robot/send?access_token=你的token

然后运行脚本:

python monitor.py

预期输出类似:

2024-01-01 10:00:00 INFO no previous state, save initial signature

此时项目目录下会生成state.json,内容里带有本次计算的签名。

6.2 第二次运行

再次执行:

python monitor.py

如果页面没有变化,输出:

2024-01-01 10:10:00 INFO content unchanged, skip notify

这说明状态文件生效了,程序知道上一次的签名是什么。

6.3 模拟页面变化

想测试通知是否生效,可以在本地改一下页面内容做验证。例如把https://example.com/products替换成本地文件,或者修改某个商品文案后再运行脚本。

如果检测到变化,输出:

2024-01-01 10:20:00 INFO content changed, send notify

同时钉钉群里会收到一条文本消息:

目标页面发生变化,请及时查看!可能是有新品上架。

如果钉钉设置过自定义关键词,文案中必须包含关键词。比如关键词是“上新”,那就把消息文案改成:

上新提醒:目标页面发生变化,请及时查看!

6.4 运行失败时先看哪里

脚本运行失败时,第一件事是看退出码和错误日志:

  • 网络不通:会看到fetch page failed,先确认目标页面是否能正常访问;
  • 解析不到目标区域:会看到target region not found,说明选择器失效或页面结构变化;
  • 钉钉推送被拒绝:会看到接口返回的errmsg,多半是关键词不匹配或 Webhook 配置问题。

7. 常见问题与排查思路

问题现象可能原因排查方式解决方案
请求返回 403 或被拒绝被目标网站反爬机制拦截打印 HTTP 状态码和响应内容前 500 字修改 User-Agent,降低请求频率,优先寻找官方接口
提取不到目标区域页面结构改版,CSS 选择器失效把页面 HTML 保存到本地,用浏览器开发者工具重新查看结构更新MONITOR_SELECTOR选择器
页面中文乱码目标页面编码不是 UTF-8检查response.encoding和页面<meta charset>手动指定编码,如resp.encoding = "gbk"
钉钉没有收到消息Webhook 配置错误或缺少关键词查看脚本日志中的errmsg检查 Webhook 地址,并在文案中包含自定义关键词
每次运行都发通知目标区域包含了动态内容,如广告或时间戳把提取出的区域内容打印出来,观察哪些部分每次不同缩小选择器范围,或对区域内容做二次清洗
部署到 GitHub Actions 后状态丢失没有把state.json提交到仓库查看 Actions 运行日志,确认每次都是首次运行使用缓存功能,或把状态文件提交回仓库,但要注意并发冲突
脚本运行报ModuleNotFoundError依赖没有安装检查虚拟环境是否激活执行pip install -r requirements.txt

在 GitHub Actions 场景下,状态文件的处理是一个容易被忽略的问题。默认情况下,每次运行都在一个全新的环境中,仓库里只有代码,没有state.json。于是每次运行都会走“首次初始化”分支,永远不会通知。

解决办法有两种:

  1. 在 Action 里用缓存把state.json保存下来,推荐做法;
  2. 每次运行后把state.json提交回仓库,实现简单但容易产生大量无意义的提交记录。

推荐第一种。在 workflow 中加上缓存配置即可,本文不展开太多,实际项目里可以按 GitHub Actions 的缓存文档配置。

8. 最佳实践与工程建议

8.1 合规优先

在做任何页面监控之前,先确认目标网站的服务条款是否允许。个人学习场景下,低频监控一般问题不大,但也要注意:

  • 遵守robots.txt
  • 控制请求频率,建议 10 分钟一次起步,不要做秒级轮询;
  • 优先使用官方 API;
  • 不要把监控用于商业爬取或批量采集。

本文的示例代码只做页面变化检测,不做自动下单,也不做任何绕过风控的操作。

8.2 敏感信息不落库

钉钉 Webhook 地址、目标 URL 等敏感信息,不要硬编码到代码里。本地开发时用环境变量,GitHub Actions 中使用secrets注入。这样即使代码不小心公开,敏感信息也不会泄露。

8.3 日志要具体

上面的脚本只用了简单的logger.info。在真实场景中,建议把以下信息写入日志:

  • 当前任务名称;
  • 本次抓取耗时;
  • 提取到的商品数量;
  • 签名比较结果;
  • 通知发送结果。

日志越具体,后续排查问题越容易。尤其要记录“页面结构异常”的情况,因为选择器失效是这类监控系统最常见的故障。

8.4 降低误报率

误报是监控系统的大敌。如果天天推消息说“页面变了”,用户很快就会忽略通知。降低误报可以这样做:

  • 缩小比较区域,只提取稳定的商品列表;
  • 过滤掉脚本、样式等无关节点;
  • 对提取出的文本做空行和空白字符清理后再签名;
  • 要求“连续两次检测到变化”才通知,避免瞬时抖动。

8.5 多目标监控时做配置化

如果后续要监控多个页面,不要把每个页面的逻辑复制一遍。建议把目标 URL、选择器、通知文案做成配置项,用循环遍历。不过,如果只是自己用,一个页面的脚本已经足够,不需要过度设计。

8.6 状态文件冲突问题

在 GitHub Actions 中使用缓存时,还要注意并发问题。如果上一轮任务还没结束,下一轮任务又启动了,两个进程同时写state.json可能导致文件冲突。最简单的处理是:把 cron 间隔设置得比脚本运行时间长一些,或者加入文件锁。

8.7 不要依赖“页面一定不变”

页面结构随时可能改版。即使今天脚本运行正常,明天也可能因为改版而失效。所以监控任务必须能够暴露自身故障:当目标区域提取不到内容时,不要静默跳过,最好也发一条“监控异常”的消息,让用户知道监控可能失效了。

9. 总结与后续学习方向

这个系统的本质其实是三个动作:抓到、比较、喊人。抓页面状态,比较状态变化,变化后通知人。整个方案没有涉及高深技术,但把“定时任务 + 变化检测 + 消息推送”串起来之后,能解决很多实际问题。

下一步如果你想继续深入,可以从这几个方向扩展:

  • 把签名比较升级为结构化解析,提取商品名称、价格、状态,并在通知里带上具体变化;
  • 把状态存到 SQLite 或轻量数据库中,保存历史快照;
  • 增加去重逻辑,避免同一个变化被重复提醒;
  • 用 LLM 对新抓到的商品信息做摘要,再推送一条更自然的通知;
  • 做一个简单的 Web 页面,展示历史变化记录。

需要提醒的是,监控脚本只是“信息提醒工具”,它无法保证你一定能抢到喜欢的款式。最终的上新时间仍然要以官方渠道的信息为准,购买行为也要在合法合规的渠道进行。祝你能顺利蹲到想要的隐藏款小可。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询