สถาบันข้อมูลขนาดใหญ่ (องค์การมหาชน)

Logo BDI For web

ดึงข้อมูลจากเว็บไซต์ (Data Scraping) ด้วย Python Part 2 : Scrape HTML โดยใช้ Python

Apr 29, 2020

บทความนี้เป็นส่วนหนึ่งของซีรีส์ “ดึงข้อมูลจากเว็บไซต์ (Data Scraping) ด้วย Python” โดยผู้อ่านสามารถเข้าถึงภาคอื่น ๆ ของซีรีส์นี้ได้ ดังนี้ :

จากในภาค 1 ของบทความเรื่อง Data Scraping นี้ เราได้เห็นภาพรวมแล้วว่าจะต้องทำอะไรบ้างแบบคร่าว ๆ ในตอนนี้เราจะเริ่มลงมือเอา Python มา scrape HTML จากหน้าเว็บเพื่อรวบรวมข้อมูลสถานพยาบาลต่าง ๆ ทั่วประเทศ จาก เว็บไซต์ ของกองยุทธศาสตร์และแผนงานกันครับ ขั้นตอนแรกสุดคือการดึงเอา HTML code ของหน้าเว็บนั้น ๆ มาเข้าในตัวแปรใน Python script เพื่อที่เราจะได้สกัดเอาข้อมูลออกมาจาก HTML ได้ วิธีการดึงเอา HTML มานั้นเราจะใช้ Python library ที่ชื่อว่า requests โดยให้พิมพ์โค้ดเหล่านี้ลงไปครับ:

import requests

i_page = 0
url = f'http://203.157.10.8/hcode_2014/query_list.php?pageNum_rsList={i_page}'

source = requests.get(url)

ในโค้ดนี้บรรทัดสุดท้ายเป็นการทำ HTTP request ไปที่ URL ที่เรานิยามไว้ในตัวแปร string ที่ชื่อว่า url เราเขียน url ให้เป็น string ที่สามารถเปลี่ยนเลขหน้าได้ คือ เปลี่ยนไปตามตัวแปรที่ชื่อว่า i_page ครับ เราทำอย่างนี้เอาไว้เพื่อที่ว่าต่อไปเราจะเอาโค้ดนี้ไปวางไว้ใน for loop ให้ scrape ทุกหน้าเว็บได้เลย ในโค้ดข้างบนนี้ที่เรา set ให้ i_page = 0 นั้นคือ URL จะเป็นของเว็บตารางหน้าแรกสุด เราสามารถที่จะดู content ในตัวแปร source ที่เราได้ดึงมาได้โดยการ print(source.content) ซึ่งก็จะพบว่า source.content นั้นมีหน้าตาเหมือนกับ HTML ที่เรา inspect กันใน developer tools ดังในบทความ Part 1 เลยครับ


Static vs Dynamic vs Hidden Websites

เนื่องจากเว็บไซต์ ที่เรากำลัง scrape กันนี้เป็น Static Website (คือเนื้อหาทั้งหมดที่แสดงบน webpage นั้นอยู่ใน HTML code แล้ว) ข้อมูลทั้งหมดที่เราอยากได้จึงอยู่ในตัวแปร source หมดแล้ว แต่ในบางเว็บไซต์ที่ไม่ใช่ static นั้นอาจจะมีเนื้อหาหรือข้อมูลบางส่วนที่ไม่ได้อยู่ใน HTML code เช่น เนื้อหาอาจจะมาในรูปแบบของ JavaScript แทน (ใน Dynamic Website) หรือบางเว็บไซต์ ก็อาจจะต้องมีการให้บอทเรานั้น login เข้าไปก่อน เพื่อที่จะเห็นเนื้อหาทั้งหมด (Hidden Website) เป็นต้น ในกรณีเหล่านี้จะต้องใช้เทคนิคพิเศษอื่น ๆ เพิ่มเติม และใช้ Python libraries อื่น ๆ ที่นอกเหนือไปจาก requests ซึ่งเราจะยังไม่กล่าวถึงในบทความนี้ครับ


