<?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: Diego Fortes</title>
    <description>The latest articles on DEV Community by Diego Fortes (@codedcitadel).</description>
    <link>https://dev.to/codedcitadel</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%2F3995335%2F71cdb351-b23e-43b7-b147-9674acff38c0.jpg</url>
      <title>DEV Community: Diego Fortes</title>
      <link>https://dev.to/codedcitadel</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/codedcitadel"/>
    <language>en</language>
    <item>
      <title>Ep. 9: Instagram Could BAN This Chrome Extension That I coded</title>
      <dc:creator>Diego Fortes</dc:creator>
      <pubDate>Sun, 21 Jun 2026 14:26:25 +0000</pubDate>
      <link>https://dev.to/codedcitadel/ep-9-i-got-many-instagram-accounts-blocked-to-be-able-to-code-this-instagram-comment-exporter-4de8</link>
      <guid>https://dev.to/codedcitadel/ep-9-i-got-many-instagram-accounts-blocked-to-be-able-to-code-this-instagram-comment-exporter-4de8</guid>
      <description>&lt;h1&gt;
  
  
  How I Built an Instagram Comments Exporter Chrome Extension (And How Getting an Instagram Scraper Blacklisted in 2022 Helped Me)
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Social media&lt;/strong&gt; tools may sound straightforward. And they are, until the DOM changes something. And these changes happen frequently. &lt;/p&gt;

&lt;p&gt;I learned that the hard way back in 2022, when I spent six months on a private scraper that eventually got every burner account and proxy I owned blacklisted. &lt;/p&gt;

&lt;p&gt;The project that I'm gonna share in today's post is almost a sequel to that era: a proper &lt;strong&gt;Chrome extension&lt;/strong&gt; that exports post comments to CSV, with pause/resume, Giphy replies, and a UI that actually looks like Instagram, and that won't get users blacklisted - even though the extension itself is in considerable risk, since Mark is not really a fan of scrapers.&lt;/p&gt;

&lt;p&gt;This extension follows the same reverse-engineering playbook I used for the &lt;a href="https://codedcitadel.com/blog/instagram-dm-exporter-chrome-extension/" rel="noopener noreferrer"&gt;Instagram DM Exporter&lt;/a&gt; — open the Network tab, find the API, prove it in the console, then wrap it in an extension. &lt;/p&gt;

&lt;p&gt;Let's get into the details of it!&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Instagram tools are fun until they aren't
&lt;/h2&gt;

&lt;p&gt;So, yeah, back in 2022, I built a fairly complex Instagram scraper: it would automatically like, follow, unfollow, DM... I'm not proud to admit that I got many burner accounts and proxies blocked in the process, but it was a great learning experience.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdrnw195t1bbij3o3l5ks.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdrnw195t1bbij3o3l5ks.jpg" width="545" height="301"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcri86fir78yonpgvg3gh.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcri86fir78yonpgvg3gh.jpg" width="800" height="375"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;As you can tell by the "no user detected" error, it no longer works — but it was genuinely useful at the time.&lt;/p&gt;

&lt;p&gt;It took me roughly six months to complete, coding at least an hour a day. It was one of the projects I had the most fun building. It became obsolete within months, though: every two to three months Instagram's security would tighten, the DOM would change, things would break, and I simply didn't have the bandwidth to keep up with updates.&lt;/p&gt;

&lt;p&gt;I built it purely for personal use — to fetch and study competitor data for my previous business. I never published it.&lt;/p&gt;

&lt;p&gt;Either way, that project taught me that building tools for social media is tricky, but really rewarding.&lt;/p&gt;

&lt;h3&gt;
  
  
  The $100K challenge and my hesitation
&lt;/h3&gt;

&lt;p&gt;During this challenge, we've already built one Instagram tool: the &lt;a href="https://codedcitadel.com/blog/instagram-dm-exporter-chrome-extension/" rel="noopener noreferrer"&gt;Instagram DM Exporter&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I wanted to build a few more, but I wasn't sure whether it would be worth the long-term investment, given how quickly these things break.&lt;/p&gt;

&lt;p&gt;That is the honest tradeoff with any &lt;strong&gt;export Instagram comments&lt;/strong&gt; project: you are building on someone else's platform, and they don't owe you a stable API.&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding an Instagram comments exporter that still works
&lt;/h2&gt;

&lt;p&gt;During research over the past few days, I came across a solid "Instagram comments exporter" extension that appears to have worked reliably for years.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flfixw2w75j9plsu591dv.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flfixw2w75j9plsu591dv.jpg" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Despite a recent update in March 2026, it seems somewhat abandoned. It has strong reviews for a free tool, and the two main complaints are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It sometimes gets stuck without giving the user any warning.&lt;/li&gt;
&lt;li&gt;The date formatting is confusing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmtewxxidrkanuvjjih4r.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmtewxxidrkanuvjjih4r.jpg" width="799" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I tested it myself and it successfully fetched around 2,000 comments from a Reel — the date issue is real, though.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F175ubeye5ixfslg1ic91.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F175ubeye5ixfslg1ic91.jpg" width="800" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg9x4m4r20s4u1mi73z11.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg9x4m4r20s4u1mi73z11.jpg" width="800" height="183"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The UX is decent, but asking the user to re-enter the post URL is redundant — we could fill that field automatically.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftx8jgp0rejdae8dg0jx6.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftx8jgp0rejdae8dg0jx6.jpg" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It also doesn't fetch GIF responses, which is something worth adding. For GIFs specifically, we'd need to download the images separately (perhaps in a .zip), since direct URLs are temporary — a lesson I learned back in 2022. For profile pictures, we'll start by storing the URL only; it's a reasonable MVP scope even if those URLs can also expire.&lt;/p&gt;

&lt;h3&gt;
  
  
  The AI angle (and why export-only might not be enough)
&lt;/h3&gt;

&lt;p&gt;This also opens the door to an "Instagram comments AI analyze" feature, which would be compelling and relatively inexpensive to build with the Gemini Flash API or a similar model router.&lt;/p&gt;

&lt;p&gt;The AI angle is clearly trending, and layering analysis on top of raw export could meaningfully differentiate the product. Our first project, the YouTube comments exporter, has zero users — which suggests that raw CSV export alone may not be enough of a hook. An AI-powered analysis layer might be what actually drives adoption.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foep0quk09x87u306y3u3.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foep0quk09x87u306y3u3.jpg" width="782" height="646"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Either way, let's get started.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instagram comments exporter takeaway:&lt;/strong&gt; A working competitor proves demand, but gaps — stuck exports, wrong dates, missing GIFs, redundant URL entry — are where a new Chrome extension can still win, especially if you add analysis on top of raw export later.&lt;/p&gt;




&lt;h2&gt;
  
  
  Hello world: reverse-engineering Instagram comment pagination
&lt;/h2&gt;

&lt;p&gt;The first step is to open Instagram and observe how comments are fetched.&lt;/p&gt;

&lt;p&gt;I used the same approach from previous episodes: open the Network tab, paste a piece of visible content, and try to find a matching object in the requests. And it worked:&lt;/p&gt;

&lt;p&gt;The next question: are all comments loaded at once, or fetched on demand? Almost certainly the latter.&lt;/p&gt;

&lt;p&gt;We can see that a GET request to the post's endpoint retrieves some comments — but probably not all.&lt;/p&gt;

&lt;p&gt;Scrolling through the comments confirms this:&lt;/p&gt;

&lt;p&gt;Additional comments trigger a different endpoint entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://www.instagram.com/api/graphql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So what's happening is: the first batch of comments loads with the post itself, and subsequent batches are fetched on demand as the user scrolls. This is the same pattern we saw in the &lt;a href="https://codedcitadel.com/blog/instagram-dm-exporter-chrome-extension/" rel="noopener noreferrer"&gt;Instagram DM Exporter&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The next step is to replicate that on-demand fetch. I copied the request headers...&lt;/p&gt;

&lt;p&gt;...removed sensitive content (cookies, etc.), replaced it with dummy values, and sent it to Claude to generate a console script for fetching comments on demand.&lt;/p&gt;

&lt;p&gt;It worked better than expected: everything fetched correctly. The script automatically pulls the user's cookie to extract the CSRF token and all other necessary values. On the first run, though, I was still getting the same batch of data despite knowing there was more available.&lt;/p&gt;

&lt;h3&gt;
  
  
  GraphQL vs query hash: picking the right endpoint
&lt;/h3&gt;

&lt;p&gt;There are two endpoints in play: the &lt;code&gt;graphql&lt;/code&gt; endpoint and a &lt;code&gt;query&lt;/code&gt; hash endpoint. One returns more concise data; the other returns richer, more detailed responses.&lt;/p&gt;

&lt;p&gt;For our purposes, the simpler endpoint would likely be sufficient — though it was worth investigating whether the richer one would complicate pagination. I forwarded everything to Claude and got a revised script.&lt;/p&gt;

&lt;p&gt;That's when we hit the first bug of the project:&lt;/p&gt;

&lt;p&gt;The script couldn't find the media ID. I moved from Claude to Cursor and provided the full page source as reference context.&lt;/p&gt;

&lt;p&gt;After that, it was fetching complete data — but without looping yet.&lt;/p&gt;

&lt;p&gt;At this point I also questioned whether fetching all available fields was overkill. Not everything returned is worth keeping. I gave it another 30 minutes or so; if pagination didn't work cleanly, I'd move on and simplify.&lt;/p&gt;

&lt;p&gt;I sent the data back to Cursor and tried pagination again.&lt;/p&gt;

&lt;p&gt;Pagination worked perfectly with the richer data. I then sent the response object to Claude to help decide which fields were actually worth keeping — some of what we were fetching was redundant, and some could eventually become part of a premium tier. For now, the priority was making the free export as useful as possible.&lt;/p&gt;

&lt;p&gt;After some back-and-forth with Claude, I landed on a clear set of fields and updated the console script accordingly.&lt;/p&gt;

&lt;p&gt;One final test confirmed everything working cleanly. Time to build the extension.&lt;/p&gt;




&lt;h2&gt;
  
  
  Building the Chrome extension: architecture and Instagram-native design
&lt;/h2&gt;

&lt;p&gt;With pagination proven in the console, it was time to decide on architecture and design direction. I usually have a rough idea before brainstorming with Claude — this was no different.&lt;/p&gt;

&lt;p&gt;Part 1 of brainstorm with Claude:&lt;/p&gt;

