ProxynetProxynet

چگونه با پایتون خزنده وب بسازیم؟ آموزش گام‌به‌گام

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

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

Acar Diveroli
نویسنده: Acar Diveroli
برچسب‌های SEED، QUEUE و PAGES روی یک خط؛ صف درون بیضی نقطه‌چین آبی است و عنوان خزنده پایین سمت چپ

فرض کنید فهرست همه صفحه‌های دسته‌بندی و محصول یک کتاب‌فروشی اینترنتی را لازم دارید که نقشه سایت ندارد. هدف آزمایشی ما books.toscrape.com است، سایتی که برای تمرین اسکرپینگ ساخته شده. خزنده‌ای که هر پیوندی را دنبال کند و به خاطر نسپارد کجا بوده، اینجا هرگز به پایان نمی‌رسد: بیشتر پیوندها به صفحه‌هایی برمی‌گردند که پیش‌تر دیده است. در اندازه‌گیری ما، از 4,098 پیوند در 60 صفحه نخست، 3,515 پیوند به نشانی‌هایی اشاره می‌کردند که خزنده از قبل می‌شناخت و هنوز 524 نشانی تازه در صف منتظر بودند.

در ادامه با Requests و BeautifulSoup و بدون هیچ فریم‌ورکی یک خزنده می‌سازیم: صف، مجموعه دیده‌شده‌ها، نرمال‌سازی نشانی، محدوده، محدودیت عمق، robots.txt، مکث میان درخواست‌ها و خروجی JSON Lines. اینکه خزنده چیست و چه تفاوتی با اسکرپر دارد، در صفحه خزنده وب و در نوشته وب اسکرپینگ و خزش وب آمده است. همه اعداد از اجرای اسکریپت نهایی در 25 سپتامبر 2026 به دست آمده‌اند.

خزنده وب پایتون به چه بخش‌هایی نیاز دارد؟

شش بخش، که هرکدام چند سطر پایتون است:

  • صف مرزی (frontier): صف نشانی‌هایی که هنوز باید دیده شوند، یک collections.deque.
  • مجموعه دیده‌شده‌ها (seen set): همه نشانی‌هایی که خزنده تا این لحظه به آن‌ها برخورده است.
  • دریافت‌کننده (fetcher): یک requests.Session با مهلت زمانی (timeout) و یک User-Agent صادق که نام ربات و راه تماس را می‌گوید (User-Agent چیست؟).
  • استخراج‌کننده پیوند: select("a[href]"). خواندن بقیه صفحه کار اسکرپر است (BeautifulSoup چیست).
  • فیلتر محدوده: میزبان، طرح نشانی (scheme) و نوع محتوا تعیین می‌کنند چه چیزی به حساب بیاید.
  • خروجی: برای هر صفحه یک شیء JSON در یک فایل .jsonl.

از ⁦Requests 2.34.2⁩ و ⁦Beautiful Soup 4.15.0⁩ استفاده می‌کنیم که در 25 سپتامبر 2026 نسخه‌های جاری PyPI هستند؛ Requests به ⁦Python 3.10⁩ یا جدیدتر نیاز دارد. Scrapy هر شش بخش را آماده دارد.

حلقه خزش گام‌به‌گام چگونه کار می‌کند؟

  1. بذر (seed). نشانی آغازین را نرمال‌سازی کنید، (url, 0) را در صف و خود نشانی را در مجموعه دیده‌شده‌ها بگذارید.
  2. برداشتن. قدیمی‌ترین ورودی را با popleft() بردارید.
  3. پرسیدن از robots.txt. قواعد میزبان را از حافظه نهان (cache) بخوانید و فقط یک بار دریافتشان کنید.
  4. صبر، سپس دریافت. فاصله میان درخواست‌ها را برای هر میزبان جدا نگه دارید، بعد درخواست GET بفرستید.
  5. بررسی پاسخ. نشانی نهایی پس از تغییر مسیرها (redirect) و هدر Content-Type را بخوانید.
  6. استخراج و فیلتر. هر href را با urljoin به نشانی کامل برسانید، نرمال‌سازی کنید و هرچه دیده‌شده، بیرون از سایت یا بیش از حد عمیق است را کنار بگذارید.
  7. ثبت و افزودن به صف. یک سطر JSON بنویسید و نشانی‌های تازه را با depth + 1 به صف اضافه کنید.

