用Golang写爬虫 使用goquery

用Golang写爬虫 使用goquery

用Golang写爬虫(一)

16c5666d84f1d51etplv-t2oaga2asx-no-mark100100100100.awebp_-1

·  阅读 3617

16bd6c5cd299f4dbtplv-t2oaga2asx-zoom-crop-mark3024302430241702.awebp_

之前一直都是再用Python写爬虫,最近想体验下Golang写爬虫的感觉,所以就有了这个系列。我想要抓取的页面是豆瓣Top250页面,选择它的理由有3个:

  1. 豆瓣页面代码相对规范
  2. 豆瓣对爬虫爱好者相对更宽容
  3. Top250页面简洁,很适合拿来练手

我们先看第一版的代码。

按逻辑我把抓取代码分成2个部分:

  1. HTTP请求
  2. 解析页面中的内容

我们先看HTTP请求,Golang语言的HTTP请求库不需要使用第三方的库,标准库就内置了足够好的支持:

import (
	"fmt"
	"net/http"
	"io/ioutil"
)

func fetch (url string) string {
	fmt.Println("Fetch Url", url)
	client := &http.Client{}
	req, _ := http.NewRequest("GET", url, nil)
	req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)")
	resp, err := client.Do(req)
	if err != nil {
		fmt.Println("Http get err:", err)
        return ""
	}
	if resp.StatusCode != 200 {
		fmt.Println("Http status code:", resp.StatusCode)
		return ""
	}
	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		fmt.Println("Read error", err)
		return ""
	}
	return string(body)
}
复制代码

我把URL请求的逻辑都放在了fetch函数中,里面做了一些异常处理。值得说的有2点:

  1. 在Header中设置了User-Agent,让访问看起来更像搜索引擎Bot。如果一个网站希望自己的内容被Google收录那么他就不会拒绝这样的UA的访问。
  2. 需要通过ioutil.ReadAll 读取resp的body内容,最后用string(body)把它转化成字符串

接着就是解析页面的部分:

import (
    "regexp"
	"strings"
)

func parseUrls(url string) {
	body := fetch(url)
	body = strings.Replace(body, "\n", "", -1)
	rp := regexp.MustCompile(`<div class="hd">(.*?)</div>`)
	titleRe := regexp.MustCompile(`<span class="title">(.*?)</span>`)
	idRe := regexp.MustCompile(`<a href="https://movie.douban.com/subject/(\d+)/"`)
	items := rp.FindAllStringSubmatch(body, -1)
	for _, item := range items {
		fmt.Println(idRe.FindStringSubmatch(item[1])[1],
			titleRe.FindStringSubmatch(item[1])[1])
	}
}
复制代码

这篇文章我们主要体验用标准库完成页面的解析,也就是用正则表达式包regexp来完成。不过要注意需要用strings.Replace(body, "\n", "", -1)这步把body内容中的回车符去掉,要不然下面的正则表达式.*就不符合了。FindAllStringSubmatch方法会把符合正则表达式的结果都解析出来(一个列表),而FindStringSubmatch是找第一个符合的结果。

Top250页面是要翻页的,最后在main函数里面实现抓取全部Top250页面。另外为了和之后的改进做对比,我们加上代码运行耗时的逻辑:

import (
       "time"
       "strconv"
)
func main() {
        start := time.Now()
        for i := 0; i < 10; i++ {
                parseUrls("https://movie.douban.com/top250?start=" + strconv.Itoa(25 * i))
        }
        elapsed := time.Since(start)
        fmt.Printf("Took %s", elapsed)
}
复制代码

在Golang中把数字转成字符串需要使用strconv.Itoa(嘿嘿,本博客域名就是这个模块),这样就可以根据start的参数的不通拼出正确的页面路径。用一个for循环完成翻页。

运行起来非常快:

❯ go run crawler/doubanCrawler1.go
... # 省略输出
Took 1.454627547s
复制代码

通过终端输出可以看到我们拿到了对应电影条目的ID和电影标题!

代码地址

完整代码可以在这个地址找到。

原文

用Golang写爬虫(二) – 并发

16c5666d84f1d51etplv-t2oaga2asx-no-mark100100100100.awebp_-2

·  阅读 3265

16bde48b9a26a879tplv-t2oaga2asx-zoom-crop-mark3024302430241702.awebp_

原文链接: strconv.com/posts/web-c…

在上篇文章里面我用Go写了一个爬虫,但是它的执行是串行的,效率很低,这篇文章把它改成并发的。由于这个程序只抓取10个页面,大概1s多就完成了,为了对比我们先给之前的doubanCrawler1.go加一点Sleep的代码,让它跑的「慢」些:

func parseUrls(url string) {
    ...
	time.Sleep(2 * time.Second)
}
```go

这样运行起来大体可以计算出来程序跑完约需要21s+,我们运行一下试试:

```bash
❯ go run doubanCrawler2.go
...
Took 21.315744555s
复制代码

已经很慢了。接着我们开始让它变得更快~

goroutine的错误用法

先修改成用Go原生支持的并发方案goroutine来做。在Golang中使用goroutine非常方便,直接使用Go关键字就可以,我们看一个版本:

func main() {
	start := time.Now()
	for i := 0; i < 10; i++ {
		go parseUrls("https://movie.douban.com/top250?start=" + strconv.Itoa(25*i))
	}
	elapsed := time.Since(start)
	fmt.Printf("Took %s", elapsed)
}
复制代码

就是在parseUrls函数前加了go关键字。但其实这样就是不对的,运行的话不会抓取到任何结果。因为协程刚生成,整个程序就结束了,goroutine还没抓完呢。怎么办呢?可以结束前Sleep一个时间,这个时间应该要大于所有goroutine执行最慢的那个,这样就保证了全部协程都能正常运行完再结束(doubanCrawler3.go):

func main() {
    start := time.Now()
    for i := 0; i < 10; i++ {
        go parseUrls("https://movie.douban.com/top250?start=" + strconv.Itoa(25*i))
    }
    time.Sleep(4 * time.Second)
    elapsed := time.Since(start)
    fmt.Printf("Took %s", elapsed)
}
复制代码

在for循环后加了Sleep 4秒。当然这个Sleep的时间不要控制,假设某次请求花的时间超了,让总体时间超过4s就看到结果程序结束了,假如全部goroutine都在3秒(2秒固定Sleep+1秒程序运行)结束,那么多Sleep的一秒就浪费了!运行一下:

go run doubanCrawler3.go
...
Took 4.000849896s  # 这个时间大致就是4s
复制代码

goroutine的正确用法

那怎么用goroutine呢?有没有像Python多进程/线程的那种等待子进/线程执行完的join方法呢?当然是有的,可以让Go 协程之间信道(channel)进行通信:从一端发送数据,另一端接收数据,信道需要发送和接收配对,否则会被阻塞:

func parseUrls(url string, ch chan bool) {
    ...
    ch <- true
}

func main() {
    start := time.Now()
    ch := make(chan bool)
    for i := 0; i < 10; i++ {
        go parseUrls("https://movie.douban.com/top250?start="+strconv.Itoa(25*i), ch)
    }

    for i := 0; i < 10; i++ {
        <-ch
    }

    elapsed := time.Since(start)
    fmt.Printf("Took %s", elapsed)
}
复制代码

在上面的改法中,parseUrls都是在goroutine中执行,但是注意函数签名改了,多接收了信道参数ch。当函数逻辑执行结束会给信道ch发送一个布尔值。

而在main函数中,在用一个for循环,<- ch 会等待接收数据(这里只是接收,相当于确认任务完成)。这样的流程就实现了一个更好的并发方案:

go run doubanCrawler4.go
...
Took 2.450826901s  # 这个时间比之前的写死了4s的那个优化太多了!
复制代码

sync.WaitGroup

还有一个好的方案sync.WaitGroup。我们这个程序只是打印抓到到的对应内容,所以正好用WaitGroup:等待一组并发操作完成:

import (
	...
	"sync"
)
...
func main() {
	start := time.Now()
	var wg sync.WaitGroup
	wg.Add(10)

	for i := 0; i < 10; i++ {
		go func() {
			defer wg.Done()
			parseUrls("https://movie.douban.com/top250?start="+strconv.Itoa(25*i))
		}()
	}

	wg.Wait()

	elapsed := time.Since(start)
	fmt.Printf("Took %s", elapsed)
}
复制代码

一开始我们给调用wg.Add添加要等待的goroutine量,我们的页面总数就是10,所以这里可以直接写出来。

另外这里使用了defer关键字来调用wg.Done,以确保在退出goroutine的闭包之前,向WaitGroup表明了我们已经退出。由于要执行wg.Done和parseUrls2件事,所以不能直接用go关键字,需要把语句包一下。

(感谢 @bhblinux 指出)不过要注意,在闭包中需要把参数i作为func的参数传入,要不然i会使用最后一次循环的那个值:

// 错误代码1f447.svg_
for i := 0; i < 10; i++ {
    go func() {
        defer wg.Done()
        parseUrls("https://movie.douban.com/top250?start="+strconv.Itoa(25*i))
    }()
}
❯ go run crawler/doubanCrawler5.go
Fetch Url https://movie.douban.com/top250?start=75
Fetch Url https://movie.douban.com/top250?start=250
Fetch Url https://movie.douban.com/top250?start=250
Fetch Url https://movie.douban.com/top250?start=250
Fetch Url https://movie.douban.com/top250?start=250
Fetch Url https://movie.douban.com/top250?start=250
Fetch Url https://movie.douban.com/top250?start=250
Fetch Url https://movie.douban.com/top250?start=250
Fetch Url https://movie.douban.com/top250?start=250
Fetch Url https://movie.douban.com/top250?start=200
...
复制代码

咦,看代码,i在等于9的时候循环结束,start应该是225(9 * 25),但为什么250呢?这是因为在最后还有个i++,虽然不符合条件没有进行循环,但是i的值确实发生了改变!

在这样的用法中,WaitGroup相当于是一个协程安全的并发计数器:调用Add增加计数,调用Done减少计数。调用Wait会阻塞并等待至计数器归零。这样也实现了并发和等待全部goroutine执行完成:

go run doubanCrawler5.go
...
Took 2.382876529s  # 这个时间和之前的信道用法效果一致!
复制代码

后记

好啦,这篇文章先写到这里啦~

代码地址

完整代码都可以在这个地址找到。

用Golang写爬虫(三) – 使用goquery

16c5666d84f1d51etplv-t2oaga2asx-no-mark100100100100.awebp_

·  阅读 3820

16bee0e2781db2d9tplv-t2oaga2asx-zoom-crop-mark3024302430241702.awebp_

在写爬虫的时候,想要对HTML内容进行选择和查找匹配时通常是不直接写正则表达式的:因为正则表达式可读性和可维护性比较差。用Python写爬虫这方面可选择的方案非常多了,其中有一个被开发者常用的库pyquery,而Golang也有对应的goquery,可以说goquery是jQuery的Golang版本实现。借用jQueryCSS选择器的语法可以非常方面的实现内容匹配和查找。

安装goquery

goquery是第三方库,需要手动安装:

❯ go get github.com/PuerkitoBio/goquery
复制代码

创建文档

goquery向外暴露的结构主要是goquery.Document,一般是由2种方法创建的:

doc, error := goquery.NewDocumentFromReader(reader io.Reader)
doc, error := goquery.NewDocument(url string)
复制代码

第二种直接传入了url,但是往往我们会对请求做很多定制(如添加头信息、设置Cookie等),所以常用的是第一种方法,我们的代码也要做对应的改动:

import (
    "fmt"
    "log"
    "net/http"
    "strconv"
    "time"

    "github.com/PuerkitoBio/goquery"
)

func fetch(url string) *goquery.Document {
    ...
    defer resp.Body.Close()
    doc, err := goquery.NewDocumentFromReader(res.Body)
    if err != nil {
        log.Fatal(err)
    }
    return doc
复制代码

原来是把res.Body转成字符返回,现在直接返回goquery.Document类型的doc了

CSS选择器

这篇文章不会具体介绍选择器的语法,如果你还不了解可以直接看文末的延伸阅读链接一。

我们先看看豆瓣电影Top250单个条目的部分相关的HTML代码:

<ol class="grid_view">
  <li>
    <div class="item">
      <div class="info">
        <div class="hd">
          <a href="https://movie.douban.com/subject/1292052/" class="">
            <span class="title">肖申克的救赎</span>
            <span class="title">&nbsp;/&nbsp;The Shawshank Redemption</span>
            <span class="other">&nbsp;/&nbsp;月黑高飞(港)  /  刺激1995(台)</span>
          </a>
          <span class="playable">[可播放]</span>
        </div>
      </div>
    </div>
  </li>
  ....
</ol>
复制代码

还是原来的需求:获得条目ID和标题。这次需要把parseUrls的逻辑改成使用goquery的版本:

func parseUrls(url string, ch chan bool) {
    doc := fetch(url)
    doc.Find("ol.grid_view li").Find(".hd").Each(func(index int, ele *goquery.Selection) {
        movieUrl, _ := ele.Find("a").Attr("href")
        fmt.Println(strings.Split(movieUrl, "/")[4], ele.Find(".title").Eq(0).Text())
    })
    time.Sleep(2 * time.Second)
    ch <- true
}
复制代码

doc.Find的参数就是css选择器,而且Find支持链式调用。这里的意思就是先找雷鸣是”grid_view”的所有ol下的li元素,然后再找li元素里面以hd为名字的元素(看上面的HTML可以知道是div)。Find找到的结果是列表,需要使用Each方法循环获得,可以传递一个包含索引index和子元素ele参数的函数,获得具体内容的逻辑就在这个函数中。

在上面的例子中,类名叫做title的span一共有2个,所以需要取第一个(用Eq(0)),Text方法可以获得元素的内容。而获得条目ID的方法是先拿到条目页面链接(用Attr获得href属性,注意,它返回2个参数,第一个是属性值,第二是是否存在这个属性)。这样就拿到了ID和标题啦,是不是可读性和可维护性高了很多呢?

PS:其实爬虫练习的目的已经达到了,获得更多内容就是多写些逻辑罢了。

原文地址: strconv.com/posts/web-c…

代码地址

完整代码可以在这个地址找到。

延伸阅读

  1. www.w3school.com.cn/cssref/css_…
  2. www.itlipeng.cn/2017/04/25/…
分类:

后端

标签:

用Golang写爬虫(四) – 使用soup

16c5666d84f1d51etplv-t2oaga2asx-no-mark100100100100.awebp_-3

·  阅读 2672

16bf2f7234d157b2tplv-t2oaga2asx-zoom-crop-mark3024302430241702.awebp_

Python爬虫工程师有个常用的提取数据的库BeautifulSoup,而在Golang语言也有一个对应的库soup,由于我比较喜欢Python写爬虫所以自然而然的就想到了soup,这篇文章就是就来体验一下它。

安装soup

soup是第三方库,需要手动安装:

❯ go get github.com/anaskhan96/soup复制代码

使用soup

就如之前的练习,我们是要定义头信息的,但是soup这个库只开放了Get方法接收url参数。不过其他soup也是可以定义Header和Cookie的,可以改成这样:

import (
    "fmt"
    "log"
    "strconv"
    "strings"
    "time"

    "github.com/anaskhan96/soup"
)

func fetch(url string) soup.Root {
    fmt.Println("Fetch Url", url)
    soup.Headers = map[string]string{
        "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
    }

    source, err := soup.Get(url)
    if err != nil {
        log.Fatal(err)
    }
    doc := soup.HTMLParse(source)
    return doc
}复制代码

这次不再用内置的net/http这个包了。soup支持直接设置Headers(以及Cookies)的值,也可以实现自定义头信息和Cookie,然后就可以soup.Get(url)了,然后用soup.HTMLParse就可以获得文档对象了。

然后再看看豆瓣电影Top250单个条目的部分相关的HTML代码:

<ol class="grid_view">
  <li>
    <div class="item">
      <div class="info">
        <div class="hd">
          <a href="https://movie.douban.com/subject/1292052/" class="">
            <span class="title">肖申克的救赎</span>
            <span class="title"> / The Shawshank Redemption</span>
            <span class="other"> / 月黑高飞(港)  /  刺激1995(台)</span>
          </a>
          <span class="playable">[可播放]</span>
        </div>
      </div>
    </div>
  </li>
  ....
</ol>复制代码

还是原来的需求:获得条目ID和标题。这次需要把parseUrls的逻辑改成使用soup的版本:

func parseUrls(url string, ch chan bool) {
	doc := fetch(url)
	for _, root := range doc.Find("ol", "class", "grid_view").FindAll("div", "class", "hd") {
		movieUrl, _ := root.Find("a").Attrs()["href"]
		title := root.Find("span", "class", "title").Text()
		fmt.Println(strings.Split(movieUrl, "/")[4], title)
	}
	time.Sleep(2 * time.Second)
	ch <- true
}复制代码

可以感受到和goquery都用了Find这个方法名字,但是参数形式不一样,需要传递三个:「标签名」、「类型」、「具体值」。如果有多个可以使用FindAll(Find是找第一个)。如果想要找属性的值需要用Attrs方法,从map里面获得。

获得文本还是用Text方法。另外它内有goquery那样的Each方法,需要手动写一个for range格式的循环。

后记

通过这个爬虫算是基本了解了这个库,我觉得总体上soup是足够用的

代码地址

完整代码可以在这个地址找到。

用Golang写爬虫(五) – 使用XPath

16c5666d84f1d51etplv-t2oaga2asx-no-mark100100100100.awebp_-4

·  阅读 5834

16bf80ea0bcbde59tplv-t2oaga2asx-zoom-crop-mark3024302430241702.awebp_

在这个系列文章里面已经介绍了BeautifulSoup的替代库soup和Pyquery的替代库goquery,但其实我写Python爬虫最愿意用的页面解析组合是lxml+XPath。为什么呢?先分别说一下lxml和XPath的优势吧

lxml

lxml是HTML/XML的解析器,它用 C 语言实现的 libxml2 和l ibxslt 的P ython 绑定。除了效率高,还有一个特点是文档容错能力强。

XPath

XPath全称XML Path Language,也就是XML路径语言,是一门在XML文档中查找信息的语言,最初是用来搜寻XML文档的,但是它同样适用于HTML文档的搜索。通过编写对应的路径表达式或者使用内置的标准函数,可以方便的直接获取到想要的任何内容,不用像soup和goquery那样要用Find方法链式的找节点再用Text之类的方法或者对应的值(也就是一句代码就拿到结果了),这就是它的特点和优势,而lxml正好支持XPath,所以lxml+XPath一直是我写爬虫的首选。

XPath与BeautifulSoup(soup)、Pyquery(goquery)相比,学习曲线要高一些,但是学会它是非常有价值的,你会爱上它。你看我现在,原来用Python写爬虫学会了XPath,现在可以直接找支持XPath的库直接用了。

另外说一点,如果你非常喜欢BeautifulSoup,一定要选择BeautifulSoup+lxml这个组合,因为BeautifulSoup默认的HTML解析器用的是Python标准库中的html.parser,虽然文档容错能力也很强,但是效率会差很多。

我学习XPath是通过w3school,可以从延伸阅读找到链接

Golang中的Xpath库

用Golang写的Xpath库是很多的,由于我还没有什么实际开发经验,所以能搜到的几个库都试用一下,然后再出结论吧。

首先把豆瓣Top250的部分HTML代码贴出来

<ol class="grid_view">
  <li>
    <div class="item">
      <div class="info">
        <div class="hd">
          <a href="https://movie.douban.com/subject/1292052/" class="">
            <span class="title">肖申克的救赎</span>
            <span class="title">&nbsp;/&nbsp;The Shawshank Redemption</span>
            <span class="other">&nbsp;/&nbsp;月黑高飞(港)  /  刺激 1995(台)</span>
          </a>
          <span class="playable">[可播放]</span>
        </div>
      </div>
    </div>
  </li>
  ....
</ol>
复制代码

还是原来的需求:获得条目 ID 和标题

github.com/lestrrat-go/libxml2

lestrrat-go/libxml2是一个libxml2的Golang绑定库,

首先安装它:

go get github.com/lestrrat-go/libxml2
复制代码

接着改代码

import (
        "log"
        "time"
        "strings"
        "strconv"
        "net/http"

        "github.com/lestrrat-go/libxml2"
        "github.com/lestrrat-go/libxml2/types"
        "github.com/lestrrat-go/libxml2/xpath"
)

func fetch(url string) types.Document {
        log.Println("Fetch Url", url)
        client := &http.Client{}
        req, _ := http.NewRequest("GET", url, nil)
        req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)")
        resp, err := client.Do(req)
        if err != nil {
                log.Fatal("Http get err:", err)
        }
        if resp.StatusCode != 200 {
                log.Fatal("Http status code:", resp.StatusCode)
        }
        defer resp.Body.Close()
        doc, err := libxml2.ParseHTMLReader(resp.Body)
        if err != nil {
                log.Fatal(err)
        }
        return doc
}
复制代码

fetch 函数和之前的整体一致,doc 是用libxml2.ParseHTMLReader(resp.Body)获得的。parseUrls的改动比较大:

func parseUrls(url string, ch chan bool) {
        doc := fetch(url)
        defer doc.Free()
        nodes := xpath.NodeList(doc.Find(`//ol[@class="grid_view"]/li//div[@class="hd"]`))
        for _, node := range nodes {
                urls, _ := node.Find("./a/@href")
                titles, _ := node.Find(`.//span[@class="title"]/text()`)
                log.Println(strings.Split(urls.NodeList()[0].TextContent(), "/")[4],
                        titles.NodeList()[0].TextContent())
        }
        time.Sleep(2 * time.Second)
        ch <- true
}
复制代码

个人觉得libxml2设计的接口用起来体验不好,每次都要用NodeList()[index].TextContent这么麻烦的写法获得匹配值。

另外文档写的非常简陋,看项目源码,还有能用xpath.NewContext创建一个上下文,然后用xpath.String(ctx.Find("/foo/bar"))的方式获得对应XPath语句的结果,但依然很麻烦!

github.com/antchfx/htmlquery

htmlquery如其名,是一个对HTML文档做XPath查询的包。它的核心是antchfx/xpath,项目更新频繁,文档也比较完整。

首先安装它:

❯ go get github.com/antchfx/htmlquery
复制代码

接着按需求修改:

import (
    "log"
    "time"
    "strings"
    "strconv"
    "net/http"

    "golang.org/x/net/html"
    "github.com/antchfx/htmlquery"
)

