<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Surendra Tamang</title><description>Data engineer for web scraping infrastructure and AI extraction. Scrapers that survive anti-bot systems, pipelines that keep running, structured data delivered to your database or API.</description><link>https://tamangsurendra.com.np/</link><language>en</language><item><title>From a scraper to a monitored data pipeline</title><link>https://tamangsurendra.com.np/blog/scraper-to-monitored-pipeline/</link><guid isPermaLink="true">https://tamangsurendra.com.np/blog/scraper-to-monitored-pipeline/</guid><description>Scheduling, alerting, deduplication and change history: the parts that turn a one-off script into data a business can rely on.</description><pubDate>Sat, 26 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Most scraping projects end the same way. The script works on delivery day, the client is happy, and three weeks later someone notices the dashboard hasn&amp;#39;t changed since the 4th. Nobody got an error. The scraper ran, found nothing, and exited cleanly.&lt;/p&gt;
&lt;p&gt;The scraper wasn&amp;#39;t the problem. Everything around it was missing. This post covers the parts I add to every scraper before I call it done: scheduling, alerting, deduplication, change history, and a clean handoff.&lt;/p&gt;
&lt;h2&gt;Why scrapers die in week two&lt;/h2&gt;
&lt;p&gt;Scrapers rarely crash loudly. They break in a few quiet ways:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Layout changes.&lt;/strong&gt; A class name changes, the selector matches nothing, and the run returns zero rows with exit code 0.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Soft blocks.&lt;/strong&gt; Cloudflare or DataDome serves a challenge page with HTTP 200. Your parser reads the challenge page, finds no products, and moves on.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Burned proxies.&lt;/strong&gt; A proxy pool that worked in testing gets flagged. Success rate drops from 98% to 40%, and the dataset quietly shrinks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Duplicates.&lt;/strong&gt; The same listing gets scraped from page 3 today and page 4 tomorrow. Counts go up, but the data doesn&amp;#39;t.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these throw an exception. So &amp;quot;it didn&amp;#39;t error&amp;quot; tells you nothing about whether the data is right.&lt;/p&gt;
&lt;h2&gt;Scheduling: cron is fine until it isn&amp;#39;t&lt;/h2&gt;
&lt;p&gt;For one scraper on one server, cron is fine. Don&amp;#39;t reach for Airflow on day one.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# every day at 02:15, log output, never overlap runs
15 2 * * * flock -n /tmp/prices.lock /app/run.sh &amp;gt;&amp;gt; /var/log/prices.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;flock -n&lt;/code&gt; matters more than it looks. Without it, a slow run overlaps the next one and you get two scrapers fighting over the same proxy pool.&lt;/p&gt;
&lt;p&gt;Cron stops being enough when you need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Retries with backoff&lt;/strong&gt; for one failed source, without rerunning all of them&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backfills&lt;/strong&gt;, such as &amp;quot;rerun last Tuesday for source B&amp;quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dependencies&lt;/strong&gt;, such as &amp;quot;load to the warehouse only after all 12 sources finish&amp;quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A place to see history&lt;/strong&gt; of what ran, how long it took and what failed&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&amp;#39;s the point to move to an orchestrator like Airflow. Moving is easy if each source is already its own command with a date argument, like &lt;code&gt;run.sh --source b --date 2026-09-30&lt;/code&gt;. Design for that from day one, even on cron.&lt;/p&gt;
&lt;h2&gt;Alert on zero rows, not on exceptions&lt;/h2&gt;
&lt;p&gt;Exceptions are the failures you already handle. The dangerous ones are silent, so check the output, not the exit code.&lt;/p&gt;
&lt;p&gt;After every run I record a few numbers per source and compare them with recent history:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def check_run(source: str, rows: int, recent: list[int]) -&amp;gt; list[str]:
    problems = []
    if rows == 0:
        problems.append(f&amp;quot;{source}: zero rows&amp;quot;)
    elif recent and rows &amp;lt; 0.5 * (sum(recent) / len(recent)):
        problems.append(f&amp;quot;{source}: {rows} rows, under half the recent average&amp;quot;)
    return problems
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add three more checks and you catch most real failures:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Freshness:&lt;/strong&gt; the newest record is older than expected.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Nulls:&lt;/strong&gt; a required field (price, title, URL) is suddenly empty on many rows. That usually means a selector broke.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Block rate:&lt;/strong&gt; the share of responses that were challenge pages or 403s. Rising block rate is the early warning before rows drop.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Send problems to a place people actually read, like Slack or email. Send nothing when everything is fine. An alert channel that posts &amp;quot;all good&amp;quot; every day gets muted within a week.&lt;/p&gt;
&lt;h2&gt;Deduplication and change history in PostgreSQL&lt;/h2&gt;
&lt;p&gt;Scraped data needs two things most scripts don&amp;#39;t give you: one row per real-world thing, and a record of how it changed.&lt;/p&gt;
&lt;p&gt;Pick a natural key per source. That&amp;#39;s usually the site&amp;#39;s own ID or a normalised URL, never the scrape timestamp. Then upsert:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;create table listings (
    source      text not null,
    external_id text not null,
    title       text,
    price       numeric,
    first_seen  timestamptz not null default now(),
    last_seen   timestamptz not null default now(),
    primary key (source, external_id)
);

create table listing_changes (
    source      text not null,
    external_id text not null,
    field       text not null,
    old_value   text,
    new_value   text,
    changed_at  timestamptz not null default now()
);

insert into listings (source, external_id, title, price)
values ($1, $2, $3, $4)
on conflict (source, external_id) do update
set title = excluded.title,
    price = excluded.price,
    last_seen = now();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Before the upsert, compare the incoming row with the stored one and write any differences to &lt;code&gt;listing_changes&lt;/code&gt;. Now &amp;quot;what was the price on the 12th?&amp;quot; and &amp;quot;which listings disappeared this week?&amp;quot; are simple queries. You don&amp;#39;t have to rescrape.&lt;/p&gt;
&lt;p&gt;Two things this gives you for free:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Reruns are safe.&lt;/strong&gt; Running the same day twice updates rows instead of doubling them, so a retry can never corrupt the dataset.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Disappearances show up.&lt;/strong&gt; A listing whose &lt;code&gt;last_seen&lt;/code&gt; stops moving was removed or sold. For price and inventory monitoring, that&amp;#39;s often the most valuable signal in the data.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Handoff: the client owns the code and the accounts&lt;/h2&gt;
&lt;p&gt;A pipeline that only I can run isn&amp;#39;t a deliverable. At handoff the client gets:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The code in their repository, with a README that says how to run one source for one date&lt;/li&gt;
&lt;li&gt;Proxy, server and alerting accounts in their name, or clear instructions to move them&lt;/li&gt;
&lt;li&gt;A short video walkthrough of where the data lands, what the alerts mean, and what to do when one fires&lt;/li&gt;
&lt;li&gt;A list of known risks, such as which sources are fragile and what changes most often&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then they choose. They run it themselves, or I keep it running as a monitored data feed, and a site change becomes my problem instead of theirs. Both are fine. What isn&amp;#39;t fine is a scraper nobody watches.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Do I need Airflow for a single scraper?&lt;/strong&gt;
No. Cron with &lt;code&gt;flock&lt;/code&gt; and output checks is enough for a few sources. Move to an orchestrator when you need retries per source, backfills or dependencies.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What should I alert on first?&lt;/strong&gt;
Zero rows and sudden drops in row count. They catch layout changes, soft blocks and proxy problems before anyone downstream notices.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How do I stop duplicate records?&lt;/strong&gt;
Use a natural key from the source (site ID or normalised URL) as the primary key, and upsert instead of insert. Reruns then update rows instead of adding new ones.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How often do scrapers need fixing?&lt;/strong&gt;
It depends on the site. Heavily protected or frequently redesigned sites need attention more often than stable ones. That&amp;#39;s why monitoring matters more than the first version of the code.&lt;/p&gt;
</content:encoded></item><item><title>LLM extraction that never invents a value</title><link>https://tamangsurendra.com.np/blog/llm-extraction-with-evals/</link><guid isPermaLink="true">https://tamangsurendra.com.np/blog/llm-extraction-with-evals/</guid><description>Structured output schemas, validation and a golden-set eval harness for extracting data from PDFs and messy pages with Claude or OpenAI.</description><pubDate>Wed, 17 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;LLMs are very good at reading messy documents: court filings, invoices, product pages with no consistent layout. They&amp;#39;re also very good at producing an answer that looks right when the document doesn&amp;#39;t contain one. For data extraction, that second habit is the real risk.&lt;/p&gt;
&lt;p&gt;This is the setup I use so that a wrong value is caught, not shipped.&lt;/p&gt;
&lt;h2&gt;The failure mode: confident, wrong, unnoticed&lt;/h2&gt;
&lt;p&gt;Traditional scrapers fail loudly. A selector matches nothing, and you get an empty field. LLMs fail quietly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A missing date gets filled with the document&amp;#39;s filing date instead of the hearing date.&lt;/li&gt;
&lt;li&gt;A price with no currency gets &lt;code&gt;USD&lt;/code&gt; because that&amp;#39;s the most common currency.&lt;/li&gt;
&lt;li&gt;Two similar parties get merged, or a name gets a middle initial it never had.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Every output is valid JSON and every value is plausible. Without measurement, you won&amp;#39;t know your accuracy is 85% until a client finds the other 15%.&lt;/p&gt;
&lt;h2&gt;Schema-first: tool use and JSON schema&lt;/h2&gt;
&lt;p&gt;Never ask for &amp;quot;JSON with these fields&amp;quot; in plain prompt text. Define the schema and make the model fill it through &lt;strong&gt;tool use / structured outputs&lt;/strong&gt;, which both Anthropic and OpenAI support. Make every uncertain field &lt;strong&gt;nullable&lt;/strong&gt; and tell the model that null is the correct answer when the value isn&amp;#39;t in the text.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import os

import anthropic

client = anthropic.Anthropic()

CASE_TOOL = {
    &amp;quot;name&amp;quot;: &amp;quot;record_case&amp;quot;,
    &amp;quot;description&amp;quot;: &amp;quot;Record fields found in the document. Use null when a value is not stated.&amp;quot;,
    &amp;quot;input_schema&amp;quot;: {
        &amp;quot;type&amp;quot;: &amp;quot;object&amp;quot;,
        &amp;quot;properties&amp;quot;: {
            &amp;quot;case_number&amp;quot;: {&amp;quot;type&amp;quot;: [&amp;quot;string&amp;quot;, &amp;quot;null&amp;quot;]},
            &amp;quot;filing_date&amp;quot;: {&amp;quot;type&amp;quot;: [&amp;quot;string&amp;quot;, &amp;quot;null&amp;quot;], &amp;quot;description&amp;quot;: &amp;quot;YYYY-MM-DD&amp;quot;},
            &amp;quot;case_type&amp;quot;: {&amp;quot;type&amp;quot;: [&amp;quot;string&amp;quot;, &amp;quot;null&amp;quot;], &amp;quot;enum&amp;quot;: [&amp;quot;civil&amp;quot;, &amp;quot;criminal&amp;quot;, &amp;quot;family&amp;quot;, &amp;quot;traffic&amp;quot;, None]},
            &amp;quot;amount_claimed&amp;quot;: {&amp;quot;type&amp;quot;: [&amp;quot;number&amp;quot;, &amp;quot;null&amp;quot;]},
        },
        &amp;quot;required&amp;quot;: [&amp;quot;case_number&amp;quot;, &amp;quot;filing_date&amp;quot;, &amp;quot;case_type&amp;quot;, &amp;quot;amount_claimed&amp;quot;],
    },
}

