<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[WrenTools]]></title><description><![CDATA[WrenTools]]></description><link>https://wrentools.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 28 Oct 2026 07:37:59 GMT</lastBuildDate><atom:link href="https://wrentools.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Read RSS, Atom and JSON Feed in Python: four feedparser traps]]></title><description><![CDATA[feedparser reads RSS and Atom but not JSON Feed, fetches URL strings, reports pubDate as updated and drops Atom feed authors. Measured, with fixes.]]></description><link>https://wrentools.hashnode.dev/read-rss-atom-and-json-feed-in-python</link><guid isPermaLink="true">https://wrentools.hashnode.dev/read-rss-atom-and-json-feed-in-python</guid><category><![CDATA[Python]]></category><category><![CDATA[rss]]></category><category><![CDATA[web scraping]]></category><category><![CDATA[automation]]></category><category><![CDATA[apify]]></category><dc:creator><![CDATA[WrenTools]]></dc:creator><pubDate>Tue, 27 Oct 2026 15:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ad91735786cf66498f3c2f4/e419e00c-1c0e-4320-8cfa-3bdc3f9bd7d8.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>Disclosure:</strong> we built and sell the Apify Actor whose code this post walks through, <a href="https://apify.com/wrentools/rss-feed-reader">RSS, Atom &amp; JSON Feed Reader</a> (publisher WrenTools). The post was written with AI assistance and reviewed by a person before publishing. Every output below was produced by running that code with feedparser 6.0.14.</p>
<p><strong>In short:</strong> feedparser is the right tool for RSS and Atom, but four things catch people. It returns zero entries for a JSON Feed, so read that format with <code>json</code> yourself. A <code>str</code> you pass it is fetched as a URL or opened as a file, so pass bytes. <code>entry.get("updated_parsed")</code> quietly returns the published date when the feed gave no updated date; use <code>dict.get(entry, "updated_parsed")</code>. An Atom entry without its own author doesn't inherit the feed's. To find a site's feed in the first place, read the <code>&lt;link rel="alternate"&gt;</code> tags on its home page.</p>
<p>Reading a feed in Python usually starts and ends with <code>feedparser.parse(...)</code>. It handles RSS 0.9x to 2.0, RDF and Atom, tolerates the broken XML you meet in the wild, resolves relative links and strips scripts from HTML. We built a feed reader on it. Here are the four surprises it had to handle, what feedparser actually returned for each, and the fix, then how to find a feed and filter it by date.</p>
<p><strong>Predict:</strong> in our three test feeds (one RSS, one Atom, one JSON Feed), no item names its own author, and only the Atom entry gives an updated date. Once our reader has turned each first item into the same row, which rows have an author? Guess, then check the figure.</p>
<p><img src="https://cdn.hashnode.com/uploads/gql/6ad91735786cf66498f3c2f4/0a8c5e94-e7b8-44ae-9da7-9909df046135.png" alt="The first item of each test feed mapped to one row. RSS 2.0: title from its title element, link '/posts/spring' made absolute as https://blog.example/posts/spring, published from pubDate 'Tue, 14 Oct 2026 11:00:00 +0200' as 2026-10-14T09:00:00Z, no id, no author, updated None although feedparser's .get() gives 2026-10-14T09:00:00Z. Atom 1.0: id urn:lab:1, updated 2026-10-20T08:00:00Z, no published date, author Dana Reyes inherited from the feed where feedparser's entry gives None. JSON Feed: feedparser.parse finds 0 entries, so it is read with json; link /p/1 made absolute, date_published 09:30-05:00 as 2026-10-21T14:30:00Z, author Sam Ito from the feed's top-level authors." /></p>
<h2>Why does feedparser return no entries for a JSON Feed?</h2>
<p>Because it doesn't read the format. <a href="https://www.jsonfeed.org/">JSON Feed</a> (1.0 and 1.1) is a third feed format, alongside RSS and Atom. Given a valid JSON Feed 1.1 document with one item:</p>
<pre><code class="language-text">feedparser.parse: version='' entries=0 bozo=1
</code></pre>
<p>No error is raised, so a loop over <code>entries</code> just does nothing. It has been asked for on feedparser's tracker: <a href="https://github.com/kurtmckee/feedparser/issues/103">Support for JSON Feeds</a>. The fix is short, because JSON Feed is plain JSON. Check for the version URL, then map the fields:</p>
<pre><code class="language-python">doc = json.loads(body.decode("utf-8-sig"))
if "jsonfeed.org/version/" not in str(doc.get("version", "")) or not isinstance(doc.get("items"), list):
    raise ValueError("JSON, but not a JSON Feed")
feed_authors = doc.get("authors") or ([doc["author"]] if isinstance(doc.get("author"), dict) else [])
for item in doc["items"]:
    authors = item.get("authors") or ([item["author"]] if isinstance(item.get("author"), dict) else []) or feed_authors
    link = urljoin(base_url, item["url"]) if isinstance(item.get("url"), str) else None
    published = _iso_text(item.get("date_published"))  # to UTC
    ...
</code></pre>
<p>Two details matter. Version 1.0 used a single <code>author</code> object and 1.1 uses an <code>authors</code> list, so read both. And 1.1 says an item with no authors takes the feed's. Our reader returned this for the same document:</p>
<pre><code class="language-text">format=jsonfeed entries=1 link=https://m.example/p/1 published=2026-10-21T14:30:00Z author=Sam Ito summary='First post'
</code></pre>
<p>The relative <code>/p/1</code> became a full link, <code>09:30-05:00</code> became UTC, and the author came from the feed's top level.</p>
<h2>Why does <code>feedparser.parse</code> make a network request?</h2>
<p>Because a <code>str</code> argument is read as a URL, a file path, or a document, in that order. We started a server on 127.0.0.1 and passed its address as a string, then wrote a feed to a temporary file and passed its path:</p>
<pre><code class="language-text">feedparser.parse(url string): server requests=1 entries=2
feedparser.parse(path string): version='atom10' entries=1
</code></pre>
<p>That is handy in a notebook. In a service it means a string from a user can make your process fetch any URL, or read a local file, without going through your HTTP client and whatever limits you put on it: timeouts, size caps, robots.txt, a block on private addresses. Our reader fetches with its own client and passes feedparser the bytes only. The parser refuses anything else, so a refactor can't bring the old path back:</p>
<pre><code class="language-python">def parse(body: bytes, content_type: str, base_url: str, include_content: bool = False) -&gt; Feed:
    if not isinstance(body, bytes):
        raise TypeError("parse takes the fetched bytes, never a URL or a path")
    ...
    parsed = feedparser.parse(body, response_headers={"content-location": base_url,
                                                      "content-type": content_type or "application/xml"})
</code></pre>
<p>Passing <code>content-location</code> keeps relative links working: the <code>/posts/spring</code> link in our test RSS came back as <code>https://blog.example/posts/spring</code>.</p>
<h2>Why does an RSS item have an "updated" date the feed never gave?</h2>
<p>Because feedparser maps a missing <code>updated_parsed</code> to <code>published_parsed</code>. Its own source says so, in a <code>DeprecationWarning</code>: <em>"a temporary mapping has been created from <code>updated_parsed</code> to <code>published_parsed</code> if <code>updated_parsed</code> doesn't exist. This fallback will be removed in a future version of feedparser."</em> For an RSS item with only a <code>&lt;pubDate&gt;</code>:</p>
<pre><code class="language-text">e.get('updated_parsed') is set: True; 'updated' in e: False; dict.get: None
</code></pre>
<p>So <code>in</code> says there is no updated date and <code>.get()</code> returns one anyway. Any code that flags items as "edited since publishing" will flag every one. Read the key without the mapping:</p>
<pre><code class="language-python">"published": _iso(dict.get(e, "published_parsed")),
"updated": _iso(dict.get(e, "updated_parsed")),
</code></pre>
<p>That gave <code>published=2026-10-14T09:00:00Z updated=None</code>, with the <code>+0200</code> in the <code>pubDate</code> converted to UTC. Because the fallback is marked for removal, code that relies on it will also change behaviour on some future upgrade.</p>
<h2>Where did the Atom author go?</h2>
<p>An Atom feed often names its author once, on the <code>&lt;feed&gt;</code>, and not on each <code>&lt;entry&gt;</code>. RFC 4287 (s.4.2.1) says such an entry takes the feed's author. feedparser leaves the entry empty:</p>
<pre><code class="language-text">feedparser entry author: None; feed author: 'Dana Reyes'
</code></pre>
<p>Apply the rule yourself: the entry's author, then its <code>&lt;source&gt;</code>'s, then the feed's.</p>
<pre><code class="language-python">feed_author = parsed.feed.get("author") if parsed.version.startswith("atom") else None
...
"author": e.get("author") or (dict.get(e, "source") or {}).get("author") or feed_author,
</code></pre>
<p>Our reader then returned <code>'Dana Reyes'</code> for the entry. We don't do the same for RSS: RSS 2.0 has no feed-level author. Its <code>managingEditor</code> is an editorial contact, and copying it onto every item would credit the wrong person.</p>
<h2>How do you find a site's feed from its home page?</h2>
<p>Many sites announce it in the page's <code>&lt;head&gt;</code>:</p>
<pre><code class="language-html">&lt;link rel="alternate" type="application/rss+xml" title="Posts" href="/feed.xml"&gt;
&lt;link rel="alternate" type="application/atom+xml" href="https://blog.example/atom.xml"&gt;
&lt;link rel="alternate" hreflang="de" href="/de/"&gt;
</code></pre>
<p>The third tag is <code>alternate</code> too, but a translation, not a feed, so check the <code>type</code> as well as the <code>rel</code>. The standard library's <code>HTMLParser</code> is enough. Collect the <code>&lt;link&gt;</code> tags whose <code>rel</code> includes <code>alternate</code> and whose type is RSS, Atom, RDF or JSON Feed, make each <code>href</code> absolute, and drop duplicates:</p>
<pre><code class="language-text">discover: ['https://blog.example/feed.xml', 'https://blog.example/atom.xml']
</code></pre>
<p>Our reader does this when a URL you give it turns out to be HTML, and reads up to five feeds a page advertises.</p>
<h2>A date cut-off that doesn't lose posts</h2>
<p>For a daily digest you want "items from the last day" only. Two choices decide whether that works. Compare in UTC, after converting each item's own offset. And decide what to do with an item that has no date: a cut-off can't judge it, so we keep it rather than drop it silently. On our test feed (one item from 14 October, one undated), measured at noon UTC on 25 October:</p>
<pre><code class="language-text">'7 days' -&gt; cut-off 2026-10-18T12:00:00+00:00; kept ['Undated note']
'2026-10-14' -&gt; kept ['Spring survey', 'Undated note']
</code></pre>
<p>An item uses its published date, else its updated date. If you'd rather drop undated items, filter them after the cut-off, where it's visible.</p>
<h2>Questions people ask</h2>
<p><strong>Does feedparser sanitize HTML?</strong> Yes. Given <code>&lt;p onclick="x()"&gt;All six sites.&lt;/p&gt;&lt;script&gt;alert(1)&lt;/script&gt;</code> as an item's description, its <code>summary</code> was <code>&lt;p&gt;All six sites.&lt;/p&gt;</code>: the script and the event handler were gone. We also turn the summary into plain text (at most 2,000 characters) so it can go in a spreadsheet, which gave <code>All six sites.</code></p>
<p><strong>Will these still hold after an upgrade?</strong> We measured 6.0.14, the version we pin. The <code>updated_parsed</code> mapping above is marked for removal, so re-check that one when you upgrade.</p>
<h2>Try it</h2>
<p>The reader is about 240 lines of <code>core.py</code> on top of feedparser, and returns one row per item in the same shape for every format, with the feed's title on each row so many feeds can be mixed. Failures (a page with no feed, a robots.txt that disallows the feed) come back as error rows and aren't charged. It costs $0.001 a feed plus $0.0003 an item. Two examples use it: <a href="https://github.com/WrenTools/apify-actor-examples/tree/main/rss-feeds-to-a-spreadsheet">RSS feeds into a spreadsheet</a>, which on each later run adds only the new posts, and <a href="https://github.com/WrenTools/apify-actor-examples/tree/main/podcast-episodes-to-searchable-transcripts">a podcast's episodes into searchable transcripts</a>, which reads each episode's audio link from the feed.</p>
<p>Which feed broke your parser: a JSON Feed, a missing date, or something stranger? Tell us in the comments.</p>
]]></content:encoded></item><item><title><![CDATA[Download files from a list of URLs in Python without getting burned]]></title><description><![CDATA[Stream downloads with urllib: refuse oversized files before the body, check the content type, clean file names and block private IPs. Measured.]]></description><link>https://wrentools.hashnode.dev/download-files-from-a-list-of-urls-in-python</link><guid isPermaLink="true">https://wrentools.hashnode.dev/download-files-from-a-list-of-urls-in-python</guid><category><![CDATA[Python]]></category><category><![CDATA[Security]]></category><category><![CDATA[http]]></category><category><![CDATA[web scraping]]></category><category><![CDATA[apify]]></category><dc:creator><![CDATA[WrenTools]]></dc:creator><pubDate>Tue, 27 Oct 2026 00:32:56 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ad91735786cf66498f3c2f4/006dc7e5-18c4-4472-a01e-2a79fe07a970.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>Disclosure:</strong> we built and sell the Apify Actor whose code this post walks through, <a href="https://apify.com/wrentools/bulk-file-downloader">Bulk File Downloader</a> (publisher WrenTools). The post was written with AI assistance and reviewed by a person before publishing. Every output below was produced by running that code.</p>
<p><strong>In short:</strong> stream each download and check four things before you keep it. Refuse a file whose declared <code>Content-Length</code> is over your cap, and count bytes as you read in case there is no length. In our test, a 50 MB file with no length stopped after 5,242,880 bytes against a 5 MB cap. Check the <code>Content-Type</code> before reading the body: a "PDF" link that answers with a sign-in page is caught there. Take the file name's last path segment only, and check the address you actually connected to, because <code>localhost</code> passes a URL check and still reaches 127.0.0.1.</p>
<p>A list of links looks harmless: loop over it and call <code>urlopen</code>. Then one link turns out to be a 4 GB archive. Another "PDF" is an HTML sign-in page, because the share link expired. A server suggests the name <code>../../etc/passwd</code>. And one URL points inside your own network, at a router or a cloud metadata address. If the list comes from someone else, that last one is a server-side request forgery (SSRF).</p>
<p>Here is how our downloader handles each case, using only the standard library, and what each check did when we pointed it at a test server.</p>
<p><strong>Predict:</strong> six test links, one trap each: a 50 MB file with a <code>Content-Length</code>, the same without one, a "PDF" that answers with a sign-in page, a server-suggested name of <code>../../etc/passwd</code>, a <code>localhost</code> address, and a redirect to <code>ftp://</code>. A plain loop calls <code>urlopen</code>, reads each body and saves it under the server's name. How many of the six does it stop? Guess, then check the figure.</p>
<p><img src="https://cdn.hashnode.com/uploads/gql/6ad91735786cf66498f3c2f4/c037cd4d-f85c-4743-a367-bbc2b82eb9ba.png" alt="Table of six test links fetched two ways. A plain urlopen loop stopped none: it read both 50 MB files in full (50,000,000 and 50,003,968 bytes), saved a 40-byte sign-in page as report.pdf, would save ../../etc/passwd to ../etc/passwd outside its folder, fetched the localhost link from 127.0.0.1, and followed the redirect to ftp://, failing only because no FTP server listened. The downloader stopped five: file-too-large for both big files with 0 bytes kept, content-type-not-allowed for the sign-in page, blocked-non-public-address 127.0.0.1, and fetch-failed for the ftp redirect. It saved the sixth safely as passwd." /></p>
<h2>How do you stop a huge file before it fills your disk?</h2>
<p>Two checks, because a server doesn't have to tell you the size. First, read the declared length and refuse before touching the body. Then count while you read, in chunks, and stop the moment the count passes the cap:</p>
<pre><code class="language-python">CHUNK = 256 * 1024

declared = headers.get("content-length", "")
if declared.isdigit() and int(declared) &gt; max_bytes:
    got.error, got.message = "file-too-large", f"{int(declared)} bytes declared"
    return got
