@chronicle.app/youtube 0.3.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.
Files changed (33) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -0
  3. package/dist/auth/GoogleOAuthProvider.d.ts +14 -0
  4. package/dist/auth/GoogleOAuthProvider.js +15 -0
  5. package/dist/connectors/YouTubeBaseExtractor.d.ts +36 -0
  6. package/dist/connectors/YouTubeBaseExtractor.js +40 -0
  7. package/dist/connectors/YouTubeLikesExtractor.d.ts +17 -0
  8. package/dist/connectors/YouTubeLikesExtractor.js +54 -0
  9. package/dist/connectors/YouTubePlaylistsExtractor.d.ts +16 -0
  10. package/dist/connectors/YouTubePlaylistsExtractor.js +44 -0
  11. package/dist/connectors/YouTubeSubscriptionsExtractor.d.ts +10 -0
  12. package/dist/connectors/YouTubeSubscriptionsExtractor.js +27 -0
  13. package/dist/connectors/YouTubeTakeoutExtractor.d.ts +92 -0
  14. package/dist/connectors/YouTubeTakeoutExtractor.js +315 -0
  15. package/dist/connectors/YouTubeTakeoutTransformer.d.ts +27 -0
  16. package/dist/connectors/YouTubeTakeoutTransformer.js +260 -0
  17. package/dist/connectors/YouTubeTransformer.d.ts +15 -0
  18. package/dist/connectors/YouTubeTransformer.js +220 -0
  19. package/dist/connectors/YouTubeUploadsExtractor.d.ts +11 -0
  20. package/dist/connectors/YouTubeUploadsExtractor.js +50 -0
  21. package/dist/index.d.ts +9 -0
  22. package/dist/index.js +12 -0
  23. package/dist/takeout/csv.d.ts +4 -0
  24. package/dist/takeout/csv.js +12 -0
  25. package/dist/takeout/parseActivityHtml.d.ts +30 -0
  26. package/dist/takeout/parseActivityHtml.js +116 -0
  27. package/dist/utils/YouTubeProxy.d.ts +138 -0
  28. package/dist/utils/YouTubeProxy.js +138 -0
  29. package/dist/utils/time.d.ts +12 -0
  30. package/dist/utils/time.js +30 -0
  31. package/dist/utils/urls.d.ts +10 -0
  32. package/dist/utils/urls.js +15 -0
  33. package/package.json +92 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Louis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # Chronicle YouTube Plugin
