Enterprise AI in practice: how to build a custom SEO monitoring and optimization system (SEO Copilot)

Danny · September 14, 2026 · 62 min read · Updated September 24, 2026

This article documents how a cross-border business can build an SEO Copilot: from defining the SEO problems and preparing infrastructure to connecting Search Console, GA4, Google Ads and WordPress data, then producing actionable recommendations. A complete development SOP is included at the end for reference or as a direct guide for an AI Agent.

Why build your own SEO tool?

There is no shortage of SEO tools. Ahrefs, Semrush, Screaming Frog, plus the wave of AI SEO assistants that appeared over the past two years, can all help a business with keyword research, competitor analysis and site audits.

But after running cross-border business websites for a long time, I kept running into the same problem: general SEO tools solve analysis at the industry level, while a business actually needs a decision system built on its own data.

Semrush can tell you how many searches a keyword gets worldwide, which competitors are ranking and roughly how much organic traffic a site might attract. What it cannot answer are the questions a business cares about every day:

  • Why did impressions on a product page go up recently while its click-through rate keeps falling?
  • Which keywords are already on page two of Google and need one more push to reach page one?
  • Which pages on the site are competing for the same keyword?
  • Did last month’s content edits actually improve rankings and conversions?
  • Which pages pull in a lot of organic traffic but produce no business value?

The answers to these questions are not in third-party SEO SaaS platforms. They sit inside the company’s own data assets: Google Search Console, Google Analytics 4, Google Ads, the WordPress content database, and the company’s own product and business information. That data is unique to every business.

For an SEO software platform, “what is the global search volume for this keyword?” is a generic question. For a cross-border B2B company, “is this keyword worth investing content resources in?” depends on current ranking, page type, product fit, user behavior and conversion data, and only the company has that information.

So I started thinking: if all this scattered data could be connected and then handed to an AI Agent for automatic analysis, could I build an SEO Copilot that genuinely belongs to the business?

Making that happen used to be hard. You needed SEO expertise, data analysis skills, database skills, backend development, frontend development and server deployment at the same time. For most SEO specialists, marketers and even business owners, that is close to impossible. Most companies end up exporting Excel files on a schedule, cleaning the data by hand, and guessing the next optimization move from experience.

With AI Coding Agents, this has started to change. A person who understands the business no longer needs to be a professional programmer to take part in building a complex system. You define the business goals, map out the data sources and judge whether the analysis results make sense; the Agent handles the data processing, backend development, frontend interface, automation and deployment.

This article walks through building an SEO Copilot like that from zero to one. It is not a simple data dashboard, but an AI driven SEO system that collects SEO data automatically, analyzes site health, finds optimization opportunities, generates action recommendations and keeps tracking the results.

NOTE

To protect client data privacy, every site name, keyword, page URL and business figure in this article has been generalized. The architecture and the development process match the real project.

Before you start: what should SEO Copilot actually solve?

A common mistake when building AI tools is opening Claude Code or Codex and typing “build me an SEO tool.” A few minutes later a dashboard with a clean interface shows up, but it may not solve the core problem in SEO work at all. The most important part of an SEO system is not the interface, it is whether it helps the business make correct decisions. So before writing any code, define what this SEO Copilot actually needs to solve.

1. Monitoring overall SEO health

The first requirement is understanding the site’s overall health. The system needs to track organic search impressions, clicks, CTR, average position and organic conversion trends over time, with Google Search Console and Google Analytics 4 as the sources.

The end goal is not to display more numbers, but to answer one question: “over the past period, is the site’s SEO performance improving or getting worse?”

2. Automatically finding keyword growth opportunities

The keywords most worth attention in SEO are usually not brand new ones with no rankings at all, but the ones already close to page one that need one more push.

For example, a commercial keyword:

Example Keyword
Average Position: 13
Impressions (last 90 days): 15000
Clicks (last 90 days): 200

This tells us Google already considers the page relevant to the search intent. Compared with competing for a brand new keyword from scratch, these keywords usually have higher optimization value. SEO Copilot therefore needs to automatically surface keywords ranked 8 to 20, keywords with rising impressions, and keywords connected to the business, then turn them into a “worth optimizing now” task list.

3. Finding page-level SEO problems

Keywords are only the entry point. What actually affects SEO results is the page. The system needs to identify three types of problem pages.

High impressions, low clicks

A page gets strong impressions and ranks decently, but its CTR is clearly below what the ranking should produce. Typical causes are an unappealing title, a meta description that does not match search intent, or a mismatch between the page and the intent Google inferred.

Traffic but no conversions

A page receives a healthy amount of organic traffic each month and users stay on it, but no business action comes out of it. Usually the fix involves adjusting the CTA, improving the page structure, or adding related product content.

Thin content

The system needs to identify pages with too little content, missing topic coverage, or a mismatch with search intent.

4. Finding keyword cannibalization

Many business sites end up with several pages competing for the same keyword. A product page, a blog post and a resource page all try to rank for the same term, so Google is unsure which page is the main answer, which is Keyword Cannibalization.

SEO Copilot needs to analyze which keywords map to multiple URLs, which page performs best, whether content should be merged, and whether a page’s positioning needs to change.

5. Generating SEO recommendations automatically

Finding problems is not the end goal. The valuable part is telling the operator what to do next.

For example the system finds:

Page: /example-product-guide/
Keyword: Example Keyword
Position: 12
CTR: 0.8%

AI output:

  • Rewrite the title to improve click appeal
  • Add an FAQ around related questions
  • Add case studies and use scenarios
  • Add internal links

That is how SEO work moves from manual analysis to a system that hands out optimization tasks.

6. Closing the SEO optimization loop

The traditional SEO process is: find a problem, edit the page, wait for results. Its biggest weakness is that nobody records why the change was made or whether it worked. SEO Copilot builds a longer chain:

The SEO optimization loop

Find the problem

→

Generate a suggestion

→

Apply the change

→

Log the change date

→

Measure the effect

The result is a data driven SEO improvement cycle.

What infrastructure does SEO Copilot need?

Once the goals are clear, the next step is preparing the development environment. The system consists of an AI Agent, the data sources, a data warehouse and a server environment.

1. AI Coding Agent

This project uses an AI Coding Agent as the main development tool, meaning something like Claude Code or OpenAI Codex. The install steps for the former are in How to Install Claude Code on macOS, Windows, and Linux, and Codex is covered in How to install Codex CLI on macOS, Linux, and Windows. It takes on project planning, code generation, data structure design, debugging and deployment support.

Keep in mind that an Agent is not an automatic development machine. Its main value is turning your business idea into a working system quickly. You are responsible for deciding “what to do,” the Agent handles “how to implement it.” If you have not subscribed yet, this article covers it: How to Subscribe to ChatGPT and Claude Code Without a Credit Card.

2. Google Search Console

Google Search Console is Google’s own search analytics tool for websites. It shows which queries users typed, how many impressions and clicks a page received, plus CTR and average position, which makes it the most important first-party data for SEO.

The limitation is obvious: the web interface is built for manual inspection, and the moment you want a trend comparison longer than a few months, or want to look at search performance next to conversion data, the data has to move into BigQuery. The first half, connecting the site to Search Console and submitting the sitemap, is covered in How to Connect Your Website to Google Search Console and Submit a Sitemap. The second half, exporting search data to BigQuery, is in How to Export Google Search Console Data to BigQuery.

3. Google Analytics 4

GA4 provides what happens after a user arrives on the site: sessions, acquisition sources, page behavior and conversion events. Put simply, GSC tells you how users find the site and GA4 tells you whether that visit produced value.

The data preparation path is similar to GSC. First set up the data stream, covered in How to Set up GA4 Data Stream for Your Website, then export the event data to BigQuery as described in How to Export GA4 Data to BigQuery.

4. Google Ads

Google Ads data supplements commercial keyword analysis. Ad performance tells you which keywords carry commercial value, and combining it with organic ranking data reveals which paid keywords deserve a long-term SEO investment. The export process is covered in How to Export Google Ads Data to BigQuery.

5. Google BigQuery

BigQuery is the data foundation of the whole SEO Copilot. GSC, GA4 and Google Ads produce data, BigQuery stores and analyzes it. The system flow looks like this:

From data sources to SEO recommendations

Google Search Console

Google Analytics 4

Google Ads

↓↓↓

Google BigQuery

Stores and manages all SEO data

↓

SEO Analysis Engine

Turns rules into a shortlist of issues

↓

AI Agent

Explains causes and writes recommendations

↓

SEO Recommendation

An action list for the operator

Why not Excel? Because SEO data is long-cycle, multi-source and updated frequently, which calls for an actual data warehouse.

Once the data is in BigQuery, the Agent also needs to read it on its own. That means creating a dedicated Service Account with query permissions, and the steps are in How to Create a BigQuery Service Account for Your AI Agent.

6. Extra data assets SEO Copilot needs

