<?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[Monie Tools]]></title><description><![CDATA[Monie Tools]]></description><link>https://monietools.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Mon, 26 Oct 2026 20:14:33 GMT</lastBuildDate><atom:link href="https://monietools.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Pull SEC financials and 10-K risk factors into your agent with one call]]></title><description><![CDATA[Standardised income-statement and cash-flow figures, filing lists and 10-K sections (Risk Factors, MD&A) as JSON straight from SEC EDGAR, plus a…]]></description><link>https://monietools.hashnode.dev/pull-sec-financials-and-10-k-risk-factors-into-your-agent-with-one-call</link><guid isPermaLink="true">https://monietools.hashnode.dev/pull-sec-financials-and-10-k-risk-factors-into-your-agent-with-one-call</guid><category><![CDATA[Python]]></category><category><![CDATA[AI]]></category><category><![CDATA[finance]]></category><category><![CDATA[mcp]]></category><dc:creator><![CDATA[Monie Tools]]></dc:creator><pubDate>Tue, 20 Oct 2026 01:57:50 GMT</pubDate><content:encoded><![CDATA[<p>If you've asked an LLM about a company's numbers, you've seen the two ways it fails. It invents figures, or it reads a whole 10-K (easily tens of thousands of tokens) to answer a question that lives in three paragraphs. Both problems have the same fix: give the model <strong>the specific numbers or the specific section</strong> from the primary source, and nothing else.</p>
<p>SEC EDGAR already publishes everything you need for free: XBRL "company facts" for the numbers, plus the filings themselves. The annoying part is the plumbing. Ticker-to-CIK lookup, choosing among competing US-GAAP tags for "revenue", de-duplicating restated periods, and cutting Item 1A out of a 10-K's HTML without grabbing the table of contents.</p>
<p>This post uses a small tool I built that does that plumbing and returns JSON: <a href="https://apify.com/poised_beardlichen/sec-edgar-financials">SEC EDGAR Financials, Filings &amp; 10-K Sections</a> on the Apify Store. If you'd rather self-host, <a href="https://github.com/dgunning/edgartools">EdgarTools</a> is a solid open-source Python library. The patterns below carry over.</p>
<p><strong>Disclosure:</strong> I built this tool. Data comes only from SEC EDGAR's public APIs, and it is not investment advice.</p>
<h2>The five modes</h2>
<table>
<thead>
<tr>
<th><code>mode</code></th>
<th>Returns</th>
<th>Price</th>
</tr>
</thead>
<tbody><tr>
<td><code>overview</code></td>
<td>Profile, industry (SIC), fiscal year end, latest annual figures, recent major filings</td>
<td>$0.003</td>
</tr>
<tr>
<td><code>financials</code></td>
<td><code>periods[]</code> of annual or quarterly figures (up to 20 periods)</td>
<td>$0.004</td>
</tr>
<tr>
<td><code>filings</code></td>
<td>One row per filing, with document links</td>
<td>$0.0005 per filing</td>
</tr>
<tr>
<td><code>section</code></td>
<td>Text of one 10-K/10-Q item, e.g. <code>risk_factors</code>, <code>mdna</code>, <code>business</code>, <code>cybersecurity</code></td>
<td>$0.008</td>
</tr>
<tr>
<td><code>search</code></td>
<td>EDGAR full-text search hits across all filers</td>
<td>$0.0005 per hit</td>
</tr>
</tbody></table>
<p>Unknown companies, missing sections, failed SEC requests and searches with no hits are not charged. The <code>query</code> field takes a ticker (<code>AAPL</code>), a company name (<code>Microsoft</code>) or a CIK.</p>
<h2>Setup</h2>
<pre><code class="language-bash">pip install apify-client
export APIFY_TOKEN=apify_api_xxx   # Apify Console → Settings → API &amp; Integrations
</code></pre>
<h2>One call: five years of standardised financials</h2>
<pre><code class="language-bash">curl -X POST \
  "https://api.apify.com/v2/acts/poised_beardlichen~sec-edgar-financials/run-sync-get-dataset-items" \
  -H "Authorization: Bearer $APIFY_TOKEN" -H "Content-Type: application/json" \
  -d '{"query": "NVDA", "mode": "financials", "period": "annual", "periods": 5}'
