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
package/src/cli.js
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { Command, Option } from 'commander';
|
|
5
|
+
|
|
6
|
+
import { createReporter } from './output.js';
|
|
7
|
+
import { EXIT_CODES, YtStatsError, ERROR_CODES, SETUP_GUIDE, fail } from './errors.js';
|
|
8
|
+
import { diagnose, DIAGNOSTICS, EXIT } from './diagnostics.js';
|
|
9
|
+
import { resolveDateRange } from './dates.js';
|
|
10
|
+
import { getAuthenticatedClient, login, logout } from './auth/session.js';
|
|
11
|
+
import { resolveCredentials, saveCredentials, validateClientId } from './auth/credentials.js';
|
|
12
|
+
import { listAccounts, setDefaultAccount, migrateLegacyTokens } from './auth/tokens.js';
|
|
13
|
+
import { configDir, writeJson, removeFile } from './config/store.js';
|
|
14
|
+
import { diagnoseGoogleError } from './errors.js';
|
|
15
|
+
import { createApis } from './api/client.js';
|
|
16
|
+
import * as data from './api/data.js';
|
|
17
|
+
import * as analytics from './api/analytics.js';
|
|
18
|
+
import * as reporting from './api/reporting.js';
|
|
19
|
+
import { fetchAll } from './fetch-all.js';
|
|
20
|
+
|
|
21
|
+
const pkg = JSON.parse(
|
|
22
|
+
fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build the CLI. Everything stateful is injected so the whole surface can be
|
|
27
|
+
* driven from tests without spawning a process.
|
|
28
|
+
*/
|
|
29
|
+
export function buildProgram(deps = {}) {
|
|
30
|
+
const {
|
|
31
|
+
stdout = s => process.stdout.write(s + '\n'),
|
|
32
|
+
stderr = s => process.stderr.write(s + '\n'),
|
|
33
|
+
exit = code => { process.exitCode = code; },
|
|
34
|
+
session = { getAuthenticatedClient, login, logout },
|
|
35
|
+
now = () => new Date(),
|
|
36
|
+
} = deps;
|
|
37
|
+
|
|
38
|
+
const program = new Command();
|
|
39
|
+
let reporter = createReporter({ stdout, stderr });
|
|
40
|
+
|
|
41
|
+
// Commander's default behaviour is to print usage to stderr and call
|
|
42
|
+
// process.exit — which leaves stdout EMPTY. An agent parsing stdout would get
|
|
43
|
+
// nothing at all, so every usage error is converted into the same envelope.
|
|
44
|
+
program.exitOverride();
|
|
45
|
+
program.configureOutput({
|
|
46
|
+
writeErr: () => {}, // suppressed; we re-emit as a diagnostic
|
|
47
|
+
writeOut: s => stdout(s.replace(/\n$/, '')),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
program
|
|
51
|
+
.name('ytstats')
|
|
52
|
+
.description(
|
|
53
|
+
'Pull your YouTube channel stats and analytics as JSON.\n\n' +
|
|
54
|
+
'Bring your own Google Cloud OAuth credentials — there is no shared client id,\n' +
|
|
55
|
+
'no server, and nothing leaves your machine. Run `ytstats login` to get started.',
|
|
56
|
+
)
|
|
57
|
+
.version(pkg.version)
|
|
58
|
+
.option('-a, --account <channel>', 'channel id or @handle when several are logged in')
|
|
59
|
+
.option('--compact', 'single-line JSON instead of pretty-printed', false)
|
|
60
|
+
.option('-q, --quiet', 'suppress progress output on stderr', false)
|
|
61
|
+
.showHelpAfterError('(run `ytstats --help` for usage)');
|
|
62
|
+
|
|
63
|
+
// Rebuild the reporter once global flags are known.
|
|
64
|
+
program.hook('preAction', thisCommand => {
|
|
65
|
+
const o = thisCommand.opts();
|
|
66
|
+
reporter = createReporter({ stdout, stderr, quiet: o.quiet, compact: o.compact });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Wrap a command body: diagnostics in, one envelope + exit code out.
|
|
71
|
+
*
|
|
72
|
+
* `validate` runs BEFORE authentication and collects every input problem at
|
|
73
|
+
* once. Checking auth first would hide a malformed date behind a login error,
|
|
74
|
+
* costing an agent an extra loop iteration to discover the second problem.
|
|
75
|
+
*/
|
|
76
|
+
const run = (name, body, { validate } = {}) => async (...args) => {
|
|
77
|
+
const opts = program.opts();
|
|
78
|
+
try {
|
|
79
|
+
if (validate) {
|
|
80
|
+
const problems = validate(...args, opts);
|
|
81
|
+
if (problems?.length) return exit(reporter.fail(name, problems));
|
|
82
|
+
}
|
|
83
|
+
const result = await body(...args, opts);
|
|
84
|
+
exit(reporter.succeed(name, result));
|
|
85
|
+
} catch (err) {
|
|
86
|
+
exit(reporter.fail(name, err));
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Authenticate and hand back the API bundle for a command body. */
|
|
91
|
+
function withApis(globalOpts) {
|
|
92
|
+
const { client, account } = session.getAuthenticatedClient({ account: globalOpts.account });
|
|
93
|
+
return { apis: createApis(client), account };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const rangeFrom = (cmdOpts) =>
|
|
97
|
+
resolveDateRange({ days: cmdOpts.days, start: cmdOpts.start, end: cmdOpts.end, now: now() });
|
|
98
|
+
|
|
99
|
+
/** Collect date/range problems without throwing, so all are reported together. */
|
|
100
|
+
function validateRange(cmdOpts) {
|
|
101
|
+
const problems = [];
|
|
102
|
+
for (const flag of ['start', 'end']) {
|
|
103
|
+
const value = cmdOpts[flag];
|
|
104
|
+
if (value === undefined) continue;
|
|
105
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
106
|
+
problems.push(diagnose(DIAGNOSTICS.INPUT_INVALID_DATE, {
|
|
107
|
+
flag: `--${flag}`, value, expected: 'YYYY-MM-DD (e.g. 2026-01-01)',
|
|
108
|
+
}));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const parsed = new Date(`${value}T00:00:00Z`);
|
|
112
|
+
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) {
|
|
113
|
+
problems.push(diagnose(DIAGNOSTICS.INPUT_INVALID_DATE, {
|
|
114
|
+
flag: `--${flag}`, value, expected: 'an existing calendar date in YYYY-MM-DD form',
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (cmdOpts.days !== undefined) {
|
|
120
|
+
const n = Number(cmdOpts.days);
|
|
121
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
122
|
+
problems.push(diagnose(DIAGNOSTICS.INPUT_INVALID_RANGE, {
|
|
123
|
+
flag: '--days', value: cmdOpts.days, expected: 'a positive integer, e.g. 90',
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!problems.length && cmdOpts.start && cmdOpts.end && cmdOpts.start > cmdOpts.end) {
|
|
129
|
+
problems.push(diagnose(DIAGNOSTICS.INPUT_INVALID_RANGE, {
|
|
130
|
+
flag: '--start', value: cmdOpts.start,
|
|
131
|
+
expected: `a date on or before --end (${cmdOpts.end})`,
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return problems;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const dateOptions = cmd =>
|
|
139
|
+
cmd
|
|
140
|
+
.option('-d, --days <number>', 'days of history to cover', '90')
|
|
141
|
+
.option('--start <date>', 'start date YYYY-MM-DD (overrides --days)')
|
|
142
|
+
.option('--end <date>', 'end date YYYY-MM-DD (default: today)');
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------- auth
|
|
145
|
+
|
|
146
|
+
program
|
|
147
|
+
.command('login')
|
|
148
|
+
.description('sign in to YouTube with your own Google Cloud OAuth client')
|
|
149
|
+
.option('-c, --client-secret <path>', 'path to the client_secret JSON downloaded from Google Cloud')
|
|
150
|
+
.option('--no-browser', 'print the URL and paste the redirect back (headless/SSH)')
|
|
151
|
+
.option('--timeout <seconds>', 'how long to wait for the browser callback', '300')
|
|
152
|
+
.action(run('login', async (cmdOpts, globalOpts) => {
|
|
153
|
+
const credentials = resolveCredentials({ clientSecretPath: cmdOpts.clientSecret });
|
|
154
|
+
reporter.progress(`Using OAuth client from: ${credentials.source}`);
|
|
155
|
+
|
|
156
|
+
// Surface a questionable client ID in the envelope, not just on stderr —
|
|
157
|
+
// it is the leading cause of a browser "Access blocked" and a timed-out login.
|
|
158
|
+
const idWarning = validateClientId(credentials.clientId);
|
|
159
|
+
if (idWarning) reporter.warn(idWarning);
|
|
160
|
+
|
|
161
|
+
const identity = await session.login({
|
|
162
|
+
credentials,
|
|
163
|
+
noBrowser: !cmdOpts.browser,
|
|
164
|
+
timeoutMs: Number(cmdOpts.timeout) * 1000,
|
|
165
|
+
deps: { log: msg => reporter.progress(msg) },
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
reporter.progress(`Signed in as ${identity.channelTitle ?? identity.channelId}.`);
|
|
169
|
+
reporter.progress(`Credentials stored in ${configDir()} (readable only by you).`);
|
|
170
|
+
return {
|
|
171
|
+
channelId: identity.channelId,
|
|
172
|
+
channelTitle: identity.channelTitle,
|
|
173
|
+
customUrl: identity.customUrl,
|
|
174
|
+
configDir: configDir(),
|
|
175
|
+
};
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
program
|
|
179
|
+
.command('logout')
|
|
180
|
+
.description('revoke tokens with Google and forget them locally')
|
|
181
|
+
.option('--all', 'log out of every channel on this machine', false)
|
|
182
|
+
.option('--forget-credentials', 'also delete the stored OAuth client id/secret', false)
|
|
183
|
+
.action(run('logout', async (cmdOpts, globalOpts) => {
|
|
184
|
+
const result = await session.logout({
|
|
185
|
+
account: globalOpts.account,
|
|
186
|
+
all: cmdOpts.all,
|
|
187
|
+
forgetCredentials: cmdOpts.forgetCredentials,
|
|
188
|
+
});
|
|
189
|
+
reporter.progress(result.loggedOut ? 'Logged out.' : 'Nothing to log out of.');
|
|
190
|
+
return result;
|
|
191
|
+
}));
|
|
192
|
+
|
|
193
|
+
program
|
|
194
|
+
.command('status')
|
|
195
|
+
.description('show which channels are signed in and where credentials live')
|
|
196
|
+
.action(run('status', async () => {
|
|
197
|
+
const accounts = listAccounts();
|
|
198
|
+
let credentialSource = null;
|
|
199
|
+
try {
|
|
200
|
+
credentialSource = resolveCredentials().source;
|
|
201
|
+
} catch {
|
|
202
|
+
// Not configured yet; reported as null below.
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
authenticated: accounts.length > 0,
|
|
206
|
+
configDir: configDir(),
|
|
207
|
+
credentialSource,
|
|
208
|
+
accounts,
|
|
209
|
+
setupGuide: accounts.length === 0 ? SETUP_GUIDE : undefined,
|
|
210
|
+
};
|
|
211
|
+
}));
|
|
212
|
+
|
|
213
|
+
program
|
|
214
|
+
.command('doctor')
|
|
215
|
+
.description('check every prerequisite and report exactly what is missing')
|
|
216
|
+
.action(run('doctor', async (cmdOpts, globalOpts) => {
|
|
217
|
+
// Ordered from cheapest to most expensive; each check reports pass/fail
|
|
218
|
+
// independently so the caller sees the whole picture in one round trip.
|
|
219
|
+
const checks = [];
|
|
220
|
+
const record = (id, label, ok, detail, diagnostic) => {
|
|
221
|
+
checks.push({ id, label, ok, detail, diagnostic: diagnostic ?? null });
|
|
222
|
+
return ok;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// 1. Config directory writable
|
|
226
|
+
let dir = null;
|
|
227
|
+
try {
|
|
228
|
+
dir = configDir();
|
|
229
|
+
const probe = `.doctor-${process.pid}.json`;
|
|
230
|
+
writeJson(probe, { ok: true });
|
|
231
|
+
removeFile(probe);
|
|
232
|
+
record('config_writable', 'Config directory is writable', true, dir);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
record('config_writable', 'Config directory is writable', false, dir,
|
|
235
|
+
diagnose(DIAGNOSTICS.CONFIG_UNWRITABLE, { detail: err.message }));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 2. OAuth client credentials present
|
|
239
|
+
let credentials = null;
|
|
240
|
+
try {
|
|
241
|
+
credentials = resolveCredentials();
|
|
242
|
+
record('credentials', 'OAuth client credentials found', true, `source: ${credentials.source}`);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
record('credentials', 'OAuth client credentials found', false, null,
|
|
245
|
+
err.diagnostic ?? diagnose(DIAGNOSTICS.AUTH_NO_CREDENTIALS));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 3. At least one signed-in account
|
|
249
|
+
const accounts = listAccounts();
|
|
250
|
+
if (accounts.length) {
|
|
251
|
+
record('signed_in', 'Signed in to at least one channel', true,
|
|
252
|
+
accounts.map(a => `${a.channelTitle ?? a.channelId} (${a.channelId})`).join(', '));
|
|
253
|
+
} else {
|
|
254
|
+
record('signed_in', 'Signed in to at least one channel', false, null,
|
|
255
|
+
diagnose(DIAGNOSTICS.AUTH_NO_TOKENS));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 4. Live API reachability — only meaningful if the above passed
|
|
259
|
+
if (credentials && accounts.length) {
|
|
260
|
+
try {
|
|
261
|
+
const { apis } = withApis(globalOpts);
|
|
262
|
+
const channel = await data.fetchChannel(apis);
|
|
263
|
+
if (channel) {
|
|
264
|
+
record('api_reachable', 'YouTube API reachable and token valid', true,
|
|
265
|
+
`${channel.title} — ${channel.subscriberCount} subscribers`);
|
|
266
|
+
} else {
|
|
267
|
+
record('api_reachable', 'YouTube API reachable and token valid', false, null,
|
|
268
|
+
diagnose(DIAGNOSTICS.AUTH_NO_CHANNEL));
|
|
269
|
+
}
|
|
270
|
+
} catch (err) {
|
|
271
|
+
record('api_reachable', 'YouTube API reachable and token valid', false, null,
|
|
272
|
+
err.diagnostic ?? diagnoseGoogleError(err));
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
record('api_reachable', 'YouTube API reachable and token valid', false,
|
|
276
|
+
'skipped — earlier checks failed', null);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const failed = checks.filter(c => !c.ok && c.diagnostic);
|
|
280
|
+
for (const c of failed) reporter.warn(c.diagnostic);
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
healthy: checks.every(c => c.ok),
|
|
284
|
+
configDir: dir,
|
|
285
|
+
checks: checks.map(({ diagnostic, ...rest }) => ({
|
|
286
|
+
...rest,
|
|
287
|
+
diagnosticCode: diagnostic?.code ?? null,
|
|
288
|
+
})),
|
|
289
|
+
blocking: failed.map(c => c.diagnostic),
|
|
290
|
+
};
|
|
291
|
+
}));
|
|
292
|
+
|
|
293
|
+
program
|
|
294
|
+
.command('import-legacy <tokensFile>')
|
|
295
|
+
.description('import tokens from a pre-ytstats project-local tokens.json')
|
|
296
|
+
.option('-c, --client-secret <path>', 'client_secret JSON matching those tokens')
|
|
297
|
+
.action(run('import-legacy', async (tokensFile, cmdOpts) => {
|
|
298
|
+
const credentials = resolveCredentials({ clientSecretPath: cmdOpts.clientSecret });
|
|
299
|
+
|
|
300
|
+
// The legacy file holds no channel identity, so exchange the tokens for it
|
|
301
|
+
// before storing anything.
|
|
302
|
+
const legacy = JSON.parse(fs.readFileSync(tokensFile, 'utf-8'));
|
|
303
|
+
const client = new (await import('googleapis')).google.auth.OAuth2(
|
|
304
|
+
credentials.clientId, credentials.clientSecret, 'http://127.0.0.1',
|
|
305
|
+
);
|
|
306
|
+
client.setCredentials(legacy);
|
|
307
|
+
|
|
308
|
+
const { google } = await import('googleapis');
|
|
309
|
+
const res = await google.youtube({ version: 'v3', auth: client })
|
|
310
|
+
.channels.list({ part: 'snippet', mine: true });
|
|
311
|
+
const channel = res.data.items?.[0];
|
|
312
|
+
if (!channel) {
|
|
313
|
+
throw new YtStatsError('Those tokens do not resolve to a YouTube channel.', {
|
|
314
|
+
code: ERROR_CODES.NO_YOUTUBE_CHANNEL,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
saveCredentials(credentials);
|
|
319
|
+
const result = migrateLegacyTokens(tokensFile, {
|
|
320
|
+
channelId: channel.id,
|
|
321
|
+
channelTitle: channel.snippet?.title,
|
|
322
|
+
customUrl: channel.snippet?.customUrl,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
reporter.progress(result.migrated
|
|
326
|
+
? `Imported tokens for ${channel.snippet?.title ?? channel.id}.`
|
|
327
|
+
: `Nothing imported (${result.reason}).`);
|
|
328
|
+
return { ...result, channelId: channel.id, channelTitle: channel.snippet?.title ?? null };
|
|
329
|
+
}));
|
|
330
|
+
|
|
331
|
+
program
|
|
332
|
+
.command('use <channel>')
|
|
333
|
+
.description('set the default channel for subsequent commands')
|
|
334
|
+
.action(run('use', async channel => {
|
|
335
|
+
const account = setDefaultAccount(channel);
|
|
336
|
+
return { channelId: account.channelId, channelTitle: account.channelTitle };
|
|
337
|
+
}));
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------- data
|
|
340
|
+
|
|
341
|
+
program
|
|
342
|
+
.command('channel')
|
|
343
|
+
.description('channel metadata and lifetime stats')
|
|
344
|
+
.action(run('channel', async (cmdOpts, globalOpts) => {
|
|
345
|
+
const { apis } = withApis(globalOpts);
|
|
346
|
+
return data.fetchChannel(apis);
|
|
347
|
+
}));
|
|
348
|
+
|
|
349
|
+
program
|
|
350
|
+
.command('videos')
|
|
351
|
+
.description('all videos with metadata and current view/like/comment counts')
|
|
352
|
+
.option('-n, --limit <number>', 'maximum videos to return')
|
|
353
|
+
.addOption(new Option('-s, --sort <field>', 'sort field')
|
|
354
|
+
.choices(['publishedAt', 'viewCount', 'likeCount', 'commentCount', 'durationSeconds'])
|
|
355
|
+
.default('publishedAt'))
|
|
356
|
+
.addOption(new Option('--order <dir>', 'sort direction').choices(['asc', 'desc']).default('desc'))
|
|
357
|
+
.addOption(new Option('-t, --type <type>', 'filter by content type')
|
|
358
|
+
.choices(['SHORTS', 'VIDEO_ON_DEMAND', 'LIVE_STREAM']))
|
|
359
|
+
.action(run('videos', async (cmdOpts, globalOpts) => {
|
|
360
|
+
const { apis } = withApis(globalOpts);
|
|
361
|
+
reporter.progress('Listing videos...');
|
|
362
|
+
const channel = await data.fetchChannel(apis);
|
|
363
|
+
if (!channel) throw new YtStatsError('No channel found.', { code: ERROR_CODES.NO_YOUTUBE_CHANNEL });
|
|
364
|
+
|
|
365
|
+
const ids = await data.fetchAllVideoIds(apis, channel.uploadsPlaylistId);
|
|
366
|
+
let videos = await data.fetchVideos(apis, ids);
|
|
367
|
+
|
|
368
|
+
if (cmdOpts.type) videos = videos.filter(v => v.contentType === cmdOpts.type);
|
|
369
|
+
const dir = cmdOpts.order === 'asc' ? 1 : -1;
|
|
370
|
+
videos.sort((a, b) => {
|
|
371
|
+
const x = a[cmdOpts.sort], y = b[cmdOpts.sort];
|
|
372
|
+
if (x === y) return 0;
|
|
373
|
+
return (x > y ? 1 : -1) * dir;
|
|
374
|
+
});
|
|
375
|
+
if (cmdOpts.limit) videos = videos.slice(0, Number(cmdOpts.limit));
|
|
376
|
+
return videos;
|
|
377
|
+
}));
|
|
378
|
+
|
|
379
|
+
// ------------------------------------------------------------ analytics
|
|
380
|
+
|
|
381
|
+
const simple = (name, description, fn) => {
|
|
382
|
+
const cmd = program.command(name).description(description);
|
|
383
|
+
dateOptions(cmd).action(run(
|
|
384
|
+
name,
|
|
385
|
+
async (cmdOpts, globalOpts) => {
|
|
386
|
+
const { apis } = withApis(globalOpts);
|
|
387
|
+
const range = rangeFrom(cmdOpts);
|
|
388
|
+
reporter.progress(`Querying ${range.startDate} to ${range.endDate}...`);
|
|
389
|
+
const rows = await fn(apis, range, cmdOpts);
|
|
390
|
+
// Empty is ambiguous — say explicitly that the query worked and found nothing.
|
|
391
|
+
if (Array.isArray(rows) && rows.length === 0) {
|
|
392
|
+
reporter.warn(diagnose(DIAGNOSTICS.DATA_EMPTY, {
|
|
393
|
+
step: name, detail: `No rows for ${range.startDate}..${range.endDate}`,
|
|
394
|
+
}));
|
|
395
|
+
}
|
|
396
|
+
return { period: range, rows };
|
|
397
|
+
},
|
|
398
|
+
{ validate: cmdOpts => validateRange(cmdOpts) },
|
|
399
|
+
));
|
|
400
|
+
return cmd;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
simple('daily', 'day-by-day views, watch time, likes, comments, subscribers',
|
|
404
|
+
(apis, range) => analytics.fetchDailyAnalytics(apis, range));
|
|
405
|
+
|
|
406
|
+
simple('traffic', 'where views come from, by traffic source type',
|
|
407
|
+
(apis, range) => analytics.fetchTrafficSources(apis, range));
|
|
408
|
+
|
|
409
|
+
simple('demographics', 'viewer age and gender split',
|
|
410
|
+
(apis, range) => analytics.fetchDemographics(apis, range));
|
|
411
|
+
|
|
412
|
+
simple('devices', 'views by device type',
|
|
413
|
+
(apis, range) => analytics.fetchDeviceTypes(apis, range));
|
|
414
|
+
|
|
415
|
+
simple('content-types', 'Shorts vs long-form vs live performance',
|
|
416
|
+
(apis, range) => analytics.fetchContentTypes(apis, range));
|
|
417
|
+
|
|
418
|
+
simple('playback-locations', 'where viewers watch (Shorts feed, watch page, embedded)',
|
|
419
|
+
(apis, range) => analytics.fetchPlaybackLocations(apis, range));
|
|
420
|
+
|
|
421
|
+
simple('video-analytics', 'per-video metrics for the period (top 200 by views)',
|
|
422
|
+
(apis, range) => analytics.fetchVideoAnalytics(apis, range));
|
|
423
|
+
|
|
424
|
+
simple('search-terms', 'what people search on YouTube to find your channel',
|
|
425
|
+
(apis, range) => analytics.fetchSearchTerms(apis, range))
|
|
426
|
+
.option('-n, --limit <number>', 'maximum terms (max 25)', '25');
|
|
427
|
+
|
|
428
|
+
simple('geography', 'viewer breakdown by country',
|
|
429
|
+
(apis, range, opts) => analytics.fetchGeography(apis, { ...range, maxResults: Number(opts.limit) }))
|
|
430
|
+
.option('-n, --limit <number>', 'maximum countries', '50');
|
|
431
|
+
|
|
432
|
+
dateOptions(
|
|
433
|
+
program
|
|
434
|
+
.command('retention <videoId>')
|
|
435
|
+
.description('audience retention curve for one video (ratios >1.0 mean rewatching)'),
|
|
436
|
+
).action(run('retention', async (videoId, cmdOpts, globalOpts) => {
|
|
437
|
+
const { apis } = withApis(globalOpts);
|
|
438
|
+
const range = rangeFrom(cmdOpts);
|
|
439
|
+
const curve = await analytics.fetchAudienceRetention(apis, { ...range, videoId });
|
|
440
|
+
return { videoId, period: range, curve };
|
|
441
|
+
}));
|
|
442
|
+
|
|
443
|
+
dateOptions(
|
|
444
|
+
program
|
|
445
|
+
.command('query')
|
|
446
|
+
.description('arbitrary YouTube Analytics API query')
|
|
447
|
+
.requiredOption('-m, --metrics <list>', 'comma-separated metrics, e.g. views,likes')
|
|
448
|
+
.option('--dimensions <list>', 'comma-separated dimensions, e.g. day')
|
|
449
|
+
.option('--filters <filters>', 'dimension filters, e.g. video==VIDEO_ID')
|
|
450
|
+
.option('--sort <field>', 'sort field, prefix with - for descending')
|
|
451
|
+
.option('-n, --max <number>', 'maximum rows'),
|
|
452
|
+
).action(run('query', async (cmdOpts, globalOpts) => {
|
|
453
|
+
const { apis } = withApis(globalOpts);
|
|
454
|
+
const range = rangeFrom(cmdOpts);
|
|
455
|
+
return analytics.runCustomReport(apis, {
|
|
456
|
+
...range,
|
|
457
|
+
metrics: cmdOpts.metrics,
|
|
458
|
+
dimensions: cmdOpts.dimensions,
|
|
459
|
+
filters: cmdOpts.filters,
|
|
460
|
+
sort: cmdOpts.sort,
|
|
461
|
+
maxResults: cmdOpts.max ? Number(cmdOpts.max) : undefined,
|
|
462
|
+
});
|
|
463
|
+
}));
|
|
464
|
+
|
|
465
|
+
// ------------------------------------------------------------- reach
|
|
466
|
+
|
|
467
|
+
program
|
|
468
|
+
.command('reach')
|
|
469
|
+
.description('thumbnail impressions and CTR (Reporting API; 1-2 days behind)')
|
|
470
|
+
.action(run('reach', async (cmdOpts, globalOpts) => {
|
|
471
|
+
const { apis } = withApis(globalOpts);
|
|
472
|
+
const result = await reporting.fetchReach(apis, { onProgress: m => reporter.progress(m) });
|
|
473
|
+
if (result.pending) reporter.warn(result.message);
|
|
474
|
+
return result;
|
|
475
|
+
}));
|
|
476
|
+
|
|
477
|
+
program
|
|
478
|
+
.command('reach-jobs')
|
|
479
|
+
.description('list YouTube Reporting API jobs on this channel')
|
|
480
|
+
.action(run('reach-jobs', async (cmdOpts, globalOpts) => {
|
|
481
|
+
const { apis } = withApis(globalOpts);
|
|
482
|
+
return reporting.listReachJobs(apis);
|
|
483
|
+
}));
|
|
484
|
+
|
|
485
|
+
// ------------------------------------------------------------- fetch
|
|
486
|
+
|
|
487
|
+
dateOptions(
|
|
488
|
+
program
|
|
489
|
+
.command('fetch')
|
|
490
|
+
.description('every dimension in a single JSON document (the one to pipe into a script)')
|
|
491
|
+
.option('--no-retention', 'skip retention curves (they cost one API call per video)')
|
|
492
|
+
.option('--retention-limit <number>', 'how many recent videos to pull retention for', '50')
|
|
493
|
+
.option('--reach', 'also include thumbnail impressions/CTR from the Reporting API', false),
|
|
494
|
+
).action(run('fetch', async (cmdOpts, globalOpts) => {
|
|
495
|
+
const { apis } = withApis(globalOpts);
|
|
496
|
+
const range = rangeFrom(cmdOpts);
|
|
497
|
+
reporter.progress(`Fetching ${range.startDate} to ${range.endDate}...`);
|
|
498
|
+
|
|
499
|
+
const result = await fetchAll(apis, {
|
|
500
|
+
range,
|
|
501
|
+
retention: cmdOpts.retention,
|
|
502
|
+
retentionLimit: Number(cmdOpts.retentionLimit),
|
|
503
|
+
reach: cmdOpts.reach,
|
|
504
|
+
onProgress: m => reporter.progress(m),
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
for (const w of result.warnings) reporter.warn(`${w.step}: ${w.message}`);
|
|
508
|
+
for (const n of result.notes) reporter.progress(n);
|
|
509
|
+
return result;
|
|
510
|
+
}));
|
|
511
|
+
|
|
512
|
+
program.addHelpText('after', `
|
|
513
|
+
Examples:
|
|
514
|
+
ytstats login --client-secret ~/Downloads/client_secret_123.json
|
|
515
|
+
ytstats fetch --days 90 > snapshot.json
|
|
516
|
+
ytstats videos --type SHORTS --sort viewCount | jq '.data[0:5]'
|
|
517
|
+
ytstats query -m views,likes --dimensions day --start 2026-01-01
|
|
518
|
+
|
|
519
|
+
Output contract:
|
|
520
|
+
stdout exactly one JSON document: {ok, command, fetchedAt, data} or {ok:false, error}
|
|
521
|
+
stderr progress and warnings, safe to discard
|
|
522
|
+
exit 0 ok, ${EXIT_CODES.AUTH} auth, ${EXIT_CODES.INPUT} bad input, ${EXIT_CODES.API} API error
|
|
523
|
+
`);
|
|
524
|
+
|
|
525
|
+
return program;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Translate a Commander failure into a diagnostic.
|
|
530
|
+
*
|
|
531
|
+
* Commander reports usage problems by writing prose to stderr and exiting, which
|
|
532
|
+
* leaves an agent with an empty stdout and nothing to act on. Every one of these
|
|
533
|
+
* becomes a normal envelope instead.
|
|
534
|
+
*/
|
|
535
|
+
export function diagnoseCommanderError(err, program) {
|
|
536
|
+
const message = err?.message ?? '';
|
|
537
|
+
const commands = program?.commands?.map(c => c.name()) ?? [];
|
|
538
|
+
|
|
539
|
+
switch (err?.code) {
|
|
540
|
+
case 'commander.unknownCommand': {
|
|
541
|
+
const value = message.match(/unknown command '([^']+)'/)?.[1];
|
|
542
|
+
return diagnose(DIAGNOSTICS.INPUT_UNKNOWN_COMMAND, { value, allowed: commands });
|
|
543
|
+
}
|
|
544
|
+
case 'commander.unknownOption': {
|
|
545
|
+
const flag = message.match(/unknown option '([^']+)'/)?.[1];
|
|
546
|
+
return diagnose(DIAGNOSTICS.INPUT_UNKNOWN_OPTION, { flag, detail: message });
|
|
547
|
+
}
|
|
548
|
+
case 'commander.missingMandatoryOptionValue':
|
|
549
|
+
case 'commander.missingArgument': {
|
|
550
|
+
const flag = message.match(/'([^']+)'/)?.[1];
|
|
551
|
+
return diagnose(DIAGNOSTICS.INPUT_MISSING_REQUIRED, { flag, detail: message });
|
|
552
|
+
}
|
|
553
|
+
case 'commander.invalidArgument': {
|
|
554
|
+
// e.g. option '-t, --type <type>' argument 'BOGUS' is invalid. Allowed choices are A, B.
|
|
555
|
+
const flag = message.match(/option '([^']+)'/)?.[1];
|
|
556
|
+
const value = message.match(/argument '([^']+)'/)?.[1];
|
|
557
|
+
const allowed = message.match(/Allowed choices are ([^.]+)\./)?.[1]?.split(', ');
|
|
558
|
+
return diagnose(DIAGNOSTICS.INPUT_INVALID_CHOICE, { flag, value, allowed, detail: message });
|
|
559
|
+
}
|
|
560
|
+
case 'commander.excessArguments':
|
|
561
|
+
return diagnose(DIAGNOSTICS.INPUT_INVALID_VALUE, {
|
|
562
|
+
detail: message, expected: 'fewer positional arguments',
|
|
563
|
+
});
|
|
564
|
+
default:
|
|
565
|
+
return diagnose(DIAGNOSTICS.INPUT_INVALID_VALUE, { detail: message });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Entry point used by bin/ytstats.js. */
|
|
570
|
+
export async function main(argv = process.argv, deps = {}) {
|
|
571
|
+
const stdout = deps.stdout ?? (s => process.stdout.write(s + '\n'));
|
|
572
|
+
const stderr = deps.stderr ?? (s => process.stderr.write(s + '\n'));
|
|
573
|
+
const setExit = deps.exit ?? (code => { process.exitCode = code; });
|
|
574
|
+
|
|
575
|
+
const program = buildProgram({ stdout, stderr, exit: setExit, ...deps });
|
|
576
|
+
|
|
577
|
+
try {
|
|
578
|
+
await program.parseAsync(argv);
|
|
579
|
+
} catch (err) {
|
|
580
|
+
// --help and --version are successful terminations, not failures.
|
|
581
|
+
if (err?.code === 'commander.helpDisplayed' || err?.code === 'commander.help') return setExit(EXIT.OK);
|
|
582
|
+
if (err?.code === 'commander.version') return setExit(EXIT.OK);
|
|
583
|
+
|
|
584
|
+
const compact = argv.includes('--compact');
|
|
585
|
+
const quiet = argv.includes('--quiet') || argv.includes('-q');
|
|
586
|
+
const reporter = createReporter({ stdout, stderr, compact, quiet });
|
|
587
|
+
|
|
588
|
+
const diagnostic = String(err?.code ?? '').startsWith('commander.')
|
|
589
|
+
? diagnoseCommanderError(err, program)
|
|
590
|
+
: err;
|
|
591
|
+
|
|
592
|
+
setExit(reporter.fail(commandNameFrom(argv), diagnostic));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Best-effort command name for the envelope when parsing never got that far. */
|
|
597
|
+
function commandNameFrom(argv) {
|
|
598
|
+
const rest = argv.slice(2).filter(a => !a.startsWith('-'));
|
|
599
|
+
return rest[0] ?? 'ytstats';
|
|
600
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const APP_NAME = 'ytstats';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the per-user config directory for the current OS.
|
|
8
|
+
*
|
|
9
|
+
* Pure function — platform/env/home are injected so the behaviour for every OS
|
|
10
|
+
* can be asserted from any OS.
|
|
11
|
+
*
|
|
12
|
+
* Precedence:
|
|
13
|
+
* 1. YTSTATS_CONFIG_DIR (absolute or relative, always resolved to absolute)
|
|
14
|
+
* 2. Platform convention:
|
|
15
|
+
* - macOS ~/Library/Application Support/ytstats
|
|
16
|
+
* - Windows %APPDATA%\ytstats (roaming, already per-user)
|
|
17
|
+
* - Linux $XDG_CONFIG_HOME/ytstats or ~/.config/ytstats
|
|
18
|
+
*/
|
|
19
|
+
export function resolveConfigDir({ platform, env, home }) {
|
|
20
|
+
if (env.YTSTATS_CONFIG_DIR) return path.resolve(env.YTSTATS_CONFIG_DIR);
|
|
21
|
+
|
|
22
|
+
if (platform === 'darwin') {
|
|
23
|
+
return path.join(home, 'Library', 'Application Support', APP_NAME);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (platform === 'win32') {
|
|
27
|
+
const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
28
|
+
return path.join(appData, APP_NAME);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The XDG spec says a relative XDG_CONFIG_HOME must be ignored.
|
|
32
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
33
|
+
const base = xdg && path.isAbsolute(xdg) ? xdg : path.join(home, '.config');
|
|
34
|
+
return path.join(base, APP_NAME);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The config directory for the running process. */
|
|
38
|
+
export function configDir() {
|
|
39
|
+
return resolveConfigDir({
|
|
40
|
+
platform: process.platform,
|
|
41
|
+
env: process.env,
|
|
42
|
+
home: os.homedir(),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const CREDENTIALS_FILE = 'credentials.json';
|
|
47
|
+
export const TOKENS_FILE = 'tokens.json';
|