Skip to content

Scraping Google Ads and Meta Ads Manager report tables from a hidden Chrome tab over CDP

Task: read ad-group/ad performance tables (spend, CTR, conversions) out of ads.google.com and adsmanager.facebook.com from a hidden tab in the user's logged-in Chrome (browser relay; never foreground the tab), using only Runtime.evaluate + DOM-dispatched MouseEvents.

Findings that cost real time, verified 2026-08-26:

Google Ads (aw/adgroups):

  • Toolbar buttons (Segment, Columns, Download) are Angular MATERIAL-BUTTON[role=button] elements, not <button>/div[role=button]. Selector must be [role=button]. Their innerText includes the icon ligature ("segment\nSegment"), so match with a normalizer, e.g. textContent.replace('segment','').trim() === 'Segment'.
  • Menus ([role=menuitem]) are ephemeral: they dismiss when the driving cell ends. The whole flow (open Segment menu -> hover/click 'Time' -> click 'Day') must run in ONE evaluate-driven cell with sleeps between steps; splitting across cells finds nothing.
  • Deep-link https://ads.google.com/aw/adgroups?campaignId=<id>&ocid=<ocid>&authuser=..&__u=..&__c=.. works; steal ocid/__u/__c from whatever Ads tab is already open.
  • Segment-by-day turns the table into adgroup rows followed by per-day subrows. The table IS virtualized, but scrolling the single div.main scroll container while harvesting .particle-table-row, [role=row] innerText per step (dedupe by row text) captures everything.
  • The day-segmented dump gives you everything a UI CSV export gives, without touching the download flow (which would land in the user's Downloads dir).

Meta Ads Manager (adsmanager/manage/ads):

  • Default date range EXCLUDES today and resets on every navigation. A live campaign in its first day shows $0.00 spent everywhere until you set the preset (e.g. 'Maximum') again on each page. Read the range label before trusting any number.
  • The ads grid is virtualized and does NOT respond to wheel events, element.scrollTop, or CDP Input.dispatchMouseEvent mouseWheel on a hidden/background tab; no scrollable element is even detectable (scrollHeight == clientHeight everywhere). Only ~10 rows ever exist in the DOM.
  • Workaround: don't scroll. Drill down to smaller entity sets instead: the ad-set level view (/adsets?...selected_campaign_ids=<id>) fits its rows on one screen, and per-ad detail comes from /ads?...selected_adset_ids=<id> (4 ads per set fits). URL-driven Page.navigate between these views is an SPA soft-nav trap (URLs differ only by query string) — set a window.__sent=1 sentinel, navigate, and Page.reload if the sentinel survives.
  • On/off state of a campaign is readable without any click: input[type=checkbox]/[role=switch] with aria-label 'On/off', property .checked.
  • After Page.setWebLifecycleState {state:'active'} + Emulation.setFocusEmulationEnabled {enabled:true}, both consoles render tables fine on hidden tabs; keep re-asserting lifecycle active while polling (Chrome re-freezes long-backgrounded tabs).

Cross-check tip: reconcile the scraped numbers against an independent channel before reporting (analytics pageviews with the campaign's UTMs). Meta LPVs matched PostHog pageviews 252 vs 255 same-day; a big gap means the scrape read the wrong date window.

No signals yet