A Rust CLI for SEO content (blog posts/landing-page copy): generate N drafts → deterministic rule checks → LLM rubric scoring → feedback-driven regeneration.
LLM backend is the Claude Code CLI (claude -p) as a subprocess — no separate API key required.
Ports the same "generate N → rule-check → de-anchoring rubric score → regenerate" architecture as Loop-Suite/bizplan-loop (a business-plan generation tool) into SEO copy generation.
This CLI is complementary, not overlapping, with the seo-reference-library Claude Code skill some users already have. seo-reference-library is an evidence-based audit tool — it measures an existing site and produces SEO design patterns/checklists/scores; it diagnoses a page that already exists. seo-loop, on the other hand, generates new content and scores/regenerates it toward a target score in a loop — it produces a piece of writing that doesn't exist yet. Use seo-reference-library when you need an audit, and seo-loop when you need a new blog-post/landing-page copy draft. You can use them together: move the checklist that seo-reference-library produced into specs/*.toml's guide/context fields to strengthen the scoring criteria.
flowchart LR
A["brief + spec"] --> B["generate.rs: N angle-varied drafts"]
B --> C["checks.rs: title/meta length, heading hierarchy,<br/>keyword placement, Flesch readability, link/citation counts"]
C --> D["score.rs: LLM rubric<br/>multiple judge models/rounds"]
D --> E["trimmed-mean aggregation per criterion"]
E --> F{"loop mode?"}
F -->|"gen"| G["best.md + ranked runs"]
F -->|"loop, target score"| H["feedback → regenerate<br/>until target / max-iter"]
H --> B
G --> I["held-out gate model re-scores<br/>first vs. best (reward-hacking check)"]
flowchart TB
subgraph gen["seo gen"]
G1["N drafts, angle-varied prompts"] --> G2["checks.rs + score.rs"] --> G3["best.md + ranked runs/*.md"]
end
subgraph score["seo score"]
S1["existing draft.md"] --> S2["checks.rs + score.rs"] --> S3["report only, no regeneration"]
end
subgraph loopmode["seo loop"]
L1["brief"] --> L2["gen round"] --> L3{"target reached<br/>or max-iter hit?"}
L3 -->|"no"| L4["feedback_text() → regenerate"] --> L2
L3 -->|"yes"| L5["held-out gate model<br/>re-scores first vs. best"]
end
flowchart LR
A["frontmatter + markdown body"] --> B["parse_front_matter()<br/>(split('\\n')-based offsets, CRLF-safe)"]
B --> C["headings() / scan_links()<br/>(bracket-depth aware, no regex)"]
C --> D["title/meta length, H1 count,<br/>heading-skip, alt text, link counts"]
C --> E["readability(): Flesch Reading Ease /<br/>Flesch-Kincaid Grade (Latin-script only)"]
C --> F["paragraph_length_issues()"]
D --> G["citation count < min_citations<br/>→ hard 60-point cap in score.rs"]
G --> H["format_issues(): merged into<br/>one deterministic issue list"]
E --> H
F --> H
- Rust 1.70+
claudeCLI installed and logged in (use--claude-binif not on PATH)
cargo build --release # target/release/seo# 1) generate N drafts + score + rank
seo --model sonnet --judge-model haiku \
gen --spec specs/example-blogpost.toml --brief brief.example.md -n 6 --rounds 2 --concurrency 3 --out runs/blog
# 2) score an existing draft only
seo --judge-model sonnet,haiku \
score --spec specs/example-blogpost.toml --input draft.md --rounds 3 --out runs/check
# 3) self-improvement loop toward a target score (+ held-out check)
seo --model opus --judge-model sonnet --gate-model haiku \
loop --spec specs/example-blogpost.toml --brief brief.example.md --target 85 --max-iter 4 --out runs/loopThe invocation always takes this shape (verified against claude --help).
claude -p --output-format json --safe-mode --no-session-persistence --tools "" \
[--model M] [--append-system-prompt S] [--json-schema SCHEMA] [--max-budget-usd X]
| Flag | Reason |
|---|---|
--safe-mode |
Don't load the working directory's CLAUDE.md/skills/plugins/hooks/MCP → reproducibility. Disable with --load-context |
--tools "" |
Fully disables built-in tools (Read/Edit/Write/Bash) → pure text generation, no file access |
--no-session-persistence |
No session file written. Avoids contention under parallel execution |
--json-schema |
Forces the scoring result into a schema. A validated object arrives in the response's structured_output |
--output-format json |
Collects result / structured_output / total_cost_usd, printed as a running total at the end |
--bare is not used — it doesn't read OAuth/keychain and only accepts ANTHROPIC_API_KEY, which breaks auth for subscription-login users.
The prompt is passed over stdin; writing stdin and reading stdout/stderr happen on separate threads simultaneously (to avoid deadlock from a saturated pipe buffer).
The document being generated/scored is frontmatter plus Markdown.
---
title: "title, 50-60 characters"
meta_description: "meta description, 120-160 characters"
---
# H1 (exactly one)
Body... ## subheading ...-
Deterministic checks (
checks.rs, Rust, no LLM):- title/meta_description character-count range
- Exactly one H1, no heading-level skipping (e.g. H1→H3 forbidden)
- Whether the target keyword is present in the title / H1 / first 100 characters of the intro (presence, not density — the SEO industry has no agreed-upon density threshold, so this project doesn't enforce an arbitrary percentage)
- Whether image alt text is present
- Whether the internal-link count falls within the spec's configured range (default 3–5) — this range varies a lot by site structure and article length. It's not an absolute standard, just a per-site reference value to tune in the spec file.
- Source/citation (external authoritative link) count — for E-E-A-T. A criterion marked
citation_required = truegets a hard 60-point cap in code if citations fall belowmin_citations(see below) - (secondary signal) Flesch Reading Ease / Flesch-Kincaid Grade — computed only for Latin-script content (see limitations below)
-
LLM rubric scoring: 0–100 per criterion, default 4 criteria (weights tunable in
specs/*.toml):search_intent_match0.30 — fit with search intentkeyword_naturalness0.20 — natural keyword usage (avoiding over-optimization)eeat_signals0.25 — experience/expertise/authoritativeness/trustworthiness signals,citation_required = truestructure_readability0.25 — heading hierarchy, paragraph structure, scannability
Before scoring, the model must first write out "what conditions content needs to meet to rank for this search intent" (de-anchoring), and for every criterion it must quote the document verbatim and explain "why not a higher score." No quoted evidence caps the score at 60 (a general rule for content scoring, per the prompt).
-
Citation-shortfall 60-point cap (enforced in code): for a criterion like
eeat_signalswithcitation_required = true, since the source/citation link count is deterministically countable — unlike bizplan-loop, which leaves this entirely to the LLM prompt —score.rsapplies a hard 60-point cap directly based on the measured count. Shown in the report with a 🔒60 marker. -
Aggregation:
--rounds Nrounds → cycling models/lenses → trimmed mean per criterion (n≥4 drops min & max) → weighted sum. -
Instability signal: per-criterion score spread (±) is shown in the report. Don't trust a criterion with a wide spread.
-
Held-out gate (
--gate-model): a model that never participated in the loop re-scores only the first and best drafts. If the loop score rose but the held-out score didn't, it's flagged as scorer optimization (reward hacking).
For de-anchoring, trimmed mean, held-out gate, length canary, and why a --judge-model panel is preferred over just increasing --rounds — with primary sources (arXiv papers, etc.) — see bizplan-loop's DESIGN.md.
- BlogPilot Open Source AI SEO Content Studio (MIT):
src/checks.rsports the Flesch Reading Ease / Flesch-Kincaid Grade calculation logic fromsrc/lib/seo/readability.ts(Markdown stripping → sentence/word splitting → syllable-count estimation → standard formula), rewritten in Rust. The algorithm structure was ported, not the code itself — see NOTICE for details. The same repo's E-E-A-T checklist (eeat-checklist.md) is a methodology document, not code, so it was only used as an idea reference (reflected in the citation-link check and rubric wording). The same repo's TF-IDF keyword extraction (tfidf.ts) was not ported — it needs a corpus of top-ranking competitor documents, and this CLI is a single-document generation/scoring tool with no such corpus. sour4bh/auto-seo(no declared license, all rights reserved) — its code was not consulted at all. Only the general idea of "hybrid rule + LLM scoring" (not copyrightable) served as a general reference when designing this project's deterministic-check + LLM-rubric structure.- Yoast SEO (wordpress-seo, GPL-2.0) — its source was neither read nor referenced. Title/meta length, exactly-one-H1, heading hierarchy, etc. are widely known SEO-industry facts and were implemented independently.
- CyberCraftBD/power-seo (MIT) — the idea for
paragraph_length_issues()(flagging overly long paragraphs) came from this repo'sparagraph-length.ts. The original thresholds are English word-count based (120–150); this project redesigned it around Korean character count (600 chars) instead, since it also handles non-English content — neither the code nor the original threshold was copied as-is. See NOTICE.
name = "Blog post"
context = "Site/brand/target-audience context. Inserted verbatim into the prompt"
keyword = "target keyword"
site_domain = "example.com" # basis for internal/external link classification
title_min = 50
title_max = 60
meta_min = 120
meta_max = 160
internal_links_min = 3
internal_links_max = 5
min_citations = 1
[[criteria]]
id = "eeat_signals"
name = "E-E-A-T signals"
weight = 0.25
guide = "..."
citation_required = true # 60-point cap if citations are insufficientBundled spec: specs/example-blogpost.toml. Example content brief: brief.example.md.
- Keyword density is not checked. Only presence (title/H1/intro) is checked — density thresholds have no industry consensus, so enforcing an arbitrary percentage could actively encourage bad optimization.
- The 3–5 internal-link range varies a lot by source. Tune
internal_links_min/maxper site in the spec file. - The Flesch readability metric is English-only. The Flesch formula is based on English syllable counting and doesn't hold for Korean and other non-Latin-script content. If the body's Latin-character share is under 50%, the calculation is skipped automatically and shown as N/A in the report (an arbitrary judgment call — other thresholds are possible, 50% was chosen conservatively).
- Internal/citation link classification is a simple heuristic based on whether the URL's host matches
site_domain— subdomains, shortened URLs, etc. can still be misclassified in edge cases. - LLM scores do not guarantee actual search ranking or click-through rate. Intended for relative comparison and direction for improvement within the same spec and scoring model.
- If the generation and scoring models are the same, it tends to rate its own style generously (a warning is printed if
--judge-modelisn't set). claude -pdoesn't expose temperature → draft diversity comes only from angle prompts.- Output is Markdown (including frontmatter). Converting it to an actual CMS's publish format is out of scope.
docs/research-and-evidence-survey-2026-08-01.md §5 백로그 중 다음을 구현했다.
- 다축 reward-hacking canary 추가 (
loop_run.rs): 기존 길이 인플레이션 canary(분량 +25%인데 점수 +5 미만)는 arXiv:2605.27996("Reward Bias Substitution")이 경고하는 "단일 축 방어"에 해당한다. 이 논문의 핵심 주장 — 편향 축 하나(길이)만 막으면 최적화 압력이 관측되지 않는 다른 축으로 옮겨간다 — 을 근거로, 회차 간 타깃 키워드 등장 횟수(Metrics::keyword_occurrences)가 +50% 이상 급증했는데 점수 상승폭이 미미한(+5점 미만) 경우를 감시하는 키워드 밀도 canary를 추가했다. "저품질 인용 링크로 개수만 채우기" 축 대신 이 축을 고른 이유:checks.rs에 이미 있는norm_kw정규화 로직을 재사용할 수 있어 구현이 더 깔끔하고, 링크 품질 판정은 URL을 실제로 fetch해야 해서 아래 "스킵" 항목과 같은 이유로 제외했다. - 한국어 가독성 휴리스틱 opt-in 추가 (
checks.rs::korean_readability,Metrics::korean_readability_heuristic): 라틴 문자 비중이 50% 이상이면(Flesch가 계산되는 조건) 계산하지 않는 상호배타 필드로 추가했다. 공식은naaaayeonn/AI-literacy-care-Agent(Python, 문서READABILITY_FORMULA.md)를 참고했으나 코드는 포팅하지 않고 공식만 Rust로 재작성했다:100 - (평균 어절수/문장 × 1.015) - (평균 음절수/어절 × 8.0) - (전문용어비율 × 35.0). 이 공식의 계수는 원 출처 스스로 동료검토된 학술적 근거가 없다고 명시한 경험적 휴리스틱이므로 Flesch(표준 공식)와 별도 필드로 분리하고, 리포트에는 항상 "⚠️ 검증되지 않은 휴리스틱" 표기를 붙인다. score_doc라운드 병렬화 (score.rs): 문서 1건 채점 시 라운드마다 순차 호출하던 것을,main.rs::par_map이 문서 간 병렬화에 쓰는std::thread::scope패턴을 그대로 라운드 단위에 적용해 병렬화했다.--concurrency는 이미 문서 단위 예산이므로 별도 옵션 없이 라운드 수만큼만 스레드를 스폰하는 단순 구현으로 제한했다. 결과는 라운드 인덱스 순서를 유지해 반환하므로trimmed_mean등 집계 로직은 영향받지 않는다.
다음 두 항목은 리서치 문서가 명시적으로 보류를 결론 낸 항목이라 구현하지 않았다.
pulldown-cmark도입: §3.3이 "현재는 불필요, reference-style 링크·코드스팬 내]·오토링크 버그가 실제로 나오면 그때 재검토"로 조건부 보류를 명시했다. 트리거 조건 미충족 상태라 구현하지 않았다.- 인용 URL 실제 fetch 검증(FacTool류): §5가 "오프라인/재현성 우선 철학과 상충, 아이디어 단계"로 명시했다. 네트워크 의존성을 추가하는 자체가 이 CLI의 설계 철학과 맞지 않아 구현하지 않았다.
Findings CONFIRMED by a review-panel pass (functionality/good_things/tests lenses) were applied:
- Fixed a real bug where the frontmatter parser under-counted by 1 byte per line on CRLF documents, truncating the start of the body (rewritten around
split('\n')-based offset calculation instead of.lines()). - Frontmatter with no closing
---is now explicitly detected and flagged (previously it silently fell back to treating the whole document as body, corrupting readability/heading checks). - Fixed
is_internal_url, which used substring matching and could misclassify hosts likenotexample.com/example.com.evil.comas internal links, to compare the host exactly instead. - Added a paragraph-length check (idea from CyberCraftBD/power-seo, MIT, redesigned around Korean character count — see NOTICE).
scan_links()now tracks bracket depth to handle a nested[a[b]c](url)label correctly and ignores escaped\[/\].- Broadened test coverage: CRLF frontmatter, unclosed frontmatter, host-spoofing rejection, the Korean-readability-returns-None branch, whitespace-only alt text, nested/escaped brackets, and more.