diff --git a/cmd/nlm/main.go b/cmd/nlm/main.go index fe37265..73905c0 100644 --- a/cmd/nlm/main.go +++ b/cmd/nlm/main.go @@ -47,7 +47,7 @@ type ChatSession struct { // ChatMessage represents a single message in the conversation type ChatMessage struct { - Role string `json:"role"` // "user" or "assistant" + Role string `json:"role"` // "user" or "assistant" Content string `json:"content"` Timestamp time.Time `json:"timestamp"` } @@ -102,6 +102,10 @@ func init() { fmt.Fprintf(os.Stderr, " video-create Create video overview\n") fmt.Fprintf(os.Stderr, " video-download [filename] Download video file (requires --direct-rpc)\n\n") + fmt.Fprintf(os.Stderr, "PPT Commands:\n") + fmt.Fprintf(os.Stderr, " ppt-list List all PPT overviews for a notebook with status\n") + fmt.Fprintf(os.Stderr, " ppt-create [source-ids...] Create PPT overview (uses all sources if none specified)\n") + fmt.Fprintf(os.Stderr, "Artifact Commands:\n") fmt.Fprintf(os.Stderr, " create-artifact Create artifact (note|audio|report|app)\n") fmt.Fprintf(os.Stderr, " get-artifact Get artifact details\n") @@ -259,6 +263,21 @@ func validateArgs(cmd string, args []string) error { fmt.Fprintf(os.Stderr, "usage: nlm video-download [filename]\n") return fmt.Errorf("invalid arguments") } + case "ppt-list": + if len(args) != 1 { + fmt.Fprintf(os.Stderr, "usage: nlm ppt-list \n") + return fmt.Errorf("invalid arguments") + } + case "ppt-download": + if len(args) < 1 || len(args) > 3 { + fmt.Fprintf(os.Stderr, "usage: nlm ppt-download [filename]\n") + return fmt.Errorf("invalid arguments") + } + case "ppt-create": + if len(args) < 1 { + fmt.Fprintf(os.Stderr, "usage: nlm ppt-create [source-id...]\n") + return fmt.Errorf("invalid arguments") + } case "audio-create": if len(args) != 2 { fmt.Fprintf(os.Stderr, "usage: nlm audio-create \n") @@ -417,7 +436,7 @@ func isValidCommand(cmd string) bool { "list", "ls", "create", "rm", "analytics", "list-featured", "sources", "add", "rm-source", "rename-source", "refresh-source", "check-source", "discover-sources", "notes", "new-note", "update-note", "rm-note", - "audio-create", "audio-get", "audio-rm", "audio-share", "audio-list", "audio-download", "video-create", "video-list", "video-download", + "audio-create", "audio-get", "audio-rm", "audio-share", "audio-list", "audio-download", "video-create", "video-list", "video-download", "ppt-create", "ppt-list", "ppt-download", "create-artifact", "get-artifact", "list-artifacts", "artifacts", "rename-artifact", "delete-artifact", "generate-guide", "generate-outline", "generate-section", "generate-magic", "generate-mindmap", "generate-chat", "chat", "chat-list", "rephrase", "expand", "summarize", "critique", "brainstorm", "verify", "explain", "outline", "study-guide", "faq", "briefing-doc", "mindmap", "timeline", "toc", @@ -738,6 +757,21 @@ func runCmd(client *api.Client, cmd string, args ...string) error { } err = downloadVideoOverview(client, args[0], filename) + // PPT operations + case "ppt-create": + sourceIDs := []string{} + if len(args) > 1 { + sourceIDs = args[1:] + } + err = createPPTOverview(client, args[0], sourceIDs) + case "ppt-list": + err = listPPTOverviews(client, args[0]) + case "ppt-download": + filename := "" + if len(args) > 2 { + filename = args[2] + } + err = downloadPPTOverview(client, args[0], args[1], filename) // Artifact operations case "create-artifact": err = createArtifact(client, args[0], args[1]) @@ -2301,3 +2335,157 @@ func downloadVideoOverview(c *api.Client, notebookID string, filename string) er return nil } + +// PPT operations +func createPPTOverview(c *api.Client, projectID string, sourceIDs []string) error { + fmt.Printf("Creating PPT overview for notebook %s...\n", projectID) + if len(sourceIDs) > 0 { + fmt.Printf("Using %d specified source(s)\n", len(sourceIDs)) + } else { + fmt.Printf("Using all sources from notebook\n") + } + + result, err := c.CreatePPTOverview(projectID, sourceIDs) + if err != nil { + return fmt.Errorf("create PPT overview: %w", err) + } + + if !result.IsReady { + fmt.Println("✅ PPT overview creation started. PPT generation may take several minutes.") + fmt.Printf(" Project ID: %s\n", result.ProjectID) + if result.PPTID != "" { + fmt.Printf(" PPT ID: %s\n", result.PPTID) + } + return nil + } + + // If the result is immediately ready (unlikely but possible) + fmt.Printf("✅ PPT Overview created:\n") + fmt.Printf(" Title: %s\n", result.Title) + fmt.Printf(" PPT ID: %s\n", result.PPTID) + + if result.PPTData != "" { + if strings.HasPrefix(result.PPTData, "http://") || strings.HasPrefix(result.PPTData, "https://") { + fmt.Printf(" PPT URL: %s\n", result.PPTData) + } else { + fmt.Printf(" PPT data available\n") + } + } + + return nil +} + +func listPPTOverviews(c *api.Client, notebookID string) error { + fmt.Printf("Listing PPT overviews for notebook %s...\n", notebookID) + + pptOverviews, err := c.ListPPTOverviews(notebookID) + if err != nil { + return fmt.Errorf("list PPT overviews: %w", err) + } + + if len(pptOverviews) == 0 { + fmt.Println("No PPT overviews found.") + return nil + } + + // 初始化 tabwriter,设置合适的最小列宽和间距 + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + // 1. 在表头增加 DOWNLOAD_URL + fmt.Fprintln(w, "PPT_ID\tTITLE\tSTATUS") + for _, ppt := range pptOverviews { + status := "pending" + if ppt.IsReady { + status = "ready" + } + title := ppt.Title + if title == "" { + title = "(untitled)" + } + + // // 2. 获取下载链接,如果为空则显示横线或 N/A + // downloadURL := ppt.PPTData + // if downloadURL == "" { + // downloadURL = "-" + // } + + // 3. 在 Fprintf 中增加对应的格式化占位符 + fmt.Fprintf(w, "%s\t%s\t%s\t\n", + ppt.PPTID, + title, + status, + // downloadURL, + ) + } + return w.Flush() +} + +func downloadPPTOverview(c *api.Client, notebookID string, pptID string, filename string) error { + fmt.Printf("Fetching PPT overviews for notebook %s...\n", notebookID) + + // 获取该笔记本下所有的 PPT 列表 + results, err := c.ListPPTOverviews(notebookID) + if err != nil { + return fmt.Errorf("list PPT overviews: %w", err) + } + + if len(results) == 0 { + return fmt.Errorf("no PPT overviews found for notebook %s", notebookID) + } + + // 确定需要下载的任务列表 + var tasks []*api.PPTOverviewResult + if pptID != "" { + // 如果指定了 PPTID,进行过滤 + for _, r := range results { + if r.PPTID == pptID { + tasks = append(tasks, r) + break + } + } + if len(tasks) == 0 { + return fmt.Errorf("PPT with ID %s not found", pptID) + } + } else { + // 如果没有指定 PPTID,下载全部 + tasks = results + fmt.Printf("No PPT ID specified, downloading all %d PPT(s)...\n", len(tasks)) + } + + // 执行下载逻辑 + for _, ppt := range tasks { + // 处理文件名 + targetFile := filename + if targetFile == "" || len(tasks) > 1 { + // 如果是下载多个,或者没指定文件名,则根据 ppt.Title 生成文件名 + targetFile = fmt.Sprintf("%s.pdf", ppt.Title) + } + + fmt.Printf("Downloading PPT: %s (ID: %s)...\n", ppt.Title, ppt.PPTID) + + // 检查下载链接 + if ppt.PPTData != "" && (strings.HasPrefix(ppt.PPTData, "http://") || strings.HasPrefix(ppt.PPTData, "https://")) { + // 使用认证下载 + if err := c.DownloadPPTWithAuth(ppt.PPTData, targetFile); err != nil { + fmt.Printf("❌ Failed to download %s: %v\n", ppt.PPTID, err) + continue + } + } else { + // 尝试保存 base64 数据 + if err := ppt.SavePPTToFile(targetFile); err != nil { + fmt.Printf("❌ Failed to save %s: %v\n", ppt.PPTID, err) + continue + } + } + + fmt.Printf("✅ %s Saved to: %s", ppt.Title, targetFile) + // 显示文件大小 + if stat, err := os.Stat(targetFile); err == nil { + fmt.Printf(" (%.2f MB)\n", float64(stat.Size())/(1024*1024)) + } else { + fmt.Println() + } + } + + return nil +} diff --git a/internal/api/client.go b/internal/api/client.go index a8ce765..7fa3ff3 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -18,6 +18,7 @@ import ( pb "github.com/tmc/nlm/gen/notebooklm/v1alpha1" "github.com/tmc/nlm/gen/service" + "github.com/tmc/nlm/internal/auth" "github.com/tmc/nlm/internal/batchexecute" "github.com/tmc/nlm/internal/rpc" ) @@ -1997,3 +1998,295 @@ func extractYouTubeVideoID(urlStr string) (string, error) { return "", fmt.Errorf("unsupported YouTube URL format") } + +// PPT operations + +type PPTOverviewResult struct { + ProjectID string + PPTID string + Title string + PPTData string // Base64 encoded or URL + IsReady bool +} + +func (c *Client) CreatePPTOverview(projectID string, sourceIDs []string) (*PPTOverviewResult, error) { + if projectID == "" { + return nil, fmt.Errorf("project ID required") + } + + // Get source IDs from project if not provided + if len(sourceIDs) == 0 { + project, err := c.GetProject(projectID) + if err != nil { + return nil, fmt.Errorf("get project: %w", err) + } + + // Extract all source IDs from the project + for _, source := range project.Sources { + if source.SourceId != nil && source.SourceId.SourceId != "" { + sourceIDs = append(sourceIDs, source.SourceId.SourceId) + } + } + + if len(sourceIDs) == 0 { + return nil, fmt.Errorf("no sources found in project") + } + + if c.config.Debug { + fmt.Printf("Using %d sources from project for PPT creation\n", len(sourceIDs)) + } + } + + // Format source IDs as required: [[["id1"]], [["id2"]], ...] + var sourceIDsArray []interface{} + for _, sourceID := range sourceIDs { + sourceIDsArray = append(sourceIDsArray, []interface{}{[]interface{}{sourceID}}) + } + + // PPT structure based on the request: + // [[2], "notebook-id", [null, null, 8, [[["id1"]], [["id2"]]], null, null, null, null, null, null, null, null, null, null, null, null, [[]]]]] + pptArgs := []interface{}{ + []interface{}{2}, // Mode + projectID, // Notebook ID + []interface{}{ + nil, + nil, + 8, // Type (8 for PPT, vs 3 for video) + sourceIDsArray, // [[["id1"]], [["id2"]]] + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + []interface{}{}, // Empty array at the end + }, + } + + // PPT uses the same RPC endpoint as video but with different type + resp, err := c.rpc.Do(rpc.Call{ + ID: rpc.RPCCreatePPTOverview, + NotebookID: projectID, + Args: pptArgs, + }) + if err != nil { + return nil, fmt.Errorf("create PPT overview: %w", err) + } + + // Parse response - PPT returns similar structure to video: [["ppt-id", "title", status, ...]] + var responseData []interface{} + if err := json.Unmarshal(resp, &responseData); err != nil { + // Try parsing as string then as JSON (double encoded) + var strData string + if err2 := json.Unmarshal(resp, &strData); err2 == nil { + if err3 := json.Unmarshal([]byte(strData), &responseData); err3 != nil { + return nil, fmt.Errorf("parse PPT response: %w", err) + } + } else { + return nil, fmt.Errorf("parse PPT response: %w", err) + } + } + + result := &PPTOverviewResult{ + ProjectID: projectID, + IsReady: false, // PPT generation is async + } + + // Extract PPT details from response + if len(responseData) > 0 { + if pptData, ok := responseData[0].([]interface{}); ok && len(pptData) > 0 { + // First element is PPT ID + if id, ok := pptData[0].(string); ok { + result.PPTID = id + if c.config.Debug { + fmt.Printf("PPT creation initiated with ID: %s\n", id) + } + } + // Second element is title + if len(pptData) > 1 { + if title, ok := pptData[1].(string); ok { + result.Title = title + } + } + // Third element is status (1 = processing, 2 = ready?) + if len(pptData) > 2 { + if status, ok := pptData[2].(float64); ok { + result.IsReady = status == 2 + } + } + } + } + + return result, nil +} + +// ListPPTOverviews returns PPT overviews for a notebook +func (c *Client) ListPPTOverviews(projectID string) ([]*PPTOverviewResult, error) { + if !c.config.UseDirectRPC { + return nil, fmt.Errorf("PPT list requires --direct-rpc flag") + } + + // Use ListArtifacts RPC to get all artifacts, then filter for PPT (type 8) + resp, err := c.rpc.Do(rpc.Call{ + ID: rpc.RPCListArtifacts, + Args: []interface{}{ + []interface{}{2}, // Mode + projectID, + "NOT artifact.status = \"ARTIFACT_STATUS_SUGGESTED\"", // Filter + }, + NotebookID: projectID, + }) + if err != nil { + return nil, fmt.Errorf("list artifacts for PPT: %w", err) + } + + // Parse response + var responseData []interface{} + if err := json.Unmarshal(resp, &responseData); err != nil { + var strData string + if err2 := json.Unmarshal(resp, &strData); err2 == nil { + if err3 := json.Unmarshal([]byte(strData), &responseData); err3 != nil { + return nil, fmt.Errorf("parse artifacts response: %w", err) + } + } else { + return nil, fmt.Errorf("parse artifacts response: %w", err) + } + } + + results := []*PPTOverviewResult{} + + // Helper function to recursively search for download URL + var findDownloadURL func(interface{}) string + findDownloadURL = func(item interface{}) string { + switch v := item.(type) { + case string: + if strings.HasPrefix(v, "https://contribution.usercontent.google.com/download") { + return v + } + case []interface{}: + for _, subItem := range v { + if url := findDownloadURL(subItem); url != "" { + return url + } + } + case map[string]interface{}: + for _, val := range v { + if url := findDownloadURL(val); url != "" { + return url + } + } + } + return "" + } + + if len(responseData) > 0 { + if artifactsArray, ok := responseData[0].([]interface{}); ok { + for _, artifactItem := range artifactsArray { + if artifact, ok := artifactItem.([]interface{}); ok && len(artifact) >= 3 { + // Check artifact type (index 2) + artifactType, ok := artifact[2].(float64) + if !ok { + continue + } + + // Type 8 is PPT + if int(artifactType) == 8 { + result := &PPTOverviewResult{ + ProjectID: projectID, + IsReady: false, + } + + // Extract artifact ID (index 0) + if id, ok := artifact[0].(string); ok { + result.PPTID = id + } + + // Extract title (index 1) + if title, ok := artifact[1].(string); ok { + result.Title = title + } + + // Check status (index 4) + if len(artifact) > 4 { + if status, ok := artifact[4].(float64); ok { + if int(status) == 3 { + result.IsReady = true + } + } + } + + // Search for download URL - typically near the end of the array + downloadURL := "" + for i := len(artifact) - 1; i >= 0; i-- { + if urlStr, ok := artifact[i].(string); ok { + if strings.HasPrefix(urlStr, "https://contribution.usercontent.google.com/download") { + downloadURL = urlStr + break + } + } + } + + // If not found in direct positions, search recursively + if downloadURL == "" { + downloadURL = findDownloadURL(artifact) + } + + if downloadURL != "" { + result.PPTData = downloadURL + result.IsReady = true + } + + results = append(results, result) + } + } + } + } + } + + return results, nil +} + +// SavePPTToFile saves PPT data to a file +// NOTE: For URL downloads, use client.DownloadPPTWithAuth() for proper authentication +func (r *PPTOverviewResult) SavePPTToFile(filename string) error { + if r.PPTData == "" { + return fmt.Errorf("no PPT data to save") + } + + // Check if PPTData is a URL or base64 data + if strings.HasPrefix(r.PPTData, "http://") || strings.HasPrefix(r.PPTData, "https://") { + return fmt.Errorf("PPT data is a URL, use DownloadPPTWithAuth() instead") + } + + // Try to decode as base64 + pptBytes, err := base64.StdEncoding.DecodeString(r.PPTData) + if err != nil { + // If not base64, treat as raw data + pptBytes = []byte(r.PPTData) + } + + if err := os.WriteFile(filename, pptBytes, 0644); err != nil { + return fmt.Errorf("write PPT file: %w", err) + } + + return nil +} + +// DownloadPPTWithAuth downloads a PPT using browser with authentication +func (c *Client) DownloadPPTWithAuth(pptURL, filename string) error { + u, err := url.Parse(pptURL) + if err != nil { + return fmt.Errorf("parse PPT download_url: %w", err) + } + u.RawQuery = u.Query().Encode() + + // Use browser to download the file with authentication + ba := auth.New(c.config.Debug) + return ba.DownloadFileWithAuth(u.String(), filename) +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 2c15a2b..a5e3fe8 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "net/http/cookiejar" "net/url" "os" "os/exec" @@ -115,7 +116,7 @@ func (ba *BrowserAuth) tryMultipleProfiles(targetURL string) (token, cookies str userDataDir = tempDir // Copy the entire profile directory to temp location - if err := ba.copyProfileDataFromPath(profile.Path); err != nil { + if err := ba.copyProfileDataFromPath(profile.Path, false); err != nil { if ba.debug { fmt.Printf("Error copying profile %s: %v\n", profile.Name, err) } @@ -484,7 +485,7 @@ func (ba *BrowserAuth) GetAuth(opts ...Option) (token, cookies string, err error defer os.RemoveAll(tempDir) // Copy profile data - err = tempAuth.copyProfileDataFromPath(p.Path) + err = tempAuth.copyProfileDataFromPath(p.Path, false) if err != nil { fmt.Println(" Error: could not copy profile data") updatedProfiles = append(updatedProfiles, p) @@ -623,7 +624,7 @@ func (ba *BrowserAuth) GetAuth(opts ...Option) (token, cookies string, err error ba.tempDir = tempDir // Copy the profile data - if err := ba.copyProfileDataFromPath(selectedProfile.Path); err != nil { + if err := ba.copyProfileDataFromPath(selectedProfile.Path, false); err != nil { return "", "", fmt.Errorf("copy profile: %w", err) } @@ -699,11 +700,12 @@ func (ba *BrowserAuth) copyProfileData(profileName string) error { } } - return ba.copyProfileDataFromPath(sourceDir) + return ba.copyProfileDataFromPath(sourceDir, false) } // copyProfileDataFromPath copies profile data from a specific path -func (ba *BrowserAuth) copyProfileDataFromPath(sourceDir string) error { +// copyProfileDataFromPath copies profile data from a specific path +func (ba *BrowserAuth) copyProfileDataFromPath(sourceDir string, isTruth bool) error { if ba.debug { fmt.Printf("Copying profile data from: %s\n", sourceDir) } @@ -716,13 +718,13 @@ func (ba *BrowserAuth) copyProfileDataFromPath(sourceDir string) error { // Copy only essential files for authentication (not entire profile) essentialFiles := []string{ - "Cookies", // Authentication cookies - "Cookies-journal", // Cookie database journal - "Login Data", // Saved login information + "Cookies", // Authentication cookies + "Cookies-journal", // Cookie database journal + "Login Data", // Saved login information "Login Data-journal", // Login database journal - "Web Data", // Form data and autofill - "Web Data-journal", // Web data journal - "Preferences", // Browser preferences + "Web Data", // Form data and autofill + "Web Data-journal", // Web data journal + "Preferences", // Browser preferences "Secure Preferences", // Secure browser settings } @@ -749,10 +751,19 @@ func (ba *BrowserAuth) copyProfileDataFromPath(sourceDir string) error { fmt.Printf("Copied %d essential files for authentication\n", copiedCount) } - // Create minimal Local State file - localState := `{"os_crypt":{"encrypted_key":""}}` - if err := os.WriteFile(filepath.Join(ba.tempDir, "Local State"), []byte(localState), 0644); err != nil { - return fmt.Errorf("write local state: %w", err) + if isTruth { + srcLocalState := filepath.Join(filepath.Dir(sourceDir), "Local State") + dstLocalState := filepath.Join(ba.tempDir, "Local State") + + if err := copyFile(srcLocalState, dstLocalState); err != nil { + return fmt.Errorf("copy actual local state: %w", err) + } + } else { + // Create minimal Local State file + localState := `{"os_crypt":{"encrypted_key":""}}` + if err := os.WriteFile(filepath.Join(ba.tempDir, "Local State"), []byte(localState), 0644); err != nil { + return fmt.Errorf("write local state: %w", err) + } } return nil @@ -1239,3 +1250,168 @@ func (ba *BrowserAuth) tryExtractAuth(ctx context.Context) (token, cookies strin return token, cookies, nil } + +// DownloadFileWithBrowser downloads a file using browser with authentication +// This method uses the browser's download functionality to download files that require authentication +func (ba *BrowserAuth) DownloadFileWithAuth(downloadURL, savePath string) error { + // Get the most recently used profile + profiles, err := ba.scanProfilesForDomain("google.com") + if err != nil { + return fmt.Errorf("scan profiles: %w", err) + } + + if len(profiles) == 0 { + return fmt.Errorf("no valid profiles found") + } + + selectedProfile := &profiles[0] + + // Create a temporary directory for browser profile + tempDir, err := os.MkdirTemp("", "nlm-download-*") + if err != nil { + return fmt.Errorf("create temp dir: %w", err) + } + defer os.RemoveAll(tempDir) + + ba.tempDir = tempDir + defer ba.cleanup() + + // Copy the profile data + if err := ba.copyProfileDataFromPath(selectedProfile.Path, true); err != nil { + return fmt.Errorf("copy profile: %w", err) + } + + // Create download directory + downloadDir := filepath.Join(tempDir, "downloads") + if err := os.MkdirAll(downloadDir, 0755); err != nil { + return fmt.Errorf("create download dir: %w", err) + } + + // Set up Chrome with download support + chromeOpts := []chromedp.ExecAllocatorOption{ + chromedp.NoFirstRun, + chromedp.NoDefaultBrowserCheck, + chromedp.UserDataDir(ba.tempDir), + chromedp.Flag("headless", !ba.debug), + chromedp.Flag("disable-default-apps", true), + chromedp.Flag("exclude-switches", "enable-automation"), + chromedp.Flag("disable-blink-features", "AutomationControlled"), + chromedp.Flag("disable-features", "IsolateOrigins,site-per-process"), + chromedp.Flag("window-size", "1280,800"), + chromedp.ExecPath(getChromePath()), + chromedp.UserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"), + } + + allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), chromeOpts...) + ba.cancel = allocCancel + defer allocCancel() + + ctx, cancel := chromedp.NewContext(allocCtx) + defer cancel() + + ctx, cancel = context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + + if ba.debug { + ctx, _ = chromedp.NewContext(ctx, chromedp.WithLogf(func(format string, args ...interface{}) { + fmt.Printf("ChromeDP: "+format+"\n", args...) + })) + } + + var netCookies []*network.Cookie + if err := chromedp.Run(ctx, + // 等待账号信息加载完成 + chromedp.Sleep(15*time.Second), + // 刷新cookie + chromedp.Navigate("https://accounts.google.com/CheckCookie?continue=https://www.google.com/"), + + // 获取accounts.google.com/CheckCookie校验过的cookie + chromedp.ActionFunc(func(ctx context.Context) error { + cookies, err := network.GetCookies(). + WithUrls([]string{"https://google.com", "https://usercontent.google.com"}). + Do(ctx) + + if err != nil { + return fmt.Errorf("get google cookie failed, err: %w", err) + } + + netCookies = cookies + return nil + }), + ); err != nil { + if ba.debug { + fmt.Printf("Navigation warning: %v (file may be downloading)\n", err) + } + } + + jar, _ := cookiejar.New(nil) + var cookies []*http.Cookie + for _, c := range netCookies { + cookies = append(cookies, &http.Cookie{ + Name: c.Name, + Value: c.Value, + Domain: c.Domain, + Path: c.Path, + Secure: c.Secure, + }) + } + + // 将 Cookies 注入到 Jar 中 + // 注意:CookieJar 需要根据 URL 匹配 Cookie。 + // Google 的核心 Cookie 通常是 .google.com 域,所以我们需要设置进去 + u, _ := url.Parse(downloadURL) + rootURL, _ := url.Parse("https://google.com") + + jar.SetCookies(rootURL, cookies) + jar.SetCookies(u, cookies) + + client := http.DefaultClient + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 redirects") + } + return nil + } + client.Jar = jar + + req, err := http.NewRequest("GET", downloadURL, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("http download failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + // 读取一点 body 看看错误信息(如果是 403/401 说明 Cookie 没生效) + bodySample, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("download failed with status %s: %s", resp.Status, string(bodySample)) + } + + targetDir := filepath.Dir(savePath) + if err = os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("create target dir: %w", err) + } + + outFile, err := os.Create(savePath) + if err != nil { + return fmt.Errorf("create output file: %w", err) + } + defer outFile.Close() + + written, err := io.Copy(outFile, resp.Body) + if err != nil { + return fmt.Errorf("write file: %w", err) + } + + if ba.debug { + fmt.Printf("File downloaded successfully: %s (%.2f MB)\n", savePath, float64(written)/(1024*1024)) + } + + return nil +} diff --git a/internal/auth/chrome_linux.go b/internal/auth/chrome_linux.go index c14e50e..bec92e9 100644 --- a/internal/auth/chrome_linux.go +++ b/internal/auth/chrome_linux.go @@ -57,3 +57,58 @@ func getChromePath() string { } return "" } + +// getBrowserPathForProfile 在 Linux 中查找浏览器可执行文件 +func getBrowserPathForProfile(browserName string) string { + var binaryName string + + switch browserName { + case "Brave": + binaryName = "brave-browser" + case "Chrome Canary": + // Linux 上通常没有官方的 "Canary" 版本,对应的是 "google-chrome-unstable" + binaryName = "google-chrome-unstable" + default: + // 默认回退到标准 chrome 或 chromium + return getChromePath() + } + + // 在 Linux 中,最好的做法是使用 exec.LookPath 在 $PATH 中查找 + if path, err := exec.LookPath(binaryName); err == nil { + return path + } + + // 备选方案:检查常见的硬编码路径(如 /usr/bin) + commonPaths := []string{ + filepath.Join("/usr/bin", binaryName), + filepath.Join("/usr/local/bin", binaryName), + filepath.Join("/snap/bin", binaryName), // 支持 Snap 安装 + } + + for _, path := range commonPaths { + if _, err := os.Stat(path); err == nil { + return path + } + } + + return "" +} + +// 获取配置文件的基准目录(遵循 XDG 规范) +func getConfigDir() string { + if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" { + return xdgConfig + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config") +} + +func getCanaryProfilePath() string { + // 对应 google-chrome-unstable 的配置路径 + return filepath.Join(getConfigDir(), "google-chrome-unstable") +} + +func getBraveProfilePath() string { + // Brave 在 Linux 下的配置路径 + return filepath.Join(getConfigDir(), "BraveSoftware", "Brave-Browser") +} diff --git a/internal/auth/chrome_windows.go b/internal/auth/chrome_windows.go index 0e459d2..74cd567 100644 --- a/internal/auth/chrome_windows.go +++ b/internal/auth/chrome_windows.go @@ -67,3 +67,51 @@ func getChromePath() string { return "" } + +// getBrowserPathForProfile returns the appropriate browser executable for a given browser type +func getBrowserPathForProfile(browserName string) string { + switch browserName { + case "Brave": + // Try Brave paths + bravePaths := []string{ + filepath.Join(os.Getenv("PROGRAMFILES"), "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + filepath.Join(os.Getenv("PROGRAMFILES(X86)"), "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + filepath.Join(os.Getenv("LOCALAPPDATA"), "BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + } + for _, path := range bravePaths { + if _, err := os.Stat(path); err == nil { + return path + } + } + case "Chrome Canary": + canaryPaths := []string{ + filepath.Join(os.Getenv("LOCALAPPDATA"), "Google", "Chrome SxS", "Application", "chrome.exe"), + } + for _, path := range canaryPaths { + if _, err := os.Stat(path); err == nil { + return path + } + } + } + + // Fallback to any Chrome-based browser + return getChromePath() +} + +func getCanaryProfilePath() string { + localAppData := os.Getenv("LOCALAPPDATA") + if localAppData == "" { + home, _ := os.UserHomeDir() + localAppData = filepath.Join(home, "AppData", "Local") + } + return filepath.Join(localAppData, "Google", "Chrome SxS", "User Data") +} + +func getBraveProfilePath() string { + localAppData := os.Getenv("LOCALAPPDATA") + if localAppData == "" { + home, _ := os.UserHomeDir() + localAppData = filepath.Join(home, "AppData", "Local") + } + return filepath.Join(localAppData, "BraveSoftware", "Brave-Browser", "User Data") +} diff --git a/internal/rpc/rpc.go b/internal/rpc/rpc.go index 78574a2..ae514f7 100644 --- a/internal/rpc/rpc.go +++ b/internal/rpc/rpc.go @@ -41,6 +41,8 @@ const ( // NotebookLM service - Video operations RPCCreateVideoOverview = "R7cb6c" // CreateVideoOverview + // NotebookLM service - PPT operations (uses same RPC ID as video but different type)cd + RPCCreatePPTOverview = "R7cb6c" // CreatePPTOverview // NotebookLM service - Generation operations RPCGenerateDocumentGuides = "tr032e" // GenerateDocumentGuides