Parse ข้อมูลจาก HTML code โดยใช้ BeautifulSoup

ในขั้นตอนที่แล้วนั้นเราได้ scrape เนื้อหาทั้งหมดมาจากหน้าแรกสุดแล้ว แต่ว่าเนื้อหาที่ได้มานั้นมันมีมากเกินกว่าที่เราอยากได้และยังค่อนข้างยุ่งเหยิง สิ่งที่เราจะต้องทำกันต่อไปคือเอาเนื้อหาในตัวแปร source นี้มาตัดเลือก (parse) เอาเฉพาะตัวเลข รหัส 9 หลัก (code) กับรหัส 5 หลัก (oldcode) ในตารางครับเราเรียกใช้  BeautifulSoup ได้โดยพิมพ์บรรทัดที่ 2 และ 8 ข้างล่างนี้เพิ่มลงไปใน Python script ครับ

import requests
from bs4 import BeautifulSoup

i_page = 0
url = f'http://203.157.10.8/hcode_2014/query_list.php?pageNum_rsList={i_page}'

source = requests.get(url)
soup = BeautifulSoup(source.content, 'lxml', from_encoding='utf-8')

ในโค้ดบรรทัดสุดท้ายนี้เราได้สร้าง BeautifulSoup object ซึ่งรับเอา input มาจากเนื้อหาของ source แล้วเอา BeautifulSoup object นี้ไปใส่ไว้ในตัวแปรที่ชื่อว่า soup จะสังเกตเห็นว่าเราตั้ง encoding เป็น 'utf-8' เพราะว่าเนื้อหาใน website นี้เป็น Unicode ครับ ผู้อ่านที่สนใจสามารถศึกษาเพิ่มเติมเกี่ยว Unicode และ UTF-8 ซึ่งเป็นมาตรฐานการจัดระเบียบ เข้ารหัส และแสดงผลข้อมูลข้อความในภาษาต่าง ๆ ทั่วโลกได้ที่บทความนี้ครับ


หา Elements โดยใช้ ID

ในรูปที่ 5 ข้างล่างนี้ จะสังเกตเห็นใน Developer tools ว่า รหัส 9 หลัก (code) กับรหัส 5 หลัก (oldcode) แต่ละตัวถูกวางอยู่ใน <td> tag  ซึ่งอยู่ใน <tr> tag (แต่ละ <tr> tag คือ 1 Table Row นั่นเอง) และแต่ละ <tr> tag ก็อยู่ใน <tbody> tag  ครอบด้วย <table> tag  อีกทีหนึ่ง ซึ่งตารางนี้ <table> tag  อยู่ใน <div> tag  ที่มี id='content' ดังในรูปที่ 5 ที่ลูกศรชี้อยู่

รูปที่ 5 ลูกศรชี้บอกตำแหน่งที่ <div> tag  เปิดครอบ <table> tag  ซึ่งเป็นตารางที่มีตัวเลขรหัส 9 หลัก (code) กับรหัส 5 หลัก อยู่

ดังนั้น ในขั้นแรกของการ parse จาก HTML content ทั้งหมดนั้น เราจะเลือกเจาะเข้าไปดูเฉพาะใน Element ที่ <div> tag  นี้ครอบอยู่:

<div id=”content”>
<!-- all the data we want are in this tag -->
</div>

เราใช้ BeautifulSoup สกัดเอาเฉพาะ Element นี้ออกมาจาก content ทั้งหมดได้ด้วยการบอกชื่อ tag ('div') และ ID (id='content') ให้แก่ BeautifulSoup method .find() คือให้พิมพ์บรรทัดข้างล่างนี้ลงไปใน Python script:

div = soup.find('div', id='content')

