Bootstrap

中秋节快到了,确定不爬点月饼送岳母娘?

中秋节快到了,确定不爬点月饼送岳母娘?

最近在学Go时,发现Go语言写爬虫好像也不错,恰逢中秋节,于是想爬点月饼的图片玩玩,各位也可以爬点送岳母娘啊~

温馨提示:本文是Go爬虫的教学博文,不会讨论过多有关Go语言写爬虫的重难点,不要担心看不懂,我也会介绍本文中用到的所有知识....如果是大佬,就此止步吧~ 也可以给本菜鸟点个赞再走~

一、获取页面图片链接

我们这里先介绍如何获取一个页面里面的图片链接。原理很简单,就是先利用我们编写的函数获取页面源代码,然后利用正则表达式获取图片链接,然后将链接保存到字符串数组里面。

下面展示函数:

func GetHtml(url string) string {
  resp, _ := http.Get(url)
  defer resp.Body.Close()

  bytes, _ := ioutil.ReadAll(resp.Body)
  html := string(bytes)
  return html
}

本函数需要注意的是,需要延时关闭。

下面展示函数:

func GetPageImgurls(url string) []string {
  html := GetHtml(url)

  re := regexp.MustCompile(ReImg)
  rets := re.FindAllStringSubmatch(html, -1)

  imgUrls := make([]string, 0)
  for _, ret := range rets {
    imgUrl := "https://www.yuebing.com/"+ret[1]
    imgUrls = append(imgUrls, imgUrl)
  }
  return imgUrls
}

因为爬取到的路径是相对路径,所以需要将相对路径前面加上域名、协议等信息形成绝对路径存入字符串数组中,便于以后下载图片。

二、实现同步下载功能

接着我们来实现同步下载功能,我们是将图片以时间戳命名保存到硬盘中。

下面展示函数:

func DownloadImg(url string)  {
  resp, _ := http.Get(url)
  defer resp.Body.Close()
  filename := `E:\\code\\src\\day4\\爬取图片\\img\\`+strconv.Itoa(int(time.Now().UnixNano()))+".jpg"
  imgBytes, _ := ioutil.ReadAll(resp.Body)
  err := ioutil.WriteFile(filename, imgBytes, 0644)
  if err == nil{
    fmt.Println(filename+"下载成功!")
  }else{
    fmt.Println(filename+"下载失败!")
  }
}

这个是图片字节流,下面的代表r w x分别是4 2 1,所以这个0644代表拥有文件的用户可读可写,同一组的用户可读,其他用户可读。

  owner   group   other
0 - rwx  -  rwx  -  rwx

另外,在处理strconv.Itoa(int(time.Now().UnixNano()))时,需要将时间戳改为int类型因为itoa时将int类型转为字符串类型,而时间戳是int64类型的。

三、实现异步下载功能

有人说用Go实现异步下载很容易啊~一行代码就能实现,嘿嘿嘿。没错,我们先看一看怎么实现的。

func DownloadImgAsync(url string)  {
  go DownloadImg(url)
}

但是这样,多少张图片就需要开辟多少条协程。

我们应该怎么办呢?

chSem = make(chan int,5)

先建立一个管道,容量为5,这样就可以同时下载张图片,也就是并发量为5.

func DownloadImgAsync(url string)  {
  downloadWG.Add(1)
  go func() {
    chSem <- 1
    DownloadImg(url)
    <-chSem
    downloadWG.Done()
  }()
    downloadWG.Wait()
}

然后每次下载前往管道里面写入一个数,下载完就从管道读出一个数,这样就保证每次最多同时只下载5张照片。

然后你想到了运行会出现什么问题吗?

对的,我们保存文件是以时间戳命名的,如果异步下载的话,可能多个文件时间戳一致,所以我们得生成随机文件名。

四、生成随机文件名

上面我们说到了要生成随机文件名,下面我们就来写吧~

首先先要生成随机数,我打算在时间戳后面添加一个随机数来避免文件名重复。

先来展示一下生成随机数的代码:

func GetRandomInt(start,end int) int {
  randomMT.Lock()
  <- time.After(1 * time.Nanosecond)
  r := rand.New(rand.NewSource(time.Now().UnixNano()))
  ret := start + r.Intn(end - start)
  randomMT.Unlock()
  return ret

}

先建立一个互斥锁,然后阻塞一纳秒,然后计算范围内的随机数,然后解开互斥锁,最后返回这个字符串。

接下来的生成随机文件名的函数就比较简单了:

func GetRandomName() string {
   timestamp := strconv.Itoa(int(time.Now().UnixNano()))
   randomNum := strconv.Itoa(GetRandomInt(100, 10000))
   return timestamp + "-" + randomNum
}

就是生成时间戳和随机数,然后拼接。

五、使用Title属性作为文件名

我们是利用正则表达式获取图片链接和图片名Title的,刚开始我想是一个正则表达式爬取链接,一个爬取名称,但是有没有可能有图片没有Title属性,所以我选择爬取所有的不管是否有Title属性的信息。就像这样:

我们先来看看有关的第一段代码:

func GetPageImginfos(url string) []map[string] string {
  html := GetHtml(url)

  re := regexp.MustCompile(ReImgName)
  rets := re.FindAllStringSubmatch(html, -1)
  imgInfos := make([]map[string] string,0)
  for _,ret := range rets {
    imgInfo := make(map[string] string)
    imgUrl := "https://www.yuebing.com/"+ret[1]
    imgInfo["url"] = imgUrl[0:78]
    imgInfo["filename"]=GetImgNameTag(ret[1])

    //fmt.Println(imgInfo["filename"])

    imgInfos = append(imgInfos, imgInfo)

  }
  return imgInfos
}

这段代码是利用正则表达式

ReImgName = ``

爬取带有图片链接和Title属性的字符串,然后将url和filename保存到Map中,因为图片链接都是一样长的,所以比较省事这里利用截取字符串就行了,但是Title标签就没这么轻松,它的长度是不固定的。那么怎么办呢?

下面展示一下怎么获取Title标签内的值吧:

func GetImgNameTag(imgTag string) string {
  re := regexp.MustCompile(ReTitle)
  rets := re.FindAllStringSubmatch(imgTag, -1)
  //fmt.Println(rets)
  if len(rets) > 0{
    return rets[0][1]
  }else {
    return GetRandomName()
  }
}

我们是再次使用正则表达式来获取Title内的值的。

正则表达式内容如下:

ReTitle = `title="(.+)`

这个爬虫就初步完成了。赶快爬点送岳母娘吧~

然后我就发现了一个大问题。

就是我发现这个异步下载只能异步下载没一页,并不能并发下载多页的图片。于是要对程序进行修改.......

我们把异步下载函数加上参数

func DownloadImgAsync(url ,filename string,wg *sync.WaitGroup)  {
  wg.Add(1)
  go func() {
    chSem <- 1
    DownloadImg(url,filename)
    <-chSem
    downloadWG.Done()
  }()
}

然后不在这里wait,而在主函数里面wait。

这里展示一下主函数。

func main() {
  for i:=1;i<=15;i++{
    j := strconv.Itoa(i)
    url := "https://www.yuebing.com/category-0-b0-min0-max0-attr0-" + j + "-sort_order-ASC.html"
    imginfos := GetPageImginfos(url)
    for _,imgInfoMap := range imginfos{
      DownloadImgAsync(imgInfoMap["url"],imgInfoMap["filename"],&downloadWG)
      time.Sleep(500 * time.Millisecond)
    }
  }
  downloadWG.Wait()
}

这样就明显速度快多了。

下面介绍本文中用到的相关知识。

六、 Go并发之CSP并发模型、协程并发

这个爬虫采用了并发下载图片,Go并发采用的是CSP并发模型,而Go使用的是协程。所以我们来谈谈什么是CSP并发模型和什么是协程。

1. 什么是CSP并发模型

CSP通信顺序进程交谈循序程序,又被译为交换消息的循序程序(communicating sequential processes),它是一种用来描述并发性系统之间进行交互的模型。

CSP模型的最大优点是灵活。但是容易出现死锁的情况,且未给予直接的并行支持,并行需要建立在并发的基础之上。

在CSP模型里面,进程间需要经过一种被称为管道来进行通信。

什么是管道,两个并发任务不需要共享内存,而是通过建立一条点对点的管道,数据用完之后,管道立即撤销。有了管道,不需要事先锁,而是需要用数据时建立管道。不需要数据时就撤销管道了。

管道与共享内存之间有很大的区别,内存共享是通过内存来共享内存,而管道是通过通信来共享内存。所以管道通信比内存共享效率要高很多。

2. 协程

coroutine就是协程,也称为go程。通过管道能够实现百万级的并发。如果说线程是抢占式的,那么协程是协作式的。在协程里面,也是通过管道来调度的。解放线程对CPU和内存的开销,线程是先占用CPU和内存后才调度,而协程是通过通信发送信号来调度,协程全是通过管道,由于协程的消耗比线程小很多,所以能够实现百万并发。

在协程中,IO操作时绝大部分时间与CPU无关,这是管道带来的优势,不需要长时间锁住内存,也不需要CPU来做调度。

8G内存的电脑,用JAVA,C来做并发,差不多也就千级并发,而用GO语言,通过管道可以让并发能力得到很大提升。

七、Golang的同步等待组

我们现在开十条子协程,然后当十条子协程全部结束后,主协程立马结束。动动你的小脑袋,想一想应该怎么做?如果是一条子协程的话就很容易实现,当这条子协程结束时让主协程结束就行了。但是我们现在是10条,让任何一条子协程发布让主协程结束的命令都不行,因为你无法确定哪一条子协程是最后结束的。所以我们现在用上了等待组

等待组是什么原理呢?创造一个子协程就登记一下,然后子协程干完活就将其除名,名单除干净了就结束主协程。