</code></pre>
<p>The response is one item per company, with a <code>periods</code> array:</p>
<pre><code class="language-json">{
  "name": "WALMART INC.", "cik": "0000104169", "ticker": "WMT", "period": "annual", "currency": "USD",
  "periods": [{
    "periodEnd": "2026-01-31", "fiscalYear": 2026, "form": "10-K",
    "revenue": 713163000000, "netIncome": 21893000000, "epsDiluted": 2.73,
    "operatingCashFlow": 41565000000, "capitalExpenditure": 26642000000, "freeCashFlow": 14923000000,
    "grossMargin": 0.2493, "netMargin": 0.0307
  }],
  "source": "https://data.sec.gov/api/xbrl/companyfacts/CIK0000104169.json"
}
</code></pre>
<p>(That example is from the tool's README.) Each metric is mapped from the most common US-GAAP tags in priority order, and the latest filing for each period wins, so restatements are picked up. Margins and free cash flow are worked out for you. Every item carries a <code>source</code> URL, so the agent can cite where the numbers came from.</p>
<p>Two caveats worth passing on to your users. Quarterly figures come from 10-Qs, so Q4 isn't reported on its own; derive it from the annual figure. And funds, trusts and some foreign filers have no standardised XBRL financials. They come back with an <code>error</code> field rather than numbers, and aren't charged.</p>
<h2>A small screen across several companies (Python)</h2>
<p><code>queries</code> batches several companies into one run in the same mode:</p>
<pre><code class="language-python">import os
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])

run = client.actor("poised_beardlichen/sec-edgar-financials").call(run_input={
    "queries": ["AAPL", "MSFT", "GOOGL", "AMZN", "META"],
    "mode": "financials",
    "period": "annual",
    "periods": 3,
})

for co in client.dataset(run["defaultDatasetId"]).iterate_items():
    if "error" in co:
        print(co["query"], "-&gt;", co["error"]); continue
    latest = co["periods"][0]
    print(f'{co["ticker"]:6} FY{latest["fiscalYear"]}  '
          f'rev ${latest["revenue"]/1e9:,.1f}B  '
          f'net margin {latest["netMargin"]:.1%}  '
          f'FCF ${(latest.get("freeCashFlow") or 0)/1e9:,.1f}B')
</code></pre>
<p>Five companies in financials mode cost 5 × \(0.004 = \)0.02.</p>
<h2>Just the risk factors, not the whole 10-K</h2>
<pre><code class="language-python">run = client.actor("poised_beardlichen/sec-edgar-financials").call(run_input={
    "query": "TSLA",
    "mode": "section",
    "section": "risk_factors",     # also: mdna, business, market_risk, legal_proceedings, cybersecurity, ...
    "filingForm": "10-K",
})
item = next(client.dataset(run["defaultDatasetId"]).iterate_items())
print(item["form"], item["filingDate"], item["words"], "words")
print(item["documentUrl"])
print(item["text"][:1000])
</code></pre>
<p>You get <code>text</code>, plus <code>chars</code>, <code>words</code>, <code>truncated</code>, <code>accessionNumber</code>, <code>filingDate</code> and <code>documentUrl</code>. The table of contents is skipped automatically. <code>maxSectionChars</code> (default 200,000) caps very long sections, and <code>truncated</code> tells you when that happened. If a company puts the item in an exhibit or uses unusual headings, you get an explanatory <code>error</code> instead, at no charge.</p>
<h2>Year-over-year risk factor diff</h2>
<p>This is the use case I built <code>section</code> mode for: what did management <strong>add</strong> to the risk factors this year? Pull the last two 10-K accession numbers, fetch Item 1A from each, and diff by paragraph:</p>
<pre><code class="language-python">import difflib, os
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])
ACTOR = "poised_beardlichen/sec-edgar-financials"

def run(inp):
    r = client.actor(ACTOR).call(run_input=inp)
    return list(client.dataset(r["defaultDatasetId"]).iterate_items())

ticker = "AAPL"
filings = run({"query": ticker, "mode": "filings", "formTypes": ["10-K"], "limit": 2})
new_acc, old_acc = filings[0]["accessionNumber"], filings[1]["accessionNumber"]

def risk_paragraphs(acc):
    item = run({"query": ticker, "mode": "section", "section": "risk_factors",
                "filingForm": "10-K", "accessionNumber": acc})[0]
    return [p.strip() for p in item["text"].split("\n\n") if len(p.strip()) &gt; 80]

new, old = risk_paragraphs(new_acc), risk_paragraphs(old_acc)
old_set = set(old)

added = [p for p in new if p not in old_set
         and max((difflib.SequenceMatcher(None, p, o).ratio() for o in old), default=0) &lt; 0.8]

print(f"{len(added)} new or substantially rewritten risk paragraphs in {filings[0]['filingDate']}:")
for p in added:
    print("-", p[:300], "...\n")