โค้ดนี้เป็นการตักเอามาเฉพาะซุปในส่วนที่เป็น Element ที่ <div> tag  ซึ่งมี id='content' ครอบอยู่ เอามาเก็บไว้ในตัวแปรที่ชื่อว่า div เราสามารถดูว่า HTML ใน div นี้มีอะไรบ้างได้โดยการ print มันออกมา ซึ่งเราจะใช้ .prettify() มาช่วยทำให้มัน print ออกมาเป็นระเบียบ ดูง่ายขึ้น (.prettify() นี้สามารถนำไปใช้ print ได้ทุก BeautifulSoup objects) โดยพิมพ์บรรทัดข้างล่างนี้ลงไปใน Python script:

print(div.prettify())

หา Elements โดยใช้ Class Name

มาถึงตรงนี้ เราได้ตัดซุปของเรา ทำให้ซุปมีขนาดเล็กลงแล้ว แต่มันก็ยังไม่เล็กถึงขั้นที่เราจะพอใจได้ เราจะเห็นในรูปที่ 5 ว่าข้อมูลที่เราสนใจทั้งหมดนั้นอยู่ในตาราง <table> tag  ที่มี class='tdlg' ดังนั้นเราจะตักเอาเฉพาะส่วนตารางนี้มา โดยพิมพ์บรรทัดข้างล่างนี้ลงไปใน Python script:

table = div.find('table', class_='tdlg')

สังเกตว่าในโค้ดนี้ คำว่า class_ นั้นมี underscore ตามท้ายด้วย นั่นก็เพราะว่ามันจะได้ไม่ซ้ำกันกับ Python keyword class ครับ ถ้าเราจะ print ดู HTML ใน table นี้ก็ทำได้เหมือนกับตอนที่ print div ครับ:

print(table.prettify())

ในกรณีที่ใน <div> tag นี้มี elements ที่เป็น <table> tag  ที่มี class ซ้ำกันหลายตาราง ตัวโค้ดข้างบนนี้จะเลือกเอามาเฉพาะตารางแรกสุด (บนสุด) แต่ถ้าอยากตักเอามาหลายตารางก็ให้ใช้โค้ดข้างล่างนี้แทนครับ:

tables = div.find_all('table', class_='tdlg')

โดยคราวนี้ tables จะเป็นตัวแปรประเภท list of BeautifulSoup objects ครับ คือเป็น list ที่มี elements เป็น BeautifulSoup objects แต่ละ object เป็น table หนึ่งตารางครับ แต่เนื่องจากในกรณีของเรานี้มีแค่ตารางเดียว ดังนั้นจึงใช้แค่ .find() ก็เพียงพอครับ


หาหลาย Elements โดยใช้ find_all()

หลังจากที่เราตักเอาซุปเฉพาะส่วนที่เป็นตารางมาแล้ว ในตารางก็จะมี rows หลาย ๆ rows ซึ่งแต่ละ row จะถูกครอบด้วย  <tr> tag และข้างในแต่ละ row นั้นก็จะมีตัวเลขข้อมูลที่เราอยากได้ซึ่งถูกครอบด้วย <td> tag อยู่ ดังนั้นในขั้นตอนต่อไปนี้คือการไล่ดูทีละ row เพื่อดูว่าใน row นั้นมีข้อมูลอะไรอยู่บ้าง โดยเราจะใช้ for loops สองอันซ้อนกันครับ : loop อันนอกคือการวนไล่ดูแต่ละ row, ส่วน loop อันในนั้นจะวนไล่ดูแต่ละ <td> tag ใน row นั้น ๆ เพื่อสกัดเอาตัวเลขออกมาครับ

แต่ก่อนที่จะเข้า for loops นั้นเราจะต้องได้ list ของ rows ทั้งหมดมาก่อน เพื่อที่จะได้วนลูปได้ โดยให้เราพิมพ์บรรทัดข้างล่างนี้ลงไปใน Python script ครับ:

rows = table.find_all('tr')

โดย rows จะเป็นตัวแปรประเภท list of BeautifulSoup objects ครับ คือเป็น list ที่มี elements เป็น BeautifulSoup objects แต่ละ object คือหนึ่ง BeautifulSoup object ของหนึ่ง row ครับ

