<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: FrameJet</title>
    <description>The latest articles on DEV Community by FrameJet (@framejet).</description>
    <link>https://dev.to/framejet</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4129338%2F94fe6787-9763-40de-8587-135002694289.png</url>
      <title>DEV Community: FrameJet</title>
      <link>https://dev.to/framejet</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/framejet"/>
    <language>en</language>
    <item>
      <title>Your screenshot API shouldn't need a CSS selector for step 2</title>
      <dc:creator>FrameJet</dc:creator>
      <pubDate>Wed, 23 Sep 2026 07:07:00 +0000</pubDate>
      <link>https://dev.to/framejet/your-screenshot-api-shouldnt-need-a-css-selector-for-step-2-ccn</link>
      <guid>https://dev.to/framejet/your-screenshot-api-shouldnt-need-a-css-selector-for-step-2-ccn</guid>
      <description>&lt;p&gt;Most screenshot APIs let you script the page before capture: &lt;code&gt;click: "#accept"&lt;/code&gt;, &lt;code&gt;wait_for: ".results"&lt;/code&gt;, a blob of JavaScript. That covers a lot. It stops covering things when the screenshot you want is &lt;strong&gt;several clicks past the URL&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Say you want the revision history of a Wikipedia article, starting from the homepage. Step 1 is "type into the search box". Step 2 is "click the right result". The problem is step 2's DOM &lt;strong&gt;doesn't exist yet&lt;/strong&gt; when you write the request. It only appears after step 1 has run in a live browser. To script the flow you'd have to run the flow first, which means keeping your own browser session open. And if you have one of those, you don't need a screenshot API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Passing the goal in plain words
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://framejet.dev/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=goal_mode" rel="noopener noreferrer"&gt;Framejet&lt;/a&gt; now takes a &lt;code&gt;goal&lt;/code&gt; along with the URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-G&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Api-Key: &lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; shot.png &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"url=https://en.wikipedia.org/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"goal=Search for the Colosseum article, open it, then open its View history page. Stop when the revision list is visible."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"values=Colosseum"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://framejet.dev/v1/take"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It walks the flow step by step and shoots when the stop condition is met. That exact request comes back as "Colosseum: Revision history" in about 16 seconds, with the site banner already stripped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the navigation isn't done by an LLM
&lt;/h2&gt;

&lt;p&gt;"AI navigates the page" usually means a language model reading the DOM and guessing a selector. Selector guessing is where these systems fail in the way that hurts most: the model names an element that isn't there, or clicks the wrong one and carries on.&lt;/p&gt;

&lt;p&gt;The decisions here go to TypeSafe's Jev, a &lt;strong&gt;constrained-choice&lt;/strong&gt; model. Each step, the visible controls are sent as a numbered menu, and Jev answers with one index plus a probability distribution over all of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It &lt;strong&gt;can't answer with a selector&lt;/strong&gt;, so it can't make one up.&lt;/li&gt;
&lt;li&gt;An off-menu or malformed answer is detectable, so it gets refused rather than executed.&lt;/li&gt;
&lt;li&gt;It's fast (roughly a second per decision) and cheap, because it's picking from a list, not writing text.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  It doesn't invent the text it types
&lt;/h2&gt;

&lt;p&gt;Anything typed into a field comes from the &lt;code&gt;values&lt;/code&gt; you pass. If none of them fits the field in front of it, the capture &lt;strong&gt;fails&lt;/strong&gt; instead of guessing. I didn't want a tool that puts made-up strings into someone else's form.&lt;/p&gt;

&lt;h2&gt;
  
  
  It fails loudly
&lt;/h2&gt;

&lt;p&gt;This is the rule I'd want as a user: &lt;strong&gt;only a reported DONE returns an image.&lt;/strong&gt; If the navigator reports BLOCKED, runs out of steps, or makes three moves without a page change, the API returns &lt;code&gt;422 goal_unreached&lt;/code&gt; and refunds the credit. A DONE judgment can still be wrong, so check important outputs.&lt;/p&gt;

&lt;p&gt;An early version returned 200 with whatever page it ended on in four of those paths. The caller couldn't tell. Fixing that mattered more than any accuracy tweak.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use selectors instead (often)
&lt;/h2&gt;

