Skip to content

Weekly link-freshness engine + MCP server - #17

Merged
in-c0 merged 2 commits into
mainfrom
feat/link-freshness
Aug 27, 2026
Merged

Weekly link-freshness engine + MCP server#17
in-c0 merged 2 commits into
mainfrom
feat/link-freshness

Conversation

@in-c0

@in-c0 in-c0 commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Builds the thing the README has had as a TODO since the first commit — "set up cron jobs/GitHub Actions to periodically re-run the scrapers and keep the dataset up-to-date" — and then exposes the result over MCP.

Why

The dataset is a table of URLs. URLs rot. Measured on main today, over the 176 rows that carry all eight columns:

outcome share of 1,232 checks
ok — resolves at exactly the stored address 52.5%
moved — resolves only after redirecting elsewhere 41.7%
dead — 404/410 4.0%
blocked — 401/403/429 0.6%

Per column, the share still resolving at the stored address:

column ok note
Security Policy 76.7%
Privacy Policy 73.9%
Terms of Service 57.4%
Developer Community/Forum 56.8%
Official Documentation 38.1% 16.5% dead
Release Notes 35.2%
Rate Limiting Policy 29.5% 66.5% moved

The two policy columns rot in opposite directions: rate-limit pages almost never 404, they just move; documentation pages are the ones that actually disappear. The redirects are not cosmetic — api.slack.comdocs.slack.dev, stripe.com/docsdocs.stripe.com, cloud.google.comdocs.cloud.google.com are whole doc-platform migrations.

What is added

tools/check-links.mjs — probes every URL in every column, follows redirects, classifies the outcome. Two distinctions carry the design:

  • moved vs dead. A redirect has a known-good replacement, so --fix rewrites those rows automatically. A 404 does not, and inventing one would put a fabrication in the dataset — dead URLs are reported for a human to resolve.
  • blocked vs dead. 401/403/429 means we were refused, which says nothing about whether the page exists. Treating a bot-detection challenge as rot would let Cloudflare silently delete good rows.

.github/workflows/link-freshness.yml — runs it every Monday and opens a PR with the corrections. Installs only csv-parse and csv-stringify rather than the full manifest, so CI does not download Playwright browsers in order to make fetch calls.

mcp/server.mjs — serves the index over MCP with search_apis, get_api_resources and index_health. Every URL comes back with when it was last verified and what the verifier saw; with no report present it reports "unknown" rather than implying freshness. Handing a model a link without its age invites it to trust a 404, and a tool result reads as authoritative.

tools/verify-mcp.mjs — drives the server as a real MCP client end-to-end.

A defect found along the way

Only 176 of 562 rows (31%) carry all eight columns, and the missing fields are omitted rather than blanked — so every later value shifts one column left. 189 rows have a URL in Official_Documentation_URL that is plainly a privacy policy, community forum or security page. Microsoft Graph People API, Slack Interactivity API, Dropbox File Request API and Zendesk Support API are examples.

This is worse than rot: those rows are not stale, they are mislabelled, and a consumer asking for documentation gets a privacy policy with no indication anything is wrong.

Per-column statistics therefore cover complete rows only — otherwise "documentation health" would be partly a claim about privacy policies. The problem is measured separately under alignment in datasets/link-health.json, and flagged in the README's Known Issues as the highest-value contribution available. It is not fixed here — repairing it needs per-row judgement about which column each URL belongs to.

Also measured

74 of 164 documentation hosts (45%) now serve /llms.txt — including Slack, Stripe, AWS, GitHub, Twilio, PayPal and Mailchimp. Tracked per run, because if that number keeps climbing it is a direct signal about this project's remaining usefulness, and the honest thing is to watch it rather than not look.

Notes for review

  • api-docs-urls.csv shows every line as changed because csv-stringify normalises quoting. The meaningful changes are the 1,472 URLs replaced with their redirect targets.
  • datasets/link-health-history.csv is append-only — the series is the interesting output, not any single snapshot.
  • npm test → 13 passing. node tools/verify-mcp.mjs → all MCP checks passed.
  • The weekly workflow needs Settings → Actions → General → "Allow GitHub Actions to create and approve pull requests" enabled; the repo currently has it off, so the PR-opening step will fail until it is turned on. Everything before that step still runs and uploads the report as an artifact.

🤖 Generated with Claude Code

