Skip to content
Levi Maia Braga — home
Go back

SerpVive

Updated:
Source on GitHub

SerpVive Website

A content decay monitor for blogs. It connects to Google Search Console, detects which pages are losing organic traffic using a deterministic scoring engine, and then — only for the one step that genuinely needs judgement — uses a language model to diagnose why, grounded in the live search results and the page’s actual content.

What a diagnosis actually returns

Three screenshots below are one report, read top to bottom — not three examples. This is the sample analysis the product serves on its own landing page.

About this data

It is a real run against a live blog post, not a mockup. The analysed page appears as a path only — /blog/jade-plant-care-guide — so the site being diagnosed stays anonymous. The competitor domains are real, named exactly as the model named them, and were already public before this page existed.

The head of the report is the arithmetic and the summary. One click in twenty-eight days against 174 impressions at average position #54 — that is the deterministic engine’s output, and it costs nothing. Everything below it is the part that needed a model.

The top of a SerpVive report: the page path and keyword, clicks, impressions and average position for the last 28 days, a prose summary of the SERP, a topic-coverage bar at 14 of 16, and four bullet points listing what the page already does better than its competitors

Topic coverage is the one number I would point at. 14 of 16 means the model enumerated sixteen subtopics the ranking pages collectively cover and found two missing from this one — and it says which two, with the competitor that owns each.

The causes are ranked and every one carries its evidence. Five of them, High through Low, and each closes with an Evidence: line stating the counts the judgement rests on. That line is the part I care about: a claim like “low internal linking” is worthless without “yours 16, theirs 43, 50 and 27”.

Five ranked causes from a SerpVive report, each with a coloured severity bar and a High, Medium or Low badge: search intent mismatch, low internal linking, an over-long title tag, no video content, and external links pointing only to affiliate pages — every one followed by an italic Evidence line citing specific counts

The refresh brief turns the diagnosis into work. Each action is estimated in minutes and typed by what it touches, and the urgent one expands into a micro-draft — three concrete title rewrites, with the competitor whose title shape they borrow.

The Refresh Brief section of a SerpVive report: four actions totalling an estimated four hours, the urgent one expanded to show three suggested title rewrites in monospace with a reference line, and three collapsed actions labelled Important, Important and Nice to have

That is the whole output of one paid call. The rest of this page is how it gets there, and what it costs.

There are two ways in, and the report above is only the first. That one starts from Search Console: the engine flags a decaying page, and diagnosing it is a deliberate second step against history the system already holds. The other takes a URL that nothing is monitoring — api/analyze-url accepts { url, keyword } and nothing else, with no property to verify and no months of data to ingest first. The two are sibling functions in the same module, runDiagnosisPipeline and runExternalPipeline, and they diverge where you would expect: the standalone one runs a different prompt, because there is no traffic history to explain a decline from. It is how someone evaluates the tool before connecting anything to it.


The problem, and where I drew the line

Bloggers and SEO consultants find decaying content by exporting Search Console data into spreadsheets and eyeballing the deltas, page by page, month after month. That work splits cleanly in two:

SerpVive is built along that seam. Detection is free, instant and reproducible. Diagnosis costs real money and takes minutes. Keeping them separate is what makes the product viable rather than a wrapper that bills an LLM call for subtraction.


The scoring engine is deterministic on purpose

Everything in src/lib/engine/ is pure arithmetic. No model is involved at any point.

Decay score — (peak_clicks − current_clicks) / peak_clicks × 100, where peak is the highest monthly click total in a 16-month window and current is the sum of the last 28 days.

Velocity, at two resolutions — velocity_7d compares this week against last week, velocity_28d compares the last 28 days against the 28 before that. Positive means declining. One catches a cliff, the other catches a slide.

Seasonality check — the current 28-day window is compared against the same window one year earlier. If the ratio sits within ±20%, the page is flagged seasonal rather than decaying. This exists to kill a specific false positive: a page that predictably dips every year is not dying.

Noise filter — if the peak is under 10 monthly clicks, the score is forced to zero. Without it, a page going from 3 clicks to 1 reports as a 67% collapse and drowns the signal. A page that is growing scores zero too.

The window and the guards, from the scorer itself:

const twentyEightDaysAgo = new Date(now.getTime() - 28 * 24 * 60 * 60 * 1000)
  .toISOString()
  .slice(0, 10);
const sixteenMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 16, 1)
  .toISOString()
  .slice(0, 10);src/lib/engine/decay-scorer.ts
// Calculate decay score
let decayScore = 0;
if (peakClicks >= 10 && currentClicks28d < peakClicks) { 
  decayScore = Math.round(((peakClicks - currentClicks28d) / peakClicks) * 10000) / 100;
}src/lib/engine/decay-scorer.ts

That one highlighted condition is both guards at once. peakClicks >= 10 is the noise filter; currentClicks28d < peakClicks means a page that is growing scores zero rather than a negative number that would sort strangely.

Velocity is the same arithmetic over two window pairs — this week against last, and the last 28 days against the 28 before them:

const velocity28d =
  period2Clicks > 0
    ? Math.round(((period2Clicks - period1Clicks) / period2Clicks) * 10000) /
      100
    : 0;src/lib/engine/velocity.ts

And seasonality is a ratio against the same window one year earlier, inside a ±20% band:

let isSeasonal = false;
if (lastYearClicks > 0 && currentClicks > 0) {
  const ratio = currentClicks / lastYearClicks;
  isSeasonal = ratio >= 1 - tolerance && ratio <= 1 + tolerance; 
}src/lib/engine/seasonal.ts

Why no LLM here. The project has an explicit rule, written into its CLAUDE.md before the first line of application code: never use a language model where deterministic math works. The model is reserved for the single step that cannot be computed — explaining why a page is losing, given the SERP, the competitors’ content and the page itself. That boundary is the main design decision in the whole system, and it is the reason detection can run on every page of every site continuously while diagnosis stays a deliberate, budgeted action.


A four-provider failover chain across three external APIs

Diagnosis calls run through a chain of four models spanning three separate providers. The ordering is not arbitrary — it escalates by blast radius, from the cheap fix to the full infrastructure swap:

flowchart TD
    START([Diagnosis request]) --> KILL{AI_DISABLED?}
    KILL -->|yes| STOP([AiDisabledError])
    KILL -->|no| CAP{Under spend cap?}
    CAP -->|no| CAPSTOP([AiSpendCapExceededError])
    CAP -->|yes| P1

    P1["1 · Claude Opus 5<br/>Anthropic"] -->|success| DONE([Diagnosis returned])
    P1 -->|"timeout 120s / error"| P2
    P2["2 · Claude Sonnet 5<br/>Anthropic — rate limits only"] -->|success| DONE
    P2 -->|fail| P3
    P3["3 · Gemini 3.7 Flash<br/>Google — survives an outage"] -->|success| DONE
    P3 -->|fail| P4
    P4["4 · GPT-5.6 Terra<br/>OpenAI — third infrastructure"] -->|success| DONE
    P4 -->|fail| EXHAUSTED([FallbackExhaustedError<br/>+ full attempt log])

Three mechanics make it work:

Each attempt is raced against a timer, so a provider that hangs cannot hold the chain:

// Race the provider call against a timeout
const result = await Promise.race([
  provider.call(messages, options),
  new Promise<never>(
    (_, reject) => setTimeout(() => reject(new Error("TIMEOUT")), timeout)
  ),
]);src/lib/ai/fallback-chain.ts

A failure that cannot succeed on a retry is classified as permanent, so the chain stops paying for it:

function isNonRetryable(error: string): boolean {
  const lower = error.toLowerCase();
  return (
    lower.includes("401") ||
    lower.includes("403") ||
    lower.includes("invalid_api_key") ||
    lower.includes("invalid api key") ||
    lower.includes("incorrect api key") ||
    lower.includes("authentication") ||
    lower.includes("permission denied") ||
    lower.includes("model_not_found") ||
    lower.includes("model not found") ||
    lower.includes("not set") ||
    lower.includes("billing") ||
    lower.includes("quota exceeded") ||
    lower.includes("invalid_request") ||
    lower.includes("400")
  );
}src/lib/ai/fallback-chain.ts

Fourteen substring tests, which is the honest shape of it: the providers do not agree on an error taxonomy, so the classifier matches strings against their messages. It is the least elegant code on this page and I am not going to dress it up.

"not set" is highlighted because it is the one that catches a bug rather than an outage. Each provider reads its own key inside call(), not at construction — providers/openai.ts throws "OPENAI_API_KEY not set" there, gemini.ts the equivalent. getDiagnosisChain() is supposed to have excluded those providers already, so if that error ever reaches the classifier the two disagree about what is configured. Treating it as permanent means the chain skips the slot instead of timing out against it.

Both levels have fired in production. One diagnosis of eleven completed on Sonnet after Opus failed — a same-provider fallback. Separately, Gemini hit its API quota mid-brief and the chain fell through to OpenAI and completed. The design was not theoretical; it is load-bearing, and the model_used column and pipeline logs are where those two episodes are recorded.


A pipeline that expects malformed output

Language models return broken JSON in predictable ways, so the pipeline repairs before it rejects. Every step below is a real branch in json-extract.ts and diagnose.ts:

flowchart TD
    RAW([Raw model response]) --> S1

    subgraph EXTRACT["1 · Extract and repair — json-extract.ts"]
        direction TB
        S1[Strip markdown fences] --> S2[Slice to outermost braces]
        S2 --> P1{Parses?}
        P1 -->|no| S4[Escape newlines<br/>drop trailing commas]
        S4 --> P2{Parses?}
        P2 -->|no| S6[Close unbalanced brackets]
        S6 --> P3{Parses?}
    end

    P1 -->|yes| PRE
    P2 -->|yes| PRE
    P3 -->|yes| PRE
    P3 -->|no| FAIL([Unrecoverable])

    PRE["2 · Truncate to 5 causes"] --> ZOD{"3 · Zod validation"}
    ZOD -->|valid| STORE([Diagnosis stored])
    ZOD -->|invalid| RETRY["4 · Error-informed retry"]
    RETRY --> ZOD2{Valid now?}
    ZOD2 -->|yes| STORE
    ZOD2 -->|no| GIVEUP([Reject])

Two steps of the ladder, as they are written:

// Step 1: Remove markdown code fences
let cleaned = text.replace(/```(?:json)?\s*/g, "").replace(/```/g, "");

// Step 2: Find the JSON object boundaries
const firstBrace = cleaned.indexOf("{");
const lastBrace = cleaned.lastIndexOf("}");src/lib/ai/json-extract.ts
// Step 5: Try to fix truncated JSON by closing open brackets
const openBraces = (fixedTruncated.match(/{/g) ?? []).length;
const closeBraces = (fixedTruncated.match(/}/g) ?? []).length;

for (let i = 0; i < openBrackets - closeBrackets; i++) {
  fixedTruncated += "]"; 
}
for (let i = 0; i < openBraces - closeBraces; i++) {
  fixedTruncated += "}"; 
}src/lib/ai/json-extract.ts

Those two highlighted lines are what recovers a diagnosis that hit the output token limit mid-object: the model stopped talking, and the brackets get closed for it rather than the whole run being thrown away.

The retry is worth singling out. It does not simply ask again — it hands the model back its own broken output together with the precise validation errors. And the pre-validation truncation exists because a model returning six good causes when the schema allows five is not a failure worth paying for a second call to fix.

Untrusted input is sanitised on the way in. Scraped pages, competitor content and Search Console queries all pass through sanitize.ts before they reach a prompt, and model output is sanitised for XSS vectors before it is stored or rendered. The honest provenance: this was not foresight. A security audit flagged unsanitised external content in prompts as the top vulnerability — a competitor could embed hidden instructions in their own HTML — and the module is the fix.


Schema and data

Row-level security is not one setting, it is a policy per table per operation. One of the thirty migrations, in full:

ALTER TABLE public.external_analyses ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users can view own external analyses"
  ON public.external_analyses FOR SELECT
  USING (auth.uid() = user_id); 

CREATE POLICY "Users can insert own external analyses"
  ON public.external_analyses FOR INSERT
  WITH CHECK (auth.uid() = user_id); 

CREATE POLICY "Users can delete own external analyses"
  ON public.external_analyses FOR DELETE
  USING (auth.uid() = user_id);supabase/migrations/20260313_add_external_analyses.sql

auth.uid() is the caller’s id taken from their JWT, so Postgres refuses another user’s rows whatever the application asks for. Crons and webhooks use the service role, which bypasses RLS deliberately — and that key never reaches the browser.

Instrumentation and cost control

Every AI call logs tokens_input, tokens_output, cost_usd and processing_time_ms to the database. That is not decoration — it is the input to three controls:

The measured numbers above come from the previous model generation (Opus 4.6 / Sonnet 4.6): 11 production diagnoses, $0.2177 average cost per diagnosis, ~213 s average end-to-end latency. The chain now points at the current generation and has not been re-measured. The latency is not a request-response path — a diagnosis searches Google live, crawls the page plus up to three ranking competitors, and generates up to 8,192 output tokens before validation.


Running it yourself

Warning

This is not a clone-and-run project, and it would be dishonest to present it as one. Before anything renders you need a Google Cloud project with OAuth credentials for Search Console, a Supabase project with thirty migrations applied, and API keys for two paid third-party services. Budget an evening, not ten minutes.

What you need first

PrerequisiteWhat it costs
Supabase projectFree tier is enough. The schema is thirty migrations.
Google Cloud OAuth clientFree, but the Search Console consent screen has to be configured, and you must own a verified property to have any data to read.
Anthropic API keyPaid per call. A diagnosis averaged $0.2177 on the previous model generation.
Serper.dev keyPaid per search. One diagnosis is one SERP fetch.
Firecrawl keyOptional. Without it the fetcher falls back to Cheerio, which cannot see JavaScript-rendered pages.
Gemini / OpenAI keysOptional. The chain silently skips any provider whose key is unset, so you can run on Anthropic alone.

Setup

git clone https://github.com/levimbraga/serpvive.git
cd serpvive
npm install
cp .env.example .env.local

Apply the migrations to your Supabase project in filename order — they are timestamped, so sorting by name is chronological:

ls supabase/migrations/*.sql | sort   # 30 files, 20260309_… through 20260820_…supabase/migrations

They are plain SQL. Paste them into the Supabase SQL editor in that order, or run them with the Supabase CLI. Order matters: later migrations add columns to tables the earlier ones create.

Then:

npm run dev   # http://localhost:3000

The environment

I checked this the only way worth checking it: every process.env.X in the source, against every line the file declares. Nothing the code reads is missing. The gap runs the other way — .env.example declares NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, and docs/ARCHITECTURE.md repeats it, but no line of the application reads it. Checkout is server-side through stripe/checkout; the publishable key is a leftover. Anyone setting the project up would go and fetch a key they do not need.

The ones that decide whether the app boots at all:

VariablePurpose
NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEYClient and auth. Nothing works without them.
SUPABASE_SERVICE_ROLE_KEYServer-side admin client for crons and pipelines. Bypasses RLS — never expose it to the browser.
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URIThe Search Console OAuth flow. The redirect URI must match the Google console exactly.
ANTHROPIC_API_KEYPrimary diagnosis model.
SERPER_API_KEYLive SERP results.
CRON_SECRETBearer token guarding every cron endpoint.
NEXT_PUBLIC_APP_URLAbsolute base for redirects and emails.
ADMIN_EMAILAdmin account for demo management.

And the ones that decide what it costs you:

VariablePurpose
AI_SPEND_CAP_USDCeiling on total recorded AI spend. Unset means no cap. Approximate by design, and fails open.
AI_DISABLEDtrue stops every AI call at the single funnel they share, leaving ingestion, scoring and email running.
GOOGLE_GEMINI_API_KEY, OPENAI_API_KEYLinks three and four of the failover chain. Skipped when unset.
FIRECRAWL_API_KEYJS-rendered page extraction. Falls back to Cheerio.

Set AI_SPEND_CAP_USD before you run anything. It is the only thing standing between a loop and your card.

The first run

Connect a Search Console property, then trigger the engine from the dashboard. The deterministic pass runs first and costs nothing: it ingests the property’s pages, computes decay scores, velocity and seasonality, and classifies each page as healthy, warning, critical or dead. You get a populated dashboard without a single model call.

Only then is a diagnosis a separate, deliberate action — and that is the one that spends money. Expect a few minutes: the pipeline searches Google live, crawls your page plus up to three ranking competitors, and generates up to 8,192 output tokens before validation.

Adapting it to something else

The decision I defend most

The architecture and the complete SQL schema were specified before the first line of application code. The initial commit contains eleven project documents — product spec, architecture with the full schema, scope, backend algorithms, design system — and no features. The stack was then locked against drift by an explicit instruction never to substitute alternatives unasked.

Over 300+ commits later, the system still matches its day-one architecture document. That is the claim I would most want tested, and it is the one most easily checked: the first commit is in the history, and so is everything since.

On how it was built

Built with heavy AI assistance, before I decided to learn properly. It works, it runs unattended in production, and the decision to keep it here is part of the story.

Because every commit was a pair session, the git history cannot separate a human-typed line from a model-generated one, and I will not pretend otherwise. What I can defend is every decision on this page — each has a reason I can reconstruct, and several exist only because something broke in production and had to be understood.

What is not done

No automated tests and no CI — the project’s biggest gap. No paying users. Monetisation is integrated and switched off. Alerting is detected and logged but never delivered: the product finds decay and does not tell you. The quality of the diagnoses has never been systematically evaluated. These are listed in full, with what I would do first about each, in the repository README.


Source on GitHub

Share this post: