#!/usr/bin/env python3 """post-type.py — media vs text-only, and forwarded-in vs original, compared inside each channel. Writes post-type-stats.json. Reads posts.csv (made by analyse.py) and, for the photo / video split only, the wave-one preview pages in pages/w1/ that posts.csv was built from. Never touches the network. Deterministic: the one random step (the label-shuffle check) uses a fixed seed. Why a separate script: stats.json already carries the headline within-channel comparison (`media_vs_text_within_channel`). The article on media vs text needs a few more cuts of the same rows — the comparison restricted to posts old enough to have settled, the split by channel size, photo vs video, forwards, and a check of how much of the spread chance alone produces — and adding them here keeps every figure in stats.json exactly as published. python3 post-type.py """ import csv import glob import gzip import json import os import random import re import statistics HERE = os.path.dirname(os.path.abspath(__file__)) MIN_EACH = 3 # same rule as stats.json: at least 3 posts of each kind MATURE_HOURS = 48.0 # same line as analyse.py's "settled" cut SHUFFLES = 200 SEED = 20261008 # Same message split and id pattern as analyse.py. RE_MSG_SPLIT = re.compile(r'(?=
photo | video | other, from wave-one pages.""" kinds = {} for path in sorted(glob.glob(os.path.join(HERE, "pages", "w1", "*.posts.html.gz"))): name = os.path.basename(path)[:-len(".posts.html.gz")] with gzip.open(path, "rt", encoding="utf-8", errors="replace") as fh: html = fh.read() for block in RE_MSG_SPLIT.split(html): m = RE_DATA_POST.search(block) if not m or m.group(1).lower() != name.lower(): continue kinds[(name.lower(), int(m.group(2)))] = media_kind(block) if UNSUPPORTED in block: UNSUPPORTED_POSTS.add((name.lower(), int(m.group(2)))) return kinds def load(): kinds = load_kinds() chans = {} with open(os.path.join(HERE, "posts.csv"), encoding="utf-8") as fh: for r in csv.DictReader(fh): p = { "views": int(r["views"]), "ratio": float(r["view_to_subscriber_ratio"]), "age": float(r["age_hours_at_capture"]), "fwd": r["is_forward"] == "1", "media": r["has_media"] == "1", } p["unsupported"] = (r["channel"].lower(), int(r["post_id"])) in UNSUPPORTED_POSTS p["kind"] = kinds.get((r["channel"].lower(), int(r["post_id"])), "other") \ if p["media"] else "text" c = chans.setdefault(r["channel"], { "subscribers": int(r["subscribers"]), "band": r["size_band"], "posts": []}) c["posts"].append(p) return chans def within(chans, is_a, is_b, keep=lambda p: True): """Per channel: median views of group A over median views of group B.""" out = [] for name, c in chans.items(): posts = [p for p in c["posts"] if keep(p)] a = [p["views"] for p in posts if is_a(p)] b = [p["views"] for p in posts if is_b(p)] if len(a) >= MIN_EACH and len(b) >= MIN_EACH: mb = statistics.median(b) if mb: out.append({"channel": name, "band": c["band"], "ratio": statistics.median(a) / mb, "a": a, "b": b}) return out def describe(rows): v = [r["ratio"] for r in rows] if not v: return {"channels_compared": 0} return { "channels_compared": len(v), "summary": summary(v), "share_where_first_group_did_better": round(sum(1 for x in v if x > 1) / len(v), 4), "share_within_25pct_either_way": round( sum(1 for x in v if 0.8 <= x <= 1.25) / len(v), 4), "share_first_group_at_least_1_5x": round(sum(1 for x in v if x >= 1.5) / len(v), 4), "share_first_group_at_most_two_thirds": round( sum(1 for x in v if x <= 2 / 3) / len(v), 4), } def shuffle_check(rows): """Shuffle the labels inside each channel, keeping its counts, and see what spread of ratios chance alone produces. Also: in how many channels is the observed ratio outside the middle 90% of its own shuffles?""" rng = random.Random(SEED) per_run = [] outside = 0 for r in rows: r["_null"] = [] for _ in range(SHUFFLES): run = [] for r in rows: pool = r["a"] + r["b"] rng.shuffle(pool) a, b = pool[:len(r["a"])], pool[len(r["a"]):] mb = statistics.median(b) x = statistics.median(a) / mb if mb else None if x is not None: run.append(x) r["_null"].append(x) per_run.append(run) for r in rows: lo, hi = pct(r["_null"], 0.05), pct(r["_null"], 0.95) if r["ratio"] < lo or r["ratio"] > hi: outside += 1 del r["_null"] return { "what_it_is": "labels shuffled inside each channel %d times (seed %d), " "keeping each channel's count of each kind. The figures are " "the median, across shuffles, of each summary statistic." % (SHUFFLES, SEED), "median_of_shuffled_p25": round(statistics.median(pct(x, 0.25) for x in per_run), 4), "median_of_shuffled_median": round(statistics.median(pct(x, 0.5) for x in per_run), 4), "median_of_shuffled_p75": round(statistics.median(pct(x, 0.75) for x in per_run), 4), "median_of_shuffled_share_over_1": round(statistics.median( sum(1 for v in x if v > 1) / len(x) for x in per_run), 4), "channels_outside_their_own_middle_90pct": outside, "channels_tested": len(rows), "share_outside": round(outside / len(rows), 4) if rows else None, "share_expected_by_chance": 0.10, } def main(): chans = load() posts = [p for c in chans.values() for p in c["posts"]] media = lambda p: p["media"] text = lambda p: not p["media"] mature = lambda p: p["age"] >= MATURE_HOURS st = {"source": "posts.csv and pages/w1/*.posts.html.gz; wave one, 2026-09-09", "rule": "a channel enters a comparison only with at least %d posts of each " "kind; ratio = median views of the first kind / median views of " "the second, inside the same channel" % MIN_EACH, "posts": len(posts), "channels": len(chans)} all_ages = within(chans, media, text) st["media_vs_text_all_ages"] = describe(all_ages) st["media_vs_text_all_ages"]["note"] = ( "reproduces stats.json media_vs_text_within_channel (146 channels, median 1.0339)") unsup = [p for p in posts if not p["media"] and p["unsupported"]] unsup_chans = sum(1 for c in chans.values() if any(not p["media"] and p["unsupported"] for p in c["posts"])) dropped = within(chans, media, text, lambda p: p["media"] or not p["unsupported"]) moved = within(chans, lambda p: p["media"] or p["unsupported"], lambda p: not p["media"] and not p["unsupported"]) st["text_posts_with_unsupported_attachment"] = { "what_it_is": "has_media=0 posts whose stored preview HTML contains %s; " "the preview shows an attachment placeholder, not text alone" % UNSUPPORTED, "posts": len(unsup), "of_text_posts": sum(1 for p in posts if not p["media"]), "channels_with_any": unsup_chans, "media_vs_text_with_them_dropped": describe(dropped), "media_vs_text_with_them_counted_as_media": describe(moved), } mat = within(chans, media, text, mature) st["media_vs_text_posts_48h_or_older"] = describe(mat) # Age is the largest single driver of a post's count. Do media and text posts # in the same channel differ in age? Median age of media posts over median # age of text posts, per channel. age_ratio = [] for r in all_ages: ps = chans[r["channel"]]["posts"] am = statistics.median(p["age"] for p in ps if p["media"]) at = statistics.median(p["age"] for p in ps if not p["media"]) if at: age_ratio.append(am / at) st["age_of_media_over_age_of_text_within_channel"] = summary(age_ratio) st["media_vs_text_by_size_band"] = {} for band in sorted({c["band"] for c in chans.values()}): keep_band = [r for r in all_ages if r["band"] == band] keep_mat = [r for r in mat if r["band"] == band] st["media_vs_text_by_size_band"][band] = { "all_ages": describe(keep_band), "posts_48h_or_older": describe(keep_mat), } kinds = {} for kind in ("photo", "video", "other"): is_k = (lambda k: (lambda p: p["kind"] == k))(kind) kinds[kind] = { "posts": sum(1 for p in posts if p["kind"] == kind), "vs_text_all_ages": describe(within(chans, is_k, text)), "vs_text_posts_48h_or_older": describe(within(chans, is_k, text, mature)), } kinds["note"] = ("photo = a photo marker and no video marker; video = video or " "round video; other = document, voice, audio, sticker, poll, " "location. An album with any video counts as video.") kinds["text_posts"] = sum(1 for p in posts if p["kind"] == "text") st["media_kind_vs_text"] = kinds fwd = lambda p: p["fwd"] own = lambda p: not p["fwd"] st["forwarded_in_vs_original"] = { "note": "is_forward = forwarded INTO this channel from elsewhere. The source " "cannot see how often a channel's own posts were forwarded outward.", "channels_with_any_forward": sum(1 for c in chans.values() if any(p["fwd"] for p in c["posts"])), "all_ages": describe(within(chans, fwd, own)), "posts_48h_or_older": describe(within(chans, fwd, own, mature)), } st["shuffle_check_media_vs_text_all_ages"] = shuffle_check(all_ages) # Why the pooled figure in stats.json post_type points the other way. bands = {} for band in sorted({c["band"] for c in chans.values()}): bp = [p for c in chans.values() if c["band"] == band for p in c["posts"]] bands[band] = { "posts": len(bp), "share_with_media": round(sum(p["media"] for p in bp) / len(bp), 4), "pooled_median_ratio_media": round(pct([p["ratio"] for p in bp if p["media"]], 0.5), 4), "pooled_median_ratio_text": round(pct([p["ratio"] for p in bp if not p["media"]], 0.5), 4), } text_posts = [p for c in chans.values() for p in c["posts"] if not p["media"]] media_posts = [p for c in chans.values() for p in c["posts"] if p["media"]] small = "small (<1k)" sub_media = [c["subscribers"] for c in chans.values() for p in c["posts"] if p["media"]] sub_text = [c["subscribers"] for c in chans.values() for p in c["posts"] if not p["media"]] shares, ratios = [], [] for c in chans.values(): shares.append(sum(p["media"] for p in c["posts"]) / len(c["posts"])) ratios.append(statistics.median(p["ratio"] for p in c["posts"])) st["why_pooled_differs"] = { "by_size_band": bands, "share_of_text_posts_from_small_channels": round( sum(1 for c in chans.values() if c["band"] == small for p in c["posts"] if not p["media"]) / len(text_posts), 4), "share_of_media_posts_from_small_channels": round( sum(1 for c in chans.values() if c["band"] == small for p in c["posts"] if p["media"]) / len(media_posts), 4), "median_subscribers_behind_a_media_post": int(statistics.median(sub_media)), "median_subscribers_behind_a_text_post": int(statistics.median(sub_text)), "spearman_channel_media_share_vs_channel_median_ratio": spearman(shares, ratios), "channels_all_media": sum(1 for s in shares if s == 1.0), "channels_no_media": sum(1 for s in shares if s == 0.0), } out = os.path.join(HERE, "post-type-stats.json") with open(out, "w", encoding="utf-8") as fh: json.dump(st, fh, indent=1, ensure_ascii=False) fh.write("\n") print("wrote", out) if __name__ == "__main__": main()