<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Catalin Friptu</title>
    <description>The latest articles on DEV Community by Catalin Friptu (@malmurk).</description>
    <link>https://dev.to/malmurk</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4203901%2F1b5ae5b9-9c8e-48dc-a2c3-daab4f22f704.png</url>
      <title>DEV Community: Catalin Friptu</title>
      <link>https://dev.to/malmurk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/malmurk"/>
    <language>en</language>
    <item>
      <title>Turn PDFs (even scanned ones) into RAG-ready chunks with one API call</title>
      <dc:creator>Catalin Friptu</dc:creator>
      <pubDate>Sat, 24 Oct 2026 05:38:01 +0000</pubDate>
      <link>https://dev.to/malmurk/turn-pdfs-even-scanned-ones-into-rag-ready-chunks-with-one-api-call-4dci</link>
      <guid>https://dev.to/malmurk/turn-pdfs-even-scanned-ones-into-rag-ready-chunks-with-one-api-call-4dci</guid>
      <description>&lt;p&gt;Every RAG project hits the same wall on day two: the documents. Half are born-digital PDFs, a quarter are scans someone ran through a photocopier in 2011, and the rest are Word files. Your embedding model wants clean text in sensible chunks, with page numbers so the answer can cite its source.&lt;/p&gt;

&lt;p&gt;This post shows the two ways I do it: a small Python script you own, and a hosted API for when you don't want to run OCR yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "RAG-ready" actually means
&lt;/h2&gt;

&lt;p&gt;Before code, the target. For each document you want:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Text in reading order&lt;/strong&gt;, not columns glued together mid-sentence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structure kept as Markdown&lt;/strong&gt;: headings, lists and tables survive, because they carry meaning for the LLM and help chunking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OCR only where needed.&lt;/strong&gt; Running OCR on a born-digital page is slower and &lt;em&gt;worse&lt;/em&gt; than reading its text layer. Running nothing on a scanned page gives you an empty string, and your retriever silently never finds that page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunks with overlap&lt;/strong&gt; (1,000–2,000 characters with ~200 overlap works for most embedding models) and &lt;strong&gt;page numbers&lt;/strong&gt; on each chunk.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Point 3 is where most pipelines break. A "PDF" is often mixed: 40 typed pages and 2 scanned signature pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1: do it yourself in Python
&lt;/h2&gt;

&lt;p&gt;For born-digital PDFs, &lt;code&gt;pdfplumber&lt;/code&gt; gets you most of the way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pdfplumber&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pdf_pages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;pdfplumber&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extract_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;overlap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;overlap&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;page_no&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;pdf_pages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;paper.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;page_no&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: no text layer -&amp;gt; needs OCR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="p"&gt;...&lt;/span&gt;  &lt;span class="c1"&gt;# embed c, store with page_no
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;if not text.strip()&lt;/code&gt; line is the important one: it's how you detect a scanned page. From there you render that page to an image (&lt;code&gt;pypdfium2&lt;/code&gt;) and run Tesseract on it (&lt;code&gt;pytesseract&lt;/code&gt;), with the right language packs installed. Then you'll want headings from font sizes, tables from ruled lines, DOCX support, language detection... It's a weekend, plus the fun of installing Tesseract language data in every environment you deploy to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: one API call
&lt;/h2&gt;

