searchdiff 0.1.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tsuruta Lab
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.
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: searchdiff
3
+ Version: 0.1.0
4
+ Summary: Did the change work? Weekly blocks, cliffs, and difference-in-differences for Google Search Console data, from a CSV or the API.
5
+ Author: Tsuruta Lab
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/tsurutanmen/searchdiff
8
+ Project-URL: Issues, https://github.com/tsurutanmen/searchdiff/issues
9
+ Keywords: search console,seo,difference in differences,ctr,analytics
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Provides-Extra: api
19
+ Requires-Dist: google-auth>=2.0; extra == "api"
20
+ Requires-Dist: requests>=2.25; extra == "api"
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest>=7; extra == "test"
23
+ Dynamic: license-file
24
+
25
+ # searchdiff
26
+
27
+ Did the change work?
28
+
29
+ Search Console answers two questions badly on its own. "When did traffic drop, and how far back is
30
+ it?" gets a daily chart with the weekday wobble in it. "We rewrote the titles, did it help?" gets a
31
+ before/after on the changed pages, which contains the season, the algorithm update, and the change,
32
+ all mixed together. `searchdiff` gives the first question weekly blocks from a fixed anchor, and the
33
+ second a control group and a difference in differences.
34
+
35
+ ```
36
+ pip install git+https://github.com/tsurutanmen/searchdiff # CSV only, no dependencies
37
+ pip install "git+https://github.com/tsurutanmen/searchdiff#egg=searchdiff[api]" # plus the API
38
+ ```
39
+
40
+ ## Weekly blocks
41
+
42
+ ```
43
+ $ searchdiff weeks daily.csv --anchor 2026-07-18
44
+
45
+ week days clicks /day impr ctr pos vs prev of peak
46
+ 2026-07-18..07-24 7 1486 212.3 28420 5.2% 6.6 +2% 95%
47
+ 2026-07-25..07-31 7 1487 212.4 28605 5.2% 7.0 +0% 95%
48
+ 2026-08-01..08-07 7 1562 223.1 28365 5.5% 6.1 +5% 100% peak
49
+ 2026-08-08..08-14 7 1000 142.9 17816 5.6% 6.1 -36% 64% CLIFF
50
+ 2026-08-15..08-21 7 1031 147.3 18516 5.6% 5.9 +3% 66%
51
+ 2026-08-22..08-28 7 1197 171.0 20313 5.9% 6.2 +16% 77%
52
+ 2026-08-29..08-31 3 483 161.0 8482 5.7% 7.6 -6% 72%
53
+ last block is partial; its /day figure is comparable, its totals are not
54
+ note: cliff in the block starting 2026-08-08 (-36%); latest block is at 72% of peak
55
+ ```
56
+
57
+ `daily.csv` here is the `Dates` export from the Performance report, unchanged. Any CSV with
58
+ `date,clicks,impressions[,position]` works, with or without a `page` column. Blocks are counted
59
+ from the anchor in both directions, so the same anchor gives the same blocks next month.
60
+
61
+ ## Did the change work
62
+
63
+ ```
64
+ $ searchdiff effect pages.csv --change 2026-08-04 --treated "re:/p/0[0-4]$" --window 28
65
+
66
+ change on 2026-08-04, 28 days before and after
67
+
68
+ pages clicks/day impr/day ctr position
69
+ treated 5 30.25 -> 21.50 564.4 -> 409.5 5.36% -> 5.25% 7.2 -> 8.0
70
+ control 35 182.57 -> 143.82 3487.6 -> 2512.7 5.23% -> 5.72% 7.7 -> 7.4
71
+
72
+ difference in differences (treated change minus control change):
73
+ ctr -0.60 points (treated -0.11, control +0.49)
74
+ clicks/day -7.7 % (treated -28.9%, control -21.2%)
75
+ impr/day +0.5 % (treated -27.4%, control -28.0%)
76
+ position +0.95 ranks (treated +0.72, control -0.23; negative is better)
77
+ ```
78
+
79
+ The treated pages lost 29% of their clicks after the change. So did everything else: a traffic cliff
80
+ hit the whole site four days later. The difference in differences is within a CTR point of zero. The
81
+ rewrite did nothing, and a before/after on the five pages alone would have called it a disaster.
82
+
83
+ The same data with pages that really did get a lift:
84
+
85
+ ```
86
+ ctr +3.41 points (treated +3.33, control -0.09)
87
+ clicks/day +49.0 % (treated +20.4%, control -28.6%)
88
+ ```
89
+
90
+ `pages.csv` needs `date,page,clicks,impressions[,position]` rows, which the API gives and the UI does
91
+ not. `--treated` and `--control` take a substring, a glob, `re:` a regex, or `@file` with one URL per
92
+ line. The control defaults to every page not treated; a control of the same kind as the treated pages
93
+ is better. `--gap N` skips the first N days after the change for edits that take time to be re-indexed.
94
+
95
+ The report adds notes when the window runs past the data, when the treated group has too few clicks
96
+ for anything to be distinguishable from noise, and when treated and control moved together.
97
+
98
+ ## Fetching from the API
99
+
100
+ ```
101
+ searchdiff fetch --site https://example.com/ --key service-account.json \
102
+ --start 2026-06-01 --end 2026-08-31 --out pages.csv --coverage
103
+ ```
104
+
105
+ The service account's e-mail has to be added as a user of the property. `--coverage` prints how much
106
+ of the clicks the query dimension shows. Search Console hides rare queries; on a site of ours the
107
+ query-level sum was 39% of the total while the page-level sum matched it. Analyse by page unless the
108
+ words are the point. Paging past 25,000 rows is handled.
109
+
110
+ ## Python
111
+
112
+ ```python
113
+ import datetime as dt, searchdiff as sd
114
+
115
+ rows = sd.load_csv("pages.csv")
116
+ print(sd.weekly(rows, anchor=dt.date(2026, 7, 18)))
117
+ rep = sd.effect(rows, dt.date(2026, 8, 4), sd.matcher("re:/blog/"), window=28)
118
+ rep.did # {'ctr': points, 'clicks': relative, 'impressions': relative, 'position': ranks}
119
+ rep.to_dict()
120
+ ```
121
+
122
+ ## Where this came from
123
+
124
+ A title rewrite on a set of comparison pages, measured before/after, looked like nothing: 3.38% to
125
+ 3.37%. A second rewrite on another set, the same. Both times the tempting reading was "wait longer".
126
+ With the untouched pages as control the answer was the same and available on day 28, and the
127
+ proposal to rewrite 475 more titles was dropped. The weekly-block view is how a 40% cliff and its
128
+ slow return were tracked without re-reading a daily chart every session. The tool packages those
129
+ two habits.
130
+
131
+ ## Claude Code skill
132
+
133
+ `skill/searchdiff/SKILL.md` teaches Claude Code to reach for the control group instead of the
134
+ before/after, and how to read the two tables. Install by copying the folder:
135
+
136
+ ```
137
+ cp -r skill/searchdiff ~/.claude/skills/searchdiff
138
+ ```
139
+
140
+ ## License
141
+
142
+ MIT.
@@ -0,0 +1,118 @@
1
+ # searchdiff
2
+
3
+ Did the change work?
4
+
5
+ Search Console answers two questions badly on its own. "When did traffic drop, and how far back is
6
+ it?" gets a daily chart with the weekday wobble in it. "We rewrote the titles, did it help?" gets a
7
+ before/after on the changed pages, which contains the season, the algorithm update, and the change,
8
+ all mixed together. `searchdiff` gives the first question weekly blocks from a fixed anchor, and the
9
+ second a control group and a difference in differences.
10
+
11
+ ```
12
+ pip install git+https://github.com/tsurutanmen/searchdiff # CSV only, no dependencies
13
+ pip install "git+https://github.com/tsurutanmen/searchdiff#egg=searchdiff[api]" # plus the API
14
+ ```
15
+
16
+ ## Weekly blocks
17
+
18
+ ```
19
+ $ searchdiff weeks daily.csv --anchor 2026-07-18
20
+
21
+ week days clicks /day impr ctr pos vs prev of peak
22
+ 2026-07-18..07-24 7 1486 212.3 28420 5.2% 6.6 +2% 95%
23
+ 2026-07-25..07-31 7 1487 212.4 28605 5.2% 7.0 +0% 95%
24
+ 2026-08-01..08-07 7 1562 223.1 28365 5.5% 6.1 +5% 100% peak
25
+ 2026-08-08..08-14 7 1000 142.9 17816 5.6% 6.1 -36% 64% CLIFF
26
+ 2026-08-15..08-21 7 1031 147.3 18516 5.6% 5.9 +3% 66%
27
+ 2026-08-22..08-28 7 1197 171.0 20313 5.9% 6.2 +16% 77%
28
+ 2026-08-29..08-31 3 483 161.0 8482 5.7% 7.6 -6% 72%
29
+ last block is partial; its /day figure is comparable, its totals are not
30
+ note: cliff in the block starting 2026-08-08 (-36%); latest block is at 72% of peak
31
+ ```
32
+
33
+ `daily.csv` here is the `Dates` export from the Performance report, unchanged. Any CSV with
34
+ `date,clicks,impressions[,position]` works, with or without a `page` column. Blocks are counted
35
+ from the anchor in both directions, so the same anchor gives the same blocks next month.
36
+
37
+ ## Did the change work
38
+
39
+ ```
40
+ $ searchdiff effect pages.csv --change 2026-08-04 --treated "re:/p/0[0-4]$" --window 28
41
+
42
+ change on 2026-08-04, 28 days before and after
43
+
44
+ pages clicks/day impr/day ctr position
45
+ treated 5 30.25 -> 21.50 564.4 -> 409.5 5.36% -> 5.25% 7.2 -> 8.0
46
+ control 35 182.57 -> 143.82 3487.6 -> 2512.7 5.23% -> 5.72% 7.7 -> 7.4
47
+
48
+ difference in differences (treated change minus control change):
49
+ ctr -0.60 points (treated -0.11, control +0.49)
50
+ clicks/day -7.7 % (treated -28.9%, control -21.2%)
51
+ impr/day +0.5 % (treated -27.4%, control -28.0%)
52
+ position +0.95 ranks (treated +0.72, control -0.23; negative is better)
53
+ ```
54
+
55
+ The treated pages lost 29% of their clicks after the change. So did everything else: a traffic cliff
56
+ hit the whole site four days later. The difference in differences is within a CTR point of zero. The
57
+ rewrite did nothing, and a before/after on the five pages alone would have called it a disaster.
58
+
59
+ The same data with pages that really did get a lift:
60
+
61
+ ```
62
+ ctr +3.41 points (treated +3.33, control -0.09)
63
+ clicks/day +49.0 % (treated +20.4%, control -28.6%)
64
+ ```
65
+
66
+ `pages.csv` needs `date,page,clicks,impressions[,position]` rows, which the API gives and the UI does
67
+ not. `--treated` and `--control` take a substring, a glob, `re:` a regex, or `@file` with one URL per
68
+ line. The control defaults to every page not treated; a control of the same kind as the treated pages
69
+ is better. `--gap N` skips the first N days after the change for edits that take time to be re-indexed.
70
+
71
+ The report adds notes when the window runs past the data, when the treated group has too few clicks
72
+ for anything to be distinguishable from noise, and when treated and control moved together.
73
+
74
+ ## Fetching from the API
75
+
76
+ ```
77
+ searchdiff fetch --site https://example.com/ --key service-account.json \
78
+ --start 2026-06-01 --end 2026-08-31 --out pages.csv --coverage
79
+ ```
80
+
81
+ The service account's e-mail has to be added as a user of the property. `--coverage` prints how much
82
+ of the clicks the query dimension shows. Search Console hides rare queries; on a site of ours the
83
+ query-level sum was 39% of the total while the page-level sum matched it. Analyse by page unless the
84
+ words are the point. Paging past 25,000 rows is handled.
85
+
86
+ ## Python
87
+
88
+ ```python
89
+ import datetime as dt, searchdiff as sd
90
+
91
+ rows = sd.load_csv("pages.csv")
92
+ print(sd.weekly(rows, anchor=dt.date(2026, 7, 18)))
93
+ rep = sd.effect(rows, dt.date(2026, 8, 4), sd.matcher("re:/blog/"), window=28)
94
+ rep.did # {'ctr': points, 'clicks': relative, 'impressions': relative, 'position': ranks}
95
+ rep.to_dict()
96
+ ```
97
+
98
+ ## Where this came from
99
+
100
+ A title rewrite on a set of comparison pages, measured before/after, looked like nothing: 3.38% to
101
+ 3.37%. A second rewrite on another set, the same. Both times the tempting reading was "wait longer".
102
+ With the untouched pages as control the answer was the same and available on day 28, and the
103
+ proposal to rewrite 475 more titles was dropped. The weekly-block view is how a 40% cliff and its
104
+ slow return were tracked without re-reading a daily chart every session. The tool packages those
105
+ two habits.
106
+
107
+ ## Claude Code skill
108
+
109
+ `skill/searchdiff/SKILL.md` teaches Claude Code to reach for the control group instead of the
110
+ before/after, and how to read the two tables. Install by copying the folder:
111
+
112
+ ```
113
+ cp -r skill/searchdiff ~/.claude/skills/searchdiff
114
+ ```
115
+
116
+ ## License
117
+
118
+ MIT.
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "searchdiff"
7
+ version = "0.1.0"
8
+ description = "Did the change work? Weekly blocks, cliffs, and difference-in-differences for Google Search Console data, from a CSV or the API."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Tsuruta Lab" }]
13
+ keywords = ["search console", "seo", "difference in differences", "ctr", "analytics"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Internet :: WWW/HTTP :: Site Management",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.optional-dependencies]
24
+ api = ["google-auth>=2.0", "requests>=2.25"]
25
+ test = ["pytest>=7"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/tsurutanmen/searchdiff"
29
+ Issues = "https://github.com/tsurutanmen/searchdiff/issues"
30
+
31
+ [project.scripts]
32
+ searchdiff = "searchdiff.cli:main"
33
+
34
+ [tool.setuptools.packages.find]
35
+ include = ["searchdiff*"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
@@ -0,0 +1,14 @@
1
+ """searchdiff: did the change work?
2
+
3
+ Search Console data as weekly blocks (where was the cliff, how far back is
4
+ it) and as a difference in differences (the pages you changed against the
5
+ pages you did not, before and after). Reads the UI's CSV exports or pulls
6
+ from the API with a service account.
7
+ """
8
+
9
+ from .data import Row, Totals, load_csv, write_csv
10
+ from .weeks import weekly, WeeksReport
11
+ from .effect import effect, matcher, EffectReport
12
+
13
+ __version__ = "0.1.0"
14
+ __all__ = ["Row", "Totals", "load_csv", "write_csv", "weekly", "WeeksReport", "effect", "matcher", "EffectReport"]
@@ -0,0 +1,77 @@
1
+ """Pull rows from the Search Console API with a service account.
2
+
3
+ Needs the ``api`` extra: pip install "searchdiff[api]".
4
+ The service account's e-mail must be added as a user of the property.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import datetime as dt
10
+ from typing import Dict, List, Optional, Sequence
11
+
12
+ from .data import Row
13
+
14
+ SCOPE = "https://www.googleapis.com/auth/webmasters.readonly"
15
+ ROW_LIMIT = 25000
16
+
17
+
18
+ def _session(key_path: str):
19
+ try:
20
+ from google.oauth2 import service_account
21
+ from google.auth.transport.requests import AuthorizedSession
22
+ except ImportError as exc: # pragma: no cover
23
+ raise ImportError("pip install 'searchdiff[api]' for the Search Console API") from exc
24
+ creds = service_account.Credentials.from_service_account_file(key_path, scopes=[SCOPE])
25
+ return AuthorizedSession(creds)
26
+
27
+
28
+ def query(site: str, key_path: str, start: dt.date, end: dt.date, dimensions: Sequence[str] = ("date", "page"),
29
+ session=None, search_type: str = "web", data_state: str = "final") -> List[Dict]:
30
+ """All rows for the dimensions, following ``startRow`` until the API runs dry."""
31
+ import urllib.parse
32
+ sess = session or _session(key_path)
33
+ url = f"https://www.googleapis.com/webmasters/v3/sites/{urllib.parse.quote(site, safe='')}/searchAnalytics/query"
34
+ out: List[Dict] = []
35
+ start_row = 0
36
+ while True:
37
+ body = {"startDate": start.isoformat(), "endDate": end.isoformat(), "dimensions": list(dimensions),
38
+ "rowLimit": ROW_LIMIT, "startRow": start_row, "type": search_type, "dataState": data_state}
39
+ r = sess.post(url, json=body, timeout=120)
40
+ r.raise_for_status()
41
+ rows = r.json().get("rows", [])
42
+ out.extend(rows)
43
+ if len(rows) < ROW_LIMIT:
44
+ break
45
+ start_row += ROW_LIMIT
46
+ return out
47
+
48
+
49
+ def to_rows(api_rows: Sequence[Dict], dimensions: Sequence[str]) -> List[Row]:
50
+ di = {d: i for i, d in enumerate(dimensions)}
51
+ out = []
52
+ for r in api_rows:
53
+ keys = r.get("keys", [])
54
+ out.append(Row(
55
+ date=dt.date.fromisoformat(keys[di["date"]]) if "date" in di else None,
56
+ page=keys[di["page"]] if "page" in di else None,
57
+ clicks=float(r.get("clicks", 0)), impressions=float(r.get("impressions", 0)),
58
+ position=float(r["position"]) if "position" in r else None,
59
+ ))
60
+ return out
61
+
62
+
63
+ def fetch(site: str, key_path: str, start: dt.date, end: dt.date, dimensions: Sequence[str] = ("date", "page")) -> List[Row]:
64
+ return to_rows(query(site, key_path, start, end, dimensions), dimensions)
65
+
66
+
67
+ def coverage(site: str, key_path: str, start: dt.date, end: dt.date, session=None) -> Dict[str, float]:
68
+ """How much of the traffic the query dimension shows. Search Console hides
69
+ rare queries, so the sum over queries is usually well below the total; the
70
+ sum over pages is not. Analyse by page unless you need the words."""
71
+ total = query(site, key_path, start, end, dimensions=(), session=session)
72
+ q = query(site, key_path, start, end, dimensions=("query",), session=session)
73
+ p = query(site, key_path, start, end, dimensions=("page",), session=session)
74
+ tc = sum(r.get("clicks", 0) for r in total)
75
+ return {"total_clicks": tc,
76
+ "query_share": (sum(r.get("clicks", 0) for r in q) / tc) if tc else 0.0,
77
+ "page_share": (sum(r.get("clicks", 0) for r in p) / tc) if tc else 0.0}
@@ -0,0 +1,73 @@
1
+ """searchdiff weeks DATA.csv [--anchor DATE]
2
+ searchdiff effect DATA.csv --change DATE --treated SPEC [--control SPEC] [--window 28] [--gap 0]
3
+ searchdiff fetch --site URL --key sa.json --start DATE --end DATE --out DATA.csv [--coverage]"""
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import datetime as dt
9
+ import json
10
+ import sys
11
+
12
+ from .data import load_csv, write_csv
13
+ from .weeks import weekly
14
+ from .effect import effect, matcher
15
+
16
+
17
+ def _d(s: str) -> dt.date:
18
+ return dt.date.fromisoformat(s)
19
+
20
+
21
+ def main(argv=None) -> int:
22
+ try:
23
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
24
+ except (AttributeError, ValueError):
25
+ pass
26
+ ap = argparse.ArgumentParser(prog="searchdiff", description="Did the change work? Search Console data as weekly blocks and difference-in-differences.")
27
+ sub = ap.add_subparsers(dest="cmd", required=True)
28
+
29
+ w = sub.add_parser("weeks", help="weekly blocks from an anchor date, cliffs, share of peak")
30
+ w.add_argument("data"); w.add_argument("--anchor", type=_d); w.add_argument("--block", type=int, default=7)
31
+ w.add_argument("--json")
32
+
33
+ e = sub.add_parser("effect", help="before/after on changed pages against a control")
34
+ e.add_argument("data"); e.add_argument("--change", type=_d, required=True)
35
+ e.add_argument("--treated", required=True, help="substring, glob, re:regex, or @file of URLs")
36
+ e.add_argument("--control", help="same forms; default: every page not treated")
37
+ e.add_argument("--window", type=int, default=28); e.add_argument("--gap", type=int, default=0)
38
+ e.add_argument("--json")
39
+
40
+ f = sub.add_parser("fetch", help="pull date x page rows from the API with a service account")
41
+ f.add_argument("--site", required=True, help="property, e.g. https://example.com/ or sc-domain:example.com")
42
+ f.add_argument("--key", required=True, help="service account JSON")
43
+ f.add_argument("--start", type=_d, required=True); f.add_argument("--end", type=_d, required=True)
44
+ f.add_argument("--out", required=True); f.add_argument("--dimensions", default="date,page")
45
+ f.add_argument("--coverage", action="store_true", help="also report how much traffic the query dimension shows")
46
+
47
+ a = ap.parse_args(argv)
48
+ if a.cmd == "weeks":
49
+ rep = weekly(load_csv(a.data), anchor=a.anchor, block_days=a.block)
50
+ print(rep)
51
+ if a.json:
52
+ json.dump(rep.to_dict(), open(a.json, "w", encoding="utf-8"), indent=1)
53
+ elif a.cmd == "effect":
54
+ rep = effect(load_csv(a.data), a.change, matcher(a.treated), matcher(a.control) if a.control else None,
55
+ window=a.window, gap=a.gap)
56
+ print(rep)
57
+ if a.json:
58
+ json.dump(rep.to_dict(), open(a.json, "w", encoding="utf-8"), indent=1)
59
+ else:
60
+ from .api import fetch, coverage
61
+ dims = [d.strip() for d in a.dimensions.split(",") if d.strip()]
62
+ rows = fetch(a.site, a.key, a.start, a.end, dims)
63
+ write_csv(rows, a.out)
64
+ print(f"wrote {len(rows)} rows to {a.out}")
65
+ if a.coverage:
66
+ c = coverage(a.site, a.key, a.start, a.end)
67
+ print(f"total clicks {c['total_clicks']:.0f}; the query dimension shows {c['query_share']:.0%} of them, "
68
+ f"the page dimension {c['page_share']:.0%}. Analyse by page unless you need the words.")
69
+ return 0
70
+
71
+
72
+ if __name__ == "__main__":
73
+ sys.exit(main())
@@ -0,0 +1,119 @@
1
+ """Rows of Search Console data and how to read them from a CSV.
2
+
3
+ A row is (date, page, clicks, impressions, position). CTR is always
4
+ recomputed from clicks and impressions, never read from the file, because
5
+ exported CTRs are rounded and averaged CTRs are wrong.
6
+
7
+ Accepted CSV shapes (header names are matched case-insensitively, a few
8
+ synonyms are understood):
9
+
10
+ date,clicks,impressions[,ctr][,position] the UI "Dates" export
11
+ date,page,clicks,impressions[,ctr][,position] per page per day (API)
12
+ page,clicks,impressions[,ctr][,position] the UI "Pages" export (no dates)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import csv
18
+ import datetime as dt
19
+ from dataclasses import dataclass
20
+ from typing import Iterable, List, Optional
21
+
22
+ SYNONYMS = {
23
+ "date": {"date", "day", "日付"},
24
+ "page": {"page", "url", "top pages", "pages", "ページ", "上位ページ"},
25
+ "clicks": {"clicks", "click", "クリック数", "クリック"},
26
+ "impressions": {"impressions", "impr", "表示回数", "表示"},
27
+ "position": {"position", "avg position", "average position", "掲載順位", "平均掲載順位"},
28
+ }
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Row:
33
+ date: Optional[dt.date]
34
+ page: Optional[str]
35
+ clicks: float
36
+ impressions: float
37
+ position: Optional[float] # impression-weighted when aggregated
38
+
39
+
40
+ def _col(header: List[str], key: str) -> Optional[int]:
41
+ names = {h.strip().lower(): i for i, h in enumerate(header)}
42
+ for syn in SYNONYMS[key]:
43
+ if syn in names:
44
+ return names[syn]
45
+ return None
46
+
47
+
48
+ def _num(s: str) -> float:
49
+ s = (s or "").strip().replace(",", "").replace("%", "")
50
+ if s in ("", "-"):
51
+ return 0.0
52
+ return float(s)
53
+
54
+
55
+ def _date(s: str) -> dt.date:
56
+ s = s.strip()
57
+ for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%d/%m/%Y", "%m/%d/%Y", "%Y%m%d"):
58
+ try:
59
+ return dt.datetime.strptime(s, fmt).date()
60
+ except ValueError:
61
+ pass
62
+ return dt.date.fromisoformat(s[:10])
63
+
64
+
65
+ def load_csv(path: str, delimiter: str = ",") -> List[Row]:
66
+ with open(path, newline="", encoding="utf-8-sig") as f:
67
+ reader = csv.reader(f, delimiter=delimiter)
68
+ header = next(reader)
69
+ ci = {k: _col(header, k) for k in SYNONYMS}
70
+ if ci["clicks"] is None or ci["impressions"] is None:
71
+ raise ValueError(f"need clicks and impressions columns; got {header}")
72
+ rows = []
73
+ for r in reader:
74
+ if not r or all(not c.strip() for c in r):
75
+ continue
76
+ rows.append(Row(
77
+ date=_date(r[ci["date"]]) if ci["date"] is not None else None,
78
+ page=r[ci["page"]].strip() if ci["page"] is not None else None,
79
+ clicks=_num(r[ci["clicks"]]),
80
+ impressions=_num(r[ci["impressions"]]),
81
+ position=_num(r[ci["position"]]) if ci["position"] is not None and r[ci["position"]].strip() else None,
82
+ ))
83
+ return rows
84
+
85
+
86
+ def write_csv(rows: Iterable[Row], path: str) -> None:
87
+ with open(path, "w", newline="", encoding="utf-8") as f:
88
+ w = csv.writer(f)
89
+ w.writerow(["date", "page", "clicks", "impressions", "ctr", "position"])
90
+ for r in rows:
91
+ ctr = r.clicks / r.impressions if r.impressions else 0.0
92
+ w.writerow([r.date.isoformat() if r.date else "", r.page or "", int(r.clicks), int(r.impressions),
93
+ f"{ctr:.4f}", "" if r.position is None else f"{r.position:.2f}"])
94
+
95
+
96
+ @dataclass
97
+ class Totals:
98
+ clicks: float = 0.0
99
+ impressions: float = 0.0
100
+ pos_weighted: float = 0.0
101
+ days: int = 0
102
+
103
+ def add(self, r: Row) -> None:
104
+ self.clicks += r.clicks
105
+ self.impressions += r.impressions
106
+ if r.position is not None:
107
+ self.pos_weighted += r.position * r.impressions
108
+
109
+ @property
110
+ def ctr(self) -> float:
111
+ return self.clicks / self.impressions if self.impressions else 0.0
112
+
113
+ @property
114
+ def position(self) -> Optional[float]:
115
+ return self.pos_weighted / self.impressions if self.impressions and self.pos_weighted else None
116
+
117
+ def per_day(self, key: str) -> float:
118
+ v = getattr(self, key)
119
+ return v / self.days if self.days else 0.0
@@ -0,0 +1,145 @@
1
+ """Did the change work? Before/after on the changed pages against a control.
2
+
3
+ The number that answers the question is the difference in differences:
4
+
5
+ (treated_after - treated_before) - (control_after - control_before)
6
+
7
+ for CTR (the usual target of a title or description rewrite), for clicks per
8
+ day and for impressions per day. The control's own change is printed next
9
+ to it, because a treated group that "improved" by exactly what the control
10
+ improved did not improve.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import datetime as dt
16
+ import fnmatch
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from typing import Callable, Dict, List, Optional, Sequence
20
+
21
+ from .data import Row, Totals
22
+
23
+
24
+ def matcher(spec: Optional[str]) -> Callable[[str], bool]:
25
+ """A page selector: a regex (prefix 're:'), a glob with * or ?, a substring, or a file of URLs (prefix '@')."""
26
+ if not spec:
27
+ return lambda page: False
28
+ if spec.startswith("@"):
29
+ with open(spec[1:], encoding="utf-8") as f:
30
+ urls = {ln.strip() for ln in f if ln.strip() and not ln.startswith("#")}
31
+ return lambda page: page in urls
32
+ if spec.startswith("re:"):
33
+ rx = re.compile(spec[3:])
34
+ return lambda page: bool(rx.search(page))
35
+ if any(ch in spec for ch in "*?["):
36
+ return lambda page: fnmatch.fnmatch(page, spec)
37
+ return lambda page: spec in page
38
+
39
+
40
+ @dataclass
41
+ class Group:
42
+ name: str
43
+ pages: int
44
+ before: Totals
45
+ after: Totals
46
+
47
+ def delta(self, key: str) -> float:
48
+ """CTR: difference in points. Position: difference in ranks.
49
+ Clicks and impressions: relative change of the per-day rate, so groups
50
+ of different sizes can be compared."""
51
+ if key == "ctr":
52
+ return self.after.ctr - self.before.ctr
53
+ if key == "position":
54
+ a, b = self.after.position, self.before.position
55
+ return (a - b) if a is not None and b is not None else float("nan")
56
+ b = self.before.per_day(key)
57
+ return (self.after.per_day(key) - b) / b if b else float("nan")
58
+
59
+
60
+ @dataclass
61
+ class EffectReport:
62
+ change_date: dt.date
63
+ window: int
64
+ treated: Group
65
+ control: Group
66
+ did: Dict[str, float] = field(default_factory=dict)
67
+ notes: List[str] = field(default_factory=list)
68
+
69
+ def __str__(self) -> str:
70
+ t, c = self.treated, self.control
71
+ L = [f"change on {self.change_date.isoformat()}, {self.window} days before and after", ""]
72
+ L.append(f"{'':10s} {'pages':>6s} {'clicks/day':>21s} {'impr/day':>21s} {'ctr':>19s} {'position':>15s}")
73
+ for g in (t, c):
74
+ pb, pa = g.before.position, g.after.position
75
+ pos = f"{pb:6.1f} -> {pa:6.1f}" if pb is not None and pa is not None else f"{'':6s} {'':6s}"
76
+ L.append(f"{g.name:10s} {g.pages:6d} {g.before.per_day('clicks'):9.2f} -> {g.after.per_day('clicks'):9.2f} "
77
+ f"{g.before.per_day('impressions'):9.1f} -> {g.after.per_day('impressions'):9.1f} "
78
+ f"{g.before.ctr:8.2%} -> {g.after.ctr:8.2%} {pos:>15s}")
79
+ L.append("")
80
+ L.append("difference in differences (treated change minus control change):")
81
+ L.append(f" ctr {self.did['ctr']:+6.2f} points (treated {t.delta('ctr')*100:+.2f}, control {c.delta('ctr')*100:+.2f})")
82
+ L.append(f" clicks/day {self.did['clicks']*100:+6.1f} % (treated {t.delta('clicks'):+.1%}, control {c.delta('clicks'):+.1%})")
83
+ L.append(f" impr/day {self.did['impressions']*100:+6.1f} % (treated {t.delta('impressions'):+.1%}, control {c.delta('impressions'):+.1%})")
84
+ if "position" in self.did and self.did["position"] == self.did["position"]:
85
+ L.append(f" position {self.did['position']:+6.2f} ranks (treated {t.delta('position'):+.2f}, control {c.delta('position'):+.2f}; negative is better)")
86
+ for n in self.notes:
87
+ L.append(f"note: {n}")
88
+ return "\n".join(L)
89
+
90
+ def to_dict(self) -> dict:
91
+ def g(x: Group):
92
+ return {"name": x.name, "pages": x.pages,
93
+ "before": {"clicks_per_day": x.before.per_day("clicks"), "impressions_per_day": x.before.per_day("impressions"),
94
+ "ctr": x.before.ctr, "position": x.before.position, "days": x.before.days},
95
+ "after": {"clicks_per_day": x.after.per_day("clicks"), "impressions_per_day": x.after.per_day("impressions"),
96
+ "ctr": x.after.ctr, "position": x.after.position, "days": x.after.days}}
97
+ return {"change_date": self.change_date.isoformat(), "window": self.window,
98
+ "treated": g(self.treated), "control": g(self.control), "did": self.did, "notes": self.notes}
99
+
100
+
101
+ def _totals(rows: Sequence[Row], lo: dt.date, hi: dt.date) -> Totals:
102
+ t = Totals()
103
+ days = set()
104
+ for r in rows:
105
+ if r.date is not None and lo <= r.date <= hi:
106
+ t.add(r)
107
+ days.add(r.date)
108
+ t.days = (hi - lo).days + 1
109
+ return t
110
+
111
+
112
+ def effect(rows: List[Row], change_date: dt.date, treated: Callable[[str], bool],
113
+ control: Optional[Callable[[str], bool]] = None, window: int = 28, gap: int = 0) -> EffectReport:
114
+ """``gap`` days after the change are excluded, for changes that take time to be re-indexed."""
115
+ paged = [r for r in rows if r.date is not None and r.page]
116
+ if not paged:
117
+ raise ValueError("the effect view needs rows with both a date and a page")
118
+ tr = [r for r in paged if treated(r.page)]
119
+ ct = [r for r in paged if (control(r.page) if control else not treated(r.page))]
120
+ b_lo, b_hi = change_date - dt.timedelta(days=window), change_date - dt.timedelta(days=1)
121
+ a_lo, a_hi = change_date + dt.timedelta(days=gap), change_date + dt.timedelta(days=gap + window - 1)
122
+ first = min(r.date for r in paged); last = max(r.date for r in paged)
123
+
124
+ T = Group("treated", len({r.page for r in tr}), _totals(tr, b_lo, b_hi), _totals(tr, a_lo, a_hi))
125
+ C = Group("control", len({r.page for r in ct}), _totals(ct, b_lo, b_hi), _totals(ct, a_lo, a_hi))
126
+ rep = EffectReport(change_date=change_date, window=window, treated=T, control=C)
127
+ rep.did = {
128
+ "ctr": (T.delta("ctr") - C.delta("ctr")) * 100.0,
129
+ "clicks": T.delta("clicks") - C.delta("clicks"),
130
+ "impressions": T.delta("impressions") - C.delta("impressions"),
131
+ "position": T.delta("position") - C.delta("position"),
132
+ }
133
+ if T.pages == 0:
134
+ rep.notes.append("no treated pages matched")
135
+ if C.pages == 0:
136
+ rep.notes.append("no control pages; the before/after of the treated group alone cannot separate the change from the season")
137
+ if b_lo < first or a_hi > last:
138
+ rep.notes.append(f"data covers {first.isoformat()}..{last.isoformat()}; the window {b_lo.isoformat()}..{a_hi.isoformat()} is cut short")
139
+ if T.before.impressions and T.before.impressions < 200 * 1:
140
+ rep.notes.append("treated group has few impressions before the change; a CTR point is worth less than a click here")
141
+ if T.before.clicks + T.after.clicks < 30:
142
+ rep.notes.append("fewer than 30 treated clicks in total; nothing here is distinguishable from noise")
143
+ if T.pages and C.pages and abs(C.delta("ctr")) > 0 and abs(T.delta("ctr") - C.delta("ctr")) < 0.002:
144
+ rep.notes.append("treated and control moved together: the change did not do anything the control did not also do")
145
+ return rep
@@ -0,0 +1,116 @@
1
+ """Weekly blocks from an anchor date, with cliffs and recovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ from dataclasses import dataclass, field
7
+ from typing import List, Optional
8
+
9
+ from .data import Row, Totals
10
+
11
+ CLIFF = -0.30 # week-over-week change that counts as a cliff
12
+
13
+
14
+ @dataclass
15
+ class Week:
16
+ start: dt.date
17
+ end: dt.date
18
+ days: int
19
+ totals: Totals
20
+ change: Optional[float] = None # clicks/day vs previous block
21
+ of_peak: float = 0.0
22
+ flag: str = ""
23
+
24
+
25
+ @dataclass
26
+ class WeeksReport:
27
+ anchor: dt.date
28
+ weeks: List[Week] = field(default_factory=list)
29
+ partial_last: bool = False
30
+ notes: List[str] = field(default_factory=list)
31
+
32
+ def __str__(self) -> str:
33
+ L = [f"weekly blocks from {self.anchor.isoformat()}", ""]
34
+ L.append(f"{'week':23s} {'days':>4s} {'clicks':>7s} {'/day':>6s} {'impr':>8s} {'ctr':>6s} {'pos':>5s} {'vs prev':>8s} {'of peak':>8s} ")
35
+ for w in self.weeks:
36
+ t = w.totals
37
+ ch = "" if w.change is None else f"{w.change:+.0%}"
38
+ pos = "" if t.position is None else f"{t.position:.1f}"
39
+ L.append(f"{w.start.isoformat()}..{w.end.strftime('%m-%d'):6s} {w.days:4d} {t.clicks:7.0f} {t.per_day('clicks'):6.1f} "
40
+ f"{t.impressions:8.0f} {t.ctr:6.1%} {pos:>5s} {ch:>8s} {w.of_peak:8.0%} {w.flag}")
41
+ if self.partial_last:
42
+ L.append("last block is partial; its /day figure is comparable, its totals are not")
43
+ for n in self.notes:
44
+ L.append(f"note: {n}")
45
+ return "\n".join(L)
46
+
47
+ def to_dict(self) -> dict:
48
+ return {"anchor": self.anchor.isoformat(), "partial_last": self.partial_last, "notes": self.notes,
49
+ "weeks": [{"start": w.start.isoformat(), "end": w.end.isoformat(), "days": w.days,
50
+ "clicks": w.totals.clicks, "impressions": w.totals.impressions, "ctr": w.totals.ctr,
51
+ "position": w.totals.position, "clicks_per_day": w.totals.per_day("clicks"),
52
+ "change": w.change, "of_peak": w.of_peak, "flag": w.flag} for w in self.weeks]}
53
+
54
+
55
+ def weekly(rows: List[Row], anchor: Optional[dt.date] = None, block_days: int = 7) -> WeeksReport:
56
+ dated = [r for r in rows if r.date is not None]
57
+ if not dated:
58
+ raise ValueError("no dated rows; the weeks view needs a date column")
59
+ days = sorted({r.date for r in dated})
60
+ first, last = days[0], days[-1]
61
+ if anchor is None:
62
+ anchor = first
63
+ # walk backwards from the anchor so blocks before it also line up
64
+ start = anchor
65
+ while start - dt.timedelta(days=block_days) >= first:
66
+ start -= dt.timedelta(days=block_days)
67
+ while start > first:
68
+ start -= dt.timedelta(days=block_days)
69
+
70
+ by_day = {}
71
+ for r in dated:
72
+ t = by_day.setdefault(r.date, Totals())
73
+ t.add(r)
74
+ rep = WeeksReport(anchor=anchor)
75
+ cur = start
76
+ prev_rate = None
77
+ while cur <= last:
78
+ end = cur + dt.timedelta(days=block_days - 1)
79
+ tot = Totals()
80
+ n = 0
81
+ for d in days:
82
+ if cur <= d <= end:
83
+ t = by_day[d]
84
+ tot.clicks += t.clicks; tot.impressions += t.impressions; tot.pos_weighted += t.pos_weighted
85
+ n += 1
86
+ tot.days = n
87
+ if n:
88
+ w = Week(start=cur, end=min(end, last), days=n, totals=tot)
89
+ rate = tot.per_day("clicks")
90
+ if prev_rate:
91
+ w.change = (rate - prev_rate) / prev_rate
92
+ prev_rate = rate if rate else prev_rate
93
+ rep.weeks.append(w)
94
+ cur = end + dt.timedelta(days=1)
95
+ if rep.weeks:
96
+ peak = max(w.totals.per_day("clicks") for w in rep.weeks) or 1.0
97
+ for w in rep.weeks:
98
+ w.of_peak = w.totals.per_day("clicks") / peak
99
+ if w.change is not None and w.change <= CLIFF:
100
+ w.flag = "CLIFF"
101
+ elif w.of_peak >= 0.999:
102
+ w.flag = "peak"
103
+ rep.partial_last = rep.weeks[-1].days < block_days
104
+ cliffs = [w for w in rep.weeks if w.flag == "CLIFF"]
105
+ if cliffs:
106
+ c = cliffs[-1]
107
+ after = [w for w in rep.weeks if w.start > c.start]
108
+ if after:
109
+ rep.notes.append(f"cliff in the block starting {c.start.isoformat()} ({c.change:+.0%}); "
110
+ f"latest block is at {rep.weeks[-1].of_peak:.0%} of peak")
111
+ span = (last - first).days + 1
112
+ if span < 28:
113
+ rep.notes.append(f"only {span} days of data; week-over-week changes are noisy below four weeks")
114
+ if last >= dt.date.today() - dt.timedelta(days=3):
115
+ rep.notes.append("Search Console lags two to three days; the last block will grow")
116
+ return rep
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: searchdiff
3
+ Version: 0.1.0
4
+ Summary: Did the change work? Weekly blocks, cliffs, and difference-in-differences for Google Search Console data, from a CSV or the API.
5
+ Author: Tsuruta Lab
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/tsurutanmen/searchdiff
8
+ Project-URL: Issues, https://github.com/tsurutanmen/searchdiff/issues
9
+ Keywords: search console,seo,difference in differences,ctr,analytics
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Provides-Extra: api
19
+ Requires-Dist: google-auth>=2.0; extra == "api"
20
+ Requires-Dist: requests>=2.25; extra == "api"
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest>=7; extra == "test"
23
+ Dynamic: license-file
24
+
25
+ # searchdiff
26
+
27
+ Did the change work?
28
+
29
+ Search Console answers two questions badly on its own. "When did traffic drop, and how far back is
30
+ it?" gets a daily chart with the weekday wobble in it. "We rewrote the titles, did it help?" gets a
31
+ before/after on the changed pages, which contains the season, the algorithm update, and the change,
32
+ all mixed together. `searchdiff` gives the first question weekly blocks from a fixed anchor, and the
33
+ second a control group and a difference in differences.
34
+
35
+ ```
36
+ pip install git+https://github.com/tsurutanmen/searchdiff # CSV only, no dependencies
37
+ pip install "git+https://github.com/tsurutanmen/searchdiff#egg=searchdiff[api]" # plus the API
38
+ ```
39
+
40
+ ## Weekly blocks
41
+
42
+ ```
43
+ $ searchdiff weeks daily.csv --anchor 2026-07-18
44
+
45
+ week days clicks /day impr ctr pos vs prev of peak
46
+ 2026-07-18..07-24 7 1486 212.3 28420 5.2% 6.6 +2% 95%
47
+ 2026-07-25..07-31 7 1487 212.4 28605 5.2% 7.0 +0% 95%
48
+ 2026-08-01..08-07 7 1562 223.1 28365 5.5% 6.1 +5% 100% peak
49
+ 2026-08-08..08-14 7 1000 142.9 17816 5.6% 6.1 -36% 64% CLIFF
50
+ 2026-08-15..08-21 7 1031 147.3 18516 5.6% 5.9 +3% 66%
51
+ 2026-08-22..08-28 7 1197 171.0 20313 5.9% 6.2 +16% 77%
52
+ 2026-08-29..08-31 3 483 161.0 8482 5.7% 7.6 -6% 72%
53
+ last block is partial; its /day figure is comparable, its totals are not
54
+ note: cliff in the block starting 2026-08-08 (-36%); latest block is at 72% of peak
55
+ ```
56
+
57
+ `daily.csv` here is the `Dates` export from the Performance report, unchanged. Any CSV with
58
+ `date,clicks,impressions[,position]` works, with or without a `page` column. Blocks are counted
59
+ from the anchor in both directions, so the same anchor gives the same blocks next month.
60
+
61
+ ## Did the change work
62
+
63
+ ```
64
+ $ searchdiff effect pages.csv --change 2026-08-04 --treated "re:/p/0[0-4]$" --window 28
65
+
66
+ change on 2026-08-04, 28 days before and after
67
+
68
+ pages clicks/day impr/day ctr position
69
+ treated 5 30.25 -> 21.50 564.4 -> 409.5 5.36% -> 5.25% 7.2 -> 8.0
70
+ control 35 182.57 -> 143.82 3487.6 -> 2512.7 5.23% -> 5.72% 7.7 -> 7.4
71
+
72
+ difference in differences (treated change minus control change):
73
+ ctr -0.60 points (treated -0.11, control +0.49)
74
+ clicks/day -7.7 % (treated -28.9%, control -21.2%)
75
+ impr/day +0.5 % (treated -27.4%, control -28.0%)
76
+ position +0.95 ranks (treated +0.72, control -0.23; negative is better)
77
+ ```
78
+
79
+ The treated pages lost 29% of their clicks after the change. So did everything else: a traffic cliff
80
+ hit the whole site four days later. The difference in differences is within a CTR point of zero. The
81
+ rewrite did nothing, and a before/after on the five pages alone would have called it a disaster.
82
+
83
+ The same data with pages that really did get a lift:
84
+
85
+ ```
86
+ ctr +3.41 points (treated +3.33, control -0.09)
87
+ clicks/day +49.0 % (treated +20.4%, control -28.6%)
88
+ ```
89
+
90
+ `pages.csv` needs `date,page,clicks,impressions[,position]` rows, which the API gives and the UI does
91
+ not. `--treated` and `--control` take a substring, a glob, `re:` a regex, or `@file` with one URL per
92
+ line. The control defaults to every page not treated; a control of the same kind as the treated pages
93
+ is better. `--gap N` skips the first N days after the change for edits that take time to be re-indexed.
94
+
95
+ The report adds notes when the window runs past the data, when the treated group has too few clicks
96
+ for anything to be distinguishable from noise, and when treated and control moved together.
97
+
98
+ ## Fetching from the API
99
+
100
+ ```
101
+ searchdiff fetch --site https://example.com/ --key service-account.json \
102
+ --start 2026-06-01 --end 2026-08-31 --out pages.csv --coverage
103
+ ```
104
+
105
+ The service account's e-mail has to be added as a user of the property. `--coverage` prints how much
106
+ of the clicks the query dimension shows. Search Console hides rare queries; on a site of ours the
107
+ query-level sum was 39% of the total while the page-level sum matched it. Analyse by page unless the
108
+ words are the point. Paging past 25,000 rows is handled.
109
+
110
+ ## Python
111
+
112
+ ```python
113
+ import datetime as dt, searchdiff as sd
114
+
115
+ rows = sd.load_csv("pages.csv")
116
+ print(sd.weekly(rows, anchor=dt.date(2026, 7, 18)))
117
+ rep = sd.effect(rows, dt.date(2026, 8, 4), sd.matcher("re:/blog/"), window=28)
118
+ rep.did # {'ctr': points, 'clicks': relative, 'impressions': relative, 'position': ranks}
119
+ rep.to_dict()
120
+ ```
121
+
122
+ ## Where this came from
123
+
124
+ A title rewrite on a set of comparison pages, measured before/after, looked like nothing: 3.38% to
125
+ 3.37%. A second rewrite on another set, the same. Both times the tempting reading was "wait longer".
126
+ With the untouched pages as control the answer was the same and available on day 28, and the
127
+ proposal to rewrite 475 more titles was dropped. The weekly-block view is how a 40% cliff and its
128
+ slow return were tracked without re-reading a daily chart every session. The tool packages those
129
+ two habits.
130
+
131
+ ## Claude Code skill
132
+
133
+ `skill/searchdiff/SKILL.md` teaches Claude Code to reach for the control group instead of the
134
+ before/after, and how to read the two tables. Install by copying the folder:
135
+
136
+ ```
137
+ cp -r skill/searchdiff ~/.claude/skills/searchdiff
138
+ ```
139
+
140
+ ## License
141
+
142
+ MIT.
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ searchdiff/__init__.py
5
+ searchdiff/api.py
6
+ searchdiff/cli.py
7
+ searchdiff/data.py
8
+ searchdiff/effect.py
9
+ searchdiff/weeks.py
10
+ searchdiff.egg-info/PKG-INFO
11
+ searchdiff.egg-info/SOURCES.txt
12
+ searchdiff.egg-info/dependency_links.txt
13
+ searchdiff.egg-info/entry_points.txt
14
+ searchdiff.egg-info/requires.txt
15
+ searchdiff.egg-info/top_level.txt
16
+ tests/test_searchdiff.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ searchdiff = searchdiff.cli:main
@@ -0,0 +1,7 @@
1
+
2
+ [api]
3
+ google-auth>=2.0
4
+ requests>=2.25
5
+
6
+ [test]
7
+ pytest>=7
@@ -0,0 +1 @@
1
+ searchdiff
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,107 @@
1
+ import datetime as dt
2
+ import os
3
+ import subprocess
4
+ import sys
5
+
6
+ import pytest
7
+
8
+ import searchdiff as sd
9
+ from searchdiff.api import to_rows
10
+
11
+ HERE = os.path.dirname(os.path.abspath(__file__))
12
+ DATA = os.path.join(HERE, "data")
13
+
14
+
15
+ @pytest.fixture(scope="module", autouse=True)
16
+ def data():
17
+ if not os.path.exists(os.path.join(DATA, "pages.csv")):
18
+ subprocess.check_call([sys.executable, os.path.join(HERE, "make_data.py")])
19
+
20
+
21
+ def test_load_ui_export_shape():
22
+ rows = sd.load_csv(os.path.join(DATA, "daily.csv"))
23
+ assert len(rows) == 90 and rows[0].page is None and rows[0].date == dt.date(2026, 6, 3)
24
+ assert rows[0].position is not None
25
+
26
+
27
+ def test_weeks_find_the_cliff():
28
+ rep = sd.weekly(sd.load_csv(os.path.join(DATA, "daily.csv")), anchor=dt.date(2026, 7, 18))
29
+ flags = {w.start: w.flag for w in rep.weeks}
30
+ assert flags[dt.date(2026, 8, 8)] == "CLIFF"
31
+ assert any("cliff in the block starting 2026-08-08" in n for n in rep.notes)
32
+ assert any(w.flag == "peak" for w in rep.weeks)
33
+ assert all(0 < w.of_peak <= 1.0 for w in rep.weeks)
34
+ # blocks line up on the anchor
35
+ assert dt.date(2026, 7, 18) in flags
36
+
37
+
38
+ def test_weeks_anchor_defaults_to_first_day():
39
+ rep = sd.weekly(sd.load_csv(os.path.join(DATA, "daily.csv")))
40
+ assert rep.weeks[0].start == dt.date(2026, 6, 3)
41
+ assert rep.weeks[-1].days <= 7
42
+
43
+
44
+ def test_effect_null_change_is_null():
45
+ rows = sd.load_csv(os.path.join(DATA, "pages.csv"))
46
+ rep = sd.effect(rows, dt.date(2026, 8, 4), sd.matcher("re:/p/0[0-4]$"), window=28)
47
+ assert rep.treated.pages == 5 and rep.control.pages == 35
48
+ assert abs(rep.did["ctr"]) < 1.0 # within a CTR point of zero
49
+ # the cliff hit both groups: treated alone looks like a loss, the control explains it
50
+ assert rep.treated.delta("impressions") < 0 and rep.control.delta("impressions") < 0
51
+
52
+
53
+ def test_effect_real_lift_is_seen():
54
+ rows = sd.load_csv(os.path.join(DATA, "pages.csv"))
55
+ rep = sd.effect(rows, dt.date(2026, 8, 4), sd.matcher("re:/p/1[0-4]$"), control=sd.matcher("re:/p/[23][0-9]$"), window=28)
56
+ assert rep.did["ctr"] > 2.0
57
+ assert rep.did["clicks"] > 0.2 # more than +20% relative to the control's change
58
+
59
+
60
+ def test_matchers(tmp_path):
61
+ assert sd.matcher("/p/01")("https://example.com/p/01")
62
+ assert sd.matcher("*/p/0?")("https://example.com/p/07")
63
+ assert not sd.matcher("re:/p/1")("https://example.com/p/07")
64
+ f = tmp_path / "urls.txt"; f.write_text("https://example.com/p/03\n# c\n")
65
+ assert sd.matcher("@" + str(f))("https://example.com/p/03")
66
+ assert not sd.matcher(None)("anything")
67
+
68
+
69
+ def test_effect_notes_when_window_exceeds_data():
70
+ rows = sd.load_csv(os.path.join(DATA, "pages.csv"))
71
+ rep = sd.effect(rows, dt.date(2026, 8, 25), sd.matcher("/p/00"), window=28)
72
+ assert any("cut short" in n for n in rep.notes)
73
+
74
+
75
+ def test_api_rows_conversion():
76
+ api_rows = [{"keys": ["2026-08-01", "https://e.com/a"], "clicks": 3, "impressions": 50, "ctr": 0.06, "position": 4.2}]
77
+ rows = to_rows(api_rows, ("date", "page"))
78
+ assert rows[0].date == dt.date(2026, 8, 1) and rows[0].page == "https://e.com/a" and rows[0].clicks == 3
79
+
80
+
81
+ def test_api_query_pages_with_mock():
82
+ from searchdiff.api import query, ROW_LIMIT
83
+
84
+ class Resp:
85
+ def __init__(self, rows): self._rows = rows
86
+ def raise_for_status(self): pass
87
+ def json(self): return {"rows": self._rows}
88
+
89
+ class Sess:
90
+ def __init__(self): self.calls = []
91
+ def post(self, url, json, timeout):
92
+ self.calls.append(json["startRow"])
93
+ n = ROW_LIMIT if json["startRow"] == 0 else 3
94
+ return Resp([{"keys": ["2026-08-01", "x"], "clicks": 1, "impressions": 1} for _ in range(n)])
95
+
96
+ s = Sess()
97
+ rows = query("https://e.com/", "unused", dt.date(2026, 8, 1), dt.date(2026, 8, 2), session=s)
98
+ assert len(rows) == ROW_LIMIT + 3 and s.calls == [0, ROW_LIMIT]
99
+
100
+
101
+ def test_cli(tmp_path, capsys):
102
+ from searchdiff.cli import main
103
+ out = tmp_path / "w.json"
104
+ assert main(["weeks", os.path.join(DATA, "daily.csv"), "--anchor", "2026-07-18", "--json", str(out)]) == 0
105
+ assert "CLIFF" in capsys.readouterr().out and out.exists()
106
+ assert main(["effect", os.path.join(DATA, "pages.csv"), "--change", "2026-08-04", "--treated", "re:/p/0[0-4]$"]) == 0
107
+ assert "difference in differences" in capsys.readouterr().out