Skip to content

Commit 65df31a

Browse files
author
root
committed
feat: add click_ad action for ad element interaction
- Add new click_ad action that can find and click ad elements automatically - Support both auto-discovery of common ad selectors and explicit selector input - Add screenshot capture before and after ad clicks - Update frontend UI to include click_ad in action lists - Add comprehensive test coverage for all ad click scenarios - Update gitignore to exclude .blackboxcli/
1 parent 3af2059 commit 65df31a

6 files changed

Lines changed: 321 additions & 2 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ cover.out
1313

1414
# Session files
1515
session-*.md
16+
.blackboxcli/

‎frontend/src/components/CreateTaskModal.svelte‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
let errorMessage = '';
3030
let submitting = false;
3131
32-
const actions = ['navigate', 'click', 'type', 'wait', 'screenshot', 'extract', 'scroll', 'select', 'if_element', 'if_text', 'if_url', 'loop', 'end_loop', 'break_loop', 'goto', 'solve_captcha'];
32+
const actions = ['navigate', 'click', 'type', 'wait', 'screenshot', 'extract', 'scroll', 'select', 'if_element', 'if_text', 'if_url', 'loop', 'end_loop', 'break_loop', 'goto', 'solve_captcha', 'click_ad'];
3333
3434
ListProxyRoutingPresets().then((list) => {
3535
routingPresets = (list || []) as ProxyRoutingPreset[];

‎frontend/src/components/TaskDetail.svelte‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
let auditRequestSeq = 0;
3232
let lastAuditTaskId: string | null = null;
3333
34-
const actions = ['navigate', 'click', 'type', 'wait', 'screenshot', 'extract', 'scroll', 'select'];
34+
const actions = ['navigate', 'click', 'type', 'wait', 'screenshot', 'extract', 'scroll', 'select', 'click_ad'];
3535
3636
ListProxyRoutingPresets().then((list) => {
3737
routingPresets = (list || []) as ProxyRoutingPreset[];

‎internal/browser/actions_test.go‎

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,140 @@ func validExecutableStep(action models.StepAction) models.TaskStep {
393393
return models.TaskStep{Action: action, Value: "document.readyState === 'complete'"}
394394
case models.ActionEmulateDevice:
395395
return models.TaskStep{Action: action, Value: "375x812"}
396+
case models.ActionClickAd:
397+
return models.TaskStep{Action: action}
396398
default:
397399
return models.TaskStep{Action: action}
398400
}
399401
}
402+
403+
func TestExecClickAdWithSelector(t *testing.T) {
404+
mock := &mockExecutor{}
405+
r := newMockRunner(t, mock)
406+
result := &models.TaskResult{TaskID: "ad-sel", ExtractedData: make(map[string]string)}
407+
408+
// The mock executor returns zero-value from Evaluate, so adDiscoveryResult.Found
409+
// will be false. This verifies the selector path invokes the executor and handles
410+
// the "not found" case gracefully.
411+
err := r.execClickAd(context.Background(), models.TaskStep{Selector: "ins.adsbygoogle"}, result)
412+
if err == nil {
413+
t.Fatal("expected error for element not found via mock, got nil")
414+
}
415+
if !strings.Contains(err.Error(), "element not found") {
416+
t.Fatalf("expected 'element not found' error, got: %v", err)
417+
}
418+
if mock.callCount() != 1 {
419+
t.Fatalf("expected 1 executor call (metadata eval), got %d", mock.callCount())
420+
}
421+
}
422+
423+
func TestExecClickAdAutoDiscover(t *testing.T) {
424+
mock := &mockExecutor{}
425+
r := newMockRunner(t, mock)
426+
result := &models.TaskResult{TaskID: "ad-auto", ExtractedData: make(map[string]string)}
427+
428+
// Without a selector, execClickAd runs the discovery script.
429+
// The mock returns zero-value (found=false) so we expect an error.
430+
err := r.execClickAd(context.Background(), models.TaskStep{}, result)
431+
if err == nil {
432+
t.Fatal("expected error for no ad found, got nil")
433+
}
434+
if !strings.Contains(err.Error(), "no ad element found") {
435+
t.Fatalf("expected 'no ad element found' error, got: %v", err)
436+
}
437+
}
438+
439+
func TestExecClickAdSelectorNotFound(t *testing.T) {
440+
mock := &mockExecutor{}
441+
r := newMockRunner(t, mock)
442+
result := &models.TaskResult{TaskID: "ad-notfound", ExtractedData: make(map[string]string)}
443+
444+
// With a selector, the mock returns zero-value adDiscoveryResult (found=false).
445+
err := r.execClickAd(context.Background(), models.TaskStep{Selector: "#nonexistent-ad"}, result)
446+
if err == nil {
447+
t.Fatal("expected error for element not found, got nil")
448+
}
449+
if !strings.Contains(err.Error(), "element not found") {
450+
t.Fatalf("expected 'element not found' error, got: %v", err)
451+
}
452+
}
453+
454+
func TestExecClickAdError(t *testing.T) {
455+
mock := &mockExecutor{runErr: errors.New("ad click failed")}
456+
r := newMockRunner(t, mock)
457+
result := &models.TaskResult{TaskID: "ad-err", ExtractedData: make(map[string]string)}
458+
459+
err := r.execClickAd(context.Background(), models.TaskStep{Selector: "ins.adsbygoogle"}, result)
460+
if err == nil {
461+
t.Fatal("expected error, got nil")
462+
}
463+
if !strings.Contains(err.Error(), "ad click failed") {
464+
t.Fatalf("expected 'ad click failed' in error, got: %v", err)
465+
}
466+
}
467+
468+
func TestExecClickAdVarName(t *testing.T) {
469+
mock := &mockExecutor{}
470+
r := newMockRunner(t, mock)
471+
result := &models.TaskResult{TaskID: "ad-var", ExtractedData: make(map[string]string)}
472+
473+
// Without selector, discovery returns found=false, but we verify varName prefix is used.
474+
_ = r.execClickAd(context.Background(), models.TaskStep{VarName: "my_ad"}, result)
475+
// The error is expected (no ad found), but we just verify no panic.
476+
}
477+
478+
func TestExecClickAdScreenshots(t *testing.T) {
479+
mock := &mockExecutor{}
480+
r := newMockRunner(t, mock)
481+
result := &models.TaskResult{TaskID: "ad-ss", ExtractedData: make(map[string]string)}
482+
483+
// With a selector provided, the mock returns zero-value adDiscoveryResult (found=false),
484+
// so the click itself will fail. But captureAdScreenshot is called before the click
485+
// and the screenshot call (FullScreenshot) goes through the mock successfully.
486+
// We test the helper directly instead.
487+
path, err := r.captureAdScreenshot(context.Background(), result, "ad", "before")
488+
if err != nil {
489+
t.Fatalf("unexpected error: %v", err)
490+
}
491+
if path == "" {
492+
t.Fatal("expected non-empty screenshot path")
493+
}
494+
if len(result.Screenshots) != 1 {
495+
t.Fatalf("expected 1 screenshot, got %d", len(result.Screenshots))
496+
}
497+
if result.ExtractedData["ad_screenshot_before"] == "" {
498+
t.Error("expected ad_screenshot_before in extracted data")
499+
}
500+
501+
// Capture an after screenshot too.
502+
path2, err := r.captureAdScreenshot(context.Background(), result, "ad", "after")
503+
if err != nil {
504+
t.Fatalf("unexpected error: %v", err)
505+
}
506+
if path2 == "" {
507+
t.Fatal("expected non-empty screenshot path")
508+
}
509+
if len(result.Screenshots) != 2 {
510+
t.Fatalf("expected 2 screenshots, got %d", len(result.Screenshots))
511+
}
512+
if result.ExtractedData["ad_screenshot_after"] == "" {
513+
t.Error("expected ad_screenshot_after in extracted data")
514+
}
515+
}
516+
517+
func TestCaptureAdScreenshotError(t *testing.T) {
518+
mock := &mockExecutor{runErr: errors.New("screenshot failed")}
519+
r := newMockRunner(t, mock)
520+
result := &models.TaskResult{TaskID: "ad-ss-err", ExtractedData: make(map[string]string)}
521+
522+
_, err := r.captureAdScreenshot(context.Background(), result, "ad", "before")
523+
if err == nil {
524+
t.Fatal("expected error, got nil")
525+
}
526+
if !strings.Contains(err.Error(), "screenshot failed") {
527+
t.Fatalf("expected 'screenshot failed' in error, got: %v", err)
528+
}
529+
if len(result.Screenshots) != 0 {
530+
t.Fatalf("expected 0 screenshots on error, got %d", len(result.Screenshots))
531+
}
532+
}

‎internal/browser/steps.go‎

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

‎internal/models/task.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const (
5959
ActionEmulateDevice StepAction = "emulate_device"
6060
ActionGetTitle StepAction = "get_title"
6161
ActionGetAttributes StepAction = "get_attributes"
62+
ActionClickAd StepAction = "click_ad"
6263
)
6364

6465
func ExecutableStepActions() []StepAction {
@@ -87,6 +88,7 @@ func ExecutableStepActions() []StepAction {
8788
ActionEmulateDevice,
8889
ActionGetTitle,
8990
ActionGetAttributes,
91+
ActionClickAd,
9092
}
9193
}
9294

0 commit comments

Comments
 (0)