Viết crawler dữ liệu đơn giản với Golang

Viết crawler dữ liệu đơn giản với Golang

Có rất nhiều công cụ và thư viện hỗ trợ crawl dữ liệu hiện nay như scrapy, beautifulSoup của python, colly của golang. Tuy nhiên trong bài viết này, mình sẽ sử dụng goquery trong Golang để crawl lượng dữ liệu doanh nghiệp lớn và nhanh chóng từ trang infodoanhnghiep[.]com để khám phá tốc độ xử lý của Golang.

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

  • goquery: goquery có cú pháp và tập hợp các tính năng tương tự như jQuery của Go. Nó được viết dựa trên gói net/html và thư viện CSS Selector cascadia dùng để bóc tách các phần tử HTML và trích xuất dữ liệu từ các phần tử đó nhanh chóng.
  • encoding/xml: Package này dùng để đọc file xml, thường được dùng trong việc crawl dữ liệu các website đã có sẵn file sitemap.xml (file này cần thiết trong việc giúp google bot crawl đánh index cho site của bạn). Với website mình crawl trong bài này thì nó đã có sẵn file sitemap.xml đầy đủ và chi tiết cho mình dùng rồi. Với trường hợp không có file sitemap.xml thì ta có thể sử dụng công cụ hỗ trợ sinh file sitemap như xml-sitemaps[.]com, xmlsitemapgenerator[.]org
  • errgroup: errGroup được sử dụng khi có nhiều goroutines chạy đồng thời, nó sẽ chia goroutines thành các group để thực hiện các phần nhỏ công việc trong 1 công việc chung. Gói errGroup cung cấp cơ chế đồng bộ hoá, truyền lỗi và cancel context rất hiệu quả.
  • semaphore: Một giải pháp giúp quản lý chặt chẽ các goroutines đang xử lý các nhiệm vụ nhỏ trong công việc chung. Semaphore có cơ chế tương tự worker pool (Ví dụ có 8 công nhân cùng phải giải quyết 100 công việc, thì các công nhân sẽ tự nhận công việc chưa có ai xử lý để làm, làm xong thì sẽ kiểm tra task xem công việc nào chưa ai làm để làm tiếp; 8 công nhân cứ lần lượt làm như thế đến khi nào hết việc thì thôi)
  • Ngoài một số package chính trên. Mình còn cần đến vài package khác như fake-useragent, mapstructure, encoding/json, context,...

Cài đặt thư viện

Có một vài cách để cài đặt thư viện trong Go. Ta có thể sử dụng go get từng thư viện hoặc sử dụng thư viện trước trong code rồi dùng go mod tidy để hệ thống tự cài các thư viện cần thiết cho mình. Tuy nhiên, cần lưu ý về Go Modules để tránh lỗi phát sinh khi sử dụng Modules internal và external.

Cài đặt các thư viện cần thiết

go get github.com/PuerkitoBio/goquery
go get golang.org/x/sync/errgroup
golang.org/x/sync/semaphore

Xử lý file xml

Do trang mục tiêu nhắm đến có sẵn file sitemap.xml nên mình sẽ triển khai theo hướng khai thác này. Chúng ta sẽ lấy các URL có trong sitemap để sử dụng ở các bước tiếp theo. File sitemap.xml thường có định dạng như sau:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    <url>
        <loc>https://infodoanhnghiep.com/thong-tin/Cong-Ty-TNHH-Vng-Online-80708.html</loc>
    </url>
    <url>
        <loc>https://infodoanhnghiep.com/thong-tin/Cong-Ty-TNHH-Kien-Truc-G2k-54468.html</loc>
    </url>
    <url>
        <loc>https://infodoanhnghiep.com/thong-tin/Cong-Ty-Co-Phan-Khoa-Hoc-Va-Moi-Truong-Hai-Phong-Xanh-54482.html</loc>
    </url>
</urlset>

Dựa theo định dạng của sitemap.xml thì chúng ta sẽ viết một struct Urlset tương ứng để bóc tách URL từ đó, trong đó Urls là một slice gồm các url đã được lọc ra:

package processXml

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

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(sitekey int) (urlSet Urlset) {
    // đường dẫn đến file xml
	sitemap := "processxml/sitemap/sitemap-" + strconv.Itoa(sitekey) + ".xml"
	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
}

Lấy thông tin về dữ liệu doanh nghiệp trong từng trang

Đối với từng URL đã thu thập được từ bước trước, mình sẽ tiến hành lấy tất cả những thông tin cần thiết trong từng trang. Với trường hợp ở đây là dữ liệu của từng doanh nghiệp.

  • Viết một struct tương ứng với dữ liệu mà mình sẽ lấy, trong đó Company là dữ liệu của từng doanh nghiệp cụ thể, còn Companies là danh sách các Company trong đó. Ngoài ra, sử dụng NewCompanies() để khởi tạo một biến Companies trả về địa chỉ của biến đấy. Trong đó sử dụng thêm tags ở cuối các thuộc tính để parsing sang json sau này.
type Company struct {
	Name                 string `json:"mame"`
	AlternateName        string `json:"alternate_name"`
	TaxId                string `json:"tax_id"`
	Status               string `json:"status"`
	TaxRegisterAddress   string `json:"tax_register_address"`
	Address              string `json:"address"`
	Phone                string `json:"phone"`
	Founder              string `json:"founder"`
	President            string `json:"president"`
	LicenseDate          string `json:"license_date"`
	StartDate            string `json:"start_date"`
	ReceiveAccountDate   string `json:"receive_account_date"`
	FinancialYear        string `json:"financial_year"`
	Employees            string `json:"employees"`
	LevelChapterItemType string `json:"level_chapter_item_type"`
	BankAccount          string `json:"bank_account"`
}