Google Search Console, Google Analytics 4, Google Ads and BigQuery all answer the same question: what is happening on your own site. A Copilot that supports business SEO decisions needs three more kinds of data: your own target keywords, how competitors’ keywords perform, and the content the site actually has today. SEO decisions are not only about how your site performs. They are also about what the market is doing and what content you already own.

Three data assets that are easy to overlook

Google Ads keyword structure

Commercial terms the business validated

Competitor keyword data

Rankings and traffic from SEMrush

Site content snapshot

Pulled through the WordPress REST API

↓↓↓

Google BigQuery

Joined with the rest of the data layer

Google Ads data is not only useful for judging ad performance. It is also where your commercial keywords come from. When a company keeps bidding on the same set of terms, those terms have usually been validated by the business and bring inquiries or orders. Put the bid keywords, ad group structure, click data and conversion data next to organic search data, and the system can spot opportunities that neither report shows on its own. If a keyword keeps converting through ads while its organic position sits on page two, the system flags it as a term worth a long-term SEO push.

One caveat. The Google Ads data that lands in BigQuery is shaped for ad performance analysis, while the ad group keyword structure your team maintains by hand is a separate thing that is better stored as its own source. With both in place, the system knows which keywords bring traffic and which ones carry commercial value.

Competitor keyword data comes from an external SEO platform such as SEMrush, which can sync each competitor’s ranking keywords, the pages they rank with, position changes and estimated traffic on a schedule. This data answers questions your own dashboards cannot: which new opportunities a competitor picked up recently, which topics are turning into industry trends, and which terms are worth targeting early. If the platform exposes an API, a scheduled job can handle the whole sync with no manual exports. A lighter tool such as KWFinder covers the basic keyword data if you want to keep the cost down.

The third asset is a snapshot of your own content. Ranking data tells you a page was found, not what the page says, so the system needs to pull the site’s content and store it. On WordPress you can call the REST API directly to fetch pages, posts, products and categories, saving the URL, title, meta information, body content, last update time and content length. Once a week is enough. With that snapshot in place, the AI can judge whether a page covers its search intent, whether it is thin content, and whether it needs an update.

All three belong in the same recurring update flow as everything else rather than a one-off import. Every week the system refreshes the search and behavior data in BigQuery, syncs competitor keywords, updates the content snapshot, then runs the analysis and writes the recommendations.

The weekly update flow

Weekly job

Triggered on a schedule

Refresh BigQuery data

Search and behavior data updated

Sync competitor keywords

Rankings, pages and estimated traffic

Update content snapshot

Latest version of every page

Run the SEO analysis

Models filter opportunities and problems

Generate recommendations

A list a human can act on

After a few months, what accumulates becomes more useful than any single report: search data, behavior data, commercial keywords, competitor movements and content assets all sit in one warehouse, so every analysis sees your own business, the market and the content you already have at the same time. That is the practical difference between a self-built system and an off-the-shelf SEO tool. A tool hands you industry data anyone can buy. Your own system derives its recommendations from your data.

7. VPS server

To keep the system running, it needs a server hosting the backend services, database, dashboard and scheduled tasks. You can use a cloud instance, a VPS or run everything in Docker. For an individual developer or a small team, a low-cost VPS is enough for this type of AI application. I recommend Hostinger or Tencent Cloud Lighthouse.

Don’t let the Agent write code first, make it understand the project

This is the most important step in the whole development process. Many AI projects fail not because the Agent cannot write code, but because the Agent has no idea what problem it is supposed to solve.

Tell an Agent “build an SEO dashboard” and it may produce a few stat cards, a couple of trend charts and a good looking homepage, but it does not know which metrics matter or which analysis has business value. The correct order is to establish project context first.

The traditional development flow

Requirements move through a product manager, design, development and finally testing:

Traditional development

Requirements

→

Product manager

→

Design

→

Development

→

Testing

The Agent development flow

Now the flow starts from the business goal, turns it into project documents the Agent can read, then generates a plan and moves into implementation:

Agent development

Business goal

→

Project documents

→

Agent understands

→

Plan generated

→

Implementation

Before development starts, prepare the project documents. For example:

project/
├── README.md
├── BUSINESS.md
├── DATA_SCHEMA.md
├── PLAN.md
├── TODO.md
└── CLAUDE.md

BUSINESS.md: why the project exists

It records the business type, the SEO goal, user needs and the value the system delivers. For example: this is an SEO data analysis system for a cross-border business website, used to find keyword opportunities and page optimization directions automatically.

DATA_SCHEMA.md: what the data looks like

It records data sources, table structures and field relationships. For example, the Search Console data contains these fields:

Search Console
keyword
click
impression
position
CTR

PLAN.md: the build order

For example:

  1. Phase 1: data connections
  2. Phase 2: database design
  3. Phase 3: analysis modules
  4. Phase 4: dashboard
  5. Phase 5: automated reporting

CLAUDE.md: the Agent’s working rules

It tells the Agent the project conventions, technical requirements, coding style and things to watch out for. With that in place the Agent stops being a one-off code generator and becomes an engineering assistant that takes part in the project over time.

The next chapter moves into the actual system design phase: how to let the Agent design the SEO Copilot architecture, and how to build the data warehouse.

Let the Agent be the product manager: design the system first, then build

Once the groundwork is done, the next step is not asking the Agent to write code immediately. Many AI projects fail not because of technical problems, but because product design was never finished before development. In the past a product manager handled defining business goals, breaking down feature requirements, designing user flows and setting acceptance criteria. In the Agent development model that work still exists, only the executor changed: you need to get the business logic straight first so the Agent can understand the project.

1. From feature requests to system capabilities

At the start I did not tell the Agent “build me an SEO dashboard.” I broke down which capabilities the system needs instead. SEO Copilot splits into six core modules, each handling a different task.

The six core modules of SEO Copilot

01

Data collection

Pulls raw data from four sources

02

Data warehouse

Unified fields, long-term snapshots

03

SEO analysis engine

Turns data into a task list with rules

04

AI diagnosis

Explains causes in plain language

05

Dashboard

Lowers decision cost, answers what to do today

06

Automated reporting

Delivers the conclusions to a human

Data collection layer

It gathers the raw data, and there are four sources:

SourceWhat it provides
Google Search ConsoleQuery, click, impression, CTR, position
Google Analytics 4User behavior, page views, conversion events
Google AdsPaid keywords, cost per click, commercial value reference
WordPress databasePage information, content structure, publish date, categories

The goal of this layer is not analysis, only making sure all the data enters the system reliably.

Data warehouse layer

This layer stores and organizes the data. Why does it need its own layer? Because the data structures of the platforms are completely different: Search Console focuses on search performance, GA4 on user behavior, WordPress on content. If AI reads those tables directly, it has to work out again every time where the data lives and what each field means, which is slow. So a unified data layer comes first.

SEO analysis engine

This is the core of the system, and it turns raw data into SEO insight. A raw row is just keyword, position, impression and click; after analysis it becomes a task with a cause and a recommendation, such as an opportunity type of “page one promotion,” a cause of ranking already on page two with rising impressions and low CTR, and recommendations to optimize the title, expand the content and add internal links.

AI diagnosis layer

This layer solves the problem that traditional tools stop at “finding the issue.” A traditional tool says “this page has a low CTR,” but what the business needs is “why is it low, and what should we do.” So an LLM comes in here, combining page content, search terms, ranking and user behavior to produce natural language recommendations.

Dashboard layer

The dashboard is not for showing more data either, it is for lowering decision cost. The key screens are organized around SEO health score, keyword opportunities, page diagnosis and content suggestions, answering the question “what should I do today?”

2. Define the data flow before the pages

Many people start designing the interface when they build a dashboard, which is the wrong order for SEO Copilot. The right sequence is to start from the business question, then decide what data is needed, how it is calculated and how it is analyzed, and only then decide how to display it.

Define the data flow first

Business question

→

What data is needed

→

How it is calculated

→

How it is analyzed

→

How it is displayed

A concrete example: the question is “which keywords have a chance of reaching page one?” The data needed is keyword, position, impression, click and CTR, the analysis logic is position between 8 and 20 with impressions trending up and commercial relevance, and the output is an optimization list. Only at that point does the dashboard come into play.

3. Give the Agent project context

During actual development I ask the Agent to read these files first:

PROJECT_CONTEXT.md
DATA_SCHEMA.md
FEATURE_LIST.md
TECH_STACK.md

They work as the project manual. PROJECT_CONTEXT.md tells the Agent what the project is, DATA_SCHEMA.md tells it how the data is organized, FEATURE_LIST.md tells it what the system needs to do, and TECH_STACK.md tells it which technologies are in use. The benefit is that every time the Agent starts, it recovers the project context quickly, which is the main difference between Agent development and an ordinary ChatGPT conversation.

Building the SEO data warehouse: turning scattered data into a business asset

Once the system design is finished, the next step is building the data foundation. The core idea behind SEO Copilot is not letting AI guess, it is letting AI analyze the company’s own data, market data and content assets, so the data warehouse comes first.

1. Why BigQuery is required

The first instinct for most companies doing SEO analysis is exporting to Excel. That works in the short term, but three problems show up later.

Data volume grows

After a site has been running for a few years, Search Console data, GA4 event data and Ads data accumulate every single day, and Excel cannot manage that over time.

Data cannot be joined

To find out whether visits from a keyword led to a conversion, you have to join three pieces of information together, and doing it by hand is painful.

One conversion spans three data sets

Search Console

Keywords

→

GA4

User behavior

→

CRM

Inquiries

No automation

SEO Copilot has to run every day: pulling the latest data in the early hours, updating the database, analyzing the changes and generating a report. That requires structured data. BigQuery therefore becomes the data foundation of the system.

2. The overall SEO data architecture

The data flow starts from the site’s own data plus the three extra assets, converges in BigQuery, passes through the processing layer into the SEO Analysis Database, reaches the AI Agent, and ends as an SEO Report and dashboard.

SEO data architecture

Data from your own site

Search Console

Google Analytics 4

Google Ads

Extra data assets

Ad keyword structure

Competitor keywords

Site content snapshot

↓↓↓

Google BigQuery

Single store for raw data

↓

Data processing layer

Cleaning, unified fields, per-source tables

↓

SEO Analysis Database

Where analysis output is stored

↓

AI Agent

Explains causes, writes recommendations

↓

SEO Report / Dashboard

Conclusions for humans

Those sources do not appear in BigQuery by themselves. The three Google products have their own export paths: search data in How to Export Google Search Console Data to BigQuery, GA4 in How to Export GA4 Data to BigQuery, and Google Ads in How to Export Google Ads Data to BigQuery. The ad keyword structure, competitor keywords and content snapshot have no built-in export, so a scheduled job has to sync them, as described in the infrastructure section above.

3. Table design

Do not mix everything into one table. Splitting by source is the least painful approach.

TableFieldsWhat it stores
gsc_dailydate, query, page, click, impression, ctr, positionSearch performance
ga4_dailydate, page, session, engagement, conversionUser behavior
ads_keyword_dailydate, keyword, click, cost, conversionCommercial value
ads_keyword_structurekeyword, ad_group, business_valueCommercial keyword structure
competitor_keywordsdate, competitor, keyword, page, position, trafficCompetitive environment
pagesurl, title, content, category, publish_dateSite content

4. Why unified fields matter

Field names differ completely across sources. Search Console calls it click, GA4 calls it sessions, WordPress calls it post_status. Hand those tables to AI as they are, and it has to translate fields on every run, which is slow and error-prone. A unified model on top of the analysis layer maps every source to the same naming convention:

page
date
traffic
ranking
conversion
content_quality

With that in place, a single row the SEO Agent reads already contains traffic, ranking, conversion and content quality, and the analysis no longer has to care which table the data came from.

Once the data is in, how do you design the SEO analysis engine?

The data warehouse is only the foundation. What creates value is the analysis logic. The core of SEO Copilot is not the dashboard, it is the SEO Analysis Engine, which turns data into action.

1. Page one opportunity model

The first model looks for keywords that are one step away from page one. The conditions are usually a position between 8 and 20, impressions trending upward, and a query connected to the business. Matching rows are flagged as opportunities.

position: 8-20
impression: trending up
query: commercially relevant

The output is something an operator can act on directly:

Keyword: Example Keyword
Current position: 13
Opportunity: page one
Recommended: expand the page content
add internal links
strengthen E-E-A-T signals

2. High impressions, low clicks model

This is the most common SEO problem. Take one row of data:

impression: 50000
click: 200
CTR: 0.4%

Google is already showing the page widely, but users are not clicking. Possible causes are an unappealing title, a description that does not match intent, or a misread of the search intent. The AI reads the page content and narrows that down to one or two causes, then writes a recommendation.

3. Page conversion model

SEO is not only about traffic. The end goal for a business is usually inquiries, sales leads and orders, so this model joins the whole path together:

Conversion path analysis

SEO Traffic

→

Landing Page

→

User Behavior

→

Conversion

That is how you find which pages bring real commercial value and which ones just look busy.

4. Keyword cannibalization model

The detection logic is not complicated: look for one keyword mapped to several URLs. For example:

keyword: example keyword
URL A: position 8
URL B: position 15
URL C: position 22

The system flags possible page competition and recommends merging the pages, repositioning them, or designating one main target page.

5. Thin content model

SEO Copilot also needs to understand page quality. The checks cover word count, content structure, keyword coverage and internal link count, and the purpose is finding pages that exist but fail to satisfy search intent. The output is a list of content additions.

6. Why the analysis engine matters more than AI

Many people assume AI SEO means calling GPT. It does not. The biggest strength of an LLM is understanding, but it cannot replace data logic. The correct architecture is:

How rules, data and AI divide the work

Structured data

→

SEO rule analysis

→

AI understanding and recommendations

→

Human decision

AI explains, rules discover, data proves. That is also the biggest difference between an enterprise AI application and a plain ChatGPT prompt.

The next chapter moves into how to build an AI SEO diagnosis Agent that generates optimization recommendations automatically, covering how the Agent reads analysis output, how prompts are designed, how the AI understands page context, automated SEO reports, and the weekly and monthly reporting flow.

Getting the AI Agent into SEO analysis: from monitoring to diagnosis

The previous chapters completed the foundation of SEO Copilot: data sources connected, the data warehouse built, SEO analysis rules designed. But at that point the system is still, in essence, an automated data analysis platform. It can tell you which keywords changed position, which pages changed traffic and which metrics look abnormal, but it cannot answer why the problem appeared or what to optimize next. That is where an AI Agent adds value.

The core of SEO Copilot is not wiring GPT into a dashboard. A genuinely useful AI application puts the AI on top of structured data and business logic instead of letting it loose on raw data.

1. Why traditional SEO reports fall short

Most companies produce SEO reports on a schedule, covering organic traffic trends, keyword count changes, page visits and ranking movement. The data itself is fine. The problem is that data does not turn into action by itself.

A report shows a page gained 80% more impressions and 20% more clicks over the past 30 days while CTR dropped. What does that mean? Traffic demand may have risen across the market, the ranking improvement may have brought more impressions than the page can convert, or the title and intent match may be weak. A traditional SEO specialist has to run another round of analysis on top of that.

SEO Copilot takes on the whole stretch from spotting the change in data to understanding why it happened to proposing an optimization.

2. How the SEO Agent works

The SEO Agent does not read every piece of site data and improvise. A more sensible architecture is the chain below:

The SEO Agent pipeline

Data warehouse

History and unified fields

SEO analysis models

Rules run first

Anomaly and opportunity detection

Narrow down to the rows worth reading

AI Agent reads the context

Page content and business background

Optimization recommendations

Cause, priority, action

Report output

Into the dashboard and email

In this chain the program handles data calculation, condition checks and metric filtering, while the AI handles reading context, explaining causes and writing recommendations. That combination is more reliable than leaning on an LLM alone, because the verifiable part of the work always stays inside the program.

3. What context the AI Agent needs

An SEO Agent that only knows “keyword Example Keyword, position 12” cannot produce a good recommendation, because SEO judgment depends on context. Analyzing a page requires four kinds of information.

The first is page information: URL, title, meta description, page body and page type. Page type is often overlooked, yet a product page, a category page, a blog post and a technical resource page all need completely different optimization strategies. The same position 12 result means the product page needs specifications and an inquiry entry point, while the blog post needs depth and internal links.

The second is search performance data: queries, clicks, impressions, CTR, average position and historical trend. Those numbers tell the AI how Google evaluates the page. The third is user behavior data: sessions, engagement, time on page and conversion events, which tell the AI whether users actually find the page useful.

The fourth is business background, the part most general SEO tools miss. The same keyword may only represent traffic value for a news site, while for a B2B company it may represent real purchase intent. The AI has to understand that different business goals lead to different SEO conclusions.

4. SEO diagnosis model design

Inside SEO Copilot the AI analysis splits into several independent diagnosis models. They share the same context and differ only in filtering conditions and output format. These five were the first to work reliably.

Model 1: keyword opportunity analysis

The goal is finding the keywords most worth investing in. The system pulls out keywords around position 14 with rising impressions and low CTR, and the AI judges the search intent behind them and whether the current page content can serve it. Its output usually has three parts: the evidence that Google already accepts the page as relevant, the gaps in content depth and topic coverage, and concrete actions such as expanding the content, covering related topics, improving the page structure and adding internal links.

Model 2: page one opportunity analysis

This model handles pages ranked between 11 and 20 with rising impressions. The system marks them as potential growth pages and the AI assigns priority. Priority is not only about the ranking number: it also depends on whether the page targets a core business keyword, how expensive the change is, and whether existing internal links can carry some of the work.

Model 3: high impressions, low clicks analysis

This is the most common problem on business sites and the place where the AI adds the most direct value. After reading the page content and the search result snippet, it narrows the possible causes to one or two, for example noticing that the title is missing a modifier users actually search for, or that the description talks about specifications while users want selection advice. If the site runs on WordPress, this kind of bulk title and meta description editing can be handled with the All In One SEO plugin, and its official MCP support makes batch updates possible.

Model 4: keyword cannibalization analysis

When several pages rank for the same keyword, the AI has to answer which page ranks best, which one earns the most clicks and which content matches intent most closely, then recommend merging pages, repositioning them or setting one main target page. That judgment involves content semantics, which rules alone struggle to handle.

Model 5: content quality analysis

Ranking movement is the result; content is the cause. This model combines page length, content structure, topic coverage and internal links to identify thin pages, outdated pages and pages that do not cover the intent well enough. The output is not a score, it is a list of additions that can be scheduled.

5. Why the SEO Agent is not just a GPT call

Many people think of AI SEO as calling a large model and typing “optimize my page.” Enterprise AI applications do not work that way. The effective architecture runs business data through the analysis logic first, hands it to the AI to understand, and leaves the decision to a human. The AI’s strength is language; the data rules set the direction.

The order enterprise AI applications follow

Business data

→

Analysis logic

→

AI understanding

→

Human decision

So SEO Copilot does not replace SEO, it amplifies the judgment of an SEO specialist.

Building the automation layer of SEO Copilot

Once the data, analysis and AI diagnosis layers are in place, the next step is keeping the system running. The biggest SEO problem is usually not a lack of analysis, it is the inability to keep going.

1. Why automation is necessary

Most SEO programs end the same way: phase one builds a keyword list, phase two optimizes pages, phase three stops maintaining anything. The cause is usually the cost of manual monitoring, which is why after one round nobody looks again. SEO Copilot turns SEO monitoring from a periodic manual check into something that runs continuously.

2. The automation flow

One complete run looks like this:

What runs every day

Scheduled job starts

Sync the latest SEO data

Update the data warehouse

Run the analysis models

Call the AI diagnosis

Generate the report

Send the notification

Day to day, the system checks for new ranking opportunities, pages with abnormal traffic and pages losing CTR. Each week it generates an SEO analysis report.

3. Deployment architecture

An architecture that works for internal business use is not complicated:

VPS server
├── Frontend Dashboard
├── Backend API
├── Database
├── SEO Analysis Worker
└── Scheduler

ComponentWhat it does
Frontend DashboardDisplays the SEO overview, keyword opportunities, page diagnosis and optimization tasks
Backend APIServes the data endpoints, handles user requests and system logic
DatabaseStores historical SEO data, analysis results and AI reports
Analysis WorkerRuns the background computation, such as keyword opportunity detection, page anomaly checks and content analysis in the early hours
SchedulerTriggers those jobs on a timer, similar to scheduled processes inside a company

4. Why a VPS is enough for this AI application

Many people assume an AI application needs a complex cloud architecture. For an internal SEO tool the requirements are modest: a limited number of users, a manageable data volume and a workload that is mostly scheduled jobs. A standard VPS can run the web app, the database, the API service and the background tasks at the same time. If the scale really grows, moving to Cloud Run, AWS ECS or Kubernetes is still an option, and there is no reason to pay for a problem that has not appeared yet.

5. How SEO Copilot keeps evolving

Launching the system is not the end. The SEO environment keeps changing, so the tool has to keep evolving. Three directions are worth doing first. Competitor monitoring automatically surfaces competitors’ new ranking keywords and new content pages, which gives you evidence at the topic selection stage. Content update suggestions combine historical page performance with search trends to draft an update plan. SEO experiment management logs when a page was changed, what was changed and how it performed, gradually building an internal SEO experiment database you can consult the next time you make a decision.

SEO Copilot Complete Development SOP

The sections above are intended to help you understand the design logic of the complete system and prepare the essential tools required to build it. We have summarized that approach in a complete SOP that you can hand directly to an AI Agent for development. You can paste the content below into an Agent and use it as the complete implementation guide for SEO Copilot.

# SEO Copilot Single-Tenant Development and Deployment SOP (Agent Development SOP)

> **Document type**: A system-building and engineering-delivery standard operating procedure for AI coding agents such as Claude Code, Codex and Antigravity.
> **Engineering goal**: Build and deliver, from scratch, a single-user SEO intelligence loop for a small fixed set of websites (1–N sites hard-coded in configuration, not a self-service multi-tenant SaaS). The complete pipeline is: data ingestion → seven diagnosis engines → keyword selection and cannibalization prevention → prioritized to-do generation → 28-day attribution backtesting → LLM weekly report.
> **Execution constraint**: Avoid redundant theory. Strictly follow the architecture boundaries, data contracts, directory conventions and defensive rules in this SOP.
> **Source note**: This SOP was checked file by file against a real single-tenant system that has been stable in production for months. It covers all 10 Python modules, DDL and deployment configuration. Items marked “production-validated” describe real code behavior and can be copied directly; items marked “design suggestion” simplify areas that the production system may not implement. **This system is intentionally rough: no ORM, no migration tool, no login system and no task queue.** It serves one person managing a small number of their own websites, not a multi-user product. Do not over-engineer it.

---