حلقه وقتی تمام می‌شود که صف خالی شود یا تعداد صفحه‌ها به سقف برسد.

کدام بخش جلوی کدام مشکل را می‌گیرد؟

بخشجلوی چه چیزی را می‌گیرددر پایتونبدون آن
مجموعه دیده‌شده‌ها، پرشده هنگام ورود به صفدریافت دوباره یک صفحهset، افزودن هنگام ورود به صف3,515 پیوند آشنا در اجرای books ما دوباره دریافت می‌شدند
محدودیت عمق و سقف صفحهزنجیره‌های بی‌پایان مثل تقویم‌ها و فیلترها(url, depth)، --depth، --max-pagesصفی که مدام بزرگ‌تر می‌شود (524 نشانی منتظر پس از 60 صفحه)
نرمال‌سازی نشانییک صفحه با چند نگارشurldefrag، urlsplit، parse_qslهمان صفحه بیش از یک بار دریافت و ذخیره می‌شود
بررسی تغییر مسیر و نوع محتوالغزیدن به سایتی دیگر، تجزیه PDF به‌جای HTMLr.url، Content-Type، stream=Trueپیوندهای صفحه‌های سایت‌های دیگر وارد صف می‌شوند
حافظه نهان robots.txt و مکث برای هر میزبانمسیرهای ممنوع، رگبار درخواست‌هاRobotFileParser.parse()، time.monotonic()سایت با 429 پاسخ می‌دهد

چرا صف باید deque باشد و نه list؟

deque.popleft() قدیمی‌ترین نشانی را برمی‌دارد، پس خزش به شیوه سطح‌اول (BFS) پیش می‌رود: اول صفحه آغازین، بعد صفحه‌هایی که یک کلیک فاصله دارند، بعد دو کلیک. صفحه‌های نزدیک به صفحه اصلی زودتر می‌رسند و محدودیت عمق معنا پیدا می‌کند، چون عمق به ترتیب بالا می‌رود. برداشتن با pop() از همان سری که به آن اضافه می‌کنید، خزش را عمق‌اول (DFS) می‌کند: یک شاخه تا ته، بعد شاخه بعدی.

لیست پایتون هم می‌تواند نقش صف را بازی کند، اما pop(0) همه عناصر باقی‌مانده را جابه‌جا می‌کند (O(n))، در حالی که deque از هر دو سر تقریباً در زمان ثابت برمی‌دارد (مستندات collections). خزنده‌های بازگشتی در واقع همان DFS با ظاهری دیگرند و ممکن است با رسیدن به حد پیش‌فرض پایتون، یعنی 1,000 فراخوانی تودرتو، با RecursionError متوقف شوند.

BFS همچنین هر نشانی را نخستین بار در کمترین عمقش می‌بیند، پس پیوندی که در همان برخورد اول بیش از حد عمیق است می‌تواند دیده‌شده علامت بخورد و کنار گذاشته شود.

نرمال‌سازی نشانی را چگونه در کد بنویسیم؟

مجموعه دیده‌شده‌ها رشته‌ها را با هم مقایسه می‌کند، پس normalize() به هر صفحه یک نگارش واحد می‌دهد:

  • urldefrag() بخش #reviews را حذف می‌کند. طبق بخش 3.5 از ⁦RFC 3986⁩، قطعه نشانی (fragment) هرگز به سرور نمی‌رسد.
  • طرح نشانی و میزبان به حروف کوچک تبدیل می‌شوند؛ urlsplit().hostname از قبل کوچک است.
  • پورت‌های پیش‌فرض (:80 برای http و :443 برای https) حذف می‌شوند و مسیر خالی به / تبدیل می‌شود؛ بخش 6.2.3 همان RFC این دو را هم‌ارز می‌داند.
  • پارامترهای ردیابی (utm_*، gclid، fbclid) حذف و بقیه مرتب می‌شوند، تا ?b=2&a=1 و ?a=1&b=2 یکی شوند.
  • پیوندهای mailto:، tel: و javascript: و پورت‌های خراب مقدار None برمی‌گردانند.

