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

จากในภาค 1 และ 2 ของบทความเรื่อง Data Scraping นี้ เราได้เห็นภาพรวมแล้วว่าจะต้องทำอะไรบ้างในเบื้องต้นของการทำ Data Scraping ตั้งแต่การศึกษา page source ของ HTML page ที่เราสนใจ ไปถึงจนการเขียนโค้ด Python เพื่อจับข้อมูลที่เก็บอยู่ในโครงสร้าง HTML tags แบบมีลำดับชั้นต่าง ๆ และนำข้อมูลที่ได้มาจัดเป็น pandas DataFrame

เราได้ตารางข้อมูลสถานพยาบาลมาแล้ว จาก เว็บไซต์ ของกองยุทธศาสตร์และแผนงาน สำนักงานปลัดกระทรวงสาธารณสุข แต่ยังขาดรายละเอียดอีกมาก เช่น ข้อมูลจำนวนเตียง พิกัดทางภูมิศาสตร์ ถ้ายังจำกันได้จากตอนที่แล้ว เราสามารถเข้าถึงหน้าเว็บที่มีรายละเอียดของแต่ละสถานพยาบาลนี้ได้ทาง URL โดยใช้ code (รหัส 9 หลัก) กับ oldcode (รหัส 5 หลัก) บทความที่แล้ว ใน Part 2 นั้นเราได้ทำการ scrape สกัดเอา code (รหัส 9 หลัก) กับ oldcode (รหัส 5 หลัก) ของสถานพยาบาลทั้งหมดมาหมดแล้ว (รูปที่ 6 ใน Part 2) ตอนนี้เราจึงสามารถสั่ง bot ให้เข้าไปที่หน้าเว็บเหล่านี้ทั้งหมดได้โดยตรงทาง URL เลยครับ


ลอง scrape and parse สักหนึ่งหน้าเว็บ

เราจะเริ่มต้นด้วยการทดลองเขียน Python script เพื่อ scrape and parse สักหนึ่งสถานพยาบาลก่อน โดยลองทำของโรงพยาบาลเกาะพีพี ดังที่แสดงในรูปที่ 2 ข้างล่างนี้ พอทำได้สำเร็จแล้วเราสามารถนำโค้ดที่ได้นี้ไปใส่ใน for loop เพื่อทำ data scraping and parsing ทุกสถานพยาบาลได้ในคราวเดียวเลยครับ


รูปที่ 2 หน้าข้อมูลโรงพยาบาลเกาะพีพี

เราจะดึงเอา HTML มาทั้งหมดจากหน้าเว็บของโรงพยาบาลเกาะพีพี โดยให้พิมพ์โค้ดเหล่านี้ลงใน Python script อันใหม่ครับ:

import requests

code  = '007775300'
oldcode = '77753'

url = f'http://203.157.10.8/hcode_2014/query_detail.php?p=3&code={code}&oldcode={oldcode}&status=01'

source = requests.get(url)

เราเขียน url ให้เป็น string ที่สามารถเปลี่ยน code และ oldcode ได้ เพื่อที่ในอนาคตเราจะได้เอาโค้ดนี้ไป scrape สถานพยาบาลอื่น ๆ ต่อได้ครับ สำหรับ  code = '007775300' และ oldcode = '77753' ที่เราใช้อยู่นี้เป็นของโรงพยาบาลเกาะพีพี ซึ่งสามารถหาดูได้จากตารางที่เรา scrape มาในบทความก่อนหน้านี้ Part 2 ครับ ได้ HTML มาทั้งหมดแล้วทีนี้เราก็จะมา parse เลือกตักเอาเฉพาะส่วนที่เป็นข้อมูลที่เราอยากได้ โดยใช้ BeautifulSoup เช่นเคยครับ ให้เพิ่มบรรทัดที่ 2 และ 11 ข้างล่างนี้ลงใน script:

import requests
from bs4 import BeautifulSoup

code  = '007775300'
oldcode = '77753'

url = f'http://203.157.10.8/hcode_2014/query_detail.php?p=3&code={code}&oldcode={oldcode}&status=01'

source = requests.get(url)

soup = BeautifulSoup(source.content, 'lxml')

สำรวจโครงสร้าง HTML ของหน้า webpage

ขั้นตอนจะคล้ายๆกับตอนที่เรา parse ตารางในบทความที่แล้วครับ คือแรกสุดเราจะดูโครงสร้าง HTML โดยใช้ Developer tools (เปิดโดยคลิกขวา แล้วเลือก inspect) จะเห็น HTML อยู่ด้านขวามือในรูปที่ 7 ข้างล่างนี้ครับ:


รูปที่ 7 หน้าเว็บ ข้อมูลโรงพยาบาลเกาะพีพี เหมือนรูปที่ 2 แต่เปิด Developer tools ด้วย

ลองเล่นกับ Developer tools ไปสักพัก เราก็จะพบว่าข้อมูลทั้งหมดที่เราสนใจนั้นอยู่ใน <div> tag  ที่มี id='content' ดังในรูปที่ 8 ที่ลูกศรอันบนสุดชี้อยู่:


รูปที่ 8 เหมือนรูปที่ 7 แต่มีลูกศรชี้บอกตำแหน่งใน HTML ที่ <div> tag, <table> tag และ <tr> tags ครอบข้อมูลที่เราสนใจวางอยู่

พอเปิด <div> tag นี้เข้าไปอีก (ใน Developer tools) ก็จะพบว่ามี <table> tag  ครอบอีกทีหนึ่ง และในนั้นก็จะเป็น <tr> tag หลาย ๆ อัน โดยแต่ละอันคือหนึ่งบรรทัดบนหน้าเว็บครับ เราจะเปิด tags เหล่านี้ โดยใช้โค้ดข้างล่าง (คล้าย ๆ กับตอนที่เรา parse ข้อมูลจากตาราง ในบทความที่แล้ว Part 2) เลยครับ :


ใช้ BeautifulSoup ตักเอา element ออกมา

div   = soup.find('div', id='content')
table = div.find ('table')
rows  = table.findChildren('tr', recursive=False)

ตามโค้ดบรรทัดที่ 3 นี้ rows เป็น list ที่มี BeautifulSoup elements อยู่ข้างใน โดยที่แต่ละ element ใน rows คือหนึ่งบรรทัดที่แสดงบนหน้าเว็บ จะสังเกตว่าเราใช้โค้ด rows = table.findChildren('tr', recursive=False) ซึ่งต่างจากเมื่อตอนที่แล้ว (Part 2) ที่เราใช้โค้ด rows = table.find_all('tr') ครับ เหตุผลก็เพราะว่าในครั้งนี้ถ้าเราลองดูใน Developer tools จะพบว่านอกจากจะมี <tr> tag ตัวหลักๆ ที่ครอบแต่ละบรรทัดแล้ว ข้างในบรรทัดยังมี <tr> tag ย่อยๆ ครอบบาง elements ด้วยครับ แต่เนื่องจากเราอยากสกัดเอาเฉพาะ <tr> tag ที่ครอบแต่ละบรรทัดเท่านั้น เราจึงใช้โค้ดดังกล่าว และตั้งให้ recursive=False ครับ คือไม่เจาะเข้าไปตักเอา <tr> tag ย่อยๆ ข้างในบรรทัดครับ

ในแต่ละบรรทัดที่แสดงบนหน้าเว็บ (คือ แต่ละ element ใน rows) เราจะเปิด tag เข้าไปเรื่อย ๆ จนกว่าจะเจอข้อมูลที่เป็น text ครับ โดยให้ดูใน Developer tools ก่อนว่าแต่ละบรรทัดนั้นมีโครงสร้างอย่างไรบ้าง จะเห็นจากบนหน้าเว็บได้ว่าในแต่ละบรรทัด ข้อมูลจะมากันเป็นคู่ครับ คือมี หัวข้อ (header) และก็ค่า (value) มาด้วยกัน โดยตัว header จะเป็นตัวหนา และค่า value จะเป็นตัวอักษรไม่หนา จะเห็นว่าในหลายๆ บรรทัดส่วนใหญ่แล้ว header จะถูกครอบโดย <th> tag และ value ถูกครอบโดย <td> tag ครับ ตามตัวอย่างในรูปที่ 9:


รูปที่ 9 หน้าเว็บเดียวกับในรูปที่ 7 แต่มีลูกศรชี้บอกตำแหน่งใน HTML ที่ <th> tag และ <td> tag ครอบ header และ value ของเบอร์โทรศัพท์อยู่

ถ้าทุกบรรทัดมีโครงสร้างแบบเดียวกันนี้หมด (ดังในรูปที่ 9) ก็จะง่ายกับเรามากเลยครับ เพราะเราจะสามารถสั่ง bot ได้เลยว่าให้หา header จาก <th> tag และหา value จาก <td> tag ทำแบบนี้เหมือนกันทุกบรรทัด เขียน for loop สั้นๆ ก็จบครับ แต่ในกรณีของเรานี้จะพบว่ามีบางบรรทัดที่ header ดันไม่ได้อยู่ใน <th> tag ดังตัวอย่างในรูปที่ 10 ที่ header อยู่ใน <td> tag แทน แต่มี <strong> tag ครอบอยู่เพื่อทำให้ตัวอักษรเป็นตัวหนาขึ้นมาครับ


