dottide 0.2.0__py3-none-any.whl
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/__init__.py +0 -0
- dottide/config.py +106 -0
- dottide/discover.py +122 -0
- dottide/google.py +126 -0
- dottide/keys.py +53 -0
- dottide/profile_template.toml +25 -0
- dottide/report.py +197 -0
- dottide/server.py +172 -0
- dottide/snapshot.py +65 -0
- dottide/stores.py +94 -0
- dottide-0.2.0.dist-info/METADATA +115 -0
- dottide-0.2.0.dist-info/RECORD +15 -0
- dottide-0.2.0.dist-info/WHEEL +4 -0
- dottide-0.2.0.dist-info/entry_points.txt +2 -0
- dottide-0.2.0.dist-info/licenses/LICENSE +21 -0
dottide/__init__.py
ADDED
|
File without changes
|
dottide/config.py
ADDED
|
@@ -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
|
dottide/discover.py
ADDED
|
@@ -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)
|
dottide/google.py
ADDED
|
@@ -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
|
dottide/keys.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Service account keys, read from the macOS keychain or a file outside any repo.
|
|
2
|
+
|
|
3
|
+
Keychain values are stored base64-encoded: `security find-generic-password -w` prints values
|
|
4
|
+
that contain newlines as hex, and base64 also lets us pass the key on stdin instead of argv,
|
|
5
|
+
so it never shows up in the process list.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
SECURITY = "/usr/bin/security"
|
|
16
|
+
ACCOUNT = "dottide"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _validate(info: dict, where: str) -> dict:
|
|
20
|
+
if info.get("type") != "service_account" or "client_email" not in info:
|
|
21
|
+
raise ValueError(f"{where} is not a Google service account key")
|
|
22
|
+
return info
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read(service: str) -> dict:
|
|
26
|
+
out = subprocess.run(
|
|
27
|
+
[SECURITY, "find-generic-password", "-s", service, "-a", ACCOUNT, "-w"],
|
|
28
|
+
capture_output=True,
|
|
29
|
+
text=True,
|
|
30
|
+
)
|
|
31
|
+
if out.returncode != 0:
|
|
32
|
+
raise PermissionError(
|
|
33
|
+
f"no keychain item {service!r}; run: dottide import-key {service} <key.json>"
|
|
34
|
+
)
|
|
35
|
+
return _validate(json.loads(base64.b64decode(out.stdout.strip())), f"keychain item {service!r}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def read_file(path: str) -> dict:
|
|
39
|
+
return _validate(json.loads(Path(path).expanduser().read_text()), path)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def store(service: str, path: str) -> str:
|
|
43
|
+
"""Copy a key file into the keychain. Returns the service account email."""
|
|
44
|
+
info = read_file(path)
|
|
45
|
+
blob = base64.b64encode(json.dumps(info, separators=(",", ":")).encode()).decode()
|
|
46
|
+
label = f"'dotTide {service}'"
|
|
47
|
+
command = f"add-generic-password -U -s {service} -a {ACCOUNT} -l {label} -w {blob}\n"
|
|
48
|
+
out = subprocess.run([SECURITY, "-i"], input=command, capture_output=True, text=True)
|
|
49
|
+
if out.returncode != 0 or out.stderr.strip():
|
|
50
|
+
raise RuntimeError(f"keychain write failed: {out.stderr.strip() or out.returncode}")
|
|
51
|
+
if read(service)["client_email"] != info["client_email"]:
|
|
52
|
+
raise RuntimeError("keychain read-back did not match the key file")
|
|
53
|
+
return info["client_email"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# dotTide profile, written by `dottide init`. The file name is the profile name.
|
|
2
|
+
# Never commit a filled-in copy. Every field is explained in docs/configuration.md.
|
|
3
|
+
|
|
4
|
+
[key]
|
|
5
|
+
# Where the Google service account key lives. Pick one.
|
|
6
|
+
keychain = "dottide-main" # set with: dottide import-key dottide-main key.json
|
|
7
|
+
# file = "~/.config/dottide/keys/main.json"
|
|
8
|
+
|
|
9
|
+
# One block per site. `dottide discover --write` adds them for you once the key can read your sites.
|
|
10
|
+
#
|
|
11
|
+
# search_console: the property exactly as Search Console shows it:
|
|
12
|
+
# "sc-domain:example.com" for a domain property, "https://example.com/" for a URL prefix.
|
|
13
|
+
# ga4_property: the numeric property ID (Admin > Property details), not the G- measurement ID.
|
|
14
|
+
# Leave it out for sites without GA4.
|
|
15
|
+
#
|
|
16
|
+
# [[sites]]
|
|
17
|
+
# name = "example.com"
|
|
18
|
+
# search_console = "sc-domain:example.com"
|
|
19
|
+
# ga4_property = "123456789"
|
|
20
|
+
|
|
21
|
+
[stores]
|
|
22
|
+
# Public numbers, no key needed.
|
|
23
|
+
wordpress_org = [] # plugin slugs
|
|
24
|
+
firefox = [] # Firefox Add-ons slugs
|
|
25
|
+
github_owners = [] # users or orgs; only public repos are counted
|
dottide/report.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""The four reports, shared by the MCP server, the CLI, and the weekly snapshot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, timedelta
|
|
6
|
+
|
|
7
|
+
from dottide import google, stores
|
|
8
|
+
from dottide.config import Profile, find_site, load_profiles
|
|
9
|
+
|
|
10
|
+
# Search Console data settles about three days after the fact.
|
|
11
|
+
GSC_LAG_DAYS = 3
|
|
12
|
+
|
|
13
|
+
AI_SOURCES = {
|
|
14
|
+
"chatgpt.com": "ChatGPT",
|
|
15
|
+
"chat.openai.com": "ChatGPT",
|
|
16
|
+
"perplexity.ai": "Perplexity",
|
|
17
|
+
"copilot.microsoft.com": "Copilot",
|
|
18
|
+
"copilot.com": "Copilot",
|
|
19
|
+
"gemini.google.com": "Gemini",
|
|
20
|
+
"bard.google.com": "Gemini",
|
|
21
|
+
"claude.ai": "Claude",
|
|
22
|
+
"chat.mistral.ai": "Mistral",
|
|
23
|
+
"chat.deepseek.com": "DeepSeek",
|
|
24
|
+
"grok.com": "Grok",
|
|
25
|
+
"meta.ai": "Meta AI",
|
|
26
|
+
"you.com": "You.com",
|
|
27
|
+
"phind.com": "Phind",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ai_assistant(source: str) -> str | None:
|
|
32
|
+
host = source.lower().strip().removeprefix("www.")
|
|
33
|
+
for domain, name in AI_SOURCES.items():
|
|
34
|
+
if host == domain or host.endswith("." + domain):
|
|
35
|
+
return name
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def period(start: str | None, end: str | None, days: int) -> tuple[date, date]:
|
|
40
|
+
last = date.fromisoformat(end) if end else date.today() - timedelta(days=GSC_LAG_DAYS)
|
|
41
|
+
first = date.fromisoformat(start) if start else last - timedelta(days=days - 1)
|
|
42
|
+
if first > last:
|
|
43
|
+
raise ValueError(f"start {first} is after end {last}")
|
|
44
|
+
return first, last
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def previous(first: date, last: date) -> tuple[date, date]:
|
|
48
|
+
length = (last - first).days + 1
|
|
49
|
+
prev_last = first - timedelta(days=1)
|
|
50
|
+
return prev_last - timedelta(days=length - 1), prev_last
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _delta(now: dict, before: dict | None, fields: tuple[str, ...]) -> dict:
|
|
54
|
+
out = dict(now)
|
|
55
|
+
for f in fields:
|
|
56
|
+
old = (before or {}).get(f)
|
|
57
|
+
out[f"previous_{f}"] = old
|
|
58
|
+
out[f"change_{f}"] = None if old is None else round(now[f] - old, 2)
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def sites() -> dict:
|
|
63
|
+
"""Every configured site, the key that covers it, and whether access works when you ask."""
|
|
64
|
+
report = []
|
|
65
|
+
for profile in load_profiles():
|
|
66
|
+
entry: dict = {"profile": profile.name, "key": profile.key_source, "sites": []}
|
|
67
|
+
try:
|
|
68
|
+
entry["service_account"] = google.client_email(profile)
|
|
69
|
+
visible = google.gsc_properties(profile)
|
|
70
|
+
entry["search_console_visible"] = visible
|
|
71
|
+
except Exception as exc: # noqa: BLE001 - report every failure, never stop the list
|
|
72
|
+
entry["key_error"] = str(exc)
|
|
73
|
+
visible = None
|
|
74
|
+
for site in profile.sites:
|
|
75
|
+
status: dict = {"site": site.name}
|
|
76
|
+
if site.search_console:
|
|
77
|
+
if visible is None:
|
|
78
|
+
status["search_console"] = "no key access"
|
|
79
|
+
elif site.search_console in visible:
|
|
80
|
+
status["search_console"] = "ok"
|
|
81
|
+
else:
|
|
82
|
+
near = [v for v in visible if site.name in v]
|
|
83
|
+
status["search_console"] = "not shared with this key" + (
|
|
84
|
+
f"; visible instead: {', '.join(near)}" if near else ""
|
|
85
|
+
)
|
|
86
|
+
if site.ga4_property:
|
|
87
|
+
status["ga4"] = _ga4_check(profile, site.ga4_property)
|
|
88
|
+
else:
|
|
89
|
+
status["ga4"] = "not used"
|
|
90
|
+
entry["sites"].append(status)
|
|
91
|
+
report.append(entry)
|
|
92
|
+
return {"profiles": report}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _ga4_check(profile: Profile, prop: str) -> str:
|
|
96
|
+
day = (date.today() - timedelta(days=1)).isoformat()
|
|
97
|
+
try:
|
|
98
|
+
google.ga4_sources(profile, prop, day, day)
|
|
99
|
+
return "ok"
|
|
100
|
+
except Exception as exc: # noqa: BLE001
|
|
101
|
+
return str(exc)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def search_queries(
|
|
105
|
+
site: str,
|
|
106
|
+
by: str = "query",
|
|
107
|
+
start: str | None = None,
|
|
108
|
+
end: str | None = None,
|
|
109
|
+
days: int = 28,
|
|
110
|
+
compare: bool = False,
|
|
111
|
+
limit: int = 25,
|
|
112
|
+
) -> dict:
|
|
113
|
+
if by not in ("query", "page"):
|
|
114
|
+
raise ValueError("by must be 'query' or 'page'")
|
|
115
|
+
profile, s = find_site(load_profiles(), site)
|
|
116
|
+
if not s.search_console:
|
|
117
|
+
raise ValueError(f"{site} has no search_console property in its profile")
|
|
118
|
+
first, last = period(start, end, days)
|
|
119
|
+
fields = ("clicks", "impressions", "ctr", "position")
|
|
120
|
+
rows = google.gsc_query(profile, s.search_console, str(first), str(last), by, limit)
|
|
121
|
+
totals = google.gsc_query(profile, s.search_console, str(first), str(last), None).get(
|
|
122
|
+
"total", dict.fromkeys(fields, 0)
|
|
123
|
+
)
|
|
124
|
+
result: dict = {
|
|
125
|
+
"site": site,
|
|
126
|
+
"property": s.search_console,
|
|
127
|
+
"start": str(first),
|
|
128
|
+
"end": str(last),
|
|
129
|
+
"by": by,
|
|
130
|
+
}
|
|
131
|
+
if compare:
|
|
132
|
+
p_first, p_last = previous(first, last)
|
|
133
|
+
p_args = (profile, s.search_console, str(p_first), str(p_last))
|
|
134
|
+
before = google.gsc_query(*p_args, by, 5000)
|
|
135
|
+
p_totals = google.gsc_query(*p_args, None).get("total")
|
|
136
|
+
result["previous_start"], result["previous_end"] = str(p_first), str(p_last)
|
|
137
|
+
result["totals"] = _delta(totals, p_totals, fields)
|
|
138
|
+
result["rows"] = [{by: k, **_delta(v, before.get(k), fields)} for k, v in rows.items()]
|
|
139
|
+
else:
|
|
140
|
+
result["totals"] = totals
|
|
141
|
+
result["rows"] = [{by: k, **v} for k, v in rows.items()]
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _traffic_summary(rows: list[dict], limit: int) -> dict:
|
|
146
|
+
ai: dict[str, dict] = {}
|
|
147
|
+
for row in rows:
|
|
148
|
+
if name := ai_assistant(row["source"]):
|
|
149
|
+
bucket = ai.setdefault(name, {"assistant": name, "sessions": 0, "engaged_sessions": 0})
|
|
150
|
+
bucket["sessions"] += row["sessions"]
|
|
151
|
+
bucket["engaged_sessions"] += row["engaged_sessions"]
|
|
152
|
+
return {
|
|
153
|
+
"sessions": sum(r["sessions"] for r in rows),
|
|
154
|
+
"ai_sessions": sum(a["sessions"] for a in ai.values()),
|
|
155
|
+
"ai_referrals": sorted(ai.values(), key=lambda a: -a["sessions"]),
|
|
156
|
+
"sources": rows[:limit],
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def traffic(
|
|
161
|
+
site: str,
|
|
162
|
+
start: str | None = None,
|
|
163
|
+
end: str | None = None,
|
|
164
|
+
days: int = 28,
|
|
165
|
+
compare: bool = False,
|
|
166
|
+
limit: int = 25,
|
|
167
|
+
) -> dict:
|
|
168
|
+
profile, s = find_site(load_profiles(), site)
|
|
169
|
+
if not s.ga4_property:
|
|
170
|
+
raise ValueError(f"{site} has no GA4 property; traffic only covers sites with GA4")
|
|
171
|
+
first, last = period(start, end, days)
|
|
172
|
+
rows = google.ga4_sources(profile, s.ga4_property, str(first), str(last))
|
|
173
|
+
result = {"site": site, "property": s.ga4_property, "start": str(first), "end": str(last)}
|
|
174
|
+
result |= _traffic_summary(rows, limit)
|
|
175
|
+
if compare:
|
|
176
|
+
p_first, p_last = previous(first, last)
|
|
177
|
+
p_rows = google.ga4_sources(profile, s.ga4_property, str(p_first), str(p_last))
|
|
178
|
+
before = _traffic_summary(p_rows, limit)
|
|
179
|
+
result["previous"] = {
|
|
180
|
+
"start": str(p_first),
|
|
181
|
+
"end": str(p_last),
|
|
182
|
+
"sessions": before["sessions"],
|
|
183
|
+
"ai_sessions": before["ai_sessions"],
|
|
184
|
+
"ai_referrals": before["ai_referrals"],
|
|
185
|
+
}
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def store_stats(profile: str | None = None) -> dict:
|
|
190
|
+
merged: dict[str, list] = {}
|
|
191
|
+
for p in load_profiles():
|
|
192
|
+
if profile and p.name != profile:
|
|
193
|
+
continue
|
|
194
|
+
for store, items in p.stores.items():
|
|
195
|
+
merged.setdefault(store, [])
|
|
196
|
+
merged[store] += [i for i in items if i not in merged[store]]
|
|
197
|
+
return stores.collect(merged)
|
dottide/server.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""dotTide MCP server and command line.
|
|
2
|
+
|
|
3
|
+
dottide run the MCP server on stdio (what MCP clients call)
|
|
4
|
+
dottide sites print any report as JSON; also search_queries, traffic, store_stats
|
|
5
|
+
dottide init write a starter profile to ~/.config/dottide/main.toml
|
|
6
|
+
dottide discover [--write] suggest profile entries for sites the key can read
|
|
7
|
+
dottide snapshot [--out DIR] write the weekly snapshot, one file per profile
|
|
8
|
+
dottide import-key NAME FILE copy a service account key into the macOS keychain
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
from mcp.server.mcpserver import MCPServer
|
|
19
|
+
from mcp.types import ToolAnnotations
|
|
20
|
+
|
|
21
|
+
from dottide import discover as finder
|
|
22
|
+
from dottide import keys, report, snapshot
|
|
23
|
+
from dottide.config import init_profile
|
|
24
|
+
|
|
25
|
+
READ_ONLY = ToolAnnotations(read_only_hint=True, open_world_hint=True)
|
|
26
|
+
|
|
27
|
+
server = MCPServer(
|
|
28
|
+
name="dottide",
|
|
29
|
+
instructions=(
|
|
30
|
+
"Read-only numbers for every configured site and product. Call `sites` first to see "
|
|
31
|
+
"which sites exist and whether their access works. Search Console data lags about "
|
|
32
|
+
"three days, so default ranges end three days ago."
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@server.tool(annotations=READ_ONLY)
|
|
38
|
+
def sites() -> dict:
|
|
39
|
+
"""Every configured site, which key covers it, and whether Search Console and GA4 access
|
|
40
|
+
works at the moment of the call."""
|
|
41
|
+
return report.sites()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@server.tool(annotations=READ_ONLY)
|
|
45
|
+
def search_queries(
|
|
46
|
+
site: str,
|
|
47
|
+
by: str = "query",
|
|
48
|
+
start: str | None = None,
|
|
49
|
+
end: str | None = None,
|
|
50
|
+
days: int = 28,
|
|
51
|
+
compare: bool = False,
|
|
52
|
+
limit: int = 25,
|
|
53
|
+
) -> dict:
|
|
54
|
+
"""Search Console clicks, impressions, CTR (percent) and average position, by "query" or
|
|
55
|
+
"page". Dates are YYYY-MM-DD; without them the range is the last `days` days ending three
|
|
56
|
+
days ago. compare=true adds the previous period of the same length and the change."""
|
|
57
|
+
return report.search_queries(site, by, start, end, days, compare, limit)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@server.tool(annotations=READ_ONLY)
|
|
61
|
+
def traffic(
|
|
62
|
+
site: str,
|
|
63
|
+
start: str | None = None,
|
|
64
|
+
end: str | None = None,
|
|
65
|
+
days: int = 28,
|
|
66
|
+
compare: bool = False,
|
|
67
|
+
limit: int = 25,
|
|
68
|
+
) -> dict:
|
|
69
|
+
"""GA4 sessions by source and medium, plus AI assistant referrals (ChatGPT, Perplexity,
|
|
70
|
+
Copilot, Gemini, Claude and others) totalled separately. Only for sites with GA4."""
|
|
71
|
+
return report.traffic(site, start, end, days, compare, limit)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@server.tool(annotations=READ_ONLY)
|
|
75
|
+
def store_stats(profile: str | None = None) -> dict:
|
|
76
|
+
"""Live public store numbers that need no keys: WordPress.org plugins (installs, rating,
|
|
77
|
+
downloads last 7 days), Firefox Add-ons (daily users, ratings), GitHub stars."""
|
|
78
|
+
return report.store_stats(profile)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@server.tool(annotations=READ_ONLY)
|
|
82
|
+
def discover(profile: str | None = None) -> dict:
|
|
83
|
+
"""Every Search Console property and GA4 web stream the key can read, matched by domain,
|
|
84
|
+
with ready-to-paste [[sites]] blocks for any site not in a profile yet. Reads only; the
|
|
85
|
+
user adds the blocks with `dottide discover --write`."""
|
|
86
|
+
return finder.discover(profile)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main() -> None:
|
|
90
|
+
# httpx logs every request URL at INFO; keep stderr for real problems.
|
|
91
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
92
|
+
if len(sys.argv) == 1:
|
|
93
|
+
server.run("stdio")
|
|
94
|
+
return
|
|
95
|
+
parser = argparse.ArgumentParser(prog="dottide")
|
|
96
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
97
|
+
sub.add_parser("sites")
|
|
98
|
+
q = sub.add_parser("search_queries")
|
|
99
|
+
q.add_argument("site")
|
|
100
|
+
q.add_argument("--by", default="query", choices=["query", "page"])
|
|
101
|
+
t = sub.add_parser("traffic")
|
|
102
|
+
t.add_argument("site")
|
|
103
|
+
for p in (q, t):
|
|
104
|
+
p.add_argument("--start")
|
|
105
|
+
p.add_argument("--end")
|
|
106
|
+
p.add_argument("--days", type=int, default=28)
|
|
107
|
+
p.add_argument("--compare", action="store_true")
|
|
108
|
+
p.add_argument("--limit", type=int, default=25)
|
|
109
|
+
s = sub.add_parser("store_stats")
|
|
110
|
+
s.add_argument("--profile")
|
|
111
|
+
i = sub.add_parser("init")
|
|
112
|
+
i.add_argument("--profile", default="main", help="profile name, which is also the file name")
|
|
113
|
+
d = sub.add_parser("discover")
|
|
114
|
+
d.add_argument("--profile", help="only use this profile's key, and write to it")
|
|
115
|
+
d.add_argument("--write", action="store_true", help="append the new sites to the profile")
|
|
116
|
+
snap = sub.add_parser("snapshot")
|
|
117
|
+
snap.add_argument("--out", help="folder for <profile>-<year>-W<week>.json; stdout if absent")
|
|
118
|
+
k = sub.add_parser("import-key")
|
|
119
|
+
k.add_argument("name", help="keychain item name, for example dottide-main")
|
|
120
|
+
k.add_argument("file", help="path to the downloaded service account JSON key")
|
|
121
|
+
args = parser.parse_args()
|
|
122
|
+
try:
|
|
123
|
+
_run(args)
|
|
124
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
125
|
+
# Expected problems (missing key, unknown site, existing file) get one clear line.
|
|
126
|
+
sys.exit(f"dottide: {exc}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _run(args: argparse.Namespace) -> None:
|
|
130
|
+
if args.command == "import-key":
|
|
131
|
+
print(f"stored {keys.store(args.name, args.file)} in keychain item {args.name}")
|
|
132
|
+
return
|
|
133
|
+
if args.command == "init":
|
|
134
|
+
path = init_profile(args.profile)
|
|
135
|
+
print(f"wrote {path}")
|
|
136
|
+
print("next: dottide import-key dottide-main <key.json>, then dottide discover --write")
|
|
137
|
+
return
|
|
138
|
+
if args.command == "discover":
|
|
139
|
+
found = finder.discover(args.profile)
|
|
140
|
+
if args.write and found["new_sites"]:
|
|
141
|
+
path = finder.write(args.profile, found["toml"])
|
|
142
|
+
print(f"added {len(found['new_sites'])} sites to {path}")
|
|
143
|
+
elif found["new_sites"]:
|
|
144
|
+
print(found["toml"])
|
|
145
|
+
print(
|
|
146
|
+
f"\n# {len(found['new_sites'])} new sites. Add them with --write.", file=sys.stderr
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
print("Every site the key can read is already in a profile.")
|
|
150
|
+
for error in found.get("errors", []):
|
|
151
|
+
print(f"note: {error}", file=sys.stderr)
|
|
152
|
+
return
|
|
153
|
+
if args.command == "snapshot":
|
|
154
|
+
snapshot.run(args.out)
|
|
155
|
+
return
|
|
156
|
+
if args.command == "sites":
|
|
157
|
+
result = report.sites()
|
|
158
|
+
elif args.command == "search_queries":
|
|
159
|
+
result = report.search_queries(
|
|
160
|
+
args.site, args.by, args.start, args.end, args.days, args.compare, args.limit
|
|
161
|
+
)
|
|
162
|
+
elif args.command == "traffic":
|
|
163
|
+
result = report.traffic(
|
|
164
|
+
args.site, args.start, args.end, args.days, args.compare, args.limit
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
result = report.store_stats(args.profile)
|
|
168
|
+
print(json.dumps(result, indent=2))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
main()
|
dottide/snapshot.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Weekly snapshot: last full week against the week before, one JSON file per profile.
|
|
2
|
+
|
|
3
|
+
One file per profile keeps each side's numbers apart, the same way the keys are.
|
|
4
|
+
A failing site records its error and the snapshot carries on, so one revoked
|
|
5
|
+
permission never costs the whole week's numbers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from datetime import UTC, date, datetime, timedelta
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from dottide import report
|
|
15
|
+
from dottide.config import load_profiles
|
|
16
|
+
|
|
17
|
+
SCHEMA = 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _safe(fn, *args, **kwargs) -> dict:
|
|
21
|
+
try:
|
|
22
|
+
return fn(*args, **kwargs)
|
|
23
|
+
except Exception as exc: # noqa: BLE001 - the error is the data point
|
|
24
|
+
return {"error": str(exc)}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build(profile_name: str) -> dict:
|
|
28
|
+
profile = next(p for p in load_profiles() if p.name == profile_name)
|
|
29
|
+
last = date.today() - timedelta(days=report.GSC_LAG_DAYS)
|
|
30
|
+
first = last - timedelta(days=6)
|
|
31
|
+
week = {"start": str(first), "end": str(last), "days": 7, "compare": True}
|
|
32
|
+
sites = []
|
|
33
|
+
for site in profile.sites:
|
|
34
|
+
entry: dict = {"site": site.name}
|
|
35
|
+
if site.search_console:
|
|
36
|
+
entry["queries"] = _safe(report.search_queries, site.name, "query", **week, limit=10)
|
|
37
|
+
entry["pages"] = _safe(report.search_queries, site.name, "page", **week, limit=10)
|
|
38
|
+
if site.ga4_property:
|
|
39
|
+
entry["traffic"] = _safe(report.traffic, site.name, **week, limit=10)
|
|
40
|
+
sites.append(entry)
|
|
41
|
+
return {
|
|
42
|
+
"schema": SCHEMA,
|
|
43
|
+
"profile": profile.name,
|
|
44
|
+
"generated_at": datetime.now(UTC).isoformat(timespec="seconds"),
|
|
45
|
+
"week": {"start": str(first), "end": str(last)},
|
|
46
|
+
"sites": sites,
|
|
47
|
+
"stores": _safe(report.store_stats, profile.name),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run(out: str | None) -> list[Path]:
|
|
52
|
+
written = []
|
|
53
|
+
year, week, _ = date.today().isocalendar()
|
|
54
|
+
for profile in load_profiles():
|
|
55
|
+
data = build(profile.name)
|
|
56
|
+
if out is None:
|
|
57
|
+
print(json.dumps(data, indent=2))
|
|
58
|
+
continue
|
|
59
|
+
folder = Path(out).expanduser()
|
|
60
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
path = folder / f"{profile.name}-{year}-W{week:02d}.json"
|
|
62
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
63
|
+
written.append(path)
|
|
64
|
+
print(path)
|
|
65
|
+
return written
|
dottide/stores.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Public store numbers that need no login: WordPress.org, Firefox Add-ons, GitHub."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import os
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
TIMEOUT = 20
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get(url: str, **kwargs) -> httpx.Response:
|
|
15
|
+
return httpx.get(url, timeout=TIMEOUT, follow_redirects=True, **kwargs)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def wordpress_org(slug: str) -> dict:
|
|
19
|
+
info = _get(
|
|
20
|
+
"https://api.wordpress.org/plugins/info/1.2/",
|
|
21
|
+
params={"action": "plugin_information", "request[slug]": slug},
|
|
22
|
+
).json()
|
|
23
|
+
if "error" in info:
|
|
24
|
+
return {"slug": slug, "error": info["error"]}
|
|
25
|
+
daily = _get(
|
|
26
|
+
"https://api.wordpress.org/stats/plugin/1.0/downloads.php",
|
|
27
|
+
params={"slug": slug, "limit": 8},
|
|
28
|
+
).json()
|
|
29
|
+
# The endpoint returns full days only; keep the newest seven.
|
|
30
|
+
week = dict(sorted(daily.items())[-7:])
|
|
31
|
+
return {
|
|
32
|
+
"slug": slug,
|
|
33
|
+
"name": html.unescape(info.get("name") or ""),
|
|
34
|
+
"version": info.get("version"),
|
|
35
|
+
"active_installs": info.get("active_installs"),
|
|
36
|
+
"rating": info.get("rating"),
|
|
37
|
+
"num_ratings": info.get("num_ratings"),
|
|
38
|
+
"support_threads": info.get("support_threads"),
|
|
39
|
+
"support_threads_resolved": info.get("support_threads_resolved"),
|
|
40
|
+
"last_updated": info.get("last_updated"),
|
|
41
|
+
"downloads_last_7_days": sum(int(v) for v in week.values()),
|
|
42
|
+
"downloads_by_day": {day: int(v) for day, v in week.items()},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def firefox(slug: str) -> dict:
|
|
47
|
+
response = _get(f"https://addons.mozilla.org/api/v5/addons/addon/{slug}/")
|
|
48
|
+
if response.status_code != 200:
|
|
49
|
+
return {"slug": slug, "error": f"AMO {response.status_code}"}
|
|
50
|
+
data = response.json()
|
|
51
|
+
return {
|
|
52
|
+
"slug": slug,
|
|
53
|
+
"version": (data.get("current_version") or {}).get("version"),
|
|
54
|
+
"average_daily_users": data.get("average_daily_users"),
|
|
55
|
+
"weekly_downloads": data.get("weekly_downloads"),
|
|
56
|
+
"rating": data.get("ratings", {}).get("average"),
|
|
57
|
+
"num_ratings": data.get("ratings", {}).get("count"),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def github(owner: str) -> dict:
|
|
62
|
+
"""Stars and forks for an owner's public repos. Anonymous unless DOTTIDE_GITHUB_TOKEN is set."""
|
|
63
|
+
headers = {"Accept": "application/vnd.github+json"}
|
|
64
|
+
if token := os.environ.get("DOTTIDE_GITHUB_TOKEN"):
|
|
65
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
66
|
+
response = _get(
|
|
67
|
+
f"https://api.github.com/users/{owner}/repos",
|
|
68
|
+
params={"type": "owner", "per_page": 100},
|
|
69
|
+
headers=headers,
|
|
70
|
+
)
|
|
71
|
+
if response.status_code == 403 and response.headers.get("x-ratelimit-remaining") == "0":
|
|
72
|
+
reset = datetime.fromtimestamp(int(response.headers["x-ratelimit-reset"]), UTC)
|
|
73
|
+
return {"owner": owner, "error": f"GitHub rate limit, resets {reset:%Y-%m-%d %H:%M} UTC"}
|
|
74
|
+
if response.status_code != 200:
|
|
75
|
+
return {"owner": owner, "error": f"GitHub {response.status_code}"}
|
|
76
|
+
repos = [
|
|
77
|
+
{"repo": r["name"], "stars": r["stargazers_count"], "forks": r["forks_count"]}
|
|
78
|
+
for r in response.json()
|
|
79
|
+
if not r.get("fork") and not r.get("private")
|
|
80
|
+
]
|
|
81
|
+
return {
|
|
82
|
+
"owner": owner,
|
|
83
|
+
"total_stars": sum(r["stars"] for r in repos),
|
|
84
|
+
"repos": sorted(repos, key=lambda r: -r["stars"]),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def collect(stores: dict) -> dict:
|
|
89
|
+
"""stores is the merged [stores] table: lists of slugs or owners per store."""
|
|
90
|
+
return {
|
|
91
|
+
"wordpress_org": [wordpress_org(s) for s in stores.get("wordpress_org", [])],
|
|
92
|
+
"firefox": [firefox(s) for s in stores.get("firefox", [])],
|
|
93
|
+
"github": [github(o) for o in stores.get("github_owners", [])],
|
|
94
|
+
}
|
|
@@ -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,15 @@
|
|
|
1
|
+
dottide/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
dottide/config.py,sha256=oIFWuaGRT4xY3R1rtJUM9nqEwZHmnxBtFWhJ4D9tLLE,3276
|
|
3
|
+
dottide/discover.py,sha256=pGFQ_4zAsYI0ra12oW8f9U1DBHEAFrFNJwuDNh_hE-U,4832
|
|
4
|
+
dottide/google.py,sha256=Vp_gs1KwQs37xdjpOC2mdisHIGoEZIMlPK52F0y70FQ,4872
|
|
5
|
+
dottide/keys.py,sha256=iqBMPMy7hOPs2x3YnOGBc3BWkTQ3E8cGyAQF7GtzqoM,2021
|
|
6
|
+
dottide/profile_template.toml,sha256=6mDlSKeT0Tnz0460gSYoY_3nk7WVEUb2IZJA5dLwP3w,1071
|
|
7
|
+
dottide/report.py,sha256=ZVGatjCopiswHKoYXVQ5zpCWrB93yeU6xPGdRJ3Q_Zo,7240
|
|
8
|
+
dottide/server.py,sha256=Gu0xnoWY_ulW4OY_shQ6kVbJH2fBJyZw4q2vmiTWMzU,6664
|
|
9
|
+
dottide/snapshot.py,sha256=z_rlheJkkSMMUNksRH4VJwAbnh8nx_caGsiL-4vyIcc,2288
|
|
10
|
+
dottide/stores.py,sha256=LoO_3Zu3KVjIQB0RxNauyDQoouD04FcIS6L_vlk_kjE,3622
|
|
11
|
+
dottide-0.2.0.dist-info/METADATA,sha256=aHKcJ956xx72Lk4_GXlcCxktcqm8li9l8jWWMaYzuXk,6409
|
|
12
|
+
dottide-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
13
|
+
dottide-0.2.0.dist-info/entry_points.txt,sha256=W7izUDENGBxbvryFkU_tm1woqJRZkfQ6Yhc9qMpQLZo,48
|
|
14
|
+
dottide-0.2.0.dist-info/licenses/LICENSE,sha256=9872o4dDkt3afhExxcLHMsWCbMY8ad2QlbR_AMw45UU,1069
|
|
15
|
+
dottide-0.2.0.dist-info/RECORD,,
|
|
@@ -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.
|