ZyVOP Logo
Content That Connects
SeriesAI NewsCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article
  • Newsletter

Company

  • About Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeScrapingPython Scraping Patterns: 15 Real Problems, 15 Working Solutions
Scraping
👍2

Python Scraping Patterns: 15 Real Problems, 15 Working Solutions

15 real Python scraping problems and their working solutions — lazy loading, infinite scroll, login walls, JavaScript data, CAPTCHAs, rate limits, and more. Copy-paste ready code.

#common web scraping problems python#python scraping troubleshooting#web scraping edge cases python#python scraper not working fix#web scraping patterns cookbook
Z
ZyVOP

Senior Developer

July 23, 2026
15 min read
4 views
Python Scraping Patterns: 15 Real Problems, 15 Working Solutions

Every scraping project hits the same walls. You write what looks like correct code, point it at a real website, and something doesn't work. The element isn't there. The data is missing. The response is empty. The selector returns None.

This is a reference guide. Fifteen patterns you will hit. Fifteen solutions that work. Bookmark it, come back when you're stuck.


Pattern 1: The Element You Need Doesn't Exist in BeautifulSoup

The problem: soup.select_one(".price") returns None even though the element is clearly visible in your browser.

Why it happens: The page uses JavaScript to render the content. BeautifulSoup parses the initial HTML response from the server — before JavaScript runs. Any element injected by JavaScript after page load is invisible to BeautifulSoup.

The signal: Open the page, right-click the element, click "View Page Source." If the element isn't in the raw source, it's JavaScript-rendered.

Solution A — Use Playwright to get the rendered HTML:

from playwright.sync_api import sync_playwright
from bs4 import BeautifulSoup

def get_rendered_html(url: str) -> str:
    """Fetch fully rendered HTML after JavaScript executes."""
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page    = browser.new_page()
        page.goto(url, wait_until="networkidle")  # Wait for JS to finish
        html = page.content()   # This is the post-JS DOM
        browser.close()
    return html

html = get_rendered_html("https://js-heavy-site.com/products")
soup = BeautifulSoup(html, "lxml")
price = soup.select_one(".price")   # Now it finds it

Solution B — Find the XHR/fetch call that loads the data (faster, cheaper):

  1. Open DevTools → Network tab → filter by XHR/Fetch

  2. Reload the page

  3. Find the API call that returns JSON with your data

  4. Call that endpoint directly — no browser needed

import httpx

# Instead of scraping the rendered page, call the API the page uses
r = httpx.get(
    "https://site.com/api/products?category=electronics",
    headers={"User-Agent": "Mozilla/5.0 Chrome/120"}
)
data = r.json()   # Clean structured data, no parsing needed

When to use B over A: Always try B first. It's 10–50x faster and requires no browser.


Pattern 2: The Data Is in a <script> Tag as JSON

The problem: The data you want is on the page but not in HTML elements — it's embedded in a JavaScript variable inside a <script> tag.

Why it happens: Modern frameworks (Next.js, Nuxt, SvelteKit) embed server-side data as JSON in <script id="__NEXT_DATA__"> or similar tags for client-side hydration.

Solution:

import httpx
import json
import re
from bs4 import BeautifulSoup

def extract_next_data(url: str) -> dict:
    """Extract data from Next.js's __NEXT_DATA__ script tag."""
    r    = httpx.get(url, headers={"User-Agent": "Mozilla/5.0 Chrome/120"})
    soup = BeautifulSoup(r.text, "lxml")

    # Method 1: __NEXT_DATA__ (Next.js)
    script = soup.find("script", {"id": "__NEXT_DATA__"})
    if script:
        return json.loads(script.string)

    # Method 2: Variable assignment pattern — var data = {...}
    for script in soup.find_all("script"):
        text = script.string or ""
        # Match: var productData = {...} or window.__data = {...}
        match = re.search(
            r'(?:var\s+\w+|window\.\w+)\s*=\s*(\{.+?\});',
            text,
            re.DOTALL
        )
        if match:
            try:
                return json.loads(match.group(1))
            except json.JSONDecodeError:
                continue

    return {}

data = extract_next_data("https://nextjs-site.com/product/123")
# Navigate the JSON tree to your data
product = data.get("props", {}).get("pageProps", {}).get("product", {})
print(product.get("price"))

