R ile Scraping - İkinci El Araç Verisi

12 minute read

Published:


Projenin Amacı

Data Engineering, Cloud ve de otomasyonu nasıl bir araya getirebilirim düşüncesi ile ortaya çıktı. Otomatize bir yapı olacağı için veri kaynağı bulmam gerekiyordu o nedenle de değişkenlik gösteren bir kaynağa ihtiyacım vardı. Aynı zamanda veri almanın sorun teşkil etmeyeceğini düşündüğüm bir site bulup hem rvest hem de dplyr ile pratik yapmak istedim. Carvago ile yaptığım denemelerde sorun yaşamadıktan sonra gerekli veri temizliği aşamalarını ekledim. Projede amaçladığım bir diğer şey ise verileri farklı ortamlarda farklı şekillerde tutmaktı. Ham (günlük CSV) ve işlenmiş (birleştirilmiş ve tekil ilanlardan oluşan CSV) veri Github repo’suna yazılırken işlenmiş veriyi sadece AWS RDS üzerindeki PostgreSQL’e yazdım. Bunun yanı sıra yine ham ve işlenmiş veriyi Athena ile kullanabilmek üzere S3’e yazdım. Bununla birlikte Github Actions için de iyi bir pratik olması adına bazı aşamaları ayırdım. Örneğin ilk aşamada veri çekme, temizleme ve basit bir log dosyasına temel analizleri yazdırırken AWS S3 ile senkronizasyonu ayrı bir akış olarak ekledim. Tüm bunlardan sonra da hata alınmaması durumunda RMarkdown ile rapor ekleyip bunun github.io üzerinden yayınlanacağı akışı ekledim. Böylece kaynağından alınan verinin basit işlemeler ile temel raporlama sunacak hale getirildiği uçtan-uca bir veri akışı oluşturdum.

Tüm bunların yanı sıra hem maliyeti minimumda tutacak hem de güvenlik zaafiyeti ortaya çıkarmayacak bir akış oluşturmaya çalıştım. Bu amaçla RDS servisini Github Actions çalışmasından önce çalıştıracak bir Lambda fonksiyonu ekledim ve aynı şekilde bir süre sonra DB’yi durduracak bir fonksiyon daha ekledim. Böylece DB belirlenen saatlar dışında çalışmadığı için ekstra bir maliyet yaratmıyor. Aynı zamanda DB’ye dışarıdan olası bir erişimi engellemek adına sadece verilerin yazıldığı tablo üzerinde yetkisi olan bir kullanıcı yaratttım. Github Actions üzerinden rastgele IP bloğuyla gelindiği için DB’ye gelen trafik kurallarından (security groups) erişimi her yere açmam gerekti. Bu nedenle varsayılan olan 5432 DB Port’unu farklı bir port ile değiştirdim. Son olarak ise S3’e erişecek kullanıcı için de sadece veri yazacağı bucket’a erişecek şekilde bir rol tanımladım ve sadece CLI (programmatic access) ile erişimi var. Tüm bu bilgieri de Github Actions Secret altında tutarak kod içerisinde herhangi birini kullanmadım.

Güvenliğe neden ekstra özen gösterdiğim konusunda fikir vermesi açısından;




İlanların Alınması


İlanların çekilebilmesi için ilk olarak carvago.com adresinde bulunan ilanların listelenmesi gerekmekte. Böylece ilk aşamada ilanlara ait URL’ler alınacak ve sonraki aşamada bu URL’lere gidilerek ilan detayları alınacak. Buradaki en büyük yardımcım copy -> xpath oldu. Böylece gerekli olan tüm verilerin olduğu div bloklarına doğrudan erişebildim. Copy xpath nedir derseniz 👇🏻


Görsel Kaynağı


  1. utils.R içerisinde bulunan gatherer ile listelenen ilanlar her bir sayfadan alınıyor (her bir sayfada 20 ilan var). Böylece tüm ilanlara ait ilan başlığı, ilan id’si ve ilana ait url’den oluşan bir data frame elde ediliyor.

  2. Yine utils.R içerisinde bulunan ads_to_df fonksiyonu ile ilk adımda elde edilen data frame içerisinde bulunan linkleri kullanarak ilgili ilanın sayfasına gidiliyor ve ilana ait marka, model, trafiğe çıkış tarihi (ay/yıl), fiyatı, km’si gibi bilgiler alınıyor. Yukarıda da belirttiğim gibi xpath’ler işimi inanılmaz kolaylaştırdı. rvest içerisinde farklı node’lara bakmama gerek kalmadan doğrudan verinin olduğu div içerisinden html_text fonksiyonu ile değerleri alabildim. Alınan tüm bilgiler daha sonra data frame oluştururken kullanılmak üzere bir değişkene atanıyor. Son olarak bu değişkenler kullanılarak ilana ait tek satırdan oluşan bir data frame oluşturuluyor. Fonksiyonun amacı tek bir URL içerisindeki bilgileri data frame haline getirmek aslında.

     price <- data %>%  html_nodes(xpath = '/html/body/div[1]/div/main/section[1]/div[2]/div[2]/div[1]/div/div[1]') %>% html_text()
    

    2.1 Burada bahsetmem gereken bir sorun ise kimi ilanlarda bazı bilgiler girilmediği için div’ler arasında kaymalar olabilmesi. Bu nedenle örneğin yakıt tüketimi x.y l/100km olması gereken kolonda çekiş tipine karşılık gelen 4x2 değeri görülebiliyor. Bu durumdan kaynaklı ortaya çıkabilecek problemler için de data frame oluşturma aşamasında div’lerden birine ait değerin boş olması durumunda ifelse kullanarak NA ile doldurulacak şekilde kurguladım. Yine sorunu minimize etmek adına verilerin çekildiği analysis.R içerisinde bazı regex ve kural bazlı düzeltmeler ekledim. Bununla birlikte yine veriyi alırken bazı karakterlerden (€ örneğin) kaynaklı fazla sayıda boşluk (\n gibi) ortaya çıkıyor. Bu problemi de veriyi yazmadan önce stri_replace_all_charclass(df$variable, "\\p{WHITE_SPACE}", "") ile çözdüm. Son olarak tüm çekilen bilgileri kullanarak bir data frame oluşturdum 👇🏻

     df <- data.frame(
           brand = ifelse(length(brand) != 0, brand, NA),
           model = ifelse(length(model) != 0, model, NA),
           ...
         )
    
  3. Son olarak utils.R içerisindeki df_maker fonksiyonu ile 1. adımda elde edilen ilanların her birini for döngüsü ile birleştirilmiş bir data frame haline getiriliyor. Buradaki detay ise fonksiyonun tryCatch içerisinde çalışması. Normalde sadece döngü içerisinde olması durumunda ilk 4xx/5xx hatasında döngü kırılıyor. Sorunun önüne geçebilmek ve try’da hata alması durumunda sonraki döngüye geçebilmesi adına stackoverflow‘da denk geldiğim çözümü uyguladım. Bu sayede ilan kaldırılmış olsa ya da erişim sorunu yaşansa bile bir sonraki adıma geçerek nihai data frame oluşturuluyor. Son olarak da günün tarihini y_m_d formatında olacak şekilde veri adına ekleyerek raw_data klasörüne veri yazılıyor.

İlanların Analizi

İlanlar alındıktan ve sorunsuz bir şekilde yazıldıktan sonra analysis.R script’i ile önce klasör altındaki tüm dosyalar okunarak tek bir data frame haline getiriliyor. Birleştirilen veri üzerinde tarih dönüşümü, metin temizliği gibi işlemler yapıldıktan sonra tekil ilanlar processed klasörüne yazılıyor. Bunun yanı sıra cat fonksiyonunu kullanan primitif bir raporlama yapılıyor. İlk olarak sink kullanılarak sürekli üstüne yazılan bir report/daily_report.log dosyası oluşturuluyor. cat ile yazdırılan tüm içerikler bu dosya içerisine gönderiliyor böylece. Tekil ilanların yazılması tamamlandıktan sonra analiz zamanı, toplam veri boyutu, verideki tarih aralığı (min - max) gibi değerler yazdırılıyor. Daha sonra basit gruplamalar ile betimsel istatistikler hesaplanıyor ve kable ile tablo formatında yazılıyor. Örnek bir kod ve tablo çıktısı aşağıdaki gibi;


kable(df %>% group_by(insertdate) %>% count(), format = "pipe")

|insertdate |   n|
|:----------|---:|
|22/08/2022 |  99|
|23/08/2022 |  99|
|24/08/2022 | 198|
|25/08/2022 | 194|
|26/08/2022 | 198|
|27/08/2022 | 198|

Veriler DB’ye df_t_db.R script’i ile yazılıyor. Çok basit olan bu kod içerisinde processed/ klasörüne en son yazılan dosya adını alarak en güncel kümülatif veriyi okuyup bu veriyi DB’ye insert ile atıyor. r # en son yazılan dosyanın bulunması f <- file.info(list.files("processed/", full.names = T)) f <- rownames(f)[which.max(f$mtime)]

Günlük rapora buradan veya buradan erişebilirsiniz.

İlanların RMarkdown ile Raporlanması

Bir önceki aşama olan ilanların analizinde basit bir log’lama yapılıyordu. Bu aşamada RMarkdown kullanılarak HTML bir sayfa oluşturuluyor ve ggplot2 ile görseller oluşturuluyor.

Temel analizlere ek olarak en çok ilan verilen 10 marka, araçların yıllara göre dağılımı, ortalama km vb. görseller oluşturuluyor. Bununla birlikte DT paketi kullanılarak belirli filtrelemer sonrasında örnek bir veri seti Data Table formatında markdown’ın sonuna ekleniyor. Şu an için veri sayısının görece az olması, bazı markaların domine etmesi nedeniyle görseller çok bilgi vermiyor, bir süre sonra eklemeler yapmayı planlıyorum.

Rapora buradan erişebilirsiniz.


Github Actions


Github Actions (GA) kullanabileceğim her fırsatta yararlandığım bir Github ürünü. GA hakkında detaylı bilgi almak isteyenler için daha önce Github Actions ile CML yazımda Github Actions’tan bahsetmiştim. Bu projede de schedule, image ve needs özelliğinden fazlasıyla yararlandım.

GA için Docker İmajının Oluşturulması

GA akışı içerisinde Ubuntu, Windows, macOS gibi işletim sistemleri çalıştırabiliyorsunuz. Bununla birlikte kodunuzun çalışacağı bir Docker imajı da kullanabiliyorsunuz. Bu nedenle ben de proje için gerekli olabilecek olan paketlerden oluşan bir imaj oluşturdum. Daha önce EC2’da kullanmak için oluşturdum yine Actions kullandığım bir imaj hazırlama akışım vardı. Burada kullandığım imaj Ubuntu üstüne hem R hem de Python kuruyor ve seçtiğim paketlerin kurulumunu yapıyor. Böyle bir imaj doğal olarak boyut olarak bir hayli büyük oluyor. Bu durumdan kaçınmak adına rocker‘ın hazırladığı R imajından faydalandım. Geliştirme yaptığım sürüm olan 4.0.5 sürümünü kullanarak gerekli paketleri yükledim. Son olarak da Dockerhub‘a yükleyerek GA akışının da erişebilmesini sağladım.

# https://github.com/silverstone1903/used-car-scraper/blob/master/utils/Dockerfile
FROM  rocker/r-ver:4.0.5

RUN mkdir app
WORKDIR app
RUN apt-get update -y && apt-get install -y libxml2-dev libcurl4-openssl-dev libssl-dev libpq-dev git
COPY pkgs.R .
RUN Rscript pkgs.R

Actions için Secret Tanımlanması

Veri DB’ye yazılırken kodun içerisinde herhangi bir host, kullanıcı adı veya şifre geçmemesi için Secrets‘tan yararlandım. İşletim sistemlerinde bulunan environment variables mantığıyla çalışan bu secret’ları repo ayarları içerisinde tanımladıktan sonra verilerin DB’ye yazıldığı script içerisinde ve actions workflow‘u içerisinde belirttim. Aşağıda kod parçacıklarını görebilirsiniz.

