How to Access Chrome Extensions From Python With Pyppeteer
Load unpacked Chrome extensions in Pyppeteer, find MV2 or MV3 targets, open extension pages, and troubleshoot headless Chromium issues.

Direct answer: launch Pyppeteer with a dedicated user-data directory, remove its default --disable-extensions flag, and add both --disable-extensions-except=/absolute/path/to/extension and --load-extension=/absolute/path/to/extension. Run headed Chromium while debugging. Then inspect browser targets to find the extension ID: Manifest V2 normally exposes a background page, while Manifest V3 exposes a service-worker target. Navigate to an extension page such as chrome-extension://<extension-id>/popup.html after discovering that ID.
This approach loads an unpacked extension for local automation. It does not install an extension from the Chrome Web Store, and a popup may not exist until the extension opens it.
What you need
- Python 3 and a virtual environment.
- Pyppeteer and its compatible Chromium revision.
- An unpacked extension directory containing a valid
manifest.json. - A separate user-data directory for the automation run.
Pyppeteer works best with the Chromium revision it bundles. Arbitrary Chrome versions are not guaranteed. The Pyppeteer project repository also warns that the project is unmaintained and points users toward playwright-python for actively maintained automation.
1. Prepare an unpacked extension
Point Pyppeteer at the directory that contains the extension manifest, rather than at a downloaded ZIP file. A minimal Manifest V3 directory might look like this:
my-extension/
├── manifest.json
├── service-worker.js
└── popup.html
Example manifest.json:
{
"manifest_version": 3,
"name": "Pyppeteer test extension",
"version": "1.0.0",
"action": {
"default_popup": "popup.html"
},
"background": {
"service_worker": "service-worker.js"
},
"permissions": []
}
For Manifest V2, the background entry is usually a persistent or event background page. For Manifest V3, the background entry is a service worker that can start asynchronously and be suspended when idle.
2. Install Pyppeteer
python -m venv .venv
source .venv/bin/activate
python -m pip install pyppeteer
On Windows PowerShell, activate the environment with .venv\\Scripts\\Activate.ps1. The first launch can download Pyppeteer’s bundled Chromium. Pin your Python and Pyppeteer versions in a repeatable build if this automation runs in CI.

