Practical Scraping
2020.06.22 · 2-min read · Tags: data, python, tutorial, bank-of-thailand

ปกติเวลาเราจะดึงข้อมูลจากเว็บมาใช้งาน ถ้าคนทำข้อมูลนึกถึงหัวอกคนใช้งาน ก็จะทำให้ข้อมูลอยู่ในรูปแบบที่เรียกว่า "machine-readable" ซึ่งก็แปลว่า ทำให้ช้อมูลอยู่ในรูปแบบที่คอมพิวเตอร์เข้าใจง่าย ๆ โดยระบบการรับส่งข้อมูลที่เป็นมาตรฐานที่สุด คือการใช้ API (application programming interface) และในโลกแห่งทุ่งลาเวนเดอร์ ทุกหน่วยงานก็จะมี API ให้เรายิงไปดูดข้อมูลมาด้วยความง่ายดาย

แต่อนิจจา เราไม่ได้อยู่ในทุ่งลาเวนเดอร์ มีหน่วยงานมากมายที่ยังจัดเก็บข้อมูลโดยใช้กระดาษเป็นแผ่น ๆ อยู่ ซึ่งอันนั้นจะขอยกไว้เป็นกลุ่มผู้ป่วยที่อาจจะเยียวยาได้ยากนิดนึง (วิธีส่วนใหญ่ ก็จะเป็นการ scan เอกสารเหล่านี้ แล้วใช้ OCR แปลงให้เป็น text file)

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

ในตัวอย่างนี้ เราจะลองมาดึงข้อมูลของสถานพยาบาลทุกแห่งในประเทศไทยกัน!

Note

บทความนี้ปรับปรุงจากเนื้อหาการสอนน้อง ๆ พนักงานใหม่ในสายนโยบายการเงินของแบงก์ชาติ เมื่อปี 2018

ข้อมูลที่มี

ก่อนที่เราจะ scrape อะไร ก็ต้องมาดูกันก่อนว่าลักษณะของที่เราจะไปเอามานั้นเป็นยังไง ลองกดไปดูหน้านี้ จะเห็นข้อมูลต่าง ๆ กดดูตาม tab ต่าง ๆ ข้างบนได้

หน้าตัวอย่าง

นอกจากนี้ ถ้าเราลองเปลี่ยนรหัส 5 ตัวหลังสุด ก็จะพบว่ามันคือรหัสสถานพยาบาลต่าง ๆ นั่นเอง ซึ่งก็แปลว่าถ้าเราอยากดึงข้อมูลของสถานพยาบาลทุกแห่งในประเทศ เราก็สามารถ loop รหัสนี้ ตั้งแต่ 00001 ไปจนถึง 99999 ได้เลย แน่นอนว่าสถานพยาบาลในประเทศไทยคงมีไม่ถึงเกือบแสนแห่ง แต่อันไหนที่ไม่มีเราก็แค่ข้ามไปเท่านั้นเอง

ขั้นตอนการ scrape โดยทั่วไป

อันนี้ไม่ได้เป็นสูตรตายตัวหรืออะไร แต่ขั้นตอนทั่ว ๆ ไปก็จะเป็นประมาณนี้

  1. หาข้อมูลว่าอยู่ตรงไหน เค้าจัดเก็บข้อมูลยังไง
  2. ดึงข้อมูลจากหน้าเพจมา
  3. แยกข้อมูลที่เราต้องการออกมาเก็บไว้
  4. ทำซ้ำข้อ 2–3 ไปเรื่อย ๆ จนกว่าจะพอใจ

ซึ่งข้อ 1 เราได้ทำไปประมาณนึงละ ต่อมาเรามาลองดูขั้นตอนต่อไปกัน

เริ่มจากการทำ HTTP request ก่อน

ลองสร้าง file ใหม่ (จะเป็นใน Jupyter Notebook หรือ text editor อะไรก็ได้ แล้วแต่สะดวก) แล้วใส่ code นี้ไปดู

1import requests
2
3url = 'http://gishealth.moph.go.th/healthmap/info.php?maincode=00001'
4r = requests.get(url, timeout=1)

requests นี่เป็น package ที่มี feature ค่อนข้างเยอะ แต่อันนี้ความหมายง่าย ๆ ก็คือ ให้ส่ง get request ไปขอข้อมูลที่ URL ข้างต้น โดยตั้งค่า timeout ไว้ที่ 1 วินาที คือ ถ้าไม่ได้ response ใด ๆ กลับมาใน 1 วินาที ก็ให้เลิกซะ จากนั้นถ้าเราลองพิมพ์ผลลัพธ์ที่ได้ออกมา

