@mannyflussnpm/adapter-youtube-history 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.json +41 -0
- package/index.ts +644 -0
- package/package.json +18 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "wl-001",
|
|
4
|
+
"snippet": {
|
|
5
|
+
"publishedAt": "2023-11-14T10:00:00.000Z",
|
|
6
|
+
"title": "How to Build a Data Vault in TypeScript",
|
|
7
|
+
"channelId": "UCtechwithtim",
|
|
8
|
+
"channelTitle": "Tech With Tim",
|
|
9
|
+
"resourceId": {
|
|
10
|
+
"kind": "youtube#video",
|
|
11
|
+
"videoId": "dQw4w9WgXcQ"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "wl-002",
|
|
17
|
+
"snippet": {
|
|
18
|
+
"publishedAt": "2023-11-14T14:30:00.000Z",
|
|
19
|
+
"title": "The Future of Local-First Software",
|
|
20
|
+
"channelId": "UCfireship",
|
|
21
|
+
"channelTitle": "Fireship",
|
|
22
|
+
"resourceId": {
|
|
23
|
+
"kind": "youtube#video",
|
|
24
|
+
"videoId": "jNQXAC9IVRw"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "wl-003",
|
|
30
|
+
"snippet": {
|
|
31
|
+
"publishedAt": "2023-11-15T09:00:00.000Z",
|
|
32
|
+
"title": "Understanding Zero-Knowledge Proofs",
|
|
33
|
+
"channelId": "UC3blue1brown",
|
|
34
|
+
"channelTitle": "3Blue1Brown",
|
|
35
|
+
"resourceId": {
|
|
36
|
+
"kind": "youtube#video",
|
|
37
|
+
"videoId": "abc123def45"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
]
|
package/index.ts
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* youtube-history — live YouTube watch history via the internal API.
|
|
3
|
+
*
|
|
4
|
+
* Uses YouTube's `youtubei/v1/browse` endpoint (the same one that powers
|
|
5
|
+
* youtube.com/feed/history) to pull the user's full watch history.
|
|
6
|
+
*
|
|
7
|
+
* Emits `video_watched` envelopes with title, channel, videoId, URL, and
|
|
8
|
+
* watch date (derived from section headers like "Monday", "Jun 18").
|
|
9
|
+
*
|
|
10
|
+
* **Transcript fetching (opt-in):** set `YOUTUBE_HISTORY_FETCH_TRANSCRIPTS=1`
|
|
11
|
+
* to also pull captions via YouTube's public timedtext API (no auth needed).
|
|
12
|
+
* Transcripts are stored as content-addressed assets.
|
|
13
|
+
*
|
|
14
|
+
* Cursor: JSON `{ continuation, newestVideoId }`. During backfill, the
|
|
15
|
+
* continuation token drives pagination. Once backfill is complete, only
|
|
16
|
+
* videos newer than `newestVideoId` are kept.
|
|
17
|
+
*
|
|
18
|
+
* ## Setup (one-time)
|
|
19
|
+
*
|
|
20
|
+
* Export your YouTube cookies using a browser extension like
|
|
21
|
+
* "Get cookies.txt LOCALLY" (Chrome/Firefox). Save the Netscape-format file to:
|
|
22
|
+
* ~/.personal-data-vault/youtube-cookies.txt
|
|
23
|
+
*
|
|
24
|
+
* Alternatively, set the raw Cookie header string as:
|
|
25
|
+
* export YOUTUBE_COOKIE='...'
|
|
26
|
+
*
|
|
27
|
+
* Cookies expire after a few weeks/months — re-export when pulls start
|
|
28
|
+
* returning 0 events.
|
|
29
|
+
*
|
|
30
|
+
* Falls back to synthetic fixture when no cookies are available (conformance).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
import { homedir } from "node:os";
|
|
36
|
+
import { join } from "node:path";
|
|
37
|
+
import { createHash } from "node:crypto";
|
|
38
|
+
import {
|
|
39
|
+
ENVELOPE_VERSION,
|
|
40
|
+
type Adapter,
|
|
41
|
+
type AdapterContext,
|
|
42
|
+
type Cursor,
|
|
43
|
+
type Envelope,
|
|
44
|
+
type PullResult,
|
|
45
|
+
} from "@mannyflussnpm/spec";
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Config
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
const FIXTURE_PATH = fileURLToPath(
|
|
52
|
+
new URL("./fixtures/watch-history.json", import.meta.url),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const COOKIES_PATH = join(
|
|
56
|
+
homedir(),
|
|
57
|
+
".personal-data-vault",
|
|
58
|
+
"youtube-cookies.txt",
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const BROWSE_URL = "https://www.youtube.com/youtubei/v1/browse";
|
|
62
|
+
const MAX_PER_PULL = 500;
|
|
63
|
+
|
|
64
|
+
const FETCH_TRANSCRIPTS =
|
|
65
|
+
process.env.YOUTUBE_HISTORY_FETCH_TRANSCRIPTS === "1";
|
|
66
|
+
|
|
67
|
+
/** YouTube's web client API key (public, embedded in youtube.com). */
|
|
68
|
+
const YT_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8";
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Cookie loading & auth
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
interface CookieJar {
|
|
75
|
+
header: string;
|
|
76
|
+
sapisid: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function loadCookies(): CookieJar | null {
|
|
80
|
+
// 1. Raw cookie header via env var
|
|
81
|
+
const envCookie = process.env.YOUTUBE_COOKIE;
|
|
82
|
+
if (envCookie) {
|
|
83
|
+
const sapisid = extractSapisidFromHeader(envCookie);
|
|
84
|
+
return { header: envCookie, sapisid };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 2. Netscape-format file
|
|
88
|
+
if (!existsSync(COOKIES_PATH)) return null;
|
|
89
|
+
|
|
90
|
+
const raw = readFileSync(COOKIES_PATH, "utf8");
|
|
91
|
+
const cookies: string[] = [];
|
|
92
|
+
let sapisid = "";
|
|
93
|
+
|
|
94
|
+
for (const line of raw.split("\n")) {
|
|
95
|
+
const trimmed = line.trim();
|
|
96
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
97
|
+
|
|
98
|
+
const parts = trimmed.split("\t");
|
|
99
|
+
if (parts.length < 7) continue;
|
|
100
|
+
|
|
101
|
+
const [domain, , , , , name, value] = parts;
|
|
102
|
+
if (!domain.includes("youtube.com")) continue;
|
|
103
|
+
|
|
104
|
+
cookies.push(`${name}=${value}`);
|
|
105
|
+
if (name === "SAPISID") sapisid = value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return cookies.length > 0
|
|
109
|
+
? { header: cookies.join("; "), sapisid }
|
|
110
|
+
: null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function extractSapisidFromHeader(header: string): string {
|
|
114
|
+
const match = header.match(/SAPISID=([^;]+)/);
|
|
115
|
+
return match ? match[1] : "";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function makeAuthHeader(sapisid: string): string {
|
|
119
|
+
const hash = createHash("sha1")
|
|
120
|
+
.update(sapisid + " https://www.youtube.com")
|
|
121
|
+
.digest("hex");
|
|
122
|
+
return `SAPISIDHASH ${hash}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Cursor helpers
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
interface YoutubeHistoryCursor {
|
|
130
|
+
continuation: string | null;
|
|
131
|
+
newestVideoId: string | null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseCursor(cursor: Cursor): YoutubeHistoryCursor {
|
|
135
|
+
if (!cursor) return { continuation: null, newestVideoId: null };
|
|
136
|
+
try {
|
|
137
|
+
return JSON.parse(cursor) as YoutubeHistoryCursor;
|
|
138
|
+
} catch {
|
|
139
|
+
return { continuation: null, newestVideoId: cursor };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function encodeCursor(c: YoutubeHistoryCursor): string {
|
|
144
|
+
return JSON.stringify(c);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// API call
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
async function browseRequest(
|
|
152
|
+
body: Record<string, unknown>,
|
|
153
|
+
jar: CookieJar,
|
|
154
|
+
): Promise<Record<string, unknown> | null> {
|
|
155
|
+
try {
|
|
156
|
+
const res = await fetch(`${BROWSE_URL}?key=${YT_API_KEY}`, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: {
|
|
159
|
+
"Content-Type": "application/json",
|
|
160
|
+
"X-YouTube-Client-Name": "1",
|
|
161
|
+
"X-YouTube-Client-Version": "2.20250625.00.00",
|
|
162
|
+
Cookie: jar.header,
|
|
163
|
+
Authorization: makeAuthHeader(jar.sapisid),
|
|
164
|
+
Origin: "https://www.youtube.com",
|
|
165
|
+
Referer: "https://www.youtube.com/feed/history",
|
|
166
|
+
},
|
|
167
|
+
body: JSON.stringify(body),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (!res.ok) {
|
|
171
|
+
console.error(
|
|
172
|
+
`[youtube-history] API returned ${res.status} ${res.statusText}`,
|
|
173
|
+
);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return (await res.json()) as Record<string, unknown>;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.error(`[youtube-history] API request failed:`, err);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Response parsing
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
interface ParsedEntry {
|
|
189
|
+
videoId: string;
|
|
190
|
+
title: string;
|
|
191
|
+
channel: string;
|
|
192
|
+
/** Date string from the section header, e.g. "Monday", "Jun 18", "Apr 15". */
|
|
193
|
+
dateLabel: string;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Parse the watch history browse response into flat video entries.
|
|
198
|
+
* Handles both the initial page structure and continuation responses.
|
|
199
|
+
*/
|
|
200
|
+
function parseHistoryResponse(
|
|
201
|
+
data: Record<string, unknown>,
|
|
202
|
+
): ParsedEntry[] {
|
|
203
|
+
const entries: ParsedEntry[] = [];
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
// Try initial page structure first
|
|
207
|
+
const tabs =
|
|
208
|
+
(data as any).contents?.twoColumnBrowseResultsRenderer?.tabs;
|
|
209
|
+
if (tabs?.[0]) {
|
|
210
|
+
const slr = tabs[0].tabRenderer?.content?.sectionListRenderer;
|
|
211
|
+
if (slr?.contents) {
|
|
212
|
+
parseSections(slr.contents, entries);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Try continuation response structure
|
|
217
|
+
const actions = (data as any).onResponseReceivedActions;
|
|
218
|
+
if (actions?.[0]?.appendContinuationItemsAction?.continuationItems) {
|
|
219
|
+
parseSections(
|
|
220
|
+
actions[0].appendContinuationItemsAction.continuationItems,
|
|
221
|
+
entries,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
// Response shape changed — return whatever we parsed so far
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return entries;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function parseSections(
|
|
232
|
+
sections: any[],
|
|
233
|
+
entries: ParsedEntry[],
|
|
234
|
+
): void {
|
|
235
|
+
let currentDate = "Unknown";
|
|
236
|
+
|
|
237
|
+
for (const section of sections) {
|
|
238
|
+
// Check for date header
|
|
239
|
+
const header = section.itemSectionRenderer?.header;
|
|
240
|
+
if (header?.itemSectionHeaderRenderer?.title?.simpleText) {
|
|
241
|
+
currentDate = header.itemSectionHeaderRenderer.title.simpleText;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const items = section.itemSectionRenderer?.contents;
|
|
245
|
+
if (!items) continue;
|
|
246
|
+
|
|
247
|
+
for (const item of items) {
|
|
248
|
+
// --- Regular video: lockupViewModel ---
|
|
249
|
+
const lvm = item.lockupViewModel;
|
|
250
|
+
if (lvm) {
|
|
251
|
+
const videoId =
|
|
252
|
+
lvm.rendererContext?.commandContext?.onTap?.innertubeCommand
|
|
253
|
+
?.watchEndpoint?.videoId ?? lvm.contentId;
|
|
254
|
+
const title = lvm.metadata?.lockupMetadataViewModel?.title?.content;
|
|
255
|
+
const channelLabel =
|
|
256
|
+
lvm.metadata?.lockupMetadataViewModel?.image
|
|
257
|
+
?.decoratedAvatarViewModel?.a11yLabel;
|
|
258
|
+
const channel = channelLabel
|
|
259
|
+
? channelLabel.replace(/^Go to channel /, "")
|
|
260
|
+
: "";
|
|
261
|
+
|
|
262
|
+
if (videoId && title) {
|
|
263
|
+
entries.push({ videoId, title, channel, dateLabel: currentDate });
|
|
264
|
+
}
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// --- Shorts shelf: reelShelfRenderer ---
|
|
269
|
+
const shelf = item.reelShelfRenderer;
|
|
270
|
+
if (shelf?.items) {
|
|
271
|
+
for (const short of shelf.items) {
|
|
272
|
+
const slvm = short.shortsLockupViewModel;
|
|
273
|
+
if (!slvm) continue;
|
|
274
|
+
|
|
275
|
+
const videoId =
|
|
276
|
+
slvm.onTap?.innertubeCommand?.reelWatchEndpoint?.videoId;
|
|
277
|
+
const title = slvm.overlayMetadata?.primaryText?.content;
|
|
278
|
+
if (videoId && title) {
|
|
279
|
+
entries.push({
|
|
280
|
+
videoId,
|
|
281
|
+
title,
|
|
282
|
+
channel: "",
|
|
283
|
+
dateLabel: currentDate,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Find a continuation token in the response.
|
|
294
|
+
*/
|
|
295
|
+
function findContinuation(
|
|
296
|
+
obj: unknown,
|
|
297
|
+
depth = 0,
|
|
298
|
+
): string | null {
|
|
299
|
+
if (obj == null || depth > 20) return null;
|
|
300
|
+
|
|
301
|
+
if (Array.isArray(obj)) {
|
|
302
|
+
for (const item of obj) {
|
|
303
|
+
const c = findContinuation(item, depth + 1);
|
|
304
|
+
if (c) return c;
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (typeof obj !== "object") return null;
|
|
310
|
+
|
|
311
|
+
const record = obj as Record<string, unknown>;
|
|
312
|
+
|
|
313
|
+
if (record.nextContinuationData) {
|
|
314
|
+
const ncd = record.nextContinuationData as Record<string, unknown>;
|
|
315
|
+
if (typeof ncd.continuation === "string") return ncd.continuation;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (record.continuationItemRenderer) {
|
|
319
|
+
const cir = record.continuationItemRenderer as Record<string, unknown>;
|
|
320
|
+
if (cir.continuationEndpoint) {
|
|
321
|
+
const ce = cir.continuationEndpoint as Record<string, unknown>;
|
|
322
|
+
if (ce.continuationCommand) {
|
|
323
|
+
const cc = ce.continuationCommand as Record<string, unknown>;
|
|
324
|
+
if (typeof cc.token === "string") return cc.token;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
for (const value of Object.values(record)) {
|
|
330
|
+
const c = findContinuation(value, depth + 1);
|
|
331
|
+
if (c) return c;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Date parsing
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Parse a section header date label into an ISO timestamp.
|
|
343
|
+
* Handles: "Monday", "Tuesday", ..., "Yesterday", "Today",
|
|
344
|
+
* "Jun 18", "Apr 15", "Mar 3", etc.
|
|
345
|
+
*/
|
|
346
|
+
function parseDateLabel(label: string, now: Date): Date {
|
|
347
|
+
const days: Record<string, number> = {
|
|
348
|
+
sunday: 0,
|
|
349
|
+
monday: 1,
|
|
350
|
+
tuesday: 2,
|
|
351
|
+
wednesday: 3,
|
|
352
|
+
thursday: 4,
|
|
353
|
+
friday: 5,
|
|
354
|
+
saturday: 6,
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const lower = label.toLowerCase().trim();
|
|
358
|
+
|
|
359
|
+
// Day names: "Monday" = most recent occurrence of that day
|
|
360
|
+
const dayIndex = days[lower];
|
|
361
|
+
if (dayIndex !== undefined) {
|
|
362
|
+
const today = now.getDay();
|
|
363
|
+
let diff = today - dayIndex;
|
|
364
|
+
if (diff < 0) diff += 7;
|
|
365
|
+
if (diff === 0) diff = 0; // Today would be "Today", not the day name
|
|
366
|
+
// Actually, "Monday" on a Monday = today. But YouTube uses "Today" for that.
|
|
367
|
+
// "Monday" on any other day = the most recent Monday.
|
|
368
|
+
if (diff === 0) diff = 7; // Same day name = last week
|
|
369
|
+
const d = new Date(now);
|
|
370
|
+
d.setDate(d.getDate() - diff);
|
|
371
|
+
d.setHours(12, 0, 0, 0);
|
|
372
|
+
return d;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (lower === "yesterday") {
|
|
376
|
+
const d = new Date(now);
|
|
377
|
+
d.setDate(d.getDate() - 1);
|
|
378
|
+
d.setHours(12, 0, 0, 0);
|
|
379
|
+
return d;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (lower === "today") {
|
|
383
|
+
return now;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// "Jun 18", "Apr 15" — month abbreviation + day
|
|
387
|
+
// "Jun 25, 2025" — month abbreviation + day + year (continuation pages)
|
|
388
|
+
const months: Record<string, number> = {
|
|
389
|
+
jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
|
|
390
|
+
jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const match = lower.match(/^([a-z]{3})\s+(\d{1,2})(?:,\s*(\d{4}))?$/);
|
|
394
|
+
if (match) {
|
|
395
|
+
const month = months[match[1]];
|
|
396
|
+
const day = parseInt(match[2]);
|
|
397
|
+
const explicitYear = match[3] ? parseInt(match[3]) : undefined;
|
|
398
|
+
if (month !== undefined && day > 0 && day <= 31) {
|
|
399
|
+
const year =
|
|
400
|
+
explicitYear ??
|
|
401
|
+
(month > now.getMonth() ? now.getFullYear() - 1 : now.getFullYear());
|
|
402
|
+
return new Date(year, month, day, 12, 0, 0);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return now;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// Transcript fetching (public timedtext API, no auth)
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
async function fetchTranscript(videoId: string): Promise<string | null> {
|
|
414
|
+
try {
|
|
415
|
+
const url = `https://www.youtube.com/api/timedtext?v=${videoId}&lang=en`;
|
|
416
|
+
const res = await fetch(url);
|
|
417
|
+
if (!res.ok) return null;
|
|
418
|
+
|
|
419
|
+
const xml = await res.text();
|
|
420
|
+
if (!xml || !xml.includes("<text ")) return null;
|
|
421
|
+
|
|
422
|
+
const texts: string[] = [];
|
|
423
|
+
const textRegex = /<text[^>]*>([^<]*)<\/text>/g;
|
|
424
|
+
let match;
|
|
425
|
+
while ((match = textRegex.exec(xml)) !== null) {
|
|
426
|
+
const decoded = match[1]
|
|
427
|
+
.replace(/&/g, "&")
|
|
428
|
+
.replace(/</g, "<")
|
|
429
|
+
.replace(/>/g, ">")
|
|
430
|
+
.replace(/"/g, '"')
|
|
431
|
+
.replace(/'/g, "'")
|
|
432
|
+
.trim();
|
|
433
|
+
if (decoded) texts.push(decoded);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return texts.length > 0 ? texts.join(" ") : null;
|
|
437
|
+
} catch {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
// Payload type
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
interface VideoWatchedPayload {
|
|
447
|
+
title: string;
|
|
448
|
+
channel: string;
|
|
449
|
+
channelId: string;
|
|
450
|
+
videoId: string;
|
|
451
|
+
url: string;
|
|
452
|
+
transcript: string | null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// Adapter
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
const adapter: Adapter = {
|
|
460
|
+
name: "youtube-history",
|
|
461
|
+
|
|
462
|
+
async pull(cursor: Cursor, ctx: AdapterContext): Promise<PullResult> {
|
|
463
|
+
const jar = loadCookies();
|
|
464
|
+
const useFixture = process.env.PDV_FIXTURE_MODE === "1" || !jar;
|
|
465
|
+
|
|
466
|
+
if (useFixture) {
|
|
467
|
+
return pullFromFixture(cursor);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// --- Live mode ---
|
|
471
|
+
const cur = parseCursor(cursor);
|
|
472
|
+
const events: Envelope<VideoWatchedPayload>[] = [];
|
|
473
|
+
const now = new Date();
|
|
474
|
+
let continuation: string | null = cur.continuation;
|
|
475
|
+
let newestVideoId = cur.newestVideoId;
|
|
476
|
+
let capped = false;
|
|
477
|
+
|
|
478
|
+
do {
|
|
479
|
+
const body: Record<string, unknown> = {
|
|
480
|
+
context: {
|
|
481
|
+
client: {
|
|
482
|
+
clientName: "WEB",
|
|
483
|
+
clientVersion: "2.20250625.00.00",
|
|
484
|
+
hl: "en",
|
|
485
|
+
gl: "US",
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
if (continuation) {
|
|
491
|
+
body.continuation = continuation;
|
|
492
|
+
} else {
|
|
493
|
+
body.browseId = "FEhistory";
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const data = await browseRequest(body, jar);
|
|
497
|
+
if (!data) break;
|
|
498
|
+
|
|
499
|
+
if (data.error) {
|
|
500
|
+
console.error(
|
|
501
|
+
`[youtube-history] API error:`,
|
|
502
|
+
JSON.stringify(data.error).slice(0, 500),
|
|
503
|
+
);
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const entries = parseHistoryResponse(data);
|
|
508
|
+
|
|
509
|
+
for (const entry of entries) {
|
|
510
|
+
if (events.length >= MAX_PER_PULL) {
|
|
511
|
+
capped = true;
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// On re-pull (no continuation), stop at the first video we've
|
|
516
|
+
// already seen.
|
|
517
|
+
if (
|
|
518
|
+
!cur.continuation &&
|
|
519
|
+
newestVideoId &&
|
|
520
|
+
entry.videoId === newestVideoId
|
|
521
|
+
) {
|
|
522
|
+
capped = true;
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const watchedAt = parseDateLabel(entry.dateLabel, now).toISOString();
|
|
527
|
+
|
|
528
|
+
events.push({
|
|
529
|
+
v: ENVELOPE_VERSION,
|
|
530
|
+
ts: watchedAt,
|
|
531
|
+
source: adapter.name,
|
|
532
|
+
type: "video_watched",
|
|
533
|
+
id: entry.videoId,
|
|
534
|
+
payload: {
|
|
535
|
+
title: entry.title,
|
|
536
|
+
channel: entry.channel,
|
|
537
|
+
channelId: "",
|
|
538
|
+
videoId: entry.videoId,
|
|
539
|
+
url: `https://youtube.com/watch?v=${entry.videoId}`,
|
|
540
|
+
transcript: null,
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
if (!newestVideoId && !cur.continuation && events.length === 1) {
|
|
545
|
+
newestVideoId = entry.videoId;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (capped) break;
|
|
550
|
+
|
|
551
|
+
continuation = findContinuation(data);
|
|
552
|
+
} while (continuation);
|
|
553
|
+
|
|
554
|
+
// --- Transcript phase (opt-in) ---
|
|
555
|
+
if (FETCH_TRANSCRIPTS && events.length > 0) {
|
|
556
|
+
for (const event of events) {
|
|
557
|
+
const raw = await fetchTranscript(event.payload.videoId);
|
|
558
|
+
if (!raw) continue;
|
|
559
|
+
|
|
560
|
+
const { uri } = ctx.storeAsset(Buffer.from(raw, "utf-8"), {
|
|
561
|
+
mime: "text/plain; source=youtube-transcript",
|
|
562
|
+
});
|
|
563
|
+
event.payload.transcript = uri;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return {
|
|
568
|
+
events,
|
|
569
|
+
cursor:
|
|
570
|
+
events.length > 0 || continuation
|
|
571
|
+
? encodeCursor({
|
|
572
|
+
continuation: continuation ?? null,
|
|
573
|
+
newestVideoId,
|
|
574
|
+
})
|
|
575
|
+
: cursor,
|
|
576
|
+
hasMore: !!continuation || capped,
|
|
577
|
+
};
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
// ---------------------------------------------------------------------------
|
|
582
|
+
// Fixture path
|
|
583
|
+
// ---------------------------------------------------------------------------
|
|
584
|
+
|
|
585
|
+
async function pullFromFixture(cursor: Cursor): Promise<PullResult> {
|
|
586
|
+
let raw: string;
|
|
587
|
+
try {
|
|
588
|
+
raw = readFileSync(FIXTURE_PATH, "utf8");
|
|
589
|
+
} catch {
|
|
590
|
+
return { events: [], cursor, hasMore: false };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const items = JSON.parse(raw) as {
|
|
594
|
+
id: string;
|
|
595
|
+
snippet: {
|
|
596
|
+
publishedAt: string;
|
|
597
|
+
title: string;
|
|
598
|
+
channelId: string;
|
|
599
|
+
channelTitle: string;
|
|
600
|
+
resourceId: { kind: string; videoId: string };
|
|
601
|
+
};
|
|
602
|
+
}[];
|
|
603
|
+
|
|
604
|
+
const cur = parseCursor(cursor);
|
|
605
|
+
const events: Envelope[] = [];
|
|
606
|
+
let firstVideoId: string | null = null;
|
|
607
|
+
|
|
608
|
+
for (const item of items) {
|
|
609
|
+
if (
|
|
610
|
+
cur.newestVideoId &&
|
|
611
|
+
item.snippet.resourceId.videoId === cur.newestVideoId
|
|
612
|
+
)
|
|
613
|
+
break;
|
|
614
|
+
|
|
615
|
+
if (!firstVideoId) firstVideoId = item.snippet.resourceId.videoId;
|
|
616
|
+
|
|
617
|
+
events.push({
|
|
618
|
+
v: ENVELOPE_VERSION,
|
|
619
|
+
ts: item.snippet.publishedAt,
|
|
620
|
+
source: adapter.name,
|
|
621
|
+
type: "video_watched",
|
|
622
|
+
id: item.id,
|
|
623
|
+
payload: {
|
|
624
|
+
title: item.snippet.title,
|
|
625
|
+
channel: item.snippet.channelTitle,
|
|
626
|
+
channelId: item.snippet.channelId,
|
|
627
|
+
videoId: item.snippet.resourceId.videoId,
|
|
628
|
+
url: `https://youtube.com/watch?v=${item.snippet.resourceId.videoId}`,
|
|
629
|
+
transcript: null,
|
|
630
|
+
},
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return {
|
|
635
|
+
events,
|
|
636
|
+
cursor:
|
|
637
|
+
events.length > 0
|
|
638
|
+
? encodeCursor({ continuation: null, newestVideoId: firstVideoId })
|
|
639
|
+
: cursor,
|
|
640
|
+
hasMore: false,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export default adapter;
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mannyflussnpm/adapter-youtube-history",
|
|
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
|
+
}
|