<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[AnyCrawler]]></title><description><![CDATA[AnyCrawler]]></description><link>https://anycrawler.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 12 Nov 2026 07:27:52 GMT</lastBuildDate><atom:link href="https://anycrawler.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building a RAG Pipeline Without a Scraper Farm: Free Fetch-to-Markdown for Agents]]></title><description><![CDATA[Build a RAG ingest path that fetches a public URL and returns clean Markdown—no scraper farm. Free endpoint included.]]></description><link>https://anycrawler.hashnode.dev/building-a-rag-pipeline-without-a-scraper-farm-free-fetch-to-markdown-for-agents</link><guid isPermaLink="true">https://anycrawler.hashnode.dev/building-a-rag-pipeline-without-a-scraper-farm-free-fetch-to-markdown-for-agents</guid><category><![CDATA[RAG ]]></category><category><![CDATA[AI]]></category><category><![CDATA[agents]]></category><category><![CDATA[llm]]></category><category><![CDATA[webscraping ]]></category><dc:creator><![CDATA[AnyCrawler]]></dc:creator><pubDate>Sat, 07 Nov 2026 02:14:22 GMT</pubDate><content:encoded><![CDATA[<p>If your RAG pipeline needs fresh public pages as retrieval sources, you should not have to run a scraper farm, maintain site-specific parsers, or paste raw HTML into your chunker. This tutorial solves one searchable need: <strong>build a RAG ingest path that fetches a public URL and returns clean Markdown</strong>, so you can chunk, embed, and cite without owning a crawl stack.</p>
<p>We will wire a minimal fetch-to-Markdown step with AnyCrawler’s free endpoint (no API key), then drop the Markdown into a tiny RAG-style ingest function. I work on AnyCrawler; that is disclosed again at the end.</p>
<h2>The problem: RAG wants documents, scrapers want farms</h2>
<p>Classic web scraping and RAG ingest pull in opposite directions:</p>
<ul>
<li><strong>Scrapers optimize for tables and selectors.</strong> RAG usually wants readable prose, headings, and stable chunk boundaries.</li>
<li><strong>Homegrown fetch + BeautifulSoup / Readability</strong> works until the next docs theme, SPA shell, or cookie wall breaks your cleaner.</li>
<li><strong>Playwright / Puppeteer farms</strong> are correct for JS-heavy apps, but they are expensive and brittle as the default path for “index this public docs URL.”</li>
</ul>
<p>For many agent and RAG demos, the job is narrower: <strong>one public URL → title + clean Markdown → chunks with source URL metadata</strong>. That is a fetch layer, not a scraper farm.</p>
<h2>The minimal solution: free URL → Markdown GET</h2>
<p>AnyCrawler’s free crawl endpoint:</p>
<pre><code class="language-text">GET https://api.anycrawler.com/free/v1/crawl?url={url}
</code></pre>
<p>What you get:</p>
<ul>
<li><strong>No API key</strong> on this free path</li>
<li><strong>One public page</strong> per request</li>
<li>JSON with <code>results.markdown</code> (plus <code>title</code> / <code>description</code> / <code>status_code</code> when available)</li>
<li>Successful responses <strong>may be cached up to 24 hours</strong></li>
</ul>
<p>Try the same flow in the browser first: <a href="https://anycrawler.com/free/">free URL→Markdown playground</a>.</p>
<h2>Step 1 — Prove the page becomes Markdown</h2>
<pre><code class="language-bash">curl -sS "https://api.anycrawler.com/free/v1/crawl?url=https://example.com" \
  | python3 -m json.tool
</code></pre>
<p>Example shape:</p>
<pre><code class="language-json">{
  "ok": true,
  "requested_url": "https://example.com/",
  "results": {
    "description": null,
    "markdown": "This domain is for use in documentation examples...",
    "title": "Example Domain"
  },
  "status_code": 200,
  "total_ms": 519,
  "timestamp": "2026-11-05T10:38:26.962Z"
}
</code></pre>
<p>For ingest, keep <code>results.markdown</code> as the document body and <code>requested_url</code> as citation metadata.</p>
<h2>Step 2 — Python ingest helper (fetch → Markdown → chunks)</h2>
<pre><code class="language-python">import requests
from typing import Any

FREE_ENDPOINT = "https://api.anycrawler.com/free/v1/crawl"


def fetch_markdown(page_url: str, timeout: int = 60) -&gt; dict[str, Any]:
    resp = requests.get(FREE_ENDPOINT, params={"url": page_url}, timeout=timeout)
    resp.raise_for_status()
    data = resp.json()
    if not data.get("ok"):
        raise RuntimeError(f"crawl failed: {data}")

    results = data.get("results") or {}
    return {
        "source_url": data.get("requested_url") or page_url,
        "title": results.get("title"),
        "description": results.get("description"),
        "markdown": results.get("markdown") or "",
        "status_code": data.get("status_code"),
    }


def naive_chunks(markdown: str, max_chars: int = 800) -&gt; list[str]:
    """Split on blank lines, then pack paragraphs up to max_chars."""
    paras = [p.strip() for p in markdown.split("\n\n") if p.strip()]
    chunks: list[str] = []
    buf = ""
    for p in paras:
        candidate = f"{buf}\n\n{p}".strip() if buf else p
        if len(candidate) &lt;= max_chars:
            buf = candidate
        else:
            if buf:
                chunks.append(buf)
            buf = p if len(p) &lt;= max_chars else p[:max_chars]
    if buf:
        chunks.append(buf)
    return chunks


def ingest_public_url(page_url: str) -&gt; list[dict[str, Any]]:
    doc = fetch_markdown(page_url)
    out = []
    for i, text in enumerate(naive_chunks(doc["markdown"])):
        out.append(
            {
                "id": f"{doc['source_url']}#chunk-{i}",
                "text": text,
                "metadata": {
                    "source_url": doc["source_url"],
                    "title": doc["title"],
                    "chunk_index": i,
                },
            }
        )
    return out


# Example: replace with your vector store upsert
docs = ingest_public_url("https://example.com")
print(len(docs), "chunks")
print(docs[0]["metadata"] if docs else None)
print((docs[0]["text"][:200] + "...") if docs else None)
</code></pre>
<p>Swap <code>naive_chunks</code> for your real splitter (tiktoken, LlamaIndex, LangChain) and upsert into your store. The important part is that the <strong>source is already Markdown</strong>, so you skip a second HTML-cleaning stage for many public pages.</p>
<h2>Step 3 — JavaScript version for Node agents</h2>
<pre><code class="language-javascript">const FREE_ENDPOINT = "https://api.anycrawler.com/free/v1/crawl";

async function fetchMarkdown(pageUrl) {
  const endpoint = new URL(FREE_ENDPOINT);
  endpoint.searchParams.set("url", pageUrl);

  const res = await fetch(endpoint, { method: "GET" });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);

  const data = await res.json();
  if (!data.ok) throw new Error(`crawl failed: ${JSON.stringify(data)}`);

  return {
    sourceUrl: data.requested_url || pageUrl,
    title: data.results?.title ?? null,
    description: data.results?.description ?? null,
    markdown: data.results?.markdown ?? "",
    statusCode: data.status_code,
  };
}

function naiveChunks(markdown, maxChars = 800) {
  const paras = markdown.split(/\n\n+/).map((p) =&gt; p.trim()).filter(Boolean);
  const chunks = [];
  let buf = "";
  for (const p of paras) {
    const candidate = buf ? `${buf}\n\n${p}` : p;
    if (candidate.length &lt;= maxChars) {
      buf = candidate;
    } else {
      if (buf) chunks.push(buf);
      buf = p.length &lt;= maxChars ? p : p.slice(0, maxChars);
    }
  }
  if (buf) chunks.push(buf);
  return chunks;
}

async function ingestPublicUrl(pageUrl) {
  const doc = await fetchMarkdown(pageUrl);
  return naiveChunks(doc.markdown).map((text, i) =&gt; ({
    id: `${doc.sourceUrl}#chunk-${i}`,
    text,
    metadata: {
      sourceUrl: doc.sourceUrl,
      title: doc.title,
      chunkIndex: i,
    },
  }));
}

const docs = await ingestPublicUrl("https://example.com");
console.log(docs.length, "chunks");
console.log(docs[0]?.metadata);
</code></pre>
<p>In an agent tool loop, expose <code>fetchMarkdown</code> (or <code>ingestPublicUrl</code>) as one tool: “Fetch a public page and return clean Markdown for RAG.” Keep the tool description honest—this is <strong>read one public URL</strong>, not full-site crawl or logged-in browsing.</p>
<h2>Step 4 — Wire it into a RAG query path</h2>
<p>A practical pattern:</p>
<ol>
<li>Planner or user supplies a public docs/article URL (or a search step picks one).</li>
<li>Ingest helper calls the free crawl endpoint.</li>
<li>You chunk <code>results.markdown</code>, embed, and store <code>source_url</code> / <code>title</code> as metadata.</li>
<li>At answer time, retrieve chunks and cite the source URL.</li>
</ol>
<p>Cache awareness: free successful results may be reused for up to <strong>24 hours</strong>. That is fine for prototypes. If your index must recrawl sooner, or you need explicit <strong>fetch vs browser-render</strong> modes, move to AnyCrawler’s authenticated production crawl API.</p>
<h2>When a scraper farm is still the wrong default</h2>
<p>Stay on a fetch-to-Markdown layer when you:</p>
<ul>
<li>Index public docs, blog posts, or changelogs for RAG</li>
<li>Prototype agent tools that “read this URL”</li>
<li>Want Markdown chunk boundaries instead of HTML DOM noise</li>
</ul>
<p>Reach for browsers / site-specific scrapers when you:</p>
<ul>
<li>Need authenticated sessions or multi-step UI flows</li>
<li>Extract structured tables with brittle selectors as the product</li>
<li>Must interact (click, paginate, fill forms) rather than read</li>
</ul>
<p>Most early RAG and agent stacks over-buy the second category. Start with Markdown fetch; graduate only when a page proves it needs a render mode or a real scraper.</p>
<h2>Short FAQ (related searches)</h2>
<h3>How do I build RAG ingest without maintaining scrapers?</h3>
<p>Call a crawl API that returns cleaned Markdown in JSON, then chunk and embed that Markdown. The free AnyCrawler endpoint above does one public URL per request with no API key—enough for many demos and internal tools.</p>
<h3>Is fetch-to-Markdown enough for JS-heavy sites?</h3>
<p>Often for content-oriented public pages; not always for app shells. If Markdown is empty or useless, try the production API’s <strong>render</strong> mode, or treat that URL as a scraper exception—not a reason to scrap the whole pipeline.</p>
<h3>Are free URL→Markdown results cached?</h3>
<p>Yes. Successful free results may be served from cache for up to <strong>24 hours</strong>. Plan refresh policy accordingly.</p>
<h3>What URLs work for RAG ingest?</h3>
<p>Complete, publicly reachable <code>http</code>/<code>https</code> URLs. Login walls, private networks, and unreachable pages are not suitable for the free tool.</p>
<h2>Try the free fetch layer, then grow</h2>
<ol>
<li>Paste a public docs URL into the <a href="https://anycrawler.com/free/">AnyCrawler free URL→Markdown tool</a>.</li>
<li>Drop the Python or JS ingest helper into your RAG prototype.</li>
<li>Move to the authenticated crawl API when you need fetch/render modes, usage records, and production limits.</li>
</ol>
<p><strong>Disclosure:</strong> I work on the AnyCrawler team. Questions: <a href="mailto:marketing@anycrawler.com">marketing@anycrawler.com</a>.</p>
]]></content:encoded></item></channel></rss>