Quick answer: An iframe is a separate document with its own URL, so its content never appears in the parent page's HTML. The best fix is usually to find the iframe's src and fetch that URL directly, skipping the parent page entirely. If the iframe is built dynamically or needs page context, use a headless browser and access the frame through Playwright's frame APIs. Cross-origin restrictions limit JavaScript in the page, but they don't stop you from fetching the frame's URL yourself.
If you've ever scraped a page, gotten a perfectly valid response, and found none of the content you saw in your browser, there's a decent chance an iframe ate your data. Embedded videos, payment forms, comment sections, booking widgets, dashboards, ad units: all commonly live in iframes. Here's why they're annoying and the three ways to get at them.
Why Iframes Break Normal Scraping
An <iframe> embeds one HTML document inside another. The key word is document: the browser makes a second, independent request to the iframe's src URL and renders the result in a box. The parent page's HTML contains only the tag:
<iframe src="https://widgets.example-reviews.com/embed/store-4821"
width="600" height="400"></iframe>
So when you fetch the parent page with requests or curl, you get exactly that: a tag with a URL, and zero of the content inside it. Your CSS selectors find nothing because the reviews, prices, or comments literally are not in the document you downloaded.
There's a second layer of trouble: the same-origin policy. If the iframe comes from a different domain than the parent page, JavaScript in the parent can't reach into it at all. iframe.contentDocument returns null cross-origin. This trips up people who try to scrape iframes by injecting JavaScript into the parent page, and it's why some browser-automation snippets you find online only work on same-origin frames.
The good news: the same-origin policy is a browser security rule about scripts, not a rule about you. Nothing stops your scraper from requesting the iframe's URL directly.
Method 1: Fetch the Iframe's URL Directly (Best)
This is the move in 90 percent of cases. The iframe's content is a normal web page with its own URL. Extract the src, then scrape that URL as its own document:
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
parent_url = "https://shop.example.com/product/4821"
resp = requests.get(parent_url, timeout=15)
soup = BeautifulSoup(resp.text, "lxml")
iframe = soup.select_one("iframe")
frame_url = urljoin(parent_url, iframe["src"]) # handles relative srcs
frame_resp = requests.get(
frame_url,
headers={"Referer": parent_url}, # many embeds check the referer
timeout=15,
)
frame_soup = BeautifulSoup(frame_resp.text, "lxml")
print(frame_soup.get_text(" ", strip=True)[:500])
Two practical notes. First, always resolve relative src values against the parent URL with urljoin. Second, send a Referer header pointing at the parent page: embed endpoints frequently check it and return 403 or an empty shell without it. Some also want specific query params or a session cookie from the parent; copy the exact request from your browser's network tab if the plain fetch comes back empty.
Even better, once you're fetching the frame URL directly, you can skip HTML parsing and get clean markdown in one call:
curl -X POST https://link.sc/v1/fetch \
-H "Authorization: Bearer lsc_your_key" \
-H "Content-Type: application/json" \
-d '{"url": "https://widgets.example-reviews.com/embed/store-4821", "format": "markdown"}'
Method 2: When There Is No src to Grab
Sometimes the parent HTML has no useful src. Common cases:
src="about:blank"or asrcdocattribute, with content injected by script- The iframe itself is created by JavaScript after page load
- The
srccontains short-lived signed tokens generated in the browser
For srcdoc, you're in luck: the frame's entire HTML is inline in the attribute, so decode the HTML entities and parse it directly. For script-created frames, you need the page to actually run, which means a headless browser or a rendering API. Before reaching for a browser, though, check the network tab: dynamically created iframes still load their content over HTTP, and very often it's a JSON API you can call directly, which beats both parsing and rendering. That's the same triage I recommend for any JavaScript-rendered site.
Method 3: Switching Frames in a Headless Browser
When the frame genuinely needs browser context (tokens minted at runtime, content that only loads inside the embed), drive a real browser and use its frame APIs. In Playwright, every iframe is a Frame object you can query like a page:
import { chromium } from "playwright";
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto("https://shop.example.com/product/4821", {
waitUntil: "networkidle",
});
// Locate the frame by URL fragment (or by name)
const frame = page.frames().find(f => f.url().includes("example-reviews.com"));
// Or, more robustly, via a frame locator on the iframe element
const reviews = await page
.frameLocator("iframe[src*='example-reviews']")
.locator(".review-text")
.allTextContents();
console.log(reviews.length, "reviews");
await browser.close();
Key points that save debugging time:
page.frames()lists all frames, including nested ones (iframes inside iframes appear here too; walkframe.childFrames()if needed).frameLocatoris the modern API and auto-waits for the frame to attach, which matters because iframes often load after the main page fires its load event.- Playwright and Puppeteer operate at the browser-internals level, so they can read cross-origin frames that in-page JavaScript cannot touch. Selenium's
driver.switch_to.frame(...)does the same job.
The cost is everything that comes with headless browsers: memory, speed, and bot detection. If you're weighing that trade-off, I broke it down in curl vs headless vs stealth browser.
Same-Origin vs Cross-Origin: What Actually Limits You
| Situation | In-page JavaScript | Playwright/Puppeteer | Direct fetch of frame URL |
|---|---|---|---|
| Same-origin iframe | Full access via contentDocument |
Full access | Works |
| Cross-origin iframe | Blocked by same-origin policy | Full access | Works |
| Frame URL needs referer/cookies | n/a | Handled automatically | Send them manually |
| Frame content behind login | Needs the session anyway | Works after login flow | Needs session cookies |
The takeaway: the same-origin policy only bites when you try to script across frames inside the page. Both direct fetching and browser automation frameworks bypass it cleanly, because neither is subject to in-page security boundaries.
One honest caveat: some embeds (payment forms especially, but also some video and CAPTCHA frames) are deliberately hardened against automation, with token exchanges between parent and frame that are impractical to replicate. If the iframe is a Stripe checkout or a Turnstile widget, the content is not really "data" to scrape, and I'd step back and ask whether you should be touching it at all; the legality of scraping gets much murkier around payment and personal data.
My Decision Order
- Look at the parent HTML. If the iframe has a real
src, fetch that URL directly with aRefererheader. Fastest, cheapest, most reliable. - No usable src? Check the network tab for the JSON or HTML request the frame makes, and call that.
- Still stuck, or the frame needs runtime tokens? Headless browser with
frameLocator. - Getting blocked at any of these steps? Route the fetch through link.sc, which renders the page (frames included) server-side and returns the content as clean markdown, with the pricing staying flat whether the page had zero iframes or five.
Iframes feel mysterious the first time they swallow your data, but they're just pages inside pages. Find the inner page's URL, and it's ordinary scraping again.
Tired of chasing frame URLs by hand? Sign up for link.sc and get 500 free credits a month: fetch any URL, rendered and unblocked, as clean markdown or JSON.