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 filesitemap.xml
đầy đủ và chi tiết cho mình dùng rồi. Với trường hợp không có filesitemap.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ònCompanies
là danh sách cácCompany
trong đó. Ngoài ra, sử dụngNewCompanies()
để khởi tạo một biếnCompanies
trả về địa chỉ của biến đấy. Trong đó sử dụng thêmtags
ở 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ínhclass=company-info-section
. Trong đó thì lấy giá trị Text của các thẻdiv
có thuộc tínhclass=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 structCompany
ở phía trên. Với các kết quảCompany
, thêm nó vào struct listCompanies
để 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.
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,..
- Source code (Github): go-crawl-business