parts, size = [], 0
while True:
    chunk = resp.read(CHUNK)
    if not chunk:
        break
    size += len(chunk)
    if size &gt; max_bytes:
        got.error, got.message = "file-too-large", f"more than {max_bytes} bytes"
        return got
    parts.append(chunk)
</code></pre>
<p>Our test server streams 64 KiB blocks until it passes 50,000,000 bytes, 50,003,968 in all. We fetched that file twice with a 5,000,000-byte cap, once with <code>Content-Length: 50000000</code> declared and once with no length:</p>
<table>
<thead>
<tr>
<th>Server sends</th>
<th>Result</th>
<th>Body bytes read</th>
</tr>
</thead>
<tbody><tr>
<td><code>Content-Length: 50000000</code></td>
<td><code>file-too-large</code>, "50000000 bytes declared"</td>
<td>0</td>
</tr>
<tr>
<td>no <code>Content-Length</code> (body runs to the close)</td>
<td><code>file-too-large</code>, "more than 5000000 bytes"</td>
<td>5,242,880</td>
</tr>
</tbody></table>
<p>The second figure is 20 chunks of 256 KiB, the first count past 5,000,000. So the most you ever hold is the cap plus one chunk. Send <code>Accept-Encoding: identity</code> too. <code>urllib</code> doesn't decompress for you, so this asks for the file itself rather than a gzip stream, and the bytes you count are the file's bytes.</p>
<h2>How do you know you got a PDF and not a sign-in page?</h2>
<p>Ask for the content type before you read the body. Rules are full MIME types (<code>application/pdf</code>) or prefixes ending in a slash (<code>image/</code>):</p>
<pre><code class="language-python">def type_allowed(content_type: str, allowed: list) -&gt; bool:
    if not allowed:
        return True
    content_type = (content_type or "").lower()
    for rule in allowed:
        rule = rule.strip().lower()
        if rule and (content_type == rule or (rule.endswith("/") and content_type.startswith(rule))):
            return True
    return False
</code></pre>
<p>Our test server answered <code>/report.pdf</code> the way an expired share link does: status 200, <code>text/html</code>, "Please sign in". With <code>["application/pdf"]</code> the download stopped with <code>content-type-not-allowed</code>, "content type text/html", and an empty body. Without the filter, those 40 bytes of HTML were stored as <code>report.pdf</code>. Status 200 only means the server answered. It doesn't mean you got the file.</p>
<p>Keep a SHA-256 of every body you store (<code>hashlib.sha256(body).hexdigest()</code>). It lets you skip unchanged files on the next run and spot two links that serve the same thing.</p>
<h2>Which file name should you save it under?</h2>
<p>Prefer the server's <code>Content-Disposition</code> name, else the URL's last path segment. Then keep only the last segment of whatever you got, whichever way its slashes lean. The standard library's <code>email.message.Message</code> already decodes the RFC 2231 <code>filename*=</code> form, so there's no need to parse it yourself:</p>
<pre><code class="language-python">def file_name(final_url: str, headers: dict, content_type: str) -&gt; str:
    name = None
    disposition = headers.get("content-disposition")
    if disposition:
        msg = Message()
        msg["content-disposition"] = disposition
        name = msg.get_filename()
    if not name:
        name = unquote(urlsplit(final_url).path.rsplit("/", 1)[-1])
    name = (name or "").replace("\\", "/").rsplit("/", 1)[-1].strip()
    if not name:
        name = "file" + (mimetypes.guess_extension(content_type or "") or "")
    return name
</code></pre>
<p>What it made of the headers we sent:</p>
<table>
<thead>
<tr>
<th><code>Content-Disposition</code></th>
<th>File name</th>
<th>Stored key</th>
</tr>
</thead>
<tbody><tr>
<td><code>attachment; filename="../../etc/passwd"</code></td>
<td><code>passwd</code></td>
<td><code>0001-passwd</code></td>
</tr>
<tr>
<td><code>attachment; filename="C:\Users\me\report.pdf"</code></td>
<td><code>report.pdf</code></td>
<td><code>0001-report.pdf</code></td>
</tr>
<tr>
<td><code>attachment; filename*=UTF-8''r%C3%A9sum%C3%A9%202026.pdf</code></td>
<td><code>résumé 2026.pdf</code></td>
<td><code>0001-r_sum__2026.pdf</code></td>
</tr>
<tr>
<td>none, URL <code>/named/plain</code></td>
<td><code>plain</code></td>
<td><code>0001-plain</code></td>
</tr>
</tbody></table>
<p>The stored key adds the link's position, so two files both called <code>report.pdf</code> don't overwrite each other. It also replaces every character the storage doesn't allow (Apify's key-value store takes letters, digits and <code>!-_.'()</code>). If you write to disk instead, the position prefix still does the job.</p>
<h2>Why check the address after connecting, not the URL?</h2>
<p>Checking the URL's host against a blocklist misses names. <code>localhost</code>, or any domain whose DNS points at a private address, sails through a string check. A redirect can also change the host after your check has run. So the downloader checks the peer address of the socket it actually opened, on every connection, redirects included:</p>
<pre><code class="language-python">def is_public_address(ip: str) -&gt; bool:
    addr = ipaddress.ip_address(ip.split("%", 1)[0])
    if addr.version == 6 and addr.ipv4_mapped:
        addr = addr.ipv4_mapped
    return addr.is_global


def _connect_public(address, *args, **kwargs):
    sock = socket.create_connection(address, *args, **kwargs)
    peer = sock.getpeername()[0]
    if not is_public_address(peer):
        sock.close()
        raise BlockedAddress(f"blocked-non-public-address {peer}")
    return sock
