Skip to main content

Command Palette

Search for a command to run...

Web data for LLM fine-tuning: a practical 2026 guide

Web data for LLM fine-tuning, a practical 2026 guide

Updated
8 min readView as Markdown
Web data for LLM fine-tuning: a practical 2026 guide

This article was originally published on the Zenrows blog. Read the original here: https://www.zenrows.com/blog/web-data-llm-fine-tuning

Architecture overview

LLM fine-tuning pipelines that rely entirely on synthetic data are structurally unstable. The generator produces examples using the teacher model's patterns; the judge filters against those same patterns. Each retrain cycle amplifies the biases from the previous one. By the third or fourth cycle, the model has learned to reproduce domain text rather than to reflect domain knowledge.

The intervention is a real-data seed, a small, verified set of examples collected from authoritative sources before any synthetic expansion begins. The seed does two things. It gives the generator concrete domain examples to expand from rather than its own patterns. It gives the judge something to filter against that is not its own output.

Fine-tuning amplifies signal differently than pretraining. A single mislabeled extraction in a dataset of a few hundred rows represents a measurable percentage of the training signal, and the model takes it as a rule. This guide covers collecting that seed from sources that block standard crawlers, formatting it correctly, validating each row, and measuring whether the fine-tune held.

What you'll implement:

  • Protected web data collection using Zenrows Fetch with mode=auto

  • PDF retrieval and structured chunking

  • Provenance tracking per source document

  • Concurrent batch collection with rate limit management

  • Three fine-tuning data formats: instruction-output pairs, preference pairs, domain continuation

  • Seed validation with length, schema, and deduplication checks

  • Holdout split and accuracy measurement

All code: GitHub repository

Why authoritative sources are the hard part

Authoritative domain sources like regulatory filings, clinical guidance, technical documentation, expert-reviewed publications, are almost always behind access controls. They block standard crawlers at the TLS handshake level. A plain requests.get() exposes the Python HTTP client fingerprint, the target identifies it as automated traffic, and the response is a challenge page or an empty shell.

This is not a scraping problem that can be solved with headers or user-agent rotation alone. JavaScript-rendered pages require a headless browser context. Protected sources require IP rotation and fingerprint masking at the session level. The collection layer has to handle both, per request, without per-site configuration.

Prerequisites

python3 -m pip install requests python-dotenv pypdf jsonschema
# .env — add to .gitignore before committing
ZENROWS_API_KEY=your_zenrows_api_key_here

Collecting from protected sources

Zenrows Fetch handles JavaScript rendering, access control, and Markdown conversion in one API call. mode=auto is Adaptive Stealth Mode. Zenrows selects the retrieval configuration per target based on how that target responds, alternating between direct fetch, headless browser, and proxy escalation as needed.

import os
import requests
from dotenv import load_dotenv

load_dotenv()

# Load at module level — one environment read, not one per request
apikey = os.getenv("ZENROWS_API_KEY")

params = {
    "url": "https://www.scrapingcourse.com/ecommerce/",
    "apikey": apikey,
    "mode": "auto",              # Adaptive Stealth Mode picks retrieval strategy per target
    "response_type": "markdown", # Returns clean Markdown, not raw HTML
}

response = requests.get("https://api.zenrows.com/v1/", params=params)
print(response.text)

The Markdown output strips most navigation, footer, and boilerplate. That reduced noise is what makes it a better training input than raw HTML.

Handling PDFs and non-HTML documents

The most authoritative domain sources are often PDFs. response_type=pdf returns raw bytes; js_render=true is required and must be included explicitly.

params = {
    "url": pdf_url,
    "apikey": apikey,
    "js_render": "true",    # Required for PDF response type
    "response_type": "pdf", # Returns raw bytes — extract text separately
}

response = requests.get("https://api.zenrows.com/v1/", params=params)

with open("source-document.pdf", "wb") as f:
    f.write(response.content)

Why this approach: chunk on structure, not character count

A regulatory filing can run to tens of thousands of tokens. Fixed-character chunking cuts mid-sentence, mid-argument, and mid-table. Chunking on the document's own page boundaries preserves context. Each chunk stays internally coherent, which is what makes it a usable training example.

from pypdf import PdfReader
import io

reader = PdfReader(io.BytesIO(response.content))

def chunk_by_page(reader, max_chars=6000):
    chunks = []
    for page in reader.pages:
        text = page.extract_text()
        if not text or not text.strip():
            continue  # skip blank or image-only pages
        if len(text) <= max_chars:
            chunks.append(text)
        else:
            # only split within a page when the page itself exceeds the limit
            for i in range(0, len(text), max_chars):
                chunks.append(text[i:i + max_chars])
    return chunks

chunks = chunk_by_page(reader)
print(f"Produced {len(chunks)} chunks from {len(reader.pages)} pages")

Provenance tracking

Every row in an authoritative seed needs a traceable source. A metadata record per document keeps the dataset auditable, covers legal requirements for commercial use, and makes it possible to re-retrieve and re-verify specific rows later.

from datetime import date

# Fetch the license from the landing page using Zenrows CSS extractor
license_params = {
    "url": landing_page_url,
    "apikey": apikey,
    "js_render": "true",
    "css_extractor": '{"license_url": "a[href*=\\"creativecommons.org\\"] @href"}',
}
license_resp = requests.get("https://api.zenrows.com/v1/", params=license_params)
license_data = license_resp.json()

source = {
    "url": pdf_url,
    "retrieved": date.today().isoformat(),
    "license": license_data.get("license_url", "unknown"),
}

Batch collection with rate limit management

Match concurrency to your plan limit. Spreading requests across workers with a short pause keeps the pipeline inside your quota without throttling from the target side.

import time
from concurrent.futures import ThreadPoolExecutor

MAX_CONCURRENT = 50  # Verify against your Zenrows plan

def fetch(url):
    params = {
        "url": url,
        "apikey": apikey,
        "response_type": "markdown",
        "mode": "auto",
        "wait": 3000,  # Wait for dynamic content to settle before returning
    }
    response = requests.get("https://api.zenrows.com/v1/", params=params)
    time.sleep(0.5)  # Pace requests within each worker thread

    if response.status_code != 200:
        return {"url": url, "status": response.status_code, "content": None}
    return {"url": url, "status": response.status_code, "content": response.text}

with ThreadPoolExecutor(max_workers=MAX_CONCURRENT) as executor:
    results = list(executor.map(fetch, urls))

For larger runs, Zenrows Batch manages concurrency, retries, and scheduling across large URL lists without a local orchestration layer.

Three fine-tuning data formats

The right format depends on the training method, not on what is easiest to produce.

Instruction-output pairs

Standard for supervised fine-tuning. Each example pairs the page Markdown (input) with the correct structured extraction (output). The model learns what to extract and how to format it.

import json

def to_training_example(instruction, markdown_content, extracted_json):
    return {
        "messages": [
            {"role": "system", "content": instruction},
            # The Markdown you pulled from the protected source
            {"role": "user", "content": markdown_content},
            # The verified structured output for this input
            {"role": "assistant", "content": json.dumps(extracted_json)},
        ]
    }

instruction = "Extract the product name, price, and availability as JSON."

with open("training_data.jsonl", "w") as f:
    for markdown_content, extracted_json in seed_examples:
        example = to_training_example(instruction, markdown_content, extracted_json)
        f.write(json.dumps(example) + "\n")  # One JSON object per line — JSONL format

Preference pairs

Used in DPO and RLHF. Two candidate outputs for the same input, one marked preferred. Correct when the model already produces reasonable outputs and the goal is distributional refinement rather than teaching the task from scratch.

def to_preference_pair(prompt, chosen, rejected):
    return {
        "prompt": prompt,
        "chosen": chosen,   # The preferred output
        "rejected": rejected, # The not-preferred output to the same prompt
    }

Domain text continuation

Raw domain text with no input-output structure. Useful for domain vocabulary and style familiarization, weak for extraction tasks because the model is never shown what should be extracted.

def to_continuation_example(markdown_content):
    # One field — the model learns by predicting what comes next
    return {"text": markdown_content}

For extraction tasks, instruction-output pairs are the correct choice. Continuation is a pretraining-style objective applied at fine-tuning scale; it improves fluency, not accuracy.

Seed validation

A row that fails any of these three checks does not enter the training set.

import hashlib
from jsonschema import validate, ValidationError

schema = {
    "type": "object",
    "properties": {
        "name": {"type": "string", "minLength": 1},
        "price": {"type": "string", "minLength": 1},
    },
    "required": ["name", "price"],
}

seen = set()

def keep(markdown, extracted):
    # Length: pages that rendered to almost nothing carry no useful signal
    if len(markdown.strip()) < 200:
        return False
    try:
        # Schema: catches wrong types, empty values, missing required fields
        validate(instance=extracted, schema=schema)
    except ValidationError:
        return False
    # Deduplication: normalise whitespace before hashing to catch formatting-only duplicates
    h = hashlib.sha256(" ".join(markdown.lower().split()).encode()).hexdigest()
    if h in seen:
        return False
    seen.add(h)
    return True

clean_examples = [(url, md, js) for url, md, js in seed_examples if keep(md, js)]

Measuring whether the seed held

Hold out 10 to 20 percent of the real seed before any synthetic expansion and keep those examples permanently out of training and out of the synthetic generator.

import random

random.seed(42)       # Reproducible split across runs
random.shuffle(seed_examples)

split = int(len(seed_examples) * 0.85)
train_examples = seed_examples[:split]
holdout_examples = seed_examples[split:]  # Never enters training

After fine-tuning, run the model against the holdout and record task accuracy. Record the score after each retrain. A stable or improving score means the seed is anchoring correctly. A declining score is an early signal of drift before it compounds across cycles.

What to build next