Python定时任务自动化:用Cron+Python打造你的个人工作流引擎

5 阅读41分钟

你每周花多少时间在那些"不得不做但价值极低"的事情上?


前言:被重复劳动偷走的人生

凌晨一点,办公室只剩下你一个人。显示器上,数据报表还差最后一步——把今天的销售数据从ERP系统导出,手动整理成Excel格式,截图插入PPT,再发一封邮件给领导。这套流程你已经很熟练了,毕竟它每天都要重复一次。

这不是个例。这是我观察过数百位职场人后发现的共同困境:我们花费了大量时间在完全可以自动化的事情上。每天手动导出数据、每周手动整理报告、每天定时检查系统状态、每周清理一次过期的日志文件……这些事情单独看都不起眼,但累积起来,足以吞噬我们三分之一的工作时间。

让我给你算一笔账:

  • 每天花30分钟做日报相关工作 → 每年150小时
  • 每天花20分钟导出和整理数据 → 每年100小时
  • 每天花15分钟分类整理文件 → 每年75小时
  • 每天花10分钟发送定时报告 → 每年50小时

合计:每年375小时。这意味着整整47个工作日——将近两个月的全职工作时间——被你花在了这些机械重复的事情上。两个月啊,足够你学一门新技能、完成一个项目、甚至休一个长假。

更让人沮丧的是,这类工作几乎没有任何技术门槛,也没有任何成就感。你明知道这些事可以被自动化,但就是不知道怎么动手;或者尝试过用Excel宏或者简单脚本,但发现维护成本高、容易出错,最后还是回归手动。

于是我们陷入了一个怪圈:越忙越没时间优化,越没时间优化就越忙

今天这篇文章,就是来打破这个怪圈的。我们将用最朴素的工具组合——Cron + Python——打造一套完整的个人工作流自动化引擎。整个方案的成本是:学习约2-3小时,之后永久受益。按照前面的计算,这2-3小时的投资,一年能换回375小时。投资回报率超过5000%

你准备好了吗?让我们开始吧。


一、为什么是 Cron + Python?

在开始写代码之前,我想先花一点篇幅解释一下为什么我们选择这个组合,以及它相比其他方案的优劣。这会帮助你理解我们接下来所有决策的前提。

1.1 Cron是什么?

Cron是Linux/Unix系统自带的任务调度器,它的职责很简单:在指定的时间执行指定的任务。你可以把它理解为系统级别的"闹钟"。cron的优势在于:

  • 系统内置,无需安装:任何Linux服务器、Mac电脑(macOS基于Unix)、甚至Windows的WSL子系统里都有
  • 开机自启:只要机器开着,cron任务就会准时运行,不依赖你登录
  • 资源占用极低:cron本身就是一个守护进程,几乎不占用任何CPU和内存
  • 稳定可靠:cron诞生于1975年,经过了近50年的工业级验证

cron的语法也非常直观。例如:

# 每天早上9点执行日报脚本
0 9 * * * /usr/bin/python3 /home/user/daily_report.py

# 每周一到周五下午6点执行备份
0 18 * * 1-5 /usr/bin/python3 /home/user/backup.py

这里* * * * *五个星号分别代表:分、时、日、月、周。理解了这个,你就掌握了cron的核心。

1.2 Python是什么,为什么用它来写自动化脚本?

Python几乎是目前最适合写自动化脚本的语言,没有之一:

  • 语法简洁:同样的功能,Python代码量通常是Java或C++的三分之一甚至五分之一
  • 生态丰富:处理Excel有pandas/openpyxl,发送邮件有smtplib,监控文件有watchdog,发HTTP请求有requests库——几乎所有你能想到的场景都有现成的轮子
  • 跨平台:同一份Python代码,在Mac、Linux、Windows都能运行(少数系统调用除外)
  • 学习曲线平缓:非计算机专业的人也能在几天内学会基础Python并写出实用脚本

1.3 为什么是" Cron + Python"而不是"纯Python"或"纯Cron"?

这是一个关键的架构决策。让我解释一下:

纯Python方案(只用APScheduler或schedule库)的优点是任务逻辑和调度逻辑都在一个进程里,调试方便。但缺点是:如果Python进程崩溃了,所有任务都停了;如果你有多台机器,每台都要单独部署一套调度系统。

纯Cron方案(只用crontab)的优点是系统级调度、稳定可靠。但缺点是:复杂的任务逻辑写在shell脚本里非常痛苦,文件操作、HTTP请求、数据处理这些能力都是shell的短板。

Cron + Python方案则结合了两者的优点:用cron作为"可靠的时钟",用Python作为"强大的执行器"。cron负责"几点几分执行",Python负责"具体做什么"。这样的分工有几个关键好处:

  1. 稳定性:cron由系统守护进程管理,几乎不会崩溃。Python脚本即使出错,也只是单次任务失败,不会影响整个调度系统
  2. 可维护性:Python代码有完整的错误处理、日志记录、调试工具,复杂逻辑写起来毫无压力
  3. 可扩展性:如果任务需要分布式运行,cron可以分布在多台机器上,Python逻辑保持一致
  4. 可观测性:Python脚本可以在每次运行后写日志、发通知,cron本身是没有这些能力的

这就是为什么这个组合是最高性价比的选择:它几乎不需要额外的学习成本(cron和Python都是你系统上已经有的东西),但能覆盖90%以上的自动化场景。


二、环境准备:5分钟搭建你的自动化工作台

在我们进入实战代码之前,先确保你的开发环境准备好了。这部分很简单,但很重要。

2.1 检查Python环境

打开终端,输入以下命令检查Python是否已安装:

python3 --version

如果显示类似Python 3.11.0这样的版本号,说明Python已经就绪。如果提示命令不存在,你需要先安装Python(macOS用户可以通过brew install python,Linux用户用sudo apt install python3sudo yum install python3)。

2.2 创建虚拟环境(推荐)

为了避免项目之间的依赖冲突,建议为自动化项目创建一个独立的虚拟环境:

# 创建项目目录
mkdir -p ~/automation-projects
cd ~/automation-projects

# 创建虚拟环境
python3 -m venv venv

# 激活虚拟环境(每次新开终端时需要)
source venv/bin/activate

激活虚拟环境后,你的终端前面会显示(venv)的标记,说明你现在的Python环境是独立的。

2.3 安装必要的Python库

我们的实战代码会用到以下库,先一次性安装:

# 安装实战所需的核心库
pip install schedule APScheduler watchdog pandas openpyxl requests pillow schedule

如果安装过程中有警告信息,只要是关于依赖冲突或版本不匹配的,通常不影响使用。遇到明确的错误(如某个库完全安装失败)再单独处理。

2.4 验证cron是否可用

crontab -l

如果显示no crontab for user,说明当前用户还没有创建任何定时任务,这是正常的。如果显示了已有的任务列表,说明cron已经配置好了。

2.5 目录结构建议

在我们开始之前,建议先建立好项目的目录结构:

mkdir -p ~/automation-projects/
mkdir -p ~/automation-projects/logs           # 存放日志文件
mkdir -p ~/automation-projects/data           # 存放数据文件
mkdir -p ~/automation-projects/reports        # 存放生成的报表
mkdir -p ~/automation-projects/scripts        # 存放Python脚本

这样的目录结构让项目一目了然:脚本scripts里运行,日志logs里记录,数据data里流转,产出reports里交付。


三、实战场景一:每日自动数据报表

场景描述

"每天早上9点,我需要把昨天的销售数据从数据库导出,生成一份Excel报表,包含关键指标的汇总和趋势图,然后发送给团队。这件事花了我每天30分钟,但它的价值几乎为零——数据是死的,分析报告也是千篇一律,我做它的唯一原因就是'流程要求'。"

这是我们收到的最典型的一个痛点。手动做日报不仅耗时,而且容易出错——尤其是月末年末数据量大的时候,手动筛选和计算很容易遗漏或算错。

解决方案

我们用Python脚本自动完成整个流程:

  1. 从数据源(这里用模拟的CSV文件代替数据库)读取昨日数据
  2. 用pandas进行数据处理和统计
  3. 用openpyxl生成格式美观的Excel报表
  4. 用smtplib发送邮件给指定收件人

整个过程完全自动化,cron负责定时触发,Python负责执行细节。

完整代码

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
每日自动数据报表生成脚本
功能:自动读取昨日销售数据,生成Excel报表并发送邮件
依赖:pandas, openpyxl(已通过pip安装)
作者:自动化工作流
"""

import os
import sys
import logging
import smtplib
import pandas as pd
from datetime import datetime, timedelta
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders

# ============================================================
# 1. 配置区 - 所有需要修改的配置都在这里
# ============================================================

# 邮件配置
SMTP_SERVER = "smtp.qq.com"        # QQ邮箱SMTP服务器地址
SMTP_PORT = 587                     # SMTP端口(QQ邮箱用587)
SENDER_EMAIL = "your_email@qq.com"  # 发件人邮箱
SENDER_PASSWORD = "your_app_password"  # 邮箱授权码(非登录密码)
RECIPIENT_EMAILS = ["boss@company.com", "team@company.com"]  # 收件人列表

# 路径配置
DATA_DIR = "/Users/eitan/automation-projects/data"        # 数据目录
REPORT_DIR = "/Users/eitan/automation-projects/reports"  # 报表输出目录
LOG_DIR = "/Users/eitan/automation-projects/logs"       # 日志目录

# ============================================================
# 2. 日志配置 - 记录脚本运行情况
# ============================================================

def setup_logging():
    """配置日志系统,记录脚本运行全过程"""
    # 确保日志目录存在
    os.makedirs(LOG_DIR, exist_ok=True)
    
    # 日志文件名包含日期,方便追踪
    log_file = os.path.join(LOG_DIR, f"daily_report_{datetime.now().strftime('%Y%m%d')}.log")
    
    # 配置logging:同时输出到终端和文件
    logging.basicConfig(
        level=logging.INFO,
        format='%(asctime)s [%(levelname)s] %(message)s',
        handlers=[
            logging.FileHandler(log_file, encoding='utf-8'),
            logging.StreamHandler(sys.stdout)
        ]
    )
    return logging.getLogger(__name__)

# ============================================================
# 3. 数据读取 - 从数据源获取昨日数据
# ============================================================

def get_yesterday_data(data_dir):
    """
    读取昨日的销售数据
    
    实际场景中,这里通常是:
    - 连接数据库(pymysql / psycopg2)执行SQL查询
    - 调用API获取数据
    - 读取Excel/CSV文件
    
    本例使用模拟数据演示流程
    """
    logger = logging.getLogger(__name__)
    
    # 计算昨日日期
    yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
    logger.info(f"正在读取 {yesterday} 的数据...")
    
    # 模拟:从CSV读取昨日销售记录
    # 真实场景可能是:df = pd.read_sql(query, connection)
    csv_path = os.path.join(data_dir, f"sales_{yesterday}.csv")
    
    # 如果文件不存在,生成模拟数据用于演示
    if not os.path.exists(csv_path):
        logger.warning(f"数据文件不存在,使用模拟数据: {csv_path}")
        # 生成7天的模拟数据
        dates = [(datetime.now() - timedelta(days=i)).strftime('%Y-%m-%d') for i in range(1, 8)]
        import random
        data = {
            '日期': dates * 10,
            '产品': random.choices(['产品A', '产品B', '产品C', '产品D'], k=70),
            '销售额': [random.randint(1000, 50000) for _ in range(70)],
            '成本': [random.randint(500, 25000) for _ in range(70)],
            '地区': random.choices(['华东', '华南', '华北', '西南'], k=70),
            '销售渠道': random.choices(['线上', '线下', '代理'], k=70),
        }
        df = pd.DataFrame(data)
        yesterday = dates[0]  # 取最早那天作为昨日
    else:
        df = pd.read_csv(csv_path, encoding='utf-8')
    
    logger.info(f"成功读取 {len(df)} 条记录")
    return df, yesterday

# ============================================================
# 4. 数据处理 - 用pandas做统计分析
# ============================================================

def analyze_data(df, yesterday):
    """
    对销售数据进行统计分析
    生成日报所需的所有指标
    """
    logger = logging.getLogger(__name__)
    logger.info("正在进行数据分析和统计...")
    
    # 筛选昨日数据(按日期过滤)
    # 真实场景中,如果数据源已经按日期分割,这步可能不需要
    if '日期' in df.columns and yesterday in df['日期'].values:
        df_yesterday = df[df['日期'] == yesterday]
    else:
        # 如果日期无法精确匹配,取最新一天的数据
        df_yesterday = df.tail(20)  # 取最后20条作为模拟昨日数据
    
    # 计算各项指标
    total_sales = df_yesterday['销售额'].sum()
    total_cost = df_yesterday['成本'].sum()
    
    metrics = {
        '报表日期': yesterday,
        '总销售额': total_sales,
        '总成本': total_cost,
        '订单数量': len(df_yesterday),
        '平均客单价': df_yesterday['销售额'].mean(),
        '毛利率': ((total_sales - total_cost) / total_sales * 100) if total_sales > 0 else 0,
    }
    
    # 按地区统计
    region_stats = df_yesterday.groupby('地区').agg({
        '销售额': 'sum',
        '成本': 'sum',
        '日期': 'count'
    }).rename(columns={'日期': '订单数'}).reset_index()
    region_stats['毛利率'] = ((region_stats['销售额'] - region_stats['成本']) 
                              / region_stats['销售额'] * 100)
    
    # 按渠道统计
    channel_stats = df_yesterday.groupby('销售渠道').agg({
        '销售额': 'sum',
        '成本': 'sum',
        '日期': 'count'
    }).rename(columns={'日期': '订单数'}).reset_index()
    channel_stats['毛利率'] = ((channel_stats['销售额'] - channel_stats['成本']) 
                               / channel_stats['销售额'] * 100)
    
    logger.info(f"分析完成 - 总销售额: {metrics['总销售额']:.2f}, "
                f"毛利率: {metrics['毛利率']:.2f}%")
    
    return metrics, region_stats, channel_stats

# ============================================================
# 5. 生成Excel报表 - 用openpyxl制作专业报表
# ============================================================

def generate_excel_report(metrics, region_stats, channel_stats, yesterday, report_dir):
    """
    生成格式美观的Excel报表
    包含:封面、汇总数据、各维度分析
    """
    logger = logging.getLogger(__name__)
    logger.info("正在生成Excel报表...")
    
    from openpyxl import Workbook
    from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
    
    # 创建工作簿
    wb = Workbook()
    
    # ===== Sheet1: 总览 =====
    ws_summary = wb.active
    ws_summary.title = "总览"
    
    # 定义样式
    header_font = Font(name='微软雅黑', size=14, bold=True, color='FFFFFF')
    header_fill = PatternFill(start_color='2F75B5', end_color='2F75B5', 
                              fill_type='solid')
    subheader_font = Font(name='微软雅黑', size=11, bold=True)
    subheader_fill = PatternFill(start_color='D6E4F0', end_color='D6E4F0',
                                  fill_type='solid')
    value_font = Font(name='微软雅黑', size=11)
    title_font = Font(name='微软雅黑', size=20, bold=True, color='2F75B5')
    thin_border = Border(
        left=Side(style='thin'),
        right=Side(style='thin'),
        top=Side(style='thin'),
        bottom=Side(style='thin')
    )
    
    # 标题
    ws_summary['A1'] = f"每日销售报表 - {yesterday}"
    ws_summary['A1'].font = title_font
    ws_summary.merge_cells('A1:D1')
    
    # 关键指标
    ws_summary['A3'] = "关键指标"
    ws_summary['A3'].font = Font(name='微软雅黑', size=13, bold=True)
    
    row = 4
    for key, value in metrics.items():
        ws_summary.cell(row=row, column=1, value=key).font = subheader_font
        ws_summary.cell(row=row, column=1).fill = subheader_fill
        ws_summary.cell(row=row, column=1).border = thin_border
        
        # 格式化数值
        if isinstance(value, float):
            if '率' in key or '均' in key:
                ws_summary.cell(row=row, column=2, value=f"{value:.2f}%")
            else:
                ws_summary.cell(row=row, column=2, value=f"¥{value:,.2f}")
        else:
            ws_summary.cell(row=row, column=2, value=value)
        
        ws_summary.cell(row=row, column=2).font = value_font
        ws_summary.cell(row=row, column=2).border = thin_border
        ws_summary.cell(row=row, column=2).alignment = Alignment(horizontal='right')
        row += 1
    
    # 设置列宽
    ws_summary.column_dimensions['A'].width = 20
    ws_summary.column_dimensions['B'].width = 25
    
    # ===== Sheet2: 地区分析 =====
    ws_region = wb.create_sheet("地区分析")
    ws_region['A1'] = f"按地区销售分析 - {yesterday}"
    ws_region['A1'].font = title_font
    ws_region.merge_cells('A1:E1')
    
    # 表头
    headers = ['地区', '销售额', '成本', '订单数', '毛利率']
    for col, header in enumerate(headers, 1):
        cell = ws_region.cell(row=3, column=col, value=header)
        cell.font = header_font
        cell.fill = header_fill
        cell.border = thin_border
        cell.alignment = Alignment(horizontal='center')
    
    # 数据行
    for row_idx, (_, data_row) in enumerate(region_stats.iterrows(), 4):
        ws_region.cell(row=row_idx, column=1, value=data_row['地区']).border = thin_border
        ws_region.cell(row=row_idx, column=2, 
                       value=f"¥{data_row['销售额']:,.0f}").border = thin_border
        ws_region.cell(row=row_idx, column=3, 
                       value=f"¥{data_row['成本']:,.0f}").border = thin_border
        ws_region.cell(row=row_idx, column=4, 
                       value=int(data_row['订单数'])).border = thin_border
        cell = ws_region.cell(row=row_idx, column=5, 
                              value=f"{data_row['毛利率']:.2f}%")
        cell.border = thin_border
        cell.alignment = Alignment(horizontal='right')
    
    # ===== Sheet3: 渠道分析 =====
    ws_channel = wb.create_sheet("渠道分析")
    ws_channel['A1'] = f"按渠道销售分析 - {yesterday}"
    ws_channel['A1'].font = title_font
    ws_channel.merge_cells('A1:E1')
    
    for col, header in enumerate(headers, 1):
        cell = ws_channel.cell(row=3, column=col, value=header)
        cell.font = header_font
        cell.fill = header_fill
        cell.border = thin_border
        cell.alignment = Alignment(horizontal='center')
    
    for row_idx, (_, data_row) in enumerate(channel_stats.iterrows(), 4):
        ws_channel.cell(row=row_idx, column=1, value=data_row['销售渠道']).border = thin_border
        ws_channel.cell(row=row_idx, column=2, 
                        value=f"¥{data_row['销售额']:,.0f}").border = thin_border
        ws_channel.cell(row=row_idx, column=3, 
                        value=f"¥{data_row['成本']:,.0f}").border = thin_border
        ws_channel.cell(row=row_idx, column=4, 
                        value=int(data_row['订单数'])).border = thin_border
        cell = ws_channel.cell(row=row_idx, column=5, 
                                value=f"{data_row['毛利率']:.2f}%")
        cell.border = thin_border
        cell.alignment = Alignment(horizontal='right')
    
    # 保存文件
    os.makedirs(report_dir, exist_ok=True)
    report_file = os.path.join(report_dir, f"日报_{yesterday}.xlsx")
    wb.save(report_file)
    logger.info(f"报表已生成: {report_file}")
    
    return report_file

# ============================================================
# 6. 发送邮件 - 将报表发送给团队
# ============================================================

def send_email_with_attachment(report_file, metrics, yesterday, sender_email, 
                                 sender_password, recipient_emails):
    """
    发送带附件的邮件
    """
    logger = logging.getLogger(__name__)
    logger.info(f"正在发送邮件至 {recipient_emails}...")
    
    try:
        # 构造邮件内容
        msg = MIMEMultipart()
        msg['From'] = sender_email
        msg['To'] = ', '.join(recipient_emails)
        msg['Subject'] = f"【每日报表】销售日报 - {yesterday}"
        
        # HTML邮件正文
        html_body = f"""
        <html>
        <body>
            <h2 style="color: #2F75B5;">📊 每日销售报表 - {yesterday}</h2>
            <p>您好,</p>
            <p>以下是昨日 ({yesterday}) 的销售数据汇总:</p>
            
            <table style="border-collapse: collapse; width: 100%; max-width: 500px;">
                <tr style="background-color: #2F75B5; color: white;">
                    <th style="padding: 10px; border: 1px solid #ddd; text-align: left;">指标</th>
                    <th style="padding: 10px; border: 1px solid #ddd; text-align: right;">数值</th>
                </tr>
                <tr>
                    <td style="padding: 10px; border: 1px solid #ddd;">总销售额</td>
                    <td style="padding: 10px; border: 1px solid #ddd; text-align: right; font-weight: bold;">
                        ¥{metrics['总销售额']:,.2f}
                    </td>
                </tr>
                <tr style="background-color: #f9f9f9;">
                    <td style="padding: 10px; border: 1px solid #ddd;">订单数量</td>
                    <td style="padding: 10px; border: 1px solid #ddd; text-align: right;">{metrics['订单数量']}</td>
                </tr>
                <tr>
                    <td style="padding: 10px; border: 1px solid #ddd;">平均客单价</td>
                    <td style="padding: 10px; border: 1px solid #ddd; text-align: right;">
                        ¥{metrics['平均客单价']:,.2f}
                    </td>
                </tr>
                <tr style="background-color: #f9f9f9;">
                    <td style="padding: 10px; border: 1px solid #ddd;">毛利率</td>
                    <td style="padding: 10px; border: 1px solid #ddd; text-align: right; 
                        color: {'green' if metrics['毛利率'] > 20 else 'orange'}; font-weight: bold;">
                        {metrics['毛利率']:.2f}%
                    </td>
                </tr>
            </table>
            
            <p style="margin-top: 20px;">详细报表请查看附件。如有疑问,请回复此邮件。</p>
            <p style="color: #666; font-size: 12px;">
                ⚡ 此邮件由自动化系统发送,请勿直接回复。
            </p>
        </body>
        </html>
        """
        
        msg.attach(MIMEText(html_body, 'html', 'utf-8'))
        
        # 添加附件
        with open(report_file, 'rb') as f:
            part = MIMEBase('application', 'octet-stream')
            part.set_payload(f.read())
            encoders.encode_base64(part)
            # 文件名支持中文
            import base64
            filename_b64 = base64.b64encode(os.path.basename(report_file).encode('utf-8')).decode()
            part.add_header('Content-Disposition', 'attachment', 
                          filename=f"=?UTF-8?B?{filename_b64}?=")
            msg.attach(part)
        
        # 发送邮件
        server = smtplib.SMTP(SMTP_SERVER, SMTP_PORT)
        server.starttls()  # 启用TLS加密
        server.login(sender_email, sender_password)
        server.send_message(msg)
        server.quit()
        
        logger.info("邮件发送成功!")
        return True
        
    except Exception as e:
        logger.error(f"邮件发送失败: {str(e)}")
        # 即使邮件发送失败,也不要让整个脚本崩溃
        # 报表已经生成了,邮件失败可以稍后重试或手动补发
        return False

# ============================================================
# 7. 主函数 - 串联所有步骤
# ============================================================

def main():
    """主执行流程"""
    logger = setup_logging()
    logger.info("=" * 50)
    logger.info("【每日报表自动化】任务启动")
    logger.info("=" * 50)
    
    try:
        # 步骤1: 读取数据
        df, yesterday = get_yesterday_data(DATA_DIR)
        
        # 步骤2: 数据分析
        metrics, region_stats, channel_stats = analyze_data(df, yesterday)
        
        # 步骤3: 生成Excel报表
        report_file = generate_excel_report(
            metrics, region_stats, channel_stats, yesterday, REPORT_DIR
        )
        
        # 步骤4: 发送邮件(可选,配置了邮件信息才会执行)
        if all([SENDER_EMAIL != "your_email@qq.com", 
                SENDER_PASSWORD != "your_app_password"]):
            send_email_with_attachment(
                report_file, metrics, yesterday,
                SENDER_EMAIL, SENDER_PASSWORD, RECIPIENT_EMAILS
            )
        else:
            logger.info("邮件配置未填写,跳过发送步骤(正常用于测试)")
        
        logger.info("【每日报表自动化】任务完成!")
        return 0
        
    except Exception as e:
        logger.error(f"任务执行过程中发生错误: {str(e)}")
        import traceback
        logger.error(traceback.format_exc())
        return 1

if __name__ == "__main__":
    sys.exit(main())

Crontab配置

# 每天早上8点50执行(留10分钟给数据准备)
50 8 * * * /usr/bin/python3 /Users/eitan/automation-projects/scripts/daily_report.py >> /Users/eitan/automation-projects/logs/cron_daily_report.log 2>&1

# 或者如果你用了虚拟环境,用venv里的python
# 50 8 * * * /Users/eitan/automation-projects/venv/bin/python /Users/eitan/automation-projects/scripts/daily_report.py >> /Users/eitan/automation-projects/logs/cron_daily_report.log 2>&1

运行效果

当脚本成功执行后,你会看到以下结果:

  1. 终端/日志输出:记录了从数据读取到邮件发送的每一步操作,包含读取了多少条记录、生成了哪些统计数据
  2. 生成的Excel文件:在reports目录下有一份格式美观的报表,包含总览、地区分析、渠道分析三个Sheet,每个Sheet都有清晰的表头、边框和格式化数字
  3. 邮件送达:收件人邮箱里有一封HTML格式的邮件,正文是昨天业绩的摘要表格,附件是完整的Excel报表

整个过程耗时约3-5秒(取决于数据量),完全不需要人工干预。


四、实战场景二:文件监控自动化

场景描述

"我的工作经常需要处理各种来源的文件——客户发来的合同设计稿、运营整理的活动素材、技术团队的日志包。以前我是手动把这些文件分类到不同文件夹的,但经常搞混,而且每次换电脑都要重新设置。更让人头疼的是,有些文件名乱七八糟的,比如新建文件夹(1)_最终版_改2.psd,我每次看到都头疼。"

文件管理是自动化里"性价比"最高的场景之一:规则明确、实现简单、效果立竿见影。而且文件监控几乎是零成本的——文件来了就处理,没来就不处理,不需要额外的数据或接口。

解决方案

我们用Python的watchdog库来监控指定文件夹。当检测到新文件创建或修改时,脚本自动完成以下工作:

  1. 识别文件类型:根据扩展名判断是图片、文档、视频还是其他
  2. 自动重命名:将乱码或混乱的文件名规范化
  3. 自动分类:根据类型移动到对应文件夹
  4. 日志记录:所有操作都记录下来,方便追溯

完整代码

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
文件监控自动化脚本
功能:监控指定文件夹,新文件自动分类/重命名/移动
依赖:watchdog(已通过pip安装)
"""

import os
import sys
import re
import shutil
import logging
import time
from datetime import datetime
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler

# ============================================================
# 1. 配置区 - 根据你的实际情况修改
# ============================================================

# 要监控的根目录
WATCH_DIR = "/Users/eitan/Downloads"

# 分类规则:扩展名 -> 目标文件夹名
FILE_CATEGORIES = {
    'images': ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.svg', '.webp', '.ico', '.psd', '.ai'],
    'documents': ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.md', '.csv'],
    'videos': ['.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm'],
    'audio': ['.mp3', '.wav', '.flac', '.aac', '.ogg', '.m4a'],
    'archives': ['.zip', '.rar', '.7z', '.tar', '.gz', '.bz2'],
    'code': ['.py', '.js', '.java', '.cpp', '.c', '.h', '.css', '.html', '.json', '.xml', '.sql'],
    'design': ['.psd', '.ai', '.sketch', '.fig', '.xd'],
}

# 分类文件夹映射
CATEGORY_FOLDERS = {
    'images': '📷 图片',
    'documents': '📄 文档',
    'videos': '🎬 视频',
    'audio': '🎵 音频',
    'archives': '📦 压缩包',
    'code': '💻 代码',
    'design': '🎨 设计稿',
}

# 忽略的文件(临时文件、系统文件等)
IGNORE_PATTERNS = [
    '.DS_Store',           # macOS系统文件
    'Thumbs.db',           # Windows缩略图
    'desktop.ini',         # Windows桌面配置
    '.partial',            # 下载未完成
    '.crdownload',        # Chrome下载
    '.download',           # 正在下载
]

