เรียนรู้การเลือกเครื่องมือ อ่าน HTML ดึงข้อมูลหลายหน้า และบันทึกเป็น CSV/JSON
ตัวอย่างใช้เว็บไซต์ฝึกหัด Quotes to Scrape
วันที่ตรวจสอบเอกสารอ้างอิง: 7 ตุลาคม 2026

info-web-scraping-python-workshop

#1. Web Scraping คืออะไร

Web Scraping คือการใช้โปรแกรมดึงข้อมูลจากหน้าเว็บ แยกส่วนที่ต้องการ และแปลงเป็นข้อมูลที่นำไปใช้งานต่อได้ เช่น ตาราง CSV, JSON หรือฐานข้อมูล คำที่ใช้ทั่วไปสะกดว่า “Scraping”

ตัวอย่างงาน ได้แก่ รวบรวมชื่อบทความและลิงก์ สร้างชุดข้อมูลสำหรับการวิเคราะห์ ติดตามข้อมูลสินค้าที่ได้รับอนุญาตให้เก็บ และเตรียมข้อมูลสำหรับระบบค้นหาหรือ RAG

การทำงานมีห้าขั้นตอน: ขอหน้าเว็บ → อ่านโครงสร้าง → เลือกข้อมูล → ทำความสะอาด → จัดเก็บ

การดึงข้อมูลสำเร็จยังไม่รับประกันว่าข้อมูลถูกต้อง จึงต้องตรวจจำนวนรายการ ฟิลด์ที่หายไป รายการซ้ำ และแหล่งที่มา

#2. Scraping, Crawling และ API ต่างกันอย่างไร

แนวทาง หน้าที่หลัก ตัวอย่าง
Web Scraping ดึงข้อมูลเฉพาะจากหน้าเว็บ ดึงชื่อบทความจาก HTML
Web Crawling สำรวจลิงก์เพื่อค้นหาหลายหน้า ติดตามลิงก์หน้าถัดไป
API Integration รับข้อมูลตามรูปแบบที่ระบบกำหนด เรียก endpoint ที่ส่ง JSON

งานหนึ่งอาจใช้ Crawling เพื่อค้นหาหน้า แล้วใช้ Scraping ดึงข้อมูลจากแต่ละหน้า หากเจ้าของเว็บไซต์มี API สำหรับงานที่ต้องการ ควรเริ่มจาก API เพราะมีโครงสร้างและเงื่อนไขการใช้งานที่ชัดเจนกว่า DOM ซึ่งอาจเปลี่ยนเมื่อปรับหน้าเว็บ

#3. เลือกเครื่องมืออย่างไร

สถานการณ์ เครื่องมือ เหตุผล
ข้อมูลอยู่ใน HTML ที่เซิร์ฟเวอร์ส่งกลับ Requests + Beautiful Soup ขอหน้าเว็บและแยกข้อมูลได้โดยไม่เปิดเบราว์เซอร์
ข้อมูลปรากฏหลัง JavaScript ทำงาน Playwright เปิดเบราว์เซอร์และรอ element ที่ต้องการ
ต้องจัดการ crawler หลายหน้าอย่างเป็นระบบ Scrapy มีโครงสร้าง spider และกลไกจัดการการรวบรวมข้อมูล
มี API ที่ได้รับอนุญาตให้ใช้ HTTP client เช่น Requests รับข้อมูลแบบมีโครงสร้างโดยตรง

Requests ทำหน้าที่ส่ง HTTP request ส่วน Beautiful Soup อ่านและค้นหาข้อมูลใน HTML/XML ทั้งสองไม่ได้รัน JavaScript [1][2] Playwright ควบคุมเบราว์เซอร์และเข้าถึง DOM หลังหน้าเว็บทำงานได้ [3][4]

ไม่จำเป็นต้องใช้ LLM กับทุกงาน หากโครงสร้างข้อมูลแน่นอน ให้ใช้ selector และการตรวจสอบข้อมูลก่อน ส่วน LLM เหมาะกับขั้นตอนเพิ่มเติม เช่น จัดหมวดหมู่หรือสรุปเนื้อหา โดยควรเก็บข้อมูลต้นฉบับไว้ตรวจสอบ

