You build a small "ask the docs" helper: download a page, paste it into the prompt, add the user's question. It works, and the bill comes out about three times what the text on the screen suggests. The Node.js reference page for UDP sockets is 7,127 tokens of text. Downloaded as is, it's 23,178.
The model reads the tags too
You pay per token, a chunk of text about three-quarters of an English word long. Input tokens are what you send, output tokens are what the model writes back. What your code downloads isn't the page you see, it's the HTML behind it: tags, attributes, inline scripts, SVG icons. The model has no "skip the markup" mode. Every character you send is cut into tokens and billed at the input price.
Markup is made of short, rare fragments, so each token covers fewer characters than in prose: 3.4 against 4.1 on the pages below. Here is one heading from the Node.js path page, exactly as the server sends it:
<section><h3>Windows vs. POSIX<span><a class="mark" href="#windows-vs-posix" id="windows-vs-posix">#</a></span><a aria-hidden="true" class="legacy" id="path_windows_vs_posix"></a></h3>
That's 62 tokens. The words a reader sees, "Windows vs. POSIX", are 5. Code samples pay as well: every apostrophe in them is written as ', an HTML entity (the escaped form of a character), and each one adds about 2.5 tokens over a plain '.
I counted 63 pages four ways
I took all 63 API reference pages of Node.js 20.19.2, the HTML copies that ship with its documentation on Debian, and counted each one four ways with tiktoken, OpenAI's open-source tokenizer (the program that cuts text into tokens), using o200k_base, the encoding of current GPT models.
| What you send, all 63 pages | Tokens | × text | Saved |
|---|---|---|---|
| Raw HTML, as downloaded | 2,284,043 | 3.06× | — |
| Only the article block, tags kept | 1,754,618 | 2.35× | 23% |
| Whole page, tags stripped | 746,178 | 1.00× | 67% |
| Article block, tags stripped | 705,226 | 0.95× | 69% |
The obvious fix is to cut away the page chrome, the navigation, header and footer repeated on every page. Here it saves 23%. Stripping the tags saves 67%, because most of the waste sits inside the article. Against the bare article text, the raw pages carry 1.58 million extra tokens: 34% around the article and 66% inside it. The 63 pages hold 34,912 href= attributes, 33,092 class= attributes and 22,622 escaped apostrophes.
Per page, raw HTML ran from 2.05× its text (C++ addons) to 9.2× (the short usage example), plus one stub page at 20.7×. 50 of the 63 landed between 2.5× and 4.5×.
What it does to the bill
Take the UDP page, close to the median at 3.25×, and a helper that answers 1,000 questions a day: one page, a 30-token question and a 300-token answer per call. Prices are gpt-5.6's list prices, $2 per million input tokens and $12 per million output, from the catalog of the LLM gateway I work on, read on 22 October 2026.
With raw HTML the month costs $1,500. With text it costs $537. The answer is the same $108 either way, 7% of the first bill and 20% of the second. Cutting away only the navigation and keeping the tags lands at $1,070.
So the lever here is input. It's 93% of the raw bill, and more than two-thirds of that input is markup the model didn't need to answer a question about UDP sockets.
Strip the tags before the call
Download the page the way your code will, curl -o page.html <url>, and run python page_text.py page.html. It needs the standard library plus tiktoken for the count:
import sys, tiktoken, html.parser # pip install tiktoken SKIP = {"head", "script", "style", "svg", "nav", "header", "footer", "aside"} BLOCK = {"p", "div", "li", "pre", "tr", "td", "th", "br", "h1", "h2", "h3", "h4", "section"} class PageText(html.parser.HTMLParser): def __init__(self): super().__init__() self.skip, self.parts = 0, [] # skip > 0 means "inside a dropped tag" def handle_starttag(self, tag, attrs): self.skip += tag in SKIP self.parts.append("\n" if tag in BLOCK else "") def handle_endtag(self, tag): self.skip -= tag in SKIP def handle_data(self, data): if not self.skip: self.parts.append(data) page = open(sys.argv[1], encoding="utf-8").read() p = PageText(); p.feed(page) text = "\n".join(s.strip() for s in "".join(p.parts).splitlines() if s.strip()) tok = tiktoken.get_encoding("o200k_base").encode print(f"{len(tok(page)):,} tokens as HTML, {len(tok(text)):,} as text") It drops what rarely holds the answer (scripts, styles, icons, navigation, header, footer), keeps the rest as text, and starts a new line at every paragraph, list item, heading and table cell, so code samples stay readable. On the UDP page it prints 23,178 tokens as HTML, 7,127 as text. The text variable is what you send.
The Node.js sidebar isn't a <nav> tag, so its list of modules stays in. If a site wraps the article in <main> or gives it an id, cut that block out first; here that removed the sidebar and the table of contents, about 5% of the text tokens.
What this doesn't solve
Stripping throws away what tags carry. Links lose their URLs and tables lose their columns, so if the task is "list every link" or "read this price table", keep that part as HTML or convert it to Markdown. Pages built by JavaScript in the browser can arrive as a nearly empty shell, and no stripping helps there. Documentation pages are lean by web standards, I measured local copies rather than the live site, and I didn't measure any other kind of site, so run the script on yours. The counts come from OpenAI's tokenizer; Claude and Gemini split text differently.
The gateway I mentioned sells gpt-5.6 at $1.70 / $10.20, 15% under list, which turns the two monthly bills into $1,275 and $457. That's the same 15% off both. It bills the tokens you send, tags included, and strips nothing for you.
The takeaway
The model bills markup at the same price as meaning. Send it the text of the page, not the page: on these 63 pages that was two-thirds of the input, for twenty lines of code.
