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
package/ops/demo_data.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Synthetic dataset so you can see the dashboard before wiring up Google.
|
|
3
|
+
|
|
4
|
+
python3 ops/demo_data.py # write demo data for the configured sites
|
|
5
|
+
python3 ops/demo_data.py --clean # delete data/ and exit
|
|
6
|
+
|
|
7
|
+
Writes every file the pipeline produces (see docs/ARCHITECTURE.md) for the
|
|
8
|
+
sites in the current config — with no config of your own that is the
|
|
9
|
+
example's example.com + docs.example.com. Deterministic (seeded), and shaped
|
|
10
|
+
so every dashboard feature has something to show: striking-distance queries,
|
|
11
|
+
CTR gaps, a low-engagement landing page, a probe finding, indexing problems,
|
|
12
|
+
rising/falling queries, AI referrals, proposals. It is fake; the "why" on
|
|
13
|
+
every proposal says so.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import math
|
|
18
|
+
import random
|
|
19
|
+
import shutil
|
|
20
|
+
import sys
|
|
21
|
+
from datetime import date, datetime, timedelta, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ingest"))
|
|
25
|
+
import seo_config # noqa: E402
|
|
26
|
+
|
|
27
|
+
DATA = seo_config.DATA
|
|
28
|
+
rng = random.Random(20260907)
|
|
29
|
+
|
|
30
|
+
TODAY = date.today()
|
|
31
|
+
END = TODAY - timedelta(days=3) # GSC finalizes ~3 days behind
|
|
32
|
+
FULL_DAYS = 488
|
|
33
|
+
RECENT_DAYS = 93
|
|
34
|
+
|
|
35
|
+
# Query templates per site "role"; {t} is the site's topic word.
|
|
36
|
+
TOPICS = ["widgets", "dashboards", "canvas rendering", "supply chain games", "static sites",
|
|
37
|
+
"webhooks", "api design", "markdown", "rate limiting", "feature flags"]
|
|
38
|
+
QUERY_SHAPES = [
|
|
39
|
+
("{t}", 1.0), ("what is {t}", 0.6), ("{t} tutorial", 0.7), ("{t} examples", 0.5),
|
|
40
|
+
("best {t} tools", 0.4), ("{t} vs {u}", 0.3), ("how to use {t}", 0.5),
|
|
41
|
+
("{t} explained", 0.35), ("free {t}", 0.3), ("{t} guide", 0.45), ("{t} for beginners", 0.4),
|
|
42
|
+
("open source {t}", 0.3), ("{t} cheat sheet", 0.25), ("{t} pricing", 0.2),
|
|
43
|
+
]
|
|
44
|
+
PAGE_SHAPES = ["/", "/docs/", "/docs/getting-started/", "/blog/{s}/", "/guides/{s}/", "/pricing/",
|
|
45
|
+
"/examples/", "/about/", "/blog/{s}-explained/", "/compare/{s}-vs-alternatives/"]
|
|
46
|
+
|
|
47
|
+
# (source, medium, share of sessions, is an AI assistant). Shared by the 90-day
|
|
48
|
+
# sources aggregate and the daily source series so the two agree. The AI rows
|
|
49
|
+
# are the ones that grow across the window in the series.
|
|
50
|
+
SOURCE_MIX = [
|
|
51
|
+
("google", "organic", 0.58, False),
|
|
52
|
+
("(direct)", "(none)", 0.20, False),
|
|
53
|
+
("bing", "organic", 0.05, False),
|
|
54
|
+
("chatgpt.com", "referral", 0.045, True),
|
|
55
|
+
("perplexity.ai", "referral", 0.015, True),
|
|
56
|
+
("github.com", "referral", 0.03, False),
|
|
57
|
+
("duckduckgo", "organic", 0.02, False),
|
|
58
|
+
("t.co", "referral", 0.015, False),
|
|
59
|
+
("news.ycombinator.com", "referral", 0.02, False),
|
|
60
|
+
("claude.ai", "referral", 0.01, True),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def slugify(s):
|
|
65
|
+
return s.lower().replace(" ", "-")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def weekly(d, base, growth=0.0, day0=None):
|
|
69
|
+
"""Weekday-shaped daily value with mild noise and optional linear growth."""
|
|
70
|
+
dow = d.weekday()
|
|
71
|
+
shape = [1.0, 1.05, 1.05, 1.0, 0.9, 0.55, 0.5][dow]
|
|
72
|
+
g = 1.0 + growth * ((d - day0).days / 365) if day0 else 1.0
|
|
73
|
+
return max(0, base * shape * g * rng.uniform(0.8, 1.2))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def expected_ctr(pos):
|
|
77
|
+
table = {1: .28, 2: .15, 3: .10, 4: .07, 5: .05, 6: .04, 7: .035, 8: .03, 9: .026, 10: .022}
|
|
78
|
+
if pos <= 10:
|
|
79
|
+
return table[max(1, round(pos))]
|
|
80
|
+
return max(0.004, 0.02 * math.exp(-(pos - 10) / 8))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def site_queries(site, idx):
|
|
84
|
+
"""A few dozen queries for one host, with some deliberately in striking
|
|
85
|
+
distance (pos 5-15) and some with CTR gaps (pos 1-6, half the expected CTR)."""
|
|
86
|
+
host = site["gscHost"]
|
|
87
|
+
brand = site.get("brand") or host.split(".")[0]
|
|
88
|
+
topics = TOPICS[idx * 3: idx * 3 + 3] or TOPICS[:3]
|
|
89
|
+
pages = [f"https://{host}" + p.format(s=slugify(topics[i % len(topics)])) for i, p in enumerate(PAGE_SHAPES)]
|
|
90
|
+
rows = []
|
|
91
|
+
n = 0
|
|
92
|
+
for t in topics:
|
|
93
|
+
for shape, weight in QUERY_SHAPES:
|
|
94
|
+
q = shape.format(t=t, u=topics[(topics.index(t) + 1) % len(topics)])
|
|
95
|
+
n += 1
|
|
96
|
+
role = n % 5
|
|
97
|
+
if role == 0: # striking distance
|
|
98
|
+
pos = rng.uniform(5.5, 14)
|
|
99
|
+
imps = int(rng.uniform(40, 400) * weight * 3)
|
|
100
|
+
ctr = expected_ctr(pos) * rng.uniform(0.8, 1.2)
|
|
101
|
+
elif role == 1: # CTR gap: ranks well, rarely clicked
|
|
102
|
+
pos = rng.uniform(1.2, 5.8)
|
|
103
|
+
imps = int(rng.uniform(80, 600) * weight * 3)
|
|
104
|
+
ctr = expected_ctr(pos) * rng.uniform(0.2, 0.45)
|
|
105
|
+
else:
|
|
106
|
+
pos = rng.uniform(1.5, 40)
|
|
107
|
+
imps = int(rng.uniform(10, 900) * weight * 3)
|
|
108
|
+
ctr = expected_ctr(pos) * rng.uniform(0.7, 1.4)
|
|
109
|
+
page = pages[n % len(pages)]
|
|
110
|
+
rows.append((q, page, imps, ctr, pos))
|
|
111
|
+
# branded head term
|
|
112
|
+
rows.append((brand, f"https://{host}/", int(rng.uniform(1500, 4000)), 0.42, 1.1))
|
|
113
|
+
rows.append((f"{brand} docs", f"https://{host}/docs/", int(rng.uniform(200, 600)), 0.31, 1.4))
|
|
114
|
+
return rows
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def gsc_row(keys, imps, ctr, pos):
|
|
118
|
+
clicks = round(imps * ctr)
|
|
119
|
+
return {"keys": keys, "clicks": clicks, "impressions": imps,
|
|
120
|
+
"ctr": (clicks / imps) if imps else 0, "position": round(pos, 2)}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def write_gsc(prop, slug, sites_in_prop, index_of):
|
|
124
|
+
d = DATA / "gsc" / slug
|
|
125
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
full_rows = []
|
|
127
|
+
for s in sites_in_prop:
|
|
128
|
+
full_rows += site_queries(s, index_of[s["host"]])
|
|
129
|
+
|
|
130
|
+
def dataset(rows, scale, suffix, start):
|
|
131
|
+
qp = [gsc_row([q, p], max(1, int(imps * scale)), ctr, pos * rng.uniform(0.95, 1.05))
|
|
132
|
+
for q, p, imps, ctr, pos in rows]
|
|
133
|
+
by_q, by_p = {}, {}
|
|
134
|
+
for r in qp:
|
|
135
|
+
for key, bucket in ((r["keys"][0], by_q), (r["keys"][1], by_p)):
|
|
136
|
+
cur = bucket.setdefault(key, {"clicks": 0, "impressions": 0, "posw": 0})
|
|
137
|
+
cur["clicks"] += r["clicks"]
|
|
138
|
+
cur["impressions"] += r["impressions"]
|
|
139
|
+
cur["posw"] += r["position"] * r["impressions"]
|
|
140
|
+
|
|
141
|
+
def agg(bucket):
|
|
142
|
+
out = []
|
|
143
|
+
for k, v in bucket.items():
|
|
144
|
+
out.append({"keys": [k], "clicks": v["clicks"], "impressions": v["impressions"],
|
|
145
|
+
"ctr": v["clicks"] / v["impressions"] if v["impressions"] else 0,
|
|
146
|
+
"position": round(v["posw"] / v["impressions"], 2) if v["impressions"] else 0})
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
meta = {"site": prop, "startDate": start.isoformat(), "endDate": END.isoformat()}
|
|
150
|
+
for name, dims, rows_ in (("query_page", ["query", "page"], qp),
|
|
151
|
+
("queries", ["query"], agg(by_q)),
|
|
152
|
+
("pages", ["page"], agg(by_p))):
|
|
153
|
+
(d / f"{name}{suffix}.json").write_text(json.dumps(
|
|
154
|
+
{**meta, "dimensions": dims, "rowCount": len(rows_), "rows": rows_}))
|
|
155
|
+
|
|
156
|
+
dataset(full_rows, 1.0, "", END - timedelta(days=FULL_DAYS))
|
|
157
|
+
dataset(full_rows, 0.22, "_90d", END - timedelta(days=RECENT_DAYS))
|
|
158
|
+
|
|
159
|
+
# dates.json — daily totals with seasonality and growth
|
|
160
|
+
total_imps = sum(r[2] for r in full_rows)
|
|
161
|
+
day0 = END - timedelta(days=FULL_DAYS)
|
|
162
|
+
dates = []
|
|
163
|
+
for i in range(FULL_DAYS):
|
|
164
|
+
day = day0 + timedelta(days=i)
|
|
165
|
+
imps = weekly(day, total_imps / FULL_DAYS * 0.8, growth=0.6, day0=day0)
|
|
166
|
+
clicks = imps * rng.uniform(0.045, 0.07)
|
|
167
|
+
dates.append({"keys": [day.isoformat()], "clicks": round(clicks), "impressions": round(imps),
|
|
168
|
+
"ctr": clicks / imps if imps else 0, "position": round(rng.uniform(9, 14), 1)})
|
|
169
|
+
(d / "dates.json").write_text(json.dumps(
|
|
170
|
+
{"site": prop, "dimensions": ["date"], "startDate": day0.isoformat(), "endDate": END.isoformat(),
|
|
171
|
+
"rowCount": len(dates), "rows": dates}))
|
|
172
|
+
return full_rows
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def ga4_report(dims, mets, rows):
|
|
176
|
+
return {
|
|
177
|
+
"dimensionHeaders": [{"name": n} for n in dims],
|
|
178
|
+
"metricHeaders": [{"name": n, "type": "TYPE_INTEGER"} for n in mets],
|
|
179
|
+
"rows": [{"dimensionValues": [{"value": str(v)} for v in dv],
|
|
180
|
+
"metricValues": [{"value": str(v)} for v in mv]} for dv, mv in rows],
|
|
181
|
+
"rowCount": len(rows),
|
|
182
|
+
"metadata": {"currencyCode": "USD", "timeZone": "UTC"},
|
|
183
|
+
"kind": "analyticsData#runReport",
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def write_ga4(site, idx, conv, gsc_rows):
|
|
188
|
+
host = site["host"]
|
|
189
|
+
d = DATA / "ga4" / host
|
|
190
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
base = [60, 25, 12][idx % 3] * (1 + idx * 0.3)
|
|
192
|
+
yesterday = TODAY - timedelta(days=1)
|
|
193
|
+
day0 = yesterday - timedelta(days=89)
|
|
194
|
+
daily = []
|
|
195
|
+
for i in range(90):
|
|
196
|
+
day = day0 + timedelta(days=i)
|
|
197
|
+
s = round(weekly(day, base, growth=0.5 if idx == 0 else -0.2, day0=day0))
|
|
198
|
+
daily.append(([day.strftime("%Y%m%d")], [s, round(s * 0.85)]))
|
|
199
|
+
total = sum(m[0] for _, m in daily)
|
|
200
|
+
meta = {"site": host, "property": f"properties/{site.get('ga4Property') or '000000000'}",
|
|
201
|
+
"pulled": TODAY.isoformat()}
|
|
202
|
+
(d / "daily.json").write_text(json.dumps({**meta, **ga4_report(["date"], ["sessions", "totalUsers"], daily)}))
|
|
203
|
+
|
|
204
|
+
mix = [(src, med, w) for src, med, w, _ai in SOURCE_MIX]
|
|
205
|
+
others = [h for h in seo_config.hosts() if h != host]
|
|
206
|
+
if others:
|
|
207
|
+
mix.append((others[0], "referral", 0.015))
|
|
208
|
+
sources = [([src, med], [round(total * w), round(total * w * 0.8)]) for src, med, w in mix]
|
|
209
|
+
(d / "sources.json").write_text(json.dumps({**meta, **ga4_report(["sessionSource", "sessionMedium"], ["sessions", "totalUsers"], sources)}))
|
|
210
|
+
|
|
211
|
+
pages = sorted({r[1] for r in gsc_rows if r[1].split("/")[2] == site["gscHost"]})
|
|
212
|
+
paths = [p.split(site["gscHost"], 1)[1].rstrip("/") or "/" for p in pages] or ["/", "/docs", "/pricing"]
|
|
213
|
+
landing = []
|
|
214
|
+
for i, p in enumerate(paths[:12]):
|
|
215
|
+
sessions = round(total * (0.35 if i == 0 else 0.6 / max(1, len(paths))) * rng.uniform(0.6, 1.4))
|
|
216
|
+
eng = rng.uniform(0.45, 0.72)
|
|
217
|
+
if i == 2: # one page that does not deliver what the click promised
|
|
218
|
+
sessions = max(sessions, 45)
|
|
219
|
+
eng = 0.18
|
|
220
|
+
landing.append(([p], [sessions, round(eng, 4)]))
|
|
221
|
+
landing.sort(key=lambda r: -r[1][0])
|
|
222
|
+
(d / "landing.json").write_text(json.dumps({**meta, **ga4_report(["landingPage"], ["sessions", "engagementRate"], landing)}))
|
|
223
|
+
|
|
224
|
+
if conv and conv.get("site") == host:
|
|
225
|
+
rows = []
|
|
226
|
+
for i in range(0, 90, 2):
|
|
227
|
+
day = (day0 + timedelta(days=i)).strftime("%Y%m%d")
|
|
228
|
+
for ev in conv.get("events", [])[:2]:
|
|
229
|
+
rows.append(([day, ev, rng.choice(["web", "docs", "(not set)"])], [rng.randint(0, 4)]))
|
|
230
|
+
(d / "funnel.json").write_text(json.dumps({**meta, **ga4_report(["date", "eventName", conv.get("sourceDimension") or "customEvent:source_app"], ["eventCount"], rows)}))
|
|
231
|
+
return paths, total
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def write_timeseries(prop, slug, sites_in_prop, gsc_rows, ga4_paths):
|
|
235
|
+
d = DATA / "timeseries"
|
|
236
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
day0 = END - timedelta(days=180)
|
|
238
|
+
pages = {}
|
|
239
|
+
for q, p, imps, ctr, pos in gsc_rows:
|
|
240
|
+
cur = pages.setdefault(p, [0, 0])
|
|
241
|
+
cur[0] += imps * 0.37
|
|
242
|
+
cur[1] += imps * ctr * 0.37
|
|
243
|
+
rows = []
|
|
244
|
+
for i in range(180):
|
|
245
|
+
day = day0 + timedelta(days=i)
|
|
246
|
+
for p, (imps, clicks) in pages.items():
|
|
247
|
+
di = weekly(day, imps / 180, growth=0.5, day0=day0)
|
|
248
|
+
dc = di * (clicks / imps if imps else 0.05)
|
|
249
|
+
if round(di) or round(dc):
|
|
250
|
+
rows.append({"keys": [day.isoformat(), p], "clicks": round(dc), "impressions": round(di),
|
|
251
|
+
"ctr": (dc / di) if di else 0, "position": round(rng.uniform(4, 20), 1)})
|
|
252
|
+
(d / f"gsc-{slug}.json").write_text(json.dumps(
|
|
253
|
+
{"site": prop, "startDate": day0.isoformat(), "endDate": END.isoformat(), "rows": rows}))
|
|
254
|
+
for s in sites_in_prop:
|
|
255
|
+
paths, total = ga4_paths.get(s["host"], ([], 0))
|
|
256
|
+
if not s.get("ga4Property"):
|
|
257
|
+
continue
|
|
258
|
+
rows = []
|
|
259
|
+
ga0 = TODAY - timedelta(days=180)
|
|
260
|
+
for i in range(180):
|
|
261
|
+
day = ga0 + timedelta(days=i)
|
|
262
|
+
for j, p in enumerate(paths[:8]):
|
|
263
|
+
v = weekly(day, (total / 90) * (0.35 if j == 0 else 0.08), day0=ga0)
|
|
264
|
+
if round(v):
|
|
265
|
+
rows.append({"date": day.strftime("%Y%m%d"), "page": p, "sessions": round(v)})
|
|
266
|
+
(d / f"ga4-{s['host']}.json").write_text(json.dumps({"site": s["host"], "rows": rows}))
|
|
267
|
+
|
|
268
|
+
# date x source/medium. AI assistants grow over the window and the
|
|
269
|
+
# rest hold roughly flat, so the demo actually shows the thing the
|
|
270
|
+
# AI chart exists to show.
|
|
271
|
+
rows = []
|
|
272
|
+
for i in range(180):
|
|
273
|
+
day = ga0 + timedelta(days=i)
|
|
274
|
+
ramp = 0.3 + 1.7 * (i / 179)
|
|
275
|
+
for src, med, w, g in SOURCE_MIX:
|
|
276
|
+
v = weekly(day, (total / 90) * w * (ramp if g else 1.0), day0=ga0)
|
|
277
|
+
if round(v):
|
|
278
|
+
rows.append({"date": day.strftime("%Y%m%d"), "source": src,
|
|
279
|
+
"medium": med, "sessions": round(v)})
|
|
280
|
+
(d / f"ga4-sources-{s['host']}.json").write_text(
|
|
281
|
+
json.dumps({"site": s["host"], "rows": rows}))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def write_probe(sites):
|
|
285
|
+
d = DATA / "probes"
|
|
286
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
out = []
|
|
288
|
+
for i, s in enumerate(sites):
|
|
289
|
+
healthy = i == 0
|
|
290
|
+
out.append({
|
|
291
|
+
"site": f"https://{s['gscHost']}",
|
|
292
|
+
"robots": {"status": 200, "exists": True, "sitemap_declared": True, "ai_crawlers_blocked": []},
|
|
293
|
+
"sitemap": {"status": 200, "exists": True, "url_count": 42 - i * 10,
|
|
294
|
+
"newest_lastmod": (TODAY - timedelta(days=2 + i * 9)).isoformat()},
|
|
295
|
+
"llms.txt": {"status": 200 if healthy else 404, "exists": healthy, "bytes": 2400 if healthy else 0},
|
|
296
|
+
"llms-full.txt": {"status": 200 if healthy else 404, "exists": healthy, "bytes": 18000 if healthy else 0},
|
|
297
|
+
"homepage": {"status": 200, "title": f"{s['label'].title()} — the friendly demo site",
|
|
298
|
+
"meta_description": "A demo site used to show what n-seo's dashboard looks like with data in it.",
|
|
299
|
+
"canonical": f"https://{s['gscHost']}/", "og_tags": 4 if healthy else 0,
|
|
300
|
+
"jsonld_types": ["WebSite", "Organization"] if healthy else [],
|
|
301
|
+
"h1_count": 1, "lang": "en", "visible_text_bytes": 2100 if healthy else 380},
|
|
302
|
+
"soft_404": {"status": 404, "real_404": True},
|
|
303
|
+
})
|
|
304
|
+
now = datetime.now(timezone.utc)
|
|
305
|
+
(d / f"probe-{now:%Y%m%d-%H%M%S}.json").write_text(json.dumps({"probed_at": now.isoformat(), "sites": out}, indent=2))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def write_metadata_audit(sites, gsc_by_host):
|
|
309
|
+
audit = {"generated": TODAY.isoformat(), "window": "90d", "sites": {}}
|
|
310
|
+
for i, s in enumerate(sites):
|
|
311
|
+
rows = gsc_by_host.get(s["host"], [])
|
|
312
|
+
by_page = {}
|
|
313
|
+
for q, p, imps, ctr, pos in rows:
|
|
314
|
+
by_page.setdefault(p, []).append({"q": q, "imps": int(imps * 0.22), "clicks": int(imps * 0.22 * ctr),
|
|
315
|
+
"pos": round(pos, 1), "ctr": round(ctr, 4)})
|
|
316
|
+
findings = []
|
|
317
|
+
for j, (page, qs) in enumerate(sorted(by_page.items(), key=lambda kv: -sum(q["imps"] for q in kv[1]))[:3]):
|
|
318
|
+
qs.sort(key=lambda q: -q["imps"])
|
|
319
|
+
imps = sum(q["imps"] for q in qs)
|
|
320
|
+
issues = [f"title misses ranking-query language: {', '.join(q['q'] for q in qs[:2])}"]
|
|
321
|
+
missed = 0
|
|
322
|
+
if j == 0:
|
|
323
|
+
missed = round(imps * 0.04)
|
|
324
|
+
issues.append(f"CTR below position expectation (~{missed} clicks missed in 90d)")
|
|
325
|
+
issues.append("meta description missing")
|
|
326
|
+
elif j == 1:
|
|
327
|
+
issues.append("title long (71 chars — SERP truncates ~60)")
|
|
328
|
+
else:
|
|
329
|
+
issues.append("meta description duplicates the title")
|
|
330
|
+
title = page.rstrip("/").rsplit("/", 1)[-1].replace("-", " ").title() or s["label"].title()
|
|
331
|
+
findings.append({"page": page, "title": f"{title} | {s['label'].title()}" + (" — everything you need to know about it and more" if j == 1 else ""),
|
|
332
|
+
"description": "" if j == 0 else f"{title} | {s['label'].title()}. Learn more.",
|
|
333
|
+
"imps": imps, "clicks": sum(q["clicks"] for q in qs), "issues": issues,
|
|
334
|
+
"top_queries": qs[:5], "missed_clicks_window": missed})
|
|
335
|
+
audit["sites"][s["host"]] = findings
|
|
336
|
+
(DATA / "metadata-audit.json").write_text(json.dumps(audit, indent=1))
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def write_index_status(sites, gsc_by_host):
|
|
340
|
+
out = {"generated": datetime.now(timezone.utc).isoformat(timespec="seconds"), "sites": {}}
|
|
341
|
+
for i, s in enumerate(sites):
|
|
342
|
+
if not s.get("gscProperty"):
|
|
343
|
+
continue
|
|
344
|
+
pages = sorted({r[1] for r in gsc_by_host.get(s["host"], [])})
|
|
345
|
+
checked = len(pages) + 12
|
|
346
|
+
problems = []
|
|
347
|
+
if pages:
|
|
348
|
+
problems.append({"url": pages[-1].rstrip("/") + "/changelog/", "coverage": "Discovered - currently not indexed",
|
|
349
|
+
"lastCrawl": None, "verdict": "NEUTRAL", "robots": "ALLOWED",
|
|
350
|
+
"canonicalMismatch": False, "googleCanonical": None})
|
|
351
|
+
problems.append({"url": pages[0].rstrip("/") + "/archive/2024/", "coverage": "Crawled - currently not indexed",
|
|
352
|
+
"lastCrawl": (TODAY - timedelta(days=20)).isoformat() + "T04:12:00Z", "verdict": "NEUTRAL",
|
|
353
|
+
"robots": "ALLOWED", "canonicalMismatch": False, "googleCanonical": None})
|
|
354
|
+
if i == 0:
|
|
355
|
+
problems.append({"url": pages[min(2, len(pages) - 1)].rstrip("/") + "/old-name/", "coverage": "Soft 404",
|
|
356
|
+
"lastCrawl": (TODAY - timedelta(days=140)).isoformat() + "T09:30:00Z", "verdict": "FAIL",
|
|
357
|
+
"robots": "ALLOWED", "canonicalMismatch": True,
|
|
358
|
+
"googleCanonical": pages[min(2, len(pages) - 1)]})
|
|
359
|
+
out["sites"][s["gscHost"]] = {
|
|
360
|
+
"property": s["gscProperty"], "checked": checked, "indexed": checked - len(problems),
|
|
361
|
+
"neverCrawled": sum(1 for p in problems if not p["lastCrawl"]),
|
|
362
|
+
"sitemap": {"submitted": 1, "entries": [{"path": f"https://{s['gscHost']}/sitemap.xml",
|
|
363
|
+
"lastSubmitted": (TODAY - timedelta(days=30)).isoformat() + "T00:00:00Z",
|
|
364
|
+
"lastDownloaded": (TODAY - timedelta(days=1)).isoformat() + "T06:00:00Z",
|
|
365
|
+
"pending": False, "errors": 0, "warnings": 0}]},
|
|
366
|
+
"problems": problems,
|
|
367
|
+
}
|
|
368
|
+
(DATA / "index-status.json").write_text(json.dumps(out, indent=2))
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def write_trends(props, sites, gsc_rows_by_prop):
|
|
372
|
+
out = {"generated": TODAY.isoformat(), "sites": {}, "ai_referrals": {}}
|
|
373
|
+
brands = seo_config.brand_patterns()
|
|
374
|
+
import re
|
|
375
|
+
for prop, slug in props.items():
|
|
376
|
+
rows = gsc_rows_by_prop[prop]
|
|
377
|
+
brand = re.compile(brands[prop], re.I) if prop in brands else None
|
|
378
|
+
recent = {q: int(imps * 0.2) for q, p, imps, ctr, pos in rows}
|
|
379
|
+
prior = {q: int(imps * 0.2 * rng.uniform(0.3, 1.6)) for q, p, imps, ctr, pos in rows}
|
|
380
|
+
# make some clear risers and fallers
|
|
381
|
+
for k, q in enumerate(list(recent)[:6]):
|
|
382
|
+
if k % 2 == 0:
|
|
383
|
+
prior[q] = max(0, int(recent[q] / 4))
|
|
384
|
+
else:
|
|
385
|
+
prior[q] = int(recent[q] * 2.5)
|
|
386
|
+
movers = []
|
|
387
|
+
for q in recent:
|
|
388
|
+
ri, pi = recent[q], prior[q]
|
|
389
|
+
if max(ri, pi) >= 30:
|
|
390
|
+
movers.append({"query": q, "recent_imps": ri, "prior_imps": pi, "delta": ri - pi,
|
|
391
|
+
"recent_pos": round(rng.uniform(3, 18), 1), "recent_clicks": int(ri * 0.05)})
|
|
392
|
+
movers.sort(key=lambda m: -abs(m["delta"]))
|
|
393
|
+
|
|
394
|
+
def split(d):
|
|
395
|
+
b = [q for q in d if brand and brand.search(q)]
|
|
396
|
+
g = [q for q in d if q not in b]
|
|
397
|
+
return {"branded_clicks": sum(d[q] * 0.3 for q in b), "generic_clicks": sum(d[q] * 0.05 for q in g),
|
|
398
|
+
"branded_imps": sum(d[q] for q in b), "generic_imps": sum(d[q] for q in g)}
|
|
399
|
+
|
|
400
|
+
monthly = {}
|
|
401
|
+
dates_file = DATA / "gsc" / slug / "dates.json"
|
|
402
|
+
for r in json.loads(dates_file.read_text())["rows"]:
|
|
403
|
+
m = r["keys"][0][:7]
|
|
404
|
+
cur = monthly.setdefault(m, {"clicks": 0, "imps": 0})
|
|
405
|
+
cur["clicks"] += r["clicks"]
|
|
406
|
+
cur["imps"] += r["impressions"]
|
|
407
|
+
out["sites"][prop] = {"recent_split": split(recent), "prior_split": split(prior),
|
|
408
|
+
"rising": [m for m in movers if m["delta"] > 0][:20],
|
|
409
|
+
"falling": [m for m in movers if m["delta"] < 0][:15], "monthly": monthly}
|
|
410
|
+
for i, s in enumerate(sites):
|
|
411
|
+
if not s.get("ga4Property"):
|
|
412
|
+
continue
|
|
413
|
+
ai, total = {}, {}
|
|
414
|
+
for k in range(12, 0, -1):
|
|
415
|
+
ym = (TODAY.replace(day=1) - timedelta(days=30 * k)).strftime("%Y%m")
|
|
416
|
+
t = round(1500 * (1 + i) * (1 + (12 - k) * 0.04) * rng.uniform(0.9, 1.1))
|
|
417
|
+
total[ym] = t
|
|
418
|
+
ai[ym] = round(t * (0.01 + (12 - k) * 0.004))
|
|
419
|
+
out["ai_referrals"][s["host"]] = {"ai": ai, "total": total}
|
|
420
|
+
(DATA / f"trends-{TODAY.isoformat()}.json").write_text(json.dumps(out, indent=1))
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def write_proposals(sites, gsc_by_host):
|
|
424
|
+
s0 = sites[0]
|
|
425
|
+
pages = sorted({r[1] for r in gsc_by_host.get(s0["host"], [])})
|
|
426
|
+
q = next((r for r in gsc_by_host.get(s0["host"], []) if 5 <= r[4] <= 15), None)
|
|
427
|
+
out = {
|
|
428
|
+
"generated": TODAY.isoformat(),
|
|
429
|
+
"candidates": [{"host": s0["gscHost"], "query": r[0], "recent_imps": int(r[2] * 0.2), "prior_imps": int(r[2] * 0.05),
|
|
430
|
+
"delta": int(r[2] * 0.15), "recent_pos": round(r[4], 1), "recent_clicks": int(r[2] * 0.2 * r[3])}
|
|
431
|
+
for r in gsc_by_host.get(s0["host"], [])[:4]],
|
|
432
|
+
"proposals": [
|
|
433
|
+
{"host": s0["host"], "title": f"New guide page for “{q[0] if q else 'rising query'}”",
|
|
434
|
+
"kind": "New top-level page",
|
|
435
|
+
"why": f"DEMO DATA — a rising query (pos {round(q[4], 1) if q else 9}) with no page that answers it directly; impressions quadrupled over 84 days",
|
|
436
|
+
"how": "Write an answer-formatted guide: the query as H1, a 40-60 word direct answer, then detail, FAQPage schema, internal links from the two closest pages.",
|
|
437
|
+
"spec": [f"URL: {pages[0].rstrip('/') if pages else 'https://example.com'}/guides/{slugify(q[0]) if q else 'topic'}/",
|
|
438
|
+
"Outline: direct answer -> how it works -> examples -> FAQ", "Schema: Article + FAQPage",
|
|
439
|
+
"Success: first-page ranking for the target query within 8 weeks"],
|
|
440
|
+
"impact": 40, "effort": "M", "tag": "content"},
|
|
441
|
+
{"host": (sites[1] if len(sites) > 1 else s0)["host"], "title": "Add llms.txt and server-render the homepage intro",
|
|
442
|
+
"kind": "Config/template change",
|
|
443
|
+
"why": "DEMO DATA — the probe shows no llms.txt and 380 bytes of visible text on the homepage; AI crawlers see an empty shell",
|
|
444
|
+
"how": "Publish /llms.txt (markdown summary + key links) and move the homepage H1 + intro paragraph into the server-rendered HTML.",
|
|
445
|
+
"spec": ["Serve /llms.txt and /llms-full.txt", "Static H1 + 2 paragraphs in the HTML shell",
|
|
446
|
+
"Verify with probes/site_probe.py after deploy"],
|
|
447
|
+
"impact": 15, "effort": "S", "tag": "hygiene"},
|
|
448
|
+
],
|
|
449
|
+
"verdicts": [{"title": "Rewrite title/description: /docs/getting-started/", "verdict": "keep-watching",
|
|
450
|
+
"evidence": "DEMO DATA — 12 days since the change; CTR up from 1.9% to 2.6% but the 28-day window has not closed."}],
|
|
451
|
+
"inference_ran": True,
|
|
452
|
+
}
|
|
453
|
+
(DATA / "opportunity-proposals.json").write_text(json.dumps(out, indent=1))
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def write_run_files():
|
|
457
|
+
ts = datetime.now(timezone.utc)
|
|
458
|
+
(DATA / "last-run.json").write_text(json.dumps({
|
|
459
|
+
"ts": ts.strftime("%Y-%m-%dT%H:%MZ"), "failures": "",
|
|
460
|
+
"steps": [{"name": n, "ok": True, "seconds": s} for n, s in
|
|
461
|
+
(("probe", 6.2), ("gsc", 41.0), ("ga4", 9.8), ("timeseries", 22.4), ("metadata-audit", 14.1),
|
|
462
|
+
("index-status", 38.5), ("opportunity-scan", 17.0), ("daily-diff", 0.3))]}, indent=1))
|
|
463
|
+
with (DATA / "daily-ops.log").open("a") as f:
|
|
464
|
+
f.write(f"=== daily run {ts:%Y-%m-%d %H:%M} (demo data) ===\n")
|
|
465
|
+
f.write("--- probe\n 2 sites probed\n--- gsc\n 2 properties pulled\n--- daily-diff\n daily-log updated (0 alerts)\n")
|
|
466
|
+
f.write("=== done (0 failures) ===\n")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def main():
|
|
470
|
+
if "--clean" in sys.argv:
|
|
471
|
+
if DATA.exists():
|
|
472
|
+
shutil.rmtree(DATA)
|
|
473
|
+
print(f"removed {DATA}")
|
|
474
|
+
else:
|
|
475
|
+
print("data/ does not exist")
|
|
476
|
+
return 0
|
|
477
|
+
|
|
478
|
+
cfg = seo_config.load()
|
|
479
|
+
sites = cfg["sites"]
|
|
480
|
+
if not sites:
|
|
481
|
+
print("no sites in config — nothing to generate")
|
|
482
|
+
return 1
|
|
483
|
+
if seo_config.using_example():
|
|
484
|
+
print("no n-seo.config.json — generating demo data for the example config's sites")
|
|
485
|
+
DATA.mkdir(parents=True, exist_ok=True)
|
|
486
|
+
index_of = {s["host"]: i for i, s in enumerate(sites)}
|
|
487
|
+
props = seo_config.gsc_properties(include_extra=False)
|
|
488
|
+
|
|
489
|
+
gsc_rows_by_prop, gsc_by_host = {}, {}
|
|
490
|
+
for prop, slug in props.items():
|
|
491
|
+
in_prop = [s for s in sites if s.get("gscProperty") == prop]
|
|
492
|
+
rows = write_gsc(prop, slug, in_prop, index_of)
|
|
493
|
+
gsc_rows_by_prop[prop] = rows
|
|
494
|
+
for s in in_prop:
|
|
495
|
+
gsc_by_host[s["host"]] = [r for r in rows if r[1].split("/")[2] == s["gscHost"]]
|
|
496
|
+
|
|
497
|
+
# the example config leaves ga4Property empty; the demo still needs GA4 files
|
|
498
|
+
ga4_paths = {}
|
|
499
|
+
for s in sites:
|
|
500
|
+
s.setdefault("ga4Property", None)
|
|
501
|
+
demo_site = dict(s, ga4Property=s.get("ga4Property") or str(100000000 + index_of[s["host"]]))
|
|
502
|
+
ga4_paths[s["host"]] = write_ga4(demo_site, index_of[s["host"]], cfg["conversions"], gsc_by_host.get(s["host"], []))
|
|
503
|
+
demo_sites = [dict(s, ga4Property=s.get("ga4Property") or str(100000000 + index_of[s["host"]])) for s in sites]
|
|
504
|
+
|
|
505
|
+
for prop, slug in props.items():
|
|
506
|
+
in_prop = [s for s in demo_sites if s.get("gscProperty") == prop]
|
|
507
|
+
write_timeseries(prop, slug, in_prop, gsc_rows_by_prop[prop], ga4_paths)
|
|
508
|
+
write_probe(sites)
|
|
509
|
+
write_metadata_audit(sites, gsc_by_host)
|
|
510
|
+
write_index_status(sites, gsc_by_host)
|
|
511
|
+
write_trends(props, demo_sites, gsc_rows_by_prop)
|
|
512
|
+
write_proposals(sites, gsc_by_host)
|
|
513
|
+
write_run_files()
|
|
514
|
+
|
|
515
|
+
written = sorted(str(p.relative_to(DATA.parent)) for p in DATA.rglob("*") if p.is_file())
|
|
516
|
+
print(f"wrote {len(written)} files under data/ for {', '.join(s['host'] for s in sites)}:")
|
|
517
|
+
for w in written:
|
|
518
|
+
print(" " + w)
|
|
519
|
+
if DATA.parent == seo_config.ROOT: # in-place: data lives inside the engine checkout
|
|
520
|
+
print("\nnow run: npm start (then open the dashboard)")
|
|
521
|
+
print("remove with: python3 ops/demo_data.py --clean")
|
|
522
|
+
else:
|
|
523
|
+
print("\nnow run: n-seo start (then open the dashboard)")
|
|
524
|
+
print("remove with: n-seo demo --clean")
|
|
525
|
+
return 0
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
if __name__ == "__main__":
|
|
529
|
+
sys.exit(main())
|