AvaKim and others added 2 commits August 27, 2026 18:17
The dataset is a table of URLs and URLs rot, but the README's TODO to
"set up cron jobs/GitHub Actions to keep the dataset up-to-date" was never
built. This builds it, and exposes the result over MCP.

tools/check-links.mjs probes every URL in every column, follows redirects and
classifies the outcome. Two distinctions do the real work:

- moved vs dead. A redirect has a known-good replacement, so --fix rewrites
  those rows automatically. A 404 does not, and inventing one would put a
  fabrication in the dataset, so dead URLs are reported for a human instead.
- blocked vs dead. 401/403/429 means we were refused, which says nothing about
  whether the page exists. Counting a Cloudflare challenge as rot would let a
  bot-detector silently delete good rows.

Per-column tallies cover structurally complete rows only. Most rows in this
dataset omit fields rather than blanking them, so later values shift a column
left and a privacy-policy URL lands under Official_Documentation_URL; counting
those would make "documentation health" partly a claim about privacy policies.
That defect is measured separately under `alignment`.

Reports go to datasets/link-health.json (snapshot) and
datasets/link-health-history.csv (appended each run). The series is the point:
a snapshot says the data is stale, the series says how fast docs rot.

mcp/server.mjs serves the index over stdio with three tools. Every URL is
returned with when it was last verified and what the verifier saw — handing a
model a link without its age invites it to trust a 404, and a tool result reads
as authoritative. With no report present it says "unknown" rather than implying
freshness. tools/verify-mcp.mjs drives it as a real client end-to-end.

Deliberately dependency-light: the workflow installs only csv-parse and
csv-stringify rather than the full manifest, so CI does not download playwright
browsers to make fetch calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Baseline measured 2026-08-27 over 562 rows / 959 distinct URLs / 164 hosts.

Across the 176 structurally complete rows (1,232 URL checks):
  ok 647 (52.5%) · moved 514 (41.7%) · dead 49 (4.0%) · blocked 7 (0.6%)

By column, share still resolving at exactly the stored address:
  Security Policy      76.7%      Terms of Service    57.4%
  Privacy Policy       73.9%      Release Notes       35.2%
  Developer Community  56.8%      Documentation       38.1%  (16.5% dead)
                                  Rate Limiting       29.5%  (66.5% moved)

The policy columns rot hardest in opposite directions: rate-limit pages almost
never 404, they just move, while documentation pages are the ones that actually
disappear.

Also measured: 74 of 164 documentation hosts (45%) now serve /llms.txt, and
only 176 of 562 rows (31%) carry all eight columns.

The whole file is rewritten because csv-stringify normalises quoting; the
meaningful changes are the 1,472 URLs replaced by their redirect targets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@in-c0
in-c0 merged commit 61636b1 into main Aug 27, 2026
1 check failed
@in-c0
in-c0 deleted the feat/link-freshness branch August 27, 2026 08:19
in-c0 added a commit that referenced this pull request Aug 27, 2026
#17 added @modelcontextprotocol/sdk and zod to package.json but installed them
with --no-save, so the lockfile never learned about them. ci.yml runs `npm ci`,
which refuses to install when the manifest and lockfile disagree — so CI has
been red on main since that merge. It was green immediately before it.

No dependency choices change here; the lockfile is regenerated with
`npm install --package-lock-only`.

Co-authored-by: AvaKim <avajiyo@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
in-c0 added a commit that referenced this pull request Aug 28, 2026
Google's documentation redirects to whichever locale it infers from the client.
The checker followed those redirects and recorded the destination as canonical,
so a --fix pass pinned English rows to whatever language the runner looked like
that day: 212 URLs across the dataset carried ?hl=pt-br, ?hl=ar, ?hl=he and
similar. None were there before #17 — this was my regression, and the first
automated PR (#21) was about to add six more.

Two changes. Requests now send Accept-Language: en-US,en;q=0.9, so the redirect
mostly does not happen. And normalise() drops language-selection parameters, so a
redirect that only picks a translation classifies as `ok` rather than `moved` and
never produces a correction. A genuine move that happens to land on a localised
URL still counts as moved, with the language stripped before it is written.

--fix now also strips language parameters an earlier pass baked in: 212 cleaned,
zero remaining.

Side effect worth noting: `moved` drops from 41.7% at the baseline to 0.1%. Some
of that fall was the redirect repair working, but part of the original figure was
this bug counting translations as moves.

Co-authored-by: AvaKim <avajiyo@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants