ProxynetProxynet

صفحه‌بندی چیست و چگونه همه صفحه‌ها را اسکرپ کنیم؟

تاریخ انتشار:

20 دقیقه مطالعه

Acar Diveroli
نویسنده: Acar Diveroli
صحنه فروریختن شبکه در قیفی ژرف: بالای قیف مکعب صف با وجه آبی و چپ مقیاسی که صفحه توقف پویش را نشان می‌دهد

اسکریپتی که برای گرفتن کاتالوگ هزار محصولی نوشته‌اید، بیست محصول صفحه اول را بدون دردسر می‌خواند. کار اصلی بعد از آن شروع می‌شود: پیدا کردن نشانی 49 صفحه باقی‌مانده، فهمیدن اینکه فهرست تمام شده است، ذخیره‌نکردن یک محصول برای بار دوم و شروع‌نکردن از اول وقتی در صفحه 31 اتصال قطع می‌شود. تفاوت کدی که یک صفحه را می‌خواند با کدی که کل فهرست را کامل جمع می‌کند، نامش صفحه‌بندی است.

در این نوشته صفحه‌بندی را کوتاه تعریف می‌کنیم و بعد از دید کسی که خزش می‌کند به آن نگاه می‌اندازیم: پنج نوع صفحه‌بندی در ابزار توسعه‌دهنده چگونه شناخته می‌شوند، صفحه بعدی از کجا به دست می‌آید، خزش کی متوقف می‌شود، رکوردهای تکراری چطور پالایش می‌شوند. در مرکز ماجرا یک صف نشانی روی SQLite قرار دارد؛ خزشی که وسط کار فرایندش را کشتیم، به لطف همین صف از صفحه‌ای که مانده بود ادامه داد. کدها را روی سایت‌های تمرینی books.toscrape.com و quotes.toscrape.com اجرا کردیم.

صفحه‌بندی چیست؟

اگر در یک پایگاه داده ده هزار رکورد باشد، سرور همه را در یک پاسخ نمی‌فرستد. هم پرس‌وجو سنگین می‌شود و هم پاسخ با هزاران سطری که کاربر نگاهشان نمی‌کند باد می‌کند. به جای آن فهرست به بخش‌هایی با اندازه ثابت تقسیم می‌شود و کارخواه هر بار یک بخش می‌خواهد. پیوندهای «1 2 3 ... 50» پایین صفحه دسته‌بندی، پارامتر ?page=2 یک API و جریان‌هایی که با پایین‌رفتن بارگذاری می‌شوند، چهره‌های مختلف یک ایده‌اند.

توسعه‌دهنده‌ای که صفحه‌بندی را طراحی می‌کند می‌پرسد کدام روش پایگاه داده را کمتر خسته می‌کند. پرسش طرف خزنده چیز دیگری است: این سایت کدام روش را انتخاب کرده و من از بیرون چطور این را تشخیص بدهم؟ چارچوب کلی جمع‌آوری انبوه داده در صفحه استخراج داده و ساختار خزنده‌هایی که از پیوندی به پیوند دیگر می‌روند در صفحه وب کراولر آمده است.

انواع صفحه‌بندی کدام‌اند و چگونه شناخته می‌شوند؟

راه شناخت در همه انواع یکی است: در مرورگر ابزار توسعه‌دهنده را باز کنید، زبانه Network را پاک کنید، به صفحه دوم بروید و ببینید چه چیزی عوض شد. نوار نشانی تغییر کرد یا در پس‌زمینه درخواستی بیرون رفت؟

1. نشانی با شماره صفحه. نشانی به شکل ?page=2، /page/2/ یا page-2.html تغییر می‌کند. ساده‌ترین نوع برای شناخت است و حلقه هم ساده است: عدد را زیاد می‌کنید. نقطه ضعفش این است که بیشتر وقت‌ها نمی‌دانید صفحه آخر کدام است.

2. پیوند «بعدی». پایین صفحه یک عنصر <a> هست که به صفحه بعدی می‌رود. نشانی را شما نمی‌سازید، از صفحه می‌خوانید. اگر پیوند نباشد، فهرست تمام شده است. وقتی سایت ساختار نشانی‌اش را عوض کند کد شما خراب نمی‌شود؛ در فهرست‌های HTML انتخاب اول همین است. منطق انتخابگرها را در نوشته انتخابگر CSS و XPath توضیح داده‌ایم.

3. offset/limit. در درخواست API دو پارامتر مثل offset=40&limit=20 یا skip و take می‌بینید: «40 رکورد اول را رد کن، 20 تا بده». معادل شماره صفحه در API است.

4. مکان‌نما (cursor). در پاسخ رشته‌ای به‌ظاهر بی‌معنا مثل next_cursor، after یا next_page_token می‌آید و در درخواست بعدی همان رشته را بی‌کم‌وکاست پس می‌فرستید. مکان‌نما این اطلاعات را حمل می‌کند که «آخرین رکوردی که دادم این بود»؛ سعی نمی‌کنید رمزش را باز کنید، فقط منتقلش می‌کنید.

5. اسکرول بی‌نهایت و «بارگذاری بیشتر». نوار نشانی تغییر نمی‌کند. وقتی به ته صفحه می‌رسید یا دکمه را می‌زنید، در زبانه Network یک درخواست تازه Fetch/XHR پیدا می‌شود. وقتی به آن درخواست نگاه می‌کنید، بیشتر وقت‌ها یکی از سه نوع بالا را می‌بینید. اسکرول بی‌نهایت روش جداگانه‌ای نیست، رابطی است که روی صفحه‌بندی API پوشانده شده.

به این‌ها باید نشانه rel="next" را هم اضافه کرد. در دو جا به چشمتان می‌خورد. اولی عنصر <link rel="next" href="..."> در بخش <head> سند HTML است. گوگل آشکارا می‌نویسد که دیگر از این برچسب استفاده نمی‌کند، اما بسیاری از سایت‌ها هنوز آن را چاپ می‌کنند و وقتی پیدایش کنید، رونوشت تمیزی از پیوند «بعدی» است. دومی هدر Link در پاسخ HTTP است؛ قالبش را ⁦RFC 8288⁩ تعریف می‌کند و سرویس‌هایی مثل GitHub نشانی کامل صفحه بعدی را در همین هدر می‌دهند.

نوعچطور شناخته می‌شودصفحه بعدی از کجا می‌آیدشرط توقفدامش
شماره صفحهpage=2، /page/2/ در نشانیعدد را شما زیاد می‌کنید404، فهرست خالی یا محتوای تکراریرفتار پس از صفحه آخر در هر سایت فرق می‌کند
پیوند «بعدی»<a> پایین صفحه، rel="next" در <head>از صفحه خوانده می‌شودپیوندی نیستفراموش‌کردن تبدیل نشانی نسبی به نشانی کامل
offset/limitoffset، limit، skip در درخواست APIoffset += limitبخش ناقص یا خالیبا تغییر فهرست رکوردها جابه‌جا می‌شوند
مکان‌نماnext_cursor، after، یک نشانه در پاسخاز پاسخ عیناً منتقل می‌شودمکان‌نما خالی است یا نیستمکان‌نما منقضی می‌شود، از وسط نمی‌شود شروع کرد
اسکرول بی‌نهایت، «بارگذاری بیشتر»نشانی تغییر نمی‌کند، درخواست XHR بیرون می‌رودقاعده API زیرینقاعده API یا نیامدن کارت تازهاسکرول‌کردن با مرورگر بی‌جهت گران است

حلقه صفحه‌بندی از چه گام‌هایی تشکیل می‌شود؟

نوعش هرچه باشد، حلقه همان شش گام را دنبال می‌کند:

  1. نشانی آغازین را در صف بگذارید. صفحه دسته‌بندی یا نخستین درخواست API.
  2. نشانی بعدی را بردارید و ببینید مجاز است یا نه. اگر robots.txt آن مسیر را ممنوع کرده باشد، درخواست اصلاً بیرون نمی‌رود.
  3. صبر کنید، بعد درخواست را بفرستید. میان دو درخواست به یک سایت فاصله ثابتی بگذارید.
  4. رکوردها را بیرون بکشید. به هر رکورد کلیدی بدهید که آن را یکتا مشخص کند.
  5. صفحه بعدی را پیدا کنید. پیوند، عدد، offset یا مکان‌نما.
  6. رکوردها، نشانی بعدی و نشانه «این صفحه تمام شد» را با هم بنویسید. بعد به گام دوم برگردید.

واژه «با هم» در گام ششم موضوع باقی نوشته است. اول به سراغ شرط توقف برویم.

خزش کی باید متوقف شود؟

فهمیدن اینکه فهرست تمام شده از آنچه به نظر می‌رسد سخت‌تر است، چون سایت‌ها پس از صفحه آخر رفتار متفاوتی دارند. در سایت‌های تمرینی سه رفتار جداگانه را کنار هم دیدیم. books.toscrape.com پنجاه صفحه دارد و درخواست page-51.html پاسخ 404 می‌دهد. اما quotes.toscrape.com/page/11/ با کد 200 صفحه‌ای برمی‌گرداند که هیچ نقل‌قولی در آن نیست. API جیسون همان سایت در صفحه آخر "has_next": false می‌نویسد و اگر صفحه یازدهم را بخواهید فهرست خالی می‌گیرید. در سایت‌های واقعی رفتار چهارمی رایج‌تر است: نشان‌دادن دوباره و بی‌صدای صفحه اول یا آخر وقتی شماره صفحه بیرون از بازه است.

به همین دلیل به یک شرط تکیه نکنید، چند تا را با هم به کار ببرید:

  • نشانه آشکار: پیوند «بعدی» نیست، has_next نادرست است، مکان‌نما خالی است، در هدر Link نشانه rel="next" نیست.
  • بخش خالی یا ناقص: در صفحه هیچ رکوردی نیست یا تعداد رکوردهای آمده از مقدار limit کمتر است.
  • رکورد تازه‌ای نیست: همه رکوردهای صفحه پیش‌تر دیده شده‌اند. شرطی که سایت‌های نشان‌دهنده مکرر صفحه آخر را می‌گیرد همین است.
  • سقف بالا: تعداد صفحه‌ای که تحت هیچ شرایطی از آن نمی‌گذرید. پیوند «بعدی» خراب یا مکان‌نمایی که خودش را تکرار می‌کند نمی‌تواند اسکریپت شما را در حلقه بی‌پایان بیندازد.
  • تعداد کل: اگر API مقدار total یا total_pages می‌دهد، از آن برای توقف استفاده نکنید، برای راستی‌آزمایی به کار ببرید.

وقتی با شماره صفحه پیش می‌روید، کد 404 هم می‌تواند به معنای «فهرست تمام شد» باشد و هم «ساختار نشانی عوض شد». اگر همان صفحه اول 404 می‌گیرید، معنای دوم درست است.

رکوردهای تکراری چگونه پالایش می‌شوند؟

دو بار آمدن یک رکورد در جریان صفحه‌بندی خطا نیست، وضعیتی است که انتظارش را داریم. رایج‌ترین دلیلش این است که فهرست هنگام خزش شما تغییر می‌کند. اگر در فهرستی با ترتیب «تازه‌ترین‌ها» درست وقتی صفحه سوم را می‌خوانید پنج محصول تازه به ابتدای فهرست اضافه شود، همه رکوردها پنج ردیف پایین می‌روند و پنج رکورد اول صفحه چهارم همان‌هایی‌اند که همین حالا دیدید. اگر رکوردی حذف شود عکسش رخ می‌دهد، یک رکورد بالا می‌رود و شما هرگز آن را نمی‌بینید. offset/limit و شماره صفحه در برابر این جابه‌جایی آسیب‌پذیرند؛ مکان‌نما چون می‌گوید «آن‌هایی که بعد از این رکوردند» تحت تأثیر قرار نمی‌گیرد. محصول اسپانسرشده و محصولی که در دو دسته فهرست شده هم تکرار تولید می‌کنند.

راه‌حل این است که تکرار را نه در کد، در پایگاه داده پالایش کنید:

  • به هر رکورد کلیدی پایدار بدهید. شناسه محصول، نشانی محصول یا فیلد id در API. اگر هیچ‌کدام نبود، از فیلدهای ثابت رکورد یک چکیده (hash) بسازید. شماره ردیف در فهرست کلید نمی‌شود.
  • کلید را کلید اصلی کنید و درج را با INSERT OR IGNORE انجام دهید. اگر همان کلید بار دوم بیاید، SQLite سطر را بی‌صدا رد می‌کند. این رفتار در سند قواعد تعارض SQLite تعریف شده است. اگر بخواهید فیلد متغیری مثل قیمت را به‌روز کنید، از ON CONFLICT ... DO UPDATE استفاده می‌کنید.
  • ترتیب را ثابت کنید. اگر سایت گزینه مرتب‌سازی دارد، فیلدی را انتخاب کنید که تغییر نمی‌کند (به جای تاریخ افزودن، بر اساس نام یا شناسه). جابه‌جایی کمتر می‌شود.

حلقه‌های سه نوع API خیلی به هم شبیه‌اند؛ تفاوت در این است که درخواست بعدی چطور ساخته می‌شود. سه تابع زیر رکوردها را یکی‌یکی تولید می‌کنند (yield)، پس کد فراخوان مجبور نیست بداند صفحه‌بندی از چه نوعی است. نام فیلدها (items، next_cursor) از یک API تا API دیگر فرق می‌کند؛ به سند هدف خودتان نگاه کنید.

python
import time

import requests


def crawl_offset(session, url, limit=100, max_pages=500, delay=1.0):
    """offset/limit: stops on a short or empty page."""
    offset = 0
    for _ in range(max_pages):
        response = session.get(url, params={"offset": offset, "limit": limit}, timeout=20)
        response.raise_for_status()
        batch = response.json()["items"]
        yield from batch
        if len(batch) < limit:
            break
        offset += limit
        time.sleep(delay)


def crawl_cursor(session, url, max_pages=500, delay=1.0):
    """cursor: the cursor in the response is carried unchanged into the next request."""
    cursor, seen = None, set()
    for _ in range(max_pages):
        response = session.get(url, params={"cursor": cursor} if cursor else {}, timeout=20)
        response.raise_for_status()
        payload = response.json()
        yield from payload["items"]
        cursor = payload.get("next_cursor")
        if not cursor or cursor in seen:  # no cursor, or it repeats itself
            break
        seen.add(cursor)
        time.sleep(delay)


def crawl_link_header(session, url, max_pages=500, delay=1.0):
    """Link header: the rel="next" address arrives ready, no parameter is computed."""
    for _ in range(max_pages):
        response = session.get(url, timeout=20)
        response.raise_for_status()
        yield from response.json()
        url = response.links.get("next", {}).get("url")
        if not url:
            break
        time.sleep(delay)

هر سه را روی یک API ساختگی محلی با 250 رکورد آزمودیم: هرکدام 250 رکورد را در سه درخواست و بدون تکرار جمع کرد. روی یک نقطه پایانی خراب که همیشه یک مکان‌نما برمی‌گرداند، crawl_cursor پس از درخواست دوم متوقف شد؛ اگر مجموعه seen نبود 500 درخواست می‌فرستاد. تابع crawl_link_header را جداگانه روی فهرست برچسب‌های یک مخزن عمومی GitHub اجرا کردیم و در دو صفحه 200 رکورد گرفتیم. کتابخانه Requests هدر Link را خودش تجزیه و در دیکشنری response.links می‌گذارد. سند صفحه‌بندی GitHub هم همین را توصیه می‌کند: نشانی را دستی نسازید، نشانی rel="next" را دنبال کنید.

اسکرول بی‌نهایت و دکمه «بارگذاری بیشتر» چگونه خزش می‌شوند؟

حرکت اول خودکارسازی مرورگر نیست، زبانه Network است. صفحه quotes.toscrape.com/scroll نمونه خوبی است: با پایین‌رفتن نقل‌قول‌های تازه می‌آیند و هر بار درخواستی به /api/quotes?page=2، page=3 بیرون می‌رود. پاسخ جیسون است و در آن فیلد has_next هست. فراخوانی مستقیم این درخواست هم سریع‌تر از اسکرول است و هم بار کمتری به سایت می‌آورد؛ تصویرها، قلم‌ها و اسکریپت‌ها اصلاً دانلود نمی‌شوند. نمونه صف پایین‌تر نقل‌قول‌ها را از همین راه جمع می‌کند. جزئیات پیداکردن درخواست در بخش «اول درخواست API/XHR را پیدا کنید» از نوشته صفحه‌های ایستا و پویا آمده است.

اگر درخواست تکرارپذیر نباشد (پارامتر امضاشده حمل کند یا پاسخ به شکل تکه HTML بیاید و با اسکریپت صفحه پردازش شود)، به خودکارسازی مرورگر می‌روید. شرط توقف در آنجا «تعداد کارت‌ها دیگر زیاد نمی‌شود» است:

python
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("https://quotes.toscrape.com/scroll")
    page.wait_for_selector(".quote")

    count, idle = 0, 0
    while idle < 3 and count < 500:  # stop after three rounds without new cards, or at the upper bound
        page.mouse.wheel(0, 10000)
        page.wait_for_timeout(1000)
        current = page.locator(".quote").count()
        idle = idle + 1 if current == count else 0
        count = current

    print(count, "cards loaded")
    browser.close()

این اسکریپت در سایت تمرینی هر 100 کارت را بارگذاری کرد. در دکمه «بارگذاری بیشتر» حلقه همان است، فقط به جای چرخ ماوس روی دکمه کلیک می‌کنید و وقتی دکمه از صفحه برداشته شد هم متوقف می‌شوید. در Selenium همین کار با فراخوانی execute_script("window.scrollTo(0, document.body.scrollHeight)") و شمردن دوباره کارت‌ها انجام می‌شود؛ تفاوت دو ابزار در نوشته Playwright و Selenium آمده است. اسکرول یک مرز دارد: در جریان‌های بلند صفحه هزاران عنصر را در حافظه نگه می‌دارد و کند می‌شود. اگر بیش از چند صد کارت لازم دارید، جست‌وجوی درخواست API ارزشش را دارد.

خزش نیمه‌تمام چگونه ادامه پیدا می‌کند: صف نشانی روی SQLite

در فهرست‌های کوتاه نگه‌داشتن نشانی بعدی در یک متغیر کافی است؛ تابع خزنده دسته‌بندی در نوشته پایش قیمت رقبا در تجارت الکترونیک همین‌طور کار می‌کند و برای دسته‌ای دوصفحه‌ای انتخاب درستی است. وقتی فهرست به صدها صفحه می‌رسد متغیر کافی نیست: اتصال قطع می‌شود، رایانه به خواب می‌رود، سرور دوباره راه می‌افتد و نشانی داخل متغیر همراه فرایند از بین می‌رود. باید جایی که مانده‌اید را روی دیسک بنویسید.

برای این کار به سرور صف جداگانه نیازی نیست. SQLite که همراه پایتون می‌آید با دو جدول کار را راه می‌اندازد: queue نشانی‌های قابل خزش و وضعیتشان (pending، done، failed، blocked) و items رکوردهای جمع‌شده را نگه می‌دارد. نکته کار یک قاعده است: رکوردهای یک صفحه، نشانی بعدی که از همان صفحه آموخته شده و نشانه done آن صفحه در یک تراکنش پایگاه داده نوشته می‌شوند. تراکنش یا کامل روی دیسک می‌نشیند یا اصلاً نمی‌نشیند. اگر فرایند درست همان لحظه بمیرد، صفحه در صف pending می‌ماند و در اجرای بعدی دوباره خوانده می‌شود. صفحه‌ای که «تمام‌شده» به نظر برسد ولی رکوردهایش ناقص باشد نمی‌تواند پدید بیاید.

اسکریپت زیر دو نوع صفحه‌بندی متفاوت را در یک صف خزش می‌کند: کاتالوگ کتاب را با دنبال‌کردن پیوند «بعدی» و نقل‌قول‌ها را از API جیسون پشت اسکرول بی‌نهایت.

python
import hashlib
import json
import sqlite3
import time
from urllib.parse import urljoin, urlsplit
from urllib.robotparser import RobotFileParser

import requests
from bs4 import BeautifulSoup

DB_PATH = "crawl.db"
BOT_NAME = "ExampleCrawler"
USER_AGENT = f"{BOT_NAME}/1.0 (+https://example.com/bot)"
PROXY = None  # example: "http://user:pass@pr.proxynet.io:8000"
DELAY = 1.0  # shortest gap between two requests to the same site (seconds)
MAX_PAGES = 200  # upper bound against a broken "next" chain
MAX_ATTEMPTS = 3

SEEDS = [
    ("https://books.toscrape.com/", "books"),
    ("https://quotes.toscrape.com/api/quotes?page=1", "quotes"),
]

SCHEMA = """
CREATE TABLE IF NOT EXISTS queue (
    url      TEXT PRIMARY KEY,
    kind     TEXT NOT NULL,
    status   TEXT NOT NULL DEFAULT 'pending',  -- pending | done | failed | blocked
    attempts INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS items (
    item_key TEXT PRIMARY KEY,
    kind     TEXT NOT NULL,
    data     TEXT NOT NULL,
    page_url TEXT NOT NULL
);
"""


def parse_books(response):
    """HTML list: records come from the cards, the next page from the 'next' link."""
    soup = BeautifulSoup(response.content, "html.parser")
    items = []
    for card in soup.select("article.product_pod"):
        link = card.select_one("h3 a")
        url = urljoin(response.url, link["href"])
        items.append((url, {"title": link["title"], "price": card.select_one(".price_color").text}))
    next_link = soup.select_one("li.next a")
    return items, urljoin(response.url, next_link["href"]) if next_link else None


def parse_quotes(response):
    """JSON API (the request behind the infinite scroll): stops when has_next ends."""
    payload = response.json()
    items = []
    for quote in payload["quotes"]:
        key = hashlib.sha1(quote["text"].encode("utf-8")).hexdigest()
        items.append((key, {"text": quote["text"], "author": quote["author"]["name"]}))
    next_url = None
    if payload["has_next"]:
        next_url = urljoin(response.url, f"?page={payload['page'] + 1}")
    return items, next_url


PARSERS = {"books": parse_books, "quotes": parse_quotes}
robots_cache = {}
last_request = {}


def allowed(session, url):
    """Reads robots.txt once per site. 4xx: no restriction, 5xx: no path is crawled (RFC 9309)."""
    root = "{0.scheme}://{0.netloc}".format(urlsplit(url))
    if root not in robots_cache:
        parser = RobotFileParser()
        response = session.get(root + "/robots.txt", timeout=20)
        if response.status_code >= 500:
            parser.disallow_all = True
        elif response.status_code >= 400:
            parser.allow_all = True
        else:
            parser.parse(response.text.splitlines())
        robots_cache[root] = parser
    return robots_cache[root].can_fetch(BOT_NAME, url)


def polite_get(session, url):
    """Does not hit the same site more often than DELAY (or the Crawl-delay in robots.txt)."""
    host = urlsplit(url).netloc
    root = "{0.scheme}://{0.netloc}".format(urlsplit(url))
    delay = max(DELAY, float(robots_cache[root].crawl_delay(BOT_NAME) or 0))
    wait = last_request.get(host, 0) + delay - time.monotonic()
    if wait > 0:
        time.sleep(wait)
    try:
        return session.get(url, timeout=20)
    finally:
        last_request[host] = time.monotonic()


def crawl():
    db = sqlite3.connect(DB_PATH)
    db.executescript(SCHEMA)
    with db:  # adds the seed addresses on the first run, leaves them alone afterwards
        db.executemany("INSERT OR IGNORE INTO queue (url, kind) VALUES (?, ?)", SEEDS)

    session = requests.Session()
    session.headers["User-Agent"] = USER_AGENT
    if PROXY:
        session.proxies = {"http": PROXY, "https": PROXY}

    for _ in range(MAX_PAGES):
        row = db.execute(
            "SELECT url, kind FROM queue WHERE status = 'pending' ORDER BY attempts, rowid LIMIT 1"
        ).fetchone()
        if row is None:
            break  # queue empty: the crawl is finished
        url, kind = row

        if not allowed(session, url):
            with db:
                db.execute("UPDATE queue SET status = 'blocked' WHERE url = ?", (url,))
            continue

        try:
            response = polite_get(session, url)
            response.raise_for_status()
            items, next_url = PARSERS[kind](response)
        except (requests.RequestException, KeyError, ValueError) as exc:
            with db:
                db.execute(
                    "UPDATE queue SET attempts = attempts + 1,"
                    " status = CASE WHEN attempts + 1 >= ? THEN 'failed' ELSE 'pending' END"
                    " WHERE url = ?",
                    (MAX_ATTEMPTS, url),
                )
            print(f"ERROR {url}: {exc}")
            continue

        # The records, the next address and the "this page is done" flag are written in one transaction.
        # If the process dies in the middle of this block, none of them is written and the page stays 'pending'.
        with db:
            before = db.total_changes
            db.executemany(
                "INSERT OR IGNORE INTO items (item_key, kind, data, page_url) VALUES (?, ?, ?, ?)",
                [(key, kind, json.dumps(data, ensure_ascii=False), url) for key, data in items],
            )
            new_items = db.total_changes - before
            # Stop condition: an empty page, or records that have all been seen before
            if next_url and items and new_items:
                db.execute("INSERT OR IGNORE INTO queue (url, kind) VALUES (?, ?)", (next_url, kind))
            db.execute("UPDATE queue SET status = 'done' WHERE url = ?", (url,))
        print(f"OK    {url}: {len(items)} records, {new_items} new")

    for kind, status, count in db.execute(
        "SELECT kind, status, COUNT(*) FROM queue GROUP BY kind, status ORDER BY kind, status"
    ):
        print(f"queue   {kind:7} {status:8} {count}")
    for kind, count in db.execute("SELECT kind, COUNT(*) FROM items GROUP BY kind ORDER BY kind"):
        print(f"record  {kind:7} {count}")
    db.close()


