google-apis-cli 0.2.0 → 0.4.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/README.md +19 -14
- package/dist/bin/gapi.js +10 -3
- package/dist/src/apis/ads.js +43 -1
- package/dist/src/apis/bigquery.js +191 -0
- package/dist/src/apis/business-information.js +32 -0
- package/dist/src/apis/cloudquotas.js +4 -0
- package/dist/src/apis/crux.js +4 -0
- package/dist/src/apis/ga4-admin.js +47 -0
- package/dist/src/apis/ga4-data-alpha.js +1 -0
- package/dist/src/apis/ga4-data.js +8 -0
- package/dist/src/apis/gsc.js +12 -1
- package/dist/src/apis/gtm.js +35 -0
- package/dist/src/apis/merchant-accounts.js +13 -0
- package/dist/src/apis/merchant-reports.js +1 -0
- package/dist/src/apis/pagespeed.js +4 -0
- package/dist/src/apis/places.js +4 -0
- package/dist/src/apis/sa360.js +1 -0
- package/dist/src/apis/web-risk.js +4 -0
- package/dist/src/apis/youtube.js +15 -0
- package/dist/src/catalog.js +49 -7
- package/dist/src/credentials.js +42 -16
- package/dist/src/entities.js +180 -0
- package/dist/src/plan.js +55 -8
- package/dist/src/render.js +95 -4
- package/dist/src/responses.js +37 -0
- package/dist/src/run.js +301 -21
- package/dist/src/search.js +208 -0
- package/dist/src/transport/download.js +7 -1
- package/dist/src/transport/retry.js +21 -0
- package/dist/src/transport/upload.js +46 -21
- package/dist/src/transport.js +72 -39
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
# google-apis-cli (`gapi`)
|
|
2
2
|
|
|
3
|
-
`gapi` is one CLI for
|
|
3
|
+
`gapi` is one CLI for 49 Google API entries, built from Google's discovery documents at runtime:
|
|
4
4
|
|
|
5
5
|
- Google Ads v26, GA4 Admin (v1beta, v1alpha), GA4 Data (v1beta, v1alpha), Search Console, Tag Manager, Indexing
|
|
6
6
|
- Merchant API (13 v1 sub-APIs + Reviews v1alpha), Business Profile (7 v1 APIs + v4)
|
|
7
7
|
- Search Ads 360 (Reporting v0, legacy v2), Campaign Manager 360 v5, Display & Video 360 v4, Bid Manager v2
|
|
8
8
|
- YouTube Data v3, YouTube Analytics v2, YouTube Reporting v1
|
|
9
9
|
- PageSpeed Insights v5, CrUX v1 (API key)
|
|
10
|
-
- BigQuery Data Transfer v1, Data Manager v1, Gmail Postmaster Tools v2, Site Verification v1, Sheets v4, Places (New) v1, Web Risk v1
|
|
10
|
+
- BigQuery v2, BigQuery Data Transfer v1, Data Manager v1, Gmail Postmaster Tools v2, Site Verification v1, Sheets v4, Places (New) v1, Web Risk v1, Cloud Quotas v1
|
|
11
11
|
|
|
12
12
|
`gapi --help` lists the entry names; [skills/gapi/reference/index.md](skills/gapi/reference/index.md) gives title, version, method count and auth per entry.
|
|
13
13
|
|
|
14
14
|
- One login. One refresh token. Every scope the entries use (computed from the documents). Read and write.
|
|
15
15
|
- One runtime dependency: `google-auth-library` (Google). Nothing third-party.
|
|
16
16
|
- All network calls use native `fetch`.
|
|
17
|
-
- TypeScript 7 (strict), Node
|
|
17
|
+
- TypeScript 7 (strict), Node 26. Node runs `.ts` directly (type-stripping) — no build step in dev. Biome for lint + format. pnpm 12 (pinned via `packageManager`) for package management.
|
|
18
18
|
- It replaces the 4 fragmented Google MCPs in vmCODE.
|
|
19
19
|
|
|
20
|
-
Command shape: `gapi <api> <resource>… <method> [--params '<json>'] [--body '<json>'] [--upload <file> [--upload-content-type <type>] [--upload-protocol media|multipart|resumable]] [--output <file>] [--page-all [--page-limit <n>]] [--dry-run]`. `gapi <api> [<resource>…]` lists a level (the api level also lists helpers); `… <method> --help` shows method help. `gapi <api> +<helper> … [--dry-run] [--project <id>] [--api-key <key>] [<entry header flag>]` runs a hand command, e.g. `gapi ads +gaql "<query>" --customer <id> [--stream]`, `gapi indexing +publishBatch <file
|
|
20
|
+
Command shape: `gapi <api> <resource>… <method> [--params '<json>'] [--body '<json>'] [--upload <file> [--upload-content-type <type>] [--upload-protocol media|multipart|resumable]] [--output <file>] [--page-all [--page-limit <n>]] [--cache <age>] [--dry-run]`. `gapi <api> [<resource>…]` lists a level (the api level also lists helpers); `… <method> --help` shows method help. `gapi <api> +<helper> … [--dry-run] [--cache <age>] [--project <id>] [--api-key <key>] [<entry header flag>]` runs a hand command, e.g. `gapi ads +gaql "<query>" --customer <id> [--stream]`, `gapi indexing +publishBatch <file>`, `gapi bigquery +query "<sql>" --project <id> [--max-bytes <n>] [--estimate]`. `gapi search <words…>` lists at most 20 methods across all API entries that match the words (command, marks, HTTP method, first sentence of the description). `gapi fields ads|sa360 <words…>` lists at most 20 GAQL field names from the discovery document (no request); `gapi fields ga4-data <words…> --property <id>` ranks GA4 dimensions and metrics from one `getMetadata` request; `--cache` exits 3. `gapi schema <api> <resource>[.<sub>…].<method> | <SchemaName> [--resolve-refs]` prints a method shape or a schema as JSON; `--find <word>` prints instead only the field paths of the expanded schema (a method: `request.…` and `response.…`) whose path or printed description (first sentence, max 100 chars) contains the word. `gapi entities sync [<api>…] [--page-limit <n>]` stores the operator's own accounts, properties and sites (entity source overlays) in a local index; `gapi entities find <words…> [--api <api>]` prints at most 20 matching entities offline, one tab-separated line each (API, kind, ID, name, parent, domain, sync date). `gapi discovery refresh [<api>…]` refetches discovery documents now; `gapi discovery path` prints the cache dir. `gapi search --help`, `gapi fields --help`, `gapi schema --help`, `gapi discovery --help` and `gapi auth [<sub>] --help` print that command's usage and run nothing.
|
|
21
21
|
|
|
22
22
|
## Install
|
|
23
23
|
|
|
@@ -25,7 +25,7 @@ Command shape: `gapi <api> <resource>… <method> [--params '<json>'] [--body '<
|
|
|
25
25
|
npm i -g google-apis-cli # installs the `gapi` bin
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
Needs **Node ≥
|
|
28
|
+
Needs **Node ≥ 26**. The package ships `dist/` (plain `tsc` output, built at publish, no bundle); in the repo, Node runs the `.ts` sources directly.
|
|
29
29
|
|
|
30
30
|
## Start here
|
|
31
31
|
|
|
@@ -36,7 +36,7 @@ gapi auth status # account, refresh token, API key, granted and missing scopes
|
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
First run, or a console step fails? **[docs/SETUP.md](docs/SETUP.md)** walks the steps (services,
|
|
39
|
-
API key, Business Profile access, consent, Merchant registration) and the traps in each. `gapi` is multi-tenant: every operator brings their own Cloud project.
|
|
39
|
+
API key, Business Profile access, consent, Merchant registration, client access) and the traps in each. `gapi` is multi-tenant: every operator brings their own Cloud project.
|
|
40
40
|
|
|
41
41
|
## OAuth App Verification
|
|
42
42
|
|
|
@@ -54,22 +54,26 @@ A call moves through these parts in order.
|
|
|
54
54
|
|
|
55
55
|
| Part | File | Function |
|
|
56
56
|
|---|---|---|
|
|
57
|
-
| **run** | `src/run.ts` | `run(argv, io)`: strict argv parse, routing (`--help`, `auth`, `schema`, `discovery`, `<api> …`), `GapiError` → exit code. `bin/gapi.ts` passes process io. |
|
|
57
|
+
| **run** | `src/run.ts` | `run(argv, io)`: strict argv parse, routing (`--help`, `auth`, `search`, `schema`, `discovery`, `<api> …`), `GapiError` → exit code. `bin/gapi.ts` passes process io. One JSON writer for responses, `--dry-run` and `auth status`: compact lines off a terminal, 2-space indent when `io.stdout.isTTY` (#200 D2). |
|
|
58
58
|
| **Catalog** | `src/catalog.ts` | API entries (`src/apis/*.ts`, glob, no registry) → `ApiModel`. Discovery entries: document cached 24 h. |
|
|
59
59
|
| **Planner** | `src/plan.ts` | `plan(model, words \| methodId, input, settings)` → `CallPlan`. Pure. Every `--params`/`--body` rule; validation fails exit 3 before network. |
|
|
60
|
-
| **Transport** | `src/transport.ts` | `
|
|
61
|
-
| **
|
|
62
|
-
| **
|
|
60
|
+
| **Transport** | `src/transport.ts` + `src/transport/{upload,download,batch}.ts` | `receive(plan, { fetch, authorize, sleep, signal? })`: async iterator of decoded responses (#200 D3); `decode(data, step)` reshapes each (`step` = the request that got it), `undefined` yields nothing. `send(plan, { …, out })` writes one compact JSON line per response by iterating it. `signal` (`AbortSignal`) sets a deadline; an abort is never retried. `authorize` adds the credential per request; GET/HEAD/PUT/DELETE, `reads` POSTs and helper plans marked `retry` (`+query`: idempotent `requestId`) retry 408/429/5xx and network errors (max 3, backoff with jitter); non-2xx or network failure → exit 1; 403 `ACCESS_TOKEN_SCOPE_INSUFFICIENT` (also in an array body or a batch part) → exit 2. A `pages` plan follows its follow rule; the Planner's `--page-all` rule (AIP-158) follows `nextPageToken`, sending `pageToken` in the query or in the request body (methods whose request schema has `pageToken`): one JSON line per page, max `--page-limit` (default 10) yielded pages. `--upload` streams a file to `rootUrl` + `upload/` (media, multipart, or resumable in 8 MiB chunks, resumed from the session's `Range` after a 5xx or network error); `--output` writes the `alt=media` body to a file, from `rootUrl` + `download/` when the method sets `useMediaDownloadService`. `+publishBatch` sends `multipart/mixed` to the document's `batchPath`. `--dry-run` prints the plan instead. |
|
|
61
|
+
| **Search** | `src/search.ts` | `search(models, query)`: SQLite FTS5 index (`node:sqlite`) built in memory per call from every loaded ApiModel, nothing on disk. Words split at camelCase, `-`, `_`, `.`; query tokens are letters and digits only, double-quoted, joined with `OR`; best 20 by `bm25`. `rankFields(fields, query)` (#200 D7): same split and match over `gaqlFields(model)` (resource, metrics, segments schemas → snake_case GAQL names) or `ga4Fields(metadata)`; names and first description sentences in two FTS5 tables, since bm25 normalizes by row length; a field whose last name part equals the query comes first. |
|
|
62
|
+
| **Renderers** | `src/render.ts` | Root help, level listing, method help, search hits, `schemaView` (`$ref` expansion, cycle guard), `schemaFind` (`--find` field paths), and the skill reference Markdown that `pnpm gen:skill` writes to `skills/gapi/reference/` (generated; do not edit by hand). |
|
|
63
|
+
| **Response cache** | `src/responses.ts` | `--cache <age>` (1s–7d, opt-in per call, [ADR 0001](docs/adr/0001-response-cache.md)): `run` checks eligibility before any network call (no `nocache` entry, single call or pages, `GET` or a `reads` POST), then answers a hit from `responses/` in the config dir (dir 700, files 600) with no fetch, or stores a miss after a successful send. Key holds `identity(auth)` from Credentials (refresh-token hash), so accounts never share entries. `auth login` and `auth logout` delete `responses/`. |
|
|
64
|
+
| **Entities** | `src/entities.ts` | `gapi entities sync` / `find` ([ADR 0002](docs/adr/0002-entities-store.md)): only reader and writer of `entities.db` in the config dir (mode 600; another schema version is dropped). `sync` runs each entry's `entities` sources through `receive` (`--page-limit` pages per list), then replaces that API's rows in one transaction; a failed API keeps its rows, exit 1. `find`: trigram FTS5, bm25, best 20; a term under 3 characters exits 3. Rows hold `identity(auth)`; every run deletes rows older than 7 days; `auth login` and `auth logout` delete the file. |
|
|
65
|
+
| **Credentials** | `src/credentials.ts` | Only reader and writer of `config.json` and `credentials.json` (mode 600) in the config dir (`GAPI_CONFIG_DIR` or `~/.config/gapi`; it also holds the discovery `cache/`, `responses/` and `entities.db`). `settings`: `--project`, `--api-key` and entry header flags, each flag → env → config. `authorize`: OAuth Bearer (refreshed token saved) or `X-Goog-Api-Key`. `login`, `status` (with `missing`), `logout` (revoke, then delete), `setup`. |
|
|
63
66
|
|
|
64
|
-
CLI tests call `run` in-process (`test/run.test.ts`, `test/helpers/io.ts`). Terms: [GLOSSARY.md](GLOSSARY.md).
|
|
67
|
+
CLI tests call `run` in-process (`test/run.test.ts`, `test/helpers/io.ts`). `test/skill.test.ts` runs every `gapi … --dry-run` line of SKILL.md and recipes.md against the shipped entries; a new example method needs its entry in the pruned docs of `test/fixtures/skill/`. Terms: [GLOSSARY.md](GLOSSARY.md).
|
|
65
68
|
|
|
66
69
|
## Where to look
|
|
67
70
|
|
|
68
71
|
| For | Read |
|
|
69
72
|
|---|---|
|
|
70
|
-
| Onboarding a Google account: services to enable, Ads access level, Business Profile access, API key, consent, Merchant registration, customer IDs, runtime errors | **[docs/SETUP.md](docs/SETUP.md)** |
|
|
71
|
-
| Using the CLI as an agent: grammar, `--params`, `schema`, `--dry-run`, paging, media, helpers, exit codes, marks | [skills/gapi/SKILL.md](skills/gapi/SKILL.md) + generated [reference](skills/gapi/reference/index.md) |
|
|
73
|
+
| Onboarding a Google account: services to enable, Ads access level, Business Profile access, API key, consent, Merchant registration, client access, customer IDs, runtime errors | **[docs/SETUP.md](docs/SETUP.md)** |
|
|
74
|
+
| Using the CLI as an agent: grammar, `--params`, `schema`, `--dry-run`, paging, media, helpers, exit codes, marks | [skills/gapi/SKILL.md](skills/gapi/SKILL.md) + [recipes](skills/gapi/recipes.md) + generated [reference](skills/gapi/reference/index.md) |
|
|
72
75
|
| What the CLI does, decisions, scopes, endpoints, risks | **[docs/SPEC.md](docs/SPEC.md)** — behavior source of truth |
|
|
76
|
+
| Releasing a version (tag push → npm via trusted publishing; changes in [CHANGELOG.md](CHANGELOG.md)) | [docs/runbook.md](docs/runbook.md) item 4 |
|
|
73
77
|
| How work is tracked (issues, blocking edges) | [docs/agents/issue-tracker.md](docs/agents/issue-tracker.md) |
|
|
74
78
|
| Triage labels | [docs/agents/triage-labels.md](docs/agents/triage-labels.md) |
|
|
75
79
|
| Domain vocabulary (GLOSSARY.md, ADRs) | [docs/agents/domain.md](docs/agents/domain.md) |
|
|
@@ -77,7 +81,8 @@ CLI tests call `run` in-process (`test/run.test.ts`, `test/helpers/io.ts`). Term
|
|
|
77
81
|
|
|
78
82
|
## Add an API or a resource
|
|
79
83
|
|
|
80
|
-
- New API = one new `src/apis/<api>.ts` file that default-exports an API entry (`satisfies ApiEntry`): the discovery URL plus overlays (`retired`, `deprecated`, `paid`, `headers`, `helpers`). The discovery document gives every resource and method. The glob finds the file; `api` must equal the file name, or the Catalog fails to load. No shared file changes.
|
|
84
|
+
- New API = one new `src/apis/<api>.ts` file that default-exports an API entry (`satisfies ApiEntry`): the discovery URL plus overlays (`retired`, `deprecated`, `paid`, `reads`, `nocache`, `headers`, `helpers`, `entities`). The discovery document gives every resource and method. The glob finds the file; `api` must equal the file name, or the Catalog fails to load. No shared file changes.
|
|
85
|
+
- Check the API's caching terms; set `nocache` (`{ reason, source }`) when they forbid or bound caching ([ADR 0001](docs/adr/0001-response-cache.md)). Else list POST methods that only read (reports, searches) by id in `reads`, so `--cache` accepts them.
|
|
81
86
|
- Accept rule — `pnpm discovery:check` exits 0. It fails when an overlay names a method id that is not in the document. CI runs it weekly and on manual dispatch (`.github/workflows/discovery.yml`); `prepublishOnly` runs it before every publish.
|
|
82
87
|
- Test through `run(argv, io)`: fixture entries via `io.apisDir`, cache seeded in a temp `GAPI_CONFIG_DIR`, fake `fetch` (`test/helpers/fetch.ts`). `--dry-run` shows the request. Use no real network.
|
|
83
88
|
- Before commit: `pnpm typecheck` (tsc --noEmit) · `pnpm lint` (biome) · `pnpm test`.
|
package/dist/bin/gapi.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { run } from '../src/run.js';
|
|
3
3
|
const { argv, env, stdin, stdout, stderr } = process;
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
// stdout EPIPE (`gapi … | head`): the reader is gone, so stop, which also stops --page-all
|
|
5
|
+
// fetches. Any other stream error (ENOSPC, EBADF) lost output: exit 5, never a silent 0.
|
|
6
|
+
for (const s of [stdout, stderr]) {
|
|
7
|
+
s.on('error', (e) => {
|
|
8
|
+
if (e.code !== 'EPIPE')
|
|
9
|
+
process.exit(5);
|
|
10
|
+
if (s === stdout)
|
|
11
|
+
process.exit(0);
|
|
12
|
+
});
|
|
13
|
+
}
|
|
7
14
|
process.exitCode = await run(argv.slice(2), { env, fetch, stdin, stdout, stderr });
|
package/dist/src/apis/ads.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// `:search` keeps its raw {results, nextPageToken}; `ads customers googleAds search
|
|
8
8
|
// --page-all` pages it through the request body (epic #30 D6); `--stream` is the all-rows path.
|
|
9
|
+
import { items, text } from '../entities.js';
|
|
9
10
|
import { GapiError } from '../errors.js';
|
|
10
11
|
import { isRecord } from '../json.js';
|
|
11
12
|
import { plan } from '../plan.js';
|
|
@@ -21,7 +22,10 @@ const streamConcat = (data) => {
|
|
|
21
22
|
const message = isRecord(failed.error) ? failed.error.message : undefined;
|
|
22
23
|
throw new GapiError(1, typeof message === 'string' ? message : 'searchStream: a chunk holds an error', failed);
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
+
const results = data.flatMap((c) => (isRecord(c) && Array.isArray(c.results) ? c.results : []));
|
|
26
|
+
// SUMMARY_ROW_SETTING puts the totals in the last chunk's summaryRow; keep them.
|
|
27
|
+
const summaryRow = data.findLast((c) => isRecord(c) && c.summaryRow !== undefined)?.summaryRow;
|
|
28
|
+
return summaryRow === undefined ? results : { results, summaryRow };
|
|
25
29
|
};
|
|
26
30
|
// D10: one GAQL query; `--stream` returns every row, without it the first page as returned.
|
|
27
31
|
const gaql = {
|
|
@@ -41,9 +45,34 @@ const gaql = {
|
|
|
41
45
|
return stream ? { ...call, decode: streamConcat } : call;
|
|
42
46
|
},
|
|
43
47
|
};
|
|
48
|
+
// Per accessible customer: itself (level 0, gives its name) and its direct clients, queried
|
|
49
|
+
// as that customer (`login-customer-id`). A client's parent is its manager.
|
|
50
|
+
// shortcut: `level <= 1` skips clients of a sub-manager the login cannot access directly;
|
|
51
|
+
// upgrade to the whole tree when an operator needs them.
|
|
52
|
+
const customerClients = {
|
|
53
|
+
method: 'googleads.customers.googleAds.search',
|
|
54
|
+
params: (parent) => ({ customerId: parent?.id }),
|
|
55
|
+
body: () => ({
|
|
56
|
+
query: 'SELECT customer_client.id, customer_client.descriptive_name, customer_client.level FROM customer_client WHERE customer_client.level <= 1',
|
|
57
|
+
}),
|
|
58
|
+
headers: (parent) => ({ 'login-customer-id': parent?.id ?? '' }),
|
|
59
|
+
entities: (data, parent) => items(data, 'results').flatMap((r) => {
|
|
60
|
+
const c = isRecord(r.customerClient) ? r.customerClient : {};
|
|
61
|
+
const id = text(c.id);
|
|
62
|
+
if (!id)
|
|
63
|
+
return [];
|
|
64
|
+
const name = text(c.descriptiveName);
|
|
65
|
+
return [{ kind: 'customer', id, name, parent: id === parent?.id ? undefined : parent?.id }];
|
|
66
|
+
}),
|
|
67
|
+
};
|
|
44
68
|
export default {
|
|
45
69
|
api: 'ads',
|
|
46
70
|
discovery: 'https://googleads.googleapis.com/$discovery/rest?version=v26',
|
|
71
|
+
reads: [
|
|
72
|
+
'googleads.customers.googleAds.search',
|
|
73
|
+
'googleads.customers.googleAds.searchStream',
|
|
74
|
+
'googleads.googleAdsFields.search',
|
|
75
|
+
],
|
|
47
76
|
headers: [
|
|
48
77
|
{
|
|
49
78
|
name: 'login-customer-id',
|
|
@@ -53,4 +82,17 @@ export default {
|
|
|
53
82
|
},
|
|
54
83
|
],
|
|
55
84
|
helpers: { '+gaql': gaql },
|
|
85
|
+
// Customer IDs as calls take them: 10 digits, no `customers/` prefix.
|
|
86
|
+
entities: [
|
|
87
|
+
{
|
|
88
|
+
method: 'googleads.customers.listAccessibleCustomers',
|
|
89
|
+
entities: (data) => isRecord(data) && Array.isArray(data.resourceNames)
|
|
90
|
+
? data.resourceNames.flatMap((n) => {
|
|
91
|
+
const id = typeof n === 'string' ? n.replace(/^customers\//, '') : '';
|
|
92
|
+
return id ? [{ kind: 'customer', id }] : [];
|
|
93
|
+
})
|
|
94
|
+
: [],
|
|
95
|
+
children: { customer: customerClients },
|
|
96
|
+
},
|
|
97
|
+
],
|
|
56
98
|
};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// BigQuery API v2 (#200 D4) and the read-only `+query` helper (D5).
|
|
2
|
+
// No `reads`: jobs.query also runs DML/DDL, so a cache hit could skip a write (ADR 0001).
|
|
3
|
+
// No `nocache`: the operator owns the warehouse data.
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { items, text } from '../entities.js';
|
|
6
|
+
import { GapiError } from '../errors.js';
|
|
7
|
+
import { isRecord } from '../json.js';
|
|
8
|
+
import { plan } from '../plan.js';
|
|
9
|
+
const QUERY_ARGS = '"<sql>" [--max-bytes <n>] [--estimate] [--page-limit <n>]';
|
|
10
|
+
const BUDGET = String(10 * 2 ** 30); // 10 GiB: one third of the 32 GiB daily quota (D11)
|
|
11
|
+
const WAIT_MS = 60_000; // per call; the server default is 10 s
|
|
12
|
+
const positive = (flag, value) => {
|
|
13
|
+
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) {
|
|
14
|
+
throw new GapiError(3, `${flag} must be a positive integer`);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
};
|
|
18
|
+
// D5 row decode: `f`/`v` cells → plain JSON. Integer and decimal types stay strings, as sent:
|
|
19
|
+
// a JSON number loses integers above 2^53.
|
|
20
|
+
function cell(field, v) {
|
|
21
|
+
if (v === null || v === undefined)
|
|
22
|
+
return null;
|
|
23
|
+
if (field.mode === 'REPEATED' && Array.isArray(v)) {
|
|
24
|
+
const item = { ...field, mode: undefined };
|
|
25
|
+
return v.map((c) => cell(item, isRecord(c) ? c.v : c));
|
|
26
|
+
}
|
|
27
|
+
if ((field.type === 'RECORD' || field.type === 'STRUCT') && isRecord(v)) {
|
|
28
|
+
return record(field.fields ?? [], v);
|
|
29
|
+
}
|
|
30
|
+
if (typeof v !== 'string')
|
|
31
|
+
return v;
|
|
32
|
+
if (field.type === 'FLOAT' || field.type === 'FLOAT64') {
|
|
33
|
+
const n = Number(v);
|
|
34
|
+
return Number.isFinite(n) ? n : v; // NaN and Infinity have no JSON number: kept as sent
|
|
35
|
+
}
|
|
36
|
+
if (field.type === 'BOOLEAN' || field.type === 'BOOL')
|
|
37
|
+
return v === 'true';
|
|
38
|
+
if (field.type === 'TIMESTAMP' && /^-?\d+$/.test(v))
|
|
39
|
+
return timestamp(BigInt(v));
|
|
40
|
+
return v;
|
|
41
|
+
}
|
|
42
|
+
function record(fields, row) {
|
|
43
|
+
const cells = Array.isArray(row.f) ? row.f : [];
|
|
44
|
+
return Object.fromEntries(fields.map((f, i) => [f.name, cell(f, isRecord(cells[i]) ? cells[i].v : null)]));
|
|
45
|
+
}
|
|
46
|
+
// `useInt64Timestamp`: microseconds since the epoch → ISO 8601 with 6 fraction digits.
|
|
47
|
+
function timestamp(us) {
|
|
48
|
+
const floor = us < 0n && us % 1000n !== 0n ? 1n : 0n;
|
|
49
|
+
const ms = us / 1000n - floor;
|
|
50
|
+
const rest = us - ms * 1000n;
|
|
51
|
+
return new Date(Number(ms)).toISOString().replace('Z', `${String(rest).padStart(3, '0')}Z`);
|
|
52
|
+
}
|
|
53
|
+
// One printed line per result page; a running job (`jobComplete: false`) prints nothing.
|
|
54
|
+
function page(data) {
|
|
55
|
+
if (!isRecord(data) || data.jobComplete === false)
|
|
56
|
+
return undefined;
|
|
57
|
+
const fields = isRecord(data.schema) && Array.isArray(data.schema.fields) ? data.schema.fields : [];
|
|
58
|
+
const rows = Array.isArray(data.rows)
|
|
59
|
+
? data.rows.map((r) => record(fields, isRecord(r) ? r : {}))
|
|
60
|
+
: [];
|
|
61
|
+
const { totalRows, totalBytesProcessed, cacheHit } = data;
|
|
62
|
+
return { rows, totalRows, totalBytesProcessed, cacheHit };
|
|
63
|
+
}
|
|
64
|
+
const isDryRun = (body) => isRecord(body) && body.dryRun === true;
|
|
65
|
+
// D5 follow rule: dry run → the same body as a real query (same requestId); a running job →
|
|
66
|
+
// getQueryResults; a pageToken → the next page. Else stop: without a job, every row came inline.
|
|
67
|
+
function follow(model, settings) {
|
|
68
|
+
const results = (job, pageToken) => plan(model, 'bigquery.jobs.getQueryResults', {
|
|
69
|
+
params: {
|
|
70
|
+
projectId: job.projectId,
|
|
71
|
+
jobId: job.jobId,
|
|
72
|
+
...(typeof job.location === 'string' && { location: job.location }),
|
|
73
|
+
timeoutMs: WAIT_MS,
|
|
74
|
+
'formatOptions.useInt64Timestamp': true,
|
|
75
|
+
...(typeof pageToken === 'string' && pageToken && { pageToken }),
|
|
76
|
+
},
|
|
77
|
+
}, settings).url;
|
|
78
|
+
return (data, last) => {
|
|
79
|
+
if (isDryRun(last.body)) {
|
|
80
|
+
const { dryRun: _, ...query } = last.body;
|
|
81
|
+
return { ...last, body: query };
|
|
82
|
+
}
|
|
83
|
+
if (!isRecord(data))
|
|
84
|
+
return undefined;
|
|
85
|
+
const token = typeof data.pageToken === 'string' && data.pageToken ? data.pageToken : undefined;
|
|
86
|
+
if (data.jobComplete !== false && !token)
|
|
87
|
+
return undefined;
|
|
88
|
+
const job = data.jobReference;
|
|
89
|
+
if (!isRecord(job) || typeof job.jobId !== 'string') {
|
|
90
|
+
throw new GapiError(1, 'BigQuery returned more results but no job reference', data);
|
|
91
|
+
}
|
|
92
|
+
return { httpMethod: 'GET', url: results(job, data.jobComplete === false ? undefined : token) };
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// D5: one read-only statement. Step 1, a free dry run, checks the statement type and the
|
|
96
|
+
// estimate; step 2 runs the query only when both pass, with the budget as maximumBytesBilled.
|
|
97
|
+
const query = {
|
|
98
|
+
summary: `${QUERY_ARGS} run one read-only SQL query under a byte budget (default 10 GiB)`,
|
|
99
|
+
options: {
|
|
100
|
+
'max-bytes': { type: 'string' },
|
|
101
|
+
estimate: { type: 'boolean' },
|
|
102
|
+
'page-limit': { type: 'string' },
|
|
103
|
+
},
|
|
104
|
+
plan: async ({ model, args, flags, settings }) => {
|
|
105
|
+
if (args.length !== 1)
|
|
106
|
+
throw new GapiError(3, `usage: gapi bigquery +query ${QUERY_ARGS}`);
|
|
107
|
+
if (!settings.project) {
|
|
108
|
+
throw new GapiError(3, '+query needs a job project: --project <id>, GOOGLE_CLOUD_PROJECT, or project in the config');
|
|
109
|
+
}
|
|
110
|
+
const maxBytes = positive('--max-bytes', flags['max-bytes'] ?? BUDGET);
|
|
111
|
+
const pageLimit = Number(positive('--page-limit', flags['page-limit'] ?? '10'));
|
|
112
|
+
const body = {
|
|
113
|
+
query: args[0],
|
|
114
|
+
useLegacySql: false,
|
|
115
|
+
maximumBytesBilled: maxBytes,
|
|
116
|
+
jobCreationMode: 'JOB_CREATION_OPTIONAL',
|
|
117
|
+
timeoutMs: WAIT_MS,
|
|
118
|
+
jobTimeoutMs: '600000', // BigQuery cancels the job server-side: bounds the poll loop
|
|
119
|
+
formatOptions: { useInt64Timestamp: true },
|
|
120
|
+
requestId: randomUUID(), // idempotent resend: the plan may retry (D5)
|
|
121
|
+
dryRun: true,
|
|
122
|
+
};
|
|
123
|
+
const call = plan(model, 'bigquery.jobs.query', { params: { projectId: settings.project }, body, dryRun: flags['dry-run'] === true }, settings);
|
|
124
|
+
const estimate = flags.estimate === true;
|
|
125
|
+
const decode = (data, step) => {
|
|
126
|
+
if (!isDryRun(step?.body))
|
|
127
|
+
return page(data);
|
|
128
|
+
const { statementType, totalBytesProcessed: bytes } = isRecord(data) ? data : {};
|
|
129
|
+
if (estimate)
|
|
130
|
+
return { statementType, totalBytesProcessed: bytes };
|
|
131
|
+
if (statementType !== 'SELECT') {
|
|
132
|
+
throw new GapiError(3, `+query runs only SELECT; this statement is ${statementType}; send it with gapi bigquery jobs insert`);
|
|
133
|
+
}
|
|
134
|
+
if (typeof bytes !== 'string' || !/^\d+$/.test(bytes)) {
|
|
135
|
+
throw new GapiError(1, 'the dry run returned no byte estimate', data);
|
|
136
|
+
}
|
|
137
|
+
if (BigInt(bytes) > BigInt(maxBytes)) {
|
|
138
|
+
throw new GapiError(1, `estimate ${bytes} bytes is over the budget of ${maxBytes} bytes; narrow the dates or columns, or raise --max-bytes`);
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
};
|
|
142
|
+
if (estimate)
|
|
143
|
+
return { ...call, send: 'once', decode, retry: true };
|
|
144
|
+
return {
|
|
145
|
+
...call,
|
|
146
|
+
send: 'pages',
|
|
147
|
+
pageLimit,
|
|
148
|
+
follow: follow(model, settings),
|
|
149
|
+
decode,
|
|
150
|
+
retry: true,
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
export default {
|
|
155
|
+
api: 'bigquery',
|
|
156
|
+
discovery: 'https://bigquery.googleapis.com/$discovery/rest?version=v2',
|
|
157
|
+
helpers: { '+query': query },
|
|
158
|
+
// Dataset ID `project.dataset`, as SQL names it. A GA4 export dataset `analytics_<id>` gets
|
|
159
|
+
// GA4 property `properties/<id>` as parent, so a find on the property returns it.
|
|
160
|
+
entities: [
|
|
161
|
+
{
|
|
162
|
+
method: 'bigquery.projects.list',
|
|
163
|
+
entities: (data) => items(data, 'projects').flatMap((p) => {
|
|
164
|
+
const id = isRecord(p.projectReference) ? text(p.projectReference.projectId) : undefined;
|
|
165
|
+
return id ? [{ kind: 'project', id, name: text(p.friendlyName) }] : [];
|
|
166
|
+
}),
|
|
167
|
+
children: {
|
|
168
|
+
project: {
|
|
169
|
+
method: 'bigquery.datasets.list',
|
|
170
|
+
params: (parent) => ({ projectId: parent?.id }),
|
|
171
|
+
entities: (data, parent) => items(data, 'datasets').flatMap((d) => {
|
|
172
|
+
const ref = isRecord(d.datasetReference) ? d.datasetReference : {};
|
|
173
|
+
const project = text(ref.projectId);
|
|
174
|
+
const dataset = text(ref.datasetId);
|
|
175
|
+
if (!project || !dataset)
|
|
176
|
+
return [];
|
|
177
|
+
const ga4 = /^analytics_(\d+)$/.exec(dataset)?.[1];
|
|
178
|
+
return [
|
|
179
|
+
{
|
|
180
|
+
kind: 'dataset',
|
|
181
|
+
id: `${project}.${dataset}`,
|
|
182
|
+
name: text(d.friendlyName),
|
|
183
|
+
parent: ga4 ? `properties/${ga4}` : parent?.id,
|
|
184
|
+
},
|
|
185
|
+
];
|
|
186
|
+
}),
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
};
|
|
@@ -1,5 +1,37 @@
|
|
|
1
|
+
import { domainOf, items, text } from '../entities.js';
|
|
1
2
|
export default {
|
|
2
3
|
api: 'business-information',
|
|
3
4
|
discovery: 'https://mybusinessbusinessinformation.googleapis.com/$discovery/rest?version=v1',
|
|
4
5
|
scopes: ['business.manage'],
|
|
6
|
+
// This API lists locations but not accounts; Account Management lists the accounts.
|
|
7
|
+
entities: [
|
|
8
|
+
{
|
|
9
|
+
api: 'business-account-management',
|
|
10
|
+
method: 'mybusinessaccountmanagement.accounts.list',
|
|
11
|
+
entities: (data) => items(data, 'accounts').flatMap((a) => {
|
|
12
|
+
const id = text(a.name);
|
|
13
|
+
return id ? [{ kind: 'account', id, name: text(a.accountName) }] : [];
|
|
14
|
+
}),
|
|
15
|
+
children: {
|
|
16
|
+
account: {
|
|
17
|
+
method: 'mybusinessbusinessinformation.accounts.locations.list',
|
|
18
|
+
params: (parent) => ({ parent: parent?.id, readMask: 'name,title,websiteUri' }),
|
|
19
|
+
entities: (data, parent) => items(data, 'locations').flatMap((l) => {
|
|
20
|
+
const id = text(l.name);
|
|
21
|
+
return id
|
|
22
|
+
? [
|
|
23
|
+
{
|
|
24
|
+
kind: 'location',
|
|
25
|
+
id,
|
|
26
|
+
name: text(l.title),
|
|
27
|
+
parent: parent?.id,
|
|
28
|
+
domain: domainOf(l.websiteUri),
|
|
29
|
+
},
|
|
30
|
+
]
|
|
31
|
+
: [];
|
|
32
|
+
}),
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
],
|
|
5
37
|
};
|
package/dist/src/apis/crux.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export default {
|
|
2
2
|
api: 'crux',
|
|
3
3
|
discovery: 'https://chromeuxreport.googleapis.com/$discovery/rest?version=v1',
|
|
4
|
+
nocache: {
|
|
5
|
+
reason: 'Google-owned content; Google APIs Terms §5 bound caching to the cache header',
|
|
6
|
+
source: 'https://developers.google.com/terms',
|
|
7
|
+
},
|
|
4
8
|
};
|
|
@@ -1,4 +1,51 @@
|
|
|
1
|
+
import { domainOf, items, text } from '../entities.js';
|
|
2
|
+
import { isRecord } from '../json.js';
|
|
3
|
+
// Per property: its data streams; a web stream's default URI gives the domain.
|
|
4
|
+
const dataStreams = {
|
|
5
|
+
method: 'analyticsadmin.properties.dataStreams.list',
|
|
6
|
+
params: (parent) => ({ parent: parent?.id }),
|
|
7
|
+
entities: (data, parent) => items(data, 'dataStreams').flatMap((s) => {
|
|
8
|
+
const id = text(s.name);
|
|
9
|
+
const uri = isRecord(s.webStreamData) ? s.webStreamData.defaultUri : undefined;
|
|
10
|
+
return id
|
|
11
|
+
? [
|
|
12
|
+
{
|
|
13
|
+
kind: 'stream',
|
|
14
|
+
id,
|
|
15
|
+
name: text(s.displayName),
|
|
16
|
+
parent: parent?.id,
|
|
17
|
+
domain: domainOf(uri),
|
|
18
|
+
},
|
|
19
|
+
]
|
|
20
|
+
: [];
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
1
23
|
export default {
|
|
2
24
|
api: 'ga4-admin',
|
|
3
25
|
discovery: 'https://analyticsadmin.googleapis.com/$discovery/rest?version=v1beta',
|
|
26
|
+
// Account summaries list every account and its properties in one list.
|
|
27
|
+
entities: [
|
|
28
|
+
{
|
|
29
|
+
method: 'analyticsadmin.accountSummaries.list',
|
|
30
|
+
entities: (data) => items(data, 'accountSummaries').flatMap((a) => {
|
|
31
|
+
const id = text(a.account);
|
|
32
|
+
const account = id ? [{ kind: 'account', id, name: text(a.displayName) }] : [];
|
|
33
|
+
const properties = items(a, 'propertySummaries').flatMap((p) => {
|
|
34
|
+
const property = text(p.property);
|
|
35
|
+
return property
|
|
36
|
+
? [
|
|
37
|
+
{
|
|
38
|
+
kind: 'property',
|
|
39
|
+
id: property,
|
|
40
|
+
name: text(p.displayName),
|
|
41
|
+
parent: text(p.parent),
|
|
42
|
+
},
|
|
43
|
+
]
|
|
44
|
+
: [];
|
|
45
|
+
});
|
|
46
|
+
return [...account, ...properties];
|
|
47
|
+
}),
|
|
48
|
+
children: { property: dataStreams },
|
|
49
|
+
},
|
|
50
|
+
],
|
|
4
51
|
};
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
export default {
|
|
2
2
|
api: 'ga4-data',
|
|
3
3
|
discovery: 'https://analyticsdata.googleapis.com/$discovery/rest?version=v1beta',
|
|
4
|
+
reads: [
|
|
5
|
+
'analyticsdata.properties.runReport',
|
|
6
|
+
'analyticsdata.properties.runPivotReport',
|
|
7
|
+
'analyticsdata.properties.batchRunReports',
|
|
8
|
+
'analyticsdata.properties.batchRunPivotReports',
|
|
9
|
+
'analyticsdata.properties.runRealtimeReport',
|
|
10
|
+
'analyticsdata.properties.checkCompatibility',
|
|
11
|
+
],
|
|
4
12
|
};
|
package/dist/src/apis/gsc.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
+
import { domainOf, items } from '../entities.js';
|
|
1
2
|
export default {
|
|
2
3
|
api: 'gsc',
|
|
3
4
|
discovery: 'https://searchconsole.googleapis.com/$discovery/rest?version=v1',
|
|
4
5
|
retired: {
|
|
5
6
|
'searchconsole.urlTestingTools.mobileFriendlyTest.run': {
|
|
6
7
|
reason: 'Retired by Google in December 2023',
|
|
7
|
-
source: 'https://developers.google.com/search/blog/
|
|
8
|
+
source: 'https://developers.google.com/search/blog/2023/04/page-experience-in-search',
|
|
8
9
|
},
|
|
9
10
|
},
|
|
11
|
+
reads: ['webmasters.searchanalytics.query', 'searchconsole.urlInspection.index.inspect'],
|
|
12
|
+
// A site's ID is its siteUrl: `sc-domain:acme.com` or a URL prefix; both give the domain.
|
|
13
|
+
entities: [
|
|
14
|
+
{
|
|
15
|
+
method: 'webmasters.sites.list',
|
|
16
|
+
entities: (data) => items(data, 'siteEntry').flatMap(({ siteUrl }) => typeof siteUrl === 'string'
|
|
17
|
+
? [{ kind: 'site', id: siteUrl, domain: domainOf(siteUrl) }]
|
|
18
|
+
: []),
|
|
19
|
+
},
|
|
20
|
+
],
|
|
10
21
|
};
|
package/dist/src/apis/gtm.js
CHANGED
|
@@ -1,4 +1,39 @@
|
|
|
1
|
+
import { domainOf, items, text } from '../entities.js';
|
|
1
2
|
export default {
|
|
2
3
|
api: 'gtm',
|
|
3
4
|
discovery: 'https://tagmanager.googleapis.com/$discovery/rest?version=v2',
|
|
5
|
+
// IDs are paths (`accounts/1/containers/2`). The container name carries its public ID
|
|
6
|
+
// (GTM-…). shortcut: all domain keys of a container share one space-separated domain cell;
|
|
7
|
+
// upgrade to one row per domain when a find must tell them apart.
|
|
8
|
+
entities: [
|
|
9
|
+
{
|
|
10
|
+
method: 'tagmanager.accounts.list',
|
|
11
|
+
entities: (data) => items(data, 'account').flatMap((a) => {
|
|
12
|
+
const id = text(a.path);
|
|
13
|
+
return id ? [{ kind: 'account', id, name: text(a.name) }] : [];
|
|
14
|
+
}),
|
|
15
|
+
children: {
|
|
16
|
+
account: {
|
|
17
|
+
method: 'tagmanager.accounts.containers.list',
|
|
18
|
+
params: (parent) => ({ parent: parent?.id }),
|
|
19
|
+
entities: (data, parent) => items(data, 'container').flatMap((c) => {
|
|
20
|
+
const id = text(c.path);
|
|
21
|
+
if (!id)
|
|
22
|
+
return [];
|
|
23
|
+
const names = [text(c.name), text(c.publicId)].filter(Boolean);
|
|
24
|
+
const domains = Array.isArray(c.domainName) ? c.domainName.map(domainOf) : [];
|
|
25
|
+
return [
|
|
26
|
+
{
|
|
27
|
+
kind: 'container',
|
|
28
|
+
id,
|
|
29
|
+
name: names.join(' ') || undefined,
|
|
30
|
+
parent: parent?.id,
|
|
31
|
+
domain: [...new Set(domains.filter(Boolean))].join(' ') || undefined,
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
],
|
|
4
39
|
};
|
|
@@ -1,4 +1,17 @@
|
|
|
1
|
+
import { domainOf, items, text } from '../entities.js';
|
|
1
2
|
export default {
|
|
2
3
|
api: 'merchant-accounts',
|
|
3
4
|
discovery: 'https://merchantapi.googleapis.com/$discovery/rest?version=accounts_v1',
|
|
5
|
+
// `homePageUri` (output only) gives the domain without one homepage call per account.
|
|
6
|
+
entities: [
|
|
7
|
+
{
|
|
8
|
+
method: 'merchantapi.accounts.list',
|
|
9
|
+
entities: (data) => items(data, 'accounts').flatMap((a) => {
|
|
10
|
+
const id = text(a.name);
|
|
11
|
+
return id
|
|
12
|
+
? [{ kind: 'account', id, name: text(a.accountName), domain: domainOf(a.homePageUri) }]
|
|
13
|
+
: [];
|
|
14
|
+
}),
|
|
15
|
+
},
|
|
16
|
+
],
|
|
4
17
|
};
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export default {
|
|
2
2
|
api: 'pagespeed',
|
|
3
3
|
discovery: 'https://pagespeedonline.googleapis.com/$discovery/rest?version=v5',
|
|
4
|
+
nocache: {
|
|
5
|
+
reason: 'Google-owned content; Google APIs Terms §5 bound caching to the cache header',
|
|
6
|
+
source: 'https://developers.google.com/terms',
|
|
7
|
+
},
|
|
4
8
|
};
|
package/dist/src/apis/places.js
CHANGED
|
@@ -5,6 +5,10 @@ const billed = {
|
|
|
5
5
|
export default {
|
|
6
6
|
api: 'places',
|
|
7
7
|
discovery: 'https://places.googleapis.com/$discovery/rest?version=v1',
|
|
8
|
+
nocache: {
|
|
9
|
+
reason: 'Places terms forbid caching API content (place_id excepted)',
|
|
10
|
+
source: 'https://developers.google.com/maps/documentation/places/web-service/policies',
|
|
11
|
+
},
|
|
8
12
|
paid: {
|
|
9
13
|
'places.places.autocomplete': billed,
|
|
10
14
|
'places.places.get': billed,
|
package/dist/src/apis/sa360.js
CHANGED