The Complete Guide to List Crawling
Extracting data from websites that show information in lists (product catalogs, job postings, search results) is tricky. Websites spread items across multiple pages, hide them behind “Load More” buttons, or use infinite scrolling. Without the right approach, your dataset will be incomplete.
This guide covers list crawling basics: how to extract structured data reliably using Python and automation tools.
What is List Crawling?
List crawling extracts structured data from web pages that display information in repeating formats. Unlike general web scraping that targets single pages, list crawling focuses on gathering multiple similar items (product cards, job listings, business entries) from one or more pages.
Why List Crawling Matters
Modern websites organize data in predictable patterns. Each product card, job listing, or directory entry follows the same HTML structure. This makes them ideal for automated extraction. The challenge is accessing all items when they’re spread across pagination, infinite scroll, or dynamic loading.
Key Components of List Crawling
Identification: Find the HTML container holding each repeated item (product card, job listing). This determines what data you can capture.
Extraction: Map specific fields within each container (name, price, location) to your data schema. Consistent field mapping ensures clean output.
Traversal: Navigate through all items via pagination, infinite scrolling, or “Load More” buttons. This ensures complete data collection.
Real-World Applications
E-Commerce Price Monitoring: Track price changes, stock availability, and competitor offerings by extracting product names, prices, ratings, and availability status.
Job Market Analysis: Collect job titles, salary ranges, required skills, company names, and locations from job sites. Use this data for job alerts, trend analysis, and salary benchmarking.
Business Intelligence: Extract contact info, operating hours, customer ratings, and service categories from directories for lead generation and competitive analysis.
Content Aggregation: Crawl news sites, blogs, and content platforms to build databases, track trending topics, and monitor specific subjects.
Identifying List Types
Before writing code, understand how your target website structures its lists. The loading method determines your crawling strategy.
Standard Pagination: Fixed items per page with numbered links or “Next” buttons. Page numbers appear in URLs as ?page=2 or /page/2/. Easiest to crawl.
Load More Buttons: Same URL, new items added on click. Either automate the button click or intercept the background API request directly.
Infinite Scroll: New items load automatically as you scroll down. Common on social media and modern e-commerce. Requires simulating scroll actions or capturing the underlying API calls.
Search Result Pages: Results depend on query parameters, filters, and sorting. Your crawler must manage these parameters while handling the underlying pagination. Check out my article about the best SERP APIs, allowing you to skip the scraping logic and retrieve results directly via an API.
Selecting Your Crawling Method
Method Best For Pros Cons HTTP Requests Parsing Static HTML with URL-based pagination Fast, lightweight, simple Fails with JavaScript-rendered content Headless Browser Dynamic content requiring JavaScript Handles complex interactions Resource-heavy, harder to scale Enterprise API Production systems needing reliability Managed infrastructure, built-in anti-bot handling Extra cost
Method 1: HTTP Requests with BeautifulSoup
When list items exist in the initial HTML response, extract them with HTTP requests and HTML parsing.
Setup
pip install requests beautifulsoup4
Basic List Crawler
import csv
import time
from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup
BASE_URL = "https://books.toscrape.com/"
def extract_items_from_page(soup, base_url):
"""Extract all items from a single page."""
items = []
for card in soup.select("article.product_pod"):
link_el = card.select_one("h3 a")
price_el = card.select_one("p.price_color")
rating_el = card.select_one("p.star-rating")
if not link_el or not price_el:
continue
item = {
"title": link_el.get("title", "").strip(),
"detail_url": urljoin(base_url, link_el.get("href", "")),
"price": price_el.get_text(strip=True),
"rating": extract_rating(rating_el)
}
items.append(item)
return items
def extract_rating(rating_element):
"""Extract rating from CSS classes."""
if not rating_element:
return ""
classes = rating_element.get("class", [])
return " ".join(c for c in classes if c != "star-rating")
def find_next_page(soup, current_url):
"""Find and return the next page URL."""
next_link = soup.select_one("li.next a")
if next_link:
return urljoin(current_url, next_link.get("href", ""))
return None
def crawl_book_list(max_pages=5):
"""Main crawling function."""
url = BASE_URL
all_items = []
page_count = 1
while url and page_count <= max_pages:
print(f"Crawling page {page_count}: {url}")
response = requests.get(url, timeout=15)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
items = extract_items_from_page(soup, url)
print(f"Found {len(items)} items on page {page_count}")
if not items:
print("No items found, stopping crawl")
break
all_items.extend(items)
url = find_next_page(soup, url)
page_count = 1
time.sleep(1.5)
print(f"Total items collected: {len(all_items)}")
return all_items
def save_results(items, filename="books_catalog.csv"):
"""Save extracted items to CSV."""
if not items:
print("No items to save")
return
with open(filename, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=items[0].keys())
writer.writeheader()
writer.writerows(items)
print(f"Saved {len(items)} items to {filename}")
if __name__ == "__main__":
books = crawl_book_list(max_pages=5)
save_results(books)
This code separates concerns: one function extracts items, another finds the next page link, and the main loop handles traversal.
Method 2: Headless Browser Automation
When JavaScript generates list content dynamically, use a real browser. Playwright gives you good control over browser automation.
Setup
pip install playwright
playwright install chromium
Handling Infinite Scroll
import csv
import time
from playwright.sync_api import sync_playwright
TARGET_URL = "https://www.scrapingcourse.com/infinite-scrolling/"
def scroll_and_wait(page, scroll_pause=2.0):
"""Scroll to bottom and wait for content to load."""
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
time.sleep(scroll_pause)
def get_item_count(page, selector):
"""Count currently visible items."""
return page.locator(selector).count()
def extract_items(page):
"""Extract all visible items from the page."""
items = []
for card in page.locator(".product-item").all():
item = {
"name": card.locator(".product-name").inner_text().strip(),
"price": card.locator(".product-price").inner_text().strip(),
"url": card.locator("a").first.get_attribute("href") or ""
}
items.append(item)
return items
def crawl_infinite_scroll(max_scrolls=15):
"""Crawl an infinite scrolling list."""
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page()
page.goto(TARGET_URL, wait_until="networkidle")
page.wait_for_selector(".product-item", timeout=10000)
previous_count = 0
stagnant_scrolls = 0
max_stagnant = 3
for scroll_num in range(max_scrolls):
current_count = get_item_count(page, ".product-item")
print(f"Scroll {scroll_num 1}: {current_count} items visible")
if current_count == previous_count:
stagnant_scrolls = 1
if stagnant_scrolls >= max_stagnant:
print("No new items after multiple scrolls, stopping")
break
else:
stagnant_scrolls = 0
previous_count = current_count
scroll_and_wait(page)
items = extract_items(page)
browser.close()
print(f"Extracted {len(items)} total items")
return items
def export_to_csv(items, filename="products.csv"):
"""Export items to CSV file."""
if not items:
print("No items to export")
return
with open(filename, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=items[0].keys())
writer.writeheader()
writer.writerows(items)
print(f"Exported {len(items)} items to {filename}")
if __name__ == "__main__":
products = crawl_infinite_scroll(max_scrolls=15)
export_to_csv(products)
The crawler stops when the item count stays the same for three scroll attempts. This means you’ve reached the end of the list.
Method 3: Enterprise Solution with Bright Data
For production systems that need scale and reliability, Bright Data provides managed infrastructure without the operational work.
Option A: Web Scraper API
Bright Data’s Web Scraper API offers pre-built scrapers for popular websites. It handles JavaScript rendering, anti-bot challenges, and proxy management automatically.
Synchronous scraping (for up to 20 URLs with real-time results):
import requests
def scrape_urls_sync(urls, dataset_id, api_key):
"""Scrape URLs using Bright Data Web Scraper API."""
endpoint = "https://api.brightdata.com/datasets/v3/scrape"
params = {
"dataset_id": dataset_id,
"format": "json"
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
payload = [{"url": url} for url in urls]
response = requests.post(
endpoint,
params=params,
headers=headers,
json=payload,
timeout=60
)
response.raise_for_status()
return response.json()
if __name__ == "__main__":
API_KEY = "YOUR_API_KEY"
DATASET_ID = "gd_YOUR_DATASET_ID" # Get from Bright Data dashboard
urls = [
"https://example.com/product/1",
"https://example.com/product/2"
]
results = scrape_urls_sync(urls, DATASET_ID, API_KEY)
print(f"Scraped {len(results)} items")
Asynchronous scraping (for large batches):
import requests
import time
def trigger_async_scrape(urls, dataset_id, api_key):
"""Start async scrape job."""
endpoint = "https://api.brightdata.com/datasets/v3/trigger"
params = {
"dataset_id": dataset_id,
"format": "json"
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
payload = [{"url": url} for url in urls]
response = requests.post(
endpoint,
params=params,
headers=headers,
json=payload
)
response.raise_for_status()
return response.json()["snapshot_id"]
def check_snapshot_status(snapshot_id, api_key):
"""Check if snapshot is ready."""
endpoint = f"https://api.brightdata.com/datasets/v3/snapshot/{snapshot_id}"
headers = {"Authorization": f"Bearer {api_key}"}
response = requests.get(endpoint, headers=headers)
response.raise_for_status()
return response.json()["status"]
def download_snapshot(snapshot_id, api_key):
"""Download completed snapshot data."""
endpoint = f"https://api.brightdata.com/datasets/v3/snapshot/{snapshot_id}"
params = {"format": "json"}
headers = {"Authorization": f"Bearer {api_key}"}
response = requests.get(endpoint, params=params, headers=headers)
response.raise_for_status()
return response.json()
def scrape_urls_async(urls, dataset_id, api_key, poll_interval=10):
"""Scrape URLs with polling."""
snapshot_id = trigger_async_scrape(urls, dataset_id, api_key)
print(f"Job started. Snapshot ID: {snapshot_id}")
while True:
status = check_snapshot_status(snapshot_id, api_key)
print(f"Status: {status}")
if status == "ready":
return download_snapshot(snapshot_id, api_key)
elif status == "failed":
raise Exception("Scrape job failed")
time.sleep(poll_interval)
if __name__ == "__main__":
API_KEY = "YOUR_API_KEY"
DATASET_ID = "gd_YOUR_DATASET_ID"
urls = ["https://example.com/product/" str(i) for i in range(100)]
results = scrape_urls_async(urls, DATASET_ID, API_KEY)
print(f"Scraped {len(results)} items")
Option B: Browser API
For custom scraping with full browser control, Bright Data’s Browser API provides remote browsers with built-in unblocking. Connect via Playwright or Puppeteer:
import asyncio
from playwright.async_api import async_playwright
AUTH = "brd-customer-CUSTOMER_ID-zone-ZONE_NAME:ZONE_PASSWORD"
async def scrape_with_browser_api(url):
"""Scrape using Bright Data Browser API with Playwright."""
async with async_playwright() as playwright:
endpoint_url = f"wss://{@brd.superproxy.io">AUTH}@brd.superproxy.io:9222"
browser = await playwright.chromium.connect_over_cdp(endpoint_url)
try:
page = await browser.new_page()
await page.goto(url, timeout=120000)
# Your custom extraction logic
items = []
for card in await page.locator(".product-item").all():
item = {
"name": await card.locator(".name").inner_text(),
"price": await card.locator(".price").inner_text()
}
items.append(item)
return items
finally:
await browser.close()
if __name__ == "__main__":
results = asyncio.run(scrape_with_browser_api("https://example.com/products"))
print(f"Extracted {len(results)} items")
Option C: Unlocker API
For simpler cases where you just need unblocked HTML, use the Unlocker API. It returns clean HTML/JSON while handling proxies, CAPTCHAs, and fingerprinting automatically:
import requests
from bs4 import BeautifulSoup
def fetch_with_unlocker(url, api_key, zone_name):
"""Fetch URL content using Bright Data Unlocker API."""
endpoint = "https://api.brightdata.com/request"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
payload = {
"zone": zone_name,
"url": url,
"format": "raw"
}
response = requests.post(endpoint, headers=headers, json=payload)
response.raise_for_status()
return response.text
def crawl_with_unlocker(base_url, api_key, zone_name, max_pages=5):
"""Crawl paginated list using Unlocker API."""
all_items = []
for page_num in range(1, max_pages 1):
url = f"{base_url}?page={page_num}"
print(f"Fetching page {page_num}")
html = fetch_with_unlocker(url, api_key, zone_name)
soup = BeautifulSoup(html, "html.parser")
items = []
for card in soup.select(".product-card"):
items.append({
"name": card.select_one(".name").get_text(strip=True),
"price": card.select_one(".price").get_text(strip=True)
})
if not items:
break
all_items.extend(items)
return all_items
Best Practices
Respect Rate Limits: Add delays between requests. 1–2 seconds works for most sites. For larger crawls, spread requests over time.
Handle Errors: Network issues and unexpected page structures will happen. Use try-except blocks and check your data before saving.
Check Data Quality: Look at your extracted data regularly. Missing fields or cut-off lists mean your selectors or traversal logic has problems.
Adapt to Changes: Websites update their HTML structure often. Build crawlers with easy-to-update selectors and test them regularly.
Summary
List crawling turns scattered web data into structured datasets. Your method choice depends on the target website’s technology and your scale needs.
Start with HTTP requests for static lists. Use headless browsers when JavaScript rendering is required. Consider enterprise APIs like Bright Data when you need reliability, scale, and less maintenance.
The basics stay the same: identify repeating patterns, extract consistently, and traverse completely