1import requests
2
3url = 'http://gishealth.moph.go.th/healthmap/info.php?maincode=00001'
4r = requests.get(url, timeout=1)
5print(r)
6# <Response [200]>

เราก็จะเห็นว่า r นี่จริง ๆ เป็น response object (กดตรงนี้ไปดู doc ได้) ซึ่งก็จะมี properties เยอะมาก ลองเล่นดูได้

อันนึงที่เราอาจจะสนใจ คือ status_code เป็นยังไง ก็สามารถพิมพ์ r.status_code ดูได้เลย อันนี้จะเป็น HTTP status code ซึ่งถ้าโอเค ไม่มีปัญหาอะไร ก็จะได้เลข 200 กลับมา หรือถ้าหาไม่เจอ ก็จะได้ 404 เป็นต้น (อันนี้ขึ้นกับคนเขียนเว็บ อย่างของอันนี้ ถ้าไม่เจอ เราจะได้หน้าเปล่า ๆ แต่ status code ยังเป็น 200 อยู่)

แล้วเราจะเอาข้อมูลมาจากไหนล่ะ? ข้อมูลที่ไปปรากฏในหน้า browser ก็สามารถเรียกได้จาก print(r.text) นั่นเอง ซึ่งก็จะเหมือนกับ source code ของ HTML page เด๊ะ ๆ เลย ถ้าลองหาดู ใน source code ก็จะมีข้อมูลที่เราสนใจอยู่ ไม่ว่าจะเป็นที่อยู่ เบอร์โทร เว็บไซต์ ฯลฯ

แยกข้อมูลที่เราสนใจมาเก็บไว้ด้วย RegEx

ตอนนี้เราได้ text response ที่มีข้อมูลเยอะแยะมาละ ทีนี้ถึงขั้นตอนที่เราจะต้องแยกเอาข้อมูลที่เราสนใจเก็บไว้

หลาย ๆ คนอาจจะรู้จัก wildcard search หรือการหาแบบไม่ระบุตัวอักษรทั้งหมด เช่น ถ้าเราอยากหาคำที่ขึ้นต้นด้วยอะไรก็ได้ แต่ลงท้ายด้วย ing เราก็จะหาว่า *ing หรืออยากได้คำที่ขึ้นต้นด้วย s ตามดัวยตัวอักษร 1 ตัว แล้วลงท้ายด้วย t ก็จะหาว่า s?t เป็นต้น

Regular expression เป็นประมาณนั้น แต่ทำได้มากกว่าประมาณ 230,000 เท่า ถ้าใครสนใจ ขอแนะนำให้ลองไปอ่านบทความ "RegEx เมพ" (ซึ่งยังไม่ได้เขียน... ตึ๊ง) หรือ Wikipedia ก่อน

สมมติเราต้องการตัวแปรเหล่านี้จากหน้า info.php

  • ชื่อสถานพยาบาล
  • ที่อยู่
  • เบอร์โทร

