agent-seo-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ """Agent-first SEO analysis toolkit."""
2
+
3
+ from .agent import build_agent_manifest, build_connection_status, build_privacy_audit
4
+ from .intent import SearchIntentAnalyzer
5
+ from .quality import score_markdown
6
+
7
+ __all__ = [
8
+ "SearchIntentAnalyzer",
9
+ "build_agent_manifest",
10
+ "build_connection_status",
11
+ "build_privacy_audit",
12
+ "score_markdown",
13
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+
5
+ SUPPORTED_CLIENTS = ["generic", "claude", "codex", "cursor", "windsurf", "hermes", "openclaw"]
6
+
7
+
8
+ def _safe_client(client: str = "generic") -> str:
9
+ return client if client in SUPPORTED_CLIENTS else "generic"
10
+
11
+
12
+ def _present(env: Mapping[str, str], key: str) -> bool:
13
+ return bool(str(env.get(key, "")).strip())
14
+
15
+
16
+ def build_agent_manifest(client: str = "generic") -> dict:
17
+ return {
18
+ "project": "agent-seo-engine",
19
+ "mcp_name": "io.github.davidmosiah/agent-seo-engine",
20
+ "client": _safe_client(client),
21
+ "package": {
22
+ "pip": "pipx install agent-seo-engine[mcp]",
23
+ "cli": "agent-seo-engine",
24
+ "mcp": "agent-seo-mcp",
25
+ },
26
+ "supported_clients": SUPPORTED_CLIENTS,
27
+ "standard_tools": [
28
+ "agent_seo_manifest",
29
+ "agent_seo_connection_status",
30
+ "agent_seo_privacy_audit",
31
+ "agent_seo_detect_intent",
32
+ "agent_seo_score_content",
33
+ "agent_seo_prioritize_opportunity",
34
+ ],
35
+ "recommended_first_calls": ["agent_seo_connection_status", "agent_seo_privacy_audit"],
36
+ "default_mode": "local_offline",
37
+ "hermes": {
38
+ "config_path": "~/.hermes/config.yaml",
39
+ "tool_name_prefix": "mcp_agent_seo_",
40
+ "recommended_config": (
41
+ "mcp_servers:\n"
42
+ " agent_seo:\n"
43
+ " command: agent-seo-mcp\n"
44
+ " args: []\n"
45
+ " sampling:\n"
46
+ " enabled: false"
47
+ ),
48
+ },
49
+ "agent_rules": [
50
+ "Start with connection status before content operations.",
51
+ "Use local content paths or explicit text from the user.",
52
+ "Do not send drafts to analytics providers unless the user asks.",
53
+ "Return exact checks and next actions, not generic SEO advice.",
54
+ ],
55
+ }
56
+
57
+
58
+ def build_connection_status(env: Mapping[str, str] | None = None) -> dict:
59
+ env = env or {}
60
+ services = []
61
+ if _present(env, "GEMINI_API_KEY") or _present(env, "GOOGLE_API_KEY"):
62
+ services.append("gemini")
63
+ if _present(env, "GA4_PROPERTY_ID"):
64
+ services.append("ga4")
65
+ if _present(env, "GSC_SITE_URL"):
66
+ services.append("google_search_console")
67
+
68
+ return {
69
+ "ok": True,
70
+ "mode": "offline" if not services else "offline_plus_optional_integrations",
71
+ "external_services_configured": services,
72
+ "ready_for_content_scoring": True,
73
+ "ready_for_mcp": True,
74
+ "next_steps": [
75
+ "Run agent-seo-engine score --file <markdown> --primary-keyword <keyword>.",
76
+ "Use optional analytics environment only for reports that require it.",
77
+ ],
78
+ }
79
+
80
+
81
+ def build_privacy_audit() -> dict:
82
+ return {
83
+ "project": "agent-seo-engine",
84
+ "secrets_returned_to_agent": False,
85
+ "local_files_ignored": [".env", "credentials/", "node_modules/", ".agent-data/", "coverage/"],
86
+ "external_services": ["optional: Gemini", "optional: GA4", "optional: Google Search Console"],
87
+ "data_boundary": "Content scoring, intent detection and opportunity scoring run locally by default.",
88
+ "safety_rules": [
89
+ "Keep drafts local unless an optional integration is explicitly requested.",
90
+ "Do not commit credentials, analytics exports or unpublished content plans.",
91
+ "Prefer structured JSON output for agents and markdown only for human review.",
92
+ "Treat generated recommendations as editorial suggestions, not automatic publishing approval.",
93
+ ],
94
+ }
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from .agent import build_agent_manifest, build_connection_status, build_privacy_audit
9
+ from .intent import SearchIntentAnalyzer
10
+ from .opportunity import prioritize_opportunity
11
+ from .quality import score_markdown
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> int:
15
+ parser = argparse.ArgumentParser(prog="agent-seo-engine")
16
+ parser.add_argument("--format", choices=["json", "markdown"], default="json")
17
+ sub = parser.add_subparsers(dest="command", required=True)
18
+
19
+ manifest = sub.add_parser("manifest")
20
+ manifest.add_argument("--client", default="generic")
21
+
22
+ sub.add_parser("doctor")
23
+ sub.add_parser("privacy-audit")
24
+
25
+ intent = sub.add_parser("intent")
26
+ intent.add_argument("keyword")
27
+
28
+ score = sub.add_parser("score")
29
+ score.add_argument("--file", required=True)
30
+ score.add_argument("--primary-keyword", default="")
31
+ score.add_argument("--min-words", type=int, default=900)
32
+
33
+ opp = sub.add_parser("opportunity")
34
+ opp.add_argument("--impressions", type=int, default=0)
35
+ opp.add_argument("--clicks", type=int, default=0)
36
+ opp.add_argument("--position", type=float, default=100.0)
37
+ opp.add_argument("--conversions", type=float, default=0.0)
38
+ opp.add_argument("--commercial-intent", type=float, default=0.5)
39
+
40
+ args = parser.parse_args(argv)
41
+
42
+ if args.command == "manifest":
43
+ payload = build_agent_manifest(args.client)
44
+ elif args.command == "doctor":
45
+ payload = build_connection_status(os.environ)
46
+ elif args.command == "privacy-audit":
47
+ payload = build_privacy_audit()
48
+ elif args.command == "intent":
49
+ payload = SearchIntentAnalyzer().analyze(args.keyword)
50
+ elif args.command == "score":
51
+ payload = score_markdown(Path(args.file).read_text(encoding="utf-8"), args.primary_keyword, args.min_words)
52
+ elif args.command == "opportunity":
53
+ payload = prioritize_opportunity(args.impressions, args.clicks, args.position, args.conversions, args.commercial_intent)
54
+ else:
55
+ parser.error("unknown command")
56
+
57
+ print(_format(payload, args.format))
58
+ return 0
59
+
60
+
61
+ def _format(payload: dict, response_format: str) -> str:
62
+ if response_format == "markdown":
63
+ return "# Agent SEO Engine\n\n```json\n" + json.dumps(payload, indent=2, ensure_ascii=False) + "\n```"
64
+ return json.dumps(payload, indent=2, ensure_ascii=False)
65
+
66
+
67
+ if __name__ == "__main__":
68
+ raise SystemExit(main())
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from enum import Enum
5
+ from typing import Any
6
+
7
+
8
+ class SearchIntent(Enum):
9
+ INFORMATIONAL = "informational"
10
+ NAVIGATIONAL = "navigational"
11
+ TRANSACTIONAL = "transactional"
12
+ COMMERCIAL = "commercial_investigation"
13
+
14
+
15
+ class SearchIntentAnalyzer:
16
+ INFORMATIONAL_SIGNALS = [
17
+ "what", "why", "how", "when", "where", "who", "guide", "tutorial",
18
+ "learn", "tips", "best practices", "explained", "definition", "meaning",
19
+ ]
20
+ NAVIGATIONAL_SIGNALS = [
21
+ "login", "sign in", "website", "official", "home page", "account",
22
+ "dashboard", "portal", "app",
23
+ ]
24
+ TRANSACTIONAL_SIGNALS = [
25
+ "buy", "purchase", "order", "download", "get", "pricing", "cost",
26
+ "free trial", "sign up", "subscribe", "install", "coupon", "deal",
27
+ "discount", "cheap", "affordable", "hire",
28
+ ]
29
+ COMMERCIAL_SIGNALS = [
30
+ "best", "top", "review", "vs", "versus", "compare", "comparison",
31
+ "alternative", "alternatives", "like", "similar", "better than",
32
+ "instead of", "option", "choice",
33
+ ]
34
+
35
+ def analyze(
36
+ self,
37
+ keyword: str,
38
+ serp_features: list[str] | None = None,
39
+ top_results: list[dict[str, str]] | None = None,
40
+ ) -> dict[str, Any]:
41
+ keyword_lower = keyword.lower()
42
+ scores = {intent: 0.0 for intent in SearchIntent}
43
+
44
+ for intent, score in self._keyword_scores(keyword_lower).items():
45
+ scores[intent] += score
46
+ for intent, score in self._serp_scores(serp_features or []).items():
47
+ scores[intent] += score
48
+ for intent, score in self._result_scores(top_results or []).items():
49
+ scores[intent] += score
50
+
51
+ total = sum(scores.values())
52
+ confidence = {
53
+ intent.value: round((score / total * 100), 2) if total else 25.0
54
+ for intent, score in scores.items()
55
+ }
56
+ primary = max(scores.items(), key=lambda item: item[1])[0] if total else SearchIntent.INFORMATIONAL
57
+ ordered = sorted(scores.items(), key=lambda item: item[1], reverse=True)
58
+ secondary = None
59
+ if total and len(ordered) > 1:
60
+ if confidence[ordered[0][0].value] - confidence[ordered[1][0].value] < 15:
61
+ secondary = ordered[1][0]
62
+
63
+ return {
64
+ "keyword": keyword,
65
+ "primary_intent": primary.value,
66
+ "secondary_intent": secondary.value if secondary else None,
67
+ "confidence": confidence,
68
+ "signals_detected": self._detected_signals(keyword_lower, serp_features or []),
69
+ "recommendations": self._recommendations(primary),
70
+ }
71
+
72
+ def _keyword_scores(self, keyword: str) -> dict[SearchIntent, float]:
73
+ scores = {intent: 0.0 for intent in SearchIntent}
74
+ for signal in self.INFORMATIONAL_SIGNALS:
75
+ if signal in keyword:
76
+ scores[SearchIntent.INFORMATIONAL] += 2
77
+ for signal in self.NAVIGATIONAL_SIGNALS:
78
+ if signal in keyword:
79
+ scores[SearchIntent.NAVIGATIONAL] += 3
80
+ for signal in self.TRANSACTIONAL_SIGNALS:
81
+ if signal in keyword:
82
+ scores[SearchIntent.TRANSACTIONAL] += 2
83
+ for signal in self.COMMERCIAL_SIGNALS:
84
+ if signal in keyword:
85
+ scores[SearchIntent.COMMERCIAL] += 2
86
+ if re.match(r"^(what|why|how|when|where|who|can|should|is|are|does)\b", keyword):
87
+ scores[SearchIntent.INFORMATIONAL] += 3
88
+ if re.search(r"\d+\s+(best|top)", keyword):
89
+ scores[SearchIntent.COMMERCIAL] += 3
90
+ return scores
91
+
92
+ def _serp_scores(self, features: list[str]) -> dict[SearchIntent, float]:
93
+ scores = {intent: 0.0 for intent in SearchIntent}
94
+ for feature in features:
95
+ value = feature.lower()
96
+ if "snippet" in value or "people" in value or "knowledge" in value:
97
+ scores[SearchIntent.INFORMATIONAL] += 2
98
+ if "shopping" in value or "product" in value or "ad" in value:
99
+ scores[SearchIntent.TRANSACTIONAL] += 2
100
+ if "carousel" in value:
101
+ scores[SearchIntent.COMMERCIAL] += 1
102
+ return scores
103
+
104
+ def _result_scores(self, results: list[dict[str, str]]) -> dict[SearchIntent, float]:
105
+ scores = {intent: 0.0 for intent in SearchIntent}
106
+ for result in results[:10]:
107
+ combined = f"{result.get('title', '')} {result.get('description', '')}".lower()
108
+ url = result.get("url", "").lower()
109
+ if any(term in combined for term in ["guide", "how to", "what is", "tutorial"]):
110
+ scores[SearchIntent.INFORMATIONAL] += 0.5
111
+ if any(term in combined for term in ["best", "top", "review", "vs", "compare"]):
112
+ scores[SearchIntent.COMMERCIAL] += 0.5
113
+ if any(term in combined for term in ["buy", "price", "shop", "order", "get"]):
114
+ scores[SearchIntent.TRANSACTIONAL] += 0.5
115
+ if any(term in url for term in ["/product/", "/pricing", "/buy", "/shop", "/checkout"]):
116
+ scores[SearchIntent.TRANSACTIONAL] += 0.5
117
+ return scores
118
+
119
+ def _detected_signals(self, keyword: str, serp_features: list[str]) -> dict[str, list[str]]:
120
+ return {
121
+ "keyword": [signal for signal in (
122
+ self.INFORMATIONAL_SIGNALS + self.NAVIGATIONAL_SIGNALS + self.TRANSACTIONAL_SIGNALS + self.COMMERCIAL_SIGNALS
123
+ ) if signal in keyword],
124
+ "serp_features": serp_features,
125
+ }
126
+
127
+ def _recommendations(self, intent: SearchIntent) -> list[str]:
128
+ if intent == SearchIntent.INFORMATIONAL:
129
+ return ["Lead with a direct answer, then expand into steps, examples and FAQs."]
130
+ if intent == SearchIntent.COMMERCIAL:
131
+ return ["Use comparison tables, alternatives, decision criteria and clear evaluation language."]
132
+ if intent == SearchIntent.TRANSACTIONAL:
133
+ return ["Make pricing, trust signals, proof and conversion path obvious above the fold."]
134
+ return ["Make brand/entity navigation unambiguous and link to the canonical destination early."]
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from .agent import build_agent_manifest, build_connection_status, build_privacy_audit
7
+ from .intent import SearchIntentAnalyzer
8
+ from .opportunity import prioritize_opportunity
9
+ from .quality import score_markdown
10
+
11
+
12
+ def create_mcp():
13
+ try:
14
+ from mcp.server.fastmcp import FastMCP
15
+ except ImportError as exc:
16
+ raise RuntimeError("Install MCP support with: pip install 'agent-seo-engine[mcp]'") from exc
17
+
18
+ mcp = FastMCP("agent-seo-engine")
19
+
20
+ @mcp.tool()
21
+ def agent_seo_manifest(client: str = "generic") -> dict:
22
+ return build_agent_manifest(client)
23
+
24
+ @mcp.tool()
25
+ def agent_seo_connection_status() -> dict:
26
+ return build_connection_status(os.environ)
27
+
28
+ @mcp.tool()
29
+ def agent_seo_privacy_audit() -> dict:
30
+ return build_privacy_audit()
31
+
32
+ @mcp.tool()
33
+ def agent_seo_detect_intent(keyword: str) -> dict:
34
+ return SearchIntentAnalyzer().analyze(keyword)
35
+
36
+ @mcp.tool()
37
+ def agent_seo_score_content(markdown: str = "", file_path: str = "", primary_keyword: str = "") -> dict:
38
+ text = markdown or Path(file_path).read_text(encoding="utf-8")
39
+ return score_markdown(text, primary_keyword=primary_keyword)
40
+
41
+ @mcp.tool()
42
+ def agent_seo_prioritize_opportunity(
43
+ impressions: int = 0,
44
+ clicks: int = 0,
45
+ position: float = 100.0,
46
+ conversions: float = 0.0,
47
+ commercial_intent: float = 0.5,
48
+ ) -> dict:
49
+ return prioritize_opportunity(impressions, clicks, position, conversions, commercial_intent)
50
+
51
+ return mcp
52
+
53
+
54
+ def main() -> None:
55
+ create_mcp().run()
56
+
57
+
58
+ if __name__ == "__main__":
59
+ main()
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def prioritize_opportunity(
5
+ impressions: int = 0,
6
+ clicks: int = 0,
7
+ position: float = 100.0,
8
+ conversions: float = 0.0,
9
+ commercial_intent: float = 0.5,
10
+ ) -> dict:
11
+ volume_score = min(100.0, max(0.0, impressions / 50))
12
+ position_score = _position_score(position)
13
+ ctr = clicks / impressions if impressions else 0.0
14
+ expected_ctr = _expected_ctr(position)
15
+ ctr_gap_score = min(100.0, max(0.0, (expected_ctr - ctr) / max(expected_ctr, 0.01) * 100))
16
+ conversion_score = min(100.0, conversions * 25)
17
+ intent_score = min(100.0, max(0.0, commercial_intent * 100))
18
+
19
+ final = round(
20
+ volume_score * 0.25
21
+ + position_score * 0.25
22
+ + ctr_gap_score * 0.20
23
+ + intent_score * 0.20
24
+ + conversion_score * 0.10,
25
+ 2,
26
+ )
27
+ return {
28
+ "final_score": final,
29
+ "priority": "high" if final >= 75 else "medium" if final >= 50 else "low",
30
+ "score_breakdown": {
31
+ "volume": round(volume_score, 1),
32
+ "position": round(position_score, 1),
33
+ "ctr_gap": round(ctr_gap_score, 1),
34
+ "commercial_intent": round(intent_score, 1),
35
+ "conversions": round(conversion_score, 1),
36
+ },
37
+ "recommended_action": _action(position, ctr_gap_score),
38
+ }
39
+
40
+
41
+ def _position_score(position: float) -> float:
42
+ if position <= 3:
43
+ return 70
44
+ if position <= 10:
45
+ return 85
46
+ if position <= 20:
47
+ return 100
48
+ if position <= 50:
49
+ return 60
50
+ return 25
51
+
52
+
53
+ def _expected_ctr(position: float) -> float:
54
+ if position <= 1:
55
+ return 0.31
56
+ if position <= 3:
57
+ return 0.12
58
+ if position <= 10:
59
+ return 0.04
60
+ if position <= 20:
61
+ return 0.012
62
+ return 0.004
63
+
64
+
65
+ def _action(position: float, ctr_gap_score: float) -> str:
66
+ if 8 < position <= 20:
67
+ return "Refresh content and internal links to push the URL onto page one."
68
+ if position <= 10 and ctr_gap_score > 40:
69
+ return "Rewrite title and meta description to improve CTR."
70
+ if position > 20:
71
+ return "Build a stronger article or hub page before expecting ranking movement."
72
+ return "Monitor and improve topical depth selectively."
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+
7
+ def split_frontmatter(markdown: str) -> tuple[dict[str, Any], str]:
8
+ if not markdown.startswith("---\n"):
9
+ return {}, markdown
10
+ parts = markdown.split("---\n", 2)
11
+ if len(parts) < 3:
12
+ return {}, markdown
13
+ return _parse_simple_yaml(parts[1]), parts[2]
14
+
15
+
16
+ def score_markdown(markdown: str, primary_keyword: str = "", min_words: int = 900) -> dict[str, Any]:
17
+ frontmatter, body = split_frontmatter(markdown)
18
+ plain = _plain_text(body)
19
+ words = re.findall(r"\b[\w'-]+\b", plain)
20
+ word_count = len(words)
21
+ h1 = re.findall(r"^#\s+(.+)$", body, flags=re.MULTILINE)
22
+ h2 = re.findall(r"^##\s+(.+)$", body, flags=re.MULTILINE)
23
+ links = re.findall(r"(?<!!)\[[^\]]+\]\(([^)\s]+)", body)
24
+ internal_links = [link for link in links if link.startswith("/")]
25
+ external_links = [link for link in links if link.startswith(("http://", "https://"))]
26
+
27
+ title = str(frontmatter.get("title") or (h1[0] if h1 else "")).strip()
28
+ description = str(frontmatter.get("description") or "").strip()
29
+ keyword = primary_keyword.strip().lower()
30
+ lower_body = plain.lower()
31
+ keyword_count = lower_body.count(keyword) if keyword else 0
32
+ density = round((keyword_count / word_count * 100), 2) if keyword and word_count else 0.0
33
+
34
+ checks = {
35
+ "title": _check(bool(title), 10, "Add a title or H1."),
36
+ "title_length": _check(20 <= len(title) <= 70, 10, "Keep title between 20 and 70 characters."),
37
+ "meta_description": _check(120 <= len(description) <= 165, 15, "Rewrite description to 120-165 characters."),
38
+ "word_count": _check(word_count >= min_words, 15, f"Expand article to at least {min_words} words."),
39
+ "h1": _check(len(h1) == 1, 10, "Use exactly one H1."),
40
+ "h2_sections": _check(len(h2) >= 3, 10, "Add at least three H2 sections."),
41
+ "keyword_in_title": _check(not keyword or keyword in title.lower(), 10, "Place the primary keyword in the title."),
42
+ "keyword_in_first_100": _check(not keyword or keyword in " ".join(words[:100]).lower(), 10, "Use primary keyword in first 100 words."),
43
+ "keyword_density": _check(not keyword or 0.4 <= density <= 2.5, 10, "Adjust primary keyword density to roughly 0.4%-2.5%."),
44
+ "internal_links": _check(len(internal_links) >= 2, 5, "Add at least two internal links."),
45
+ "external_links": _check(len(external_links) >= 1, 5, "Add at least one authoritative external link."),
46
+ }
47
+
48
+ max_score = sum(item["weight"] for item in checks.values())
49
+ earned = sum(item["weight"] for item in checks.values() if item["passed"])
50
+ score = round((earned / max_score) * 100, 1) if max_score else 0.0
51
+ recommendations = [item["recommendation"] for item in checks.values() if not item["passed"]]
52
+
53
+ return {
54
+ "overall_score": score,
55
+ "grade": _grade(score),
56
+ "publishing_ready": score >= 85 and not any(
57
+ not checks[key]["passed"] for key in ["title", "meta_description", "h1", "keyword_in_title"]
58
+ ),
59
+ "checks": checks,
60
+ "metrics": {
61
+ "word_count": word_count,
62
+ "h1_count": len(h1),
63
+ "h2_count": len(h2),
64
+ "internal_link_count": len(internal_links),
65
+ "external_link_count": len(external_links),
66
+ "primary_keyword_occurrences": keyword_count,
67
+ "primary_keyword_density": density,
68
+ },
69
+ "recommendations": recommendations,
70
+ }
71
+
72
+
73
+ def _check(passed: bool, weight: int, recommendation: str) -> dict[str, Any]:
74
+ return {"passed": passed, "weight": weight, "recommendation": recommendation}
75
+
76
+
77
+ def _grade(score: float) -> str:
78
+ if score >= 90:
79
+ return "A"
80
+ if score >= 80:
81
+ return "B"
82
+ if score >= 70:
83
+ return "C"
84
+ if score >= 60:
85
+ return "D"
86
+ return "F"
87
+
88
+
89
+ def _plain_text(markdown: str) -> str:
90
+ text = re.sub(r"```.*?```", "", markdown, flags=re.DOTALL)
91
+ text = re.sub(r"^#+\s+", "", text, flags=re.MULTILINE)
92
+ text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
93
+ text = re.sub(r"[*_`>#-]", " ", text)
94
+ return re.sub(r"\s+", " ", text).strip()
95
+
96
+
97
+ def _parse_simple_yaml(block: str) -> dict[str, Any]:
98
+ data: dict[str, Any] = {}
99
+ current_key: str | None = None
100
+ for raw in block.splitlines():
101
+ line = raw.rstrip()
102
+ if not line.strip() or line.lstrip().startswith("#"):
103
+ continue
104
+ if line.startswith(" - ") and current_key:
105
+ data.setdefault(current_key, []).append(line[4:].strip().strip("\"'"))
106
+ continue
107
+ if ":" not in line:
108
+ continue
109
+ key, value = line.split(":", 1)
110
+ key = key.strip()
111
+ value = value.strip().strip("\"'")
112
+ if value:
113
+ data[key] = value
114
+ current_key = None
115
+ else:
116
+ data[key] = []
117
+ current_key = key
118
+ return data
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-seo-engine
3
+ Version: 0.1.0
4
+ Summary: Agent-first SEO quality, intent and opportunity engine with CLI and optional MCP server.
5
+ Project-URL: Homepage, https://github.com/davidmosiah/agent-seo-engine
6
+ Project-URL: Repository, https://github.com/davidmosiah/agent-seo-engine
7
+ Project-URL: Issues, https://github.com/davidmosiah/agent-seo-engine/issues
8
+ Author: David Mosiah
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agent-tools,agentic-workflows,ai-agents,cli,content-ops,local-first,mcp,mcp-server,search-intent,seo
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
18
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
19
+ Requires-Python: >=3.10
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=8.0; extra == 'dev'
22
+ Provides-Extra: mcp
23
+ Requires-Dist: mcp>=1.13.0; extra == 'mcp'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Agent SEO Engine
27
+
28
+ [![GitHub stars](https://img.shields.io/github/stars/davidmosiah/agent-seo-engine?style=flat-square&logo=github)](https://github.com/davidmosiah/agent-seo-engine/stargazers)
29
+ [![CI](https://github.com/davidmosiah/agent-seo-engine/actions/workflows/ci.yml/badge.svg)](https://github.com/davidmosiah/agent-seo-engine/actions/workflows/ci.yml)
30
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
31
+ [![Agent-first](https://img.shields.io/badge/agent--first-MCP-0ea5a3?style=flat-square)](https://github.com/davidmosiah/agent-seo-engine)
32
+
33
+ > If this agent-first tool helps your workflow, please star the repo. Stars make this agent-first tooling easier for other builders to discover and help Delx keep shipping open infrastructure.
34
+
35
+ Agent-first SEO scoring, search-intent detection and opportunity prioritization. It packages the useful parts of a production content pipeline into a clean local CLI plus an optional MCP server for Codex, Claude, Cursor, Hermes, OpenClaw and other agent runtimes.
36
+
37
+ Use it when an agent needs deterministic SEO checks before rewriting, refreshing or publishing content.
38
+
39
+ ## What It Does
40
+
41
+ - Classifies search intent: informational, navigational, transactional and commercial investigation
42
+ - Scores markdown articles for agent-readable SEO gaps
43
+ - Prioritizes GSC-style opportunities by impressions, position, CTR gap, conversions and commercial value
44
+ - Exposes `manifest`, `connection_status` and `privacy_audit` surfaces before content tools
45
+ - Runs locally by default with no required API keys
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pipx install "git+https://github.com/davidmosiah/agent-seo-engine.git"
51
+ ```
52
+
53
+ With MCP support:
54
+
55
+ ```bash
56
+ pipx install "git+https://github.com/davidmosiah/agent-seo-engine.git#egg=agent-seo-engine[mcp]"
57
+ ```
58
+
59
+ PyPI release automation is configured with Trusted Publishing. See [docs/pypi-publishing.md](docs/pypi-publishing.md) for the one-time PyPI pending-publisher setup.
60
+
61
+ ## CLI
62
+
63
+ ```bash
64
+ agent-seo-engine manifest --client codex
65
+ agent-seo-engine doctor
66
+ agent-seo-engine privacy-audit
67
+ agent-seo-engine intent "best ai agent framework"
68
+ agent-seo-engine score --file examples/article.md --primary-keyword "ai agent testing"
69
+ agent-seo-engine opportunity --impressions 4200 --clicks 80 --position 12.4 --commercial-intent 0.8
70
+ ```
71
+
72
+ All commands return structured JSON by default. Use `--format markdown` for human review.
73
+
74
+ ## MCP
75
+
76
+ ```bash
77
+ agent-seo-mcp
78
+ ```
79
+
80
+ Hermes-style config:
81
+
82
+ ```yaml
83
+ mcp_servers:
84
+ agent_seo:
85
+ command: agent-seo-mcp
86
+ args: []
87
+ sampling:
88
+ enabled: false
89
+ ```
90
+
91
+ Recommended first calls:
92
+
93
+ 1. `agent_seo_connection_status`
94
+ 2. `agent_seo_privacy_audit`
95
+ 3. `agent_seo_score_content`
96
+
97
+ ## Agent Surfaces
98
+
99
+ | Tool | Purpose |
100
+ |---|---|
101
+ | `agent_seo_manifest` | Install/runtime guidance for agent clients |
102
+ | `agent_seo_connection_status` | Local/offline readiness and optional integration status |
103
+ | `agent_seo_privacy_audit` | Draft, analytics and credential boundaries |
104
+ | `agent_seo_detect_intent` | Search intent classification |
105
+ | `agent_seo_score_content` | Markdown quality checks with exact recommendations |
106
+ | `agent_seo_prioritize_opportunity` | GSC-style opportunity scoring |
107
+
108
+ ## Copy-Paste Agent Prompt
109
+
110
+ ```text
111
+ Use agent-seo-engine. First call agent_seo_connection_status and agent_seo_privacy_audit.
112
+ Score the draft, then propose only edits tied to failed checks or high-impact opportunities.
113
+ ```
114
+
115
+ ## Agent Contract
116
+
117
+ Agents should not guess whether a draft is ready. They should call the scoring tool, read exact failed checks, then propose focused edits. The engine is intentionally deterministic and local so repeated agent runs can compare output over time.
118
+
119
+ ## Development
120
+
121
+ ```bash
122
+ python3 -m venv .venv
123
+ . .venv/bin/activate
124
+ pip install -e ".[dev]"
125
+ pytest
126
+ python -m compileall -q src
127
+ ```
@@ -0,0 +1,13 @@
1
+ agent_seo_engine/__init__.py,sha256=Qalo0dVBQngGmlNAzPE-MKfkGxWouB-Tf63zca-R9-U,355
2
+ agent_seo_engine/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ agent_seo_engine/agent.py,sha256=ZEmWF7bxB16k9psu50AiRLMqE7zfDLErRQZBXHYiRzM,3687
4
+ agent_seo_engine/cli.py,sha256=xjfjojuOR7vLnclGNU0dR9lHVgpPiI0ZWbSTJUtUfz4,2488
5
+ agent_seo_engine/intent.py,sha256=ThRRk4rpqc8XSBlxPSh47nRI8ecNpZAuZQjOEyBa44s,6152
6
+ agent_seo_engine/mcp_server.py,sha256=RiL-NvqIS90EYM2hkWoFRwgLMsrRfSki3SxLJtfUlZM,1695
7
+ agent_seo_engine/opportunity.py,sha256=v-RkkLnJRb5XAUiTBsk-mv3d0VOKxKoEuABEuL9pEOU,2236
8
+ agent_seo_engine/quality.py,sha256=ktXY-L6VXBrg6WRxwQv8oZhdeXHiiavxj7mjDllJxYI,4918
9
+ agent_seo_engine-0.1.0.dist-info/METADATA,sha256=6WiuiySsYsAQcwa5tbRVvkL5u2kcVbG1Hhq-v_XEwxY,4919
10
+ agent_seo_engine-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ agent_seo_engine-0.1.0.dist-info/entry_points.txt,sha256=5MgmiYxwWd-M_5KO3yq6x7cszhAW632GgXDLzTZsxw0,112
12
+ agent_seo_engine-0.1.0.dist-info/licenses/LICENSE,sha256=t5uYxHxwOj3qpKJUR7CAiyIhwHwm5SAeDNWuBtbs8yY,1069
13
+ agent_seo_engine-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ agent-seo-engine = agent_seo_engine.cli:main
3
+ agent-seo-mcp = agent_seo_engine.mcp_server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Mosiah
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.