---
title: "Sayfalama (Pagination) Nedir, Scraping'de Nasıl Taranır?"
description: "Sayfalama, uzun bir listenin parçalara bölünerek sunulmasıdır. Beş sayfalama türünü, durma koşulunu ve yarım kalan taramayı SQLite ile sürdürmeyi anlatıyoruz."
url: https://proxynet.io/tr/blog/pagination-web-scraping
date: 2026-09-19
author: "Acar Diveroli"
category: "Web Scraping, Nasıl Yapılır"
lang: tr
---

# Sayfalama (Pagination) Nedir, Scraping'de Nasıl Taranır?

Bin ürünlük bir kataloğu çekmek için yazdığınız betik ilk sayfadaki yirmi ürünü sorunsuz okur. Asıl iş ondan sonra başlar: kalan 49 sayfanın adresini bulmak, listenin bittiğini anlamak, aynı ürünü iki kez kaydetmemek ve 31. sayfada bağlantı koptuğunda baştan başlamamak. Tek sayfayı okuyan kod ile bütün listeyi eksiksiz toplayan kod arasındaki farkın adı sayfalamadır.

Bu yazıda sayfalamayı kısaca tanımlayıp konuya tarayan tarafın gözünden bakıyoruz: beş sayfalama türü geliştirici araçlarında nasıl tanınır, sıradaki sayfa nereden öğrenilir, tarama ne zaman durur, tekrar eden kayıtlar nasıl ayıklanır. Merkezde SQLite üzerinde duran bir URL kuyruğu var; süreci ortasında öldürdüğümüz tarama bu kuyruk sayesinde kaldığı sayfadan devam etti. Kodları `books.toscrape.com` ve `quotes.toscrape.com` alıştırma sitelerinde çalıştırdık.

> **Not: Kısa cevap**
>
> Sayfalama (pagination), uzun bir listenin sunucu tarafından parçalara bölünüp her istekte yalnızca bir parçasının gönderilmesidir. Scraping'de iş üç karara iner: sıradaki sayfayı nereden öğreneceksiniz ("sonraki" bağlantısı, sayfa numarası, `offset`, `cursor` ya da `Link` başlığı), ne zaman duracaksınız (bağlantı yok, boş sayfa, yeni kayıt yok) ve kaldığınız yeri nereye yazacaksınız. Sonuncusunun sağlam cevabı, kayıtları ve kuyruğu aynı veritabanı işleminde güncelleyen küçük bir SQLite tablosudur.

## Sayfalama (pagination) nedir?

Bir veritabanında on bin kayıt varsa sunucu bunların hepsini tek yanıtta göndermez. Hem sorgu ağırlaşır hem de yanıt, kullanıcının bakmayacağı binlerce satırla şişer. Bunun yerine liste sabit büyüklükte parçalara bölünür ve istemci her seferinde bir parça ister. Kategori sayfasının altındaki "1 2 3 ... 50" bağlantıları, bir API'nin `?page=2` parametresi ve aşağı indikçe yüklenen akışlar aynı fikrin farklı görünümleridir.

Sayfalamayı tasarlayan geliştirici "hangi yöntem veritabanını daha az yorar" diye sorar. Tarayan tarafın sorusu başkadır: site hangi yöntemi seçmiş ve bunu dışarıdan nasıl anlarım? Toplu veri çekmenin genel çerçevesi [veri kazıma çözümü](/tr/data-scraping), bağlantıdan bağlantıya gezen tarayıcıların kurgusu [web crawler](/tr/web-crawler) sayfamızda.

## Sayfalama türleri nelerdir ve nasıl tanınır?

Tanımanın yolu her türde aynıdır: tarayıcıda geliştirici araçlarını açın, Network sekmesini temizleyin, ikinci sayfaya geçin ve neyin değiştiğine bakın. Adres çubuğu mu değişti, yoksa arka planda bir istek mi gitti?

**1. Sayfa numaralı URL.** Adres `?page=2`, `/page/2/` ya da `page-2.html` biçiminde değişir. En kolay tanınan türdür, döngü de basittir: sayıyı artırırsınız. Zayıf yanı, son sayfanın kaç olduğunu çoğu zaman bilmemenizdir.

