n-seo 0.1.0

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.
Files changed (88) hide show
  1. package/.env.example +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +184 -0
  4. package/bin/n-seo.mjs +310 -0
  5. package/docs/ADDING-A-SITE.md +82 -0
  6. package/docs/ARCHITECTURE.md +213 -0
  7. package/docs/DEPLOY.md +300 -0
  8. package/docs/FAQ.md +93 -0
  9. package/docs/INSTANCE.md +365 -0
  10. package/docs/MCP.md +104 -0
  11. package/docs/OPERATING-RULES.md +106 -0
  12. package/docs/PLAYBOOK.md +122 -0
  13. package/docs/PRD.md +249 -0
  14. package/docs/RELEASING.md +189 -0
  15. package/docs/SCHEDULING.md +104 -0
  16. package/docs/SETUP-GOOGLE.md +215 -0
  17. package/docs/examples/campaign.json +59 -0
  18. package/docs/examples/draft.md +43 -0
  19. package/docs/screenshots/overview.png +0 -0
  20. package/ingest/__pycache__/analyze_ga4.cpython-313.pyc +0 -0
  21. package/ingest/__pycache__/analyze_gsc.cpython-313.pyc +0 -0
  22. package/ingest/__pycache__/analyze_metadata.cpython-313.pyc +0 -0
  23. package/ingest/__pycache__/analyze_trends.cpython-313.pyc +0 -0
  24. package/ingest/__pycache__/google_auth.cpython-313.pyc +0 -0
  25. package/ingest/__pycache__/http_util.cpython-313.pyc +0 -0
  26. package/ingest/__pycache__/pull_ga4.cpython-313.pyc +0 -0
  27. package/ingest/__pycache__/pull_gsc.cpython-313.pyc +0 -0
  28. package/ingest/__pycache__/pull_index_status.cpython-313.pyc +0 -0
  29. package/ingest/__pycache__/pull_timeseries.cpython-313.pyc +0 -0
  30. package/ingest/__pycache__/seo_config.cpython-313.pyc +0 -0
  31. package/ingest/analyze_ga4.py +79 -0
  32. package/ingest/analyze_gsc.py +136 -0
  33. package/ingest/analyze_metadata.py +158 -0
  34. package/ingest/analyze_trends.py +145 -0
  35. package/ingest/google_auth.py +238 -0
  36. package/ingest/http_util.py +87 -0
  37. package/ingest/pull_ga4.py +107 -0
  38. package/ingest/pull_gsc.py +111 -0
  39. package/ingest/pull_index_status.py +179 -0
  40. package/ingest/pull_timeseries.py +130 -0
  41. package/ingest/seo_config.py +213 -0
  42. package/n-seo.config.example.json +110 -0
  43. package/ops/__pycache__/daily.cpython-313.pyc +0 -0
  44. package/ops/__pycache__/daily_diff.cpython-313.pyc +0 -0
  45. package/ops/__pycache__/demo_data.cpython-313.pyc +0 -0
  46. package/ops/__pycache__/doctor.cpython-313.pyc +0 -0
  47. package/ops/__pycache__/export_static.cpython-313.pyc +0 -0
  48. package/ops/__pycache__/hn_digest.cpython-313.pyc +0 -0
  49. package/ops/__pycache__/indexnow.cpython-313.pyc +0 -0
  50. package/ops/__pycache__/llm.cpython-313.pyc +0 -0
  51. package/ops/__pycache__/opportunity_scan.cpython-313.pyc +0 -0
  52. package/ops/__pycache__/publish.cpython-313.pyc +0 -0
  53. package/ops/__pycache__/reddit_digest.cpython-313.pyc +0 -0
  54. package/ops/daily.py +250 -0
  55. package/ops/daily_diff.py +151 -0
  56. package/ops/demo_data.py +529 -0
  57. package/ops/doctor.py +266 -0
  58. package/ops/export_static.py +125 -0
  59. package/ops/hn_digest.py +169 -0
  60. package/ops/indexnow.py +107 -0
  61. package/ops/install-launchd.sh +76 -0
  62. package/ops/llm.py +139 -0
  63. package/ops/mcp-smoke-stdio.mjs +61 -0
  64. package/ops/opportunity_scan.py +185 -0
  65. package/ops/publish.py +158 -0
  66. package/ops/reddit_digest.py +168 -0
  67. package/ops/templates/n-seo-daily.service +11 -0
  68. package/ops/templates/n-seo-daily.timer +11 -0
  69. package/ops/templates/n-seo-dashboard.service +15 -0
  70. package/ops/templates/n-seo.cron +3 -0
  71. package/ops/templates/n-seo.daily.plist +29 -0
  72. package/ops/templates/n-seo.dashboard.plist +22 -0
  73. package/package.json +77 -0
  74. package/probes/__pycache__/site_probe.cpython-313.pyc +0 -0
  75. package/probes/site_probe.py +201 -0
  76. package/public/favicon.svg +6 -0
  77. package/public/styles.css +632 -0
  78. package/src/actions.ts +255 -0
  79. package/src/backlog.ts +197 -0
  80. package/src/config.ts +220 -0
  81. package/src/data.ts +895 -0
  82. package/src/insights.ts +22 -0
  83. package/src/mcp-stdio.ts +21 -0
  84. package/src/mcp.ts +490 -0
  85. package/src/server.tsx +260 -0
  86. package/src/settings.tsx +329 -0
  87. package/src/views.tsx +1487 -0
  88. package/tsconfig.json +15 -0
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env python3
2
+ """Daily Reddit comment-opportunity digest (modules.reddit).
3
+
4
+ Mirrors ops/hn_digest.py for the subreddits where your audience lives.
5
+ Reddit blocks unauthenticated JSON from most IPs, so this needs a free
6
+ "script" app (https://www.reddit.com/prefs/apps) with REDDIT_CLIENT_ID /
7
+ REDDIT_CLIENT_SECRET in .env. Briefings only — the comments are yours; the
8
+ prompt forbids generating participation text.
9
+
10
+ Config: modules.reddit.topics = [[subreddit, query, why-you], ...],
11
+ modules.reddit.user = your username (marks threads you already joined).
12
+ """
13
+
14
+ import json
15
+ import subprocess
16
+ import sys
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from urllib.parse import quote
20
+
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ingest"))
22
+ import seo_config # noqa: E402
23
+ import llm # noqa: E402
24
+
25
+ UA = "n-seo/0.1 (participation research)"
26
+ MAX_AGE_DAYS = 7
27
+ MAX_COMMENTS = 80 # not already saturated; early threads are prime real estate
28
+ MAX_PICKS = 6
29
+
30
+ _TOKEN = None
31
+
32
+
33
+ def oauth_token():
34
+ """App-only OAuth token; '' when no credentials are configured."""
35
+ global _TOKEN
36
+ if _TOKEN is not None:
37
+ return _TOKEN
38
+ cid, secret = seo_config.env("REDDIT_CLIENT_ID"), seo_config.env("REDDIT_CLIENT_SECRET")
39
+ if not cid or not secret:
40
+ _TOKEN = ""
41
+ return ""
42
+ p = subprocess.run(
43
+ ["curl", "-s", "--max-time", "20", "-A", UA, "-u", f"{cid}:{secret}",
44
+ "-d", "grant_type=client_credentials",
45
+ "https://www.reddit.com/api/v1/access_token"],
46
+ capture_output=True, text=True)
47
+ try:
48
+ _TOKEN = json.loads(p.stdout).get("access_token", "")
49
+ except json.JSONDecodeError:
50
+ _TOKEN = ""
51
+ return _TOKEN
52
+
53
+
54
+ def get_json(url):
55
+ token = oauth_token()
56
+ if token:
57
+ url = url.replace("https://www.reddit.com/", "https://oauth.reddit.com/")
58
+ cmd = ["curl", "-s", "--max-time", "20", "-A", UA,
59
+ "-H", f"Authorization: Bearer {token}", url]
60
+ else:
61
+ cmd = ["curl", "-s", "--max-time", "20", "-A", UA, url]
62
+ p = subprocess.run(cmd, capture_output=True, text=True)
63
+ try:
64
+ return json.loads(p.stdout)
65
+ except json.JSONDecodeError:
66
+ return {}
67
+
68
+
69
+ def search(sub, query):
70
+ url = (f"https://www.reddit.com/r/{sub}/search.json?q={quote(query)}"
71
+ f"&restrict_sr=on&sort=new&t=week&limit=8&raw_json=1")
72
+ children = (get_json(url).get("data") or {}).get("children") or []
73
+ return [c.get("data", {}) for c in children if c.get("kind") == "t3"]
74
+
75
+
76
+ def my_commented_links(user):
77
+ if not user:
78
+ return set()
79
+ url = f"https://www.reddit.com/user/{user}/comments.json?limit=100&raw_json=1"
80
+ children = (get_json(url).get("data") or {}).get("children") or []
81
+ return {c.get("data", {}).get("link_id", "").replace("t3_", "") for c in children}
82
+
83
+
84
+ def briefing(pick, expertise):
85
+ body = (pick.get("selftext") or "")[:3000]
86
+ prompt = (
87
+ f"About the reader (first-hand expertise): {expertise}\n\n"
88
+ f"Reddit thread in r/{pick['sub']}: {pick['title']}\n"
89
+ f"Post body:\n{body or '(link post or empty body)'}\n\n"
90
+ "Write a briefing for the reader in EXACTLY this format, one line each:\n"
91
+ "GIST: <one sentence — what the post is asking or saying>\n"
92
+ "ANGLE: <1-2 sentences — where the reader's genuine first-hand experience connects, and "
93
+ "whether linking one of their own resources would be welcome in this sub or read as "
94
+ "self-promo. If their expertise does not genuinely connect, say 'weak fit — skip "
95
+ "unless personally interested.'>\n"
96
+ "Do NOT write any comment text or suggested wording — briefing only."
97
+ )
98
+ out = llm.infer(prompt, fast=True) or ""
99
+ return out if "GIST:" in out else ""
100
+
101
+
102
+ def main():
103
+ m = seo_config.module("reddit")
104
+ if not m.get("enabled"):
105
+ print("reddit module disabled (modules.reddit.enabled) — skipping")
106
+ return 0
107
+ topics = [t for t in (m.get("topics") or []) if isinstance(t, list) and len(t) >= 2 and t[0]]
108
+ if not topics:
109
+ print("reddit: no topics configured — add [subreddit, query, why] rows in Settings")
110
+ return 0
111
+ expertise = (seo_config.load()["participation"] or {}).get("expertise", "").strip()
112
+ user = (m.get("user") or "").strip()
113
+
114
+ now = datetime.now(timezone.utc).timestamp()
115
+ picks, seen = [], set()
116
+ for sub, query, *rest in topics:
117
+ why = rest[0] if rest else ""
118
+ for d in search(sub, query):
119
+ pid = d.get("id")
120
+ if not pid or pid in seen:
121
+ continue
122
+ age_days = (now - (d.get("created_utc") or now)) / 86400
123
+ if age_days > MAX_AGE_DAYS or (d.get("num_comments") or 0) >= MAX_COMMENTS:
124
+ continue
125
+ seen.add(pid)
126
+ picks.append({
127
+ "id": pid,
128
+ "title": d.get("title", ""),
129
+ "url": f"https://www.reddit.com{d.get('permalink', '')}",
130
+ "sub": d.get("subreddit", sub),
131
+ "comments": d.get("num_comments", 0),
132
+ "score": d.get("score", 0),
133
+ "age_days": round(age_days, 1),
134
+ "why": why,
135
+ "selftext": d.get("selftext", ""),
136
+ })
137
+ # freshest, least-saturated, still-alive threads first
138
+ picks.sort(key=lambda p: (-(p["score"] + 2 * p["comments"]) / (1 + p["age_days"])))
139
+ picks = picks[:MAX_PICKS]
140
+
141
+ commented = my_commented_links(user)
142
+ can_brief = llm.available(fast=True) and bool(expertise)
143
+ if picks and not can_brief:
144
+ print("briefings skipped: " + ("participation.expertise is empty" if not expertise
145
+ else "llm module off"))
146
+ for p in picks:
147
+ p["commented"] = p["id"] in commented
148
+ p["briefing"] = briefing(p, expertise) if (can_brief and not p["commented"]) else ""
149
+ p.pop("selftext", None)
150
+
151
+ seo_config.DATA.mkdir(parents=True, exist_ok=True)
152
+ (seo_config.DATA / "reddit-digest.json").write_text(json.dumps(
153
+ {"generated": datetime.now(timezone.utc).isoformat(timespec="minutes"),
154
+ "user": user or None, "auth": bool(oauth_token()), "picks": picks}, indent=1))
155
+ for p in picks:
156
+ print(f"- r/{p['sub']}: {p['title']} ({p['comments']}c/{p['score']}pts, {p['age_days']}d) {p['url']}")
157
+ if not picks:
158
+ if not oauth_token():
159
+ print("no picks — Reddit blocks unauthenticated JSON from most IPs. One-time fix: "
160
+ "create a 'script' app at https://www.reddit.com/prefs/apps and add "
161
+ "REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET to .env")
162
+ else:
163
+ print("no matching active threads this week")
164
+ return 0
165
+
166
+
167
+ if __name__ == "__main__":
168
+ sys.exit(main())
@@ -0,0 +1,11 @@
1
+ [Unit]
2
+ Description=n-seo daily run (pull, probe, audit, log)
3
+ After=network-online.target
4
+ Wants=network-online.target
5
+
6
+ [Service]
7
+ Type=oneshot
8
+ WorkingDirectory=__REPO__
9
+ ExecStart=/usr/bin/python3 ops/daily.py
10
+ StandardOutput=append:__REPO__/data/daily-systemd.log
11
+ StandardError=append:__REPO__/data/daily-systemd.log
@@ -0,0 +1,11 @@
1
+ [Unit]
2
+ Description=Run n-seo daily at 07:00
3
+
4
+ [Timer]
5
+ OnCalendar=*-*-* 07:00:00
6
+ # Run a missed slot at the next boot instead of skipping the day.
7
+ Persistent=true
8
+ RandomizedDelaySec=120
9
+
10
+ [Install]
11
+ WantedBy=timers.target
@@ -0,0 +1,15 @@
1
+ [Unit]
2
+ Description=n-seo dashboard
3
+ After=network.target
4
+
5
+ [Service]
6
+ WorkingDirectory=__REPO__
7
+ # npx resolves the tsx pinned in package.json; adjust the path if node is not on PATH for user units.
8
+ ExecStart=/usr/bin/env npx tsx src/server.tsx
9
+ Restart=always
10
+ RestartSec=3
11
+ StandardOutput=append:__REPO__/data/dashboard.log
12
+ StandardError=append:__REPO__/data/dashboard.log
13
+
14
+ [Install]
15
+ WantedBy=default.target
@@ -0,0 +1,3 @@
1
+ # n-seo daily run at 07:00 local. Paste into `crontab -e`, fix the path.
2
+ # Cron does not run missed jobs; a machine that is off at 07:00 skips that day.
3
+ 0 7 * * * cd /path/to/n-seo && /usr/bin/python3 ops/daily.py >> data/daily-cron.log 2>&1
@@ -0,0 +1,29 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>n-seo.daily</string>
7
+ <key>ProgramArguments</key>
8
+ <array>
9
+ <string>/bin/sh</string>
10
+ <string>-c</string>
11
+ <string>export PATH="__NODE_BIN__:/usr/local/bin:/usr/bin:/bin"; export HOME="__HOME__"; export USER="${USER:-$(id -un)}"; export TMPDIR="${TMPDIR:-/tmp}"; cd "__REPO__" &amp;&amp; exec python3 ops/daily.py</string>
12
+ </array>
13
+ <!-- 07:00 local. A slot missed while asleep fires on wake; the run then
14
+ waits for the network before it starts. -->
15
+ <key>StartCalendarInterval</key>
16
+ <dict>
17
+ <key>Hour</key>
18
+ <integer>7</integer>
19
+ <key>Minute</key>
20
+ <integer>0</integer>
21
+ </dict>
22
+ <key>RunAtLoad</key>
23
+ <false/>
24
+ <key>StandardOutPath</key>
25
+ <string>__REPO__/data/daily-launchd.log</string>
26
+ <key>StandardErrorPath</key>
27
+ <string>__REPO__/data/daily-launchd.log</string>
28
+ </dict>
29
+ </plist>
@@ -0,0 +1,22 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>n-seo.dashboard</string>
7
+ <key>ProgramArguments</key>
8
+ <array>
9
+ <string>/bin/sh</string>
10
+ <string>-c</string>
11
+ <string>export PATH="__NODE_BIN__:/usr/local/bin:/usr/bin:/bin"; export HOME="__HOME__"; cd "__REPO__" &amp;&amp; exec npx tsx src/server.tsx</string>
12
+ </array>
13
+ <key>RunAtLoad</key>
14
+ <true/>
15
+ <key>KeepAlive</key>
16
+ <true/>
17
+ <key>StandardOutPath</key>
18
+ <string>__REPO__/data/dashboard.log</string>
19
+ <key>StandardErrorPath</key>
20
+ <string>__REPO__/data/dashboard.log</string>
21
+ </dict>
22
+ </plist>
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "n-seo",
3
+ "version": "0.1.0",
4
+ "description": "Local-first SEO / AEO / GEO control plane: pulls Search Console + GA4, probes your sites, and turns the data into a ranked action queue you can act on without an agency.",
5
+ "license": "MIT",
6
+ "author": "En Dash Consulting (https://endash.us)",
7
+ "homepage": "https://en-dash-consulting.github.io/n-seo/",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/en-dash-consulting/n-seo.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/en-dash-consulting/n-seo/issues"
14
+ },
15
+ "keywords": [
16
+ "seo",
17
+ "aeo",
18
+ "geo",
19
+ "search-console",
20
+ "google-analytics",
21
+ "ga4",
22
+ "llms-txt",
23
+ "ai-crawlers",
24
+ "mcp",
25
+ "dashboard",
26
+ "local-first",
27
+ "cli"
28
+ ],
29
+ "type": "module",
30
+ "bin": {
31
+ "n-seo": "bin/n-seo.mjs"
32
+ },
33
+ "files": [
34
+ "bin/",
35
+ "src/",
36
+ "tsconfig.json",
37
+ "ingest/",
38
+ "ops/",
39
+ "probes/",
40
+ "public/",
41
+ "n-seo.config.example.json",
42
+ ".env.example",
43
+ "docs/",
44
+ "README.md",
45
+ "LICENSE"
46
+ ],
47
+ "engines": {
48
+ "node": ">=20"
49
+ },
50
+ "scripts": {
51
+ "dev": "tsx watch src/server.tsx",
52
+ "start": "tsx src/server.tsx",
53
+ "typecheck": "tsc --noEmit",
54
+ "mcp": "tsx src/mcp-stdio.ts",
55
+ "mcp:smoke": "node ops/mcp-smoke-stdio.mjs",
56
+ "doctor": "python3 ops/doctor.py",
57
+ "demo": "python3 ops/demo_data.py",
58
+ "daily": "python3 ops/daily.py",
59
+ "test": "tsx --test tests/ts/*.test.ts",
60
+ "test:py": "python3 -m unittest discover -s tests/py -v",
61
+ "check": "npm run typecheck && npm test && npm run test:py",
62
+ "export": "python3 ops/export_static.py",
63
+ "docker:build": "docker build -t n-seo:latest .",
64
+ "docker:up": "docker compose -f docker/compose.yml up -d"
65
+ },
66
+ "dependencies": {
67
+ "@hono/node-server": "^1.13.7",
68
+ "@modelcontextprotocol/sdk": "^1.30.0",
69
+ "hono": "^4.6.14",
70
+ "tsx": "^4.19.2",
71
+ "zod": "^4.4.3"
72
+ },
73
+ "devDependencies": {
74
+ "@types/node": "^26.4.1",
75
+ "typescript": "^7.0.2"
76
+ }
77
+ }
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env python3
2
+ """No-auth SEO health probe for every configured site.
3
+
4
+ Checks each site's robots.txt (incl. AI-crawler rules), sitemap.xml, llms.txt,
5
+ llms-full.txt, homepage HTML (title, meta description, canonical, OG, JSON-LD,
6
+ server-rendered text) and 404 behaviour. Prints a summary and writes a
7
+ timestamped JSON snapshot to data/probes/ so regressions can be diffed
8
+ day over day — deploys are where SEO quietly dies.
9
+
10
+ Stdlib only — no pip installs needed.
11
+ """
12
+
13
+ import json
14
+ import re
15
+ import sys
16
+ from datetime import datetime, timezone
17
+ from html.parser import HTMLParser
18
+ from pathlib import Path
19
+
20
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ingest"))
21
+ import seo_config # noqa: E402
22
+ from http_util import fetch_text # noqa: E402
23
+
24
+ # gscHost rather than host so a site served on www is probed where it lives.
25
+ SITES = [f"https://{s['gscHost']}" for s in seo_config.sites()]
26
+
27
+ AI_CRAWLERS = [
28
+ "GPTBot", "ClaudeBot", "Claude-Web", "PerplexityBot",
29
+ "Google-Extended", "CCBot", "Bytespider", "OAI-SearchBot",
30
+ ]
31
+
32
+ UA = "Mozilla/5.0 (compatible; n-seo-probe/0.1)"
33
+
34
+
35
+ def fetch(url, timeout=15):
36
+ return fetch_text(url, timeout=timeout, ua=UA)
37
+
38
+
39
+ class MetaParser(HTMLParser):
40
+ def __init__(self):
41
+ super().__init__()
42
+ self.title = ""
43
+ self._in_title = False
44
+ self.meta_description = None
45
+ self.canonical = None
46
+ self.og = {}
47
+ self.jsonld_types = []
48
+ self._in_jsonld = False
49
+ self.h1s = []
50
+ self._in_h1 = False
51
+ self.lang = None
52
+
53
+ def handle_starttag(self, tag, attrs):
54
+ a = dict(attrs)
55
+ if tag == "html":
56
+ self.lang = a.get("lang")
57
+ elif tag == "title":
58
+ self._in_title = True
59
+ elif tag == "h1":
60
+ self._in_h1 = True
61
+ self.h1s.append("")
62
+ elif tag == "meta":
63
+ if a.get("name") == "description":
64
+ self.meta_description = a.get("content")
65
+ prop = a.get("property", "")
66
+ if prop.startswith("og:"):
67
+ self.og[prop] = a.get("content")
68
+ elif tag == "link" and a.get("rel") == "canonical":
69
+ self.canonical = a.get("href")
70
+ elif tag == "script" and a.get("type") == "application/ld+json":
71
+ self._in_jsonld = True
72
+
73
+ def handle_endtag(self, tag):
74
+ if tag == "title":
75
+ self._in_title = False
76
+ elif tag == "h1":
77
+ self._in_h1 = False
78
+ elif tag == "script":
79
+ self._in_jsonld = False
80
+
81
+ def handle_data(self, data):
82
+ if self._in_title:
83
+ self.title += data
84
+ if self._in_h1 and self.h1s:
85
+ self.h1s[-1] += data
86
+ if self._in_jsonld:
87
+ try:
88
+ d = json.loads(data)
89
+ items = d if isinstance(d, list) else [d]
90
+ for item in items:
91
+ t = item.get("@type")
92
+ if t:
93
+ self.jsonld_types.append(t)
94
+ except (json.JSONDecodeError, AttributeError):
95
+ pass
96
+
97
+
98
+ def visible_text_bytes(html):
99
+ """Rough server-rendering signal: bytes of visible text once tags and
100
+ scripts are stripped. A JS shell scores near zero — and AI crawlers
101
+ don't run JS."""
102
+ stripped = re.sub(r"<(script|style|noscript)[^>]*>.*?</\1>", " ", html,
103
+ flags=re.DOTALL | re.IGNORECASE)
104
+ stripped = re.sub(r"<[^>]+>", " ", stripped)
105
+ return len(re.sub(r"\s+", " ", stripped).strip())
106
+
107
+
108
+ def probe_site(base):
109
+ result = {"site": base}
110
+
111
+ status, body = fetch(base + "/robots.txt")
112
+ robots = {"status": status, "exists": status == 200}
113
+ if status == 200:
114
+ robots["sitemap_declared"] = "sitemap:" in body.lower()
115
+ blocked = []
116
+ for bot in AI_CRAWLERS:
117
+ m = re.search(rf"user-agent:\s*{re.escape(bot)}\s*\n(?:(?!user-agent:)[^\n]*\n)*?\s*disallow:\s*/\s*$",
118
+ body, re.IGNORECASE | re.MULTILINE)
119
+ if m:
120
+ blocked.append(bot)
121
+ robots["ai_crawlers_blocked"] = blocked
122
+ result["robots"] = robots
123
+
124
+ status, body = fetch(base + "/sitemap.xml")
125
+ sitemap = {"status": status,
126
+ "exists": bool(status == 200 and ("<urlset" in body or "<sitemapindex" in body))}
127
+ if status == 200:
128
+ sitemap["url_count"] = body.count("<loc>")
129
+ lastmods = re.findall(r"<lastmod>([^<]+)</lastmod>", body)
130
+ sitemap["newest_lastmod"] = max(lastmods) if lastmods else None
131
+ result["sitemap"] = sitemap
132
+
133
+ for f in ("llms.txt", "llms-full.txt"):
134
+ status, body = fetch(f"{base}/{f}")
135
+ ok = status == 200 and not body.lstrip().lower().startswith("<!doctype")
136
+ result[f] = {"status": status, "exists": ok, "bytes": len(body) if ok else 0}
137
+
138
+ status, body = fetch(base + "/")
139
+ home = {"status": status}
140
+ if status == 200:
141
+ p = MetaParser()
142
+ try:
143
+ p.feed(body)
144
+ except Exception:
145
+ pass
146
+ home.update({
147
+ "title": p.title.strip()[:120],
148
+ "meta_description": (p.meta_description or "")[:200] or None,
149
+ "canonical": p.canonical,
150
+ "og_tags": len(p.og),
151
+ "jsonld_types": p.jsonld_types,
152
+ "h1_count": len(p.h1s),
153
+ "lang": p.lang,
154
+ "visible_text_bytes": visible_text_bytes(body),
155
+ })
156
+ result["homepage"] = home
157
+
158
+ status, _ = fetch(base + "/definitely-not-a-real-page-9x7q")
159
+ result["soft_404"] = {"status": status, "real_404": status == 404}
160
+
161
+ return result
162
+
163
+
164
+ def main():
165
+ if not SITES:
166
+ print("no sites configured — nothing to probe")
167
+ return 0
168
+ snapshot = {
169
+ "probed_at": datetime.now(timezone.utc).isoformat(),
170
+ "sites": [probe_site(s) for s in SITES],
171
+ }
172
+
173
+ out_dir = seo_config.DATA / "probes"
174
+ out_dir.mkdir(parents=True, exist_ok=True)
175
+ out = out_dir / f"probe-{datetime.now(timezone.utc):%Y%m%d-%H%M%S}.json"
176
+ out.write_text(json.dumps(snapshot, indent=2))
177
+
178
+ for s in snapshot["sites"]:
179
+ h = s["homepage"]
180
+ print(f"\n{s['site']}")
181
+ print(f" robots.txt: {'OK' if s['robots']['exists'] else 'MISSING'}"
182
+ + (f" (blocks AI: {s['robots']['ai_crawlers_blocked']})"
183
+ if s['robots'].get('ai_crawlers_blocked') else ""))
184
+ print(f" sitemap.xml: {'OK, %s URLs' % s['sitemap'].get('url_count') if s['sitemap'].get('exists') else 'MISSING'}")
185
+ print(f" llms.txt: {'OK' if s['llms.txt']['exists'] else 'missing'}"
186
+ f" | llms-full.txt: {'OK' if s['llms-full.txt']['exists'] else 'missing'}")
187
+ if h.get("status") == 200:
188
+ print(f" title: {h.get('title') or 'MISSING'}")
189
+ print(f" meta description: {'OK' if h.get('meta_description') else 'MISSING'}"
190
+ f" | canonical: {'OK' if h.get('canonical') else 'MISSING'}"
191
+ f" | JSON-LD: {h.get('jsonld_types') or 'none'}")
192
+ print(f" visible text: {h.get('visible_text_bytes')} bytes"
193
+ f" ({'likely JS shell' if h.get('visible_text_bytes', 0) < 500 else 'server-rendered content'})")
194
+ print(f" 404 handling: {'real 404' if s['soft_404']['real_404'] else 'SOFT 404 (status %s)' % s['soft_404']['status']}")
195
+
196
+ print(f"\nSnapshot written: {out}")
197
+ return 0
198
+
199
+
200
+ if __name__ == "__main__":
201
+ sys.exit(main())
@@ -0,0 +1,6 @@
1
+ <svg viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M300 0H0V300H300V0Z" fill="#00E5B9"/>
3
+ <path d="M300 239.902H32.0074V255.489H300V239.902Z" fill="#00FFCE"/>
4
+ <path d="M55.7684 202.351L118.59 139.53L160.47 181.41L244.232 97.6489" stroke="#001769" stroke-width="27.4428" stroke-linecap="round" stroke-linejoin="round"/>
5
+ <path d="M181.411 97.6489H244.232V160.47" stroke="#001769" stroke-width="27.4428" stroke-linecap="round" stroke-linejoin="round"/>
6
+ </svg>