# 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. 运行效果
应用启动后,用户可以看到主界面,点击"读取"按钮后,程序会自动从易天富基金网获取所有基金数据并保存到本地数据库。获取完成后,数据会显示在列表中。用户可以选择某个基金,点击"查询"按钮获取该基金最新季度的十大重仓股信息。点击基金代码可以打开浏览器查看该基金的详细信息。用户也可以通过输入基金代码或名称来查询特定基金。
注:本项目中的引用网址只限于学习和测试,如有乱用,与作者无关,请遵循国家相关法律法规。*
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库实现了一个基金信息查询系统,具有以下特点:
-
技术选型合理:使用Fyne作为GUI框架,提供了良好的跨平台支持;使用goquery进行HTML解析,简化了网络爬虫的开发。
-
功能完整:实现了从易天富基金网爬取数据、解析数据、存储数据到本地数据库、以及展示数据的完整流程。
-
处理了常见问题:
- 处理了HTTPS证书验证问题
- 处理了中文字符编码问题
- 实现了数据的本地化存储
-
代码结构清晰:按照功能模块划分代码,便于维护和扩展。
-
用户体验良好:提供了图形化界面,用户可以方便地操作和查看基金信息。
通过本项目的实现,我们可以看到goquery库在网络爬虫开发中的强大功能,它极大地简化了HTML文档的解析过程,使得开发者可以专注于业务逻辑的实现。同时,Fyne框架为Go语言提供了现代化的GUI开发能力,使得我们可以用Go语言开发出功能丰富的桌面应用程序。
该项目可以进一步扩展,例如:
- 增加更多的数据源
- 实现数据的定时更新
- 添加数据可视化功能
- 提供导出数据功能
- 增加基金比较功能
这些扩展功能可以让系统更加完善,为用户提供更好的使用体验。