Automated SEO Pipeline with AI
Build a nightly AI content pipeline that auto-publishes SEO pages.
Introduction
30 Second Summary
Every freelancer knows the grind of finding clients. You write blog posts, tweak landing pages, and hope the right person Googles the right phrase at the right time. What if your site grew its search presence every single night while you slept?
In this project, you will build a Python pipeline that auto-generates SEO pages for aegntic.ai using free AI models. Each page targets a specific problem developers search for, passes a quality gate, and deploys automatically through a nightly GitHub Actions workflow.
What You'll Build
You will wake up each morning to find new pages live on your site, each one targeting a developer searching for exactly the kind of AI agent system you build, and funneling them toward hiring you.
By the end of this project, you'll have:
- A content generator that seeds from your real repos and services to create problem-solution pages attracting your ideal clients.
- A quality gate that automatically rejects thin, duplicate, or hallucinated content before it reaches production.
- A nightly CI/CD pipeline using GitHub Actions that generates, scores, commits, and triggers Cloudflare Pages to deploy new pages at zero cost.
- Secret Mission: Extend the pipeline into a multi-site lead network across aegntic.ai, socialskills.ninja, and aedex.ing, all funneling traffic back to your main business.
Are there any prerequisites?
You need a GitHub account, a Cloudflare account already hosting aegntic.ai via Pages, and an OpenRouter account (free models are used throughout, so no spend is required).
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters for getting clients.
You're going to wire up a pipeline that publishes new pages on aegntic.ai every night while you sleep. Each page targets a specific problem developers search for, demonstrates your expertise, and funnels readers toward hiring you.
Set Up Your Python Environment
Your pipeline needs two things before it can generate any content: Python with the OpenRouter SDK, and a valid API key that lets you call free models.
Since aegntic.ai is already live on Cloudflare Pages, there is no hosting to configure. In this step, you will create an isolated Python environment, install the SDK, and confirm you can talk to OpenRouter's free models.
In this step, get ready to:
- Create a Python virtual environment and install the openai package.
- Set your OpenRouter API key as an environment variable.
- Write and run a connectivity test script.
Create a virtual environment and install dependencies
A virtual environment keeps this project's packages separate from any other Python work on your machine. You will create one inside your local copy of the aegnticdotai repo, then install the single dependency the pipeline needs.
- Open your terminal by pressing Ctrl+Alt+T.
- Navigate to your local clone of the aegnticdotai repo by running:
cd ~/Desktop/aegnticdotai
- Create and activate a Python virtual environment by running these commands:
python3 -m venv venv
source venv/bin/activate
Your terminal prompt now starts with (venv). This confirms the virtual environment is active.
What is a virtual environment?
A virtual environment is an isolated space where pip install puts packages so they do not conflict with anything else on your system. When you activate it, any python3 or pip commands use this isolated copy.
- Install the openai package by running:
pip install openai
OpenRouter uses the OpenAI-compatible API format, so the standard openai Python package works as the client.
- Verify the package installed correctly by running:
python3 -c "from openai import OpenAI; print('ready')"
You should see ready printed to the terminal.
Seeing ModuleNotFoundError?
Make sure your terminal prompt shows (venv) at the start. If it does not, run source venv/bin/activate again, then retry the pip install openai command.
Help me fix my openai import error after activating my virtual environment.
Set your OpenRouter API key
The pipeline authenticates with OpenRouter using an API key stored in an environment variable. This keeps the key out of your code so it is never committed to GitHub.
- Go to https://openrouter.ai/keys in your browser.
- Click Create Key, give it a name like seo-pipeline, and copy the key immediately (it will not be shown again).
- Back in your terminal, export the key by running this command (replace your-api-key-here with the key you just copied):
export OPENROUTER_API_KEY=your-api-key-here
Why an environment variable?
Storing the key in an environment variable means your scripts can read it at runtime without hardcoding it. Later, you will add the same key as a GitHub repository secret so the automated workflow can use it too.
Write and run the connectivity test
Before building the full generator, you need proof that Python can reach OpenRouter and get a response from a free model. A small test script confirms the SDK, API key, and network are all working together.
- Create a file called test_openrouter.py in the root of your aegnticdotai folder by running:
touch test_openrouter.py
- Confirm the file exists by running:
ls test_openrouter.py
You should see test_openrouter.py printed back.
- Open test_openrouter.py in your text editor and paste the following code:
#!/usr/bin/env python3
"""Test OpenRouter connectivity with a free model."""
import os
import sys
from openai import OpenAI
# Read the API key from the environment
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
# Exit early if the key is missing
if not OPENROUTER_API_KEY:
print("Error: OPENROUTER_API_KEY not set")
sys.exit(1)
# Create a client pointing at OpenRouter's OpenAI-compatible endpoint
client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=OPENROUTER_API_KEY)
# Send a simple prompt to a free model
response = client.chat.completions.create(
model="nvidia/nemotron-3-ultra:free",
messages=[{"role": "user", "content": "Say hello in one sentence."}],
max_tokens=50,
)
print(f"Model responded: {response.choices[0].message.content}")
print("OpenRouter connection verified.")
What does this code do?
The script reads your API key from the OPENROUTER_API_KEY environment variable. If the key is missing, it prints an error and exits instead of making a failing request.
It creates an OpenAI client pointed at https://openrouter.ai/api/v1 and sends a one-sentence prompt to nvidia/nemotron-3-ultra:free. The response confirms end-to-end connectivity.
- Save test_openrouter.py.
Before you run it, what do you expect to see if everything is working correctly?
- Run the test script:
python3 test_openrouter.py
You should see two lines of output: a response from the model and the confirmation message OpenRouter connection verified. printed at the end.
✔️ Awesome, I've got everything!
Great. Double check you have saved test_openrouter.py and you can move on to building the content generator.
ⓧ I'd like to double check the full code
#!/usr/bin/env python3
"""Test OpenRouter connectivity with a free model."""
import os
import sys
from openai import OpenAI
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
if not OPENROUTER_API_KEY:
print("Error: OPENROUTER_API_KEY not set")
sys.exit(1)
client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=OPENROUTER_API_KEY)
response = client.chat.completions.create(
model="nvidia/nemotron-3-ultra:free",
messages=[{"role": "user", "content": "Say hello in one sentence."}],
max_tokens=50,
)
print(f"Model responded: {response.choices[0].message.content}")
print("OpenRouter connection verified.")
Not seeing a response?
If you see Error: OPENROUTER_API_KEY not set, your export did not persist. Run export OPENROUTER_API_KEY=your-api-key-here again in the same terminal session, then retry.
If you get a connection error or 401, double check you copied the full key from https://openrouter.ai/keys with no extra spaces.
Help me debug my OpenRouter connectivity test that is failing.
Your Python environment is ready and talking to OpenRouter. Next up, you will build the content generator that turns your seed data into full SEO pages.
Build the Content Generator
Your 127+ repos, shipped products, and service offerings are structured data. Each one represents a problem that your ideal client is actively searching for.
In this step, you will turn that structured data into a Python pipeline that generates full HTML pages. Each page targets a specific problem ("How to build X with AI agents"), demonstrates your expertise, and funnels readers toward hiring you.
In this step, get ready to:
- Create a seed data file with topics drawn from your real shipped work.
- Write a content generator that calls OpenRouter and outputs styled HTML pages.
- Run the generator and verify pages appear with a manifest tracking them.
Create the seed data file
Every page the pipeline generates starts from a seed. A seed is a structured description of the problem, the technology involved, and how your real work solves it.
- Create a file called seeds.json in your project folder with this content:
{
"site": "aegntic.ai",
"base_url": "https://aegntic.ai",
"cta_text": "Need this built? Work with me.",
"cta_link": "#work-with-me",
"topics": [
{
"slug": "onchain-trade-execution-ai-agent",
"title": "How to Build an Onchain Trade Execution Agent",
"category": "custom-agent-builds",
"tech": ["Rust", "Go"],
"problem": "Manual trading execution is slow and misses MEV opportunities",
"solution_hook": "A production agent system with real market feeds and MEV protection"
},
{
"slug": "multi-agent-coordination-persistent-swarms",
"title": "Multi-Agent Coordination with Persistent Swarms",
"category": "custom-agent-builds",
"tech": ["TypeScript", "MCP"],
"problem": "Single agents hit context limits and lose state across sessions",
"solution_hook": "Persistent swarms with shared reasoning state across MCP servers"
},
What is this structure?
The top-level fields (site, base_url, cta_text, cta_link) are site-wide config that every generated page shares.
Each topic in the topics array becomes one HTML page. The slug becomes the filename, problem grounds the opening hook, and tech constrains what the AI is allowed to reference.
- Continue adding the remaining three topics directly after the second entry (inside the same topics array):
{
"slug": "cloudflare-workers-edge-automation",
"title": "Edge Automation with Cloudflare Workers",
"category": "automation-infrastructure",
"tech": ["TypeScript", "Cloudflare Workers", "D1", "R2"],
"problem": "Centralized automation adds latency and single points of failure",
"solution_hook": "Serverless automation with D1, Queues, and R2 at the edge"
},
{
"slug": "custom-mcp-servers-production-apis",
"title": "Custom MCP Servers for Production APIs",
"category": "developer-tooling",
"tech": ["TypeScript", "Python", "MCP"],
"problem": "LLMs can't access your production data without custom integrations",
"solution_hook": "MCP servers connecting LLMs to production APIs, databases, and file systems"
},
{
"slug": "go-monolith-event-pipeline-postgres-aws",
"title": "Building a Go Event Pipeline on Postgres and AWS",
"category": "automation-infrastructure",
"tech": ["Go", "Postgres", "AWS"],
"problem": "Ingestion, enrichment, and delivery at scale requires careful architecture",
"solution_hook": "Integrated event pipeline handling ingestion, enrichment, and delivery at scale"
}
]
}
- Save seeds.json.
- Verify the file loads correctly by running this command:
python3 -c "import json; d=json.load(open('seeds.json')); print(f'{len(d[\"topics\"])} topics loaded')"
You should see 5 topics loaded printed in your terminal.
Seeing a JSON decode error?
- Check that every topic object except the last has a trailing comma after its closing }.
- Make sure the last topic object does NOT have a trailing comma.
- Confirm all strings use double quotes, not single quotes.
Help me fix a JSON syntax error in my seeds.json file
Write the page generator script
The generator reads a seed, constructs a prompt grounded in your real capabilities, calls OpenRouter, and outputs a styled HTML page matching your site's design system.
- Create a file called generate.py and add the imports and configuration constants:
#!/usr/bin/env python3
"""Generate SEO pages for aegntic.ai from seed data."""
import argparse
import json
import os
import sys
from pathlib import Path
from openai import OpenAI
# Configuration constants
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
MODEL = "nvidia/nemotron-3-ultra:free"
BASE_URL = "https://openrouter.ai/api/v1"
OUTPUT_DIR = Path("pages/solutions")
What does this code do?
- MODEL pins the free NVIDIA model you tested in Step 1.
- BASE_URL points the OpenAI client at OpenRouter's compatible endpoint.
- OUTPUT_DIR is where generated HTML pages will land. This mirrors the site's existing pages/ directory structure.
- Below the constants, add the system prompt that constrains what the AI generates:
SYSTEM_PROMPT = """You are writing a technical solutions page for aegntic.ai, the site of Mattae Cooper, an independent AI-agent engineer who builds production systems solo, end to end.
The page must:
- Open with the problem the reader is searching for (2-3 sentences)
- Explain why this problem is hard (2-3 sentences)
- Describe the production solution approach in concrete technical detail (3-4 paragraphs)
- Reference specific technologies from the tech stack provided
- End with a brief section on what working with Mattae looks like for this type of build
- Be written in second person ("you") addressing a technical decision-maker
- Use markdown-style headers (## for sections)
- Be 500-800 words
- NOT hallucinate capabilities, repos, or technologies not mentioned in the prompt
Do NOT include any HTML boilerplate, head tags, or body tags. Output only the page content as HTML fragments (h2, p, ul, li tags with Tailwind classes)."""
Why constrain the AI this tightly?
Without explicit constraints, the model invents technologies you have never used and writes generic content that could apply to anyone. The prompt forces it to stay within the tech stack from the seed data and write specifically about your shipped work.
The "NOT hallucinate" instruction combined with the quality gate in the next step creates a double check against false claims.
- Save generate.py and verify the file is valid Python so far by running:
python3 -c "from generate import SYSTEM_PROMPT; print(f'Prompt is {len(SYSTEM_PROMPT)} chars')"
You should see a character count printed (around 700 chars). This confirms the imports and string are syntactically correct.
Seeing a ModuleNotFoundError?
- Make sure your virtual environment is still activated (you should see the environment name in your terminal prompt).
- Confirm you are running the command from the same directory where generate.py lives.
Help me debug a ModuleNotFoundError when importing from generate.py
- Next, add the HTML template and seed loader below the system prompt in generate.py:
PAGE_TEMPLATE = """<section class="max-w-4xl mx-auto px-6 py-16">
<h1 class="text-4xl font-bold text-white mb-4">{title}</h1>
<p class="text-gray-400 mb-8 text-lg">{meta_description}</p>
<article class="prose prose-invert prose-lg max-w-none">
{body}
</article>
<div class="mt-12 p-8 rounded-xl bg-gradient-to-r from-cyan-900/20 to-transparent border border-cyan-800/30">
<h3 class="text-xl font-semibold text-white mb-2">{cta_text}</h3>
<a href="{cta_link}" class="inline-block mt-4 px-6 py-3 bg-[#00f0ff] text-black font-semibold rounded-lg hover:bg-[#00d4e0] transition-colors">Get in touch</a>
</div>
</section>"""
def load_seeds(path="seeds.json"):
"""Load seed topics from JSON file."""
with open(path) as f:
return json.load(f)
What does this code do?
- PAGE_TEMPLATE wraps the AI-generated body in your site's design system. It uses Tailwind classes, your neon cyan CTA color (#00f0ff), and dark theme styling to match aegntic.ai.
- load_seeds() reads your seeds.json file and returns the full dictionary including site config and topics.
- Add the generate_page function that constructs the prompt and calls the API. Place this below load_seeds:
def generate_page(client, topic, site_config):
"""Generate a single SEO page from a seed topic."""
user_prompt = f"""Write a solutions page for:
Title: {topic['title']}
Problem: {topic['problem']}
Solution approach: {topic['solution_hook']}
Tech stack: {', '.join(topic['tech'])}
Category: {topic['category']}
The builder (Mattae Cooper) has shipped this exact type of system in production. Reference the specific technologies listed."""
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
max_tokens=2000,
extra_headers={
"HTTP-Referer": site_config["base_url"],
"X-Title": "aegntic.ai SEO pipeline",
},
)
- Directly below the API call (still inside generate_page), add the code that formats the response into a full HTML page:
body = response.choices[0].message.content
meta_description = f"{topic['problem']}. {topic['solution_hook']}."
html = PAGE_TEMPLATE.format(
title=topic["title"],
meta_description=meta_description,
body=body,
cta_text=site_config["cta_text"],
cta_link=site_config["cta_link"],
)
return {
"slug": topic["slug"],
"title": topic["title"],
"meta_description": meta_description,
"category": topic["category"],
"html": html,
}
What does generate_page do?
- It builds a user prompt from the seed data, grounding the AI in your real tech stack and problem domain.
- The extra_headers identify your site to OpenRouter (required for free model access).
- The response body gets wrapped in the PAGE_TEMPLATE with your CTA baked in, so every page funnels readers to your "Work with me" section.
- Save generate.py and verify the function imported correctly:
python3 -c "from generate import generate_page; print('generate_page loaded')"
You should see generate_page loaded printed.
- Now add the first half of the main function below generate_page. This handles argument parsing and client setup:
def main():
# Parse command-line arguments
parser = argparse.ArgumentParser(description="Generate SEO pages")
parser.add_argument("--count", type=int, default=3, help="Number of pages to generate")
parser.add_argument("--seeds", default="seeds.json", help="Path to seeds file")
args = parser.parse_args()
if not OPENROUTER_API_KEY:
print("Error: OPENROUTER_API_KEY environment variable not set")
sys.exit(1)
# Set up client and load seeds
client = OpenAI(base_url=BASE_URL, api_key=OPENROUTER_API_KEY)
seeds = load_seeds(args.seeds)
site_config = {k: v for k, v in seeds.items() if k != "topics"}
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- Continue building main by adding the deduplication logic and generation loop directly below:
# Skip topics that already have generated pages
existing_slugs = {f.stem for f in OUTPUT_DIR.glob("*.html")}
topics_to_generate = [t for t in seeds["topics"] if t["slug"] not in existing_slugs]
if not topics_to_generate:
print("No new topics to generate. All seeds already have pages.")
return
count = min(args.count, len(topics_to_generate))
print(f"Generating {count} pages...")
# Generate each page via OpenRouter
generated = []
for topic in topics_to_generate[:count]:
print(f" Generating: {topic['slug']}")
try:
result = generate_page(client, topic, site_config)
generated.append(result)
except Exception as e:
print(f" Error generating {topic['slug']}: {e}")
- Finally, add the file-writing and manifest logic that closes out main, plus the entry point:
# Write HTML files to disk
for page in generated:
out_path = OUTPUT_DIR / f"{page['slug']}.html"
out_path.write_text(page["html"])
print(f" Written: {out_path}")
# Update the manifest with new entries
manifest_path = OUTPUT_DIR / "manifest.json"
manifest = []
if manifest_path.exists():
manifest = json.loads(manifest_path.read_text())
for page in generated:
manifest.append({
"slug": page["slug"],
"title": page["title"],
"meta_description": page["meta_description"],
"category": page["category"],
})
manifest_path.write_text(json.dumps(manifest, indent=2))
print(f"\nDone. {len(generated)} pages generated.")
if __name__ == "__main__":
main()
What does main do?
- It skips topics that already have a page on disk (deduplication). This means you can safely re-run the pipeline without generating duplicates.
- Each generated page is written as a standalone HTML file named after its slug.
- The manifest.json file tracks all generated pages with their metadata. This is how the SPA routing knows which solution pages exist.
- Save generate.py and confirm the argument parser works by running:
python3 generate.py --help
You should see usage information showing the --count and --seeds options.
Seeing a SyntaxError?
- Check that the indentation inside main() is consistent. Every line inside the function should be indented with 4 spaces.
- Make sure the generate_page function's return statement aligns with the html = PAGE_TEMPLATE.format( line above it.
- Verify the triple-quoted strings (""") are properly closed.
Help me fix a SyntaxError in my generate.py file
Run the generator and verify the output
The generator is complete. It reads seeds, calls the model, and writes HTML files plus a manifest that tracks every generated page.
- Before you run this, what do you expect to see? The script should call the model 3 times and produce 3 HTML files. Run the generator:
python3 generate.py --count 3
You will see output like this as each page generates:
Generating 3 pages...
Generating: onchain-trade-execution-ai-agent
Written: pages/solutions/onchain-trade-execution-ai-agent.html
Generating: multi-agent-coordination-persistent-swarms
Written: pages/solutions/multi-agent-coordination-persistent-swarms.html
Generating: cloudflare-workers-edge-automation
Written: pages/solutions/cloudflare-workers-edge-automation.html
Done. 3 pages generated.
Why does this take a moment?
Each page requires a full API call to OpenRouter. The nvidia/nemotron-3-ultra:free model generates 500-800 words per page, which takes 10-30 seconds per call depending on load.
- Verify the files exist by running:
ls pages/solutions/
You should see 3 .html files and a manifest.json.
- Check the manifest to confirm it is tracking the generated pages:
cat pages/solutions/manifest.json
You should see a JSON array with 3 entries, each containing the slug, title, meta description, and category from your seeds.
What is manifest.json for?
The manifest serves as the page registry for your SPA. Your site's js/app.js routing can read this file to discover which solution pages exist and add them to navigation. This decouples page generation from routing changes.
Seeing an API error or empty output?
- Confirm your OPENROUTER_API_KEY environment variable is still set by running echo $OPENROUTER_API_KEY.
- If you see rate limit errors, you may have exceeded 50 free requests per day. Wait a few minutes or check your OpenRouter dashboard for remaining quota.
- If you see "Error generating" for a specific slug, run the command again. Free models occasionally time out under heavy load.
Help me debug an OpenRouter API error in my generate.py script
✔️ Awesome, I've got everything!
Great. Double check you have saved both seeds.json and generate.py.
ⓧ I'd like to double check the full code
Your generate.py should contain these sections in order (scroll up to confirm each is present):
- Shebang, docstring, imports (argparse, json, os, sys, Path, OpenAI).
- Constants: OPENROUTER_API_KEY, MODEL, BASE_URL, OUTPUT_DIR.
- SYSTEM_PROMPT (triple-quoted string with content constraints).
- PAGE_TEMPLATE (HTML section with Tailwind classes and placeholder variables).
- load_seeds() function.
- generate_page() function (builds user prompt, calls API, formats HTML, returns dict).
- main() function (arg parsing, dedup check, generation loop, file writing, manifest update).
- if __name__ == "__main__": main() entry point.
Your seeds.json should have 5 topics with slugs: onchain-trade-execution-ai-agent, multi-agent-coordination-persistent-swarms, cloudflare-workers-edge-automation, custom-mcp-servers-production-apis, and go-monolith-event-pipeline-postgres-aws.
Your content generator is live. It turns structured seed data into full HTML pages styled for your site, with a CTA baked into every single one. Next up, you will add a quality gate that rejects garbage before it ever reaches production.
Add the Quality Gate
Your generator works. It pulls from your seed data, calls OpenRouter, and writes HTML pages to pages/solutions/.
The problem is that AI models hallucinate. Without filtering, the generator can produce pages claiming you use Kubernetes when you don't, repeat the same phrasing across pages, or output content too thin to rank. Publishing garbage damages your domain authority and makes you look worse than publishing nothing at all.
In this step, get ready to:
- Write scoring functions for word count, similarity, and hallucination detection.
- Wire the gate to filter pages and log rejections.
- Run the full pipeline and verify pass/reject decisions.
Write the scoring functions
The quality gate needs three checks. Each page must be long enough to rank, must only reference technologies you actually use, and must not be a near-duplicate of existing pages.
- Create a new file called quality_gate.py in your project root (the same directory as generate.py) and add the imports and configuration constants:
#!/usr/bin/env python3
"""Quality gate for generated SEO pages."""
import json
import sys
from difflib import SequenceMatcher
from pathlib import Path
OUTPUT_DIR = Path("pages/solutions")
REJECT_LOG = Path("rejected.log")
MIN_WORD_COUNT = 400
MAX_SIMILARITY = 0.6
VALID_TECH = [
"rust", "go", "typescript", "python", "cloudflare workers",
"d1", "r2", "queues", "postgres", "aws", "mcp",
"docker", "redis", "websocket", "grpc",
]
HALLUCINATION_MARKERS = [
"kubernetes", "terraform", "azure", "gcp", "react native",
"flutter", "java", "c#", ".net", "php", "ruby on rails",
]
What do these constants do?
- MIN_WORD_COUNT = 400 sets the minimum page length. Anything shorter is too thin to rank on Google.
- MAX_SIMILARITY = 0.6 is the threshold for duplicate detection. Pages more than 60% similar get rejected.
- VALID_TECH lists technologies you actually use. HALLUCINATION_MARKERS lists technologies you don't. If a generated page mentions Kubernetes or Terraform, it's making things up.
- Below the constants (after the closing ] of HALLUCINATION_MARKERS), add the three scoring functions:
def word_count(html):
import re
# Strip HTML tags and count remaining words
text = re.sub(r"<[^>]+>", "", html)
return len(text.split())
def similarity(a, b):
# Compare two strings and return a ratio from 0 to 1
return SequenceMatcher(None, a, b).ratio()
def check_hallucinations(html):
# Check if page mentions technologies not in your stack
lower = html.lower()
found = [m for m in HALLUCINATION_MARKERS if m in lower]
return found
What does each function do?
- word_count() strips all HTML tags from the page, then counts how many words remain. This measures actual content, not markup.
- similarity() uses Python's built-in difflib to compare two strings character by character. A ratio of 1.0 means identical.
- check_hallucinations() scans the page for any technology from the blocklist. If found, the model invented capabilities you don't have.
- Save quality_gate.py.
Add the gate runner
The scoring functions check individual properties. Now you need a runner that loads every generated page, runs all three checks, and takes action: keep passing pages and delete failures.
- Below your check_hallucinations function in quality_gate.py, add the first part of the run_gate() function that loads pages and runs checks:
def run_gate():
manifest_path = OUTPUT_DIR / "manifest.json"
if not manifest_path.exists():
print("No manifest found. Nothing to gate.")
return
manifest = json.loads(manifest_path.read_text())
# Load all existing pages for similarity comparison
existing_pages = []
for f in OUTPUT_DIR.glob("*.html"):
existing_pages.append(f.read_text())
passed = []
rejected = []
for entry in manifest:
slug = entry["slug"]
page_path = OUTPUT_DIR / f"{slug}.html"
if not page_path.exists():
continue
html = page_path.read_text()
reasons = []
# Check 1: minimum word count
wc = word_count(html)
if wc < MIN_WORD_COUNT:
reasons.append(f"too short ({wc} words, need {MIN_WORD_COUNT}+)")
What's happening so far?
The function reads manifest.json to find which pages exist. It loads every HTML file for comparison. Then for each page, it starts collecting rejection reasons. If no reasons accumulate, the page passes.
- Directly below the word count check (still inside the for entry in manifest: loop), add the hallucination check, similarity check, and the pass/reject decision:
# Check 2: hallucinated technologies
hallucinations = check_hallucinations(html)
if hallucinations:
reasons.append(f"hallucinated tech: {', '.join(hallucinations)}")
# Check 3: similarity to other pages
for existing in existing_pages:
if existing == html:
continue
sim = similarity(html, existing)
if sim > MAX_SIMILARITY:
reasons.append(f"too similar to existing page ({sim:.0%})")
break
# Take action: reject or pass
if reasons:
rejected.append({"slug": slug, "reasons": reasons})
page_path.unlink()
print(f" REJECT: {slug} -- {'; '.join(reasons)}")
else:
passed.append(entry)
print(f" PASS: {slug} ({wc} words)")
What does the reject action do?
When a page fails any check, page_path.unlink() deletes the HTML file from disk. This prevents garbage from ever reaching production when you push to main.
The similarity check compares every new page against all existing ones. If any pair exceeds 60% similarity, the newer page gets rejected as a near-duplicate.
- After the for loop ends (back at the same indentation level as for entry in manifest:), add the results-writing logic and the main block:
# Update manifest to contain only passing pages
manifest_path.write_text(json.dumps(passed, indent=2))
# Log rejected pages for review
if rejected:
with open(REJECT_LOG, "a") as f:
for r in rejected:
f.write(f"{r['slug']}: {'; '.join(r['reasons'])}\n")
print(f"\nResults: {len(passed)} passed, {len(rejected)} rejected")
if __name__ == "__main__":
run_gate()
Why rewrite the manifest?
The manifest is the source of truth for what pages exist. By overwriting it with only the passing entries, you keep the manifest in sync with the actual files on disk. When Cloudflare Pages deploys from main, only quality-checked pages are present.
- Save quality_gate.py.
✔️ Awesome, I've got everything!
Great. Make sure you saved the file before moving on.
ⓧ I'd like to double check the full code
Here is the complete quality_gate.py. Confirm your file matches this, chunk by chunk:
- Lines 1-24: shebang, imports, OUTPUT_DIR, REJECT_LOG, MIN_WORD_COUNT, MAX_SIMILARITY, VALID_TECH list, and HALLUCINATION_MARKERS list.
- Lines 26-40: the three helper functions (word_count, similarity, check_hallucinations).
- Lines 42-end: the run_gate() function loading manifest, running all three checks per page, deleting rejects, updating manifest, logging to rejected.log, and the if __name__ == "__main__" block.
Generate pages and run the gate
Time to see the gate in action. You already have some pages from the previous step. Generating more will give the similarity check something to compare against.
- Generate the remaining seed topics by running:
python3 generate.py --count 10
Why --count 10 when there are only 5 seeds?
The generator skips topics that already have pages on disk. You generated 3 in the previous step, so this run produces at most 2 more (the remaining seeds). Passing a count higher than available topics is harmless.
- Confirm the new pages exist by running:
ls pages/solutions/
You should see 5 HTML files and manifest.json.
Before you run the quality gate, what do you think will happen? Will all 5 pages pass, or will some get rejected for being too short, too similar, or hallucinating technologies you don't use?
- Run the quality gate:
python3 quality_gate.py
You should see output showing PASS or REJECT for each page, with specific reasons for any rejections. Expect 2-3 rejections for length, similarity, or hallucinated technologies.
All pages passing with no rejections?
That can happen if the model followed instructions well. The gate is still protecting you. Try lowering MIN_WORD_COUNT to 600 temporarily and re-running to see rejections in action.
If you see a FileNotFoundError, make sure pages/solutions/manifest.json exists. Run generate.py again if it's missing.
help me debug why my quality gate isn't rejecting any pages
- Check that rejected pages were logged by running:
cat rejected.log
If any pages were rejected, you'll see their slugs and reasons listed. If no pages were rejected, this file won't exist yet.
Your pipeline now refuses to publish garbage. In the next step, you'll automate the entire generate-gate-deploy cycle with GitHub Actions so it runs every night without you touching it.
Automate with GitHub Actions
Your pipeline generates quality-gated pages locally. The missing piece is running it without you.
In this step you will create a GitHub Actions workflow that triggers every night at 3am UTC, generates pages, runs the quality gate, and pushes passing content to main. Because Cloudflare Pages auto-deploys on every push to main, new pages go live without a single manual step.
In this step, get ready to:
- Create a GitHub Actions workflow file with a nightly cron schedule and manual trigger.
- Add your OpenRouter API key as a repository secret.
- Trigger a manual run and confirm new pages deploy to aegntic.ai automatically.
Create the workflow file
GitHub Actions workflows live in the .github/workflows/ directory of your repo. Each YAML file in that directory defines a pipeline.
- Create the workflow directory and file by running these commands:
mkdir -p .github/workflows
touch .github/workflows/seo-pipeline.yml
- Confirm the file exists by running:
ls .github/workflows/
You should see seo-pipeline.yml in the output.
- Open .github/workflows/seo-pipeline.yml in your editor and paste the trigger and job setup section:
name: SEO Content Pipeline
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
permissions:
contents: write
jobs:
generate-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install openai
What does this code do?
- schedule with cron: '0 3 * * *' runs the workflow every day at 3am UTC.
- workflow_dispatch adds a manual "Run workflow" button in the GitHub Actions tab so you can test without waiting until 3am.
- permissions: contents: write gives the workflow permission to push commits back to the repo.
- The first three steps check out the repo, install Python 3.11, and install the openai package.
- Below the Install dependencies step, paste the pipeline logic steps:
- name: Generate pages
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
run: python3 generate.py --count 5
- name: Run quality gate
run: python3 quality_gate.py
- name: Commit and push new pages
run: |
git config user.name "seo-pipeline[bot]"
git config user.email "bot@aegntic.ai"
git add pages/solutions/ || true
if git diff --staged --quiet; then
echo "No new pages to commit."
else
git commit -m "chore: auto-generate SEO pages [skip ci]"
git push
fi
What does this code do?
- The Generate pages step injects OPENROUTER_API_KEY from the repo secrets and generates 5 pages per run.
- The quality gate step filters out thin, duplicate, or hallucinated pages.
- The commit step only pushes if there are staged changes. The [skip ci] in the commit message prevents the push from re-triggering itself in an infinite loop.
- Save .github/workflows/seo-pipeline.yml.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
Your .github/workflows/seo-pipeline.yml should contain two sections that you pasted above, in order:
- The trigger and job setup section (from name: SEO Content Pipeline down through run: pip install openai).
- The pipeline logic steps section (from - name: Generate pages down through the closing fi).
Scroll up and confirm each block is present and properly indented.
Seeing a YAML syntax error later?
YAML is whitespace-sensitive. Make sure the second block you pasted lines up at the same indentation level as the steps above it (6 spaces for each - name: entry). If your editor auto-indented differently, fix the alignment before pushing.
Help me fix my YAML indentation
Add your API key as a repository secret
The workflow references ${{ secrets.OPENROUTER_API_KEY }} at runtime. You need to store your key in GitHub so the runner can access it securely.
- Open your aegntic/aegnticdotai repository on GitHub in your browser.
- Click the Settings tab in the repository header.
- In the left sidebar under the Security section, click Secrets and variables.
- Click Actions.
- Click the Secrets tab.
- Click New repository secret.
- In the Name field, type OPENROUTER_API_KEY.
- In the Secret field, paste your OpenRouter API key (the same value you exported locally).
- Click Add secret.
You should see OPENROUTER_API_KEY listed in the repository secrets table.
Don't see the Settings tab?
If the Settings tab is not visible in the repository header, click the dropdown menu (three dots) to the right of the visible tabs. You need admin or write access to the repository to manage secrets.
Help me find the Settings tab or Secrets section
Push the workflow and trigger a manual run
The workflow file exists locally. You need to push it to main so GitHub recognizes it.
- Stage and push the workflow file by running these commands:
git add .github/workflows/seo-pipeline.yml
git commit -m "feat: add nightly SEO pipeline workflow"
git push origin main
You should see the push succeed with output confirming the commit was pushed to main.
Push rejected?
If you see a rejection, someone may have pushed to main since your last pull. Run git pull --rebase origin main first, then try the push again.
Help me fix my rejected git push
- Open your repository on GitHub and click the Actions tab.
- In the left sidebar, click SEO Content Pipeline.
- Click the Run workflow dropdown button on the right side of the page.
- Leave the branch set to main and click the green Run workflow button to confirm.
Before you check the result, what do you think will happen? Will the pipeline generate new pages, or will it report that all seeds already have pages?
- Click into the running workflow to watch its progress. Wait for all steps to show green checkmarks.
The workflow checks out a fresh copy of your repo. If your pages/solutions/ files were already committed to main, the generator skips those slugs and only generates remaining seeds. If your local pages were never pushed, it generates all of them fresh.
- Once the run finishes, go to the Code tab of your repository.
- Check the latest commit. You should see a commit from seo-pipeline[bot] with the message chore: auto-generate SEO pages [skip ci].
- Open https://aegntic.ai in your browser. Within 2 minutes, Cloudflare Pages auto-deploys the new commit.
How does auto-deploy work?
Cloudflare Pages watches the main branch of aegntic/aegnticdotai. Every push triggers a new deployment automatically. The nightly workflow pushes to main, so new pages go live without any manual deploy step.
Workflow failed at the Generate pages step?
This usually means the OPENROUTER_API_KEY secret is missing or mistyped. Go back to Settings, then Secrets and variables, then Actions and verify the secret name matches exactly: OPENROUTER_API_KEY.
Help me fix my workflow API key error
Your SEO machine is fully automated. Every night at 3am UTC, GitHub Actions generates new pages, filters out garbage, and pushes quality content live. Next up, the Secret Mission extends this pipeline into a multi-site lead network.
Secret mission
Multi-Site Lead Network
Your pipeline generates pages for aegntic.ai, but you have two other products that could capture search traffic and funnel it back. socialskills.ninja has 10 platforms and existing competitor data. aedex.ing has 1,300+ tools. Each product becomes a page factory that all roads lead back to aegntic.ai.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs on free tiers (OpenRouter free models, Cloudflare Pages free hosting, GitHub Actions free tier), so there are no ongoing financial costs. However, the nightly GitHub Actions workflow will continue generating and pushing pages unless you disable it.
Resources you used:
- Python virtual environment with the openai package installed.
- Project scripts: generate.py, quality_gate.py, test_openrouter.py, seeds.json.
- Generated pages in pages/solutions/, content/comparisons/, and content/integrations/.
- GitHub Actions workflow (.github/workflows/seo-pipeline.yml) running nightly at 3am UTC.
- GitHub repository secret (OPENROUTER_API_KEY).
Keep everything running
No action needed. Choose this if you want the pipeline to keep generating and deploying pages every night.
- The GitHub Actions workflow continues running nightly at 3am UTC, generating up to 5 new pages per run.
- Cloudflare Pages auto-deploys each new commit to your live site.
- Your OpenRouter free model usage stays within the 50 requests/day free tier.
- Monitor rejected.log periodically to see what the quality gate is filtering out.
Pause - I'll come back to this later
Disable the nightly workflow so it stops generating pages, but keep all your files and secrets intact for when you return.
- Go to your repository on GitHub and click the **Actions** tab.
- Click **SEO Content Pipeline** in the left sidebar.
- Click the **...** menu (three dots) in the top right and select **Disable workflow**.
- Your files, secrets, and generated pages all remain in place. Re-enable the workflow from the same menu when you want to resume.
Delete - I don't want to use this again
Remove all pipeline files, generated pages, and the repository secret. Your existing site files remain untouched.
- Delete the local Python virtual environment by running:
rm -rf venv
- Delete the pipeline scripts and generated content from your repo by running:
rm -f generate.py quality_gate.py test_openrouter.py seeds.json rejected.log
rm -rf pages/solutions/ content/comparisons/ content/integrations/
rm -rf .github/workflows/seo-pipeline.yml
- Commit and push the deletions so the live site reflects the removal.
git add -A
git commit -m "chore: remove SEO pipeline"
git push
- Remove the GitHub repository secret: go to your repo on GitHub, click **Settings**, then **Secrets and variables** in the left sidebar, then **Actions**.
- Find OPENROUTER_API_KEY in the list and click the trash icon to delete it.
- Cloudflare Pages will auto-deploy the removal on the next push. No separate Cloudflare cleanup is needed.
Nice Work!
Nice Work!
Nice work! You just built an automated SEO lead machine that grows your site's search presence every single night while you sleep.
You've learned how to:
- Build a programmatic SEO pipeline that generates problem-solution pages targeting your ideal clients, powered by free AI models via OpenRouter.
- Implement a quality gate that automatically rejects thin, duplicate, or hallucinated content before it ever reaches production.
- Automate the full generate-gate-commit-deploy cycle with GitHub Actions running nightly at zero cost, with Cloudflare Pages auto-deploying new content on every push to main.
- Secret Mission: Extended the pipeline into a multi-site lead network across aegntic.ai, socialskills.ninja, and aedex.ing, all funneling traffic back to your main business.
Ready to quiz yourself?