Build a Blazing-Fast SEO Audit CLI in Rust with the SEOPeek API
Python scripts and Node.js tools work fine for one-off SEO checks, but when you need to audit thousands of URLs from a sitemap, pump results into a CI/CD pipeline, or distribute a zero-dependency binary to your team, Rust is the right tool for the job. This guide walks you through building a production-grade command-line SEO auditor in Rust that calls the SEOPeek API with async HTTP, parses structured results with serde, handles batch concurrency with tokio, and prints color-coded pass/fail output to the terminal.
- Project setup and Cargo.toml dependencies
- Calling the SEOPeek API with reqwest and tokio
- Parsing the JSON response with serde
- Building the CLI with clap
- Color-coded terminal output with the colored crate
- Batch auditing with concurrent tokio tasks
- Auditing an entire sitemap by parsing sitemap.xml
- Pricing and rate limits
- FAQ
1. Project Setup and Cargo.toml Dependencies
Start by creating a new Rust project. We will build a binary crate called seopeek-cli that compiles to a single executable with no runtime dependencies.
cargo new seopeek-cli
cd seopeek-cli
Replace the contents of Cargo.toml with the following. Every dependency here serves a specific purpose—there is no bloat.
[package]
name = "seopeek-cli"
version = "0.1.0"
edition = "2021"
description = "A blazing-fast CLI for running SEO audits via the SEOPeek API"
[dependencies]
# Async HTTP client
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
# Async runtime
tokio = { version = "1", features = ["full"] }
# JSON serialization/deserialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# CLI argument parsing
clap = { version = "4", features = ["derive"] }
# Colored terminal output
colored = "2"
# XML parsing for sitemaps
quick-xml = "0.36"
# Async concurrency utilities
futures = "0.3"
The key crates:
- reqwest — async HTTP client with built-in JSON deserialization. We use
rustls-tlsinstead of OpenSSL so the binary compiles without system dependencies. - tokio — the async runtime that drives all our concurrent HTTP requests.
- serde + serde_json — zero-copy JSON parsing into strongly-typed Rust structs.
- clap — derive-based CLI argument parsing with automatic help text generation.
- colored — adds ANSI color codes to terminal strings (green for pass, red for fail, yellow for warnings).
- quick-xml — a fast, low-allocation XML parser for reading sitemap.xml files.
- futures — provides
stream::buffer_unorderedfor controlled concurrency.
2. Calling the SEOPeek API with reqwest and tokio
The SEOPeek API is a single GET endpoint. You pass the target URL as a query parameter, and it returns a JSON object containing a numeric score, a letter grade, and detailed pass/fail results for 20+ on-page SEO checks. No API key is needed for the free tier.
The endpoint:
GET https://us-central1-todd-agent-prod.cloudfunctions.net/seopeekApi/api/v1/audit?url=TARGET_URL
Here is the core API client module. Create src/api.rs:
use reqwest::Client;
use serde::Deserialize;
use std::collections::HashMap;
const SEOPEEK_API: &str =
"https://us-central1-todd-agent-prod.cloudfunctions.net/seopeekApi/api/v1/audit";
#[derive(Debug, Deserialize)]
pub struct AuditResult {
pub url: String,
pub score: u32,
pub grade: String,
#[serde(default)]
pub checks: HashMap<String, CheckResult>,
}
#[derive(Debug, Deserialize)]
pub struct CheckResult {
#[serde(rename = "pass")]
pub passed: bool,
#[serde(default)]
pub message: String,
}
pub async fn audit_url(client: &Client, url: &str) -> Result<AuditResult, reqwest::Error> {
let result = client
.get(SEOPEEK_API)
.query(&[("url", url)])
.send()
.await?
.error_for_status()?
.json::<AuditResult>()
.await?;
Ok(result)
}
A few things to notice. The Client is passed in by reference rather than created on every call. This is important for performance because reqwest's Client manages an internal connection pool. Creating one client and reusing it across all requests means connections are recycled via HTTP keep-alive, which drastically reduces latency when you are auditing hundreds of URLs in a batch.
The AuditResult struct uses serde's derive macro to deserialize the JSON response directly into a typed struct. The checks field is a HashMap<String, CheckResult> because the API returns a dynamic set of check names as keys. The #[serde(rename = "pass")] annotation handles the fact that pass is a reserved-adjacent keyword in Rust—we map it to passed in our struct.
Tip: For production use, add a timeout to the client builder: Client::builder().timeout(Duration::from_secs(30)).build()?. This prevents a single slow audit from blocking your entire batch.
3. Parsing the JSON Response with serde
The SEOPeek API response looks like this:
{
"url": "https://example.com",
"score": 82,
"grade": "B",
"checks": {
"title": {"pass": true, "message": "Title tag exists and is 52 characters"},
"meta_description": {"pass": true, "message": "Meta description is 148 characters"},
"h1": {"pass": true, "message": "Single H1 tag found"},
"og_tags": {"pass": false, "message": "Missing og:image tag"},
"canonical": {"pass": true, "message": "Canonical URL is set"},
"structured_data": {"pass": false, "message": "No JSON-LD or microdata found"},
"robots_txt": {"pass": true, "message": "robots.txt is accessible"},
"sitemap": {"pass": true, "message": "Sitemap reference found in robots.txt"}
}
}
Because we defined our structs with #[derive(Deserialize)], serde handles all of this automatically. The .json::<AuditResult>() call on the reqwest response both reads the body and deserializes it in one step. There is no intermediate string allocation—serde reads directly from the response byte stream.
To work with the parsed results, you can iterate over the checks map:
let result = audit_url(&client, "https://example.com").await?;
// Count passing and failing checks
let passing = result.checks.values().filter(|c| c.passed).count();
let failing = result.checks.values().filter(|c| !c.passed).count();
// Collect just the failing check names and messages
let failures: Vec<(&String, &CheckResult)> = result
.checks
.iter()
.filter(|(_, c)| !c.passed)
.collect();
Because everything is strongly typed, the compiler catches mistakes at build time. If the API response shape ever changes, your code will fail to compile rather than silently producing wrong results at runtime. That is the Rust advantage.
4. Building the CLI with clap
Now let us wire up the command-line interface. The CLI should accept URLs in three ways: as direct arguments, from a file (one URL per line), or from a sitemap URL. It also needs flags for minimum score threshold, concurrency level, and output format.
Create src/main.rs:
mod api;
use clap::Parser;
use colored::*;
use futures::stream::{self, StreamExt};
use reqwest::Client;
use std::process;
use std::time::Duration;
#[derive(Parser, Debug)]
#[command(
name = "seopeek-cli",
about = "Blazing-fast SEO auditing from the command line",
version
)]
struct Cli {
/// URLs to audit (space-separated)
#[arg(trailing_var_arg = true)]
urls: Vec<String>,
/// Path to a file containing URLs (one per line)
#[arg(short, long)]
file: Option<String>,
/// URL of a sitemap.xml to audit all pages
#[arg(long)]
sitemap: Option<String>,
/// Minimum acceptable SEO score (exit non-zero if any URL is below)
#[arg(short, long, default_value = "70")]
min_score: u32,
/// Maximum number of concurrent audit requests
#[arg(short, long, default_value = "10")]
concurrency: usize,
/// Output results as JSON instead of formatted text
#[arg(long)]
json: bool,
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("Failed to build HTTP client");
// Collect all URLs from the three possible sources
let mut urls: Vec<String> = cli.urls.clone();
// Load URLs from file
if let Some(ref path) = cli.file {
match std::fs::read_to_string(path) {
Ok(contents) => {
urls.extend(
contents
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.starts_with('#')),
);
}
Err(e) => {
eprintln!("{} Failed to read file {}: {}", "ERROR".red().bold(), path, e);
process::exit(1);
}
}
}
// Load URLs from sitemap
if let Some(ref sitemap_url) = cli.sitemap {
match fetch_sitemap_urls(&client, sitemap_url).await {
Ok(sitemap_urls) => {
println!(
"{} Found {} URLs in sitemap",
"SITEMAP".cyan().bold(),
sitemap_urls.len()
);
urls.extend(sitemap_urls);
}
Err(e) => {
eprintln!("{} Failed to fetch sitemap: {}", "ERROR".red().bold(), e);
process::exit(1);
}
}
}
if urls.is_empty() {
eprintln!("No URLs provided. Use positional args, --file, or --sitemap.");
process::exit(1);
}
println!(
"\n{} Auditing {} URL(s) with concurrency {} {}\n",
"=".repeat(4).dimmed(),
urls.len().to_string().white().bold(),
cli.concurrency.to_string().white().bold(),
"=".repeat(4).dimmed(),
);
// Run audits concurrently
let results = run_batch_audit(&client, &urls, cli.concurrency).await;
// Output results
let mut failures = 0u32;
if cli.json {
// JSON output mode
let json_results: Vec<serde_json::Value> = results
.iter()
.map(|(url, result)| match result {
Ok(r) => serde_json::json!({
"url": url,
"score": r.score,
"grade": r.grade,
"checks": r.checks.len(),
"failing": r.checks.values().filter(|c| !c.passed).count(),
}),
Err(e) => serde_json::json!({
"url": url,
"error": e.to_string(),
}),
})
.collect();
println!("{}", serde_json::to_string_pretty(&json_results).unwrap());
} else {
// Formatted terminal output
for (url, result) in &results {
match result {
Ok(audit) => {
let failing_checks: Vec<&String> = audit
.checks
.iter()
.filter(|(_, c)| !c.passed)
.map(|(name, _)| name)
.collect();
if audit.score < cli.min_score {
failures += 1;
println!(
" {} {} {} ({}) — {} checks failing",
"FAIL".red().bold(),
url.white(),
format!("{}/100", audit.score).red().bold(),
audit.grade.red(),
failing_checks.len().to_string().yellow(),
);
for name in &failing_checks {
let msg = &audit.checks[name.as_str()].message;
println!(
" {} {}: {}",
"-".dimmed(),
name.yellow(),
msg.dimmed(),
);
}
} else {
println!(
" {} {} {} ({})",
"PASS".green().bold(),
url.white(),
format!("{}/100", audit.score).green().bold(),
audit.grade.green(),
);
}
}
Err(e) => {
failures += 1;
println!(
" {} {} — {}",
"ERR ".red().bold(),
url.white(),
e.to_string().red(),
);
}
}
}
}
// Summary
let total = results.len();
let passed = total as u32 - failures;
println!(
"\n{}",
"=".repeat(60).dimmed(),
);
println!(
" Total: {} | Passed: {} | Failed: {}",
total.to_string().white().bold(),
passed.to_string().green().bold(),
failures.to_string().red().bold(),
);
if failures > 0 {
process::exit(1);
}
}
The Cli struct uses clap's derive API, which generates a full argument parser from struct annotations. Running seopeek-cli --help automatically produces formatted help text with all flags documented.
5. Color-Coded Terminal Output
The colored crate makes it trivial to add ANSI color codes to terminal output. The pattern is simple: call .green(), .red(), or .yellow() on any string or string slice, and the crate wraps it with the appropriate escape sequences. On terminals that do not support color (or when piping to a file), colored automatically disables itself.
Here is how the output looks in practice:
==== Auditing 5 URL(s) with concurrency 10 ====
PASS https://example.com 92/100 (A)
PASS https://example.com/about 85/100 (B+)
FAIL https://example.com/blog 58/100 (D)
- og_tags: Missing og:image tag
- structured_data: No JSON-LD or microdata found
- meta_description: Meta description is too short (45 characters)
PASS https://example.com/pricing 91/100 (A)
ERR https://example.com/404-page — HTTP 404
============================================================
Total: 5 | Passed: 3 | Failed: 2
Green for pages that meet the threshold. Red for pages below the threshold or with errors. Yellow for individual failing check names. The dimmed dash and message make it easy to scan for problems. This output is designed to be readable at a glance, even when auditing hundreds of URLs.
6. Batch Auditing with Concurrent tokio Tasks
The real power of this Rust CLI comes from concurrent batch processing. Instead of auditing URLs sequentially (which would take minutes for a large site), we use futures::stream::buffer_unordered to run up to N requests in parallel.
Add this function to src/main.rs:
async fn run_batch_audit(
client: &Client,
urls: &[String],
concurrency: usize,
) -> Vec<(String, Result<api::AuditResult, reqwest::Error>)> {
let results: Vec<_> = stream::iter(urls.iter().cloned())
.map(|url| {
let client = client.clone();
async move {
let result = api::audit_url(&client, &url).await;
(url, result)
}
})
.buffer_unordered(concurrency)
.collect()
.await;
results
}
Here is what is happening step by step:
stream::iterturns the URL slice into an async stream..map()creates a future for each URL that callsaudit_url..buffer_unordered(concurrency)polls up toconcurrencyfutures at once. As each one completes, the next URL in the queue starts immediately. Results come back in completion order, not input order..collect()gathers all results into a Vec once every future has resolved.
This is the key advantage over Python or Node.js: tokio's work-stealing scheduler efficiently distributes these futures across OS threads, and reqwest's connection pool reuses TCP connections. The result is that auditing 100 URLs with concurrency 10 takes roughly the same wall-clock time as auditing 10 URLs sequentially—about 15 to 20 seconds instead of 3 minutes.
Usage examples:
# Audit a single URL
seopeek-cli https://example.com
# Audit multiple URLs
seopeek-cli https://example.com https://example.com/pricing https://example.com/blog
# Audit URLs from a file with 20 concurrent requests
seopeek-cli --file urls.txt --concurrency 20
# Set a higher quality bar
seopeek-cli --file urls.txt --min-score 85
# JSON output for piping into jq or saving to a file
seopeek-cli --file urls.txt --json > results.json
# CI/CD gate: exits non-zero if any URL scores below 75
seopeek-cli --file critical-urls.txt --min-score 75
Performance note: On a fast connection, this CLI can audit 50 URLs (the free tier daily limit) in under 10 seconds with concurrency 10. On the Pro plan, you can audit 10,000 URLs per month—an entire large site's worth of pages.
7. Auditing an Entire Sitemap by Parsing sitemap.xml
Most production websites publish a sitemap.xml that lists every indexable URL. Instead of maintaining a separate URL file, you can point the CLI at a sitemap and audit every page automatically. This is the most powerful use case for the batch auditor.
Add the sitemap parsing function to src/main.rs:
async fn fetch_sitemap_urls(
client: &Client,
sitemap_url: &str,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let response = client.get(sitemap_url).send().await?.error_for_status()?;
let body = response.text().await?;
let mut urls = Vec::new();
let mut reader = quick_xml::Reader::from_str(&body);
let mut buf = Vec::new();
let mut inside_loc = false;
loop {
match reader.read_event_into(&mut buf) {
Ok(quick_xml::events::Event::Start(ref e)) => {
if e.name().as_ref() == b"loc" {
inside_loc = true;
}
}
Ok(quick_xml::events::Event::Text(ref e)) => {
if inside_loc {
if let Ok(text) = e.unescape() {
let url = text.trim().to_string();
if !url.is_empty() {
urls.push(url);
}
}
}
}
Ok(quick_xml::events::Event::End(ref e)) => {
if e.name().as_ref() == b"loc" {
inside_loc = false;
}
}
Ok(quick_xml::events::Event::Eof) => break,
Err(e) => return Err(Box::new(e)),
_ => {}
}
buf.clear();
}
// Handle sitemap index files (sitemaps that point to other sitemaps)
if urls.iter().all(|u| u.ends_with(".xml") || u.ends_with(".xml.gz")) {
let mut all_urls = Vec::new();
for sub_sitemap in &urls {
match fetch_sitemap_urls(client, sub_sitemap).await {
Ok(sub_urls) => all_urls.extend(sub_urls),
Err(e) => eprintln!(
" {} Failed to fetch sub-sitemap {}: {}",
"WARN".yellow().bold(),
sub_sitemap,
e
),
}
}
return Ok(all_urls);
}
Ok(urls)
}
This parser handles both regular sitemaps (which contain <url><loc> entries) and sitemap index files (which contain <sitemap><loc> entries pointing to other sitemaps). The heuristic is simple: if every extracted URL ends in .xml or .xml.gz, it is treated as a sitemap index and each sub-sitemap is fetched recursively.
Usage:
# Audit every URL in a sitemap
seopeek-cli --sitemap https://example.com/sitemap.xml
# Combine sitemap with a strict score threshold
seopeek-cli --sitemap https://example.com/sitemap.xml --min-score 80
# High concurrency for large sitemaps (Pro plan recommended)
seopeek-cli --sitemap https://example.com/sitemap.xml --concurrency 20 --json > full-audit.json
For a site with 500 pages in its sitemap, this command will audit every single page in under a minute at concurrency 20. The JSON output can be piped into jq for filtering, saved as a CI artifact, or loaded into a monitoring dashboard.
Building and distributing the binary
Compile with optimizations enabled:
# Build a release binary
cargo build --release
# The binary is at target/release/seopeek-cli
ls -lh target/release/seopeek-cli
# Copy it anywhere — no dependencies needed
cp target/release/seopeek-cli /usr/local/bin/
The resulting binary is typically 5–8 MB and runs on any machine with the same OS and architecture. No Rust toolchain, no package manager, no runtime. Copy the file and run it. This makes it ideal for CI/CD runners, Docker containers, or distribution to teammates who do not have a Rust development environment.
Cross-compiling for Linux CI runners
If you develop on macOS but your CI runs Linux, you can cross-compile:
# Add the Linux target
rustup target add x86_64-unknown-linux-musl
# Build a fully static Linux binary
cargo build --release --target x86_64-unknown-linux-musl
# Upload to your CI runner or Docker image
cp target/x86_64-unknown-linux-musl/release/seopeek-cli ./seopeek-cli-linux
The musl target produces a fully static binary that works on any Linux distribution, including Alpine-based Docker images. No glibc dependency, no shared libraries, no compatibility issues.
CI/CD integration: Add the compiled binary to your repository (or download it in your CI script), then use it as a build gate: ./seopeek-cli --file critical-urls.txt --min-score 75 || exit 1. The CLI exits with code 1 when any URL fails, which breaks the build automatically.
8. Pricing and Rate Limits
The SEOPeek API has three tiers, all using the same endpoint. The free tier requires no API key.
| Plan | Audits | Price | Best for |
|---|---|---|---|
| Free | 50 / day | $0 | Local dev, small sites, trying it out |
| Starter | 1,000 / month | $9/mo | CI/CD pipelines, weekly sitemap audits |
| Pro | 10,000 / month | $29/mo | Agencies, large sites, daily bulk audits |
For the Rust CLI, here is how the plans map to use cases:
- Free (50/day): Perfect for auditing your most critical pages during local development. Run
seopeek-cli https://yoursite.com/ https://yoursite.com/pricinga few times a day. - Starter (1,000/month): Enough for a CI/CD pipeline that audits 10–20 key pages on every deploy, plus weekly full-sitemap scans of a small site.
- Pro (10,000/month): Built for agencies running audits across multiple client sites, or large sites with hundreds of pages that need daily monitoring.
View all plan details and sign up on the SEOPeek pricing page.
Frequently Asked Questions
Why Rust instead of Python or Node.js for an SEO audit CLI?
Rust compiles to a single static binary with no runtime dependencies. You can copy the executable to any machine—a CI runner, a Docker container, a teammate's laptop—and it just works. No Python virtual environments, no node_modules, no version conflicts. Rust's async runtime (tokio) also handles hundreds of concurrent HTTP requests with minimal memory overhead. A Python script auditing 500 URLs would use 200+ MB of RAM with threads or asyncio; the Rust binary does it in under 20 MB.
How many concurrent requests can I safely run?
The default concurrency is 10, which is safe for all plans. On the Pro plan (10,000 audits/month), you can increase to 20–50 concurrent requests without issue. The API handles concurrent requests well, but going above 50 may trigger rate limiting on the free tier. Use the --concurrency flag to tune this based on your plan and network conditions.
Does the SEOPeek API require an API key?
The free tier (50 audits/day) requires no API key at all. Just send a GET request with the url parameter. For the Starter ($9/month) and Pro ($29/month) plans, you will receive an API key to include as a request header for higher rate limits.
Can I use this CLI in CI/CD pipelines?
Yes, and this is one of the strongest use cases. Compile the binary once with cargo build --release, then add it to your CI/CD runner. The CLI exits with a non-zero exit code when any URL scores below the --min-score threshold, which automatically fails the build step. No runtime to install, no package manager, no dependency resolution during the build—just copy the binary and run it.
How do I audit an entire sitemap?
Use the --sitemap flag: seopeek-cli --sitemap https://yoursite.com/sitemap.xml. The CLI fetches the sitemap, parses all <loc> elements using the quick-xml crate, and audits every URL concurrently. It handles both regular sitemaps and sitemap index files (sitemaps that reference other sitemaps). Combine with --json to save structured results for downstream processing.
Build Your SEO Audit CLI Today
50 free audits per day. No API key required. One cargo build and you have a production-grade SEO auditor.
See pricing plans for higher volumes.