@chronicle.app/youtube 0.4.0 → 0.5.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/README.md +15 -4
- package/dist/connectors/YouTubeTakeoutExtractor.d.ts +15 -1
- package/dist/connectors/YouTubeTakeoutExtractor.js +120 -57
- package/dist/connectors/YouTubeTakeoutTransformer.js +9 -9
- package/dist/connectors/YouTubeTransformer.js +7 -7
- package/dist/takeout/csv.d.ts +1 -1
- package/dist/takeout/csv.js +1 -3
- package/package.json +19 -5
package/README.md
CHANGED
|
@@ -10,7 +10,18 @@ Extract liked videos and channel subscriptions from YouTube via the Data API v3
|
|
|
10
10
|
- **Uploads**: Extract videos published to your own channel
|
|
11
11
|
- **OAuth Authentication**: Google OAuth 2.0 with offline refresh tokens
|
|
12
12
|
|
|
13
|
-
Watch and search history
|
|
13
|
+
Watch and search history aren't in the Data API. The `takeout` strategy reads
|
|
14
|
+
them, with the rest of the library, from a Google Takeout:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
chronicle extract youtube --strategy takeout --input ~/Downloads/takeout-20260725T000000Z-001.zip
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
It takes the Takeout as a zip, one part of a split Takeout, the unzipped folder,
|
|
21
|
+
or its `YouTube and YouTube Music` folder, and reads a zip in place.
|
|
22
|
+
`chronicle scan <path>` finds a Takeout in a folder of exports. It reads history
|
|
23
|
+
in HTML, as Takeout writes it by default; a Takeout with its history in JSON
|
|
24
|
+
isn't read yet.
|
|
14
25
|
|
|
15
26
|
## Setup
|
|
16
27
|
|
|
@@ -47,12 +58,12 @@ chronicle extract youtube --type uploads
|
|
|
47
58
|
## Data Mapping
|
|
48
59
|
|
|
49
60
|
- Liked video → `LikeAction` by the account owner (`@me`) on a `VideoObject` (title, description, duration, thumbnail, uploading channel as author)
|
|
50
|
-
- Subscription → `FollowAction` on the channel as an `Agent`, keyed
|
|
61
|
+
- Subscription → `FollowAction` on the channel as an `Agent`, keyed by the channel it follows so re-runs supersede
|
|
51
62
|
- Playlist → `Collection` (with `visibility` from the privacy status), created via a `CreateAction` at the playlist's real creation time; each video in it → `AddAction` keyed on the playlist item id with the `Collection` as `targetCollection`
|
|
52
63
|
- Upload → `PublishAction` with the account owner as agent and the `VideoObject` as result
|
|
53
64
|
- Channels are `Agent`s keyed on their YouTube channel id
|
|
54
65
|
|
|
55
66
|
Tests run the pipeline against a fake Data API served on 127.0.0.1 with a fake
|
|
56
|
-
access token, and against a small synthetic Takeout
|
|
57
|
-
directory. They never call Google, read stored credentials, or read the host's
|
|
67
|
+
access token, and against a small synthetic Takeout, unzipped and zipped, in a
|
|
68
|
+
temporary directory. They never call Google, read stored credentials, or read the host's
|
|
58
69
|
data or accounts.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Extractor, Record } from '@chronicle.app/etl';
|
|
1
|
+
import { Extractor, Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import YouTubeTakeoutTransformer from './YouTubeTakeoutTransformer.js';
|
|
4
4
|
export interface TakeoutSelfChannel {
|
|
@@ -72,16 +72,30 @@ export declare class YouTubeTakeoutExtractor extends Extractor<typeof YouTubeTak
|
|
|
72
72
|
until?: Date | undefined;
|
|
73
73
|
input?: string | undefined;
|
|
74
74
|
}>;
|
|
75
|
+
private files;
|
|
76
|
+
/** Where the YouTube folder is in the export, as a path prefix. */
|
|
77
|
+
private prefix;
|
|
75
78
|
private archiveAsOf;
|
|
76
79
|
private selfChannel;
|
|
80
|
+
/** The path in the export of a file in the YouTube folder. */
|
|
77
81
|
private inputPath;
|
|
78
82
|
private fileExists;
|
|
83
|
+
private readCsv;
|
|
79
84
|
setup(): Promise<void>;
|
|
85
|
+
teardown(): Promise<void>;
|
|
80
86
|
protected asOfTime(): Date;
|
|
81
87
|
private inRange;
|
|
82
88
|
/** `[playlist title, csv path]` for each per-playlist membership file. */
|
|
83
89
|
private playlistItemFiles;
|
|
84
90
|
extract(): AsyncGenerator<Record>;
|
|
91
|
+
/**
|
|
92
|
+
* The records a run reads and when each happened, from every section: the
|
|
93
|
+
* archive has no index, so each file is read. Subscriptions are as of the
|
|
94
|
+
* archive, and watches say how many videos they were of.
|
|
95
|
+
*/
|
|
96
|
+
survey(options?: SurveyOptions): Promise<Survey>;
|
|
97
|
+
/** Each section's records, in the order a run reads them, as `[record type, data]`. */
|
|
98
|
+
private readSections;
|
|
85
99
|
private extractWatches;
|
|
86
100
|
private extractSearches;
|
|
87
101
|
private extractSubscriptions;
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { Extractor } from '@chronicle.app/etl';
|
|
2
|
-
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
3
|
-
import { basename, join } from 'node:path';
|
|
1
|
+
import { Extractor, InputNotFound, countMonth, openExport, } from '@chronicle.app/etl';
|
|
4
2
|
import { z } from 'zod';
|
|
5
|
-
import {
|
|
3
|
+
import { parseTakeoutCsv } from '../takeout/csv.js';
|
|
6
4
|
import { parseActivityHtml, videoIdFromUrl } from '../takeout/parseActivityHtml.js';
|
|
5
|
+
import { ytTime } from '../utils/time.js';
|
|
7
6
|
import YouTubeTakeoutTransformer from './YouTubeTakeoutTransformer.js';
|
|
7
|
+
// The folder Takeout puts YouTube's files in.
|
|
8
|
+
const PRODUCT = 'YouTube and YouTube Music';
|
|
9
|
+
// Where that folder can be in an export: the export itself, as an unpacked
|
|
10
|
+
// folder was passed before, in the Takeout folder, or in the zip that holds it.
|
|
11
|
+
const PREFIXES = ['', `${PRODUCT}/`, `Takeout/${PRODUCT}/`];
|
|
8
12
|
// Relative paths of the archive files this extractor reads.
|
|
9
13
|
const FILES = {
|
|
10
14
|
watch: 'history/watch-history.html',
|
|
@@ -15,6 +19,16 @@ const FILES = {
|
|
|
15
19
|
comments: 'comments/comments.csv',
|
|
16
20
|
channel: 'channels/channel.csv',
|
|
17
21
|
};
|
|
22
|
+
// The time the transformer dates each kind by. Takeout doesn't say when a
|
|
23
|
+
// subscription began, so it has none.
|
|
24
|
+
const DATED = {
|
|
25
|
+
watches: data => data.time,
|
|
26
|
+
searches: data => data.time,
|
|
27
|
+
playlists: data => data.createdAt,
|
|
28
|
+
'playlist-items': data => data.addedAt,
|
|
29
|
+
uploads: data => data.publishedAt,
|
|
30
|
+
comments: data => data.createdAt,
|
|
31
|
+
};
|
|
18
32
|
export class YouTubeTakeoutExtractor extends Extractor {
|
|
19
33
|
static source = 'youtube';
|
|
20
34
|
static description = 'Watch and search history and library from Takeout';
|
|
@@ -40,49 +54,63 @@ export class YouTubeTakeoutExtractor extends Extractor {
|
|
|
40
54
|
// flags across a source's extractors, so a schema-required flag here would
|
|
41
55
|
// also gate the API extractors that share the youtube source.
|
|
42
56
|
static schema = Extractor.schema.extend({
|
|
43
|
-
input: z
|
|
57
|
+
input: z
|
|
58
|
+
.string()
|
|
59
|
+
.optional()
|
|
60
|
+
.describe(`The Takeout, as a folder or zip, or its "${PRODUCT}" folder`),
|
|
44
61
|
});
|
|
62
|
+
files = null;
|
|
63
|
+
/** Where the YouTube folder is in the export, as a path prefix. */
|
|
64
|
+
prefix = '';
|
|
45
65
|
archiveAsOf = null;
|
|
46
66
|
selfChannel = null;
|
|
67
|
+
/** The path in the export of a file in the YouTube folder. */
|
|
47
68
|
inputPath(relative) {
|
|
48
|
-
|
|
49
|
-
if (!config.input) {
|
|
50
|
-
throw new Error('The takeout extractor needs --input: the path to the Takeout "YouTube and YouTube Music" folder');
|
|
51
|
-
}
|
|
52
|
-
return join(config.input, relative);
|
|
69
|
+
return this.prefix + relative;
|
|
53
70
|
}
|
|
54
71
|
async fileExists(path) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
catch {
|
|
60
|
-
return false;
|
|
61
|
-
}
|
|
72
|
+
return this.files.has(path);
|
|
73
|
+
}
|
|
74
|
+
async readCsv(path) {
|
|
75
|
+
return parseTakeoutCsv(await this.files.readText(path));
|
|
62
76
|
}
|
|
63
77
|
async setup() {
|
|
64
78
|
await super.setup();
|
|
79
|
+
const config = this.config;
|
|
80
|
+
if (!config.input) {
|
|
81
|
+
throw new Error(`The takeout extractor needs --input: the Takeout, as a folder or zip, or its "${PRODUCT}" folder`);
|
|
82
|
+
}
|
|
83
|
+
this.files = await openExport(config.input);
|
|
84
|
+
const listed = new Set(await this.files.list());
|
|
85
|
+
const prefix = PREFIXES.find(p => Object.values(FILES).some(f => listed.has(p + f)));
|
|
86
|
+
if (prefix === undefined) {
|
|
87
|
+
await this.teardown();
|
|
88
|
+
throw new InputNotFound(`No ${PRODUCT} folder in the export`, {
|
|
89
|
+
path: config.input,
|
|
90
|
+
hint: `Pass the Takeout, as a folder or zip, or its "${PRODUCT}" folder with \`--input <path>\`.`,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
this.prefix = prefix;
|
|
65
94
|
// The archive is a static artifact: its sighting time is the export's,
|
|
66
95
|
// not this run's, so re-running over an unchanged tree is a byte-identical
|
|
67
|
-
// no-op. Newest mtime across the files read stands in for the export time
|
|
96
|
+
// no-op. Newest mtime across the files read stands in for the export time;
|
|
97
|
+
// in a zip, the time its index gives each file.
|
|
68
98
|
const paths = [
|
|
69
99
|
...Object.values(FILES).map(f => this.inputPath(f)),
|
|
70
100
|
...(await this.playlistItemFiles()).map(([, path]) => path),
|
|
71
101
|
];
|
|
72
102
|
let newest = 0;
|
|
73
103
|
for (const path of paths) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
// Missing sections are fine; each is skipped with a warning later.
|
|
79
|
-
}
|
|
104
|
+
// Missing sections are fine; each is skipped with a warning later.
|
|
105
|
+
const modified = await this.files.modified(path);
|
|
106
|
+
if (modified)
|
|
107
|
+
newest = Math.max(newest, modified.getTime());
|
|
80
108
|
}
|
|
81
109
|
if (newest > 0)
|
|
82
110
|
this.archiveAsOf = new Date(newest);
|
|
83
111
|
const channelPath = this.inputPath(FILES.channel);
|
|
84
112
|
if (await this.fileExists(channelPath)) {
|
|
85
|
-
const [row] = await
|
|
113
|
+
const [row] = await this.readCsv(channelPath);
|
|
86
114
|
const id = row?.['Channel ID'];
|
|
87
115
|
if (id) {
|
|
88
116
|
this.selfChannel = {
|
|
@@ -95,6 +123,11 @@ export class YouTubeTakeoutExtractor extends Extractor {
|
|
|
95
123
|
this.logger.warn('channels/channel.csv missing or empty — the account owner falls back to the per-source singleton self');
|
|
96
124
|
}
|
|
97
125
|
}
|
|
126
|
+
async teardown() {
|
|
127
|
+
await this.files?.close();
|
|
128
|
+
this.files = null;
|
|
129
|
+
await super.teardown();
|
|
130
|
+
}
|
|
98
131
|
asOfTime() {
|
|
99
132
|
return this.archiveAsOf ?? super.asOfTime();
|
|
100
133
|
}
|
|
@@ -108,44 +141,74 @@ export class YouTubeTakeoutExtractor extends Extractor {
|
|
|
108
141
|
}
|
|
109
142
|
/** `[playlist title, csv path]` for each per-playlist membership file. */
|
|
110
143
|
async playlistItemFiles() {
|
|
111
|
-
const dir = this.inputPath('playlists');
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
.map(f => [basename(f, '-videos.csv'), join(dir, f)]);
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
return [];
|
|
120
|
-
}
|
|
144
|
+
const dir = this.inputPath('playlists/');
|
|
145
|
+
return (await this.files.list(dir))
|
|
146
|
+
.map(path => path.slice(dir.length))
|
|
147
|
+
.filter(name => !name.includes('/') && name.endsWith('-videos.csv'))
|
|
148
|
+
.map(name => [name.slice(0, -'-videos.csv'.length), dir + name]);
|
|
121
149
|
}
|
|
122
150
|
async *extract() {
|
|
123
151
|
let count = 0;
|
|
124
152
|
const self = this.selfChannel;
|
|
125
153
|
const emit = (recordType, data) => this.createRecord(data, { recordType, selfChannel: self });
|
|
126
|
-
for (const
|
|
127
|
-
this.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
this.extractUploads(),
|
|
132
|
-
this.extractComments(),
|
|
133
|
-
]) {
|
|
134
|
-
for await (const [recordType, data] of section) {
|
|
135
|
-
if (this.shouldStopExtracting(count))
|
|
136
|
-
return;
|
|
137
|
-
yield emit(recordType, data);
|
|
138
|
-
count++;
|
|
139
|
-
}
|
|
154
|
+
for await (const [recordType, data] of this.readSections()) {
|
|
155
|
+
if (this.shouldStopExtracting(count))
|
|
156
|
+
return;
|
|
157
|
+
yield emit(recordType, data);
|
|
158
|
+
count++;
|
|
140
159
|
}
|
|
141
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* The records a run reads and when each happened, from every section: the
|
|
163
|
+
* archive has no index, so each file is read. Subscriptions are as of the
|
|
164
|
+
* archive, and watches say how many videos they were of.
|
|
165
|
+
*/
|
|
166
|
+
async survey(options = {}) {
|
|
167
|
+
const kinds = {};
|
|
168
|
+
for (const recordType of YouTubeTakeoutExtractor.recordTypes) {
|
|
169
|
+
kinds[recordType] = {
|
|
170
|
+
count: 0,
|
|
171
|
+
...(options.byMonth && DATED[recordType] && { byMonth: {} }),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const videos = new Set();
|
|
175
|
+
for await (const [recordType, data] of this.readSections()) {
|
|
176
|
+
const kind = kinds[recordType];
|
|
177
|
+
kind.count++;
|
|
178
|
+
if (recordType === 'watches')
|
|
179
|
+
videos.add(data.videoId);
|
|
180
|
+
const dated = DATED[recordType];
|
|
181
|
+
if (!dated)
|
|
182
|
+
continue;
|
|
183
|
+
const time = ytTime(dated(data));
|
|
184
|
+
if (!kind.earliest || time < kind.earliest)
|
|
185
|
+
kind.earliest = time;
|
|
186
|
+
if (!kind.latest || time > kind.latest)
|
|
187
|
+
kind.latest = time;
|
|
188
|
+
if (kind.byMonth)
|
|
189
|
+
countMonth(kind.byMonth, time);
|
|
190
|
+
}
|
|
191
|
+
if (this.archiveAsOf)
|
|
192
|
+
kinds.subscriptions.asOf = this.archiveAsOf;
|
|
193
|
+
kinds.watches.measures = [{ key: 'videos', label: 'videos', value: videos.size }];
|
|
194
|
+
return kinds;
|
|
195
|
+
}
|
|
196
|
+
/** Each section's records, in the order a run reads them, as `[record type, data]`. */
|
|
197
|
+
async *readSections() {
|
|
198
|
+
yield* this.extractWatches();
|
|
199
|
+
yield* this.extractSearches();
|
|
200
|
+
yield* this.extractSubscriptions();
|
|
201
|
+
yield* this.extractPlaylists();
|
|
202
|
+
yield* this.extractUploads();
|
|
203
|
+
yield* this.extractComments();
|
|
204
|
+
}
|
|
142
205
|
async *extractWatches() {
|
|
143
206
|
const path = this.inputPath(FILES.watch);
|
|
144
207
|
if (!(await this.fileExists(path))) {
|
|
145
208
|
this.logger.warn(`No watch history at ${FILES.watch} — skipping`);
|
|
146
209
|
return;
|
|
147
210
|
}
|
|
148
|
-
const entries = parseActivityHtml(await
|
|
211
|
+
const entries = parseActivityHtml(await this.files.readText(path));
|
|
149
212
|
let ads = 0;
|
|
150
213
|
let noise = 0;
|
|
151
214
|
let unparseable = 0;
|
|
@@ -186,7 +249,7 @@ export class YouTubeTakeoutExtractor extends Extractor {
|
|
|
186
249
|
this.logger.warn(`No search history at ${FILES.search} — skipping`);
|
|
187
250
|
return;
|
|
188
251
|
}
|
|
189
|
-
const entries = parseActivityHtml(await
|
|
252
|
+
const entries = parseActivityHtml(await this.files.readText(path));
|
|
190
253
|
let skipped = 0;
|
|
191
254
|
let emitted = 0;
|
|
192
255
|
for (const entry of entries) {
|
|
@@ -211,7 +274,7 @@ export class YouTubeTakeoutExtractor extends Extractor {
|
|
|
211
274
|
const path = this.inputPath(FILES.subscriptions);
|
|
212
275
|
if (!(await this.fileExists(path)))
|
|
213
276
|
return;
|
|
214
|
-
for (const row of await
|
|
277
|
+
for (const row of await this.readCsv(path)) {
|
|
215
278
|
const channelId = row['Channel Id'];
|
|
216
279
|
if (!channelId)
|
|
217
280
|
continue;
|
|
@@ -227,7 +290,7 @@ export class YouTubeTakeoutExtractor extends Extractor {
|
|
|
227
290
|
if (!(await this.fileExists(path)))
|
|
228
291
|
return;
|
|
229
292
|
const byTitle = new Map();
|
|
230
|
-
for (const row of await
|
|
293
|
+
for (const row of await this.readCsv(path)) {
|
|
231
294
|
const playlistId = row['Playlist ID'];
|
|
232
295
|
const createdAt = row['Playlist Create Timestamp'];
|
|
233
296
|
if (!playlistId || !createdAt)
|
|
@@ -246,10 +309,10 @@ export class YouTubeTakeoutExtractor extends Extractor {
|
|
|
246
309
|
for (const [title, itemPath] of await this.playlistItemFiles()) {
|
|
247
310
|
const playlist = byTitle.get(title);
|
|
248
311
|
if (!playlist) {
|
|
249
|
-
this.logger.warn(`Playlist file "${
|
|
312
|
+
this.logger.warn(`Playlist file "${itemPath.split('/').at(-1)}" has no matching row in playlists.csv — skipping`);
|
|
250
313
|
continue;
|
|
251
314
|
}
|
|
252
|
-
for (const row of await
|
|
315
|
+
for (const row of await this.readCsv(itemPath)) {
|
|
253
316
|
const videoId = row['Video ID'];
|
|
254
317
|
const addedAt = row['Playlist Video Creation Timestamp'];
|
|
255
318
|
if (!videoId || !addedAt)
|
|
@@ -269,7 +332,7 @@ export class YouTubeTakeoutExtractor extends Extractor {
|
|
|
269
332
|
if (!(await this.fileExists(path)))
|
|
270
333
|
return;
|
|
271
334
|
let drafts = 0;
|
|
272
|
-
for (const row of await
|
|
335
|
+
for (const row of await this.readCsv(path)) {
|
|
273
336
|
const videoId = row['Video ID'];
|
|
274
337
|
if (!videoId)
|
|
275
338
|
continue;
|
|
@@ -297,7 +360,7 @@ export class YouTubeTakeoutExtractor extends Extractor {
|
|
|
297
360
|
const path = this.inputPath(FILES.comments);
|
|
298
361
|
if (!(await this.fileExists(path)))
|
|
299
362
|
return;
|
|
300
|
-
for (const row of await
|
|
363
|
+
for (const row of await this.readCsv(path)) {
|
|
301
364
|
const commentId = row['Comment ID'];
|
|
302
365
|
const videoId = row['Video ID'];
|
|
303
366
|
const createdAt = row['Comment Create Timestamp'];
|
|
@@ -78,9 +78,9 @@ export default class YouTubeTakeoutTransformer extends ChronicleTransformer {
|
|
|
78
78
|
const subscription = record.data;
|
|
79
79
|
const action = {
|
|
80
80
|
'@type': 'FollowAction',
|
|
81
|
-
|
|
81
|
+
// Keyed by the channel it follows, as the API path keys it.
|
|
82
|
+
'@key': ['@type', 'source', 'object.handle'],
|
|
82
83
|
source: 'youtube',
|
|
83
|
-
sourceId: subscription.channelId,
|
|
84
84
|
agent: this.resolveSelf(record),
|
|
85
85
|
object: this.buildChannelAgent(subscription.channelId, subscription.channelTitle),
|
|
86
86
|
};
|
|
@@ -90,9 +90,9 @@ export default class YouTubeTakeoutTransformer extends ChronicleTransformer {
|
|
|
90
90
|
const playlist = record.data;
|
|
91
91
|
const action = {
|
|
92
92
|
'@type': 'CreateAction',
|
|
93
|
-
|
|
93
|
+
// Keyed by the playlist it made, as the API path keys it.
|
|
94
|
+
'@key': ['@type', 'source', 'result.sourceId'],
|
|
94
95
|
source: 'youtube',
|
|
95
|
-
sourceId: playlist.playlistId,
|
|
96
96
|
timestamp: ytTime(playlist.createdAt),
|
|
97
97
|
agent: this.resolveSelf(record),
|
|
98
98
|
result: this.buildCollection(playlist.playlistId, {
|
|
@@ -134,12 +134,12 @@ export default class YouTubeTakeoutTransformer extends ChronicleTransformer {
|
|
|
134
134
|
};
|
|
135
135
|
const action = {
|
|
136
136
|
'@type': 'PublishAction',
|
|
137
|
-
|
|
137
|
+
// Keyed by the video it published, as the API path keys it.
|
|
138
|
+
'@key': ['@type', 'source', 'result.sourceId'],
|
|
138
139
|
source: 'youtube',
|
|
139
|
-
sourceId: upload.videoId,
|
|
140
140
|
timestamp: ytTime(upload.publishedAt),
|
|
141
141
|
agent: this.resolveSelf(record),
|
|
142
|
-
|
|
142
|
+
result: video,
|
|
143
143
|
};
|
|
144
144
|
return [action];
|
|
145
145
|
}
|
|
@@ -158,9 +158,9 @@ export default class YouTubeTakeoutTransformer extends ChronicleTransformer {
|
|
|
158
158
|
};
|
|
159
159
|
const action = {
|
|
160
160
|
'@type': 'AnnotateAction',
|
|
161
|
-
|
|
161
|
+
// Keyed by the comment it made.
|
|
162
|
+
'@key': ['@type', 'source', 'result.sourceId'],
|
|
162
163
|
source: 'youtube',
|
|
163
|
-
sourceId: data.commentId,
|
|
164
164
|
timestamp: ytTime(data.createdAt),
|
|
165
165
|
agent,
|
|
166
166
|
object: video,
|
|
@@ -60,9 +60,9 @@ export default class YouTubeTransformer extends ChronicleTransformer {
|
|
|
60
60
|
const { channelId } = subscription.snippet.resourceId;
|
|
61
61
|
const action = {
|
|
62
62
|
'@type': 'FollowAction',
|
|
63
|
-
|
|
63
|
+
// Keyed by the channel it follows.
|
|
64
|
+
'@key': ['@type', 'source', 'object.handle'],
|
|
64
65
|
source: 'youtube',
|
|
65
|
-
sourceId: channelId,
|
|
66
66
|
timestamp: ytTime(subscription.snippet.publishedAt),
|
|
67
67
|
agent: this.resolveSelf(record),
|
|
68
68
|
object: this.buildChannelAgent(channelId, {
|
|
@@ -79,9 +79,9 @@ export default class YouTubeTransformer extends ChronicleTransformer {
|
|
|
79
79
|
const playlist = record.data;
|
|
80
80
|
const action = {
|
|
81
81
|
'@type': 'CreateAction',
|
|
82
|
-
|
|
82
|
+
// Keyed by the playlist it made.
|
|
83
|
+
'@key': ['@type', 'source', 'result.sourceId'],
|
|
83
84
|
source: 'youtube',
|
|
84
|
-
sourceId: playlist.id,
|
|
85
85
|
timestamp: ytTime(playlist.snippet.publishedAt),
|
|
86
86
|
agent: this.resolveSelf(record),
|
|
87
87
|
result: this.buildCollection(playlist),
|
|
@@ -118,12 +118,12 @@ export default class YouTubeTransformer extends ChronicleTransformer {
|
|
|
118
118
|
delete video.datePublished;
|
|
119
119
|
const action = {
|
|
120
120
|
'@type': 'PublishAction',
|
|
121
|
-
|
|
121
|
+
// Keyed by the video it published.
|
|
122
|
+
'@key': ['@type', 'source', 'result.sourceId'],
|
|
122
123
|
source: 'youtube',
|
|
123
|
-
sourceId: upload.item.snippet.resourceId.videoId,
|
|
124
124
|
timestamp: ytTime(upload.item.snippet.publishedAt),
|
|
125
125
|
agent: this.resolveSelf(record),
|
|
126
|
-
|
|
126
|
+
result: video,
|
|
127
127
|
};
|
|
128
128
|
return [action];
|
|
129
129
|
}
|
package/dist/takeout/csv.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** A Takeout CSV as header-keyed rows. Fields regularly carry embedded
|
|
2
2
|
* newlines (descriptions, comment JSON), so this is never line-splittable. */
|
|
3
|
-
export declare function
|
|
3
|
+
export declare function parseTakeoutCsv(content: string): Record<string, string>[];
|
|
4
4
|
//# sourceMappingURL=csv.d.ts.map
|
package/dist/takeout/csv.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
2
1
|
import { parse } from 'csv-parse/sync';
|
|
3
2
|
/** A Takeout CSV as header-keyed rows. Fields regularly carry embedded
|
|
4
3
|
* newlines (descriptions, comment JSON), so this is never line-splittable. */
|
|
5
|
-
export
|
|
6
|
-
const content = await readFile(path, 'utf-8');
|
|
4
|
+
export function parseTakeoutCsv(content) {
|
|
7
5
|
return parse(content, {
|
|
8
6
|
columns: true,
|
|
9
7
|
bom: true,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chronicle.app/youtube",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"types": "./dist/index.d.ts",
|
|
6
6
|
"files": [
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
"type": "module",
|
|
23
23
|
"description": "Chronicle plugin for extracting data from YouTube",
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@chronicle.app/etl": "0.
|
|
26
|
-
"@chronicle.app/schema": "0.
|
|
27
|
-
"@chronicle.app/tsconfig": "0.
|
|
25
|
+
"@chronicle.app/etl": "0.5.0",
|
|
26
|
+
"@chronicle.app/schema": "0.5.0",
|
|
27
|
+
"@chronicle.app/tsconfig": "0.5.0",
|
|
28
28
|
"@types/node": "^22.10.1",
|
|
29
29
|
"typescript": "^5.7.2"
|
|
30
30
|
},
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"registry": "https://registry.npmjs.org/"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@chronicle.app/auth": "0.
|
|
42
|
+
"@chronicle.app/auth": "0.5.0",
|
|
43
43
|
"axios": "^1.7.9",
|
|
44
44
|
"csv-parse": "^5.6.0",
|
|
45
45
|
"zod": "^3.24.1"
|
|
@@ -75,6 +75,20 @@
|
|
|
75
75
|
},
|
|
76
76
|
"takeout": {
|
|
77
77
|
"delivery": "export",
|
|
78
|
+
"layouts": [
|
|
79
|
+
{
|
|
80
|
+
"files": [
|
|
81
|
+
"YouTube and YouTube Music/history/watch-history.html"
|
|
82
|
+
]
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"name": "JSON",
|
|
86
|
+
"files": [
|
|
87
|
+
"YouTube and YouTube Music/history/watch-history.json"
|
|
88
|
+
],
|
|
89
|
+
"supported": false
|
|
90
|
+
}
|
|
91
|
+
],
|
|
78
92
|
"recordTypes": [
|
|
79
93
|
"watches",
|
|
80
94
|
"searches",
|