File size: 16,557 Bytes
c6230f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
"""
Generate the demo Takeout data committed under backend/fixtures/.

Run:  python scripts/generate_demo_data.py

The demo has to survive the same statistical thresholds as real data -- MIN_SUPPORT of
15, MIN_LIFT of 1.5, the seasonal three-month span rule. Sprinkling random watches
produces a Wrapped where every insight card reads "no patterns found", so the generator
plants specific, defensible signals:

  personality   a night-owl clock, weighted so the 0-5 slot beats every other slot
                once measured against its own share of the 24 hours
  co-occurrence themed sessions (music / science / tech), which is how real viewing
                clusters -- you do not watch one lofi channel, you watch three
  seasonal      one channel present all year but spiking in December
  day habit     a Sunday science ritual
  time habit    a morning tech ritual
  opener        one channel that reliably starts a session
  rewatch       a lofi radio stream played many times over

The output is a real Takeout ZIP, with history rendered as HTML -- the format Google
actually produces by default -- so the demo exercises the whole path: ZIP member
location, format detection, the HTML connector, timestamp resolution, year filtering,
preprocessing and card generation. Serving pre-parsed JSON would skip most of that and
let the demo keep working after the parts it is meant to showcase had broken.

Output is deterministic: same SEED, byte-identical fixtures.
"""

import csv
import io
import json
import os
import random
import zipfile
from datetime import datetime, timedelta
from html import escape

SEED = 20251
YEAR = 2025
TARGET_WATCHES = 2500

HERE = os.path.dirname(os.path.abspath(__file__))
FIXTURES = os.path.join(HERE, "..", "fixtures")

# Session start slot -> weight. Tuned so that after sessions run their course, the
# measured 0-5 share beats every other slot's share-of-hours. Sessions starting at 23
# spill past midnight, which is what actually creates a night owl.
START_SLOT_WEIGHTS = [
    ((0, 4), 32),     # genuinely after midnight
    ((23, 24), 16),   # starts before midnight, spills past it
    ((18, 20), 14),
    ((7, 10), 20),    # the morning tech ritual
    ((13, 18), 18),
]

THEMES = {
    "music": [
        ("Lofi Girl", "UCSJ4gkVC6NrvII8umztf0Ow"),
        ("Chillhop Music", "UCfSUheoljDlGDjerRylO4Nw"),
        ("ODESZA", "UCwPCwqB2CqDdnBqmSJIcNRQ"),
    ],
    "science": [
        ("Kurzgesagt", "UCsXVk37bltHxD1rDPwtNM8Q"),
        ("Veritasium", "UCHnyfMqiRRG1u-2MsSQLbXA"),
        ("Mark Rober", "UCY1kMZp36IQSyNx_9h4mpCg"),
    ],
    "tech": [
        ("MKBHD", "UCBJycsmduvYEL83R_U4JriQ"),
        ("Linus Tech Tips", "UCXuqSBlHAE6Xw-yeJA0Tunw"),
        ("Fireship", "UCsBjURrPoezykLs9EqgamOA"),
    ],
    "craft": [
        ("Adam Savage's Tested", "UCiDJtJKMICpb9B1qf7qjEOA"),
        ("Steve Mould", "UCdC0An4ZPNr_YiFiYoVbwaw"),
        ("Practical Engineering", "UCMOqf8ab-42UUQIdVoKwjlQ"),
    ],
}

OPENER = ("Daily Dose Of Internet", "UCdC0An4ZPNr_YiFiYoVbwaXX")
# The opener appears in a third of all sessions, so it needs as many distinct titles as
# any other channel -- three of them would make it the "most rewatched" video by default.
OPENER_SUBJECTS = [
    "A Cat Discovers Gravity", "The World's Fastest Peeler", "A Very Confident Goose",
    "Sand Falling In Slow Motion", "A Robot Learns To Walk", "The Perfect Domino Run",
    "An Unusually Polite Crow", "A Wave Freezes Mid-Air", "The Loudest Quiet Room",
    "A Dog Meets A Mirror", "Lightning In Reverse", "A Bridge Made Of Paper",
]
SEASONAL = ("Advent of Code Solutions", "UCseasonalXXXXXXXXXXXXXX")

LONG_TAIL_TOPICS = [
    "how to", "explained", "review", "documentary", "tutorial", "highlights",
    "interview", "deep dive", "reaction", "top 10", "unboxing", "analysis",
]

