=== Unstale - Refresh Old Posts with AI, Fact-Check & Republish ===
Contributors: webandseofr
Tags: republish, old-posts, content-refresh, fact-check, evergreen-content
Requires at least: 7.0
Tested up to: 7.1
Requires PHP: 8.1
Stable tag: 1.4.2
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Old posts lose traffic. Unstale researches, fact-checks and rewrites them with the AI provider you connected in WordPress. No API key, no quota.

== Description ==

Your best posts stopped bringing traffic. Not from a penalty: the prices, the statistics and the year references inside them grew outdated while fresher pages took the rankings. A content refresh fixes that, and Unstale runs one with real AI, through the AI provider you already connected to WordPress.

Point it at a published post. It reads the post, searches the live web, checks every figure and price against what it finds, rewrites the text, updates the SEO metas, adds internal links, and hands you the result as a diff to approve. Then it republishes with a fresh date, without breaking your URLs.

= What one content refresh actually does =

* **Live web research.** The refresh searches the web while it runs, so the rewrite is based on what is true today and not on what the model remembers.
* **Fact-check of every number.** Outdated figures, prices and dates are fact-checked against the sources the refresh just read. A step that cannot verify a claim says so in the journal instead of inventing one.
* **Rewrite at the level you choose.** Minimal fixes typos, facts, internal links and the date. Balanced adds the SEO work: optimized metas, JSON-LD when no SEO plugin already emits it, key-takeaway callouts, readability.
* **Internal linking.** Up to 15 relevant internal links per refresh, with natural, varied anchors, one link per target page.
* **Pages built with Elementor, Divi or SeedProd.** The refresh reads the text out of the builder's own data, sends only that text through, and writes it back where it came from, so your layout and your styling are untouched. A page built with a builder Unstale does not support is flagged before anything runs instead of being rewritten behind your back.
* **A journal per article.** Every step, every token count and the real cost, each on its own line.

= Find out what has gone stale, for free =

Knowing a refresh works is one thing. Knowing which of your 400 posts needs one is another. The Content health screen lists your published content that has not been updated in a while, oldest first, split into three age ranges so you can see how much of your archive has drifted.

It reads nothing but your own database, so looking costs you nothing and needs no AI provider connected. When you are ready, select what you want from that screen and send it off in one batch. Unstale shows the total your provider will bill before anything starts, and names anything it left out of the batch along with the reason.

= You keep the keys, and you keep the bill =

Since WordPress 7.0, providers and credentials live in Settings > Connectors and are managed by WordPress itself. Unstale uses whatever you set up there, so this plugin stores no API key of its own and never sees your credentials.

Your WordPress talks to that provider directly. Your content, published or draft, never passes through our servers. We do not see it.

You pay your provider directly, at its own rates, and nothing to us per refresh. No credits, no monthly quota, no cap on how many old posts you refresh. Unstale shows an estimated cost before each launch and logs the real one after.

= Nothing goes live without you =

Every refresh waits in a review queue with a side-by-side diff of the current post and the proposed one. You approve it or you discard it. An optional full-auto mode exists for people who want it, off by default. Approved changes are applied as a normal WordPress revision, so one click rolls them back when revisions are enabled on your site.

= Republishing that does not break your URLs =

Republishing plugins usually warn you not to use them when your permalinks contain the post date, because bumping the date moves the URL. Unstale skips the bump automatically when your permalink structure uses the core date tags. To keep dated URLs and still refresh the date, switch to the %unstale_year%, %unstale_monthnum% and %unstale_day% tags: they freeze the original date in the URL for good. Republication also carries position control, randomized timing, allowed days and time windows.

= Works with what you already run =

Unstale fits the stack you have instead of asking you to change it, and none of this needs a setting: it detects what is active and adapts.

* **Page builders: Elementor, Divi and SeedProd.** The refresh rewrites the text inside the page the builder already built and puts it back in the builder's own data, so your layout, your styling, your images and your builder settings come back exactly as they were. On a SeedProd page that uses blocks from SeedProd Pro, the new text lands in the builder and the live page waits for you to reopen it and save, which the journal tells you plainly.
* **SEO plugins: Yoast SEO, Rank Math, All in One SEO and SEOPress.** The rewritten meta title and description go straight into that plugin's own fields, not into a second set of fields your theme would ignore. Unstale also stops emitting its own JSON-LD while one of them is active, so a refreshed post never ends up carrying two Article schemas. With no SEO plugin installed, Unstale writes the metas and the structured data itself.
* **Cache plugins: WP Rocket, W3 Total Cache, LiteSpeed Cache, WP Super Cache, WP Fastest Cache and SG Optimizer.** Once a refresh is published, Unstale purges the post through that plugin's own public purge API so the new version is live at once. The purge is always aimed at the refreshed post, never a full-site flush, so the rest of your cache stays warm. Anything not on that list, a CDN for instance, hooks onto the unstale_purged action.
* **Editors and content types.** Gutenberg and the Classic editor both, on posts, pages and public custom post types.

= What Unstale does not do =

It does not write new articles. It works only on posts you already published, which is the point: the traffic is easier to win back on an evergreen post that once ranked than on a new one nobody has read. It does not read Search Console and does not rank your archive by traffic lost, so you choose which old posts to refresh, one by one or in batches from the Content health screen. And there is no account to create anywhere.

Two refresh levels are free: Minimal and Balanced, and so is sending a batch off from the Content health screen. The Strong and Maximal levels, scheduled automation and per-category rules are part of [Unstale Pro](https://unstale.ai/). Automation of the free levels is available through the REST API (unstale/v1) and WP-CLI (wp unstale refresh).

Unstale is developed by [Web & SEO Ltd](https://webandseo.dev/), the company that owns and operates unstale.ai.

== External services ==

This plugin relies on one external service.

= The Unstale model catalog (unstale.ai) =

What it is: a public, static JSON file at https://unstale.ai/api/v1/models.json listing the supported model ids, their prices per million tokens and their notes. It exists so that a new provider model becomes available in your settings without waiting for a plugin update, and so the plugin can show you a cost estimate before each refresh.

What is sent, and when: nothing but the HTTP request itself. It is an unauthenticated GET, fired by a single scheduled task once every 24 hours. No content, no credentials, no personal data, no site identifier is transmitted, and there is no manual refresh button that could trigger it more often. If the request fails the plugin keeps using the catalog bundled in the plugin files.

This service is provided by Unstale: [Terms](https://unstale.ai/terms), [Privacy](https://unstale.ai/privacy)

= A note on AI providers =

Unstale does not contact Anthropic, OpenAI or Google itself and declares none of them as its own external service. Prompts are handed to the WordPress AI Client, which routes them to the provider you connected in Settings > Connectors. That provider, its terms and its billing are the relationship between you and them, set up in WordPress and not in this plugin.

== Installation ==

1. Install and activate the plugin. WordPress 7.0 or newer is required.
2. Go to Settings > Connectors and connect an AI provider if you have not already. Unstale will tell you if none is connected.
3. Go to Unstale > Settings, pick a default model and refresh level.
4. In the Posts list, click "Refresh with Unstale" on any published post. Review the estimated cost, confirm, then approve the proposed rewrite in Unstale > Review queue.

== Frequently Asked Questions ==

= How much does a refresh cost? =

You pay your AI provider directly, per token. Unstale shows an estimate BEFORE each launch and logs the real cost after. For a 1000-word article at the Balanced level the estimate runs from roughly $0.08 on the cheapest model in the catalogue to roughly $0.62 on the dearest, depending on the model you pick. That estimate bounds the worst case (it assumes every web search the level allows is spent), so the cost logged after the run is lower: on the cheapest model, measured runs land around two cents.

Models whose provider grants a monthly allowance of free web searches are estimated with that allowance applied, and the confirmation screen says so. A site that refreshes enough articles in one month to exhaust the allowance will pay for the searches beyond it, which the estimate does not yet track.

Web search fees are part of the estimate but only appear in the logged cost when your provider reports how many searches it ran. WordPress does not standardise that number, so the logged cost can understate searches on some providers.

= How is this different from a plugin that just changes the publish date? =

A date bump moves an old post back to the top of your feeds, but the text stays exactly as outdated as it was. Unstale rewrites the content first: it researches the subject on the live web, corrects the figures and prices that aged, updates the metas and the internal links, and only then republishes. The date bump is the last step of the job, not the whole job.

= Can I try it on a single post before touching my archive? =

Yes, and that is the default path. The Posts list has no bulk action: you refresh one post at a time, review the diff, and approve or discard. Nothing sweeps your archive unless you ask for it, either by ticking the posts you want on the Content health screen, from WP-CLI (wp unstale refresh --all, which asks you to confirm first, because every job spends money at your provider), or through the scheduled rules in Unstale Pro.

= Does my content go through your servers? =

No. WordPress calls the provider you connected, directly. The only request to unstale.ai is the public model catalog (no content, no personal data).

= Which API key does it use? =

None of its own. Since WordPress 7.0, credentials live in Settings > Connectors and are managed by WordPress. Unstale never sees, stores or transmits them. If you used an older version of this plugin, the API keys it had stored are deleted automatically when you update.

= Can it publish without my approval? =

Not by default. Every refresh waits in the review queue with a side-by-side diff. You can enable full-auto publishing in the settings if you want it.

= My permalinks contain the post date. Will the date bump break my URLs? =

No: the bump is skipped automatically when your permalink structure uses the core date tags. To keep dated URLs AND bump dates, switch your structure to the %unstale_year%, %unstale_monthnum% and %unstale_day% tags: they freeze the original date in the URL forever. If you deactivate Unstale later, the plugin automatically restores the core date tags so your site keeps routing (posts whose date was bumped will then use the bumped date in their URL). Deleting the plugin files manually without deactivating skips that safety net: re-save your permalink structure in that case.

= Does it work with my page builder, my SEO plugin and my cache plugin? =

Page builders: Elementor, Divi and SeedProd. The refresh rewrites the text inside the page the builder already built, so nothing about your layout moves. Unstale asks you to confirm before refreshing any builder-built page, whichever builder it is, so nothing is ever rewritten behind your back.

SEO plugins: Yoast SEO, Rank Math, All in One SEO and SEOPress. The meta title and description go into their own fields, and Unstale stops emitting its own structured data so you never get two Article schemas on one post. With no SEO plugin, Unstale writes both itself.

Cache plugins: WP Rocket, W3 Total Cache, LiteSpeed Cache, WP Super Cache, WP Fastest Cache and SG Optimizer. Only the refreshed post is purged, never your whole cache. A cache or CDN outside that list plugs onto the unstale_purged action.

None of it needs configuring: Unstale detects what is active on your site and adapts.

= Which models are supported? =

Whichever models the provider you connected exposes. Unstale ships model cards for Claude Sonnet 5 (default), GPT-5.6 Terra and Gemini 3.7 Flash, with pricing and ratings to help you choose. All three are generally available, so the model you pick is the model that runs; if the catalog ever marks one as preview, Unstale runs its fallback instead and states the substitution on the model card and in the job log. The catalog is updated remotely: when a provider ships a new model, it appears in your settings without a plugin update.

== Screenshots ==

1. Content health: which published content has gone stale, and by how much
2. Side-by-side diff before approval
3. Review queue with per-job actions
4. Settings: connected AI provider and model cards
5. Journal: every refresh with its token counts and real cost

== Changelog ==

= 1.4.2 =
* Fixed: on the Content health screen, every post that had never been refreshed carried an empty line inside its Unstale cell. The Refresh link dropped to the bottom of the cell and the row stretched to match. A site that has just installed the plugin has refreshed nothing yet, so the whole table showed it, and Content health is the first Unstale screen a new site opens.

= 1.4.1 =
* Fixed: the model catalogue shipped with the plugin still expected Claude Sonnet 5 to get more expensive on 1 September. Anthropic made its $2 / $10 per million tokens permanent on 10 August and cancelled that increase. Sites already pick up the correction from the online catalogue within a day; this puts it in the plugin itself, so a fresh install starts with the right figure.
* New: if you use the Unstale Pro add-on, its licence key field now appears in Unstale > Settings, next to the refresh levels it unlocks, instead of only under its own Licence menu. Nothing changes without the add-on.

= 1.4.0 =
* Fixed: on a site using All in One SEO, a refresh wrote its new title and description where nothing ever read them. AIOSEO has kept its data in its own table since version 4, and the fields Unstale was writing are only read once, when migrating from version 3. Your pages did not move, and the journal still reported success. Unstale now writes through AIOSEO's own interface, and All in One SEO joins Yoast, Rank Math and SEOPress in the supported list.
* Fixed: the cost announced before a refresh was far too high on Google models, by about fifteen times. Two separate causes. The token estimate was calibrated on a provider that bills web search results as input tokens, which Google does not. And the free monthly search allowance some providers grant was never taken into account, although the plugin already knew about it. On a 500-word article at the Balanced level, the estimate went from 24 cents to 4, against 2 cents actually measured.
* Fixed: Elementor, Divi and SeedProd pages no longer raise a page builder warning. Those three are exactly what a refresh knows how to handle without moving your layout, so sending them to a notice that said the result may be unexpected told you the opposite of the truth. The warning is now kept for a builder Unstale has no adapter for, and it says what would really happen: the refresh would rewrite the standard WordPress content, not the page your visitors see.
* Fixed: a page builder section holding a script or a style block could carry that code into the rewritten text. The block is now removed with its contents.
* Improved: the cost question in this readme now gives the real range across the catalogue instead of a single guess, and says when web searches are covered by a provider's free monthly allowance.

= 1.3.2 =
* Fixed: on a site running PHP 8.1, Unstale did not work at all. One of its files used a piece of syntax that only exists from PHP 8.2 onwards, and PHP 8.1 stops on that file rather than reading it. Every refresh loads it. The plugin has asked for PHP 8.1 since its first release, so this was a plain contradiction, and it is now fixed. Nothing changes for you on PHP 8.2 or later.

= 1.3.1 =
* Improved: when a refresh updates a SeedProd page inside the builder but leaves the live page as it is, the journal now says so and names the one thing left to do: open the page in SeedProd and save it. Until now the refresh reported success and the page did not move, which looked like a failure and was not one.

= 1.3.0 =
* New: pages built with SeedProd can now be refreshed. Unstale rewrites the text inside the page SeedProd already built, so your layout, your styling and your images come back exactly as they were. Elementor and Divi pages were already supported; SeedProd completes the set.
* On SeedProd pages that use blocks from SeedProd Pro: the refresh still runs and the new text lands in the SeedProd builder, but the live page keeps its current text until you reopen it in SeedProd and save. Unstale only rewrites what it can place exactly, so a refresh can never flatten your layout.

= 1.2.0 =
* New: pages built with Elementor or Divi can now be refreshed. Unstale reads the text out of the builder's own data, sends only that text through the refresh, and writes it back where it came from, so your layout, your styling and your settings stay exactly as they were. A page built with a builder Unstale does not support is flagged before anything runs, with the option to force it through anyway.
* New: shortcodes are now protected for the whole length of a refresh. They used to go to the model with the rest of the text, which could mangle them or drop them. They are now lifted out before the rewrite and put back afterwards, exactly as they were.
* New: SEOPress is now detected like Yoast, Rank Math and All in One SEO. Your title and description go to its own fields, and Unstale stops emitting its own structured data when SEOPress is already handling it.
* Fixed: a long article could be published with a hole in the middle. The rewrite step accepted an answer the model had cut short at its output limit, and on an article split into several batches the truncated batch was saved and the refresh moved on to the next one. Nothing further down the line could see the gap. A cut-off answer is now refused and the step is tried again.
* Fixed: a page built with SeedProd from a theme template was not recognised as a builder page. The refresh rewrote the standard WordPress content while the text your visitors actually see sat untouched, and the work was lost without a word.
* Fixed: on an Elementor or Divi page whose content contained anything shortcode-shaped, the refresh failed at the publishing step every time, permanently, blaming a step that had done nothing wrong. Those pages could never be refreshed at all.
* Fixed: the check that stops a refresh from overwriting an edit you made while the job was waiting in the review queue was switched off on Elementor and Divi pages. Your edit could be overwritten without warning.
* Fixed: backslashes in your content, in a code sample for instance, were dropped when a refresh was published.
* Improved: when a model sends back an answer Unstale cannot read, the journal now shows both ends of it. It used to keep the first 300 characters only, which on an answer cut short is the part that was fine, so the actual break was never visible.
* Improved: a builder field holding a structure rather than plain text is now left alone. It used to be replaced by the word "Array", which destroyed that part of the page. It took a field added through the extension filters to trigger it, but the damage was permanent.
* Improved: the word count used to size a refresh no longer counts shortcode markup.

= 1.1.3 =
* New: after two weeks of real use, and only once at least three posts have been refreshed and published, Unstale asks for a review on wordpress.org. It shows on Unstale screens only, never anywhere else in your admin, and "Do not ask again" means exactly that.

= 1.1.2 =
* Fixed: refreshing the same post a second time no longer leaves you with two key-takeaway boxes, a third time with three, and so on. Each refresh now replaces the boxes from the previous one instead of stacking another set on top.
* Fixed: the key-takeaway boxes are now valid blocks in a block article, wherever they come from. Nothing changes for you, but an add-on can now add its own boxes without the editor flagging your content as invalid when you reopen it.

= 1.1.1 =
* Added: two filters (`unstale_prompt`, `unstale_rewrite_prompt`) so an add-on can serve its own system prompts.

= 1.1.0 =
* New: a Content health screen. It lists the published content you have not touched in a while, oldest first, split into three age ranges so you can see at a glance how much of your archive has drifted. It reads nothing but your own database, so it costs nothing to look and works with no AI provider connected.
* New: you can select several items on that screen and send them off in one batch. Before anything starts, Unstale shows the total your provider will bill for the whole batch, and names every item it left out along with the reason it was left out. A batch is capped at 50 items, so a stray click cannot queue your entire archive.
* New: a setting decides how long content has to sit untouched before it counts as stale. It starts at 180 days, and the three age ranges on the health screen follow whatever you set it to.
* Improved: an article can now receive up to 15 internal links per refresh, instead of 10.

= 1.0.7 =
* Fixed: the boxes added by the enrich step were dropped into the post as plain HTML, between two blocks instead of inside one. The editor then reported invalid content the next time you opened the post. They are now regular blocks, and they look exactly the same on the front end.
* Improved: the before and after comparison in the review queue now compares the text your readers see, not the underlying block markup. A rewrite that turns a wall of text into a list or a table used to fill the comparison with markup and bury the actual change. On a real refresh the comparison went from 100 rows down to 31.
* Improved: at the Balanced level, a dense paragraph that hides a series of steps or a set of figures is now restructured into a list or a table instead of staying a block of text.
* Improved: the comparison heading now names the level that produced it, so a refresh you come back to a day later is no longer ambiguous.

= 1.0.6 =
* Fixed: on a long article, the step that writes your metas and structured data could hit the model's output limit mid-answer. The refresh then reported an unreadable reply, which was misleading, and tried again under the same limit. That limit is now high enough for the job, a cut-off answer says so plainly in the journal, and the step is retried.

= 1.0.5 =
* Fixed: a refresh could die on an article that has nothing to fact-check. The model was answering correctly, but explaining in a sentence that it had found no figures to verify before giving its answer, and the plugin could not read a reply written that way. Three such replies in a row and the refresh gave up. Those answers are now understood.

= 1.0.4 =
* Fixed: a model that answered with reasoning only, and no readable content, killed the refresh outright. The journal said nothing useful about it and the call it had already paid for was never counted. That answer is now treated like any other step failure: the reason appears in the journal, the tokens appear in the cost, and the step is tried again.

= 1.0.3 =
* Fixed: the cost shown for a refresh could be lower than what your AI provider actually billed you. A step that failed after the model had already answered dropped the tokens it had just spent, and because a step is tried up to three times, a refresh could pay for three calls and report none of them. Every call that reaches the provider is now counted, on its own line in the journal.
* Fixed: when a model replied with something the plugin could not read, the journal only said the answer was invalid. It now quotes what the model actually sent back, so a failed step can be diagnosed from the journal instead of guessed at.

= 1.0.2 =
* Fixed: a refresh that died in the middle of a step always reported the same generic reason, whatever had really happened. The journal now records what the worker actually reported, so a provider error, a memory limit and a scheduler timeout no longer look alike and a failed refresh can be understood from the journal alone.

= 1.0.1 =
* Fixed: a refresh could fail for good when the AI provider stopped responding. All three attempts used to fall inside a 90-second window, so a short provider outage exhausted them and the job died. Retries are now spaced 2 and 10 minutes apart, which carries a refresh through an outage instead of losing it.

= 1.0.0 =
* Initial release: pipeline on the WordPress AI Client (analyze, web research, fact-check, rewrite, enrich, internal links, cleanup, review, publish), review queue with diff, journal, SEO-safe republication, targeted cache purge, REST API and WP-CLI.

== Upgrade Notice ==

= 1.4.1 =
Small but worth taking. The bundled catalogue no longer expects a September price rise on Claude Sonnet 5 that was cancelled, and Unstale Pro's licence key can be entered from Settings rather than only from its own menu.

= 1.4.0 =
Recommended for everyone. Elementor, Divi and SeedProd pages refresh without a warning, All in One SEO sites finally receive the title and description they paid for, and the cost shown before a refresh on Google models is no longer about fifteen times too high.

= 1.3.2 =
Required if your site runs PHP 8.1: every earlier version stops on one of its own files there and the plugin cannot run at all. Nothing changes on PHP 8.2 and above.

= 1.3.1 =
A SeedProd page refreshed in the builder only now says so in the journal, instead of looking like a refresh that did nothing.

= 1.3.0 =
Adds SeedProd pages to what a refresh can handle, alongside Elementor and Divi. A page using SeedProd Pro blocks is refreshed in the builder and left untouched online until you save it there.

= 1.2.0 =
Fixes a bug that made Elementor and Divi pages impossible to refresh, and one that could publish a long article with a hole in the middle. Adds Elementor and Divi pages to what a refresh can handle.
