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.
- agent_seo_engine/__init__.py +13 -0
- agent_seo_engine/__main__.py +3 -0
- agent_seo_engine/agent.py +94 -0
- agent_seo_engine/cli.py +68 -0
- agent_seo_engine/intent.py +134 -0
- agent_seo_engine/mcp_server.py +59 -0
- agent_seo_engine/opportunity.py +72 -0
- agent_seo_engine/quality.py +118 -0
- agent_seo_engine-0.1.0.dist-info/METADATA +127 -0
- agent_seo_engine-0.1.0.dist-info/RECORD +13 -0
- agent_seo_engine-0.1.0.dist-info/WHEEL +4 -0
- agent_seo_engine-0.1.0.dist-info/entry_points.txt +3 -0
- agent_seo_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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
|
+
}
|
agent_seo_engine/cli.py
ADDED
|
@@ -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
|
+
[](https://github.com/davidmosiah/agent-seo-engine/stargazers)
|
|
29
|
+
[](https://github.com/davidmosiah/agent-seo-engine/actions/workflows/ci.yml)
|
|
30
|
+
[](LICENSE)
|
|
31
|
+
[](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,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.
|