Turns a Google News link (news.google.com/rss/articles/…, /articles/…, /read/…) into the publisher's URL, so you can link to the article and not to Google. Every failure comes back as a value you can branch on. A rate limit is its own case, so a batch can stop before it burns the rest of your IP's daily budget. The existing npm package, google-news-url-decoder 1.2.2, has no timeout, reports a 429 only as message text, and pairs batch results with inputs by position, though Google answers in arbitrary order.
- Whole-decode timeout. One
timeoutMscovers both article pages, every redirect, the retry and the RPC, including a transport that ignores itsAbortSignal. Pass your ownsignaltoo;timeoutandabortedare separate results. - Structured results.
{ ok: true, url }or{ ok: false, reason, status?, message }. Expected failures never throw. - Rate limits end the decode. A 429, or a redirect to
google.com/sorry, returnsrate_limitedat once, without trying the second article page. - One retry, connect only. A failed connect (
ECONNREFUSED, DNS, connect timeout) is retried once. A request that may have reached Google is never sent twice. - No hidden state. No module-level cache or counters. A cache is a
Mapyou pass in, and it only ever holds successes. - Keyed batches.
decodeAllreturns aMapkeyed by input URL, paces withdelayMs, and skips the rest after a rate limit. - Small. Zero runtime dependencies, ESM-only, Node 24+. Offline tests replay one real recorded decode.
import { createDecoder } from "gnews-decoder";
const decoder = createDecoder({ timeoutMs: 15_000 });
await decoder.decode("https://news.google.com/rss/articles/CBMiswFBVV95cUxP…");
// { ok: true, url: "https://www.reuters.com/world/middle-east/hope-progress-after-us-iran-hold-first-shuttle-talks-months-2026-09-23/" }
await decoder.decode("https://www.reuters.com/world/");
// { ok: false, reason: "not_google_news", message: "not a Google News article URL" }
// once your IP is throttled
// { ok: false, reason: "rate_limited", status: 429, message: "HTTP 429" }A batch:
const results = await decoder.decodeAll(urls, { delayMs: 1_500, signal });
// Map { "https://news.google.com/rss/articles/…" => { ok: true, url: "…" }, … }npm install gnews-decoderNeeds Node 24 or later; CI runs the active LTS and the current release. No other setup: no API key, no config.
| option | default | |
|---|---|---|
fetch |
globalThis.fetch |
the transport; inject one for tests or a proxy |
timeoutMs |
15000 |
budget for one whole decode |
userAgent |
a desktop Chrome UA | |
cache |
none | a Map<string, string> of token to URL. Successes only; a failure is never stored |
type DecodeResult =
| { ok: true; url: string }
| { ok: false; reason: FailureReason; status?: number; message: string };reason |
when | status |
|---|---|---|
not_google_news |
not a decodable Google News URL; no request made | |
rate_limited |
HTTP 429, or a redirect to google.com/sorry |
429 or the redirect's |
http |
any other non-2xx, a redirect off Google, too many redirects | yes |
network |
connect failed twice, the connection dropped, a read failed | |
timeout |
timeoutMs ran out |
|
aborted |
your signal fired |
|
parse |
no signature on either article page, or no URL in the RPC answer |
message is for logs. It never includes the article URL.
A bad timeoutMs (zero, negative, NaN) throws a RangeError: that's a bug in the caller, not an expected case.
decoder.decodeAll(urls, { signal?, timeoutMs?, delayMs?, onResult? }) => Promise<Map<string, BatchResult>>
Decodes serially. Results are keyed by input URL, never by position; duplicates collapse. delayMs pauses only between decodes that reach the network. The batch stops at the first rate_limited or aborted; the rest come back { ok: false, reason: "skipped" }. timeoutMs applies per decode. onResult(url, result) is called for each URL as its result lands, skipped ones included: use it for progress, or for a job runner's heartbeat, which in many runners is also how a job learns it was cancelled. If it throws, the batch rejects.
decode is createDecoder(options).decode(url, options) with a throwaway decoder. isGoogleNewsUrl is the pure URL check.
Google limits by IP, and it behaves like a daily budget rather than a rate: backing off and retrying doesn't recover it. So:
- a 429 on the first article page ends the decode; the second candidate page isn't tried, since that would spend more of a budget that's already gone
- nothing is retried except a failed connect (
ECONNREFUSED,EHOSTUNREACH,ENETUNREACH,EAI_AGAIN,ENOTFOUND, undici's connect timeout). A request that may have reached Google (a reset, a read timeout) is never sent twice decodeAllstops the batch on the first rate limit
Pacing is yours: delayMs on decodeAll, or your own loop around decode.
Semver. reason values, status, option names and exports are the contract. message text is not. A new reason counts as breaking, because it breaks exhaustive switches. Design notes, estimates and the threat model are in docs/design.md.
npm test # offline; a stub fetch fails any test that forgets to inject one
npm run test:live # GNEWS_LIVE=1: one feed fetch and one real decode. Never in CI
npm run lint && npm run typecheck && npm run buildFixtures in test/fixtures/ are one real decode recorded on 2026-09-23. Script and style bodies were stripped from the article page because they carry session tokens.
npm version <patch|minor|major> && git push --follow-tags. The tag's workflow builds and tests with no credential, then stages the tarball on npm through trusted publishing. It goes live when a maintainer approves it under Staged Packages on npmjs.com, with 2FA. Inspect it first (npm stage download <id>): approval is the control, so it has to be more than a click.
Installs here run with scripts disabled and a 7-day cooldown (.npmrc): adding a release younger than a week fails with ERESOLVE on purpose; pass --min-release-age=0 when you mean it. Nothing rebuilds dist/ for you. Publishing by hand means rm -rf dist && npm run build && npm test && npm pack, checking the tarball, then npm publish <tarball>. The first release goes out that way, because a trusted publisher can only be added to a package that already exists.
A port of SeanLF/google-news-url-decoder, branch v0.2-protocol-transport-split, itself a fork of SSujitX/google-news-url-decoder (googlenewsdecoder on PyPI) by Sujit Biswas. Both are MIT.
From the fork:
- the protocol as pure functions, with the I/O kept separate
/rss/articlesfirst (the smaller page), then/articles; locale sent up front to save a redirect- signature and timestamp read off the same element, in either order, ignoring comments and scripts
- the three-level RPC nesting, and two different answers treated as a failure, not a guess
- connect-only retry; no cookies across redirects (Google's consent wall); a cap on decoded response size; a bound on token length
- validating the result as an http(s) URL without control characters
From upstream 0.2.1:
- taking
hl/gl/ceidfrom the source URL when it has them - building the RPC payload with a JSON serialiser instead of string interpolation
- stripping the
)]}'guard explicitly when parsing the RPC answer - the observation that non-browser clients get redirected to
google.com/sorry, Google's unusual-traffic page. Reporting that asrate_limitedis this port's inference, not something either upstream measured
Not taken: upstream's fallback that matches batch results by position when IDs are missing.
Not ported: the fork's batched RPC (many decodes in one POST), the async variant (every call here is async), proxy support (inject a fetch that uses one) and the probe harness.