我们用python来写自动化脚本,经常要自动定时运行。Cron 是 Linux /macOS 系统内置的定时任务工具,不需要第三方服务,通过简单配置,就能让系统在指定时间自动执行 Python 脚本。今天我们从零开始对语法、脚本改造、环境适配、排错方案进行逐步来讲解。首先是Cron表达式语法规则,它由5个时间位+执行命令组成┌──────── 分钟 (0-59)│ ┌────── 小时 (0-23)│ │ ┌──── 日期 (1-31)│ │ │ ┌── 月份 (1-12)│ │ │ │ ┌ 星期 (0=周日,1=周一 … 6=周六)│ │ │ │ │* * * * * 执行命令
# 每天上午 10 点执行0 10 * * * python3 /脚本绝对路径/script.py# 每小时执行一次0 * * * * python3 /脚本绝对路径/script.py# 每 15 分钟执行一次*/15 * * * * python3 /脚本绝对路径/script.py# 每周一上午 9 点执行0 9 * * 1 python3 /脚本绝对路径/script.py# 每月 1 号凌晨 0 点执行0 0 1 * * python3 /脚本绝对路径/script.py
当不确定表达式是不是正确,可以用在线工具crontab.guru校验Crontab基础命令,在终端执行下面的命令来管理定时任务# 编辑当前用户的定时任务(核心命令)crontab -e# 查看已配置的所有定时任务crontab -l# 清空当前用户所有定时任务(谨慎使用,无法恢复)crontab -r
首次执行crontab -e会让我们选择编辑器,可以选择nano,操作简单。由于Cron工作目录不固定,相对路径经常会出现找不到文件的问题,所以使用绝对路径是个不错的选择# 这样容易出问题,相对路径,Cron 运行报错withopen("state.json") as f: data = json.load(f)# 正确写法:基于脚本自身位置拼接绝对路径import os# 获取脚本所在目录BASE_DIR = os.path.dirname(os.path.abspath(__file__))# 拼接文件完整路径STATE_FILE = os.path.join(BASE_DIR, "state.json")withopen(STATE_FILE) as f: data = json.load(f)
#!/usr/bin/env python3"""脚本功能说明、Cron 配置备注"""import osimport json# 业务代码...
Cron任务默认静默运行,不会主动输出信息,必须配置日志,才能判断脚本是否启动,是否异常#!/usr/bin/env python3import loggingimport osBASE_DIR = os.path.dirname(os.path.abspath(__file__))# 日志文件绝对路径LOG_FILE = os.path.join(BASE_DIR, "publish.log")# 初始化日志logging.basicConfig( filename=LOG_FILE, level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S")log = logging.getLogger(__name__)def main(): log.info("脚本开始执行") try: # 你的业务逻辑 log.info("任务执行成功") except Exception as e: # 打印完整异常堆栈 log.error(f"任务执行失败:{e}", exc_info=True) raiseif __name__ == "__main__": main()
在定时命令末尾添加 >> 日志路径 2>&1,将标准输出、错误输出统一写入日志:0 10 * * * /usr/bin/python3 /脚本路径/publish_queue.py >> /脚本路径/publish.log 2>&1
对于环境变了,最好通过脚本内手动加载.env配置文件,这样兼容性更好:import osdef load_env(file_path: str): """加载 .env 文件中的环境变量""" with open(file_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() # 跳过空行、注释行 if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") os.environ[key.strip()] = value.strip().strip("'\"")# 拼接 .env 绝对路径BASE_DIR = os.path.dirname(os.path.abspath(__file__))env_path = os.path.join(BASE_DIR, ".env")load_env(env_path)# 正常读取变量token = os.environ["DEVTO_TOKEN"]
或者直接在crontab -e编辑页面顶部声明变量,全局生效# 顶部定义环境变量DEVTO_TOKEN=abc123yourtokenGUMROAD_TOKEN=xyz789yourtoken# 定时任务0 10 * * * python3 /脚本路径/publish_queue.py >> /脚本路径/publish.log 2>&1
接下来我们看个完整实战示例,整合绝对路径、日志、环境变量、业务逻辑等:#!/usr/bin/env python3import osimport jsonimport reimport loggingimport requestsfrom datetime import date# 基础路径配置BASE_DIR = os.path.dirname(os.path.abspath(__file__))QUEUE_FILE = os.path.join(BASE_DIR, "publish_queue.json")LOG_FILE = os.path.join(BASE_DIR, "queue.log")# 日志初始化logging.basicConfig( filename=LOG_FILE, level=logging.INFO, format="%(asctime)s %(message)s")log = logging.getLogger(__name__)# 加载环境变量def load_env(file_path: str): with open(file_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") os.environ[key.strip()] = value.strip()load_env(os.path.join(BASE_DIR, ".env"))TOKEN = os.environ.get("DEVTO_TOKEN", "")# 队列读写、发布业务逻辑def load_queue(): with open(QUEUE_FILE, "r", encoding="utf-8") as f: return json.load(f)def save_queue(q): with open(QUEUE_FILE, "w", encoding="utf-8") as f: json.dump(q, f, indent=2)def publish(filepath): file_full_path = os.path.join(BASE_DIR, filepath) with open(file_full_path, "r", encoding="utf-8") as f: content = f.read() # 解析文章元信息 match = re.match(r'^---\n(.*?)\n---\n', content, re.DOTALL) fm = {} for line in match.group(1).splitlines(): if line.startswith('tags: '): fm['tags'] = [t.strip() for t in line[6:].split(',')] elif ': ' in line: k, _, v = line.partition(': ') fm[k.strip()] = v.strip() # 调用接口发布文章 headers = {"api-key": TOKEN, "Content-Type": "application/json"} resp = requests.post( "https://dev.to/api/articles", headers=headers, json={ "article": { "title": fm["title"], "body_markdown": content, "published": True, "tags": fm.get("tags", []) } } ) resp.raise_for_status() return resp.json()def main(): q = load_queue() if not q["pending"]: log.info("任务队列为空,无需执行") return item = q["pending"][0] log.info(f"开始发布:{item['title']}") result = publish(item["filename"]) publish_url = f"https://dev.to{result['path']}" log.info(f"发布成功:{publish_url}") # 更新队列 q["pending"].pop(0) q["published"].append({ "filename": item["filename"], "title": item["title"], "date": str(date.today()), "url": publish_url, "id": result["id"] }) save_queue(q)if __name__ == "__main__": main()
然后通过Crontab最终配置 contab -e# shell# 全局环境变量DEVTO_TOKEN=your_token_here# 每天9点:推送RSS0 9 * * * /脚本目录/.venv/bin/python /脚本目录/daily_ping.py >> /脚本目录/ping.log 2>&1# 每天10点:发布队列文章0 10 * * * /脚本目录/.venv/bin/python /脚本目录/auto_publish_queue.py >> /脚本目录/queue.log 2>&1