n-seo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/.env.example +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +184 -0
  4. package/bin/n-seo.mjs +310 -0
  5. package/docs/ADDING-A-SITE.md +82 -0
  6. package/docs/ARCHITECTURE.md +213 -0
  7. package/docs/DEPLOY.md +300 -0
  8. package/docs/FAQ.md +93 -0
  9. package/docs/INSTANCE.md +365 -0
  10. package/docs/MCP.md +104 -0
  11. package/docs/OPERATING-RULES.md +106 -0
  12. package/docs/PLAYBOOK.md +122 -0
  13. package/docs/PRD.md +249 -0
  14. package/docs/RELEASING.md +189 -0
  15. package/docs/SCHEDULING.md +104 -0
  16. package/docs/SETUP-GOOGLE.md +215 -0
  17. package/docs/examples/campaign.json +59 -0
  18. package/docs/examples/draft.md +43 -0
  19. package/docs/screenshots/overview.png +0 -0
  20. package/ingest/__pycache__/analyze_ga4.cpython-313.pyc +0 -0
  21. package/ingest/__pycache__/analyze_gsc.cpython-313.pyc +0 -0
  22. package/ingest/__pycache__/analyze_metadata.cpython-313.pyc +0 -0
  23. package/ingest/__pycache__/analyze_trends.cpython-313.pyc +0 -0
  24. package/ingest/__pycache__/google_auth.cpython-313.pyc +0 -0
  25. package/ingest/__pycache__/http_util.cpython-313.pyc +0 -0
  26. package/ingest/__pycache__/pull_ga4.cpython-313.pyc +0 -0
  27. package/ingest/__pycache__/pull_gsc.cpython-313.pyc +0 -0
  28. package/ingest/__pycache__/pull_index_status.cpython-313.pyc +0 -0
  29. package/ingest/__pycache__/pull_timeseries.cpython-313.pyc +0 -0
  30. package/ingest/__pycache__/seo_config.cpython-313.pyc +0 -0
  31. package/ingest/analyze_ga4.py +79 -0
  32. package/ingest/analyze_gsc.py +136 -0
  33. package/ingest/analyze_metadata.py +158 -0
  34. package/ingest/analyze_trends.py +145 -0
  35. package/ingest/google_auth.py +238 -0
  36. package/ingest/http_util.py +87 -0
  37. package/ingest/pull_ga4.py +107 -0
  38. package/ingest/pull_gsc.py +111 -0
  39. package/ingest/pull_index_status.py +179 -0
  40. package/ingest/pull_timeseries.py +130 -0
  41. package/ingest/seo_config.py +213 -0
  42. package/n-seo.config.example.json +110 -0
  43. package/ops/__pycache__/daily.cpython-313.pyc +0 -0
  44. package/ops/__pycache__/daily_diff.cpython-313.pyc +0 -0
  45. package/ops/__pycache__/demo_data.cpython-313.pyc +0 -0
  46. package/ops/__pycache__/doctor.cpython-313.pyc +0 -0
  47. package/ops/__pycache__/export_static.cpython-313.pyc +0 -0
  48. package/ops/__pycache__/hn_digest.cpython-313.pyc +0 -0
  49. package/ops/__pycache__/indexnow.cpython-313.pyc +0 -0
  50. package/ops/__pycache__/llm.cpython-313.pyc +0 -0
  51. package/ops/__pycache__/opportunity_scan.cpython-313.pyc +0 -0
  52. package/ops/__pycache__/publish.cpython-313.pyc +0 -0
  53. package/ops/__pycache__/reddit_digest.cpython-313.pyc +0 -0
  54. package/ops/daily.py +250 -0
  55. package/ops/daily_diff.py +151 -0
  56. package/ops/demo_data.py +529 -0
  57. package/ops/doctor.py +266 -0
  58. package/ops/export_static.py +125 -0
  59. package/ops/hn_digest.py +169 -0
  60. package/ops/indexnow.py +107 -0
  61. package/ops/install-launchd.sh +76 -0
  62. package/ops/llm.py +139 -0
  63. package/ops/mcp-smoke-stdio.mjs +61 -0
  64. package/ops/opportunity_scan.py +185 -0
  65. package/ops/publish.py +158 -0
  66. package/ops/reddit_digest.py +168 -0
  67. package/ops/templates/n-seo-daily.service +11 -0
  68. package/ops/templates/n-seo-daily.timer +11 -0
  69. package/ops/templates/n-seo-dashboard.service +15 -0
  70. package/ops/templates/n-seo.cron +3 -0
  71. package/ops/templates/n-seo.daily.plist +29 -0
  72. package/ops/templates/n-seo.dashboard.plist +22 -0
  73. package/package.json +77 -0
  74. package/probes/__pycache__/site_probe.cpython-313.pyc +0 -0
  75. package/probes/site_probe.py +201 -0
  76. package/public/favicon.svg +6 -0
  77. package/public/styles.css +632 -0
  78. package/src/actions.ts +255 -0
  79. package/src/backlog.ts +197 -0
  80. package/src/config.ts +220 -0
  81. package/src/data.ts +895 -0
  82. package/src/insights.ts +22 -0
  83. package/src/mcp-stdio.ts +21 -0
  84. package/src/mcp.ts +490 -0
  85. package/src/server.tsx +260 -0
  86. package/src/settings.tsx +329 -0
  87. package/src/views.tsx +1487 -0
  88. package/tsconfig.json +15 -0
