DE

[Tech Blog #20 | Data Engineering] Web Scraping Thực Chiến: Tự Động Cào Dữ Liệu Web Bằng Python & Nạp Vào CSDL

0 views
[Tech Blog #20 | Data Engineering] Web Scraping Thực Chiến: Tự Động Cào Dữ Liệu Web Bằng Python & Nạp Vào CSDL

1. Đặt vấn đề: Khi thế giới không có sẵn REST API cho bạn

Ở [Tech Blog #12], chúng ta đã học cách xây dựng pipeline ETL lấy dữ liệu từ một REST API sạch sẽ trả về JSON.
Thế nhưng trong thực tế công việc, có đến 70% lượng dữ liệu giá trị ngoài thị trường không hề có API công khai:

  • Bạn muốn theo dõi biến động giá hàng rào đối thủ trên các trang thương mại điện tử (Shopee, Tiki, Amazon).
  • Bạn cần thu thập tin tức kinh tế, giá vàng, tỷ giá ngoại tệ từ các trang báo tài chính.
  • Bạn cần cào danh sách tin tuyển dụng IT để phân tích xu hướng kỹ năng thị trường đang săn đón.

Khi đó, giải pháp duy nhất của một Data Engineer là sử dụng kỹ thuật Web Scraping (Thu thập dữ liệu web tự động): Dùng code giả lập hành vi người dùng duyệt web, tải về mã HTML thô và bóc tách các thông tin có giá trị.


2. Giải mã cấu trúc Web & Bộ đôi Requests + BeautifulSoup

Một trang web hiển thị trước mắt bạn thực chất là một cái cây phân cấp dạng HTML DOM (Document Object Model):

<div class="product-card">
    <h3 class="title">Bàn phím cơ không dây</h3>
    <span class="price">1.250.000 đ</span>
    <span class="stock">Còn hàng</span>
</div>

Để trích xuất dữ liệu, chúng ta sử dụng cặp đôi "thần thánh" trong hệ sinh thái Python:

  1. requests: Đóng vai trò là "người vận chuyển" – gửi một yêu cầu HTTP GET lên máy chủ web để tải toàn bộ mã HTML thô về máy tính.
  2. BeautifulSoup (thư viện bs4): Đóng vai trò là "chiếc kính hiển vi" – phân tích cú pháp (parse) mã HTML thành cây đối tượng Python, cho phép bạn tìm kiếm thẻ theo tên (<h1>, <div>, <span>), theo tên lớp CSS (class="price"), hoặc theo đường dẫn CSS Selector (soup.select()).

Nguyên tắc "Đạo đức cào web" (Ethical Scraping Guidelines)

Trước khi viết bất kỳ dòng code cào web nào, bạn bắt buộc phải tuân thủ 3 nguyên tắc:

  • Kiểm tra file robots.txt: Truy cập https://example.com/robots.txt để xem website có cấm bot cào dữ liệu ở đường dẫn đó hay không (Disallow: /admin).
  • Gắn User-Agent hợp lệ: Khai báo thông tin User-Agent trong request header để máy chủ nhận diện bạn là một trình duyệt web thông thường, tránh bị chặn tường lửa (Cloudflare/403 Forbidden).
  • Thêm thời gian giãn cách (time.sleep): Tuyệt đối không bắn hàng trăm request trong 1 giây vì sẽ gây nghẽn mạng hoặc sập server người khác (bị coi là tấn công từ chối dịch vụ DDoS).

3. Bảng so sánh: REST API vs. Web Scraping

Tiêu chíREST API (Dữ liệu chính thống)Web Scraping (Dữ liệu bóc tách HTML)
Độ ổn định🟢 Rất cao, có tài liệu phiên bản (v1, v2) rõ ràng.🔴 Thấp, chỉ cần người thiết kế web đổi tên class CSS là code có thể bị lỗi.
Định dạng dữ liệuCó cấu trúc chuẩn (JSON, XML).Dữ liệu thô phi cấu trúc (HTML lộn xộn).
Tốc độ xử lýRất nhanh, chỉ truyền tải dữ liệu thuần.Chậm hơn, phải tải cả cây HTML và parse thẻ.
Tính hợp pháp & Giới hạnCó điều khoản sử dụng, hạn mức (Rate-limit) minh bạch.Cần cẩn trọng về bản quyền, quy định bảo vệ dữ liệu cá nhân (GDPR) và robots.txt.

4. Thực hành thực chiến: Script Python cào dữ liệu và nạp vào Database

Dưới đây là một script Python hoàn chỉnh, sử dụng requests và BeautifulSoup để cào dữ liệu danh mục sách từ trang web thực hành books.toscrape.com (kèm cơ chế HTML nội tại tự chạy được 100% ngay cả khi offline) và nạp dữ liệu sạch vào CSDL SQLite với cơ chế Upsert chống trùng lặp:

import sqlite3
import datetime
import time
import requests
from bs4 import BeautifulSoup

# ==============================================================================
# BƯỚC 1: HÀM CÀO DỮ LIỆU HTML VÀ BÓC TÁCH (EXTRACT & TRANSFORM)
# ==============================================================================
def scrape_books_catalog(target_url: str) -> list:
    """Tải mã HTML và trích xuất thông tin sách thành danh sách có cấu trúc."""
    print(f"[{datetime.datetime.now()}] Bắt đầu kết nối tới: {target_url}")
    
    # Khai báo User-Agent để giả lập trình duyệt Chrome chuẩn
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
    }
    
    try:
        response = requests.get(target_url, headers=headers, timeout=10)
        response.raise_for_status()
        html_content = response.text
    except Exception as e:
        print(f"Không thể kết nối mạng ({e}), sử dụng HTML mô phỏng để minh họa...")
        # Fallback HTML mô phỏng cấu trúc của books.toscrape.com
        html_content = """
        <article class="product_pod">
            <h3><a title="A Light in the Attic" href="catalogue/1">A Light in the Attic</a></h3>
            <p class="star-rating Three"></p>
            <div class="product_price">
                <p class="price_color">£51.77</p>
                <p class="instock availability"><i class="icon-ok"></i> In stock</p>
            </div>
        </article>
        <article class="product_pod">
            <h3><a title="Tipping the Velvet" href="catalogue/2">Tipping the Velvet</a></h3>
            <p class="star-rating One"></p>
            <div class="product_price">
                <p class="price_color">£53.74</p>
                <p class="instock availability"><i class="icon-ok"></i> In stock</p>
            </div>
        </article>
        """

    # Phân tích cú pháp HTML bằng BeautifulSoup
    soup = BeautifulSoup(html_content, "html.parser")
    book_articles = soup.find_all("article", class_="product_pod")
    
    extracted_books = []
    current_time = datetime.datetime.utcnow().isoformat()
    
    # Bảng ánh xạ số sao từ chuỗi CSS sang số nguyên
    rating_map = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}
    
    for book in book_articles:
        # 1. Trích xuất Tiêu đề sách
        title_tag = book.find("h3").find("a")
        title = title_tag.get("title") or title_tag.text.strip()
        
        # 2. Trích xuất Giá tiền (Làm sạch bỏ ký tự tiền tệ '£')
        price_text = book.find("p", class_="price_color").text.strip()
        # Loại bỏ các ký tự không phải số và dấu chấm
        clean_price = float("".join([c for c in price_text if c.isdigit() or c == "."]))
        
        # 3. Trích xuất Đánh giá sao (Star rating)
        rating_classes = book.find("p", class_="star-rating").get("class", [])
        rating = 0
        for cls in rating_classes:
            if cls in rating_map:
                rating = rating_map[cls]
                break
                
        # 4. Trích xuất Tình trạng kho
        availability_text = book.find("p", class_="instock").text.strip()
        in_stock = 1 if "in stock" in availability_text.lower() else 0
        
        extracted_books.append((title, clean_price, rating, in_stock, current_time))
        
    print(f"Đã trích xuất và làm sạch thành công {len(extracted_books)} cuốn sách.")
    return extracted_books

# ==============================================================================
# BƯỚC 2: HÀM NẠP DỮ LIỆU VÀO DATABASE (LOAD VỚI UPSERT)
# ==============================================================================
def load_to_database(db_name: str, book_records: list):
    """Lưu trữ dữ liệu vào SQLite với cơ chế chống trùng lặp theo Tiêu đề."""
    conn = sqlite3.connect(db_name)
    cursor = conn.cursor()
    
    # Tạo bảng lưu trữ danh mục sản phẩm
    create_table_sql = """
    CREATE TABLE IF NOT EXISTS scraped_books (
        book_title TEXT PRIMARY KEY,
        price_gbp REAL NOT NULL,
        star_rating INT,
        is_in_stock INT,
        scraped_at TIMESTAMP
    );
    """
    cursor.execute(create_table_sql)
    
    # Câu lệnh Upsert: Trùng tiêu đề thì cập nhật giá và thời gian cào mới nhất
    upsert_sql = """
    INSERT INTO scraped_books (book_title, price_gbp, star_rating, is_in_stock, scraped_at)
    VALUES (?, ?, ?, ?, ?)
    ON CONFLICT(book_title) DO UPDATE SET
        price_gbp = excluded.price_gbp,
        star_rating = excluded.star_rating,
        is_in_stock = excluded.is_in_stock,
        scraped_at = excluded.scraped_at;
    """
    
    cursor.executemany(upsert_sql, book_records)
    conn.commit()
    
    # Đối soát dữ liệu đã nạp
    cursor.execute("SELECT book_title, price_gbp, star_rating FROM scraped_books LIMIT 5;")
    rows = cursor.fetchall()
    print("\n--- 5 BẢN GHI ĐẦU TIÊN TRONG CƠ SỞ DỮ LIỆU ---")
    for r in rows:
        print(f"Tên sách: '{r[0]}' | Giá: £{r[1]} | Đánh giá: {r[2]} sao")
        
    conn.close()

# ==============================================================================
# CHẠY TOÀN BỘ PIPELINE
# ==============================================================================
if __name__ == "__main__":
    TARGET = "[http://books.toscrape.com/catalogue/category/books_1/index.html](http://books.toscrape.com/catalogue/category/books_1/index.html)"
    DB_FILE = "web_scraping_data.db"
    
    books = scrape_books_catalog(TARGET)
    if books:
        load_to_database(DB_FILE, books)
        print("\nPipeline cào và nạp dữ liệu hoàn tất xuất sắc!")

5. Câu hỏi phỏng vấn thực tế (Interview Q&A)

Câu 1 (Mức độ: Entry / Junior)

Câu hỏi: "Sự khác biệt cơ bản giữa việc cào trang web tĩnh (Static Web) và trang web động (Dynamic Web) là gì? Bạn dùng công cụ nào cho từng loại?"

Gợi ý trả lời:
*"Trang web tĩnh (Static Web) là trang web mà toàn bộ nội dung văn bản và dữ liệu đã được nhúng sẵn trong mã HTML được gửi về từ máy chủ. Với dạng này, chúng ta chỉ cần dùng thư viện nhẹ và nhanh như requests kết hợp BeautifulSoup là có thể trích xuất được ngay.
Ngược lại, Trang web động (Dynamic Web / SPA) là trang web sử dụng JavaScript (như React, Vue, Angular) để gọi API ngầm và render dữ liệu ra màn hình sau khi tải trang. Nếu chỉ dùng requests thông thường, chúng ta chỉ nhận về khung HTML rỗng.
Để xử lý web động, chúng ta có 2 hướng tiếp cận:

  1. Mở cửa sổ Network Tab trên trình duyệt để tìm API ngầm mà trang web đang gọi, rồi dùng requests gọi trực tiếp vào API đó (cách tối ưu nhất).
  2. Sử dụng các công cụ tự động hóa trình duyệt như Playwright hoặc Selenium để mô phỏng một trình duyệt thật có thể chạy JavaScript, đợi dữ liệu load xong rồi mới bóc tách."*

Câu 2 (Mức độ: Junior / Mid-level)

Câu hỏi: "Nếu trang web mục tiêu thay đổi giao diện HTML (đổi tên thẻ, class CSS bị mã hóa ngẫu nhiên), pipeline cào dữ liệu của bạn sẽ bị sụp đổ. Làm thế nào bạn phát hiện sớm sự cố này?"

Gợi ý trả lời:
*"Để chủ động phát hiện và kiểm soát rủi ro giao diện web thay đổi, em triển khai các giải pháp sau:

  1. Kiểm tra tính toàn vẹn dữ liệu (Data Contract & Schema Validation): Sử dụng thư viện như Pydantic hoặc Great Expectations. Nếu hàm cào trả về số lượng bản ghi bằng 0, hoặc tỷ lệ trường dữ liệu bị NULL vượt quá ngưỡng cho phép (ví dụ >5%> 5\%), pipeline sẽ chủ động dừng lại và báo lỗi thay vì nạp dữ liệu rỗng vào Database.
  2. Sử dụng CSS Selector bền vững (Robust Selectors): Thay vì dựa vào tên class CSS ngẫu nhiên (dễ bị đổi khi deploy bản mới), em sẽ ưu tiên bắt theo cấu trúc cây DOM phân cấp hoặc các thuộc tính ổn định như data-testid, thẻ ngữ nghĩa (<article>, <header>), hoặc liên kết nội dung (href).
  3. Hệ thống Cảnh báo tự động (Alerting): Bắn thông báo ngay lập tức qua Slack/Telegram khi script gặp lỗi HTTP status bất thường (403, 404, 500) hoặc không trích xuất được dữ liệu."*

6. Key Takeaways (Ghi nhớ nhanh)

  1. Web Scraping mở khóa 70% dữ liệu thị trường: Dành cho những nguồn dữ liệu công khai giá trị nhưng không có sẵn REST API.
  2. Tuân thủ đạo đức cào web: Luôn đọc robots.txt, gắn header User-Agent của trình duyệt và chèn time.sleep để không làm quá tải máy chủ đối tác.
  3. Bộ đôi hoàn hảo: requests để tải mã HTML về máy; BeautifulSoup để dò tìm và bóc tách dữ liệu theo thẻ tag và class CSS.
  4. Ưu tiên bắt API ngầm trước: Khi gặp trang web động dùng JavaScript, luôn mở F12 Network Tab tìm endpoint API trước khi phải dùng đến các công cụ nặng nề như Selenium hay Playwright.
0Claps