fix(fetcher): stop writing soft-404 HTML into .md files - #1139
Conversation
Closes #941. Closes #768. Closes #669. 53 files in this repo currently hold '<!DOCTYPE html><html class=...' instead of documentation — 44 of them under content/en/api/kotlin/ — while the scheduled fetch has reported success five times a day throughout. platform.claude.com answers unknown doc paths with its Next.js app shell at HTTP 200, a soft 404. fetch_bytes() calls raise_for_status(), which only catches real error codes, so the shell was written straight into the target .md file. Two things kept it invisible. The run stays green because writing the file succeeds — nothing downstream asks whether the bytes are markdown. And incremental mode skips any path that already exists, so a file poisoned once is never re-fetched; it just stays wrong. That is why three separate reports (#669 Feb, #768 Mar, #941 May) describe the same defect months apart. Adds looks_like_html(), which sniffs only the first 512 bytes for a leading <!doctype html> or <html>. Checking the start matters: a real doc may discuss HTML or embed an html code block, and neither should trip the guard. When it fires the fetch is recorded as failed and no file is written, so the path stays absent and is retried next run instead of being skipped forever. Applied to platform docs and to GitHub .md files. Also deletes the 53 already-poisoned files. They must go, not just be guarded: incremental mode would otherwise skip them forever and the guard would never get a chance to run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 66ad62ac57
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| > Fetch the complete documentation index at: https://code.claude.com/docs/llms.txt | ||
| > Use this file to discover all available pages before exploring further. | ||
|
|
||
| # Extend Claude with skills |
There was a problem hiding this comment.
Restore real docs caught by the cleanup
This page is being removed even though it is a real markdown doc: the deleted content starts with # Extend Claude with skills, and the <!DOCTYPE html> match is only a code sample hundreds of lines later. The same pattern applies to the other non-kotlin deletions (troubleshoot-install.md, the prompt-library pages, the cookbook notebook, the two GitHub skill files, and content/mcp/extensions/apps/build.md), so this cleanup drops valid Claude Code/MCP/prompt docs from the repo instead of only removing soft-404 shells. Please restrict the cleanup to files whose leading bytes match the new looks_like_html() predicate.
Useful? React with 👍 / 👎.
| self.stats["failed"] += 1 | ||
| return { | ||
| "url": url, "status": "failed", | ||
| "error": "upstream returned HTML, not markdown (soft 404)", |
There was a problem hiding this comment.
Fail the scheduled fetch when the HTML guard fires
In full-fetch mode, this new soft-404 branch only increments self.stats['failed'] and returns a failed result; fetch_all() saves/prints those failures but never exits nonzero, and the scheduled workflow's fetch step is just uv run scripts/fetcher.py (.github/workflows/fetch-claude-docs.yml lines 45-48). So when platform serves HTML for a doc path, the file is skipped but the run and failure alert still stay green, preserving the invisibility called out by this fix; make the all-docs path exit nonzero when any fetch fails.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
This PR updates the documentation fetcher to prevent “soft-404” HTML (HTTP 200 app-shell pages) from being written into .md outputs, and removes previously committed poisoned files so incremental fetches can retry those URLs cleanly.
Changes:
- Add an HTML sniff (
looks_like_html) that checks the first 512 bytes of a response for<!doctype html/<html. - Treat HTML responses as failures and skip writing the output file for both platform docs and GitHub
.mddownloads. - Delete a set of previously committed poisoned files so they don’t remain permanently in incremental mode.
Reviewed changes
Copilot reviewed 9 out of 54 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/fetcher.py | Adds HTML sniffing and failure handling to avoid writing soft-404 HTML into markdown outputs. |
| content/mcp/extensions/apps/build.md | Deletes a previously committed poisoned doc output so it can be retried cleanly. |
| content/github/skills/skills/algorithmic-art/SKILL.md | Deletes a previously committed poisoned GitHub-sourced markdown file. |
| content/github/claude-plugins-official/plugins/mcp-server-dev/skills/build-mcp-app/SKILL.md | Deletes a previously committed poisoned GitHub-sourced markdown file. |
| content/github/claude-cookbooks/coding/prompting_for_frontend_aesthetics.ipynb | Deletes a previously committed poisoned GitHub-sourced notebook. |
| content/en/resources/prompt-library/website-wizard.md | Deletes a previously committed poisoned prompt-library page. |
| content/en/resources/prompt-library/cosmic-keystrokes.md | Deletes a previously committed poisoned prompt-library page. |
| content/en/docs/claude-code/troubleshoot-install.md | Deletes a previously committed poisoned Claude Code doc page. |
| content/en/docs/claude-code/skills.md | Deletes a previously committed poisoned Claude Code doc page. |
Suppressed comments (1)
scripts/fetcher.py:216
download_github_file()has the same incremental-mode issue asdownload_doc(): if a previously written file is HTML, incremental mode will skip it forever and the new sniff won’t run. Additionally, when an HTML response is detected, returning early without removing an existing output file can leave poisoned content in the tree.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| """True if the body is an HTML page rather than the markdown we asked for. | ||
|
|
||
| platform.claude.com answers unknown doc paths with its Next.js app shell at | ||
| HTTP 200 — a soft 404. raise_for_status() sees nothing wrong, so without | ||
| this check the shell gets written straight into a .md file. That is how 53 | ||
| files, 44 of them under content/en/api/kotlin/, ended up holding | ||
| "<!DOCTYPE html><html class=..." instead of documentation, across three | ||
| separate bug reports (#669, #768, #941) while the scheduled run stayed | ||
| green. | ||
| """ |
| output_path = self.get_output_path(url) | ||
| if self.incremental and output_path.exists(): | ||
| self.stats["skipped"] += 1 | ||
| return {"url": url, "status": "skipped"} | ||
| try: |
Closes #941. Closes #768. Closes #669.
The state of the repo right now
53 files contain
<!DOCTYPE html><html class=...instead of documentation —44 of them under
content/en/api/kotlin/. Meanwhile the scheduled fetch hasreported success five times a day, every day.
Why
platform.claude.comanswers unknown doc paths with its Next.js app shell atHTTP 200 — a soft 404.
fetch_bytes()callsraise_for_status(), whichonly catches real error codes, so the shell was written straight into the
target
.md.Two things kept it invisible:
asks whether the bytes are markdown.
if self.incremental and output_path.exists(): skipmeans a file poisoned once is never re-fetched.It just stays wrong.
That is why the same defect was reported three times months apart — #669 in
February, #768 in March, #941 in May — and why the symptom appeared to "go
away" each time: upstream eventually published some of those docs, so new
paths stopped breaking while the already-poisoned files sat untouched.
The fix
looks_like_html()sniffs only the first 512 bytes for a leading<!doctype html>or<html>. Checking the start is the point — a genuine docmay discuss HTML or embed an html code block, and neither should trip it:
When it fires, the fetch is recorded as
failedand no file is written, sothe path stays absent and is retried next run rather than being skipped
forever. Applied to platform docs and to GitHub
.mdfiles.Also deletes the 53 poisoned files
They have to go, not just be guarded. Incremental mode would otherwise skip
them forever and the new guard would never get a chance to run. Deleting lets
the fetcher retry each path; if upstream is still serving HTML, the guard now
declines to write instead of re-poisoning.
Note on the reports
#669 and #768 proposed URL-shape fixes (don't append
.mdto platform URLs).That framing was incomplete — the
.mdsuffix works for the paths that exist.The actual defect is trusting a 200 without looking at the body, which is why
it kept recurring after each URL-level patch.
🤖 Generated with Claude Code