(十五)Go爬虫开发

爬虫简介

Go爬虫的实现手段,主要使用的还是net/http这个包。它不仅可以接收浏览器发送过来的请求,实现服务器的功能,也可以模拟浏览器向其它的服务器发送请求。基本的流程如下:

  1. 构建、发送请求链接
  2. 获取服务器返回的响应数据
  3. 过滤、保存、使用得到的数据
  4. 关闭请求链接。

打印出完整的网页内容,和浏览器获取的内容是一样的。只不过我们写的.go程序是直接将服务器返回的所有数据内容打印出来,而浏览器是将服务器返回的内容(代码)按照既定的方式加以执行并显示给用户看,所以我们在浏览器上会看到文字,图片等信息。

爬虫的定义:网络爬虫(又被称为网页蜘蛛,网络机器人),是一种按照一定的规则自动地抓取万维网信息的程序。

爬虫获取的数据的用途:

  • 呈现数据,呈现在app或者网站上
  • 进行数据分析,获得结论

爬取百度贴吧

操作步骤

  1. 请求的URL地址,也就是明确目标 (要知道你准备在哪个范围或者网站去搜索),这里我们以“吃鸡”游戏这个贴吧为例,分析地址规律如下:

    //第一页
    http://tieba.baidu.com/f?kw=%E7%BB%9D%E5%9C%B0%E6%B1%82%E7%94%9F&ie=utf-8&pn=0 
    
    //第二页
    http://tieba.baidu.com/f?kw=%E7%BB%9D%E5%9C%B0%E6%B1%82%E7%94%9F&ie=utf-8&pn=50
    
    //第三页
    http://tieba.baidu.com/f?kw=%E7%BB%9D%E5%9C%B0%E6%B1%82%E7%94%9F&ie=utf-8&pn=100  
    

    总结规律:“下一页”地址是“前一页”地址 + 50

  2. 发送请求,获取响应 (将所有的网站的内容全部爬下来)

  3. 提取数据,去掉对我们没用处的数据

  4. 处理数据(按照我们想要的方式存储和使用)

相关代码:

package main
import (
    "fmt"
    "strconv"
    "net/http"
    "os"
)

// 实现 读取一个网页内容函数
func HttpGet(url string) (result string, err error)  {
	// 借助 http包的 Get()函数 获取网页数据
    resp, err1 := http.Get(url)             	
    if err1 != nil {
        // 将错误传出
        err = err1                   		
        return
    }
    // 读取结束,关闭resp.Body
    defer resp.Body.Close()                

    buf := make([]byte, 4096)
    for {
        // 读取Body内容
        n, err := resp.Body.Read(buf)
        if n == 0 {
            fmt.Println("读完!err:", err)
            break
        }
        // 拼接每次buf中读到的数据,到result中,返回
        result += string(buf[:n])        	
    }
    return
}

func working(start, end int)  {
    // 测试
    fmt.Printf("正在爬取 %d 到 %d 页\n", start, end)    
    // 明确目标:url
    for i:=start; i<=end; i++ {
        url := "https://tieba.baidu.com/f?kw=%E7%BB%9D%E5%9C%B0%E6%B1%82%E7%94%9F&ie=utf-8&pn=" +
        strconv.Itoa((i-1) * 50)
        fmt.Printf("正在爬:%d页,%s\n", i, url)
		// 封装函数,读取一页内容,存至 result
        result, err := HttpGet(url)             
        if err != nil {
            fmt.Println("HttpGet err:", err)
            continue
        }
		// 将读到的一个网页内容,写出成一个文件。用i.html命名文件
        fileName := strconv.Itoa(i) + ".html"
        // 每个网页保存成一个文件
        f, err := os.Create(fileName)        
        if err != nil {
            fmt.Println("Create err:", err)
            continue
        }
        f.WriteString(result)
        // 写完一个文件,关闭一个文件。
        f.Close()                           
    }
}

