This commit is contained in:
@@ -188,84 +188,21 @@ async def interactions_service(
|
|||||||
|
|
||||||
async def interactions_operation(page):
|
async def interactions_operation(page):
|
||||||
try:
|
try:
|
||||||
# When the API caller sets viewport.height = -1, treat that as "capture full webpage".
|
# Interactions endpoint policy:
|
||||||
# We implement this by resizing the viewport to the document's scroll height just
|
# - Disable all scrolling features (ignore scrollX/scrollY and do not auto-scroll).
|
||||||
# before the final screenshot. (This keeps existing coordinate-click behavior intact.)
|
# - Force viewport width to 1280.
|
||||||
full_page_via_viewport = False
|
# - Return a full-height screenshot (full_page=True) when return_type != "html".
|
||||||
requested_viewport_width = None
|
forced_viewport_width = 1280
|
||||||
|
|
||||||
async def resolve_viewport_point(px: int, py: int) -> tuple[float, float]:
|
async def resolve_viewport_point(px: int, py: int) -> tuple[float, float]:
|
||||||
"""
|
"""
|
||||||
`document.elementFromPoint(x, y)` uses *viewport* (CSS pixel) coordinates.
|
`document.elementFromPoint(x, y)` uses *viewport* (CSS pixel) coordinates.
|
||||||
|
|
||||||
This API also supports *document* coordinates (e.g. from a full-page
|
With scrolling disabled, we keep the page scrolled to the top and expand the
|
||||||
screenshot). In that case we must scroll so the point is inside the viewport,
|
viewport height to the document height (best-effort). That makes document and
|
||||||
then convert to viewport coordinates by subtracting the current scroll offsets.
|
viewport coordinates equivalent for typical pages.
|
||||||
"""
|
"""
|
||||||
x = float(px)
|
return float(px), float(py)
|
||||||
y = float(py)
|
|
||||||
|
|
||||||
vp = await page.evaluate(
|
|
||||||
"() => ({ innerWidth: window.innerWidth, innerHeight: window.innerHeight, scrollX: window.scrollX, scrollY: window.scrollY })"
|
|
||||||
)
|
|
||||||
inner_w = float(vp.get("innerWidth") or 0)
|
|
||||||
inner_h = float(vp.get("innerHeight") or 0)
|
|
||||||
cur_scroll_x = float(vp.get("scrollX") or 0)
|
|
||||||
cur_scroll_y = float(vp.get("scrollY") or 0)
|
|
||||||
|
|
||||||
# If the point is plausibly already a viewport coordinate, use it directly.
|
|
||||||
# (We allow a small slack to account for fractional/rounded coordinates.)
|
|
||||||
if inner_w and inner_h and (-5.0 <= x <= inner_w + 5.0) and (-5.0 <= y <= inner_h + 5.0):
|
|
||||||
return x, y
|
|
||||||
|
|
||||||
# Otherwise treat as document coordinates and auto-scroll to bring it into view.
|
|
||||||
# We may need to iterate because some pages clamp/adjust scroll requests.
|
|
||||||
if inner_w and inner_h:
|
|
||||||
for _ in range(6):
|
|
||||||
metrics = await page.evaluate(
|
|
||||||
"() => ({ innerWidth: window.innerWidth, innerHeight: window.innerHeight, scrollX: window.scrollX, scrollY: window.scrollY, scrollW: Math.max(document.body?.scrollWidth||0, document.documentElement?.scrollWidth||0), scrollH: Math.max(document.body?.scrollHeight||0, document.documentElement?.scrollHeight||0) })"
|
|
||||||
)
|
|
||||||
inner_w = float(metrics.get("innerWidth") or inner_w or 0)
|
|
||||||
inner_h = float(metrics.get("innerHeight") or inner_h or 0)
|
|
||||||
cur_scroll_x = float(metrics.get("scrollX") or 0)
|
|
||||||
cur_scroll_y = float(metrics.get("scrollY") or 0)
|
|
||||||
scroll_w = float(metrics.get("scrollW") or 0)
|
|
||||||
scroll_h = float(metrics.get("scrollH") or 0)
|
|
||||||
|
|
||||||
max_scroll_x = max(0.0, scroll_w - inner_w) if inner_w else 0.0
|
|
||||||
max_scroll_y = max(0.0, scroll_h - inner_h) if inner_h else 0.0
|
|
||||||
|
|
||||||
vx = x - cur_scroll_x
|
|
||||||
vy = y - cur_scroll_y
|
|
||||||
|
|
||||||
# If the mapped viewport point is inside the viewport, we're done.
|
|
||||||
if (0.0 <= vx <= max(inner_w - 1.0, 0.0)) and (0.0 <= vy <= max(inner_h - 1.0, 0.0)):
|
|
||||||
# Avoid exact-edge coordinates which sometimes hit nothing (scrollbars).
|
|
||||||
vx = min(max(vx, 0.5), max(inner_w - 1.5, 0.5))
|
|
||||||
vy = min(max(vy, 0.5), max(inner_h - 1.5, 0.5))
|
|
||||||
return vx, vy
|
|
||||||
|
|
||||||
# Scroll so the point lands near the center of the viewport.
|
|
||||||
target_scroll_x = x - (inner_w / 2.0)
|
|
||||||
target_scroll_y = y - (inner_h / 2.0)
|
|
||||||
|
|
||||||
target_scroll_x = min(max(target_scroll_x, 0.0), max_scroll_x)
|
|
||||||
target_scroll_y = min(max(target_scroll_y, 0.0), max_scroll_y)
|
|
||||||
|
|
||||||
await page.evaluate(
|
|
||||||
"""({ sx, sy }) => {
|
|
||||||
window.scrollTo({ left: sx, top: sy, behavior: 'auto' });
|
|
||||||
}""",
|
|
||||||
{"sx": target_scroll_x, "sy": target_scroll_y},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Give the browser a moment to reflow after scroll.
|
|
||||||
await page.wait_for_timeout(150)
|
|
||||||
|
|
||||||
# If we couldn't get reliable viewport metrics, fall back to best-effort conversion.
|
|
||||||
vx = x - cur_scroll_x
|
|
||||||
vy = y - cur_scroll_y
|
|
||||||
return vx, vy
|
|
||||||
|
|
||||||
async def element_from_point_retry(vx: float, vy: float, attempts: int = 10):
|
async def element_from_point_retry(vx: float, vy: float, attempts: int = 10):
|
||||||
"""Retry `elementFromPoint` to avoid timing issues around scroll/render."""
|
"""Retry `elementFromPoint` to avoid timing issues around scroll/render."""
|
||||||
@@ -296,27 +233,12 @@ async def interactions_service(
|
|||||||
await page.wait_for_timeout(100)
|
await page.wait_for_timeout(100)
|
||||||
raise ValueError(last_error or f"No element found at viewport point ({vx}, {vy})")
|
raise ValueError(last_error or f"No element found at viewport point ({vx}, {vy})")
|
||||||
|
|
||||||
# Configure viewport if provided (before navigation).
|
# Force viewport width to 1280 (ignore payload viewport for interactions endpoint).
|
||||||
if viewport is not None:
|
try:
|
||||||
try:
|
vp_size = page.viewport_size or {"width": forced_viewport_width, "height": 720}
|
||||||
if isinstance(viewport, dict):
|
await page.set_viewport_size({"width": forced_viewport_width, "height": int(vp_size.get("height", 720))})
|
||||||
vp_width = viewport.get("width")
|
except Exception as e:
|
||||||
vp_height = viewport.get("height")
|
print(f"Error setting forced viewport width: {e}")
|
||||||
else:
|
|
||||||
vp_width = getattr(viewport, "width", None)
|
|
||||||
vp_height = getattr(viewport, "height", None)
|
|
||||||
|
|
||||||
requested_viewport_width = vp_width
|
|
||||||
if vp_width and vp_height:
|
|
||||||
if int(vp_height) == -1:
|
|
||||||
# Defer height calculation until after navigation/interactions.
|
|
||||||
full_page_via_viewport = True
|
|
||||||
vp_size = page.viewport_size or {"width": 1280, "height": 720}
|
|
||||||
await page.set_viewport_size({"width": int(vp_width), "height": int(vp_size.get("height", 720))})
|
|
||||||
else:
|
|
||||||
await page.set_viewport_size({"width": int(vp_width), "height": int(vp_height)})
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error setting viewport: {e}")
|
|
||||||
|
|
||||||
# Navigate and wait for initial DOM.
|
# Navigate and wait for initial DOM.
|
||||||
await page.goto(decoded_url, wait_until="domcontentloaded", timeout=30000)
|
await page.goto(decoded_url, wait_until="domcontentloaded", timeout=30000)
|
||||||
@@ -342,60 +264,25 @@ async def interactions_service(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Apply initial scroll position if provided.
|
# Disable scrolling: always keep page at the top.
|
||||||
# Do this AFTER cookie/banner clicks since those can change scroll position.
|
try:
|
||||||
if scroll_x is not None or scroll_y is not None:
|
await page.evaluate("() => window.scrollTo({ left: 0, top: 0, behavior: 'auto' })")
|
||||||
try:
|
await page.wait_for_timeout(250)
|
||||||
await page.evaluate(
|
except Exception:
|
||||||
"""(x, y) => {
|
pass
|
||||||
const left = (x === null ? window.scrollX : x);
|
|
||||||
const top = (y === null ? window.scrollY : y);
|
|
||||||
window.scrollTo({ left, top, behavior: 'auto' });
|
|
||||||
}""",
|
|
||||||
scroll_x if scroll_x is not None else None,
|
|
||||||
scroll_y if scroll_y is not None else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait for scroll offsets to actually settle before doing coordinate clicks.
|
# Best-effort: expand viewport height to the document height (capped by Chromium).
|
||||||
if scroll_x is not None:
|
# This keeps coordinate interactions working without scrolling.
|
||||||
await page.wait_for_function(
|
try:
|
||||||
"(x) => Math.abs(window.scrollX - x) <= 2",
|
max_vp_h = 16384
|
||||||
scroll_x,
|
full_h = await page.evaluate(
|
||||||
timeout=10000,
|
"() => Math.max(document.body?.scrollHeight || 0, document.documentElement?.scrollHeight || 0)"
|
||||||
)
|
)
|
||||||
if scroll_y is not None:
|
target_h = int(min(max(int(full_h or 0), 1), max_vp_h))
|
||||||
await page.wait_for_function(
|
await page.set_viewport_size({"width": forced_viewport_width, "height": target_h})
|
||||||
"(y) => Math.abs(window.scrollY - y) <= 2",
|
await page.wait_for_timeout(250)
|
||||||
scroll_y,
|
except Exception as e:
|
||||||
timeout=10000,
|
print(f"Error resizing viewport for full-height interactions: {e}")
|
||||||
)
|
|
||||||
await page.wait_for_timeout(300)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error applying initial scroll position: {e}")
|
|
||||||
|
|
||||||
# If the caller requested full-page via viewport (viewport.height = -1), do the
|
|
||||||
# viewport expansion *now* so coordinate-based interactions can use viewport
|
|
||||||
# coordinates without requiring scrolling.
|
|
||||||
if full_page_via_viewport:
|
|
||||||
try:
|
|
||||||
max_vp_h = 16384
|
|
||||||
|
|
||||||
# Expand viewport height to the document height (capped), and reset scroll.
|
|
||||||
await page.evaluate("() => window.scrollTo({ left: 0, top: 0, behavior: 'auto' })")
|
|
||||||
await page.wait_for_timeout(250)
|
|
||||||
|
|
||||||
full_h = await page.evaluate(
|
|
||||||
"() => Math.max(document.body?.scrollHeight || 0, document.documentElement?.scrollHeight || 0)"
|
|
||||||
)
|
|
||||||
target_h = int(min(max(int(full_h or 0), 1), max_vp_h))
|
|
||||||
|
|
||||||
cur_vp = page.viewport_size or {"width": 1280, "height": 720}
|
|
||||||
target_w = int(requested_viewport_width or cur_vp.get("width", 1280))
|
|
||||||
|
|
||||||
await page.set_viewport_size({"width": target_w, "height": target_h})
|
|
||||||
await page.wait_for_timeout(500)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error resizing viewport for full-page interactions: {e}")
|
|
||||||
|
|
||||||
for interaction in interactions:
|
for interaction in interactions:
|
||||||
# Support both Pydantic model instances and raw dicts.
|
# Support both Pydantic model instances and raw dicts.
|
||||||
@@ -422,17 +309,34 @@ async def interactions_service(
|
|||||||
if selector:
|
if selector:
|
||||||
if action == "click":
|
if action == "click":
|
||||||
await page.wait_for_selector(selector, timeout=30000)
|
await page.wait_for_selector(selector, timeout=30000)
|
||||||
locator = page.locator(selector)
|
# Avoid Playwright auto-scrolling: click via DOM.
|
||||||
await locator.scroll_into_view_if_needed(timeout=30000)
|
await page.evaluate(
|
||||||
await locator.click(force=True, timeout=30000)
|
"""(sel) => {
|
||||||
|
const el = document.querySelector(sel);
|
||||||
|
if (!el) throw new Error(`No element for selector: ${sel}`);
|
||||||
|
el.click();
|
||||||
|
}""",
|
||||||
|
selector,
|
||||||
|
)
|
||||||
|
|
||||||
elif action == "type":
|
elif action == "type":
|
||||||
if text is None:
|
if text is None:
|
||||||
raise ValueError("Type interaction must contain text")
|
raise ValueError("Type interaction must contain text")
|
||||||
await page.wait_for_selector(selector, timeout=30000)
|
await page.wait_for_selector(selector, timeout=30000)
|
||||||
locator = page.locator(selector)
|
# Avoid Playwright auto-scrolling: focus/fill via DOM.
|
||||||
await locator.scroll_into_view_if_needed(timeout=30000)
|
await page.evaluate(
|
||||||
await locator.fill(text)
|
"""({ sel, value }) => {
|
||||||
|
const el = document.querySelector(sel);
|
||||||
|
if (!el) throw new Error(`No element for selector: ${sel}`);
|
||||||
|
el.focus?.();
|
||||||
|
if ('value' in el) {
|
||||||
|
el.value = value;
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
}""",
|
||||||
|
{"sel": selector, "value": text},
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown interaction action: {action}")
|
raise ValueError(f"Unknown interaction action: {action}")
|
||||||
@@ -491,31 +395,8 @@ async def interactions_service(
|
|||||||
if return_type == "html":
|
if return_type == "html":
|
||||||
return await page.content()
|
return await page.content()
|
||||||
|
|
||||||
if full_page_via_viewport:
|
# Always return full-page screenshot (full height).
|
||||||
try:
|
return await page.screenshot(full_page=True, type="png")
|
||||||
# Chromium has practical limits on viewport height; cap to avoid errors.
|
|
||||||
max_vp_h = 16384
|
|
||||||
|
|
||||||
# Ensure we capture from the top of the document.
|
|
||||||
await page.evaluate("() => window.scrollTo({ left: 0, top: 0, behavior: 'auto' })")
|
|
||||||
await page.wait_for_timeout(250)
|
|
||||||
|
|
||||||
full_h = await page.evaluate(
|
|
||||||
"() => Math.max(document.body?.scrollHeight || 0, document.documentElement?.scrollHeight || 0)"
|
|
||||||
)
|
|
||||||
target_h = int(min(max(full_h, 1), max_vp_h))
|
|
||||||
|
|
||||||
cur_vp = page.viewport_size or {"width": 1280, "height": 720}
|
|
||||||
target_w = int(requested_viewport_width or cur_vp.get("width", 1280))
|
|
||||||
|
|
||||||
await page.set_viewport_size({"width": target_w, "height": target_h})
|
|
||||||
await page.wait_for_timeout(500)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error resizing viewport for full-page capture: {e}")
|
|
||||||
|
|
||||||
# For coordinate-based workflows, default is viewport screenshot so pixel coords align.
|
|
||||||
# If full_page_via_viewport is enabled, the viewport has been expanded to the full document height.
|
|
||||||
return await page.screenshot(full_page=False, type="png")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error during interactions capture: {e}")
|
print(f"Error during interactions capture: {e}")
|
||||||
|
|||||||
Reference in New Issue
Block a user