</code></pre>
<p>The cost is 2 filing rows plus 2 sections: 2 × \(0.0005 + 2 × \)0.008 = $0.017. The script compares every new paragraph against every old one, which is fine for one company. For a large batch, swap in embeddings or TF-IDF. Then hand <strong>only the <code>added</code> paragraphs</strong> to the LLM with a prompt like <em>"Classify each new risk (regulatory, supply chain, AI, litigation, other) and quote the sentence that justifies the class."</em> That's a few thousand tokens instead of two full 10-Ks, and every claim maps back to a paragraph you can show the user.</p>
<h2>Full-text search across all filers</h2>
<pre><code class="language-json">{ "query": "\"going concern\" lithium", "mode": "search", "formTypes": ["10-Q"], "dateFrom": "2026-01-01", "limit": 25 }
</code></pre>
<p>This returns matching filings across every company, including ones you didn't know to ask about. It's useful for thematic research ("who mentioned tariffs in an 8-K this quarter?").</p>
<h2>Using it from an agent (MCP)</h2>
<pre><code class="language-json">{
  "mcpServers": {
    "apify": {
      "url": "https://mcp.apify.com/?tools=poised_beardlichen/sec-edgar-financials",
      "headers": { "Authorization": "Bearer &lt;YOUR_APIFY_TOKEN&gt;" }
    }
  }
}
</code></pre>
<p>Typical calls finish in 1–3 seconds, so results usually come back inline. Prompts that work:</p>
<ul>
<li><em>"Compare NVDA and AMD revenue and net margin over the last 5 fiscal years. Cite the source URL for each company."</em></li>
<li><em>"Get Microsoft's latest 10-K risk factors and list anything about AI regulation, with quotes."</em></li>
<li><em>"Find 8-Ks filed this month that mention 'material weakness'."</em></li>
</ul>
<p>Agents can also pay per call through Apify's agentic payments, without an Apify account, so a hosted agent can use it without you handing out a token.</p>
<p>Rules worth putting in the system prompt:</p>
<ol>
<li><strong>Numbers come from <code>financials</code>, never from prose.</strong> Have the model compute ratios from the returned fields instead of quoting figures from MD&amp;A text.</li>
<li><strong>Always cite</strong> <code>source</code> or <code>documentUrl</code>.</li>
<li><strong>Say "not reported"</strong> when a field is null. Don't fill gaps.</li>
</ol>
<h2>Being a good EDGAR citizen</h2>
<p>The tool sends a declared User-Agent, throttles to half of the SEC's fair-access limit, and caches within each run. If you build your own client instead, do the same. The SEC asks for a descriptive User-Agent with contact details, and caps automated access at 10 requests per second.</p>
<h2>Wrap-up</h2>
<p>For most "ask questions about public companies" agents, three calls cover the bulk of what users ask: <code>financials</code> for numbers, <code>section</code> for the narrative, and <code>filings</code> or <code>search</code> for what changed. Keep the model's context to exactly those pieces and the answers get cheaper and easier to check.</p>
<p>Spotted a wrong number or a section it couldn't find? Open an issue on the tool's Issues tab with the ticker and filing. That's exactly the feedback I need.</p>
]]></content:encoded></item><item><title><![CDATA[Give your AI agent eyes: screenshots, PDFs and web-page checks through MCP]]></title><description><![CDATA[Wire Claude, Cursor or any MCP client to tools that capture clean screenshots, render PDFs, audit page speed and check whether a site looks legitimate…]]></description><link>https://monietools.hashnode.dev/give-your-ai-agent-eyes-screenshots-pdfs-and-web-page-checks-through-mcp</link><guid isPermaLink="true">https://monietools.hashnode.dev/give-your-ai-agent-eyes-screenshots-pdfs-and-web-page-checks-through-mcp</guid><category><![CDATA[mcp]]></category><category><![CDATA[AI]]></category><category><![CDATA[agents]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Monie Tools]]></dc:creator><pubDate>Tue, 20 Oct 2026 01:56:27 GMT</pubDate><content:encoded><![CDATA[<p>Agents are good at reading HTML and bad at knowing what a page <em>looks like</em>. Ask one to "check the pricing page renders properly on mobile" or "save a dated copy of these terms as a PDF", and it either can't do it or it drives a full browser session, which is slow and expensive in tokens.</p>
<p>A smaller pattern works better for a lot of tasks: give the agent a handful of <strong>single-purpose tools</strong>, each taking a URL and returning JSON, at a fixed price per call. This post wires up that toolkit through the <a href="https://docs.apify.com/platform/integrations/mcp">Apify MCP server</a>:</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>What the agent gets</th>
<th>Price</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://apify.com/poised_beardlichen/website-screenshot-pdf-agent"><code>website-screenshot-pdf-agent</code></a></td>
<td>PNG/JPEG/WebP screenshot or PDF, with cookie banners, ads and chat widgets removed</td>
<td>$0.0015 per screenshot, $0.003 per full page, $0.005 per PDF</td>
</tr>
<tr>
<td><a href="https://apify.com/poised_beardlichen/pagespeed-lighthouse-checker"><code>pagespeed-lighthouse-checker</code></a></td>
<td>Lighthouse scores, Core Web Vitals lab metrics, top fixes</td>
<td>$0.05 per audit, or $0.005 with your own Google API key</td>
</tr>
<tr>
<td><a href="https://apify.com/poised_beardlichen/website-legitimacy-check"><code>website-legitimacy-check</code></a></td>
<td>Domain age, TLS, Wayback history, red flags, a 0–100 signal score</td>
<td>$0.008 per site</td>
</tr>
<tr>
<td><a href="https://apify.com/poised_beardlichen/tech-stack-detector"><code>tech-stack-detector</code></a></td>
<td>CMS, frameworks, analytics and hosting detected on the page</td>
<td>$0.0005 per site in fast mode</td>
</tr>
</tbody></table>
<p><strong>Disclosure:</strong> I built and publish these tools. Failed or blocked captures and failed audits are free. Everything below also works through the plain REST API if you don't use MCP.</p>
<h2>1. Connect the tools</h2>
<p>Add one MCP server entry that loads only the tools you want, comma-separated, in the <code>tools</code> parameter. Loading a small, explicit set keeps the agent's tool list short and its behaviour predictable:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "apify": {
      "url": "https://mcp.apify.com/?tools=poised_beardlichen/website-screenshot-pdf-agent,poised_beardlichen/pagespeed-lighthouse-checker,poised_beardlichen/website-legitimacy-check,poised_beardlichen/tech-stack-detector,get-actor-run,get-dataset-items",
      "headers": { "Authorization": "Bearer &lt;YOUR_APIFY_TOKEN&gt;" }
    }
  }
}
</code></pre>
<p>That's the format for Claude Desktop, Cursor and VS Code. If your client supports OAuth, you can drop the <code>headers</code> block and sign in on first use instead. The Apify CLI can also write the config for you (<code>apify mcp install cursor</code>).</p>
<p>I added <code>get-actor-run</code> and <code>get-dataset-items</code> explicitly because of the next section.</p>
<h2>2. The async gotcha (read this before you blame the tool)</h2>
<p>The MCP server waits only about 30–45 seconds for a run to finish. A single default screenshot usually takes 10–20 seconds, so it comes back inline. A full-page PDF of a heavy site, or a Lighthouse audit, can take longer. In that case the tool call returns a <strong>run ID instead of results</strong>.</p>
<p>Agents often read that as a failure and retry, which costs you twice. Put a line like this in your system prompt or rules file:</p>
<blockquote>
<p>If an Apify tool returns a run ID instead of results, call <code>get-actor-run</code> with that ID until the status is SUCCEEDED, then call <code>get-dataset-items</code> with its <code>defaultDatasetId</code>. Do not re-run the tool.</p>
</blockquote>
<p>The same pattern in plain HTTP, for scripts:</p>
<pre><code class="language-bash"># 1. start
RUN=$(curl -s -X POST "https://api.apify.com/v2/acts/poised_beardlichen~website-screenshot-pdf-agent/runs" \
  -H "Authorization: Bearer $APIFY_TOKEN" -H "Content-Type: application/json" \
  -d '{"url": "https://example.com", "format": "pdf"}')