#4. เป้าหมาย Workshop

เมื่อทำจบ ผู้เรียนจะสามารถ:

  1. แยกให้ออกว่าข้อมูลอยู่ใน HTML หรือเกิดหลัง JavaScript
  2. ใช้ CSS selector ดึงข้อความ ผู้เขียน และแท็ก
  3. ดึงข้อมูลหลายหน้าโดยจำกัดจำนวนหน้า
  4. บันทึก JSON และ CSV พร้อม URL ต้นทางและเวลาเก็บ
  5. ใช้ Playwright กับหน้าเว็บแบบ JavaScript
  6. อธิบายและแก้ปัญหา selector เปลี่ยน ข้อมูลว่าง และ HTTP error

เว็บไซต์ฝึกหัดคือ Quotes to Scrape และ เวอร์ชัน JavaScript ซึ่งอยู่ในชุด sandbox สำหรับฝึก scraping [5]

#5. เตรียมสภาพแวดล้อม

ใช้ Python 3.10 ขึ้นไปสำหรับตัวอย่างนี้

#macOS / Linux

mkdir web-scraping-workshop
cd web-scraping-workshop
python3 -m venv .venv
source .venv/bin/activate
python -m pip install requests beautifulsoup4

#Windows PowerShell

mkdir web-scraping-workshop
cd web-scraping-workshop
py -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install requests beautifulsoup4

หาก PowerShell ไม่อนุญาตให้ activate สามารถใช้ Python ภายใน virtual environment โดยตรง:

.\.venv\Scripts\python.exe -m pip install requests beautifulsoup4

ตัวอย่างใช้ html.parser ที่มากับ Python จึงไม่ต้องติดตั้ง parser เพิ่ม ชื่อแพ็กเกจคือ beautifulsoup4 แต่ชื่อที่ import คือ bs4 [2]

#6. เข้าใจ HTML และ CSS Selector

HTML สมมติต่อไปนี้ใช้โครงสร้างเดียวกับแนวทางใน workshop:

<div class="quote">
  <span class="text">Learning starts with a question.</span>
  <small class="author">Demo Author</small>
  <div class="tags">
    <a class="tag">learning</a>
    <a class="tag">practice</a>
  </div>
</div>
Selector ความหมาย
div.quote div ที่มี class quote
span.text span ที่มี class text
small.author small ที่มี class author
.tags a.tag ลิงก์ class tag ภายใน element class tags
li.next a ลิงก์ภายในรายการ class next

เปิด DevTools ของเบราว์เซอร์แล้วเลือก Inspect เพื่อดูโครงสร้างจริง การเลือก element ภายใน div.quote แต่ละกล่องช่วยให้ข้อความและผู้เขียนจับคู่กันถูกต้อง

DOM ในแท็บ Elements อาจมีข้อมูลที่ JavaScript เพิ่มภายหลัง จึงควรเปรียบเทียบกับ HTML ใน response ก่อนเลือกใช้ Requests

#7. Workshop A: ดึงข้อมูลหนึ่งหน้า

สร้างไฟล์ scrape_one_page.py:

import requests
from bs4 import BeautifulSoup

url = "https://quotes.toscrape.com/"
response = requests.get(
    url,
    headers={"User-Agent": "WebScrapingWorkshop/1.0"},
    timeout=(5, 20),
)
response.raise_for_status()

soup = BeautifulSoup(response.content, "html.parser")
cards = soup.select("div.quote")

if not cards:
    raise RuntimeError("ไม่พบข้อมูล: ตรวจ HTML และ selector")

for card in cards:
    text_node = card.select_one("span.text")
    author_node = card.select_one("small.author")

    if text_node is None or author_node is None:
        raise RuntimeError("โครงสร้างข้อมูลไม่ตรงกับที่คาดไว้")

    print({
        "text": text_node.get_text(" ", strip=True),
        "author": author_node.get_text(" ", strip=True),
        "tags": [
            tag.get_text(" ", strip=True)
            for tag in card.select(".tags a.tag")
        ],
    })

