@nytka/plugin-pagespeed
v0.1.1
Published
PageSpeed Insights connector for nytka projects. Collects Lighthouse lab scores, Core Web Vitals lab metrics and the non-passing audits with their fix lists for any public URL into datasets/ and registers them with provenance.
Readme
@nytka/plugin-pagespeed
PageSpeed Insights connector for nytka projects. Runs Google's hosted Lighthouse against any
public URL — your own or a competitor's — and collects the scores, the lab Core Web Vitals
and every non-passing audit with its fix list into the project's datasets/, registered in
datasets/index.json with provenance.
Read-only, by nature. The API has one method, runPagespeed, and it is a GET. There
is no write surface to keep out of reach, so there is no allowlist here to admire: the
client builds one URL, sends one verb, and nothing about a page can be changed through it.
Lab data, not field data. What this collects is a Lighthouse run in Google's data centre under a throttled, emulated device — a repeatable measurement of the page as built, not what Chrome users experienced. Field data is a different measurement from a different API, and a different connector: see Lab, not field and decision 0013.
Why this source
Every other connector in the line reports on a property the project owns — a Search Console grant, a GA4 property, a CMS token. PageSpeed Insights answers for any URL on the public web, on one API key and no OAuth. That makes it the first source here that reads a competitor: the same measurement, the same scale, for a page you do not control.
What it gives that nothing else in the line does:
- The four Lighthouse scores — performance, accessibility, best practices, SEO — per URL and per device, from Google's own runner, so the number matches what a client sees on pagespeed.web.dev.
- The fix list. Every audit the page fails, with Lighthouse's estimated savings and the actual resources behind it — the render-blocking stylesheet, the unsized image, the low-contrast span. This is the part a task gets written from.
Install
npm install @nytka/plugin-pagespeedOne dependency, @nytka/core, which has none. The API is JSON over HTTP and Node's built-in
fetch speaks it; there is no client library to carry.
Setup
About five minutes. There is no key file and nothing to put in private/. This is not the
service account the other Google connectors share (GOOGLE_SERVICE_ACCOUNT_KEY): PageSpeed
Insights takes a plain API key as a query parameter.
1. Enable the API
Google Cloud console → pick a project (the one holding the service account is fine) → APIs & Services → Library → search PageSpeed Insights API → Enable.
2. Create the key
APIs & Services → Credentials → Create credentials → API key. Then edit the key:
- Application restrictions: None. A referrer restriction blocks a command line, which sends no referrer.
- API restrictions: Restrict key → PageSpeed Insights API — and Chrome UX Report API
if the same key is to serve
@nytka/plugin-crux, which acceptsPAGESPEED_API_KEYin place of its own. The key can then do nothing else if it leaks.
Calls without a key are refused outright: the anonymous project's daily quota is zero (observed 2026-09-19). A key of your own is the whole setup.
3. Configure
Append the package's .env.example to the project's .env and fill it in:
cat node_modules/@nytka/plugin-pagespeed/.env.example >> .envPAGESPEED_API_KEY=AIza… # the key value from step 2
PAGESPEED_URLS=https://example.com/, https://example.com/pricing/PAGESPEED_URLS is every page to measure, in full — scheme, host, path — comma-separated.
Each origin in the list gets its own pair of datasets; several pages of one origin become
rows of the same pair. Every entry costs two calls per run and ten seconds to a minute each,
so keep it to the pages whose speed you act on.
Then name the system in references/systems.yaml, pointers only:
pageSpeed: # @nytka/plugin-pagespeed
urls:
- https://example.com/
- https://example.com/pricing/
authEnvVar: PAGESPEED_API_KEY # the key VALUE, not a path; a Cloud API key restricted to this API4. Verify
npx nytka-pagespeed checkOne mobile, performance-only run of the first URL. Prints the score and the three headline metrics, writes nothing. It spends one call of the quota, because the API has no free call.
Use
npx nytka-pagespeed check # auth smoke test — one run, nothing written
npx nytka-pagespeed measure # every URL × mobile + desktop → two datasets per origin
npx nytka-pagespeed measure --strategy mobile # one device only
npx nytka-pagespeed measure --url https://example.org/ # a one-off list, the env var ignored
npx nytka-pagespeed measure --snapshot # key today's run to the day, not the month
npx nytka-pagespeed measure --no-register # write the payloads, leave the registry alonemeasure calls the API once per URL and device, sequentially, and prints a progress line per
run on stderr. Four categories are requested in every call — the performance run carries
the other three at no extra cost — so one call yields both datasets.
The surface, and the question each part answers
| Command | Calls | Question |
|---|---|---|
| check | 1 | Does the key work, and roughly how fast is the first page on a phone? |
| measure → pagespeed-scores-… | URLs × devices | How does each page score on performance, accessibility, best practices and SEO, on mobile and desktop, and what are its lab Core Web Vitals? |
| measure → pagespeed-audits-… | (same calls) | What exactly does each page fail, how much would fixing it save, and on which resources? |
Why two datasets from one run. A Lighthouse score varies run to run — network jitter, the runner's load, an A/B variant served — so the audits that explain a score must come from the run that produced it. Two commands making two sets of calls would produce a fix list for a score nobody recorded.
What a query script is asked
- Which URL sits below 90 on mobile? →
scoresrows,strategy === 'mobile', sort byperformance. - Which audit would save the most LCP, and on which resources? →
auditsrows, sort bysavings.LCP, readitems. - Did mobile performance move since last month? → this month's and last month's
scoresentries, same URL, same strategy. - Is the competitor faster? → the same three questions over their origin's pair.
The two datasets
pagespeed-scores-<origin>-<period> — operation lab-scores
One row per URL × device.
| Column | Type | From |
|---|---|---|
| url | string | the URL as configured — what was asked for |
| strategy | mobile | desktop | the emulated device |
| date | YYYY-MM-DD | the UTC day of lighthouseResult.fetchTime |
| performance, accessibility, bestPractices, seo | integer 0–100 or null | the category score × 100, rounded; null when Lighthouse could not score it |
| fcpMs, lcpMs, tbtMs, speedIndexMs | integer ms or null | the metric audits' numericValue, rounded |
| cls | number to 3 dp or null | cumulative-layout-shift, unitless |
| ttfbMs | integer ms or null | server-response-time — the root document's time to first byte |
| finalUrl | string or null | where the page landed after redirects (finalDisplayedUrl) |
| lighthouseVersion | string | which Lighthouse produced the numbers |
The performance score is a weighted blend of five of these — FCP 10 %, Speed Index 10 %, LCP 25 %, TBT 30 %, CLS 25 % in Lighthouse 10 and later. TTFB is not scored; it is kept because it is the number a hosting conversation turns on.
pagespeed-audits-<origin>-<period> — operation lab-audits
One row per URL × device × non-passing audit.
| Column | Type | From |
|---|---|---|
| url, strategy | as above | |
| audit | string | the audit id, e.g. unused-javascript, color-contrast |
| title | string | Lighthouse's wording for the failing state |
| category | performance | accessibility | best-practices | seo | null | which category's auditRefs names it |
| group | string or null | the report section (metrics, insights, diagnostics, a11y-color-contrast, …) |
| score | 0–1 or null | as Lighthouse gives it |
| mode | binary | numeric | metricSavings | error | scoreDisplayMode |
| displayValue | string or null | the report's short reading, e.g. Root document took 610 ms |
| numericValue, numericUnit | number, string, or null | the unscored measurement behind it |
| weight | number | the audit's weight in its category score — 0 for every opportunity and insight, so sort on it to see what moved the score |
| savings | object or null | metricSavings as Lighthouse gives it: { LCP: 750, FCP: 0 } — milliseconds, except CLS |
| itemCount | integer | how many items the audit listed |
| items | array | details.items as Lighthouse gives it, first 100 — the resources, nodes and bytes behind the failure |
Non-passing is the Lighthouse report's own rule, not a stricter one: an audit fails when
its score is below 0.9 in any scored mode (binary, numeric, metricSavings), an
error always counts, and informative, manual and notApplicable never do. One rule is
this package's: an audit the category files under the hidden group is skipped, because
Lighthouse 13 hides an audit exactly when an insight has superseded it (interactive,
server-response-time, redirects), and listing both would count one finding twice.
metricSavings is the mode Lighthouse 11 introduced for opportunities and insights — the
older references do not list it. It is where the fix list lives; a filter written from the
documented modes alone would drop it entirely.
Several origins
PAGESPEED_URLS=https://example.com/, https://example.com/pricing/, https://competitor.example/Two origins, so two pairs: pagespeed-scores-example-com-… with two rows per device, and
pagespeed-scores-competitor-example-… with one. The registry entry's subject is the
origin, not the URL, and it is subject rather than property because the thing measured
need not be one the project owns.
Lab, not field
Lighthouse in Google's data centre loads the page once, on an emulated Moto G Power over a throttled 4G connection (mobile) or a desktop profile, and reports what it saw. That is lab data: repeatable, comparable across pages and sites, and available for any URL. It is not what your visitors experienced — a page can score 100 in the lab and fail Core Web Vitals in the field because real users are on worse phones, or pass in the field because they are mostly on fast desktops.
The same API response still carries field data (loadingExperience, from the Chrome UX
Report), and this package does not read it. Google's own page, 2026-09-19: "We plan to
discontinue including real-world data from the Chrome User Experience Report in this API. We
recommend the CrUX API or the CrUX History API instead." Field data belongs to a later
@nytka/plugin-crux on that API; 0013
has the four reasons. Every summary this package writes ends with lab data, not what users
experienced so the two are never read as one.
Not collected, and why. The full Lighthouse result — several hundred KB per run, a
full-page screenshot and every translation string included — stays in the response; the
rows keep what a query needs. The pwa category is gone from Lighthouse 12, and the
discovery document's agentic_browsing category is new and undocumented; neither is asked
for. Items of passing and informative audits are not kept: a fix list of things that are
fine is noise.
Troubleshooting
| What you see | What it means |
|---|---|
| PAGESPEED_API_KEY is not set … and exit 0 | The project has not configured PageSpeed Insights — step 3. Not an error: capability is configuration presence |
| no URL to measure | PAGESPEED_URLS is empty and no --url was given — step 3 |
| PageSpeed Insights rejected the API key | The key is wrong, or was deleted in the console — step 2 |
| the PageSpeed Insights API is not enabled in the Cloud project that owns this key | Step 1 was skipped for the project the key belongs to |
| the API key is restricted to HTTP referrers | Step 2's application restriction is set. Change it to None and keep the API restriction |
| plugin-crux says the key's restriction does not allow its API | Step 2's API restriction lists only PageSpeed Insights. Add Chrome UX Report API beside it |
| the daily quota for this caller is zero | No key reached Google — the value is blank or not loaded — or the API is not enabled in the key's project |
| rate-limited the call (HTTP 429) | The per-100-seconds or per-day quota. Retry later; the figures are in the Cloud console for the key's project |
| Lighthouse could not measure https://… (ERRORED_DOCUMENT_REQUEST / NO_FCP / …) | Google's runner could not load that page — down, blocked, behind a login, or painted nothing. The other URLs still collect; this one is in the entry's summary and the payload's failures |
| Google asked for a captcha before measuring … | Google's FAQ names a shared key as the usual cause. A key of your own avoids it |
| did not answer for https://… within 120s | A slow page or a busy runner; that run is recorded as failed and the rest continue |
| no URL could be measured — N runs failed | Every run failed; nothing was written. The list says why, per URL |
| no project.yaml found walking up from cwd | Not inside a nytka project. cd to the project root |
What it writes
| Path | Committed? |
|---|---|
| datasets/payloads/<id>.json | no — the directory ships its own .gitignore |
| datasets/index.json | yes — two entries per origin, added or replaced |
The dataset id
scores pagespeed-scores-example-com-2026-09 keyed to the month (default)
audits pagespeed-audits-example-com-2026-09
scores pagespeed-scores-example-com-2026-09-19 keyed to the day (--snapshot)A lab run has no date range — it is a measurement taken now — so the id is keyed to the
reporting period, not to the run (PLG-007): a month of daily runs replaces one entry rather
than laying down thirty. The entry's period records exactly what the id is keyed to, and
dateRange is explicitly null. --snapshot keys the id to the day instead, for whoever
wants a dated series — the same opt-in @nytka/plugin-sanity offers.
The row's date is the run's UTC day; the entry's collectedAt and period are the local
calendar. Around midnight the two can differ by a day, and both are right.
Rules it follows
- Payloads never enter agent context. Query them with a script; write conclusions to
research/. The CLI prints row counts and paths for this reason;checkprints four numbers, which is the smoke test's job. - The key never appears in output. It travels as a query parameter, which is how the API takes it; no URL is printed, and any response body is redacted before it can reach an error message.
- One run's failure is that run's. A page Google cannot load is recorded and skipped; the collection carries on. A key or quota refusal stops it at once, because every further call would fail the same way.
- Calls are sequential, on purpose: the quota is per 100 seconds and a run takes up to a minute.
- The project is found by walking up for
project.yaml, so it works at any install depth. - No YAML parsing, no config file. Secrets from
.env, everything else from flags.
Quotas and limits worth knowing
- Verified 2026-09-19: a call with no key answers HTTP 429,
Queries per day, with a limit of 0 for the anonymous project. There is no keyless mode any more. - Quoted, not verified here: 25,000 queries per day and 100 per 100 seconds per project (PLG-017, from Google's older documentation). The Cloud console shows the figures that apply to your project; the connector claims no number in its messages.
- What has and has not been run against the live API. Confirmed without a credential
on 2026-09-19: the URL format, the query-parameter auth, Google's error envelope, the
keyless quota refusal and the
API_KEY_INVALIDfault for a made-up key. Not yet confirmed: a successful response. Its shape here is a Lighthouse 13.5.0 result produced locally and wrapped in the envelope the reference documents. Two spellings of the enums exist in Google's own pages — the reference and Lighthouse CI's client usemobileandbest-practices; the discovery document saysMOBILEandBEST_PRACTICES— and the lowercase form is what goes on the wire. The first keyedcheckis the check. - Lighthouse moves. PageSpeed Insights ran 13.0 from 2025-10-20 and later 13.x since;
scores are comparable within a version and not always across one. Every row carries
lighthouseVersionso a jump can be read for what it is. - Every run is a fresh page load. A run is not cached: the same URL measured twice in a minute costs two calls and may score differently.
Tests
npm testnode --test against recorded responses. No credential, no network. Sixty-two tests,
including: the scores rounding and the CLS precision, the 0.9 threshold at its boundary in
every mode Lighthouse has including the one the reference omits, the hidden group skipped
with the real interactive and server-response-time audits as witnesses, the id holding
still across a month of runs and moving with --snapshot, a captcha, a runtimeError, a
Lighthouse 500 and a timeout each failing one run and not the collection, the observed
keyless 429 and the live invalid-key 400 pinned byte for byte, the key absent from every
error the package can produce,
and the CLI's exit codes at the process boundary.
The fixtures are Lighthouse 13.5.0 results run locally on 2026-09-19 against two real pages
— one that scores 100 across the board, one at 66 with a dozen failures — anonymised to
example.com and example.org and trimmed to the audits the tests need, then wrapped in
the pagespeedonline#result envelope from the reference. error-429-keyless.json and
error-400-key-invalid.json are live bodies, verbatim.
