返回 DeepSeek-Reasonix
page.go
根目录 / internal / browser / cdp / page.go
1 package cdp
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "strconv"
9 "strings"
10 "sync"
11
12 "reasonix/internal/browser"
13 )
14
15 // worldName labels the isolated world in DevTools; page script can neither
16 // enumerate nor reach it.
17 const worldName = "reasonix-browser"
18
19 // snapshotBudget caps the nodes one snapshot may emit so a large document
20 // cannot flood the model's context.
21 const snapshotBudget = 2000
22
23 // pageIdentity is fixed for a tab's whole life: which target it is, which
24 // session may drive it, and which partition it belongs to.
25 type pageIdentity struct {
26 id string
27 target string
28 session string
29 context string
30 owner string
31 temporary bool
32 }
33
34 // pageDocument is what one document version owns. Navigation, page
35 // replacement, and a take-over retire all of it together, which is why it is
36 // one value and not five independent flags: no combination of a live token
37 // with a dead isolated world can exist.
38 type pageDocument struct {
39 frame string
40 world int64
41 token string
42 lastSeq int64
43 takenOver bool
44 }
45
46 // page is one agent-owned tab. Its mutex guards the document state and the
47 // per-tab records that outlive a single document.
48 type page struct {
49 pageIdentity
50 detach []func()
51
52 mu sync.Mutex
53 doc pageDocument
54 loading bool
55 dead bool
56 url string
57 title string
58 downloads []string
59 }
60
61 // retire drops everything bound to the document that just went away: the
62 // isolated world holding the refs, the token those refs were promised under,
63 // and the take-over counter the new world starts over from. The frame outlives
64 // the document that was loaded in it.
65 func (p *page) retire(url string) {
66 p.mu.Lock()
67 defer p.mu.Unlock()
68 p.doc = pageDocument{frame: p.doc.frame}
69 if url != "" {
70 p.url = url
71 }
72 }
73
74 func (p *page) tab() browser.Tab {
75 p.mu.Lock()
76 defer p.mu.Unlock()
77 return browser.Tab{ID: p.id, URL: p.url, Title: p.title, Loading: p.loading, Temporary: p.temporary}
78 }
79
80 func (p *page) setLoading(loading bool) {
81 p.mu.Lock()
82 p.loading = loading
83 p.mu.Unlock()
84 }
85
86 func (p *page) markDead() {
87 p.mu.Lock()
88 p.dead = true
89 p.doc.world = 0
90 p.doc.token = ""
91 p.mu.Unlock()
92 }
93
94 func (p *page) isDead() bool {
95 p.mu.Lock()
96 defer p.mu.Unlock()
97 return p.dead
98 }
99
100 // attach opens a flat DevTools session on target and wires the events that
101 // retire document state. The returned page is not yet bootstrapped: the
102 // isolated world is created lazily by the first snapshot.
103 func (e *Executor) attach(ctx context.Context, id, target, contextID, owner string, temporary bool) (*page, error) {
104 var out struct {
105 SessionID string `json:"sessionId"`
106 }
107 if err := e.conn.call(ctx, "", "Target.attachToTarget", map[string]any{"targetId": target, "flatten": true}, &out); err != nil {
108 return nil, fmt.Errorf("attach to tab: %w", err)
109 }
110 p := &page{pageIdentity: pageIdentity{
111 id: id, target: target, session: out.SessionID, context: contextID, owner: owner, temporary: temporary,
112 }}
113 if err := e.conn.call(ctx, p.session, "Page.enable", nil, nil); err != nil {
114 return nil, fmt.Errorf("enable page events: %w", err)
115 }
116 e.subscribe(p)
117 e.refreshFrame(ctx, p)
118 return p, nil
119 }
120
121 // subscribe binds the page's document lifetime to Chrome's frame events.
122 func (e *Executor) subscribe(p *page) {
123 p.detach = append(p.detach,
124 e.conn.on(p.session, "Page.frameNavigated", func(params json.RawMessage) {
125 var ev struct {
126 Frame struct {
127 ID string `json:"id"`
128 ParentID string `json:"parentId"`
129 URL string `json:"url"`
130 } `json:"frame"`
131 }
132 if err := json.Unmarshal(params, &ev); err != nil || ev.Frame.ParentID != "" {
133 return
134 }
135 p.mu.Lock()
136 p.doc.frame = ev.Frame.ID
137 p.mu.Unlock()
138 p.retire(ev.Frame.URL)
139 }),
140 e.conn.on(p.session, "Page.frameStartedLoading", func(json.RawMessage) { p.setLoading(true) }),
141 e.conn.on(p.session, "Page.frameStoppedLoading", func(json.RawMessage) { p.setLoading(false) }),
142 e.conn.on(p.session, "Runtime.executionContextsCleared", func(json.RawMessage) { p.retire("") }),
143 e.conn.on(p.session, "Inspector.targetCrashed", func(json.RawMessage) { p.markDead() }),
144 )
145 }
146
147 func (p *page) unsubscribe() {
148 for _, off := range p.detach {
149 off()
150 }
151 p.detach = nil
152 }
153
154 // refreshFrame records the main frame and the tab's current address.
155 func (e *Executor) refreshFrame(ctx context.Context, p *page) {
156 var tree struct {
157 FrameTree struct {
158 Frame struct {
159 ID string `json:"id"`
160 URL string `json:"url"`
161 Title string `json:"title"`
162 } `json:"frame"`
163 } `json:"frameTree"`
164 }
165 if err := e.conn.call(ctx, p.session, "Page.getFrameTree", nil, &tree); err != nil {
166 return
167 }
168 p.mu.Lock()
169 p.doc.frame = tree.FrameTree.Frame.ID
170 if p.url == "" {
171 p.url = tree.FrameTree.Frame.URL
172 }
173 p.mu.Unlock()
174 }
175
176 // evalResult is the shape of a Runtime.evaluate or Runtime.callFunctionOn
177 // reply that this package cares about.
178 type evalResult struct {
179 Result struct {
180 Type string `json:"type"`
181 Subtype string `json:"subtype"`
182 Value json.RawMessage `json:"value"`
183 ObjectID string `json:"objectId"`
184 } `json:"result"`
185 ExceptionDetails *struct {
186 Text string `json:"text"`
187 Exception *struct {
188 Description string `json:"description"`
189 } `json:"exception"`
190 } `json:"exceptionDetails"`
191 }
192
193 func (r evalResult) exception() error {
194 if r.ExceptionDetails == nil {
195 return nil
196 }
197 if r.ExceptionDetails.Exception != nil && r.ExceptionDetails.Exception.Description != "" {
198 return fmt.Errorf("page script failed: %s", firstLine(r.ExceptionDetails.Exception.Description))
199 }
200 return fmt.Errorf("page script failed: %s", r.ExceptionDetails.Text)
201 }
202
203 func firstLine(s string) string {
204 line, _, _ := strings.Cut(s, "\n")
205 return line
206 }
207
208 // ensureWorld returns the page's isolated world, creating and bootstrapping it
209 // when the previous document took the old one with it.
210 func (e *Executor) ensureWorld(ctx context.Context, p *page) (int64, error) {
211 p.mu.Lock()
212 world, frame := p.doc.world, p.doc.frame
213 p.mu.Unlock()
214 if world != 0 {
215 return world, nil
216 }
217 if frame == "" {
218 e.refreshFrame(ctx, p)
219 p.mu.Lock()
220 frame = p.doc.frame
221 p.mu.Unlock()
222 }
223 if frame == "" {
224 return 0, fmt.Errorf("tab %s has no main frame", p.id)
225 }
226 var out struct {
227 ExecutionContextID int64 `json:"executionContextId"`
228 }
229 if err := e.conn.call(ctx, p.session, "Page.createIsolatedWorld", map[string]any{"frameId": frame, "worldName": worldName}, &out); err != nil {
230 return 0, fmt.Errorf("create isolated world: %w", err)
231 }
232 var boot evalResult
233 if err := e.conn.call(ctx, p.session, "Runtime.evaluate", evaluateParams(bootstrapJS, out.ExecutionContextID, true), &boot); err != nil {
234 return 0, fmt.Errorf("install page helper: %w", err)
235 }
236 if err := boot.exception(); err != nil {
237 return 0, err
238 }
239 p.mu.Lock()
240 p.doc.world = out.ExecutionContextID
241 p.doc.lastSeq = 0
242 p.mu.Unlock()
243 return out.ExecutionContextID, nil
244 }
245
246 func evaluateParams(expression string, contextID int64, byValue bool) map[string]any {
247 return map[string]any{
248 "expression": expression,
249 "contextId": contextID,
250 "returnByValue": byValue,
251 "awaitPromise": true,
252 "userGesture": true,
253 }
254 }
255
256 // eval runs one expression in the page's isolated world and decodes its value.
257 // A world that died between the lookup and the call is rebuilt once, which is
258 // the ordinary race with a page navigating itself.
259 func (e *Executor) eval(ctx context.Context, p *page, expression string, out any) error {
260 for attempt := range 2 {
261 world, err := e.ensureWorld(ctx, p)
262 if err != nil {
263 return err
264 }
265 var res evalResult
266 err = e.conn.call(ctx, p.session, "Runtime.evaluate", evaluateParams(expression, world, out != nil), &res)
267 if err != nil {
268 if attempt == 0 && staleContext(err) {
269 p.retire("")
270 continue
271 }
272 return err
273 }
274 if err := res.exception(); err != nil {
275 return err
276 }
277 if out == nil {
278 return nil
279 }
280 if len(res.Result.Value) == 0 {
281 return fmt.Errorf("page helper returned no value")
282 }
283 return json.Unmarshal(res.Result.Value, out)
284 }
285 return fmt.Errorf("tab %s replaced its document while the call was in flight", p.id)
286 }
287
288 // evalHandle runs one expression and keeps the remote object alive so a DOM
289 // command can address the node it returned.
290 func (e *Executor) evalHandle(ctx context.Context, p *page, expression string) (string, error) {
291 world, err := e.ensureWorld(ctx, p)
292 if err != nil {
293 return "", err
294 }
295 var res evalResult
296 if err := e.conn.call(ctx, p.session, "Runtime.evaluate", evaluateParams(expression, world, false), &res); err != nil {
297 return "", err
298 }
299 if err := res.exception(); err != nil {
300 return "", err
301 }
302 if res.Result.Subtype == "null" || res.Result.ObjectID == "" {
303 return "", browser.ErrStaleReference
304 }
305 return res.Result.ObjectID, nil
306 }
307
308 func (e *Executor) releaseHandle(ctx context.Context, p *page, objectID string) {
309 if objectID == "" {
310 return
311 }
312 _ = e.conn.call(ctx, p.session, "Runtime.releaseObject", map[string]any{"objectId": objectID}, nil)
313 }
314
315 func staleContext(err error) bool {
316 var pe *protocolError
317 if !errors.As(err, &pe) {
318 return false
319 }
320 msg := strings.ToLower(pe.Message)
321 return strings.Contains(msg, "cannot find context") || strings.Contains(msg, "execution context was destroyed")
322 }
323
324 // pageState is the isolated world's view of the document between calls.
325 type pageState struct {
326 UserSeq int64 `json:"userSeq"`
327 URL string `json:"url"`
328 Title string `json:"title"`
329 Ready string `json:"ready"`
330 }
331
332 // observe reads the document's state and decides whether a human touched the
333 // page since the last snapshot. The take-over flag is sticky: only a fresh
334 // snapshot clears it, matching the shell's rule that the agent resumes after
335 // re-reading the page.
336 func (e *Executor) observe(ctx context.Context, p *page) (pageState, error) {
337 var st pageState
338 if err := e.eval(ctx, p, "__rx.state()", &st); err != nil {
339 return pageState{}, err
340 }
341 p.mu.Lock()
342 defer p.mu.Unlock()
343 p.url, p.title = st.URL, st.Title
344 if st.UserSeq != p.doc.lastSeq {
345 p.doc.lastSeq = st.UserSeq
346 p.doc.takenOver = true
347 }
348 return st, nil
349 }
350
351 // markAgentInput opens the window in which trusted input is the executor's own
352 // rather than the user's.
353 func (e *Executor) markAgentInput(ctx context.Context, p *page, windowMillis int) error {
354 var seq int64
355 if err := e.eval(ctx, p, "__rx.window("+strconv.Itoa(windowMillis)+")", &seq); err != nil {
356 return err
357 }
358 p.mu.Lock()
359 p.doc.lastSeq = seq
360 p.mu.Unlock()
361 return nil
362 }
363
363 lines GO