WP Manifestindependent plugin directory
manifest / seo / postmeta-audit

PostMeta Audit

WordPress plugin that audits every post/page for on-page SEO issues, shows a sortable, filterable admin report with scores, and exports results to CSV/XLSX. No external APIs.

by Harsha Shinde · github.com/xexpertai/postmeta-audit · website

★ 0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/xexpertai/postmeta-audit/archive/refs/heads/main.zip

A WordPress plugin that answers one question quickly and offline: which pages have broken SEO basics?

PostMeta Audit checks your published content against 12 rules. It stores what it finds in its own database table and shows a sortable, filterable report under Tools → SEO Audit. You can export the report as CSV or XLSX to send to a client.

  • No external HTTP calls, no telemetry and no paid services. Links are resolved locally with url_to_postid().
  • No runtime dependencies. The admin uses vanilla JS and CSS with no build step, and the XLSX writer is built in.
  • PHP 8.1+, WordPress 6.4+, MySQL/MariaDB via $wpdb.
  • Licence: GPL-2.0-or-later.

Report

Per-post details Settings
Details panel Settings

Full scan with the batched progress bar (25 posts per request):

Scan progress

The screenshots show the bundled demo data (bin/seed-demo.php). Every post in them is fictional and its title starts with "Demo data".


Contents

Install

Into an existing site

  1. Copy this folder to wp-content/plugins/postmeta-audit. You can also build a clean zip with git archive --format=zip --prefix=postmeta-audit/ -o postmeta-audit.zip HEAD and upload it under Plugins → Add New → Upload. The zip leaves out the development files listed in .gitattributes.
  2. Activate PostMeta Audit. Activation creates the table {prefix}pma_results.
  3. Go to Tools → SEO Audit and click Run full scan.

The plugin needs nothing beyond PHP 8.1+ with the dom and mbstring extensions. XLSX export also needs zip. Composer is only used for development tools.

Local demo with Docker

You need Docker with Compose v2.

docker compose up -d --wait   # WordPress on http://localhost:8080, MySQL 8, WP-CLI
bin/docker-setup.sh           # installs WordPress, activates the plugin, seeds demo data, runs `wp pma scan`

Then log in at http://localhost:8080/wp-admin/ as admin / admin and open Tools → SEO Audit.

  • Run any WP-CLI command with docker compose run --rm cli wp ….
  • Stop the demo with docker compose down. Add -v to delete the data too.
  • The plugin folder is mounted read-only into the containers.

Usage

Report (Tools → SEO Audit → Report)

Element What it does
Run full scan Scans every published post in scope using AJAX batches of 25 posts per request, with a progress bar. The report reloads when the scan finishes.
Summary bar Shows the average score, pages scanned and issue count per rule for the current filters. Click a rule chip to filter by it.
Table Built on WP_List_Table. Columns: Title (edit link, plus Details / View row actions), Type, Score, Errors, Warnings, Top issue and Last scanned. It shows 20 rows per page.
Sorting Sort by Score, Errors, Warnings, Title, Type or Last scanned.
Filters Filter by post type, severity and rule. A post matches when it has at least one issue with the selected severity and rule. Search matches titles.
Details The Details row action opens a panel listing every issue for that post with its message and measured value.
Export CSV / XLSX Downloads the report with the active filters applied. See Exports.

Automatic re-scan. Saving a post re-scans it in the same request, so the report is always current for that post. A post that is unpublished, trashed or deleted is removed from the report.

Settings (Tools → SEO Audit → Settings)

The settings are stored with the Settings API in the option pma_settings and sanitised on save.

  • Thresholds: SEO title length (default 30–60), meta description length (default 70–160) and minimum word count (default 300).
  • Post types to audit (default: posts and pages).
  • Each rule can be switched on or off.

Rules

Each rule is a separate class in includes/rules/ that implements RuleInterface::check( WP_Post $post, DOMDocument $html ): array.

ID Rule Fails when Severity Measured value
R1 SEO title length The title is missing, or shorter/longer than the configured range (30–60). The SEO title is the Yoast (_yoast_wpseo_title) or Rank Math (rank_math_title) title if set, otherwise the post title. missing → error, length → warning characters
R2 Meta description length The description is missing, or shorter/longer than the range (70–160). It comes from Yoast (_yoast_wpseo_metadesc), Rank Math (rank_math_description) or the explicit excerpt. missing → error, length → warning characters
R3 Duplicate title Another published post (in scope) has the same SEO title. The comparison ignores case and extra whitespace. warning number of other posts
R4 Duplicate description Another published post has the same meta description. warning number of other posts
R5 Single H1 The content has zero <h1> or more than one. warning H1 count
R6 Heading hierarchy Heading levels skip, e.g. h2 → h4. The page title counts as level 1, so content may start at h2. notice e.g. h2→h4
R7 Image alt text An <img> has a missing or empty (whitespace-only) alt. warning image count
R8 Thin content The visible text has fewer words than the minimum (300). Scripts, styles and block comments are ignored, and words are counted Unicode-aware. warning word count
R9 Internal link The post has no link to another published post on the same site. notice 0
R10 Broken internal link An internal link points to a missing or unpublished post. It is resolved with url_to_postid() and no HTTP calls. error link count
R11 Slug quality The slug is longer than 75 characters, contains stop-words (a, an, the, and, of, to, in, …) or contains uppercase letters. notice slug
R12 Featured image The post has no featured image, or the thumbnail ID points to nothing. notice 0

Notes and limits

  • Yoast/Rank Math template variables are partly expanded: title, sitename, sep and excerpt (%%var%% / %var%). Any other variable is removed before the length is measured.

  • Internal links are same-host links (www. is ignored), or relative links inside the WordPress install. Some links are never treated as post links and are skipped by R9/R10:

    • anchors, mailto:/tel: links and the front page;
    • files with an extension other than .html/.php;
    • wp-admin, wp-content, wp-includes and wp-json URLs;
    • feeds, and category/tag/author/pagination archives.

    You can override this with the postmeta_audit_internal_content_url filter.

  • R3/R4 compare against all published posts in scope. Saving a post re-scans that post only, so run a full scan to refresh the duplicate flags on the other posts.

Scoring

score = max( 0, 100 − 15 × errors − 5 × warnings − 1 × notices )

For example, 2 errors and 1 warning give 100 − 30 − 5 = 65. The same formula is used in PHP (Scoring::score()) and in SQL for sorting. Tests check that both give the same result.

Exports

Both formats contain one row per issue, with these columns: Post ID, Title, URL, Type, Score, Errors, Warnings, Notices, Rule, Rule name, Severity, Message, Measured value and Last scanned (UTC).

  • Active filters apply. The exports respect post type, severity, rule, search and sort order. When a severity or rule filter is set, only issues matching that filter are exported. Without one, posts with no issues still get a single "No issues found." row.
  • CSV is UTF-8 with a BOM, so Excel shows accented characters such as "Café – naïve" correctly. Cells that start with = + - @ are prefixed with ' to stop formula injection.
  • XLSX is written by Xlsx_Writer, a small SpreadsheetML writer that uses ZipArchive and needs no Composer package. It produces one sheet with a bold, frozen header row and an autofilter. Text uses inline strings and numbers use numeric cells, and characters that are invalid in XML are removed. The test suite checks that every part of the file is well-formed XML and that LibreOffice opens and converts it.

WP-CLI

wp pma scan                       # scan all post types in scope
wp pma scan --post_type=page      # only pages
wp pma scan --format=json         # summary as JSON (table|csv|json|yaml)

The command prints a table with the issue count per rule, then Success: Scanned N posts. Average score: X.Y. It exits with code 0, or 1 if the post type is not enabled.

Demo data

