ytstats 0.1.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/CHANGELOG.md +40 -0
- package/LICENSE +28 -0
- package/README.md +256 -0
- package/bin/ytstats.js +9 -0
- package/docs/architecture.md +128 -0
- package/docs/auth.md +186 -0
- package/docs/cli.md +285 -0
- package/docs/configuration.md +127 -0
- package/docs/contributing.md +155 -0
- package/docs/gotchas/auth.md +121 -0
- package/docs/gotchas/cli-output.md +155 -0
- package/docs/gotchas/config-storage.md +98 -0
- package/docs/gotchas/youtube-api.md +132 -0
- package/docs/gotchas.md +10 -0
- package/docs/output-contract.md +214 -0
- package/docs/testing.md +93 -0
- package/docs/youtube-apis.md +180 -0
- package/package.json +66 -0
- package/src/api/analytics.js +259 -0
- package/src/api/client.js +40 -0
- package/src/api/data.js +65 -0
- package/src/api/reporting.js +100 -0
- package/src/api/transforms.js +170 -0
- package/src/auth/credentials.js +172 -0
- package/src/auth/oauth.js +167 -0
- package/src/auth/session.js +257 -0
- package/src/auth/tokens.js +140 -0
- package/src/cli.js +600 -0
- package/src/config/paths.js +47 -0
- package/src/config/store.js +123 -0
- package/src/dates.js +73 -0
- package/src/diagnostics.js +857 -0
- package/src/errors.js +233 -0
- package/src/fetch-all.js +181 -0
- package/src/index.js +44 -0
- package/src/output.js +168 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { call } from './client.js';
|
|
2
|
+
import { rowsFromAnalytics } from './transforms.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* YouTube Analytics API v2 (reports.query, ids=channel==MINE).
|
|
6
|
+
*
|
|
7
|
+
* Two API limits are encoded here rather than left to callers, because exceeding
|
|
8
|
+
* either produces an opaque failure:
|
|
9
|
+
* - dimensions=video rejects maxResults above 200
|
|
10
|
+
* - insightTrafficSourceDetail requires maxResults and errors above ~25, and
|
|
11
|
+
* only tolerates the `views` metric
|
|
12
|
+
*/
|
|
13
|
+
const MAX_VIDEO_ROWS = 200;
|
|
14
|
+
const MAX_DETAIL_ROWS = 25;
|
|
15
|
+
|
|
16
|
+
async function query(apis, { startDate, endDate, metrics, dimensions, filters, sort, maxResults }) {
|
|
17
|
+
const params = { ids: 'channel==MINE', startDate, endDate, metrics };
|
|
18
|
+
if (dimensions) params.dimensions = dimensions;
|
|
19
|
+
if (filters) params.filters = filters;
|
|
20
|
+
if (sort) params.sort = sort;
|
|
21
|
+
if (maxResults) params.maxResults = maxResults;
|
|
22
|
+
|
|
23
|
+
const res = await call(() => apis.analytics.reports.query(params));
|
|
24
|
+
return res.data;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const num = v => (v === undefined || v === null ? null : v);
|
|
28
|
+
|
|
29
|
+
export async function fetchDailyAnalytics(apis, { startDate, endDate }) {
|
|
30
|
+
const data = await query(apis, {
|
|
31
|
+
startDate,
|
|
32
|
+
endDate,
|
|
33
|
+
// Thumbnail impressions/CTR are documented for this API but always fail;
|
|
34
|
+
// reach data comes from the Reporting API instead.
|
|
35
|
+
metrics: 'views,estimatedMinutesWatched,averageViewDuration,likes,dislikes,comments,shares,subscribersGained,subscribersLost',
|
|
36
|
+
dimensions: 'day',
|
|
37
|
+
sort: 'day',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return rowsFromAnalytics(data).map(r => ({
|
|
41
|
+
date: r.day,
|
|
42
|
+
views: num(r.views),
|
|
43
|
+
estimatedMinutesWatched: num(r.estimatedMinutesWatched),
|
|
44
|
+
averageViewDuration: num(r.averageViewDuration),
|
|
45
|
+
likes: num(r.likes),
|
|
46
|
+
dislikes: num(r.dislikes),
|
|
47
|
+
comments: num(r.comments),
|
|
48
|
+
shares: num(r.shares),
|
|
49
|
+
subscribersGained: num(r.subscribersGained),
|
|
50
|
+
subscribersLost: num(r.subscribersLost),
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Card/annotation metrics. Degrades to [] — some channels never have this data. */
|
|
55
|
+
export async function fetchCardMetrics(apis, { startDate, endDate }) {
|
|
56
|
+
try {
|
|
57
|
+
const data = await query(apis, {
|
|
58
|
+
startDate,
|
|
59
|
+
endDate,
|
|
60
|
+
metrics: 'views,annotationClickThroughRate,cardClicks,cardImpressions',
|
|
61
|
+
dimensions: 'day',
|
|
62
|
+
sort: 'day',
|
|
63
|
+
});
|
|
64
|
+
return rowsFromAnalytics(data).map(r => ({
|
|
65
|
+
date: r.day,
|
|
66
|
+
annotationClickThroughRate: num(r.annotationClickThroughRate),
|
|
67
|
+
cardClicks: num(r.cardClicks),
|
|
68
|
+
cardImpressions: num(r.cardImpressions),
|
|
69
|
+
}));
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function fetchVideoAnalytics(apis, { startDate, endDate, maxResults = MAX_VIDEO_ROWS }) {
|
|
76
|
+
const data = await query(apis, {
|
|
77
|
+
startDate,
|
|
78
|
+
endDate,
|
|
79
|
+
metrics: 'views,estimatedMinutesWatched,averageViewDuration,averageViewPercentage,likes,comments,shares,subscribersGained,subscribersLost',
|
|
80
|
+
dimensions: 'video',
|
|
81
|
+
sort: '-views',
|
|
82
|
+
maxResults: Math.min(maxResults, MAX_VIDEO_ROWS),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return rowsFromAnalytics(data).map(r => ({
|
|
86
|
+
videoId: r.video,
|
|
87
|
+
views: num(r.views),
|
|
88
|
+
estimatedMinutesWatched: num(r.estimatedMinutesWatched),
|
|
89
|
+
averageViewDuration: num(r.averageViewDuration),
|
|
90
|
+
averageViewPercentage: num(r.averageViewPercentage),
|
|
91
|
+
likes: num(r.likes),
|
|
92
|
+
comments: num(r.comments),
|
|
93
|
+
shares: num(r.shares),
|
|
94
|
+
subscribersGained: num(r.subscribersGained),
|
|
95
|
+
subscribersLost: num(r.subscribersLost),
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function fetchTrafficSources(apis, { startDate, endDate }) {
|
|
100
|
+
const data = await query(apis, {
|
|
101
|
+
startDate,
|
|
102
|
+
endDate,
|
|
103
|
+
metrics: 'views,estimatedMinutesWatched',
|
|
104
|
+
dimensions: 'insightTrafficSourceType',
|
|
105
|
+
sort: '-views',
|
|
106
|
+
});
|
|
107
|
+
return rowsFromAnalytics(data).map(r => ({
|
|
108
|
+
sourceType: r.insightTrafficSourceType,
|
|
109
|
+
views: num(r.views),
|
|
110
|
+
estimatedMinutesWatched: num(r.estimatedMinutesWatched),
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function fetchDemographics(apis, { startDate, endDate }) {
|
|
115
|
+
const data = await query(apis, {
|
|
116
|
+
startDate,
|
|
117
|
+
endDate,
|
|
118
|
+
metrics: 'viewerPercentage',
|
|
119
|
+
dimensions: 'ageGroup,gender',
|
|
120
|
+
});
|
|
121
|
+
return rowsFromAnalytics(data).map(r => ({
|
|
122
|
+
ageGroup: r.ageGroup,
|
|
123
|
+
gender: r.gender,
|
|
124
|
+
viewerPercentage: num(r.viewerPercentage),
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function fetchDeviceTypes(apis, { startDate, endDate }) {
|
|
129
|
+
const data = await query(apis, {
|
|
130
|
+
startDate,
|
|
131
|
+
endDate,
|
|
132
|
+
metrics: 'views,estimatedMinutesWatched',
|
|
133
|
+
dimensions: 'deviceType',
|
|
134
|
+
sort: '-views',
|
|
135
|
+
});
|
|
136
|
+
return rowsFromAnalytics(data).map(r => ({
|
|
137
|
+
deviceType: r.deviceType,
|
|
138
|
+
views: num(r.views),
|
|
139
|
+
estimatedMinutesWatched: num(r.estimatedMinutesWatched),
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Shorts vs long-form vs live, using YouTube's own creatorContentType.
|
|
145
|
+
* Values are lowercase here (`shorts`, `videoOnDemand`) whereas the duration-based
|
|
146
|
+
* `contentType` on a video resource is uppercase (`SHORTS`). They can disagree.
|
|
147
|
+
*/
|
|
148
|
+
export async function fetchContentTypes(apis, { startDate, endDate }) {
|
|
149
|
+
const data = await query(apis, {
|
|
150
|
+
startDate,
|
|
151
|
+
endDate,
|
|
152
|
+
metrics: 'views,estimatedMinutesWatched,likes,shares,subscribersGained,subscribersLost',
|
|
153
|
+
dimensions: 'creatorContentType',
|
|
154
|
+
sort: '-views',
|
|
155
|
+
});
|
|
156
|
+
return rowsFromAnalytics(data).map(r => ({
|
|
157
|
+
contentType: r.creatorContentType,
|
|
158
|
+
views: num(r.views),
|
|
159
|
+
estimatedMinutesWatched: num(r.estimatedMinutesWatched),
|
|
160
|
+
likes: num(r.likes),
|
|
161
|
+
shares: num(r.shares),
|
|
162
|
+
subscribersGained: num(r.subscribersGained),
|
|
163
|
+
subscribersLost: num(r.subscribersLost),
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function fetchSearchTerms(apis, { startDate, endDate, maxResults = MAX_DETAIL_ROWS }) {
|
|
168
|
+
const data = await query(apis, {
|
|
169
|
+
startDate,
|
|
170
|
+
endDate,
|
|
171
|
+
metrics: 'views',
|
|
172
|
+
dimensions: 'insightTrafficSourceDetail',
|
|
173
|
+
filters: 'insightTrafficSourceType==YT_SEARCH',
|
|
174
|
+
sort: '-views',
|
|
175
|
+
maxResults: Math.min(maxResults, MAX_DETAIL_ROWS),
|
|
176
|
+
});
|
|
177
|
+
return rowsFromAnalytics(data).map(r => ({
|
|
178
|
+
searchTerm: r.insightTrafficSourceDetail,
|
|
179
|
+
views: num(r.views),
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function fetchGeography(apis, { startDate, endDate, maxResults = 50 }) {
|
|
184
|
+
const data = await query(apis, {
|
|
185
|
+
startDate,
|
|
186
|
+
endDate,
|
|
187
|
+
metrics: 'views,estimatedMinutesWatched,subscribersGained,subscribersLost',
|
|
188
|
+
dimensions: 'country',
|
|
189
|
+
sort: '-views',
|
|
190
|
+
maxResults,
|
|
191
|
+
});
|
|
192
|
+
return rowsFromAnalytics(data).map(r => ({
|
|
193
|
+
country: r.country,
|
|
194
|
+
views: num(r.views),
|
|
195
|
+
estimatedMinutesWatched: num(r.estimatedMinutesWatched),
|
|
196
|
+
subscribersGained: num(r.subscribersGained),
|
|
197
|
+
subscribersLost: num(r.subscribersLost),
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function fetchPlaybackLocations(apis, { startDate, endDate }) {
|
|
202
|
+
const data = await query(apis, {
|
|
203
|
+
startDate,
|
|
204
|
+
endDate,
|
|
205
|
+
metrics: 'views,estimatedMinutesWatched',
|
|
206
|
+
dimensions: 'insightPlaybackLocationType',
|
|
207
|
+
sort: '-views',
|
|
208
|
+
});
|
|
209
|
+
return rowsFromAnalytics(data).map(r => ({
|
|
210
|
+
locationType: r.insightPlaybackLocationType,
|
|
211
|
+
views: num(r.views),
|
|
212
|
+
estimatedMinutesWatched: num(r.estimatedMinutesWatched),
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function fetchTrafficSourceDetails(apis, { startDate, endDate, sourceType, maxResults = MAX_DETAIL_ROWS }) {
|
|
217
|
+
const data = await query(apis, {
|
|
218
|
+
startDate,
|
|
219
|
+
endDate,
|
|
220
|
+
// estimatedMinutesWatched triggers an internal error on this dimension.
|
|
221
|
+
metrics: 'views',
|
|
222
|
+
dimensions: 'insightTrafficSourceDetail',
|
|
223
|
+
filters: `insightTrafficSourceType==${sourceType}`,
|
|
224
|
+
sort: '-views',
|
|
225
|
+
maxResults: Math.min(maxResults, MAX_DETAIL_ROWS),
|
|
226
|
+
});
|
|
227
|
+
return rowsFromAnalytics(data).map(r => ({
|
|
228
|
+
sourceType,
|
|
229
|
+
detail: r.insightTrafficSourceDetail,
|
|
230
|
+
views: num(r.views),
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Retention curve for one video, ~100 points at 1% intervals.
|
|
236
|
+
* Ratios above 1.0 are normal for Shorts (viewers loop) and are never clamped.
|
|
237
|
+
*/
|
|
238
|
+
export async function fetchAudienceRetention(apis, { videoId, startDate, endDate }) {
|
|
239
|
+
const data = await query(apis, {
|
|
240
|
+
startDate,
|
|
241
|
+
endDate,
|
|
242
|
+
metrics: 'audienceWatchRatio',
|
|
243
|
+
dimensions: 'elapsedVideoTimeRatio',
|
|
244
|
+
filters: `video==${videoId}`,
|
|
245
|
+
});
|
|
246
|
+
return rowsFromAnalytics(data).map(r => ({
|
|
247
|
+
position: r.elapsedVideoTimeRatio,
|
|
248
|
+
ratio: r.audienceWatchRatio,
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Escape hatch: arbitrary metric/dimension combination, columns included. */
|
|
253
|
+
export async function runCustomReport(apis, opts) {
|
|
254
|
+
const data = await query(apis, opts);
|
|
255
|
+
return {
|
|
256
|
+
columns: data.columnHeaders?.map(h => ({ name: h.name, type: h.dataType })) ?? [],
|
|
257
|
+
rows: rowsFromAnalytics(data),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { google } from 'googleapis';
|
|
2
|
+
import { mapGoogleError } from '../errors.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bundle the three YouTube API surfaces plus an authenticated CSV downloader.
|
|
6
|
+
*
|
|
7
|
+
* Every fetcher takes this bundle as its first argument, so tests hand in plain
|
|
8
|
+
* objects and never touch the network.
|
|
9
|
+
*/
|
|
10
|
+
export function createApis(authClient) {
|
|
11
|
+
return {
|
|
12
|
+
youtube: google.youtube({ version: 'v3', auth: authClient }),
|
|
13
|
+
analytics: google.youtubeAnalytics({ version: 'v2', auth: authClient }),
|
|
14
|
+
reporting: google.youtubereporting({ version: 'v1', auth: authClient }),
|
|
15
|
+
|
|
16
|
+
/** Reporting API report bodies are plain CSV behind an OAuth-protected URL. */
|
|
17
|
+
async downloadCsv(url) {
|
|
18
|
+
const token = await authClient.getAccessToken();
|
|
19
|
+
const res = await fetch(url, {
|
|
20
|
+
headers: {
|
|
21
|
+
Authorization: `Bearer ${token?.token ?? token}`,
|
|
22
|
+
'Accept-Encoding': 'gzip',
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
throw new Error(`Failed to download report (${res.status} ${res.statusText})`);
|
|
27
|
+
}
|
|
28
|
+
return res.text();
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Run an API call, converting Google's error shapes into typed YtStatsErrors. */
|
|
34
|
+
export async function call(fn) {
|
|
35
|
+
try {
|
|
36
|
+
return await fn();
|
|
37
|
+
} catch (err) {
|
|
38
|
+
throw mapGoogleError(err);
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/api/data.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { call } from './client.js';
|
|
2
|
+
import { normalizeChannel, normalizeVideo } from './transforms.js';
|
|
3
|
+
|
|
4
|
+
const BATCH_SIZE = 50;
|
|
5
|
+
|
|
6
|
+
/** The authenticated user's channel, or null when the account owns none. */
|
|
7
|
+
export async function fetchChannel(apis) {
|
|
8
|
+
const res = await call(() =>
|
|
9
|
+
apis.youtube.channels.list({
|
|
10
|
+
part: 'snippet,contentDetails,statistics,status,topicDetails',
|
|
11
|
+
mine: true,
|
|
12
|
+
}),
|
|
13
|
+
);
|
|
14
|
+
const channel = res.data.items?.[0];
|
|
15
|
+
return channel ? normalizeChannel(channel) : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Every video id on the channel, via the uploads playlist.
|
|
20
|
+
*
|
|
21
|
+
* playlistItems.list costs 1 quota unit per page of 50; search.list would cost
|
|
22
|
+
* 100 per call. Do not swap this for search.
|
|
23
|
+
*/
|
|
24
|
+
export async function fetchAllVideoIds(apis, uploadsPlaylistId) {
|
|
25
|
+
if (!uploadsPlaylistId) return [];
|
|
26
|
+
|
|
27
|
+
const ids = [];
|
|
28
|
+
let pageToken;
|
|
29
|
+
|
|
30
|
+
do {
|
|
31
|
+
const res = await call(() =>
|
|
32
|
+
apis.youtube.playlistItems.list({
|
|
33
|
+
part: 'contentDetails',
|
|
34
|
+
playlistId: uploadsPlaylistId,
|
|
35
|
+
maxResults: BATCH_SIZE,
|
|
36
|
+
pageToken,
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
for (const item of res.data.items ?? []) {
|
|
40
|
+
const id = item.contentDetails?.videoId;
|
|
41
|
+
if (id) ids.push(id);
|
|
42
|
+
}
|
|
43
|
+
pageToken = res.data.nextPageToken;
|
|
44
|
+
} while (pageToken);
|
|
45
|
+
|
|
46
|
+
return ids;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Full video resources for the given ids, batched 50 at a time (1 unit per batch). */
|
|
50
|
+
export async function fetchVideos(apis, videoIds) {
|
|
51
|
+
if (!videoIds?.length) return [];
|
|
52
|
+
|
|
53
|
+
const videos = [];
|
|
54
|
+
for (let i = 0; i < videoIds.length; i += BATCH_SIZE) {
|
|
55
|
+
const batch = videoIds.slice(i, i + BATCH_SIZE);
|
|
56
|
+
const res = await call(() =>
|
|
57
|
+
apis.youtube.videos.list({
|
|
58
|
+
part: 'snippet,contentDetails,statistics,status,liveStreamingDetails,topicDetails',
|
|
59
|
+
id: batch.join(','),
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
videos.push(...(res.data.items ?? []).map(normalizeVideo));
|
|
63
|
+
}
|
|
64
|
+
return videos;
|
|
65
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { call } from './client.js';
|
|
2
|
+
import { parseCsv, normalizeReportingDate } from './transforms.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* YouTube Reporting API v1.
|
|
6
|
+
*
|
|
7
|
+
* This is the only source of thumbnail impressions and CTR — the equivalent
|
|
8
|
+
* Analytics API metrics are documented but always return "query is not supported"
|
|
9
|
+
* (https://issuetracker.google.com/issues/254665034).
|
|
10
|
+
*
|
|
11
|
+
* It is asynchronous by design: create a job, YouTube generates daily CSVs, we
|
|
12
|
+
* download them. First run therefore returns nothing for 24-48h, and the data is
|
|
13
|
+
* always 1-2 days behind — the same lag YouTube Studio shows.
|
|
14
|
+
*/
|
|
15
|
+
export const REACH_REPORT_TYPE = 'channel_reach_basic_a1';
|
|
16
|
+
const REACH_JOB_NAME = 'ytstats channel reach (CTR)';
|
|
17
|
+
|
|
18
|
+
export async function listReachJobs(apis) {
|
|
19
|
+
const res = await call(() => apis.reporting.jobs.list());
|
|
20
|
+
return res.data.jobs ?? [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Find or create the reach job. Safe to call on every run. */
|
|
24
|
+
export async function ensureReachJob(apis) {
|
|
25
|
+
const jobs = await listReachJobs(apis);
|
|
26
|
+
const existing = jobs.find(j => j.reportTypeId === REACH_REPORT_TYPE);
|
|
27
|
+
if (existing) return existing;
|
|
28
|
+
|
|
29
|
+
const res = await call(() =>
|
|
30
|
+
apis.reporting.jobs.create({
|
|
31
|
+
requestBody: { reportTypeId: REACH_REPORT_TYPE, name: REACH_JOB_NAME },
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
return res.data;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function listReports(apis, jobId) {
|
|
38
|
+
const reports = [];
|
|
39
|
+
let pageToken;
|
|
40
|
+
do {
|
|
41
|
+
const params = { jobId };
|
|
42
|
+
if (pageToken) params.pageToken = pageToken;
|
|
43
|
+
const res = await call(() => apis.reporting.jobs.reports.list(params));
|
|
44
|
+
reports.push(...(res.data.reports ?? []));
|
|
45
|
+
pageToken = res.data.nextPageToken;
|
|
46
|
+
} while (pageToken);
|
|
47
|
+
return reports;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Download every available reach report and merge them.
|
|
52
|
+
*
|
|
53
|
+
* Reports overlap, so rows are deduped on (date, videoId) with the last write
|
|
54
|
+
* winning — later reports carry corrected figures for the same day.
|
|
55
|
+
*/
|
|
56
|
+
export async function fetchReach(apis, { onProgress } = {}) {
|
|
57
|
+
const job = await ensureReachJob(apis);
|
|
58
|
+
const reports = await listReports(apis, job.id);
|
|
59
|
+
|
|
60
|
+
if (reports.length === 0) {
|
|
61
|
+
return {
|
|
62
|
+
job: { id: job.id, reportTypeId: job.reportTypeId, createTime: job.createTime ?? null },
|
|
63
|
+
reportCount: 0,
|
|
64
|
+
pending: true,
|
|
65
|
+
message:
|
|
66
|
+
'Reporting job is set up. YouTube generates the first reports within 24-48 hours ' +
|
|
67
|
+
'(including a 30-day backfill). Re-run `ytstats reach` after that.',
|
|
68
|
+
rows: [],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const deduped = new Map();
|
|
73
|
+
for (const [i, report] of reports.entries()) {
|
|
74
|
+
onProgress?.(`Downloading reach report ${i + 1}/${reports.length}...`);
|
|
75
|
+
const csv = await apis.downloadCsv(report.downloadUrl);
|
|
76
|
+
for (const row of parseCsv(csv)) {
|
|
77
|
+
const date = normalizeReportingDate(row.date);
|
|
78
|
+
const videoId = row.video_id ?? null;
|
|
79
|
+
deduped.set(`${date}|${videoId}`, {
|
|
80
|
+
date,
|
|
81
|
+
channelId: row.channel_id ?? null,
|
|
82
|
+
videoId,
|
|
83
|
+
impressions: row.impressions ?? null,
|
|
84
|
+
// Decimal fraction, not a percentage: 0.0561 means 5.61%.
|
|
85
|
+
impressionsCtr: row.impressions_ctr ?? null,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const rows = Array.from(deduped.values()).sort((a, b) =>
|
|
91
|
+
a.date === b.date ? String(a.videoId).localeCompare(String(b.videoId)) : a.date < b.date ? -1 : 1,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
job: { id: job.id, reportTypeId: job.reportTypeId, createTime: job.createTime ?? null },
|
|
96
|
+
reportCount: reports.length,
|
|
97
|
+
pending: false,
|
|
98
|
+
rows,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure shaping helpers. No network, no auth — everything here is deterministic so
|
|
3
|
+
* it can be tested directly.
|
|
4
|
+
*
|
|
5
|
+
* ytstats emits idiomatic camelCase JSON. Mapping that onto any particular
|
|
6
|
+
* database schema is the consumer's job, not this package's.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const DURATION_RE = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
|
|
10
|
+
|
|
11
|
+
/** ISO 8601 duration (PT15M33S, P1DT2H) to whole seconds. 0 when absent/invalid. */
|
|
12
|
+
export function parseDuration(iso) {
|
|
13
|
+
if (!iso || typeof iso !== 'string') return 0;
|
|
14
|
+
const m = iso.match(DURATION_RE);
|
|
15
|
+
if (!m) return 0;
|
|
16
|
+
const [, d, h, min, s] = m;
|
|
17
|
+
return (
|
|
18
|
+
Number(d || 0) * 86400 +
|
|
19
|
+
Number(h || 0) * 3600 +
|
|
20
|
+
Number(min || 0) * 60 +
|
|
21
|
+
Math.floor(Number(s || 0))
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Duration-based content classification.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately identical to the historical behaviour: <=60s is a Short. YouTube's
|
|
29
|
+
* own creatorContentType uses extra signals and can disagree — a 62s video meant
|
|
30
|
+
* as a Short lands in VIDEO_ON_DEMAND here. Callers wanting YouTube's own view
|
|
31
|
+
* should read the contentTypes analytics dimension instead.
|
|
32
|
+
*/
|
|
33
|
+
export function classifyContent(video) {
|
|
34
|
+
if (video?.liveStreamingDetails) return 'LIVE_STREAM';
|
|
35
|
+
const seconds = parseDuration(video?.contentDetails?.duration);
|
|
36
|
+
if (seconds > 0 && seconds <= 60) return 'SHORTS';
|
|
37
|
+
return 'VIDEO_ON_DEMAND';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Minimal RFC 4180 CSV parser: quoted fields, embedded commas/newlines, and ""
|
|
42
|
+
* escapes. The Reporting API emits video titles and search terms, both of which
|
|
43
|
+
* routinely contain commas, so naive splitting silently corrupts rows.
|
|
44
|
+
*/
|
|
45
|
+
export function parseCsv(text) {
|
|
46
|
+
if (typeof text !== 'string' || text.trim() === '') return [];
|
|
47
|
+
|
|
48
|
+
const records = [];
|
|
49
|
+
let field = '';
|
|
50
|
+
let record = [];
|
|
51
|
+
let inQuotes = false;
|
|
52
|
+
|
|
53
|
+
for (let i = 0; i < text.length; i++) {
|
|
54
|
+
const ch = text[i];
|
|
55
|
+
|
|
56
|
+
if (inQuotes) {
|
|
57
|
+
if (ch === '"') {
|
|
58
|
+
if (text[i + 1] === '"') { field += '"'; i++; }
|
|
59
|
+
else inQuotes = false;
|
|
60
|
+
} else field += ch;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (ch === '"') { inQuotes = true; continue; }
|
|
65
|
+
if (ch === ',') { record.push(field); field = ''; continue; }
|
|
66
|
+
if (ch === '\r') continue;
|
|
67
|
+
if (ch === '\n') { record.push(field); records.push(record); record = []; field = ''; continue; }
|
|
68
|
+
field += ch;
|
|
69
|
+
}
|
|
70
|
+
if (field !== '' || record.length) { record.push(field); records.push(record); }
|
|
71
|
+
|
|
72
|
+
if (records.length < 2) return [];
|
|
73
|
+
|
|
74
|
+
const headers = records[0].map(h => h.trim());
|
|
75
|
+
return records.slice(1)
|
|
76
|
+
.filter(r => r.some(v => v !== ''))
|
|
77
|
+
.map(values => {
|
|
78
|
+
const row = {};
|
|
79
|
+
headers.forEach((h, i) => { row[h] = coerce(values[i]); });
|
|
80
|
+
return row;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Numeric-looking cells become numbers, except values with a leading zero, which
|
|
86
|
+
* are identifiers (video ids, zero-padded codes) that must stay strings.
|
|
87
|
+
*/
|
|
88
|
+
function coerce(value) {
|
|
89
|
+
if (value === undefined || value === '') return null;
|
|
90
|
+
const v = String(value).trim();
|
|
91
|
+
if (v === '') return null;
|
|
92
|
+
if (/^-?(0|[1-9]\d*)(\.\d+)?$/.test(v)) return Number(v);
|
|
93
|
+
return v;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Reporting API dates arrive as 20260328.0; everything else uses ISO. Unify. */
|
|
97
|
+
export function normalizeReportingDate(value) {
|
|
98
|
+
if (value === null || value === undefined) return value ?? null;
|
|
99
|
+
const s = String(value).trim();
|
|
100
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
|
|
101
|
+
const m = s.match(/^(\d{4})(\d{2})(\d{2})(?:\.0+)?$/);
|
|
102
|
+
return m ? `${m[1]}-${m[2]}-${m[3]}` : value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Analytics responses are columnHeaders + positional rows; zip into objects. */
|
|
106
|
+
export function rowsFromAnalytics(data) {
|
|
107
|
+
if (!data?.rows?.length || !data?.columnHeaders?.length) return [];
|
|
108
|
+
const headers = data.columnHeaders.map(h => h.name);
|
|
109
|
+
return data.rows.map(row => {
|
|
110
|
+
const obj = {};
|
|
111
|
+
headers.forEach((h, i) => { obj[h] = row[i]; });
|
|
112
|
+
return obj;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const int = v => {
|
|
117
|
+
const n = parseInt(v, 10);
|
|
118
|
+
return Number.isFinite(n) ? n : 0;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export function normalizeChannel(channel) {
|
|
122
|
+
const s = channel?.snippet ?? {};
|
|
123
|
+
const cd = channel?.contentDetails ?? {};
|
|
124
|
+
const st = channel?.statistics ?? {};
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
id: channel?.id ?? null,
|
|
128
|
+
title: s.title ?? null,
|
|
129
|
+
description: s.description ?? null,
|
|
130
|
+
customUrl: s.customUrl ?? null,
|
|
131
|
+
publishedAt: s.publishedAt ?? null,
|
|
132
|
+
country: s.country ?? null,
|
|
133
|
+
// YouTube rounds this to 3 significant figures above 1,000 subscribers.
|
|
134
|
+
subscriberCount: int(st.subscriberCount),
|
|
135
|
+
viewCount: int(st.viewCount),
|
|
136
|
+
videoCount: int(st.videoCount),
|
|
137
|
+
uploadsPlaylistId: cd.relatedPlaylists?.uploads ?? null,
|
|
138
|
+
thumbnailUrl: s.thumbnails?.high?.url ?? s.thumbnails?.default?.url ?? null,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function normalizeVideo(video) {
|
|
143
|
+
const s = video?.snippet ?? {};
|
|
144
|
+
const cd = video?.contentDetails ?? {};
|
|
145
|
+
const st = video?.statistics ?? {};
|
|
146
|
+
const status = video?.status ?? {};
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
id: video?.id ?? null,
|
|
150
|
+
channelId: s.channelId ?? null,
|
|
151
|
+
title: s.title ?? null,
|
|
152
|
+
description: s.description ?? null,
|
|
153
|
+
publishedAt: s.publishedAt ?? null,
|
|
154
|
+
thumbnailUrl:
|
|
155
|
+
s.thumbnails?.maxres?.url ?? s.thumbnails?.high?.url ?? s.thumbnails?.default?.url ?? null,
|
|
156
|
+
duration: cd.duration ?? null,
|
|
157
|
+
durationSeconds: parseDuration(cd.duration),
|
|
158
|
+
definition: cd.definition ?? null,
|
|
159
|
+
dimension: cd.dimension ?? null,
|
|
160
|
+
caption: cd.caption === 'true' || cd.caption === true,
|
|
161
|
+
tags: Array.isArray(s.tags) ? s.tags : [],
|
|
162
|
+
categoryId: s.categoryId ?? null,
|
|
163
|
+
privacyStatus: status.privacyStatus ?? null,
|
|
164
|
+
madeForKids: Boolean(status.madeForKids),
|
|
165
|
+
viewCount: int(st.viewCount),
|
|
166
|
+
likeCount: int(st.likeCount),
|
|
167
|
+
commentCount: int(st.commentCount),
|
|
168
|
+
contentType: classifyContent(video),
|
|
169
|
+
};
|
|
170
|
+
}
|