322 lines
12 KiB
Python
Executable File
322 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
周报发送脚本(支持 G5/G6)
|
||
功能:
|
||
1. 构造多段邮件(纯文本 + HTML),存入钉邮草稿箱
|
||
2. 自动归档到 weekly-reports/YYYY/YYYY-Www.md
|
||
|
||
用法:
|
||
python3 send_weekly_report.py --project G5 --plain "..." --html "..." --date-range "2026-07-27 ~ 2026-07-31" --archive
|
||
"""
|
||
import email
|
||
from email.mime.multipart import MIMEMultipart
|
||
from email.mime.text import MIMEText
|
||
from email.header import Header
|
||
import imaplib
|
||
import os
|
||
import time
|
||
import sys
|
||
import re
|
||
import argparse
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
|
||
def load_env(path):
|
||
"""读取 .env 文件,返回键值字典(标准库实现,无额外依赖)。"""
|
||
env = {}
|
||
try:
|
||
with open(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('=')
|
||
env[key.strip()] = value.strip().strip('"').strip("'")
|
||
except FileNotFoundError:
|
||
pass
|
||
return env
|
||
|
||
|
||
_ENV = load_env(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
|
||
|
||
IMAP_HOST = 'imap.mxhichina.com'
|
||
IMAP_PORT = 993
|
||
IMAP_USER = _ENV.get('WITSOFT_IMAP_USER', 'yangxuan@witsoft.cn')
|
||
IMAP_PASSWORD = _ENV.get('WITSOFT_IMAP_PASSWORD', '')
|
||
|
||
# MySQL 同步配置(简历 resume 库,用于月度/年度总结)
|
||
MYSQL_HOST = '127.0.0.1'
|
||
MYSQL_PORT = 3306
|
||
MYSQL_USER = _ENV.get('MYSQL_USER', 'root')
|
||
MYSQL_PASSWORD = _ENV.get('MYSQL_PWD', '')
|
||
MYSQL_DB = 'resume'
|
||
MYSQL_ENABLED = bool(MYSQL_PASSWORD)
|
||
|
||
def archive_report(plain_body: str, date_range: str, project: str = "G5"):
|
||
"""将周报归档到 weekly-reports/YYYY/YYYY-Www.md
|
||
|
||
Args:
|
||
plain_body: 纯文本正文
|
||
date_range: 日期范围字符串,如 "2026-07-27 ~ 2026-07-31"
|
||
project: 项目名称
|
||
|
||
Returns:
|
||
归档文件路径
|
||
"""
|
||
# 解析日期范围
|
||
match = re.match(r'(\d{4}-\d{2}-\d{2})\s*~\s*(\d{4}-\d{2}-\d{2})', date_range)
|
||
if not match:
|
||
print(f"⚠️ 日期格式错误,跳过归档:{date_range}")
|
||
return None
|
||
|
||
start_date = datetime.strptime(match.group(1), '%Y-%m-%d')
|
||
end_date = datetime.strptime(match.group(2), '%Y-%m-%d')
|
||
|
||
# 计算周数
|
||
week_number = start_date.isocalendar()[1]
|
||
year = start_date.year
|
||
|
||
# 创建年份目录
|
||
reports_dir = Path(__file__).parent / 'weekly-reports' / str(year)
|
||
reports_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 生成文件名
|
||
filename = f"{year}-W{week_number:02d}-周报.md"
|
||
filepath = reports_dir / filename
|
||
|
||
# 生成归档内容
|
||
archive_content = f"""# {project}开发周报 ({year}-W{week_number:02d})
|
||
|
||
**日期范围:** {date_range}
|
||
|
||
## 基本信息
|
||
{plain_body.split('本周工作内容')[0].strip()}
|
||
|
||
## 本周工作内容
|
||
|
||
{plain_body.split('本周工作内容')[1].split('存在问题')[0].strip()}
|
||
|
||
## 存在问题
|
||
|
||
{plain_body.split('存在问题')[1].strip() if '存在问题' in plain_body else '无'}
|
||
|
||
---
|
||
*归档时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}*
|
||
*原始邮件主题:{project}开发周报 ({date_range})*
|
||
"""
|
||
|
||
# 写入文件
|
||
with open(filepath, 'w', encoding='utf-8') as f:
|
||
f.write(archive_content)
|
||
|
||
print(f"✅ 周报已归档:{filepath}")
|
||
return str(filepath)
|
||
|
||
|
||
def _parse_plain_for_db(plain_body: str):
|
||
"""从纯文本正文解析出项目、主要任务、每日明细、问题,供 MySQL 写入。
|
||
返回 dict:{project, main_task, days:[(date, weekday, items)], problems:[]}
|
||
"""
|
||
project = "G5"
|
||
main_task = ""
|
||
days = []
|
||
problems = []
|
||
cur = None
|
||
in_prob = False
|
||
weekday_map = {"周一": 1, "周二": 2, "周三": 3, "周四": 4, "周五": 5, "周六": 6, "周日": 7}
|
||
for raw in plain_body.splitlines():
|
||
line = raw.strip()
|
||
if not line:
|
||
continue
|
||
if line.startswith("项目名称"):
|
||
pm = re.sub(r"^[\U0001F4C5]?\s*项目名称[::]?\s*", "", line).strip()
|
||
if "G6" in pm:
|
||
project = "G6"
|
||
elif "G5" in pm:
|
||
project = "G5"
|
||
continue
|
||
if line.startswith("主要任务"):
|
||
main_task = re.sub(r"^[\U0001F4C5]?\s*主要任务[::]?\s*", "", line).strip()
|
||
continue
|
||
if "存在问题" in line:
|
||
in_prob = True
|
||
cur = None
|
||
continue
|
||
if re.match(r"^(下周计划|下周计划:|备注[::]|说明[::])", line):
|
||
in_prob = False
|
||
cur = None
|
||
continue
|
||
low = line.lower()
|
||
if any(s in low for s in ("best regards", "杨轩", "地址", "手机", "邮箱", "yangxuan@")):
|
||
break
|
||
dm = re.match(r"^[\U0001F4C5\U0001F539\u2022]?\s*(周一|周二|周三|周四|周五|周六|周日)[((]\s*(\d{4})-(\d{2})-(\d{2})\s*[))]", line)
|
||
if not dm:
|
||
dm = re.match(r"^[\U0001F4C5\U0001F539\u2022]?\s*(周一|周二|周三|周四|周五|周六|周日)[((]\s*(\d{2})-(\d{2})\s*[))]", line)
|
||
if dm:
|
||
wd = dm.group(1)
|
||
if len(dm.groups()) == 4:
|
||
y, mm, dd = dm.group(2), dm.group(3), dm.group(4)
|
||
else:
|
||
y, mm, dd = "2026", dm.group(2), dm.group(3)
|
||
cur = {"date": f"{y}-{mm}-{dd}", "weekday": weekday_map.get(wd, 0), "items": []}
|
||
days.append(cur)
|
||
in_prob = False
|
||
continue
|
||
if in_prob:
|
||
if line not in ("无", "暂无", "None", ""):
|
||
problems.append(re.sub(r"^[-*\u00B7\u25CF\u25C6]\s*", "", line).strip())
|
||
elif cur:
|
||
item = re.sub(r"^[-*\u00B7\u25CF\u25C6]\s*", "", line).strip()
|
||
if item:
|
||
cur["items"].append(item)
|
||
return {"project": project, "main_task": main_task, "days": days, "problems": problems}
|
||
|
||
|
||
def sync_to_mysql(plain_body: str, date_range: str, project: str = "G5"):
|
||
"""将周报数据同步到 MySQL resume 库(用于月度/年度总结、OKR 分析)。
|
||
无密码或连接失败时静默跳过,不影响发信与本地归档。
|
||
"""
|
||
if not MYSQL_ENABLED:
|
||
print("⚠️ MYSQL_PWD 未配置,跳过 MySQL 同步")
|
||
return
|
||
try:
|
||
import pymysql
|
||
except ImportError:
|
||
print("⚠️ 未安装 pymysql,跳过 MySQL 同步(pip install pymysql)")
|
||
return
|
||
|
||
data = _parse_plain_for_db(plain_body)
|
||
real_proj = data["project"] or project
|
||
m = re.match(r"(\d{4}-\d{2}-\d{2})\s*~\s*(\d{4}-\d{2}-\d{2})", date_range)
|
||
if not m:
|
||
print("⚠️ 日期格式错误,跳过 MySQL 同步")
|
||
return
|
||
start_s, end_s = m.group(1), m.group(2)
|
||
start_d = datetime.strptime(start_s, "%Y-%m-%d")
|
||
week_number = start_d.isocalendar()[1]
|
||
year = start_d.year
|
||
|
||
try:
|
||
conn = pymysql.connect(host=MYSQL_HOST, port=MYSQL_PORT, user=MYSQL_USER,
|
||
password=MYSQL_PASSWORD, database=MYSQL_DB, charset="utf8mb4")
|
||
cur = conn.cursor()
|
||
# 清同键旧数据(含级联)
|
||
cur.execute("SELECT id FROM weekly_reports WHERE year=%s AND week_number=%s AND project=%s",
|
||
(year, week_number, real_proj))
|
||
rows = cur.fetchall()
|
||
for (rid,) in rows:
|
||
cur.execute("DELETE FROM weekly_report_daily WHERE report_id=%s", (rid,))
|
||
cur.execute("DELETE FROM weekly_report_problems WHERE report_id=%s", (rid,))
|
||
cur.execute("DELETE FROM weekly_reports WHERE id=%s", (rid,))
|
||
# 插入主表
|
||
cur.execute(
|
||
"INSERT INTO weekly_reports (year, week_number, start_date, end_date, project, main_task, has_problems) "
|
||
"VALUES (%s,%s,%s,%s,%s,%s,%s)",
|
||
(year, week_number, start_s, end_s, real_proj, data["main_task"], 1 if data["problems"] else 0))
|
||
new_id = cur.lastrowid
|
||
for d in data["days"]:
|
||
cur.execute(
|
||
"INSERT INTO weekly_report_daily (report_id, day_of_week, work_date, work_items) VALUES (%s,%s,%s,%s)",
|
||
(new_id, d["weekday"], d["date"], "\n".join(d["items"])))
|
||
for p in data["problems"]:
|
||
cur.execute(
|
||
"INSERT INTO weekly_report_problems (report_id, problem_description) VALUES (%s,%s)",
|
||
(new_id, p))
|
||
conn.commit()
|
||
cur.close()
|
||
conn.close()
|
||
print(f"✅ 周报已同步 MySQL resume 库:{real_proj} W{week_number:02d}({data['main_task'][:20] or '无任务'})")
|
||
except Exception as e:
|
||
print(f"⚠️ MySQL 同步失败(不影响发信):{e}")
|
||
|
||
|
||
def send_to_drafts(plain_body: str, html_body: str, date_range: str, project: str = "G5", archive: bool = True, sync_mysql: bool = True):
|
||
"""构造多段邮件并存入钉邮草稿箱,同时可选归档到本地
|
||
|
||
Args:
|
||
plain_body: 纯文本正文
|
||
html_body: HTML 正文
|
||
date_range: 日期范围字符串
|
||
project: 项目名称,默认"G5"
|
||
archive: 是否同时归档到本地,默认 True
|
||
sync_mysql: 是否同步到 MySQL resume 库,默认 True
|
||
|
||
Returns:
|
||
IMAP append 结果
|
||
"""
|
||
# 归档到本地
|
||
if archive:
|
||
archive_report(plain_body, date_range, project)
|
||
|
||
# 同步 MySQL(月度/年度总结数据源)
|
||
if sync_mysql:
|
||
sync_to_mysql(plain_body, date_range, project)
|
||
|
||
msg = MIMEMultipart('related')
|
||
msg['From'] = Header('杨轩', 'utf-8').encode() + ' <yangxuan@witsoft.cn>'
|
||
msg['To'] = Header('刘强', 'utf-8').encode() + ' <liuqiang@witsoft.cn>'
|
||
msg['Cc'] = (
|
||
Header('陈明', 'utf-8').encode() + ' <chenm@witsoft.cn>, '
|
||
+ Header('曾莉', 'utf-8').encode() + ' <zengli@witsoft.cn>, '
|
||
+ Header('袁晴', 'utf-8').encode() + ' <yuanq@witsoft.cn>'
|
||
)
|
||
msg['Subject'] = Header(f'{project}开发周报 ({date_range})', 'utf-8').encode()
|
||
|
||
alt = MIMEMultipart('alternative')
|
||
alt.attach(MIMEText(plain_body, 'plain', 'utf-8'))
|
||
alt.attach(MIMEText(html_body, 'html', 'utf-8'))
|
||
msg.attach(alt)
|
||
|
||
raw_bytes = msg.as_bytes()
|
||
|
||
conn = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
|
||
conn.login(IMAP_USER, IMAP_PASSWORD)
|
||
|
||
drafts = '"&g0l6Pw-"'
|
||
result = conn.append(drafts, None, imaplib.Time2Internaldate(time.time()), raw_bytes)
|
||
conn.logout()
|
||
return result
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description='周报发送脚本(支持 G5/G6)')
|
||
parser.add_argument('--project', type=str, default='G5', help='项目名称,默认 G5')
|
||
parser.add_argument('--plain', type=str, required=True, help='纯文本正文')
|
||
parser.add_argument('--html', type=str, required=True, help='HTML 正文')
|
||
parser.add_argument('--date-range', type=str, required=True, help='日期范围,如 "2026-07-27 ~ 2026-07-31"')
|
||
parser.add_argument('--no-archive', action='store_true', help='不归档到本地')
|
||
parser.add_argument('--no-mysql', action='store_true', help='不同步到 MySQL resume 库')
|
||
|
||
args = parser.parse_args()
|
||
|
||
result = send_to_drafts(
|
||
plain_body=args.plain,
|
||
html_body=args.html,
|
||
date_range=args.date_range,
|
||
project=args.project,
|
||
archive=not args.no_archive,
|
||
sync_mysql=not args.no_mysql
|
||
)
|
||
print(f"邮件存入草稿箱结果:{result}")
|
||
|
||
|
||
if __name__ == '__main__':
|
||
# 命令行模式
|
||
if len(sys.argv) > 1:
|
||
main()
|
||
else:
|
||
# 测试模式
|
||
plain = """项目名称: 维云智造 G5
|
||
主要任务: 测试
|
||
|
||
本周工作内容
|
||
周一(2026-07-27)
|
||
- 测试内容 1
|
||
- 测试内容 2
|
||
|
||
存在问题
|
||
无"""
|
||
html = "<p>test</p>"
|
||
result = send_to_drafts(plain, html, "2026-07-27 ~ 2026-07-31", "G5")
|
||
print(result)
|