Automation9 min read
Automated SEO Blog Pipeline: Gemini + CRAAP Test Source Verification + WordPress REST API
How we built a production content pipeline that researches, verifies sources, generates 1,500-word SEO articles, and publishes to WordPress — in 3–5 minutes, running daily on Cloud Scheduler
- Gemini
- Vertex AI
- WordPress REST API
- CRAAP Test
- Cloud Run
- Cloud Scheduler
- SEO
The constraint is simple: businesses that need 20–30 articles per month cannot hire enough writers to do it well. But automated content pipelines that skip source verification produce hallucinated facts and plagiarized structures that damage SEO rather than helping it. This pipeline treats source quality as a first-class constraint: no article passes to generation unless it has at least three independently verified sources.
Pipeline Architecture
Topic Input (manual | Cloud Scheduler seed | RSS trigger)
↓
Topic Adherence Lock
Original topic preserved with a hash — verified at each stage
Drift > 30% similarity → restart, don't silently continue
↓
Deep Research Phase (Gemini + Vertex AI Search grounding)
├── 3 independent search queries per topic angle
├── Collect 8–12 candidate sources
└── CRAAP Test Filter (rejects ~30–40% of sources)
Currency | Relevance | Authority | Accuracy | Purpose
↓
Generation Gate: needs ≥3 verified sources to proceed
↓
Content Generation (Gemini 2.0 Flash)
├── 1,500–2,500 words
├── SEO keyword integration (1.5–2.5% density)
├── H2/H3 structure + meta description
├── Working citations (URL + publication date)
└── AI disclosure paragraph
↓
Post-Generation Validation
├── Topic drift check (embedding similarity vs. original topic)
├── Citation URL verification (not 404)
└── Content completeness score
↓
WordPress REST API publish
Sets: title, content, category, tags, Yoast SEO meta, statusCRAAP Test Implementation
The CRAAP test (Currency, Relevance, Authority, Accuracy, Purpose) is a library science methodology for evaluating source quality. We implemented it as a scoring class that rejects sources below a threshold before they can influence the generated article:
1from datetime import datetime, timedelta
2from urllib.parse import urlparse
3import re
4
5class SourceVerifier:
6 TRUSTED_DOMAINS = {
7 ".gov", ".edu", "reuters.com", "bbc.com", "ft.com",
8 "cloud.google.com", "arxiv.org", "nature.com", "techcrunch.com"
9 }
10 BIAS_PATTERNS = [
11 r"buy now", r"limited offer", r"click here", r"sponsored"
12 ]
13
14 def score(self, source: dict, topic: str) -> float:
15 """Return 0–100 CRAAP score. Below 60 = reject."""
16 score = 0.0
17
18 # Currency (25 points): how recent?
19 pub_date = source.get("published_date")
20 if pub_date:
21 age_months = (datetime.now() - pub_date).days / 30
22 if age_months <= 6: score += 25
23 elif age_months <= 18: score += 15
24 elif age_months <= 36: score += 5
25
26 # Relevance (20 points): semantic similarity to topic
27 similarity = self._cosine_similarity(
28 self._embed(source["title"] + " " + source["snippet"]),
29 self._embed(topic)
30 )
31 score += similarity * 20
32
33 # Authority (25 points): domain credibility
34 domain = urlparse(source["url"]).netloc.lower()
35 if any(d in domain for d in self.TRUSTED_DOMAINS):
36 score += 25
37 elif domain.endswith((".com", ".org", ".net")):
38 score += 12
39
40 # Accuracy (15 points): cross-referenced?
41 if source.get("corroborated_by_n_sources", 0) >= 2:
42 score += 15
43
44 # Purpose (15 points): informational, not commercial
45 content = source.get("snippet", "").lower()
46 has_bias = any(re.search(p, content) for p in self.BIAS_PATTERNS)
47 if not has_bias:
48 score += 15
49
50 return score
51
52 def filter_sources(self, sources: list, topic: str) -> list:
53 scored = [(s, self.score(s, topic)) for s in sources]
54 verified = [s for s, score in scored if score >= 60]
55 if len(verified) < 3:
56 raise InsufficientSourcesError(
57 f"Only {len(verified)} verified sources — needs ≥3 to proceed"
58 )
59 return verifiedSEO-Optimized Generation Prompt
1def build_generation_prompt(
2 topic: str,
3 sources: list[dict],
4 target_keyword: str,
5 lsi_keywords: list[str],
6) -> str:
7 source_context = "\n".join([
8 f"- [{s['title']}]({s['url']}) — {s['snippet']}"
9 for s in sources
10 ])
11
12 return f"""Write a 1,500–2,500 word blog article about: {topic}
13
14PRIMARY KEYWORD: {target_keyword} (use naturally at 1.5–2.5% density)
15LSI KEYWORDS: {', '.join(lsi_keywords)} (vary these throughout)
16
17SOURCES (cite these, do not invent others):
18{source_context}
19
20STRUCTURE REQUIREMENTS:
211. Title containing the primary keyword
222. Opening paragraph with keyword in first 100 words
233. 4–6 H2 sections with keyword variations in headings
244. Inline citations: [Source Name](URL) — only from the provided sources
255. Conclusion with keyword and clear takeaway
266. Meta description: exactly 150–155 characters
27
28STYLE: Informative, authoritative. Avoid: "delve into", "In conclusion", filler phrases.
29AI DISCLOSURE: Add one sentence: "This article was researched and drafted with AI assistance."
30"""WordPress REST API Publishing
1import requests
2from base64 import b64encode
3
4class WordPressPublisher:
5 def __init__(self, site_url: str, username: str, app_password: str):
6 credentials = b64encode(f"{username}:{app_password}".encode()).decode()
7 self.headers = {
8 "Authorization": f"Basic {credentials}",
9 "Content-Type": "application/json",
10 }
11 self.api_url = f"{site_url}/wp-json/wp/v2"
12
13 def publish_post(self, article: dict) -> dict:
14 payload = {
15 "title": article["title"],
16 "content": article["html_content"],
17 "status": "publish", # or "draft" for review
18 "categories": [article["category_id"]],
19 "tags": article["tag_ids"],
20 # Yoast SEO meta (requires Yoast plugin)
21 "yoast_head_json": {
22 "title": article["seo_title"],
23 "description": article["meta_description"],
24 "og_title": article["seo_title"],
25 "og_description": article["meta_description"],
26 },
27 }
28 response = requests.post(
29 f"{self.api_url}/posts",
30 json=payload,
31 headers=self.headers,
32 )
33 response.raise_for_status()
34 return response.json()Results After V9
- Per article (vs. 4–6 hours manual)
- 3–5 min
- Sources filtered by CRAAP test
- 30–40%
- Off-topic rejections in V9 (was 15% in V5)
- <2%
- Cloud Scheduler production runs
- Daily
The V5 → V9 improvement in topic adherence came from adding the topic hash lock at the start of the pipeline. V5 allowed topic drift — an article about "BigQuery pricing" could become "cloud cost optimization" three research passes later. V9 rejects any generation where the topic vector drifts more than 30% from the original.