<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Ai on</title><link>https://augmentedresilience.com/tags/ai/</link><description>Recent content in Ai on</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Sun, 19 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://augmentedresilience.com/tags/ai/index.xml" rel="self" type="application/rss+xml"/><item><title>AI Writes Code Fast. Here's Why Security-First Thinking Matters More Than Ever.</title><link>https://augmentedresilience.com/posts/augmented-resilience-posts/ai-writes-code-fast.-here-is-why-security-first-thinking-matters-more-than-ever/</link><pubDate>Sun, 19 Apr 2026 00:00:00 +0000</pubDate><guid>https://augmentedresilience.com/posts/augmented-resilience-posts/ai-writes-code-fast.-here-is-why-security-first-thinking-matters-more-than-ever/</guid><description>&lt;h2 id="speed-is-the-feature-unexamined-speed-is-the-liability">Speed Is the Feature. Unexamined Speed Is the Liability.&lt;/h2>
&lt;p>One of the most impressive things about using AI to write code is how fast it moves. You describe an endpoint, a data model, a feature — and within seconds you have working code. Not a sketch. Not pseudocode. Actual, runnable implementation.&lt;/p>
&lt;p>That speed is real, and it is genuinely useful. I use it every day.&lt;/p>
&lt;p>But here is something I noticed the longer I worked with AI-generated code: it writes to the happy path. AI is extraordinarily good at making code that works when everything goes as expected. It is considerably less reliable at writing code that stays safe when things go wrong — when someone sends unexpected input, when a dependency is compromised, when a secret accidentally surfaces in a log, when a logged-in user tries to access someone else&amp;rsquo;s data.&lt;/p></description><content>&lt;h2 id="speed-is-the-feature-unexamined-speed-is-the-liability">Speed Is the Feature. Unexamined Speed Is the Liability.&lt;/h2>
&lt;p>One of the most impressive things about using AI to write code is how fast it moves. You describe an endpoint, a data model, a feature — and within seconds you have working code. Not a sketch. Not pseudocode. Actual, runnable implementation.&lt;/p>
&lt;p>That speed is real, and it is genuinely useful. I use it every day.&lt;/p>
&lt;p>But here is something I noticed the longer I worked with AI-generated code: it writes to the happy path. AI is extraordinarily good at making code that works when everything goes as expected. It is considerably less reliable at writing code that stays safe when things go wrong — when someone sends unexpected input, when a dependency is compromised, when a secret accidentally surfaces in a log, when a logged-in user tries to access someone else&amp;rsquo;s data.&lt;/p>
&lt;p>This is not a criticism of AI models. It is a structural observation about how code generation works. AI learns from what code looks like, not from what happens to systems that run it. The attack surface is invisible at generation time.&lt;/p>
&lt;p>Security-first thinking is the discipline that fills that gap. And building it into how you work with AI is one of the highest-leverage things you can do as a developer.&lt;/p>
&lt;hr>
&lt;h2 id="what-ai-gets-wrong-by-default">What AI Gets Wrong By Default&lt;/h2>
&lt;p>Before we get to principles, it helps to see the failure modes concretely. These are patterns I started noticing after reviewing AI-generated code more carefully.&lt;/p>
&lt;p>&lt;strong>Hardcoded secrets.&lt;/strong> Ask AI to write a function that connects to a database or calls an external API, and it will often produce something like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>db &lt;span style="color:#f92672">=&lt;/span> connect(host&lt;span style="color:#f92672">=&lt;/span>&lt;span style="color:#e6db74">&amp;#34;prod-db.company.com&amp;#34;&lt;/span>, user&lt;span style="color:#f92672">=&lt;/span>&lt;span style="color:#e6db74">&amp;#34;admin&amp;#34;&lt;/span>, password&lt;span style="color:#f92672">=&lt;/span>&lt;span style="color:#e6db74">&amp;#34;Sup3rS3cr3t!&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>client &lt;span style="color:#f92672">=&lt;/span> OpenAI(api_key&lt;span style="color:#f92672">=&lt;/span>&lt;span style="color:#e6db74">&amp;#34;sk-proj-abc123...&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The code works. It will also put your credentials in git history forever. A secret committed to a repository — even briefly, even to a private repo — must be treated as compromised. Git history is permanent. The only remediation is rotation.&lt;/p>
&lt;p>&lt;strong>Missing input validation.&lt;/strong> AI tends to write code that trusts request data. An endpoint that receives &lt;code>req.body.quantity&lt;/code> will often use it directly, skipping the check for whether it is a positive integer, whether it is within expected bounds, whether it contains what the code assumes it contains.&lt;/p>
&lt;p>&lt;strong>Fetch-then-check authorization.&lt;/strong> This one is subtle. AI commonly writes authorization logic like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-js" data-lang="js">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">const&lt;/span> &lt;span style="color:#a6e22e">order&lt;/span> &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#66d9ef">await&lt;/span> &lt;span style="color:#a6e22e">db&lt;/span>.&lt;span style="color:#a6e22e">orders&lt;/span>.&lt;span style="color:#a6e22e">findById&lt;/span>(&lt;span style="color:#a6e22e">req&lt;/span>.&lt;span style="color:#a6e22e">params&lt;/span>.&lt;span style="color:#a6e22e">id&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">if&lt;/span> (&lt;span style="color:#a6e22e">order&lt;/span>.&lt;span style="color:#a6e22e">userId&lt;/span> &lt;span style="color:#f92672">!==&lt;/span> &lt;span style="color:#a6e22e">req&lt;/span>.&lt;span style="color:#a6e22e">user&lt;/span>.&lt;span style="color:#a6e22e">id&lt;/span>) &lt;span style="color:#66d9ef">return&lt;/span> &lt;span style="color:#a6e22e">res&lt;/span>.&lt;span style="color:#a6e22e">status&lt;/span>(&lt;span style="color:#ae81ff">403&lt;/span>).&lt;span style="color:#a6e22e">send&lt;/span>();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">return&lt;/span> &lt;span style="color:#a6e22e">res&lt;/span>.&lt;span style="color:#a6e22e">json&lt;/span>(&lt;span style="color:#a6e22e">order&lt;/span>);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That looks right. But it fetches the record first and checks ownership second. A slightly better pattern is to include the user ID in the query itself, so that non-owned records simply return null — and you return a 404, not a 403. Returning 403 confirms to an attacker that the resource exists. It is a small thing that compounds at scale.&lt;/p>
&lt;p>&lt;strong>Weak cryptography by familiarity.&lt;/strong> Ask AI to hash a password and it might reach for SHA-256. SHA-256 is a solid hash function — for data integrity. It is the wrong tool for passwords because it is fast. Fast means a GPU can test billions of candidate passwords per second against a leaked hash. bcrypt and Argon2 are deliberately slow. That slowness is the security property.&lt;/p>
&lt;p>&lt;strong>No rate limiting on authentication.&lt;/strong> AI generates login endpoints without the defensive scaffolding that should always accompany them: rate limiting per IP, account lockout after repeated failures, uniform response times to prevent user enumeration.&lt;/p>
&lt;p>None of these are exotic edge cases. They are the everyday failure modes that show up in security audits and breach post-mortems, again and again.&lt;/p>
&lt;hr>
&lt;h2 id="the-mindset-shift-from-checklist-to-first-principles">The Mindset Shift: From Checklist to First Principles&lt;/h2>
&lt;p>Here is where I want to push back against the usual framing. Security is often presented as a checklist — OWASP Top 10, compliance requirements, &amp;ldquo;use HTTPS.&amp;rdquo; Checklists have their place, but they do not produce secure code. They produce code that passes the checklist.&lt;/p>
&lt;p>Security-first thinking is different. It is a way of looking at code that asks one question at every boundary: &lt;em>who or what is being trusted here, and has that trust been earned?&lt;/em>&lt;/p>
&lt;p>Every class of vulnerability — SQL injection, XSS, CSRF, IDOR, broken authentication, insecure deserialization — reduces to a single failure: &lt;strong>untrusted data crossing a trust boundary with the authority of trusted data.&lt;/strong>&lt;/p>
&lt;p>SQL injection happens when user input is trusted to be SQL-safe before it reaches the database. XSS happens when user-generated content is trusted to be display-safe before it enters the DOM. IDOR happens when a request parameter is trusted to represent a resource the current user is allowed to access.&lt;/p>
&lt;p>Once you see security through this lens — trust boundaries and their enforcement — you stop asking &amp;ldquo;did I remember the checklist item?&amp;rdquo; and start asking &amp;ldquo;where are the trust boundaries in this code, and what enforces them?&amp;rdquo; That question applies to every system, every language, every framework. It does not go stale.&lt;/p>
&lt;p>This is what I mean by security-first thinking as a mindset rather than a procedure. It is not about knowing rules. It is about developing the habit of seeing trust assumptions in code.&lt;/p>
&lt;hr>
&lt;h2 id="the-eight-principles">The Eight Principles&lt;/h2>
&lt;p>When I worked through this with Luna recently — building a security knowledge base from 50 cybersecurity books — we distilled the trust boundary framework down to eight irreducible properties that AI-generated code must satisfy.&lt;/p>
&lt;p>I want to walk through each one because they are not independent rules. They are facets of the same underlying principle.&lt;/p>
&lt;p>&lt;strong>1. Input is untrusted by default.&lt;/strong> Every value arriving from outside your process — HTTP body, query parameters, headers, cookies, file uploads — is hostile until validated. Client-side validation is UX. Server-side validation is security. They cannot substitute for each other.&lt;/p>
&lt;p>&lt;strong>2. Output is encoded for its context.&lt;/strong> A string displayed in HTML needs HTML encoding. A value interpolated into SQL needs parameterization. A value passed to a shell command needs to go through an argument array, not string concatenation. The encoding is not about the string itself — it is about the context it will be interpreted in.&lt;/p>
&lt;p>&lt;strong>3. Secrets never live in code.&lt;/strong> Passwords, API keys, tokens, certificates — these belong in environment variables or a secrets manager, never in source files. This is absolute. Git history is permanent.&lt;/p>
&lt;p>&lt;strong>4. Authentication is verified, not assumed.&lt;/strong> Every protected route, every protected function, checks identity on that request. Being logged in at some prior point does not carry forward.&lt;/p>
&lt;p>&lt;strong>5. Authorization is checked at the data layer.&lt;/strong> Being authenticated is not the same as being authorized to access a specific resource. Ownership checks belong in the query itself, not as a post-fetch condition.&lt;/p>
&lt;p>&lt;strong>6. Least privilege is the default.&lt;/strong> Database connections, service accounts, IAM roles, file operations — each gets only the access its specific function requires. Nothing more. A compromised least-privilege component fails safely. A compromised over-privileged one does not.&lt;/p>
&lt;p>&lt;strong>7. Cryptography is never homegrown.&lt;/strong> Cipher and hash choices go to battle-tested libraries: bcrypt or Argon2 for passwords, AES-GCM for encryption, HMAC-SHA256 for message authentication. The failure modes of hand-rolled crypto are subtle and catastrophic.&lt;/p>
&lt;p>&lt;strong>8. Dependencies are trust extensions.&lt;/strong> Every package you add is code you are trusting. Its transitive dependencies are code you are trusting. Supply chain attacks are real. Lock files, audits, and version pinning are not paranoia — they are basic hygiene.&lt;/p>
&lt;p>These eight properties are not a checklist to run through at the end. They are filters to apply while generating code. The question during every code-writing session is: which of these are relevant to what I am building right now?&lt;/p>
&lt;hr>
&lt;h2 id="baking-it-into-your-ai-workflow">Baking It Into Your AI Workflow&lt;/h2>
&lt;p>Knowing principles is not the same as applying them consistently. The reason I started building infrastructure around this is that consistency requires more than intention — it requires systems.&lt;/p>
&lt;p>Here is what I built, and it came directly from the work of turning 50 cybersecurity books into a searchable knowledge base.&lt;/p>
&lt;p>&lt;strong>Tier 1 topic files.&lt;/strong> Each of the eight principles above now lives in a concise reference file — 8 to 12KB each — distilled from the source material. &lt;code>input-validation.md&lt;/code>, &lt;code>secrets-management.md&lt;/code>, &lt;code>authentication.md&lt;/code>, &lt;code>authorization.md&lt;/code>, &lt;code>cryptography.md&lt;/code>, and the others. Each file is dense but scannable: concrete patterns, code examples, anti-patterns, quick checklists.&lt;/p>
&lt;p>&lt;strong>Engineer PREFERENCES.&lt;/strong> I wired those topic files into the Engineer agent through a PREFERENCES file. The trigger table maps code categories to topic files: anything involving SQL gets &lt;code>xss-injection-prevention.md&lt;/code> loaded. Anything involving authentication loads &lt;code>authentication.md&lt;/code>. Anything involving secrets loads &lt;code>secrets-management.md&lt;/code>. The agent loads the relevant files silently before writing code and runs the associated checklist before declaring work done.&lt;/p>
&lt;p>The result is that security context is present in every relevant code-generation session without me having to ask for it. The principles are not something I have to remember to invoke. They are part of the workflow infrastructure.&lt;/p>
&lt;p>This is what I keep coming back to with PAI: the value is not in any single AI interaction. It is in building infrastructure that makes the right thing the default thing. Security-first code used to require conscious effort and specialized knowledge in the moment. Now it is loaded context — always present, reliably applied.&lt;/p>
&lt;hr>
&lt;h2 id="security-and-ai-are-not-in-tension">Security and AI Are Not in Tension&lt;/h2>
&lt;p>I want to end on this because it is easy to come away from a security conversation feeling like the message is &amp;ldquo;AI is dangerous, slow down, be careful.&amp;rdquo; That is not the message.&lt;/p>
&lt;p>AI writing code fast is a genuine capability improvement. Being able to go from specification to working implementation in minutes changes what is possible for a small team or a solo developer. That is real.&lt;/p>
&lt;p>Security-first thinking is not a constraint on that capability. It is the thing that makes the output of that capability trustworthy. Code that moves fast and ships vulnerable systems is not actually faster in any meaningful sense — it is accumulating debt that will be paid with interest.&lt;/p>
&lt;p>The combination is the point. AI that knows how to think about trust boundaries, that loads security context automatically, that applies principles rather than just patterns — that is a force multiplier, not a liability. You get the speed and you get the rigor.&lt;/p>
&lt;p>Building toward that combination is one of the more interesting engineering problems I have worked on. The knowledge base, the topic files, the wiring into the workflow — it is all aimed at the same thing: making security-first thinking something that happens automatically, not something that requires a specialist in the room.&lt;/p>
&lt;p>The specialist knowledge exists. We built it into 77KB of reference material drawn from 50 books. Now it is always in the room.&lt;/p>
&lt;hr>
&lt;p>&lt;em>Part of the PAI series on building infrastructure that makes AI more useful, more reliable, and more trustworthy. The security knowledge base and topic files referenced in this post were built using Claude Code, PostgreSQL, pgvector, and source material from O&amp;rsquo;Reilly&amp;rsquo;s security catalog.&lt;/em>&lt;/p></content></item><item><title>I Turned 50 Cybersecurity Books Into a Searchable Brain</title><link>https://augmentedresilience.com/posts/augmented-resilience-posts/i-turned-50-cybersecurity-books-into-a-searchable-brain/</link><pubDate>Sat, 21 Mar 2026 00:00:00 +0000</pubDate><guid>https://augmentedresilience.com/posts/augmented-resilience-posts/i-turned-50-cybersecurity-books-into-a-searchable-brain/</guid><description>&lt;h2 id="the-problem-with-security-books">The Problem With Security Books&lt;/h2>
&lt;p>I have a lot of cybersecurity books. PDFs from Humble Bundles, O&amp;rsquo;Reilly downloads, books I&amp;rsquo;ve bought and never finished, reference material I collected &amp;ldquo;just in case.&amp;rdquo; Like most people, they lived in a folder I rarely opened.&lt;/p>
&lt;p>The reason is friction. When I needed to look something up — say, how SQL injection payloads work, or the steps for privilege escalation on Linux — I&amp;rsquo;d have to remember which book covered it, open it, and search inside. Or just Google it and hope Stack Overflow had something decent.&lt;/p></description><content>&lt;h2 id="the-problem-with-security-books">The Problem With Security Books&lt;/h2>
&lt;p>I have a lot of cybersecurity books. PDFs from Humble Bundles, O&amp;rsquo;Reilly downloads, books I&amp;rsquo;ve bought and never finished, reference material I collected &amp;ldquo;just in case.&amp;rdquo; Like most people, they lived in a folder I rarely opened.&lt;/p>
&lt;p>The reason is friction. When I needed to look something up — say, how SQL injection payloads work, or the steps for privilege escalation on Linux — I&amp;rsquo;d have to remember which book covered it, open it, and search inside. Or just Google it and hope Stack Overflow had something decent.&lt;/p>
&lt;p>That&amp;rsquo;s not a knowledge base. That&amp;rsquo;s a graveyard.&lt;/p>
&lt;p>So I built something better: a local semantic search engine over all of them, powered by PostgreSQL, pgvector, and OpenAI embeddings. Now I ask questions in plain English and get back the exact passages — with the book and chapter — that answer them. The whole thing runs locally on my machine.&lt;/p>
&lt;p>Here&amp;rsquo;s how I built it, and why it&amp;rsquo;s become one of the most useful tools in my PAI (Personal AI Infrastructure) stack.&lt;/p>
&lt;hr>
&lt;h2 id="what-semantic-search-actually-means">What Semantic Search Actually Means&lt;/h2>
&lt;p>Traditional search is keyword matching. You type &amp;ldquo;SQL injection&amp;rdquo; and it finds documents containing those exact words.&lt;/p>
&lt;p>Semantic search is different. It converts your query and your documents into vectors — lists of numbers that represent &lt;em>meaning&lt;/em> in high-dimensional space. Similar concepts cluster together regardless of exact wording. Ask &amp;ldquo;how to bypass database input validation&amp;rdquo; and you&amp;rsquo;ll surface the same SQL injection content, even though you never typed &amp;ldquo;SQL injection.&amp;rdquo;&lt;/p>
&lt;p>This matters enormously for a security knowledge base. Security concepts have dozens of names. &amp;ldquo;Privilege escalation,&amp;rdquo; &amp;ldquo;privesc,&amp;rdquo; &amp;ldquo;root access,&amp;rdquo; &amp;ldquo;vertical privilege abuse&amp;rdquo; — these all mean the same thing. Semantic search finds all of them.&lt;/p>
&lt;hr>
&lt;h2 id="the-stack">The Stack&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>PostgreSQL 17&lt;/strong> — the database&lt;/li>
&lt;li>&lt;strong>pgvector 0.8.2&lt;/strong> — vector similarity search extension for Postgres&lt;/li>
&lt;li>&lt;strong>OpenAI text-embedding-3-small&lt;/strong> — converts text chunks to 1536-dimensional vectors&lt;/li>
&lt;li>&lt;strong>CyberSecKB.ts&lt;/strong> — a custom Bun/TypeScript CLI I built to tie it all together&lt;/li>
&lt;/ul>
&lt;p>Everything runs locally. The only external call is to OpenAI&amp;rsquo;s embedding API (which runs once at ingest time, not at query time).&lt;/p>
&lt;hr>
&lt;h2 id="the-pipeline-from-pdf-to-searchable-knowledge">The Pipeline: From PDF to Searchable Knowledge&lt;/h2>
&lt;h3 id="step-1-convert-pdfs-to-markdown">Step 1: Convert PDFs to Markdown&lt;/h3>
&lt;p>Raw PDFs are terrible for text processing. I convert everything to Markdown first using a &lt;code>pdf2md&lt;/code> Python tool:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>cd ~/projects/pdf-to-markdown
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>source venv/bin/activate
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#75715e"># Text-based PDFs (most books):&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>python pdf2md input/mybook.pdf
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#75715e"># Image-based or scanned PDFs (use OCR first):&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ocrmypdf --force-ocr input/mybook.pdf /tmp/ocr.pdf
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>python pdf2md /tmp/ocr.pdf output/mybook.md
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#75715e"># Move to library:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>mv output/mybook.md ~/projects/cybersecurity-library/books/
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="step-2-ingest-into-the-database">Step 2: Ingest into the Database&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>TOOL&lt;span style="color:#f92672">=&lt;/span>~/.claude/skills/PAI/USER/KNOWLEDGE/CYBERSECURITY/Tools/CyberSecKB.ts
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#75715e"># Single book with topics tagged:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>bun $TOOL ingest &lt;span style="color:#ae81ff">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ae81ff">&lt;/span> --file ~/projects/cybersecurity-library/books/mybook.md &lt;span style="color:#ae81ff">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ae81ff">&lt;/span> --title &lt;span style="color:#e6db74">&amp;#34;My Book Title&amp;#34;&lt;/span> &lt;span style="color:#ae81ff">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ae81ff">&lt;/span> --topics web,network,linux
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#75715e"># Or load everything at once:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>bun $TOOL ingest --batch ~/projects/cybersecurity-library/books/
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The ingest process:&lt;/p>
&lt;ol>
&lt;li>Reads the Markdown file&lt;/li>
&lt;li>Splits it into ~800-token chunks, preserving chapter headings&lt;/li>
&lt;li>Sends chunks to OpenAI&amp;rsquo;s embedding API in batches&lt;/li>
&lt;li>Stores chunks + their vector embeddings in PostgreSQL&lt;/li>
&lt;/ol>
&lt;h3 id="step-3-search">Step 3: Search&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#75715e"># Plain English query:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>bun $TOOL search &lt;span style="color:#e6db74">&amp;#34;how do attackers bypass WAF rules for SQL injection&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#75715e"># Filter by topic:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>bun $TOOL search &lt;span style="color:#e6db74">&amp;#34;privilege escalation&amp;#34;&lt;/span> --topics linux --limit &lt;span style="color:#ae81ff">5&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#75715e"># Check what&amp;#39;s in the KB:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>bun $TOOL list
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>bun $TOOL stats
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="what-it-looks-like-in-practice">What It Looks Like in Practice&lt;/h2>
&lt;p>Here&amp;rsquo;s a real query. I asked:&lt;/p>
&lt;pre tabindex="0">&lt;code>bun $TOOL search &amp;#34;SQL injection bypass techniques&amp;#34; --limit 3
&lt;/code>&lt;/pre>&lt;p>Result:&lt;/p>
&lt;pre tabindex="0">&lt;code>━━━ [63.3%] Web Penetration Testing With Kali Linux → Detecting and Exploiting Injection-Based Flaws
The `;` metacharacter in a SQL statement is used similarly to how it&amp;#39;s used
in command injection to combine multiple queries on the same line...
━━━ [62.5%] Web Penetration Testing With Kali Linux → Detecting and Exploiting Injection-Based Flaws
If user input is used without prior validation, and it is concatenated
directly into a SQL query, a user can inject different data...
━━━ [60.4%] Web Penetration Testing With Kali Linux → Detecting and Exploiting Injection-Based Flaws
Input taken from cookies, input forms, and URL variables is used to build
SQL statements that are passed back to the database...
&lt;/code>&lt;/pre>&lt;p>Each result shows the similarity score, book title, chapter, and a preview. I can immediately tell which book to go deeper in.&lt;/p>
&lt;p>Another query — privilege escalation:&lt;/p>
&lt;pre tabindex="0">&lt;code>bun $TOOL search &amp;#34;privilege escalation linux&amp;#34; --limit 3
&lt;/code>&lt;/pre>&lt;pre tabindex="0">&lt;code>━━━ [66.1%] Cybersecurity Attack And Defense Strategies → Privilege Escalation
Most systems are built using the least privilege concept — users are
purposefully given the least privileges they need to perform their work...
━━━ [65.9%] Kali Linux Cookbook → Privilege Escalation
CVE-2015-1328: overlayfs vulnerability affecting Ubuntu where it does not
do proper checking of file creation in the upper filesystem area...
━━━ [65.8%] Cybersecurity Attack And Defense Strategies → Privilege Escalation
On Linux, vertical escalation allows attackers to have root privileges
that enable them to modify systems and programs...
&lt;/code>&lt;/pre>&lt;p>This is the power of the system: I asked about a concept, not a keyword, and got specific, sourced, actionable results from three different books.&lt;/p>
&lt;hr>
&lt;h2 id="the-current-state-of-the-kb">The Current State of the KB&lt;/h2>
&lt;p>After the initial batch ingest:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>50 books&lt;/strong> indexed&lt;/li>
&lt;li>&lt;strong>11,757 chunks&lt;/strong> stored and embedded&lt;/li>
&lt;li>Coverage spans: penetration testing, malware analysis, forensics, identity and access, cloud security, social engineering, cryptography, threat modeling, and more&lt;/li>
&lt;/ul>
&lt;p>Some of what&amp;rsquo;s in there:&lt;/p>
&lt;ul>
&lt;li>&lt;em>Practical Malware Analysis&lt;/em> (620 chunks)&lt;/li>
&lt;li>&lt;em>Cybersecurity Threats, Malware Trends and Strategies&lt;/em> (552 chunks)&lt;/li>
&lt;li>&lt;em>Cybersecurity Attack and Defense Strategies&lt;/em> (460 chunks)&lt;/li>
&lt;li>&lt;em>Security Chaos Engineering&lt;/em> (387 chunks)&lt;/li>
&lt;li>&lt;em>Hardware Hacking Handbook&lt;/em> (378 chunks)&lt;/li>
&lt;li>&lt;em>Modern Data Protection&lt;/em> (338 chunks)&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="why-this-fits-into-pai">Why This Fits Into PAI&lt;/h2>
&lt;p>This knowledge base is part of my PAI system — Personal AI Infrastructure. The idea behind PAI is to build infrastructure that &lt;em>amplifies&lt;/em> what I can do with AI, rather than using AI one prompt at a time.&lt;/p>
&lt;p>The Security KB is a perfect example. It&amp;rsquo;s not about asking ChatGPT &amp;ldquo;explain SQL injection.&amp;rdquo; It&amp;rsquo;s about having my own curated library, chunked, embedded, and ready to surface exactly the passage I need — from books I trust, with sources I can trace back.&lt;/p>
&lt;p>When I&amp;rsquo;m working through a security challenge or studying for a certification, I can query the KB directly. Luna (my PAI assistant) can also query it as part of a larger workflow — search the KB, pull context into the prompt, and answer questions grounded in my actual library rather than generic training data.&lt;/p>
&lt;hr>
&lt;h2 id="building-it-with-claude-code">Building It With Claude Code&lt;/h2>
&lt;p>The entire CyberSecKB tool was built using Claude Code through PAI. The process:&lt;/p>
&lt;ol>
&lt;li>Described what I wanted: ingest markdown books, chunk by section, embed with OpenAI, store in pgvector&lt;/li>
&lt;li>Claude Code scaffolded the TypeScript CLI&lt;/li>
&lt;li>We hit a few real-world issues along the way:
&lt;ul>
&lt;li>The OpenAI project key needed embedding model access enabled separately&lt;/li>
&lt;li>Batch size of 2048 hit the 300k token/request limit — tuned down to 200&lt;/li>
&lt;li>The 1M tokens/minute rate limit required adding a 15-second delay between batches&lt;/li>
&lt;li>A SQL type error in the search function when no topics filter was passed&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ol>
&lt;p>Each issue was diagnosed and fixed in the same conversation. The tool went from concept to 50 books indexed in a single session.&lt;/p>
&lt;hr>
&lt;h2 id="whats-next">What&amp;rsquo;s Next&lt;/h2>
&lt;p>A few things I want to add:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tag all books with proper topics&lt;/strong> — the batch ingest skipped topic assignment; I&amp;rsquo;ll tag each book so &lt;code>--topics web&lt;/code> or &lt;code>--topics linux&lt;/code> filters actually work&lt;/li>
&lt;li>&lt;strong>Tier 1 topic files&lt;/strong> — condensed 5-15KB reference files for the most-used topics (SQLi, XSS, privilege escalation, etc.) that load directly into context&lt;/li>
&lt;li>&lt;strong>AI Security KB integration&lt;/strong> — the AI Security research KB shares the same database; queries cross both domains automatically&lt;/li>
&lt;/ul>
&lt;p>The knowledge base is live. The friction is gone. Now the books actually get used.&lt;/p>
&lt;hr>
&lt;p>&lt;em>Built with PAI, Claude Code, PostgreSQL, pgvector, and OpenAI embeddings. All processing runs locally except the embedding API calls at ingest time.&lt;/em>&lt;/p></content></item><item><title>Building an AI Conference Directory That Populates Itself</title><link>https://augmentedresilience.com/posts/augmented-resilience-posts/building-an-ai-conference-directory-that-populates-itself/</link><pubDate>Sat, 14 Mar 2026 00:00:00 +0000</pubDate><guid>https://augmentedresilience.com/posts/augmented-resilience-posts/building-an-ai-conference-directory-that-populates-itself/</guid><description>&lt;h2 id="the-problem-ai-conferences-are-everywhere-and-nowhere">The Problem: AI Conferences Are Everywhere and Nowhere&lt;/h2>
&lt;p>If you&amp;rsquo;ve ever tried to find a comprehensive list of upcoming AI conferences, you know the pain. There&amp;rsquo;s no single source. AAAI has their page. NeurIPS has theirs. ICML posts deadlines on OpenReview. Half the emerging summits only exist on LinkedIn event pages or buried in Reddit threads.&lt;/p>
&lt;p>I wanted a simple, searchable directory of AI conferences — one site where I could see what&amp;rsquo;s coming up, filter by topic, and get the key details. But I didn&amp;rsquo;t want to manually curate it. I&amp;rsquo;ve seen too many &amp;ldquo;awesome lists&amp;rdquo; on GitHub that are lovingly maintained for three months and then abandoned.&lt;/p></description><content>&lt;h2 id="the-problem-ai-conferences-are-everywhere-and-nowhere">The Problem: AI Conferences Are Everywhere and Nowhere&lt;/h2>
&lt;p>If you&amp;rsquo;ve ever tried to find a comprehensive list of upcoming AI conferences, you know the pain. There&amp;rsquo;s no single source. AAAI has their page. NeurIPS has theirs. ICML posts deadlines on OpenReview. Half the emerging summits only exist on LinkedIn event pages or buried in Reddit threads.&lt;/p>
&lt;p>I wanted a simple, searchable directory of AI conferences — one site where I could see what&amp;rsquo;s coming up, filter by topic, and get the key details. But I didn&amp;rsquo;t want to manually curate it. I&amp;rsquo;ve seen too many &amp;ldquo;awesome lists&amp;rdquo; on GitHub that are lovingly maintained for three months and then abandoned.&lt;/p>
&lt;p>What I wanted was a system that populates itself.&lt;/p>
&lt;p>So I built one. And with Claude Code running through my PAI system, the whole pipeline — from search to database to website — came together over a few focused sessions.&lt;/p>
&lt;p>Here&amp;rsquo;s the full story.&lt;/p>
&lt;hr>
&lt;h2 id="the-architecture-three-layers-zero-manual-data-entry">The Architecture: Three Layers, Zero Manual Data Entry&lt;/h2>
&lt;p>The final system has three layers, each handling a distinct responsibility:&lt;/p>
&lt;pre tabindex="0">&lt;code>SearXNG (search engine)
→ conference_tracker.py (discovery)
→ Airtable (database)
→ fetch-events.mjs (build-time fetch)
→ React + Vite site on Netlify
&lt;/code>&lt;/pre>&lt;p>Each layer is independently useful, loosely coupled, and replaceable. Let&amp;rsquo;s walk through them.&lt;/p>
&lt;hr>
&lt;h2 id="layer-1-the-tracker--finding-conferences-automatically">Layer 1: The Tracker — Finding Conferences Automatically&lt;/h2>
&lt;p>The foundation is a Python script called &lt;code>conference_tracker.py&lt;/code>. Its job is simple: search the web for AI conferences and store what it finds.&lt;/p>
&lt;h3 id="search-searxng-instead-of-google">Search: SearXNG Instead of Google&lt;/h3>
&lt;p>Rather than hitting the Google API (with its quotas and billing), I use &lt;a href="https://github.com/searxng/searxng" target="_blank" rel="noopener noreferrer">SearXNG&lt;/a>
— an open-source, self-hosted meta-search engine. It aggregates results from Google, Bing, DuckDuckGo, and others without API keys or rate limits.&lt;/p>
&lt;p>The tracker runs a curated list of search queries defined in &lt;code>config.yaml&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-yaml" data-lang="yaml">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f92672">search_queries&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> - &lt;span style="color:#e6db74">&amp;#34;AI conference 2026&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> - &lt;span style="color:#e6db74">&amp;#34;artificial intelligence conference 2026&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> - &lt;span style="color:#e6db74">&amp;#34;machine learning conference 2026&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> - &lt;span style="color:#e6db74">&amp;#34;NeurIPS 2026&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> - &lt;span style="color:#e6db74">&amp;#34;ICML 2026&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> - &lt;span style="color:#e6db74">&amp;#34;AAAI 2026&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> - &lt;span style="color:#e6db74">&amp;#34;AI summit 2026&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> - &lt;span style="color:#e6db74">&amp;#34;deep learning conference 2026&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> - &lt;span style="color:#e6db74">&amp;#34;computer vision conference 2026 CVPR&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> - &lt;span style="color:#e6db74">&amp;#34;natural language processing conference 2026&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Each query returns up to 10 results. The tracker extracts the title, URL, and snippet from each result, deduplicates against what&amp;rsquo;s already in the database, and stores new finds.&lt;/p>
&lt;h3 id="storage-airtable-as-the-source-of-truth">Storage: Airtable as the Source of Truth&lt;/h3>
&lt;p>Why Airtable? Because it&amp;rsquo;s a real database with an API, but it also has a spreadsheet-like UI for manual review. When you&amp;rsquo;re building a pipeline that discovers data automatically, you want a way to eyeball the results and clean up noise — and Airtable is perfect for that.&lt;/p>
&lt;p>The tracker writes five fields per record: &lt;code>title&lt;/code>, &lt;code>websiteUrl&lt;/code>, &lt;code>description&lt;/code>, &lt;code>Source Query&lt;/code>, and &lt;code>Date Found&lt;/code>. That&amp;rsquo;s it. Just the raw discovery data. The structured details come later.&lt;/p>
&lt;p>The deduplication is URL-based — normalized and lowercased. If we&amp;rsquo;ve already stored &lt;code>neurips.cc/2026&lt;/code>, we don&amp;rsquo;t store it again even if it appears in a different search query.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">def&lt;/span> &lt;span style="color:#a6e22e">extract_conference_info&lt;/span>(result, source_query):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">return&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#e6db74">&amp;#34;title&amp;#34;&lt;/span>: result[&lt;span style="color:#e6db74">&amp;#34;title&amp;#34;&lt;/span>][:&lt;span style="color:#ae81ff">200&lt;/span>],
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#e6db74">&amp;#34;websiteUrl&amp;#34;&lt;/span>: result[&lt;span style="color:#e6db74">&amp;#34;url&amp;#34;&lt;/span>],
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#e6db74">&amp;#34;description&amp;#34;&lt;/span>: result[&lt;span style="color:#e6db74">&amp;#34;snippet&amp;#34;&lt;/span>][:&lt;span style="color:#ae81ff">1000&lt;/span>],
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#e6db74">&amp;#34;Source Query&amp;#34;&lt;/span>: source_query,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#e6db74">&amp;#34;Date Found&amp;#34;&lt;/span>: datetime&lt;span style="color:#f92672">.&lt;/span>now(timezone&lt;span style="color:#f92672">.&lt;/span>utc)&lt;span style="color:#f92672">.&lt;/span>strftime(&lt;span style="color:#e6db74">&amp;#34;%Y-%m-&lt;/span>&lt;span style="color:#e6db74">%d&lt;/span>&lt;span style="color:#e6db74">&amp;#34;&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>After one run, we had 87 unique conference records. The real stuff — NeurIPS, ICML, CVPR, AAAI — alongside smaller but interesting events like the Quantum AI and NLP Conference, Deep Learning Indaba, and the Wharton Human-AI Research summit.&lt;/p>
&lt;hr>
&lt;h2 id="layer-2-the-website--react--vite-on-netlify">Layer 2: The Website — React + Vite on Netlify&lt;/h2>
&lt;p>The directory itself is a React app built with Vite and deployed on Netlify. It&amp;rsquo;s a single-page app with search, tag filtering, and individual event pages.&lt;/p>
&lt;p>The key architectural decision: &lt;strong>data is fetched at build time, not runtime.&lt;/strong> A prebuild script (&lt;code>fetch-events.mjs&lt;/code>) pulls conference data from the database and writes it to a &lt;code>data.ts&lt;/code> file that Vite bundles into the site. This means:&lt;/p>
&lt;ul>
&lt;li>No API keys exposed in the browser&lt;/li>
&lt;li>No CORS issues&lt;/li>
&lt;li>Instant page loads (data is already in the bundle)&lt;/li>
&lt;li>The site works even if Airtable is temporarily down&lt;/li>
&lt;/ul>
&lt;p>The prebuild hook in &lt;code>package.json&lt;/code> makes this automatic:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-json" data-lang="json">&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#f92672">&amp;#34;scripts&amp;#34;&lt;/span>: {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#f92672">&amp;#34;fetch-events&amp;#34;&lt;/span>: &lt;span style="color:#e6db74">&amp;#34;bun scripts/fetch-events.mjs&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#f92672">&amp;#34;prebuild&amp;#34;&lt;/span>: &lt;span style="color:#e6db74">&amp;#34;bun scripts/fetch-events.mjs&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#f92672">&amp;#34;build&amp;#34;&lt;/span>: &lt;span style="color:#e6db74">&amp;#34;vite build&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Every time Netlify builds the site, it automatically fetches the latest data from Airtable. Fresh data on every deploy.&lt;/p>
&lt;hr>
&lt;h2 id="the-middleman-problem-cutting-google-sheets">The Middleman Problem: Cutting Google Sheets&lt;/h2>
&lt;p>Here&amp;rsquo;s where the story gets interesting.&lt;/p>
&lt;p>The original pipeline had an extra step: Airtable → Google Sheets → website. The &lt;code>fetch-events.mjs&lt;/code> script was pulling from a published Google Sheet CSV. Why? Because when I first prototyped the site, I started with a spreadsheet. It was quick and easy.&lt;/p>
&lt;p>But once the conference tracker was writing directly to Airtable, Google Sheets became a middleman with no purpose. Data had to be synced from Airtable to Sheets (manually or via Zapier), and that sync was another thing that could break.&lt;/p>
&lt;p>The fix was straightforward: teach &lt;code>fetch-events.mjs&lt;/code> to talk directly to the Airtable API.&lt;/p>
&lt;h3 id="airtables-rest-api">Airtable&amp;rsquo;s REST API&lt;/h3>
&lt;p>The Airtable API is clean. A single GET request returns records as JSON:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">const&lt;/span> &lt;span style="color:#a6e22e">url&lt;/span> &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#66d9ef">new&lt;/span> &lt;span style="color:#a6e22e">URL&lt;/span>(&lt;span style="color:#e6db74">`https://api.airtable.com/v0/&lt;/span>&lt;span style="color:#e6db74">${&lt;/span>&lt;span style="color:#a6e22e">baseId&lt;/span>&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74">/&lt;/span>&lt;span style="color:#e6db74">${&lt;/span>&lt;span style="color:#a6e22e">tableId&lt;/span>&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74">`&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">const&lt;/span> &lt;span style="color:#a6e22e">resp&lt;/span> &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#66d9ef">await&lt;/span> &lt;span style="color:#a6e22e">fetch&lt;/span>(&lt;span style="color:#a6e22e">url&lt;/span>.&lt;span style="color:#a6e22e">toString&lt;/span>(), {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#a6e22e">headers&lt;/span>&lt;span style="color:#f92672">:&lt;/span> { &lt;span style="color:#a6e22e">Authorization&lt;/span>&lt;span style="color:#f92672">:&lt;/span> &lt;span style="color:#e6db74">`Bearer &lt;/span>&lt;span style="color:#e6db74">${&lt;/span>&lt;span style="color:#a6e22e">pat&lt;/span>&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74">`&lt;/span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>});
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">const&lt;/span> &lt;span style="color:#a6e22e">data&lt;/span> &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#66d9ef">await&lt;/span> &lt;span style="color:#a6e22e">resp&lt;/span>.&lt;span style="color:#a6e22e">json&lt;/span>();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#75715e">// data.records = [{ id, fields: { title, date, ... } }]
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The one gotcha: Airtable paginates at 100 records. You need to follow the &lt;code>offset&lt;/code> token:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-javascript" data-lang="javascript">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">async&lt;/span> &lt;span style="color:#66d9ef">function&lt;/span> &lt;span style="color:#a6e22e">fetchFromAirtable&lt;/span>(&lt;span style="color:#a6e22e">pat&lt;/span>, &lt;span style="color:#a6e22e">baseId&lt;/span>, &lt;span style="color:#a6e22e">tableId&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">const&lt;/span> &lt;span style="color:#a6e22e">allRecords&lt;/span> &lt;span style="color:#f92672">=&lt;/span> [];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">let&lt;/span> &lt;span style="color:#a6e22e">offset&lt;/span> &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#66d9ef">null&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">do&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">const&lt;/span> &lt;span style="color:#a6e22e">url&lt;/span> &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#66d9ef">new&lt;/span> &lt;span style="color:#a6e22e">URL&lt;/span>(&lt;span style="color:#e6db74">`https://api.airtable.com/v0/&lt;/span>&lt;span style="color:#e6db74">${&lt;/span>&lt;span style="color:#a6e22e">baseId&lt;/span>&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74">/&lt;/span>&lt;span style="color:#e6db74">${&lt;/span>&lt;span style="color:#a6e22e">tableId&lt;/span>&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74">`&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">if&lt;/span> (&lt;span style="color:#a6e22e">offset&lt;/span>) &lt;span style="color:#a6e22e">url&lt;/span>.&lt;span style="color:#a6e22e">searchParams&lt;/span>.&lt;span style="color:#a6e22e">set&lt;/span>(&lt;span style="color:#e6db74">&amp;#39;offset&amp;#39;&lt;/span>, &lt;span style="color:#a6e22e">offset&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">const&lt;/span> &lt;span style="color:#a6e22e">resp&lt;/span> &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#66d9ef">await&lt;/span> &lt;span style="color:#a6e22e">fetch&lt;/span>(&lt;span style="color:#a6e22e">url&lt;/span>.&lt;span style="color:#a6e22e">toString&lt;/span>(), {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#a6e22e">headers&lt;/span>&lt;span style="color:#f92672">:&lt;/span> { &lt;span style="color:#a6e22e">Authorization&lt;/span>&lt;span style="color:#f92672">:&lt;/span> &lt;span style="color:#e6db74">`Bearer &lt;/span>&lt;span style="color:#e6db74">${&lt;/span>&lt;span style="color:#a6e22e">pat&lt;/span>&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74">`&lt;/span> },
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">const&lt;/span> &lt;span style="color:#a6e22e">data&lt;/span> &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#66d9ef">await&lt;/span> &lt;span style="color:#a6e22e">resp&lt;/span>.&lt;span style="color:#a6e22e">json&lt;/span>();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#a6e22e">allRecords&lt;/span>.&lt;span style="color:#a6e22e">push&lt;/span>(...&lt;span style="color:#a6e22e">data&lt;/span>.&lt;span style="color:#a6e22e">records&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#a6e22e">offset&lt;/span> &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#a6e22e">data&lt;/span>.&lt;span style="color:#a6e22e">offset&lt;/span> &lt;span style="color:#f92672">||&lt;/span> &lt;span style="color:#66d9ef">null&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> } &lt;span style="color:#66d9ef">while&lt;/span> (&lt;span style="color:#a6e22e">offset&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">return&lt;/span> &lt;span style="color:#a6e22e">allRecords&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="graceful-fallback">Graceful Fallback&lt;/h3>
&lt;p>I kept the Google Sheets path as a fallback. The &lt;code>main()&lt;/code> function uses a priority chain:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Airtable&lt;/strong> — if &lt;code>AIRTABLE_PAT&lt;/code>, &lt;code>AIRTABLE_BASE_ID&lt;/code>, &lt;code>AIRTABLE_TABLE_ID&lt;/code> are set&lt;/li>
&lt;li>&lt;strong>Google Sheets&lt;/strong> — if &lt;code>GOOGLE_SHEET_CSV_URL&lt;/code> is set&lt;/li>
&lt;li>&lt;strong>Fallback events&lt;/strong> — hardcoded sample data so the build never fails&lt;/li>
&lt;/ol>
&lt;p>This means you can&amp;rsquo;t break the site by misconfiguring a data source. The build always succeeds.&lt;/p>
&lt;hr>
&lt;h2 id="layer-3-the-enrichment--ai-powered-data-extraction">Layer 3: The Enrichment — AI-Powered Data Extraction&lt;/h2>
&lt;p>This is where things got really interesting.&lt;/p>
&lt;p>After cutting Google Sheets, I had 87 conference records in Airtable. But they only had three useful fields: title, description, and URL. No dates. No locations. No tags. The site worked, but every event card was sparse — no way to filter by date or location, no tags to browse by topic.&lt;/p>
&lt;p>Filling in 87 records by hand? No thanks.&lt;/p>
&lt;h3 id="the-idea-visit-each-url-and-ask-ai-to-extract-the-data">The Idea: Visit Each URL and Ask AI to Extract the Data&lt;/h3>
&lt;p>The approach: for each conference record, fetch its web page, extract the text content, and use AI inference to pull out structured fields like date, location, organizer, and tags.&lt;/p>
&lt;p>I built an enrichment script — &lt;code>enrich_conferences.py&lt;/code> — that sits alongside the tracker in the same project.&lt;/p>
&lt;h3 id="step-1-fetch-and-clean-the-page">Step 1: Fetch and Clean the Page&lt;/h3>
&lt;p>Each conference URL gets fetched with &lt;code>requests&lt;/code>, then cleaned with BeautifulSoup. Navigation, footers, scripts, and styling get stripped, leaving just the text content:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">def&lt;/span> &lt;span style="color:#a6e22e">fetch_page_text&lt;/span>(url, timeout&lt;span style="color:#f92672">=&lt;/span>&lt;span style="color:#ae81ff">15&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> resp &lt;span style="color:#f92672">=&lt;/span> requests&lt;span style="color:#f92672">.&lt;/span>get(url, headers&lt;span style="color:#f92672">=&lt;/span>headers, timeout&lt;span style="color:#f92672">=&lt;/span>timeout)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> soup &lt;span style="color:#f92672">=&lt;/span> BeautifulSoup(resp&lt;span style="color:#f92672">.&lt;/span>text, &lt;span style="color:#e6db74">&amp;#34;html.parser&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">for&lt;/span> tag &lt;span style="color:#f92672">in&lt;/span> soup([&lt;span style="color:#e6db74">&amp;#34;script&amp;#34;&lt;/span>, &lt;span style="color:#e6db74">&amp;#34;style&amp;#34;&lt;/span>, &lt;span style="color:#e6db74">&amp;#34;nav&amp;#34;&lt;/span>, &lt;span style="color:#e6db74">&amp;#34;footer&amp;#34;&lt;/span>, &lt;span style="color:#e6db74">&amp;#34;header&amp;#34;&lt;/span>, &lt;span style="color:#e6db74">&amp;#34;aside&amp;#34;&lt;/span>]):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> tag&lt;span style="color:#f92672">.&lt;/span>decompose()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> text &lt;span style="color:#f92672">=&lt;/span> soup&lt;span style="color:#f92672">.&lt;/span>get_text(separator&lt;span style="color:#f92672">=&lt;/span>&lt;span style="color:#e6db74">&amp;#34;&lt;/span>&lt;span style="color:#ae81ff">\n&lt;/span>&lt;span style="color:#e6db74">&amp;#34;&lt;/span>, strip&lt;span style="color:#f92672">=&lt;/span>&lt;span style="color:#66d9ef">True&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> lines &lt;span style="color:#f92672">=&lt;/span> [line&lt;span style="color:#f92672">.&lt;/span>strip() &lt;span style="color:#66d9ef">for&lt;/span> line &lt;span style="color:#f92672">in&lt;/span> text&lt;span style="color:#f92672">.&lt;/span>splitlines() &lt;span style="color:#66d9ef">if&lt;/span> line&lt;span style="color:#f92672">.&lt;/span>strip()]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">return&lt;/span> &lt;span style="color:#e6db74">&amp;#34;&lt;/span>&lt;span style="color:#ae81ff">\n&lt;/span>&lt;span style="color:#e6db74">&amp;#34;&lt;/span>&lt;span style="color:#f92672">.&lt;/span>join(lines)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="step-2-ai-extraction-via-pai-inference">Step 2: AI Extraction via PAI Inference&lt;/h3>
&lt;p>The cleaned text gets sent to Claude (via PAI&amp;rsquo;s Inference tool) with a structured extraction prompt. The prompt is specific about what to extract and what format to use:&lt;/p>
&lt;pre tabindex="0">&lt;code>Given text from a conference web page, extract these fields as JSON:
{
&amp;#34;date&amp;#34;: &amp;#34;human-readable date like &amp;#39;May 5-6, 2026&amp;#39;&amp;#34;,
&amp;#34;endDate&amp;#34;: &amp;#34;ISO end date like &amp;#39;2026-05-06&amp;#39;&amp;#34;,
&amp;#34;location&amp;#34;: &amp;#34;City, State/Country&amp;#34;,
&amp;#34;venue&amp;#34;: &amp;#34;venue name&amp;#34;,
&amp;#34;price&amp;#34;: &amp;#34;ticket price or &amp;#39;Free&amp;#39;&amp;#34;,
&amp;#34;organizer&amp;#34;: &amp;#34;organizing body&amp;#34;,
&amp;#34;tags&amp;#34;: &amp;#34;comma-separated topic tags (max 4)&amp;#34;
}
&lt;/code>&lt;/pre>&lt;p>One critical addition: if the page is a &lt;strong>list of conferences&lt;/strong> (like &amp;ldquo;Top 10 AI Conferences of 2026&amp;rdquo;), the AI returns &lt;code>{&amp;quot;is_list_page&amp;quot;: true}&lt;/code> and the script skips it. This was essential — about 15% of our URLs were aggregator pages, not individual conference pages.&lt;/p>
&lt;h3 id="step-3-write-back-to-airtable">Step 3: Write Back to Airtable&lt;/h3>
&lt;p>Non-empty extracted fields get PATCHed back to Airtable. The script only writes fields that actually exist in the table schema — a lesson learned the hard way when &lt;code>venue&lt;/code> and &lt;code>imageUrl&lt;/code> threw 422 errors because those columns hadn&amp;rsquo;t been created yet.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">def&lt;/span> &lt;span style="color:#a6e22e">build_patch_fields&lt;/span>(extracted, allowed_fields):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">if&lt;/span> extracted&lt;span style="color:#f92672">.&lt;/span>get(&lt;span style="color:#e6db74">&amp;#34;is_list_page&amp;#34;&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">return&lt;/span> &lt;span style="color:#66d9ef">None&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> patch &lt;span style="color:#f92672">=&lt;/span> {}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">for&lt;/span> key &lt;span style="color:#f92672">in&lt;/span> [&lt;span style="color:#e6db74">&amp;#34;date&amp;#34;&lt;/span>, &lt;span style="color:#e6db74">&amp;#34;endDate&amp;#34;&lt;/span>, &lt;span style="color:#e6db74">&amp;#34;location&amp;#34;&lt;/span>, &lt;span style="color:#e6db74">&amp;#34;venue&amp;#34;&lt;/span>, &lt;span style="color:#e6db74">&amp;#34;price&amp;#34;&lt;/span>, &lt;span style="color:#e6db74">&amp;#34;organizer&amp;#34;&lt;/span>, &lt;span style="color:#e6db74">&amp;#34;tags&amp;#34;&lt;/span>]:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">if&lt;/span> key &lt;span style="color:#f92672">not&lt;/span> &lt;span style="color:#f92672">in&lt;/span> allowed_fields:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> val &lt;span style="color:#f92672">=&lt;/span> extracted&lt;span style="color:#f92672">.&lt;/span>get(key, &lt;span style="color:#e6db74">&amp;#34;&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">if&lt;/span> isinstance(val, str) &lt;span style="color:#f92672">and&lt;/span> val&lt;span style="color:#f92672">.&lt;/span>strip():
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> patch[key] &lt;span style="color:#f92672">=&lt;/span> val&lt;span style="color:#f92672">.&lt;/span>strip()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">return&lt;/span> patch &lt;span style="color:#66d9ef">if&lt;/span> patch &lt;span style="color:#66d9ef">else&lt;/span> &lt;span style="color:#66d9ef">None&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="the-results">The Results&lt;/h3>
&lt;p>Running the enrichment script across all 87 records:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Outcome&lt;/th>
&lt;th>Count&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Records enriched&lt;/td>
&lt;td>48&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>List/aggregator pages (correctly skipped)&lt;/td>
&lt;td>12&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>No extractable fields (social media, OpenReview, etc.)&lt;/td>
&lt;td>11&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Errors (timeouts, HTTP 403s)&lt;/td>
&lt;td>16&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>After enrichment:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Field&lt;/th>
&lt;th>Records populated&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Date&lt;/td>
&lt;td>42&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Location&lt;/td>
&lt;td>41&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tags&lt;/td>
&lt;td>47&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Organizer&lt;/td>
&lt;td>27&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Price&lt;/td>
&lt;td>4&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>From zero structured data to a directory where most events have dates, locations, and topic tags — without opening a single conference website manually.&lt;/p>
&lt;p>Some highlights from the extraction:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>NeurIPS 2026:&lt;/strong> December 6-12, Sydney, Australia — Deep Learning, Research, Algorithms, LLMs&lt;/li>
&lt;li>&lt;strong>CVPR 2026:&lt;/strong> June 3-7, Denver, CO — Computer Vision, Deep Learning, Research&lt;/li>
&lt;li>&lt;strong>ICML 2026:&lt;/strong> July 6-11, Seoul, South Korea — LLMs, Computer Vision, NLP, Robotics&lt;/li>
&lt;li>&lt;strong>AI Council 2026:&lt;/strong> May 12-14, San Francisco, CA — Generative AI, ML Ops, AI Safety&lt;/li>
&lt;li>&lt;strong>MIDL 2026:&lt;/strong> July 8-10, Taipei — Deep Learning, Healthcare AI, Computer Vision&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="the-pipeline-today">The Pipeline Today&lt;/h2>
&lt;p>Here&amp;rsquo;s what the full system looks like now:&lt;/p>
&lt;pre tabindex="0">&lt;code>SearXNG (self-hosted search)
→ conference_tracker.py (Python — discovers conferences)
→ Airtable (source of truth — 87 records)
→ enrich_conferences.py (Python — AI-powered field extraction)
→ Airtable (now with dates, locations, tags)
→ fetch-events.mjs (Node — build-time data fetch)
→ data.ts (bundled into the site)
→ React + Vite app on Netlify
&lt;/code>&lt;/pre>&lt;p>The tracker discovers. The enricher structures. The fetcher delivers. The site displays. Each piece runs independently and can be re-run at any time.&lt;/p>
&lt;p>The enrichment script is idempotent — it only processes records where the &lt;code>date&lt;/code> field is empty, so running it again only touches new or previously-failed records.&lt;/p>
&lt;hr>
&lt;h2 id="what-id-do-differently-and-whats-next">What I&amp;rsquo;d Do Differently (And What&amp;rsquo;s Next)&lt;/h2>
&lt;h3 id="the-timeout-problem">The Timeout Problem&lt;/h3>
&lt;p>About 16 records hit the 25-second inference timeout. The fast tier (Haiku) is quick but occasionally chokes on pages with dense, complex content. A retry mechanism using the standard tier (Sonnet) for failed records would catch most of these.&lt;/p>
&lt;h3 id="missing-table-columns">Missing Table Columns&lt;/h3>
&lt;p>The &lt;code>venue&lt;/code> and &lt;code>imageUrl&lt;/code> fields don&amp;rsquo;t exist in the Airtable table yet. The enrichment script extracts venue names beautifully (The Venetian for Ai4, COEX Convention Center for ICML, Dongguk University for AAAI Summer), but the data gets dropped because the columns aren&amp;rsquo;t there. A quick table schema update in the Airtable UI fixes this.&lt;/p>
&lt;h3 id="scheduled-runs">Scheduled Runs&lt;/h3>
&lt;p>Right now, both the tracker and enricher are manual. The natural next step is scheduling — run the tracker daily to discover new conferences, the enricher on new records, and trigger a Netlify deploy afterward. The Netlify build hook is already configured; it just needs a cron job or GitHub Action to call it.&lt;/p>
&lt;h3 id="data-quality">Data Quality&lt;/h3>
&lt;p>Some records are noise — Reddit discussion threads, Amazon Science blog posts, Twitter/X profiles. A quality filter (either rule-based on URL patterns or AI-powered) would clean the dataset before enrichment runs.&lt;/p>
&lt;hr>
&lt;h2 id="lessons-learned">Lessons Learned&lt;/h2>
&lt;h3 id="1-eliminate-middlemen-early">1. Eliminate Middlemen Early&lt;/h3>
&lt;p>Google Sheets added zero value once Airtable was in the picture. But it lingered because it was the &amp;ldquo;original&amp;rdquo; approach. Every extra hop in a pipeline is a thing that can break, a thing that needs syncing, and a thing that slows you down. Cut it.&lt;/p>
&lt;h3 id="2-build-time-data-fetching-is-underrated">2. Build-Time Data Fetching Is Underrated&lt;/h3>
&lt;p>Pulling data at build time instead of runtime means no API keys in the browser, no loading spinners, and no CORS headaches. For data that changes daily (not per-second), this is the right architecture.&lt;/p>
&lt;h3 id="3-ai-extraction-beats-manual-curation">3. AI Extraction Beats Manual Curation&lt;/h3>
&lt;p>Using AI to extract structured data from unstructured web pages isn&amp;rsquo;t perfect — we got 48 out of 87 records enriched, not 87 out of 87. But it took 20 minutes of runtime versus what would have been hours of manual work. And the script is re-runnable. Improvement is incremental.&lt;/p>
&lt;h3 id="4-detect-your-datas-shape-before-writing">4. Detect Your Data&amp;rsquo;s Shape Before Writing&lt;/h3>
&lt;p>The Airtable 422 errors on &lt;code>venue&lt;/code> were entirely preventable. The enrichment script now probes the table schema at startup and only writes to fields that exist. Defensive coding at system boundaries saves debugging time.&lt;/p>
&lt;h3 id="5-list-page-detection-is-essential-for-web-scraping-pipelines">5. List Page Detection Is Essential for Web Scraping Pipelines&lt;/h3>
&lt;p>When you&amp;rsquo;re scraping URLs from search results, a significant percentage will be aggregator pages (&amp;ldquo;Top 10 Best AI Conferences&amp;rdquo;) rather than individual event pages. If you don&amp;rsquo;t detect and skip these, you&amp;rsquo;ll corrupt your dataset with merged data from multiple events. The &lt;code>is_list_page&lt;/code> flag in the AI extraction prompt was one of the highest-value additions to the whole pipeline.&lt;/p>
&lt;hr>
&lt;h2 id="the-bigger-picture">The Bigger Picture&lt;/h2>
&lt;p>This project is a miniature version of a pattern I keep coming back to: &lt;strong>systems that compound.&lt;/strong>&lt;/p>
&lt;p>The tracker runs once and discovers 87 conferences. The enricher runs once and structures 48 of them. The next time the tracker runs, it discovers only &lt;em>new&lt;/em> conferences (deduplication handles the rest). The next time the enricher runs, it only processes records it hasn&amp;rsquo;t touched yet.&lt;/p>
&lt;p>Every run makes the dataset better without redoing previous work. That&amp;rsquo;s the whole point of building infrastructure instead of doing things manually — you invest upfront so the system improves over time with minimal additional effort.&lt;/p>
&lt;p>Working with Claude through PAI made each layer come together faster than I expected. The tracker, the Airtable integration, the Google Sheets elimination, the enrichment script — each was a focused session where the AI handled the implementation details while I focused on architecture decisions.&lt;/p>
&lt;p>That&amp;rsquo;s the augmented part of Augmented Resilience. Not replacing the thinking — amplifying it.&lt;/p></content></item><item><title>When Your PDF Workflow Breaks - Building a Markdown Converter with Claude Code</title><link>https://augmentedresilience.com/posts/augmented-resilience-posts/building-a-pdf-to-markdown-converter-with-claude-code/</link><pubDate>Wed, 18 Feb 2026 00:00:00 +0000</pubDate><guid>https://augmentedresilience.com/posts/augmented-resilience-posts/building-a-pdf-to-markdown-converter-with-claude-code/</guid><description>&lt;h2 id="the-problem-pdfs-are-knowledge-prisons">The Problem: PDFs Are Knowledge Prisons&lt;/h2>
&lt;p>You know that feeling when you download a brilliant research paper, only to realize you can&amp;rsquo;t easily feed it into your AI workflow? Or when you want to add documentation to your knowledge base, but it&amp;rsquo;s locked in a format that doesn&amp;rsquo;t play well with version control or LLM tools?&lt;/p>
&lt;p>Yeah, I was there last week.&lt;/p>
&lt;p>I had just downloaded a fascinating 1.3MB research paper on Generative Engine Optimization and wanted to process it with my AI tools. But PDFs are terrible for this. They&amp;rsquo;re designed for &lt;em>printing&lt;/em>, not for &lt;em>processing&lt;/em>. What I needed was Markdown—clean, portable, AI-friendly Markdown.&lt;/p></description><content>&lt;h2 id="the-problem-pdfs-are-knowledge-prisons">The Problem: PDFs Are Knowledge Prisons&lt;/h2>
&lt;p>You know that feeling when you download a brilliant research paper, only to realize you can&amp;rsquo;t easily feed it into your AI workflow? Or when you want to add documentation to your knowledge base, but it&amp;rsquo;s locked in a format that doesn&amp;rsquo;t play well with version control or LLM tools?&lt;/p>
&lt;p>Yeah, I was there last week.&lt;/p>
&lt;p>I had just downloaded a fascinating 1.3MB research paper on Generative Engine Optimization and wanted to process it with my AI tools. But PDFs are terrible for this. They&amp;rsquo;re designed for &lt;em>printing&lt;/em>, not for &lt;em>processing&lt;/em>. What I needed was Markdown—clean, portable, AI-friendly Markdown.&lt;/p>
&lt;p>So I built a converter. And with Claude Code as my copilot through the PAI (Personal AI Infrastructure) system, the whole thing took less than 30 minutes.&lt;/p>
&lt;p>Here&amp;rsquo;s how it went down.&lt;/p>
&lt;hr>
&lt;h2 id="why-markdown-is-better-than-pdf-for-llms">Why Markdown is Better Than PDF for LLMs&lt;/h2>
&lt;p>Before diving into the build, let&amp;rsquo;s answer the obvious question: &lt;em>why bother converting?&lt;/em> Can&amp;rsquo;t LLMs just read PDFs directly?&lt;/p>
&lt;p>Technically, yes. But the results are significantly worse, and the reasons are fundamental to how PDFs work.&lt;/p>
&lt;h3 id="pdfs-are-layout-first-not-structure-first">PDFs Are Layout-First, Not Structure-First&lt;/h3>
&lt;p>PDFs were designed to describe &lt;em>where things appear on a page&lt;/em>, not &lt;em>what they mean&lt;/em>. As Steven Howard explains in &lt;a href="https://untetheredai.substack.com/p/why-pdfs-fail-under-llm-parsing" target="_blank" rel="noopener noreferrer">Why PDFs Fail Under LLM Parsing&lt;/a>
:&lt;/p>
&lt;blockquote>
&lt;p>&amp;ldquo;Table cells with wrapped text insert hard line breaks that fragment token continuity and break logical row recognition. Headers and footers simply add noise to the context when used with LLMs. Sentences are split with arbitrary CR/LFs making it very difficult to find paragraph boundaries.&amp;rdquo;&lt;/p>&lt;/blockquote>
&lt;p>This architectural mismatch — a format designed for printing being fed into a system designed for understanding — causes cascading problems downstream.&lt;/p>
&lt;h3 id="the-token-efficiency-problem">The Token Efficiency Problem&lt;/h3>
&lt;p>Every token your LLM processes costs money and consumes context window space. PDF extraction wastes both.&lt;/p>
&lt;p>According to analysis from &lt;a href="https://markdownconverters.com/blog/pdf-vs-markdown-ai-tokens" target="_blank" rel="noopener noreferrer">MarkdownConverters&lt;/a>
, &lt;strong>Markdown saves up to 70% more tokens compared to extracted PDF text&lt;/strong> for the same content. The culprit: PDF extraction introduces formatting artifacts, metadata noise, headers/footers, and encoding remnants that all consume tokens without adding semantic value.&lt;/p>
&lt;p>To put that in practical terms: a PDF that would use 10,000 tokens might only need 3,000 tokens when properly converted to Markdown. At scale, this compounds dramatically.&lt;/p>
&lt;h3 id="the-rag-performance-problem">The RAG Performance Problem&lt;/h3>
&lt;p>If you&amp;rsquo;re building Retrieval Augmented Generation (RAG) systems — using documents as a knowledge base for AI — document format directly impacts answer quality.&lt;/p>
&lt;p>The research here is compelling:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Academic validation&lt;/strong>: A 2024 paper on arXiv (&lt;a href="https://arxiv.org/abs/2401.12599" target="_blank" rel="noopener noreferrer">Revolutionizing RAG with Enhanced PDF Structure Recognition&lt;/a>
) found that &amp;ldquo;the low accuracy of PDF parsing significantly impacts the effectiveness of professional knowledge-based QA.&amp;rdquo;&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Industry validation&lt;/strong>: NVIDIA&amp;rsquo;s technical blog documents how their NeMo Retriever pipeline converts extracted content to Markdown specifically because it &amp;ldquo;preserves row/column relationships in an LLM-native format, significantly reducing numeric hallucination&amp;rdquo; — and &lt;strong>reduces incorrect answers by 50%&lt;/strong>. (&lt;a href="https://developer.nvidia.com/blog/approaches-to-pdf-data-extraction-for-information-retrieval/" target="_blank" rel="noopener noreferrer">NVIDIA: Approaches to PDF Data Extraction for Information Retrieval&lt;/a>
)&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Chunking quality&lt;/strong>: Analysis from &lt;a href="https://medium.com/data-science/improved-rag-document-processing-with-markdown-426a2e0dd82b" target="_blank" rel="noopener noreferrer">Towards Data Science&lt;/a>
shows that Markdown&amp;rsquo;s heading structure (&lt;code>#&lt;/code>, &lt;code>##&lt;/code>, &lt;code>###&lt;/code>) produces semantically meaningful chunks, while PDF-based chunking relies on arbitrary page breaks and heuristics.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Retrieval failure rates&lt;/strong>: Unstructured.io&amp;rsquo;s &lt;a href="https://unstructured.io/blog/contextual-chunking-in-unstructured-platform-boost-your-rag-retrieval-accuracy" target="_blank" rel="noopener noreferrer">research on contextual chunking&lt;/a>
— tested across 5,563 question-answer pairs — showed an &lt;strong>84% reduction in retrieval failure rates&lt;/strong> when using structure-aware chunking (the kind Markdown enables natively).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Real-world outcomes&lt;/strong>: The 2025 Semrush AI Index, cited by &lt;a href="https://developer.webex.com/blog/boosting-ai-performance-the-power-of-llm-friendly-content-in-markdown" target="_blank" rel="noopener noreferrer">Webex Developers Blog&lt;/a>
, found that 72% of top AI-indexed articles used Markdown or Markdown-like structures, achieving &lt;strong>34% higher retrieval accuracy&lt;/strong> across ChatGPT, Perplexity, and Gemini.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h3 id="the-bottom-line">The Bottom Line&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Impact&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Token reduction&lt;/td>
&lt;td>Up to 70% fewer tokens vs PDF extraction&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Incorrect answers in RAG&lt;/td>
&lt;td>50% reduction (NVIDIA NeMo)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Retrieval failure rates&lt;/td>
&lt;td>84% reduction (Unstructured.io)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Retrieval accuracy&lt;/td>
&lt;td>34% higher (Semrush AI Index 2025)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Markdown isn&amp;rsquo;t just more convenient — it&amp;rsquo;s meaningfully better for AI. Converting your document libraries is one of the highest-ROI steps you can take before building any LLM-powered workflow.&lt;/p>
&lt;hr>
&lt;h2 id="the-first-failure-when-bleeding-edge-python-bites-back">The First Failure: When Bleeding-Edge Python Bites Back&lt;/h2>
&lt;p>I&amp;rsquo;m running Python 3.14.2—the latest release, barely a few weeks old. Modern, shiny, cutting-edge. Perfect, right?&lt;/p>
&lt;p>Not quite.&lt;/p>
&lt;p>My first instinct was to use &lt;code>marker-pdf&lt;/code>, a high-performance converter optimized for scientific papers and books. It looked perfect on paper (pun intended). But when I tried to install it:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Building wheel for Pillow (pyproject.toml): finished with status &amp;#39;error&amp;#39;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Ugh.&lt;/p>
&lt;p>Turns out, &lt;code>marker-pdf&lt;/code> depends on Pillow (the Python imaging library), and Pillow hasn&amp;rsquo;t built binary wheels for Python 3.14 yet. I could have downgraded Python. I could have fought with source compilation. But why?&lt;/p>
&lt;p>&lt;strong>This is where working with Claude Code really shines.&lt;/strong> Instead of going down a rabbit hole trying to force marker-pdf to work, Claude suggested pivoting to &lt;strong>PyMuPDF4LLM&lt;/strong>—a mature, actively maintained library specifically designed for AI/LLM workflows.&lt;/p>
&lt;p>And it just worked.&lt;/p>
&lt;hr>
&lt;h2 id="the-solution-pymupdf4llm">The Solution: PyMuPDF4LLM&lt;/h2>
&lt;p>PyMuPDF4LLM turned out to be exactly what I needed:&lt;/p>
&lt;ul>
&lt;li>Works flawlessly with Python 3.14 (no compilation errors)&lt;/li>
&lt;li>Fast and accurate conversion&lt;/li>
&lt;li>Built specifically for feeding documents into LLMs&lt;/li>
&lt;li>Clean, simple API&lt;/li>
&lt;li>Actively maintained by the PyMuPDF team&lt;/li>
&lt;/ul>
&lt;p>The installation was literally:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>pip install pymupdf4llm
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Five seconds later, I was ready to go.&lt;/p>
&lt;hr>
&lt;h2 id="building-the-tool-first-principles-thinking">Building the Tool: First Principles Thinking&lt;/h2>
&lt;p>As someone new to the CLI world, I&amp;rsquo;ve been learning to think through project structure from first principles. Where should this live? How should it be organized?&lt;/p>
&lt;p>With Claude&amp;rsquo;s guidance, I chose &lt;code>/Users/dsa/projects/pdf-to-markdown/&lt;/code> for a few key reasons:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Separation of Concerns:&lt;/strong> Tool projects should be separate from my main workspace&lt;/li>
&lt;li>&lt;strong>Discoverability:&lt;/strong> Clear, descriptive naming means I&amp;rsquo;ll find it again in 6 months&lt;/li>
&lt;li>&lt;strong>Reusability:&lt;/strong> This structure works both as a CLI tool AND as a library I could import later&lt;/li>
&lt;/ol>
&lt;p>The project structure ended up simple but complete:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>pdf-to-markdown/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>├── README.md # Documentation
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>├── venv/ # Isolated Python environment
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>├── input/ # Test PDFs
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>├── output/ # Generated markdown
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>├── pdf2md # CLI wrapper script
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>└── requirements.txt # Dependencies
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="the-code-a-simple-but-powerful-cli">The Code: A Simple but Powerful CLI&lt;/h2>
&lt;p>I wanted a tool I could actually use—something with a clean command-line interface that handles the common cases elegantly. Working with Claude through PAI, we created a Python script that does exactly that:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#75715e">#!/usr/bin/env python3&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#e6db74">&amp;#34;&amp;#34;&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#e6db74">PDF to Markdown Converter
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#e6db74">A simple CLI tool to convert PDF files to Markdown using PyMuPDF4LLM
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#e6db74">&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f92672">import&lt;/span> sys
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f92672">import&lt;/span> os
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f92672">from&lt;/span> pathlib &lt;span style="color:#f92672">import&lt;/span> Path
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f92672">import&lt;/span> pymupdf4llm
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f92672">import&lt;/span> pymupdf
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f92672">from&lt;/span> tqdm &lt;span style="color:#f92672">import&lt;/span> tqdm
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">def&lt;/span> &lt;span style="color:#a6e22e">convert_pdf_to_markdown&lt;/span>(pdf_path: str, output_path: str &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#66d9ef">None&lt;/span>) &lt;span style="color:#f92672">-&amp;gt;&lt;/span> str:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#e6db74">&amp;#34;&amp;#34;&amp;#34;Convert a PDF file to Markdown format.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">if&lt;/span> &lt;span style="color:#f92672">not&lt;/span> os&lt;span style="color:#f92672">.&lt;/span>path&lt;span style="color:#f92672">.&lt;/span>exists(pdf_path):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">raise&lt;/span> &lt;span style="color:#a6e22e">FileNotFoundError&lt;/span>(&lt;span style="color:#e6db74">f&lt;/span>&lt;span style="color:#e6db74">&amp;#34;PDF file not found: &lt;/span>&lt;span style="color:#e6db74">{&lt;/span>pdf_path&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74">&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#75715e"># Get page count for progress bar&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> doc &lt;span style="color:#f92672">=&lt;/span> pymupdf&lt;span style="color:#f92672">.&lt;/span>open(pdf_path)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> page_count &lt;span style="color:#f92672">=&lt;/span> doc&lt;span style="color:#f92672">.&lt;/span>page_count
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> doc&lt;span style="color:#f92672">.&lt;/span>close()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print(&lt;span style="color:#e6db74">f&lt;/span>&lt;span style="color:#e6db74">&amp;#34;Converting: &lt;/span>&lt;span style="color:#e6db74">{&lt;/span>pdf_path&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74">&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">with&lt;/span> tqdm(total&lt;span style="color:#f92672">=&lt;/span>page_count, unit&lt;span style="color:#f92672">=&lt;/span>&lt;span style="color:#e6db74">&amp;#34;page&amp;#34;&lt;/span>, desc&lt;span style="color:#f92672">=&lt;/span>&lt;span style="color:#e6db74">&amp;#34;Processing&amp;#34;&lt;/span>, colour&lt;span style="color:#f92672">=&lt;/span>&lt;span style="color:#e6db74">&amp;#34;blue&amp;#34;&lt;/span>) &lt;span style="color:#66d9ef">as&lt;/span> bar:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> md_text &lt;span style="color:#f92672">=&lt;/span> pymupdf4llm&lt;span style="color:#f92672">.&lt;/span>to_markdown(pdf_path, page_chunks&lt;span style="color:#f92672">=&lt;/span>&lt;span style="color:#66d9ef">False&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> bar&lt;span style="color:#f92672">.&lt;/span>n &lt;span style="color:#f92672">=&lt;/span> page_count
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> bar&lt;span style="color:#f92672">.&lt;/span>refresh()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">if&lt;/span> output_path &lt;span style="color:#f92672">is&lt;/span> &lt;span style="color:#66d9ef">None&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output_path &lt;span style="color:#f92672">=&lt;/span> Path(pdf_path)&lt;span style="color:#f92672">.&lt;/span>with_suffix(&lt;span style="color:#e6db74">&amp;#39;.md&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">with&lt;/span> open(output_path, &lt;span style="color:#e6db74">&amp;#39;w&amp;#39;&lt;/span>, encoding&lt;span style="color:#f92672">=&lt;/span>&lt;span style="color:#e6db74">&amp;#39;utf-8&amp;#39;&lt;/span>) &lt;span style="color:#66d9ef">as&lt;/span> f:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> f&lt;span style="color:#f92672">.&lt;/span>write(md_text)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print(&lt;span style="color:#e6db74">f&lt;/span>&lt;span style="color:#e6db74">&amp;#34;✓ Done: &lt;/span>&lt;span style="color:#e6db74">{&lt;/span>output_path&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74"> (&lt;/span>&lt;span style="color:#e6db74">{&lt;/span>len(md_text)&lt;span style="color:#e6db74">:&lt;/span>&lt;span style="color:#e6db74">,&lt;/span>&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74"> characters)&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">return&lt;/span> str(output_path)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">def&lt;/span> &lt;span style="color:#a6e22e">batch_convert&lt;/span>(input_dir: str, output_dir: str &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#66d9ef">None&lt;/span>) &lt;span style="color:#f92672">-&amp;gt;&lt;/span> &lt;span style="color:#66d9ef">None&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#e6db74">&amp;#34;&amp;#34;&amp;#34;Convert all PDFs in a directory to Markdown.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> input_path &lt;span style="color:#f92672">=&lt;/span> Path(input_dir)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">if&lt;/span> &lt;span style="color:#f92672">not&lt;/span> input_path&lt;span style="color:#f92672">.&lt;/span>is_dir():
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">raise&lt;/span> &lt;span style="color:#a6e22e">NotADirectoryError&lt;/span>(&lt;span style="color:#e6db74">f&lt;/span>&lt;span style="color:#e6db74">&amp;#34;Not a directory: &lt;/span>&lt;span style="color:#e6db74">{&lt;/span>input_dir&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74">&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pdfs &lt;span style="color:#f92672">=&lt;/span> sorted(input_path&lt;span style="color:#f92672">.&lt;/span>glob(&lt;span style="color:#e6db74">&amp;#34;*.pdf&amp;#34;&lt;/span>))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">if&lt;/span> &lt;span style="color:#f92672">not&lt;/span> pdfs:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print(&lt;span style="color:#e6db74">f&lt;/span>&lt;span style="color:#e6db74">&amp;#34;No PDF files found in: &lt;/span>&lt;span style="color:#e6db74">{&lt;/span>input_dir&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74">&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sys&lt;span style="color:#f92672">.&lt;/span>exit(&lt;span style="color:#ae81ff">0&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">if&lt;/span> output_dir:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output_dir &lt;span style="color:#f92672">=&lt;/span> Path(output_dir)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">else&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output_dir &lt;span style="color:#f92672">=&lt;/span> input_path&lt;span style="color:#f92672">.&lt;/span>parent &lt;span style="color:#f92672">/&lt;/span> &lt;span style="color:#e6db74">&amp;#34;output&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output_dir&lt;span style="color:#f92672">.&lt;/span>mkdir(parents&lt;span style="color:#f92672">=&lt;/span>&lt;span style="color:#66d9ef">True&lt;/span>, exist_ok&lt;span style="color:#f92672">=&lt;/span>&lt;span style="color:#66d9ef">True&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> total &lt;span style="color:#f92672">=&lt;/span> len(pdfs)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> succeeded &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#ae81ff">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> failed &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#ae81ff">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print(&lt;span style="color:#e6db74">f&lt;/span>&lt;span style="color:#e6db74">&amp;#34;&lt;/span>&lt;span style="color:#ae81ff">\n&lt;/span>&lt;span style="color:#e6db74">Batch mode: &lt;/span>&lt;span style="color:#e6db74">{&lt;/span>total&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74"> PDF(s) found in &amp;#39;&lt;/span>&lt;span style="color:#e6db74">{&lt;/span>input_dir&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74">&amp;#39;&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print(&lt;span style="color:#e6db74">f&lt;/span>&lt;span style="color:#e6db74">&amp;#34;Output folder: &lt;/span>&lt;span style="color:#e6db74">{&lt;/span>output_dir&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#ae81ff">\n&lt;/span>&lt;span style="color:#e6db74">&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">for&lt;/span> i, pdf_path &lt;span style="color:#f92672">in&lt;/span> enumerate(pdfs, start&lt;span style="color:#f92672">=&lt;/span>&lt;span style="color:#ae81ff">1&lt;/span>):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print(&lt;span style="color:#e6db74">f&lt;/span>&lt;span style="color:#e6db74">&amp;#34;[&lt;/span>&lt;span style="color:#e6db74">{&lt;/span>i&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74">/&lt;/span>&lt;span style="color:#e6db74">{&lt;/span>total&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74">] &lt;/span>&lt;span style="color:#e6db74">{&lt;/span>pdf_path&lt;span style="color:#f92672">.&lt;/span>name&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74">&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output_path &lt;span style="color:#f92672">=&lt;/span> output_dir &lt;span style="color:#f92672">/&lt;/span> pdf_path&lt;span style="color:#f92672">.&lt;/span>with_suffix(&lt;span style="color:#e6db74">&amp;#39;.md&amp;#39;&lt;/span>)&lt;span style="color:#f92672">.&lt;/span>name
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">try&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> convert_pdf_to_markdown(str(pdf_path), str(output_path))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> succeeded &lt;span style="color:#f92672">+=&lt;/span> &lt;span style="color:#ae81ff">1&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">except&lt;/span> &lt;span style="color:#a6e22e">Exception&lt;/span> &lt;span style="color:#66d9ef">as&lt;/span> e:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print(&lt;span style="color:#e6db74">f&lt;/span>&lt;span style="color:#e6db74">&amp;#34; ✗ Failed: &lt;/span>&lt;span style="color:#e6db74">{&lt;/span>e&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74">&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> failed &lt;span style="color:#f92672">+=&lt;/span> &lt;span style="color:#ae81ff">1&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print(&lt;span style="color:#e6db74">&amp;#34;─&amp;#34;&lt;/span> &lt;span style="color:#f92672">*&lt;/span> &lt;span style="color:#ae81ff">40&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print(&lt;span style="color:#e6db74">f&lt;/span>&lt;span style="color:#e6db74">&amp;#34;Batch complete: &lt;/span>&lt;span style="color:#e6db74">{&lt;/span>succeeded&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74"> converted, &lt;/span>&lt;span style="color:#e6db74">{&lt;/span>failed&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74"> failed&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print(&lt;span style="color:#e6db74">f&lt;/span>&lt;span style="color:#e6db74">&amp;#34;Output folder: &lt;/span>&lt;span style="color:#e6db74">{&lt;/span>output_dir&lt;span style="color:#e6db74">}&lt;/span>&lt;span style="color:#e6db74">&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">def&lt;/span> &lt;span style="color:#a6e22e">main&lt;/span>():
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#e6db74">&amp;#34;&amp;#34;&amp;#34;Main CLI entry point&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> args &lt;span style="color:#f92672">=&lt;/span> sys&lt;span style="color:#f92672">.&lt;/span>argv[&lt;span style="color:#ae81ff">1&lt;/span>:]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">if&lt;/span> &lt;span style="color:#f92672">not&lt;/span> args:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print(&lt;span style="color:#e6db74">&amp;#34;Usage:&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print(&lt;span style="color:#e6db74">&amp;#34; pdf2md &amp;lt;input.pdf&amp;gt; [output.md] # Convert a single PDF&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print(&lt;span style="color:#e6db74">&amp;#34; pdf2md --batch &amp;lt;folder/&amp;gt; # Convert all PDFs in a folder&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print(&lt;span style="color:#e6db74">&amp;#34; pdf2md --batch &amp;lt;folder/&amp;gt; --output &amp;lt;out_folder/&amp;gt; # Batch with custom output dir&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print(&lt;span style="color:#e6db74">&amp;#34;&lt;/span>&lt;span style="color:#ae81ff">\n&lt;/span>&lt;span style="color:#e6db74">Examples:&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print(&lt;span style="color:#e6db74">&amp;#34; pdf2md document.pdf # Creates document.md&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print(&lt;span style="color:#e6db74">&amp;#34; pdf2md document.pdf custom.md # Creates custom.md&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print(&lt;span style="color:#e6db74">&amp;#34; pdf2md --batch input/ # Converts all PDFs in input/&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> print(&lt;span style="color:#e6db74">&amp;#34; pdf2md --batch ~/documents/pdfs/ --output ~/knowledge-base/docs/&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sys&lt;span style="color:#f92672">.&lt;/span>exit(&lt;span style="color:#ae81ff">1&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">if&lt;/span> args[&lt;span style="color:#ae81ff">0&lt;/span>] &lt;span style="color:#f92672">==&lt;/span> &lt;span style="color:#e6db74">&amp;#34;--batch&amp;#34;&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> input_dir &lt;span style="color:#f92672">=&lt;/span> args[&lt;span style="color:#ae81ff">1&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output_dir &lt;span style="color:#f92672">=&lt;/span> &lt;span style="color:#66d9ef">None&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">if&lt;/span> &lt;span style="color:#e6db74">&amp;#34;--output&amp;#34;&lt;/span> &lt;span style="color:#f92672">in&lt;/span> args:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> idx &lt;span style="color:#f92672">=&lt;/span> args&lt;span style="color:#f92672">.&lt;/span>index(&lt;span style="color:#e6db74">&amp;#34;--output&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output_dir &lt;span style="color:#f92672">=&lt;/span> args[idx &lt;span style="color:#f92672">+&lt;/span> &lt;span style="color:#ae81ff">1&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> batch_convert(input_dir, output_dir)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#66d9ef">else&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pdf_path &lt;span style="color:#f92672">=&lt;/span> args[&lt;span style="color:#ae81ff">0&lt;/span>]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> output_path &lt;span style="color:#f92672">=&lt;/span> args[&lt;span style="color:#ae81ff">1&lt;/span>] &lt;span style="color:#66d9ef">if&lt;/span> len(args) &lt;span style="color:#f92672">&amp;gt;&lt;/span> &lt;span style="color:#ae81ff">1&lt;/span> &lt;span style="color:#66d9ef">else&lt;/span> &lt;span style="color:#66d9ef">None&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> convert_pdf_to_markdown(pdf_path, output_path)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#66d9ef">if&lt;/span> __name__ &lt;span style="color:#f92672">==&lt;/span> &lt;span style="color:#e6db74">&amp;#34;__main__&amp;#34;&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> main()
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>What I love about this code:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Smart defaults:&lt;/strong> If you don&amp;rsquo;t specify an output path, it just replaces &lt;code>.pdf&lt;/code> with &lt;code>.md&lt;/code>&lt;/li>
&lt;li>&lt;strong>Progress bars:&lt;/strong> &lt;code>tqdm&lt;/code> gives you a blue progress bar with page count&lt;/li>
&lt;li>&lt;strong>Batch mode:&lt;/strong> &lt;code>--batch&lt;/code> processes an entire folder at once, with optional &lt;code>--output&lt;/code> target&lt;/li>
&lt;li>&lt;strong>Helpful errors:&lt;/strong> Clear messages when things go wrong&lt;/li>
&lt;li>&lt;strong>Flexible usage:&lt;/strong> Works with relative paths, absolute paths, custom output names&lt;/li>
&lt;/ul>
&lt;p>Make it executable:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>chmod +x pdf2md
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And now it&amp;rsquo;s a proper command-line tool.&lt;/p>
&lt;hr>
&lt;h2 id="the-moment-of-truth-testing-with-real-data">The Moment of Truth: Testing with Real Data&lt;/h2>
&lt;p>Theory is great. But does it actually work?&lt;/p>
&lt;p>I grabbed that 1.3MB research paper on Generative Engine Optimization and ran:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>python pdf2md input/test.pdf output/test.md
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The output:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-text" data-lang="text">&lt;span style="display:flex;">&lt;span>Converting input/test.pdf to Markdown...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Processing: 100%|████████████████| 12/12 [00:02&amp;lt;00:00, 5.8 pages/s]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>✓ Done: output/test.md (73,463 characters)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>1.3MB PDF → 74KB of clean Markdown in seconds.&lt;/strong>&lt;/p>
&lt;p>I opened the output file, and there it was—perfectly formatted markdown:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-markdown" data-lang="markdown">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#75715e">## **GEO: Generative Engine Optimization**
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#75715e">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Pranjal Aggarwal [∗]
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Indian Institute of Technology Delhi
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>New Delhi, India
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>pranjal2041@gmail.com
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Ashwin Kalyan
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Independent
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>Seattle, USA
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>asaavashwin@gmail.com
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Headers, formatting, structure—all preserved. No manual cleanup needed.&lt;/p>
&lt;p>Success.&lt;/p>
&lt;hr>
&lt;h2 id="what-this-unlocks">What This Unlocks&lt;/h2>
&lt;p>Now that I have PDFs converting to Markdown reliably, a whole world of possibilities opens up:&lt;/p>
&lt;h3 id="ai-workflows">AI Workflows&lt;/h3>
&lt;ul>
&lt;li>Feed research papers and documentation directly into Claude or other LLMs&lt;/li>
&lt;li>Build RAG (Retrieval Augmented Generation) pipelines backed by your document library&lt;/li>
&lt;li>Process technical documentation at scale without losing structure&lt;/li>
&lt;/ul>
&lt;h3 id="knowledge-management">Knowledge Management&lt;/h3>
&lt;ul>
&lt;li>Import PDFs into your Obsidian vault automatically&lt;/li>
&lt;li>Version control document content (because it&amp;rsquo;s now plain text in git)&lt;/li>
&lt;li>Full-text search across your entire converted document library&lt;/li>
&lt;/ul>
&lt;h3 id="automation-ideas">Automation Ideas&lt;/h3>
&lt;ul>
&lt;li>Watch folder that auto-converts any dropped PDFs&lt;/li>
&lt;li>Batch process entire directories of reports, papers, or manuals&lt;/li>
&lt;li>Feed converted markdown directly into a vector database&lt;/li>
&lt;li>API wrapper to convert PDFs via HTTP requests&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="lessons-learned-especially-for-cli-beginners">Lessons Learned (Especially for CLI Beginners)&lt;/h2>
&lt;h3 id="1-virtual-environments-are-non-negotiable">1. Virtual Environments Are Non-Negotiable&lt;/h3>
&lt;p>Every Python project should live in its own virtual environment. Always:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>python3 -m venv venv
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>source venv/bin/activate
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>pip install --upgrade pip
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This keeps dependencies isolated and projects reproducible.&lt;/p>
&lt;h3 id="2-bleeding-edge-isnt-always-better">2. Bleeding-Edge Isn&amp;rsquo;t Always Better&lt;/h3>
&lt;p>Python 3.14 is awesome, but sometimes mature tooling (like PyMuPDF) that &amp;ldquo;just works&amp;rdquo; beats bleeding-edge alternatives. Don&amp;rsquo;t be afraid to pivot when something doesn&amp;rsquo;t work.&lt;/p>
&lt;h3 id="3-test-with-real-data">3. Test With Real Data&lt;/h3>
&lt;p>I didn&amp;rsquo;t test with &amp;ldquo;hello.pdf&amp;rdquo; containing two sentences. I tested with a 1.3MB research paper. Real data reveals real issues (or in this case, confirms it works beautifully).&lt;/p>
&lt;h3 id="4-document-as-you-build">4. Document As You Build&lt;/h3>
&lt;p>Writing the README alongside the code made the project immediately understandable. Future-me will thank present-me.&lt;/p>
&lt;h3 id="5-claude-code--pai--superpowers">5. Claude Code + PAI = Superpowers&lt;/h3>
&lt;p>Working with Claude through the PAI infrastructure meant I had a senior developer helping me think through:&lt;/p>
&lt;ul>
&lt;li>Project structure (first principles)&lt;/li>
&lt;li>Library selection (when to pivot)&lt;/li>
&lt;li>Code organization (clean, maintainable)&lt;/li>
&lt;li>Real-world usage patterns&lt;/li>
&lt;/ul>
&lt;p>This wasn&amp;rsquo;t just coding faster—it was learning better patterns while building.&lt;/p>
&lt;hr>
&lt;h2 id="usage-examples">Usage Examples&lt;/h2>
&lt;h3 id="basic-conversion">Basic Conversion&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#75715e"># Activate environment first (always!)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>source venv/bin/activate
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#75715e"># Convert a PDF&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>python pdf2md document.pdf
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#75715e"># Custom output name&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>python pdf2md research.pdf my-notes.md
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#75715e"># Full paths&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>python pdf2md ~/Downloads/paper.pdf ~/Documents/notes.md
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="batch-processing">Batch Processing&lt;/h3>
&lt;p>Convert an entire folder of PDFs:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>source venv/bin/activate
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#75715e"># Convert all PDFs in a folder (output goes to output/ by default)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>python pdf2md --batch ~/documents/pdfs/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#75715e"># Convert to a specific knowledge base directory&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>python pdf2md --batch ~/documents/pdfs/ --output ~/knowledge-base/docs/
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="add-to-path-optional">Add to PATH (Optional)&lt;/h3>
&lt;p>To use &lt;code>pdf2md&lt;/code> from anywhere:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#75715e"># Add to ~/.zshrc&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>export PATH&lt;span style="color:#f92672">=&lt;/span>&lt;span style="color:#e6db74">&amp;#34;/Users/dsa/projects/pdf-to-markdown:&lt;/span>$PATH&lt;span style="color:#e6db74">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#75715e"># Then run from anywhere&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>pdf2md ~/Downloads/paper.pdf ~/Documents/paper.md
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="whats-next">What&amp;rsquo;s Next?&lt;/h2>
&lt;p>This tool works great as-is, but there are some exciting enhancements on the roadmap:&lt;/p>
&lt;h3 id="immediate-improvements">Immediate Improvements&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Better layout analysis:&lt;/strong> Install &lt;code>pymupdf_layout&lt;/code> for improved structure detection on complex documents&lt;/li>
&lt;li>&lt;strong>Recursive batch mode:&lt;/strong> Process nested folder structures, not just flat directories&lt;/li>
&lt;/ul>
&lt;h3 id="future-integrations">Future Integrations&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>RAG pipeline:&lt;/strong> Auto-feed converted markdown into a vector database&lt;/li>
&lt;li>&lt;strong>Obsidian plugin:&lt;/strong> Detect PDFs in vault and convert automatically&lt;/li>
&lt;li>&lt;strong>FastAPI wrapper:&lt;/strong> Create an HTTP API for web apps to use&lt;/li>
&lt;li>&lt;strong>Electron/Tauri app:&lt;/strong> Build a desktop GUI for non-technical users&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="the-bigger-picture-why-this-matters">The Bigger Picture: Why This Matters&lt;/h2>
&lt;p>This project is tiny—roughly 100 lines of Python, 30 minutes of work. But it represents something bigger:&lt;/p>
&lt;p>&lt;strong>The ability to build tools that solve your actual problems.&lt;/strong>&lt;/p>
&lt;p>I had a workflow friction (PDFs don&amp;rsquo;t work well with AI tools). I built a solution. Now that friction is gone, and I can focus on higher-level work.&lt;/p>
&lt;p>And the data is clear: converting your document library to Markdown isn&amp;rsquo;t a nice-to-have. It&amp;rsquo;s a multiplier on every AI workflow that follows. Up to 70% fewer tokens consumed. 84% fewer retrieval failures. 50% fewer incorrect answers. These aren&amp;rsquo;t marginal improvements—they&amp;rsquo;re transformational.&lt;/p>
&lt;p>Working with Claude Code through PAI accelerated all of this. It&amp;rsquo;s like having a patient senior developer sitting next to you, suggesting better approaches, catching errors before they happen, and explaining &lt;em>why&lt;/em> certain patterns work.&lt;/p>
&lt;hr>
&lt;h2 id="resources">Resources&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>PyMuPDF4LLM Docs:&lt;/strong> &lt;a href="https://pymupdf.readthedocs.io/en/latest/pymupdf4llm/" target="_blank" rel="noopener noreferrer">https://pymupdf.readthedocs.io/en/latest/pymupdf4llm/&lt;/a>
&lt;/li>
&lt;li>&lt;strong>PyMuPDF GitHub:&lt;/strong> &lt;a href="https://github.com/pymupdf/PyMuPDF" target="_blank" rel="noopener noreferrer">https://github.com/pymupdf/PyMuPDF&lt;/a>
&lt;/li>
&lt;/ul>
&lt;h3 id="citations-markdown-vs-pdf-for-llms">Citations: Markdown vs PDF for LLMs&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Why PDFs Fail Under LLM Parsing&lt;/strong> — Steven Howard, Untethered AI: &lt;a href="https://untetheredai.substack.com/p/why-pdfs-fail-under-llm-parsing" target="_blank" rel="noopener noreferrer">https://untetheredai.substack.com/p/why-pdfs-fail-under-llm-parsing&lt;/a>
&lt;/li>
&lt;li>&lt;strong>PDF vs Markdown for AI: Token Efficiency&lt;/strong> — MarkdownConverters: &lt;a href="https://markdownconverters.com/blog/pdf-vs-markdown-ai-tokens" target="_blank" rel="noopener noreferrer">https://markdownconverters.com/blog/pdf-vs-markdown-ai-tokens&lt;/a>
&lt;/li>
&lt;li>&lt;strong>Revolutionizing RAG with Enhanced PDF Structure Recognition&lt;/strong> — arXiv:2401.12599 (2024): &lt;a href="https://arxiv.org/abs/2401.12599" target="_blank" rel="noopener noreferrer">https://arxiv.org/abs/2401.12599&lt;/a>
&lt;/li>
&lt;li>&lt;strong>Approaches to PDF Data Extraction for Information Retrieval&lt;/strong> — NVIDIA Technical Blog: &lt;a href="https://developer.nvidia.com/blog/approaches-to-pdf-data-extraction-for-information-retrieval/" target="_blank" rel="noopener noreferrer">https://developer.nvidia.com/blog/approaches-to-pdf-data-extraction-for-information-retrieval/&lt;/a>
&lt;/li>
&lt;li>&lt;strong>Improved RAG Document Processing With Markdown&lt;/strong> — Dr. Leon Eversberg, Towards Data Science: &lt;a href="https://medium.com/data-science/improved-rag-document-processing-with-markdown-426a2e0dd82b" target="_blank" rel="noopener noreferrer">https://medium.com/data-science/improved-rag-document-processing-with-markdown-426a2e0dd82b&lt;/a>
&lt;/li>
&lt;li>&lt;strong>Contextual Chunking: Boost Your RAG Retrieval Accuracy&lt;/strong> — Unstructured.io: &lt;a href="https://unstructured.io/blog/contextual-chunking-in-unstructured-platform-boost-your-rag-retrieval-accuracy" target="_blank" rel="noopener noreferrer">https://unstructured.io/blog/contextual-chunking-in-unstructured-platform-boost-your-rag-retrieval-accuracy&lt;/a>
&lt;/li>
&lt;li>&lt;strong>Boosting AI Performance: The Power of LLM-Friendly Content in Markdown&lt;/strong> — Webex Developers Blog: &lt;a href="https://developer.webex.com/blog/boosting-ai-performance-the-power-of-llm-friendly-content-in-markdown" target="_blank" rel="noopener noreferrer">https://developer.webex.com/blog/boosting-ai-performance-the-power-of-llm-friendly-content-in-markdown&lt;/a>
&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;strong>Happy converting!&lt;/strong>&lt;/p></content></item></channel></rss>