Building text2.site
I opened an article a few days ago. It had maybe 800 words worth reading.
The page downloaded 4 MB.
There was a cookie banner, a newsletter modal waiting for my mouse to move toward the top of the screen, a fixed bar above, another fixed bar below, a "you may also like" box wedged into the middle of a paragraph, three social media embeds, and a video that started playing without being invited. The 800 words were still there, technically. I just had to excavate them.
That was the entire origin story. No revelation. No grand theory about saving the web.
I was annoyed.
So I built text2.site, a small service that does one thing: you paste a URL and it gives you the text. No account, no database, no cookies, and nothing stored. Just a .txt file that opens everywhere and will probably continue opening thirty years from now.
Why plain text
Plain text may be the most underestimated format we have.
It goes into a note and remains readable. It works in an e-book reader. A screen reader can move through it without tripping over decorative buttons. If the original website disappears, the file does not suddenly forget how to exist. It takes kilobytes instead of megabytes.
More importantly, plain text has no opinion about how you should consume it.
It does not demand a browser, a particular app, an account, JavaScript, or a relationship with an advertising company. It is simply the content, which feels almost radical now.
One file, no framework, no build step
The first decision was also the most important: use almost nothing.
No React. No Next.js. No bundler, build pipeline, or dist/ directory. The service is a Node server using the native http module, with handwritten HTML and CSS, and an npm start command that behaves the same way on my machine and on the VPS.
This is not nostalgia. It is arithmetic.
Every framework I added would become another thing to update, another surface that could break, and another layer between "I want to change this" and "I changed it." For a service with one job, the framework would cost more than it gave me.
The entire front end is three files. The server fits inside my head. I like software that can still fit inside the head of the person maintaining it.
Readability, and what happens when it gives up
At the center of the service is Mozilla Readability, the same engine behind Firefox Reader View. It identifies the article and throws away the surrounding machinery. It is mature, has zero dependencies, weighs about 200 KB, and is remarkably good at this specific job.
It also has a personality: when uncertain, it returns nothing.
That makes sense inside a browser. If Firefox is not confident that a page contains an article, it simply does not offer Reader View. You continue reading the normal page. A converter does not have that luxury. If my service says "I found nothing," the user does not get a second option. They leave empty-handed.
So I built a ladder underneath Readability. When the first extraction fails, text2.site tries the following:
- Unwrap
<noscript>and run Readability again. Many modern pages are JavaScript shells with the real content sitting inside<noscript>for search engines. - Look for
articleBodyin JSON-LD. It is not common, but checking it is almost free. - Try
<article>,<main>, and[role=main]. - As a last resort, take the entire
<body>and aggressively remove navigation, headers, footers, and obvious noise.
One small detail cost me a real bug: <noscript> must only be unwrapped after the first attempt fails. If I unwrap it immediately, perfectly good pages sometimes acquire an "Enable JavaScript to continue" message right in the middle of the article.
The order matters. It often does.
The >>> 2 + 2 4 that made me rewrite everything
My first HTML-to-text converter was handwritten. A recursive walk(), around sixty lines, and it worked.
Then I converted the Python tutorial.
>>> 2 + 2 4 >>> 50 - 5*6 20 >>> 8 / 5 # division always
returns a floating-point number 1.6
That was supposed to be a code block. Every >>> should begin a new line, and every result should appear on the following line. My converter flattened the whole thing into one paragraph. The code became alphabet soup.
The problem was not a small bug. The problem was that I had underestimated the job.
Converting HTML to readable text looks easy until you try to do it properly. A <pre> block must preserve every meaningful space. Tables need aligned columns. Nested ordered lists can start at five and use Roman numerals. A quotation needs > on every wrapped line, not only the first one.
Each edge case is an afternoon of work followed by another afternoon of discovering what the first afternoon broke. Multiply that by ten and I would spend a week badly rebuilding what html-to-text has already handled well for years.
So I replaced my converter. The same Python example now comes out like this:
>>> 2 + 2
4
>>> 50 - 5*6
20
The RFC 9110 method table comes out aligned as well:
Method Safe Idempotent Section
CONNECT no no 9.3.6
GET yes yes 9.3.1
POST no no 9.3.3
Before that, a table could become NameQtyApple10.
I am not exaggerating.
I kept a few formatting decisions of my own on top of the library. Headings use = and - underlines, the way plain-text README files have done for decades, and links become numbered references.
Links should not ruin the sentence
This part is a matter of taste, and I will defend mine.
A link contains two pieces of information: the words you read and the address they point to. Put the complete address inline and the paragraph becomes painful to read. Delete the address and you destroy information.
So text2.site turns a link into the words [4] and places the address in a reference list at the bottom. Academic writing solved this problem a century ago. There was no reason for me to invent a worse answer.
I added two rules that made a surprising difference.
First, the same URL always gets the same number. If an article cites one source five times, the reader sees [1] five times, not five separate references pretending to be different things.
Second, fragment links do not become references. A #section-3 link from a table of contents is useless once the page becomes a text file. Removing those links reduced the RFC 9110 output from 3,017 references to 1,166. Nearly two thousand lines of noise disappeared because of one rule.
The coffee that became caf?
This bug was embarrassing, which is probably why it became one of my favorites.
The service read the response bytes and called .toString('utf8'). Always. No questions asked.
Unfortunately, a meaningful part of the web still arrives as windows-1252 or ISO-8859-1: old institutional websites, city government pages, a CMS from 2011, a personal blog that never migrated. Words like caf茅 and a莽茫o arrived mangled.
The fix follows the order defined by HTML. Check the BOM first because it is unambiguous. Then inspect the HTTP header. Then look for <meta charset> inside the first 1,024 bytes. Use UTF-8 only as the final fallback.
There is one more practical trick. If a page insists that it is UTF-8 but the decoded result is full of replacement characters, I decode it again as windows-1252 and keep whichever version is less broken. Standards are useful. So is noticing when a website is lying.
The best part is that this required zero new dependencies. Node's own TextDecoder already supports shift_jis, gb18030, big5, koi8-r, and the rest. I only needed to confirm that the Alpine Node image ships with full ICU support. A thirty-second docker exec turned a vague doubt into a fact.
The day I realized I had built an open proxy
Everything looked harmless while the service ran on localhost. Then I prepared to put it on a public domain and the obvious finally became visible.
The /extract endpoint fetches any URL it receives. On a VPS, that means a stranger could ask my server to request http://127.0.0.1:5432, an internal network address, or the cloud metadata endpoint at 169.254.169.254, which can expose credentials on some providers.
It was not exactly a bug in one line of code. It was a consequence of deployment waiting patiently to bite me.
Now every hostname is resolved before the request. The service rejects loopback, private, link-local, CGNAT, and multicast ranges in both IPv4 and IPv6, including mapped forms such as ::ffff:10.0.0.1.
The detail that nearly escaped me was automatic redirection. With redirect: 'follow', the first URL can pass validation and the second hop can land directly on the metadata endpoint. Redirects are now followed manually, one at a time, with every destination checked again. The maximum is five.
One hole remains, and I document it because pretending otherwise would not close it. I resolve and validate the address, then fetch performs DNS resolution again. A record that changes between those two moments can slip through. That is DNS rebinding. Closing it correctly means pinning the connection to the IP address that was already verified.
It is on the list.
Lightweight is not an aesthetic. It is a measurement.
The project originally used jsdom. It is the standard DOM implementation for Node, and it works.
Then I measured it.
| Measurement | jsdom | linkedom |
|---|---|---|
| Installed size | 25 MB | 5.6 MB |
| Load time | 497 ms | 108 ms |
| Memory while processing Wikipedia | +17 MB | +1 MB |
I switched to linkedom.
The migration needed two fixes, both discovered by comparing the output side by side. linkedom exposes the contents of <template> as normal children, allowing boilerplate to leak into the text. It also has no base URL option, so relative links require a manually injected <base href>.
The final result was worth it. node_modules went from 25 MB to 6.3 MB, and somewhere along the way the project also gained a proper text renderer. Less weight and more capability at the same time.
That almost never happens.
A container and a quota of ten
text2.site runs in Docker. The image uses two stages, the process runs as an unprivileged user, the filesystem is read-only, all Linux capabilities are dropped with cap_drop: ALL, and dumb-init runs as PID 1 so the SIGTERM from docker stop actually reaches Node instead of waiting ten seconds for a kill.
The port binds to 127.0.0.1, nothing else. The container can only be reached through nginx. As far as the public internet is concerned, the container itself does not exist.
There is also a quota: ten successful conversions per IP every 24 hours. This is not the beginning of a premium plan. It is there so the server can remain cheap enough to stay open to everyone.
One rule matters to me: failure does not spend quota. Invalid URL, unavailable website, page with no extractable article - the attempt is returned. Only an actual result counts. Charging the user for my failure would be ugly.
The counters live in memory, and a restart clears them. That is a deliberate tradeoff, not an oversight. Deploying a database to preserve ten integers would cost more than the abuse it might prevent.
What I deliberately did not build
Pages rendered entirely in JavaScript do not work. Paywalls do not work. Content behind a login does not work.
I could support them with a headless browser. I will not.
A Chromium process can consume 300 MB of RAM per page and take seconds for each conversion. At that point I would have sacrificed exactly what I was trying to preserve: a small service that does one thing without dragging an operating system behind it.
When extraction fails, the website says so. A clear error is better than returning a navigation bar disguised as an article.
What building it taught me
Three things stayed with me.
First: most of the quality came from work performed before and after the main library. Readability is excellent, but it can only understand the DOM I give it. Removing hidden elements, excluding <template>, and stripping the little 露 symbols that Sphinx and MDN attach to headings are preparation, not magic. That preparation changed the result.
Second: measurement is cheap and opinion is expensive. "jsdom is heavy" was only a feeling until I had 25 MB and 5.6 MB on the screen. Half an hour of benchmarking settled a question I could have argued about for a week.
Third: writing tests after the system already works feels like wasted time. It is not. The 42 tests I wrote at the end immediately found two problems: I was removing <script> elements before looking for JSON-LD inside them, and a short but legitimate page such as example.com had started being rejected. Both bugs would have reached production quietly.
text2.site has one screen, one text box, and one button.
It took much more work than it appears to contain.
That is the point.
text2.site - paste the URL, get the text.
Member discussion