Fyne ( go跨平台GUI )项目实战-使用goquery库进行网络爬虫与信息抓取-查询基金网季报数据

104 阅读14分钟

# Fyne ( go跨平台GUI )项目实战-使用goquery库进行网络爬虫与信息抓取-查询基金网季报数据

1. goquery库介绍、下载与安装

1.1 goquery库简介

goquery是一个使用Go语言编写的jQuery风格的HTML解析库,它基于Go的HTML解析库html,提供了类似于jQuery的语法和功能,可以方便地从HTML文档中提取数据。goquery非常适合用于网络爬虫项目,特别是需要从网页中提取特定信息的场景。

goquery的主要特点:

  • 语法类似于jQuery,学习成本低
  • 基于Go标准库,性能优秀
  • 支持CSS选择器,方便查找元素
  • 支持链式调用,代码简洁

1.2 下载与安装

要使用goquery库,需要先安装Go语言环境(建议1.16及以上版本),然后通过以下命令安装goquery:

go get github.com/PuerkitoBio/goquery

安装完成后,在代码中导入库:

import "github.com/PuerkitoBio/goquery"

2. 项目结构

FundQuery/
├── components/                  # 自定义UI组件
│   ├── myButtonCircle.go        # 圆形按钮组件
│   ├── myButtonRectangle.go     # 矩形按钮组件
│   ├── myCircle.go              # 圆形绘制控件
│   ├── myHyperlink.go           # 超链接组件
│   └── myLabel.go               # 增强标签组件
├── theme/                       # 主题资源
│   ├── themeIcon.go             # 图标资源嵌入
│   └── themeTtf.go              # 字体资源嵌入
├── utils/                       # 工具类
│   ├── sqlite.go                # SQLite数据库操作
│   └── util.go                  # 通用辅助函数
├── demoWebScreen.go             # 网络爬虫核心实现
├── main.go                      # 程序入口
└──

3. 项目流程图

graph TD
    A[用户启动应用] --> B[初始化界面]
    B --> C[点击读取基金数据]
    C --> D[发送HTTP请求到易天富基金网]
    D --> E[接收HTML响应]
    E --> F[使用goquery解析HTML提取数据]
    F --> G[存储到SQLite数据库]
    G --> H[加载数据到列表显示]
    H --> I[用户选择基金]
    I --> J[点击查询按钮]
    J --> K[读取基金最新季度十大重仓股]
    K --> L[加载预览显示]
    L --> M[点击基金代码]
    M --> N[打开网页显示基金详情]
    B --> O[输入基金代码或名称]
    O --> P[点击查询按钮]
    P --> Q[从数据库过滤基金数据]
    Q --> H

4. 运行效果

应用启动后,用户可以看到主界面,点击"读取"按钮后,程序会自动从易天富基金网获取所有基金数据并保存到本地数据库。获取完成后,数据会显示在列表中。用户可以选择某个基金,点击"查询"按钮获取该基金最新季度的十大重仓股信息。点击基金代码可以打开浏览器查看该基金的详细信息。用户也可以通过输入基金代码或名称来查询特定基金。

image.png

image.png


注:本项目中的引用网址只限于学习和测试,如有乱用,与作者无关,请遵循国家相关法律法规。*


5. 根据项目流程图进行项目代码拆解

5.1 界面组件或变量声明

// 基金数据结构体
type FetchData struct {
    Id     int    // 数据库ID
    No     string // 序号
    Number string // 基金代码
    Title  string // 基金名称
    Url    string // 基金详情链接
}

// 季报数据结构体
type FetchZcgmxData struct {
    季报日期    string // 季报发布日期
    序号      string // 持仓序号
    股票代码    string // 持仓股票代码
    股票名称    string // 持仓股票名称
    占净值比例   string // 持仓占基金净值比例
    持股数量    string // 持仓股数
    持股市值    string // 持仓市值
    占股票市值比例 string // 占股票市值比例
    持仓变动    string // 持仓变动情况
}

// 天天基金网季报数据结构体
type FetchEastMoneyZcgmxData struct {
    季报日期  string // 季报发布日期
    序号    string // 持仓序号
    股票代码  string // 持仓股票代码
    股票名称  string // 持仓股票名称
    占净值比例 string // 持仓占基金净值比例
    持股数量  string // 持仓股数
    持股市值  string // 持仓市值
}

// 界面组件变量声明
var (
    // 主界面组件
    lblMainMsg *canvas.Text     // 主界面提示信息标签
    btnFetch   *widget.Button   // 读取基金数据按钮
    listFunds  *widget.List     // 基金列表
    entrySearch *widget.Entry   // 搜索输入框
    btnSearch  *widget.Button   // 搜索按钮
    
    // 基金详情界面组件
    lblFundDetailMsg *canvas.Text // 基金详情提示信息标签
    tableHoldings    *widget.Table // 持仓表格
    fundDataList     []FetchData  // 基金数据列表
    holdingDataList  []FetchZcgmxData // 持仓数据列表
)

5.2 组件实例化及添加到布局

// 创建网络爬虫主界面
func createFetchScreen() fyne.CanvasObject {
    // 创建垂直布局容器
    mainContainer := container.NewVBox()
    
    // 创建提示信息标签,用于显示操作状态
    lblMainMsg = canvas.NewText("欢迎使用基金信息查询系统", colornames.Black)
    mainContainer.Add(lblMainMsg)
    
    // 创建读取基金数据按钮,点击后开始从易天富基金网读取数据
    btnFetch = widget.NewButton("读取基金数据", func() {
        // 点击按钮时执行读取基金数据功能
        loadFundData()
    })
    mainContainer.Add(btnFetch)
    
    // 创建水平布局容器放置搜索组件
    searchContainer := container.NewHBox()
    
    // 创建搜索输入框,用于输入基金代码或名称
    entrySearch = widget.NewEntry()
    entrySearch.SetPlaceHolder("请输入基金代码或基金名称")
    searchContainer.Add(entrySearch)
    
    // 创建搜索按钮,点击后根据输入内容查询基金
    btnSearch = widget.NewButton("查询", func() {
        // 点击按钮时执行搜索功能
        searchFunds(entrySearch.Text)
    })
    searchContainer.Add(btnSearch)
    
    mainContainer.Add(searchContainer)
    
    // 创建基金列表,用于显示基金数据
    listFunds = widget.NewList(
        // 获取列表项数量
        func() int {
            return len(fundDataList)
        },
        // 创建列表项
        func() fyne.CanvasObject {
            return container.NewHBox(
                widget.NewLabel("基金代码"),    // 显示基金代码
                widget.NewLabel("基金名称"),    // 显示基金名称
                widget.NewButton("查询", func() {}), // 查询按钮,用于查询该基金的持仓信息
            )
        },
        // 更新列表项内容
        func(id widget.ListItemID, item fyne.CanvasObject) {
            if id < len(fundDataList) {
                // 设置基金代码
                item.(*fyne.Container).Objects[0].(*widget.Label).SetText(fundDataList[id].Number)
                // 设置基金名称
                item.(*fyne.Container).Objects[1].(*widget.Label).SetText(fundDataList[id].Title)
                // 为查询按钮绑定功能
                item.(*fyne.Container).Objects[2].(*widget.Button).OnTapped = func() {
                    queryFundHoldings(fundDataList[id])
                }
            }
        },
    )
    mainContainer.Add(listFunds)
    
    return mainContainer
}

5.3 数据结构

// 基金数据结构体,用于存储基金基本信息
type FetchData struct {
    Id     int    // 数据库ID,主键
    No     string // 序号,基金在列表中的顺序号
    Number string // 基金代码,唯一标识一只基金
    Title  string // 基金名称,基金的中文名称
    Url    string // 基金详情链接,指向基金详细信息页面的URL
}

// 季报数据结构体,用于存储基金持仓信息
type FetchZcgmxData struct {
    季报日期    string // 季报发布日期,表示数据的时效性
    序号      string // 持仓序号,在十大重仓股中的排序
    股票代码    string // 持仓股票代码,唯一标识一只股票
    股票名称    string // 持仓股票名称,股票的中文名称
    占净值比例   string // 持仓占基金净值比例,表示该股票在基金中的重要性
    持股数量    string // 持仓股数,基金持有该股票的数量
    持股市值    string // 持仓市值,基金持有该股票的总价值
    占股票市值比例 string // 占股票市值比例,表示基金在该股票中的占比
    持仓变动    string // 持仓变动情况,与上期相比的变动情况
}

// 天天基金网季报数据结构体,用于存储从天天基金网获取的持仓信息
type FetchEastMoneyZcgmxData struct {
    季报日期  string // 季报发布日期,表示数据的时效性
    序号    string // 持仓序号,在十大重仓股中的排序
    股票代码  string // 持仓股票代码,唯一标识一只股票
    股票名称  string // 持仓股票名称,股票的中文名称
    占净值比例 string // 持仓占基金净值比例,表示该股票在基金中的重要性
    持股数量  string // 持仓股数,基金持有该股票的数量
    持股市值  string // 持仓市值,基金持有该股票的总价值
}

5.4 数据库创建、存储、查询等函数

// 创建或检查基金数据表
func CreateFundTable(db *sql.DB) error {
    // 创建基金数据表的SQL语句
    sql_table := `
    CREATE TABLE IF NOT EXISTS FetchData(
        Id INTEGER PRIMARY KEY AUTOINCREMENT,     -- 数据库ID,主键,自增
        No TEXT NOT NULL,                          -- 序号,不能为空
        Number TEXT NOT NULL UNIQUE,               -- 基金代码,不能为空且唯一
        Title TEXT NOT NULL,                       -- 基金名称,不能为空
        Url TEXT NOT NULL                          -- 基金详情链接,不能为空
    );
    `
    _, err := db.Exec(sql_table)
    return err
}

// 插入基金数据
func InsertFundData(db *sql.DB, data FetchData) (int64, error) {
    // 准备插入语句,避免SQL注入
    stmt, err := db.Prepare("INSERT OR REPLACE INTO FetchData(No, Number, Title, Url) values(?,?,?,?)")
    if err != nil {
        return 0, err
    }
    defer stmt.Close() // 函数结束时关闭语句

    // 执行插入操作
    res, err := stmt.Exec(data.No, data.Number, data.Title, data.Url)
    if err != nil {
        return 0, err
    }
    
    // 获取插入数据的ID
    id, err := res.LastInsertId()
    return id, err
}

// 查询所有基金数据
func QueryAllFunds(db *sql.DB) ([]FetchData, error) {
    // 执行查询语句,按基金代码排序
    rows, err := db.Query("SELECT * FROM FetchData ORDER BY Number")
    if err != nil {
        return []FetchData{}, err
    }
    defer rows.Close() // 函数结束时关闭结果集

    // 定义存储基金数据的切片
    var data []FetchData
    
    // 遍历查询结果
    for rows.Next() {
        var item FetchData
        err := rows.Scan(&item.Id, &item.No, &item.Number, &item.Title, &item.Url)
        if err != nil {
            return []FetchData{}, err
        }
        data = append(data, item)
    }
    return data, nil
}

// 根据基金代码或名称查询基金数据
func QueryFundsByKeyword(db *sql.DB, keyword string) ([]FetchData, error) {
    // 执行模糊查询语句,查找基金代码或名称包含关键字的基金
    rows, err := db.Query("SELECT * FROM FetchData WHERE Number LIKE ? OR Title LIKE ? ORDER BY Number", 
                          "%"+keyword+"%", "%"+keyword+"%")
    if err != nil {
        return []FetchData{}, err
    }
    defer rows.Close() // 函数结束时关闭结果集

    // 定义存储基金数据的切片
    var data []FetchData
    
    // 遍历查询结果
    for rows.Next() {
        var item FetchData
        err := rows.Scan(&item.Id, &item.No, &item.Number, &item.Title, &item.Url)
        if err != nil {
            return []FetchData{}, err
        }
        data = append(data, item)
    }
    return data, nil
}

5.5 功能按钮代码

