+13

Crawl dữ liệu với golang

Vấn đề crawl dữ liệu thì cũng có khá nhiều công cụ và thư viện hỗ trợ làm việc này. Nhưng hôm nay mình xin mạn phép hướng dẫn crawl dữ liệu bằng một ngôn ngữ lập trình khá nổi trong thời gian gần đây - Golang. Vì hiệu năng của nó cực tốt, và càng phù hợp hơn khi chúng ta cần crawl một lượng dữ liệu lớn và gửi đi một lượng request liên tục thì golang - với khả năng tính toán song song của mình xem ra là rất phù hợp...

Một số package cần sử dụng

  • Gocolly - là một package viết bằng ngôn ngữ lập trình Golang, hỗ trợ crawl dữ liệu tương đối nổi tiếng (dựa trên lõi một package nổi tiếng khác là Goquery). Nhìn chung mình chọn nó vì nó đơn giản dễ sử dụng, lại vừa đủ dùng. Bạn có thể xem qua một chút trên trang github của package này

  • Go MySQL Driver - Package để chúng ta làm việc với Mysql, bởi lẽ mình cần crawl dữ liệu về và lưu vào database để dùng mà. Bạn có thể cần xem qua package này tại đây

  • Flag - Flag thì đã quá nổi tiếng rồi, pack này hỗ trợ chúng ta trong việc xây dựng cái CLI tool với các tham số truyền vào, ví dụ như crawl --target=medium.com chẳng hạn. Thì flag hỗ trợ chúng ta tạo và paser các tham số --target=medium.com kiểu này. Và khi gõ --help thì nó show luôn thông tin tham số xem nó có kiểu dữ liệu gì. Tham số đó để làm gì... blala tương tự như những gì chúng ta thấy khi gõ zip --hepl hay composer --help..v.v ấy

  • encoding/xml Package này dùng để đọc file xml. Mình gặp may một chút, cái site mình nhắm đến để crawl dữ liệu có sẵn file sitemap.xml (file này tương đối cần thiết cho việc google bot crawl và đánh index site của bạn, nhiều site chu đáo, họ có sẵn file này) nên mình đỡ được bao nhiêu công đoạn. Lấy được file sitemap.xml này về là tóm hầu hết url hiện có của site. Mình chỉ cần đọc file này ra, đưa vào mảng và quét qua hết url trong file để crawl dữ liệu từng trang là được. Kaka

    Nếu không thì cũng đầy công cụ hỗ trợ sinh file sitemap kiểu này. Ví dụ như https://www.xml-sitemaps.com/, https://xmlsitemapgenerator.org/...

  • Ngoài một số package chính trên. Mình còn cần đến vài package khác như crypto/md5, encoding/hex, regexpnet/http...

Xử lý file xml

Như đã nói ở trên. Do trang mục tiêu mình nhắm đến có sẵn file sitemap.xml, nên mình sẽ triển khai theo hướng khai thác file này. Một file sitemap.xml thường có định dạng như một ví dụ dưới đây

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<!-- Generated by Web-Site-Map.com -->
<url><loc>https://www.dkn.tv/the-gioi/ty-le-nguoi-my-ung-ho-xay-tuong-bien-gioi-cao-nhat-moi-thoi-dai.html</loc><priority>1.00</priority></url>
<url><loc>https://www.dkn.tv/the-gioi/bao-nuoc-ngoai-vi-sao-du-khach-han-quoc-den-viet-nam-tang-manh.html</loc><priority>0.85</priority></url>
<url><loc>https://www.dkn.tv/the-gioi/dong-ho-go-lon-nhat-the-gioi-sau-15-nam-sang-tao-da-tim-duoc-noi-trung-bay.html</loc><priority>0.85</priority></url>
<url><loc>https://www.dkn.tv/the-gioi/nguoi-goc-viet-lam-thien-nguyen-de-tra-on-nuoc-my.html</loc><priority>0.85</priority></url>
<url><loc>https://www.dkn.tv/tin-giai-tri/vang-duy-manh-doi-tuyen-viet-nam-dung-hang-thu-nao-dau-yemen.html/loc><priority>0.85</priority></url>
<url><loc>https://www.dkn.tv/trong-nuoc/nhau-dem-o-phong-tro-4-thanh-nien-bi-nhom-nguoi-la-chem-guc.html</loc><priority>0.85</priority></url>
</urlset>

Và đây là phần code xử lý đọc file sitemap.xml trên và trả về một struct Urlset trong đó field Urls là một slice gồm các chuỗi url đã được bóc ra để tiện lặp qua slice này và quét các url trong đó

package processXml

import (
	"encoding/xml"
	"io/ioutil"
	"os"
	"fmt"
)

type Urlset struct {
	XMLUrlSet xml.Name `xml:"urlset"`
	Urls   []Url   `xml:"url"`	
}

type Url struct {
	Url xml.Name `xml:"url"`
	Loc string `xml:"loc"`
}