Pattern 3: Infinite Scroll Pages

The problem: A page loads 20 items. Scrolling loads 20 more. You need all of them. BeautifulSoup only sees the initial 20.

Why it happens: The site uses Intersection Observer API or scroll event listeners to trigger additional XHR requests as you scroll.

Solution A — Intercept the pagination API (preferred):

import httpx
import asyncio

# Infinite scroll almost always calls an API endpoint with page/offset params
# Find it in DevTools → Network → XHR → scroll the page → watch which call fires

async def scrape_all_via_api(base_url: str, limit: int = 20) -> list[dict]:
    all_items = []
    offset    = 0

    async with httpx.AsyncClient() as client:
        while True:
            r    = await client.get(base_url, params={"limit": limit, "offset": offset})
            data = r.json()

            items = data.get("items") or data.get("results") or data.get("data", [])
            if not items:
                break

            all_items.extend(items)
            print(f"  Fetched {len(all_items)} total")

            if len(items) < limit:   # Last page
                break
            offset += limit

    return all_items

items = asyncio.run(scrape_all_via_api("https://api.site.com/feed"))

Solution B — Playwright scroll automation:

from playwright.sync_api import sync_playwright
import time

def scrape_infinite_scroll(url: str, max_scrolls: int = 20) -> list[str]:
    """Scroll down, wait for new content, collect all visible items."""
    with sync_playwright() as p:
        browser  = p.chromium.launch(headless=True)
        page     = browser.new_page()
        page.goto(url, wait_until="networkidle")

        seen_items = set()
        all_text   = []

        for _ in range(max_scrolls):
            # Collect current items
            items = page.locator(".item-card").all()
            for item in items:
                text = item.inner_text()
                if text not in seen_items:
                    seen_items.add(text)
                    all_text.append(text)

            # Scroll to bottom
            page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
            time.sleep(2)   # Wait for new items to load

            # Check if height changed (new content loaded)
            new_height = page.evaluate("document.body.scrollHeight")

        browser.close()
    return all_text

Pattern 4: Login Walls — Scraping Content That Requires Authentication

The problem: The content you need is behind a login. Navigating to the page redirects to a login form.

Solution — Save and reuse session cookies:

import httpx
import json
from pathlib import Path

COOKIES_FILE = Path("session.json")

def login_and_save(login_url: str, username: str, password: str,
                   username_field: str = "email",
                   password_field: str = "password"):
    """
    Log in via form POST and save session cookies.
    Works for most standard HTML login forms.
    """
    with httpx.Client(follow_redirects=True) as client:
        # First GET the login page (get CSRF token if present)
        login_page = client.get(login_url)
        soup       = BeautifulSoup(login_page.text, "lxml")

        # Extract CSRF token if present
        csrf_input = soup.find("input", {"name": ["csrf_token", "_token",
                                                   "authenticity_token"]})
        csrf_value = csrf_input.get("value", "") if csrf_input else ""

        # POST login credentials
        client.post(login_url, data={
            username_field: username,
            password_field: password,
            "csrf_token":   csrf_value,   # Include CSRF if found
        })

        # Save cookies
        cookies_dict = dict(client.cookies)
        with open(COOKIES_FILE, "w") as f:
            json.dump(cookies_dict, f)
        print(f"Saved {len(cookies_dict)} session cookies")


def scrape_with_session(url: str) -> str:
    """Make requests using a saved login session."""
    if not COOKIES_FILE.exists():
        raise RuntimeError("No session found. Run login_and_save() first.")

    with open(COOKIES_FILE) as f:
        cookies = json.load(f)

    r = httpx.get(url, cookies=cookies,
                  headers={"User-Agent": "Mozilla/5.0 Chrome/120"},
                  follow_redirects=True)

    # Detect if we got redirected to login page (session expired)
    if "login" in r.url.path.lower() or "sign-in" in r.url.path.lower():
        raise RuntimeError("Session expired. Re-login required.")

    return r.text

Pattern 5: Rate Limited — Getting 429 Too Many Requests

The problem: Your scraper works for the first 20–50 requests then starts getting 429 Too Many Requests.

Why it happens: The server tracks request frequency per IP and throttles clients that exceed a threshold.

Solution — Adaptive backoff with jitter:

import asyncio
import random
import httpx
from typing import Callable

