Neden önemli?
Anadolu Market'in fiyatlama ekibi, kendi ürünlerinin tedarikçi kataloglarındaki liste fiyatlarını her sabah kontrol etmek istiyor. Tedarikçinin API'si yok; yalnızca herkese açık bir ürün kataloğu sayfası var. Bir çalışanın 300 ürünü tek tek açıp Excel'e yazması yarım gün sürer. Web kazıma (web scraping), bir web sayfasının HTML'ini programla okuyup içindeki yapılandırılmış veriyi (ürün adı, fiyat, stok) çıkarmaktır.
Kazıma güçlü ama hassas bir araçtır: başkasının sunucusuna yük bindirir, telif ve kişisel veri konularına dokunur. Bu yüzden derste teknik kadar kural da öğreneceksin. Altın kural: resmi bir API ya da veri dosyası varsa önce onu kullan; kazıma son çaredir.
HTML'in yapısı: etiket, öznitelik, ağaç
HTML, iç içe geçmiş etiketlerden (tag) oluşur. Her etiketin adı (div, span, a), öznitelikleri (attribute: class, id, href, data-sku) ve içeriği vardır. Tarayıcı bu metni bir ağaca, DOM'a (Document Object Model) çevirir: her etiket bir düğümdür, içindeki etiketler çocuklarıdır.
<div class="urun" data-sku="AM-101"> ← ebeveyn düğüm, iki özniteliği var <h3 class="ad">Çay Bardağı 6'lı</h3> ← çocuk: ürün adı <span class="fiyat">149,90 TL</span> ← çocuk: fiyat metni <a href="/urun/101">İncele</a> ← göreli bağlantı </div>
Kazımanın ilk adımı tarayıcıda sayfaya sağ tıklayıp İncele (Inspect) demek ve aradığın verinin hangi etiket ve sınıfta durduğunu görmektir. Kararlı bir "yol" ararsın: id ve data-* öznitelikleri genelde tasarım değişikliklerinden en az etkilenenlerdir.
Sayfayı çekmek: requests
Gerçek kazıyıcıda HTML'i requests ile indirirsin. Tarayıcı motorunda ağ olmadığı için bu kodu kendi bilgisayarında dene; derste HTML'i metin olarak vereceğiz.
import requests
BASLIK = {"User-Agent": "AnadoluMarketFiyatBot/1.0 (veri@anadolumarket.example)"}
yanit = requests.get("https://tedarikci.example/katalog?sayfa=1", headers=BASLIK, timeout=10)
yanit.raise_for_status() # 4xx/5xx ise hata fırlat
yanit.encoding = yanit.apparent_encoding # Türkçe karakter sorunu varsa
html = yanit.text
pip install requests beautifulsoup4 lxml. BeautifulSoup ve lxml tarayıcıda da çalışır; requests ile gerçek ağ isteği yalnızca kendi bilgisayarında çalışır. HTTP ayrıntılarını bir sonraki "HTTP ve REST API" dersinde göreceksin.BeautifulSoup: HTML'i ağaç olarak gezmek
BeautifulSoup(html, "html.parser") metni gezilebilir bir nesneye çevirir. İkinci argüman ayrıştırıcıdır (parser): "html.parser" Python ile gelir; "lxml" daha hızlıdır ve kurulum ister.
from bs4 import BeautifulSoup
html = """
<div id="katalog">
<div class="urun" data-sku="AM-101"><h3 class="ad"> Çay Bardağı </h3><span class="fiyat">149,90 TL</span></div>
<div class="urun indirim" data-sku="AM-102"><h3 class="ad">Demlik</h3><span class="fiyat">1.299,00 TL</span></div>
<div class="urun" data-sku="AM-103"><h3 class="ad">Kupa</h3><span class="tukendi">Stokta yok</span></div>
</div>
"""
soup = BeautifulSoup(html, "html.parser")
ilk = soup.find("div", class_="urun") # ilk eşleşme ya da None
print(ilk["data-sku"], ilk.h3.get_text(strip=True))
kartlar = soup.find_all("div", class_="urun") # tüm eşleşmeler (liste)
print(len(kartlar))
for kart in kartlar:
fiyat = kart.find("span", class_="fiyat")
print(kart.get("data-sku"), fiyat.get_text() if fiyat else "-")
| Araç | Ne döndürür | Bulamazsa |
|---|---|---|
find(ad, class_=..., attrs={...}) | İlk eşleşen etiket | None |
find_all(ad, ..., limit=n) | Tüm eşleşmeler (liste) | [] |
select("css") / select_one("css") | CSS seçiciye uyan liste / ilk eleman | [] / None |
etiket.get_text(strip=True) | İçteki tüm metin, kenar boşlukları atılmış | Boş metin |
etiket["href"] / etiket.get("href") | Öznitelik değeri | KeyError / None |
etiket.attrs | Tüm öznitelikler sözlüğü | {} |
find("span", class="fiyat") sözdizimi hatası verir; BeautifulSoup bu yüzden class_ adını kullanır. Ayrıca class çok değerli bir özniteliktir: kart["class"] metin değil liste döner (["urun", "indirim"]).CSS seçiciler
select tarayıcıdaki CSS seçicilerini kullanır; karmaşık yolları tek satırda yazmanı sağlar.
| Seçici | Anlamı |
|---|---|
div.urun | class'ı urun olan div |
#katalog | id'si katalog olan etiket |
div.urun.indirim | hem urun hem indirim sınıfı olan div |
#katalog span.fiyat | katalog içindeki (her derinlikte) fiyat span'leri |
div.urun > h3 | urun div'inin doğrudan çocuğu olan h3 |
a[href], [data-sku="AM-102"] | özniteliği olan / değeri eşleşen etiket |
a[href^="https"], a[href$=".pdf"] | ile başlayan / ile biten değer |
tr:nth-of-type(2) | türünün ikinci elemanı |
from bs4 import BeautifulSoup
html = """<ul id="liste">
<li class="urun" data-sku="AM-1"><a href="/u/1">Kalem</a> <b>12,50</b></li>
<li class="urun indirim" data-sku="AM-2"><a href="/u/2">Defter</a> <b>40,00</b></li>
</ul>"""
soup = BeautifulSoup(html, "html.parser")
print([a.get_text() for a in soup.select("#liste li.urun > a")])
print(soup.select_one("li.indirim")["data-sku"])
print(soup.select_one('[data-sku="AM-1"] b').get_text())
li = soup.select_one("li.urun")
print(li.find_next_sibling("li")["data-sku"], li.parent["id"])
Ağaçta gezinme için .parent, .children, .find_next_sibling(), .find_parent() kullanılır. Örneğin "Fiyat:" yazan bir etiketin hemen yanındaki değeri bulmak için önce etiketi bulur, sonra kardeşine geçersin.
Tablo kazımak
Belediye duyuruları, kur tabloları, sınav sonuçları gibi veriler çoğu zaman <table> içindedir. Kalıp hep aynıdır: başlıkları th'lerden al, her tr için td metinlerini topla, ikisini zip ile sözlüğe çevir.
from bs4 import BeautifulSoup
html = """<table id="nobet">
<tr><th>Eczane</th><th>İlçe</th><th>Telefon</th></tr>
<tr><td>Şifa</td><td>Konak</td><td>0232 000 00 01</td></tr>
<tr><td>Hayat</td><td>Bornova</td><td>0232 000 00 02</td></tr>
</table>"""
soup = BeautifulSoup(html, "html.parser")
tablo = soup.find("table", id="nobet")
basliklar = [th.get_text(strip=True) for th in tablo.find_all("th")]
kayitlar = []
for tr in tablo.find_all("tr")[1:]:
hucreler = [td.get_text(strip=True) for td in tr.find_all("td")]
kayitlar.append(dict(zip(basliklar, hucreler)))
print(kayitlar)
Basit tablolar için pandas.read_html(html) tüm tabloları DataFrame listesi olarak tek satırda verir (arka planda lxml kullanır). Birleştirilmiş hücreli, iç içe karmaşık tablolarda ise elle ayrıştırma daha güvenilirdir.
Bağlantılar ve sayfalama: urllib.parse
Sayfadaki bağlantılar çoğu zaman görelidir: /urun/42, ?sayfa=2, ../kampanya. İsteği atmak için bunları mevcut sayfanın adresiyle birleştirip mutlak adrese çevirmen gerekir. Bunu asla metin birleştirmeyle yapma; urljoin tüm kuralları bilir.
from urllib.parse import urljoin, urlparse, parse_qs, urlencode, urldefrag
sayfa = "https://tedarikci.example/katalog/mutfak/?sayfa=1"
for href in ["/urun/42", "?sayfa=2", "../banyo/", "detay.html#yorumlar", "https://baska.example/x"]:
print(href, "→", urljoin(sayfa, href))
parca = urlparse("https://tedarikci.example/ara?q=çay+bardağı&sayfa=3")
print(parca.netloc, parca.path, parse_qs(parca.query))
print(urlencode({"q": "çay bardağı", "sayfa": 4}))
print(urldefrag("https://tedarikci.example/u/1#yorum").url)
Sayfalama (pagination) için iki yaygın yol vardır: "Sonraki" bağlantısını izlemek (a[rel~=next] ya da a.sonraki) veya sayfa numarasını sorgu parametresinde artırmak. Her iki durumda da ziyaret edilenler kümesi tut; aksi halde döngüye giren bir site seni sonsuza kadar meşgul eder. Bir de üst sınır koy: "en fazla 50 sayfa".
lxml ve XPath
lxml hem BeautifulSoup için hızlı bir ayrıştırıcıdır (BeautifulSoup(html, "lxml")) hem de kendi başına XPath sorgu dili sunar. Büyük sayfalarda belirgin hız farkı yaratır.
from lxml import html as lh
agac = lh.fromstring('<div><p class="fiyat">149,90</p><p class="fiyat">89,00</p><a href="/x">X</a></div>')
print(agac.xpath('//p[@class="fiyat"]/text()'))
print(agac.xpath("//a/@href"))
Kurallar: robots.txt, kullanım koşulları, KVKK
Teknik olarak kazıyabilmen, kazıyabileceğin anlamına gelmez. Bir kazıyıcıyı canlıya almadan önce şu soruları cevapla:
| Konu | Ne yapmalısın? |
|---|---|
| robots.txt | Sitenin kökündeki /robots.txt dosyasında senin kullanıcı ajanına kapalı (Disallow) yolları kazıma; Crawl-delay varsa uy. |
| Kullanım koşulları | Site otomatik erişimi yasaklıyorsa ya da veriyi ticari kullanıma kapatıyorsa hukuk ekibine danış; çoğu zaman izin ya da lisans gerekir. |
| Kişisel veri (KVKK) | Ad, telefon, e-posta, yorum yazarı gibi kişisel verileri toplama. Gerekliyse hukuki dayanak, aydınlatma ve saklama süresi şarttır; "herkese açık" olması serbestçe işlenebileceği anlamına gelmez. |
| Telif ve veri tabanı hakkı | Metin, görsel ve derlenmiş veri tabanları korunabilir; içeriği kopyalayıp yayımlamak yerine yalnızca gereken olgusal alanları (fiyat, stok) al. |
| Giriş gerektiren alanlar | Oturum açarak, CAPTCHA aşarak ya da erişim engelini dolanarak kazıma; bu hem sözleşme hem ceza hukuku riski taşır. |
Python, robots.txt'yi okumak için hazır bir modülle gelir. Dosyayı ağdan çekmek yerine metin olarak da verebilirsin:
from urllib.robotparser import RobotFileParser
robots = """User-agent: *
Disallow: /sepet/
Disallow: /hesap/
Crawl-delay: 2
"""
rp = RobotFileParser()
rp.parse(robots.splitlines())
for yol in ["/katalog?sayfa=1", "/sepet/ekle", "/hesap/siparisler"]:
print(yol, rp.can_fetch("AnadoluBot", "https://tedarikci.example" + yol))
print("bekleme:", rp.crawl_delay("AnadoluBot"))
Nezaket kuralları
- Yavaş ol: istekler arasında en az 1–2 saniye bekle (
time.sleep);Crawl-delayvarsa daha uzun. Paralel istekle siteyi boğma. - Kendini tanıt: açıklayıcı bir
User-Agentve iletişim adresi gönder; tarayıcı taklidi yapma. - Önbellekle (cache): aynı sayfayı gün içinde tekrar indirme; HTML'i diske kaydet, ayrıştırmayı oradan tekrarla.
- Hataya saygı: 429 ya da 503 alırsan dur,
Retry-Afterkadar bekle; ısrar etme. - Gece çalış: sitenin yoğun olmadığı saatlerde, gerektiği kadar sık.
Dinamik sayfalar: Playwright ve Selenium
Bazı sitelerde HTML ilk geldiğinde ürünler yoktur; JavaScript sonradan bir API'den çekip sayfaya yerleştirir. requests JavaScript çalıştırmadığı için boş liste görürsün. İki seçenek vardır: tarayıcının Ağ (Network) sekmesinde sayfanın çağırdığı JSON adresini bulup onu kullanmak (çoğu zaman daha temiz ve hızlı), ya da gerçek bir tarayıcıyı programla sürmek.
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
tarayici = p.chromium.launch(headless=True)
sayfa = tarayici.new_page(user_agent="AnadoluMarketFiyatBot/1.0")
sayfa.goto("https://tedarikci.example/katalog", timeout=15000)
sayfa.wait_for_selector("div.urun") # JavaScript ürünleri yükleyene kadar bekle
html = sayfa.content() # sonra BeautifulSoup'a ver
tarayici.close()
pip install playwright ve ardından playwright install chromium. Selenium alternatifi için pip install selenium. Tarayıcı sürmek yavaş ve kaynak yiyicidir; yalnızca gerçekten gerektiğinde kullan.Derste Playwright'ı tek bir örnekle tanıdın: sayfayı aç, içeriği al. Gerçek otomasyon işlerinde ise asıl zorluklar başka yerdedir: sayfanın ne zaman "hazır" olduğunu bilmek, her çalıştırmada yeniden giriş yapmamak, sayfanın arkasındaki JSON API'yi bulmak ve dosya indirmek. Bu bölüm bu dört kalıbı gösteriyor; alıştırmalar ise tarayıcıda çalışan BeautifulSoup ve JSON üzerinde.
Bekleme stratejileri
Dinamik sayfada içerik JavaScript ile sonradan gelir. En kötü çözüm time.sleep(5)'tir: yavaş ağda yetmez, hızlı ağda boşa bekletir. Playwright'ın locator API'si eylemlerden önce elemanın görünür, etkin ve sabit olmasını kendiliğinden bekler (auto-wait); sen yalnız neyi beklediğini söylersin.
from playwright.sync_api import sync_playwright, expect
with sync_playwright() as p:
tarayici = p.chromium.launch(headless=True)
sayfa = tarayici.new_page()
sayfa.goto("https://portal.example.com/siparisler", wait_until="domcontentloaded")
satirlar = sayfa.locator("table#siparis tbody tr")
expect(satirlar.first).to_be_visible(timeout=10_000) # ilk satır gelene kadar
sayfa.get_by_role("button", name="Daha fazla").click() # tıklamadan önce kendisi bekler
expect(satirlar).to_have_count(40) # 40 satır olana kadar
sayfa.wait_for_load_state("networkidle") # son çare: ağ susana kadar
print(satirlar.all_inner_texts()[:3])
| Bekleme | Ne zaman? |
|---|---|
expect(locator).to_be_visible() | Belirli bir eleman görünene kadar (en sağlam) |
page.wait_for_response(lambda r: "/api/siparis" in r.url) | Belirli bir API çağrısı bitene kadar |
wait_for_load_state("networkidle") | Ağda 500 ms hiçbir istek olmayana kadar; sürekli yoklama yapan sayfalarda hiç gelmeyebilir |
time.sleep | Hiçbir zaman (hata ayıklarken bile page.pause() daha iyidir) |
Giriş yapılan portalda oturumu saklamak
Her çalıştırmada kullanıcı adı ve parola girmek yavaştır, iki aşamalı doğrulamaya takılır ve hesabı kilitletebilir. Playwright tarayıcı bağlamının çerezlerini ve localStorage'ını bir JSON dosyasına (storage_state) yazar; sonraki çalıştırmalar bu dosyayla giriş yapmış olarak başlar.
import os
DURUM = "oturum.json" # .gitignore'a ekle: içinde oturum çerezleri var!
def baglam_ac(tarayici):
if os.path.exists(DURUM):
return tarayici.new_context(storage_state=DURUM)
baglam = tarayici.new_context()
s = baglam.new_page()
s.goto("https://portal.example.com/giris")
s.get_by_label("Kullanıcı adı").fill(os.environ["PORTAL_KULLANICI"])
s.get_by_label("Parola").fill(os.environ["PORTAL_PAROLA"])
s.get_by_role("button", name="Giriş").click()
s.wait_for_url("**/panel")
baglam.storage_state(path=DURUM)
return baglam
Oturum süresi dolunca (portal giriş sayfasına yönlendirince) dosyayı silip yeniden giriş yap. Çerezlerin süresini dosyadan okuyabilirsin: her çerezin expires alanı Unix zamanıdır (-1: tarayıcı kapanınca biten oturum çerezi).
Ağ yanıtını yakalayıp gizli API'yi kullanmak
Çoğu dinamik sayfa veriyi bir JSON uç noktasından çeker ve HTML'i tarayıcıda üretir. O JSON'u doğrudan almak, HTML'i kazımaktan çok daha sağlam ve hızlıdır: seçiciler kırılmaz, sayılar biçimlenmemiş gelir. Tarayıcının geliştirici araçlarında Ağ (Network) sekmesinde Fetch/XHR süzgeciyle bu çağrıyı bulabilir ya da Playwright ile yakalayabilirsin:
with sayfa.expect_response(lambda r: "/api/v1/siparisler" in r.url and r.status == 200) as bilgi:
sayfa.get_by_role("button", name="Listele").click()
veri = bilgi.value.json() # {"items": [...], "next_cursor": "..."}
# Uç noktayı öğrendikten sonra aynı oturum çerezleriyle doğrudan çağır:
yanit = baglam.request.get("https://portal.example.com/api/v1/siparisler", params={"cursor": veri["next_cursor"]})
Dosya indirmek
Rapor dışa aktarma düğmeleri çoğunlukla bir indirme başlatır. Playwright indirmeyi bir olay olarak yakalar; dosyayı geçici bir yerden kendi klasörüne sen taşırsın. Dosya adını tarihle zenginleştir ve önce geçici ada yazıp sonra yeniden adlandır ki yarım dosya bir sonraki adımda okunmasın.
with sayfa.expect_download() as indirme:
sayfa.get_by_role("link", name="Excel'e aktar").click()
dosya = indirme.value
hedef = f"indirilenler/{dosya.suggested_filename}"
dosya.save_as(hedef)
pip install playwright ve playwright install chromium. playwright codegen https://site komutu tarayıcıda yaptığın tıklamaları koda çevirir; seçicileri bulmak için harika bir başlangıçtır. Tarayıcı içinde Playwright çalışmaz; aşağıdaki alıştırmalar kazınan HTML ve yakalanan JSON üzerinde çalışır.Kırılgan seçicilerden kaçın
div > div:nth-of-type(3) > span gibi konuma bağlı seçiciler ilk tasarım değişikliğinde kırılır. Sırasıyla tercih et: id ve data-* öznitelikleri, anlamlı sınıf adları, metin etiketleri ("Fiyat:" yazan hücrenin kardeşi). Seçicileri dosyanın başında sabit olarak topla; değişiklik tek yerde yapılır.
Gizli yapılandırılmış veri: JSON-LD
Birçok e-ticaret sayfası arama motorları için ürün bilgisini <script type="application/ld+json"> içinde JSON olarak verir. Bu veri görsel HTML'den çok daha kararlıdır: json.loads(soup.find("script", type="application/ld+json").string) ile ad, fiyat ve para birimini doğrudan alırsın.
Test edilebilir kazıyıcı
Ağ katmanını (indir) ve ayrıştırma katmanını (çöz) ayır. Ayrıştırıcı yalnızca HTML metni alan saf bir fonksiyon olsun; testlerde kaydedilmiş örnek sayfaları kullan. Bu derste alıştırmaların hepsi bu kalıpla kurulu: HTML metni girer, sözlük çıkar.
Ölçek
Yüzlerce siteyi düzenli kazıyacaksan Scrapy çerçevesi; istek kuyruğu, gecikme, yeniden deneme, robots.txt ve dışa aktarma işlerini hazır verir. Ama kurallar aynı kalır: izin, nezaket, minimum veri.