3. Launch Chromium with extension flags
Pyppeteer’s launcher adds --disable-extensions by default. If you leave that flag in place, Chromium can start successfully while silently refusing to load your extension. Remove that one default and add the two extension flags:
import asyncio
from pathlib import Path
from pyppeteer import launch
EXTENSION_PATH = str(Path("./my-extension").resolve())
USER_DATA_DIR = str(Path("./.pyppeteer-profile").resolve())
async def main():
browser = await launch(
headless=False,
userDataDir=USER_DATA_DIR,
# Remove Pyppeteer's default extension-disabling flag.
ignoreDefaultArgs=["--disable-extensions"],
args=[
f"--disable-extensions-except={EXTENSION_PATH}",
f"--load-extension={EXTENSION_PATH}",
],
)
# Inspect targets before creating a normal page.
for target in browser.targets():
print(target.type, target.url)
page = await browser.newPage()
await page.goto("https://example.com", {"waitUntil": "networkidle2"})
await browser.close()
asyncio.get_event_loop().run_until_complete(main())
Save this as load_extension.py and run:
python load_extension.py
The exact handling of ignoreDefaultArgs can vary with Pyppeteer and its Chromium revision. If removing only --disable-extensions is insufficient, inspect the actual launched command line and use a narrowly scoped override. Setting ignoreDefaultArgs=True discards every default argument and is riskier; Pyppeteer’s documentation labels that option dangerous.
4. Find the extension ID
Chrome assigns an ID to the loaded extension. Do not hard-code it unless your build process deliberately fixes the extension identity. Discover it from targets:
def print_extension_targets(browser):
for target in browser.targets():
print(f"type={target.type!r} url={target.url!r}")
Typical observations include:
| Manifest | Target you may see | What it means |
|---|---|---|
| V2 | background_page |
The extension background page is available as a target. |
| V3 | service_worker |
The extension service worker has started; it may appear asynchronously. |
| Either | chrome-extension://<id>/... |
The URL reveals the extension ID needed for direct navigation. |
Because a Manifest V3 worker can start after launch, poll for it instead of assuming it exists immediately:
import asyncio
async def wait_for_extension_id(browser, timeout=15):
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
for target in browser.targets():
url = target.url
if url.startswith("chrome-extension://"):
return url.split("/")[2]
await asyncio.sleep(0.25)
raise TimeoutError("No extension target appeared before the timeout")
For a service-worker target whose URL includes the extension ID, the same extraction works. If the worker has not started, trigger the extension through the page or wait a little longer.
5. Open the popup or another extension page
Once you have the ID, navigate a page to the extension resource:
extension_id = await wait_for_extension_id(browser)
extension_page = await browser.newPage()
await extension_page.goto(
f"chrome-extension://{extension_id}/popup.html",
{"waitUntil": "domcontentloaded"},
)
print(await extension_page.title())
A popup page is not always a normal browser tab. Chrome may create it only while the action popup is open, and it can disappear when focus changes. Direct navigation to the popup resource is generally more reliable for DOM inspection and screenshots. If the popup depends on an active tab, open the regular page first and then use the extension’s expected messaging or storage state.
Complete runnable example
This script launches a persistent profile, waits for either a background page or service worker target, opens the popup, and saves a screenshot:
import asyncio
from pathlib import Path
from pyppeteer import launch
EXTENSION_PATH = str(Path("./my-extension").resolve())
USER_DATA_DIR = str(Path("./.pyppeteer-profile").resolve())
async def find_extension_id(browser, timeout=15):
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while loop.time() < deadline:
for target in browser.targets():
if target.type in {"background_page", "service_worker"}:
url = target.url
if url.startswith("chrome-extension://"):
return url.split("/")[2]
if target.url.startswith("chrome-extension://"):
return target.url.split("/")[2]
await asyncio.sleep(0.25)
raise TimeoutError("Extension background page or service worker was not found")
async def main():
browser = await launch(
headless=False,
userDataDir=USER_DATA_DIR,
ignoreDefaultArgs=["--disable-extensions"],
args=[
f"--disable-extensions-except={EXTENSION_PATH}",
f"--load-extension={EXTENSION_PATH}",
],
)
try:
for target in browser.targets():
print("target:", target.type, target.url)
extension_id = await find_extension_id(browser)
print("extension ID:", extension_id)
page = await browser.newPage()
await page.goto("https://example.com", {"waitUntil": "networkidle2"})
popup = await browser.newPage()
await popup.goto(
f"chrome-extension://{extension_id}/popup.html",
{"waitUntil": "domcontentloaded"},
)
await popup.screenshot({"path": "extension-popup.png", "fullPage": True})
print("popup title:", await popup.title())
finally:
await browser.close()
asyncio.get_event_loop().run_until_complete(main())
Headless mode and extension compatibility
Use headless=False first. A visible browser lets you confirm that the extension loaded, inspect extension errors, and see whether a popup opens. Headless extension behavior depends on the Chromium revision and launch mode. If an extension works headed but not headless:
- Confirm the exact Chromium executable and version Pyppeteer launched.
- Run the same profile headed and inspect the extension target list.
- Check whether your extension relies on a visible action popup, focus, or user gesture.
- Test the service worker separately; MV3 workers can be suspended and restarted.
- Only then try a headless run with the same extension flags and profile isolation.
Useful launch and navigation choices
| Option | Use | Guidance |
|---|---|---|
headless=False |
Debugging extension loading | Recommended while diagnosing targets, permissions, and popup behavior. |
userDataDir |
Persistent browser state | Use a dedicated directory; do not share a profile with a normal Chrome session. |
ignoreDefaultArgs=["--disable-extensions"] |
Allow extensions to load | Keep the override narrow. |
--disable-extensions-except=... |
Restrict loaded extensions | Pass an absolute unpacked-extension path. |
--load-extension=... |
Load the unpacked extension | Use the same absolute path. |
waitUntil="domcontentloaded" |
Open an extension page | Useful when the page has no conventional network-idle point. |
waitUntil="networkidle2" |
Wait for a regular website | Can be useful for the page under test, but extension workers are separate targets. |
Common errors and fixes
“The extension does not load”
Cause: Pyppeteer’s default --disable-extensions flag is still present, or the extension path is wrong.

Fix: remove that default with ignoreDefaultArgs=["--disable-extensions"], resolve the path with Path(...).resolve(), and pass both extension flags.
No background page appears
Cause: the extension is Manifest V3, so it exposes a service worker instead; the worker may also start later or be suspended.
Fix: inspect all targets, wait for target.type == "service_worker", and extract the ID from its chrome-extension:// URL.
The extension ID is empty or changes between runs
Cause: target discovery ran too early, or the script assumed a fixed ID.
Fix: wait for a target and derive the ID from its URL. Keep the extension directory and manifest stable if you need repeatable identity.
chrome-extension://... navigation fails
Cause: the resource path is not present in the manifest, the filename is wrong, or the extension has not finished loading.
Fix: verify the exact popup filename, wait for the extension target, and open a resource listed by the extension’s files.
The popup closes immediately
Cause: Chrome action popups are short-lived UI surfaces and can close when focus changes.
Fix: navigate directly to the popup HTML in a page for inspection, or test the underlying extension page and service-worker logic instead of relying on the action popup lifecycle.
Pyppeteer fails with a browser-version error
Cause: the installed Chrome version is outside the compatibility range expected by Pyppeteer.
Fix: use the bundled Chromium revision, or pin a known compatible Python, Pyppeteer, and browser combination. Arbitrary Chrome versions are not guaranteed.
It works locally but fails in CI
Cause: CI may not provide a display, may use a different browser revision, or may reuse a locked profile.
Fix: use an isolated profile per job, record the browser version, run headed under a display server when debugging, and verify the extension directory is present in the job workspace.
Reliability, performance, and cost considerations
- Reliability: wait for targets instead of using fixed sleeps. MV3 service workers are asynchronous and can be suspended.
- Isolation: one user-data directory per concurrent browser avoids profile locks and state leakage.
- Repeatability: pin Python, Pyppeteer, and the Chromium revision; test the same manifest in development and CI.
- Performance: reusing one browser process is usually cheaper than launching Chromium for every URL, but create separate contexts or profiles when state must be isolated.
- Debugging: headed mode adds visibility while you diagnose extension startup. Switch modes only after target discovery and navigation work.
- Cost: this local workflow has infrastructure costs for browser startup, CPU, memory, and CI time. It does not provide a hosted screenshot endpoint or automatic page-cleaning behavior.
Or skip the browser setup
If your goal is a clean screenshot or PDF rather than extension automation itself, ScreenshotNeo provides a single GET request. Its API accepts the URL and returns PNG, JPEG, WebP, or PDF output; see the ScreenshotNeo API documentation for the full option set.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const data = Buffer.from(await res.arrayBuffer());
require('fs').writeFileSync('shot.webp', data);
ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.
The Free plan includes 1,000 shots per month with no card. Paid plans start at $5 for 3,000 shots; every feature is available on every plan. Create a free ScreenshotNeo account.
Pyppeteer versus Playwright for extensions
Pyppeteer exposes the Chromium flags directly through launch(args=...), which is enough for loading an unpacked extension. Playwright Python offers a higher-level persistent-context workflow and current documentation for extension service-worker discovery. When choosing between them, compare maintenance status, persistent-context support, Manifest V3 worker handling, browser-version control, and headed/headless debugging needs.
Checklist
- Use an unpacked extension directory with a valid manifest.
- Resolve the extension path to an absolute path.
- Use a dedicated
userDataDir. - Remove Pyppeteer’s default
--disable-extensionsflag. - Add
--disable-extensions-exceptand--load-extension. - Run headed while diagnosing problems.
- Find the extension ID from a background-page or service-worker target.
- Wait for MV3 targets; do not assume the worker exists at launch.
- Navigate directly to
chrome-extension://<id>/...for stable page inspection. - Pin the browser and Python dependencies for CI.
FAQ
Can Pyppeteer load a Chrome Web Store extension?
The documented workflow loads an unpacked extension directory. Download or unpack the extension as part of your own build process, then pass that directory to Chromium.
Do I need to know the extension ID in advance?
No. Inspect the background-page or service-worker target URL and extract the ID after launch.
Why is Manifest V3 harder to detect?
Manifest V3 uses a service worker rather than a persistent background page. The worker starts asynchronously and may be suspended, so target discovery must tolerate both delays and restarts.
Should I set ignoreDefaultArgs=True?
Usually no. Remove only --disable-extensions first. Discarding every Pyppeteer default is broader and riskier.
Is Pyppeteer maintained?
The project repository describes it as unmaintained and suggests playwright-python as an alternative. Keep that maintenance status in your tooling decision and pin versions if you continue using Pyppeteer.


