Python Web Scraping Guide: Legal, Reliable, and Maintainable Data Collection
Python is excellent for collecting public web data, but production scraping is not just "send requests and parse HTML." Real projects need permission checks, polite traffic, stable selectors, retry logic, data validation, and monitoring.
This guide focuses on compliant and maintainable scraping. It does not cover bypassing access controls, defeating bot protection, solving CAPTCHAs, or collecting data behind login, paywalls, or explicit restrictions. When a site offers an API, data export, feed, or partnership channel, use that first.
Useful references:
Start With A Permission Check
Before writing code, answer these questions:
- Is the data public and intended to be viewed without login?
- Does the site provide an API, sitemap, RSS feed, or export?
- Does
robots.txtallow crawling for the paths you need? - Do the terms of service restrict automated access or reuse?
- Could the data include personal information, copyrighted content, or regulated records?
- Can you identify your crawler and provide contact information?
If the answer is unclear, do not start with scraping. Ask for permission or use an official data source.
Choose The Right Tool
| Page type | Recommended approach | Why |
|---|---|---|
| Static HTML | httpx or requests + BeautifulSoup/parsel |
Simple, fast, low overhead |
| Many similar pages | Scrapy | Scheduling, retries, pipelines, exports |
| JavaScript-rendered pages | Playwright | Executes client-side rendering |
| API-backed pages | Call the public API if allowed | More stable and efficient than HTML parsing |
| One-off data cleanup | Pandas + saved HTML/CSV | Avoid repeated requests |
Use the simplest tool that can collect the data reliably and politely.
A Small Static Scraper
For static pages, start with explicit timeouts, headers that identify your crawler, and strict parsing.
from __future__ import annotations
import time
from dataclasses import dataclass
from urllib.parse import urljoin
import httpx
from bs4 import BeautifulSoup
BASE_URL = "https://example.com/articles/"
@dataclass
class Article:
title: str
url: str
summary: str
def fetch_html(url: str) -> str:
headers = {
"User-Agent": "ExampleResearchBot/1.0 (+https://example.com/contact)"
}
with httpx.Client(timeout=15, follow_redirects=True, headers=headers) as client:
response = client.get(url)
response.raise_for_status()
return response.text
def parse_articles(html: str) -> list[Article]:
soup = BeautifulSoup(html, "html.parser")
articles: list[Article] = []
for card in soup.select("article.card"):
title_el = card.select_one("h2 a")
summary_el = card.select_one(".summary")
if not title_el:
continue
title = title_el.get_text(" ", strip=True)
url = urljoin(BASE_URL, title_el.get("href", ""))
summary = summary_el.get_text(" ", strip=True) if summary_el else ""
articles.append(Article(title=title, url=url, summary=summary))
return articles
def main() -> None:
html = fetch_html(BASE_URL)
for article in parse_articles(html):
print(article)
time.sleep(2)
if __name__ == "__main__":
main()
The selector choices should be easy to test. If a selector fails, return fewer records and log the problem rather than silently storing broken data.
Handling Dynamic Pages With Playwright
Use Playwright when the target content is created by JavaScript and there is no permitted API or feed.
from playwright.sync_api import sync_playwright
def collect_titles(url: str) -> list[str]:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(url, wait_until="networkidle", timeout=30_000)
titles = [
item.inner_text().strip()
for item in page.locator("article h2").all()
]
browser.close()
return titles
Playwright is heavier than a normal HTTP request. Use it only for pages that need rendering. If the same content is available in the HTML or a permitted public API, do not start a browser.
Scaling With Scrapy
Scrapy is a good fit when you have many pages, retries, exports, and data pipelines.
import scrapy
class ArticleSpider(scrapy.Spider):
name = "articles"
allowed_domains = ["example.com"]
start_urls = ["https://example.com/articles/"]
custom_settings = {
"ROBOTSTXT_OBEY": True,
"CONCURRENT_REQUESTS_PER_DOMAIN": 2,
"DOWNLOAD_DELAY": 2,
"AUTOTHROTTLE_ENABLED": True,
"AUTOTHROTTLE_TARGET_CONCURRENCY": 1.0,
"USER_AGENT": "ExampleResearchBot/1.0 (+https://example.com/contact)",
"FEEDS": {
"articles.jsonl": {"format": "jsonlines", "encoding": "utf8"}
},
}
def parse(self, response):
for card in response.css("article.card"):
title = card.css("h2 a::text").get()
href = card.css("h2 a::attr(href)").get()
if title and href:
yield {
"title": title.strip(),
"url": response.urljoin(href),
"source_url": response.url,
}
next_url = response.css("a.next::attr(href)").get()
if next_url:
yield response.follow(next_url, callback=self.parse)
The important parts are not just the spider. Settings such as ROBOTSTXT_OBEY, domain concurrency, download delay, and AutoThrottle help keep traffic predictable.
Data Quality Pipeline
Raw scraped data is rarely ready to use. Add validation and normalization early.
from itemadapter import ItemAdapter
class CleanArticlePipeline:
def process_item(self, item, spider):
adapter = ItemAdapter(item)
title = (adapter.get("title") or "").strip()
url = (adapter.get("url") or "").strip()
if not title or not url:
raise ValueError("Missing title or URL")
adapter["title"] = " ".join(title.split())
adapter["url"] = url
return item
In production, store validation failures separately so selector changes are visible.
Polite Crawling Rules
Polite crawlers reduce load and are easier to operate:
- Respect
robots.txtand site terms. - Use low concurrency by default.
- Add delay and AutoThrottle.
- Cache responses during development.
- Avoid repeated downloads of unchanged pages.
- Stop on repeated errors instead of retrying aggressively.
- Identify your crawler with a clear user agent.
- Provide a contact address or page.
If a site blocks or challenges the crawler, treat that as a boundary. Do not build around it without permission.
Incremental Crawling
For recurring jobs, do not fetch everything every time.
Use:
- Sitemaps or feeds when available.
- Last seen URL or timestamp.
- ETag and Last-Modified headers when supported.
- Content hashes to detect changes.
- A persistent seen-URL store.
import hashlib
def content_hash(text: str) -> str:
normalized = " ".join(text.split())
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
Incremental crawling saves bandwidth, reduces load, and makes data changes easier to audit.
Monitoring
Production scrapers should report:
- Pages fetched.
- Records extracted.
- Empty pages.
- HTTP status distribution.
- Retry count.
- Parse failure count.
- Duplicate rate.
- Job duration.
A sudden drop in extracted records usually means the site changed its markup or your crawler reached a restriction.
Common Mistakes
Parsing With Fragile Selectors
Selectors like .container > div:nth-child(3) > span break easily. Prefer semantic selectors, stable attributes, or structured data embedded in the page.
Ignoring Character Encoding
Always test multilingual data. Broken encoding can silently corrupt titles, addresses, and names.
Mixing Collection And Business Logic
Keep crawling, parsing, validation, and storage separate. That makes it easier to fix one layer without changing everything.
Running Too Fast
High concurrency causes bans, incomplete data, and operational risk. A slower crawler that finishes reliably is better than a fast crawler that breaks or overloads a site.
No Reproducible Input
Save sample HTML responses for parser tests. When the site changes, you can compare old and new markup.
Final Checklist
Before running a crawler repeatedly, confirm:
- You are allowed to collect the target data.
robots.txtand terms have been checked.- The crawler identifies itself.
- Rate limits and delays are configured.
- Parser tests cover representative pages.
- Data validation catches empty or malformed records.
- Incremental crawling avoids unnecessary requests.
- Logs and metrics make failures visible.
- Personal or sensitive data is handled according to applicable rules.
Reliable scraping is mostly engineering discipline: collect only what you are allowed to collect, request gently, validate everything, and watch the job after it runs.
Try these browser-local tools — no sign-up required →