def extract(text: str) -&amp;gt; dict:
    msg = client.messages.create(
        model=os.environ[&amp;quot;EXTRACTION_MODEL&amp;quot;],
        max_tokens=1024,
        tools=[CASE_TOOL],
        tool_choice={&amp;quot;type&amp;quot;: &amp;quot;tool&amp;quot;, &amp;quot;name&amp;quot;: &amp;quot;record_case&amp;quot;},
        messages=[{&amp;quot;role&amp;quot;: &amp;quot;user&amp;quot;, &amp;quot;content&amp;quot;: f&amp;quot;Extract the case fields.\n\n&amp;lt;document&amp;gt;\n{text}\n&amp;lt;/document&amp;gt;&amp;quot;}],
    )
    return next(b.input for b in msg.content if b.type == &amp;quot;tool_use&amp;quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Enums stop invented categories. Nullable fields give the model a legitimate way to say &amp;quot;not found&amp;quot;.&lt;/p&gt;
&lt;h2&gt;Validation rules the model cannot bypass&lt;/h2&gt;
&lt;p&gt;The schema controls shape. Validation controls truth. After extraction, run checks in plain code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import re
from datetime import date

def validate(fields: dict, text: str) -&amp;gt; list[str]:
    errors = []
    cn = fields.get(&amp;quot;case_number&amp;quot;)
    if cn and cn not in text:
        errors.append(&amp;quot;case_number not found verbatim in document&amp;quot;)
    fd = fields.get(&amp;quot;filing_date&amp;quot;)
    if fd:
        try:
            if date.fromisoformat(fd) &amp;gt; date.today():
                errors.append(&amp;quot;filing_date in the future&amp;quot;)
        except ValueError:
            errors.append(&amp;quot;filing_date not a valid date&amp;quot;)
    amt = fields.get(&amp;quot;amount_claimed&amp;quot;)
    if amt is not None and not re.search(rf&amp;quot;{int(amt):,}|{int(amt)}&amp;quot;, text):
        errors.append(&amp;quot;amount_claimed not present in document&amp;quot;)
    return errors
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The most useful single rule is &lt;strong&gt;grounding&lt;/strong&gt;: identifiers, names and amounts must appear in the source text. It catches most invented values with a few lines of code. Add range checks, date sanity checks and cross-field rules (a closing date can&amp;#39;t come before the filing date) as you find real errors.&lt;/p&gt;
&lt;h2&gt;Building a golden set and measuring accuracy&lt;/h2&gt;
&lt;p&gt;You can&amp;#39;t improve what you don&amp;#39;t measure. Build a &lt;strong&gt;golden set&lt;/strong&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Pick 50–200 real documents that cover the variety: clean ones, scanned ones, odd layouts, documents where fields are genuinely missing.&lt;/li&gt;
&lt;li&gt;Label the correct values by hand, including &lt;code&gt;null&lt;/code&gt; where a field isn&amp;#39;t present.&lt;/li&gt;
&lt;li&gt;Run the pipeline and compare &lt;strong&gt;per field&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def score(golden: list[dict], predicted: list[dict], field: str) -&amp;gt; dict:
    pairs = [(g[field], p[field]) for g, p in zip(golden, predicted)]
    correct = sum(g == p for g, p in pairs)
    invented = sum(g is None and p is not None for g, p in pairs)
    return {&amp;quot;accuracy&amp;quot;: correct / len(pairs), &amp;quot;invented&amp;quot;: invented}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Track &lt;strong&gt;invented values&lt;/strong&gt; separately from misses. A missed value is an empty cell someone can fill. An invented value is wrong data someone will trust.&lt;/p&gt;
&lt;p&gt;Rerun the golden set every time you change the prompt, the model or the schema. Prompts that &amp;quot;look better&amp;quot; regularly make one field worse.&lt;/p&gt;
&lt;h2&gt;Fail closed: when to route to a human&lt;/h2&gt;
&lt;p&gt;Decide up front what happens when a record fails validation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Retry once&lt;/strong&gt; with the validation errors included in the prompt. This often fixes format errors.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Route to a review queue&lt;/strong&gt; if it still fails. A person checks a few records a day instead of the whole dataset.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Never write failed records&lt;/strong&gt; into the main table as if they were good.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On a well-tuned pipeline the review queue is a small share of volume, and it&amp;#39;s also your best source of new golden-set examples.&lt;/p&gt;
&lt;h2&gt;Keeping cost under control&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Send only what&amp;#39;s needed.&lt;/strong&gt; Strip boilerplate, navigation and repeated headers before extraction.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cache by document hash.&lt;/strong&gt; Unchanged documents never get extracted twice.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Start with a smaller model&lt;/strong&gt; and escalate to a larger one only for records that fail validation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Measure cost per valid record&lt;/strong&gt;, not per API call. It&amp;#39;s the same principle as &lt;a href=&quot;/blog/web-scraping-cost-per-1000-pages&quot;&gt;scraping cost per 1,000 pages&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Claude or OpenAI?&lt;/strong&gt;
Both support structured outputs well. Pick per project by running the same golden set through each and comparing accuracy, invented values and cost.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Do I still need OCR for scanned PDFs?&lt;/strong&gt;
Often, yes. Modern models can read page images directly, but a text layer from OCR is cheaper and easier to ground-check. Test both on your golden set.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How big should the golden set be?&lt;/strong&gt;
Start with 50 documents to catch obvious problems. Grow it to a few hundred, weighted toward the document types that fail most.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can I trust the model&amp;#39;s own confidence score?&lt;/strong&gt;
Not by itself. Use validation rules and golden-set accuracy instead. They measure what actually happened.&lt;/p&gt;
</content:encoded></item><item><title>What web scraping really costs per 1,000 pages</title><link>https://tamangsurendra.com.np/blog/web-scraping-cost-per-1000-pages/</link><guid isPermaLink="true">https://tamangsurendra.com.np/blog/web-scraping-cost-per-1000-pages/</guid><description>Proxies, browsers, servers and maintenance: how to estimate the real running cost of a scraper before you build it, with a worked example.</description><pubDate>Wed, 11 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Clients usually ask what a scraper costs to build. The more useful question is what it costs to &lt;strong&gt;run&lt;/strong&gt;, every month, for as long as they need the data. A cheap build on the wrong approach can cost more in proxies within a few months than the build itself.&lt;/p&gt;
&lt;p&gt;This is the model I use to estimate running cost before writing any code.&lt;/p&gt;
&lt;h2&gt;The four costs&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Proxies.&lt;/strong&gt; Usually the largest variable cost on protected sites. Residential and mobile proxies are mostly billed per GB of traffic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compute.&lt;/strong&gt; Servers or containers to run the crawlers. Cheap for HTTP scraping, much more for browsers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Storage and delivery.&lt;/strong&gt; Database, object storage, and whatever sends the data to the client. Usually small.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Maintenance.&lt;/strong&gt; Engineering time when the site changes or anti-bot rules tighten. Often the largest cost over a year, and the one most estimates leave out.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Proxies: page weight × volume × price per GB&lt;/h2&gt;
&lt;p&gt;Per-GB billing means &lt;strong&gt;how much data each page transfers&lt;/strong&gt; matters as much as how many pages you fetch:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Typical transfer per page&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;JSON API call&lt;/td&gt;
&lt;td&gt;a few KB to ~100 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server-rendered HTML (Scrapy)&lt;/td&gt;
&lt;td&gt;~50–500 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full browser page load&lt;/td&gt;
&lt;td&gt;~1–5 MB, because of scripts, images, fonts and trackers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;These are rough ranges. Measure your own target in the DevTools network tab: the &amp;quot;transferred&amp;quot; total at the bottom is what your proxy bills.&lt;/p&gt;
&lt;p&gt;The formula:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;monthly proxy cost = pages per month × MB per page ÷ 1,024 × price per GB
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two levers cut this dramatically:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Use the API instead of the page&lt;/strong&gt; when one exists. See &lt;a href=&quot;/blog/scrapy-vs-playwright-vs-api&quot;&gt;Scrapy vs Playwright vs the hidden API&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Block what you don&amp;#39;t need&lt;/strong&gt; in browsers: images, fonts, media and third-party scripts. Page weight can often drop by more than half.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Also route unprotected requests (sitemaps, static assets, lightly protected pages) through cheap datacenter proxies or no proxy at all. Save the residential traffic for pages that need it.&lt;/p&gt;
&lt;h2&gt;Compute: HTTP is cheap, browsers are not&lt;/h2&gt;
&lt;p&gt;A single small server can run Scrapy at hundreds of concurrent requests. A headless browser needs real CPU and memory per open page, so the same server handles only a handful of browser pages at once.&lt;/p&gt;
&lt;p&gt;In practice, browser-based scraping often needs &lt;strong&gt;5–20× the compute&lt;/strong&gt; of an HTTP approach for the same page count, and it&amp;#39;s slower. For a small daily crawl that&amp;#39;s still cheap. For millions of pages a month, it decides the architecture.&lt;/p&gt;
&lt;h2&gt;Maintenance: budget it, or it budgets you&lt;/h2&gt;
&lt;p&gt;Sites change layouts, add fields, move to a new frontend or tighten their anti-bot settings. Each change means a fix. How often depends on the site: some stay stable for a year, some need attention monthly.&lt;/p&gt;
&lt;p&gt;This is why I price ongoing work as a monitored data feed rather than hoping nothing breaks. The monitoring catches the break (zero rows, a rising block rate), and the fix is already paid for.&lt;/p&gt;
&lt;h2&gt;A worked example&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Illustrative numbers only.&lt;/strong&gt; Plug in your own page weights and current proxy prices.&lt;/p&gt;
&lt;p&gt;A client wants 50 retailers&amp;#39; product pages checked daily, about 20,000 pages a day, or &lt;strong&gt;600,000 pages a month&lt;/strong&gt;.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Browser, residential proxies&lt;/th&gt;
&lt;th&gt;Hidden API + HTTP, residential only where needed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Transfer per page&lt;/td&gt;
&lt;td&gt;2 MB&lt;/td&gt;
&lt;td&gt;0.1 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly transfer&lt;/td&gt;
&lt;td&gt;~1,170 GB&lt;/td&gt;
&lt;td&gt;~59 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proxy cost at an assumed $4/GB&lt;/td&gt;
&lt;td&gt;~$4,700&lt;/td&gt;
&lt;td&gt;~$235&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compute&lt;/td&gt;
&lt;td&gt;several browser servers&lt;/td&gt;
&lt;td&gt;one small server&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Same data, but running costs roughly 20× apart. The difference comes almost entirely from the approach, decided before any code is written. That&amp;#39;s why a paid phase-1 on the real targets matters: it measures the true cost per record before the client commits.&lt;/p&gt;
&lt;h2&gt;Cost per record, not per page&lt;/h2&gt;
&lt;p&gt;Clients don&amp;#39;t care about pages. They care about records: products, listings, filings. Divide total monthly cost by delivered, deduplicated, valid records:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cost per record = (proxies + compute + storage + maintenance) ÷ valid records delivered
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That number makes it easy to compare approaches and vendors, and to decide whether the data is worth buying at all.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Why not just use a scraping API service?&lt;/strong&gt;
Services that handle proxies and unblocking are great for starting quickly and for low volumes. At high volume, a custom setup with smart routing is often much cheaper. Compare them on cost per record, not on price per request.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Datacenter or residential proxies?&lt;/strong&gt;
Start with datacenter proxies and move a source to residential only when its block rate proves it&amp;#39;s needed. Many sites don&amp;#39;t need residential at all.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How do I estimate maintenance?&lt;/strong&gt;
Look at how often the site has been redesigned, how aggressive its anti-bot protection is, and how many sources you have. Then budget a monthly allowance of hours instead of guessing per incident.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What&amp;#39;s the cheapest way to cut costs on an existing scraper?&lt;/strong&gt;
Measure transfer per page first. Blocking images, fonts and third-party scripts, or switching to the site&amp;#39;s API, usually saves more than negotiating proxy prices.&lt;/p&gt;
</content:encoded></item><item><title>Scheduling Scrapy with Airflow: retries, backfills, alerts</title><link>https://tamangsurendra.com.np/blog/scheduling-scrapy-with-airflow/</link><guid isPermaLink="true">https://tamangsurendra.com.np/blog/scheduling-scrapy-with-airflow/</guid><description>Moving a set of Scrapy spiders from cron to Airflow: one task per source, safe reruns, proxy-aware concurrency, and alerts that fire on bad data.</description><pubDate>Wed, 08 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Cron is fine for one spider. Once you have ten sources feeding the same database, the gaps show: one failed source means rerunning everything, nobody can see what ran last night, and backfilling &amp;quot;last Tuesday for source B&amp;quot; means editing a crontab by hand.&lt;/p&gt;
&lt;p&gt;This is how I move Scrapy projects to Apache Airflow without rewriting the spiders.&lt;/p&gt;
&lt;h2&gt;Make every spider rerunnable first&lt;/h2&gt;
&lt;p&gt;Airflow can only retry and backfill safely if a run for a given date is &lt;strong&gt;idempotent&lt;/strong&gt;: running it twice gives the same result as running it once. Two things make that true:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The spider takes the logical date as an argument&lt;/strong&gt;, instead of reading &amp;quot;today&amp;quot; from the clock:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class PricesSpider(scrapy.Spider):
    name = &amp;quot;prices&amp;quot;

    def __init__(self, run_date=None, **kwargs):
        super().__init__(**kwargs)
        self.run_date = run_date  # &amp;quot;2025-10-07&amp;quot;, set by Airflow
&lt;/code&gt;&lt;/pre&gt;
&lt;ol start=&quot;2&quot;&gt;
&lt;li&gt;&lt;strong&gt;The pipeline upserts&lt;/strong&gt; on a natural key (the site&amp;#39;s own ID) instead of inserting. Reruns then update rows instead of duplicating them. The schema for this is in &lt;a href=&quot;/blog/scraper-to-monitored-pipeline&quot;&gt;From a scraper to a monitored data pipeline&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If your spiders already work like that, the Airflow part is small.&lt;/p&gt;
&lt;h2&gt;One DAG, one task per source&lt;/h2&gt;
&lt;p&gt;This example uses Airflow 3&amp;#39;s &lt;code&gt;airflow.sdk&lt;/code&gt; imports; on Airflow 2.x the same ideas apply with &lt;code&gt;airflow.decorators&lt;/code&gt; and &lt;code&gt;airflow.operators.bash&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from datetime import datetime, timedelta

from airflow.providers.standard.operators.bash import BashOperator
from airflow.sdk import dag, task

SOURCES = [&amp;quot;retailer_a&amp;quot;, &amp;quot;retailer_b&amp;quot;, &amp;quot;retailer_c&amp;quot;]

@dag(
    schedule=&amp;quot;15 2 * * *&amp;quot;,
    start_date=datetime(2025, 10, 1),
    catchup=False,
    default_args={
        &amp;quot;retries&amp;quot;: 3,
        &amp;quot;retry_delay&amp;quot;: timedelta(minutes=10),
        &amp;quot;retry_exponential_backoff&amp;quot;: True,
    },
)
def prices():
    for source in SOURCES:
        crawl = BashOperator(
            task_id=f&amp;quot;crawl_{source}&amp;quot;,
            bash_command=f&amp;quot;cd /opt/scrapers &amp;amp;&amp;amp; scrapy crawl {source} -a run_date={{{{ ds }}}}&amp;quot;,
            pool=&amp;quot;residential_proxies&amp;quot;,
        )

        @task(task_id=f&amp;quot;check_{source}&amp;quot;)
        def check(source=source, ds=None):
            rows = count_rows(source, ds)  # your query against the target table
            if rows == 0:
                raise ValueError(f&amp;quot;{source}: zero rows for {ds}&amp;quot;)

        crawl &amp;gt;&amp;gt; check()

prices()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What each part gives you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One task per source.&lt;/strong&gt; Retailer B failing doesn&amp;#39;t block A and C, and a retry reruns only B.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;{{ ds }}&lt;/code&gt;&lt;/strong&gt;: Airflow passes the run&amp;#39;s logical date, so a rerun of Tuesday scrapes as Tuesday.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retries with exponential backoff&lt;/strong&gt;: temporary blocks and proxy hiccups often clear on their own after a few minutes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A check task after each crawl&lt;/strong&gt;: a spider that exits cleanly with zero rows now fails loudly, which is the most common silent failure in scraping.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Pools: limit concurrency by proxy, not by server&lt;/h2&gt;
&lt;p&gt;Scrapy already controls concurrency inside one spider. The problem is five spiders starting at 02:15 and all hitting the same residential proxy pool at once.&lt;/p&gt;
&lt;p&gt;An Airflow &lt;strong&gt;pool&lt;/strong&gt; caps how many tasks can use a shared resource at the same time:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;airflow pools set residential_proxies 2 &amp;quot;Paid residential proxy pool&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now at most two proxy-heavy crawls run together, and the rest queue. Sources on cheap datacenter IPs can use a different pool with more slots.&lt;/p&gt;
&lt;h2&gt;Backfills without editing anything&lt;/h2&gt;
&lt;p&gt;A site was down for three days, or you fixed a parser bug and want to rebuild last week. With idempotent spiders, that&amp;#39;s one command:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Airflow 3
airflow backfill create --dag-id prices --from-date 2025-09-29 --to-date 2025-10-05

# Airflow 2.x
airflow dags backfill prices -s 2025-09-29 -e 2025-10-05
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each day runs with its own &lt;code&gt;ds&lt;/code&gt;, and the upserts make the reruns safe.&lt;/p&gt;
&lt;h2&gt;Alerts that fire on bad data&lt;/h2&gt;
&lt;p&gt;Airflow can call a function whenever a task fails. Send that to Slack or email:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def notify(context):
    ti = context[&amp;quot;task_instance&amp;quot;]
    post_to_slack(f&amp;quot;{ti.dag_id}.{ti.task_id} failed for {context[&amp;#39;ds&amp;#39;]}: {ti.log_url}&amp;quot;)

# add to default_args: &amp;quot;on_failure_callback&amp;quot;: notify
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Because the check tasks raise on zero or low row counts, this one callback covers both crashes and silent failures. It stays quiet when everything is fine, so people keep reading it.&lt;/p&gt;
&lt;h2&gt;When Airflow is overkill&lt;/h2&gt;
&lt;p&gt;Stay on cron if you have one or two spiders, no downstream dependencies, and someone checks the output anyway. Airflow is a service you have to run and upgrade. Move when retries per source, backfills, dependencies or run history start costing you real time.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Should the spider run inside the Airflow worker?&lt;/strong&gt;
For small setups, yes (a BashOperator is fine). For heavier crawls, run each spider in its own container with KubernetesPodOperator or DockerOperator, so a memory-hungry browser crawl can&amp;#39;t take down the scheduler.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What about Scrapyd or Zyte&amp;#39;s Scrapy Cloud?&lt;/strong&gt;
They&amp;#39;re good for running spiders. Airflow adds the orchestration around them: dependencies, checks, backfills and one place to see everything. You can trigger Scrapyd jobs from Airflow too.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How do I load into a warehouse after all sources finish?&lt;/strong&gt;
Add a final task downstream of every check task. It only runs when all sources pass, so your warehouse never gets a half-loaded day.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Airflow, Prefect or Dagster?&lt;/strong&gt;
All three work. Airflow has the largest ecosystem and is the one most job postings ask for, which matters if clients will maintain it after you.&lt;/p&gt;
</content:encoded></item><item><title>Scrapy vs Playwright vs the hidden API: how to choose</title><link>https://tamangsurendra.com.np/blog/scrapy-vs-playwright-vs-api/</link><guid isPermaLink="true">https://tamangsurendra.com.np/blog/scrapy-vs-playwright-vs-api/</guid><description>A practical decision guide for picking the right scraping approach per site, and why the fastest scraper often uses no HTML at all.</description><pubDate>Wed, 18 Jun 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&amp;quot;Should I use Scrapy or Playwright?&amp;quot; is the question I get most often, and the honest answer is usually &amp;quot;neither, first.&amp;quot; Before choosing a tool, check where the data actually comes from. Often the page is only a shell, and the data arrives from an API you can call directly.&lt;/p&gt;
&lt;p&gt;This is the order I work through for every new site.&lt;/p&gt;
&lt;h2&gt;Step 1: Open the network tab before writing any code&lt;/h2&gt;
&lt;p&gt;Load the page with DevTools open, filter by &lt;strong&gt;Fetch/XHR&lt;/strong&gt;, and reload. Look for responses that contain the data you want, as JSON. Common places:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A REST endpoint like &lt;code&gt;/api/products?page=2&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;A GraphQL endpoint (&lt;code&gt;/graphql&lt;/code&gt;) with a query in the request body&lt;/li&gt;
&lt;li&gt;Data embedded in the HTML as JSON, in a &lt;code&gt;&amp;lt;script id=&amp;quot;__NEXT_DATA__&amp;quot;&amp;gt;&lt;/code&gt; tag or &lt;code&gt;window.__INITIAL_STATE__&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you find it, you usually don&amp;#39;t need to parse HTML at all:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import httpx

r = httpx.get(
    &amp;quot;https://example.com/api/products&amp;quot;,
    params={&amp;quot;page&amp;quot;: 2, &amp;quot;per_page&amp;quot;: 100},
    headers={&amp;quot;accept&amp;quot;: &amp;quot;application/json&amp;quot;},
)
items = r.json()[&amp;quot;items&amp;quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why the API wins:&lt;/strong&gt; structured data, fewer requests (often 100 items per call instead of 20 per page), and no selectors that break when the design changes. Frontends get redesigned far more often than their APIs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Watch for:&lt;/strong&gt; auth tokens, signed parameters and rate limits. If a token comes from an earlier response, trace where it&amp;#39;s set and fetch it the same way the browser does.&lt;/p&gt;
&lt;h2&gt;Step 2: Is the HTML server-rendered? Use Scrapy&lt;/h2&gt;
&lt;p&gt;Right-click → &lt;strong&gt;View page source&lt;/strong&gt; (not Inspect). If the data is in the raw HTML, the site is server-rendered, and Scrapy is the right tool:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Fast and asynchronous, with hundreds of concurrent requests on a small server&lt;/li&gt;
&lt;li&gt;Built-in retries, throttling, caching and export pipelines&lt;/li&gt;
&lt;li&gt;Very cheap to run compared with a browser&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import scrapy

class ProductsSpider(scrapy.Spider):
    name = &amp;quot;products&amp;quot;
    start_urls = [&amp;quot;https://example.com/catalog&amp;quot;]

    def parse(self, response):
        for card in response.css(&amp;quot;article.product&amp;quot;):
            yield {
                &amp;quot;url&amp;quot;: response.urljoin(card.css(&amp;quot;a::attr(href)&amp;quot;).get()),
                &amp;quot;title&amp;quot;: card.css(&amp;quot;h2::text&amp;quot;).get(default=&amp;quot;&amp;quot;).strip(),
                &amp;quot;price&amp;quot;: card.css(&amp;quot;.price::text&amp;quot;).get(),
            }
        yield from response.follow_all(css=&amp;quot;a.next&amp;quot;, callback=self.parse)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a full walkthrough, see &lt;a href=&quot;/blog/web-scraping-scrapy-part-1&quot;&gt;Scrapy part 1&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Step 3: Rendered by JavaScript and no usable API? Use a browser&lt;/h2&gt;
&lt;p&gt;Use Playwright (or a stealth-focused browser) when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The data only appears after JavaScript runs, and you can&amp;#39;t find or reproduce the API call&lt;/li&gt;
&lt;li&gt;The site&amp;#39;s anti-bot challenge requires a real browser to pass&lt;/li&gt;
&lt;li&gt;You need to interact: click through filters, scroll infinite lists, submit forms&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;The cost:&lt;/strong&gt; a browser page uses far more CPU, memory and bandwidth than an HTTP request. It&amp;#39;s easily an order of magnitude more expensive per page, and slower. On a big crawl that&amp;#39;s the difference between one small server and a fleet.&lt;/p&gt;
&lt;h2&gt;Step 4: Often the best answer is both&lt;/h2&gt;
&lt;p&gt;Most production scrapers I build are hybrids:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Browser for the session, HTTP for the data.&lt;/strong&gt; Use Playwright to pass the challenge and log in, then hand the cookies to a fast HTTP client for thousands of API calls.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;scrapy-playwright.&lt;/strong&gt; Keep Scrapy&amp;#39;s scheduling and pipelines, and render only the pages that need it with &lt;code&gt;meta={&amp;quot;playwright&amp;quot;: True}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Browser for discovery, Scrapy for detail pages.&lt;/strong&gt; The listing is JavaScript-heavy, but the detail pages are server-rendered.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The decision table&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;JSON API or embedded JSON exists&lt;/td&gt;
&lt;td&gt;HTTP client on the API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data is in the page source&lt;/td&gt;
&lt;td&gt;Scrapy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JS-rendered, API reproducible&lt;/td&gt;
&lt;td&gt;HTTP client on the API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JS-rendered, API unusable&lt;/td&gt;
&lt;td&gt;Playwright / browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anti-bot challenge needs a browser&lt;/td&gt;
&lt;td&gt;Browser for session, HTTP for data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Millions of pages&lt;/td&gt;
&lt;td&gt;Scrapy or HTTP client. Browsers only where unavoidable.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Is using a site&amp;#39;s internal API allowed?&lt;/strong&gt;
It&amp;#39;s the same data the site sends to every visitor&amp;#39;s browser, but the rules are the site&amp;#39;s terms and your local law. Stick to public data, respect rate limits, and avoid personal data.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Isn&amp;#39;t Playwright easier for beginners?&lt;/strong&gt;
It feels easier because it &amp;quot;just shows the page&amp;quot;. But it&amp;#39;s slower, costs more to run, and breaks just as easily on layout changes. Spend 10 minutes in the network tab first.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can Scrapy handle JavaScript?&lt;/strong&gt;
Not by itself. Add scrapy-playwright for the pages that need it, or better, call the API that the JavaScript uses.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What about login-protected data?&lt;/strong&gt;
Log in with a browser or an HTTP session, keep the cookies, and reuse them. Only scrape behind a login when you have the right to access that data.&lt;/p&gt;
</content:encoded></item><item><title>How antibot systems fingerprint your scraper</title><link>https://tamangsurendra.com.np/blog/how-antibot-systems-fingerprint-your-scraper/</link><guid isPermaLink="true">https://tamangsurendra.com.np/blog/how-antibot-systems-fingerprint-your-scraper/</guid><description>TLS handshakes, header order, and browser APIs: the signals protection vendors actually check, from someone who reverse-engineers them.</description><pubDate>Wed, 12 Mar 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When a scraper gets blocked, most people change the User-Agent and add a proxy. Sometimes that works for a day. Then the blocks come back, because the User-Agent was never the main thing being checked.&lt;/p&gt;
&lt;p&gt;Cloudflare, DataDome, Akamai, PerimeterX (HUMAN) and Kasada all score many signals together. This post walks through them in the order a request actually meets them, and ends with what holds up in production.&lt;/p&gt;
&lt;h2&gt;What a protection vendor sees before your first request finishes&lt;/h2&gt;
&lt;p&gt;Before your HTTP request is even read, the server already knows several things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Your IP and its network.&lt;/strong&gt; Is it a datacenter range (AWS, Hetzner, OVH), a residential ISP or a mobile carrier? Has this IP been seen on other protected sites recently?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Your TLS handshake.&lt;/strong&gt; Which ciphers, extensions and curves your client offered, in what order.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Your HTTP/2 connection.&lt;/strong&gt; The SETTINGS values, window sizes and pseudo-header order your client sends.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Only then come headers, cookies, JavaScript challenges and behaviour. Each layer adds to a risk score. Crossing a threshold gets you a challenge page, a block or, worse, quietly fake data.&lt;/p&gt;
&lt;h2&gt;TLS fingerprinting: you&amp;#39;re identified at the handshake&lt;/h2&gt;
&lt;p&gt;Every TLS client says hello differently. Chrome, Firefox, Python &lt;code&gt;requests&lt;/code&gt;, Go&amp;#39;s &lt;code&gt;net/http&lt;/code&gt; and Node all send a distinct ClientHello. Hashing its fields gives a fingerprint. JA3 was the original; JA4 is the newer, more robust version.&lt;/p&gt;
&lt;p&gt;This is why this fails:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import requests
requests.get(url, headers={&amp;quot;User-Agent&amp;quot;: &amp;quot;Mozilla/5.0 ... Chrome/124.0 ...&amp;quot;})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The header claims Chrome, but the TLS handshake says Python &lt;code&gt;urllib3&lt;/code&gt;. That mismatch is one of the cheapest signals a vendor can check, and one of the most reliable.&lt;/p&gt;
&lt;p&gt;The fix is a client that reproduces a real browser handshake, like &lt;code&gt;curl_cffi&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from curl_cffi import requests

r = requests.get(url, impersonate=&amp;quot;chrome&amp;quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now the TLS and HTTP/2 layers look like Chrome. Headers alone never could.&lt;/p&gt;
&lt;h2&gt;HTTP/2 frames and header order&lt;/h2&gt;
&lt;p&gt;HTTP/2 adds another fingerprint. Browsers send specific SETTINGS values (header table size, initial window size, max concurrent streams), a WINDOW_UPDATE of a specific size, and pseudo-headers (&lt;code&gt;:method&lt;/code&gt;, &lt;code&gt;:authority&lt;/code&gt;, &lt;code&gt;:scheme&lt;/code&gt;, &lt;code&gt;:path&lt;/code&gt;) in a fixed order. Each browser family does this slightly differently, and HTTP libraries differ from all of them.&lt;/p&gt;
&lt;p&gt;Regular header order matters too. Chrome sends &lt;code&gt;sec-ch-ua&lt;/code&gt;, &lt;code&gt;sec-ch-ua-mobile&lt;/code&gt; and &lt;code&gt;sec-ch-ua-platform&lt;/code&gt; near the top, then &lt;code&gt;user-agent&lt;/code&gt;, &lt;code&gt;accept&lt;/code&gt; and the &lt;code&gt;sec-fetch-*&lt;/code&gt; headers in a consistent order. A request with the right headers in the wrong order, or with &lt;code&gt;sec-ch-ua&lt;/code&gt; claiming Chrome 124 while the User-Agent says 120, stands out.&lt;/p&gt;
&lt;p&gt;The rule: &lt;strong&gt;every layer must tell the same story.&lt;/strong&gt; IP location, TLS, HTTP/2, headers, and later the JavaScript environment all need to agree on who you are.&lt;/p&gt;
&lt;h2&gt;Browser-side signals: canvas, fonts, and API probing&lt;/h2&gt;
&lt;p&gt;If the site serves a JavaScript challenge, it runs code in your browser and reports back. Typical probes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Automation flags:&lt;/strong&gt; &lt;code&gt;navigator.webdriver&lt;/code&gt;, and traces left by the Chrome DevTools Protocol that Playwright and Puppeteer use.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Environment consistency:&lt;/strong&gt; does &lt;code&gt;navigator.platform&lt;/code&gt; match the User-Agent? Do the screen size, timezone and language match the IP&amp;#39;s country?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rendering:&lt;/strong&gt; canvas and WebGL output, the GPU renderer string, and installed fonts. Headless browsers on Linux servers produce recognisable results.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;API behaviour:&lt;/strong&gt; how native functions stringify, whether properties were patched, and timing quirks that differ in headless mode.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Behaviour:&lt;/strong&gt; mouse movement, scrolling, and time between page load and interaction.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Challenge scripts are obfuscated and change often, so the exact checks shift. The categories stay the same.&lt;/p&gt;
&lt;h2&gt;What actually works in production&lt;/h2&gt;
&lt;p&gt;No single trick. It&amp;#39;s a set of habits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Use the lightest client that passes.&lt;/strong&gt; Try a TLS-impersonating HTTP client first. It&amp;#39;s fast and cheap. Go to a real browser only for sites whose JavaScript challenge requires one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;When you need a browser, use one built for it.&lt;/strong&gt; Patched or stealth-focused browsers (for example Camoufox, or Chromium driven without the usual automation leaks) pass far more often than stock headless Chrome.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Match the proxy to the target.&lt;/strong&gt; Datacenter IPs are fine for unprotected sites. Heavily protected ones need residential or mobile IPs, geolocated to match your browser&amp;#39;s timezone and language.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep sessions sticky.&lt;/strong&gt; Reuse one IP, one fingerprint and one cookie jar per session. Rotating the IP every request while keeping the same cookies is a red flag, not camouflage.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Slow down.&lt;/strong&gt; Most blocks I debug come from request rate, not fingerprints. Realistic concurrency per IP beats clever evasion.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Measure block rate.&lt;/strong&gt; Log challenge pages and 403s per source. A rising block rate warns you before your data drops. That&amp;#39;s the core of &lt;a href=&quot;/blog/scraper-to-monitored-pipeline&quot;&gt;monitoring a scraper properly&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And stay on the right side of the line. Collect publicly available data, respect rate limits, avoid personal data you don&amp;#39;t need, and read the site&amp;#39;s terms before you start.&lt;/p&gt;
&lt;h2&gt;FAQ&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Why do I get blocked even with residential proxies?&lt;/strong&gt;
Because the IP is only one signal. If your TLS fingerprint says Python while your User-Agent says Chrome, a clean residential IP won&amp;#39;t save you.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Is headless Chrome enough?&lt;/strong&gt;
For lightly protected sites, often yes. For DataDome, Kasada or strict Cloudflare settings, stock headless Chrome is easy to detect. Use a stealth-focused browser, or find the site&amp;#39;s underlying API.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What&amp;#39;s JA3 and JA4?&lt;/strong&gt;
Ways to hash a TLS ClientHello into a fingerprint. JA4 is the newer format, and it&amp;#39;s harder to fool by shuffling extension order.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can I bypass these systems permanently?&lt;/strong&gt;
No. Vendors update their checks continuously. What lasts is a setup that detects blocks early and adapts: monitoring, a fallback client, and time budgeted for maintenance.&lt;/p&gt;
</content:encoded></item><item><title>Scrapy, part 5: deploying to production</title><link>https://tamangsurendra.com.np/blog/web-scraping-scrapy-part-5/</link><guid isPermaLink="true">https://tamangsurendra.com.np/blog/web-scraping-scrapy-part-5/</guid><description>Docker, Kubernetes, CI/CD, and monitoring. What it takes to keep a scraper fleet running while you sleep.</description><pubDate>Wed, 25 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Scrapy Part 5: Production Deployment&lt;/h1&gt;
&lt;p&gt;Final part of the series. The scraper works; now it has to run unattended. This part covers packaging it with a multi-stage Docker build, running it on Kubernetes with autoscaling, wiring up a CI/CD pipeline with automated tests, monitoring it with Prometheus and Grafana, and hardening it for production.&lt;/p&gt;
&lt;h2&gt;Docker Containerization&lt;/h2&gt;
&lt;h3&gt;Multi-Stage Dockerfile&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;# webscraper/Dockerfile
# Multi-stage build for optimized production image

# Build stage
FROM python:3.11-slim as builder

# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

# Install system dependencies
RUN apt-get update &amp;amp;&amp;amp; apt-get install -y \
    gcc \
    g++ \
    libxml2-dev \
    libxslt-dev \
    libffi-dev \
    libssl-dev \
    build-essential \
    &amp;amp;&amp;amp; rm -rf /var/lib/apt/lists/*

# Create and activate virtual environment
RUN python -m venv /opt/venv
ENV PATH=&amp;quot;/opt/venv/bin:$PATH&amp;quot;

# Install Python dependencies
COPY requirements.txt .
RUN pip install --upgrade pip &amp;amp;&amp;amp; \
    pip install -r requirements.txt

# Production stage
FROM python:3.11-slim as production

# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PATH=&amp;quot;/opt/venv/bin:$PATH&amp;quot;

# Create non-root user
RUN groupadd -r scrapy &amp;amp;&amp;amp; useradd -r -g scrapy scrapy

# Install runtime dependencies
RUN apt-get update &amp;amp;&amp;amp; apt-get install -y \
    curl \
    ca-certificates \
    &amp;amp;&amp;amp; rm -rf /var/lib/apt/lists/*

# Copy virtual environment from builder stage
COPY --from=builder /opt/venv /opt/venv

# Create application directory
WORKDIR /app

# Copy application code
COPY --chown=scrapy:scrapy . .

# Create necessary directories
RUN mkdir -p /app/logs /app/data /app/exports &amp;amp;&amp;amp; \
    chown -R scrapy:scrapy /app

# Switch to non-root user
USER scrapy

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
    CMD curl -f http://localhost:6023/ || exit 1

# Default command
CMD [&amp;quot;scrapy&amp;quot;, &amp;quot;list&amp;quot;]

# Development stage
FROM production as development

USER root

# Install development dependencies
RUN pip install pytest pytest-cov black flake8 mypy

# Install debugging tools
RUN apt-get update &amp;amp;&amp;amp; apt-get install -y \
    vim \
    htop \
    net-tools \
    &amp;amp;&amp;amp; rm -rf /var/lib/apt/lists/*

USER scrapy

# Override default command for development
CMD [&amp;quot;tail&amp;quot;, &amp;quot;-f&amp;quot;, &amp;quot;/dev/null&amp;quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Docker Compose for Development&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# docker-compose.yml
version: &amp;#39;3.8&amp;#39;

services:
  # Main scraper service
  scraper:
    build:
      context: .
      target: development
      dockerfile: Dockerfile
    volumes:
      - .:/app
      - ./data:/app/data
      - ./logs:/app/logs
    environment:
      - SCRAPY_SETTINGS_MODULE=webscraper.settings.development
      - REDIS_URL=redis://redis:6379/0
      - MONGO_URI=mongodb://mongo:27017/scrapy_dev
      - POSTGRES_HOST=postgres
      - POSTGRES_DB=scrapy_dev
      - POSTGRES_USER=scrapy
      - POSTGRES_PASSWORD=scrapy_password
    depends_on:
      - redis
      - mongo
      - postgres
    networks:
      - scrapy-network

  # Redis for distributed scraping
  redis:
    image: redis:7-alpine
    ports:
      - &amp;quot;6379:6379&amp;quot;
    volumes:
      - redis-data:/data
    command: redis-server --appendonly yes
    networks:
      - scrapy-network

  # MongoDB for document storage
  mongo:
    image: mongo:6
    ports:
      - &amp;quot;27017:27017&amp;quot;
    volumes:
      - mongo-data:/data/db
    environment:
      - MONGO_INITDB_ROOT_USERNAME=admin
      - MONGO_INITDB_ROOT_PASSWORD=admin_password
    networks:
      - scrapy-network

  # PostgreSQL for structured data
  postgres:
    image: postgres:15
    ports:
      - &amp;quot;5432:5432&amp;quot;
    volumes:
      - postgres-data:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB=scrapy_dev
      - POSTGRES_USER=scrapy
      - POSTGRES_PASSWORD=scrapy_password
    networks:
      - scrapy-network

  # Elasticsearch for search and analytics
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.8.0
    ports:
      - &amp;quot;9200:9200&amp;quot;
    volumes:
      - es-data:/usr/share/elasticsearch/data
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - &amp;quot;ES_JAVA_OPTS=-Xms512m -Xmx512m&amp;quot;
    networks:
      - scrapy-network

  # Kibana for data visualization
  kibana:
    image: docker.elastic.co/kibana/kibana:8.8.0
    ports:
      - &amp;quot;5601:5601&amp;quot;
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
    depends_on:
      - elasticsearch
    networks:
      - scrapy-network

  # Prometheus for monitoring
  prometheus:
    image: prom/prometheus:latest
    ports:
      - &amp;quot;9090:9090&amp;quot;
    volumes:
      - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus-data:/prometheus
    command:
      - &amp;#39;--config.file=/etc/prometheus/prometheus.yml&amp;#39;
      - &amp;#39;--storage.tsdb.path=/prometheus&amp;#39;
      - &amp;#39;--web.console.libraries=/etc/prometheus/console_libraries&amp;#39;
      - &amp;#39;--web.console.templates=/etc/prometheus/consoles&amp;#39;
    networks:
      - scrapy-network

  # Grafana for dashboards
  grafana:
    image: grafana/grafana:latest
    ports:
      - &amp;quot;3000:3000&amp;quot;
    volumes:
      - grafana-data:/var/lib/grafana
      - ./monitoring/grafana:/etc/grafana/provisioning
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    depends_on:
      - prometheus
    networks:
      - scrapy-network

  # Splash for JavaScript rendering
  splash:
    image: scrapinghub/splash:latest
    ports:
      - &amp;quot;8050:8050&amp;quot;
    command: --max-timeout=3600 --slots=5
    networks:
      - scrapy-network

volumes:
  redis-data:
  mongo-data:
  postgres-data:
  es-data:
  prometheus-data:
  grafana-data:

networks:
  scrapy-network:
    driver: bridge
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Production Docker Compose&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# docker-compose.prod.yml
version: &amp;#39;3.8&amp;#39;

services:
  # Load balancer
  nginx:
    image: nginx:alpine
    ports:
      - &amp;quot;80:80&amp;quot;
      - &amp;quot;443:443&amp;quot;
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf
      - ./nginx/ssl:/etc/nginx/ssl
    depends_on:
      - scraper-coordinator
    networks:
      - scrapy-network

  # Coordinator service
  scraper-coordinator:
    build:
      context: .
      target: production
    command: [&amp;quot;python&amp;quot;, &amp;quot;scripts/coordinator.py&amp;quot;]
    environment:
      - SCRAPY_SETTINGS_MODULE=webscraper.settings.production
      - REDIS_URL=redis://redis-cluster:6379/0
      - SENTRY_DSN=${SENTRY_DSN}
    deploy:
      replicas: 1
      resources:
        limits:
          cpus: &amp;#39;0.5&amp;#39;
          memory: 512M
        reservations:
          cpus: &amp;#39;0.25&amp;#39;
          memory: 256M
    depends_on:
      - redis-cluster
    networks:
      - scrapy-network

  # Worker services
  scraper-worker:
    build:
      context: .
      target: production
    command: [&amp;quot;python&amp;quot;, &amp;quot;scripts/worker.py&amp;quot;]
    environment:
      - SCRAPY_SETTINGS_MODULE=webscraper.settings.production
      - REDIS_URL=redis://redis-cluster:6379/0
      - WORKER_ID=${HOSTNAME}
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: &amp;#39;1.0&amp;#39;
          memory: 1G
        reservations:
          cpus: &amp;#39;0.5&amp;#39;
          memory: 512M
    depends_on:
      - redis-cluster
      - scraper-coordinator
    networks:
      - scrapy-network

  # Redis cluster
  redis-cluster:
    image: redis:7-alpine
    command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis-prod-data:/data
    deploy:
      resources:
        limits:
          cpus: &amp;#39;0.5&amp;#39;
          memory: 512M
    networks:
      - scrapy-network

  # Monitoring stack
  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./monitoring/prometheus.prod.yml:/etc/prometheus/prometheus.yml
      - prometheus-prod-data:/prometheus
    deploy:
      resources:
        limits:
          cpus: &amp;#39;0.3&amp;#39;
          memory: 256M
    networks:
      - scrapy-network

  grafana:
    image: grafana/grafana:latest
    volumes:
      - grafana-prod-data:/var/lib/grafana
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
      - GF_INSTALL_PLUGINS=grafana-piechart-panel
    deploy:
      resources:
        limits:
          cpus: &amp;#39;0.3&amp;#39;
          memory: 256M
    networks:
      - scrapy-network

volumes:
  redis-prod-data:
  prometheus-prod-data:
  grafana-prod-data:

networks:
  scrapy-network:
    driver: overlay
    attachable: true
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Kubernetes Deployment&lt;/h2&gt;
&lt;h3&gt;Kubernetes Manifests&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# k8s/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: scrapy-production
  labels:
    name: scrapy-production

---
# k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: scrapy-config
  namespace: scrapy-production
data:
  SCRAPY_SETTINGS_MODULE: &amp;quot;webscraper.settings.production&amp;quot;
  REDIS_URL: &amp;quot;redis://redis-service:6379/0&amp;quot;
  LOG_LEVEL: &amp;quot;INFO&amp;quot;
  CONCURRENT_REQUESTS: &amp;quot;16&amp;quot;
  DOWNLOAD_DELAY: &amp;quot;1&amp;quot;

---
# k8s/secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: scrapy-secrets
  namespace: scrapy-production
type: Opaque
data:
  # Base64 encoded values
  POSTGRES_PASSWORD: c2NyYXB5X3Bhc3N3b3Jk  # scrapy_password
  MONGO_PASSWORD: bW9uZ29fcGFzc3dvcmQ=      # mongo_password
  SENTRY_DSN: aHR0cHM6Ly9zZW50cnkuaW8=    # https://sentry.io

---
# k8s/redis-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis
  namespace: scrapy-production
spec:
  replicas: 1
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      containers:
      - name: redis
        image: redis:7-alpine
        ports:
        - containerPort: 6379
        command: [&amp;quot;redis-server&amp;quot;]
        args: [&amp;quot;--appendonly&amp;quot;, &amp;quot;yes&amp;quot;, &amp;quot;--maxmemory&amp;quot;, &amp;quot;256mb&amp;quot;]
        resources:
          requests:
            memory: &amp;quot;128Mi&amp;quot;
            cpu: &amp;quot;100m&amp;quot;
          limits:
            memory: &amp;quot;512Mi&amp;quot;
            cpu: &amp;quot;500m&amp;quot;
        volumeMounts:
        - name: redis-storage
          mountPath: /data
      volumes:
      - name: redis-storage
        persistentVolumeClaim:
          claimName: redis-pvc

---
apiVersion: v1
kind: Service
metadata:
  name: redis-service
  namespace: scrapy-production
spec:
  selector:
    app: redis
  ports:
  - port: 6379
    targetPort: 6379
  type: ClusterIP

---
# k8s/coordinator-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: scraper-coordinator
  namespace: scrapy-production
spec:
  replicas: 1
  selector:
    matchLabels:
      app: scraper-coordinator
  template:
    metadata:
      labels:
        app: scraper-coordinator
    spec:
      containers:
      - name: coordinator
        image: your-registry/webscraper:latest
        command: [&amp;quot;python&amp;quot;, &amp;quot;scripts/coordinator.py&amp;quot;]
        envFrom:
        - configMapRef:
            name: scrapy-config
        - secretRef:
            name: scrapy-secrets
        resources:
          requests:
            memory: &amp;quot;256Mi&amp;quot;
            cpu: &amp;quot;200m&amp;quot;
          limits:
            memory: &amp;quot;512Mi&amp;quot;
            cpu: &amp;quot;500m&amp;quot;
        ports:
        - containerPort: 8080
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /ready
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 5

---
# k8s/worker-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: scraper-worker
  namespace: scrapy-production
spec:
  replicas: 3
  selector:
    matchLabels:
      app: scraper-worker
  template:
    metadata:
      labels:
        app: scraper-worker
    spec:
      containers:
      - name: worker
        image: your-registry/webscraper:latest
        command: [&amp;quot;python&amp;quot;, &amp;quot;scripts/worker.py&amp;quot;]
        envFrom:
        - configMapRef:
            name: scrapy-config
        - secretRef:
            name: scrapy-secrets
        env:
        - name: WORKER_ID
          valueFrom:
            fieldRef:
              fieldPath: metadata.name
        resources:
          requests:
            memory: &amp;quot;512Mi&amp;quot;
            cpu: &amp;quot;300m&amp;quot;
          limits:
            memory: &amp;quot;1Gi&amp;quot;
            cpu: &amp;quot;1000m&amp;quot;
        ports:
        - containerPort: 8081
        livenessProbe:
          httpGet:
            path: /health
            port: 8081
          initialDelaySeconds: 60
          periodSeconds: 30
        readinessProbe:
          httpGet:
            path: /ready
            port: 8081
          initialDelaySeconds: 10
          periodSeconds: 10

---
# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: scraper-worker-hpa
  namespace: scrapy-production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: scraper-worker
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        averageUtilization: 80
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 300
      policies:
      - type: Percent
        value: 100
        periodSeconds: 15
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
      - type: Percent
        value: 10
        periodSeconds: 60

---
# k8s/cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: scraper-job
  namespace: scrapy-production
spec:
  schedule: &amp;quot;0 */6 * * *&amp;quot;  # Every 6 hours
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: scraper
            image: your-registry/webscraper:latest
            command: [&amp;quot;scrapy&amp;quot;, &amp;quot;crawl&amp;quot;, &amp;quot;ecommerce&amp;quot;]
            envFrom:
            - configMapRef:
                name: scrapy-config
            - secretRef:
                name: scrapy-secrets
            resources:
              requests:
                memory: &amp;quot;1Gi&amp;quot;
                cpu: &amp;quot;500m&amp;quot;
              limits:
                memory: &amp;quot;2Gi&amp;quot;
                cpu: &amp;quot;1000m&amp;quot;
          restartPolicy: OnFailure
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 1

---
# k8s/pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: redis-pvc
  namespace: scrapy-production
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 10Gi
  storageClassName: fast-ssd
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;CI/CD Pipeline&lt;/h2&gt;
&lt;h3&gt;GitHub Actions Workflow&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/ci-cd.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  # Test and quality checks
  test:
    runs-on: ubuntu-latest
    services:
      redis:
        image: redis
        options: &amp;gt;-
          --health-cmd &amp;quot;redis-cli ping&amp;quot;
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 6379:6379

    steps:
    - uses: actions/checkout@v4

    - name: Set up Python
      uses: actions/setup-python@v4
      with:
        python-version: &amp;#39;3.11&amp;#39;

    - name: Cache dependencies
      uses: actions/cache@v3
      with:
        path: ~/.cache/pip
        key: ${{ runner.os }}-pip-${{ hashFiles(&amp;#39;**/requirements.txt&amp;#39;) }}

    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install -r requirements-dev.txt

    - name: Code formatting check
      run: |
        black --check --diff .

    - name: Linting
      run: |
        flake8 webscraper tests

    - name: Type checking
      run: |
        mypy webscraper

    - name: Security scan
      run: |
        bandit -r webscraper

    - name: Run tests
      run: |
        pytest tests/ --cov=webscraper --cov-report=xml --cov-report=html
      env:
        REDIS_URL: redis://localhost:6379/0

    - name: Upload coverage
      uses: codecov/codecov-action@v3
      with:
        token: ${{ secrets.CODECOV_TOKEN }}
        file: ./coverage.xml

  # Build and push Docker image
  build:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
    - uses: actions/checkout@v4

    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v3

    - name: Login to Container Registry
      uses: docker/login-action@v3
      with:
        registry: ${{ env.REGISTRY }}
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}

    - name: Extract metadata
      id: meta
      uses: docker/metadata-action@v5
      with:
        images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
        tags: |
          type=ref,event=branch
          type=ref,event=pr
          type=sha,prefix={{branch}}-
          type=raw,value=latest,enable={{is_default_branch}}

    - name: Build and push
      uses: docker/build-push-action@v5
      with:
        context: .
        target: production
        push: true
        tags: ${{ steps.meta.outputs.tags }}
        labels: ${{ steps.meta.outputs.labels }}
        cache-from: type=gha
        cache-to: type=gha,mode=max

  # Deploy to staging
  deploy-staging:
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == &amp;#39;refs/heads/develop&amp;#39;
    environment: staging

    steps:
    - uses: actions/checkout@v4

    - name: Configure kubectl
      uses: azure/k8s-set-context@v3
      with:
        method: kubeconfig
        kubeconfig: ${{ secrets.KUBE_CONFIG_STAGING }}

    - name: Deploy to staging
      run: |
        # Update image in k8s manifests
        sed -i &amp;quot;s|your-registry/webscraper:latest|${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:develop|g&amp;quot; k8s/*.yaml
        
        # Apply manifests
        kubectl apply -f k8s/ -n scrapy-staging
        
        # Wait for rollout
        kubectl rollout status deployment/scraper-coordinator -n scrapy-staging
        kubectl rollout status deployment/scraper-worker -n scrapy-staging

    - name: Run smoke tests
      run: |
        # Wait for services to be ready
        kubectl wait --for=condition=ready pod -l app=scraper-coordinator -n scrapy-staging --timeout=300s
        
        # Run basic smoke tests
        python scripts/smoke_tests.py --environment=staging

  # Deploy to production
  deploy-production:
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == &amp;#39;refs/heads/main&amp;#39;
    environment: production

    steps:
    - uses: actions/checkout@v4

    - name: Configure kubectl
      uses: azure/k8s-set-context@v3
      with:
        method: kubeconfig
        kubeconfig: ${{ secrets.KUBE_CONFIG_PRODUCTION }}

    - name: Deploy to production
      run: |
        # Update image in k8s manifests
        sed -i &amp;quot;s|your-registry/webscraper:latest|${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest|g&amp;quot; k8s/*.yaml
        
        # Apply manifests with rolling update
        kubectl apply -f k8s/ -n scrapy-production
        
        # Wait for rollout
        kubectl rollout status deployment/scraper-coordinator -n scrapy-production --timeout=600s
        kubectl rollout status deployment/scraper-worker -n scrapy-production --timeout=600s

    - name: Verify deployment
      run: |
        # Check pod health
        kubectl get pods -n scrapy-production
        
        # Run production health checks
        python scripts/health_check.py --environment=production

    - name: Notify deployment
      uses: 8398a7/action-slack@v3
      with:
        status: ${{ job.status }}
        channel: &amp;#39;#deployments&amp;#39;
        webhook_url: ${{ secrets.SLACK_WEBHOOK }}
      if: always()
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Testing Framework&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# tests/test_spiders.py
import pytest
import responses
from scrapy.http import HtmlResponse, Request
from webscraper.spiders.ecommerce_spider import EcommerceSpider
from webscraper.items import ProductItem

class TestEcommerceSpider:
    
    @pytest.fixture
    def spider(self):
        return EcommerceSpider()
    
    @pytest.fixture
    def sample_product_html(self):
        return &amp;quot;&amp;quot;&amp;quot;
        &amp;lt;html&amp;gt;
        &amp;lt;body&amp;gt;
            &amp;lt;h1 class=&amp;quot;product-title&amp;quot;&amp;gt;Test Product&amp;lt;/h1&amp;gt;
            &amp;lt;span class=&amp;quot;price-current&amp;quot;&amp;gt;$99.99&amp;lt;/span&amp;gt;
            &amp;lt;div class=&amp;quot;product-description&amp;quot;&amp;gt;
                &amp;lt;p&amp;gt;This is a test product description&amp;lt;/p&amp;gt;
            &amp;lt;/div&amp;gt;
            &amp;lt;span class=&amp;quot;brand-name&amp;quot;&amp;gt;TestBrand&amp;lt;/span&amp;gt;
            &amp;lt;span class=&amp;quot;rating-value&amp;quot;&amp;gt;4.5&amp;lt;/span&amp;gt;
            &amp;lt;div class=&amp;quot;stock-status&amp;quot;&amp;gt;In Stock&amp;lt;/div&amp;gt;
        &amp;lt;/body&amp;gt;
        &amp;lt;/html&amp;gt;
        &amp;quot;&amp;quot;&amp;quot;
    
    def create_response(self, html, url=&amp;quot;http://test.com&amp;quot;):
        request = Request(url=url)
        return HtmlResponse(url=url, request=request, body=html.encode(&amp;#39;utf-8&amp;#39;))
    
    def test_parse_product_basic(self, spider, sample_product_html):
        &amp;quot;&amp;quot;&amp;quot;Test basic product parsing&amp;quot;&amp;quot;&amp;quot;
        response = self.create_response(sample_product_html)
        
        items = list(spider.parse_product(response))
        
        assert len(items) == 1
        item = items[0]
        
        assert item[&amp;#39;name&amp;#39;] == &amp;#39;Test Product&amp;#39;
        assert item[&amp;#39;price&amp;#39;] == 99.99
        assert item[&amp;#39;brand&amp;#39;] == &amp;#39;TestBrand&amp;#39;
        assert item[&amp;#39;rating&amp;#39;] == 4.5
    
    def test_parse_product_missing_fields(self, spider):
        &amp;quot;&amp;quot;&amp;quot;Test handling of missing fields&amp;quot;&amp;quot;&amp;quot;
        html = &amp;quot;&amp;lt;html&amp;gt;&amp;lt;body&amp;gt;&amp;lt;h1&amp;gt;Product&amp;lt;/h1&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;&amp;quot;
        response = self.create_response(html)
        
        items = list(spider.parse_product(response))
        
        assert len(items) == 1
        item = items[0]
        assert item[&amp;#39;name&amp;#39;] == &amp;#39;Product&amp;#39;
        assert &amp;#39;price&amp;#39; not in item or item[&amp;#39;price&amp;#39;] is None
    
    @responses.activate
    def test_api_integration(self, spider):
        &amp;quot;&amp;quot;&amp;quot;Test API integration&amp;quot;&amp;quot;&amp;quot;
        # Mock API response
        responses.add(
            responses.GET,
            &amp;#39;http://api.test.com/products&amp;#39;,
            json={&amp;#39;products&amp;#39;: [{&amp;#39;id&amp;#39;: 1, &amp;#39;name&amp;#39;: &amp;#39;API Product&amp;#39;}]},
            status=200
        )
        
        # Test API call logic
        import requests
        response = requests.get(&amp;#39;http://api.test.com/products&amp;#39;)
        assert response.status_code == 200
        assert response.json()[&amp;#39;products&amp;#39;][0][&amp;#39;name&amp;#39;] == &amp;#39;API Product&amp;#39;

# tests/test_pipelines.py
import pytest
from itemadapter import ItemAdapter
from webscraper.pipelines import AdvancedValidationPipeline, ValidationError
from webscraper.items import ProductItem

class TestValidationPipeline:
    
    @pytest.fixture
    def pipeline(self):
        return AdvancedValidationPipeline()
    
    @pytest.fixture
    def valid_item(self):
        return ProductItem({
            &amp;#39;name&amp;#39;: &amp;#39;Test Product&amp;#39;,
            &amp;#39;url&amp;#39;: &amp;#39;https://test.com/product&amp;#39;,
            &amp;#39;price&amp;#39;: 99.99,
            &amp;#39;rating&amp;#39;: 4.5,
            &amp;#39;in_stock&amp;#39;: True
        })
    
    def test_valid_item_passes(self, pipeline, valid_item):
        &amp;quot;&amp;quot;&amp;quot;Test that valid item passes validation&amp;quot;&amp;quot;&amp;quot;
        result = pipeline.process_item(valid_item, None)
        assert result is not None
        assert result[&amp;#39;validation_passed&amp;#39;] is True
    
    def test_missing_required_field_fails(self, pipeline):
        &amp;quot;&amp;quot;&amp;quot;Test that missing required fields cause validation failure&amp;quot;&amp;quot;&amp;quot;
        item = ProductItem({&amp;#39;price&amp;#39;: 99.99})
        
        with pytest.raises(ValidationError):
            pipeline.process_item(item, None)
    
    def test_invalid_price_type_fails(self, pipeline):
        &amp;quot;&amp;quot;&amp;quot;Test that invalid price type causes validation failure&amp;quot;&amp;quot;&amp;quot;
        item = ProductItem({
            &amp;#39;name&amp;#39;: &amp;#39;Test Product&amp;#39;,
            &amp;#39;url&amp;#39;: &amp;#39;https://test.com/product&amp;#39;,
            &amp;#39;price&amp;#39;: &amp;#39;invalid&amp;#39;
        })
        
        with pytest.raises(ValidationError):
            pipeline.process_item(item, None)
    
    def test_price_cleaning(self, pipeline):
        &amp;quot;&amp;quot;&amp;quot;Test price cleaning functionality&amp;quot;&amp;quot;&amp;quot;
        price_tests = [
            (&amp;#39;$99.99&amp;#39;, 99.99),
            (&amp;#39;€1,234.56&amp;#39;, 1234.56),
            (&amp;#39;1.234,56&amp;#39;, 1234.56),
            (&amp;#39;FREE&amp;#39;, None),
            (&amp;#39;&amp;#39;, None)
        ]
        
        for input_price, expected in price_tests:
            cleaned = pipeline._clean_price(input_price)
            assert cleaned == expected

# scripts/smoke_tests.py
import requests
import time
import sys
import argparse
from typing import Dict, List

class SmokeTests:
    &amp;quot;&amp;quot;&amp;quot;Basic smoke tests for deployed environment&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, environment: str):
        self.environment = environment
        self.base_urls = {
            &amp;#39;staging&amp;#39;: &amp;#39;https://staging-scraper.yourcompany.com&amp;#39;,
            &amp;#39;production&amp;#39;: &amp;#39;https://scraper.yourcompany.com&amp;#39;
        }
        self.base_url = self.base_urls[environment]
    
    def test_health_endpoints(self) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Test health endpoints&amp;quot;&amp;quot;&amp;quot;
        endpoints = [&amp;#39;/health&amp;#39;, &amp;#39;/ready&amp;#39;, &amp;#39;/metrics&amp;#39;]
        
        for endpoint in endpoints:
            try:
                response = requests.get(f&amp;quot;{self.base_url}{endpoint}&amp;quot;, timeout=10)
                if response.status_code != 200:
                    print(f&amp;quot;❌ Health check failed for {endpoint}: {response.status_code}&amp;quot;)
                    return False
                print(f&amp;quot;✅ Health check passed for {endpoint}&amp;quot;)
            except Exception as e:
                print(f&amp;quot;❌ Health check failed for {endpoint}: {e}&amp;quot;)
                return False
        
        return True
    
    def test_basic_functionality(self) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Test basic scraping functionality&amp;quot;&amp;quot;&amp;quot;
        try:
            # Trigger a test spider run
            response = requests.post(
                f&amp;quot;{self.base_url}/api/spiders/test/start&amp;quot;,
                json={&amp;#39;test_mode&amp;#39;: True},
                timeout=30
            )
            
            if response.status_code != 200:
                print(f&amp;quot;❌ Failed to start test spider: {response.status_code}&amp;quot;)
                return False
            
            job_id = response.json().get(&amp;#39;job_id&amp;#39;)
            
            # Check job status
            for _ in range(10):  # Wait up to 50 seconds
                status_response = requests.get(
                    f&amp;quot;{self.base_url}/api/jobs/{job_id}/status&amp;quot;,
                    timeout=10
                )
                
                if status_response.status_code == 200:
                    status = status_response.json().get(&amp;#39;status&amp;#39;)
                    if status == &amp;#39;completed&amp;#39;:
                        print(&amp;quot;✅ Basic functionality test passed&amp;quot;)
                        return True
                    elif status == &amp;#39;failed&amp;#39;:
                        print(&amp;quot;❌ Basic functionality test failed&amp;quot;)
                        return False
                
                time.sleep(5)
            
            print(&amp;quot;❌ Basic functionality test timed out&amp;quot;)
            return False
            
        except Exception as e:
            print(f&amp;quot;❌ Basic functionality test failed: {e}&amp;quot;)
            return False
    
    def run_all_tests(self) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Run all smoke tests&amp;quot;&amp;quot;&amp;quot;
        print(f&amp;quot;🚀 Running smoke tests for {self.environment} environment&amp;quot;)
        
        tests = [
            self.test_health_endpoints,
            self.test_basic_functionality
        ]
        
        results = []
        for test in tests:
            results.append(test())
        
        success = all(results)
        
        if success:
            print(&amp;quot;🎉 All smoke tests passed!&amp;quot;)
        else:
            print(&amp;quot;💥 Some smoke tests failed!&amp;quot;)
        
        return success

def main():
    parser = argparse.ArgumentParser(description=&amp;#39;Run smoke tests&amp;#39;)
    parser.add_argument(&amp;#39;--environment&amp;#39;, required=True, 
                       choices=[&amp;#39;staging&amp;#39;, &amp;#39;production&amp;#39;],
                       help=&amp;#39;Target environment&amp;#39;)
    args = parser.parse_args()
    
    tests = SmokeTests(args.environment)
    success = tests.run_all_tests()
    
    sys.exit(0 if success else 1)

if __name__ == &amp;quot;__main__&amp;quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Production Monitoring and Observability&lt;/h2&gt;
&lt;h3&gt;Prometheus Metrics&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/monitoring/metrics.py
from prometheus_client import Counter, Histogram, Gauge, CollectorRegistry, push_to_gateway
import time
from functools import wraps
from typing import Callable
import logging

class ScrapingMetrics:
    &amp;quot;&amp;quot;&amp;quot;Prometheus metrics for scraping operations&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, pushgateway_url: str = None):
        self.registry = CollectorRegistry()
        self.pushgateway_url = pushgateway_url
        
        # Counters
        self.requests_total = Counter(
            &amp;#39;scrapy_requests_total&amp;#39;,
            &amp;#39;Total number of requests made&amp;#39;,
            [&amp;#39;spider&amp;#39;, &amp;#39;status&amp;#39;],
            registry=self.registry
        )
        
        self.items_scraped_total = Counter(
            &amp;#39;scrapy_items_scraped_total&amp;#39;,
            &amp;#39;Total number of items scraped&amp;#39;,
            [&amp;#39;spider&amp;#39;, &amp;#39;item_type&amp;#39;],
            registry=self.registry
        )
        
        self.errors_total = Counter(
            &amp;#39;scrapy_errors_total&amp;#39;,
            &amp;#39;Total number of errors&amp;#39;,
            [&amp;#39;spider&amp;#39;, &amp;#39;error_type&amp;#39;],
            registry=self.registry
        )
        
        # Histograms
        self.response_time = Histogram(
            &amp;#39;scrapy_response_time_seconds&amp;#39;,
            &amp;#39;Response time for requests&amp;#39;,
            [&amp;#39;spider&amp;#39;, &amp;#39;domain&amp;#39;],
            registry=self.registry
        )
        
        self.item_processing_time = Histogram(
            &amp;#39;scrapy_item_processing_time_seconds&amp;#39;,
            &amp;#39;Time to process items&amp;#39;,
            [&amp;#39;spider&amp;#39;, &amp;#39;pipeline&amp;#39;],
            registry=self.registry
        )
        
        # Gauges
        self.active_requests = Gauge(
            &amp;#39;scrapy_active_requests&amp;#39;,
            &amp;#39;Number of active requests&amp;#39;,
            [&amp;#39;spider&amp;#39;],
            registry=self.registry
        )
        
        self.queue_size = Gauge(
            &amp;#39;scrapy_queue_size&amp;#39;,
            &amp;#39;Size of request queue&amp;#39;,
            [&amp;#39;spider&amp;#39;],
            registry=self.registry
        )
        
        self.memory_usage = Gauge(
            &amp;#39;scrapy_memory_usage_bytes&amp;#39;,
            &amp;#39;Memory usage in bytes&amp;#39;,
            [&amp;#39;spider&amp;#39;],
            registry=self.registry
        )
    
    def record_request(self, spider: str, status: str, response_time: float = None, domain: str = None):
        &amp;quot;&amp;quot;&amp;quot;Record request metrics&amp;quot;&amp;quot;&amp;quot;
        self.requests_total.labels(spider=spider, status=status).inc()
        
        if response_time and domain:
            self.response_time.labels(spider=spider, domain=domain).observe(response_time)
    
    def record_item(self, spider: str, item_type: str):
        &amp;quot;&amp;quot;&amp;quot;Record scraped item&amp;quot;&amp;quot;&amp;quot;
        self.items_scraped_total.labels(spider=spider, item_type=item_type).inc()
    
    def record_error(self, spider: str, error_type: str):
        &amp;quot;&amp;quot;&amp;quot;Record error&amp;quot;&amp;quot;&amp;quot;
        self.errors_total.labels(spider=spider, error_type=error_type).inc()
    
    def update_queue_size(self, spider: str, size: int):
        &amp;quot;&amp;quot;&amp;quot;Update queue size&amp;quot;&amp;quot;&amp;quot;
        self.queue_size.labels(spider=spider).set(size)
    
    def update_active_requests(self, spider: str, count: int):
        &amp;quot;&amp;quot;&amp;quot;Update active requests count&amp;quot;&amp;quot;&amp;quot;
        self.active_requests.labels(spider=spider).set(count)
    
    def update_memory_usage(self, spider: str, bytes_used: int):
        &amp;quot;&amp;quot;&amp;quot;Update memory usage&amp;quot;&amp;quot;&amp;quot;
        self.memory_usage.labels(spider=spider).set(bytes_used)
    
    def push_metrics(self, job_name: str):
        &amp;quot;&amp;quot;&amp;quot;Push metrics to Pushgateway&amp;quot;&amp;quot;&amp;quot;
        if self.pushgateway_url:
            try:
                push_to_gateway(
                    self.pushgateway_url, 
                    job=job_name, 
                    registry=self.registry
                )
            except Exception as e:
                logging.error(f&amp;quot;Failed to push metrics: {e}&amp;quot;)

def monitor_performance(metrics: ScrapingMetrics, spider_name: str):
    &amp;quot;&amp;quot;&amp;quot;Decorator to monitor function performance&amp;quot;&amp;quot;&amp;quot;
    def decorator(func: Callable) -&amp;gt; Callable:
        @wraps(func)
        def wrapper(*args, **kwargs):
            start_time = time.time()
            try:
                result = func(*args, **kwargs)
                return result
            except Exception as e:
                metrics.record_error(spider_name, type(e).__name__)
                raise
            finally:
                duration = time.time() - start_time
                metrics.item_processing_time.labels(
                    spider=spider_name, 
                    pipeline=func.__name__
                ).observe(duration)
        return wrapper
    return decorator

# Integration with Scrapy
class PrometheusStatsCollector:
    &amp;quot;&amp;quot;&amp;quot;Collect Scrapy stats and export to Prometheus&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, crawler):
        self.crawler = crawler
        self.metrics = ScrapingMetrics(
            pushgateway_url=crawler.settings.get(&amp;#39;PROMETHEUS_PUSHGATEWAY_URL&amp;#39;)
        )
        self.spider_name = None
    
    @classmethod
    def from_crawler(cls, crawler):
        return cls(crawler)
    
    def spider_opened(self, spider):
        self.spider_name = spider.name
        spider.logger.info(f&amp;quot;Prometheus metrics enabled for spider: {spider.name}&amp;quot;)
    
    def spider_closed(self, spider, reason):
        # Push final metrics
        if self.metrics.pushgateway_url:
            self.metrics.push_metrics(f&amp;quot;scrapy_{spider.name}&amp;quot;)
        
        # Log final stats
        stats = self.crawler.stats.get_stats()
        spider.logger.info(f&amp;quot;Final stats: {stats}&amp;quot;)
    
    def request_scheduled(self, request, spider):
        self.metrics.update_active_requests(
            spider.name, 
            self.crawler.stats.get_value(&amp;#39;scheduler/enqueued&amp;#39;, 0)
        )
    
    def response_received(self, response, request, spider):
        # Record response metrics
        status = str(response.status)
        domain = response.url.split(&amp;#39;/&amp;#39;)[2] if &amp;#39;://&amp;#39; in response.url else &amp;#39;unknown&amp;#39;
        
        self.metrics.record_request(
            spider=spider.name,
            status=status,
            domain=domain
        )
    
    def item_scraped(self, item, response, spider):
        # Record item metrics
        item_type = type(item).__name__
        self.metrics.record_item(spider.name, item_type)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Grafana Dashboard Configuration&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;dashboard&amp;quot;: {
    &amp;quot;id&amp;quot;: null,
    &amp;quot;title&amp;quot;: &amp;quot;Scrapy Monitoring Dashboard&amp;quot;,
    &amp;quot;description&amp;quot;: &amp;quot;Comprehensive monitoring for Scrapy spiders&amp;quot;,
    &amp;quot;tags&amp;quot;: [&amp;quot;scrapy&amp;quot;, &amp;quot;monitoring&amp;quot;],
    &amp;quot;timezone&amp;quot;: &amp;quot;browser&amp;quot;,
    &amp;quot;panels&amp;quot;: [
      {
        &amp;quot;id&amp;quot;: 1,
        &amp;quot;title&amp;quot;: &amp;quot;Requests per Second&amp;quot;,
        &amp;quot;type&amp;quot;: &amp;quot;graph&amp;quot;,
        &amp;quot;targets&amp;quot;: [
          {
            &amp;quot;expr&amp;quot;: &amp;quot;rate(scrapy_requests_total[5m])&amp;quot;,
            &amp;quot;legendFormat&amp;quot;: &amp;quot;{{spider}} - {{status}}&amp;quot;
          }
        ],
        &amp;quot;gridPos&amp;quot;: {&amp;quot;h&amp;quot;: 8, &amp;quot;w&amp;quot;: 12, &amp;quot;x&amp;quot;: 0, &amp;quot;y&amp;quot;: 0},
        &amp;quot;yAxes&amp;quot;: [
          {&amp;quot;label&amp;quot;: &amp;quot;Requests/sec&amp;quot;, &amp;quot;min&amp;quot;: 0}
        ]
      },
      {
        &amp;quot;id&amp;quot;: 2,
        &amp;quot;title&amp;quot;: &amp;quot;Items Scraped per Hour&amp;quot;,
        &amp;quot;type&amp;quot;: &amp;quot;graph&amp;quot;,
        &amp;quot;targets&amp;quot;: [
          {
            &amp;quot;expr&amp;quot;: &amp;quot;rate(scrapy_items_scraped_total[1h])*3600&amp;quot;,
            &amp;quot;legendFormat&amp;quot;: &amp;quot;{{spider}} - {{item_type}}&amp;quot;
          }
        ],
        &amp;quot;gridPos&amp;quot;: {&amp;quot;h&amp;quot;: 8, &amp;quot;w&amp;quot;: 12, &amp;quot;x&amp;quot;: 12, &amp;quot;y&amp;quot;: 0}
      },
      {
        &amp;quot;id&amp;quot;: 3,
        &amp;quot;title&amp;quot;: &amp;quot;Error Rate&amp;quot;,
        &amp;quot;type&amp;quot;: &amp;quot;graph&amp;quot;,
        &amp;quot;targets&amp;quot;: [
          {
            &amp;quot;expr&amp;quot;: &amp;quot;rate(scrapy_errors_total[5m]) / rate(scrapy_requests_total[5m]) * 100&amp;quot;,
            &amp;quot;legendFormat&amp;quot;: &amp;quot;{{spider}} error rate %&amp;quot;
          }
        ],
        &amp;quot;gridPos&amp;quot;: {&amp;quot;h&amp;quot;: 8, &amp;quot;w&amp;quot;: 12, &amp;quot;x&amp;quot;: 0, &amp;quot;y&amp;quot;: 8},
        &amp;quot;alert&amp;quot;: {
          &amp;quot;conditions&amp;quot;: [
            {
              &amp;quot;query&amp;quot;: {&amp;quot;queryType&amp;quot;: &amp;quot;&amp;quot;, &amp;quot;refId&amp;quot;: &amp;quot;A&amp;quot;},
              &amp;quot;reducer&amp;quot;: {&amp;quot;type&amp;quot;: &amp;quot;last&amp;quot;, &amp;quot;params&amp;quot;: []},
              &amp;quot;evaluator&amp;quot;: {&amp;quot;params&amp;quot;: [5], &amp;quot;type&amp;quot;: &amp;quot;gt&amp;quot;}
            }
          ],
          &amp;quot;executionErrorState&amp;quot;: &amp;quot;alerting&amp;quot;,
          &amp;quot;frequency&amp;quot;: &amp;quot;10s&amp;quot;,
          &amp;quot;handler&amp;quot;: 1,
          &amp;quot;name&amp;quot;: &amp;quot;High Error Rate&amp;quot;,
          &amp;quot;noDataState&amp;quot;: &amp;quot;no_data&amp;quot;
        }
      },
      {
        &amp;quot;id&amp;quot;: 4,
        &amp;quot;title&amp;quot;: &amp;quot;Response Time Distribution&amp;quot;,
        &amp;quot;type&amp;quot;: &amp;quot;heatmap&amp;quot;,
        &amp;quot;targets&amp;quot;: [
          {
            &amp;quot;expr&amp;quot;: &amp;quot;scrapy_response_time_seconds_bucket&amp;quot;,
            &amp;quot;legendFormat&amp;quot;: &amp;quot;{{le}}&amp;quot;
          }
        ],
        &amp;quot;gridPos&amp;quot;: {&amp;quot;h&amp;quot;: 8, &amp;quot;w&amp;quot;: 12, &amp;quot;x&amp;quot;: 12, &amp;quot;y&amp;quot;: 8}
      },
      {
        &amp;quot;id&amp;quot;: 5,
        &amp;quot;title&amp;quot;: &amp;quot;Memory Usage&amp;quot;,
        &amp;quot;type&amp;quot;: &amp;quot;graph&amp;quot;,
        &amp;quot;targets&amp;quot;: [
          {
            &amp;quot;expr&amp;quot;: &amp;quot;scrapy_memory_usage_bytes / 1024 / 1024&amp;quot;,
            &amp;quot;legendFormat&amp;quot;: &amp;quot;{{spider}} Memory (MB)&amp;quot;
          }
        ],
        &amp;quot;gridPos&amp;quot;: {&amp;quot;h&amp;quot;: 8, &amp;quot;w&amp;quot;: 24, &amp;quot;x&amp;quot;: 0, &amp;quot;y&amp;quot;: 16}
      },
      {
        &amp;quot;id&amp;quot;: 6,
        &amp;quot;title&amp;quot;: &amp;quot;Queue Size&amp;quot;,
        &amp;quot;type&amp;quot;: &amp;quot;graph&amp;quot;,
        &amp;quot;targets&amp;quot;: [
          {
            &amp;quot;expr&amp;quot;: &amp;quot;scrapy_queue_size&amp;quot;,
            &amp;quot;legendFormat&amp;quot;: &amp;quot;{{spider}} Queue&amp;quot;
          }
        ],
        &amp;quot;gridPos&amp;quot;: {&amp;quot;h&amp;quot;: 8, &amp;quot;w&amp;quot;: 12, &amp;quot;x&amp;quot;: 0, &amp;quot;y&amp;quot;: 24}
      },
      {
        &amp;quot;id&amp;quot;: 7,
        &amp;quot;title&amp;quot;: &amp;quot;Active Requests&amp;quot;,
        &amp;quot;type&amp;quot;: &amp;quot;stat&amp;quot;,
        &amp;quot;targets&amp;quot;: [
          {
            &amp;quot;expr&amp;quot;: &amp;quot;sum(scrapy_active_requests)&amp;quot;,
            &amp;quot;legendFormat&amp;quot;: &amp;quot;Total Active&amp;quot;
          }
        ],
        &amp;quot;gridPos&amp;quot;: {&amp;quot;h&amp;quot;: 8, &amp;quot;w&amp;quot;: 12, &amp;quot;x&amp;quot;: 12, &amp;quot;y&amp;quot;: 24}
      }
    ],
    &amp;quot;time&amp;quot;: {&amp;quot;from&amp;quot;: &amp;quot;now-1h&amp;quot;, &amp;quot;to&amp;quot;: &amp;quot;now&amp;quot;},
    &amp;quot;refresh&amp;quot;: &amp;quot;5s&amp;quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Security and Compliance&lt;/h2&gt;
&lt;h3&gt;Security Hardening&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/security/security_middleware.py
import hmac
import hashlib
import time
from scrapy.downloadermiddlewares.useragent import UserAgentMiddleware
from scrapy.exceptions import IgnoreRequest
import logging

class SecurityMiddleware:
    &amp;quot;&amp;quot;&amp;quot;Security middleware for production environments&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, settings):
        self.settings = settings
        self.logger = logging.getLogger(__name__)
        
        # Rate limiting
        self.rate_limits = {}
        self.last_request_time = {}
        
        # Security headers
        self.required_headers = {
            &amp;#39;User-Agent&amp;#39;: True,
            &amp;#39;Accept&amp;#39;: True,
            &amp;#39;Accept-Language&amp;#39;: True
        }
        
        # Blocked patterns
        self.blocked_patterns = [
            r&amp;#39;.*\.exe$&amp;#39;,
            r&amp;#39;.*\.zip$&amp;#39;,
            r&amp;#39;.*admin.*&amp;#39;,
            r&amp;#39;.*login.*&amp;#39;,
            r&amp;#39;.*private.*&amp;#39;
        ]
    
    def process_request(self, request, spider):
        # Validate request security
        if not self._validate_request_security(request):
            raise IgnoreRequest(&amp;quot;Request blocked by security policy&amp;quot;)
        
        # Apply rate limiting
        self._apply_rate_limiting(request, spider)
        
        # Add security headers
        self._add_security_headers(request)
        
        return None
    
    def _validate_request_security(self, request):
        &amp;quot;&amp;quot;&amp;quot;Validate request against security policies&amp;quot;&amp;quot;&amp;quot;
        url = request.url.lower()
        
        # Check blocked patterns
        import re
        for pattern in self.blocked_patterns:
            if re.match(pattern, url):
                self.logger.warning(f&amp;quot;Blocked request to: {request.url}&amp;quot;)
                return False
        
        # Check for required headers
        for header, required in self.required_headers.items():
            if required and header not in request.headers:
                self.logger.warning(f&amp;quot;Missing required header: {header}&amp;quot;)
                return False
        
        return True
    
    def _apply_rate_limiting(self, request, spider):
        &amp;quot;&amp;quot;&amp;quot;Apply rate limiting per domain&amp;quot;&amp;quot;&amp;quot;
        from urllib.parse import urlparse
        domain = urlparse(request.url).netloc
        
        current_time = time.time()
        min_delay = self.settings.getfloat(&amp;#39;SECURITY_MIN_DELAY&amp;#39;, 1.0)
        
        if domain in self.last_request_time:
            time_since_last = current_time - self.last_request_time[domain]
            if time_since_last &amp;lt; min_delay:
                sleep_time = min_delay - time_since_last
                spider.logger.debug(f&amp;quot;Security rate limiting: {sleep_time:.2f}s for {domain}&amp;quot;)
                time.sleep(sleep_time)
        
        self.last_request_time[domain] = time.time()
    
    def _add_security_headers(self, request):
        &amp;quot;&amp;quot;&amp;quot;Add security headers to requests&amp;quot;&amp;quot;&amp;quot;
        # Add timestamp for request validation
        timestamp = str(int(time.time()))
        request.headers[&amp;#39;X-Request-Timestamp&amp;#39;] = timestamp
        
        # Add security token if configured
        secret_key = self.settings.get(&amp;#39;SECURITY_SECRET_KEY&amp;#39;)
        if secret_key:
            signature = hmac.new(
                secret_key.encode(),
                f&amp;quot;{request.url}{timestamp}&amp;quot;.encode(),
                hashlib.sha256
            ).hexdigest()
            request.headers[&amp;#39;X-Security-Signature&amp;#39;] = signature

class DataEncryptionPipeline:
    &amp;quot;&amp;quot;&amp;quot;Encrypt sensitive data before storage&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, encryption_key):
        self.encryption_key = encryption_key
        self.sensitive_fields = [&amp;#39;email&amp;#39;, &amp;#39;phone&amp;#39;, &amp;#39;address&amp;#39;, &amp;#39;personal_id&amp;#39;]
    
    @classmethod
    def from_crawler(cls, crawler):
        encryption_key = crawler.settings.get(&amp;#39;ENCRYPTION_KEY&amp;#39;)
        if not encryption_key:
            raise ValueError(&amp;quot;ENCRYPTION_KEY setting is required&amp;quot;)
        return cls(encryption_key)
    
    def process_item(self, item, spider):
        from cryptography.fernet import Fernet
        
        fernet = Fernet(self.encryption_key.encode())
        
        for field in self.sensitive_fields:
            if field in item and item[field]:
                # Encrypt sensitive data
                encrypted_data = fernet.encrypt(str(item[field]).encode())
                item[f&amp;quot;{field}_encrypted&amp;quot;] = encrypted_data.decode()
                # Remove original field
                del item[field]
        
        return item

# scripts/security_audit.py
import subprocess
import json
import sys
from typing import Dict, List

class SecurityAudit:
    &amp;quot;&amp;quot;&amp;quot;Security audit for production deployment&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self):
        self.issues = []
    
    def audit_dependencies(self) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Audit Python dependencies for known vulnerabilities&amp;quot;&amp;quot;&amp;quot;
        try:
            result = subprocess.run(
                [&amp;#39;safety&amp;#39;, &amp;#39;check&amp;#39;, &amp;#39;--json&amp;#39;],
                capture_output=True,
                text=True
            )
            
            if result.returncode != 0:
                vulnerabilities = json.loads(result.stdout)
                for vuln in vulnerabilities:
                    self.issues.append({
                        &amp;#39;type&amp;#39;: &amp;#39;dependency_vulnerability&amp;#39;,
                        &amp;#39;severity&amp;#39;: &amp;#39;high&amp;#39;,
                        &amp;#39;package&amp;#39;: vuln[&amp;#39;package_name&amp;#39;],
                        &amp;#39;vulnerability&amp;#39;: vuln[&amp;#39;vulnerability_id&amp;#39;],
                        &amp;#39;description&amp;#39;: vuln[&amp;#39;advisory&amp;#39;]
                    })
                return False
            
            return True
            
        except Exception as e:
            self.issues.append({
                &amp;#39;type&amp;#39;: &amp;#39;audit_error&amp;#39;,
                &amp;#39;severity&amp;#39;: &amp;#39;medium&amp;#39;,
                &amp;#39;description&amp;#39;: f&amp;quot;Failed to audit dependencies: {e}&amp;quot;
            })
            return False
    
    def audit_docker_image(self, image_name: str) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Audit Docker image for security issues&amp;quot;&amp;quot;&amp;quot;
        try:
            result = subprocess.run(
                [&amp;#39;trivy&amp;#39;, &amp;#39;image&amp;#39;, &amp;#39;--format&amp;#39;, &amp;#39;json&amp;#39;, image_name],
                capture_output=True,
                text=True
            )
            
            if result.returncode == 0:
                scan_results = json.loads(result.stdout)
                
                for result in scan_results.get(&amp;#39;Results&amp;#39;, []):
                    for vuln in result.get(&amp;#39;Vulnerabilities&amp;#39;, []):
                        if vuln.get(&amp;#39;Severity&amp;#39;) in [&amp;#39;HIGH&amp;#39;, &amp;#39;CRITICAL&amp;#39;]:
                            self.issues.append({
                                &amp;#39;type&amp;#39;: &amp;#39;container_vulnerability&amp;#39;,
                                &amp;#39;severity&amp;#39;: vuln[&amp;#39;Severity&amp;#39;].lower(),
                                &amp;#39;package&amp;#39;: vuln.get(&amp;#39;PkgName&amp;#39;),
                                &amp;#39;vulnerability&amp;#39;: vuln.get(&amp;#39;VulnerabilityID&amp;#39;),
                                &amp;#39;description&amp;#39;: vuln.get(&amp;#39;Description&amp;#39;, &amp;#39;&amp;#39;)
                            })
            
            return len([i for i in self.issues if i[&amp;#39;type&amp;#39;] == &amp;#39;container_vulnerability&amp;#39;]) == 0
            
        except Exception as e:
            self.issues.append({
                &amp;#39;type&amp;#39;: &amp;#39;audit_error&amp;#39;,
                &amp;#39;severity&amp;#39;: &amp;#39;medium&amp;#39;,
                &amp;#39;description&amp;#39;: f&amp;quot;Failed to audit Docker image: {e}&amp;quot;
            })
            return False
    
    def audit_kubernetes_config(self, config_path: str) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Audit Kubernetes configuration&amp;quot;&amp;quot;&amp;quot;
        security_checks = [
            self._check_non_root_user,
            self._check_resource_limits,
            self._check_security_context,
            self._check_network_policies
        ]
        
        passed = True
        for check in security_checks:
            if not check(config_path):
                passed = False
        
        return passed
    
    def _check_non_root_user(self, config_path: str) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Check if containers run as non-root&amp;quot;&amp;quot;&amp;quot;
        # Implementation for checking non-root user
        return True
    
    def _check_resource_limits(self, config_path: str) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Check if resource limits are set&amp;quot;&amp;quot;&amp;quot;
        # Implementation for checking resource limits
        return True
    
    def _check_security_context(self, config_path: str) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Check security context configuration&amp;quot;&amp;quot;&amp;quot;
        # Implementation for checking security context
        return True
    
    def _check_network_policies(self, config_path: str) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Check network policies&amp;quot;&amp;quot;&amp;quot;
        # Implementation for checking network policies
        return True
    
    def generate_report(self) -&amp;gt; Dict:
        &amp;quot;&amp;quot;&amp;quot;Generate security audit report&amp;quot;&amp;quot;&amp;quot;
        severity_counts = {}
        for issue in self.issues:
            severity = issue[&amp;#39;severity&amp;#39;]
            severity_counts[severity] = severity_counts.get(severity, 0) + 1
        
        return {
            &amp;#39;total_issues&amp;#39;: len(self.issues),
            &amp;#39;severity_breakdown&amp;#39;: severity_counts,
            &amp;#39;issues&amp;#39;: self.issues,
            &amp;#39;passed&amp;#39;: len(self.issues) == 0
        }
    
    def run_full_audit(self, image_name: str = None, config_path: str = None) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Run complete security audit&amp;quot;&amp;quot;&amp;quot;
        print(&amp;quot;🔒 Running security audit...&amp;quot;)
        
        # Audit dependencies
        deps_ok = self.audit_dependencies()
        print(f&amp;quot;Dependencies: {&amp;#39;✅&amp;#39; if deps_ok else &amp;#39;❌&amp;#39;}&amp;quot;)
        
        # Audit Docker image if provided
        if image_name:
            image_ok = self.audit_docker_image(image_name)
            print(f&amp;quot;Docker image: {&amp;#39;✅&amp;#39; if image_ok else &amp;#39;❌&amp;#39;}&amp;quot;)
        
        # Audit Kubernetes config if provided
        if config_path:
            k8s_ok = self.audit_kubernetes_config(config_path)
            print(f&amp;quot;Kubernetes config: {&amp;#39;✅&amp;#39; if k8s_ok else &amp;#39;❌&amp;#39;}&amp;quot;)
        
        # Generate report
        report = self.generate_report()
        
        if report[&amp;#39;passed&amp;#39;]:
            print(&amp;quot;🎉 Security audit passed!&amp;quot;)
        else:
            print(f&amp;quot;💥 Security audit failed with {report[&amp;#39;total_issues&amp;#39;]} issues&amp;quot;)
            for issue in self.issues:
                print(f&amp;quot;  - {issue[&amp;#39;severity&amp;#39;].upper()}: {issue[&amp;#39;description&amp;#39;]}&amp;quot;)
        
        return report[&amp;#39;passed&amp;#39;]

if __name__ == &amp;quot;__main__&amp;quot;:
    import argparse
    
    parser = argparse.ArgumentParser(description=&amp;#39;Run security audit&amp;#39;)
    parser.add_argument(&amp;#39;--image&amp;#39;, help=&amp;#39;Docker image to audit&amp;#39;)
    parser.add_argument(&amp;#39;--config&amp;#39;, help=&amp;#39;Kubernetes config path to audit&amp;#39;)
    args = parser.parse_args()
    
    audit = SecurityAudit()
    success = audit.run_full_audit(args.image, args.config)
    
    sys.exit(0 if success else 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Wrapping Up&lt;/h2&gt;
&lt;p&gt;That is the end of the series: setup and first spiders in Part 1, JavaScript rendering and forms in Part 2, anti-detection and distributed scaling in Part 3, data pipelines and storage in Part 4, and deployment here. When something breaks in production, and it will, the official &lt;a href=&quot;https://docs.scrapy.org&quot;&gt;Scrapy documentation&lt;/a&gt; is still the reference I reach for first.&lt;/p&gt;
</content:encoded></item><item><title>Scrapy, part 4: cleaning and storing the data</title><link>https://tamangsurendra.com.np/blog/web-scraping-scrapy-part-4/</link><guid isPermaLink="true">https://tamangsurendra.com.np/blog/web-scraping-scrapy-part-4/</guid><description>Scraped data is messy; delivered data can&apos;t be. Validation pipelines and storage across MongoDB, PostgreSQL, and Elasticsearch.</description><pubDate>Tue, 24 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Scrapy Part 4: Data Processing and Storage&lt;/h1&gt;
&lt;p&gt;Scraped data arrives messy: inconsistent price formats, broken URLs, duplicates, missing fields. This part builds the pipeline layer that fixes that. You will write a validation and cleaning pipeline, an enrichment pipeline for computed fields, and storage backends for MongoDB and PostgreSQL with deduplication, batch upserts, and analytics queries.&lt;/p&gt;
&lt;h2&gt;Data Cleaning and Validation&lt;/h2&gt;
&lt;h3&gt;Validation Pipeline&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/pipelines.py
import re
import hashlib
import json
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any
from itemadapter import ItemAdapter
from scrapy.exceptions import DropItem
import logging

class AdvancedValidationPipeline:
    &amp;quot;&amp;quot;&amp;quot;Comprehensive data validation and cleaning pipeline&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self):
        self.logger = logging.getLogger(__name__)
        self.validation_stats = {
            &amp;#39;total_items&amp;#39;: 0,
            &amp;#39;valid_items&amp;#39;: 0,
            &amp;#39;dropped_items&amp;#39;: 0,
            &amp;#39;field_fixes&amp;#39;: 0,
            &amp;#39;validation_errors&amp;#39;: {}
        }
        
        # Validation rules configuration
        self.validation_rules = {
            &amp;#39;required_fields&amp;#39;: [&amp;#39;name&amp;#39;, &amp;#39;url&amp;#39;],
            &amp;#39;field_types&amp;#39;: {
                &amp;#39;price&amp;#39;: (int, float),
                &amp;#39;rating&amp;#39;: (int, float),
                &amp;#39;review_count&amp;#39;: int,
                &amp;#39;in_stock&amp;#39;: bool
            },
            &amp;#39;field_ranges&amp;#39;: {
                &amp;#39;price&amp;#39;: (0, 1000000),
                &amp;#39;rating&amp;#39;: (0, 5),
                &amp;#39;review_count&amp;#39;: (0, float(&amp;#39;inf&amp;#39;))
            },
            &amp;#39;string_patterns&amp;#39;: {
                &amp;#39;email&amp;#39;: r&amp;#39;^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$&amp;#39;,
                &amp;#39;url&amp;#39;: r&amp;#39;^https?:\/\/[^\s/$.?#].[^\s]*$&amp;#39;,
                &amp;#39;phone&amp;#39;: r&amp;#39;^[\+]?[1-9][\d]{0,15}$&amp;#39;
            },
            &amp;#39;max_lengths&amp;#39;: {
                &amp;#39;name&amp;#39;: 200,
                &amp;#39;description&amp;#39;: 5000,
                &amp;#39;brand&amp;#39;: 100
            }
        }
    
    def process_item(self, item, spider):
        adapter = ItemAdapter(item)
        self.validation_stats[&amp;#39;total_items&amp;#39;] += 1
        
        try:
            # Clean and validate all fields
            self._clean_item_fields(adapter)
            self._validate_required_fields(adapter)
            self._validate_field_types(adapter)
            self._validate_field_ranges(adapter)
            self._validate_string_patterns(adapter)
            self._validate_field_lengths(adapter)
            
            # Additional business logic validation
            self._validate_business_rules(adapter)
            
            # Generate quality score
            adapter[&amp;#39;data_quality_score&amp;#39;] = self._calculate_quality_score(adapter)
            
            # Add validation metadata
            adapter[&amp;#39;validation_timestamp&amp;#39;] = datetime.now().isoformat()
            adapter[&amp;#39;validation_passed&amp;#39;] = True
            
            self.validation_stats[&amp;#39;valid_items&amp;#39;] += 1
            return item
            
        except ValidationError as e:
            self.validation_stats[&amp;#39;dropped_items&amp;#39;] += 1
            error_type = type(e).__name__
            self.validation_stats[&amp;#39;validation_errors&amp;#39;][error_type] = \
                self.validation_stats[&amp;#39;validation_errors&amp;#39;].get(error_type, 0) + 1
            
            self.logger.warning(f&amp;quot;Validation failed for item: {e}&amp;quot;)
            raise DropItem(f&amp;quot;Validation error: {e}&amp;quot;)
    
    def _clean_item_fields(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Clean and normalize field values&amp;quot;&amp;quot;&amp;quot;
        # Clean text fields
        text_fields = [&amp;#39;name&amp;#39;, &amp;#39;description&amp;#39;, &amp;#39;brand&amp;#39;, &amp;#39;category&amp;#39;]
        for field in text_fields:
            if adapter.get(field):
                cleaned = self._clean_text(adapter[field])
                if cleaned != adapter[field]:
                    adapter[field] = cleaned
                    self.validation_stats[&amp;#39;field_fixes&amp;#39;] += 1
        
        # Clean price field
        if adapter.get(&amp;#39;price&amp;#39;):
            cleaned_price = self._clean_price(adapter[&amp;#39;price&amp;#39;])
            if cleaned_price != adapter[&amp;#39;price&amp;#39;]:
                adapter[&amp;#39;price&amp;#39;] = cleaned_price
                self.validation_stats[&amp;#39;field_fixes&amp;#39;] += 1
        
        # Clean URL fields
        url_fields = [&amp;#39;url&amp;#39;, &amp;#39;image_url&amp;#39;]
        for field in url_fields:
            if adapter.get(field):
                cleaned_url = self._clean_url(adapter[field])
                if cleaned_url != adapter[field]:
                    adapter[field] = cleaned_url
                    self.validation_stats[&amp;#39;field_fixes&amp;#39;] += 1
        
        # Normalize boolean fields
        bool_fields = [&amp;#39;in_stock&amp;#39;, &amp;#39;featured&amp;#39;, &amp;#39;on_sale&amp;#39;]
        for field in bool_fields:
            if field in adapter:
                adapter[field] = self._normalize_boolean(adapter[field])
    
    def _clean_text(self, text: str) -&amp;gt; str:
        &amp;quot;&amp;quot;&amp;quot;Clean and normalize text content&amp;quot;&amp;quot;&amp;quot;
        if not isinstance(text, str):
            text = str(text)
        
        # Remove excessive whitespace
        text = re.sub(r&amp;#39;\s+&amp;#39;, &amp;#39; &amp;#39;, text.strip())
        
        # Remove HTML tags
        text = re.sub(r&amp;#39;&amp;lt;[^&amp;gt;]+&amp;gt;&amp;#39;, &amp;#39;&amp;#39;, text)
        
        # Remove special characters that might cause issues
        text = re.sub(r&amp;#39;[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\xff]&amp;#39;, &amp;#39;&amp;#39;, text)
        
        # Normalize quotes
        text = text.replace(&amp;#39;&amp;quot;&amp;#39;, &amp;#39;&amp;quot;&amp;#39;).replace(&amp;#39;&amp;quot;&amp;#39;, &amp;#39;&amp;quot;&amp;#39;)
        text = text.replace(&amp;#39;&amp;#39;&amp;#39;, &amp;quot;&amp;#39;&amp;quot;).replace(&amp;#39;&amp;#39;&amp;#39;, &amp;quot;&amp;#39;&amp;quot;)
        
        return text
    
    def _clean_price(self, price: Any) -&amp;gt; Optional[float]:
        &amp;quot;&amp;quot;&amp;quot;Clean and convert price to float&amp;quot;&amp;quot;&amp;quot;
        if price is None:
            return None
        
        if isinstance(price, (int, float)):
            return float(price)
        
        if isinstance(price, str):
            # Remove currency symbols and whitespace
            price_clean = re.sub(r&amp;#39;[^\d.,]&amp;#39;, &amp;#39;&amp;#39;, price)
            
            # Handle different decimal separators
            if &amp;#39;,&amp;#39; in price_clean and &amp;#39;.&amp;#39; in price_clean:
                # Assume comma is thousands separator
                price_clean = price_clean.replace(&amp;#39;,&amp;#39;, &amp;#39;&amp;#39;)
            elif &amp;#39;,&amp;#39; in price_clean:
                # Could be decimal separator or thousands
                if price_clean.count(&amp;#39;,&amp;#39;) == 1 and len(price_clean.split(&amp;#39;,&amp;#39;)[1]) &amp;lt;= 2:
                    price_clean = price_clean.replace(&amp;#39;,&amp;#39;, &amp;#39;.&amp;#39;)
                else:
                    price_clean = price_clean.replace(&amp;#39;,&amp;#39;, &amp;#39;&amp;#39;)
            
            try:
                return float(price_clean)
            except ValueError:
                return None
        
        return None
    
    def _clean_url(self, url: str) -&amp;gt; str:
        &amp;quot;&amp;quot;&amp;quot;Clean and normalize URLs&amp;quot;&amp;quot;&amp;quot;
        if not isinstance(url, str):
            return str(url)
        
        url = url.strip()
        
        # Add protocol if missing
        if url.startswith(&amp;#39;//&amp;#39;):
            url = &amp;#39;https:&amp;#39; + url
        elif not url.startswith((&amp;#39;http://&amp;#39;, &amp;#39;https://&amp;#39;)):
            url = &amp;#39;https://&amp;#39; + url
        
        return url
    
    def _normalize_boolean(self, value: Any) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Normalize various boolean representations&amp;quot;&amp;quot;&amp;quot;
        if isinstance(value, bool):
            return value
        
        if isinstance(value, str):
            value = value.lower().strip()
            return value in (&amp;#39;true&amp;#39;, &amp;#39;yes&amp;#39;, &amp;#39;1&amp;#39;, &amp;#39;on&amp;#39;, &amp;#39;available&amp;#39;, &amp;#39;in stock&amp;#39;)
        
        if isinstance(value, (int, float)):
            return bool(value)
        
        return False
    
    def _validate_required_fields(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Validate that required fields are present&amp;quot;&amp;quot;&amp;quot;
        for field in self.validation_rules[&amp;#39;required_fields&amp;#39;]:
            if not adapter.get(field):
                raise ValidationError(f&amp;quot;Required field missing: {field}&amp;quot;)
    
    def _validate_field_types(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Validate field data types&amp;quot;&amp;quot;&amp;quot;
        for field, expected_types in self.validation_rules[&amp;#39;field_types&amp;#39;].items():
            if field in adapter and adapter[field] is not None:
                if not isinstance(adapter[field], expected_types):
                    raise ValidationError(
                        f&amp;quot;Field {field} has invalid type: &amp;quot;
                        f&amp;quot;expected {expected_types}, got {type(adapter[field])}&amp;quot;
                    )
    
    def _validate_field_ranges(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Validate numeric field ranges&amp;quot;&amp;quot;&amp;quot;
        for field, (min_val, max_val) in self.validation_rules[&amp;#39;field_ranges&amp;#39;].items():
            if field in adapter and adapter[field] is not None:
                value = adapter[field]
                if not (min_val &amp;lt;= value &amp;lt;= max_val):
                    raise ValidationError(
                        f&amp;quot;Field {field} value {value} outside valid range [{min_val}, {max_val}]&amp;quot;
                    )
    
    def _validate_string_patterns(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Validate string fields against regex patterns&amp;quot;&amp;quot;&amp;quot;
        for field, pattern in self.validation_rules[&amp;#39;string_patterns&amp;#39;].items():
            if field in adapter and adapter[field] is not None:
                if not re.match(pattern, str(adapter[field])):
                    raise ValidationError(f&amp;quot;Field {field} doesn&amp;#39;t match required pattern&amp;quot;)
    
    def _validate_field_lengths(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Validate string field lengths&amp;quot;&amp;quot;&amp;quot;
        for field, max_length in self.validation_rules[&amp;#39;max_lengths&amp;#39;].items():
            if field in adapter and adapter[field] is not None:
                if len(str(adapter[field])) &amp;gt; max_length:
                    raise ValidationError(
                        f&amp;quot;Field {field} exceeds maximum length of {max_length}&amp;quot;
                    )
    
    def _validate_business_rules(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Validate business-specific rules&amp;quot;&amp;quot;&amp;quot;
        # Example business rules
        
        # Price must be positive for in-stock items
        if adapter.get(&amp;#39;in_stock&amp;#39;) and adapter.get(&amp;#39;price&amp;#39;, 0) &amp;lt;= 0:
            raise ValidationError(&amp;quot;In-stock items must have positive price&amp;quot;)
        
        # Rating must be present if review_count &amp;gt; 0
        if adapter.get(&amp;#39;review_count&amp;#39;, 0) &amp;gt; 0 and not adapter.get(&amp;#39;rating&amp;#39;):
            raise ValidationError(&amp;quot;Items with reviews must have rating&amp;quot;)
        
        # Discount validation
        original_price = adapter.get(&amp;#39;original_price&amp;#39;)
        current_price = adapter.get(&amp;#39;price&amp;#39;)
        if original_price and current_price:
            if current_price &amp;gt; original_price:
                raise ValidationError(&amp;quot;Current price cannot exceed original price&amp;quot;)
    
    def _calculate_quality_score(self, adapter) -&amp;gt; float:
        &amp;quot;&amp;quot;&amp;quot;Calculate data quality score (0-1)&amp;quot;&amp;quot;&amp;quot;
        score = 0.0
        max_score = 0.0
        
        # Completeness score (40% of total)
        important_fields = [&amp;#39;name&amp;#39;, &amp;#39;price&amp;#39;, &amp;#39;description&amp;#39;, &amp;#39;brand&amp;#39;, &amp;#39;category&amp;#39;]
        present_fields = sum(1 for field in important_fields if adapter.get(field))
        completeness = present_fields / len(important_fields)
        score += completeness * 0.4
        max_score += 0.4
        
        # Accuracy score (30% of total)
        accuracy = 1.0  # Assume high accuracy if validation passed
        score += accuracy * 0.3
        max_score += 0.3
        
        # Consistency score (20% of total)
        consistency = self._check_consistency(adapter)
        score += consistency * 0.2
        max_score += 0.2
        
        # Freshness score (10% of total)
        freshness = 1.0  # Assume fresh data
        score += freshness * 0.1
        max_score += 0.1
        
        return score / max_score if max_score &amp;gt; 0 else 0.0
    
    def _check_consistency(self, adapter) -&amp;gt; float:
        &amp;quot;&amp;quot;&amp;quot;Check internal consistency of data&amp;quot;&amp;quot;&amp;quot;
        consistency_score = 1.0
        
        # Check if price and in_stock are consistent
        if adapter.get(&amp;#39;price&amp;#39;, 0) &amp;lt;= 0 and adapter.get(&amp;#39;in_stock&amp;#39;, False):
            consistency_score -= 0.3
        
        # Check if rating and review_count are consistent
        rating = adapter.get(&amp;#39;rating&amp;#39;)
        review_count = adapter.get(&amp;#39;review_count&amp;#39;, 0)
        if rating and rating &amp;gt; 0 and review_count == 0:
            consistency_score -= 0.2
        
        return max(0.0, consistency_score)
    
    def close_spider(self, spider):
        &amp;quot;&amp;quot;&amp;quot;Log validation statistics when spider closes&amp;quot;&amp;quot;&amp;quot;
        stats = self.validation_stats
        total = stats[&amp;#39;total_items&amp;#39;]
        
        self.logger.info(&amp;quot;=== Validation Pipeline Statistics ===&amp;quot;)
        self.logger.info(f&amp;quot;Total items processed: {total}&amp;quot;)
        self.logger.info(f&amp;quot;Valid items: {stats[&amp;#39;valid_items&amp;#39;]} ({stats[&amp;#39;valid_items&amp;#39;]/total*100:.1f}%)&amp;quot;)
        self.logger.info(f&amp;quot;Dropped items: {stats[&amp;#39;dropped_items&amp;#39;]} ({stats[&amp;#39;dropped_items&amp;#39;]/total*100:.1f}%)&amp;quot;)
        self.logger.info(f&amp;quot;Field fixes applied: {stats[&amp;#39;field_fixes&amp;#39;]}&amp;quot;)
        
        if stats[&amp;#39;validation_errors&amp;#39;]:
            self.logger.info(&amp;quot;Validation errors by type:&amp;quot;)
            for error_type, count in stats[&amp;#39;validation_errors&amp;#39;].items():
                self.logger.info(f&amp;quot;  {error_type}: {count}&amp;quot;)

class ValidationError(Exception):
    &amp;quot;&amp;quot;&amp;quot;Custom exception for validation errors&amp;quot;&amp;quot;&amp;quot;
    pass

class DataEnrichmentPipeline:
    &amp;quot;&amp;quot;&amp;quot;Enrich items with additional computed fields&amp;quot;&amp;quot;&amp;quot;
    
    def process_item(self, item, spider):
        adapter = ItemAdapter(item)
        
        # Add computed fields
        self._add_price_analysis(adapter)
        self._add_text_analysis(adapter)
        self._add_category_classification(adapter)
        self._add_uniqueness_hash(adapter)
        
        return item
    
    def _add_price_analysis(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Add price-related computed fields&amp;quot;&amp;quot;&amp;quot;
        current_price = adapter.get(&amp;#39;price&amp;#39;)
        original_price = adapter.get(&amp;#39;original_price&amp;#39;)
        
        if current_price and original_price:
            discount = original_price - current_price
            discount_percent = (discount / original_price) * 100
            
            adapter[&amp;#39;discount_amount&amp;#39;] = discount
            adapter[&amp;#39;discount_percentage&amp;#39;] = round(discount_percent, 2)
            adapter[&amp;#39;is_on_sale&amp;#39;] = discount &amp;gt; 0
        
        # Price tier classification
        if current_price:
            if current_price &amp;lt; 50:
                adapter[&amp;#39;price_tier&amp;#39;] = &amp;#39;budget&amp;#39;
            elif current_price &amp;lt; 200:
                adapter[&amp;#39;price_tier&amp;#39;] = &amp;#39;mid-range&amp;#39;
            elif current_price &amp;lt; 500:
                adapter[&amp;#39;price_tier&amp;#39;] = &amp;#39;premium&amp;#39;
            else:
                adapter[&amp;#39;price_tier&amp;#39;] = &amp;#39;luxury&amp;#39;
    
    def _add_text_analysis(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Add text analysis metrics&amp;quot;&amp;quot;&amp;quot;
        description = adapter.get(&amp;#39;description&amp;#39;, &amp;#39;&amp;#39;)
        
        if description:
            words = description.split()
            adapter[&amp;#39;description_word_count&amp;#39;] = len(words)
            adapter[&amp;#39;description_char_count&amp;#39;] = len(description)
            
            # Simple sentiment analysis (in production, use proper NLP)
            positive_words = [&amp;#39;excellent&amp;#39;, &amp;#39;great&amp;#39;, &amp;#39;amazing&amp;#39;, &amp;#39;perfect&amp;#39;, &amp;#39;wonderful&amp;#39;]
            negative_words = [&amp;#39;bad&amp;#39;, &amp;#39;terrible&amp;#39;, &amp;#39;awful&amp;#39;, &amp;#39;poor&amp;#39;, &amp;#39;disappointing&amp;#39;]
            
            pos_count = sum(1 for word in words if word.lower() in positive_words)
            neg_count = sum(1 for word in words if word.lower() in negative_words)
            
            if pos_count &amp;gt; neg_count:
                adapter[&amp;#39;sentiment&amp;#39;] = &amp;#39;positive&amp;#39;
            elif neg_count &amp;gt; pos_count:
                adapter[&amp;#39;sentiment&amp;#39;] = &amp;#39;negative&amp;#39;
            else:
                adapter[&amp;#39;sentiment&amp;#39;] = &amp;#39;neutral&amp;#39;
    
    def _add_category_classification(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Add automatic category classification&amp;quot;&amp;quot;&amp;quot;
        name = adapter.get(&amp;#39;name&amp;#39;, &amp;#39;&amp;#39;).lower()
        description = adapter.get(&amp;#39;description&amp;#39;, &amp;#39;&amp;#39;).lower()
        text = f&amp;quot;{name} {description}&amp;quot;
        
        # Simple keyword-based classification
        categories = {
            &amp;#39;electronics&amp;#39;: [&amp;#39;phone&amp;#39;, &amp;#39;laptop&amp;#39;, &amp;#39;computer&amp;#39;, &amp;#39;tablet&amp;#39;, &amp;#39;tv&amp;#39;, &amp;#39;camera&amp;#39;],
            &amp;#39;clothing&amp;#39;: [&amp;#39;shirt&amp;#39;, &amp;#39;pants&amp;#39;, &amp;#39;dress&amp;#39;, &amp;#39;shoes&amp;#39;, &amp;#39;jacket&amp;#39;, &amp;#39;jeans&amp;#39;],
            &amp;#39;home&amp;#39;: [&amp;#39;furniture&amp;#39;, &amp;#39;kitchen&amp;#39;, &amp;#39;bedroom&amp;#39;, &amp;#39;bathroom&amp;#39;, &amp;#39;decor&amp;#39;],
            &amp;#39;books&amp;#39;: [&amp;#39;book&amp;#39;, &amp;#39;novel&amp;#39;, &amp;#39;textbook&amp;#39;, &amp;#39;manual&amp;#39;, &amp;#39;guide&amp;#39;],
            &amp;#39;sports&amp;#39;: [&amp;#39;fitness&amp;#39;, &amp;#39;exercise&amp;#39;, &amp;#39;sports&amp;#39;, &amp;#39;outdoor&amp;#39;, &amp;#39;gym&amp;#39;]
        }
        
        scores = {}
        for category, keywords in categories.items():
            score = sum(1 for keyword in keywords if keyword in text)
            if score &amp;gt; 0:
                scores[category] = score
        
        if scores:
            adapter[&amp;#39;auto_category&amp;#39;] = max(scores, key=scores.get)
            adapter[&amp;#39;category_confidence&amp;#39;] = max(scores.values()) / len(categories[adapter[&amp;#39;auto_category&amp;#39;]])
    
    def _add_uniqueness_hash(self, adapter):
        &amp;quot;&amp;quot;&amp;quot;Add uniqueness hash for deduplication&amp;quot;&amp;quot;&amp;quot;
        # Create hash from key identifying fields
        key_fields = [&amp;#39;name&amp;#39;, &amp;#39;brand&amp;#39;, &amp;#39;sku&amp;#39;]
        hash_input = &amp;#39;&amp;#39;.join(str(adapter.get(field, &amp;#39;&amp;#39;)) for field in key_fields)
        
        if hash_input.strip():
            adapter[&amp;#39;uniqueness_hash&amp;#39;] = hashlib.md5(
                hash_input.lower().encode(&amp;#39;utf-8&amp;#39;)
            ).hexdigest()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Multiple Storage Backends&lt;/h2&gt;
&lt;h3&gt;MongoDB Storage Pipeline&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/storage/mongodb_pipeline.py
import pymongo
from pymongo import MongoClient, UpdateOne
from itemadapter import ItemAdapter
from datetime import datetime
import logging
from typing import Dict, List

class MongoDBPipeline:
    &amp;quot;&amp;quot;&amp;quot;Advanced MongoDB storage pipeline with indexing and aggregation&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, mongo_uri, mongo_db, collection_name):
        self.mongo_uri = mongo_uri
        self.mongo_db = mongo_db
        self.collection_name = collection_name
        self.client = None
        self.db = None
        self.collection = None
        self.logger = logging.getLogger(__name__)
        
        # Batch processing
        self.batch_size = 100
        self.batch_items = []
        
        # Statistics
        self.stats = {
            &amp;#39;inserted&amp;#39;: 0,
            &amp;#39;updated&amp;#39;: 0,
            &amp;#39;duplicates&amp;#39;: 0,
            &amp;#39;errors&amp;#39;: 0
        }
    
    @classmethod
    def from_crawler(cls, crawler):
        return cls(
            mongo_uri=crawler.settings.get(&amp;quot;MONGO_URI&amp;quot;, &amp;quot;mongodb://localhost:27017&amp;quot;),
            mongo_db=crawler.settings.get(&amp;quot;MONGO_DATABASE&amp;quot;, &amp;quot;scrapy&amp;quot;),
            collection_name=crawler.settings.get(&amp;quot;MONGO_COLLECTION&amp;quot;, &amp;quot;items&amp;quot;)
        )
    
    def open_spider(self, spider):
        &amp;quot;&amp;quot;&amp;quot;Initialize MongoDB connection and setup indexes&amp;quot;&amp;quot;&amp;quot;
        self.client = MongoClient(self.mongo_uri)
        self.db = self.client[self.mongo_db]
        self.collection = self.db[self.collection_name]
        
        # Create indexes for performance
        self._create_indexes()
        
        self.logger.info(f&amp;quot;Connected to MongoDB: {self.mongo_uri}/{self.mongo_db}&amp;quot;)
    
    def _create_indexes(self):
        &amp;quot;&amp;quot;&amp;quot;Create database indexes for optimal performance&amp;quot;&amp;quot;&amp;quot;
        indexes = [
            # Unique index for deduplication
            (&amp;quot;uniqueness_hash&amp;quot;, pymongo.ASCENDING),
            
            # Search indexes
            (&amp;quot;name&amp;quot;, pymongo.TEXT),
            (&amp;quot;description&amp;quot;, pymongo.TEXT),
            (&amp;quot;brand&amp;quot;, pymongo.ASCENDING),
            (&amp;quot;category&amp;quot;, pymongo.ASCENDING),
            
            # Filter indexes
            (&amp;quot;price&amp;quot;, pymongo.ASCENDING),
            (&amp;quot;rating&amp;quot;, pymongo.DESCENDING),
            (&amp;quot;in_stock&amp;quot;, pymongo.ASCENDING),
            
            # Time-based indexes
            (&amp;quot;scraped_at&amp;quot;, pymongo.DESCENDING),
            (&amp;quot;updated_at&amp;quot;, pymongo.DESCENDING),
            
            # Compound indexes
            ((&amp;quot;category&amp;quot;, pymongo.ASCENDING), (&amp;quot;price&amp;quot;, pymongo.ASCENDING)),
            ((&amp;quot;brand&amp;quot;, pymongo.ASCENDING), (&amp;quot;rating&amp;quot;, pymongo.DESCENDING)),
        ]
        
        for index in indexes:
            try:
                if isinstance(index, tuple):
                    self.collection.create_index([index])
                else:
                    self.collection.create_index(index)
            except Exception as e:
                self.logger.warning(f&amp;quot;Failed to create index {index}: {e}&amp;quot;)
        
        # Create text index for search
        try:
            self.collection.create_index([
                (&amp;quot;name&amp;quot;, pymongo.TEXT),
                (&amp;quot;description&amp;quot;, pymongo.TEXT),
                (&amp;quot;brand&amp;quot;, pymongo.TEXT)
            ], name=&amp;quot;text_search&amp;quot;)
        except Exception as e:
            self.logger.warning(f&amp;quot;Failed to create text index: {e}&amp;quot;)
    
    def process_item(self, item, spider):
        &amp;quot;&amp;quot;&amp;quot;Process item and add to batch&amp;quot;&amp;quot;&amp;quot;
        adapter = ItemAdapter(item)
        
        # Add MongoDB-specific fields
        adapter[&amp;#39;_scraped_at&amp;#39;] = datetime.utcnow()
        adapter[&amp;#39;_spider_name&amp;#39;] = spider.name
        
        # Add to batch
        self.batch_items.append(dict(adapter))
        
        # Process batch if full
        if len(self.batch_items) &amp;gt;= self.batch_size:
            self._process_batch()
        
        return item
    
    def _process_batch(self):
        &amp;quot;&amp;quot;&amp;quot;Process batch of items with upsert operations&amp;quot;&amp;quot;&amp;quot;
        if not self.batch_items:
            return
        
        try:
            operations = []
            
            for item in self.batch_items:
                uniqueness_hash = item.get(&amp;#39;uniqueness_hash&amp;#39;)
                
                if uniqueness_hash:
                    # Upsert based on uniqueness hash
                    operation = UpdateOne(
                        {&amp;#39;uniqueness_hash&amp;#39;: uniqueness_hash},
                        {
                            &amp;#39;$set&amp;#39;: item,
                            &amp;#39;$setOnInsert&amp;#39;: {&amp;#39;_created_at&amp;#39;: datetime.utcnow()},
                            &amp;#39;$currentDate&amp;#39;: {&amp;#39;_updated_at&amp;#39;: True}
                        },
                        upsert=True
                    )
                    operations.append(operation)
                else:
                    # Insert without duplicate check
                    self.collection.insert_one(item)
                    self.stats[&amp;#39;inserted&amp;#39;] += 1
            
            if operations:
                result = self.collection.bulk_write(operations, ordered=False)
                self.stats[&amp;#39;inserted&amp;#39;] += result.upserted_count
                self.stats[&amp;#39;updated&amp;#39;] += result.modified_count
                self.stats[&amp;#39;duplicates&amp;#39;] += len(operations) - result.upserted_count - result.modified_count
            
        except Exception as e:
            self.stats[&amp;#39;errors&amp;#39;] += len(self.batch_items)
            self.logger.error(f&amp;quot;Error processing batch: {e}&amp;quot;)
        
        finally:
            self.batch_items.clear()
    
    def close_spider(self, spider):
        &amp;quot;&amp;quot;&amp;quot;Process remaining items and close connection&amp;quot;&amp;quot;&amp;quot;
        # Process remaining batch
        self._process_batch()
        
        # Log statistics
        self.logger.info(&amp;quot;=== MongoDB Pipeline Statistics ===&amp;quot;)
        for stat, count in self.stats.items():
            self.logger.info(f&amp;quot;{stat.capitalize()}: {count}&amp;quot;)
        
        # Create aggregation pipelines for analytics
        self._create_analytics_views()
        
        # Close connection
        if self.client:
            self.client.close()
    
    def _create_analytics_views(self):
        &amp;quot;&amp;quot;&amp;quot;Create MongoDB views for analytics&amp;quot;&amp;quot;&amp;quot;
        try:
            # Category statistics view
            self.db.create_collection(&amp;quot;category_stats&amp;quot;, viewOn=self.collection_name, pipeline=[
                {&amp;quot;$group&amp;quot;: {
                    &amp;quot;_id&amp;quot;: &amp;quot;$category&amp;quot;,
                    &amp;quot;total_products&amp;quot;: {&amp;quot;$sum&amp;quot;: 1},
                    &amp;quot;avg_price&amp;quot;: {&amp;quot;$avg&amp;quot;: &amp;quot;$price&amp;quot;},
                    &amp;quot;avg_rating&amp;quot;: {&amp;quot;$avg&amp;quot;: &amp;quot;$rating&amp;quot;},
                    &amp;quot;in_stock_count&amp;quot;: {&amp;quot;$sum&amp;quot;: {&amp;quot;$cond&amp;quot;: [&amp;quot;$in_stock&amp;quot;, 1, 0]}}
                }},
                {&amp;quot;$sort&amp;quot;: {&amp;quot;total_products&amp;quot;: -1}}
            ])
            
            # Brand statistics view
            self.db.create_collection(&amp;quot;brand_stats&amp;quot;, viewOn=self.collection_name, pipeline=[
                {&amp;quot;$group&amp;quot;: {
                    &amp;quot;_id&amp;quot;: &amp;quot;$brand&amp;quot;,
                    &amp;quot;total_products&amp;quot;: {&amp;quot;$sum&amp;quot;: 1},
                    &amp;quot;avg_price&amp;quot;: {&amp;quot;$avg&amp;quot;: &amp;quot;$price&amp;quot;},
                    &amp;quot;price_range&amp;quot;: {
                        &amp;quot;min&amp;quot;: {&amp;quot;$min&amp;quot;: &amp;quot;$price&amp;quot;},
                        &amp;quot;max&amp;quot;: {&amp;quot;$max&amp;quot;: &amp;quot;$price&amp;quot;}
                    }
                }},
                {&amp;quot;$sort&amp;quot;: {&amp;quot;total_products&amp;quot;: -1}}
            ])
            
            # Daily scraping statistics
            self.db.create_collection(&amp;quot;daily_stats&amp;quot;, viewOn=self.collection_name, pipeline=[
                {&amp;quot;$group&amp;quot;: {
                    &amp;quot;_id&amp;quot;: {
                        &amp;quot;$dateToString&amp;quot;: {
                            &amp;quot;format&amp;quot;: &amp;quot;%Y-%m-%d&amp;quot;,
                            &amp;quot;date&amp;quot;: &amp;quot;$_scraped_at&amp;quot;
                        }
                    },
                    &amp;quot;items_scraped&amp;quot;: {&amp;quot;$sum&amp;quot;: 1},
                    &amp;quot;unique_brands&amp;quot;: {&amp;quot;$addToSet&amp;quot;: &amp;quot;$brand&amp;quot;},
                    &amp;quot;unique_categories&amp;quot;: {&amp;quot;$addToSet&amp;quot;: &amp;quot;$category&amp;quot;}
                }},
                {&amp;quot;$addFields&amp;quot;: {
                    &amp;quot;unique_brand_count&amp;quot;: {&amp;quot;$size&amp;quot;: &amp;quot;$unique_brands&amp;quot;},
                    &amp;quot;unique_category_count&amp;quot;: {&amp;quot;$size&amp;quot;: &amp;quot;$unique_categories&amp;quot;}
                }},
                {&amp;quot;$sort&amp;quot;: {&amp;quot;_id&amp;quot;: -1}}
            ])
            
        except Exception as e:
            self.logger.warning(f&amp;quot;Failed to create analytics views: {e}&amp;quot;)

class MongoDBAnalytics:
    &amp;quot;&amp;quot;&amp;quot;Advanced analytics queries for MongoDB&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, mongo_uri, mongo_db, collection_name):
        self.client = MongoClient(mongo_uri)
        self.db = self.client[mongo_db]
        self.collection = self.db[collection_name]
    
    def get_price_trends(self, days: int = 30) -&amp;gt; List[Dict]:
        &amp;quot;&amp;quot;&amp;quot;Get price trends over time&amp;quot;&amp;quot;&amp;quot;
        pipeline = [
            {
                &amp;quot;$match&amp;quot;: {
                    &amp;quot;_scraped_at&amp;quot;: {
                        &amp;quot;$gte&amp;quot;: datetime.utcnow() - timedelta(days=days)
                    }
                }
            },
            {
                &amp;quot;$group&amp;quot;: {
                    &amp;quot;_id&amp;quot;: {
                        &amp;quot;date&amp;quot;: {&amp;quot;$dateToString&amp;quot;: {&amp;quot;format&amp;quot;: &amp;quot;%Y-%m-%d&amp;quot;, &amp;quot;date&amp;quot;: &amp;quot;$_scraped_at&amp;quot;}},
                        &amp;quot;category&amp;quot;: &amp;quot;$category&amp;quot;
                    },
                    &amp;quot;avg_price&amp;quot;: {&amp;quot;$avg&amp;quot;: &amp;quot;$price&amp;quot;},
                    &amp;quot;min_price&amp;quot;: {&amp;quot;$min&amp;quot;: &amp;quot;$price&amp;quot;},
                    &amp;quot;max_price&amp;quot;: {&amp;quot;$max&amp;quot;: &amp;quot;$price&amp;quot;},
                    &amp;quot;product_count&amp;quot;: {&amp;quot;$sum&amp;quot;: 1}
                }
            },
            {&amp;quot;$sort&amp;quot;: {&amp;quot;_id.date&amp;quot;: 1}}
        ]
        
        return list(self.collection.aggregate(pipeline))
    
    def get_top_products(self, category: str = None, limit: int = 10) -&amp;gt; List[Dict]:
        &amp;quot;&amp;quot;&amp;quot;Get top-rated products&amp;quot;&amp;quot;&amp;quot;
        match_stage = {&amp;quot;rating&amp;quot;: {&amp;quot;$exists&amp;quot;: True, &amp;quot;$gte&amp;quot;: 4.0}}
        if category:
            match_stage[&amp;quot;category&amp;quot;] = category
        
        pipeline = [
            {&amp;quot;$match&amp;quot;: match_stage},
            {&amp;quot;$sort&amp;quot;: {&amp;quot;rating&amp;quot;: -1, &amp;quot;review_count&amp;quot;: -1}},
            {&amp;quot;$limit&amp;quot;: limit},
            {
                &amp;quot;$project&amp;quot;: {
                    &amp;quot;name&amp;quot;: 1,
                    &amp;quot;brand&amp;quot;: 1,
                    &amp;quot;price&amp;quot;: 1,
                    &amp;quot;rating&amp;quot;: 1,
                    &amp;quot;review_count&amp;quot;: 1,
                    &amp;quot;url&amp;quot;: 1
                }
            }
        ]
        
        return list(self.collection.aggregate(pipeline))
    
    def get_inventory_alerts(self) -&amp;gt; List[Dict]:
        &amp;quot;&amp;quot;&amp;quot;Get products that went out of stock recently&amp;quot;&amp;quot;&amp;quot;
        pipeline = [
            {
                &amp;quot;$match&amp;quot;: {
                    &amp;quot;in_stock&amp;quot;: False,
                    &amp;quot;_scraped_at&amp;quot;: {&amp;quot;$gte&amp;quot;: datetime.utcnow() - timedelta(days=1)}
                }
            },
            {
                &amp;quot;$project&amp;quot;: {
                    &amp;quot;name&amp;quot;: 1,
                    &amp;quot;brand&amp;quot;: 1,
                    &amp;quot;price&amp;quot;: 1,
                    &amp;quot;category&amp;quot;: 1,
                    &amp;quot;_scraped_at&amp;quot;: 1
                }
            },
            {&amp;quot;$sort&amp;quot;: {&amp;quot;_scraped_at&amp;quot;: -1}}
        ]
        
        return list(self.collection.aggregate(pipeline))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;PostgreSQL Storage Pipeline&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/storage/postgresql_pipeline.py
import psycopg2
import psycopg2.extras
from itemadapter import ItemAdapter
from datetime import datetime
import logging
import json
from typing import Dict, List, Optional

class PostgreSQLPipeline:
    &amp;quot;&amp;quot;&amp;quot;Advanced PostgreSQL storage with JSONB and full-text search&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, postgres_settings):
        self.postgres_settings = postgres_settings
        self.connection = None
        self.cursor = None
        self.logger = logging.getLogger(__name__)
        
        # Batch processing
        self.batch_size = 50
        self.batch_items = []
        
        # Statistics
        self.stats = {
            &amp;#39;inserted&amp;#39;: 0,
            &amp;#39;updated&amp;#39;: 0,
            &amp;#39;errors&amp;#39;: 0
        }
    
    @classmethod
    def from_crawler(cls, crawler):
        postgres_settings = crawler.settings.getdict(&amp;quot;POSTGRES_SETTINGS&amp;quot;)
        return cls(postgres_settings)
    
    def open_spider(self, spider):
        &amp;quot;&amp;quot;&amp;quot;Initialize PostgreSQL connection and setup tables&amp;quot;&amp;quot;&amp;quot;
        try:
            self.connection = psycopg2.connect(**self.postgres_settings)
            self.cursor = self.connection.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
            
            # Create tables and indexes
            self._create_tables()
            self._create_indexes()
            
            self.logger.info(&amp;quot;Connected to PostgreSQL successfully&amp;quot;)
            
        except Exception as e:
            self.logger.error(f&amp;quot;Error connecting to PostgreSQL: {e}&amp;quot;)
            raise
    
    def _create_tables(self):
        &amp;quot;&amp;quot;&amp;quot;Create database tables&amp;quot;&amp;quot;&amp;quot;
        create_tables_sql = &amp;quot;&amp;quot;&amp;quot;
        -- Main products table
        CREATE TABLE IF NOT EXISTS products (
            id SERIAL PRIMARY KEY,
            uniqueness_hash VARCHAR(32) UNIQUE,
            name VARCHAR(500) NOT NULL,
            brand VARCHAR(100),
            category VARCHAR(100),
            price DECIMAL(10,2),
            original_price DECIMAL(10,2),
            currency VARCHAR(3) DEFAULT &amp;#39;USD&amp;#39;,
            rating DECIMAL(3,2),
            review_count INTEGER,
            in_stock BOOLEAN DEFAULT true,
            description TEXT,
            specifications JSONB,
            images JSONB,
            url VARCHAR(1000),
            source_spider VARCHAR(50),
            data_quality_score DECIMAL(3,2),
            scraped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            raw_data JSONB
        );
        
        -- Price history table
        CREATE TABLE IF NOT EXISTS price_history (
            id SERIAL PRIMARY KEY,
            product_id INTEGER REFERENCES products(id),
            price DECIMAL(10,2) NOT NULL,
            original_price DECIMAL(10,2),
            scraped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        );
        
        -- Categories table
        CREATE TABLE IF NOT EXISTS categories (
            id SERIAL PRIMARY KEY,
            name VARCHAR(100) UNIQUE NOT NULL,
            parent_category VARCHAR(100),
            description TEXT
        );
        
        -- Brands table
        CREATE TABLE IF NOT EXISTS brands (
            id SERIAL PRIMARY KEY,
            name VARCHAR(100) UNIQUE NOT NULL,
            website VARCHAR(255),
            description TEXT
        );
        
        -- Search statistics table
        CREATE TABLE IF NOT EXISTS search_stats (
            id SERIAL PRIMARY KEY,
            search_term VARCHAR(255),
            results_count INTEGER,
            avg_price DECIMAL(10,2),
            searched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        );
        &amp;quot;&amp;quot;&amp;quot;
        
        try:
            self.cursor.execute(create_tables_sql)
            self.connection.commit()
            self.logger.info(&amp;quot;Database tables created successfully&amp;quot;)
        except Exception as e:
            self.logger.error(f&amp;quot;Error creating tables: {e}&amp;quot;)
            self.connection.rollback()
    
    def _create_indexes(self):
        &amp;quot;&amp;quot;&amp;quot;Create database indexes&amp;quot;&amp;quot;&amp;quot;
        indexes_sql = [
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_name ON products USING gin(to_tsvector(&amp;#39;english&amp;#39;, name));&amp;quot;,
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_brand ON products(brand);&amp;quot;,
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_category ON products(category);&amp;quot;,
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_price ON products(price);&amp;quot;,
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_rating ON products(rating DESC);&amp;quot;,
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_scraped_at ON products(scraped_at DESC);&amp;quot;,
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_in_stock ON products(in_stock);&amp;quot;,
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_data_quality ON products(data_quality_score DESC);&amp;quot;,
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_price_history_product_id ON price_history(product_id);&amp;quot;,
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_price_history_scraped_at ON price_history(scraped_at DESC);&amp;quot;,
            
            # JSONB indexes
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_specs ON products USING gin(specifications);&amp;quot;,
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_raw_data ON products USING gin(raw_data);&amp;quot;,
            
            # Full-text search index
            &amp;quot;CREATE INDEX IF NOT EXISTS idx_products_fulltext ON products USING gin(to_tsvector(&amp;#39;english&amp;#39;, name || &amp;#39; &amp;#39; || COALESCE(description, &amp;#39;&amp;#39;)));&amp;quot;
        ]
        
        for index_sql in indexes_sql:
            try:
                self.cursor.execute(index_sql)
                self.connection.commit()
            except Exception as e:
                self.logger.warning(f&amp;quot;Error creating index: {e}&amp;quot;)
                self.connection.rollback()
    
    def process_item(self, item, spider):
        &amp;quot;&amp;quot;&amp;quot;Process item and add to batch&amp;quot;&amp;quot;&amp;quot;
        adapter = ItemAdapter(item)
        
        # Prepare item for PostgreSQL
        postgres_item = self._prepare_item(adapter, spider)
        self.batch_items.append(postgres_item)
        
        # Process batch if full
        if len(self.batch_items) &amp;gt;= self.batch_size:
            self._process_batch()
        
        return item
    
    def _prepare_item(self, adapter: ItemAdapter, spider) -&amp;gt; Dict:
        &amp;quot;&amp;quot;&amp;quot;Prepare item for PostgreSQL storage&amp;quot;&amp;quot;&amp;quot;
        return {
            &amp;#39;uniqueness_hash&amp;#39;: adapter.get(&amp;#39;uniqueness_hash&amp;#39;),
            &amp;#39;name&amp;#39;: adapter.get(&amp;#39;name&amp;#39;),
            &amp;#39;brand&amp;#39;: adapter.get(&amp;#39;brand&amp;#39;),
            &amp;#39;category&amp;#39;: adapter.get(&amp;#39;category&amp;#39;),
            &amp;#39;price&amp;#39;: adapter.get(&amp;#39;price&amp;#39;),
            &amp;#39;original_price&amp;#39;: adapter.get(&amp;#39;original_price&amp;#39;),
            &amp;#39;currency&amp;#39;: adapter.get(&amp;#39;currency&amp;#39;, &amp;#39;USD&amp;#39;),
            &amp;#39;rating&amp;#39;: adapter.get(&amp;#39;rating&amp;#39;),
            &amp;#39;review_count&amp;#39;: adapter.get(&amp;#39;review_count&amp;#39;),
            &amp;#39;in_stock&amp;#39;: adapter.get(&amp;#39;in_stock&amp;#39;, True),
            &amp;#39;description&amp;#39;: adapter.get(&amp;#39;description&amp;#39;),
            &amp;#39;specifications&amp;#39;: json.dumps(adapter.get(&amp;#39;specifications&amp;#39;, {})),
            &amp;#39;images&amp;#39;: json.dumps(adapter.get(&amp;#39;images&amp;#39;, [])),
            &amp;#39;url&amp;#39;: adapter.get(&amp;#39;url&amp;#39;),
            &amp;#39;source_spider&amp;#39;: spider.name,
            &amp;#39;data_quality_score&amp;#39;: adapter.get(&amp;#39;data_quality_score&amp;#39;),
            &amp;#39;raw_data&amp;#39;: json.dumps(dict(adapter))
        }
    
    def _process_batch(self):
        &amp;quot;&amp;quot;&amp;quot;Process batch of items with upsert&amp;quot;&amp;quot;&amp;quot;
        if not self.batch_items:
            return
        
        upsert_sql = &amp;quot;&amp;quot;&amp;quot;
        INSERT INTO products (
            uniqueness_hash, name, brand, category, price, original_price,
            currency, rating, review_count, in_stock, description,
            specifications, images, url, source_spider, data_quality_score, raw_data
        ) VALUES (
            %(uniqueness_hash)s, %(name)s, %(brand)s, %(category)s, %(price)s, %(original_price)s,
            %(currency)s, %(rating)s, %(review_count)s, %(in_stock)s, %(description)s,
            %(specifications)s, %(images)s, %(url)s, %(source_spider)s, %(data_quality_score)s, %(raw_data)s
        )
        ON CONFLICT (uniqueness_hash) DO UPDATE SET
            name = EXCLUDED.name,
            brand = EXCLUDED.brand,
            category = EXCLUDED.category,
            price = EXCLUDED.price,
            original_price = EXCLUDED.original_price,
            rating = EXCLUDED.rating,
            review_count = EXCLUDED.review_count,
            in_stock = EXCLUDED.in_stock,
            description = EXCLUDED.description,
            specifications = EXCLUDED.specifications,
            images = EXCLUDED.images,
            updated_at = CURRENT_TIMESTAMP,
            raw_data = EXCLUDED.raw_data
        RETURNING id, (xmax = 0) AS inserted;
        &amp;quot;&amp;quot;&amp;quot;
        
        try:
            # Execute batch upsert
            results = []
            for item in self.batch_items:
                self.cursor.execute(upsert_sql, item)
                result = self.cursor.fetchone()
                results.append(result)
            
            # Insert price history
            self._insert_price_history(results)
            
            self.connection.commit()
            
            # Update statistics
            for result in results:
                if result[&amp;#39;inserted&amp;#39;]:
                    self.stats[&amp;#39;inserted&amp;#39;] += 1
                else:
                    self.stats[&amp;#39;updated&amp;#39;] += 1
                    
        except Exception as e:
            self.stats[&amp;#39;errors&amp;#39;] += len(self.batch_items)
            self.logger.error(f&amp;quot;Error processing batch: {e}&amp;quot;)
            self.connection.rollback()
        
        finally:
            self.batch_items.clear()
    
    def _insert_price_history(self, results: List[Dict]):
        &amp;quot;&amp;quot;&amp;quot;Insert price history records&amp;quot;&amp;quot;&amp;quot;
        price_history_sql = &amp;quot;&amp;quot;&amp;quot;
        INSERT INTO price_history (product_id, price, original_price)
        VALUES (%s, %s, %s)
        &amp;quot;&amp;quot;&amp;quot;
        
        price_history_data = []
        for i, result in enumerate(results):
            item = self.batch_items[i]
            if item.get(&amp;#39;price&amp;#39;):
                price_history_data.append((
                    result[&amp;#39;id&amp;#39;],
                    item[&amp;#39;price&amp;#39;],
                    item.get(&amp;#39;original_price&amp;#39;)
                ))
        
        if price_history_data:
            self.cursor.executemany(price_history_sql, price_history_data)
    
    def close_spider(self, spider):
        &amp;quot;&amp;quot;&amp;quot;Process remaining items and close connection&amp;quot;&amp;quot;&amp;quot;
        # Process remaining batch
        self._process_batch()
        
        # Update categories and brands tables
        self._update_reference_tables()
        
        # Log statistics
        self.logger.info(&amp;quot;=== PostgreSQL Pipeline Statistics ===&amp;quot;)
        for stat, count in self.stats.items():
            self.logger.info(f&amp;quot;{stat.capitalize()}: {count}&amp;quot;)
        
        # Close connection
        if self.cursor:
            self.cursor.close()
        if self.connection:
            self.connection.close()
    
    def _update_reference_tables(self):
        &amp;quot;&amp;quot;&amp;quot;Update categories and brands reference tables&amp;quot;&amp;quot;&amp;quot;
        try:
            # Update categories
            self.cursor.execute(&amp;quot;&amp;quot;&amp;quot;
                INSERT INTO categories (name)
                SELECT DISTINCT category
                FROM products
                WHERE category IS NOT NULL
                ON CONFLICT (name) DO NOTHING;
            &amp;quot;&amp;quot;&amp;quot;)
            
            # Update brands
            self.cursor.execute(&amp;quot;&amp;quot;&amp;quot;
                INSERT INTO brands (name)
                SELECT DISTINCT brand
                FROM products
                WHERE brand IS NOT NULL
                ON CONFLICT (name) DO NOTHING;
            &amp;quot;&amp;quot;&amp;quot;)
            
            self.connection.commit()
            
        except Exception as e:
            self.logger.error(f&amp;quot;Error updating reference tables: {e}&amp;quot;)
            self.connection.rollback()

class PostgreSQLAnalytics:
    &amp;quot;&amp;quot;&amp;quot;Advanced analytics queries for PostgreSQL&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, postgres_settings):
        self.connection = psycopg2.connect(**postgres_settings)
        self.cursor = self.connection.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
    
    def search_products(self, query: str, limit: int = 50) -&amp;gt; List[Dict]:
        &amp;quot;&amp;quot;&amp;quot;Full-text search for products&amp;quot;&amp;quot;&amp;quot;
        search_sql = &amp;quot;&amp;quot;&amp;quot;
        SELECT id, name, brand, category, price, rating, review_count, url,
               ts_rank(to_tsvector(&amp;#39;english&amp;#39;, name || &amp;#39; &amp;#39; || COALESCE(description, &amp;#39;&amp;#39;)), 
                      plainto_tsquery(&amp;#39;english&amp;#39;, %s)) AS rank
        FROM products
        WHERE to_tsvector(&amp;#39;english&amp;#39;, name || &amp;#39; &amp;#39; || COALESCE(description, &amp;#39;&amp;#39;)) 
              @@ plainto_tsquery(&amp;#39;english&amp;#39;, %s)
        ORDER BY rank DESC, rating DESC NULLS LAST
        LIMIT %s;
        &amp;quot;&amp;quot;&amp;quot;
        
        self.cursor.execute(search_sql, (query, query, limit))
        return self.cursor.fetchall()
    
    def get_price_trends(self, product_id: int, days: int = 30) -&amp;gt; List[Dict]:
        &amp;quot;&amp;quot;&amp;quot;Get price trends for a specific product&amp;quot;&amp;quot;&amp;quot;
        trends_sql = &amp;quot;&amp;quot;&amp;quot;
        SELECT DATE(scraped_at) as date, 
               AVG(price) as avg_price,
               MIN(price) as min_price,
               MAX(price) as max_price,
               COUNT(*) as data_points
        FROM price_history
        WHERE product_id = %s 
          AND scraped_at &amp;gt;= CURRENT_DATE - INTERVAL &amp;#39;%s days&amp;#39;
        GROUP BY DATE(scraped_at)
        ORDER BY date;
        &amp;quot;&amp;quot;&amp;quot;
        
        self.cursor.execute(trends_sql, (product_id, days))
        return self.cursor.fetchall()
    
    def get_category_insights(self) -&amp;gt; List[Dict]:
        &amp;quot;&amp;quot;&amp;quot;Get insights by category&amp;quot;&amp;quot;&amp;quot;
        insights_sql = &amp;quot;&amp;quot;&amp;quot;
        SELECT category,
               COUNT(*) as product_count,
               AVG(price) as avg_price,
               MIN(price) as min_price,
               MAX(price) as max_price,
               AVG(rating) as avg_rating,
               COUNT(CASE WHEN in_stock THEN 1 END) as in_stock_count,
               AVG(data_quality_score) as avg_quality_score
        FROM products
        WHERE category IS NOT NULL
        GROUP BY category
        ORDER BY product_count DESC;
        &amp;quot;&amp;quot;&amp;quot;
        
        self.cursor.execute(insights_sql)
        return self.cursor.fetchall()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Next Steps&lt;/h2&gt;
&lt;p&gt;That covers the data layer: validation, enrichment, and two storage backends with deduplication and batch upserts. Before moving on, a useful exercise is to wire both pipelines into the spider from Part 3 and check the quality scores it produces on real data. &lt;a href=&quot;/tutorials/web-scraping-scrapy-part-5&quot;&gt;Part 5: Production Deployment&lt;/a&gt; covers Docker, Kubernetes, CI/CD, and monitoring.&lt;/p&gt;
</content:encoded></item><item><title>Scrapy, part 3: staying undetected at scale</title><link>https://tamangsurendra.com.np/blog/web-scraping-scrapy-part-3/</link><guid isPermaLink="true">https://tamangsurendra.com.np/blog/web-scraping-scrapy-part-3/</guid><description>Proxies, fingerprints, and distributed crawls: running scrapers across millions of pages without getting blocked.</description><pubDate>Mon, 23 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Anti-detection and scaling&lt;/h1&gt;
&lt;p&gt;This part is about running crawls that survive contact with real anti-bot systems: fingerprint-consistent headers, proxy rotation with health checks, distributed workers on Scrapy-Redis, and the monitoring and compliance layers around all of it.&lt;/p&gt;
&lt;h2&gt;What this part covers&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Advanced anti-detection and stealth techniques&lt;/li&gt;
&lt;li&gt;Distributed scraping with Scrapy-Redis&lt;/li&gt;
&lt;li&gt;IP rotation and proxy management&lt;/li&gt;
&lt;li&gt;Browser fingerprinting avoidance&lt;/li&gt;
&lt;li&gt;Rate limiting and adaptive throttling&lt;/li&gt;
&lt;li&gt;Monitoring, alerting, and health checks&lt;/li&gt;
&lt;li&gt;Legal compliance and ethical scraping&lt;/li&gt;
&lt;li&gt;Performance optimization and scaling&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Anti-Detection Techniques&lt;/h2&gt;
&lt;p&gt;Detection systems look at far more than your user agent: request timing, header order, and fingerprint consistency all get checked. Here&amp;#39;s how to stay under the radar:&lt;/p&gt;
&lt;h3&gt;Stealth Configuration&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/settings.py - Advanced Stealth Settings

# Respect robots.txt but with custom delay
ROBOTSTXT_OBEY = True
ROBOTSTXT_USER_AGENT = &amp;#39;*&amp;#39;

# Realistic browser behavior
DOWNLOAD_DELAY = 3  # Base delay between requests
RANDOMIZE_DOWNLOAD_DELAY = True  # 0.5 * to 1.5 * DOWNLOAD_DELAY
DOWNLOAD_TIMEOUT = 30
DOWNLOAD_MAXSIZE = 1073741824  # 1GB
DOWNLOAD_WARNSIZE = 33554432   # 32MB

# Advanced AutoThrottle settings
AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_START_DELAY = 1
AUTOTHROTTLE_MAX_DELAY = 60
AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0  # Conservative for stealth
AUTOTHROTTLE_DEBUG = False

# Realistic request patterns
CONCURRENT_REQUESTS = 8
CONCURRENT_REQUESTS_PER_DOMAIN = 2

# Advanced cookie handling
COOKIES_ENABLED = True
COOKIES_DEBUG = False

# Memory and resource management
MEMUSAGE_ENABLED = True
MEMUSAGE_LIMIT_MB = 2048
MEMUSAGE_WARNING_MB = 1024

# DNS timeout
DNSCACHE_ENABLED = True
DNSCACHE_SIZE = 10000
DNS_TIMEOUT = 60

# Enable telnet console for debugging (disable in production)
TELNETCONSOLE_ENABLED = False

# Custom middleware stack for stealth
DOWNLOADER_MIDDLEWARES = {
    &amp;#39;webscraper.middlewares.StealthUserAgentMiddleware&amp;#39;: 400,
    &amp;#39;webscraper.middlewares.ProxyRotationMiddleware&amp;#39;: 410,
    &amp;#39;webscraper.middlewares.HeaderSpoofingMiddleware&amp;#39;: 420,
    &amp;#39;webscraper.middlewares.CookiePersistenceMiddleware&amp;#39;: 430,
    &amp;#39;webscraper.middlewares.RequestTimingMiddleware&amp;#39;: 440,
    &amp;#39;webscraper.middlewares.FingerprintResistanceMiddleware&amp;#39;: 450,
    &amp;#39;scrapy.downloadermiddlewares.retry.RetryMiddleware&amp;#39;: 500,
    &amp;#39;scrapy.downloadermiddlewares.useragent.UserAgentMiddleware&amp;#39;: None,  # Disabled
}

# Anti-detection headers
DEFAULT_REQUEST_HEADERS = {
    &amp;#39;Accept&amp;#39;: &amp;#39;text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7&amp;#39;,
    &amp;#39;Accept-Language&amp;#39;: &amp;#39;en-US,en;q=0.9&amp;#39;,
    &amp;#39;Accept-Encoding&amp;#39;: &amp;#39;gzip, deflate, br&amp;#39;,
    &amp;#39;DNT&amp;#39;: &amp;#39;1&amp;#39;,
    &amp;#39;Connection&amp;#39;: &amp;#39;keep-alive&amp;#39;,
    &amp;#39;Upgrade-Insecure-Requests&amp;#39;: &amp;#39;1&amp;#39;,
    &amp;#39;Sec-Fetch-Dest&amp;#39;: &amp;#39;document&amp;#39;,
    &amp;#39;Sec-Fetch-Mode&amp;#39;: &amp;#39;navigate&amp;#39;,
    &amp;#39;Sec-Fetch-Site&amp;#39;: &amp;#39;none&amp;#39;,
    &amp;#39;Sec-Fetch-User&amp;#39;: &amp;#39;?1&amp;#39;,
    &amp;#39;Cache-Control&amp;#39;: &amp;#39;max-age=0&amp;#39;,
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Advanced Stealth Middleware&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/middlewares.py
import random
import time
import json
import hashlib
from datetime import datetime, timedelta
from scrapy.downloadermiddlewares.useragent import UserAgentMiddleware
from scrapy import signals
from scrapy.exceptions import IgnoreRequest

class StealthUserAgentMiddleware(UserAgentMiddleware):
    &amp;quot;&amp;quot;&amp;quot;Advanced user agent rotation with browser consistency&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self):
        # Browser families with consistent headers
        self.browser_profiles = {
            &amp;#39;chrome&amp;#39;: {
                &amp;#39;user_agents&amp;#39;: [
                    &amp;#39;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36&amp;#39;,
                    &amp;#39;Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36&amp;#39;,
                    &amp;#39;Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36&amp;#39;,
                ],
                &amp;#39;sec_ch_ua&amp;#39;: &amp;#39;&amp;quot;Not_A Brand&amp;quot;;v=&amp;quot;8&amp;quot;, &amp;quot;Chromium&amp;quot;;v=&amp;quot;120&amp;quot;, &amp;quot;Google Chrome&amp;quot;;v=&amp;quot;120&amp;quot;&amp;#39;,
                &amp;#39;sec_ch_ua_mobile&amp;#39;: &amp;#39;?0&amp;#39;,
                &amp;#39;sec_ch_ua_platform&amp;#39;: &amp;#39;&amp;quot;Windows&amp;quot;&amp;#39;,
            },
            &amp;#39;firefox&amp;#39;: {
                &amp;#39;user_agents&amp;#39;: [
                    &amp;#39;Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0&amp;#39;,
                    &amp;#39;Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0&amp;#39;,
                    &amp;#39;Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0&amp;#39;,
                ],
            },
            &amp;#39;safari&amp;#39;: {
                &amp;#39;user_agents&amp;#39;: [
                    &amp;#39;Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15&amp;#39;,
                    &amp;#39;Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15&amp;#39;,
                ],
            }
        }
        self.current_profile = None
        self.session_ua = None
    
    def process_request(self, request, spider):
        # Maintain consistent user agent per session
        if not self.session_ua:
            browser = random.choice(list(self.browser_profiles.keys()))
            self.current_profile = self.browser_profiles[browser]
            self.session_ua = random.choice(self.current_profile[&amp;#39;user_agents&amp;#39;])
        
        request.headers[&amp;#39;User-Agent&amp;#39;] = self.session_ua
        
        # Add browser-specific headers
        if &amp;#39;chrome&amp;#39; in self.session_ua.lower():
            profile = self.browser_profiles[&amp;#39;chrome&amp;#39;]
            request.headers[&amp;#39;sec-ch-ua&amp;#39;] = profile[&amp;#39;sec_ch_ua&amp;#39;]
            request.headers[&amp;#39;sec-ch-ua-mobile&amp;#39;] = profile[&amp;#39;sec_ch_ua_mobile&amp;#39;]
            request.headers[&amp;#39;sec-ch-ua-platform&amp;#39;] = profile[&amp;#39;sec_ch_ua_platform&amp;#39;]

class HeaderSpoofingMiddleware:
    &amp;quot;&amp;quot;&amp;quot;Spoof headers to mimic real browser behavior&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self):
        self.session_id = hashlib.md5(str(time.time()).encode()).hexdigest()[:16]
        self.request_count = 0
    
    def process_request(self, request, spider):
        self.request_count += 1
        
        # Add realistic timing headers
        request.headers[&amp;#39;X-Request-ID&amp;#39;] = f&amp;quot;{self.session_id}-{self.request_count}&amp;quot;
        
        # Randomize header order
        headers_to_randomize = [
            &amp;#39;Accept-Encoding&amp;#39;,
            &amp;#39;Accept-Language&amp;#39;, 
            &amp;#39;Cache-Control&amp;#39;,
            &amp;#39;Connection&amp;#39;,
            &amp;#39;DNT&amp;#39;,
            &amp;#39;Upgrade-Insecure-Requests&amp;#39;
        ]
        
        # Occasionally omit some headers to appear more human
        if random.random() &amp;lt; 0.1:  # 10% chance
            header_to_remove = random.choice(headers_to_randomize)
            if header_to_remove in request.headers:
                del request.headers[header_to_remove]
        
        # Add referer for non-start URLs
        if not request.meta.get(&amp;#39;is_start_url&amp;#39;, False):
            if &amp;#39;Referer&amp;#39; not in request.headers:
                # Use the domain as referer
                domain = request.url.split(&amp;#39;/&amp;#39;)[2]
                request.headers[&amp;#39;Referer&amp;#39;] = f&amp;quot;https://{domain}/&amp;quot;

class RequestTimingMiddleware:
    &amp;quot;&amp;quot;&amp;quot;Implement realistic request timing patterns&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self):
        self.last_request_time = {}
        self.human_patterns = [
            # Reading pattern: quick succession then pause
            [0.5, 0.3, 0.8, 5.0],
            # Browsing pattern: varied intervals
            [1.2, 2.1, 0.7, 3.5, 1.8],
            # Search pattern: quick then slow
            [0.4, 0.6, 8.0, 2.0],
        ]
        self.current_pattern = []
        self.pattern_index = 0
    
    def process_request(self, request, spider):
        domain = request.url.split(&amp;#39;/&amp;#39;)[2]
        current_time = time.time()
        
        # Get or initialize timing for this domain
        if domain not in self.last_request_time:
            self.last_request_time[domain] = current_time
            return
        
        # Calculate time since last request to this domain
        time_since_last = current_time - self.last_request_time[domain]
        
        # Choose a human-like delay pattern
        if not self.current_pattern:
            self.current_pattern = random.choice(self.human_patterns).copy()
            self.pattern_index = 0
        
        target_delay = self.current_pattern[self.pattern_index]
        self.pattern_index = (self.pattern_index + 1) % len(self.current_pattern)
        
        # Apply delay if needed
        if time_since_last &amp;lt; target_delay:
            sleep_time = target_delay - time_since_last
            spider.logger.debug(f&amp;#39;Applying human-like delay: {sleep_time:.2f}s for {domain}&amp;#39;)
            time.sleep(sleep_time)
        
        self.last_request_time[domain] = time.time()

class FingerprintResistanceMiddleware:
    &amp;quot;&amp;quot;&amp;quot;Resist browser fingerprinting attempts&amp;quot;&amp;quot;&amp;quot;
    
    def process_request(self, request, spider):
        # Randomize Accept header slightly
        accept_variations = [
            &amp;#39;text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8&amp;#39;,
            &amp;#39;text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8&amp;#39;,
            &amp;#39;text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8&amp;#39;,
        ]
        
        if random.random() &amp;lt; 0.3:  # 30% chance to vary
            request.headers[&amp;#39;Accept&amp;#39;] = random.choice(accept_variations)
        
        # Randomize Accept-Language
        lang_variations = [
            &amp;#39;en-US,en;q=0.9&amp;#39;,
            &amp;#39;en-US,en;q=0.8&amp;#39;,
            &amp;#39;en-US,en;q=0.9,es;q=0.8&amp;#39;,
            &amp;#39;en-US,en;q=0.5&amp;#39;,
        ]
        
        if random.random() &amp;lt; 0.2:  # 20% chance to vary
            request.headers[&amp;#39;Accept-Language&amp;#39;] = random.choice(lang_variations)

class CookiePersistenceMiddleware:
    &amp;quot;&amp;quot;&amp;quot;Advanced cookie management for session consistency&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self):
        self.session_cookies = {}
    
    def process_response(self, request, response, spider):
        # Store cookies for this domain
        domain = request.url.split(&amp;#39;/&amp;#39;)[2]
        cookies = response.headers.getlist(&amp;#39;Set-Cookie&amp;#39;)
        
        if cookies:
            if domain not in self.session_cookies:
                self.session_cookies[domain] = {}
            
            for cookie in cookies:
                cookie_str = cookie.decode(&amp;#39;utf-8&amp;#39;)
                if &amp;#39;=&amp;#39; in cookie_str:
                    name, value = cookie_str.split(&amp;#39;=&amp;#39;, 1)
                    # Store only the name=value part
                    value = value.split(&amp;#39;;&amp;#39;)[0]
                    self.session_cookies[domain][name] = value
        
        return response
    
    def process_request(self, request, spider):
        domain = request.url.split(&amp;#39;/&amp;#39;)[2]
        
        # Add stored cookies for this domain
        if domain in self.session_cookies:
            cookie_header = &amp;#39;; &amp;#39;.join([
                f&amp;quot;{name}={value}&amp;quot; 
                for name, value in self.session_cookies[domain].items()
            ])
            if cookie_header:
                request.headers[&amp;#39;Cookie&amp;#39;] = cookie_header
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Proxy Rotation System&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/proxy_manager.py
import random
import time
import requests
from typing import List, Dict, Optional
import threading
from dataclasses import dataclass
from enum import Enum

class ProxyStatus(Enum):
    ACTIVE = &amp;quot;active&amp;quot;
    FAILED = &amp;quot;failed&amp;quot;
    RATE_LIMITED = &amp;quot;rate_limited&amp;quot;
    TESTING = &amp;quot;testing&amp;quot;

@dataclass
class ProxyInfo:
    host: str
    port: int
    username: Optional[str] = None
    password: Optional[str] = None
    protocol: str = &amp;quot;http&amp;quot;
    status: ProxyStatus = ProxyStatus.TESTING
    success_count: int = 0
    failure_count: int = 0
    last_used: Optional[float] = None
    response_time: Optional[float] = None
    rate_limit_until: Optional[float] = None
    
    @property
    def url(self) -&amp;gt; str:
        if self.username and self.password:
            return f&amp;quot;{self.protocol}://{self.username}:{self.password}@{self.host}:{self.port}&amp;quot;
        return f&amp;quot;{self.protocol}://{self.host}:{self.port}&amp;quot;
    
    @property
    def success_rate(self) -&amp;gt; float:
        total = self.success_count + self.failure_count
        return self.success_count / total if total &amp;gt; 0 else 0.0
    
    def is_available(self) -&amp;gt; bool:
        if self.status == ProxyStatus.FAILED:
            return False
        if self.rate_limit_until and time.time() &amp;lt; self.rate_limit_until:
            return False
        return True

class ProxyManager:
    &amp;quot;&amp;quot;&amp;quot;Advanced proxy rotation and health management&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, proxy_list: List[Dict], health_check_interval: int = 300):
        self.proxies = [ProxyInfo(**proxy) for proxy in proxy_list]
        self.health_check_interval = health_check_interval
        self.last_health_check = 0
        self.lock = threading.Lock()
        
        # Start health checking thread
        self.health_thread = threading.Thread(target=self._health_check_loop, daemon=True)
        self.health_thread.start()
    
    def get_proxy(self) -&amp;gt; Optional[ProxyInfo]:
        &amp;quot;&amp;quot;&amp;quot;Get the best available proxy&amp;quot;&amp;quot;&amp;quot;
        with self.lock:
            available_proxies = [p for p in self.proxies if p.is_available()]
            
            if not available_proxies:
                return None
            
            # Sort by success rate and response time
            available_proxies.sort(
                key=lambda p: (p.success_rate, -p.response_time or 0),
                reverse=True
            )
            
            # Weighted selection favoring better proxies
            weights = [max(0.1, p.success_rate) for p in available_proxies]
            proxy = random.choices(available_proxies, weights=weights)[0]
            
            proxy.last_used = time.time()
            return proxy
    
    def report_success(self, proxy: ProxyInfo, response_time: float = None):
        &amp;quot;&amp;quot;&amp;quot;Report successful proxy usage&amp;quot;&amp;quot;&amp;quot;
        with self.lock:
            proxy.success_count += 1
            proxy.status = ProxyStatus.ACTIVE
            if response_time:
                proxy.response_time = response_time
    
    def report_failure(self, proxy: ProxyInfo, is_rate_limit: bool = False):
        &amp;quot;&amp;quot;&amp;quot;Report proxy failure&amp;quot;&amp;quot;&amp;quot;
        with self.lock:
            proxy.failure_count += 1
            
            if is_rate_limit:
                proxy.status = ProxyStatus.RATE_LIMITED
                proxy.rate_limit_until = time.time() + 300  # 5 minute cooldown
            elif proxy.failure_count &amp;gt; 5:
                proxy.status = ProxyStatus.FAILED
    
    def _health_check_loop(self):
        &amp;quot;&amp;quot;&amp;quot;Background thread for proxy health checking&amp;quot;&amp;quot;&amp;quot;
        while True:
            time.sleep(self.health_check_interval)
            self._perform_health_checks()
    
    def _perform_health_checks(self):
        &amp;quot;&amp;quot;&amp;quot;Check health of all proxies&amp;quot;&amp;quot;&amp;quot;
        test_url = &amp;quot;http://httpbin.org/ip&amp;quot;
        
        for proxy in self.proxies:
            if proxy.status == ProxyStatus.FAILED:
                continue
                
            try:
                start_time = time.time()
                response = requests.get(
                    test_url,
                    proxies={&amp;quot;http&amp;quot;: proxy.url, &amp;quot;https&amp;quot;: proxy.url},
                    timeout=10
                )
                response_time = time.time() - start_time
                
                if response.status_code == 200:
                    self.report_success(proxy, response_time)
                else:
                    self.report_failure(proxy)
                    
            except Exception:
                self.report_failure(proxy)
    
    def get_stats(self) -&amp;gt; Dict:
        &amp;quot;&amp;quot;&amp;quot;Get proxy pool statistics&amp;quot;&amp;quot;&amp;quot;
        with self.lock:
            total = len(self.proxies)
            active = sum(1 for p in self.proxies if p.status == ProxyStatus.ACTIVE)
            failed = sum(1 for p in self.proxies if p.status == ProxyStatus.FAILED)
            rate_limited = sum(1 for p in self.proxies if p.status == ProxyStatus.RATE_LIMITED)
            
            return {
                &amp;#39;total&amp;#39;: total,
                &amp;#39;active&amp;#39;: active,
                &amp;#39;failed&amp;#39;: failed,
                &amp;#39;rate_limited&amp;#39;: rate_limited,
                &amp;#39;success_rate&amp;#39;: sum(p.success_rate for p in self.proxies) / total if total &amp;gt; 0 else 0
            }

# Proxy rotation middleware
class AdvancedProxyRotationMiddleware:
    &amp;quot;&amp;quot;&amp;quot;Advanced proxy rotation with health management&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, proxy_list):
        self.proxy_manager = ProxyManager(proxy_list)
    
    @classmethod
    def from_crawler(cls, crawler):
        proxy_list = crawler.settings.get(&amp;#39;PROXY_LIST&amp;#39;, [])
        return cls(proxy_list)
    
    def process_request(self, request, spider):
        proxy = self.proxy_manager.get_proxy()
        
        if proxy:
            request.meta[&amp;#39;proxy&amp;#39;] = proxy.url
            request.meta[&amp;#39;proxy_info&amp;#39;] = proxy
            spider.logger.debug(f&amp;#39;Using proxy: {proxy.host}:{proxy.port}&amp;#39;)
        else:
            spider.logger.warning(&amp;#39;No available proxies&amp;#39;)
    
    def process_response(self, request, response, spider):
        proxy_info = request.meta.get(&amp;#39;proxy_info&amp;#39;)
        
        if proxy_info:
            if response.status == 200:
                self.proxy_manager.report_success(proxy_info)
            elif response.status == 429:  # Rate limited
                self.proxy_manager.report_failure(proxy_info, is_rate_limit=True)
            elif response.status &amp;gt;= 400:
                self.proxy_manager.report_failure(proxy_info)
        
        return response
    
    def process_exception(self, request, exception, spider):
        proxy_info = request.meta.get(&amp;#39;proxy_info&amp;#39;)
        
        if proxy_info:
            self.proxy_manager.report_failure(proxy_info)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Distributed Scraping with Scrapy-Redis&lt;/h2&gt;
&lt;p&gt;When one machine isn&amp;#39;t enough, Scrapy-Redis coordinates multiple workers through a shared queue:&lt;/p&gt;
&lt;h3&gt;Scrapy-Redis Setup&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Install Scrapy-Redis
pip install scrapy-redis

# Start Redis server
redis-server

# Or using Docker
docker run -d -p 6379:6379 redis:alpine
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Distributed Spider Configuration&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/settings.py - Redis Configuration

# Enable Scrapy-Redis
SCHEDULER = &amp;quot;scrapy_redis.scheduler.Scheduler&amp;quot;
DUPEFILTER_CLASS = &amp;quot;scrapy_redis.dupefilter.RFPDupeFilter&amp;quot;
ITEM_PIPELINES = {
    &amp;#39;scrapy_redis.pipelines.RedisPipeline&amp;#39;: 300,
    &amp;#39;webscraper.pipelines.ValidationPipeline&amp;#39;: 400,
}

# Redis connection
REDIS_URL = &amp;#39;redis://localhost:6379&amp;#39;

# Or with detailed configuration
REDIS_PARAMS = {
    &amp;#39;host&amp;#39;: &amp;#39;localhost&amp;#39;,
    &amp;#39;port&amp;#39;: 6379,
    &amp;#39;db&amp;#39;: 0,
    &amp;#39;password&amp;#39;: None,
}

# Scheduler configuration
SCHEDULER_PERSIST = True  # Keep scheduler data after spider closes
SCHEDULER_QUEUE_CLASS = &amp;#39;scrapy_redis.queue.PriorityQueue&amp;#39;

# Request serialization
SCHEDULER_SERIALIZER = &amp;quot;scrapy_redis.picklecompat&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Distributed Spider&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/spiders/distributed_spider.py
from scrapy_redis.spiders import RedisSpider
from webscraper.items import ProductItem
from scrapy.loader import ItemLoader
import json

class DistributedEcommerceSpider(RedisSpider):
    &amp;quot;&amp;quot;&amp;quot;Distributed spider using Redis for coordination&amp;quot;&amp;quot;&amp;quot;
    
    name = &amp;#39;distributed_ecommerce&amp;#39;
    redis_key = &amp;#39;distributed_ecommerce:start_urls&amp;#39;
    
    custom_settings = {
        &amp;#39;CONCURRENT_REQUESTS&amp;#39;: 32,
        &amp;#39;CONCURRENT_REQUESTS_PER_DOMAIN&amp;#39;: 16,
        &amp;#39;DOWNLOAD_DELAY&amp;#39;: 1,
        &amp;#39;SCHEDULER_IDLE_BEFORE_CLOSE&amp;#39;: 10,  # Wait 10 seconds before closing
    }
    
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.processed_count = 0
        self.error_count = 0
    
    def parse(self, response):
        &amp;quot;&amp;quot;&amp;quot;Parse category pages and product listings&amp;quot;&amp;quot;&amp;quot;
        self.logger.info(f&amp;#39;Worker {self.name} processing: {response.url}&amp;#39;)
        
        # Extract product links
        product_links = response.css(&amp;#39;.product-item a::attr(href)&amp;#39;).getall()
        
        for link in product_links:
            product_url = response.urljoin(link)
            yield response.follow(
                product_url,
                callback=self.parse_product,
                meta={&amp;#39;category_url&amp;#39;: response.url}
            )
        
        # Extract pagination
        next_page = response.css(&amp;#39;.pagination .next::attr(href)&amp;#39;).get()
        if next_page:
            yield response.follow(next_page, callback=self.parse)
        
        # Add more category pages to Redis queue
        self.discover_more_urls(response)
    
    def parse_product(self, response):
        &amp;quot;&amp;quot;&amp;quot;Parse individual product pages&amp;quot;&amp;quot;&amp;quot;
        try:
            loader = ItemLoader(item=ProductItem(), response=response)
            
            loader.add_css(&amp;#39;name&amp;#39;, &amp;#39;h1.product-title::text&amp;#39;)
            loader.add_css(&amp;#39;price&amp;#39;, &amp;#39;.price-current::text&amp;#39;)
            loader.add_css(&amp;#39;description&amp;#39;, &amp;#39;.product-description::text&amp;#39;)
            loader.add_css(&amp;#39;brand&amp;#39;, &amp;#39;.brand-name::text&amp;#39;)
            loader.add_css(&amp;#39;rating&amp;#39;, &amp;#39;.rating-value::text&amp;#39;)
            
            # Add metadata
            loader.add_value(&amp;#39;url&amp;#39;, response.url)
            loader.add_value(&amp;#39;category_url&amp;#39;, response.meta.get(&amp;#39;category_url&amp;#39;))
            loader.add_value(&amp;#39;worker_id&amp;#39;, self.name)
            loader.add_value(&amp;#39;scraped_at&amp;#39;, self.get_current_time())
            
            item = loader.load_item()
            self.processed_count += 1
            
            if self.processed_count % 100 == 0:
                self.logger.info(f&amp;#39;Worker {self.name} processed {self.processed_count} products&amp;#39;)
            
            yield item
            
        except Exception as e:
            self.error_count += 1
            self.logger.error(f&amp;#39;Error parsing product {response.url}: {e}&amp;#39;)
    
    def discover_more_urls(self, response):
        &amp;quot;&amp;quot;&amp;quot;Discover and add more URLs to the Redis queue&amp;quot;&amp;quot;&amp;quot;
        # Find category links
        category_links = response.css(&amp;#39;.category-nav a::attr(href)&amp;#39;).getall()
        
        for link in category_links:
            category_url = response.urljoin(link)
            # Add to Redis queue for other workers
            self.server.lpush(
                f&amp;#39;{self.redis_key}:discovered&amp;#39;,
                json.dumps({&amp;#39;url&amp;#39;: category_url, &amp;#39;priority&amp;#39;: 1})
            )
    
    def get_current_time(self):
        from datetime import datetime
        return datetime.now().isoformat()
    
    def closed(self, reason):
        &amp;quot;&amp;quot;&amp;quot;Spider closing callback&amp;quot;&amp;quot;&amp;quot;
        self.logger.info(f&amp;#39;Worker {self.name} closing: {reason}&amp;#39;)
        self.logger.info(f&amp;#39;Processed: {self.processed_count}, Errors: {self.error_count}&amp;#39;)

# Master coordinator spider
class CoordinatorSpider(RedisSpider):
    &amp;quot;&amp;quot;&amp;quot;Coordinator spider that feeds URLs to workers&amp;quot;&amp;quot;&amp;quot;
    
    name = &amp;#39;coordinator&amp;#39;
    redis_key = &amp;#39;distributed_ecommerce:start_urls&amp;#39;
    
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.seed_urls()
    
    def seed_urls(self):
        &amp;quot;&amp;quot;&amp;quot;Seed initial URLs into Redis&amp;quot;&amp;quot;&amp;quot;
        initial_urls = [
            &amp;#39;https://example-store.com/categories/electronics&amp;#39;,
            &amp;#39;https://example-store.com/categories/clothing&amp;#39;,
            &amp;#39;https://example-store.com/categories/home&amp;#39;,
            &amp;#39;https://example-store.com/categories/books&amp;#39;,
        ]
        
        for url in initial_urls:
            self.server.lpush(self.redis_key, url)
        
        self.logger.info(f&amp;#39;Seeded {len(initial_urls)} URLs&amp;#39;)
    
    def parse(self, response):
        &amp;quot;&amp;quot;&amp;quot;This spider doesn&amp;#39;t parse, it just coordinates&amp;quot;&amp;quot;&amp;quot;
        pass
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Worker Management Script&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# scripts/worker_manager.py
import subprocess
import sys
import time
import signal
import json
import redis
from typing import List, Dict

class WorkerManager:
    &amp;quot;&amp;quot;&amp;quot;Manage distributed scraping workers&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, redis_url: str = &amp;#39;redis://localhost:6379&amp;#39;):
        self.redis_client = redis.from_url(redis_url)
        self.workers: List[subprocess.Popen] = []
        self.running = True
        
        # Register signal handlers
        signal.signal(signal.SIGINT, self.shutdown_handler)
        signal.signal(signal.SIGTERM, self.shutdown_handler)
    
    def start_workers(self, spider_name: str, num_workers: int = 4):
        &amp;quot;&amp;quot;&amp;quot;Start multiple worker processes&amp;quot;&amp;quot;&amp;quot;
        print(f&amp;quot;Starting {num_workers} workers for spider &amp;#39;{spider_name}&amp;#39;&amp;quot;)
        
        for i in range(num_workers):
            worker_id = f&amp;quot;{spider_name}_worker_{i}&amp;quot;
            cmd = [
                &amp;#39;scrapy&amp;#39;, &amp;#39;crawl&amp;#39;, spider_name,
                &amp;#39;-s&amp;#39;, f&amp;#39;BOT_NAME={worker_id}&amp;#39;,
                &amp;#39;-L&amp;#39;, &amp;#39;INFO&amp;#39;
            ]
            
            worker = subprocess.Popen(
                cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True
            )
            
            self.workers.append(worker)
            print(f&amp;quot;Started worker {worker_id} (PID: {worker.pid})&amp;quot;)
    
    def monitor_workers(self):
        &amp;quot;&amp;quot;&amp;quot;Monitor worker health and restart if needed&amp;quot;&amp;quot;&amp;quot;
        while self.running:
            for i, worker in enumerate(self.workers):
                if worker.poll() is not None:  # Worker has terminated
                    print(f&amp;quot;Worker {i} terminated with code {worker.returncode}&amp;quot;)
                    
                    # Restart worker if it crashed
                    if worker.returncode != 0:
                        print(f&amp;quot;Restarting worker {i}&amp;quot;)
                        # Implementation for restarting worker
                        pass
            
            # Monitor Redis queue sizes
            self.print_queue_stats()
            
            time.sleep(30)  # Check every 30 seconds
    
    def print_queue_stats(self):
        &amp;quot;&amp;quot;&amp;quot;Print Redis queue statistics&amp;quot;&amp;quot;&amp;quot;
        try:
            queue_size = self.redis_client.llen(&amp;#39;distributed_ecommerce:start_urls&amp;#39;)
            processing = self.redis_client.scard(&amp;#39;distributed_ecommerce:dupefilter&amp;#39;)
            
            print(f&amp;quot;Queue size: {queue_size}, Processed: {processing}&amp;quot;)
            
        except Exception as e:
            print(f&amp;quot;Error getting Redis stats: {e}&amp;quot;)
    
    def shutdown_handler(self, signum, frame):
        &amp;quot;&amp;quot;&amp;quot;Handle shutdown signals&amp;quot;&amp;quot;&amp;quot;
        print(&amp;quot;\nShutting down workers...&amp;quot;)
        self.running = False
        
        for i, worker in enumerate(self.workers):
            print(f&amp;quot;Terminating worker {i}&amp;quot;)
            worker.terminate()
            
            # Wait for graceful shutdown
            try:
                worker.wait(timeout=10)
            except subprocess.TimeoutExpired:
                print(f&amp;quot;Force killing worker {i}&amp;quot;)
                worker.kill()
        
        print(&amp;quot;All workers shut down&amp;quot;)
        sys.exit(0)
    
    def run(self, spider_name: str, num_workers: int = 4):
        &amp;quot;&amp;quot;&amp;quot;Main execution method&amp;quot;&amp;quot;&amp;quot;
        self.start_workers(spider_name, num_workers)
        self.monitor_workers()

if __name__ == &amp;quot;__main__&amp;quot;:
    if len(sys.argv) &amp;lt; 2:
        print(&amp;quot;Usage: python worker_manager.py &amp;lt;spider_name&amp;gt; [num_workers]&amp;quot;)
        sys.exit(1)
    
    spider_name = sys.argv[1]
    num_workers = int(sys.argv[2]) if len(sys.argv) &amp;gt; 2 else 4
    
    manager = WorkerManager()
    manager.run(spider_name, num_workers)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Monitoring and Alerting&lt;/h2&gt;
&lt;h3&gt;Monitoring System&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/monitoring.py
import time
import json
import smtplib
from datetime import datetime, timedelta
from email.mime.text import MimeText
from email.mime.multipart import MimeMultipart
from typing import Dict, List, Optional
import psutil
import redis
from dataclasses import dataclass, asdict

@dataclass
class ScrapingMetrics:
    timestamp: str
    spider_name: str
    pages_scraped: int
    items_extracted: int
    errors: int
    response_time_avg: float
    memory_usage_mb: float
    cpu_usage_percent: float
    active_requests: int
    queue_size: int
    success_rate: float

class PerformanceMonitor:
    &amp;quot;&amp;quot;&amp;quot;Monitor scraping performance and system resources&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, redis_url: str = &amp;#39;redis://localhost:6379&amp;#39;):
        self.redis_client = redis.from_url(redis_url)
        self.metrics_history: List[ScrapingMetrics] = []
        self.alert_thresholds = {
            &amp;#39;error_rate&amp;#39;: 0.1,      # 10% error rate
            &amp;#39;memory_usage&amp;#39;: 2048,    # 2GB memory usage
            &amp;#39;cpu_usage&amp;#39;: 80,         # 80% CPU usage
            &amp;#39;response_time&amp;#39;: 10,     # 10 second average response time
            &amp;#39;queue_stall&amp;#39;: 300,      # 5 minutes without queue progress
        }
        self.last_queue_size = 0
        self.queue_stall_start = None
    
    def collect_metrics(self, spider_name: str) -&amp;gt; ScrapingMetrics:
        &amp;quot;&amp;quot;&amp;quot;Collect current performance metrics&amp;quot;&amp;quot;&amp;quot;
        # Get system metrics
        memory_usage = psutil.virtual_memory().used / 1024 / 1024  # MB
        cpu_usage = psutil.cpu_percent(interval=1)
        
        # Get Redis metrics
        try:
            queue_size = self.redis_client.llen(f&amp;#39;{spider_name}:start_urls&amp;#39;)
            processed_count = self.redis_client.scard(f&amp;#39;{spider_name}:dupefilter&amp;#39;)
            
            # Detect queue stall
            if queue_size == self.last_queue_size:
                if not self.queue_stall_start:
                    self.queue_stall_start = time.time()
            else:
                self.queue_stall_start = None
            
            self.last_queue_size = queue_size
            
        except Exception:
            queue_size = 0
            processed_count = 0
        
        # Create metrics object (simplified - in real implementation, 
        # you&amp;#39;d collect these from your spider&amp;#39;s stats)
        metrics = ScrapingMetrics(
            timestamp=datetime.now().isoformat(),
            spider_name=spider_name,
            pages_scraped=processed_count,
            items_extracted=processed_count * 0.8,  # Estimate
            errors=processed_count * 0.05,  # Estimate
            response_time_avg=2.5,  # Would be collected from actual stats
            memory_usage_mb=memory_usage,
            cpu_usage_percent=cpu_usage,
            active_requests=queue_size,
            queue_size=queue_size,
            success_rate=0.95  # Would be calculated from actual stats
        )
        
        self.metrics_history.append(metrics)
        
        # Keep only last 24 hours of metrics
        cutoff_time = datetime.now() - timedelta(hours=24)
        self.metrics_history = [
            m for m in self.metrics_history 
            if datetime.fromisoformat(m.timestamp) &amp;gt; cutoff_time
        ]
        
        return metrics
    
    def check_alerts(self, metrics: ScrapingMetrics) -&amp;gt; List[Dict]:
        &amp;quot;&amp;quot;&amp;quot;Check metrics against alert thresholds&amp;quot;&amp;quot;&amp;quot;
        alerts = []
        
        # Error rate alert
        error_rate = metrics.errors / max(1, metrics.pages_scraped)
        if error_rate &amp;gt; self.alert_thresholds[&amp;#39;error_rate&amp;#39;]:
            alerts.append({
                &amp;#39;type&amp;#39;: &amp;#39;error_rate&amp;#39;,
                &amp;#39;severity&amp;#39;: &amp;#39;high&amp;#39;,
                &amp;#39;message&amp;#39;: f&amp;#39;High error rate: {error_rate:.2%}&amp;#39;,
                &amp;#39;value&amp;#39;: error_rate,
                &amp;#39;threshold&amp;#39;: self.alert_thresholds[&amp;#39;error_rate&amp;#39;]
            })
        
        # Memory usage alert
        if metrics.memory_usage_mb &amp;gt; self.alert_thresholds[&amp;#39;memory_usage&amp;#39;]:
            alerts.append({
                &amp;#39;type&amp;#39;: &amp;#39;memory_usage&amp;#39;,
                &amp;#39;severity&amp;#39;: &amp;#39;medium&amp;#39;,
                &amp;#39;message&amp;#39;: f&amp;#39;High memory usage: {metrics.memory_usage_mb:.1f}MB&amp;#39;,
                &amp;#39;value&amp;#39;: metrics.memory_usage_mb,
                &amp;#39;threshold&amp;#39;: self.alert_thresholds[&amp;#39;memory_usage&amp;#39;]
            })
        
        # CPU usage alert
        if metrics.cpu_usage_percent &amp;gt; self.alert_thresholds[&amp;#39;cpu_usage&amp;#39;]:
            alerts.append({
                &amp;#39;type&amp;#39;: &amp;#39;cpu_usage&amp;#39;,
                &amp;#39;severity&amp;#39;: &amp;#39;medium&amp;#39;,
                &amp;#39;message&amp;#39;: f&amp;#39;High CPU usage: {metrics.cpu_usage_percent:.1f}%&amp;#39;,
                &amp;#39;value&amp;#39;: metrics.cpu_usage_percent,
                &amp;#39;threshold&amp;#39;: self.alert_thresholds[&amp;#39;cpu_usage&amp;#39;]
            })
        
        # Response time alert
        if metrics.response_time_avg &amp;gt; self.alert_thresholds[&amp;#39;response_time&amp;#39;]:
            alerts.append({
                &amp;#39;type&amp;#39;: &amp;#39;response_time&amp;#39;,
                &amp;#39;severity&amp;#39;: &amp;#39;medium&amp;#39;,
                &amp;#39;message&amp;#39;: f&amp;#39;Slow response time: {metrics.response_time_avg:.1f}s&amp;#39;,
                &amp;#39;value&amp;#39;: metrics.response_time_avg,
                &amp;#39;threshold&amp;#39;: self.alert_thresholds[&amp;#39;response_time&amp;#39;]
            })
        
        # Queue stall alert
        if self.queue_stall_start:
            stall_duration = time.time() - self.queue_stall_start
            if stall_duration &amp;gt; self.alert_thresholds[&amp;#39;queue_stall&amp;#39;]:
                alerts.append({
                    &amp;#39;type&amp;#39;: &amp;#39;queue_stall&amp;#39;,
                    &amp;#39;severity&amp;#39;: &amp;#39;high&amp;#39;,
                    &amp;#39;message&amp;#39;: f&amp;#39;Queue stalled for {stall_duration/60:.1f} minutes&amp;#39;,
                    &amp;#39;value&amp;#39;: stall_duration,
                    &amp;#39;threshold&amp;#39;: self.alert_thresholds[&amp;#39;queue_stall&amp;#39;]
                })
        
        return alerts
    
    def generate_report(self, hours: int = 24) -&amp;gt; Dict:
        &amp;quot;&amp;quot;&amp;quot;Generate performance report&amp;quot;&amp;quot;&amp;quot;
        cutoff_time = datetime.now() - timedelta(hours=hours)
        recent_metrics = [
            m for m in self.metrics_history 
            if datetime.fromisoformat(m.timestamp) &amp;gt; cutoff_time
        ]
        
        if not recent_metrics:
            return {&amp;#39;error&amp;#39;: &amp;#39;No metrics available&amp;#39;}
        
        # Calculate aggregated statistics
        total_pages = sum(m.pages_scraped for m in recent_metrics)
        total_items = sum(m.items_extracted for m in recent_metrics)
        total_errors = sum(m.errors for m in recent_metrics)
        avg_response_time = sum(m.response_time_avg for m in recent_metrics) / len(recent_metrics)
        avg_memory = sum(m.memory_usage_mb for m in recent_metrics) / len(recent_metrics)
        avg_cpu = sum(m.cpu_usage_percent for m in recent_metrics) / len(recent_metrics)
        
        return {
            &amp;#39;period&amp;#39;: f&amp;#39;Last {hours} hours&amp;#39;,
            &amp;#39;total_pages_scraped&amp;#39;: total_pages,
            &amp;#39;total_items_extracted&amp;#39;: total_items,
            &amp;#39;total_errors&amp;#39;: total_errors,
            &amp;#39;error_rate&amp;#39;: total_errors / max(1, total_pages),
            &amp;#39;average_response_time&amp;#39;: avg_response_time,
            &amp;#39;average_memory_usage_mb&amp;#39;: avg_memory,
            &amp;#39;average_cpu_usage_percent&amp;#39;: avg_cpu,
            &amp;#39;current_queue_size&amp;#39;: recent_metrics[-1].queue_size if recent_metrics else 0,
            &amp;#39;metrics_count&amp;#39;: len(recent_metrics)
        }

class AlertManager:
    &amp;quot;&amp;quot;&amp;quot;Manage and send alerts&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self, config: Dict):
        self.config = config
        self.sent_alerts = {}  # Track sent alerts to avoid spam
        self.cooldown_period = 300  # 5 minutes cooldown between same alerts
    
    def send_alert(self, alert: Dict, metrics: ScrapingMetrics):
        &amp;quot;&amp;quot;&amp;quot;Send alert via configured channels&amp;quot;&amp;quot;&amp;quot;
        alert_key = f&amp;quot;{alert[&amp;#39;type&amp;#39;]}_{alert.get(&amp;#39;severity&amp;#39;, &amp;#39;medium&amp;#39;)}&amp;quot;
        current_time = time.time()
        
        # Check cooldown
        if alert_key in self.sent_alerts:
            if current_time - self.sent_alerts[alert_key] &amp;lt; self.cooldown_period:
                return  # Skip sending due to cooldown
        
        # Send via email
        if self.config.get(&amp;#39;email&amp;#39;):
            self._send_email_alert(alert, metrics)
        
        # Send via webhook
        if self.config.get(&amp;#39;webhook&amp;#39;):
            self._send_webhook_alert(alert, metrics)
        
        # Log to file
        self._log_alert(alert, metrics)
        
        self.sent_alerts[alert_key] = current_time
    
    def _send_email_alert(self, alert: Dict, metrics: ScrapingMetrics):
        &amp;quot;&amp;quot;&amp;quot;Send alert via email&amp;quot;&amp;quot;&amp;quot;
        try:
            smtp_config = self.config[&amp;#39;email&amp;#39;]
            
            msg = MimeMultipart()
            msg[&amp;#39;From&amp;#39;] = smtp_config[&amp;#39;from&amp;#39;]
            msg[&amp;#39;To&amp;#39;] = smtp_config[&amp;#39;to&amp;#39;]
            msg[&amp;#39;Subject&amp;#39;] = f&amp;quot;Scraping Alert: {alert[&amp;#39;type&amp;#39;]} - {alert[&amp;#39;severity&amp;#39;].upper()}&amp;quot;
            
            body = f&amp;quot;&amp;quot;&amp;quot;
            Alert: {alert[&amp;#39;message&amp;#39;]}
            
            Spider: {metrics.spider_name}
            Time: {metrics.timestamp}
            Current Value: {alert[&amp;#39;value&amp;#39;]}
            Threshold: {alert[&amp;#39;threshold&amp;#39;]}
            
            Current Metrics:
            - Pages Scraped: {metrics.pages_scraped}
            - Items Extracted: {metrics.items_extracted}
            - Error Rate: {metrics.errors / max(1, metrics.pages_scraped):.2%}
            - Memory Usage: {metrics.memory_usage_mb:.1f}MB
            - CPU Usage: {metrics.cpu_usage_percent:.1f}%
            - Queue Size: {metrics.queue_size}
            &amp;quot;&amp;quot;&amp;quot;
            
            msg.attach(MimeText(body, &amp;#39;plain&amp;#39;))
            
            with smtplib.SMTP(smtp_config[&amp;#39;smtp_server&amp;#39;], smtp_config[&amp;#39;smtp_port&amp;#39;]) as server:
                if smtp_config.get(&amp;#39;use_tls&amp;#39;):
                    server.starttls()
                if smtp_config.get(&amp;#39;username&amp;#39;):
                    server.login(smtp_config[&amp;#39;username&amp;#39;], smtp_config[&amp;#39;password&amp;#39;])
                server.send_message(msg)
                
        except Exception as e:
            print(f&amp;quot;Failed to send email alert: {e}&amp;quot;)
    
    def _send_webhook_alert(self, alert: Dict, metrics: ScrapingMetrics):
        &amp;quot;&amp;quot;&amp;quot;Send alert via webhook&amp;quot;&amp;quot;&amp;quot;
        import requests
        
        try:
            webhook_config = self.config[&amp;#39;webhook&amp;#39;]
            
            payload = {
                &amp;#39;alert&amp;#39;: alert,
                &amp;#39;metrics&amp;#39;: asdict(metrics),
                &amp;#39;timestamp&amp;#39;: metrics.timestamp
            }
            
            response = requests.post(
                webhook_config[&amp;#39;url&amp;#39;],
                json=payload,
                headers=webhook_config.get(&amp;#39;headers&amp;#39;, {}),
                timeout=10
            )
            response.raise_for_status()
            
        except Exception as e:
            print(f&amp;quot;Failed to send webhook alert: {e}&amp;quot;)
    
    def _log_alert(self, alert: Dict, metrics: ScrapingMetrics):
        &amp;quot;&amp;quot;&amp;quot;Log alert to file&amp;quot;&amp;quot;&amp;quot;
        import logging
        
        logging.basicConfig(
            filename=&amp;#39;scraping_alerts.log&amp;#39;,
            level=logging.INFO,
            format=&amp;#39;%(asctime)s - %(levelname)s - %(message)s&amp;#39;
        )
        
        logging.info(
            f&amp;quot;ALERT: {alert[&amp;#39;type&amp;#39;]} - {alert[&amp;#39;message&amp;#39;]} &amp;quot;
            f&amp;quot;(Spider: {metrics.spider_name}, Value: {alert[&amp;#39;value&amp;#39;]})&amp;quot;
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Monitoring Dashboard Script&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# scripts/monitoring_dashboard.py
import time
import json
from webscraper.monitoring import PerformanceMonitor, AlertManager

def main():
    &amp;quot;&amp;quot;&amp;quot;Main monitoring loop&amp;quot;&amp;quot;&amp;quot;
    
    # Configuration
    config = {
        &amp;#39;email&amp;#39;: {
            &amp;#39;smtp_server&amp;#39;: &amp;#39;smtp.gmail.com&amp;#39;,
            &amp;#39;smtp_port&amp;#39;: 587,
            &amp;#39;use_tls&amp;#39;: True,
            &amp;#39;username&amp;#39;: &amp;#39;your-email@gmail.com&amp;#39;,
            &amp;#39;password&amp;#39;: &amp;#39;your-app-password&amp;#39;,
            &amp;#39;from&amp;#39;: &amp;#39;your-email@gmail.com&amp;#39;,
            &amp;#39;to&amp;#39;: &amp;#39;alerts@yourcompany.com&amp;#39;
        },
        &amp;#39;webhook&amp;#39;: {
            &amp;#39;url&amp;#39;: &amp;#39;https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK&amp;#39;,
            &amp;#39;headers&amp;#39;: {&amp;#39;Content-Type&amp;#39;: &amp;#39;application/json&amp;#39;}
        }
    }
    
    monitor = PerformanceMonitor()
    alert_manager = AlertManager(config)
    
    spider_name = &amp;#39;distributed_ecommerce&amp;#39;
    
    print(&amp;quot;Starting monitoring dashboard...&amp;quot;)
    print(&amp;quot;Press Ctrl+C to stop&amp;quot;)
    
    try:
        while True:
            # Collect metrics
            metrics = monitor.collect_metrics(spider_name)
            
            # Check for alerts
            alerts = monitor.check_alerts(metrics)
            
            # Send alerts
            for alert in alerts:
                alert_manager.send_alert(alert, metrics)
                print(f&amp;quot;ALERT: {alert[&amp;#39;message&amp;#39;]}&amp;quot;)
            
            # Print current status
            print(f&amp;quot;\n[{metrics.timestamp}] Spider: {spider_name}&amp;quot;)
            print(f&amp;quot;Pages: {metrics.pages_scraped}, Items: {metrics.items_extracted}&amp;quot;)
            print(f&amp;quot;Errors: {metrics.errors}, Queue: {metrics.queue_size}&amp;quot;)
            print(f&amp;quot;Memory: {metrics.memory_usage_mb:.1f}MB, CPU: {metrics.cpu_usage_percent:.1f}%&amp;quot;)
            
            if alerts:
                print(f&amp;quot;Active alerts: {len(alerts)}&amp;quot;)
            
            # Generate hourly report
            if int(time.time()) % 3600 == 0:  # Every hour
                report = monitor.generate_report(hours=1)
                print(&amp;quot;\n--- Hourly Report ---&amp;quot;)
                print(json.dumps(report, indent=2))
            
            time.sleep(60)  # Check every minute
            
    except KeyboardInterrupt:
        print(&amp;quot;\nMonitoring stopped&amp;quot;)

if __name__ == &amp;quot;__main__&amp;quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Legal Compliance and Ethics&lt;/h2&gt;
&lt;h3&gt;Compliance Framework&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# webscraper/compliance.py
import time
import requests
from urllib.robotparser import RobotFileParser
from urllib.parse import urljoin, urlparse
from typing import Dict, List, Optional
import logging

class ComplianceManager:
    &amp;quot;&amp;quot;&amp;quot;Ensure legal and ethical scraping compliance&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self):
        self.robots_cache = {}
        self.rate_limits = {}
        self.compliance_rules = {
            &amp;#39;respect_robots_txt&amp;#39;: True,
            &amp;#39;rate_limit_default&amp;#39;: 1.0,  # 1 second between requests
            &amp;#39;rate_limit_per_domain&amp;#39;: {},
            &amp;#39;user_agent_required&amp;#39;: True,
            &amp;#39;contact_info&amp;#39;: &amp;#39;your-email@company.com&amp;#39;,
            &amp;#39;personal_data_handling&amp;#39;: &amp;#39;exclude&amp;#39;,
            &amp;#39;copyright_respect&amp;#39;: True,
        }
        
        self.logger = logging.getLogger(__name__)
    
    def check_robots_txt(self, url: str, user_agent: str = &amp;#39;*&amp;#39;) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Check if URL is allowed by robots.txt&amp;quot;&amp;quot;&amp;quot;
        domain = urlparse(url).netloc
        
        if domain not in self.robots_cache:
            robots_url = urljoin(f&amp;quot;https://{domain}&amp;quot;, &amp;quot;/robots.txt&amp;quot;)
            
            try:
                rp = RobotFileParser()
                rp.set_url(robots_url)
                rp.read()
                self.robots_cache[domain] = rp
                
                # Extract crawl delay if specified
                delay = rp.crawl_delay(user_agent)
                if delay:
                    self.rate_limits[domain] = float(delay)
                
            except Exception as e:
                self.logger.warning(f&amp;quot;Could not fetch robots.txt for {domain}: {e}&amp;quot;)
                return True  # Allow if robots.txt can&amp;#39;t be fetched
        
        robots_parser = self.robots_cache.get(domain)
        if robots_parser:
            return robots_parser.can_fetch(user_agent, url)
        
        return True
    
    def get_rate_limit(self, domain: str) -&amp;gt; float:
        &amp;quot;&amp;quot;&amp;quot;Get appropriate rate limit for domain&amp;quot;&amp;quot;&amp;quot;
        # Check domain-specific rate limit
        if domain in self.compliance_rules[&amp;#39;rate_limit_per_domain&amp;#39;]:
            return self.compliance_rules[&amp;#39;rate_limit_per_domain&amp;#39;][domain]
        
        # Check robots.txt specified delay
        if domain in self.rate_limits:
            return self.rate_limits[domain]
        
        # Use default
        return self.compliance_rules[&amp;#39;rate_limit_default&amp;#39;]
    
    def validate_request(self, request) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Validate request against compliance rules&amp;quot;&amp;quot;&amp;quot;
        url = request.url
        domain = urlparse(url).netloc
        user_agent = request.headers.get(&amp;#39;User-Agent&amp;#39;, &amp;#39;&amp;#39;)
        
        # Check robots.txt
        if self.compliance_rules[&amp;#39;respect_robots_txt&amp;#39;]:
            if not self.check_robots_txt(url, user_agent):
                self.logger.warning(f&amp;quot;Blocked by robots.txt: {url}&amp;quot;)
                return False
        
        # Check user agent requirement
        if self.compliance_rules[&amp;#39;user_agent_required&amp;#39;]:
            if not user_agent or &amp;#39;bot&amp;#39; not in user_agent.lower():
                self.logger.warning(f&amp;quot;Invalid user agent for compliance: {user_agent}&amp;quot;)
                return False
        
        return True
    
    def filter_personal_data(self, item: Dict) -&amp;gt; Dict:
        &amp;quot;&amp;quot;&amp;quot;Filter out personal data from scraped items&amp;quot;&amp;quot;&amp;quot;
        if self.compliance_rules[&amp;#39;personal_data_handling&amp;#39;] == &amp;#39;exclude&amp;#39;:
            sensitive_fields = [
                &amp;#39;email&amp;#39;, &amp;#39;phone&amp;#39;, &amp;#39;address&amp;#39;, &amp;#39;ssn&amp;#39;, &amp;#39;credit_card&amp;#39;,
                &amp;#39;personal_id&amp;#39;, &amp;#39;passport&amp;#39;, &amp;#39;driver_license&amp;#39;
            ]
            
            filtered_item = {}
            for key, value in item.items():
                if key.lower() not in sensitive_fields:
                    # Additional check for values that look like personal data
                    if not self.looks_like_personal_data(str(value)):
                        filtered_item[key] = value
                    else:
                        self.logger.info(f&amp;quot;Filtered potential personal data: {key}&amp;quot;)
            
            return filtered_item
        
        return item
    
    def looks_like_personal_data(self, text: str) -&amp;gt; bool:
        &amp;quot;&amp;quot;&amp;quot;Heuristic check for personal data patterns&amp;quot;&amp;quot;&amp;quot;
        import re
        
        patterns = [
            r&amp;#39;\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b&amp;#39;,  # Email
            r&amp;#39;\b\d{3}-\d{2}-\d{4}\b&amp;#39;,  # SSN
            r&amp;#39;\b\d{3}-\d{3}-\d{4}\b&amp;#39;,  # Phone
            r&amp;#39;\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b&amp;#39;,  # Credit card
        ]
        
        for pattern in patterns:
            if re.search(pattern, text):
                return True
        
        return False
    
    def generate_compliance_report(self) -&amp;gt; Dict:
        &amp;quot;&amp;quot;&amp;quot;Generate compliance status report&amp;quot;&amp;quot;&amp;quot;
        return {
            &amp;#39;robots_txt_domains_checked&amp;#39;: len(self.robots_cache),
            &amp;#39;custom_rate_limits&amp;#39;: len(self.rate_limits),
            &amp;#39;compliance_rules&amp;#39;: self.compliance_rules,
            &amp;#39;timestamp&amp;#39;: time.time()
        }

# Compliance middleware
class ComplianceMiddleware:
    &amp;quot;&amp;quot;&amp;quot;Scrapy middleware for compliance enforcement&amp;quot;&amp;quot;&amp;quot;
    
    def __init__(self):
        self.compliance_manager = ComplianceManager()
        self.last_request_time = {}
    
    def process_request(self, request, spider):
        # Validate compliance
        if not self.compliance_manager.validate_request(request):
            raise IgnoreRequest(f&amp;quot;Request blocked by compliance rules: {request.url}&amp;quot;)
        
        # Enforce rate limiting
        domain = urlparse(request.url).netloc
        rate_limit = self.compliance_manager.get_rate_limit(domain)
        
        if domain in self.last_request_time:
            time_since_last = time.time() - self.last_request_time[domain]
            if time_since_last &amp;lt; rate_limit:
                sleep_time = rate_limit - time_since_last
                spider.logger.debug(f&amp;quot;Rate limiting: sleeping {sleep_time:.2f}s for {domain}&amp;quot;)
                time.sleep(sleep_time)
        
        self.last_request_time[domain] = time.time()
    
    def process_item(self, item, spider):
        # Filter personal data
        filtered_item = self.compliance_manager.filter_personal_data(dict(item))
        return filtered_item
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;This part covered the operational side: stealth middleware and fingerprint consistency, proxy pools with health checks, distributed workers on Scrapy-Redis, monitoring with alerting, and a compliance layer that enforces robots.txt and rate limits.&lt;/p&gt;
&lt;h3&gt;What&amp;#39;s Next?&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;/tutorials/web-scraping-scrapy-part-4&quot;&gt;Part 4: Data Processing and Storage&lt;/a&gt; covers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Advanced data cleaning and validation&lt;/li&gt;
&lt;li&gt;Multiple storage backends (MongoDB, PostgreSQL, Elasticsearch)&lt;/li&gt;
&lt;li&gt;Real-time data pipelines&lt;/li&gt;
&lt;li&gt;Data quality monitoring&lt;/li&gt;
&lt;li&gt;ETL processes and data warehousing&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Practice Exercise&lt;/h3&gt;
&lt;p&gt;Build a distributed scraper that:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Uses anti-detection techniques to scrape a major e-commerce site&lt;/li&gt;
&lt;li&gt;Scales across multiple workers with Redis coordination&lt;/li&gt;
&lt;li&gt;Implements monitoring and alerting&lt;/li&gt;
&lt;li&gt;Ensures legal compliance and ethical scraping&lt;/li&gt;
&lt;li&gt;Handles millions of products with proper rate limiting&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item><item><title>Scrapy, part 2: JavaScript, forms, and dynamic pages</title><link>https://tamangsurendra.com.np/blog/web-scraping-scrapy-part-2/</link><guid isPermaLink="true">https://tamangsurendra.com.np/blog/web-scraping-scrapy-part-2/</guid><description>Rendering JavaScript, logging in, and going straight to the AJAX endpoints that actually hold the data.</description><pubDate>Sun, 22 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;JavaScript, forms, and dynamic pages&lt;/h1&gt;
&lt;p&gt;Part 1 dealt with sites that hand you clean HTML. This part is about the ones that do not: pages that render with JavaScript, data hidden behind a login, and content that only appears after a form or a scroll.&lt;/p&gt;
&lt;p&gt;All the code is on GitHub: &lt;a href=&quot;https://github.com/SurendraTamang/scrapy-tutorial/tree/main/part-2-dynamic-pages&quot;&gt;scrapy-tutorial/part-2-dynamic-pages&lt;/a&gt;. Same as before, the spiders point at placeholder sites so the focus stays on the technique. Swap the domain and selectors to run them for real.&lt;/p&gt;
&lt;h2&gt;First, decide how to get the data&lt;/h2&gt;
&lt;p&gt;When a page renders with JavaScript, most people jump straight to a headless browser. That is usually the slowest option. There are three routes, and I try them in this order.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/blog/scrapy/part-2-routes.png&quot; alt=&quot;Three ways to get data off a JavaScript page: render it with Splash, replay the AJAX API, or read embedded JSON&quot;&gt;&lt;/p&gt;
&lt;p&gt;Reach for a browser last, not first. If the data arrives over an API call, hitting that endpoint is faster and far more stable than rendering the whole page. So before writing any Splash code, open the network tab and look for the request that returns the data.&lt;/p&gt;
&lt;h2&gt;Rendering with Splash&lt;/h2&gt;
&lt;p&gt;Full file: &lt;a href=&quot;https://github.com/SurendraTamang/scrapy-tutorial/blob/main/part-2-dynamic-pages/webscraper/spiders/spa_spider.py&quot;&gt;webscraper/spiders/spa_spider.py&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;When the data really is built in the browser, Splash runs the JavaScript for you. It takes a small Lua script that says what to do on the page: load it, wait for the content, scroll, click. You send a &lt;code&gt;SplashRequest&lt;/code&gt; instead of a normal one.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;yield SplashRequest(
    url=url,
    callback=self.parse,
    args={&amp;#39;lua_source&amp;#39;: LUA_SOURCE, &amp;#39;timeout&amp;#39;: 30, &amp;#39;wait&amp;#39;: 5},
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Lua part is where you wait for the right element and interact with the page:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-lua&quot;&gt;splash:go(args.url)
splash:wait(3)
splash:runjs(&amp;quot;window.scrollTo(0, document.body.scrollHeight);&amp;quot;)

local load_more = splash:select(&amp;#39;.load-more-btn&amp;#39;)
if load_more then
    load_more:click()
    splash:wait(3)
end
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Splash needs Docker running: &lt;code&gt;docker run -p 8050:8050 scrapinghub/splash&lt;/code&gt;. The full file has the complete Lua script and the settings block to wire it in.&lt;/p&gt;
&lt;h2&gt;Logging in and holding the session&lt;/h2&gt;
&lt;p&gt;Full file: &lt;a href=&quot;https://github.com/SurendraTamang/scrapy-tutorial/blob/main/part-2-dynamic-pages/webscraper/spiders/login_spider.py&quot;&gt;webscraper/spiders/login_spider.py&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The trick with login forms is &lt;code&gt;FormRequest.from_response&lt;/code&gt;. It reads the existing form off the page, so hidden fields and CSRF tokens come along for free. You only add the fields you care about.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;return FormRequest.from_response(
    response,
    formdata={&amp;#39;username&amp;#39;: &amp;#39;you&amp;#39;, &amp;#39;password&amp;#39;: &amp;#39;secret&amp;#39;, &amp;#39;csrf_token&amp;#39;: csrf_token},
    callback=self.after_login,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Scrapy keeps the session cookies after that, so the pages you visit next are already logged in. Always confirm the login actually worked before scraping, by checking for something that only shows up when signed in.&lt;/p&gt;
&lt;h2&gt;Filling a search form&lt;/h2&gt;
&lt;p&gt;Full file: &lt;a href=&quot;https://github.com/SurendraTamang/scrapy-tutorial/blob/main/part-2-dynamic-pages/webscraper/spiders/form_spider.py&quot;&gt;webscraper/spiders/form_spider.py&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Search forms are the same idea. Carry over every field the page already has, then set your own search terms on top. Copying the existing fields matters, because servers often reject a submission that is missing a hidden token they expected.&lt;/p&gt;
&lt;h2&gt;Going straight to the API&lt;/h2&gt;
&lt;p&gt;Full file: &lt;a href=&quot;https://github.com/SurendraTamang/scrapy-tutorial/blob/main/part-2-dynamic-pages/webscraper/spiders/ajax_spider.py&quot;&gt;webscraper/spiders/ajax_spider.py&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;This is the route I reach for most. If the page loads its products from a JSON endpoint, you can skip the page and request that endpoint yourself. It returns clean structured data, no parsing of messy HTML, and it is fast.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;params = {&amp;#39;page&amp;#39;: page, &amp;#39;limit&amp;#39;: 20, &amp;#39;format&amp;#39;: &amp;#39;json&amp;#39;}
yield scrapy.Request(
    url=f&amp;#39;https://api-example.com/api/products?{urlencode(params)}&amp;#39;,
    callback=self.parse_api_response,
    headers={&amp;#39;Accept&amp;#39;: &amp;#39;application/json&amp;#39;, &amp;#39;X-Requested-With&amp;#39;: &amp;#39;XMLHttpRequest&amp;#39;},
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The one header that matters most is often &lt;code&gt;X-Requested-With: XMLHttpRequest&lt;/code&gt;, since some APIs check for it. Copy the headers the real page sends and you are usually in.&lt;/p&gt;
&lt;h2&gt;Infinite scroll&lt;/h2&gt;
&lt;p&gt;Full file: &lt;a href=&quot;https://github.com/SurendraTamang/scrapy-tutorial/blob/main/part-2-dynamic-pages/webscraper/spiders/infinite_scroll_spider.py&quot;&gt;webscraper/spiders/infinite_scroll_spider.py&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Infinite scroll is just more of the same two options. Either drive Splash to scroll and click until the end marker appears, or find the pagination API behind the scroll and page through it directly. The second is almost always the better deal.&lt;/p&gt;
&lt;h2&gt;Middleware: rotate and retry&lt;/h2&gt;
&lt;p&gt;Full file: &lt;a href=&quot;https://github.com/SurendraTamang/scrapy-tutorial/blob/main/part-2-dynamic-pages/webscraper/middlewares.py&quot;&gt;webscraper/middlewares.py&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Middleware lets you change every request in one place. Two are worth having early: one that rotates the user agent, and one that backs off and retries when a site starts returning 429s or 503s.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def process_response(self, request, response, spider):
    if response.status in (429, 502, 503, 504):
        retries = request.meta.get(&amp;#39;retry_times&amp;#39;, 0)
        if retries &amp;lt; self.max_retry_times:
            time.sleep(self.initial_delay * (2 ** retries))
            retry = request.copy()
            retry.meta[&amp;#39;retry_times&amp;#39;] = retries + 1
            retry.dont_filter = True
            return retry
    return response
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The delay doubles each attempt, so a struggling site gets room to breathe instead of a second wave of requests. The file also has proxy rotation and header rotation.&lt;/p&gt;
&lt;h2&gt;Handy selector helpers&lt;/h2&gt;
&lt;p&gt;Full file: &lt;a href=&quot;https://github.com/SurendraTamang/scrapy-tutorial/blob/main/part-2-dynamic-pages/webscraper/utils/selectors.py&quot;&gt;webscraper/utils/selectors.py&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;A few small helpers save repetition: one that tries a list of selectors and returns the first hit, one that turns an HTML table into a list of dicts, and one that digs a JSON object out of a script tag.&lt;/p&gt;
&lt;h2&gt;What I reach for first&lt;/h2&gt;
&lt;p&gt;Check the network tab before writing a line of Splash. Nine times out of ten the data is sitting in a JSON response, and reading that is faster to write, faster to run, and far less likely to break. Save the headless browser for the sites that genuinely build their content in the page and expose no API.&lt;/p&gt;
&lt;h2&gt;What is next&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;/blog/web-scraping-scrapy-part-3&quot;&gt;Part 3&lt;/a&gt; is where sites start fighting back: anti-detection, distributed crawls with Scrapy-Redis, and the monitoring you need once a crawl runs across many machines.&lt;/p&gt;
</content:encoded></item><item><title>Scrapy, part 1: fundamentals and your first spider</title><link>https://tamangsurendra.com.np/blog/web-scraping-scrapy-part-1/</link><guid isPermaLink="true">https://tamangsurendra.com.np/blog/web-scraping-scrapy-part-1/</guid><description>Scrapy&apos;s moving parts, selectors, and items, ending with a first spider that pulls real data and does not fall over on the first layout change.</description><pubDate>Sat, 21 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Scrapy fundamentals and your first spider&lt;/h1&gt;
&lt;p&gt;This is the first of five posts on Scrapy. By the end you will have a small project that works: a spider that walks an e-commerce catalog, item loaders that clean the data on the way in, and pipelines that check it and save it.&lt;/p&gt;
&lt;p&gt;All the code is on GitHub: &lt;a href=&quot;https://github.com/SurendraTamang/scrapy-tutorial/tree/main/part-1-fundamentals&quot;&gt;scrapy-tutorial/part-1-fundamentals&lt;/a&gt;. I link the exact file at the top of each section, so you can read the finished version any time the post gets into the weeds.&lt;/p&gt;
&lt;p&gt;One thing worth saying up front. The spider points at &lt;code&gt;example-store.com&lt;/code&gt;, which is a placeholder. The selectors show the shape of the work, but that site does not exist, so a plain &lt;code&gt;scrapy crawl&lt;/code&gt; will not return rows. Swap the domain and selectors for a site you actually want, and it runs. The tests use sample HTML, so they pass with no network at all.&lt;/p&gt;
&lt;p&gt;Here is the whole flow before we build any of it. A request goes in at the top, and clean data comes out the bottom.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/blog/scrapy/part-1-flow.png&quot; alt=&quot;How data flows through a Scrapy spider: start URLs, parse, parse_product, item loader, pipelines, then JSON&quot;&gt;&lt;/p&gt;
&lt;h2&gt;The series&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Part 1: fundamentals (this one)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/blog/web-scraping-scrapy-part-2&quot;&gt;Part 2: advanced scraping techniques&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/blog/web-scraping-scrapy-part-3&quot;&gt;Part 3: anti-detection and scaling&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/blog/web-scraping-scrapy-part-4&quot;&gt;Part 4: data processing and storage&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/blog/web-scraping-scrapy-part-5&quot;&gt;Part 5: production deployment&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;What scraping actually is&lt;/h2&gt;
&lt;p&gt;A scraper is a program that fetches web pages and pulls out the specific fields you care about. Nothing more mysterious than that. You point it at a page, tell it &amp;quot;the price lives in this element,&amp;quot; and it hands you the number.&lt;/p&gt;
&lt;p&gt;People reach for it in a few common places. Online stores want to track competitor prices, watch stock levels, or build a product catalog from several sources. Real estate teams collect listings and watch how prices move. Newsrooms aggregate stories and follow coverage of a topic. Researchers gather papers or public posts to study at scale. The technique is the same every time. Only the fields change.&lt;/p&gt;
&lt;h2&gt;The rules before the code&lt;/h2&gt;
&lt;p&gt;I have had this bite people, so read this part even though it is not code.&lt;/p&gt;
&lt;p&gt;Check &lt;code&gt;robots.txt&lt;/code&gt; and read the site&amp;#39;s terms before you start. Scrape slowly enough that you are not a burden on the server, because a scraper that hammers a site is both rude and easy to block. Be careful with anything personal, and stick to data that is public. When you plan a large crawl, it is often worth a short email to the site owner first. None of this is legal advice, but it keeps you on the right side of most trouble.&lt;/p&gt;
&lt;p&gt;Scrapy helps here. It reads &lt;code&gt;robots.txt&lt;/code&gt; for you when you ask it to, and it can throttle itself so you do not have to babysit the request rate.&lt;/p&gt;
&lt;h2&gt;Setting up&lt;/h2&gt;
&lt;p&gt;Start with a clean virtual environment so this project&amp;#39;s packages do not leak into anything else.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;python -m venv .venv
source .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install --upgrade pip
pip install scrapy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then let Scrapy lay out the project for you and add a couple of folders for output.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;scrapy startproject webscraper
cd webscraper
mkdir -p data/raw data/processed logs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What Scrapy generates looks like this. The files that matter for now are &lt;code&gt;items.py&lt;/code&gt;, &lt;code&gt;pipelines.py&lt;/code&gt;, &lt;code&gt;settings.py&lt;/code&gt;, and the &lt;code&gt;spiders&lt;/code&gt; folder.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;webscraper/
├── scrapy.cfg
└── webscraper/
    ├── items.py          # what a record looks like
    ├── pipelines.py      # what happens after extraction
    ├── settings.py       # how the crawl behaves
    └── spiders/          # the crawlers themselves
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Say what you want: items&lt;/h2&gt;
&lt;p&gt;Full file: &lt;a href=&quot;https://github.com/SurendraTamang/scrapy-tutorial/blob/main/part-1-fundamentals/webscraper/items.py&quot;&gt;webscraper/items.py&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;An item is the shape of one record. Defining it up front keeps your fields consistent and gives you one place to clean each value. The nice part of Scrapy items is the processors. &lt;code&gt;MapCompose&lt;/code&gt; runs a cleaning function on the way in, and &lt;code&gt;TakeFirst&lt;/code&gt; grabs the first non-empty match so you do not carry lists around.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import scrapy
from itemloaders.processors import TakeFirst, MapCompose
from w3lib.html import remove_tags


def clean_price(value):
    if value:
        cleaned = &amp;#39;&amp;#39;.join(c for c in value if c.isdigit() or c == &amp;#39;.&amp;#39;)
        try:
            return float(cleaned)
        except ValueError:
            return None
    return None


class ProductItem(scrapy.Item):
    name = scrapy.Field(output_processor=TakeFirst())
    price = scrapy.Field(
        input_processor=MapCompose(clean_price),
        output_processor=TakeFirst(),
    )
    url = scrapy.Field(output_processor=TakeFirst())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The full file carries the rest of the fields, like brand, rating, images, and a timestamp. The pattern repeats, so once you have seen three fields you have seen them all.&lt;/p&gt;
&lt;h2&gt;The spider&lt;/h2&gt;
&lt;p&gt;Full file: &lt;a href=&quot;https://github.com/SurendraTamang/scrapy-tutorial/blob/main/part-1-fundamentals/webscraper/spiders/ecommerce_spider.py&quot;&gt;webscraper/spiders/ecommerce_spider.py&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The spider does two jobs. It reads a category page and collects the links to each product, and it reads a product page and pulls out the fields. Splitting those into two methods keeps each one easy to follow.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def parse(self, response):
    for link in response.css(&amp;#39;.product-item a::attr(href)&amp;#39;).getall():
        yield response.follow(link, callback=self.parse_product)

    next_page = response.css(&amp;#39;.pagination .next::attr(href)&amp;#39;).get()
    if next_page:
        yield response.follow(next_page, callback=self.parse)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;parse&lt;/code&gt; follows every product link, then looks for a &amp;quot;next&amp;quot; link and follows that too. That second &lt;code&gt;yield&lt;/code&gt; is the whole of pagination. Scrapy keeps calling &lt;code&gt;parse&lt;/code&gt; on each new page until there is no next link left.&lt;/p&gt;
&lt;p&gt;The product page is where the extraction happens. Two habits here have saved me more times than I can count.&lt;/p&gt;
&lt;p&gt;The first is fallback selectors. Sites change their markup, and often more than one layout is live at once. So I give the loader more than one selector for the important fields and let it take the first one that hits.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;loader = ItemLoader(item=ProductItem(), response=response)
loader.add_css(&amp;#39;name&amp;#39;, &amp;#39;h1.product-title::text&amp;#39;)
loader.add_css(&amp;#39;name&amp;#39;, &amp;#39;.product-name::text&amp;#39;)     # fallback
loader.add_css(&amp;#39;price&amp;#39;, &amp;#39;.price-current::text&amp;#39;)
loader.add_css(&amp;#39;price&amp;#39;, &amp;#39;.current-price::text&amp;#39;)   # fallback
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The second is JSON-LD. A lot of stores ship a clean block of structured data in the page for search engines. When it is there, it is far more reliable than reading text out of &lt;code&gt;div&lt;/code&gt;s, so the spider reads it and fills in anything the selectors missed.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;scripts = response.xpath(&amp;#39;//script[@type=&amp;quot;application/ld+json&amp;quot;]/text()&amp;#39;).getall()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Clean it on the way in: item loaders&lt;/h2&gt;
&lt;p&gt;The loader is the bridge between the page and the item. You feed it selectors, and the item&amp;#39;s processors clean each value as it arrives. That means your spider stays about &amp;quot;where is the data&amp;quot; and the cleaning lives in one place instead of being smeared across the parse method. By the time you call &lt;code&gt;loader.load_item()&lt;/code&gt;, the record is already tidy.&lt;/p&gt;
&lt;h2&gt;Pipelines: check, dedupe, store&lt;/h2&gt;
&lt;p&gt;Full file: &lt;a href=&quot;https://github.com/SurendraTamang/scrapy-tutorial/blob/main/part-1-fundamentals/webscraper/pipelines.py&quot;&gt;webscraper/pipelines.py&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Every item the spider yields runs through the pipelines in order. This is where you catch bad records before they reach your files. The project has four small pipelines, each with one job.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class ValidationPipeline:
    def process_item(self, item, spider):
        adapter = ItemAdapter(item)
        for field in (&amp;#39;name&amp;#39;, &amp;#39;url&amp;#39;):
            if not adapter.get(field):
                raise DropItem(f&amp;#39;Missing required field: {field}&amp;#39;)
        return item
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first checks that the required fields are there and drops the record if they are not. The next filters out duplicates using the name and URL. The third writes the results to a JSON file. The last counts what came through and prints a short summary when the crawl ends, which is handy for spotting a run that quietly scraped nothing.&lt;/p&gt;
&lt;p&gt;You switch them on in &lt;code&gt;settings.py&lt;/code&gt;. The numbers set the order, low to high.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;ITEM_PIPELINES = {
    &amp;#39;webscraper.pipelines.ValidationPipeline&amp;#39;: 300,
    &amp;#39;webscraper.pipelines.DuplicationFilterPipeline&amp;#39;: 400,
    &amp;#39;webscraper.pipelines.JsonWriterPipeline&amp;#39;: 500,
    &amp;#39;webscraper.pipelines.StatisticsPipeline&amp;#39;: 600,
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Settings worth knowing&lt;/h2&gt;
&lt;p&gt;Full file: &lt;a href=&quot;https://github.com/SurendraTamang/scrapy-tutorial/blob/main/part-1-fundamentals/webscraper/settings.py&quot;&gt;webscraper/settings.py&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Most of the defaults are fine. A few settings are worth setting on purpose from day one.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;ROBOTSTXT_OBEY = True
AUTOTHROTTLE_ENABLED = True     # slow down when the site does
DOWNLOAD_DELAY = 1              # a floor between requests
RETRY_TIMES = 3                 # retry the usual transient failures
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;AutoThrottle is the one I would not skip. It watches how fast the site responds and backs off on its own, which keeps you polite without hand-tuning delays.&lt;/p&gt;
&lt;h2&gt;Running it&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;scrapy crawl ecommerce                 # run it
scrapy crawl ecommerce -o products.json  # run and dump to a file
scrapy crawl ecommerce -L DEBUG        # run with loud logging
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is also a small &lt;a href=&quot;https://github.com/SurendraTamang/scrapy-tutorial/blob/main/part-1-fundamentals/scripts/run_spider.py&quot;&gt;run_spider.py&lt;/a&gt; if you would rather start the crawl from Python and pass settings in code.&lt;/p&gt;
&lt;h2&gt;Test it without hitting the network&lt;/h2&gt;
&lt;p&gt;Full file: &lt;a href=&quot;https://github.com/SurendraTamang/scrapy-tutorial/blob/main/part-1-fundamentals/tests/test_ecommerce_spider.py&quot;&gt;tests/test_ecommerce_spider.py&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;You do not need a live site to test parsing. Build a fake response from a string of HTML, run it through the spider, and check what comes out. These tests are fast and they do not depend on a site staying up.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_parse_product(self):
    html = &amp;#39;&amp;lt;h1 class=&amp;quot;product-title&amp;quot;&amp;gt;Test Product&amp;lt;/h1&amp;gt;&amp;lt;span class=&amp;quot;price-current&amp;quot;&amp;gt;$99.99&amp;lt;/span&amp;gt;&amp;#39;
    response = HtmlResponse(url=&amp;#39;http://example.com/p/1&amp;#39;, body=html.encode())
    item = list(self.spider.parse_product(response))[0]
    assert item[&amp;#39;name&amp;#39;] == &amp;#39;Test Product&amp;#39;
    assert item[&amp;#39;price&amp;#39;] == 99.99
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice the price comes out as the number &lt;code&gt;99.99&lt;/code&gt;, not the string &lt;code&gt;$99.99&lt;/code&gt;. That is &lt;code&gt;clean_price&lt;/code&gt; from the item doing its job before the value ever lands in the record.&lt;/p&gt;
&lt;h2&gt;A few things I learned the hard way&lt;/h2&gt;
&lt;p&gt;Give your important fields a fallback selector. The extra line costs you nothing and saves a 2am fix when the site ships a redesign.&lt;/p&gt;
&lt;p&gt;Read JSON-LD when it is there. It is cleaner than scraping visible text, and it tends to survive layout changes that break your selectors.&lt;/p&gt;
&lt;p&gt;Turn on AutoThrottle and keep a delay. A fast scraper that gets you blocked is slower than a polite one that keeps running.&lt;/p&gt;
&lt;p&gt;Watch the item count at the end of a run. A crawl that finishes with zero items is usually a changed selector, not an empty site.&lt;/p&gt;
&lt;h2&gt;What is next&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;/blog/web-scraping-scrapy-part-2&quot;&gt;Part 2&lt;/a&gt; moves past the basics: pages that need JavaScript, logging in, forms, and the AJAX calls behind dynamic content.&lt;/p&gt;
&lt;p&gt;If you want to try this yourself first, clone the &lt;a href=&quot;https://github.com/SurendraTamang/scrapy-tutorial&quot;&gt;repo&lt;/a&gt;, point the spider at a simple store you like, and get the name and price out. That one exercise teaches more than reading ever will.&lt;/p&gt;
</content:encoded></item></channel></rss>