@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
@@ -0,0 +1,138 @@
1
+ import { ApiAuthError, ApiProxy } from '@chronicle.app/etl';
2
+ import { TokenHelper } from '@chronicle.app/auth';
3
+ export default class YouTubeProxy extends ApiProxy {
4
+ constructor(accessToken) {
5
+ super({
6
+ baseURL: 'https://www.googleapis.com/youtube/v3',
7
+ headers: {
8
+ 'Content-Type': 'application/json',
9
+ },
10
+ pageDelayMs: 100,
11
+ });
12
+ if (accessToken) {
13
+ this.setAccessToken(accessToken);
14
+ }
15
+ }
16
+ async initialize() {
17
+ await this.ensureAuthenticated();
18
+ }
19
+ // Lazily resolves a token from the keychain the first time a request needs
20
+ // one; a token passed to the constructor short-circuits this.
21
+ async ensureAuthenticated() {
22
+ if (this.accessToken) {
23
+ return;
24
+ }
25
+ const token = await TokenHelper.getValidToken('youtube');
26
+ this.setAccessToken(token);
27
+ }
28
+ // Keep the actionable re-auth guidance the generic 401 mapping would lose.
29
+ mapError(error) {
30
+ const mapped = super.mapError(error);
31
+ if (mapped instanceof ApiAuthError) {
32
+ return new ApiAuthError('YouTube authentication failed. Please re-authenticate:\n' +
33
+ ' chronicle auth login youtube --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET\n\n' +
34
+ 'Create OAuth credentials at: https://console.cloud.google.com/apis/credentials');
35
+ }
36
+ return mapped;
37
+ }
38
+ async get(path, params) {
39
+ await this.ensureAuthenticated();
40
+ return this.request({ url: path, method: 'GET', params });
41
+ }
42
+ /** Self channel (drives the @me agent) plus the likes playlist id. */
43
+ async getUser() {
44
+ const data = await this.get('/channels', {
45
+ part: 'snippet,contentDetails',
46
+ mine: true,
47
+ });
48
+ const channel = data.items?.[0];
49
+ if (!channel) {
50
+ throw new Error('YouTube API returned no channel for this account');
51
+ }
52
+ return channel;
53
+ }
54
+ /**
55
+ * Liked videos, newest first, from the account's likes playlist.
56
+ * snippet.publishedAt on a playlist item is when the video was liked.
57
+ */
58
+ async getLikes(options) {
59
+ return this.getPlaylistItems(options.likesPlaylistId, {
60
+ limit: options.limit,
61
+ });
62
+ }
63
+ /** Items of any playlist. snippet.publishedAt is when the item was added. */
64
+ async getPlaylistItems(playlistId, options = {}) {
65
+ return this.paginateCursor({
66
+ limit: options.limit,
67
+ fetchPage: async (cursor) => {
68
+ const data = await this.get('/playlistItems', {
69
+ part: 'snippet',
70
+ playlistId,
71
+ maxResults: 50,
72
+ ...(cursor && { pageToken: cursor }),
73
+ });
74
+ return { items: data.items ?? [], cursor: data.nextPageToken };
75
+ },
76
+ });
77
+ }
78
+ /** The account's own playlists (created playlists, not saved ones). */
79
+ async getPlaylists(options = {}) {
80
+ return this.paginateCursor({
81
+ limit: options.limit,
82
+ fetchPage: async (cursor) => {
83
+ const data = await this.get('/playlists', {
84
+ part: 'snippet,contentDetails,status',
85
+ mine: true,
86
+ maxResults: 50,
87
+ ...(cursor && { pageToken: cursor }),
88
+ });
89
+ return { items: data.items ?? [], cursor: data.nextPageToken };
90
+ },
91
+ });
92
+ }
93
+ /** Hydrate video details (duration, tags, canonical channel) in batches. */
94
+ async getVideoDetails(ids) {
95
+ let details = [];
96
+ for (let i = 0; i < ids.length; i += 50) {
97
+ const chunk = ids.slice(i, i + 50);
98
+ const data = await this.get('/videos', {
99
+ part: 'snippet,contentDetails',
100
+ id: chunk.join(','),
101
+ maxResults: 50,
102
+ });
103
+ details = details.concat(data.items ?? []);
104
+ await this.delay(100);
105
+ }
106
+ return details;
107
+ }
108
+ async getSubscriptions(options) {
109
+ return this.paginateCursor({
110
+ limit: options.limit,
111
+ fetchPage: async (cursor) => {
112
+ const data = await this.get('/subscriptions', {
113
+ part: 'snippet',
114
+ mine: true,
115
+ maxResults: 50,
116
+ ...(cursor && { pageToken: cursor }),
117
+ });
118
+ return { items: data.items ?? [], cursor: data.nextPageToken };
119
+ },
120
+ });
121
+ }
122
+ async getPlaylistItemCount(playlistId) {
123
+ const data = await this.get('/playlistItems', {
124
+ part: 'id',
125
+ playlistId,
126
+ maxResults: 1,
127
+ });
128
+ return data.pageInfo?.totalResults ?? 0;
129
+ }
130
+ async getSubscriptionsCount() {
131
+ const data = await this.get('/subscriptions', {
132
+ part: 'id',
133
+ mine: true,
134
+ maxResults: 1,
135
+ });
136
+ return data.pageInfo?.totalResults ?? 0;
137
+ }
138
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * YouTube reports the same instant at different precisions on different
3
+ * surfaces: the Data API carries milliseconds where Takeout carries whole
4
+ * seconds. Every timestamp the plugin emits is truncated to the second — the
5
+ * shared precision — so same-key assertions from the two paths land on one
6
+ * instant instead of a millisecond-inverted occurrence range.
7
+ */
8
+ export declare function ytTime(value: string | number | Date): Date;
9
+ /** Takeout's videos.csv reports duration in milliseconds; the schema (like the
10
+ * Data API) carries ISO-8601 durations. */
11
+ export declare function msToIsoDuration(ms: number): string;
12
+ //# sourceMappingURL=time.d.ts.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * YouTube reports the same instant at different precisions on different
3
+ * surfaces: the Data API carries milliseconds where Takeout carries whole
4
+ * seconds. Every timestamp the plugin emits is truncated to the second — the
5
+ * shared precision — so same-key assertions from the two paths land on one
6
+ * instant instead of a millisecond-inverted occurrence range.
7
+ */
8
+ export function ytTime(value) {
9
+ const ms = new Date(value).getTime();
10
+ if (Number.isNaN(ms)) {
11
+ throw new TypeError(`Invalid YouTube timestamp: ${String(value)}`);
12
+ }
13
+ return new Date(Math.floor(ms / 1000) * 1000);
14
+ }
15
+ /** Takeout's videos.csv reports duration in milliseconds; the schema (like the
16
+ * Data API) carries ISO-8601 durations. */
17
+ export function msToIsoDuration(ms) {
18
+ const total = Math.round(ms / 1000);
19
+ const hours = Math.floor(total / 3600);
20
+ const minutes = Math.floor((total % 3600) / 60);
21
+ const seconds = total % 60;
22
+ let out = 'PT';
23
+ if (hours)
24
+ out += `${hours}H`;
25
+ if (minutes)
26
+ out += `${minutes}M`;
27
+ if (seconds || out === 'PT')
28
+ out += `${seconds}S`;
29
+ return out;
30
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Canonical https URL builders shared by the API and Takeout paths. `url` is a
3
+ * cardinality-one property and mints a `{url}` identity keyset, so the two
4
+ * paths must emit byte-identical values — Takeout renders `http://` channel
5
+ * links and music.youtube.com watch links, which are never passed through.
6
+ */
7
+ export declare function channelUrl(channelId: string): string;
8
+ export declare function videoUrl(videoId: string): string;
9
+ export declare function playlistUrl(playlistId: string): string;
10
+ //# sourceMappingURL=urls.d.ts.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Canonical https URL builders shared by the API and Takeout paths. `url` is a
3
+ * cardinality-one property and mints a `{url}` identity keyset, so the two
4
+ * paths must emit byte-identical values — Takeout renders `http://` channel
5
+ * links and music.youtube.com watch links, which are never passed through.
6
+ */
7
+ export function channelUrl(channelId) {
8
+ return `https://www.youtube.com/channel/${channelId}`;
9
+ }
10
+ export function videoUrl(videoId) {
11
+ return `https://www.youtube.com/watch?v=${videoId}`;
12
+ }
13
+ export function playlistUrl(playlistId) {
14
+ return `https://www.youtube.com/playlist?list=${playlistId}`;
15
+ }
package/package.json ADDED
@@ -0,0 +1,92 @@
1
+ {
2
+ "name": "@chronicle.app/youtube",
3
+ "version": "0.3.0",
4
+ "main": "./dist/index.js",
5
+ "types": "./dist/index.d.ts",
6
+ "files": [
7
+ "dist/**/*.js",
8
+ "dist/**/*.d.ts"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc -b tsconfig.lib.json",
12
+ "typecheck": "tsc -p tsconfig.json",
13
+ "lint": "eslint src --ext .ts,.js",
14
+ "test": "node --test src/*.test.js"
15
+ },
16
+ "keywords": [
17
+ "chronicle",
18
+ "youtube"
19
+ ],
20
+ "author": "Andrew Louis",
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "description": "Chronicle plugin for extracting data from YouTube",
24
+ "devDependencies": {
25
+ "@chronicle.app/etl": "0.3.0",
26
+ "@chronicle.app/schema": "0.3.0",
27
+ "@chronicle.app/tsconfig": "0.3.0",
28
+ "@types/node": "^22.10.1",
29
+ "typescript": "^5.7.2"
30
+ },
31
+ "homepage": "https://chronicle.app",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/chronicle-app/chronicle.git",
35
+ "directory": "plugins/youtube"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "registry": "https://registry.npmjs.org/"
40
+ },
41
+ "dependencies": {
42
+ "@chronicle.app/auth": "0.3.0",
43
+ "axios": "^1.7.9",
44
+ "csv-parse": "^5.6.0",
45
+ "zod": "^3.24.1"
46
+ },
47
+ "peerDependencies": {
48
+ "@chronicle.app/etl": ">=0.1.0 <1.0.0",
49
+ "@chronicle.app/schema": ">=0.1.0 <1.0.0"
50
+ },
51
+ "engines": {
52
+ "node": ">=22.13.0"
53
+ },
54
+ "exports": {
55
+ ".": {
56
+ "types": "./dist/index.d.ts",
57
+ "import": "./dist/index.js"
58
+ }
59
+ },
60
+ "chronicle": {
61
+ "plugin": true,
62
+ "sources": {
63
+ "youtube": {
64
+ "strategies": {
65
+ "api": {
66
+ "delivery": "api",
67
+ "recordTypes": [
68
+ "likes",
69
+ "playlists",
70
+ "playlist-items",
71
+ "subscriptions",
72
+ "uploads"
73
+ ],
74
+ "default": true
75
+ },
76
+ "takeout": {
77
+ "delivery": "export",
78
+ "recordTypes": [
79
+ "watches",
80
+ "searches",
81
+ "subscriptions",
82
+ "playlists",
83
+ "playlist-items",
84
+ "uploads",
85
+ "comments"
86
+ ]
87
+ }
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }