SEOFORGE_GSC::detect_cannibalization($days = 28, $limit = 20): array
Queries where 2+ of the site’s pages rank in parallel. Returns the per-query page breakdown plus aggregate totals. Filters out queries with fewer than 20 impressions to kill noise.
foreach (SEOFORGE_GSC::instance()->detect_cannibalization(28, 10) as $row) {
echo $row['query'] . ' — ' . count($row['pages']) . ' competing pages';
foreach ($row['pages'] as $p) {
printf(" %s (pos %.1f, %d clicks)n", $p['page'], $p['position'], $p['clicks']);
}
}SEOFORGE_GSC::detect_ctr_anomalies($days = 28, $limit = 20): array
Top-3 pages with CTR at least 40% below the position’s rough benchmark (pos 1 ≈ 28%, pos 2 ≈ 15%, pos 3 ≈ 10%). Each row: { query, page, position, ctr, benchmark, gap_percent, impressions, clicks }. Minimum 50 impressions per row.
SEOFORGE_GSC::get_winners_losers($days = 7, $limit = 5): array
Week-over-week click-change per page. Returns { winners: array, losers: array }. Each row carries post_id (via url_to_postid) so the UI can link back to the editor.
SEOFORGE_GSC::classify_queries_by_intent($days = 28, $limit = 30): array
Takes the top N queries, batches them into a single AI prompt, parses the response into one of four intents: informational, commercial, transactional, navigational. Caches in a 6-hour transient keyed by (property, days, limit). Returns each original row augmented with an intent field.
SEOFORGE_GSC::inspect_url($url): array|WP_Error
Wraps the Worker’s URL Inspection proxy. Flattened return shape:
[
'coverage' => 'Submitted and indexed',
'indexing_state' => 'INDEXING_ALLOWED',
'verdict' => 'PASS',
'last_crawl' => '2026-04-21T09:12:00Z',
'page_fetch' => 'SUCCESSFUL',
'robots_allowed' => 'ALLOWED',
'canonical_user' => 'https://example.com/about/',
'canonical_goog' => 'https://example.com/about/',
'sitemap' => [],
'referring_urls' => [ ... ],
'mobile_verdict' => 'PASS',
'rich_verdict' => 'PASS',
'inspection_url' => 'https://search.google.com/search-console/inspect?...',
]Cached in a 6-hour per-URL transient so the post editor doesn’t burn Google’s 2000/day quota.
POST /integrations/google/gsc/url-inspection (Worker)
Thin proxy over searchconsole.googleapis.com/v1/urlInspection/index:inspect. Request:
{
"license_key": "...",
"product": "seoforge",
"site_url": "https://example.com/",
"page_url": "https://example.com/about/"
}Response:
{
"success": true,
"inspectionResult": { /* Google's payload verbatim */ }
}Primary sync on Connect
SEOFORGE_GSC::handle_post_connect() now dispatches Forge_Queue::dispatch('gsc_sync', ['days'=>90, 'limit'=>200, 'primary'=>true], 'seoforge') immediately after the property auto-detect step. Avoids the 24-hour wait for the daily cron on first-time connects.
wp_ajax_seoforge_gsc_inspect (admin-side)
Takes post_id + nonce, resolves get_permalink, calls inspect_url(), returns the flattened JSON the metabox renders. Permission: edit_posts.
wp_ajax_seoforge_get_issues (admin-side, R3)
Live Issues list refresh used by the SEO metabox while the user types in the
focus keyword / SEO title / meta description. Re-runs the rule-based scorer
with the un-saved field values plus content-derived signals collected from
resolve_content_html() — does NOT cache to wp_seoforge_analysis (the cache
row is updated only on save_post, so a typing burst can’t thrash it).
// Body params (sanitized server-side)
post_id // (int) target post; capability check `edit_post`
nonce // (string) action `seoforge_nonce`
meta_title // (string) optional — un-saved SEO title
meta_desc // (string) optional — un-saved meta description
keyword // (string) optional — un-saved focus keyword
// Successful response (matches the renderer shape so JS can rebuild <li> items)
{
"score": 78,
"issues": [
{"severity": "error", "message": "Content too short (49 words, aim for 300+)", "fixable": true, "cost": 2},
{"severity": "warning", "message": "No internal links found", "fixable": true, "cost": 2},
{"severity": "info", "message": "No images in content", "fixable": false, "cost": 2}
],
"count": 3,
"fixable_count": 2,
"ai_configured": true
}fixable mirrors the renderer’s gating: image/alt issues are never fixable
(no text-AI target), and content-rewrite issues require an editable
post_content (page-builder / ACF-only posts get false). cost is 1 for the
“No focus keyword set” path (routes through /meta/generate?type=keyword,
1 credit) and 2 otherwise (fix_issue).
Frontend wiring lives in assets/js/admin.js — scheduleLiveIssues() is
called from runLiveScore(), debounced 600 ms (longer than the score’s
180 ms because it hits the network). In-flight requests are aborted on the
next type so the UI never displays a stale older-than-current snapshot.
On AJAX failure the existing list stays put — no flicker, no empty state.
HowTo step preview in prepare_live_scoring_context() (R3)
SEOFORGE_Analyzer::prepare_live_scoring_context($post) now also returns:
'howto_step_count' => 3,
'howto_step_names' => ['Step 1: Mix flour', 'Step 2: Knead 10 min', 'Step 3: Bake at 220C'],These mirror what SEOFORGE_Schema::preview_howto_steps() extracts from
$post->post_content — same logic the frontend renderer runs, including
the round 2 question-form filter. The Schema Builder JS reads them when
the dropdown is set to HowTo to render a warn / notice / success
panel. Frozen at page load — the SEO metabox doesn’t expose the post
body, so the count is stable across user typing in title/desc/keyword.
SEOFORGE_Schema::preview_howto_steps() is the only public wrapper around
the private extract_howto_steps() — third-party code that needs to know
“would this post emit a HowTo on the frontend?” should call this and
check count($steps) >= 1.
Drawer bulk-review mode (SF-121 story 5)
window.sfDrawerOpenBulk(groups) accepts [{ issue, ids }] — the selection payload the Site Audit sticky bar already builds. It filters to text-issue groups via sfTypeFromIssue, fans a parallel /meta/preview per selected page, and renders one accordion row per item. A single Apply All button walks dirty rows and commits each through /meta/apply. Non-text groups are passed to the legacy chunked handler.