从需求到上线,完整记录
那是多年前的一个夏天,我刚学完Python基础,学了点flask入门,觉得自己行了现在回想起来,那会儿真的是“无知者无畏”正好我工作中有个痛点——每天要手动整理一堆Excel报表发给领导,就想着能不能用Python写个自动化脚本这下可好,一脚踩进去,整整两周没睡好觉但也就是这个看起来特别土的需求,帮我完成了人生第一个“完整项目”今天把这段经历捞出来聊聊,顺便复盘一下当年踩的那些坑
问题是怎么出现的
我在一家小公司做运营支持,日常工作之一就是每天早上把昨天的销售数据从系统里导出来,处理一下,然后生成三份不同的Excel报表,发给三个不同的部门负责人
听起来不难,但操作起来极其折磨人系统导出来的是一份很宽的原始数据表,我需要:
- 1. 用VLOOKUP匹配部门名称(很多ID对不上)
每天早上光这套操作就得搞一个多小时,而且极易出错有几次我把A部门的数据发给了B部门的负责人,被领导在群里点名批评,那个尴尬啊
我就想,能不能写个Python脚本让它自动跑这样我早上只需要运行一下脚本,然后去倒杯咖啡,数据就自动处理完发出去
现在看来,这个需求简单到不能再简单,但当时我连项目结构都不懂,代码都是直接写在一个.py文件里,乱七八糟
环境背景
说一下当时的开发环境,避免有人说我环境特殊:
- • Python版本:3.7.4(后来升级到了3.8)
- • 主要依赖:pandas、openpyxl、smtplib、email(后面两个是发邮件用的)
- • 数据来源:系统每天凌晨自动生成一个CSV文件,存放在共享目录里
当时我连虚拟环境都没概念,直接在系统全局pip安装的包,后来吃了大亏,这个后面会讲到
中间踩了哪些坑
第一个坑:CSV读取直接爆炸
我自信满满地写下了第一行代码:
import pandas as pddf = pd.read_csv('daily_sales.csv')print(df.head())
运行,FileNotFoundError: File b'daily_sales.csv' does not exist
哦,文件名不对系统生成的文件名带日期,比如20190726_sales.csv我需要每天手动改代码里的文件名这不又回到解放前了吗
我当时的解决方案特别土——用glob模块去找当天最新的文件:
import globimport osfiles = glob.glob('*_sales.csv')latest_file = max(files, key=os.path.getmtime)print(f"找到文件: {latest_file}")
这个方案居然真用了一阵子但后来发现一个问题:如果我前一天没运行脚本,第二天早上会有两个文件,脚本会选最新的那个,不一定是我要的那个日期埋了个雷
第二个坑:VLOOKUP怎么写都不会
原始数据里只有department_id,没有部门名称我需要用一张映射表把ID转成名称这东西在Excel里就是一个VLOOKUP的事,在pandas里是merge
但是映射表在哪里最开始我根本不知道映射表的存在,问了IT部门才知道他们维护着一张dept_mapping.xlsx
我第一次写merge是这样的:
mapping = pd.read_excel('dept_mapping.xlsx')df = pd.merge(df, mapping, on='department_id', how='left')
运行,报错:KeyError: 'department_id'
我把原始CSV的列名打出来一看,好家伙,系统给的列名是dept_id,不是department_id而且还有空格,有的地方是 dept_id,有的地方是dept_id我当场就裂开了
这是第一次体会到数据清洗的痛什么高大上的算法,什么炫酷的可视化,在真实数据面前都是弟弟真实数据就是各种脏、各种不规范、各种想骂人
最终我是这么处理的:
# 先把列名左右的空格都去掉df.columns = df.columns.str.strip()# 统一列名df = df.rename(columns={'dept_id': 'department_id'})# 再做mergemapping = pd.read_excel('dept_mapping.xlsx')df = pd.merge(df, mapping, on='department_id', how='left')
第三个坑:邮件发不出去
数据处理完了,接下来是发邮件我用Python的标准库smtplib和email写的,代码大概是这样的:
import smtplibfrom email.mime.text import MIMETextfrom email.mime.multipart import MIMEMultipartfrom email.mime.base import MIMEBasefrom email import encoderssender = 'myemail@company.com'password = 'mypassword'# 没错,我直接把密码写死在代码里了msg = MIMEMultipart()msg['From'] = sendermsg['To'] = 'boss@company.com'msg['Subject'] = '今日销售报表'body = '请查收今日销售数据。'msg.attach(MIMEText(body, 'plain'))# 添加附件withopen('report.xlsx', 'rb') as f: part = MIMEBase('application', 'octet-stream') part.set_payload(f.read()) encoders.encode_base64(part) part.add_header('Content-Disposition', 'attachment', filename='report.xlsx') msg.attach(part)server = smtplib.SMTP('smtp.company.com', 587)server.starttls()server.login(sender, password)text = msg.as_string()server.sendmail(sender, 'boss@company.com', text)server.quit()
跑一遍,报错:SMTPAuthenticationError: 535, b'5.7.8 Username and Password not accepted'
我以为密码写错了,输了好几次都一样后来查资料才知道,公司邮箱用的是授权码而不是登录密码需要在邮箱设置里开启SMTP,然后获取一个专门的授权码
而且更离谱的是,我把密码直接写死在代码里,后来这个脚本要部署到服务器上跑,服务器上登录的是另一个邮箱,我又得改代码这是一个非常糟糕的设计习惯,后面会提到
第四个坑:脚本在服务器上跑不起来
本地调通之后,我把脚本拷贝到公司的一台Linux服务器上(用的树莓派,就是下面这货),设置了crontab定时任务,每天早上7点自动跑
结果第二天早上一看,脚本根本没跑登录服务器手动跑,报了一堆错:
ModuleNotFoundError: No module named 'pandas'
哦对了,我之前说过,我直接在全局pip安装的依赖但服务器上那个Python环境是运维配的,根本没有这些包
我当时的解决方案也特别愣——直接在服务器上pip install pandas openpyxl,把系统环境装得乱七八糟后来又有一次升级Python,系统自带的pip和手动装的包全乱了,脚本又跑不起来了欲哭无泪
这就是我不重视虚拟环境的代价
最后怎么解决的
经过两周的修修补补,脚本终于能跑了虽然代码写得很难看,但确实能把我从每天一个多小时的手工操作里解放出来让我把最终版本的代码展示一下,同时解释每个部分在做什么
完整代码(重构后版本)
#!/usr/bin/env python3"""日报自动生成脚本每天自动读取销售数据,生成三份部门报表并发送邮件"""import osimport globimport smtplibfrom email.mime.text import MIMETextfrom email.mime.multipart import MIMEMultipartfrom email.mime.base import MIMEBasefrom email import encodersfrom datetime import datetime, timedeltaimport pandas as pd# ========== 配置区 ==========CONFIG = {'data_dir': '/data/sales', # 原始数据目录'output_dir': '/data/reports', # 报表输出目录'mapping_file': 'dept_mapping.xlsx', # 部门映射表'sender': 'auto_report@company.com','smtp_server': 'smtp.company.com','smtp_port': 587,'recipients': { # 收件人配置'sales': ['sales_manager@company.com'],'ops': ['ops_manager@company.com'],'finance': ['finance_manager@company.com'] }}defget_today_file():"""找到今天的销售数据文件""" yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y%m%d') pattern = os.path.join(CONFIG['data_dir'], f'{yesterday}_sales.csv') files = glob.glob(pattern)ifnot files:raise FileNotFoundError(f"未找到数据文件: {pattern}")return files[0]defload_and_clean_data(csv_file):"""加载并清洗数据""" df = pd.read_csv(csv_file)# 清洗列名:去除首尾空格 df.columns = df.columns.str.strip()# 列名标准化 column_map = {'dept_id': 'department_id','order_amt': 'sales_amount','order_cnt': 'order_count' } df = df.rename(columns=column_map)# 加载部门映射表并合并 mapping_path = os.path.join(CONFIG['data_dir'], CONFIG['mapping_file']) mapping = pd.read_excel(mapping_path) df = pd.merge(df, mapping, on='department_id', how='left')return dfdefgenerate_reports(df):"""生成各部门报表""" os.makedirs(CONFIG['output_dir'], exist_ok=True) reports = {}for dept, recipients in CONFIG['recipients'].items():# 按部门筛选数据 dept_df = df[df['department_name'] == dept].copy()# 计算关键指标 summary = {'部门': dept,'订单数': dept_df['order_count'].sum(),'销售额': dept_df['sales_amount'].sum(),'转化率': (dept_df['order_count'].sum() / dept_df['user_visit'].sum() * 100) if dept_df['user_visit'].sum() > 0else0 }# 保存报表 output_file = os.path.join( CONFIG['output_dir'], f"report_{dept}_{datetime.now().strftime('%Y%m%d')}.xlsx" ) dept_df.to_excel(output_file, index=False) reports[dept] = {'file': output_file, 'summary': summary, 'recipients': recipients}return reportsdefsend_email(recipient, subject, body, attachment_path=None):"""发送邮件""" msg = MIMEMultipart() msg['From'] = CONFIG['sender'] msg['To'] = recipient msg['Subject'] = subject msg.attach(MIMEText(body, 'html')) # 改成HTML格式,可以加粗显示# 添加附件if attachment_path and os.path.exists(attachment_path):withopen(attachment_path, 'rb') as f: part = MIMEBase('application', 'octet-stream') part.set_payload(f.read()) encoders.encode_base64(part) part.add_header('Content-Disposition', 'attachment', filename=os.path.basename(attachment_path)) msg.attach(part)# 读取邮箱密码(从环境变量,更安全) smtp_password = os.environ.get('SMTP_PASSWORD')ifnot smtp_password:raise ValueError("未设置SMTP_PASSWORD环境变量") server = smtplib.SMTP(CONFIG['smtp_server'], CONFIG['smtp_port']) server.starttls() server.login(CONFIG['sender'], smtp_password) server.send_message(msg) server.quit()defmain():print(f"[{datetime.now()}] 开始生成日报...")# 1. 获取数据文件 csv_file = get_today_file()print(f"读取数据: {csv_file}")# 2. 数据清洗与处理 df = load_and_clean_data(csv_file)print(f"共 {len(df)} 条记录")# 3. 生成报表 reports = generate_reports(df)# 4. 发送邮件for dept, info in reports.items(): summary = info['summary']# 生成HTML格式的邮件正文 body = f""" <h3>{dept} 部门日报 - {datetime.now().strftime('%Y-%m-%d')}</h3> <p><b>订单数:</b> {summary['订单数']}</p> <p><b>销售额:</b> ¥{summary['销售额']:,.2f}</p> <p><b>转化率:</b> {summary['转化率']:.2f}%</p> <p>详细数据见附件。</p> """for recipient in info['recipients']: send_email( recipient=recipient, subject=f"[自动] {dept}部门日报 {datetime.now().strftime('%Y%m%d')}", body=body, attachment_path=info['file'] )print(f"已发送邮件给 {recipient}")print(f"[{datetime.now()}] 全部完成!")if __name__ == '__main__': main()
关键代码逐行解释
配置抽离出来:最早我的配置都是写死在代码里的,改个收件人要从代码里翻后来学乖了,所有配置做成CONFIG字典,放到文件最上面,改配置一目了然
环境变量读密码:smtp_password = os.environ.get('SMTP_PASSWORD'),这是被之前的教训逼出来的密码绝对不能写死在代码里,不然传出去就是安全事故
数据清洗逻辑:df.columns.str.strip()和rename这两个操作看着简单,但真实项目中90%的时间都花在这上面数据质量直接决定后面一切分析的准确性
配置文件结构:现在的代码至少有main()入口,有明确的函数分工,不再是一坨到底虽然还是很初级,但比最开始那个daily.py里800行堆在一起已经好太多了
跑通的效果
$ python daily_report.py[2019-08-10 07:00:01] 开始生成日报...读取数据: /data/sales/20190809_sales.csv共 1523 条记录已发送邮件给 sales_manager@company.com已发送邮件给 ops_manager@company.com已发送邮件给 finance_manager@company.com[2019-08-10 07:00:02] 全部完成!
每天早上我到公司,邮箱里已经躺着三封报表邮件了点开看,格式清晰,数据准确那一瞬间真的很有成就感
这件事带来的思考
为什么之前的方案不行
回头看,核心问题就两个:一是没考虑扩展性,二是没重视环境管理
配置写死在代码里 → 换个收件人就得改代码 → 越来越不敢改不重视虚拟环境 → 换个机器就跑不起来 → 越来越依赖本地不做错误处理 → 文件没找到程序直接崩溃 → 每天早上提心吊胆
这些问题在做一个Demo的时候根本暴露不出来,只有真正投入生产环境、天天跑、别人也在用的时候,才会一层层暴露
经验教训
第一,虚拟环境是刚需后来我所有项目都坚持用venv或conda,一个项目一个环境,干净利落具体的做法是:
# 创建虚拟环境python -m venv venv# 激活(Windows)venv\Scripts\activate# 激活(Linux/Mac)source venv/bin/activate# 安装依赖pip install pandas openpyxl# 导出依赖清单pip freeze > requirements.txt
这样在任何机器上跑,只需要pip install -r requirements.txt就能还原环境
第二,密码必须走环境变量这是最基本的安全意识,我现在所有涉及账号密码的项目,配置一律从环境变量读取,绝不写死在代码里
第三,数据清洗的时间远比分析的时间长在学校里做练习题,数据都是干净的、现成的、格式标准的工作中90%的时间是在清洗数据,只有10%的时间在真正做分析这个比例可能在很多数据相关岗位都一样
第四,代码是给人看的这是我后来才理解的——代码不只是跑通就行,要考虑维护的人(很可能就是未来的自己)能不能看懂函数要命名清晰,逻辑要分层,配置要抽离出来
类似的坑怎么避免
如果你是第一次独立做一个完整项目,我的建议是:
- 1. 先跑通,再优化。不要想着一开始就写出完美的代码。先让功能跑起来,哪怕代码写得再烂都没关系,然后再一点点重构。
- 2. 重视错误处理。文件不存在怎么办?网络超时怎么办?数据为空怎么办?这些边界情况在Demo里不会遇到,但生产环境里一定会遇到。
- 3. 做好日志。
print是最低级的调试方式,但也是最实用的。关键节点打上日志,出了问题才能定位。我现在习惯在每个函数入口和出口都打一句日志,虽然不优雅,但真的救过我的命。 - 4. 保持简单。不要一开始就用什么设计模式、什么架构,能用简单方式解决就用简单的。代码是迭代出来的,不是设计出来的。
现在这个脚本还在跑,只是已经从树莓派迁到了云服务器,代码也重构过好几轮了但每次看到它自动发出邮件,我还是会想起那个手忙脚乱的夏天——改一行代码就运行一次,改一行代码就运行一次,踩了一个又一个现在看来极其低级的坑
但也正是这些坑,一点点把我从“会写Python”变成了“能做事”做一个东西出来,永远是最好的学习方式你岸上看一百遍游泳教程,不如直接跳进水里扑腾几下放心呛几口水,没事,反而学得最快~