<?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[flyingkiwi]]></title><description><![CDATA[flyingkiwi]]></description><link>https://flyingkiwi.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Tue, 03 Nov 2026 02:32:25 GMT</lastBuildDate><atom:link href="https://flyingkiwi.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How to scrape Greenhouse and Lever job postings (public JSON APIs, no HTML scraping)]]></title><description><![CDATA[Pull every open role from the public Greenhouse and Lever APIs, add salaries, export to CSV and detect new jobs. Tested Python.]]></description><link>https://flyingkiwi.hashnode.dev/scrape-greenhouse-lever-job-postings</link><guid isPermaLink="true">https://flyingkiwi.hashnode.dev/scrape-greenhouse-lever-job-postings</guid><category><![CDATA[web scraping]]></category><category><![CDATA[Python]]></category><category><![CDATA[api]]></category><category><![CDATA[jobs]]></category><category><![CDATA[automation]]></category><dc:creator><![CDATA[flyingkiwi]]></dc:creator><pubDate>Sun, 01 Nov 2026 20:55:03 GMT</pubDate><content:encoded><![CDATA[<p>Greenhouse and Lever host the careers pages of thousands of companies, from startups to Airbnb. If you want those job postings in a spreadsheet, a database or a job-alert bot, the good news is that you usually don't need to scrape any HTML. Both platforms publish a public, documented JSON feed for every company's job board, with no login and no API key.</p>
<p>This guide shows how to find a company's board, pull every open job from Greenhouse and Lever, get descriptions and salaries, filter, export to CSV and detect new postings between runs. All the code was run on 2 November 2026 (NZ time) against Airbnb (Greenhouse) and Zoox (Lever).</p>
<h2>Step 1: Find the company's board name (the "slug")</h2>
<p>Every request needs the company's board name. You can usually read it straight from a job link:</p>
<table>
<thead>
<tr>
<th>You see a link like</th>
<th>ATS</th>
<th>Slug</th>
</tr>
</thead>
<tbody><tr>
<td><code>boards.greenhouse.io/airbnb</code> or <code>job-boards.greenhouse.io/airbnb</code></td>
<td>Greenhouse</td>
<td><code>airbnb</code></td>
</tr>
<tr>
<td>a careers page with <code>?gh_jid=8316130</code> in job URLs</td>
<td>Greenhouse</td>
<td>look for <code>boards.greenhouse.io/embed/job_board?for=&lt;slug&gt;</code> in the page source</td>
</tr>
<tr>
<td><code>jobs.lever.co/zoox</code></td>
<td>Lever</td>
<td><code>zoox</code></td>
</tr>
<tr>
<td><code>jobs.eu.lever.co/&lt;slug&gt;</code></td>
<td>Lever (EU data centre)</td>
<td><code>&lt;slug&gt;</code>, and use <code>api.eu.lever.co</code> below</td>
</tr>
</tbody></table>
<p>Companies with their own branded careers site (Airbnb's is careers.airbnb.com) often still run on Greenhouse behind the scenes. The <code>gh_jid</code> parameter in a job URL is the giveaway.</p>
<h2>Step 2: Pull every job from Greenhouse</h2>
<p>The <a href="https://developers.greenhouse.io/job-board.html">Greenhouse Job Board API</a> says it plainly: "Job Board data is publicly available, so authentication is not required for any GET endpoints."</p>
<pre><code>GET https://boards-api.greenhouse.io/v1/boards/{slug}/jobs?content=true
</code></pre>
<p>One request returns the whole board, with no pagination. Airbnb's had 202 jobs, and <code>meta.total</code> matched. Without <code>content=true</code> you get titles, locations, dates and URLs (155 KB for Airbnb). With it you also get <code>departments</code>, <code>offices</code> and the full description (2.9 MB), so turn it on only if you need those fields.</p>
<p>Two details to know:</p>
<ul>
<li>The description in <code>content</code> is <strong>HTML-escaped HTML</strong> (<code>&amp;lt;p&amp;gt;</code> rather than <code>&lt;p&gt;</code>). Unescape it before you strip tags or convert it.</li>
<li>There are two dates: <code>first_published</code> (when the post went live) and <code>updated_at</code> (any edit). Use <code>first_published</code> for "posted in the last N days". For Airbnb's engineering roles, <code>updated_at</code> made 34 look recent, against 20 by <code>first_published</code>.</li>
</ul>
<h2>Step 3: Pull every job from Lever</h2>
<p>Lever's <a href="https://github.com/lever/postings-api">postings API</a> is just as open:</p>
<pre><code>GET https://api.lever.co/v0/postings/{slug}?mode=json
</code></pre>
<p>With no <code>limit</code>, it returned all 227 of Zoox's jobs in one response. It also supports <code>skip</code> and <code>limit</code> for paging, and server-side filters for <code>location</code>, <code>commitment</code>, <code>team</code> and <code>department</code>. A slug that doesn't exist returns 404. A board that exists but has nothing open returns an empty list <code>[]</code>, which is worth telling apart in your logs.</p>
<p>Differences from Greenhouse:</p>
<ul>
<li><code>createdAt</code> is a Unix timestamp in <strong>milliseconds</strong>.</li>
<li>Department, team, location and commitment sit inside <code>categories</code>. <code>categories.allLocations</code> lists every location for multi-location roles.</li>
<li><code>workplaceType</code> is <code>onsite</code>, <code>hybrid</code> or <code>remote</code>.</li>
<li>Descriptions come in both HTML (<code>description</code>) and plain text (<code>descriptionPlain</code>).</li>
<li><code>salaryRange</code> (<code>min</code>, <code>max</code>, <code>currency</code>, <code>interval</code>) appears when the company publishes pay. 211 of Zoox's 227 jobs had it. Spotify's board had none.</li>
</ul>
<h2>Step 4: One script for both</h2>
<p>Here's a small script that normalises both feeds into the same row format. It needs only <code>requests</code>:</p>
<pre><code class="language-python"># pip install requests
import html, re
from datetime import datetime, timedelta, timezone
import requests

S = requests.Session()
S.headers["User-Agent"] = "jobs-research-script (you@example.com)"

def strip_html(s):
    return re.sub(r"\s+", " ", re.sub(r"&lt;[^&gt;]+&gt;", " ", html.unescape(s or ""))).strip()

def greenhouse_jobs(slug, with_description=False):
    # content=true adds departments, offices and the description (about 20x bigger response)
    r = S.get(f"https://boards-api.greenhouse.io/v1/boards/{slug}/jobs", params={"content": "true"}, timeout=30)
    r.raise_for_status()  # 404 = no public board under that slug
    for j in r.json()["jobs"]:
        yield {
            "ats": "greenhouse", "company": j.get("company_name") or slug, "id": str(j["id"]),
            "title": j["title"],
            "department": (j.get("departments") or [{}])[0].get("name"),
            "location": (j.get("location") or {}).get("name"),
            "posted": datetime.fromisoformat(j["first_published"]) if j.get("first_published") else None,
            "url": j["absolute_url"],
            "description": strip_html(j.get("content")) if with_description else None,
        }

def lever_jobs(slug, with_description=False):
    r = S.get(f"https://api.lever.co/v0/postings/{slug}", params={"mode": "json"}, timeout=30)
    r.raise_for_status()  # 404 = unknown slug; [] = board exists but nothing open
    for j in r.json():
        cat, pay = j.get("categories") or {}, j.get("salaryRange") or {}
        yield {
            "ats": "lever", "company": slug, "id": j["id"],
            "title": j["text"],
            "department": cat.get("department"),
            "location": cat.get("location"),
            "workplace": j.get("workplaceType"),
            "posted": datetime.fromtimestamp(j["createdAt"] / 1000, tz=timezone.utc),
            "url": j["hostedUrl"],
            "salary": (pay.get("min"), pay.get("max"), pay.get("currency"), pay.get("interval")) if pay else None,
            "description": j.get("descriptionPlain") if with_description else None,
        }

cutoff = datetime.now(timezone.utc) - timedelta(days=30)
jobs = [*greenhouse_jobs("airbnb"), *lever_jobs("zoox")]
recent_eng = [j for j in jobs if "engineer" in j["title"].lower() and j["posted"] and j["posted"] &gt;= cutoff]
print(len(jobs), "open roles;", len(recent_eng), "engineering roles posted in the last 30 days")
</code></pre>
<p>Output: <code>429 open roles; 49 engineering roles posted in the last 30 days</code> (20 at Airbnb, 29 at Zoox). Greenhouse includes a <code>company_name</code> field. Lever doesn't, so the slug stands in for the name.</p>
<h2>Step 5: Get salaries from Greenhouse</h2>
<p>Greenhouse doesn't put pay in the job list. It's on the single-job endpoint, behind a <code>pay_transparency</code> flag:</p>
<pre><code class="language-python">def greenhouse_pay(slug, job_id):
    r = S.get(f"https://boards-api.greenhouse.io/v1/boards/{slug}/jobs/{job_id}",
              params={"pay_transparency": "true"}, timeout=30)
    r.raise_for_status()
    return [(p["min_cents"] / 100, p["max_cents"] / 100, p["currency_type"], p["title"])
            for p in r.json().get("pay_input_ranges") or []]

print(greenhouse_pay("airbnb", 8316130))  # [(212000.0, 265000.0, 'USD', 'Pay Range')]
</code></pre>
<p>Amounts are in cents, and a job can have several ranges (for example one per country). It's one request per job, so only call it for the jobs you keep. 13 of the 20 recent Airbnb engineering roles had a published range.</p>
<h2>Step 6: Export to CSV and spot new jobs</h2>
<p>The usual next step is "tell me what's new since yesterday". Store the job IDs from each run and compare:</p>
<pre><code class="language-python">import csv, json, pathlib

STATE = pathlib.Path("seen_jobs.json")
seen = json.loads(STATE.read_text()) if STATE.exists() else {}

current = {f'{j["ats"]}:{j["company"]}:{j["id"]}': j for j in jobs}
new = [j for key, j in current.items() if key not in seen]
removed = [seen[key] for key in seen if key not in current]
print(len(new), "new,", len(removed), "removed since last run")

STATE.write_text(json.dumps({k: {"title": j["title"], "url": j["url"]} for k, j in current.items()}))

with open("jobs.csv", "w", newline="") as f:
    w = csv.DictWriter(f, fieldnames=["ats", "company", "title", "department", "location", "posted", "url"],
                       extrasaction="ignore")
    w.writeheader()
    w.writerows(jobs)
</code></pre>
<p>The first run reports everything as new (429 here), so treat it as a baseline. Run it daily with cron or a GitHub Actions schedule, and send <code>new</code> to Slack or email.</p>
<h2>Be a good citizen</h2>
<p>These feeds exist so companies can show their jobs on their own sites and in job aggregators, and reading them is the intended use. Still:</p>
<ul>
<li><strong>Keep the rate low.</strong> One request per board per run is all you need for the job list. Add a short pause between boards if you watch hundreds.</li>
<li><strong>Set a real User-Agent</strong> with a contact address, as in the script.</li>
<li><strong>Cache descriptions.</strong> They rarely change. Fetch them once per job, not on every run.</li>
<li><strong>Don't collect personal data.</strong> Some descriptions include a recruiter's name or email. Strip them if you republish.</li>
</ul>
<h2>Where the DIY script runs out</h2>
<p>The script above is enough for a handful of companies on Greenhouse and Lever. It gets harder when:</p>
<ul>
<li>your list includes <strong>Ashby, Workable, SmartRecruiters, Recruitee or Personio</strong>, each with its own format;</li>
<li>you have <strong>company names, not slugs</strong>, and need to work out which ATS each one uses;</li>
<li>you want <strong>per-company summaries</strong> (open roles by department, location and remote/hybrid/onsite) and new/removed tracking without running a server.</li>
</ul>
<p>We built <strong>ATS Jobs &amp; Hiring Signals</strong> on Apify for that. It reads the same public feeds for seven ATSs (including Lever's EU data centre) and returns one row format plus a free summary row per company. Here's the same query as Step 4:</p>
<pre><code class="language-python"># pip install apify-client
from apify_client import ApifyClient

client = ApifyClient("&lt;YOUR_APIFY_TOKEN&gt;")
run = client.actor("flyingkiwi/ats-jobs-hiring-signals").call(run_input={
    "companies": ["https://boards.greenhouse.io/airbnb", "https://jobs.lever.co/zoox"],
    "titleKeywords": ["engineer"],
    "postedWithinDays": 30,
})
rows = list(client.dataset(run.default_dataset_id).iterate_items())
jobs = [r for r in rows if r["type"] == "job"]
summaries = [r for r in rows if r["type"] == "company"]
</code></pre>
<p>(That's apify-client 3.x, where <code>call()</code> returns a <code>Run</code> object. With 1.x or 2.x, use <code>run["defaultDatasetId"]</code>.)</p>
<p>The run took about 4.5 seconds and returned the same 49 jobs as the DIY script (20 Airbnb, 29 Zoox), plus two summary rows. Airbnb's summary counted 202 open roles, 92 posted in the last 30 days, and 155 remote, 37 hybrid and 10 onsite. Zoox's counted 227 roles, 100 of them in Software. Set <code>"monitorMode": true</code> and schedule it to get only new jobs on each run.</p>
<p>Honest gaps:</p>
<ul>
<li><strong>Greenhouse salaries aren't included yet.</strong> The Actor reads Lever's <code>salaryRange</code> (27 of the 29 Zoox rows had pay) but doesn't make the per-job <code>pay_transparency</code> call from Step 5, so the Airbnb rows had none. If you need Greenhouse pay, use that snippet.</li>
<li><strong>Workday, iCIMS, LinkedIn and Indeed aren't covered.</strong> Many large enterprises use Workday.</li>
<li><strong>Lever names come from the slug</strong> (<code>zoox</code> becomes "Zoox"). Set <code>companyNames</code> if a slug doesn't match the real name.</li>
</ul>
<p>It costs $0.005 per run plus $0.001 per job returned, and summary rows are free, so this run was about $0.054. The DIY script is free. Pick whichever costs you less time.</p>
<p><em>We (flyingkiwi) built and maintain ATS Jobs &amp; Hiring Signals. The numbers in this post come from runs on 2 November 2026 (NZ time).</em></p>
<p>Tool: <a href="https://apify.com/flyingkiwi/ats-jobs-hiring-signals">https://apify.com/flyingkiwi/ats-jobs-hiring-signals</a></p>
]]></content:encoded></item><item><title><![CDATA[How to convert PDFs to Markdown for RAG (with page numbers, clean chunks and a missing-page check)]]></title><description><![CDATA[A tested, free Python pipeline: PyMuPDF4LLM, heading-aware chunks with page numbers, PDF clean-up and a scanned-page check.]]></description><link>https://flyingkiwi.hashnode.dev/convert-pdf-to-markdown-for-rag</link><guid isPermaLink="true">https://flyingkiwi.hashnode.dev/convert-pdf-to-markdown-for-rag</guid><category><![CDATA[RAG ]]></category><category><![CDATA[Python]]></category><category><![CDATA[pdf]]></category><category><![CDATA[markdown]]></category><category><![CDATA[llm]]></category><dc:creator><![CDATA[flyingkiwi]]></dc:creator><pubDate>Sun, 01 Nov 2026 20:53:51 GMT</pubDate><content:encoded><![CDATA[<p>If you're building retrieval-augmented generation (RAG) over PDFs, the conversion step decides a lot more than people expect. Retrieval can only find what made it out of the PDF. Answers can only cite a page if you kept the page number. And a scanned page that converts to nothing will never show up as a gap: it just silently isn't there.</p>
<p>This guide converts a real PDF to Markdown, splits it into chunks that keep their heading and page number, cleans up the usual PDF debris and checks for missing pages. Everything uses free, open-source tools. At the end there's a hosted option for when you'd rather call an API.</p>
<p>The test file throughout is the original RAG paper, <a href="https://arxiv.org/abs/2005.11401">Lewis et al. 2020</a> (19 pages, arXiv PDF).</p>
<h2>Why Markdown, and not plain text?</h2>
<p>Plain-text extraction gives you one long string. Markdown keeps the structure that makes chunking and retrieval work:</p>
<ul>
<li><strong>Headings</strong> (<code>## 2 Methods</code>, <code>### 2.1 Models</code>) give you natural chunk boundaries, and a heading is useful metadata to embed alongside the chunk.</li>
<li><strong>Tables</strong> stay as rows and columns instead of a jumble of numbers.</li>
<li><strong>Lists</strong> stay as lists, so a step-by-step procedure doesn't merge into one paragraph.</li>
</ul>
<p>LLMs also read Markdown well, so the same text works for embedding and for the context you pass to the model.</p>
<h2>Step 1: Pick a converter</h2>
<p>There's no single best PDF-to-Markdown tool. These are the ones worth trying, all free to run yourself:</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Good at</th>
<th>Watch out for</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://github.com/pymupdf/pymupdf4llm">PyMuPDF4LLM</a></td>
<td>Fast, page-by-page output, headings from font size, tables</td>
<td>AGPL licence (commercial licence available)</td>
</tr>
<tr>
<td><a href="https://github.com/docling-project/docling">Docling</a> (IBM)</td>
<td>Layout analysis, complex tables, reading order</td>
<td>Heavier install, slower on CPU</td>
</tr>
<tr>
<td><a href="https://github.com/datalab-to/marker">Marker</a></td>
<td>Equations, multi-column papers, OCR</td>
<td>Needs a GPU to be quick. Check its licence for commercial use</td>
</tr>
<tr>
<td><a href="https://github.com/opendatalab/MinerU">MinerU</a></td>
<td>Scientific papers, formulas, tables</td>
<td>Large models to download</td>
</tr>
<tr>
<td><a href="https://github.com/microsoft/markitdown">MarkItDown</a> (Microsoft)</td>
<td>Office files and HTML</td>
<td>Its PDF converter returns plain text, with no headings</td>
</tr>
</tbody></table>
<p>Start with the lightest one that handles your documents, and test it on your own PDFs, not someone else's benchmark. A tool that's great on arXiv papers can struggle with scanned invoices or two-column brochures.</p>
<p>We'll use PyMuPDF4LLM because it's quick and returns one result per page, which makes page numbers easy.</p>
<h2>Step 2: Convert the PDF page by page</h2>
<pre><code class="language-python"># pip install "pymupdf4llm&lt;2" langchain-text-splitters
import pymupdf4llm

pages = pymupdf4llm.to_markdown("rag.pdf", page_chunks=True)
print(len(pages))                          # 19
print(pages[0]["metadata"]["page_number"]) # 1
print(pages[0]["text"][:300])
</code></pre>
<p>A note on that version pin: at the time of writing, <code>pip install pymupdf4llm</code> installs version 2.0, which is an empty placeholder package. The PyMuPDF team moved the code into PyMuPDF 2.x itself. Pinning <code>&lt;2</code> gets you the 1.x release with the API used here.</p>
<p>On our test machine (a small cloud VM, CPU only), the whole 19-page paper took about 2.7 seconds. <code>page_chunks=True</code> returns a list of dictionaries, one per page, each with the page's Markdown in <code>text</code> and its 1-based <code>page_number</code> in <code>metadata</code>. Headings came out as <code>### **2 Methods**</code> and <code>#### **2.1 Models**</code>. PDFs don't actually store headings, so every tool infers them from font size and weight. Spot-check them on your own files.</p>
<h2>Step 3: Chunk by heading, keep the page number</h2>
<p>Splitting every 1,000 characters regardless of structure works, but it cuts sections in half and loses the heading. A better default is to split on headings first, then split any section that's still too long:</p>
<pre><code class="language-python">import re
import pymupdf4llm
from langchain_text_splitters import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter

pages = pymupdf4llm.to_markdown("rag.pdf", page_chunks=True)

header_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3"), ("####", "h4")],
    strip_headers=False,
)
size_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)

chunks, current = [], {}
for page in pages:
    page_no = page["metadata"]["page_number"]  # 1-based in pymupdf4llm 1.x
    for section in header_splitter.split_text(page["text"]):
        # a page that starts mid-section keeps the previous heading
        current = section.metadata or current
        heading = next((current[k] for k in ("h4", "h3", "h2", "h1") if k in current), None)
        for piece in size_splitter.split_text(section.page_content):
            chunks.append({
                "text": piece,
                "page": page_no,
                "heading": re.sub(r"[*_]", "", heading).strip() if heading else None,
                "source": "rag.pdf",
            })

print(len(chunks), "chunks")
</code></pre>
<p>This gave 110 chunks. Each one carries <code>page</code>, <code>heading</code> and <code>source</code>. Store those as metadata in your vector database so an answer can say "Lewis et al., page 3, section 2 Methods" instead of just quoting text.</p>
<p>Splitting page by page means a section that runs across a page break becomes two chunks. For citations that's usually what you want. If you'd rather have whole sections, join the pages first and insert a marker such as <code>&lt;!-- page 3 --&gt;</code> at each boundary, so you can still work out which page a chunk starts on.</p>
<h2>Step 4: Clean up the PDF debris</h2>
<p>Look at a few chunks before you embed anything. In ours, the chunk for "2 Methods" ended with a footnote and a lone <code>2</code>, which is the page number from the footer. Common debris in PDF text:</p>
<ul>
<li><strong>Page numbers, running headers and footers</strong> repeated on every page.</li>
<li><strong>Ligatures:</strong> many PDFs store "fi" and "fl" as single characters (<code>ï¬</code>, <code>ï¬‚</code>). The text looks fine on screen, but "ï¬ne-tuned" won't match a search for "fine-tuned", and embeddings treat them differently.</li>
<li><strong>Words broken across lines</strong> with a hyphen, such as <code>knowl-</code> + <code>edge</code>.</li>
</ul>
<p>PyMuPDF4LLM already joined the broken words and expanded the ligatures in our paper. Not every extractor does: pdfminer-based tools often keep both. If yours doesn't, a few lines fix most of it:</p>
<pre><code class="language-python">import re
import unicodedata

def clean(text: str) -&gt; str:
    text = unicodedata.normalize("NFKC", text)       # "ï¬" -&gt; "fi"
    text = re.sub(r"(\w)-\n(\w)", r"\1\2", text)      # "knowl-\nedge" -&gt; "knowledge"
    text = re.sub(r"\n\s*\d{1,3}\s*$", "", text)      # lone page number at the very end
    return text
</code></pre>
<p>The hyphen rule is a trade-off. In this paper it would also glue genuine compounds that happen to break at the line end, like <code>knowledge-</code> + <code>intensive</code> and <code>cross-</code> + <code>encoder</code>. For retrieval that's a smaller problem than half-words, but don't use this rule on text you'll show to users as-is.</p>
<h2>Step 5: Check for pages that came out empty</h2>
<p>This is the step most pipelines skip. A scanned page, or a page where the text was converted to vector shapes, has no text layer. Every converter returns nothing for it, and nothing raises an error. Check each page's text length and whether the page contains images:</p>
<pre><code class="language-python">import pymupdf  # installed with pymupdf4llm

def suspicious_pages(path, min_chars=50):
    doc = pymupdf.open(path)
    flagged = []
    for page in doc:
        if len(page.get_text().strip()) &lt; min_chars:
            reason = "image only (scanned?)" if page.get_images() else "blank or near-blank"
            flagged.append((page.number + 1, reason))
    return flagged

print(suspicious_pages("rag.pdf"))  # [] for this paper
</code></pre>
<p>Our paper came back clean. For a pile of real-world documents, route any flagged file to OCR (Docling and Marker both have it, as does Tesseract via <code>ocrmypdf</code>) or to a human, instead of embedding a document with holes in it. A word count per page that's far below the rest of the document is another cheap warning sign.</p>
<h2>Step 6: Embed and store</h2>
<p>From here it's standard RAG: embed <code>text</code>, keep <code>page</code>, <code>heading</code> and <code>source</code> as metadata, and pass the metadata through to your prompt so the model can cite. Re-run Steps 4 and 5 whenever you add documents, because a new batch often brings a new kind of mess.</p>
<h2>The hosted option: an API instead of a conversion server</h2>
<p>If you'd rather not run and maintain this (or you need to convert Word, PowerPoint and Excel files and shared Google Drive, Dropbox or SharePoint folders, not just PDFs), we built <strong>Document to Markdown</strong> on Apify. You send a URL, it returns Markdown, chunks with <code>page</code> and <code>heading</code>, and a per-document quality report covering empty and scanned pages. OCR for scanned pages is optional.</p>
<p>We ran it on the same paper:</p>
<pre><code class="language-python"># pip install apify-client
from apify_client import ApifyClient

client = ApifyClient("&lt;YOUR_APIFY_TOKEN&gt;")
run = client.actor("flyingkiwi/document-to-markdown").call(run_input={
    "urls": ["https://arxiv.org/pdf/2005.11401"],
    "chunkSize": 1000,
    "chunkOverlap": 100,
})

for doc in client.dataset(run.default_dataset_id).iterate_items():
    print(doc["title"], doc["pageCount"], len(doc["chunks"]), doc["quality"]["possibleContentLoss"])
