All articles

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, status

CRAAP 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:

python
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 verified

SEO-Optimized Generation Prompt

python
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

python
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.

Request a discovery call

30 minutesGoogle MeetFree