## Contents
1. [System architecture and prerequisite resources](#1-system-architecture-and-prerequisite-resources)
2. [Project directory and configuration conventions](#2-project-directory-and-configuration-conventions)
3. [Database model and table contracts (PostgreSQL)](#3-database-model-and-table-contracts-postgresql)
4. [Core module implementation guide (M1–M8)](#4-core-module-implementation-guide-m1m8)
5. [Scheduling: in-process jobs, no independent worker](#5-scheduling-in-process-jobs-no-independent-worker)
6. [Docker orchestration and deployment](#6-docker-orchestration-and-deployment)
7. [Hidden dependencies and environment preparation](#7-hidden-dependencies-and-environment-preparation)
8. [Agent automation acceptance and smoke-test checklist](#8-agent-automation-acceptance-and-smoke-test-checklist)
9. [Definition of Done](#9-definition-of-done)

---

## 1. System architecture and prerequisite resources

### 1.1 Baseline technology stack (production-validated)

* **Runtime**: Python 3.12, based on the `python:3.12-slim` image.
* **Web framework**: FastAPI + Jinja2 server-side rendering. **The application layer has no login or session system**; authentication is completely delegated to HTTP Basic Auth at the reverse proxy (Traefik). Every FastAPI route allows requests that have already passed the proxy. This is intentional simplification: a personal system does not need an account system.
* **Data access**: **No ORM and no Alembic**. Use `psycopg[binary]==3.2.*` (psycopg3) with the `dict_row` row factory. Write SQL directly in each module and use `%(name)s` placeholders. Each module embeds its own `CREATE TABLE IF NOT EXISTS ...` DDL string; its entry function runs the DDL first, so schema creation is naturally idempotent and needs no migration tool.
* **Two storage layers**:
  - **PostgreSQL 16-alpine** is the working database queried directly by the application. Diagnosis engines, the dashboard, to-dos and weekly reports all read and write here.
  - **Google BigQuery is the upstream warehouse, not something this system builds**. GA4’s BigQuery linking and Search Console’s bulk data export are **native Google features** enabled in their respective admin screens. Google then writes data daily to the BigQuery dataset you specify; no code is required for that step. The system copies (does not query) the matching tables from BigQuery into local Postgres once a week. All analysis then runs as SQL against Postgres, avoiding extra BigQuery query charges.
* **Scheduling**: APScheduler **runs inside the FastAPI main process** and starts in `lifespan`. There is no separate worker container and no persistent task queue. Manual triggers, such as “run diagnosis now”, use a Python `threading.Thread` to execute in the background. There is no persistence or retry; a process restart loses the execution state. This is sufficient for a weekly batch that one person can rerun manually. Do not introduce Celery, Redis or a database queue just because this looks less elaborate.
* **Sites are hard-coded**: `db.py` contains a Python dict such as `SITES = {"site_a": {...}, "site_b": {...}}`. Adding a site means changing code and redeploying, not creating it through a web form. This is the fundamental difference between this system and self-service SaaS. Keep it simple.

### 1.2 Prerequisite resource checklist

Before initializing the project, the Agent must verify that the following credentials and resources are available:

| Credential / resource | Environment variable or location | Required | How to obtain it and what it is for |
|---|---|---:|---|
| **Database connection** | `DATABASE_URL` | Yes | `postgresql://seocopilot:<pwd>@db:5432/seocopilot` (native psycopg3 URL; do not add the SQLAlchemy `+psycopg` dialect prefix) |
| **Postgres password** | `POSTGRES_PASSWORD` | Yes | Shared by the `db` container and `DATABASE_URL` |
| **Rank-check credential** | `SERPER_API_KEY` | Yes | Serper.dev API key for live Google SERP checks |
| **LLM weekly report** | `DEEPSEEK_API_KEY` | Yes | Official DeepSeek API key, using the `deepseek-chat` model |
| **BigQuery service account** | A file, not an environment variable | Yes | Google Cloud service-account JSON mounted at `/srv/gcp-service-account.json`. `GOOGLE_APPLICATION_CREDENTIALS` points to it; `bigquery.py` already defaults to this path. See Section 7. |

> [!NOTE]
> This system does **not** use `SECRET_KEY`, `FERNET_KEY`, `GOOGLE_CLIENT_ID/SECRET` or `RESEND_API_KEY`. There are no sessions to sign, no OAuth token to encrypt and no outbound email. If you see those variables in another document, they belong to a more complex multi-tenant SaaS version, not this single-tenant SOP.

---

## 2. Project directory and configuration conventions

### 2.1 Directory structure (production-validated)

```text
seocopilot/
├── docker-compose.yml
├── .env.example                 # Create this yourself; never commit the real .env
├── gcp-service-account.json     # Google Cloud service-account key; read-only mount, never commit
├── raw-data/                    # One-time / periodic raw data import directory, read-only mounted
│   ├── <site-a-dir>/
│   │   ├── bigquery-data/       # GSC/GA4 CSV exports for historical cold start
│   │   │   ├── <site>_gsc_url_daily_web_000000000000.csv
│   │   │   ├── <site>_gsc_query_url_daily_web_000000000000.csv
│   │   │   └── <site>_ga4_landing_page_daily_000000000000.csv
│   │   ├── google-ads-data/     # Google Ads CSV export; Chinese headers are mapped in M1
│   │   ├── 竞争对手关键词/        # Semrush/Ahrefs Excel/CSV exports; filename format is required
│   │   └── 网站content/          # WordPress JSON exports: pages.json / posts.json
│   └── <site-b-dir>/ ...
└── web/
    ├── Dockerfile
    ├── requirements.txt
    └── app/
        ├── main.py               # FastAPI entry point: routes + APScheduler lifecycle
        ├── db.py                 # Connection factory + SITES list; the only configuration center
        ├── bigquery.py           # Weekly incremental GSC/GA4 sync from BigQuery to local Postgres
        ├── importer.py           # One-time/periodic Ads, competitor and WordPress imports
        ├── selection.py          # Keyword selection: ads/gap/wishlist + site ownership
        ├── serper.py             # Rank checks, including pagination offset handling
        ├── diagnose.py           # Seven SEO diagnosis rules
        ├── todos.py              # To-do generation + 28-day attribution backtest
        ├── report.py             # Combined multi-site weekly report generated by DeepSeek
        ├── queries.py            # Dashboard aggregation queries
        ├── templates/            # Jinja2: base / index / site / tracking / diagnosis / todos / reports / empty
        └── static/               # Site logo, favicon and other static assets
```

> [!IMPORTANT]
> There is no `models.py`, `alembic/`, `auth.py` or `security.py`. Those belong to another multi-tenant version. **Do not add them back by assumption.**

### 2.2 Route table (production-validated: 8 routes + 4 manual triggers)

| Method | Path | Purpose |
|---|---|---|
| GET | `/health` | Health check; probes `SELECT 1` |
| GET | `/` | Redirects to the overview page of the first site |
| GET | `/site/{site}` | Single-site overview dashboard; renders `empty.html` if the site tables are not ready |
| GET | `/site/{site}/tracking` | Keyword rank-tracking table |
| GET | `/site/{site}/diagnosis` | Latest diagnosis results |
| GET | `/site/{site}/todos` | To-do list |
| POST | `/todo/{todo_id}/update` | Updates to-do status and `feedback`, then 303-redirects to the referrer |
| GET | `/reports` | Historical weekly reports, rendered from Markdown; **not separated by site**, one report covers all sites |
| POST | `/admin/generate-todos` `/admin/generate-report` `/admin/rank-check` `/admin/diagnose` | Manually triggers a task in a background thread and 303-redirects to the referrer |

The `{site}` path parameter must be a key in `db.SITES`; unknown keys return 404. That is the entire site-ownership check. No additional authentication dependency is required.

---

## 3. Database model and table contracts (PostgreSQL)

**There is no central schema file.** Each table’s DDL lives in the module that owns it. Use `CREATE TABLE IF NOT EXISTS` and run it whenever the module entry function starts. When rebuilding the system, put each DDL block in the top of its corresponding module instead of creating a centralized `models.py`.

### 3.1 Ingestion layer (`importer.py` creates/imports; `bigquery.py` refreshes three tables)

```sql
-- gsc_url_daily: GSC detail by day and landing page
CREATE TABLE IF NOT EXISTS gsc_url_daily (
  site text NOT NULL, data_date date NOT NULL, landing_page text NOT NULL,
  country text, device text,
  impressions int, clicks int, ctr double precision, avg_position double precision);
CREATE INDEX IF NOT EXISTS idx_gud_site_date ON gsc_url_daily (site, data_date);

-- gsc_query_url_daily: GSC detail by day, query and landing page
CREATE TABLE IF NOT EXISTS gsc_query_url_daily (
  site text NOT NULL, data_date date NOT NULL, query text NOT NULL, landing_page text NOT NULL,
  country text, device text,
  impressions int, clicks int, ctr double precision, avg_position double precision);
CREATE INDEX IF NOT EXISTS idx_gqud_site_date ON gsc_query_url_daily (site, data_date);
CREATE INDEX IF NOT EXISTS idx_gqud_site_query ON gsc_query_url_daily (site, query);

-- ga4_landing_page_daily: GA4 detail by day and landing page
CREATE TABLE IF NOT EXISTS ga4_landing_page_daily (
  site text NOT NULL, data_date date NOT NULL, landing_page text NOT NULL,
  country text, device text,
  sessions int, engaged_sessions int, engagement_rate double precision,
  engagement_time_seconds double precision, key_events double precision,
  conversion_rate double precision);
CREATE INDEX IF NOT EXISTS idx_ga4_site_date ON ga4_landing_page_daily (site, data_date);

-- ads_keywords: Google Ads keyword performance, manually imported
CREATE TABLE IF NOT EXISTS ads_keywords (
  site text NOT NULL, keyword text NOT NULL, keyword_clean text,
  match_type text, campaign text, ad_group text, kw_status text, status text,
  final_url text, clicks int, impressions int, ctr double precision,
  conversions double precision, cost double precision);

-- competitor_keywords: Semrush/Ahrefs competitor keyword export
CREATE TABLE IF NOT EXISTS competitor_keywords (
  site text NOT NULL, competitor text NOT NULL, geo text,
  keyword text NOT NULL, position int, prev_position int,
  search_volume int, kd double precision, cpc double precision,
  url text, traffic double precision, intents text, position_type text, export_date date);
CREATE INDEX IF NOT EXISTS idx_ck_site_kw ON competitor_keywords (site, keyword);

-- wp_content: WordPress content snapshot; this system has no page crawler
CREATE TABLE IF NOT EXISTS wp_content (
  site text NOT NULL, wp_id bigint, object_type text, slug text, url text,
  title text, seo_title text, meta_description text,
  content_text text, word_count int, published text, modified text);
CREATE INDEX IF NOT EXISTS idx_wp_site_type ON wp_content (site, object_type);
```

### 3.2 Analysis and execution layer (DDL embedded in each module)

```sql
-- tracked_keywords: selected terms, created by selection.py
CREATE TABLE IF NOT EXISTS tracked_keywords (
  id serial PRIMARY KEY,
  site text NOT NULL, keyword text NOT NULL, source text NOT NULL,  -- ads | gap | wishlist
  score double precision, reason text,
  search_volume int, kd double precision,
  active boolean DEFAULT true,
  created_at timestamptz DEFAULT now(),
  UNIQUE (site, keyword, source));

-- rank_check_runs / rank_checks: rank-check batches and results
CREATE TABLE IF NOT EXISTS rank_check_runs (
  id serial PRIMARY KEY,
  started_at timestamptz DEFAULT now(), finished_at timestamptz,
  trigger text, keywords int DEFAULT 0, errors int DEFAULT 0);

CREATE TABLE IF NOT EXISTS rank_checks (
  id serial PRIMARY KEY,
  run_id int REFERENCES rank_check_runs (id),
  site text NOT NULL, keyword text NOT NULL,
  check_date date DEFAULT current_date,
  position int, url text);              -- NULL position = not in the top 30
CREATE INDEX IF NOT EXISTS idx_rc_site_kw ON rank_checks (site, keyword, check_date);

-- diagnostics: diagnosis results; delete and reinsert on same-site same-day rerun
CREATE TABLE IF NOT EXISTS diagnostics (
  id serial PRIMARY KEY,
  site text NOT NULL, run_date date DEFAULT current_date,
  rule text NOT NULL, severity text NOT NULL,
  subject text NOT NULL, url text, details jsonb, score double precision);
CREATE INDEX IF NOT EXISTS idx_diag_site_date ON diagnostics (site, run_date);

-- todos: closed-loop action items
CREATE TABLE IF NOT EXISTS todos (
  id serial PRIMARY KEY,
  site text NOT NULL,
  rule text NOT NULL, severity text,
  title text NOT NULL, description text,
  subject text, url text,
  status text NOT NULL DEFAULT 'open',   -- open | in_progress | done | dismissed
  feedback text,
  baseline jsonb,                         -- 28-day snapshot at creation
  created_at timestamptz DEFAULT now(),
  completed_at timestamptz,
  UNIQUE (site, rule, subject));

-- weekly_reports: one combined report for all sites
CREATE TABLE IF NOT EXISTS weekly_reports (
  id serial PRIMARY KEY,
  week_start date NOT NULL,
  content_md text NOT NULL,
  model text, created_at timestamptz DEFAULT now());
```

> [!CAUTION]
> **Attribution backtesting is also calculated on demand and not persisted.** `todos.py` calculates the before/after 28-day comparison from `completed_at` whenever the to-do list is rendered. The `todos` table has no dedicated attribution-result field.

---

## 4. Core module implementation guide (M1–M8)

### M1 · Data ingestion: two independent paths (production-validated)

**Path A: automatic BigQuery incremental sync (continuous, no manual work)**

Prerequisite: GA4 BigQuery linking and Search Console bulk data export are enabled in Google’s admin screens. Both are native Google features and require no code. After activation, Google writes data daily to the selected BigQuery dataset; this system does not participate in that step.

`bigquery.py` does the following:

- `discover(client)`: walks all BigQuery datasets and tables in the GCP project and fuzzy-matches a site key in the dataset/table name plus a keyword from `TABLE_PATTERNS`. GA4 view names often contain partitions, dates or site names, so keyword matching is safer than exact table names:
  ```python
  TABLE_PATTERNS = {
      "gsc_url_daily": ["gsc_url_daily_web"],
      "gsc_query_url_daily": ["gsc_query_url_daily_web"],
      "ga4_landing_page_daily": ["ga4", "landing_page_daily"],
  }
  ```
- `pull(trigger)`: for every matched (site, local-table) pair, selects all BigQuery columns, runs `DELETE FROM <table> WHERE site = ?` locally and bulk loads with `cur.copy(...)` (`COPY ... FROM STDIN`). This is **idempotent**: it deletes and reloads rather than upserting, so reruns are safe.
- Pulls only the three GSC/GA4 tables. Google Ads, competitor data and WordPress content use Path B because they do not exist in BigQuery.
- `python -m app.bigquery test` runs a standalone connection test and lists every dataset/table and row count, making “why was nothing matched?” easy to diagnose.

**Path B: one-time/periodic raw-file imports (a person drops files into `raw-data/`)**

`importer.py` handles four data types. Directory and filename conventions must be followed exactly or the parser will not recognize the files:

1. **Historical GSC/GA4 CSV** (`raw-data/<site>/bigquery-data/*.csv`): export the historical range from BigQuery for the initial cold start and load it locally. After that, Path A handles incremental refreshes. Filenames only need to contain `gsc_url_daily`, `gsc_query_url_daily` or `ga4_landing_page_daily` for the glob to find them.
2. **Google Ads export** (`raw-data/<site>/google-ads-data/*.csv`): export from Google Ads. The Chinese-interface CSV has two title/summary rows, so the importer skips `lines[2:]`. The field mapping is:
   ```python
   ADS_COLMAP = {"关键字": "keyword", "匹配类型": "match_type", "广告系列": "campaign",
                 "广告组": "ad_group", "关键字状态": "kw_status", "状态": "status",
                 "最终到达网址": "final_url", "点击次数": "clicks", "展示次数": "impressions",
                 "点击率": "ctr", "转化次数": "conversions", "费用": "cost"}
   ```
   If the Google Ads account uses an English interface, change the source headers accordingly. **There is no API automation; this is a periodic manual export/import.**
3. **Competitor keyword export** (`raw-data/<site>/竞争对手关键词/*.xlsx|*.csv`, Semrush format): filenames must match `(.+?)-organic\\.Positions-(\\w{2})-(\\d{8})-`. The parser extracts competitor domain, region and export date from the default filename, so no extra form is needed.
4. **WordPress content export** (`raw-data/<site>/网站content/*.json`): expected shape is `{"items": [...], "collection"/"post_type": "..." }`. Each item needs `title`, `content` or `content_text`, `url` or `link`, and `slug`. HTML is stripped with a regex and tokenized to calculate `word_count`. **Generating this JSON is outside this system**; write a small script or use a WordPress plugin. This SOP does not include that exporter.

`importer.py` entry point `main(root)` runs all four imports. Each table is truncated and fully reloaded, not incrementally updated, so the process is idempotent and safe to repeat.

### M2 · Site-monitoring dashboard (`queries.py`, production-validated)

- `weekly_series(site)`: aggregates query-level and page-level Top 10/Page 2–3 keyword counts by `date_trunc('week', data_date)`, plus impressions, clicks, CTR, GA4 sessions and key events.
- `kpis(site)`: compares the most recent 28 days with the preceding 28 days.
- `query_table(site, bucket)`: `bucket='p1'` means `position≤10`; otherwise it returns the detail table for `10<position≤30`.
- `table_counts(site)`: row counts for six ingestion tables, so the dashboard can quickly show which data type has not been imported.
- `tracking_data(site)`: tracked keywords, latest/previous ranking and the most recent 12 checks, using a `LATERAL` subquery.
- `diagnosis_data(site)`: all findings from the latest diagnosis, sorted by rule and severity.

### M3 · Keyword rank tracking (Serper API, production-validated)

> [!CAUTION]
> **Serper pagination trap (hard constraint)**: when requesting `page=2/3`, `organic[i].position` starts counting from 1 again, and a page can contain fewer than ten results. Use the cumulative offset (the real number of results returned by earlier pages) plus the index within the current page. Add `len(organic)`, never a fixed `+10`.

**Additional optimization in this system**: if the same keyword is tracked by multiple sites, send only one SERP request and find each site’s domain in that response:

```python
def _run(trigger):
    key = os.environ["SERPER_API_KEY"]
    domains = {site: cfg["domain"].replace("https://", "").replace("http://", "")
               for site, cfg in SITES.items()}
    rows = ...  # SELECT DISTINCT site, keyword FROM tracked_keywords WHERE active
    by_kw = {}
    for r in rows:
        by_kw.setdefault(r["keyword"], []).append(r["site"])

    for kw, sites in by_kw.items():
        found = {}   # site -> (position, url)
        offset = 0
        for page in (1, 2, 3):
            resp = requests.post(SERPER_URL, json={"q": kw, "gl": "us", "hl": "en", "page": page},
                                 headers={"X-API-KEY": key, "Content-Type": "application/json"},
                                 timeout=30)
            organic = resp.json().get("organic", [])
            for i, item in enumerate(organic):
                link = item.get("link", "")
                for site in sites:
                    if site not in found and domains[site] in link:
                        found[site] = (offset + i + 1, link)
            offset += len(organic)
            if len(found) == len(sites) or len(organic) == 0 or offset >= 30:
                break   # all sites found, or no more results, or past the top 30
            time.sleep(0.2)
        # Write position/url as None for sites not found in the top 30
```

- Use `threading.Lock()` (`_lock.acquire(blocking=False)`) to prevent a scheduled job and a manual trigger from running at the same time. This is an in-process lock, not a database lock; it is enough for the current single-container deployment. If the system later scales horizontally, it will no longer be sufficient.
- Insert one `rank_check_runs` row per batch and write one `rank_checks` row per keyword.

### M4 · Seven core SEO diagnosis rules (production-validated; do not change the thresholds)

Implement these in `diagnose.py`, using SQL aggregates over the latest **28-day** window:

1. **`low_ctr` high impressions, low CTR**: `impressions≥40`, weighted average `position≤10` and `CTR<1%`. `imp≥100` is `high`; otherwise `medium`.
2. **`page2` second-page opportunity**: `impressions≥30` and weighted average `position BETWEEN 8 AND 20`.
3. **`cannibalization` keyword cannibalization**: the same query reaches `≥2` different landing pages within 28 days and has at least 30 combined impressions; `≥4` pages is `high`.
4. **`thin_page` thin content** (depends on `wp_content`): `word_count<150` and `impressions≥20`; limit `object_type` to `page`/ `post`.
5. **`click_no_session` clicks without sessions** (depends on GA4): `clicks≥3` but `sessions==0`.
6. **`no_conversion` high traffic without conversion** (depends on GA4): `sessions≥30` but `sum(key_events)==0`.
7. **`meta` meta-tag audit** (depends on `wp_content`): `seo_title` (not `title`; page SEO titles are stored separately) longer than 60 is too long; a title that lacks the brand name is also an issue after normalizing case and spaces:
   ```python
   BRAND = {"site_a": "Brand A", "site_b": "Brand B"}
   if brand.lower().replace(" ", "") not in seo_title.lower().replace(" ", ""):
       emit("Missing brand", "low", subj, url, {"cur": seo_title}, 1, f"Append '| {brand}' to the title")
   ```
   `meta_description` shorter than 70 is too short, longer than 200 is probably truncated body copy, and missing is a missing description. If the same `seo_title` appears on more than one page, report a duplicate title.

> [!IMPORTANT]
> **The system does not gracefully skip missing GA4 or crawler data.** The site list is a fixed handful and ingestion is expected to be complete before diagnosis. If your sites are added gradually or sources arrive at different times, add a per-site data-existence check in `run()` and skip rules that lack their required data.
>
> **Noise suppression for `meta` only**: emit every issue on pages with impressions (`impressions≥20`). Aggregate no/low-impression pages by issue type, such as `[batch] title too long ×N`, with up to six example URLs.
>
> **Idempotent reruns**: first `DELETE FROM diagnostics WHERE site=? AND run_date=current_date`, then insert the new results.

### M5 · Keyword selection and cross-site cannibalization prevention (production-validated; implement fully)

`selection.py` selects candidates from three sources and writes them into `tracked_keywords`:

1. **`ads` verified Google Ads terms**: a paid term is valuable if it has conversions, clicks or at least 30 impressions. Score it as `conversions*100 + clicks*3 + impressions*0.02`.
2. **`gap` competitor-gap terms**: a competitor has ranked in the top 15 and either we do not track the term or our weighted average position is `>20`. Score:
   $$	ext{score} = rac{	ext{search_volume}}{	ext{KD}+5} 	imes (	ext{commercial/transactional intent ? } 1.5 : 1) 	imes min(1+0.1	imes(	ext{competitor count}-1), 1.5)$$
3. **`wishlist` target terms**: a manually maintained seed list (`raw-data/wishlist-keywords.csv`, one `term` column). Join competitor data to obtain volume/KD and score it. Do not track terms already in the top 10. A term containing digits, such as a model number or specification, signals purchase intent and receives `×1.3`.

**Cross-site ownership prevention (original highlight; implement it)**: run each candidate through ownership rules; the first regex match from top to bottom owns the term, so a term is never tracked by two owned sites:

```python
SITE_RULES = [
    ("site_a", r"pattern1|pattern2|..."),   # category-specific terms
    ("site_b", r"pattern3|pattern4|..."),
    ("site_c", r"pattern5|..."),
]

def assign_site(kw):
    for site, pat in SITE_RULES:
        if re.search(pat, kw):
            return site
    return None   # discard terms that match none of the owned sites
```

Limit each source/site to 100 terms, sorted by score. Write with `ON CONFLICT (site, keyword, source) DO UPDATE` to refresh score/reason. Before the current run, set all existing terms to `active=false`; keep their historical ranks rather than deleting them.

### M6 · AI search visibility (GEO) monitoring (design suggestion; not implemented in production)

Follow the multi-tenant SOP idea: use a non-branded commercial-question matrix, call an LLM API with web search, and compare citations against owned domains with regex. If implemented, use an independent `geo_checks` table (`site`, `query`, `platform`, `cited`, `citation_rank`, `checked_at`) and a separate module. Do not mix GEO checks into the existing diagnosis rules.

### M7 · Action loop and 28-day attribution backtest (production-validated)

1. **To-do generation (`todos.generate`)**: for each site and rule, take the Top N from the day’s latest diagnoses, sorted by `severity` then `score`. `RULE_CAPS`: `low_ctr=10, cannibalization=6, page2=10, thin_page=5, click_no_session=1, no_conversion=5, meta=8`. If `(site, rule, subject)` exists, use `ON CONFLICT DO NOTHING`; do not duplicate or overwrite an existing status.
2. **Creation-time baseline**: query a 28-day window by query first when `subject` is a keyword; fall back to landing-page dimensions if no query result exists.
3. **Status flow**: `open → in_progress → done → dismissed`. The form submits `status` and `feedback`; if `feedback` is empty, preserve the existing value with `coalesce(nullif(%s,''), feedback)`.
4. **Attribution `impact()`**: calculate on demand, never persist. Use `completed_at` as the split point and compare impressions, clicks and weighted position for the 28 days before completion with the period from completion until now. Return `days_after`; the UI should show “data accumulating” when `days_after<7`.

### M8 · Combined multi-site LLM weekly report (production-validated)

1. **Assemble context (`report._collect`)**: iterate over all sites. For each, collect eight weeks of trends, 28-day KPI changes, the latest two rank-check comparisons (Top 10 gains/losses), diagnosis counts by rule/severity, to-do counts by status and the five most recent completed feedback items. Combine everything into one JSON object such as `{site_a: {...}, site_b: {...}, ...}`.
2. **One LLM call for one report covering every site**, not one call per site:
   ```text
   System prompt:
   You are a senior SEO consultant for B2B industrial websites. Based on the data JSON
   for multiple sites, write this week's SEO report in Markdown, without an H1.
   Structure:
   ## Overall summary — 3–5 sentences on the most important changes across all sites
   ## SWOT by site — one H3 per site, with 1–3 concrete S/W/O/T items backed by data
   ## Action priorities — global Top 5 sorted by expected impact; identify site/action/expected result
   Requirements: use only the supplied data, do not invent facts, preserve numbers,
   keep the language concise and direct, and avoid filler.
   ```
   Use DeepSeek `deepseek-chat` with `temperature=0.4` and `max_tokens=3000`.
3. **Persist; do not email**: store `content_md` in `weekly_reports.content_md` and use the current date for `week_start`. The report is rendered from Markdown on `/reports`; this system sends no email. Add Resend/SMTP only as an out-of-scope enhancement.

---

## 5. Scheduling: in-process jobs, no independent worker

### 5.1 Scheduled pipeline (production-validated; registered in `main.py` `lifespan`)

```python
@asynccontextmanager
async def lifespan(app):
    sched = BackgroundScheduler(timezone="UTC")
    # Monday pipeline (UTC): 02:00 BigQuery → 03:00 rank check → 05:00 diagnosis
    # → 05:30 to-dos → 06:00 LLM report
    sched.add_job(lambda: bigquery.pull("weekly"),  CronTrigger(day_of_week="mon", hour=2), id="weekly_bq_pull")
    sched.add_job(lambda: serper.run_check("weekly"), CronTrigger(day_of_week="mon", hour=3), id="weekly_rank_check")
    sched.add_job(lambda: diagnose.run("weekly"),   CronTrigger(day_of_week="mon", hour=5), id="weekly_diagnose")
    sched.add_job(lambda: todos.generate("weekly"), CronTrigger(day_of_week="mon", hour=5, minute=30), id="weekly_todos")
    sched.add_job(lambda: report.generate("weekly"), CronTrigger(day_of_week="mon", hour=6), id="weekly_report")
    sched.start()
    yield
    sched.shutdown(wait=False)
```

Everything runs serially on Monday. There is no daily incremental refresh: Google already updates the BigQuery source daily, and a weekly pull is enough for this system. If your use case needs fresher data, split `bigquery.pull` into a daily cron while keeping diagnosis, selection and reports weekly.

### 5.2 Manual triggers: background thread, not a task queue

```python
@app.post("/admin/diagnose")
def trigger_diagnose(request: Request):
    threading.Thread(target=diagnose.run, args=("manual",), daemon=True).start()
    return RedirectResponse(request.headers.get("referer") or "/", status_code=303)
```

Clicking a button starts a daemon thread, immediately returns a 303, and does not wait for the job to finish. The user refreshes the page to see results. There is no execution-state tracking or retry; a process restart loses a running thread. This is an intentional trade-off for one person managing a few sites once a week. Check container logs and click again if needed.

### 5.3 Idempotency guarantees

- `gsc_url_daily` / `gsc_query_url_daily` / `ga4_landing_page_daily`: `bigquery.pull()` deletes by site, then copies the fresh rows.
- `diagnostics`: delete by site and current date, then insert.
- `todos`: `UNIQUE (site, rule, subject)` + `ON CONFLICT DO NOTHING`.
- `tracked_keywords`: `ON CONFLICT (site, keyword, source) DO UPDATE`; terms not selected this run become inactive.
- Four `importer.py` source types: truncate each target table, then fully reload.

---

## 6. Docker orchestration and deployment

### 6.1 `docker-compose.yml` (production-validated)

```yaml
services:
  web:
    build: ./web
    container_name: seocopilot-web
    restart: unless-stopped
    env_file: .env
    volumes:
      - ./raw-data:/srv/raw-data:ro
      - ./gcp-service-account.json:/srv/gcp-service-account.json:ro
    depends_on:
      db:
        condition: service_healthy
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.seocopilot.entrypoints=websecure"
      - "traefik.http.routers.seocopilot.rule=Host(`seocopilot.yourdomain.com`)"
      - "traefik.http.routers.seocopilot.tls.certresolver=letsencrypt"
      - "traefik.http.routers.seocopilot.middlewares=seocopilot-auth"
      - "traefik.http.services.seocopilot.loadbalancer.server.port=8000"
      # htpasswd Basic Auth; escape $ as $$ for Compose interpolation
      - "traefik.http.middlewares.seocopilot-auth.basicauth.users=<user>:<htpasswd-hash>"

  # One-off tools container; no Traefik labels, so it is not load-balanced.
  # docker compose run --rm jobs python -m app.importer
  # docker compose run --rm jobs python -m app.selection
  jobs:
    build: ./web
    profiles: ["tools"]
    env_file: .env
    volumes:
      - ./raw-data:/srv/raw-data:ro
      - ./gcp-service-account.json:/srv/gcp-service-account.json:ro
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    container_name: seocopilot-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: seocopilot
      POSTGRES_USER: seocopilot
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U seocopilot -d seocopilot"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  pgdata:
```

There is **no independent worker container**. Scheduling and manual triggers run inside `web`; `jobs` exists only for infrequent one-off imports and reselection, and does not start during normal `docker compose up` because it uses `profiles: ["tools"]`.

**Basic Auth generation**: `htpasswd -nb <username> <password>` produces a `user:$apr1$...` hash. Put it into the label and escape each `$` as `$$` in Compose. Alternatively use `openssl passwd -apr1`.

### 6.2 `Dockerfile`

```dockerfile
FROM python:3.12-slim
WORKDIR /srv
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```

This is simpler than a multi-tenant version: no `alembic upgrade head` step and no health-check probe beyond what the Traefik layer needs.

### 6.3 `requirements.txt` (production-validated)

```text
fastapi==0.116.*
uvicorn[standard]==0.35.*
jinja2==3.1.*
psycopg[binary]==3.2.*
pandas==2.2.*
openpyxl==3.1.*
requests==2.32.*
apscheduler==3.10.*
markdown==3.7
python-multipart==0.0.*
google-cloud-bigquery==3.*
```

Not included: SQLAlchemy, Alembic, argon2-cffi, cryptography, google-auth/google-auth-oauthlib, or beautifulsoup4. There is no ORM, migration system, OAuth self-service flow or crawler. `pandas`/`openpyxl` read competitor Excel exports; `google-cloud-bigquery` is the official client used only by `bigquery.py`.

---

## 7. Hidden dependencies and environment preparation

The easiest place for a newcomer to get stuck is not code, but a few Google admin switches. Follow these steps:

1. **GA4 → BigQuery linking**: GA4 → Admin → Product links → BigQuery links → Create link. Select a GCP project and enable daily export. The free plan has limits but is sufficient for personal-scale use. Daily partitioned tables usually appear from the next day.
2. **Search Console → bulk data export**: Search Console → Settings → Bulk data export. Choose a GCP project and dataset. You need Owner/Editor access to that project and verified ownership of the Search Console property. Data begins writing daily from the next day.
3. **GCP service account**: in the project with BigQuery API enabled, create a service account with **BigQuery Data Viewer** and **BigQuery Job User**. Download its JSON key and mount it at `/srv/gcp-service-account.json`. **Never commit this file.**
4. **Historical cold start**: BigQuery linking only provides data from activation onward. For earlier history, export the desired range as CSV in BigQuery, put it under `raw-data/<site>/bigquery-data/`, run `importer.py` once, and then let `bigquery.pull()` handle ongoing refreshes.
5. **Google Ads is manual**: export the keyword report from Google Ads periodically, put it under `raw-data/<site>/google-ads-data/`, and run `importer.py`. This is the one recurring human step. Google Ads API automation is a reasonable future improvement, but outside this SOP.
6. **Competitor data**: export Organic Positions from Semrush/Ahrefs as Excel/CSV and keep the default filename format, such as `domain-organic.Positions-region-date-...`, under `raw-data/<site>/竞争对手关键词/`.
7. **WordPress content**: provide structured JSON such as `{"items": [...]}`. This SOP does not provide the exporter. A small WordPress REST API script can fetch `posts`, `pages` and `products` and output the fields required by the `wp_content` table.
8. **Traefik + Basic Auth**: if Traefik already runs, add a router and middleware labels. Without a reverse proxy, add `ports: ["8000:8000"]` to `web` and run locally; for personal use, do not force HTTPS prematurely.
9. **Serper.dev / DeepSeek accounts**: create accounts and obtain API keys.

---

## 8. Agent automation acceptance and smoke-test checklist

> The production project has no `tests/` directory and no pytest suite. Use this manual smoke-test sequence:

```bash
# 1. Health check and database connectivity
curl -s http://localhost:8000/health

# 2. BigQuery connection test and table discovery
docker compose run --rm jobs python -m app.bigquery test

# 3. Initial cold-start import
docker compose run --rm jobs python -m app.importer /srv/raw-data

# 4. Keyword selection
docker compose run --rm jobs python -m app.selection /srv/raw-data

# 5. Rank check
docker compose run --rm jobs python -c "from app.serper import run_check; print(run_check('manual'))"

# 6. Diagnosis
docker compose run --rm jobs python -c "from app.diagnose import run; run('manual')"

# 7. Generate to-dos
docker compose run --rm jobs python -c "from app.todos import generate; print(generate('manual'))"

# 8. Generate the report
docker compose run --rm jobs python -c "from app.report import generate; print(len(generate('manual')))"

# 9. Browser walkthrough
# Open http://localhost:8000/site/<your-site-key> and confirm that
# the overview, tracking, diagnosis and to-do pages all contain data.
```

Run steps 3 → 4 → 5 → 6 → 7 → 8 in order. If any step returns zero results, the previous import is wrong; check the `raw-data/` directory structure and filenames against Section 7.

---

## 9. Definition of Done

1. **BigQuery sync works**: GA4/GSC links are enabled in Google, and `bigquery.py test` lists and matches all three source tables for every site.
2. **Cold start is complete**: after `importer.py`, all six ingestion tables have non-zero row counts.
3. **Selection and ownership work**: every tracked keyword belongs to exactly one site because `SITE_RULES` matches exclusively.
4. **Serper pagination is correct**: cumulative offset is implemented and one request is shared when the same term belongs to multiple sites.
5. **All seven diagnosis rules run**, including the brand-suffix check in `meta`.
6. **The action loop works**: diagnosis → to-do → user marks complete → `impact()` returns the before/after 28-day comparison.
7. **The weekly report works**: all sites are combined into one Markdown report and can be viewed at `/reports`; email is not required.
8. **Scheduling is confirmed**: APScheduler runs in the `web` process on the Monday sequence, and manual endpoints start a background thread without blocking the request.
9. **Deployment is isolated**: `web` has Traefik + Basic Auth labels; `jobs` has no proxy labels and is used only for one-off commands.

---

## Appendix: Disclaimer

This SOP was organized by checking a real single-tenant production system file by file. Its choices—no ORM, no migration tool, no login system and no persistent task queue—are deliberate simplifications for one person managing a small number of their own websites, not a lack of capability. If the project grows into self-service multi-user registration, arbitrary site onboarding, auditing and permissions, it has crossed this SOP’s design boundary. Use a different architecture with ORM/migrations, OAuth self-service authorization, persistent queues and multi-tenant data isolation. When the Agent encounters a case not covered here, preserve the principles of simplicity, idempotency and single-person maintainability instead of adding abstractions by default.

SEO Agents will become part of a website’s core infrastructure

The value of SEO Copilot is not just that it produced a tool. More importantly, it represents a new model for enterprise AI applications. In the past companies used SaaS products with fixed features and a fixed way of analyzing data, and the company had to adapt to the tool. Now a company can use AI Agents to build its own digital system around its own business requirements.

1. SEO work is changing

The old workflow for an SEO specialist was to look at data, spot problems, write a plan and execute the optimization, with a lot of time going into the first two steps. This system can monitor continuously, detect anomalies, analyze causes and propose recommendations, which moves the person from data organizer to strategy decision maker.

2. What businesses really need is their own data intelligence

Future competition is not only about who owns more content, it is about who understands their own data faster. A business website generates search behavior, user behavior, conversion data and content changes every day. Stored and unused, that data has no value. Only through data analysis, AI understanding and automated execution does it become a growth asset.

3. AI Agents will not replace SEO, they will redefine it

The essence of SEO has not changed: understand user needs, create valuable content, solve search problems. What changes is how it gets executed. A single SEO specialist used to manage a limited amount of data and could only pick out the highlights. Now that same specialist can work with an AI team that runs around the clock and reviews the changes on every page of the site.

Conclusion

Building this SEO Copilot was not really about creating an SEO dashboard. It was about exploring how a company can use AI Agents to turn its own data assets into sustained growth capability.

The core of the system is not any single technical component. It is not BigQuery, not an LLM and not the dashboard. What matters is connecting business understanding, data assets and AI capability, so the data turns into a list of executable optimization tasks in the report that arrives every morning. For a cross-border business, the most valuable AI application in the future may not be a general platform but a series of small intelligent systems tailored to its own operations. SEO Copilot is one example.

KEY TAKEAWAY

What matters is connecting business understanding, data assets and AI capability, which is also what separates this kind of enterprise AI application from a plain ChatGPT prompt.

If this approach is useful to you, the other build log is worth reading too: how to build an AI agent-driven customer acquisition system, which applies the same method to a lead generation workflow.

More guides like this,
every Thursday.

Hosting reviews, builder comparisons, performance tips, and plugin picks — curated weekly for WordPress site owners and builders.