</code></pre>
<p>(That's apify-client 3.x, where <code>call()</code> returns a <code>Run</code> object. With the older 1.x and 2.x clients, use <code>run["defaultDatasetId"]</code>.)</p>
<p>Or with plain HTTP, which waits for the run and returns the results in one request (fine for small jobs; long runs should use the asynchronous endpoints):</p>
<pre><code class="language-bash">curl -X POST "https://api.apify.com/v2/acts/flyingkiwi~document-to-markdown/run-sync-get-dataset-items" \
  -H "Authorization: Bearer $APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"urls": ["https://arxiv.org/pdf/2005.11401"], "chunkSize": 1000, "chunkOverlap": 100}'
</code></pre>
<p>The run took about 7 seconds. It returned the title "Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks", 19 pages, 9,692 words and 96 chunks averaging about 730 characters. Every chunk had a heading and a page number:</p>
<pre><code class="language-json">{
  "index": 17,
  "page": 4,
  "heading": "3 Experiments",
  "charCount": 753,
  "text": "### 3 Experiments\n\nWe experiment with RAG in a wide range of knowledge-intensive tasks. For all experiments, we use\na single Wikipedia dump for our non-parametric knowledge source. ..."
}
</code></pre>
<p>The quality report showed 19 of 19 pages with text, no scanned pages and <code>possibleContentLoss: false</code>. The cost was $0.006: $0.003 to start the run and $0.003 for the document (up to 20 pages, then $0.001 per extra page).</p>
<p>To be straight about how it compares with the DIY route on this paper: PyMuPDF4LLM's output was cleaner on the text itself. Document to Markdown left the ligatures (<code>ï¬</code>) and the line-end hyphens in place, so run the <code>clean()</code> function from Step 4 on its output too. Where it helps is everything around the conversion: one call for mixed file types and whole shared folders, chunks and page numbers built in, the empty-page check on every document, and nothing to install or keep updated.</p>
<h2>Which route should you take?</h2>
<ul>
<li><strong>A few hundred PDFs, and you're comfortable with Python:</strong> go DIY. PyMuPDF4LLM (mind the AGPL licence) or Docling, plus the chunking and checks above, will do the job for free.</li>
<li><strong>Papers full of equations, or heavily laid-out PDFs:</strong> try Marker or MinerU and compare them on your own files.</li>
<li><strong>Mixed Office files and shared folders, called from an app or an agent, without running a server:</strong> a hosted converter saves the maintenance. Check whichever one you pick for empty pages anyway.</li>
</ul>
<p><em>We (flyingkiwi) built and maintain Document to Markdown. The numbers in this post come from runs on 2 November 2026 (NZ time): PyMuPDF4LLM 1.28.2 and langchain-text-splitters 1.1.3 locally, and Document to Markdown on Apify.</em></p>
<p>Tool: <a href="https://apify.com/flyingkiwi/document-to-markdown">https://apify.com/flyingkiwi/document-to-markdown</a></p>
]]></content:encoded></item><item><title><![CDATA[Mining App Store charts and 1-star reviews for app ideas: a worked example (with the weak ideas left in)]]></title><description><![CDATA[Use Apple's free chart and review feeds to find recurring complaints, then read AI-suggested app ideas sceptically. Real run, Python included.]]></description><link>https://flyingkiwi.hashnode.dev/mining-app-store-charts-and-1-star-reviews-for-app-ideas-a-worked-example-with-the-weak-ideas-left-in</link><guid isPermaLink="true">https://flyingkiwi.hashnode.dev/mining-app-store-charts-and-1-star-reviews-for-app-ideas-a-worked-example-with-the-weak-ideas-left-in</guid><category><![CDATA[indie hackers]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Python]]></category><category><![CDATA[Startups]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[flyingkiwi]]></dc:creator><pubDate>Wed, 28 Oct 2026 02:50:57 GMT</pubDate><content:encoded><![CDATA[<p>A common piece of indie-dev advice goes: "look at what's charting, read the 1-star reviews, build what people are begging for." It's good advice, and it's also tedious. You scroll a chart, open apps one by one, read reviews and try to remember which complaints keep coming up.</p>
<p>This post shows how to do that loop with Apple's free public data, first with a few lines of Python and then with a hosted tool that adds complaint counting and an AI idea report. It uses a real run and includes the weak ideas.</p>
<h2>Step 1: Get the raw data yourself</h2>
<p>Apple publishes the raw material for free:</p>
<ul>
<li>public RSS feeds for the top charts in every country and category;</li>
<li>a public "most recent reviews" feed for each app;</li>
<li>the iTunes Lookup API for app details.</li>
</ul>
<p>There's no login and no scraping of web pages. This script works as written:</p>
<pre><code class="language-python">import requests

# Top 50 free Health &amp; Fitness apps in the US (genre 6013)
chart = requests.get("https://itunes.apple.com/us/rss/topfreeapplications/limit=50/genre=6013/json", timeout=30).json()
apps = [(e["im:name"]["label"], e["id"]["attributes"]["im:id"]) for e in chart["feed"]["entry"]]
print(apps[:3])

# Most recent reviews for the first app
name, app_id = apps[0]
feed = requests.get(f"https://itunes.apple.com/us/rss/customerreviews/page=1/id={app_id}/sortby=mostrecent/json", timeout=30).json()
reviews = [(int(e["im:rating"]["label"]), e["title"]["label"]) for e in feed["feed"].get("entry", []) if "im:rating" in e]
low = [r for r in reviews if r[0] &lt;= 2]
print(name, len(reviews), "reviews,", len(low), "rated 1-2 stars")
</code></pre>
<p>On 28 October 2026 (NZ time) the top three were Strava, Flo and Planet Fitness. Page 1 of Strava's review feed had 50 reviews, 21 of them rated 1–2 stars. From here, the work is:</p>
<ul>
<li>paging through reviews (Apple returns at most 500 recent reviews per app and country);</li>
<li>counting which words and phrases recur in the negative reviews;</li>
<li>tracking rank changes between runs;</li>
<li>turning all of that into something you can decide on.</li>
</ul>
<h2>Step 2: The hosted version</h2>
<p>We built <strong>App Store Trends &amp; AI App-Idea Report</strong> on Apify to do that whole loop in one run. It:</p>
<ol>
<li>pulls a chart;</li>
<li>picks apps (rising or top);</li>
<li>counts recurring complaint terms in their recent 1–2 star reviews;</li>
<li>optionally asks an AI model to suggest ideas based on those complaints.</li>
</ol>
<p>This is the exact input we used:</p>
<pre><code class="language-json">{
  "country": "us",
  "chart": "top-free",
  "genre": "health-fitness",
  "chartLimit": 50,
  "appsToAnalyse": 5,
  "pickAppsBy": "rising",
  "reviewsPerApp": 100,
  "reportFocus": "ideas a solo developer could build and launch in about a month"
}
</code></pre>
<p><code>reportFocus</code> is free text that steers the AI section. The report is on by default. Set <code>"generateReport": false</code> if you only want the chart and review data, with no AI.</p>
<p>From code:</p>
<pre><code class="language-python"># pip install -U "apify-client&gt;=3"
from apify_client import ApifyClient

client = ApifyClient("&lt;YOUR_APIFY_TOKEN&gt;")
run = client.actor("flyingkiwi/app-store-trends-app-ideas").call(run_input={
    "country": "us", "chart": "top-free", "genre": "health-fitness",
    "chartLimit": 50, "appsToAnalyse": 5,
})
report = client.key_value_store(run.default_key_value_store_id).get_record("REPORT")
print(report["value"])
</code></pre>
<p>The run took about 27 seconds. The dataset holds the chart rows and per-app review stats, and the Markdown report is saved under the <code>REPORT</code> key. The excerpts below are copied from a run on 28 October 2026 (NZ time), shortened but not otherwise edited.</p>
<h2>Step 3: Start with the part that isn't AI</h2>
<p>The most useful section is computed directly from the reviews. For each app it gives the recent average rating, the share of negative reviews and how many 1–2 star reviews mention each term:</p>
<blockquote>
<ul>
<li><strong>Planet Fitness</strong> (#3, recent avg 1.72★ from 100 reviews, 79% negative): update (24), check (24), new (21), back (12), membership (11).</li>
<li><strong>Flo Cycle &amp; Period Tracker</strong> (#2, recent avg 2.73★ from 100 reviews, 54% negative): pay (18), free (10), money (9), subscription (8), tracking (8).</li>
<li><strong>Strava: Run, Bike, Walk</strong> (#1, recent avg 3.1★ from 100 reviews, 43% negative): subscription (8), update (8), new (7), annoying (6), everything (5).</li>
</ul>
</blockquote>
<p>Single words like "check" and "back" don't mean much on their own, so each app also gets an anonymised example review. For Planet Fitness, the example was about a recent update making check-in confusing for older members. The terms tell you where to look; you still have to read the reviews.</p>
<p>The "What's rising" list flagged <strong>Rythm Health</strong> at #35, released 20 days before the run, as the strongest mover. On a first run, "rising" is based only on chart position and app age. Real rank changes appear once you've run the same chart more than once.</p>
<h2>Step 4: Read the AI ideas sceptically</h2>
<p>The report then gives AI-written ideas, five in this run. Each comes with who it's for, the evidence it's based on, must-haves, a money model and a risk. The best one from this run:</p>
<blockquote>
<p><strong>"Cycle Sync Companion"</strong>: A free, privacy-focused period tracker that offers core tracking without a subscription.</p>
<ul>
<li><strong>Evidence:</strong> Flo Cycle &amp; Period Tracker: pay (18 reviews), free (10 reviews), money (9 reviews), subscription (8 reviews)...</li>
<li><strong>Risk:</strong> Competing with established, feature-rich (but often paid) apps.</li>
</ul>
</blockquote>
<p>The weakest ones:</p>
<ul>
<li><strong>"Gym Check-in Pro"</strong>, a nicer check-in app for Planet Fitness members. The report itself lists the problem as the risk: it depends on one gym chain's check-in technology, so an outside developer can't really build it.</li>
<li><strong>"No-Frills Health Log"</strong>, which was based on a single review mentioning crashes.</li>
</ul>
<p>Two more issues showed up in that run:</p>
<ul>
<li>The summary printed an unrounded rating ("1.84845") for one app. Ratings are now rounded to 2 decimals.</li>
<li>A note at the bottom said two quoted phrases couldn't be matched to any collected review, so treat those as paraphrase. Quotes are checked against the real reviews, and unmatched ones are listed in the <code>report</code> row as <code>unverifiedQuotes</code>.</li>
</ul>
<p>That's the honest picture. The computed complaint data is reliable. The ideas are a starting list to argue with, not a validated plan. Whichever route you take, the useful habit is the same: start from counted complaints with real example reviews, and treat any generated idea as a hypothesis.</p>
<h2>Honest limits</h2>
<ul>
<li><strong>No downloads or revenue.</strong> Apple publishes chart positions, not market size.</li>
<li><strong>Reviews skew recent and negative.</strong> The feed returns the most recent public reviews, which over-represent the latest update and unhappy users.</li>
<li><strong>Rank changes need repeat runs.</strong> Schedule it daily or weekly on the same chart.</li>
<li><strong>The AI can be wrong.</strong> It runs through Apify's OpenRouter proxy (<code>google/gemini-2.5-flash</code> by default), only sees the data collected in the run, and is told not to invent numbers.</li>
<li><strong>App Store only.</strong> No Google Play.</li>
<li><strong>You're not the only one doing this.</strong> Several tools work on the same idea. Compare before you commit.</li>
</ul>
<h2>What it costs</h2>
<p>The DIY script costs nothing. The hosted version charges:</p>
<ul>
<li>$0.005 per run start;</li>
<li>$0.001 per chart row;</li>
<li>$0.02 per analysed app;</li>
<li>$0.50 per AI report, charged only if the report is produced.</li>
</ul>
<p>The AI model usage is billed separately by Apify's proxy. With the default model that was about $0.01 per report on paid Apify plans in our tests, and roughly $0.08 on the free plan. The run above comes to about $0.66 plus that AI usage. Without the report it would be about $0.16.</p>
<p><em>We (flyingkiwi) built and maintain App Store Trends &amp; AI App-Idea Report. Apple and App Store are trademarks of Apple Inc.; the tool isn't affiliated with Apple.</em></p>
<p>Tool: <a href="https://apify.com/flyingkiwi/app-store-trends-app-ideas">https://apify.com/flyingkiwi/app-store-trends-app-ideas</a></p>
]]></content:encoded></item><item><title><![CDATA[Pulling open jobs from Greenhouse, Lever and Ashby into one feed (and reading hiring signals from it)]]></title><description><![CDATA[Use the public ATS job-board APIs to collect open roles, normalise them and turn per-company counts into hiring signals. Python included.]]></description><link>https://flyingkiwi.hashnode.dev/pulling-open-jobs-from-greenhouse-lever-and-ashby-into-one-feed-and-reading-hiring-signals-from-it</link><guid isPermaLink="true">https://flyingkiwi.hashnode.dev/pulling-open-jobs-from-greenhouse-lever-and-ashby-into-one-feed-and-reading-hiring-signals-from-it</guid><category><![CDATA[Python]]></category><category><![CDATA[api]]></category><category><![CDATA[web scraping]]></category><category><![CDATA[recruitment]]></category><category><![CDATA[sales]]></category><dc:creator><![CDATA[flyingkiwi]]></dc:creator><pubDate>Wed, 28 Oct 2026 02:48:18 GMT</pubDate><content:encoded><![CDATA[<p>If you sell to companies, recruit for them or invest in them, their job boards are one of the most honest public signals you can get. A company that just opened 30 sales roles in London is telling you something. The catch is that every company hosts its careers page on a different applicant tracking system (ATS), and each ATS has its own JSON shape.</p>
<p>This post shows how to pull open roles straight from the public job-board feeds of Greenhouse, Lever and Ashby, how to normalise them into one format, and how to turn the result into hiring signals.</p>
<h2>Step 1: Use the public job-board feeds (no scraping needed)</h2>
<p>Greenhouse, Lever, Ashby and several other ATSs publish public job-board feeds. These are the same endpoints their own careers widgets use, so there's no login, no headless browser and no LinkedIn scraping. This script works as written:</p>
<pre><code class="language-python">import requests

def greenhouse(slug):
    jobs = requests.get(f"https://boards-api.greenhouse.io/v1/boards/{slug}/jobs", timeout=30).json()["jobs"]
    return [{"title": j["title"], "location": j["location"]["name"], "url": j["absolute_url"]} for j in jobs]

def lever(slug):
    jobs = requests.get(f"https://api.lever.co/v0/postings/{slug}?mode=json", timeout=30).json()
    return [{"title": j["text"], "location": j["categories"].get("location"), "url": j["hostedUrl"]} for j in jobs]

def ashby(slug):
    jobs = requests.get(f"https://api.ashbyhq.com/posting-api/job-board/{slug}", timeout=30).json()["jobs"]
    return [{"title": j["title"], "location": j["location"], "url": j["jobUrl"]} for j in jobs]

for name, rows in [("airbnb", greenhouse("airbnb")), ("spotify", lever("spotify")), ("openai", ashby("openai"))]:
    print(name, len(rows), rows[0])
</code></pre>
<p>On 28 October 2026 (NZ time) it printed 195 Airbnb roles, 89 Spotify roles and 801 OpenAI roles. The slug is the last part of the careers URL: <code>boards.greenhouse.io/airbnb</code>, <code>jobs.lever.co/spotify</code> and <code>jobs.ashbyhq.com/openai</code>.</p>
<p>If you only care about a handful of companies on one or two ATSs, this is enough, and you should just write it. The work grows once you need:</p>
<ul>
<li>more ATSs, each with its own field names, date formats and salary formats;</li>
<li>department and workplace-type (remote, hybrid, onsite) fields that are named differently everywhere;</li>
<li>per-company counts, and a diff against yesterday's run to spot new and removed roles.</li>
</ul>
<h2>Step 2: The hosted version (if you'd rather not maintain it)</h2>
<p>We built <strong>ATS Jobs &amp; Hiring Signals</strong> on Apify to do those next steps. It covers seven ATSs (Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee and Personio), returns one consistent row format, adds a per-company summary and can flag roles that are new or removed since the last run.</p>
<p>The input is just the careers-page URLs:</p>
<pre><code class="language-json">{
  "companies": [
    "https://boards.greenhouse.io/airbnb",
    "https://jobs.lever.co/spotify",
    "https://jobs.ashbyhq.com/openai"
  ],
  "maxJobsPerCompany": 15
}
</code></pre>
<p>There are optional filters for title, location and department keywords, remote-only and "posted within N days". Filters are applied before anything is charged. From Python:</p>
<pre><code class="language-python"># pip install -U "apify-client&gt;=3"
from apify_client import ApifyClient

client = ApifyClient("&lt;YOUR_APIFY_TOKEN&gt;")
run = client.actor("flyingkiwi/ats-jobs-hiring-signals").call(run_input={
    "companies": ["https://jobs.lever.co/spotify"],
    "titleKeywords": ["engineer"],
})
rows = list(client.dataset(run.default_dataset_id).iterate_items())
jobs = [r for r in rows if r["type"] == "job"]
summaries = [r for r in rows if r["type"] != "job"]
</code></pre>
<p>Our three-company run finished in about 3.4 seconds and returned 45 job rows (15 per company) plus three company summary rows.</p>
<h2>Step 3: Read the job rows</h2>
<p>Every job has the same fields, whichever ATS it came from. Here's a real Greenhouse row, trimmed:</p>
<pre><code class="language-json">{
  "type": "job",
  "ats": "greenhouse",
  "company": "Airbnb",
  "title": "Associate Legal Counsel, Japan",
  "department": "Legal",
  "location": "Tokyo, Japan",
  "workplaceType": "hybrid",
  "postedAt": "2026-04-13T08:49:05+00:00",
  "url": "https://careers.airbnb.com/positions/7789554?gh_jid=7789554",
  "salaryMin": null
}
</code></pre>
<p>Salary is filled in only when the company publishes it. In this run, all 15 OpenAI (Ashby) rows had salary ranges, for example "Research Engineer, $250K - $445K". None of the Airbnb or Spotify rows did.</p>
<h2>Step 4: Use the company summaries as hiring signals</h2>
<p>The summary row counts all of a company's open roles, not just the 15 we asked for. On 28 October 2026 (NZ time):</p>
<table>
<thead>
<tr>
<th>Company (ATS)</th>
<th>Open roles</th>
<th>Posted in last 30 days</th>
<th>Biggest department</th>
<th>Top location</th>
</tr>
</thead>
<tbody><tr>
<td>Airbnb (Greenhouse)</td>
<td>195</td>
<td>90</td>
<td>Software Engineering (51)</td>
<td>United States (67)</td>
</tr>
<tr>
<td>OpenAI (Ashby)</td>
<td>801</td>
<td>233</td>
<td>Go To Market (152)</td>
<td>San Francisco (548)</td>
</tr>
<tr>
<td>Spotify (Lever)</td>
<td>89</td>
<td>38</td>
<td>Engineering (33)</td>
<td>London (37)</td>
</tr>
</tbody></table>
<p>The summary also splits roles by workplace type. For Airbnb, 148 of 195 roles were tagged remote. For Spotify it was 48 hybrid, 28 remote and 13 onsite. Numbers like these help you prioritise accounts, or spot a team that's being built out, such as a new go-to-market push or a new office.</p>
<p>A few ways to use them:</p>
<ul>
<li><strong>Sales prospecting:</strong> sort target accounts by <code>postedLast30Days</code> and look at which departments are growing.</li>
<li><strong>Recruiting:</strong> watch competitors' engineering openings by location.</li>
<li><strong>Investing and research:</strong> track headcount intent over time, which needs repeat runs (next step).</li>
</ul>
<h2>Step 5: Turn on monitor mode for alerts</h2>
<p>Set <code>"monitorMode": true</code> and schedule the run, for example daily. Each company summary then lists <code>newJobsSinceLastRun</code> and <code>removedJobsSinceLastRun</code>. With "only new jobs" on, a run with nothing new costs only the start fee. You can send the output to Slack, a sheet or your CRM with an Apify integration or a webhook. If you're building this yourself, the equivalent is storing yesterday's job IDs per company and diffing them.</p>
<h2>Honest limits</h2>
<p>These apply to the approach in general, not just to our tool:</p>
<ul>
<li><strong>Seven ATSs only.</strong> Workday, iCIMS, Taleo, BambooHR, LinkedIn and Indeed aren't supported. Many large enterprises use Workday, so check where your targets host their jobs first.</li>
<li><strong>Salary is often missing.</strong> It depends on what the company publishes. In our sample, only the Ashby company had it.</li>
<li><strong>Lever and Ashby don't publish a company display name.</strong> The name is built from the board slug, so <code>openai</code> becomes "OpenAI". A slug that doesn't match the real name (<code>palantir</code> for Palantir Technologies) needs the optional <code>companyNames</code> input.</li>
<li><strong>Location clean-up is light.</strong> "Remote - USA", "Remote, USA" and "Remote-USA" are merged into "Remote - United States" (14 Airbnb roles), and USA/UK are spelled out. Cities are left as written, so "San Francisco" and "San Francisco, CA" still count separately.</li>
<li><strong>"Posted" dates come from different fields on each ATS.</strong> Depending on the ATS, <code>postedAt</code> is a first-published, published or created date, and a re-posted role can look new. Treat "posted in last 30 days" as a rough signal.</li>
<li><strong>The first run is a baseline.</strong> New and removed roles only show up from the second monitored run.</li>
</ul>
<h2>What it costs</h2>
<p>The hosted version costs $0.005 per run start plus $0.001 per job returned, and summary rows are free. The run above (45 jobs) costs $0.05. If you only want counts, set a low <code>maxJobsPerCompany</code> and read the summaries. The DIY script above costs nothing but your time.</p>
<p><em>We (flyingkiwi) built and maintain ATS Jobs &amp; Hiring Signals. The numbers in this post come from real runs on 28 October 2026 (NZ time).</em></p>
<p>Tool: <a href="https://apify.com/flyingkiwi/ats-jobs-hiring-signals">https://apify.com/flyingkiwi/ats-jobs-hiring-signals</a></p>
]]></content:encoded></item><item><title><![CDATA[Turning PDFs, Word files, slides and shared folders into Markdown for RAG (what works and what doesn't)]]></title><description><![CDATA[A hands-on walkthrough of document-to-Markdown preprocessing for RAG: chunks with page numbers, folder links, and catching silent content loss.]]></description><link>https://flyingkiwi.hashnode.dev/turning-pdfs-word-files-slides-and-shared-folders-into-markdown-for-rag-what-works-and-what-doesn-t</link><guid isPermaLink="true">https://flyingkiwi.hashnode.dev/turning-pdfs-word-files-slides-and-shared-folders-into-markdown-for-rag-what-works-and-what-doesn-t</guid><category><![CDATA[RAG ]]></category><category><![CDATA[llm]]></category><category><![CDATA[markdown]]></category><category><![CDATA[Python]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[flyingkiwi]]></dc:creator><pubDate>Wed, 28 Oct 2026 02:47:09 GMT</pubDate><content:encoded><![CDATA[<p>Most RAG tutorials start with a clean <code>.txt</code> file. Real projects start with a shared folder of PDFs, a few Word documents, a slide deck someone exported in 2019 and a spreadsheet with three tabs. Before you can embed anything you need all of it as text. Ideally that's Markdown, so headings and tables survive, cut into chunks of a sensible size, with page numbers so answers can cite their source.</p>
<p>This post walks through that preprocessing step on real files: what good output looks like, what to check before you trust it, and where conversion still goes wrong.</p>
<h2>Option A: do it yourself with open-source tools</h2>
<p>If you're happy running Python, start here. <a href="https://github.com/microsoft/markitdown">MarkItDown</a> (Microsoft, MIT licence) handles Office files and HTML well:</p>
<pre><code class="language-python"># pip install 'markitdown[all]'
from markitdown import MarkItDown

md = MarkItDown()
print(md.convert("quarterly-report.docx").text_content)
</code></pre>
<p>For PDFs, look at <a href="https://github.com/docling-project/docling">Docling</a>, <a href="https://github.com/datalab-to/marker">Marker</a> or <a href="https://github.com/opendatalab/MinerU">MinerU</a> as well. They do layout analysis, which matters for multi-column papers, equations and complex tables. Test them on your own documents: PDF quality varies a lot by tool and by file. For example, when we ran MarkItDown 0.1.9's built-in PDF converter on the "Attention Is All You Need" paper, it returned plain text with no headings or page markers, and some lines lost the spaces between words.</p>
<p>The rest of this post uses a hosted option, for when you'd rather call an API (or schedule a job) than maintain a conversion box. The checks in the "What to check" section apply whichever tool you pick.</p>
<h2>Option B: a hosted converter</h2>
<p>We built <strong>Document to Markdown</strong> on Apify. It uses MarkItDown for Office and HTML formats, plus pdfminer.six and pdfplumber for PDFs (page markers, heading detection from font size and weight, and tables drawn with lines). It also has optional OCR for scanned pages, and a quality report on every document.</p>
<h3>Step 1: Give it a URL, a folder link or a file</h3>
<p>Inputs can be:</p>
<ul>
<li>one <code>url</code> or a list of <code>urls</code> (PDF, DOCX, PPTX, XLSX/XLS, HTML, EPUB, CSV, Jupyter notebooks);</li>
<li>Google Drive, Dropbox, OneDrive or SharePoint share links for single files;</li>
<li>a <strong>whole shared folder</strong> from Google Drive, Dropbox or SharePoint / OneDrive for Business, with subfolders included;</li>
<li>files uploaded directly in the Apify Console.</li>
</ul>
<p>Here is the exact input from one of our test runs on the arXiv paper:</p>
<pre><code class="language-json">{
  "urls": ["https://arxiv.org/pdf/1706.03762"],
  "chunkSize": 1500,
  "chunkOverlap": 150
}
</code></pre>
<p><code>chunkSize</code> is in characters. Leave it at 0 if you only want the full Markdown. PDF table detection (<code>pdfTables</code>) and saving a <code>.md</code> file per document (<code>saveMarkdownFiles</code>) are both on by default.</p>
<h3>Step 2: Run it from code (or the Console)</h3>
<pre><code class="language-python"># pip install -U "apify-client&gt;=3"
from apify_client import ApifyClient

client = ApifyClient("&lt;YOUR_APIFY_TOKEN&gt;")
run = client.actor("flyingkiwi/document-to-markdown").call(run_input={
    "urls": ["https://arxiv.org/pdf/1706.03762"],
    "chunkSize": 1500,
    "chunkOverlap": 150,
})

for doc in client.dataset(run.default_dataset_id).iterate_items():
    print(doc["fileName"], doc["pageCount"], doc["wordCount"], len(doc["chunks"]))
    print(doc["quality"]["possibleContentLoss"], doc["markdownFileUrl"])
</code></pre>
<h3>Step 3: Look at what comes back</h3>
<p>The run took about 8 seconds. It returned one dataset item for the 15-page PDF, titled "Attention Is All You Need": 5,830 words, 38,426 characters and 44 chunks. 43 of the chunks carry the section heading they belong to, and every chunk carries the page it starts on:</p>
<pre><code class="language-json">{
  "index": 10,
  "page": 3,
  "heading": "3.2 Attention",
  "charCount": 542,
  "text": "### 3.2 Attention\n\nAn attention function can be described as mapping a query and a set of key-value pairs to an output,\nwhere the query, keys, values, and output are all vectors. The output is computed as a weighted sum\n3\n\nScaled Dot-Product Attention\nMulti-Head Attention\nFigure 2: (left) Scaled Dot-Product Attention. (right) ..."
}
</code></pre>
<p>Notice the stray <code>3</code> (a page number) and the figure labels in that chunk. Real PDFs are messy, and it's worth knowing what your chunks actually contain before you tune retrieval.</p>
<p>The Markdown marks each page with <code>&lt;!-- page N --&gt;</code>. PDFs don't store headings, so they're inferred from font size and bold text. On this paper that gave <code># Attention Is All You Need</code>, <code>## Abstract</code>, <code>## 1 Introduction</code>, <code>### 3.1 Encoder and Decoder Stacks</code>, <code>### 3.2 Attention</code> and so on. Each document is also saved as a <code>.md</code> file in the run's key-value store, and <code>markdownFileUrl</code> links to it.</p>
<p>Next, we converted three non-PDF files in one run: a Python docs tutorial page (HTML), Microsoft's Financial Sample workbook (XLSX) and a test deck from python-pptx (PPTX).</p>
<ul>
<li><strong>HTML:</strong> the page starts at the content (<code># 3. An Informal Introduction to Python</code>), with the site navigation and footer stripped and the real headings kept (<code>## 3.1. Using Python as a Calculator</code>, <code>### 3.1.1. Numbers</code>).</li>
<li><strong>XLSX:</strong> each sheet becomes a <code>## Sheet name</code> heading followed by a Markdown table. The 700-row workbook came out as one 16-column table: first row as the header, empty rows and columns dropped, and no <code>NaN</code> or <code>Unnamed</code> cells.</li>
<li><strong>PPTX:</strong> the slide came out as:</li>
</ul>
<pre><code class="language-markdown">&lt;!-- Slide number: 1 --&gt;
# Presentation Title Text
Subtitle Text
</code></pre>
<h3>Step 4: Convert a whole shared folder</h3>
<p>Paste a folder link instead of a file link. We used a public Dropbox folder of rule documents (3 PDFs and 3 DOCX files):</p>
<pre><code class="language-json">{ "url": "https://www.dropbox.com/scl/fo/&lt;folder-id&gt;?rlkey=&lt;key&gt;&amp;dl=0", "chunkSize": 1500 }
</code></pre>
<p>All 6 files converted, one dataset row each, with <code>relativePath</code> showing where the file sits in the folder. Files the converter doesn't support (images, videos and so on) are skipped and listed in the run's status message and a <code>FOLDER_SUMMARY</code> record. The folder has to be shared as "anyone with the link". Private folders get a clear error and aren't charged. <code>maxFiles</code> (default 500) and <code>includeSubfolders</code> (default on) keep big folders under control.</p>
<h2>What to check before you embed anything</h2>
<p>Silent content loss is the most common RAG preprocessing bug. A scanned page or an image-only slide produces no text, and nothing tells you. Whatever tool you use, check every document for:</p>
<ul>
<li>pages with no text at all (scans, or text drawn as shapes);</li>
<li>a word count that's far too low for the page count;</li>
<li>tables that came out as jumbled lines.</li>
</ul>
<p>Document to Markdown puts these checks in a <code>quality</code> object on every row. For a PDF with a scanned page and OCR off, it looks like this:</p>
<pre><code class="language-json">{
  "tablesFound": 0, "pagesWithText": 1, "textCoverage": 0.5,
  "emptyPages": [3], "scannedPages": [2], "ocrPages": [],
  "possibleContentLoss": true,
  "warnings": ["1 page(s) look scanned (an image, or text drawn as shapes) and have no text: 2. Turn on OCR to read them."]
}
</code></pre>
<p>A simple pipeline rule is: if <code>possibleContentLoss</code> is true, re-run that document with <code>"ocr": true</code> or send it for review rather than embedding it. OCR is opt-in. Only pages without a text layer are sent to a vision model (Google Gemini 2.5 Flash by default, through Apify's AI proxy). Text pages are never OCR'd.</p>
<h2>Step 5: Feed the chunks into your vector store</h2>
<p>From here it's ordinary RAG plumbing. Loop over <code>chunks</code>, embed <code>text</code>, and store <code>page</code>, <code>heading</code> and the source <code>url</code> (or <code>relativePath</code> for folder runs) as metadata. If you'd rather use your own splitter (LangChain, LlamaIndex or something else), set <code>chunkSize</code> to 0 and split the <code>markdown</code> field yourself.</p>
<h2>Honest limits</h2>
<ul>
<li><strong>OCR is AI-based and slower.</strong> Our 3-page scanned test read every page correctly, but the run took a little over a minute. Poor scans and handwriting can be misread.</li>
<li><strong>PDF headings are a best guess.</strong> They're inferred from font size and bold text. That worked on the arXiv paper, but PDFs that style headings like body text get few or none, and a stray bold line can become a heading. DOCX, PPTX and HTML headings come straight from the file.</li>
<li><strong>PDF tables work best with ruling lines.</strong> Borderless tables stay as plain text. The arXiv paper's tables have no ruling lines, so they came through as text (<code>tablesFound: 0</code>).</li>
<li><strong>Multi-column PDFs</strong> are read in the order the text appears in the file, which is usually, but not always, reading order.</li>
<li><strong>HTML clean-up is rule-based.</strong> An unusual page template may keep some boilerplate, and JavaScript-rendered pages can come out empty.</li>
<li><strong>Spreadsheets are flattened</strong> to one table per sheet. Charts and merged-cell layout don't carry over.</li>
<li><strong>Images are dropped.</strong> You get text and tables only.</li>
<li><strong>Folder limits:</strong> personal OneDrive folders (<code>1drv.ms</code>, <code>onedrive.live.com</code>) don't work, because Microsoft requires a sign-in. Dropbox shares a folder as one ZIP, so the whole folder is downloaded, including any videos. Google Drive shortcuts are skipped.</li>
</ul>
<h2>What it costs</h2>
<p>Pricing is per event:</p>
<ul>
<li>$0.003 per run start;</li>
<li>$0.003 per converted document (up to 20 pages);</li>
<li>$0.001 per extra page;</li>
<li>$0.008 per scanned page read, only if you turn OCR on. The AI model usage for OCR is billed separately by Apify at cost, about $0.001 a page in our tests.</li>
</ul>
<p>At those prices, the 15-page PDF above costs $0.006 in total. The 6-file Dropbox folder came to $0.035: the start fee, 6 documents and 14 extra pages. You can set a maximum cost per run. The Actor checks it before each document and stops cleanly instead of going over.</p>
<h2>When to use something else</h2>
<p>If you need equations, layout-aware reading order for complex multi-column papers, or heavy OCR workloads, run Docling, Marker or MinerU yourself and compare them on your documents. If what you need is "turn this pile of mixed office files, or this shared folder, into Markdown chunks from an API, with a check for missing pages", that's the gap we built this for.</p>
<p><em>We (flyingkiwi) built and maintain Document to Markdown. Every number in this post comes from real runs on 28 October 2026 (NZ time).</em></p>
<p>Tool: <a href="https://apify.com/flyingkiwi/document-to-markdown">https://apify.com/flyingkiwi/document-to-markdown</a></p>
]]></content:encoded></item></channel></rss>