RUN_ID=$(echo "$RUN" | jq -r .data.id); DS=$(echo "$RUN" | jq -r .data.defaultDatasetId)

# 2. wait (repeat until status is SUCCEEDED)
curl -s "https://api.apify.com/v2/actor-runs/$RUN_ID?waitForFinish=60" \
  -H "Authorization: Bearer $APIFY_TOKEN" | jq -r .data.status

# 3. fetch
curl -s "https://api.apify.com/v2/datasets/$DS/items?clean=true" -H "Authorization: Bearer $APIFY_TOKEN"
</code></pre>
<h2>3. Screenshots and PDFs the agent can actually use</h2>
<p>The capture tool returns one item per URL and device, with a public <code>screenshotUrl</code> that the agent can hand to a vision model, attach to a ticket or store:</p>
<pre><code class="language-json">{
  "url": "https://www.bbc.com",
  "device": "mobile",
  "format": "jpeg",
  "fullPage": true,
  "success": true,
  "finalUrl": "https://www.bbc.com/",
  "status": 200,
  "screenshotUrl": "https://api.apify.com/v2/key-value-stores/&lt;storeId&gt;/records/screenshot-...jpg",
  "width": 1170,
  "height": 24180,
  "error": null
}
</code></pre>
<p>Prompts that work well:</p>
<ul>
<li><em>"Take a full-page mobile screenshot of <a href="https://example.com/pricing">https://example.com/pricing</a> and tell me whether the plan table is readable without horizontal scrolling."</em></li>
<li><em>"Save <a href="https://example.com/terms">https://example.com/terms</a> as an A4 PDF and give me the link. I need a dated copy."</em></li>
<li><em>"Screenshot only the <code>#checkout</code> element on desktop and on mobile."</em> (the tool takes a CSS <code>selector</code>)</li>
</ul>
<p>What the tool does for you that a raw headless browser doesn't:</p>
<ul>
<li><strong>Cookie banners and consent pop-ups hidden</strong> (OneTrust, Cookiebot, Didomi, Quantcast, Usercentrics and more), plus ads, trackers and chat bubbles. That matters because a consent wall covering 40% of the page wrecks vision-model answers.</li>
<li><strong>Lazy-loaded images</strong> handled by auto-scrolling before a full-page capture.</li>
<li><strong>Blocked pages flagged.</strong> A captcha or 403 comes back as <code>success: false</code> or <code>blocked: true</code> instead of a picture of the block page, and it isn't charged.</li>
</ul>
<p>Limits, so your agent doesn't promise too much: it captures public pages only. It doesn't bypass logins or paywalls, and some heavily protected sites will still refuse (those are flagged, not billed). Very long pages are cut at 16,000 px by default.</p>
<p>There are two versions of the screenshot tool. <code>website-screenshot-pdf-agent</code> is the lightweight one for agent calls (<code>url</code> in, item out). <a href="https://apify.com/poised_beardlichen/website-screenshot-pdf"><code>website-screenshot-pdf</code></a> is the bulk version: thousands of URLs per run, schedules, and a real-time Standby HTTP endpoint. Prices are the same.</p>
<h2>4. Page speed and Core Web Vitals on request</h2>
<pre><code class="language-json">{ "urls": ["https://example.com", "https://example.com/blog"], "strategies": ["mobile", "desktop"] }
</code></pre>
<p>Each result has <code>performance</code>, <code>accessibility</code>, <code>bestPractices</code> and <code>seo</code> scores, <code>lab</code> metrics (<code>lcpMs</code>, <code>cls</code>, <code>tbtMs</code>, <code>fcpMs</code>, <code>ttfbMs</code>), and <code>opportunities</code> ranked by estimated savings ("Reduce unused JavaScript, est. savings 1,359 KiB").</p>
<p>Two cost notes the agent should know:</p>
<ul>
<li>Calls that leave out <code>strategies</code> audit <strong>mobile only</strong>, which is one audit and the fastest option.</li>
<li>With your own free Google PageSpeed Insights API key (<code>apiKey</code>), an audit costs $0.005 instead of $0.05, and you also get real-user CrUX field data (p75 LCP, INP, CLS) when Google has it for the page.</li>
</ul>
<p>Good prompt: <em>"Audit our five landing pages on mobile and list the top three fixes that appear on more than one page."</em></p>
<h2>5. "Should I trust this site?" before the agent clicks, pays or emails</h2>
<p>Agents follow links far more obediently than people do. A cheap pre-check before any irreversible action helps:</p>
<pre><code class="language-python">from apify_client import ApifyClient
client = ApifyClient("&lt;YOUR_APIFY_TOKEN&gt;")

