name: rankmath-product-listing description: Use when writing or auditing a WordPress/WooCommerce listing that must score 70+ in RankMath - product listings, focus-keyword choice, snippet fields, expanded descriptions, internal linking, hub and collective phrases ("greenhouse construction resources"), orphan pages, inbound links, or diagnosing a low RankMath score. Works on any site given as a base URL, e.g. https://shop.example.com. Triggers: RankMath score, product listing SEO, WooCommerce SEO, focus keyword, meta description, internal links, hub page, cluster links.
RankMath WooCommerce Product Listing
Overview
Two jobs, and only one of them is scored.
- Make RankMath happy. The six inputs it actually reads, plus the body-copy spec that moved a live page from 41/100 to 84/100. RankMath scores fields, not prose: a 1,000-word page with empty snippet fields scores worse than a 700-word page with them filled.
- Make the page reachable. Every collection the copy highlights must resolve to real links, and the page itself must be linkable from inside the platform. RankMath does not score inbound links or hub existence. Steps 3b and 4 change Google's view of the page, not the RankMath number. Do not expect a score bump.
Nothing here is hardcoded to one site. Give a base URL; the host, the URL inventory, the sitename budget, and whether WooCommerce exists are all derived from it (Step 0). The one worked example at the end is anonymized - an illustration, never an input.
Step 0 - Derive the platform from the base URL
Every job starts with a base URL, e.g. https://shop.example.com or https://blog.example.com. Derive these; never assume them.
| Fact | How to derive it |
|---|---|
| Is it WooCommerce? | curl -sL "$BASE/wp-json/wc/store/products?per_page=1" - 200 means yes, rest_no_route means posts and pages only. Do not assume a /product/ URL structure exists |
| What content types exist | curl -sL "$BASE/wp-json/wp/v2" then list the route names. Custom types (farm, produce) show up here and must be included |
| Full URL inventory | scripts/build_link_inventory.sh <base> - it paginates every collection through x-wp-total and writes _audit/inventory/<host>/{nodes,edges,nav,meta}.tsv |
| Sitename append length | Fetch 3 live pages of the same content type as the page you are writing, take the longest common suffix of the <title> values, count its characters. That is the budget to subtract. A suffix of 0 chars does not mean "no append" - it means the templates differ, so re-measure within one type |
| Which hrefs count as internal | Host equals the derived host, or the href is relative / protocol-relative. Anything else is external |
| Which nodes are editorial | Read the node kinds out of nodes.tsv. If the site exposes navigation/menu-like types, their "body" is the menu, so they are chrome, not writing - exclude them from inbound and cluster counts (see H5) |
curl needs -L everywhere. WordPress sitemaps and REST routes 301 once Rank Math takes over, and curl -s without -L returns an empty body, which is how phantom URLs get invented: an empty list looks like "no such page", not like "fetch failed".
Step 1 - Focus keyword
- One keyphrase per page, unique across the whole shop. Free RankMath scores a single keyphrase; a second page on the same phrase cannot rank.
- The checkers count the keyphrase with
grep -oiF— a fixed string — so a hyphen in the copy breaks the match.10-hectare greenhousedoes NOT count toward the keyphrase10 hectare greenhouse. Witnessed 2026-10-14: an article about a 10-hectare project, house-style hyphenated, would have scored density off a phrase it never literally contained. Decide at Step 1 which spelling the copy will use everywhere, write it unhyphenated when the keyphrase is unhyphenated, and say so in the pack so a later copy edit does not "fix" it back. - The keyphrase must appear contiguously.
HDPE Female Teesdoes NOT match the keyphraseHDPE tee. This single mistake costs more points than any other check. - Prefer a phrase already inside the live slug. Never change a published slug to match a keyword; that loses existing ranking and links.
- RankMath's "URL is long" check counts the WHOLE permalink, not the slug. Witnessed on a page whose slug is 41 chars in 6 words:
https://+ host = 37, +/+ slug + trailing/= 80, and the panel printedURL is 80 characters long- the number matching the full URL rather than the 41-char slug is the proof of what it measures. Target: 75 characters or fewer (owner-supplied from the panel, 2026-10-09). Sousable slug ceiling = 75 - host overhead, and on that 29-char host the ceiling is 36 chars. Compute the host's overhead once per site and state it in the pack. - On a long-brand domain this check collides with keyword-in-URL, exactly as the title length rule collides with a long keyphrase. A keyword-exact phrase longer than the ceiling cannot both stay contiguous in the slug and pass length. Default on a published page: record the length RED as accepted, never rename. A keyword-exact slug is one of the few permalink items that scores; shortening an advisory line forfeits ranking, inbound links, and often creates a collision-suffixed duplicate. If the owner prefers the shorter URL, name which check they bought and which they gave up, and handle it as a site-wide permalink policy - count the affected nodes from the inventory (on that host 22 of 83 URLs exceeded 75, all keyword-exact or county-named) instead of editing one page.
- Read the focus keyword already saved in the page before writing anything, and keep its exact form, including singular or plural. Rewriting
HDPE elbowsasHDPE elbowdropped one live page from 73 to 37. - When the admin is unreachable, the panel's density line recovers the stored phrase - do not guess it from the slug. One panel line, "Keyword Density is 0.15 which is low, the Focus Keyword and combination appears 2 times", is two unknowns and one equation: the denominator is
uses / (density/100), so2 / 0.0015= ~1,333 words. Count the contiguous occurrences of each candidate phrase in the live body and keep the one whose count reproduces the panel's number. Witnessed 2026-10-13 on/installation-of-pop-up-sprinklers/: the slug-derived guesspop up sprinklers(17 chars) measured 10 uses / 0.74%, whileinstallation of pop up sprinklers(33) measured 2 uses / 0.15% - the panel's figure to the decimal. The stored phrase was the long one, and the inference had already cost the pack its title budget, its density target and its H2 rule. - A long stored phrase changes which checks are satisfiable, so re-price every downstream field before recommending one. A 33-char keyphrase plus a 30-char sitename append cannot produce a compliant title at all; the slug-derived 17-char phrase can. Never paste a title built on the short phrase onto a page storing the long one - RankMath prints "Focus keyword not found in your title".
- Plural text satisfies a singular keyphrase, because
HDPE elbowscontainsHDPE elbow. The reverse is false. When unsure, write the plural form in the title, the H1, and the first sentence. - When the natural long keyphrase cannot fit the title window, store its shorter contiguous tail. Witnessed 2026-10-14 on
high volume low pressure irrigation(35 chars) against a 30-char sitename append, which leaves a typed window of 15-30: the long phrase makeskeyphrase in titleandtitle lengthmutually unsatisfiable before a single word is written. Storinglow pressure irrigation(23) costs nothing in copy, because containment runs that way - every long-form mention still contains the stored phrase, so it counts as a use. Prove the choice, do not argue it: score the SAME finished file twice, once per phrase. Measured on one draft: short = density 0.8%/15 uses and 5 of 14 H2s (both PASS), long = 0.2%/4 uses and 2 of 14 (both RED). The density gap is the arithmetic, not style - a 6-word phrase needs ~10 repetitions to clear 0.5% and reads as spam. State the numbers for both in the pack and let the owner see why. - Before assigning, check the site's focus-keyword map (default
./rankmath-focus-keywords.md, override withRANKMATH_KEYWORDS=). If the page sits in a duplicate cluster, resolve that first; new copy will not help. - When the requested keyword is already claimed by another page, do not write the listing on that phrase and do not quietly swap in a near-synonym. Report the collision - the phrase, both URLs, and the owner's actual options (301 the weaker page, differentiate one page's angle, or move the phrase) - and write only after that is decided. Swapping
HDPE elbowstoHDPE elbowto dodge a collision is the same regression as bullet 4, just chosen for the wrong reason.
Step 2 - The field pack
Fill every one of these. They are the difference between the 40s and the 80s.
| Field | Rule | Where |
|---|---|---|
| Product name (H1) | Keyphrase contiguous, near the front | WooCommerce product title. Re-check the slug afterwards, renaming can rewrite it |
| SEO title | Total rendered length 45-60 chars after the sitename append, keyphrase in first 5 words, plus the number / power word / sentiment trio priced by check_title.cjs | RankMath, Edit Snippet, Title. See the sitename trap below |
| Meta description | 120-160 chars, keyphrase in first 5 words, ends with a click reason | RankMath, Description. If empty, RankMath silently falls back to the short description. That is the most common hidden failure |
| Short description | 2-3 sentences, keyphrase in sentence one, must not repeat the long description opening | WooCommerce "Product short description". Feeds the auto meta, the excerpt check, and the text above the tabs |
| Featured image alt | focus keyword + a market qualifier, e.g. " for sale in kenya" | Media library |
| Body | See spec below | WooCommerce description, Text (HTML) tab |
Check whether an image exists before promising an alt. curl -sL "$BASE/wp-json/wp/v2/media?search=<topic>&per_page=20" is public on most WordPress installs and returns source_url and alt_text for each hit. Witnessed 2026-10-13 on /installation-of-pop-up-sprinklers/: pop up, popup and lawn returned 0 items, sprinkler returned two rain-gun photos - a different product from a buried lawn head. The correct pack row was therefore "no compliant featured image exists; upload a real site photo", not an alt string written for a picture nobody has. Never invent an image, and never recycle a same-category-but-different-product photo just to turn a check green.
The sitename trap
RankMath appends a sitename template to whatever you type in the Title field, so the field length and the rendered length differ. Measure the append on the target site before budgeting the title: fetch three live pages of the same content type and take the longest common suffix of their <title> strings. Its character count is what the template costs you; typed budget = 60 minus that number.
Two measured failure modes, so treat the number as per-type and never as a site constant:
-
Mixed templates collapse the suffix to 0 chars. On one live shop, products render
… - Example Online Shopwhile posts are broken (next bullet), so a cross-type sample finds no common suffix at all. That reads as "free budget" and is wrong. Sample within the content type you are writing. -
A template can be broken and render literally. The shop's post
/greenhouse-construction/titles itselfGreenhouse Construction: Framing & Netting Installation - %s- an unsubstitutedprintfplaceholder. When the suffix looks wrong, read the raw<title>of the exact page before trusting any budget. -
Three same-type pages can show three different appends. The template only governs pages whose RankMath title field is empty; on any page where someone typed a title, that string renders verbatim - including a wrong brand. Four products on that shop measured 23, 29, 11 and 0 chars of suffix, one of them carrying the corporate sitename. So per-type sampling gives you the template, not the page. Budget against the page you are writing: fetch its own
<title>and diff it against the field content.
Example: on that shop the product template appends 23 chars, so a 48-char typed title renders at 71 and fails the length check - but only for pages still using the template. See the worked example. If the append is long enough to starve every title, the structural fix beats working around it: shorten the template under Rank Math, Titles & Meta, Products.
Title Readability needs three separate things
RankMath scores the title against three independent checks, and they are not interchangeable. All three are now testable locally: scripts/check_title.cjs is a line-by-line port of assets/admin/js/analyzer.js, and check_listing.sh prints them as three more scored gates.
- a number - the plugin's rule is literally
/\d+/.test(title). Any digit counts:90 Degree,4 Pumps, a bare10. Never invent one. - a power word - the lowercased title is split on spaces and each word is matched against
rankMath.assessor.powerWords(the PHP listassets/vendor/powerwords/en.php, 1,103 entries) asword === entryorstartsWith(word, entry)with at most 4 extra characters. Verified members:best, easy, proven, affordable, amazing, excellent, essential, ultimate, free, price, quality, expert.topis not in the list - it only carries sentiment, soTopnever cleared the power check no matter what this skill used to say. - a sentiment word - the bundled
sentimentlexicon (9,196 labelled words once RankMath's add-on andextrasare merged) is summed over the title's tokens, with the sign flipped when the previous token is a negation (not,dont,isnt, ...). PASS iff the total is non-zero, so a positive and a negative can cancel out and print RED.
One word can clear checks 2 and 3 at the same time, and that rewrites the budget arithmetic. best is a power word and sentiment +5; so are easy (+4), proven (+2), affordable (+2), amazing (+4), excellent (+5); cheap is a power word and negative −3. Witnessed 2026-10-14: a pack pronounced power and sentiment unavoidable REDs for a 21-char keyphrase inside a 15-30 typed window - "9 characters left, the trade is bought" - and recommended 10 Hectare Greenhouse Costs. Best 10 Hectare Greenhouse is 26 typed → 56 rendered and takes all four gates green: five of those nine characters were enough. Never write "jointly unsatisfiable" into a pack until the double-duty words have been run through the checker.
RETRACTION: the line this skill used to carry - "a title containing only Best Price failed all three checks, so Best is not doing the work" - is contradicted by the port, which scores Best Price as power PASS and sentiment PASS (+5), and by the corrected top finding above. It was written from a panel reading, not from the plugin, and the two cannot both be right. Until someone re-reads that panel, do not use it to justify dropping a word from a title; HDPE Elbows: 90 Degree, Top Quality is kept here as a worked example because the port confirms it (number PASS via 90, power PASS via quality, sentiment PASS score 6).
When the budget cannot hold the keyphrase plus all three - and only after the double-duty words above have been priced - the rules are jointly unsatisfiable, so drop in this fixed order and say which you dropped:
- the sentiment word,
- the number - only if a real figure exists elsewhere in the meta to carry it,
- the power word, last.
Dropping one is a prediction of a specific RankMath error, and the owner sees it the moment they paste. The panel prints the literal line "Your title doesn't contain a positive or a negative sentiment word." (same shape for the number and power word). So the drop must be written in the same table row as the title it applies to, never in a paragraph further down - and never as a bare "PASS". Witnessed 2026-10-09: a pack labelled Greenhouse Construction in Kenya: Costs as "the only option that passes today" with the note "no power word". It passed the length check only; it fails number, power and sentiment. The owner pasted it, got the sentiment error, and reasonably read it as a skill miss. The rule was captured; the reporting shape was wrong.
"PASS" is per-check, so name the check. Write length PASS / sentiment RED, not PASS. A green length bar and three readability errors appear on the same screen at the same time; a title that is compliant on the one axis the local script measures is not a passing title.
- Every title string you emit carries its rendered length, including one-off fixes for a page you are not writing. Witnessed the same day: a pack correctly priced its own five title options, then recommended
Pumps in Parallel: 4 Pump Sizingas the fix for another page's 90-char title. That string is 32 typed, so it renders 62 - the same RED it was sent to cure. A suggestion withouttyped + append = renderedis an unmeasured claim. Count gates from the script output too:check_listing.shprints 18 scored gates as of 2026-10-14 (the title trio was added), so a pack that quotes15/15is reading an older run - take the number from the output you just got, not from this file.
Never truncate or split the keyphrase to fit: a broken contiguous match costs more than a missing power word. If all four still cannot fit, the template is the problem, not the title - shorten it under Rank Math, Titles & Meta.
Never paste HTML comments into the description. They can become the first paragraph and break the "keyphrase in first 10 percent" check.
Step 3 - Body copy spec
RankMath thresholds to clear:
- 600+ words; aim 900-1100
- Open the description with an H2 carrying the exact product name, so the body matches the H1 and the snippet title
- Keyphrase in the first sentence
- Keyphrase or partial match in 5 or more H2s (or 30%+ of the H2s a page actually has, which is what a short page can satisfy), one H2 every 250-300 words
- Density 0.5-1.5 percent, roughly one use per 90 words
- A table of contents that repeats each subheading verbatim raises exact-phrase density for free, and breaks no other gate. Measured 2026-10-13 on a 1,349-word draft: a 10-item Contents list of
<a href="#slug">links took the contiguous keyphrase count from 2 to 4 (0.15% -> 0.3%) and the word count from 1,349 to 1,432. The checker's internal-link gate ignores same-page anchors (it printed8 (want 3+) [abs 8 / rel 0 / 10 same-page anchors not counted]), and sentences/transition ratios move by ~1 point, so the list neither inflates the link count nor trips the prose gates. It buys density, it does not clear it - long phrases still need real prose repetitions. A block-generated Contents list is not in the draft, so a draft converted withrm_toc.cjs --drop-tocscores its density and word count without it (measured on the HVLP draft: 1,949 -> 1,881 words once the 12-item list went). The saved post does contain it - the block is static - so the live page scores higher than the draft, never lower. - The TOC is a scored check, so both checkers print a
table of contentsgate. Use Rank Math's own block, not a hand-written list. The mechanism was read out of the plugin source on 2026-10-14 (includes/modules/schema/blocks/toc/), which settles what earlier packs could only guess:block.json- block namerank-math/toc-block, inserter title "Table of Contents by Rank Math", attributestitle,headings[],listStyle,titleWrapper(defaulth2),excludeHeadings.utils.js:136- inserting the block writes ananchorattribute onto every heading block for you. That is the whole reason 8 of this host's own posts have<h2 class="wp-block-heading" id="...">and the three that were hand-pasted had none.utils.js:23- a heading with no anchor getskebabCase(heading text), lodash rules, not WordPress slugs. Live fingerprints:pto-driven-chaff-cutter,fa-qs-on-greenhouse-construction-in-ol-kaloufrom "FAQ's on…", and on post 5279a-four-pump-example-at-400-m-per-hourfrom a heading reading "400 m³ per hour". Non-ASCII units are dropped, not transliterated -³,µ,°simply vanish, so an anchor you quote for a deep link must be read off the live page, never derived from the heading text by eye. Odd-looking is not broken.utils.js:15- an existing anchor always wins over the generated one.utils.js:130- a repeat becomesanchor-2,anchor-3.save.js- it is a static block storing aheadingsarray, and it returns nothing when that array is empty. So never hand-author a<!-- wp:rank-math/toc-block … -->comment into a draft: WordPress will call it unvalidated. The owner inserts it with two clicks; the script below only has to get the heading anchors right.
scripts/rm_toc.cjs <draft.html> [--drop-toc]does the conversion (added 2026-10-14). It rewrites each heading as<!-- wp:heading {"level":2,"anchor":"x"} -->plus the exact markup core serialises, keyed off the id the draft already carries, and with--drop-tocreplaces the hand-written Contents heading and its in-page list with anRM-TOCmarker line. Run it on every draft that ships a Contents list, and put this in the runbook: paste the converted file in the Code editor, click the marker, switch to the Visual editor, insert Table of Contents by Rank Math, delete the marker, publish. It replaces a per-heading editing session that was being skipped three posts running.check_draft.shprintsPEND, neverPASS, for a TOC it cannot prove.PENDmeans "this is not settled by the local file" - either the block placeholder is present, or the list's ids are free HTML that WordPress will strip on import.check_listing.shtags a real one as[Rank Math toc-block]: measured/chaff-cutter-operations-in-kenya/4 anchor links, 0 dead [Rank Math toc-block],/greenhouse-construction-in-ol-kalou/8, 0, and post 5290 after the owner's fix20, 0.- WordPress still strips a hand-written
idattribute when free HTML is imported as heading blocks, which is why the draft passed and the live page printed10 anchor links, 10 dead- every Contents link going nowhere. If you must set anchors by hand instead of using the block, the field is Advanced -> HTML anchor, and it only appears with a heading block selected in the Visual editor; the Code editor shows no block sidebar at all, which is how an owner came to report the field as missing on 2026-10-14. Say switch back to the Visual editor in any runbook step that uses it. - This recurs even when the runbook prints the step. Third time witnessed 2026-10-14: post 5290 was published from a draft that passed the TOC gate, with a pack whose step 7 listed all twelve anchor values, and the live page still renders
12 anchor links, 12 dead(verified: 12href="#..."in the article, 0idattributes, 0 targets that resolve). Two causes, both fixable in the pack's shape, not by repeating the instruction louder:- Ordering. An anchor table buried at step 7 reads like a later cleanup task. Put the table in the same step as the paste, under a line saying do not leave the editor until every heading has its anchor - the payoff is invisible in the editor, so a step that looks optional gets skipped.
rm_toc.cjsremoves the need for either. - The trade is never named. Dead anchors are a user-visible defect: a reader clicks a Contents line and nothing happens. A page with N dead links is worse for that reader than a page with none, and RankMath's TOC check is not worth shipping a broken UI. So offer the real choice up front: fill the N anchors, or delete the Contents list and accept that one RED. Decide it in the pack, in writing, before publishing; do not leave a broken TOC as the default outcome of nobody choosing.
- Ordering. An anchor table buried at step 7 reads like a later cleanup task. Put the table in the same step as the paste, under a line saying do not leave the editor until every heading has its anchor - the payoff is invisible in the editor, so a step that looks optional gets skipped.
- The values are case-sensitive and must match the
hrefstrings already in the list - so when the draft is the source of those strings, hand over the exact(heading text -> anchor)pairs from the draft's ownidattributes, never a re-derived guess.rm_toc.cjsprints that table for the pack, including any-2collision suffix it added.
- A table of contents that repeats each subheading verbatim raises exact-phrase density for free, and breaks no other gate. Measured 2026-10-13 on a 1,349-word draft: a 10-item Contents list of
- 25 percent or fewer sentences longer than 20 words
- 25 percent or more sentences carrying a transition word. The exact list both checkers score against is
scripts/transitions.txt(Also, In addition, Meanwhile, However, For example, First, Next, Therefore, Finally and about 25 more) - edit that file rather than letting the prose and the code drift apart again. - Under 10 percent passive voice
- Paragraphs of 120 words or fewer
- 3 or more internal links, 1 external authoritative link. A 200 status does not make a citation valid. Fetch the external page and grep its text for the term you are attributing to it, then write the anchor from what the document actually says. Witnessed twice on the same job: an FAO URL that returned 200 and was
3. THE HYPOTHALAMUS, and - 2026-10-13 - a FAO Paper 56 chapter fetched to back a sprinkler-uniformity claim that held 0 occurrences of "sprinkler" and 11 of "reference crop evapotranspiration". The honest anchor was the ET title, and the claim was narrowed to the water-need figure it does support. Cite what the page says, not what its URL looks like. - FAQ block, 5-6 questions, keyphrase in the FAQ heading and in one question
Two of the rules above still have no checker - verify them in RankMath's panels, not in the scripts: under 10% passive voice, and one H2 every 250-300 words (the scripts count H2s, never their spacing). The Title Readability trio now has one: scripts/check_title.cjs, and check_listing.sh scores it against the live <title>. The full coverage table is in Resources.
A gate that reads across a tag boundary can be satisfied by somebody else's markup. Found 2026-10-13 while adding the TOC check: check_draft.sh built "keyphrase in first paragraph" from the text after the first <p up to 500 bytes, with no stop at </p>. The draft printed PASS keyphrase in first paragraph while its real opening sentence read "Installing pop up sprinklers is the surest way..." - the match came from the Contents list that followed, whose items repeat the H2 wording. Both checkers now cut the record at </p>. Consequence for any pack written before this fix: a first-paragraph PASS is only trustworthy if the intro sentence itself holds the phrase. Re-read the printed paragraph, not just the flag.
Both prose gates share one denominator, and the sentence counter is block-blind. The sentences over 20 words and transition words gates divide by the same sentence count, which is produced by splitting flattened text on .!?. Two effects follow, measured on the parallel-pump draft 2026-10-13:
- Splitting long sentences to clear the length gate raises the sentence count and therefore lowers the transition percentage. That draft went 36% long / 35% transitions, and after cutting the long sentences read 12% long / 24% transitions - one point under the gate. Fix: add transition words in the same edit that splits sentences, then re-run.
- Heading text, table cells and Contents items have no terminal punctuation, so each one merges with its neighbour into a single multi-word "sentence". The same file measured 36% flattened and 17% counted block by block (heading, list item, cell and paragraph separately). Read a RED on that gate as "check which of my sentences are real prose" before rewriting anything: the artifact rows are not defects, and RankMath's own sentence extraction may differ. The panel is the arbiter.
Copy pasted from a book, a PDF or a course export arrives in three shapes that must be converted before publishing - not pasted, not deleted. Write the conversion into the draft and name each change in the pack:
- LaTeX (
\[ Q_T=Q_1+Q_2 \],\boxed{},\sqrt{\frac{a}{b}}) prints literally in WordPress, which has no math renderer by default. Convert to plain text with unicode operators -total flow = 100 + 100 + 100 + 100 = 400 m³/hour,D = √(4 × 0.1111 ÷ (π × 1.2)) = 0.343 m- keeping every figure identical. - Interactive widget exports ("3 of 4", ON/OFF rows, slider states) are controls, not content. Replace with a static table whose values you can derive from the source's own numbers, and keep the source's caveat sentence next to it.
- Chapter numbering (
15.1,16,17) reads as an error on a standalone post. Drop the numbers, keep the order. - A caption that says a figure is omitted for clarity is not permission to invent one. Check the media library first (below), and if nothing exists, leave the sentence as prose.
Structure that works for irrigation fittings:
- H2 with the exact product name, then the intro with the keyphrase in sentence one
- What Is X?
- Uses, type A
- Uses, type B
- A vs B comparison table
- Specifications (sizes, material, pressure)
- How to Choose the Right X
- Quality Features
- FAQ
- Where to Buy X in Kenya, with phone numbers and CTA
Where the URL list actually comes from
Never guess a site URL, and never pull URLs from curl -s on a sitemap: those files now 301 to Rank Math's own sitemap index, so curl -s without -L returns an empty body and you get no URLs with no error. That is the mechanism by which phantom hubs get invented.
Build the inventory once per site instead:
bash scripts/build_link_inventory.sh https://example.com # writes ./_audit/inventory/<host>/*.tsv
It enumerates wp-json/wp/v2 routes, paginates every content type by x-wp-total, adds the Woo Store API when WooCommerce is present, and writes:
| File | Holds |
|---|---|
nodes.tsv | kind url name words - every product, post, page and taxonomy archive on the site |
edges.tsv | source_url target_url anchor - links found inside body content only |
nav.tsv | chrome links (menu, header, footer), flagged off-site. Excluded from inbound counts |
meta.tsv | base, generation date, node and edge counts |
nav.tsv is separate on purpose: menus are on every page, so counting them makes every page look well-linked. Inbound links only count when a human wrote a sentence containing the link.
Two parsing traps worth knowing if you write your own tooling: REST JSON escapes quotes, so unescape \/ and \" before grepping href="; and gawk treats a single-character | as a regex in split(), which silently shreds strings into characters - use commas.
Link to complementary parts, not the same part. A tee page links to couplings, adapters, reducer tees, and the drip category.
Step 3b - Collective phrases and cluster links
The rule: a highlighted collection must link each item, or link one article that lists and links them. There is no third option - the two arms are Branch B and Branch A, and the only thing outside them is a named item the site has no page for, which stays in the copy and is reported (see Retain and report below).
Copy loves collective wording - "our greenhouse construction resources", "the full range of drip kits", "everything about solar pumping". Each such phrase is a promise to the reader that the platform holds a collection. check_draft.sh counts hrefs without resolving them, which is why a draft can print every check PASS while pointing at pages that do not exist. This step resolves the promise.
Trigger: any phrase implying a collection - resources, resource, guide, series, range, kits, solutions, hub, tips, how-to, everything about. Run H0 on every one, then H1-H5, before the sentence is allowed into the draft.
-
H0 - decide whether the phrase is a promise at all. Collective wording is also ordinary English, and judging a description as an unmet promise makes the writer delete accurate copy. A phrase commits the page only when one of these is visible:
- it is an anchor's own text (
<a href="...">greenhouse construction resources</a>), or - it is a heading (
<h2>/<h3>), or - its sentence sends the reader somewhere - see, browse, explore, check out, read, view, visit, shop, buy, order, compare, find, covers, includes, offers, supplies, stock, available, below, following, listed, "start with".
- None of the three: the phrase is a modifier, not a promise. Keep the wording, link nothing, judge nothing.
check_links.shprintsINFO MODIFIER, NOT PROMISEand does not count a RED. Witnessed on the corporate About page: "a Kenyan agricultural solutions company" and "a leading agricultural solutions provider in Kenya" triggered a fullRED PHRASE UNBACKED "agricultural solutions"with one member page, and the only correct fix was to stop judging the sentence. - H0 releases a phrase from judgement, never from a link check: a dead
hrefis stillRED HUB GAPwhatever the anchor text says.
- it is an anchor's own text (
-
H1 - resolve. Normalise the phrase to its significant tokens (stopwords and the collective nouns themselves are dropped -
resources,guide,range,tips,buy,price,kenyaare all stoplisted) and look for pages carrying them innodes.tsv. Matching reads the URL path and the page name only, never the domain.- A hub holds every token in its path and is mostly that path:
tokens / path_segments >= 0.4./greenhouse-construction/qualifies; a 6-segment article that happens to contain the words does not. - A member holds all but one token (all of them when the phrase has fewer than 3) in its path or its name, and is not itself a hub. A post merely titled with the phrase is a member.
- The thresholds live in
check_links.sh:match_nodes(). If you change them there, change them here. - A category archive is a hub by definition.
/product-category/.../pipe-fittings/exists to list and link every member, so it discharges the promise when the copy links it. Density cannot judge it - the path is padded with/product-category/, and that mis-scoring once told a writer to delete a true link to the shop's pipe-fittings category. Its own member links are rendered by the theme rather than written intopost_content, soedges.tsvholds none and H5 printsTHIN HUBfor it. Treat thatTHIN HUBas a blind spot of the inventory, not a defect of the archive.
- A hub holds every token in its path and is mostly that path:
-
H2 - hub exists → link it. Use the hub's own words as the anchor text, then run H5.
- H2b - the hub exists but is thin. A resolving hub is still the right link even at 300 words: the reader gets somewhere to go and the alternative is a second page competing for the same phrase. Run
check_listing.shon the hub URL, report its own reds as a separate job, and never mint a competing hub for a phrase that already has one.
- H2b - the hub exists but is thin. A resolving hub is still the right link even at 300 words: the reader gets somewhere to go and the alternative is a second page competing for the same phrase. Run
-
H3 - no hub → count the members already on the platform that cover the topic, then take one of two branches. State in the output which branch and why.
- Branch A - write the hub page. Taken when 3 or more real member pages exist and the phrase is not already a claimed focus keyword. One job then produces two deliverables: the listing you were asked for, plus a hub-page draft carrying the collective phrase as its own focus keyword, the full field pack, and an outbound link to every member found. This is the "one article that lists and links them" answer.
- Branch B - inline the list. Taken when fewer than 3 members exist, or the phrase is already claimed. Rewrite the sentence to name and link the individual pages that actually exist ("our greenhouse film, aluminium profiles and insect nets") and delete the collective framing. Nothing is promised that the platform cannot deliver.
- Zero matching pages, and the copy names nothing specific: the phrase goes away, because there is no list to keep - only a promise the platform cannot back.
-
H4 - hub exists only on another domain → cross-domain conflict; see that section. Report and stop.
-
H5 - cluster integrity report. For hub
Hand its member setM, walkedges.tsvboth ways and print exactly two lists:EXISTING-H -> mandm -> Hedges already present, with source URLs.OMITTED- the missing edges, listed for future consideration.
Only editorial edges count. WordPress exposes menus as a content type on some themes, so the inventory holds pseudo-pages like
/navigation/and/main-menu/whose "body" is the whole menu - on the corporate site/navigation/alone contributed 11 edges and made a page with one real inbound link look like it had three.check_links.shdrops rows whose source node kind isnavigation,categories,tagsorcategoryfrom every edge query and printsedges: N editorial; inbound counts you have not filtered that way are not evidence of reachability.
The skill reports omitted edges; it never schedules or performs live-page edits. Branch A still writes the hub's own outbound links, because that page is being created now.
Retain and report: named items with no page on the site
Branch B deletes a framing, never content the writer chose. When copy lists specific things - bullets, table rows, an enumeration in a sentence - classify each named item. The item stays in the copy in every case.
| Item situation | Finding | What changes |
|---|---|---|
| Links a real on-site page | no finding | nothing |
| An on-site page exists but the item is not linked | INFO LISTED UNLINKED | add the link |
| No page on the site at all | INFO LISTED NO PAGE | nothing in the copy - the site owes a page |
A missing page is a content gap, not a copy defect. Never delete a reader-useful list because the platform has not caught up with it; report the list and let the owner decide what to build. check_links.sh prints both findings and puts the counts in the VERDICT line; check_draft.sh and check_listing.sh stay silent on them.
An item qualifies as "named" when it carries no href, has no sentence-ending punctuation, and runs to 7 words or fewer - that is what separates a resource bullet from ordinary prose.
The claimed-phrase guard exists because free RankMath scores one keyphrase per page: minting a hub for a phrase already owned elsewhere creates a tenth duplicate cluster instead of fixing anything. check_links.sh refuses Branch A in that case and falls to Branch B.
Run it before publishing:
bash scripts/check_links.sh --draft page.html --base https://example.com \
--as https://example.com/the-page-url/ --phrase "greenhouse construction resources"
Findings: RED HUB GAP (an internal link that resolves to nothing), RED PHRASE UNBACKED (a collective phrase with no hub and no inline list), INFO MODIFIER, NOT PROMISE (H0 - the phrase is description, not commitment), INFO ORPHAN (zero inbound editorial links), INFO CLUSTER (the H5 report), INFO LISTED NO PAGE / INFO LISTED UNLINKED (the retain-and-report table), INFO CROSS-DOMAIN (nav sends the phrase off-platform). --url mode audits a live page instead of a draft.
Pass --phrase once per collective phrase you wrote. Without it the script only prints INFO PHRASE SPOTTED hints, which is a reminder to decide, not a verdict: hints are keyword-guesses over the copy and can name a phrase you never promised. A phrase passed with --phrase is the one that gets the RED/BRANCH treatment.
Declare a phrase only when the copy really sends the reader to a collection. A --phrase carrying no collective noun is judged as a hub promise, so declaring a service name produces a RED for a promise nobody made - witnessed twice on the corporate About page: "agricultural solutions" (a description of the company) and "borehole drilling in Kenya" (a service line, 2 member pages linked) both came back RED PHRASE UNBACKED, and both times the copy was correct. H0 covers the first automatically; the second is avoided by not declaring it.
RankMath does not score inbound links or hub existence. Step 3b changes Google's view of the page, not the RankMath number. Do not expect a score bump.
Branch A - hub page spec
The hub is a normal listing job with a different subject. It gets the whole field pack:
- Focus keyword: the collective phrase itself, verbatim, unique across the site.
- SEO title / meta / short description / alt: same rules as Step 2, measured sitename budget included.
- The body is the resource list. One H2 per member page, each H2 named after that member and each linking it once with descriptive anchors. Do not summarise a member you fail to link.
- State the count it covers in the intro ("these 7 pages cover ..."), so a future reader can tell when the hub has gone stale.
- FAQ block, 5-6 questions, per the normal spec.
- Deliver the hub as a draft with its field pack filled. Its outbound links to every member go in now, because this page is being created. The inbound edges (member pages linking back to the hub) are the
OMITTEDlist from H5: report them with their source URLs and stop. The owner applies them; the skill does not edit live pages, and the runbook in Step 4 tells them where to paste.
Cross-domain conflicts
When site chrome sends a phrase to another domain while an on-platform page also exists (verified case: a product-page nav anchor "Greenhouse Construction" pointing at the corporate site while the shop holds its own /greenhouse-construction/ post), the on-platform page has zero inbound links even though the anchor text appears on every page of the site.
This is the owner's routing decision, not an SEO fix. Report it and stop. Never edit a menu, nav, or live page. Report it actionably, with all three parts:
- the conflicting anchor label and both URLs (on-platform page, off-platform target);
- where to change it:
WP admin → Appearance → Menus → <menu> → <item>when the menu is dynamic; - if that screen does not hold the string, it is hardcoded in the theme header - say so and point at the Theme File Editor rather than guessing.
Step 4 - Verify on the live page
RankMath's editor score and Google's view can disagree. So can the page's own links. After publishing:
- Run
bash scripts/check_listing.sh <slug-or-full-url> "<focus keyword>"withRANKMATH_SITE=<base>. It reads the rendered HTML, so it catches fields RankMath reports as fine, and printsINFO inbound editorial links nfrom the inventory. Pass a full URL to audit a blog post or hub page instead of a product. - Run
bash scripts/check_links.sh --url <page-url> --base <base> --phrase "<each collective phrase>". A live page can pass every field check and still be an orphan. - Purge LiteSpeed Cache from the WP admin bar. Stale cache keeps serving the old meta description even after the field is correct.
- Re-check the score. If the Products list looks frozen, run Rank Math, Status & Tools, Tools, Re-Scorer.
Rebuild the inventory when check_links.sh prints INVENTORY_AGE_DAYS over 7, or right after publishing a page that should receive links - a stale inventory reports live pages as dead.
Step 5 - Score triage
| Band | Likely cause | Fix |
|---|---|---|
| 40s | Keyphrase not contiguous in the title, or SEO title and meta left blank | Field pack, Step 2 |
| 55-65 | Under 600 words, density off, keyphrase missing from the intro | Expand body, re-place keyphrase |
| 70-80 | Readability reds only | Sentence length, transition words, passive voice |
| Basic SEO all green but score low | Open Title Readability and Content Readability, the deductions are there and not in the body | Add a number or power word to the title, add a table of contents |
| Score DROPPED after a rewrite | The keyphrase form changed (singular vs plural), or the old title's number and power words were lost | Restore the stored keyphrase form in the title, H1 and first sentence, then put the number back and re-add a word that is both a power word and a sentiment word (Best, Easy, Proven) |
Before replacing any title that already scores well, read the stored focus keyword and the current title first, and keep every element that was passing. A rewrite that lowers a live score is a regression, not an improvement.
Output format
Deliver as a paste-ready block, in this order: slug, focus keyword, product name, SEO title with char count, meta description with char count, short description, image alt, then the description HTML.
- Slug is always stated, with the full URL, even though it is never a field to fill. Do not change a published slug; it carries the existing ranking and inbound links, and renaming a WooCommerce product can rewrite it silently.
- Focus keyword is always stated, and always tagged:
STOREDwhen it came from the RankMath editor or the keyword map,INFERRED (from slug + title + H1)otherwise. An untagged keyword reads as measured data and is how a guess ends up pasted over a live keyphrase. - SEO title with char count and its per-check status -
rendered N, length PASS/RED, and which of the number / power word / sentiment items it gives up, in that same line. The owner reads this row before the paragraph explaining it.
That HTML must begin with an H2 holding the exact product name, word for word as entered in the WooCommerce title field, before the intro paragraph. Flag anything not verified against the live site.
Cluster link report
Required final block, and the only place these facts appear:
- Phrase decision - one line per collective phrase in the copy: the phrase, the outcome and why. There are five outcomes, not two: MODIFIER (H0 - the phrase describes the business and never sends the reader anywhere, so nothing is owed and the wording stays), H2 (a hub already existed and the copy links it - no branch is taken), H2b (hub exists, reported thin), A (new hub page drafted), B (list inlined, framing deleted). Naming only "A or B" hides the common case, and skipping MODIFIER turns accurate company descriptions into fake defects.
- EXISTING - the two-way edges already present between a hub and its members, with source URLs.
- OMITTED - the missing edges, as a list for future consideration only. Reporting them is not a commitment to edit live pages, and the skill does not edit them.
- LISTED, NO PAGE - named items the copy keeps but the site has no page for, item by item. Stated as content gaps for the owner to decide on, never as a reason to cut the list.
- Reachability - inbound editorial link count for the page under audit, plus any cross-domain conflict with its WP admin location.
If Branch A was taken, the deliverables are two: the requested listing and the hub-page draft. Say so at the top.
Resources
Four scripts, all site-derived. Nothing is hardcoded to one shop: RANKMATH_SITE (or --base) is required, and the host, the URL inventory and the content types all come from it.
scripts/build_link_inventory.sh <base-url> [out-dir]- writesnodes.tsv,edges.tsv,nav.tsv,meta.tsvunder./_audit/inventory/<host>/. Run once per site, rebuild whenINVENTORY_AGE_DAYSexceeds 7.scripts/check_links.sh --draft <file.html>|--url <live-url> --base <url> [--phrase "<collective phrase>"] [--as <url>]- Step 3b. Resolves collective phrases, checks hub existence, cluster integrity, orphan status, retained-list gaps, cross-domain chrome. Counts editorial edges only: rows sourced fromnavigation/categories/tagspseudo-pages are dropped and the run printsedges: N editorial. Needs the inventory. Exits 1 when the VERDICT has any RED, so it can gate a run;INFOfindings never change the exit code.scripts/check_listing.sh <slug-or-full-url> "<focus keyword>"- the RankMath thresholds against the rendered page (18 scored gates since 2026-10-14, when the title trio was added), so it exposes stale cache and short-description fallback the editor hides. PrintsNOT_LIVE HTTP 404instead of a score for a page that is not published yet. Tags a real plugin TOC[Rank Math toc-block]. PrintsPENDfor the title trio when node or the lexicons are absent.scripts/check_draft.sh <file.html> "<focus keyword>" ["<exact product name>"]- the same body thresholds on a local file BEFORE publishing. Third argument also checks the description opens with an H2 holding that name word for word. Itstable of contentsgate prints PEND (not PASS) when the anchor proof only exists post-import.scripts/check_title.cjs "<title>" ["<title 2>" ...] [--keyword "<focus keyword>"] [--append <chars>] [--plugin <seo-by-rank-math dir>]- the Title Readability trio plus rendered length, ported from the plugin's ownassets/admin/js/analyzer.js. Pass--append 30to price a typed field as the live page will render it, or pass several candidate titles at once. Reads../lexicons/*.json;--plugin <dir>rebuilds those from a local copy of the plugin once (power words fromassets/vendor/powerwords/en.php, sentiment labels from the three sources the bundle merges, negations from the same file). The sentiment gate scores a sum, so a positive and a negative word can cancel to 0 and print RED.scripts/rm_toc.cjs <draft.html> [--out FILE] [--drop-toc]- Step 3b/TOC. Converts headings to<!-- wp:heading {"level":N,"anchor":"x"} -->so the anchor survives block import, prints the anchor table for the pack, and with--drop-tocswaps a hand-written Contents list for anRM-TOCmarker to insert Rank Math's block over. Run it after the prose is final, then re-runcheck_draft.shon the output.scripts/transitions.txt- the one transition-word list both checkers read, so a draft cannot pass here and fail there on a differently-worded list.
Run everything from the directory holding _audit/inventory/ (the SEO working directory). The inventory path is resolved as $RANKMATH_INVENTORY, else ./_audit/inventory/<host>, else $PWD/..., else $HOME/..., and the INFO inbound editorial links line prints whichever path it used - read it, because an unset inventory silently reports 0 inbound. check_links.sh exits with INVENTORY_MISSING rather than guessing.
What the scripts do and do not check
| Skill rule | Enforced by |
|---|---|
| Keyphrase in title / meta / intro / body, content length, meta and title length, image alt | check_listing.sh only - these are rendered-page fields a draft file cannot show |
| Comparison table, paragraph length, no pasted HTML comments | check_draft.sh only - a draft is where you can still fix them |
| Keyphrase density 0.5-1.6% | check_draft.sh gates it; check_listing.sh prints the live density but only REDs on absence |
| Keyphrase in the opening of the body, content length, subheadings, internal and external links, transition words, long sentences, FAQ | both, with the same thresholds |
| Keyphrase in subheadings | 5+ H2s, or 30%+ of the H2s present, same expression in both |
| Internal links >= 3 | Both; check_draft.sh additionally excludes same-page #anchors and tel:/mailto: from the count |
| Hub exists, phrases resolve, inbound links, list gaps | check_links.sh only |
| Under 10% passive voice | nothing - check it in RankMath's Content Readability panel |
| One H2 every 250-300 words | nothing - subheading distribution only counts H2s, never their spacing |
| Title readability (number / power / sentiment) | check_title.cjs - a port of the plugin's own analyzer.js; check_listing.sh scores it against the live <title> and prints PEND when node or the lexicons are missing |
| URL / permalink length | nothing - it counts scheme + host + slug, so no script here can reproduce it without the host's own length |
check_draft.sh counting hrefs is not the same as links being valid. It prints every check PASS on a draft whose 20 internal links all 404 (verified against a fixture). Always run check_links.sh on the same file.
Order of work: check_draft.sh + check_links.sh --draft before pasting, check_listing.sh + check_links.sh --url after publishing, and run check_listing.sh on a known-good page as a baseline before trusting a new result. Requires curl, grep, sed and awk, all present in Git Bash. No browser automation.
Worked example (41 to 84)
Anonymized: a real WooCommerce product page on a live shop, focus keyword HDPE tee.
Before: SEO title unset, so the template produced HDPE Female Tees for Sale in Kenya - Example Online Shop, which has no contiguous match. Meta unset, so RankMath auto-pulled 243 chars from the stale short description. Short description still held the old copy. Image alt was HDPE Tees. The body was already 1,006 words with 9 H2s and 6 links, and the page still scored 41.
After: product name HDPE Tee Fittings: Male and Female Tees, title HDPE Tee Fittings for Sale in Kenya | Best Prices (48 typed, but see the sitename trap: 37 or fewer typed is the real target), meta Buy HDPE tee fittings in Kenya - male tee and female tee, 20mm to 110mm, UV-stabilised. Best prices and countrywide delivery from Example Shop. (142 chars), alt HDPE tee fittings for sale in Kenya. Score: 84/100.
Benchmark: another product on the same shop at 79 with only 732 words and one internal link - proof that fields outweigh content volume.