From 39edf96c642612009cb2f667a0c159ca8f19e78f Mon Sep 17 00:00:00 2001 From: Ammar Hassan Date: Fri, 7 Aug 2026 08:23:35 +0500 Subject: [PATCH 1/8] Add conversations, sleep schedules and a guess-who quiz --- analyze_chat.py | 253 +++++++++++++++++++++++++++++++++++++- chatstats.py | 80 ++++++++++++ tests/test_chatstats.py | 52 ++++++++ tests/test_correctness.py | 69 +++++++++++ 4 files changed, 452 insertions(+), 2 deletions(-) diff --git a/analyze_chat.py b/analyze_chat.py index ea8aa46..4162ac5 100644 --- a/analyze_chat.py +++ b/analyze_chat.py @@ -11,6 +11,7 @@ import io import json import math +import random import re import sys import textwrap @@ -40,6 +41,11 @@ SKIPPABLE = ("jokes", "sentiment", "wordcloud", "topics", "narratives") REPLY_WINDOW_SECONDS = 60 * 60 CONVERSATION_WINDOW_SECONDS = 30 * 60 +QUIZ_QUESTIONS = 20 +QUIZ_MIN_WORDS = 4 +# The longest conversation on a real chat runs to hundreds of messages; enough +# of it to see what the night was about is the point, not the transcript. +LONGEST_SESSION_SHOWN = 15 MESSAGE_FILE_RE = re.compile(r"^message_\d+\.json$") # Vocabulary is counted per message, not per keystroke: one pasted wall of @@ -517,7 +523,11 @@ def _fmt_duration(seconds): return f"{seconds:.0f} s" if seconds < 3600: return f"{seconds / 60:.1f} min" - return f"{seconds / 3600:.1f} h" + if seconds < 86400: + return f"{seconds / 3600:.1f} h" + # Silences between conversations run to hundreds of days, and "9243.9 h" + # is a number nobody can read as "about a year". + return f"{seconds / 86400:.0f} days" def _member_name_words(msgs, extra_names=()): @@ -1124,6 +1134,17 @@ def hourly_radar(msgs): return {member: hours for member, hours in per_member.items()} +def hourly_by_year(msgs): + """The hourly profile again, cut by year. The all-time average is the shape + of nine years stacked on top of each other, which hides the thing worth + seeing: somebody's hours sliding four hours later is a move abroad, and + their 3am tail disappearing is a job.""" + per_member = defaultdict(lambda: defaultdict(lambda: [0] * 24)) + for m in msgs: + per_member[m["sender"]][m["dt"].year][m["dt"].hour] += 1 + return {member: dict(years) for member, years in per_member.items()} + + def word_cloud_data(msgs): overall = Counter() per_member = defaultdict(Counter) @@ -1266,6 +1287,63 @@ def is_question(m): "unanswered_count": total_asked - total_answered} +def quiz_questions(msgs, personality, names=(), count=QUIZ_QUESTIONS, top=10, seed=0): + """"Guess who said this", built out of the signature words already computed. + + A message only qualifies if it carries one of its sender's distinctive + words, so the answer is gettable instead of a coin flip. Messages that name + somebody give the game away, and bots are not people. + + Answers and wrong answers both come from the `top` busiest members rather + than from everybody. A fixed message floor cannot serve both a small export + and a million-message one, and somebody with a handful of messages across + nine years has no voice to recognise — offered as a wrong answer, they give + the round away by being obviously absent. + + Seeded, so rebuilding the report does not silently reshuffle the quiz. + """ + volumes = Counter(m["sender"] for m in msgs) + profiles = personality or {} + ranked = [member for member, _ in volumes.most_common() + if not is_bot(member) and profiles.get(member, {}).get("top_words")][:top] + eligible = {member: set(profiles[member]["top_words"]) for member in ranked} + # Four to choose between, or there is no question to ask. + if len(eligible) < 4: + return [] + + name_words = _member_name_words(msgs, names) + pools = defaultdict(list) + for m in msgs: + signature = eligible.get(m["sender"]) + if not signature or not m["content"]: + continue + tokens = set(_tokens(m)) + if len(tokens) < QUIZ_MIN_WORDS or tokens & name_words: + continue + if tokens & signature: + pools[m["sender"]].append(m) + + rng = random.Random(seed) + for pool in pools.values(): + rng.shuffle(pool) + questions = [] + # Round-robin, so the quiz is not twenty questions about the loudest member. + while len(questions) < count: + turn = [member for member in ranked if pools[member]] + if not turn: + break + for member in turn: + if len(questions) >= count: + break + m = pools[member].pop() + choices = rng.sample([x for x in ranked if x != member], 3) + [member] + rng.shuffle(choices) + questions.append({"content": m["content"], "answer": member, + "choices": choices, + "date": m["dt"].strftime("%Y-%m-%d")}) + return questions + + def topic_words(msgs, top=6, names=()): """Words that characterise each year, scored with tf-idf over years. @@ -1806,6 +1884,49 @@ def _matrix_plot(matrix, name, title): ax.legend(loc="upper right", bbox_to_anchor=(1.3, 1.1), fontsize=8) save(fig, "hourly_radar.png") + # hours by year: one row per member, one column per year + by_year = analyses.get("radar_years") + if by_year: + busiest = [m for m, _ in stats["member_msgs"].most_common(top)] + members = [m for m in busiest if by_year.get(m)] + years = sorted({y for m in members for y in by_year[m]}) + if members and years: + with plt.rc_context(theme): + fig, axes = plt.subplots( + len(members), len(years), squeeze=False, + figsize=(max(4, 1.05 * len(years)), max(2, 0.85 * len(members))), + sharex=True, + # Without the gap and the floor below, neighbouring years run + # together into one strip and you cannot tell them apart. + gridspec_kw={"wspace": 0.25, "hspace": 0.35}) + for r, member in enumerate(members): + # Normalized per member, not globally: a quiet year should + # still show its shape rather than flatten next to a loud one. + peak = max((max(h) for h in by_year[member].values()), default=0) or 1 + for c, year in enumerate(years): + ax = axes[r][c] + hours = by_year[member].get(year) + if hours: + ax.fill_between(range(24), hours, color=PALETTE[r % len(PALETTE)], + linewidth=0) + ax.set_ylim(0, peak) + ax.set_xlim(0, 23) + ax.set_xticks([]) + ax.set_yticks([]) + for name, side in ax.spines.items(): + side.set_visible(name == "bottom") + if name == "bottom": + side.set_color("#c8ccd2") + side.set_linewidth(0.6) + if r == 0: + ax.set_title(str(year), fontsize=7) + if c == 0: + ax.set_ylabel(_shorten(member_label(member), 14), fontsize=7, + rotation=0, ha="right", va="center") + fig.suptitle("When each member posts, year by year (midnight to midnight)", + fontweight="bold", fontsize=11) + save(fig, "hourly_by_year.png") + # word clouds wc_data = analyses.get("wordcloud") if wc_data: @@ -2424,6 +2545,8 @@ def _fig_to_png(fig): th,td{border:1px solid #e3e5e8;padding:6px 10px;text-align:left;font-size:13px} [data-theme="dark"] th,[data-theme="dark"] td{border-color:#2a2f3a} #theme{border:1px solid #e3e5e8;background:#fff;color:#202124;border-radius:6px;padding:4px 10px;cursor:pointer;margin-left:auto} +.quote{border-left:3px solid #e3e5e8;background:#f7f8fa;border-radius:4px;padding:6px 10px;margin:6px 0;font-size:13px} +[data-theme="dark"] .quote{border-left-color:#2a2f3a;background:#20242d} """ @@ -2575,7 +2698,8 @@ def _year_page_html(title, year, recap, analyses, pngs): _NARRATIVE_PAGES = [("report.html", "Report"), ("year_in_review.html", "Years"), ("group_history.html", "Group history"), - ("relationships.html", "Relationships"), ("eras.html", "Eras")] + ("relationships.html", "Relationships"), ("eras.html", "Eras"), + ("sessions.html", "Conversations"), ("quiz.html", "Quiz")] def _narrative_page(title, current, heading, subtitle, body): @@ -2764,6 +2888,117 @@ def _member_page_html(title, profile): f"Active {profile['first']} to {profile['last']}.", body) +def _sessions_html(title, sess): + esc = html_lib.escape + turns = ["Member", "Conversations", "Their messages", "Per 100 of their messages"] + openers = _rows(turns, [(esc(member_label(r["member"])), f"{r['count']:,}", + f"{r['messages']:,}", f"{r['per_100']}") + for r in sess["openers"]]) + closers = _rows(turns, [(esc(member_label(r["member"])), f"{r['count']:,}", + f"{r['messages']:,}", f"{r['per_100']}") + for r in sess["closers"]]) + sizes, durations = sess["sizes"], sess["durations"] + shape = _rows(["", "Half are under", "1 in 10 above", "1 in 100 above", "Biggest"], + [("Messages", f"{sizes['p50']:,}", f"{sizes['p90']:,}", + f"{sizes['p99']:,}", f"{sizes['max']:,}"), + ("Length", _fmt_duration(durations["p50"]), + _fmt_duration(durations["p90"]), _fmt_duration(durations["p99"]), + _fmt_duration(durations["max"]))]) if sizes else "" + + longest = sess["longest"] + big = "" + if longest: + shown = longest["messages"][:LONGEST_SESSION_SHOWN] + lines = "".join( + f"
{esc(member_label(m['sender']))} " + f"{m['dt'].strftime('%H:%M')}
" + f"{esc(_shorten(m['content'] or _media_label(m), 200))}
" + for m in shown) + more = (f"

First {len(shown)} of {longest['count']:,}.

" + if longest["count"] > len(shown) else "") + big = (f"

{longest['count']:,} messages between " + f"{longest['start']:%d %b %Y %H:%M} and {longest['end']:%H:%M}, " + f"{longest['participants']} people talking.

{lines}{more}") + + silences = _rows(["Quiet for", "From", "Until", "Broken by"], + [(_fmt_duration(s["seconds"]), + f"{s['before']['dt']:%d %b %Y}", f"{s['after']['dt']:%d %b %Y}", + f"{esc(member_label(s['after']['sender']))}: " + + esc(_shorten(s["after"]["content"] + or _media_label(s["after"]), 90))) + for s in sess["silences"]]) + + body = "".join([ + _sec("shape", "How long a conversation runs", shape), + _sec("openers", "Who starts conversations", openers), + _sec("closers", "Who has the last word", + "

