diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bfb9e66..83d95fed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.12.0 - Unreleased ### Added +- Analytics/Search Console: add `analytics accounts|report` and `searchconsole sites|query` command groups for GA4 + GSC reporting. (#402) — thanks @haresh-seenivasagan. - Sheets: add `sheets insert` to insert rows/columns into a sheet. (#203) — thanks @andybergon. - Gmail: add `watch serve --history-types` filtering (`messageAdded|messageDeleted|labelAdded|labelRemoved`) and include `deletedMessageIds` in webhook payloads. (#168) — thanks @salmonumbrella. - Contacts: support `--org`, `--title`, `--url`, `--note`, and `--custom` on create/update; include custom fields in get output with deterministic ordering. (#199) — thanks @phuctm97. diff --git a/README.md b/README.md index 80abeadf..9a7f5f4a 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, Groups (Workspace), and Keep (Workspace-only). JSON-first output, multiple accounts, and least-privilege 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, Groups (Workspace), and Keep (Workspace-only). JSON-first output, multiple accounts, and least-privilege 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, format cells, read notes, create new sheets (and export via Drive) - **Forms** - create/get forms and inspect responses - **Apps Script** - create/get 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** - export to PDF/DOCX/PPTX via Drive (plus create/copy, docs-to-text, and **sedmat** sed-style document editing with Markdown formatting, images, and tables) - **People** - access profile information - **Keep (Workspace only)** - list/get/search notes and download attachments (service account + domain-wide delegation) @@ -86,6 +88,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 - Apps Script API: https://console.cloud.google.com/apis/api/script.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 - Cloud Identity API (Groups): https://console.cloud.google.com/apis/api/cloudidentity.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 @@ -354,6 +359,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.readonly` | Workspace only; service account (domain-wide delegation) | @@ -1010,6 +1017,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 d301db26..d95cf1f1 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}"` EnableCommands string `help:"Comma-separated list of enabled top-level commands (restricts CLI)" default:"${enabled_commands}"` JSON bool `help:"Output JSON to stdout (best for scripting)" default:"${json}" aliases:"machine" short:"j"` @@ -60,30 +60,32 @@ 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"` - 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"` + 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 } @@ -324,7 +326,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" } 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 f464af91..233e69ca 100644 --- a/internal/googleauth/service.go +++ b/internal/googleauth/service.go @@ -10,21 +10,23 @@ 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" + 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" ) const ( @@ -72,6 +74,8 @@ var serviceOrder = []Service{ ServicePeople, ServiceForms, ServiceAppScript, + ServiceAnalytics, + ServiceSearchConsole, ServiceGroups, ServiceKeep, } @@ -191,6 +195,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, @@ -517,6 +532,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 445de2d1..3acdd826 100644 --- a/internal/googleauth/service_test.go +++ b/internal/googleauth/service_test.go @@ -21,6 +21,8 @@ func TestParseService(t *testing.T) { {"sheets", ServiceSheets}, {"forms", ServiceForms}, {"appscript", ServiceAppScript}, + {"analytics", ServiceAnalytics}, + {"searchconsole", ServiceSearchConsole}, {"groups", ServiceGroups}, {"keep", ServiceKeep}, } @@ -65,7 +67,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) { func TestAllServices(t *testing.T) { svcs := AllServices() - if len(svcs) != 15 { + if len(svcs) != 17 { t.Fatalf("unexpected: %v", svcs) } seen := make(map[Service]bool) @@ -74,7 +76,25 @@ 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} { + for _, want := range []Service{ + ServiceGmail, + ServiceCalendar, + ServiceChat, + ServiceClassroom, + ServiceDrive, + ServiceDocs, + ServiceSlides, + ServiceContacts, + ServiceTasks, + ServicePeople, + ServiceSheets, + ServiceForms, + ServiceAppScript, + ServiceAnalytics, + ServiceSearchConsole, + ServiceGroups, + ServiceKeep, + } { if !seen[want] { t.Fatalf("missing %q", want) } @@ -83,7 +103,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 +118,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 +135,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 +251,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 +287,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 {