func ReadSiteMap(sitemap string) (urlSet Urlset){
	xmlFile, err := os.Open(sitemap)
	if err != nil {
		fmt.Println(err)
	}

	fmt.Println("Successfully Opened users.xml")
	defer xmlFile.Close()
	byteValue, _ := ioutil.ReadAll(xmlFile)
	xml.Unmarshal(byteValue, &urlSet)

	return 
}

Connect Mysql

Theo thói quen mình thường tách phần connect db ra như dưới đây

package database 

import (
	"database/sql"
	_ "github.com/go-sql-driver/mysql"
)

func DBConn() (db *sql.DB) {
    dbDriver := "mysql"
    dbUser := "root"
    dbPass := "secret"
    dbName := "viblo-crawler-example"
    db, err := sql.Open(dbDriver, dbUser + ":" + dbPass + "@tcp(localhost:3306)/" + dbName)
    if err != nil {
        panic(err.Error())
    }
    return db
}

Bóc element

Bạn có thể xem qua rất nhiều ví dụ về việc sử dụng gocolly ở đây . Việc quan trọng là cần xác định được selector để chọn đúng đến phần tử html mà chúng ta cần lấy content trong đó - Cái này thì chỉ cần inspect element xong chuột phải rồi chọn copy selector là được, chỉ cần để ý lại chút xíu select cho chính xác nếu cần. Dưới đây là một đoạn code ví dụ kết hợp với file xml trên:

package main

import (
	"crypto/md5"
	"database/sql"
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
	"os"
	"regexp"
	"./database"
	"./processXml"
	"github.com/gocolly/colly"
	"github.com/gosimple/slug"
)

func visitLink(urlSet processXml.Urlset, db *sql.DB) {
	for i := 0; i < len(urlSet.Urls); i++ {
		c := colly.NewCollector()
		c.OnHTML("#the-post", func(e *colly.HTMLElement) {
        
			// Lấy tiêu đề bài viết
			title := e.ChildText("#the-post > div.post-head > h1")

			// Lấy thuộc tích src của ảnh đại diện bài viết dùng để download ảnh
			avataUrl := e.ChildAttr("#the-post > div.post-head > figure > div > img", "src")

			// Tải ảnh avata
			avata := downloadImage(avataUrl, title)
            
            // Lấy content bài viết
			content := ""
			e.ForEach("#the-post-content > p", func(_ int, m *colly.HTMLElement) {
				contentOrigin := regexp.MustCompile(`\n`)
				contentConverted := contentOrigin.ReplaceAllString(m.Text, "<br/>")
				content += "<p>" + contentConverted + "</p>"
			})

			// Tạo slug name dựa trên tiêu đề bài viết
			slugName := slug.Make(title)

			// Lưu dữ liệu bài viết vào DB
			insPost, err := db.Prepare("INSERT INTO dkn.posts (title, avata, content, slug) VALUES(?, ?, ?, ?)")
			handleError(err)
			insPost.Exec(title, "img/"+ avata + ".jpg", content, slugName)
			fmt.Printf("Inserted: %q\n", content)
			
		})

		c.OnRequest(func(r *colly.Request) {
			fmt.Println("Crawling... ", r.URL)
		})

		c.Visit(urlSet.Urls[i].Loc)
	}
}

func main() {
	db := database.DBConn()
	defer db.Close()
	links := processXml.ReadSiteMap("sitemaptest.xml")
	visitLink(links, db)
}


func handleError(e error) {
	if e != nil {
		panic(e)
	}
}

// Tải luôn hình đại diện bài viết

func downloadImage(src, title string) (image string){
	// Tạo hasname cho ảnh tránh bị trùng lặp
	md5HashInBytes := md5.Sum([]byte(title))
	image = hex.EncodeToString(md5HashInBytes[:])
	img, _ := os.Create("img/" + image + ".jpg")
	defer img.Close()

	resp, _ := http.Get(src)
	defer resp.Body.Close()

	b, _ := io.Copy(img, resp.Body)
	fmt.Println("Saved image ! Size: ", b)
	
	return 
}

Và kết quả thu được như dưới đây

Kết luận

Qua ví dụ nhỏ trên đây, chỉ với vài đoạn code nhỏ chúng ta đã có thể dễ dàng thu thập dữ liệu cần thiết về. Tất nhiên nếu dữ liệu trên trang mục tiêu có cấu trúc html phức tạp hơn, chúng ta cần nhiều phần dữ liệu hơn thì đòi hỏi logic trong code phải phức tạp hơn nữa. Trên đây chỉ là ví dụ ở mức đơn giản nhất thôi ^^
Mặc dù hiệu năng phụ thuộc nhiều vào tốcmạng của chúng ta nữa. Nhưng sử dụng go mang đến tốc độ rất tốt về khả năng xử lý song song cùng lúc các url. Một lợi thế nữa là có thể dễ dàng build đoạn code trên thanh một tool CLI rồi chuyển qua chạy ở các môi trường khác nhau mà không đòi hỏi người dùng cài cắm gì cả...

Bài viết hơi nhiều code, mong bạn thông cảm nhé (bow) Cảm ơn bạn đã đọc bài viết của mình ^^


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.