wp eval-file wp-content/plugins/postmeta-audit/bin/seed-demo.php          # create the 15 demo posts/pages
wp pma scan
wp eval-file wp-content/plugins/postmeta-audit/bin/seed-demo.php remove   # delete them again

The script creates 13 posts and 2 pages. All of them are labelled in three ways: the title starts with "Demo data", they are in the "Demo data" category (posts only), and they carry the _pma_demo meta. Each one has a known set of issues:

Demo post Expected rules
A complete guide to pruning roses none (clean)
Roses R1
Choosing compost for raised beds R2
Watering tips during hot summers (one) R3
Watering tips during hot summers (two) R3, R4
Starting tomatoes from seed indoors R4
Planning a herb spiral for patios R5
Our community garden opening hours (page) R6
Photo diary of the spring borders R7
Quick note on autumn leaf mulch R8
Companion planting for vegetables R9
Seasonal checklist for small gardens R10
Making leaf mould in the city R11
About our volunteer gardeners (page) R12
Everything you could ever want to know… R1, R2, R5, R8, R12

tests/integration/SeedFixtureTest.php seeds this data, runs a full scan and checks that every post reports exactly its expected rules. It runs once with plain permalinks and once with pretty permalinks. Re-running the seed script first deletes the previous demo content. The featured image is generated locally with GD and no download.

How it works

postmeta-audit.php            bootstrap (autoloader, activation hook)
uninstall.php                 drops the table, deletes pma_* options and scan meta
includes/
  class-installer.php         dbDelta schema: {prefix}pma_results (id, post_id, rule, severity, message,
                              measured_value, scanned_at) + indexes on post_id and rule
  class-scanner.php           analyze / scan_post / scan_batch (keyset pagination) / save hook
  rules/                      RuleInterface + 12 rule classes
  class-site-index.php        one query that loads every SEO title/description for R3/R4
  class-link-resolver.php     internal-link classification + url_to_postid()
  class-report-query.php      prepared, filtered, aggregated report queries (score computed in SQL)
  class-exporter.php          CSV/XLSX streaming (chunks of 200 posts)
  class-xlsx-writer.php       minimal SpreadsheetML writer
  class-request-guard.php     manage_options + nonce checks, HTTP 403 on failure
  admin/                      Tools → SEO Audit page and WP_List_Table
assets/                       vanilla JS (batched scan + progress bar) and CSS
bin/seed-demo.php             demo data; bin/e2e.sh end-to-end checks; bin/docker-setup.sh

Some design decisions:

  • Raw post content is audited. Running the_content would execute shortcodes and oEmbeds, and those can make HTTP requests. Block comments are ignored by the parser. Sites that need rendered HTML can provide it through the postmeta_audit_content_html filter.
  • Re-scan on save uses wp_after_insert_post. This is the end of the save flow, after post meta, terms and the featured image have been stored, in the classic editor, the block editor/REST API and programmatic saves alike. A plain save_post hook would run before the block editor stores meta and the featured image, so it would audit stale data. The re-scan still happens in the same request.
  • Scans replace rows. Every scan of a post deletes its old rows and inserts the new ones. A post meta value, _pma_scanned_at, marks a post as scanned even when it has no issues, so clean posts still show up with a score of 100.
  • Batches scale. The full scan pages through posts with ID > last_id LIMIT 25 (keyset pagination), primes the post caches for each batch and clears them afterwards. Each AJAX request does a fixed amount of work regardless of site size. The tests and the e2e script scan 500+ generated posts this way.
  • A full scan cleans up first. Before it starts, it removes rows for posts that are no longer published or no longer in scope.

Developer hooks

Filter Arguments Purpose
postmeta_audit_content_html string $html, WP_Post $post Change the HTML that is audited. The default is the raw post_content.
postmeta_audit_internal_content_url ?string $url, string $href Decide whether a link counts as a link to a single post. Return null to ignore it.
postmeta_audit_slug_stop_words string[] $words Change the stop-words used by R11.

Uninstall and data

