package geminiweb import ( "context" "fmt" "strings" "time" "github.com/go-rod/rod" "github.com/go-rod/rod/lib/proto" ) const geminiURL = "https://gemini.google.com/app" var modelSelectors = map[string]string{ "gemini-2.0-flash": "Flash", "gemini-2.5-pro": "Pro", "gemini-2.5-pro-thinking": "Thinking", } func NormalizeModel(model string) string { if strings.HasPrefix(model, "gemini-") { return model } return "gemini-" + model } func GetModelDisplayName(model string) string { if name, ok := modelSelectors[model]; ok { return name } return "Flash" } func NavigateToGemini(page *rod.Page) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := page.Context(ctx).Navigate(geminiURL); err != nil { return fmt.Errorf("failed to navigate to gemini: %w", err) } return page.Context(ctx).WaitLoad() } func IsLoggedIn(page *rod.Page) bool { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, err := page.Context(ctx).Element(`[aria-label*="New chat"], [data-test-id*="new-chat"], button[aria-label*="chat"]`) return err == nil } func SelectModel(page *rod.Page, model string) error { displayName := GetModelDisplayName(model) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() modelSwitcher, err := page.Context(ctx).Element(`button[aria-label*="model"], [data-test-id="model-selector"], button[aria-haspopup="listbox"]`) if err != nil { return fmt.Errorf("model selector not found: %w", err) } if err := modelSwitcher.Click(proto.InputMouseButtonLeft, 1); err != nil { return fmt.Errorf("failed to click model selector: %w", err) } time.Sleep(500 * time.Millisecond) option, err := page.Context(ctx).Element(fmt.Sprintf(`[aria-label*="%s"], [data-value="%s"]`, displayName, displayName)) if err != nil { return fmt.Errorf("model option %s not found: %w", displayName, err) } return option.Click(proto.InputMouseButtonLeft, 1) } func SendPrompt(page *rod.Page, prompt string) error { textarea, err := page.Element(`textarea[aria-label*="message"], textarea[placeholder*="message"], rich-textarea, .ql-editor, div[contenteditable="true"]`) if err != nil { return fmt.Errorf("input field not found: %w", err) } if err := textarea.Input(prompt); err != nil { return fmt.Errorf("failed to input prompt: %w", err) } time.Sleep(300 * time.Millisecond) sendBtn, err := page.Element(`button[aria-label*="Send"], button[aria-label*="submit"], button[type="submit"]`) if err != nil { return fmt.Errorf("send button not found: %w", err) } return sendBtn.Click(proto.InputMouseButtonLeft, 1) } func WaitForResponse(page *rod.Page, onChunk func(text string), onThinking func(thinking string), onComplete func()) error { lastText := "" lastThinking := "" responseComplete := false timeout := time.NewTimer(120 * time.Second) defer timeout.Stop() ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() for { select { case <-timeout.C: return fmt.Errorf("response timeout") case <-ticker.C: textChanged := false responseEls, err := page.Elements(`.response-text, message-content, .model-response, div[data-test-id="response"]`) if err == nil && len(responseEls) > 0 { for _, el := range responseEls { text, _ := el.Text() text = strings.TrimSpace(text) if text != "" && text != lastText { if strings.Contains(text, lastText) { newPart := strings.TrimPrefix(text, lastText) if newPart != "" { onChunk(newPart) } } else { onChunk(text) } lastText = text textChanged = true } } } thinkingEls, err := page.Elements(`.thinking-content, .thought-text, div[data-test-id="thinking"]`) if err == nil && len(thinkingEls) > 0 { for _, el := range thinkingEls { thinking, _ := el.Text() thinking = strings.TrimSpace(thinking) if thinking != "" && thinking != lastThinking { if strings.Contains(thinking, lastThinking) { newPart := strings.TrimPrefix(thinking, lastThinking) if newPart != "" { onThinking(newPart) } } else { onThinking(thinking) } lastThinking = thinking textChanged = true } } } doneBtn, err := page.Element(`button[aria-label*="stop"], button[aria-label*="regenerate"]`) if err == nil && doneBtn != nil { ariaLabel, _ := doneBtn.Attribute("aria-label") if ariaLabel != nil && (*ariaLabel == "Stop" || strings.Contains(*ariaLabel, "regenerate")) { if !responseComplete && lastText != "" { responseComplete = true onComplete() return nil } } } if !textChanged && responseComplete { return nil } } } } func IsRateLimited(page *rod.Page) bool { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() el, err := page.Context(ctx).Element(`[class*="rate-limit"], [class*="quota"], [data-test-id="rate-limited"]`) return err == nil && el != nil } func GetRateLimitMessage(page *rod.Page) string { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() el, err := page.Context(ctx).Element(`[class*="rate-limit"], [class*="quota"], [class*="error-message"]`) if err != nil || el == nil { return "" } text, _ := el.Text() return strings.TrimSpace(text) }