聊天记录里答应的事总被忘?Python 批量提取「待办+承诺日期」实测

2026-10-06 · 喵窝资源铺

场景:项目群一天几百条消息,谁答应「周三提交接口文档」「三天内搞定续费」全靠人脑记,散会就忘。这篇给一个纯标准库的 Python 脚本(chat_todo_extract.py):把导出的聊天记录 txt 扫一遍,抓出「明确动作 + 时间词」的句子,把「明天/本周五/10月15日/三天内」解析成绝对日期,输出按截止日排序的 CSV。宁漏不误是设计底线——漏一条待办人工翻记录能找回来,误报十条「待办」这工具就没人用了。文中代码实跑验证,真实输出贴在下面。

实跑:17 行群聊 → 10 条待办 + 7 行闲聊正确过滤

输入是一段真实感十足的群聊(有正事、有约饭、有表情包式的寒暄):跑完输出:

发言人,类型,截止日期,时间词,原文
李雷,承诺,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+ 匹配不到中文数字,「三天内」静默漏掉

「三天内搞定」第一版没进表。排查半天发现时间正则写的是 (\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 · 两张表人名模糊匹配合并 · 博客目录

提取结果基于句式规则,涉及追责/考核场景请以原文为准(输出已保留原话便于核对)。