func fetch(url string) *html.Node {
    log.Println("Fetch Url", url)
    client := &http.Client{}
    req, _ := http.NewRequest("GET", url, nil)
    req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)")
    resp, err := client.Do(req)
    if err != nil {
        log.Fatal("Http get err:", err)
    }
    if resp.StatusCode != 200 {
        log.Fatal("Http status code:", resp.StatusCode)
    }
    defer resp.Body.Close()
    doc, err := htmlquery.Parse(resp.Body)
    if err != nil {
        log.Fatal(err)
    }
    return doc
}
复制代码

fetch函数主要就是修改htmlquery.Parse(resp.Body)和函数返回值类型*html.Node。再看看parseUrls

func parseUrls(url string, ch chan bool) {
    doc := fetch(url)
    nodes := htmlquery.Find(doc, `//ol[@class="grid_view"]/li//div[@class="hd"]`)
    for _, node := range nodes {
        url := htmlquery.FindOne(node, "./a/@href")
        title := htmlquery.FindOne(node, `.//span[@class="title"]/text()`)
        log.Println(strings.Split(htmlquery.InnerText(url), "/")[4],
            htmlquery.InnerText(title))
    }
    time.Sleep(2 * time.Second)
    ch <- true
}
复制代码

antchfx/htmlquery的体验比lestrrat-go/libxml2要好,Find是选符合的节点列表,FindOne是找符合的第一个节点。

后记

通过这个爬虫算是基本了解了这2个库,还有个gopkg.in/xmlpath.v2我没有写,主要是它很久不更新了(最近一次是2015年更新的)。

随便说一下gopkg.in,gopkg是一种包管理方式,其实是用约定好的方式「代理」Github上对应项目的对应分支的包。具体的请看延伸阅读链接2。

xmlpath.v2这个包就是Github上的go-xmlpath/xmlpath, 分支是v2。

这些库里面我推荐使用antchfx/htmlquery,接口更好用一些。性能、功能这方面还有更多经验,如果之后发现其他问题我再写文章吧!

原文地址: strconv.com/posts/web-c…

代码地址

完整代码可以在这个地址找到。

