<?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[Magenta Waterwheel]]></title><description><![CDATA[Magenta Waterwheel]]></description><link>https://magentawaterwheel.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6add60eecd7f4085507c1d28/12bc7452-d061-4f86-8cad-68d6df223303.png</url><title>Magenta Waterwheel</title><link>https://magentawaterwheel.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 11:39:49 GMT</lastBuildDate><atom:link href="https://magentawaterwheel.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Converting PDFs to Clean Markdown for RAG and LLM Pipelines]]></title><description><![CDATA[Tables, OCR, and per-page chunks — a tested walkthrough of turning messy documents into text your embedding model can actually use]]></description><link>https://magentawaterwheel.hashnode.dev/converting-pdfs-to-clean-markdown-for-rag-and-llm-pipelines</link><guid isPermaLink="true">https://magentawaterwheel.hashnode.dev/converting-pdfs-to-clean-markdown-for-rag-and-llm-pipelines</guid><category><![CDATA[Python]]></category><category><![CDATA[llm]]></category><category><![CDATA[RAG ]]></category><category><![CDATA[api]]></category><category><![CDATA[Machine Learning]]></category><dc:creator><![CDATA[Magenta Waterwheel]]></dc:creator><pubDate>Fri, 30 Oct 2026 20:04:12 GMT</pubDate><content:encoded><![CDATA[<p>If you've built a RAG pipeline that ingests real-world documents instead of clean <code>.txt</code> files, you've hit this problem: PDFs don't contain text the way you'd want them to. They contain <em>layout instructions</em> (glyph positions on a page) and the actual reading order, headings, and table structure have to be reconstructed. Word and PowerPoint files aren't much better; the structure is buried in XML that most naive converters flatten into a wall of text.</p>
<p>This matters for RAG because retrieval quality depends on chunk quality. A table that gets flattened into "1 2 3 4 5 6 7 8 9" instead of a proper Markdown table is useless to both your chunker and the embedding model. A section header that disappears means your chunk boundaries stop lining up with the document's actual structure.</p>
<p>This post is a practical, tested walkthrough of converting documents to Markdown for LLM ingestion: what the output actually looks like, where OCR fits in, and where the limits are.</p>
<h2>Why Markdown specifically</h2>
<p>Markdown is the sweet spot between "plain text" and "full HTML/XML" for LLM pipelines:</p>
<ul>
<li>Headings (<code>#</code>, <code>##</code>) preserve document structure so you can chunk by section instead of by arbitrary character count</li>
<li>Tables render as pipe tables, which both humans and LLMs parse correctly</li>
<li>Lists stay lists instead of collapsing into runs of text</li>
<li>It's token-efficient compared to HTML, with no tag overhead</li>
</ul>
<p>That's why most modern RAG tooling (LangChain loaders, LlamaIndex parsers, and dedicated services) converge on Markdown or Markdown-like intermediate formats rather than plain text or raw HTML.</p>
<h2>Testing a document-to-Markdown Actor on a real PDF</h2>
<p>Rather than describe this abstractly, I ran a real file through our <a href="https://apify.com/magenta_waterwheel/document-to-markdown">PDF to Markdown Converter</a> (full disclosure: we built it) and I'm showing the unedited output below. It's an Apify Actor (a serverless program with a REST API), so the whole interaction is a single HTTP call.</p>
<p>I pointed it at the first page of a well-known arXiv paper (public, stable URL, good test case because it has a title block, author table, and body text):</p>
<pre><code class="language-bash">export APIFY_TOKEN="your-apify-token"

curl -s "https://api.apify.com/v2/acts/magenta_waterwheel~document-to-markdown/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "startUrls": [{"url": "https://arxiv.org/pdf/1706.03762"}],
    "pageRange": "1",
    "maxPagesPerDocument": 1,
    "outputMode": "document"
  }'
