Ana içeriğe geç
Kesit Python
Orta · Otomasyon, web ve araçlar

Web kazıma: HTML, BeautifulSoup ve etik

HTML'in ağaç yapısını okur; BeautifulSoup ile find, find_all ve CSS seçicilerle veri çıkarır, tablo ve sayfalamayı kazır, robots.txt, KVKK ve nezaket kurallarına uyan bir kazıyıcı kurarsın.

Uygulamada aç, 28 alıştırmayı çözÜcretsiz, hesap gerekmez, kurulum yok.

Bu sayfa dersin okuma sürümüdür. Metin bir kutuyu çalıştırmanı ya da adıma geçmeni söylüyorsa dersi uygulamada aç.

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
Kendi bilgisayarında deneKurulum: 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ürBulamazsa
find(ad, class_=..., attrs={...})İlk eşleşen etiketNone
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ğeriKeyError / None
etiket.attrsTüm öznitelikler sözlüğü{}
class bir Python anahtar sözcüğüdürfind("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çiciAnlamı
div.urunclass'ı urun olan div
#katalogid'si katalog olan etiket
div.urun.indirimhem urun hem indirim sınıfı olan div
#katalog span.fiyatkatalog içindeki (her derinlikte) fiyat span'leri
div.urun > h3urun 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:

KonuNe yapmalısın?
robots.txtSitenin 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 alanlarOturum 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ı

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()
Kendi bilgisayarında denepip 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.
Gerçek işteBir perakende şirketinde fiyat izleme kazıyıcısı genelde şu biçimdedir: gece 03:00'te çalışan bir zamanlanmış görev, izin verilen tedarikçi sayfalarını robots.txt'yi denetleyerek ve 2 saniye arayla indirir, ham HTML'i tarihli klasöre kaydeder, ayrıştırıcı yalnızca SKU ve fiyatı çıkarır, önceki günle karşılaştırıp yalnızca değişenleri kategori yöneticisine raporlar. Ayrıştırıcı kaydedilmiş HTML örnekleriyle test edilir; site tasarımı değişip testler kırılınca ekip haberdar olur.
Derinleş

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])
BeklemeNe 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.sleepHiç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"]})
Gizli API de kurallara tabidirBelgelenmemiş bir uç noktayı kullanmak sitenin kullanım koşullarına aykırı olabilir ve haber vermeden değişebilir. Derste gördüğün robots.txt, hız sınırı ve KVKK kuralları burada da geçerli. Resmî bir API varsa onu tercih et.

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)
Kendi bilgisayarında denepip 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.
Gerçek işteOtomasyon ekiplerinde tipik iş, tedarikçi ya da kamu portalından her sabah rapor indirmektir. Sağlam bir çözümün işaretleri: sleep yok, oturum dosyası saklanıyor ve süresi denetleniyor, veri mümkünse JSON'dan alınıyor, dosya adları tarihle ve atomik olarak kaydediliyor, her adım loglanıyor.
Uzmanlaş

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.

Alıştırmalara geç (28)