هرگز کل رشته پرس‌وجو (query string) را پاک نکنید: ?page=2 صفحه دیگری است. اسلش پایانی هم می‌ماند، چون /a و /a/ ممکن است با هم فرق کنند؛ اگر فرقی نداشته باشند، تغییر مسیر خودش این را به شما می‌گوید. در books.toscrape.com نشانی‌های / و /index.html یک صفحه را نشان می‌دادند و فقط قاعده‌ای مخصوص همان سایت می‌توانست آن دو را یکی کند.

مجموعه دیده‌شده‌ها طرح نشانی را هم نادیده می‌گیرد. در quotes.toscrape.com صفحه‌های نویسنده از https به http تغییر مسیر می‌دهند و پیوندهای نسبی‌شان پس از آن به http://quotes.toscrape.com/ اشاره می‌کنند؛ به همین دلیل نخستین اجرای آزمایشی ما صفحه اصلی و صفحه ورود را دو بار دریافت کرد. page_key() طرح نشانی را کنار می‌گذارد، پس هر دو نگارش یک صفحه به حساب می‌آیند.

سایت‌های تمرینی نه قطعه نشانی دارند و نه برچسب ردیابی، پس این بررسی‌ها با ورودی ساختگی انجام می‌شوند:

python
from crawler import normalize

assert normalize("https://Example.COM:443/a#reviews") == "https://example.com/a"
assert normalize("http://example.com") == "http://example.com/"
assert normalize("https://example.com:8443/a") == "https://example.com:8443/a"
assert normalize("https://example.com/list?b=2&a=1") == "https://example.com/list?a=1&b=2"
assert normalize("https://example.com/list?utm_source=mail&page=2") == "https://example.com/list?page=2"
assert normalize("mailto:shop@example.com") is None
assert normalize("javascript:void(0)") is None
assert normalize("https://example.com:abc/") is None
assert normalize("https://example.com/a/") != normalize("https://example.com/a")
print("all normalize checks passed")

محدوده: همان میزبان، تغییر مسیرها و نوع محتوا

self.hosts فقط میزبان آغازین را نگه می‌دارد. اگر سایتی هم www.example.com و هم example.com را به کار می‌برد، هر دو را دستی اضافه کنید؛ بررسی‌ای مثل endswith("example.com") نشانی notexample.com را هم راه می‌دهد.

Requests برای همه متدها به جز HEAD تغییر مسیرها را دنبال می‌کند و نشانی نهایی را در r.url نگه می‌دارد (راهنمای شروع سریع Requests)، پس محدوده را روی r.url بررسی کنید و پیوندهای نسبی را هم نسبت به همان حل کنید. در quotes.toscrape.com نشانی /author/Jane-Austen به http://quotes.toscrape.com/author/Jane-Austen/ تغییر مسیر می‌دهد؛ در اجرای ما با عمق 2، از 149 صفحه 40 صفحه این‌طور رسیدند. تغییر مسیری که به بیرون از سایت برود کنار گذاشته می‌شود، اما Requests مقصد را پیش‌تر دریافت کرده است. اگر نمی‌خواهید حتی همین درخواست فرستاده شود، allow_redirects=False بدهید و هدر Location را خودتان در صف بگذارید.

با stream=True، Requests به محض رسیدن هدرها پاسخ را برمی‌گرداند. اگر Content-Type از نوع text/html نباشد، اتصال بدون خواندن بدنه بسته می‌شود.

محدودیت عمق و سقف صفحه را چگونه تعیین کنیم؟

ورودی‌های صف به شکل (url, depth, attempts) هستند. پیوندی که depth + 1 آن از --depth بگذرد، too_deep شمرده می‌شود و بیرون می‌ماند؛ --max-pages اجرا را بی‌توجه به آنچه در صف است تمام می‌کند. روی سایت‌های تمرینی:

  • quotes.toscrape.com، عمق 2، سقف 500: صف پس از 149 صفحه خودبه‌خود خالی شد (به تفکیک عمق 1، 46 و 102)؛ 30 نشانی بیش از حد عمیق بودند.
  • همان سایت، سقف 60: اجرا در 60 صفحه متوقف شد و 89 نشانی هنوز در صف بودند.
  • books.toscrape.com، عمق 2، سقف 60: هر 60 صفحه از عمق‌های 0 و 1 آمدند و 524 نشانی منتظر ماندند. این اجرا را سقف صفحه تمام کرد، نه عمق.

