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,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
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
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())
|