run = client.actor("poised_beardlichen/website-legitimacy-check").call(run_input={"url": "example-shop.com"})
r = next(client.dataset(run["defaultDatasetId"]).iterate_items())
print(r["signalScore"], r["signalLevel"], r["redFlags"])
</code></pre>
<p>The output includes <code>domain.ageDays</code>, registrar, <code>tls.httpsValid</code>, Wayback history, parked-or-for-sale detection, lookalike-domain hints, and lists of <code>positiveSignals</code> and <code>redFlags</code>. <code>signalLevel</code> is one of <code>many_positive_signals</code>, <code>mostly_positive</code>, <code>mixed</code> or <code>several_red_flags</code>.</p>
<p>Be clear with your agent (and your users) about what this is: <strong>automated signals, not a verdict</strong>. A brand-new domain isn't proof of a scam, and an old domain can be compromised. I use it as a gate. Below a threshold, the agent stops and asks a human.</p>
<h2>6. What's this site built on?</h2>
<pre><code class="language-json">{ "urls": ["https://wordpress.org", "https://www.shopify.com"], "mode": "fast" }
</code></pre>
<p>The output includes <code>technologyNames</code>, <code>byCategory</code> (CMS, analytics, web servers and more) and per-technology <code>confidence</code> and <code>detectedVia</code>. Fast mode ($0.0005 per site) reads the HTML and headers. Browser mode ($0.005 per site) renders the page, which catches tools that only appear after JavaScript runs. It's handy for agents doing lead research ("which of these 200 prospects run Shopify?") or for audits before a migration.</p>
<h2>Keeping an agent's spend predictable</h2>
<ul>
<li><strong>Fixed prices per item</strong> let the agent estimate cost before it calls: 40 screenshots cost $0.06.</li>
<li><strong>Set a maximum cost per run</strong> (Console or run options). The run stops cleanly at the limit.</li>
<li><strong>Load only the tools you need</strong> in the <code>tools</code> parameter. Fewer tools means fewer wrong calls.</li>
</ul>
<h2>Wrap-up</h2>
<p>You don't always need a browser-driving agent. For "show me", "save this", "how fast is it" and "can I trust it", one call per URL with a fixed price is faster, cheaper and easier to audit afterwards. If a cookie banner gets through or a page renders wrong, open an issue on the tool's Issues tab with the URL and I'll look at it.</p>
]]></content:encoded></item><item><title><![CDATA[PDFs to page-cited Markdown for RAG, for about $0.006 per 10-page document]]></title><description><![CDATA[A small, code-first ingestion pipeline: PDF/DOCX/scans in, one Markdown chunk per page out, ready to embed and cite.]]></description><link>https://monietools.hashnode.dev/pdfs-to-page-cited-markdown-for-rag-for-about-0-006-per-10-page-document</link><guid isPermaLink="true">https://monietools.hashnode.dev/pdfs-to-page-cited-markdown-for-rag-for-about-0-006-per-10-page-document</guid><category><![CDATA[RAG ]]></category><category><![CDATA[Python]]></category><category><![CDATA[llm]]></category><category><![CDATA[OCR ]]></category><dc:creator><![CDATA[Monie Tools]]></dc:creator><pubDate>Mon, 19 Oct 2026 11:36:03 GMT</pubDate><content:encoded><![CDATA[<p>Most bad RAG answers over PDFs go wrong at extraction, before embeddings come into it. Two-column papers get read straight across the page, scanned pages come back empty, and the running header "ANNUAL REPORT 2025 – CONFIDENTIAL" lands in every chunk. By the time the retriever runs, the damage is already done.</p>
<p>This tutorial builds a small ingestion step that avoids those problems and keeps page numbers so answers can cite them. It uses a hosted converter I built and publish on the Apify Store (<a href="https://apify.com/poised_beardlichen/pdf-to-text-markdown-ocr">PDF to Text &amp; Markdown with OCR</a>). The pipeline itself (route by page type, chunk per page, keep provenance) works with any parser, so swap in Docling, Marker or PyMuPDF if you'd rather self-host.</p>
<p><strong>Disclosure:</strong> I built the converter used here. The prices below come from its public pricing page. I have not included benchmarks; test it on your own worst documents.</p>
<h2>What we want from the extraction step</h2>
<p>For RAG, a good extractor should:</p>
<ol>
<li><p><strong>Read in visual order.</strong> Multi-column layouts should not interleave.</p>
</li>
<li><p><strong>OCR only the pages that need it.</strong> Pages with a real text layer should be read directly, which is fast and accurate. Scanned or garbled pages should go through OCR.</p>
</li>
<li><p><strong>Drop the noise.</strong> Repeating headers, footers and page numbers should go, and hyphenated line breaks should be joined.</p>
</li>
<li><p><strong>Keep the structure.</strong> Headings should become <code>#</code>/<code>##</code>, so you have natural chunk boundaries.</p>
</li>
<li><p><strong>Keep provenance.</strong> Every chunk should keep its source URL and page number.</p>
</li>
</ol>
<p>The converter does 1–5 and returns one JSON item per document or per page. One honest limitation up front: table text comes out in reading order, but <strong>tables are not rebuilt as Markdown tables</strong>. If your corpus is mostly financial tables, test that first.</p>
<h2>What it costs</h2>
<p>Pricing is pay-per-event, with no subscription and no start fee:</p>
<table>
<thead>
<tr>
<th>Event</th>
<th>Price</th>
</tr>
</thead>
<tbody><tr>
<td>Document converted</td>
<td>$0.003</td>
</tr>
<tr>
<td>Text page (from the PDF's own text layer), <strong>first 30 per document only</strong></td>
<td>$0.0003</td>
</tr>
<tr>
<td>OCR page (scanned page that produced text)</td>
<td>$0.005</td>
</tr>
</tbody></table>
<p>Worked examples from the pricing page:</p>
<ul>
<li><p>10-page digital PDF: <strong>$0.006</strong></p>
</li>
<li><p>10-page scanned PDF: <strong>$0.053</strong></p>
</li>
<li><p>500-page digital PDF: <strong>$0.012</strong> (text pages are capped at 30 per document)</p>
</li>
</ul>
<p>So 1,000 digital pages cost \(0.30 in page fees plus the per-document fee. For 100 ten-page PDFs that comes to about \)0.60 in total. Scans cost more ($5 per 1,000 OCR pages), because OCR takes real compute. Broken links, HTML pages and failed documents are free, and blank pages are never billed as OCR pages.</p>
<h2>Setup</h2>
<p>You need an Apify account and an API token (Console → Settings → API &amp; Integrations).</p>
<pre><code class="language-bash">pip install apify-client
export APIFY_TOKEN=apify_api_xxx
</code></pre>
<h2>Step 1: one call, one document (curl)</h2>
<p>The synchronous endpoint runs the converter and returns the dataset items in the response:</p>
<pre><code class="language-bash">curl -X POST \
  "https://api.apify.com/v2/acts/poised_beardlichen~pdf-to-text-markdown-ocr/run-sync-get-dataset-items" \
  -H "Authorization: Bearer $APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"urls": ["https://arxiv.org/pdf/1706.03762"], "outputFormat": "markdown"}'
</code></pre>
<p>You get back one item per document, with <code>markdown</code>, <code>pageCount</code>, <code>textPages</code>, <code>ocrPages</code>, <code>words</code> and a <code>metadata</code> object (title, author, producer, PDF version and so on). The sync endpoint waits up to 300 seconds, which is fine for a handful of files. For batches, use the async pattern below.</p>
<h2>Step 2: page mode, so every chunk can cite a page</h2>
<p>For RAG, switch on <code>"outputMode": "page"</code>. Each page becomes its own item with <code>url</code>, <code>page</code>, <code>ocr</code> (true/false), <code>ocrConfidence</code> and <code>markdown</code>:</p>
<pre><code class="language-python">import os, json
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])