عمق را از روی ساختار سایت انتخاب کنید (صفحه اصلی، دسته، کالا یعنی عمق 2؛ هر صفحه بعدی فهرست یک سطح به عمق اضافه می‌کند) و سقف را از روی بودجه‌تان. پیوندهایی که برای به دام انداختن ربات‌ها کاشته می‌شوند در نوشته تله‌های هانی‌پات آمده‌اند.

robots.txt و مکث‌ها: نسخه کوتاه

خزنده برای هر ترکیب طرح نشانی و میزبان یک RobotFileParser نگه می‌دارد، robots.txt را با نشست خودش (مهلت زمانی، User-Agent ربات، پروکسی) دانلود می‌کند و سطرها را به parse() می‌دهد. از read() استفاده نمی‌کنیم: در ⁦Python 3.13.9⁩ این متد از urllib بدون مهلت زمانی و با User-Agent خود urllib استفاده می‌کند و در خطاهای شبکه استثنا می‌دهد. پاسخ 5xx یا فایلی که در دسترس نیست یعنی هیچ صفحه‌ای خزش نشود، همان‌طور که ⁦RFC 9309⁩ می‌خواهد؛ پاسخ 4xx یعنی قاعده‌ای در کار نیست (هر دو سایت تمرینی 404 برگرداندند). نحو فایل: فایل robots.txt چیست.

پیش از هر درخواست، خزنده مطمئن می‌شود که از آخرین درخواستش به آن میزبان، --delay (به‌طور پیش‌فرض 1 ثانیه) یا Crawl-delay سایت، هرکدام که بیشتر است، گذشته باشد. با Crawl-delay: 2 روی یک سایت آزمایشی محلی و --delay 0.2، سرور فاصله‌های 2 ثانیه‌ای ثبت کرد. با دیدن 429، خزنده آن میزبان را متوقف می‌کند و مقدار Retry-After را چاپ می‌کند. تلاش دوباره به شکل درست در نوشته کدهای وضعیت HTTP در وب اسکرپینگ آمده و همین بررسی‌ها همراه با صف SQLite در صفحه‌بندی در وب اسکرپینگ.

کد کامل: خزنده وب پایتون بدون فریم‌ورک

دو کتابخانه را در یک محیط مجازی نصب کنید، اسکریپت را با نام crawler.py ذخیره کنید و یک نشانی آغازین به آن بدهید:

bash
pip install requests==2.34.2 beautifulsoup4==4.15.0
python crawler.py https://books.toscrape.com/ --depth 2 --max-pages 60
python
"""A breadth-first crawler for one site: Requests + BeautifulSoup, no framework."""
import argparse
import json
import time
from collections import Counter, deque
from urllib.parse import parse_qsl, urldefrag, urlencode, urljoin, urlsplit, urlunsplit
from urllib.robotparser import RobotFileParser

import requests
from bs4 import BeautifulSoup