**2. "Sonraki" bağlantısı.** Sayfanın altında bir sonraki sayfaya giden bir `<a>` öğesi vardır. Adresi siz üretmezsiniz, sayfadan okursunuz. Bağlantı yoksa liste bitmiştir. Site adres yapısını değiştirdiğinde kodunuz bozulmaz; HTML listelerde ilk tercih budur. Seçici mantığını [CSS Selector ve XPath](/tr/blog/css-selector-vs-xpath) yazımızda anlattık.

**3. offset/limit.** API isteğinde `offset=40&limit=20` ya da `skip` ve `take` gibi iki parametre görürsünüz: "ilk 40 kaydı atla, 20 tane ver". Sayfa numarasının API'deki karşılığıdır.

**4. Cursor (imleç).** Yanıtın içinde `next_cursor`, `after` ya da `next_page_token` gibi anlamsız görünen bir dizgi gelir ve bir sonraki istekte bu dizgiyi aynen geri gönderirsiniz. İmleç "en son şu kaydı verdim" bilgisini taşır; içini çözmeye çalışmazsınız, yalnızca taşırsınız.

**5. Sonsuz kaydırma ve "daha fazla yükle".** Adres çubuğu değişmez. Sayfanın sonuna indiğinizde ya da düğmeye bastığınızda Network sekmesinde yeni bir Fetch/XHR isteği belirir. O isteğe baktığınızda çoğu zaman yukarıdaki üç türden birini görürsünüz. Sonsuz kaydırma ayrı bir yöntem değil, API sayfalamasının üzerine giydirilmiş arayüzdür.

Bunlara bir de `rel="next"` işaretini eklemek gerekir. İki yerde karşınıza çıkar. Birincisi HTML'in `<head>` bölümündeki `<link rel="next" href="...">` etiketidir. [Google bu etiketi artık kullanmadığını açıkça yazıyor](https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading), ama birçok site hâlâ basıyor ve bulduğunuzda "sonraki" bağlantısının temiz bir kopyasıdır. İkincisi HTTP yanıtındaki `Link` başlığıdır; biçimini [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288) tanımlar, GitHub gibi servisler sıradaki sayfanın tam adresini bu başlıkta verir.

| Tür | Nasıl tanınır | Sıradaki sayfa nereden gelir | Durma koşulu | Tuzağı |
|---|---|---|---|---|
| Sayfa numarası | Adreste `page=2`, `/page/2/` | Sayıyı siz artırırsınız | 404, boş liste ya da tekrar eden içerik | Son sayfadan sonrası siteye göre değişir |
| "Sonraki" bağlantısı | Sayfa altında `<a>`, `<head>` içinde `rel="next"` | Sayfadan okunur | Bağlantı yok | Göreli adresi tam adrese çevirmeyi unutmak |
| offset/limit | API isteğinde `offset`, `limit`, `skip` | `offset += limit` | Eksik ya da boş parça | Liste değişirken kayıt kayar |
| Cursor | Yanıtta `next_cursor`, `after`, belirteç | Yanıttan aynen taşınır | İmleç boş ya da yok | İmlecin süresi dolar, ortadan başlanamaz |
| Sonsuz kaydırma, "daha fazla yükle" | Adres değişmez, XHR isteği gider | Alttaki API'nin kuralı | API'nin kuralı ya da yeni kart gelmemesi | Tarayıcıyla kaydırmak gereksiz yere pahalıdır |

## Bir sayfalama döngüsü hangi adımlardan oluşur?

Tür ne olursa olsun döngü aynı altı adımı izler:

1. **Başlangıç adresini kuyruğa koyun.** Kategori sayfası ya da API'nin ilk isteği.
2. **Sıradaki adresi alın, izinli mi diye bakın.** `robots.txt` o yolu yasaklıyorsa istek hiç gitmez.
3. **Bekleyin, sonra isteği gönderin.** Aynı siteye giden iki istek arasına sabit bir süre koyun.
4. **Kayıtları ayıklayın.** Her kayda onu benzersiz tanımlayan bir anahtar verin.
5. **Sıradaki sayfayı bulun.** Bağlantı, sayı, offset ya da imleç.
6. **Kayıtları, sıradaki adresi ve "bu sayfa bitti" bilgisini birlikte yazın.** Sonra ikinci adıma dönün.

