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
package/src/server.tsx ADDED
@@ -0,0 +1,260 @@
1
+ import { Hono } from "hono";
2
+ import { serve, type HttpBindings } from "@hono/node-server";
3
+ import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
4
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
+ import crypto from "node:crypto";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { ROOT, PORT, siteByHost, dotEnv } from "./config.js";
9
+ import { draftBySlug, campaignBySlug } from "./data.js";
10
+ import { allActions } from "./actions.js";
11
+ import { acceptProposal, setWatching, retire } from "./backlog.js";
12
+ import { createMcpServer } from "./mcp.js";
13
+ import { applySettingsForm, SettingsPage } from "./settings.js";
14
+ import {
15
+ ActionsPage, CampaignPage, ContentPage, DraftPage, IndexingPage, InsightsPage, Layout, LogsPage,
16
+ Overview, Probes, SiteDetail, TrendsPage, TREND_RANGES,
17
+ } from "./views.js";
18
+
19
+ const app = new Hono<{ Bindings: HttpBindings }>();
20
+
21
+ /* The dashboard has write endpoints (settings, backlog). Two guards keep them
22
+ local: the server binds 127.0.0.1 unless SEO_HOST says otherwise, and any
23
+ POST must come from this origin — a form on some other web page can reach
24
+ http://localhost:PORT without a CORS preflight, and settings include the
25
+ LLM command that ops/llm.py later executes. curl (no Origin) is allowed. */
26
+ app.use("*", async (c, next) => {
27
+ if (c.req.method !== "POST" || c.req.path === "/mcp") return next();
28
+ const origin = c.req.header("origin") ?? c.req.header("referer");
29
+ if (origin) {
30
+ let host = "";
31
+ try { host = new URL(origin).host; } catch { /* malformed → reject below */ }
32
+ if (host !== c.req.header("host")) return c.text("cross-origin POST rejected", 403);
33
+ }
34
+ return next();
35
+ });
36
+
37
+ app.get("/api/actions", (c) => c.json(allActions()));
38
+
39
+ /* ---- MCP over streamable HTTP ----
40
+ The stdio transport (src/mcp-stdio.ts) is the default and needs no secret.
41
+ This endpoint exists for clients that can't spawn a local process, so it
42
+ has to carry its own auth: a bearer token from SEO_MCP_TOKEN (or the file
43
+ ~/.config/n-seo/mcp-token). The dashboard binds every interface, so
44
+ with no token set this refuses to serve rather than exposing your search
45
+ data to the LAN. Stateless — a fresh server and transport per request. */
46
+ const MCP_TOKEN_FILE = path.join(process.env.HOME ?? "", ".config", "n-seo", "mcp-token");
47
+
48
+ function resolveMcpToken(): string | undefined {
49
+ const fromEnv = process.env.SEO_MCP_TOKEN?.trim() || dotEnv("SEO_MCP_TOKEN");
50
+ if (fromEnv) return fromEnv;
51
+ try {
52
+ const fromFile = fs.readFileSync(MCP_TOKEN_FILE, "utf8").trim();
53
+ return fromFile || undefined;
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ }
58
+
59
+ const MCP_TOKEN = resolveMcpToken();
60
+
61
+ const mcpAuthorized = (c: { req: { header: (k: string) => string | undefined } }) => {
62
+ const header = c.req.header("authorization") ?? "";
63
+ const presented = header.startsWith("Bearer ") ? header.slice(7).trim() : "";
64
+ if (!presented || !MCP_TOKEN) return false;
65
+ // Constant-time compare so a wrong token can't be recovered by timing.
66
+ const a = Buffer.from(presented);
67
+ const b = Buffer.from(MCP_TOKEN);
68
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
69
+ };
70
+
71
+ app.all("/mcp", async (c) => {
72
+ if (!MCP_TOKEN) {
73
+ return c.json({ error: "MCP endpoint disabled: set SEO_MCP_TOKEN (or ~/.config/n-seo/mcp-token) to enable it." }, 503);
74
+ }
75
+ if (!mcpAuthorized(c)) {
76
+ return c.json({ error: "unauthorized" }, 401, { "WWW-Authenticate": 'Bearer realm="n-seo"' });
77
+ }
78
+ const body = c.req.method === "POST" ? await c.req.json().catch(() => undefined) : undefined;
79
+ const server = createMcpServer();
80
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
81
+ c.env.outgoing.on("close", () => {
82
+ void transport.close();
83
+ void server.close();
84
+ });
85
+ await server.connect(transport);
86
+ await transport.handleRequest(c.env.incoming, c.env.outgoing, body);
87
+ return RESPONSE_ALREADY_SENT;
88
+ });
89
+
90
+ /* ---- static ---- */
91
+
92
+ app.get("/styles.css", (c) => {
93
+ const css = fs.readFileSync(path.join(ROOT, "public", "styles.css"), "utf8");
94
+ return c.text(css, 200, { "Content-Type": "text/css; charset=utf-8" });
95
+ });
96
+
97
+ app.get("/favicon.svg", (c) => {
98
+ const svg = fs.readFileSync(path.join(ROOT, "public", "favicon.svg"), "utf8");
99
+ return c.text(svg, 200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=86400" });
100
+ });
101
+
102
+ /* ---- pages ---- */
103
+
104
+ app.get("/", (c) =>
105
+ c.html(
106
+ <Layout title="Overview · n-seo" active="overview">
107
+ <Overview />
108
+ </Layout>
109
+ )
110
+ );
111
+
112
+ app.get("/actions", (c) =>
113
+ c.html(
114
+ <Layout title="Actions · n-seo" active="actions">
115
+ <ActionsPage flash={c.req.query("flash")} />
116
+ </Layout>
117
+ )
118
+ );
119
+
120
+ app.get("/content", (c) =>
121
+ c.html(
122
+ <Layout title="Content · n-seo" active="content">
123
+ <ContentPage />
124
+ </Layout>
125
+ )
126
+ );
127
+
128
+ app.get("/drafts/:slug", (c) => {
129
+ const d = draftBySlug(c.req.param("slug"));
130
+ if (!d) return c.notFound();
131
+ return c.html(
132
+ <Layout title={`Draft · ${d.channel} · n-seo`} active="content">
133
+ <DraftPage d={d} />
134
+ </Layout>
135
+ );
136
+ });
137
+
138
+ app.get("/campaigns/:slug", (c) => {
139
+ const camp = campaignBySlug(c.req.param("slug"));
140
+ if (!camp) return c.notFound();
141
+ return c.html(
142
+ <Layout title={`${camp.name} · n-seo`} active="content">
143
+ <CampaignPage c={camp} />
144
+ </Layout>
145
+ );
146
+ });
147
+
148
+ app.get("/insights", (c) =>
149
+ c.html(
150
+ <Layout title="Insights · n-seo" active="insights">
151
+ <InsightsPage />
152
+ </Layout>
153
+ )
154
+ );
155
+
156
+ app.get("/site/:host", (c) => {
157
+ const site = siteByHost(c.req.param("host"));
158
+ if (!site) return c.notFound();
159
+ return c.html(
160
+ <Layout title={`${site.host} · n-seo`} active={site.host}>
161
+ <SiteDetail site={site} />
162
+ </Layout>
163
+ );
164
+ });
165
+
166
+ /* The same window picker /trends has, scoped to one site. */
167
+ app.get("/site/:host/:days", (c) => {
168
+ const site = siteByHost(c.req.param("host"));
169
+ const days = Number(c.req.param("days"));
170
+ if (!site || !TREND_RANGES.includes(days)) return c.notFound();
171
+ return c.html(
172
+ <Layout title={`${site.host} · n-seo`} active={site.host}>
173
+ <SiteDetail site={site} days={days} />
174
+ </Layout>
175
+ );
176
+ });
177
+
178
+ app.get("/indexing", (c) =>
179
+ c.html(
180
+ <Layout title="Indexing · n-seo" active="indexing">
181
+ <IndexingPage />
182
+ </Layout>
183
+ )
184
+ );
185
+
186
+ app.get("/probes", (c) =>
187
+ c.html(
188
+ <Layout title="Probes · n-seo" active="probes">
189
+ <Probes />
190
+ </Layout>
191
+ )
192
+ );
193
+
194
+ app.get("/trends", (c) =>
195
+ c.html(
196
+ <Layout title="Trends · n-seo" active="trends">
197
+ <TrendsPage days={90} />
198
+ </Layout>
199
+ )
200
+ );
201
+ app.get("/trends/:days", (c) => {
202
+ const days = Number(c.req.param("days"));
203
+ if (!TREND_RANGES.includes(days)) return c.notFound();
204
+ return c.html(
205
+ <Layout title="Trends · n-seo" active="trends">
206
+ <TrendsPage days={days} />
207
+ </Layout>
208
+ );
209
+ });
210
+
211
+ app.get("/logs", (c) =>
212
+ c.html(
213
+ <Layout title="Logs · n-seo" active="logs">
214
+ <LogsPage />
215
+ </Layout>
216
+ )
217
+ );
218
+
219
+ app.get("/settings", (c) =>
220
+ c.html(
221
+ <Layout title="Settings · n-seo" active="settings">
222
+ <SettingsPage saved={c.req.query("saved") === "1"} error={c.req.query("error")} />
223
+ </Layout>
224
+ )
225
+ );
226
+
227
+ /* ---- writes: the only things that change files on disk ---- */
228
+
229
+ app.post("/settings", async (c) => {
230
+ try {
231
+ applySettingsForm(await c.req.parseBody({ all: true }));
232
+ return c.redirect("/settings?saved=1", 303);
233
+ } catch (err) {
234
+ return c.redirect(`/settings?error=${encodeURIComponent((err as Error).message.slice(0, 80))}`, 303);
235
+ }
236
+ });
237
+
238
+ app.post("/api/backlog/accept", async (c) => {
239
+ const body = await c.req.parseBody();
240
+ const index = Number(body.index);
241
+ const a = Number.isInteger(index) ? acceptProposal(index) : null;
242
+ const flash = a ? `Accepted “${a.title}” into config/backlog.json` : "Proposal not found (the scan file may have changed)";
243
+ return c.redirect(`/actions?flash=${encodeURIComponent(flash)}#active`, 303);
244
+ });
245
+
246
+ app.post("/api/backlog/:id/watch", async (c) => {
247
+ const body = await c.req.parseBody();
248
+ const ok = setWatching(c.req.param("id"), typeof body.note === "string" ? body.note : "");
249
+ return c.redirect(`/actions?flash=${encodeURIComponent(ok ? "Marked watching" : "Item not found in the backlog")}#watching`, 303);
250
+ });
251
+
252
+ app.post("/api/backlog/:id/retire", (c) => {
253
+ const ok = retire(c.req.param("id"));
254
+ return c.redirect(`/actions?flash=${encodeURIComponent(ok ? "Retired from the backlog" : "Item not found in the backlog")}`, 303);
255
+ });
256
+
257
+ const HOST = process.env.SEO_HOST ?? "127.0.0.1";
258
+ serve({ fetch: app.fetch, port: PORT, hostname: HOST }, (info) => {
259
+ console.log(`n-seo → http://${HOST === "0.0.0.0" ? "localhost" : HOST}:${info.port}`);
260
+ });
@@ -0,0 +1,329 @@
1
+ /** Settings page: the module switchboard and the few config fields worth a
2
+ * form. Sites are edited in the JSON file (they need care: property ids,
3
+ * brand regexes) — the page shows them and says where to go. */
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import type { FC } from "hono/jsx";
8
+ import { CONFIG_PATH, MODULE_INFO, config, saveConfig, USING_EXAMPLE_CONFIG, loadConfig, engineInfo } from "./config.js";
9
+ import * as data from "./data.js";
10
+
11
+ const expand = (p: string) => (p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p);
12
+
13
+ function serviceAccountEmail(keyPath?: string): { email?: string; exists: boolean; path?: string } {
14
+ const p = keyPath || process.env.GOOGLE_APPLICATION_CREDENTIALS;
15
+ if (!p) return { exists: false };
16
+ const full = expand(p);
17
+ try {
18
+ // Read only the public identity — never the private key.
19
+ const j = JSON.parse(fs.readFileSync(full, "utf8")) as { client_email?: string };
20
+ return { email: j.client_email, exists: true, path: full };
21
+ } catch {
22
+ return { exists: false, path: full };
23
+ }
24
+ }
25
+
26
+ const lines = (v: unknown) => (Array.isArray(v) ? v : []).map((t) => (Array.isArray(t) ? t.join(" | ") : String(t))).join("\n");
27
+
28
+ export const SettingsPage: FC<{ saved?: boolean; error?: string }> = ({ saved, error }) => {
29
+ loadConfig();
30
+ const cfg = config();
31
+ const sa = serviceAccountEmail(cfg.google.serviceAccountKey);
32
+ const lr = data.lastRun();
33
+ const eng = engineInfo();
34
+ const hookCount = cfg.hooks.beforeRun.length + cfg.hooks.afterRun.length + Object.values(cfg.hooks.afterStep).reduce((n, l) => n + l.length, 0);
35
+ const str = (v: unknown) => (v == null ? "" : String(v));
36
+ return (
37
+ <>
38
+ <div class="strip-head">
39
+ <h1>Settings</h1>
40
+ <div class="chip-row">
41
+ {saved && <span class="chip good">saved</span>}
42
+ {error && <span class="chip bad">{error}</span>}
43
+ <span class="chip mono" title="config file">{USING_EXAMPLE_CONFIG ? "example config (not saved yet)" : CONFIG_PATH}</span>
44
+ </div>
45
+ </div>
46
+ <p class="sub">
47
+ Everything runs from one file: <code>{CONFIG_PATH}</code>. This page edits the module switches and the free-text
48
+ fields; sites and Google auth are edited in the file itself (they need care). {USING_EXAMPLE_CONFIG && <b>Saving this form creates the file from the example.</b>}
49
+ </p>
50
+
51
+ <h2>Engine <small>— what is running, and where</small></h2>
52
+ <div class="settings-grid">
53
+ <div class="s-card">
54
+ <div class="s-title">n-seo {eng.version}{eng.commit ? ` · ${eng.commit}` : ""}</div>
55
+ <p class="sub"><span class={`chip ${eng.mode === "instance" ? "good" : ""}`}>{eng.mode}</span> {eng.mode === "instance"
56
+ ? "the engine and this instance live in separate directories; upgrading the engine does not touch your config, queue or data."
57
+ : "config, queue and data live inside the engine checkout. Fine for one person; see docs/INSTANCE.md to split them."}</p>
58
+ </div>
59
+ <div class="s-card">
60
+ <div class="s-title">Engine path</div>
61
+ <div class="mono">{eng.root}</div>
62
+ <p class="sub">Upgrade: <code>n-seo upgrade</code> (git engine) or <code>npm update n-seo</code> (npm engine).</p>
63
+ </div>
64
+ <div class="s-card">
65
+ <div class="s-title">Instance path</div>
66
+ <div class="mono">{eng.instance}</div>
67
+ <p class="sub">{hookCount ? `${hookCount} hook command(s) configured` : "no hooks configured"} — <code>hooks</code> in the config file runs your own commands around the daily run.</p>
68
+ {hookCount > 0 && (
69
+ <ul class="spec">
70
+ {cfg.hooks.beforeRun.map((c) => <li><span class="chip">before run</span> <code>{c}</code></li>)}
71
+ {Object.entries(cfg.hooks.afterStep).flatMap(([step, cmds]) => cmds.map((c) => <li><span class="chip">after {step}</span> <code>{c}</code></li>))}
72
+ {cfg.hooks.afterRun.map((c) => <li><span class="chip">after run</span> <code>{c}</code></li>)}
73
+ </ul>
74
+ )}
75
+ </div>
76
+ </div>
77
+
78
+ <h2>Sites <small>— edit <code>sites[]</code> in the config file</small></h2>
79
+ {cfg.sites.length ? (
80
+ <div class="tbl-wrap">
81
+ <table>
82
+ <thead><tr><th>host</th><th>Search Console property</th><th>GA4 property</th><th>brand regex</th><th>hosting</th><th>repo</th></tr></thead>
83
+ <tbody>
84
+ {cfg.sites.map((s) => (
85
+ <tr>
86
+ <td class="mono">{s.host}{s.gscHost !== s.host && <span class="chip"> pages on {s.gscHost}</span>}</td>
87
+ <td class="mono">{s.gscProperty ?? <span class="bad-text">none</span>}</td>
88
+ <td class="mono">{s.ga4Property ?? <span class="muted">none</span>}</td>
89
+ <td class="mono">{s.brand ?? "—"}</td>
90
+ <td>{s.hosting ?? "—"}</td>
91
+ <td class="mono">{s.repo ?? "—"}</td>
92
+ </tr>
93
+ ))}
94
+ </tbody>
95
+ </table>
96
+ </div>
97
+ ) : (
98
+ <p class="empty">No sites yet.</p>
99
+ )}
100
+ <p class="sub">Each site needs at least <code>host</code> and <code>gscProperty</code> (<code>sc-domain:example.com</code> or <code>https://www.example.com/</code>). Add <code>ga4Property</code> (the numeric id from GA4 Admin → Property details) for sessions, landing pages and AI-referral tracking, and <code>brand</code> (a regex) so the trend analysis can split branded from generic demand. See docs/SETUP-GOOGLE.md.</p>
101
+
102
+ <h2>Google access <small>— edit <code>google</code> in the config file</small></h2>
103
+ <div class="settings-grid">
104
+ <div class="s-card">
105
+ <div class="s-title">Mode</div>
106
+ <div class="mono">{cfg.google.auth}</div>
107
+ <p class="sub">{cfg.google.auth === "service-account-key"
108
+ ? "A service-account JSON key, signed locally with openssl. No gcloud needed."
109
+ : cfg.google.auth === "metadata"
110
+ ? "The runtime service account from the GCE / Cloud Run / GKE metadata server — no key file. It mints its own scoped tokens, which needs Token Creator on itself."
111
+ : cfg.google.auth === "gcloud-impersonate"
112
+ ? `gcloud impersonates ${cfg.google.impersonate || "(impersonate not set)"} — your login needs Token Creator on it.`
113
+ : "gcloud user login — only works if that login already carries the Search Console / Analytics scopes."}</p>
114
+ </div>
115
+ {cfg.google.auth === "service-account-key" && (
116
+ <div class="s-card">
117
+ <div class="s-title">Key file</div>
118
+ <div class="mono">{sa.path ?? "(not set)"}</div>
119
+ <p class="sub">{sa.exists ? <span class="chip good">found</span> : <span class="chip bad">not found</span>}</p>
120
+ </div>
121
+ )}
122
+ <div class="s-card">
123
+ <div class="s-title">Grant access to</div>
124
+ <div class="mono">{sa.email ?? cfg.google.impersonate ?? (cfg.google.auth === "metadata" ? "the runtime service account" : "—")}</div>
125
+ <p class="sub">Add this email as a <b>Full</b> user on each Search Console property and a <b>Viewer</b> on each GA4 property. Then <code>python3 ops/doctor.py</code> confirms it can see them.</p>
126
+ </div>
127
+ </div>
128
+
129
+ <form method="post" action="/settings">
130
+ <h2>Modules <small>— everything below is opt-in; nothing posts, sends or publishes for you</small></h2>
131
+ <div class="settings-grid">
132
+ {MODULE_INFO.map((m) => {
133
+ const mod = cfg.modules[m.key] ?? { enabled: false };
134
+ return (
135
+ <div class={`s-card ${mod.enabled ? "s-on" : ""}`}>
136
+ <label class="s-toggle">
137
+ <input type="checkbox" name={`module.${m.key}`} value="1" checked={!!mod.enabled} />
138
+ <span class="s-title">{m.title}</span>
139
+ </label>
140
+ <p class="sub">{m.blurb}</p>
141
+ {m.needs && <p class="s-needs">needs: {m.needs}</p>}
142
+ {m.key === "llm" && (
143
+ <>
144
+ <label class="s-field">command <input type="text" name="llm.command" value={str(mod.command)} placeholder="claude -p --model sonnet" /></label>
145
+ <label class="s-field">fast command <input type="text" name="llm.fastCommand" value={str(mod.fastCommand)} placeholder="claude -p --model haiku" /></label>
146
+ <p class="s-help">Any CLI that reads the prompt on stdin and prints the reply: <code>claude -p</code>, <code>llm</code>, <code>ollama run llama3</code>.</p>
147
+ {(() => {
148
+ // A server has no CLI signed in, so it uses the http
149
+ // block instead. Edited in the config file, shown here
150
+ // so it is obvious which path is actually live.
151
+ const h = mod.http as Record<string, unknown> | undefined;
152
+ if (!h || typeof h !== "object" || !h.provider) return null;
153
+ return (
154
+ <p class="s-help">
155
+ <b>http overrides the command</b> when its key resolves: {str(h.provider)} · {str(h.model)}
156
+ {h.fastModel ? ` (fast: ${str(h.fastModel)})` : ""} · key from <code>{str(h.apiKeyEnv)}</code>
157
+ </p>
158
+ );
159
+ })()}
160
+ </>
161
+ )}
162
+ {m.key === "hackerNews" && (
163
+ <>
164
+ <label class="s-field">HN username <input type="text" name="hackerNews.user" value={str(mod.user)} /></label>
165
+ <label class="s-field">topics <small>one per line: <code>query | why you can speak to this</code></small>
166
+ <textarea name="hackerNews.topics" rows={4}>{lines(mod.topics)}</textarea>
167
+ </label>
168
+ </>
169
+ )}
170
+ {m.key === "reddit" && (
171
+ <>
172
+ <label class="s-field">Reddit username <input type="text" name="reddit.user" value={str(mod.user)} /></label>
173
+ <label class="s-field">topics <small>one per line: <code>subreddit | search query | why</code></small>
174
+ <textarea name="reddit.topics" rows={4}>{lines(mod.topics)}</textarea>
175
+ </label>
176
+ </>
177
+ )}
178
+ {m.key === "indexNow" && (
179
+ <label class="s-field">key file <input type="text" name="indexNow.keyFile" value={str(mod.keyFile) || "indexnow.key"} /></label>
180
+ )}
181
+ {m.key === "staticExport" && (
182
+ <>
183
+ <label class="s-field">sign-out URL <small>added to every exported page when the mirror sits behind an auth proxy</small>
184
+ <input type="text" name="staticExport.signOutUrl" value={str(mod.signOutUrl)} placeholder="/oauth2/sign_out" />
185
+ </label>
186
+ <label class="s-field">sign-out label <input type="text" name="staticExport.signOutLabel" value={str(mod.signOutLabel) || "Sign out"} /></label>
187
+ </>
188
+ )}
189
+ {m.key === "publish" && (
190
+ <>
191
+ <label class="s-field">target
192
+ <select name="publish.target">
193
+ {["gcs", "s3", "rsync", "command"].map((t) => (
194
+ <option value={t} selected={str(mod.target) === t}>{t}</option>
195
+ ))}
196
+ </select>
197
+ </label>
198
+ <label class="s-field">destination <input type="text" name="publish.destination" value={str(mod.destination)} placeholder="gs://your-bucket" /></label>
199
+ <label class="s-check"><input type="checkbox" name="publish.delete" value="1" checked={!!mod.delete} /> delete what is no longer in the export</label>
200
+ <label class="s-check"><input type="checkbox" name="publish.dryRun" value="1" checked={!!mod.dryRun} /> dry run — print the command, publish nothing</label>
201
+ {str(mod.command) && (
202
+ <p class="s-help"><b>command</b> (target <code>command</code>, edited in the config file): <code>{str(mod.command)}</code></p>
203
+ )}
204
+ {(() => {
205
+ // Names only: a publish env is where credential paths live.
206
+ const keys = Object.keys((mod.env as Record<string, unknown>) ?? {});
207
+ return keys.length
208
+ ? <p class="s-help">extra environment for the publish command: <code>{keys.join(", ")}</code> <small>(values are edited in the config file and never shown here)</small></p>
209
+ : null;
210
+ })()}
211
+ </>
212
+ )}
213
+ </div>
214
+ );
215
+ })}
216
+ </div>
217
+
218
+ <h2>You <small>— context for briefings</small></h2>
219
+ <div class="s-card s-wide">
220
+ <label class="s-field">expertise <small>who you are and what you genuinely know first-hand; the only context the digest briefings get</small>
221
+ <textarea name="participation.expertise" rows={4}>{cfg.participation?.expertise ?? ""}</textarea>
222
+ </label>
223
+ </div>
224
+
225
+ <h2>Watch pages <small>— reported in the daily log every day</small></h2>
226
+ <div class="s-card s-wide">
227
+ <label class="s-field">URLs <small>one per line — pages where you shipped something and want the numbers in front of you daily</small>
228
+ <textarea name="watchPages" rows={4}>{cfg.watchPages.join("\n")}</textarea>
229
+ </label>
230
+ </div>
231
+
232
+ <h2>Conversions <small>— the goal the traffic serves</small></h2>
233
+ <div class="s-card s-wide">
234
+ <div class="s-row">
235
+ <label class="s-field">site (host) <input type="text" name="conversions.site" value={cfg.conversions?.site ?? ""} placeholder="example.com" /></label>
236
+ <label class="s-field">GA4 event names <small>comma-separated; the first is the primary</small><input type="text" name="conversions.events" value={(cfg.conversions?.events ?? []).join(", ")} placeholder="sign_up, newsletter_signup" /></label>
237
+ <label class="s-field">source dimension <small>optional custom dimension</small><input type="text" name="conversions.sourceDimension" value={cfg.conversions?.sourceDimension ?? ""} placeholder="customEvent:source_app" /></label>
238
+ </div>
239
+ <p class="s-help">Register these as key events in GA4 Admin. Leave the site empty to hide the conversions panel.</p>
240
+ </div>
241
+
242
+ <div class="s-save">
243
+ <button type="submit" class="btn">Save settings</button>
244
+ <span class="sub">writes {CONFIG_PATH}</span>
245
+ </div>
246
+ </form>
247
+
248
+ <h2>Status</h2>
249
+ <div class="settings-grid">
250
+ <div class="s-card">
251
+ <div class="s-title">Last daily run</div>
252
+ {lr ? (
253
+ <>
254
+ <div class="mono">{lr.ts}</div>
255
+ <p class="sub">{lr.failures.trim() ? <span class="chip bad">failed: {lr.failures}</span> : <span class="chip good">all steps OK</span>}</p>
256
+ </>
257
+ ) : <p class="sub">never — run <code>python3 ops/daily.py</code></p>}
258
+ </div>
259
+ <div class="s-card">
260
+ <div class="s-title">Data freshness</div>
261
+ <table class="slim bare">
262
+ {data.dataFreshness().map((f) => (
263
+ <tr><td>{f.label}</td><td class="mono">{f.mtime}</td></tr>
264
+ ))}
265
+ </table>
266
+ </div>
267
+ <div class="s-card">
268
+ <div class="s-title">This process</div>
269
+ {(() => { const p = data.processFreshness(); return (
270
+ <p class="sub">started {p.startedAt} · up {p.uptimeHours}h · queue file {p.queueMtime}</p>
271
+ ); })()}
272
+ </div>
273
+ </div>
274
+ </>
275
+ );
276
+ };
277
+
278
+ /** Apply a posted settings form to the config file. Only the keys this page
279
+ * owns are touched; everything else in the JSON is preserved verbatim. */
280
+ export function applySettingsForm(body: Record<string, string | File | (string | File)[]>): void {
281
+ const get = (k: string): string => {
282
+ const v = body[k];
283
+ return typeof v === "string" ? v : Array.isArray(v) && typeof v[0] === "string" ? v[0] : "";
284
+ };
285
+ const splitLines = (s: string) => s.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
286
+ const topicLines = (s: string, parts: number, label: string) =>
287
+ splitLines(s).map((l) => {
288
+ const cols = l.split("|").map((x) => x.trim());
289
+ if (cols.length < parts || cols.slice(0, parts).some((x) => !x)) {
290
+ throw new Error(`${label}: each line needs ${parts} parts separated by | — "${l.slice(0, 40)}"`);
291
+ }
292
+ return cols.slice(0, parts);
293
+ });
294
+
295
+ saveConfig((raw) => {
296
+ const modules = ((raw.modules as Record<string, Record<string, unknown>>) ??= {});
297
+ for (const m of MODULE_INFO) {
298
+ const cur = (modules[m.key] ??= {});
299
+ cur.enabled = get(`module.${m.key}`) === "1";
300
+ }
301
+ modules.llm.command = get("llm.command") || "claude -p --model sonnet";
302
+ modules.llm.fastCommand = get("llm.fastCommand") || modules.llm.command;
303
+ modules.hackerNews.user = get("hackerNews.user");
304
+ modules.hackerNews.topics = topicLines(get("hackerNews.topics"), 2, "Hacker News topics");
305
+ modules.reddit.user = get("reddit.user");
306
+ modules.reddit.topics = topicLines(get("reddit.topics"), 3, "Reddit topics");
307
+ modules.indexNow.keyFile = get("indexNow.keyFile") || "indexnow.key";
308
+ modules.staticExport.signOutUrl = get("staticExport.signOutUrl").trim();
309
+ modules.staticExport.signOutLabel = get("staticExport.signOutLabel").trim() || "Sign out";
310
+ // `command` and `env` are deliberately not settable here: one runs a
311
+ // shell string, the other holds credential paths. Both live in the file.
312
+ const target = get("publish.target").trim();
313
+ modules.publish.target = ["gcs", "s3", "rsync", "command"].includes(target) ? target : "gcs";
314
+ modules.publish.destination = get("publish.destination").trim();
315
+ modules.publish.delete = get("publish.delete") === "1";
316
+ modules.publish.dryRun = get("publish.dryRun") === "1";
317
+
318
+ const participation = ((raw.participation as Record<string, unknown>) ??= {});
319
+ participation.expertise = get("participation.expertise");
320
+
321
+ raw.watchPages = splitLines(get("watchPages"));
322
+
323
+ const site = get("conversions.site").trim();
324
+ const conv = ((raw.conversions as Record<string, unknown>) ??= {});
325
+ conv.site = site;
326
+ conv.events = get("conversions.events").split(",").map((e) => e.trim()).filter(Boolean);
327
+ conv.sourceDimension = get("conversions.sourceDimension").trim();
328
+ });
329
+ }