รูปที่ 10 หน้าเว็บเดียวกับในรูปที่ 7 แต่มีลูกศรชี้บอกตำแหน่งใน HTML ที่ <td> tag และ <strong> tag ครอบ header “จำนวนเตียง (ตามกรอบ)” อยู่

แยก tag ประเภท header กับ value

พอโครงสร้างแต่ละบรรทัดมีความไม่เหมือนกันแบบนี้แล้ว เราเลยต้องมาวางกลยุทธ์กันใหม่ครับ เราจะเขียน functions ขึ้นมาเพื่อเอามาตรวจดูว่าแต่ละ element นั้นเป็น header หรือ value เขียนโค้ดตามข้างล่างนี้เลยครับ :

import bs4

def is_header(tag):
    # Determine if a tag is a header:  
    if type(tag) in [str, bs4.element.NavigableString]:
        return False
    return ((tag.name=="th") or (len(tag.find_all("strong"))==1)) \
            and (not tag.text.strip()=="")

def is_value(tag):
    # Determine if a tag is a value:  
    if type(tag) in [str, bs4.element.NavigableString]:
        return False
    return (not is_header(tag)) and (not tag.text.strip()=="")

ฟังก์ชัน is_header(tag) จะพิจารณาว่า tag นั้นเป็น header หรือไม่ โดยจะ return True ถ้าเป็น header และจะ return False ถ้าไม่ใช่ครับ โดย tag จะเป็น header เฉพาะในกรณีที่ tag นั้นเป็น <th> tag หรือ ถ้าเป็นประเภทอื่นก็ขอให้มี <strong> tag ครอบอยู่หนึ่งอัน (คือเป็นตัวอักษรหนา) และ text ข้างใน (คือ tag.text) จะต้องไม่ว่างเปล่าด้วย (ตามโค้ดในบรรทัดที่ 7-8) ครับ

ส่วนฟังก์ชัน is_value(tag) จะพิจารณาว่า tag นั้นเป็น value หรือไม่ โดยจะ return True ถ้าเป็น value และจะ return False ถ้าไม่ใช่ครับ โดย tag จะเป็น value เฉพาะในกรณีที่ tag นั้นไม่ได้เป็น header และ text ข้างใน (คือ tag.text) จะต้องไม่ว่างเปล่า (ตามโค้ดในบรรทัดที่ 14) ครับ


For loop เพื่อกวาด element ให้ครบ

นิยาม functions เหล่านี้เสร็จแล้วเราก็จะทำการ for loop ไล่ parse ทีละบรรทัดตามตัวอย่างโค้ดข้างล่างนี้เลยครับ เราเอาแค่ครึ่งแรกมาให้ดูก่อนครับ จะมีโค้ดเพิ่มเติมใน for loop นี้ที่เราจะทยอยนำมาอธิบายต่อไปครับ:

pairs = []
for row in rows:
    tags = row.contents
    
    # Classify tags into headers and values:
    ls_header_tags, ls_header_indices = [], []
    ls_value_tags,  ls_value_indices  = [], []
    
    headers_paired = []
    
    for i, tag in enumerate(tags):
        if is_header(tag):
            ls_header_tags += [tag]
            ls_header_indices+= [i]
            headers_paired += [False]
        elif is_value(tag):
            ls_value_tags  += [tag]
            ls_value_indices += [i]

บรรทัดบนสุดคือเราสร้าง empty list ที่ชื่อว่า pairs ขึ้นมาเพื่อจะเอาไว้เก็บข้อมูลทั้งหมดจากทุก elements จากทุก rows โดยข้อมูลจะเก็บเป็นคู่ ๆ (header คู่กับ value) ส่วน for row in rows: ในบรรทัดที่ 2 นั้นเป็นการไล่ดูทีละ row หรือทีละบรรทัดในหน้าเว็บ ในแต่ละ row จะมีหลาย elements ครับ ส่วนคำสั่ง tags = row.contents ในบรรทัดที่ 3 นี้เป็นการเอา contents ทั้งหมดใน row นี้มาใส่ในตัวแปรที่ชื่อว่า tags ซึ่งเป็น list of elements ครับ

