Every content pipeline starts with the same unglamorous step: get the words off the page. A search index, a summariser, a retrieval-augmented chatbot and a change monitor all want the same thing, which is the text a human would read, without the navigation bar, the cookie banner, the footer links and the forty <div> wrappers around each paragraph.
There is no single correct answer to "what is the text of this page". A news article's text is the story. A Wikipedia article's text arguably includes the infobox table but not the 300 category links at the bottom. A pricing page's text is mostly in lists and tables that a pure article extractor will mangle. So instead of one tool you get three, and the skill is knowing what each one keeps.
Three converters, three inputs
| API | You send | You get back | Credits per call |
|---|---|---|---|
| Website to Text | A URL | One flat string of text, plus title, date, language | 25 |
| URL to Markdown | A URL | Markdown with headings, lists and tables preserved, plus counts | 10 |
| HTML to Text | Raw HTML you already have | The main article text, or nothing | 2 |
Two things stand out before you write any code. First, the URL-based APIs fetch the page for you, which is why they cost more: they do the network work, follow redirects and parse whatever comes back. Second, the cheapest one does not fetch anything. If your system already holds the HTML (a crawler you run, an email body, a CMS export, a page a user pasted), sending that HTML to HTML to Text costs a fraction of asking another service to fetch the page again.
All three are POST endpoints that take a JSON body. Here is one helper used for the rest of this lesson.
// Node 18+, ESM. Run with: node yourfile.mjs
const KEY = process.env.APIVERVE_API_KEY;
export async function apiverve(id, body) {
const res = await fetch(`https://api.apiverve.com/v1/${id}`, {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
const json = await res.json();
if (!res.ok || json.status !== 'ok') {
throw new Error(`${id} ${res.status}: ${json.error ?? 'unknown error'}`);
}
return json.data;
}import os
import requests
KEY = os.environ["APIVERVE_API_KEY"]
def apiverve(api_id: str, body: dict) -> dict:
res = requests.post(
f"https://api.apiverve.com/v1/{api_id}",
headers={"x-api-key": KEY},
json=body,
timeout=30,
)
payload = res.json()
if not res.ok or payload.get("status") != "ok":
raise RuntimeError(f"{api_id} {res.status_code}: {payload.get('error')}")
return payload["data"]The 30-second timeout is deliberate. These calls wait on someone else's web server, and a slow origin is the most common reason one of them takes longer than a typical lookup API.
Website to Text: everything, flattened
Website to Text returns the page's readable text as a single string, along with title, title_alt, description, date, language and publisher.
curl -X POST "https://api.apiverve.com/v1/websitetotext" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com"}'Small page, so the text is one sentence. Notice the language code and the null date.
On a tiny page that looks perfect. On a real article it shows its character. Converting the Wikipedia article on RSS returned roughly the whole page as one paragraph-less line: the intro, then every section heading followed by a literal [edit], the example XML from the article body, the full reference list with ↑ markers, the navigation boxes at the bottom, and finally the hidden maintenance categories. Nothing was lost, and nothing was organised.
Three details from that response are worth remembering because they will bite you in production:
- No paragraph breaks. The string has no newlines. You cannot split it into paragraphs afterwards, because the information is gone. If structure matters downstream, this is the wrong tool.
dateis a guess. For that article it returned1999-03-15, which is the date RSS was first released, lifted from the body text. It is not when the page was published or edited. Treatdateas a hint for articles that declare a publication date, and never as a crawl timestamp or a freshness signal.titlemay be the site, not the page. The same call returned"Wikipedia"as the title. When you need the page's own title, cross-check with the Markdown converter'stitleor the metadata API in the next lesson.
Where Website to Text is the right tool: full-text search, keyword and entity extraction, language detection, spam and content classification, and "did this page mention X" checks. All of those treat text as a bag of words and do not care about paragraphs.
You store `data.date` from Website to Text as the article's publish date and use it to hide stale content. What is the risk?
Answer the check above to continue.
HTML to Text: when you already have the HTML
HTML to Text is the odd one out. It takes html, not a URL, and it does not try to keep everything. It looks for the main article body and returns that, reporting how it got there in extractionMethod.
curl -X POST "https://api.apiverve.com/v1/htmltotext" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"html":"<html><body><nav>Home | Docs | Pricing</nav><article><h1>Version 2.4 released</h1><p>Version 2.4 adds batch exports and fixes the timezone bug in scheduled reports.</p><p>Upgrade by running the installer again.</p></article><footer>Copyright 2026 Example Inc.</footer></body></html>"}'The nav and footer are gone, and so is the h1. extractionMethod is article.
That input returns the two paragraphs, separated by a blank line, with wordCount 19 and characterCount 120. The navigation and footer are dropped, which is the point. So is the <h1>, which is the surprise. Article extraction keeps body paragraphs, not headings. If you need the headline, take it from the HTML yourself or from the metadata API.
The three values of extractionMethod are the contract you code against:
extractionMethod | parsed | What happened |
|---|---|---|
article | true | A main body was found; text is its paragraphs |
markup | true | No article body; text is the markup with tags stripped |
none | false | Nothing usable; text is null and the counts are absent |
The markup fallback needs care. Sending <ul><li>Starter</li><li>Pro</li><li>Mega</li></ul> returned the text StarterProMega with a wordCount of 1. Inline-joined list items are fine for a "does this page mention Pro" check and useless for display. If your input is mostly lists and tables (pricing pages, spec sheets, navigation-heavy pages), use URL to Markdown or parse the HTML yourself.
The none case is where real bugs live. An image-only fragment returned "text": null, "parsed": false, and no wordCount field at all. Code that does data.text.length or sums data.wordCount will throw or produce NaN. Branch on parsed first.
from typing import Optional
from apiverve import apiverve
def extract_body(html: str) -> Optional[str]:
data = apiverve("htmltotext", {"html": html})
if not data.get("parsed"):
return None
if data["extractionMethod"] == "markup" and data["wordCount"] < 20:
return None # probably a nav fragment or list soup, not content
return data["text"]
if __name__ == "__main__":
with open("saved-page.html", encoding="utf-8") as f:
print(extract_body(f.read()))Pages that render in the browser
Some sites send an almost empty HTML document and build the page with JavaScript after it loads. Whether a URL-based converter sees the finished page depends on whether it runs that JavaScript, and you should not assume either way for a given site. Measure instead. Convert the page, then compare wordCount against what you see in a browser. A product page that shows 400 words to you and returns 12, or returns only "Loading..." or "Enable JavaScript to use this site", was rendered client-side and the content never reached the converter.
When that happens you have three options, in order of preference:
- Find the data source. Client-rendered pages fetch their content from somewhere, often a JSON endpoint visible in the browser's network tab, or an RSS feed (lesson four). That source is more stable than any rendering of it.
- Render it yourself and send the HTML. Run a headless browser in your own infrastructure, take
page.content()after the page settles, and pass that HTML to HTML to Text. You pay for the browser, and the extraction costs 2 credits. - Accept the gap. For a monitoring job across thousands of sites, logging "too little text" and moving on is often the right call.
A cheap guard catches this in batch jobs before bad data lands in your index:
export function looksEmpty({ markdown, wordCount }) {
if (wordCount < 50) return true;
return /enable javascript|loading\.\.\.|please wait/i.test(markdown.slice(0, 500));
}Choosing, and the rules that come with it
The decision usually takes one question: what happens to the text next?
- Search index, classifier, keyword or language detection: Website to Text. Structure is irrelevant and you get
languagefor free. - Anything a language model reads, or anything a human reads later: URL to Markdown. Headings and lists survive, and the call is cheaper.
- You already fetched the HTML, or you only want the article body: HTML to Text. It is the cheapest by a wide margin and refuses to return junk when there is no article.
Cache by URL. Page text changes on the scale of days for most sites, so a 24-hour cache on conversions is reasonable, and re-converting the same URL inside a single batch run is pure waste.
Finally, extracting text does not change who owns it. Respect a site's robots.txt and terms for automated access, do not republish full articles you converted, and be careful with pages that contain personal data: storing the text of a public profile page in your vector database is still storing personal data, with the obligations that come with it. Summaries, quotes with attribution and private indexing for your own users are the normal, defensible uses.
Next, the smaller pieces of a page that matter just as much: titles, Open Graph tags, links and short URLs, in Metadata, titles and links.