เมื่อได้ rows มาแล้วก็ทำการวนลูปกันเลยครับ ตามโค้ดข้างล่างนี้:

ls = [] # list to store text of all elements in all the rows.
for row in rows:
    # Find all elements in this row.
    # Each element is enclosed by <td> tag.
    elements = row.find_all('td')
    
    # list to store text of all the elements in this row:
    ls_elements_in_row = []
    
    for element in elements:
        text = element.text
        ls_elements_in_row += [text]
    
    ls += [ls_elements_in_row]

โค้ดข้างบนนี้ บรรทัดบนสุดคือเราสร้าง list เปล่าๆ (empty list) ที่ชื่อว่า ls ขึ้นมาเพื่อจะเอาไว้เก็บข้อมูล text ทั้งหมดจากทุก elements จากทุก rows ส่วน for loop อันนอก (ตั้งแต่บรรทัดที่ 2 ลงมา) นั้นคือการไล่ดูทีละ row ในแต่ละ row นั้นจะมีหลาย elements แต่ละ element นั้นมี <td> tag ครอบอยู่ เราตักเอา elements เหล่านี้ทั้งหมดใน row ได้โดยใช้โค้ดในบรรทัดที่ 5 คือ elements = row.find_all('td') ซึ่งจะได้เป็น list ของ elements ทั้งหมดที่มี <td> tag ครอบอยู่ครับ

พอเราได้ list ของ elements ทั้งหมดใน row นี้มาแล้ว เราก็จะทำการไล่ดูทีละ element ในตัวแปร list ที่ชื่อว่า elements เพื่อที่จะสกัดเอาข้อมูลที่เราอยากได้จาก text ของแต่ละ element ครับ ก่อนที่จะเข้า for loop อันที่สอง (ตั้งแต่บรรทัดที่ 10 ลงมา) นั้นเราจะสร้าง list เปล่าๆ (empty list) ที่ชื่อว่า ls_elements_in_row (บรรทัดที่ 8) ขึ้นมาก่อนเพื่อเอาไว้เก็บข้อมูล text ทั้งหมดจากทุก elements ใน row นี้ ในแต่ละรอบของ for loop ที่สองนี้คือดูทีละ element จากใน elements เราสกัดเอา text ของแต่ละ element ได้โดยใช้โค้ดตามบรรทัดที่ 11 คือ text = element.text โค้ดนี้ฝั่งขวามือคือการสกัดเอา text ออกมาจาก element แล้วเอาไปเก็บไว้ในตัวแปรใหม่ที่ชื่อว่า text

พอได้ text มาแล้วก็เอาไปเก็บไว้ในตัวแปร list ที่ชื่อว่า ls_elements_in_row โดยการ append เข้าไปตามในบรรทัดที่ 12 ครับ ls_elements_in_row += [text] ซึ่งพอเราวน for loop อันในนี้ครบทุกรอบแล้วก็จะได้ข้อมูลทั้งหมดใน row นี้มาเก็บไว้ที่ ls_elements_in_row สุดท้ายเราก็จะเอา ls_elements_in_row ไป append เข้ากับ ls (ตามโค้ดในบรรทัดที่ 14) คือจะได้ ls เป็น list of lists ครับ

ทีนี้ถ้าลอง print(ls) ออกมาดูก็จะพบว่าข้อมูลทั้งหมดในตารางนั้นได้ถูก scrape และเอาไป parse และจัดรูปวางไว้ใน ls นี้อย่างเป็นระเบียบเรียบร้อยครับ แต่จะมีที่ขาดตกไปคือหัวตาราง (header) ครับเพราะว่า ถ้าลองย้อนกลับไปใช้ Developer tools มา inspect ที่ row หัวตาราง (header row) จะพบว่าแต่ละ element นั้นไม่ได้ถูกครอบด้วย <td> tag แต่ถูกครอบด้วย <th> tag แทนครับ ตรงนี้เองที่ทำให้สมาชิกตัวแรกสุดของ ls เป็น empty list ครับ (คือ ls[0] เป็น empty list) วิธีการที่จะได้ header row มา คือเปลี่ยนโค้ดบรรทัดที่ 2-5 ข้างบน เป็นโค้ดบรรทัดที่ 2-8 ข้างล่างนี้แทน แล้วรัน script ใหม่ครับ:

ls = [] # list to store text of all elements in all the rows.
for i, row in enumerate(rows):
    # Find all elements in this row.
    # Each element is enclosed by either <th> or <td> tag.
    if i==0:
        elements = row.find_all('th')
    else:
        elements = row.find_all('td')
    
    # list to store text of all the elements in this row:
    ls_elements_in_row = []
    
    for element in elements:
        text = element.text
        ls_elements_in_row += [text]
    
    ls += [ls_elements_in_row]

ซึ่งบรรทัดที่ 5 เป็นการบอกให้ bot รู้ว่าถ้าเป็น row แรกสุด (if i==0:) ให้หา elements โดยดูที่ <th> tag แทน ตามในโค้ดบรรทัดที่ 6 คือให้ใช้  elements = row.find_all('th') แทนครับ (rows[0] คือ header row) ส่วน rows ที่เหลือทั้งหมดนั้นให้ทำแบบเดิม ดังในบรรทัดที่ 7-8

พอเราได้ข้อมูลครบทุกอย่างจากตารางใน webpage นี้แล้ว เราจะแปลงให้เป็น pandas DataFrame เพื่อที่ต่อไปจะได้เอาไปรวม (concatenate) กันกับตารางอื่นจากหน้าเว็บหน้าอื่น ๆ ทั้งหมดได้สะดวกครับ เอาโค้ดข้างล่างนี้ไปใส่เติมเพิ่มไว้ใน Python script ได้เลย:

import pandas as pd

df = pd.DataFrame(ls[1::])
df.columns = ls[0]

ในบรรทัดที่ 3 นี้ ls[1::] จะเอาทุก rows ใน ls มาเป็นสมาชิกในตาราง pandas DataFrame ยกเว้น row แรกสุด (ls[0]) ซึ่งเป็น header (หัวตาราง) ซึ่งเราเอาไปตั้งเป็น header ของตาราง pandas DataFrame แทนครับ (ดังในบรรทัดที่ 4)


ใช้ for loop เพื่อ scrape ตารางจากทุกหน้าเว็บ

ในส่วนที่แล้วนั้นเราได้ Python script สำหรับการ scrape ตารางจากหนึ่งหน้าเว็บ เราสามารถนำเอาโค้ดทั้งหมดนี้มารวบเป็นหนึ่ง function เพื่อที่จะได้เอาไปใช้ scrape หน้าเว็บอื่นที่เหลืออีกได้สะดวกยิ่งขึ้น ให้เราสร้าง Python script อันใหม่แบบว่างๆเปล่าๆขึ้นมา แล้วเอาโค้ดทั้งหมดที่ได้ก่อนหน้านี้มาจัดเรียงไว้ในหนึ่ง function รวบกันแล้วจะได้โค้ดหน้าตาแบบนี้ครับ :

import requests
from bs4 import BeautifulSoup
import pandas as pd

def get_table_content(i_page):
    url = f'http://203.157.10.8/hcode_2014/query_list.php?pageNum_rsList={i_page}'
    source = requests.get(url)
    
    soup = BeautifulSoup(source.content, 'lxml', from_encoding='utf-8')
    div = soup.find('div', id='content')
    table = div.find('table', class_='tdlg')
    rows = table.find_all('tr')
    
    ls = [] # list to store text of all elements in all the rows.
    for i, row in enumerate(rows):
        # Each element is enclosed by either <th> or <td> tag.
        if i==0:
            elements = row.find_all('th')
        else:
            elements = row.find_all('td')
        
        # list to store text of all the elements in this row:
        ls_elements_in_row = []
        for element in elements:
            text = element.text
            ls_elements_in_row += [text]
        
        ls += [ls_elements_in_row]
    
    df = pd.DataFrame(ls[1::])
    df.columns = ls[0]
    
    return df