func main()  {
    // 指定爬取的起始、终止页面
    var start, end int
    fmt.Printf("请输入爬取的起始页( >= 1 ):")
    fmt.Scan(&start)
    fmt.Printf("请输入爬取的终止页( >= 起始页):")
    fmt.Scan(&end)

    // 封装函数,专门完成爬取工作。
    working(start, end)
}

并发版网络爬虫

上面实现的案例中,只有一个主协程在爬取网页内容。爬完第一页,再去爬取第二页,再去爬取第三页……这样效率显然很低。学习并发时,我们了解到go语言的goroutine十分轻量级,且能很好的实现并发目的。那么要爬取N页数据,我们可以直接定义N个goroutine分别去爬取,大大提高程序的并发性,执行效率也会高出很多。

  • 将根据URL爬取网页内容、保存生成HTML文件的相关功能封装到函数中。

    func SpiderPage(idx int) {
        // 请求网址
        url := "https://tieba.baidu.com/f?kw=%E7%BB%9D%E5%9C%B0%E6%B1%82%E7%94%9F&ie=utf-8&pn=" +
        strconv.Itoa((idx-1) * 50)
        fmt.Printf("正在爬取:%d页,%s\n", idx, url)
        // 封装函数,读取一页内容,存至 result
        result, err := HttpGet2(url)            	
        if err != nil {
            fmt.Println("HttpGet err:", err)
            return
        }
        // 将读到的一个网页的内容,写出成一个文件。用i.html命名文件
        fileName := strconv.Itoa(idx) + ".html"	
        // 每个网页保存成一个文件
        f, err := os.Create(fileName)        	
        if err != nil {
            fmt.Println("Create err:", err)
            return
        }
        f.WriteString(result)
        // 写完一个文件,关闭一个文件。
        f.Close()                       		
    }
    
    // 实现 读取一个网页内容函数
    func HttpGet2(url string) (result string, err error)  {
    	// 借助 http包的 Get()函数 获取网页数据
        resp, err1 := http.Get(url)             
        if err1 != nil {
            // 将错误传出
            err = err1                   		
            return
        }
        // 读取结束,关闭resp.Body
        defer resp.Body.Close()                
    
        buf := make([]byte, 4096)
        for {  
            // 读取Body内容
            n, err := resp.Body.Read(buf)
            if n == 0 {
                fmt.Println("读完!err:", err)
                break
            }
            // 拼接每次buf中读到的数据,到result中,返回
            result += string(buf[:n])        
        }
        return
    }
    
  • working()函数只需循环启动goroutine,调用该函数即可。

    func working(start, end int)  {
        // 明确目标:url
        for i:=start; i<=end; i++ {
            // 起 go 程并发处理
            go SpiderPage(i)          
        }
    }
    

    但这样处理有一个问题。主协程很快创建N个goroutine,working()函数调用完毕退出了,而此时子goroutine还没有爬取完网页内容保存成html文件。这里需要主goroutine等待所有子协程调用完成再退出。

    可以借助channel 来达到这一目的。

    定义一个名为page的通道,将通道引用和循环因子i一起传递到了SpiderPage方法中。

    func working(start, end int)  {
        // 使用 channel 防止主go程提前退出。
        page := make(chan int)             
        // 明确目标:url
        for i:=start; i<=end; i++ {
            // 起 go 程并发处理
            go SpiderPage(i, page)      	
        }
    
        for i:=start; i<=end; i++ {
            fmt.Printf("爬取%d页面完成!\n", <-page)
        }
    }
    
    

    同时在 SpiderPage 函数内爬取网页数据完成后,将i值(代表爬取的第几页)写入page。主协程循环创建N个goroutine之后,要依次读取每一个goroutine借助channel写回的i值。在读取期间,如果page上没有写端写入,主goroutine则会阻塞等待,直到有子协程写入,读取打印第i个页面爬取完毕。

    func SpiderPage(idx int, page chan<- int) {
        // 请求网址
        url := "https://tieba.baidu.com/f?kw=%E7%BB%9D%E5%9C%B0%E6%B1%82%E7%94%9F&ie=utf-8&pn=" +
        strconv.Itoa((idx-1) * 50)
        fmt.Printf("正在爬取:%d页,%s\n", idx, url)
    	// 封装函数,读取一页内容,存至 result
        result, err := HttpGet2(url)            
        if err != nil {
            fmt.Println("HttpGet err:", err)
            return
        }
    	// 将读到的一个网页的内容,写出成一个文件。用i.html命名文件
        fileName := strconv.Itoa(idx) + ".html"
        // 每个网页保存成一个文件
        f, err := os.Create(fileName)          
        if err != nil {
            fmt.Println("Create err:", err)
            return
        }
        f.WriteString(result)
        // 写完一个文件,关闭一个文件。
        f.Close()                       
    	// 爬取一个页面完成,写入管道。
        page <- idx                     
    }
    
    
  • 最后在 main 函数中执行爬虫

    func main()  {
       // 指定爬取的起始、终止页面
       var start, end int
       fmt.Printf("请输入爬取的起始页( >= 1 ):")
       fmt.Scan(&start)
       fmt.Printf("请输入爬取的终止页( >= 起始页):")
       fmt.Scan(&end)
    
       // 封装函数,专门完成爬取工作。
       working2(start, end)
    }
    

