n-seo 0.1.1 → 0.3.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/.claude/skills/n-seo-add-site/SKILL.md +125 -0
- package/.claude/skills/n-seo-deploy/SKILL.md +171 -0
- package/.claude/skills/n-seo-review/SKILL.md +106 -0
- package/.claude/skills/n-seo-setup/SKILL.md +139 -0
- package/.claude/skills/n-seo-ship/SKILL.md +122 -0
- package/.claude/skills/n-seo-triage/SKILL.md +90 -0
- package/.claude/skills/orient/SKILL.md +54 -0
- package/README.md +190 -49
- package/bin/n-seo.mjs +177 -10
- package/docs/ADDING-A-SITE.md +4 -0
- package/docs/DEPLOY.md +4 -0
- package/docs/FAQ.md +46 -6
- package/docs/INSTANCE.md +54 -0
- package/docs/MCP.md +25 -9
- package/docs/PRD.md +29 -9
- package/docs/SCHEDULING.md +46 -5
- package/docs/SETUP-GOOGLE.md +8 -1
- package/ingest/__pycache__/analyze_metadata.cpython-312.pyc +0 -0
- package/ingest/__pycache__/google_auth.cpython-312.pyc +0 -0
- package/ingest/__pycache__/http_util.cpython-312.pyc +0 -0
- package/ingest/__pycache__/seo_config.cpython-312.pyc +0 -0
- package/ingest/analyze_ga4.py +2 -2
- package/ingest/analyze_gsc.py +2 -2
- package/ingest/analyze_metadata.py +2 -2
- package/ingest/analyze_trends.py +2 -2
- package/ingest/google_auth.py +53 -20
- package/ingest/pull_ga4.py +1 -1
- package/ingest/pull_gsc.py +1 -1
- package/ingest/pull_index_status.py +1 -1
- package/ingest/pull_timeseries.py +3 -3
- package/ingest/seo_config.py +19 -3
- package/ops/__pycache__/daily.cpython-312.pyc +0 -0
- package/ops/__pycache__/daily_diff.cpython-312.pyc +0 -0
- package/ops/__pycache__/demo_data.cpython-312.pyc +0 -0
- package/ops/__pycache__/export_static.cpython-312.pyc +0 -0
- package/ops/__pycache__/llm.cpython-312.pyc +0 -0
- package/ops/__pycache__/publish.cpython-312.pyc +0 -0
- package/ops/daily.py +2 -2
- package/ops/daily_diff.py +9 -8
- package/ops/demo_data.py +23 -17
- package/ops/doctor.py +30 -7
- package/ops/export_static.py +10 -5
- package/ops/hn_digest.py +1 -1
- package/ops/indexnow.py +3 -3
- package/ops/llm.py +23 -1
- package/ops/opportunity_scan.py +4 -4
- package/ops/py.mjs +40 -0
- package/ops/reddit_digest.py +1 -1
- package/ops/templates/n-seo-daily-task.xml +65 -0
- package/package.json +24 -9
- package/probes/__pycache__/site_probe.cpython-312.pyc +0 -0
- package/probes/site_probe.py +1 -1
- package/public/fonts/OFL.txt +105 -0
- package/public/fonts/montserrat-latin-var.woff2 +0 -0
- package/public/styles.css +18 -1
- package/src/actions.ts +1 -1
- package/src/config.ts +1 -1
- package/src/mcp.ts +2 -2
- package/src/server.tsx +22 -1
- package/src/settings.tsx +3 -3
- package/src/views.tsx +79 -15
package/ops/doctor.py
CHANGED
|
@@ -64,10 +64,19 @@ def check_config():
|
|
|
64
64
|
|
|
65
65
|
def check_tools():
|
|
66
66
|
print("tools")
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
report("OK" if shutil.which("curl") else "FAIL", "curl",
|
|
68
|
+
"" if shutil.which("curl") else "install it — all HTTP goes through curl "
|
|
69
|
+
"(Windows 10+ ships it in System32)")
|
|
70
|
+
# node signs the service-account JWT, so it is required even for someone
|
|
71
|
+
# who never opens the dashboard.
|
|
72
|
+
import google_auth
|
|
73
|
+
node = google_auth.node_bin()
|
|
74
|
+
report("OK" if shutil.which(node) else "FAIL", f"node ({node})",
|
|
75
|
+
"" if shutil.which(node) else "install Node 20+ — it runs the dashboard "
|
|
76
|
+
"and signs the service-account JWT; $NODE overrides the path")
|
|
69
77
|
v = sys.version_info
|
|
70
|
-
report("OK" if v >= (3, 10) else "FAIL",
|
|
78
|
+
report("OK" if v >= (3, 10) else "FAIL",
|
|
79
|
+
f"python {v.major}.{v.minor} ({Path(sys.executable).name})",
|
|
71
80
|
"" if v >= (3, 10) else "3.10+ required")
|
|
72
81
|
nm = seo_config.ROOT / "node_modules"
|
|
73
82
|
report("OK" if nm.exists() else "WARN", "node_modules", "" if nm.exists() else "run: npm install")
|
|
@@ -86,7 +95,7 @@ def check_auth(cfg):
|
|
|
86
95
|
f"{kp or '(unset)'} — set google.serviceAccountKey or $GOOGLE_APPLICATION_CREDENTIALS; see docs/SETUP-GOOGLE.md")
|
|
87
96
|
return None
|
|
88
97
|
try:
|
|
89
|
-
key = json.loads(Path(kp).read_text())
|
|
98
|
+
key = json.loads(Path(kp).read_text(encoding="utf-8"))
|
|
90
99
|
except (OSError, json.JSONDecodeError) as exc:
|
|
91
100
|
report("FAIL", "key file unreadable", str(exc))
|
|
92
101
|
return None
|
|
@@ -197,7 +206,6 @@ def check_modules(cfg):
|
|
|
197
206
|
report("OK", "enabled: " + (", ".join(on) or "(none beyond defaults)"))
|
|
198
207
|
llm = cfg["modules"].get("llm", {})
|
|
199
208
|
if llm.get("enabled"):
|
|
200
|
-
import shlex
|
|
201
209
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
202
210
|
import llm as llm_mod
|
|
203
211
|
http = llm_mod.http_config()
|
|
@@ -211,11 +219,17 @@ def check_modules(cfg):
|
|
|
211
219
|
f"check provider/model and that {llm['http'].get('apiKeyEnv') or 'apiKeyEnv'} "
|
|
212
220
|
"is set in the environment or .env")
|
|
213
221
|
for key in ("command", "fastCommand"):
|
|
214
|
-
|
|
222
|
+
raw = str(llm.get(key) or "").strip()
|
|
223
|
+
cmd = llm_mod.split_command(raw)
|
|
215
224
|
if not cmd:
|
|
225
|
+
if raw:
|
|
226
|
+
# Configured but unparseable — almost always an unbalanced
|
|
227
|
+
# quote. Say so, or the next line reads as "not configured".
|
|
228
|
+
report("FAIL", f"llm.{key} could not be parsed: {raw[:60]}",
|
|
229
|
+
"check for an unbalanced quote")
|
|
216
230
|
# Only complain about a missing command when there is no http
|
|
217
231
|
# block at all; a broken one already reported itself.
|
|
218
|
-
|
|
232
|
+
elif key == "command" and not http and not llm.get("http"):
|
|
219
233
|
report("FAIL", "llm has neither http nor command configured")
|
|
220
234
|
continue
|
|
221
235
|
report("OK" if shutil.which(cmd[0]) else "FAIL", f"llm.{key}: {cmd[0]}",
|
|
@@ -259,6 +273,15 @@ def main():
|
|
|
259
273
|
check_dashboard()
|
|
260
274
|
check_modules(cfg)
|
|
261
275
|
print(f"\n{'all good' if not FAILS else f'{FAILS} problem(s) to fix'}")
|
|
276
|
+
# Most people should not work through the setup docs by hand. If the
|
|
277
|
+
# skills are installed, say so exactly when it is useful: after a report
|
|
278
|
+
# that lists things to fix.
|
|
279
|
+
if FAILS and (seo_config.INSTANCE / ".claude" / "skills" / "n-seo-setup" / "SKILL.md").exists():
|
|
280
|
+
print(
|
|
281
|
+
"\nnot sure what to do with these? this instance ships the fixes as skills.\n"
|
|
282
|
+
f" cd {seo_config.INSTANCE} && claude\n"
|
|
283
|
+
' "set this up for my sites" → /n-seo-setup works through every item above'
|
|
284
|
+
)
|
|
262
285
|
return 1 if FAILS else 0
|
|
263
286
|
|
|
264
287
|
|
package/ops/export_static.py
CHANGED
|
@@ -103,11 +103,16 @@ def main():
|
|
|
103
103
|
html = html.replace("</body>", staleness + "</body>", 1)
|
|
104
104
|
dest = out / "index.html" if route == "/" else out / route.lstrip("/") / "index.html"
|
|
105
105
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
-
dest.write_text(html)
|
|
107
|
-
|
|
108
|
-
(out / "styles.css").write_text(fetch("/styles.css"))
|
|
109
|
-
|
|
110
|
-
|
|
106
|
+
dest.write_text(html, encoding="utf-8")
|
|
107
|
+
|
|
108
|
+
(out / "styles.css").write_text(fetch("/styles.css"), encoding="utf-8")
|
|
109
|
+
# styles.css declares @font-face against /fonts/, so the file has to
|
|
110
|
+
# travel with it or every heading in the mirror falls back silently.
|
|
111
|
+
fonts = ROOT / "public" / "fonts"
|
|
112
|
+
if fonts.is_dir():
|
|
113
|
+
shutil.copytree(fonts, out / "fonts")
|
|
114
|
+
(out / "favicon.svg").write_text(fetch("/favicon.svg"), encoding="utf-8")
|
|
115
|
+
(out / "robots.txt").write_text("User-agent: *\nDisallow: /\n", encoding="utf-8")
|
|
111
116
|
except BaseException:
|
|
112
117
|
# Never leave a half-built staging directory behind; the next
|
|
113
118
|
# run would otherwise start from someone else's leftovers.
|
package/ops/hn_digest.py
CHANGED
|
@@ -157,7 +157,7 @@ def main():
|
|
|
157
157
|
seo_config.DATA.mkdir(parents=True, exist_ok=True)
|
|
158
158
|
(seo_config.DATA / "hn-digest.json").write_text(json.dumps(
|
|
159
159
|
{"generated": datetime.now(timezone.utc).isoformat(timespec="minutes"),
|
|
160
|
-
"stats": stats, "picks": picks}, indent=1))
|
|
160
|
+
"stats": stats, "picks": picks}, indent=1), encoding="utf-8")
|
|
161
161
|
for p in picks:
|
|
162
162
|
print(f"- {p['title']} ({p['comments']}c/{p['points']}p) {p['url']} [{p['why']}]")
|
|
163
163
|
if not picks:
|
package/ops/indexnow.py
CHANGED
|
@@ -33,12 +33,12 @@ def key_file() -> Path:
|
|
|
33
33
|
def cmd_init() -> int:
|
|
34
34
|
kf = key_file()
|
|
35
35
|
if kf.exists():
|
|
36
|
-
key = kf.read_text().strip()
|
|
36
|
+
key = kf.read_text(encoding="utf-8").strip()
|
|
37
37
|
print(f"key file already exists: {kf}")
|
|
38
38
|
else:
|
|
39
39
|
key = secrets.token_hex(16)
|
|
40
40
|
kf.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
-
kf.write_text(key + "\n")
|
|
41
|
+
kf.write_text(key + "\n", encoding="utf-8")
|
|
42
42
|
print(f"wrote {kf}")
|
|
43
43
|
print(f"\nkey: {key}\n\nServe this key as plain text at, for every site:")
|
|
44
44
|
for s in seo_config.sites():
|
|
@@ -52,7 +52,7 @@ def cmd_ping(urls: list[str]) -> int:
|
|
|
52
52
|
if not kf.exists():
|
|
53
53
|
print(f"no key file at {kf} — run: python3 ops/indexnow.py init")
|
|
54
54
|
return 1
|
|
55
|
-
key = kf.read_text().strip()
|
|
55
|
+
key = kf.read_text(encoding="utf-8").strip()
|
|
56
56
|
by_host: dict[str, list[str]] = {}
|
|
57
57
|
for u in urls:
|
|
58
58
|
host = urlsplit(u).netloc
|
package/ops/llm.py
CHANGED
|
@@ -28,6 +28,7 @@ path runs. Nothing that comes back is applied automatically: callers turn
|
|
|
28
28
|
replies into briefings or proposals a human accepts or ignores.
|
|
29
29
|
"""
|
|
30
30
|
import json
|
|
31
|
+
import os
|
|
31
32
|
import shlex
|
|
32
33
|
import shutil
|
|
33
34
|
import subprocess
|
|
@@ -42,6 +43,27 @@ ANTHROPIC_VERSION = "2023-06-01"
|
|
|
42
43
|
MAX_TOKENS = 4096
|
|
43
44
|
|
|
44
45
|
|
|
46
|
+
def split_command(cmd: str) -> list[str]:
|
|
47
|
+
"""Split a configured command string into argv, per platform.
|
|
48
|
+
|
|
49
|
+
POSIX rules treat a backslash as an escape, so on Windows they quietly
|
|
50
|
+
turn `C:\\tools\\claude.exe` into `C:toolsclaude.exe`. There, split with
|
|
51
|
+
the non-POSIX rules cmd.exe uses and strip the quotes shlex leaves on.
|
|
52
|
+
|
|
53
|
+
Returns [] for anything unparseable. This string comes from the Settings
|
|
54
|
+
form, so a stray quote is a typo a user can make, and it must not take
|
|
55
|
+
the daily run down with a ValueError from inside shlex — callers already
|
|
56
|
+
treat [] as "no command configured". doctor tells them which it was.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
if os.name != "nt":
|
|
60
|
+
return shlex.split(cmd)
|
|
61
|
+
parts = shlex.split(cmd, posix=False)
|
|
62
|
+
except ValueError:
|
|
63
|
+
return []
|
|
64
|
+
return [p[1:-1] if len(p) > 1 and p[0] == p[-1] == '"' else p for p in parts]
|
|
65
|
+
|
|
66
|
+
|
|
45
67
|
def _enabled() -> dict | None:
|
|
46
68
|
m = seo_config.module("llm")
|
|
47
69
|
return m if m.get("enabled") else None
|
|
@@ -71,7 +93,7 @@ def command(fast: bool = False) -> list[str] | None:
|
|
|
71
93
|
if not m:
|
|
72
94
|
return None
|
|
73
95
|
cmd = (m.get("fastCommand") if fast else None) or m.get("command") or ""
|
|
74
|
-
parts =
|
|
96
|
+
parts = split_command(str(cmd))
|
|
75
97
|
if not parts or not shutil.which(parts[0]):
|
|
76
98
|
return None
|
|
77
99
|
return parts
|
package/ops/opportunity_scan.py
CHANGED
|
@@ -36,7 +36,7 @@ RISE_MIN_RATIO = 2.5 # and have grown at least this much vs prior 84d
|
|
|
36
36
|
|
|
37
37
|
def latest_trends():
|
|
38
38
|
files = sorted(DATA.glob("trends-*.json"))
|
|
39
|
-
return json.loads(files[-1].read_text()) if files else None
|
|
39
|
+
return json.loads(files[-1].read_text(encoding="utf-8")) if files else None
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
def queue():
|
|
@@ -51,7 +51,7 @@ def queue():
|
|
|
51
51
|
except json.JSONDecodeError:
|
|
52
52
|
pass
|
|
53
53
|
try:
|
|
54
|
-
return json.loads((seo_config.INSTANCE / "config" / "backlog.json").read_text()).get("actions", [])
|
|
54
|
+
return json.loads((seo_config.INSTANCE / "config" / "backlog.json").read_text(encoding="utf-8")).get("actions", [])
|
|
55
55
|
except (OSError, json.JSONDecodeError):
|
|
56
56
|
return []
|
|
57
57
|
|
|
@@ -84,7 +84,7 @@ def _host_resolver(prop):
|
|
|
84
84
|
best = {}
|
|
85
85
|
qp = seo_config.DATA / "gsc" / seo_config.gsc_data_slug(prop) / "query_page_90d.json"
|
|
86
86
|
try:
|
|
87
|
-
for r in json.loads(qp.read_text()).get("rows", []):
|
|
87
|
+
for r in json.loads(qp.read_text(encoding="utf-8")).get("rows", []):
|
|
88
88
|
q, page = r["keys"][0], r["keys"][1]
|
|
89
89
|
h = page.split("/")[2] if page.count("/") >= 2 else ""
|
|
90
90
|
if h in by_gsc_host and r["impressions"] > best.get(q, (0, ""))[0]:
|
|
@@ -175,7 +175,7 @@ def main():
|
|
|
175
175
|
print("llm module off — candidates recorded without proposals")
|
|
176
176
|
|
|
177
177
|
DATA.mkdir(parents=True, exist_ok=True)
|
|
178
|
-
(DATA / "opportunity-proposals.json").write_text(json.dumps(out, indent=1))
|
|
178
|
+
(DATA / "opportunity-proposals.json").write_text(json.dumps(out, indent=1), encoding="utf-8")
|
|
179
179
|
print(f"candidates: {len(candidates)} | proposals: {len(out['proposals'])} | "
|
|
180
180
|
f"verdicts: {len(out['verdicts'])} | inference: {out['inference_ran']}")
|
|
181
181
|
return 0
|
package/ops/py.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* Run a Python script with whichever interpreter this machine actually has.
|
|
3
|
+
*
|
|
4
|
+
* The npm scripts used to hardcode `python3`, which does not exist on
|
|
5
|
+
* Windows: Python installs there as `python`, or only behind the `py`
|
|
6
|
+
* launcher. Worse, Windows ships an App Execution Alias at `python3.exe`
|
|
7
|
+
* that opens the Microsoft Store instead of running anything, so a hardcoded
|
|
8
|
+
* `python3` fails in a way that looks like Python is missing when it is not.
|
|
9
|
+
* Probing for one that answers `--version` is the only reliable answer.
|
|
10
|
+
*
|
|
11
|
+
* node ops/py.mjs ops/doctor.py --offline
|
|
12
|
+
*
|
|
13
|
+
* $PYTHON overrides, which is also how the n-seo CLI behaves.
|
|
14
|
+
*/
|
|
15
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
16
|
+
import { pathToFileURL } from "node:url";
|
|
17
|
+
|
|
18
|
+
const CANDIDATES = process.platform === "win32"
|
|
19
|
+
? ["python", "python3", "py"]
|
|
20
|
+
: ["python3", "python"];
|
|
21
|
+
|
|
22
|
+
export function pythonBin() {
|
|
23
|
+
if (process.env.PYTHON) return process.env.PYTHON;
|
|
24
|
+
for (const c of CANDIDATES) {
|
|
25
|
+
const probe = spawnSync(c, ["--version"], { stdio: "ignore" });
|
|
26
|
+
if (!probe.error && probe.status === 0) return c;
|
|
27
|
+
}
|
|
28
|
+
return CANDIDATES[0]; // let the real command fail with a useful message
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Only act as a launcher when invoked directly, not when imported.
|
|
32
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
33
|
+
const child = spawn(pythonBin(), process.argv.slice(2), { stdio: "inherit" });
|
|
34
|
+
child.on("error", (err) => {
|
|
35
|
+
console.error(`could not start ${pythonBin()}: ${err.message}`);
|
|
36
|
+
console.error("install Python 3.10+, or set $PYTHON to its path");
|
|
37
|
+
process.exit(127);
|
|
38
|
+
});
|
|
39
|
+
child.on("exit", (code, signal) => process.exit(signal ? 1 : code ?? 1));
|
|
40
|
+
}
|
package/ops/reddit_digest.py
CHANGED
|
@@ -151,7 +151,7 @@ def main():
|
|
|
151
151
|
seo_config.DATA.mkdir(parents=True, exist_ok=True)
|
|
152
152
|
(seo_config.DATA / "reddit-digest.json").write_text(json.dumps(
|
|
153
153
|
{"generated": datetime.now(timezone.utc).isoformat(timespec="minutes"),
|
|
154
|
-
"user": user or None, "auth": bool(oauth_token()), "picks": picks}, indent=1))
|
|
154
|
+
"user": user or None, "auth": bool(oauth_token()), "picks": picks}, indent=1), encoding="utf-8")
|
|
155
155
|
for p in picks:
|
|
156
156
|
print(f"- r/{p['sub']}: {p['title']} ({p['comments']}c/{p['score']}pts, {p['age_days']}d) {p['url']}")
|
|
157
157
|
if not picks:
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-16"?>
|
|
2
|
+
<!--
|
|
3
|
+
n-seo daily run at 07:00 local, for Windows Task Scheduler.
|
|
4
|
+
|
|
5
|
+
Register it (from an elevated or normal prompt, your choice — this is a
|
|
6
|
+
per-user task):
|
|
7
|
+
|
|
8
|
+
schtasks /create /tn "n-seo daily" /xml ops\templates\n-seo-daily-task.xml
|
|
9
|
+
|
|
10
|
+
Before you do, replace the two placeholders below:
|
|
11
|
+
__NSEO__ the n-seo command, e.g. C:\Users\you\AppData\Roaming\npm\n-seo.cmd
|
|
12
|
+
(find it with: where n-seo)
|
|
13
|
+
__INSTANCE__ your instance directory, e.g. C:\Users\you\my-sites
|
|
14
|
+
|
|
15
|
+
StartWhenAvailable is the Windows equivalent of launchd's catch-up
|
|
16
|
+
behaviour: a machine that was asleep at 07:00 runs the job when it wakes,
|
|
17
|
+
rather than skipping the day the way cron does.
|
|
18
|
+
-->
|
|
19
|
+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
20
|
+
<RegistrationInfo>
|
|
21
|
+
<Description>n-seo daily run: pulls Search Console and GA4, probes the sites, refreshes the queue.</Description>
|
|
22
|
+
<URI>\n-seo daily</URI>
|
|
23
|
+
</RegistrationInfo>
|
|
24
|
+
<Triggers>
|
|
25
|
+
<CalendarTrigger>
|
|
26
|
+
<StartBoundary>2026-01-01T07:00:00</StartBoundary>
|
|
27
|
+
<Enabled>true</Enabled>
|
|
28
|
+
<ScheduleByDay>
|
|
29
|
+
<DaysInterval>1</DaysInterval>
|
|
30
|
+
</ScheduleByDay>
|
|
31
|
+
</CalendarTrigger>
|
|
32
|
+
</Triggers>
|
|
33
|
+
<Principals>
|
|
34
|
+
<Principal id="Author">
|
|
35
|
+
<LogonType>InteractiveToken</LogonType>
|
|
36
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
37
|
+
</Principal>
|
|
38
|
+
</Principals>
|
|
39
|
+
<Settings>
|
|
40
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
41
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
42
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
43
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
44
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
45
|
+
<RunOnlyIfNetworkAvailable>true</RunOnlyIfNetworkAvailable>
|
|
46
|
+
<IdleSettings>
|
|
47
|
+
<StopOnIdleEnd>false</StopOnIdleEnd>
|
|
48
|
+
<RestartOnIdle>false</RestartOnIdle>
|
|
49
|
+
</IdleSettings>
|
|
50
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
51
|
+
<Enabled>true</Enabled>
|
|
52
|
+
<Hidden>false</Hidden>
|
|
53
|
+
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
|
54
|
+
<WakeToRun>false</WakeToRun>
|
|
55
|
+
<ExecutionTimeLimit>PT2H</ExecutionTimeLimit>
|
|
56
|
+
<Priority>7</Priority>
|
|
57
|
+
</Settings>
|
|
58
|
+
<Actions Context="Author">
|
|
59
|
+
<Exec>
|
|
60
|
+
<Command>__NSEO__</Command>
|
|
61
|
+
<Arguments>daily --instance "__INSTANCE__"</Arguments>
|
|
62
|
+
<WorkingDirectory>__INSTANCE__</WorkingDirectory>
|
|
63
|
+
</Exec>
|
|
64
|
+
</Actions>
|
|
65
|
+
</Task>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "n-seo",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Agentic, local-first SEO / AEO / GEO control plane: pulls Search Console + GA4, probes your sites, and ranks the next moves. Your LLM writes the proposals; your coding agent works the queue over MCP.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "En Dash Consulting (https://endash.us)",
|
|
7
7
|
"homepage": "https://n-seo.dev/",
|
|
@@ -16,12 +16,19 @@
|
|
|
16
16
|
"seo",
|
|
17
17
|
"aeo",
|
|
18
18
|
"geo",
|
|
19
|
+
"agent",
|
|
20
|
+
"ai-agent",
|
|
21
|
+
"agentic",
|
|
22
|
+
"llm",
|
|
23
|
+
"claude",
|
|
24
|
+
"claude-code",
|
|
25
|
+
"mcp",
|
|
26
|
+
"mcp-server",
|
|
19
27
|
"search-console",
|
|
20
28
|
"google-analytics",
|
|
21
29
|
"ga4",
|
|
22
30
|
"llms-txt",
|
|
23
31
|
"ai-crawlers",
|
|
24
|
-
"mcp",
|
|
25
32
|
"dashboard",
|
|
26
33
|
"local-first",
|
|
27
34
|
"cli"
|
|
@@ -40,6 +47,14 @@
|
|
|
40
47
|
"public/",
|
|
41
48
|
"n-seo.config.example.json",
|
|
42
49
|
".env.example",
|
|
50
|
+
".claude/skills/orient/",
|
|
51
|
+
".claude/skills/n-seo-setup/",
|
|
52
|
+
".claude/skills/n-seo-add-site/",
|
|
53
|
+
".claude/skills/n-seo-triage/",
|
|
54
|
+
".claude/skills/n-seo-ship/",
|
|
55
|
+
".claude/skills/n-seo-review/",
|
|
56
|
+
".claude/skills/n-seo-deploy/",
|
|
57
|
+
"!.claude/skills/README.md",
|
|
43
58
|
"docs/",
|
|
44
59
|
"README.md",
|
|
45
60
|
"LICENSE"
|
|
@@ -53,18 +68,18 @@
|
|
|
53
68
|
"typecheck": "tsc --noEmit",
|
|
54
69
|
"mcp": "tsx src/mcp-stdio.ts",
|
|
55
70
|
"mcp:smoke": "node ops/mcp-smoke-stdio.mjs",
|
|
56
|
-
"doctor": "
|
|
57
|
-
"demo": "
|
|
58
|
-
"daily": "
|
|
71
|
+
"doctor": "node ops/py.mjs ops/doctor.py",
|
|
72
|
+
"demo": "node ops/py.mjs ops/demo_data.py",
|
|
73
|
+
"daily": "node ops/py.mjs ops/daily.py",
|
|
59
74
|
"test": "tsx --test tests/ts/*.test.ts",
|
|
60
|
-
"test:py": "
|
|
75
|
+
"test:py": "node ops/py.mjs -m unittest discover -s tests/py -v",
|
|
61
76
|
"check": "npm run typecheck && npm test && npm run test:py",
|
|
62
|
-
"export": "
|
|
77
|
+
"export": "node ops/py.mjs ops/export_static.py",
|
|
63
78
|
"docker:build": "docker build -t n-seo:latest .",
|
|
64
79
|
"docker:up": "docker compose -f docker/compose.yml up -d"
|
|
65
80
|
},
|
|
66
81
|
"dependencies": {
|
|
67
|
-
"@hono/node-server": "^1.
|
|
82
|
+
"@hono/node-server": "^2.1.1",
|
|
68
83
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
69
84
|
"hono": "^4.6.14",
|
|
70
85
|
"tsx": "^4.19.2",
|
|
Binary file
|
package/probes/site_probe.py
CHANGED
|
@@ -173,7 +173,7 @@ def main():
|
|
|
173
173
|
out_dir = seo_config.DATA / "probes"
|
|
174
174
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
175
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))
|
|
176
|
+
out.write_text(json.dumps(snapshot, indent=2), encoding="utf-8")
|
|
177
177
|
|
|
178
178
|
for s in snapshot["sites"]:
|
|
179
179
|
h = s["homepage"]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
The two fonts in this directory are used under the SIL Open Font License 1.1.
|
|
2
|
+
The full license text follows. Both are redistributed unmodified, as the
|
|
3
|
+
latin-subset variable woff2 files Google Fonts serves.
|
|
4
|
+
|
|
5
|
+
montserrat-latin-var.woff2
|
|
6
|
+
Copyright 2024 The Montserrat Project Authors
|
|
7
|
+
https://github.com/JulietaUla/Montserrat
|
|
8
|
+
|
|
9
|
+
merriweather-latin-var.woff2
|
|
10
|
+
Copyright 2020 The Merriweather Project Authors, with Reserved Font Name
|
|
11
|
+
"Merriweather". https://github.com/EbenSorkin/Merriweather4
|
|
12
|
+
|
|
13
|
+
--------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
16
|
+
This license is copied below, and is also available with a FAQ at:
|
|
17
|
+
https://openfontlicense.org
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
-----------------------------------------------------------
|
|
21
|
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
22
|
+
-----------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
PREAMBLE
|
|
25
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
26
|
+
development of collaborative font projects, to support the font creation
|
|
27
|
+
efforts of academic and linguistic communities, and to provide a free and
|
|
28
|
+
open framework in which fonts may be shared and improved in partnership
|
|
29
|
+
with others.
|
|
30
|
+
|
|
31
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
32
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
33
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
34
|
+
redistributed and/or sold with any software provided that any reserved
|
|
35
|
+
names are not used by derivative works. The fonts and derivatives,
|
|
36
|
+
however, cannot be released under any other type of license. The
|
|
37
|
+
requirement for fonts to remain under this license does not apply
|
|
38
|
+
to any document created using the fonts or their derivatives.
|
|
39
|
+
|
|
40
|
+
DEFINITIONS
|
|
41
|
+
"Font Software" refers to the set of files released by the Copyright
|
|
42
|
+
Holder(s) under this license and clearly marked as such. This may
|
|
43
|
+
include source files, build scripts and documentation.
|
|
44
|
+
|
|
45
|
+
"Reserved Font Name" refers to any names specified as such after the
|
|
46
|
+
copyright statement(s).
|
|
47
|
+
|
|
48
|
+
"Original Version" refers to the collection of Font Software components as
|
|
49
|
+
distributed by the Copyright Holder(s).
|
|
50
|
+
|
|
51
|
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
52
|
+
or substituting -- in part or in whole -- any of the components of the
|
|
53
|
+
Original Version, by changing formats or by porting the Font Software to a
|
|
54
|
+
new environment.
|
|
55
|
+
|
|
56
|
+
"Author" refers to any designer, engineer, programmer, technical
|
|
57
|
+
writer or other person who contributed to the Font Software.
|
|
58
|
+
|
|
59
|
+
PERMISSION & CONDITIONS
|
|
60
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
61
|
+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
62
|
+
redistribute, and sell modified and unmodified copies of the Font
|
|
63
|
+
Software, subject to the following conditions:
|
|
64
|
+
|
|
65
|
+
1) Neither the Font Software nor any of its individual components,
|
|
66
|
+
in Original or Modified Versions, may be sold by itself.
|
|
67
|
+
|
|
68
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
69
|
+
redistributed and/or sold with any software, provided that each copy
|
|
70
|
+
contains the above copyright notice and this license. These can be
|
|
71
|
+
included either as stand-alone text files, human-readable headers or
|
|
72
|
+
in the appropriate machine-readable metadata fields within text or
|
|
73
|
+
binary files as long as those fields can be easily viewed by the user.
|
|
74
|
+
|
|
75
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
76
|
+
Name(s) unless explicit written permission is granted by the corresponding
|
|
77
|
+
Copyright Holder. This restriction only applies to the primary font name as
|
|
78
|
+
presented to the users.
|
|
79
|
+
|
|
80
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
81
|
+
Software shall not be used to promote, endorse or advertise any
|
|
82
|
+
Modified Version, except to acknowledge the contribution(s) of the
|
|
83
|
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
84
|
+
permission.
|
|
85
|
+
|
|
86
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
87
|
+
must be distributed entirely under this license, and must not be
|
|
88
|
+
distributed under any other license. The requirement for fonts to
|
|
89
|
+
remain under this license does not apply to any document created
|
|
90
|
+
using the Font Software.
|
|
91
|
+
|
|
92
|
+
TERMINATION
|
|
93
|
+
This license becomes null and void if any of the above conditions are
|
|
94
|
+
not met.
|
|
95
|
+
|
|
96
|
+
DISCLAIMER
|
|
97
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
98
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
99
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
100
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
101
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
102
|
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
103
|
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
104
|
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
105
|
+
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
Binary file
|
package/public/styles.css
CHANGED
|
@@ -62,6 +62,19 @@
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/* Montserrat, self-hosted: one variable file, latin subset, from this origin.
|
|
66
|
+
Headings and the brand mark only — the tables, chips and numbers stay on the
|
|
67
|
+
system UI font, which is what a dense data view is designed around. The
|
|
68
|
+
fallback stack is what renders if the file is missing, so nothing breaks. */
|
|
69
|
+
@font-face {
|
|
70
|
+
font-family: "Montserrat";
|
|
71
|
+
src: url("/fonts/montserrat-latin-var.woff2") format("woff2-variations"),
|
|
72
|
+
url("/fonts/montserrat-latin-var.woff2") format("woff2");
|
|
73
|
+
font-weight: 100 900;
|
|
74
|
+
font-style: normal;
|
|
75
|
+
font-display: swap;
|
|
76
|
+
}
|
|
77
|
+
|
|
65
78
|
* { box-sizing: border-box; }
|
|
66
79
|
body {
|
|
67
80
|
margin: 0;
|
|
@@ -72,7 +85,7 @@ body {
|
|
|
72
85
|
line-height: 1.55;
|
|
73
86
|
}
|
|
74
87
|
main { max-width: 1480px; margin: 0 auto; padding: 28px 24px 80px; }
|
|
75
|
-
h1, h2, h3 { line-height: 1.2; letter-spacing: -0.01em; }
|
|
88
|
+
h1, h2, h3 { font-family: "Montserrat", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.2; letter-spacing: -0.01em; }
|
|
76
89
|
h1 { font-size: 1.8rem; font-weight: 800; margin: 0.5em 0; }
|
|
77
90
|
h2 { font-size: 1.05rem; font-weight: 700; margin: 2em 0 0.6em; text-transform: uppercase; letter-spacing: 0.06em; }
|
|
78
91
|
h2 small { font-weight: 500; color: var(--muted); font-size: 0.75rem; text-transform: none; letter-spacing: 0; }
|
|
@@ -544,6 +557,10 @@ footer { max-width: 1480px; margin: 0 auto; padding: 16px 24px 40px; color: var(
|
|
|
544
557
|
.idx-help { color: var(--muted); font-size: 0.82rem; margin-left: 8px; }
|
|
545
558
|
.idx-stale { color: var(--muted); font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
546
559
|
.idx-sitemap { margin: -4px 0 8px; font-size: 0.78rem; color: var(--muted); }
|
|
560
|
+
.idx-fix { margin: 6px 0 2px 8px; font-size: 0.8rem; color: var(--muted); line-height: 1.55; max-width: 96ch; }
|
|
561
|
+
.idx-ri { display: inline-block; font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; border-radius: 4px; padding: 1px 6px; margin-right: 4px; white-space: nowrap; }
|
|
562
|
+
.idx-ri.yes { background: var(--good-soft); color: var(--good); }
|
|
563
|
+
.idx-ri.no { background: var(--bad-soft); color: var(--bad); }
|
|
547
564
|
|
|
548
565
|
/* ---- settings ---- */
|
|
549
566
|
.settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; }
|
package/src/actions.ts
CHANGED
|
@@ -118,7 +118,7 @@ function probeActions(site: SiteCfg): Action[] {
|
|
|
118
118
|
const mk = (key: string, title: string, how: string): Action => ({
|
|
119
119
|
id: `gen-hygiene-${slug(site.host)}-${key}`,
|
|
120
120
|
host: site.host, title, kind: "Config change", why: "Live probe failing on this check", how,
|
|
121
|
-
spec: [how, `Verify with:
|
|
121
|
+
spec: [how, `Verify with: n-seo daily --only probe after deploy.`],
|
|
122
122
|
impact: 5, effort: "S", tag: "hygiene", source: "rule",
|
|
123
123
|
});
|
|
124
124
|
if (!p.robots.exists) out.push(mk("robots", "Add robots.txt", "Serve robots.txt with a Sitemap: pointer and explicit AI-crawler allowances."));
|
package/src/config.ts
CHANGED
|
@@ -80,7 +80,7 @@ export interface Hooks {
|
|
|
80
80
|
beforeRun: string[];
|
|
81
81
|
/** … after the last step (and after last-run.json is written) */
|
|
82
82
|
afterRun: string[];
|
|
83
|
-
/** … after a named step, e.g. { "daily-diff": ["
|
|
83
|
+
/** … after a named step, e.g. { "daily-diff": ["node my_sync.mjs"] } */
|
|
84
84
|
afterStep: Record<string, string[]>;
|
|
85
85
|
}
|
|
86
86
|
|
package/src/mcp.ts
CHANGED
|
@@ -273,7 +273,7 @@ export function createMcpServer(): McpServer {
|
|
|
273
273
|
},
|
|
274
274
|
async ({ host }) => {
|
|
275
275
|
const audit = data.metadataAudit();
|
|
276
|
-
if (!audit) return fail("No metadata audit on disk yet — run
|
|
276
|
+
if (!audit) return fail("No metadata audit on disk yet — run `n-seo daily --only metadata-audit`.");
|
|
277
277
|
if (!host) return json(audit);
|
|
278
278
|
const findings = audit.sites[host];
|
|
279
279
|
if (!findings) return fail(`No audit entry for "${host}". Known: ${Object.keys(audit.sites).join(", ")}`);
|
|
@@ -379,7 +379,7 @@ export function createMcpServer(): McpServer {
|
|
|
379
379
|
},
|
|
380
380
|
async () => {
|
|
381
381
|
const scan = data.opportunityScan();
|
|
382
|
-
if (!scan) return fail("No opportunity scan on disk yet — run
|
|
382
|
+
if (!scan) return fail("No opportunity scan on disk yet — run `n-seo daily --only opportunity-scan`.");
|
|
383
383
|
return json(scan);
|
|
384
384
|
}
|
|
385
385
|
);
|
package/src/server.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
5
5
|
import crypto from "node:crypto";
|
|
6
6
|
import fs from "node:fs";
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import { ROOT, PORT, siteByHost, dotEnv } from "./config.js";
|
|
8
|
+
import { ROOT, INSTANCE, PORT, siteByHost, dotEnv } from "./config.js";
|
|
9
9
|
import { draftBySlug, campaignBySlug } from "./data.js";
|
|
10
10
|
import { allActions } from "./actions.js";
|
|
11
11
|
import { acceptProposal, setWatching, retire } from "./backlog.js";
|
|
@@ -94,6 +94,20 @@ app.get("/styles.css", (c) => {
|
|
|
94
94
|
return c.text(css, 200, { "Content-Type": "text/css; charset=utf-8" });
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
+
// The one webfont the dashboard uses, served from this origin. Self-hosting
|
|
98
|
+
// keeps the dashboard free of third-party requests — it renders on machines
|
|
99
|
+
// with no outbound internet, and it never tells Google when someone opens it.
|
|
100
|
+
app.get("/fonts/:file", (c) => {
|
|
101
|
+
const file = c.req.param("file");
|
|
102
|
+
if (!/^[\w.-]+\.woff2$/.test(file)) return c.notFound();
|
|
103
|
+
const abs = path.join(ROOT, "public", "fonts", file);
|
|
104
|
+
if (!fs.existsSync(abs)) return c.notFound();
|
|
105
|
+
return c.body(fs.readFileSync(abs), 200, {
|
|
106
|
+
"Content-Type": "font/woff2",
|
|
107
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
97
111
|
app.get("/favicon.svg", (c) => {
|
|
98
112
|
const svg = fs.readFileSync(path.join(ROOT, "public", "favicon.svg"), "utf8");
|
|
99
113
|
return c.text(svg, 200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=86400" });
|
|
@@ -257,4 +271,11 @@ app.post("/api/backlog/:id/retire", (c) => {
|
|
|
257
271
|
const HOST = process.env.SEO_HOST ?? "127.0.0.1";
|
|
258
272
|
serve({ fetch: app.fetch, port: PORT, hostname: HOST }, (info) => {
|
|
259
273
|
console.log(`n-seo → http://${HOST === "0.0.0.0" ? "localhost" : HOST}:${info.port}`);
|
|
274
|
+
// Printed once per start, and only where it is actionable: an instance that
|
|
275
|
+
// has the skills installed. A running dashboard says nothing about how to
|
|
276
|
+
// get the next thing done, and most people should be asking rather than
|
|
277
|
+
// reading.
|
|
278
|
+
if (fs.existsSync(path.join(INSTANCE, ".claude", "skills", "n-seo-triage", "SKILL.md"))) {
|
|
279
|
+
console.log(` ask it: cd ${INSTANCE} && claude → "what should I work on today?"`);
|
|
280
|
+
}
|
|
260
281
|
});
|