google-search-console-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- google_search_console_cli-0.1.0.dist-info/METADATA +177 -0
- google_search_console_cli-0.1.0.dist-info/RECORD +13 -0
- google_search_console_cli-0.1.0.dist-info/WHEEL +5 -0
- google_search_console_cli-0.1.0.dist-info/entry_points.txt +2 -0
- google_search_console_cli-0.1.0.dist-info/top_level.txt +1 -0
- gsc_cli/__init__.py +4 -0
- gsc_cli/analytics.py +180 -0
- gsc_cli/auth.py +186 -0
- gsc_cli/cli.py +392 -0
- gsc_cli/client.py +18 -0
- gsc_cli/config.py +47 -0
- gsc_cli/output.py +70 -0
- gsc_cli/paths.py +27 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: google-search-console-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for Google Search Console
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: click>=8.1.7
|
|
8
|
+
Requires-Dist: google-api-python-client>=2.140.0
|
|
9
|
+
Requires-Dist: google-auth>=2.32.0
|
|
10
|
+
Requires-Dist: google-auth-oauthlib>=1.2.1
|
|
11
|
+
Requires-Dist: requests>=2.32.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8.2.0; extra == "dev"
|
|
14
|
+
|
|
15
|
+
# google-search-console-cli
|
|
16
|
+
|
|
17
|
+
CLI for Google Search Console using the official Google API Python client.
|
|
18
|
+
|
|
19
|
+
## Highlights
|
|
20
|
+
- Native OAuth login: no mandatory `gcloud` setup
|
|
21
|
+
- `pipx`-friendly install (`gsc` available globally)
|
|
22
|
+
- Site operations: list/get/add
|
|
23
|
+
- Analytics queries by date/query/page with Search Console filters
|
|
24
|
+
- Output formats: table, json, csv
|
|
25
|
+
- Diagnostics: `gsc doctor`
|
|
26
|
+
|
|
27
|
+
## Install (Recommended)
|
|
28
|
+
|
|
29
|
+
Install with `pipx` so `gsc` is available on your PATH:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pipx install google-search-console-cli
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Install From Source
|
|
36
|
+
|
|
37
|
+
If you cloned this repository and want to run from source, use one of these options.
|
|
38
|
+
|
|
39
|
+
Option 1: Local virtualenv (best for development)
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
python3 -m venv .venv
|
|
43
|
+
source .venv/bin/activate
|
|
44
|
+
pip install -e ".[dev]"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Fish shell activation:
|
|
48
|
+
|
|
49
|
+
```fish
|
|
50
|
+
. .venv/bin/activate.fish
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Then run:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
gsc --help
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Option 2: Install from source with `pipx` (best for day-to-day CLI usage)
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pipx install -e /absolute/path/to/google-search-console-cli
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## OAuth Setup (Recommended)
|
|
66
|
+
|
|
67
|
+
Create a Google OAuth client of type **Desktop app**, then run:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
gsc auth login --client-secret /absolute/path/to/client_secret.json
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Verify:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
gsc auth whoami
|
|
77
|
+
gsc doctor
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Optional: Set Default Site
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
gsc config set default-site sc-domain:example.com
|
|
84
|
+
gsc config get default-site
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
After this, you can omit `--site` in commands that need a property.
|
|
88
|
+
|
|
89
|
+
## Usage
|
|
90
|
+
|
|
91
|
+
### Sites
|
|
92
|
+
```bash
|
|
93
|
+
gsc site list
|
|
94
|
+
gsc site get --site sc-domain:example.com
|
|
95
|
+
gsc site add --site sc-domain:example.com
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Analytics
|
|
99
|
+
```bash
|
|
100
|
+
gsc analytics query \
|
|
101
|
+
--site sc-domain:example.com \
|
|
102
|
+
--start-date 2026-01-01 \
|
|
103
|
+
--end-date 2026-01-31 \
|
|
104
|
+
--dimension date \
|
|
105
|
+
--dimension query \
|
|
106
|
+
--filter query:contains:brand \
|
|
107
|
+
--filter device:equals:MOBILE
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Save as CSV:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
gsc analytics query \
|
|
114
|
+
--site sc-domain:example.com \
|
|
115
|
+
--start-date 2026-01-01 \
|
|
116
|
+
--end-date 2026-01-31 \
|
|
117
|
+
--dimension page \
|
|
118
|
+
--output csv \
|
|
119
|
+
--csv-path ./analytics.csv
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Filter Syntax
|
|
123
|
+
|
|
124
|
+
Use repeatable filters in this format:
|
|
125
|
+
|
|
126
|
+
```text
|
|
127
|
+
dimension:operator:expression
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Supported filter dimensions:
|
|
131
|
+
- `country`
|
|
132
|
+
- `device`
|
|
133
|
+
- `page`
|
|
134
|
+
- `query`
|
|
135
|
+
- `searchAppearance`
|
|
136
|
+
|
|
137
|
+
Supported operators:
|
|
138
|
+
- `contains`
|
|
139
|
+
- `equals`
|
|
140
|
+
- `notContains`
|
|
141
|
+
- `notEquals`
|
|
142
|
+
- `includingRegex`
|
|
143
|
+
- `excludingRegex`
|
|
144
|
+
|
|
145
|
+
## Convenience Script (Repo Local)
|
|
146
|
+
|
|
147
|
+
If you cloned this repo and want one command setup:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
./scripts/setup.sh /absolute/path/to/client_secret.json
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Credentials and Config Paths
|
|
154
|
+
|
|
155
|
+
By default:
|
|
156
|
+
- Credentials: `~/.config/gsc-cli/credentials.json`
|
|
157
|
+
- Config: `~/.config/gsc-cli/config.json`
|
|
158
|
+
|
|
159
|
+
Override with env vars:
|
|
160
|
+
- `GSC_CREDENTIALS_FILE`
|
|
161
|
+
- `GSC_APP_CONFIG_FILE`
|
|
162
|
+
- `GSC_CONFIG_DIR`
|
|
163
|
+
|
|
164
|
+
## ADC Fallback (Optional)
|
|
165
|
+
|
|
166
|
+
If you prefer ADC via `gcloud`, the CLI still supports it:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
gcloud auth application-default login \
|
|
170
|
+
--client-id-file=/absolute/path/to/client_secret.json \
|
|
171
|
+
--scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/webmasters
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Notes
|
|
175
|
+
- Use Search Console property formats like `sc-domain:example.com` or URL-prefix properties.
|
|
176
|
+
- `site add` requires write scope (`webmasters`).
|
|
177
|
+
- `analytics query --aggregation-type byProperty` cannot be combined with `page` grouping/filtering.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
gsc_cli/__init__.py,sha256=tLT0VSrJ4HK8Agb0I_htNiiLUgLzNWaWbyFtquOpg3I,90
|
|
2
|
+
gsc_cli/analytics.py,sha256=7mG6qFaKUOjFJSttDj23C34lv0KtRHXX3L1ZlMIZewI,5168
|
|
3
|
+
gsc_cli/auth.py,sha256=oZPMZCO04X2Khf4JR01kO5oO7ebeYk4AS6Cq81AxEGQ,6008
|
|
4
|
+
gsc_cli/cli.py,sha256=sUQ_MLtcfN2RzEIOfbCj4U3is-cI0Piw7qhAXo0tcDw,11807
|
|
5
|
+
gsc_cli/client.py,sha256=rmtTnaFdFzL_EzjpPKYgc_9OpwC-HFw_bwIU2sQ7M1c,458
|
|
6
|
+
gsc_cli/config.py,sha256=53vh9O1VDRTgXNxU7FC8GhCt9BRfrDO4a49DsJpWugM,1171
|
|
7
|
+
gsc_cli/output.py,sha256=z0jnng6o-Vfctu-pLiOkq9nQ0SQvc_xtUtPQV0xukGI,2119
|
|
8
|
+
gsc_cli/paths.py,sha256=r7Z4UxqopJs6NFwClJCPhcSxFQTL7S3C8wXcu2-QvEE,683
|
|
9
|
+
google_search_console_cli-0.1.0.dist-info/METADATA,sha256=noIEfDd7FEU7QMudWLOEVhuSi056Da5-BwVD85ZmWbM,3684
|
|
10
|
+
google_search_console_cli-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
11
|
+
google_search_console_cli-0.1.0.dist-info/entry_points.txt,sha256=yLOaaaOa7HmDIfXrYt7c7zgOJp914F3ljaAyktTKnlQ,40
|
|
12
|
+
google_search_console_cli-0.1.0.dist-info/top_level.txt,sha256=hYW7ll0hNbfn96W9Q4p4tCpCe4o98ZDGJEc2GI1iOvc,8
|
|
13
|
+
google_search_console_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gsc_cli
|
gsc_cli/__init__.py
ADDED
gsc_cli/analytics.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Search Analytics request and parsing helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date
|
|
6
|
+
|
|
7
|
+
ALLOWED_DIMENSIONS = {
|
|
8
|
+
"country",
|
|
9
|
+
"device",
|
|
10
|
+
"page",
|
|
11
|
+
"query",
|
|
12
|
+
"searchAppearance",
|
|
13
|
+
"date",
|
|
14
|
+
"hour",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
ALLOWED_FILTER_DIMENSIONS = {
|
|
18
|
+
"country",
|
|
19
|
+
"device",
|
|
20
|
+
"page",
|
|
21
|
+
"query",
|
|
22
|
+
"searchAppearance",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
ALLOWED_OPERATORS = {
|
|
26
|
+
"contains",
|
|
27
|
+
"equals",
|
|
28
|
+
"notContains",
|
|
29
|
+
"notEquals",
|
|
30
|
+
"includingRegex",
|
|
31
|
+
"excludingRegex",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
ALLOWED_TYPES = {"web", "image", "video", "news", "discover", "googleNews"}
|
|
35
|
+
ALLOWED_AGGREGATION_TYPES = {"auto", "byPage", "byProperty", "byNewsShowcasePanel"}
|
|
36
|
+
ALLOWED_DATA_STATES = {"final", "all", "hourly_all"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ValidationError(ValueError):
|
|
40
|
+
"""Raised for invalid user input before API execution."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_ymd(value: str) -> str:
|
|
44
|
+
"""Validate YYYY-MM-DD format and return unchanged string."""
|
|
45
|
+
try:
|
|
46
|
+
date.fromisoformat(value)
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
raise ValidationError(f"Invalid date '{value}'. Use YYYY-MM-DD.") from exc
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def validate_date_range(start_date: str, end_date: str) -> None:
|
|
53
|
+
start = date.fromisoformat(start_date)
|
|
54
|
+
end = date.fromisoformat(end_date)
|
|
55
|
+
if start > end:
|
|
56
|
+
raise ValidationError("start-date must be <= end-date.")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_filter_expression(filter_expression: str) -> dict:
|
|
60
|
+
"""Parse one filter expression in the form dimension:operator:expression."""
|
|
61
|
+
parts = filter_expression.split(":", 2)
|
|
62
|
+
if len(parts) != 3:
|
|
63
|
+
raise ValidationError(
|
|
64
|
+
"Invalid --filter format. Expected dimension:operator:expression"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
dimension, operator, expression = parts
|
|
68
|
+
if dimension not in ALLOWED_FILTER_DIMENSIONS:
|
|
69
|
+
allowed = ", ".join(sorted(ALLOWED_FILTER_DIMENSIONS))
|
|
70
|
+
raise ValidationError(
|
|
71
|
+
f"Unsupported filter dimension '{dimension}'. Allowed: {allowed}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if operator not in ALLOWED_OPERATORS:
|
|
75
|
+
allowed = ", ".join(sorted(ALLOWED_OPERATORS))
|
|
76
|
+
raise ValidationError(
|
|
77
|
+
f"Unsupported filter operator '{operator}'. Allowed: {allowed}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if expression == "":
|
|
81
|
+
raise ValidationError("Filter expression cannot be empty.")
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
"dimension": dimension,
|
|
85
|
+
"operator": operator,
|
|
86
|
+
"expression": expression,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_query_request(
|
|
91
|
+
*,
|
|
92
|
+
start_date: str,
|
|
93
|
+
end_date: str,
|
|
94
|
+
dimensions: tuple[str, ...],
|
|
95
|
+
query_type: str,
|
|
96
|
+
aggregation_type: str,
|
|
97
|
+
row_limit: int,
|
|
98
|
+
start_row: int,
|
|
99
|
+
data_state: str,
|
|
100
|
+
filters: tuple[str, ...],
|
|
101
|
+
) -> dict:
|
|
102
|
+
"""Build and validate a Search Analytics query request body."""
|
|
103
|
+
parse_ymd(start_date)
|
|
104
|
+
parse_ymd(end_date)
|
|
105
|
+
validate_date_range(start_date, end_date)
|
|
106
|
+
|
|
107
|
+
if query_type not in ALLOWED_TYPES:
|
|
108
|
+
raise ValidationError(f"Unsupported type '{query_type}'.")
|
|
109
|
+
|
|
110
|
+
if aggregation_type not in ALLOWED_AGGREGATION_TYPES:
|
|
111
|
+
raise ValidationError(f"Unsupported aggregation-type '{aggregation_type}'.")
|
|
112
|
+
|
|
113
|
+
if data_state not in ALLOWED_DATA_STATES:
|
|
114
|
+
raise ValidationError(f"Unsupported data-state '{data_state}'.")
|
|
115
|
+
|
|
116
|
+
if not (1 <= row_limit <= 25000):
|
|
117
|
+
raise ValidationError("row-limit must be between 1 and 25000.")
|
|
118
|
+
|
|
119
|
+
if start_row < 0:
|
|
120
|
+
raise ValidationError("start-row must be >= 0.")
|
|
121
|
+
|
|
122
|
+
for dimension in dimensions:
|
|
123
|
+
if dimension not in ALLOWED_DIMENSIONS:
|
|
124
|
+
allowed = ", ".join(sorted(ALLOWED_DIMENSIONS))
|
|
125
|
+
raise ValidationError(
|
|
126
|
+
f"Unsupported dimension '{dimension}'. Allowed: {allowed}"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
parsed_filters = [parse_filter_expression(item) for item in filters]
|
|
130
|
+
|
|
131
|
+
uses_page_dimension = "page" in dimensions
|
|
132
|
+
uses_page_filter = any(item["dimension"] == "page" for item in parsed_filters)
|
|
133
|
+
if aggregation_type == "byProperty" and (uses_page_dimension or uses_page_filter):
|
|
134
|
+
raise ValidationError(
|
|
135
|
+
"aggregation-type=byProperty cannot be used with page dimension or page filter."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
body = {
|
|
139
|
+
"startDate": start_date,
|
|
140
|
+
"endDate": end_date,
|
|
141
|
+
"type": query_type,
|
|
142
|
+
"aggregationType": aggregation_type,
|
|
143
|
+
"rowLimit": row_limit,
|
|
144
|
+
"startRow": start_row,
|
|
145
|
+
"dataState": data_state,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if dimensions:
|
|
149
|
+
body["dimensions"] = list(dimensions)
|
|
150
|
+
|
|
151
|
+
if parsed_filters:
|
|
152
|
+
body["dimensionFilterGroups"] = [
|
|
153
|
+
{
|
|
154
|
+
"groupType": "and",
|
|
155
|
+
"filters": parsed_filters,
|
|
156
|
+
}
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
return body
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def rows_to_records(response: dict, dimensions: tuple[str, ...]) -> list[dict]:
|
|
163
|
+
"""Convert API rows into flat records suitable for output formats."""
|
|
164
|
+
rows = response.get("rows", [])
|
|
165
|
+
records: list[dict] = []
|
|
166
|
+
|
|
167
|
+
for row in rows:
|
|
168
|
+
record: dict = {}
|
|
169
|
+
keys = row.get("keys", [])
|
|
170
|
+
|
|
171
|
+
for index, dimension in enumerate(dimensions):
|
|
172
|
+
record[dimension] = keys[index] if index < len(keys) else None
|
|
173
|
+
|
|
174
|
+
for metric in ("clicks", "impressions", "ctr", "position"):
|
|
175
|
+
if metric in row:
|
|
176
|
+
record[metric] = row[metric]
|
|
177
|
+
|
|
178
|
+
records.append(record)
|
|
179
|
+
|
|
180
|
+
return records
|
gsc_cli/auth.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Authentication helpers for Google Search Console."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import google.auth
|
|
9
|
+
from google.auth.exceptions import DefaultCredentialsError, RefreshError, TransportError
|
|
10
|
+
from google.auth.transport.requests import Request
|
|
11
|
+
from google.oauth2.credentials import Credentials
|
|
12
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
13
|
+
|
|
14
|
+
from gsc_cli.paths import credentials_file
|
|
15
|
+
|
|
16
|
+
READ_SCOPE = "https://www.googleapis.com/auth/webmasters.readonly"
|
|
17
|
+
WRITE_SCOPE = "https://www.googleapis.com/auth/webmasters"
|
|
18
|
+
CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AuthError(RuntimeError):
|
|
22
|
+
"""Raised when credentials cannot be loaded or refreshed."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def login_with_client_secret(
|
|
26
|
+
client_secret_path: str,
|
|
27
|
+
*,
|
|
28
|
+
write: bool = True,
|
|
29
|
+
launch_browser: bool = True,
|
|
30
|
+
) -> Path:
|
|
31
|
+
"""Run OAuth installed-app flow and persist credential file."""
|
|
32
|
+
scope = WRITE_SCOPE if write else READ_SCOPE
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
flow = InstalledAppFlow.from_client_secrets_file(
|
|
36
|
+
client_secret_path,
|
|
37
|
+
scopes=[scope],
|
|
38
|
+
)
|
|
39
|
+
credentials = flow.run_local_server(port=0, open_browser=launch_browser)
|
|
40
|
+
except OSError as exc:
|
|
41
|
+
raise AuthError(f"Could not read client secret file: {client_secret_path}") from exc
|
|
42
|
+
except Exception as exc: # noqa: BLE001
|
|
43
|
+
raise AuthError(f"OAuth login failed: {exc}") from exc
|
|
44
|
+
|
|
45
|
+
path = credentials_file()
|
|
46
|
+
_persist_credentials(credentials, path)
|
|
47
|
+
return path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_credentials(write: bool = False):
|
|
51
|
+
"""Load credentials, preferring local stored OAuth tokens, then ADC fallback."""
|
|
52
|
+
required_scope = WRITE_SCOPE if write else READ_SCOPE
|
|
53
|
+
|
|
54
|
+
stored = _load_stored_credentials(required_scope)
|
|
55
|
+
if stored is not None:
|
|
56
|
+
return stored
|
|
57
|
+
|
|
58
|
+
return _load_adc_credentials(required_scope)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def stored_credentials_info() -> dict | None:
|
|
62
|
+
"""Return metadata about stored credentials if present."""
|
|
63
|
+
path = credentials_file()
|
|
64
|
+
if not path.exists():
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
69
|
+
except json.JSONDecodeError as exc:
|
|
70
|
+
raise AuthError(f"Stored credentials are invalid JSON: {path}") from exc
|
|
71
|
+
|
|
72
|
+
scopes = payload.get("scopes", [])
|
|
73
|
+
if not isinstance(scopes, list):
|
|
74
|
+
scopes = []
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
"path": str(path),
|
|
78
|
+
"scopes": scopes,
|
|
79
|
+
"has_refresh_token": bool(payload.get("refresh_token")),
|
|
80
|
+
"client_id": payload.get("client_id"),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _load_stored_credentials(required_scope: str):
|
|
85
|
+
path = credentials_file()
|
|
86
|
+
if not path.exists():
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
credentials = Credentials.from_authorized_user_file(str(path))
|
|
91
|
+
except Exception as exc: # noqa: BLE001
|
|
92
|
+
raise AuthError(
|
|
93
|
+
f"Stored credentials at {path} are unreadable. "
|
|
94
|
+
"Run `gsc auth login --client-secret <path>` again."
|
|
95
|
+
) from exc
|
|
96
|
+
|
|
97
|
+
_validate_scope(credentials.scopes, required_scope)
|
|
98
|
+
return _ensure_valid_credentials(credentials, source_path=path, required_scope=required_scope)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _load_adc_credentials(required_scope: str):
|
|
102
|
+
try:
|
|
103
|
+
credentials, _ = google.auth.default(scopes=[required_scope])
|
|
104
|
+
except DefaultCredentialsError as exc:
|
|
105
|
+
raise AuthError(_missing_credentials_message(required_scope)) from exc
|
|
106
|
+
|
|
107
|
+
return _ensure_valid_credentials(
|
|
108
|
+
credentials,
|
|
109
|
+
source_path=None,
|
|
110
|
+
required_scope=required_scope,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _ensure_valid_credentials(credentials, source_path: Path | None, required_scope: str):
|
|
115
|
+
if credentials.valid:
|
|
116
|
+
return credentials
|
|
117
|
+
|
|
118
|
+
if credentials.expired and credentials.refresh_token:
|
|
119
|
+
try:
|
|
120
|
+
credentials.refresh(Request())
|
|
121
|
+
except (RefreshError, TransportError) as exc:
|
|
122
|
+
if source_path:
|
|
123
|
+
raise AuthError(
|
|
124
|
+
"Stored OAuth credentials could not be refreshed. "
|
|
125
|
+
"Run `gsc auth login --client-secret <path>` again."
|
|
126
|
+
) from exc
|
|
127
|
+
raise AuthError(_refresh_failed_message(required_scope)) from exc
|
|
128
|
+
|
|
129
|
+
if source_path:
|
|
130
|
+
_persist_credentials(credentials, source_path)
|
|
131
|
+
return credentials
|
|
132
|
+
|
|
133
|
+
if source_path:
|
|
134
|
+
raise AuthError(
|
|
135
|
+
"Stored OAuth credentials are invalid and cannot be refreshed. "
|
|
136
|
+
"Run `gsc auth login --client-secret <path>` again."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
raise AuthError(_refresh_failed_message(required_scope))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _persist_credentials(credentials: Credentials, path: Path) -> None:
|
|
143
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
path.write_text(credentials.to_json() + "\n", encoding="utf-8")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _validate_scope(granted_scopes: list[str] | None, required_scope: str) -> None:
|
|
148
|
+
if not granted_scopes:
|
|
149
|
+
raise AuthError(
|
|
150
|
+
"Stored credentials missing scopes. "
|
|
151
|
+
"Run `gsc auth login --client-secret <path>` again."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
scope_set = set(granted_scopes)
|
|
155
|
+
if required_scope == READ_SCOPE and WRITE_SCOPE in scope_set:
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
if required_scope in scope_set:
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
raise AuthError(
|
|
162
|
+
f"Stored credentials do not include required scope '{required_scope}'. "
|
|
163
|
+
"Run `gsc auth login --client-secret <path>` again."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _missing_credentials_message(scope: str) -> str:
|
|
168
|
+
scopes = f"{CLOUD_PLATFORM_SCOPE},{scope}"
|
|
169
|
+
return (
|
|
170
|
+
"No usable credentials found. Preferred setup:\n"
|
|
171
|
+
"gsc auth login --client-secret <path-to-client-secret.json>\n\n"
|
|
172
|
+
"ADC fallback:\n"
|
|
173
|
+
"gcloud auth application-default login "
|
|
174
|
+
"--client-id-file=<path-to-client-secret.json> "
|
|
175
|
+
f"--scopes={scopes}"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _refresh_failed_message(scope: str) -> str:
|
|
180
|
+
scopes = f"{CLOUD_PLATFORM_SCOPE},{scope}"
|
|
181
|
+
return (
|
|
182
|
+
"Failed to refresh ADC credentials. Re-run:\n"
|
|
183
|
+
"gcloud auth application-default login "
|
|
184
|
+
"--client-id-file=<path-to-client-secret.json> "
|
|
185
|
+
f"--scopes={scopes}"
|
|
186
|
+
)
|
gsc_cli/cli.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Click CLI for Google Search Console."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from functools import wraps
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from googleapiclient.errors import HttpError
|
|
11
|
+
|
|
12
|
+
from gsc_cli import __version__
|
|
13
|
+
from gsc_cli.analytics import (
|
|
14
|
+
ALLOWED_AGGREGATION_TYPES,
|
|
15
|
+
ALLOWED_DATA_STATES,
|
|
16
|
+
ALLOWED_DIMENSIONS,
|
|
17
|
+
ALLOWED_TYPES,
|
|
18
|
+
ValidationError,
|
|
19
|
+
build_query_request,
|
|
20
|
+
rows_to_records,
|
|
21
|
+
)
|
|
22
|
+
from gsc_cli.auth import AuthError, load_credentials, login_with_client_secret, stored_credentials_info
|
|
23
|
+
from gsc_cli.client import build_search_console_service
|
|
24
|
+
from gsc_cli.config import ConfigError, get_default_site, set_default_site
|
|
25
|
+
from gsc_cli.output import render_records
|
|
26
|
+
from gsc_cli.paths import app_config_file, credentials_file
|
|
27
|
+
|
|
28
|
+
USER_INPUT_EXIT_CODE = 2
|
|
29
|
+
AUTH_EXIT_CODE = 3
|
|
30
|
+
API_EXIT_CODE = 4
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@click.group()
|
|
34
|
+
@click.version_option(version=__version__)
|
|
35
|
+
def cli() -> None:
|
|
36
|
+
"""Google Search Console CLI."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def command_errors(func):
|
|
40
|
+
@wraps(func)
|
|
41
|
+
def wrapper(*args, **kwargs):
|
|
42
|
+
try:
|
|
43
|
+
return func(*args, **kwargs)
|
|
44
|
+
except (ValidationError, ValueError, ConfigError) as exc:
|
|
45
|
+
click.echo(f"Error: {exc}", err=True)
|
|
46
|
+
raise click.exceptions.Exit(USER_INPUT_EXIT_CODE) from exc
|
|
47
|
+
except AuthError as exc:
|
|
48
|
+
click.echo(f"Auth error: {exc}", err=True)
|
|
49
|
+
raise click.exceptions.Exit(AUTH_EXIT_CODE) from exc
|
|
50
|
+
except HttpError as exc:
|
|
51
|
+
status = getattr(exc.resp, "status", "unknown")
|
|
52
|
+
click.echo(f"API error ({status}): {_extract_http_error(exc)}", err=True)
|
|
53
|
+
raise click.exceptions.Exit(API_EXIT_CODE) from exc
|
|
54
|
+
|
|
55
|
+
return wrapper
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _extract_http_error(exc: HttpError) -> str:
|
|
59
|
+
content = getattr(exc, "content", None)
|
|
60
|
+
if not content:
|
|
61
|
+
return str(exc)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
payload = json.loads(content.decode("utf-8"))
|
|
65
|
+
except Exception: # noqa: BLE001
|
|
66
|
+
return str(exc)
|
|
67
|
+
|
|
68
|
+
if isinstance(payload, dict):
|
|
69
|
+
error = payload.get("error", {})
|
|
70
|
+
message = error.get("message")
|
|
71
|
+
if message:
|
|
72
|
+
return message
|
|
73
|
+
|
|
74
|
+
return str(exc)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _resolve_site(site_url: str | None) -> str:
|
|
78
|
+
if site_url:
|
|
79
|
+
return site_url
|
|
80
|
+
|
|
81
|
+
default_site = get_default_site()
|
|
82
|
+
if default_site:
|
|
83
|
+
return default_site
|
|
84
|
+
|
|
85
|
+
raise ValidationError(
|
|
86
|
+
"No site specified. Pass --site or set one with "
|
|
87
|
+
"`gsc config set default-site <siteUrl>`."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@cli.group()
|
|
92
|
+
def auth() -> None:
|
|
93
|
+
"""Authenticate and inspect credentials."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@auth.command("login")
|
|
97
|
+
@click.option(
|
|
98
|
+
"--client-secret",
|
|
99
|
+
required=True,
|
|
100
|
+
type=click.Path(exists=True, dir_okay=False, path_type=str),
|
|
101
|
+
help="Path to OAuth client secret JSON.",
|
|
102
|
+
)
|
|
103
|
+
@click.option("--readonly", is_flag=True, help="Request readonly scope only.")
|
|
104
|
+
@click.option("--no-launch-browser", is_flag=True, help="Do not auto-open browser.")
|
|
105
|
+
@command_errors
|
|
106
|
+
def auth_login(client_secret: str, readonly: bool, no_launch_browser: bool) -> None:
|
|
107
|
+
"""Run OAuth login and save local credentials."""
|
|
108
|
+
output_path = login_with_client_secret(
|
|
109
|
+
client_secret,
|
|
110
|
+
write=not readonly,
|
|
111
|
+
launch_browser=not no_launch_browser,
|
|
112
|
+
)
|
|
113
|
+
click.echo(f"Saved credentials to {output_path}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@auth.command("whoami")
|
|
117
|
+
@click.option("--output", "output_format", type=click.Choice(["table", "json"]), default="table")
|
|
118
|
+
@command_errors
|
|
119
|
+
def auth_whoami(output_format: str) -> None:
|
|
120
|
+
"""Show locally stored credential details."""
|
|
121
|
+
info = stored_credentials_info()
|
|
122
|
+
if info is None:
|
|
123
|
+
raise ValidationError(
|
|
124
|
+
"No local OAuth credentials found. Run `gsc auth login --client-secret <path>` first."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
record = {
|
|
128
|
+
"path": info["path"],
|
|
129
|
+
"has_refresh_token": info["has_refresh_token"],
|
|
130
|
+
"scopes": ",".join(info["scopes"]),
|
|
131
|
+
"client_id": info.get("client_id") or "",
|
|
132
|
+
}
|
|
133
|
+
click.echo(render_records([record], output_format=output_format))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@cli.group()
|
|
137
|
+
def config() -> None:
|
|
138
|
+
"""Manage CLI configuration."""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@config.group("set")
|
|
142
|
+
def config_set() -> None:
|
|
143
|
+
"""Set config values."""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@config_set.command("default-site")
|
|
147
|
+
@click.argument("site_url")
|
|
148
|
+
@command_errors
|
|
149
|
+
def config_set_default_site(site_url: str) -> None:
|
|
150
|
+
"""Set default site used when --site is omitted."""
|
|
151
|
+
path = set_default_site(site_url)
|
|
152
|
+
click.echo(f"Set default-site to {site_url}")
|
|
153
|
+
click.echo(f"Config file: {path}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@config.group("get")
|
|
157
|
+
def config_get() -> None:
|
|
158
|
+
"""Get config values."""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@config_get.command("default-site")
|
|
162
|
+
@command_errors
|
|
163
|
+
def config_get_default_site() -> None:
|
|
164
|
+
"""Get default site."""
|
|
165
|
+
site_url = get_default_site()
|
|
166
|
+
if not site_url:
|
|
167
|
+
raise ValidationError("default-site is not set.")
|
|
168
|
+
click.echo(site_url)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@cli.command("doctor")
|
|
172
|
+
def doctor() -> None:
|
|
173
|
+
"""Run diagnostics for environment, auth, and API connectivity."""
|
|
174
|
+
checks: list[dict] = []
|
|
175
|
+
failures = 0
|
|
176
|
+
|
|
177
|
+
checks.append(
|
|
178
|
+
{
|
|
179
|
+
"check": "python",
|
|
180
|
+
"status": "ok",
|
|
181
|
+
"detail": sys.version.split()[0],
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
checks.append(
|
|
186
|
+
{
|
|
187
|
+
"check": "config-path",
|
|
188
|
+
"status": "ok",
|
|
189
|
+
"detail": str(app_config_file()),
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
default_site = get_default_site()
|
|
194
|
+
checks.append(
|
|
195
|
+
{
|
|
196
|
+
"check": "default-site",
|
|
197
|
+
"status": "ok" if default_site else "warn",
|
|
198
|
+
"detail": default_site or "not set",
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
info = None
|
|
203
|
+
try:
|
|
204
|
+
info = stored_credentials_info()
|
|
205
|
+
except AuthError as exc:
|
|
206
|
+
checks.append(
|
|
207
|
+
{
|
|
208
|
+
"check": "stored-credentials",
|
|
209
|
+
"status": "fail",
|
|
210
|
+
"detail": str(exc),
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
failures += 1
|
|
214
|
+
|
|
215
|
+
if info is None:
|
|
216
|
+
checks.append(
|
|
217
|
+
{
|
|
218
|
+
"check": "stored-credentials",
|
|
219
|
+
"status": "warn",
|
|
220
|
+
"detail": f"not found at {credentials_file()} (ADC fallback may still work)",
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
elif info:
|
|
224
|
+
checks.append(
|
|
225
|
+
{
|
|
226
|
+
"check": "stored-credentials",
|
|
227
|
+
"status": "ok",
|
|
228
|
+
"detail": info["path"],
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
load_credentials(write=False)
|
|
234
|
+
checks.append(
|
|
235
|
+
{
|
|
236
|
+
"check": "auth-refresh",
|
|
237
|
+
"status": "ok",
|
|
238
|
+
"detail": "credentials load and refresh succeeded",
|
|
239
|
+
}
|
|
240
|
+
)
|
|
241
|
+
except AuthError as exc:
|
|
242
|
+
checks.append(
|
|
243
|
+
{
|
|
244
|
+
"check": "auth-refresh",
|
|
245
|
+
"status": "fail",
|
|
246
|
+
"detail": str(exc),
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
failures += 1
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
service = build_search_console_service(write=False)
|
|
253
|
+
response = service.sites().list().execute()
|
|
254
|
+
count = len(response.get("siteEntry", []))
|
|
255
|
+
checks.append(
|
|
256
|
+
{
|
|
257
|
+
"check": "api-connectivity",
|
|
258
|
+
"status": "ok",
|
|
259
|
+
"detail": f"sites.list succeeded ({count} properties)",
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
except (AuthError, HttpError, Exception) as exc: # noqa: BLE001
|
|
263
|
+
checks.append(
|
|
264
|
+
{
|
|
265
|
+
"check": "api-connectivity",
|
|
266
|
+
"status": "fail",
|
|
267
|
+
"detail": str(exc),
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
failures += 1
|
|
271
|
+
|
|
272
|
+
click.echo(render_records(checks, output_format="table"))
|
|
273
|
+
if failures:
|
|
274
|
+
raise click.exceptions.Exit(1)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@cli.group()
|
|
278
|
+
def site() -> None:
|
|
279
|
+
"""Manage Search Console properties."""
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@site.command("list")
|
|
283
|
+
@click.option("--output", "output_format", type=click.Choice(["table", "json", "csv"]), default="table")
|
|
284
|
+
@click.option("--csv-path", type=click.Path(dir_okay=False, writable=True, path_type=str), default=None)
|
|
285
|
+
@command_errors
|
|
286
|
+
def site_list(output_format: str, csv_path: str | None) -> None:
|
|
287
|
+
"""List accessible Search Console properties."""
|
|
288
|
+
service = build_search_console_service(write=False)
|
|
289
|
+
response = service.sites().list().execute()
|
|
290
|
+
entries = response.get("siteEntry", [])
|
|
291
|
+
|
|
292
|
+
records = [
|
|
293
|
+
{
|
|
294
|
+
"siteUrl": item.get("siteUrl"),
|
|
295
|
+
"permissionLevel": item.get("permissionLevel"),
|
|
296
|
+
}
|
|
297
|
+
for item in entries
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
click.echo(render_records(records, output_format=output_format, csv_path=csv_path))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@site.command("get")
|
|
304
|
+
@click.option("--site", "site_url", required=False, help="Site URL, e.g. sc-domain:example.com")
|
|
305
|
+
@click.option("--output", "output_format", type=click.Choice(["table", "json", "csv"]), default="json")
|
|
306
|
+
@click.option("--csv-path", type=click.Path(dir_okay=False, writable=True, path_type=str), default=None)
|
|
307
|
+
@command_errors
|
|
308
|
+
def site_get(site_url: str | None, output_format: str, csv_path: str | None) -> None:
|
|
309
|
+
"""Get one Search Console property."""
|
|
310
|
+
resolved_site = _resolve_site(site_url)
|
|
311
|
+
service = build_search_console_service(write=False)
|
|
312
|
+
item = service.sites().get(siteUrl=resolved_site).execute()
|
|
313
|
+
records = [item]
|
|
314
|
+
click.echo(render_records(records, output_format=output_format, csv_path=csv_path))
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@site.command("add")
|
|
318
|
+
@click.option("--site", "site_url", required=False, help="Site URL, e.g. sc-domain:example.com")
|
|
319
|
+
@command_errors
|
|
320
|
+
def site_add(site_url: str | None) -> None:
|
|
321
|
+
"""Add a Search Console property."""
|
|
322
|
+
resolved_site = _resolve_site(site_url)
|
|
323
|
+
service = build_search_console_service(write=True)
|
|
324
|
+
service.sites().add(siteUrl=resolved_site).execute()
|
|
325
|
+
click.echo(f"Added site: {resolved_site}")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@cli.group()
|
|
329
|
+
def analytics() -> None:
|
|
330
|
+
"""Query Search Analytics."""
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@analytics.command("query")
|
|
334
|
+
@click.option("--site", "site_url", required=False, help="Site URL, e.g. sc-domain:example.com")
|
|
335
|
+
@click.option("--start-date", required=True, help="Start date in YYYY-MM-DD")
|
|
336
|
+
@click.option("--end-date", required=True, help="End date in YYYY-MM-DD")
|
|
337
|
+
@click.option("--dimension", "dimensions", multiple=True, type=click.Choice(sorted(ALLOWED_DIMENSIONS)))
|
|
338
|
+
@click.option("--type", "query_type", default="web", type=click.Choice(sorted(ALLOWED_TYPES)))
|
|
339
|
+
@click.option(
|
|
340
|
+
"--aggregation-type",
|
|
341
|
+
default="auto",
|
|
342
|
+
type=click.Choice(sorted(ALLOWED_AGGREGATION_TYPES)),
|
|
343
|
+
)
|
|
344
|
+
@click.option("--row-limit", type=click.IntRange(1, 25000), default=1000)
|
|
345
|
+
@click.option("--start-row", type=click.IntRange(min=0), default=0)
|
|
346
|
+
@click.option("--data-state", type=click.Choice(sorted(ALLOWED_DATA_STATES)), default="final")
|
|
347
|
+
@click.option(
|
|
348
|
+
"--filter",
|
|
349
|
+
"filters",
|
|
350
|
+
multiple=True,
|
|
351
|
+
help="Filter expression in dimension:operator:expression format.",
|
|
352
|
+
)
|
|
353
|
+
@click.option("--output", "output_format", type=click.Choice(["table", "json", "csv"]), default="table")
|
|
354
|
+
@click.option("--csv-path", type=click.Path(dir_okay=False, writable=True, path_type=str), default=None)
|
|
355
|
+
@command_errors
|
|
356
|
+
def analytics_query(
|
|
357
|
+
site_url: str | None,
|
|
358
|
+
start_date: str,
|
|
359
|
+
end_date: str,
|
|
360
|
+
dimensions: tuple[str, ...],
|
|
361
|
+
query_type: str,
|
|
362
|
+
aggregation_type: str,
|
|
363
|
+
row_limit: int,
|
|
364
|
+
start_row: int,
|
|
365
|
+
data_state: str,
|
|
366
|
+
filters: tuple[str, ...],
|
|
367
|
+
output_format: str,
|
|
368
|
+
csv_path: str | None,
|
|
369
|
+
) -> None:
|
|
370
|
+
"""Run a Search Analytics query."""
|
|
371
|
+
resolved_site = _resolve_site(site_url)
|
|
372
|
+
request_body = build_query_request(
|
|
373
|
+
start_date=start_date,
|
|
374
|
+
end_date=end_date,
|
|
375
|
+
dimensions=dimensions,
|
|
376
|
+
query_type=query_type,
|
|
377
|
+
aggregation_type=aggregation_type,
|
|
378
|
+
row_limit=row_limit,
|
|
379
|
+
start_row=start_row,
|
|
380
|
+
data_state=data_state,
|
|
381
|
+
filters=filters,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
service = build_search_console_service(write=False)
|
|
385
|
+
response = service.searchanalytics().query(siteUrl=resolved_site, body=request_body).execute()
|
|
386
|
+
|
|
387
|
+
records = rows_to_records(response, dimensions)
|
|
388
|
+
click.echo(render_records(records, output_format=output_format, csv_path=csv_path))
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
if __name__ == "__main__":
|
|
392
|
+
cli()
|
gsc_cli/client.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Google Search Console API client helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from googleapiclient.discovery import build
|
|
6
|
+
|
|
7
|
+
from gsc_cli.auth import load_credentials
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_search_console_service(write: bool = False):
|
|
11
|
+
"""Create a Search Console API service client."""
|
|
12
|
+
credentials = load_credentials(write=write)
|
|
13
|
+
return build(
|
|
14
|
+
"searchconsole",
|
|
15
|
+
"v1",
|
|
16
|
+
credentials=credentials,
|
|
17
|
+
cache_discovery=False,
|
|
18
|
+
)
|
gsc_cli/config.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Persistent CLI configuration helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from gsc_cli.paths import app_config_file
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigError(RuntimeError):
|
|
12
|
+
"""Raised for invalid config state."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_config() -> dict:
|
|
16
|
+
path = app_config_file()
|
|
17
|
+
if not path.exists():
|
|
18
|
+
return {}
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
22
|
+
except json.JSONDecodeError as exc:
|
|
23
|
+
raise ConfigError(f"Config file is not valid JSON: {path}") from exc
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def save_config(config: dict) -> Path:
|
|
27
|
+
path = app_config_file()
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
30
|
+
return path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def set_default_site(site_url: str) -> Path:
|
|
34
|
+
if not site_url.strip():
|
|
35
|
+
raise ConfigError("default-site cannot be empty")
|
|
36
|
+
|
|
37
|
+
config = load_config()
|
|
38
|
+
config["default_site"] = site_url.strip()
|
|
39
|
+
return save_config(config)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_default_site() -> str | None:
|
|
43
|
+
value = load_config().get("default_site")
|
|
44
|
+
if not isinstance(value, str):
|
|
45
|
+
return None
|
|
46
|
+
value = value.strip()
|
|
47
|
+
return value or None
|
gsc_cli/output.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Output rendering helpers for CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def render_records(records: list[dict], output_format: str, csv_path: str | None = None) -> str:
|
|
11
|
+
if output_format == "json":
|
|
12
|
+
return json.dumps(records, indent=2)
|
|
13
|
+
|
|
14
|
+
if output_format == "csv":
|
|
15
|
+
if not csv_path:
|
|
16
|
+
raise ValueError("csv_path is required when output format is csv")
|
|
17
|
+
_write_csv(records, csv_path)
|
|
18
|
+
return f"Wrote {len(records)} row(s) to {csv_path}"
|
|
19
|
+
|
|
20
|
+
if output_format == "table":
|
|
21
|
+
return _render_table(records)
|
|
22
|
+
|
|
23
|
+
raise ValueError(f"Unsupported output format: {output_format}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _write_csv(records: list[dict], csv_path: str) -> None:
|
|
27
|
+
path = Path(csv_path)
|
|
28
|
+
fieldnames: list[str] = []
|
|
29
|
+
|
|
30
|
+
if records:
|
|
31
|
+
for record in records:
|
|
32
|
+
for key in record.keys():
|
|
33
|
+
if key not in fieldnames:
|
|
34
|
+
fieldnames.append(key)
|
|
35
|
+
|
|
36
|
+
with path.open("w", newline="", encoding="utf-8") as handle:
|
|
37
|
+
writer = csv.DictWriter(handle, fieldnames=fieldnames)
|
|
38
|
+
if fieldnames:
|
|
39
|
+
writer.writeheader()
|
|
40
|
+
writer.writerows(records)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _render_table(records: list[dict]) -> str:
|
|
44
|
+
if not records:
|
|
45
|
+
return "No rows found."
|
|
46
|
+
|
|
47
|
+
headers: list[str] = []
|
|
48
|
+
for record in records:
|
|
49
|
+
for key in record.keys():
|
|
50
|
+
if key not in headers:
|
|
51
|
+
headers.append(key)
|
|
52
|
+
|
|
53
|
+
widths = {key: len(key) for key in headers}
|
|
54
|
+
for record in records:
|
|
55
|
+
for key in headers:
|
|
56
|
+
cell = "" if record.get(key) is None else str(record.get(key))
|
|
57
|
+
widths[key] = max(widths[key], len(cell))
|
|
58
|
+
|
|
59
|
+
header_line = " | ".join(key.ljust(widths[key]) for key in headers)
|
|
60
|
+
separator_line = "-+-".join("-" * widths[key] for key in headers)
|
|
61
|
+
|
|
62
|
+
lines = [header_line, separator_line]
|
|
63
|
+
for record in records:
|
|
64
|
+
line = " | ".join(
|
|
65
|
+
("" if record.get(key) is None else str(record.get(key))).ljust(widths[key])
|
|
66
|
+
for key in headers
|
|
67
|
+
)
|
|
68
|
+
lines.append(line)
|
|
69
|
+
|
|
70
|
+
return "\n".join(lines)
|
gsc_cli/paths.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Filesystem paths used by the CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def config_dir() -> Path:
|
|
10
|
+
env_value = os.environ.get("GSC_CONFIG_DIR")
|
|
11
|
+
if env_value:
|
|
12
|
+
return Path(env_value).expanduser()
|
|
13
|
+
return Path.home() / ".config" / "gsc-cli"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def credentials_file() -> Path:
|
|
17
|
+
env_value = os.environ.get("GSC_CREDENTIALS_FILE")
|
|
18
|
+
if env_value:
|
|
19
|
+
return Path(env_value).expanduser()
|
|
20
|
+
return config_dir() / "credentials.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def app_config_file() -> Path:
|
|
24
|
+
env_value = os.environ.get("GSC_APP_CONFIG_FILE")
|
|
25
|
+
if env_value:
|
|
26
|
+
return Path(env_value).expanduser()
|
|
27
|
+
return config_dir() / "config.json"
|