&lt;p&gt;One tool I find consistently useful is &lt;a href="https://mcpmarket.com" rel="noopener noreferrer"&gt;mcpMarket.com&lt;/a&gt; (not an ad — it's free) for finding AI skills, which are essentially &lt;code&gt;.md&lt;/code&gt; files with specific instructions for a given domain. In this case, I grabbed a &lt;code&gt;web-app-designer&lt;/code&gt; skill.&lt;/p&gt;

&lt;p&gt;I attached the skill to my Claude prompt:&lt;/p&gt;

&lt;p&gt;For the UI, I had a clear vision: a floating bubble in the bottom right corner of the page, showing a live comment count. Clicking it opens a modal with extraction controls, settings, and export history. I also wanted to support pause/resume — since we're capturing pagination cursors per batch, this should be achievable.&lt;/p&gt;

&lt;p&gt;The design goal was to match Instagram's visual language exactly — same colors, same font, same feel. To do that, I extracted Instagram's CSS directly.&lt;/p&gt;

&lt;p&gt;I then asked Grok to clean it up and rename the classes using BEM conventions. It's not pure BEM, but consistent enough to work cleanly with the HTML/CSS we'd write later.&lt;/p&gt;

&lt;p&gt;It's a condensed version of Instagram's full CSS, but more than sufficient for our needs.&lt;/p&gt;

&lt;p&gt;Part 2 of brainstorm with Claude:&lt;/p&gt;

&lt;p&gt;The first issue was that the bubble wasn't being injected into the page. More specific instructions fixed it.&lt;/p&gt;

&lt;p&gt;I noticed one specific error, though it wasn't clear yet whether it was extension-related.&lt;/p&gt;

&lt;p&gt;The modal and bubble were both rendering correctly. Before touching the design, though, the core functionality needed to be solid — and it wasn't quite there yet.&lt;/p&gt;

&lt;p&gt;Much better. The remaining items on the list: proper comment pagination in the table, Giphy URL rendering instead of blank rows, user avatars, and a working resume button.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmq2vwownvjv6emky90wy.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmq2vwownvjv6emky90wy.jpg" width="800" height="559"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pause/resume was working. Next up: richer feedback — a progress bar, estimated time remaining, and live comment count.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pixel-perfect UI: from ChatGPT mockup to working modal
&lt;/h2&gt;

&lt;p&gt;With functionality stable enough to move forward, I asked Cursor to describe the current UI in detail so I could feed that description to ChatGPT and get a proper design mockup.&lt;/p&gt;

&lt;p&gt;The first design was acceptable but not exciting. I gave ChatGPT more creative freedom and described what I had in mind more loosely.&lt;/p&gt;

&lt;p&gt;The second attempt — with fewer constraints — came out significantly better.&lt;/p&gt;

&lt;p&gt;I saved the mockup and asked Claude to implement it pixel-perfect in HTML/CSS/JS. Before sending it, I trimmed the image so Claude could focus on the modal specifically.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fya9n0q0mu46n8van929g.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fya9n0q0mu46n8van929g.jpg" width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One prompt pattern I always use for image-to-code work: explicitly request "pixel perfect" and emphasize attention to detail. It consistently improves output quality.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmz1krw67005ynod7gw9h.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmz1krw67005ynod7gw9h.jpg" width="800" height="528"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Part 1 — converting image to code:&lt;/p&gt;

&lt;p&gt;The result:&lt;/p&gt;

&lt;p&gt;It looked excellent. When the prompt is specific and the reference image is clear, Claude's image-to-code output is genuinely impressive. I committed this to GitHub before integrating it into the extension.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjpv0dixktzj9kaxt20bj.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjpv0dixktzj9kaxt20bj.jpg" width="799" height="361"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3m8x195kop3ej00akvjn.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3m8x195kop3ej00akvjn.jpg" width="800" height="495"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One bug to fix first:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fancdaz74thoju84yiyhb.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fancdaz74thoju84yiyhb.jpg" width="752" height="374"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fob3v03uifajjpztq5c9f.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fob3v03uifajjpztq5c9f.jpg" width="798" height="199"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After fixing it, the extension was in good shape. The buttons were a bit oversized and the comments table deserved more vertical space, but neither was a significant change.&lt;/p&gt;

&lt;p&gt;Moving the export button to the bottom gave the table more room:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fef2hoqd26ng5qu8xoktk.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fef2hoqd26ng5qu8xoktk.jpg" width="800" height="449"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbf1fjura3ixcqjod4hl3.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbf1fjura3ixcqjod4hl3.jpg" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The bubble now updates in real time and auto-detects the current URL:&lt;/p&gt;

&lt;p&gt;I still wasn't satisfied with the table height, so I went back to ChatGPT for another design pass.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F04vtmu0cbao8pqx7rebf.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F04vtmu0cbao8pqx7rebf.jpg" width="799" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That iteration didn't add much — it just made the modal taller without solving the underlying layout issue.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvh2yjvxovx50cjg3fd8m.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvh2yjvxovx50cjg3fd8m.jpg" width="800" height="525"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The better solution: move the extraction controls into their own tab, freeing the table to take up the full panel. I also added Chrome storage persistence so exported comments survive a modal close.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pause/resume, Giphy replies, and shipping the MVP
&lt;/h2&gt;

&lt;p&gt;The final stretch involved several smaller improvements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Displaying the comments table in the extraction panel&lt;/li&gt;
&lt;li&gt;Ensuring the resume function correctly continues from the last pagination cursor&lt;/li&gt;
&lt;li&gt;Persisting comments to Chrome storage between sessions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pause/resume is now working reliably — users can stop an extraction mid-way and continue later without losing progress.&lt;/p&gt;

&lt;p&gt;Giphy comments were still rendering as blank rows. I also added a rough time estimate so users have some sense of how long a large export will take.&lt;/p&gt;

&lt;p&gt;Giphy comments are now displaying correctly:&lt;/p&gt;

&lt;p&gt;At this point, the extension was ready to ship. I generated a logo in ChatGPT and submitted to the Chrome Web Store.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making the logo
&lt;/h3&gt;

&lt;p&gt;Deployed to the Chrome Web Store:&lt;/p&gt;




&lt;h2&gt;
  
  
  What I learned building an Instagram comments exporter
&lt;/h2&gt;

&lt;p&gt;Social media tools break. That's not pessimism — it's the cost of building on Instagram's GraphQL endpoints instead of a public API. The difference between my 2022 scraper and this extension is scope: export-only, no automated likes or DMs, and a UX that respects how Instagram already loads data.&lt;/p&gt;

&lt;p&gt;The console-first workflow paid off again. Proving pagination against &lt;code&gt;https://www.instagram.com/api/graphql&lt;/code&gt; before writing the bubble modal saved days of extension debugging.&lt;/p&gt;

&lt;p&gt;And the product lesson stings a little: the YouTube comments exporter has zero users. Raw export may not be a compelling enough hook on its own. &lt;strong&gt;Instagram comments AI analysis&lt;/strong&gt; — sentiment, topic clustering, spam detection via Gemini Flash or similar — is probably where the real value lives. But a reliable exporter still has to come first.&lt;/p&gt;

&lt;p&gt;Have you tried exporting Instagram comments for research, moderation, or competitor analysis? I'd love to hear what broke on your end — or whether AI analysis on top of a CSV export is something you'd actually use.&lt;/p&gt;

&lt;p&gt;Thanks for reading!&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ: Instagram comments exporter
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do you export Instagram comments from a post?
&lt;/h3&gt;

&lt;p&gt;The reliable approach is a Chrome extension that runs on &lt;code&gt;instagram.com&lt;/code&gt;, reads the post's media ID from the page, then paginates through Instagram's GraphQL endpoint (&lt;code&gt;https://www.instagram.com/api/graphql&lt;/code&gt;) using the same cookies and CSRF token as your logged-in session. The first comment batch loads with the post; additional batches fetch when you scroll, so the exporter must loop pagination cursors until exhausted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do Instagram comment export tools stop working?
&lt;/h3&gt;

&lt;p&gt;Instagram changes DOM structure, API query hashes, and anti-bot measures every few months. Tools that scrape HTML break faster than tools that call the same GraphQL endpoints the web app uses — but even GraphQL-based exporters need maintenance when query variables or headers change.&lt;/p&gt;

&lt;h3&gt;
  
  
  What data should an Instagram comments CSV include?
&lt;/h3&gt;

&lt;p&gt;At minimum: username, comment text, timestamp, like count, and reply thread ID. Useful additions: profile picture URL (note: often temporary), Giphy/media URLs for sticker-only replies, and pagination state so exports can pause and resume on large threads (2,000+ comments on Reels is common).&lt;/p&gt;

&lt;h3&gt;
  
  
  Is exporting Instagram comments legal?
&lt;/h3&gt;

&lt;p&gt;This post is not legal advice. Exporting comments you can already see while logged in, for personal research or moderation on accounts you manage, carries a different risk profile than bulk scraping at scale with burner accounts. Check Instagram's Terms of Service and applicable laws in your jurisdiction before exporting or analyzing third-party data commercially.&lt;/p&gt;

&lt;h3&gt;
  
  
  How is this different from the Instagram DM Exporter?
&lt;/h3&gt;

&lt;p&gt;Same platform, same playbook. The &lt;a href="https://codedcitadel.com/blog/instagram-dm-exporter-chrome-extension/" rel="noopener noreferrer"&gt;Instagram DM Exporter&lt;/a&gt; paginates DM threads; this extension paginates post comments via GraphQL. Both reverse-engineer Instagram's internal API from the Network tab before becoming Chrome extensions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can you analyze Instagram comments with AI?
&lt;/h3&gt;

&lt;p&gt;Yes — and that may be more valuable than CSV export alone. Once comments are structured (username, text, timestamp), you can batch them through Gemini Flash or similar APIs for sentiment analysis, topic clustering, or spam detection. Export is step one; analysis is the product layer worth building next.&lt;/p&gt;

</description>
      <category>buildinginpublic</category>
      <category>instagramcommentsexporter</category>
      <category>chromeextensions</category>
    </item>
    <item>
      <title>Ep. 8: Bulk Export Gmail Emails to PDF in Seconds (How I Built This Chrome Extension From Scratch Under 15 hours)</title>
      <dc:creator>Diego Fortes</dc:creator>
      <pubDate>Sun, 21 Jun 2026 14:26:14 +0000</pubDate>
      <link>https://dev.to/codedcitadel/ep-8-bulk-export-gmail-emails-to-pdf-in-seconds-how-i-built-this-chrome-extension-from-scratch-477m</link>
      <guid>https://dev.to/codedcitadel/ep-8-bulk-export-gmail-emails-to-pdf-in-seconds-how-i-built-this-chrome-extension-from-scratch-477m</guid>
      <description>&lt;h1&gt;
  
  
  Bulk Export Gmail Emails to PDF in Seconds (How I Built This Chrome Extension From Scratch Under 15 hours)
&lt;/h1&gt;

&lt;p&gt;I've been using a Chrome extension for a long time that exports Gmail emails as PDF files. Recently it stopped working properly. It used to be my favourite extension.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frqkmio9oth99dxq9sf9y.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frqkmio9oth99dxq9sf9y.jpg" width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So I went looking for an alternative. There are quite a few extensions that do this, but the only one with decent reviews requires you to log in, which I find completely unnecessary for something this simple. And even then, the reviews have gone downhill lately and most of them haven't been updated in a while.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd1sl7id85cfrouzm9lgo.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd1sl7id85cfrouzm9lgo.jpg" width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'm also extremely picky about giving unknown extensions access to private data like email content. I prefer to build something myself when I can. Another thing that bothered me was how most of these extensions inject a permanent download button into the Gmail UI. I find that disruptive, and it's something I didn't want in whatever I was going to build.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Freuq2ifmugm9wuqaf3up.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Freuq2ifmugm9wuqaf3up.jpg" width="799" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I started this project at roughly 02:30 in the morning. Let's see how long it took.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I wanted to build
&lt;/h2&gt;

&lt;p&gt;A Gmail export tool that works without opening each email manually, doesn't require any third-party login, uses as few Chrome permissions as possible, only injects a button when you actually have emails selected (not a permanent UI element) and supports PDF, HTML, TXT and JSON exports. I wasn't 100% sure about all the formats at the start, I wanted to see how complex things got first.&lt;/p&gt;




&lt;h2&gt;
  
  
  Starting in the browser console, not in Cursor
&lt;/h2&gt;

&lt;p&gt;If you've watched any of my previous videos, you know I always start coding in the browser before I even open the editor. Unless the project needs a background.js or a database from the start, I like to have a rough working proof of concept before I think about architecture.&lt;/p&gt;

&lt;p&gt;My first step was opening Gmail and watching what happened in the Network tab.&lt;/p&gt;

&lt;p&gt;I opened an email, pasted a piece of its content into the network search bar, and found a JSON object containing the email data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ipfa8j2f89804dmewz5.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ipfa8j2f89804dmewz5.jpg" width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The next question was whether this data was accessible from the Gmail homepage too, or only after opening the email. And whether long emails were fully included in this object or if I'd need to actually "open" them to fetch the content.&lt;/p&gt;

&lt;p&gt;I opened Gmail in a new tab and searched for the same string again.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4fdq5ejjarnntnlboqvv.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4fdq5ejjarnntnlboqvv.jpg" width="799" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The data was there from the start. That meant I could theoretically fetch emails just by selecting their checkboxes, without ever opening them. I wasn't fully confident this would hold for very long threads, so I ran another test: I opened a conversation with 92 messages, grabbed a unique phrase from a message somewhere in the middle, and checked if the initial data object had it.&lt;/p&gt;

&lt;p&gt;It did. Gmail preloads entire thread data in its initial page payload. I was actually surprised.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdfmqhg52nycluk0qlxyw.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdfmqhg52nycluk0qlxyw.jpg" width="800" height="443"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Reverse-engineering Gmail's fetch API
&lt;/h2&gt;

&lt;p&gt;So the data was there, but I still needed to figure out how to fetch it on demand. Gmail was making a POST call to a specific internal URL and passing some tokens along with it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F93y67n6vr0ityk8wpw6b.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F93y67n6vr0ityk8wpw6b.jpg" width="800" height="442"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgbsyxdh6hfitaswk7vcc.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgbsyxdh6hfitaswk7vcc.jpg" width="799" height="617"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I copied all the headers, replaced the cookies and any sensitive data, and sent it to Claude asking it to write a browser script that could export selected emails based on an email ID or the currently open email. I also included some CSS selectors I'd found, and noted that unique email IDs were available in the &lt;code&gt;id&lt;/code&gt; element.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwoqy6uwmcg9zpdjopmrb.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwoqy6uwmcg9zpdjopmrb.jpg" width="798" height="176"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first version just opened each email in a new tab and tried to fetch from there, which wasn't what I wanted at all. So I simplified the ask: just console.log the HTML of selected emails and export it. That worked. Attachments weren't being fetched yet, but the core was there.&lt;/p&gt;

&lt;p&gt;This is where AI really shines. Once you understand the network layer and know which endpoints to target, you can just chat with Claude and let it handle the technical implementation. You do need to get yourself to that point first though.&lt;/p&gt;




&lt;h2&gt;
  
  
  Attachments, base64 and a lot of debugging
&lt;/h2&gt;

&lt;p&gt;Getting attachments right took way longer than I expected.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6kpk7a38ytk6bdsg1714.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6kpk7a38ytk6bdsg1714.jpg" width="799" height="476"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The attachment URLs in Gmail are authenticated, meaning they only work for the currently logged-in user. Fetching them cross-origin from a script triggers CORS errors. I ran a quick test: grabbed an attachment's &lt;code&gt;src&lt;/code&gt; from an open email and tried to open it directly in a new tab.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd3rf8ahk39za2fxt173f.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd3rf8ahk39za2fxt173f.jpg" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyw635v6hd37z0xo35k72.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyw635v6hd37z0xo35k72.jpg" width="799" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It worked, and the size was fixable (the URL had a quality-capping query string that I could strip). So the plan became: find all images in the HTML, fetch their &lt;code&gt;src&lt;/code&gt;, convert them to base64, and inline them into the exported file.&lt;/p&gt;

&lt;p&gt;The approach Claude suggested was to replace filename references in the HTML with base64-encoded &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tags. Almost worked on the first try, but the &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; tag wasn't being injected correctly into the HTML head, there was a class being added twice, and the latest CSS was overwriting the earlier one. I fixed those one by one.&lt;/p&gt;

&lt;p&gt;There was also a bug where attachment images were being appended at the bottom of the email instead of staying inline. And Claude was using regex to parse the HTML, which is the wrong tool for this. My approach instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;get the data from this console log:

[CCgmail] Attachments (3): (3) [{…}, {…}, {…}]

for each of them, we need to:

find the exact 'filename' in the html
convert the image to base64
replace the file name with &amp;lt;img class="CCG-image" src="base64" /&amp;gt;
download .html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Almost perfect after that. One last issue: the previous image thumbnail was still showing after the replacement.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2kjhobn3ug9593t9onb3.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2kjhobn3ug9593t9onb3.jpg" width="800" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Fixed it by also removing all &lt;code&gt;a&amp;gt;img[class]:not([class*='CCG'])&lt;/code&gt; after the replacement.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1nj997w7hmzb61p3n8hl.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1nj997w7hmzb61p3n8hl.jpg" width="800" height="563"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Editing live websites is always tricky. Things break without warning, debugging takes longer than you think, and regex is almost never the right answer for HTML parsing. Still my favourite type of work though, lol.&lt;/p&gt;

&lt;p&gt;One more thing I caught: Gmail won't allow &lt;code&gt;DOMParser&lt;/code&gt; in scripts running on the page because of CORS restrictions. That meant background.js was going to be necessary for parsing HTML properly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpzp9b3fq1cpurldw84c2.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpzp9b3fq1cpurldw84c2.jpg" width="767" height="516"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The quality of the base64 images was also an issue at first. I had to strip the size-capping query strings from the URLs before converting them, which made a visible difference.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fio8azpbf9krbb2cs7y69.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fio8azpbf9krbb2cs7y69.jpg" width="799" height="346"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Building the Chrome extension
&lt;/h2&gt;

&lt;p&gt;I'm always very strict about permissions. Too many permissions slow down the Chrome Web Store review process and increase the chance of getting denied. If you're not using a permission, don't ask for it.&lt;/p&gt;

&lt;p&gt;I brainstormed the architecture with Claude, got a prompt written for Cursor, and kicked off the first build.&lt;/p&gt;

&lt;p&gt;My initial instinct was to use the popup to trigger exports, but I ended up going with a subtler approach: a button injected into Gmail's existing toolbar, only visible when you have emails selected.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fozy90volx9af8yxhmvqx.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fozy90volx9af8yxhmvqx.jpg" width="799" height="126"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To match Gmail's visual style as closely as possible, I used a function I'd built in my AI Bookmark Manager extension that exports all CSS from the current page. I then fed that CSS to ChatGPT to design the extension UI.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fopd6pk1dtf0huqgdpe1y.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fopd6pk1dtf0huqgdpe1y.jpg" width="798" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdoredzk2osxlnpvk9jwg.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdoredzk2osxlnpvk9jwg.jpg" width="669" height="518"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Claude suggested adding a history tab, which I hadn't thought of but wasn't complex to add. Why not.&lt;/p&gt;

&lt;p&gt;The first build had some bugs and the popup design wasn't great, but that was fine. Functionality first, always.&lt;/p&gt;




&lt;h2&gt;
  
  
  The PDF export nightmare
&lt;/h2&gt;

&lt;p&gt;This was by far the hardest part of the project, which is ironic because it's the whole point of the extension.&lt;/p&gt;

&lt;p&gt;I knew I needed to convert the email HTML to PDF using html2pdf. I sent the HTML string from content.js to background.js to handle the conversion. The output was completely wrong.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg2nu4zg6rzhq17908zlt.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg2nu4zg6rzhq17908zlt.jpg" width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I ran a simple "hello world" test to isolate the issue. It was cutting things off.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fylwhqgp0l8gl48kvrkvy.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fylwhqgp0l8gl48kvrkvy.jpg" width="800" height="326"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After some debugging I got to a "good enough" state, but then I found another bug: all selected emails were being exported into one single HTML file instead of separate ones. Fixed that.&lt;/p&gt;

&lt;p&gt;HTML exports worked fine with external images, but PDF exports needed images converted to base64 first. I added that. Things were looking better.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmbsx7pnaiyfpm0krftau.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmbsx7pnaiyfpm0krftau.jpg" width="800" height="192"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then came the attachments issue. The zip files were downloading but the attachments inside were corrupted, and some had no file extension at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3hfl9nxddc7dnn0ec726.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3hfl9nxddc7dnn0ec726.jpg" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkfe5psge8s1sdgt7ns8u.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkfe5psge8s1sdgt7ns8u.jpg" width="800" height="154"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I moved the PDF conversion to background.js so I could use &lt;code&gt;querySelectorAll&lt;/code&gt; instead of regex. Then I remembered &lt;code&gt;DOMParser&lt;/code&gt; doesn't work in background.js either. I was coding while tired, lol.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn8xxej56xjabtdfm4t6u.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn8xxej56xjabtdfm4t6u.jpg" width="533" height="595"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Some emails were producing empty PDFs. 8mb of HTML rendering into a 10kb PDF.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6g2iescs9svy6rvreqte.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6g2iescs9svy6rvreqte.jpg" width="800" height="851"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fylrr1spwz9dlneq20he9.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fylrr1spwz9dlneq20he9.jpg" width="800" height="492"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftknitdc84iavv2fvtasv.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftknitdc84iavv2fvtasv.jpg" width="396" height="149"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The attachments still weren't downloading correctly either. The problem was a back-and-forth between content.js and background.js that wasn't sustainable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnb3uriod51x3dfniomke.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnb3uriod51x3dfniomke.jpg" width="644" height="331"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is one of those moments where not understanding how Chrome extensions work under the hood would either get you completely stuck or make you spend a lot of money asking AI to guess. Claude kept suggesting doing everything in content.js, but that's not possible because we need background.js for DOM parsing. And background.js can't download authenticated files because of CORS.&lt;/p&gt;

&lt;p&gt;The solution: fetch the HTML in content.js, send it to background.js to find attachments using &lt;code&gt;querySelectorAll&lt;/code&gt;, send those back to content.js as a JSON object, and download everything from content.js. Then zip it all.&lt;/p&gt;

&lt;p&gt;I tested the download and zip logic manually in the browser first.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7hl2kd9ba9d0l3bt5auv.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7hl2kd9ba9d0l3bt5auv.jpg" width="800" height="297"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Worked perfectly. So it was just a matter of wiring it correctly in the extension. Got there.&lt;/p&gt;

&lt;p&gt;The PDF being empty on large emails was still an open issue though. Claude's diagnosis was that html2pdf was running on a frame that hadn't fully loaded yet, which makes sense for emails with a lot of content. I also found that some emails were triggering an &lt;code&gt;overflow-x&lt;/code&gt; scroll that was cutting off the PDF content.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl4rmrfe7blz4phdzp16r.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl4rmrfe7blz4phdzp16r.jpg" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy498pijjig4753s1siud.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy498pijjig4753s1siud.jpg" width="800" height="356"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I sent the HTML (with sensitive data redacted) to Claude and got a CSS fix for the overflow issue. That helped. But the empty PDF problem on large emails was still there.&lt;/p&gt;

&lt;p&gt;The actual fix: do the PDF conversion in an offscreen HTML page. Create a &lt;code&gt;.html&lt;/code&gt; file bundled with the extension, inject the email HTML into it, let it fully render, then convert. This is Chrome's &lt;code&gt;offscreen&lt;/code&gt; permission, which adds almost nothing to the permissions footprint.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm4jt9l855xcc71y88cwn.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm4jt9l855xcc71y88cwn.jpg" width="678" height="592"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That fixed it. The PDF was finally being exported correctly, including all images.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxu14qxr1ylwdf00g3f8i.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxu14qxr1ylwdf00g3f8i.jpg" width="798" height="144"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're building something similar: html2pdf fails silently on large emails. An offscreen document lets you render the full HTML in a real DOM before conversion, and that's the difference between a 10kb empty PDF and a proper export.&lt;/p&gt;




&lt;h2&gt;
  
  
  Injecting the button into Gmail's UI
&lt;/h2&gt;

&lt;p&gt;Now that the export logic was solid, I needed to wire it to a button inside Gmail.&lt;/p&gt;

&lt;p&gt;First I built a function to monitor checkboxes:&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="nx"&gt;td&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;tooltip&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aria&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whenever at least one checkbox is checked, the button appears. When none are checked, it hides. I spent some time finding a CSS selector that wouldn't break every time Gmail updates its classes. Gmail randomises class names, so I had to find something structural:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"navigation"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="nd"&gt;:not&lt;/span&gt;&lt;span class="o"&gt;([&lt;/span&gt;&lt;span class="nt"&gt;class&lt;/span&gt;&lt;span class="o"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;id&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nd"&gt;:first-of-type&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nd"&gt;:first-of-type&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing I discovered: Gmail already has a &lt;code&gt;data-tooltip&lt;/code&gt; attribute on its toolbar elements that handles tooltips automatically. I didn't need to build my own tooltip system. Worth exploring the existing code before building things that already exist.&lt;/p&gt;

&lt;p&gt;I also replicated Gmail's bubble hover animation. It's triggered by JavaScript that adds a class on hover, so I had to attach a debugger to a setTimeout to give myself time to hover over the element and inspect it while paused. Took a few minutes but got there.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcodedcitadel.com%2Fblog-images%2Fep8-gmail-pdf%2FSCREENSHOT_12-06-2026-08h25.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcodedcitadel.com%2Fblog-images%2Fep8-gmail-pdf%2FSCREENSHOT_12-06-2026-08h25.jpg" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  JSON, TXT, history and keeping permissions tight
&lt;/h2&gt;

&lt;p&gt;The JSON export followed the same flow. I asked Claude to suggest the best structure for it and sent the prompt to Cursor. One bug: images were being converted to base64 by default, which should be optional. Fixed via settings.&lt;/p&gt;

&lt;p&gt;For TXT export I added an option to strip hyperlinks, since plain text with a wall of URLs is pretty unreadable.&lt;/p&gt;

&lt;p&gt;Claude also suggested a history tab, so I added pagination to that.&lt;/p&gt;

&lt;p&gt;At this point I reviewed the manifest and tightened everything up. Final permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"downloads"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"storage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"offscreen"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"host_permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://mail.google.com/*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PDF export still worked fine with just those four.&lt;/p&gt;




&lt;h2&gt;
  
  
  The logo (and GPT being stubborn)
&lt;/h2&gt;

&lt;p&gt;I wanted a Gmail-inspired palette but something clearly distinct from Gmail's actual icon.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi2zoa8bmmwdtd5n57ijn.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi2zoa8bmmwdtd5n57ijn.jpg" width="800" height="365"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;First attempt was too close to Gmail's real icon.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpvcb86ukx919dxruu7uz.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpvcb86ukx919dxruu7uz.jpg" width="677" height="656"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Second attempt, different colors, still not great.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyf7sd9q014h2v3o23iqa.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyf7sd9q014h2v3o23iqa.jpg" width="650" height="628"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I asked GPT to just take the icons I sent and be creative with the colors. "Let your inner da Vinci shine, GPT."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fanyqi0mskelj9qwm70i2.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fanyqi0mskelj9qwm70i2.jpg" width="689" height="635"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Better. Just needed to be a bit bigger.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4r5sdsdm6i8d0cu8pj1c.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4r5sdsdm6i8d0cu8pj1c.jpg" width="693" height="648"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That one is ugly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1u4hndpnsiz4slefvoal.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1u4hndpnsiz4slefvoal.jpg" width="722" height="667"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GPT completely ignored the sizing request, as it sometimes does. One last attempt, and if it didn't work I was keeping the smaller version.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1u4hndpnsiz4slefvoal.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1u4hndpnsiz4slefvoal.jpg" width="722" height="667"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No luck. AGI is here and it's stubborn, lol. I took what I had into Photopea for some basic editing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8dptba5rjs5q766008o1.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8dptba5rjs5q766008o1.jpg" width="799" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The edges were a bit rough so I sent it back to GPT one last time and it came out nicely.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3j2wpqsijw5niw8zpkz3.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3j2wpqsijw5niw8zpkz3.jpg" width="800" height="638"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I asked Cursor to replace the icon throughout the project.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fobqe6upqqwqpki796hvv.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fobqe6upqqwqpki796hvv.jpg" width="800" height="347"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Shipping the MVP
&lt;/h2&gt;

&lt;p&gt;I kept the popup design simple intentionally. My rule of thumb: if the user is going to spend more than 50% of their time looking at a UI, then put real effort into it. For an export tool, the user clicks a button and waits. The UI doesn't need to be beautiful, it needs to be clear.&lt;/p&gt;

&lt;p&gt;I'll polish things after it goes live. Functional ugly MVP first.&lt;/p&gt;

&lt;p&gt;One thing I noticed before packaging: Cursor had crept extra permissions back into the manifest. This is why I always review manifest.json changes manually before zipping. I caught it and removed them.&lt;/p&gt;

&lt;p&gt;For the name I went with "Gmail to PDF: Save Emails as PDF, HTML, TXT". Descriptive, good for SEO and tells you exactly what it does.&lt;/p&gt;

&lt;p&gt;Uploaded.&lt;/p&gt;




&lt;h2&gt;
  
  
  By the numbers
&lt;/h2&gt;

&lt;p&gt;120~ prompts total (90~ with Cursor, the rest spread across Claude and GPT) and 15~ hours across two days.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy4oqdgzqjves21xs9hx5.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy4oqdgzqjves21xs9hx5.jpg" width="799" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It was a fun project. There are still a few things to fix (the download button doesn't appear when you're inside an individual email, for example), but I'm happy with where it landed. I didn't pad it with useless things, and it does exactly what it promises.&lt;/p&gt;

&lt;p&gt;One more brick to the Citadel. Let's keep moving.&lt;/p&gt;

&lt;p&gt;Have you run into the same problem with Gmail PDF extensions breaking? Or built something similar? I'd love to hear what approach you took.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ: Gmail to PDF export
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the best way to save Gmail emails as PDF?
&lt;/h3&gt;

&lt;p&gt;The most reliable approach for a self-built tool is: fetch email HTML from Gmail's internal API in content.js (where your session cookies live), inline images as base64, then convert HTML to PDF in an offscreen document so large emails render fully before html2pdf runs. Extensions that skip the offscreen step often produce empty or truncated PDFs on threads over ~8MB.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do Gmail PDF Chrome extensions stop working?
&lt;/h3&gt;

&lt;p&gt;Gmail changes its DOM, class names and internal API payloads regularly. Extensions that scrape the UI with brittle selectors or outdated fetch patterns break without updates. Mixed reviews and stale release dates on the Chrome Web Store are usually a sign the maintainer hasn't kept up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do you need to log in to export Gmail to PDF?
&lt;/h3&gt;

&lt;p&gt;No. If the extension runs inside your already-authenticated Gmail tab, it inherits your session. A separate login to a third-party service is unnecessary for basic export functionality and is a privacy red flag for email data.&lt;/p&gt;

&lt;h3&gt;
  
  
  What permissions should a Gmail export extension need?
&lt;/h3&gt;

&lt;p&gt;For this project, the final minimal set was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"downloads"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"storage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"offscreen"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"host_permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://mail.google.com/*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Extra permissions slow Chrome Web Store review and increase the risk of denial if they're not used.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can you export Gmail emails to formats other than PDF?
&lt;/h3&gt;

&lt;p&gt;Yes. This extension supports PDF, HTML, TXT and JSON. HTML preserves layout and links; TXT strips formatting (optionally without links); JSON is useful for archiving structured data with optional base64 images.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is html2pdf so hard with Gmail emails?
&lt;/h3&gt;

&lt;p&gt;Gmail emails often include authenticated image URLs, inline pasted images, large thread HTML and layout that triggers &lt;code&gt;overflow-x&lt;/code&gt; scroll. html2pdf runs on a snapshot of the DOM - if images aren't base64-inlined, if the frame is empty, or if the renderer cuts off overflow content, you get blank or tiny PDFs. The fix requires a deliberate pipeline: content script fetch, background parse, content script download, offscreen render, then convert.&lt;/p&gt;

</description>
      <category>buildinginpublic</category>
      <category>gmailtopdf</category>
      <category>chromeextensions</category>
    </item>
    <item>
      <title>I Built a Chrome Extension That Bookmarks AI Replies on Claude, ChatGPT and Grok (Free)</title>
      <dc:creator>Diego Fortes</dc:creator>
      <pubDate>Sun, 21 Jun 2026 14:21:02 +0000</pubDate>
      <link>https://dev.to/codedcitadel/i-built-a-chrome-extension-that-bookmarks-ai-replies-on-claude-chatgpt-and-grok-free-2iac</link>
      <guid>https://dev.to/codedcitadel/i-built-a-chrome-extension-that-bookmarks-ai-replies-on-claude-chatgpt-and-grok-free-2iac</guid>
      <description>&lt;h1&gt;
  
  
  I Coded a Chrome Extension That Bookmarks AI Replies on Claude, ChatGPT and Grok - Here's Exactly How It Went
&lt;/h1&gt;

&lt;p&gt;Have you ever had a perfect AI reply that you knew you'd never find again?&lt;/p&gt;

&lt;p&gt;That is exactly what started this. I was using Claude and wanted to save a specific message - not the whole conversation, just that one reply. So I did what any developer does: I Googled it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffjbfwtasshfbuex8p7h0.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffjbfwtasshfbuex8p7h0.jpg" width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I came across something called "AI Toolbox for Claude" - but it had quite a few negative reviews, mostly because it was a paid service. That got me thinking: how complex would it actually be to build something like this myself?&lt;/p&gt;

&lt;p&gt;That thought turned into AI Bookmark - a Chrome extension that lets you star and save specific replies from Claude, ChatGPT and Grok, with cloud sync via Supabase. This is the full story of how it was built, including every bug, every dead end and every moment where the AI surprised me.&lt;/p&gt;




&lt;h2&gt;
  
  
  Ideating the Concept
&lt;/h2&gt;

&lt;p&gt;Before writing a single line of code, I had to think through the architecture.&lt;/p&gt;

&lt;p&gt;My first instinct was local storage - it is simple, it requires no backend, and for a validation project it is more than enough. But there is a real problem with local storage in Chrome extensions: if a user uninstalls the extension, the data is gone. That is a terrible experience for something that is supposed to help people save things. So I knew I would need to offer an import/export function at minimum - and depending on how the project grew, a proper cloud option would become necessary.&lt;/p&gt;

&lt;p&gt;In terms of what data to actually store, the requirements were fairly clear. We would need: the URL of the conversation, the chat title, the content of the reply itself, and some way to scroll back to that specific message when the user wants to revisit it. That last part - the scroll-to mechanism - would turn out to be the most interesting engineering problem of the whole project.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwowsmdo2kw87ctrbvbd9.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwowsmdo2kw87ctrbvbd9.jpg" width="800" height="398"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One more thing I wanted from the start: seamless visual integration. I did not want the extension to feel bolted on. I wanted the bookmark button to look like it belonged on Claude and ChatGPT natively. To do that, I planned to inspect their CSS and build the UI to match.&lt;/p&gt;




&lt;h2&gt;
  
  
  Brainstorming the Technical Approach
&lt;/h2&gt;

&lt;p&gt;Once the concept was clear, I sat down with Claude to work through the technical details.&lt;/p&gt;

&lt;p&gt;One of the first things I needed to figure out was whether sharing a single Supabase account across multiple projects was viable. I only have one Supabase account, and several of my extensions already use it - each one gets its own table. That setup works fine, and this project would follow the same pattern.&lt;/p&gt;

&lt;p&gt;The more interesting question was: how do you link back to a specific reply inside a conversation?&lt;/p&gt;

&lt;p&gt;Grok actually makes this easy - it has a native "share reply" feature that generates a URL directly to that message. For Grok, storing that URL would be sufficient.&lt;/p&gt;

&lt;p&gt;Claude and ChatGPT, however, do not have this. So I had to think of an alternative.&lt;/p&gt;

&lt;p&gt;My initial idea was to store the &lt;code&gt;window.scrollY&lt;/code&gt; position - essentially recording how far down the page the user was when they bookmarked the reply. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fafqe5kiwdb40jdlefq0a.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fafqe5kiwdb40jdlefq0a.jpg" width="798" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is a blunt instrument, but it works as a fallback. Claude suggested something more elegant: look for a DOM element that identifies the specific reply by index. Something like &lt;code&gt;data-index="7"&lt;/code&gt;, where 7 indicates which reply in the conversation that is.&lt;/p&gt;

&lt;p&gt;I thought that was smart. So I grabbed the HTML of Claude's interface and asked whether anything like that existed.&lt;/p&gt;

&lt;p&gt;It did not. Unfortunately.&lt;/p&gt;

&lt;p&gt;That meant I would need to use a combination of approaches - DOM selectors where possible, scroll position as a fallback. More on that in a moment.&lt;/p&gt;

&lt;p&gt;One more decision at this stage: I would store not just the AI reply, but also the prompt the user sent before it. Context matters. A reply without the question that generated it is much less useful.&lt;/p&gt;




&lt;h2&gt;
  
  
  The First "Hello World" - Finding Replies in Claude's DOM
&lt;/h2&gt;

&lt;p&gt;The first real coding step was figuring out how to identify individual replies in Claude's interface and inject a bookmark button next to each one.&lt;/p&gt;

&lt;p&gt;After some exploration, I found a useful CSS class: &lt;code&gt;font-claude-response&lt;/code&gt;. This let me count how many responses were visible on screen. The full selector that worked was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.group&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;.contents&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;'group'&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the first working script - it injects a bookmark button into each reply and logs which response number was clicked:&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.group &amp;gt; .contents + [role='group'] &amp;gt; * &amp;gt; *&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertAdjacentHTML&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;beforeend&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;button class="ai-bookmark__bookmark"&amp;gt;🔖&amp;lt;/button&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.ai-bookmark__bookmark&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&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="o"&gt;=&amp;gt;&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="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stopPropagation&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;parent&lt;/span&gt; &lt;span class="o"&gt;=&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;closest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-test-render-count]&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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.font-claude-response&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;all&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.font-claude-response&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;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`This is font-claude-response #&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; of &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;all&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcaevxcrqpdu8u3kl5wcz.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcaevxcrqpdu8u3kl5wcz.jpg" width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Not production-ready, but it confirmed the concept worked. I could identify which reply number had been bookmarked. The next challenge was scrolling back to it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Debugging Claude and the Scroll Mechanism
&lt;/h2&gt;

&lt;p&gt;The scroll-to feature took longer than expected.&lt;/p&gt;

&lt;p&gt;My first thought was to track &lt;code&gt;window.scrollY&lt;/code&gt; - but Claude's interface does not have &lt;code&gt;overflow-y: scroll&lt;/code&gt; on the &lt;code&gt;body&lt;/code&gt; or &lt;code&gt;html&lt;/code&gt; elements. It uses a specific inner div that handles the scroll. I had to figure out which div that was, attach the scroll tracker to it, and read from there instead.&lt;/p&gt;

&lt;p&gt;To test this, I wrote a quick script: click anywhere on the screen, log the scroll position, then scroll back to that exact position. It worked. The position was trackable and scrollable.&lt;/p&gt;

&lt;p&gt;The remaining problem was accuracy. Scroll position alone is not reliable - it changes depending on window size, screen resolution, and whether the conversation has fully loaded. So the final approach combined both methods: scroll to the approximate position first, then scan the DOM for the specific reply element that should now be visible, and highlight it so the user knows they are in the right place.&lt;/p&gt;

&lt;p&gt;After a long debugging session, I finally had a function that could both bookmark a reply (capturing the content and the prompt before it) and scroll back to it reliably. Claude was working. Time to look at ChatGPT.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjw10wn8bo3dsjac8970u.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjw10wn8bo3dsjac8970u.jpg" width="800" height="350"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Working on ChatGPT
&lt;/h2&gt;

&lt;p&gt;The first thing I noticed when opening ChatGPT's DevTools is how heavy it is. Just opening the console was enough to make it sluggish - though that might also be my laptop.&lt;/p&gt;

&lt;p&gt;The good news though: ChatGPT makes the DOM work considerably easier than Claude. They have a specific attribute called &lt;code&gt;data-testid="conversation-turn-{n}"&lt;/code&gt; on each message. This meant I could identify any reply with a single selector, and get the prompt before it just by fetching &lt;code&gt;conversation-turn-{n-1}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The implementation worked almost immediately - with one small bug. The stored prompt was including the text "You said:" at the beginning, which is a label ChatGPT injects into the DOM. A more specific CSS selector fixed it.&lt;/p&gt;

&lt;p&gt;One architectural decision I made at this stage: I would keep separate content scripts for each platform (claude.js, chatgpt.js, grok.js) rather than one unified script with conditionals. It adds some redundancy, but each platform is different enough that a single script would become difficult to maintain. If ChatGPT or Claude overhauls their DOM structure - which they will - I want to be able to update one file without risking the others.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2i6gcm57oa6kqrdo0ssc.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2i6gcm57oa6kqrdo0ssc.jpg" width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Working on Grok
&lt;/h2&gt;

&lt;p&gt;Grok seemed like it would be the simplest, given the native share URL feature. It turned out to be more complicated in practice.&lt;/p&gt;

&lt;p&gt;My initial plan was to programmatically trigger the share button and read the URL from the clipboard. That did not work - the button was not being focused correctly for &lt;code&gt;$0.click()&lt;/code&gt;, and even with a timeout to wait for focus, reading clipboard content from a content script is not straightforward.&lt;/p&gt;

&lt;p&gt;So I fell back to the same approach as Claude and ChatGPT - DOM selectors and scroll position. I also ran into a manifest.json issue that cost me some time: the permissions were pointing to &lt;code&gt;x.com&lt;/code&gt; instead of &lt;code&gt;grok.com&lt;/code&gt;, so the content script was never being injected at all. Once that was fixed, the script worked correctly.&lt;/p&gt;

&lt;p&gt;There was one CSS quirk worth noting: the &lt;code&gt;~&lt;/code&gt; symbol in CSS selectors was useful for finding distant siblings in Grok's DOM structure - something I had not needed for the other platforms.&lt;/p&gt;

&lt;p&gt;One remaining UX issue: Grok's bookmark icon was not hiding alongside Grok's native action buttons on hover the way it should. I logged it to fix later and moved on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fad4nmaz5qdzigx4ls9qr.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fad4nmaz5qdzigx4ls9qr.jpg" width="799" height="375"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Figuring Out the UI
&lt;/h2&gt;

&lt;p&gt;With the core functionality working across all three platforms, it was time to think about how the user would actually view and manage their bookmarks.&lt;/p&gt;

&lt;p&gt;My first instinct was to use the extension popup - the small window that appears when you click the extension icon. But the UX felt clunky. The popup closes the moment you click anywhere outside it, which means you would lose your position every time you tried to navigate to a bookmark. The state management overhead was not worth it.&lt;/p&gt;

&lt;p&gt;A sidebar was the next option. But sidebars require an additional browser permission, and I try to keep the permissions list as minimal as possible. Users are rightfully suspicious of extensions that ask for too much access.&lt;/p&gt;

&lt;p&gt;The most sensible solution was a dedicated HTML page - essentially a local web app that opens in a new tab. The user gets a full-page view of all their bookmarks, can filter by platform, open conversations, and manage tags and notes without any of the state-management headaches of the popup.&lt;/p&gt;

&lt;p&gt;Now, I will admit something: I usually code functionality first and worry about design later. For this project, I broke that rule. I wanted to see how the thing would look before committing to the layout. So I asked Claude to describe the UI and took that description to ChatGPT to generate some design concepts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa34pc78e05qwht8h2khz.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa34pc78e05qwht8h2khz.jpg" width="800" height="521"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first concept was decent but not quite right. I sent another reference. Still not satisfied. Then another one. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fufr7gtkxtettb1s3jitt.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fufr7gtkxtettb1s3jitt.jpg" width="799" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I eventually caught myself spending more time on design than on the features that actually needed to be shipped - which is a rookie mistake I am apparently still capable of making. I settled on the second design concept, which was cleaner, and moved on.&lt;/p&gt;

&lt;p&gt;I sent the designs to Claude and asked it to code th HTML, CSS and JS so we could use it for our Chrome extension.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7l6clwwo96rycm64586p.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7l6clwwo96rycm64586p.jpg" width="799" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fisqq5f4u9bi2mz8md7q8.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fisqq5f4u9bi2mz8md7q8.jpg" width="800" height="386"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Building the Chrome Extension - First Build
&lt;/h2&gt;

&lt;p&gt;With a design reference and a detailed prompt, I sent everything to Cursor and let it build version 0.0.1 of AI Bookmark.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbm1rxy4c0aobbfw25ab0.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbm1rxy4c0aobbfw25ab0.jpg" width="800" height="396"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first bug appeared immediately: HTML was being injected into the Claude interface even when no reply elements were found on the page. The fix was straightforward - only inject anything if the target elements actually exist in the DOM.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuue3v9urkulo68zd7r1j.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuue3v9urkulo68zd7r1j.jpg" width="799" height="357"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The second bug was more interesting. As new replies were generated during a conversation, the bookmark button was not being injected into them. The extension only ran once on page load and did not account for dynamic content. I considered monitoring API calls, but a MutationObserver was simpler and more reliable for this use case.&lt;/p&gt;

&lt;p&gt;Then came the Supabase issue. When testing, the bookmarks were being saved to Chrome's local storage instead of Supabase. Cursor had quietly added a fallback that stored locally in development mode. Understandable logic, but not what I wanted - the whole point was cloud sync. I fixed the logic, created a dummy user in Supabase for development testing, and confirmed that saves were going through correctly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stars, Icons and Syncing State
&lt;/h2&gt;

&lt;p&gt;With the save mechanism working, I focused on the UI of the bookmark button itself.&lt;/p&gt;

&lt;p&gt;I replaced the temporary 🔖 emoji with a proper star SVG icon - one that fills in when a reply is already bookmarked and returns to outline when it is removed. (The irony of using Claude to code its own code). I also needed to fetch the conversation title for each bookmark, which turned out to be straightforward: Claude's interface has a unique &lt;code&gt;aria-label&lt;/code&gt; on the conversation element that could be read directly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F64gk4s4dlus2viiytg6m.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F64gk4s4dlus2viiytg6m.jpg" width="799" height="329"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The sync logic worked as follows: clicking the star saves to Supabase and marks it as active. Clicking it again deletes the record. On page reload, the extension checks Supabase and restores the star state for any previously bookmarked replies. This meant the UI stayed consistent even across sessions and devices.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Scroll-to Feature in ChatGPT - A Longer Battle
&lt;/h2&gt;

&lt;p&gt;Getting scroll-to working on Claude was relatively smooth. ChatGPT was a different story.&lt;/p&gt;

&lt;p&gt;The core issue was timing. ChatGPT's page is heavy - it loads asynchronously, and content continues rendering well after the initial page load. The extension was executing the scroll function before the target element even existed in the DOM.&lt;/p&gt;

&lt;p&gt;The fix involved two layers: first, wait for the page to fully load before attempting to scroll. Second, once scrolling begins, check whether the target element is actually visible in the viewport. If it is not, adjust and try again until it lands on screen.&lt;/p&gt;

&lt;p&gt;There was also a sign bug - in some cases &lt;code&gt;scrollY&lt;/code&gt; was returning a negative value, requiring a multiplication by -1 to get the correct position.&lt;/p&gt;

&lt;p&gt;Debugging all of this was slowed significantly by ChatGPT's performance with DevTools open. The page became noticeably laggy, which made it hard to distinguish between actual bugs and DevTools overhead. Once I accepted that and added appropriate timeouts and load-wait logic, things stabilized.&lt;/p&gt;

&lt;p&gt;One additional complication: ChatGPT conversations with lots of images required waiting for all images to load before scrolling, since image loading affects the scroll position of everything below.&lt;/p&gt;

&lt;p&gt;After all of that - it worked.&lt;/p&gt;




&lt;h2&gt;
  
  
  Toast Notifications
&lt;/h2&gt;

&lt;p&gt;With the core functionality solid, I added a small UX improvement: toast notifications.&lt;/p&gt;

&lt;p&gt;When a user opens a bookmarked conversation and the extension starts scrolling to the target reply, a toast appears explaining that the starred message is being located. This prevents the moment of confusion where the page scrolls on its own and the user does not know why.&lt;/p&gt;

&lt;p&gt;I also added toasts for the bookmark add/remove action, with a note that it may take a moment for the message to sync. I asked ChatGPT to write the toast HTML and CSS, then forwarded it to Claude to integrate into the project.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb6jst4114j0l2g0o75st.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb6jst4114j0l2g0o75st.jpg" width="800" height="396"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Login with Google + Supabase Auth
&lt;/h2&gt;

&lt;p&gt;I will be honest: authentication in Chrome extensions is the dark souls of extension development. Every time I do it, it takes at least 2-3 hours and involves bugs I have hit before and somehow forgotten about.&lt;/p&gt;

&lt;p&gt;The login flow uses Google OAuth wired through Supabase Auth. One thing I have learned through repeated pain: always choose "web application" as the OAuth type in Google Cloud Console, not "Chrome extension." The former works. The latter causes subtle issues that are difficult to diagnose.&lt;/p&gt;

&lt;p&gt;The other thing I always forget: adding &lt;code&gt;chromiumapp&lt;/code&gt; to the redirect URLs in Supabase. Without it, the auth callback never completes. I have a boilerplate Chrome extension project that documents all of this, and I fed it to the AI to remind myself of the steps. That saved significant time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn3nqtv6s3gdusry8roww.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn3nqtv6s3gdusry8roww.jpg" width="800" height="266"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once login was working, I added a &lt;code&gt;dev&lt;/code&gt; environment variable that bypasses Supabase auth entirely during development, and made sure the &lt;code&gt;zip-and-build.bat&lt;/code&gt; script automatically disables it before packaging. This way I never accidentally ship a version with the dev backdoor enabled.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bookmark Manager - UX Improvements
&lt;/h2&gt;

&lt;p&gt;The bookmark manager HTML page went through a few rounds of iteration.&lt;/p&gt;

&lt;p&gt;The initial version showed bookmarks as a flat list. It worked, but the UX felt messy - there was no clear way to preview a reply without navigating away from the page. I added a modal that shows the full conversation turn (prompt + reply) when a bookmark is clicked, along with a quick-preview mode for scanning bookmarks without opening them fully.&lt;/p&gt;

&lt;p&gt;I also added tags and notes to each bookmark - small fields inside the modal where users can annotate their saved replies. These sync to Supabase in real time. I initially had them auto-saving on every keystroke, which would burn through unnecessary database writes. I switched to a save button with a checkmark animation on confirmation instead.&lt;/p&gt;

&lt;p&gt;One last UX fix in the popup: it was taking too long to show content on open, displaying a loading screen that felt jarring. I replaced it with loading skeletons - placeholder shapes that match the layout of the actual content while it loads. Much cleaner.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbieefhsh94mv9iaw4em7.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbieefhsh94mv9iaw4em7.jpg" width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Logo and Final Touches
&lt;/h2&gt;

&lt;p&gt;The last step before publishing was the logo.&lt;/p&gt;

&lt;p&gt;I am fond of shadows and gradients in logos - I think they give things a sense of depth that flat designs sometimes lack. After a few iterations, I landed on something I was genuinely happy with. I asked Cursor to resize and apply it consistently across all the assets.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2For67btrar91b83ju3dym.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2For67btrar91b83ju3dym.jpg" width="800" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvg37n1jez97euslf1s24.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvg37n1jez97euslf1s24.jpg" width="799" height="377"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For the Chrome Web Store listing, I asked Cursor to write the description in the specific format I use for all my extensions. Then ChatGPT generated the screenshots. One trick I have learned with AI-generated text in store listings: give it explicit rules about what not to do (never use em dashes, avoid marketing buzzwords, keep sentences short) and the result looks significantly less AI-generated. I also created the small promotional banner - in my experience, having one helps considerably with discoverability in the store.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Would Do Differently
&lt;/h2&gt;

&lt;p&gt;The main thing is the architecture of the content scripts. Keeping three separate files (claude.js, chatgpt.js, grok.js) made sense for isolation, but there is a lot of shared logic between them - the Supabase write functions, the toast system, the scroll mechanism. At some point that redundancy will become a maintenance problem. A shared utilities module would be the cleaner approach.&lt;/p&gt;

&lt;p&gt;The other thing: I spent too much time on design before the features were finished. It is a pattern I keep falling into and keep identifying after the fact. Functionality first, always. The design can be improved incrementally once the thing actually works.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;AI Bookmark is live on the Chrome Web Store now. The current version supports bookmarking specific replies on Claude, ChatGPT and Grok, with cloud sync, tags, notes, and scroll-to navigation.&lt;/p&gt;

&lt;p&gt;There are features I deliberately left out of this version - expandable conversations in the manager, bulk export, sharing bookmarks between users - because I want real feedback before building things nobody asked for. Once there are users testing it, I will have a much clearer picture of what actually needs to be added.&lt;/p&gt;

&lt;p&gt;If you try it, let me know what you think. The feedback form is built right into the extension.&lt;/p&gt;

&lt;p&gt;Thanks for reading - and if you want to follow along as I build more of these, I document everything on the Coded Citadel YouTube channel.&lt;/p&gt;

</description>
      <category>buildinginpublic</category>
      <category>chromeextensions</category>
      <category>claudeai</category>
      <category>chatgpt</category>
    </item>
    <item>
      <title>I Hacked Claude to Track My Usage Limit</title>
      <dc:creator>Diego Fortes</dc:creator>
      <pubDate>Sun, 21 Jun 2026 14:20:51 +0000</pubDate>
      <link>https://dev.to/codedcitadel/i-hacked-claude-to-track-my-usage-limit-2jgo</link>
      <guid>https://dev.to/codedcitadel/i-hacked-claude-to-track-my-usage-limit-2jgo</guid>
      <description>&lt;h1&gt;
  
  
  I Hacked Claude to Track My Usage Limit (Episode 6)
&lt;/h1&gt;

&lt;p&gt;I'm on a journey of vibecoding until I make $100k, building Chrome extensions one at a time. this is my 6th one, and I'm always looking to fix painpoints I find in my own life, on the internet, wherever.&lt;/p&gt;

&lt;p&gt;this one started with something that has always annoyed me: being suddenly interrupted mid-conversation with Claude. no warning, no progress bar, just a wall. so I decided to check if this was something I could detect, to have a bit more control over it.&lt;/p&gt;




&lt;h2&gt;
  
  
  finding the idea
&lt;/h2&gt;

&lt;p&gt;I must be honest: after searching around, I realized this was not my best idea. there were tons of other solutions already available. but I still thought it'd be a fun project, and coincidentally enough, the technical side turned out to be more interesting than expected. so, why not?&lt;/p&gt;




&lt;h2&gt;
  
  
  reverse engineering Claude's website
&lt;/h2&gt;

&lt;p&gt;just like I had to do in my Claude Deep Search episode, I needed to take a look at what Claude's website was doing behind the scenes. I searched for a while and couldn't find any endpoint that could tell me about usage. so, ironically enough, I asked Claude to come up with a script to surface all potential endpoints, and that's how I found it.&lt;/p&gt;

&lt;p&gt;from there, things went pretty smoothly.&lt;/p&gt;




&lt;h2&gt;
  
  
  building the widget
&lt;/h2&gt;

&lt;p&gt;the first thing I needed was a unique CSS selector above the chat box to inject the widget; I searched for something that had only 1 result in the DOM, to make sure I wasn't attaching it to something that could break. I then brainstormed my requests with Claude so it could help me generate a prompt for Cursor.&lt;/p&gt;

&lt;p&gt;as usual, the first build was not working. it was a simple mistake with how the JavaScript was being injected. fixed it, moved on.&lt;/p&gt;

&lt;p&gt;I thought the stats bar sitting above the chat box was a bit distracting, so I added customization options in the popup (you can choose exactly what gets shown, or turn it off completely). I also added a skeleton loader and fading animations, fixed a bug where the widget was going offscreen, and decided to keep only the "current session" data visible by default for a more concise look.&lt;/p&gt;

&lt;p&gt;I also added a small on/off toggle in the popup, wired up to the X button on the stats bar, so once you close it, it stays closed. a few more options made it in too: a link to watch the videos, and a warning when you try to open the popup outside of claude.ai.&lt;/p&gt;

&lt;p&gt;final test, everything working. 3% of usage just to say hello. Claude, have some mercy.&lt;/p&gt;




&lt;h2&gt;
  
  
  logo
&lt;/h2&gt;

&lt;p&gt;everything was looking good, so it was time to design a logo. ChatGPT is always my go-to for this. I wanted something with a percentage symbol, and I made sure to ask it not to use borders (sometimes it makes the logo look too small at extension icon size). it didn't seem too interested in listening to me and went with its own creative direction anyway, but honestly it looked good. I asked Cursor to resize and replace it throughout the project.&lt;/p&gt;




&lt;h2&gt;
  
  
  notifications
&lt;/h2&gt;

&lt;p&gt;before shipping, I had one more idea: it'd be really useful to show a notification the moment your limit resets, so you'd know exactly when you're back without having to check.&lt;/p&gt;

&lt;p&gt;I sent the prompt to Cursor and, well, it wasn't working. I started going through it (was it the setTimeout? was the logic off?), and turns out it was not. maybe I had notifications disabled by default? yep. I had notifications disabled on my own machine, lol. one setting change later and it worked perfectly. I'm glad I didn't spend hours debugging that one.&lt;/p&gt;




&lt;h2&gt;
  
  
  shipping
&lt;/h2&gt;

&lt;p&gt;I have a build-and-zip bat file I reuse across all my projects; I just paste it in the folder and it's done. drag and drop the zip into the Chrome Web Store, fill in the metadata, generate a screenshot with ChatGPT (I always give it something to work with: colors, where to place the text, etc.), resize it, and submit.&lt;/p&gt;

&lt;p&gt;for the privacy policy section, I copied all the questions and asked Cursor to fill them in for me in a code block so I could copy and paste. then I created a simple privacy policy page on CodedCitadel and that was it.&lt;/p&gt;




&lt;p&gt;if you want to see the full build process, I covered everything in the video: &lt;a href="https://youtu.be/iyXIi6pNq-M" rel="noopener noreferrer"&gt;I Hacked Claude to Track My Usage Limit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;and if you've got a small annoying problem you think could be a Chrome extension, drop it in the comments. I'm always looking for the next one.&lt;/p&gt;

</description>
      <category>buildinginpublic</category>
      <category>chromeextensions</category>
      <category>claude</category>
      <category>indiehacker</category>
    </item>
    <item>
      <title>I Built a Chrome Extension That Saves Any File to Google Drive in One Click - In Under 8 Hours</title>
      <dc:creator>Diego Fortes</dc:creator>
      <pubDate>Sun, 21 Jun 2026 14:15:40 +0000</pubDate>
      <link>https://dev.to/codedcitadel/i-built-a-chrome-extension-that-saves-any-file-to-google-drive-in-one-click-in-under-8-hours-5bca</link>
      <guid>https://dev.to/codedcitadel/i-built-a-chrome-extension-that-saves-any-file-to-google-drive-in-one-click-in-under-8-hours-5bca</guid>
      <description>&lt;h1&gt;
  
  
  I Built a Chrome Extension That Saves Any File to Google Drive in One Click - In Under 8 Hours
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://youtu.be/D4-1VtvkdIw" rel="noopener noreferrer"&gt;https://youtu.be/D4-1VtvkdIw&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most Chrome extensions take days to go from idea to deploy. This one took less than 8 hours. I'm going to walk you through exactly how I built a save-to-Google-Drive extension - the research, the auth headaches, the bugs, the design process, all of it - so you can build yours faster.&lt;/p&gt;

&lt;p&gt;This is episode 5 of VibeCoding Until I Make $100k.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Idea Came From Reddit (As It Often Does)
&lt;/h2&gt;

&lt;p&gt;I wasn't sitting around trying to invent a problem to solve. I stumbled onto a Reddit thread in r/chrome_extensions where someone literally posted: "someone could build this." They were asking for an extension that saves any file, image, or link directly to Google Drive with a right-click.&lt;/p&gt;

&lt;p&gt;That's the kind of validation you want before building anything.&lt;/p&gt;

&lt;p&gt;My first instinct was to check whether this would violate Google's policies. This is not a trivial concern - LinkedIn has a similar gray area with a popular extension that touches privacy policy in ways that create real competition barriers. I researched it (okay, I asked Claude, which counts as research), and the short answer was: you're fine as long as you don't use "Google" in your extension's name and don't make the UI look like Google Drive.&lt;/p&gt;

&lt;p&gt;I then searched for competitors. There were some, but the demand was clearly there and being explicitly requested, which told me there was a niche worth entering.&lt;/p&gt;

&lt;p&gt;One review in the Chrome Web Store stood out. A user on an existing save-to-drive extension was complaining that the extension downloaded files locally first, instead of putting them directly into Google Drive. They wanted a bypass - send CSV, PDF, DOC files straight to a Google Drive folder without touching the local machine.&lt;/p&gt;

&lt;p&gt;That seemed doable. I checked whether Google's API supported requesting only write access to Drive. It did. Perfect.&lt;/p&gt;

&lt;p&gt;Spoiler: it turned out not to be possible in practice. But more on that shortly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setting Up OAuth (And the Mistakes I Made)
&lt;/h2&gt;

&lt;p&gt;The first real technical challenge with any extension that touches Google services is OAuth. You need to create an OAuth client via Google Cloud Console before your extension can write anything to a user's Drive.&lt;/p&gt;

&lt;p&gt;The flow I wanted was simple: user clicks the extension popup, logs in once, then right-clicks any file or image on any webpage and saves it directly to their Google Drive.&lt;/p&gt;

&lt;p&gt;Here's something I've learned from past projects: popups can not reliably handle Google OAuth. If you've ever tried to do a Supabase Google OAuth flow from a popup, you know exactly what I mean - it breaks in frustrating ways. So from the start, I planned to delegate the authentication from the popup to the background service worker. The popup triggers the auth, the background script handles it.&lt;/p&gt;

&lt;p&gt;I still ran into a bug early on. My first assumption was that I'd selected the wrong OAuth client type - "web browser" instead of "Chrome extension." So I deleted it and started over with the correct type.&lt;/p&gt;

&lt;p&gt;One thing that caught me here: when setting up the OAuth client for a Chrome extension, you need an "item ID" - basically your extension's unique Chrome ID. Don't just copy it from &lt;code&gt;chrome://extensions&lt;/code&gt;. I highly recommend generating a fixed one manually instead, because when you publish to the store it tends to generate a new ID, and suddenly your OAuth config is pointing at the wrong thing.&lt;/p&gt;

&lt;p&gt;Worth knowing: this only matters during development. You'll need to update the ID anyway once it's live on the store.&lt;/p&gt;




&lt;h2&gt;
  
  
  The File Permission Problem
&lt;/h2&gt;

&lt;p&gt;Once login was working, I hit the issue I'd been hoping to avoid: Google's API does not let you display scope permissions as narrowly as I wanted. Even though I only needed "create new files" access, the OAuth consent screen was showing users "add, edit, and delete files" - the full Drive scope.&lt;/p&gt;

&lt;p&gt;I researched this for a while. Turns out Google has fixed wording for their permission scopes. There's no way around it.&lt;/p&gt;

&lt;p&gt;So I added a note in the popup being upfront about it: the extension only creates files and has no ability to read, edit, or delete anything already in your Drive. Not ideal, but the alternative was pretending the limitation didn't exist.&lt;/p&gt;




&lt;h2&gt;
  
  
  Building the Context Menu
&lt;/h2&gt;

&lt;p&gt;What I wanted was simple: right-click anything on any page, see "Save to Google Drive," click it, done.&lt;/p&gt;

&lt;p&gt;Adding the context menu entry itself was fine. The bug came when I tried adding a custom icon to it - nothing was working no matter what I tried. After a few prompts going nowhere with Cursor's auto mode, I switched to Claude Sonnet 4.6 directly. That's my general rule - if 3 to 5 prompts haven't moved the needle, stop grinding and switch to a stronger model.&lt;/p&gt;

&lt;p&gt;Turned out the issue wasn't the code at all. Manifest V3 just doesn't support custom icons for context menu items. Not fixable. I moved on.&lt;/p&gt;

&lt;p&gt;Then during testing: clicking the option did absolutely nothing. No save, no error, just silence. Took me a minute to realize I'd never actually connected the click handler to the save function - it existed, it just wasn't wired up. Once that was sorted, saves started going through.&lt;/p&gt;

&lt;p&gt;New problem: the extension kept creating a duplicate "Downloads" folder in Drive instead of saving to the existing one. The fix was looking up the folder by its ID and always writing to that same ID, rather than searching by folder name each time and accidentally spawning a new one.&lt;/p&gt;

&lt;p&gt;Debugged that one for a bit, but after it clicked it was solid.&lt;/p&gt;




&lt;h2&gt;
  
  
  One More Bug Worth Mentioning
&lt;/h2&gt;

&lt;p&gt;When right-clicking images, the extension was grabbing the wrong URL - the surrounding link's &lt;code&gt;href&lt;/code&gt; instead of the image's &lt;code&gt;src&lt;/code&gt;. So it was technically saving something, just not the image.&lt;/p&gt;

&lt;p&gt;The fix was to check for a &lt;code&gt;src&lt;/code&gt; attribute first whenever the context menu fires on an element. If there's a &lt;code&gt;src&lt;/code&gt;, use it. If not, fall back to &lt;code&gt;href&lt;/code&gt;. Simple stuff, but easy to miss when you're moving fast.&lt;/p&gt;




&lt;h2&gt;
  
  
  Design: ChatGPT for UI, Claude for Code
&lt;/h2&gt;

&lt;p&gt;With the functionality stable, I moved to design.&lt;/p&gt;

&lt;p&gt;My process here is a bit split. I asked Cursor to describe the current UI and functionality in plain language, then sent that description to ChatGPT to generate a UI design. I didn't want to use any Google Drive colors or design patterns - that's an easy way to get your extension rejected on submission - so I went with Coded Citadel's color palette instead (black and gold, Montserrat).&lt;/p&gt;

&lt;p&gt;Once I had the design direction, I asked Claude to write the HTML, CSS, and JS for it, then had Cursor implement it into the extension. Claude handles the artifact creation cleanly; Cursor handles the integration into the existing project.&lt;/p&gt;

&lt;p&gt;While Claude was coding the UI, I used ChatGPT to generate logo options in parallel. Once I had a logo I liked, I asked Cursor to replace the old one across the entire project. No manual file hunting.&lt;/p&gt;

&lt;p&gt;The design update broke the login flow - which is something to expect whenever you touch the popup HTML significantly. I fixed that and spent another chunk of time on small UX improvements: better login error handling, the ability to rename files before saving, an X button on save confirmation toasts, a cleaner folder name.&lt;/p&gt;




&lt;h2&gt;
  
  
  Google OAuth Review: A False Alarm
&lt;/h2&gt;

&lt;p&gt;I thought I'd need to go through Google's OAuth verification process, which can take weeks. After digging deeper into the Google Cloud Console, I realized I had unnecessary scopes enabled - ones I'd never actually needed but that had been added during development without me noticing. This is extremely common when using AI-assisted development. AI tools will sometimes request broader permissions than necessary just to make something work.&lt;/p&gt;

&lt;p&gt;I removed all the unused scopes, which dropped my app out of the extended review requirement. Worth double-checking before you submit anything.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cleaning Up Before Submission
&lt;/h2&gt;

&lt;p&gt;The last step before submitting to the Chrome Web Store was a cleanup pass.&lt;/p&gt;

&lt;p&gt;This is something I always do on AI-assisted projects: go through the permissions in &lt;code&gt;manifest.json&lt;/code&gt; and remove everything that isn't actively used. AI coding tools tend to add permissions defensively - "just in case." Unnecessary permissions make your extension look suspicious to reviewers and to users.&lt;/p&gt;

&lt;p&gt;I also generated store screenshots via ChatGPT, wrote the store listing copy, and had Cursor fill in all the Chrome Web Store submission fields based on the project context.&lt;/p&gt;

&lt;p&gt;Total time from idea to submitted: under 8 hours.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key Takeaways for Building Chrome Extensions Faster
&lt;/h2&gt;

&lt;p&gt;A few things from this build that are worth internalizing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do your policy research early.&lt;/strong&gt; A 30-minute check on OAuth scopes and naming restrictions at the start can save you from rebuilding things later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't handle Google OAuth in the popup.&lt;/strong&gt; Delegate to the background service worker from day one. You'll thank yourself later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generate a fixed extension ID during development.&lt;/strong&gt; Avoids ID mismatch issues when you publish.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If the AI is stuck after 3 to 5 prompts, switch models.&lt;/strong&gt; Don't grind. A stronger model will usually resolve it in one or two prompts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clean up permissions before submitting.&lt;/strong&gt; AI tools add more than you need. Audit your &lt;code&gt;manifest.json&lt;/code&gt; before it goes anywhere near the Chrome Web Store.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Design in parallel.&lt;/strong&gt; While Claude is generating UI code, use another tool for the logo. The hours you save by running things in parallel add up fast.&lt;/p&gt;




&lt;p&gt;If you're building Chrome extensions or following along with the VibeCoding series, the full video walkthrough is up on the Coded Citadel YouTube channel. More extensions coming.&lt;/p&gt;

</description>
      <category>buildinginpublic</category>
      <category>chromeextensions</category>
      <category>googledrive</category>
      <category>indiehacker</category>
    </item>
    <item>
      <title>How I Reverse Engineered Instagram to Export DMs - No API Key Needed</title>
      <dc:creator>Diego Fortes</dc:creator>
      <pubDate>Sun, 21 Jun 2026 14:15:29 +0000</pubDate>
      <link>https://dev.to/codedcitadel/how-i-reverse-engineered-instagram-to-export-dms-no-api-key-needed-293h</link>
      <guid>https://dev.to/codedcitadel/how-i-reverse-engineered-instagram-to-export-dms-no-api-key-needed-293h</guid>
      <description>&lt;h1&gt;
  
  
  How I Reverse Engineered Instagram to Export DMs - No API Key Needed
&lt;/h1&gt;

&lt;p&gt;This Chrome extension exports any Instagram conversation in one click. No API key, no OAuth, no permissions to request. Just reverse engineering what Instagram's own page is already doing.&lt;/p&gt;

&lt;p&gt;I'm going to walk you through how I found the idea, how the reverse engineering actually works, and how I built and published the extension. Let's get into it.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the Idea Came Up
&lt;/h2&gt;

&lt;p&gt;I used to build Instagram automation tools back in 2022, so I know the ecosystem reasonably well. I started this one by looking at what other developers were building in that space - chrome extensions to check who unfollows you, download images and videos, audio rippers, and so on. All of it felt crowded.&lt;/p&gt;

&lt;p&gt;I had one idea I thought was interesting: a tool to extract all the text from an Instagram page's videos in one click, so you could feed it to an AI as a knowledge base. Imagine following a tutorial account and being able to ask AI questions based on everything that person has ever posted. I didn't go deep on it though, because the first thing I searched was "transcription Chrome extension" and found something already very popular doing exactly that.&lt;/p&gt;

&lt;p&gt;I kept looking. Then I remembered a friend asking me years ago how to export Instagram DMs. Back then the only option was requesting a full data export from Instagram, which takes days and gives you way more than you asked for. I searched the Chrome Web Store and found basically one paid extension doing this. That was enough validation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reverse Engineering Instagram's DM Loading
&lt;/h2&gt;

&lt;p&gt;Before building the extension I needed to understand how Instagram actually loads conversations.&lt;/p&gt;

&lt;p&gt;I opened a DM thread, hit F12, went to the Network tab, and used CTRL+F to search for a word I knew was in the conversation. This is a simple but effective trick - if the word shows up inside a JSON response, you've found the API call responsible for loading that content.&lt;/p&gt;

&lt;p&gt;It showed up. I found the URL and the payload structure, sent it all to Claude, and asked it to write a function that could replicate those requests on demand.&lt;/p&gt;

&lt;p&gt;The way it works is in two parts. First, you need to extract a handful of auth parameters that Instagram already has in the page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fb_dtsg      // auth token from Instagram's script tags
lsd          // lightweight session token, also from script tags
csrftoken    // pulled from document.cookie (not HttpOnly, accessible via JS)
convo_id     // parsed from the current URL - e.g. /direct/t/123456/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of these require a separate API key or OAuth flow. They're already there.&lt;/p&gt;

&lt;p&gt;Second, you make a POST request using those parameters to fetch the conversation content. Instagram's "infinite scroll" in DMs is basically pagination under the hood, so you just loop through pages until you've fetched everything.&lt;/p&gt;

&lt;p&gt;Once this script was working in the browser console, the hard part was done. Wiring it into a Chrome extension is straightforward from there. This is why I always start with a browser script first - if it works in DevTools, the extension is mostly just plumbing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Building the Extension
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Extracting Instagram's CSS
&lt;/h3&gt;

&lt;p&gt;Whenever I build a Chrome extension that injects UI into an existing website, I extract all the CSS variables from that site and add them to the project's samples folder. It helps the AI generate UI that looks like it belongs on the page rather than something foreign dropped in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Finding the Right Injection Point
&lt;/h3&gt;

&lt;p&gt;I needed to find exactly where in Instagram's DOM to inject the download button. The tricky part: Instagram uses randomized class names specifically to make scraping harder. There's no &lt;code&gt;.dm-conversation-header&lt;/code&gt; to target.&lt;/p&gt;

&lt;p&gt;The way around this is to look for structural patterns instead of class names. I used CTRL+F in the Elements tab and searched for CSS patterns until I found one that returned exactly one result. That's your injection point. One match means it's unique to that element, which means you can rely on it.&lt;/p&gt;

&lt;h3&gt;
  
  
  UX Decisions
&lt;/h3&gt;

&lt;p&gt;I added the download button in two places - inside the popup and injected directly into the conversation page. Some people might call that redundant. I think redundancy in UX is a feature. The less the user has to figure out, the better.&lt;/p&gt;

&lt;p&gt;One issue I ran into early: the popup had to stay open while the export was running, which is terrible UX. I fixed it by having the popup trigger content.js and letting the download run from there. Popup closes, download continues in the background.&lt;/p&gt;

&lt;p&gt;I also added a setInterval that runs every second and checks whether the user has navigated to a different DM conversation. If they have, it re-injects the button for the new conversation. It's a lightweight check and it keeps the UI in sync without any complex event listeners.&lt;/p&gt;

&lt;p&gt;The export format options (JSON, TXT, etc.) started out only in the popup, but I moved them into the injected toast as well. Keeping the user in context is always worth the extra few lines of code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Logo
&lt;/h3&gt;

&lt;p&gt;ChatGPT for the logo, as always. Once I had one I liked, I cleaned it up in Photopea - removed the white margins, made the background transparent - then asked Cursor to resize it to the four sizes Chrome requires and replace it throughout the project.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Note on Separate Tutorial Videos
&lt;/h2&gt;

&lt;p&gt;One thing I'm planning to do for each extension in this series: a short standalone tutorial video aimed at people who just want to use the tool, not watch the build. For this one it'll be something like "how to export Instagram DMs." I'll also link those videos from the Chrome Web Store listing, which I think helps with both visibility and downloads.&lt;/p&gt;

&lt;p&gt;More on that as I test it.&lt;/p&gt;




&lt;p&gt;The extension is live and free. Link in the video description. Next one's already in progress.&lt;/p&gt;

</description>
      <category>buildinginpublic</category>
      <category>chromeextensions</category>
      <category>instagram</category>
      <category>reverseengineering</category>
    </item>
    <item>
      <title>Claude Has No Way to Search Your Conversations. So I Built One (Free)</title>
      <dc:creator>Diego Fortes</dc:creator>
      <pubDate>Sun, 21 Jun 2026 14:10:18 +0000</pubDate>
      <link>https://dev.to/codedcitadel/claude-has-no-way-to-search-your-conversations-so-i-built-one-free-1ccl</link>
      <guid>https://dev.to/codedcitadel/claude-has-no-way-to-search-your-conversations-so-i-built-one-free-1ccl</guid>
      <description>&lt;h1&gt;
  
  
  Claude Has No Way to Search Your Conversations. So I Built One (Free)
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://youtu.be/D4-1VtvkdIw" rel="noopener noreferrer"&gt;https://youtu.be/D4-1VtvkdIw&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Claude does have a search function. It just only searches conversation titles, not what's actually inside them. I found this out the hard way, coded a fix in 3 hours, published it for free, and it got accepted to the Chrome Web Store in 12 hours. This is how it happened.&lt;/p&gt;

&lt;p&gt;This is episode 3 of VibeCoding Until I Make $100k.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Idea Came From My Own Frustration
&lt;/h2&gt;

&lt;p&gt;I started this episode the same way I usually do - scraping Reddit's r/chrome_extensions to find ideas worth building. I sent everything to Claude, asked it to summarize and spot patterns. Productivity and AI tools dominated the results, which made sense, but nothing in there grabbed me. Everything either felt overcrowded or too niche.&lt;/p&gt;

&lt;p&gt;After hitting a wall, I went back to a Claude conversation I'd had a few days earlier and couldn't find it. I tried typing in the search bar - only titles came back. I clicked the "Enable Deeper Search" button. Nothing happened. It just... didn't work.&lt;/p&gt;

&lt;p&gt;I Googled around to see if this was just me. It wasn't. Other people were running into the same thing. The only solutions I found were paid extensions. So I figured I'd build a free one, use it myself, and add it to the series.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Figure Out How Claude Stores Conversations
&lt;/h2&gt;

&lt;p&gt;Before touching any extension code, I always write a standalone script I can run directly in the browser. I just want to confirm the thing is doable before going any further.&lt;/p&gt;

&lt;p&gt;The first question was: where do the conversations actually live? Are they stored locally? Is there an API?&lt;/p&gt;

&lt;p&gt;I opened DevTools, started a new conversation to trigger some network requests, and found an API that returns all conversation IDs. From there I wrote a quick fetch script to pull each conversation individually and confirmed I could get the full text content out of them.&lt;/p&gt;

&lt;p&gt;That was all I needed to know. The data was accessible, the extension was buildable, and I moved on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Building the Extension
&lt;/h2&gt;

&lt;p&gt;The extension is essentially just a search bar that pulls from Claude's local conversation data and highlights matches. Since there's nothing complex going on - no shared state, no rendering logic - I skipped the full toolchain. No Vite, no React. Plain JS, HTML, and CSS.&lt;/p&gt;

&lt;p&gt;I wanted the search UI to sit right below Claude's existing search button, and I wanted it to actually look like it belonged there. To pull that off, I copied Claude's actual HTML and CSS into a samples folder in the project. This is a habit I've developed: giving the AI real reference material from the target site produces much more faithful output than describing what you want in words.&lt;/p&gt;

&lt;p&gt;I copied two things specifically - the HTML for the search button area, and all of Claude's CSS variables extracted from the page. Then I explained the idea to Claude, had it generate a prompt, and handed that to Cursor. One prompt, a few minutes, and it was almost working. The only bug was that conversations weren't being stored properly after fetch. Fixed that, and the core search was done.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: The Bells and Whistles
&lt;/h2&gt;

&lt;p&gt;The core was done faster than I expected, so I figured I'd add a couple of things.&lt;/p&gt;

&lt;p&gt;The first addition was word highlighting - marking every instance of the search term in the conversation text. Not too complex.&lt;/p&gt;

&lt;p&gt;Then I had another idea: scrollbar markers. Little indicators on the right side of the screen showing at a glance where all the results are, similar to how some code editors show search hits in the minimap. I wasn't even sure this was possible to build.&lt;/p&gt;

&lt;p&gt;Turns out it's a visual trick. There's no way to inject actual elements into the browser scrollbar. What I did instead was create a &lt;code&gt;position: fixed&lt;/code&gt; div that tracks the scroll percentage and positions colored markers to match where each result sits in the document. It looks like scrollbar markers. It's not. But it works.&lt;/p&gt;

&lt;p&gt;The catch: long conversations. When there's a lot of HTML on the page, things get messy. Even Claude's own CTRL+F has issues in very long conversations. This is one of the harder parts of vibecoding content.js stuff - Cursor can't see the full rendered page or debug it properly the way you can with a standalone app. So there are limitations.&lt;/p&gt;

&lt;p&gt;I fixed it enough to be useful and moved on. Having something out there that works for most cases is better than chasing perfection on an edge case before anyone's even using it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Publishing
&lt;/h2&gt;

&lt;p&gt;Logo via ChatGPT - I wanted a search icon, Claude's brand color, and the letter C. Cleaned up the background in Photopea, done.&lt;/p&gt;

&lt;p&gt;For screenshots, I sent ChatGPT a screenshot of the extension's search bar plus a reference screenshot from another extension I liked, and asked it to generate store screenshots. They came out good.&lt;/p&gt;

&lt;p&gt;For the store listing copy, I do the same thing I always do now: ask Cursor to fill in the Chrome Web Store fields based on the project it already has full access to. The extension, the code, the README - it knows everything. This used to take a while. Now it takes a few minutes.&lt;/p&gt;

&lt;p&gt;The extension was accepted within 12 hours of submission.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Few Things Worth Taking Away From This One
&lt;/h2&gt;

&lt;p&gt;The idea came from a personal frustration, not from a research session. The research session failed. Keep that in mind next time you're stuck trying to "find" an idea - sometimes you're already living it.&lt;/p&gt;

&lt;p&gt;The samples folder trick is underrated. Copying the actual HTML and CSS from the site you're building for and dropping it into a reference folder gives the AI something concrete to work with. The output quality goes up noticeably.&lt;/p&gt;

&lt;p&gt;And on the scrollbar markers - it's worth knowing that a lot of UI effects that look complex are just positioning tricks. The constraint (can't touch the real scrollbar) forced a simpler solution that worked fine anyway.&lt;/p&gt;




&lt;p&gt;If you want to try the extension, it's free on the Chrome Web Store. Link in the video description. More extensions coming - including what might eventually become a multi-AI search that covers ChatGPT, Claude, and others in one place.&lt;/p&gt;

</description>
      <category>buildinginpublic</category>
      <category>chromeextensions</category>
      <category>claudeai</category>
      <category>indiehacker</category>
    </item>
    <item>
      <title>I Built a YouTube Filter Chrome Extension Using 96 AI Prompts - Here's Exactly How</title>
      <dc:creator>Diego Fortes</dc:creator>
      <pubDate>Sun, 21 Jun 2026 14:10:07 +0000</pubDate>
      <link>https://dev.to/codedcitadel/i-built-a-youtube-filter-chrome-extension-using-96-ai-prompts-heres-exactly-how-58j6</link>
      <guid>https://dev.to/codedcitadel/i-built-a-youtube-filter-chrome-extension-using-96-ai-prompts-heres-exactly-how-58j6</guid>
      <description>&lt;h1&gt;
  
  
  I Built a YouTube Filter Chrome Extension Using 96 AI Prompts - Here's Exactly How
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Published on the Coded Citadel | VibeCoding Until I Make $100k - Episode 2&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;I spent hours debugging a bug that should have taken minutes to find. The fix was one line. This is that story.&lt;/p&gt;

&lt;p&gt;In episode 2 of &lt;em&gt;VibeCoding Until I Make $100k&lt;/em&gt;, I built &lt;strong&gt;YouTube Filter Pro&lt;/strong&gt; - a Chrome extension that lets you filter YouTube search results by view count, video length, and more. YouTube does not have this natively. I used &lt;strong&gt;96 AI prompts&lt;/strong&gt; across the entire build. Here's the full breakdown.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is YouTube Filter Pro?
&lt;/h2&gt;

&lt;p&gt;YouTube Filter Pro is a Chrome extension that adds advanced filtering to YouTube search results. It lets you filter videos by view count range, duration, and other criteria that YouTube's native UI does not expose. It injects directly into the YouTube page, uses YouTube's own CSS variables for a native look, and stores your filter preferences in &lt;code&gt;chrome.storage&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I Built It: The Idea Came From a Dead End
&lt;/h2&gt;

&lt;p&gt;Before landing on this idea, I scraped roughly 500 to 600 Reddit comments across the Etsy and EtsySellers subreddits looking for pain points. I chose ecom because people there are already paying for tools - lower friction to monetize.&lt;/p&gt;

&lt;p&gt;The Etsy analysis surfaced real problems. But I am not familiar enough with Etsy to build credible solutions for it. So I moved on.&lt;/p&gt;

&lt;p&gt;I went to YouTube and started searching for Shopify and ecommerce-related content. I wanted to filter videos by view count - specifically find videos with under 10,000 views for niche research. That is when it hit me: &lt;strong&gt;YouTube does not have a filter for that&lt;/strong&gt;. No view count range. No proper date range. No duration filter beyond the basic short/long toggle.&lt;/p&gt;

&lt;p&gt;There was my idea.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to Build a Chrome Extension That Filters YouTube Videos
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Reverse-engineer the data source before writing any extension code
&lt;/h3&gt;

&lt;p&gt;My first step for every Chrome extension is a console script - what I call the "hello world." Before structuring a single file, I need a script that proves the core functionality works in DevTools.&lt;/p&gt;

&lt;p&gt;For this extension, the core question was: how does YouTube deliver video data to the page?&lt;/p&gt;

&lt;p&gt;I opened the Network tab, searched for a channel name inside the response payloads, and found the answer: YouTube returns a JSON object containing view counts, durations, titles, and more. It is not a clean REST endpoint, but it is findable.&lt;/p&gt;

&lt;p&gt;Once I confirmed the data shape, I used an LLM to write a browser script that fetched this JSON on demand. It worked. I could pull view counts for videos on the page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Understand YouTube's two-phase video loading (the bug that cost hours)
&lt;/h3&gt;

&lt;p&gt;This is the critical thing I got wrong and it is worth documenting clearly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;YouTube's video loading works in two phases:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On page load, YouTube sets a global object called &lt;code&gt;ytInitialData&lt;/code&gt;. This contains the videos currently visible on the page plus a continuation token.&lt;/li&gt;
&lt;li&gt;When you scroll to the bottom, YouTube uses that token to fetch the next batch of videos.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I was only intercepting the token-based fetch. That meant I was getting data for videos that were not yet visible - and filtering against that set. Some videos matched by coincidence, which made the extension look like it was working during early tests.&lt;/p&gt;

&lt;p&gt;It was not working.&lt;/p&gt;

&lt;p&gt;After debugging, the fix was to read &lt;code&gt;ytInitialData&lt;/code&gt; for the initial set and then monitor scroll-triggered requests for subsequent batches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-contained answer:&lt;/strong&gt; To properly filter YouTube videos in a Chrome extension, you must read &lt;code&gt;window.ytInitialData&lt;/code&gt; on page load for the initial video set, then intercept the continuation token requests triggered by scrolling to capture subsequent batches. Filtering only against continuation responses means you are filtering videos that are not yet rendered.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Build the UI to match YouTube's design system
&lt;/h3&gt;

&lt;p&gt;I did not design this from scratch. YouTube exposes its full CSS variable set in the DOM. I fetched all of those variables - colors, font sizes, font families, paddings, border radii - and sent them to an image generation model with a screenshot of the target area and a brief UI description.&lt;/p&gt;

&lt;p&gt;The result was a filter button placed next to YouTube's search bar that opens a modal. The modal looks native because it uses YouTube's own design tokens.&lt;/p&gt;

&lt;p&gt;I then used Claude to generate the HTML and CSS from that mockup, and passed that output into a &lt;code&gt;sample/&lt;/code&gt; folder inside my project for the AI to reference when building the actual extension.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Keep the architecture simple for an MVP
&lt;/h3&gt;

&lt;p&gt;The extension has no React, no build pipeline, no popup or sidebar. It is a &lt;code&gt;content.js&lt;/code&gt; injected into the page. Filters are stored in &lt;code&gt;chrome.storage.sync&lt;/code&gt;. Videos get a data attribute when their metadata is confirmed fetched, and the filtering logic reads those attributes.&lt;/p&gt;

&lt;p&gt;Simple is the right call here. The only job of this extension is to filter videos. Complexity would slow everything down.&lt;/p&gt;




&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;After fixing the &lt;code&gt;ytInitialData&lt;/code&gt; bug, all filters worked correctly: view count range, video duration, and combinations of both. The extension filters the visible result set in real time.&lt;/p&gt;

&lt;p&gt;One known limitation: "People also watched" and "Explore more" sections are not filtered. They appear to pull from a separate API. For the MVP, this is acceptable. If downloads and user requests justify it, that is a future version.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Used and How Many Prompts It Took
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Qwen&lt;/strong&gt; - Fetching and parsing YouTube's network request format&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ChatGPT Image generation&lt;/strong&gt; - UI mockup using YouTube CSS variables; logo generation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude&lt;/strong&gt; - HTML/CSS from the UI mockup; Chrome extension architecture and implementation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Total prompts used across the full build: 96&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Test filtering logic thoroughly before moving to UI.&lt;/strong&gt; I assumed the data fetch was correct because some videos were showing correct data attributes. That assumption cost hours. A proper test would have caught the &lt;code&gt;ytInitialData&lt;/code&gt; gap immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The hello-world console script is non-negotiable.&lt;/strong&gt; If the core logic does not work in DevTools, nothing else matters. Do not skip this step to save time. You will lose more time later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fetching CSS variables from the target site is a legitimate design strategy.&lt;/strong&gt; The result looks more native than anything I could have designed from scratch. For extensions injected into an existing product, this approach is underrated.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pertinent Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does YouTube have a built-in view count filter?
&lt;/h3&gt;

&lt;p&gt;No. YouTube's native filter options include upload date, type, duration (short/long only), and features like HD or subtitles. There is no native view count range or precise duration filter.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does a Chrome extension filter YouTube videos?
&lt;/h3&gt;

&lt;p&gt;A Chrome extension can filter YouTube videos by reading the &lt;code&gt;ytInitialData&lt;/code&gt; object loaded with the page and intercepting continuation requests triggered by scrolling. Each video's metadata - views, duration, channel name - is available in these payloads and can be used to show or hide DOM elements.&lt;/p&gt;

&lt;h3&gt;
  
  
  How many prompts does it take to build a Chrome extension?
&lt;/h3&gt;

&lt;p&gt;This extension required 96 prompts across research, UI design, code generation, and debugging. Prompt count varies heavily based on how much debugging is required and how well the initial architecture is defined.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is &lt;code&gt;ytInitialData&lt;/code&gt; in YouTube?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ytInitialData&lt;/code&gt; is a JavaScript object YouTube sets on page load containing the initial video results, channel data, and a continuation token for loading more videos on scroll. It is accessible at &lt;code&gt;window.ytInitialData&lt;/code&gt; in the browser console.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next episode: the extension goes into the Chrome Web Store. Subscribe to Coded Citadel on YouTube to follow the series.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildinginpublic</category>
      <category>chromeextensions</category>
      <category>vibecoding</category>
      <category>youtube</category>
    </item>
    <item>
      <title>I'm VibeCoding My Way to $100K — This Is How It Starts</title>
      <dc:creator>Diego Fortes</dc:creator>
      <pubDate>Sun, 21 Jun 2026 14:02:25 +0000</pubDate>
      <link>https://dev.to/codedcitadel/im-vibecoding-my-way-to-100k-this-is-how-it-starts-19cn</link>
      <guid>https://dev.to/codedcitadel/im-vibecoding-my-way-to-100k-this-is-how-it-starts-19cn</guid>
      <description>&lt;h1&gt;
  
  
  I'm VibeCoding My Way to $100K — This Is How It Starts
&lt;/h1&gt;

&lt;p&gt;I want to build $100k in revenue by shipping software with AI. I know that sounds like every other "zero to X" challenge you've seen. The difference is I'm not trying to sell you anything, and I'm not going to pretend it's going faster than it is.&lt;/p&gt;

&lt;p&gt;I've been a dev for 10+ years. I've shipped apps that made real money. But the things that worked before don't work the same way anymore, and I'm starting fresh with a different approach. This is episode 1.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Chrome Extensions
&lt;/h2&gt;

&lt;p&gt;I'm starting exclusively with Chrome extensions, and I want to explain why because it's not an obvious choice.&lt;/p&gt;

&lt;p&gt;There's very little overhead. No hosting to pay for, no domain to manage, no deployment pipeline to maintain. You zip a folder and submit it. The Chrome Web Store also does a decent amount of passive discovery on its own - it keeps recommending extensions to users, which means you get eyeballs without having to earn every single one.&lt;/p&gt;

&lt;p&gt;There's also less competition than you'd expect. Most developers aren't building Chrome extensions seriously. The ones that exist are often abandoned, poorly designed, or paywalled for features that should be free. That's the gap I'm going after, at least for now.&lt;/p&gt;

&lt;p&gt;The plan for the beginning is simple: build things that are genuinely useful, make them free, and let word of mouth do the early marketing. Useful free tools get shared. That's the distribution strategy until I have enough of an audience to think about monetization.&lt;/p&gt;




&lt;h2&gt;
  
  
  The First Extension: YouTube Comments Scraper
&lt;/h2&gt;

&lt;p&gt;The first thing I needed was a tool to scrape YouTube comments so I could feed them to an AI and find pain points and ideas worth building around. I found an existing app that does this but it's paywalled. So I built my own and made it free.&lt;/p&gt;

&lt;p&gt;Before writing a single line of extension code, I had a few back-and-forth conversations with Claude to nail down what the MVP actually needed to be. A few decisions came out of that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zustand for state management&lt;/li&gt;
&lt;li&gt;A tabbed UI instead of a standard popup&lt;/li&gt;
&lt;li&gt;A sidebar layout rather than the typical extension popup&lt;/li&gt;
&lt;li&gt;YouTube Data API for fetching comments (there's no shortcut here)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once I knew what I was building, I moved to design.&lt;/p&gt;




&lt;h2&gt;
  
  
  Design Process: ChatGPT Generates, Claude Codes
&lt;/h2&gt;

&lt;p&gt;I use ChatGPT for the initial visual design. I tell it what kind of extension it is, what UI patterns I want (tabs, fonts, color ideas), and let it generate mockup images. It's been consistently good at this.&lt;/p&gt;

&lt;p&gt;Once I have designs I'm happy with, I send them to Claude and ask it to code the HTML and CSS based on the images. One thing I always do: I use BEM (Block Element Modifier) for all HTML and CSS. It's a naming methodology that makes the code much easier for both AI and humans to read and navigate. If you're not using it, I'd recommend it - the AI output quality improves noticeably.&lt;/p&gt;

&lt;p&gt;I also keep a &lt;code&gt;samples&lt;/code&gt; folder in every Chrome extension project. Anything I want the AI to use as a reference - existing HTML snippets, CSS patterns, design examples - goes in there. For this project I added the sidebar example Claude generated from the ChatGPT designs. When you give the AI real reference material instead of just describing what you want, the output is much closer to what you're after on the first try.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wiring It Up
&lt;/h2&gt;

&lt;p&gt;For the backend, I used Supabase Edge Functions to keep my YouTube API key out of the extension code. You never want API keys sitting in client-side JavaScript.&lt;/p&gt;

&lt;p&gt;The core comment fetching worked on the first real attempt, which was a nice surprise. From there I built out the bulk functionality: you can add individual video URLs one by one or drop in a playlist URL and fetch all comments at once. I also added buttons to open the results directly in Claude or ChatGPT, which felt like the natural next step given what the tool is for.&lt;/p&gt;




&lt;h2&gt;
  
  
  Logo and Icons
&lt;/h2&gt;

&lt;p&gt;Logo via ChatGPT. I described the extension, said I wanted something minimalist, and it came back with something I actually liked.&lt;/p&gt;

&lt;p&gt;One thing worth knowing: when you submit a Chrome extension, it needs the logo in four different sizes. I don't resize these manually. I drop the full-size logo in the root folder and ask Cursor to resize it and replace it throughout the project. Takes about 30 seconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Review Prompt
&lt;/h2&gt;

&lt;p&gt;Before publishing I added one more thing: a post-install review prompt. After the user has had the extension for 10 minutes, a modal appears asking for a star rating.&lt;/p&gt;

&lt;p&gt;If they give 3 stars or fewer, it opens a private feedback form. If they give 4 or 5, it redirects them to the Chrome Web Store to leave a public review. The feedback form is wired to Supabase, and I'm also capturing a browser fingerprint so I can tell when the same user sends multiple responses.&lt;/p&gt;

&lt;p&gt;This is worth implementing early. You want negative feedback going somewhere you can act on it, not onto your public store listing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Publishing
&lt;/h2&gt;

&lt;p&gt;I have a &lt;code&gt;.bat&lt;/code&gt; script that zips the extension folder and drops a production-ready file into a &lt;code&gt;zipped&lt;/code&gt; folder. One click, ready to submit. After that it's just writing the store description, uploading screenshots, and waiting. In my experience it takes anywhere from 12 hours to 3 days to go live.&lt;/p&gt;

&lt;p&gt;This one's live now. Free to install, no trial, no paywall.&lt;/p&gt;




&lt;p&gt;Episode 2 is already up. More extensions coming.&lt;/p&gt;

</description>
      <category>buildinginpublic</category>
      <category>chromeextensions</category>
      <category>youtubeapi</category>
      <category>indiehacker</category>
    </item>
    <item>
      <title>Why I'm Building in Public Toward $100K</title>
      <dc:creator>Diego Fortes</dc:creator>
      <pubDate>Sun, 21 Jun 2026 14:02:25 +0000</pubDate>
      <link>https://dev.to/codedcitadel/why-im-building-in-public-toward-100k-m4a</link>
      <guid>https://dev.to/codedcitadel/why-im-building-in-public-toward-100k-m4a</guid>
      <description>&lt;h1&gt;
  
  
  Why I'm Building in Public Toward $100K
&lt;/h1&gt;

&lt;p&gt;I'm Diego. I've been a dev for over a decade, and for most of that time I've been building things quietly - shipping, failing, occasionally winning, mostly keeping it to myself.&lt;/p&gt;

&lt;p&gt;I wanted to change that. Not to sell a course about it. Just to actually document something honestly while it's happening.&lt;/p&gt;

&lt;p&gt;This site and my &lt;a href="https://www.youtube.com/@CodedCitadel" rel="noopener noreferrer"&gt;YouTube channel&lt;/a&gt; are that.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'm building
&lt;/h2&gt;

&lt;p&gt;Chrome extensions, at least for now. All of them started as personal frustrations.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;YouTube Filter Pro&lt;/strong&gt; - filter search results by duration, views, upload date, and more&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Deep Search&lt;/strong&gt; - full-text search across all your Claude conversations, runs entirely locally&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;YouTube Comments Exporter&lt;/strong&gt; - export comments for research, analysis, or archival&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instagram DM Exporter&lt;/strong&gt; - export DM threads as structured files&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each one is free. Monetization is a later problem. Right now the goal is to ship things people actually use and figure out what that looks like at scale.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "building in public" means on this blog
&lt;/h2&gt;

&lt;p&gt;Not a highlight reel. Not a retrospective written after everything worked out.&lt;/p&gt;

&lt;p&gt;You'll see real install numbers and active user counts on this site - pulled live from the Chrome Web Store. You'll see posts when something interesting happens, a bug worth writing about, or a decision I had to make and learned from. Some weeks nothing will be worth writing about, and I won't force it.&lt;/p&gt;

&lt;p&gt;Some launches will flop. That's fine. That's kind of the point.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why $100K
&lt;/h2&gt;

&lt;p&gt;It's a number with enough weight to mean something, but specific enough to track. The real thing I'm after is a repeatable system - one where I can identify a problem, build a solution fast, ship it, and distribute it without it taking over my life.&lt;/p&gt;

&lt;p&gt;If that system eventually generates $100K, great. If it takes longer than expected, the documentation will reflect that.&lt;/p&gt;




&lt;p&gt;Follow along on &lt;a href="https://www.youtube.com/@CodedCitadel" rel="noopener noreferrer"&gt;YouTube&lt;/a&gt; or bookmark the blog. New episodes drop regularly.&lt;/p&gt;

</description>
      <category>buildinginpublic</category>
      <category>chromeextensions</category>
    </item>
  </channel>
</rss>