The last message before everything goes quiet for " + f"{CONVERSATION_WINDOW_SECONDS // 60} minutes.

" + closers), + _sec("longest", "The longest conversation", big), + _sec("silences", "The longest silences", + "

The chat's own dead periods, and the message that " + "ended each one.

" + silences), + ]) + return _narrative_page( + title, "sessions.html", "Conversations", + f"{sess['count']:,} conversations, split wherever nobody spoke for " + f"{CONVERSATION_WINDOW_SECONDS // 60} minutes.", body) + + +def _quiz_html(title, questions): + # `` inside a message would close the tag early and drop the rest + # of the quiz on the floor. + payload = json.dumps(questions, ensure_ascii=False).replace(" +

+ +""" + return _narrative_page(title, "quiz.html", "Guess who said this", + "Every line below uses a word that gives its sender away. " + "Press 1-4 or click.", body) + + def write_narrative_pages(title, analyses, out_dir): """The group's history, its pairs, its eras, and a page per member.""" out_dir.mkdir(parents=True, exist_ok=True) @@ -2780,6 +3015,14 @@ def write_narrative_pages(title, analyses, out_dir): (out_dir / "eras.html").write_text( _eras_html(title, eras, analyses.get("turnover") or {"years": [], "born": {}, "died": {}}), encoding="utf-8") + sess = analyses.get("sessions") + if sess: + (out_dir / "sessions.html").write_text( + _sessions_html(title, sess), encoding="utf-8") + quiz = analyses.get("quiz") + if quiz: + (out_dir / "quiz.html").write_text( + _quiz_html(title, quiz), encoding="utf-8") for member, profile in (analyses.get("member_profiles") or {}).items(): if profile: (out_dir / f"member_{_slug(member)}.html").write_text( @@ -2877,6 +3120,7 @@ def insights(stats, analyses): "reply_matrix.png": "Who replies to whom", "reply_chains.png": "Longest reply chains", "hourly_radar.png": "Hourly activity profiles", + "hourly_by_year.png": "Each member's hours, year by year", "conversation_starters.png": "Who starts conversations", "ghosting.png": "Longest silent streaks", "monologues.png": "Longest solo runs", @@ -3561,6 +3805,7 @@ def process_thread(thread_dir, args): "pace": pace_trends(msgs), "pair_matrices": pair_matrices(msgs), "radar": hourly_radar(msgs), + "radar_years": hourly_by_year(msgs), "monologues": monologues(msgs), "unsent": unsent_stats(msgs), "taken_down": taken_down_stats(msgs), @@ -3588,6 +3833,10 @@ def process_thread(thread_dir, args): analyses["eras"] = chatstats.eras(msgs, names=names) analyses["turnover"] = chatstats.vocabulary_turnover(msgs, names=names) analyses["member_profiles"] = chatstats.member_profiles(msgs, names=names) + analyses["sessions"] = chatstats.sessions(msgs, top=args.top) + # Both render as narrative pages, so they skip together. + analyses["quiz"] = quiz_questions(msgs, analyses["personality"], names=names, + top=args.top) out_dir = Path(args.output) / _slug(title) _progress(args, "charts") diff --git a/chatstats.py b/chatstats.py index dfd7463..af0231f 100644 --- a/chatstats.py +++ b/chatstats.py @@ -298,6 +298,86 @@ def _share(part, whole): return (part / whole) if whole else None +def _percentiles(values): + """p50/p90/p99 and the largest. Session sizes are heavily skewed — a mean + sits between the two-message exchanges and the all-nighters and describes + neither.""" + if not values: + return None + ordered = sorted(values) + + def at(q): + return ordered[min(len(ordered) - 1, int(q * len(ordered)))] + + return {"p50": at(0.50), "p90": at(0.90), "p99": at(0.99), "max": ordered[-1]} + + +def _turn_rates(counts, totals, top): + """Who opens (or closes) conversations most, and how often they do it per + 100 of their own messages. The count alone just ranks by who talks most; + the rate is what separates someone who always speaks first from someone who + is simply always there.""" + rows = [{"member": member, "count": n, "messages": totals[member], + "per_100": round(100 * n / totals[member], 2)} + for member, n in counts.items() if totals[member]] + rows.sort(key=lambda r: (-r["count"], r["member"])) + return rows[:top] + + +def sessions(msgs, top=10): + """The chat cut into conversations, and the silences between them. + + Same gap `conversation_starters` splits on, so the two cannot disagree about + where a conversation ends. That function reports who opens; this answers the + rest of it — who has the last word, how long conversations actually run, and + what the chat's own dead periods were. + + Only the current session and the longest-so-far are held, so the pass costs + two lists of references rather than one per conversation. + """ + if not msgs: + return None + openers, closers, totals = Counter(), Counter(), Counter() + sizes, durations, silences = [], [], [] + longest = [] + current = [] + + def close(run): + nonlocal longest + openers[run[0]["sender"]] += 1 + closers[run[-1]["sender"]] += 1 + sizes.append(len(run)) + durations.append((run[-1]["ts_ms"] - run[0]["ts_ms"]) / 1000) + if len(run) > len(longest): + longest = list(run) + + for m in msgs: + totals[m["sender"]] += 1 + if current: + gap = (m["ts_ms"] - current[-1]["ts_ms"]) / 1000 + if gap > SESSION_GAP_SECONDS: + silences.append({"seconds": gap, "before": current[-1], "after": m}) + close(current) + current = [] + current.append(m) + if current: + close(current) + + silences.sort(key=lambda s: -s["seconds"]) + return { + "count": len(sizes), + "openers": _turn_rates(openers, totals, top), + "closers": _turn_rates(closers, totals, top), + "sizes": _percentiles(sizes), + "durations": _percentiles(durations), + "longest": {"count": len(longest), "start": longest[0]["dt"], + "end": longest[-1]["dt"], + "participants": len({m["sender"] for m in longest}), + "messages": longest} if longest else None, + "silences": silences[:top], + } + + # --------------------------------------------------------------------------- # # one member's arc # # --------------------------------------------------------------------------- # diff --git a/tests/test_chatstats.py b/tests/test_chatstats.py index 6188e55..3b5a6c8 100644 --- a/tests/test_chatstats.py +++ b/tests/test_chatstats.py @@ -343,3 +343,55 @@ def test_a_word_said_once_is_not_vocabulary(): msgs += [mk("Alice", BASE.replace(year=2021), "unicorn season")] turnover = cs.vocabulary_turnover(msgs) assert turnover["born"].get(2021, []) == [] + + +# --------------------------------------------------------------------------- # +# conversations and the silences between them # +# --------------------------------------------------------------------------- # + +def test_session_splits_only_once_the_gap_is_exceeded(): + """The boundary is the whole definition, so pin both sides of it.""" + gap = cs.SESSION_GAP_SECONDS + msgs = [mk("Alice", BASE, "one"), + mk("Bob", BASE + timedelta(seconds=gap), "still the same conversation"), + mk("Bob", BASE + timedelta(seconds=2 * gap + 1), "a new one")] + sess = cs.sessions(msgs) + assert sess["count"] == 2 + assert sess["sizes"]["max"] == 2 + + +def test_one_silence_is_reported_per_gap_with_both_sides(): + gap = cs.SESSION_GAP_SECONDS + msgs = [mk("Alice", BASE, "before the quiet"), + mk("Bob", BASE + timedelta(days=30), "after the quiet")] + sess = cs.sessions(msgs) + assert len(sess["silences"]) == 1 + silence = sess["silences"][0] + assert silence["before"]["content"] == "before the quiet" + assert silence["after"]["content"] == "after the quiet" + assert silence["seconds"] == 30 * 86400 + assert silence["seconds"] > gap + + +def test_openers_and_closers_are_the_ends_of_each_conversation(): + gap = cs.SESSION_GAP_SECONDS + msgs = [mk("Alice", BASE, "opens"), + mk("Bob", BASE + timedelta(minutes=1), "closes"), + mk("Alice", BASE + timedelta(seconds=gap + 61), "opens again"), + mk("Bob", BASE + timedelta(seconds=gap + 121), "closes again")] + sess = cs.sessions(msgs) + assert {r["member"]: r["count"] for r in sess["openers"]} == {"Alice": 2} + assert {r["member"]: r["count"] for r in sess["closers"]} == {"Bob": 2} + # Alice opened both of her two messages' conversations: 100 per 100. + assert next(r for r in sess["openers"] if r["member"] == "Alice")["per_100"] == 100.0 + + +def test_a_single_message_chat_is_one_conversation_and_no_silence(): + sess = cs.sessions([mk("Alice", BASE, "alone")]) + assert sess["count"] == 1 + assert sess["silences"] == [] + assert sess["longest"]["count"] == 1 + + +def test_sessions_of_an_empty_chat_is_none(): + assert cs.sessions([]) is None diff --git a/tests/test_correctness.py b/tests/test_correctness.py index 2f629e7..4825bf8 100644 --- a/tests/test_correctness.py +++ b/tests/test_correctness.py @@ -583,3 +583,72 @@ def test_incremental_rerun_when_track_file_changes(tmp_path, capsys): terms.write_text("bro\nshawarma\n", encoding="utf-8") assert ac.main(args) == 0 assert "unchanged since last run" not in capsys.readouterr().out + + +# --------------------------------------------------------------------------- # +# hours by year, and the "guess who said this" quiz # +# --------------------------------------------------------------------------- # + +def test_hours_are_kept_apart_by_year(): + msgs = [mk("Alice", datetime(2020, 6, 1, 3, 0), "late"), + mk("Alice", datetime(2021, 6, 1, 14, 0), "reformed")] + by_year = ac.hourly_by_year(msgs) + assert by_year["Alice"][2020][3] == 1 + assert by_year["Alice"][2021][14] == 1 + # The 3am habit belongs to 2020 alone; the all-time radar is what blends them. + assert by_year["Alice"][2021][3] == 0 + + +def _quiz_chat(extra=()): + """Four members, each with a word only they use.""" + words = {"Alice": "shawarma", "Bob": "cricket", "Carol": "biryani", "Dave": "chai"} + msgs = [] + at = BASE + for i in range(6): + for member, word in words.items(): + at += timedelta(minutes=1) + msgs.append(mk(member, at, f"{word} again today please {i}")) + return msgs + list(extra) + + +def test_quiz_answers_are_always_one_of_the_choices(): + msgs = _quiz_chat() + questions = ac.quiz_questions(msgs, ac.personalities(msgs)) + assert questions + assert all(q["answer"] in q["choices"] for q in questions) + assert all(len(q["choices"]) == 4 for q in questions) + + +def test_quiz_is_stable_across_runs(): + """The report is regenerated often; the quiz should not silently reshuffle.""" + msgs = _quiz_chat() + personality = ac.personalities(msgs) + assert ac.quiz_questions(msgs, personality) == ac.quiz_questions(msgs, personality) + + +def test_quiz_leaves_out_bots_and_messages_that_name_somebody(): + giveaway = mk("Alice", BASE + timedelta(days=1), "shawarma with Bob tonight okay") + bot = mk("Meta AI", BASE + timedelta(days=2), "shawarma is a levantine dish here") + msgs = _quiz_chat([giveaway, bot]) + questions = ac.quiz_questions(msgs, ac.personalities(msgs)) + contents = {q["content"] for q in questions} + assert giveaway["content"] not in contents + assert bot["content"] not in contents + assert all(q["answer"] != "Meta AI" for q in questions) + + +def test_quiz_needs_four_members_to_choose_between(): + msgs = [mk("Alice", BASE + timedelta(minutes=i), f"shawarma please {i}") + for i in range(6)] + assert ac.quiz_questions(msgs, ac.personalities(msgs)) == [] + + +def test_quiz_draws_only_from_the_busiest_members(): + """--top caps the cast, so a member outside it is never an answer and never + a wrong answer either.""" + msgs = _quiz_chat() + quiet = [mk("Eve", BASE + timedelta(days=1, minutes=i), f"pomegranate {i}") + for i in range(6)] + questions = ac.quiz_questions(msgs + quiet, ac.personalities(msgs + quiet), top=4) + assert questions + assert all("Eve" not in q["choices"] for q in questions) From 46b2f53f6ae94fcc90c873df85c5113af0ab2d00 Mon Sep 17 00:00:00 2001 From: Ammar Hassan Date: Fri, 7 Aug 2026 08:23:36 +0500 Subject: [PATCH 2/8] Document conversations, sleep schedules and the quiz --- README.md | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 46a4b1a..1137793 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,15 @@ Runs locally. Your data is not uploaded anywhere. - Eras. The chat cut into periods where its volume or its vocabulary turned over, each named for the word it uses most out of proportion, plus the words the chat picked up and stopped saying each year. +- Conversations. The chat cut into conversations wherever nobody spoke for 30 + minutes: who opens them, who has the last word, how long they actually run, the + longest one ever, and the chat's own longest silences with the message that + ended each. +- Sleep schedules. Every member's posting hours, year by year rather than + averaged over all of them, so somebody's hours sliding later reads as a move + and their 3am tail vanishing reads as a job. +- Guess who said this. A quiz built out of each member's signature words, so the + answer is gettable rather than a coin flip. - Message-length and word trends over time. - Sentiment (VADER). Average mood per member and per year. - Weirdest statements. All-caps, 3am, punctuation-spiral, and extreme-length messages. @@ -119,9 +128,9 @@ Options: | `--check` | Validate the export instead of analyzing (always exits 0) | Writes `summary.md`, `report.html`, PNG charts, `year_.html` year-in-review -pages, `group_history.html`, `relationships.html`, `eras.html` and a -`member_.html` per member into `output//`. Every page is linked from -the report's top bar. +pages, `group_history.html`, `relationships.html`, `eras.html`, `sessions.html`, +`quiz.html` and a `member_.html` per member into `output//`. Every +page is linked from the report's top bar. ### Config file @@ -144,7 +153,7 @@ python analyze_chat.py --config config.json ### Group history, relationships and eras -Three pages sit beside the report, plus one page per member. +Five pages sit beside the report, plus one page per member. **Group history** reads back the messages Messenger writes about the group itself — renames, nicknames, joins and removals. They are dropped from the vocabulary @@ -172,6 +181,21 @@ eras out of five after the chat's single commonest word. Underneath it, the word the chat first said and last said each year, which is the plainest version of the same story. +**Conversations** splits the chat wherever nobody spoke for 30 minutes — the same +gap the rest of the report uses, so the two cannot disagree about where a +conversation ends. Openers and closers are given as a count and as a rate per 100 +of that member's own messages, because the count alone just ranks by who talks +most; the rate is what separates someone who always speaks first from someone who +is simply always there. Conversation length is reported as percentiles rather than +an average, since the distribution runs from two-message exchanges to all-nighters +and a mean describes neither. Underneath sits the inverse: the chat's longest +silences, and the message that broke each one. + +**Quiz** is "guess who said this". A message only qualifies if it uses one of its +sender's signature words, so the answer is gettable; messages that name somebody +give it away and are left out, as are bots. It is seeded, so regenerating the +report does not reshuffle the questions. + ### Non-English chats The built-in stopword list is English only, so in a chat that mixes languages the @@ -332,7 +356,7 @@ Sample output from the synthetic thread is in `examples/`: - `summary.json` - the report data as structured JSON - `year_in_review.html` and `year_.html` - one page per year - PNG charts: messages per year, activity heatmap, pace trends, activity by - hour/weekday, top members, top words, word clouds, emoji timeline, yearly recap, + hour/weekday, hours by year, top members, top words, word clouds, emoji timeline, yearly recap, pair dynamics, hourly radar, reaction dynamics, question dynamics, topics per year, running jokes, response speed, swear stats, tracked terms, domains, media leaderboard, reply chains, ghosting, monologues, conversation starters, monthly From 97c541d6d5fd1eac78410c3907e115c82834367b Mon Sep 17 00:00:00 2001 From: Ammar Hassan Date: Fri, 7 Aug 2026 08:49:00 +0500 Subject: [PATCH 3/8] Serve the reader from a SQLite store instead of re-parsing the export --- analyze_chat.py | 56 +++++++--- chat_ui.py | 180 +++++++++++++----------------- chatdb.py | 257 +++++++++++++++++++++++++++++++++++++++++++ tests/test_chatdb.py | 131 ++++++++++++++++++++++ 4 files changed, 505 insertions(+), 119 deletions(-) create mode 100644 chatdb.py create mode 100644 tests/test_chatdb.py diff --git a/analyze_chat.py b/analyze_chat.py index 4162ac5..0a94584 100644 --- a/analyze_chat.py +++ b/analyze_chat.py @@ -3882,26 +3882,54 @@ def _progress(args, phase): sys.stderr.flush() +def _reader_store(thread_dir, args): + """The thread's SQLite file, keyed on the export and on the options that + change what gets stored. + + The timezone lands in the stored month/day/year and --anonymize in the + stored names, so a run under different flags must not read back the last + run's file. Named after the folder rather than the chat, because the folder + is known without parsing anything and the title is not. + """ + import chatdb + out = Path(args.output) / ".reader" + out.mkdir(parents=True, exist_ok=True) + key = json.dumps([_thread_fingerprint(thread_dir), str(args.tz or ""), + "anon" if args.anonymize else "plain"], sort_keys=True) + return chatdb.MessageStore(out / f"{_slug(thread_dir.name)}.sqlite3", key) + + def serve(thread_dirs, args): from chat_ui import run_server threads = [] for d in thread_dirs: - loaded = load_thread(d) - if loaded is None: - continue - title, participants, raw = loaded - try: - msgs = normalize_messages(raw, tz=args.tz) - except ValueError as exc: - print(f" [error] {d}: {exc}") - continue - if not msgs: + store = _reader_store(d, args) + # A store built for this export already answers every reader query, so + # the export is only parsed when the store is missing or stale -- or + # when the word explorer needs the messages anyway. + msgs, title = None, store.title + if not store.ready or not args.no_index: + loaded = load_thread(d) + if loaded is None: + continue + title, participants, raw = loaded + try: + msgs = normalize_messages(raw, tz=args.tz) + except ValueError as exc: + print(f" [error] {d}: {exc}") + continue + if not msgs: + print(f" [skip] {d}: no usable messages") + continue + if args.anonymize: + apply_anonymization(msgs, anonymize_map(msgs)) + if not store.ready: + store.build(msgs, title=title) + elif not store.total: print(f" [skip] {d}: no usable messages") continue - if args.anonymize: - apply_anonymization(msgs, anonymize_map(msgs)) - threads.append({"slug": _slug(title), "title": title, - "thread_dir": d, "msgs": msgs}) + threads.append({"slug": _slug(title or d.name), "title": title or d.name, + "thread_dir": d, "msgs": msgs, "store": store}) if not threads: print("[error] no readable threads to serve") return 1 diff --git a/chat_ui.py b/chat_ui.py index 7c4b521..9ac4c11 100644 --- a/chat_ui.py +++ b/chat_ui.py @@ -5,7 +5,6 @@ filters, full-text search, reply threading, media, and sentiment tinting. """ -import bisect import json import mimetypes import random @@ -15,6 +14,7 @@ from urllib.parse import parse_qs, unquote, urlsplit import analyze_chat as ac +import chatdb from wordindex import WordIndex _PAGE_SIZE = 400 @@ -28,153 +28,122 @@ def _snippet(text, n=90): class ThreadIndex: - def __init__(self, slug, title, thread_dir, msgs, build_index=True): + """The reader's view of one thread, answered out of a `MessageStore`. + + `msgs` is only needed to fill an empty store and to build the word index. + Once the store is populated and the word explorer is off, the reader runs + without the parsed messages in memory at all. + """ + + def __init__(self, slug, title, thread_dir, msgs=None, build_index=True, store=None): self.slug = slug self.title = title self.thread_dir = Path(thread_dir) - self.msgs = msgs - self.by_id = {m.get("id"): m for m in msgs if m.get("id") is not None} + if store is None: + # No file given: an in-memory database, so callers that just hand + # over a list of messages keep working unchanged. + store = chatdb.MessageStore(":memory:", "memory") + self.store = store + if not self.store.ready: + if msgs is None: + raise ValueError("an unbuilt store needs messages to fill it") + self.store.build(msgs) + self.total = self.store.total self.colors = {} - members = sorted({m["sender"] for m in msgs}) - for i, name in enumerate(members): + for i, name in enumerate(sorted(n for n, _ in self.store.members())): self.colors[name] = ac.PALETTE[i % len(ac.PALETTE)] - self.all_pairs = [] - self.member_pairs = {} - # (month, day) -> indices, so "on this day" can group by the same local - # calendar date the rest of the report uses. Rebuilding the day window - # from timestamps would apply the system timezone instead of --tz. - self.by_monthday = {} - for i, m in enumerate(msgs): - self.all_pairs.append((m["ts_ms"], i)) - self.member_pairs.setdefault(m["sender"], []).append((m["ts_ms"], i)) - dt = m["dt"] - self.by_monthday.setdefault((dt.month, dt.day), []).append(i) self._sent_cache = {} # The word explorer's inverted index. Built here rather than lazily so # the cost lands at startup, where it is announced, instead of on the # first search. - self.words = WordIndex(msgs) if build_index else None + self.words = WordIndex(msgs) if (build_index and msgs is not None) else None def to_json(self, idx): - m = self.msgs[idx] + row = self.store.row(idx) + return self._row_json(row) if row is not None else None + + def _row_json(self, row): + p = json.loads(row["payload"]) j = { - "ts": m["ts_ms"], "sender": m["sender"], "color": self.colors[m["sender"]], - "content": m["content"], "mtype": m["mtype"], - "reactions": [{"actor": a, "reaction": r} for a, r in m["reactions"]], - "has_photo": m.get("has_photo", False), "photo_uris": m.get("photo_uris", []), - "has_sticker": m.get("has_sticker", False), - "has_gif": m.get("has_gif", False), "gif_uris": m.get("gif_uris", []), - "has_video": m.get("has_video", False), "video_uris": m.get("video_uris", []), - "has_audio": m.get("has_audio", False), "audio_uris": m.get("audio_uris", []), - "has_file": m.get("has_file", False), "file_uris": m.get("file_uris", []), - "file_names": m.get("file_names", []), - "is_taken_down": m.get("is_taken_down", False), - "link": m.get("link"), - "is_unsent": m.get("is_unsent", False), "reply_to": None, "sentiment": None, + "ts": row["ts_ms"], "sender": row["sender"], + "color": self.colors.get(row["sender"], ac.PALETTE[0]), + "content": row["content"], "mtype": p.get("mtype"), + "reactions": [{"actor": a, "reaction": r} for a, r in p.get("reactions", [])], + "has_photo": p.get("has_photo", False), "photo_uris": p.get("photo_uris", []), + "has_sticker": p.get("has_sticker", False), + "has_gif": p.get("has_gif", False), "gif_uris": p.get("gif_uris", []), + "has_video": p.get("has_video", False), "video_uris": p.get("video_uris", []), + "has_audio": p.get("has_audio", False), "audio_uris": p.get("audio_uris", []), + "has_file": p.get("has_file", False), "file_uris": p.get("file_uris", []), + "file_names": p.get("file_names", []), + "is_taken_down": p.get("is_taken_down", False), + "link": p.get("link"), + "is_unsent": p.get("is_unsent", False), "reply_to": None, "sentiment": None, } - rid = m.get("reply_to") - if rid is not None and rid in self.by_id: - p = self.by_id[rid] - j["reply_to"] = {"sender": p["sender"], "snippet": _snippet(p["content"])} - if ac._VADER is not None and m["content"]: - c = self._sent_cache.get(m["content"]) + if row["reply_to"] is not None: + parent = self.store.by_msg_id(row["reply_to"]) + if parent is not None: + j["reply_to"] = {"sender": parent["sender"], + "snippet": _snippet(parent["content"])} + if ac._VADER is not None and row["content"]: + c = self._sent_cache.get(row["content"]) if c is None: - c = ac._VADER.polarity_scores(m["content"])["compound"] - self._sent_cache[m["content"]] = c + c = ac._VADER.polarity_scores(row["content"])["compound"] + self._sent_cache[row["content"]] = c if len(self._sent_cache) > 50_000: self._sent_cache.clear() j["sentiment"] = c return j def meta(self): - total = len(self.msgs) - member_counts = {} - for m in self.msgs: - member_counts[m["sender"]] = member_counts.get(m["sender"], 0) + 1 + start, end = self.store.span() members = [{"name": n, "count": c, "color": self.colors[n]} - for n, c in sorted(member_counts.items(), key=lambda kv: -kv[1])] + for n, c in self.store.members()] return { "title": self.title, "slug": self.slug, - "total": total, - "start": self.msgs[0]["ts_ms"], - "end": self.msgs[-1]["ts_ms"], + "total": self.total, + "start": start, + "end": end, "members": members, "sentiment_available": ac._VADER is not None, - "has_replies": bool(self.by_id), + "has_replies": self.store.has_replies(), } def page(self, before=None, after=None, member=None, q=None, limit=_PAGE_SIZE, regex=False): if q: return self._search(q, member, limit, regex) - pairs = self.member_pairs.get(member) if member else self.all_pairs - n = len(pairs) - if before is not None: - end = bisect.bisect_left(pairs, (before, -1)) - start = max(0, end - limit) - sel = pairs[start:end][::-1] - next_before = pairs[start][0] if start > 0 else None - next_after = None - elif after is not None: - start = bisect.bisect_right(pairs, (after, 10 ** 15)) - end = min(n, start + limit) - sel = pairs[start:end] - next_after = pairs[end - 1][0] if end < n else None - next_before = None - else: - end = n - start = max(0, end - limit) - sel = pairs[start:end][::-1] - next_before = pairs[start][0] if start > 0 else None - next_after = None - return {"messages": [self.to_json(i) for _, i in sel], - "next_before": next_before, "next_after": next_after, + rows, cursor = self.store.page(before=before, after=after, member=member, + limit=limit) + return {"messages": [self._row_json(r) for r in rows], + "next_before": cursor["next_before"], + "next_after": cursor["next_after"], "search": False} def _search(self, q, member, limit, regex=False): - ql = q.lower() pattern = None if regex: try: pattern = re.compile(q, re.IGNORECASE) except re.error: pattern = None - # Keep indices, never the message dicts: recovering an index later with - # msgs.index() is a linear scan of dict comparisons per hit, which turns - # a search over a long chat into seconds of quadratic work. - hits = [] - total = 0 - for i, m in enumerate(self.msgs): - if member and m["sender"] != member: - continue - content = m["content"] or "" - if pattern is not None: - found = pattern.search(content) is not None - else: - found = ql in content.lower() - if found: - total += 1 - if len(hits) < limit: - hits.append(i) - return {"messages": [self.to_json(i) for i in hits], + if pattern is not None: + rows, total = self.store.search_regex(pattern, member, limit) + else: + rows, total = self.store.search(q, member, limit) + return {"messages": [self._row_json(r) for r in rows], "next_before": None, "next_after": None, "search": True, "total_matches": total, - "shown": len(hits), "truncated": total > len(hits)} + "shown": len(rows), "truncated": total > len(rows)} def day(self, month, day, limit=_PAGE_SIZE): - out = sorted(self.by_monthday.get((month, day), []), - key=lambda i: self.msgs[i]["ts_ms"]) - years = sorted({self.msgs[i]["dt"].year for i in out}) - return {"messages": [self.to_json(i) for i in out[:limit]], - "total": len(out), "years": years} + rows, total, years = self.store.day(month, day, limit) + return {"messages": [self._row_json(r) for r in rows], + "total": total, "years": years} def random_memory(self): - reacted = [i for i, m in enumerate(self.msgs) if m["reactions"]] - long_text = [i for i, m in enumerate(self.msgs) - if m["content"] and len(m["content"]) > 40] - pool = reacted or long_text or list(range(len(self.msgs))) - idx = random.choice(pool) - return {"message": self.to_json(idx)} + row = self.store.random_row() + return {"message": self._row_json(row) if row is not None else None} def resolve_media(self, rel): # Same resolver the analyzer uses, so the reader and --check agree on @@ -325,7 +294,7 @@ def do_GET(self): def _landing(self): items = "".join( f'
  • {html(t.title)} ' - f"{len(t.msgs):,} messages
  • " + f"{t.total:,} messages" for t in threads.values() ) html_doc = f""" @@ -718,11 +687,12 @@ def run_server(threads, port, output_dir, build_index=True): if build_index: print(f" Indexing {t['title']} for word search...", flush=True) indexed[t["slug"]] = ThreadIndex(t["slug"], t["title"], t["thread_dir"], - t["msgs"], build_index=build_index) + t.get("msgs"), build_index=build_index, + store=t.get("store")) handler = make_handler(indexed, Path(output_dir)) server = ThreadingHTTPServer(("127.0.0.1", port), handler) for t in indexed.values(): - print(f" {t.title}: {len(t.msgs):,} messages -> " + print(f" {t.title}: {t.total:,} messages -> " f"http://127.0.0.1:{port}/t/{t.slug}/") print(" Press Ctrl+C to stop.") try: diff --git a/chatdb.py b/chatdb.py new file mode 100644 index 0000000..2679370 --- /dev/null +++ b/chatdb.py @@ -0,0 +1,257 @@ +"""SQLite store for a parsed thread. + +The reader used to hold every message in memory and re-parse the export's JSON +on every start. On a 1.79M-message chat that is a slow startup and a large +resident process before anybody has read a word, and search was a substring scan +over the whole list. + +This module writes the parsed messages once into a SQLite file beside the +report, keyed on the same thread fingerprint `--incremental` uses, and answers +the reader's questions with queries. A second start of an unchanged export +reuses the file and does no parsing at all. + +Nothing here knows about HTML or HTTP. It takes normalized messages and returns +plain rows, so the reader and the tests see the same data. +""" +import json +import sqlite3 +import threading + +# Bump when the schema or the payload shape changes: an older file is thrown +# away and rebuilt rather than read with the wrong assumptions. +SCHEMA_VERSION = 1 + +_SCHEMA = """ +CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); +CREATE TABLE messages ( + idx INTEGER PRIMARY KEY, + msg_id TEXT, + ts_ms INTEGER NOT NULL, + sender TEXT NOT NULL, + content TEXT, + reply_to TEXT, + month INTEGER NOT NULL, + day INTEGER NOT NULL, + year INTEGER NOT NULL, + reacted INTEGER NOT NULL, + length INTEGER NOT NULL, + payload TEXT NOT NULL +); +CREATE INDEX messages_ts ON messages(ts_ms); +CREATE INDEX messages_sender_ts ON messages(sender, ts_ms); +CREATE INDEX messages_monthday ON messages(month, day); +CREATE INDEX messages_msg_id ON messages(msg_id); +""" + +# Columns the reader queries on. Everything else about a message — media uris, +# the flags, reactions — is only ever read whole, so it rides along as one JSON +# payload instead of six join tables nothing would ever query separately. +_PAYLOAD_SKIP = ("dt", "ts_ms", "sender", "content", "reply_to", "id") + + +def _payload(m): + out = {k: v for k, v in m.items() if k not in _PAYLOAD_SKIP} + # Tuples survive a JSON round trip as lists; the reader treats reactions as + # pairs either way, so normalize here rather than at every read. + out["reactions"] = [list(r) for r in m.get("reactions") or []] + return out + + +class MessageStore: + """One thread's messages on disk, and the queries the reader asks of them.""" + + def __init__(self, path, fingerprint): + self.path = path + self.fingerprint = fingerprint + self._lock = threading.Lock() + self._conn = sqlite3.connect(str(path), check_same_thread=False) + self._conn.row_factory = sqlite3.Row + self.ready = self._matches_fingerprint() + + # -- building ---------------------------------------------------------- # + + def _matches_fingerprint(self): + try: + rows = dict(self._conn.execute("SELECT key, value FROM meta").fetchall()) + except sqlite3.Error: + return False + return (rows.get("fingerprint") == self.fingerprint + and rows.get("schema") == str(SCHEMA_VERSION) + and rows.get("complete") == "1") + + @property + def title(self): + """The thread's own title, stored so a reused file does not have to + re-read the export just to learn what the chat is called. + + Asked of a file that does not exist yet, which is the first run. + """ + try: + row = self._one("SELECT value FROM meta WHERE key = 'title'") + except sqlite3.Error: + return None + return row["value"] if row is not None else None + + def build(self, msgs, title=None): + """Replace the file's contents with these messages. + + `complete` is written last, so a run interrupted halfway leaves a file + that fails the fingerprint check and gets rebuilt rather than one that + looks valid and is missing its tail. + """ + with self._lock: + cur = self._conn + cur.executescript( + "DROP TABLE IF EXISTS messages; DROP TABLE IF EXISTS meta;") + cur.executescript(_SCHEMA) + cur.executemany( + "INSERT INTO messages (idx, msg_id, ts_ms, sender, content, reply_to," + " month, day, year, reacted, length, payload)" + " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + [(i, m.get("id"), m["ts_ms"], m["sender"], m["content"], + m.get("reply_to"), m["dt"].month, m["dt"].day, m["dt"].year, + 1 if m.get("reactions") else 0, len(m["content"] or ""), + json.dumps(_payload(m), ensure_ascii=False)) + for i, m in enumerate(msgs)]) + cur.executemany("INSERT INTO meta (key, value) VALUES (?,?)", + [("fingerprint", self.fingerprint), + ("schema", str(SCHEMA_VERSION)), + ("title", title or ""), + ("complete", "1")]) + cur.commit() + self.ready = True + + # -- reading ----------------------------------------------------------- # + + def _all(self, sql, params=()): + with self._lock: + return self._conn.execute(sql, params).fetchall() + + def _one(self, sql, params=()): + with self._lock: + return self._conn.execute(sql, params).fetchone() + + @property + def total(self): + return self._one("SELECT COUNT(*) AS n FROM messages")["n"] + + def span(self): + row = self._one("SELECT MIN(ts_ms) AS a, MAX(ts_ms) AS b FROM messages") + return (row["a"], row["b"]) + + def members(self): + return [(r["sender"], r["n"]) for r in self._all( + "SELECT sender, COUNT(*) AS n FROM messages" + " GROUP BY sender ORDER BY n DESC, sender")] + + def has_replies(self): + return self._one( + "SELECT 1 AS x FROM messages WHERE msg_id IS NOT NULL LIMIT 1") is not None + + def row(self, idx): + return self._one("SELECT * FROM messages WHERE idx = ?", (idx,)) + + def rows(self, idxs): + """Several messages by index, returned in the order asked for.""" + if not idxs: + return [] + marks = ",".join("?" * len(idxs)) + found = {r["idx"]: r for r in + self._all(f"SELECT * FROM messages WHERE idx IN ({marks})", tuple(idxs))} + return [found[i] for i in idxs if i in found] + + def by_msg_id(self, msg_id): + return self._one("SELECT sender, content FROM messages WHERE msg_id = ?", + (msg_id,)) + + def page(self, before=None, after=None, member=None, limit=400): + """One screen of the feed, walking backwards unless asked to go forward. + + Returns the rows plus the cursor for the next call, mirroring what the + in-memory reader returned so the handler and the front end are unchanged. + """ + where, params = [], [] + if member: + where.append("sender = ?") + params.append(member) + if after is not None: + where.append("ts_ms > ?") + params.append(after) + order = "ASC" + else: + if before is not None: + where.append("ts_ms < ?") + params.append(before) + order = "DESC" + clause = (" WHERE " + " AND ".join(where)) if where else "" + rows = self._all( + f"SELECT * FROM messages{clause} ORDER BY ts_ms {order}, idx {order}" + f" LIMIT ?", tuple(params) + (limit + 1,)) + more = len(rows) > limit + rows = rows[:limit] + if order == "ASC": + return rows, {"next_before": None, + "next_after": rows[-1]["ts_ms"] if (rows and more) else None} + # Newest first, the order the feed renders in. The cursor is the oldest + # message on the page, which is where the next one picks up. + return rows, {"next_before": rows[-1]["ts_ms"] if (rows and more) else None, + "next_after": None} + + def search(self, q, member=None, limit=400): + """Substring search, matching what the reader has always done. + + Deliberately not FTS5. Full-text indexes match whole tokens, so + searching `tube` stops finding `youtube.com` -- on a 500k-row bench that + is 0 hits where a substring scan finds 358,453. Nobody would report that + as a bug; they would just conclude the search box is broken. The scan + costs about 60 ms per 500k messages, which is not the reader's problem. + """ + needle = q.lower() + return self._search_scan(lambda c: needle in c.lower(), member, limit) + + def _search_scan(self, predicate, member, limit): + """Streamed scan, for substring and regex alike. + + Rows are consumed as they arrive rather than collected, so searching a + chat far larger than memory stays possible. + """ + clause = " WHERE sender = ?" if member else "" + params = (member,) if member else () + hits, total = [], 0 + with self._lock: + cur = self._conn.execute( + f"SELECT * FROM messages{clause} ORDER BY ts_ms", params) + for row in cur: + if predicate(row["content"] or ""): + total += 1 + if len(hits) < limit: + hits.append(row) + return hits, total + + def search_regex(self, pattern, member=None, limit=400): + return self._search_scan(lambda c: pattern.search(c) is not None, member, limit) + + def day(self, month, day, limit=400): + rows = self._all( + "SELECT * FROM messages WHERE month = ? AND day = ? ORDER BY ts_ms LIMIT ?", + (month, day, limit)) + total = self._one( + "SELECT COUNT(*) AS n FROM messages WHERE month = ? AND day = ?", + (month, day))["n"] + years = [r["year"] for r in self._all( + "SELECT DISTINCT year FROM messages WHERE month = ? AND day = ? ORDER BY year", + (month, day))] + return rows, total, years + + def random_row(self): + """A message worth resurfacing: one that drew reactions, else a long one.""" + for clause in ("WHERE reacted = 1", "WHERE length > 40", ""): + row = self._one( + f"SELECT * FROM messages {clause} ORDER BY RANDOM() LIMIT 1") + if row is not None: + return row + return None + + def close(self): + with self._lock: + self._conn.close() diff --git a/tests/test_chatdb.py b/tests/test_chatdb.py new file mode 100644 index 0000000..bb18d1b --- /dev/null +++ b/tests/test_chatdb.py @@ -0,0 +1,131 @@ +"""The reader's SQLite store. + +The store replaced an in-memory list, so the tests that matter are the ones +that would let it quietly disagree with that list: does paging reach every +message exactly once, does search still mean what it meant, and does a stale +file get rebuilt instead of read. +""" +import re +from datetime import timedelta + +import analyze_chat as ac +import chat_ui +import chatdb +from test_correctness import BASE, mk + + +def sample(n=40): + msgs = [mk(["Alice", "Bob", "Carol"][i % 3], BASE + timedelta(minutes=7 * i), + f"message number {i} about youtube.com and cricket") + for i in range(n)] + msgs[3]["reactions"] = [("Bob", "love")] + return msgs + + +def store_for(msgs, tmp_path, fingerprint="fp"): + store = chatdb.MessageStore(tmp_path / "t.sqlite3", fingerprint) + if not store.ready: + store.build(msgs, title="Test Thread") + return store + + +def test_a_reopened_store_needs_no_messages(tmp_path): + msgs = sample() + store_for(msgs, tmp_path).close() + again = chatdb.MessageStore(tmp_path / "t.sqlite3", "fp") + assert again.ready + assert again.title == "Test Thread" + assert again.total == len(msgs) + # The point of the store: a working reader with nothing parsed. + reader = chat_ui.ThreadIndex("t", again.title, ".", msgs=None, + build_index=False, store=again) + assert reader.page(limit=5)["messages"] + + +def test_a_changed_export_is_rebuilt_not_reused(tmp_path): + store_for(sample(), tmp_path).close() + stale = chatdb.MessageStore(tmp_path / "t.sqlite3", "a-different-fingerprint") + assert not stale.ready + + +def test_an_interrupted_build_is_not_mistaken_for_a_finished_one(tmp_path): + store = store_for(sample(), tmp_path) + store._conn.execute("DELETE FROM meta WHERE key = 'complete'") + store._conn.commit() + store.close() + assert not chatdb.MessageStore(tmp_path / "t.sqlite3", "fp").ready + + +def test_search_still_finds_a_substring_inside_a_word(tmp_path): + """A full-text index would tokenize `youtube.com` and lose this; the reader + has always matched substrings and must keep doing so.""" + msgs = sample() + reader = chat_ui.ThreadIndex("t", "T", ".", msgs, build_index=False, + store=store_for(msgs, tmp_path)) + expected = sum(1 for m in msgs if "tube" in m["content"].lower()) + assert expected > 0 + assert reader.page(q="tube")["total_matches"] == expected + + +def test_search_is_case_insensitive_and_counts_every_match(tmp_path): + msgs = sample() + reader = chat_ui.ThreadIndex("t", "T", ".", msgs, build_index=False, + store=store_for(msgs, tmp_path)) + assert (reader.page(q="CRICKET")["total_matches"] + == reader.page(q="cricket")["total_matches"] == len(msgs)) + + +def test_regex_search_reaches_the_store(tmp_path): + msgs = sample() + reader = chat_ui.ThreadIndex("t", "T", ".", msgs, build_index=False, + store=store_for(msgs, tmp_path)) + hits = reader.page(q=r"number \d+ about", limit=100, regex=True) + assert hits["total_matches"] == len(msgs) + + +def test_paging_backwards_reaches_every_message_once(tmp_path): + msgs = sample() + reader = chat_ui.ThreadIndex("t", "T", ".", msgs, build_index=False, + store=store_for(msgs, tmp_path)) + seen, cursor = [], None + while True: + page = reader.page(before=cursor, limit=6) + seen += [m["ts"] for m in page["messages"]] + # Newest first is the order the feed renders in. + assert page["messages"] == sorted(page["messages"], key=lambda m: -m["ts"]) + cursor = page["next_before"] + if cursor is None: + break + assert sorted(seen) == sorted(m["ts_ms"] for m in msgs) + + +def test_paging_forwards_reaches_every_message_once(tmp_path): + msgs = sample() + reader = chat_ui.ThreadIndex("t", "T", ".", msgs, build_index=False, + store=store_for(msgs, tmp_path)) + seen, cursor = [], 0 + while True: + page = reader.page(after=cursor, limit=6) + seen += [m["ts"] for m in page["messages"]] + if page["next_after"] is None: + break + cursor = page["next_after"] + assert sorted(seen) == sorted(m["ts_ms"] for m in msgs) + + +def test_member_filter_pages_only_that_member(tmp_path): + msgs = sample() + reader = chat_ui.ThreadIndex("t", "T", ".", msgs, build_index=False, + store=store_for(msgs, tmp_path)) + page = reader.page(member="Alice", limit=500) + assert {m["sender"] for m in page["messages"]} == {"Alice"} + assert len(page["messages"]) == sum(1 for m in msgs if m["sender"] == "Alice") + + +def test_an_unbuilt_store_without_messages_is_an_error(tmp_path): + empty = chatdb.MessageStore(tmp_path / "fresh.sqlite3", "fp") + try: + chat_ui.ThreadIndex("t", "T", ".", msgs=None, build_index=False, store=empty) + except ValueError: + return + raise AssertionError("expected an unbuilt store with no messages to be refused") From 819a42b4f2a3fc911e89acdbd4d5ae4ae364554c Mon Sep 17 00:00:00 2001 From: Ammar Hassan Date: Fri, 7 Aug 2026 08:49:01 +0500 Subject: [PATCH 4/8] Document the reader store --- README.md | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1137793..72779b3 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Options: | `--json` | Also write `summary.json` with the report data as structured JSON | | `--serve` | Start the local chat reader web UI instead of writing reports | | `--port` | Port for `--serve` (default: 8080) | -| `--no-index` | Skip the word-search index when serving. Starts faster; the word explorer is unavailable | +| `--no-index` | Skip the word-search index when serving. Starts instantly from the reader store and skips parsing entirely; the word explorer is unavailable | | `--tz` | Timezone for analysis, e.g. `+03:00` or `America/New_York` (Messenger timestamps are UTC; default is your system timezone) | | `--config` | JSON config file with any of the options above | | `--skip` | Skip analyses: `jokes`, `sentiment`, `wordcloud`, `topics`, `narratives` (comma-separated) | @@ -303,12 +303,32 @@ index of their own — the candidate messages are the ones containing every word so `in the` came back in 1.0 s and `what the hell` in 0.04 s on the same chat. Pass `--no-index` to skip the build if you only want to read. +### The reader's store + +The reader keeps the parsed messages in a SQLite file at +`output/.reader/.sqlite3` and answers the feed, the date jump, "on this +day", the random memory and search out of it. The file is keyed on the same +fingerprint `--incremental` uses, plus `--tz` and `--anonymize`, since both +change what gets stored; anything else rebuilds it. + +The practical effect is on the second start. With `--no-index` the export is not +parsed at all — the reader opens the file and serves. Without it, the messages +are still parsed because the word explorer needs them in memory. A stale or +half-written file is rebuilt rather than trusted: the "complete" marker is +written last, so a run interrupted mid-build leaves a file that fails its own +check. + +Search stays a substring scan, now inside SQLite, and still reports the true +match count and shows the first page of hits. It is deliberately not a full-text +index: FTS5 matches whole tokens, so searching `tube` would stop finding +`youtube.com` — on a 500k-row benchmark that is 0 hits against 358,453. The scan +costs roughly 60 ms per 500k messages, which is not worth breaking search for. + Media files are served from the export folder only; paths are resolved inside the thread directory so nothing outside it can be read, and files are streamed with `Range` support so video and audio can seek. Anything that is not an image, video or audio downloads rather than rendering, since an export can contain `.html` or -`.svg` attachments. Search is a substring scan over the messages (regex is -opt-in) that reports the true match count and shows the first page of hits. +`.svg` attachments. Text that comes from the export — thread titles, names, message bodies — is escaped everywhere it is rendered, in the reader and in the generated reports. From e96cd29a0aee2b8053fdaf7227f2460465e59f00 Mon Sep 17 00:00:00 2001 From: Ammar Hassan Date: Fri, 7 Aug 2026 09:18:02 +0500 Subject: [PATCH 5/8] Add trendsetters, the words a member starts that others pick up --- analyze_chat.py | 97 ++++++++++++++++++++++++++++++- chatstats.py | 111 ++++++++++++++++++++++++++++++++++++ tests/test_chatstats.py | 86 ++++++++++++++++++++++++++++ tests/test_flags.py | 13 +++++ tests/test_report_output.py | 26 +++++++++ 5 files changed, 330 insertions(+), 3 deletions(-) diff --git a/analyze_chat.py b/analyze_chat.py index 0a94584..5b50be9 100644 --- a/analyze_chat.py +++ b/analyze_chat.py @@ -2699,7 +2699,8 @@ def _year_page_html(title, year, recap, analyses, pngs): _NARRATIVE_PAGES = [("report.html", "Report"), ("year_in_review.html", "Years"), ("group_history.html", "Group history"), ("relationships.html", "Relationships"), ("eras.html", "Eras"), - ("sessions.html", "Conversations"), ("quiz.html", "Quiz")] + ("sessions.html", "Conversations"), + ("trendsetters.html", "Trendsetters"), ("quiz.html", "Quiz")] def _narrative_page(title, current, heading, subtitle, body): @@ -2945,6 +2946,50 @@ def _sessions_html(title, sess): f"{CONVERSATION_WINDOW_SECONDS // 60} minutes.", body) +def _trend_days(value): + """A median wait, in the unit it reads in: same day, days, or months.""" + if value is None: + return "-" + if value < 1: + return "same day" + if value < 60: + return f"{value:.0f} days" + return f"{value / 30.4:.0f} months" + + +def _trendsetters_html(title, trend): + esc = html_lib.escape + leaders = _rows(["Member", "Words they started", "Their messages", + "Per 1,000 messages", "Typical wait", "Most-adopted word"], + [(esc(member_label(r["member"])), f"{r['words']:,}", + f"{r['messages']:,}", f"{r['per_1k']}", _trend_days(r["days"]), + esc(r["best"])) for r in trend["members"]]) + words = _rows(["Word", "Started by", "First said", "Picked up by", + "Typical wait", "Uses"], + [(f"{esc(w['word'])}", esc(member_label(w["member"])), + w["first"], f"{w['adopters']} others", _trend_days(w["days"]), + f"{w['uses']:,}") for w in trend["words"]]) + band = (f"

    Only words the chat used between {trend['min_uses']:,} " + f"and {trend['max_uses']:,} times, and only when at least " + f"{trend['min_adopters']} other members said them afterwards. A word first " + f"said in the chat's opening {trend['warmup_days']} days does not count as " + f"started: the export beginning is not the same as the word being new " + f"({trend['warmup_skipped']:,} left out that way).

    ") + rate = (f"

    Divided by how much each member says, so a chatty member " + f"cannot win on volume alone. Members under {trend['min_messages']:,} " + f"messages are left out, since a rate needs a denominator worth dividing " + f"by.

    ") + body = "".join([ + _sec("who", "Who starts the words", + band + rate + (leaders or "

    Nobody started a word that caught on.

    ")), + _sec("words", "The words that caught on", + words or "

    No word in the band was picked up by enough members.

    "), + ]) + subtitle = (f"{trend['adopted']:,} of the {trend['considered']:,} words in the band " + f"were picked up by {trend['min_adopters']} members or more.") + return _narrative_page(title, "trendsetters.html", "Trendsetters", subtitle, body) + + def _quiz_html(title, questions): # `` inside a message would close the tag early and drop the rest # of the quiz on the floor. @@ -3019,6 +3064,10 @@ def write_narrative_pages(title, analyses, out_dir): if sess: (out_dir / "sessions.html").write_text( _sessions_html(title, sess), encoding="utf-8") + trend = analyses.get("trendsetters") + if trend: + (out_dir / "trendsetters.html").write_text( + _trendsetters_html(title, trend), encoding="utf-8") quiz = analyses.get("quiz") if quiz: (out_dir / "quiz.html").write_text( @@ -3151,6 +3200,7 @@ def insights(stats, analyses): "topics": "Topics", "jokes": "Jokes", "media": "Media", "speed": "Speed", "swear": "Swearing", "starters": "Starters", "ghosting": "Ghosting", "hourly": "Hours", "lengths": "Lengths", "domains": "Domains", + "trendsetters": "Trendsetters", } @@ -3255,6 +3305,18 @@ def member_cell(name): rows.append(row) sections.append(_sec("pair_dynamics", "Pair dynamics", _table(["Replier \\n Replied-to"] + [member_label(m) for m in pm["members"]], rows))) + trend = analyses.get("trendsetters") + if trend and trend["members"]: + rows = [(cell(r["member"]), f"{r['words']:,}", f"{r['per_1k']}", + html_lib.escape(r["best"])) for r in trend["members"]] + sections.append(_sec("trendsetters", "Who starts the words", + f"

    Words a member said first that at least " + f"{trend['min_adopters']} others then picked up, per 1,000 " + f"of their own messages so the loudest member does not win " + f"by volume. Full page.

    " + + _table(["Member", "Words they started", + "Per 1,000 messages", "Most-adopted word"], rows))) + conv = analyses.get("conversations") if conv and conv["starters"]: rows = [(cell(m), f"{c:,}") for m, c in conv["starters"].most_common(top)] @@ -3411,7 +3473,10 @@ def member_cell(name): written = {"year_in_review.html": True, "group_history.html": bool(analyses.get("group_history")), "relationships.html": bool(analyses.get("relationships")), - "eras.html": bool(analyses.get("eras"))} + "eras.html": bool(analyses.get("eras")), + "sessions.html": bool(analyses.get("sessions")), + "trendsetters.html": bool(analyses.get("trendsetters")), + "quiz.html": bool(analyses.get("quiz"))} extra_pages = '' + "".join( f'{label}' for href, label in _NARRATIVE_PAGES @@ -3650,6 +3715,9 @@ def write_summary_json(title, stats, analyses, out_dir, anonymized, dates, top=1 if analyses.get("turnover"): payload["vocabulary_turnover"] = {"born": analyses["turnover"]["born"], "died": analyses["turnover"]["died"]} + if analyses.get("trendsetters"): + payload["trendsetters"] = {"members": analyses["trendsetters"]["members"], + "words": analyses["trendsetters"]["words"]} if analyses.get("member_profiles"): # The messages a member's most-reacted list holds are whole message # dicts; the JSON keeps the arc, not the message objects. @@ -3834,7 +3902,9 @@ def process_thread(thread_dir, args): analyses["turnover"] = chatstats.vocabulary_turnover(msgs, names=names) analyses["member_profiles"] = chatstats.member_profiles(msgs, names=names) analyses["sessions"] = chatstats.sessions(msgs, top=args.top) - # Both render as narrative pages, so they skip together. + analyses["trendsetters"] = chatstats.trendsetters( + msgs, band=_parse_band(args.trend_band), names=names, top=args.top) + # All of these render as narrative pages, so they skip together. analyses["quiz"] = quiz_questions(msgs, analyses["personality"], names=names, top=args.top) @@ -3872,6 +3942,23 @@ def _parse_skip(value): return names & set(SKIPPABLE) +def _parse_band(value): + """--trend-band as (min uses, max uses), or None to keep the defaults. + + Left to chatstats to fill in rather than defaulted here, so the band a run + used and the band the page explains cannot drift apart. + """ + parts = [p.strip() for p in str(value or "").split(",") if p.strip()] + if len(parts) == 2 and all(p.isdigit() for p in parts): + low, high = int(parts[0]), int(parts[1]) + if 0 < low <= high: + return low, high + if value: + print(f" [warn] ignoring --trend-band {value!r}: expected two rising " + f"numbers, e.g. 20,2000") + return None + + def _slug(name): return re.sub(r"[^A-Za-z0-9]+", "_", name).strip("_").lower() or "thread" @@ -4235,6 +4322,10 @@ def main(argv=None): "export does not list, e.g. a deleted account that shows " "as 'Facebook user'. Dropped from topic words and running " "jokes, where a name would otherwise read as a topic") + parser.add_argument("--trend-band", default="", + help="How often a word must be used to count as one somebody " + "started, as min,max (default: 20,2000). Lower it on a " + "small chat, where nothing reaches twenty uses") parser.add_argument("--stopwords-file", default="", help="Extra stopwords to ignore in word stats, one per line " "(# comments ignored). The built-in list is English only, " diff --git a/chatstats.py b/chatstats.py index af0231f..373a18f 100644 --- a/chatstats.py +++ b/chatstats.py @@ -36,6 +36,23 @@ # 100% collapse and fills the page. DRIFT_MIN_INTERACTIONS = 100 +# Only words in this band can be coined. Nobody introduced "the", a word said +# twice is a typo rather than a coinage, and the band is also what keeps the +# sweep over a nine-year vocabulary down to the words that could plausibly +# spread. +TREND_MIN_USES = 20 +TREND_MAX_USES = 2000 +# Two people saying the same thing is a conversation. Three others picking it +# up is the word getting into the chat's own vocabulary. +TREND_MIN_ADOPTERS = 3 +# An export starting is not a word being coined: on day one every word is new, +# so without this whoever talked most in the opening months "introduces" +# thousands of words the chat had been saying for years. +TREND_WARMUP_DAYS = 90 +# A rate needs a denominator worth dividing by, the same floor drift uses: +# three lucky words out of forty messages is not a trendsetter. +TREND_MIN_MESSAGES = 100 + # --------------------------------------------------------------------------- # # the group's own history # @@ -627,3 +644,97 @@ def vocabulary_turnover(msgs, min_uses=TURNOVER_MIN_USES, top=15, names=()): "died": {y: [{"word": w, "count": c} for c, w in sorted(v, reverse=True)[:top]] for y, v in died.items()}, } + + +# --------------------------------------------------------------------------- # +# who starts the words # +# --------------------------------------------------------------------------- # + +def trendsetters(msgs, band=None, min_adopters=TREND_MIN_ADOPTERS, + warmup_days=TREND_WARMUP_DAYS, min_messages=TREND_MIN_MESSAGES, + top=10, names=()): + """Who says a word first and then watches everybody else start saying it. + + A different question from who talks most, so the words a member started are + divided by how much they say, the same per-1,000-messages convention the + word explorer and the relationships page use. + + Two passes over the chat rather than one: the first counts every word, the + second records who said the banded ones first. One pass would mean holding + a first-use record for the whole vocabulary when only the few thousand + words in the band are ever read. + """ + if not msgs: + return None + name_words = ac._member_name_words(msgs, names) + min_uses, max_uses = band or (TREND_MIN_USES, TREND_MAX_USES) + + # Bots are left out of both passes. Meta AI's vocabulary is not the chat's, + # and crediting it with a word only takes the credit off a person. + totals = Counter() + said = Counter() + for m in msgs: + if ac.is_bot(m["sender"]): + continue + said[m["sender"]] += 1 + for w in ac._vocab(m): + if len(w) > 2 and w not in ac.STOPWORDS and w not in name_words: + totals[w] += 1 + in_band = {w for w, n in totals.items() if min_uses <= n <= max_uses} + + # Messages are chronological, so the first time a member is seen saying a + # word is their first use of it. + first_use = defaultdict(dict) + for m in msgs: + if ac.is_bot(m["sender"]): + continue + for w in ac._vocab(m): + if w in in_band: + first_use[w].setdefault(m["sender"], m) + + cutoff = msgs[0]["ts_ms"] + warmup_days * 86400 * 1000 + coined = [] + warmup_skipped = 0 + for word, by_member in first_use.items(): + order = sorted(by_member.values(), key=lambda m: m["ts_ms"]) + if len(order) - 1 < min_adopters: + continue + origin = order[0] + if origin["ts_ms"] < cutoff: + warmup_skipped += 1 + continue + coined.append({ + "word": word, "member": origin["sender"], "uses": totals[word], + "first": origin["dt"].strftime("%Y-%m-%d"), + "adopters": len(order) - 1, + "days": ac._median(sorted((m["dt"] - origin["dt"]).days for m in order[1:])), + }) + + per_member = defaultdict(list) + for c in coined: + per_member[c["member"]].append(c) + members = [] + for member, words in per_member.items(): + if said[member] < min_messages: + continue + members.append({ + "member": member, "words": len(words), "messages": said[member], + "per_1k": round(1000 * len(words) / said[member], 2), + "days": ac._median(sorted(w["days"] for w in words)), + "best": max(words, key=lambda w: (w["adopters"], w["uses"]))["word"], + }) + # By the rate, not the count -- the whole point of the normalisation. + members.sort(key=lambda r: (-r["per_1k"], -r["words"], r["member"])) + coined.sort(key=lambda c: (-c["adopters"], -c["uses"], c["word"])) + return { + "members": members[:top], + "words": coined[:top], + "considered": len(in_band), + "adopted": len(coined), + "warmup_skipped": warmup_skipped, + "warmup_days": warmup_days, + "min_uses": min_uses, + "max_uses": max_uses, + "min_adopters": min_adopters, + "min_messages": min_messages, + } diff --git a/tests/test_chatstats.py b/tests/test_chatstats.py index 3b5a6c8..00a63ab 100644 --- a/tests/test_chatstats.py +++ b/tests/test_chatstats.py @@ -395,3 +395,89 @@ def test_a_single_message_chat_is_one_conversation_and_no_silence(): def test_sessions_of_an_empty_chat_is_none(): assert cs.sessions([]) is None + + +# --------------------------------------------------------------------------- # +# who starts the words # +# --------------------------------------------------------------------------- # + +def _filler(who, n): + """Ordinary talk, so a member has a denominator to be divided by.""" + return [mk(who, BASE + timedelta(hours=i), "ordinary talk") for i in range(n)] + + +def _catches_on(who, word, day, adopters, uses=10): + """`who` says `word` first, others pick it up a day apart, then it sticks.""" + out = [mk(who, BASE + timedelta(days=day), f"{word} again")] + for n, other in enumerate(adopters, start=1): + out.append(mk(other, BASE + timedelta(days=day + n), f"{word} again")) + out += [mk(who, BASE + timedelta(days=day + 5, hours=h), f"{word} {word}") + for h in range(uses)] + return out + + +def _chat(*parts): + return sorted([m for part in parts for m in part], key=lambda m: m["ts_ms"]) + + +def test_a_word_is_credited_to_whoever_said_it_first(): + msgs = _chat(_filler("Alice", 300), + _catches_on("Alice", "yeeted", 120, ["Bob", "Dana", "Charlie"])) + trend = cs.trendsetters(msgs) + assert [w["word"] for w in trend["words"]] == ["yeeted"] + assert trend["words"][0]["member"] == "Alice" + assert trend["words"][0]["adopters"] == 3 + # Picked up one, two and three days later. + assert trend["words"][0]["days"] == 2 + + +def test_a_word_only_two_others_repeated_is_not_a_trend(): + msgs = _chat(_filler("Alice", 300), + _catches_on("Alice", "yeeted", 120, ["Bob", "Dana"])) + assert cs.trendsetters(msgs)["words"] == [] + + +def test_a_word_from_the_chats_opening_days_is_started_by_nobody(): + """The export beginning is not the same as the word being new.""" + cast = ["Bob", "Dana", "Charlie"] + msgs = _chat(_filler("Alice", 300), + _catches_on("Alice", "yeeted", 10, cast), + _catches_on("Alice", "sussy", 120, cast)) + trend = cs.trendsetters(msgs) + assert [w["word"] for w in trend["words"]] == ["sussy"] + assert trend["warmup_skipped"] >= 1 + + +def test_a_word_outside_the_band_is_not_looked_at(): + msgs = _chat(_filler("Alice", 300), + _catches_on("Alice", "yeeted", 120, ["Bob", "Dana", "Charlie"], uses=2)) + # Eight uses: under the default floor of twenty, over a ceiling of three. + assert cs.trendsetters(msgs)["words"] == [] + assert cs.trendsetters(msgs, band=(1, 3))["words"] == [] + assert [w["word"] for w in cs.trendsetters(msgs, band=(5, 50))["words"]] == ["yeeted"] + + +def test_a_trendsetter_is_ranked_by_rate_not_by_count(): + """Alice started twice as many words, out of nearly three times the talk.""" + msgs = _chat(_filler("Alice", 300), _filler("Bob", 120), + _catches_on("Alice", "yeeted", 120, ["Bob", "Dana", "Charlie"]), + _catches_on("Alice", "sussy", 150, ["Bob", "Dana", "Charlie"]), + _catches_on("Bob", "bussin", 180, ["Alice", "Dana", "Charlie"])) + trend = cs.trendsetters(msgs) + rows = {r["member"]: r for r in trend["members"]} + assert rows["Alice"]["words"] == 2 + assert rows["Bob"]["words"] == 1 + assert rows["Bob"]["per_1k"] > rows["Alice"]["per_1k"] + assert trend["members"][0]["member"] == "Bob" + + +def test_a_member_who_barely_spoke_is_not_a_trendsetter(): + msgs = _chat(_filler("Alice", 300), + _catches_on("Dana", "yeeted", 120, ["Alice", "Bob", "Charlie"])) + trend = cs.trendsetters(msgs) + assert trend["words"][0]["member"] == "Dana" + assert trend["members"] == [] + + +def test_trendsetters_of_an_empty_chat_is_none(): + assert cs.trendsetters([]) is None diff --git a/tests/test_flags.py b/tests/test_flags.py index f5032f9..e7af5ad 100644 --- a/tests/test_flags.py +++ b/tests/test_flags.py @@ -53,6 +53,19 @@ def test_progress_flag_smoke(tmp_path): assert (tmp_path / "saturday_squad" / "summary.md").exists() +def test_trend_band_reaches_the_page_and_a_bad_one_is_ignored(tmp_path, capsys): + out = tmp_path / "band" + assert ac.main(["--input", SAMPLE, "--output", str(out), "--trend-band", "3,50"]) == 0 + page = (out / "saturday_squad" / "trendsetters.html").read_text(encoding="utf-8") + assert "between 3 and 50 times" in page + + bad = tmp_path / "bad_band" + assert ac.main(["--input", SAMPLE, "--output", str(bad), "--trend-band", "lots"]) == 0 + assert "ignoring --trend-band" in capsys.readouterr().out + page = (bad / "saturday_squad" / "trendsetters.html").read_text(encoding="utf-8") + assert "between 20 and 2,000 times" in page + + def test_incremental_skips_unchanged(tmp_path, capsys): out = tmp_path / "inc" assert ac.main(["--input", SAMPLE, "--output", str(out), "--incremental", "--json"]) == 0 diff --git a/tests/test_report_output.py b/tests/test_report_output.py index 50f6a72..0ee72c4 100644 --- a/tests/test_report_output.py +++ b/tests/test_report_output.py @@ -348,6 +348,32 @@ def test_summary_json_carries_the_narratives(tmp_path): assert "top_reacted" not in payload["members"]["Alice"] +def test_the_trendsetters_page_and_its_report_section_name_the_same_member(tmp_path): + """Alice says "yeeted" first, three others pick it up, and it sticks around + long enough to clear the band.""" + msgs = [raw("Alice", BASE + timedelta(hours=i), "ordinary talk here") + for i in range(110)] + msgs += [raw("Bob", BASE + timedelta(hours=i, minutes=1), "ordinary talk here") + for i in range(30)] + start = BASE + timedelta(days=120) + msgs.append(raw("Alice", start, "yeeted the whole thing")) + msgs += [raw(who, start + timedelta(days=n), "yeeted again") + for n, who in enumerate(["Bob", "Dana", "Charlie"], start=1)] + msgs += [raw("Alice", start + timedelta(days=5, hours=h), "yeeted yeeted") + for h in range(10)] + + out = generate(tmp_path, msgs, "--json") + page = (out / "trendsetters.html").read_text(encoding="utf-8") + assert "yeeted" in page + assert "3 others" in page + report = (out / "report.html").read_text(encoding="utf-8") + assert '
    ' in report + assert 'href="trendsetters.html"' in report + payload = json.loads((out / "summary.json").read_text(encoding="utf-8")) + assert payload["trendsetters"]["members"][0]["member"] == "Alice" + assert payload["trendsetters"]["words"][0]["word"] == "yeeted" + + def test_extra_stopwords_reach_the_narrative_pages(tmp_path): """Run as a script, analyze_chat is `__main__`, and chatstats importing it by name used to get a second copy whose STOPWORDS never saw From 083390d3b3dbc22a76a6a8d50d131bb26ffd4340 Mon Sep 17 00:00:00 2001 From: Ammar Hassan Date: Fri, 7 Aug 2026 09:18:03 +0500 Subject: [PATCH 6/8] Document trendsetters --- README.md | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 72779b3..6c90f9d 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ Runs locally. Your data is not uploaded anywhere. minutes: who opens them, who has the last word, how long they actually run, the longest one ever, and the chat's own longest silences with the message that ended each. +- Trendsetters. Who says a word first and then watches everybody else start + saying it, scored per 1,000 of their own messages so the loudest member does + not win by volume. - Sleep schedules. Every member's posting hours, year by year rather than averaged over all of them, so somebody's hours sliding later reads as a move and their 3am tail vanishing reads as a job. @@ -116,6 +119,7 @@ Options: | `--stopwords-file` | Extra stopwords to ignore in word stats, one per line (the built-in list is English only) | | `--year` | Analyze only one year, e.g. `--year 2017` | | `--top` | Number of entries in leaderboards and charts (default: 10) | +| `--trend-band` | How often a word must be used to count as one somebody started, as `min,max` (default: `20,2000`). Lower it on a small chat, where nothing reaches twenty uses | | `--json` | Also write `summary.json` with the report data as structured JSON | | `--serve` | Start the local chat reader web UI instead of writing reports | | `--port` | Port for `--serve` (default: 8080) | @@ -129,8 +133,8 @@ Options: Writes `summary.md`, `report.html`, PNG charts, `year_.html` year-in-review pages, `group_history.html`, `relationships.html`, `eras.html`, `sessions.html`, -`quiz.html` and a `member_.html` per member into `output//`. Every -page is linked from the report's top bar. +`trendsetters.html`, `quiz.html` and a `member_.html` per member into +`output//`. Every page is linked from the report's top bar. ### Config file @@ -153,7 +157,7 @@ python analyze_chat.py --config config.json ### Group history, relationships and eras -Five pages sit beside the report, plus one page per member. +Six pages sit beside the report, plus one page per member. **Group history** reads back the messages Messenger writes about the group itself — renames, nicknames, joins and removals. They are dropped from the vocabulary @@ -191,6 +195,21 @@ an average, since the distribution runs from two-message exchanges to all-nighte and a mean describes neither. Underneath sits the inverse: the chat's longest silences, and the message that broke each one. +**Trendsetters** asks who introduces vocabulary that other people actually +adopt, which is not the same question as who talks most. For every word the +chat used between 20 and 2,000 times — nobody coined "the", and a word said +twice is a typo — it takes whoever said it first, and counts the word as having +caught on only once at least three other members said it too. Three things keep +the answer honest. A word first said in the chat's opening 90 days does not +count, because an export beginning is not a word being new and otherwise +whoever talked most in month one "starts" thousands of words the chat had been +saying for years. Bots are left out, since Meta AI's vocabulary is not the +chat's. And the count is divided by how much each member says, per 1,000 of +their own messages, so a chatty member cannot win on volume alone — which means +members under 100 messages are left out, a rate needing a denominator worth +dividing by. Use `--trend-band` to lower the band on a chat too small for twenty +uses; the words still show even when nobody clears the leaderboard's floor. + **Quiz** is "guess who said this". A message only qualifies if it uses one of its sender's signature words, so the answer is gettable; messages that name somebody give it away and are left out, as are bots. It is seeded, so regenerating the From 1da3351beb32d996d99111658b3df6bd33e7d710 Mon Sep 17 00:00:00 2001 From: Ammar Hassan Date: Fri, 7 Aug 2026 10:31:24 +0500 Subject: [PATCH 7/8] Regenerate the committed sample report in full --- examples/eras.html | 47 + examples/example_summary.md | 4 +- examples/group_history.html | 47 + examples/hourly_by_year.png | Bin 0 -> 39576 bytes examples/member_alice.html | 47 + examples/member_bob.html | 47 + examples/member_charlie.html | 47 + examples/member_dana.html | 47 + examples/quiz.html | 91 ++ examples/relationships.html | 47 + examples/report.html | 9 +- examples/screenshot_reader.png | Bin 0 -> 66597 bytes examples/screenshot_word_explorer.png | Bin 0 -> 64624 bytes examples/sessions.html | 47 + examples/summary.json | 1503 ++++++++++++++++++++++++- examples/trendsetters.html | 47 + examples/year_2017.html | 2 + examples/year_2018.html | 2 + examples/year_2019.html | 2 + examples/year_2020.html | 2 + examples/year_2021.html | 2 + examples/year_2022.html | 2 + examples/year_2023.html | 2 + examples/year_2024.html | 2 + examples/year_2025.html | 2 + examples/year_2026.html | 2 + examples/year_in_review.html | 2 + 27 files changed, 2045 insertions(+), 7 deletions(-) create mode 100644 examples/eras.html create mode 100644 examples/group_history.html create mode 100644 examples/hourly_by_year.png create mode 100644 examples/member_alice.html create mode 100644 examples/member_bob.html create mode 100644 examples/member_charlie.html create mode 100644 examples/member_dana.html create mode 100644 examples/quiz.html create mode 100644 examples/relationships.html create mode 100644 examples/screenshot_reader.png create mode 100644 examples/screenshot_word_explorer.png create mode 100644 examples/sessions.html create mode 100644 examples/trendsetters.html diff --git a/examples/eras.html b/examples/eras.html new file mode 100644 index 0000000..9f1099d --- /dev/null +++ b/examples/eras.html @@ -0,0 +1,47 @@ + + + +Saturday Squad - eras + + + +
    +

    Eras

    +

    1 period across 2017-01 to 2026-01.

    +

    Eras

    A month opens a new era when the three months from it carry less than half or more than double the messages of the three before it, or when fewer than a third of the previous quarter's top words survive into this one. Each era is named for the word it uses most out of proportion to the rest of the chat.

    bro2017-01 to 2026-01 · 94 messages
    EraSpanMonthsMessagesLoudestWords it made its own
    bro2017-01 to 2026-0110994Alicebro, shawarma

    Words born and words that died

    A word is born the year it is first said and dies the year it is last said. Every word alive at the end would die in the final year, so that year is left out.

    YearFirst saidLast said
    2019shawarma-
    2020bro-
    2023-shawarma
    2025-bro
    +
    + + \ No newline at end of file diff --git a/examples/example_summary.md b/examples/example_summary.md index 26f40f4..4e3edb4 100644 --- a/examples/example_summary.md +++ b/examples/example_summary.md @@ -1,6 +1,6 @@ # Saturday Squad flashback -Generated 2026-08-06 20:14 +Generated 2026-08-07 09:31 ## All-time totals @@ -319,6 +319,8 @@ Top topic words per year (tf-idf): ![ghosting.png](ghosting.png) +![hourly_by_year.png](hourly_by_year.png) + ![hourly_radar.png](hourly_radar.png) ![length_trends.png](length_trends.png) diff --git a/examples/group_history.html b/examples/group_history.html new file mode 100644 index 0000000..a8b9d28 --- /dev/null +++ b/examples/group_history.html @@ -0,0 +1,47 @@ + + + +Saturday Squad - group history + + + +
    +

    Group history

    +

    0 changes the group made to itself. Currently called -.

    +

    What the group has called itself

    No renames.

    Nicknames

    No nicknames.

    Who changes things

    Comings and goings

    Nobody joined or left.

    +
    + + \ No newline at end of file diff --git a/examples/hourly_by_year.png b/examples/hourly_by_year.png new file mode 100644 index 0000000000000000000000000000000000000000..228297d1c393738b4d275f9e3807bdb9dd1f0d30 GIT binary patch literal 39576 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 z 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 TYvF}AR1O(QMTwGUMz8)4; + + +Saturday Squad - alice + + + +
    +

    Alice

    +

    Active 2017-01-01 to 2026-01-01.

    +
    26messages
    28%of the chat
    2017peak year
    21days spoken on

    Year by year

    The words are what set that year apart from this member's own other years, not what they said most.

    YearMessagesWords of that year
    20174week, usual, spot, shut, last, lads
    20183sleep, since, rip, looks, helmet, haircut
    20193wedding, trip, months, issues, shawarma, booked
    20204moment, means, lockdown, group, every, decided
    20213work, pregnant, news, moving, abroad, guys
    20222wishing, surprise, setting, same, party, own
    20232place, checked, shawarma, guys
    20242saw, mid, cringe
    20252already, booked, bro
    20261years, ten, chat, believe

    Who they answer

    MemberReplies
    Bob7
    Dana6
    Charlie5
    Member201720182019202020212022202320242025
    Bob110111020
    Charlie100201001
    Dana012010101

    Their most-reacted messages

    • 2 reactions · 2017-01-01 · "Happy new year lads!!"
    +
    + + \ No newline at end of file diff --git a/examples/member_bob.html b/examples/member_bob.html new file mode 100644 index 0000000..f968beb --- /dev/null +++ b/examples/member_bob.html @@ -0,0 +1,47 @@ + + + +Saturday Squad - bob + + + +
    +

    Bob

    +

    Active 2017-01-01 to 2026-01-01.

    +
    26messages
    28%of the chat
    2017peak year
    20days spoken on

    Year by year

    The words are what set that year apart from this member's own other years, not what they said most.

    YearMessagesWords of that year
    20174usual, spot, saturday, meet, lose, late
    20184wow, morning
    20192trip, money, marrying, charlie's, believe, weekend
    20203whole, traffic, stuck, started, shit, own
    20212pregnant, enough, close
    20223shut, send, said, party, legendary, full
    20232squad, reunion, real
    20243untrustworthy, tonight, thinking, seen, movie, group
    20252bro, booked, already
    20261years, ten, life, best

    Who they answer

    MemberReplies
    Alice12
    Dana3
    Charlie3
    Member2017201820192020202120222023202420252026
    Alice3111210021
    Charlie0000001200
    Dana0100011000

    Their most-reacted messages

    • 3 reactions · 2020-08-31 · "started my own thing, gonna be a whole business by 2021"
    • 3 reactions · 2023-09-01 · "THE SQUAD IS BACK"
    • 3 reactions · 2026-01-01 · "best ten years of my life"
    • 1 reactions · 2018-01-01 · "see you all in 2018"
    +
    + + \ No newline at end of file diff --git a/examples/member_charlie.html b/examples/member_charlie.html new file mode 100644 index 0000000..6e7cf38 --- /dev/null +++ b/examples/member_charlie.html @@ -0,0 +1,47 @@ + + + +Saturday Squad - charlie + + + +
    +

    Charlie

    +

    Active 2017-01-01 to 2026-01-01.

    +
    20messages
    21%of the chat
    2017peak year
    16days spoken on

    Year by year

    The words are what set that year apart from this member's own other years, not what they said most.

    YearMessagesWords of that year
    20173job, guys, coming
    20182nerd, morning, crew, bed
    20193shawarma, right, life, joking, donkey, changing
    20203tell, sick, please, man, filmed, bro
    20213sleep, matches, match, game, addictive, judge
    20221wish
    20231first
    20242valentine's, said, people, only, mid, love
    20251waiting, payday, judge
    20261many, here's

    Who they answer

    MemberReplies
    Bob5
    Alice5
    Dana2
    Member20172018201920202023202420252026
    Alice11101100
    Bob10110011
    Dana00020000

    Their most-reacted messages

    Nobody ever reacted to them.

    +
    + + \ No newline at end of file diff --git a/examples/member_dana.html b/examples/member_dana.html new file mode 100644 index 0000000..10b59df --- /dev/null +++ b/examples/member_dana.html @@ -0,0 +1,47 @@ + + + +Saturday Squad - dana + + + +
    +

    Dana

    +

    Active 2017-01-01 to 2026-01-01.

    +
    22messages
    23%of the chat
    2017peak year
    19days spoken on

    Year by year

    The words are what set that year apart from this member's own other years, not what they said most.

    YearMessagesWords of that year
    20173told, stressing, obv, interview, fine, brand
    20183merry, meeting, look, helmet, die, christmas
    20193though, spot, seriously, near, hotel, awake
    20203woke, wish, whole, tried, through, thats
    20211quiet, group, chat
    20222unexpectedly, send, said, full, deep, nobody
    20232years, thought, reunion, every, december, confirmed
    20241love
    20253trip, happening, flights, bro, book, week
    20261we'll, same, next, decade

    Who they answer

    MemberReplies
    Bob8
    Charlie4
    Alice2
    Member2017201820192020202120222023202420252026
    Alice0000010100
    Bob1111111010
    Charlie2001000001

    Their most-reacted messages

    • 3 reactions · 2018-12-25 · "merry christmas everyone"
    • 2 reactions · 2018-03-20 · "this meeting is so boring i might die"
    • 1 reactions · 2019-07-04 · "why is nobody awake"
    +
    + + \ No newline at end of file diff --git a/examples/quiz.html b/examples/quiz.html new file mode 100644 index 0000000..3ed54a7 --- /dev/null +++ b/examples/quiz.html @@ -0,0 +1,91 @@ + + + +Saturday Squad - guess who said this + + + +
    +

    Guess who said this

    +

    Every line below uses a word that gives its sender away. Press 1-4 or click.

    +
    +

    + + +
    + + \ No newline at end of file diff --git a/examples/relationships.html b/examples/relationships.html new file mode 100644 index 0000000..5839656 --- /dev/null +++ b/examples/relationships.html @@ -0,0 +1,47 @@ + + + +Saturday Squad - relationships + + + +
    +

    Relationships

    +

    4 members, 6 pairs that ever interacted.

    +

    Who interacts with whom

    A reply inside the hour, or a reaction.

    PairInteractions2017201820192020202120222023202420252026
    Alice + Bob245313321222
    Bob + Dana161412123011
    Alice + Charlie113112011110
    Bob + Charlie111012002212
    Alice + Dana100230111110
    Charlie + Dana82203000001

    Pairs that drifted

    Share of that member's own interaction, from the pair's peak year to 2025, the most recent year the export covers end to end. Pairs under 100 interactions in their peak year are left out.

    No pair moved by more than half.

    First to speak after a day of silence

    MemberTimes
    Bob7
    Alice7
    Dana7
    Charlie5

    Who gets the last word

    MemberSessions ended
    Dana11
    Alice7
    Charlie6
    Bob6

    Who goes unanswered

    Share of a member's own messages that nobody answered within the hour, so the loudest member does not win by volume.

    MemberUnansweredMessagesShare
    Dana102245.5%
    Charlie72035.0%
    Alice72626.9%
    Bob62623.1%
    +
    + + \ No newline at end of file diff --git a/examples/report.html b/examples/report.html index 9a0fe1a..1f9069c 100644 --- a/examples/report.html +++ b/examples/report.html @@ -13,7 +13,8 @@ nav{display:flex;gap:4px;flex-wrap:wrap;flex:1} nav a{color:var(--muted);text-decoration:none;font-size:12px;padding:4px 8px;border-radius:6px} nav a:hover{background:var(--border);color:var(--fg)} -.years{margin-left:auto;color:var(--muted);text-decoration:none;font-size:12px;padding:4px 8px;border:1px solid var(--border);border-radius:6px} +.pages{margin-left:auto;display:flex;gap:6px;flex-wrap:wrap} +.years{color:var(--muted);text-decoration:none;font-size:12px;padding:4px 8px;border:1px solid var(--border);border-radius:6px} #theme{border:1px solid var(--border);background:var(--bg);color:var(--fg);border-radius:6px;padding:4px 10px;cursor:pointer} main{max-width:1040px;margin:0 auto;padding:0 20px} h1{font-size:26px;margin-bottom:4px} @@ -35,18 +36,18 @@ @media(prefers-color-scheme:dark){:root:not([data-theme]){--bg:#17191f;--fg:#e8e8e8;--muted:#9aa0a6;--card:#20242d;--border:#2a2f3a}}

    Saturday Squad flashback

    -

    Generated 2026-08-06 20:14 +

    Generated 2026-08-07 09:31

    2017-01-01start
    2026-01-01end
    94messages
    4members
    509words
    1emojis
    18reactions
    3questions asked
    3swear messages
    4media
    4links shared
    0 (0 min)calls
    30conversations
    0copy-paste floods
    27active days
    3.5messages per active day
    1 dayslongest daily streak
    -

    Highlights

    • Alice leads the chat with 26 messages (28% of the chat).
    • The longest single session ran 7 messages.
    • Alice answers fastest with a 1.0 min median reply time.
    • Charlie holds the longest silent streak (634 days).
    • Charlie once went on a 3-message solo run.
    • Bob replies to Alice more than anyone (12 times).
    • Best vibes come from Bob (+0.09 avg sentiment).
    • The record day was 2025-08-09 with 7 messages.
    • The chat's favorite emoji is 😎 (1 uses).

    Leaderboard

    MemberMessagesShare
    Alice2627.7%
    Bob2627.7%
    Dana2223.4%
    Charlie2021.3%

    Member personalities

    MemberMessagesWords/msgPeak hourNight owl %Signature wordTop emojis
    Alice264.852:0046.2%guys😎
    Bob265.3117:0046.2%life-
    Charlie204.951:0040.0%said-
    Dana226.641:0045.5%week-

    Most reactive

    ReactorReactions
    Charlie6
    Alice6
    Bob3
    Dana3

    Response speed

    Median time to reply, fastest first. Ghosted % is the share of a member's turns that got no reply within an hour.

    MemberRepliesMedian replyReplies <5 minGhosted %
    Alice181.0 min83.3%26.9%
    Bob191.0 min94.7%23.1%
    Charlie121.0 min100.0%23.5%
    Dana141.0 min92.9%38.9%

    Swear-word analytics

    3 messages contain profanity.

    MemberSwear messagesSignature swear word
    Bob1shit
    Dana1hell
    Alice1damn

    Pair dynamics

    Replier \n Replied-toAliceBobCharlieDana
    Alice0756
    Bob12033
    Charlie5502
    Dana2840

    Conversation starters

    A conversation is split on a 30-minute gap. The chat had 30 separate sessions.

    MemberSessions started
    Alice9
    Dana8
    Bob7
    Charlie6

    Ghosting stats

    Longest silence between a member's own messages, in days.

    MemberLongest silence (days)
    Charlie634
    Dana541
    Bob478
    Alice423

    Monologues

    MemberLongest solo run
    Charlie3
    Dana2

    Hourly profiles

    MemberPeak hour
    Alice1:00
    Bob2:00
    Charlie1:00
    Dana1:00

    Message length trends

    YearAvg charsLongest
    201729.460
    201823.860
    201932.148
    202036.061
    202129.146
    202229.855
    202337.254
    202425.852
    202519.151
    202635.242

    Links and domains

    DomainShares
    youtube.com2
    github.com1
    open.spotify.com1

    Weirdest statements

    • Charlie (2023-12-21 00:01) [ALL CAPS, sent after midnight] "WE'RE GOING FIRST THING"
    • Alice (2021-12-01 02:02) [punctuation spiral, sent after midnight] "I'M PREGNANT??? no. im moving abroad for work."
    • Alice (2017-01-15 14:45) [ALL CAPS] "THE USUAL SPOT GOT SHUT DOWN LAST WEEK"
    • Charlie (2026-01-01 05:03) [punctuation spiral, sent after midnight] "here's to many more. let's go!!!"
    • Bob (2023-09-01 14:01) [ALL CAPS] "THE SQUAD IS BACK"
    • Alice (2017-06-15 19:01) [ALL CAPS] "CONGRATS BROOOOO"

    Extremes

    Longest message: Dana (61 chars) "i tried to bake bread and the oven smoked out the whole house"

    Most-reacted: Dana (3 reactions) "merry christmas everyone"

    Record day: 2025-08-09 (7 messages)

    Sentiment

    Scored 90 messages; English-only VADER, may be noisy on mixed-language chat.

    MemberAvg sentiment
    Bob+0.091
    Alice+0.084
    Charlie+0.076
    Dana+0.027

    Emoji report

    MemberEmojisEmojis/100Top emojis
    Alice13.8😎

    Question dynamics

    3 questions asked; 3 answered within an hour.

    MemberAskedAnsweredAnswer %ResponsesMedian answer
    Charlie22100.0%0n/a
    Alice11100.0%0n/a
    Bob00n/a130.0 min
    Dana00n/a227.0 min

    What the chat was about

    • 2017: usual (2), spot (2), happy (2), told (1), stressing (1), saturday (1)
    • 2018: morning (2), helmet (2), wow (1), since (1), rip (1), nerd (1)
    • 2019: shawarma (3), trip (2), life (2), wedding (1), though (1), seriously (1)
    • 2020: whole (2), hour (2), bro (2), woke (1), tried (1), traffic (1)
    • 2021: pregnant (2), work (1), quiet (1), news (1), moving (1), matches (1)
    • 2022: send (2), party (2), full (2), said (2), wishing (1), unexpectedly (1)
    • 2023: reunion (2), shawarma (2), thought (1), squad (1), real (1), place (1)
    • 2024: mid (2), love (2), valentine's (1), untrustworthy (1), tonight (1), thinking (1)
    • 2025: bro (3), already (2), booked (2), waiting (1), payday (1), happening (1)
    • 2026: ten (2), years (2), we'll (1), next (1), many (1), here's (1)

    Media leaderboard

    MemberPhotosStickersGIFsVideosAudioFiles
    Alice200000
    Charlie100000
    Dana100000

    Charts

    activity_by_hour.png
    When the chat is alive, by hour
    activity_by_weekday.png
    Busiest days of the week
    activity_heatmap.png
    Day-by-day activity calendar
    conversation_starters.png
    Who starts conversations
    emoji_timeline.png
    Favorite emojis over the years
    ghosting.png
    Longest silent streaks
    hourly_radar.png
    Hourly activity profiles
    length_trends.png
    Average message length
    media_by_year.png
    Media per year
    media_leaderboard.png
    Media sent per member
    messages_by_year.png
    Messages per year
    monologues.png
    Longest solo runs
    monthly_timeline.png
    Messages per month over the years
    most_reacted.png
    The most-reacted messages
    pace_trends.png
    Rolling average messages per day
    question_speed.png
    Median time to answer a question
    questions_asked.png
    Questions asked per member
    reaction_matrix.png
    Who reacts to whose messages
    reactions_given.png
    Who reacts the most
    reply_chains.png
    Longest reply chains
    reply_matrix.png
    Who replies to whom
    response_speed.png
    Median time to reply
    sentiment_over_time.png
    Mood over the years
    sentiment_per_member.png
    Average mood per member
    swear_by_member.png
    Swear words per member
    swear_over_time.png
    Swearing over the years
    top_domains.png
    Most shared domains
    top_emojis.png
    Favorite emojis
    top_members.png
    Who talks the most
    top_words.png
    Favorite words
    topics_by_year.png
    What the chat was about each year
    tracked_terms.png
    Custom tracked terms per year
    word_trends.png
    Top words over time
    wordcloud.png
    Overall word cloud
    wordcloud_alice.png
    wordcloud_alice.png
    wordcloud_bob.png
    wordcloud_bob.png
    wordcloud_charlie.png
    wordcloud_charlie.png
    wordcloud_dana.png
    wordcloud_dana.png
    yearly_recap.png
    Year-by-year recap
    +

    Highlights

    • Alice leads the chat with 26 messages (28% of the chat).
    • The longest single session ran 7 messages.
    • Alice answers fastest with a 1.0 min median reply time.
    • Charlie holds the longest silent streak (634 days).
    • Charlie once went on a 3-message solo run.
    • Bob replies to Alice more than anyone (12 times).
    • Best vibes come from Bob (+0.09 avg sentiment).
    • The record day was 2025-08-09 with 7 messages.
    • The chat's favorite emoji is 😎 (1 uses).

    Leaderboard

    MemberMessagesShare
    Alice2627.7%
    Bob2627.7%
    Dana2223.4%
    Charlie2021.3%

    Member personalities

    MemberMessagesWords/msgPeak hourNight owl %Signature wordTop emojis
    Alice264.852:0046.2%guys😎
    Bob265.3117:0046.2%life-
    Charlie204.951:0040.0%said-
    Dana226.641:0045.5%week-

    Most reactive

    ReactorReactions
    Charlie6
    Alice6
    Bob3
    Dana3

    Response speed

    Median time to reply, fastest first. Ghosted % is the share of a member's turns that got no reply within an hour.

    MemberRepliesMedian replyReplies <5 minGhosted %
    Alice181.0 min83.3%26.9%
    Bob191.0 min94.7%23.1%
    Charlie121.0 min100.0%23.5%
    Dana141.0 min92.9%38.9%

    Swear-word analytics

    3 messages contain profanity.

    MemberSwear messagesSignature swear word
    Bob1shit
    Dana1hell
    Alice1damn

    Pair dynamics

    Replier \n Replied-toAliceBobCharlieDana
    Alice0756
    Bob12033
    Charlie5502
    Dana2840

    Conversation starters

    A conversation is split on a 30-minute gap. The chat had 30 separate sessions.

    MemberSessions started
    Alice9
    Dana8
    Bob7
    Charlie6

    Ghosting stats

    Longest silence between a member's own messages, in days.

    MemberLongest silence (days)
    Charlie634
    Dana541
    Bob478
    Alice423

    Monologues

    MemberLongest solo run
    Charlie3
    Dana2

    Hourly profiles

    MemberPeak hour
    Alice1:00
    Bob2:00
    Charlie1:00
    Dana1:00

    Message length trends

    YearAvg charsLongest
    201729.460
    201823.860
    201932.148
    202036.061
    202129.146
    202229.855
    202337.254
    202425.852
    202519.151
    202635.242

    Links and domains

    DomainShares
    youtube.com2
    github.com1
    open.spotify.com1

    Weirdest statements

    • Charlie (2023-12-21 00:01) [ALL CAPS, sent after midnight] "WE'RE GOING FIRST THING"
    • Alice (2021-12-01 02:02) [punctuation spiral, sent after midnight] "I'M PREGNANT??? no. im moving abroad for work."
    • Alice (2017-01-15 14:45) [ALL CAPS] "THE USUAL SPOT GOT SHUT DOWN LAST WEEK"
    • Charlie (2026-01-01 05:03) [punctuation spiral, sent after midnight] "here's to many more. let's go!!!"
    • Bob (2023-09-01 14:01) [ALL CAPS] "THE SQUAD IS BACK"
    • Alice (2017-06-15 19:01) [ALL CAPS] "CONGRATS BROOOOO"

    Extremes

    Longest message: Dana (61 chars) "i tried to bake bread and the oven smoked out the whole house"

    Most-reacted: Dana (3 reactions) "merry christmas everyone"

    Record day: 2025-08-09 (7 messages)

    Sentiment

    Scored 90 messages; English-only VADER, may be noisy on mixed-language chat.

    MemberAvg sentiment
    Bob+0.091
    Alice+0.084
    Charlie+0.076
    Dana+0.027

    Emoji report

    MemberEmojisEmojis/100Top emojis
    Alice13.8😎

    Question dynamics

    3 questions asked; 3 answered within an hour.

    MemberAskedAnsweredAnswer %ResponsesMedian answer
    Charlie22100.0%0n/a
    Alice11100.0%0n/a
    Bob00n/a130.0 min
    Dana00n/a227.0 min

    What the chat was about

    • 2017: usual (2), spot (2), happy (2), told (1), stressing (1), saturday (1)
    • 2018: morning (2), helmet (2), wow (1), since (1), rip (1), nerd (1)
    • 2019: shawarma (3), trip (2), life (2), wedding (1), though (1), seriously (1)
    • 2020: whole (2), hour (2), bro (2), woke (1), tried (1), traffic (1)
    • 2021: pregnant (2), work (1), quiet (1), news (1), moving (1), matches (1)
    • 2022: send (2), party (2), full (2), said (2), wishing (1), unexpectedly (1)
    • 2023: reunion (2), shawarma (2), thought (1), squad (1), real (1), place (1)
    • 2024: mid (2), love (2), valentine's (1), untrustworthy (1), tonight (1), thinking (1)
    • 2025: bro (3), already (2), booked (2), waiting (1), payday (1), happening (1)
    • 2026: ten (2), years (2), we'll (1), next (1), many (1), here's (1)

    Media leaderboard

    MemberPhotosStickersGIFsVideosAudioFiles
    Alice200000
    Charlie100000
    Dana100000

    Charts

    activity_by_hour.png
    When the chat is alive, by hour
    activity_by_weekday.png
    Busiest days of the week
    activity_heatmap.png
    Day-by-day activity calendar
    conversation_starters.png
    Who starts conversations
    emoji_timeline.png
    Favorite emojis over the years
    ghosting.png
    Longest silent streaks
    hourly_by_year.png
    Each member's hours, year by year
    hourly_radar.png
    Hourly activity profiles
    length_trends.png
    Average message length
    media_by_year.png
    Media per year
    media_leaderboard.png
    Media sent per member
    messages_by_year.png
    Messages per year
    monologues.png
    Longest solo runs
    monthly_timeline.png
    Messages per month over the years
    most_reacted.png
    The most-reacted messages
    pace_trends.png
    Rolling average messages per day
    question_speed.png
    Median time to answer a question
    questions_asked.png
    Questions asked per member
    reaction_matrix.png
    Who reacts to whose messages
    reactions_given.png
    Who reacts the most
    reply_chains.png
    Longest reply chains
    reply_matrix.png
    Who replies to whom
    response_speed.png
    Median time to reply
    sentiment_over_time.png
    Mood over the years
    sentiment_per_member.png
    Average mood per member
    swear_by_member.png
    Swear words per member
    swear_over_time.png
    Swearing over the years
    top_domains.png
    Most shared domains
    top_emojis.png
    Favorite emojis
    top_members.png
    Who talks the most
    top_words.png
    Favorite words
    topics_by_year.png
    What the chat was about each year
    tracked_terms.png
    Custom tracked terms per year
    word_trends.png
    Top words over time
    wordcloud.png
    Overall word cloud
    wordcloud_alice.png
    wordcloud_alice.png
    wordcloud_bob.png
    wordcloud_bob.png
    wordcloud_charlie.png
    wordcloud_charlie.png
    wordcloud_dana.png
    wordcloud_dana.png
    yearly_recap.png
    Year-by-year recap
    + \ No newline at end of file diff --git a/examples/summary.json b/examples/summary.json index 4e37e3a..d2f4312 100644 --- a/examples/summary.json +++ b/examples/summary.json @@ -1,6 +1,6 @@ { "title": "Saturday Squad", - "generated": "2026-08-06 20:14", + "generated": "2026-08-07 09:31", "anonymized": false, "period": { "start": "2017-01-01", @@ -26,7 +26,1285 @@ }, "total_words": 509, "active_days": 27, - "members": 4, + "members": { + "Alice": { + "member": "Alice", + "total": 26, + "share": 28, + "first": "2017-01-01", + "last": "2026-01-01", + "active_days": 21, + "by_year": { + "2017": 4, + "2018": 3, + "2019": 3, + "2020": 4, + "2021": 3, + "2022": 2, + "2023": 2, + "2024": 2, + "2025": 2, + "2026": 1 + }, + "peak_year": 2017, + "words_by_year": { + "2017": [ + { + "word": "week", + "score": 0.3002, + "count": 1 + }, + { + "word": "usual", + "score": 0.3002, + "count": 1 + }, + { + "word": "spot", + "score": 0.3002, + "count": 1 + }, + { + "word": "shut", + "score": 0.3002, + "count": 1 + }, + { + "word": "last", + "score": 0.3002, + "count": 1 + }, + { + "word": "lads", + "score": 0.3002, + "count": 1 + } + ], + "2018": [ + { + "word": "sleep", + "score": 0.367, + "count": 1 + }, + { + "word": "since", + "score": 0.367, + "count": 1 + }, + { + "word": "rip", + "score": 0.367, + "count": 1 + }, + { + "word": "looks", + "score": 0.367, + "count": 1 + }, + { + "word": "helmet", + "score": 0.367, + "count": 1 + }, + { + "word": "haircut", + "score": 0.367, + "count": 1 + } + ], + "2019": [ + { + "word": "wedding", + "score": 0.5504, + "count": 1 + }, + { + "word": "trip", + "score": 0.5504, + "count": 1 + }, + { + "word": "months", + "score": 0.5504, + "count": 1 + }, + { + "word": "issues", + "score": 0.5504, + "count": 1 + }, + { + "word": "shawarma", + "score": 0.4349, + "count": 1 + }, + { + "word": "booked", + "score": 0.4349, + "count": 1 + } + ], + "2020": [ + { + "word": "moment", + "score": 0.2752, + "count": 1 + }, + { + "word": "means", + "score": 0.2752, + "count": 1 + }, + { + "word": "lockdown", + "score": 0.2752, + "count": 1 + }, + { + "word": "group", + "score": 0.2752, + "count": 1 + }, + { + "word": "every", + "score": 0.2752, + "count": 1 + }, + { + "word": "decided", + "score": 0.2752, + "count": 1 + } + ], + "2021": [ + { + "word": "work", + "score": 0.5504, + "count": 1 + }, + { + "word": "pregnant", + "score": 0.5504, + "count": 1 + }, + { + "word": "news", + "score": 0.5504, + "count": 1 + }, + { + "word": "moving", + "score": 0.5504, + "count": 1 + }, + { + "word": "abroad", + "score": 0.5504, + "count": 1 + }, + { + "word": "guys", + "score": 0.4349, + "count": 1 + } + ], + "2022": [ + { + "word": "wishing", + "score": 0.3002, + "count": 1 + }, + { + "word": "surprise", + "score": 0.3002, + "count": 1 + }, + { + "word": "setting", + "score": 0.3002, + "count": 1 + }, + { + "word": "same", + "score": 0.3002, + "count": 1 + }, + { + "word": "party", + "score": 0.3002, + "count": 1 + }, + { + "word": "own", + "score": 0.3002, + "count": 1 + } + ], + "2023": [ + { + "word": "place", + "score": 0.8256, + "count": 1 + }, + { + "word": "checked", + "score": 0.8256, + "count": 1 + }, + { + "word": "shawarma", + "score": 0.6524, + "count": 1 + }, + { + "word": "guys", + "score": 0.6524, + "count": 1 + } + ], + "2024": [ + { + "word": "saw", + "score": 1.1009, + "count": 1 + }, + { + "word": "mid", + "score": 1.1009, + "count": 1 + }, + { + "word": "cringe", + "score": 1.1009, + "count": 1 + } + ], + "2025": [ + { + "word": "already", + "score": 1.1009, + "count": 1 + }, + { + "word": "booked", + "score": 0.8698, + "count": 1 + }, + { + "word": "bro", + "score": 0.7347, + "count": 1 + } + ], + "2026": [ + { + "word": "years", + "score": 0.8256, + "count": 1 + }, + { + "word": "ten", + "score": 0.8256, + "count": 1 + }, + { + "word": "chat", + "score": 0.8256, + "count": 1 + }, + { + "word": "believe", + "score": 0.8256, + "count": 1 + } + ] + }, + "talks_to": { + "2017": { + "Bob": 1, + "Charlie": 1 + }, + "2018": { + "Dana": 1, + "Bob": 1 + }, + "2019": { + "Dana": 2 + }, + "2020": { + "Bob": 1, + "Charlie": 2 + }, + "2021": { + "Bob": 1, + "Dana": 1 + }, + "2022": { + "Bob": 1, + "Charlie": 1 + }, + "2023": { + "Dana": 1 + }, + "2024": { + "Bob": 2 + }, + "2025": { + "Dana": 1, + "Charlie": 1 + } + }, + "closest": [ + [ + "Bob", + 7 + ], + [ + "Dana", + 6 + ], + [ + "Charlie", + 5 + ] + ] + }, + "Bob": { + "member": "Bob", + "total": 26, + "share": 28, + "first": "2017-01-01", + "last": "2026-01-01", + "active_days": 20, + "by_year": { + "2017": 4, + "2018": 4, + "2019": 2, + "2020": 3, + "2021": 2, + "2022": 3, + "2023": 2, + "2024": 3, + "2025": 2, + "2026": 1 + }, + "peak_year": 2017, + "words_by_year": { + "2017": [ + { + "word": "usual", + "score": 0.254, + "count": 1 + }, + { + "word": "spot", + "score": 0.254, + "count": 1 + }, + { + "word": "saturday", + "score": 0.254, + "count": 1 + }, + { + "word": "meet", + "score": 0.254, + "count": 1 + }, + { + "word": "lose", + "score": 0.254, + "count": 1 + }, + { + "word": "late", + "score": 0.254, + "count": 1 + } + ], + "2018": [ + { + "word": "wow", + "score": 1.6513, + "count": 1 + }, + { + "word": "morning", + "score": 1.6513, + "count": 1 + } + ], + "2019": [ + { + "word": "trip", + "score": 0.4128, + "count": 1 + }, + { + "word": "money", + "score": 0.4128, + "count": 1 + }, + { + "word": "marrying", + "score": 0.4128, + "count": 1 + }, + { + "word": "charlie's", + "score": 0.4128, + "count": 1 + }, + { + "word": "believe", + "score": 0.4128, + "count": 1 + }, + { + "word": "weekend", + "score": 0.3262, + "count": 1 + } + ], + "2020": [ + { + "word": "whole", + "score": 0.367, + "count": 1 + }, + { + "word": "traffic", + "score": 0.367, + "count": 1 + }, + { + "word": "stuck", + "score": 0.367, + "count": 1 + }, + { + "word": "started", + "score": 0.367, + "count": 1 + }, + { + "word": "shit", + "score": 0.367, + "count": 1 + }, + { + "word": "own", + "score": 0.367, + "count": 1 + } + ], + "2021": [ + { + "word": "pregnant", + "score": 1.1009, + "count": 1 + }, + { + "word": "enough", + "score": 1.1009, + "count": 1 + }, + { + "word": "close", + "score": 1.1009, + "count": 1 + } + ], + "2022": [ + { + "word": "shut", + "score": 0.4128, + "count": 1 + }, + { + "word": "send", + "score": 0.4128, + "count": 1 + }, + { + "word": "said", + "score": 0.4128, + "count": 1 + }, + { + "word": "party", + "score": 0.4128, + "count": 1 + }, + { + "word": "legendary", + "score": 0.4128, + "count": 1 + }, + { + "word": "full", + "score": 0.4128, + "count": 1 + } + ], + "2023": [ + { + "word": "squad", + "score": 1.1009, + "count": 1 + }, + { + "word": "reunion", + "score": 1.1009, + "count": 1 + }, + { + "word": "real", + "score": 1.1009, + "count": 1 + } + ], + "2024": [ + { + "word": "untrustworthy", + "score": 0.4128, + "count": 1 + }, + { + "word": "tonight", + "score": 0.4128, + "count": 1 + }, + { + "word": "thinking", + "score": 0.4128, + "count": 1 + }, + { + "word": "seen", + "score": 0.4128, + "count": 1 + }, + { + "word": "movie", + "score": 0.4128, + "count": 1 + }, + { + "word": "group", + "score": 0.4128, + "count": 1 + } + ], + "2025": [ + { + "word": "bro", + "score": 1.1009, + "count": 1 + }, + { + "word": "booked", + "score": 1.1009, + "count": 1 + }, + { + "word": "already", + "score": 1.1009, + "count": 1 + } + ], + "2026": [ + { + "word": "years", + "score": 0.8256, + "count": 1 + }, + { + "word": "ten", + "score": 0.8256, + "count": 1 + }, + { + "word": "life", + "score": 0.6524, + "count": 1 + }, + { + "word": "best", + "score": 0.6524, + "count": 1 + } + ] + }, + "talks_to": { + "2017": { + "Alice": 3 + }, + "2018": { + "Alice": 1, + "Dana": 1 + }, + "2019": { + "Alice": 1 + }, + "2020": { + "Alice": 1 + }, + "2021": { + "Alice": 2 + }, + "2022": { + "Dana": 1, + "Alice": 1 + }, + "2023": { + "Dana": 1, + "Charlie": 1 + }, + "2024": { + "Charlie": 2 + }, + "2025": { + "Alice": 2 + }, + "2026": { + "Alice": 1 + } + }, + "closest": [ + [ + "Alice", + 12 + ], + [ + "Dana", + 3 + ], + [ + "Charlie", + 3 + ] + ] + }, + "Dana": { + "member": "Dana", + "total": 22, + "share": 23, + "first": "2017-01-01", + "last": "2026-01-01", + "active_days": 19, + "by_year": { + "2017": 3, + "2018": 3, + "2019": 3, + "2020": 3, + "2021": 1, + "2022": 2, + "2023": 2, + "2024": 1, + "2025": 3, + "2026": 1 + }, + "peak_year": 2017, + "words_by_year": { + "2017": [ + { + "word": "told", + "score": 0.4718, + "count": 1 + }, + { + "word": "stressing", + "score": 0.4718, + "count": 1 + }, + { + "word": "obv", + "score": 0.4718, + "count": 1 + }, + { + "word": "interview", + "score": 0.4718, + "count": 1 + }, + { + "word": "fine", + "score": 0.4718, + "count": 1 + }, + { + "word": "brand", + "score": 0.4718, + "count": 1 + } + ], + "2018": [ + { + "word": "merry", + "score": 0.4128, + "count": 1 + }, + { + "word": "meeting", + "score": 0.4128, + "count": 1 + }, + { + "word": "look", + "score": 0.4128, + "count": 1 + }, + { + "word": "helmet", + "score": 0.4128, + "count": 1 + }, + { + "word": "die", + "score": 0.4128, + "count": 1 + }, + { + "word": "christmas", + "score": 0.4128, + "count": 1 + } + ], + "2019": [ + { + "word": "though", + "score": 0.4128, + "count": 1 + }, + { + "word": "spot", + "score": 0.4128, + "count": 1 + }, + { + "word": "seriously", + "score": 0.4128, + "count": 1 + }, + { + "word": "near", + "score": 0.4128, + "count": 1 + }, + { + "word": "hotel", + "score": 0.4128, + "count": 1 + }, + { + "word": "awake", + "score": 0.4128, + "count": 1 + } + ], + "2020": [ + { + "word": "woke", + "score": 0.1651, + "count": 1 + }, + { + "word": "wish", + "score": 0.1651, + "count": 1 + }, + { + "word": "whole", + "score": 0.1651, + "count": 1 + }, + { + "word": "tried", + "score": 0.1651, + "count": 1 + }, + { + "word": "through", + "score": 0.1651, + "count": 1 + }, + { + "word": "thats", + "score": 0.1651, + "count": 1 + } + ], + "2021": [ + { + "word": "quiet", + "score": 1.1009, + "count": 1 + }, + { + "word": "group", + "score": 1.1009, + "count": 1 + }, + { + "word": "chat", + "score": 1.1009, + "count": 1 + } + ], + "2022": [ + { + "word": "unexpectedly", + "score": 0.5504, + "count": 1 + }, + { + "word": "send", + "score": 0.5504, + "count": 1 + }, + { + "word": "said", + "score": 0.5504, + "count": 1 + }, + { + "word": "full", + "score": 0.5504, + "count": 1 + }, + { + "word": "deep", + "score": 0.5504, + "count": 1 + }, + { + "word": "nobody", + "score": 0.4349, + "count": 1 + } + ], + "2023": [ + { + "word": "years", + "score": 0.3303, + "count": 1 + }, + { + "word": "thought", + "score": 0.3303, + "count": 1 + }, + { + "word": "reunion", + "score": 0.3303, + "count": 1 + }, + { + "word": "every", + "score": 0.3303, + "count": 1 + }, + { + "word": "december", + "score": 0.3303, + "count": 1 + }, + { + "word": "confirmed", + "score": 0.3303, + "count": 1 + } + ], + "2024": [ + { + "word": "love", + "score": 3.3026, + "count": 1 + } + ], + "2025": [ + { + "word": "trip", + "score": 0.5504, + "count": 1 + }, + { + "word": "happening", + "score": 0.5504, + "count": 1 + }, + { + "word": "flights", + "score": 0.5504, + "count": 1 + }, + { + "word": "bro", + "score": 0.5504, + "count": 1 + }, + { + "word": "book", + "score": 0.5504, + "count": 1 + }, + { + "word": "week", + "score": 0.4349, + "count": 1 + } + ], + "2026": [ + { + "word": "we'll", + "score": 0.8256, + "count": 1 + }, + { + "word": "same", + "score": 0.8256, + "count": 1 + }, + { + "word": "next", + "score": 0.8256, + "count": 1 + }, + { + "word": "decade", + "score": 0.8256, + "count": 1 + } + ] + }, + "talks_to": { + "2017": { + "Charlie": 2, + "Bob": 1 + }, + "2018": { + "Bob": 1 + }, + "2019": { + "Bob": 1 + }, + "2020": { + "Charlie": 1, + "Bob": 1 + }, + "2021": { + "Bob": 1 + }, + "2022": { + "Bob": 1, + "Alice": 1 + }, + "2023": { + "Bob": 1 + }, + "2024": { + "Alice": 1 + }, + "2025": { + "Bob": 1 + }, + "2026": { + "Charlie": 1 + } + }, + "closest": [ + [ + "Bob", + 8 + ], + [ + "Charlie", + 4 + ], + [ + "Alice", + 2 + ] + ] + }, + "Charlie": { + "member": "Charlie", + "total": 20, + "share": 21, + "first": "2017-01-01", + "last": "2026-01-01", + "active_days": 16, + "by_year": { + "2017": 3, + "2018": 2, + "2019": 3, + "2020": 3, + "2021": 3, + "2022": 1, + "2023": 1, + "2024": 2, + "2025": 1, + "2026": 1 + }, + "peak_year": 2017, + "words_by_year": { + "2017": [ + { + "word": "job", + "score": 1.1009, + "count": 1 + }, + { + "word": "guys", + "score": 1.1009, + "count": 1 + }, + { + "word": "coming", + "score": 1.1009, + "count": 1 + } + ], + "2018": [ + { + "word": "nerd", + "score": 0.8256, + "count": 1 + }, + { + "word": "morning", + "score": 0.8256, + "count": 1 + }, + { + "word": "crew", + "score": 0.8256, + "count": 1 + }, + { + "word": "bed", + "score": 0.8256, + "count": 1 + } + ], + "2019": [ + { + "word": "shawarma", + "score": 0.4718, + "count": 1 + }, + { + "word": "right", + "score": 0.4718, + "count": 1 + }, + { + "word": "life", + "score": 0.4718, + "count": 1 + }, + { + "word": "joking", + "score": 0.4718, + "count": 1 + }, + { + "word": "donkey", + "score": 0.4718, + "count": 1 + }, + { + "word": "changing", + "score": 0.4718, + "count": 1 + } + ], + "2020": [ + { + "word": "tell", + "score": 0.5504, + "count": 1 + }, + { + "word": "sick", + "score": 0.5504, + "count": 1 + }, + { + "word": "please", + "score": 0.5504, + "count": 1 + }, + { + "word": "man", + "score": 0.5504, + "count": 1 + }, + { + "word": "filmed", + "score": 0.5504, + "count": 1 + }, + { + "word": "bro", + "score": 0.5504, + "count": 1 + } + ], + "2021": [ + { + "word": "sleep", + "score": 0.5504, + "count": 1 + }, + { + "word": "matches", + "score": 0.5504, + "count": 1 + }, + { + "word": "match", + "score": 0.5504, + "count": 1 + }, + { + "word": "game", + "score": 0.5504, + "count": 1 + }, + { + "word": "addictive", + "score": 0.5504, + "count": 1 + }, + { + "word": "judge", + "score": 0.4349, + "count": 1 + } + ], + "2022": [ + { + "word": "wish", + "score": 3.3026, + "count": 1 + } + ], + "2023": [ + { + "word": "first", + "score": 3.3026, + "count": 1 + } + ], + "2024": [ + { + "word": "valentine's", + "score": 0.4718, + "count": 1 + }, + { + "word": "said", + "score": 0.4718, + "count": 1 + }, + { + "word": "people", + "score": 0.4718, + "count": 1 + }, + { + "word": "only", + "score": 0.4718, + "count": 1 + }, + { + "word": "mid", + "score": 0.4718, + "count": 1 + }, + { + "word": "love", + "score": 0.4718, + "count": 1 + } + ], + "2025": [ + { + "word": "waiting", + "score": 1.1009, + "count": 1 + }, + { + "word": "payday", + "score": 1.1009, + "count": 1 + }, + { + "word": "judge", + "score": 0.8698, + "count": 1 + } + ], + "2026": [ + { + "word": "many", + "score": 1.6513, + "count": 1 + }, + { + "word": "here's", + "score": 1.6513, + "count": 1 + } + ] + }, + "talks_to": { + "2017": { + "Bob": 1, + "Alice": 1 + }, + "2018": { + "Alice": 1 + }, + "2019": { + "Bob": 1, + "Alice": 1 + }, + "2020": { + "Dana": 2, + "Bob": 1 + }, + "2023": { + "Alice": 1 + }, + "2024": { + "Alice": 1 + }, + "2025": { + "Bob": 1 + }, + "2026": { + "Bob": 1 + } + }, + "closest": [ + [ + "Bob", + 5 + ], + [ + "Alice", + 5 + ], + [ + "Dana", + 2 + ] + ] + } + }, "longest_streak_days": 1, "media": 4, "calls": 0, @@ -1720,5 +2998,226 @@ "2025": 3 } } + }, + "group_history": { + "current_name": null, + "names": [], + "nicknames": {}, + "membership": [], + "busiest": {}, + "kinds": {} + }, + "relationships": { + "pairs": [ + { + "pair": [ + "Alice", + "Bob" + ], + "total": 24, + "by_year": { + "2017": 5, + "2018": 3, + "2019": 1, + "2020": 3, + "2021": 3, + "2022": 2, + "2024": 2, + "2025": 2, + "2026": 2, + "2023": 1 + }, + "peak_year": 2017 + }, + { + "pair": [ + "Bob", + "Dana" + ], + "total": 16, + "by_year": { + "2017": 1, + "2018": 4, + "2019": 1, + "2020": 2, + "2021": 1, + "2022": 2, + "2023": 3, + "2025": 1, + "2026": 1 + }, + "peak_year": 2018 + }, + { + "pair": [ + "Alice", + "Charlie" + ], + "total": 11, + "by_year": { + "2017": 3, + "2018": 1, + "2019": 1, + "2020": 2, + "2022": 1, + "2023": 1, + "2024": 1, + "2025": 1 + }, + "peak_year": 2017 + }, + { + "pair": [ + "Bob", + "Charlie" + ], + "total": 11, + "by_year": { + "2017": 1, + "2019": 1, + "2020": 2, + "2023": 2, + "2024": 2, + "2025": 1, + "2026": 2 + }, + "peak_year": 2020 + }, + { + "pair": [ + "Alice", + "Dana" + ], + "total": 10, + "by_year": { + "2018": 2, + "2019": 3, + "2021": 1, + "2022": 1, + "2023": 1, + "2024": 1, + "2025": 1 + }, + "peak_year": 2019 + }, + { + "pair": [ + "Charlie", + "Dana" + ], + "total": 8, + "by_year": { + "2017": 2, + "2020": 3, + "2026": 1, + "2018": 2 + }, + "peak_year": 2020 + } + ], + "drift": [], + "first_after_silence": { + "Bob": 7, + "Alice": 7, + "Charlie": 5, + "Dana": 7 + }, + "last_word": { + "Charlie": 6, + "Dana": 11, + "Bob": 6, + "Alice": 7 + }, + "ignored": [ + { + "member": "Dana", + "unanswered": 10, + "messages": 22, + "pct": 45.5 + }, + { + "member": "Charlie", + "unanswered": 7, + "messages": 20, + "pct": 35.0 + }, + { + "member": "Alice", + "unanswered": 7, + "messages": 26, + "pct": 26.9 + }, + { + "member": "Bob", + "unanswered": 6, + "messages": 26, + "pct": 23.1 + } + ] + }, + "eras": [ + { + "start": "2017-01", + "end": "2026-01", + "months": 109, + "messages": 94, + "name": "bro", + "words": [ + { + "word": "bro", + "count": 6, + "lift": 1.0 + }, + { + "word": "shawarma", + "count": 5, + "lift": 1.0 + } + ], + "top_member": "Alice" + } + ], + "vocabulary_turnover": { + "born": { + "2019": [ + { + "word": "shawarma", + "count": 5 + } + ], + "2020": [ + { + "word": "bro", + "count": 6 + } + ] + }, + "died": { + "2023": [ + { + "word": "shawarma", + "count": 5 + } + ], + "2025": [ + { + "word": "bro", + "count": 6 + } + ] + } + }, + "trendsetters": { + "members": [], + "words": [ + { + "word": "bro", + "member": "Charlie", + "uses": 6, + "first": "2020-08-31", + "adopters": 3, + "days": 1803 + } + ] } } \ No newline at end of file diff --git a/examples/trendsetters.html b/examples/trendsetters.html new file mode 100644 index 0000000..bea16c2 --- /dev/null +++ b/examples/trendsetters.html @@ -0,0 +1,47 @@ + + + +Saturday Squad - trendsetters + + + +
    +

    Trendsetters

    +

    1 of the 12 words in the band were picked up by 3 members or more.

    +

    Who starts the words

    Only words the chat used between 3 and 50 times, and only when at least 3 other members said them afterwards. A word first said in the chat's opening 90 days does not count as started: the export beginning is not the same as the word being new (0 left out that way).

    Divided by how much each member says, so a chatty member cannot win on volume alone. Members under 100 messages are left out, since a rate needs a denominator worth dividing by.

    Nobody started a word that caught on.

    The words that caught on

    WordStarted byFirst saidPicked up byTypical waitUses
    broCharlie2020-08-313 others59 months6
    +
    + + \ No newline at end of file diff --git a/examples/year_2017.html b/examples/year_2017.html index f9bc307..0b91b62 100644 --- a/examples/year_2017.html +++ b/examples/year_2017.html @@ -28,6 +28,8 @@ th,td{border:1px solid #e3e5e8;padding:6px 10px;text-align:left;font-size:13px} [data-theme="dark"] th,[data-theme="dark"] td{border-color:#2a2f3a} #theme{border:1px solid #e3e5e8;background:#fff;color:#202124;border-radius:6px;padding:4px 10px;cursor:pointer;margin-left:auto} +.quote{border-left:3px solid #e3e5e8;background:#f7f8fa;border-radius:4px;padding:6px 10px;margin:6px 0;font-size:13px} +[data-theme="dark"] .quote{border-left-color:#2a2f3a;background:#20242d}
    Saturday Squad flashback diff --git a/examples/year_2018.html b/examples/year_2018.html index 678d9fa..f2d8b84 100644 --- a/examples/year_2018.html +++ b/examples/year_2018.html @@ -28,6 +28,8 @@ th,td{border:1px solid #e3e5e8;padding:6px 10px;text-align:left;font-size:13px} [data-theme="dark"] th,[data-theme="dark"] td{border-color:#2a2f3a} #theme{border:1px solid #e3e5e8;background:#fff;color:#202124;border-radius:6px;padding:4px 10px;cursor:pointer;margin-left:auto} +.quote{border-left:3px solid #e3e5e8;background:#f7f8fa;border-radius:4px;padding:6px 10px;margin:6px 0;font-size:13px} +[data-theme="dark"] .quote{border-left-color:#2a2f3a;background:#20242d}
    Saturday Squad flashback diff --git a/examples/year_2019.html b/examples/year_2019.html index f4d5920..84e9d4a 100644 --- a/examples/year_2019.html +++ b/examples/year_2019.html @@ -28,6 +28,8 @@ th,td{border:1px solid #e3e5e8;padding:6px 10px;text-align:left;font-size:13px} [data-theme="dark"] th,[data-theme="dark"] td{border-color:#2a2f3a} #theme{border:1px solid #e3e5e8;background:#fff;color:#202124;border-radius:6px;padding:4px 10px;cursor:pointer;margin-left:auto} +.quote{border-left:3px solid #e3e5e8;background:#f7f8fa;border-radius:4px;padding:6px 10px;margin:6px 0;font-size:13px} +[data-theme="dark"] .quote{border-left-color:#2a2f3a;background:#20242d}
    Saturday Squad flashback diff --git a/examples/year_2020.html b/examples/year_2020.html index b763422..bff9c37 100644 --- a/examples/year_2020.html +++ b/examples/year_2020.html @@ -28,6 +28,8 @@ th,td{border:1px solid #e3e5e8;padding:6px 10px;text-align:left;font-size:13px} [data-theme="dark"] th,[data-theme="dark"] td{border-color:#2a2f3a} #theme{border:1px solid #e3e5e8;background:#fff;color:#202124;border-radius:6px;padding:4px 10px;cursor:pointer;margin-left:auto} +.quote{border-left:3px solid #e3e5e8;background:#f7f8fa;border-radius:4px;padding:6px 10px;margin:6px 0;font-size:13px} +[data-theme="dark"] .quote{border-left-color:#2a2f3a;background:#20242d}
    Saturday Squad flashback diff --git a/examples/year_2021.html b/examples/year_2021.html index 8db161b..0960882 100644 --- a/examples/year_2021.html +++ b/examples/year_2021.html @@ -28,6 +28,8 @@ th,td{border:1px solid #e3e5e8;padding:6px 10px;text-align:left;font-size:13px} [data-theme="dark"] th,[data-theme="dark"] td{border-color:#2a2f3a} #theme{border:1px solid #e3e5e8;background:#fff;color:#202124;border-radius:6px;padding:4px 10px;cursor:pointer;margin-left:auto} +.quote{border-left:3px solid #e3e5e8;background:#f7f8fa;border-radius:4px;padding:6px 10px;margin:6px 0;font-size:13px} +[data-theme="dark"] .quote{border-left-color:#2a2f3a;background:#20242d}
    Saturday Squad flashback diff --git a/examples/year_2022.html b/examples/year_2022.html index dd9e9fa..9ae273b 100644 --- a/examples/year_2022.html +++ b/examples/year_2022.html @@ -28,6 +28,8 @@ th,td{border:1px solid #e3e5e8;padding:6px 10px;text-align:left;font-size:13px} [data-theme="dark"] th,[data-theme="dark"] td{border-color:#2a2f3a} #theme{border:1px solid #e3e5e8;background:#fff;color:#202124;border-radius:6px;padding:4px 10px;cursor:pointer;margin-left:auto} +.quote{border-left:3px solid #e3e5e8;background:#f7f8fa;border-radius:4px;padding:6px 10px;margin:6px 0;font-size:13px} +[data-theme="dark"] .quote{border-left-color:#2a2f3a;background:#20242d}
    Saturday Squad flashback diff --git a/examples/year_2023.html b/examples/year_2023.html index 5c0aa74..473579e 100644 --- a/examples/year_2023.html +++ b/examples/year_2023.html @@ -28,6 +28,8 @@ th,td{border:1px solid #e3e5e8;padding:6px 10px;text-align:left;font-size:13px} [data-theme="dark"] th,[data-theme="dark"] td{border-color:#2a2f3a} #theme{border:1px solid #e3e5e8;background:#fff;color:#202124;border-radius:6px;padding:4px 10px;cursor:pointer;margin-left:auto} +.quote{border-left:3px solid #e3e5e8;background:#f7f8fa;border-radius:4px;padding:6px 10px;margin:6px 0;font-size:13px} +[data-theme="dark"] .quote{border-left-color:#2a2f3a;background:#20242d}
    Saturday Squad flashback diff --git a/examples/year_2024.html b/examples/year_2024.html index fa3e3e0..20a4f20 100644 --- a/examples/year_2024.html +++ b/examples/year_2024.html @@ -28,6 +28,8 @@ th,td{border:1px solid #e3e5e8;padding:6px 10px;text-align:left;font-size:13px} [data-theme="dark"] th,[data-theme="dark"] td{border-color:#2a2f3a} #theme{border:1px solid #e3e5e8;background:#fff;color:#202124;border-radius:6px;padding:4px 10px;cursor:pointer;margin-left:auto} +.quote{border-left:3px solid #e3e5e8;background:#f7f8fa;border-radius:4px;padding:6px 10px;margin:6px 0;font-size:13px} +[data-theme="dark"] .quote{border-left-color:#2a2f3a;background:#20242d}
    Saturday Squad flashback diff --git a/examples/year_2025.html b/examples/year_2025.html index de47930..713ae1d 100644 --- a/examples/year_2025.html +++ b/examples/year_2025.html @@ -28,6 +28,8 @@ th,td{border:1px solid #e3e5e8;padding:6px 10px;text-align:left;font-size:13px} [data-theme="dark"] th,[data-theme="dark"] td{border-color:#2a2f3a} #theme{border:1px solid #e3e5e8;background:#fff;color:#202124;border-radius:6px;padding:4px 10px;cursor:pointer;margin-left:auto} +.quote{border-left:3px solid #e3e5e8;background:#f7f8fa;border-radius:4px;padding:6px 10px;margin:6px 0;font-size:13px} +[data-theme="dark"] .quote{border-left-color:#2a2f3a;background:#20242d}
    Saturday Squad flashback diff --git a/examples/year_2026.html b/examples/year_2026.html index 78b71b9..282ad68 100644 --- a/examples/year_2026.html +++ b/examples/year_2026.html @@ -28,6 +28,8 @@ th,td{border:1px solid #e3e5e8;padding:6px 10px;text-align:left;font-size:13px} [data-theme="dark"] th,[data-theme="dark"] td{border-color:#2a2f3a} #theme{border:1px solid #e3e5e8;background:#fff;color:#202124;border-radius:6px;padding:4px 10px;cursor:pointer;margin-left:auto} +.quote{border-left:3px solid #e3e5e8;background:#f7f8fa;border-radius:4px;padding:6px 10px;margin:6px 0;font-size:13px} +[data-theme="dark"] .quote{border-left-color:#2a2f3a;background:#20242d}
    Saturday Squad flashback diff --git a/examples/year_in_review.html b/examples/year_in_review.html index 4e336c4..47d6fb9 100644 --- a/examples/year_in_review.html +++ b/examples/year_in_review.html @@ -28,6 +28,8 @@ th,td{border:1px solid #e3e5e8;padding:6px 10px;text-align:left;font-size:13px} [data-theme="dark"] th,[data-theme="dark"] td{border-color:#2a2f3a} #theme{border:1px solid #e3e5e8;background:#fff;color:#202124;border-radius:6px;padding:4px 10px;cursor:pointer;margin-left:auto} +.quote{border-left:3px solid #e3e5e8;background:#f7f8fa;border-radius:4px;padding:6px 10px;margin:6px 0;font-size:13px} +[data-theme="dark"] .quote{border-left-color:#2a2f3a;background:#20242d}
    Saturday Squad flashback From 6042266e407bd2ee43c5a81488dc2ee75b986a1f Mon Sep 17 00:00:00 2001 From: Ammar Hassan Date: Fri, 7 Aug 2026 10:31:27 +0500 Subject: [PATCH 8/8] Rewrite the README as a simple step-by-step guide --- README.md | 683 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 353 insertions(+), 330 deletions(-) diff --git a/README.md b/README.md index 6c90f9d..2436a99 100644 --- a/README.md +++ b/README.md @@ -1,144 +1,213 @@ # chat-flashback -Analyzes a Facebook Messenger export and generates reports about a group chat: -yearly recaps, member personalities, reaction dynamics, response-speed leaderboards, -swear-word stats, custom term tracking, conversation starters, reply chains, ghosting -stats, sentiment, word clouds, and a weirdest statements section. Also ships a local -web UI to read the chat like a messaging app. - -Runs locally. Your data is not uploaded anywhere. - -## Features - -- Yearly recaps. Top member, top word, record day per year. -- Member personalities. Signature words, emojis, peak posting hour, night-owl percentage. -- Reaction dynamics. Most-reacted messages, reactor rankings. -- Response-speed leaderboard. Median time to reply per member, plus the share of - each member's turns that got no reply within an hour. -- Swear-word analytics. Per-member counts and signature swear words. -- Custom term tracking. Count and chart any words or phrases with `--track` or `--track-file`. -- Conversation starters. Sessions split on 30-minute gaps, longest single back-and-forth. -- Reply chains. Longest reply chains reconstructed from `reply_to_message_id`. -- Ghosting stats. Longest silence between each member's own messages. -- Activity heatmap. GitHub-style calendar grid plus pace trends (messages/day, calls, media). -- Pair dynamics. Heatmaps of who replies to whom and who reacts to whose messages. -- Hourly radar profiles. Each member's 24-hour activity shape. -- Word clouds. Overall and for the six busiest members, generated with `wordcloud`. -- Monologues and unsent messages. Longest solo runs ("could've been an email") and `is_unsent`. -- Emoji report. Emoji counts per member and a timeline of favorite emojis over the years. -- Question dynamics. Who asks questions, who answers, who gets left on read, answer speed. -- What the chat was about. TF-IDF topic words per year. -- Running jokes. Repeated phrases that look like inside jokes (frequency, members, years). -- Year in review. A page per year (monthly activity, top words/emojis, jokes) plus an index. -- Group history. Every name the group gave itself and every nickname it gave its - members, as dated ranges, read back out of Messenger's own event messages. -- Member pages. One per member: their years, the words that set each of their years - apart from their own others, who they answer, and their most-reacted messages. -- Relationships. Pairs by year, pairs that drifted, who speaks first after a day of - silence, who gets the last word, and who goes unanswered most. -- Eras. The chat cut into periods where its volume or its vocabulary turned over, - each named for the word it uses most out of proportion, plus the words the chat - picked up and stopped saying each year. -- Conversations. The chat cut into conversations wherever nobody spoke for 30 - minutes: who opens them, who has the last word, how long they actually run, the - longest one ever, and the chat's own longest silences with the message that - ended each. -- Trendsetters. Who says a word first and then watches everybody else start - saying it, scored per 1,000 of their own messages so the loudest member does - not win by volume. -- Sleep schedules. Every member's posting hours, year by year rather than - averaged over all of them, so somebody's hours sliding later reads as a move - and their 3am tail vanishing reads as a job. -- Guess who said this. A quiz built out of each member's signature words, so the - answer is gettable rather than a coin flip. -- Message-length and word trends over time. -- Sentiment (VADER). Average mood per member and per year. -- Weirdest statements. All-caps, 3am, punctuation-spiral, and extreme-length messages. -- Media leaderboard. Photos, stickers, GIFs, videos, audio, and file attachments per member. -- Handles newer-format export fields: `gifs`, `videos`, `audio_files`, `files`, `polls`, - and `is_taken_down`, and de-duplicates messages that appear in multiple files. -- `--check`. Validate an export before analyzing: unknown message types/keys, empty - messages, media files missing on disk, duplicate messages, and gaps between files. -- Copy-paste floods handled. Vocabulary counts each word or emoji at most three - times per message, so one pasted wall of the same word cannot decide the top - words, the topics of a year, or somebody's signature word. Volume stats still - count every keystroke, and the totals report how many floods there were. -- Bots flagged. Members that are obviously software (`Meta AI`) are labelled - `(bot)` and kept out of the human awards: fastest replier, best vibes, and the - weirdest-statements reel. -- Self-contained `report.html` with every table and chart from `summary.md` - embedded (share one file). Sticky nav, dark/light toggle, and filterable, - sortable tables. -- Local chat reader (`--serve`). Browse the chat in a Messenger-style web UI, with - "on this day" nostalgia, random memories, regex search, and a theme toggle. -- Word explorer in the reader. Look up any word, phrase or emoji and get who says - it, how often per 1,000 messages, when it started, who picked it up from whom, - the words it keeps company with, and real messages you can jump straight to. -- `--anonymize`. Replaces names with Person A, Person B in all output. -- `--tz`, `--config`, `--progress`, and `--incremental` for timezones, config files, - progress output, and skipping unchanged threads. -- Supports group and 1-on-1 chats. Detects multiple threads in one export. - -## Requirements - -Python 3.8+. +Turns a Facebook Messenger export into a set of reports, charts and web pages, plus a +local reader that lets you scroll the whole chat like a messaging app. + +Everything runs on your machine. Nothing is uploaded anywhere. + +![The chat reader](examples/screenshot_reader.png) + +## Contents + +- [Quick start](#quick-start) +- [What you get](#what-you-get) +- [Options](#options) +- [Common tasks](#common-tasks) +- [The chat reader](#the-chat-reader) +- [How the harder numbers are worked out](#how-the-harder-numbers-are-worked-out) +- [Privacy](#privacy) +- [Examples and sample data](#examples-and-sample-data) +- [Tests](#tests) + +## Quick start + +Four steps. You need Python 3.8 or newer. + +**1. Install** ```bash pip install -r requirements.txt ``` -Or install as a package, which gives you a `chatflashback` command: +Or install it as a package, which gives you a `chatflashback` command: ```bash pip install . -chatflashback --input data --output output ``` -Optional extras that the tool skips gracefully if missing: -- `vaderSentiment` powers sentiment (English-only, may be noisy on mixed-language chats). -- `wordcloud` powers the word clouds. +Two optional extras add features. Without them the tool skips those sections and +keeps going. -Install both with `pip install ".[full]"`. +```bash +pip install ".[full]" # vaderSentiment for mood, wordcloud for word clouds +``` + +**2. Download your Messenger data** + +1. Facebook Settings, then Your information, then Download your information. +2. Select Messages and the chats you want. +3. Set the format to JSON and the media quality to Low. +4. Download the zip and extract it. -## Usage +The folder you want holds `message_1.json`, `message_2.json` and so on. It sits at +`youraccount_.../your_activity_across_facebook/messages/inbox//`. + +**3. Run it** ```bash python analyze_chat.py --input data --output output ``` -Options: +Point `--input` at one thread folder, or at the whole `messages/inbox/` folder to do +every thread in one go. + +**4. Open the report** + +Open `output//report.html` in a browser. It is one self-contained file with +every table and chart in it, so you can send it to somebody as is. The other pages +sit next to it and are linked from the bar across the top. + +To read the chat itself instead: + +```bash +python analyze_chat.py --input data --serve +``` + +That starts a reader on `http://127.0.0.1:8080`. + +## What you get + +Running the tool writes everything into `output//`. + +### The report -| Flag | Description | +`report.html` and `summary.md` hold the same numbers, one as a web page and one as +plain text. Add `--json` to also get `summary.json`. + +Inside: yearly recaps with the top member, word and record day of each year. Member +personalities with signature words, favourite emojis, peak hour and night-owl share. +Reaction dynamics. A response-speed leaderboard with median reply time and the share +of each member's turns that nobody answered. Swear-word counts per member. Who starts +conversations, who ghosts, who monologues. Question dynamics: who asks, who answers, +who gets left on read. Emoji counts and a timeline of favourite emojis. Topic words +per year. Repeated phrases that look like inside jokes. Message-length and word trends +over time. Mood per member and per year, if `vaderSentiment` is installed. A weirdest +statements reel of all-caps, 3am and punctuation-spiral messages. A media leaderboard +for photos, stickers, GIFs, videos, audio and files. + +Around 40 PNG charts come with it, including a GitHub-style activity heatmap, pair +matrices of who replies to and reacts to whom, hourly radars, word clouds and a +sleep-schedule grid of everyone's posting hours year by year. + +### The pages beside the report + +| Page | What is on it | |---|---| -| `--input, -i` | Path to a thread folder or an export `messages/` folder (default: `data/`) | -| `--output, -o` | Output folder for charts and `summary.md` (default: `output/`) | -| `--anonymize` | Replace member names with Person A, Person B in all output | -| `--track` | Comma-separated words or phrases to count, e.g. `--track "lol, bro"` | -| `--track-file` | File with tracked terms, one per line (`#` comments and blank lines ignored) | -| `--names` | Names of people the export doesn't list (e.g. a deleted account shown as "Facebook user"), so they don't read as topic words | -| `--stopwords-file` | Extra stopwords to ignore in word stats, one per line (the built-in list is English only) | -| `--year` | Analyze only one year, e.g. `--year 2017` | -| `--top` | Number of entries in leaderboards and charts (default: 10) | -| `--trend-band` | How often a word must be used to count as one somebody started, as `min,max` (default: `20,2000`). Lower it on a small chat, where nothing reaches twenty uses | -| `--json` | Also write `summary.json` with the report data as structured JSON | -| `--serve` | Start the local chat reader web UI instead of writing reports | +| `year_in_review.html` | An index, plus `year_.html` for every year | +| `group_history.html` | Every name the group gave itself and every nickname, as dated ranges | +| `member_.html` | One per member: their years, their words, who they answer, their most-reacted messages | +| `relationships.html` | Pairs by year, pairs that drifted apart, who breaks the silence, who gets the last word | +| `eras.html` | The chat cut into periods, each named after the word it made its own | +| `sessions.html` | Conversations: who opens them, who ends them, how long they run, the longest silences | +| `trendsetters.html` | Who says a word first and then watches everybody else start saying it | +| `quiz.html` | Guess who said this, built from each member's signature words | + +### The reader + +`--serve` opens a Messenger-style reader on localhost: + +- Infinite-scroll feed grouped by day, newest or oldest first +- Sender colours, inline reactions, reply threading and "N years ago" badges +- Photo and GIF thumbnails, inline video and audio players, download links for files +- Search across every message, with a `.*` regex toggle and per-member filters +- Jump to a date, an "On this day" view across the years, and a "Surprise me" button +- A word explorer behind the Words button +- A light and dark toggle that is remembered between visits + +### Handling of awkward exports + +Newer export fields (`gifs`, `videos`, `audio_files`, `files`, `polls`, `is_taken_down`) +are read, and messages that appear in more than one file are de-duplicated. + +Copy-paste floods are capped. Each word or emoji counts at most three times per +message, so one pasted wall of the same word cannot decide the top words or what a +year was about. Volume stats still count every keystroke, and the totals say how many +floods there were. + +Members that are obviously software, such as `Meta AI`, are labelled `(bot)` and kept +out of the human awards. + +## Options + +| Flag | What it does | +|---|---| +| `--input`, `-i` | A thread folder or an export `messages/` folder (default: `data/`) | +| `--output`, `-o` | Where to write everything (default: `output/`) | +| `--anonymize` | Replace names with Person A, Person B everywhere, including inside quoted messages | +| `--track` | Words or phrases to count and chart, e.g. `--track "lol, bro"` | +| `--track-file` | The same, from a file, one per line (`#` for comments) | +| `--names` | Names the export does not list, such as a deleted account showing as "Facebook user", so they do not read as topic words | +| `--stopwords-file` | Extra words to ignore, one per line. The built-in list is English only | +| `--year` | Analyze one year only, e.g. `--year 2017` | +| `--top` | How many rows in each leaderboard (default: 10) | +| `--trend-band` | How often a word must be used to count as one somebody started, as `min,max` (default: `20,2000`) | +| `--json` | Also write `summary.json` | +| `--serve` | Start the reader instead of writing reports | | `--port` | Port for `--serve` (default: 8080) | -| `--no-index` | Skip the word-search index when serving. Starts instantly from the reader store and skips parsing entirely; the word explorer is unavailable | -| `--tz` | Timezone for analysis, e.g. `+03:00` or `America/New_York` (Messenger timestamps are UTC; default is your system timezone) | -| `--config` | JSON config file with any of the options above | -| `--skip` | Skip analyses: `jokes`, `sentiment`, `wordcloud`, `topics`, `narratives` (comma-separated) | -| `--progress` | Show phase progress while analyzing | -| `--incremental` | Skip threads that are unchanged since the last run | -| `--check` | Validate the export instead of analyzing (always exits 0) | +| `--no-index` | Skip the word index when serving. Starts instantly, but the word explorer is off | +| `--tz` | Timezone, e.g. `+03:00` or `America/New_York`. Messenger timestamps are UTC and the default is your system timezone | +| `--config` | A JSON file holding any of these options | +| `--skip` | Skip analyses: `jokes`, `sentiment`, `wordcloud`, `topics`, `narratives` | +| `--progress` | Print which phase is running | +| `--incremental` | Skip threads that have not changed since the last run | +| `--check` | Validate the export instead of analyzing it | + +## Common tasks + +### Share a report without real names + +```bash +python analyze_chat.py --input data --anonymize +``` + +Names become Person A, Person B in every chart, table and quoted message. + +### Chats that mix languages + +The built-in stopword list is English only. In a chat that mixes languages, the other +language's function words take over the top-word, topic and inside-joke sections. +A Hinglish list ships with the tool: + +```bash +python analyze_chat.py --input data --stopwords-file stopwords/hinglish.txt +``` + +Any file works: one word per line, `#` for comments. Chat spelling is not standard, so +a word usually needs several entries (`mein`, `mei`, `mai`) before it stops showing up. + +### Very large chats + +The analysis holds everything in memory, so a chat with hundreds of thousands of +messages needs headroom. Two phases dominate. Inside jokes counts every 2 to 4 word +phrase and drives peak memory. Sentiment is the slowest. Drop both if a run is too +heavy: + +```bash +python analyze_chat.py --input data --skip jokes,sentiment +``` + +Every other report, chart and page is still written. -Writes `summary.md`, `report.html`, PNG charts, `year_.html` year-in-review -pages, `group_history.html`, `relationships.html`, `eras.html`, `sessions.html`, -`trendsetters.html`, `quiz.html` and a `member_.html` per member into -`output//`. Every page is linked from the report's top bar. +### Count your own words -### Config file +```bash +python analyze_chat.py --input data --track "shawarma, bro" +``` -Pass options in a JSON file instead of on the command line. CLI flags still win. +Each term gets a row in the report and a line on a chart. `--track-file` reads a longer +list from a file. + +### Use a config file instead of flags ```json { @@ -146,7 +215,6 @@ Pass options in a JSON file instead of on the command line. CLI flags still win. "output": "out", "top": 15, "json": true, - "anonymize": false, "track": "lol, bro" } ``` @@ -155,251 +223,199 @@ Pass options in a JSON file instead of on the command line. CLI flags still win. python analyze_chat.py --config config.json ``` -### Group history, relationships and eras - -Six pages sit beside the report, plus one page per member. - -**Group history** reads back the messages Messenger writes about the group itself -— renames, nicknames, joins and removals. They are dropped from the vocabulary -(otherwise "named the group" ranks as a running joke said by everyone for years), -but they are a record nobody has read: on a nine-year chat, 520 group names and -369 nickname changes. Nicknames are shown as dated ranges, because the question -people ask is what someone was called in 2021, not when a name was set. - -**Relationships** counts a pair's interactions — a reply inside the hour, or a -reaction — per year, and flags a pair as drifted when its share of either -member's own interaction moves by more than half between the pair's peak year and -the most recent year the export covers end to end. The share is what makes it -drift rather than the chat simply going quiet. It also reports who speaks first -after a day of silence, who gets the last word, and whose messages go unanswered, -as a share of their own messages so the loudest member does not win by volume. - -**Eras** cuts the chat into periods. The rule: a month opens a new era when the -three months from it carry less than half or more than double the messages of the -three before it, or when fewer than a third of the previous quarter's top words -survive into this one. Quarters too small to have a character of their own cannot -open an era, and eras shorter than six months are merged into their neighbour. An -era is named for the word it uses most out of proportion to the rest of the chat, -which is not the same as its most common word — on a real chat, tf-idf named four -eras out of five after the chat's single commonest word. Underneath it, the words -the chat first said and last said each year, which is the plainest version of the -same story. - -**Conversations** splits the chat wherever nobody spoke for 30 minutes — the same -gap the rest of the report uses, so the two cannot disagree about where a -conversation ends. Openers and closers are given as a count and as a rate per 100 -of that member's own messages, because the count alone just ranks by who talks -most; the rate is what separates someone who always speaks first from someone who -is simply always there. Conversation length is reported as percentiles rather than -an average, since the distribution runs from two-message exchanges to all-nighters -and a mean describes neither. Underneath sits the inverse: the chat's longest -silences, and the message that broke each one. - -**Trendsetters** asks who introduces vocabulary that other people actually -adopt, which is not the same question as who talks most. For every word the -chat used between 20 and 2,000 times — nobody coined "the", and a word said -twice is a typo — it takes whoever said it first, and counts the word as having -caught on only once at least three other members said it too. Three things keep -the answer honest. A word first said in the chat's opening 90 days does not -count, because an export beginning is not a word being new and otherwise -whoever talked most in month one "starts" thousands of words the chat had been -saying for years. Bots are left out, since Meta AI's vocabulary is not the -chat's. And the count is divided by how much each member says, per 1,000 of -their own messages, so a chatty member cannot win on volume alone — which means -members under 100 messages are left out, a rate needing a denominator worth -dividing by. Use `--trend-band` to lower the band on a chat too small for twenty -uses; the words still show even when nobody clears the leaderboard's floor. - -**Quiz** is "guess who said this". A message only qualifies if it uses one of its -sender's signature words, so the answer is gettable; messages that name somebody -give it away and are left out, as are bots. It is seeded, so regenerating the -report does not reshuffle the questions. - -### Non-English chats - -The built-in stopword list is English only, so in a chat that mixes languages the -other language's function words take over the top-word, topic and running-joke -sections. `stopwords/hinglish.txt` ships with common Urdu/Hindi words as typed in -Latin script: +Flags on the command line still win over the file. + +### Re-run without redoing everything + +`--incremental` stores a fingerprint of each thread (file names, sizes, modification +times) in `output/.chatflashback_state.json` and skips threads that have not changed. +Changing a flag such as `--year` or `--top` forces a re-run. + +### Check an export before analyzing it ```bash -python analyze_chat.py --input data --stopwords-file stopwords/hinglish.txt +python analyze_chat.py --input data --check ``` -Any file works: one word per line, `#` for comments. Chat spelling is not -standard, so a word usually needs several entries (`mein`, `mei`, `mai`) before -it stops showing up in the results. +Per thread, it reports message types, unknown `type` values and unknown message keys +so a new export format is easy to spot, plus empty messages, attachments missing from +disk, duplicate messages and gaps of over 90 days between message files. Add `--json` +to also write `check.json`. It never analyzes and always exits 0. -### Very large chats - -Everything is held in memory, so a chat with hundreds of thousands of messages -needs headroom. Two analyses dominate: running jokes counts every 2-4 word -phrase in the chat and drives peak memory, and sentiment is the slowest phase. -Drop them if a run is too heavy: +## The chat reader ```bash -python analyze_chat.py --input data --skip jokes,sentiment +python analyze_chat.py --input --serve ``` -Every other report, chart and page is still produced; only those sections are +The server listens on `127.0.0.1` only, with no authentication, so it is not reachable +from your network. + +### Word explorer + +![The word explorer](examples/screenshot_word_explorer.png) + +Type a word into the panel behind the Words button. You get total uses and how many +messages they land in, a per-member table with a per-1,000-messages rate so a quiet +member who says it constantly is not buried under a chatty one, the year it peaked, +how often it is sent on its own, whether it pulls more reactions than average, who +said it first and how long everybody else took to pick it up, the words that sit +beside it more often than chance predicts, and example messages with a button that +jumps the feed to that moment. + +Matching is exact. `bruh` does not quietly include `bruhh`. Spellings that differ only +in held-down letters are listed separately, and a "count spellings together" box folds +them into the totals. Autocomplete suggests words by how often they are used. + +Type more than one word and it becomes a phrase, counted only where those words sit +side by side, so `full send` ignores "send me the full list". Emoji are indexed as +words, so `😂`, `😂😂` and `lol 😂` all work. Punctuation and capitals are ignored, and +a phrase can be made entirely of stopwords (`the end` is a fair question even though +`the` is not). + +"Show all N in the feed" turns the reader into every message holding that word, oldest +first. Click one and the feed opens the conversation around it. + +The index is built at startup and kept in memory. On a 1.79M-message chat that takes +about 30 seconds and peaks near 180 MB while building, for 140,755 distinct words. A +lookup then takes milliseconds for an ordinary word and up to about 3 seconds for one +of the most common ones, since every statistic is computed on demand over the messages +that matched. Phrases need no index of their own, so `in the` came back in 1.0 s and +`what the hell` in 0.04 s on the same chat. Pass `--no-index` to skip the build if you +only want to read. + +### Where the reader keeps its data + +Parsed messages go into a SQLite file at `output/.reader/.sqlite3`, which +answers the feed, the date jump, "on this day", the random memory and search. The file +is keyed on the same fingerprint `--incremental` uses, plus `--tz` and `--anonymize`, +since both change what gets stored. Anything else rebuilds it. + +The difference shows on the second start. With `--no-index` the export is not parsed at +all: the reader opens the file and serves. Without it, the messages are parsed anyway +because the word explorer needs them in memory. A half-written file is rebuilt rather +than trusted, because the "complete" marker is written last. + +Search is a substring scan running inside SQLite, and reports the true match count +along with the first page of hits. It is deliberately not a full-text index: FTS5 +matches whole tokens, so searching `tube` would stop finding `youtube.com`. On a +500k-row benchmark that is 0 hits against 358,453. The scan costs roughly 60 ms per +500k messages. + +Media is served from the export folder only. Paths are resolved inside the thread +directory so nothing outside it can be read, and files stream with `Range` support so +video and audio can seek. Anything that is not an image, video or audio downloads +rather than rendering, since an export can contain `.html` or `.svg` attachments. + +## How the harder numbers are worked out + +Most of the report is a straight count. These are the ones with a rule behind them. + +### Eras + +A month opens a new era when the three months from it carry less than half or more +than double the messages of the three before it, or when fewer than a third of the +previous quarter's top words survive into this one. Quarters too small to have a +character of their own cannot open an era, and eras shorter than six months are merged +into their neighbour. + +Each era is named after the word it uses most out of proportion to the rest of the +chat, which is not its most common word. TF-IDF was tried first and named four eras out +of five after the chat's single commonest word. + +Underneath sits the plainest version of the same story: the words the chat first said +and last said in each year. + +### Relationships + +A pair interacts when one answers the other within an hour, or reacts to their message. +Those are counted per year. + +A pair has drifted when its share of either member's own interaction moves by more than +half between the pair's peak year and the most recent year the export covers end to +end. Using the share, rather than the raw count, stops a pair reading as drifted when +the whole chat simply went quiet. Pairs under 100 interactions in their peak year are left out. -### Incremental runs +The page also reports who speaks first after a day of silence, who gets the last word, +and whose messages go unanswered, as a share of their own messages so the loudest +member does not top the list by volume. -`--incremental` records a fingerprint (file names, sizes, mtimes) for each thread in -`output/.chatflashback_state.json` and skips threads that have not changed since the -last run. Changing flags like `--year` or `--top` forces a re-run. +### Conversations -### Validating an export +The chat is cut wherever nobody spoke for 30 minutes, the same gap the rest of the +report uses. -Before analyzing a fresh download, run `--check` to surface anything the tool may not -handle yet and to find broken attachments: +Openers and closers are given as a count and as a rate per 100 of that member's own +messages. The count alone just ranks by who talks most; the rate separates someone who +always speaks first from someone who is always there. -```bash -python analyze_chat.py --input data --check -``` +Conversation length is given as percentiles rather than an average, because the range +runs from two-message exchanges to all-nighters. Underneath sits the inverse: the +chat's longest silences and the message that broke each one. -It reports, per thread: message types, unknown `type` values and unknown top-level -message keys (so new export formats are easy to spot), empty messages, media -attachments that are missing on disk, duplicate messages (it de-duplicates them -automatically when analyzing), and gaps over 90 days between message files. Add -`--json` to also write `check.json`. `--check` always exits 0 and never analyzes. +### Trendsetters -## Chat reader +Who introduces vocabulary that other people actually adopt, which is a different +question from who talks most. -```bash -python analyze_chat.py --input --serve -``` +For every word the chat used between 20 and 2,000 times, the tool takes whoever said it +first. The word counts as having caught on only once at least three other members said +it too. -Starts a local server on `127.0.0.1:8080` (localhost only, no authentication) and -opens a Messenger-style reader: +Three rules keep the answer honest: -- Infinite-scroll feed grouped by day, newest or oldest first -- Sender color chips, inline reactions, media thumbnails, shares and call messages -- Photo/GIF thumbnails, inline video and audio players, and download links for files. - Media that is not on disk says so in place of the thumbnail, rather than leaving a - blank message — most exports ship with only a fraction of their attachments -- Reply threading, "N years ago" badges, subtle sentiment tint -- Full-text search (with a `.*` regex toggle) and per-member filters -- A jump-to-date control, an "On this day" view across the years, and a random-memory - "Surprise me" button -- A light/dark theme toggle (remembered between visits) -- The word explorer, behind the "Words" button -- Links to the full report and the year-in-review pages +- A word first said in the chat's opening 90 days does not count. An export beginning is + not the same as a word being new, and without this rule whoever talked most in month + one "starts" thousands of words the chat had been saying for years. +- Bots are left out. +- The count is divided by how much each member says, per 1,000 of their own messages, + so a chatty member cannot win on volume. Members under 100 messages are left out, + since a rate needs a denominator worth dividing by. -### Word explorer +On a small chat nothing reaches twenty uses. Lower the band with `--trend-band 3,50` +and the words still show even when nobody clears the leaderboard's floor. -Type a word into the panel behind the reader's "Words" button and it comes back -with the whole life of that word in the chat: total uses and how many messages -they land in, a per-member table with a per-1,000-messages rate so a quiet member -who says it constantly is not buried under a chatty one, the year it peaked, how -often it is sent on its own, whether it pulls more reactions than the chat's -average, who said it first and how many days everyone else took to pick it up, -the words that sit beside it more often than chance predicts, and example -messages with a button that jumps the feed to that moment. - -Matching is exact: `bruh` does not silently include `bruhh`. Other spellings that -differ only in held-down letters are listed separately, and "count spellings -together" folds them into the totals. Autocomplete suggests words by how often -they are used. - -Type more than one word and it becomes a phrase, counted only where those words -sit side by side — `full send` ignores "send me the full list". Emoji are indexed -as words, so `😂` works the same way, and so does `😂😂` or `lol 😂`. Punctuation -and capitals are ignored, and a phrase may be built entirely out of stopwords -(`the end` is a fair question even though `the` is not). - -"Show all N in the feed" turns the reader into every message holding that word, -oldest first, grouped by day. Click any one of them and the feed opens the whole -conversation around it, so you can read what the word was actually about. - -The index is built once at startup and lives in memory. On a 1.79M-message chat -it takes about 30 seconds and peaks near 180 MB while building, for 140,755 -distinct words. A lookup then takes milliseconds for an ordinary word, and up to -about 3 seconds for one of the most common words in the chat, since every -statistic is computed on demand over the messages that matched. Phrases need no -index of their own — the candidate messages are the ones containing every word, -so `in the` came back in 1.0 s and `what the hell` in 0.04 s on the same chat. -Pass `--no-index` to skip the build if you only want to read. - -### The reader's store - -The reader keeps the parsed messages in a SQLite file at -`output/.reader/.sqlite3` and answers the feed, the date jump, "on this -day", the random memory and search out of it. The file is keyed on the same -fingerprint `--incremental` uses, plus `--tz` and `--anonymize`, since both -change what gets stored; anything else rebuilds it. - -The practical effect is on the second start. With `--no-index` the export is not -parsed at all — the reader opens the file and serves. Without it, the messages -are still parsed because the word explorer needs them in memory. A stale or -half-written file is rebuilt rather than trusted: the "complete" marker is -written last, so a run interrupted mid-build leaves a file that fails its own -check. - -Search stays a substring scan, now inside SQLite, and still reports the true -match count and shows the first page of hits. It is deliberately not a full-text -index: FTS5 matches whole tokens, so searching `tube` would stop finding -`youtube.com` — on a 500k-row benchmark that is 0 hits against 358,453. The scan -costs roughly 60 ms per 500k messages, which is not worth breaking search for. - -Media files are served from the export folder only; paths are resolved inside the -thread directory so nothing outside it can be read, and files are streamed with -`Range` support so video and audio can seek. Anything that is not an image, video -or audio downloads rather than rendering, since an export can contain `.html` or -`.svg` attachments. - -Text that comes from the export — thread titles, names, message bodies — is -escaped everywhere it is rendered, in the reader and in the generated reports. - -## Getting your Messenger data - -1. Facebook Settings > Your information > Download your information. -2. Select Messages and the chats you want. -3. Set format to JSON and media quality to Low. -4. Download and extract. Point `--input` at: - `youraccount_.../your_activity_across_facebook/messages/inbox//` +### Quiz -A thread folder contains `message_1.json`, `message_2.json`, ... that you can point -`--input` at directly. +A message qualifies only if it uses one of its sender's signature words, so the answer +is gettable rather than a coin flip. Messages that name somebody give the answer away +and are left out, as are bot messages. The three wrong answers are sampled by message +volume, so they are plausible for that era. It is seeded, so regenerating the report +does not reshuffle the questions. -## Privacy +### Group history -- All processing happens locally. -- `--anonymize` replaces real names in every chart and report, including names that - appear inside quoted message text. -- `--serve` binds to `127.0.0.1` only, so the reader is never reachable from the network. -- `.gitignore` excludes `data/` and `output/` so an export cannot be committed. +Messenger writes its own messages about the group: renames, nicknames, joins and +removals. They are dropped from the vocabulary, because otherwise "named the group" +ranks as an inside joke said by everyone for years. Read back, they are a record nobody +has seen. On a nine-year chat that was 520 group names and 369 nickname changes. +Nicknames are shown as dated ranges, since the question people ask is what somebody was +called in 2021, not when the name was set. -## Sample data +## Privacy -`sample_data/` contains a small synthetic thread used for testing: +- All processing happens on your machine. +- `--anonymize` replaces real names everywhere, including names inside quoted messages. +- `--serve` binds to `127.0.0.1`, so the reader is never reachable from the network. +- Text that comes from the export, meaning thread titles, names and message bodies, is + escaped everywhere it is rendered, in the reader and in the generated reports. +- `.gitignore` excludes `data/` and `output/`, so an export cannot be committed by + accident. -```bash -python analyze_chat.py --input sample_data --track "shawarma, bro" --json -``` +## Examples and sample data -## Tests +`sample_data/` holds a small synthetic thread, 94 messages across four members and nine +years. Run it to see the output without touching your own data: ```bash -pip install -r requirements-dev.txt -python -m pytest +python analyze_chat.py --input sample_data --track "shawarma, bro" --json --trend-band 3,50 ``` -## Examples - -Sample output from the synthetic thread is in `examples/`: - -- `example_summary.md` - a full generated report -- `report.html` - the same report as a single self-contained HTML file -- `summary.json` - the report data as structured JSON -- `year_in_review.html` and `year_.html` - one page per year -- PNG charts: messages per year, activity heatmap, pace trends, activity by - hour/weekday, hours by year, top members, top words, word clouds, emoji timeline, yearly recap, - pair dynamics, hourly radar, reaction dynamics, question dynamics, topics per - year, running jokes, response speed, swear stats, tracked terms, domains, media - leaderboard, reply chains, ghosting, monologues, conversation starters, monthly - timeline, word trends, and sentiment +`examples/` holds exactly that run, committed so you can look before installing +anything: `example_summary.md`, `report.html`, `summary.json`, every generated page, +and the charts. The band is lowered there because 94 messages never reach twenty uses +of a word. ![Activity heatmap](examples/activity_heatmap.png) @@ -409,7 +425,14 @@ Sample output from the synthetic thread is in `examples/`: ![Hourly radar](examples/hourly_radar.png) -![Word cloud](examples/wordcloud.png) +## Tests + +```bash +pip install -r requirements-dev.txt +python -m pytest +``` + +The suite takes about six minutes. ## License