&lt;p&gt;If you know the page, a selector is better: deterministic, instant, free, and it reaches any node. Framejet still takes them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;actions=click:#searchInput;type:#searchInput=Colosseum;wait:1000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Visual regression testing in particular should use &lt;code&gt;actions&lt;/code&gt;, not &lt;code&gt;goal&lt;/code&gt;. A non-deterministic capture path turns your diffs into noise. &lt;code&gt;goal&lt;/code&gt; is for the case a selector can't cover: an agent handed an arbitrary URL at runtime, or the same intent across many sites you don't control.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Clean mode is on by default.&lt;/strong&gt; Consent walls and chat widgets are removed before the capture, not cropped after. This matters more when a vision model reads the result, because a screenshot of a cookie banner tells it nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;REST and a remote MCP server share one key and one quota.&lt;/strong&gt; The MCP tool is &lt;code&gt;screenshot(url, goal, values, actions, …)&lt;/code&gt;, listed as &lt;code&gt;dev.framejet/screenshot&lt;/code&gt; on the official MCP registry and &lt;code&gt;framejet/screenshot&lt;/code&gt; on Smithery. There's nothing to install.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limits:&lt;/strong&gt; a single region (US East), a hard time limit of under a minute per capture, and goal mode is new, so some pages won't work. I'm genuinely interested in the ones that fail.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Have a public page that takes a few clicks to reach? Share the URL and the screen you need in a comment. I can try it with Framejet and reply with the request that worked, or the failure if it didn't.&lt;/p&gt;

&lt;p&gt;200 screenshots a month free, no card: &lt;a href="https://framejet.dev/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=goal_mode" rel="noopener noreferrer"&gt;framejet.dev&lt;/a&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>automation</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Why your headless screenshots have a cookie banner over them (and how to get rid of it)</title>
      <dc:creator>FrameJet</dc:creator>
      <pubDate>Thu, 17 Sep 2026 10:35:58 +0000</pubDate>
      <link>https://dev.to/framejet/why-your-headless-screenshots-have-a-cookie-banner-over-them-and-how-to-get-rid-of-it-4gg0</link>
      <guid>https://dev.to/framejet/why-your-headless-screenshots-have-a-cookie-banner-over-them-and-how-to-get-rid-of-it-4gg0</guid>
      <description>&lt;p&gt;If you have ever automated screenshots with Puppeteer or Playwright, you've seen it: the page renders perfectly, and then a consent wall covers half of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;p&gt;A headless browser starts with an empty profile. No cookies, no stored consent. To every website it is a brand-new visitor, and brand-new visitors in the EU (and increasingly everywhere) get a consent banner. Your screenshot is just faithfully recording what that visitor sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1: accept the banner first
&lt;/h2&gt;

&lt;p&gt;Click "Accept" before capturing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;networkidle2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#onetrust-accept-btn-handler&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shot.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works for one site. It breaks the moment you capture a site using a different consent platform, a different button id, or a banner inside an iframe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: hide the overlays
&lt;/h2&gt;

&lt;p&gt;Instead of interacting, remove the elements:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addStyleTag&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`
  #onetrust-consent-sdk, #CybotCookiebotDialog, .fc-consent-root,
  [id^="sp_message_container"], #didomi-host { display: none !important; }
  html, body { overflow: auto !important; }
`&lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details people miss: many banners lock scrolling on &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt;, so you must restore &lt;code&gt;overflow&lt;/code&gt;, and some leave a dark full-screen backdrop that is a separate element from the dialog. A selector list gets you most of the big platforms; for the long tail you need a heuristic, e.g. fixed-position elements covering most of the viewport with a high z-index.&lt;/p&gt;

&lt;p&gt;Chat widgets (Intercom, Drift, HubSpot...) and sticky headers are the same problem in a different place. The sticky header is especially bad on full-page captures, where it can repeat down the image.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 3: use a service that does it for you
&lt;/h2&gt;

&lt;p&gt;This is the part where I mention that I got tired of maintaining that selector list and built &lt;a href="https://framejet.dev" rel="noopener noreferrer"&gt;Framejet&lt;/a&gt;, a screenshot API with this on by default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Api-Key: &lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; shot.png &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://framejet.dev/v1/take?url=https://example.com&amp;amp;full_page=true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It removes consent overlays, sticky headers and chat bubbles before the capture, doesn't charge for failed captures or cached repeats, and has an MCP server if you want an AI agent to take screenshots. There's a free tier of 200 screenshots a month. Other APIs (ScreenshotOne, Urlbox, ApiFlash) solve this too, so pick whatever fits your volume and budget.&lt;/p&gt;

&lt;p&gt;Whatever you choose, the underlying trick is the same: treat overlays as a rendering problem, not a clicking problem.&lt;/p&gt;

</description>
      <category>puppeteer</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>api</category>
    </item>
  </channel>
</rss>