type Companies struct {
	TotalCompanies int       `json:"total_companies"`
	List           []Company `json:"companies"`
}

func NewCompanies() *Companies {
	return &Companies{}
}
  • Tiếp theo lấy dữ liệu doanh nghiệp để đổ vào struct Companies. Ta sẽ sử dụng goquery. Xác định thẻ html mà mình cần để lấy dữ liệu từ đó. Với trường hợp ở đây mình sẽ lấy là div có thuộc tính class=company-info-section. Trong đó thì lấy giá trị Text của các thẻ div có thuộc tính class=responsive-table-cell.
var s string
doc.Find("div.company-info-section").First().Each(func(i int, sectionHtml *goquery.Selection) {
	sectionHtml.Find("div.responsive-table-cell").Each(func(i int, cellHtml *goquery.Selection) {
		s += strings.TrimSpace(cellHtml.Text()) + "\n"
	})
})
  • Với kết quả Text đã lấy được, mình sẽ đưa nó sang dạng map để có thể đưa vào struct Company ở phía trên. Với các kết quả Company, thêm nó vào struct list Companies để dễ dàng quản lý:
s = formatField(s)
x := strings.Split(s, "\n")
y := make(map[string]string)
for i := 0; i < len(x)-1; i += 2 {
	y[x[i]] = x[i+1]
}
Company := Company{}
mapstructure.Decode(y, &Company)

companies.TotalCompanies++
companies.List = append(companies.List, Company)

Lấy dữ liệu trong tất cả các trang

Để lấy dữ liệu của tất cả các trang, ta chỉ cần chạy qua các url đã parse từ file xml trước đó. Sử dụng goroutines để xử lý đa luồng tăng tốc độ của chương trình.

client := &http.Client{}
companies := utilities.NewCompanies()
urlSet := processXml.ReadSiteMap(siteIndex)
for _, i := range urlSet.Urls {
	group.Go(func() error {
		defer sem.Release(1)
		// work
		err := companies.ExtractInfomation(a, client)
		checkError(err)
		return nil
	})
}

if err := group.Wait(); err != nil {
	fmt.Printf("g.Wait() err = %+v\n", err)
}

Tuy nhiên, trường hợp này chỉ sử dụng với lượng dữ liệu crawl nhỏ, nếu chạy đồng thời với lượng dữ liệu lớn sẽ rất khó kiểm soát các goroutines (dễ xảy ra lỗi như tốn kém bộ nhớ, mất mát dữ liệu, bị deadlock,..). Mặc dù goroutines trong Golang rất nhẹ (chỉ khoảng 2Kb trong stack).

Tối ưu hoá goroutines

Để tối ưu hoá goroutines, ta cần sử dụng semaphore trong Golang.

companies := utilities.NewCompanies()
urlSet := processXml.ReadSiteMap(siteIndex)

sem := semaphore.NewWeighted(int64(runtime.NumCPU()))
group, ctx := errgroup.WithContext(context.Background())
for _, i := range urlSet.Urls {
	err := sem.Acquire(ctx, 1)
	if err != nil {
		fmt.Printf("Acquire err = %+v\n", err)
		continue
	}
	group.Go(func() error {
		defer sem.Release(1)
		// work
		err := companies.ExtractInfomation(a, client)
		checkError(err)
		return nil
	})
}

if err := group.Wait(); err != nil {
	fmt.Printf("g.Wait() err = %+v\n", err)
}

Dòng sem := semaphore.NewWeighted(int64(runtime.NumCPU())) khởi tạo semaphore theo số luồng CPU (máy mình là 16 luồng) và bạn có thể thay đổi theo ý muốn (như 4 * int64(runtime.NumCPU())). Các sem.Acquire hoạt động như một khóa và chặn các goroutines. sem.Release mở khóa để các goroutines có thể tiếp tục công việc của mình.

Với cách này sẽ chỉ có 16 goroutines chạy đồng thời tương ứng với crawler đồng thời 16 page, khi hoàn thành crawler 16 page đó thì 16 page sau tiếp tục được crawler đồng thời cho đến khi crawler hết số lượng page.

Việc sử dụng goroutines sẽ tối ưu được rất nhiều thời gian chạy code của bạn. Nếu không sử dụng goroutines thì time để chạy hết 50k URL khoảng 1h25p, còn sử dụng goroutines thì time chỉ còn hơn 8 phút.
Screenshot-2022-06-15-095320

Ghi kết quả vào file json

Bởi vì mình đã thêm tags cho struct để phục vụ việc parsing từ struct sang json, vì vậy nên để convert từ struct Companies sang json rất dễ dàng, chỉ cần sử dụng phương thức Marshal.

companiesJson, err := json.Marshal(companies)
checkError(err)

Nhận xét

  • Trên đây là 1 ví dụ đơn giản để crawl data sử dụng goquery của Golang và cách áp dụng goroutines.
  • Đối với các website không có sẵn file sitemap.xml, các bạn cần phải sử dụng các công cụ bên thứ 3 để tạo sitemap. Hoặc có thể tự crawl các trang cần thiết trên website đó.
  • Việc crawl một website bằng cách đọc HTML thuần có thể không hoạt động đúng trong trường hợp: website load bằng ajax, website sử dụng cloudflare để ngăn crawler,..
  • Website có thể áp dụng các biện pháp chống crawler như cài đặt firewall ở trên cloudflare, chặn hoặc hạn chế các IP truy vấn liên tục, sử dụng captcha,.. Khi đó các bạn cần phải đổi IP liên tục, random user-agent, fake bot crawler của các search engine,..

Subscribe to DEV2SEC

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe