Created
May 15, 2026 06:34
-
-
Save Kishlay-notabot/e608cf98ce017ba58fab08a6e7944071 to your computer and use it in GitHub Desktop.
Merge WhatsApp 'Message Yourself' exports into one sorted, deduplicated txt file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| ``` | |
| #!/usr/bin/env python3 | |
| """ | |
| Merge WhatsApp 'Message Yourself' exports into one sorted, deduplicated .txt. | |
| Usage: | |
| python merge_whatsapp.py <folder> [output.txt] | |
| - Reads every .txt in <folder>. | |
| - Parses messages (multi-line messages preserved as one entry). | |
| - Deduplicates messages by exact text — duplicate files and overlapping date | |
| ranges across exports both collapse automatically. | |
| - Sorts oldest -> newest. | |
| - Writes to output.txt (default: <folder>/merged.txt). | |
| """ | |
| import re | |
| import sys | |
| from datetime import datetime | |
| from pathlib import Path | |
| # Header of a new message: "20/01/2026, 6:30 pm - " | |
| # Tolerates invisible unicode chars (LRM/RLM/BOM) WhatsApp sometimes inserts, | |
| # and the narrow-no-break-space WhatsApp uses around am/pm (matched by \s). | |
| MSG_START = re.compile( | |
| r'^[\u200e\u200f\ufeff]*' | |
| r'(\d{1,2}/\d{1,2}/\d{4}),\s+' # date | |
| r'(\d{1,2}:\d{2})\s*' # time | |
| r'(am|pm|AM|PM)\s+-\s', # period + " - " | |
| ) | |
| def parse_timestamp(date: str, time: str, ampm: str) -> datetime: | |
| return datetime.strptime(f"{date} {time} {ampm.upper()}", "%d/%m/%Y %I:%M %p") | |
| def parse_messages(path: Path): | |
| """Return list of (timestamp, full_text) for every message in a chat export.""" | |
| out = [] | |
| buf_ts, buf_lines = None, [] | |
| with open(path, encoding='utf-8', errors='replace') as f: | |
| for line in f: | |
| line = line.rstrip('\n') | |
| m = MSG_START.match(line) | |
| if m: | |
| if buf_ts is not None: | |
| out.append((buf_ts, '\n'.join(buf_lines))) | |
| buf_ts = parse_timestamp(m.group(1), m.group(2), m.group(3)) | |
| buf_lines = [line] | |
| elif buf_ts is not None: | |
| # continuation of a multi-line message | |
| buf_lines.append(line) | |
| # lines before the first message (encryption notice) are skipped | |
| if buf_ts is not None: | |
| out.append((buf_ts, '\n'.join(buf_lines))) | |
| return out | |
| def main(): | |
| if len(sys.argv) < 2: | |
| print(__doc__) | |
| sys.exit(1) | |
| folder = Path(sys.argv[1]) | |
| output = Path(sys.argv[2]) if len(sys.argv) > 2 else folder / 'merged.txt' | |
| txts = sorted(p for p in folder.glob('*.txt') if p.resolve() != output.resolve()) | |
| if not txts: | |
| print(f"no .txt files found in {folder}") | |
| sys.exit(1) | |
| # 1. parse every file | |
| all_msgs = [] | |
| for p in txts: | |
| msgs = parse_messages(p) | |
| print(f" {p.name:40s} {len(msgs):>6} messages") | |
| all_msgs.extend(msgs) | |
| # 2. dedupe by exact message text | |
| seen, deduped = set(), [] | |
| for ts, text in all_msgs: | |
| if text not in seen: | |
| seen.add(text) | |
| deduped.append((ts, text)) | |
| # 3. sort oldest -> newest (stable) | |
| deduped.sort(key=lambda x: x[0]) | |
| # 4. write | |
| with open(output, 'w', encoding='utf-8') as f: | |
| for _, text in deduped: | |
| f.write(text + '\n') | |
| print(f"\n{len(all_msgs)} parsed -> {len(deduped)} unique -> {output}") | |
| if __name__ == '__main__': | |
| main() | |
| ``` |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment