Go开发桌面客户端软件小试:网站Sitemap生成
在前一篇【手把手教你用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 main
import (
"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.FS
type 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 *Crawler
func 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 = mainSite
err = 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 = crawler
return crawler, nil
}
func (crawler *Crawler) isCanceled() bool {
select {
case <-crawler.ctx.Done():
return true
default:
return false
}
}
// Start 启动n个工作协程
func (crawler *Crawler) Start() {
req := &URLRecord{
URL: crawler.Domain,
URLType: URLTypePage,
Refer: "",
Depth: 1,
FailedTimes: 0,
}
crawler.EnqueuePage(req)
//todo 加 waitGroup
for i := 0; i < crawler.PageWorkerCount; i++ {
go crawler.GetHTMLPage(i)
}
// only download need to work with assets
if 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 string
content, 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.Response
resp, 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.Header
body, err = io.ReadAll(resp.Body)
}
return
}
看看软件的成果界面:
软件主界面:
爬虫任务界面:
如果你对完整的代码感兴趣,可以访问我的GitCode仓库:Go开发桌面软件小试-网站Sitemap生成 - https://gitcode.com/anqicms/sitemap。