【读取】从易天富基金网读取全部基金数据
// 从易天富基金网读取全部基金数据
func loadFundData() {
    // 显示正在获取数据的提示信息
    lblMainMsg.Text = "正在从易天富基金网获取数据,请稍后..."
    lblMainMsg.Refresh()
    
    // 发送HTTP GET请求获取网页内容
    res, err := http.Get("https://www.etf88.com/datacenter/jj/jjdata_alljz_all.html")
    if err != nil {
        lblMainMsg.Text = "获取数据失败: " + err.Error()
        lblMainMsg.Refresh()
        return
    }
    defer res.Body.Close() // 函数结束时关闭响应体

    // 检查HTTP响应状态码
    if res.StatusCode != 200 {
        lblMainMsg.Text = fmt.Sprintf("获取数据失败,状态码错误:%d%s", res.StatusCode, res.Status)
        lblMainMsg.Refresh()
        return
    }
    
    // 显示数据获取完毕,正在解析的提示信息
    lblMainMsg.Text = "数据获取完毕,正在解析数据,请稍后..."
    lblMainMsg.Refresh()
    
    // 使用goquery将HTML响应体解析为文档对象
    dom, err := goquery.NewDocumentFromReader(res.Body)
    if err != nil {
        lblMainMsg.Text = "解析数据失败: " + err.Error()
        lblMainMsg.Refresh()
        return
    }
    
    // 显示正在查询dom文档结点数据的提示信息
    lblMainMsg.Text = "正在提取基金数据,请稍后..."
    lblMainMsg.Refresh()
    
    // 定义存储基金数据的切片
    var data []FetchData
    // 计数器
    k := 0
    
    // 使用goquery选择器查找所有td>a元素并提取基金数据
    dom.Find("td>a").Each(func(i int, selection *goquery.Selection) {
        // 过滤掉不需要的元素
        if selection.Text() != "加自选" {
            // 如果文本长度为6,认为是基金代码
            if len(selection.Text()) == 6 {
                data = append(data, FetchData{Number: selection.Text()})
                k = k + 1
            }
            // 如果文本长度大于6,认为是基金名称
            if len(selection.Text()) > 6 {
                data[k-1].No = strconv.Itoa(k) // 设置序号
                data[k-1].Title = selection.Text() // 设置基金名称
                url, _ := selection.Attr("href") // 获取链接地址
                data[k-1].Url = "https://www.etf88.com/" + url // 拼接完整URL
            }
        }
    })
    
    // 保存数据到数据库
    saveFundsToDatabase(data)
    
    // 更新界面显示
    fundDataList = data
    listFunds.Refresh()
    
    // 显示操作完成的提示信息
    lblMainMsg.Text = fmt.Sprintf("数据读取完成,共获取%d只基金", len(data))
    lblMainMsg.Refresh()
}
数据提取
// 数据提取过程已在loadFundData函数中实现
// 使用goquery选择器从HTML文档中提取基金代码、名称和链接
dom.Find("td>a").Each(func(i int, selection *goquery.Selection) {
    // 过滤掉不需要的元素("加自选"按钮)
    if selection.Text() != "加自选" {
        // 如果文本长度为6,认为是基金代码
        if len(selection.Text()) == 6 {
            data = append(data, FetchData{Number: selection.Text()})
            k = k + 1
        }
        // 如果文本长度大于6,认为是基金名称
        if len(selection.Text()) > 6 {
            data[k-1].No = strconv.Itoa(k) // 设置序号
            data[k-1].Title = selection.Text() // 设置基金名称
            url, _ := selection.Attr("href") // 获取链接地址
            data[k-1].Url = "https://www.etf88.com/" + url // 拼接完整URL
        }
    }
})
数据保存
// 保存基金数据到数据库
func saveFundsToDatabase(funds []FetchData) {
    // 获取数据库连接
    db, err := utils.GetConnection()
    if err != nil {
        lblMainMsg.Text = "数据库连接失败: " + err.Error()
        lblMainMsg.Refresh()
        return
    }
    defer db.Close() // 函数结束时关闭数据库连接

    // 创建基金数据表
    err = CreateFundTable(db)
    if err != nil {
        lblMainMsg.Text = "创建数据表失败: " + err.Error()
        lblMainMsg.Refresh()
        return
    }
    
    // 开始事务处理,提高插入效率
    tx, err := db.Begin()
    if err != nil {
        lblMainMsg.Text = "启动事务失败: " + err.Error()
        lblMainMsg.Refresh()
        return
    }
    
    // 准备插入语句
    stmt, err := tx.Prepare("INSERT OR REPLACE INTO FetchData(No, Number, Title, Url) VALUES (?, ?, ?, ?)")
    if err != nil {
        tx.Rollback()
        lblMainMsg.Text = "准备插入语句失败: " + err.Error()
        lblMainMsg.Refresh()
        return
    }
    defer stmt.Close()
    
    // 插入数据
    for _, fund := range funds {
        _, err = stmt.Exec(fund.No, fund.Number, fund.Title, fund.Url)
        if err != nil {
            tx.Rollback()
            lblMainMsg.Text = "插入数据失败: " + err.Error()
            lblMainMsg.Refresh()
            return
        }
    }
    
    // 提交事务
    err = tx.Commit()
    if err != nil {
        lblMainMsg.Text = "提交事务失败: " + err.Error()
        lblMainMsg.Refresh()
        return
    }
    
    lblMainMsg.Text = "数据保存成功"
    lblMainMsg.Refresh()
}
数据加载到列表
// 数据加载到列表显示已通过listFunds组件的回调函数实现
// 在listFunds的构造函数中定义了数据源和显示方式
listFunds = widget.NewList(
    // 获取列表项数量
    func() int {
        return len(fundDataList)
    },
    // 创建列表项
    func() fyne.CanvasObject {
        return container.NewHBox(
            widget.NewLabel("基金代码"),    // 显示基金代码
            widget.NewLabel("基金名称"),    // 显示基金名称
            widget.NewButton("查询", func() {}), // 查询按钮
        )
    },
    // 更新列表项内容
    func(id widget.ListItemID, item fyne.CanvasObject) {
        if id < len(fundDataList) {
            // 设置基金代码
            item.(*fyne.Container).Objects[0].(*widget.Label).SetText(fundDataList[id].Number)
            // 设置基金名称
            item.(*fyne.Container).Objects[1].(*widget.Label).SetText(fundDataList[id].Title)
            // 为查询按钮绑定功能
            item.(*fyne.Container).Objects[2].(*widget.Button).OnTapped = func() {
                queryFundHoldings(fundDataList[id])
            }
        }
    },
)
点击数据列表中的查询按钮:读取基金最新季度的十大重仓股票数据并加载预览
// 查询基金最新季度的十大重仓股票数据
func queryFundHoldings(fund FetchData) {
    // 显示正在查询的提示信息
    lblMainMsg.Text = "正在查询基金 " + fund.Number + " 的最新持仓信息..."
    lblMainMsg.Refresh()
    
    // 根据基金代码构建重仓股数据URL
    url := "http://quotes.money.163.com/fund/cgmx_" + fund.Number + ".html"
    
    // 发送HTTP GET请求获取网页内容
    res, err := http.Get(url)
    if err != nil {
        lblMainMsg.Text = "获取持仓数据失败: " + err.Error()
        lblMainMsg.Refresh()
        return
    }
    defer res.Body.Close() // 函数结束时关闭响应体

    // 检查HTTP响应状态码
    if res.StatusCode != 200 {
        lblMainMsg.Text = fmt.Sprintf("获取持仓数据失败,状态码错误:%d%s", res.StatusCode, res.Status)
        lblMainMsg.Refresh()
        return
    }
    
    // 使用goquery将HTML响应体解析为文档对象
    dom, err := goquery.NewDocumentFromReader(res.Body)
    if err != nil {
        lblMainMsg.Text = "解析持仓数据失败: " + err.Error()
        lblMainMsg.Refresh()
        return
    }
    
    // 获取季报日期
    var reportDate string
    dom.Find("select>option").Each(func(i int, selection *goquery.Selection) {
        if i == 0 {
            reportDate = selection.Text()
        }
    })

    // 提取十大重仓股数据
    var holdings []FetchEastMoneyZcgmxData
    dom.Find("tbody>tr").Each(func(i int, selection *goquery.Selection) {
        content := selection.Text()
        content = strings.Replace(content, "\n", ";", -1) // 替换换行符
        content = strings.Replace(content, " ", "", -1)   // 去除空格
        row := strings.Split(content, ";") // 按分号分割

        // 解析表格行数据(如果列数为6)
        if len(row) == 6 && i < 10 { // 只取前10条数据
            holding := FetchEastMoneyZcgmxData{
                季报日期:  reportDate,     // 设置季报日期
                序号:    strconv.Itoa(i + 1), // 设置序号
                股票代码:  "",            // 股票代码后续获取
                股票名称:  row[1],        // 设置股票名称
                占净值比例: row[4],       // 设置占净值比例
                持股数量:  row[2],        // 设置持股数量
                持股市值:  row[3],        // 设置持股市值
            }
            holdings = append(holdings, holding)
        }
    })
    
    // 获取股票代码
    dom.Find("td>a").Each(func(i int, selection *goquery.Selection) {
        if i < 10 { // 只处理前10个链接
            url, _ := selection.Attr("href")
            strCode := strings.Split(GetBetweenStr(url, "com/", ".html"), "/")[1]
            if i < len(holdings) {
                holdings[i].股票代码 = strCode
            }
        }
    })
    
    // 显示持仓信息预览
    showHoldingsPreview(holdings, fund)
}
点击基金代码,打开网页显示基金详细页
// 打开基金详细页
func openFundDetailPage(url string) {
    // 使用系统默认浏览器打开基金详情页面
    var err error
    switch runtime.GOOS {
    case "linux":
        err = exec.Command("xdg-open", url).Start()
    case "windows":
        err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
    case "darwin":
        err = exec.Command("open", url).Start()
    default:
        err = fmt.Errorf("unsupported platform")
    }
    
    // 如果打开浏览器失败,显示错误信息
    if err != nil {
        lblMainMsg.Text = "打开浏览器失败: " + err.Error()
        lblMainMsg.Refresh()
    }
}
输入基金代码或基金名称后,点击查询按钮,从数据库中过滤基金数据
// 从数据库中过滤基金数据
func searchFunds(keyword string) {
    // 检查关键字是否为空
    if keyword == "" {
        lblMainMsg.Text = "请输入查询关键字"
        lblMainMsg.Refresh()
        return
    }
    
    // 显示正在查询的提示信息
    lblMainMsg.Text = "正在查询基金数据..."
    lblMainMsg.Refresh()
    
    // 获取数据库连接
    db, err := utils.GetConnection()
    if err != nil {
        lblMainMsg.Text = "数据库连接失败: " + err.Error()
        lblMainMsg.Refresh()
        return
    }
    defer db.Close() // 函数结束时关闭数据库连接

    // 根据关键字查询基金数据
    funds, err := QueryFundsByKeyword(db, keyword)
    if err != nil {
        lblMainMsg.Text = "查询数据失败: " + err.Error()
        lblMainMsg.Refresh()
        return
    }
    
    // 更新数据列表
    fundDataList = funds
    
    // 刷新列表显示
    listFunds.Refresh()
    
    // 显示查询结果
    lblMainMsg.Text = fmt.Sprintf("查询完成,找到%d只符合条件的基金", len(funds))
    lblMainMsg.Refresh()
}

6. 项目总结

本项目基于Fyne框架和goquery库实现了一个基金信息查询系统,具有以下特点:

  1. 技术选型合理:使用Fyne作为GUI框架,提供了良好的跨平台支持;使用goquery进行HTML解析,简化了网络爬虫的开发。

  2. 功能完整:实现了从易天富基金网爬取数据、解析数据、存储数据到本地数据库、以及展示数据的完整流程。

  3. 处理了常见问题

    • 处理了HTTPS证书验证问题
    • 处理了中文字符编码问题
    • 实现了数据的本地化存储
  4. 代码结构清晰:按照功能模块划分代码,便于维护和扩展。

  5. 用户体验良好:提供了图形化界面,用户可以方便地操作和查看基金信息。

通过本项目的实现,我们可以看到goquery库在网络爬虫开发中的强大功能,它极大地简化了HTML文档的解析过程,使得开发者可以专注于业务逻辑的实现。同时,Fyne框架为Go语言提供了现代化的GUI开发能力,使得我们可以用Go语言开发出功能丰富的桌面应用程序。

该项目可以进一步扩展,例如:

  • 增加更多的数据源
  • 实现数据的定时更新
  • 添加数据可视化功能
  • 提供导出数据功能
  • 增加基金比较功能

这些扩展功能可以让系统更加完善,为用户提供更好的使用体验。