DEV Community

eklavya pandey
eklavya pandey

Posted on

I built a tool that reverse-engineers any website into React in one command. Here's how the capture actually works.

I'm a student in India and I kept doing two things by hand.

One: clone a repo, open the README, find out it needs a .env with eleven keys, a Postgres, and a pnpm version I don't have, spend twenty minutes, give up or not.

Two: see a landing page I like, open devtools, and poke around to see how they did the grid, what font that is, where the pricing data comes from.

So I wrote one command that does both.

npx breakinggood https://github.com/someone/some-repo
npx breakinggood https://tailwindcss.com
Enter fullscreen mode Exit fullscreen mode

First one clones, scans the dependencies, sets up .env, installs, runs, opens the browser. Second one captures the page with your own Chrome and writes a Vite + React + Tailwind project that matches it, with a mock API server that replays the real API responses. It's MIT, on npm, repo is eklavya2201/breakinggood.

This post is about the second half, the website capture, because that's where all the interesting decisions were. The repo half is at the end.

Why not just screenshot-to-code

There are a lot of "paste a screenshot, get React" tools. I tried a few before writing this and the output always looked roughly right and was wrong in every detail. A picture loses:

  • the actual font (the model guesses Inter, every time)
  • exact colours (it eyeballs #1a1a1a as #000)
  • spacing and radii
  • all the text below the fold
  • and, the one that annoyed me most, the data. The pricing cards are hard-coded. The testimonials are lorem ipsum with a hat on.

A browser has all of that. So the capture doesn't start from a picture. It starts from the page.

The capture, step by step

Everything lives in one file, src/site/capture.ts, about 330 lines of Playwright.

1. Use the Chrome that's already installed. playwright-core can drive an installed Chrome or Edge through the channel option. So the first launch attempt is chromium.launch({ channel: "chrome" }), then msedge, then a Playwright Chromium if one happens to exist. Nobody downloads 150 MB of browser for a CLI they're trying once. There's a BREAKINGGOOD_BROWSER env var if you want to point it at a specific binary.

2. Record the network before navigating. A page.on("response") listener runs from the start. It keeps only xhr and fetch resource types, and for JSON responses under status 400 it reads the body and keeps the first 6000 characters. Everything else (images, scripts, CSS) is ignored. Then it deduplicates by method + URL-without-query and caps at 40 requests. This is the list that becomes the mock server later.

3. Load, settle, scroll. goto with domcontentloaded, then wait for networkidle with a 15 s cap (it's best-effort, SPAs never go idle). Then it clicks through a short list of cookie-banner button labels ("Accept all", "I agree", "Got it"...) because otherwise half of every screenshot is a GDPR modal. Then it scrolls the page in 700 px steps down to 12000 px so lazy-loaded sections actually render, and scrolls back to the top.

4. Read the computed styles, not the CSS. This is the part I'd argue is the whole trick. One page.evaluate walks the first 4000 elements in the body and calls getComputedStyle on each. For every leaf element with text it counts the font family, text colour, and font size. For every element with a non-transparent background it adds the background colour weighted by the element's area. Border radii get counted too. Then it takes the top N of each map.

Weighting backgrounds by area matters. Without it, the most common background is whatever colour the 300 tiny icon wrappers have. With it, the page background and the hero colour come out first. Text colour is weighted by count instead, because body text is a lot of small elements and that's correct.

The result is a small token block that goes into the prompt:

fonts: Inter | JetBrains Mono
text colours: rgb(15, 23, 42) | rgb(100, 116, 139) | ...
backgrounds (by area): rgb(255, 255, 255) | rgb(248, 250, 252) | ...
border radii: 8px | 12px | 9999px
content max-width: 1280px
dark mode: false
Enter fullscreen mode Exit fullscreen mode

Dark mode is decided from the luminance of the page background, which is the body background, else the html background, else the largest painted background. Transparent counts as white.

5. A simplified DOM. The full HTML of a modern page is hundreds of KB of class soup. The capture walks the body recursively and keeps only the tag name, a handful of attributes (href, src, alt, placeholder, type, role, aria-label, title), and the text. Scripts, styles, SVG internals, iframes go. Anything display: none goes. Classes go, all of them, because Tailwind classes from the original are noise once you have the computed tokens. Text nodes are capped at 300 characters, depth at 14, and the whole thing at 45 KB.

The model gets this plus a semantic outline (every header, nav, section, h1-h3 with its first 80 characters), the first 80 links, and the first 40 real images with their natural sizes. Images use the original public URLs, so the rebuild shows the real pictures.

6. Tiled screenshots. Full-page screenshots of long pages are 8000+ px tall and the vision APIs reject or downscale them. So the page is sliced from the top into JPEG tiles, 1800 px tall at 1440 wide, max four tiles. Then the viewport switches to 390 x 844 and takes three more tiles for mobile. JPEG at quality 82 keeps each tile under a few hundred KB. The mobile tiles are what tell the model how the grid collapses.

7. Fingerprints. Cheap checks: __NEXT_DATA__, #__nuxt, __remixContext, data-wf-site, wp-content, a generator meta tag. They go in the prompt as "likely original stack" and don't do much, but they're nice to see in the output.

The prompt

The system prompt is about 30 lines. It asks for a fixed set of files (package.json, vite config, src/App.jsx, one component per major section, mock/db.json, mock/server.js), Tailwind v4 with an @theme block built from the captured palette and fonts, real text from the DOM, original image URLs, and a dependency-free Node HTTP server on port 8787 that serves every captured API path from db.json. The Vite config proxies /api to it. The React code has to fetch from /api/... wherever the original site fetched data, so the clone actually makes network calls instead of hard-coding the pricing table.

Two lines in that prompt took the most iterations:

Stay compact: aim for under 1500 lines in total. Put repeated content (nav items, feature cards, logos, testimonials, footer columns) in plain arrays and render with .map().

Without that, tailwindcss.com produced a 3000-line App.jsx and the model ran out of output tokens halfway through the footer. With it, it's around 900 lines across eight files.

File format. The model writes:

=== FILE: src/App.jsx ===
...
=== END FILE ===
Enter fullscreen mode Exit fullscreen mode

Not JSON. JSON means every file is one giant escaped string, and a single unescaped quote in a 1000-line response kills the whole thing. The delimiter format is parsed with one regex, tolerates a stray code fence inside a block, and refuses any path with ... If package.json or src/App.jsx is missing from the output, it fails loudly instead of running half a project.

Providers. The capture is the same regardless of model. There's a thin provider layer for Anthropic, OpenAI, Groq, OpenRouter, Gemini and Ollama, picked from whichever API key you have in your environment. Screenshots only go to models that take images. Text-only models still get the DOM, the tokens and the API bodies, and the output is noticeably worse but usable.

What went wrong

  • 8000 px screenshots. First version sent one full-page PNG. The API silently downscaled it to a blur. Tiles fixed it.
  • The model running out of output. See the "stay compact" line above. maxTokens is 128k but the fix was asking for less, not allowing more.
  • Workspace packages flagged as foreign. On the repo side, the scanner looked at resolved in the lockfile and flagged packages/foo workspace links as non-registry packages. Now it only flags entries whose resolved has a URL scheme that isn't the npm or yarn registry.
  • Cookie banners in every screenshot. The label list above. It's not clever and it works on most sites.
  • The hero is a video or a canvas. Nothing to do. The screenshot shows a frame, the DOM shows a <video>, the model puts a placeholder. Fine.

The repo side, briefly

npx breakinggood <github url> shallow-clones, detects the stack (Node package manager from lockfiles, Python, Go, Rust, Docker Compose), and before installing anything runs a scan in src/repo/scan.ts:

  • lockfile packages with hasInstallScript: true that aren't on a list of about 70 well-known native packages (esbuild, sharp, prisma, playwright...)
  • packages whose resolved URL is outside the public registry
  • direct dependencies published less than 90 days ago (one registry fetch per dep, 6 s total budget, skipped silently if offline)
  • preinstall / postinstall / prepare hooks that match curl ... | sh, powershell -enc, base64 -d, eval(atob(
  • setup.py / pyproject.toml calling os.system, subprocess, urllib, exec
  • source files with 200+ character base64 blobs or long \x escape runs
  • a committed real .env

High findings ask before continuing. It is not a sandbox and I say so in the README. It catches the obvious stuff, which is the stuff that actually shows up in the npm incidents I've read about.

Then it copies .env.example to .env, fills anything named like a secret (SECRET, JWT, SESSION, SALT, and so on) with a random value so the app at least boots, installs, runs the dev script, watches its output for the localhost URL or port it prints, and opens the browser there.

Limits

  • Pages behind a login, Cloudflare challenges, and bot walls don't capture. There's an open issue to at least detect the challenge page and say so instead of rebuilding it.
  • Heavy canvas or WebGL pages come out as a static frame.
  • It rebuilds structure and style of public pages. The content and the brand belong to whoever owns the site. There's a --sanitize flag that swaps the brand name and logo for neutral placeholders, and every clone writes a provenance file saying where it came from.
  • The quality ceiling is the model. Same capture, different model, visibly different output.

Try it

npx breakinggood https://tailwindcss.com
Enter fullscreen mode Exit fullscreen mode

The README has untouched before/after screenshots of Hacker News and tailwindcss.com. If you run it on something and it does badly, open an issue with the URL, that's the most useful thing right now. There are a few good first issues too: Svelte output, a --framework next option, Deno and Bun detection.

Repo: https://github.com/eklavya2201/breakinggood

Top comments (0)