---
title: "Find Your Stale Posts Before an AI Stops Citing Them"
url: https://hostmy.blog/find-stale-posts/
date: 2026-09-22
modified: 2026-09-22
lang: en
author: "Aditya Sharma"
description: "Stale pages are roughly 3x more likely to lose citations. A script that ranks your whole archive by staleness, using the WordPress REST API."
categories:
  - "AI Search"
image: https://hostmy.blog/wp-content/uploads/2026/09/hmb-card-1889-1024x538.jpg
word_count: 1633
---

# Find Your Stale Posts Before an AI Stops Citing Them

Freshness advice usually arrives as a vague instruction to update your old content. That is not actionable when you have 400 posts and no idea which ones matter.

Two numbers make it concrete. Stale pages were found to be roughly 3 times more likely to lose citations. And the median cited page was 298 days old.

Read those together and a useful picture appears. Being old is not the problem, because the typical cited page was nearly ten months old. Going stale is the problem, and that split is the whole basis of [deciding what to update and what to leave alone](/content-freshness-ai-answers/). A page that was last touched 900 days ago and still names 2021 as the current state of something has gone stale. A page from 2023 that was revised last month has not.

This post gives you a script that ranks your entire archive by staleness, using the WordPress REST API. No plugin, no database access, no packages.

Stale in a way that matters, against merely old
Stale in a way that matters, against merely old
Rewrite this
Leave this alone

A version number
PHP 7.4 called current
a definition

A price
changed since publishing
an argument

A count
29 AI crawlers, was fewer
a worked example

A relative date
'as of last year'
a dated quotation

Age alone is a poor filter, because the median cited page was already 298 days old. What dates badly is the checkable fact inside the sentence.

## Step 1: confirm the REST API answers

WordPress exposes posts at a public endpoint by default. Check yours first:

`curl -sS "https://yoursite.com/wp-json/wp/v2/posts?per_page=2&_fields=id,link,modified_gmt" \
-w '\ncode=%{http_code}\n'`

Expected output is a JSON array and a 200:

`[{"id":21341,"modified_gmt":"2026-08-28T13:56:48","link":"https://example.com/a-post/"},
{"id":21264,"modified_gmt":"2026-08-20T12:06:08","link":"https://example.com/another/"}]
code=200`

A 401, 403 or 404 means the API is restricted, which some security plugins do by default, and it is the same class of block you turn up when you check which of your pages an assistant gives up on. If so, the script below will tell you clearly rather than throwing a traceback.

The `_fields` parameter matters more than it looks. Without it the API returns the full rendered content of every post, which on a large archive is tens of megabytes.

## Step 2: the script

Save this as `stale.py`.

`#!/usr/bin/env python3
"""Rank WordPress posts by staleness, using the public REST API."""
import html, json, re, sys, urllib.request, urllib.error
from datetime import datetime, timezone
MEDIAN_CITED_AGE = 298 # days, the median age of a cited page
FIELDS = "id,link,title,date_gmt,modified_gmt,content"
def api(base, page, per_page):
url = ("%s/wp-json/wp/v2/posts?per_page=%d&page=%d&orderby=modified"
"&order=asc&_fields=%s" % (base.rstrip("/"), per_page, page, FIELDS))
req = urllib.request.Request(url, headers={
"User-Agent": "Mozilla/5.0 (compatible; stale-check/1.0)"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read().decode("utf-8", "replace"))
except urllib.error.HTTPError as e:
if e.code == 400: # page past the end of the list
return []
if e.code in (401, 403, 404):
print("REST API not reachable (HTTP %d)." % e.code)
print("Open %s/wp-json/wp/v2/posts in a browser to confirm."
% base.rstrip("/"))
sys.exit(1)
raise
except urllib.error.URLError as e:
print("could not reach %s: %s" % (base, e.reason))
sys.exit(1)
def days_since(stamp):
try:
d = datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%S")
except ValueError:
d = datetime.strptime(stamp.split(".")[0], "%Y-%m-%dT%H:%M:%S")
d = d.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - d).days
def dated_claims(html, this_year):
"""Years named in the body that are older than the current one."""
years = {int(y) for y in re.findall(r"\b(20[12]\d)\b", html)}
return sorted(y for y in years if y < this_year)
def score(age, gap, old_years):
"""Higher is staler. Age past the cited median dominates."""
s = max(0, age - MEDIAN_CITED_AGE) / 30.0 # months past the median
s += min(gap, 3650) / 365.0 # years untouched since publish
s += 2.0 * len(old_years) # each stale year reference
return s
def main(base, cap):
this_year = datetime.now(timezone.utc).year
rows, page, per_page = [], 1, 100
while len(rows) < cap:
batch = api(base, page, per_page)
if not batch:
break
rows.extend(batch)
page += 1
if len(batch) < per_page:
break
if not rows:
print("no posts returned from", base)
return 1
rows = rows[:cap]
scored = []
for p in rows:
age = days_since(p["modified_gmt"])
gap = days_since(p["date_gmt"]) - age
body = p.get("content", {}).get("rendered", "")
old = dated_claims(body, this_year)
scored.append((score(age, gap, old), age, gap, old, p))
scored.sort(key=lambda t: -t[0])
over = sum(1 for s in scored if s[1] > MEDIAN_CITED_AGE)
print("posts checked %d" % len(scored))
print("older than %dd %d (%.1f%%)"
% (MEDIAN_CITED_AGE, over, 100 * over / len(scored)))
print("\nstalest first\n")
print("%-6s %-6s %-6s %-14s %-34s %s"
% ("score", "age", "gap", "old years", "post", "url"))
for s, age, gap, old, p in scored[:25]:
title = html.unescape(re.sub(r"<[^>]+>", "", p["title"]["rendered"]))[:32]
print("%-6.1f %-6d %-6d %-14s %-34s %s" % (
s, age, gap, ",".join(str(y) for y in old[-3:]) or "-",
title, p["link"]))
return 0
if __name__ == "__main__":
if len(sys.argv) < 2:
print("usage: python3 stale.py https://example.com [max_posts]")
sys.exit(2)
cap = int(sys.argv[2]) if len(sys.argv) > 2 else 200
sys.exit(main(sys.argv[1], cap))`