</code></pre>
<p><code>http.client</code> sets <code>_create_connection</code> on each connection object, so a small subclass swaps in <code>_connect_public</code>, and an <code>HTTPHandler</code>/<code>HTTPSHandler</code> pair hands those subclasses to <code>urllib</code>. Both test URLs were refused, with nothing sent beyond the TCP handshake:</p>
<ul>
<li><code>http://127.0.0.1:&lt;port&gt;/report.pdf</code>: <code>blocked-non-public-address 127.0.0.1</code></li>
<li><code>http://localhost:&lt;port&gt;/report.pdf</code>: <code>blocked-non-public-address 127.0.0.1</code></li>
</ul>
<p>And what <code>ipaddress.is_global</code> says, after unwrapping IPv4-mapped IPv6:</p>
<table>
<thead>
<tr>
<th>Address</th>
<th>Public?</th>
</tr>
</thead>
<tbody><tr>
<td><code>8.8.8.8</code></td>
<td>yes</td>
</tr>
<tr>
<td><code>10.0.0.5</code>, <code>192.168.1.1</code></td>
<td>no</td>
</tr>
<tr>
<td><code>169.254.169.254</code> (cloud metadata)</td>
<td>no</td>
</tr>
<tr>
<td><code>127.0.0.1</code>, <code>::1</code></td>
<td>no</td>
</tr>
<tr>
<td><code>::ffff:127.0.0.1</code> (loopback in IPv6 clothing)</td>
<td>no</td>
</tr>
<tr>
<td><code>100.64.0.1</code> (carrier-grade NAT)</td>
<td>no</td>
</tr>
<tr>
<td><code>fd00::1</code> (IPv6 private)</td>
<td>no</td>
</tr>
</tbody></table>
<p>Without the unwrapping step, <code>::ffff:127.0.0.1</code> is the classic way past a check that only knows IPv4 ranges.</p>
<h2>Don't follow redirects to other schemes</h2>
<p><code>urllib</code>'s redirect handler follows <code>ftp://</code> as well as <code>http(s)://</code>. We pointed a link at a 302 to <code>ftp://127.0.0.1/secret.txt</code>. Plain <code>urllib</code> followed it and tried to open an FTP connection, which failed only because no FTP server was listening. The downloader's handler refuses anything but http and https, so the link fails as a fetch error instead:</p>
<pre><code class="language-python">class _HttpOnlyRedirects(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        if not newurl.lower().startswith(("http://", "https://")):
            raise urllib.error.HTTPError(newurl, code, "redirect to a non-http(s) URL refused", headers, fp)
        return super().redirect_request(req, fp, code, msg, headers, newurl)
</code></pre>
<p>Build the opener with <code>ProxyHandler({})</code> too, so an environment variable can't quietly route your requests through a proxy.</p>
<h2>Be polite to the sites</h2>
<p>Fetching a list is not crawling, but the files still live on someone's server. Before the first file from a site, the downloader reads that site's <code>robots.txt</code> and skips what it disallows. A 401 or 403 on <code>robots.txt</code>, or a server error, is read as "disallow everything", the cautious reading. It also leaves 500 ms between two requests to the same site, and fetches each duplicate URL once.</p>
<h2>What this doesn't do</h2>
<ul>
<li>It doesn't crawl. Only the URLs you list are fetched, one request each, and links inside files are not followed.</li>
<li>It doesn't sign in. A file behind a login is out of reach, by design.</li>
<li>It doesn't run JavaScript. A "download" button that builds its link in the browser has no direct URL to give it.</li>
</ul>
<h2>Try it</h2>
<p>The checks above are about 150 lines of <code>core.py</code> plus 165 of <code>http_fetch.py</code>, standard library only. The Actor stores each file in the run's key-value store and returns a dataset row per link with the file name, size, content type, SHA-256, or the reason it failed. Failed links aren't charged. It costs $0.002 a file plus $0.001 for each started megabyte. The <a href="https://github.com/WrenTools/apify-actor-examples/tree/main/download-files-from-a-list-of-links">GitHub example</a> reads a text file of links, downloads them in one run, checks each SHA-256 and keeps a manifest, so a second run fetches only new or fixed links.</p>
<p>Which of these has bitten you in a real script: the huge file, the sign-in page, the odd file name, or the private address? Tell us in the comments.</p>
]]></content:encoded></item><item><title><![CDATA[PDF to Markdown in Python: rebuilding headings, lists and paragraphs from font sizes]]></title><description><![CDATA[Turn a PDF into clean Markdown with pdfminer.six: find the body size, map larger sizes to headings, join wrapped lines, rebuild lists, escape the rest.]]></description><link>https://wrentools.hashnode.dev/pdf-to-markdown-in-python-headings-from-font-sizes</link><guid isPermaLink="true">https://wrentools.hashnode.dev/pdf-to-markdown-in-python-headings-from-font-sizes</guid><category><![CDATA[Python]]></category><category><![CDATA[pdf]]></category><category><![CDATA[markdown]]></category><category><![CDATA[RAG ]]></category><category><![CDATA[apify]]></category><dc:creator><![CDATA[WrenTools]]></dc:creator><pubDate>Sun, 25 Oct 2026 18:24:53 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ad91735786cf66498f3c2f4/475fee7b-d9ac-4b66-80eb-1f9af86ce6b0.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>Disclosure:</strong> we built and sell the Apify Actor whose code this post walks through, <a href="https://apify.com/wrentools/pdf-text-markdown-extractor">PDF to Text &amp; Markdown Extractor</a> (publisher WrenTools). The post was written with AI assistance and reviewed by a person before publishing. Every output below was produced by running that code.</p>
<p><strong>In short:</strong> a PDF has no headings or paragraphs, only positioned text with font sizes. To get Markdown, take pdfminer.six's text boxes, call the most common size (weighted by characters) the body, and make short blocks at least 1.12 times larger into <code>#</code>, <code>##</code> and <code>###</code>, largest first. Then join wrapped lines, turn bullet lines into <code>- </code> items, and escape lines that only look like Markdown. On our three-page test report that gives five headings, a list and two clean paragraphs.</p>
<p>A PDF is a page description, not a document. It says "draw these glyphs at this position in this font at this size", and nothing marks a line as a heading or says where a paragraph ends. If you want Markdown, for a notes app, a static site or a RAG pipeline, you have to infer the structure back from the layout.</p>
<p>Here is what that looks like in Python with <a href="https://github.com/pdfminer/pdfminer.six">pdfminer.six</a>, using the rules from our extractor's <code>core.py</code>.</p>
<h2>What does plain text extraction give you?</h2>
<p>pdfminer's one-liner on our test report:</p>
<pre><code class="language-python">from pdfminer.high_level import extract_text
extract_text("report.pdf")
</code></pre>
<pre><code class="language-text">'Quarterly Field Report\n\n1. Summary\nThe survey covered twelve sites over six weeks. Most sites met the target, and two were\nre-measured after the storm in week four.\n\n2. Findings\n\n2.1 Water quality\n...'
</code></pre>
<p>The words are all there, but a line break falls in the middle of a sentence, page breaks show up as <code>\x0c</code> form feeds, and nothing tells "2.1 Water quality" apart from the body text. The structure is in the font sizes, so we need the layout objects instead.</p>
<h2>1. Read text boxes with their sizes</h2>
<p>pdfminer's layout analysis groups characters into <code>LTTextBox</code> objects made of <code>LTTextLine</code>s, and each character (<code>LTChar</code>) knows its size and font name. Per line we keep the median size and whether most characters are bold:</p>
<pre><code class="language-python">from statistics import median
from pdfminer.layout import LTChar

def line_style(line):
    chars = [c for c in line if isinstance(c, LTChar) and c.get_text().strip()]
    if not chars:
        return 0.0, False
    size = median(round(c.size, 1) for c in chars)
    bold = sum("bold" in c.fontname.lower() for c in chars) &gt; len(chars) / 2
    return size, bold
</code></pre>
<p>The median keeps one odd glyph, such as a superscript, from moving the line's size.</p>
<h2>2. Split a box when its lines change size</h2>
<p>pdfminer often puts a heading in the same box as the paragraph right under it. So we walk the lines of each box and start a new block whenever the size changes by 0.6 pt or more, or the weight changes:</p>
<pre><code class="language-python">current = None
for text, size, bold in styled_lines:
    if current and abs(current.size - size) &lt; 0.6 and current.bold == bold:
        current.lines.append(text)
    else:
        current = Block(page, [text], size, bold)
        blocks.append(current)
</code></pre>
<h2>3. Find the body size</h2>
<p>The body text is whatever most of the characters are set in. Count characters per (rounded) size, not lines or blocks, because one long paragraph outweighs ten short captions:</p>
<pre><code class="language-python">from collections import Counter

def body_size(blocks):
    weights = Counter()
    for b in blocks:
        weights[round(b.size)] += sum(len(line) for line in b.lines)
    return float(weights.most_common(1)[0][0]) if weights else 0.0
</code></pre>
<p><strong>Predict:</strong> the test report sets its text at four sizes: 22, 15, 12.5 and 11 pt. Which one will <code>body_size</code> pick as the body, and which of the others will end up as <code>#</code>, <code>##</code> and <code>###</code>? Make a guess, then check it against the figure.</p>
<p><img src="https://cdn.hashnode.com/uploads/gql/6ad91735786cf66498f3c2f4/1a8c0b5e-d6b2-4877-9fe4-aafce34245f8.png" alt="Bar chart of the characters set in each font size of the three-page test report. 11 pt holds 350 of the 423 characters, in 4 blocks, so it is the body. The heading threshold is 11 x 1.12 = 12.32 pt. Above it, 22 pt (22 characters, 1 block) becomes #, 15 pt (21 characters, 2 blocks) becomes ##, and 12.5 pt (30 characters, 2 blocks, counted as 12) becomes ###: five headings, including page 2's 2.2 Equipment." /></p>
<p>On the test report that gives 11 pt.</p>
<h2>4. Map larger sizes to heading levels</h2>
<p>Every size at least 1.12 times the body is a heading size. The largest becomes <code>#</code>, the next <code>##</code>, and everything smaller than that <code>###</code>:</p>
<pre><code class="language-python">HEADING_RATIO = 1.12

def heading_levels(blocks):
    body = body_size(blocks)
    sizes = sorted({round(b.size) for b in blocks if body and b.size &gt;= body * HEADING_RATIO}, reverse=True)
    return {size: min(i + 1, 3) for i, size in enumerate(sizes)}
</code></pre>
<p>The report has headings at 22, 15 and 12.5 pt, so the result is <code>{22: 1, 15: 2, 12: 3}</code>. The 12.5 pt subheadings are only 1.14 times the body, so a threshold much above 1.12 would have missed them. A block also has to be short to count, at most 3 lines and 200 characters, so a paragraph set slightly large (a pull quote, an abstract) stays a paragraph, but only while pdfminer keeps its lines in one box. In our test, a 4-line 14 pt paragraph with lines 1.2 times its size apart stayed one paragraph; spaced twice its size apart, each line became a box of its own and a <code>##</code> heading.</p>
<h2>5. Join wrapped lines, and rejoin hyphenated words</h2>
<p>Inside a paragraph block, lines are joined with a space. A line that ends in <code>-</code> followed by one that starts with a lowercase letter is treated as a word broken across the line:</p>
<pre><code class="language-python">def join(lines):
    out = ""
    for line in lines:
        if not out:
            out = line
        elif out.endswith("-") and line[:1].islower():
            out = out[:-1] + line
        else:
            out += " " + line
    return out
</code></pre>
<p><code>["the extraction was effi-", "cient"]</code> becomes "the extraction was efficient". The rule has limits. A real hyphenated word that happens to break at its hyphen loses it: <code>["two sites were re-", "measured"]</code> becomes "two sites were remeasured". And <code>["the Covid-", "19 wave"]</code> becomes "the Covid- 19 wave", because the digit isn't lowercase. Fixing those needs a dictionary. We decided a rare wrong join was better than leaving every broken word split.</p>
<h2>6. Turn bullet lines into list items</h2>
<p>A line that starts with a bullet character and a space becomes a <code>- </code> item. A following line that doesn't start with a bullet is a wrapped continuation of that item:</p>
<pre><code class="language-python">import re
BULLET = re.compile(r"^\s*[•●▪◦‣⁃–\-\*]\s+")
</code></pre>
<h2>7. Escape what only looks like Markdown</h2>
<p>A body paragraph that starts with <code>1. </code> or <code># </code> would turn into a list or a heading once rendered. So the start of each paragraph is escaped:</p>
<pre><code class="language-python">def escape_md(text):
    return re.sub(r"^(#{1,6}\s|&gt;|\d+\.\s)", r"\\\1", text)
</code></pre>
<p>"1. Summary of costs" becomes <code>\1. Summary of costs</code>, and "# of sites" becomes <code>\# of sites</code>.</p>
<h2>The result</h2>
<p>The same test report, through all seven rules:</p>
<pre><code class="language-markdown"># Quarterly Field Report

## 1. Summary

The survey covered twelve sites over six weeks. Most sites met the target, and two were re-measured after the storm in week four.

## 2. Findings

### 2.1 Water quality

Turbidity fell at nine of twelve sites.

- Site A: within limits
- Site B: within limits
- Site C: above limits

### 2.2 Equipment

Two probes were replaced. The spare kit is now stored at the north depot so field crews can reach it within an hour.
</code></pre>
<p>"2.2 Equipment" sits on page 2, and it is still a <code>###</code>, because the levels are computed over the whole document rather than page by page.</p>
<h2>What this approach doesn't do</h2>
<ul>
<li><strong>Tables and images</strong> are not rebuilt. A table comes out as its cell text in reading order.</li>
<li><strong>Scanned pages</strong> have no text layer, so there is nothing to read. Page 3 of the test report is blank, and the extractor lists it as a page without text instead of returning an empty string as if it had succeeded. For scans you need OCR (we use Tesseract in a separate <a href="https://apify.com/wrentools/scanned-pdf-image-ocr">OCR Actor</a>).</li>
<li><strong>Protected PDFs.</strong> pdfminer will extract text from a PDF whose owner has disabled copying unless you check first. We check <code>PDFDocument.is_extractable</code> and refuse those files, and we refuse password-protected ones too.</li>
</ul>
<h2>Try it</h2>
<p>The full code is about 280 lines of <code>core.py</code>. It also splits the Markdown into overlapping chunks for RAG, each tagged with its start and end page. If you'd rather call it than run it, the <a href="https://github.com/WrenTools/apify-actor-examples/tree/main/pdf-to-markdown-and-rag-chunks">GitHub example</a> sends a list of PDF links to the Actor and saves Markdown files and chunks. It costs $0.002 a PDF plus $0.0001 a page.</p>
<p>If you've found a better signal than font size for headings, such as weight, spacing or the PDF's own tags, I'd like to hear it in the comments.</p>
]]></content:encoded></item><item><title><![CDATA[OCR a scanned PDF in Python: why 300 dpi, and how to catch silent misreads]]></title><description><![CDATA[Render scanned PDF pages with pypdfium2, read them with Tesseract's TSV output, and flag misreads by word confidence. Measured at 72-300 dpi.]]></description><link>https://wrentools.hashnode.dev/ocr-a-scanned-pdf-in-python-at-300-dpi</link><guid isPermaLink="true">https://wrentools.hashnode.dev/ocr-a-scanned-pdf-in-python-at-300-dpi</guid><category><![CDATA[Python]]></category><category><![CDATA[OCR ]]></category><category><![CDATA[tesseract]]></category><category><![CDATA[pdf]]></category><category><![CDATA[apify]]></category><dc:creator><![CDATA[WrenTools]]></dc:creator><pubDate>Sun, 25 Oct 2026 18:24:31 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ad91735786cf66498f3c2f4/c5e47e04-23cd-4be4-b954-8a9c1ce264c3.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>Disclosure:</strong> we built and sell the Apify Actor whose code this post walks through, <a href="https://apify.com/wrentools/scanned-pdf-image-ocr">Image &amp; Scanned PDF OCR</a> (publisher WrenTools). The post was written with AI assistance and reviewed by a person before publishing. Every output below was produced by running that code.</p>
<p><strong>In short:</strong> a scanned PDF page has no text layer, so render it to an image with pypdfium2 and read it with Tesseract. Render at 300 dpi. On our test page, 72 dpi lost all the small print and turned "9:00" into "2:00". 150 dpi read "Fine print" as "Fine pint". 300 dpi read every line exactly, in about twice the time of 150. Ask Tesseract for TSV output: each word's confidence lets you flag the misreads it does make.</p>
<p>When a PDF comes from a scanner or a phone app, each page is a picture. Text extraction returns nothing, because there is no text in the file, only pixels. You have to draw each page and read the pixels back with OCR.</p>
<p>Here is how our OCR Actor does it in Python, and what we measured when we changed the one setting that matters most, the resolution.</p>
<h2>How do you tell a scanned page from a text page?</h2>
<p>Ask the PDF for its text layer. With <a href="https://github.com/pypdfium2-team/pypdfium2">pypdfium2</a>:</p>
<pre><code class="language-python">import pypdfium2 as pdfium

pdf = pdfium.PdfDocument("scan.pdf")
page = pdf[0]
page.get_textpage().get_text_range()
</code></pre>
<pre><code class="language-text">''
</code></pre>
<p>Our test page is a Letter page scanned at 200 dpi. It holds a notice at body size, two lines of small print (about 7 pt) and one line of fine print (about 5 pt), on off-white paper with speckle and a 1.2 degree tilt, like a phone scan. Its text layer is empty, so OCR is the only way to read it.</p>
<h2>Draw the page, then read it with Tesseract</h2>
<p>PDF pages are measured in points, 72 to the inch, so a scale of <code>dpi / 72</code> draws the page at the resolution you want. Greyscale is enough for OCR:</p>
<pre><code class="language-python">dpi = 300
img = page.render(scale=dpi / 72, grayscale=True).to_pil()
img.save("page.png")
</code></pre>
<p>Then run the <code>tesseract</code> program on the image. We ask for TSV rather than plain text, because TSV gives every word with its position and a confidence from 0 to 100:</p>
<pre><code class="language-python">import subprocess

args = ["tesseract", "page.png", "stdout", "-l", "eng", "--psm", "3", "--dpi", str(dpi), "tsv"]
tsv = subprocess.run(args, capture_output=True, timeout=180).stdout.decode("utf-8", "replace")
</code></pre>
<p><code>--psm 3</code> is Tesseract's automatic page layout. We pass <code>--dpi</code> because we know it; without it Tesseract estimates the resolution. On this page, leaving it out gave the same text, with two paragraph breaks in different places.</p>
<h2>Which resolution? We measured it</h2>
<p><strong>Predict:</strong> before you look at the chart, guess two things. What happens to the 5 pt fine print at 72 dpi, and how much longer does a page take at 300 dpi than at 150? Then check your guess against the figure.</p>
<p><img src="https://cdn.hashnode.com/uploads/gql/6ad91735786cf66498f3c2f4/38bbe20f-ada5-474b-b1a0-7882f08c8dcb.png" alt="Bar chart of how much of one scanned page Tesseract read correctly at 72, 150 and 300 dpi. At 72 dpi the body text is 99.0% right and the 7 pt small print and 5 pt fine print are not found at all; three words are read wrong, among them 2:00 for 9:00 (confidence 67.3). At 150 dpi: 100%, 99.7% and 98.0%, with pint for print: (confidence 55.8). At 300 dpi all three are 100% with no word wrong, in 2.1x the time of 150 dpi." /></p>
<p>The same page, drawn at three resolutions and read with Tesseract 5.4 (English):</p>
<table>
<thead>
<tr>
<th>Drawn at</th>
<th>Image size</th>
<th>Time</th>
<th>Body text</th>
<th>Small print (7 pt)</th>
<th>Fine print (5 pt)</th>
</tr>
</thead>
<tbody><tr>
<td>72 dpi</td>
<td>612 x 792</td>
<td>0.37 s</td>
<td>99.0%</td>
<td>not found</td>
<td>not found</td>
</tr>
<tr>
<td>150 dpi</td>
<td>1275 x 1651</td>
<td>0.48 s</td>
<td>100%</td>
<td>99.7%</td>
<td>98.0%</td>
</tr>
<tr>
<td>300 dpi</td>
<td>2550 x 3301</td>
<td>1.02 s</td>
<td>100%</td>
<td>100%</td>
<td>100%</td>
</tr>
</tbody></table>
<p>(Accuracy is character similarity to the text we drew. Times are on one machine and are only for comparing the rows.)</p>
<p>At <strong>72 dpi</strong>, both lines of small print are simply missing, and the body text has a silent error:</p>
<pre><code class="language-text">‘on Tuesday 4 November from 2:00 to 13:00.
lease store drinking water in advance.
</code></pre>
<p>The page says <strong>9:00</strong>. A wrong number that still looks like a number is the worst kind of OCR error, because nothing downstream notices it.</p>
<p>At <strong>150 dpi</strong> the body and the small print are right, and the fine print comes out as:</p>
<pre><code class="language-text">Fine pint this offer is valid for orders placed before 31 March 2027 only
</code></pre>
<p>At <strong>300 dpi</strong> every line is exact:</p>
<pre><code class="language-text">Fine print: this offer is valid for orders placed before 31 March 2027 only.
</code></pre>
<p>300 dpi took about twice as long as 150 here. We use 300 for every page, because the small print on a page is often the part someone needs, such as terms, reference numbers and dates.</p>
<h2>Use word confidences to catch misreads</h2>
<p>Each TSV row is one word (level 5), with its block, paragraph and line numbers, its box and its confidence. Not every misread scored low: at 72 dpi <code>‘on</code> (a stray mark before "on") scored 91.4, and at 150 dpi <code>only</code> ("only." without its full stop) scored 82.2. The wrong time and the misread word in the fine print did:</p>
<table>
<thead>
<tr>
<th>Resolution</th>
<th>Word read</th>
<th>Should be</th>
<th>Confidence</th>
</tr>
</thead>
<tbody><tr>
<td>72 dpi</td>
<td><code>2:00</code></td>
<td>9:00</td>
<td>67.3</td>
</tr>
<tr>
<td>150 dpi</td>
<td><code>pint</code></td>
<td>print:</td>
<td>55.8</td>
</tr>
</tbody></table>
<p>So a confidence threshold makes a good review flag. It is not a verdict, though. At 300 dpi, "offer is" was read correctly with a confidence of 10.5. Flag low words for a person to check, and don't delete them.</p>
<p>We also report a character-weighted mean confidence per page (81.2 at 72 dpi, 91.4 at 150, 93.9 at 300). A low page mean is a quick sign that a page needs a second look.</p>
<h2>Rebuild the lines from the TSV</h2>
<p>The TSV keys <code>(block, paragraph, line)</code> give you the layout back. Words with the same key share a line, and a new block or paragraph starts a new paragraph:</p>
<pre><code class="language-python">def parse_tsv(tsv):
    words, lines, last = [], [], None
    for row in tsv.splitlines()[1:]:
        cols = row.split("\t")
        if len(cols) &lt; 12 or cols[0] != "5":       # level 5 = a word
            continue
        text, conf = cols[11].strip(), float(cols[10])
        if not text or conf &lt; 0:
            continue
        key = (int(cols[2]), int(cols[3]), int(cols[4]))  # block, paragraph, line
        words.append((text, conf))
        if last is not None and key[:2] != last[:2]:
            lines.append("")                         # blank line between paragraphs
        if key != last:
            lines.append(text)
        else:
            lines[-1] += " " + text
        last = key
    return words, "\n".join(lines).strip()
</code></pre>
<h2>Guard rails worth having</h2>
<ul>
<li><strong>Very large pages.</strong> A poster drawn at 300 dpi can run to hundreds of megapixels. We scale anything over 40 million pixels down before OCR, and mark that page as scaled. Pillow's decompression-bomb check refuses an image file over 400 million pixels without decoding it.</li>
<li><strong>Access controls.</strong> A PDF whose owner has turned off copying (permission bit 5) is refused. Drawing it and reading the pixels back would extract the same text by a different route. A PDF that needs a password is refused too, and the tool takes no password input.</li>
<li><strong>One thread.</strong> On a machine with a fraction of a core, Tesseract's OpenMP threads only compete with each other, so we set <code>OMP_THREAD_LIMIT=1</code>.</li>
</ul>
<h2>What this doesn't do</h2>
<ul>
<li><strong>Layout.</strong> You get lines and paragraphs, not tables or columns as structure.</li>
<li><strong>Handwriting.</strong> We haven't tested it, and Tesseract is built for printed text.</li>
<li><strong>Text PDFs.</strong> If a page already has a text layer, extract it instead (see <a href="https://wrentools.hashnode.dev/pdf-to-markdown-in-python-headings-from-font-sizes">our post on rebuilding Markdown from font sizes</a>). It's faster and it's exact.</li>
</ul>
<h2>Try it</h2>
<p>The whole thing is about 230 lines of <code>core.py</code>. The Actor has 27 Tesseract languages installed and reads images (PNG, JPEG, TIFF, WebP and more) as well as PDFs. The <a href="https://github.com/WrenTools/apify-actor-examples/tree/main/ocr-scanned-pdfs-and-images">GitHub example</a> sends a list of PDF and image links to it and saves the text. It costs $0.003 a page.</p>
<p>What resolution do you scan or render at, and have you found a case where 300 dpi wasn't enough? I'd like to hear it in the comments.</p>
]]></content:encoded></item><item><title><![CDATA[Offline OCR in a Manifest V3 Chrome extension with tesseract.js: five things we had to measure]]></title><description><![CDATA[How we run tesseract.js fully offline in a Manifest V3 Chrome extension: offscreen document, drag-to-pixel mapping, 2x upscaling, network-cut tests.]]></description><link>https://wrentools.hashnode.dev/offline-ocr-in-a-manifest-v3-chrome-extension-with-tesseract-js-five-things-we-had-to-measure</link><guid isPermaLink="true">https://wrentools.hashnode.dev/offline-ocr-in-a-manifest-v3-chrome-extension-with-tesseract-js-five-things-we-had-to-measure</guid><category><![CDATA[chrome extension]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[OCR ]]></category><category><![CDATA[tesseract]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[WrenTools]]></dc:creator><pubDate>Sun, 25 Oct 2026 14:30:45 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ad91735786cf66498f3c2f4/36398d6f-bb43-4d67-8d5d-26f40b2f57f9.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>Disclosure:</strong> we built and publish the extension this post is about, <a href="https://chromewebstore.google.com/detail/mfaolaapfbdmfilfgjgcpkdgfimljdpj">Offline OCR: Copy Text from Screen</a> (free, publisher WrenTools). The post was written with AI assistance and reviewed by a person before publishing. The code below is from the extension, and every figure was measured on it.</p>
<p><strong>In short:</strong> tesseract.js runs fully offline in a Manifest V3 extension once it lives in an offscreen document (a service worker has no DOM) and every worker, WASM and language path points inside the package, about 15 MB in all. Map the drag onto the screenshot with the image's own scale rather than <code>devicePixelRatio</code>, enlarge the crop to 2 pixels per CSS pixel, drop lines under confidence 60, and prove "offline" with a test that cuts the network.</p>
<p>We wanted a Chrome extension that reads text out of anything on screen (a paused video, a picture, a screenshot of an error) without sending the image anywhere. tesseract.js does the reading. Getting it to run fully inside a Manifest V3 extension, and read well, took five decisions we could only make by measuring. It is our answer to tesseract.js's <a href="https://github.com/naptha/tesseract.js/issues/601">Will tesseract.js support a Manifest V3 extension?</a>.</p>
<h2>1. The engine can't run in the service worker</h2>
<p>tesseract.js runs in a Web Worker and wants a canvas. An MV3 background script is a service worker, and <a href="https://developer.chrome.com/docs/extensions/reference/api/offscreen">Chrome's docs</a> say it plainly: "Service workers don't have DOM access." The way out is an offscreen document, a hidden page the extension owns. Only one can be open at a time, so creation has to be guarded against two clicks racing:</p>
<pre><code class="language-js">let creating = null;

