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.
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ü, bağlantıdan bağlantıya gezen tarayıcıların kurgusu 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 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, 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 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:
- Başlangıç adresini kuyruğa koyun. Kategori sayfası ya da API'nin ilk isteği.
- Sıradaki adresi alın, izinli mi diye bakın.
robots.txto yolu yasaklıyorsa istek hiç gitmez. - Bekleyin, sonra isteği gönderin. Aynı siteye giden iki istek arasına sabit bir süre koyun.
- Kayıtları ayıklayın. Her kayda onu benzersiz tanımlayan bir anahtar verin.
- Sıradaki sayfayı bulun. Bağlantı, sayı, offset ya da imleç.
- 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_nextyanlış, imleç boş,Linkbaşlığındarel="next"yok. - Boş ya da eksik parça: sayfada hiç kayıt yok ya da gelen kayıt sayısı
limitdeğ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
totalya datotal_pagesveriyorsa 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
idalanı. 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 IGNOREile yapın. Aynı anahtar ikinci kez gelirse SQLite satırı sessizce atlar. Bu davranış SQLite'ın çakışma kuralları belgesinde tanımlıdır. Fiyat gibi değişen bir alanı güncellemek istiyorsanızON CONFLICT ... DO UPDATEkullanı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.
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 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 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:
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ı 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 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.
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'ınsqlite3modü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.- Kuyruğun birincil anahtarı adresin kendisidir. Aynı adres ikinci kez keşfedilirse
INSERT OR IGNOREonu 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 attemptssayesinde önce hiç denenmemiş adresler alınır; üç denemede okunamayan sayfafailedolur ve tarama onsuz devam eder. Sayaç bilerek yalındır: hangi durum kodunda beklenip hangisinde durulacağını veRetry-Afterbaşlığını işleyen yeniden deneme kodu Scraping'de HTTP Hata Kodları yazımızda hazır,polite_getyerine 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
PARSERSsö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 yazımızda. İşçi sayısı artınca hazır bir çatı daha az iş çıkarır: Scrapy 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 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? 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 yazımızda. Botunuzu tanıtan bir User-Agent yazmak da işin parçasıdır: User-Agent Nedir?.
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 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 oturumu açın, liste bitince kimliği değiştirin. Mekanizması IP Rotasyonu Nedir ve Nasıl Çalışır? 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 yazımızda, altyapı tarafı fiyat izleme çözümü 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 sayfamıza bakın.
- Resmî API'lerden toplu veri: Cursor ve
Linkbaşlığı en çok burada çıkar. Kimlik gerektiren API'lerde oturumun nasıl taşınacağı Python'da Oturum ve Çerez Yönetimi 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 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 sayfadapage-3.htmlbiçimindedir. Dizgi toplayarak kurulan adres ikinci sayfada bozulur;urljoinikisini 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ı 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.




