go-stock/backend/data/fund_data_api.go
ArvinLovegood db43da6577 feat(fund):添加基金监控和查询功能
- 新增基金数据 API,实现基金信息爬取和数据库操作
- 添加基金监控逻辑,定期更新基金净值信息
- 实现基金列表查询、关注和取消关注功能
- 新增基金相关前端组件,展示基金信息和操作
2025-03-09 19:32:08 +08:00

389 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package data
import (
"context"
"encoding/json"
"fmt"
"github.com/duke-git/lancet/v2/convertor"
"github.com/duke-git/lancet/v2/strutil"
"github.com/go-resty/resty/v2"
"go-stock/backend/db"
"go-stock/backend/logger"
"strconv"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"gorm.io/gorm"
)
type FundApi struct {
client *resty.Client
config *Settings
}
func NewFundApi() *FundApi {
return &FundApi{
client: resty.New(),
config: getConfig(),
}
}
type FollowedFund struct {
gorm.Model
Code string `json:"code" gorm:"index"` // 基金代码
Name string `json:"name"` // 基金简称
NetUnitValue *float64 `json:"netUnitValue"` // 单位净值
NetUnitValueDate string `json:"netUnitValueDate"` // 单位净值日期
NetEstimatedUnit *float64 `json:"netEstimatedUnit"` // 估算单位净值
NetEstimatedTime string `json:"netEstimatedUnitTime"` // 估算单位净值日期
NetAccumulated *float64 `json:"netAccumulated"` // 累计净值
FundBasic FundBasic `json:"fundBasic" gorm:"foreignKey:Code;references:Code"`
}
func (FollowedFund) TableName() string {
return "followed_fund"
}
// FundBasic 基金基本信息结构体
type FundBasic struct {
gorm.Model
Code string `json:"code" gorm:"index"` // 基金代码
Name string `json:"name"` // 基金简称
FullName string `json:"fullName"` // 基金全称
Type string `json:"type"` // 基金类型
Establishment string `json:"establishment"` // 成立日期
Scale string `json:"scale"` // 最新规模(亿元)
Company string `json:"company"` // 基金管理人
Manager string `json:"manager"` // 基金经理
Rating string `json:"rating"` //基金评级
TrackingTarget string `json:"trackingTarget"` //跟踪标的
NetUnitValue *float64 `json:"netUnitValue"` // 单位净值
NetUnitValueDate string `json:"netUnitValueDate"` // 单位净值日期
NetEstimatedUnit *float64 `json:"netEstimatedUnit"` // 估算单位净值
NetEstimatedTime string `json:"netEstimatedUnitTime"` // 估算单位净值日期
NetAccumulated *float64 `json:"netAccumulated"` // 累计净值
//净值涨跌幅: 近1月,近3月,近6月,近1年,近3年,近5年,今年来,成立来
NetGrowth1 *float64 `json:"netGrowth1"` //近1月
NetGrowth3 *float64 `json:"netGrowth3"` //近3月
NetGrowth6 *float64 `json:"netGrowth6"` //近6月
NetGrowth12 *float64 `json:"netGrowth12"` //近1年
NetGrowth36 *float64 `json:"netGrowth36"` //近3年
NetGrowth60 *float64 `json:"netGrowth60"` //近5年
NetGrowthYTD *float64 `json:"netGrowthYTD"` //今年来
NetGrowthAll *float64 `json:"netGrowthAll"` //成立来
}
func (FundBasic) TableName() string {
return "fund_basic"
}
// CrawlFundBasic 爬取基金基本信息
func (f *FundApi) CrawlFundBasic(fundCode string) (*FundBasic, error) {
defer func() {
if r := recover(); r != nil {
logger.SugaredLogger.Errorf("CrawlFundBasic panic: %v", r)
}
}()
crawler := CrawlerApi{
crawlerBaseInfo: CrawlerBaseInfo{
Name: "天天基金",
BaseUrl: "http://fund.eastmoney.com",
Headers: map[string]string{"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"},
},
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(f.config.CrawlTimeOut)*time.Second)
defer cancel()
crawler = crawler.NewCrawler(ctx, crawler.crawlerBaseInfo)
url := fmt.Sprintf("%s/%s.html", crawler.crawlerBaseInfo.BaseUrl, fundCode)
logger.SugaredLogger.Infof("CrawlFundBasic url:%s", url)
// 使用现有爬虫框架解析页面
htmlContent, ok := crawler.GetHtml(url, ".merchandiseDetail", true)
if !ok {
return nil, fmt.Errorf("页面解析失败")
}
fund := &FundBasic{Code: fundCode}
doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent))
if err != nil {
return nil, err
}
// 解析基础信息
name := doc.Find(".merchandiseDetail .fundDetail-tit").First().Text()
fund.Name = strings.TrimSpace(strutil.ReplaceWithMap(name, map[string]string{"查看相关ETF>": ""}))
logger.SugaredLogger.Infof("基金名称:%s", fund.Name)
doc.Find(".infoOfFund table td ").Each(func(i int, s *goquery.Selection) {
text := strutil.RemoveWhiteSpace(s.Text(), true)
logger.SugaredLogger.Infof("基金信息:%+v", text)
defer func() {
if r := recover(); r != nil {
logger.SugaredLogger.Errorf("panic: %v", r)
}
}()
splitEx := strutil.SplitEx(text, "", true)
if strutil.ContainsAny(text, []string{"基金类型", "类型"}) {
fund.Type = splitEx[1]
}
if strutil.ContainsAny(text, []string{"成立日期", "成立日"}) {
fund.Establishment = splitEx[1]
}
if strutil.ContainsAny(text, []string{"基金规模", "规模"}) {
fund.Scale = splitEx[1]
}
if strutil.ContainsAny(text, []string{"管理人", "基金公司"}) {
fund.Company = splitEx[1]
}
if strutil.ContainsAny(text, []string{"基金经理", "经理人"}) {
fund.Manager = splitEx[1]
}
if strutil.ContainsAny(text, []string{"基金评级", "评级"}) {
fund.Rating = splitEx[1]
}
if strutil.ContainsAny(text, []string{"跟踪标的", "标的"}) {
fund.TrackingTarget = splitEx[1]
}
})
//获取基金净值涨跌幅信息
doc.Find(".dataOfFund dl > dd").Each(func(i int, s *goquery.Selection) {
text := strutil.RemoveWhiteSpace(s.Text(), true)
logger.SugaredLogger.Infof("净值涨跌幅信息:%+v", text)
defer func() {
if r := recover(); r != nil {
logger.SugaredLogger.Errorf("panic: %v", r)
}
}()
splitEx := strutil.SplitAndTrim(text, "", "%")
toFloat, err1 := convertor.ToFloat(splitEx[1])
if err1 != nil {
logger.SugaredLogger.Errorf("转换失败:%+v", err)
return
}
logger.SugaredLogger.Infof("净值涨跌幅信息:%+v", toFloat)
if strutil.ContainsAny(text, []string{"近1月"}) {
fund.NetGrowth1 = &toFloat
}
if strutil.ContainsAny(text, []string{"近3月"}) {
fund.NetGrowth3 = &toFloat
}
if strutil.ContainsAny(text, []string{"近6月"}) {
fund.NetGrowth6 = &toFloat
}
if strutil.ContainsAny(text, []string{"近1年"}) {
fund.NetGrowth12 = &toFloat
}
if strutil.ContainsAny(text, []string{"近3年"}) {
fund.NetGrowth36 = &toFloat
}
if strutil.ContainsAny(text, []string{"近5年"}) {
fund.NetGrowth60 = &toFloat
}
if strutil.ContainsAny(text, []string{"今年来"}) {
fund.NetGrowthYTD = &toFloat
}
if strutil.ContainsAny(text, []string{"成立来"}) {
fund.NetGrowthAll = &toFloat
}
})
doc.Find(".dataOfFund dl > dd.dataNums,.dataOfFund dl > dt").Each(func(i int, s *goquery.Selection) {
text := s.Text()
defer func() {
if r := recover(); r != nil {
logger.SugaredLogger.Errorf("panic: %v", r)
}
}()
logger.SugaredLogger.Infof("净值信息:%+v", text)
})
logger.SugaredLogger.Infof("基金信息:%+v", fund)
count := int64(0)
db.Dao.Model(fund).Where("code=?", fund.Code).Count(&count)
if count == 0 {
db.Dao.Create(fund)
} else {
db.Dao.Model(fund).Where("code=?", fund.Code).Updates(fund)
}
return fund, nil
}
func (f *FundApi) GetFundList(key string) []FundBasic {
var funds []FundBasic
db.Dao.Where("code like ? or name like ?", "%"+key+"%", "%"+key+"%").Limit(10).Find(&funds)
return funds
}
func (f *FundApi) GetFollowedFund() []FollowedFund {
var funds []FollowedFund
db.Dao.Preload("FundBasic").Find(&funds)
return funds
}
func (f *FundApi) FollowFund(fundCode string) string {
var fund FundBasic
db.Dao.Where("code=?", fundCode).First(&fund)
if fund.Code != "" {
follow := &FollowedFund{
Code: fundCode,
Name: fund.Name,
}
err := db.Dao.Model(follow).Where("code = ?", fundCode).FirstOrCreate(follow, "code", fund.Code).Error
if err != nil {
return "关注失败"
}
return "关注成功"
} else {
return "基金信息不存在"
}
}
func (f *FundApi) UnFollowFund(fundCode string) string {
var fund FollowedFund
db.Dao.Where("code=?", fundCode).First(&fund)
if fund.Code != "" {
err := db.Dao.Model(&fund).Delete(&fund).Error
if err != nil {
return "取消关注失败"
}
return "取消关注成功"
} else {
return "基金信息不存在"
}
}
func (f *FundApi) AllFund() {
defer func() {
if r := recover(); r != nil {
logger.SugaredLogger.Errorf("AllFund panic: %v", r)
}
}()
response, err := f.client.SetTimeout(time.Duration(f.config.CrawlTimeOut)*time.Second).R().
SetHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36").
Get("https://fund.eastmoney.com/allfund.html")
if err != nil {
return
}
//中文编码
htmlContent := GB18030ToUTF8(response.Body())
doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent))
cnt := 0
doc.Find("ul.num_right li").Each(func(i int, s *goquery.Selection) {
text := strutil.SplitEx(s.Text(), "|", true)
if len(text) > 0 {
cnt++
name := text[0]
str := strutil.SplitAndTrim(name, "", "", "")
logger.SugaredLogger.Infof("%d,基金信息 code:%s,name:%s", cnt, str[0], str[1])
//go f.CrawlFundBasic(str[0])
fund := &FundBasic{
Code: str[0],
Name: str[1],
}
count := int64(0)
db.Dao.Model(fund).Where("code=?", fund.Code).Count(&count)
if count == 0 {
db.Dao.Create(fund)
}
}
})
}
type FundNetUnitValue struct {
Fundcode string `json:"fundcode"`
Name string `json:"name"`
Jzrq string `json:"jzrq"`
Dwjz string `json:"dwjz"`
Gsz string `json:"gsz"`
Gszzl string `json:"gszzl"`
Gztime string `json:"gztime"`
}
// CrawlFundNetEstimatedUnit 爬取净值估算值
func (f *FundApi) CrawlFundNetEstimatedUnit(code string) {
var fundNetUnitValue FundNetUnitValue
response, err := f.client.SetTimeout(time.Duration(f.config.CrawlTimeOut)*time.Second).R().
SetHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36").
SetHeader("Referer", "https://fund.eastmoney.com/").
SetQueryParams(map[string]string{"rt": strconv.FormatInt(time.Now().UnixMilli(), 10)}).
Get(fmt.Sprintf("https://fundgz.1234567.com.cn/js/%s.js", code))
if err != nil {
logger.SugaredLogger.Errorf("err:%s", err.Error())
return
}
if response.StatusCode() == 200 {
htmlContent := string(response.Body())
logger.SugaredLogger.Infof("htmlContent:%s", htmlContent)
if strings.Contains(htmlContent, "jsonpgz") {
htmlContent = strutil.Trim(htmlContent, "jsonpgz(", ");")
htmlContent = strutil.Trim(htmlContent, ");")
logger.SugaredLogger.Infof("基金净值信息:%s", htmlContent)
err := json.Unmarshal([]byte(htmlContent), &fundNetUnitValue)
if err != nil {
logger.SugaredLogger.Errorf("json.Unmarshal error:%s", err.Error())
return
}
fund := &FollowedFund{
Code: fundNetUnitValue.Fundcode,
Name: fundNetUnitValue.Name,
NetEstimatedTime: fundNetUnitValue.Gztime,
}
netEstimatedUnit, err := convertor.ToFloat(fundNetUnitValue.Gsz)
if err == nil {
fund.NetEstimatedUnit = &netEstimatedUnit
}
db.Dao.Model(fund).Where("code=?", fund.Code).Updates(fund)
}
}
}
// CrawlFundNetUnitValue 爬取净值
func (f *FundApi) CrawlFundNetUnitValue(code string) {
// var fundNetUnitValue FundNetUnitValue
url := fmt.Sprintf("http://hq.sinajs.cn/rn=%d&list=f_%s", time.Now().UnixMilli(), code)
logger.SugaredLogger.Infof("url:%s", url)
response, err := f.client.SetTimeout(time.Duration(f.config.CrawlTimeOut)*time.Second).R().
SetHeader("Host", "hq.sinajs.cn").
SetHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0").
SetHeader("Referer", "https://finance.sina.com.cn").
Get(url)
if err != nil {
logger.SugaredLogger.Errorf("err:%s", err.Error())
return
}
if response.StatusCode() == 200 {
data := string(GB18030ToUTF8(response.Body()))
logger.SugaredLogger.Infof("data:%s", data)
datas := strutil.SplitAndTrim(data, "=", "\"")
if len(datas) >= 2 {
//codex := strings.Split(datas[0], "hq_str_f_")[1]
parts := strutil.SplitAndTrim(datas[1], ",", "\"")
logger.SugaredLogger.Infof("parts:%s", parts)
val, err := convertor.ToFloat(parts[1])
if err != nil {
logger.SugaredLogger.Errorf("err:%s", err.Error())
return
}
fund := &FollowedFund{
Name: parts[0],
Code: code,
NetUnitValue: &val,
NetUnitValueDate: parts[4],
}
db.Dao.Model(fund).Where("code=?", fund.Code).Updates(fund)
}
}
}