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,213 @@
1
+ """The one config file, as seen from Python.
2
+
3
+ n-seo.config.json (or $N_SEO_CONFIG) is shared with the dashboard
4
+ (src/config.ts). Every script imports this instead of carrying its own site
5
+ list, so a site added to the config is picked up by every pull, probe, audit
6
+ and export. Stdlib only.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+ # The engine checkout: code, engine docs, public assets.
15
+ ROOT = Path(__file__).resolve().parent.parent
16
+ # The instance: one user's config, queue, content and data. Defaults to the
17
+ # engine checkout ("in-place" mode); N_SEO_INSTANCE separates them so that
18
+ # upgrading the engine is a git pull that never touches your files.
19
+ INSTANCE = Path(os.environ["N_SEO_INSTANCE"]).resolve() if os.environ.get("N_SEO_INSTANCE") else ROOT
20
+ DATA = INSTANCE / "data"
21
+ CONFIG_PATH = Path(os.environ.get("N_SEO_CONFIG") or INSTANCE / "n-seo.config.json")
22
+ EXAMPLE_PATH = ROOT / "n-seo.config.example.json"
23
+
24
+ MODULE_KEYS = [
25
+ "indexStatus", "metadataAudit", "opportunityScan", "llm", "hackerNews",
26
+ "reddit", "indexNow", "staticExport", "publish", "gitAutoCommit",
27
+ "notifications",
28
+ ]
29
+
30
+ # Per-module defaults, so a half-written block cannot make a step guess.
31
+ # Mirrors MODULE_DEFAULTS in src/config.ts.
32
+ MODULE_DEFAULTS = {
33
+ "staticExport": {"signOutUrl": "", "signOutLabel": "Sign out"},
34
+ "publish": {"target": "gcs", "destination": "", "command": "",
35
+ "delete": False, "dryRun": False, "env": {}},
36
+ }
37
+
38
+ _cache = None
39
+
40
+
41
+ def using_example() -> bool:
42
+ return not CONFIG_PATH.exists()
43
+
44
+
45
+ def load(force: bool = False) -> dict:
46
+ """The normalized config. Falls back to the example file so a fresh
47
+ checkout can run `ops/demo_data.py` and open the dashboard before any
48
+ Google setup."""
49
+ global _cache
50
+ if _cache is not None and not force:
51
+ return _cache
52
+ path = CONFIG_PATH if CONFIG_PATH.exists() else EXAMPLE_PATH
53
+ raw = json.loads(path.read_text())
54
+ modules = {k: {"enabled": False, **MODULE_DEFAULTS.get(k, {})} for k in MODULE_KEYS}
55
+ for k, v in (raw.get("modules") or {}).items():
56
+ modules[k] = {"enabled": False, **MODULE_DEFAULTS.get(k, {}), **(v or {})}
57
+ sites = []
58
+ for s in raw.get("sites") or []:
59
+ if not s.get("host"):
60
+ continue
61
+ sites.append({
62
+ **s,
63
+ "label": s.get("label") or s["host"],
64
+ "gscHost": s.get("gscHost") or s["host"],
65
+ "gscProperty": s.get("gscProperty") or None,
66
+ "ga4Property": str(s.get("ga4Property") or "") or None,
67
+ "brand": s.get("brand") or None,
68
+ })
69
+ conv = raw.get("conversions") or {}
70
+
71
+ def str_list(v):
72
+ return [x for x in (v or []) if isinstance(x, str) and x.strip()] if isinstance(v, list) else []
73
+
74
+ raw_hooks = raw.get("hooks") or {}
75
+ hooks = {
76
+ "beforeRun": str_list(raw_hooks.get("beforeRun")),
77
+ "afterRun": str_list(raw_hooks.get("afterRun")),
78
+ "afterStep": {k: str_list(v) for k, v in (raw_hooks.get("afterStep") or {}).items()},
79
+ }
80
+ _cache = {
81
+ "name": raw.get("name") or "n-seo",
82
+ "port": int(os.environ.get("SEO_PORT") or raw.get("port") or 4600),
83
+ "google": {**(raw.get("google") or {}),
84
+ "auth": (raw.get("google") or {}).get("auth") or "service-account-key"},
85
+ "sites": sites,
86
+ "watchPages": raw.get("watchPages") or [],
87
+ "conversions": conv if conv.get("site") else None,
88
+ "participation": raw.get("participation") or {},
89
+ "modules": modules,
90
+ "gscExtraProperties": str_list(raw.get("gscExtraProperties")),
91
+ "hooks": hooks,
92
+ }
93
+ return _cache
94
+
95
+
96
+ def engine_info() -> dict:
97
+ """Mirrors src/config.ts engineInfo(): what engine is running, for which instance."""
98
+ try:
99
+ version = json.loads((ROOT / "package.json").read_text()).get("version", "0.0.0")
100
+ except (OSError, json.JSONDecodeError):
101
+ version = "0.0.0"
102
+ try:
103
+ p = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "--short", "HEAD"],
104
+ capture_output=True, text=True)
105
+ commit = p.stdout.strip() or None if p.returncode == 0 else None
106
+ except OSError:
107
+ commit = None
108
+ return {"version": version, "commit": commit, "root": str(ROOT), "instance": str(INSTANCE),
109
+ "mode": "in-place" if INSTANCE == ROOT else "instance"}
110
+
111
+
112
+ def hooks() -> dict:
113
+ return load()["hooks"]
114
+
115
+
116
+ def sites() -> list[dict]:
117
+ return load()["sites"]
118
+
119
+
120
+ def hosts() -> list[str]:
121
+ return [s["host"] for s in sites()]
122
+
123
+
124
+ def module(name: str) -> dict:
125
+ return load()["modules"].get(name, {"enabled": False})
126
+
127
+
128
+ def enabled(name: str) -> bool:
129
+ return bool(module(name).get("enabled"))
130
+
131
+
132
+ def port() -> int:
133
+ return load()["port"]
134
+
135
+
136
+ def dashboard_base() -> str:
137
+ return f"http://localhost:{port()}"
138
+
139
+
140
+ def gsc_slug(prop: str) -> str:
141
+ """data/gsc/<slug>/ — must match src/config.ts gscSlug()."""
142
+ s = prop
143
+ if s.startswith("sc-domain:"):
144
+ s = s[len("sc-domain:"):]
145
+ for prefix in ("https://", "http://"):
146
+ if s.startswith(prefix):
147
+ s = s[len(prefix):]
148
+ return s.rstrip("/").replace("/", "_")
149
+
150
+
151
+ def gsc_data_slug(prop: str) -> str:
152
+ """The data/gsc/ directory for a property. A url-prefix property
153
+ ("https://example.com/") would slug to the same name as the domain
154
+ property ("sc-domain:example.com"), so it gets a "-urlprefix" suffix.
155
+ Must match src/config.ts gscDataSlug()."""
156
+ s = gsc_slug(prop)
157
+ return s + "-urlprefix" if prop.lower().startswith(("http://", "https://")) else s
158
+
159
+
160
+ def gsc_properties(include_extra: bool = True) -> dict[str, str]:
161
+ """{GSC property -> data/gsc subdirectory}, deduplicated. A domain
162
+ property covers its subdomains, so several hosts can share one.
163
+ gscExtraProperties (pulled for their data, never shown as sites) are
164
+ appended unless include_extra is False."""
165
+ out = {}
166
+ for s in sites():
167
+ if s["gscProperty"]:
168
+ out[s["gscProperty"]] = gsc_data_slug(s["gscProperty"])
169
+ if include_extra:
170
+ for prop in load()["gscExtraProperties"]:
171
+ out.setdefault(prop, gsc_data_slug(prop))
172
+ return out
173
+
174
+
175
+ def gsc_dir_for(site: dict) -> Path | None:
176
+ return DATA / "gsc" / gsc_data_slug(site["gscProperty"]) if site.get("gscProperty") else None
177
+
178
+
179
+ def ga4_properties() -> dict[str, str]:
180
+ """{host -> numeric GA4 property id}"""
181
+ return {s["host"]: s["ga4Property"] for s in sites() if s.get("ga4Property")}
182
+
183
+
184
+ def index_hosts() -> dict[str, str]:
185
+ """{crawlable host -> GSC property to inspect its URLs against}"""
186
+ return {s["gscHost"]: s["gscProperty"] for s in sites() if s.get("gscProperty")}
187
+
188
+
189
+ def brand_patterns() -> dict[str, str]:
190
+ """{GSC property -> brand regex}; hosts sharing a property are OR-ed."""
191
+ out: dict[str, list[str]] = {}
192
+ for s in sites():
193
+ if s.get("gscProperty") and s.get("brand"):
194
+ out.setdefault(s["gscProperty"], []).append(s["brand"])
195
+ return {p: "|".join(f"(?:{b})" for b in bs) for p, bs in out.items()}
196
+
197
+
198
+ def expand(path_str: str) -> Path:
199
+ return Path(os.path.expanduser(os.path.expandvars(path_str))).resolve()
200
+
201
+
202
+ def env(key: str, default: str = "") -> str:
203
+ """Read one key from the environment, then from the instance's .env (KEY=value lines)."""
204
+ if os.environ.get(key):
205
+ return os.environ[key]
206
+ try:
207
+ for line in (INSTANCE / ".env").read_text().splitlines():
208
+ line = line.strip()
209
+ if line.startswith(f"{key}="):
210
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
211
+ except OSError:
212
+ pass
213
+ return default
@@ -0,0 +1,110 @@
1
+ {
2
+ "name": "My sites",
3
+ "port": 4600,
4
+ "google": {
5
+ "auth": "service-account-key",
6
+ "serviceAccountKey": "~/.config/n-seo/service-account.json",
7
+ "impersonate": ""
8
+ },
9
+ "sites": [
10
+ {
11
+ "host": "example.com",
12
+ "label": "example",
13
+ "gscProperty": "sc-domain:example.com",
14
+ "gscHost": "example.com",
15
+ "ga4Property": "",
16
+ "brand": "example",
17
+ "repo": "../example-site",
18
+ "hosting": "Netlify"
19
+ },
20
+ {
21
+ "host": "docs.example.com",
22
+ "label": "docs",
23
+ "gscProperty": "sc-domain:example.com",
24
+ "gscHost": "docs.example.com",
25
+ "ga4Property": "",
26
+ "brand": "example"
27
+ }
28
+ ],
29
+ "watchPages": [
30
+ "https://example.com/",
31
+ "https://example.com/pricing"
32
+ ],
33
+ "gscExtraProperties": [],
34
+ "conversions": {
35
+ "site": "",
36
+ "events": [
37
+ "sign_up",
38
+ "newsletter_signup"
39
+ ],
40
+ "sourceDimension": "customEvent:source_app"
41
+ },
42
+ "participation": {
43
+ "expertise": "Describe who you are and what you genuinely know first-hand. This is the only context the briefing generator gets, and it is what keeps briefings honest: they point at threads where YOUR experience applies."
44
+ },
45
+ "modules": {
46
+ "indexStatus": {
47
+ "enabled": true
48
+ },
49
+ "metadataAudit": {
50
+ "enabled": true
51
+ },
52
+ "opportunityScan": {
53
+ "enabled": true
54
+ },
55
+ "llm": {
56
+ "enabled": false,
57
+ "command": "claude -p --model sonnet",
58
+ "fastCommand": "claude -p --model haiku"
59
+ },
60
+ "hackerNews": {
61
+ "enabled": false,
62
+ "user": "",
63
+ "topics": [
64
+ [
65
+ "your niche keyword",
66
+ "why you can speak to this"
67
+ ]
68
+ ]
69
+ },
70
+ "reddit": {
71
+ "enabled": false,
72
+ "user": "",
73
+ "topics": [
74
+ [
75
+ "subreddit",
76
+ "search query",
77
+ "why you can speak to this"
78
+ ]
79
+ ]
80
+ },
81
+ "indexNow": {
82
+ "enabled": false,
83
+ "keyFile": "indexnow.key"
84
+ },
85
+ "staticExport": {
86
+ "enabled": false,
87
+ "signOutUrl": "",
88
+ "signOutLabel": "Sign out"
89
+ },
90
+ "publish": {
91
+ "enabled": false,
92
+ "target": "gcs",
93
+ "destination": "gs://your-bucket",
94
+ "delete": false,
95
+ "dryRun": false,
96
+ "env": {}
97
+ },
98
+ "gitAutoCommit": {
99
+ "enabled": false
100
+ },
101
+ "notifications": {
102
+ "enabled": false
103
+ }
104
+ },
105
+ "hooks": {
106
+ "beforeRun": [],
107
+ "afterStep": {},
108
+ "afterRun": []
109
+ }
110
+ }
package/ops/daily.py ADDED
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env python3
2
+ """The daily run: refresh every snapshot, diff, digest, export.
3
+
4
+ Portable replacement for a shell script — schedule it with launchd, cron or
5
+ systemd (see docs/SCHEDULING.md), or run it by hand any time:
6
+
7
+ python3 ops/daily.py # everything the config enables
8
+ python3 ops/daily.py --list # show the steps and which are on
9
+ python3 ops/daily.py --only probe,gsc
10
+ python3 ops/daily.py --skip index-status --no-network-wait
11
+ python3 ops/daily.py --skip hooks # steps only, no config hooks
12
+
13
+ Hooks (config `hooks`: beforeRun / afterStep.<name> / afterRun) are shell
14
+ commands run in the instance directory around the steps; a failing hook is
15
+ recorded as a failure but never aborts the run.
16
+
17
+ Each step is a subprocess; its output is teed to the console and to
18
+ data/daily-ops.log. A failed step is retried once after waiting for the
19
+ network — on a laptop, a run that fires on wake usually fails only because
20
+ Wi-Fi is not up yet. Results land in data/last-run.json for the dashboard.
21
+ """
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+ import platform
27
+ import subprocess
28
+ import sys
29
+ import time
30
+ from datetime import date, datetime, timezone
31
+ from pathlib import Path
32
+
33
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ingest"))
34
+ import seo_config # noqa: E402
35
+
36
+ ROOT = seo_config.ROOT # the engine: scripts run from here
37
+ INSTANCE = seo_config.INSTANCE # the instance: data, log, git commits, hooks run here
38
+ LOG = seo_config.DATA / "daily-ops.log"
39
+
40
+ # (name, script, module gate or None)
41
+ STEPS = [
42
+ ("probe", "probes/site_probe.py", None),
43
+ ("gsc", "ingest/pull_gsc.py", None),
44
+ ("ga4", "ingest/pull_ga4.py", None),
45
+ ("timeseries", "ingest/pull_timeseries.py", None),
46
+ ("metadata-audit", "ingest/analyze_metadata.py", "metadataAudit"),
47
+ ("index-status", "ingest/pull_index_status.py", "indexStatus"),
48
+ ("opportunity-scan", "ops/opportunity_scan.py", "opportunityScan"),
49
+ ("daily-diff", "ops/daily_diff.py", None),
50
+ ("hn-digest", "ops/hn_digest.py", "hackerNews"),
51
+ ("reddit-digest", "ops/reddit_digest.py", "reddit"),
52
+ ("static-export", "ops/export_static.py", "staticExport"),
53
+ ("publish", "ops/publish.py", "publish"),
54
+ ]
55
+
56
+
57
+ def log(line: str):
58
+ print(line, flush=True)
59
+ LOG.parent.mkdir(parents=True, exist_ok=True)
60
+ with LOG.open("a") as f:
61
+ f.write(line.rstrip("\n") + "\n")
62
+
63
+
64
+ def wait_for_network(max_wait=300):
65
+ waited = 0
66
+ while waited < max_wait:
67
+ p = subprocess.run(["curl", "-sf", "--max-time", "5", "-o", "/dev/null",
68
+ "https://www.google.com/generate_204"], capture_output=True)
69
+ if p.returncode == 0:
70
+ if waited:
71
+ log(f"network: waited {waited}s for connectivity")
72
+ return True
73
+ time.sleep(10)
74
+ waited += 10
75
+ log(f"network: still down after {max_wait}s — continuing; network steps will fail")
76
+ return False
77
+
78
+
79
+ def _hook_env(step: str | None = None) -> dict:
80
+ env = dict(os.environ)
81
+ env["N_SEO_ROOT"] = str(ROOT)
82
+ env["N_SEO_INSTANCE"] = str(INSTANCE)
83
+ if step:
84
+ env["N_SEO_STEP"] = step
85
+ return env
86
+
87
+
88
+ def run_step(script: str) -> bool:
89
+ # Engine scripts run with cwd=ROOT; they find the instance through the
90
+ # inherited N_SEO_INSTANCE (seo_config resolves it), never through cwd.
91
+ p = subprocess.Popen([sys.executable, str(ROOT / script)], cwd=ROOT, env=_hook_env(),
92
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
93
+ for line in p.stdout:
94
+ log(" " + line.rstrip("\n"))
95
+ return p.wait() == 0
96
+
97
+
98
+ def run_hook(cmd: str, step: str | None = None) -> bool:
99
+ """One `hooks` command: a shell string run in the instance directory with
100
+ N_SEO_ROOT / N_SEO_INSTANCE / N_SEO_STEP set. Output is teed like a step."""
101
+ p = subprocess.Popen(cmd, shell=True, cwd=INSTANCE, env=_hook_env(step),
102
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
103
+ for line in p.stdout:
104
+ log(" " + line.rstrip("\n"))
105
+ return p.wait() == 0
106
+
107
+
108
+ def run_hooks(kind: str, cmds: list, results: list, failures: list, step: str | None = None):
109
+ """Run a hook list, recording each as hook:<kind>:<i>. A failing hook is a
110
+ recorded failure, never an abort — the run's own steps still matter more."""
111
+ for i, cmd in enumerate(cmds):
112
+ name = f"hook:{kind}:{i}"
113
+ t0 = time.time()
114
+ log(f"--- {name} $ {cmd}")
115
+ ok = run_hook(cmd, step)
116
+ if not ok:
117
+ failures.append(name)
118
+ log(f"{name} FAILED")
119
+ results.append({"name": name, "ok": ok, "seconds": round(time.time() - t0, 1)})
120
+
121
+
122
+ def notify(text: str):
123
+ if not seo_config.enabled("notifications") or platform.system() != "Darwin":
124
+ return
125
+ subprocess.run(["osascript", "-e",
126
+ f'display notification "{text}" with title "n-seo daily run"'],
127
+ capture_output=True)
128
+
129
+
130
+ def git_autocommit():
131
+ if not seo_config.enabled("gitAutoCommit"):
132
+ return
133
+ # The instance is what has history worth committing; the engine is code.
134
+ paths = [p for p in ("docs/daily-log.md", "docs/reports", "site") if (INSTANCE / p).exists()]
135
+ if not paths:
136
+ return
137
+ subprocess.run(["git", "add", *paths], cwd=INSTANCE, capture_output=True)
138
+ staged = subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=INSTANCE).returncode != 0
139
+ if not staged:
140
+ return
141
+ c = subprocess.run(["git", "commit", "-q", "-m", f"Daily refresh {date.today().isoformat()}"],
142
+ cwd=INSTANCE, capture_output=True, text=True)
143
+ if c.returncode != 0:
144
+ log(f"git: commit FAILED — {(c.stderr or c.stdout).strip()[:200]}")
145
+ return
146
+ log("git: committed daily refresh")
147
+ remotes = subprocess.run(["git", "remote"], cwd=INSTANCE, capture_output=True, text=True).stdout.split()
148
+ if remotes:
149
+ p = subprocess.run(["git", "push", "-q"], cwd=INSTANCE, capture_output=True, text=True)
150
+ log("git: pushed" if p.returncode == 0 else f"git: push FAILED ({p.stderr.strip()[:120]})")
151
+
152
+
153
+ def main():
154
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
155
+ ap.add_argument("--list", action="store_true", help="list steps and exit")
156
+ ap.add_argument("--only", help="comma-separated step names to run")
157
+ ap.add_argument("--skip", help="comma-separated step names to skip")
158
+ ap.add_argument("--no-network-wait", action="store_true")
159
+ args = ap.parse_args()
160
+
161
+ enabled_steps = [(n, s) for n, s, gate in STEPS if gate is None or seo_config.enabled(gate)]
162
+ hooks = seo_config.hooks()
163
+ if args.list:
164
+ e = seo_config.engine_info()
165
+ print(f" engine {e['version']} ({e['mode']}) — instance {e['instance']}")
166
+ for c in hooks["beforeRun"]:
167
+ print(f" on hook:before $ {c}")
168
+ for n, s, gate in STEPS:
169
+ on = gate is None or seo_config.enabled(gate)
170
+ print(f" {'on ' if on else 'off'} {n:18s} {s}" + (f" (modules.{gate})" if gate else ""))
171
+ for c in hooks["afterStep"].get(n, []):
172
+ print(f" {'on ' if on else 'off'} hook:{n:13s} $ {c}")
173
+ for c in hooks["afterRun"]:
174
+ print(f" on hook:after $ {c}")
175
+ return 0
176
+
177
+ only = set(args.only.split(",")) if args.only else None
178
+ skip = set(args.skip.split(",")) if args.skip else set()
179
+ run_hooks_flag = "hooks" not in skip
180
+ skip.discard("hooks")
181
+ known = {n for n, _, _ in STEPS}
182
+ for bad in (only or set()) | skip:
183
+ if bad not in known:
184
+ print(f"unknown step {bad!r}; known: {', '.join(known)}")
185
+ return 2
186
+ todo = [(n, s) for n, s in enabled_steps if (not only or n in only) and n not in skip]
187
+ if only and run_hooks_flag and (hooks["beforeRun"] or hooks["afterRun"]):
188
+ # Hooks are run-level, not step-level: --only narrows the steps but
189
+ # beforeRun/afterRun still fire, which surprises anyone using --only
190
+ # as a quick smoke test.
191
+ print("note: beforeRun/afterRun hooks still run under --only "
192
+ "(pass --skip hooks to suppress them)", file=sys.stderr)
193
+ enabled_names = {n for n, _ in enabled_steps}
194
+ for name in sorted((only or set()) - enabled_names):
195
+ if name in dict(STEPS):
196
+ print(f"note: {name} is disabled by its module in the config — skipped", file=sys.stderr)
197
+
198
+ log(f"=== daily run {datetime.now():%Y-%m-%d %H:%M} ({len(todo)} steps) ===")
199
+ if seo_config.using_example():
200
+ log("note: no n-seo.config.json — running on the example config")
201
+ if not args.no_network_wait:
202
+ wait_for_network()
203
+
204
+ results, failures = [], []
205
+ if run_hooks_flag:
206
+ run_hooks("before", hooks["beforeRun"], results, failures)
207
+ for name, script in todo:
208
+ t0 = time.time()
209
+ log(f"--- {name}")
210
+ ok = run_step(script)
211
+ if not ok:
212
+ log(f"{name} failed, waiting for network and retrying once")
213
+ # --no-network-wait means "do not sit here"; without this the
214
+ # retry path waits the full timeout for every failing step.
215
+ if not args.no_network_wait:
216
+ wait_for_network()
217
+ ok = run_step(script)
218
+ if ok:
219
+ log(f"{name} recovered on retry")
220
+ if not ok:
221
+ failures.append(name)
222
+ log(f"{name} FAILED")
223
+ results.append({"name": name, "ok": ok, "seconds": round(time.time() - t0, 1)})
224
+ if run_hooks_flag:
225
+ run_hooks(name, hooks["afterStep"].get(name, []), results, failures, step=name)
226
+
227
+ def write_last_run():
228
+ seo_config.DATA.mkdir(parents=True, exist_ok=True)
229
+ (seo_config.DATA / "last-run.json").write_text(json.dumps({
230
+ "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%MZ"),
231
+ "failures": "; ".join(failures),
232
+ "steps": results,
233
+ }, indent=1))
234
+
235
+ write_last_run()
236
+ if run_hooks_flag and hooks["afterRun"]:
237
+ # afterRun sees a complete last-run.json (a mirror sync wants it), and
238
+ # is then recorded in it too.
239
+ run_hooks("after", hooks["afterRun"], results, failures)
240
+ write_last_run()
241
+
242
+ if failures:
243
+ notify(f"{', '.join(failures)} failed — see the Logs page")
244
+ git_autocommit()
245
+ log(f"=== done ({len(failures)} failures) ===")
246
+ return 1 if failures else 0
247
+
248
+
249
+ if __name__ == "__main__":
250
+ sys.exit(main())