正则表达式

对爬取到的网页内容进行筛选提取,可以使用 string 包中的一些字符串操作函数,如:搜索(ContainsIndex)、替换(Replace)和解析(SplitJoin),但是处理网页数据实现起来相对而言复杂度较高。实际在工作中,对于这类字符串拆分提取操作,我们通常使用正则表达式来实现。通过正则表达式提取网页内容要方便许多。

如果strings包提供的函数能解决你的问题,那么就尽量使用它来解决。因为他们足够简单、而且性能和可读性都要比正则好。

正则表达式是一种进行模式匹配和文本操纵的复杂而又强大的工具。虽然正则表达式比纯粹的文本匹配效率低,但是它却更灵活。按照它的语法规则,随需构造出的匹配模式就能够从原始文本中筛选出几乎任何你想要得到的字符组合。

字符类

字符含义举例
.匹配任意一个字符abc. 可以匹配 abcd、abc9等
[]匹配括号中的任意一个字符[abc]d 可以匹配 ad、bd或cd
-在 [] 括号内表示字符范围[0-9a-fA-F] 可以匹配一位十六进制数字
^位于 [] 括号内的开头,匹配除括号中的字符之外任意一个字符[^xy] 匹配除 xy 之外任一字符
[[:xxx:]]grep工具预定义的一些命名字符类[[:alpha:]] 匹配一个字符,[[:digit:]] 匹配一个数字

数量限定符

字符含义举例
*匹配前面的子表达式零次或多次。例如,zo* 能匹配 “z” 以及 “zoo”。* 等价于{0,}。
+匹配前面的子表达式一次或多次。例如,‘zo+’ 能匹配 “zo” 以及 “zoo”,但不能匹配 “z”。+ 等价于 {1,}。
?匹配前面的子表达式零次或一次。例如,“do(es)?” 可以匹配 “do” 、 “does” 中的 “does” 、 “doxy” 中的 “do” 。? 等价于 {0,1}。
{n}n 是一个非负整数。匹配确定的 n 次。例如,‘o{2}’ 不能匹配 “Bob” 中的 ‘o’,但是能匹配 “food” 中的两个 o。
{n,}n 是一个非负整数。至少匹配n 次。例如,‘o{2,}’ 不能匹配 “Bob” 中的 ‘o’,但能匹配 “foooood” 中的所有 o。‘o{1,}’ 等价于 ‘o+’。‘o{0,}’ 则等价于 ‘o*’。
{n,m}m 和 n 均为非负整数,其中n <= m。最少匹配 n 次且最多匹配 m 次。例如,“o{1,3}” 将匹配 “fooooood” 中的前三个 o。‘o{0,1}’ 等价于 ‘o?’。请注意在逗号和两个数之间不能有空格。

