ใน圈子 Ruby (Rubyland) มีสอง Gem ที่ครองความนิยมในเว็บสครัปปิ้งมาหลายปี: Nokogiri และ Mechanize เราจะใช้บทความละ Gem ก่อนจะนำไปปฏิบัติจริงกับตัวอย่าง
หัวข้อที่ครอบคลุม
- เว็บสครัปปิ้งคืออะไร?
- ขออนุญาต
- ปัญหาที่ต้องแก้
- Nokogiri
- การดึงข้อมูล (Extraction)
- หน้าเว็บ (Pages)
- API
- การนำทางโหนด (Node Navigation)
เว็บสครัปปิ้งคืออะไร?
มีคำที่หรูหรามากกว่า "เว็บสครัปปิ้ง" หรือ "สกรีนสครัปปิ้ง" เช่น "เว็บฮาร์เวสติ้ง" (Web harvesting) และ "เว็บดาต้าอีกซ์แทร็กชัน" (Web data extraction) ซึ่งบอกเลยว่ากำลังทำอะไรอยู่ เราสามารถทำให้การดึงข้อมูลจากหน้าเว็บเป็นอัตโนมัติ—และไม่ได้ยากอย่างที่คิด
ในทางหนึ่ง เครื่องมือเหล่านี้ช่วยให้คุณเลียนแบบและทำให้การเล่นเว็บของมนุษย์เป็นอัตโนมัติ คุณเขียนโปรแกรมที่ดึงเฉพาะข้อมูลที่สนใจ การระบุเป้าหมายข้อมูลเฉพาะจุดง่ายเกือบเท่า CSS selectors
สองสามปีก่อน ผมสมัครคอร์สวิดีโอออนไลน์ที่มีวิดีโอสั้นๆ หลายล้านคลิป แต่ไม่มีตัวเลือกดาวน์โหลดแบบกลุ่ม ต้องเข้าแต่ละลิงก์เองและกด 'save as' เอง นั่นคือ "เว็บสครัปปิ้งแบบมนุษย์" —สิ่งที่เราต้องทำเมื่อขาดความรู้ในการทำอัตโนมัติ คอร์สเนื้อหาดี แต่วิธีใช้งานน่เบื่อเกินไป จึงไม่ได้ใช้บริการต่อ
วันนี้ผมไม่สนใจ UX ที่ทำให้สมองละลายแบบนั้นแล้ว สคริปต์สครัปที่ดาวน์โหลดให้อัตโนมัติใช้เวลาเขียนไม่เกินไม่กี่นาที ไม่เป็นไรเลย!
ลองแยกขั้นตอนสั้นๆ: 1) ดึงหน้าเว็บที่มีข้อมูลที่ต้องการ 2) ค้นหาและระบุข้อมูลที่ต้องการดึง 3) จัดเป้าหมาย สไลด์ข้อมูลถ้าจำเป็น และตัดสินใจว่าจะเก็บยังไง ไว้ที่ไหน HTML ที่เขียนดีมักเป็นกุญแจสำคัญทำให้กระบวนการง่ายและสนุก สำหรับการดึงข้อมูลซับซ้อน markup ที่โครงสร้างแย่จะทำให้ปวดหัว
API เป็นอย่างไร? คำถามดี ถ้ามี API ของบริการนั้น มักไม่ต้องเขียนสครัปเปอร์เอง วิธีนี้เหมาะกับเว็บที่ไม่มี API โดยไม่มี API นี่มักเป็นวิธีเดียวที่ทำให้การดึงข้อมูลอัตโนมัติได้
การสครัปทำงานอย่างไร? คำตอบสั้น: โดยการเดินทาง (traverse) โครงสร้างข้อมูลแบบต้นไม้ (tree data structures) Nokogiri สร้างโครงสร้างเหล่านี้จากเอกสารที่คุณให้ และให้คุณระบุเป้าหมายบิทที่สนใจเพื่อดึงออกมา CSS เป็นภาษาสำหรับการเดินทางต้นไม้ ค้นหาโครงสร้างต้นไม้ เราจึงนำมาใช้สำหรับดึงข้อมูลได้
มีแนวทางและโซลูชันเยอะมาก Rubyland มีสอง Gem ที่ครองแสงสปอตไล่หลายปี หลายคนยังคงใช้ Nokogiri และ Mechanize สำหรับ HTML scraping ทั้งคู่ทนทาน สะดวก และสามารถทำได้มาก เราจะดูทั้งคู่ แต่ก่อนหน้านั้น ผมอยากพูดถึงปัญหาที่จะแก้ในตอนท้ายของซีรีส์นี้
ขออนุญาต (Permission)
ก่อนเริ่มสครัป ต้องแน่ใจว่ามีอนุญาตจากเว็บไซต์ที่จะเข้าถึงข้อมูล หากเว็บมี API หรือ RSS feed อาจง่ายกว่าและก़กฎหมายกว่า
ไม่ใช่ทุกคนชอบถูกสครัปมาก (massive scraping) — เข้าใจได้ ทำความเข้าใจเว็บนั้นๆ ให้ดี อย่าลงเอยทำให้ตัวเองปวดหัว โอกาสทำลายสลายร้ายแรงน้อย แต่เสี่ยงโดยไม่รู้ตัวไม่ควรทำ
ปัญหาที่ต้องแก้
ผมต้องสร้าง podcast ใหม่ การออกแบบไม่เป็นไปตามใจ และเกลียดวิธีเผยแพร่โพสต์ใหม่ ดูด WYSIWYGs! บทบาทย้อนหลัง: สองปีก่อน ผมสร้าง podcast รุ่นแรก อยากเล่น Sinatra สร้างของเบาๆ พบปัญหาไม่คาดคิด เพราะทำนี่เองเกือบทั้งหมด
มาจาก Rails เรียนรู้มาก แต่เสียดายที่ไม่ได้ใช้ static site ที่ deploy ผ่าน GitHub Pages ได้ การ deploy ตอนใหม่และดูแลรักษาขาดความเรียบง่ายที่ต้องการ จึงตัดสินใจโฟกัสผลิตเนื้อหา podcast แทน
ช่วงฤดูร้อนนี้ เริ่มจริงจัง สร้างเว็บ Middleman host บน GitHub Pages สำหรับซีซั่น 2 อยากมีของใหม่: дизайн เรียบง่าย, Markdown สำหรับเผยแพร่ตอนใหม่, ไม่ต้องต่อสู้กับ Heroku — สวรรค์! แต่มี 139 ตอนเก่าที่ต้อง import และแปลงก่อนใช้กับ Middleman ได้
Middleman ใช้ไฟล์ .markdown ที่มี frontmatter สำหรับข้อมูล — แทนที่ database การโอนย้ายด้วยมือ 139 ตอน ไม่ใช่ทางเลือก นี่คือหน้าที่ของคอมพิวเตอร์ ผมต้องหาวิธี parse HTML เว็บเก่า สครัปเนื้อหาที่เกี่ยวข้อง และโอนไปเป็น blog posts สำหรับเผยแพร่ podcast บน Middleman
ดังนั้น 3 บทความต่อไป จะแนะนำเครื่องมือที่ใช้ใน Rubyland สำหรับงานแบบนี้ และสุดท้ายจะแสดงโซลูชันของผม
Nokogiri
แม้คุณจะเพิ่งเริ่ม Ruby/Rails โอกาสสูงมากที่เคยได้ยิน Gem นี้ ชื่อดังและจดจำง่าย ไม่แน่ใจว่ามากน้อยคนรู้ว่า "nokogiri" ภาษาญี่ปุ่นแปลว่า "เลื่อย" (saw)
ชื่อนี้เหมาะสมเมื่อเข้าใจว่ามันทำอะไร: ผู้สร้างคือ Tenderlove, Aaron Patterson Nokogiri แปลงเอกสาร XML และ HTML เป็นโครงสร้างข้อมูล — โครงสร้างแบบต้นไม้ (tree data structure) ให้ละเอียด กรุงและมี interface สวย ใช้ได้ดีมากสำหรับ HTML scraping ทุกแบบ
Nokogiri ไม่ได้แค่ parse HTML; XML ก็ได้ ให้ทั้ง XPath (XML path Language) และ CSS interfaces สำหรับ traverse เอกสาร XPath คือ query language สำหรับเลือกโหนดจาก XML CSS selectors คุ้นเคยกว่าสำหรับมือใหม่ เหมือน CSS ที่เขียน style ใช้ target ส่วนของหน้าเว็บที่สนใจได้ง่ายมาก แค่บอก Nokogiri ว่าอยากได้อะไร
หน้าเว็บ (Pages)
สิ่งแรกที่ต้องทำคือดึงหน้าเว็บที่สนใจ ระบุประเภทเอกสาร Nokogiri ที่จะ parse — XML หรือ HTML:
Nokogiri::XML Nokogiri::HTML
some_scraper.rb
require "nokogiri"
require "open-uri"
page = Nokogiri::XML(File.open("some.xml"))
page = Nokogiri::HTML(File.open("some.html"))
Nokogiri::XML และ Nokogiri::HTML รับ IO objects หรือ String objects โค้ดข้างบนเปิดและดึงหน้าเว็บที่กำหนดด้วย open-uri แล้วโหลดโครงสร้าง XML หรือ HTML เข้า Nokogiri document XML ไม่ใช่สิ่งที่มือใหม่เจอบ่อย แนะนำให้โฟกัส HTML parsing
ทำไมต้อง open-uri? โมดูลนี้ใน Ruby Standard Library ให้ดึงเว็บได้ง่าย เพราะรับ IO objects จึงใช้ open-uri ได้สะดวก
API
ลองทำตัวอย่างเล็กๆ:
at_css
some_podcast_scraper.rb
require 'nokogiri'
require "open-uri"
url = 'https://betweenscreens.fm/'
page = Nokogiri::HTML(open(url))
header = page.at_css("h2.post-title")
title = header.text
puts "This is the raw header of the latest episode: #{header}"
puts "This is the title of the latest episode: #{title}"
โค้ดนี้ครอบคลุมทุกขั้นตอนของเว็บสครัปปิ้ง — แต่ในระดับไมโคร เราตัดสินใจ URL และเว็บที่ต้องการ โหลดเข้า Nokogiri document แล้วเปิดหน้านั้นและ target ส่วนเฉพาะ
ที่นี่ผมอยากรู้ title ตอนล่าสุด ใช้ at_css กับ CSS selector h2.post-title เพื่อระบุจุดดึงข้อมูล เมธอดนี้ดึงเฉพาะ element เดียว ให้ selector ทั้งหมด — ไมใช่สิ่งที่ต้องการ จึงใช้เมธอด text ดึง inner text เท่านั้น ดู output เปรียบเทียบ header และ text ด้านล่าง
Output
This is the raw title of the latest episode: <h2 class="post-title"><a href="episodes/142/">David Heinemeier Hansson</a></h2> This is the title of the latest episode: David Heinemeier Hansson
ตัวอย่างนี้ใช้งานจำกัด แต่มีส่วนผสมทุกอย่างที่ต้องเข้าใจ ง่ายและทรงพลัง ลองดูอะไรทำได้อีก
ข้อควรระวัง!
ถ้าคุณมือใหม่ ไม่แน่ใจจะ target HTML ยังไง แนะนำค้นหาวิธี inspect เนื้อหาเว็บใน browser ทุก browser ทำให้ง่ายมาก วันนี้
Chrome: คลิกขวาที่ element เลือก inspect จะเปิดหน้าต่างเล็กด้านล่างแสดง DOM แบบ X-ray มีออปชันเยอะ แนะนำให้ศึกษาเอง เวลาที่คุ้มค่า!
css
เมธอด css ให้ไม่ใช่แค่ element เดียว แต่อย่างที่ตรงกับเกณฑ์ค้นหาบนหน้านั้น สะดวกและตรงไปตรงมา!
some_scraper.rb
require 'nokogiri'
require "open-uri"
url = 'https://betweenscreens.fm/'
page = Nokogiri::HTML(open(url))
headers = page.css("h2.post-title")
headers.each do |header|
puts "This is the raw title of the latest episode: #{header}"
end
headers.each do |header|
puts "This is the title of the latest episode: #{header.text}"
end
Output
This is the raw title of the latest episode: <h2 class="post-title"><a href="episodes/142/">David Heinemeier Hansson</a></h2> This is the raw title of the latest episode: <h2 class="post-title"><a href="episodes/141/">Zach Holman</a></h2> This is the raw title of the latest episode: <h2 class="post-title"><a href="episodes/140/">Joel Glovier</a></h2> This is the raw title of the latest episode: <h2 class="post-title"><a href="episodes/139/">João Ferreira</a></h2> This is the raw title of the latest episode: <h2 class="post-title"><a href="episodes/138/">Corwin Harrell</a></h2> This is the raw title of the latest episode: <h2 class="post-title"><a href="episodes/137/">Roberto Machado</a></h2> This is the raw title of the latest episode: <h2 class="post-title"><a href="episodes/136/">James Edward Gray II</a></h2> This is the title of the latest episode: David Heinemeier Hansson This is the title of the latest episode: Zach Holman This is the title of the latest episode: Joel Glovier This is the title of the latest episode: João Ferreira This is the title of the latest episode: Corwin Harrell This is the title of the latest episode: Roberto Machado This is the title of the latest episode: James Edward Gray II
ความต่างเล็กน้อย: loop raw headers ก่อน แล้วค่อยดึง inner text ด้วย text Nokogiri หยุดที่ท้ายหน้า ไม่พยายามติดตาม pagination เอง
ลองดูข้อมูลเพิ่ม: วันที่และ subtitle แต่ละตอน แค่ขยายตัวอย่างข้างบน ทำทีละขั้นตอน ได้ชิ้นเล็กทำงาน แล้วค่อยเพิ่มความซับซ้อน
some_scraper.rb
require 'nokogiri'
require "open-uri"
url = 'https://betweenscreens.fm/'
page = Nokogiri::HTML(open(url))
articles = page.css("article.index-article")
articles.each do |article|
header = article.at_css("h2.post-title")
date = article.at_css(".post-date")
subtitle = article.at_css(".topic-list")
puts "This is the raw header: #{header}"
puts "This is the raw date: #{date}"
puts "This is the raw subtitle: #{subtitle}\n\n"
puts "This is the text header: #{header.text}"
puts "This is the text date: #{date.text}"
puts "This is the text subtitle: #{subtitle.text}\n\n"
end
Output
This is the raw header: <h2 class="post-title"><a href="episodes/142/">David Heinemeier Hansson</a></h2> This is the raw date: <span class="post-date">Oct 18 | 2016</span> This is the raw subtitle: <h3 class="topic-list">Rails community | Tone | Technical disagreements | Community policing | Ungratefulness | No assholes allowed | Basecamp | Open source persona | Aspirations | Guarding motivations | Dealing with audiences | Pressure | Honesty | Diverse opinions | Small talk</h3> This is the text header: David Heinemeier Hansson This is the text date: Oct 18 | 2016 This is the text subtitle: Rails community | Tone | Technical disagreements | Community policing | Ungratefulness | No assholes allowed | Basecamp | Open source persona | Aspirations | Guarding motivations | Dealing with audiences | Pressure | Honesty | Diverse opinions | Small talk ... (ตอนอื่นๆ ต่อไป) ...
ตอนนี้มีข้อมูลเล่นได้แล้ว จัดโครงสร้างหรือปรับแต่งยังไงก็ได้ ข้างบนแสดงข้อมูลให้อ่านง่าย ไ่น่นใช้ regex กับ text ก็เจาะลึกได้ จะรายละเอียดตอนแก้ปัญหา podcast จริง ไม่ใช่สอน regexp แต่อาจเห็นใน action — ไม่เยอะเกินไปจนสมองไหล
แอตทริบิวต์ (Attributes)
อาจจะดีถ้าดึง href ของแต่ละตอนด้วย ไมยากเลย
some_scraper.rb
require 'nokogiri'
require "open-uri"
url = 'https://betweenscreens.fm/'
page = Nokogiri::HTML(open(url))
articles = page.css("article.index-article")
articles.each do |article|
header = article.at_css("h2.post-title")
date = article.at_css(".post-date")
subtitle = article.at_css(".topic-list")
link = article.at_css("h2.post-title a")
podcast_url = "https://betweenscreens.fm/"
puts "This is the raw header: #{header}"
puts "This is the raw date: #{date}"
puts "This is the raw subtitle: #{subtitle}"
puts "This is the raw link: #{link}\n\n"
puts "This is the text header: #{header.text}"
puts "This is the text date: #{date.text}"
puts "This is the text subtitle: #{subtitle.text}"
puts "This is the raw link: #{podcast_url}#{link[:href]}\n\n"
end
จุดสำคัญคือ [:href] และ podcast_url ถ้า tag [:] จะดึง attribute จาก selector ได้ ผมแยกตัวแปรเพื่อให้เห็นชัดเจน
... href = article.at_css("h2.post-title a")[:href] ...
เพื่อ URL สมบูรณ์ ใช้ตัวแปร root domain สร้าง full URL
... podcast_url = "https://betweenscreens.fm/"
puts "This is the raw link: #{podcast_url}#{link[:href]}\n\n" ...
Output
This is the raw header: <h2 class="post-title"><a href="episodes/143/">Jason Long</a></h2> This is the raw date: <span class="post-date">Oct 25 | 2016</span> This is the raw subtitle: <h3 class="topic-list">Open source | Empathy | Lower barriers | Learning tool | Design contributions | Git website | Branding | GitHub | Neovim | Tmux | Design love | Knowing audiences | Showing work | Dribbble | Progressions | Ideas</h3> This is the raw link: <a href="episodes/143/">Jason Long</a> This is the text header: Jason Long This is the text date: Oct 25 | 2016 This is the text subtitle: Open source | Empathy | Lower barriers | Learning tool | Design contributions | Git website | Branding | GitHub | Neovim | Tmux | Design love | Knowing audiences | Showing work | Dribbble | Progressions | Ideas This is the href: https://betweenscreens.fm/episodes/143/ ... (ตอนอื่นๆ ต่อไป) ...
สะดวกใช่ไหม? ดึง [:class] ของ selector ได้เหมือนกัน
require 'nokogiri'
require "open-uri"
url = 'https://betweenscreens.fm/'
page = Nokogiri::HTML(open(url))
body_classes = page.at_css("body")[:class]
ถ้าโหนดมีหลาย class จะได้ลิสต์ทั้งหมด
การนำทางโหนด (Node Navigation)
- parent
- children
- previous_sibling
- next_sibling
เราคุ้นเคย tree structures ใน CSS หรือ jQuery จะเสียดายถ้า Nokogiri ไม่มี API สะดวกสำหรับเดินใน tree
some_scraper.rb
require 'nokogiri'
require "open-uri"
url = 'https://betweenscreens.fm/'
page = Nokogiri::HTML(open(url))
header = page.at_css("h2.post-title")
header_children = page.at_css("h2.post-title").children
header_parent = page.at_css("h2.post-title").parent
header_prev_sibling = page.at_css("h2.post-title").previous_sibling
puts "#{header}\n\n"
puts "#{header_children}\n\n"
puts "#{header_parent}\n\n"
puts "#{header_prev_sibling}\n\n"
Output
#header <h2 class="post-title"><a href="episodes/143/">Jason Long</a></h2> #header_children <a href="episodes/143/">Jason Long</a> #header_parent <article class="index-article"> <span class="post-date">Oct 25 | 2016</span><h2 class="post-title"><a href="episodes/143/">Jason Long</a></h2> <h3 class="topic-list">Open source | Empathy | Lower barriers | Learning tool | Design contributions | Git website | Branding | GitHub | Neovim | Tmux | Design love | Knowing audiences | Showing work | Dribbble | Progressions | Ideas</h3> <div class="soundcloud-player-small"> </div> </article> #header_previous_sibling <span class="post-date">Oct 25 | 2016</span>
เห็นเองได้ว่า powerful มาก — โดยเฉพาะ .parent ที่รวบรวมมาหมดในครั้งเดียว แทนที่จะ define โหนดหลายตัวเอง
ยัง chain ได้สำหรับ traversal ซับซ้อน แต่ควรทำให้เรียบง่าย "Keep it simple, stupid!"
... header_parent_parent = page.at_css("h2.post-title").parent.parent
header_prev_sibling_parent_children = page.at_css("h2.post-title").previous_sibling.parent.children ...
some_scraper.rb
require 'nokogiri'
require "open-uri"
url = 'https://betweenscreens.fm/'
page = Nokogiri::HTML(open(url))
header = page.at_css("h2.post-title")
header_prev_sibling_children = page.at_css("h2.post-title").previous_sibling.children
header_parent_parent = page.at_css("h2.post-title").parent.parent
header_prev_sibling_parent = page.at_css("h2.post-title").previous_sibling.parent
header_prev_sibling_parent_children = page.at_css("h2.post-title").previous_sibling.parent.children
puts "#{header}\n\n"
puts "#{header_prev_sibling_children}\n\n"
puts "#{header_parent_parent}\n\n"
puts "#{header_prev_sibling_parent}\n\n"
puts "#{header_prev_sibling_parent_children}\n\n"
Output
#header <h2 class="post-title"><a href="episodes/143/">Jason Long</a></h2> #header_previous_sibling_children Oct 25 | 2016 #header_parent_parent <li> <article class="index-article"> <span class="post-date">Oct 25 | 2016</span><h2 class="post-title"><a href="episodes/143/">Jason Long</a></h2> <h3 class="topic-list">Open source | Empathy | Lower barriers | Learning tool | Design contributions | Git website | Branding | GitHub | Neovim | Tmux | Design love | Knowing audiences | Showing work | Dribbble | Progressions | Ideas</h3> <div class="soundcloud-player-small"> </div> </article> </li> #header_previous_sibling_parent <article class="index-article"> <span class="post-date">Oct 25 | 2016</span><h2 class="post-title"><a href="episodes/143/">Jason Long</a></h2> <h3 class="topic-list">Open source | Empathy | Lower barriers | Learning tool | Design contributions | Git website | Branding | GitHub | Neovim | Tmux | Design love | Knowing audiences | Showing work | Dribbble | Progressions | Ideas</h3> <div class="soundcloud-player-small"> </div> </article> #header_previous_sibling_parent_children <span class="post-date">Oct 25 | 2016</span><h2 class="post-title"><a href="episodes/143/">Jason Long</a></h2> <h3 class="topic-list">Open source | Empathy | Lower barriers | Learning tool | Design contributions | Git website | Branding | GitHub | Neovim | Tmux | Design love | Knowing audiences | Showing work | Dribbble | Progressions | Ideas</h3> <div class="soundcloud-player-small"> </div>
ข้อคิดสุดท้าย
Nokogiri ไม่ได้ใหญ่มาก แต่มีอะไรให้มาก แนะนำลองเล่นกับสิ่งที่เรียนรู้ และขยายความรู้ผ่าน documentation เมื่อชนกำแพง แต่อย่าทำให้ตัวเองปวดหัว!
บทนำเล็กๆ นี้ควรช่วยให้เข้าใจได้ดีว่า ทำอะไรได้บ้าง และทำงานยังไง หวังว่าคุณจะสำรวจต่อเอง และสนุกกับมัน ดังที่คุณจะพบเอง มันคือเครื่องมืออุดมสมบูรณ์ที่ให้มากไม่มีที่สิ้นสุด