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

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

作者: Fesion | 来源:发表于2024-08-21 22:28 被阅读0次

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

sitemapgen2.png

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
}

看看软件的成果界面:

软件主界面:


sitemapgen-1.png

爬虫任务界面:

sitemapgen2.png

如果你对完整的代码感兴趣,可以访问我的GitCode仓库:Go开发桌面软件小试-网站Sitemap生成 - https://github.com/fesiong/sitemap

相关文章

网友评论

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

      本文链接:https://www.haomeiwen.com/subject/gzdikjtx.html