<?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: Salih Küçükkayış</title>
    <description>The latest articles on DEV Community by Salih Küçükkayış (@saliga_software).</description>
    <link>https://dev.to/saliga_software</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%2F4177566%2F83f04f62-816c-4d7f-bbba-fddc8928b0bc.png</url>
      <title>DEV Community: Salih Küçükkayış</title>
      <link>https://dev.to/saliga_software</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/saliga_software"/>
    <language>en</language>
    <item>
      <title>We sent an AI agent to 100 SaaS websites. It found the price on 90, and how to cancel on 62.</title>
      <dc:creator>Salih Küçükkayış</dc:creator>
      <pubDate>Wed, 21 Oct 2026 13:03:31 +0000</pubDate>
      <link>https://dev.to/saliga_software/we-sent-an-ai-agent-to-100-saas-websites-it-found-the-price-on-90-and-how-to-cancel-on-62-198p</link>
      <guid>https://dev.to/saliga_software/we-sent-an-ai-agent-to-100-saas-websites-it-found-the-price-on-90-and-how-to-cancel-on-62-198p</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://salihk.dev/blog/ai-agent-100-saas-sites/" rel="noopener noreferrer"&gt;salihk.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;More and more of the web is read by agents working for someone: a browser agent comparing tools, a research assistant checking a price. So I wanted to know a plain thing. If an AI agent lands on your home page, can it find what a customer would ask?&lt;/p&gt;

&lt;p&gt;I picked 100 well-known SaaS and developer-tool websites and gave a browsing agent three questions for each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Price:&lt;/strong&gt; what does the cheapest paid plan cost per month?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cancel:&lt;/strong&gt; how does a customer cancel a paid subscription?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limit:&lt;/strong&gt; what rate limit applies to the public API?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rules: start on the home page, read and click only, no logging in, no typing, no forms, at most 12 steps, and quote the page the answer came from. Every run is recorded.&lt;/p&gt;

&lt;p&gt;The results, with the recording of every run, are on &lt;a href="https://saligasoftware.com/lab/agent-arena/" rel="noopener noreferrer"&gt;a public page&lt;/a&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Found&lt;/th&gt;
&lt;th&gt;Not found&lt;/th&gt;
&lt;th&gt;Blocked&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Price&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;1 (+5 no fixed price)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cancel&lt;/td&gt;
&lt;td&gt;62&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API rate limit&lt;/td&gt;
&lt;td&gt;63&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is a test of whether the answer can be &lt;strong&gt;found&lt;/strong&gt;, not whether it exists. "Not found" means this agent didn't reach it in 12 steps. As you will see below, sometimes the page is right there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prices are easy, unless the page stops the agent
&lt;/h2&gt;

&lt;p&gt;Five sites have no fixed-price plan to find: Replicate, AssemblyAI and Stytch charge by usage, Lemon Squeezy by transaction, Make by credits. Of the other 95, the agent found the price on 90, usually in two steps: click "Pricing", read.&lt;/p&gt;

&lt;p&gt;Four stopped it with a bot check. On OpenAI, Canva and Box the agent did everything right. It found the pricing link and clicked it, and the pricing page answered with Cloudflare's "Verify you are human". Pipedrive's pricing link led straight to a Cloudflare page that says "Sorry, you have been blocked". We don't try to get past those checks, so those count as blocked.&lt;/p&gt;

&lt;p&gt;▶ &lt;strong&gt;&lt;a href="https://saligasoftware.com/lab/agent-arena/runs/openai-fiyat-1.mp4" rel="noopener noreferrer"&gt;Watch the recording&lt;/a&gt;&lt;/strong&gt; — OpenAI: home page, "Try ChatGPT", "See plans and pricing", then the bot check.&lt;/p&gt;

&lt;p&gt;That is a choice those companies are free to make. It does mean that an agent asked "what does ChatGPT Plus cost?" can't read the answer from the source.&lt;/p&gt;

&lt;p&gt;The one real miss was Heroku. The agent opened heroku.com/pricing, read it, scrolled, and left. The Eco dyno is $5 a month, on that page, in a table that stays collapsed until you press "Show Cedar Dynos". The agent reads what is on screen, and the price wasn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cancel is the hard one
&lt;/h2&gt;

