Go开发桌面客户端软件小试:网站Sitemap生成

在前一篇【手把手教你用Go开发客户端软件(使用Go + HTML)】中,我们详细介绍了如何通过Go语言开发一个简单的桌面客户端软件。本次,我们将继续这个系列,使用Go语言结合Sciter的Go绑定库——go-sciter,实战开发一个可以生成网站Sitemap的小工具。

请添加图片描述

Sitemap 是什么

Sitemap是指网站地图,主要用于列出网站的所有页面,以便搜索引擎更容易地爬取网站内容。通常情况下,Sitemap文件是一个XML格式的文件,里面包含了网站上所有希望被搜索引擎索引的链接。通过Sitemap,网站管理员可以更好地告知搜索引擎哪些页面是重要的、哪些页面需要更新。

Sitemap的好处包括:

  • 提升SEO:帮助搜索引擎更快更全面地索引网页。
  • 提高爬取效率:确保搜索引擎能发现和索引所有的页面,尤其是深层次或孤立页面。
  • 内容更新通知:搜索引擎可以根据Sitemap中的更新时间来判断页面是否需要重新爬取。

Sitemap生成思路

在开发这个Sitemap生成器时,我们的核心思路是遍历一个网站的所有链接,并根据需要生成相应的Sitemap。主要流程如下:

  1. 用户输入网址:用户在前端界面中输入目标网站的URL,并点击“生成”按钮。

  2. 异步调用生成逻辑:程序在后台异步执行Sitemap生成的逻辑,避免阻塞用户的操作体验。

  3. 请求网站页面:程序收到用户提交的网址后,从入口页面开始发起HTTP请求,获取页面内容。

  4. 遍历页面链接:程序对页面进行解析,提取页面中的所有链接,并将它们加入队列中。

  5. 检查现有Sitemap:如果网站已有Sitemap,程序会优先读取Sitemap中的链接,并将其加入队列,同时去除重复的链接。

  6. 协程处理链接:启动一个协程,从队列中逐一取出链接,继续对这些页面发起请求并解析内容,提取更多链接加入队列。

  7. 处理过的链接标记:每处理完一个链接,就会将其标记为已处理,并将该链接写入到生成的Sitemap文件中。

  8. 循环处理:重复上述过程,直到队列中的所有链接都被处理完毕,最后生成完整的Sitemap。

  9. 前端显示进度:在生成过程中,前端会定期刷新并显示当前的进度,例如已处理的链接数量和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