Skip to content

Make the content commands do what the guide said they did - #118

Merged
tamnd merged 1 commit into
mainfrom
content-signals-guide-110
Aug 12, 2026
Merged

tamnd merged 1 commit into
mainfrom
content-signals-guide-110

Conversation

@tamnd

@tamnd tamnd commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Closes #110.

The content signals guide documented a command that does not exist. content quality - was shown reading a JSONL stream and there was no stream form. The output table listed lang, lang_confidence, text_length, link_density, boilerplate_ratio and readability_score, and QualityReport emits none of those. A --warc flag was documented on three subcommands and a --lang flag on quality, and neither was ever registered. Every pipeline on the page failed.

The done-when list asked whether the streaming form is worth having. Two of these are: scoring one URL at a time is a spot check, and filtering a corpus is the reason the signals exist at all. So the form exists now rather than being deleted from the page.

What changed in the binary

All four content commands take - in place of the URL and read a list from stdin. A line is either a bare URL or a JSON object with a url field, which is what search, columnar and crawl fetch already write with -o jsonl, so a query feeds a content command with no glue in between.

$ ccrawl content outlinks https://go.dev/blog/ -o jsonl -n 3 | jq -r .url | ccrawl content quality - -o jsonl
{"url":"https://go.dev/","word_count":833,"title_length":27,"has_main_content":true,"spam_score":0.1,"is_parked":false}
{"url":"https://go.dev/solutions/case-studies","word_count":1415,"title_length":42,"has_main_content":true,"spam_score":0,"is_parked":false}
{"url":"https://go.dev/solutions/use-cases","word_count":131,"title_length":39,"has_main_content":true,"spam_score":0,"is_parked":false}

A URL that cannot be fetched is named on stderr and the rest of the list carries on, because any real list of seeds has a few dead hosts in it and losing the other nine hundred to them helps nobody:

$ ccrawl content quality - -o jsonl < seeds.txt
{"url":"https://go.dev/blog/","word_count":302,...}
{"url":"https://example.com/","word_count":19,...}
fetch https://nx.invalid/: dial tcp: lookup nx.invalid: no such host, skipping it
1 of the 3 URLs on stdin could not be fetched
exit 0

The exit codes follow the rule the last release settled on. A run that scored at least one page exits 0. A run where every URL failed exits 1, because nothing was learned about those pages, they were never seen. Empty stdin exits 3, which is what dropping Single from these ops buys. A single URL that fails still exits 1.

content outlinks now carries the source URL on every row. One page at a time it was redundant; over a stream the rows of a hundred pages land in one file and an edge with no tail is not an edge.

- is refused under serve, api and mcp. The same handler serves all three surfaces, and stdin there is the JSON-RPC session or the operator's terminal, never the client asking for the URL.

What changed in the docs

The guide is rewritten against the binary. Every field table is the fields the command emits, with what each one measures rather than what its name suggests: spam_score is sixteen English sales phrases at a tenth each and capped at 1, is_parked is a regex on a page under 150 words, has_main_content is fifty words. It says outright that these are blunt, that spam_score reads English only, and that a page scoring 0 is not thereby clean.

Every pipeline on the page was run before it was written down. The stored-WARC section now points at ccrawl parse, since the content commands fetch and do not read archives, and says plainly that scoring a stored capture means refetching the page as it is today, which is a different thing.

The CLI reference had content outlinks down as emitting anchor text, which it has never done. Fixed, along with the quality field list, and the - form is documented there too.

While checking what these commands need, the requirements page turned out to claim crawl fetch reads robots.txt first. It only does with --robots; crawl run is the one that checks by default. That page now says which commands check it, which do not, and that a long list on stdin is what crawl run is for.

Tests

cli/content_cmd_test.go stands two pages up on localhost and drives the commands against them, so the single URL path, the stream, the skipped dead URL, the all-failed error, the empty result, the JSON line with a url field and the resolved relative link are covered without touching the network. One test runs a real search against the fake Common Crawl and feeds every row through the stdin parser, because the documented pipeline rests on a contract between two commands rather than on either of them.

cli coverage 50.7 to 52.3 percent, floor raised to 52. scripts/docs-drift.sh passes.

Done when

  • every field in the guide is a field the command emits
  • every pipeline in the guide runs, and each one was run before it was written down
  • the streaming form was worth having, so it exists

The content signals guide documented a command that does not exist. It showed
"content quality -" reading a JSONL stream, and there was no stream form. It
listed six output fields, lang, lang_confidence, text_length, link_density,
boilerplate_ratio and readability_score, and the command emits none of them. It
documented a --warc flag on all three subcommands and a --lang flag on quality,
neither of which was ever registered. Every pipeline on the page failed.

Two of those were worth fixing rather than deleting. Scoring one URL at a time
is a spot check, and filtering a corpus is the reason the signals exist, so all
four commands now take "-" and read a list from stdin. A line is either a bare
URL or a JSON object with a url field, which is what search, columnar and crawl
fetch already write with -o jsonl, so a query feeds a content command with no
glue in between. A URL that cannot be fetched is named on stderr and the rest of
the list carries on, because any real list of seeds has a few dead hosts in it.
A stream where every URL failed is an error rather than an empty result, the
same distinction that keeps a search that could not read the index off exit 3,
and empty stdin is exit 3, which is what dropping Single from these ops buys.

content outlinks now carries the source URL on every row. One page at a time it
was redundant; over a stream the rows of a hundred pages land in one file and an
edge with no tail is not an edge.

"-" is refused under serve, api and mcp. The same handler serves all three
surfaces and stdin there is the JSON-RPC session or the operator's terminal,
never the client asking for the URL.

The rest is the docs matching the binary. Every field table is now the fields
the command emits, with what each one actually measures: spam_score is sixteen
English sales phrases at a tenth each, is_parked is a regex on a page under 150
words, has_main_content is fifty words. Every pipeline on the page was run
before it was written down. The requirements page said crawl fetch reads
robots.txt first, which is only true with --robots, and now says which commands
check it and which do not, the content commands being the ones that do not.

Tests stand two pages up on localhost and drive the commands against them, so
the stream form, the skip, the all-failed error, the empty result and the
resolved relative link are all covered without touching the network. One test
runs a real search against the fake Common Crawl and feeds every row to the
stdin parser, because the documented pipeline rests on a contract between two
commands rather than on either of them. cli coverage 50.7 to 52.3.

Closes #110
@tamnd tamnd added this to the v0.10.1 milestone Aug 12, 2026
@tamnd
tamnd merged commit b4898da into main Aug 12, 2026
7 of 8 checks passed
@tamnd
tamnd deleted the content-signals-guide-110 branch August 12, 2026 08:11
tamnd added a commit that referenced this pull request Aug 12, 2026
docs-drift reads the requirements page with a hardcoded list of the
commands it covers, because the page is organised by dependency and its
headings name no command to scope against. #118 added a paragraph about
robots.txt and neither crawl fetch nor crawl run was on the list, so
--robots and --no-robots had nothing to match and the check went red on
main.

Closes #122
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.

content-signals guide documents a content quality command that does not exist

1 participant