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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gsc = gsc_cli.cli:cli
gsc_cli/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Google Search Console CLI package."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "0.1.0"
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"