run_input = {
    "urls": [
        "https://arxiv.org/pdf/1706.03762",
        # Google Drive and Dropbox share links work too
    ],
    "outputMode": "page",          # one item per page
    "outputFormat": "markdown",
    "ocrMode": "auto",             # OCR only pages without a usable text layer
    "ocrLanguages": ["eng"],       # 27 languages available, e.g. ["eng", "deu"]
    "removeHeadersFooters": True,
}

# .call() starts the run and waits for it to finish
run = client.actor("poised_beardlichen/pdf-to-text-markdown-ocr").call(run_input=run_input)

chunks = []
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    if not item.get("success") or not item.get("markdown"):
        continue
    chunks.append({
        "id": f'{item["url"]}#page={item["page"]}',
        "text": item["markdown"],
        "metadata": {
            "source": item["url"],
            "page": item["page"],
            "ocr": item["ocr"],
            "ocr_confidence": item.get("ocrConfidence"),
        },
    })

with open("chunks.jsonl", "w") as f:
    for c in chunks:
        f.write(json.dumps(c) + "\n")

print(f"{len(chunks)} page chunks written")
</code></pre>
<p><code>chunks.jsonl</code> now holds one record per page with a stable ID such as <code>https://arxiv.org/pdf/1706.03762#page=3</code>. Feed it to whatever vector store you use. A page is usually a sensible chunk size for reports and papers. If your pages are long, split each page on its <code>##</code> headings and keep the <code>page</code> metadata on every sub-chunk.</p>
<h3>Route low-confidence OCR to a human</h3>
<p>Because every page reports <code>ocr</code> and <code>ocrConfidence</code>, you can quarantine bad scans instead of silently indexing them. Do the split before you embed anything, so suspect pages go to a review file and never reach <code>chunks.jsonl</code>:</p>
<pre><code class="language-python">def low_confidence(c, threshold=70):
    return c["metadata"]["ocr"] and (c["metadata"]["ocr_confidence"] or 0) &lt; threshold

needs_review = [c for c in chunks if low_confidence(c)]
clean = [c for c in chunks if not low_confidence(c)]

# Overwrite chunks.jsonl with only the pages that are safe to embed
with open("chunks.jsonl", "w") as f:
    for c in clean:
        f.write(json.dumps(c) + "\n")

# Park the suspect pages for a human to check (or re-run them with a different language setting)
with open("needs_review.jsonl", "w") as f:
    for c in needs_review:
        f.write(json.dumps(c) + "\n")

print(f"{len(clean)} pages ready to embed, {len(needs_review)} sent to review")
</code></pre>
<p>The threshold of 70 is just a starting point. Look at a few of your own pages and pick a value.</p>
<h2>Step 3: batches without blocking (async pattern)</h2>
<p>Text PDFs usually finish quickly. Long scans take roughly 1–3 seconds per OCR page. For big batches, start the run, poll it, then fetch the results:</p>
<pre><code class="language-python">import time

run = client.actor("poised_beardlichen/pdf-to-text-markdown-ocr").start(run_input={
    "urls": open("pdf_urls.txt").read().split(),
    "outputMode": "page",
    "outputFormat": "markdown",
    "maxConcurrency": 4,
})

while True:
    info = client.run(run["id"]).wait_for_finish(wait_secs=60)
    if info["status"] in ("SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT"):
        break

items = client.dataset(run["defaultDatasetId"]).list_items(clean=True).items
print(info["status"], len(items), "page items")
</code></pre>
<p>Two settings worth knowing:</p>
<ul>
<li><p><strong>Maximum cost per run.</strong> Set it in the run options (or the Console) and the run stops cleanly at your limit, so a 5,000-URL batch can't surprise you.</p>
</li>
<li><p><code>maxPagesPerDocument</code><strong>.</strong> Use it to sample huge files cheaply before committing to a full run.</p>
</li>
</ul>
<h2>The same thing in JavaScript</h2>
<pre><code class="language-js">import { ApifyClient } from 'apify-client';

const client = new ApifyClient({ token: process.env.APIFY_TOKEN });

const run = await client.actor('poised_beardlichen/pdf-to-text-markdown-ocr').call({
  urls: ['https://arxiv.org/pdf/1706.03762'],
  outputMode: 'page',
  outputFormat: 'markdown',
});

const { items } = await client.dataset(run.defaultDatasetId).listItems();
const chunks = items
  .filter((it) =&gt; it.success &amp;&amp; it.markdown)
  .map((it) =&gt; ({ id: `${it.url}#page=${it.page}`, text: it.markdown, page: it.page }));

console.log(chunks.length, 'chunks');
</code></pre>
<h2>Letting an agent read PDFs directly (MCP)</h2>
<p>If you'd rather have Claude, Cursor or another MCP client fetch documents on demand, add the converter through the <a href="https://mcp.apify.com">Apify MCP server</a>:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "apify": {
      "url": "https://mcp.apify.com/?tools=poised_beardlichen/pdf-to-text-markdown-ocr",
      "headers": { "Authorization": "Bearer &lt;YOUR_APIFY_TOKEN&gt;" }
    }
  }
}
</code></pre>
<p>Then ask something like: <em>"Read <a href="https://arxiv.org/pdf/1706.03762">https://arxiv.org/pdf/1706.03762</a> and summarise section 3 with page references."</em></p>
<p>One practical detail: the MCP server waits only about 30–45 seconds for a run. For a long scanned PDF the tool call can return a run ID instead of results. The agent then uses <code>get-actor-run</code> and <code>get-dataset-items</code> with that ID. Tell your agent this in its instructions, or it may assume the call failed.</p>
<h2>When not to use this</h2>
<ul>
<li><p><strong>You need table structure.</strong> Tables come out as text in reading order and are not rebuilt as Markdown tables. A table-focused parser or a vision model is a better fit there.</p>
</li>
<li><p><strong>Your documents can't leave your network.</strong> This is a hosted service. Files are downloaded into the run's temporary container and deleted after conversion, and results live in your own Apify storage. If that still isn't acceptable, run Docling or Marker locally. The pipeline above stays the same.</p>
</li>
<li><p><strong>The PDFs are password-protected to open.</strong> Those are reported as errors and not charged. PDFs that only have owner restrictions work.</p>
</li>
</ul>
<h2>Recap</h2>
<ul>
<li><p>Extract per page and keep <code>url</code> and <code>page</code> on every chunk, so answers can cite pages.</p>
</li>
<li><p>OCR only the pages that need it, and quarantine pages with low OCR confidence.</p>
</li>
<li><p>Strip repeating headers and footers before embedding.</p>
</li>
<li><p>Cap your spend per run, and sample big files first.</p>
</li>
</ul>
<p>If you try it on a document that converts badly, open an issue on the Actor's Issues tab with the URL. That's the fastest way to get it fixed.</p>
]]></content:encoded></item></channel></rss>