# Titles are built from templates x subjects rather than a short fixed list. With only
# a handful of titles per theme, 600 watches of that theme means the same video appears
# ~75 times and the "most rewatched" card reports nonsense. This yields a few hundred
# distinct titles per theme, so repeats happen at a believable rate.
TITLE_TEMPLATES = {
    "music": ["{} Mix", "{} Session", "{} Radio", "{} — Extended Set"],
    "science": ["Why {} Happens", "The Truth About {}", "{}, Explained", "What If {}?"],
    "tech": ["{} Review", "I Tried {} For A Week", "{} In 100 Seconds", "The Problem With {}"],
    "craft": ["One Day Build: {}", "How {} Actually Works", "Restoring {}", "Testing {}"],
}

TITLE_SUBJECTS = {
    "music": [
        "Rainy Day Jazz", "Sunset Drive", "Deep Focus", "Midnight City", "Ambient Study",
        "Late Night Piano", "Coffee Shop", "Winter Lights", "Slow Mornings", "Neon Streets",
        "Quiet Hours", "Rooftop Dusk", "Analog Dreams", "Paper Planes", "Blue Hour",
    ],
    "science": [
        "Black Holes", "The Fermi Paradox", "Cosmic Rays", "The Immune System",
        "Antimatter", "Deep Sea Vents", "Neutron Stars", "Gut Bacteria", "Ice Friction",
        "Solar Flares", "Memory Formation", "Plate Tectonics", "Dark Matter",
        "Bird Migration", "Quantum Tunnelling",
    ],
    "tech": [
        "The New Flagship", "A $500 Gaming PC", "Mechanical Keyboards", "TypeScript",
        "USB-C", "Rust", "This Studio Setup", "Server Components", "E-Ink Monitors",
        "Local LLMs", "Thunderbolt Docks", "Passkeys", "The Framework Laptop",
        "Neovim", "Docker Compose",
    ],
    "craft": [
        "A Workshop Cart", "Suspension Bridges", "A Spinning Top", "A 1950s Lathe",
        "Concrete", "Cheap Tools", "A Vacuum Former", "Ball Bearings", "Hydraulic Presses",
        "Wood Joinery", "A CNC Router", "Cast Iron",
    ],
}

# One deliberately replayed stream, so the "most rewatched" card has a real answer.
REPEAT_TITLE = "lofi hip hop radio - beats to relax/study to"
REPEAT_CHANNEL = "Lofi Girl"


def make_title(rng, theme):
    template = rng.choice(TITLE_TEMPLATES[theme])
    return template.format(rng.choice(TITLE_SUBJECTS[theme]))


def channel_url(channel_id):
    return f"https://www.youtube.com/channel/{channel_id}"


def video_url(rng):
    alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"
    return "https://www.youtube.com/watch?v=" + "".join(rng.choice(alphabet) for _ in range(11))


def entry(title, url, channel, channel_id, when):
    return {
        "header": "YouTube",
        "title": f"Watched {title}",
        "titleUrl": url,
        "subtitles": [{"name": channel, "url": channel_url(channel_id)}],
        "time": when.strftime("%Y-%m-%dT%H:%M:%SZ"),
    }


def pick_start_hour(rng):
    total = sum(weight for _, weight in START_SLOT_WEIGHTS)
    roll = rng.uniform(0, total)
    running = 0
    for (start, end), weight in START_SLOT_WEIGHTS:
        running += weight
        if roll <= running:
            return rng.randrange(start, end) % 24
    return 21


def pick_theme(rng, day, hour):
    """Themes are what make co-occurrence real: a sitting has a mood."""
    if day.weekday() == 6 and rng.random() < 0.62:
        return "science"          # the Sunday ritual
    if 7 <= hour < 10 and rng.random() < 0.70:
        return "tech"             # the morning ritual
    return rng.choices(
        ["music", "science", "tech", "craft"], weights=[34, 22, 26, 18]
    )[0]


