在本文中,我將編寫一個小型的網絡爬蟲。我不確定我的網站是否具有良好的頁面標題以及是否存在重複的標題,因此我寫了這個小型工具來查找。

我將從編寫一個接受命令行中的起始頁面的命令開始,並跟隨任何具有原始URL為基礎的鏈接

隨後,我將添加一個可選的標誌,以檢測網站是否具有重複的標題,這對於SEO目的可能很有用。

介紹golang.org/x/net/html

golang.org/x套件是由Go團隊維護的套件,但由於各種原因,它們不是標準庫的一部分。

也許它們太專門了,不會被大多數Go開發人員使用。 也許它們仍在開發中或者實驗中,因此無法包含在stdlib中,stdlib必須遵循Go 1.0的承諾,即不進行不向後兼容的更改-當某些東西進入stdlib時,它就是“最終版”。

其中之一就是golang.org/x/net/html

要安裝它,執行以下命令

go get golang.org/x/net...

在本文中,我將特別使用html.Parse()函數和html.Node結構:

package html

type Node struct {
 Type NodeType
 Data string
 Attr []Attribute
 FirstChild, NextSibling \*node
}

type NodeType int32

const (
 ErrorNode NodeType = iota
 TextNode
 DocumentNode
 ElementNode
 CommentNode
 DoctypeNode
)

type Attribute struct {
 Key, Val string
}

func Parse(r io.Reader) (\*Node, error)

列出網站鏈接和頁面標題

下面的程式首先接受URL,並計算出它找到的唯一鏈接,其輸出如下:

http://localhost:1313/go-filesystem-structure/ -> Go項目的文件系統結構
http://localhost:1313/golang-measure-time/ -> 在Go程序中測量執行時間
http://localhost:1313/go-tutorial-fortune/ -> Go CLI教程:fortune克隆版
http://localhost:1313/go-tutorial-lolcat/ -> 使用Go構建命令行應用程序:lolcat

讓我們從main()開始,因為它顯示了程序的高級概述。

  1. 通過使用 os.Args[1] 從CLI參數中獲取url
  2. 實例化visited,一個具有字符串鍵和字符串值的映射,在其中存儲URL和網站頁面的標題。
  3. 調用analyze()url被兩次傳遞,因為該函數是遞歸的,第二個參數用作遞歸調用的基URL。
  4. 遍歷visited映射,該映射已通過引用傳遞給analyze()並且現在已填充了所有值,因此我們可以打印它們。
package main

import (
 "fmt"
 "net/http"
 "os"
 "strings"

 "golang.org/x/net/html"
)

func main() {
 url := os.Args[1]
 if url == "" {
 fmt.Println("Usage: `webcrawler <url>`")
 os.Exit(1)
 }
 visited := map[string]string{}
 analyze(url, url, &visited)
 for k, v := range visited {
 fmt.Printf("%s -> %s\n", k, v)
 }
}

足夠簡單?讓我們進入analyze()。首先,它調用parse(),它根據指向URL的字符串獲取並解析它,返回一個html.Node指針和一個錯誤。

func parse(url string) (*html.Node, error)

在檢查成功後,analyze()使用pageTitle()獲取頁面標題,pageTitle()掃描一個html.Node的引用,直到找到標題標籤,然後返回其值。

func pageTitle(n *html.Node) string

獲得了頁面標題後,我們可以將其添加到visited映射。

接下來,我們通過調用pageLinks()來獲取所有頁面鏈接,pageLinks()給定起始頁面節點,將遞歸掃描所有頁面節點,並返回一個找到的唯一鏈接列表(無重複)。

func pageLinks(links []string, n *html.Node) []string

獲得鏈接列表後,我們對它們進行迭代,並進行一個小的檢查:如果visited尚未包含該頁面,則意味著我們還沒有訪問該頁面,並且鏈接必須具有baseurl作為前綴。如果這兩個斷言都被證實,我們可以使用鏈接URL調用analyze()

// analyze given a url and a basurl, recoursively scans the page
// following all the links and fills the `visited` map
func analyze(url, baseurl string, visited \*map[string]string) {
 page, err := parse(url)
 if err != nil {
 fmt.Printf("Error getting page %s %s\n", url, err)
 return
 }
 title := pageTitle(page)
 (\*visited)[url] = title

 //recursively find links
 links := pageLinks(nil, page)
 for \_, link := range links {
 if (\*visited)[link] == "" && strings.HasPrefix(link, baseurl) {
 analyze(link, baseurl, visited)
 }
 }
}

pageTitle()使用了我們上面介紹的golang.org/x/net/html API。在第一次迭代中,n<html>節點。我們尋找標題標籤。第一次迭代永遠無法滿足這一點,因此我們首先循環遍歷<html>的第一個子節點,然後是它的兄弟節點,並且我們遞歸地調用pageTitle(),傳遞新的節點。

最終,我們將到達<title>標籤:一個具有Type等於html.ElementNode(參見上面)且Data等於titlehtml.Node實例,並通過訪問其FirstChild.Data屬性返回其內容。

// pageTitle given a reference to a html.Node, scans it until it
// finds the title tag, and returns its value
func pageTitle(n \*html.Node) string {
 var title string
 if n.Type == html.ElementNode && n.Data == "title" {
 return n.FirstChild.Data
 }
 for c := n.FirstChild; c != nil; c = c.NextSibling {
 title = pageTitle(c)
 if title != "" {
 break
 }
 }
 return title
}

pageLinks()pageTitle()沒有太大區別,只是它在找到第一個項目時不停止,而是查找每個鏈接,因此我們必須將links切片作為此遞歸函數的參數傳遞。通過檢查html.Node是否具有html.ElementNode類型,Data必須是a,並且它們必須具有具有KeyhrefAttr,否則可能是一個錨,從而發現鏈接。

// pageLinks will recursively scan a `html.Node` and will return
// a list of links found, with no duplicates
func pageLinks(links []string, n \*html.Node) []string {
 if n.Type == html.ElementNode && n.Data == "a" {
 for \_, a := range n.Attr {
 if a.Key == "href" {
 if !sliceContains(links, a.Val) {
 links = append(links, a.Val)
 }
 }
 }
 }
 for c := n.FirstChild; c != nil; c = c.NextSibling {
 links = pageLinks(links, c)
 }
 return links
}

sliceContains()是一個被pageLinks()調用的實用函數,用於檢查切片中的唯一性。

// sliceContains returns true if `slice` contains `value`
func sliceContains(slice []string, value string) bool {
 for \_, v := range slice {
 if v == value {
 return true
 }
 }
 return false
}

最後一個函數是parse()。它使用http標準庫的功能來獲取URL的內容(http.Get()),然後使用golang.org/x/net/htmlhtml.Parse() API解析HTTP請求的響應體,返回html.Node引用。

// parse given a string pointing to a URL will fetch and parse it
// returning an html.Node pointer
func parse(url string) (\*html.Node, error) {
 r, err := http.Get(url)
 if err != nil {
 return nil, fmt.Errorf("Cannot get page")
 }
 b, err := html.Parse(r.Body)
 if err != nil {
 return nil, fmt.Errorf("Cannot parse page")
 }
 return b, err
}

檢測重複的標題

因為我想要使用命令行標誌來檢查重複,所以我將稍微更改傳遞給程序的URL的方式:不再使用os.Args,而是也使用一個標誌來傳遞URL。

這是修改後的main()函數,在執行常規的準備analyze()執行和值打印之前,進行標誌解析。此外,在最後還檢查了dup布爾標誌,如果為true,則運行checkDuplicates()

import (
 "flag"
//...
)


func main() {
 var url string
 var dup bool
 flag.StringVar(&url, "url", "", "the url to parse")
 flag.BoolVar(&dup, "dup", false, "if set, check for duplicates")
 flag.Parse()

 if url == "" {
 flag.PrintDefaults()
 os.Exit(1)
 }

 visited := map[string]string{}
 analyze(url, url, &visited)
 for link, title := range visited {
 fmt.Printf("%s -> %s\n", link, title)
 }

 if dup {
 checkDuplicates(&visited)
 }
}

checkDuplicates()接受url -> titles的映射並對其進行迭代,以構建自己的uniques映射,這次將頁面標題作為鍵,因此我們可以檢查 uniques[title] == "" 來確定標題是否已經存在,並且我們可以通過打印uniques[title]來訪問輸入該標題的第一個頁面。

// checkDuplicates scans the visited map for pages with duplicate titles
// and writes a report
func checkDuplicates(visited \*map[string]string) {
 found := false
 uniques := map[string]string{}
 fmt.Printf("\nChecking duplicates..\n")
 for link, title := range \*visited {
 if uniques[title] == "" {
 uniques[title] = link
 } else {
 found = true
 fmt.Printf("Duplicate title \"%s\" in %s but already found in %s\n", title, link, uniques[title])
 }
 }

 if !found {
 fmt.Println("No duplicates were found 😇")
 }
}

Credits

The Go Programming Language Donovan and Kernighan書籍在整本書中都以網絡爬蟲作為示例,並在不同章節中進行變更以引入新概念。 本文提供的代碼受該書啟發。