拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Python+GitHub Actions:从零搭建零成本上新监控提醒系统

魔卡少女樱衣架扭蛋第六弹上新隐藏款居然是小可。这个消息放到 IP 周边圈子里基本等于“快去看”。但放到技术人眼里它背后其实是一个非常经典的工程问题有一个页面内容随时可能变化变化时机完全不确定你怎么在变化发生后的第一时间知道手动刷页面当然可以但热门扭蛋的上新窗口往往很短人肉盯着既不现实也容易漏。更合理的做法是给自己搭一个小小的“上新监控提醒系统”定时抓取目标页面对页面内容做检测一旦发生变化就推一条消息到手机上。这个套路不只能盯扭蛋还能扩展到商品补货监控、活动页面巡检、接口变更通知、资料页面更新提醒等场景。这篇文章会从零开始把这个系统的完整搭建过程讲清楚。内容包括监控系统运行的原理、Python 抓取与解析、基于哈希的变化检测、钉钉机器人通知以及基于 GitHub Actions 的免费定时运行方案。如果你已经掌握 Python 基础语法跟着本文操作很快就能跑通一个最小可用版本。1. 这篇文章真正要解决的问题先说痛点。魔卡少女樱这个 IP 的周边产品热度一直不低衣架扭蛋做到第六弹说明前面几弹的持续关注度是比较高的。当一个新弹次上线时玩家和收藏者最关心的通常是两个信息第一什么时候开始卖第二隐藏款是什么。本文开头提到的第六弹里隐藏款是“小可”这属于重要卖点。听起来很简单但实际体验并不好。因为绝大多数平台不会在上新那一刻主动打电话告诉你。你能做的只有每天自己打开页面看一眼在各个群里等信息但群消息容易被淹没等电商平台的“上新提醒”但有些店铺根本不做这个功能等大V和博主发动态这个往往已经滞后了。对一个热门 IP 来说滞后意味着抢不到热门款也意味着你可能要和溢价黄牛市场打交道。所以“第一时间知道”在周边圈子里是实打实的刚需。把这个问题拆开看它其实由三个子问题组成如何定时获取目标页面的当前状态如何判断当前状态和上一次状态是否不同状态发生变化时如何把结果通知到人对应到技术方案上就是三个模块抓取模块、变化检测模块、通知模块。把它们串起来的则是一个定时调度器。很多开发者会误以为这种需求必须用到消息队列、爬虫框架、Redis 之类的东西其实完全不是。对于一个监控频率不高的个人项目Python 脚本加一个 GitHub Actions 定时任务就够了。本文的方案不引入消息中间件不用数据库也不需要额外服务器属于最轻量的一套实现。这里也把边界说清楚本文做的是“只监控、不抢购”。自动下单、自动绕过验证码、绕过平台风控这类操作都不在讨论范围内。监控的目的只是让用户及时知道页面发生了变化最终购买行为仍然需要用户自己在合规渠道完成。2. 上新监控系统的核心概念与基本原理在写代码之前先把四个核心概念讲透。如果不理解这几个概念代码写出来也是大概能跑但遇到问题不知道从哪里排查。2.1 轮询与 Webhook监控一个外部页面时最常用的两种信息获取方式轮询按照固定时间间隔主动向目标服务器发起请求询问“你变化了吗”。Webhook由目标服务器在数据变化时主动向你的接口推送消息。Webhook 在体验上是最好的因为它是准实时的。但问题在于我们自己通常是监控方而不是服务提供方第三方电商平台并不会因为我们想监控就给我们开放 Webhook。所以在“监控外部网站”这个场景下轮询是唯一普适的选择。轮询的优点是实现简单不依赖目标平台提供任何接口缺点是存在延迟和一定的服务器压力。延迟取决于轮询间隔间隔越短越及时但越容易被反爬机制盯上。对扭蛋上新监控来说间隔 10 分钟已经比较合理。2.2 页面签名与变化检测判断页面是否变化最直接的办法是把两次抓到的页面内容做比较。但页面里的实时部分太多比如广告位、时间戳、访问计数这些都可能造成误报。所以更推荐的做法是先缩小比较范围只比较你关心的页面区域。本文采用“区域签名”方案。思路是用 CSS 选择器选中目标页面里的商品列表区域把该区域对应的 HTML 字符串取出来对这个字符串做 SHA-256 哈希得到一个签名把签名保存下来下次运行再算一次签名两者对比不一致就认为页面发生了变化。这个方案的好处是代码量少而且只要购物车区域里多了一个新商品、改了一个文案、价格发生了变化签名都会变不容易漏报。代价是它只能告诉你“变了”不能直接告诉你“变了什么”。如果想知道变化细节需要进一步解析出商品名称、价格等结构化字段实现成本会高一些。如果只想做一个最简版本可以先只做签名比较。等确认真的有上新需求之后再升级为结构化解析。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 .venvWindows 环境下激活虚拟环境.venv\Scripts\activatemacOS / 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, headersHEADERS, timeout10) 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, encodingutf-8) as f: return json.load(f) return {} def save_state(state: dict) - None: with open(STATE_FILE, w, encodingutf-8) as f: json.dump(state, f, ensure_asciiFalse, indent2)使用哈希而不是直接保存 HTML 原文的好处有两点第一签名体积小状态文件只有几行第二比较起来非常快不涉及大段文本的内容比对。缺点是它无法告诉你具体变化但作为第一版已经足够。4.5 接入通知检测到变化后我们需要发一条钉钉消息。钉钉自定义机器人的 Webhook 地址形如https://oapi.dingtalk.com/robot/send?access_tokenxxx具体以你在钉钉群里创建机器人时获得的信息为准。def send_dingtalk(webhook: str, content: str) - None: payload { msgtype: text, text: { content: content } } resp requests.post(webhook, jsonpayload, timeout10) 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 21GitHub 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( levellogging.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, headersHEADERS, timeout10) 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, encodingutf-8) as f: return json.load(f) return {} def save_state(state: dict) - None: with open(STATE_FILE, w, encodingutf-8) as f: json.dump(state, f, ensure_asciiFalse, indent2) 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, jsonpayload, timeout10) 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()脚本执行流程如下请求目标页面提取目标区域文本计算区域签名和上次签名做比较如果不同发钉钉消息保存当前签名和检查时间。这里有一处需要留意第一次运行的时候由于没有历史签名脚本只做初始化保存不会发送通知。这是正确行为。从第二次运行开始签名比较才有意义。5.2 依赖清单建议在项目根目录下创建requirements.txt方便复现环境requests2.31.0 beautifulsoup44.12.2版本号请以实际安装为准这里只是一个参考。如果不想锁定版本也可以写成requests2.28,3.0 beautifulsoup44.11,5.05.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/checkoutv4 - name: Setup Python uses: actions/setup-pythonv5 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_WEBHOOKhttps://oapi.dingtalk.com/robot/send?access_token你的token export MONITOR_URLhttps://example.com/products export MONITOR_SELECTOR.product-listWindows 环境使用set命令set DINGTALK_WEBHOOKhttps://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。于是每次运行都会走“首次初始化”分支永远不会通知。解决办法有两种在 Action 里用缓存把state.json保存下来推荐做法每次运行后把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 页面展示历史变化记录。需要提醒的是监控脚本只是“信息提醒工具”它无法保证你一定能抢到喜欢的款式。最终的上新时间仍然要以官方渠道的信息为准购买行为也要在合法合规的渠道进行。祝你能顺利蹲到想要的隐藏款小可。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门