From 5e78a1cf4b4a3e474220fcfb65bd868248d43f2b Mon Sep 17 00:00:00 2001 From: walker <13750528578@163.com> Date: Sat, 12 Sep 2026 18:12:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(xhs):=20=E9=87=8D=E8=AF=95=E4=B8=B4?= =?UTF-8?q?=E6=97=B6=E9=A1=B5=E9=9D=A2=E5=88=9B=E5=BB=BA=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/xhs/browser_session.go | 50 +++++++++++++++++-- internal/xhs/browser_session_test.go | 73 ++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 4 deletions(-) diff --git a/internal/xhs/browser_session.go b/internal/xhs/browser_session.go index 6d76aa2..77ccffa 100644 --- a/internal/xhs/browser_session.go +++ b/internal/xhs/browser_session.go @@ -22,6 +22,8 @@ const ( xhsImagePublishURL = "https://creator.xiaohongshu.com/publish/publish?from=menu&target=image" xhsLoginURL = "https://creator.xiaohongshu.com/login" profileStateFile = "profile-state.json" + pageOpenAttempts = 3 + pageOpenRetryDelay = 250 * time.Millisecond accountProbeScript = `() => { const selectors = [ '[data-testid="user-name"]', @@ -219,7 +221,7 @@ func (s *rodBrowserSession) Open(ctx context.Context) error { if candidate.controlURL == "" { continue } - if err := s.attachBrowser(candidate.controlURL, profileDir); err == nil { + if err := s.attachBrowser(ctx, candidate.controlURL, profileDir); err == nil { return nil } else { s.debugf("browser reuse candidate failed url=%s profileBound=%t err=%v", candidate.controlURL, candidate.profileBound, err) @@ -256,7 +258,7 @@ func (s *rodBrowserSession) Open(ctx context.Context) error { return fmt.Errorf("%w: %v", ErrBrowserLaunch, err) } s.debugf("open publish page url=%s", xhsPublishURL) - page, err := browser.Page(xhsPublishURL) + page, err := s.openPublishPage(ctx, browser) if err != nil { _ = browser.Close() return fmt.Errorf("%w: open publish page: %v", ErrBrowserLaunch, err) @@ -622,7 +624,7 @@ func appendUniqueBrowserReuseCandidate(candidates []browserReuseCandidate, seen return append(candidates, candidate) } -func (s *rodBrowserSession) attachBrowser(controlURL string, profileDir string) (err error) { +func (s *rodBrowserSession) attachBrowser(ctx context.Context, controlURL string, profileDir string) (err error) { browser, err := s.newBrowser(controlURL) if err != nil { if isConnectionRefusedError(err) { @@ -631,7 +633,7 @@ func (s *rodBrowserSession) attachBrowser(controlURL string, profileDir string) return fmt.Errorf("connect running browser: %w", err) } s.debugf("open publish page url=%s", xhsPublishURL) - page, pageErr := browser.Page(xhsPublishURL) + page, pageErr := s.openPublishPage(ctx, browser) if pageErr != nil { return fmt.Errorf("open publish page: %w", pageErr) } @@ -663,6 +665,46 @@ func (s *rodBrowserSession) attachBrowser(controlURL string, profileDir string) return nil } +func (s *rodBrowserSession) openPublishPage(ctx context.Context, browser sessionBrowser) (sessionPage, error) { + var lastErr error + for attempt := 1; attempt <= pageOpenAttempts; attempt++ { + if err := ctx.Err(); err != nil { + return nil, err + } + page, err := browser.Page(xhsPublishURL) + if err == nil { + return page, nil + } + lastErr = err + if page != nil { + _ = page.Close() + } + if !isTransientPageOpenError(err) || attempt == pageOpenAttempts { + return nil, err + } + s.debugf("transient publish page open failure attempt=%d/%d err=%v", attempt, pageOpenAttempts, err) + timer := time.NewTimer(pageOpenRetryDelay) + select { + case <-ctx.Done(): + timer.Stop() + return nil, ctx.Err() + case <-timer.C: + } + } + return nil, lastErr +} + +func isTransientPageOpenError(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "inspected target navigated or closed") || + strings.Contains(message, "target closed") || + strings.Contains(message, "session with given id not found") || + strings.Contains(message, "no target with given id found") +} + func discoverLoopbackBrowserControlURLs(ctx context.Context) ([]string, error) { const versionURL = "http://127.0.0.1:9222/json/version" request, err := http.NewRequestWithContext(ctx, http.MethodGet, versionURL, nil) diff --git a/internal/xhs/browser_session_test.go b/internal/xhs/browser_session_test.go index 1de0b63..c8f8881 100644 --- a/internal/xhs/browser_session_test.go +++ b/internal/xhs/browser_session_test.go @@ -26,12 +26,22 @@ func (f fakeSessionLauncher) Launch() (string, error) { type fakeSessionBrowser struct { page *fakeSessionPage pageErr error + pageErrors []error pageURLs []string closeCalls int } func (f *fakeSessionBrowser) Page(url string) (sessionPage, error) { f.pageURLs = append(f.pageURLs, url) + if len(f.pageErrors) > 0 { + index := len(f.pageURLs) - 1 + if index >= len(f.pageErrors) { + index = len(f.pageErrors) - 1 + } + if f.pageErrors[index] != nil { + return nil, f.pageErrors[index] + } + } if f.pageErr != nil { return nil, f.pageErr } @@ -539,6 +549,69 @@ func TestBrowserSessionOpenReusesRunningBrowser(t *testing.T) { } } +func TestBrowserSessionOpenRetriesTransientPublishPageFailure(t *testing.T) { + tempDir := t.TempDir() + profileDir := filepath.Join(tempDir, "existing-profile") + if err := os.MkdirAll(profileDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(filepath.Join(profileDir, "DevToolsActivePort"), []byte("9222\n/devtools/browser/test-browser\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + page := &fakeSessionPage{account: "walker"} + browser := &fakeSessionBrowser{ + page: page, + pageErrors: []error{ + errors.New("{-32000 Inspected target navigated or closed}"), + nil, + }, + } + session := &rodBrowserSession{ + opts: SessionOptions{Account: "walker", ProfileDir: profileDir}, + userConfigDir: func() (string, error) { return tempDir, nil }, + mkdirAll: os.MkdirAll, + readFile: os.ReadFile, + writeFile: func(path string, data []byte, perm os.FileMode) error { return os.WriteFile(path, data, perm) }, + discoverBrowserURLs: func(context.Context) ([]string, error) { return nil, nil }, + newBrowser: func(string) (sessionBrowser, error) { return browser, nil }, + newLauncher: func(SessionOptions, string) sessionLauncher { + t.Fatal("newLauncher should not be called when transient reuse succeeds") + return fakeSessionLauncher{} + }, + } + if err := session.Open(context.Background()); err != nil { + t.Fatalf("Open() error = %v", err) + } + if len(browser.pageURLs) != 2 { + t.Fatalf("page open attempts = %d, want 2", len(browser.pageURLs)) + } + if session.page != page || session.ownsBrowser { + t.Fatal("Open() did not retain the reused browser page") + } +} + +func TestTransientPageOpenErrorClassification(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {name: "observed navigation race", err: errors.New("{-32000 Inspected target navigated or closed}"), want: true}, + {name: "closed target", err: errors.New("target closed"), want: true}, + {name: "missing session", err: errors.New("session with given id not found"), want: true}, + {name: "missing target", err: errors.New("No target with given id found"), want: true}, + {name: "login failure", err: ErrNotLoggedIn, want: false}, + {name: "nil", err: nil, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isTransientPageOpenError(tt.err); got != tt.want { + t.Fatalf("isTransientPageOpenError(%v) = %t, want %t", tt.err, got, tt.want) + } + }) + } +} + func TestBrowserSessionCloseReturnsReusedPageCloseError(t *testing.T) { wantErr := errors.New("close reused page") page := &fakeSessionPage{closeErr: wantErr}