@mannyflussnpm/adapter-youtube-takeout 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body>
4
+ <div class="outer-cell mdl-cell mdl-cell--12-col mdl-shadow--2dp">
5
+ <div class="mdl-grid">
6
+ <div class="header-cell mdl-cell mdl-cell--12-col">
7
+ <p class="mdl-typography--title">YouTube<br /></p>
8
+ </div>
9
+ <div class="content-cell mdl-cell mdl-cell--6-col mdl-typography--body-1">
10
+ Watched <a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">Rick Astley - Never Gonna Give You Up</a><br />
11
+ <a href="https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw">Rick Astley</a><br />
12
+ Nov 14, 2023, 10:30:15 AM GMT+00:00<br />
13
+ </div>
14
+ </div>
15
+ </div>
16
+ <div class="outer-cell mdl-cell mdl-cell--12-col mdl-shadow--2dp">
17
+ <div class="mdl-grid">
18
+ <div class="header-cell mdl-cell mdl-cell--12-col">
19
+ <p class="mdl-typography--title">YouTube<br /></p>
20
+ </div>
21
+ <div class="content-cell mdl-cell mdl-cell--6-col mdl-typography--body-1">
22
+ Watched <a href="https://www.youtube.com/watch?v=jNQXAC9IVRw">Me at the zoo</a><br />
23
+ <a href="https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A">jawed</a><br />
24
+ Apr 23, 2005, 8:31:00 PM GMT+00:00<br />
25
+ </div>
26
+ </div>
27
+ </div>
28
+ </body>
29
+ </html>
package/index.ts ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * youtube-takeout — a Tier-0 (file-drop) adapter for YouTube Takeout exports.
3
+ *
4
+ * Reads `watch-history.html` from a Google Takeout export (YouTube →
5
+ * history). Parses the HTML to extract watched videos with title, channel,
6
+ * URL, and watch timestamp. Emits `video_watched` envelopes.
7
+ *
8
+ * Cursor = max watch timestamp, so re-runs only pick up new exports.
9
+ *
10
+ * Point YOUTUBE_TAKEOUT_PATH at your watch-history.html;
11
+ * defaults to the bundled synthetic fixture for conformance testing.
12
+ */
13
+
14
+ import { readFileSync } from "node:fs";
15
+ import { fileURLToPath } from "node:url";
16
+ import {
17
+ ENVELOPE_VERSION,
18
+ type Adapter,
19
+ type AdapterContext,
20
+ type Cursor,
21
+ type Envelope,
22
+ type PullResult,
23
+ } from "@mannyflussnpm/spec";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Config
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const TAKEOUT_PATH =
30
+ process.env.YOUTUBE_TAKEOUT_PATH ??
31
+ fileURLToPath(new URL("./fixtures/watch-history.html", import.meta.url));
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Helpers
35
+ // ---------------------------------------------------------------------------
36
+
37
+ interface ParsedEntry {
38
+ title: string;
39
+ channel: string;
40
+ channelId: string;
41
+ videoId: string;
42
+ url: string;
43
+ watchedAt: string; // ISO
44
+ }
45
+
46
+ /**
47
+ * Parse YouTube Takeout watch-history.html into structured entries.
48
+ * The HTML format is consistent: each entry is a content-cell div containing
49
+ * "Watched <a href=video>Title</a><br><a href=channel>Channel</a><br>Date<br>"
50
+ */
51
+ function parseHistory(html: string): ParsedEntry[] {
52
+ const entries: ParsedEntry[] = [];
53
+
54
+ // Find all content-cell divs
55
+ const cellRegex =
56
+ /<div class="content-cell[^"]* mdl-typography--body-1">([\s\S]*?)<\/div>/g;
57
+
58
+ let match;
59
+ while ((match = cellRegex.exec(html)) !== null) {
60
+ // Normalize unicode spaces
61
+ const content = match[1].replace(/\u202f/g, " ").replace(/&nbsp;/g, " ");
62
+
63
+ // Only process "Watched" entries (not "Viewed" posts)
64
+ if (!content.includes("Watched")) continue;
65
+
66
+ // Extract video link: <a href="...watch?v=VIDEO_ID">TITLE</a>
67
+ const videoMatch = content.match(
68
+ /<a href="https?:\/\/www\.youtube\.com\/watch\?v=([^"]+)">([^<]+)<\/a>/,
69
+ );
70
+ if (!videoMatch) continue;
71
+
72
+ const videoId = videoMatch[1];
73
+ const title = videoMatch[2]
74
+ .replace(/&amp;/g, "&")
75
+ .replace(/&quot;/g, '"')
76
+ .replace(/&#39;/g, "'")
77
+ .replace(/&lt;/g, "<")
78
+ .replace(/&gt;/g, ">");
79
+
80
+ // Extract optional channel link: <a href="...channel/CHANNEL_ID">CHANNEL</a>
81
+ const channelMatch = content.match(
82
+ /<a href="https?:\/\/www\.youtube\.com\/channel\/([^"]+)">([^<]+)<\/a>/,
83
+ );
84
+ const channel = channelMatch
85
+ ? channelMatch[2]
86
+ .replace(/&amp;/g, "&")
87
+ .replace(/&quot;/g, '"')
88
+ .replace(/&#39;/g, "'")
89
+ : "Unknown";
90
+ const channelId = channelMatch ? channelMatch[1] : "";
91
+
92
+ // Extract date: text after last <br>. Two shapes in the wild, and the
93
+ // trailing offset is optional — Takeout emits both:
94
+ // "Jul 19, 2026, 10:39:10 PM PDT"
95
+ // "Nov 14, 2023, 10:30:15 AM GMT+00:00"
96
+ const dateMatch = content.match(
97
+ /<br\s*\/?>\s*([A-Z][a-z]{2} \d{1,2}, \d{4}, \d{1,2}:\d{2}:\d{2} [AP]M [A-Z]+(?:[+-]\d{2}:\d{2})?)\s*(?:<br|$)/,
98
+ );
99
+ if (!dateMatch) continue;
100
+
101
+ const watchedAt = parseTakeoutDate(dateMatch[1]);
102
+
103
+ entries.push({
104
+ title,
105
+ channel,
106
+ channelId,
107
+ videoId,
108
+ url: `https://youtube.com/watch?v=${videoId}`,
109
+ watchedAt,
110
+ });
111
+ }
112
+
113
+ return entries;
114
+ }
115
+
116
+ /**
117
+ * Parse YouTube Takeout date formats:
118
+ * "Nov 14, 2023, 10:30:15 AM GMT+00:00"
119
+ * "Jul 19, 2026, 10:39:10 PM PDT"
120
+ */
121
+ function parseTakeoutDate(raw: string): string {
122
+ // Try GMT offset format first
123
+ let match = raw.match(
124
+ /([A-Z][a-z]{2}) (\d{1,2}), (\d{4}), (\d{1,2}):(\d{2}):(\d{2}) ([AP]M) GMT([+-]\d{2}):(\d{2})/,
125
+ );
126
+ if (match) {
127
+ return buildDate(match, true, match[8], match[9]);
128
+ }
129
+
130
+ // Try timezone abbreviation format: "Jul 19, 2026, 10:39:10 PM PDT"
131
+ match = raw.match(
132
+ /([A-Z][a-z]{2}) (\d{1,2}), (\d{4}), (\d{1,2}):(\d{2}):(\d{2}) ([AP]M) ([A-Z]+)/,
133
+ );
134
+ if (match) {
135
+ return buildDate(match, false, match[8], null);
136
+ }
137
+
138
+ return new Date(0).toISOString();
139
+ }
140
+
141
+ function buildDate(
142
+ match: RegExpMatchArray,
143
+ _hasOffset: boolean,
144
+ tzStr: string,
145
+ tzMin: string | null,
146
+ ): string {
147
+ const months: Record<string, number> = {
148
+ Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5,
149
+ Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11,
150
+ };
151
+
152
+ // Common US timezone offsets (standard / daylight)
153
+ const tzOffsets: Record<string, number> = {
154
+ PST: -8, PDT: -7,
155
+ MST: -7, MDT: -6,
156
+ CST: -6, CDT: -5,
157
+ EST: -5, EDT: -4,
158
+ GMT: 0, UTC: 0,
159
+ };
160
+
161
+ const month = months[match[1]];
162
+ const day = parseInt(match[2]);
163
+ const year = parseInt(match[3]);
164
+ let hours = parseInt(match[4]);
165
+ const minutes = parseInt(match[5]);
166
+ const seconds = parseInt(match[6]);
167
+ const ampm = match[7];
168
+
169
+ if (ampm === "PM" && hours < 12) hours += 12;
170
+ if (ampm === "AM" && hours === 12) hours = 0;
171
+
172
+ // Calculate offset in minutes
173
+ let offsetMin = 0;
174
+ if (tzMin !== null) {
175
+ // GMT offset format: "+00:00" or "-07:00"
176
+ const sign = tzStr.startsWith("-") ? -1 : 1;
177
+ offsetMin = sign * (parseInt(tzStr.substring(1)) * 60 + parseInt(tzMin));
178
+ } else {
179
+ // Timezone abbreviation
180
+ offsetMin = (tzOffsets[tzStr] ?? 0) * 60;
181
+ }
182
+
183
+ const localMs = Date.UTC(year, month, day, hours, minutes, seconds);
184
+ return new Date(localMs - offsetMin * 60 * 1000).toISOString();
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Adapter
189
+ // ---------------------------------------------------------------------------
190
+
191
+ const adapter: Adapter = {
192
+ name: "youtube-takeout",
193
+
194
+ async pull(cursor: Cursor, _ctx: AdapterContext): Promise<PullResult> {
195
+ const since = cursor ? new Date(cursor) : new Date(0);
196
+ const events: Envelope[] = [];
197
+ let maxDate = since;
198
+
199
+ let html: string;
200
+ try {
201
+ html = readFileSync(TAKEOUT_PATH, "utf8");
202
+ } catch {
203
+ return { events: [], cursor, hasMore: false };
204
+ }
205
+
206
+ const entries = parseHistory(html);
207
+
208
+ for (const entry of entries) {
209
+ const ts = new Date(entry.watchedAt);
210
+ if (ts <= since) continue;
211
+
212
+ events.push({
213
+ v: ENVELOPE_VERSION,
214
+ ts: entry.watchedAt,
215
+ source: adapter.name,
216
+ type: "video_watched",
217
+ id: entry.videoId + "-" + entry.watchedAt,
218
+ payload: {
219
+ title: entry.title,
220
+ channel: entry.channel,
221
+ channelId: entry.channelId,
222
+ videoId: entry.videoId,
223
+ url: entry.url,
224
+ },
225
+ });
226
+
227
+ if (ts > maxDate) maxDate = ts;
228
+ }
229
+
230
+ return {
231
+ events,
232
+ cursor: events.length > 0 ? maxDate.toISOString() : cursor,
233
+ hasMore: false,
234
+ };
235
+ },
236
+ };
237
+
238
+ export default adapter;
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@mannyflussnpm/adapter-youtube-takeout",
3
+ "version": "1.0.0",
4
+ "description": "adapter for Personal Data Vault",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "types": "./index.ts",
8
+ "exports": {
9
+ ".": "./index.ts"
10
+ },
11
+ "pdv": {
12
+ "type": "adapter"
13
+ },
14
+ "peerDependencies": {
15
+ "@mannyflussnpm/spec": "^1.0.0"
16
+ },
17
+ "license": "MIT"
18
+ }