# df_2_db.R
db_host <- Sys.getenv("DB_HOST")
db_name <- Sys.getenv("DB_NAME")
db_pass <- Sys.getenv("DB_PASS")
db_user <- Sys.getenv("DB_USER")
db_port <- Sys.getenv("DB_PORT")
#workflow.yml
- name: Insert Data to DB
        continue-on-error: true
        # https://canovasjm.netlify.app/2021/01/12/github-secrets-from-python-and-r/#on-github-secrets
        env: 
          DB_HOST: $
          DB_NAME: $
          DB_PASS: $
          DB_USER: $
          DB_PORT: $
        run: Rscript codes/df_2_db.R 

Actions ile Akışlar

Actions yaml’ı içerisinde Scraper, DataSync ve son olarak da rmarkdown işleri bulunmakta.

Scraper ile run_all.R ve analysis.R kodları çalıştırılıyor ve ham & işlenmiş veri yazılıyor. Veriler yazılıp repo’ya push’landıktan ve DB’ye de yazıldıktan sonra hata alınmaması durumunda ikinci aşama olan DataSync işine geçiliyor. actions/checkout@v2 ile repo içeriğine ulaşıldıktan sonra aws-actions/configure-aws-credentials@v1 ile bucket’a veri yazılıyor. Veri bucket’a yazıldıktan sonra 3. aşama olan rmarkdown aşamasına geçiyor. Birinci ve ikinci aşamadan farklı olarak docker imajı oluşturulmasında oluşturduğum imaj yerine cloud-first-initialization ile oluşturduğum imajı kullanıyor. Bunun nedenlerinden biri rocker imajını büyütmemek istemem ve de kullandığım imajda bu aşama için gerekli olan ekstra tüm paketlerin (rmarkdown, ggplot2, DT vb.) kurulu olması.

Yukarıda bahsettiğim şekilde secret’lar da tanımlandığı için workflow içerisinde gerekli oldukları yerlerde secrets.SECRET_NAME formatında olacak şekilde ekledim. needs argümanı ile de bir önceki aşamanın tamamlanması gerektiğini belirttim. Böylece 2. aşamaya geçilebilmesi için ilk aşamada hata alınmaması gerekmekte. Workflow’un genel yapısı ise aşağıdaki gibi 👇🏻

name: Scraper & Data Sync & Markdown
on:
  workflow_dispatch:
  schedule:
   - cron: "0 20 * * *"

jobs:
  scraper:
    runs-on: ubuntu-latest
    container:
      image: silverstone1903/rockerrr
    ...

  datasync:
    runs-on: ubuntu-latest
    needs: scraper
    ...


  rmarkdown:
    runs-on: ubuntu-latest
    container:
        image: silverstone1903/pythonr
    needs: datasync
    ...


AWS


AWS RDS’te PostgreSQL DB oluşturulması

RDS kullanarak free-tier özelliklerine sahip PostgreSQL DB oluşturulmuştur. Free-Tier DB’nin nasıl oluşturulduğuna dair rehbere buradan erişebilirsiniz.

DB’ye Github Actions üzerinden erişileceği için security group altında izin verilen port için her yerden (anywhere) erişim tanımlanmıştır. Bu nedenle de olası bir zaafiyeti engellemek adına normalde 5432 olan port yerine farklı bir port kullanılmıştır.

PostgreSQL’de Kullanıcı oluşturulması ve Yetkilendirme

AWS RDS ile DB oluşturduktan sonra superuser (yetkili abi) ile erişim sağlıyorsunuz fakat olası bir kötü senaryoda DB’nize tüm yetkileri olan kullanıcı ile ulaşılmasını engellemek adına sadece yazma işleminde kullanılacak, yetkileri sınırlı ve ilgili tabloda yetkileri olacak olan bir kullanıcı yaratmak gerekiyor.

# username adında sifresi pass olan kullanıcı yaratılması
create user username with password 'pass'; 

# username kullanıcısına tablename için yetkilerin verilmesi
grant select, insert, update on public.tablename to username;