ได้ elements ทั้งหมดใน row นี้มาอยู่ในตัวแปร tags แล้ว เราก็จะมาไล่เช็คดูใน tags ว่า element ไหนเป็น header และ element ไหนเป็น value เพื่อที่จะสกัดเป็นข้อมูลออกมาครับ แต่ก่อนที่เราจะทำ for loop เพื่อไล่ดูทีละ element เราจะสร้าง empty lists  ขึ้นมาก่อน (บรรทัดที่ 6-7) เพื่อเอามาไว้เก็บบันทึกค่าและตำแหน่งของ headers กับ values ครับ โดย ls_header_tags จะเป็น list ที่เอาไว้เก็บ element ที่เป็น header, ls_value_tags จะเป็น list ที่เอาไว้เก็บ element ที่เป็น value, ls_header_indices เป็น list ที่เอาไว้บันทึกว่าแต่ละ header นั้นอยู่ที่ตำแหน่งใด, และ ls_value_indices เป็น list ที่เอาไว้บันทึกว่าแต่ละ value นั้นอยู่ที่ตำแหน่งใดครับ เราต้องบันทึกตำแหน่งของแต่ละ header กับ value เพื่อที่ในภายหลังเราจะได้สามารถจับคู่ header กับ value ได้ถูกคู่ครับ

ส่วน headers_paired ในบรรทัดที่ 9 นั้นเป็น list ที่เอาไว้บันทึกว่าแต่ละ header ใน ls_header_tags นั้นมี value มาจับคู่ด้วยแล้วหรือยังครับ

บรรทัดถัดมา (บรรทัดที่ 11) for i, tag in enumerate(tags): เป็นการไล่ดูทีละ element ใน tags เพื่อจัดแบ่งว่าเป็น header หรือว่าเป็น list ครับ โดยถ้าเป็น header (if is_header(tag):) จะเข้าเกณฑ์ในบรรทัดที่ 12-15 ก็ให้บันทึก (append) element และตำแหน่งของมันไว้ใน ls_header_tags และ ls_header_indices ตามลำดับ และ เนื่องจากเรายังไม่ได้จับคู่ header นี้กับ value ใดๆ เราจึงจะบันทึกไว้ใน headers_paired ด้วยว่า False ครับ ส่วนในกรณีที่เป็น value (elif is_value(tag):) จะเข้าเกณฑ์ในบรรทัดที่ 16-18 ก็ให้บันทึก (append) element และตำแหน่งของมันไว้ใน ls_value_tags และ ls_value_indices ตามลำดับครับ ส่วนกรณีถ้า element นั้นไม่เข้าข่ายเป็น header หรือ value เลยก็ปล่อยผ่านข้ามไปครับ

เราได้ headers กับ values ทั้งหมดในบรรทัดนี้ (row นี้) มาแล้ว แต่ว่ายังไม่ได้จับคู่ว่า value ไหนเป็นของ header ใดครับ เราจึงจะเพิ่มโค้ดลงไปให้ bot จับคู่ให้ก่อนที่จะเอาไปบันทึกไว้ใน pairs โค้ดจะกลายเป็นแบบนี้ครับ :

from bisect import bisect

pairs = []
for row in rows:
    tags = row.contents
    
    # Classify tags into headers and values:
    ls_header_tags, ls_header_indices = [], []
    ls_value_tags,  ls_value_indices  = [], []

    headers_paired = []
    
    for i, tag in enumerate(tags):
        if is_header(tag):
            ls_header_tags += [tag]
            ls_header_indices+= [i]
            headers_paired += [False]
        elif is_value(tag):
            ls_value_tags  += [tag]
            ls_value_indices += [i]
    
    # Below is in addition to what we’ve shown earlier:

    for i in range(0, len(ls_value_tags)):
        #  Find location of the header using bisect library:
        loc_hd = bisect(ls_header_indices, ls_value_indices[i])
        
        if loc_hd==0: # when value comes before any header
            # use header of the previous pair instead
            header = pairs[-1][0]+"_2"
        else:
            #  Extract text from the header tag:
            header = ls_header_tags[loc_hd-1].text\
                        .replace("\xa0", "")\
                        .replace("\n", " ")\
                        .replace("\t", " ")\
                        .strip()
            headers_paired[loc_hd-1] = True
        
        #  Extract text from the value tag:  
        value = ls_value_tags[i].text\
                        .replace("\xa0", "")\
                        .replace("\n", " ")\
                        .replace("\t", " ")\
                        .strip()
        
        # Record the pair in pairs:
        pairs += [[header, value]]
    
    # Assign "" value to any unpaired header:
    for i in range(len(ls_header_tages)):
        if not headers_paired[i]:
            header = ls_header_tags[i].text\
                        .replace("\xa0", "")\
                        .replace("\n", " ")\
                        .replace("\t", " ")\
                        .strip()
            pairs += [[header, ""]]

