2026-10-06 · 喵窝资源铺
场景:项目群一天几百条消息,谁答应「周三提交接口文档」「三天内搞定续费」全靠人脑记,散会就忘。这篇给一个纯标准库的 Python 脚本(chat_todo_extract.py):把导出的聊天记录 txt 扫一遍,抓出「明确动作 + 时间词」的句子,把「明天/本周五/10月15日/三天内」解析成绝对日期,输出按截止日排序的 CSV。宁漏不误是设计底线——漏一条待办人工翻记录能找回来,误报十条「待办」这工具就没人用了。文中代码实跑验证,真实输出贴在下面。
输入是一段真实感十足的群聊(有正事、有约饭、有表情包式的寒暄):跑完输出:
发言人,类型,截止日期,时间词,原文 李雷,承诺,2026-10-06,今天,好的,会议纪要我今天下班前回复确认 王经理,承诺,2026-10-07,明天,项目说明书我明天上午整理好发群里 李雷,承诺,2026-10-07,周三,收到,接口文档我周三下班前提交 韩梅梅,承诺,2026-10-07,明天,上周说好的团建照片,我明天发给大家 王经理,承诺,2026-10-09,周五,@赵小刚 客户反馈表你负责汇总,周五之前给我 赵小刚,承诺,2026-10-09,周五,好的,我周五前完成 王经理,承诺,2026-10-09,三天内,续费的事我来处理,三天内搞定 韩梅梅,承诺,2026-10-10,这周内,测试用例我这周内整理完 韩梅梅,承诺,2026-10-10,10月10日,对了,9月报销单必须在10月10日前提交财务,逾期不收 王经理,承诺,2026-10-15,10月15日,麻烦李雷把服务器权限也确认一下,10月15日之前给我答复
被正确过滤掉的 7 行包括:「中午吃什么」「食堂新出的麻辣香锅」「下午茶去不去」这类闲聊,以及「服务器权限没问题」——有确认没截止时间的应答不算待办。运行日志:提取待办 10 条 / 过滤日常闲聊 7 行。
第一版跑完,「阿里云服务器10月20号到期,谁来跟进续费?」进了待办表——有动词(跟进)有时间词(10月20号),看着完全像一条待办。但它是抛出的问题,没有任何人承诺要做什么。
正解:识别待办之前先排问句——句尾是「?」或含「谁来/谁能/有没有人」的直接跳过。等王经理回复「续费的事我来处理,三天内搞定」,那条才是待办,两条消息各司其职。教训:待办的判定单位是「承诺句」,不是「含有动作和时间的句子」。
「三天内搞定」第一版没进表。排查半天发现时间正则写的是(\d+)\s*天——「三」是汉字,\d+匹配不到,正则静默失败,整句落进闲聊。
正解:字符类加上中文数字 [\d一二两三四五六七八九十]+,再写个 10 行的中文数字转换(处理「十」「二十三」)。这类 bug 最阴险的地方是静默:不报错、不崩,只是少提一条,你根本不知道漏了。防它的唯一办法是像上面那样,用一条已知答案的语料当验收测试。
「测试用例我这周内整理完」——「这周内」既不是周几也不是具体日期,时间解析直接返回空,句子因为「有时间词没解析出日期」被丢进了未写明组。
正解:给模糊时段定明确锚点并写进文档让用户知道:「这周内」= 本周五(周五已过则顺延),「月底」= 当月最后一天。锚点是约定而不是事实,所以输出里保留原始时间词列(时间词,这周内),用户一眼能看到脚本做了什么假设,不认同就自己改那一行——工具展示假设,替人做决定但不隐瞒决定。
微信导出的记录里,一条消息换行后,续行是裸文本,没有「发言人:」前缀。第一版按行独立解析,续行里的「明天之前发你」丢了发言人,成了无主待办。
正解:维护「最近发言人」变量——带前缀的行更新它,续行继承它。聊天记录不是天然的逐行结构,解析状态机的状态要跨行传递。
周二说「周五之前给我」,解析成(5 - weekday()) % 7得 3 天后,对。但周日说「周五」,% 7得 0——当天就是周五吗?不是,人指的是接下来那个周五。
正解:delta = (5 - base.weekday()) % 7 or 4——余数为 0 时取 4(下个周期),即「指向未来最近的那个周五,今天不算」。相对日期永远只指未来,这是中文口语的默认语义。同理「10月10日」若已过,脚本推到明年而不是报错(报销单 9 月的截止在 10 月,跨年处理真实存在)。
同一输入连跑两次输出 md5 一致(51d74137… == 51d74137…)。注意:脚本用「运行当天」做相对日期基准,今天跑和明天跑结果不同是特性不是 bug(「明天」本来就跟着日历走),但同一天内重跑必须一致,这样才能放心挂到 cron 里每天自动出新待办表。
python chat_todo_extract.py 聊天记录.txt 待办表.csv
纯标准库,Python 3.8+。支持微信/QQ/飞书导出的 2026-10-06 09:13 王经理: 内容 和 [2026-10-06 09:13] 王经理: 内容 两种常见格式;发言人取冒号前 ≤20 字符的那段。输出 CSV 按截止日排序,「未写明」排最后,原话完整保留——机器解析的日期永远要让人能点回原文核对。
#!/usr/bin/env python3
"""chat_todo_extract.py — 聊天记录批量提取待办+承诺日期
用法: python chat_todo_extract.py 聊天记录.txt 输出.csv
输出: 发言人,类型,截止日期,时间词,原文 (按截止日排序)
原则: 宁漏不误——只抓「明确动作+时间」的承诺句,疑问句/闲聊全过滤
"""
import csv, re, sys, datetime
ACT = re.compile(
r'(我(?:会|来|去|们)?|我们|负责|安排|处理|提交|发送|整理|跟进|确认|回复|交付|完成|发出)'
r'[^。!?\n]*')
DATE_PATTERNS = [
(re.compile(r'(?:下|本|这)?周([一二三四五六日天])'), 'week'),
(re.compile(r'(\d{1,2})月(\d{1,2})[日号]'), 'md'),
(re.compile(r'明天|后天|今天'), 'rel'),
(re.compile(r'(\d{4})-(\d{1,2})-(\d{1,2})'), 'iso'),
(re.compile(r'([\d一二两三四五六七八九十]+)\s*天[之以]?[内外后]'), 'days'),
(re.compile(r'(?:这|本)周内|本?月底|周末前'), 'end'),
]
CN_NUM = {'一':1,'两':2,'二':2,'三':3,'四':4,'五':5,
'六':6,'七':7,'八':8,'九':9,'十':10}
WEEK = {'一':1,'二':2,'三':3,'四':4,'五':5,'六':6,'日':7,'天':7}
def cn_num(t):
if t.isdigit():
return int(t)
if '十' in t:
a, _, b = t.partition('十')
return CN_NUM.get(a, 1) * 10 + (CN_NUM.get(b, 0) if b else 0)
return CN_NUM.get(t, 0)
def parse_date(text, base):
"""返回 (绝对日期iso, 原始时间词)。锚点约定:
这周内=本周五(周五已过顺延下周) / 月底=当月最后一天 / 相对日期只指未来"""
for pat, kind in DATE_PATTERNS:
m = pat.search(text)
if not m:
continue
if kind == 'week':
today = base.weekday() + 1
delta = (WEEK[m.group(1)] - today) % 7 or 7
return (base + datetime.timedelta(days=delta)).isoformat(), m.group(0)
if kind == 'md':
try:
d = datetime.date(base.year, int(m.group(1)), int(m.group(2)))
except ValueError:
return 'INVALID', m.group(0) # 如2月30,保留原词人工看
if d < base:
d = datetime.date(base.year + 1, int(m.group(1)), int(m.group(2)))
return d.isoformat(), m.group(0)
if kind == 'rel':
delta = {'今天': 0, '明天': 1, '后天': 2}[m.group(0)]
return (base + datetime.timedelta(days=delta)).isoformat(), m.group(0)
if kind == 'iso':
return f'{m.group(1)}-{m.group(2).zfill(2)}-{m.group(3).zfill(2)}', m.group(0)
if kind == 'days':
return (base + datetime.timedelta(days=cn_num(m.group(1)))).isoformat(), m.group(0)
if kind == 'end':
w = m.group(0)
if '月底' in w:
nxt = datetime.date(base.year + (base.month == 12), base.month % 12 + 1, 1)
return (nxt - datetime.timedelta(days=1)).isoformat(), w
delta = (4 - base.weekday()) % 7 or 4 # 本周五(周一=0)
return (base + datetime.timedelta(days=delta)).isoformat(), w
return '', ''
def main(path, out):
base = datetime.date.today()
rows, skipped, cur_speaker = [], 0, ''
line_re = re.compile(
r'^(?:\[?(\d{4}-\d{1,2}-\d{1,2})[ ,]+(\d{1,2}:\d{2}(?::\d{2})?)\]?\s*)?([^::\n]{1,20})[::]\s*(.+)$')
for raw in open(path, encoding='utf-8'):
raw = raw.rstrip('\n')
m = line_re.match(raw)
if m:
cur_speaker = m.group(3).strip() # 带前缀的行更新发言人
text = m.group(4)
else:
text = raw # 续行继承最近发言人
if not text.strip():
continue
# 疑问句不是承诺:「谁来跟进续费?」有动词有时间词也跳过
if re.search(r'[??]\s*$', text) or re.search(r'(谁来|谁能|谁负责|有没有人)', text):
skipped += 1
continue
typ = '承诺' if ACT.search(text) else ''
if not typ:
skipped += 1
continue
due, raw_when = parse_date(text, base)
if not due and not re.search(r'周|月|日|号|天|deadline|之前|以前|以内|月底', text):
skipped += 1
continue # 有动作没时间=日常陈述(如「服务器权限没问题」)
rows.append({'发言人': cur_speaker, '类型': typ, '截止日期': due or '未写明',
'时间词': raw_when or '-', '原文': text.strip()[:80]})
rows.sort(key=lambda r: (r['截止日期'] == '未写明', r['截止日期']))
with open(out, 'w', newline='', encoding='utf-8-sig') as f:
w = csv.DictWriter(f, fieldnames=['发言人', '类型', '截止日期', '时间词', '原文'])
w.writeheader(); w.writerows(rows)
print(f'提取待办 {len(rows)} 条 / 过滤日常闲聊 {skipped} 行')
if __name__ == '__main__':
main(sys.argv[1], sys.argv[2])
喵窝资源铺提供代跑脚本服务:把导出的聊天记录 txt 发过来,跑完把待办表(CSV,按截止日排序)发回你,¥5 起,虚拟物品不退款(跑不通不收费)。脚本本身免费自取。
📖 相邻文章:会议纪要自动转待办清单 · 考勤打卡交叉核对异常清单 · 微信群接龙统计成Excel · 两张表人名模糊匹配合并 · 博客目录
提取结果基于句式规则,涉及追责/考核场景请以原文为准(输出已保留原话便于核对)。