-
Notifications
You must be signed in to change notification settings - Fork 110
Expand file tree
/
Copy pathpage.py
More file actions
709 lines (613 loc) · 28.2 KB
/
page.py
File metadata and controls
709 lines (613 loc) · 28.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
import asyncio
import time
from typing import Optional, Union
from playwright.async_api import CDPSession, Page
from pydantic import BaseModel
from stagehand.handlers.act_handler import ActHandler
from stagehand.handlers.extract_handler import ExtractHandler
from stagehand.handlers.observe_handler import ObserveHandler
from .schemas import (
DEFAULT_EXTRACT_SCHEMA,
ActOptions,
ActResult,
ExtractOptions,
ExtractResult,
ObserveOptions,
ObserveResult,
)
from .types import DefaultExtractSchema, EmptyExtractSchema
_INJECTION_SCRIPT = None
class StagehandPage:
"""Wrapper around Playwright Page that integrates with Stagehand server"""
_cdp_client: Optional[CDPSession] = None
def __init__(self, page: Page, stagehand_client, context=None):
"""
Initialize a StagehandPage instance.
Args:
page (Page): The underlying Playwright page.
stagehand_client: The client used to interface with the Stagehand server.
context: The StagehandContext instance (optional).
"""
self._page = page
self._stagehand = stagehand_client
self._context = context
self._frame_id = None
@property
def frame_id(self) -> Optional[str]:
"""Get the current root frame ID."""
return self._frame_id
def update_root_frame_id(self, new_id: str):
"""Update the root frame ID."""
self._frame_id = new_id
self._stagehand.logger.debug(f"Updated frame ID to {new_id}", category="page")
# TODO try catch here
async def ensure_injection(self):
"""Ensure custom injection scripts are present on the page using domScripts.js."""
exists_before = await self._page.evaluate(
"typeof window.getScrollableElementXpaths === 'function'"
)
if not exists_before:
global _INJECTION_SCRIPT
if _INJECTION_SCRIPT is None:
import os
script_path = os.path.join(os.path.dirname(__file__), "domScripts.js")
try:
with open(script_path) as f:
_INJECTION_SCRIPT = f.read()
except Exception as e:
self._stagehand.logger.error(f"Error reading domScripts.js: {e}")
_INJECTION_SCRIPT = "/* fallback injection script */"
# Inject the script into the current page context
await self._page.evaluate(_INJECTION_SCRIPT)
# Ensure that the script is injected on future navigations
await self._page.add_init_script(_INJECTION_SCRIPT)
async def goto(
self,
url: str,
*,
referer: Optional[str] = None,
timeout: Optional[int] = None,
wait_until: Optional[str] = None,
):
"""
Navigate to URL using the Stagehand server.
Args:
url (str): The URL to navigate to.
referer (Optional[str]): Optional referer URL.
timeout (Optional[int]): Optional navigation timeout in milliseconds.
wait_until (Optional[str]): Optional wait condition; one of ('load', 'domcontentloaded', 'networkidle', 'commit').
Returns:
The result from the Stagehand server's navigation execution.
"""
if not self._stagehand.use_api:
await self._page.goto(
url, referer=referer, timeout=timeout, wait_until=wait_until
)
return
options = {}
if referer is not None:
options["referer"] = referer
if timeout is not None:
options["timeout"] = timeout
if wait_until is not None:
options["wait_until"] = wait_until
options["waitUntil"] = wait_until
payload = {"url": url}
if options:
payload["options"] = options
# Add frame ID if available
if self._frame_id:
payload["frameId"] = self._frame_id
lock = self._stagehand._get_lock_for_session()
async with lock:
result = await self._stagehand._execute("navigate", payload)
return result
async def act(
self, action_or_result: Union[str, ObserveResult, dict], **kwargs
) -> ActResult:
"""
Execute an AI action or a pre-observed action via the Stagehand server.
Args:
action_or_result (Union[str, ObserveResult]):
- A string with the action command to be executed by the AI.
- An ObserveResult containing selector and method for direct execution.
**kwargs: Additional options corresponding to fields in ActOptions
(e.g., model_name, timeout_ms). These are ignored if
action_or_result is an ObserveResult.
Returns:
ActResult: The result from the Stagehand server's action execution.
"""
await self.ensure_injection()
payload: dict
# Check if it's an ObserveResult for direct execution
if isinstance(action_or_result, ObserveResult):
if kwargs:
self._stagehand.logger.debug(
"Additional keyword arguments provided to 'act' when using an ObserveResult are ignored."
)
payload = action_or_result.model_dump(exclude_none=True, by_alias=True)
# If it's a string, construct ActOptions using the string and kwargs
elif isinstance(action_or_result, str):
options = ActOptions(action=action_or_result, **kwargs)
payload = options.model_dump(exclude_none=True, by_alias=True)
elif isinstance(action_or_result, ActOptions):
payload = action_or_result.model_dump(exclude_none=True, by_alias=True)
elif isinstance(action_or_result, dict):
if "description" in action_or_result:
payload = ObserveResult(**action_or_result).model_dump(
exclude_none=True, by_alias=True
)
else:
payload = ActOptions(**action_or_result).model_dump(
exclude_none=True, by_alias=True
)
else:
raise TypeError(
"Invalid arguments for 'act'. Expected str, ObserveResult, or ActOptions."
)
# TODO: Temporary until we move api based logic to client
if not self._stagehand.use_api:
# TODO: revisit passing user_provided_instructions
if not hasattr(self, "_observe_handler"):
# TODO: revisit handlers initialization on page creation
self._observe_handler = ObserveHandler(self, self._stagehand, "")
if not hasattr(self, "_act_handler"):
self._act_handler = ActHandler(
self, self._stagehand, "", self._stagehand.self_heal
)
self._stagehand.logger.debug("act", category="act", auxiliary=payload)
if payload.get("iframes"):
raise ValueError(
"iframes is not yet supported without API (to enable make sure you set env=BROWSERBASE and use_api=true)"
)
result = await self._act_handler.act(payload)
return result
# Add frame ID if available
if self._frame_id:
payload["frameId"] = self._frame_id
lock = self._stagehand._get_lock_for_session()
async with lock:
result = await self._stagehand._execute("act", payload)
if isinstance(result, dict):
return ActResult(**result)
return result
async def observe(
self,
options_or_instruction: Union[str, ObserveOptions, None] = None,
**kwargs,
) -> list[ObserveResult]:
"""
Make an AI observation via the Stagehand server.
Args:
options_or_instruction (Union[str, ObserveOptions, None]):
- A string with the observation instruction for the AI.
- An ObserveOptions object.
- None to use default options.
**kwargs: Additional options corresponding to fields in ObserveOptions
(e.g., model_name, only_visible, return_action).
Returns:
list[ObserveResult]: A list of observation results from the Stagehand server.
"""
await self.ensure_injection()
options_dict = {}
if isinstance(options_or_instruction, ObserveOptions):
# Already a pydantic object – take it as is.
options_obj = options_or_instruction
else:
if isinstance(options_or_instruction, str):
options_dict["instruction"] = options_or_instruction
# Merge any explicit keyword arguments (highest priority)
options_dict.update(kwargs)
if not options_dict:
raise TypeError("No instruction provided for observe.")
try:
options_obj = ObserveOptions(**options_dict)
except Exception as e:
raise TypeError(f"Invalid observe options: {e}") from e
# Serialized payload for server / local handlers
payload = options_obj.model_dump(exclude_none=True, by_alias=True)
# If in LOCAL mode, use local implementation
if not self._stagehand.use_api:
self._stagehand.logger.debug(
"observe", category="observe", auxiliary=payload
)
# If we don't have an observe handler yet, create one
# TODO: revisit passing user_provided_instructions
if not hasattr(self, "_observe_handler"):
self._observe_handler = ObserveHandler(self, self._stagehand, "")
# Call local observe implementation
result = await self._observe_handler.observe(
options_obj,
from_act=False,
)
return result
# Add frame ID if available
if self._frame_id:
payload["frameId"] = self._frame_id
lock = self._stagehand._get_lock_for_session()
async with lock:
result = await self._stagehand._execute("observe", payload)
# Convert raw result to list of ObserveResult models
if isinstance(result, list):
return [ObserveResult(**item) for item in result]
elif isinstance(result, dict):
# If single dict, wrap in list (should ideally be list from server)
return [ObserveResult(**result)]
# Handle unexpected return types
self._stagehand.logger.info(
f"Unexpected result type from observe: {type(result)}"
)
return []
async def extract(
self,
options_or_instruction: Union[str, ExtractOptions, None] = None,
*,
schema: Optional[type[BaseModel]] = None,
**kwargs,
) -> ExtractResult:
"""
Extract data using AI via the Stagehand server.
Args:
options_or_instruction (Union[str, ExtractOptions, None]):
- A string with the instruction specifying what data to extract.
- An ExtractOptions object.
- None to extract the entire page content.
schema (Optional[Union[type[BaseModel], None]]):
A Pydantic model class that defines the structure
of the expected extracted data.
**kwargs: Additional options corresponding to fields in ExtractOptions
(e.g., model_name, use_text_extract, selector, dom_settle_timeout_ms).
Returns:
ExtractResult: Depending on the type of the schema provided, the result will be a Pydantic model or JSON representation of the extracted data.
"""
await self.ensure_injection()
options_dict = {}
if isinstance(options_or_instruction, ExtractOptions):
options_obj = options_or_instruction
else:
if isinstance(options_or_instruction, str):
options_dict["instruction"] = options_or_instruction
# Merge keyword overrides (highest priority)
options_dict.update(kwargs)
# Ensure schema_definition is only set once (explicit arg precedence)
if schema is not None:
options_dict["schema_definition"] = schema
if options_dict:
try:
options_obj = ExtractOptions(**options_dict)
except Exception as e:
raise TypeError(f"Invalid extract options: {e}") from e
else:
# No options_dict provided and no ExtractOptions given: full page extract.
options_obj = None
# If we started with an existing ExtractOptions instance and the caller
# explicitly provided a schema, override it
if (
schema is not None
and isinstance(options_obj, ExtractOptions)
and options_obj.schema_definition != schema
):
options_obj = options_obj.model_copy(update={"schema_definition": schema})
if options_obj is None:
payload = {}
else:
payload = options_obj.model_dump(exclude_none=True, by_alias=True)
# Determine the schema to pass to the handler
schema_to_validate_with = None
if (
options_obj is not None
and options_obj.schema_definition is not None
and options_obj.schema_definition != DEFAULT_EXTRACT_SCHEMA
):
if isinstance(options_obj.schema_definition, type) and issubclass(
options_obj.schema_definition, BaseModel
):
# Case 1: Pydantic model class
schema_to_validate_with = options_obj.schema_definition
elif isinstance(options_obj.schema_definition, dict):
# TODO: revisit this case to pass the json_schema since litellm has a bug when passing it directly
# Case 2: Dictionary
# Assume it's a direct JSON schema dictionary
schema_to_validate_with = options_obj.schema_definition
else:
schema_to_validate_with = DefaultExtractSchema
if not self._stagehand.use_api:
# If we don't have an extract handler yet, create one
if not hasattr(self, "_extract_handler"):
self._extract_handler = ExtractHandler(
self, self._stagehand, self._stagehand.system_prompt
)
# Allow for no options to extract the entire page
if options_obj is None:
# Call local extract implementation with no options
result = await self._extract_handler.extract(
None,
None, # Explicitly pass None for schema if no options
)
return result
# Call local extract implementation
result = await self._extract_handler.extract(
options_obj,
schema_to_validate_with,
)
return result.data
# Use API
# Add frame ID if available
if self._frame_id:
payload["frameId"] = self._frame_id
lock = self._stagehand._get_lock_for_session()
async with lock:
result_dict = await self._stagehand._execute("extract", payload)
if isinstance(result_dict, dict):
# Pydantic will validate against known fields + allow extras if configured
processed_data_payload = result_dict
if schema_to_validate_with and isinstance(processed_data_payload, dict):
try:
# For extract with no params
if not options_obj:
validated_model = EmptyExtractSchema.model_validate(
processed_data_payload
)
processed_data_payload = validated_model
else:
validated_model = schema_to_validate_with.model_validate(
processed_data_payload
)
processed_data_payload = validated_model
except Exception as e:
self._stagehand.logger.error(
f"Failed to validate extracted data against schema {schema_to_validate_with.__name__}: {e}. Keeping raw data dict in .data field."
)
return ExtractResult(data=processed_data_payload).data
# Handle unexpected return types
self._stagehand.logger.info(
f"Unexpected result type from extract: {type(result_dict)}"
)
return result_dict
# Method to get or initialize the persistent CDP client
async def get_cdp_client(self) -> CDPSession:
"""Gets the persistent CDP client, initializing it if necessary."""
# Check only if the client is None, rely on send_cdp's exception handling for disconnections
if self._cdp_client is None:
try:
self._stagehand.logger.debug("Creating new persistent CDP session.")
self._cdp_client = await self._page.context.new_cdp_session(self._page)
except Exception as e:
self._stagehand.logger.error(f"Failed to create CDP session: {e}")
raise # Re-raise the exception
return self._cdp_client
# Modified send_cdp to use the persistent client
async def send_cdp(self, method: str, params: Optional[dict] = None) -> dict:
"""Sends a CDP command using the persistent session."""
client = await self.get_cdp_client()
try:
# Type assertion might be needed depending on playwright version/typing
result = await client.send(method, params or {})
except Exception as e:
self._stagehand.logger.debug(
f"CDP command '{method}' failed: {e}. Attempting to reconnect..."
)
# Handle specific errors if needed (e.g., session closed)
if "Target closed" in str(e) or "Session closed" in str(e):
# Attempt to reset the client if the session closed unexpectedly
self._cdp_client = None
client = await self.get_cdp_client() # Try creating a new one
result = await client.send(method, params or {})
else:
raise # Re-raise other errors
return result
# Method to enable a specific CDP domain
async def enable_cdp_domain(self, domain: str):
"""Enables a specific CDP domain."""
try:
await self.send_cdp(f"{domain}.enable")
except Exception as e:
self._stagehand.logger.debug(f"Failed to enable CDP domain '{domain}': {e}")
# Method to disable a specific CDP domain
async def disable_cdp_domain(self, domain: str):
"""Disables a specific CDP domain."""
try:
await self.send_cdp(f"{domain}.disable")
except Exception:
# Ignore errors during disable, often happens during cleanup
pass
# Method to detach the persistent CDP client
async def detach_cdp_client(self):
"""Detaches the persistent CDP client if it exists."""
if self._cdp_client and self._cdp_client.is_connected():
try:
await self._cdp_client.detach()
self._cdp_client = None
except Exception as e:
self._stagehand.logger.debug(f"Error detaching CDP client: {e}")
self._cdp_client = None
async def _wait_for_settled_dom(self, timeout_ms: int = None):
"""
Wait for the DOM to settle (stop changing) before proceeding.
**Definition of "settled"**
• No in-flight network requests (except WebSocket / Server-Sent-Events).
• That idle state lasts for at least **500 ms** (the "quiet-window").
**How it works**
1. Subscribes to CDP Network and Page events for the main target and all
out-of-process iframes (via `Target.setAutoAttach { flatten:true }`).
2. Every time `Network.requestWillBeSent` fires, the request ID is added
to an **`inflight`** set.
3. When the request finishes—`loadingFinished`, `loadingFailed`,
`requestServedFromCache`, or a *data:* response—the request ID is
removed.
4. *Document* requests are also mapped **frameId → requestId**; when
`Page.frameStoppedLoading` fires the corresponding Document request is
removed immediately (covers iframes whose network events never close).
5. A **stalled-request sweep timer** runs every 500 ms. If a *Document*
request has been open for ≥ 2 s it is forcibly removed; this prevents
ad/analytics iframes from blocking the wait forever.
6. When `inflight` becomes empty the helper starts a 500 ms timer.
If no new request appears before the timer fires, the promise
resolves → **DOM is considered settled**.
7. A global guard (`timeoutMs` or `stagehand.domSettleTimeoutMs`,
default ≈ 30 s) ensures we always resolve; if it fires we log how many
requests were still outstanding.
Args:
timeout_ms (int, optional): Maximum time to wait in milliseconds.
If None, uses the stagehand client's dom_settle_timeout_ms.
"""
timeout = timeout_ms or getattr(self._stagehand, "dom_settle_timeout_ms", 30000)
client = await self.get_cdp_client()
# Check if document exists
try:
await self._page.title()
except Exception:
await self._page.wait_for_load_state("domcontentloaded")
# Enable CDP domains
await client.send("Network.enable")
await client.send("Page.enable")
await client.send(
"Target.setAutoAttach",
{
"autoAttach": True,
"waitForDebuggerOnStart": False,
"flatten": True,
"filter": [
{"type": "worker", "exclude": True},
{"type": "shared_worker", "exclude": True},
],
},
)
# Set up tracking structures
inflight = set() # Set of request IDs
meta = {} # Dict of request ID -> {"url": str, "start": float}
doc_by_frame = {} # Dict of frame ID -> request ID
# Event tracking
quiet_timer = None
stalled_request_sweep_task = None
loop = asyncio.get_event_loop()
done_event = asyncio.Event()
def clear_quiet():
nonlocal quiet_timer
if quiet_timer:
quiet_timer.cancel()
quiet_timer = None
def resolve_done():
"""Cleanup and mark as done"""
clear_quiet()
if stalled_request_sweep_task and not stalled_request_sweep_task.done():
stalled_request_sweep_task.cancel()
done_event.set()
def maybe_quiet():
"""Start quiet timer if no requests are in flight"""
nonlocal quiet_timer
if len(inflight) == 0 and not quiet_timer:
quiet_timer = loop.call_later(0.5, resolve_done)
def finish_req(request_id: str):
"""Mark a request as finished"""
if request_id not in inflight:
return
inflight.remove(request_id)
meta.pop(request_id, None)
# Remove from frame mapping
for fid, rid in list(doc_by_frame.items()):
if rid == request_id:
doc_by_frame.pop(fid)
clear_quiet()
maybe_quiet()
# Event handlers
def on_request(params):
"""Handle Network.requestWillBeSent"""
if params.get("type") in ["WebSocket", "EventSource"]:
return
request_id = params["requestId"]
inflight.add(request_id)
meta[request_id] = {"url": params["request"]["url"], "start": time.time()}
if params.get("type") == "Document" and params.get("frameId"):
doc_by_frame[params["frameId"]] = request_id
clear_quiet()
def on_finish(params):
"""Handle Network.loadingFinished"""
finish_req(params["requestId"])
def on_failed(params):
"""Handle Network.loadingFailed"""
finish_req(params["requestId"])
def on_cached(params):
"""Handle Network.requestServedFromCache"""
finish_req(params["requestId"])
def on_data_url(params):
"""Handle Network.responseReceived for data: URLs"""
if params.get("response", {}).get("url", "").startswith("data:"):
finish_req(params["requestId"])
def on_frame_stop(params):
"""Handle Page.frameStoppedLoading"""
frame_id = params["frameId"]
if frame_id in doc_by_frame:
finish_req(doc_by_frame[frame_id])
# Register event handlers
client.on("Network.requestWillBeSent", on_request)
client.on("Network.loadingFinished", on_finish)
client.on("Network.loadingFailed", on_failed)
client.on("Network.requestServedFromCache", on_cached)
client.on("Network.responseReceived", on_data_url)
client.on("Page.frameStoppedLoading", on_frame_stop)
async def sweep_stalled_requests():
"""Remove stalled document requests after 2 seconds"""
while not done_event.is_set():
await asyncio.sleep(0.5)
now = time.time()
for request_id, request_meta in list(meta.items()):
if now - request_meta["start"] > 2.0:
inflight.discard(request_id)
meta.pop(request_id, None)
self._stagehand.logger.debug(
"⏳ forcing completion of stalled iframe document",
auxiliary={"url": request_meta["url"][:120]},
)
maybe_quiet()
# Start stalled request sweeper
stalled_request_sweep_task = asyncio.create_task(sweep_stalled_requests())
# Set up timeout guard
async def timeout_guard():
await asyncio.sleep(timeout / 1000)
if not done_event.is_set():
if len(inflight) > 0:
self._stagehand.logger.debug(
"⚠️ DOM-settle timeout reached – network requests still pending",
auxiliary={"count": len(inflight)},
)
resolve_done()
timeout_task = asyncio.create_task(timeout_guard())
# Initial check
maybe_quiet()
try:
# Wait for completion
await done_event.wait()
finally:
# Cleanup
client.remove_listener("Network.requestWillBeSent", on_request)
client.remove_listener("Network.loadingFinished", on_finish)
client.remove_listener("Network.loadingFailed", on_failed)
client.remove_listener("Network.requestServedFromCache", on_cached)
client.remove_listener("Network.responseReceived", on_data_url)
client.remove_listener("Page.frameStoppedLoading", on_frame_stop)
if quiet_timer:
quiet_timer.cancel()
if stalled_request_sweep_task and not stalled_request_sweep_task.done():
stalled_request_sweep_task.cancel()
try:
await stalled_request_sweep_task
except asyncio.CancelledError:
pass
if timeout_task and not timeout_task.done():
timeout_task.cancel()
try:
await timeout_task
except asyncio.CancelledError:
pass
# Forward other Page methods to underlying Playwright page
def __getattr__(self, name):
"""
Forward attribute lookups to the underlying Playwright page.
Args:
name (str): Name of the attribute to access.
Returns:
The attribute from the underlying Playwright page.
"""
# self._stagehand.logger.debug(f"Getting attribute: {name}")
return getattr(self._page, name)