รัน:

python scrape_one_page.py

timeout=(5, 20) แยกเวลารอการเชื่อมต่อและเวลารออ่านข้อมูล ตัวเลขนี้ไม่ได้รับประกันว่าคำขอทั้งหมดจะจบภายใน 25 วินาที เพราะ read timeout วัดช่วงรอข้อมูล ไม่ใช่เวลารวมของการดาวน์โหลด [1]

raise_for_status() ทำให้ HTTP 4xx/5xx กลายเป็น exception แต่สถานะ 200 ก็ยังอาจเป็นหน้าแจ้งปัญหาหรือ HTML ที่ไม่มีข้อมูล จึงต้องตรวจ selector อีกชั้นหนึ่ง

User-Agent ในตัวอย่างใช้ระบุชื่อโปรแกรม ไม่ได้ใช้เพื่อข้ามมาตรการของเว็บไซต์

#8. Workshop B: ดึงหลายหน้าและบันทึก CSV/JSON

สร้างไฟล์ scrape_quotes.py:

import csv
import json
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urljoin, urlparse

import requests
from bs4 import BeautifulSoup

BASE_URL = "https://quotes.toscrape.com/"
MAX_PAGES = 3
DELAY_SECONDS = 1.0
OUTPUT_DIR = Path("output")


def parse_quotes(html, source_url, scraped_at):
    soup = BeautifulSoup(html, "html.parser")
    cards = soup.select("div.quote")
    if not cards:
        raise ValueError(f"ไม่พบ div.quote: {source_url}")

    rows = []
    for card in cards:
        text_node = card.select_one("span.text")
        author_node = card.select_one("small.author")
        if text_node is None or author_node is None:
            raise ValueError(f"ข้อมูลขาดฟิลด์สำคัญ: {source_url}")

        text = text_node.get_text(" ", strip=True)
        author = author_node.get_text(" ", strip=True)
        if not text or not author:
            raise ValueError(f"ข้อความหรือชื่อผู้เขียนว่าง: {source_url}")

        rows.append({
            "text": text,
            "author": author,
            "tags": [
                node.get_text(" ", strip=True)
                for node in card.select(".tags a.tag")
            ],
            "source_url": source_url,
            "scraped_at": scraped_at,
        })

    next_link = soup.select_one("li.next a")
    href = next_link.get("href") if next_link else None
    next_url = urljoin(source_url, href) if href else None
    return rows, next_url


