在前一篇【手把手教你用Go开发客户端软件(使用Go + HTML)】中,我们详细介绍了如何通过Go语言开发一个简单的桌面客户端软件。本次,我们将继续这个系列,使用Go语言结合Sciter的Go绑定库——go-sciter,实战开发一个可以生成网站Sitemap的小工具。
Sitemap 是什么
Sitemap是指网站地图,主要用于列出网站的所有页面,以便搜索引擎更容易地爬取网站内容。通常情况下,Sitemap文件是一个XML格式的文件,里面包含了网站上所有希望被搜索引擎索引的链接。通过Sitemap,网站管理员可以更好地告知搜索引擎哪些页面是重要的、哪些页面需要更新。
Sitemap的好处包括:
- 提升SEO:帮助搜索引擎更快更全面地索引网页。
- 提高爬取效率:确保搜索引擎能发现和索引所有的页面,尤其是深层次或孤立页面。
- 内容更新通知:搜索引擎可以根据Sitemap中的更新时间来判断页面是否需要重新爬取。
Sitemap生成思路
在开发这个Sitemap生成器时,我们的核心思路是遍历一个网站的所有链接,并根据需要生成相应的Sitemap。主要流程如下:
-
用户输入网址:用户在前端界面中输入目标网站的URL,并点击“生成”按钮。
-
异步调用生成逻辑:程序在后台异步执行Sitemap生成的逻辑,避免阻塞用户的操作体验。
-
请求网站页面:程序收到用户提交的网址后,从入口页面开始发起HTTP请求,获取页面内容。
-
遍历页面链接:程序对页面进行解析,提取页面中的所有链接,并将它们加入队列中。
-
检查现有Sitemap:如果网站已有Sitemap,程序会优先读取Sitemap中的链接,并将其加入队列,同时去除重复的链接。
-
协程处理链接:启动一个协程,从队列中逐一取出链接,继续对这些页面发起请求并解析内容,提取更多链接加入队列。
-
处理过的链接标记:每处理完一个链接,就会将其标记为已处理,并将该链接写入到生成的Sitemap文件中。
-
循环处理:重复上述过程,直到队列中的所有链接都被处理完毕,最后生成完整的Sitemap。
-
前端显示进度:在生成过程中,前端会定期刷新并显示当前的进度,例如已处理的链接数量和Sitemap的生成状态。
整个过程可以概括为爬取、分析、去重、保存四个步骤,确保在网站的大量链接中不漏掉重要页面,同时避免重复的链接被多次处理。
具体代码实现
1. 初始化项目
注意:go-sciter 需要使用最新的 v0.5.1-0.20220404063322-7f18ada7f2f5
main.go 的代码
该程序使用Sciter GUI库创建了一个窗口应用,主要功能包括:
加载并显示嵌入的HTML视图文件。
定义了打开URL、获取正在运行的任务信息和创建新任务的功能。
通过openUrl函数在系统默认浏览器中打开链接。
getRunningTask函数返回当前正在运行的任务信息。
createTask函数接收域名参数,创建一个新的爬虫任务,并保存站点地图到文件。
package mainimport ("anqicms.com/sitemap/utils""embed""encoding/json""github.com/sciter-sdk/go-sciter""github.com/sciter-sdk/go-sciter/window""github.com/skratchdot/open-golang/open""log""os""path/filepath""strings"
)//go:embed all:views
var views embed.FStype Map map[string]interface{}func main() {w, err := window.New(sciter.SW_TITLEBAR|sciter.SW_RESIZEABLE|sciter.SW_CONTROLS|sciter.SW_MAIN|sciter.SW_ENABLE_DEBUG, &sciter.Rect{Left: 100,Top: 50,Right: 1100,Bottom: 660,})if err != nil {log.Fatal(err)}w.SetCallback(&sciter.CallbackHandler{OnLoadData: func(params *sciter.ScnLoadData) int {if strings.HasPrefix(params.Uri(), "home://") {fileData, err := views.ReadFile(params.Uri()[7:])if err == nil {w.DataReady(params.Uri()[7:], fileData)}}return 0},})w.DefineFunction("openUrl", openUrl)w.DefineFunction("getRunningTask", getRunningTask)w.DefineFunction("createTask", createTask)mainView, err := views.ReadFile("views/main.html")if err != nil {os.Exit(0)}w.LoadHtml(string(mainView), "")w.SetTitle("Sitemap 生成")w.Show()w.Run()
}func openUrl(args ...*sciter.Value) *sciter.Value {link := args[0].String()_ = open.Run(link)return nil
}// 获取运行中的task
func getRunningTask(args ...*sciter.Value) *sciter.Value {if RunningCrawler == nil {return nil}return jsonValue(RunningCrawler)
}// 创建任务
func createTask(args ...*sciter.Value) *sciter.Value {domain := args[0].String()exePath, _ := os.Executable()sitemapPath := filepath.Dir(exePath) + "/" + utils.GetMd5String(domain, false, true) + ".txt"crawler, err := NewCrawler(CrawlerTypeSitemap, domain, sitemapPath)if err != nil {return jsonValue(Map{"msg": err.Error(),"status": -1,})}crawler.OnFinished = func() {// 完成时处理函数}crawler.Start()return jsonValue(Map{"msg": "任务已创建","status": 1,})
}func jsonValue(val interface{}) *sciter.Value {buf, err := json.Marshal(val)if err != nil {return nil}return sciter.NewValue(string(buf))
}
2 前端设计:
使用go-sciter库实现前端界面,包含一个输入框和“生成”按钮。用户在输入框中填写目标网址后,点击按钮启动Sitemap生成。
views/task.html 的代码
HTML 结构:
定义了一个带有布局的简单网页,包括侧边栏 (aside) 和主要内容区域 (container)。
自定义标签与属性:
resizeable:指示页面可调整大小。
脚本 (text/tiscript):
变量与函数:
running:标记任务是否正在运行。
syncTask():同步并显示任务状态。
showResult(result):展示任务结果。
事件监听:
click 事件绑定到按钮,用于触发任务开始/取消操作。
定时器:
每秒调用 syncTask() 更新任务状态。
功能概述:
用户可以输入网站地址以生成网站地图。
提供“开始执行”和“停止”按钮控制任务。
显示任务进度和结果。
<html resizeable>
<head><style src="home://views/style.css" /><meta charSet="utf-8" />
</head>
<body>
<div class="layout"><div class="aside"><h1 class="soft-title"><a href="home://views/main.html">Sitemap<br/>生成器</a></h1><div class="aside-menus"><a href="home://views/task.html" class="menu-item active">开始使用</a><a href="home://views/help.html" class="menu-item">使用教程</a></div></div><div class="container"><div><form class="control-form" #taslForm><div class="form-header">、<h3>Sitemap 生成</h3></div><div class="form-content"><div class="form-item"><div class="form-label">网址地址:</div><div class="input-block"><input(domain) class="layui-input" type="text" placeholder="http://或https://开头的网站地址" /><div class="text-muted">程序将抓取推送网址下的所有链接。</div></div></div><div><button type="default" class="stop-btn" #cancelTask>停止</button><button type="default" #taskSubmit>开始执行</button></div></div></form><div class="result-list" #resultList><div class="form-header"><h3>查看结果</h3></div><div class="form-content"><table><colgroup><col width="40%"><col width="60%"></colgroup><tbody><tr><td>目标站点</td><td #resultDomain></td></tr><tr><td>保存结果</td><td #resultPath></td></tr><tr><td>任务状态</td><td #resultStatus></td></tr><tr><td>发现页面</td><td #resultTotal></td></tr><tr><td>已处理页面</td><td #resultFinished></td></tr><tr><td>错误页面</td><td #resultNotfound></td></tr></tbody></table></div></div></div></div>
</div></body>
</html><script type="text/tiscript">let running = false;function syncTask() {let res = view.getRunningTask()if (res) {let result = JSON.parse(res);running = true;$(#cancelTask).@.addClass("active");$(#resultList).@.addClass("active");$(#taskSubmit).text = "执行中";showResult(result);} else {running = false;$(#cancelTask).@.removeClass("active");$(#resultList).@.removeClass("active");$(#taskSubmit).text = "开始执行";return;}}event click $(#cancelTask){$(#cancelTask).@.removeClass("active");$(#resultList).@.removeClass("active");}event click $(#taskSubmit){let res = view.createTask($(#taslForm).value.domain)let result = JSON.parse(res)view.msgbox(#alert, result.msg);if (result.status == 1) {// 同步结果syncTask();}}// 打开本地路径event click $(#resultPath){view.openUrl($(#resultPath).text)}// 展示结果function showResult(result) {if (!result) {return;}$(#resultDomain).text = result.domain;$(#resultPath).text = result.save_path;$(#resultStatus).text = result.status;$(#resultTotal).text = result.total + "条";$(#resultFinished).text = result.finished + "条";$(#resultNotfound).text = result.notfound + "条";}// 进来的时候先执行一遍syncTask();// 1秒钟刷新一次self.timer(1000ms, function() {syncTask();return true;});
</script>
网页的抓取以及Sitemap的保存
限于篇幅,这里只列出了部分代码
简要说明一下:爬虫支持采集服务端渲染的静态页面,也支持采集客户端渲染的页面。如果网页是客户端渲染,则会调用ChromeDP来进行先渲染后抓取的操作步骤。
crawler.go 的部分代码
var RunningCrawler *Crawlerfunc NewCrawler(crawlerType string, startPage string, savePath string) (*Crawler, error) {if RunningCrawler != nil {RunningCrawler.Stop()}urlObj, err := url.Parse(startPage)if err != nil {log.Printf("解析起始地址失败: url: %s, %s", startPage, err.Error())return nil, err}if crawlerType != CrawlerTypeCollect {if crawlerType == CrawlerTypeDownload {_, err = os.Stat(savePath)if err != nil {log.Errorf("存储地址不存在")return nil, err}} else {// 检测上级目录_, err = os.Stat(filepath.Dir(savePath))if err != nil {log.Errorf("存储地址不存在")return nil, err}}}log.SetLevel(log.INFO)ctx, cancelFunc := context.WithCancel(context.Background())crawler := &Crawler{ctx: ctx,Cancel: cancelFunc,Type: crawlerType,PageWorkerCount: 5,AssetWorkerCount: 5,SavePath: savePath,PageQueue: make(chan *URLRecord, 500000),AssetQueue: make(chan *URLRecord, 500000),LinksPool: &sync.Map{},LinksMutex: &sync.Mutex{},Domain: startPage,MaxRetryTimes: 3,IsActive: true,lastActive: time.Now().Unix(),gRWLock: new(sync.RWMutex),}mainSite := urlObj.Host // Host成员带端口.crawler.MainSite = mainSiteerr = crawler.LoadTaskQueue()if err != nil {log.Errorf("加载任务队列失败: %s", err.Error())cancelFunc()return nil, err}crawler.Id = int(time.Now().Unix())if crawlerType == CrawlerTypeSitemap {crawler.sitemapFile = NewSitemapGenerator("txt", crawler.SavePath, false)}RunningCrawler = crawlerreturn crawler, nil
}func (crawler *Crawler) isCanceled() bool {select {case <-crawler.ctx.Done():return truedefault:return false}
}// Start 启动n个工作协程
func (crawler *Crawler) Start() {req := &URLRecord{URL: crawler.Domain,URLType: URLTypePage,Refer: "",Depth: 1,FailedTimes: 0,}crawler.EnqueuePage(req)//todo 加 waitGroupfor i := 0; i < crawler.PageWorkerCount; i++ {go crawler.GetHTMLPage(i)}// only download need to work with assetsif crawler.Type == CrawlerTypeDownload {for i := 0; i < crawler.AssetWorkerCount; i++ {go crawler.GetStaticAsset(i)}}//检查活动go crawler.CheckProcess()
}func (crawler *Crawler) Stop() {if !crawler.IsActive {return}crawler.LinksMutex.Lock()crawler.IsActive = false//停止//time.Sleep(200 * time.Millisecond)close(crawler.AssetQueue)close(crawler.PageQueue)crawler.LinksMutex.Unlock()if crawler.sitemapFile != nil {_ = crawler.sitemapFile.Save()}log.Infof("任务完成", crawler.Domain)//开始执行抓取任务if crawler.OnFinished != nil && !crawler.canceled {crawler.OnFinished()}RunningCrawler = nil
}// getAndRead 发起请求获取页面或静态资源, 返回响应体内容.
func (crawler *Crawler) getAndRead(req *URLRecord) (body []byte, header http.Header, err error) {err = crawler.UpdateURLRecordStatus(req.URL, URLTaskStatusPending)if err != nil {log.Infof("更新任务队列记录失败: req: %s, error: %s", req.URL, err.Error())return}if req.FailedTimes > crawler.MaxRetryTimes {log.Infof("失败次数过多, 不再尝试: req: %s", req.URL)return}if req.URLType == URLTypePage && crawler.Single && 1 < req.Depth {log.Infof("当前页面已达到最大深度, 不再抓取: req: %s", req.URL)return}if crawler.Render && req.URLType == URLTypePage {var content stringcontent, err = ChromeDPGetArticle(req.URL)if err != nil {log.Errorf("请求失败, 重新入队列: req: %s, error: %s", req.URL, err.Error())req.FailedTimes++if req.URLType == URLTypePage {crawler.EnqueuePage(req)}return}header = http.Header{}header.Set("Content-Type", "text/html")body = []byte(content)} else {var resp *http.Responseresp, err = getURL(req.URL, req.Refer)if err != nil {log.Errorf("请求失败, 重新入队列: req: %s, error: %s", req.URL, err.Error())req.FailedTimes++if req.URLType == URLTypePage {crawler.EnqueuePage(req)}return}defer resp.Body.Close()if resp.StatusCode >= 400 {crawler.Notfound++if crawler.Type == CrawlerType404 {crawler.SafeFile(req.URL, resp.StatusCode)}// 抓取失败一般是5xx或403, 405等, 出现404基本上就没有重试的意义了, 可以直接放弃err = crawler.UpdateURLRecordStatus(req.URL, URLTaskStatusFailed)log.Infof("页面404等错误: req: %s", req.URL)if err != nil {log.Errorf("更新任务记录状态失败: req: %s, error: %s", req.URL, err.Error())}err = errors.New(fmt.Sprintf("页面错误:%d", resp.StatusCode))return}header = resp.Headerbody, err = io.ReadAll(resp.Body)}return
}
看看软件的成果界面:
软件主界面:
爬虫任务界面:
如果你对完整的代码感兴趣,可以访问我的GitCode仓库:Go开发桌面软件小试-网站Sitemap生成 - https://gitcode.com/anqicms/sitemap。