Content Performance Feedback Loop
[To Build] · Google Search Console integration ·
ContentPerformanceMetrictable ·anomaly-detectorextension
Ingests organic search performance data from Google Search Console (GSC) for published blog posts, surfaces ranking signals and traffic trends in the dashboard, and feeds performance context into content refresh briefs.
Related: Blog Writer · Anomaly Detector · Content Audit Agent · Insights · Content Toolkit Overview
Overview
| Function | Track organic search performance per blog post; surface ranking signals; enrich refresh briefs with GSC data |
| Type | Data Integration + Dashboard Feature |
| Status | To Build |
| Priority | P3 — Growth |
| Data source | Google Search Console API (via existing @leadmetrics/provider-google) |
| Credits | 0 cr (API data fetch; no LLM inference) |
| Plan | Pro+ |
Why This Is Needed
Credits are spent to generate content, but there is currently no signal connecting that spend to organic results. A DM agency cannot tell their client:
- “This blog post is ranking #6 for its target keyword — it needs one more push to reach page 1”
- “These 3 posts drove 42% of your organic traffic this month”
- “Post X dropped from position 4 to 11 this week — it needs a refresh”
Without this data, content decisions are disconnected from content performance. The feedback loop closes the gap: published content → GSC data → ranking signals → refresh recommendations → better content.
How It Works
Weekly GSC data sync (cron: every Sunday at 02:00 UTC)
↓
For each tenant with a connected Google Search Console channel:
Fetch impressions, clicks, CTR, avg position for all pages
Period: last 16 weeks (rolling window)
Granularity: weekly aggregates per page URL
↓
Match GSC page URLs to BlogPost records:
Match by: BlogPost.publishedUrl OR BlogPost.slug
↓
Upsert ContentPerformanceMetric rows per (blogPostId, weekStartDate)
↓
Compute and store signals on BlogPost:
- currentAvgPosition (latest week)
- positionTrend (delta vs 4-week rolling average)
- weeklyClicks (latest week)
- weeklyImpressions (latest week)
↓
Trigger anomaly-detector if:
- avgPosition dropped > 3 places week-over-week
- weeklyClicks dropped > 30% week-over-weekContentPerformanceMetric Model
model ContentPerformanceMetric {
id String @id @default(cuid())
tenantId String
blogPostId String
weekStartDate DateTime // Monday of the measurement week
pageUrl String // as reported by GSC
impressions Int
clicks Int
ctr Float // 0–1
avgPosition Float // lower is better; 1.0 = rank 1
primaryKeyword String? // matched from BlogPost; for display only
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id])
blogPost BlogPost @relation(fields: [blogPostId], references: [id])
@@unique([blogPostId, weekStartDate])
@@index([tenantId, weekStartDate])
}Fields on BlogPost (new):
publishedUrl String? // the live URL where the post is published (WordPress or other)
currentAvgPosition Float? // from latest GSC sync
positionTrend Float? // avgPosition delta vs 4-week rolling average; negative = improving
weeklyClicks Int? // from latest sync week
weeklyImpressions Int? // from latest sync week
lastGscSyncAt DateTime? // timestamp of last successful GSC data fetchDashboard UI
Blog Post Detail — Performance Panel
A “Performance” tab is added to the blog post detail page:
- Position trend chart — 16-week sparkline of
avgPosition(inverted Y axis: lower = better = higher on chart) - Clicks + Impressions chart — 16-week stacked bar
- Current position badge — “Currently ranking #6 for ‘email marketing automation’” (using
currentAvgPosition+primaryKeyword) - Ranking signal badges:
- “Near page 1” — amber badge when
currentAvgPositionis between 4 and 10 - “Top 3” — green badge when
currentAvgPosition≤ 3 - “Not ranking” — grey badge when
currentAvgPosition> 50 or no GSC data - “Dropping” — red badge when
positionTrend > 3(position worsened by more than 3 places)
- “Near page 1” — amber badge when
Blog List View
Add columns to the blog post list table:
- “Avg Position” —
currentAvgPositionrounded to 1 decimal; coloured (green ≤ 3, amber 4–10, red > 10, grey = no data) - “Weekly Clicks” — integer; 0 if no GSC data
- Sort by position (ascending) to surface highest-ranking posts
Content Performance Summary (Dashboard Home)
A summary widget on the main dashboard:
- “Top performing posts this week” — top 3 by weekly clicks
- “Dropping posts” — any posts with
positionTrend > 3 - “Near page 1 opportunities” — posts with
currentAvgPositionbetween 4 and 10
Performance-Enriched Refresh Brief
When “Refresh this post” is triggered from the Content Audit workflow, the GSC performance data is injected into the brief context:
// In POST /tenant/v1/blog/:id/refresh:
if (blogPost.currentAvgPosition) {
performanceContext = `
## Current Search Performance
- Currently ranking position ${blogPost.currentAvgPosition.toFixed(1)} for primary keyword "${blogPost.primaryKeyword}"
- ${blogPost.weeklyClicks} clicks last week from ${blogPost.weeklyImpressions} impressions
- Position trend: ${positionTrendDescription}
Refresh goal: Improve ranking from position ${blogPost.currentAvgPosition.toFixed(1)} to top 3.
Focus on: ${refreshFocusFromTrend(blogPost.positionTrend)}
`;
}This makes the refresh brief goal-oriented rather than just correction-oriented: the agent knows where the post is ranking and what it needs to do to improve.
anomaly-detector Extension
The existing anomaly-detector agent is extended to handle content-performance anomaly types alongside the existing channel anomaly types.
New anomaly types added:
| Anomaly type | Trigger condition | Action |
|---|---|---|
content_position_drop | positionTrend > 3 (dropped more than 3 places week-over-week) | DM alert + “Consider refreshing” badge on post |
content_near_page_one | currentAvgPosition moved from > 10 to 4–10 (new near-page-1 opportunity) | DM notification: “Post is near page 1 — a quick refresh could push it over” |
content_zero_clicks | Post ranked in GSC (position ≤ 50) but 0 clicks for 4 consecutive weeks | Flag as low-CTR: check title and meta description |
GSC URL Matching
GSC reports performance by full page URL (e.g. https://acme.com/blog/email-marketing-guide). BlogPost records store a slug (e.g. email-marketing-guide) and an optional publishedUrl (e.g. the WordPress URL set at publish time).
Matching logic:
function matchGscUrlToBlogPost(
gscUrl: string,
blogPosts: { id: string; slug: string; publishedUrl: string | null }[]
): string | null {
// 1. Exact match on publishedUrl
const exactMatch = blogPosts.find(p => p.publishedUrl === gscUrl);
if (exactMatch) return exactMatch.id;
// 2. Slug match: check if gscUrl path ends with the slug
const urlPath = new URL(gscUrl).pathname;
const slugMatch = blogPosts.find(p => urlPath.endsWith(`/${p.slug}`) || urlPath.endsWith(`/${p.slug}/`));
return slugMatch?.id ?? null;
}Unmatched GSC URLs are stored in a gsc_unmatched_urls log (not a Prisma model — a MongoDB collection) for debugging. DMs can view and manually assign unmatched URLs to blog posts via a settings page.
Key Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Weekly granularity | Aggregate per week, not per day | Daily GSC data is noisy; weekly smoothing gives cleaner trend signals |
| 16-week rolling window | Keep only last 16 weeks of data | Sufficient for trend analysis; avoids unbounded table growth |
| GSC only in v1 | Not GA4, not Bing, not Search Ads | GSC data is the most direct signal for content ranking performance |
| Store performance fields on BlogPost | currentAvgPosition, positionTrend etc. on BlogPost itself | Makes list-view queries efficient without joining ContentPerformanceMetric on every list load |
| anomaly-detector extension | Add content anomaly types to existing agent | Avoids a separate agent for a task that follows the same detection pattern |
Implementation Phases
Phase 1 — DB + GSC Sync
- Add
ContentPerformanceMetricmodel to Prisma schema (migration) - Add performance fields to
BlogPostmodel (migration) - Implement weekly GSC sync cron job in API scheduler
- Implement URL matching logic
- Implement
publishedUrlfield population in WordPress publish flow (already uses WP REST API — extract permalink from response)
Phase 2 — Dashboard Performance Panel
- Blog post detail: Performance tab with position trend chart + signal badges
- Blog list: Avg Position + Weekly Clicks columns
- Dashboard home: top performing posts + dropping posts + near page 1 widgets
Phase 3 — Refresh Brief Enrichment
- Inject
performanceContextinto refresh brief inPOST /tenant/v1/blog/:id/refresh - Extend
blog-writersystem prompt to accept and useperformanceContext
Phase 4 — Anomaly Detector Extension
- Add
content_position_drop,content_near_page_one,content_zero_clicksanomaly types - Extend anomaly-detector agent to process
ContentPerformanceMetricdata - DM portal notifications for content anomalies
- “Near page 1” badge in blog list view