@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.
- package/fixtures/watch-history.html +29 -0
- package/index.ts +238 -0
- package/package.json +18 -0
|
@@ -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(/ /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(/&/g, "&")
|
|
75
|
+
.replace(/"/g, '"')
|
|
76
|
+
.replace(/'/g, "'")
|
|
77
|
+
.replace(/</g, "<")
|
|
78
|
+
.replace(/>/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(/&/g, "&")
|
|
87
|
+
.replace(/"/g, '"')
|
|
88
|
+
.replace(/'/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
|
+
}
|