if __name__ == "__main__":
    crawl()

برای اجرای اسکریپت pip install requests beautifulsoup4 کافی است. ادامه‌دادن را این‌طور آزمودیم: اسکریپت را راه انداختیم و در ثانیه چهاردهم فرایند را مستقیم کشتیم (نه با Ctrl+C). در آن لحظه در پایگاه داده 20 صفحه تمام‌شده، 200 کتاب، 100 نقل‌قول و تنها یک نشانی در وضعیت pending بود: page-11.html. بدون تغییر هیچ چیز دوباره اجرا کردیم؛ خزش با page-11.html ادامه یافت و در کمتر از یک دقیقه با 50 صفحه کاتالوگ و 1000 کتاب تمام شد. به API نقل‌قول‌ها حتی یک درخواست نرفت، هر ده صفحه‌اش done بود. اجرای سوم نشانی در انتظاری پیدا نکرد و فقط خلاصه را نوشت.

جاهایی از کد که باید به آن‌ها دقت کرد:

  • بلوک with db: مرز تراکنش است. در ماژول sqlite3 پایتون وقتی اتصال به عنوان مدیر زمینه به کار می‌رود، اگر بلوک بدون خطا تمام شود تراکنش تأیید و اگر استثنایی رخ دهد برگردانده می‌شود. جزئیاتش در سند همین ماژول آمده است.
  • کلید اصلی صف خود نشانی است. اگر همان نشانی بار دوم کشف شود، INSERT OR IGNORE آن را رد می‌کند؛ پیوندهای حلقوی این‌طور حل می‌شوند.
  • شرط «رکورد تازه‌ای نیست» با ادامه‌دادن تعارض ندارد. چون رکوردهای صفحه نیمه‌تمام اصلاً نوشته نشده‌اند، هنگام خواندن دوباره همه تازه‌اند و زنجیره پاره نمی‌شود.
  • صفحه‌ای که خطا می‌گیرد به ته صف می‌رود. به لطف ORDER BY attempts اول نشانی‌هایی برداشته می‌شوند که هرگز آزموده نشده‌اند؛ صفحه‌ای که در سه تلاش خوانده نشود failed می‌شود و خزش بدون آن ادامه می‌یابد. شمارنده عمداً ساده است: کد تلاش دوباره که تصمیم می‌گیرد در کدام کد وضعیت صبر و در کدام توقف شود و هدر Retry-After را پردازش می‌کند، در نوشته کدهای وضعیت HTTP در اسکرپینگ آماده است و می‌توانید آن را جای polite_get بگذارید.
  • افزودن سایت تازه یعنی نوشتن یک تجزیه‌گر. تابعی می‌نویسید که فهرست رکوردها و نشانی بعدی را برگرداند و آن را به دیکشنری PARSERS اضافه می‌کنید؛ منطق صف، انتظار و ادامه‌دادن تغییر نمی‌کند.

این صف برای یک فرایند است. اگر قرار باشد چند کارگر از یک صف بخوانند، کارگری که نشانی را برمی‌دارد باید آن را به وضعیت میانی مثل claimed ببرد و این وضعیت باید با انقضای زمان به pending برگردد. اینکه خزش همزمان کی سرعت می‌آورد در نوشته همروندی و موازی‌سازی آمده است. با زیادشدن تعداد کارگرها یک چارچوب آماده کار کمتری می‌تراشد: Scrapy صف، پالایش تکرار و ادامه‌دادن را در خودش دارد.

robots.txt، محدودیت نرخ و پروکسی

صفحه‌بندی کاری است که در آن بیشترین درخواست پشت‌سرهم را به یک سایت می‌فرستید. تابع allowed در اسکریپت پرونده robots.txt هر سایت را یک بار می‌خواند و هر نشانی را از آن می‌پرسد. حالت‌هایی که پرونده پیدا نمی‌شود را ⁦RFC 9309⁩ سامان می‌دهد: پاسخ 4xx به معنای «پرونده نیست، محدودیت نیست» شمرده می‌شود و در پاسخ 5xx خزنده ناچار است همه مسیرها را ممنوع فرض کند. در هر دو سایت تمرینی درخواست 404 برگرداند. دلیل اینکه پرونده را با session می‌گیریم این است که درخواست با User-Agent اسکریپت و در صورت وجود از راه پروکسی بیرون برود. ماژول urllib.robotparser نویسه‌های جایگزین (*، $) را پشتیبانی نمی‌کند؛ مرزهایش در نوشته پرونده robots.txt چیست و چطور خوانده می‌شود؟ آمده است.

تابع polite_get میان دو درخواست به یک سایت دست‌کم DELAY ثانیه فاصله می‌گذارد و اگر در سایت Crawl-delay نوشته شده باشد آن را مبنا می‌گیرد؛ انتظار برای هر سایت جداگانه نگه داشته می‌شود. اگر کد 429 دیدن گرفتید، واکنش درست عوض‌کردن IP نیست، بزرگ‌کردن مقدار DELAY است؛ دلیل‌هایش در نوشته ⁦429 Too Many Requests⁩ آمده است. نوشتن User-Agent‌ای که ربات شما را معرفی کند هم بخشی از کار است: User-Agent چیست؟.

