@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.
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/dist/auth/GoogleOAuthProvider.d.ts +14 -0
- package/dist/auth/GoogleOAuthProvider.js +15 -0
- package/dist/connectors/YouTubeBaseExtractor.d.ts +36 -0
- package/dist/connectors/YouTubeBaseExtractor.js +40 -0
- package/dist/connectors/YouTubeLikesExtractor.d.ts +17 -0
- package/dist/connectors/YouTubeLikesExtractor.js +54 -0
- package/dist/connectors/YouTubePlaylistsExtractor.d.ts +16 -0
- package/dist/connectors/YouTubePlaylistsExtractor.js +44 -0
- package/dist/connectors/YouTubeSubscriptionsExtractor.d.ts +10 -0
- package/dist/connectors/YouTubeSubscriptionsExtractor.js +27 -0
- package/dist/connectors/YouTubeTakeoutExtractor.d.ts +92 -0
- package/dist/connectors/YouTubeTakeoutExtractor.js +315 -0
- package/dist/connectors/YouTubeTakeoutTransformer.d.ts +27 -0
- package/dist/connectors/YouTubeTakeoutTransformer.js +260 -0
- package/dist/connectors/YouTubeTransformer.d.ts +15 -0
- package/dist/connectors/YouTubeTransformer.js +220 -0
- package/dist/connectors/YouTubeUploadsExtractor.d.ts +11 -0
- package/dist/connectors/YouTubeUploadsExtractor.js +50 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +12 -0
- package/dist/takeout/csv.d.ts +4 -0
- package/dist/takeout/csv.js +12 -0
- package/dist/takeout/parseActivityHtml.d.ts +30 -0
- package/dist/takeout/parseActivityHtml.js +116 -0
- package/dist/utils/YouTubeProxy.d.ts +138 -0
- package/dist/utils/YouTubeProxy.js +138 -0
- package/dist/utils/time.d.ts +12 -0
- package/dist/utils/time.js +30 -0
- package/dist/utils/urls.d.ts +10 -0
- package/dist/utils/urls.js +15 -0
- package/package.json +92 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { ChronicleTransformer, createImageObject } from '@chronicle.app/etl';
|
|
2
|
+
import { ytTime } from '../utils/time.js';
|
|
3
|
+
import { channelUrl, playlistUrl, videoUrl } from '../utils/urls.js';
|
|
4
|
+
// YouTube returns thumbnails at several sizes; pick the largest available.
|
|
5
|
+
function largestThumbnail(thumbnails) {
|
|
6
|
+
if (!thumbnails)
|
|
7
|
+
return undefined;
|
|
8
|
+
const order = ['maxres', 'standard', 'high', 'medium', 'default'];
|
|
9
|
+
for (const size of order) {
|
|
10
|
+
const url = thumbnails[size]?.url;
|
|
11
|
+
if (url)
|
|
12
|
+
return url;
|
|
13
|
+
}
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
// When a liked video has been deleted or made private, videos.list returns
|
|
17
|
+
// nothing and the playlist item carries these literal placeholder strings in
|
|
18
|
+
// place of the video's real title — never assert them as data.
|
|
19
|
+
const PLACEHOLDER_TITLES = new Set(['Deleted video', 'Private video']);
|
|
20
|
+
export default class YouTubeTransformer extends ChronicleTransformer {
|
|
21
|
+
async transform(record) {
|
|
22
|
+
switch (record.extraction.recordType) {
|
|
23
|
+
case 'likes': {
|
|
24
|
+
return this.buildLikeAction(record);
|
|
25
|
+
}
|
|
26
|
+
case 'subscriptions': {
|
|
27
|
+
return this.buildFollowAction(record);
|
|
28
|
+
}
|
|
29
|
+
case 'playlists': {
|
|
30
|
+
return this.buildPlaylistCreateAction(record);
|
|
31
|
+
}
|
|
32
|
+
case 'playlist-items': {
|
|
33
|
+
return this.buildPlaylistAddAction(record);
|
|
34
|
+
}
|
|
35
|
+
case 'uploads': {
|
|
36
|
+
return this.buildUploadPublishAction(record);
|
|
37
|
+
}
|
|
38
|
+
default: {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
buildLikeAction(record) {
|
|
44
|
+
const liked = record.data;
|
|
45
|
+
const action = {
|
|
46
|
+
'@type': 'LikeAction',
|
|
47
|
+
'@key': ['@type', 'source', 'agent.handle', 'object.sourceId', 'timestamp'],
|
|
48
|
+
source: 'youtube',
|
|
49
|
+
timestamp: ytTime(liked.item.snippet.publishedAt),
|
|
50
|
+
agent: this.resolveSelf(record),
|
|
51
|
+
object: this.buildVideo(liked),
|
|
52
|
+
};
|
|
53
|
+
return [action];
|
|
54
|
+
}
|
|
55
|
+
// A subscription is a state row (current channel title/avatar at read time),
|
|
56
|
+
// but its publishedAt is the real subscribe instant. Keyed on the followed
|
|
57
|
+
// channel's id so snapshot re-reads supersede rather than duplicate.
|
|
58
|
+
buildFollowAction(record) {
|
|
59
|
+
const subscription = record.data;
|
|
60
|
+
const { channelId } = subscription.snippet.resourceId;
|
|
61
|
+
const action = {
|
|
62
|
+
'@type': 'FollowAction',
|
|
63
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
64
|
+
source: 'youtube',
|
|
65
|
+
sourceId: channelId,
|
|
66
|
+
timestamp: ytTime(subscription.snippet.publishedAt),
|
|
67
|
+
agent: this.resolveSelf(record),
|
|
68
|
+
object: this.buildChannelAgent(channelId, {
|
|
69
|
+
name: subscription.snippet.title,
|
|
70
|
+
description: subscription.snippet.description,
|
|
71
|
+
image: largestThumbnail(subscription.snippet.thumbnails),
|
|
72
|
+
}),
|
|
73
|
+
};
|
|
74
|
+
return [action];
|
|
75
|
+
}
|
|
76
|
+
// A playlist's coming-into-being: real creation timestamp from the API, the
|
|
77
|
+
// account owner as agent, the Collection as origin-grade result.
|
|
78
|
+
buildPlaylistCreateAction(record) {
|
|
79
|
+
const playlist = record.data;
|
|
80
|
+
const action = {
|
|
81
|
+
'@type': 'CreateAction',
|
|
82
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
83
|
+
source: 'youtube',
|
|
84
|
+
sourceId: playlist.id,
|
|
85
|
+
timestamp: ytTime(playlist.snippet.publishedAt),
|
|
86
|
+
agent: this.resolveSelf(record),
|
|
87
|
+
result: this.buildCollection(playlist),
|
|
88
|
+
};
|
|
89
|
+
return [action];
|
|
90
|
+
}
|
|
91
|
+
// Adding a video to a playlist. The membership's identity is (video,
|
|
92
|
+
// playlist, add instant) — the only identity every reader of the account
|
|
93
|
+
// shares (a Takeout archive has no playlist-item ids), so assertions from
|
|
94
|
+
// any path converge on one action. A re-added video is a new instant, hence
|
|
95
|
+
// a new action; the item id rides along as provenance.
|
|
96
|
+
buildPlaylistAddAction(record) {
|
|
97
|
+
const { playlist, item, video } = record.data;
|
|
98
|
+
const action = {
|
|
99
|
+
'@type': 'AddAction',
|
|
100
|
+
'@key': ['@type', 'source', 'object.sourceId', 'target.sourceId', 'timestamp'],
|
|
101
|
+
source: 'youtube',
|
|
102
|
+
sourceId: item.id,
|
|
103
|
+
timestamp: ytTime(item.snippet.publishedAt),
|
|
104
|
+
agent: this.resolveSelf(record),
|
|
105
|
+
object: this.buildVideo({ item, video }),
|
|
106
|
+
target: this.buildCollection(playlist),
|
|
107
|
+
};
|
|
108
|
+
return [action];
|
|
109
|
+
}
|
|
110
|
+
// An upload is the video's lifecycle occurrence: the action's own timestamp
|
|
111
|
+
// IS datePublished (carrying the scalar on the video too would make the fold
|
|
112
|
+
// derive a duplicate agentless occurrence), the video rides in `object`
|
|
113
|
+
// (where the lifecycle render-back and edge facets look), and the agent is
|
|
114
|
+
// routed onto the video's `publisher` edge by the fold.
|
|
115
|
+
buildUploadPublishAction(record) {
|
|
116
|
+
const upload = record.data;
|
|
117
|
+
const video = this.buildVideo(upload);
|
|
118
|
+
delete video.datePublished;
|
|
119
|
+
const action = {
|
|
120
|
+
'@type': 'PublishAction',
|
|
121
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
122
|
+
source: 'youtube',
|
|
123
|
+
sourceId: upload.item.snippet.resourceId.videoId,
|
|
124
|
+
timestamp: ytTime(upload.item.snippet.publishedAt),
|
|
125
|
+
agent: this.resolveSelf(record),
|
|
126
|
+
object: video,
|
|
127
|
+
};
|
|
128
|
+
return [action];
|
|
129
|
+
}
|
|
130
|
+
buildCollection(playlist) {
|
|
131
|
+
const thumbnail = largestThumbnail(playlist.snippet.thumbnails);
|
|
132
|
+
return {
|
|
133
|
+
'@type': 'Collection',
|
|
134
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
135
|
+
source: 'youtube',
|
|
136
|
+
sourceId: playlist.id,
|
|
137
|
+
url: playlistUrl(playlist.id),
|
|
138
|
+
name: playlist.snippet.title,
|
|
139
|
+
...(playlist.snippet.description && {
|
|
140
|
+
description: playlist.snippet.description,
|
|
141
|
+
}),
|
|
142
|
+
...(playlist.status?.privacyStatus && {
|
|
143
|
+
visibility: playlist.status.privacyStatus,
|
|
144
|
+
}),
|
|
145
|
+
...(thumbnail && { emblem: createImageObject({ url: thumbnail }) }),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
buildVideo(liked) {
|
|
149
|
+
const { item, video } = liked;
|
|
150
|
+
const { videoId } = item.snippet.resourceId;
|
|
151
|
+
// The hydrated videos.list response is canonical; the playlist item is the
|
|
152
|
+
// fallback for videos that have since gone private or been deleted — but
|
|
153
|
+
// its placeholder title/description are YouTube boilerplate, not the
|
|
154
|
+
// video's metadata, so an unavailable video keeps only its id and url.
|
|
155
|
+
const snippet = video?.snippet ?? item.snippet;
|
|
156
|
+
const unavailable = !video && PLACEHOLDER_TITLES.has(item.snippet.title);
|
|
157
|
+
const uploaderChannelId = video?.snippet.channelId ?? item.snippet.videoOwnerChannelId;
|
|
158
|
+
const uploaderChannelTitle = video?.snippet.channelTitle ?? item.snippet.videoOwnerChannelTitle;
|
|
159
|
+
const thumbnail = largestThumbnail(snippet.thumbnails);
|
|
160
|
+
const videoObject = {
|
|
161
|
+
'@type': 'VideoObject',
|
|
162
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
163
|
+
source: 'youtube',
|
|
164
|
+
sourceId: videoId,
|
|
165
|
+
url: videoUrl(videoId),
|
|
166
|
+
...(!unavailable && { name: snippet.title }),
|
|
167
|
+
...(!unavailable && snippet.description && { description: snippet.description }),
|
|
168
|
+
...(video?.contentDetails?.duration && {
|
|
169
|
+
duration: video.contentDetails.duration,
|
|
170
|
+
}),
|
|
171
|
+
...(video?.snippet.publishedAt && {
|
|
172
|
+
datePublished: video.snippet.publishedAt,
|
|
173
|
+
}),
|
|
174
|
+
...(thumbnail && { emblem: createImageObject({ url: thumbnail }) }),
|
|
175
|
+
...(uploaderChannelId && {
|
|
176
|
+
author: [
|
|
177
|
+
this.buildChannelAgent(uploaderChannelId, {
|
|
178
|
+
name: uploaderChannelTitle,
|
|
179
|
+
}),
|
|
180
|
+
],
|
|
181
|
+
}),
|
|
182
|
+
};
|
|
183
|
+
return videoObject;
|
|
184
|
+
}
|
|
185
|
+
// A YouTube channel as an Agent, keyed on its channel id. Shared by the
|
|
186
|
+
// account owner and subscribed/uploading channels so all merge on the id.
|
|
187
|
+
buildChannelAgent(channelId, opts) {
|
|
188
|
+
const agent = {
|
|
189
|
+
'@type': 'Agent',
|
|
190
|
+
'@key': ['@type', 'source', 'handle'],
|
|
191
|
+
source: 'youtube',
|
|
192
|
+
handle: channelId,
|
|
193
|
+
url: channelUrl(channelId),
|
|
194
|
+
};
|
|
195
|
+
if (opts.name) {
|
|
196
|
+
agent.name = opts.name;
|
|
197
|
+
}
|
|
198
|
+
if (opts.description) {
|
|
199
|
+
agent.description = opts.description;
|
|
200
|
+
}
|
|
201
|
+
if (opts.image) {
|
|
202
|
+
agent.emblem = createImageObject({ url: opts.image });
|
|
203
|
+
}
|
|
204
|
+
return agent;
|
|
205
|
+
}
|
|
206
|
+
// The account owner, from the self channel resolved in extractor setup.
|
|
207
|
+
// Only this agent is tagged @me; other channels route through
|
|
208
|
+
// buildChannelAgent and must not.
|
|
209
|
+
resolveSelf(record) {
|
|
210
|
+
const user = record.context?.user;
|
|
211
|
+
if (!user?.id) {
|
|
212
|
+
throw new Error('YouTube self channel could not be resolved for the account owner');
|
|
213
|
+
}
|
|
214
|
+
const self = this.buildChannelAgent(user.id, {
|
|
215
|
+
name: user.snippet?.title,
|
|
216
|
+
image: largestThumbnail(user.snippet?.thumbnails),
|
|
217
|
+
});
|
|
218
|
+
return { ...self, sameAs: [...(self.sameAs ?? []), '@me'] };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Record } from '@chronicle.app/etl';
|
|
2
|
+
import { YouTubeBaseExtractor } from './YouTubeBaseExtractor.js';
|
|
3
|
+
export declare class YouTubeUploadsExtractor extends YouTubeBaseExtractor<typeof YouTubeUploadsExtractor> {
|
|
4
|
+
static description: string;
|
|
5
|
+
static recordTypes: string[];
|
|
6
|
+
private uploadsPlaylistId;
|
|
7
|
+
setup(): Promise<void>;
|
|
8
|
+
extract(): AsyncGenerator<Record>;
|
|
9
|
+
determineCount(): Promise<number | null>;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=YouTubeUploadsExtractor.d.ts.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { YouTubeBaseExtractor } from './YouTubeBaseExtractor.js';
|
|
2
|
+
export class YouTubeUploadsExtractor extends YouTubeBaseExtractor {
|
|
3
|
+
static description = 'Videos uploaded to your channel';
|
|
4
|
+
static recordTypes = ['uploads'];
|
|
5
|
+
uploadsPlaylistId;
|
|
6
|
+
async setup() {
|
|
7
|
+
await super.setup();
|
|
8
|
+
const uploadsPlaylistId = this.user?.contentDetails?.relatedPlaylists?.uploads;
|
|
9
|
+
if (!uploadsPlaylistId) {
|
|
10
|
+
throw new Error('YouTube API did not expose an uploads playlist for this account');
|
|
11
|
+
}
|
|
12
|
+
this.uploadsPlaylistId = uploadsPlaylistId;
|
|
13
|
+
}
|
|
14
|
+
async *extract() {
|
|
15
|
+
const { since, until } = this.config;
|
|
16
|
+
let items = await this.proxy.getPlaylistItems(this.uploadsPlaylistId, {
|
|
17
|
+
limit: since ? undefined : (this.getEffectiveLimit() ?? undefined),
|
|
18
|
+
});
|
|
19
|
+
items = items.filter(item => {
|
|
20
|
+
const publishedAt = new Date(item.snippet.publishedAt);
|
|
21
|
+
if (since && publishedAt <= since)
|
|
22
|
+
return false;
|
|
23
|
+
if (until && publishedAt > until)
|
|
24
|
+
return false;
|
|
25
|
+
return true;
|
|
26
|
+
});
|
|
27
|
+
const limit = this.getEffectiveLimit();
|
|
28
|
+
if (limit !== null) {
|
|
29
|
+
items = items.slice(0, limit);
|
|
30
|
+
}
|
|
31
|
+
const details = await this.proxy.getVideoDetails(items.map(item => item.snippet.resourceId.videoId));
|
|
32
|
+
const detailsById = new Map(details.map(video => [video.id, video]));
|
|
33
|
+
this.logger.info(`Retrieved ${items.length} uploads from YouTube`);
|
|
34
|
+
for (const item of items) {
|
|
35
|
+
const upload = {
|
|
36
|
+
item,
|
|
37
|
+
video: detailsById.get(item.snippet.resourceId.videoId),
|
|
38
|
+
};
|
|
39
|
+
yield this.createRecordWithUser(upload, { recordType: 'uploads' });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async determineCount() {
|
|
43
|
+
try {
|
|
44
|
+
return await this.proxy.getPlaylistItemCount(this.uploadsPlaylistId);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { YouTubeBaseExtractor } from './connectors/YouTubeBaseExtractor.js';
|
|
2
|
+
export { YouTubeLikesExtractor } from './connectors/YouTubeLikesExtractor.js';
|
|
3
|
+
export { YouTubeSubscriptionsExtractor } from './connectors/YouTubeSubscriptionsExtractor.js';
|
|
4
|
+
export { YouTubePlaylistsExtractor } from './connectors/YouTubePlaylistsExtractor.js';
|
|
5
|
+
export { YouTubeUploadsExtractor } from './connectors/YouTubeUploadsExtractor.js';
|
|
6
|
+
export { YouTubeTakeoutExtractor } from './connectors/YouTubeTakeoutExtractor.js';
|
|
7
|
+
export { default as YouTubeTransformer } from './connectors/YouTubeTransformer.js';
|
|
8
|
+
export { default as YouTubeTakeoutTransformer } from './connectors/YouTubeTakeoutTransformer.js';
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { YouTubeBaseExtractor } from './connectors/YouTubeBaseExtractor.js';
|
|
2
|
+
export { YouTubeLikesExtractor } from './connectors/YouTubeLikesExtractor.js';
|
|
3
|
+
export { YouTubeSubscriptionsExtractor } from './connectors/YouTubeSubscriptionsExtractor.js';
|
|
4
|
+
export { YouTubePlaylistsExtractor } from './connectors/YouTubePlaylistsExtractor.js';
|
|
5
|
+
export { YouTubeUploadsExtractor } from './connectors/YouTubeUploadsExtractor.js';
|
|
6
|
+
export { YouTubeTakeoutExtractor } from './connectors/YouTubeTakeoutExtractor.js';
|
|
7
|
+
export { default as YouTubeTransformer } from './connectors/YouTubeTransformer.js';
|
|
8
|
+
export { default as YouTubeTakeoutTransformer } from './connectors/YouTubeTakeoutTransformer.js';
|
|
9
|
+
// Register OAuth provider
|
|
10
|
+
import { OAuthProviderRegistry } from '@chronicle.app/auth';
|
|
11
|
+
import { GoogleOAuthProvider } from './auth/GoogleOAuthProvider.js';
|
|
12
|
+
OAuthProviderRegistry.register(GoogleOAuthProvider);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** A Takeout CSV as header-keyed rows. Fields regularly carry embedded
|
|
2
|
+
* newlines (descriptions, comment JSON), so this is never line-splittable. */
|
|
3
|
+
export declare function readTakeoutCsv(path: string): Promise<Record<string, string>[]>;
|
|
4
|
+
//# sourceMappingURL=csv.d.ts.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { parse } from 'csv-parse/sync';
|
|
3
|
+
/** A Takeout CSV as header-keyed rows. Fields regularly carry embedded
|
|
4
|
+
* newlines (descriptions, comment JSON), so this is never line-splittable. */
|
|
5
|
+
export async function readTakeoutCsv(path) {
|
|
6
|
+
const content = await readFile(path, 'utf-8');
|
|
7
|
+
return parse(content, {
|
|
8
|
+
columns: true,
|
|
9
|
+
bom: true,
|
|
10
|
+
skip_empty_lines: true,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for Google Takeout "My Activity" HTML files (watch-history.html,
|
|
3
|
+
* search-history.html). The file is machine-generated Material Design Lite
|
|
4
|
+
* markup: one `outer-cell` div per activity entry, whose first body cell
|
|
5
|
+
* carries a verb ("Watched", "Searched for", …) followed by a non-breaking
|
|
6
|
+
* space, the linked title/query, an optional channel link, and a rendered
|
|
7
|
+
* local-time date line; a caption cell carries provenance, including the
|
|
8
|
+
* "From Google Ads" marker on ad impressions.
|
|
9
|
+
*
|
|
10
|
+
* Timestamps are rendered in the account's timezone with a single fixed
|
|
11
|
+
* abbreviation for the whole file (e.g. every stamp `EDT`), so parsing maps
|
|
12
|
+
* the abbreviation to its UTC offset and reconstructs the instant.
|
|
13
|
+
*/
|
|
14
|
+
export interface ActivityEntry {
|
|
15
|
+
verb: string;
|
|
16
|
+
/** First link's text — the video title or the search query. */
|
|
17
|
+
titleText?: string;
|
|
18
|
+
/** First link's href — the watch URL or the results URL. */
|
|
19
|
+
titleHref?: string;
|
|
20
|
+
channelName?: string;
|
|
21
|
+
channelId?: string;
|
|
22
|
+
time: Date;
|
|
23
|
+
/** "From Google Ads" in the caption cell — an ad impression, not activity. */
|
|
24
|
+
isAd: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare function parseActivityDate(text: string): Date;
|
|
27
|
+
export declare function parseActivityHtml(html: string): ActivityEntry[];
|
|
28
|
+
/** The 11-char video id from a watch URL (www or music host). */
|
|
29
|
+
export declare function videoIdFromUrl(url: string): string | undefined;
|
|
30
|
+
//# sourceMappingURL=parseActivityHtml.d.ts.map
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for Google Takeout "My Activity" HTML files (watch-history.html,
|
|
3
|
+
* search-history.html). The file is machine-generated Material Design Lite
|
|
4
|
+
* markup: one `outer-cell` div per activity entry, whose first body cell
|
|
5
|
+
* carries a verb ("Watched", "Searched for", …) followed by a non-breaking
|
|
6
|
+
* space, the linked title/query, an optional channel link, and a rendered
|
|
7
|
+
* local-time date line; a caption cell carries provenance, including the
|
|
8
|
+
* "From Google Ads" marker on ad impressions.
|
|
9
|
+
*
|
|
10
|
+
* Timestamps are rendered in the account's timezone with a single fixed
|
|
11
|
+
* abbreviation for the whole file (e.g. every stamp `EDT`), so parsing maps
|
|
12
|
+
* the abbreviation to its UTC offset and reconstructs the instant.
|
|
13
|
+
*/
|
|
14
|
+
import { decodeEntities } from '@chronicle.app/etl';
|
|
15
|
+
// Google's renderer separates tokens with U+00A0 (and in newer exports
|
|
16
|
+
// U+202F before AM/PM) rather than plain spaces.
|
|
17
|
+
const SPACERS = /[\u00A0\u202F]/g;
|
|
18
|
+
const BODY_CELL = /<div class="content-cell mdl-cell mdl-cell--6-col mdl-typography--body-1">([\s\S]*?)<\/div>/;
|
|
19
|
+
const CAPTION_CELL = /mdl-typography--caption">([\s\S]*?)<\/div>/;
|
|
20
|
+
const LINK = /<a href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/g;
|
|
21
|
+
const DATE_LINE = /([A-Z][a-z]{2} \d{1,2}, \d{4}, \d{1,2}:\d{2}:\d{2}[\u00A0\u202F ]?(?:AM|PM)[\u00A0\u202F ][A-Z]{2,5})(?:<br|$)/g;
|
|
22
|
+
const CHANNEL_ID_IN_URL = /\/channel\/([^/?#"]+)/;
|
|
23
|
+
const MONTHS = {
|
|
24
|
+
Jan: 0,
|
|
25
|
+
Feb: 1,
|
|
26
|
+
Mar: 2,
|
|
27
|
+
Apr: 3,
|
|
28
|
+
May: 4,
|
|
29
|
+
Jun: 5,
|
|
30
|
+
Jul: 6,
|
|
31
|
+
Aug: 7,
|
|
32
|
+
Sep: 8,
|
|
33
|
+
Oct: 9,
|
|
34
|
+
Nov: 10,
|
|
35
|
+
Dec: 11,
|
|
36
|
+
};
|
|
37
|
+
// North-American + UTC abbreviations Google renders My Activity in. An
|
|
38
|
+
// unmapped abbreviation throws rather than silently misplacing a history.
|
|
39
|
+
const TZ_OFFSET_MINUTES = {
|
|
40
|
+
UTC: 0,
|
|
41
|
+
GMT: 0,
|
|
42
|
+
NST: -210,
|
|
43
|
+
NDT: -150,
|
|
44
|
+
AST: -240,
|
|
45
|
+
ADT: -180,
|
|
46
|
+
EST: -300,
|
|
47
|
+
EDT: -240,
|
|
48
|
+
CST: -360,
|
|
49
|
+
CDT: -300,
|
|
50
|
+
MST: -420,
|
|
51
|
+
MDT: -360,
|
|
52
|
+
PST: -480,
|
|
53
|
+
PDT: -420,
|
|
54
|
+
AKST: -540,
|
|
55
|
+
AKDT: -480,
|
|
56
|
+
HST: -600,
|
|
57
|
+
};
|
|
58
|
+
/** Decoded link/verb text, with layout non-breaking spaces normalized. */
|
|
59
|
+
function cleanText(text) {
|
|
60
|
+
return decodeEntities(text).replaceAll(SPACERS, ' ');
|
|
61
|
+
}
|
|
62
|
+
export function parseActivityDate(text) {
|
|
63
|
+
const normalized = text.replaceAll(SPACERS, ' ');
|
|
64
|
+
const match = normalized.match(/^([A-Z][a-z]{2}) (\d{1,2}), (\d{4}), (\d{1,2}):(\d{2}):(\d{2}) (AM|PM) ([A-Z]{2,5})$/);
|
|
65
|
+
if (!match) {
|
|
66
|
+
throw new Error(`Unparseable My Activity timestamp: "${text}"`);
|
|
67
|
+
}
|
|
68
|
+
const [, mon, day, year, hour, minute, second, meridiem, tz] = match;
|
|
69
|
+
const offset = TZ_OFFSET_MINUTES[tz];
|
|
70
|
+
if (offset === undefined) {
|
|
71
|
+
throw new Error(`Unknown timezone abbreviation "${tz}" in "${text}"`);
|
|
72
|
+
}
|
|
73
|
+
let h = Number(hour) % 12;
|
|
74
|
+
if (meridiem === 'PM')
|
|
75
|
+
h += 12;
|
|
76
|
+
const utc = Date.UTC(Number(year), MONTHS[mon], Number(day), h, Number(minute), Number(second));
|
|
77
|
+
return new Date(utc - offset * 60_000);
|
|
78
|
+
}
|
|
79
|
+
export function parseActivityHtml(html) {
|
|
80
|
+
const entries = [];
|
|
81
|
+
for (const cell of html.split('<div class="outer-cell').slice(1)) {
|
|
82
|
+
const body = cell.match(BODY_CELL)?.[1];
|
|
83
|
+
if (!body)
|
|
84
|
+
continue;
|
|
85
|
+
const verb = cleanText(body.match(/^([^<]*)/)?.[1] ?? '').trim();
|
|
86
|
+
// The date line is the body's last text segment; ad entries carry earlier
|
|
87
|
+
// time-like segments ("Watched at 8:33 AM") that lack a date and don't
|
|
88
|
+
// match the full pattern — take the last full match.
|
|
89
|
+
let dateText;
|
|
90
|
+
for (const match of body.matchAll(DATE_LINE))
|
|
91
|
+
dateText = match[1];
|
|
92
|
+
if (!dateText)
|
|
93
|
+
continue;
|
|
94
|
+
const links = [...body.matchAll(LINK)];
|
|
95
|
+
const [firstHref, firstText] = links[0] ? [links[0][1], links[0][2]] : [];
|
|
96
|
+
const channelLink = links.slice(1).find(link => CHANNEL_ID_IN_URL.test(link[1]));
|
|
97
|
+
entries.push({
|
|
98
|
+
verb,
|
|
99
|
+
...(firstHref !== undefined && {
|
|
100
|
+
titleHref: decodeEntities(firstHref),
|
|
101
|
+
titleText: cleanText(firstText ?? ''),
|
|
102
|
+
}),
|
|
103
|
+
...(channelLink && {
|
|
104
|
+
channelName: cleanText(channelLink[2]),
|
|
105
|
+
channelId: channelLink[1].match(CHANNEL_ID_IN_URL)?.[1],
|
|
106
|
+
}),
|
|
107
|
+
time: parseActivityDate(dateText),
|
|
108
|
+
isAd: (cell.match(CAPTION_CELL)?.[1] ?? '').includes('From Google Ads'),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return entries;
|
|
112
|
+
}
|
|
113
|
+
/** The 11-char video id from a watch URL (www or music host). */
|
|
114
|
+
export function videoIdFromUrl(url) {
|
|
115
|
+
return url.match(/[?&]v=([\w-]{11})/)?.[1];
|
|
116
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { ApiProxy } from '@chronicle.app/etl';
|
|
2
|
+
export interface YouTubeThumbnails {
|
|
3
|
+
default?: {
|
|
4
|
+
url: string;
|
|
5
|
+
width?: number;
|
|
6
|
+
height?: number;
|
|
7
|
+
};
|
|
8
|
+
medium?: {
|
|
9
|
+
url: string;
|
|
10
|
+
width?: number;
|
|
11
|
+
height?: number;
|
|
12
|
+
};
|
|
13
|
+
high?: {
|
|
14
|
+
url: string;
|
|
15
|
+
width?: number;
|
|
16
|
+
height?: number;
|
|
17
|
+
};
|
|
18
|
+
standard?: {
|
|
19
|
+
url: string;
|
|
20
|
+
width?: number;
|
|
21
|
+
height?: number;
|
|
22
|
+
};
|
|
23
|
+
maxres?: {
|
|
24
|
+
url: string;
|
|
25
|
+
width?: number;
|
|
26
|
+
height?: number;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export interface YouTubeSelfChannel {
|
|
30
|
+
id: string;
|
|
31
|
+
snippet: {
|
|
32
|
+
title: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
customUrl?: string;
|
|
35
|
+
thumbnails?: YouTubeThumbnails;
|
|
36
|
+
};
|
|
37
|
+
contentDetails?: {
|
|
38
|
+
relatedPlaylists?: {
|
|
39
|
+
likes?: string;
|
|
40
|
+
uploads?: string;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export interface YouTubePlaylistItem {
|
|
45
|
+
id: string;
|
|
46
|
+
snippet: {
|
|
47
|
+
publishedAt: string;
|
|
48
|
+
title: string;
|
|
49
|
+
description?: string;
|
|
50
|
+
thumbnails?: YouTubeThumbnails;
|
|
51
|
+
channelId: string;
|
|
52
|
+
channelTitle: string;
|
|
53
|
+
resourceId: {
|
|
54
|
+
kind: string;
|
|
55
|
+
videoId: string;
|
|
56
|
+
};
|
|
57
|
+
videoOwnerChannelId?: string;
|
|
58
|
+
videoOwnerChannelTitle?: string;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export interface YouTubeVideo {
|
|
62
|
+
id: string;
|
|
63
|
+
snippet: {
|
|
64
|
+
publishedAt: string;
|
|
65
|
+
channelId: string;
|
|
66
|
+
channelTitle: string;
|
|
67
|
+
title: string;
|
|
68
|
+
description?: string;
|
|
69
|
+
thumbnails?: YouTubeThumbnails;
|
|
70
|
+
tags?: string[];
|
|
71
|
+
};
|
|
72
|
+
contentDetails?: {
|
|
73
|
+
duration?: string;
|
|
74
|
+
definition?: string;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export interface YouTubePlaylist {
|
|
78
|
+
id: string;
|
|
79
|
+
snippet: {
|
|
80
|
+
publishedAt: string;
|
|
81
|
+
title: string;
|
|
82
|
+
description?: string;
|
|
83
|
+
thumbnails?: YouTubeThumbnails;
|
|
84
|
+
channelId: string;
|
|
85
|
+
};
|
|
86
|
+
status?: {
|
|
87
|
+
privacyStatus?: string;
|
|
88
|
+
};
|
|
89
|
+
contentDetails?: {
|
|
90
|
+
itemCount?: number;
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export interface YouTubeSubscription {
|
|
94
|
+
id: string;
|
|
95
|
+
snippet: {
|
|
96
|
+
publishedAt: string;
|
|
97
|
+
title: string;
|
|
98
|
+
description?: string;
|
|
99
|
+
thumbnails?: YouTubeThumbnails;
|
|
100
|
+
resourceId: {
|
|
101
|
+
kind: string;
|
|
102
|
+
channelId: string;
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
export default class YouTubeProxy extends ApiProxy {
|
|
107
|
+
constructor(accessToken?: string);
|
|
108
|
+
initialize(): Promise<void>;
|
|
109
|
+
private ensureAuthenticated;
|
|
110
|
+
protected mapError(error: unknown): unknown;
|
|
111
|
+
private get;
|
|
112
|
+
/** Self channel (drives the @me agent) plus the likes playlist id. */
|
|
113
|
+
getUser(): Promise<YouTubeSelfChannel>;
|
|
114
|
+
/**
|
|
115
|
+
* Liked videos, newest first, from the account's likes playlist.
|
|
116
|
+
* snippet.publishedAt on a playlist item is when the video was liked.
|
|
117
|
+
*/
|
|
118
|
+
getLikes(options: {
|
|
119
|
+
likesPlaylistId: string;
|
|
120
|
+
limit?: number;
|
|
121
|
+
}): Promise<YouTubePlaylistItem[]>;
|
|
122
|
+
/** Items of any playlist. snippet.publishedAt is when the item was added. */
|
|
123
|
+
getPlaylistItems(playlistId: string, options?: {
|
|
124
|
+
limit?: number;
|
|
125
|
+
}): Promise<YouTubePlaylistItem[]>;
|
|
126
|
+
/** The account's own playlists (created playlists, not saved ones). */
|
|
127
|
+
getPlaylists(options?: {
|
|
128
|
+
limit?: number;
|
|
129
|
+
}): Promise<YouTubePlaylist[]>;
|
|
130
|
+
/** Hydrate video details (duration, tags, canonical channel) in batches. */
|
|
131
|
+
getVideoDetails(ids: string[]): Promise<YouTubeVideo[]>;
|
|
132
|
+
getSubscriptions(options: {
|
|
133
|
+
limit?: number;
|
|
134
|
+
}): Promise<YouTubeSubscription[]>;
|
|
135
|
+
getPlaylistItemCount(playlistId: string): Promise<number>;
|
|
136
|
+
getSubscriptionsCount(): Promise<number>;
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=YouTubeProxy.d.ts.map
|