其它特殊字符

字符含义举例
\转义字符,普通字符转义为特殊字符,特殊字符转义为普通字符普通字符< 写成\<,特殊字符 . 写出 \.
()将正则表达式的一部分括起来组成一个单元,可以对整个单元使用数量限定符([0-9]{1, 3}\.){3}[0-9]{1, 3}匹配IP地址
|连接两个子表达式,表示或的关系n(o|either) 匹配 no 或 neither

Go语言使用正则

Go语言通过regexp(regular expression)标准包为正则表达式提供了官方支持。使用正则表达式只需要两步即可:

  1. **解析、编译正则表达式。**使用 regexp.MustCompile() 函数

    func MustCompile(str string) *Regexp
    

    函数的主要作用是将正则表达式中,奇形怪状的符号(如.*?[ …)转换成 Go语言能识别的格式,并将其存成结构体格式,方便编译器识别。

    • 参数:正则表达式字串。建议使用反引号。
    • 返回值:编译后的结构体。解析失败时会产生panic错误。
  2. 根据解析好的规则(结构体形式),**从指定字符串中提取需要的信息。**使用 FindAllStringSubmatch() 函数

    func (re *Regexp) FindAllStringSubmatch(s string, n int) [][]string
    
    • 参数1:待解析的字符串。

    • 参数2:匹配的次数。通常传-1,表示匹配所有。

    • 返回值:返回成功匹配的[ ][ ]string。

    • 说明

      [   
          [string1 string2] 
          [string1 string2] 
          [string1 string2] 
      ]
      

      其中:string1: 表示带有匹配参考项的全部字串。string2: 表示去除匹配参考项后的字串。

    • 注意,要使用前面 regexp.MustCompile() 函数调用的返回值,来调用此函数。

使用正则表达式处理字符串检索小例子:

package main
import (
    "fmt"
    "regexp"
)

func main()  {
    str := "abc a7c mfc cat 8ca azc cba"
    // 1. 解析、编译正则表达式
    // 可以不用检查出错情况
    ret := regexp.MustCompile(`a.c`) 
    //ret := regexp.MustCompile(`a[0-9]c`)
    //ret := regexp.MustCompile(`a\dc`)

    // 2. 提取需要信息
    alls := ret.FindAllStringSubmatch(str, -1)
    fmt.Println(alls)
}

提取网页中<div></div>标签数据的例子:

import (
   "regexp"
   "fmt"
)
func main()  {
   str := `   
<!DOCTYPE html>
<html lang="zh-CN">
<head>
   <title>Go语言标准库文档中文版 | Go语言中文网 | Golang中文社区 | Golang中国</title>
   <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no">
   <meta http-equiv="X-UA-Compatible" content="IE=edge, chrome=1">
   <meta charset="utf-8">
   <link rel="shortcut icon" href="/static/img/go.ico">
   <link rel="apple-touch-icon" type="image/png" href="/static/img/logo2.png">
   <meta name="author" content="polaris <polaris@studygolang.com>">
   <meta name="keywords" content="中文, 文档, 标准库, Go语言,Golang,Go社区,Go中文社区,Golang中文社区,Go语言社区,Go语言学习,学习Go语言,Go语言学习园地,Golang 中国,Golang中国,Golang China, Go语言论坛, Go语言中文网">
   <meta name="description" content="Go语言文档中文版,Go语言中文网,中国 Golang 社区,Go语言学习园地,致力于构建完善的 Golang 中文社区,Go语言爱好者的学习家园。分享 Go 语言知识,交流使用经验">
</head>
        <title>标题</title>
        <div>过年来吃鸡啊</div>
        <div>hello regexp</div>
        <div>你在吗?</div>
        <body>呵呵</body>

<frameset cols="15,85">
   <frame src="/static/pkgdoc/i.html">
   <frame name="main" src="/static/pkgdoc/main.html" tppabs="main.html" >
   <noframes>
   </noframes>
</frameset>
</html>
   `//反引号``
   ret := regexp.MustCompile(`<div>(.*)</div>`)
   result := ret.FindAllStringSubmatch(str, -1)
   fmt.Println(result)
}

输出数据

[
    [<div>过年来吃鸡啊</div> 过年来吃鸡啊]
    [<div>hello regexp</div> hello regexp]
    [<div>你在吗?</div> 你在吗?]
]

模式匹配

(?s) 是正则表达式的模式修饰符。即Singleline(单行模式)。表示更改.的含义。使它与每一个字符匹配(包括换行符\n)。

(.*?) 是一个单元分组。“.”匹配任意字符。“*?”表重复>=0次匹配。

这个语法,在正则表达式知识里是较难的应用,不必过度学习。结论:将(?s:(.\*?))元组放置于某一特征字串中,可以提取带有这一特征字串的内容

ret := regexp.MustCompile(`<div>(?s:(.*?))</div>`)
result := ret.FindAllStringSubmatch(str, -1)
for _, subStr := range result {
    fmt.Println(subStr[1])
}
// 执行结果
// 过年来吃鸡啊
// hello regexp
// 你在吗?

捧腹网爬虫实践

  1. 请求的URL地址:

    // 第一页
    https://m.pengfue.com/xiaohua_1.html
    // 第二页
    https://m.pengfue.com/xiaohua_2.html
    // 第三页
    https://m.pengfue.com/xiaohua_3.html
    

    使用浏览器自带功能,查看网页源码,每一个网页中共有 10 条段子。每个标题都对应有一个独立的URL链接,该URL以 <h1 class="f18"><a href="开头,以"结尾。

    点击该链接,可以打开一个独立的页面,包含该段子对应的标题及内容。也就是说,主页中的10条段子,可以在10个网页中分别呈现。

    查看一个段子网页源码,找寻“标题”和“正文内容”规律。发现:

    • 标题被包裹在 <a href="https://m.pengfue.com/content/1857797/" title="标题"> 中。
    • 正文内容被包裹在 <div class="con-txt">正文内容</div> 中。
  2. 获取服务器返回的响应数据

  3. 过滤、保存、使用得到的数据

  4. 关闭请求链接。

相关代码

首先提取一个网页中10个段子所对应页面的URL。

package main
import (
	"crypto/tls"
	"fmt"
	"net/http"
	"regexp"
	"strconv"
)

func HttpGet(url string)(result string, err error)  {
    // 处理 https 协议
	tr := &http.Transport{
		TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
	}
	client := &http.Client{Transport: tr}
	resp, err1 := client.Get(url)
	if err1 != nil {
		err = err1
		return
	}
	defer resp.Body.Close()
	buf := make([]byte, 4096)
	for {
		n, _ := resp.Body.Read(buf)
		if n == 0 {
			break
		}
		result += string(buf[:n])
	}
	return
}

func SpiderPage(idx int) {
	url := "https://m.pengfu.com/xiaohua_" + strconv.Itoa(idx) + ".html"
	result, err := HttpGet(url)
	if err != nil {
		fmt.Println("HttpGet err:", err)
		return
	}
	// fmt.Println(result)
	// 从 result 中提取各个段子的url, 
    // 使用正则表达式:`<h1 class="f18"><a href=" 一个段子的URL " 
	// 解析编译正则表达式
	ret := regexp.MustCompile(`<h1 class="f18"><a href="(?s:(.*?))"`)
	if ret == nil {
		fmt.Println("regexp.MustCompile err:", err)
		return
	}
	// 2.取需要信息
	alls := ret.FindAllStringSubmatch(result, -1)

	// 提取一个段子的 URL
	for _, jokeURL := range alls {
		fmt.Println("url=", jokeURL[1])
	}
}

func working(start, end int) {
	// 测试
	fmt.Printf("正在爬取 %d 到 %d 页\n", start, end)
	for i:=start; i<=end; i++ {
		SpiderPage(i)
	}
}

func main()  {
	// 指定爬取的起始、终止页面
	var start, end int
	fmt.Printf("1请输入爬取的起始页( >= 1 ):")
	fmt.Scan(&start)
	fmt.Printf("请输入爬取的终止页( >= 起始页):")
	fmt.Scan(&end)

	// 封装函数,专门完成爬取工作。
	working(start, end)
}

第二步, 封装函数将每一个段子所对应的页面中的标题和正文内容取出。

func SpiderJokePage(jokeURL string) (title, content string, err error) {
    // 读取段子页面内容
    result, err1 := HttpGet(jokeURL)
    if err1 != nil {
        err = err1
        return
    }
    //解析、编译正则表达式, 处理 title
    // <a href="https://m.pengfue.com/content/1857797/" title="标题">
    ret1 := regexp.MustCompile(`<a href=".*?" title="(?s:(.*?))">`)
    if ret1 == nil {
        err = fmt.Errorf("%s", "MustCompile err")
        return
    }
    // 提取 title
    // 有两处,取第一处
    tmpTitle := ret1.FindAllStringSubmatch(result, 1)   	
    for _, data := range tmpTitle {
        // 存至返回值 title
        title = data[1]             			
        title = strings.Replace(title, "\t", "", -1)
        // 取一个即可。
        break                 			
    }

    //解析、编译正则表达式, 处理 content 
    // `<div class="con-txt">正文内容</div>` 
    ret2 := regexp.MustCompile(`<div class="con-txt">(?s:(.*?))</div>`)
    if ret2 == nil {
        err = fmt.Errorf("%s", "MustCompile err")
        return
    }
    // 提取 Content
    tmpContent := ret2.FindAllStringSubmatch(result, -1)
    for _, data := range tmpContent {
        // 存至返回值 content
        content = data[1]
        content = strings.Replace(content, "\t", "", -1)
        // 提取一个即可。
        break                 			
    }
    return
}

第三步,封装函数,将每页的10个段子标题及内容保存成一个.txt文件,以页号命名此文件。由于反复打开追加实现较为复杂,首先我们先将读到的所有标题和正文内容保存到 []string 中,然后一次性写入文件。

func SaveJoke2File(idx int, fileTitle, fileContent []string) {
    f, err := os.Create(strconv.Itoa(idx) + ".txt")
    if err != nil {
        fmt.Println("Create err:", err)
        return
    }
    defer f.Close()

    n := len(fileTitle)
    for i:=0; i<n; i++ {
        // 写入标题
        f.WriteString(fileTitle[i] + "\n")
        // 写入内容
        f.WriteString(fileContent[i] + "\n")
        // 写一个华丽分割线
        f.WriteString("--------------------------------------------------------------\n")
    }
}

测试,可以完成从指定网页中提取段子标题和正文,存至文件中。

并发实现

起一个 goroutine 去调用SpiderPage函数即可。但同样需要借助channel,控制主goroutine在创建子协程完成后,不会立即结束。

func working(start, end int) {
    fmt.Printf("正在爬取 %d 到 %d 页\n", start, end) // 测试
    page := make(chan int)

    for i:=start; i<=end; i++ {
        go SpiderPage(i, page)
    }

    for i:=start; i<=end; i++ {
        fmt.Printf("第%d个页面爬取完毕\n", <-page)
    }
}

同时当然也需要在 SpiderPage 函数结尾处,向page通道中写入数据。完成同步。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值