class AdaptiveRateLimiter:
    """
    Automatically adjusts request delay based on response codes.
    Slows down when throttled, speeds up when succeeding.
    """

    def __init__(self, min_delay: float = 0.5, max_delay: float = 30.0):
        self.delay     = min_delay
        self.min_delay = min_delay
        self.max_delay = max_delay

    async def wait(self):
        # Add jitter: ±20% of current delay
        jitter = self.delay * random.uniform(0.8, 1.2)
        await asyncio.sleep(jitter)

    def on_success(self):
        # Gradually speed up when things work
        self.delay = max(self.min_delay, self.delay * 0.95)

    def on_rate_limit(self, retry_after: int = None):
        # Back off hard when throttled
        self.delay = min(self.max_delay, self.delay * 2.0)
        print(f"  Rate limited. New delay: {self.delay:.1f}s")

    def on_error(self):
        # Moderate backoff on errors
        self.delay = min(self.max_delay, self.delay * 1.5)


async def scrape_with_rate_limiting(urls: list[str]) -> list[dict]:
    limiter = AdaptiveRateLimiter(min_delay=1.0)
    results = []

    async with httpx.AsyncClient() as client:
        for url in urls:
            await limiter.wait()

            try:
                r = await client.get(url, timeout=15)

                if r.status_code == 429:
                    retry_after = int(r.headers.get("Retry-After", 30))
                    limiter.on_rate_limit(retry_after)
                    await asyncio.sleep(retry_after)
                    continue   # Retry this URL next iteration

                elif r.status_code == 200:
                    limiter.on_success()
                    results.append({"url": url, "html": r.text})

                else:
                    limiter.on_error()

            except httpx.TimeoutException:
                limiter.on_error()
                print(f"  Timeout: {url}")

    return results

Pattern 6: Paginated Results with No "Next" Link

The problem: A site has pagination but no visible "next page" link — just a page number in the URL.

Solution — URL pattern detection:

import httpx
import asyncio
import re
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse

def detect_pagination_pattern(url: str, html: str) -> dict | None:
    """
    Auto-detect pagination URL pattern from a page.
    Returns dict with info needed to generate all page URLs.
    """
    soup   = BeautifulSoup(html, "lxml")
    parsed = urlparse(url)

    # Strategy 1: Look for numbered page links
    page_links = soup.select("a[href]")
    page_numbers = set()
    for link in page_links:
        href = link.get("href", "")
        # Match ?page=N or /page/N or &p=N patterns
        match = re.search(r'[?&/](?:page|p|pg)=?/?(\d+)', href)
        if match:
            page_numbers.add(int(match.group(1)))

    if page_numbers:
        max_page = max(page_numbers)
        return {"type": "query_param", "max_page": max_page}

    # Strategy 2: Look for total count + items per page
    count_el = soup.select_one(".total-results, .result-count, [data-total]")
    if count_el:
        count_text = count_el.get_text()
        count_match = re.search(r"([\d,]+)", count_text)
        if count_match:
            total = int(count_match.group(1).replace(",", ""))
            return {"type": "offset", "total": total}

    return None