@@ -0,0 +1,215 @@
1
+ # Connecting Search Console and GA4
2
+
3
+ The pipeline reads two Google APIs: Search Console (queries, pages, index
4
+ coverage) and Google Analytics 4 (sessions, sources, landing pages). Both are
5
+ free within generous quotas. The recommended way to authorize is a **service
6
+ account with a JSON key** — a machine identity you add as a read-only user in
7
+ each console. No gcloud install, no browser login, no token that expires when
8
+ your session does.
9
+
10
+ ## 1. Pick a Google Cloud project
11
+
12
+ Any project works; a dedicated one keeps it tidy. In the
13
+ [Cloud Console](https://console.cloud.google.com/), create a project (or open
14
+ an existing one). Billing is not required for these APIs.
15
+
16
+ ## 2. Enable the APIs
17
+
18
+ APIs & Services → Library. Enable all three:
19
+
20
+ - **Google Search Console API**
21
+ - **Google Analytics Data API**
22
+ - **Google Analytics Admin API** (used only to list properties; harmless to
23
+ skip if you already know your property id, but the doctor check uses it)
24
+
25
+ ## 3. Create a service account and key
26
+
27
+ IAM & Admin → Service Accounts → Create service account. Name it something like
28
+ `n-seo-reader`. **Grant it no project roles** — it needs none. Access to
29
+ your Search Console and GA4 data comes from the consoles themselves in step 4.
30
+
31
+ Open the account → Keys → Add key → Create new key → JSON. Store the download
32
+ outside the repo, readable only by you:
33
+
34
+ ```sh
35
+ mkdir -p ~/.config/n-seo
36
+ mv ~/Downloads/<project>-<hash>.json ~/.config/n-seo/service-account.json
37
+ chmod 600 ~/.config/n-seo/service-account.json
38
+ ```
39
+
40
+ Note the account's email (`n-seo-reader@<project>.iam.gserviceaccount.com`).
41
+ You will paste it into two places next.
42
+
43
+ ## 4. Grant access in the consoles
44
+
45
+ ### Search Console
46
+
47
+ Open the property → Settings → Users and permissions → Add user. Paste the
48
+ service account email, permission **Full**. (Restricted is enough for reading
49
+ performance data; Full also allows the sitemaps endpoint, which the indexing
50
+ sweep uses to report sitemap state.)
51
+
52
+ If the "Add user" dialog rejects the service-account address — this happens
53
+ on some domain properties — make the service account a verified owner instead:
54
+ enable the **Site Verification API** in the same Cloud project, call it with
55
+ the service account to request a DNS TXT token for the domain, add that TXT
56
+ record at your DNS provider, then call the API's verify method. The service
57
+ account then appears in Search Console as an owner and no UI step is needed.
58
+ Google documents the flow under "Site Verification API — Getting started."
59
+
60
+ ### GA4
61
+
62
+ Admin → Property → Property access management → Add users. Paste the same
63
+ email, role **Viewer**. Adding it at the account level instead gives it every
64
+ property under the account, which is handy if you run several sites.
65
+
66
+ While you are in Admin, open **Property details** and note the numeric
67
+ **Property ID** (nine or ten digits). That goes in the config.
68
+
69
+ ## 5. Fill in the config
70
+
71
+ ```sh
72
+ cp n-seo.config.example.json n-seo.config.json
73
+ ```
74
+
75
+ ```json
76
+ {
77
+ "google": {
78
+ "auth": "service-account-key",
79
+ "serviceAccountKey": "~/.config/n-seo/service-account.json"
80
+ },
81
+ "sites": [
82
+ {
83
+ "host": "example.com",
84
+ "label": "example",
85
+ "gscProperty": "sc-domain:example.com",
86
+ "gscHost": "example.com",
87
+ "ga4Property": "123456789",
88
+ "brand": "example"
89
+ }
90
+ ]
91
+ }
92
+ ```
93
+
94
+ `gscProperty` must match the property exactly as Search Console has it:
95
+ `sc-domain:example.com` for a domain property, or the full URL prefix
96
+ (`https://www.example.com/`, trailing slash included) for a URL-prefix
97
+ property. `$GOOGLE_APPLICATION_CREDENTIALS` is honored if you prefer that to
98
+ `serviceAccountKey`. See [ADDING-A-SITE.md](ADDING-A-SITE.md) for every field.
99
+
100
+ ## 6. Verify
101
+
102
+ ```sh
103
+ python3 ingest/google_auth.py # mints a token, lists the properties the account can see
104
+ python3 ops/doctor.py # full setup check: config, key, APIs, each site's access
105
+ ```
106
+
107
+ The first command should print `token OK` followed by your properties and the
108
+ permission level on each. If a site you configured is missing from that list,
109
+ step 4 has not taken effect yet (it can take a few minutes).
110
+
111
+ Then run the pipeline once:
112
+
113
+ ```sh
114
+ python3 ops/daily.py
115
+ ```
116
+
117
+ ## Alternative auth modes
118
+
119
+ Set `google.auth` to one of:
120
+
121
+ | Mode | How it gets a token | When to use |
122
+ |---|---|---|
123
+ | `service-account-key` | Signs an OAuth JWT with the key file using `openssl` | Default. No extra tools |
124
+ | `gcloud-impersonate` | `gcloud auth print-access-token --impersonate-service-account=<google.impersonate>` | You already use gcloud and would rather grant your user *Service Account Token Creator* on the SA than keep a key file. Still add the SA to the consoles as above |
125
+ | `gcloud-user` | `gcloud auth print-access-token` for your own login | Rarely works: gcloud's default client does not carry the Search Console or Analytics scopes for user credentials, and Google blocks `application-default login` with those scopes. Kept for completeness |
126
+ | `metadata` | The runtime service account from the GCE / Cloud Run / GKE metadata server, exchanged for a scoped token | Running on Google Cloud. No key file exists, so none can leak |
127
+
128
+ ## Running it somewhere other than your laptop
129
+
130
+ A key file is a secret you have to mount, rotate and keep out of the image.
131
+ On Google Cloud you can skip it: give the workload a service account and set
132
+
133
+ ```json
134
+ "google": { "auth": "metadata" }
135
+ ```
136
+
137
+ There is one wrinkle worth knowing, because the failure is otherwise
138
+ baffling. The metadata server hands out a token scoped to `cloud-platform`,
139
+ and the Search Console API checks for its own scope, so it rejects that
140
+ token. n-seo therefore does what the `gcloud-impersonate` mode does, without
141
+ gcloud: it takes the metadata token and asks IAM Credentials for a properly
142
+ scoped one **for the same account**. That self-impersonation needs the
143
+ account to hold Token Creator *on itself*:
144
+
145
+ ```sh
146
+ SA=n-seo-runtime@PROJECT.iam.gserviceaccount.com
147
+ gcloud iam service-accounts add-iam-policy-binding "$SA" \
148
+ --member="serviceAccount:$SA" \
149
+ --role=roles/iam.serviceAccountTokenCreator
150
+ ```
151
+
152
+ Without it the first pull fails with a 403, and the error prints that exact
153
+ command. Everything else is unchanged: the same service account still has to
154
+ be added as a **Full** user in Search Console and a **Viewer** in GA4.
155
+
156
+ Set `google.impersonate` as well if the workload should borrow a *different*
157
+ account than the one it runs as; then that account needs Token Creator for
158
+ the runtime account.
159
+
160
+ `python3 ops/doctor.py` reports the detected account and whether the
161
+ exchange works, so run it once on the box before trusting a schedule.
162
+
163
+ ### The LLM module on a server
164
+
165
+ `modules.llm.command` shells out to a CLI, and a container has none signed
166
+ in — so the opportunity scan produces no proposals and the digests no
167
+ briefings. Point it at an HTTP endpoint instead:
168
+
169
+ ```json
170
+ "llm": {
171
+ "enabled": true,
172
+ "http": {
173
+ "provider": "anthropic",
174
+ "model": "claude-sonnet-5",
175
+ "fastModel": "claude-haiku-4-5-20251001",
176
+ "apiKeyEnv": "ANTHROPIC_API_KEY"
177
+ }
178
+ }
179
+ ```
180
+
181
+ The key is read from the environment or the instance's `.env` under the name
182
+ you give in `apiKeyEnv`; it never goes in the config file. `provider` may
183
+ also be `openai`, which speaks the chat-completions shape and therefore also
184
+ covers gateways and local servers that emulate it — add `baseUrl` to point
185
+ somewhere other than the vendor. When `http` is set and its key resolves it
186
+ wins; otherwise the `command` path still runs, so a laptop and a server can
187
+ share one config file.
188
+
189
+ ## Troubleshooting
190
+
191
+ | Symptom | Cause |
192
+ |---|---|
193
+ | `403 … insufficient permission` / `User does not have sufficient permissions for this site` | The service account is not (yet) a user on that property. Re-check step 4; wait a few minutes |
194
+ | `404` on a Search Console property | `gscProperty` does not match the console: `sc-domain:` vs URL prefix, missing trailing slash, `www` vs apex |
195
+ | `invalid_grant` / `Invalid JWT` when minting a token | System clock off by more than a few minutes, or a corrupted key file. Check `date`; re-download the key |
196
+ | `no key file was found` | The path in `serviceAccountKey` does not exist. `~` is expanded; relative paths resolve from the repo root |
197
+ | GA4 returns `PERMISSION_DENIED` | Viewer not granted on that property, or the property id is the *measurement id* (`G-…`) instead of the numeric property id |
198
+ | Every step fails at once | Offline. The daily run waits up to five minutes for the network before starting |
199
+
200
+ Why not `gcloud auth application-default login --scopes=…`? Google rejects
201
+ those scopes for gcloud's built-in OAuth client, so it fails regardless of what
202
+ you do. The service-account key avoids the problem entirely.
203
+
204
+ ## Data windows and quotas
205
+
206
+ - **Search Console** keeps 16 months of performance data and finalizes each
207
+ day about three days late. The pipeline pulls the full 16 months (for totals
208
+ and history) and the trailing 90 days (the decision window) and uses
209
+ `dataState: final`, so the most recent three days are always missing by
210
+ design.
211
+ - **URL Inspection** allows 2,000 inspections per day and 600 per minute per
212
+ property. The indexing sweep caps itself at 400 URLs per host per run.
213
+ - **GA4 Data API** has a daily token budget per property that ordinary use
214
+ never approaches; the four reports the pipeline runs are small.
215
+ - Nothing here costs money.
@@ -0,0 +1,59 @@
1
+ {
2
+ "slug": "docs-listing-outreach",
3
+ "name": "Docs listing and backlink outreach",
4
+ "site": "docs.example.com",
5
+ "summary": "Get docs.example.com listed on the curated resource pages and awesome-lists our audience already reads. Ten ranked targets, three templates, one send plan.",
6
+ "voice": "Short, specific, no marketing adjectives. Lead with what the reader's audience gets. One ask per message. Disclose that it is our project.",
7
+ "targets": [
8
+ {
9
+ "rank": 1,
10
+ "name": "awesome-example-tools",
11
+ "category": "awesome-list",
12
+ "url": "github.com/someone/awesome-example-tools",
13
+ "contact": "Pull request following CONTRIBUTING.md — one line, alphabetical, matching the existing entry format",
14
+ "angle": "The list has no interactive tutorial entry; ours is free and needs no account",
15
+ "value": "durable backlink from a 4k-star repo; steady referral traffic",
16
+ "likelihood": "high — the list accepts PRs weekly and has no competing entry",
17
+ "evidence": "17 open PRs merged in the last 60 days; two comparable tools listed",
18
+ "status": ""
19
+ },
20
+ {
21
+ "rank": 2,
22
+ "name": "Example University — course resource page",
23
+ "category": "education",
24
+ "url": "cs.example.edu/courses/101/resources",
25
+ "contact": "Instructor email on the syllabus page",
26
+ "angle": "The course's week 4 assignment covers exactly what our getting-started guide teaches",
27
+ "value": ".edu link; seasonal traffic at semester start",
28
+ "likelihood": "medium — instructors reply in batches at term boundaries",
29
+ "evidence": "Resource page updated each August; links to two similar tools",
30
+ "status": "sent 2026-08-14"
31
+ }
32
+ ],
33
+ "templates": [
34
+ {
35
+ "id": "A",
36
+ "audience": "awesome-list maintainers",
37
+ "subject": "Add docs.example.com (interactive getting-started, no account needed)",
38
+ "body": "Hi —\n\nPR attached adding docs.example.com under Tutorials. It's our project (disclosure), free, runs in the browser without an account, and covers the same ground as the two guides already listed but interactively.\n\nHappy to trim to one line or move sections if you prefer.\n\nThanks for maintaining the list."
39
+ },
40
+ {
41
+ "id": "B",
42
+ "audience": "instructors",
43
+ "subject": "A free interactive companion to your week-4 material",
44
+ "body": "Hi Professor —\n\nI build docs.example.com, a free interactive guide to <topic>. Your 101 course's week-4 assignment covers the same concepts, and a few students have told us they used the guide alongside it.\n\nIf it would be useful to list on the course resources page, here is the link: https://docs.example.com/getting-started. No account, no tracking beyond standard analytics.\n\nEither way, thank you for teaching this.\n\n— <your name>"
45
+ }
46
+ ],
47
+ "plan": [
48
+ { "day": "Mon", "action": "Open the awesome-list PR", "template": "A", "notes": "Read CONTRIBUTING.md first; match the entry format exactly" },
49
+ { "day": "Wed", "action": "Email the two instructors whose fall syllabi are already posted", "template": "B", "notes": "Personalize the week reference for each" },
50
+ { "day": "Fri", "action": "Check PR feedback; edit the existing PR rather than opening a new one", "template": "", "notes": "" }
51
+ ],
52
+ "week2": "Follow up on unanswered instructor emails once, then stop. Move to the next three targets.",
53
+ "later": "Once the awesome-list entry is merged, cite it in the next two pitches as social proof.",
54
+ "cautions": [
55
+ "One follow-up maximum per target.",
56
+ "Never send the same template to two maintainers of the same list.",
57
+ "Disclose that it is your project every time."
58
+ ]
59
+ }
@@ -0,0 +1,43 @@
1
+ ---
2
+ title: "Dev.to: how we cut our docs site's JS shell to zero and doubled AI referrals"
3
+ order: 1
4
+ action: "Voice pass, then post on dev.to with the canonical URL set to the original on docs.example.com"
5
+ channel: dev.to
6
+ status: ready to post
7
+ tags: distribution, geo
8
+ notes: >
9
+ Cross-post, not a new article: set the canonical to the original so the
10
+ ranking stays with your domain. Post mid-week morning in your audience's
11
+ timezone. Reply to the first few comments the same day.
12
+ ---
13
+
14
+ # How we cut our docs site's JS shell to zero and doubled AI referrals
15
+
16
+ Six months ago every page on docs.example.com served about 400 bytes of
17
+ visible text and a 1.2 MB bundle. Google coped. Nothing else did: Bing indexed
18
+ eleven pages, and referrals from ChatGPT and Perplexity were a rounding error.
19
+
20
+ Here is what we changed, in the order it mattered, with the numbers.
21
+
22
+ ## 1. Server-render the content, keep the app
23
+
24
+
25
+
26
+ ## 2. Let the crawlers in
27
+
28
+
29
+
30
+ ## 3. Add llms.txt and llms-full.txt
31
+
32
+
33
+
34
+ ## What moved
35
+
36
+ | | Before | After 90 days |
37
+ |---|---|---|
38
+ | Pages indexed by Bing | 11 | 212 |
39
+ | AI-assistant referral sessions / month | 9 | 21 |
40
+ | Google clicks / day | 140 | 165 |
41
+
42
+ The whole change was a week of work. The measuring took longer than the
43
+ doing, and that was the point.
Binary file
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env python3
2
+ """GA4 analysis: 90-day traffic mix and the AI-referral (GEO) scoreboard.
3
+
4
+ Reads data/ga4/<host>/*.json, writes docs/reports/ga4-findings-<YYYY-MM>.md
5
+ and prints it.
6
+ """
7
+
8
+ import json
9
+ import re
10
+ import sys
11
+ from datetime import date
12
+
13
+ import seo_config
14
+
15
+ GA_DIR = seo_config.DATA / "ga4"
16
+
17
+ AI_SOURCES = re.compile(
18
+ r"chatgpt|chat\.openai|openai\.com|perplexity|claude\.ai|copilot|gemini\.google"
19
+ r"|edgeservices|you\.com|poe\.com|phind|kagi|mistral|deepseek", re.I)
20
+ SEARCH_SOURCES = re.compile(r"google|bing|duckduckgo|yahoo|ecosia|brave|yandex|baidu", re.I)
21
+
22
+
23
+ def rows(site, name):
24
+ p = GA_DIR / site / f"{name}.json"
25
+ if not p.exists():
26
+ return []
27
+ d = json.loads(p.read_text())
28
+ out = []
29
+ for r in d.get("rows", []):
30
+ dims = [v["value"] for v in r.get("dimensionValues", [])]
31
+ mets = [float(v["value"]) for v in r.get("metricValues", [])]
32
+ out.append((dims, mets))
33
+ return out
34
+
35
+
36
+ def main():
37
+ if not GA_DIR.exists():
38
+ print("no GA4 data yet — run ingest/pull_ga4.py")
39
+ return 0
40
+ out = [f"# GA4 Findings — {date.today().isoformat()}",
41
+ "\nWindow: last 90 days.\n",
42
+ "| Site | Sessions | AI-referral | Search | Direct/other | Top AI sources |",
43
+ "|---|---|---|---|---|---|"]
44
+ detail = []
45
+ for site_dir in sorted(p for p in GA_DIR.iterdir() if p.is_dir()):
46
+ site = site_dir.name
47
+ src = rows(site, "sources")
48
+ total = sum(m[0] for _, m in src)
49
+ ai = [("/".join(d), m[0]) for d, m in src if AI_SOURCES.search(d[0])]
50
+ search = sum(m[0] for d, m in src if SEARCH_SOURCES.search(d[0]) and not AI_SOURCES.search(d[0]))
51
+ ai_total = sum(v for _, v in ai)
52
+ ai.sort(key=lambda t: -t[1])
53
+ top_ai = ", ".join(f"{s} ({v:.0f})" for s, v in ai[:3]) or "—"
54
+ out.append(f"| {site} | {total:,.0f} | {ai_total:,.0f}"
55
+ f" ({100*ai_total/total:.1f}%) | {search:,.0f} ({100*search/total:.1f}%)"
56
+ f" | {total-ai_total-search:,.0f} | {top_ai} |" if total else
57
+ f"| {site} | 0 | — | — | — | — |")
58
+
59
+ landing = rows(site, "landing")[:8]
60
+ detail.append(f"\n## {site}\n\n**Top landing pages (sessions / engagement):**\n")
61
+ for d, m in landing:
62
+ detail.append(f"- {d[0]} — {m[0]:.0f} sessions, {100*m[1]:.0f}% engaged")
63
+ if ai:
64
+ detail.append("\n**All AI sources:**\n")
65
+ for s, v in ai:
66
+ detail.append(f"- {s}: {v:.0f} sessions")
67
+
68
+ text = "\n".join(out) + "\n" + "\n".join(detail) + "\n"
69
+ dest_dir = seo_config.INSTANCE / "docs" / "reports"
70
+ dest_dir.mkdir(parents=True, exist_ok=True)
71
+ dest = dest_dir / f"ga4-findings-{date.today():%Y-%m}.md"
72
+ dest.write_text(text)
73
+ print(text)
74
+ print(f"[saved to {dest}]")
75
+ return 0
76
+
77
+
78
+ if __name__ == "__main__":
79
+ sys.exit(main())
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env python3
2
+ """Analyze pulled Search Console data into a markdown report: totals, 28-day
3
+ trend, striking-distance queries, CTR gaps, top pages, dead pages.
4
+
5
+ Reads data/gsc/<slug>/*.json (from pull_gsc.py), writes
6
+ docs/reports/gsc-findings-<YYYY-MM>.md and prints it. Run it whenever you
7
+ want a shareable snapshot; the dashboard shows the same data live.
8
+ """
9
+
10
+ import json
11
+ import sys
12
+ from datetime import date, timedelta
13
+
14
+ import seo_config
15
+
16
+ # Rough expected CTR by average position (industry midpoints).
17
+ EXPECTED_CTR = {1: 0.28, 2: 0.15, 3: 0.10, 4: 0.07, 5: 0.05, 6: 0.04}
18
+
19
+
20
+ def load(slug, dataset):
21
+ p = seo_config.DATA / "gsc" / slug / f"{dataset}.json"
22
+ return json.loads(p.read_text())["rows"] if p.exists() else []
23
+
24
+
25
+ def fmt_pct(x):
26
+ return f"{100 * x:.1f}%"
27
+
28
+
29
+ def section(prop, slug, out):
30
+ queries = load(slug, "queries")
31
+ pages = load(slug, "pages")
32
+ dates = load(slug, "dates")
33
+
34
+ clicks = sum(r["clicks"] for r in dates)
35
+ imps = sum(r["impressions"] for r in dates)
36
+ out.append(f"\n## {prop}\n")
37
+ out.append(f"**16-month totals:** {clicks:,.0f} clicks · {imps:,.0f} impressions"
38
+ f" · overall CTR {fmt_pct(clicks / imps) if imps else 'n/a'}\n")
39
+
40
+ if dates:
41
+ by_day = {r["keys"][0]: r for r in dates}
42
+ today = date.today()
43
+
44
+ def window(offset_start, offset_end):
45
+ c = i = 0
46
+ for d in range(offset_start, offset_end):
47
+ r = by_day.get((today - timedelta(days=d)).isoformat())
48
+ if r:
49
+ c += r["clicks"]
50
+ i += r["impressions"]
51
+ return c, i
52
+ c1, i1 = window(3, 31)
53
+ c2, i2 = window(31, 59)
54
+ out.append(f"**Last 28d vs prior 28d:** {c1:,.0f} vs {c2:,.0f} clicks · "
55
+ f"{i1:,.0f} vs {i2:,.0f} impressions\n")
56
+
57
+ hosts = {}
58
+ for r in pages:
59
+ host = r["keys"][0].split("/")[2]
60
+ h = hosts.setdefault(host, {"clicks": 0, "impressions": 0})
61
+ h["clicks"] += r["clicks"]
62
+ h["impressions"] += r["impressions"]
63
+ if len(hosts) > 1:
64
+ out.append("**By host:**\n")
65
+ for host, v in sorted(hosts.items(), key=lambda kv: -kv[1]["clicks"]):
66
+ out.append(f"- {host}: {v['clicks']:,.0f} clicks / {v['impressions']:,.0f} impressions")
67
+ out.append("")
68
+
69
+ top = sorted(queries, key=lambda r: -r["clicks"])[:15]
70
+ out.append("**Top queries by clicks:**\n")
71
+ out.append("| Query | Clicks | Impressions | CTR | Pos |")
72
+ out.append("|---|---|---|---|---|")
73
+ for r in top:
74
+ out.append(f"| {r['keys'][0]} | {r['clicks']:.0f} | {r['impressions']:.0f}"
75
+ f" | {fmt_pct(r['ctr'])} | {r['position']:.1f} |")
76
+
77
+ striking = [r for r in queries if 5 <= r["position"] <= 15 and r["impressions"] >= 50]
78
+ striking.sort(key=lambda r: -r["impressions"])
79
+ out.append("\n**Striking distance (pos 5-15, >=50 impressions) — biggest prizes:**\n")
80
+ if striking:
81
+ out.append("| Query | Impressions | Clicks | Pos |")
82
+ out.append("|---|---|---|---|")
83
+ for r in striking[:15]:
84
+ out.append(f"| {r['keys'][0]} | {r['impressions']:.0f} | {r['clicks']:.0f} | {r['position']:.1f} |")
85
+ else:
86
+ out.append("_none at threshold_")
87
+
88
+ ctr_gap = []
89
+ for r in queries:
90
+ pos = round(r["position"])
91
+ if pos in EXPECTED_CTR and r["impressions"] >= 100:
92
+ expected = EXPECTED_CTR[pos]
93
+ if r["ctr"] < expected * 0.5:
94
+ ctr_gap.append((r, expected))
95
+ ctr_gap.sort(key=lambda t: -(t[0]["impressions"] * (t[1] - t[0]["ctr"])))
96
+ out.append("\n**CTR gaps (ranking well, clicked rarely — title/snippet problems):**\n")
97
+ if ctr_gap:
98
+ out.append("| Query | Impressions | CTR | Expected | Pos |")
99
+ out.append("|---|---|---|---|---|")
100
+ for r, exp in ctr_gap[:12]:
101
+ out.append(f"| {r['keys'][0]} | {r['impressions']:.0f} | {fmt_pct(r['ctr'])}"
102
+ f" | ~{fmt_pct(exp)} | {r['position']:.1f} |")
103
+ else:
104
+ out.append("_none at threshold_")
105
+
106
+ top_pages = sorted(pages, key=lambda r: -r["clicks"])[:10]
107
+ out.append("\n**Top pages by clicks:**\n")
108
+ for r in top_pages:
109
+ out.append(f"- {r['keys'][0]} — {r['clicks']:.0f} clicks, {r['impressions']:.0f} imps, pos {r['position']:.1f}")
110
+
111
+ dead = [r for r in pages if r["impressions"] >= 200 and r["clicks"] <= 2]
112
+ dead.sort(key=lambda r: -r["impressions"])
113
+ if dead:
114
+ out.append("\n**High-impression, near-zero-click pages:**\n")
115
+ for r in dead[:10]:
116
+ out.append(f"- {r['keys'][0]} — {r['impressions']:.0f} imps, {r['clicks']:.0f} clicks, pos {r['position']:.1f}")
117
+ return out
118
+
119
+
120
+ def main():
121
+ out = [f"# Search Console Findings — {date.today().isoformat()}",
122
+ "\nWindow: trailing 16 months (final data through ~3 days ago)."]
123
+ for prop, slug in seo_config.gsc_properties(include_extra=False).items():
124
+ section(prop, slug, out)
125
+ text = "\n".join(out) + "\n"
126
+ dest_dir = seo_config.INSTANCE / "docs" / "reports"
127
+ dest_dir.mkdir(parents=True, exist_ok=True)
128
+ dest = dest_dir / f"gsc-findings-{date.today():%Y-%m}.md"
129
+ dest.write_text(text)
130
+ print(text)
131
+ print(f"\n[saved to {dest}]")
132
+ return 0
133
+
134
+
135
+ if __name__ == "__main__":
136
+ sys.exit(main())