For loop อันล่าสุดที่เพิ่มเข้ามานี้ (ในบรรทัดที่ 24) for i in range(0, len(ls_value_tags)): เป็นการไล่ดูทีละ value ใน ls_value_tags เพื่อหาคู่ header ของมันครับ โดยใน loop นี้จะเริ่มต้นด้วยการเอาตำแหน่งของ value นี้ (คือ ls_value_indices[i]) มาเทียบดูกับตำแหน่งของทุก headers (คือ ls_header_indices) ลองดูว่าถ้าเราเอาตัวเลข ls_value_indices[i] นี้ ไปใส่ลงใน ls_header_indices (ซึ่งเป็น sorted list) แล้ว ตัวเลขนี้จะไปตกอยู่ที่ตำแหน่งใด พอได้ตำแหน่งที่ ls_value_indices[i] ตกลงไปใน ls_header_indicesแล้วเราก็จะรู้ header ของ value นี้ครับ (มันคือ header ตัวที่อยู่ทางซ้ายมือของตำแหน่งที่ ls_header_indices ไปตกอยู่นั่นเองครับ

ตัวอย่างเพื่อให้เห็นภาพ เช่น ถ้า ls_header_indices = [0, 2, 4] และ ls_value_indices[i] = 3 แสดงว่า มี headers อยู่ 3 ตัว แต่ละตัวอยู่ที่ตำแหน่ง 0, 2 และ 4. พอเราไล่ดูทีละ value ไปเจอ value นึงที่มีตำแหน่งอยู่ที่ 3 ของบรรทัด. ถ้าเราจะเอาเลข 3 นี้ใส่ลงไปใน [0, 2, 4] ให้ยังคงเป็น sorted list อยู่เหมือนเดิม มันจะไปตกอยู่ที่ list index = 2 กลายเป็น [0, 2, 3, 4] ครับ อันนี้มันจะบอกเราว่า header ของ value นี้อยู่ทางซ้ายมือของ list index = 2 ซึ่งก็คือ list index = 2-1 = 1 นั่นเองครับ

เราหาตำแหน่งที่ ls_value_indices[i] ตกลงไปใน ls_header_indices ได้โดยใช้ bisect จาก bisect library ครับ เริ่มจาก import ตามโค้ดบรรทัดบนสุด from bisect import bisect แล้วเขียนโค้ดในบรรทัดที่ 26 ว่า loc_hd = bisect(ls_header_indices, ls_value_indices[i]) เราก็จะได้ตำแหน่งที่ ls_value_indices[i] ตกอยู่ อยู่ในตัวแปร loc_hd ครับ

บรรทัดถัดมา (บรรทัดที่ 28 และ 31) if else จะแบ่งออกเป็น 2 กรณีครับ กรณีแรก (บรรทัดที่ 28) if loc_hd==0:  แสดงว่า value นี้ไม่มี header ไหนอยู่ทางซ้ายมือเลย กรณีนี้เป็นไปได้ครับคือถ้าบางบรรทัดในหน้าเว็บนี้ไม่มี header ทางซ้ายสุดเลย ดังตัวอย่างในรูปที่ 11 ข้างล่างนี้เป็น webpage ของ โรงพยาบาลส่งเสริมสุขภาพตำบลนิบงบารู พบว่าบางบรรทัดไม่มี header เลย แต่มี value อยู่ครับ:


รูปที่ 11 webpage ของ โรงพยาบาลส่งเสริมสุขภาพตำบลนิบงบารู ลูกศรชี้บรรทัดที่ไม่มี header เลย แต่มี value อยู่ครับ

กรณีแบบนี้สามารถเดาได้ว่าถ้าบรรทัดไหนไม่มี header ให้ใช้ header ของบรรทัดก่อนหน้าแทนครับ ดังนั้นในโค้ดเรากรณีที่ if loc_hd==0: เราจึงให้ header เป็น header ของคู่ล่าสุดครับ แต่เติม string “_2” ต่อท้ายเข้าไปด้วยเพื่อที่จะได้ไม่ซ้ำกันครับ (ตามโค้ดในบรรทัดที่ 30 คือ header = pairs[-1][0]+"_2") เราทำแบบนี้ไปก่อน แล้วหลังจาก scrape ข้อมูลทั้งหมดได้มาเป็นตารางแล้ว เราสามารถ clean pattern เหล่านี้ในภายหลังได้ไม่ยากครับ

ถัดมาในบรรทัดที่ 31 else: คือกรณีที่มี header อยู่ทางซ้ายมือของ value เราก็จับ header นี้มาคู่กับ value ได้เลยครับ header นี้อยู่ทางซ้ายมือของ index loc_hd นั่นคืออยู่ที่ index loc_hd-1 จึงสามารถเข้าถึงได้ด้วยโค้ด ls_header_tags[loc_hd-1] (บรรทัดที่ 33) ซึ่งจะได้เป็น element ที่เป็น BeautifulSoup object แต่เนื่องจากเราอยากได้เป็น text จึงทำการ .text ต่อท้าย แล้วตัดพวก space, tab และ newline ออกโดยใช้ .replace() และ .strip() (บรรทัดที่ 34-37) เพื่อให้ดูกระชับขึ้นครับ เราทำแบบเดียวกันนี้กับ value เช่นกันครับ (บรรทัดที่ 41-45) เอามาเฉพาะ text และตัด space, tab และ newline ออก สุดท้ายนี้ (บรรทัดที่ 48) คือเอา header กับ value มาคู่กัน แล้วบันทึก (append) ลงใน pairs ครับ สังเกตด้วยครับว่าในบรรทัดที่ 38 เราตั้งให้ headers_paired[loc_hd-1] = True เพราะว่า header นี้หาคู่ value เจอแล้วครับ

มาถึงบรรทัดที่ 50 เราได้เอา value ทั้งหมดใน ls_value_tags ไปจับคู่กับ header แล้ว แต่ก็เป็นไปได้ที่บาง header ใน ls_header_tags จะยังไม่มี value ใดๆ มาจับคู่ ดังนั้น เราจะทำการ for loop (บรรทัดที่ 51) เพื่อไล่ดูใน headers_paired ว่ามี header ไหนบ้างที่ยังไม่มีคู่ (บรรทัดที่ 52) ซึ่งถ้า header ไหนยังไม่มีคู่ เราจะใส่ value เป็น "" ดังในบรรทัดที่ 58 ครับ


นำผลที่ BeautifulSoup ตักมาจัดลงตาราง

ทีนี้หลังจาก bot วน for loop for row in rows: ครบทุก rows แล้วก็จะได้ข้อมูลจากทุกบรรทัดบน webpage มาเก็บอยู่ใน list pairs เป็นคู่ [header, value] ถ้าลองให้ Jupyter Notebook แสดงผล pairs ดู (รูปที่ 12) จะพบว่ามีข้อมูลทั้งหมดจาก webpage โรงพยาบาลเกาะพีพี ตามที่เราต้องการครับ:


รูปที่ 12 ข้อมูลที่ได้จากการ scrape and parse หน้าเว็บโรงพยาบาลเกาะพีพี (รูปที่ 2) เก็บอยู่ใน list

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

import numpy as np
import pandas as pd

# Create DataFrame:
df = pd.DataFrame(pairs)

# Set index to be the header column: 
df.set_index([0], inplace=True)

# Replace blank or dash by NaN:
df.replace({"":np.nan, "-":np.nan}, inplace=True)

# Drop any (header, value) pair that has no value:
df.dropna(how='any', axis='index', inplace=True)

# Keep only one pair if any duplicate exists:
df.drop_duplicates(inplace=True)

# Transpose:
df = df.T

ถ้าลองให้ Jupyter Notebook แสดงผล df ดู (รูปที่ 13 ข้างล่างนี้) จะพบว่ามีข้อมูลทั้งหมดจากหน้าเว็บข้อมูลโรงพยาบาลเกาะพีพีตามที่เราต้องการครับ:


รูปที่ 13 ข้อมูลที่ได้จากการ scrape and parse หน้าเว็บโรงพยาบาลเกาะพีพีเก็บอยู่ใน pandas DataFrame

ใช้ for loop เพื่อ scrape ข้อมูลทุกสถานพยาบาลจากทุกหน้าเว็บ

ในส่วนที่แล้วนั้นเราได้ Python script สำหรับการ scrape and parse ข้อมูลของสถานพยาบาลหนึ่งแห่ง (โรงพยาบาลเกาะพีพี) ซึ่งเราเข้าไปที่ webpage ของสถานพยาบาลนี้ได้โดยใช้ code และ oldcode เพื่อบอก URL

เราสามารถทำแบบเดียวกันนี้กับสถานพยาบาลอื่น ๆ ที่เหลือทั้งหมดได้โดยเอา code และ oldcode ของทุกสถานพยาบาลที่เราได้ scrape มาแล้วก่อนหน้านี้ มาสั่งให้ bot รัน for loop ทำให้ครบทุกสถานพยาบาลเลย ขั้นแรกสุด ให้เราสร้าง Python script อันใหม่แบบว่าง ๆ เปล่า ๆ ขึ้นมา แล้วเอาโค้ดข้างล่างนี้ใส่ลงไปเลยครับ:

import requests
import bs4
from bs4 import BeautifulSoup

import pandas as pd
from bisect import bisect

# Import table of codes and oldcodes:
df_ids = pd.read_excel("table_id.xlsx", header=[0])

# A DataFrame to store data of all the healthcare providers:
df_all = pd.DataFrame()

6 บรรทัดแรกคือเรา import ทุก Python libraries ที่เราจะใช้ ถัดมาในบรรทัดที่ 9 เรา import (read) ข้อมูลตาราง รหัส 9 หลัก (code) กับรหัส 5 หลัก (oldcode) ของทุกสถานพยาบาลที่เราเคย scrape มาจากบทความตอนที่แล้วใน Part 2 เอามาใส่ไว้ใน df_ids เพื่อที่ต่อไปจะได้ทำ for loop ไล่ scrape ทีละสถานพยาบาล ส่วนในบรรทัดล่างสุดนั้นเราสร้าง pandas DataFrame เปล่าๆ ขึ้นมาหนึ่งอันเพื่อเอาไว้เก็บบันทึกข้อมูลของทุกสถานพยาบาลครับ

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

def get_info_table(url):
    source = requests.get(url)
    soup  = BeautifulSoup(source.content, 'lxml')
    div   = soup.find('div', id='content')
    table = div.find('table')
    rows  = table.findChildren('tr', recursive=False)
    
    pairs = []
    for row in rows:
        tags = row.contents
        ls_header_tags, ls_header_indices = [], []
        ls_value_tags,  ls_value_indices  = [], []
        headers_paired = []
        
        for i, tag in enumerate(tags):
            if is_header(tag):
                ls_header_tags += [tag]
                ls_header_indices+= [i]
                headers_paired += [False]
            elif is_value(tag):
                ls_value_tags  += [tag]
                ls_value_indices += [i]
        
        for i in range(0, len(ls_value_tags)):
            loc_hd = bisect(ls_header_indices, ls_value_indices[i])
            if loc_hd==0: # when value comes before any header
                header = pairs[-1][0]+"_2"
            else:
                header = ls_header_tags[loc_hd-1].text\
                        .replace("\xa0", "").replace("\n", " ")\
                        .replace("\t", " ").strip()
                headers_paired[loc_hd-1] = True
            
            value = ls_value_tags[i].text\
                        .replace("\xa0", "").replace("\n", " ")\
                        .replace("\t", " ").strip()
            
            pairs += [[header, value]]
        
        for i in range(len(ls_header_tags)):
            if not headers_paired[i]:
                header = ls_header_tags[i].text\
                            .replace("\xa0", "")\
                            .replace("\n", " ")\
                            .replace("\t", " ")\
                            .strip()
                pairs += [[header, ""]]
    
    df = pd.DataFrame(pairs)
    df.set_index([0], inplace=True)
    df.replace({"":np.nan, "-":np.nan}, inplace=True)
    df.dropna(how='any', axis='index', inplace=True)
    df.drop_duplicates(inplace=True)
    
    return df.T

Function get_info_table(url) ข้างบนนี้คือ เพียงเราบอกมันว่า URL ไหน มันก็จะไป scrape and parse ข้อมูลของสถานพยาบาลนั้น ส่งมาให้เราเป็นตาราง pandas DataFrame แบบเดียวกับในรูปที่ 13 ข้างบนเลยครับ ผู้อ่านสามารถดูรายละเอียดของแต่ละบรรทัดใน function นี้ข้างบนครับ

ได้ function นี้มาแล้วเราก็ for loop ไล่ scrape ทีละสถานพยาบาลได้ตามนี้เลย:

for i in range(0, df_ids.shape[0]):
    code    = str(df_ids.iloc[i, 1]).replace("\xa0", "")
    oldcode = str(df_ids.iloc[i, 2]).replace("\xa0", "")
    url = f"http://203.157.10.8/hcode_2014/query_detail.php?p=3&code={code}&oldcode={oldcode}&status=01"
    try:
        df     = get_info_table(url)
        df_all = pd.concat([df_all, df], axis=0, sort=False,\
                               ignore_index=True, join="outer")
        print(f"Done scraping {i}")
        df_all.to_pickle("temp.pkl")
    except Exception as e:
        print(f"Cannot scrape {i}")
        print(str(e))

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

ใน for loop แต่ละ loop คือการไล่ดูทีละสถานพยาบาล ในบรรทัดที่ 2-3 เราอ่าน code กับ oldcode เพื่อเอาไปสร้าง URL ในบรรทัดที่ 4 แล้วเราก็ลองส่ง URL ไปให้ function get_info_table() scrape and parse ข้อมูลสถานพยาบาลนี้มา ข้อมูลนี้อยู่ในตัวแปร df ซึ่งถัดมาเราเอาไปเติม (concatenate) ใส่ไว้ใน df_all เราใช้ try except เผื่อกรณีที่บางหน้าวเว็บไม่สามารถ scrape ได้ ก็ให้ข้ามไปครับ

คำเตือน: เนื่องจากการ scrape สถานพยาบาลทั้งหมดจาก 2 หมื่นกว่าหน้าเว็บจะต้องรัน for loop นี้ 2 หมื่นกว่ารอบ ซึ่งใช้เวลาค่ออนข้างนาน ผู้อ่านอาจจะลอง scrape เพียง 100 หน้าแรกโดยการเปลี่ยน for loop ในบรรทัดแรก ให้เป็น for i in range(0, 100): ดูก่อนได้ครับ ถ้าทดลองรันแล้วไม่มีปัญหาอะไรก็ค่อยรันทั้ง 2 หมื่นกว่าหน้าเว็บรวดเดียวเลยได้ครับ

เนื่องจากระยะเวลาในการรันให้ครบทุกสถานพยาบาล (2 หมื่นกว่าแห่ง คือ for loop 2 หมื่นกว่ารอบ) นั้นค่อนข้างนาน เราจึงจะทำการ save (export) ข้อมูลไว้ใน pickle file ทุกรอบ (บรรทัดสุดท้ายของ for loop) เผื่อว่าเราหยุดรัน Python ไปกระทันหัน จะได้กลับมารันต่อได้ครับ ท้ายที่สุดแล้ว พอรันจบ เราจะ save (export) ข้อมูลไว้ใน Excel file ซึ่งถ้าลองเปิดดูจะได้ตารางหน้าตาแบบรูปที่ 14 นี้ครับ:


รูปที่ 14 ตารางที่ได้จากการ scrape and parse หน้าเว็บข้อมูลสถานพยาบาล 2 หมื่นกว่าแห่งทั่วประเทศ

จะเห็นได้จากตารางนี้ว่าตำแหน่งที่ตั้ง latitude กับ longitude นั้นยังอยู่รวมกันใน column เดียวที่ชื่อว่า “พิกัดทางภูมิศาสตร์ :” สมมุติถ้าเราอยากจะแยก latitude กับ longitude ออกไปอยู่คนละ column ก็สามารถทำได้โดยใช้ split() method เพื่อตัดคำ สกัดเอามาเฉพาะตัวเลข latitude กับ longitude ตามตัวอย่างโค้ดข้างล่างนี้ครับ :

df_all["Longitude:"] = df_all["พิกัดทางภูมิศาสตร์ :"].apply(lambda x:\
 	 	 	 	 	 	 	x.split("Longitude:")[1]\
 	 	 	 	 	 	 	.split("Latitude")[0]\
 	 	 	 	 	 	 	.replace("\xa0", "")\
 	 	 	 	 	 	 	.strip() )

df_all["Latitude:"] = df_all["พิกัดทางภูมิศาสตร์ :"].apply(lambda x:\
 	 	 	 	 	 	 	x.split("Latitude")[1]\
 	 	 	 	 	 	 	.replace("\xa0", "")\
 	 	 	 	 	 	 	.strip() )

df_all["Longitude:"].replace({"":np.nan, "-":np.nan}, inplace=True)
df_all["Latitude:"].replace({"":np.nan, ":-":np.nan}, inplace=True)

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

ใน columns อื่นๆ ที่ข้อมูลยังไม่ได้อยู่ใน format ที่เราต้องการ ก็มีเครื่องมือต่างๆ ใน python ที่สามารถสกัดหรือแปลงเอาเฉพาะข้อมูลส่วนที่เราต้องการเท่านั้นมาได้เช่นกัน เช่น python library ที่ชื่อว่า re ซึ่งเป็นการนำ Regular Expression มาใช้ในการสกัดเอาเฉพาะคำที่มี pattern ตามที่เราต้องการครับ แต่ในบทความนี้เราจะยังไม่อธิบายรายละเอียดของวิธีนี้ครับ

บทสรุป

ในบทความนี้ ทั้งหมด 3 ตอนที่ผ่านมา เราได้ฝึกทำ automation สำหรับ data scraping เพื่อเก็บข้อมูลบนเว็บไซต์ โดยใช้ Python libraries requests กับ BeautifulSoup เราได้ลงรายละเอียดแบบ step-by-step ในการเขียน Python script ลอง scrape ข้อมูลสถานพยาบาลทั่วประเทศกว่า 2 หมื่นแห่ง เว็บไซต์ของกองยุทธศาสตร์และแผนงาน สำนักงานปลัดกระทรวงสาธารณสุข

สิ่งที่เราได้ทำ คือ:

  • ดูโครงสร้าง HTML โดยใช้ developer tools บน web browser
  • จับ pattern ของ URL เพื่อจะได้เข้าถึงแต่ละ webpage ได้โดยง่าย
  • กวาดเอาเนื้อหา HTML มาทั้งหมด (scrape) โดยใช้ Python library requests
  • ใช้ BeautifulSoup สกัดเอาเฉพาะข้อมูลที่เราสนใจ (parse) จาก HTML ที่เราได้มา

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

Recommended Posts