def build_watch_history():
    rng = random.Random(SEED)
    start = datetime(YEAR, 1, 1)
    entries = []
    long_tail = [
        (f"{topic.title()} Channel {i}", f"UClongtail{i:04d}XXXXXXXXXXX")
        for i, topic in enumerate(LONG_TAIL_TOPICS * 21)
    ]

    for day in range(365):
        date = start + timedelta(days=day)

        # ~82% active days, plus a deliberate unbroken streak in the summer
        in_streak = 165 <= date.timetuple().tm_yday <= 218
        if not in_streak and rng.random() > 0.82:
            continue

        for _ in range(rng.choices([1, 2], weights=[72, 28])[0]):
            hour = pick_start_hour(rng)
            when = date.replace(hour=hour, minute=rng.randrange(0, 60))
            theme = pick_theme(rng, date, hour)

            # A session opener is a habit: the same channel starts the run.
            if rng.random() < 0.34:
                entries.append(
                    entry(rng.choice(OPENER_SUBJECTS), video_url(rng),
                          OPENER[0], OPENER[1], when)
                )
                when += timedelta(minutes=rng.randrange(2, 6))

            marathon = date.weekday() >= 5 and rng.random() < 0.22
            length = (rng.randrange(26, 42) if marathon
                      else rng.choices([3, 5, 8, 14], weights=[40, 32, 20, 8])[0])
            for _ in range(length):
                # December spike on a channel that is present all year anyway --
                # concentration only counts as seasonal if it could have appeared
                # elsewhere.
                seasonal_chance = 0.42 if date.month == 12 else 0.02
                if rng.random() < seasonal_chance:
                    channel, channel_id = SEASONAL
                    title = f"Day {rng.randrange(1, 26)} Puzzle Walkthrough"
                elif rng.random() < 0.72:
                    channel, channel_id = rng.choice(THEMES[theme])
                    if channel == REPEAT_CHANNEL and rng.random() < 0.11:
                        title = REPEAT_TITLE
                    else:
                        title = make_title(rng, theme)
                else:
                    channel, channel_id = rng.choice(long_tail)
                    title = f"{rng.choice(LONG_TAIL_TOPICS).title()} {rng.randrange(1, 400)}"

                entries.append(entry(title, video_url(rng), channel, channel_id, when))
                when += timedelta(minutes=rng.randrange(3, 9))

    # Trim evenly across the year rather than keeping only the newest, so every month
    # survives -- truncating a sorted list would silently cut the demo to six months.
    if len(entries) > TARGET_WATCHES:
        keep = set(rng.sample(range(len(entries)), TARGET_WATCHES))
        entries = [e for i, e in enumerate(entries) if i in keep]

    entries.sort(key=lambda e: e["time"], reverse=True)  # Takeout is newest-first
    return entries


SEARCH_TERMS = [
    "kurzgesagt black hole", "how to learn rust", "lofi study mix", "mkbhd iphone review",
    "veritasium electricity", "typescript generics", "best mechanical keyboard",
    "advent of code day 7", "how do bridges work", "react server components",
    "chillhop radio", "python async explained", "mark rober squirrel maze",
    "docker compose tutorial", "why is the sky blue", "linux ricing guide",
    "odesza live set", "css grid vs flexbox", "quantum computing explained",
    "home lab setup",
]


def build_search_history():
    rng = random.Random(SEED + 1)
    start = datetime(YEAR, 1, 1)
    out = []
    for _ in range(180):
        term = rng.choice(SEARCH_TERMS)
        when = start + timedelta(
            days=rng.randrange(0, 365), hours=pick_start_hour(rng),
            minutes=rng.randrange(0, 60),
        )
        out.append({
            "header": "YouTube",
            "title": f"Searched for {term}",
            "titleUrl": "https://www.youtube.com/results?search_query="
                        + term.replace(" ", "+"),
            "time": when.strftime("%Y-%m-%dT%H:%M:%SZ"),
        })
    out.sort(key=lambda e: e["time"], reverse=True)
    return out


def build_subscriptions(watch_entries):
    """Subscribed channels, only some of which get watched -- that is the ghost stat."""
    rng = random.Random(SEED + 2)
    watched = []
    for item in watch_entries:
        for subtitle in item.get("subtitles", []):
            watched.append((subtitle["name"], subtitle["url"].rsplit("/", 1)[-1]))
    unique = sorted(set(watched))
    rng.shuffle(unique)

    rows = [(cid, f"https://www.youtube.com/channel/{cid}", name)
            for name, cid in unique[:58]]
    for i in range(92):  # subscribed and never watched
        cid = f"UCghost{i:04d}XXXXXXXXXXXXX"
        rows.append((cid, f"https://www.youtube.com/channel/{cid}", f"Ghost Channel {i}"))

    rng.shuffle(rows)
    return rows


TAKEOUT_ROOT = "Takeout/YouTube and YouTube Music"
CSV_NEWLINE = chr(10)
NBSP = " "      # Takeout puts this after the activity verb
NNBSP = " "   # ...and this before the meridiem