## Step 3: run it

```
python3 stale.py https://yoursite.com 200
```

Real output from a run against a live WordPress site, September 2026:

`posts checked 40
older than 298d 40 (100.0%)

stalest first

score age gap old years post url
184.6 5836 0 - WordPress Trademark Changes Hand https://example.com/news/2010/09/trademark-to-wpf/
150.1 4648 1866 - Showcase https://example.com/news/2008/11/showcase/
132.6 4177 469 2013 WordPress 3.8 "Parker" https://example.com/news/2013/12/parker/
130.6 4177 472 - 3.8 RC2 https://example.com/news/2013/12/3-8-rc2/
100.6 3257 0 2017 The Month in WordPress: Septembe https://example.com/news/2017/10/september-2017/
63.9 1916 6511 - RSS Fixed https://example.com/news/2003/08/rss-fixed/`

And the error path, when the API is closed:

`REST API not reachable (HTTP 404).
Open https://example.com/wp-json/wp/v2/posts in a browser to confirm.`

## Step 4: read the four columns

**`age`** is days since the post was last modified. This is the number that matters most, and it is the one WordPress makes hardest to see in the admin.

**`gap`** is days between publication and the last edit. A gap of 0 means the post has never been touched since the day it went live. A large gap means someone has been maintaining it, which is a good sign even on an old post.

**`old years`** lists years named in the body that are earlier than the current one. This is the crude but effective part. A post that says "as of 2021" is making a dated claim, whether or not anyone remembers writing it.

The query uses `orderby=modified&order=asc`, so the API hands back the least recently modified posts first. Cap it at 40 and you get your 40 stalest posts without downloading the archive.

## Step 5: intersect with what you actually care about

Here is the honest limitation. The score is dominated by age, so on most sites the top of the list is simply your oldest content. Some of that is archive material nobody should update.

The fix is to cross reference against pages that still get traffic. Export your top pages from analytics or Search Console to `important.txt`, one URL per line, then filter:

`python3 stale.py https://yoursite.com 500 > stale.txt
grep -F -f important.txt stale.txt`

Expected output is the subset that appears in both:

`150.1 4648 1866 - Showcase https://example.com/news/2008/11/showcase/
132.6 4177 469 2013 WordPress 3.8 "Parker" https://example.com/news/2013/12/parker/`

`grep -F` treats the patterns as fixed strings rather than regular expressions, which matters because URLs contain characters that regex would otherwise interpret. That flag behaves the same on macOS BSD grep and GNU grep.

The intersection is the real worklist: pages that both matter and have gone stale. Everything else can wait, be left alone deliberately, or go into [the separate call about what to delete, redirect or refresh](/content-pruning-for-ai/).

## Step 6: update in a way that counts

Two things to avoid.

Changing the modified date without changing the content is not an update. It moves one number and nothing else, and the dated claims sitting in the body are still there.

And updating a page does not require rewriting it. The highest value edits are usually replacing the stale year references the script found, correcting anything that has since changed, and making sure the opening still answers the question the page is about in a sentence that clears [the numbers underneath the short sentence advice](/18-word-ceiling/), since 41.9 percent of citations in that dataset came from the first 30 percent of a page.

While you are in there, it is worth checking the sentence shape of what you rewrite. The method for that is in [putting a number on how liftable your writing is](/measure-quotable-writing/).

## What freshness will not fix

Being current does not make a page reachable. If a crawler cannot fetch it, none of this matters, and that check comes first every time. It is five commands, covered in [a reachability check you can run in a minute](/ai-crawler-test/).

It is also worth keeping expectations calibrated. Only 23.05 percent of cited URLs ranked in the organic top 10, and homepages were cited about 4 percent of the time. Freshness work on deep pages is generally better spent than freshness work on your front page.

## If you would rather not run scripts

Running this once gives you a worklist and a sense of how your archive is actually aging, which is worth the twenty minutes, and it slots in beside [the five layer check you can run by hand with curl](/five-layer-ai-seo-audit/).

If you want it tracked rather than sampled, RankReady is a WordPress AI SEO plugin that surfaces last-modified age per post inside the admin, alongside the crawler access and schema work, so stale pages are visible where you already edit them.

It runs alongside Rank Math, Yoast, AIOSEO and SEOPress instead of replacing them, since those own titles, meta and sitemaps while this owns machine access and readability. Setup takes about five minutes.

The honest framing: keeping pages current and crawlable is the part you control. Whether anything cites you is not, and a plugin that claims otherwise is selling you something. It is on [WordPress.org](https://wordpress.org/plugins/rankready-ai-llm-seo/).

Run it against your own archive with a cap of 500. How many of your stalest 25 are pages you would be embarrassed to have quoted today?