用GitHub Actions免费跑定时爬虫,邮件自动推送每日报告
早上七点半手机叮咚一声响。我眯着眼点开邮箱里面是昨晚刚整理好的行情摘要、几条行业动态、还有今天要用的几个数据表。这不是什么大厂办公系统就是我自己写的一个小爬虫跑在GitHub Actions上每天定时抓数据、拼邮件、自动发送。算下来已经跑了三个多月一分钱服务器费用没花一次手动操作都不用。这个方案特别适合这几类人想每天定时收一份数据汇总但不想买服务器的开发者刚学会爬虫不知道怎么部署的初学者以及那些手里数据源不少、却总是早上起来一个个网站翻的同学。整套系统的核心就一句话用GitHub的免费云资源执行定时任务用邮箱作为最终的消息出口。数据抓取、内容解析、邮件构造、定时调度四个环节串起来就是一个能长期稳定跑的自动化管道。1. 为什么非要用GitHub Actions来跑定时爬虫先说个很多人都踩过的坑。不少人写完爬虫第一反应是放在自己电脑上开个定时任务让它跑。听着挺合理实际用起来全是问题笔记本不可能24小时开机就算开着合盖休眠、系统更新重启、网络波动随便哪一项都能让任务断掉。更麻烦的是爬虫要跑就得留日志本地散落的一堆输出文件很快就没人愿意去翻。后来有人转向租服务器。但为了一条每天跑五分钟的定时任务去养一台云主机成本账怎么算都不划算——机器要钱维护要时间安全补丁不跟还得提心吊胆。GitHub Actions的优势正好补在这两个空档上。它是GitHub自带的持续集成平台你只要把代码推到仓库它就会在云端虚拟机里按你设定的计划执行命令。我的使用经验如下维度自建服务器本地电脑GitHub Actions费用每月固定支出电费硬件折旧公共仓库免费私有仓库也有免费额度可用性取决于服务商关机就断云端托管7x24小时在线维护成本系统更新、安全加固基本无零维护只管写代码日志可追溯性自己搭日志系统本地文件易丢网页端可视化每次运行都有完整记录定时能力需要维护cron服务系统计划任务原生支持cron表达式除了省心Actions还有一个很多教程没提到的价值它是带版本控制的任务系统。你的爬虫代码、依赖清单、调度配置全部以文件形式存在Git仓库里哪天跑挂了可以直接对比上一次正常运行的版本差异定位是哪次改动引入的问题。这种“代码即配置、配置即历史”的玩法比在服务器里改脚本再忘了改了什么要舒服得多。当然它也有适用边界。高频采集比如每分钟抓一次不适合因为免费额度下的调度粒度限制在5分钟需要固定出口IP的场景也不适合Actions每次运行的IP都属于动态池超大并发和长时间运行更不适合单次任务默认执行时限是6小时。但说句实在话绝大部分“每天定时抓点数据发邮件”的需求都远没触到它的天花板。2. 项目整体设计四个模块如何串联动手写代码之前我先把整套系统拆成了四个独立的模块。这么做不是为了显得结构清晰而是考虑到一个非常实际的问题爬虫是整条链路里最脆弱的一环。目标站点改个页面结构、换个返回字段解析代码就从“正常”变成“报错”。如果所有逻辑都堆在一个脚本里改动时牵一发动全身拆开之后哪一环坏了就修哪一环互不拖累。具体到我的项目目录结构长这样my-daily-report/ ├── .github/ │ └── workflows/ │ └── daily-report.yml ├── main.py ├── requirements.txt └── README.md四个模块的分工如下数据获取模块。负责向目标数据源发起请求得到原始的HTML或JSON内容。这个模块只做一件事拿到数据并尽可能把网络异常、超时、状态码错误都拦截在这一层。解析与处理模块。拿上一层返回的内容按规则提取关键字段再构造出邮件正文。这里需要注意不要把解析逻辑和抓取逻辑混在一起因为网页改版改的是解析层蹭不动的抓取层改动越少越好。邮件发送模块。把处理好的文本或HTML内容封装成邮件通过SMTP协议发出去。这个模块最关键的一个原则是发送邮箱的授权信息绝对不写死在代码里而是从环境变量读取。调度触发模块。这是GitHub Actions替你完成的不需要写代码只需要在YAML配置里声明“什么时候运行、跑哪个命令”。单个文件的职责划分我用一个非常朴素的判断标准main.py里不出现任何具体数据源地址也不出现任何邮箱参数。这些都通过环境变量传入。职责分离带来的直接好处是换数据源、换收件邮箱都只要改配置或者改仓库的Secrets不用动核心逻辑。3. 核心代码实现抓取、解析、发信三件套3.1 数据获取请求和异常处理我的示例数据源用一个返回JSON的公开接口来演示这样最直观。结构化数据比HTML好解析得多如果你要抓的是网页多一步BeautifulSoup解析即可后面会提到。import os import smtplib import json from email.mime.text import MIMEText from email.header import Header from urllib.request import Request, urlopen from urllib.error import URLError def fetch_data(): target_url https://api.example.com/weather?citybeijing req Request(target_url, headers{User-Agent: Mozilla/5.0}) try: with urlopen(req, timeout10) as resp: return json.loads(resp.read().decode(utf-8)) except URLError as e: print(f[ERROR] 数据请求失败: {e}) return None这段代码里的timeout10特别值得多说一句。我见过很多新手写爬虫根本不设超时一旦目标接口迟迟不响应任务就挂在那里直到GitHub Actions判定运行超时。设置合理超时配合失败返回None能让任务在异常情况下快速结束而且日志里能清楚看到是哪一步出了问题。如果目标不是接口而是普通网页把解析换成BeautifulSoup即可from bs4 import BeautifulSoup soup BeautifulSoup(html_content, html.parser) weather_box soup.select_one(.weather-today) temperature weather_box.select_one(.temp).text.strip()3.2 构建HTML邮件内容邮件正文我用HTML格式而不是纯文本因为带标题、表格、高亮的邮件在手机邮箱里阅读体验好很多。构造逻辑很简单拿到数据后拼接HTML片段def build_html_report(data): if not data: return p数据获取失败请检查数据源或查看Actions运行日志。/p city data.get(city, 未知) temp data.get(temperature, N/A) humidity data.get(humidity, N/A) condition data.get(condition, N/A) html_content f html body stylefont-family: Arial, sans-serif; padding: 20px; h2{city}今日天气报告/h2 table border1 cellpadding8 styleborder-collapse: collapse; trth气温/thtd{temp}°C/td/tr trth湿度/thtd{humidity}%/td/tr trth天气/thtd{condition}/td/tr /table p stylecolor: gray; font-size: 12px;本邮件由 GitHub Actions 定时任务自动发送。/p /body /html return html_content这里有个看起来很基础、但坑过不少人的细节邮件标题和正文的编码。使用Python标准库构造中文邮件必须显式指定字符集否则在部分邮箱客户端里会显示一串乱码。3.3 邮件发送SMTP与授权码邮件发送我直接用Python标准库的smtplib没有引入任何第三方依赖。很多教程会推荐各种邮件服务SDK但对“每天给自己发一封报告”这种场景标准库已经绰绰有余。def send_email(subject: str, html_body: str): smtp_host os.getenv(SMTP_HOST) smtp_port int(os.getenv(SMTP_PORT, 465)) smtp_user os.getenv(SMTP_USER) smtp_pass os.getenv(SMTP_PASS) receiver os.getenv(RECEIVER_EMAIL) if not all([smtp_host, smtp_user, smtp_pass, receiver]): raise ValueError(缺少SMTP配置请在仓库Secrets中设置相关环境变量) msg MIMEText(html_body, html, utf-8) msg[Subject] Header(subject, utf-8) msg[From] smtp_user msg[To] receiver try: if smtp_port 465: server smtplib.SMTP_SSL(smtp_host, smtp_port, timeout15) else: server smtplib.SMTP(smtp_host, smtp_port, timeout15) server.starttls() server.login(smtp_user, smtp_pass) server.sendmail(smtp_user, [receiver], msg.as_string()) print([OK] 邮件发送成功) except smtplib.SMTPException as e: print(f[ERROR] 邮件发送失败: {e}) raise finally: server.quit()说几个关于SMTP的要点都是我实际踩过的端口选择有一定的讲究。465走的是SSL加密连接587走的是STARTTLS升级加密。用465就老老实实SMTP_SSL用587就先SMTP建连再starttls。我曾经图省事端口写成465、连接方式却用SMTP.starttls结果一直报握手错误翻看官方文档才明白这两者不能混用。多数邮箱服务商都要求使用“授权码”而不是登录密码。你需要先去邮箱后台开启SMTP服务生成一串专用的授权码把它当作密码交给程序使用。授权码不同于账号密码即使泄露也还能单独作废相对安全。发件账号和from地址必须一致。我试过用A邮箱的授权码、却在From字段填B邮箱地址直接被服务商拒信。大多数邮箱服务商都会校验发件身份所以From字段老老实实写登录账号就行。下面是main.py把三个模块串起来的部分def main(): data fetch_data() report build_html_report(data) send_email(【定时报告】每日数据汇总, report) if __name__ __main__: main()4. 定时调度与部署YAML工作流是真正的灵魂代码写完了怎么让它在云端跑起来GitHub Actions的配置文件放在.github/workflows/目录下采用YAML格式描述整个任务的触发条件和执行步骤。4.1 cron表达式与调度配置我的workflow配置如下name: Daily Report on: schedule: # UTC 时间 01:30对应北京时间 09:30 - cron: 30 1 * * * workflow_dispatch: jobs: run-crawler: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkoutv4 - name: Setup Python uses: actions/setup-pythonv5 with: python-version: 3.11 cache: pip - name: Install dependencies run: | pip install --upgrade pip pip install -r requirements.txt - name: Run crawler and send email env: SMTP_HOST: ${{ secrets.SMTP_HOST }} SMTP_PORT: ${{ secrets.SMTP_PORT }} SMTP_USER: ${{ secrets.SMTP_USER }} SMTP_PASS: ${{ secrets.SMTP_PASS }} RECEIVER_EMAIL: ${{ secrets.RECEIVER_EMAIL }} run: python main.py第一次配置时最容易踩的坑是cron时区。GitHub Actions的schedule默认使用UTC时间不是你的本地时间。我最初不知道这一点设定每天7点执行实际却在下午3点收到邮件愣了半天才反应过来是时区问题。上面对比的写法30 1 * * *表示UTC的凌晨1:30换算成东八区就是早上9:30。如果你不想每次都在脑子里换算时区也有更直观的办法在workflow顶层配置中添加时区变量但要注意Actions对这个能力的支持有版本差异。我自己的习惯是就老老实实保留UTC换算注释里写明本地时间即可。毕竟换算一次之后就没这个烦恼了而且配置加了环境变量反而让排错变复杂。cron表达式的五个字段分别是分钟、小时、日、月、星期。格式是标准的POSIX cron语法“每分钟”的最细粒度是5分钟一次——官方文档明确说过schedule触发的最小间隔是5分钟。想跑更频繁的任务Actions并不适合。4.2 Secrets密钥配置流程YAML里引用的secrets.SMTP_PASS等变量对应的值必须先在GitHub仓库后台设置好否则任务一跑就报环境变量缺失的错误。具体操作路径是仓库主页Settings → Secrets and variables → Actions → New repository secret。每个密钥单独创建名字和YAML里写的保持一致即可。我把SMTP_HOST、SMTP_PORT、SMTP_USER、SMTP_PASS、RECEIVER_EMAIL五个值都存在了Secrets里。这里还有一个非常关键的提醒千万不要把授权码直接写在代码或YAML文件里。GitHub的日志系统会记录全部输出一旦你在代码里print过密钥内容或者YAML里明文写错了——比如把YAML文件直接提交到仓库——授权码就等于公开了。正确做法是代码里只通过os.getenv()读取环境变量值本身完全靠Secrets注入任何人看代码、看日志都接触不到真实密钥。4.3 首次运行与日志查看配置完成后把整个目录推送到GitHub仓库推动到main分支后进入仓库的Actions标签页就能看到名为Daily Report的workflow。手动测试很方便点右上角的Run workflow下拉按钮里面会出现workflow_dispatch触发选项这是我在YAML里刻意加的——方便当天修改完代码后立刻手动触发验证不用干等第二天的定时任务。点击进入某次运行记录能看到所有的step执行情况绿色的对勾表示成功红色叉号表示失败。展开失败的step终端输出会完整展示异常调用栈。这套可视化日志系统非常直观排查问题时的效率比在服务器上翻日志高得多。5. 跑了三个月后汇总的踩坑实录这一节写相对琐碎的实战体会。我整理了这段时间遇到最多的几个问题做成速查表遇到同类状况可以直接对照排查。现象根本原因解决方案邮件正文和主题乱码构造MIMEText时未指定 utf-8 字符集统一使用MIMEText(html, html, utf-8)和Header(subject, utf-8)定时任务没按预期时间执行忽略了GitHub Actions默认UTC时区换算时区并在cron注释里写明对应本地时间任务运行两三分钟才收到邮件Actions排队等待空闲执行器属正常现象schedule触发存在延迟不必焦虑邮件发送报认证错误SMTP授权码过期或输入错误重新生成授权码并确认值已更新到Secrets邮件发送后进垃圾箱短时间发信频繁、内容含大量链接控制发送频率正文结构简洁不要堆砌过多超链接解析到的数据为空目标网站改版或接口字段变化抓取模块加强异常日志第一时间在Actions日志中发现Actions页面显示黄色圆点任务正在排队等待执行稍等片刻观察状态是否转为绿色或红色仓库长期不活跃任务不再触发仓库超过60天无活动会被官方暂停定时调度手动触发一次workflow或推送任意commit激活挑几个展开说说。第一个是关于时区的坑值得再多说一句。我在第一次配置时就踩了。当时设定的cron是0 8 * * *以为每天8点收到邮件。结果第一次运行发生在下午4点。翻开日志看了半天才意识到Actions用的是UTC。现在我的习惯是每一条cron注释里都写明对应的东八区时间以防几个月后回去改配置时再犯迷糊。第二个是熟视无睹的“key过保期”。SMTP授权码不是永久有效的部分邮箱服务商会定期强制失效。某天突然收不到邮件、检查日志发现一直报认证错误第一反应不是密码错了而是授权码被服务商重置了。去邮箱后台重新生成一份更新到Secrets问题迎刃而解。第三个问题是日志输出里的“过度自信”。我一开始写爬虫时喜欢在日志里打印各种中间数据包括完整的响应内容。直到有一次某接口的返回里意外包含了一个敏感字段我才意识到这套日志不仅自己能看到只要仓库权限允许他人也能看到。从那以后日志里只保留关键步骤和错误信息涉及敏感数据的print一律删除。6. 功能扩展从每日一封邮件到自动化报告系统基础链路跑通之后你手里其实已经握了一个“免费的云端自动任务平台”能干的事远不止发一封天气邮件。我给你列几个我实操过且稳定运行的扩展方向。方向一多数据源整合推送汇总报表。我后来在main.py里增加了一个数据源列表每天分别抓取几个不同接口的数据合并到同一封邮件里按不同板块展示。改造成本很低核心就是让fetch_data返回一个数组build_html_report遍历数组生成多个表格区块。方向二携带附件发送。如果你想每天自动生成一个CSV或Excel附件只需要把构造邮件的方式从MIMEText换成MIMEMultipart把附件文件读取后attach进去即可。比如定时生成一份当日库存清单的CSV文件再通过邮件发给自己和同事这在很多小团队内部是很实用的替代方案。方向三失败自动重试与多渠道提醒。cron的任务如果执行失败Actions本身就支持失败通知配置。另外也可以在代码里加一个重试机制当发信失败时尝试换一个备用SMTP端口或备用服务商重新发送如果仍然失败把错误信息写入日志文件并推送到其他通知渠道比如手机App的推送接口确保异常不会沉默地躺在日志里无人发现。方向四多workflow拆分调度。不需要把所有任务塞进一个workflow。我有两个不同的爬虫一个早上跑、一个晚上跑分别放在两个YAML文件里。它们相互独立一个挂了不影响另一个。如果多个任务之间有先后依赖关系可以用needs关键字控制执行顺序。方向五历史数据积累。每次跑完可以让程序把当日数据追加写入一个CSV文件然后用git push把更新推回仓库。这样一段时间后你就在Git提交历史上积累了完整的数据轨迹。这个玩法稍微进阶一点因为需要配置git提交身份和权限但对长期运维来说价值很高。7. 最后关于“稳定运行”我的一点体会这套方案我前后跑了三个多月最深的感受是“自动”的真正价值不在于省掉了手动点击而在于把不确定性变成了确定性。以前我每天早上挨个网站翻数据偶尔漏看一条信息就得晚上补救现在每天九点半邮件准时到一样的数据、一样的格式一个月下来从没漏过。对我来说这种可预期的稳定性比脚本本身值钱得多。给刚开始尝试的同学一个非常具体的建议先把workflow_dispatch手动触发调通再去开cron定时。这样你能在一个可控的节奏下验证代码、验证邮件格式、验证解析逻辑等一切稳定后再交给定时器全自动运行。别一上来就设定时任务否则第二天早上发现邮件没来你都不知道是该查代码还是该查配置。再分享一个小技巧发信邮箱最好和收信邮箱分开。用一个专门用于发信的专用账号收信用你平时常用的邮箱。这样做的好处是就算哪天真出现了授权码安全事件你只需要替换发信专用账号的授权码不会波及到日常使用的邮箱账号。GitHub Actions这个平台能做的事情还有很多把定时爬虫和邮件通知跑通之后你会发现“给服务器加一个定时能力”这件事根本不用自己买机器更不用搭复杂的调度系统。一份代码、一个配置、三个密钥一天的自动化就在你手上了。