<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Damien Alleyne</title>
    <description>The latest articles on DEV Community by Damien Alleyne (@dalleyne).</description>
    <link>https://dev.to/dalleyne</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3748529%2F28d6f35f-9dc8-4abf-9573-3274a2f19a03.jpeg</url>
      <title>DEV Community: Damien Alleyne</title>
      <link>https://dev.to/dalleyne</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dalleyne"/>
    <language>en</language>
    <item>
      <title>My Code, My Test, and My Prompt All Agreed. All Three Were Wrong.</title>
      <dc:creator>Damien Alleyne</dc:creator>
      <pubDate>Wed, 08 Jul 2026 20:09:41 +0000</pubDate>
      <link>https://dev.to/dalleyne/my-code-my-test-and-my-prompt-all-agreed-all-three-were-wrong-5hag</link>
      <guid>https://dev.to/dalleyne/my-code-my-test-and-my-prompt-all-agreed-all-three-were-wrong-5hag</guid>
      <description>&lt;p&gt;A friend handed me a grocery receipt to test my receipt-scanning app, &lt;a href="https://app.bankinginbim.com" rel="noopener noreferrer"&gt;Receipt Tracker&lt;/a&gt;, which turns a photo into categorised line items. It was a dense Massy Stores run, 37 items, and the photo wasn't the sharpest. The app got most of it, but a few items were missing and the totals didn't add up. He was generous about it, impressed at how much it had pulled off a bad photo. I was less impressed: a scan you have to double-check by hand for missing items isn't saving you the work.&lt;/p&gt;

&lt;p&gt;The model doing the reading was Google's &lt;a href="https://ai.google.dev/gemini-api/docs/models/gemini-2.5-flash" rel="noopener noreferrer"&gt;Gemini 2.5 Flash&lt;/a&gt;, and on that receipt it kept dropping lines and inventing quantities. Upgrading to &lt;a href="https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash" rel="noopener noreferrer"&gt;Gemini 3.5 Flash&lt;/a&gt; fixed it cleanly: all 37 items, every run, reconciling to the printed $254.03 to the cent. The upgrade wasn't cheap. The newer model lists at &lt;a href="https://ai.google.dev/gemini-api/docs/pricing" rel="noopener noreferrer"&gt;$1.50 per million input tokens&lt;/a&gt; against 2.5 Flash's $0.30, five times the price, and enough of a jump that it drew &lt;a href="https://simonwillison.net/2026/May/19/gemini-35-flash/" rel="noopener noreferrer"&gt;pushback from developers&lt;/a&gt; when it landed. I paid it for the quality and kept it.&lt;/p&gt;

&lt;p&gt;The model is the app's only real running cost, and I'd just chosen to pay more for it. So when I noticed &lt;a href="https://openrouter.ai/deepseek/deepseek-v4-flash" rel="noopener noreferrer"&gt;DeepSeek's V4 models&lt;/a&gt;, the cheapest listed at $0.09 per million input tokens against Gemini's $1.50, the obvious question was whether the trade could run the other way: the same accuracy for a fraction of the cost.&lt;/p&gt;

&lt;p&gt;What followed turned up two real bugs and improved the prompt for the model I &lt;em&gt;wasn't&lt;/em&gt; trying to replace. Most of it was me being wrong. The uncomfortable part is why I nearly shipped the wrong call anyway: my code, my test, and my prompt all agreed, and I trusted that agreement without noticing all three came from the same place.&lt;/p&gt;

&lt;h2&gt;
  
  
  DeepSeek can't even see
&lt;/h2&gt;

&lt;p&gt;The investigation started with DeepSeek, whose V4 models go for a fraction of Gemini's price, and ended about thirty seconds later. On &lt;a href="https://openrouter.ai" rel="noopener noreferrer"&gt;OpenRouter&lt;/a&gt;, every DeepSeek model lists text as its only input modality. V4 &lt;em&gt;does&lt;/em&gt; have a "Vision" mode, but it only lives in the chat app at &lt;a href="https://chat.deepseek.com" rel="noopener noreferrer"&gt;chat.deepseek.com&lt;/a&gt;; the API itself is text-only. The capability exists; you just can't build on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qwen looks great, then fails by $88
&lt;/h2&gt;

&lt;p&gt;The real candidate was &lt;a href="https://openrouter.ai/qwen/qwen3-vl-32b-instruct" rel="noopener noreferrer"&gt;Qwen3-VL-32B&lt;/a&gt;: a proper vision model, strong on document OCR benchmarks, and about 18× cheaper than Gemini per receipt. I already had an eval harness (14 receipt fixtures, 135 assertions) so I pointed it at Qwen.&lt;/p&gt;

&lt;p&gt;It passed about 85%. I trusted Gemini as the model that just worked, and honestly I'd have guessed it passed everything, though I wasn't checking that closely. So Qwen coming in lower made me wary, and one failure in particular jumped out, because it was about money. Reconciliation is a check that runs in code after the model: add up the line items it pulled out, and see whether they match the printed total. This was the same dense receipt Gemini reconciles to the cent, so when Qwen's numbers didn't add up on it, it looked like a straightforward Qwen problem. I checked directly with a quick probe, summing each item's &lt;code&gt;price × quantity&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Qwen3-VL-32B on the 37-item Massy receipt:
  printed total:   $254.03
  sum of items:    $341.99
  reconciliation gap: $87.96   ← ouch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eighty-eight dollars off. I dug in and found a clean culprit: promotional quantities. On the "Gala Apples" line, Qwen set &lt;code&gt;quantity: 8&lt;/code&gt; but kept the line's printed total ($5.52) in the price field, so &lt;code&gt;price × quantity&lt;/code&gt; came out to $44.16 instead of $5.52. I wrote it up as a systematic Qwen failure, ran the bigger &lt;a href="https://openrouter.ai/qwen/qwen3-vl-235b-a22b-instruct" rel="noopener noreferrer"&gt;235B model&lt;/a&gt; to confirm it wasn't just a small-model problem (it wasn't; the same gap showed up at 235B), and started drafting the verdict: &lt;em&gt;cheaper, but the totals don't add up. Keep Gemini.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I'd run this whole investigation with my coding agent, &lt;a href="https://claude.com/product/claude-code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt;, so that verdict was as much its conclusion as mine, and I almost took it. But first I asked it the question I'd been skipping: was the prompt itself tuned for Gemini, giving it an unfair edge?&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug was mine
&lt;/h2&gt;

&lt;p&gt;Answering that meant running Gemini through the same probe. Gemini reconciles this receipt to the cent, so it should have passed clean. Instead my probe put Gemini just as far off, at $337.11, an $83 gap on a receipt I &lt;em&gt;knew&lt;/em&gt; reconciles perfectly. That was the giveaway: Gemini's extraction hadn't changed, so the same line items that reconcile to $254.03 could not suddenly be $83 off. The probe was doing the wrong math, on both models.&lt;/p&gt;

&lt;p&gt;In the JSON the model fills, each item's &lt;code&gt;price&lt;/code&gt; field holds the &lt;strong&gt;line total&lt;/strong&gt; (the extended price actually charged), rather than a unit price. The prompt's own examples say so: &lt;code&gt;Drumsticks, quantity: 0.862, price: 17.20&lt;/code&gt;. So the correct reconciliation is &lt;strong&gt;Σ price&lt;/strong&gt;, full stop. My probe computed &lt;strong&gt;Σ (price × quantity)&lt;/strong&gt;, which double-counts every multi-quantity line and manufactures ~$80 of phantom gap out of thin air.&lt;/p&gt;

&lt;p&gt;With the correct metric, Σ price:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Σ price&lt;/th&gt;
&lt;th&gt;Gap vs printed total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemini&lt;/td&gt;
&lt;td&gt;$254.03&lt;/td&gt;
&lt;td&gt;$0.00 ✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen&lt;/td&gt;
&lt;td&gt;$258.68&lt;/td&gt;
&lt;td&gt;$4.65 (one extra line, not promo leakage)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The "$88 Qwen failure" never existed. It was an artifact of multiplying the wrong two numbers. And the same bug was hiding in my actual test suite's reconciliation assertion, inflating the sum on any multi-buy line exactly the way my probe did.&lt;/p&gt;

&lt;p&gt;The promotional &lt;code&gt;quantity: 8&lt;/code&gt; I'd flagged as a Qwen defect? Production &lt;strong&gt;already handles it&lt;/strong&gt;: a deterministic step re-derives the quantity by dividing the line total by the per-unit price the model extracts into its own field from the receipt's "N @ unit price" line, and the integrity guard sums line totals rather than &lt;code&gt;price × quantity&lt;/code&gt;. I'd benchmarked raw model output and ignored the production code that normalises the exact field I was panicking about. Gemini and Qwen emit the same promo quantities; production fixes both.&lt;/p&gt;

&lt;p&gt;There was a third instance, too, in the prompt itself. A few lines apart, it handed the model two contradictory rules:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Do NOT multiply quantities — the receipt already shows the correct totals."&lt;/p&gt;

&lt;p&gt;"RECONCILIATION CHECK: sum every extracted item's price × quantity."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The same wrong formula, written three times, by someone who plainly knew better. That's the real tell: you don't fix this by being more careful. The field is called &lt;code&gt;price&lt;/code&gt; but it holds a &lt;em&gt;line total&lt;/em&gt;, and "price × quantity = total" is the formula your hands type on autopilot. The durable fix is to name the field for what it holds, &lt;code&gt;lineTotal&lt;/code&gt;, so that multiplying it by a quantity reads as the obvious nonsense it is.&lt;/p&gt;

&lt;p&gt;And there's a deeper reason all three copies survived: &lt;strong&gt;no reconciliation test summed line totals on a multi-quantity receipt.&lt;/strong&gt; My one reconciliation check was gated to quantity-1 restaurant receipts, where &lt;code&gt;Σ (lineTotal × quantity)&lt;/code&gt; and &lt;code&gt;Σ lineTotal&lt;/code&gt; give the same answer. The bug lived in the exact blind spot where no test was looking, so I added the missing one: sum the line totals on the dense 37-item Massy receipt and confirm it matches the printed total.&lt;/p&gt;

&lt;p&gt;Confirm what a field &lt;em&gt;means&lt;/em&gt; before you compute with it; a line-total-vs-unit-price mix-up fabricates gaps that look exactly like a model defect. And benchmark the &lt;em&gt;production pipeline&lt;/em&gt; rather than raw model output, because deterministic post-processing can erase the very differences you're measuring.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real gap was my prompt
&lt;/h2&gt;

&lt;p&gt;With the metric fixed, I ran the honest comparison: both models, the same 135 assertions, three runs each.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Overall (of 135, 3-run mean)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.5 Flash&lt;/td&gt;
&lt;td&gt;131.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-VL-32B&lt;/td&gt;
&lt;td&gt;116.0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A real ~15-assertion gap, but &lt;strong&gt;not&lt;/strong&gt; in reconciliation (Qwen passes the money guard). When I bucketed Qwen's failures, the largest cluster was &lt;em&gt;category assignment&lt;/em&gt;: of seven category checks, it failed six. And the pattern was almost comically specific:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Receipt&lt;/th&gt;
&lt;th&gt;Expected&lt;/th&gt;
&lt;th&gt;Qwen gave&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chefette, KFC&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Fast Food&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Dining&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heaven Sent (baby store)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Baby Care&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Baby &amp;amp; Children&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Massy (formula)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Formula&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Baby &amp;amp; Children &amp;gt; Formula&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Qwen wasn't &lt;em&gt;miscategorising&lt;/em&gt;. It understood every receipt fine. It was returning the &lt;strong&gt;parent category&lt;/strong&gt; (or the full &lt;code&gt;Parent &amp;gt; Sub&lt;/code&gt; path) instead of the &lt;strong&gt;leaf subcategory&lt;/strong&gt; my taxonomy wanted.&lt;/p&gt;

&lt;p&gt;My prompt has a rule for exactly this: "use the subcategory, never the parent." But look at how it's written: &lt;em&gt;"never return just 'Groceries', always pick the specific subcategory under it."&lt;/em&gt; Every example is a &lt;strong&gt;grocery&lt;/strong&gt; example. That rule lives in a prompt I've revised across &lt;strong&gt;more than 40 commits&lt;/strong&gt;, nearly every one a reaction to something Gemini got subtly wrong on a Bajan receipt. Gemini generalised the rule to all categories. Qwen read it literally, applied it to groceries, and returned parents for everything else.&lt;/p&gt;

&lt;p&gt;The benchmark wasn't measuring "which model is better at receipts." It was measuring &lt;strong&gt;which model better fits a prompt I'd unconsciously shaped around Gemini.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that helped both models
&lt;/h2&gt;

&lt;p&gt;If the gap is prompt-fit, it should be promptable. I added one closing rule that restates "leaf subcategory only" for &lt;em&gt;every&lt;/em&gt; tree, not just groceries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;restaurant / fast-food → &lt;code&gt;Fast Food&lt;/code&gt; (not &lt;code&gt;Dining&lt;/code&gt;, and not the ingredient)&lt;/li&gt;
&lt;li&gt;baby → &lt;code&gt;Baby Care&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;hardware → &lt;code&gt;Home&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I A/B'd it, three runs per condition, both models:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Category checks (of 7, 3-run mean)&lt;/th&gt;
&lt;th&gt;Overall (3-run mean)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen baseline&lt;/td&gt;
&lt;td&gt;~1 / 7&lt;/td&gt;
&lt;td&gt;116.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen + leaf rule&lt;/td&gt;
&lt;td&gt;7 / 7&lt;/td&gt;
&lt;td&gt;121.3 (+5.3)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini baseline&lt;/td&gt;
&lt;td&gt;~6 / 7&lt;/td&gt;
&lt;td&gt;131.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini + leaf rule&lt;/td&gt;
&lt;td&gt;7 / 7&lt;/td&gt;
&lt;td&gt;132.7 (no regression)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cheap model's category adherence went from roughly &lt;strong&gt;1/7 to 7/7&lt;/strong&gt;, and because those are 3-run means, that climb is worth about the +5.3 the overall score gained: the whole improvement was category, with nothing regressing elsewhere. The rule wasn't a Qwen-specific patch, either. It nudged Gemini's flaky case to solid too, with no regression anywhere. A model-neutral improvement that I only discovered by trying to onboard a model my prompt had quietly excluded.&lt;/p&gt;

&lt;p&gt;That fix shipped, to the Gemini path I'm actually running.&lt;/p&gt;

&lt;h2&gt;
  
  
  So did I switch?
&lt;/h2&gt;

&lt;p&gt;No. I'll be honest about why, because the honest reasons are the useful ones: this app is parked, I'm effectively its only active user, and at my volume the 18× price difference is &lt;em&gt;cents per receipt&lt;/em&gt;. There is no production case for swapping. Qwen3-VL-32B turned out to be a genuinely viable, far cheaper model, about 15 checks behind a frontier model and most of those look tunable, but "viable and cheaper" doesn't beat "already working" when the savings round to zero.&lt;/p&gt;

&lt;p&gt;What I actually got out of it was better than a model swap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Three copies of one bug, found and killed: the probe, the test, and the prompt all summed &lt;code&gt;price × quantity&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The field renamed &lt;code&gt;price → lineTotal&lt;/code&gt; where the code does money math, plus the reconciliation test the bug had been hiding from.&lt;/li&gt;
&lt;li&gt;A model-neutral prompt improvement for the model I kept.&lt;/li&gt;
&lt;li&gt;A sharper picture of what my benchmark was really measuring.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a challenger model "fails," the first suspect should be your own measurement: the metric, the pipeline, the prompt you wrote for one model without realising it. I didn't switch in the end, but that was because Qwen is narrowly behind and the savings are cents at my volume. It was never because it failed a test it actually passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually tell another developer
&lt;/h2&gt;

&lt;p&gt;I've argued before that &lt;a href="https://blog.alleyne.dev/the-job-isn-t-writing-code-it-s-knowing-when-the-ai-is-wrong" rel="noopener noreferrer"&gt;catching where the AI is wrong&lt;/a&gt; is the core of the job. This is me catching myself. My coding agent generated the original schema, the first prompt, and the tests, and I revised that prompt across 40-plus commits afterward without ever going back to question what it had built underneath. So the same wrong assumption about what &lt;code&gt;price&lt;/code&gt; meant was sitting in all three, and all three agreed with each other.&lt;/p&gt;

&lt;p&gt;That agreement was worthless, and it helps to see why. The wrong formula was &lt;code&gt;price × quantity&lt;/code&gt;: the prompt told the model to sum it, my probe computed it, and the test asserted it. Watch what it does to two kinds of line, remembering that &lt;code&gt;price&lt;/code&gt; already holds the line total:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line&lt;/th&gt;
&lt;th&gt;&lt;code&gt;price × quantity&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;single item (&lt;code&gt;quantity: 1&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;3.99 × 1 = 3.99&lt;/td&gt;
&lt;td&gt;✓ matches the line total&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;multi-buy (&lt;code&gt;quantity: 8&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;5.52 × 8 = 44.16&lt;/td&gt;
&lt;td&gt;✗ line total is 5.52&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every fixture the reconciliation check ran on was a single item at quantity 1, where &lt;code&gt;price × 1&lt;/code&gt; and the line total are the same number, so the formula looked correct and the check stayed green. It only breaks on a multi-buy line like the Gala Apples (&lt;code&gt;8 @ 0.69&lt;/code&gt;), and the check never ran on one. Those receipts were in my fixture set; reconciliation just wasn't scoped to them. The prompt taught the model the formula, the code checked with it, and the test agreed because it never met the input that exposes it.&lt;/p&gt;

&lt;p&gt;When the code, the test, and the prompt all trace back to one source, their agreement doesn't verify anything. It echoes the same assumption back to you. You don't catch that by reading the code more carefully, because a careful read just runs the assumption again in your head. You catch it by checking against something the model never generated: the printed total on the receipt, a known-good incumbent, an invariant from the real world.&lt;/p&gt;

&lt;p&gt;I got a second demonstration for free. The fix that renamed &lt;code&gt;price&lt;/code&gt; to &lt;code&gt;lineTotal&lt;/code&gt; quietly broke a downstream check that still read the old field. My agent wrote it and TypeScript passed it, because a stray &lt;code&gt;any&lt;/code&gt; let the blind spot survive the rename. A different coding agent, Cursor, reviewing the diff cold, caught what mine couldn't. Same lesson, one level up: the fix agreed with itself until something that hadn't written it took a look.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The receipt scanner is &lt;a href="https://app.bankinginbim.com" rel="noopener noreferrer"&gt;Receipt Tracker&lt;/a&gt;; the code is private but I'm happy to talk shop about any of the above. Originally published on &lt;a href="https://blog.alleyne.dev/code-test-prompt-all-agreed" rel="noopener noreferrer"&gt;blog.alleyne.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>benchmarking</category>
    </item>
    <item>
      <title>The Job Isn't Writing Code. It's Knowing When the AI Is Wrong.</title>
      <dc:creator>Damien Alleyne</dc:creator>
      <pubDate>Fri, 20 Feb 2026 21:32:29 +0000</pubDate>
      <link>https://dev.to/dalleyne/the-job-isnt-writing-code-its-knowing-when-the-ai-is-wrong-4fek</link>
      <guid>https://dev.to/dalleyne/the-job-isnt-writing-code-its-knowing-when-the-ai-is-wrong-4fek</guid>
      <description>&lt;p&gt;I use an AI coding agent for almost everything on my job board &lt;a href="https://jobs.alleyne.dev/" rel="noopener noreferrer"&gt;GlobalRemote&lt;/a&gt;. It writes my scrapers, builds my CI pipelines, architects my database schemas. It's written the vast majority of the codebase.&lt;/p&gt;

&lt;p&gt;After a few months of building this way, I've noticed a pattern: the most valuable thing I do isn't writing code. It's catching where the AI gets it wrong — specifically the cases where the output looks correct but doesn't hold up once you think about it.&lt;/p&gt;

&lt;p&gt;Here are three recent examples.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The Wrong Tool for the Job
&lt;/h2&gt;

&lt;p&gt;My pipeline extracts tech stack requirements from job postings using regex. A role showed up on the board with no tech stack listed. The AI investigated, found the regex wasn't matching that posting's format, and proposed expanding the regex pattern.&lt;/p&gt;

&lt;p&gt;Fair enough. But we already had LLMs classifying and extracting other fields from these same job descriptions. Why maintain a brittle regex when we could use the LLM we're already paying for?&lt;/p&gt;

&lt;p&gt;The agent agreed and built the LLM-based extraction instead. More resilient, handles edge cases the regex never would have caught.&lt;/p&gt;

&lt;p&gt;The AI optimized within the current approach. I questioned whether the approach itself was right. That's a pattern I keep seeing — AI agents are excellent at solving the problem you give them, but they don't question whether you're solving the right problem. That's still on you.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Technically Correct, Actually Misleading
&lt;/h2&gt;

&lt;p&gt;My pipeline extracted geographic data from a GitLab job posting — a role open in the US, Canada, France, Germany, Ireland, Netherlands, Spain, and the UK — and tagged it as &lt;code&gt;multi-region&lt;/code&gt; with regions &lt;code&gt;Americas&lt;/code&gt; and &lt;code&gt;Europe&lt;/code&gt;. I asked the agent to verify. It confirmed the data was accurate — the posting listed countries across both regions.&lt;/p&gt;

&lt;p&gt;The problem: if a user from Brazil sees "Americas", they'll assume they can apply. Someone in Hungary sees "Europe", same thing. But this job is only open in 8 specific countries.&lt;/p&gt;

&lt;p&gt;The agent hadn't considered this. It checked my existing data, found I already had a &lt;code&gt;select-countries&lt;/code&gt; badge for this situation, updated the job, and then updated the LLM extraction prompt so the system would get this distinction right on future runs.&lt;/p&gt;

&lt;p&gt;I caught this because I've been the person in a non-obvious country getting excluded from roles that say "Americas" or "Global Remote." I've had Zapier, Outliant, and others reject me on location after their postings implied I was eligible.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The Silent Failure
&lt;/h2&gt;

&lt;p&gt;My pipeline ran on schedule. Scraped 39 jobs. Processed them. Reported: "No new entries to add." No errors, clean exit.&lt;/p&gt;

&lt;p&gt;Zero new jobs from 39 listings didn't seem right. I pulled the raw data and asked the agent to audit its own pipeline's decisions.&lt;/p&gt;

&lt;p&gt;It found two bugs. One was a dedup rule incorrectly matching a new job against a discontinued listing with a similar title — different posting, different job ID, valid salary data, silently dropped. The other was a salary field that the pipeline never parsed, so jobs with visible salary data were being dropped for "no salary transparency."&lt;/p&gt;

&lt;p&gt;The pipeline didn't error or warn. It reported success while quietly dropping valid jobs.&lt;/p&gt;

&lt;p&gt;I didn't catch this by reading code. I caught it because the output didn't pass a gut check.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;Ben Shoemaker wrote a &lt;a href="https://www.benshoemaker.us/writing/in-defense-of-not-reading-the-code/" rel="noopener noreferrer"&gt;piece recently&lt;/a&gt; arguing that engineers should stop reading code line-by-line and invest in the "harness" — specs, tests, verification layers, trust boundaries. OpenAI calls this &lt;a href="https://openai.com/index/harness-engineering/" rel="noopener noreferrer"&gt;Harness Engineering&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Looking at these three examples through that lens, that's what I've been doing without realizing it. The AI handles production. I handle specification, trust boundaries, and the "does this actually make sense for my users?" layer.&lt;/p&gt;

&lt;p&gt;If you're an engineer building with AI tools right now, I'd suggest paying attention to the moments where you override the AI's suggestions. Those moments aren't interruptions to your workflow — they're the most valuable part of it. That's the skill set the market is shifting toward, and it's worth documenting for yourself even if you never publish it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm a Senior Software Engineer with over a decade of experience, including building internationalization systems serving 50M+ users. I write about building with AI at&lt;/em&gt; &lt;a href="http://blog.alleyne.dev" rel="noopener noreferrer"&gt;&lt;em&gt;blog.alleyne.dev&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>career</category>
      <category>coding</category>
    </item>
    <item>
      <title>I Benchmarked 6 LLMs to Automate My Job Board for $0.35/Month</title>
      <dc:creator>Damien Alleyne</dc:creator>
      <pubDate>Tue, 10 Feb 2026 14:00:26 +0000</pubDate>
      <link>https://dev.to/dalleyne/i-benchmarked-6-llms-to-automate-my-job-board-for-035month-3j3a</link>
      <guid>https://dev.to/dalleyne/i-benchmarked-6-llms-to-automate-my-job-board-for-035month-3j3a</guid>
      <description>&lt;h2&gt;
  
  
  Background
&lt;/h2&gt;

&lt;p&gt;I run a curated remote job board (&lt;a href="https://jobs.alleyne.dev" rel="noopener noreferrer"&gt;GlobalRemote&lt;/a&gt;) focused on established remote-first companies with transparent salaries that hire globally — companies like GitLab, Automattic, Buffer, and Zapier. I &lt;a href="https://blog.alleyne.dev/from-interview-surprise-to-mvp-testing-developer-job-transparency" rel="noopener noreferrer"&gt;started it last September&lt;/a&gt; to scratch my own itch, manually curating every listing — researching interview processes, verifying geographic restrictions, and cross-referencing salary data. That worked for a small board, but didn't scale.&lt;/p&gt;

&lt;p&gt;So I &lt;a href="https://blog.alleyne.dev/i-built-2-job-scrapers-in-one-weekend-to-avoid-paying-for-data" rel="noopener noreferrer"&gt;built custom Apify scrapers&lt;/a&gt; with department-level filtering to pull only engineering, product, design, and data roles from Greenhouse and Ashby boards. That cut the noise by 80%, but I still needed to automatically:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Classify&lt;/strong&gt; whether a scraped job is a relevant tech role (engineer, designer, data scientist, PM) — department filtering catches the obvious non-tech roles, but borderline titles like "Integrations Consultant" or "Senior Sales Engineer" still slip through&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extract&lt;/strong&gt; details that aren't in the job board's structured fields — geographic eligibility, regional variants, and salary data when it's buried in the description text&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Previously, this pipeline ran locally using Ollama with qwen3:8b. I wanted to move it entirely to the cloud (GitHub Actions) using cheap API models, so it runs automatically twice a week without my local machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Question
&lt;/h2&gt;

&lt;p&gt;Which cloud LLM models give the best accuracy for classification and extraction, at the lowest cost? Should we use the same model for both tasks, or different models for each?&lt;/p&gt;

&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Ground Truth Dataset
&lt;/h3&gt;

&lt;p&gt;I built a test set from my own production data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Classification (50 tests):&lt;/strong&gt; 25 jobs that ARE on my board (known relevant, with expected categories) + 25 jobs that are NOT relevant (sales, marketing, HR, finance, legal titles from the same companies)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extraction (5 tests):&lt;/strong&gt; Jobs with known geographic badges and salary ranges, covering &lt;code&gt;open-globally&lt;/code&gt;, &lt;code&gt;multi-region&lt;/code&gt;, &lt;code&gt;us-canada-only&lt;/code&gt;, &lt;code&gt;americas-only&lt;/code&gt;, and &lt;code&gt;null&lt;/code&gt; salary cases&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Models Tested
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Pricing (input/output per 1M tokens)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;Fast inference&lt;/td&gt;
&lt;td&gt;$0.80 / $4.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5 Mini&lt;/td&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;Reasoning&lt;/td&gt;
&lt;td&gt;~$0.15 / ~$0.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5 Nano&lt;/td&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;Reasoning (smallest)&lt;/td&gt;
&lt;td&gt;~$0.05 / ~$0.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 2.5 Flash&lt;/td&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;Fast inference&lt;/td&gt;
&lt;td&gt;$0.15 / $0.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3 Flash (preview)&lt;/td&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;Fast inference (preview)&lt;/td&gt;
&lt;td&gt;~$0.15 / ~$0.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3 8B&lt;/td&gt;
&lt;td&gt;Alibaba (via Ollama)&lt;/td&gt;
&lt;td&gt;Open-source&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; GPT-4o-mini and Gemini 2.0 Flash were also tested initially but replaced with their successors (GPT-5 Mini, Gemini 2.5 Flash) for the final benchmarks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prompts
&lt;/h3&gt;

&lt;p&gt;Same prompts used across all models — the exact prompts from my production pipeline:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Classification prompt:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Classify this job title for a tech job board. Respond with JSON only.

Title: {title}
Company: {company}

{"isRelevant": true/false, "category": "engineering|product|design|data|other", "reason": "1-3 words"}

Rules:
- engineering: Software Engineer, Platform Engineer, SRE, DevOps, QA, Solutions Engineer, Design Engineer, Developer Advocate, Security Engineer
- product: Product Manager ONLY (not Growth Manager, not Renewals Manager)
- design: Product Designer, UX Designer, Visual Designer, Brand Designer
- data: Data Scientist, Data Engineer, ML Engineer, AI Engineer, Research Scientist
- ALL other roles (sales, marketing, HR, support, finance, legal) → isRelevant: false, category: "other"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Extraction prompt:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Extract job details from this posting. Respond with JSON only.

Title: {title}
Company: {company}
Text: {description}

{
  "geoBadge": "open-globally|americas-only|emea-only|...|multi-region|...",
  "regionalVariants": ["Americas", "EMEA", "APAC"] or null,
  "salaryMin": number or null,
  "salaryMax": number or null,
  "salaryCurrency": "USD" or "EUR" or "GBP" or "CAD" or null,
  "reasoning": "brief explanation"
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Full Comparison Table
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;F1 (overall score)&lt;/th&gt;
&lt;th&gt;Precision (% flagged that were correct)&lt;/th&gt;
&lt;th&gt;Recall (% of relevant jobs caught)&lt;/th&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Geography&lt;/th&gt;
&lt;th&gt;Salary&lt;/th&gt;
&lt;th&gt;Cost/run&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GPT-5 Mini&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;94.1%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;92.3%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;96.0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;96.0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;80.0%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.008&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Claude Haiku 4.5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;91.7%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;95.7%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;88.0%&lt;/td&gt;
&lt;td&gt;88.0%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.019&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 2.5 Flash&lt;/td&gt;
&lt;td&gt;89.8%&lt;/td&gt;
&lt;td&gt;91.7%&lt;/td&gt;
&lt;td&gt;88.0%&lt;/td&gt;
&lt;td&gt;88.0%&lt;/td&gt;
&lt;td&gt;80.0%&lt;/td&gt;
&lt;td&gt;80.0%&lt;/td&gt;
&lt;td&gt;$0.003&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3 Flash (preview)&lt;/td&gt;
&lt;td&gt;89.4%&lt;/td&gt;
&lt;td&gt;95.5%&lt;/td&gt;
&lt;td&gt;84.0%&lt;/td&gt;
&lt;td&gt;84.0%&lt;/td&gt;
&lt;td&gt;40.0%&lt;/td&gt;
&lt;td&gt;40.0%&lt;/td&gt;
&lt;td&gt;$0.003&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3 8B&lt;/td&gt;
&lt;td&gt;85.7%&lt;/td&gt;
&lt;td&gt;77.4%&lt;/td&gt;
&lt;td&gt;96.0%&lt;/td&gt;
&lt;td&gt;92.0%&lt;/td&gt;
&lt;td&gt;60.0%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5 Nano&lt;/td&gt;
&lt;td&gt;23.3%&lt;/td&gt;
&lt;td&gt;27.8%&lt;/td&gt;
&lt;td&gt;20.0%&lt;/td&gt;
&lt;td&gt;20.0%&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;$0.006&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Key Findings
&lt;/h3&gt;

&lt;h4&gt;
  
  
  1. GPT-5 Mini is the best classifier
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;F1: 94.1%&lt;/strong&gt; with 96% recall — it catches nearly every relevant job&lt;/li&gt;
&lt;li&gt;Only 1 false negative: "Senior Marketing Data Analyst" (ambiguous title)&lt;/li&gt;
&lt;li&gt;2 false positives: "Integrations Consultant" and "Senior Sales Engineer" (borderline roles)&lt;/li&gt;
&lt;li&gt;96% category accuracy — correctly distinguishes engineering vs. design vs. data&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  2. Claude Haiku 4.5 is the best extractor
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;100% geography badge accuracy&lt;/strong&gt; — correctly identifies open-globally, multi-region, us-canada-only, americas-only&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;100% salary accuracy&lt;/strong&gt; — extracts exact numbers and currency, handles null correctly&lt;/li&gt;
&lt;li&gt;Classification is good (91.7% F1) but misses some edge cases like "Growth Designer"&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  3. GPT-5 Nano is unusable
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;23.3% F1, 0% extraction accuracy — massive JSON parse errors&lt;/li&gt;
&lt;li&gt;Classified most jobs as irrelevant, couldn't extract structured data&lt;/li&gt;
&lt;li&gt;Despite being cheapest, it costs MORE than Gemini 2.5 Flash while being terrible&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verdict: Do not use GPT-5 Nano for structured extraction tasks&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  4. Gemini 3 Flash (preview) has JSON reliability issues
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Classification is decent (89.4% F1) but extraction fails 60% of the time with parse errors&lt;/li&gt;
&lt;li&gt;The preview model wraps JSON in markdown code blocks or adds commentary&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not production-ready yet&lt;/strong&gt; — wait for GA&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  5. Gemini 2.5 Flash is the budget option
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Cheapest cloud model at $0.003/run&lt;/li&gt;
&lt;li&gt;Decent classification (89.8% F1) but weaker extraction (80% geography, 80% salary)&lt;/li&gt;
&lt;li&gt;One parse error on a EUR salary extraction test&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  6. Qwen3 8B is surprisingly capable
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Free and runs locally&lt;/li&gt;
&lt;li&gt;85.7% F1 classification — decent but too many false positives (7)&lt;/li&gt;
&lt;li&gt;100% salary extraction but only 60% geography badge accuracy&lt;/li&gt;
&lt;li&gt;Misclassifies "multi-region" as "open-globally" consistently&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Error Pattern Analysis
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Common false negatives across models:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Senior Marketing Data Analyst" — every model flagged this as marketing, not data. Ambiguous title.&lt;/li&gt;
&lt;li&gt;"Data Analyst, Customer Intelligence" — "Customer" in title triggers exclusion&lt;/li&gt;
&lt;li&gt;"Senior Growth Designer" — "Growth" confuses classification&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Common false positives:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Integrations Consultant - Americas" — every model said "engineering" (borderline role)&lt;/li&gt;
&lt;li&gt;"Senior Sales Engineer" — GPT-5 Mini incorrectly treated as engineering&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Extraction patterns:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Americas" region consistently extracted correctly by Claude Haiku&lt;/li&gt;
&lt;li&gt;"US and Europe" → "multi-region" was the hardest badge to get right&lt;/li&gt;
&lt;li&gt;EUR salary with different format than USD tripped up Gemini models&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Decision: Hybrid Model Strategy
&lt;/h2&gt;

&lt;p&gt;Based on benchmarks, I implemented &lt;strong&gt;task-based model routing&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Primary Model&lt;/th&gt;
&lt;th&gt;Fallback&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Classification&lt;/td&gt;
&lt;td&gt;GPT-5 Mini&lt;/td&gt;
&lt;td&gt;Claude Haiku → Ollama&lt;/td&gt;
&lt;td&gt;Best F1 (94.1%), best recall (96%), best category accuracy (96%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extraction&lt;/td&gt;
&lt;td&gt;Claude Haiku&lt;/td&gt;
&lt;td&gt;GPT-5 Mini → Ollama&lt;/td&gt;
&lt;td&gt;Perfect geography + salary (100%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Cost Estimate
&lt;/h3&gt;

&lt;p&gt;Per twice-weekly ingestion run (~50-100 jobs to classify, ~10-20 to extract):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Estimated tokens&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Classification&lt;/td&gt;
&lt;td&gt;GPT-5 Mini&lt;/td&gt;
&lt;td&gt;~30K input, ~5K output&lt;/td&gt;
&lt;td&gt;~$0.008&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extraction&lt;/td&gt;
&lt;td&gt;Claude Haiku&lt;/td&gt;
&lt;td&gt;~20K input, ~5K output&lt;/td&gt;
&lt;td&gt;~$0.036&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total per run&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$0.044&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Monthly (8 runs)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$0.35&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Implementation
&lt;/h3&gt;

&lt;p&gt;The routing is handled in a small LLM abstraction layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TASK_ROUTING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;claude&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ollama&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;   &lt;span class="c1"&gt;// GPT-5 Mini first&lt;/span&gt;
  &lt;span class="na"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;claude&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ollama&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;   &lt;span class="c1"&gt;// Claude Haiku first&lt;/span&gt;
  &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;claude&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ollama&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Pipeline calls specify the task:&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;batchGenerateJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;classificationPrompts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;classify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;batchGenerateJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;extractionPrompts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;extract&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each task resolves to the best available model in priority order. If OpenAI is down, classification falls back to Claude Haiku. If Anthropic is down, extraction falls back to GPT-5 Mini. Ollama is available as a fallback for local development, but isn't used in the cloud pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  GPT-5 API Gotchas
&lt;/h2&gt;

&lt;p&gt;If you're migrating from GPT-4o-mini to GPT-5 models, watch out for:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;temperature&lt;/code&gt; parameter&lt;/strong&gt; — GPT-5 models are reasoning models (like o1/o3). They don't accept &lt;code&gt;temperature&lt;/code&gt;. Remove it entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;max_completion_tokens&lt;/code&gt; not &lt;code&gt;max_tokens&lt;/code&gt;&lt;/strong&gt; — The parameter name changed for reasoning models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;response_format: { type: 'json_object' }&lt;/code&gt; still works&lt;/strong&gt; — JSON mode is supported via Chat Completions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chat Completions API still works&lt;/strong&gt; — Despite OpenAI promoting the new Responses API, Chat Completions hasn't been deprecated. For simple single-turn JSON extraction, Chat Completions is fine.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Timeline
&lt;/h2&gt;

&lt;p&gt;I ran a local pipeline with Ollama for a while, which worked but required my Mac to be on. Moving everything to the cloud was another weekend:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Benchmarked 6 cloud models to find the best fit for classification and extraction&lt;/li&gt;
&lt;li&gt;Built the cloud pipeline on GitHub Actions with hybrid model routing&lt;/li&gt;
&lt;li&gt;Hardened data quality — HTML-based requirements extraction, fuzzy title dedup, paid-trial badge detection&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Result:&lt;/strong&gt; Fully automated, runs twice a week, creates PRs for review, costs ~$0.35/month&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>ai</category>
      <category>webscraping</category>
      <category>llm</category>
    </item>
    <item>
      <title>I Built 2 Job Scrapers in One Weekend to Avoid Paying for Data</title>
      <dc:creator>Damien Alleyne</dc:creator>
      <pubDate>Mon, 02 Feb 2026 17:26:51 +0000</pubDate>
      <link>https://dev.to/dalleyne/i-built-2-job-scrapers-in-one-weekend-to-avoid-paying-for-data-4njp</link>
      <guid>https://dev.to/dalleyne/i-built-2-job-scrapers-in-one-weekend-to-avoid-paying-for-data-4njp</guid>
      <description>&lt;p&gt;I run &lt;a href="https://jobs.alleyne.dev" rel="noopener noreferrer"&gt;GlobalRemote&lt;/a&gt;, a curated job board that shows interview processes and hiring transparency upfront. To keep it relevant, I needed to update it &lt;strong&gt;2x per week&lt;/strong&gt; with fresh jobs from Greenhouse and Ashby boards.&lt;/p&gt;

&lt;p&gt;The problem? The scraper I was using fetched &lt;em&gt;every&lt;/em&gt; job from each company — Sales, HR, Support, everything — and stored it all in my Apify dataset. With 6-8 companies, that's 300-400 jobs per scrape, but only 5-10 were actually relevant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I was burning through my Apify free tier ($5/month, ~2000 dataset operations) on irrelevant data.&lt;/strong&gt; Two scrapes per week would blow past my quota. I wasn't ready to pay for a higher tier just to subsidize wasteful scraping.&lt;/p&gt;

&lt;p&gt;So my options were:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Update infrequently (once every 2-3 weeks) and let the board go stale&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Pay for a higher Apify tier to subsidize wasteful scraping&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Build my own scrapers with department filtering&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I chose #3.&lt;/p&gt;

&lt;p&gt;The scrapers are now &lt;a href="https://apify.com/dalleyne" rel="noopener noreferrer"&gt;live on Apify Store&lt;/a&gt;, open-source, and I'm dogfooding them on GlobalRemote right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: I Couldn't Update Frequently Enough
&lt;/h2&gt;

&lt;p&gt;The scraper I was using worked like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Fetch all jobs from a company's job board&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Store everything in an Apify dataset&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I filter locally for the jobs I actually want&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This makes sense if you want &lt;em&gt;all&lt;/em&gt; the jobs. But for a curated board like GlobalRemote, I only wanted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Engineering roles (not Sales, Marketing, HR)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;From specific departments (e.g., "Code Wrangling" at Automattic, "Engineering" at GitLab)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Recent postings (not 6-month-old listings)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With 300-400 jobs stored per scrape and only 5-10 relevant, I was wasting my dataset quota. &lt;strong&gt;Two scrapes per week would exceed my free tier limit.&lt;/strong&gt; The choice was: pay for a higher tier or update less frequently. Neither was ideal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: Per-URL Department Filtering
&lt;/h2&gt;

&lt;p&gt;I built two Apify actors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://apify.com/dalleyne/greenhouse-job-scraper" rel="noopener noreferrer"&gt;&lt;strong&gt;Greenhouse Job Scraper&lt;/strong&gt;&lt;/a&gt; (Automattic, GitLab, Speechify, etc.)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://apify.com/dalleyne/ashby-job-scraper" rel="noopener noreferrer"&gt;&lt;strong&gt;Ashby Job Scraper&lt;/strong&gt;&lt;/a&gt; (Buffer, Zapier, RevenueCat, etc.)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both support &lt;strong&gt;per-URL configuration&lt;/strong&gt;, meaning each company can have different filters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"urls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://job-boards.greenhouse.io/automatticcareers"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"departments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;307170&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"maxJobs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"daysBack"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://job-boards.greenhouse.io/gitlab"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"departments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4011044002&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"maxJobs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scraper:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Fetches department metadata&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Filters jobs by department ID &lt;em&gt;before&lt;/em&gt; storing them&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Only stores jobs that match your criteria&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You only pay for the jobs you actually get (not the ones filtered out)&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; I went from storing 300-400 jobs per scrape to 30-50 jobs — an 80% reduction in dataset usage.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Tech Stack
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Apify platform&lt;/strong&gt; — handles hosting, scheduling, dataset storage&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Greenhouse + Ashby APIs&lt;/strong&gt; — public APIs for job boards&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AI (Claude)&lt;/strong&gt; — for rapid development&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  How the APIs Work
&lt;/h3&gt;

&lt;p&gt;Both platforms expose public APIs for their job boards. This meant I could:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Fetch departments/teams programmatically&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Filter by department/team ID before fetching job details&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Only pull full job data for matches&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;No browser automation or HTML scraping needed&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is key: I'm filtering &lt;em&gt;before&lt;/em&gt; fetching details, not after. Most scrapers fetch everything, then you filter locally. Mine filters first, then only fetches what you need.&lt;/p&gt;

&lt;h3&gt;
  
  
  Development Process
&lt;/h3&gt;

&lt;p&gt;I built both scrapers over one weekend using AI (Claude).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Saturday (Jan 31):&lt;/strong&gt; Greenhouse scraper&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Prompt: "Build an Apify actor that scrapes Greenhouse job boards with department filtering"&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;AI figured out the API structure&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I tested on Automattic and GitLab job boards&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Sunday (Feb 1):&lt;/strong&gt; Ashby scraper&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Prompt: "Build an Apify actor for Ashby job boards with department filtering (similar structure to the existing Greenhouse scraper)"&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;AI figured out Ashby's API&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Tested on Buffer, Zapier, RevenueCat&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What AI handled:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Reading API documentation (Greenhouse, Ashby, Apify actor structure)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Writing the scraper logic and Apify boilerplate&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Handling edge cases (null departments, missing dates)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Generating input/output schemas&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I did:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Product decisions (per-URL config vs global config)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Testing on real job boards&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Iterating when things didn't work&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Catching issues (e.g., updated Node 20 → 22 in Dockerfile)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;I never opened:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://developers.greenhouse.io/job-board.html" rel="noopener noreferrer"&gt;Greenhouse API documentation&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://developers.ashbyhq.com/docs/public-job-posting-api" rel="noopener noreferrer"&gt;Ashby API documentation&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://docs.apify.com/platform/actors" rel="noopener noreferrer"&gt;Apify's actor documentation&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Total development time: One weekend.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AI is a co-pilot, not autopilot - but it handled all the research and boilerplate so I could focus on testing and product decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dogfooding on GlobalRemote
&lt;/h2&gt;

&lt;p&gt;I'm using both scrapers to populate &lt;a href="https://jobs.alleyne.dev" rel="noopener noreferrer"&gt;GlobalRemote&lt;/a&gt; right now.&lt;/p&gt;

&lt;p&gt;When I need fresh data, I trigger both scrapers. They return 30-50 relevant jobs instead of 300-400, keeping me well within my Apify free tier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I've learned from dogfooding:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Department filtering reduced dataset usage by ~80%&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I can now update regularly without exceeding my quota&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the scrapers break, GlobalRemote breaks. That's a strong incentive to keep them working.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Filter before storing, not after
&lt;/h3&gt;

&lt;p&gt;For curated job boards, filtering &lt;em&gt;before&lt;/em&gt; storage is way more cost-effective. The scraper I was using didn't do this.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Per-URL config beats global config
&lt;/h3&gt;

&lt;p&gt;My first version had global department filters (same filter for all companies). That was a mistake. Different companies organize departments differently. Per-URL config gives users way more flexibility.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Real examples &amp;gt; Fake examples
&lt;/h3&gt;

&lt;p&gt;In my README, I used &lt;em&gt;real&lt;/em&gt; companies (Automattic, GitLab) and &lt;em&gt;real&lt;/em&gt; department IDs (307170 = "Code Wrangling" at Automattic). Fake examples would've been useless for someone trying to replicate this.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. AI accelerates weekend projects into production tools
&lt;/h3&gt;

&lt;p&gt;I shipped two working scrapers in one weekend without reading a single API doc. AI handled research and implementation; I handled product decisions and testing. That's the real power of AI in 2026.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Open-sourcing on Apify was easy
&lt;/h3&gt;

&lt;p&gt;Publishing to Apify Store took ~10 minutes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Add README&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Set pricing&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Add input/output schemas&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Add Banking information (they prefer PayPal)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Click "Publish"&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Both scrapers are live and stable. I will be using them on GlobalRemote twice a week, well within my free tier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Potential improvements:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Add automated tests (right now it's just manual verification)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Add salary parsing to Ashby scraper (Greenhouse already extracts salary ranges)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Build a Lever scraper (if there's demand)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;But honestly?&lt;/strong&gt; I built these to solve my own problem. If other people find them useful, great. If not, I'm still updating GlobalRemote 2x/week without blowing my budget.&lt;/p&gt;




&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Greenhouse scraper:&lt;/strong&gt; &lt;a href="http://apify.com/dalleyne/greenhouse-job-scraper" rel="noopener noreferrer"&gt;apify.com/dalleyne/greenhouse-job-scraper&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Ashby scraper:&lt;/strong&gt; &lt;a href="http://apify.com/dalleyne/ashby-job-scraper" rel="noopener noreferrer"&gt;apify.com/dalleyne/ashby-job-scraper&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GlobalRemote:&lt;/strong&gt; &lt;a href="http://jobs.alleyne.dev" rel="noopener noreferrer"&gt;jobs.alleyne.dev&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; Both scrapers are MIT licensed - &lt;a href="https://github.com/d-alleyne/greenhouse-job-scraper" rel="noopener noreferrer"&gt;Greenhouse&lt;/a&gt; | &lt;a href="https://github.com/d-alleyne/ashby-job-scraper" rel="noopener noreferrer"&gt;Ashby&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building a job board or need ATS data, feel free to use them. And if you have feedback or find bugs, I'm on &lt;a href="https://linkedin.com/in/damienalleyne" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; or reachable via Apify.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>showdev</category>
      <category>sideprojects</category>
      <category>webscraping</category>
    </item>
  </channel>
</rss>
