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.
- dottide-0.2.0/.gitignore +13 -0
- dottide-0.2.0/CHANGELOG.md +14 -0
- dottide-0.2.0/LICENSE +21 -0
- dottide-0.2.0/PKG-INFO +115 -0
- dottide-0.2.0/README.md +90 -0
- dottide-0.2.0/pyproject.toml +49 -0
- dottide-0.2.0/src/dottide/__init__.py +0 -0
- dottide-0.2.0/src/dottide/config.py +106 -0
- dottide-0.2.0/src/dottide/discover.py +122 -0
- dottide-0.2.0/src/dottide/google.py +126 -0
- dottide-0.2.0/src/dottide/keys.py +53 -0
- dottide-0.2.0/src/dottide/profile_template.toml +25 -0
- dottide-0.2.0/src/dottide/report.py +197 -0
- dottide-0.2.0/src/dottide/server.py +172 -0
- dottide-0.2.0/src/dottide/snapshot.py +65 -0
- dottide-0.2.0/src/dottide/stores.py +94 -0
- dottide-0.2.0/tests/test_discover.py +60 -0
- dottide-0.2.0/tests/test_report.py +72 -0
- dottide-0.2.0/uv.lock +1038 -0
dottide-0.2.0/.gitignore
ADDED
|
@@ -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.
|
dottide-0.2.0/README.md
ADDED
|
@@ -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
|