Altıncı adımdaki "birlikte" sözcüğü yazının geri kalanının konusu. Önce durma koşuluna bakalım.

## Tarama ne zaman durmalı?

Listenin bittiğini anlamak göründüğünden zordur, çünkü siteler son sayfadan sonrası için farklı davranır. Alıştırma sitelerinde üç ayrı davranışı yan yana gördük. `books.toscrape.com` 50 sayfadır, `page-51.html` isteği `404` döner. `quotes.toscrape.com/page/11/` ise `200` koduyla, içinde hiç alıntı olmayan bir sayfa döner. Aynı sitenin JSON API'si son sayfada `"has_next": false` yazar, 11. sayfayı isterseniz boş liste alırsınız. Gerçek sitelerde dördüncüsü daha yaygındır: aralık dışı sayfa numarasında sessizce ilk ya da son sayfayı yeniden göstermek.

Bu yüzden tek bir koşula güvenmeyin, birkaçını birlikte kullanın:

- **Açık işaret:** "sonraki" bağlantısı yok, `has_next` yanlış, imleç boş, `Link` başlığında `rel="next"` yok.
- **Boş ya da eksik parça:** sayfada hiç kayıt yok ya da gelen kayıt sayısı `limit` değerinden az.
- **Yeni kayıt yok:** sayfadaki bütün kayıtlar daha önce görülmüş. Son sayfayı tekrar tekrar gösteren siteleri yakalayan koşul budur.
- **Üst sınır:** ne olursa olsun aşılmayacak bir sayfa sayısı. Bozuk bir "sonraki" bağlantısı ya da kendini tekrar eden bir imleç betiğinizi sonsuz döngüye sokamaz.
- **Toplam sayı:** API `total` ya da `total_pages` veriyorsa durmak için değil, doğrulamak için kullanın.

Sayfa numarasıyla ilerlerken `404` "liste bitti" anlamına da "adres yapısı değişti" anlamına da gelebilir. İlk sayfada `404` alıyorsanız ikincisidir.

## Tekrar eden kayıtlar nasıl ayıklanır?

Sayfalama sırasında aynı kaydın iki kez gelmesi hata değil, beklenen bir durumdur. En sık nedeni listenin siz tararken değişmesidir. "En yeniler" sırasındaki bir listede üçüncü sayfayı okuduğunuz anda başa beş yeni ürün eklenirse bütün kayıtlar beş sıra aşağı kayar ve dördüncü sayfanın ilk beş kaydı az önce gördüklerinizdir. Bir kayıt silinirse tersi olur, bir kayıt yukarı kayar ve onu hiç görmezsiniz. offset/limit ve sayfa numarası bu kaymaya açıktır; cursor ise "şu kayıttan sonrakiler" dediği için etkilenmez. Sponsorlu ürün ve iki kategoride listelenen ürün de tekrar üretir.

Çözüm, tekrarı kodda değil veritabanında ayıklamaktır:

