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.
- searchdiff-0.1.0/LICENSE +21 -0
- searchdiff-0.1.0/PKG-INFO +142 -0
- searchdiff-0.1.0/README.md +118 -0
- searchdiff-0.1.0/pyproject.toml +38 -0
- searchdiff-0.1.0/searchdiff/__init__.py +14 -0
- searchdiff-0.1.0/searchdiff/api.py +77 -0
- searchdiff-0.1.0/searchdiff/cli.py +73 -0
- searchdiff-0.1.0/searchdiff/data.py +119 -0
- searchdiff-0.1.0/searchdiff/effect.py +145 -0
- searchdiff-0.1.0/searchdiff/weeks.py +116 -0
- searchdiff-0.1.0/searchdiff.egg-info/PKG-INFO +142 -0
- searchdiff-0.1.0/searchdiff.egg-info/SOURCES.txt +16 -0
- searchdiff-0.1.0/searchdiff.egg-info/dependency_links.txt +1 -0
- searchdiff-0.1.0/searchdiff.egg-info/entry_points.txt +2 -0
- searchdiff-0.1.0/searchdiff.egg-info/requires.txt +7 -0
- searchdiff-0.1.0/searchdiff.egg-info/top_level.txt +1 -0
- searchdiff-0.1.0/setup.cfg +4 -0
- searchdiff-0.1.0/tests/test_searchdiff.py +107 -0
searchdiff-0.1.0/LICENSE
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
searchdiff
|
|
@@ -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
|