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,214 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: The JSON envelope, the diagnostic schema, the full failure-code catalog, and exit-code derivation.
|
|
3
|
+
tags: [output, envelope, diagnostics, error-codes, agents]
|
|
4
|
+
source:
|
|
5
|
+
- src/output.js
|
|
6
|
+
- src/diagnostics.js
|
|
7
|
+
- src/errors.js
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Output Contract
|
|
11
|
+
|
|
12
|
+
`ytstats` is designed for a program — often an LLM in a retry loop — rather than a human at a terminal. Everything here follows from that. For why the implementation looks the way it does, see [gotchas/cli-output.md](gotchas/cli-output.md).
|
|
13
|
+
|
|
14
|
+
## The two rules
|
|
15
|
+
|
|
16
|
+
**1. stdout is exactly one JSON document, always.** Every code path: success, failure, bad flag, unknown command, crash. There is no path that writes nothing.
|
|
17
|
+
|
|
18
|
+
**2. stderr is everything a human reads**, and is safe to discard entirely.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
ytstats fetch --days 30 2>/dev/null | jq '.data.channel.subscriberCount'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Commander's default behaviour violates rule 1 — it prints prose to stderr and calls `process.exit`, leaving stdout empty. `exitOverride()` and `configureOutput()` exist precisely to prevent that.
|
|
25
|
+
|
|
26
|
+
## The envelope
|
|
27
|
+
|
|
28
|
+
Shape-invariant: **every key is present on every response**, so a consumer never branches on whether a field exists.
|
|
29
|
+
|
|
30
|
+
```jsonc
|
|
31
|
+
{
|
|
32
|
+
"ok": true,
|
|
33
|
+
"command": "channel",
|
|
34
|
+
"fetchedAt": "2026-07-27T10:00:00.000Z",
|
|
35
|
+
"data": { }, // null whenever ok is false — never partial
|
|
36
|
+
"errors": [], // non-empty iff ok is false
|
|
37
|
+
"warnings": [], // non-fatal; never affects ok or the exit code
|
|
38
|
+
"nextSteps": [], // ordered, deduplicated, ready-to-run commands
|
|
39
|
+
"meta": {
|
|
40
|
+
"version": "0.1.0",
|
|
41
|
+
"exitCode": 0,
|
|
42
|
+
"helpCommand": "ytstats --help",
|
|
43
|
+
"docs": "https://www.npmjs.com/package/ytstats"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
| Field | Type | Contract |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `ok` | boolean | True iff `errors` is empty |
|
|
51
|
+
| `command` | string \| null | The command that ran; best-effort from argv when parsing failed early |
|
|
52
|
+
| `fetchedAt` | ISO 8601 string | When the envelope was rendered |
|
|
53
|
+
| `data` | object \| array \| null | The payload. **`null` whenever `ok` is false** — never partial |
|
|
54
|
+
| `errors` | array | Error-severity diagnostics. Non-empty exactly when `ok` is false |
|
|
55
|
+
| `warnings` | array | Warning-severity diagnostics. Never affect `ok` or the exit code |
|
|
56
|
+
| `nextSteps` | string[] | Flattened remediation, errors before warnings, deduplicated |
|
|
57
|
+
| `meta.version` | string | The `ytstats` version, read from `package.json` |
|
|
58
|
+
| `meta.exitCode` | number | The process exit code, duplicated for stdout-only consumers |
|
|
59
|
+
| `meta.helpCommand` | string | Always `ytstats --help` |
|
|
60
|
+
| `meta.docs` | string | Package documentation URL |
|
|
61
|
+
|
|
62
|
+
`data` being `null` on failure is load-bearing: a consumer that reads `data` without checking `ok` would otherwise act on half a dataset and never know.
|
|
63
|
+
|
|
64
|
+
`--compact` emits the same document on a single line.
|
|
65
|
+
|
|
66
|
+
## Diagnostics
|
|
67
|
+
|
|
68
|
+
Each entry in `errors` and `warnings` answers four questions — what happened, why, can it be fixed, and what to run next:
|
|
69
|
+
|
|
70
|
+
```jsonc
|
|
71
|
+
{
|
|
72
|
+
"code": "AUTH_TOKEN_EXPIRED", // stable API — branch on this, never on prose
|
|
73
|
+
"severity": "error", // "error" | "warning"
|
|
74
|
+
"title": "Stored refresh token is no longer valid",
|
|
75
|
+
"detail": "Google rejected the stored refresh token (invalid_grant)…",
|
|
76
|
+
"cause": "Most commonly the OAuth consent screen is still in Testing mode…",
|
|
77
|
+
"recoverable": true, // can this be fixed and retried at all?
|
|
78
|
+
"retryable": false, // would re-running the SAME command help?
|
|
79
|
+
"remediation": {
|
|
80
|
+
"summary": "Sign in again, then publish your consent screen to Production.",
|
|
81
|
+
"steps": ["Run: ytstats login", "…"],
|
|
82
|
+
"commands": [{ "run": "ytstats login", "description": "Re-authorize this machine" }],
|
|
83
|
+
"docs": ["https://console.cloud.google.com/apis/credentials/consent"]
|
|
84
|
+
},
|
|
85
|
+
"context": { "flag": "--start", "value": "01/01/2026", "expected": "YYYY-MM-DD" }
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### recoverable vs retryable
|
|
90
|
+
|
|
91
|
+
These are the anti-loop signals, and they are not the same question:
|
|
92
|
+
|
|
93
|
+
- **`recoverable`** — can this be fixed at all? `AUTH_SERVICE_ACCOUNT` is `recoverable: false`: no configuration will ever make a service account work with YouTube APIs.
|
|
94
|
+
- **`retryable`** — would re-running the *identical* command help? `AUTH_TIMEOUT` is `retryable: false` because the usual cause is a browser-side rejection that a retry cannot change. `API_RATE_LIMITED` is `retryable: true`.
|
|
95
|
+
|
|
96
|
+
An agent that respects both never loops pointlessly.
|
|
97
|
+
|
|
98
|
+
### context
|
|
99
|
+
|
|
100
|
+
Whatever the call site supplied: `flag`, `value`, `expected`, `allowed`, `step`, `account`, `detail`. `diagnose()` also folds these into the `detail` prose (`Option: --start. Received: "01/01/2026". Expected: YYYY-MM-DD.`) so a consumer reading only `detail` still learns what was wrong. Stack-like lines are stripped — diagnostics are prose, never traces.
|
|
101
|
+
|
|
102
|
+
### nextSteps
|
|
103
|
+
|
|
104
|
+
Every diagnostic's `remediation.commands` flattened into one ordered, deduplicated list of `"<command> # <description>"` strings, errors before warnings. A diagnostic with no commands contributes its `remediation.summary` instead.
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
ytstats fetch 2>/dev/null | jq -r 'if .ok then "fine" else .nextSteps[0] end'
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Diagnostic catalog
|
|
111
|
+
|
|
112
|
+
`code` values are **public API**. New ones may be added freely; existing ones are never repurposed or deleted without a major version bump.
|
|
113
|
+
|
|
114
|
+
### Authentication — exit 2
|
|
115
|
+
|
|
116
|
+
| Code | Recoverable | Retryable | Meaning |
|
|
117
|
+
|---|---|---|---|
|
|
118
|
+
| `AUTH_NO_CREDENTIALS` | yes | no | No OAuth client found in any of the four sources |
|
|
119
|
+
| `AUTH_NO_TOKENS` | yes | no | Client exists, but no channel has been authorized here |
|
|
120
|
+
| `AUTH_TOKEN_EXPIRED` | yes | no | Refresh token rejected (`invalid_grant`) — usually the 7-day Testing trap |
|
|
121
|
+
| `AUTH_TOKEN_REVOKED` | yes | no | Access explicitly revoked, by logout elsewhere or in Google account settings |
|
|
122
|
+
| `AUTH_ACCOUNT_UNKNOWN` | yes | no | `--account` matched no signed-in channel |
|
|
123
|
+
| `AUTH_CONSENT_DECLINED` | yes | **yes** | Google returned `access_denied` — consent dismissed or a scope refused |
|
|
124
|
+
| `AUTH_TIMEOUT` | yes | no | Callback never arrived; usually "Access blocked" in the browser |
|
|
125
|
+
| `AUTH_CLIENT_ID_INVALID` | yes | no | Client ID does not end in `.apps.googleusercontent.com` |
|
|
126
|
+
| `AUTH_STATE_MISMATCH` | yes | **yes** | OAuth state check failed — stale tab or another process on the port |
|
|
127
|
+
| `AUTH_SERVICE_ACCOUNT` | **no** | no | Service account key supplied. No workaround exists |
|
|
128
|
+
| `AUTH_NO_CHANNEL` | yes | no | Authorization succeeded but the account owns no YouTube channel |
|
|
129
|
+
| `AUTH_CREDENTIALS_MALFORMED` | yes | no | File is not the JSON Google produces for an OAuth client |
|
|
130
|
+
| `AUTH_CREDENTIALS_NOT_FOUND` | yes | no | `--client-secret` path could not be opened |
|
|
131
|
+
|
|
132
|
+
### Google APIs — exit 4
|
|
133
|
+
|
|
134
|
+
| Code | Recoverable | Retryable | Meaning |
|
|
135
|
+
|---|---|---|---|
|
|
136
|
+
| `API_NOT_ENABLED` | yes | yes | One of the three YouTube APIs is not enabled in the project |
|
|
137
|
+
| `API_QUOTA_EXCEEDED` | yes | yes | Daily quota spent; resets at midnight Pacific |
|
|
138
|
+
| `API_RATE_LIMITED` | yes | yes | Transient rate limit, unlike a daily quota exhaustion |
|
|
139
|
+
| `API_QUERY_NOT_SUPPORTED` | yes | no | YouTube rejected this metric/dimension combination |
|
|
140
|
+
| `API_FORBIDDEN` | yes | no | Authenticated but not permitted to read this resource |
|
|
141
|
+
| `API_NOT_FOUND` | yes | no | 404 for the requested id |
|
|
142
|
+
| `API_UNAVAILABLE` | yes | yes | Google 5xx; nothing wrong with the request |
|
|
143
|
+
| `NETWORK_UNREACHABLE` | yes | yes | Request failed before a response — DNS, TLS, proxy, or no connectivity |
|
|
144
|
+
|
|
145
|
+
### Input — exit 3
|
|
146
|
+
|
|
147
|
+
| Code | Recoverable | Retryable | Meaning |
|
|
148
|
+
|---|---|---|---|
|
|
149
|
+
| `INPUT_UNKNOWN_COMMAND` | yes | no | No such command; `context.allowed` lists the valid set |
|
|
150
|
+
| `INPUT_UNKNOWN_OPTION` | yes | no | Flag not recognised by this command |
|
|
151
|
+
| `INPUT_MISSING_REQUIRED` | yes | no | A mandatory option was not supplied |
|
|
152
|
+
| `INPUT_INVALID_CHOICE` | yes | no | Value outside the enumerated set; see `context.allowed` |
|
|
153
|
+
| `INPUT_INVALID_DATE` | yes | no | Not `YYYY-MM-DD`, or not a real calendar date |
|
|
154
|
+
| `INPUT_INVALID_RANGE` | yes | no | Inverted range, or a non-positive `--days` |
|
|
155
|
+
| `INPUT_INVALID_VALUE` | yes | no | Value could not be interpreted for this flag |
|
|
156
|
+
|
|
157
|
+
### Data and config
|
|
158
|
+
|
|
159
|
+
| Code | Severity | Exit | Meaning |
|
|
160
|
+
|---|---|---|---|
|
|
161
|
+
| `AUTH_CLIENT_ID_SUSPICIOUS` | warning | 0 | Client ID has the right suffix but an unusual shape; proceeding |
|
|
162
|
+
| `DATA_PARTIAL` | warning | 0 | Some datasets failed while others succeeded — empty means "not fetched", not "zero" |
|
|
163
|
+
| `DATA_EMPTY` | warning | 0 | Query succeeded and returned zero rows — genuinely no data |
|
|
164
|
+
| `REACH_PENDING` | warning | 0 | Reporting job created; YouTube has not generated reports yet |
|
|
165
|
+
| `CONFIG_UNWRITABLE` | error | 1 | Config directory not writable, so authentication cannot persist |
|
|
166
|
+
| `UNEXPECTED` | error | 1 | Unrecognised condition. A bug worth reporting |
|
|
167
|
+
|
|
168
|
+
## Exit codes
|
|
169
|
+
|
|
170
|
+
| Code | Class |
|
|
171
|
+
|---|---|
|
|
172
|
+
| `0` | Success |
|
|
173
|
+
| `1` | General or unexpected |
|
|
174
|
+
| `2` | Authentication |
|
|
175
|
+
| `3` | Bad input |
|
|
176
|
+
| `4` | API error |
|
|
177
|
+
|
|
178
|
+
`exitCodeFor()` returns the **worst** exit code across all error-severity diagnostics, and `EXIT.OK` when there are none — which is why warnings never change it. The result is duplicated at `meta.exitCode`.
|
|
179
|
+
|
|
180
|
+
`EXIT` in `src/diagnostics.js` also defines `PARTIAL: 5`, which no diagnostic currently uses; partial results are reported as warnings on a successful run instead.
|
|
181
|
+
|
|
182
|
+
## Two code vocabularies
|
|
183
|
+
|
|
184
|
+
Reading the source, you will meet two sets of codes:
|
|
185
|
+
|
|
186
|
+
- **`DIAGNOSTICS` codes** (`src/diagnostics.js`) — the fine-grained catalog above. This is the public contract, surfaced in the envelope.
|
|
187
|
+
- **`ERROR_CODES`** (`src/errors.js`) — a coarser internal vocabulary on `YtStatsError.code`: `MISSING_CREDENTIALS`, `INVALID_CREDENTIALS`, `NOT_AUTHENTICATED`, `AUTH_FAILED`, `AUTH_TIMEOUT`, `ACCESS_DENIED`, `API_NOT_ENABLED`, `NO_YOUTUBE_CHANNEL`, `QUOTA_EXCEEDED`, `QUERY_NOT_SUPPORTED`, `NOT_FOUND`, `INVALID_INPUT`, `API_ERROR`, `UNKNOWN`. Used for internal control flow, such as `FATAL_CODES` in `fetch-all.js`.
|
|
188
|
+
|
|
189
|
+
`legacyCodeFor()` maps the first onto the second when `mapGoogleError()` constructs an error. Consumers should read the envelope's `code`, which is always the `DIAGNOSTICS` vocabulary.
|
|
190
|
+
|
|
191
|
+
## Error classification
|
|
192
|
+
|
|
193
|
+
`diagnoseGoogleError()` turns a Google API failure into a precise diagnostic by checking, **in this order**: network-level error codes, then reason strings (`accessNotConfigured`, `NoLinkedYouTubeAccount`, `quotaExceeded`, `rateLimitExceeded`), then message fingerprints (`query is not supported`, `invalid_grant`, revocation phrasing), and only then generic HTTP statuses.
|
|
194
|
+
|
|
195
|
+
The order matters: a bare 403 can mean four unrelated things, so reordering silently degrades precise diagnostics into `API_FORBIDDEN`.
|
|
196
|
+
|
|
197
|
+
## Redaction
|
|
198
|
+
|
|
199
|
+
`redact()` strips anything resembling a secret from text bound for stdout or stderr:
|
|
200
|
+
|
|
201
|
+
- Google client secrets (`GOCSPX-…`)
|
|
202
|
+
- Access tokens (`ya29.…`), refresh tokens (`1//…`), authorization codes (`4/…`)
|
|
203
|
+
- JSON fields named `client_secret`, `clientSecret`, `refresh_token`, `access_token`, `code_verifier`, `codeVerifier`, `authorization_code`
|
|
204
|
+
- Query parameters `code`, `code_verifier`, `client_secret`
|
|
205
|
+
|
|
206
|
+
It runs on the **serialized** JSON, after `JSON.stringify`, so a secret embedded in prose is caught too. It deliberately does *not* match a bare `"code"` field, because every diagnostic carries `"code": "AUTH_NO_TOKENS"` — public API that must survive redaction.
|
|
207
|
+
|
|
208
|
+
Redaction is belt-and-braces: these values are never deliberately logged in the first place.
|
|
209
|
+
|
|
210
|
+
## Failures are differentiated
|
|
211
|
+
|
|
212
|
+
There is no single "not authenticated" bucket. A generic error would force the caller to guess between six unrelated problems, each with a different fix. The design assumption is an agent that reads `code`, checks `recoverable`/`retryable`, and runs `nextSteps[0]` — so every anticipated failure earns its own code and its own recovery path.
|
|
213
|
+
|
|
214
|
+
Input problems are additionally reported **all at once, before authentication**, so one loop iteration fixes everything rather than discovering a bad date only after fixing auth.
|
package/docs/testing.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: How ytstats is tested — injection seams, temp config dirs, real-HTTP loopback tests, subprocess end-to-end runs, and what coverage numbers actually mean.
|
|
3
|
+
tags: [testing, vitest, coverage, injection]
|
|
4
|
+
source:
|
|
5
|
+
- test/**
|
|
6
|
+
- vitest.config.js
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Testing
|
|
10
|
+
|
|
11
|
+
316 tests across 13 files. **None of them requires network access**, and none opens a browser.
|
|
12
|
+
|
|
13
|
+
## Running
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm test # vitest run
|
|
17
|
+
npm run test:watch
|
|
18
|
+
npm run coverage # vitest run --coverage
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`vitest.config.js` sets a Node environment, matches `test/**/*.test.js`, and allows a 15-second timeout — long enough for the end-to-end tests, which spawn real subprocesses.
|
|
22
|
+
|
|
23
|
+
`prepublishOnly` runs `npm test`, so a failing suite blocks publication.
|
|
24
|
+
|
|
25
|
+
## Injection is the strategy
|
|
26
|
+
|
|
27
|
+
The suite runs offline because every effect is a parameter with a real default. `buildProgram()` takes `stdout`, `stderr`, `exit`, `session`, and `now`; `login()` takes `OAuth2`, `startLoopbackServer`, `openBrowser`, `fetchIdentity`, `promptForRedirectUrl`, and `log`; `fetchAll()` takes a `fetchers` bundle; every API fetcher takes `apis` as its first argument.
|
|
28
|
+
|
|
29
|
+
Nothing is mocked at the module level. Tests hand in plain objects, which means a test breaks when a *contract* changes rather than when an implementation detail moves.
|
|
30
|
+
|
|
31
|
+
## Test files
|
|
32
|
+
|
|
33
|
+
| File | Tests | Covers |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `test/envelope.test.js` | 96 | Envelope shape, the diagnostic catalog, severity routing, `nextSteps`, redaction |
|
|
36
|
+
| `test/api/transforms.test.js` | 33 | Duration parsing, content classification, CSV, date normalization, row zipping |
|
|
37
|
+
| `test/cli.e2e.test.js` | 30 | The real binary, spawned as a subprocess |
|
|
38
|
+
| `test/api/fetchers.test.js` | 27 | Exact query parameters sent by every Data, Analytics, and Reporting fetcher |
|
|
39
|
+
| `test/auth/credentials.test.js` | 20 | Resolution precedence, file shapes, service-account rejection, discovery |
|
|
40
|
+
| `test/auth/oauth.test.js` | 18 | PKCE, auth URL construction, and the loopback server over real HTTP |
|
|
41
|
+
| `test/auth/session.test.js` | 18 | `login`, `logout`, `getAuthenticatedClient`, refresh persistence |
|
|
42
|
+
| `test/auth/tokens.test.js` | 18 | Multi-account store, merging, default promotion, legacy import |
|
|
43
|
+
| `test/config/store.test.js` | 14 | Atomic writes, permissions, traversal rejection |
|
|
44
|
+
| `test/fetch-all.test.js` | 13 | Orchestration, per-step degradation, fatal codes, retention capping |
|
|
45
|
+
| `test/dates.test.js` | 11 | Window resolution, calendar validation, bounds |
|
|
46
|
+
| `test/client-id.test.js` | 10 | Client ID pre-flight validation, both tiers |
|
|
47
|
+
| `test/config/paths.test.js` | 8 | Per-OS directory resolution, including Windows and Linux from any host |
|
|
48
|
+
|
|
49
|
+
`envelope.test.js` dominates the count because it parameterizes with `it.each` over the entire `DIAGNOSTICS` catalog — every code is asserted to be fully specified, and every recoverable one to carry remediation. Adding a diagnostic adds tests automatically.
|
|
50
|
+
|
|
51
|
+
## Isolation
|
|
52
|
+
|
|
53
|
+
`test/helpers/tmp.js` provides `useTempConfigDir()`, which creates a fresh temp directory, points `YTSTATS_CONFIG_DIR` at it, and returns a `cleanup()` that restores the previous value and removes the directory.
|
|
54
|
+
|
|
55
|
+
Every test touching the config store uses it, so there is no shared global state and no risk of a test reading the developer's real credentials.
|
|
56
|
+
|
|
57
|
+
The helper also exports `mode(path)` — an octal permission string for asserting `0600`/`0700` — and `isWindows`, used to skip POSIX-mode assertions where they are meaningless.
|
|
58
|
+
|
|
59
|
+
## What each layer proves
|
|
60
|
+
|
|
61
|
+
**Pure functions are tested directly.** `transforms.js`, `dates.js`, `config/paths.js`, and `diagnostics.js` have no effects to arrange.
|
|
62
|
+
|
|
63
|
+
**Fetchers assert the exact query parameters**, not merely the return shape. This is the mechanism that pins the undocumented API limits — `MAX_VIDEO_ROWS` at 200, `MAX_DETAIL_ROWS` at 25, the `views`-only metric list on `insightTrafficSourceDetail`, and the absence of `videoThumbnailImpressions`. A test that only checked the returned rows would let someone raise a limit and reintroduce an opaque failure.
|
|
64
|
+
|
|
65
|
+
There is also a test asserting `search.list` is never called, protecting the 100× quota difference against a plausible-looking refactor.
|
|
66
|
+
|
|
67
|
+
**The session layer injects `OAuth2`, the loopback server, the browser opener, and the identity lookup**, so `login` and `logout` are covered end to end without Google.
|
|
68
|
+
|
|
69
|
+
**The loopback server is tested over real HTTP** on `127.0.0.1` — the test makes actual `fetch` calls against a listening server. It covers state mismatch, user denial, timeout, favicon noise, and the guarantee that the success page never contains the authorization code.
|
|
70
|
+
|
|
71
|
+
**The CLI is tested by spawning the actual binary.** `test/cli.e2e.test.js` runs `node bin/ytstats.js …` with `execFile`, a temp `YTSTATS_CONFIG_DIR`, and `NO_COLOR=1`, and never throws on a non-zero exit — the exit code is part of what it asserts. This is what proves the output contract holds on paths that are hard to reach in-process: unknown commands, invalid flags, `--help`, `--version`.
|
|
72
|
+
|
|
73
|
+
## Coverage numbers
|
|
74
|
+
|
|
75
|
+
`npm run coverage` currently reports about 71% overall, and two figures need explaining:
|
|
76
|
+
|
|
77
|
+
**`src/cli.js` reports 0%.** Its 30 end-to-end tests run the file as a **subprocess**, which v8 coverage cannot instrument from the parent process. The file is well covered; the number is a measurement artifact, not a gap. Do not chase it by converting the e2e tests to in-process calls — running the real binary is the point.
|
|
78
|
+
|
|
79
|
+
**`src/api/client.js` reports about 33%.** The uncovered lines are `createApis()` and `downloadCsv()`, which construct live googleapis clients and perform real HTTP. Fetchers take the resulting bundle as a parameter, so the tests hand in plain objects and never execute the constructor.
|
|
80
|
+
|
|
81
|
+
## What is not covered
|
|
82
|
+
|
|
83
|
+
**No test performs a live call against Google.** Request shapes are asserted against the documented contract, not the live service. If YouTube changes a limit or starts rejecting a combination that works today, the suite will not notice — that class of failure surfaces as a `API_QUERY_NOT_SUPPORTED` diagnostic at runtime, which is why per-step degradation exists.
|
|
84
|
+
|
|
85
|
+
The browser is never opened, and no real OAuth consent is exercised.
|
|
86
|
+
|
|
87
|
+
## Adding tests
|
|
88
|
+
|
|
89
|
+
For a new fetcher, assert the exact parameters passed to the injected API bundle — see [contributing.md](contributing.md#adding-a-new-dataset).
|
|
90
|
+
|
|
91
|
+
For a new diagnostic, the catalog test fails unless the entry has a title, detail, cause, and at least one remediation step.
|
|
92
|
+
|
|
93
|
+
For anything touching the config store, use `useTempConfigDir()` rather than writing to the real directory.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: How ytstats calls the YouTube Data, Analytics, and Reporting APIs — exact queries, encoded limits, quota costs, and transforms.
|
|
3
|
+
tags: [youtube-api, analytics, reporting, quota, fetchers]
|
|
4
|
+
source:
|
|
5
|
+
- src/api/**
|
|
6
|
+
- src/fetch-all.js
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# YouTube APIs
|
|
10
|
+
|
|
11
|
+
`ytstats` reads from three separate Google APIs. This is the request-level reference; for the traps each one hides, see [gotchas/youtube-api.md](gotchas/youtube-api.md).
|
|
12
|
+
|
|
13
|
+
## The three surfaces
|
|
14
|
+
|
|
15
|
+
`createApis(authClient)` bundles them, plus a CSV downloader:
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
{
|
|
19
|
+
youtube: google.youtube({ version: 'v3', auth }), // Data API v3
|
|
20
|
+
analytics: google.youtubeAnalytics({ version: 'v2', auth }), // Analytics API v2
|
|
21
|
+
reporting: google.youtubereporting({ version: 'v1', auth }), // Reporting API v1
|
|
22
|
+
downloadCsv(url), // OAuth-authenticated fetch
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Every fetcher takes this bundle as its first argument. That is the injection seam: tests hand in plain objects and never touch the network. All three APIs must be enabled in the same Google Cloud project that issued the OAuth client, or requests fail with `API_NOT_ENABLED`.
|
|
27
|
+
|
|
28
|
+
`call(fn)` wraps each request and converts Google's error shapes into typed `YtStatsError`s via `mapGoogleError()`.
|
|
29
|
+
|
|
30
|
+
## Data API v3
|
|
31
|
+
|
|
32
|
+
Three fetchers in `src/api/data.js`.
|
|
33
|
+
|
|
34
|
+
### fetchChannel
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
youtube.channels.list({
|
|
38
|
+
part: 'snippet,contentDetails,statistics,status,topicDetails',
|
|
39
|
+
mine: true,
|
|
40
|
+
})
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Returns the normalized channel, or `null` when the account owns none. `uploadsPlaylistId` comes from `contentDetails.relatedPlaylists.uploads` and is what every video enumeration keys off.
|
|
44
|
+
|
|
45
|
+
### fetchAllVideoIds
|
|
46
|
+
|
|
47
|
+
Pages `playlistItems.list` over the uploads playlist at `maxResults: 50`, following `nextPageToken` until exhausted.
|
|
48
|
+
|
|
49
|
+
**This must not be swapped for `search.list`.** `playlistItems.list` costs 1 quota unit per page of 50; `search.list` costs 100 per call — a 100× difference. A test asserts `search.list` is never called.
|
|
50
|
+
|
|
51
|
+
### fetchVideos
|
|
52
|
+
|
|
53
|
+
Batches ids 50 at a time into `videos.list`:
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
youtube.videos.list({
|
|
57
|
+
part: 'snippet,contentDetails,statistics,status,liveStreamingDetails,topicDetails',
|
|
58
|
+
id: batch.join(','),
|
|
59
|
+
})
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
1 quota unit per batch. `liveStreamingDetails` is requested because `classifyContent()` uses its presence to identify live streams.
|
|
63
|
+
|
|
64
|
+
### Quota costs
|
|
65
|
+
|
|
66
|
+
| Operation | Cost |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `channels.list` | 1 |
|
|
69
|
+
| `playlistItems.list` | 1 per page of 50 |
|
|
70
|
+
| `videos.list` | 1 per batch of 50 |
|
|
71
|
+
| `search.list` | **100** — never used |
|
|
72
|
+
|
|
73
|
+
The Data API grants 10,000 units/day per project. A full fetch for a 100-video channel costs roughly 5 units. The Analytics and Reporting APIs have separate quotas.
|
|
74
|
+
|
|
75
|
+
## Analytics API v2
|
|
76
|
+
|
|
77
|
+
Every fetcher in `src/api/analytics.js` funnels through one private `query()` helper that always sets `ids: 'channel==MINE'` and includes `dimensions`, `filters`, `sort`, and `maxResults` only when supplied.
|
|
78
|
+
|
|
79
|
+
Two undocumented limits are encoded as constants rather than left to callers, because exceeding either produces an opaque failure:
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
const MAX_VIDEO_ROWS = 200; // dimensions=video rejects maxResults above this
|
|
83
|
+
const MAX_DETAIL_ROWS = 25; // insightTrafficSourceDetail errors above ~25
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Both are applied with `Math.min`, so a caller cannot exceed them.
|
|
87
|
+
|
|
88
|
+
### Fetchers
|
|
89
|
+
|
|
90
|
+
| Fetcher | Metrics | Dimensions | Sort | maxResults |
|
|
91
|
+
|---|---|---|---|---|
|
|
92
|
+
| `fetchDailyAnalytics` | `views`, `estimatedMinutesWatched`, `averageViewDuration`, `likes`, `dislikes`, `comments`, `shares`, `subscribersGained`, `subscribersLost` | `day` | `day` | — |
|
|
93
|
+
| `fetchCardMetrics` | `views`, `annotationClickThroughRate`, `cardClicks`, `cardImpressions` | `day` | `day` | — |
|
|
94
|
+
| `fetchVideoAnalytics` | `views`, `estimatedMinutesWatched`, `averageViewDuration`, `averageViewPercentage`, `likes`, `comments`, `shares`, `subscribersGained`, `subscribersLost` | `video` | `-views` | ≤ 200 |
|
|
95
|
+
| `fetchTrafficSources` | `views`, `estimatedMinutesWatched` | `insightTrafficSourceType` | `-views` | — |
|
|
96
|
+
| `fetchDemographics` | `viewerPercentage` | `ageGroup,gender` | — | — |
|
|
97
|
+
| `fetchDeviceTypes` | `views`, `estimatedMinutesWatched` | `deviceType` | `-views` | — |
|
|
98
|
+
| `fetchContentTypes` | `views`, `estimatedMinutesWatched`, `likes`, `shares`, `subscribersGained`, `subscribersLost` | `creatorContentType` | `-views` | — |
|
|
99
|
+
| `fetchSearchTerms` | `views` | `insightTrafficSourceDetail` | `-views` | ≤ 25 |
|
|
100
|
+
| `fetchGeography` | `views`, `estimatedMinutesWatched`, `subscribersGained`, `subscribersLost` | `country` | `-views` | 50 |
|
|
101
|
+
| `fetchPlaybackLocations` | `views`, `estimatedMinutesWatched` | `insightPlaybackLocationType` | `-views` | — |
|
|
102
|
+
| `fetchTrafficSourceDetails` | `views` | `insightTrafficSourceDetail` | `-views` | ≤ 25 |
|
|
103
|
+
| `fetchAudienceRetention` | `audienceWatchRatio` | `elapsedVideoTimeRatio` | — | — |
|
|
104
|
+
| `runCustomReport` | caller-supplied | caller-supplied | caller-supplied | caller-supplied |
|
|
105
|
+
|
|
106
|
+
`fetchSearchTerms` filters with `insightTrafficSourceType==YT_SEARCH`; `fetchTrafficSourceDetails` filters on whichever source type it is given. `fetchAudienceRetention` filters `video==<videoId>`.
|
|
107
|
+
|
|
108
|
+
The `views`-only metric lists on the two detail fetchers are not an oversight: adding `estimatedMinutesWatched` to `insightTrafficSourceDetail` triggers an internal server error.
|
|
109
|
+
|
|
110
|
+
Tests assert the **exact query parameters** each fetcher sends. That is what pins these limits rather than merely documenting them.
|
|
111
|
+
|
|
112
|
+
### Notable omission
|
|
113
|
+
|
|
114
|
+
`videoThumbnailImpressions` and `videoThumbnailImpressionsClickRate` appear nowhere in this file. They are documented by Google but never work; a test asserts their absence. Impressions and CTR come from the Reporting API instead.
|
|
115
|
+
|
|
116
|
+
### fetchCardMetrics degrades silently
|
|
117
|
+
|
|
118
|
+
It is the one fetcher with its own `try`/`catch` returning `[]`, because some channels never have card data and the query fails outright. Unlike every other step, a failure here produces **no warning** — treat empty card fields as unknown rather than zero.
|
|
119
|
+
|
|
120
|
+
### runCustomReport
|
|
121
|
+
|
|
122
|
+
The escape hatch behind `ytstats query`. Returns `{ columns, rows }`, where `columns` is derived from the response's `columnHeaders` as `[{ name, type }]`.
|
|
123
|
+
|
|
124
|
+
## Reporting API v1
|
|
125
|
+
|
|
126
|
+
The only source of thumbnail impressions and CTR, and asynchronous by design. `src/api/reporting.js` uses one report type:
|
|
127
|
+
|
|
128
|
+
```js
|
|
129
|
+
export const REACH_REPORT_TYPE = 'channel_reach_basic_a1';
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The lifecycle:
|
|
133
|
+
|
|
134
|
+
1. **`ensureReachJob()`** lists jobs and returns the existing one with a matching `reportTypeId`, creating it only if absent. Safe to call on every run — it never creates a duplicate.
|
|
135
|
+
2. **`listReports()`** pages `jobs.reports.list` for that job.
|
|
136
|
+
3. **Zero reports** → return `pending: true` with an explanatory `message`. The CLI surfaces this as the `REACH_PENDING` warning. Google generates the first reports within 24-48 hours, including a 30-day backfill.
|
|
137
|
+
4. **Download and merge.** Each report's `downloadUrl` is fetched through `apis.downloadCsv`, parsed, and folded into a `Map` keyed `` `${date}|${videoId}` ``. Reports overlap, so last write wins — later reports carry corrected figures for the same day.
|
|
138
|
+
5. **Sort** by date, then video id.
|
|
139
|
+
|
|
140
|
+
Rows are `{ date, channelId, videoId, impressions, impressionsCtr }`. **`impressionsCtr` is a decimal fraction, not a percentage**: `0.0561` means 5.61%.
|
|
141
|
+
|
|
142
|
+
Report data is permanently 1-2 days behind — the same lag YouTube Studio shows — because it covers midnight-to-midnight Pacific periods generated after the period closes.
|
|
143
|
+
|
|
144
|
+
### downloadCsv
|
|
145
|
+
|
|
146
|
+
Report bodies are plain CSV behind an OAuth-protected URL that the googleapis client does not handle, so `createApis` provides a `fetch` wrapper that attaches `Authorization: Bearer <token>` from `authClient.getAccessToken()` and requests gzip. A non-2xx response throws.
|
|
147
|
+
|
|
148
|
+
## Transforms
|
|
149
|
+
|
|
150
|
+
`src/api/transforms.js` is pure — no network, no auth, deterministic, directly tested.
|
|
151
|
+
|
|
152
|
+
| Function | Purpose |
|
|
153
|
+
|---|---|
|
|
154
|
+
| `parseDuration(iso)` | ISO 8601 duration (`PT15M33S`, `P1DT2H`) to whole seconds; `0` when absent or unparseable |
|
|
155
|
+
| `classifyContent(video)` | `LIVE_STREAM` if `liveStreamingDetails` is present, `SHORTS` if duration is 1-60s, else `VIDEO_ON_DEMAND` |
|
|
156
|
+
| `parseCsv(text)` | RFC 4180 subset: quoted fields, embedded commas and newlines, `""` escapes |
|
|
157
|
+
| `normalizeReportingDate(v)` | `20260328.0` → `2026-03-28`; ISO passes through; unrecognised values are returned unchanged |
|
|
158
|
+
| `rowsFromAnalytics(data)` | Zips `columnHeaders` + positional `rows` into objects |
|
|
159
|
+
| `normalizeChannel(ch)` | Flattens the channel resource into camelCase |
|
|
160
|
+
| `normalizeVideo(v)` | Flattens the video resource, adding `durationSeconds` and `contentType` |
|
|
161
|
+
|
|
162
|
+
Output is idiomatic camelCase JSON. Mapping it onto a particular database schema is the consumer's job.
|
|
163
|
+
|
|
164
|
+
A naive `split(',')` would corrupt report rows silently, because video titles and search terms routinely contain commas — hence the real parser. Its `coerce()` helper converts numeric-looking cells to numbers but leaves anything with a leading zero as a string, so zero-padded identifiers survive.
|
|
165
|
+
|
|
166
|
+
## Orchestration
|
|
167
|
+
|
|
168
|
+
`fetchAll()` in `src/fetch-all.js` runs every dataset in one pass. Order and concurrency:
|
|
169
|
+
|
|
170
|
+
1. **`fetchChannel`** — outside `step()`. A missing channel throws `NO_YOUTUBE_CHANNEL`; everything downstream needs `uploadsPlaylistId`.
|
|
171
|
+
2. **`fetchAllVideoIds`** then **`fetchVideos`** — sequential, the second needs the first.
|
|
172
|
+
3. **`fetchDailyAnalytics` + `fetchCardMetrics`** — concurrent; the results are merged by date.
|
|
173
|
+
4. **Eight analytics fetchers** — concurrent: video analytics, traffic sources, demographics, device types, content types, search terms, geography, playback locations.
|
|
174
|
+
5. **Traffic source details** — one call per source type the channel actually returned, concurrent.
|
|
175
|
+
6. **Retention** — sequential, one API call per video, capped at `retentionLimit` (default 50) with the newest videos first.
|
|
176
|
+
7. **Reach** — only with `reach: true`.
|
|
177
|
+
|
|
178
|
+
Every step after the channel runs inside `step(name, fn, fallback)`, which catches, records a warning, and returns the fallback — except for `FATAL_CODES` (`NOT_AUTHENTICATED`, `MISSING_CREDENTIALS`, `INVALID_CREDENTIALS`, `NO_YOUTUBE_CHANNEL`, `QUOTA_EXCEEDED`), which rethrow.
|
|
179
|
+
|
|
180
|
+
Returns `{ period: { startDate, endDate, days }, warnings, notes, data }`. When retention is truncated, `notes` says so explicitly.
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ytstats",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pull your YouTube channel stats and analytics as JSON. Bring your own Google Cloud credentials, no server, no data leaves your machine.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ytstats": "./bin/ytstats.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin/",
|
|
17
|
+
"src/",
|
|
18
|
+
"README.md",
|
|
19
|
+
"CHANGELOG.md",
|
|
20
|
+
"docs/",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:watch": "vitest",
|
|
26
|
+
"coverage": "vitest run --coverage",
|
|
27
|
+
"prepublishOnly": "npm test"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"youtube",
|
|
31
|
+
"analytics",
|
|
32
|
+
"youtube-api",
|
|
33
|
+
"youtube-analytics",
|
|
34
|
+
"cli",
|
|
35
|
+
"json",
|
|
36
|
+
"statistics",
|
|
37
|
+
"creator",
|
|
38
|
+
"agent",
|
|
39
|
+
"agentic"
|
|
40
|
+
],
|
|
41
|
+
"author": "Nicolas Dao",
|
|
42
|
+
"license": "BSD-3-Clause",
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/nicolasdao/ytstats.git"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/nicolasdao/ytstats#readme",
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/nicolasdao/ytstats/issues"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"commander": "^14.0.3",
|
|
53
|
+
"googleapis": "^171.4.0",
|
|
54
|
+
"open": "^9.1.0"
|
|
55
|
+
},
|
|
56
|
+
"overridesRationale": {
|
|
57
|
+
"gaxios": "googleapis-common pins gaxios 7.1.3, which drags in rimraf > glob > minimatch > brace-expansion and a high-severity DoS advisory (GHSA-mh99-v99m-4gvg). gaxios 7.1.4+ dropped that chain. Same major version, so this is a patch-level floor, not a compatibility risk. Remove once googleapis-common raises its own floor."
|
|
58
|
+
},
|
|
59
|
+
"overrides": {
|
|
60
|
+
"gaxios": "^7.1.4"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
64
|
+
"vitest": "^4.1.10"
|
|
65
|
+
}
|
|
66
|
+
}
|