ถึงจะไม่ค่อยคุ้นชินกับ regex ก็สามาถไปลองผิดลองถูกได้ เพราะ มีเว็บช่วยดูเพียบ เนื่องจาก regex เป็นเครื่องมือที่คนใช้กันแพร่หลายมากนั่นเอง โดยเว็บนึงที่อยากจะแนะนำคือ regex101.com เพราะสามารถ export code มาเป็นภาษาต่าง ๆ ได้เลย แถมมี quick reference ให้อีกด้วย (ดูเฉลยได้ที่ https://regex101.com/r/ADp5rM/1)

regex

จากนั้นเราก็เอา regex pattern ที่เราได้ มาใส่ใน code ของเราได้เลย

1import requests
2import re
3
4url = 'http://gishealth.moph.go.th/healthmap/info.php?maincode=00001'
5r = requests.get(url, timeout=1)
6
7regex = r"<td width=\"70%\" class=\"tdbold\">\s+<div >" \
8 + r"\s*([^<&]+).*ที่อยู่\s+([^<&]+).*โทร.\s*([0-9๐-๙- ,]+)"

โดย function ที่จะใช้ มี 2 อัน

  • re.search() จะ return ค่า match อันแรก เป็น re.Match object ซึ่งเราสามารถดูผลลัพธ์ได้โดยการเรียก .group ขึ้นมา
  • re.findall() จะ return ค่า match ทุกอัน

ในกรณีนี้ เนื่องจากเรารู้อยู่แล้วว่าในหน้านึง จะมี match แค่อันเดียว ก็ใช้ re.search() จะมีประสิทธิภาพมากกว่า

1import requests
2import re
3
4url = 'http://gishealth.moph.go.th/healthmap/info.php?maincode=00001'
5r = requests.get(url, timeout=1)
6
7regex = r"<td width=\"70%\" class=\"tdbold\">\s+<div >" \
8 + r"\s*([^<&]+).*ที่อยู่\s+([^<&]+).*โทร.\s*([0-9๐-๙- ,]+)"
9m = re.search(regex, r.text, re.MULTILINE | re.DOTALL)
10
11print([m.group(1), m.group(2), m.group(3)])
12# ['สำนักงานสาธารณสุขจังหวัดสมุทรปราการ',
13# '19 ซอย 35 อัศวนนท์ 2 ถ.สุขุมวิท ต.ปากน้ำ อ.เมือง จ.สมุทรปราการ 10270 ',
14# '02-3895980']

ทำวนไปสิ

อ่าห์ ตอนนี้ถ้าเราใส่ URL เข้าไป ก็จะได้ชื่อสถานพยาบาล ที่อยู่ แล้วก็เบอร์โทรมาแล้ว แต่! งานของเรายังไม่จบแค่นี้ เพราะถ้าจะเอาข้อมูลของสถานพยาบาลเดียว กดเข้าไปก๊อปมาแปะ ก็คงจะเร็วกว่านั่งเขียนโปรแกรมเป็นแน่แท้

สิ่งที่คอมจะชนะคน ก็คือความถึกกับความเร็วนี่แหละ

ลองสร้าง loop

เราจะมาเริ่มสร้าง loop เล็ก ๆ ดูก่อน ให้มั่นใจว่า script เราใช้ได้ ไม่พัง ^^

ทีนี้ จะ loop ตามที่เกริ่นไว้ข้างต้น ก็ต้องไล่เลข 00001, 00002, ... ไปเรื่อย ๆ ซึ่งเรารู้วิธี loop จาก 1 ถึง 99,999 อยู่แล้วแหละ แต่ว่าต้องใส่เลข 0 ข้างหน้านี่ทำยังไงน้อ

จะเขียน function เองขึ้นมาก็คงได้ แต่ต้องคิดไว้ก่อนว่า มันต้องมีคนพยายามทำแบบนี้มาแล้วสิน่า แล้วก็ search โลด... Keyword ที่ใช้ คือ python fill zero left ก็จะพบว่า python มี function ชื่อ zfill อยู่แล้ว โดยวิธีใช้ก็ง่าย ๆ คือสมมติเราบอก str(89).zfill(5) ก็จะได้ string "00089" ออกมา โอ้ว ชีวิตอะไรจะง่ายเท่านี้

ว่าแล้วเราก็จัดการ loop โลด

1import requests
2import re
3
4url = 'http://gishealth.moph.go.th/healthmap/info.php?maincode='
5regex = r"<td width=\"70%\" class=\"tdbold\">\s+<div >" \
6 + r"\s*([^<&]+).*ที่อยู่\s+([^<&]+).*โทร.\s*([0-9๐-๙- ,]+)"
7
8for i in range(1, 6):
9 r = requests.get(url + str(i).zfill(5), timeout=1)
10 m = re.search(regex, r.text, re.MULTILINE | re.DOTALL)
11 print(i, [m.group(1).strip(), m.group(2).strip(), m.group(3).strip()])

ไม่แน่ใจว่าตอนนี้จะเป็นยังไง แต่ตอนเขียนอยู่นี่ เหมือนมีปัญหากับที่ 00003 อยู่ ทำให้ค้างนานมาก ได้ response มาประมาณนี้

1 ['สำนักงานสาธารณสุขจังหวัดสมุทรปราการ', '19 ซอย 35 อัศวนนท์ 2 ถ.สุขุมวิท ต.ปากน้ำ อ.เมือง จ.สมุทรปราการ 10270', '02-3895980']
2 ['สำนักงานสาธารณสุขจังหวัดนนทบุรี', 'ศูนย์ราชการจังหวัดนนทบุรี ม.8 ถ.รัตนาธิเบศร์ ต.บางกระสอ อ.เมือง จ.นนทบุรี 11000', '02 9503071-6']
---------------------------------------------------------------------------
timeout Traceback (most recent call last)
...

คือได้ผลของสองอันแรกมา แต่อันที่สามเจ๊ง แล้วก็เลยจบเลย ซึ่งถ้าเราอยากดึงข้อมูลสถานพยาบาลมาเป็นพัน ๆ หมื่น ๆ แห่ง ปล่อยให้เจ๊งกลางคันอย่างนี้คงไม่ดีแน่

จัดการกับ timeout

วิธีจัดการกับ error ต่าง ๆ คือใช้ Try Except ซึ่งถ้าเกิด error ขึ้น ก็จะไปเข้าที่ except แต่โปรแกรมก็ยัง run ต่อไปได้ ลองแบบนี้ดู

1import requests
2import re
3
4url = 'http://gishealth.moph.go.th/healthmap/info.php?maincode='
5regex = r"<td width=\"70%\" class=\"tdbold\">\s+<div >" \
6 + r"\s*([^<&]+).*ที่อยู่\s+([^<&]+).*โทร.\s*([0-9๐-๙- ,]+)"
7
8for i in range(1, 6):
9 try:
10 r = requests.get(url + str(i).zfill(5), timeout=1)
11 m = re.search(regex, r.text, re.MULTILINE | re.DOTALL)
12 print(i, [m.group(1).strip(), m.group(2).strip(), m.group(3).strip()])
13 except:
14 print('*** Error at i = ' + str(i))

ก็จะได้ response แบบนี้

1 ['สำนักงานสาธารณสุขจังหวัดสมุทรปราการ', '19 ซอย 35 อัศวนนท์ 2 ถ.สุขุมวิท ต.ปากน้ำ อ.เมือง จ.สมุทรปราการ 10270', '02-3895980']
2 ['สำนักงานสาธารณสุขจังหวัดนนทบุรี', 'ศูนย์ราชการจังหวัดนนทบุรี ม.8 ถ.รัตนาธิเบศร์ ต.บางกระสอ อ.เมือง จ.นนทบุรี 11000', '02 9503071-6']
*** Error at i = 3
4 ['สำนักงานสาธารณสุขจังหวัดพระนครศรีอยุธยา', 'ค 130 ถนนอู่ทอง ต.หอรัตนไชย', '035-241520']
5 ['สำนักงานสาธารณสุขจังหวัดอ่างทอง', '122 ม.4 ถ.โพธิ์พระยา-ท่าเรือ ต.บ้านอิฐ อ.เมือง จ.อ่างทอง', '035-611222']

เห็นได้ว่าจะเขียนว่า อันที่ 3 นั้นเจ๊ง แต่ก็ยังไปดึงข้อมูลของอันต่อ ๆ ไปมาได้ไม่มีปัญหา

เก็บเข้าไฟล์

เอาล่ะ เริ่ม loop ได้แล้ว ขั้นสุดท้ายก็คือเก็บข้อมูลที่ได้ลงไฟล์ เพื่อเอามาวิเคราะห์ต่อไป โดยในตัวอย่างนี้จะลองเก็บเป็นไฟล์ .csv ก่อน ขั้นตอนนี้ไม่ยากแล้ว เพราะค่อนข้าง standard คือเปิดไฟล์ขึ้นมา แล้วก็เรียก csvwriter.writerow() เป็นอันเสร็จสิ้น

1import requests
2import re
3import csv
4
5url = 'http://gishealth.moph.go.th/healthmap/info.php?maincode='
6regex = r"<td width=\"70%\" class=\"tdbold\">\s+<div >" \
7 + r"\s*([^<&]+).*ที่อยู่\s+([^<&]+).*โทร.\s*([0-9๐-๙- ,]+)"
8
9with open('hospital.csv', 'w', newline='', encoding='utf-8-sig') as csvfile:
10 csvwriter = csv.writer(csvfile, quoting=csv.QUOTE_ALL)
11 for i in range(1, 6):
12 try:
13 r = requests.get(url + str(i).zfill(5), timeout=1)
14 m = re.search(regex, r.text, re.MULTILINE | re.DOTALL)
15 csvwriter.writerow([str(i).zfill(5), m.group(1).strip(),
16 m.group(2).strip(), m.group(3).strip()])
17 except:
18 print('*** Error at i = ' + str(i))
19 csvwriter.writerow([str(i).zfill(5), "Error"])

ลองเช็คไฟล์ hospital.csv ดู ก็จะเห็นว่ามีข้อมูล 4 สถานพยาบาล ส่วนเบอร์ 00003 นั้นก็จะมี log ออกมาว่า "Error" เรียบร้อย

1"00001","สำนักงานสาธารณสุขจังหวัดสมุทรปราการ","19 ซอย 35 อัศวนนท์ 2 ถ.สุขุมวิท ต.ปากน้ำ อ.เมือง จ.สมุทรปราการ 10270","02-3895980"
2"00002","สำนักงานสาธารณสุขจังหวัดนนทบุรี","ศูนย์ราชการจังหวัดนนทบุรี ม.8 ถ.รัตนาธิเบศร์ ต.บางกระสอ อ.เมือง จ.นนทบุรี 11000","02 9503071-6"
3"00003","Error"
4"00004","สำนักงานสาธารณสุขจังหวัดพระนครศรีอยุธยา","ค 130 ถนนอู่ทอง ต.หอรัตนไชย","035-241520"
5"00005","สำนักงานสาธารณสุขจังหวัดอ่างทอง","122 ม.4 ถ.โพธิ์พระยา-ท่าเรือ ต.บ้านอิฐ อ.เมือง จ.อ่างทอง","035-611222"

ยังช้าไปนะ

ถึงตอนนี้ เราก็แค่ให้ loop ตั้งแต่ 1 ถึง 99,999 ก็เสร็จแล้ว! แต่ก็ยังมีอีกหลายอย่างให้ปรับปรุงเหมือนกัน เช่น จะ loop ตั้งแต่ 1 ถึง 99,999 นั้น กว่าจะได้ก็อาจจะแก่ตายก่อน โดยเฉพาะถ้า server ไม่เร็ว หรือมี traffic มาก คอขวดหลักที่ทำให้ช้าคือตอน requests.get() ต้องรอ response จาก server ค่อนข้างนาน

จะดีมั้ย ถ้าระหว่างที่รอ เราก็ขอ request อื่นไปด้วยได้... คำตอบก็คือ ดีสิ!

ทำแบบนี้เรียก multithreading เปรียบเทียบก็เหมือนแทนที่จะนั่งพิมพ์ข้อมูลยู่คนเดียว ก็ขอให้เพื่อนมาช่วยทำหลาย ๆ คน ซึ่งจริง ๆ python สามารถทำได้ แต่ในขั้นนี้จะขอแนะนำแบบง่าย ๆ ก่อน เป็น fake multithreading ก่อนละกัน คือ เขียน script ขึ้นมา โดยเราสามารถบอกได้ว่าให้ดูดข้อมูลตั้งแต่รหัสเท่าไหร่ถึงเท่าไหร่ แล้วเราก็ run sript นี้ไปพร้อม ๆ กันซัก 10 อัน

ก่อนอื่นก็ทำให้ code เรารับ argument จาก command line ได้ก่อน ตามด้านล่างเลยจ้า

hospital_scrape.py
1import requests
2import re
3import csv
4import sys
5
6url = 'http://gishealth.moph.go.th/healthmap/info.php?maincode='
7regex = r"<td width=\"70%\" class=\"tdbold\">\s+<div >" \
8 + r"\s*([^<&]+).*ที่อยู่\s+([^<&]+).*โทร.\s*([0-9๐-๙- ,]+)"
9
10with open(sys.argv[3], 'w', newline='', encoding='utf-8-sig') as csvfile:
11 csvwriter = csv.writer(csvfile, quoting=csv.QUOTE_ALL)
12 for i in range(int(sys.argv[1]), int(sys.argv[2]) + 1):
13 try:
14 r = requests.get(url + str(i).zfill(5), timeout=1)
15 m = re.search(regex, r.text, re.MULTILINE | re.DOTALL)
16 csvwriter.writerow([str(i).zfill(5), m.group(1).strip(),
17 m.group(2).strip(), m.group(3).strip()])
18 except:
19 print('*** Error at i = ' + str(i))
20 csvwriter.writerow([str(i).zfill(5), "Error"])

จากนั้นถ้าเราต้องการให้ script ดูดข้อมูลของรหัส 1–100 ไปไว้ที่ out_1.csv ก็พิมพ์ไปว่า

python hospital_scrape.py 1 100 out_1.csv

ได้เลย

บทสรุป

จะเห็นได้ว่าการ scrape ข้อมูลนี้ไม่ได้ยากเลย โดยเฉพาะถ้าเป็นข้อมูลที่เราสามารถเห็นได้ชัด ๆ จากการดู source code ขั้นต่อไปก็อาจจะมีการใช้เครื่องมืออย่างอื่นที่ advance ขึ้น เช่น ดึงข้อมูลจาก class ต่าง ๆ นอกจากนี้ ก็ยังมีบางจุดที่เราสามารถปรับปรุง code นี้ได้ เช่น การทำให้เป็น true multithreading หรือการจัดการกับ error เพราะตอนนี้พอเราเจอ error เราก็แค่บอกว่าเป็น error แต่อาจจะดีถ้าเราจะบอกประเภท error ได้ เช่น เป็น timed out หรือ not found ได้ เวลาเราจะจัดการ clean up ข้อมูลจะได้ง่ายขึ้นหน่อย

ยังไงก็หวังว่าจะได้รับประโยชน์ไปจากบทความนี้นะครับ