BOT_NAME = "ExampleSiteMapper"
USER_AGENT = f"{BOT_NAME}/1.0 (+https://example.com/bot; bot@example.com)"
TIMEOUT = 15      # seconds; without a timeout Requests can wait forever
MAX_RETRIES = 2   # a URL goes back to the end of the queue at most twice
TRACKING = {"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "gclid", "fbclid"}
DEFAULT_PORTS = {"http": 80, "https": 443}


def normalize(url):
    """One spelling per page, or None for links a crawler should not follow."""
    url, _fragment = urldefrag(url.strip())
    parts = urlsplit(url)
    scheme = parts.scheme.lower()
    try:
        port = parts.port
    except ValueError:  # a broken port such as ":abc"
        return None
    if scheme not in DEFAULT_PORTS or not parts.hostname:
        return None  # mailto:, tel:, javascript:, ftp:, ...
    host = parts.hostname  # already lowercase
    if port and port != DEFAULT_PORTS[scheme]:
        host = f"{host}:{port}"
    query = sorted((k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
                   if k not in TRACKING)
    return urlunsplit((scheme, host, parts.path or "/", urlencode(query), ""))


def page_key(url):
    """The seen-set key: http:// and https:// of one address count as one page."""
    return url.split("://", 1)[1]


class Crawler:
    def __init__(self, start, max_depth, max_pages, delay, out, proxy=None):
        self.start = normalize(start)
        self.hosts = {urlsplit(self.start).hostname}  # add a "www." twin here on purpose
        self.max_depth, self.max_pages, self.delay, self.out = max_depth, max_pages, delay, out
        self.queue = deque([(self.start, 0, 0)])  # (url, depth, attempts)
        self.seen = {page_key(self.start)}  # marked when a URL is first met, not when fetched
        self.robots, self.last, self.stopped = {}, {}, {}
        self.stats, self.depths = Counter(), Counter()
        self.session = requests.Session()
        self.session.headers["User-Agent"] = USER_AGENT
        # per request: session.proxies would lose to HTTP(S)_PROXY environment variables
        self.proxies = {"http": proxy, "https": proxy} if proxy else None

    def in_scope(self, url):
        return urlsplit(url).hostname in self.hosts

    def fetch(self, url):
        """GET with a per-host pause. stream=True reads the headers before the body."""
        parts = urlsplit(url)
        rules = self.robots.get(f"{parts.scheme}://{parts.netloc}")
        gap = max(self.delay, (rules.crawl_delay(BOT_NAME) if rules else None) or 0)
        pause = self.last.get(parts.netloc, float("-inf")) + gap - time.monotonic()
        if pause > 0:
            time.sleep(pause)
        try:
            return self.session.get(url, timeout=TIMEOUT, stream=True, proxies=self.proxies)
        finally:
            self.last[parts.netloc] = time.monotonic()  # per host: http and https share it

    def robots_ok(self, url):
        root = "{0.scheme}://{0.netloc}".format(urlsplit(url))
        if root not in self.robots:
            rules = RobotFileParser()
            try:
                with self.fetch(root + "/robots.txt") as r:
                    status = r.status_code
                    rules.parse(r.text.splitlines() if status == 200 else [])
            except requests.RequestException:
                status = None
                rules.parse([])
            if status is None or status >= 500:
                rules.disallow_all = True  # robots.txt unreachable: crawl nothing on this host
            self.robots[root] = rules
        return self.robots[root].can_fetch(BOT_NAME, url)

    def extract_links(self, html, base):
        soup = BeautifulSoup(html, "html.parser")
        title = " ".join(soup.title.get_text().split()) if soup.title else ""
        return title, [urljoin(base, a["href"]) for a in soup.select("a[href]")]

    def enqueue(self, links, depth):
        for link in links:
            self.stats["raw_links"] += 1
            url = normalize(link)
            if url is None:
                self.stats["not_http"] += 1
                continue
            if page_key(url) in self.seen:
                self.stats["duplicate"] += 1
                continue
            self.seen.add(page_key(url))  # BFS meets every URL first at its lowest depth
            if not self.in_scope(url):
                self.stats["offsite"] += 1
            elif depth + 1 > self.max_depth:
                self.stats["too_deep"] += 1
            else:
                self.queue.append((url, depth + 1, 0))
                self.stats["queued"] += 1

    def retry(self, url, depth, attempts, why):
        if attempts < MAX_RETRIES:
            self.queue.append((url, depth, attempts + 1))
            self.stats["retried"] += 1
        else:
            self.stats["failed"] += 1
            print(f"giving up on {url}: {why}")

    def run(self):
        started = time.monotonic()
        with open(self.out, "w", encoding="utf-8", newline="\n") as out:  # JSON Lines: \n, no BOM
            while self.queue and self.stats["fetched"] < self.max_pages:
                url, depth, attempts = self.queue.popleft()
                host = urlsplit(url).netloc
                if host in self.stopped:
                    self.stats["skipped_stopped_host"] += 1
                    continue
                if not self.robots_ok(url):
                    self.stats["robots_disallowed"] += 1
                    continue
                try:
                    r = self.fetch(url)
                except requests.RequestException as exc:
                    self.retry(url, depth, attempts, type(exc).__name__)
                    continue
                with r:
                    if r.status_code == 429:
                        self.stopped[host] = r.headers.get("Retry-After", "not sent")
                        print(f"429 from {host}, host stopped (Retry-After: {self.stopped[host]})")
                        continue
                    if r.status_code >= 500:
                        self.retry(url, depth, attempts, f"HTTP {r.status_code}")
                        continue
                    final = normalize(r.url)
                    moved = page_key(final) != page_key(url)  # not just http -> https
                    if not self.in_scope(final) or (moved and page_key(final) in self.seen):
                        self.stats["redirect_skipped"] += 1  # left the site, or a known page
                        continue
                    is_html = "text/html" in r.headers.get("Content-Type", "")
                    try:  # the body is downloaded here, and only for HTML
                        html = r.content if r.status_code == 200 and is_html else b""
                    except requests.RequestException as exc:  # connection broke mid-body
                        self.retry(url, depth, attempts, type(exc).__name__)
                        continue
                    if final != url:
                        self.stats["redirected"] += 1
                        self.seen.add(page_key(final))
                    self.stats["fetched"] += 1
                    self.depths[depth] += 1
                    title, links = "", []
                    if html:
                        self.stats["html_bytes"] += len(html)  # for a traffic estimate
                        title, links = self.extract_links(html, r.url)
                    elif not is_html:
                        self.stats["not_html"] += 1  # body never downloaded
                    record = {"url": url, "final_url": final, "depth": depth,
                              "status": r.status_code, "title": title, "links_found": len(links)}
                    out.write(json.dumps(record, ensure_ascii=False) + "\n")
                    print(f"{r.status_code} d{depth} {url}")
                    self.enqueue(links, depth)
        print(f"\n{self.stats['fetched']} pages in {time.monotonic() - started:.1f} s,"
              f" by depth {dict(sorted(self.depths.items()))}, left in queue {len(self.queue)}")
        for key, value in sorted(self.stats.items()):
            print(f"  {key:<21}{value}")


if __name__ == "__main__":
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("start", help="start URL, e.g. https://books.toscrape.com/")
    ap.add_argument("--depth", type=int, default=2, help="link hops from the start URL")
    ap.add_argument("--max-pages", type=int, default=100, help="hard ceiling on fetched pages")
    ap.add_argument("--delay", type=float, default=1.0, help="minimum seconds between requests to one host")
    ap.add_argument("--out", default="pages.jsonl")
    ap.add_argument("--proxy", help="e.g. http://user:pass@pr.proxynet.io:8000")
    args = ap.parse_args()
    Crawler(args.start, args.depth, args.max_pages, args.delay, args.out, args.proxy).run()

هنگام تغییر کد، سه نکته مهم است:

  • تلاش‌های دوباره به ته صف می‌روند، حداکثر دو بار، پس از خطای اتصال یا 5xx. یک صفحه آزمایشی محلی که دو بار 500 داد، در تلاش سوم 200 برگرداند. تلاش دوباره درون خود Requests: خطای Max Retries Exceeded With URL.
  • فایل خروجی JSON Lines ساده است. encoding="utf-8" هیچ نشانه ترتیب بایت (BOM) نمی‌نویسد و newline="\n" پایان سطر \n را که JSON Lines می‌خواهد حفظ می‌کند؛ در غیر این صورت ویندوز \r\n می‌نویسد. ensure_ascii=False متن غیر ASCII را خوانا نگه می‌دارد. عنوانی با حروف ترکی پس از نوشتن و خواندن دوباره سالم ماند (خطاهای کدگذاری یونیکد در پایتون).
  • فقط GET. خزنده /login را مثل هر صفحه دیگری باز می‌کند و هرگز فرمی را ارسال نمی‌کند.

وقتی اجرایش کردیم چه دیدیم

از ⁦Python 3.13.9⁩، مکث پیش‌فرض یک‌ثانیه‌ای و عمق 2 استفاده کردیم. روی quotes.toscrape.com با سقف 500:

text
149 pages in 179.1 s, by depth {0: 1, 1: 46, 2: 102}, left in queue 0
  duplicate            2916
  fetched              149
  html_bytes           722227
  offsite              2
  queued               148
  raw_links            3096
  redirected           40
  too_deep             30

روی books.toscrape.com با سقف 60:

text
60 pages in 69.8 s, by depth {0: 1, 1: 59}, left in queue 524
  duplicate            3515
  fetched              60
  html_bytes           2047561
  queued               583
  raw_links            4098

در اجرای quotes، 94% پیوندها به نشانی‌های آشنا اشاره می‌کردند و فقط 2 نشانی متمایز بیرون از سایت بود. دو سطر از خروجی، یکی ساده و یکی با تغییر مسیر:

json
{"url": "https://books.toscrape.com/", "final_url": "https://books.toscrape.com/", "depth": 0, "status": 200, "title": "All products | Books to Scrape - Sandbox", "links_found": 94}
{"url": "https://quotes.toscrape.com/author/Jane-Austen", "final_url": "http://quotes.toscrape.com/author/Jane-Austen/", "depth": 1, "status": 200, "title": "Quotes to Scrape", "links_found": 4}

یک سایت محلی کوچک شاخه‌های خطا را پوشش داد. خزنده یک مسیر ممنوع، سه پیوند غیروب و یک تغییر مسیر به میزبانی دیگر را رد کرد، یک PDF را نخوانده بست و یک 404 را ثبت کرد. یک 429 با Retry-After: 120 میزبان را متوقف کرد و 503 روی robots.txt باعث شد هیچ صفحه‌ای دریافت نشود.

پروکسی کجای خزنده قرار می‌گیرد؟

پروکسی‌ای که با گزینه --proxy http://user:pass@pr.proxynet.io:8000 داده می‌شود، در هر درخواست، از جمله درخواست robots.txt، به کار می‌رود. کد session.proxies را تنظیم نمی‌کند، چون مستندات Requests هشدار می‌دهد که متغیرهای محیطی پروکسی بر آن غلبه می‌کنند (استفاده پیشرفته). از طریق یک پروکسی آزمایشی محلی با رمز عبور، 10 صفحه نخست books همان سطرهایی را داد که بدون پروکسی به دست آمده بود، حتی وقتی یک پروکسی از کار افتاده در HTTPS_PROXY تنظیم شده بود.

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

ترافیک پروکسی مسکونی چرخشی به‌ازای هر گیگابایت محاسبه می‌شود، پس اول html_bytes را در یک اجرای کوتاه اندازه بگیرید. هر دو سایت تمرینی HTML فشرده‌نشده فرستادند، حدود 34 کیلوبایت برای هر صفحه books و 5 کیلوبایت برای هر صفحه quotes: 10,000 صفحه تقریباً 0.34 یا 0.05 گیگابایت می‌شود، به‌علاوه هدرها. مکث و robots.txt برای همه IPهای خروجی برقرارند و چرخش IP دلیلی نیست که به یک سایت بیش از آنچه اجازه می‌دهد درخواست بفرستید.

کاربردها

خطاهای رایج

  • list.pop(0) به‌عنوان صف. هر فراخوانی کل فهرست را جابه‌جا می‌کند.
  • فراخوانی بازگشتی برای هر پیوند. خزش عمق‌اول می‌شود و ممکن است با RecursionError تمام شود.
  • علامت‌زدن نشانی‌ها به‌عنوان دیده‌شده فقط پس از دریافت. یک نشانی بارها وارد صف می‌شود.
  • پاک‌کردن کل رشته پرس‌وجو. ?page=2 و همه صفحه‌های بعدی ناپدید می‌شوند.
  • حل پیوندها نسبت به نشانی درخواست‌شده. پس از تغییر مسیر، پایه r.url است.
  • نبودن مهلت زمانی. بدون آن، Requests ممکن است تا بی‌نهایت منتظر بماند.
  • تجزیه فایل‌های PDF و ZIP به‌جای HTML. اول Content-Type را بررسی کنید.
  • نبودن سقف صفحه. در سایتی با فیلتر یا تقویم، اجرا هرگز تمام نمی‌شود.
  • پرکردن فرم‌ها. خزنده صفحه‌ها را با GET می‌خواند؛ فرستادن داده کار دیگری است (ارسال JSON با POST در Python Requests).

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

نیازپیشنهاد
چند صد صفحه از یک سایت، با دیدن سازوکار کاراسکریپت همین نوشته
ادامه یک خزش طولانی پس از توقفصفی روی دیسک: نسخه SQLite در صفحه‌بندی در وب اسکرپینگ
درخواست‌های زیاد به‌طور هم‌زمانهمروندی، با اندازه‌ای که در همروندی و موازی‌سازی آمده
هزاران صفحه، با تلاش دوباره و خروجی‌گرفتن آمادهScrapy (CrawlSpider، تنظیم پروکسی)
پیوندهایی که فقط پس از اجرای جاوااسکریپت ظاهر می‌شوندخزنده‌ای مبتنی بر مرورگر، مثل PlaywrightCrawler در Crawlee for Python، یا Crawl4AI
سایت نقشه سایت منتشر می‌کنداول آن را بخوانید و فقط برای آنچه در آن نیست خزش کنید (چگونه نقشه سایت را پیدا کنیم)
خزشی که روی میزبان‌های زیادی پخش استپروکسی چرخشی با همان نرخ مؤدبانه برای هر میزبان

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

برای خزنده وب از کدام کتابخانه پایتون استفاده کنم؟

برای چند صد صفحه، Requests برای دانلود و BeautifulSoup برای خواندن پیوندها کافی است. برای هزاران صفحه با تلاش دوباره، خروجی‌گرفتن و زمان‌بندی، Scrapy شما را از نوشتن این کدها بی‌نیاز می‌کند. پیوندهایی که جاوااسکریپت می‌سازد به ابزاری مبتنی بر مرورگر نیاز دارند.

می‌توانم با BeautifulSoup خزنده بسازم یا به Scrapy نیاز دارم؟

می‌توانید. BeautifulSoup یک تجزیه‌گر (parser) است: پیوندها را از HTML می‌خواند، اما صفحه دانلود نمی‌کند، صف نگه نمی‌دارد و میان درخواست‌ها صبر نمی‌کند. اسکریپت بالا این‌ها را در کمتر از 200 سطر می‌نویسد. Scrapy این‌ها را آماده دارد و در کارهای بزرگ‌تر به صرفه است.

آیا خزنده پایتون پیوندهایی را که با جاوااسکریپت بارگذاری می‌شوند می‌بیند؟

نه. Requests اسکریپت اجرا نمی‌کند، پس پیوندهایی که جاوااسکریپت اضافه می‌کند هرگز به BeautifulSoup نمی‌رسند. اول دنبال درخواست JSON پشت صفحه بگردید که بتوانید آن را مستقیم فراخوانی کنید. اگر نبود، جایی که شرایط سایت خودکارسازی را مجاز می‌داند، از خزنده‌ای مبتنی بر مرورگر استفاده کنید.

آیا خزنده را چندرشته‌ای (multithreaded) کنم؟

فقط وقتی چند میزبان را خزش می‌کنید. در یک سایت، مکث میان درخواست‌ها سرعت را تعیین می‌کند و رشته‌های اضافه فقط منتظر می‌مانند یا فاصله را زیر پا می‌گذارند. میان میزبان‌های زیاد، یک کارگر مؤدب برای هر میزبان کمک می‌کند (همروندی و موازی‌سازی).

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

از نقشه سایت شروع کنید که معمولاً نامش در robots.txt آمده یا در /sitemap.xml پیدا می‌شود. اگر نبود، خزنده‌ای مثل همین را با محدودیت عمق و سقف صفحه اجرا کنید و نتیجه را فهرست صفحه‌هایی بدانید که پیوندها به آن‌ها می‌رسند، نه فهرستی کامل.

آیا نوشتن و اجرای خزنده وب قانونی است؟

این مشاوره حقوقی نیست. پاسخ به کشور شما، شرایط سایت، اینکه صفحه‌ها داده شخصی دارند یا نه و کاری که با نسخه‌ها می‌کنید بستگی دارد. از robots.txt پیروی کنید، نرخ درخواست را پایین نگه دارید و از بخش‌های پشت صفحه ورود دور بمانید. وضعیت کشور به کشور: آیا اسکرپینگ وب قانونی است؟

خلاصه

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

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