append pfps

This commit is contained in:
2026-04-04 21:57:44 -06:00
parent bd7c87ae8c
commit b9ab938c71
34 changed files with 543 additions and 11 deletions
+16 -1
View File
@@ -1,5 +1,20 @@
# Adrian
<!-- pfp:chat-exports -->
## Profile pictures
**Adrian — 1**
![](pfp/adrian/axe7adrian_0.jpg)
**Adrian — 2**
![](pfp/adrian/axe7adrian_1.jpg)
<!-- /pfp:chat-exports -->
- 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"
+12 -1
View File
@@ -1,5 +1,16 @@
# Agate
<!-- pfp:chat-exports -->
## Profile pictures
**Agate**
![](pfp/agate/loonyagate_0.jpg)
<!-- /pfp:chat-exports -->
- 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"
+20 -1
View File
@@ -1,5 +1,24 @@
# Azure
<!-- pfp:chat-exports -->
## Profile pictures
**`guidingflyer530` — 1**
![](pfp/azure/guidingflyer530_0.jpg)
**`guidingflyer530` — 2**
![](pfp/azure/guidingflyer530_1.jpg)
**`actuallynotazure`**
![](pfp/azure/actuallynotazure_0.jpg)
<!-- /pfp:chat-exports -->
- 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"
+12 -1
View File
@@ -1,5 +1,16 @@
# Beanie
<!-- pfp:chat-exports -->
## Profile pictures
**Beanie**
![](pfp/beanie/beaniee___0.jpg)
<!-- /pfp:chat-exports -->
- 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"
+16 -1
View File
@@ -1,5 +1,20 @@
# Heart and Mind
<!-- pfp:chat-exports -->
## Profile pictures
**Heart (`heart_cccc`)**
![](pfp/heart-and-mind/heart_cccc_0.jpg)
**Mind (`brush_colourful`)**
![](pfp/heart-and-mind/brush_colourful_0.jpg)
<!-- /pfp:chat-exports -->
- 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"
+12 -1
View File
@@ -1,5 +1,16 @@
# Jenni
<!-- pfp:chat-exports -->
## Profile pictures
**Jenni**
![](pfp/jenni/jennimilano_0.jpg)
<!-- /pfp:chat-exports -->
- 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"
+32 -1
View File
@@ -1,5 +1,36 @@
# Noname
<!-- pfp:chat-exports -->
## 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)
<!-- /pfp:chat-exports -->
- 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"
+12 -1
View File
@@ -1,5 +1,16 @@
# NotoriousRooster
<!-- pfp:chat-exports -->
## Profile pictures
**NotoriousRooster**
![](pfp/notorious-rooster/notorious_rooster_0.jpg)
<!-- /pfp:chat-exports -->
- 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?"
+12 -1
View File
@@ -1,5 +1,16 @@
# RaincloudTheDragon
<!-- pfp:chat-exports -->
## Profile pictures
**RaincloudTheDragon**
![](pfp/raincloud/raincloudthedragon_0.jpg)
<!-- /pfp:chat-exports -->
- 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"
+12 -1
View File
@@ -1,5 +1,16 @@
# Starboy
<!-- pfp:chat-exports -->
## Profile pictures
**Starboy**
![](pfp/starboy/starboy_journeys_0.jpg)
<!-- /pfp:chat-exports -->
- 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"
+20 -1
View File
@@ -1,5 +1,24 @@
# Ubear / Malgru
<!-- pfp:chat-exports -->
## Profile pictures
**`verify52w`**
![](pfp/ubear/verify52w_0.jpg)
**`sky_city_2013`**
![](pfp/ubear/sky_city_2013_0.jpg)
**`ancientmalgru`**
![](pfp/ubear/ancientmalgru_0.jpg)
<!-- /pfp:chat-exports -->
- 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"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+301
View File
@@ -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/<slug>/ 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 = "<!-- pfp:chat-exports -->"
SECTION_END = "<!-- /pfp:chat-exports -->"
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())