我们来看看等待组的有关示例:

func main() {
    fmt.Println(time.Now())
    var wg sync.WaitGroup
    //起一个协程就加一
    wg.Add(1)
    go func() {
        for i:=0;i<5;i++{
            fmt.Println(i)
            //相当于阻塞一秒,读到时间
            <- time.After(time.Second)
        }
        fmt.Println(time.Now())
        //活干完之后减一
        wg.Done()

    }()

    wg.Add(1)
    go func() {
        var i int
        ticker := time.NewTicker(time.Second)
        for{
            <- ticker.C
            i++
            fmt.Println("秒表",i)
            if i>9 {
                break
            }
        }
        fmt.Println(time.Now())
        wg.Done()
    }()
    //等待组阻塞等待至记录清零为止
    wg.Wait()
    fmt.Println("END")
}

这段代码是建立一条协程就使用wg.Add(1)给等待组加一,然后活干完之后就减一。

WaitGroup 等待一组 goroutine 完成。主 goroutine 调用 Add 来添加要等待的 goroutine 的数量。 然后每个 goroutine 运行并在完成时调用 Done。 同时,Wait 可用于阻塞,直到所有 goroutine 完成。

Add()方法是用来设置等待组中的计数器的值,我们可以理解每个等待组中都有一个计数器,这个计数器可以用来表示这个等待组中要执行的协程数量。如果计数器为,那么表示被阻塞的协程都被释放了。

Done()方法就是当同步等待组中的某个协程执行完毕后,使同步等待组中的计数器数量减一。

这里一条协程5秒结束,另一条协程10秒结束,那按理来说应该是10秒结束,我们来看看运行结果吧!

2021-08-25 19:10:28.3511953 +0800 CST m=+0.016989401
0
1
秒表 1
2
秒表 2
秒表 3
3
秒表 4
4
秒表 5
2021-08-25 19:10:33.4452142 +0800 CST m=+5.111008301
秒表 6
秒表 7
秒表 8
秒表 9
秒表 10
2021-08-25 19:10:38.4369656 +0800 CST m=+10.102759701
END

下面来谈谈几个需要注意的事项:

八、 Golang的互斥锁

我们都知道有并发就有并发安全的问题。对于有的变量不能是并发运行访问的。比如银行的存取款业务,假如可以并发进行的话,你想一想你往银行存这个月的工资200万,你老婆同一时间在银行取200万去做美容。假如不使用,你存完之后发现金额没有变化,你老婆取完钱后发现钱也没有变化。你是慌死了,那你老婆不高兴坏了.......

所以我们这里就需要用到,当一个人访问这个业务时,就给它加上,别人就不能访问了。

看一看这个存钱的例子:

var wg sync.WaitGroup
func main() {
    var money = 2000
    for i:=0;i<10;i++{
        wg.Add(1)
        go func() {
            for j:=0;j<100;j++{
                money += 1
            }
            wg.Done()
        }()
    }
    wg.Wait()
    fmt.Println("最终金额",money)
}

这个例子就是10个人每个人给你存100块钱。这一百块钱分一百次存。这样存完后我们就有三千块钱了。

我们看一看运行结果

最终金额 3000

好像是没问题哦!那我们加大一下存款金额吧。让10个人每个人存1000,这一千块钱分一千次存,这样我们就会得到一万二千块钱,来看一看运行结果吧!

最终金额 10366

是不是和我们预想得不一样?

这就是出现了并发安全问题

对于这种问题,我们应该不允许并发访问。

然后我们看看怎么使用互斥锁解决这类问题吧!

func main() {
    var money = 2000

    var mt sync.Mutex

    wg.Add(1)
    go func() {
        fmt.Println("搏达试图抢断")

        mt.Lock()
        fmt.Println("搏达抢断成功")

        money -= 300
        <- time.After(10 * time.Second)

        mt.Unlock()
        fmt.Println("搏达扔了球")
        wg.Done()
    }()

    wg.Add(1)
    go func() {
        fmt.Println("搏达试图跳舞")

        mt.Lock()
        fmt.Println("搏达跳舞成功")

        money -= 500
        <- time.After(10 * time.Second)
        mt.Unlock()
        fmt.Println("搏达放弃跳舞")
        wg.Done()
    }()

    wg.Wait()

}

这段程序的意义是两个协程同时抢锁,跳舞协程先抢到锁的话,搏达就开始跳舞,然后跳完舞解锁,抢断协程开始抢到锁,然后搏达结束跳舞开始抢断。如果抢断协程先抢到锁的话,搏达就先开始抢断然后再跳舞。

运行结果

搏达试图抢断
搏达抢断成功
搏达试图跳舞
搏达扔了球
搏达跳舞成功
搏达放弃跳舞

我们可以看到,搏达扔了球才能开始跳舞。这就是锁的功劳,让搏达不至于一边跳舞一边抢断而累趴。

九、最后

代码我已经上传到Github了,请自取。

https://github.com/ReganYue/Crawling_yuebing_pics