ตัว function get_table_content() นี้ ขอแค่เราบอกมันว่าอยากได้ข้อมูลจากหน้าเว็บไหน มันก็จะ scrape และ parse ข้อมูล แล้ว return ออกมาให้เราเป็นตาราง pandas DataFrame อย่างเป็นระเบียบครับ รายละเอียดของแต่ละบรรทัดใน function นี้สามารถย้อนขึ้นไปอ่านได้ด้านบนครับ เราใช้ for loop เพื่อเรียก function นี้ให้สกัดเอาข้อมูลทั้งหมด 2,736 หน้าเว็บมาให้เราทีเดียวได้ตามโค้ดข้างล่างนี้ครับ :

df_all = pd.DataFrame()
for i_page in range(0, 2736):
    try:
        df = get_table_content(i_page)
        df_all = pd.concat([df_all, df], axis=0, sort=False)
        print(f'Done getting data from page {i_page}')
    except:
        print(f'Cannot get data from page {i_page}')

df_all.to_excel('table_id.xlsx', index=False)

เราใช้ try except (บรรทัดที่ 3 และ 7) เพื่อที่ว่าตอนรันจะได้ไม่สะดุดครับ ถ้า webpage ไหนมี error ก็ให้ข้ามไปก่อน แต่ให้ print (บรรทัดที่ 8) แจ้งเตือนให้เราทราบครับ สำหรับเว็บไซต์นี้ผมลองรันโค้ดแล้วรันผ่านทุกหน้า ไม่มีหน้าไหน error ครับ เลยไม่มีปัญหาอะไรแต่ถ้ากรณีที่มี error นั้นอาจจะเป็นได้จากหลายสาเหตุ โดยส่วนใหญ่มักจะเป็นเพราะหน้านั้นมีการจัดวางโครงสร้างตารางแตกต่างไปจากหน้าอื่น ๆ ครับ

ข้อมูลที่ดึงมาทั้งหมดจะถูกบันทึกอยู่ใน Excel ถ้าลองเปิดดูก็จะพบกับตารางหน้าตาแบบนี้เลยครับ

รูปที่ 6 ตารางข้อมูลที่เรา scrape มาจากหน้าเว็บที่เป็นตารางแสดง code รหัส 9 หลัก, oldcode รหัส 5 หลัก รวมถึงรายละเอียดที่อยู่และประเภทสถานพยาบาล

ในที่สุดเราก็ได้มาแล้วสำหรับตารางที่มีข้อมูลสถานพยาบาลกว่า 27,000 แห่งพร้อมข้อมูลเบื้องต้นครับ เราได้เห็นวิธีการ scrape ข้อมูลจากเว็บไซต์แบบพื้นฐานแล้ว แต่ถ้าย้อนกลับไปดูที่เว็บไซต์ก็จะพบว่ายังมีข้อมูลรายละเอียด เช่น จำนวนเตียงผู้ป่วย พิกัดทางภูมิศาสตร์ เป็นต้น ที่ในตารางนี้ยังไม่มี สาเหตุก็เพราะว่าเรายังไม่ได้เจาะเข้าไป scrape ข้อมูลจากในหน้าเว็บเหล่านี้ครับ ซึ่งจะไปทำต่อใน Part 3 กับการ scrape แบบลึกยิ่งขึ้นอีกครับ

สุดท้ายนี้ เราขอขอบคุณ สำนักงานปลัดกระทรวงสาธารณสุข สำหรับข้อมูลที่เผยแพร่สาธารณะนี้ เพื่อให้เราได้นำมาเขียนบทความที่เป็นประโยชน์ด้านการศึกษา Data Science ครับ

Isarapong Eksinchol, PhD

Senior Project Manager & Data Scientist at Big Data Institute (Public Organization), BDI

© Big Data Institute | Privacy Notice