延伸阅读

  1. www.w3school.com.cn/xpath/xpath…
  2. labix.org/gopkg.in

    用Golang写爬虫(六) – 使用collyimgSpider 采集中…

    Colly是Golang世界最知名的Web爬虫框架了,它的API清晰明了,高度可配置和可扩展,支持分布式抓取,还支持多种存储后端(如内存、Redis、MongoDB等)。这篇文章记录我学习使用它的的一些感受和理解。

    首先安装它:

    ❯ go get -u github.com/gocolly/colly/...
    复制代码

    这个go get和之前安装包不太一样,最后有...这样的省略号,它的意思是也获取这个包的子包和依赖。

    从最简单的例子开始

    Colly的文档写的算是很详细很完整的了,而且项目下的_examples目录里面也有很多爬虫例子,上手非常容易。先看我的一个例子:

    package main
    
    import (
    	"fmt"
    
    	"github.com/gocolly/colly"
    )
    
    func main() {
    	c := colly.NewCollector(
    		colly.UserAgent("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"),
    	)
    
    	c.OnRequest(func(r *colly.Request) {
    		fmt.Println("Visiting", r.URL)
    	})
    
    	c.OnError(func(_ *colly.Response, err error) {
    		fmt.Println("Something went wrong:", err)
    	})
    
    	c.OnResponse(func(r *colly.Response) {
    		fmt.Println("Visited", r.Request.URL)
    	})
    
    	c.OnHTML(".paginator a", func(e *colly.HTMLElement) {
    		e.Request.Visit(e.Attr("href"))
    	})
    
        c.OnScraped(func(r *colly.Response) {
            fmt.Println("Finished", r.Request.URL)
        })
    
    	c.Visit("https://movie.douban.com/top250?start=0&filter=")
    }
    复制代码

    这个程序就是去找豆瓣电影Top250的全部链接,如OnHTML方法的第一个函数所描述,找类名是paginator的标签下的a标签的href属性值。

    运行一下:

    ❯ go run colly/doubanCrawler1.go
    Visiting https://movie.douban.com/top250?start=0&filter=
    Visited https://movie.douban.com/top250?start=0&filter=
    Visiting https://movie.douban.com/top250?start=25&filter=
    Visited https://movie.douban.com/top250?start=25&filter=
    ...
    Finished https://movie.douban.com/top250?start=25&filter=
    Finished https://movie.douban.com/top250?start=0&filter=
    复制代码

    在Colly中主要实体就是一个Collector对象(用colly.NewCollector创建),Collector管理网络通信和对于响应的回调执行。Collector在初始化时可以接受多种设置项,例如这个例子里面我就设置了UserAgent的值。其他的设置项可以去看官方网站。

    Collector对象接受多种回调方法,有不同的作用,按调用顺序我列出来:

    1. OnRequest。请求前
    2. OnError。请求过程中发生错误
    3. OnResponse。收到响应后
    4. OnHTML。如果收到的响应内容是HTML调用它。
    5. OnXML。如果收到的响应内容是XML 调用它。写爬虫基本用不到,所以上面我没有使用它。
    6. OnScraped。在OnXML/OnHTML回调完成后调用。不过官网写的是Called after OnXML callbacks,实际上对于OnHTML也有效,大家可以注意一下。

    抓取条目ID和标题

    还是之前的需求,先看看豆瓣Top250页面每个条目的部分HTML代码:

    <ol class="grid_view">
      <li>
        <div class="item">
          <div class="info">
            <div class="hd">
              <a href="https://movie.douban.com/subject/1292052/" class="">
                <span class="title">肖申克的救赎</span>
                <span class="title">&nbsp;/&nbsp;The Shawshank Redemption</span>
                <span class="other">&nbsp;/&nbsp;月黑高飞(港)  /  刺激 1995(台)</span>
              </a>
              <span class="playable">[可播放]</span>
            </div>
          </div>
        </div>
      </li>
      ....
    </ol>
    复制代码

    看看这个程序怎么写的:

    package main
    
    import (
    	"log"
    	"strings"
    
    	"github.com/gocolly/colly"
    )
    
    func main() {
    	c := colly.NewCollector(
    		colly.Async(true),
    		colly.UserAgent("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"),
    	)
    
    	c.Limit(&colly.LimitRule{DomainGlob:  "*.douban.*", Parallelism: 5})
    
    	c.OnRequest(func(r *colly.Request) {
    		log.Println("Visiting", r.URL)
    	})
    
    	c.OnError(func(_ *colly.Response, err error) {
    		log.Println("Something went wrong:", err)
    	})
    
    	c.OnHTML(".hd", func(e *colly.HTMLElement) {
    		log.Println(strings.Split(e.ChildAttr("a", "href"), "/")[4],
    			strings.TrimSpace(e.DOM.Find("span.title").Eq(0).Text()))
        })
    
    	c.OnHTML(".paginator a", func(e *colly.HTMLElement) {
    		e.Request.Visit(e.Attr("href"))
    	})
    
    	c.Visit("https://movie.douban.com/top250?start=0&filter=")
    	c.Wait()
    }
    复制代码

    如果你有心运行上面的那个例子,可以感受到抓取时同步的,比较慢。而这次在colly.NewCollector里面加了一项colly.Async(true),表示抓取时异步的。在Colly里面非常方便控制并发度,只抓取符合某个(些)规则的URLS,有一句c.Limit(&colly.LimitRule{DomainGlob: "*.douban.*", Parallelism: 5}),表示限制只抓取域名是douban(域名后缀和二级域名不限制)的地址,当然还支持正则匹配某些符合的 URLS,具体的可以看官方文档。

    另外Limit方法中也限制了并发是5。为什么要控制并发度呢?因为抓取的瓶颈往往来自对方网站的抓取频率的限制,如果在一段时间内达到某个抓取频率很容易被封,所以我们要控制抓取的频率。另外为了不给对方网站带来额外的压力和资源消耗,也应该控制你的抓取机制。

    这个例子里面没有OnResponse方法,主要是里面没有实际的逻辑。但是多用了Wait方法,这是因为在Async为true时需要等待协程都完成再结束。但是呢,有2个OnHTML方法,一个用来确认都访问那些页面,另外一个里面就是抓取条目信息的逻辑了。也就是这部分:

    c.OnHTML(".hd", func(e *colly.HTMLElement) {
        log.Println(strings.Split(e.ChildAttr("a", "href"), "/")[4],
            strings.TrimSpace(e.DOM.Find("span.title").Eq(0).Text()))
    })
    复制代码

    Colly的HTML解析库用的是goquery,所以写起来遵循goquery的语法就可以了。ChildAttr方法可以获得元素对应属性的值,另外一个没有列出来的ChildText,用于获得元素的文本内容。但是我们这个例子中类名为title的span标签有2个,用ChildText回直接返回2个标签的全部的值,但是Colly又没有提供ChildTexts方法(有ChildAttrs),所以只能看源码看ChildText实现改成了strings.TrimSpace(e.DOM.Find("span.title").Eq(0).Text()),这样就可以拿到第一个符合的文本了。

    在Colly中使用XPath

    如果你不喜欢goquery这种形式,当然也可以切换HTML解析方案,看我这个例子:

    import "github.com/antchfx/htmlquery"
    
    c.OnResponse(func(r *colly.Response) {
        doc, err := htmlquery.Parse(strings.NewReader(string(r.Body)))
        if err != nil {
            log.Fatal(err)
        }
        nodes := htmlquery.Find(doc, `//ol[@class="grid_view"]/li//div[@class="hd"]`)
        for _, node := range nodes {
            url := htmlquery.FindOne(node, "./a/@href")
            title := htmlquery.FindOne(node, `.//span[@class="title"]/text()`)
            log.Println(strings.Split(htmlquery.InnerText(url), "/")[4],
                htmlquery.InnerText(title))
        }
    })
    复制代码

    这次我改在OnResponse方法里面获得条目ID和标题。htmlquery.Parse需要接受一个实现io.Reader接口的对象,所以用了strings.NewReader(string(r.Body))。其他的代码是之前 用Golang写爬虫(五) – 使用XPath里面写过的,直接拷贝过来就可以了。

    后记

    试用Colly后就喜欢上了它,你呢?

    代码地址

    本文原文地址: strconv.com/posts/use-c…

    完整代码可以在这个地址找到。

    分类:

    后端

    标签:

    用Golang写爬虫(七) – 如何保存数据到文件

    16c5666d84f1d51etplv-t2oaga2asx-no-mark100100100100.awebp_-6

    ·  阅读 2334

    16c170d076d2bceatplv-t2oaga2asx-zoom-crop-mark3024302430241702.awebp_

    在之前的练习中获得的条目ID和标题直接用fmt.Println或者log.Println在终端打印出来了,但是在实际工作需要把它保存在文件或者数据库中。这篇文章学习一下保存到纯文本、CSV和JSON三种文件里。

    保存在纯文本文件

    纯文本文件是只保存文本了,不保存其格式设置的文件,最常见的如txt后缀文件、配置文件、源代码等等。。代码修改思路是

    1. 修改parseUrls方法中打印的部分,改成写入文件:

    func checkError(err error) {
        if err != nil {
            panic(err)
        }
    }
    
    _, err := f.WriteString(strings.Split(htmlquery.InnerText(url), "/")[4] + "\t" +
            htmlquery.InnerText(title) + "\n")
    checkError(err)
    复制代码

    这样就把id和标题用制表符隔开,当然要注意最后还写入了换行符。每次调用WriteString写入文本后要判断是不是有错误err,这次抽象成了一个函数checkError,如果有错误说明有严重问题,这不是只打印信息说明文件就可以了,所以用panic直接抛错结束程序。

    PS: 说一个细节,在Python中字符串拼接用+最好应该用join方法,但是在Golang中strings.Join是最慢的,而最快的是bytes.Buffer,具体的可以看延伸阅读链接2里面的实验。但是对于我们这种少量小文本拼接的需求就直接使用+,没有引入bytes.Buffer。

    2. 修改main方法,初始化文件,并用defer在函数执行结束后关闭文件

    func main() {
        start := time.Now()
        ch := make(chan bool)
        f, err := os.Create("movie.txt")
        checkError(err)
        defer f.Close()
        _, err = f.WriteString("ID\tTitle\n")
        checkError(err)
    
        for i := 0; i < 10; i++ {
            go parseUrls("https://movie.douban.com/top250?start="+strconv.Itoa(25*i), ch, f)
        }
    
        for i := 0; i < 10; i++ {
            <-ch
        }
        f.Sync()
    
        elapsed := time.Since(start)
        log.Printf("Took %s", elapsed)
    }
    复制代码

    这段代码中,用os.Create创建一个文件,然后写了一行ID\tTitle\n描述字段。而在parseUrls函数中增加了参数f(类型是*os.File),这样就可以在parseUrls把抓到的数据写入文件了。最后还用了Sync方法,它的作用是将文件系统的最近写入的数据在内存中的拷贝刷新到硬盘中,这是一个安全保存数据的习惯。

    运行一下:

    ❯ go run doubanCrawler1.go
    ...
    ❯ wc -l movie.txt
    251 movie.txt  # 一行字段描述,250行代表Top250个条目head -3 movie.txt
    ID	Title
    1292052	肖申克的救赎
    1291546	霸王别姬
    复制代码

    保存在CSV文件

    数据处理工作中经常用到csv(逗号分隔值Comma-Separated Values)文件,其文件以纯文本形式存储表格数据(数字和文本),字段间用某种分隔符(常见逗号或制表符)分隔。Golang标准库中有用于编码csv文件的encoding/csv包,可以直接使用。还是按之前的2步:

    先修改 parseUrls 方法中打印的部分,改成写入csv文件

    import (
        ...
        "encoding/csv"
    )
    
    err := w.Write([]string{
        strings.Split(htmlquery.InnerText(url), "/")[4],
        htmlquery.InnerText(title)})
    checkError(err)
    复制代码

    Write方法传入的是一个切片,元素分别是ID和标题。然后改main函数:

    func main() {
        start := time.Now()
        ch := make(chan bool)
        f, err := os.Create("movie.csv")
        checkError(err)
        defer f.Close()
    
        writer := csv.NewWriter(f)
        defer writer.Flush()
    
        err = writer.Write([]string{"ID", "Title"})
        checkError(err)
    
        for i := 0; i < 10; i++ {
            go parseUrls("https://movie.douban.com/top250?start="+strconv.Itoa(25*i), ch, writer)
        }
    
        for i := 0; i < 10; i++ {
            <-ch
        }
    
        elapsed := time.Since(start)
        log.Printf("Took %s", elapsed)
    }
    复制代码

    首先还是用os.Create创建一个文件,按习惯使用csv后缀。用csv.NewWriter创建一个写csv的writer。parseUrls的最后一个参数改成了writer(类型是*csv.Writer)。这样就可以了:

    go run doubanCrawler2.go
    ...
    ❯ head -3 movie.csv
    ID,Title
    1295865,燃情岁月
    1395091,未麻的部屋
    复制代码

    后记

    虽然本节只是学习文件的写,没有读的内容。但是也算大体了解了文件操作。

    代码地址

    原文地址: strconv.com/posts/save-…

    完整代码可以在这个地址找到。

    延伸阅读

    1. tutorialedge.net/golang/read…
    2. gocn.vip/question/26…
    3. golang.org/pkg/encodin…
    分类:

    后端

    标签:

    用Golang写爬虫(八) – 使用GORM存入MySQL数据库

    16c5666d84f1d51etplv-t2oaga2asx-no-mark100100100100.awebp_-7

    ·  阅读 2207

    16c1cf4fbd954676tplv-t2oaga2asx-zoom-crop-mark3024302430241702.awebp_

    上篇文章把数据存进了文件,这篇文章将把数据存入MySQL数据库。数据库的使用是每个开发者必备的技能,所以本篇文章我们使用Golang操作数据库。Golang没有内置的驱动支持任何的数据库,但是Go定义了database/sql接口,开发者可以基于驱动接口开发相应数据库的驱动。当然现在各种数据库驱动生态已经很稳定了,可以直接使用。

    我在实际开发工作中一般不直接用数据库驱动(如github.com/go-sql-driver/mysql),而是用ORM。ORM有三大优点:

    1. 安全性。使用底层的数据库驱动是在「拼」SQL,这样很容易出现SQL注入等风险。
    2. 易于理解。拼 SQL 这种形式放在代码中是很乱的,只有开发者在当时编写时可以理解,但是后来的维护者就不那么好理解了。
    3. 易于维护。如果数据库操作代码写多了往往会有很多重复、可重用的SQL其实是可以避免的,如果用ORM,他把CURD这些操作都费装起来,这种代码看起来会非常直观,很容易找到一些错误的,不好的,重复的用法。
    4. 可移植。ORM往往支持多种数据库,如MySQL、PostgreSQL、SQLite等,这样在本地开发你可选择SQLite,而在生产环境可以切换MySQL,完全不需要改动代码逻辑,只是改一下配置。

    所以使用ORM能提高开发效率,降低开发成本,但是缺点是ORM的封装是有成本的,会稍微影响到性能,不过我认为这点性能损耗微乎其微。

    目前Golang世界里面最受欢迎的ORM叫做gorm,我们就使用它,先安装它:

    ❯ go get -u github.com/jinzhu/gorm
    ❯ go get -u github.com/go-sql-driver/mysql  # 也要安装对应数据库驱动
    复制代码

    gorm用的MySQL驱动其实就是go-sql-driver/mysql,但通常这么import:

    import (
      "github.com/jinzhu/gorm"
      _ "github.com/jinzhu/gorm/dialects/mysql"
    )
    复制代码

    有时候对于某个包,不需要把整个包都导入进来,只是执行他的init函数。可以使用import _这样的写法,不过要注意,这种方式无法通过包名来调用包中的其他函数。

    定义模型

    接着我们声明Model:

    type Movie struct {
        ID int
        Title string `gorm:"type:varchar(100);unique_index"`
    }
    复制代码

    这个结构体叫做Movie,模型有2个字段ID和Title。Title后面有个反引号,这个部分叫做成员变量标签(Tag),当需要用到Tag中的内容时可以使用反射包(reflect)中的方法来获取,这是gorm支持的用法。它表示定义title资格字段的长度是100,并且有唯一索引。其他支持的标签可以看官方文档。

    如果看文档或者很多文章,经常能看到继承了gorm.Model的模型,如下面这个:

    type Movie struct {
        gorm.Model
        Title string `gorm:"type:varchar(100);unique_index"`
    }
    复制代码

    gorm.Model 自带了ID, CreatedAt, UpdatedAt, DeletedAt这么4个字段,在我们这里完全没有必要。

    初始化

    在main函数里面连接数据库,并创建表:

    func main() {
        start := time.Now()
        ch := make(chan bool)
        db, err := gorm.Open("mysql", "root:@/test?charset=utf8")
        defer db.Close()
        checkError(err)
    
        db.DropTableIfExists(&Movie{})
        db.Set("gorm:table_options", "ENGINE=InnoDB").AutoMigrate(&Movie{})
    
        for i := 0; i < 11; i++ {
            go parseUrls("https://movie.douban.com/top250?start="+strconv.Itoa(25*i), ch, db)
        }
    
        for i := 0; i < 11; i++ {
            <-ch
        }
    
        elapsed := time.Since(start)
        log.Printf("Took %s", elapsed)
    }
    复制代码

    gorm.Open的第一个参数是数据库驱动名字,第二个就是具体的db uri,我这里用了默认的test库,用户名root,密码为空,字符集为utf-8。

    为了每次都初始化表,所以先用DropTableIfExists删除表,再用AutoMigrate创建表。Migrate是迁移的意思,自动迁移仅仅会创建表,缺少列和索引,并且不会改变现有列的类型或删除未使用的列以保护数据。这样可以更新表结构,一般不会直接用CreateTable创建表了。db.Set是为了创建表时添加表后缀。

    parseUrls的最后一个参数db是*gorm.DB类型的。

    好了,运行一下:

    go run doubanCrawler.go
    复制代码

    执行结束可以用MySQL CLI看到数据了:

    mysql> select * from movies order by id desc limit 3;
    +----------|-----------------------------+
    | id       | title                       |
    +----------|-----------------------------+
    | 30170448 | 何以为家                    |
    | 27191492 | 四个春天                    |
    | 26799731 | 请以你的名字呼唤我          |
    +----------|-----------------------------+
    3 rows in set (0.00 sec)
    复制代码

    用gorm查询

    上面用到的只是db.Create创建记录,接着看看怎么查询(按行注释):

    func main() {
        db, err := gorm.Open("mysql", "root:@/test?charset=utf8")
        defer db.Close()
        checkError(err)
    
        var movie Movie
        var movies []Movie
        db.First(&movie, 30170448)  // 查询ID为30170448的记录,只支持主键
        log.Println(movie)
        log.Println(movie.ID, movie.Title) // 可以获得对应属性
    
        db.Order("id").Limit(3).Find(&movies) // 按ID升序排,取前三个记录赋值给movies
        log.Println(movies)
        log.Println(movies[0].ID)
    
        db.Order("id desc").Limit(3).Offset(1).Find(&movies)  // Order也支持desc选择降序, offset表示对结果集从第2个记录开始
        log.Println(movies)
        db.Select("title").First(&movies, "title = ?", "四个春天") // 用Select可以限定只返回那些字段,First也支持条件
        log.Println(movie)
    
        var count int64
        db.Where("id = ?", 30170448).Or("title = ?", "四个春天").Find(&movies).Count(&count)  // Where后加条件,在这里是id为30170448或者title为"四个春天"2个条件符合之一即可,最后用Count算一下符合的记录数
        log.Println(count)
    }
    复制代码

    执行一下:

     go run doubanCrawler2.go
    2019/07/13 21:16:24 {30170448 何以为家}
    2019/07/13 21:16:24 30170448 何以为家
    2019/07/13 21:16:24 [{1291543 功夫} {1291545 大鱼} {1291546 霸王别姬}]
    2019/07/13 21:16:24 1291543
    2019/07/13 21:16:24 [{27191492 四个春天} {26799731 请以你的名字呼唤我} {26787574 奇迹男孩}]
    2019/07/13 21:16:24 [{0 何以为家}]
    2019/07/13 21:16:24 {30170448 何以为家}
    2019/07/13 21:16:24 2
    复制代码

    更新和删除用法就不展示了,看文档即可。

    代码地址

    完整代码可以在这个地址找到。

    延伸阅读

    1. gorm.io/docs/