# 等待文件完全写入的时间(秒)
WRITE_WAIT_TIME = 2

# ============================================================
# 2. 工具函数
# ============================================================

def setup_logging(log_dir):
    """配置日志"""
    os.makedirs(log_dir, exist_ok=True)
    log_file = os.path.join(log_dir, f"file_monitor_{datetime.now().strftime('%Y%m%d')}.log")
    
    logging.basicConfig(
        level=logging.INFO,
        format='%(asctime)s [%(levelname)s] %(message)s',
        handlers=[
            logging.FileHandler(log_file, encoding='utf-8'),
            logging.StreamHandler(sys.stdout)
        ]
    )
    return logging.getLogger(__name__)

def get_file_category(filename):
    """根据文件扩展名判断文件类型"""
    ext = os.path.splitext(filename)[1].lower()
    for category, extensions in FILE_CATEGORIES.items():
        if ext in extensions:
            return category
    return 'others'

def sanitize_filename(filename):
    """
    规范化文件名:去除乱码、特殊字符、空格
    例如: "新建文件夹(1)_最终版_改2.psd" -> "最终版_改2.psd"
    """
    # 获取文件扩展名和基础名
    name, ext = os.path.splitext(filename)
    
    # 替换空格为下划线
    name = re.sub(r'\s+', '_', name)
    
    # 去除括号内容(如"(1)")
    name = re.sub(r'\([^)]*\)', '', name)
    
    # 去除连续的横杠
    name = re.sub(r'-+', '-', name)
    
    # 去除开头结尾的横杠和下划线
    name = re.sub(r'^[_\-]+|[_\-]+$', '', name)
    
    # 只保留字母、数字、中文、常用符号
    name = re.sub(r'[^\w\u4e00-\u9fff\-\.]', '', name)
    
    # 如果名字太短或为空,生成一个默认名字
    if len(name.strip()) < 2:
        name = f"file_{datetime.now().strftime('%H%M%S')}"
    
    return name + ext.lower()

def should_ignore(filename):
    """检查文件是否应该被忽略"""
    for pattern in IGNORE_PATTERNS:
        if pattern in filename or filename.startswith('.'):
            return True
    return False

def is_file_ready(filepath, timeout=5):
    """
    检查文件是否已经完全写入
    原理:连续两次读取文件大小,如果不变则认为写入完成
    """
    try:
        if not os.path.exists(filepath):
            return False
        
        size1 = os.path.getsize(filepath)
        time.sleep(WRITE_WAIT_TIME)
        size2 = os.path.getsize(filepath)
        
        return size1 == size2 and size2 > 0
        
    except Exception:
        return False

# ============================================================
# 3. 文件处理逻辑
# ============================================================

class FileOrganizerHandler(FileSystemEventHandler):
    """
    文件系统事件处理器
    当检测到新文件时,自动分类和重命名
    """
    
    def __init__(self, watch_dir, logger):
        self.watch_dir = watch_dir
        self.logger = logger
        self.processed_files = set()
        self._ensure_category_folders()
    
    def _ensure_category_folders(self):
        """确保所有分类文件夹存在"""
        self.category_dirs = {}
        for category, folder_name in CATEGORY_FOLDERS.items():
            target_dir = os.path.join(self.watch_dir, folder_name)
            if not os.path.exists(target_dir):
                os.makedirs(target_dir)
                self.logger.info(f"创建分类文件夹: {target_dir}")
            self.category_dirs[category] = target_dir
        
        others_dir = os.path.join(self.watch_dir, '📁 其他')
        if not os.path.exists(others_dir):
            os.makedirs(others_dir)
        self.category_dirs['others'] = others_dir
    
    def on_created(self, event):
        """当新文件创建时触发"""
        if event.is_directory:
            return
        
        filepath = event.src_path
        
        if should_ignore(filepath):
            self.logger.debug(f"跳过忽略文件: {filepath}")
            return
        
        if filepath in self.processed_files:
            return
        
        if not is_file_ready(filepath):
            self.logger.warning(f"文件尚未就绪,跳过: {filepath}")
            return
        
        try:
            self._process_file(filepath)
        except Exception as e:
            self.logger.error(f"处理文件失败 {filepath}: {str(e)}")
    
    def on_modified(self, event):
        """当文件被修改时触发(有些程序创建文件时会触发modify而非create)"""
        if event.is_directory:
            return
        
        filepath = event.src_path
        
        if filepath in self.processed_files:
            return
        
        time.sleep(WRITE_WAIT_TIME)
        
        if os.path.exists(filepath) and os.path.getsize(filepath) > 0:
            try:
                self._process_file(filepath)
            except Exception as e:
                self.logger.error(f"处理文件失败 {filepath}: {str(e)}")
    
    def _process_file(self, filepath):
        """执行文件分类和重命名"""
        filename = os.path.basename(filepath)
        category = get_file_category(filename)
        
        self.logger.info(f"📥 检测到新文件: {filename} (类型: {category})")
        
        # 步骤1: 规范化文件名
        new_filename = sanitize_filename(filename)
        
        # 步骤2: 确定目标文件夹
        target_dir = self.category_dirs.get(category, self.category_dirs['others'])
        
        # 步骤3: 处理重名文件(如果目标位置已有同名文件)
        target_path = os.path.join(target_dir, new_filename)
        if os.path.exists(target_path):
            name, ext = os.path.splitext(new_filename)
            timestamp = datetime.now().strftime('%H%M%S')
            new_filename = f"{name}_{timestamp}{ext}"
            target_path = os.path.join(target_dir, new_filename)
        
        # 步骤4: 移动文件
        shutil.move(filepath, target_path)
        
        # 记录已处理
        self.processed_files.add(filepath)
        self.processed_files.add(target_path)
        
        self.logger.info(f"✅ 文件已分类完成:")
        self.logger.info(f"   原始名称: {filename}")
        self.logger.info(f"   新名称:   {new_filename}")
        self.logger.info(f"   目标位置: {target_path}")

# ============================================================
# 4. 主函数
# ============================================================

def main():
    """启动文件监控"""
    LOG_DIR = "/Users/eitan/automation-projects/logs"
    logger = setup_logging(LOG_DIR)
    
    logger.info("=" * 50)
    logger.info("【文件监控自动化】启动")
    logger.info(f"监控目录: {WATCH_DIR}")
    logger.info("按 Ctrl+C 停止监控")
    logger.info("=" * 50)
    
    if not os.path.exists(WATCH_DIR):
        logger.error(f"监控目录不存在: {WATCH_DIR}")
        sys.exit(1)
    
    event_handler = FileOrganizerHandler(WATCH_DIR, logger)
    observer = Observer()
    observer.schedule(event_handler, WATCH_DIR, recursive=False)
    observer.start()
    
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        logger.info("收到停止信号,正在关闭...")
        observer.stop()
    
    observer.join()
    logger.info("文件监控已停止")