def main():
    records = []
    seen_records = set()
    visited = set()
    current_url = BASE_URL
    scraped_at = datetime.now(timezone.utc).isoformat()

    with requests.Session() as session:
        session.headers.update({
            "User-Agent": "WebScrapingWorkshop/1.0"
        })

        for page_number in range(1, MAX_PAGES + 1):
            if not current_url or current_url in visited:
                break

            parsed = urlparse(current_url)
            if (
                parsed.scheme != "https"
                or parsed.netloc != urlparse(BASE_URL).netloc
            ):
                raise ValueError("ลิงก์อยู่นอกขอบเขตเว็บไซต์ฝึกหัด")

            visited.add(current_url)
            response = session.get(current_url, timeout=(5, 20))
            response.raise_for_status()

            # ตรวจ URL หลัง redirect ก่อนนำข้อมูลมาใช้
            if urlparse(response.url).netloc != urlparse(BASE_URL).netloc:
                raise ValueError("response มาจากเว็บไซต์นอกขอบเขต")

            rows, next_url = parse_quotes(
                response.content, response.url, scraped_at
            )

            for row in rows:
                key = (row["text"], row["author"])
                if key not in seen_records:
                    seen_records.add(key)
                    records.append(row)

            print(
                f"หน้า {page_number}: พบ {len(rows)} รายการ "
                f"รวมไม่ซ้ำ {len(records)} รายการ"
            )

            current_url = next_url
            if current_url and page_number < MAX_PAGES:
                time.sleep(DELAY_SECONDS)

    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)

    json_path = OUTPUT_DIR / "quotes.json"
    json_path.write_text(
        json.dumps(records, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )

    csv_path = OUTPUT_DIR / "quotes.csv"
    with csv_path.open("w", encoding="utf-8-sig", newline="") as file:
        writer = csv.DictWriter(
            file,
            fieldnames=[
                "text", "author", "tags", "source_url", "scraped_at"
            ],
        )
        writer.writeheader()
        for row in records:
            writer.writerow({
                **row,
                "tags": " | ".join(row["tags"]),
            })

    print(f"บันทึก {len(records)} รายการใน {OUTPUT_DIR.resolve()}")


if __name__ == "__main__":
    try:
        main()
    except (requests.RequestException, ValueError) as error:
        raise SystemExit(f"หยุดการทำงาน: {error}")

รัน:

python scrape_quotes.py

ผลลัพธ์เป็น output/quotes.json และ output/quotes.csv จำนวนจริงขึ้นกับข้อมูลเว็บไซต์ขณะรัน โดยตั้งใจเก็บไม่เกิน 3 หน้า

#จุดที่ควรเข้าใจ

  • Session ใช้ซ้ำในการส่งคำขอ
  • urljoin() แปลงลิงก์สัมพัทธ์ให้เป็น URL เต็ม
  • MAX_PAGES จำกัดขอบเขตการทำงาน
  • visited ป้องกันการวนกลับไปหน้าเดิม
  • seen_records ตัดรายการซ้ำด้วยข้อความและผู้เขียน
  • JSON เก็บ tags เป็น list ส่วน CSV แปลงเป็นข้อความ
  • utf-8-sig ช่วยให้ Excel หลายสภาพแวดล้อมอ่าน UTF-8 ได้สะดวก
  • ถ้าเกิดข้อผิดพลาดก่อนเก็บครบ โปรแกรมหยุดก่อนเขียนผลลัพธ์ใหม่

การตัดซ้ำในตัวอย่างอาจทำให้ข้อมูลที่ตั้งใจซ้ำหายไป งานจริงควรกำหนด primary key ให้ตรงกับความหมายของข้อมูล เช่น URL บทความหรือรหัสสินค้า

CSV ที่นำข้อมูลภายนอกไปเปิดใน spreadsheet ควรจัดการค่าที่ขึ้นต้นด้วยอักขระสูตรตามนโยบายของงาน ตัวอย่างนี้มุ่งใช้ข้อมูลจาก sandbox

#9. Workshop C: หน้าเว็บที่โหลดข้อมูลด้วย JavaScript

ติดตั้ง Playwright และ Chromium:

python -m pip install playwright
python -m playwright install chromium

Linux บางระบบต้องติดตั้ง dependency ของเบราว์เซอร์เพิ่มตามเอกสาร Playwright [3]

สร้างไฟล์ scrape_js.py:

import json
from datetime import datetime, timezone
from pathlib import Path

from playwright.sync_api import (
    TimeoutError as PlaywrightTimeoutError,
    sync_playwright,
)

URL = "https://quotes.toscrape.com/js/"


def main():
    with sync_playwright() as playwright:
        browser = playwright.chromium.launch(headless=True)
        try:
            page = browser.new_page()
            response = page.goto(
                URL, wait_until="domcontentloaded", timeout=30_000
            )

            if response is None or not response.ok:
                status = response.status if response else "no response"
                raise RuntimeError(f"เปิดหน้าเว็บไม่สำเร็จ: {status}")

            # รอข้อมูลที่ต้องการ ไม่ใช่แค่เหตุการณ์โหลดเอกสาร
            page.locator("div.quote").first.wait_for(
                state="visible", timeout=15_000
            )

            records = page.locator("div.quote").evaluate_all(
                """cards => cards.map(card => ({
                    text: card.querySelector("span.text")
                        ?.textContent?.trim() ?? "",
                    author: card.querySelector("small.author")
                        ?.textContent?.trim() ?? "",
                    tags: Array.from(
                        card.querySelectorAll(".tags a.tag")
                    ).map(tag => tag.textContent.trim())
                }))"""
            )

            if not records or any(
                not row["text"] or not row["author"]
                for row in records
            ):
                raise RuntimeError("ข้อมูลว่างหรือขาดฟิลด์สำคัญ")

            scraped_at = datetime.now(timezone.utc).isoformat()
            for row in records:
                row["source_url"] = page.url
                row["scraped_at"] = scraped_at

            output_dir = Path("output")
            output_dir.mkdir(exist_ok=True)
            (output_dir / "quotes-js.json").write_text(
                json.dumps(records, ensure_ascii=False, indent=2),
                encoding="utf-8",
            )
            print(f"บันทึก {len(records)} รายการ")
        finally:
            browser.close()


if __name__ == "__main__":
    try:
        main()
    except (PlaywrightTimeoutError, RuntimeError) as error:
        raise SystemExit(f"หยุดการทำงาน: {error}")

รัน:

python scrape_js.py

domcontentloaded บอกว่าเอกสาร HTML ถูก parse แล้ว แต่ไม่ได้รับประกันว่าข้อมูลแบบ asynchronous โหลดครบ จึงต้องรอ element หรือเงื่อนไขของข้อมูลเพิ่มเติม [4]

ตัวอย่างนี้เก็บหนึ่งหน้าของ sandbox ซึ่งเหมาะกับการรอ quote แรก สำหรับเว็บที่ทยอยเพิ่มรายการ ต้องรอเงื่อนไขที่แสดงว่ารายการครบ เช่น loading indicator หายไป จำนวนรายการถึงค่าที่คาด หรือ response ของ API ที่เกี่ยวข้องเสร็จแล้ว ก่อนดึง snapshot

#10. ตรวจคุณภาพข้อมูล

ก่อนนำไปวิเคราะห์หรือทำ RAG ให้ตรวจอย่างน้อย:

สิ่งที่ตรวจ เกณฑ์ใน Workshop
ความครบถ้วน text และ author ต้องไม่ว่าง
รายการซ้ำ ตรวจคู่ text และ author
แหล่งที่มา มี source_url
เวลาเก็บข้อมูล มี scraped_at ในรูปแบบ ISO 8601
รูปแบบข้อมูล tags เป็น list ใน JSON
จำนวนรายการ เปรียบเทียบข้อมูลที่เก็บกับหน้าเว็บจริง

เปิด JSON เพื่อตรวจเบื้องต้น:

python -m json.tool output/quotes.json

หากใช้กับ RAG ควรทำขั้นตอนเพิ่มเติม ได้แก่ เลือกเนื้อหาหลัก ตัดเมนูและส่วนท้าย แบ่งข้อความเป็น chunk เก็บ metadata และตรวจสิทธิการนำข้อมูลไปใช้ก่อนสร้าง embedding

#11. ปัญหาที่พบบ่อย

อาการ สาเหตุที่เป็นไปได้ วิธีตรวจและแก้
ไม่พบข้อมูล selector เปลี่ยนหรือข้อมูลเกิดจาก JavaScript ดู response HTML เทียบกับ DOM
HTTP 403 เซิร์ฟเวอร์ปฏิเสธคำขอ ตรวจสิทธิและช่องทางที่เว็บไซต์อนุญาต
HTTP 429 ส่งคำขอถี่เกินไป หยุดหรือชะลอ และเคารพ Retry-After หากมี
Timeout เครือข่ายหรือเว็บตอบช้า ตรวจ URL และกำหนด timeout ให้เหมาะสม
ภาษาไทยเพี้ยน encoding ไม่ตรง ตรวจ charset และอ่าน bytes ด้วย parser
ข้อมูลซ้ำ เก็บหน้าเดิมหรือรายการซ้ำ ตรวจ visited และออกแบบ key
Playwright เก็บข้อมูลไม่ครบ ดึงก่อนข้อมูลโหลดเสร็จ รอเงื่อนไขเฉพาะของข้อมูล
CSV เปิดแล้วตัวอักษรผิด โปรแกรมอ่าน encoding ไม่ตรง ใช้ UTF-8 และเลือก encoding ตอน import

ในงานจริงควรเพิ่ม logging, checkpoint และ retry แบบจำกัดจำนวนสำหรับความผิดพลาดชั่วคราว โดยไม่ retry การปฏิเสธสิทธิซ้ำอย่างต่อเนื่อง

#12. การใช้งานอย่างรับผิดชอบ

ใช้ sandbox สำหรับฝึก และตรวจเงื่อนไขของเว็บไซต์ก่อนเปลี่ยนเป็นแหล่งข้อมูลจริง อ่าน robots.txt และปฏิบัติตามข้อกำหนดการเข้าถึง โดย robots.txt เป็นคำแนะนำสำหรับ crawler ไม่ใช่หลักฐานการอนุญาตให้นำข้อมูลไปใช้

จำกัดความถี่และจำนวนหน้า เก็บเฉพาะข้อมูลที่จำเป็น เคารพสิทธิในเนื้อหาและความเป็นส่วนตัว และใช้ช่องทาง API ที่เว็บไซต์จัดเตรียมเมื่อเหมาะสม

เมื่อพบหน้า login, CAPTCHA หรือการปฏิเสธการเข้าถึง ให้ใช้ข้อมูลและช่องทางที่ได้รับอนุญาต ตัวอย่างนี้ไม่มีขั้นตอนหลบเลี่ยงมาตรการเหล่านั้น

#13. แบบฝึกหัดสำหรับการสอน

  1. เปลี่ยนจำนวนหน้าจาก 3 เป็น 2 และเปรียบเทียบจำนวนรายการ
  2. เพิ่มฟิลด์จำนวนแท็กต่อรายการ
  3. กรองเฉพาะรายการที่มีแท็กตามคำที่กำหนด
  4. เก็บข้อมูลลง SQLite โดยออกแบบ unique key
  5. เปรียบเทียบ HTML ที่ Requests ได้จากหน้า /js/ กับ DOM ที่ Playwright อ่าน
  6. ทดลองแก้ selector ให้ผิด แล้วอธิบายข้อความผิดพลาด
  7. เพิ่มการบันทึก log เมื่อพบฟิลด์ที่ขาดหาย

#แนวทางประเมิน

หัวข้อ คะแนน
ดึงข้อมูลและจับคู่ฟิลด์ถูกต้อง 30
เก็บหลายหน้าและจำกัดขอบเขต 20
ส่งออก CSV/JSON ถูกต้อง 20
ตรวจข้อมูลและจัดการข้อผิดพลาด 20
อธิบายการเลือกเครื่องมือและการเข้าถึงอย่างเหมาะสม 10
รวม 100

#14. แนวทางต่อยอด

สำหรับแล็บแรก เริ่มจาก Requests + Beautiful Soup และข้อมูลหนึ่งหน้า จากนั้นเพิ่ม pagination และการตรวจข้อมูล ก่อนเปรียบเทียบกับ Playwright

หากต้องรวบรวมหลายเว็บไซต์หรือมีงานต่อเนื่อง ค่อยพิจารณาโครงสร้าง crawler เช่น Scrapy, ฐานข้อมูล, scheduler และการติดตามความผิดพลาดของแต่ละรอบ [6]

สถานะตัวอย่าง: ตรวจไวยากรณ์ Python ของโค้ดในบทความแล้ว แต่ยังไม่ได้รันดึงข้อมูลจริงจากเว็บไซต์ในสภาพแวดล้อมนี้ จำนวนรายการและ selector ควรตรวจอีกครั้งเมื่อทำ workshop

#เอกสารอ้างอิง

  1. Requests — Quickstart
  2. Beautiful Soup — Official documentation
  3. Playwright Python — Getting started
  4. Playwright Python — Locator API
  5. To Scrape — Scraping Sandbox
  6. Scrapy — Official tutorial