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.
- package/.env.example +13 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/bin/n-seo.mjs +310 -0
- package/docs/ADDING-A-SITE.md +82 -0
- package/docs/ARCHITECTURE.md +213 -0
- package/docs/DEPLOY.md +300 -0
- package/docs/FAQ.md +93 -0
- package/docs/INSTANCE.md +365 -0
- package/docs/MCP.md +104 -0
- package/docs/OPERATING-RULES.md +106 -0
- package/docs/PLAYBOOK.md +122 -0
- package/docs/PRD.md +249 -0
- package/docs/RELEASING.md +189 -0
- package/docs/SCHEDULING.md +104 -0
- package/docs/SETUP-GOOGLE.md +215 -0
- package/docs/examples/campaign.json +59 -0
- package/docs/examples/draft.md +43 -0
- package/docs/screenshots/overview.png +0 -0
- package/ingest/__pycache__/analyze_ga4.cpython-313.pyc +0 -0
- package/ingest/__pycache__/analyze_gsc.cpython-313.pyc +0 -0
- package/ingest/__pycache__/analyze_metadata.cpython-313.pyc +0 -0
- package/ingest/__pycache__/analyze_trends.cpython-313.pyc +0 -0
- package/ingest/__pycache__/google_auth.cpython-313.pyc +0 -0
- package/ingest/__pycache__/http_util.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_ga4.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_gsc.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_index_status.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_timeseries.cpython-313.pyc +0 -0
- package/ingest/__pycache__/seo_config.cpython-313.pyc +0 -0
- package/ingest/analyze_ga4.py +79 -0
- package/ingest/analyze_gsc.py +136 -0
- package/ingest/analyze_metadata.py +158 -0
- package/ingest/analyze_trends.py +145 -0
- package/ingest/google_auth.py +238 -0
- package/ingest/http_util.py +87 -0
- package/ingest/pull_ga4.py +107 -0
- package/ingest/pull_gsc.py +111 -0
- package/ingest/pull_index_status.py +179 -0
- package/ingest/pull_timeseries.py +130 -0
- package/ingest/seo_config.py +213 -0
- package/n-seo.config.example.json +110 -0
- package/ops/__pycache__/daily.cpython-313.pyc +0 -0
- package/ops/__pycache__/daily_diff.cpython-313.pyc +0 -0
- package/ops/__pycache__/demo_data.cpython-313.pyc +0 -0
- package/ops/__pycache__/doctor.cpython-313.pyc +0 -0
- package/ops/__pycache__/export_static.cpython-313.pyc +0 -0
- package/ops/__pycache__/hn_digest.cpython-313.pyc +0 -0
- package/ops/__pycache__/indexnow.cpython-313.pyc +0 -0
- package/ops/__pycache__/llm.cpython-313.pyc +0 -0
- package/ops/__pycache__/opportunity_scan.cpython-313.pyc +0 -0
- package/ops/__pycache__/publish.cpython-313.pyc +0 -0
- package/ops/__pycache__/reddit_digest.cpython-313.pyc +0 -0
- package/ops/daily.py +250 -0
- package/ops/daily_diff.py +151 -0
- package/ops/demo_data.py +529 -0
- package/ops/doctor.py +266 -0
- package/ops/export_static.py +125 -0
- package/ops/hn_digest.py +169 -0
- package/ops/indexnow.py +107 -0
- package/ops/install-launchd.sh +76 -0
- package/ops/llm.py +139 -0
- package/ops/mcp-smoke-stdio.mjs +61 -0
- package/ops/opportunity_scan.py +185 -0
- package/ops/publish.py +158 -0
- package/ops/reddit_digest.py +168 -0
- package/ops/templates/n-seo-daily.service +11 -0
- package/ops/templates/n-seo-daily.timer +11 -0
- package/ops/templates/n-seo-dashboard.service +15 -0
- package/ops/templates/n-seo.cron +3 -0
- package/ops/templates/n-seo.daily.plist +29 -0
- package/ops/templates/n-seo.dashboard.plist +22 -0
- package/package.json +77 -0
- package/probes/__pycache__/site_probe.cpython-313.pyc +0 -0
- package/probes/site_probe.py +201 -0
- package/public/favicon.svg +6 -0
- package/public/styles.css +632 -0
- package/src/actions.ts +255 -0
- package/src/backlog.ts +197 -0
- package/src/config.ts +220 -0
- package/src/data.ts +895 -0
- package/src/insights.ts +22 -0
- package/src/mcp-stdio.ts +21 -0
- package/src/mcp.ts +490 -0
- package/src/server.tsx +260 -0
- package/src/settings.tsx +329 -0
- package/src/views.tsx +1487 -0
- 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,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,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__" && 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__" && 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
|
+
}
|
|
Binary file
|
|
@@ -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>
|