</code></pre>
<p>The real, unedited response (truncated for length; the full <code>markdown</code> field is longer):</p>
<pre><code class="language-json">[{
  "url": "https://arxiv.org/pdf/1706.03762",
  "status": "success",
  "fileName": "1706.03762v7.pdf",
  "title": "Attention Is All You Need",
  "pageCount": 15,
  "pagesExtracted": 1,
  "ocrPages": 0,
  "tableCount": 2,
  "wordCount": 420,
  "markdown": "# Attention Is All You Need\n\n| Ashish Vaswani * | Noam Shazeer * | Niki Parmar * | Jakob Uszkoreit * |\n| --- | --- | --- | --- |\n| Google Brain | Google Brain | Google Research | Google Research |\n\n## Abstract\n\nThe dominant sequence transduction models are based on complex recurrent or convolutional neural networks..."
}]
</code></pre>
<p>Two things worth noting in that output: the author block, which is laid out as a table in the PDF, came back as an actual Markdown table (<code>tableCount: 2</code>), and the heading structure (<code># Attention Is All You Need</code>, <code>## Abstract</code>) survived instead of collapsing into one text blob. <code>ocrPages: 0</code> confirms this went through text extraction, not OCR, since arXiv PDFs have a real text layer.</p>
<h2>Getting per-page output for chunking</h2>
<p>For RAG, you usually don't want one giant Markdown blob per document; you want chunks, and page boundaries are a reasonable default chunking unit before you apply your own semantic splitter. Setting <code>outputMode</code> to <code>"page"</code> gives you one dataset item per page instead of per document. I tested this too, with a plain Python script using <code>requests</code>:</p>
<pre><code class="language-python">import os
import requests

TOKEN = os.environ["APIFY_TOKEN"]
URL = "https://api.apify.com/v2/acts/magenta_waterwheel~document-to-markdown/run-sync-get-dataset-items"

payload = {
    "startUrls": [{"url": "https://arxiv.org/pdf/1706.03762"}],
    "pageRange": "1",
    "maxPagesPerDocument": 1,
    "outputMode": "page",
}

resp = requests.post(URL, params={"token": TOKEN}, json=payload, timeout=180)
resp.raise_for_status()
items = resp.json()

for item in items:
    print(item["url"], "page", item["pageNumber"],
          "words:", item["wordCount"], "tables:", item["tableCount"])
</code></pre>
<p>Output from the actual run:</p>
<pre><code class="language-plaintext">https://arxiv.org/pdf/1706.03762 page 1 words: 420 tables: 2
</code></pre>
<p>Each item in <code>"page"</code> mode carries its own <code>pageNumber</code>, <code>markdown</code>, and metadata, which maps directly onto a loop that upserts into Pinecone, Weaviate, pgvector, or Chroma: one page, one (pre-chunking) unit, with the document's own structure already intact rather than an arbitrary character-count split.</p>
<h2>Where OCR fits in</h2>
<p>Not every PDF has a usable text layer: scanned contracts, faxed forms, and image-only exports are common in real document corpora (especially anything from legal, finance, or older archival systems). The Actor exposes an <code>ocrMode</code> input with three settings:</p>
<ul>
<li><code>auto</code>: runs OCR only on pages that have no usable text layer (the sensible default; it doesn't waste OCR cost on pages that already have real text)</li>
<li><code>always</code>: runs OCR on every page, useful if you don't trust the embedded text layer (some PDFs have garbled or wrong text layers from bad scans with a OCR pass baked in)</li>
<li><code>never</code>: skip OCR entirely</li>
</ul>
<p>OCR pages are billed at a higher per-page rate than text-layer pages, which is a fair trade: OCR is more compute-intensive, and <code>auto</code> mode means you're not paying that rate on documents that don't need it. I didn't burn budget re-testing OCR specifically for this post since the pricing and auto-detection logic are documented on the <a href="https://magentawaterwheel.com/pdf-to-markdown">Actor's product page</a>, but the behavior (skip OCR when a text layer exists) matches what I'd expect from checking the <code>ocrPages: 0</code> field on the arXiv test above: a native-text PDF correctly didn't trigger OCR.</p>
<h2>Honest limitations</h2>
<ul>
<li><strong>Table extraction is good, not infallible.</strong> Simple grid tables convert cleanly, as shown above. Deeply nested or merged-cell tables, or tables that are really just visually aligned text without actual table structure in the PDF, can still come out imperfect. Spot-check anything with complex tables before trusting it in production.</li>
<li><strong>This is priced per page, including OCR pages at a higher rate.</strong> For a large backlog (tens of thousands of pages), do the math up front rather than discovering the bill after a bulk run. Failed documents aren't charged, which helps with messy crawls that include corrupt or password-protected files, but successful pages are billed even if the content turns out to be low-value (e.g., a mostly-blank cover page).</li>
<li><strong>It converts structure, it doesn't deduplicate or summarize.</strong> You still own the chunking strategy, embedding choice, and dedup logic downstream; this tool solves "get clean structured text out of a binary file," not the rest of the RAG pipeline.</li>
<li><strong>Max file size and page caps exist</strong> (configurable, defaults around 50MB and 1,000 pages per document) to prevent a single huge file from blowing up a batch job's cost or runtime unexpectedly. Good to set deliberately rather than leave on defaults if your corpus includes unusually large files.</li>
</ul>
<h2>Where this fits in a bigger pipeline</h2>
<p>If you're already running document ingestion as part of a content or competitive-intelligence workflow, this slots in naturally alongside other scraping-style data collection. For instance, if you're pulling competitor whitepapers or decks to analyze alongside search visibility data (something like Magenta Waterwheel's <a href="https://magentawaterwheel.com/ai-overview-tracker">AI Overview tracker</a> for checking whether those same competitors show up in Google's AI answers), the pattern is the same: call an Actor's REST endpoint, get structured JSON back, feed it into whatever you're already building.</p>
<p>For the full input schema, pricing table, and more worked examples, see the <a href="https://magentawaterwheel.com/pdf-to-markdown">PDF to Markdown Converter</a> product page, or the <a href="https://apify.com/magenta_waterwheel/document-to-markdown">Apify Store listing</a> directly if you want to read the raw schema before writing any code.</p>
]]></content:encoded></item><item><title><![CDATA[Curly Keyword Search Volume API shuts down Oct 28 — a practical migration checklist]]></title><description><![CDATA[What breaks, what a drop-in actually needs, and honest options if you used Curly for volume, CPC, and competition]]></description><link>https://magentawaterwheel.hashnode.dev/curly-keyword-search-volume-api-shuts-down-oct-28-a-practical-migration-checklist</link><guid isPermaLink="true">https://magentawaterwheel.hashnode.dev/curly-keyword-search-volume-api-shuts-down-oct-28-a-practical-migration-checklist</guid><category><![CDATA[SEO]]></category><category><![CDATA[Google Keyword Planner]]></category><category><![CDATA[apify]]></category><category><![CDATA[keyword research]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[Magenta Waterwheel]]></dc:creator><pubDate>Mon, 26 Oct 2026 15:06:59 GMT</pubDate><content:encoded><![CDATA[<p>On October 19, 2026, Curly's founder posted on X that three Apify Actors shut down on <strong>October 28, 2026</strong>: the Keyword Search Volume API, the SERP API, and the News API. Other Curly Actors are not part of that cut. The Keyword Search Volume listing on the Apify Store also shows a pricing change to "free" scheduled for the same date, which matches the announcement.</p>
<p>If you wired <code>curly/keyword-search-volume-scraper</code> into a script, cron, webhook, or no-code flow, you have a few days to pick a path that still returns Google search volume after the Actor goes dark. This post is a migration checklist — not a full SEO stack review.</p>
<h2>How Curly's volume API worked</h2>
<p>According to the Apify listing, each run took:</p>
<ul>
<li>One seed <code>keyword</code> (string, up to 200 characters)</li>
<li>Optional <code>language</code> (two-letter code, default <code>en</code>)</li>
<li>Optional <code>region</code> (two-letter code, default <code>US</code>, about 30 markets)</li>
</ul>
<p>You got back a single rich report: average search volume, monthly volume, CPC, competition, SEO difficulty, search intent, clickstream and audience splits, related keyword ideas, questions and comparisons, content ideas, and ranking pages.</p>
<p>Pricing was pay-per-report (on the order of $1.50–$2.00 per 1,000 successful reports depending on plan, plus a small run-start fee). One keyword per run. That made it convenient for "give me everything about this seed" workflows — and awkward if you only needed three fields.</p>
<h2>What actually breaks on Oct 28</h2>
<p>Anything that still POSTs to <code>curly~keyword-search-volume-scraper</code> (or the Store slug <code>curly/keyword-search-volume-scraper</code>) will stop delivering those reports after shutdown. Schedules keep firing; datasets go empty or fail.</p>
<p>The painful part is not "pick any SEO tool." It is matching <strong>input shape</strong> and being honest about <strong>output coverage</strong>.</p>
<h3>Fields that usually have a replacement</h3>
<ul>
<li>Search volume (Keyword Planner average monthly searches)</li>
<li>Monthly volume history</li>
<li>CPC and competition (often with bid ranges)</li>
</ul>
<h3>Fields that usually do not</h3>
<ul>
<li>SEO difficulty</li>
<li>Search intent labels</li>
<li>Related ideas / questions / comparisons generated from the seed</li>
<li>Content ideas and ranking pages</li>
<li>Clickstream / audience splits</li>
</ul>
<p>If your pipeline only logged volume, CPC, and competition into a sheet or warehouse, a Keyword Planner–backed API is enough. If you treated Curly as a mini Ahrefs, you need a second tool for difficulty and ideas.</p>
<h2>Migration checklist (Planner-style Actors)</h2>
<p>Use this when moving to any Google Keyword Planner–shaped Actor or Ads API wrapper:</p>
<ol>
<li><strong>Change <code>keyword</code> → <code>keywords</code> (array).</strong> Even one term becomes <code>["your keyword"]</code>. Batch when you can; many tools charge a per-run lookup fee.</li>
<li><strong>Keep <code>language</code>.</strong> Same two-letter codes (<code>en</code>, <code>de</code>, …).</li>
<li><strong>Change <code>region</code> codes to location names.</strong> <code>US</code> → <code>United States</code>, <code>GB</code> → <code>United Kingdom</code>. Some tools accept cities like <code>London,England,United Kingdom</code>.</li>
<li><strong>Do not assume SEO difficulty or ideas.</strong> If the tool says it is Keyword Planner data, it will not invent difficulty scores or a related-keyword tree the way Curly's report did.</li>
<li><strong>Re-test one sync call</strong> with <code>run-sync-get-dataset-items</code> (or your client) before you flip production schedules.</li>
<li><strong>Update docs and secrets hygiene.</strong> Same rule as before: never put an Apify token in client-side code.</li>
</ol>
<h3>Before / after example</h3>
<p>Curly:</p>
<pre><code class="language-json">{
  "keyword": "content marketing automation",
  "language": "en",
  "region": "US"
}
</code></pre>
<p>Planner-style:</p>
<pre><code class="language-json">{
  "keywords": ["content marketing automation"],
  "language": "en",
  "location": "United States"
}
</code></pre>
<p>Same endpoint pattern on Apify (<code>run-sync-get-dataset-items</code>); different Actor id and input schema.</p>
<h2>Options after Curly (honest comparison)</h2>
<h3>1. Google Keyword Planner (UI or Ads API)</h3>
<p>Best if you already have a Google Ads account and mostly need spot checks or Ads-native workflows. Exact Planner numbers, official source, no third-party Actor. Downsides: account and spend history quirks, API access is heavier than a single Apify POST, and many people used Curly specifically to avoid standing up Ads.</p>
<h3>2. Free or freemium volume checkers</h3>
<p>Fine for a handful of keywords. Usually rate-limited, UI-only, or rounded. Poor fit for scheduled pipelines and large lists.</p>
<h3>3. Full SEO suites (Ahrefs, Semrush, Similarweb, etc.)</h3>
<p>Right answer if you relied on Curly for difficulty, intent, ideas, and SERP context — not only volume. Higher cost; different data model; overkill if you only need Planner metrics.</p>
<h3>4. Apify Store pay-per-event keyword Actors</h3>
<p>Closest operationally if you already lived in Apify schedules and webhooks. You keep the same platform, swap the Actor, and adjust the JSON.</p>
<p>One option in that bucket is Magenta Waterwheel's Keyword Planner API (<code>magenta_waterwheel/keyword-search-volume</code>) and Bulk Keyword Planner (<code>magenta_waterwheel/bulk-keyword-planner</code>). They return Google Keyword Planner volume, monthly history, CPC, bids, and competition for the keywords you send. They do <strong>not</strong> replace SEO difficulty, search intent, or keyword ideas. Input mapping and field table: <a href="https://magentawaterwheel.com/curly-alternative">Curly alternative guide</a>. Free keyword checkers and other Planner alternatives also live on <a href="https://magentawaterwheel.com">magentawaterwheel.com</a> if you want to compare before committing a pipeline.</p>
<p>Price note from that guide (live PPE as of 2026-10-24; always re-check the Store): Keyword Planner API charges per keyword with data plus a per-run lookup fee, so one keyword per run is more expensive than Curly's old per-report model unless you batch. Bulk is a flat fee per batch of up to 1,000 keywords. Keywords with no Google data are free on the per-keyword path.</p>
<h2>What I would do this week</h2>
<ul>
<li>Inventory every call site for <code>curly/keyword-search-volume-scraper</code> (and the SERP/News Actors if you used those).</li>
<li>Decide which fields you actually consume in code or sheets.</li>
<li>If the answer is volume + CPC + competition only: migrate to Keyword Planner (Ads) or a Planner Actor with the array / language / location checklist above.</li>
<li>If you need difficulty or ideas: pick a suite or SERP tool in parallel; do not expect a volume Actor to fake those fields.</li>
<li>Cut over before <strong>October 28, 2026</strong>, then delete or disable the Curly schedules so failed runs do not pile up.</li>
</ul>
<p>That is the whole job: match the fields you use, fix the input shape, and do not pretend a Planner API is a full SEO suite.</p>
<hr />
<p><em>Disclosure: I work on Magenta Waterwheel's keyword tools on Apify. The migration table and pricing above match our public Curly alternative page; re-check Apify Store prices before you budget.</em></p>
]]></content:encoded></item></channel></rss>