diff --git a/Story/Adrian.md b/Story/Adrian.md index 51b4e8b..f9a170f 100644 --- a/Story/Adrian.md +++ b/Story/Adrian.md @@ -1,5 +1,20 @@ # Adrian + + +## Profile pictures + +**Adrian — 1** + +![](pfp/adrian/axe7adrian_0.jpg) + +**Adrian — 2** + +![](pfp/adrian/axe7adrian_1.jpg) + + + + - Observed handle: `Axe7Adrian` - Activity: 1392 messages across 29 streams - First seen: 2023-12-29 in `DRACONIANDISCOURSE_2_TIKTOK_SKINWALKERS` @@ -30,4 +45,4 @@ Adrian treats Rain less like an untouchable king and more like a familiar sovere - "I just randomly say cloud rain dagon, it manifested in my head one day" - "I just came back from taking my cat from the vet" - "I gotta wake up at 4am tomorrow gg" -- "6 ate 7 HAHAHAHAHAHAHAHA is better than SIX SEVEN" +- "6 ate 7 HAHAHAHAHAHAHAHA is better than SIX SEVEN" \ No newline at end of file diff --git a/Story/Agate.md b/Story/Agate.md index 9b4d74d..cfd8b6a 100644 --- a/Story/Agate.md +++ b/Story/Agate.md @@ -1,5 +1,16 @@ # Agate + + +## Profile pictures + +**Agate** + +![](pfp/agate/loonyagate_0.jpg) + + + + - Observed handle: `LoonyAgate` - Activity: 69 messages across 1 stream - First seen: 2026-03-27 in `creeper.ts` @@ -31,4 +42,4 @@ Agate does not seem to chase Rain's approval. His tone is more guide-to-player t - "I savored my suffering" - "I know there's a secret bench somewhere here" - "I feel like this game in general was made by sadists" -- "had kinda guessed you're azure lol" +- "had kinda guessed you're azure lol" \ No newline at end of file diff --git a/Story/Azure.md b/Story/Azure.md index 2bdc867..63b2659 100644 --- a/Story/Azure.md +++ b/Story/Azure.md @@ -1,5 +1,24 @@ # Azure + + +## Profile pictures + +**`guidingflyer530` — 1** + +![](pfp/azure/guidingflyer530_0.jpg) + +**`guidingflyer530` — 2** + +![](pfp/azure/guidingflyer530_1.jpg) + +**`actuallynotazure`** + +![](pfp/azure/actuallynotazure_0.jpg) + + + + - Observed handles: `guidingflyer530`, `actuallynotazure` - Activity: 9826 messages across 40 streams - First seen: 2025-10-29 in `obliterate.ts` @@ -31,4 +50,4 @@ Azure is profoundly Rain-oriented. The volume and tone both suggest approval-see - "We hate Chinese spy bot apps" - "rain my account was suspended" - "look at us malgru, the delinquent duo" -- "I'm thinking of making art of random people" +- "I'm thinking of making art of random people" \ No newline at end of file diff --git a/Story/Beanie.md b/Story/Beanie.md index 30f9158..ba79cc9 100644 --- a/Story/Beanie.md +++ b/Story/Beanie.md @@ -1,5 +1,16 @@ # Beanie + + +## Profile pictures + +**Beanie** + +![](pfp/beanie/beaniee___0.jpg) + + + + - Observed handle: `beaniee__` - Activity: 1852 messages across 15 streams - First seen: 2026-01-16 in `david.ts` @@ -31,4 +42,4 @@ Beanie seems comfortable enough with Rain to tease him, embarrass him, and play - "#stealingthemoon" - "nah just keep em guessing" - "I love movies" -- "freak" +- "freak" \ No newline at end of file diff --git a/Story/Heart-and-Mind.md b/Story/Heart-and-Mind.md index 0bb69a9..6830ac1 100644 --- a/Story/Heart-and-Mind.md +++ b/Story/Heart-and-Mind.md @@ -1,5 +1,20 @@ # Heart and Mind + + +## Profile pictures + +**Heart (`heart_cccc`)** + +![](pfp/heart-and-mind/heart_cccc_0.jpg) + +**Mind (`brush_colourful`)** + +![](pfp/heart-and-mind/brush_colourful_0.jpg) + + + + - Observed handles: `Heart_CCCC` as Heart, `brush_colourful` as Mind - Activity: one direct bit in `errands.ts`, then later referenced by chat as a memorable event - First seen: 2026-01-30 in `errands.ts` @@ -30,4 +45,4 @@ They do not appear to seek Rain's approval at all. If anything, their clipped co - Mind: "and I'm the Mind" - Heart: "Put your viewers back" - Heart: "Lets go mind this is" -- Later callback: "who were those two people who said they were the heart and the mind" +- Later callback: "who were those two people who said they were the heart and the mind" \ No newline at end of file diff --git a/Story/Jenni.md b/Story/Jenni.md index 551077f..e7721aa 100644 --- a/Story/Jenni.md +++ b/Story/Jenni.md @@ -1,5 +1,16 @@ # Jenni + + +## Profile pictures + +**Jenni** + +![](pfp/jenni/jennimilano_0.jpg) + + + + - Observed handle: `JenniMilano` - Activity: 38 messages across 1 stream - First seen: 2025-10-29 in `obliterate.ts` @@ -31,4 +42,4 @@ Jenni's regard for Rain reads as immediate interest and active rapport-seeking. - "Do you have a streaming schedule?" - "You have a cute model!!" - "Are you open to making new friends?" -- "I've sent you a friend request" +- "I've sent you a friend request" \ No newline at end of file diff --git a/Story/Noname.md b/Story/Noname.md index 2b72057..006e429 100644 --- a/Story/Noname.md +++ b/Story/Noname.md @@ -1,5 +1,36 @@ # Noname + + +## Profile pictures + +**Noname — 1** + +![](pfp/noname/noname106668_0.jpg) + +**Noname — 2** + +![](pfp/noname/noname106668_1.jpg) + +**Noname — 3** + +![](pfp/noname/noname106668_2.jpg) + +**Noname — 4** + +![](pfp/noname/noname106668_3.jpg) + +**Noname — 5** + +![](pfp/noname/noname106668_4.jpg) + +**Noname — 6** + +![](pfp/noname/noname106668_5.jpg) + + + + - Observed handle: `noname106668` - Activity: 282 messages across 16 streams - First seen: 2023-12-29 in `DRACONIANDISCOURSE_2_TIKTOK_SKINWALKERS` @@ -31,4 +62,4 @@ Noname's relation to Rain is casual and low-volume. Even when invoking his name, - "I don't know very much about hollow knight since I'm still in my first run" - "is silksong as hard as people are saying it is" - "Willie loves coming" -- "piza feddy five bear simulator" +- "piza feddy five bear simulator" \ No newline at end of file diff --git a/Story/NotoriousRooster.md b/Story/NotoriousRooster.md index 87774dd..0fc5fbb 100644 --- a/Story/NotoriousRooster.md +++ b/Story/NotoriousRooster.md @@ -1,5 +1,16 @@ # NotoriousRooster + + +## Profile pictures + +**NotoriousRooster** + +![](pfp/notorious-rooster/notorious_rooster_0.jpg) + + + + - Observed handle: `notorious_rooster` - Activity: 1015 messages across 50 streams - First seen: 2023-11-02 in `whistleblewor.ts` @@ -31,4 +42,4 @@ Rooster seems to regard Rain as the source of the event rather than an object of - "I ate tamales and I am eepy" - "I WILL CRONCH ON THEM AS MUCH AS I WANT" - "HE IS THE PURPLE GUY" -- "Do you have a girlfriend?" +- "Do you have a girlfriend?" \ No newline at end of file diff --git a/Story/RaincloudTheDragon.md b/Story/RaincloudTheDragon.md index 8bae52a..140272a 100644 --- a/Story/RaincloudTheDragon.md +++ b/Story/RaincloudTheDragon.md @@ -1,5 +1,16 @@ # RaincloudTheDragon + + +## Profile pictures + +**RaincloudTheDragon** + +![](pfp/raincloud/raincloudthedragon_0.jpg) + + + + - Observed handle: `RaincloudTheDragon` - Activity in chat exports as a chatter: 97 messages across 32 streams - Canonical role: sovereign host, resurrected digital dragon consciousness @@ -31,4 +42,4 @@ Most of the roster treats Rain as the center of gravity, but not in one uniform - "the founder has arrived" - "mods awake, don't post any tiddy" - "come on. you know why." -- "i can't announce because my discord is broken" +- "i can't announce because my discord is broken" \ No newline at end of file diff --git a/Story/Starboy.md b/Story/Starboy.md index 110b462..4a60118 100644 --- a/Story/Starboy.md +++ b/Story/Starboy.md @@ -1,5 +1,16 @@ # Starboy + + +## Profile pictures + +**Starboy** + +![](pfp/starboy/starboy_journeys_0.jpg) + + + + - Observed handle: `Starboy_Journeys` - Activity: 476 messages across 9 streams - First seen: 2025-11-14 in `mcrib.ts` @@ -31,4 +42,4 @@ Starboy treats Rain as a provocation target: flirtable, mockable, and endlessly - "Did you unlock the secret Roxy footjob ending yet?" - "Bro is still streaming this shit, this is so boring to watch" - "Hi Rain, looking scrumptious as usual" -- "Arnold is a fucking beast" +- "Arnold is a fucking beast" \ No newline at end of file diff --git a/Story/Ubear.md b/Story/Ubear.md index ac5d538..0d41392 100644 --- a/Story/Ubear.md +++ b/Story/Ubear.md @@ -1,5 +1,24 @@ # Ubear / Malgru + + +## Profile pictures + +**`verify52w`** + +![](pfp/ubear/verify52w_0.jpg) + +**`sky_city_2013`** + +![](pfp/ubear/sky_city_2013_0.jpg) + +**`ancientmalgru`** + +![](pfp/ubear/ancientmalgru_0.jpg) + + + + - Canonical name: `Ubear` - Observed handles: `verify52w`, `Sky_City_2013`, `AncientMalgru`, `AnCIentmalGru` - Possible additional handle: `imnoob87` @@ -33,4 +52,4 @@ Ubear's regard for Rain is fixation more than devotion. He wants access, reactio - `Sky_City_2013`: "wait why are you back here again" - `Sky_City_2013`: "i have.. nothing" - `AncientMalgru`: "get the fuck out of here" -- `AncientMalgru`: "you were going to show the portrait" +- `AncientMalgru`: "you were going to show the portrait" \ No newline at end of file diff --git a/Story/pfp/adrian/axe7adrian_0.jpg b/Story/pfp/adrian/axe7adrian_0.jpg new file mode 100644 index 0000000..c14a141 --- /dev/null +++ b/Story/pfp/adrian/axe7adrian_0.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1591028c64f6d93995c5eebc6d24511d471a04ce6d5c2d56a389a9fafbe9d5dd +size 23292 diff --git a/Story/pfp/adrian/axe7adrian_1.jpg b/Story/pfp/adrian/axe7adrian_1.jpg new file mode 100644 index 0000000..9218ca3 --- /dev/null +++ b/Story/pfp/adrian/axe7adrian_1.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d2e9d663171ebd8bd18d0c4bb216322976cf1e01887685ab77811b4b913bb137 +size 19216 diff --git a/Story/pfp/agate/loonyagate_0.jpg b/Story/pfp/agate/loonyagate_0.jpg new file mode 100644 index 0000000..f50322e --- /dev/null +++ b/Story/pfp/agate/loonyagate_0.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a719e650e11b4c73df7f463c506ca33724a5fd295b483f1516bc13c9bbb7c6b6 +size 22146 diff --git a/Story/pfp/azure/actuallynotazure_0.jpg b/Story/pfp/azure/actuallynotazure_0.jpg new file mode 100644 index 0000000..cd58431 --- /dev/null +++ b/Story/pfp/azure/actuallynotazure_0.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:be3b6e20b9865701b835e4164f77f0cfed8622c2a6c501fb2ffe147da42e806d +size 8868 diff --git a/Story/pfp/azure/guidingflyer530_0.jpg b/Story/pfp/azure/guidingflyer530_0.jpg new file mode 100644 index 0000000..981ff4b --- /dev/null +++ b/Story/pfp/azure/guidingflyer530_0.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ed0d03db5a2a8ef083c5ba55f8705d1854459abf7893c870699f4e167cf3b86 +size 8481 diff --git a/Story/pfp/azure/guidingflyer530_1.jpg b/Story/pfp/azure/guidingflyer530_1.jpg new file mode 100644 index 0000000..36d519d --- /dev/null +++ b/Story/pfp/azure/guidingflyer530_1.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1ee7c057fdbdf9523a129242ba16b23a1495162014abbb338279fb1a1455f3e2 +size 23004 diff --git a/Story/pfp/beanie/beaniee___0.jpg b/Story/pfp/beanie/beaniee___0.jpg new file mode 100644 index 0000000..6d57f42 --- /dev/null +++ b/Story/pfp/beanie/beaniee___0.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc6620c42931b86009f5d8e4de2b62e0f083e11d70637740eb5966a44627ace7 +size 9296 diff --git a/Story/pfp/heart-and-mind/brush_colourful_0.jpg b/Story/pfp/heart-and-mind/brush_colourful_0.jpg new file mode 100644 index 0000000..4abd317 --- /dev/null +++ b/Story/pfp/heart-and-mind/brush_colourful_0.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95938a8025ff78ed3abaeb64bad4fb1e9f7af5143c8a2ebefbde9bb016d7b5a5 +size 14239 diff --git a/Story/pfp/heart-and-mind/heart_cccc_0.jpg b/Story/pfp/heart-and-mind/heart_cccc_0.jpg new file mode 100644 index 0000000..aa8885e --- /dev/null +++ b/Story/pfp/heart-and-mind/heart_cccc_0.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:29318cb3423f9b4f269107da935575fa9a79f2f4be26482ac1dfa583de4ff8a7 +size 19937 diff --git a/Story/pfp/jenni/jennimilano_0.jpg b/Story/pfp/jenni/jennimilano_0.jpg new file mode 100644 index 0000000..3afc1c4 --- /dev/null +++ b/Story/pfp/jenni/jennimilano_0.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95b385a40afa85327973a19eff1f7f54d2dceb3bb7154e6fc37c87007eacbabe +size 15346 diff --git a/Story/pfp/noname/noname106668_0.jpg b/Story/pfp/noname/noname106668_0.jpg new file mode 100644 index 0000000..375fc35 --- /dev/null +++ b/Story/pfp/noname/noname106668_0.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2b8a560052bfcd063c4e2b62d815ad947a0f70824e2a8857bbbf3626e5d1656a +size 19760 diff --git a/Story/pfp/noname/noname106668_1.jpg b/Story/pfp/noname/noname106668_1.jpg new file mode 100644 index 0000000..2d413be --- /dev/null +++ b/Story/pfp/noname/noname106668_1.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03a5ee8721ec271e3742d90f927c9937d99e947a87c2abb7356b676e86f65f2a +size 27426 diff --git a/Story/pfp/noname/noname106668_2.jpg b/Story/pfp/noname/noname106668_2.jpg new file mode 100644 index 0000000..bcf5357 --- /dev/null +++ b/Story/pfp/noname/noname106668_2.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:966cde6c5f8cd54874c90e86db4257bdd32e5ce5cd864f6ff28f3e5c10b3b738 +size 15983 diff --git a/Story/pfp/noname/noname106668_3.jpg b/Story/pfp/noname/noname106668_3.jpg new file mode 100644 index 0000000..9767f37 --- /dev/null +++ b/Story/pfp/noname/noname106668_3.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59d94c9c2d03b13ea7cd25c1c54da00617a357cb8111329a20e7ec69d6d7b1bc +size 21097 diff --git a/Story/pfp/noname/noname106668_4.jpg b/Story/pfp/noname/noname106668_4.jpg new file mode 100644 index 0000000..6bcc4c2 --- /dev/null +++ b/Story/pfp/noname/noname106668_4.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b3f4d3a8a627b2db15aa3d5cf10cc428fb98ae8dd3b5328188524374e52668f +size 11579 diff --git a/Story/pfp/noname/noname106668_5.jpg b/Story/pfp/noname/noname106668_5.jpg new file mode 100644 index 0000000..cd4b86d --- /dev/null +++ b/Story/pfp/noname/noname106668_5.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6040601f3a45e4ec73b8b9b3df8b68dce97e8e1f53982dc2982bbf87830a6469 +size 18674 diff --git a/Story/pfp/notorious-rooster/notorious_rooster_0.jpg b/Story/pfp/notorious-rooster/notorious_rooster_0.jpg new file mode 100644 index 0000000..14bd5c1 --- /dev/null +++ b/Story/pfp/notorious-rooster/notorious_rooster_0.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fbec126e2d57c88e2bc06b12f8b607a3d939a83a071b1293f7eb62953ab2db01 +size 8313 diff --git a/Story/pfp/raincloud/raincloudthedragon_0.jpg b/Story/pfp/raincloud/raincloudthedragon_0.jpg new file mode 100644 index 0000000..3d2f3b2 --- /dev/null +++ b/Story/pfp/raincloud/raincloudthedragon_0.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ab4102213d17ed8d44659071d72b618c8fff884bfda8618ff060a53fa3c809a1 +size 20866 diff --git a/Story/pfp/starboy/starboy_journeys_0.jpg b/Story/pfp/starboy/starboy_journeys_0.jpg new file mode 100644 index 0000000..138fcd2 --- /dev/null +++ b/Story/pfp/starboy/starboy_journeys_0.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c0a992b0d41e3d175c3fa2086a46c1eb097983343e9a5291ad0baeedf7cd2fc3 +size 22756 diff --git a/Story/pfp/ubear/ancientmalgru_0.jpg b/Story/pfp/ubear/ancientmalgru_0.jpg new file mode 100644 index 0000000..a14ef36 --- /dev/null +++ b/Story/pfp/ubear/ancientmalgru_0.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3001b2aef1542397cbdb8dae76d98d8c4a49b04b63799acd51d6b784f41e3250 +size 22054 diff --git a/Story/pfp/ubear/sky_city_2013_0.jpg b/Story/pfp/ubear/sky_city_2013_0.jpg new file mode 100644 index 0000000..523d5d4 --- /dev/null +++ b/Story/pfp/ubear/sky_city_2013_0.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9ee56b4589308a8cd6b7c996747e1d755e409af1dc78d5b748ba7cf1b67ccb9b +size 23277 diff --git a/Story/pfp/ubear/verify52w_0.jpg b/Story/pfp/ubear/verify52w_0.jpg new file mode 100644 index 0000000..14bd5c1 --- /dev/null +++ b/Story/pfp/ubear/verify52w_0.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fbec126e2d57c88e2bc06b12f8b607a3d939a83a071b1293f7eb62953ab2db01 +size 8313 diff --git a/tools/pfp_from_chat.py b/tools/pfp_from_chat.py new file mode 100644 index 0000000..6a5f417 --- /dev/null +++ b/tools/pfp_from_chat.py @@ -0,0 +1,301 @@ +""" +Scan TwitchDownloader chat JSONs for roster logins, download distinct profile images +(commenter.logo) as JPEGs under Story/pfp/, and print markdown snippets (or patch files). + +Usage: + python tools/pfp_from_chat.py [--chat-root PATH] [--story-root PATH] [--no-write-md] + +Defaults: + chat-root: MIXER_TWITCH_CHAT env, else Windows Synology path used in this project. + Writes JPEGs under Story/pfp// and inserts a marked section into each roster .md. +""" +from __future__ import annotations + +import argparse +import gzip +import json +import os +import re +import sys +from collections import defaultdict +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from urllib.parse import urlparse +from urllib.request import Request, urlopen + +# slug -> list of twitch logins (lowercase) +ROSTER: dict[str, list[str]] = { + "adrian": ["axe7adrian"], + "noname": ["noname106668"], + "agate": ["loonyagate"], + "notorious-rooster": ["notorious_rooster"], + "ubear": ["verify52w", "sky_city_2013", "ancientmalgru"], + "azure": ["guidingflyer530", "actuallynotazure"], + "starboy": ["starboy_journeys"], + "jenni": ["jennimilano"], + "heart-and-mind": ["heart_cccc", "brush_colourful"], + "beanie": ["beaniee__"], + "raincloud": ["raincloudthedragon"], +} + +MD_FILES: dict[str, str] = { + "adrian": "Adrian.md", + "noname": "Noname.md", + "agate": "Agate.md", + "notorious-rooster": "NotoriousRooster.md", + "ubear": "Ubear.md", + "azure": "Azure.md", + "starboy": "Starboy.md", + "jenni": "Jenni.md", + "heart-and-mind": "Heart-and-Mind.md", + "beanie": "Beanie.md", + "raincloud": "RaincloudTheDragon.md", +} + +DEFAULT_CHAT_ROOT = os.environ.get( + "MIXER_TWITCH_CHAT", + r"C:\Users\Nathan\SynologyDrive\YouTube\Streams\MixerTwitch", +) + + +def _avatar_fingerprint(logo_url: str) -> str | None: + """Stable id for the same uploaded image (ignores 70x70 vs 300x300, etc.).""" + if not logo_url: + return None + path = urlparse(logo_url).path + base = re.sub(r"-\d+x\d+\.(png|jpe?g|webp)$", "", path, flags=re.IGNORECASE) + return base or path + + +def _best_logo_url(logo_url: str) -> str: + """Prefer a larger Twitch CDN size for download.""" + if not logo_url: + return logo_url + u = logo_url + for small, large in (("70x70", "300x300"), ("36x36", "300x300"), ("50x50", "300x300")): + if small in u: + u = u.replace(small, large) + break + return u + + +def _parse_created_at(raw) -> datetime | None: + if raw is None: + return None + if isinstance(raw, (int, float)): + return datetime.utcfromtimestamp(raw) + s = str(raw).replace("Z", "+00:00") + try: + return datetime.fromisoformat(s) + except ValueError: + return None + + +def open_chat_json(path: Path) -> dict | None: + try: + with path.open("rb") as f: + magic = f.read(4) + if magic[:2] == b"\x1f\x8b": + with gzip.open(path, "rt", encoding="utf-8") as f: + return json.load(f) + with path.open("r", encoding="utf-8") as f: + return json.load(f) + except (OSError, json.JSONDecodeError) as e: + print(f"skip {path}: {e}", file=sys.stderr) + return None + + +@dataclass +class SeenAvatar: + fingerprint: str + first_at: datetime + url: str + + +def collect_avatars(chat_root: Path) -> dict[tuple[str, str], dict[str, SeenAvatar]]: + """ + Returns map (slug, login) -> fingerprint -> SeenAvatar (earliest message wins per fp). + """ + login_to_slug: dict[str, str] = {} + for slug, logins in ROSTER.items(): + for lg in logins: + login_to_slug[lg.lower()] = slug + + # (slug, login) -> fp -> SeenAvatar + acc: dict[tuple[str, str], dict[str, SeenAvatar]] = defaultdict(dict) + + files = sorted(chat_root.glob("*/chat/*.json")) + if not files: + print(f"No JSON under {chat_root}/*/chat/", file=sys.stderr) + return acc + + for jp in files: + data = open_chat_json(jp) + if not data: + continue + for c in data.get("comments") or []: + com = c.get("commenter") or {} + login = (com.get("name") or com.get("login") or "").strip().lower() + if not login or login not in login_to_slug: + continue + logo = com.get("logo") + if not logo or not str(logo).startswith("http"): + continue + fp = _avatar_fingerprint(str(logo)) + if not fp: + continue + slug = login_to_slug[login] + key = (slug, login) + created = _parse_created_at(c.get("created_at")) + if created is None: + created = datetime.min + cur = acc[key].get(fp) + url = _best_logo_url(str(logo)) + if cur is None or created < cur.first_at: + acc[key][fp] = SeenAvatar(fp, created, url) + + return acc + + +def download_as_jpeg(url: str, dest: Path) -> bool: + dest.parent.mkdir(parents=True, exist_ok=True) + req = Request(url, headers={"User-Agent": "BattleRoyale-pfp-fetch/1.0"}) + try: + with urlopen(req, timeout=60) as resp: + data = resp.read() + except OSError as e: + print(f"download failed {url}: {e}", file=sys.stderr) + return False + + try: + from io import BytesIO + + from PIL import Image + + im = Image.open(BytesIO(data)).convert("RGB") + im.save(dest, "JPEG", quality=92) + except ImportError: + # No Pillow: write raw if already jpeg + if data[:2] == b"\xff\xd8": + dest.write_bytes(data) + else: + print("Install Pillow for PNG/WebP -> JPEG: pip install pillow", file=sys.stderr) + return False + except OSError as e: + print(f"jpeg encode {url}: {e}", file=sys.stderr) + return False + return True + + +SECTION_START = "" +SECTION_END = "" + + +def build_section_md(images: list[tuple[str, str]]) -> str: + """images: list of (caption, relative_path from Story/)""" + lines = [SECTION_START, "", "## Profile pictures", ""] + for caption, rp in images: + lines.append(f"**{caption}**") + lines.append("") + lines.append(f"![]({rp})") + lines.append("") + lines.append(SECTION_END) + return "\n".join(lines) + "\n" + + +def inject_section(content: str, section_md: str) -> str: + if SECTION_START in content and SECTION_END in content: + pre, _, rest = content.partition(SECTION_START) + _, _, post = rest.partition(SECTION_END) + return pre.rstrip() + "\n\n" + section_md + "\n" + post.lstrip() + # After first heading line + lines = content.splitlines() + if not lines: + return section_md + content + out = [lines[0], "", section_md] + if len(lines) > 1 and lines[1].strip(): + out.append("") + out.extend(lines[1:]) + return "\n".join(out) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--chat-root", type=Path, default=Path(DEFAULT_CHAT_ROOT)) + ap.add_argument("--story-root", type=Path, default=Path(__file__).resolve().parents[1] / "Story") + ap.add_argument( + "--no-write-md", + action="store_true", + help="Only download images; do not edit Story/*.md", + ) + args = ap.parse_args() + + if not args.chat_root.is_dir(): + print(f"Chat root not found: {args.chat_root}", file=sys.stderr) + print("Set MIXER_TWITCH_CHAT or pass --chat-root.", file=sys.stderr) + return 1 + + acc = collect_avatars(args.chat_root) + pfp_root = args.story_root / "pfp" + + # Flatten per slug for multi-login: still per-login files + for slug, md_name in MD_FILES.items(): + story_rel = f"pfp/{slug}" + section_images: list[tuple[str, str]] = [] + + display = Path(MD_FILES[slug]).stem.replace("-", " ") + + if slug == "heart-and-mind": + order = [("Heart", "heart_cccc"), ("Mind", "brush_colourful")] + for label, login in order: + key = (slug, login) + avs = acc.get(key, {}) + ordered = sorted(avs.values(), key=lambda x: x.first_at) + for i, av in enumerate(ordered): + fn = f"{login}_{i}.jpg" + rel = f"{story_rel}/{fn}" + dest = pfp_root / slug / fn + if download_as_jpeg(av.url, dest): + cap = f"{label} (`{login}`)" + if len(ordered) > 1: + cap = f"{cap} — {i + 1}" + section_images.append((cap, rel)) + else: + logins = ROSTER[slug] + for login in logins: + key = (slug, login) + avs = acc.get(key, {}) + ordered = sorted(avs.values(), key=lambda x: x.first_at) + if not ordered: + continue + for i, av in enumerate(ordered): + fn = f"{login}_{i}.jpg" + rel = f"{story_rel}/{fn}" + dest = pfp_root / slug / fn + if not download_as_jpeg(av.url, dest): + continue + if len(logins) > 1: + cap = f"`{login}`" + else: + cap = display + if len(ordered) > 1: + cap = f"{cap} — {i + 1}" + section_images.append((cap, rel)) + + if not section_images: + print(f"No avatars found for {slug}", file=sys.stderr) + continue + + section_md = build_section_md(section_images) + md_path = args.story_root / md_name + if not args.no_write_md and md_path.is_file(): + text = md_path.read_text(encoding="utf-8") + md_path.write_text(inject_section(text, section_md), encoding="utf-8") + print(f"updated {md_path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())