- **Her kayda kararlı bir anahtar verin.** Ürün kimliği, ürün adresi ya da API'deki `id` alanı. Hiçbiri yoksa kaydın değişmeyen alanlarından bir özet (hash) üretin. Listedeki sıra numarası anahtar olmaz.
- **Anahtarı birincil anahtar yapın, eklemeyi `INSERT OR IGNORE` ile yapın.** Aynı anahtar ikinci kez gelirse SQLite satırı sessizce atlar. Bu davranış [SQLite'ın çakışma kuralları](https://www.sqlite.org/lang_conflict.html) belgesinde tanımlıdır. Fiyat gibi değişen bir alanı güncellemek istiyorsanız `ON CONFLICT ... DO UPDATE` kullanırsınız.
- **Sıralamayı sabitleyin.** Site sıralama seçeneği sunuyorsa değişmeyen bir alanı seçin (eklenme tarihi yerine ada ya da kimliğe göre). Kayma azalır.

## API sayfalaması: offset, cursor ve Link başlığı

Üç API türünün döngüleri birbirine çok benzer; fark, sıradaki isteğin nasıl kurulduğundadır. Aşağıdaki üç fonksiyon kayıtları tek tek üretir (`yield`), böylece çağıran kod sayfalamanın hangi türde olduğunu bilmek zorunda kalmaz. Alan adları (`items`, `next_cursor`) API'den API'ye değişir; kendi hedefinizin belgesine bakın.

```python
import time

import requests

def crawl_offset(session, url, limit=100, max_pages=500, delay=1.0):
    """offset/limit: eksik ya da boş sayfa gelince durur."""
    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: yanıttaki imleç bir sonraki isteğe aynen taşınır."""
    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:  # imleç yok ya da kendini tekrar ediyor
            break
        seen.add(cursor)
        time.sleep(delay)

def crawl_link_header(session, url, max_pages=500, delay=1.0):
    """Link başlığı: rel="next" adresi hazır gelir, parametre hesaplanmaz."""
    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)
```

Üçünü 250 kayıtlık yerel bir sahte API'de denedik: her biri 250 kaydı üç istekte, tekrarsız topladı. Hep aynı imleci döndüren bozuk bir uç noktada `crawl_cursor` ikinci istekten sonra durdu; `seen` kümesi olmasaydı 500 istek gönderecekti. `crawl_link_header` fonksiyonunu ayrıca GitHub'ın açık bir deposunun etiket listesinde çalıştırdık, iki sayfada 200 kayıt aldık. Requests `Link` başlığını `response.links` sözlüğüne kendisi ayrıştırır. [GitHub'ın sayfalama belgesi](https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api) de aynısını önerir: adresi elle kurmayın, `rel="next"` adresini izleyin.

## Sonsuz kaydırma ve "daha fazla yükle" düğmesi nasıl taranır?

İlk hamle tarayıcı otomasyonu değil, Network sekmesidir. `quotes.toscrape.com/scroll` sayfası iyi bir örnek: aşağı indikçe yeni alıntılar gelir ve her seferinde `/api/quotes?page=2`, `page=3` diye bir istek gider. Yanıt JSON'dur, içinde `has_next` alanı vardır. Bu isteği doğrudan çağırmak kaydırmaktan hem hızlıdır hem siteye daha az yük bindirir; görseller, yazı tipleri ve betikler hiç indirilmez. Aşağıdaki kuyruk örneği alıntıları bu yoldan toplar. İsteği bulmanın ayrıntısı [Statik ve Dinamik Sayfalar](/tr/blog/static-vs-dynamic-pages) yazımızdaki "Önce API/XHR isteğini bulmak" bölümünde.

İstek tekrarlanamıyorsa (imzalı parametre taşıyor ya da yanıt HTML parçası olarak gelip sayfanın betiğiyle işleniyorsa) tarayıcı otomasyonuna geçilir. Oradaki durma koşulu "kart sayısı artmayı bıraktı" olur:

```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:  # üç tur yeni kart gelmezse ya da üst sınıra varınca dur
        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, "kart yüklendi")
    browser.close()
```

Bu betik alıştırma sitesinde 100 kartın tamamını yükledi. "Daha fazla yükle" düğmesinde döngü aynıdır, yalnızca tekerlek yerine düğmeye tıklarsınız ve düğme sayfadan kalktığında da durursunuz. Selenium'da aynı iş `execute_script("window.scrollTo(0, document.body.scrollHeight)")` çağrısı ve kart sayısını yeniden saymakla yapılır; iki aracın farkları [Playwright ve Selenium Farkı](/tr/blog/playwright-vs-selenium) yazımızda. Kaydırmanın bir sınırı var: uzun akışlarda sayfa binlerce öğeyi bellekte tutar ve yavaşlar. Birkaç yüz karttan fazlası gerekiyorsa API isteğini aramaya değer.

## Yarıda kalan tarama nasıl sürdürülür: SQLite URL kuyruğu

Kısa listelerde sıradaki adresi bir değişkende tutmak yeter; [E-Ticarette Rakip Fiyat Takibi](/tr/blog/competitor-price-tracking) yazımızdaki kategori tarayan fonksiyon böyle çalışır ve iki sayfalık bir kategori için doğru seçimdir. Liste yüzlerce sayfaya çıkınca değişken yetmez: bağlantı kopar, bilgisayar uykuya geçer, sunucu yeniden başlar ve değişkendeki adres süreçle birlikte kaybolur. Kaldığınız yeri diske yazmanız gerekir.

Bunun için ayrı bir kuyruk sunucusuna gerek yok. Python'la birlikte gelen SQLite iki tabloyla işi görür: `queue` taranacak adresleri ve durumlarını (`pending`, `done`, `failed`, `blocked`), `items` toplanan kayıtları tutar. Püf noktası tek bir kuraldır: **bir sayfanın kayıtları, o sayfadan öğrenilen sıradaki adres ve sayfanın `done` işareti aynı veritabanı işleminde (transaction) yazılır.** İşlem ya bütünüyle diske geçer ya da hiç geçmez. Süreç tam o sırada ölürse sayfa kuyrukta `pending` kalır, bir sonraki çalıştırmada yeniden okunur. "Bitti" göründüğü hâlde kayıtları eksik bir sayfa oluşamaz.

Aşağıdaki betik iki farklı sayfalama türünü aynı kuyrukta tarar: kitap kataloğunu "sonraki" bağlantısını izleyerek, alıntıları sonsuz kaydırmanın arkasındaki JSON API'den.

```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  # örnek: "http://user:pass@pr.proxynet.io:8000"
DELAY = 1.0  # aynı siteye iki istek arasındaki en kısa süre (saniye)
MAX_PAGES = 200  # bozuk bir "sonraki" zincirine karşı üst sınır
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 liste: kayıtlar kartlardan, sonraki sayfa 'next' bağlantısından okunur."""
    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 (sonsuz kaydırmanın arkasındaki istek): has_next bitince durulur."""
    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):
    """robots.txt'yi site başına bir kez okur. 4xx: kısıt yok, 5xx: hiçbir yol taranmaz (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):
    """Aynı siteye DELAY'den (ya da robots.txt'deki Crawl-delay'den) sık istek göndermez."""
    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:  # ilk çalıştırmada başlangıç adreslerini ekler, sonrakilerde dokunmaz
        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  # kuyruk boş: tarama bitti
        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"HATA  {url}: {exc}")
            continue

        # Kayıtlar, sıradaki adres ve "bu sayfa bitti" işareti tek işlemde yazılır.
        # Süreç bu bloğun ortasında ölürse hiçbiri yazılmaz, sayfa kuyrukta 'pending' kalır.
        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
            # Durma koşulu: boş sayfa ya da tamamı daha önce görülmüş kayıtlar
            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"TAMAM {url}: {len(items)} kayıt, {new_items} yeni")

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

if __name__ == "__main__":
    crawl()
```

Betiği çalıştırmak için `pip install requests beautifulsoup4` yeterlidir. Sürdürmeyi şöyle denedik: betiği başlattık, 14. saniyede süreci doğrudan öldürdük (Ctrl+C değil). O anda veritabanında 20 tamamlanmış sayfa, 200 kitap, 100 alıntı ve `pending` durumunda tek bir adres vardı: `page-11.html`. Hiçbir şey değiştirmeden yeniden çalıştırdık; tarama `page-11.html` ile devam edip bir dakikanın altında 50 katalog sayfası ve 1.000 kitapla bitti. Alıntı API'sine tek istek gitmedi, on sayfası da `done` idi. Üçüncü çalıştırma bekleyen adres bulamayınca yalnız özeti yazdı.

Kodda dikkat edilecek yerler:

- **`with db:` bloğu işlemin sınırıdır.** Python'ın `sqlite3` modülünde bağlantı bir bağlam yöneticisi olarak kullanıldığında blok hatasız biterse işlem onaylanır, istisna çıkarsa geri alınır. Ayrıntısı [modülün belgesinde](https://docs.python.org/3/library/sqlite3.html#sqlite3-connection-context-manager).
- **Kuyruğun birincil anahtarı adresin kendisidir.** Aynı adres ikinci kez keşfedilirse `INSERT OR IGNORE` onu atlar; döngüsel bağlantılar böyle çözülür.
- **"Yeni kayıt yok" koşulu sürdürmeyle çakışmaz.** Yarıda kalan sayfanın kayıtları hiç yazılmadığı için, yeniden okunduğunda hepsi yenidir ve zincir kopmaz.
- **Hata alan sayfa kuyruğun sonuna geçer.** `ORDER BY attempts` sayesinde önce hiç denenmemiş adresler alınır; üç denemede okunamayan sayfa `failed` olur ve tarama onsuz devam eder. Sayaç bilerek yalındır: hangi durum kodunda beklenip hangisinde durulacağını ve `Retry-After` başlığını işleyen yeniden deneme kodu [Scraping'de HTTP Hata Kodları](/tr/blog/http-status-codes-web-scraping) yazımızda hazır, `polite_get` yerine onu koyabilirsiniz.
- **Yeni bir site eklemek bir ayrıştırıcı yazmaktır.** Kayıt listesini ve sıradaki adresi döndüren bir fonksiyon yazıp `PARSERS` sözlüğüne eklersiniz; kuyruk, bekleme ve sürdürme mantığı değişmez.

Bu kuyruk tek süreç içindir. Birden çok işçi aynı kuyruktan okuyacaksa adresi alan işçinin onu `claimed` gibi bir ara duruma çekmesi ve bu durumun zaman aşımıyla `pending`'e dönmesi gerekir. Eşzamanlı taramanın ne zaman hız kazandırdığı [Concurrency ve Parallelism](/tr/blog/concurrency-vs-parallelism) yazımızda. İşçi sayısı artınca hazır bir çatı daha az iş çıkarır: [Scrapy](/tr/blog/scrapy-proxy) kuyruk, tekrar ayıklama ve sürdürme özelliklerini kendi içinde taşır.

## robots.txt, hız sınırı ve proxy

Sayfalama, bir siteye art arda en çok istek gönderdiğiniz iştir. Betikteki `allowed` fonksiyonu her sitenin `robots.txt` dosyasını bir kez okur ve her adresi ona sorar. Dosyanın bulunamadığı durumları [RFC 9309](https://www.rfc-editor.org/rfc/rfc9309) düzenler: `4xx` yanıtı "dosya yok, kısıt yok" sayılır, `5xx` yanıtında tarayıcı bütün yolları yasak kabul etmek zorundadır. İki alıştırma sitesinde de istek `404` döndü. Dosyayı `session` ile almamızın nedeni, isteğin betiğin User-Agent değeriyle ve varsa proxy üzerinden gitmesidir. `urllib.robotparser` joker karakterleri (`*`, `$`) desteklemez; sınırları [robots.txt Dosyası Nedir, Nasıl Okunur?](/tr/blog/robots-txt) yazımızda.

`polite_get` aynı siteye giden iki istek arasına en az `DELAY` saniye koyar, sitede `Crawl-delay` yazıyorsa onu esas alır; bekleme site başına tutulur. `429` görmeye başladıysanız doğru tepki IP değiştirmek değil, `DELAY` değerini büyütmektir; nedenleri [429 Too Many Requests](/tr/blog/http-429-too-many-requests) yazımızda. Botunuzu tanıtan bir User-Agent yazmak da işin parçasıdır: [User-Agent Nedir?](/tr/blog/what-is-user-agent).

Proxy bu resme iki yerde girer. Birincisi konumdur: Türkiye'deki ziyaretçiye gösterilen kataloğu ve fiyatı görmek için isteğin Türkiye'den çıkması gerekir. İkincisi yük dağıtımıdır: birden çok siteye yayılan uzun taramalarda trafik tek adrese yığılmasın diye [Rotating Proxy](https://proxynet.io/tr/rotating-proxy) kullanılır. Burada bir tuzak var. Arama sonuçları ve filtreli listeler çoğu zaman sunucudaki bir oturuma bağlıdır; listenin ortasında IP değişirse site sizi ilk sayfaya döndürebilir ya da aynı kayıtları yeniden verebilir. Bir listeyi baştan sona aynı çıkış adresiyle taramak için [Sticky Proxy](https://proxynet.io/tr/sticky-proxy) oturumu açın, liste bitince kimliği değiştirin. Mekanizması [IP Rotasyonu Nedir ve Nasıl Çalışır?](/tr/blog/ip-rotation-explained) yazımızda. `PROXY` satırını doldurup betiği kimlik doğrulamalı yerel bir test proxy'si üzerinden de çalıştırdık; `robots.txt` dahil bütün trafik proxy'den geçti, sonuç değişmedi.

## Kullanım alanları

- **Kategori ve katalog taraması:** Rakip kategorilerindeki ürün adreslerini toplamak fiyat takibinin ilk adımıdır; akışın tamamı [E-Ticarette Rakip Fiyat Takibi](/tr/blog/competitor-price-tracking) yazımızda, altyapı tarafı [fiyat izleme çözümü](/tr/price-monitoring) sayfamızda.
- **Pazaryeri listeleri:** Satıcı ve ürün listeleri binlerce sayfaya uzanır, sürdürülebilir kuyruk burada zorunludur. Konum ve oturum ayarı için [e-ticaret proxy](/tr/e-commerce-proxy) sayfamıza bakın.
- **Resmî API'lerden toplu veri:** Cursor ve `Link` başlığı en çok burada çıkar. Kimlik gerektiren API'lerde oturumun nasıl taşınacağı [Python'da Oturum ve Çerez Yönetimi](/tr/blog/python-login-session-cookies) yazımızda.
- **Tek seferlik küçük işler:** Elli satırlık bir tablo için kuyruk kurmayın; kodsuz seçenekler [Web Sitesinden Veri Çekme](/tr/blog/extract-data-from-website) yazımızda.

## Sık yapılan hatalar

- **Son sayfa numarasını koda gömmek.** Katalog büyür, 50 sayfa 53 olur ve son üç sayfa sessizce eksik kalır. Sayıyı değil durma koşulunu yazın.
- **Üst sınır koymamak.** Kendine bağlantı veren bir "sonraki" öğesi ya da tekrar eden bir imleç betiği saatlerce aynı sayfada döndürür.
- **Göreli bağlantıyı elle birleştirmek.** Alıştırma sitesinde "sonraki" bağlantısı ilk sayfada `catalogue/page-2.html`, ikinci sayfada `page-3.html` biçimindedir. Dizgi toplayarak kurulan adres ikinci sayfada bozulur; `urljoin` ikisini de doğru çözer.
- **Kaldığı yeri kayıtlardan ayrı yazmak.** Önce "sayfa bitti" yazıp sonra kayıtları ekleyen kod, arada öldüğünde o sayfayı kalıcı olarak kaybeder. Sırayı tersine çevirmek de tekrara yol açar. İkisi aynı işlemde olmalıdır.
- **Tekrarı bellekteki bir kümeyle ayıklamak.** Süreç yeniden başladığında küme boştur; benzersizlik veritabanının işidir.
- **Gizli bağlantıları izlemek.** Sayfalama bağlantısını ararken sayfadaki bütün `<a>` öğelerini kuyruğa atan kod, insanlara görünmeyen tuzak bağlantılara da girer. Yalnızca sayfalama öğesini seçin; ayrıntısı [Honeypot Tuzakları](/tr/blog/honeypot-traps) yazımızda.

## Karar rehberi

| Durum | Öneri |
|---|---|
| Sayfada "sonraki" bağlantısı var | Bağlantıyı izleyin, `urljoin` kullanın |
| Yalnızca sayfa numaraları var, son sayfa belli değil | Sayıyı artırın; boş sayfa, `404` ve "yeni kayıt yok" koşullarını birlikte kullanın |
| Network sekmesinde JSON isteği görünüyor | Tarayıcıyı bırakın, isteği doğrudan çağırın |
| API imleç veriyor | İmleci aynen taşıyın, görülen imleçleri bir kümede tutun |
| API `Link` başlığı veriyor | `response.links["next"]` adresini izleyin |
| İstek tekrarlanamıyor, sonsuz kaydırma var | Playwright ya da Selenium ile kaydırın, kart sayısı artmayınca durun |
| Liste 50 sayfadan uzun ya da tarama dakikalar sürüyor | SQLite kuyruğu kurun, kayıt ve durumu aynı işlemde yazın |
| Liste siz tararken değişiyor | Kararlı anahtar, `INSERT OR IGNORE`, sabit sıralama, gerekirse ikinci tur |
| Filtreli ya da oturuma bağlı liste, proxy ile | Liste boyunca sticky oturum, liste bitince yeni kimlik |

## Sık sorulan sorular

### Cursor pagination nedir, offset'ten farkı ne?

offset "baştan şu kadar kaydı atla" der; cursor "şu kayıttan sonrakileri ver" der. offset'te istediğiniz sayfaya atlayabilirsiniz ama liste değişirse kayıtlar kayar ve tekrar ya da eksik oluşur. Cursor'da atlama yoktur, yalnızca sırayla ilerlersiniz; karşılığında liste değişse de kaldığınız yer sabittir. Pratik farkı: offset'li taramayı 40. sayfadan sürdürebilirsiniz, imlecin süresi dolduysa cursor'lı taramaya baştan başlamanız gerekebilir.

### Infinite scroll (sonsuz kaydırma) nedir?

Sayfanın sonuna yaklaşıldığında JavaScript'in arka planda yeni bir istek gönderip gelen kayıtları listenin altına eklemesidir. Kullanıcı sayfa numarası görmez, ama arkadaki API neredeyse her zaman sayfa numarası, offset ya da imleçle çalışır. Scraping'de hedef o API isteğidir.

### Son sayfayı önceden öğrenmek mümkün mü?

Bazen. Sayfa altındaki "Page 1 of 50" yazısı, API yanıtındaki `total_pages` alanı ya da `Link` başlığındaki `rel="last"` adresi bunu söyler. Bu bilgiyi ilerleme göstermek ve sonucu doğrulamak için kullanın. Döngüyü yine de durma koşullarıyla bitirin, çünkü toplam sayı tarama sırasında değişebilir.

### Sayfaları paralel taramak mümkün mü?

Sayfa numarası ve offset türlerinde adresleri önceden üretebildiğiniz için mümkündür. "Sonraki" bağlantısı ve cursor türlerinde her sayfa bir öncekine bağlıdır, zincir sırayla ilerler; paralellik ancak farklı listeler (kategoriler) arasında kurulur. Paralellik hız sınırını ortadan kaldırmaz: aynı siteye giden toplam istek hızını yine sınırlayın.

### Neden CSV ya da JSON dosyası değil de SQLite?

Dosyaya ekleme yapmak basittir ama üç şeyi vermez: benzersizlik kontrolü, "ya hep ya hiç" yazma ve kuyruğu sorgulama. SQLite üçünü de tek dosyada, kurulum gerektirmeden sağlar. Tarama bitince `items` tablosunu CSV'ye dökmek birkaç satırdır.

### Site tarama sırasında sayfa başına kayıt sayısını değiştirirse ne olur?

"Sonraki" bağlantısı ve cursor türlerinde hiçbir şey olmaz, çünkü sıradaki sayfayı site söyler. Sayfa numarası ve offset türlerinde sınırlar kayar ve bazı kayıtlar iki kez, bazıları hiç gelmeyebilir. Anahtar üzerinden tekrar ayıklama ilk sorunu çözer; ikincisi için toplam sayıyla karşılaştırma ve gerekirse ikinci bir tur gerekir.

## Özetle

Sayfalama taramasında kod üç soruya cevap verir: sıradaki sayfa nerede, liste ne zaman bitti, kaldığım yer nerede yazılı. İlkinde adres uydurmak yerine sitenin verdiği işareti ("sonraki" bağlantısı, imleç, `Link` başlığı) izleyin; sonsuz kaydırmada önce arkadaki API isteğini arayın. İkincisinde tek koşula güvenmeyin, "yeni kayıt yok" ve sayfa üst sınırını her zaman ekleyin. Üçüncüsünde kayıtları ve kuyruk durumunu aynı SQLite işleminde yazın; süreç nerede ölürse ölsün tarama kaldığı sayfadan sürer. İstek hızını düşük tutun, `robots.txt` kurallarına uyun. Konum ve yük dağıtımı seçenekleri [proxy hizmetlerimizde](/tr/proxy).