async function ensureOffscreen() {
  if (await chrome.offscreen.hasDocument()) return;
  if (!creating) {
    creating = chrome.offscreen.createDocument({
      url: 'offscreen.html',
      reasons: ['WORKERS'],
      justification: 'Runs the bundled OCR engine in a Web Worker to read text from the selected area.',
    }).finally(() =&gt; { creating = null; });
  }
  await creating;
}
</code></pre>
<p>The service worker captures the tab (<code>chrome.tabs.captureVisibleTab</code>) and passes the screenshot and the dragged rectangle to the offscreen document, which crops, reads and replies.</p>
<h2>2. No remote code, so the engine's defaults have to go</h2>
<p>MV3 extensions "need to bundle all code they are using inside the extension itself", WebAssembly included (<a href="https://developer.chrome.com/docs/extensions/develop/migrate/remote-hosted-code">Chrome's docs</a>). tesseract.js fetches its worker, its WASM core and its language data from a CDN by default. So every path points inside the extension (if you came from tesseract.js's <a href="https://github.com/naptha/tesseract.js/issues/136">Tesseract couldn't load any languages!</a> or <a href="https://github.com/naptha/tesseract.js/issues/101">tesseract.js with a custom language and no internet</a>, <code>langPath</code> is the line that points the engine at language data in the package):</p>
<pre><code class="language-js">Tesseract.createWorker('eng', Tesseract.OEM.LSTM_ONLY, {
  workerPath: chrome.runtime.getURL('vendor/worker.min.js'),
  corePath: chrome.runtime.getURL('vendor/core'),
  langPath: chrome.runtime.getURL('vendor/lang'),
  workerBlobURL: false,
  cacheMethod: 'none',
  gzip: true,
});
</code></pre>
<p>Setting options wasn't enough for us. The shipped scripts still contain the CDN addresses as defaults (we counted one in <code>tesseract.min.js</code> and two in <code>worker.min.js</code>). Our build replaces each with a dead local path, stops if the count ever changes, and a test fails if any built script names a remote address. The package comes to about 15 MB, nearly all of it the engine (three WASM cores, picked by the CPU's SIMD support) and the English model.</p>
<h2>3. Mapping a drag onto the screenshot: take the scale from the image</h2>
<p>The user drags in CSS pixels. <code>captureVisibleTab</code> returns device pixels. It's tempting to multiply by <code>devicePixelRatio</code>, but page zoom changes the ratio as well. The screenshot already knows the answer: its width over the viewport's width.</p>
<p><strong>Predict:</strong> a 2x screen captures a 1000 px wide viewport at 2000 px, and you drag a 100 x 50 box at (10, 20). Where is the crop in the screenshot? And on a 1x screen, which pixel columns does a drag from x = 10.5 to x = 30.7 keep? Guess, then check the figure below the code.</p>
<pre><code class="language-js">function cropBox(rect, viewportWidth, imageWidth, imageHeight) {
  const s = imageWidth / viewportWidth;
  const x0 = Math.max(0, Math.floor(rect.x * s));
  const y0 = Math.max(0, Math.floor(rect.y * s));
  const x1 = Math.min(imageWidth, Math.ceil((rect.x + rect.width) * s));
  const y1 = Math.min(imageHeight, Math.ceil((rect.y + rect.height) * s));
  if (x1 - x0 &lt; 2 || y1 - y0 &lt; 2) return null;
  return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
}
</code></pre>
<p>Flooring the start and ceiling the end means a box never clips the edge of a glyph.</p>
<p><img src="https://cdn.hashnode.com/uploads/gql/6ad91735786cf66498f3c2f4/f821cfe2-2933-47dc-b6a7-dbf6d7563575.png" alt="cropBox run on the extension's test cases. On a 2x screen (viewport 1000 CSS px, screenshot 2000 px, scale 2) a drag at x 10..110, y 20..70 becomes a 200 x 100 crop at (20, 40). On a 1x screen a drag from x 10.5 to 30.7 keeps columns 10 to 30: floor 10, ceil 31, width 21. A drag from (790, 590) running 40 px past the corner of an 800 x 600 screenshot is clamped to a 10 x 10 crop, and a 1 x 1 drag returns null: The selected area is empty." /></p>
<h2>4. Enlarge to 2 pixels per CSS pixel; don't bother inverting</h2>
<p>Our first build read the crop as it came, because the first three test pages read well. Then we rendered the store screenshots and code text came out wrong. Measured on the same blocks (similarity to the true text):</p>
<table>
<thead>
<tr>
<th>Text</th>
<th>Read as captured (1x)</th>
<th>Enlarged to 2x</th>
</tr>
</thead>
<tbody><tr>
<td>14 px code</td>
<td>0.962</td>
<td>0.990</td>
</tr>
<tr>
<td>16 px text</td>
<td>0.992</td>
<td>exact</td>
</tr>
</tbody></table>
<p>2x helped or tied on every block we tried, so the crop is drawn onto a canvas in grey at 2 image pixels per CSS pixel (a 2x screen's capture is already there), capped at a 4096 px side so a large photo isn't blown up. We also tried inverting light-on-dark text first. It changed no read, because Tesseract already retries inverted lines itself, so we dropped it.</p>
<p>What still misreads, for honesty: at 14 px a caret can come out as a tilde (<code>^18.0.0</code> as <code>~18.0.0</code>), and once <code>18.2.0</code> came out as <code>l8.2.0</code>.</p>
<h2>5. Drop the low-confidence lines</h2>
<p>Cut a screenshot tight around a dark, rounded box and the corners read as junk lines (<code>Ee ™</code>, <code>o 4</code>). Their line confidence was 21 to 44. Every real line we measured scored 74 or more. So lines under 60 are left out:</p>
<pre><code class="language-js">const MIN_LINE_CONFIDENCE = 60;
function textOf(blocks) {
  const paragraphs = [];
  for (const block of blocks || []) {
    for (const paragraph of block.paragraphs || []) {
      const lines = (paragraph.lines || []).filter((l) =&gt; l.confidence &gt;= MIN_LINE_CONFIDENCE)
        .map((l) =&gt; String(l.text).replace(/\n+$/, ''));
      if (lines.length) paragraphs.push(lines.join('\n'));
    }
  }
  return paragraphs.join('\n\n');
}
</code></pre>
<h2>Bonus: proving "offline" with a test, not a promise</h2>
<p>"Nothing leaves your computer" is easy to write. We wanted a test that fails if it isn't true. The browser tests start Playwright's Chromium with every host except the local test server resolving to nothing:</p>
<pre><code class="language-python">args=["--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1"]
</code></pre>
<p>If the engine tried to fetch anything from a CDN, the read would fail. It reads exactly. (Two practical notes: branded Chrome no longer loads unpacked extensions from the command line, so the tests use Playwright's own Chromium. And a test can't click the toolbar button that grants <code>activeTab</code>, so the test build alone adds host access. A static test checks that this, an open shadow root and the name are the only differences from the shipped build.)</p>
<p>The permissions came out small as a side effect: <code>activeTab</code>, <code>scripting</code> and <code>offscreen</code>, no host access and no content script. The extension sees a page only when you call it.</p>
<p>If you've shipped tesseract.js in an extension and found a better answer to any of these, especially the small code text, I'd like to hear it in the comments.</p>
]]></content:encoded></item><item><title><![CDATA[Transcribe audio and video files to text and SRT subtitles with about 50 lines of Python]]></title><description><![CDATA[Turn a list of audio or video links into .txt transcripts and .srt subtitles from Python, with Whisper, $0.01 a minute and a hard cost cap.]]></description><link>https://wrentools.hashnode.dev/transcribe-audio-and-video-files-to-text-and-srt-subtitles-with-about-50-lines-of-python</link><guid isPermaLink="true">https://wrentools.hashnode.dev/transcribe-audio-and-video-files-to-text-and-srt-subtitles-with-about-50-lines-of-python</guid><category><![CDATA[Python]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[speech to text]]></category><category><![CDATA[whisper]]></category><category><![CDATA[apify]]></category><dc:creator><![CDATA[WrenTools]]></dc:creator><pubDate>Wed, 21 Oct 2026 20:46:17 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ad91735786cf66498f3c2f4/84f80562-4a23-41e0-8468-6f5c6dcd6fc4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>Disclosure:</strong> we built and sell the Apify Actor this tutorial calls (Apify account <code>wrentools</code>). The post was written with AI assistance, reviewed by a person before publishing, and the code was run against the live Actor. It is adapted from our examples repository on GitHub, <a href="https://github.com/Wrentools/apify-actor-examples/tree/main/transcribe-audio-to-text-and-subtitles">Wrentools/apify-actor-examples</a>, which also has a Node version.</p>
<p><strong>In short:</strong> send a list of media links to the <a href="https://apify.com/wrentools/audio-video-transcription">Audio &amp; Video Transcription</a> Actor with the <code>apify-client</code> package, and each file comes back as a plain-text transcript and an <code>.srt</code> subtitle file, transcribed by Whisper inside the run. It costs $0.01 per started minute of audio (a one-hour meeting is $0.60), and <code>max_total_charge_usd</code> caps what one run can spend.</p>
<p>You have a folder of interviews, lectures or voice notes online and want two things for each: a plain-text transcript and an <code>.srt</code> subtitle file. This example does that in one Apify run, from Python, with a hard cap on what the run can cost.</p>
<h2>What runs where</h2>
<p>The <a href="https://apify.com/wrentools/audio-video-transcription">Audio &amp; Video Transcription</a> Actor runs OpenAI's Whisper model (<code>base</code>, through faster-whisper) inside the run, so the files are not sent on to a third-party speech API. It accepts most audio and video formats (MP3, M4A, WAV, FLAC, OGG, MP4, MOV, WebM, MKV and more), detects the language, and returns the text, timed segments and subtitle files. It skips silence and music before transcribing, which reduces made-up text in long pauses.</p>
<p>It costs $0.01 per started minute of audio, at least one minute per file, with nothing charged for files that fail. A 30-second voice note costs $0.01 and a one-hour meeting $0.60. You need an Apify account and its API token (Console &gt; Settings &gt; API &amp; Integrations).</p>
<h2>The code</h2>
<pre><code class="language-bash">pip install "apify-client&gt;=3,&lt;4"
export APIFY_TOKEN=&lt;your token&gt;          # PowerShell: $env:APIFY_TOKEN = "&lt;your token&gt;"
python transcribe.py https://example.com/interview.mp3 https://example.com/lecture.mp4
</code></pre>
<pre><code class="language-python">import os
import sys
import urllib.request
from decimal import Decimal
from pathlib import Path