&lt;p&gt;Disclosure: I built this one. It's a pay-per-page Actor on the Apify Store, &lt;a href="https://apify.com/malmurk/pdf-text-extractor" rel="noopener noreferrer"&gt;PDF Text Extractor&lt;/a&gt;, that does exactly the list above: text layer where there is one, OCR only on pages without one (13 languages), Markdown with headings/lists/ruled tables, per-page rows, chunks with overlap, and language detection. It also reads DOCX and image files.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.apify.com/v2/acts/malmurk~pdf-text-extractor/run-sync-get-dataset-items"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$APIFY_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
        "startUrls": [{"url": "https://arxiv.org/pdf/1706.03762"}],
        "outputMode": "page",
        "chunkSize": 1500,
        "chunkOverlap": 200
      }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get one JSON row per page (page 2 of that real run, text shortened):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"file_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1706.03762v7.pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"page_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"markdown"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"### 1 Introduction&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;Recurrent neural networks, long short-term memory [13] and ..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"used_ocr"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"language"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"chunks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"index"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"end"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1431&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"### 1 Introduction&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;Recurrent neural networks ..."&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"index"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1234&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"end"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2526&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"integral part of compelling sequence modeling ..."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Feeding a vector store from Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;apify_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ApifyClient&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;APIFY_TOKEN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;malmurk/pdf-text-extractor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;startUrls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pdf_urls&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;outputMode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chunkSize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chunks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]:&lt;/span&gt;
        &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pricing is $1 per 1,000 pages, plus $7 per 1,000 for pages that actually needed OCR. Files that fail (dead link, password-protected PDF, an HTML page instead of a PDF) cost nothing and come back as a row with an &lt;code&gt;error&lt;/code&gt; field, which is handy for spotting the "download" links that are really web pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas either way
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"PDF links" that are HTML pages.&lt;/strong&gt; Many sites link to a viewer page with a Download button. Check the &lt;code&gt;Content-Type&lt;/code&gt; before you parse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tables without ruling lines&lt;/strong&gt; (most scientific papers) come out as text in reading order with any text-layer tool. If tables matter, check them by eye on a sample.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OCR language.&lt;/strong&gt; Tesseract with only &lt;code&gt;eng&lt;/code&gt; on a German scan produces confident garbage. Set the languages you actually have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotated margin stamps&lt;/strong&gt; (like arXiv's vertical ID) are noise; skip rotated text on text-layer pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Use it from an agent
&lt;/h2&gt;

&lt;p&gt;If you work in Claude, Cursor or another MCP client, the same Actor is available as a tool through Apify's MCP server: add &lt;code&gt;https://mcp.apify.com?tools=malmurk/pdf-text-extractor&lt;/code&gt; and ask it to "read this PDF and summarise section 3".&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Cătălin, a Python developer in Chișinău building scraping and document tools. Questions or a PDF that breaks it: leave a comment. Drafted with AI assistance, tested and edited by me.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>rag</category>
      <category>ai</category>
      <category>pdf</category>
    </item>
    <item>
      <title>How to find local businesses with no website (a lead list for web designers)</title>
      <dc:creator>Catalin Friptu</dc:creator>
      <pubDate>Sat, 24 Oct 2026 05:37:28 +0000</pubDate>
      <link>https://dev.to/malmurk/how-to-find-local-businesses-with-no-website-a-lead-list-for-web-designers-1fa2</link>
      <guid>https://dev.to/malmurk/how-to-find-local-businesses-with-no-website-a-lead-list-for-web-designers-1fa2</guid>
      <description>&lt;p&gt;If you build websites for small businesses, the best prospects are the ones that don't have a website yet: a dentist with only a phone number on the map, a salon that lives on Instagram, a restaurant whose domain expired two years ago.&lt;/p&gt;

&lt;p&gt;Here's how to build that list for any city from OpenStreetMap, for free, plus the three traps that make most "no website" lists useless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: query OpenStreetMap with Overpass
&lt;/h2&gt;

&lt;p&gt;OpenStreetMap stores businesses as tagged objects: &lt;code&gt;amenity=dentist&lt;/code&gt;, &lt;code&gt;shop=hairdresser&lt;/code&gt;, &lt;code&gt;amenity=restaurant&lt;/code&gt;, &lt;code&gt;shop=car_repair&lt;/code&gt;, and so on. Many have &lt;code&gt;phone&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;opening_hours&lt;/code&gt;, and sometimes &lt;code&gt;website&lt;/code&gt;. The &lt;a href="https://overpass-turbo.eu/" rel="noopener noreferrer"&gt;Overpass API&lt;/a&gt; lets you ask for "dentists in this city with a name but no website tag":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[out:json][timeout:60];
area["name"="Graz"]["boundary"="administrative"]["admin_level"="6"]-&amp;gt;.city;
nwr["amenity"="dentist"]["name"](area.city)[!"website"][!"contact:website"];
out center tags;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paste it into &lt;a href="https://overpass-turbo.eu/" rel="noopener noreferrer"&gt;overpass-turbo.eu&lt;/a&gt;, press Run, and export the result. For Graz (run on 2026-10-24) that returned 76 dentist entries with no website tag, 62 of them with a phone number (a few are the same practice mapped twice, as a point and as a building).&lt;/p&gt;

&lt;p&gt;Swap &lt;code&gt;amenity=dentist&lt;/code&gt; for any niche: &lt;code&gt;shop=beauty&lt;/code&gt;, &lt;code&gt;shop=hairdresser&lt;/code&gt;, &lt;code&gt;amenity=restaurant&lt;/code&gt;, &lt;code&gt;office=lawyer&lt;/code&gt;, &lt;code&gt;craft=plumber&lt;/code&gt;. The &lt;code&gt;admin_level&lt;/code&gt; of a city differs by country: if you get zero results, look up the city's boundary relation on openstreetmap.org and check its &lt;code&gt;admin_level&lt;/code&gt; tag.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: don't trust the empty field (the three traps)
&lt;/h2&gt;

&lt;p&gt;This is where raw lists fall apart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap 1: "no website tag" is not "no website".&lt;/strong&gt; OSM volunteers often skip the field. A missing tag means &lt;em&gt;unknown&lt;/em&gt;. Google the name before you pitch, or you'll be the person offering a website to someone who has a nice one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap 2: chains.&lt;/strong&gt; Franchise locations are often mapped without a website, because the brand site is elsewhere. Your "leads" fill up with Subway and Dave &amp;amp; Buster's. Filter out anything with a &lt;code&gt;brand&lt;/code&gt; or &lt;code&gt;brand:wikidata&lt;/code&gt; tag, and any name that appears at several locations in your area.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap 3: the website field lies the other way too.&lt;/strong&gt; When a business &lt;em&gt;does&lt;/em&gt; list a website, check it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the domain doesn't resolve any more (expired: great lead),&lt;/li&gt;
&lt;li&gt;it times out or returns 404 (broken: great lead),&lt;/li&gt;
&lt;li&gt;it's actually a Facebook or Instagram URL, or the domain &lt;em&gt;redirects&lt;/em&gt; to a Facebook page (social only: great lead).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simple &lt;code&gt;HEAD&lt;/code&gt; request, falling back to &lt;code&gt;GET&lt;/code&gt;, with a short timeout catches all three. Be generous in the other direction: a 403 from a bot wall or a 500 means a server exists, so don't count that as "no website".&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: make the phone numbers usable
&lt;/h2&gt;

&lt;p&gt;Mappers type phone numbers however they like: &lt;code&gt;0316 822052&lt;/code&gt;, &lt;code&gt;+43 316 822 052&lt;/code&gt;, &lt;code&gt;(0316) 82-20-52&lt;/code&gt;. Convert them to international E.164 (&lt;code&gt;+43316822052&lt;/code&gt;) with the &lt;code&gt;phonenumbers&lt;/code&gt; library and the country of the city, so they work in a dialer, WhatsApp or a CRM.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shortcut
&lt;/h2&gt;

&lt;p&gt;Disclosure: I packaged all of the above as a pay-per-result Actor on the Apify Store, &lt;a href="https://apify.com/malmurk/osm-no-website-leads" rel="noopener noreferrer"&gt;Businesses Without Websites Finder&lt;/a&gt;. You type a city and pick categories, and it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;runs the Overpass queries per category,&lt;/li&gt;
&lt;li&gt;removes duplicates (shops are often mapped twice, as a point and as a building),&lt;/li&gt;
&lt;li&gt;skips chains using OSM brand tags and the name-suggestion-index,&lt;/li&gt;
&lt;li&gt;checks every listed website live and labels each business &lt;code&gt;none&lt;/code&gt;, &lt;code&gt;social_only&lt;/code&gt; or &lt;code&gt;dead&lt;/code&gt;, with the reason (&lt;code&gt;DNS lookup failed&lt;/code&gt;, &lt;code&gt;timeout&lt;/code&gt;, &lt;code&gt;website redirects to www.facebook.com&lt;/code&gt;),&lt;/li&gt;
&lt;li&gt;converts phones to E.164.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A run for dentists in Chișinău returns 100 leads in about 90 seconds. It costs $3 per 1,000 leads, and businesses that get filtered out are free. It also works from Make, Zapier or n8n if you want new leads to land in a Google Sheet every week.&lt;/p&gt;

&lt;p&gt;Whichever route you take: check each lead by hand before you call, and keep the local rules for cold outreach in mind (in the EU, GDPR and ePrivacy apply to calls and emails).&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Data © OpenStreetMap contributors, ODbL. I'm Cătălin, a Python developer in Chișinău. If you try the query on your city and get zero results, post the city in the comments and I'll help with the area filter. Drafted with AI assistance, tested and edited by me.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>freelancing</category>
      <category>opendata</category>
    </item>
  </channel>
</rss>
