返回 DeepSeek-Reasonix
capture.go
根目录 / internal / browser / cdp / capture.go
1 package cdp
2
3 import (
4 "bytes"
5 "context"
6 "encoding/base64"
7 "encoding/json"
8 "fmt"
9 "image/png"
10 "os"
11 "path/filepath"
12 "strconv"
13 "strings"
14 "time"
15
16 "reasonix/internal/browser"
17 )
18
19 // downloadPollInterval is how often a waiting browser_download re-reads the
20 // records the browser's progress events keep up to date.
21 const downloadPollInterval = 200 * time.Millisecond
22
23 // downloadRecord is one download this executor observed, keyed by the guid
24 // Chrome assigns it.
25 type downloadRecord struct {
26 guid string
27 tab string
28 url string
29 suggested string
30 path string
31 state string
32 received int64
33 total int64
34 }
35
36 func (d *downloadRecord) download() browser.Download {
37 bytesSeen := d.received
38 if d.state == "completed" && d.total > 0 {
39 bytesSeen = d.total
40 }
41 return browser.Download{ID: d.guid, URL: d.url, Path: d.path, State: d.state, Bytes: bytesSeen}
42 }
43
44 // Screenshot writes a PNG this task owns and names it; image bytes reach the
45 // model through the tool's image channel, never through a control frame.
46 func (e *Executor) Screenshot(ctx context.Context, req browser.ScreenshotRequest) (browser.Screenshot, error) {
47 p, err := e.lookup(ctx, req.TabID)
48 if err != nil {
49 return browser.Screenshot{}, err
50 }
51 params, err := e.captureParams(ctx, p, req)
52 if err != nil {
53 return browser.Screenshot{}, err
54 }
55 var out struct {
56 Data string `json:"data"`
57 }
58 if err := e.conn.call(ctx, p.session, "Page.captureScreenshot", params, &out); err != nil {
59 return browser.Screenshot{}, fmt.Errorf("capture screenshot: %w", err)
60 }
61 data, err := base64.StdEncoding.DecodeString(out.Data)
62 if err != nil {
63 return browser.Screenshot{}, fmt.Errorf("decode screenshot: %w", err)
64 }
65 // The tab's registered ID, not the caller's argument: a screenshot names a
66 // file, and only IDs this executor minted may reach a path.
67 name := fmt.Sprintf("%s-%d.png", p.id, time.Now().UnixNano())
68 path, err := e.artifactPath("screenshots", name)
69 if err != nil {
70 return browser.Screenshot{}, err
71 }
72 if err := os.WriteFile(path, data, 0o600); err != nil {
73 return browser.Screenshot{}, fmt.Errorf("write screenshot: %w", err)
74 }
75 shot := browser.Screenshot{Path: path, MIME: "image/png"}
76 if cfg, err := png.DecodeConfig(bytes.NewReader(data)); err == nil {
77 shot.Width, shot.Height = cfg.Width, cfg.Height
78 }
79 return shot, nil
80 }
81
82 // captureParams turns the request into a capture region. Element and full-page
83 // clips are page coordinates, so the visual viewport's offset is added back.
84 func (e *Executor) captureParams(ctx context.Context, p *page, req browser.ScreenshotRequest) (map[string]any, error) {
85 params := map[string]any{"format": "png"}
86 if req.Ref == "" && !req.FullPage {
87 return params, nil
88 }
89 metrics, err := e.layout(ctx, p)
90 if err != nil {
91 return nil, err
92 }
93 params["captureBeyondViewport"] = true
94 if req.Ref != "" {
95 rect, err := e.locate(ctx, p, req.Ref)
96 if err != nil {
97 return nil, err
98 }
99 if rect.Hidden {
100 return nil, fmt.Errorf("element %s has no visible box to capture", req.Ref)
101 }
102 params["clip"] = map[string]any{
103 "x": rect.X - rect.Width/2 + metrics.pageX,
104 "y": rect.Y - rect.Height/2 + metrics.pageY,
105 "width": rect.Width, "height": rect.Height, "scale": 1,
106 }
107 return params, nil
108 }
109 params["clip"] = map[string]any{"x": 0, "y": 0, "width": metrics.contentWidth, "height": metrics.contentHeight, "scale": 1}
110 return params, nil
111 }
112
113 // layoutMetrics is the subset of Page.getLayoutMetrics this package uses.
114 type layoutMetrics struct {
115 pageX, pageY float64
116 viewWidth, viewHeight float64
117 contentWidth, contentHeight float64
118 }
119
120 func (e *Executor) layout(ctx context.Context, p *page) (layoutMetrics, error) {
121 var out struct {
122 CSSContentSize struct {
123 Width float64 `json:"width"`
124 Height float64 `json:"height"`
125 } `json:"cssContentSize"`
126 CSSVisualViewport struct {
127 PageX float64 `json:"pageX"`
128 PageY float64 `json:"pageY"`
129 ClientWidth float64 `json:"clientWidth"`
130 ClientHeight float64 `json:"clientHeight"`
131 } `json:"cssVisualViewport"`
132 }
133 if err := e.conn.call(ctx, p.session, "Page.getLayoutMetrics", nil, &out); err != nil {
134 return layoutMetrics{}, fmt.Errorf("read layout metrics: %w", err)
135 }
136 return layoutMetrics{
137 pageX: out.CSSVisualViewport.PageX, pageY: out.CSSVisualViewport.PageY,
138 viewWidth: out.CSSVisualViewport.ClientWidth, viewHeight: out.CSSVisualViewport.ClientHeight,
139 contentWidth: out.CSSContentSize.Width, contentHeight: out.CSSContentSize.Height,
140 }, nil
141 }
142
143 // setDownloadBehavior points a browser context's downloads at the task's
144 // artifact directory. allowAndName writes each file under its guid, which is
145 // the only name that cannot collide before the download is finished.
146 func (e *Executor) setDownloadBehavior(ctx context.Context, contextID string) error {
147 dir, err := e.artifactPath("downloads", "")
148 if err != nil {
149 return err
150 }
151 params := map[string]any{"behavior": "allowAndName", "downloadPath": dir, "eventsEnabled": true}
152 if contextID != "" {
153 params["browserContextId"] = contextID
154 }
155 if err := e.conn.call(ctx, "", "Browser.setDownloadBehavior", params, nil); err != nil {
156 return fmt.Errorf("configure downloads: %w", err)
157 }
158 return nil
159 }
160
161 func (e *Executor) downloadWillBegin(params json.RawMessage) {
162 var ev struct {
163 FrameID string `json:"frameId"`
164 GUID string `json:"guid"`
165 URL string `json:"url"`
166 SuggestedFilename string `json:"suggestedFilename"`
167 }
168 if err := json.Unmarshal(params, &ev); err != nil || ev.GUID == "" {
169 return
170 }
171 e.mu.Lock()
172 defer e.mu.Unlock()
173 tab := ""
174 for _, p := range e.pages {
175 p.mu.Lock()
176 if p.doc.frame == ev.FrameID {
177 tab = p.id
178 p.downloads = append(p.downloads, ev.GUID)
179 }
180 p.mu.Unlock()
181 }
182 e.downloads[ev.GUID] = &downloadRecord{
183 guid: ev.GUID, tab: tab, url: ev.URL, suggested: ev.SuggestedFilename,
184 state: "inProgress", path: filepath.Join(e.artifacts, "downloads", ev.GUID),
185 }
186 }
187
188 func (e *Executor) downloadProgress(params json.RawMessage) {
189 var ev struct {
190 GUID string `json:"guid"`
191 TotalBytes float64 `json:"totalBytes"`
192 ReceivedBytes float64 `json:"receivedBytes"`
193 State string `json:"state"`
194 }
195 if err := json.Unmarshal(params, &ev); err != nil || ev.GUID == "" {
196 return
197 }
198 e.mu.Lock()
199 record, ok := e.downloads[ev.GUID]
200 if !ok {
201 e.mu.Unlock()
202 return
203 }
204 record.state = ev.State
205 record.received = int64(ev.ReceivedBytes)
206 record.total = int64(ev.TotalBytes)
207 finished := ev.State == "completed"
208 from, suggested := record.path, record.suggested
209 e.mu.Unlock()
210 if !finished {
211 return
212 }
213 if to, err := renameDownload(from, suggested); err == nil {
214 e.mu.Lock()
215 record.path = to
216 e.mu.Unlock()
217 }
218 }
219
220 // renameDownload gives a finished download its suggested name without ever
221 // overwriting a file that is already there.
222 func renameDownload(from, suggested string) (string, error) {
223 name := safeFilename(suggested)
224 if name == "" {
225 return from, nil
226 }
227 dir := filepath.Dir(from)
228 target := filepath.Join(dir, name)
229 ext := filepath.Ext(name)
230 stem := strings.TrimSuffix(name, ext)
231 for i := 1; ; i++ {
232 if _, err := os.Stat(target); os.IsNotExist(err) {
233 break
234 }
235 if i > 500 {
236 return from, nil
237 }
238 target = filepath.Join(dir, stem+" ("+strconv.Itoa(i)+")"+ext)
239 }
240 if err := os.Rename(from, target); err != nil {
241 return from, err
242 }
243 return target, nil
244 }
245
246 // safeFilename keeps a server-suggested name from escaping the download
247 // directory or naming a device.
248 func safeFilename(name string) string {
249 name = strings.TrimSpace(filepath.Base(strings.ReplaceAll(name, `\`, "/")))
250 if name == "." || name == ".." || name == "/" {
251 return ""
252 }
253 cleaned := strings.Map(func(r rune) rune {
254 if r < 0x20 || strings.ContainsRune(`/\:*?"<>|`, r) {
255 return '_'
256 }
257 return r
258 }, name)
259 if len(cleaned) > 180 {
260 cleaned = cleaned[:180]
261 }
262 return cleaned
263 }
264
265 // Downloads lists a tab's downloads, optionally waiting for the ones still
266 // running to settle.
267 func (e *Executor) Downloads(ctx context.Context, req browser.DownloadsRequest) ([]browser.Download, error) {
268 p, err := e.lookup(ctx, req.TabID)
269 if err != nil {
270 return nil, err
271 }
272 deadline := time.Now().Add(req.WaitFor)
273 for {
274 list, pending := e.tabDownloads(p)
275 if !pending || req.WaitFor <= 0 || time.Now().After(deadline) {
276 return list, nil
277 }
278 select {
279 case <-ctx.Done():
280 return list, ctx.Err()
281 case <-time.After(downloadPollInterval):
282 }
283 }
284 }
285
286 func (e *Executor) tabDownloads(p *page) ([]browser.Download, bool) {
287 p.mu.Lock()
288 guids := append([]string(nil), p.downloads...)
289 p.mu.Unlock()
290 e.mu.Lock()
291 defer e.mu.Unlock()
292 list := make([]browser.Download, 0, len(guids))
293 pending := false
294 for _, guid := range guids {
295 record, ok := e.downloads[guid]
296 if !ok {
297 continue
298 }
299 if record.state == "inProgress" {
300 pending = true
301 }
302 list = append(list, record.download())
303 }
304 return list, pending
305 }
306
306 lines GO