2
+
3
+ Extract liked videos and channel subscriptions from YouTube via the Data API v3 with OAuth authentication.
4
+
5
+ ## Features
6
+
7
+ - **Liked Videos**: Extract your liked videos (from the account's likes playlist, with like timestamps)
8
+ - **Subscriptions**: Extract your channel subscriptions (with subscribe timestamps)
9
+ - **Playlists**: Extract your own playlists and their videos (creation and item-add timestamps, privacy status)
10
+ - **Uploads**: Extract videos published to your own channel
11
+ - **OAuth Authentication**: Google OAuth 2.0 with offline refresh tokens
12
+
13
+ Watch and search history are not exposed by the Data API (Google Takeout only) and are out of scope for this plugin.
14
+
15
+ ## Setup
16
+
17
+ ### 1. Create Google OAuth credentials
18
+
19
+ 1. Go to the [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
20
+ 2. Create (or reuse) a project and enable the **YouTube Data API v3**
21
+ 3. Create an OAuth client ID (web application)
22
+ 4. Note your **Client ID** and **Client Secret**
23
+ 5. Add Chronicle's callback to the authorized redirect URIs
24
+
25
+ ### 2. Authenticate
26
+
27
+ ```bash
28
+ chronicle auth login youtube --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET
29
+ ```
30
+
31
+ This opens your browser for Google consent and stores the tokens (including the offline refresh token) in Chronicle's credential store. Access tokens expire after an hour and are refreshed automatically.
32
+
33
+ ### 3. Extract Data
34
+
35
+ ```bash
36
+ # Liked videos (default extraction type)
37
+ chronicle extract youtube --type likes
38
+
39
+ # Subscriptions
40
+ chronicle extract youtube --type subscriptions
41
+
42
+ # Playlists and uploads
43
+ chronicle extract youtube --type playlists
44
+ chronicle extract youtube --type uploads
45
+ ```
46
+
47
+ ## Data Mapping
48
+
49
+ - 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 on the channel id so re-runs supersede
51
+ - 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
+ - Upload → `PublishAction` with the account owner as agent and the `VideoObject` as result
53
+ - Channels are `Agent`s keyed on their YouTube channel id
54
+
55
+ 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 folder in a temporary
57
+ directory. They never call Google, read stored credentials, or read the host's
58
+ data or accounts.
@@ -0,0 +1,14 @@
1
+ import { OAuthProvider } from '@chronicle.app/auth';
2
+ export declare class GoogleOAuthProvider extends OAuthProvider {
3
+ static providerId: string;
4
+ static authorizationUrl: string;
5
+ static tokenUrl: string;
6
+ static scopes: string[];
7
+ static requiresClientSecret: boolean;
8
+ static extraAuthParams: {
9
+ access_type: string;
10
+ prompt: string;
11
+ include_granted_scopes: string;
12
+ };
13
+ }
14
+ //# sourceMappingURL=GoogleOAuthProvider.d.ts.map
@@ -0,0 +1,15 @@
1
+ import { OAuthProvider } from '@chronicle.app/auth';
2
+ export class GoogleOAuthProvider extends OAuthProvider {
3
+ static providerId = 'youtube';
4
+ static authorizationUrl = 'https://accounts.google.com/o/oauth2/v2/auth';
5
+ static tokenUrl = 'https://oauth2.googleapis.com/token';
6
+ static scopes = ['https://www.googleapis.com/auth/youtube.readonly'];
7
+ static requiresClientSecret = true;
8
+ // Without offline access + forced consent Google issues no refresh token,
9
+ // leaving the stored credential unable to outlive the first hour.
10
+ static extraAuthParams = {
11
+ access_type: 'offline',
12
+ prompt: 'consent',
13
+ include_granted_scopes: 'true',
14
+ };
15
+ }
@@ -0,0 +1,36 @@
1
+ import { Extractor, Record } from '@chronicle.app/etl';
2
+ import { z } from 'zod';
3
+ import YouTubeTransformer from './YouTubeTransformer.js';
4
+ import YouTubeProxy, { YouTubeSelfChannel } from '../utils/YouTubeProxy.js';
5
+ export declare abstract class YouTubeBaseExtractor<T extends typeof YouTubeBaseExtractor> extends Extractor<T> {
6
+ static source: string;
7
+ static description: string;
8
+ static delivery: "api";
9
+ static strategy: string;
10
+ static defaultTransformer: typeof YouTubeTransformer;
11
+ static schema: z.ZodObject<{
12
+ since: z.ZodOptional<z.ZodDate>;
13
+ until: z.ZodOptional<z.ZodDate>;
14
+ limit: z.ZodOptional<z.ZodNumber>;
15
+ } & {
16
+ accessToken: z.ZodOptional<z.ZodString>;
17
+ }, "strip", z.ZodTypeAny, {
18
+ limit?: number | undefined;
19
+ accessToken?: string | undefined;
20
+ since?: Date | undefined;
21
+ until?: Date | undefined;
22
+ }, {
23
+ limit?: number | undefined;
24
+ accessToken?: string | undefined;
25
+ since?: Date | undefined;
26
+ until?: Date | undefined;
27
+ }>;
28
+ protected proxy: YouTubeProxy;
29
+ protected user?: YouTubeSelfChannel;
30
+ static hasValidCredentials(): Promise<boolean>;
31
+ setup(): Promise<void>;
32
+ protected createRecordWithUser(data: any, options: {
33
+ recordType: string;
34
+ }): Record;
35
+ }
36
+ //# sourceMappingURL=YouTubeBaseExtractor.d.ts.map
@@ -0,0 +1,40 @@
1
+ import { Extractor } from '@chronicle.app/etl';
2
+ import { TokenHelper } from '@chronicle.app/auth';
3
+ import { z } from 'zod';
4
+ import YouTubeTransformer from './YouTubeTransformer.js';
5
+ import YouTubeProxy from '../utils/YouTubeProxy.js';
6
+ export class YouTubeBaseExtractor extends Extractor {
7
+ static source = 'youtube';
8
+ static description = 'Base extractor for YouTube data';
9
+ static delivery = 'api';
10
+ static strategy = 'api';
11
+ static defaultTransformer = YouTubeTransformer;
12
+ static schema = Extractor.schema.extend({
13
+ accessToken: z
14
+ .string()
15
+ .optional()
16
+ .describe('OAuth access token for the YouTube Data API (auto-retrieved if not provided)'),
17
+ });
18
+ proxy;
19
+ user;
20
+ static async hasValidCredentials() {
21
+ try {
22
+ await TokenHelper.getValidToken('youtube');
23
+ return true;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ async setup() {
30
+ const config = this.config;
31
+ this.proxy = new YouTubeProxy(config.accessToken);
32
+ this.user = await this.proxy.getUser();
33
+ }
34
+ createRecordWithUser(data, options) {
35
+ return this.createRecord(data, {
36
+ recordType: options.recordType,
37
+ user: this.user,
38
+ });
39
+ }
40
+ }
@@ -0,0 +1,17 @@
1
+ import { Record } from '@chronicle.app/etl';
2
+ import { YouTubeBaseExtractor } from './YouTubeBaseExtractor.js';
3
+ import { YouTubePlaylistItem, YouTubeVideo } from '../utils/YouTubeProxy.js';
4
+ export interface YouTubeLikedVideo {
5
+ item: YouTubePlaylistItem;
6
+ video?: YouTubeVideo;
7
+ }
8
+ export declare class YouTubeLikesExtractor extends YouTubeBaseExtractor<typeof YouTubeLikesExtractor> {
9
+ static description: string;
10
+ static recordTypes: string[];
11
+ static default: boolean;
12
+ private likesPlaylistId;
13
+ setup(): Promise<void>;
14
+ extract(): AsyncGenerator<Record>;
15
+ determineCount(): Promise<number | null>;
16
+ }
17
+ //# sourceMappingURL=YouTubeLikesExtractor.d.ts.map
@@ -0,0 +1,54 @@
1
+ import { YouTubeBaseExtractor } from './YouTubeBaseExtractor.js';
2
+ export class YouTubeLikesExtractor extends YouTubeBaseExtractor {
3
+ static description = 'Liked videos';
4
+ static recordTypes = ['likes'];
5
+ static default = true;
6
+ likesPlaylistId;
7
+ async setup() {
8
+ await super.setup();
9
+ const likesPlaylistId = this.user?.contentDetails?.relatedPlaylists?.likes;
10
+ if (!likesPlaylistId) {
11
+ throw new Error('YouTube API did not expose a likes playlist for this account');
12
+ }
13
+ this.likesPlaylistId = likesPlaylistId;
14
+ }
15
+ async *extract() {
16
+ const { since, until } = this.config;
17
+ // The likes playlist is newest-first, so a `since` bound also caps how far
18
+ // we need to paginate; without one we fetch to the configured limit.
19
+ let items = await this.proxy.getLikes({
20
+ likesPlaylistId: this.likesPlaylistId,
21
+ limit: since ? undefined : (this.getEffectiveLimit() ?? undefined),
22
+ });
23
+ items = items.filter(item => {
24
+ const likedAt = new Date(item.snippet.publishedAt);
25
+ if (since && likedAt <= since)
26
+ return false;
27
+ if (until && likedAt > until)
28
+ return false;
29
+ return true;
30
+ });
31
+ const limit = this.getEffectiveLimit();
32
+ if (limit !== null) {
33
+ items = items.slice(0, limit);
34
+ }
35
+ const details = await this.proxy.getVideoDetails(items.map(item => item.snippet.resourceId.videoId));
36
+ const detailsById = new Map(details.map(video => [video.id, video]));
37
+ this.logger.info(`Retrieved ${items.length} liked videos from YouTube`);
38
+ for (const item of items) {
39
+ const liked = {
40
+ item,
41
+ video: detailsById.get(item.snippet.resourceId.videoId),
42
+ };
43
+ yield this.createRecordWithUser(liked, { recordType: 'likes' });
44
+ }
45
+ }
46
+ async determineCount() {
47
+ try {
48
+ return await this.proxy.getPlaylistItemCount(this.likesPlaylistId);
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,16 @@
1
+ import { Record } from '@chronicle.app/etl';
2
+ import { YouTubeBaseExtractor } from './YouTubeBaseExtractor.js';
3
+ import { YouTubePlaylist, YouTubePlaylistItem, YouTubeVideo } from '../utils/YouTubeProxy.js';
4
+ export interface YouTubePlaylistItemRecord {
5
+ playlist: YouTubePlaylist;
6
+ item: YouTubePlaylistItem;
7
+ video?: YouTubeVideo;
8
+ }
9
+ export declare class YouTubePlaylistsExtractor extends YouTubeBaseExtractor<typeof YouTubePlaylistsExtractor> {
10
+ static description: string;
11
+ static recordTypes: string[];
12
+ static temporality: "snapshot";
13
+ extract(): AsyncGenerator<Record>;
14
+ determineCount(): Promise<number | null>;
15
+ }
16
+ //# sourceMappingURL=YouTubePlaylistsExtractor.d.ts.map
@@ -0,0 +1,44 @@
1
+ import { YouTubeBaseExtractor } from './YouTubeBaseExtractor.js';
2
+ export class YouTubePlaylistsExtractor extends YouTubeBaseExtractor {
3
+ static description = 'Your playlists and their videos';
4
+ static recordTypes = ['playlists', 'playlist-items'];
5
+ // Re-running re-reports every playlist and its current membership, so
6
+ // sightings belong at read time and removed items depart via completeness.
7
+ static temporality = 'snapshot';
8
+ async *extract() {
9
+ const playlists = await this.proxy.getPlaylists();
10
+ this.logger.info(`Retrieved ${playlists.length} playlists from YouTube`);
11
+ let recordCount = 0;
12
+ for (const playlist of playlists) {
13
+ if (this.shouldStopExtracting(recordCount))
14
+ break;
15
+ yield this.createRecordWithUser(playlist, { recordType: 'playlists' });
16
+ recordCount++;
17
+ const items = await this.proxy.getPlaylistItems(playlist.id);
18
+ const details = await this.proxy.getVideoDetails(items.map(item => item.snippet.resourceId.videoId));
19
+ const detailsById = new Map(details.map(video => [video.id, video]));
20
+ for (const item of items) {
21
+ if (this.shouldStopExtracting(recordCount))
22
+ break;
23
+ const data = {
24
+ playlist,
25
+ item,
26
+ video: detailsById.get(item.snippet.resourceId.videoId),
27
+ };
28
+ yield this.createRecordWithUser(data, {
29
+ recordType: 'playlist-items',
30
+ });
31
+ recordCount++;
32
+ }
33
+ }
34
+ }
35
+ async determineCount() {
36
+ try {
37
+ const playlists = await this.proxy.getPlaylists();
38
+ return playlists.reduce((sum, playlist) => sum + 1 + (playlist.contentDetails?.itemCount ?? 0), 0);
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,10 @@
1
+ import { Record } from '@chronicle.app/etl';
2
+ import { YouTubeBaseExtractor } from './YouTubeBaseExtractor.js';
3
+ export declare class YouTubeSubscriptionsExtractor extends YouTubeBaseExtractor<typeof YouTubeSubscriptionsExtractor> {
4
+ static description: string;
5
+ static recordTypes: string[];
6
+ static temporality: "snapshot";
7
+ extract(): AsyncGenerator<Record>;
8
+ determineCount(): Promise<number | null>;
9
+ }
10
+ //# sourceMappingURL=YouTubeSubscriptionsExtractor.d.ts.map
@@ -0,0 +1,27 @@
1
+ import { YouTubeBaseExtractor } from './YouTubeBaseExtractor.js';
2
+ export class YouTubeSubscriptionsExtractor extends YouTubeBaseExtractor {
3
+ static description = 'Channel subscriptions';
4
+ static recordTypes = ['subscriptions'];
5
+ // Re-running re-reports the full current subscription list with each
6
+ // channel's latest title/avatar, so sightings belong at read time.
7
+ static temporality = 'snapshot';
8
+ async *extract() {
9
+ const subscriptions = await this.proxy.getSubscriptions({
10
+ limit: this.getEffectiveLimit() ?? undefined,
11
+ });
12
+ this.logger.info(`Retrieved ${subscriptions.length} subscriptions from YouTube`);
13
+ for (const subscription of subscriptions) {
14
+ yield this.createRecordWithUser(subscription, {
15
+ recordType: 'subscriptions',
16
+ });
17
+ }
18
+ }
19
+ async determineCount() {
20
+ try {
21
+ return await this.proxy.getSubscriptionsCount();
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,92 @@
1
+ import { Extractor, Record } from '@chronicle.app/etl';
2
+ import { z } from 'zod';
3
+ import YouTubeTakeoutTransformer from './YouTubeTakeoutTransformer.js';
4
+ export interface TakeoutSelfChannel {
5
+ id: string;
6
+ title?: string;
7
+ }
8
+ export interface TakeoutWatchRecord {
9
+ videoId: string;
10
+ title?: string;
11
+ titleUrl?: string;
12
+ channelId?: string;
13
+ channelName?: string;
14
+ time: string;
15
+ }
16
+ export interface TakeoutSearchRecord {
17
+ query: string;
18
+ time: string;
19
+ }
20
+ export interface TakeoutSubscriptionRecord {
21
+ channelId: string;
22
+ channelTitle?: string;
23
+ }
24
+ export interface TakeoutPlaylistRecord {
25
+ playlistId: string;
26
+ title: string;
27
+ createdAt: string;
28
+ visibility?: string;
29
+ }
30
+ export interface TakeoutPlaylistItemRecord {
31
+ playlistId: string;
32
+ playlistTitle: string;
33
+ videoId: string;
34
+ addedAt: string;
35
+ }
36
+ export interface TakeoutUploadRecord {
37
+ videoId: string;
38
+ title?: string;
39
+ description?: string;
40
+ durationMs?: string;
41
+ publishedAt: string;
42
+ }
43
+ export interface TakeoutCommentRecord {
44
+ commentId: string;
45
+ videoId: string;
46
+ rawText: string;
47
+ createdAt: string;
48
+ }
49
+ export declare class YouTubeTakeoutExtractor extends Extractor<typeof YouTubeTakeoutExtractor> {
50
+ static source: string;
51
+ static description: string;
52
+ static delivery: "export";
53
+ static strategy: string;
54
+ static recordTypes: string[];
55
+ static default: boolean;
56
+ static temporality: "snapshot";
57
+ static defaultTransformer: typeof YouTubeTakeoutTransformer;
58
+ static schema: z.ZodObject<{
59
+ since: z.ZodOptional<z.ZodDate>;
60
+ until: z.ZodOptional<z.ZodDate>;
61
+ limit: z.ZodOptional<z.ZodNumber>;
62
+ } & {
63
+ input: z.ZodOptional<z.ZodString>;
64
+ }, "strip", z.ZodTypeAny, {
65
+ limit?: number | undefined;
66
+ since?: Date | undefined;
67
+ until?: Date | undefined;
68
+ input?: string | undefined;
69
+ }, {
70
+ limit?: number | undefined;
71
+ since?: Date | undefined;
72
+ until?: Date | undefined;
73
+ input?: string | undefined;
74
+ }>;
75
+ private archiveAsOf;
76
+ private selfChannel;
77
+ private inputPath;
78
+ private fileExists;
79
+ setup(): Promise<void>;
80
+ protected asOfTime(): Date;
81
+ private inRange;
82
+ /** `[playlist title, csv path]` for each per-playlist membership file. */
83
+ private playlistItemFiles;
84
+ extract(): AsyncGenerator<Record>;
85
+ private extractWatches;
86
+ private extractSearches;
87
+ private extractSubscriptions;
88
+ private extractPlaylists;
89
+ private extractUploads;
90
+ private extractComments;
91
+ }
92
+ //# sourceMappingURL=YouTubeTakeoutExtractor.d.ts.map