پروکسی در دو جای این تصویر وارد می‌شود. اولی موقعیت است: برای دیدن کاتالوگ و قیمتی که به بازدیدکننده داخل Türkiye نشان داده می‌شود، درخواست باید از Türkiye بیرون برود. دومی پخش بار است: در خزش‌های بلندی که روی چند سایت پخش می‌شوند از پروکسی چرخشی استفاده می‌شود تا ترافیک روی یک نشانی تلنبار نشود. اینجا دامی هست. نتایج جست‌وجو و فهرست‌های فیلترشده بیشتر وقت‌ها به نشستی روی سرور بسته‌اند؛ اگر وسط فهرست IP عوض شود، سایت می‌تواند شما را به صفحه اول برگرداند یا همان رکوردها را دوباره بدهد. برای خزش یک فهرست از ابتدا تا انتها با یک نشانی خروجی، نشست پروکسی با نشست ثابت باز کنید و وقتی فهرست تمام شد هویت را عوض کنید. سازوکارش در نوشته چرخش IP چیست و چگونه کار می‌کند؟ آمده است. سطر PROXY را هم پر کردیم و اسکریپت را از راه یک پروکسی آزمایشی محلی با احراز هویت اجرا کردیم؛ همه ترافیک از جمله robots.txt از پروکسی گذشت و نتیجه عوض نشد.

کاربردها

  • خزش دسته‌بندی و کاتالوگ: جمع‌کردن نشانی محصول‌ها از دسته‌های رقیب نخستین گام پایش قیمت است؛ کل جریان در نوشته پایش قیمت رقبا در تجارت الکترونیک و سمت زیرساخت در صفحه پایش قیمت آمده است.
  • فهرست‌های بازارگاه: فهرست فروشنده و محصول به هزاران صفحه می‌رسد و صف قابل ادامه اینجا اجباری است. برای تنظیم موقعیت و نشست به صفحه پروکسی تجارت الکترونیک نگاه کنید.
  • داده انبوه از API رسمی: مکان‌نما و هدر Link بیشتر همین‌جا پیدا می‌شوند. اینکه در API‌های نیازمند احراز هویت نشست چطور منتقل می‌شود در نوشته نشست و کوکی در پایتون آمده است.
  • کارهای کوچک یک‌باره: برای جدولی پنجاه‌سطری صف راه نیندازید؛ گزینه‌های بدون کد در نوشته استخراج داده از وب‌سایت آمده است.

خطاهای رایج

  • جاگذاری شماره صفحه آخر در کد. کاتالوگ بزرگ می‌شود، 50 صفحه 53 تا می‌شود و سه صفحه آخر بی‌صدا جا می‌مانند. عدد را ننویسید، شرط توقف را بنویسید.
  • نگذاشتن سقف. عنصر «بعدی» که به خودش پیوند می‌دهد یا مکان‌نمایی که تکرار می‌شود، اسکریپت را ساعت‌ها در یک صفحه می‌چرخاند.
  • چسباندن دستی پیوند نسبی. در سایت تمرینی پیوند «بعدی» در صفحه اول catalogue/page-2.html و در صفحه دوم page-3.html است. نشانی‌ای که با جمع‌کردن رشته ساخته شود در صفحه دوم خراب می‌شود؛ urljoin هر دو را درست حل می‌کند.
  • نوشتن محل توقف جدا از رکوردها. کدی که اول «صفحه تمام شد» می‌نویسد و بعد رکوردها را اضافه می‌کند، اگر میان این دو بمیرد آن صفحه را برای همیشه از دست می‌دهد. برعکس‌کردن ترتیب هم به تکرار می‌انجامد. هر دو باید در یک تراکنش باشند.
  • پالایش تکرار با مجموعه‌ای در حافظه. وقتی فرایند دوباره راه بیفتد مجموعه خالی است؛ یکتایی کار پایگاه داده است.
  • دنبال‌کردن پیوندهای پنهان. کدی که هنگام جست‌وجوی پیوند صفحه‌بندی همه عنصرهای <a> صفحه را به صف می‌ریزد، به پیوندهای تله که برای آدم‌ها دیده نمی‌شوند هم وارد می‌شود. فقط عنصر صفحه‌بندی را انتخاب کنید؛ جزئیاتش در نوشته تله‌های هانی‌پات آمده است.

راهنمای انتخاب

