< the skull

BUILDING TEXT2.SITE
===================
2026-07-21

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 `articleBody` in 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 [1] - paste the URL, get the text.

Links:
  [1] https://text2.site


< the skull