From 7c2cbfb3583f97635d17da5340b8f952b2e3615e Mon Sep 17 00:00:00 2001 From: Alberto Date: Tue, 3 Mar 2026 10:35:03 -0500 Subject: [PATCH] Add Google Search Console CLI commands and auth scopes --- README.md | 12 +- internal/cmd/root.go | 53 +-- internal/cmd/searchconsole.go | 658 ++++++++++++++++++++++++++++ internal/cmd/searchconsole_test.go | 189 ++++++++ internal/googleapi/searchconsole.go | 20 + internal/googleauth/service.go | 42 +- internal/googleauth/service_test.go | 30 +- 7 files changed, 957 insertions(+), 47 deletions(-) create mode 100644 internal/cmd/searchconsole.go create mode 100644 internal/cmd/searchconsole_test.go create mode 100644 internal/googleapi/searchconsole.go diff --git a/README.md b/README.md index 8d071122..f2bc0dc5 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, Contacts, Tasks, People, Search Console, Groups (Workspace), and Keep (Workspace-only). JSON-first output, multiple accounts, and least-privilege auth built in. ## Features @@ -20,6 +20,7 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli - **Apps Script** - create/get projects, inspect content, and run functions - **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 +- **Search Console** - list verified properties, run Search Analytics queries, and manage sitemaps - **Keep (Workspace only)** - list/get/search notes and download attachments (service account + domain-wide delegation) - **Groups** - list groups you belong to, view group members (Google Workspace) - **Local time** - quick local/UTC time display for scripts and agents @@ -86,6 +87,7 @@ 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 + - 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 @@ -148,6 +150,14 @@ export GOG_ACCOUNT=you@gmail.com gog gmail labels list ``` +### 5. Search Console quick test + +```bash +gog auth add you@gmail.com --services searchconsole --readonly +gog searchconsole sites list +gog searchconsole searchanalytics query sc-domain:example.com --start 2026-02-01 --end 2026-02-29 --dimensions query,page --limit 100 --json +``` + ## Authentication & Secrets ### Accounts and tokens diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 80547ede..fc074937 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/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,31 @@ 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"` + SearchConsole SearchConsoleCmd `cmd:"" name:"searchconsole" aliases:"gsc,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"` + 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 } @@ -327,7 +328,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/Search Console" } func helpDescription() string { diff --git a/internal/cmd/searchconsole.go b/internal/cmd/searchconsole.go new file mode 100644 index 00000000..7990dd50 --- /dev/null +++ b/internal/cmd/searchconsole.go @@ -0,0 +1,658 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + "time" + + gapi "google.golang.org/api/googleapi" + "google.golang.org/api/webmasters/v3" + + "github.com/steipete/gogcli/internal/config" + "github.com/steipete/gogcli/internal/googleapi" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +var newSearchConsoleService = googleapi.NewSearchConsole + +const ( + defaultSearchAnalyticsLimit = int64(1000) + maxSearchAnalyticsLimit = int64(5000) +) + +type SearchConsoleCmd struct { + Sites SearchConsoleSitesCmd `cmd:"" name:"sites" help:"List and inspect Search Console properties"` + SearchAnalytics SearchConsoleSearchAnalyticsCmd `cmd:"" name:"searchanalytics" aliases:"analytics" help:"Search Analytics queries"` + Query SearchConsoleSearchAnalyticsQueryCmd `cmd:"" name:"query" help:"Run a Search Analytics query (alias for 'searchconsole searchanalytics query')"` + Sitemaps SearchConsoleSitemapsCmd `cmd:"" name:"sitemaps" help:"List/get/submit/delete sitemaps"` +} + +type SearchConsoleSitesCmd struct { + List SearchConsoleSitesListCmd `cmd:"" name:"list" aliases:"ls" help:"List accessible Search Console properties"` + Get SearchConsoleSitesGetCmd `cmd:"" name:"get" help:"Get a specific Search Console property"` +} + +type SearchConsoleSitesListCmd struct{} + +func (c *SearchConsoleSitesListCmd) 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 wrapSearchConsoleError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"sites": resp.SiteEntry}) + } + + if len(resp.SiteEntry) == 0 { + u.Err().Println("No Search Console properties") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + + _, _ = fmt.Fprintln(w, "SITE_URL\tPERMISSION") + for _, site := range resp.SiteEntry { + _, _ = fmt.Fprintf(w, "%s\t%s\n", site.SiteUrl, site.PermissionLevel) + } + return nil +} + +type SearchConsoleSitesGetCmd struct { + SiteURL string `arg:"" name:"site_url" help:"Property URI (for example https://www.example.com/ or sc-domain:example.com)"` +} + +func (c *SearchConsoleSitesGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + siteURL := strings.TrimSpace(c.SiteURL) + if siteURL == "" { + return usage("empty site_url") + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newSearchConsoleService(ctx, account) + if err != nil { + return err + } + + site, err := svc.Sites.Get(siteURL).Context(ctx).Do() + if err != nil { + return wrapSearchConsoleError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"site": site}) + } + + return writeResult(ctx, u, + kv("site_url", site.SiteUrl), + kv("permission_level", site.PermissionLevel), + ) +} + +type SearchConsoleSearchAnalyticsCmd struct { + Query SearchConsoleSearchAnalyticsQueryCmd `cmd:"" name:"query" aliases:"run" help:"Run Search Analytics query"` +} + +type SearchConsoleSearchAnalyticsQueryCmd struct { + SiteURL string `arg:"" name:"site_url" help:"Property URI (for example https://www.example.com/ or sc-domain:example.com)"` + + StartDate string `name:"start" help:"Start date (YYYY-MM-DD)"` + EndDate string `name:"end" help:"End date (YYYY-MM-DD)"` + Dimensions string `name:"dimensions" help:"Comma-separated dimensions (query,page,country,device,searchAppearance,date,hour)"` + SearchType string `name:"type" help:"Search type (web,image,video,news,discover,googleNews)" default:"web"` + AggregationType string `name:"aggregation" help:"Aggregation type (auto|byPage|byProperty|byNewsShowcasePanel)"` + DataState string `name:"data-state" help:"Data state (final|all)"` + Limit int64 `name:"limit" help:"Row limit (1-5000)" default:"1000"` + StartRow int64 `name:"start-row" help:"Zero-based row offset" default:"0"` + Filter []string `name:"filter" help:"Dimension filter, repeatable: dimension:operator:expression"` + FilterGroupType string `name:"filter-group-type" help:"Filter group type for --filter values" enum:"and,or" default:"and"` + RequestPath string `name:"request" help:"Path to SearchAnalyticsQueryRequest JSON ('-' for stdin). If set, request body is loaded from this file."` +} + +func (c *SearchConsoleSearchAnalyticsQueryCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + siteURL := strings.TrimSpace(c.SiteURL) + if siteURL == "" { + return usage("empty site_url") + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + req, err := c.buildRequest() + if err != nil { + return err + } + + svc, err := newSearchConsoleService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Searchanalytics.Query(siteURL, req).Context(ctx).Do() + if err != nil { + return wrapSearchConsoleError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "rows": resp.Rows, + "responseAggregationType": resp.ResponseAggregationType, + }) + } + + if len(resp.Rows) == 0 { + u.Err().Println("No rows") + return nil + } + + dimensions := requestDimensions(req, resp.Rows) + + w, flush := tableWriter(ctx) + defer flush() + + header := append([]string{}, dimensions...) + header = append(header, "CLICKS", "IMPRESSIONS", "CTR", "POSITION") + _, _ = fmt.Fprintln(w, strings.Join(header, "\t")) + + for _, row := range resp.Rows { + values := make([]string, 0, len(dimensions)+4) + for i := 0; i < len(dimensions); i++ { + if i < len(row.Keys) { + values = append(values, row.Keys[i]) + } else { + values = append(values, "") + } + } + + values = append( + values, + formatMetric(row.Clicks, 0), + formatMetric(row.Impressions, 0), + formatMetric(row.Ctr, 4), + formatMetric(row.Position, 2), + ) + _, _ = fmt.Fprintln(w, strings.Join(values, "\t")) + } + + return nil +} + +func (c *SearchConsoleSearchAnalyticsQueryCmd) buildRequest() (*webmasters.SearchAnalyticsQueryRequest, error) { + requestPath := strings.TrimSpace(c.RequestPath) + if requestPath != "" { + req, err := readSearchAnalyticsRequest(requestPath) + if err != nil { + return nil, err + } + if err := validateDateRange(req.StartDate, req.EndDate); err != nil { + return nil, err + } + return req, nil + } + + start := strings.TrimSpace(c.StartDate) + end := strings.TrimSpace(c.EndDate) + if start == "" || end == "" { + return nil, usage("--start and --end are required unless --request is set") + } + if err := validateDateRange(start, end); err != nil { + return nil, err + } + + limit := c.Limit + if limit == 0 { + limit = defaultSearchAnalyticsLimit + } + if limit < 1 || limit > maxSearchAnalyticsLimit { + return nil, usagef("invalid --limit %d (expected 1..5000)", limit) + } + if c.StartRow < 0 { + return nil, usage("invalid --start-row (must be >= 0)") + } + + req := &webmasters.SearchAnalyticsQueryRequest{ + StartDate: start, + EndDate: end, + RowLimit: limit, + StartRow: c.StartRow, + } + + if dims := splitCommaList(c.Dimensions); len(dims) > 0 { + req.Dimensions = dims + } + + if v := strings.TrimSpace(c.SearchType); v != "" { + req.SearchType = v + } + if v := strings.TrimSpace(c.AggregationType); v != "" { + req.AggregationType = v + } + if v := strings.TrimSpace(c.DataState); v != "" { + req.DataState = v + } + + if len(c.Filter) > 0 { + filters := make([]*webmasters.ApiDimensionFilter, 0, len(c.Filter)) + for _, raw := range c.Filter { + filter, err := parseDimensionFilter(raw) + if err != nil { + return nil, err + } + filters = append(filters, filter) + } + + req.DimensionFilterGroups = []*webmasters.ApiDimensionFilterGroup{ + { + GroupType: strings.ToLower(strings.TrimSpace(c.FilterGroupType)), + Filters: filters, + }, + } + } + + return req, nil +} + +type SearchConsoleSitemapsCmd struct { + List SearchConsoleSitemapsListCmd `cmd:"" name:"list" aliases:"ls" help:"List sitemaps for a property"` + Get SearchConsoleSitemapsGetCmd `cmd:"" name:"get" help:"Get a sitemap"` + Submit SearchConsoleSitemapsSubmitCmd `cmd:"" name:"submit" help:"Submit a sitemap"` + Delete SearchConsoleSitemapsDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete a sitemap"` +} + +type SearchConsoleSitemapsListCmd struct { + SiteURL string `arg:"" name:"site_url" help:"Property URI (for example https://www.example.com/ or sc-domain:example.com)"` + SitemapIndex string `name:"sitemap-index" help:"Filter to a sitemap index URL"` +} + +func (c *SearchConsoleSitemapsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + siteURL := strings.TrimSpace(c.SiteURL) + if siteURL == "" { + return usage("empty site_url") + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newSearchConsoleService(ctx, account) + if err != nil { + return err + } + + call := svc.Sitemaps.List(siteURL) + if v := strings.TrimSpace(c.SitemapIndex); v != "" { + call = call.SitemapIndex(v) + } + + resp, err := call.Context(ctx).Do() + if err != nil { + return wrapSearchConsoleError(err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"sitemaps": resp.Sitemap}) + } + + if len(resp.Sitemap) == 0 { + u.Err().Println("No sitemaps") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + + _, _ = fmt.Fprintln(w, "PATH\tTYPE\tPENDING\tWARNINGS\tERRORS\tLAST_SUBMITTED\tLAST_DOWNLOADED\tCONTENTS") + for _, sitemap := range resp.Sitemap { + _, _ = fmt.Fprintf( + w, + "%s\t%s\t%t\t%d\t%d\t%s\t%s\t%s\n", + sitemap.Path, + sitemap.Type, + sitemap.IsPending, + sitemap.Warnings, + sitemap.Errors, + sitemap.LastSubmitted, + sitemap.LastDownloaded, + formatSitemapContents(sitemap.Contents), + ) + } + + return nil +} + +type SearchConsoleSitemapsGetCmd struct { + SiteURL string `arg:"" name:"site_url" help:"Property URI (for example https://www.example.com/ or sc-domain:example.com)"` + FeedPath string `arg:"" name:"feed_path" 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 site_url") + } + feedPath := strings.TrimSpace(c.FeedPath) + if feedPath == "" { + return usage("empty feed_path") + } + + 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", formatSitemapContents(sitemap.Contents)), + ) +} + +type SearchConsoleSitemapsSubmitCmd struct { + SiteURL string `arg:"" name:"site_url" help:"Property URI (for example https://www.example.com/ or sc-domain:example.com)"` + FeedPath string `arg:"" name:"feed_path" 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 site_url") + } + feedPath := strings.TrimSpace(c.FeedPath) + if feedPath == "" { + return usage("empty feed_path") + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + if err := dryRunExit(ctx, flags, "searchconsole.sitemaps.submit", map[string]any{ + "site_url": siteURL, + "feed_path": feedPath, + }); 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:"site_url" help:"Property URI (for example https://www.example.com/ or sc-domain:example.com)"` + FeedPath string `arg:"" name:"feed_path" 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 site_url") + } + feedPath := strings.TrimSpace(c.FeedPath) + if feedPath == "" { + return usage("empty feed_path") + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + if err := confirmDestructive(ctx, flags, fmt.Sprintf("delete sitemap %s", feedPath)); 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 validateDateRange(startDate, endDate string) error { + startDate = strings.TrimSpace(startDate) + endDate = strings.TrimSpace(endDate) + if startDate == "" || endDate == "" { + return usage("startDate and endDate are required") + } + + start, err := parseDateOnly(startDate) + if err != nil { + return usagef("invalid start date %q (expected YYYY-MM-DD)", startDate) + } + end, err := parseDateOnly(endDate) + if err != nil { + return usagef("invalid end date %q (expected YYYY-MM-DD)", endDate) + } + if end.Before(start) { + return usage("end date must be on or after start date") + } + return nil +} + +func parseDateOnly(raw string) (time.Time, error) { + return time.Parse("2006-01-02", strings.TrimSpace(raw)) +} + +func parseDimensionFilter(raw string) (*webmasters.ApiDimensionFilter, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, usage("empty --filter value") + } + + first := strings.Index(raw, ":") + last := strings.Index(raw[first+1:], ":") + if first <= 0 || last < 0 { + return nil, usagef("invalid --filter %q (expected dimension:operator:expression)", raw) + } + + second := first + 1 + last + dimension := strings.TrimSpace(raw[:first]) + operator := strings.TrimSpace(raw[first+1 : second]) + expression := strings.TrimSpace(raw[second+1:]) + if dimension == "" || operator == "" || expression == "" { + return nil, usagef("invalid --filter %q (expected dimension:operator:expression)", raw) + } + + opCanonical, err := normalizeFilterOperator(operator) + if err != nil { + return nil, err + } + + return &webmasters.ApiDimensionFilter{ + Dimension: dimension, + Operator: opCanonical, + Expression: expression, + }, nil +} + +func normalizeFilterOperator(op string) (string, error) { + switch strings.ToLower(strings.TrimSpace(op)) { + case "equals": + return "equals", nil + case "notequals": + return "notEquals", nil + case "contains": + return "contains", nil + case "notcontains": + return "notContains", nil + case "includingregex": + return "includingRegex", nil + case "excludingregex": + return "excludingRegex", nil + default: + return "", usagef("invalid filter operator %q (expected equals|notEquals|contains|notContains|includingRegex|excludingRegex)", op) + } +} + +func readSearchAnalyticsRequest(path string) (*webmasters.SearchAnalyticsQueryRequest, error) { + var ( + b []byte + err error + ) + + if path == "-" { + b, err = io.ReadAll(os.Stdin) + } else { + expanded, expandErr := config.ExpandPath(path) + if expandErr != nil { + return nil, expandErr + } + b, err = os.ReadFile(expanded) //nolint:gosec // user-provided path + } + if err != nil { + return nil, err + } + + var req webmasters.SearchAnalyticsQueryRequest + if err := json.Unmarshal(b, &req); err != nil { + return nil, fmt.Errorf("decode search analytics request: %w", err) + } + + if req.RowLimit == 0 { + req.RowLimit = defaultSearchAnalyticsLimit + } + if req.RowLimit < 1 || req.RowLimit > maxSearchAnalyticsLimit { + return nil, usagef("invalid request.rowLimit %d (expected 1..5000)", req.RowLimit) + } + if req.StartRow < 0 { + return nil, usage("invalid request.startRow (must be >= 0)") + } + + return &req, nil +} + +func requestDimensions(req *webmasters.SearchAnalyticsQueryRequest, rows []*webmasters.ApiDataRow) []string { + if 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 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 formatMetric(v float64, decimals int) string { + if decimals <= 0 { + return strconv.FormatFloat(v, 'f', 0, 64) + } + return strconv.FormatFloat(v, 'f', decimals, 64) +} + +func formatSitemapContents(contents []*webmasters.WmxSitemapContent) string { + if len(contents) == 0 { + return "" + } + + parts := make([]string, 0, len(contents)) + for _, content := range contents { + 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_test.go b/internal/cmd/searchconsole_test.go new file mode 100644 index 00000000..1bdd5f3d --- /dev/null +++ b/internal/cmd/searchconsole_test.go @@ -0,0 +1,189 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/option" + "google.golang.org/api/webmasters/v3" + + "github.com/steipete/gogcli/internal/outfmt" +) + +func TestParseDimensionFilter(t *testing.T) { + filter, err := parseDimensionFilter("query:contains:shoes") + if err != nil { + t.Fatalf("parseDimensionFilter: %v", err) + } + + if filter.Dimension != "query" { + t.Fatalf("unexpected dimension: %q", filter.Dimension) + } + if filter.Operator != "contains" { + t.Fatalf("unexpected operator: %q", filter.Operator) + } + if filter.Expression != "shoes" { + t.Fatalf("unexpected expression: %q", filter.Expression) + } + + if _, err := parseDimensionFilter("query:contains"); err == nil { + t.Fatalf("expected error for invalid filter") + } +} + +func TestSearchConsoleSearchAnalyticsQueryCmd_BuildRequest(t *testing.T) { + cmd := &SearchConsoleSearchAnalyticsQueryCmd{ + StartDate: "2026-02-01", + EndDate: "2026-02-28", + Dimensions: "query,page", + SearchType: "web", + AggregationType: "byPage", + DataState: "final", + Limit: 250, + StartRow: 10, + Filter: []string{"query:contains:buy shoes", "country:equals:usa"}, + FilterGroupType: "and", + } + + 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 len(req.DimensionFilterGroups) != 1 || len(req.DimensionFilterGroups[0].Filters) != 2 { + t.Fatalf("unexpected filter groups: %#v", req.DimensionFilterGroups) + } +} + +func TestSearchConsoleSearchAnalyticsQueryCmd_BuildRequestFromJSON(t *testing.T) { + withStdin(t, `{"startDate":"2026-02-01","endDate":"2026-02-10","rowLimit":50}`, func() { + cmd := &SearchConsoleSearchAnalyticsQueryCmd{RequestPath: "-"} + req, err := cmd.buildRequest() + if err != nil { + t.Fatalf("buildRequest: %v", err) + } + if req.RowLimit != 50 { + t.Fatalf("unexpected rowLimit: %d", req.RowLimit) + } + }) +} + +func TestSearchConsoleSitesListCmd_JSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/sites") { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "siteEntry": []map[string]any{ + { + "siteUrl": "https://example.com/", + "permissionLevel": "siteOwner", + }, + }, + }) + })) + defer srv.Close() + + svc, err := webmasters.NewService(context.Background(), option.WithHTTPClient(srv.Client()), option.WithEndpoint(srv.URL+"/")) + if err != nil { + t.Fatalf("webmasters.NewService: %v", err) + } + + orig := newSearchConsoleService + newSearchConsoleService = func(context.Context, string) (*webmasters.Service, error) { return svc, nil } + t.Cleanup(func() { newSearchConsoleService = orig }) + + ctx := outfmt.WithMode(context.Background(), outfmt.Mode{JSON: true}) + out := captureStdout(t, func() { + cmd := &SearchConsoleSitesListCmd{} + if runErr := cmd.Run(ctx, &RootFlags{Account: "user@example.com"}); runErr != nil { + t.Fatalf("Run: %v", runErr) + } + }) + + var parsed map[string]any + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + sites, ok := parsed["sites"].([]any) + if !ok || len(sites) != 1 { + t.Fatalf("unexpected sites output: %#v", parsed["sites"]) + } +} + +func TestSearchConsoleSearchAnalyticsQueryCmd_JSON(t *testing.T) { + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/searchAnalytics/query") { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "responseAggregationType": "byPage", + "rows": []map[string]any{ + { + "keys": []string{"buy shoes", "https://example.com/shoes"}, + "clicks": 42, + "impressions": 1010, + "ctr": 0.0416, + "position": 8.2, + }, + }, + }) + })) + defer srv.Close() + + svc, err := webmasters.NewService(context.Background(), option.WithHTTPClient(srv.Client()), option.WithEndpoint(srv.URL+"/")) + if err != nil { + t.Fatalf("webmasters.NewService: %v", err) + } + + orig := newSearchConsoleService + newSearchConsoleService = func(context.Context, string) (*webmasters.Service, error) { return svc, nil } + t.Cleanup(func() { newSearchConsoleService = orig }) + + ctx := outfmt.WithMode(context.Background(), outfmt.Mode{JSON: true}) + out := captureStdout(t, func() { + cmd := &SearchConsoleSearchAnalyticsQueryCmd{ + SiteURL: "sc-domain:example.com", + StartDate: "2026-02-01", + EndDate: "2026-02-07", + Dimensions: "query,page", + Limit: 5, + } + if runErr := cmd.Run(ctx, &RootFlags{Account: "user@example.com"}); runErr != nil { + t.Fatalf("Run: %v", runErr) + } + }) + + if gotBody["startDate"] != "2026-02-01" || gotBody["endDate"] != "2026-02-07" { + t.Fatalf("unexpected request body: %#v", gotBody) + } + + var parsed map[string]any + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + rows, ok := parsed["rows"].([]any) + if !ok || len(rows) != 1 { + t.Fatalf("unexpected rows output: %#v", parsed["rows"]) + } +} diff --git a/internal/googleapi/searchconsole.go b/internal/googleapi/searchconsole.go new file mode 100644 index 00000000..312dd826 --- /dev/null +++ b/internal/googleapi/searchconsole.go @@ -0,0 +1,20 @@ +package googleapi + +import ( + "context" + "fmt" + + "google.golang.org/api/webmasters/v3" + + "github.com/steipete/gogcli/internal/googleauth" +) + +func NewSearchConsole(ctx context.Context, email string) (*webmasters.Service, error) { + if opts, err := optionsForAccount(ctx, googleauth.ServiceSearchConsole, email); err != nil { + return nil, fmt.Errorf("search console options: %w", err) + } else if svc, err := webmasters.NewService(ctx, opts...); err != nil { + return nil, fmt.Errorf("create search console service: %w", err) + } else { + return svc, nil + } +} diff --git a/internal/googleauth/service.go b/internal/googleauth/service.go index b3c3afe8..985623b4 100644 --- a/internal/googleauth/service.go +++ b/internal/googleauth/service.go @@ -10,21 +10,22 @@ 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" + ServiceSearchConsole Service = "searchconsole" + ServiceGroups Service = "groups" + ServiceKeep Service = "keep" ) const ( @@ -81,6 +82,7 @@ var serviceOrder = []Service{ ServicePeople, ServiceForms, ServiceAppScript, + ServiceSearchConsole, ServiceGroups, ServiceKeep, } @@ -200,6 +202,11 @@ var serviceInfoByService = map[Service]serviceInfo{ user: true, apis: []string{"Apps Script API"}, }, + ServiceSearchConsole: { + scopes: []string{"https://www.googleapis.com/auth/webmasters"}, + user: true, + apis: []string{"Search Console API"}, + }, ServiceGroups: { scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"}, user: false, @@ -533,6 +540,11 @@ func scopesForServiceWithOptions(service Service, opts ScopeOptions) ([]string, }, nil } + 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 94e25823..ef3b54be 100644 --- a/internal/googleauth/service_test.go +++ b/internal/googleauth/service_test.go @@ -21,6 +21,7 @@ func TestParseService(t *testing.T) { {"sheets", ServiceSheets}, {"forms", ServiceForms}, {"appscript", ServiceAppScript}, + {"searchconsole", ServiceSearchConsole}, {"groups", ServiceGroups}, {"keep", ServiceKeep}, } @@ -65,7 +66,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) { func TestAllServices(t *testing.T) { svcs := AllServices() - if len(svcs) != 15 { + if len(svcs) != 16 { t.Fatalf("unexpected: %v", svcs) } seen := make(map[Service]bool) @@ -74,7 +75,7 @@ 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, ServiceSearchConsole, ServiceGroups, ServiceKeep} { if !seen[want] { t.Fatalf("missing %q", want) } @@ -83,7 +84,7 @@ func TestAllServices(t *testing.T) { func TestUserServices(t *testing.T) { svcs := UserServices() - if len(svcs) != 13 { + if len(svcs) != 14 { t.Fatalf("unexpected: %v", svcs) } @@ -98,6 +99,8 @@ func TestUserServices(t *testing.T) { seenSlides = true case ServiceForms, ServiceAppScript: // expected user services + case ServiceSearchConsole: + // expected user service case ServiceKeep: t.Fatalf("unexpected keep in user services") } @@ -113,7 +116,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,searchconsole" if got := UserServiceCSV(); got != want { t.Fatalf("unexpected user services csv: %q", got) } @@ -229,7 +232,7 @@ 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, ServiceSearchConsole}, ScopeOptions{ Readonly: true, DriveScope: DriveScopeFull, }) @@ -252,6 +255,7 @@ 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/webmasters.readonly", "profile", } for _, w := range want { @@ -271,6 +275,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) { @@ -306,6 +311,21 @@ func TestScopesForManageWithOptions_GmailScopeReadonly(t *testing.T) { } } +func TestScopesForManageWithOptions_SearchConsoleReadonly(t *testing.T) { + scopes, err := ScopesForManageWithOptions([]Service{ServiceSearchConsole}, ScopeOptions{Readonly: true}) + if err != nil { + t.Fatalf("err: %v", err) + } + + if !containsScope(scopes, "https://www.googleapis.com/auth/webmasters.readonly") { + t.Fatalf("missing webmasters.readonly in %v", scopes) + } + + if containsScope(scopes, "https://www.googleapis.com/auth/webmasters") { + t.Fatalf("unexpected webmasters scope in %v", scopes) + } +} + func TestScopes_ServiceKeep_DefaultIsReadonly(t *testing.T) { scopes, err := Scopes(ServiceKeep) if err != nil {