@@ -66,6 +66,8 @@ func (r *Runner) executeStep(ctx context.Context, step models.TaskStep, result *
6666 return r .execGetTitle (ctx , step , result )
6767 case models .ActionGetAttributes :
6868 return r .execGetAttributes (ctx , step , result )
69+ case models .ActionClickAd :
70+ return r .execClickAd (ctx , step , result )
6971 default :
7072 return fmt .Errorf ("unknown action: %s" , step .Action )
7173 }
@@ -424,3 +426,184 @@ func (r *Runner) execGetAttributes(ctx context.Context, step models.TaskStep, re
424426 }
425427 return nil
426428}
429+
430+ // adDiscoveryScript is injected into the page to find a visible ad element.
431+ // It returns a JSON-serialisable object with the ad's bounding rect and metadata.
432+ const adDiscoveryScript = `(function() {
433+ var selectors = [
434+ 'ins.adsbygoogle',
435+ 'iframe[id*="google_ads"]',
436+ 'iframe[id*="aswift"]',
437+ 'div[id*="google_ads"]',
438+ 'div[id*="ad-container"]',
439+ 'div[class*="ad-slot"]',
440+ 'div[class*="ad-wrapper"]',
441+ 'div[data-ad]',
442+ 'iframe[data-google-container-id]',
443+ 'a[href*="googleads"]',
444+ 'a[href*="doubleclick"]'
445+ ];
446+ for (var i = 0; i < selectors.length; i++) {
447+ var el = document.querySelector(selectors[i]);
448+ if (el) {
449+ var rect = el.getBoundingClientRect();
450+ if (rect.width > 0 && rect.height > 0) {
451+ return {
452+ found: true,
453+ selector: selectors[i],
454+ tag: el.tagName.toLowerCase(),
455+ href: el.href || el.src || '',
456+ x: Math.round(rect.x + rect.width / 2),
457+ y: Math.round(rect.y + rect.height / 2)
458+ };
459+ }
460+ }
461+ }
462+ return { found: false };
463+ })()`
464+
465+ // adClickAtScript dispatches a mouse click at the given page coordinates.
466+ const adClickAtScript = `(function(x, y) {
467+ var el = document.elementFromPoint(x, y);
468+ if (el) {
469+ el.dispatchEvent(new MouseEvent('click', {
470+ bubbles: true, cancelable: true, view: window,
471+ clientX: x, clientY: y
472+ }));
473+ return true;
474+ }
475+ return false;
476+ })`
477+
478+ type adDiscoveryResult struct {
479+ Found bool `json:"found"`
480+ Selector string `json:"selector"`
481+ Tag string `json:"tag"`
482+ Href string `json:"href"`
483+ X float64 `json:"x"`
484+ Y float64 `json:"y"`
485+ }
486+
487+ // captureAdScreenshot takes a full-page screenshot labelled with the given tag
488+ // (e.g. "before", "after") and stores the path in both result.Screenshots and
489+ // result.ExtractedData. Errors are non-fatal and returned for the caller to log.
490+ func (r * Runner ) captureAdScreenshot (ctx context.Context , result * models.TaskResult , keyPrefix , label string ) (string , error ) {
491+ var buf []byte
492+ if err := r .exec .Run (ctx , chromedp .FullScreenshot (& buf , 100 )); err != nil {
493+ return "" , fmt .Errorf ("capture ad screenshot (%s): %w" , label , err )
494+ }
495+ sanitizedID := sanitizeFilename (result .TaskID )
496+ filename := fmt .Sprintf ("%s_ad_%s_%d.png" , sanitizedID , label , time .Now ().UnixMilli ())
497+ path := filepath .Join (r .screenshotDir , filename )
498+ if ! strings .HasPrefix (path , filepath .Clean (r .screenshotDir )+ string (os .PathSeparator )) {
499+ return "" , fmt .Errorf ("ad screenshot path escapes screenshot directory" )
500+ }
501+ if err := os .WriteFile (path , buf , 0o644 ); err != nil {
502+ return "" , fmt .Errorf ("save ad screenshot (%s): %w" , label , err )
503+ }
504+ result .Screenshots = append (result .Screenshots , path )
505+ result .ExtractedData [keyPrefix + "_screenshot_" + label ] = path
506+ return path , nil
507+ }
508+
509+ func (r * Runner ) execClickAd (ctx context.Context , step models.TaskStep , result * models.TaskResult ) error {
510+ keyPrefix := "ad"
511+ if step .VarName != "" {
512+ keyPrefix = step .VarName
513+ }
514+
515+ // If a selector is explicitly provided, use it directly.
516+ if strings .TrimSpace (step .Selector ) != "" {
517+ // Try to extract metadata before clicking.
518+ metaJS := fmt .Sprintf (`(function() {
519+ var el = document.querySelector(%q);
520+ if (!el) return { found: false };
521+ var rect = el.getBoundingClientRect();
522+ return {
523+ found: true,
524+ selector: %q,
525+ tag: el.tagName.toLowerCase(),
526+ href: el.href || el.src || '',
527+ x: Math.round(rect.x + rect.width / 2),
528+ y: Math.round(rect.y + rect.height / 2)
529+ };
530+ })()` , step .Selector , step .Selector )
531+
532+ var info adDiscoveryResult
533+ if err := r .exec .Run (ctx , chromedp .Evaluate (metaJS , & info )); err != nil {
534+ return fmt .Errorf ("click_ad: evaluate selector metadata: %w" , err )
535+ }
536+ if ! info .Found {
537+ return fmt .Errorf ("click_ad: element not found for selector %q" , step .Selector )
538+ }
539+
540+ result .ExtractedData [keyPrefix + "_selector" ] = info .Selector
541+ result .ExtractedData [keyPrefix + "_tag" ] = info .Tag
542+ result .ExtractedData [keyPrefix + "_href" ] = info .Href
543+
544+ // Capture before-click screenshot.
545+ if _ , err := r .captureAdScreenshot (ctx , result , keyPrefix , "before" ); err != nil {
546+ r .addLog (result , "warn" , fmt .Sprintf ("click_ad: %v" , err ))
547+ }
548+
549+ // For iframes, dispatch a coordinate-based click since we can't enter cross-origin frames.
550+ if info .Tag == "iframe" {
551+ clickJS := fmt .Sprintf (`(%s)(%v, %v)` , adClickAtScript , info .X , info .Y )
552+ var clicked bool
553+ if err := r .exec .Run (ctx , chromedp .Evaluate (clickJS , & clicked )); err != nil {
554+ return fmt .Errorf ("click_ad: dispatch click on iframe: %w" , err )
555+ }
556+ if ! clicked {
557+ return fmt .Errorf ("click_ad: no element at iframe center (%v, %v)" , info .X , info .Y )
558+ }
559+ } else {
560+ // Regular element — use standard chromedp click.
561+ if err := r .exec .Run (ctx ,
562+ chromedp .WaitVisible (step .Selector , chromedp .ByQuery ),
563+ chromedp .Click (step .Selector , chromedp .ByQuery ),
564+ ); err != nil {
565+ return err
566+ }
567+ }
568+
569+ // Capture after-click screenshot.
570+ if _ , err := r .captureAdScreenshot (ctx , result , keyPrefix , "after" ); err != nil {
571+ r .addLog (result , "warn" , fmt .Sprintf ("click_ad: %v" , err ))
572+ }
573+ return nil
574+ }
575+
576+ // No selector provided — auto-discover an ad element.
577+ var info adDiscoveryResult
578+ if err := r .exec .Run (ctx , chromedp .Evaluate (adDiscoveryScript , & info )); err != nil {
579+ return fmt .Errorf ("click_ad: ad discovery failed: %w" , err )
580+ }
581+ if ! info .Found {
582+ return fmt .Errorf ("click_ad: no ad element found on page" )
583+ }
584+
585+ result .ExtractedData [keyPrefix + "_selector" ] = info .Selector
586+ result .ExtractedData [keyPrefix + "_tag" ] = info .Tag
587+ result .ExtractedData [keyPrefix + "_href" ] = info .Href
588+
589+ // Capture before-click screenshot.
590+ if _ , err := r .captureAdScreenshot (ctx , result , keyPrefix , "before" ); err != nil {
591+ r .addLog (result , "warn" , fmt .Sprintf ("click_ad: %v" , err ))
592+ }
593+
594+ // Dispatch a coordinate-based click (works for both iframes and regular elements).
595+ clickJS := fmt .Sprintf (`(%s)(%v, %v)` , adClickAtScript , info .X , info .Y )
596+ var clicked bool
597+ if err := r .exec .Run (ctx , chromedp .Evaluate (clickJS , & clicked )); err != nil {
598+ return fmt .Errorf ("click_ad: dispatch click: %w" , err )
599+ }
600+ if ! clicked {
601+ return fmt .Errorf ("click_ad: no element at coordinates (%v, %v)" , info .X , info .Y )
602+ }
603+
604+ // Capture after-click screenshot.
605+ if _ , err := r .captureAdScreenshot (ctx , result , keyPrefix , "after" ); err != nil {
606+ r .addLog (result , "warn" , fmt .Sprintf ("click_ad: %v" , err ))
607+ }
608+ return nil
609+ }
0 commit comments