وضعیتپیشنهاد
در صفحه پیوند «بعدی» هستپیوند را دنبال کنید، urljoin به کار ببرید
فقط شماره صفحه هست و صفحه آخر معلوم نیستعدد را زیاد کنید؛ صفحه خالی، 404 و «رکورد تازه‌ای نیست» را با هم به کار ببرید
در زبانه Network درخواست جیسون دیده می‌شودمرورگر را رها کنید، درخواست را مستقیم فراخوانی کنید
API مکان‌نما می‌دهدمکان‌نما را عیناً منتقل کنید، مکان‌نماهای دیده‌شده را در یک مجموعه نگه دارید
API هدر Link می‌دهدنشانی response.links["next"] را دنبال کنید
درخواست تکرارپذیر نیست، اسکرول بی‌نهایت هستبا Playwright یا Selenium اسکرول کنید، وقتی تعداد کارت‌ها زیاد نشد بایستید
فهرست بیش از 50 صفحه است یا خزش چند دقیقه طول می‌کشدصف SQLite بسازید، رکورد و وضعیت را در یک تراکنش بنویسید
فهرست هنگام خزش شما تغییر می‌کندکلید پایدار، INSERT OR IGNORE، ترتیب ثابت و در صورت نیاز دور دوم
فهرست فیلترشده یا وابسته به نشست، با پروکسینشست ثابت در کل فهرست، هویت تازه در پایان فهرست

پرسش‌های متداول

صفحه‌بندی با مکان‌نما چیست و تفاوتش با offset چیست؟

offset می‌گوید «از اول این‌قدر رکورد را رد کن»؛ مکان‌نما می‌گوید «آن‌هایی که بعد از این رکوردند را بده». در offset می‌توانید به هر صفحه‌ای بپرید، اما اگر فهرست تغییر کند رکوردها جابه‌جا می‌شوند و تکرار یا جاافتادگی پیش می‌آید. در مکان‌نما پرشی نیست، فقط به ترتیب پیش می‌روید؛ در عوض جایی که مانده‌اید حتی با تغییر فهرست ثابت می‌ماند. تفاوت عملی: خزش با offset را می‌توانید از صفحه چهلم ادامه بدهید، ولی اگر مکان‌نما منقضی شده باشد شاید مجبور شوید خزش مکان‌نمایی را از اول شروع کنید.

اسکرول بی‌نهایت چیست؟

یعنی جاوااسکریپت وقتی به ته صفحه نزدیک می‌شوید در پس‌زمینه درخواست تازه‌ای می‌فرستد و رکوردهای آمده را به ته فهرست می‌چسباند. کاربر شماره صفحه نمی‌بیند، اما API پشت آن تقریباً همیشه با شماره صفحه، offset یا مکان‌نما کار می‌کند. در اسکرپینگ هدف همان درخواست API است.

آیا می‌شود صفحه آخر را از پیش فهمید؟

گاهی. نوشته «Page 1 of 50» پایین صفحه، فیلد total_pages در پاسخ API یا نشانی rel="last" در هدر Link این را می‌گویند. از این اطلاعات برای نشان‌دادن پیشرفت و راستی‌آزمایی نتیجه استفاده کنید. باز هم حلقه را با شرط‌های توقف تمام کنید، چون تعداد کل ممکن است در جریان خزش تغییر کند.

آیا می‌شود صفحه‌ها را موازی خزش کرد؟

در نوع شماره صفحه و offset می‌شود، چون نشانی‌ها را از پیش می‌توانید بسازید. در نوع پیوند «بعدی» و مکان‌نما هر صفحه به صفحه پیشین وابسته است و زنجیره به ترتیب پیش می‌رود؛ موازی‌سازی تنها میان فهرست‌های متفاوت (دسته‌ها) برقرار می‌شود. موازی‌سازی محدودیت نرخ را از میان برنمی‌دارد: نرخ کل درخواست به یک سایت را همچنان محدود کنید.

چرا SQLite و نه پرونده CSV یا جیسون؟

افزودن به پرونده ساده است اما سه چیز را نمی‌دهد: کنترل یکتایی، نوشتن «یا همه یا هیچ» و پرس‌وجو از صف. SQLite هر سه را در یک پرونده و بدون نصب فراهم می‌کند. پس از پایان خزش، ریختن جدول items در CSV چند سطر است.

اگر سایت هنگام خزش تعداد رکورد هر صفحه را عوض کند چه می‌شود؟

در نوع پیوند «بعدی» و مکان‌نما هیچ اتفاقی نمی‌افتد، چون صفحه بعدی را سایت می‌گوید. در نوع شماره صفحه و offset مرزها جابه‌جا می‌شوند و ممکن است بعضی رکوردها دو بار بیایند و بعضی اصلاً نیایند. پالایش تکرار بر پایه کلید مشکل اول را حل می‌کند؛ برای دومی مقایسه با تعداد کل و در صورت نیاز دور دوم لازم است.

خلاصه

در خزش صفحه‌بندی‌شده کد به سه پرسش پاسخ می‌دهد: صفحه بعدی کجاست، فهرست کی تمام شد، جایی که مانده‌ام کجا نوشته شده. در اولی به جای ساختن نشانی، نشانه‌ای را که سایت می‌دهد (پیوند «بعدی»، مکان‌نما، هدر Link) دنبال کنید؛ در اسکرول بی‌نهایت اول درخواست API پشت آن را بجویید. در دومی به یک شرط تکیه نکنید، «رکورد تازه‌ای نیست» و سقف صفحه را همیشه اضافه کنید. در سومی رکوردها و وضعیت صف را در یک تراکنش SQLite بنویسید؛ فرایند هرجا بمیرد، خزش از همان صفحه ادامه می‌یابد. نرخ درخواست را پایین نگه دارید و از قواعد robots.txt پیروی کنید. گزینه‌های موقعیت و پخش بار در خدمات پروکسی ما آمده است.

پرسش از ChatGPTپرسش از Claude