From 78e3ed7cd84eb64d93916a58f7bb0381d69933f4 Mon Sep 17 00:00:00 2001 From: Haresh Seenivasagan Date: Tue, 3 Mar 2026 21:39:14 +0800 Subject: [PATCH 1/3] feat(analytics): add GA4 and Search Console commands --- README.md | 32 +- internal/cmd/analytics.go | 275 +++++++ .../execute_analytics_searchconsole_test.go | 754 ++++++++++++++++++ internal/cmd/root.go | 56 +- internal/cmd/searchconsole.go | 239 ++++++ internal/googleapi/analytics.go | 31 + internal/googleapi/searchconsole.go | 20 + internal/googleapi/services_more_test.go | 12 + internal/googleauth/service.go | 51 +- internal/googleauth/service_test.go | 49 +- 10 files changed, 1470 insertions(+), 49 deletions(-) create mode 100644 internal/cmd/analytics.go create mode 100644 internal/cmd/execute_analytics_searchconsole_test.go create mode 100644 internal/cmd/searchconsole.go create mode 100644 internal/googleapi/analytics.go create mode 100644 internal/googleapi/searchconsole.go diff --git a/README.md b/README.md index 13fd0f56..f308c9bf 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ![GitHub Repo Banner](https://ghrb.waren.build/banner?header=gogcli%F0%9F%A7%AD&subheader=Google+in+your+terminal&bg=f3f4f6&color=1f2937&support=true) -Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Slides, Sheets, Forms, Apps Script, Contacts, Tasks, People, Admin, Groups (Workspace), and Keep (Workspace-only). JSON-first output, multiple accounts, and flexible auth built in. +Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Slides, Sheets, Forms, Apps Script, Analytics, Search Console, Contacts, Tasks, People, Admin, Groups (Workspace), and Keep (Workspace-only). JSON-first output, multiple accounts, and flexible auth built in. ## Features @@ -18,6 +18,8 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli - **Sheets** - read/write/update spreadsheets, insert rows/cols, manage tabs and named ranges, format/merge/freeze/resize cells, read/write notes, inspect formats, find/replace text, list links, and create/export sheets - **Forms** - create/update forms, manage questions, inspect responses, and manage watches - **Apps Script** - create/get/bind projects, inspect content, and run functions +- **Analytics** - list GA4 account summaries and run reports via the Analytics Data API +- **Search Console** - list properties and run Search Analytics queries - **Docs/Slides** - create/copy/export docs/slides, edit Docs by tab, import Markdown, do richer find-replace, export Docs as Markdown/HTML, and generate Slides from Markdown or templates - **People** - profile lookup and directory search helpers - **Keep (Workspace only)** - list/get/search/create/delete notes and download attachments (service account + domain-wide delegation) @@ -93,6 +95,9 @@ Before adding an account, create OAuth2 credentials from Google Cloud Console: - Google Sheets API: https://console.cloud.google.com/apis/api/sheets.googleapis.com - Google Forms API: https://console.cloud.google.com/apis/api/forms.googleapis.com - Google Slides API: https://console.cloud.google.com/apis/api/slides.googleapis.com + - Google Analytics Admin API: https://console.cloud.google.com/apis/api/analyticsadmin.googleapis.com + - Google Analytics Data API: https://console.cloud.google.com/apis/api/analyticsdata.googleapis.com + - Google Search Console API: https://console.cloud.google.com/apis/api/searchconsole.googleapis.com 3. Configure OAuth consent screen: https://console.cloud.google.com/auth/branding 4. If your app is in "Testing", add test users: https://console.cloud.google.com/auth/audience 5. Create OAuth client: @@ -395,6 +400,8 @@ Service scope matrix (auto-generated; run `go run scripts/gen-auth-services-md.g | people | yes | People API | `profile` | OIDC profile scope | | forms | yes | Forms API | `https://www.googleapis.com/auth/forms.body`
`https://www.googleapis.com/auth/forms.responses.readonly` | | | appscript | yes | Apps Script API | `https://www.googleapis.com/auth/script.projects`
`https://www.googleapis.com/auth/script.deployments`
`https://www.googleapis.com/auth/script.processes` | | +| analytics | yes | Analytics Admin API, Analytics Data API | `https://www.googleapis.com/auth/analytics.readonly` | GA4 account summaries + reporting | +| searchconsole | yes | Search Console API | `https://www.googleapis.com/auth/webmasters.readonly` | | | groups | no | Cloud Identity API | `https://www.googleapis.com/auth/cloud-identity.groups.readonly` | Workspace only | | keep | no | Keep API | `https://www.googleapis.com/auth/keep` | Workspace only; service account (domain-wide delegation) | @@ -1139,6 +1146,29 @@ gog appscript run myFunction --params '["arg1", 123, true]' gog appscript run myFunction --dev-mode ``` +### Analytics + +```bash +# Account summaries (helps discover property IDs) +gog analytics accounts +gog analytics accounts --all + +# GA4 reports +gog analytics report 123456789 --from 2026-02-01 --to 2026-02-07 --dimensions date,country --metrics activeUsers,sessions +gog analytics report properties/123456789 --from 7daysAgo --to today --dimensions date --metrics totalUsers,newUsers --max 200 +``` + +### Search Console + +```bash +# Sites/properties +gog searchconsole sites + +# Search Analytics query +gog searchconsole query sc-domain:example.com --from 2026-02-01 --to 2026-02-07 --dimensions query,page --type WEB --max 1000 +gog searchconsole query https://example.com/ --from 2026-02-01 --to 2026-02-07 --dimensions date --type WEB +``` + ### People ```bash diff --git a/internal/cmd/analytics.go b/internal/cmd/analytics.go new file mode 100644 index 00000000..7890435b --- /dev/null +++ b/internal/cmd/analytics.go @@ -0,0 +1,275 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + analyticsadmin "google.golang.org/api/analyticsadmin/v1beta" + analyticsdata "google.golang.org/api/analyticsdata/v1beta" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +var ( + newAnalyticsAdminService = googleapi.NewAnalyticsAdmin + newAnalyticsDataService = googleapi.NewAnalyticsData +) + +type AnalyticsCmd struct { + Accounts AnalyticsAccountsCmd `cmd:"" name:"accounts" aliases:"list,ls" default:"withargs" help:"List GA4 account summaries"` + Report AnalyticsReportCmd `cmd:"" name:"report" help:"Run a GA4 report (Analytics Data API)"` +} + +type AnalyticsAccountsCmd struct { + Max int64 `name:"max" aliases:"limit" help:"Max account summaries per page (API max 200)" default:"50"` + Page string `name:"page" aliases:"cursor" help:"Page token"` + All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"` + FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` +} + +func (c *AnalyticsAccountsCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + if c.Max <= 0 { + return usage("--max must be > 0") + } + + svc, err := newAnalyticsAdminService(ctx, account) + if err != nil { + return err + } + + fetch := func(pageToken string) ([]*analyticsadmin.GoogleAnalyticsAdminV1betaAccountSummary, string, error) { + call := svc.AccountSummaries.List().PageSize(c.Max).Context(ctx) + if strings.TrimSpace(pageToken) != "" { + call = call.PageToken(pageToken) + } + resp, callErr := call.Do() + if callErr != nil { + return nil, "", callErr + } + return resp.AccountSummaries, resp.NextPageToken, nil + } + + var items []*analyticsadmin.GoogleAnalyticsAdminV1betaAccountSummary + nextPageToken := "" + if c.All { + all, collectErr := collectAllPages(c.Page, fetch) + if collectErr != nil { + return collectErr + } + items = all + } else { + items, nextPageToken, err = fetch(c.Page) + if err != nil { + return err + } + } + + if outfmt.IsJSON(ctx) { + if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "account_summaries": items, + "nextPageToken": nextPageToken, + }); err != nil { + return err + } + if len(items) == 0 { + return failEmptyExit(c.FailEmpty) + } + return nil + } + + if len(items) == 0 { + u.Err().Println("No Analytics accounts") + return failEmptyExit(c.FailEmpty) + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ACCOUNT\tDISPLAY_NAME\tPROPERTIES") + for _, item := range items { + if item == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%d\n", + sanitizeTab(analyticsResourceID(item.Account)), + sanitizeTab(item.DisplayName), + len(item.PropertySummaries), + ) + } + printNextPageHint(u, nextPageToken) + return nil +} + +type AnalyticsReportCmd struct { + Property string `arg:"" name:"property" help:"GA4 property ID or resource (e.g. 123456789 or properties/123456789)"` + From string `name:"from" help:"Start date (YYYY-MM-DD or GA relative date like 7daysAgo)" default:"7daysAgo"` + To string `name:"to" help:"End date (YYYY-MM-DD or GA relative date like today)" default:"today"` + Dimensions string `name:"dimensions" help:"Comma-separated dimensions (e.g. date,country)" default:"date"` + Metrics string `name:"metrics" help:"Comma-separated metrics (e.g. activeUsers,sessions)" default:"activeUsers"` + Max int64 `name:"max" aliases:"limit" help:"Max rows to return (1-250000)" default:"100"` + Offset int64 `name:"offset" help:"Row offset for pagination" default:"0"` + FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no rows"` +} + +func (c *AnalyticsReportCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + property := normalizeAnalyticsProperty(c.Property) + if property == "" { + return usage("empty property") + } + metrics := splitCommaList(c.Metrics) + if len(metrics) == 0 { + return usage("empty --metrics") + } + dimensions := splitCommaList(c.Dimensions) + if c.Max <= 0 { + return usage("--max must be > 0") + } + if c.Offset < 0 { + return usage("--offset must be >= 0") + } + + svc, err := newAnalyticsDataService(ctx, account) + if err != nil { + return err + } + + req := &analyticsdata.RunReportRequest{ + DateRanges: []*analyticsdata.DateRange{{ + StartDate: strings.TrimSpace(c.From), + EndDate: strings.TrimSpace(c.To), + }}, + Metrics: analyticsMetrics(metrics), + Limit: c.Max, + Offset: c.Offset, + } + if len(dimensions) > 0 { + req.Dimensions = analyticsDimensions(dimensions) + } + + resp, err := svc.Properties.RunReport(property, req).Context(ctx).Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "property": property, + "from": req.DateRanges[0].StartDate, + "to": req.DateRanges[0].EndDate, + "dimensions": dimensions, + "metrics": metrics, + "row_count": resp.RowCount, + "dimensionHeaders": resp.DimensionHeaders, + "metricHeaders": resp.MetricHeaders, + "rows": resp.Rows, + }); err != nil { + return err + } + if len(resp.Rows) == 0 { + return failEmptyExit(c.FailEmpty) + } + return nil + } + + if len(resp.Rows) == 0 { + u.Err().Println("No analytics rows") + return failEmptyExit(c.FailEmpty) + } + + headers := make([]string, 0, len(dimensions)+len(metrics)) + for _, d := range dimensions { + headers = append(headers, strings.ToUpper(d)) + } + for _, m := range metrics { + headers = append(headers, strings.ToUpper(m)) + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, strings.Join(headers, "\t")) + for _, row := range resp.Rows { + values := make([]string, 0, len(dimensions)+len(metrics)) + for i := range dimensions { + values = append(values, sanitizeTab(analyticsDimensionValue(row, i))) + } + for i := range metrics { + values = append(values, sanitizeTab(analyticsMetricValue(row, i))) + } + fmt.Fprintln(w, strings.Join(values, "\t")) + } + return nil +} + +func normalizeAnalyticsProperty(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if strings.HasPrefix(raw, "properties/") { + return raw + } + return "properties/" + strings.TrimPrefix(raw, "/") +} + +func analyticsDimensions(names []string) []*analyticsdata.Dimension { + out := make([]*analyticsdata.Dimension, 0, len(names)) + for _, n := range names { + n = strings.TrimSpace(n) + if n == "" { + continue + } + out = append(out, &analyticsdata.Dimension{Name: n}) + } + return out +} + +func analyticsMetrics(names []string) []*analyticsdata.Metric { + out := make([]*analyticsdata.Metric, 0, len(names)) + for _, n := range names { + n = strings.TrimSpace(n) + if n == "" { + continue + } + out = append(out, &analyticsdata.Metric{Name: n}) + } + return out +} + +func analyticsDimensionValue(row *analyticsdata.Row, index int) string { + if row == nil || index < 0 || index >= len(row.DimensionValues) || row.DimensionValues[index] == nil { + return "" + } + return row.DimensionValues[index].Value +} + +func analyticsMetricValue(row *analyticsdata.Row, index int) string { + if row == nil || index < 0 || index >= len(row.MetricValues) || row.MetricValues[index] == nil { + return "" + } + return row.MetricValues[index].Value +} + +func analyticsResourceID(resource string) string { + resource = strings.TrimSpace(resource) + if resource == "" { + return "" + } + if i := strings.LastIndex(resource, "/"); i >= 0 && i+1 < len(resource) { + return resource[i+1:] + } + return resource +} diff --git a/internal/cmd/execute_analytics_searchconsole_test.go b/internal/cmd/execute_analytics_searchconsole_test.go new file mode 100644 index 00000000..728271e5 --- /dev/null +++ b/internal/cmd/execute_analytics_searchconsole_test.go @@ -0,0 +1,754 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + analyticsadminapi "google.golang.org/api/analyticsadmin/v1beta" + analyticsdataapi "google.golang.org/api/analyticsdata/v1beta" + "google.golang.org/api/option" + searchconsoleapi "google.golang.org/api/searchconsole/v1" +) + +func TestExecute_AnalyticsAccounts_JSON(t *testing.T) { + origNew := newAnalyticsAdminService + t.Cleanup(func() { newAnalyticsAdminService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1beta/accountSummaries")) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "accountSummaries": []map[string]any{ + { + "account": "accounts/123", + "displayName": "Demo Account", + "propertySummaries": []map[string]any{ + {"property": "properties/999", "displayName": "Main Property"}, + }, + }, + }, + "nextPageToken": "next123", + }) + })) + defer srv.Close() + + svc, err := analyticsadminapi.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newAnalyticsAdminService = func(context.Context, string) (*analyticsadminapi.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "--account", "a@b.com", "analytics", "accounts", "--max", "1"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + AccountSummaries []struct { + Account string `json:"account"` + } `json:"account_summaries"` + NextPageToken string `json:"nextPageToken"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(parsed.AccountSummaries) != 1 || parsed.AccountSummaries[0].Account != "accounts/123" || parsed.NextPageToken != "next123" { + t.Fatalf("unexpected payload: %#v", parsed) + } +} + +func TestExecute_AnalyticsAccounts_Text(t *testing.T) { + origNew := newAnalyticsAdminService + t.Cleanup(func() { newAnalyticsAdminService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1beta/accountSummaries")) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "accountSummaries": []map[string]any{ + { + "account": "accounts/123", + "displayName": "Demo Account", + "propertySummaries": []map[string]any{ + {"property": "properties/999", "displayName": "Main Property"}, + }, + }, + }, + "nextPageToken": "next123", + }) + })) + defer srv.Close() + + svc, err := analyticsadminapi.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newAnalyticsAdminService = func(context.Context, string) (*analyticsadminapi.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--account", "a@b.com", "analytics", "accounts", "--max", "1"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + if !strings.Contains(out, "ACCOUNT") || + !strings.Contains(out, "DISPLAY_NAME") || + !strings.Contains(out, "PROPERTIES") || + !strings.Contains(out, "123") || + !strings.Contains(out, "Demo Account") || + !strings.Contains(out, "1") { + t.Fatalf("unexpected out=%q", out) + } +} + +func TestExecute_AnalyticsAccounts_AllPages_JSON(t *testing.T) { + origNew := newAnalyticsAdminService + t.Cleanup(func() { newAnalyticsAdminService = origNew }) + + page1Calls := 0 + page2Calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1beta/accountSummaries")) { + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("pageSize"); got != "1" { + t.Fatalf("expected pageSize=1, got %q", got) + } + + w.Header().Set("Content-Type", "application/json") + switch r.URL.Query().Get("pageToken") { + case "": + page1Calls++ + _ = json.NewEncoder(w).Encode(map[string]any{ + "accountSummaries": []map[string]any{ + {"account": "accounts/111", "displayName": "One"}, + }, + "nextPageToken": "p2", + }) + case "p2": + page2Calls++ + _ = json.NewEncoder(w).Encode(map[string]any{ + "accountSummaries": []map[string]any{ + {"account": "accounts/222", "displayName": "Two"}, + }, + "nextPageToken": "", + }) + default: + t.Fatalf("unexpected pageToken=%q", r.URL.Query().Get("pageToken")) + } + })) + defer srv.Close() + + svc, err := analyticsadminapi.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newAnalyticsAdminService = func(context.Context, string) (*analyticsadminapi.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{ + "--json", + "--account", "a@b.com", + "analytics", "accounts", + "--all", + "--max", "1", + }); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + AccountSummaries []struct { + Account string `json:"account"` + } `json:"account_summaries"` + NextPageToken string `json:"nextPageToken"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(parsed.AccountSummaries) != 2 || + parsed.AccountSummaries[0].Account != "accounts/111" || + parsed.AccountSummaries[1].Account != "accounts/222" || + parsed.NextPageToken != "" { + t.Fatalf("unexpected payload: %#v", parsed) + } + if page1Calls != 1 || page2Calls != 1 { + t.Fatalf("unexpected page calls: page1=%d page2=%d", page1Calls, page2Calls) + } +} + +func TestExecute_AnalyticsAccounts_ServiceError(t *testing.T) { + origNew := newAnalyticsAdminService + t.Cleanup(func() { newAnalyticsAdminService = origNew }) + newAnalyticsAdminService = func(context.Context, string) (*analyticsadminapi.Service, error) { + return nil, errors.New("analytics admin service down") + } + + _ = captureStderr(t, func() { + err := Execute([]string{"--account", "a@b.com", "analytics", "accounts"}) + if err == nil || !strings.Contains(err.Error(), "analytics admin service down") { + t.Fatalf("unexpected err: %v", err) + } + }) +} + +func TestExecute_AnalyticsReport_Text(t *testing.T) { + origNew := newAnalyticsDataService + t.Cleanup(func() { newAnalyticsDataService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1beta/properties/123:runReport")) { + http.NotFound(w, r) + return + } + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req["limit"] != "10" { + t.Fatalf("unexpected report limit payload: %#v", req) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "dimensionHeaders": []map[string]any{{"name": "date"}, {"name": "country"}}, + "metricHeaders": []map[string]any{{"name": "activeUsers"}, {"name": "sessions"}}, + "rowCount": 1, + "rows": []map[string]any{ + { + "dimensionValues": []map[string]any{{"value": "2026-02-01"}, {"value": "US"}}, + "metricValues": []map[string]any{{"value": "42"}, {"value": "11"}}, + }, + }, + }) + })) + defer srv.Close() + + svc, err := analyticsdataapi.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newAnalyticsDataService = func(context.Context, string) (*analyticsdataapi.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{ + "--account", "a@b.com", + "analytics", "report", "123", + "--from", "2026-02-01", + "--to", "2026-02-01", + "--dimensions", "date,country", + "--metrics", "activeUsers,sessions", + "--max", "10", + }); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + if !strings.Contains(out, "DATE") || + !strings.Contains(out, "COUNTRY") || + !strings.Contains(out, "ACTIVEUSERS") || + !strings.Contains(out, "SESSIONS") || + !strings.Contains(out, "2026-02-01") || + !strings.Contains(out, "US") || + !strings.Contains(out, "42") || + !strings.Contains(out, "11") { + t.Fatalf("unexpected out=%q", out) + } +} + +func TestExecute_AnalyticsReport_JSON(t *testing.T) { + origNew := newAnalyticsDataService + t.Cleanup(func() { newAnalyticsDataService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1beta/properties/123:runReport")) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "dimensionHeaders": []map[string]any{{"name": "date"}}, + "metricHeaders": []map[string]any{{"name": "activeUsers"}}, + "rowCount": 1, + "rows": []map[string]any{ + { + "dimensionValues": []map[string]any{{"value": "2026-02-01"}}, + "metricValues": []map[string]any{{"value": "42"}}, + }, + }, + }) + })) + defer srv.Close() + + svc, err := analyticsdataapi.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newAnalyticsDataService = func(context.Context, string) (*analyticsdataapi.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{ + "--json", + "--account", "a@b.com", + "analytics", "report", "123", + "--from", "2026-02-01", + "--to", "2026-02-01", + "--dimensions", "date", + "--metrics", "activeUsers", + }); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + Property string `json:"property"` + From string `json:"from"` + To string `json:"to"` + RowCount int64 `json:"row_count"` + Rows []struct { + DimensionValues []struct { + Value string `json:"value"` + } `json:"dimensionValues"` + MetricValues []struct { + Value string `json:"value"` + } `json:"metricValues"` + } `json:"rows"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if parsed.Property != "properties/123" || parsed.From != "2026-02-01" || parsed.To != "2026-02-01" || parsed.RowCount != 1 || len(parsed.Rows) != 1 { + t.Fatalf("unexpected payload: %#v", parsed) + } +} + +func TestExecute_AnalyticsReport_FailEmpty_JSON(t *testing.T) { + origNew := newAnalyticsDataService + t.Cleanup(func() { newAnalyticsDataService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/v1beta/properties/123:runReport")) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "dimensionHeaders": []map[string]any{{"name": "date"}}, + "metricHeaders": []map[string]any{{"name": "activeUsers"}}, + "rowCount": 0, + "rows": []map[string]any{}, + }) + })) + defer srv.Close() + + svc, err := analyticsdataapi.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newAnalyticsDataService = func(context.Context, string) (*analyticsdataapi.Service, error) { return svc, nil } + + var execErr error + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + execErr = Execute([]string{ + "--json", + "--account", "a@b.com", + "analytics", "report", "123", + "--from", "2026-02-01", + "--to", "2026-02-01", + "--dimensions", "date", + "--metrics", "activeUsers", + "--fail-empty", + }) + }) + }) + if execErr == nil { + t.Fatalf("expected error") + } + if got := ExitCode(execErr); got != emptyResultsExitCode { + t.Fatalf("expected exit code %d, got %d", emptyResultsExitCode, got) + } + + var parsed struct { + Property string `json:"property"` + RowCount int64 `json:"row_count"` + Rows []map[string]any `json:"rows"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v\nout=%q", err, out) + } + if parsed.Property != "properties/123" || parsed.RowCount != 0 || len(parsed.Rows) != 0 { + t.Fatalf("unexpected payload: %#v", parsed) + } +} + +func TestExecute_AnalyticsReport_ServiceError(t *testing.T) { + origNew := newAnalyticsDataService + t.Cleanup(func() { newAnalyticsDataService = origNew }) + newAnalyticsDataService = func(context.Context, string) (*analyticsdataapi.Service, error) { + return nil, errors.New("analytics data service down") + } + + _ = captureStderr(t, func() { + err := Execute([]string{ + "--account", "a@b.com", + "analytics", "report", "123", + "--from", "2026-02-01", + "--to", "2026-02-01", + "--metrics", "activeUsers", + }) + if err == nil || !strings.Contains(err.Error(), "analytics data service down") { + t.Fatalf("unexpected err: %v", err) + } + }) +} + +func TestExecute_AnalyticsReport_ValidatesMetricsBeforeServiceCall(t *testing.T) { + origNew := newAnalyticsDataService + t.Cleanup(func() { newAnalyticsDataService = origNew }) + newAnalyticsDataService = func(context.Context, string) (*analyticsdataapi.Service, error) { + t.Fatalf("expected validation to fail before creating analytics data service") + return nil, errors.New("unexpected analytics data service call") + } + + _ = captureStderr(t, func() { + err := Execute([]string{ + "--account", "a@b.com", + "analytics", "report", "123", + "--from", "2026-02-01", + "--to", "2026-02-01", + "--metrics", "", + }) + if err == nil || !strings.Contains(err.Error(), "empty --metrics") { + t.Fatalf("unexpected err: %v", err) + } + }) +} + +func TestExecute_SearchConsoleSites_Text(t *testing.T) { + origNew := newSearchConsoleService + t.Cleanup(func() { newSearchConsoleService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/webmasters/v3/sites")) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "siteEntry": []map[string]any{ + {"siteUrl": "sc-domain:example.com", "permissionLevel": "SITE_OWNER"}, + }, + }) + })) + defer srv.Close() + + svc, err := searchconsoleapi.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newSearchConsoleService = func(context.Context, string) (*searchconsoleapi.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--account", "a@b.com", "searchconsole", "sites"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + if !strings.Contains(out, "SITE") || !strings.Contains(out, "PERMISSION") || !strings.Contains(out, "sc-domain:example.com") || !strings.Contains(out, "SITE_OWNER") { + t.Fatalf("unexpected out=%q", out) + } +} + +func TestExecute_SearchConsoleSites_JSON(t *testing.T) { + origNew := newSearchConsoleService + t.Cleanup(func() { newSearchConsoleService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/webmasters/v3/sites")) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "siteEntry": []map[string]any{ + {"siteUrl": "sc-domain:example.com", "permissionLevel": "SITE_OWNER"}, + }, + }) + })) + defer srv.Close() + + svc, err := searchconsoleapi.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newSearchConsoleService = func(context.Context, string) (*searchconsoleapi.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "--account", "a@b.com", "searchconsole", "sites"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + Sites []struct { + SiteURL string `json:"siteUrl"` + PermissionLevel string `json:"permissionLevel"` + } `json:"sites"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(parsed.Sites) != 1 || parsed.Sites[0].SiteURL != "sc-domain:example.com" || parsed.Sites[0].PermissionLevel != "SITE_OWNER" { + t.Fatalf("unexpected payload: %#v", parsed) + } +} + +func TestExecute_SearchConsoleSites_ServiceError(t *testing.T) { + origNew := newSearchConsoleService + t.Cleanup(func() { newSearchConsoleService = origNew }) + newSearchConsoleService = func(context.Context, string) (*searchconsoleapi.Service, error) { + return nil, errors.New("search console service down") + } + + _ = captureStderr(t, func() { + err := Execute([]string{"--account", "a@b.com", "searchconsole", "sites"}) + if err == nil || !strings.Contains(err.Error(), "search console service down") { + t.Fatalf("unexpected err: %v", err) + } + }) +} + +func TestExecute_SearchConsoleQuery_JSON(t *testing.T) { + origNew := newSearchConsoleService + t.Cleanup(func() { newSearchConsoleService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/searchAnalytics/query")) { + http.NotFound(w, r) + return + } + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req["startDate"] != "2026-02-01" || req["endDate"] != "2026-02-07" || req["type"] != "WEB" { + t.Fatalf("unexpected request payload: %#v", req) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "responseAggregationType": "AUTO", + "rows": []map[string]any{ + { + "keys": []string{"gog cli", "https://example.com/docs"}, + "clicks": 12, + "impressions": 300, + "ctr": 0.04, + "position": 7.3, + }, + }, + }) + })) + defer srv.Close() + + svc, err := searchconsoleapi.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newSearchConsoleService = func(context.Context, string) (*searchconsoleapi.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{ + "--json", + "--account", "a@b.com", + "searchconsole", "query", "sc-domain:example.com", + "--from", "2026-02-01", + "--to", "2026-02-07", + "--dimensions", "query,page", + "--type", "web", + "--max", "10", + }); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + SiteURL string `json:"site_url"` + Type string `json:"type"` + Rows []struct { + Keys []string `json:"keys"` + } `json:"rows"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if parsed.SiteURL != "sc-domain:example.com" || parsed.Type != "WEB" || len(parsed.Rows) != 1 || len(parsed.Rows[0].Keys) != 2 { + t.Fatalf("unexpected payload: %#v", parsed) + } +} + +func TestExecute_SearchConsoleQuery_Text(t *testing.T) { + origNew := newSearchConsoleService + t.Cleanup(func() { newSearchConsoleService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/searchAnalytics/query")) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "responseAggregationType": "AUTO", + "rows": []map[string]any{ + { + "keys": []string{"gog cli", "https://example.com/docs"}, + "clicks": 12, + "impressions": 300, + "ctr": 0.04, + "position": 7.3, + }, + }, + }) + })) + defer srv.Close() + + svc, err := searchconsoleapi.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newSearchConsoleService = func(context.Context, string) (*searchconsoleapi.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{ + "--account", "a@b.com", + "searchconsole", "query", "sc-domain:example.com", + "--from", "2026-02-01", + "--to", "2026-02-07", + "--dimensions", "query,page", + "--type", "web", + "--max", "10", + }); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + if !strings.Contains(out, "QUERY") || + !strings.Contains(out, "PAGE") || + !strings.Contains(out, "CLICKS") || + !strings.Contains(out, "IMPRESSIONS") || + !strings.Contains(out, "CTR") || + !strings.Contains(out, "POSITION") || + !strings.Contains(out, "gog cli") || + !strings.Contains(out, "https://example.com/docs") || + !strings.Contains(out, "12") || + !strings.Contains(out, "300") { + t.Fatalf("unexpected out=%q", out) + } +} + +func TestExecute_SearchConsoleQuery_ServiceError(t *testing.T) { + origNew := newSearchConsoleService + t.Cleanup(func() { newSearchConsoleService = origNew }) + newSearchConsoleService = func(context.Context, string) (*searchconsoleapi.Service, error) { + return nil, errors.New("search console service down") + } + + _ = captureStderr(t, func() { + err := Execute([]string{ + "--account", "a@b.com", + "searchconsole", "query", "sc-domain:example.com", + "--from", "2026-02-01", + "--to", "2026-02-07", + }) + if err == nil || !strings.Contains(err.Error(), "search console service down") { + t.Fatalf("unexpected err: %v", err) + } + }) +} + +func TestExecute_SearchConsoleQuery_ValidatesDateBeforeServiceCall(t *testing.T) { + origNew := newSearchConsoleService + t.Cleanup(func() { newSearchConsoleService = origNew }) + newSearchConsoleService = func(context.Context, string) (*searchconsoleapi.Service, error) { + t.Fatalf("expected validation to fail before creating search console service") + return nil, errors.New("unexpected search console service call") + } + + _ = captureStderr(t, func() { + err := Execute([]string{ + "--account", "a@b.com", + "searchconsole", "query", "sc-domain:example.com", + "--from", "2026/02/01", + "--to", "2026-02-07", + }) + if err == nil || !strings.Contains(err.Error(), "invalid --from") { + t.Fatalf("unexpected err: %v", err) + } + }) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c898444b..36f2e2ca 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -29,7 +29,7 @@ const ( type RootFlags struct { Color string `help:"Color output: auto|always|never" default:"${color}"` - Account string `help:"Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript)" aliases:"acct" short:"a"` + Account string `help:"Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript/analytics/searchconsole)" aliases:"acct" short:"a"` Client string `help:"OAuth client name (selects stored credentials + token bucket)" default:"${client}"` AccessToken string `help:"Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h)" env:"GOG_ACCESS_TOKEN"` //nolint:gosec // CLI/env input, not an embedded secret EnableCommands string `help:"Comma-separated list of enabled top-level commands (restricts CLI)" default:"${enabled_commands}"` @@ -61,31 +61,33 @@ type CLI struct { Me PeopleMeCmd `cmd:"" name:"me" help:"Show your profile (alias for 'people me')"` Whoami PeopleMeCmd `cmd:"" name:"whoami" aliases:"who-am-i" help:"Show your profile (alias for 'people me')"` - Auth AuthCmd `cmd:"" help:"Auth and credentials"` - Groups GroupsCmd `cmd:"" aliases:"group" help:"Google Groups"` - Admin AdminCmd `cmd:"" help:"Google Workspace Admin (Directory API) - requires domain-wide delegation"` - Drive DriveCmd `cmd:"" aliases:"drv" help:"Google Drive"` - Docs DocsCmd `cmd:"" aliases:"doc" help:"Google Docs (export via Drive)"` - Slides SlidesCmd `cmd:"" aliases:"slide" help:"Google Slides"` - Calendar CalendarCmd `cmd:"" aliases:"cal" help:"Google Calendar"` - Classroom ClassroomCmd `cmd:"" aliases:"class" help:"Google Classroom"` - Time TimeCmd `cmd:"" help:"Local time utilities"` - Gmail GmailCmd `cmd:"" aliases:"mail,email" help:"Gmail"` - Chat ChatCmd `cmd:"" help:"Google Chat"` - Contacts ContactsCmd `cmd:"" aliases:"contact" help:"Google Contacts"` - Tasks TasksCmd `cmd:"" aliases:"task" help:"Google Tasks"` - People PeopleCmd `cmd:"" aliases:"person" help:"Google People"` - Keep KeepCmd `cmd:"" help:"Google Keep (Workspace only)"` - Sheets SheetsCmd `cmd:"" aliases:"sheet" help:"Google Sheets"` - Forms FormsCmd `cmd:"" aliases:"form" help:"Google Forms"` - AppScript AppScriptCmd `cmd:"" name:"appscript" aliases:"script,apps-script" help:"Google Apps Script"` - Config ConfigCmd `cmd:"" help:"Manage configuration"` - ExitCodes AgentExitCodesCmd `cmd:"" name:"exit-codes" aliases:"exitcodes" help:"Print stable exit codes (alias for 'agent exit-codes')"` - Agent AgentCmd `cmd:"" help:"Agent-friendly helpers"` - Schema SchemaCmd `cmd:"" help:"Machine-readable command/flag schema" aliases:"help-json,helpjson"` - VersionCmd VersionCmd `cmd:"" name:"version" help:"Print version"` - Completion CompletionCmd `cmd:"" help:"Generate shell completion scripts"` - Complete CompletionInternalCmd `cmd:"" name:"__complete" hidden:"" help:"Internal completion helper"` + Auth AuthCmd `cmd:"" help:"Auth and credentials"` + Groups GroupsCmd `cmd:"" aliases:"group" help:"Google Groups"` + Admin AdminCmd `cmd:"" help:"Google Workspace Admin (Directory API) - requires domain-wide delegation"` + Drive DriveCmd `cmd:"" aliases:"drv" help:"Google Drive"` + Docs DocsCmd `cmd:"" aliases:"doc" help:"Google Docs (export via Drive)"` + Slides SlidesCmd `cmd:"" aliases:"slide" help:"Google Slides"` + Calendar CalendarCmd `cmd:"" aliases:"cal" help:"Google Calendar"` + Classroom ClassroomCmd `cmd:"" aliases:"class" help:"Google Classroom"` + Time TimeCmd `cmd:"" help:"Local time utilities"` + Gmail GmailCmd `cmd:"" aliases:"mail,email" help:"Gmail"` + Chat ChatCmd `cmd:"" help:"Google Chat"` + Contacts ContactsCmd `cmd:"" aliases:"contact" help:"Google Contacts"` + Tasks TasksCmd `cmd:"" aliases:"task" help:"Google Tasks"` + People PeopleCmd `cmd:"" aliases:"person" help:"Google People"` + Keep KeepCmd `cmd:"" help:"Google Keep (Workspace only)"` + Sheets SheetsCmd `cmd:"" aliases:"sheet" help:"Google Sheets"` + Forms FormsCmd `cmd:"" aliases:"form" help:"Google Forms"` + AppScript AppScriptCmd `cmd:"" name:"appscript" aliases:"script,apps-script" help:"Google Apps Script"` + Analytics AnalyticsCmd `cmd:"" aliases:"ga" help:"Google Analytics"` + SearchConsole SearchConsoleCmd `cmd:"" name:"searchconsole" aliases:"gsc,search-console" help:"Google Search Console"` + Config ConfigCmd `cmd:"" help:"Manage configuration"` + ExitCodes AgentExitCodesCmd `cmd:"" name:"exit-codes" aliases:"exitcodes" help:"Print stable exit codes (alias for 'agent exit-codes')"` + Agent AgentCmd `cmd:"" help:"Agent-friendly helpers"` + Schema SchemaCmd `cmd:"" help:"Machine-readable command/flag schema" aliases:"help-json,helpjson"` + VersionCmd VersionCmd `cmd:"" name:"version" help:"Print version"` + Completion CompletionCmd `cmd:"" help:"Generate shell completion scripts"` + Complete CompletionInternalCmd `cmd:"" name:"__complete" hidden:"" help:"Internal completion helper"` } type exitPanic struct{ code int } @@ -333,7 +335,7 @@ func newParser(description string) (*kong.Kong, *CLI, error) { } func baseDescription() string { - return "Google CLI for Gmail/Calendar/Chat/Classroom/Drive/Contacts/Tasks/Sheets/Docs/Slides/People/Forms/App Script" + return "Google CLI for Gmail/Calendar/Chat/Classroom/Drive/Contacts/Tasks/Sheets/Docs/Slides/People/Forms/App Script/Analytics/Search Console/Admin" } func helpDescription() string { diff --git a/internal/cmd/searchconsole.go b/internal/cmd/searchconsole.go new file mode 100644 index 00000000..7696fe4b --- /dev/null +++ b/internal/cmd/searchconsole.go @@ -0,0 +1,239 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + "time" + + searchconsoleapi "google.golang.org/api/searchconsole/v1" + + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +var newSearchConsoleService = googleapi.NewSearchConsole + +type SearchConsoleCmd struct { + Sites SearchConsoleSitesCmd `cmd:"" name:"sites" aliases:"list,ls" default:"withargs" help:"List Search Console sites"` + Query SearchConsoleQueryCmd `cmd:"" name:"query" aliases:"report" help:"Run a Search Analytics query"` +} + +type SearchConsoleSitesCmd struct { + FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` +} + +func (c *SearchConsoleSitesCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newSearchConsoleService(ctx, account) + if err != nil { + return err + } + resp, err := svc.Sites.List().Context(ctx).Do() + if err != nil { + return err + } + + rows := resp.SiteEntry + if outfmt.IsJSON(ctx) { + if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "sites": rows, + }); err != nil { + return err + } + if len(rows) == 0 { + return failEmptyExit(c.FailEmpty) + } + return nil + } + + if len(rows) == 0 { + u.Err().Println("No Search Console sites") + return failEmptyExit(c.FailEmpty) + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "SITE\tPERMISSION") + for _, item := range rows { + if item == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\n", sanitizeTab(item.SiteUrl), sanitizeTab(item.PermissionLevel)) + } + return nil +} + +type SearchConsoleQueryCmd struct { + SiteURL string `arg:"" name:"siteUrl" help:"Search Console property URL (e.g. https://example.com/ or sc-domain:example.com)"` + From string `name:"from" required:"" help:"Start date (YYYY-MM-DD)"` + To string `name:"to" required:"" help:"End date (YYYY-MM-DD)"` + Dimensions string `name:"dimensions" help:"Comma-separated dimensions (DATE,QUERY,PAGE,COUNTRY,DEVICE,SEARCH_APPEARANCE,HOUR)" default:"QUERY"` + Type string `name:"type" help:"Search type (WEB,IMAGE,VIDEO,NEWS,DISCOVER,GOOGLE_NEWS)" default:"WEB"` + Max int64 `name:"max" aliases:"limit" help:"Max rows to return (1-25000)" default:"1000"` + Offset int64 `name:"offset" help:"Row offset for pagination" default:"0"` + FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no rows"` +} + +func (c *SearchConsoleQueryCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + siteURL := strings.TrimSpace(c.SiteURL) + if siteURL == "" { + return usage("empty siteUrl") + } + + from, err := parseSearchConsoleDate(c.From, "--from") + if err != nil { + return err + } + to, err := parseSearchConsoleDate(c.To, "--to") + if err != nil { + return err + } + if to < from { + return usage("--to must be on or after --from") + } + + if c.Max <= 0 || c.Max > 25000 { + return usage("--max must be between 1 and 25000") + } + if c.Offset < 0 { + return usage("--offset must be >= 0") + } + + dimensions, err := normalizeSearchConsoleDimensions(c.Dimensions) + if err != nil { + return err + } + searchType, err := normalizeSearchConsoleType(c.Type) + if err != nil { + return err + } + + svc, err := newSearchConsoleService(ctx, account) + if err != nil { + return err + } + resp, err := svc.Searchanalytics.Query(siteURL, &searchconsoleapi.SearchAnalyticsQueryRequest{ + StartDate: from, + EndDate: to, + Dimensions: dimensions, + Type: searchType, + RowLimit: c.Max, + StartRow: c.Offset, + }).Context(ctx).Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "site_url": siteURL, + "from": c.From, + "to": c.To, + "type": searchType, + "dimensions": dimensions, + "response_aggregation_type": resp.ResponseAggregationType, + "rows": resp.Rows, + }); err != nil { + return err + } + if len(resp.Rows) == 0 { + return failEmptyExit(c.FailEmpty) + } + return nil + } + + if len(resp.Rows) == 0 { + u.Err().Println("No Search Console rows") + return failEmptyExit(c.FailEmpty) + } + + headers := make([]string, 0, len(dimensions)+4) + headers = append(headers, dimensions...) + headers = append(headers, "CLICKS", "IMPRESSIONS", "CTR", "POSITION") + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, strings.Join(headers, "\t")) + for _, row := range resp.Rows { + if row == nil { + continue + } + values := make([]string, 0, len(dimensions)+4) + for i := range dimensions { + values = append(values, sanitizeTab(searchConsoleKey(row, i))) + } + values = append(values, + strconv.FormatFloat(row.Clicks, 'f', -1, 64), + strconv.FormatFloat(row.Impressions, 'f', -1, 64), + strconv.FormatFloat(row.Ctr, 'f', -1, 64), + strconv.FormatFloat(row.Position, 'f', -1, 64), + ) + fmt.Fprintln(w, strings.Join(values, "\t")) + } + return nil +} + +func parseSearchConsoleDate(value string, flagName string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", usagef("empty %s", flagName) + } + if _, err := time.Parse("2006-01-02", value); err != nil { + return "", usagef("invalid %s (expected YYYY-MM-DD)", flagName) + } + return value, nil +} + +func normalizeSearchConsoleType(raw string) (string, error) { + v := strings.ToUpper(strings.TrimSpace(raw)) + if v == "" { + return "", usage("empty --type") + } + switch v { + case "WEB", "IMAGE", "VIDEO", "NEWS", "DISCOVER", "GOOGLE_NEWS": + return v, nil + default: + return "", usagef("invalid --type %q (expected WEB|IMAGE|VIDEO|NEWS|DISCOVER|GOOGLE_NEWS)", raw) + } +} + +func normalizeSearchConsoleDimensions(raw string) ([]string, error) { + parts := splitCommaList(raw) + if len(parts) == 0 { + return nil, nil + } + + out := make([]string, 0, len(parts)) + for _, part := range parts { + v := strings.ToUpper(strings.TrimSpace(part)) + switch v { + case "DATE", "QUERY", "PAGE", "COUNTRY", "DEVICE", "SEARCH_APPEARANCE", "HOUR": + out = append(out, v) + default: + return nil, usagef("invalid dimension %q (expected DATE|QUERY|PAGE|COUNTRY|DEVICE|SEARCH_APPEARANCE|HOUR)", part) + } + } + return out, nil +} + +func searchConsoleKey(row *searchconsoleapi.ApiDataRow, index int) string { + if row == nil || index < 0 || index >= len(row.Keys) { + return "" + } + return row.Keys[index] +} diff --git a/internal/googleapi/analytics.go b/internal/googleapi/analytics.go new file mode 100644 index 00000000..22d39c8c --- /dev/null +++ b/internal/googleapi/analytics.go @@ -0,0 +1,31 @@ +package googleapi + +import ( + "context" + "fmt" + + analyticsadmin "google.golang.org/api/analyticsadmin/v1beta" + analyticsdata "google.golang.org/api/analyticsdata/v1beta" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewAnalyticsAdmin(ctx context.Context, email string) (*analyticsadmin.Service, error) { + if opts, err := optionsForAccount(ctx, googleauth.ServiceAnalytics, email); err != nil { + return nil, fmt.Errorf("analyticsadmin options: %w", err) + } else if svc, err := analyticsadmin.NewService(ctx, opts...); err != nil { + return nil, fmt.Errorf("create analyticsadmin service: %w", err) + } else { + return svc, nil + } +} + +func NewAnalyticsData(ctx context.Context, email string) (*analyticsdata.Service, error) { + if opts, err := optionsForAccount(ctx, googleauth.ServiceAnalytics, email); err != nil { + return nil, fmt.Errorf("analyticsdata options: %w", err) + } else if svc, err := analyticsdata.NewService(ctx, opts...); err != nil { + return nil, fmt.Errorf("create analyticsdata service: %w", err) + } else { + return svc, nil + } +} diff --git a/internal/googleapi/searchconsole.go b/internal/googleapi/searchconsole.go new file mode 100644 index 00000000..6c2539cd --- /dev/null +++ b/internal/googleapi/searchconsole.go @@ -0,0 +1,20 @@ +package googleapi + +import ( + "context" + "fmt" + + searchconsoleapi "google.golang.org/api/searchconsole/v1" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewSearchConsole(ctx context.Context, email string) (*searchconsoleapi.Service, error) { + if opts, err := optionsForAccount(ctx, googleauth.ServiceSearchConsole, email); err != nil { + return nil, fmt.Errorf("searchconsole options: %w", err) + } else if svc, err := searchconsoleapi.NewService(ctx, opts...); err != nil { + return nil, fmt.Errorf("create searchconsole service: %w", err) + } else { + return svc, nil + } +} diff --git a/internal/googleapi/services_more_test.go b/internal/googleapi/services_more_test.go index e305de8c..3808b392 100644 --- a/internal/googleapi/services_more_test.go +++ b/internal/googleapi/services_more_test.go @@ -62,6 +62,18 @@ func TestNewServicesWithStoredToken(t *testing.T) { t.Fatalf("NewTasks: %v", err) } + if _, err := NewAnalyticsAdmin(ctx, "a@b.com"); err != nil { + t.Fatalf("NewAnalyticsAdmin: %v", err) + } + + if _, err := NewAnalyticsData(ctx, "a@b.com"); err != nil { + t.Fatalf("NewAnalyticsData: %v", err) + } + + if _, err := NewSearchConsole(ctx, "a@b.com"); err != nil { + t.Fatalf("NewSearchConsole: %v", err) + } + if _, err := NewKeep(ctx, "a@b.com"); err != nil { t.Fatalf("NewKeep: %v", err) } diff --git a/internal/googleauth/service.go b/internal/googleauth/service.go index 92e001ca..39242ebc 100644 --- a/internal/googleauth/service.go +++ b/internal/googleauth/service.go @@ -10,22 +10,24 @@ import ( type Service string const ( - ServiceGmail Service = "gmail" - ServiceCalendar Service = "calendar" - ServiceChat Service = "chat" - ServiceClassroom Service = "classroom" - ServiceDrive Service = "drive" - ServiceDocs Service = "docs" - ServiceSlides Service = "slides" - ServiceContacts Service = "contacts" - ServiceTasks Service = "tasks" - ServicePeople Service = "people" - ServiceSheets Service = "sheets" - ServiceForms Service = "forms" - ServiceAppScript Service = "appscript" - ServiceGroups Service = "groups" - ServiceKeep Service = "keep" - ServiceAdmin Service = "admin" + ServiceGmail Service = "gmail" + ServiceCalendar Service = "calendar" + ServiceChat Service = "chat" + ServiceClassroom Service = "classroom" + ServiceDrive Service = "drive" + ServiceDocs Service = "docs" + ServiceSlides Service = "slides" + ServiceContacts Service = "contacts" + ServiceTasks Service = "tasks" + ServicePeople Service = "people" + ServiceSheets Service = "sheets" + ServiceForms Service = "forms" + ServiceAppScript Service = "appscript" + ServiceAnalytics Service = "analytics" + ServiceSearchConsole Service = "searchconsole" + ServiceGroups Service = "groups" + ServiceKeep Service = "keep" + ServiceAdmin Service = "admin" ) const ( @@ -83,6 +85,8 @@ var serviceOrder = []Service{ ServicePeople, ServiceForms, ServiceAppScript, + ServiceAnalytics, + ServiceSearchConsole, ServiceGroups, ServiceKeep, ServiceAdmin, @@ -203,6 +207,17 @@ var serviceInfoByService = map[Service]serviceInfo{ user: true, apis: []string{"Apps Script API"}, }, + ServiceAnalytics: { + scopes: []string{"https://www.googleapis.com/auth/analytics.readonly"}, + user: true, + apis: []string{"Analytics Admin API", "Analytics Data API"}, + note: "GA4 account summaries + reporting", + }, + ServiceSearchConsole: { + scopes: []string{"https://www.googleapis.com/auth/webmasters.readonly"}, + user: true, + apis: []string{"Search Console API"}, + }, ServiceGroups: { scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"}, user: false, @@ -551,6 +566,10 @@ func scopesForServiceWithOptions(service Service, opts ScopeOptions) ([]string, }, nil } + return Scopes(service) + case ServiceAnalytics: + return Scopes(service) + case ServiceSearchConsole: return Scopes(service) case ServiceGroups: return Scopes(service) diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go index 3a664f98..bbbd9a9b 100644 --- a/internal/googleauth/service_test.go +++ b/internal/googleauth/service_test.go @@ -21,8 +21,11 @@ func TestParseService(t *testing.T) { {"sheets", ServiceSheets}, {"forms", ServiceForms}, {"appscript", ServiceAppScript}, + {"analytics", ServiceAnalytics}, + {"searchconsole", ServiceSearchConsole}, {"groups", ServiceGroups}, {"keep", ServiceKeep}, + {"admin", ServiceAdmin}, } for _, tt := range tests { got, err := ParseService(tt.in) @@ -65,7 +68,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) { func TestAllServices(t *testing.T) { svcs := AllServices() - if len(svcs) != 16 { + if len(svcs) != 18 { t.Fatalf("unexpected: %v", svcs) } seen := make(map[Service]bool) @@ -74,7 +77,26 @@ func TestAllServices(t *testing.T) { seen[s] = true } - for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceChat, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceSlides, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceForms, ServiceAppScript, ServiceGroups, ServiceKeep, ServiceAdmin} { + for _, want := range []Service{ + ServiceGmail, + ServiceCalendar, + ServiceChat, + ServiceClassroom, + ServiceDrive, + ServiceDocs, + ServiceSlides, + ServiceContacts, + ServiceTasks, + ServicePeople, + ServiceSheets, + ServiceForms, + ServiceAppScript, + ServiceAnalytics, + ServiceSearchConsole, + ServiceGroups, + ServiceKeep, + ServiceAdmin, + } { if !seen[want] { t.Fatalf("missing %q", want) } @@ -83,7 +105,7 @@ func TestAllServices(t *testing.T) { func TestUserServices(t *testing.T) { svcs := UserServices() - if len(svcs) != 13 { + if len(svcs) != 15 { t.Fatalf("unexpected: %v", svcs) } @@ -98,6 +120,8 @@ func TestUserServices(t *testing.T) { seenSlides = true case ServiceForms, ServiceAppScript: // expected user services + case ServiceAnalytics, ServiceSearchConsole: + // expected user services case ServiceKeep: t.Fatalf("unexpected keep in user services") } @@ -113,7 +137,7 @@ func TestUserServices(t *testing.T) { } func TestUserServiceCSV(t *testing.T) { - want := "gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people,forms,appscript" + want := "gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people,forms,appscript,analytics,searchconsole" if got := UserServiceCSV(); got != want { t.Fatalf("unexpected user services csv: %q", got) } @@ -229,7 +253,20 @@ func TestScopesForServices_UnionSorted(t *testing.T) { } func TestScopesForManageWithOptions_Readonly(t *testing.T) { - scopes, err := ScopesForManageWithOptions([]Service{ServiceGmail, ServiceDrive, ServiceCalendar, ServiceContacts, ServiceTasks, ServiceSheets, ServiceDocs, ServicePeople, ServiceForms, ServiceAppScript}, ScopeOptions{ + scopes, err := ScopesForManageWithOptions([]Service{ + ServiceGmail, + ServiceDrive, + ServiceCalendar, + ServiceContacts, + ServiceTasks, + ServiceSheets, + ServiceDocs, + ServicePeople, + ServiceForms, + ServiceAppScript, + ServiceAnalytics, + ServiceSearchConsole, + }, ScopeOptions{ Readonly: true, DriveScope: DriveScopeFull, }) @@ -252,6 +289,8 @@ func TestScopesForManageWithOptions_Readonly(t *testing.T) { "https://www.googleapis.com/auth/forms.responses.readonly", "https://www.googleapis.com/auth/script.projects.readonly", "https://www.googleapis.com/auth/script.deployments.readonly", + "https://www.googleapis.com/auth/analytics.readonly", + "https://www.googleapis.com/auth/webmasters.readonly", "profile", } for _, w := range want { From 47beb8782f31edfac66a03542463393f94d312d0 Mon Sep 17 00:00:00 2001 From: Haresh Seenivasagan Date: Tue, 3 Mar 2026 21:49:11 +0800 Subject: [PATCH 2/3] docs(changelog): add analytics/searchconsole entry for PR #402 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87614640..03a182a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Added +- Analytics/Search Console: add `analytics accounts|report` and `searchconsole sites|query` command groups for GA4 + GSC reporting. (#402) — thanks @haresh-seenivasagan. - Gmail: add `gmail autoreply` to reply once to matching messages, label the thread for dedupe, and optionally archive/mark read. Includes docs and regression coverage for skip/reply flows. ## 0.12.0 - 2026-03-09 From c6520ed8fd48a81b849e50151db0c8fa55f0538b Mon Sep 17 00:00:00 2001 From: haresh seenivasagan Date: Sat, 14 Mar 2026 16:40:15 +0800 Subject: [PATCH 3/3] feat(searchconsole): add sitemap commands and canonicalize queries --- CHANGELOG.md | 2 +- README.md | 12 +- .../execute_analytics_searchconsole_test.go | 8 +- internal/cmd/root.go | 2 +- internal/cmd/searchconsole.go | 769 ++++++++++++++++-- internal/cmd/searchconsole_more_test.go | 310 +++++++ internal/googleauth/service.go | 6 +- internal/googleauth/service_test.go | 23 + 8 files changed, 1062 insertions(+), 70 deletions(-) create mode 100644 internal/cmd/searchconsole_more_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 03a182a0..3465332a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased ### Added -- Analytics/Search Console: add `analytics accounts|report` and `searchconsole sites|query` command groups for GA4 + GSC reporting. (#402) — thanks @haresh-seenivasagan. +- Analytics/Search Console: add `analytics accounts|report`; add `searchconsole sites|get`, `searchconsole searchanalytics query`, and `searchconsole sitemaps list|get|submit|delete` for GA4 + GSC reporting and sitemap management. (#402) — thanks @haresh-seenivasagan. - Gmail: add `gmail autoreply` to reply once to matching messages, label the thread for dedupe, and optionally archive/mark read. Includes docs and regression coverage for skip/reply flows. ## 0.12.0 - 2026-03-09 diff --git a/README.md b/README.md index f308c9bf..2a9f2824 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli - **Forms** - create/update forms, manage questions, inspect responses, and manage watches - **Apps Script** - create/get/bind projects, inspect content, and run functions - **Analytics** - list GA4 account summaries and run reports via the Analytics Data API -- **Search Console** - list properties and run Search Analytics queries +- **Search Console** - list properties, run Search Analytics queries, and manage sitemaps - **Docs/Slides** - create/copy/export docs/slides, edit Docs by tab, import Markdown, do richer find-replace, export Docs as Markdown/HTML, and generate Slides from Markdown or templates - **People** - profile lookup and directory search helpers - **Keep (Workspace only)** - list/get/search/create/delete notes and download attachments (service account + domain-wide delegation) @@ -401,7 +401,7 @@ Service scope matrix (auto-generated; run `go run scripts/gen-auth-services-md.g | forms | yes | Forms API | `https://www.googleapis.com/auth/forms.body`
`https://www.googleapis.com/auth/forms.responses.readonly` | | | appscript | yes | Apps Script API | `https://www.googleapis.com/auth/script.projects`
`https://www.googleapis.com/auth/script.deployments`
`https://www.googleapis.com/auth/script.processes` | | | analytics | yes | Analytics Admin API, Analytics Data API | `https://www.googleapis.com/auth/analytics.readonly` | GA4 account summaries + reporting | -| searchconsole | yes | Search Console API | `https://www.googleapis.com/auth/webmasters.readonly` | | +| searchconsole | yes | Search Console API | `https://www.googleapis.com/auth/webmasters` | Search Analytics + sitemap management | | groups | no | Cloud Identity API | `https://www.googleapis.com/auth/cloud-identity.groups.readonly` | Workspace only | | keep | no | Keep API | `https://www.googleapis.com/auth/keep` | Workspace only; service account (domain-wide delegation) | @@ -1163,10 +1163,18 @@ gog analytics report properties/123456789 --from 7daysAgo --to today --dimension ```bash # Sites/properties gog searchconsole sites +gog searchconsole sites get sc-domain:example.com # Search Analytics query gog searchconsole query sc-domain:example.com --from 2026-02-01 --to 2026-02-07 --dimensions query,page --type WEB --max 1000 gog searchconsole query https://example.com/ --from 2026-02-01 --to 2026-02-07 --dimensions date --type WEB +gog searchconsole searchanalytics query sc-domain:example.com --from 2026-02-01 --to 2026-02-07 --filter query:contains:gog --aggregation BY_PAGE + +# Sitemaps +gog searchconsole sitemaps sc-domain:example.com +gog searchconsole sitemaps get sc-domain:example.com https://example.com/sitemap.xml +gog searchconsole sitemaps submit sc-domain:example.com https://example.com/sitemap.xml +gog searchconsole sitemaps delete sc-domain:example.com https://example.com/sitemap.xml --force ``` ### People diff --git a/internal/cmd/execute_analytics_searchconsole_test.go b/internal/cmd/execute_analytics_searchconsole_test.go index 728271e5..7065154b 100644 --- a/internal/cmd/execute_analytics_searchconsole_test.go +++ b/internal/cmd/execute_analytics_searchconsole_test.go @@ -587,9 +587,13 @@ func TestExecute_SearchConsoleQuery_JSON(t *testing.T) { if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("decode request: %v", err) } - if req["startDate"] != "2026-02-01" || req["endDate"] != "2026-02-07" || req["type"] != "WEB" { + if req["startDate"] != "2026-02-01" || req["endDate"] != "2026-02-07" || req["type"] != "web" { t.Fatalf("unexpected request payload: %#v", req) } + dimensions, ok := req["dimensions"].([]any) + if !ok || len(dimensions) != 2 || dimensions[0] != "query" || dimensions[1] != "page" { + t.Fatalf("unexpected dimensions payload: %#v", req["dimensions"]) + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "responseAggregationType": "AUTO", @@ -643,7 +647,7 @@ func TestExecute_SearchConsoleQuery_JSON(t *testing.T) { if err := json.Unmarshal([]byte(out), &parsed); err != nil { t.Fatalf("unmarshal: %v", err) } - if parsed.SiteURL != "sc-domain:example.com" || parsed.Type != "WEB" || len(parsed.Rows) != 1 || len(parsed.Rows[0].Keys) != 2 { + if parsed.SiteURL != "sc-domain:example.com" || parsed.Type != "web" || len(parsed.Rows) != 1 || len(parsed.Rows[0].Keys) != 2 { t.Fatalf("unexpected payload: %#v", parsed) } } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 36f2e2ca..dd131334 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -80,7 +80,7 @@ type CLI struct { Forms FormsCmd `cmd:"" aliases:"form" help:"Google Forms"` AppScript AppScriptCmd `cmd:"" name:"appscript" aliases:"script,apps-script" help:"Google Apps Script"` Analytics AnalyticsCmd `cmd:"" aliases:"ga" help:"Google Analytics"` - SearchConsole SearchConsoleCmd `cmd:"" name:"searchconsole" aliases:"gsc,search-console" help:"Google Search Console"` + SearchConsole SearchConsoleCmd `cmd:"" name:"searchconsole" aliases:"gsc,search-console,webmasters" help:"Google Search Console"` Config ConfigCmd `cmd:"" help:"Manage configuration"` ExitCodes AgentExitCodesCmd `cmd:"" name:"exit-codes" aliases:"exitcodes" help:"Print stable exit codes (alias for 'agent exit-codes')"` Agent AgentCmd `cmd:"" help:"Agent-friendly helpers"` diff --git a/internal/cmd/searchconsole.go b/internal/cmd/searchconsole.go index 7696fe4b..7505bb66 100644 --- a/internal/cmd/searchconsole.go +++ b/internal/cmd/searchconsole.go @@ -2,14 +2,18 @@ package cmd import ( "context" + "encoding/json" + "errors" "fmt" "os" "strconv" "strings" "time" + gapi "google.golang.org/api/googleapi" searchconsoleapi "google.golang.org/api/searchconsole/v1" + "github.com/steipete/gogcli/internal/config" "github.com/steipete/gogcli/internal/googleapi" "github.com/steipete/gogcli/internal/outfmt" "github.com/steipete/gogcli/internal/ui" @@ -17,16 +21,28 @@ import ( var newSearchConsoleService = googleapi.NewSearchConsole +const ( + defaultSearchConsoleRowLimit = int64(1000) + maxSearchConsoleRowLimit = int64(25000) +) + type SearchConsoleCmd struct { - Sites SearchConsoleSitesCmd `cmd:"" name:"sites" aliases:"list,ls" default:"withargs" help:"List Search Console sites"` - Query SearchConsoleQueryCmd `cmd:"" name:"query" aliases:"report" help:"Run a Search Analytics query"` + Sites SearchConsoleSitesCmd `cmd:"" name:"sites" aliases:"list,ls" help:"List and inspect Search Console sites"` + SearchAnalytics SearchConsoleSearchAnalyticsCmd `cmd:"" name:"searchanalytics" aliases:"analytics" help:"Search Analytics queries"` + Query SearchConsoleQueryCmd `cmd:"" name:"query" aliases:"report" help:"Run a Search Analytics query"` + Sitemaps SearchConsoleSitemapsCmd `cmd:"" name:"sitemaps" help:"List/get/submit/delete sitemaps"` } type SearchConsoleSitesCmd struct { + List SearchConsoleSitesListCmd `cmd:"" default:"withargs" aliases:"ls" help:"List accessible Search Console sites"` + Get SearchConsoleSitesGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a specific Search Console site"` +} + +type SearchConsoleSitesListCmd struct { FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` } -func (c *SearchConsoleSitesCmd) Run(ctx context.Context, flags *RootFlags) error { +func (c *SearchConsoleSitesListCmd) Run(ctx context.Context, flags *RootFlags) error { u := ui.FromContext(ctx) account, err := requireAccount(flags) if err != nil { @@ -39,7 +55,7 @@ func (c *SearchConsoleSitesCmd) Run(ctx context.Context, flags *RootFlags) error } resp, err := svc.Sites.List().Context(ctx).Do() if err != nil { - return err + return wrapSearchConsoleError(err) } rows := resp.SiteEntry @@ -72,53 +88,76 @@ func (c *SearchConsoleSitesCmd) Run(ctx context.Context, flags *RootFlags) error return nil } -type SearchConsoleQueryCmd struct { - SiteURL string `arg:"" name:"siteUrl" help:"Search Console property URL (e.g. https://example.com/ or sc-domain:example.com)"` - From string `name:"from" required:"" help:"Start date (YYYY-MM-DD)"` - To string `name:"to" required:"" help:"End date (YYYY-MM-DD)"` - Dimensions string `name:"dimensions" help:"Comma-separated dimensions (DATE,QUERY,PAGE,COUNTRY,DEVICE,SEARCH_APPEARANCE,HOUR)" default:"QUERY"` - Type string `name:"type" help:"Search type (WEB,IMAGE,VIDEO,NEWS,DISCOVER,GOOGLE_NEWS)" default:"WEB"` - Max int64 `name:"max" aliases:"limit" help:"Max rows to return (1-25000)" default:"1000"` - Offset int64 `name:"offset" help:"Row offset for pagination" default:"0"` - FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no rows"` +type SearchConsoleSitesGetCmd struct { + SiteURL string `arg:"" name:"siteUrl" help:"Search Console property URL (e.g. https://example.com/ or sc-domain:example.com)"` } -func (c *SearchConsoleQueryCmd) Run(ctx context.Context, flags *RootFlags) error { +func (c *SearchConsoleSitesGetCmd) Run(ctx context.Context, flags *RootFlags) error { u := ui.FromContext(ctx) - account, err := requireAccount(flags) - if err != nil { - return err - } - siteURL := strings.TrimSpace(c.SiteURL) if siteURL == "" { return usage("empty siteUrl") } - from, err := parseSearchConsoleDate(c.From, "--from") + account, err := requireAccount(flags) if err != nil { return err } - to, err := parseSearchConsoleDate(c.To, "--to") + + svc, err := newSearchConsoleService(ctx, account) if err != nil { return err } - if to < from { - return usage("--to must be on or after --from") + site, err := svc.Sites.Get(siteURL).Context(ctx).Do() + if err != nil { + return wrapSearchConsoleError(err) } - if c.Max <= 0 || c.Max > 25000 { - return usage("--max must be between 1 and 25000") - } - if c.Offset < 0 { - return usage("--offset must be >= 0") + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "site": site, + }) } - dimensions, err := normalizeSearchConsoleDimensions(c.Dimensions) + return writeResult(ctx, u, + kv("site_url", site.SiteUrl), + kv("permission_level", site.PermissionLevel), + ) +} + +type SearchConsoleSearchAnalyticsCmd struct { + Query SearchConsoleQueryCmd `cmd:"" name:"query" default:"withargs" aliases:"run" help:"Run a Search Analytics query"` +} + +type SearchConsoleQueryCmd struct { + SiteURL string `arg:"" name:"siteUrl" help:"Search Console property URL (e.g. https://example.com/ or sc-domain:example.com)"` + + From string `name:"from" aliases:"start" help:"Start date (YYYY-MM-DD)"` + To string `name:"to" aliases:"end" help:"End date (YYYY-MM-DD)"` + Dimensions string `name:"dimensions" help:"Comma-separated dimensions (DATE,QUERY,PAGE,COUNTRY,DEVICE,SEARCH_APPEARANCE,HOUR)" default:"QUERY"` + Type string `name:"type" help:"Search type (WEB,IMAGE,VIDEO,NEWS,DISCOVER,GOOGLE_NEWS)" default:"WEB"` + Aggregation string `name:"aggregation" help:"Aggregation type (AUTO,BY_PROPERTY,BY_PAGE,BY_NEWS_SHOWCASE_PANEL)"` + DataState string `name:"data-state" help:"Data state (FINAL,ALL,HOURLY_ALL)"` + Max int64 `name:"max" aliases:"limit" help:"Max rows to return (1-25000)" default:"1000"` + Offset int64 `name:"offset" aliases:"start-row" help:"Row offset for pagination" default:"0"` + Filter []string `name:"filter" help:"Dimension filter, repeatable: dimension:operator:expression"` + Request string `name:"request" help:"SearchAnalyticsQueryRequest JSON spec. Accepts @file, a plain file path, -, or inline JSON."` + FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no rows"` +} + +func (c *SearchConsoleQueryCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) if err != nil { return err } - searchType, err := normalizeSearchConsoleType(c.Type) + + siteURL := strings.TrimSpace(c.SiteURL) + if siteURL == "" { + return usage("empty siteUrl") + } + + req, err := c.buildRequest() if err != nil { return err } @@ -127,25 +166,23 @@ func (c *SearchConsoleQueryCmd) Run(ctx context.Context, flags *RootFlags) error if err != nil { return err } - resp, err := svc.Searchanalytics.Query(siteURL, &searchconsoleapi.SearchAnalyticsQueryRequest{ - StartDate: from, - EndDate: to, - Dimensions: dimensions, - Type: searchType, - RowLimit: c.Max, - StartRow: c.Offset, - }).Context(ctx).Do() + resp, err := svc.Searchanalytics.Query(siteURL, req).Context(ctx).Do() if err != nil { - return err + return wrapSearchConsoleError(err) + } + + queryType := req.Type + if queryType == "" { + queryType = req.SearchType } if outfmt.IsJSON(ctx) { if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ "site_url": siteURL, - "from": c.From, - "to": c.To, - "type": searchType, - "dimensions": dimensions, + "from": req.StartDate, + "to": req.EndDate, + "type": queryType, + "dimensions": req.Dimensions, "response_aggregation_type": resp.ResponseAggregationType, "rows": resp.Rows, }); err != nil { @@ -162,8 +199,7 @@ func (c *SearchConsoleQueryCmd) Run(ctx context.Context, flags *RootFlags) error return failEmptyExit(c.FailEmpty) } - headers := make([]string, 0, len(dimensions)+4) - headers = append(headers, dimensions...) + headers := requestSearchConsoleDimensions(req, resp.Rows) headers = append(headers, "CLICKS", "IMPRESSIONS", "CTR", "POSITION") w, flush := tableWriter(ctx) @@ -173,21 +209,418 @@ func (c *SearchConsoleQueryCmd) Run(ctx context.Context, flags *RootFlags) error if row == nil { continue } - values := make([]string, 0, len(dimensions)+4) - for i := range dimensions { + values := make([]string, 0, len(headers)) + for i := range headers[:len(headers)-4] { values = append(values, sanitizeTab(searchConsoleKey(row, i))) } values = append(values, - strconv.FormatFloat(row.Clicks, 'f', -1, 64), - strconv.FormatFloat(row.Impressions, 'f', -1, 64), - strconv.FormatFloat(row.Ctr, 'f', -1, 64), - strconv.FormatFloat(row.Position, 'f', -1, 64), + formatSearchConsoleMetric(row.Clicks, 0), + formatSearchConsoleMetric(row.Impressions, 0), + formatSearchConsoleMetric(row.Ctr, 4), + formatSearchConsoleMetric(row.Position, 2), ) fmt.Fprintln(w, strings.Join(values, "\t")) } return nil } +func (c *SearchConsoleQueryCmd) buildRequest() (*searchconsoleapi.SearchAnalyticsQueryRequest, error) { + requestSpec := strings.TrimSpace(c.Request) + if requestSpec != "" { + return buildSearchConsoleRequestFromSpec(requestSpec) + } + + from, err := parseSearchConsoleDate(c.From, "--from") + if err != nil { + return nil, err + } + to, err := parseSearchConsoleDate(c.To, "--to") + if err != nil { + return nil, err + } + if err := validateSearchConsoleDateRange(from, to); err != nil { + return nil, err + } + + if c.Max <= 0 || c.Max > maxSearchConsoleRowLimit { + return nil, usagef("--max must be between 1 and %d", maxSearchConsoleRowLimit) + } + if c.Offset < 0 { + return nil, usage("--offset must be >= 0") + } + + dimensions, err := normalizeSearchConsoleDimensions(c.Dimensions) + if err != nil { + return nil, err + } + searchType, err := normalizeSearchConsoleType(c.Type) + if err != nil { + return nil, err + } + + req := &searchconsoleapi.SearchAnalyticsQueryRequest{ + StartDate: from, + EndDate: to, + Dimensions: dimensions, + Type: searchType, + RowLimit: c.Max, + StartRow: c.Offset, + } + + if v := strings.TrimSpace(c.Aggregation); v != "" { + aggregation, err := normalizeSearchConsoleAggregation(v) + if err != nil { + return nil, err + } + req.AggregationType = aggregation + } + if v := strings.TrimSpace(c.DataState); v != "" { + dataState, err := normalizeSearchConsoleDataState(v) + if err != nil { + return nil, err + } + req.DataState = dataState + } + + if len(c.Filter) > 0 { + filters := make([]*searchconsoleapi.ApiDimensionFilter, 0, len(c.Filter)) + for _, raw := range c.Filter { + filter, err := parseSearchConsoleFilter(raw) + if err != nil { + return nil, err + } + filters = append(filters, filter) + } + req.DimensionFilterGroups = []*searchconsoleapi.ApiDimensionFilterGroup{ + { + GroupType: "and", + Filters: filters, + }, + } + } + + return req, nil +} + +type SearchConsoleSitemapsCmd struct { + List SearchConsoleSitemapsListCmd `cmd:"" default:"withargs" aliases:"ls" help:"List sitemaps for a site"` + Get SearchConsoleSitemapsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a sitemap"` + Submit SearchConsoleSitemapsSubmitCmd `cmd:"" name:"submit" help:"Submit a sitemap"` + Delete SearchConsoleSitemapsDeleteCmd `cmd:"" name:"delete" aliases:"rm,remove" help:"Delete a sitemap"` +} + +type SearchConsoleSitemapsListCmd struct { + SiteURL string `arg:"" name:"siteUrl" help:"Search Console property URL (e.g. https://example.com/ or sc-domain:example.com)"` + SitemapIndex string `name:"sitemap-index" help:"Filter to a sitemap index URL"` + FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` +} + +func (c *SearchConsoleSitemapsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + siteURL := strings.TrimSpace(c.SiteURL) + if siteURL == "" { + return usage("empty siteUrl") + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newSearchConsoleService(ctx, account) + if err != nil { + return err + } + call := svc.Sitemaps.List(siteURL).Context(ctx) + if v := strings.TrimSpace(c.SitemapIndex); v != "" { + call = call.SitemapIndex(v) + } + resp, err := call.Do() + if err != nil { + return wrapSearchConsoleError(err) + } + + rows := resp.Sitemap + if outfmt.IsJSON(ctx) { + if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "sitemaps": rows, + }); err != nil { + return err + } + if len(rows) == 0 { + return failEmptyExit(c.FailEmpty) + } + return nil + } + + if len(rows) == 0 { + u.Err().Println("No Search Console sitemaps") + return failEmptyExit(c.FailEmpty) + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "PATH\tTYPE\tPENDING\tWARNINGS\tERRORS\tLAST_SUBMITTED\tLAST_DOWNLOADED\tCONTENTS") + for _, item := range rows { + if item == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%t\t%d\t%d\t%s\t%s\t%s\n", + sanitizeTab(item.Path), + sanitizeTab(item.Type), + item.IsPending, + item.Warnings, + item.Errors, + sanitizeTab(item.LastSubmitted), + sanitizeTab(item.LastDownloaded), + sanitizeTab(formatSearchConsoleSitemapContents(item.Contents)), + ) + } + return nil +} + +type SearchConsoleSitemapsGetCmd struct { + SiteURL string `arg:"" name:"siteUrl" help:"Search Console property URL (e.g. https://example.com/ or sc-domain:example.com)"` + FeedPath string `arg:"" name:"feedpath" help:"Sitemap URL"` +} + +func (c *SearchConsoleSitemapsGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + siteURL := strings.TrimSpace(c.SiteURL) + if siteURL == "" { + return usage("empty siteUrl") + } + feedPath := strings.TrimSpace(c.FeedPath) + if feedPath == "" { + return usage("empty feedpath") + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newSearchConsoleService(ctx, account) + if err != nil { + return err + } + sitemap, err := svc.Sitemaps.Get(siteURL, feedPath).Context(ctx).Do() + if err != nil { + return wrapSearchConsoleError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "sitemap": sitemap, + }) + } + + return writeResult(ctx, u, + kv("path", sitemap.Path), + kv("type", sitemap.Type), + kv("pending", sitemap.IsPending), + kv("warnings", sitemap.Warnings), + kv("errors", sitemap.Errors), + kv("last_submitted", sitemap.LastSubmitted), + kv("last_downloaded", sitemap.LastDownloaded), + kv("contents", formatSearchConsoleSitemapContents(sitemap.Contents)), + ) +} + +type SearchConsoleSitemapsSubmitCmd struct { + SiteURL string `arg:"" name:"siteUrl" help:"Search Console property URL (e.g. https://example.com/ or sc-domain:example.com)"` + FeedPath string `arg:"" name:"feedpath" help:"Sitemap URL"` +} + +func (c *SearchConsoleSitemapsSubmitCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + siteURL := strings.TrimSpace(c.SiteURL) + if siteURL == "" { + return usage("empty siteUrl") + } + feedPath := strings.TrimSpace(c.FeedPath) + if feedPath == "" { + return usage("empty feedpath") + } + + if err := dryRunExit(ctx, flags, "searchconsole.sitemaps.submit", map[string]any{ + "site_url": siteURL, + "feed_path": feedPath, + }); err != nil { + return err + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newSearchConsoleService(ctx, account) + if err != nil { + return err + } + if err := svc.Sitemaps.Submit(siteURL, feedPath).Context(ctx).Do(); err != nil { + return wrapSearchConsoleError(err) + } + + return writeResult(ctx, u, + kv("submitted", true), + kv("site_url", siteURL), + kv("feed_path", feedPath), + ) +} + +type SearchConsoleSitemapsDeleteCmd struct { + SiteURL string `arg:"" name:"siteUrl" help:"Search Console property URL (e.g. https://example.com/ or sc-domain:example.com)"` + FeedPath string `arg:"" name:"feedpath" help:"Sitemap URL"` +} + +func (c *SearchConsoleSitemapsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + siteURL := strings.TrimSpace(c.SiteURL) + if siteURL == "" { + return usage("empty siteUrl") + } + feedPath := strings.TrimSpace(c.FeedPath) + if feedPath == "" { + return usage("empty feedpath") + } + + if err := dryRunAndConfirmDestructive(ctx, flags, "searchconsole.sitemaps.delete", map[string]any{ + "site_url": siteURL, + "feed_path": feedPath, + }, fmt.Sprintf("delete sitemap %s", feedPath)); err != nil { + return err + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newSearchConsoleService(ctx, account) + if err != nil { + return err + } + if err := svc.Sitemaps.Delete(siteURL, feedPath).Context(ctx).Do(); err != nil { + return wrapSearchConsoleError(err) + } + + return writeResult(ctx, u, + kv("deleted", true), + kv("site_url", siteURL), + kv("feed_path", feedPath), + ) +} + +func buildSearchConsoleRequestFromSpec(spec string) (*searchconsoleapi.SearchAnalyticsQueryRequest, error) { + b, err := readSearchConsoleRequestBytes(spec) + if err != nil { + return nil, err + } + + var req searchconsoleapi.SearchAnalyticsQueryRequest + if err := json.Unmarshal(b, &req); err != nil { + return nil, fmt.Errorf("decode search console request: %w", err) + } + + if req.RowLimit == 0 { + req.RowLimit = defaultSearchConsoleRowLimit + } + if req.RowLimit < 1 || req.RowLimit > maxSearchConsoleRowLimit { + return nil, usagef("request.rowLimit must be between 1 and %d", maxSearchConsoleRowLimit) + } + if req.StartRow < 0 { + return nil, usage("request.startRow must be >= 0") + } + if err := validateSearchConsoleDateRange(req.StartDate, req.EndDate); err != nil { + return nil, err + } + + if len(req.Dimensions) > 0 { + dimensions, err := normalizeSearchConsoleDimensionList(req.Dimensions) + if err != nil { + return nil, err + } + req.Dimensions = dimensions + } + + if req.Type == "" && req.SearchType != "" { + req.Type = req.SearchType + } + if req.Type == "" { + req.Type = "web" + } + searchType, err := normalizeSearchConsoleType(req.Type) + if err != nil { + return nil, err + } + req.Type = searchType + req.SearchType = searchType + + if v := strings.TrimSpace(req.AggregationType); v != "" { + aggregation, err := normalizeSearchConsoleAggregation(v) + if err != nil { + return nil, err + } + req.AggregationType = aggregation + } + if v := strings.TrimSpace(req.DataState); v != "" { + dataState, err := normalizeSearchConsoleDataState(v) + if err != nil { + return nil, err + } + req.DataState = dataState + } + + for _, group := range req.DimensionFilterGroups { + if group == nil { + continue + } + if strings.TrimSpace(group.GroupType) == "" { + group.GroupType = "and" + } + if !strings.EqualFold(strings.TrimSpace(group.GroupType), "and") { + return nil, usagef("invalid request.groupType %q (expected and)", group.GroupType) + } + group.GroupType = "and" + for _, filter := range group.Filters { + if filter == nil { + continue + } + dimension, err := normalizeSearchConsoleDimension(filter.Dimension) + if err != nil { + return nil, err + } + operator, err := normalizeSearchConsoleFilterOperator(filter.Operator) + if err != nil { + return nil, err + } + if strings.TrimSpace(filter.Expression) == "" { + return nil, usage("request filter expression cannot be empty") + } + filter.Dimension = dimension + filter.Operator = operator + } + } + + return &req, nil +} + +func readSearchConsoleRequestBytes(spec string) ([]byte, error) { + spec = strings.TrimSpace(spec) + switch { + case spec == "", spec == "-", strings.HasPrefix(spec, "@"), strings.HasPrefix(spec, "{"), strings.HasPrefix(spec, "["): + return resolveInlineOrFileBytes(spec) + default: + path, err := config.ExpandPath(spec) + if err != nil { + return nil, err + } + return os.ReadFile(path) //nolint:gosec // user-provided path + } +} + func parseSearchConsoleDate(value string, flagName string) (string, error) { value = strings.TrimSpace(value) if value == "" { @@ -199,41 +632,251 @@ func parseSearchConsoleDate(value string, flagName string) (string, error) { return value, nil } +func validateSearchConsoleDateRange(from string, to string) error { + start, err := time.Parse("2006-01-02", strings.TrimSpace(from)) + if err != nil { + return usage("invalid start date (expected YYYY-MM-DD)") + } + end, err := time.Parse("2006-01-02", strings.TrimSpace(to)) + if err != nil { + return usage("invalid end date (expected YYYY-MM-DD)") + } + if end.Before(start) { + return usage("--to must be on or after --from") + } + return nil +} + func normalizeSearchConsoleType(raw string) (string, error) { - v := strings.ToUpper(strings.TrimSpace(raw)) - if v == "" { + v := strings.TrimSpace(raw) + switch { + case strings.EqualFold(v, "web"): + return "web", nil + case strings.EqualFold(v, "image"): + return "image", nil + case strings.EqualFold(v, "video"): + return "video", nil + case strings.EqualFold(v, "news"): + return "news", nil + case strings.EqualFold(v, "discover"): + return "discover", nil + case strings.EqualFold(strings.ReplaceAll(v, "_", ""), "googleNews"), + strings.EqualFold(strings.ReplaceAll(v, "-", ""), "googleNews"): + return "googleNews", nil + case v == "": return "", usage("empty --type") - } - switch v { - case "WEB", "IMAGE", "VIDEO", "NEWS", "DISCOVER", "GOOGLE_NEWS": - return v, nil default: return "", usagef("invalid --type %q (expected WEB|IMAGE|VIDEO|NEWS|DISCOVER|GOOGLE_NEWS)", raw) } } +func normalizeSearchConsoleAggregation(raw string) (string, error) { + v := strings.TrimSpace(raw) + switch { + case v == "": + return "", nil + case strings.EqualFold(v, "auto"): + return "auto", nil + case strings.EqualFold(strings.ReplaceAll(strings.ReplaceAll(v, "_", ""), "-", ""), "byProperty"): + return "byProperty", nil + case strings.EqualFold(strings.ReplaceAll(strings.ReplaceAll(v, "_", ""), "-", ""), "byPage"): + return "byPage", nil + case strings.EqualFold(strings.ReplaceAll(strings.ReplaceAll(v, "_", ""), "-", ""), "byNewsShowcasePanel"): + return "byNewsShowcasePanel", nil + default: + return "", usagef("invalid --aggregation %q (expected AUTO|BY_PROPERTY|BY_PAGE|BY_NEWS_SHOWCASE_PANEL)", raw) + } +} + +func normalizeSearchConsoleDataState(raw string) (string, error) { + v := strings.TrimSpace(raw) + switch { + case v == "": + return "", nil + case strings.EqualFold(v, "final"): + return "final", nil + case strings.EqualFold(v, "all"): + return "all", nil + case strings.EqualFold(strings.ReplaceAll(v, "-", "_"), "hourly_all"): + return "hourly_all", nil + default: + return "", usagef("invalid --data-state %q (expected FINAL|ALL|HOURLY_ALL)", raw) + } +} + func normalizeSearchConsoleDimensions(raw string) ([]string, error) { parts := splitCommaList(raw) if len(parts) == 0 { return nil, nil } + return normalizeSearchConsoleDimensionList(parts) +} +func normalizeSearchConsoleDimensionList(parts []string) ([]string, error) { out := make([]string, 0, len(parts)) for _, part := range parts { - v := strings.ToUpper(strings.TrimSpace(part)) - switch v { - case "DATE", "QUERY", "PAGE", "COUNTRY", "DEVICE", "SEARCH_APPEARANCE", "HOUR": - out = append(out, v) - default: - return nil, usagef("invalid dimension %q (expected DATE|QUERY|PAGE|COUNTRY|DEVICE|SEARCH_APPEARANCE|HOUR)", part) + v, err := normalizeSearchConsoleDimension(part) + if err != nil { + return nil, err } + out = append(out, v) } return out, nil } +func normalizeSearchConsoleDimension(raw string) (string, error) { + v := strings.TrimSpace(raw) + switch { + case strings.EqualFold(v, "date"): + return "date", nil + case strings.EqualFold(v, "query"): + return "query", nil + case strings.EqualFold(v, "page"): + return "page", nil + case strings.EqualFold(v, "country"): + return "country", nil + case strings.EqualFold(v, "device"): + return "device", nil + case strings.EqualFold(strings.ReplaceAll(strings.ReplaceAll(v, "_", ""), "-", ""), "searchAppearance"): + return "searchAppearance", nil + case strings.EqualFold(v, "hour"): + return "hour", nil + case v == "": + return "", usage("empty dimension") + default: + return "", usagef("invalid dimension %q (expected DATE|QUERY|PAGE|COUNTRY|DEVICE|SEARCH_APPEARANCE|HOUR)", raw) + } +} + +func parseSearchConsoleFilter(raw string) (*searchconsoleapi.ApiDimensionFilter, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, usage("empty --filter") + } + + first := strings.Index(raw, ":") + if first <= 0 { + return nil, usagef("invalid --filter %q (expected dimension:operator:expression)", raw) + } + rest := raw[first+1:] + second := strings.Index(rest, ":") + if second < 0 { + return nil, usagef("invalid --filter %q (expected dimension:operator:expression)", raw) + } + + dimension, err := normalizeSearchConsoleDimension(raw[:first]) + if err != nil { + return nil, err + } + operator, err := normalizeSearchConsoleFilterOperator(rest[:second]) + if err != nil { + return nil, err + } + expression := strings.TrimSpace(rest[second+1:]) + if expression == "" { + return nil, usagef("invalid --filter %q (expected dimension:operator:expression)", raw) + } + + return &searchconsoleapi.ApiDimensionFilter{ + Dimension: dimension, + Operator: operator, + Expression: expression, + }, nil +} + +func normalizeSearchConsoleFilterOperator(raw string) (string, error) { + v := strings.TrimSpace(raw) + switch { + case v == "": + return "", usage("empty filter operator") + case strings.EqualFold(v, "equals"): + return "equals", nil + case strings.EqualFold(strings.ReplaceAll(strings.ReplaceAll(v, "_", ""), "-", ""), "notEquals"): + return "notEquals", nil + case strings.EqualFold(v, "contains"): + return "contains", nil + case strings.EqualFold(strings.ReplaceAll(strings.ReplaceAll(v, "_", ""), "-", ""), "notContains"): + return "notContains", nil + case strings.EqualFold(strings.ReplaceAll(strings.ReplaceAll(v, "_", ""), "-", ""), "includingRegex"): + return "includingRegex", nil + case strings.EqualFold(strings.ReplaceAll(strings.ReplaceAll(v, "_", ""), "-", ""), "excludingRegex"): + return "excludingRegex", nil + default: + return "", usagef("invalid filter operator %q (expected EQUALS|NOT_EQUALS|CONTAINS|NOT_CONTAINS|INCLUDING_REGEX|EXCLUDING_REGEX)", raw) + } +} + +func requestSearchConsoleDimensions( + req *searchconsoleapi.SearchAnalyticsQueryRequest, + rows []*searchconsoleapi.ApiDataRow, +) []string { + if req != nil && len(req.Dimensions) > 0 { + out := make([]string, 0, len(req.Dimensions)) + for _, dim := range req.Dimensions { + out = append(out, strings.ToUpper(strings.TrimSpace(dim))) + } + return out + } + + keyCount := 0 + for _, row := range rows { + if row != nil && len(row.Keys) > keyCount { + keyCount = len(row.Keys) + } + } + + out := make([]string, 0, keyCount) + for i := 0; i < keyCount; i++ { + out = append(out, "KEY_"+strconv.Itoa(i+1)) + } + return out +} + func searchConsoleKey(row *searchconsoleapi.ApiDataRow, index int) string { if row == nil || index < 0 || index >= len(row.Keys) { return "" } return row.Keys[index] } + +func formatSearchConsoleMetric(v float64, decimals int) string { + if decimals <= 0 { + return strconv.FormatFloat(v, 'f', 0, 64) + } + return strconv.FormatFloat(v, 'f', decimals, 64) +} + +func formatSearchConsoleSitemapContents(contents []*searchconsoleapi.WmxSitemapContent) string { + if len(contents) == 0 { + return "" + } + + parts := make([]string, 0, len(contents)) + for _, content := range contents { + if content == nil { + continue + } + parts = append(parts, fmt.Sprintf("%s:%d/%d", content.Type, content.Indexed, content.Submitted)) + } + return strings.Join(parts, ",") +} + +func wrapSearchConsoleError(err error) error { + var apiErr *gapi.Error + if !errors.As(err, &apiErr) { + return err + } + if apiErr.Code != 403 { + return err + } + + message := strings.ToLower(apiErr.Message) + switch { + case strings.Contains(message, "accessnotconfigured"), strings.Contains(message, "api has not been used"): + return fmt.Errorf("Search Console API is not enabled for this OAuth project. Enable it at https://console.cloud.google.com/apis/api/searchconsole.googleapis.com") + case strings.Contains(message, "insufficientpermissions"), strings.Contains(message, "insufficient permission"): + return fmt.Errorf("insufficient permissions for Search Console API. Re-authorize with: gog auth add --services searchconsole") + default: + return err + } +} diff --git a/internal/cmd/searchconsole_more_test.go b/internal/cmd/searchconsole_more_test.go new file mode 100644 index 00000000..a9c0fcd4 --- /dev/null +++ b/internal/cmd/searchconsole_more_test.go @@ -0,0 +1,310 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/option" + searchconsoleapi "google.golang.org/api/searchconsole/v1" +) + +func TestSearchConsoleQueryCmd_BuildRequest(t *testing.T) { + cmd := &SearchConsoleQueryCmd{ + From: "2026-02-01", + To: "2026-02-28", + Dimensions: "query,page", + Type: "web", + Aggregation: "by_page", + DataState: "final", + Max: 250, + Offset: 10, + Filter: []string{"query:contains:buy shoes", "country:equals:usa"}, + } + + req, err := cmd.buildRequest() + if err != nil { + t.Fatalf("buildRequest: %v", err) + } + + if req.StartDate != "2026-02-01" || req.EndDate != "2026-02-28" { + t.Fatalf("unexpected date range: %s - %s", req.StartDate, req.EndDate) + } + if req.RowLimit != 250 || req.StartRow != 10 { + t.Fatalf("unexpected pagination: limit=%d startRow=%d", req.RowLimit, req.StartRow) + } + if len(req.Dimensions) != 2 || req.Dimensions[0] != "query" || req.Dimensions[1] != "page" { + t.Fatalf("unexpected dimensions: %#v", req.Dimensions) + } + if req.Type != "web" || req.AggregationType != "byPage" || req.DataState != "final" { + t.Fatalf("unexpected query options: %#v", req) + } + if len(req.DimensionFilterGroups) != 1 || req.DimensionFilterGroups[0].GroupType != "and" { + t.Fatalf("unexpected filter groups: %#v", req.DimensionFilterGroups) + } + if len(req.DimensionFilterGroups[0].Filters) != 2 { + t.Fatalf("unexpected filter count: %#v", req.DimensionFilterGroups[0].Filters) + } + if req.DimensionFilterGroups[0].Filters[0].Dimension != "query" || req.DimensionFilterGroups[0].Filters[0].Operator != "contains" { + t.Fatalf("unexpected first filter: %#v", req.DimensionFilterGroups[0].Filters[0]) + } +} + +func TestSearchConsoleQueryCmd_BuildRequestFromJSON(t *testing.T) { + withStdin(t, `{ + "startDate":"2026-02-01", + "endDate":"2026-02-10", + "rowLimit":50, + "searchType":"IMAGE", + "dimensions":["QUERY","search_appearance"], + "dimensionFilterGroups":[{"groupType":"AND","filters":[{"dimension":"PAGE","operator":"NOT_CONTAINS","expression":"draft"}]}] + }`, func() { + cmd := &SearchConsoleQueryCmd{Request: "-"} + req, err := cmd.buildRequest() + if err != nil { + t.Fatalf("buildRequest: %v", err) + } + if req.RowLimit != 50 { + t.Fatalf("unexpected rowLimit: %d", req.RowLimit) + } + if req.Type != "image" || req.SearchType != "image" { + t.Fatalf("unexpected type fields: type=%q searchType=%q", req.Type, req.SearchType) + } + if len(req.Dimensions) != 2 || req.Dimensions[0] != "query" || req.Dimensions[1] != "searchAppearance" { + t.Fatalf("unexpected dimensions: %#v", req.Dimensions) + } + if len(req.DimensionFilterGroups) != 1 || req.DimensionFilterGroups[0].GroupType != "and" { + t.Fatalf("unexpected filter groups: %#v", req.DimensionFilterGroups) + } + filter := req.DimensionFilterGroups[0].Filters[0] + if filter.Dimension != "page" || filter.Operator != "notContains" || filter.Expression != "draft" { + t.Fatalf("unexpected filter: %#v", filter) + } + }) +} + +func TestExecute_SearchConsoleSitesGet_JSON(t *testing.T) { + origNew := newSearchConsoleService + t.Cleanup(func() { newSearchConsoleService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/webmasters/v3/sites/")) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "siteUrl": "sc-domain:example.com", + "permissionLevel": "SITE_OWNER", + }) + })) + defer srv.Close() + + svc, err := searchconsoleapi.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newSearchConsoleService = func(context.Context, string) (*searchconsoleapi.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{ + "--json", + "--account", "a@b.com", + "searchconsole", "sites", "get", "sc-domain:example.com", + }); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + Site struct { + SiteURL string `json:"siteUrl"` + PermissionLevel string `json:"permissionLevel"` + } `json:"site"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if parsed.Site.SiteURL != "sc-domain:example.com" || parsed.Site.PermissionLevel != "SITE_OWNER" { + t.Fatalf("unexpected payload: %#v", parsed) + } +} + +func TestExecute_SearchConsoleSearchAnalyticsQuery_JSON(t *testing.T) { + origNew := newSearchConsoleService + t.Cleanup(func() { newSearchConsoleService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/searchAnalytics/query")) { + http.NotFound(w, r) + return + } + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req["aggregationType"] != "byPage" || req["dataState"] != "final" || req["type"] != "web" { + t.Fatalf("unexpected request payload: %#v", req) + } + filterGroups, ok := req["dimensionFilterGroups"].([]any) + if !ok || len(filterGroups) != 1 { + t.Fatalf("unexpected filter groups: %#v", req["dimensionFilterGroups"]) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "responseAggregationType": "BY_PAGE", + "rows": []map[string]any{ + { + "keys": []string{"gog cli", "https://example.com/docs"}, + "clicks": 12, + "impressions": 300, + "ctr": 0.04, + "position": 7.3, + }, + }, + }) + })) + defer srv.Close() + + svc, err := searchconsoleapi.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newSearchConsoleService = func(context.Context, string) (*searchconsoleapi.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{ + "--json", + "--account", "a@b.com", + "searchconsole", "searchanalytics", "query", "sc-domain:example.com", + "--from", "2026-02-01", + "--to", "2026-02-07", + "--dimensions", "query,page", + "--type", "web", + "--aggregation", "by_page", + "--data-state", "final", + "--filter", "query:contains:gog", + "--max", "10", + }); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + Type string `json:"type"` + ResponseAggregationType string `json:"response_aggregation_type"` + Rows []struct { + Keys []string `json:"keys"` + } `json:"rows"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if parsed.Type != "web" || parsed.ResponseAggregationType != "BY_PAGE" || len(parsed.Rows) != 1 { + t.Fatalf("unexpected payload: %#v", parsed) + } +} + +func TestExecute_SearchConsoleSitemapsList_JSON(t *testing.T) { + origNew := newSearchConsoleService + t.Cleanup(func() { newSearchConsoleService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/sitemaps")) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "sitemap": []map[string]any{ + { + "path": "https://example.com/sitemap.xml", + "type": "SITEMAP", + "isPending": false, + "warnings": "1", + "errors": "0", + "lastSubmitted": "2026-02-01", + "lastDownloaded": "2026-02-02", + }, + }, + }) + })) + defer srv.Close() + + svc, err := searchconsoleapi.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newSearchConsoleService = func(context.Context, string) (*searchconsoleapi.Service, error) { return svc, nil } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{ + "--json", + "--account", "a@b.com", + "searchconsole", "sitemaps", "sc-domain:example.com", + }); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + Sitemaps []struct { + Path string `json:"path"` + Type string `json:"type"` + } `json:"sitemaps"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(parsed.Sitemaps) != 1 || parsed.Sitemaps[0].Path != "https://example.com/sitemap.xml" || parsed.Sitemaps[0].Type != "SITEMAP" { + t.Fatalf("unexpected payload: %#v", parsed) + } +} + +func TestExecute_SearchConsoleSitemapsSubmit_DryRun_JSON(t *testing.T) { + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{ + "--json", + "--dry-run", + "searchconsole", "sitemaps", "submit", + "sc-domain:example.com", + "https://example.com/sitemap.xml", + }); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + DryRun bool `json:"dry_run"` + Op string `json:"op"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !parsed.DryRun || parsed.Op != "searchconsole.sitemaps.submit" { + t.Fatalf("unexpected payload: %#v", parsed) + } +} diff --git a/internal/googleauth/service.go b/internal/googleauth/service.go index 39242ebc..698b0c65 100644 --- a/internal/googleauth/service.go +++ b/internal/googleauth/service.go @@ -214,9 +214,10 @@ var serviceInfoByService = map[Service]serviceInfo{ note: "GA4 account summaries + reporting", }, ServiceSearchConsole: { - scopes: []string{"https://www.googleapis.com/auth/webmasters.readonly"}, + scopes: []string{"https://www.googleapis.com/auth/webmasters"}, user: true, apis: []string{"Search Console API"}, + note: "Search Analytics + sitemap management", }, ServiceGroups: { scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"}, @@ -570,6 +571,9 @@ func scopesForServiceWithOptions(service Service, opts ScopeOptions) ([]string, case ServiceAnalytics: return Scopes(service) case ServiceSearchConsole: + if opts.Readonly { + return []string{"https://www.googleapis.com/auth/webmasters.readonly"}, nil + } return Scopes(service) case ServiceGroups: return Scopes(service) diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go index bbbd9a9b..eb7e5525 100644 --- a/internal/googleauth/service_test.go +++ b/internal/googleauth/service_test.go @@ -310,6 +310,7 @@ func TestScopesForManageWithOptions_Readonly(t *testing.T) { "https://www.googleapis.com/auth/tasks", "https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/webmasters", } for _, nw := range notWant { if containsScope(scopes, nw) { @@ -356,6 +357,28 @@ func TestScopes_ServiceKeep_DefaultIsReadonly(t *testing.T) { } } +func TestScopes_ServiceSearchConsole_DefaultIsWritable(t *testing.T) { + scopes, err := Scopes(ServiceSearchConsole) + if err != nil { + t.Fatalf("Scopes: %v", err) + } + + if len(scopes) != 1 || scopes[0] != "https://www.googleapis.com/auth/webmasters" { + t.Fatalf("unexpected searchconsole scopes: %#v", scopes) + } +} + +func TestScopesForServiceWithOptions_SearchConsole_Readonly(t *testing.T) { + scopes, err := scopesForServiceWithOptions(ServiceSearchConsole, ScopeOptions{Readonly: true}) + if err != nil { + t.Fatalf("scopesForServiceWithOptions: %v", err) + } + + if len(scopes) != 1 || scopes[0] != "https://www.googleapis.com/auth/webmasters.readonly" { + t.Fatalf("unexpected searchconsole readonly scopes: %#v", scopes) + } +} + func TestScopesForServiceWithOptions_ServiceKeep_Readonly(t *testing.T) { scopes, err := scopesForServiceWithOptions(ServiceKeep, ScopeOptions{Readonly: true}) if err != nil {