# RPostgreSQL paketi insert sırasında drop & create yaptığı için 
# kullanıcıya tabloyu tekrar yaratması için yetki verilmesi
alter user username CREATEDB;

DB’nin Başlatılması & Durdurulması

Her ne kadar DB free-tier özelliğinde olsa da ben artık free-tier’dan yararlanmadığım için çalıştığı süre ve kullandığı depolama alanı için maliyet yaratıyor. Bunula birlikte canlıda bir veri tabanı olmadığı için de sürekli çalışır durumda olması gerekmiyor. Bu nedenle servisi Github Actions script’i çalışmadan başlatacak ve script tamamlandıktan sonra kapatacak iki Lambda fonksiyonu kurguladım. Açıkçası nasıl yapayım diye düşünme gereği duymadan zaten hali hazırda AWS‘te paylaşılan örneği uyguladım. Buna göre RDS’i başlatacak olan fonksiyon 22.50’de tetiklenirken kapatacak fonksiyon ise 23.50’de tetikleniyor. DB’nin ayağa kalması recovery vb. nedenlerle 5 dakikayı geçebiliyor, kapanması da benzeri bir süre alıyor. GA script’i gecikmeli başlayabileceği için süreyi uzun tutmakta fayda var. Zamanlama ise Amazon EventBridge kuralları ile yönetiliyor.

Alternatif olarak Github Actions içerisinde Lambda’ları tetikleyecek bir kod eklenerek çalışma başlangıcında ve bitiminde bu işlemler yapılabilir. Açılma süresi 5 dakikayı bulduğu için Actions içerisinde bunu kontrol edecek bir adım olması gerektiği unutulmamalıdır (sleep vb.). Bir diğer alternatif olarak da Amazon Aurora Serverless kullanılabilir.

S3 Erişimi

Verilerin repo içerisinde ve DB’de saklanmasının yanı sıra bir de S3 üzerinde senkronize etmek istedim. S3’e veri yazması için IAM ile bir kullanıcı yarattım. Bu kullanıcıya sadece CLI’dan erişecek yetkiyi tanımladım ve S3’te sadece veri yazacağı bucket’a erişebilmesi için yeni bir rol yarattım. Detayları aşağıda olan policy ile sadece ilgili bucket için tümüyle yetki tanımlamış oldum. Aynı zamanda bu kullanıcı ile de bucket’ın public olmasına gerek kalmadı.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetBucketLocation",
                "s3:ListAllMyBuckets"
            ],
            "Resource": "arn:aws:s3:::*"
        },
        {
            "Effect": "Allow",
            "Action": "s3:*",
            "Resource": [
                "arn:aws:s3:::bucket-name",
                "arn:aws:s3:::bucket-name/*"
            ]
        }
    ]
}

Şu an için Actions script’i Türkiye saati ile 23.00’da tetiklenecek şekilde kurgulanmış durumda. Günlük olarak 10 sayfa (200 ilan) veri çekiliyor. Tüm akış yaklaşık 6 dakika sürüyor ve bunun içinde 2 defa docker imajının çekilmesi var.

Şimdiye dek 1 haftalık veri alınmış durumda. carvago tarafında veri alınmasını engelleyecek büyük bir değişiklik olmadığı sürece çalıştırmayı düşünüyorum. Verilere erişmek isteyenler şu an için sadece repo üzerinden erişebilirler. Belki ileride sadece select yetkisi olan bir kullanıcı paylaşabilirim DB için veya verileri S3’te public yaparak Athena ile sorgulanabilir hale getiririm 👀

Repo: silverstone1903/used-car-scraper
Log: link
Rapor: link

English version: dev.to

Kaynaklar

  1. https://canovasjm.netlify.app/2021/01/12/github-secrets-from-python-and-r/#on-github-secrets
  2. https://stackoverflow.com/a/67041362
  3. https://aws.amazon.com/blogs/database/schedule-amazon-rds-stop-and-start-using-aws-lambda/
  4. https://stackoverflow.com/questions/8093914/use-trycatch-skip-to-next-value-of-loop-upon-error/55937737#55937737

Ve irili ufaklı şeylere bakıp eklemeyi unuttuğum nice stackoverflow link’i 😅