&lt;p&gt;62 of 100. And the misses are not where I expected.&lt;/p&gt;

&lt;p&gt;GitHub has an official page called "&lt;a href="https://docs.github.com/en/billing/how-tos/manage-plan-and-licenses/downgrade-plan" rel="noopener noreferrer"&gt;Downgrading your account's plan&lt;/a&gt;". Vercel's Pro plan docs have a "&lt;a href="https://vercel.com/docs/plans/pro-plan" rel="noopener noreferrer"&gt;Downgrading to Hobby&lt;/a&gt;" section. Netlify has "&lt;a href="https://docs.netlify.com/manage/accounts-and-billing/billing/change-your-pricing-plan/" rel="noopener noreferrer"&gt;Change your pricing plan&lt;/a&gt;". The agent reached none of them, in five attempts each.&lt;/p&gt;

&lt;p&gt;Look at where it went instead. On GitHub: pricing, then "Contact Sales", the support request page, back to pricing, the help centre, out of steps. On Vercel: pricing, the knowledge base, the sales contact page, the knowledge base again. On Netlify: support, pricing, the knowledge base, the contact page.&lt;/p&gt;

&lt;p&gt;▶ &lt;strong&gt;&lt;a href="https://saligasoftware.com/lab/agent-arena/runs/github-iptal-1.mp4" rel="noopener noreferrer"&gt;Watch the recording&lt;/a&gt;&lt;/strong&gt; — GitHub: twelve steps between pricing, sales and support. The downgrade page is in the docs.&lt;/p&gt;

&lt;p&gt;The pattern is that pricing pages talk about getting in, and the way out lives in the docs, one or two levels away from anything linked on the home page. A person searches for "github cancel plan" and finds it. An agent that browses from the home page, the way this one does, doesn't.&lt;/p&gt;

&lt;p&gt;Sites that did well tend to link billing help close to pricing. Figma, Notion, Shopify and Linear were found in four to eight steps, quoted from their help centres and docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  API rate limits: public, or not
&lt;/h2&gt;

&lt;p&gt;63 of 100. Here "not found" mixes two things: limits that are public but deep in the docs, and limits that aren't public at all. Mistral, for one, shows its limits in the console after you sign in. Docs on a separate site make it harder: Trello's limits live on developer.atlassian.com, and the agent only got there on a later attempt. I have not sorted all 35 misses by hand, so I won't say which is which.&lt;/p&gt;

&lt;h2&gt;
  
  
  Are the "found" answers right?
&lt;/h2&gt;

&lt;p&gt;I checked 15 at random against the source page, five per task. 13 were fully right. Two were partly right: for Cursor the agent quoted a line about &lt;em&gt;when&lt;/em&gt; to cancel, not &lt;em&gt;how&lt;/em&gt;. For Confluence it brought back Jira's rate limit page. None were made up.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;A Chromium browser driven by Playwright, at 1280×800. Two models split the work. TypeSafe's Jev picks what to click from the links and buttons on the page. When it thinks the answer may be on the page, a Gemini Flash-Lite model reads the page and writes the answer with a quote. It can click, scroll, go back and give up, nothing else. It respects robots.txt and says who it is in its user agent (&lt;code&gt;AgentArenaBot/0.1&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Every site and task got one run. The misses got two more. Then I fixed two things in the agent, pressing "reject" on cookie banners and following a link's address when an overlay swallows the click, and ran the remaining misses twice more. Up to five attempts. If any of them found the answer, it counts. 788 runs in all, 16 to 19 October.&lt;/p&gt;

&lt;p&gt;At the models' paid rates the whole thing would cost $2.62, about a third of a cent a run. I ran it on free tiers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't tell you
&lt;/h2&gt;

&lt;p&gt;One agent, one week, a desktop browser, run from Istanbul. Prices change with where you are: monday.com showed me €9 a seat; you may see $9. A stronger agent, or one allowed to use a search engine, would find more. A site that changes its pages tomorrow may score differently. And blocking bots on a pricing page may be a perfectly good decision for a company. This only measures what it costs in one situation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data, and your site
&lt;/h2&gt;

&lt;p&gt;Every site, task, answer, quote, source and recording is on the &lt;a href="https://saligasoftware.com/lab/agent-arena/" rel="noopener noreferrer"&gt;results page&lt;/a&gt;, and as &lt;a href="https://saligasoftware.com/lab/agent-arena/data.json" rel="noopener noreferrer"&gt;JSON&lt;/a&gt; and &lt;a href="https://saligasoftware.com/lab/agent-arena/data.csv" rel="noopener noreferrer"&gt;CSV&lt;/a&gt;. The data and the agent's code are on &lt;a href="https://github.com/msalihk/agent-arena-data" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. If a result about your company is wrong, there is a "Wrong? Tell us" link on every row, and I will check and correct it.&lt;/p&gt;

&lt;p&gt;If you want to know how your own site does, &lt;a href="https://docs.google.com/forms/d/e/1FAIpQLSePAELES_V_u93oKa1I9R4sa0aP2EZwfyGVXTlS6u_h3biMHg/viewform" rel="noopener noreferrer"&gt;leave the URL&lt;/a&gt;. I'll run the same three tasks and send you the result with the recordings. For now I run them by hand, so it can take a few days.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>saas</category>
      <category>testing</category>
    </item>
    <item>
      <title>Which email provider and CMS does a company use? Check 1,000 domains in one run</title>
      <dc:creator>Salih Küçükkayış</dc:creator>
      <pubDate>Sun, 18 Oct 2026 15:49:51 +0000</pubDate>
      <link>https://dev.to/saliga_software/which-email-provider-and-cms-does-a-company-use-check-1000-domains-in-one-run-4p0e</link>
      <guid>https://dev.to/saliga_software/which-email-provider-and-cms-does-a-company-use-check-1000-domains-in-one-run-4p0e</guid>
      <description>&lt;p&gt;Sales and IT teams ask the same two questions about a list of companies: &lt;em&gt;are they on Google Workspace or Microsoft 365?&lt;/em&gt; and &lt;em&gt;is the website WordPress, Shopify or something else?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Both answers are public. The email provider is in the domain's MX records; the CMS is in the HTML, headers and cookies of the homepage. &lt;a href="https://apify.com/saliga/tech-stack-detector" rel="noopener noreferrer"&gt;Tech Stack Detector&lt;/a&gt; reads both for a whole list. Disclosure: I built it; it costs $1.50 per 1,000 websites.&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="s2"&gt;"apify.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"github.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gitlab.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"notion.so"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"checkDns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;A real run (4 domains, 8 seconds):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Domain&lt;/th&gt;
&lt;th&gt;emailProvider&lt;/th&gt;
&lt;th&gt;MX&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;apify.com&lt;/td&gt;
&lt;td&gt;Google Workspace&lt;/td&gt;
&lt;td&gt;aspmx.l.google.com&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;github.com&lt;/td&gt;
&lt;td&gt;Microsoft 365&lt;/td&gt;
&lt;td&gt;github-com.mail.protection.outlook.com&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gitlab.com&lt;/td&gt;
&lt;td&gt;Google Workspace&lt;/td&gt;
&lt;td&gt;aspmx.l.google.com&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;notion.so → notion.com&lt;/td&gt;
&lt;td&gt;GoDaddy Email&lt;/td&gt;
&lt;td&gt;smtp.secureserver.net&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Two traps we hit, so you do not have to
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;TXT verification records are not an email provider.&lt;/strong&gt; Many domains carry &lt;code&gt;MS=ms12345&lt;/code&gt; or &lt;code&gt;apple-domain-verification=...&lt;/code&gt; in TXT. That only proves someone verified the domain once. &lt;code&gt;emailProvider&lt;/code&gt; is read from MX only; security gateways (Mimecast, Proofpoint) are named as gateways, since the real mailbox provider is hidden behind them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirects change the domain.&lt;/strong&gt; gitlab.com redirects to about.gitlab.com, which has no MX at all. DNS is read for the domain you gave &lt;em&gt;and&lt;/em&gt; the one the page ended on.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you only need one domain, the command line is enough: &lt;code&gt;dig +short MX example.com&lt;/code&gt;. The Actor is for when you have a few hundred.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the stack
&lt;/h2&gt;

&lt;p&gt;The same row has the CMS, ecommerce platform, analytics, tag managers, CDN and hosting, from 7,000+ open Wappalyzer fingerprints, with a version where the site shows it.&lt;/p&gt;

&lt;p&gt;More copy-paste examples (curl, Python, Node.js, n8n): &lt;a href="https://github.com/msalihk/apify-actor-examples" rel="noopener noreferrer"&gt;github.com/msalihk/apify-actor-examples&lt;/a&gt;&lt;/p&gt;

</description>
      <category>sales</category>
      <category>dns</category>
      <category>webdev</category>
      <category>automation</category>
    </item>
    <item>
      <title>Find broken links and dead sitemap URLs without installing a crawler</title>
      <dc:creator>Salih Küçükkayış</dc:creator>
      <pubDate>Tue, 13 Oct 2026 14:06:15 +0000</pubDate>
      <link>https://dev.to/saliga_software/find-broken-links-and-dead-sitemap-urls-without-installing-a-crawler-1aeo</link>
      <guid>https://dev.to/saliga_software/find-broken-links-and-dead-sitemap-urls-without-installing-a-crawler-1aeo</guid>
      <description>&lt;p&gt;Desktop crawlers are great until you need the check to run every week, on a server, for several sites, and land in a spreadsheet. Then you want something you can call from a schedule or an API.&lt;/p&gt;

&lt;p&gt;I built two small Apify Actors for this. Disclosure: I am the developer, and both are paid per result (cents per run for a small site).&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Broken links on the pages people actually see
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apify.com/saliga/broken-link-checker" rel="noopener noreferrer"&gt;Broken Link Checker&lt;/a&gt; crawls a site, collects every link and image on the pages it visits, and checks each URL once:&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;"startUrls"&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://crawler-test.com/"&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;"maxPagesToCrawl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxLinksToCheck"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;150&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;On that test site, 5 pages gave 143 unique URLs and 8 broken ones in under a minute. Each row says which pages link to the broken URL and with what link text, so you know what to edit:&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;"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://crawler-test.com/robots_protocol/non_200_with_noindex"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"problem"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Not found (404)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"foundOn"&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;"page"&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://crawler-test.com/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Non-200 with Noindex"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tag"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a"&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;Details that matter in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HEAD first, and every error is confirmed with a GET (many servers answer HEAD wrongly), without downloading the file.&lt;/li&gt;
&lt;li&gt;401, 403 and 429 are warnings, not "broken": the page exists but refused a bot.&lt;/li&gt;
&lt;li&gt;robots.txt is respected; a host that does not answer at all is reported as unreachable, not hidden.&lt;/li&gt;
&lt;li&gt;With &lt;code&gt;reportRedirects&lt;/code&gt;, internal links that go through a 301/302 are listed too. Updating them saves a hop.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Pages your sitemap promises but cannot deliver
&lt;/h2&gt;

&lt;p&gt;A sitemap should list only live pages. &lt;a href="https://apify.com/saliga/sitemap-extractor" rel="noopener noreferrer"&gt;Sitemap Extractor&lt;/a&gt; reads every sitemap (robots.txt, indexes, .gz) and, with &lt;code&gt;checkStatus&lt;/code&gt;, checks each URL:&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;"startUrls"&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="s2"&gt;"https://crawler-test.com/test_sitemap.xml"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checkStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxUrlsPerSite"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&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;18 URLs checked in 9 seconds. Redirects come back with their final URL, robots-blocked pages are marked, dead domains have a &lt;code&gt;checkError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Schedule it weekly with &lt;em&gt;Changed since&lt;/em&gt; = &lt;code&gt;7 days&lt;/code&gt; to check only new or changed pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it from code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.apify.com/v2/acts/saliga~broken-link-checker/run-sync-get-dataset-items"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$APIFY_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"startUrls":[{"url":"https://example.com/"}],"maxPagesToCrawl":50}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is the list of problem links as JSON. Pipe it into a Slack message, a GitHub issue or a sheet.&lt;/p&gt;

&lt;p&gt;More copy-paste examples (Python, Node.js, n8n): &lt;a href="https://github.com/msalihk/apify-actor-examples" rel="noopener noreferrer"&gt;github.com/msalihk/apify-actor-examples&lt;/a&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>automation</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>My PDF converter turned every borderless table into loose numbers. The fix runs on a CPU.</title>
      <dc:creator>Salih Küçükkayış</dc:creator>
      <pubDate>Sun, 11 Oct 2026 18:35:01 +0000</pubDate>
      <link>https://dev.to/saliga_software/my-pdf-converter-turned-every-borderless-table-into-loose-numbers-the-fix-runs-on-a-cpu-1e2b</link>
      <guid>https://dev.to/saliga_software/my-pdf-converter-turned-every-borderless-table-into-loose-numbers-the-fix-runs-on-a-cpu-1e2b</guid>
      <description>&lt;p&gt;&lt;a href="https://apify.com/saliga/document-to-markdown" rel="noopener noreferrer"&gt;Document to Markdown&lt;/a&gt; is an Apify Actor I published this week. You give it links to PDFs, Word, PowerPoint or Excel files and get Markdown back, ready for an LLM: headings, lists, page markers and, optionally, chunks for a RAG pipeline with the page number and the nearest heading on each one.&lt;/p&gt;

&lt;p&gt;The first version handled tables the way most text extraction does, which is to say it did not. I only found out how badly when I put it next to the competition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four files, four tools
&lt;/h2&gt;

&lt;p&gt;I picked four public test PDFs that people who build PDF tools already use to embarrass them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a report page with a &lt;strong&gt;borderless&lt;/strong&gt; table: horizontal rules only, a header that spans columns;&lt;/li&gt;
&lt;li&gt;a dense budget table, nine columns, no lines at all;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;scanned&lt;/strong&gt; two-column page with bullet points;&lt;/li&gt;
&lt;li&gt;a two-column article with a real table on its last page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I ran the same four files through my Actor and three others in the Apify Store, each once, from Apify's free credit.&lt;/p&gt;

&lt;p&gt;My converter lost on the two things that mattered. On both tables the cells came out one per line, so the budget became a long column of loose numbers. A model reading that cannot tell which number belongs to which row. The scanned page came out with "¢" where the bullets were, and every paragraph broken at the end of each scanned line.&lt;/p&gt;

&lt;p&gt;The best result came from an ML-based converter with OCR switched on. Its tables were clean Markdown. It also took &lt;strong&gt;13 minutes and 5 seconds&lt;/strong&gt; for the four files and cost $0.148. One page of OCR alone took about eight minutes.&lt;/p&gt;

&lt;p&gt;That is a fine trade for a handful of important documents. It is not one for a pipeline that ingests a few thousand reports a month.&lt;/p&gt;

&lt;h2&gt;
  
  
  What closed the gap
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Borderless tables.&lt;/strong&gt; Text in a PDF has coordinates. When numbers line up in the same horizontal positions down a page, they are a table, lines or no lines. &lt;a href="https://github.com/camelot-dev/camelot" rel="noopener noreferrer"&gt;Camelot&lt;/a&gt;'s "stream" mode (MIT licensed) finds those columns from the spacing alone, in 0.1 to 0.3 seconds a page on one CPU.&lt;/p&gt;

&lt;p&gt;The catch is that a two-column article is also text in two aligned columns, so stream mode happily turns prose into a two-column "table". I only run it on pages that look numeric, and I throw away anything with fewer than three columns, too few numbers, or cells full of long sentences. The two-column article now stays prose, and the real table on its last page is still found.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rows a PDF packs into one cell.&lt;/strong&gt; Some generators put a whole column into one text box with line breaks. Those are split back into rows when there are at least three lines or they are mostly numbers, so a two-line header stays one header.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scanned pages.&lt;/strong&gt; Tesseract already knows which words form a line, a paragraph and a block. Rebuilding the Markdown from that instead of from raw lines fixed the broken paragraphs, and mapping the characters Tesseract reads bullets as ("¢", "©", "•" and friends) to &lt;code&gt;-&lt;/code&gt; fixed the lists. Lines much taller than the page's median become headings.&lt;/p&gt;

&lt;p&gt;After the changes, on the same four files: three tables on the two-page borderless report, the budget as one nine-column table with the same rows as the ML converter, the scanned page with its list and headings. All of it took 23 seconds on one CPU, 21 of them OCR. On Apify, the four files took 16 seconds and $0.001 of compute.&lt;/p&gt;

&lt;p&gt;It is not as good as the ML converter everywhere. A header cell that spans columns comes out as empty cells next to it, and a table inside a scanned page comes out as text. But it is close on the tables that matter, and it is priced at &lt;strong&gt;$2 per 1,000 pages&lt;/strong&gt; ($5 per 1,000 pages that need OCR), with no per-run file limit on any Apify plan, the free one included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it from n8n
&lt;/h2&gt;

&lt;p&gt;The simplest way in is the Apify API's &lt;code&gt;run-sync-get-dataset-items&lt;/code&gt; endpoint: one HTTP request that runs the Actor, waits, and returns the results.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Copy the workflow below and paste it onto an empty n8n canvas (Ctrl/Cmd+V).&lt;/li&gt;
&lt;li&gt;Create a &lt;strong&gt;Header Auth&lt;/strong&gt; credential: name &lt;code&gt;Authorization&lt;/code&gt;, value &lt;code&gt;Bearer YOUR_APIFY_TOKEN&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Put your file URLs in the &lt;strong&gt;Run Actor&lt;/strong&gt; node and run it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A Code node turns every chunk into one n8n item, with what you need to cite the source:&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;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"source"&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://.../report.pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pageStart"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pageEnd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"heading"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.1 Results"&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;Connect that to an embeddings node and a vector store, and keep &lt;code&gt;source&lt;/code&gt; and &lt;code&gt;pageStart&lt;/code&gt; as metadata, so an answer can say which page it came from.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  The n8n workflow (JSON)
  &lt;br&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PDF and Office files → Markdown RAG chunks (Apify Document to Markdown)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"nodes"&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;"parameters"&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Start"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"n8n-nodes-base.manualTrigger"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"typeVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"position"&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;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&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;"parameters"&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;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POST"&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://api.apify.com/v2/acts/saliga~document-to-markdown/run-sync-get-dataset-items"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"authentication"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"genericCredentialType"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"genericAuthType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"httpHeaderAuth"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"sendBody"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"specifyBody"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"jsonBody"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;urls&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: [&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;    &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;https://raw.githubusercontent.com/microsoft/markitdown/main/packages/markitdown/tests/test_files/test.pdf&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;    &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;https://raw.githubusercontent.com/microsoft/markitdown/main/packages/markitdown/tests/test_files/test.docx&lt;/span&gt;&lt;span class="se"&gt;\"\n&lt;/span&gt;&lt;span class="s2"&gt;  ],&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;chunkSize&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: 1500,&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;chunkOverlap&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: 200,&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;extractTables&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: true&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&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;"options"&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;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;310000&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Run Actor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"n8n-nodes-base.httpRequest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"typeVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"position"&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;220&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&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;"parameters"&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;"jsCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"// One n8n item per RAG chunk, with the metadata you need for citations.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;const out = [];&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;for (const doc of $input.all()) {&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;  for (const c of doc.json.chunks ?? []) {&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;    out.push({ json: { text: c.text, source: doc.json.url, title: doc.json.title ?? doc.json.fileName,&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;      pageStart: c.pageStart, pageEnd: c.pageEnd, heading: c.heading, chunk: c.index } });&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;  }&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;return out;"&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"One item per chunk"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"n8n-nodes-base.code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"typeVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"position"&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;440&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&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;"connections"&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;"Start"&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;"main"&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;"node"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Run Actor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"main"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"index"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&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;"Run Actor"&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;"main"&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;"node"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"One item per chunk"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"main"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"index"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&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;span class="nl"&gt;"settings"&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;"executionOrder"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"v1"&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;&lt;/p&gt;

&lt;p&gt;Without n8n it is one request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.apify.com/v2/acts/saliga~document-to-markdown/run-sync-get-dataset-items"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$APIFY_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"urls": ["https://example.com/report.pdf"], "chunkSize": 1500}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An AI agent can use it the same way through the Apify MCP server: it passes file URLs and gets Markdown and chunks back.&lt;/p&gt;

&lt;p&gt;The lesson I keep relearning: my own test files said the converter was fine. Four files someone else picked to be hard said otherwise in an afternoon.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>n8n</category>
      <category>pdf</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