from apify_client import ApifyClient

ACTOR = "wrentools/audio-video-transcription"


def main(urls: list[str]) -&gt; int:
    client = ApifyClient(os.environ["APIFY_TOKEN"])
    run = client.actor(ACTOR).call(
        run_input={
            "fileUrls": urls,
            "language": "auto",          # or "en", "de", "es", ...
            "subtitleFormats": ["srt"],
        },
        max_total_charge_usd=Decimal("1.00"),  # the run stops before spending more than this
    )
    if run is None or run.status != "SUCCEEDED":
        print(f"run did not succeed: {run.status if run else 'no run'}", file=sys.stderr)
        return 1

    out = Path.cwd()
    for item in client.dataset(run.default_dataset_id).iterate_items():
        if item["status"] != "ok":
            print(f"FAILED {item['source']}: {item['error']['code']}")
            continue
        stem = Path(item["fileName"]).stem
        (out / f"{stem}.txt").write_text(item["text"], encoding="utf-8")
        srt_url = (item.get("subtitles") or {}).get("srt")
        if srt_url:
            with urllib.request.urlopen(srt_url) as response:
                (out / f"{stem}.srt").write_bytes(response.read())
        print(f"{item['fileName']}: {item['language']}, {item['durationSeconds']:.0f} s, "
              f"{item['wordCount']} words, billed {item['billedMinutes']} min")
    return 0


if __name__ == "__main__":
    if len(sys.argv) &lt; 2:
        sys.exit("usage: python transcribe.py &lt;media file link&gt; [more links...]")
    sys.exit(main(sys.argv[1:]))
</code></pre>
<p><code>call()</code> starts the run and waits for it. The run's dataset then holds one item per file, in the order you gave them. <code>max_total_charge_usd</code> caps the run: the Actor stops before a file it cannot pay for and transcribes only the minutes the remainder covers.</p>
<h2>What comes back</h2>
<p>For a 24-second sample recording, the item looks like this (shortened):</p>
<pre><code class="language-json">{
  "fileName": "speech-sample.mp3",
  "status": "ok",
  "language": "en",
  "durationSeconds": 24.005,
  "billedMinutes": 1,
  "wordCount": 59,
  "text": "This is a short sample recording for the transcription actor. The Riverside Community Garden will hold ...",
  "segments": [
    {"id": 0, "start": 0, "end": 4, "text": "This is a short sample recording for the transcription actor."}
  ]
}
</code></pre>
<p>and the <code>.srt</code> the script writes starts:</p>
<pre><code class="language-plaintext">1
00:00:00,000 --&gt; 00:00:04,000
This is a short sample recording for the transcription actor.
</code></pre>
<p>A file that fails has <code>"status": "error"</code> and an <code>error.code</code> such as <code>fetch-failed</code>, <code>no-audio-track</code> or <code>unsupported-codec</code>.</p>
<h2>Options worth knowing</h2>
<ul>
<li><code>language</code>: <code>auto</code>, or a code such as <code>en</code> or <code>ja</code>. Setting it skips detection.</li>
<li><code>task: "translate"</code>: English text from speech in any language (rougher than transcription).</li>
<li><code>wordTimestamps</code>: each word's start and end, for karaoke-style captions or search.</li>
<li><code>subtitleFormats</code>: <code>["srt", "vtt"]</code> by default.</li>
<li><code>maxMinutesPerFile</code>: stops long files early (they are marked <code>truncated</code>).</li>
</ul>
<h2>Limits</h2>
<ul>
<li>Links must point to the media file itself. Pages on YouTube, TikTok or Instagram are not files, and the Actor does not extract media from them.</li>
<li>No speaker labels: the text does not say who spoke.</li>
<li><code>base</code> is a small, fast model. It does well on clear speech in widely spoken languages; names, rare words and noisy audio are weaker. Check transcripts that matter against the audio.</li>
<li>Transcribe only recordings you have the right to process, and do not use the output alone for decisions about people. Both cautions come from the <a href="https://github.com/openai/whisper/blob/main/model-card.md">Whisper model card</a>.</li>
</ul>
<p>Questions about the example are welcome in the <a href="https://github.com/Wrentools/apify-actor-examples/issues">repository's Issues</a>.</p>
]]></content:encoded></item></channel></rss>