if __name__ == "__main__":
    main()

Crontab配置

文件监控脚本需要持续运行,所以我们有两种启动方式:

方式一:使用systemd服务(推荐,用于长期运行的服务器)

# 创建systemd服务文件
sudo nano /etc/systemd/system/file-monitor.service
[Unit]
Description=File Monitor Service
After=network.target

[Service]
Type=simple
User=eitan
WorkingDirectory=/Users/eitan/automation-projects
ExecStart=/Users/eitan/automation-projects/venv/bin/python /Users/eitan/automation-projects/scripts/file_monitor.py
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
# 启用并启动服务
sudo systemctl daemon-reload
sudo systemctl enable file-monitor
sudo systemctl start file-monitor

# 查看状态
sudo systemctl status file-monitor

方式二:使用screen或nohup(简单场景)

# 在screen中运行
screen -S file_monitor -dm /Users/eitan/automation-projects/venv/bin/python /Users/eitan/automation-projects/scripts/file_monitor.py

# 或使用nohup后台运行(重启后不会自动恢复)
nohup /Users/eitan/automation-projects/venv/bin/python /Users/eitan/automation-projects/scripts/file_monitor.py > /Users/eitan/automation-projects/logs/file_monitor_stdout.log 2>&1 &

# 获取进程ID
echo $!

运行效果

启动脚本后,当你往监控目录里拖入一个文件:

  1. 2秒后(等待写入完成),日志里出现📥 检测到新文件: 新建文件夹(1)_最终版_改2.psd (类型: images)
  2. 文件被重命名为最终版_改2.psd
  3. 从下载目录移动到📷 图片文件夹
  4. 日志显示✅ 文件已分类完成

整个过程完全无声无息,你不需要做任何操作。几个月后回头看日志,你会发现自己省下了几十个小时的文件整理时间。


五、实战场景三:API定时调度

场景描述

"我需要每隔30分钟抓取一次某个API的数据,检查库存和价格变化。如果价格跌破某个阈值,就发一条通知给我。以前我是开着浏览器一直刷新页面,现在我想让机器来做这件事。"

API调度是最常见的自动化需求之一。它让机器去做"不断重复地检查"这件事,而人只需要在真正需要行动的时候才介入。

解决方案

我们用Python的requests库定期调用外部API,用APScheduler作为进程内的调度器(也可以用cron来触发脚本,但APScheduler更适合需要高频调度的场景)。当检测到异常时,通过多个渠道发送告警。

完整代码

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
API定时调度脚本
功能:定时调用外部API获取数据,异常时自动告警
依赖:requests, APScheduler(已通过pip安装)
"""

import os
import sys
import json
import logging
import time
import smtplib
from datetime import datetime
from email.mime.text import MIMEText

import requests
from requests.exceptions import RequestException, Timeout
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.interval import IntervalTrigger

# ============================================================
# 1. 配置区
# ============================================================

# API配置
API_URL = "https://api.example.com/products"  # 替换为你的API地址
API_HEADERS = {
    "Authorization": "Bearer YOUR_TOKEN_HERE",
    "Content-Type": "application/json",
}
API_TIMEOUT = 10  # 请求超时时间(秒)

# 轮询间隔(秒)
POLL_INTERVAL_SECONDS = 1800  # 30分钟

# 告警阈值
PRICE_DROP_THRESHOLD = 0.1  # 价格下跌超过10%触发告警
LOW_STOCK_THRESHOLD = 10    # 库存低于10件触发告警

# 邮件告警配置
SMTP_SERVER = "smtp.qq.com"
SMTP_PORT = 587
ALERT_EMAIL = "your_email@qq.com"
ALERT_PASSWORD = "your_app_password"
ALERT_RECIPIENT = "your_phone@139.com"  # 告警通知邮箱

# 数据存储路径(存放上次状态,用于比较变化)
DATA_DIR = "/Users/eitan/automation-projects/data"
STATE_FILE = os.path.join(DATA_DIR, "api_monitor_state.json")
LOG_DIR = "/Users/eitan/automation-projects/logs"

# ============================================================
# 2. 日志配置
# ============================================================

def setup_logging():
    """配置日志"""
    os.makedirs(LOG_DIR, exist_ok=True)
    log_file = os.path.join(LOG_DIR, f"api_monitor_{datetime.now().strftime('%Y%m%d')}.log")
    
    logging.basicConfig(
        level=logging.INFO,
        format='%(asctime)s [%(levelname)s] %(message)s',
        handlers=[
            logging.FileHandler(log_file, encoding='utf-8'),
            logging.StreamHandler(sys.stdout)
        ]
    )
    return logging.getLogger(__name__)

# ============================================================
# 3. 状态管理 - 记录上次数据,用于比较
# ============================================================

def load_previous_state():
    """加载上次的状态数据"""
    try:
        if os.path.exists(STATE_FILE):
            with open(STATE_FILE, 'r', encoding='utf-8') as f:
                return json.load(f)
    except Exception:
        pass
    return {}

def save_current_state(state):
    """保存当前状态"""
    os.makedirs(DATA_DIR, exist_ok=True)
    with open(STATE_FILE, 'w', encoding='utf-8') as f:
        json.dump(state, f, ensure_ascii=False, indent=2)

# ============================================================
# 4. API调用逻辑
# ============================================================

def fetch_api_data(logger):
    """
    调用外部API获取数据
    包含完整的错误处理和重试逻辑
    """
    logger.info(f"正在调用API: {API_URL}")
    
    try:
        response = requests.get(
            API_URL,
            headers=API_HEADERS,
            timeout=API_TIMEOUT
        )
        
        response.raise_for_status()
        
        data = response.json()
        logger.info(f"API调用成功,返回 {len(data) if isinstance(data, list) else 'N/A'} 条记录")
        
        return data, None
    
    except Timeout:
        error_msg = f"API请求超时(超时时间: {API_TIMEOUT}秒)"
        logger.error(error_msg)
        return None, error_msg
    
    except RequestException as e:
        error_msg = f"API请求失败: {str(e)}"
        logger.error(error_msg)
        return None, error_msg
    
    except json.JSONDecodeError:
        error_msg = "API返回的不是有效的JSON格式"
        logger.error(error_msg)
        return None, error_msg

def analyze_and_compare(new_data, previous_state, logger):
    """
    分析新数据,与上次状态比较,找出异常
    """
    logger.info("正在分析数据并与上次状态比较...")
    
    alerts = []
    
    if not isinstance(new_data, list):
        logger.warning("API返回的不是列表格式,跳过分析")
        return alerts, {}
    
    current_state = {}
    
    for item in new_data:
        try:
            product_id = item.get('id') or item.get('product_id')
            name = item.get('name', '未知商品')
            price = float(item.get('price', 0))
            stock = int(item.get('stock', 0))
            
            current_state[product_id] = {
                'name': name,
                'price': price,
                'stock': stock,
                'checked_at': datetime.now().isoformat()
            }
            
            if product_id in previous_state:
                prev = previous_state[product_id]
                prev_price = prev.get('price', 0)
                prev_stock = prev.get('stock', 0)
                
                if prev_price > 0:
                    price_change_pct = (price - prev_price) / prev_price
                    
                    if price_change_pct <= -PRICE_DROP_THRESHOLD:
                        alert = (
                            f"💰 【价格下跌告警】\n"
                            f"商品: {name}\n"
                            f"原价: ¥{prev_price:.2f}\n"
                            f"现价: ¥{price:.2f}\n"
                            f"跌幅: {abs(price_change_pct)*100:.1f}%\n"
                            f"商品ID: {product_id}"
                        )
                        alerts.append(alert)
                        logger.warning(f"检测到价格下跌: {name} ({price_change_pct*100:.1f}%)")
                    
                    elif price_change_pct >= 0.05:
                        logger.info(f"商品价格上涨: {name} (+{price_change_pct*100:.1f}%)")
                
                if prev_stock > LOW_STOCK_THRESHOLD and stock <= LOW_STOCK_THRESHOLD:
                    alert = (
                        f"📦 【库存不足告警】\n"
                        f"商品: {name}\n"
                        f"商品ID: {product_id}\n"
                        f"当前库存: {stock} 件\n"
                        f"⚠️ 库存已降至预警线以下!"
                    )
                    alerts.append(alert)
                    logger.warning(f"检测到库存不足: {name} (库存: {stock})")
            
            else:
                logger.info(f"发现新商品: {name}")
        
        except (ValueError, TypeError, KeyError) as e:
            logger.warning(f"数据解析异常,跳过该商品: {str(e)}")
            continue
    
    return alerts, current_state

# ============================================================
# 5. 告警通知
# ============================================================

def send_alert(alert_message, logger):
    """
    发送告警通知
    包含邮件和HTTP回调(可扩展)
    """
    logger.info(f"🚨 发送告警通知: {alert_message[:50]}...")
    
    try:
        if ALERT_EMAIL and ALERT_PASSWORD != "your_app_password":
            msg = MIMEText(alert_message, 'plain', 'utf-8')
            msg['Subject'] = f"【API监控告警】{datetime.now().strftime('%H:%M:%S')}"
            msg['From'] = ALERT_EMAIL
            msg['To'] = ALERT_RECIPIENT
            
            server = smtplib.SMTP(SMTP_SERVER, SMTP_PORT)
            server.starttls()
            server.login(ALERT_EMAIL, ALERT_PASSWORD)
            server.send_message(msg)
            server.quit()
            logger.info("邮件告警已发送")
        
        return True
        
    except Exception as e:
        logger.error(f"告警发送失败: {str(e)}")
        return False

# ============================================================
# 6. 飞书Webhook示例(仅供参考,需要时可取消注释)
# ============================================================

def send_feishu_alert(message):
    """
    通过飞书机器人发送告警
    需要配置飞书群机器人的Webhook地址
    """
    webhook_url = "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_WEBHOOK_HERE"
    
    payload = {
        "msg_type": "text",
        "content": {
            "text": message
        }
    }
    
    try:
        resp = requests.post(webhook_url, json=payload, timeout=5)
        result = resp.json()
        if result.get('code') == 0:
            logging.getLogger(__name__).info("飞书告警发送成功")
        else:
            logging.getLogger(__name__).error(f"飞书告警发送失败: {result}")
    except Exception as e:
        logging.getLogger(__name__).error(f"飞书告警异常: {str(e)}")

# ============================================================
# 7. 主任务函数
# ============================================================

def monitor_task():
    """定时任务:检查API数据"""
    logger = setup_logging()
    
    logger.info("=" * 50)
    logger.info(f"【API监控】轮询开始 - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    
    try:
        data, error = fetch_api_data(logger)
        
        if error:
            send_alert(f"⚠️ API调用异常\n{error}", logger)
            return
        
        previous_state = load_previous_state()
        alerts, current_state = analyze_and_compare(data, previous_state, logger)
        save_current_state(current_state)
        
        if alerts:
            for alert in alerts:
                send_alert(alert, logger)
        else:
            logger.info("本次检查未发现异常")
        
        logger.info(f"【API监控】轮询完成 - 下次检查: {POLL_INTERVAL_SECONDS/60:.0f}分钟后")
        
    except Exception as e:
        logger.error(f"监控任务异常: {str(e)}")
        import traceback
        logger.error(traceback.format_exc())

# ============================================================
# 8. 主函数
# ============================================================

def main():
    """启动定时调度"""
    logger = setup_logging()
    
    logger.info("=" * 50)
    logger.info("【API定时调度】服务启动")
    logger.info(f"轮询间隔: 每 {POLL_INTERVAL_SECONDS/60:.0f} 分钟")
    logger.info(f"告警阈值: 价格下跌>{PRICE_DROP_THRESHOLD*100:.0f}%, 库存<{LOW_STOCK_THRESHOLD}件")
    logger.info("按 Ctrl+C 停止")
    logger.info("=" * 50)
    
    scheduler = BlockingScheduler()
    
    scheduler.add_job(
        monitor_task,
        IntervalTrigger(seconds=POLL_INTERVAL_SECONDS),
        id='api_monitor',
        name='API数据监控',
        replace_existing=True
    )
    
    logger.info("立即执行首次检查...")
    monitor_task()
    
    try:
        scheduler.start()
    except (KeyboardInterrupt, SystemExit):
        logger.info("收到停止信号,正在关闭...")
        scheduler.shutdown()

if __name__ == "__main__":
    main()

Crontab配置

如果你更习惯用cron来驱动这个脚本,也可以将APScheduler的调度职责交给cron:

# 每30分钟执行一次API监控脚本
*/30 * * * * /Users/eitan/automation-projects/venv/bin/python /Users/eitan/automation-projects/scripts/api_monitor.py >> /Users/eitan/automation-projects/logs/cron_api_monitor.log 2>&1

但需要注意:用cron驱动时,如果上一次任务还没执行完(比如API响应很慢),新的任务又会被触发,可能造成任务堆积。这时我们需要在脚本内部加一个锁机制:

# 在脚本开头加锁,防止重复执行
LOCK_FILE = "/tmp/api_monitor.lock"

if os.path.exists(LOCK_FILE):
    lock_time = os.path.getmtime(LOCK_FILE)
    if time.time() - lock_time < 3600:
        print("检测到上一次任务仍在运行,跳过本次执行")
        sys.exit(0)
    else:
        print("发现过期锁文件,清理后继续...")

open(LOCK_FILE, 'w').close()

运行效果

启动脚本后,它会按照设定的时间间隔自动运行:

  • 每次运行时:在日志里记录"轮询开始"、API调用结果、数据分析摘要
  • 当检测到异常时:立即发送告警邮件/通知,并详细记录异常内容
  • 当API不可用时:记录错误并发送"API调用异常"的告警

你可以同时运行多个监控任务,每个监控不同的API,真正实现"一次搭建,长期自动运行"。


六、实战场景四:日志自动清理

场景描述

"服务器上动不动就磁盘满了,每次都是等系统报警才发现。一查原因,全是日志文件占用的空间——有些日志文件一年都没清理过。但手动清理又怕删错东西,影响业务。"

日志管理是服务器运维的经典难题。太短了查不了问题,太长了占满磁盘。这个场景我会演示一个更"懒人友好"的方案:不只是清理日志,而是建立一个自动化的日志生命周期管理系统。

解决方案

我们写一个脚本来做三件事:

  1. 扫描:找出所有日志文件,按大小和日期排序
  2. 分析:判断哪些可以清理(超过保留期限的),哪些必须保留(正在写入的)
  3. 清理:按策略清理日志,同时保留最新N个文件以防万一

完整代码

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
日志自动清理脚本
功能:自动扫描并清理过期日志,防止磁盘空间被日志占满
依赖:标准库,无需额外安装
"""

