使用GitHub Actions完成ArXiv每日论文速递
一、简介
本次完成了一个每日8:30往邮箱里发送当日(近24h)上传至ArXiv上的论文。效果如下:

除了ArXiv上的分类,也可以通过关键词进行查找,正如我也按照“Point Cloud”进行搜索也会进行推送。如果你也想收到我的邮件,请联系我
有一个不理想的地方是手机端也可以读,但是并没有HTML支持的固定窗口的滑动(上图灰色部分)
二、GitHub Actions
先了解几个概念:
Git & GitHub
- Git:一个版本控制工具,帮你管理代码历史。
- GitHub:一个基于 Git 的在线托管平台,相当于“代码的远程保险库”。
GitHub Actions
- 是 GitHub 提供的 “自动化 CI/CD” 服务。
- 你可以把脚本、任务写成一个 YAML 文件,GitHub 就会在指定时刻或事件触发时,在它的云服务器上帮你跑代码。
- 好处:不用自己租服务器/搭本地守护进程,GitHub 提供免费且可靠的执行环境。
换句话说,我可以把GitHub Actions当作一个服务器,定时触发,在最简单的思考就是写一个一直跑的程序,在每天时间一过8:30就进行执行函数,这当然是一种资源的浪费以及麻烦的操作,还需要一个服务器,我曾想过用组里面的服务器但这毕竟不太好
三、步骤
3.1 准备工作
-
GitHub账号
-
安装Git和Python 3(推荐3.8+),然后代码编辑器,我使用的是VSCode
-
准备一个SMTP邮箱(QQ/126/163等)我这里使用的是QQ,点击“设置-账号-POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务”,将服务状态打开
-
安装
requirements.txt
中的依赖:requests>=2.32 feedparser>=6.0 python-dotenv>=1.0 googletrans4.0.0-rc1 # 若不想自动翻译,可删掉
3.2 在本地初始化并测试脚本
-
新建文件夹需要以下几个文件:
fetch.py
:Python代码,负责抓取论文、格式化、发邮件requirements.txt
:列出依赖.env
:存放邮箱账号、授权码、SMTP服务器、端口、收件人等
-
安装依赖
pip install -r requirements.txt
-
本地测试
python fetch
此时应该可以看到控制台 [OK] Mail sent.
并且收到了测试邮件,就证明脚本本身没问题。
3.3 把项目推倒GitHub上
-
在GitHub上创建仓库(比如
arxiv-digest
),不要勾选README -
本地在项目根目录
git init git add . git commit -m "init script" git branch -M main git remote add origin https://github.com/你的用户名/arxiv-digest.git git push -u origin main
3.4 配置 GitHub Actions
-
在项目根目录下新建目录并文件:
.github/ workflows/ daily.yml
-
daily.yml
内容(直接复制粘贴)name: arXiv Digest on: schedule: - cron: '30 0 * * *' # UTC 00:30 = 北京时间 08:30 workflow_dispatch: # 允许手动触发 jobs: run: runs-on: ubuntu-latest env: EMAIL_USER: ${{ secrets.EMAIL_USER }} EMAIL_PASS: ${{ secrets.EMAIL_PASS }} EMAIL_HOST: ${{ secrets.EMAIL_HOST }} EMAIL_PORT: ${{ secrets.EMAIL_PORT }} EMAIL_TO: ${{ secrets.EMAIL_TO }} steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install deps run: pip install -r requirements.txt - name: Run script run: python fetch.py
-
提交并推送
git add .github/workflows/daily.yml git commit -m "add GitHub Actions" git push
3.5 填写 Secrets
现在我们还需要对GitHub Actions进行一定的处理,填写相应的信息,因为我们没有把.env进行填入,所以这里手动填入一下
-
在 GitHub 仓库页面,点击 Settings → Secrets and variables → Actions
-
点击 New repository secret 依次添加:
- Name:
EMAIL_USER
Value:(你的邮箱账号) - Name:
EMAIL_PASS
Value:(你的授权码) - Name:
EMAIL_HOST
Value:smtp.qq.com
(或你邮箱的 SMTP 服务器) - Name:
EMAIL_PORT
Value:465
(或 587) - Name:
EMAIL_TO
Value:(收件人邮箱,可以逗号分隔多个人)
- Name:
-
添加完成后,这些变量就会在 Actions 里以环境变量的形式注入给脚本。
3.6 验证 & 自动化
-
在 Actions 页面打开你的工作流,点击 Run workflow 手动触发一次
-
观察日志,最后应有
[OK] Mail sent.
-
去邮箱收信确认
-
之后每天 08:30(UTC+8)GitHub Actions 会自动运行、帮你发最新论文
四、其他
❓Q1:我以后要修改怎么办?如何再次提交?
有任何脚本改动(比如想加新关键词、改排版、加新翻译),只要在本地测试 OK →
git commit && git push
即可自动更新
❓Q2:我的信息会被别人看见吗?
所有敏感信息都存在 GitHub Secrets 中,别人看不到
可能会发生的问题
我在配置的时候遇到了许多问题,现在稍微总结一些
-
googletrans
无法翻译或一直返回英文-
原因:国内网络常无法直连 Google 翻译接口,或指定的
service_urls
不可达。 -
解决:
去掉手动指定
service_urls
,使用默认Translator()
;
-
-
Git 提交时报 “Author identity unknown”
- 原因:本地 Git 没有配置
user.name
和user.email
,无法记录提交作者信息。 - 解决:
git config --global user.name "YourName" git config --global user.email "you@example.com"
- 原因:本地 Git 没有配置
-
推送到 GitHub 时 “src refspec main does not match any”
原因:本地分支叫
master
,但远程默认使用main
(或反之)。解决:
-
将本地分支重命名为
main
:git branch -M main git push -u origin main
-
或者直接把
master
推上去:git push -u origin master
-
-
网易126邮箱不太行
使用QQ邮箱
五、代码
fetch.py
代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os, ssl, smtplib, socket, time, html
from datetime import datetime, timedelta, timezone
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import List
import requests, feedparser
from dotenv import load_dotenv
# ---------------------- 可选:中文翻译 ---------------------- #
from googletrans import Translator
_translator = Translator() # 默认即可
def zh(text: str) -> str:
"""
英→中:优先用 googletrans;若抛异常或超时就返回原文,
以保证脚本即使在 Google 被墙时也能继续发送邮件。
"""
text = text.strip()
try:
return _translator.translate(text, dest="zh-cn").text # src 自动检测
except Exception as e:
print(f"[warn] translate failed: {e}")
return text # 失败直接用英文
# ----------------------------------------------------------- #
ARXIV_API = (
"http://export.arxiv.org/api/query"
"?search_query={query}&sortBy=submittedDate&sortOrder=descending&max_results=200"
)
HTTP_TIMEOUT, RETRY, BACKOFF = 10, 3, 5
def _http_get(url: str) -> str:
for n in range(RETRY):
try:
r = requests.get(url, timeout=HTTP_TIMEOUT,
headers={"User-Agent": "arxiv-digest/1.0"},
proxies={})
r.raise_for_status()
return r.text
except (requests.RequestException, socket.timeout) as e:
wait = BACKOFF * (2**n)
print(f"[warn] {e} — retry in {wait}s …")
time.sleep(wait)
raise RuntimeError("Failed after retries")
def fetch(query: str, hours: int = 24) -> List[dict]:
since_utc = datetime.now(timezone.utc) - timedelta(hours=hours)
raw = _http_get(ARXIV_API.format(query=query))
feed = feedparser.parse(raw)
out = []
for e in feed.entries:
pub = datetime(*e.published_parsed[:6], tzinfo=timezone.utc)
if pub < since_utc:
continue
title_en = e.title.replace("\n", " ")
summ_en = e.summary.replace("\n", " ")
out.append({
"title_en": title_en,
"title_zh": zh(title_en),
"url": e.link,
"authors": ", ".join(a.name for a in e.authors),
"abs_zh": zh(summ_en),
})
# print(zh(title_en))
if not out:
print("[Warning] No new papers found in the last 24 hours.")
else:
print(f"\t → {len(out)} papers")
return out
# ---------- HTML 生成 --------------------------------------------------- #
def li_block(idx: int, p: dict) -> str:
return (
f"<p><b>[{idx}] {html.escape(p['title_en'])}</b></p>"
f"<p>标题:{html.escape(p['title_zh'])}</p>"
f"<p>链接:<a href='{p['url']}'>{p['url']}</a></p>"
'<div style="max-height:120px; overflow-y:auto; '
'background:#f5f5f5; padding:6px; border-radius:4px;">'
f"<p>作者:{html.escape(p['authors'])}</p>"
f"<p>摘要:{html.escape(p['abs_zh'])}</p>"
"</div><hr>"
)
def section_html(code: str, cname: str, papers: List[dict]) -> str:
header = (
f"<h2 style='text-align:center; color:#2f4f4f;'>"
f"{code} {cname},共计 {len(papers)} 篇</h2>"
)
body = "".join(li_block(i, p) for i, p in enumerate(papers, 1)) \
or "<p>过去 24 h 暂无新稿 🎉</p>"
return header + body
def build_email(cg, gr, pc) -> str:
now_bj = datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M")
return (
"<html><body>"
f"<p>更新时间:{now_bj} (Beijing)</p>"
f"{section_html('cs.CG', '计算几何', cg)}"
f"{section_html('cs.GR', '图形学', gr)}"
f"{section_html('Point Cloud', '相关', pc)}"
"</body></html>"
)
# ---------- 邮件发送 ---------------------------------------------------- #
def send(html_body: str):
host = os.getenv("EMAIL_HOST", "smtp.qq.com")
port = int(os.getenv("EMAIL_PORT", "465"))
user = os.environ["EMAIL_USER"]
pwd = os.environ["EMAIL_PASS"]
to = [x.strip() for x in os.environ["EMAIL_TO"].split(",")]
msg = MIMEMultipart("alternative")
msg["Subject"] = "Daily arXiv Digest – CG, Graphics, Point Cloud"
msg["From"] = user
msg["To"] = ", ".join(to)
msg.attach(MIMEText(html_body, "html", "utf-8"))
ctx = ssl.create_default_context()
smtp = (smtplib.SMTP_SSL(host, port, context=ctx, timeout=10)
if port 465 else smtplib.SMTP(host, port, timeout=10))
if port != 465:
smtp.starttls(context=ctx)
smtp.login(user, pwd)
smtp.send_message(msg)
smtp.quit()
print("[OK] Mail sent.")
# ---------- 主流程 ------------------------------------------------------ #
def main():
print("[*] Fetching cs.CG …")
cg = fetch("cat:cs.CG")
print("[*] Fetching cs.GR …")
gr = fetch("cat:cs.GR")
print('[*] Fetching "point cloud" …')
pc = fetch('ti:"point cloud"+OR+abs:"point cloud"')
send(build_email(cg, gr, pc))
if __name__ "__main__":
load_dotenv()
main()
.env
文件:
EMAIL_USER=xxxx@qq.com # *确保没有多余字符*
EMAIL_PASS=**************** # 16 位授权码
EMAIL_TO=xxxx@qq.com
EMAIL_HOST=smtp.qq.com
EMAIL_PORT=465 # 使用 SSL
