dottide 0.2.0__tar.gz

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.
@@ -0,0 +1,13 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ .DS_Store
8
+
9
+ # Keys and filled-in profiles never belong in the repo.
10
+ *.json
11
+ *.toml
12
+ !pyproject.toml
13
+ !src/dottide/profile_template.toml
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ - Published on PyPI: `uvx dottide` runs it with nothing to clone.
6
+ - `init` writes a starter profile to `~/.config/dottide/`.
7
+ - `discover` finds every Search Console property and GA4 property the key can read and adds the missing sites to a profile.
8
+
9
+ ## 0.1.0 (not published)
10
+
11
+ - First version: the `sites`, `search_queries`, `traffic` and `store_stats` MCP tools, the matching commands, and the weekly snapshot.
12
+ - Keys stored in the macOS keychain with `dottide import-key`.
13
+ - Docs for Google setup, configuration and everyday use.
14
+ - README cover in light and dark.
dottide-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dotbrain LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
dottide-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.5
2
+ Name: dottide
3
+ Version: 0.2.0
4
+ Summary: Read-only numbers for every site and product you run, across Google accounts, for people and AI agents.
5
+ Project-URL: Homepage, https://github.com/dotbrainhq/dottide
6
+ Project-URL: Documentation, https://github.com/dotbrainhq/dottide/tree/main/docs
7
+ Project-URL: Issues, https://github.com/dotbrainhq/dottide/issues
8
+ Project-URL: Changelog, https://github.com/dotbrainhq/dottide/blob/main/CHANGELOG.md
9
+ Author-email: Dotbrain LLC <hello@dotbrainhq.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai-agents,analytics,claude,cli,ga4,google-analytics,google-search-console,mcp,mcp-server,seo
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: google-auth[requests]>=2.40
22
+ Requires-Dist: httpx>=0.28
23
+ Requires-Dist: mcp>=2.3
24
+ Description-Content-Type: text/markdown
25
+
26
+ <picture>
27
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/dotbrainhq/dottide/main/.github/assets/cover-dark.png">
28
+ <img src="https://raw.githubusercontent.com/dotbrainhq/dottide/main/.github/assets/cover-light.png" alt="dotTide: every site's numbers, one place. Search Console, GA4 and store stats across Google accounts, for you and your AI agents. Local and read-only. Next to the text, the dotTide icon: a wave running across a green tile, with one dot under the surface.">
29
+ </picture>
30
+
31
+ # dotTide
32
+
33
+ dotTide is a read-only MCP server and command-line tool that gives Claude, Cursor, Codex and other AI agents the numbers for every site you run. It reads Google Search Console and Google Analytics 4 (GA4) across multiple Google accounts, plus WordPress.org, Firefox Add-ons and GitHub stats, and writes a weekly snapshot for scripts and agents.
34
+
35
+ Nothing gets added to your sites. dotTide only reads numbers you already have, and your keys stay on your Mac.
36
+
37
+ ## Why
38
+
39
+ If your sites sit under different Google accounts, checking them means logging in and out of each one. The weekly check slips to monthly, and any AI agent you ask works from stale numbers. Store numbers for plugins, extensions and repos live in yet more places.
40
+
41
+ dotTide reads all of it with one read-only key, and answers the same way in Claude, on the command line, and in a weekly JSON file.
42
+
43
+ ## What it does
44
+
45
+ - **`sites`:** every configured site, which key covers it, and whether Search Console and GA4 access works when you ask.
46
+ - **`search_queries`:** clicks, impressions, CTR and average position by query or page, for any date range, with an optional comparison to the previous period.
47
+ - **`traffic`:** GA4 sessions by source and medium, with AI assistant referrals (ChatGPT, Perplexity, Copilot, Gemini, Claude and others) counted separately. Only for sites that have GA4.
48
+ - **`store_stats`:** live public numbers that need no key: WordPress.org plugins, Firefox Add-ons, GitHub stars.
49
+ - **`discover`:** finds every Search Console property and GA4 property the key can read, and writes the profile entries for you.
50
+ - **Weekly snapshot:** last full week against the week before, one JSON file per profile, for scripts and agents that read files.
51
+
52
+ ## How access works
53
+
54
+ No site moves to a new Google account. You create one Google service account, and whichever Google account owns a site invites that service account's email:
55
+
56
+ - **Search Console:** as a Restricted user.
57
+ - **GA4:** as a Viewer.
58
+
59
+ The service account can only read, and you can remove it from any site in one click. Its key lives in the macOS keychain.
60
+
61
+ ## Quick start
62
+
63
+ You need macOS and [uv](https://docs.astral.sh/uv/). There is nothing to clone: `uvx` downloads dotTide the first time you run it.
64
+
65
+ 1. Write a starter profile, then add a plugin slug, an add-on slug or a GitHub user under `[stores]` in it:
66
+
67
+ ```
68
+ uvx dottide init
69
+ ```
70
+
71
+ 2. Check that it runs. Store numbers need no Google setup at all:
72
+
73
+ ```
74
+ uvx dottide store_stats
75
+ ```
76
+
77
+ 3. Set up Google access by following [Google setup](https://github.com/dotbrainhq/dottide/blob/main/docs/google-setup.md). It takes about ten minutes, plus a minute per site.
78
+
79
+ 4. Store the key, let dotTide find your sites, and check access:
80
+
81
+ ```
82
+ uvx dottide import-key dottide-main ~/Downloads/<downloaded-key>.json
83
+ uvx dottide discover --write
84
+ uvx dottide sites
85
+ ```
86
+
87
+ 5. Connect it to Claude Code:
88
+
89
+ ```
90
+ claude mcp add --scope user dottide -- uvx dottide
91
+ ```
92
+
93
+ To update later, run `uvx dottide@latest sites` once; after that `uvx dottide` uses the new version.
94
+
95
+ ## Documentation
96
+
97
+ - [Google setup](https://github.com/dotbrainhq/dottide/blob/main/docs/google-setup.md): the service account, click by click, and sharing each site.
98
+ - [Configuration](https://github.com/dotbrainhq/dottide/blob/main/docs/configuration.md): profiles, every field, keys and environment variables.
99
+ - [Using dotTide](https://github.com/dotbrainhq/dottide/blob/main/docs/usage.md): each tool and its options, MCP clients, the weekly snapshot and its format, and fixes for common errors.
100
+ - [Asking an AI agent for your numbers](https://github.com/dotbrainhq/dottide/blob/main/docs/examples.md): example prompts for weekly checks, content to refresh, AI referrals and store numbers, plus a saved Claude Code command.
101
+ - [Connecting AI clients](https://github.com/dotbrainhq/dottide/blob/main/docs/clients.md): Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, Codex, Gemini CLI, Grok, and agents without MCP.
102
+
103
+ ## Good to know
104
+
105
+ - Search Console data settles about three days after the fact, so default ranges end three days ago.
106
+ - Every Google scope dotTide asks for is read-only, and no tool can change anything.
107
+ - GitHub allows 60 anonymous requests an hour per IP. Set `DOTTIDE_GITHUB_TOKEN` to a read-only token if you hit the limit.
108
+
109
+ ## Contributing
110
+
111
+ See [CONTRIBUTING.md](https://github.com/dotbrainhq/dottide/blob/main/CONTRIBUTING.md). Security reports go through [SECURITY.md](https://github.com/dotbrainhq/dottide/blob/main/SECURITY.md).
112
+
113
+ ## License
114
+
115
+ The code is released under the [MIT License](https://github.com/dotbrainhq/dottide/blob/main/LICENSE). The dotTide name and icon are not covered by the license, so please use your own name and icon if you ship a copy.
@@ -0,0 +1,90 @@
1
+ <picture>
2
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/dotbrainhq/dottide/main/.github/assets/cover-dark.png">
3
+ <img src="https://raw.githubusercontent.com/dotbrainhq/dottide/main/.github/assets/cover-light.png" alt="dotTide: every site's numbers, one place. Search Console, GA4 and store stats across Google accounts, for you and your AI agents. Local and read-only. Next to the text, the dotTide icon: a wave running across a green tile, with one dot under the surface.">
4
+ </picture>
5
+
6
+ # dotTide
7
+
8
+ dotTide is a read-only MCP server and command-line tool that gives Claude, Cursor, Codex and other AI agents the numbers for every site you run. It reads Google Search Console and Google Analytics 4 (GA4) across multiple Google accounts, plus WordPress.org, Firefox Add-ons and GitHub stats, and writes a weekly snapshot for scripts and agents.
9
+
10
+ Nothing gets added to your sites. dotTide only reads numbers you already have, and your keys stay on your Mac.
11
+
12
+ ## Why
13
+
14
+ If your sites sit under different Google accounts, checking them means logging in and out of each one. The weekly check slips to monthly, and any AI agent you ask works from stale numbers. Store numbers for plugins, extensions and repos live in yet more places.
15
+
16
+ dotTide reads all of it with one read-only key, and answers the same way in Claude, on the command line, and in a weekly JSON file.
17
+
18
+ ## What it does
19
+
20
+ - **`sites`:** every configured site, which key covers it, and whether Search Console and GA4 access works when you ask.
21
+ - **`search_queries`:** clicks, impressions, CTR and average position by query or page, for any date range, with an optional comparison to the previous period.
22
+ - **`traffic`:** GA4 sessions by source and medium, with AI assistant referrals (ChatGPT, Perplexity, Copilot, Gemini, Claude and others) counted separately. Only for sites that have GA4.
23
+ - **`store_stats`:** live public numbers that need no key: WordPress.org plugins, Firefox Add-ons, GitHub stars.
24
+ - **`discover`:** finds every Search Console property and GA4 property the key can read, and writes the profile entries for you.
25
+ - **Weekly snapshot:** last full week against the week before, one JSON file per profile, for scripts and agents that read files.
26
+
27
+ ## How access works
28
+
29
+ No site moves to a new Google account. You create one Google service account, and whichever Google account owns a site invites that service account's email:
30
+
31
+ - **Search Console:** as a Restricted user.
32
+ - **GA4:** as a Viewer.
33
+
34
+ The service account can only read, and you can remove it from any site in one click. Its key lives in the macOS keychain.
35
+
36
+ ## Quick start
37
+
38
+ You need macOS and [uv](https://docs.astral.sh/uv/). There is nothing to clone: `uvx` downloads dotTide the first time you run it.
39
+
40
+ 1. Write a starter profile, then add a plugin slug, an add-on slug or a GitHub user under `[stores]` in it:
41
+
42
+ ```
43
+ uvx dottide init
44
+ ```
45
+
46
+ 2. Check that it runs. Store numbers need no Google setup at all:
47
+
48
+ ```
49
+ uvx dottide store_stats
50
+ ```
51
+
52
+ 3. Set up Google access by following [Google setup](https://github.com/dotbrainhq/dottide/blob/main/docs/google-setup.md). It takes about ten minutes, plus a minute per site.
53
+
54
+ 4. Store the key, let dotTide find your sites, and check access:
55
+
56
+ ```
57
+ uvx dottide import-key dottide-main ~/Downloads/<downloaded-key>.json
58
+ uvx dottide discover --write
59
+ uvx dottide sites
60
+ ```
61
+
62
+ 5. Connect it to Claude Code:
63
+
64
+ ```
65
+ claude mcp add --scope user dottide -- uvx dottide
66
+ ```
67
+
68
+ To update later, run `uvx dottide@latest sites` once; after that `uvx dottide` uses the new version.
69
+
70
+ ## Documentation
71
+
72
+ - [Google setup](https://github.com/dotbrainhq/dottide/blob/main/docs/google-setup.md): the service account, click by click, and sharing each site.
73
+ - [Configuration](https://github.com/dotbrainhq/dottide/blob/main/docs/configuration.md): profiles, every field, keys and environment variables.
74
+ - [Using dotTide](https://github.com/dotbrainhq/dottide/blob/main/docs/usage.md): each tool and its options, MCP clients, the weekly snapshot and its format, and fixes for common errors.
75
+ - [Asking an AI agent for your numbers](https://github.com/dotbrainhq/dottide/blob/main/docs/examples.md): example prompts for weekly checks, content to refresh, AI referrals and store numbers, plus a saved Claude Code command.
76
+ - [Connecting AI clients](https://github.com/dotbrainhq/dottide/blob/main/docs/clients.md): Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, Codex, Gemini CLI, Grok, and agents without MCP.
77
+
78
+ ## Good to know
79
+
80
+ - Search Console data settles about three days after the fact, so default ranges end three days ago.
81
+ - Every Google scope dotTide asks for is read-only, and no tool can change anything.
82
+ - GitHub allows 60 anonymous requests an hour per IP. Set `DOTTIDE_GITHUB_TOKEN` to a read-only token if you hit the limit.
83
+
84
+ ## Contributing
85
+
86
+ See [CONTRIBUTING.md](https://github.com/dotbrainhq/dottide/blob/main/CONTRIBUTING.md). Security reports go through [SECURITY.md](https://github.com/dotbrainhq/dottide/blob/main/SECURITY.md).
87
+
88
+ ## License
89
+
90
+ The code is released under the [MIT License](https://github.com/dotbrainhq/dottide/blob/main/LICENSE). The dotTide name and icon are not covered by the license, so please use your own name and icon if you ship a copy.
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "dottide"
3
+ version = "0.2.0"
4
+ description = "Read-only numbers for every site and product you run, across Google accounts, for people and AI agents."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ keywords = ["mcp", "mcp-server", "google-search-console", "google-analytics", "ga4", "seo", "analytics", "ai-agents", "claude", "cli"]
8
+ requires-python = ">=3.12"
9
+ authors = [{ name = "Dotbrain LLC", email = "hello@dotbrainhq.com" }]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "Operating System :: MacOS",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Topic :: Internet :: WWW/HTTP :: Site Management",
18
+ ]
19
+ dependencies = [
20
+ "google-auth[requests]>=2.40",
21
+ "httpx>=0.28",
22
+ "mcp>=2.3",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/dotbrainhq/dottide"
27
+ Documentation = "https://github.com/dotbrainhq/dottide/tree/main/docs"
28
+ Issues = "https://github.com/dotbrainhq/dottide/issues"
29
+ Changelog = "https://github.com/dotbrainhq/dottide/blob/main/CHANGELOG.md"
30
+
31
+ [project.scripts]
32
+ dottide = "dottide.server:main"
33
+
34
+ [dependency-groups]
35
+ dev = ["pytest>=8", "ruff>=0.6"]
36
+
37
+ [build-system]
38
+ requires = ["hatchling"]
39
+ build-backend = "hatchling.build"
40
+
41
+ [tool.hatch.build.targets.sdist]
42
+ include = ["src", "tests", "README.md", "LICENSE", "CHANGELOG.md", "pyproject.toml", "uv.lock"]
43
+
44
+ [tool.ruff]
45
+ line-length = 100
46
+ target-version = "py312"
47
+
48
+ [tool.ruff.lint]
49
+ select = ["E", "F", "I", "UP", "B"]
File without changes
@@ -0,0 +1,106 @@
1
+ """Profiles: one TOML file per side, each with its own Google key and its own sites.
2
+
3
+ Keeping one file per side means a personal profile and a business profile never share a key,
4
+ and an agent can be pointed at a single file when it should only see one side.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import tomllib
11
+ from dataclasses import dataclass, field
12
+ from importlib.resources import files
13
+ from pathlib import Path
14
+
15
+ from dottide import keys
16
+
17
+ DEFAULT_DIR = Path("~/.config/dottide")
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Site:
22
+ name: str
23
+ profile: str
24
+ search_console: str | None = None
25
+ ga4_property: str | None = None
26
+
27
+
28
+ @dataclass
29
+ class Profile:
30
+ name: str
31
+ keychain: str | None = None
32
+ key_file: str | None = None
33
+ sites: list[Site] = field(default_factory=list)
34
+ stores: dict = field(default_factory=dict)
35
+
36
+ @property
37
+ def key_source(self) -> str:
38
+ if self.keychain:
39
+ return f"keychain:{self.keychain}"
40
+ if self.key_file:
41
+ return f"file:{self.key_file}"
42
+ return "none"
43
+
44
+ def key_info(self) -> dict:
45
+ if self.keychain:
46
+ return keys.read(self.keychain)
47
+ if self.key_file:
48
+ return keys.read_file(self.key_file)
49
+ raise PermissionError(f"profile {self.name!r} has no [key] set")
50
+
51
+
52
+ def config_paths() -> list[Path]:
53
+ """DOTTIDE_CONFIG may name one file or a folder of .toml files."""
54
+ target = Path(os.environ.get("DOTTIDE_CONFIG", DEFAULT_DIR)).expanduser()
55
+ if target.is_file():
56
+ return [target]
57
+ if target.is_dir():
58
+ return sorted(p for p in target.glob("*.toml") if not p.name.startswith("_"))
59
+ return []
60
+
61
+
62
+ def load_profile(path: Path) -> Profile:
63
+ data = tomllib.loads(path.read_text())
64
+ key = data.get("key", {})
65
+ profile = Profile(
66
+ name=path.stem,
67
+ keychain=key.get("keychain"),
68
+ key_file=key.get("file"),
69
+ stores=data.get("stores", {}),
70
+ )
71
+ for entry in data.get("sites", []):
72
+ ga4 = entry.get("ga4_property")
73
+ profile.sites.append(
74
+ Site(
75
+ name=entry["name"],
76
+ profile=profile.name,
77
+ search_console=entry.get("search_console"),
78
+ ga4_property=str(ga4) if ga4 else None,
79
+ )
80
+ )
81
+ return profile
82
+
83
+
84
+ def load_profiles() -> list[Profile]:
85
+ return [load_profile(p) for p in config_paths()]
86
+
87
+
88
+ def find_site(profiles: list[Profile], name: str) -> tuple[Profile, Site]:
89
+ for profile in profiles:
90
+ for site in profile.sites:
91
+ if site.name == name:
92
+ return profile, site
93
+ known = ", ".join(s.name for p in profiles for s in p.sites) or "none configured"
94
+ raise ValueError(f"unknown site {name!r}. Configured sites: {known}")
95
+
96
+
97
+ def init_profile(name: str = "main") -> Path:
98
+ """Write a starter profile from the bundled template. Never overwrites a file."""
99
+ folder = DEFAULT_DIR.expanduser()
100
+ path = folder / f"{name}.toml"
101
+ if path.exists():
102
+ raise FileExistsError(f"{path} already exists; edit it, or pick another --profile name")
103
+ folder.mkdir(mode=0o700, parents=True, exist_ok=True)
104
+ path.write_text(files("dottide").joinpath("profile_template.toml").read_text())
105
+ path.chmod(0o600)
106
+ return path
@@ -0,0 +1,122 @@
1
+ """Find every site the key can read and suggest profile entries for the ones not set up yet.
2
+
3
+ Reading is all it does on Google's side. The only write is `--write`, which appends
4
+ the suggested [[sites]] blocks to a local profile file.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from urllib.parse import urlparse
10
+
11
+ from dottide import google
12
+ from dottide.config import Profile, config_paths, load_profiles
13
+
14
+
15
+ def host(address: str) -> str:
16
+ """The bare domain behind a Search Console property or a site URL."""
17
+ if address.startswith("sc-domain:"):
18
+ address = address.removeprefix("sc-domain:")
19
+ elif "://" in address:
20
+ address = urlparse(address).hostname or ""
21
+ return address.strip("/").lower().removeprefix("www.")
22
+
23
+
24
+ def plan(gsc_properties: list[str], streams: list[dict], configured: list[dict]) -> list[dict]:
25
+ """Suggested sites, one per domain, skipping anything a profile already covers.
26
+
27
+ A domain property wins over a URL-prefix property for the same domain, because it
28
+ covers every subdomain and protocol.
29
+ """
30
+ taken = {host(s["name"]) for s in configured}
31
+ taken |= {host(s["search_console"]) for s in configured if s.get("search_console")}
32
+ taken_ga4 = {s["ga4_property"] for s in configured if s.get("ga4_property")}
33
+
34
+ by_host: dict[str, dict] = {}
35
+ for prop in gsc_properties:
36
+ entry = by_host.setdefault(host(prop), {"name": host(prop)})
37
+ current = entry.get("search_console", "")
38
+ if not current or (prop.startswith("sc-domain:") and not current.startswith("sc-domain:")):
39
+ entry["search_console"] = prop
40
+ for stream in streams:
41
+ key = host(stream["url"]) if stream["url"] else ""
42
+ if not key or stream["property"] in taken_ga4:
43
+ continue
44
+ entry = by_host.setdefault(key, {"name": key})
45
+ entry.setdefault("ga4_property", stream["property"])
46
+ entry.setdefault("measurement_id", stream["measurement_id"])
47
+ return [by_host[k] for k in sorted(by_host) if k not in taken]
48
+
49
+
50
+ def to_toml(sites: list[dict]) -> str:
51
+ blocks = []
52
+ for site in sites:
53
+ lines = ["[[sites]]", f'name = "{site["name"]}"']
54
+ if site.get("search_console"):
55
+ lines.append(f'search_console = "{site["search_console"]}"')
56
+ if site.get("ga4_property"):
57
+ note = f" # {site['measurement_id']}" if site.get("measurement_id") else ""
58
+ lines.append(f'ga4_property = "{site["ga4_property"]}"{note}')
59
+ blocks.append("\n".join(lines))
60
+ return "\n\n".join(blocks)
61
+
62
+
63
+ def discover(profile_name: str | None = None) -> dict:
64
+ """Read what the key can see and compare it with every profile."""
65
+ profiles = load_profiles()
66
+ keyed = [p for p in profiles if p.keychain or p.key_file]
67
+ if profile_name:
68
+ keyed = [p for p in keyed if p.name == profile_name]
69
+ if not keyed:
70
+ raise ValueError("no profile with a [key]; add one first (see docs/configuration.md)")
71
+
72
+ configured = [
73
+ {"name": s.name, "search_console": s.search_console, "ga4_property": s.ga4_property}
74
+ for p in profiles
75
+ for s in p.sites
76
+ ]
77
+ # Several profiles often share one key; read each key once.
78
+ seen: dict[str, Profile] = {}
79
+ for p in keyed:
80
+ seen.setdefault(p.key_source, p)
81
+
82
+ gsc, streams, errors = [], [], []
83
+ for profile in seen.values():
84
+ gsc += google.gsc_properties(profile)
85
+ try:
86
+ streams += google.ga4_web_streams(profile)
87
+ except Exception as exc: # noqa: BLE001 - GA4 is optional; Search Console still counts
88
+ errors.append(f"GA4 ({profile.key_source}): {exc}")
89
+
90
+ new = plan(sorted(set(gsc)), streams, configured)
91
+ result = {
92
+ "keys": list(seen),
93
+ "search_console_properties": sorted(set(gsc)),
94
+ "ga4_web_streams": streams,
95
+ "already_configured": [s["name"] for s in configured],
96
+ "new_sites": new,
97
+ "toml": to_toml(new),
98
+ }
99
+ if errors:
100
+ result["errors"] = errors
101
+ return result
102
+
103
+
104
+ def write(profile_name: str | None, toml: str) -> str:
105
+ """Append suggested blocks to a profile file and return its path."""
106
+ paths = config_paths()
107
+ if profile_name:
108
+ paths = [p for p in paths if p.stem == profile_name]
109
+ if len(paths) != 1:
110
+ names = ", ".join(p.stem for p in config_paths()) or "none"
111
+ raise ValueError(f"pick one profile with --profile to write to. Profiles: {names}")
112
+ path = paths[0]
113
+ text = path.read_text().rstrip()
114
+ # New sites go before [stores] when it exists, so the file keeps its usual order.
115
+ marker = "\n[stores]"
116
+ if marker in text:
117
+ head, tail = text.split(marker, 1)
118
+ text = f"{head.rstrip()}\n\n{toml}\n{marker}{tail}"
119
+ else:
120
+ text = f"{text}\n\n{toml}"
121
+ path.write_text(text + "\n")
122
+ return str(path)
@@ -0,0 +1,126 @@
1
+ """Search Console and GA4 reads through a service account. Read-only scopes only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import quote
6
+
7
+ import httpx
8
+ from google.auth.transport.requests import Request
9
+ from google.oauth2 import service_account
10
+
11
+ from dottide.config import Profile
12
+
13
+ SCOPES = [
14
+ "https://www.googleapis.com/auth/webmasters.readonly",
15
+ "https://www.googleapis.com/auth/analytics.readonly",
16
+ ]
17
+ GSC = "https://searchconsole.googleapis.com/webmasters/v3"
18
+ GA4_DATA = "https://analyticsdata.googleapis.com/v1beta"
19
+ GA4_ADMIN = "https://analyticsadmin.googleapis.com/v1beta"
20
+
21
+ _credentials: dict[str, service_account.Credentials] = {}
22
+
23
+
24
+ def client_email(profile: Profile) -> str:
25
+ return profile.key_info()["client_email"]
26
+
27
+
28
+ def _token(profile: Profile) -> str:
29
+ creds = _credentials.get(profile.name)
30
+ if creds is None:
31
+ creds = service_account.Credentials.from_service_account_info(
32
+ profile.key_info(), scopes=SCOPES
33
+ )
34
+ _credentials[profile.name] = creds
35
+ if not creds.valid:
36
+ creds.refresh(Request())
37
+ return creds.token
38
+
39
+
40
+ def _call(profile: Profile, method: str, url: str, **kwargs) -> dict:
41
+ headers = {"Authorization": f"Bearer {_token(profile)}"}
42
+ response = httpx.request(method, url, headers=headers, timeout=30, **kwargs)
43
+ if response.status_code >= 400:
44
+ try:
45
+ message = response.json()["error"]["message"]
46
+ except (ValueError, KeyError, TypeError):
47
+ message = response.text[:200]
48
+ raise RuntimeError(f"Google API {response.status_code}: {message}")
49
+ return response.json()
50
+
51
+
52
+ def gsc_properties(profile: Profile) -> list[str]:
53
+ entries = _call(profile, "GET", f"{GSC}/sites").get("siteEntry", [])
54
+ return [e["siteUrl"] for e in entries if e.get("permissionLevel") != "siteUnverifiedUser"]
55
+
56
+
57
+ def gsc_query(
58
+ profile: Profile, prop: str, start: str, end: str, dimension: str | None, limit: int = 25
59
+ ) -> dict[str, dict]:
60
+ """Rows keyed by query or page. With no dimension, one row keyed "total"."""
61
+ body: dict = {"startDate": start, "endDate": end, "rowLimit": limit}
62
+ if dimension:
63
+ body["dimensions"] = [dimension]
64
+ url = f"{GSC}/sites/{quote(prop, safe='')}/searchAnalytics/query"
65
+ rows = _call(profile, "POST", url, json=body).get("rows", [])
66
+ return {
67
+ (row["keys"][0] if dimension else "total"): {
68
+ "clicks": row["clicks"],
69
+ "impressions": row["impressions"],
70
+ "ctr": round(row["ctr"] * 100, 2),
71
+ "position": round(row["position"], 1),
72
+ }
73
+ for row in rows
74
+ }
75
+
76
+
77
+ def ga4_sources(profile: Profile, prop: str, start: str, end: str) -> list[dict]:
78
+ """Sessions by source and medium, biggest first."""
79
+ body = {
80
+ "dateRanges": [{"startDate": start, "endDate": end}],
81
+ "dimensions": [{"name": "sessionSource"}, {"name": "sessionMedium"}],
82
+ "metrics": [{"name": "sessions"}, {"name": "engagedSessions"}, {"name": "totalUsers"}],
83
+ "orderBys": [{"metric": {"metricName": "sessions"}, "desc": True}],
84
+ "limit": 1000,
85
+ }
86
+ data = _call(profile, "POST", f"{GA4_DATA}/properties/{prop}:runReport", json=body)
87
+ rows = []
88
+ for row in data.get("rows", []):
89
+ source, medium = (d["value"] for d in row["dimensionValues"])
90
+ sessions, engaged, users = (int(m["value"]) for m in row["metricValues"])
91
+ rows.append(
92
+ {
93
+ "source": source,
94
+ "medium": medium,
95
+ "sessions": sessions,
96
+ "engaged_sessions": engaged,
97
+ "users": users,
98
+ }
99
+ )
100
+ return rows
101
+
102
+
103
+ def ga4_web_streams(profile: Profile) -> list[dict]:
104
+ """Every GA4 web stream the key can read, with the site address it tracks."""
105
+ streams, page = [], None
106
+ while True:
107
+ params = {"pageSize": 200, **({"pageToken": page} if page else {})}
108
+ data = _call(profile, "GET", f"{GA4_ADMIN}/accountSummaries", params=params)
109
+ for account in data.get("accountSummaries", []):
110
+ for prop in account.get("propertySummaries", []):
111
+ pid = prop["property"].split("/")[1]
112
+ url = f"{GA4_ADMIN}/properties/{pid}/dataStreams"
113
+ for stream in _call(profile, "GET", url).get("dataStreams", []):
114
+ web = stream.get("webStreamData")
115
+ if web:
116
+ streams.append(
117
+ {
118
+ "property": pid,
119
+ "name": prop.get("displayName"),
120
+ "measurement_id": web.get("measurementId"),
121
+ "url": web.get("defaultUri", ""),
122
+ }
123
+ )
124
+ page = data.get("nextPageToken")
125
+ if not page:
126
+ return streams