import os
import sys
import gzip
import shutil
import logging
from datetime import datetime, timedelta
from pathlib import Path

# ============================================================
# 1. 配置区
# ============================================================

# 要扫描的日志目录(支持多个目录)
LOG_DIRECTORIES = [
    "/Users/eitan/automation-projects/logs",
    "/var/log",              # Linux系统日志
    "/Users/eitan/Library/Logs",  # macOS应用日志
]

# 日志保留策略
RETENTION_DAYS = {
    '.log': 30,          # 普通日志保留30天
    '.txt': 14,          # 文本文件保留14天
    '.out': 7,           # 输出文件保留7天
    '.err': 3,           # 错误日志只保留3天
    '.gz': 90,           # 已压缩的日志保留更久
    '.zip': 90,
}

# 超过此大小的日志文件优先压缩(单位:字节)
AUTO_COMPRESS_SIZE = 10 * 1024 * 1024  # 10MB

# 保留最小磁盘空间(GB),低于此值时强制清理
MIN_FREE_SPACE_GB = 5

# 是否执行实际操作(False时只生成报告)
DRY_RUN = False  # 调试时设为True,确认无误后改为False

# 日志目录
SCRIPT_LOG_DIR = "/Users/eitan/automation-projects/logs"

# ============================================================
# 2. 日志配置
# ============================================================

def setup_logging():
    """配置脚本自身的日志"""
    os.makedirs(SCRIPT_LOG_DIR, exist_ok=True)
    log_file = os.path.join(SCRIPT_LOG_DIR, f"log_cleaner_{datetime.now().strftime('%Y%m%d')}.log")
    
    logging.basicConfig(
        level=logging.INFO,
        format='%(asctime)s [%(levelname)s] %(message)s',
        handlers=[
            logging.FileHandler(log_file, encoding='utf-8'),
            logging.StreamHandler(sys.stdout)
        ]
    )
    return logging.getLogger(__name__)

# ============================================================
# 3. 工具函数
# ============================================================

def get_file_age_days(filepath):
    """获取文件年龄(天)"""
    try:
        mtime = os.path.getmtime(filepath)
        age = datetime.now() - datetime.fromtimestamp(mtime)
        return age.days
    except Exception:
        return 0

def format_size(bytes_size):
    """将字节数转换为人类可读的格式"""
    for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
        if bytes_size < 1024:
            return f"{bytes_size:.2f} {unit}"
        bytes_size /= 1024
    return f"{bytes_size:.2f} PB"

def is_log_file(filepath):
    """判断文件是否是日志文件"""
    ext = os.path.splitext(filepath)[1].lower()
    return ext in RETENTION_DAYS

def should_compress(filepath):
    """判断文件是否应该被压缩"""
    if not os.path.exists(filepath):
        return False
    
    ext = os.path.splitext(filepath)[1].lower()
    if ext in ['.gz', '.zip', '.bz2']:
        return False
    
    size = os.path.getsize(filepath)
    return size >= AUTO_COMPRESS_SIZE

def compress_file(filepath, logger):
    """压缩单个日志文件"""
    try:
        compressed_path = f"{filepath}.gz"
        
        if os.path.exists(compressed_path):
            os.remove(compressed_path)
        
        with open(filepath, 'rb') as f_in:
            with gzip.open(compressed_path, 'wb') as f_out:
                shutil.copyfileobj(f_in, f_out)
        
        original_size = os.path.getsize(filepath)
        compressed_size = os.path.getsize(compressed_path)
        
        logger.info(f"  压缩: {os.path.basename(filepath)}")
        logger.info(f"    原始大小: {format_size(original_size)}")
        logger.info(f"    压缩后:   {format_size(compressed_size)} "
                    f"(节省 {format_size(original_size - compressed_size)})")
        
        if DRY_RUN:
            logger.info(f"  [DRY-RUN] 原文件应删除: {filepath}")
        else:
            os.remove(filepath)
            logger.info(f"  原文件已删除: {filepath}")
        
        return True
        
    except Exception as e:
        logger.error(f"  压缩失败 {filepath}: {str(e)}")
        return False

def delete_file(filepath, logger, reason=""):
    """删除文件"""
    try:
        size = os.path.getsize(filepath)
        
        if DRY_RUN:
            logger.info(f"  [DRY-RUN] 应删除: {filepath} ({format_size(size)}) {reason}")
        else:
            os.remove(filepath)
            logger.info(f"  删除: {filepath} ({format_size(size)}) {reason}")
        
        return size
    except Exception as e:
        logger.error(f"  删除失败 {filepath}: {str(e)}")
        return 0

# ============================================================
# 4. 磁盘空间检查
# ============================================================

def get_disk_usage(path="/"):
    """获取磁盘使用情况"""
    try:
        stat = shutil.disk_usage(path)
        return {
            'total': stat.total,
            'used': stat.used,
            'free': stat.free,
            'percent': (stat.used / stat.total) * 100
        }
    except Exception:
        return None

def check_and_warn_low_space(logger):
    """检查磁盘空间,如果过低则发出警告"""
    usage = get_disk_usage("/")
    
    if not usage:
        logger.warning("无法获取磁盘使用情况")
        return
    
    free_gb = usage['free'] / (1024**3)
    used_percent = usage['percent']
    
    logger.info(f"磁盘使用情况: 已用 {used_percent:.1f}%, "
                f"剩余 {free_gb:.2f} GB")
    
    if free_gb < MIN_FREE_SPACE_GB:
        logger.warning(f"⚠️ 磁盘空间不足!剩余 {free_gb:.2f} GB,"
                      f"低于警戒线 {MIN_FREE_SPACE_GB} GB")

# ============================================================
# 5. 扫描和清理逻辑
# ============================================================

def scan_and_analyze(directory, logger):
    """
    扫描目录下的日志文件
    返回:(待压缩列表, 待删除列表, 统计信息)
    """
    to_compress = []
    to_delete = []
    total_files = 0
    total_size = 0
    
    try:
        for root, dirs, files in os.walk(directory):
            for filename in files:
                filepath = os.path.join(root, filename)
                
                if not is_log_file(filepath):
                    continue
                
                total_files += 1
                
                try:
                    file_size = os.path.getsize(filepath)
                    total_size += file_size
                    file_age = get_file_age_days(filepath)
                    ext = os.path.splitext(filename)[1].lower()
                    retention = RETENTION_DAYS.get(ext, 30)
                    
                    if should_compress(filepath):
                        to_compress.append({
                            'path': filepath,
                            'size': file_size,
                            'age': file_age,
                            'retention': retention
                        })
                    elif file_age > retention:
                        to_delete.append({
                            'path': filepath,
                            'size': file_size,
                            'age': file_age,
                            'retention': retention,
                            'reason': f"超过保留期限({retention}天)"
                        })
                
                except Exception as e:
                    logger.warning(f"  无法处理文件 {filepath}: {str(e)}")
    
    except PermissionError:
        logger.warning(f"  无权限访问目录: {directory}")
    except Exception as e:
        logger.error(f"  扫描目录失败 {directory}: {str(e)}")
    
    return to_compress, to_delete, {
        'total_files': total_files,
        'total_size': total_size
    }