async def scrape_all_pages(base_url: str, page_param: str = "page",
                            items_per_page: int = 20) -> list[str]:
    """Scrape all pages of a paginated result set."""
    all_htmls = []

    # Fetch first page and detect pagination
    async with httpx.AsyncClient() as client:
        r    = await client.get(base_url)
        all_htmls.append(r.text)
        info = detect_pagination_pattern(base_url, r.text)

        if not info:
            return all_htmls   # Only one page

        max_page = info.get("max_page") or (info.get("total", 0) // items_per_page + 1)

        # Build and fetch all remaining pages
        parsed = urlparse(base_url)
        for page_num in range(2, max_page + 1):
            params = parse_qs(parsed.query)
            params[page_param] = [str(page_num)]
            new_query = urlencode(params, doseq=True)
            page_url  = urlunparse(parsed._replace(query=new_query))

            r = await client.get(page_url)
            all_htmls.append(r.text)
            await asyncio.sleep(1.0)
            print(f"  Page {page_num}/{max_page}")

    return all_htmls

Pattern 7: Downloading Files (PDFs, CSVs, Images) at Scale

The problem: You need to download thousands of files linked from scraped pages — annual reports, product images, data exports.

Solution — Async parallel downloader with resume support:

import asyncio
import httpx
import hashlib
from pathlib import Path
from tqdm.asyncio import tqdm

DOWNLOAD_DIR = Path("downloads")
DOWNLOAD_DIR.mkdir(exist_ok=True)
SEMAPHORE    = asyncio.Semaphore(10)   # 10 concurrent downloads

def local_path(url: str, extension: str = None) -> Path:
    """Generate a stable local filename from a URL."""
    url_hash = hashlib.md5(url.encode()).hexdigest()[:12]
    ext      = extension or Path(url.split("?")[0]).suffix or ".bin"
    return DOWNLOAD_DIR / f"{url_hash}{ext}"

async def download_file(client: httpx.AsyncClient, url: str,
                        force: bool = False) -> Path | None:
    """Download a single file. Skips if already downloaded (resume support)."""
    dest = local_path(url)

    if dest.exists() and not force:
        return dest   # Already downloaded — skip

    async with SEMAPHORE:
        try:
            async with client.stream("GET", url, timeout=30) as r:
                r.raise_for_status()
                # Use content-type to get the right extension
                content_type = r.headers.get("content-type", "")
                if ".pdf" not in str(dest) and "pdf" in content_type:
                    dest = local_path(url, ".pdf")

                with open(dest, "wb") as f:
                    async for chunk in r.aiter_bytes(8192):
                        f.write(chunk)
            return dest
        except Exception as e:
            print(f"  Failed {url}: {e}")
            return None


async def batch_download(urls: list[str]) -> list[Path]:
    """Download all URLs concurrently with progress bar."""
    headers = {"User-Agent": "Mozilla/5.0 Chrome/120"}
    results = []

    async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
        tasks   = [download_file(client, url) for url in urls]
        results = await tqdm.gather(*tasks, desc="Downloading", unit="file")

    successful = [r for r in results if r]
    print(f"\nDownloaded {len(successful)}/{len(urls)} files to {DOWNLOAD_DIR}")
    return successful

# Example: download all PDFs from a page
pdf_urls = [
    "https://example.com/report-2024.pdf",
    "https://example.com/report-2023.pdf",
    "https://example.com/annual-2022.pdf",
]

paths = asyncio.run(batch_download(pdf_urls))

Pattern 8: Scraping Tables with Merged Cells

The problem: pd.read_html() works on simple tables but returns garbled data when rows have colspan or rowspan attributes.

Solution — Manual cell expansion:

def parse_table_with_spans(table_element) -> list[list[str]]:
    """
    Parse an HTML table that contains rowspan/colspan cells correctly.
    Returns a rectangular 2D list of strings.
    """
    # First pass: determine table dimensions
    rows     = table_element.find_all("tr")
    num_rows = len(rows)
    num_cols = 0
    for row in rows:
        cols = sum(
            int(cell.get("colspan", 1))
            for cell in row.find_all(["td", "th"])
        )
        num_cols = max(num_cols, cols)

    # Build grid filled with None
    grid = [[None] * num_cols for _ in range(num_rows)]

    for row_idx, row in enumerate(rows):
        col_idx = 0
        for cell in row.find_all(["td", "th"]):
            # Skip cells already filled by a previous rowspan
            while col_idx < num_cols and grid[row_idx][col_idx] is not None:
                col_idx += 1

            rowspan = int(cell.get("rowspan", 1))
            colspan = int(cell.get("colspan", 1))
            value   = cell.get_text(strip=True)

            # Fill all cells this span covers
            for r in range(row_idx, min(row_idx + rowspan, num_rows)):
                for c in range(col_idx, min(col_idx + colspan, num_cols)):
                    grid[r][c] = value

            col_idx += colspan

    return grid

# Usage
import pandas as pd
from bs4 import BeautifulSoup

html = """<table>
  <tr><th colspan="2">Product</th><th>Price</th></tr>
  <tr><td rowspan="2">Widget</td><td>Small</td><td>$9.99</td></tr>
  <tr><td>Large</td><td>$19.99</td></tr>
</table>"""

soup  = BeautifulSoup(html, "lxml")
table = soup.find("table")
grid  = parse_table_with_spans(table)
df    = pd.DataFrame(grid[1:], columns=grid[0])
print(df)
#   Product  Product  Price
# 0  Widget    Small  $9.99
# 1  Widget    Large $19.99

Pattern 9: Rotating User Agents Without Getting Detected

The problem: You rotate User-Agents but still get blocked because you use a Chrome UA with Firefox Accept headers — or vice versa.

Why it happens: Browsers send a characteristic set of headers that must be internally consistent. A Chrome User-Agent with Firefox headers is a red flag.

Solution — Consistent browser profiles:

import random

BROWSER_PROFILES = [
    {
        "User-Agent":        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
        "Accept":            "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
        "Accept-Language":   "en-US,en;q=0.9",
        "Accept-Encoding":   "gzip, deflate, br",
        "sec-ch-ua":         '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
        "sec-ch-ua-mobile":  "?0",
        "sec-ch-ua-platform":'"Windows"',
        "Sec-Fetch-Dest":    "document",
        "Sec-Fetch-Mode":    "navigate",
        "Sec-Fetch-Site":    "none",
        "Sec-Fetch-User":    "?1",
    },
    {
        "User-Agent":       "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
        "Accept":           "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
        "Accept-Language":  "en-US,en;q=0.9",
        "Accept-Encoding":  "gzip, deflate, br",
        "sec-ch-ua":        '"Not_A Brand";v="8", "Chromium";v="119", "Google Chrome";v="119"',
        "sec-ch-ua-mobile": "?0",
        "sec-ch-ua-platform":'"macOS"',
        "Sec-Fetch-Dest":   "document",
        "Sec-Fetch-Mode":   "navigate",
        "Sec-Fetch-Site":   "none",
    },
    {
        "User-Agent":       "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
        "Accept":           "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
        "Accept-Language":  "en-US,en;q=0.5",
        "Accept-Encoding":  "gzip, deflate, br",
        "Sec-Fetch-Dest":   "document",
        "Sec-Fetch-Mode":   "navigate",
        "Sec-Fetch-Site":   "none",
        "Sec-Fetch-User":   "?1",
        "TE":               "trailers",
        # Note: no sec-ch-ua — Firefox doesn't send it
    },
]

def get_random_profile() -> dict:
    """Return a complete, internally consistent browser profile."""
    return random.choice(BROWSER_PROFILES).copy()

Pattern 10: Extracting Data Across Multiple Formats on the Same Page

The problem: A single page contains data in mixed formats — some in an HTML table, some in JSON inside a script tag, some in plain text paragraphs.

Solution — Multi-strategy extraction with fallback chain:

import re, json
from bs4 import BeautifulSoup

def extract_price(html: str) -> float | None:
    """
    Try multiple strategies to find a price, in order of reliability.
    Returns the first successful result.
    """
    soup = BeautifulSoup(html, "lxml")

    # Strategy 1: Structured data (most reliable) — JSON-LD schema.org
    for script in soup.find_all("script", type="application/ld+json"):
        try:
            data = json.loads(script.string)
            if isinstance(data, list):
                data = data[0]
            price = (data.get("offers", {}).get("price")
                     or data.get("price"))
            if price:
                return float(str(price).replace(",", ""))
        except Exception:
            continue

    # Strategy 2: Meta tags (Open Graph / Twitter Cards)
    meta_price = soup.find("meta", property="product:price:amount")
    if meta_price:
        try:
            return float(meta_price.get("content", "").replace(",", ""))
        except ValueError:
            pass

    # Strategy 3: data-price attributes
    for el in soup.select("[data-price], [data-product-price]"):
        val = el.get("data-price") or el.get("data-product-price")
        if val:
            try:
                return float(str(val).replace(",", ""))
            except ValueError:
                continue

    # Strategy 4: CSS class patterns
    for selector in [".price", ".product-price", ".a-price-whole", "[itemprop='price']"]:
        el = soup.select_one(selector)
        if el:
            text = el.get_text(strip=True)
            match = re.search(r"[\d,]+\.?\d*", text.replace(",", ""))
            if match:
                try:
                    return float(match.group())
                except ValueError:
                    pass

    # Strategy 5: Regex on full page text (last resort)
    text = soup.get_text()
    match = re.search(r"(?:price|cost)[\s:$£€₹]*?([\d,]+\.?\d*)", text, re.IGNORECASE)
    if match:
        try:
            return float(match.group(1).replace(",", ""))
        except ValueError:
            pass

    return None   # Couldn't find a price

Pattern 11: Handling Cloudflare's Bot Protection Without a Proxy

The problem: curl_cffi with TLS impersonation works on most sites but still fails on Cloudflare-protected pages that use JavaScript challenges.

Solution — Detect the protection level and choose the right tool:

import httpx
from curl_cffi import requests as cffi_requests

def detect_protection_level(url: str) -> str:
    """
    Detect what level of bot protection a site uses.
    Returns: "none", "tls", "js_challenge", "captcha"
    """
    try:
        # First try with vanilla httpx
        r = httpx.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
        if r.status_code == 200 and len(r.text) > 1000:
            return "none"
        if r.status_code == 403:
            if "cloudflare" in r.text.lower():
                if "challenge" in r.text.lower() or "cf-browser-verification" in r.text:
                    return "js_challenge"
                return "tls"
            return "tls"
        if r.status_code == 429:
            return "rate_limit"
    except Exception:
        pass
    return "unknown"


async def fetch_adaptive(url: str) -> str | None:
    """
    Automatically pick the right fetching strategy based on protection level.
    """
    level = detect_protection_level(url)
    print(f"  Protection level: {level}")

    if level == "none":
        import httpx
        r = httpx.get(url)
        return r.text if r.status_code == 200 else None

    elif level == "tls":
        # TLS fingerprint impersonation fixes this
        r = cffi_requests.get(url, impersonate="chrome120", timeout=20)
        return r.text if r.status_code == 200 else None

    elif level == "js_challenge":
        # Need a full browser to solve JavaScript challenges
        from playwright.async_api import async_playwright
        from playwright_stealth import stealth_async
        async with async_playwright() as p:
            browser = await p.chromium.launch(headless=True)
            page    = await browser.new_page()
            await stealth_async(page)
            await page.goto(url, wait_until="networkidle")
            html = await page.content()
            await browser.close()
        return html

    else:
        print(f"  Cannot automatically handle protection level: {level}")
        return None

Pattern 12: Storing Scraped Data Without Duplicates

The problem: You re-run your scraper and end up with duplicate rows in your database or CSV.

Solution — URL-based upsert pattern:

import sqlite3
import hashlib
from datetime import datetime, timezone

def create_dedup_store(db_path: str = "scrape_store.db"):
    """Create a SQLite store with built-in deduplication."""
    conn = sqlite3.connect(db_path)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS items (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            url_hash    TEXT UNIQUE NOT NULL,   -- MD5 of URL for fast lookup
            url         TEXT NOT NULL,
            title       TEXT,
            price       REAL,
            data_json   TEXT,                   -- Raw JSON for extra fields
            first_seen  TEXT DEFAULT (datetime('now')),
            last_updated TEXT DEFAULT (datetime('now'))
        )
    """)
    conn.execute("""
        CREATE INDEX IF NOT EXISTS idx_url_hash ON items(url_hash)
    """)
    conn.commit()
    return conn


def upsert_item(conn: sqlite3.Connection, url: str,
                title: str = None, price: float = None,
                extra: dict = None):
    """
    Insert a new item or update if the URL already exists.
    Tracks first_seen and last_updated separately.
    """
    import json
    url_hash = hashlib.md5(url.encode()).hexdigest()
    now      = datetime.now(timezone.utc).isoformat()

    conn.execute("""
        INSERT INTO items (url_hash, url, title, price, data_json, first_seen, last_updated)
        VALUES (?, ?, ?, ?, ?, ?, ?)
        ON CONFLICT(url_hash) DO UPDATE SET
            title        = excluded.title,
            price        = excluded.price,
            data_json    = excluded.data_json,
            last_updated = excluded.last_updated
    """, (url_hash, url, title, price,
          json.dumps(extra or {}), now, now))
    conn.commit()


# Usage
conn = create_dedup_store()
upsert_item(conn, "https://site.com/product/123", title="Widget", price=9.99)
upsert_item(conn, "https://site.com/product/123", title="Widget", price=8.99)  # Updates price, doesn't duplicate
upsert_item(conn, "https://site.com/product/456", title="Gadget", price=29.99)

import pandas as pd
df = pd.read_sql("SELECT * FROM items", conn)
print(df)
conn.close()

Pattern 13: Scraping Sites That Block Based on Geolocation

The problem: The site returns different content (or blocks you) based on your IP's country.

Solution — Geo-targeted requests:

from curl_cffi import requests as cffi_requests

# Country-specific proxy endpoints (from providers like Bright Data, Oxylabs)
COUNTRY_PROXIES = {
    "US": "http://user:pass@us.proxy.example.com:8080",
    "UK": "http://user:pass@uk.proxy.example.com:8080",
    "IN": "http://user:pass@in.proxy.example.com:8080",
    "DE": "http://user:pass@de.proxy.example.com:8080",
}

def fetch_from_country(url: str, country: str = "US") -> dict:
    """
    Fetch a URL appearing to originate from a specific country.
    Useful for price comparison, geo-restricted content, localisation testing.
    """
    proxy = COUNTRY_PROXIES.get(country.upper())
    if not proxy:
        raise ValueError(f"No proxy configured for country: {country}")

    headers = {
        "User-Agent":      "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120",
        "Accept-Language": {
            "US": "en-US,en;q=0.9",
            "UK": "en-GB,en;q=0.9",
            "IN": "en-IN,en;q=0.9",
            "DE": "de-DE,de;q=0.9,en;q=0.8",
        }.get(country.upper(), "en-US,en;q=0.9")
    }

    r = cffi_requests.get(
        url,
        impersonate="chrome120",
        proxies={"https": proxy},
        headers=headers,
        timeout=20
    )
    return {"html": r.text, "status": r.status_code, "country": country}


# Compare prices across regions
def compare_regional_prices(product_url: str) -> dict:
    results = {}
    for country in ["US", "UK", "IN"]:
        response = fetch_from_country(product_url, country)
        soup     = BeautifulSoup(response["html"], "lxml")
        price_el = soup.select_one(".price, [data-price]")
        results[country] = price_el.get_text(strip=True) if price_el else "N/A"
    return results

Pattern 14: Detecting When a Scraper Has Gone Silent

The problem: Your scraper runs on a schedule but you don't know if it's actually collecting data. It might be blocked, broken, or returning empty results — silently.

Solution — Health check with alerting:

import sqlite3
import smtplib
from datetime import datetime, timezone, timedelta
from email.mime.text import MIMEText

def health_check(db_path: str, alert_email: str, expected_hourly_rate: int = 100):
    """
    Check if the scraper is producing data at the expected rate.
    Sends an alert email if the rate drops below threshold.
    """
    conn = sqlite3.connect(db_path)

    # How many items were scraped in the last hour?
    one_hour_ago = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
    count = conn.execute(
        "SELECT COUNT(*) FROM items WHERE last_updated > ?", (one_hour_ago,)
    ).fetchone()[0]
    conn.close()

    status = {
        "timestamp":      datetime.now(timezone.utc).isoformat(),
        "items_last_hour":count,
        "expected_rate":  expected_hourly_rate,
        "health":         "OK" if count >= expected_hourly_rate * 0.5 else "DEGRADED",
    }

    print(f"Health: {status['health']} — {count} items in last hour "
          f"(expected ≥{expected_hourly_rate * 0.5:.0f})")

    if status["health"] != "OK":
        _send_alert(alert_email, status)

    return status


def _send_alert(to_email: str, status: dict):
    """Send a health alert email."""
    msg = MIMEText(
        f"Scraper health check FAILED\n\n"
        f"Timestamp:    {status['timestamp']}\n"
        f"Items/hour:   {status['items_last_hour']} (expected ≥{status['expected_rate'] * 0.5:.0f})\n"
        f"Status:       {status['health']}\n\n"
        f"Check the scraper logs immediately."
    )
    msg["Subject"] = f"⚠️ Scraper Alert: {status['health']}"
    msg["From"]    = "monitor@yourdomain.com"
    msg["To"]      = to_email

    try:
        with smtplib.SMTP("smtp.gmail.com", 587) as s:
            s.starttls()
            s.login("monitor@yourdomain.com", "app_password")
            s.send_message(msg)
        print(f"Alert sent to {to_email}")
    except Exception as e:
        print(f"Alert send failed: {e}")


# Schedule this to run every 30 minutes via cron:
# */30 * * * * python health_check.py
if __name__ == "__main__":
    health_check("scrape_store.db", "you@yourdomain.com", expected_hourly_rate=200)

Pattern 15: Scraping GraphQL APIs

The problem: The site uses GraphQL instead of REST. The Network tab shows POST requests to /graphql with JSON bodies.

Solution:

import httpx
import asyncio
import json

async def graphql_query(endpoint: str, query: str, variables: dict = None,
                        headers: dict = None) -> dict:
    """
    Execute a GraphQL query against any endpoint.
    Works for both public and authenticated GraphQL APIs.
    """
    default_headers = {
        "Content-Type":  "application/json",
        "User-Agent":    "Mozilla/5.0 Chrome/120",
        "Accept":        "application/json",
    }
    if headers:
        default_headers.update(headers)

    payload = {"query": query}
    if variables:
        payload["variables"] = variables

    async with httpx.AsyncClient(headers=default_headers) as client:
        r = await client.post(endpoint, json=payload, timeout=20)
        r.raise_for_status()
        data = r.json()

    if "errors" in data:
        raise Exception(f"GraphQL errors: {data['errors']}")

    return data.get("data", {})


async def scrape_shopify_storefront_graphql(store_domain: str,
                                             storefront_token: str) -> list[dict]:
    """
    Scrape a Shopify store's products using the official Storefront GraphQL API.
    Requires a Storefront API token (available free from Shopify partner dashboard).
    """
    endpoint = f"https://{store_domain}/api/2024-01/graphql.json"
    headers  = {"X-Shopify-Storefront-Access-Token": storefront_token}

    query = """
    query GetProducts($first: Int!, $after: String) {
        products(first: $first, after: $after) {
            pageInfo {
                hasNextPage
                endCursor
            }
            edges {
                node {
                    id
                    title
                    handle
                    priceRange {
                        minVariantPrice { amount currencyCode }
                        maxVariantPrice { amount currencyCode }
                    }
                    availableForSale
                    tags
                    vendor
                }
            }
        }
    }
    """

    all_products = []
    cursor       = None

    while True:
        variables = {"first": 50, "after": cursor}
        data      = await graphql_query(endpoint, query, variables, headers)
        products  = data.get("products", {})
        edges     = products.get("edges", [])

        for edge in edges:
            node = edge["node"]
            all_products.append({
                "id":        node["id"],
                "title":     node["title"],
                "handle":    node["handle"],
                "min_price": node["priceRange"]["minVariantPrice"]["amount"],
                "currency":  node["priceRange"]["minVariantPrice"]["currencyCode"],
                "available": node["availableForSale"],
                "vendor":    node["vendor"],
            })

        page_info = products.get("pageInfo", {})
        if not page_info.get("hasNextPage"):
            break
        cursor = page_info.get("endCursor")
        print(f"  Fetched {len(all_products)} products...")

    return all_products

# Example usage
products = asyncio.run(scrape_shopify_storefront_graphql(
    store_domain     = "your-store.myshopify.com",
    storefront_token = "your-storefront-access-token",
))
print(f"Total products: {len(products)}")

Quick Reference

Pattern

Root cause

First thing to try

Element not in BeautifulSoup

JS rendering

Check DevTools XHR first; use Playwright if no API

Data in <script> tag

JS hydration

Parse __NEXT_DATA__ or variable assignment regex

Infinite scroll

Intersection Observer

Find the pagination API endpoint

Login wall

Session required

POST login, save cookies, reuse

429 errors

Rate limiting

Adaptive backoff + jitter

No next link

URL-based pagination

Detect page number pattern; generate URLs

File downloads

N/A

Async streaming with resume support

Merged table cells

rowspan/colspan

Manual cell expansion algorithm

Still blocked with rotated UA

Header inconsistency

Use complete consistent browser profiles

Mixed data formats

Multiple data sources

Multi-strategy fallback chain

Cloudflare JS challenge

JavaScript execution required

Playwright + stealth

Duplicate records

Re-runs overwrite

URL-hash upsert in SQLite

Geo-blocked content

IP geolocation

Country-specific residential proxy

Silent scraper failure

No monitoring

Hourly health check with email alert

GraphQL site

Non-REST API

POST JSON to /graphql endpoint

Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Popular Tags

#.env.example Node.js#0x profiling#10x faster python scraper tutorial#12-factor#2026#2FA#@nestjs/throttler#AI#AI Anxiety#AI Backend