22Browser Driver - Playwright-based stealth browser automation.
33
44Integrates with StealthBrowser for anti-detection and human-like behavior.
5+
6+ SR-150 extensions:
7+ - drag_element(source_sel, target_sel) — CDP-based drag operation
8+ - play_media(selector, max_seconds) — play video/audio and wait
59"""
10+
611from __future__ import annotations
712
813import asyncio
914import logging
15+ import random
1016from contextlib import asynccontextmanager
1117from dataclasses import dataclass
1218from pathlib import Path
2430@dataclass
2531class ElementInfo :
2632 """Information about a DOM element."""
33+
2734 selector : str
2835 tag : str
2936 text : str
@@ -39,6 +46,8 @@ class BrowserDriver:
3946
4047 Provides high-level API for survey automation with
4148 built-in anti-detection and human-like behavior.
49+
50+ SR-150: drag_element(), play_media() primitives added.
4251 """
4352
4453 def __init__ (
@@ -184,15 +193,17 @@ async def find_elements(self, selector: str) -> list[ElementInfo]:
184193 box = await element .bounding_box ()
185194 is_visible = await element .is_visible ()
186195
187- results .append (ElementInfo (
188- selector = f"{ selector } :nth-child({ i + 1 } )" ,
189- tag = tag ,
190- text = text .strip () if text else "" ,
191- attributes = {},
192- bounding_box = box ,
193- is_visible = is_visible ,
194- is_enabled = True ,
195- ))
196+ results .append (
197+ ElementInfo (
198+ selector = f"{ selector } :nth-child({ i + 1 } )" ,
199+ tag = tag ,
200+ text = text .strip () if text else "" ,
201+ attributes = {},
202+ bounding_box = box ,
203+ is_visible = is_visible ,
204+ is_enabled = True ,
205+ )
206+ )
196207 except Exception :
197208 continue
198209
@@ -360,3 +371,287 @@ def rotate_identity(self) -> None:
360371 """Rotate browser fingerprint and session."""
361372 self .stealth .rotate_session ()
362373 logger .info ("Rotated browser identity" )
374+
375+ # -------------------------------------------------------------------------
376+ # SR-150: Extended Primitives for drag-drop and media playback
377+ # -------------------------------------------------------------------------
378+
379+ async def drag_element (
380+ self ,
381+ source_sel : str ,
382+ target_sel : str ,
383+ jitter : bool = True ,
384+ ) -> bool :
385+ """SR-150: Drag element from source to target using CDP mouse events.
386+
387+ Implements realistic drag-and-drop with:
388+ - Human-like mouse path (10-step Bezier with timing jitter)
389+ - mouseDown → multiple mouseMoved → mouseUp sequence
390+ - No Playwright page.mouse.down() calls — pure CDP for stealth
391+
392+ Args:
393+ source_sel: CSS selector for drag source element
394+ target_sel: CSS selector for drop target element
395+ jitter: Add random timing/position jitter (default True)
396+
397+ Returns:
398+ True if drag completed successfully, False otherwise
399+ """
400+ try :
401+ # Get source element bounding box
402+ source = await self ._page .query_selector (source_sel )
403+ if not source :
404+ logger .warning (f"Drag source not found: { source_sel } " )
405+ return False
406+
407+ source_box = await source .bounding_box ()
408+ if not source_box :
409+ logger .warning (f"Drag source has no bounding box: { source_sel } " )
410+ return False
411+
412+ # Get target element bounding box
413+ target = await self ._page .query_selector (target_sel )
414+ if not target :
415+ logger .warning (f"Drag target not found: { target_sel } " )
416+ return False
417+
418+ target_box = await target .bounding_box ()
419+ if not target_box :
420+ logger .warning (f"Drag target has no bounding box: { target_sel } " )
421+ return False
422+
423+ # Calculate center points with optional jitter
424+ jitter_px = 5 if jitter else 0
425+ source_x = (
426+ source_box ["x" ] + source_box ["width" ] / 2 + random .randint (- jitter_px , jitter_px )
427+ )
428+ source_y = (
429+ source_box ["y" ] + source_box ["height" ] / 2 + random .randint (- jitter_px , jitter_px )
430+ )
431+ target_x = (
432+ target_box ["x" ] + target_box ["width" ] / 2 + random .randint (- jitter_px , jitter_px )
433+ )
434+ target_y = (
435+ target_box ["y" ] + target_box ["height" ] / 2 + random .randint (- jitter_px , jitter_px )
436+ )
437+
438+ # Get CDP session
439+ cdp = await self ._page .context .new_cdp_session (self ._page )
440+
441+ # Generate 10-step path with Bezier-like curve
442+ steps = 10
443+ path_points = []
444+ for i in range (steps + 1 ):
445+ t = i / steps
446+ # Quadratic bezier with control point offset for natural curve
447+ ctrl_x = (source_x + target_x ) / 2 + (target_y - source_y ) * 0.1
448+ ctrl_y = (source_y + target_y ) / 2 - (target_x - source_x ) * 0.1
449+ x = (1 - t ) ** 2 * source_x + 2 * (1 - t ) * t * ctrl_x + t ** 2 * target_x
450+ y = (1 - t ) ** 2 * source_y + 2 * (1 - t ) * t * ctrl_y + t ** 2 * target_y
451+ # Add micro-jitter
452+ if jitter and 0 < i < steps :
453+ x += random .uniform (- 2 , 2 )
454+ y += random .uniform (- 2 , 2 )
455+ path_points .append ((x , y ))
456+
457+ # Move to source first
458+ await cdp .send (
459+ "Input.dispatchMouseEvent" ,
460+ {
461+ "type" : "mouseMoved" ,
462+ "x" : source_x ,
463+ "y" : source_y ,
464+ },
465+ )
466+ await asyncio .sleep (random .uniform (0.05 , 0.15 ) if jitter else 0.05 )
467+
468+ # Mouse down
469+ await cdp .send (
470+ "Input.dispatchMouseEvent" ,
471+ {
472+ "type" : "mousePressed" ,
473+ "x" : source_x ,
474+ "y" : source_y ,
475+ "button" : "left" ,
476+ "clickCount" : 1 ,
477+ },
478+ )
479+ await asyncio .sleep (random .uniform (0.08 , 0.15 ) if jitter else 0.1 )
480+
481+ # Drag along path
482+ for x , y in path_points [1 :]:
483+ await cdp .send (
484+ "Input.dispatchMouseEvent" ,
485+ {
486+ "type" : "mouseMoved" ,
487+ "x" : x ,
488+ "y" : y ,
489+ "button" : "left" ,
490+ },
491+ )
492+ delay = random .uniform (0.02 , 0.06 ) if jitter else 0.03
493+ await asyncio .sleep (delay )
494+
495+ # Mouse up at target
496+ await cdp .send (
497+ "Input.dispatchMouseEvent" ,
498+ {
499+ "type" : "mouseReleased" ,
500+ "x" : target_x ,
501+ "y" : target_y ,
502+ "button" : "left" ,
503+ "clickCount" : 1 ,
504+ },
505+ )
506+
507+ await cdp .detach ()
508+ logger .debug (f"Dragged { source_sel } → { target_sel } " )
509+ return True
510+
511+ except Exception as e :
512+ logger .warning (f"Drag failed: { e } " )
513+ return False
514+
515+ async def play_media (
516+ self ,
517+ selector : str ,
518+ max_seconds : float | None = None ,
519+ ) -> float :
520+ """SR-150: Play video/audio element and wait for completion.
521+
522+ Uses CDP Runtime.evaluate to control media playback directly.
523+ Waits for the 'ended' event or max_seconds timeout.
524+
525+ Args:
526+ selector: CSS selector for <video> or <audio> element
527+ max_seconds: Maximum seconds to wait (None = wait for full duration)
528+
529+ Returns:
530+ Actual seconds played (may be less than duration if max_seconds hit)
531+ """
532+ try :
533+ # Get element and verify it's a media element
534+ element = await self ._page .query_selector (selector )
535+ if not element :
536+ logger .warning (f"Media element not found: { selector } " )
537+ return 0.0
538+
539+ tag = await element .evaluate ("el => el.tagName.toLowerCase()" )
540+ if tag not in ("video" , "audio" ):
541+ logger .warning (f"Element is not a media element: { selector } (tag={ tag } )" )
542+ return 0.0
543+
544+ # Get media duration via CDP
545+ cdp = await self ._page .context .new_cdp_session (self ._page )
546+
547+ # Get duration
548+ duration_result = await cdp .send (
549+ "Runtime.evaluate" ,
550+ {
551+ "expression" : f"document.querySelector('{ selector } ').duration || 30" ,
552+ "returnByValue" : True ,
553+ },
554+ )
555+ duration = duration_result .get ("result" , {}).get ("value" , 30.0 )
556+
557+ # Cap at max_seconds if specified
558+ if max_seconds is not None and duration > max_seconds :
559+ duration = max_seconds
560+
561+ # Warn for very long media
562+ if duration > 120 :
563+ logger .warning (f"Long media detected ({ duration } s), proceeding anyway: { selector } " )
564+
565+ # For audio, mute before playing (avoid noise on host)
566+ if tag == "audio" :
567+ await cdp .send (
568+ "Runtime.evaluate" ,
569+ {
570+ "expression" : f"document.querySelector('{ selector } ').muted = true" ,
571+ },
572+ )
573+
574+ # Start playback
575+ await cdp .send (
576+ "Runtime.evaluate" ,
577+ {
578+ "expression" : f"document.querySelector('{ selector } ').play()" ,
579+ },
580+ )
581+ logger .debug (f"Started playing { tag } : { selector } (duration={ duration } s)" )
582+
583+ # Wait for media to complete (with small jitter buffer)
584+ jitter = random .uniform (0.3 , 0.8 )
585+ await asyncio .sleep (duration + jitter )
586+
587+ # Pause to ensure clean state
588+ await cdp .send (
589+ "Runtime.evaluate" ,
590+ {
591+ "expression" : f"document.querySelector('{ selector } ').pause()" ,
592+ },
593+ )
594+
595+ await cdp .detach ()
596+ logger .debug (f"Finished playing { tag } : { selector } " )
597+ return duration
598+
599+ except Exception as e :
600+ logger .warning (f"Media playback failed: { e } " )
601+ return 0.0
602+
603+ async def click_at (self , x : int , y : int ) -> bool :
604+ """Click at specific coordinates (for hotspot questions).
605+
606+ Args:
607+ x: X coordinate
608+ y: Y coordinate
609+
610+ Returns:
611+ True if click succeeded
612+ """
613+ try :
614+ cdp = await self ._page .context .new_cdp_session (self ._page )
615+
616+ # Move to position
617+ await cdp .send (
618+ "Input.dispatchMouseEvent" ,
619+ {
620+ "type" : "mouseMoved" ,
621+ "x" : x ,
622+ "y" : y ,
623+ },
624+ )
625+ await asyncio .sleep (random .uniform (0.05 , 0.1 ))
626+
627+ # Click
628+ await cdp .send (
629+ "Input.dispatchMouseEvent" ,
630+ {
631+ "type" : "mousePressed" ,
632+ "x" : x ,
633+ "y" : y ,
634+ "button" : "left" ,
635+ "clickCount" : 1 ,
636+ },
637+ )
638+ await asyncio .sleep (random .uniform (0.05 , 0.1 ))
639+
640+ await cdp .send (
641+ "Input.dispatchMouseEvent" ,
642+ {
643+ "type" : "mouseReleased" ,
644+ "x" : x ,
645+ "y" : y ,
646+ "button" : "left" ,
647+ "clickCount" : 1 ,
648+ },
649+ )
650+
651+ await cdp .detach ()
652+ logger .debug (f"Clicked at ({ x } , { y } )" )
653+ return True
654+
655+ except Exception as e :
656+ logger .warning (f"Click at coordinates failed: { e } " )
657+ return False
0 commit comments