def run_cleanup(logger):
    """
    执行清理任务
    """
    logger.info("=" * 50)
    logger.info("【日志自动清理】任务开始")
    logger.info(f"执行模式: {'DRY-RUN(仅报告,不删除)' if DRY_RUN else '正式执行'}")
    logger.info("=" * 50)
    
    check_and_warn_low_space(logger)
    
    all_to_compress = []
    all_to_delete = []
    total_stats = {'total_files': 0, 'total_size': 0}
    
    for directory in LOG_DIRECTORIES:
        if not os.path.exists(directory):
            logger.info(f"目录不存在,跳过: {directory}")
            continue
        
        logger.info(f"\n正在扫描: {directory}")
        to_compress, to_delete, stats = scan_and_analyze(directory, logger)
        
        all_to_compress.extend(to_compress)
        all_to_delete.extend(to_delete)
        total_stats['total_files'] += stats['total_files']
        total_stats['total_size'] += stats['total_size']
        
        logger.info(f"  扫描完成: 发现 {stats['total_files']} 个日志文件, "
                    f"共 {format_size(stats['total_size'])}")
    
    all_to_compress.sort(key=lambda x: x['size'], reverse=True)
    all_to_delete.sort(key=lambda x: x['age'], reverse=True)
    
    logger.info("\n" + "=" * 50)
    logger.info("📊 清理分析报告")
    logger.info("=" * 50)
    logger.info(f"扫描日志文件总数: {total_stats['total_files']}")
    logger.info(f"日志文件总大小:   {format_size(total_stats['total_size'])}")
    logger.info(f"\n待压缩文件: {len(all_to_compress)} 个")
    logger.info(f"待删除文件: {len(all_to_delete)} 个")
    
    compress_space = sum(f['size'] for f in all_to_compress)
    delete_space = sum(f['size'] for f in all_to_delete)
    logger.info(f"\n预计可释放空间:")
    logger.info(f"  压缩后节省:   {format_size(int(compress_space * 0.8)):>15}")
    logger.info(f"  删除回收:     {format_size(delete_space):>15}")
    logger.info(f"  合计约:       {format_size(int(compress_space * 0.8) + delete_space):>15}")
    
    logger.info("\n" + "-" * 50)
    logger.info("📦 压缩大日志文件...")
    compressed_count = 0
    for item in all_to_compress[:20]:
        if compress_file(item['path'], logger):
            compressed_count += 1
    
    logger.info(f"本次压缩: {compressed_count} 个文件")
    
    logger.info("\n" + "-" * 50)
    logger.info("🗑️  删除过期日志文件...")
    deleted_count = 0
    deleted_size = 0
    for item in all_to_delete:
        size = delete_file(item['path'], logger, item['reason'])
        if size > 0:
            deleted_count += 1
            deleted_size += size
    
    logger.info(f"本次删除: {deleted_count} 个文件, "
                f"回收 {format_size(deleted_size)}")
    
    logger.info("\n" + "-" * 50)
    check_and_warn_low_space(logger)
    
    logger.info("\n" + "=" * 50)
    logger.info("【日志自动清理】任务完成")
    logger.info(f"完成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    logger.info("=" * 50)

# ============================================================
# 6. 主函数
# ============================================================

def main():
    logger = setup_logging()
    run_cleanup(logger)

if __name__ == "__main__":
    main()

Crontab配置

# 每周日凌晨2点执行日志清理
0 2 * * 0 /usr/bin/python3 /Users/eitan/automation-projects/scripts/log_cleaner.py >> /Users/eitan/automation-projects/logs/cron_log_cleaner.log 2>&1

# 或者每天凌晨3点执行一次(更频繁)
0 3 * * * /usr/bin/python3 /Users/eitan/automation-projects/scripts/log_cleaner.py >> /Users/eitan/automation-projects/logs/cron_log_cleaner.log 2>&1

运行效果

第一次运行时,建议将DRY_RUN设为True,查看清理报告而不实际删除:

2026-03-29 03:00:01 [INFO] ==================================================
2026-03-29 03:00:01 [INFO] 【日志自动清理】任务开始
2026-03-29 03:00:01 [INFO] 执行模式: DRY-RUN(仅报告,不删除)
2026-03-29 03:00:05 [INFO] 📊 清理分析报告
2026-03-29 03:00:05 [INFO] 扫描日志文件总数: 156
2026-03-29 03:00:05 [INFO] 日志文件总大小:   4.72 GB
2026-03-29 03:00:05 [INFO] 
2026-03-29 03:00:05 [INFO] 待压缩文件: 8 个
2026-03-29 03:00:05 [INFO] 待删除文件: 43 个
2026-03-29 03:00:05 [INFO] 
2026-03-29 03:00:05 [INFO] 预计可释放空间:
2026-03-29 03:00:05 [INFO]   压缩后节省:       1.86 GB
2026-03-29 03:00:05 [INFO]   删除回收:         0.94 GB
2026-03-29 03:00:05 [INFO]   合计约:           2.80 GB

确认无误后,将DRY_RUN改为False,下次执行时就会真正清理了。


七、效率数据对比

让我们用一个清晰的表格来量化自动化带来的价值:

任务类型手动耗时/次自动化耗时/次效率提升年化节省时间年化节省成本(按¥300/小时)
日报生成30分钟0分钟(自动)100%150+小时¥45,000
数据导出20分钟0分钟(自动)100%100小时¥30,000
文件分类15分钟0分钟(自动)100%75小时¥22,500
API数据监控10分钟(每天多次)0分钟(自动)100%50小时¥15,000
日志清理30分钟(每周)0分钟(自动)100%25小时¥7,500
合计95分钟/天0分钟/天100%400+小时/年¥120,000+/年

年化价值计算说明:

  • 按每天节省95分钟(折合1.58小时)计算
  • 每年工作250天:1.58小时 × 250天 = 395小时 ≈ 400小时
  • 按知识工作者平均时薪¥300计算:400小时 × ¥300 = ¥120,000/年

这还只是保守估计。如果你所在的行业薪资水平更高,或者有更多可自动化的重复任务,实际节省会远超这个数字。


八、ROI分析:你的投入产出比

让我们严肃地算一笔账。

8.1 时间成本

根据前面的表格,如果我们实现了文章中提到的四个自动化场景:

  • 每日自动数据报表:每天节省30分钟 = 125小时/年
  • 文件监控自动化:每天节省15分钟 = 62.5小时/年
  • API定时调度:每天节省10分钟 = 41.7小时/年
  • 日志自动清理:每周节省30分钟 = 25小时/年

合计:每年节省约254小时

加上数据导出、报告发送等文中未详细展开但同样重要的自动化任务,保守估计总节省时间超过400小时/年

8.2 金钱价值

按照知识工作者的平均时薪(保守按¥300/小时计算):

  • 400小时 × ¥300/小时 = ¥120,000/年

这意味着,只要你的时薪超过¥150(绝大部分职场人显然都超过了这个数字),这套自动化方案的价值就超过了它的实施成本。

如果你的时薪是¥500(这在大城市并不罕见),年化价值直接跳升到¥200,000

8.3 实施成本

让我们诚实地评估一下这套方案的实施成本:

阶段时间投入说明
环境搭建30分钟安装Python、创建虚拟环境、安装库
学习cron语法30分钟理解* * * * *五个星号
场景一(日报)45分钟复制代码、修改配置、测试运行
场景二(文件监控)30分钟复制代码、配置监控目录
场景三(API调度)45分钟理解API调用逻辑、配置阈值
场景四(日志清理)20分钟复制代码、配置保留策略
总计约3.5小时

3.5小时的一次性投入,换来每年400小时(且持续多年)的回报。

8.4 投资回报率(ROI)

ROI = (年化收益 - 年化成本) / 年化成本 × 100%

这里没有持续的年化成本(工具都是免费的,一次配置永久使用),所以:

ROI = (¥120,000 - ¥1,050) / ¥1,050 × 100% ≈ 11,300%

即使我们把"时间就是金钱"这个概念再保守一点——假设你学习这些技能需要投入1000元的培训费用(实际为零,因为这篇文章是免费的),ROI依然超过10,000%

这个数字在投资世界里几乎是不可想象的 ——但在我们日常工作中,这样的"投资机会"几乎每天都在被忽视。我们花大价钱买各种效率工具,却不愿意花3个小时学习如何自动化那些每天都在吞噬我们时间的机械任务。

8.5 隐性收益

除了可以直接量化的金钱价值,还有几项不太容易量化但同样重要的收益:

  • 精神状态的改善:每天不再被"还有15分钟的日报要整理"这种小事打断心流
  • 错误率的下降:机器做重复性工作比人更稳定,不会因为疲劳而出错
  • 可追溯性:所有自动化操作都有日志可查,出了问题能快速定位
  • 可扩展性:当你的自动化脚本稳定运行后,你可以很方便地添加新的自动化场景

这些隐性收益,可能比那120,000元更有价值。


九、行动清单:从今天开始你的自动化之旅

好了,道理你都懂了。现在最关键的问题是:从哪里开始?

我见过太多人在"准备阶段"就消耗掉了所有动力——他们花一周时间研究最佳实践、对比各种工具、列清单、做计划,但始终没有真正动手写一行代码。

所以我的建议是:现在就开始,先解决一个问题

第一步:选择你的第一个场景(10分钟)

从以下三个最容易上手的场景中选择一个:

优先级场景难度推荐理由
⭐⭐⭐日志清理零风险,DRY-RUN模式随便试
⭐⭐⭐每日数据报表价值最高,每天都能看到成果
⭐⭐文件监控真正"自动化",一劳永逸

第二步:复制代码并运行(20分钟)

选择好场景后,直接复制文章中对应的代码,保存为.py文件。

# 创建脚本目录
mkdir -p ~/automation-projects/scripts

# 用你喜欢的方式创建文件
nano ~/automation-projects/scripts/daily_report.py
# 或者
vim ~/automation-projects/scripts/daily_report.py
# 或者直接把代码粘贴进一个文本编辑器,保存为.py

第三步:修改配置(10分钟)

找到代码中的"配置区"(所有脚本都有明确的配置区标注),填入你自己的信息:

  • 邮件账号和密码(需要邮箱授权码)
  • 文件路径(根据你的实际情况修改)
  • API地址和密钥(如果是API监控场景)

第四步:本地测试(15分钟)

先手动运行一次脚本,确认无误:

cd ~/automation-projects
source venv/bin/activate
python scripts/你的脚本.py

检查输出是否符合预期,日志是否正确生成。

第五步:配置定时任务(5分钟)

# 编辑crontab
crontab -e

# 添加一行,例如每天早上8点50执行
50 8 * * * /path/to/your/venv/bin/python /path/to/your/script.py >> /path/to/logs/cron.log 2>&1

第六步:庆祝并继续(5分钟)

恭喜你完成了第一个自动化脚本!现在去泡杯咖啡,感受一下"明天早上这件事会自动完成"的轻松感。

然后,你可以继续实现下一个场景。

常见问题

Q: 我的电脑是Windows,能用这套方案吗?

A: 可以。Windows上有两个选择:一是安装WSL(Windows Subsystem for Linux),在Linux子系统里使用cron;二是使用Windows自带的"任务计划程序"(Task Scheduler)来替代cron,功能类似。

Q: 脚本出错了怎么办?

A: 每个脚本都有日志记录功能。先查看logs目录下的日志文件,错误信息会详细记录。另外,所有脚本都有try-except包裹,理论上不应该出现脚本直接崩溃的情况(但业务逻辑错误还是会被捕获)。

Q: 我不懂代码,能学会这些吗?

A: 绝对能。这套方案里用到的Python语法已经是最简单的了——基本就是"读取文件、处理数据、写文件"。如果你能看懂Excel公式,你就能看懂这些代码。遇到不懂的地方,善用搜索引擎,ChatGPT也能帮你解释代码含义。

Q: 如何保证脚本长期稳定运行?

A: 几个建议:1)定期查看日志,确保任务在正常执行;2)使用DRY_RUN模式测试新配置;3)关键任务配置邮件告警,任务失败时自动通知你;4)定期备份你的脚本和配置。


十、写在最后

写这篇文章的时候,我一直在想一个词:"杠杆"

我们每天做的那些重复性工作,其实都是在用时间换一点微薄的产出。但自动化不一样——它是一次投入,然后永久回报。我们在文章里算的那些数字(400小时/年、¥120,000/年),本质上都是在计算自动化作为"时间杠杆"的威力。

3-4个小时的学习和实施,换来每年400小时的自由。这是我能想到的,普通人能够触及的、最高性价比的时间杠杆。

当然,我不是在说所有人都应该成为"极客"或者把生活全部自动化。有些事情值得你亲力亲为——比如写一封用心的邮件,比如精心准备一次演讲,比如和家人朋友共度的时光。我倡导的从来不是"用机器替代一切",而是**"把不值得的事情自动化,把时间留给真正重要的事"**。

希望这篇文章能帮你迈出第一步。如果你的第一个自动化脚本成功运行了,欢迎回来和我分享——那一刻的成就感,值得庆祝。

现在,去吧。解放你的时间,去做那些只有你能做的事情。

你比自己想象的更有能力掌控自己的工作方式。 🚀


如果你觉得这篇文章有帮助,欢迎转发给需要的朋友。也欢迎在评论区分享你的自动化实践——我会在后续文章中挑选有代表性的案例进行深入讲解。

关注我,持续更新更多「Python自动化实战」系列文章。


相关阅读推荐: