From 199c43e7018ce827096f0f530c25c9fa225cc0b0 Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Sat, 19 Sep 2026 10:45:26 +0700 Subject: [PATCH 1/3] Fix schema-audit CLI checking no pages The script appended paths that start with a slash to a site URL it had already given a trailing slash, so every request went to //path/. Sites answer that with a 301, and the audit skipped every page as not found: on imagewize.com it checked nothing and reported 0 of 11. Rebuilt along the lines of the schema_audit MCP fix: pages come from the site's sitemap (new --max-pages, default 25), with the common paths only as a fallback. Each page is fetched once instead of up to six times, non-200 URLs are listed separately with their redirect target, and --pages accepts full URLs. Also fixes the pages-needing-schema list, which was always empty: the grep -c fallback printed 0 twice and broke the numeric test. JSON-LD spanning several lines is now read, and Person is detected. --- go/internal/catalog/catalog.json | 19 +- wp-cli/seo/README.md | 29 +-- wp-cli/seo/schema-audit.sh | 338 +++++++++++++++++++------------ 3 files changed, 241 insertions(+), 145 deletions(-) diff --git a/go/internal/catalog/catalog.json b/go/internal/catalog/catalog.json index ff0853c..90d3a02 100644 --- a/go/internal/catalog/catalog.json +++ b/go/internal/catalog/catalog.json @@ -4153,12 +4153,13 @@ { "category": "wp-cli", "key": "wp-cli/seo/schema-audit", - "description": "Check key pages for schema markup and validate implementation", + "description": "Check a site's pages (from its sitemap) for JSON-LD schema markup", "script_path": "wp-cli/seo/schema-audit.sh", "runs_on": "local", "runs": "local", "requires": [ - "curl" + "curl", + "perl" ], "doc": "wp-cli/seo/README.md", "args": [ @@ -4178,15 +4179,23 @@ "required": false, "default": "audits", "description": "Output directory", - "raw": "--output optional {audits} Output directory" + "raw": "--output optional {audits} Output directory" }, { "name": "--pages", "required_raw": "optional", "required": false, "default": "/,/about/,/contact/", - "description": "Comma-separated page paths to check", - "raw": "--pages optional {/,/about/,/contact/} Comma-separated page paths to check" + "description": "Comma-separated page paths or URLs (default: sitemap pages)", + "raw": "--pages optional {/,/about/,/contact/} Comma-separated page paths or URLs (default: sitemap pages)" + }, + { + "name": "--max-pages", + "required_raw": "optional", + "required": false, + "default": "25", + "description": "Maximum number of sitemap pages to check", + "raw": "--max-pages optional {25} Maximum number of sitemap pages to check" } ], "examples": [ diff --git a/wp-cli/seo/README.md b/wp-cli/seo/README.md index 6a312bf..6f73630 100644 --- a/wp-cli/seo/README.md +++ b/wp-cli/seo/README.md @@ -165,29 +165,32 @@ REPORT_DIR="reports" ./wp-cli/seo/redirect-audit.sh --url https://example.com **Script:** `schema-audit.sh` -Validates JSON-LD schema markup presence and types across key WordPress pages. +Validates JSON-LD schema markup presence and types across a site's pages. ### Features -- Detect JSON-LD schema blocks in page HTML -- Identify specific schema types: - - Organization - - LocalBusiness - - Service - - Product - - WebSite - - BreadcrumbList -- Check key pages (homepage, services, contact, portfolio, about, shop) -- Generate summary report with recommendations +- Takes the page list from the site's sitemap: `wp-sitemap.xml` (WordPress core), `sitemap_index.xml` (Yoast, Rank Math, The SEO Framework), then `sitemap.xml`. From a sitemap index it reads only the page sitemaps, when there are any. The homepage always comes first. +- Caps the list at `--max-pages` (default 25) and says when the cap cut it short +- Falls back to common paths (`/about/`, `/contact/`, `/shop/`, ...) only when the site has no sitemap +- Detects JSON-LD blocks in page HTML, including ones spanning several lines or with extra attributes +- Identifies schema types anywhere in the JSON-LD, including inside `@graph`: Organization, LocalBusiness, Service, Product, WebSite, BreadcrumbList, Article, FAQPage, HowTo, Person +- Lists URLs that don't return 200 in their own section, with status code and redirect target. They never count as missing schema. +- Generates a summary report with the pages that need schema, plus recommendations ### Usage ```bash -# Audit schema on a site +# Audit the pages in the site's sitemap (first 25) ./wp-cli/seo/schema-audit.sh https://example.com +# Check more sitemap pages +./wp-cli/seo/schema-audit.sh https://example.com --max-pages 60 + +# Specific pages: paths or full URLs +./wp-cli/seo/schema-audit.sh https://example.com --pages /,/services/,/contact/ + # With custom output directory -OUTPUT_DIR="reports/seo" ./wp-cli/seo/schema-audit.sh https://example.com +./wp-cli/seo/schema-audit.sh https://example.com --output reports/seo ``` ### Output Files diff --git a/wp-cli/seo/schema-audit.sh b/wp-cli/seo/schema-audit.sh index 03aee63..ad8c4fa 100755 --- a/wp-cli/seo/schema-audit.sh +++ b/wp-cli/seo/schema-audit.sh @@ -1,27 +1,35 @@ #!/usr/bin/env bash # # Schema Markup Audit Script -# Purpose: Check for schema markup on key pages and validate implementation +# Purpose: Check for schema markup on a site's pages and validate implementation # Output: Text reports with schema detection results # # Usage: ./schema-audit.sh [OPTIONS] # +# Pages come from --pages if passed. Otherwise they come from the site's +# sitemap (wp-sitemap.xml, sitemap_index.xml, then sitemap.xml; only the page +# sitemaps when there is an index), capped at --max-pages. A site with no +# sitemap falls back to a list of common paths. URLs that don't return 200 +# are listed separately and never counted as missing schema. +# # Options: # --output DIR Output directory (default: audits) -# --pages P1,P2 Comma-separated list of page paths to check (default: common pages) +# --pages P1,P2 Comma-separated page paths or full URLs to check (default: sitemap pages) +# --max-pages N Maximum number of sitemap pages to check (default: 25) # -h, --help Show this help # -# Requires: curl, grep +# Requires: curl, perl # -# @desc Check key pages for schema markup and validate implementation +# @desc Check a site's pages (from its sitemap) for JSON-LD schema markup # @category seo # @platform wordpress # @runs local # @mutates false -# @requires curl +# @requires curl perl # @arg site-url required {https://example.com} Site URL to check -# @flag --output optional {audits} Output directory -# @flag --pages optional {/,/about/,/contact/} Comma-separated page paths to check +# @flag --output optional {audits} Output directory +# @flag --pages optional {/,/about/,/contact/} Comma-separated page paths or URLs (default: sitemap pages) +# @flag --max-pages optional {25} Maximum number of sitemap pages to check # @example wp-ops schema-audit https://example.com --pages /,/services/,/contact/ # @doc wp-cli/seo/README.md @@ -29,10 +37,16 @@ set -euo pipefail # Configuration OUTPUT_DIR="audits" +MAX_PAGES=25 DATE=$(date +%Y-%m-%d) -SCHEMA_AUDIT_FILE="${OUTPUT_DIR}/schema-audit-${DATE}.txt" +SCHEMA_TYPES=("Organization" "LocalBusiness" "Service" "Product" "WebSite" "BreadcrumbList" "Article" "FAQPage" "HowTo" "Person") + +# Checked in order: WordPress core, then the index Yoast / Rank Math / The SEO +# Framework serve, then the plain path most other generators use +SITEMAP_PATHS=("wp-sitemap.xml" "sitemap_index.xml" "sitemap.xml") -# Default pages to check for schema (name|url pairs) +# Fallback when the site has no usable sitemap: common paths, several of which +# won't exist on any given site (name|path pairs) DEFAULT_PAGES=( "Homepage|/" "Services|/services/" @@ -55,24 +69,29 @@ while [[ $# -gt 0 ]]; do case $1 in --output) OUTPUT_DIR="$2" - SCHEMA_AUDIT_FILE="${OUTPUT_DIR}/schema-audit-${DATE}.txt" shift 2 ;; --pages) CUSTOM_PAGES="$2" shift 2 ;; + --max-pages) + MAX_PAGES="$2" + shift 2 + ;; -h|--help) echo "Usage: $(basename "$0") [OPTIONS] " echo "" echo "Options:" echo " --output DIR Output directory (default: audits)" - echo " --pages P1,P2 Comma-separated list of page paths (e.g., /,/about/,/contact/)" + echo " --pages P1,P2 Comma-separated page paths or full URLs (default: sitemap pages)" + echo " --max-pages N Maximum number of sitemap pages to check (default: 25)" echo " -h, --help Show this help" echo "" echo "Examples:" echo " $(basename "$0") https://example.com" echo " $(basename "$0") https://example.com --output reports/seo" + echo " $(basename "$0") https://example.com --max-pages 50" echo " $(basename "$0") https://example.com --pages /,/services/,/contact/" exit 0 ;; @@ -95,33 +114,124 @@ if [[ -z "$SITE_URL" ]]; then exit 1 fi -# Ensure site URL ends with / -if [[ "$SITE_URL" != */ ]]; then - SITE_URL="${SITE_URL}/" +if [[ ! "$MAX_PAGES" =~ ^[1-9][0-9]*$ ]]; then + echo "Error: --max-pages must be a positive whole number." + exit 1 fi -# Use custom pages if provided, otherwise use defaults -if [[ -n "$CUSTOM_PAGES" ]]; then - IFS=',' read -ra PAGES_TO_CHECK <<< "$CUSTOM_PAGES" - # Convert to name|url format +# Normalize to scheme + host with no trailing slash, so "${ORIGIN}/path/" +# never produces the "//path/" a site answers with a 301 +if [[ "$SITE_URL" != http://* && "$SITE_URL" != https://* ]]; then + SITE_URL="https://${SITE_URL}" +fi +ORIGIN=$(echo "$SITE_URL" | sed -E 's|^(https?://[^/]+).*|\1|') +DOMAIN=$(echo "$ORIGIN" | sed -E 's|^https?://||') +SCHEMA_AUDIT_FILE="${OUTPUT_DIR}/schema-audit-${DATE}.txt" + +WORK_DIR=$(mktemp -d) +trap 'rm -rf "$WORK_DIR"' EXIT + +# Print the values of a sitemap or sitemap index, one per line +parse_locs() { + perl -0777 -ne 'while (/\s*(?:)?\s*<\/loc>/gis) { (my $u = $1) =~ s/&/&/g; print "$u\n" }' "$1" +} + +# "Homepage" for the root, the path for anything else +page_label() { + local path="/${1#*://*/}" + if [[ "$path" == "/" ]]; then echo "Homepage"; else echo "$path"; fi +} + +# Fill PAGES_TO_CHECK from the first sitemap that lists URLs on this site. +# Sets SITEMAP_URL and SITEMAP_TOTAL; returns 1 when no sitemap qualifies. +discover_sitemap_pages() { + local path url child page + local urls_file="${WORK_DIR}/urls.txt" + for path in "${SITEMAP_PATHS[@]}"; do + url="${ORIGIN}/${path}" + curl -s -f -L --max-time 30 "$url" -o "${WORK_DIR}/sitemap.xml" 2>/dev/null || continue + + : > "$urls_file" + if grep -qi ' "${WORK_DIR}/children.txt" + # Page sitemaps only (wp-sitemap-posts-page-1.xml, page-sitemap.xml), + # when the index has any + if grep -qiE '/[^/]*page[^/]*$' "${WORK_DIR}/children.txt"; then + grep -iE '/[^/]*page[^/]*$' "${WORK_DIR}/children.txt" > "${WORK_DIR}/children-pages.txt" + mv "${WORK_DIR}/children-pages.txt" "${WORK_DIR}/children.txt" + fi + while IFS= read -r child; do + if curl -s -f -L --max-time 30 "$child" -o "${WORK_DIR}/child.xml" 2>/dev/null; then + parse_locs "${WORK_DIR}/child.xml" >> "$urls_file" + fi + done < "${WORK_DIR}/children.txt" + elif grep -qi ' "$urls_file" + fi + + # Same host only, homepage first, duplicates dropped, order kept + { echo "${ORIGIN}/"; grep -E "^https?://${DOMAIN//./\\.}(/|$)" "$urls_file" || true; } \ + | awk '!seen[$0]++' > "${WORK_DIR}/pages.txt" + SITEMAP_TOTAL=$(wc -l < "${WORK_DIR}/pages.txt" | tr -d ' ') + # Only the homepage we added ourselves means the sitemap listed nothing here + if [[ "$SITEMAP_TOTAL" -le 1 ]]; then + continue + fi + + SITEMAP_URL="$url" PAGES_TO_CHECK=() - for page in ${CUSTOM_PAGES//,/ }; do - page_name=$(echo "$page" | sed 's|/||g' | sed 's|-| |g' | awk '{print toupper(substr($0,1,1)) tolower(substr($0,2))}') - PAGES_TO_CHECK+=("${page_name}|${page}") + while IFS= read -r page; do + PAGES_TO_CHECK+=("$(page_label "$page")|${page}") + done < <(head -n "$MAX_PAGES" "${WORK_DIR}/pages.txt") + return 0 + done + return 1 +} + +# Pick the page list: --pages, else the sitemap, else the common paths +PAGE_SOURCE="" +SITEMAP_URL="" +SITEMAP_TOTAL=0 +PAGES_TO_CHECK=() +if [[ -n "$CUSTOM_PAGES" ]]; then + PAGE_SOURCE="custom" + IFS=',' read -ra custom <<< "$CUSTOM_PAGES" + for page in "${custom[@]}"; do + if [[ "$page" == http://* || "$page" == https://* ]]; then + page_url="$page" + else + page_url="${ORIGIN}/${page#/}" + fi + PAGES_TO_CHECK+=("$(page_label "$page_url")|${page_url}") done +elif discover_sitemap_pages; then + PAGE_SOURCE="sitemap" else - PAGES_TO_CHECK=("${DEFAULT_PAGES[@]}") + PAGE_SOURCE="default" + for entry in "${DEFAULT_PAGES[@]}"; do + PAGES_TO_CHECK+=("${entry%%|*}|${ORIGIN}${entry##*|}") + done fi +case "$PAGE_SOURCE" in + sitemap) + if [[ ${#PAGES_TO_CHECK[@]} -lt $SITEMAP_TOTAL ]]; then + SOURCE_LINE="first ${#PAGES_TO_CHECK[@]} of ${SITEMAP_TOTAL} URLs from ${SITEMAP_URL} (raise --max-pages or pass --pages to check others)" + else + SOURCE_LINE="${#PAGES_TO_CHECK[@]} URLs from ${SITEMAP_URL}" + fi + ;; + default) SOURCE_LINE="${#PAGES_TO_CHECK[@]} common paths (no sitemap found), so some are expected not to exist" ;; + custom) SOURCE_LINE="${#PAGES_TO_CHECK[@]} passed in --pages" ;; +esac + # Create output directory if it doesn't exist mkdir -p "$OUTPUT_DIR" -# Extract domain for display -DOMAIN=$(echo "$SITE_URL" | sed -E 's|^https?://||' | sed -E 's|/.*||') - echo "==========================================" echo "Schema Markup Audit - ${DOMAIN}" echo "Date: ${DATE}" +echo "Pages: ${SOURCE_LINE}" echo "==========================================" echo "" @@ -129,115 +239,80 @@ echo "" cat > "${SCHEMA_AUDIT_FILE}" <> "${SCHEMA_AUDIT_FILE}" - echo "=== ${page_name} ===" >> "${SCHEMA_AUDIT_FILE}" - echo "URL: ${full_url}" >> "${SCHEMA_AUDIT_FILE}" - echo "" >> "${SCHEMA_AUDIT_FILE}" - - # Check if page exists (returns 200) - HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${full_url}" 2>/dev/null || echo "0") - - if [ "$HTTP_STATUS" != "200" ]; then - echo " ⚠ Skipping: Page not found (HTTP ${HTTP_STATUS})" - echo "Status: ⚠ PAGE NOT FOUND (HTTP ${HTTP_STATUS})" >> "${SCHEMA_AUDIT_FILE}" - echo "" >> "${SCHEMA_AUDIT_FILE}" - echo "---" >> "${SCHEMA_AUDIT_FILE}" - return - fi - - # Fetch page and look for JSON-LD schema. Plugins like Yoast/Rank Math add - # extra attributes (e.g. class="yoast-schema-graph") to the script tag, so - # match on the type attribute rather than requiring an exact opening tag. - SCHEMA_CONTENT=$(curl -s "${full_url}" | grep -oE ']*type="application/ld\+json"[^>]*>.*' || echo "") - - if [ -z "$SCHEMA_CONTENT" ]; then - echo " ❌ No JSON-LD schema found" - echo "Status: ❌ NO SCHEMA FOUND" >> "${SCHEMA_AUDIT_FILE}" - echo "" >> "${SCHEMA_AUDIT_FILE}" - echo "---" >> "${SCHEMA_AUDIT_FILE}" - return - fi - - # Extract and analyze schema - echo " ✓ Schema markup found" - echo "Status: ✓ SCHEMA PRESENT" >> "${SCHEMA_AUDIT_FILE}" - echo "" >> "${SCHEMA_AUDIT_FILE}" - - # Check for specific schema types - SCHEMA_TYPES=("Organization" "LocalBusiness" "Service" "Product" "WebSite" "BreadcrumbList" "Article" "FAQPage" "HowTo") - - for schema_type in "${SCHEMA_TYPES[@]}"; do - if echo "$SCHEMA_CONTENT" | grep -q "\"@type\":.*\"${schema_type}\""; then - echo " ✓ ${schema_type} schema detected" - echo " - ${schema_type} schema: ✓" >> "${SCHEMA_AUDIT_FILE}" - fi - done - - # Save raw schema for review - echo "" >> "${SCHEMA_AUDIT_FILE}" - echo "Raw Schema:" >> "${SCHEMA_AUDIT_FILE}" - echo "$SCHEMA_CONTENT" | sed -E 's/]*type="application\/ld\+json"[^>]*>//g' | sed 's/<\/script>//g' >> "${SCHEMA_AUDIT_FILE}" - echo "" >> "${SCHEMA_AUDIT_FILE}" - echo "---" >> "${SCHEMA_AUDIT_FILE}" -} - -# Check each page PAGES_WITH_SCHEMA=0 PAGES_WITHOUT_SCHEMA=0 -TOTAL_PAGES=${#PAGES_TO_CHECK[@]} +MISSING_SCHEMA=() +NOT_CHECKED=() for page_entry in "${PAGES_TO_CHECK[@]}"; do - # Split name and URL page_name="${page_entry%%|*}" - page_url="${page_entry##*|}" - - # Check if page exists (returns 200) - HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${SITE_URL}${page_url}" 2>/dev/null || echo "0") - - if [ "$HTTP_STATUS" != "200" ]; then - echo "⚠ Skipping ${page_name}: Page not found (HTTP ${HTTP_STATUS})" - echo "=== ${page_name} ===" >> "${SCHEMA_AUDIT_FILE}" - echo "URL: ${SITE_URL}${page_url}" >> "${SCHEMA_AUDIT_FILE}" - echo "Status: ⚠ PAGE NOT FOUND (HTTP ${HTTP_STATUS})" >> "${SCHEMA_AUDIT_FILE}" - echo "" >> "${SCHEMA_AUDIT_FILE}" - echo "---" >> "${SCHEMA_AUDIT_FILE}" + full_url="${page_entry#*|}" + body="${WORK_DIR}/page.html" + + # One request per page: body to a file, status and redirect target on stdout. + # Redirects are not followed, so a URL that redirects is reported, not audited. + result=$(curl -s --max-time 30 -o "$body" -w '%{http_code} %{redirect_url}' "$full_url" 2>/dev/null) || true + http_status="${result%% *}" + redirect_url="${result#* }" + [[ "$result" != *" "* ]] && redirect_url="" + [[ -z "$http_status" || "$http_status" == "000" ]] && http_status=0 + + if [[ "$http_status" != "200" ]]; then + status_text="HTTP ${http_status}" + [[ "$http_status" == "0" ]] && status_text="no response" + [[ -n "$redirect_url" ]] && status_text="${status_text} → ${redirect_url}" + echo "⚠ Not checked: ${page_name} (${status_text})" + NOT_CHECKED+=("${page_name} (${full_url}): ${status_text}") continue fi - # Check schema - SCHEMA_FOUND=$(curl -s "${SITE_URL}${page_url}" | grep -cE ']*type="application/ld\+json"[^>]*>' || echo "0") + echo "" >> "${SCHEMA_AUDIT_FILE}" + echo "=== ${page_name} ===" >> "${SCHEMA_AUDIT_FILE}" + echo "URL: ${full_url}" >> "${SCHEMA_AUDIT_FILE}" + echo "" >> "${SCHEMA_AUDIT_FILE}" - if [ "$SCHEMA_FOUND" -gt 0 ]; then - PAGES_WITH_SCHEMA=$((PAGES_WITH_SCHEMA + 1)) - check_schema "$page_name" "$page_url" - else + # JSON-LD blocks, which may span lines and carry extra attributes + # (Yoast adds class="yoast-schema-graph") + SCHEMA_CONTENT=$(perl -0777 -ne 'while (/]*type=["\x27]application\/ld\+json["\x27][^>]*>(.*?)<\/script>/gis) { print "$1\n" }' "$body") + + if [[ -z "$SCHEMA_CONTENT" ]]; then PAGES_WITHOUT_SCHEMA=$((PAGES_WITHOUT_SCHEMA + 1)) + MISSING_SCHEMA+=("${page_name} (${full_url})") echo "❌ ${page_name}: No schema" - echo "=== ${page_name} ===" >> "${SCHEMA_AUDIT_FILE}" - echo "URL: ${SITE_URL}${page_url}" >> "${SCHEMA_AUDIT_FILE}" echo "Status: ❌ NO SCHEMA FOUND" >> "${SCHEMA_AUDIT_FILE}" echo "" >> "${SCHEMA_AUDIT_FILE}" echo "---" >> "${SCHEMA_AUDIT_FILE}" + else + PAGES_WITH_SCHEMA=$((PAGES_WITH_SCHEMA + 1)) + echo "✓ ${page_name}: Schema markup found" + echo "Status: ✓ SCHEMA PRESENT" >> "${SCHEMA_AUDIT_FILE}" + echo "" >> "${SCHEMA_AUDIT_FILE}" + for schema_type in "${SCHEMA_TYPES[@]}"; do + # "@type": "X" or "@type": ["Y", "X"] + if echo "$SCHEMA_CONTENT" | grep -qE "\"@type\"[[:space:]]*:[[:space:]]*(\[[^]]*)?\"${schema_type}\""; then + echo " ✓ ${schema_type} schema detected" + echo " - ${schema_type} schema: ✓" >> "${SCHEMA_AUDIT_FILE}" + fi + done + echo "" >> "${SCHEMA_AUDIT_FILE}" + echo "Raw Schema:" >> "${SCHEMA_AUDIT_FILE}" + echo "$SCHEMA_CONTENT" >> "${SCHEMA_AUDIT_FILE}" + echo "" >> "${SCHEMA_AUDIT_FILE}" + echo "---" >> "${SCHEMA_AUDIT_FILE}" fi - echo "" sleep 1 # Be nice to the server - done +REACHABLE=$((PAGES_WITH_SCHEMA + PAGES_WITHOUT_SCHEMA)) + # Generate summary cat >> "${SCHEMA_AUDIT_FILE}" <> "${SCHEMA_AUDIT_FILE}" <> "${SCHEMA_AUDIT_FILE}" </dev/null || echo "0") - if [ "$HTTP_STATUS" = "200" ]; then - SCHEMA_FOUND=$(curl -s "${SITE_URL}${page_url}" | grep -cE ']*type="application/ld\+json"[^>]*>' || echo "0") - if [ "$SCHEMA_FOUND" -eq 0 ]; then - echo "- ${page_name} (${SITE_URL}${page_url})" >> "${SCHEMA_AUDIT_FILE}" - fi - fi + echo "PAGES NEEDING SCHEMA MARKUP:" >> "${SCHEMA_AUDIT_FILE}" + for page in "${MISSING_SCHEMA[@]}"; do + echo "- ${page}" >> "${SCHEMA_AUDIT_FILE}" + done + echo "" >> "${SCHEMA_AUDIT_FILE}" +fi + +# Not-found URLs are not schema problems, so they get their own section. From +# a sitemap they are still worth fixing: the sitemap lists a URL that +# redirects or is gone. +if [[ ${#NOT_CHECKED[@]} -gt 0 ]]; then + case "$PAGE_SOURCE" in + sitemap) heading="NOT CHECKED: sitemap URLs that did not return 200 (the sitemap lists a redirect or a missing page):" ;; + default) heading="NOT CHECKED: common paths that don't exist on this site (not a schema problem):" ;; + *) heading="NOT CHECKED: URLs that did not return 200:" ;; + esac + echo "$heading" >> "${SCHEMA_AUDIT_FILE}" + for page in "${NOT_CHECKED[@]}"; do + echo "- ${page}" >> "${SCHEMA_AUDIT_FILE}" done echo "" >> "${SCHEMA_AUDIT_FILE}" fi @@ -325,11 +405,15 @@ NEXT STEPS ======================================== EOF +echo "" echo "==========================================" echo "Schema Audit Complete!" echo "==========================================" -echo "Pages with schema: ${PAGES_WITH_SCHEMA}/${TOTAL_PAGES}" -echo "Pages without schema: ${PAGES_WITHOUT_SCHEMA}/${TOTAL_PAGES}" +echo "Pages with schema: ${PAGES_WITH_SCHEMA}/${REACHABLE} reachable" +echo "Pages without schema: ${PAGES_WITHOUT_SCHEMA}/${REACHABLE} reachable" +if [[ ${#NOT_CHECKED[@]} -gt 0 ]]; then + echo "Not checked (did not return 200): ${#NOT_CHECKED[@]}" +fi echo "" echo "Full report saved to: ${SCHEMA_AUDIT_FILE}" echo "" From bff313bc089dd14226881bfd806e3a711bee2488 Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Sat, 19 Sep 2026 10:45:45 +0700 Subject: [PATCH 2/3] Detect schema types inside graph arrays in schema_audit Only the top-level type field of each JSON-LD block was read, so the Organization, WebSite and BreadcrumbList nodes SEO plugins nest in a graph array went uncounted. The imagewize.com homepage reported none of the tracked types. The text fallback for unparseable JSON-LD also never matched, since it lowercased the schema but not the type it searched for. --- mcp-server/src/tools/schemaAudit.ts | 41 ++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/mcp-server/src/tools/schemaAudit.ts b/mcp-server/src/tools/schemaAudit.ts index fcf82a5..d5dcc1d 100644 --- a/mcp-server/src/tools/schemaAudit.ts +++ b/mcp-server/src/tools/schemaAudit.ts @@ -66,19 +66,36 @@ function extractSchema(html: string): string[] { * Check which schema types are present in JSON-LD content */ function checkSchemaTypes(rawSchema: string[]): SchemaTypeCheck[] { - return SCHEMA_TYPES.map((type) => ({ - type, - found: rawSchema.some((schema) => { - // Check for @type field with the type value - try { - const parsed = JSON.parse(schema); - return parsed["@type"] === type || (Array.isArray(parsed["@type"]) && parsed["@type"].includes(type)); - } catch { - // Fallback to string search if JSON parsing fails - return schema.toLowerCase().includes(`"@type":"${type}"`); + const found = new Set(); + + // Every @type anywhere in the tree: SEO plugins nest their nodes in an + // "@graph" array rather than putting one @type at the top level + const collect = (node: unknown): void => { + if (Array.isArray(node)) { + node.forEach(collect); + } else if (node && typeof node === "object") { + for (const [key, value] of Object.entries(node)) { + if (key === "@type") { + (Array.isArray(value) ? value : [value]).forEach((t) => typeof t === "string" && found.add(t)); + } else { + collect(value); + } + } + } + }; + + for (const schema of rawSchema) { + try { + collect(JSON.parse(schema)); + } catch { + // Unparseable JSON-LD: fall back to matching "@type": "X" in the text + for (const type of SCHEMA_TYPES) { + if (new RegExp(`"@type"\\s*:\\s*(\\[[^\\]]*)?"${type}"`).test(schema)) found.add(type); } - }), - })); + } + } + + return SCHEMA_TYPES.map((type) => ({ type, found: found.has(type) })); } /** From 06d42aeff021dfbeb7fd56f9ab5b835a116a01a4 Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Sat, 19 Sep 2026 10:45:45 +0700 Subject: [PATCH 3/3] Add 5.24.3 changelog entry for schema audit fixes --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f010b7b..de6ae4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [5.24.3] - 2026-09-19 + +### Fixed + +- **`wp-ops schema-audit` checked no pages at all on many sites.** The script appended paths starting with `/` to a site URL it had already given a trailing slash, so every request went to `https://example.com//path/`. Sites answer that with a 301, and the audit skipped every page as "not found", homepage included. On imagewize.com it checked nothing and reported 0 of 11. + - The script is rebuilt along the lines of the 5.24.2 `schema_audit` MCP fix. Pages come from the site's sitemap (`wp-sitemap.xml`, `sitemap_index.xml`, then `sitemap.xml`; only the page sitemaps when there's an index), capped by a new `--max-pages` flag (default 25). A site with no sitemap falls back to the common paths, and the output says so. + - URLs that don't return 200 are listed in their own "NOT CHECKED" section with the status code and redirect target, and never count toward the totals. + - The "PAGES NEEDING SCHEMA MARKUP" list in the report was always empty. Its `grep -c ... || echo "0"` fallback printed `0` twice, which broke the numeric test. The list is now built during the audit. + - Each page is fetched once instead of up to six times. + - `--pages` accepts full URLs as well as paths. + - JSON-LD blocks spanning several lines are now read, and `Person` is among the detected types. The script now needs `perl` for this, which is present on macOS and standard Linux servers. +- **The `schema_audit` MCP tool now detects schema types inside `@graph`.** It read only the top-level `@type`, so the Organization, WebSite and BreadcrumbList nodes that Yoast, Rank Math and The SEO Framework nest in an `@graph` array went uncounted. The imagewize.com homepage reported "None of the tracked types". The text fallback for JSON-LD that doesn't parse never matched either, because it lowercased the schema but not the type it searched for. + ## [5.24.2] - 2026-09-19 ### Fixed