CAPTION = (
    "<b>Products:</b><br>&emsp;YouTube<br>"
    "<b>Why is this here?</b><br>&emsp;This activity was saved to your Google Account "
    "because the following settings were on:&nbsp;YouTube watch history."
)

HTML_HEAD = (
    "<html><head><title>My Activity History</title>"
    "<style type=\"text/css\">.outer-cell{margin-bottom:16px}</style></head>"
    "<body><div class=\"mdl-grid\">"
)
HTML_TAIL = "</div></body></html>"


def render_stamp(when):
    """e.g. 'Jan 5, 2025, 11:30:45 PM UTC' with the narrow no-break space Takeout uses.

    Rendered in UTC and tagged UTC so the demo's timezone round-trips exactly and the
    planted clock-hour signals land where the generator put them.
    """
    hour = when.hour % 12 or 12
    meridiem = "AM" if when.hour < 12 else "PM"
    return (
        f"{when.strftime('%b')} {when.day}, {when.year}, "
        f"{hour}:{when.strftime('%M:%S')}{NNBSP}{meridiem} UTC"
    )


def render_cell(verb, title, url, channel, channel_url_, stamp):
    """One outer-cell, matching the template takeout_html_scanner parses."""
    body = f"{verb}{NBSP}<a href=\"{escape(url, quote=True)}\">{escape(title)}</a><br>"
    if channel:
        body += f"<a href=\"{escape(channel_url_, quote=True)}\">{escape(channel)}</a><br>"
    body += f"{stamp}<br>"

    return (
        '<div class="outer-cell mdl-cell mdl-cell--12-col mdl-shadow--2dp">'
        '<div class="mdl-grid">'
        '<div class="header-cell mdl-cell mdl-cell--12-col">'
        '<p class="mdl-typography--title">YouTube<br></p></div>'
        '<div class="content-cell mdl-cell mdl-cell--6-col mdl-typography--body-1">'
        f"{body}</div>"
        '<div class="content-cell mdl-cell mdl-cell--6-col mdl-typography--body-1 '
        'mdl-typography--text-right"></div>'
        '<div class="content-cell mdl-cell mdl-cell--12-col mdl-typography--caption">'
        f"{CAPTION}</div></div></div>"
    )


def render_watch_html(entries):
    cells = []
    for item in entries:
        when = datetime.strptime(item["time"], "%Y-%m-%dT%H:%M:%SZ")
        subtitle = (item.get("subtitles") or [{}])[0]
        cells.append(render_cell(
            "Watched", item["title"][len("Watched "):], item["titleUrl"],
            subtitle.get("name"), subtitle.get("url", ""), render_stamp(when),
        ))
    return HTML_HEAD + "".join(cells) + HTML_TAIL


def render_search_html(entries):
    cells = []
    for item in entries:
        when = datetime.strptime(item["time"], "%Y-%m-%dT%H:%M:%SZ")
        cells.append(render_cell(
            "Searched for", item["title"][len("Searched for "):], item["titleUrl"],
            None, "", render_stamp(when),
        ))
    return HTML_HEAD + "".join(cells) + HTML_TAIL


def write_takeout_zip(path, watch, search, subs_rows):
    """A real Takeout archive: HTML history plus the subscriptions CSV."""
    csv_buffer = io.StringIO()
    writer = csv.writer(csv_buffer, lineterminator=CSV_NEWLINE)
    writer.writerow(["Channel Id", "Channel Url", "Channel Title"])
    writer.writerows(subs_rows)

    with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
        # Fixed timestamps keep the archive byte-identical between runs.
        def add(name, text):
            info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
            info.compress_type = zipfile.ZIP_DEFLATED
            archive.writestr(info, text)

        add(f"{TAKEOUT_ROOT}/history/watch-history.html", render_watch_html(watch))
        add(f"{TAKEOUT_ROOT}/history/search-history.html", render_search_html(search))
        add(f"{TAKEOUT_ROOT}/subscriptions/subscriptions.csv", csv_buffer.getvalue())


def main():
    os.makedirs(FIXTURES, exist_ok=True)

    watch = build_watch_history()
    search = build_search_history()
    subs = build_subscriptions(watch)

    zip_path = os.path.join(FIXTURES, "demo_takeout.zip")
    write_takeout_zip(zip_path, watch, search, subs)

    print(f"watch entries : {len(watch)}")
    print(f"search entries: {len(search)}")
    print(f"subscriptions : {len(subs)}")
    print(f"archive       : {os.path.getsize(zip_path) / 1024:.0f} KB  ({zip_path})")


if __name__ == "__main__":
    main()