Data Where it lives
Results {prefix}pma_results
Settings pma_settings
Schema version pma_db_version
Scan marker _pma_scanned_at post meta

Deleting the plugin from the Plugins screen runs uninstall.php. It drops the table, deletes every pma_* option and removes the scan meta. On multisite it does this for every site.

Development

Requirements:

  • PHP 8.1+ with mysqli, dom, mbstring, zip and gd;
  • Composer;
  • a MySQL or MariaDB server for the WordPress test suite.
composer install

# Test database: any empty database the user may create/drop tables in.
export WP_TESTS_DB_HOST=127.0.0.1:3306 WP_TESTS_DB_NAME=wordpress_test WP_TESTS_DB_USER=root WP_TESTS_DB_PASSWORD=root
vendor/bin/phpunit          # 75 integration tests on the real WordPress test suite (wp-phpunit)
vendor/bin/phpcs            # WordPress-Extra + WordPress-Docs, must report 0 errors

Test suite. The tests run inside the official WordPress test framework. Each one runs in a database transaction that is rolled back afterwards. The suite covers:

  • a passing and a failing case for each of R1–R12;
  • the score formula, in PHP and in SQL;
  • the 15-post demo fixture, with plain and pretty permalinks;
  • a full scan of 500 posts through the AJAX endpoint (21 requests, progress reaches 100%);
  • re-scanning on save in the same request;
  • capability and nonce checks: 403 for a missing nonce, a forged nonce or a subscriber;
  • input sanitisation;
  • CSV BOM/UTF-8, export filters, and XLSX structure (plus a LibreOffice round-trip when soffice is installed);
  • activation and uninstall;
  • the WP-CLI command;
  • a test that hooks pre_http_request and fails if it is ever called while the whole plugin workflow runs.

End-to-end checks. bin/e2e.sh runs against a real site through WP-CLI and HTTP:

WP="docker compose run --rm -T cli wp" SITE_URL=http://localhost:8080 bin/e2e.sh

It checks the following:

  1. Activation creates the table.
  2. wp pma scan exits with code 0 and prints the post count and average score.
  3. AJAX and export requests return 403 without a nonce, with a forged nonce, or as a subscriber.
  4. The CSV has a BOM and keeps "Café – naïve" intact.
  5. A filtered export contains only matching rows.
  6. A 500-post batched AJAX scan reaches 100%.
  7. Saving a post replaces its rows.
  8. Uninstall removes the table and the pma_ options.

CI (.github/workflows/ci.yml) has three jobs:

  • PHPCS;
  • PHPUnit on PHP 8.1–8.4 with MySQL 8 (LibreOffice is installed on 8.3);
  • the Docker end-to-end job, which also fails on any PHP notice or error logged by the plugin.

Security

  • Capability and nonce checks. Every state-changing or data-returning admin action checks manage_options and an action-specific nonce: scan start, scan batch and export. Failures return HTTP 403. There are no nopriv AJAX handlers.
  • Input handling. Request values are unslashed, sanitised and whitelisted in one place (Filters::from_array()). Sort columns and directions come from fixed maps, and search uses $wpdb->esc_like().
  • SQL. Every query with variable data uses $wpdb->prepare().
  • Output. All output is escaped with esc_html, esc_attr and esc_url.
  • Translations. All user-facing strings are translatable with the text domain postmeta-audit (languages/postmeta-audit.pot).

AI-assisted development

This project was built with an AI coding assistant (Anthropic's Claude, running in Claude Code). The assistant wrote the code, tests and documentation from a written specification. Its output was checked with:

  • the automated PHPUnit suite;
  • PHPCS (WordPress-Extra);
  • the end-to-end script against a real WordPress 7.1 install;
  • a LibreOffice round-trip of the XLSX export.

Bugs and suggestions are welcome as issues.

Licence

GPL-2.0-or-later. See LICENSE. WordPress plugins must be licensed under the GPL or a compatible licence.