前言
最近发现服务器磁盘快满了,顺手翻了下服务器上的数据库,惊讶地发现有一个之前写的爬虫程序,它生成的数据竟然占了整整200GB的空间!闲来无事,我决定重新查看这段代码,回顾一下当时我是如何编写这个网站爬虫,并整理成这篇文章,分享给大家。
介绍
这是一款我用 Golang 编写的全网网址采集程序,能够自动爬取和分析互联网上几乎所有能够触及的网站信息。通过它,网站的标题、站点描述、微信号、QQ号、联系电话、运行环境、IP 信息,甚至是网站所使用的框架等都能自动采集和整理。这个项目最初是为了分析行业内的竞争对手而写的,后来我不断优化,扩展成了可以对全网行业网站进行数据收集和分类的工具。
设计思路
这个 Golang 爬虫的核心设计思路是基于广度优先搜索(BFS)的递归式爬取。它会从一个种子 URL 开始,不断抓取页面上的所有链接,并递归地继续访问这些链接,直到所有可触及的页面都被爬取。为了避免陷入死循环和重复抓取,我设计了一些去重机制和 URL 过滤规则。同时,为了避免对目标站点造成压力,爬虫还设置了并发控制。
具体流程
- 种子网站的选择:首先要从一组初始 URL 开始,即所谓的种子网站。这些 URL 可以通过一些目录网站或者搜索引擎获取。实际上,我是以 hao123以及另外几个网址导航作为种子网站。
-
网页抓取:使用 Golang 的
gorequest
包进行 HTTP 请求,抓取网页内容,由于网页会有GBK等非UTF-8编码的页面,需要进行编码判断,并对非UTF-8编码的页面进行转码。 -
页面解析:使用
goquery
包来解析 HTML 页面,提取所需的信息,如标题、描述、联系方式等。 - 重复检查和限速:为了避免抓取重复内容,设计了一个 URL 去重机制,同时为了防止爬虫对目标网站的服务器造成负担,加入了爬虫限速和并发限制。
- 运行环境检测:通过分析页面的头信息和脚本,可以大致推断出网站所使用的技术栈,比如服务器、框架等。
- 数据分析与清洗:采集到的数据通过一定的清洗和筛选,剔除无效信息,然后数据会存储在数据库中,方便后续的检索和分析。我使用了 MySQL 存储结构化数据。
代码实现
接下来,我将展示部分 Golang 爬虫的核心代码,帮助大家更好地理解其中的设计逻辑。
SingleData
函数负责从指定网站抓取数据并存储到数据库。主要步骤包括:
- 更新网站状态为正在抓取。
- 调用GetWebsite抓取网站内容,并提取数据。
- 存储抓取结果和内容数据。
- 如果存在链接,则处理每个链接,包括限制子域名数量、去重,并将新发现的网站信息存入数据库。
GetWebsite
函数用于抓取单个网站的数据。流程如下:
- 使用提供的URL发起请求。
- 处理响应,提取如标题、描述等元信息。
- 清洗HTML内容,去除脚本和样式标签。
- 解析文档以获取更多细节,如联系方式等。
- 收集页面内的链接。
CollectLinks
函数从给定的文档中收集所有有效链接,排除非域名,排除链轮类网址,进行基本验证后返回。
// SingleData 单个数据抓取
func SingleData(website Website) {
//锁定当前数据
DB.Model(&Website{}).Where("`domain` = ?", website.Domain).UpdateColumn("status", 2)
log.Println(fmt.Sprintf("开始采集:%s://%s", website.Scheme, website.Domain))
err := website.GetWebsite()
if err == nil {
website.Status = 1
} else {
website.Status = 3
}
log.Println(fmt.Sprintf("入库2:%s", website.Domain))
DB.Where("`domain` = ?", website.Domain).Updates(&website)
// 同时写入data
contentData := WebsiteData{
ID: website.ID,
Content: website.Content,
}
DB.Where("`id` = ?", contentData.ID).FirstOrCreate(&contentData)
// end
if len(website.Links) > 0 {
for _, v := range website.Links {
//如果超过了5个子域名,则直接抛弃
item, itemOk := topDomains.Load(v.TopDomain)
if itemOk {
if item.(int) >= 4 {
//跳过这个记录
continue
}
}
if _, ok := existsDomain.Load(v.Domain); ok {
continue
}
if itemOk {
topDomains.Store(v.TopDomain, item.(int)+1)
} else {
topDomains.Store(v.TopDomain, 1)
}
existsDomain.Store(v.Domain, true)
runMap.Store(v.Domain, v.Scheme)
webData := Website{
Url: v.Url,
Domain: v.Domain,
TopDomain: v.TopDomain,
Scheme: v.Scheme,
Title: v.Title,
}
DB.Clauses(clause.OnConflict{
DoNothing: true,
}).Where("`domain` = ?", webData.Domain).Create(&webData)
log.Println(fmt.Sprintf("入库:%s", v.Domain))
}
}
}
// GetWebsite 一个域名数据抓取
func (website *Website) GetWebsite() error {
if website.Url == "" {
website.Url = website.Scheme + "://" + website.Domain
}
ops := &Options{}
if ProxyValid {
ops.ProxyIP = JsonData.DBConfig.Proxy
}
requestData, err := Request(website.Url, ops)
if err != nil {
log.Println(err)
return err
}
// 注入内容
website.Content = requestData.Body
if requestData.Domain != "" {
website.Domain = requestData.Domain
website.TopDomain = getTopDomain(website.Domain)
}
if requestData.Scheme != "" {
website.Scheme = requestData.Scheme
}
website.Server = requestData.Server
//获取IP
conn, err := net.ResolveIPAddr("ip", website.Domain)
if err == nil {
website.IP = conn.String()
}
//尝试判断cms
website.Cms = getCms(requestData.Body)
//先删除一些不必要的标签
re, _ := regexp.Compile("\\<style[\\S\\s]+?\\</style\\>")
requestData.Body = re.ReplaceAllString(requestData.Body, "")
re, _ = regexp.Compile("\\<script[\\S\\s]+?\\</script\\>")
requestData.Body = re.ReplaceAllString(requestData.Body, "")
//解析文档内容
htmlR := strings.NewReader(requestData.Body)
doc, err := goquery.NewDocumentFromReader(htmlR)
if err != nil {
fmt.Println(err)
return err
}
contentText := doc.Text()
contentText = strings.ReplaceAll(contentText, "\n", " ")
contentText = strings.ReplaceAll(contentText, "\r", " ")
contentText = strings.ReplaceAll(contentText, "\t", " ")
website.Title = doc.Find("title").Text()
desc, exists := doc.Find("meta[name=description]").Attr("content")
if exists {
website.Description = desc
} else {
website.Description = strings.ReplaceAll(contentText, " ", "")
}
nameRune := []rune(website.Description)
curLen := len(nameRune)
if curLen > 200 {
website.Description = string(nameRune[:200])
}
nameRune = []rune(website.Title)
curLen = len(nameRune)
if curLen > 200 {
website.Title = string(nameRune[:200])
}
//尝试获取微信
reg := regexp.MustCompile(`(?i)(微信|微信客服|微信号|微信咨询|微信服务)\s*(:|:|\s)\s*([a-z0-9\-_]{4,30})`)
match := reg.FindStringSubmatch(contentText)
if len(match) > 1 {
website.WeChat = match[3]
}
//尝试获取QQ
reg = regexp.MustCompile(`(?i)(QQ|QQ客服|QQ号|QQ号码|QQ咨询|QQ联系|QQ交谈)\s*(:|:|\s)\s*([0-9]{5,12})`)
match = reg.FindStringSubmatch(contentText)
if len(match) > 1 { //
website.QQ = match[3]
}
//尝试获取电话
reg = regexp.MustCompile(`([0148][1-9][0-9][0-9\-]{4,15})`)
match = reg.FindStringSubmatch(contentText)
if len(match) > 1 {
website.Cellphone = match[1]
}
website.Links = CollectLinks(doc)
return nil
}
// CollectLinks 读取页面链接
func CollectLinks(doc *goquery.Document) []Link {
var links []Link
aLinks := doc.Find("a")
//读取所有连接
existsLinks := map[string]bool{}
for i := range aLinks.Nodes {
href, exists := aLinks.Eq(i).Attr("href")
title := strings.TrimSpace(aLinks.Eq(i).Text())
if exists {
scheme, host := ParseDomain(href)
if host != "" && scheme != "" {
hosts := strings.Split(host, ".")
if len(hosts) < 2 || (len(hosts) > 3 && hosts[0] != "www") || (len(hosts) == 3 && hosts[0] != "www" && len(hosts[1]) > 4) {
//refuse
} else {
if !existsLinks[host] {
//去重
existsLinks[host] = true
links = append(links, Link{
Title: title,
Url: scheme + "://" + host,
Domain: host,
TopDomain: getTopDomain(host),
Scheme: scheme,
})
}
}
}
}
}
return links
}
结语
这就是我使用 Golang 编写的全网网址爬取程序的基本思路和部分实现。整个项目虽然简单,但在实际应用中也遇到了一些挑战,如去重、大量无效链接、泛域名、限速和防止封禁等问题。如果你也对 Golang 爬虫感兴趣,不妨尝试自己动手写一个,结合自己的需求来定制化实现!
如果你想看完整的源码,可以访问我的Github仓库:https://github.com/fesiong/cobweb