feat(stock):添加香港股票数据支持

- 新增 StockInfoHK模型用于存储香港股票基本信息- 实现香港股票数据的爬取和解析功能
- 更新数据库初始化逻辑,支持香港股票数据导入
- 修改股票价格信息获取接口,支持香港股票
- 优化股票数据解析逻辑,适配香港股票数据格式
This commit is contained in:
ArvinLovegood 2025-02-22 21:47:05 +08:00
parent 4c249f0806
commit a6f17c632e
14 changed files with 43359 additions and 21 deletions

View File

@ -30,12 +30,14 @@
## 🧩 功能开发计划
| 功能说明 | 状态 | 备注 |
|-----------------|----|---------------------------------------------------------------------------------------------------------|
| 港股支持 | 🚧 | 港股数据支持 |
| 港股支持 | | 港股数据支持 |
| 多轮对话 | ✅ | AI分析后可继续对话提问 |
| 自定义AI分析提问模板 | ✅ | 可配置的提问模板 [v2025.2.12.7-alpha](https://github.com/ArvinLovegood/go-stock/releases/tag/v2025.2.12.7-alpha) |
| 不再强制依赖Chrome浏览器 | ✅ | 默认使用edge浏览器抓取新闻资讯 |
## 👀 更新日志
### 2025.02.22 港股数据支持
### 2025.02.16 AI分析后可继续对话提问
- [v2025.2.16.1-alpha](https://github.com/ArvinLovegood/go-stock/releases/tag/v2025.2.16.1-alpha)

View File

@ -2,9 +2,12 @@ package data
import (
"context"
"fmt"
"github.com/PuerkitoBio/goquery"
"github.com/duke-git/lancet/v2/strutil"
"go-stock/backend/db"
"go-stock/backend/logger"
"go-stock/backend/models"
"strings"
"testing"
"time"
@ -116,3 +119,50 @@ func TestGetHtmlWithActions(t *testing.T) {
}
//logger.SugaredLogger.Infof("htmlContent:%s", htmlContent)
}
func TestHk(t *testing.T) {
//https://stock.finance.sina.com.cn/hkstock/quotes/00001.html
db.Init("../../data/stock.db")
hks := &[]models.StockInfoHK{}
db.Dao.Model(&models.StockInfoHK{}).Limit(1).Find(hks)
crawlerAPI := CrawlerApi{}
crawlerBaseInfo := CrawlerBaseInfo{
Name: "TestCrawler",
Description: "Test Crawler Description",
BaseUrl: "https://stock.finance.sina.com.cn",
Headers: map[string]string{"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/133.0.0.0"},
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
defer cancel()
crawlerAPI = crawlerAPI.NewCrawler(ctx, crawlerBaseInfo)
for _, hk := range *hks {
logger.SugaredLogger.Infof("hk: %+v", hk)
url := fmt.Sprintf("https://stock.finance.sina.com.cn/hkstock/quotes/%s.html", strings.ReplaceAll(hk.Code, ".HK", ""))
htmlContent, ok := crawlerAPI.GetHtml(url, "#stock_cname", true)
if !ok {
continue
}
//logger.SugaredLogger.Infof("htmlContent: %s", htmlContent)
document, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent))
if err != nil {
logger.SugaredLogger.Error(err.Error())
}
document.Find("#stock_cname").Each(func(i int, selection *goquery.Selection) {
text := strutil.RemoveNonPrintable(selection.Text())
logger.SugaredLogger.Infof("股票名称-:%s", text)
})
document.Find("#mts_stock_hk_price").Each(func(i int, selection *goquery.Selection) {
text := strutil.RemoveNonPrintable(selection.Text())
logger.SugaredLogger.Infof("股票名称-现价: %s", text)
})
document.Find(".deta_hqContainer >.deta03 li").Each(func(i int, selection *goquery.Selection) {
text := strutil.RemoveNonPrintable(selection.Text())
logger.SugaredLogger.Infof("股票名称-%s: %s", "", text)
})
}
}

View File

@ -442,6 +442,14 @@ func SearchGuShiTongStockInfo(stock string, crawlTimeOut int64) *[]string {
Headers: map[string]string{"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/133.0.0.0"},
})
url := "https://gushitong.baidu.com/stock/ab-" + RemoveAllNonDigitChar(stock)
if strutil.HasPrefixAny(stock, []string{"HK", "hk"}) {
url = "https://gushitong.baidu.com/stock/hk-" + RemoveAllNonDigitChar(stock)
}
if strutil.HasPrefixAny(stock, []string{"SZ", "SH", "sh", "sz"}) {
url = "https://gushitong.baidu.com/stock/ab-" + RemoveAllNonDigitChar(stock)
}
logger.SugaredLogger.Infof("SearchGuShiTongStockInfo搜索股票-%s: %s", stock, url)
actions := []chromedp.Action{
chromedp.Navigate(url),
@ -471,6 +479,11 @@ func SearchGuShiTongStockInfo(stock string, crawlTimeOut int64) *[]string {
}
func GetFinancialReports(stockCode string, crawlTimeOut int64) *[]string {
if strutil.HasPrefixAny(stockCode, []string{"HK", "hk"}) {
stockCode = strings.ReplaceAll(stockCode, "hk", "")
stockCode = strings.ReplaceAll(stockCode, "HK", "")
}
// 创建一个 chromedp 上下文
timeoutCtx, timeoutCtxCancel := context.WithTimeout(context.Background(), time.Duration(crawlTimeOut)*time.Second)
defer timeoutCtxCancel()

View File

@ -21,3 +21,9 @@ func TestNewDeepSeekOpenAiConfig(t *testing.T) {
func TestGetTopNewsList(t *testing.T) {
GetTopNewsList(30)
}
func TestSearchGuShiTongStockInfo(t *testing.T) {
SearchGuShiTongStockInfo("hk01810", 60)
SearchGuShiTongStockInfo("sh600745", 60)
}

View File

@ -19,6 +19,7 @@ import (
"github.com/go-resty/resty/v2"
"go-stock/backend/db"
"go-stock/backend/logger"
"go-stock/backend/models"
"golang.org/x/sys/windows/registry"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
@ -387,6 +388,9 @@ func (receiver StockDataApi) GetStockList(key string) []StockBasic {
var result2 []IndexBasic
db.Dao.Model(&IndexBasic{}).Where("market in ?", []string{"SSE", "SZSE"}).Where("name like ? or ts_code like ?", "%"+key+"%", "%"+key+"%").Find(&result2)
var result3 []models.StockInfoHK
db.Dao.Model(&models.StockInfoHK{}).Where("name like ? or code like ?", "%"+key+"%", "%"+key+"%").Find(&result3)
for _, item := range result2 {
result = append(result, StockBasic{
TsCode: item.TsCode,
@ -397,6 +401,14 @@ func (receiver StockDataApi) GetStockList(key string) []StockBasic {
ListDate: item.ListDate,
})
}
for _, item := range result3 {
result = append(result, StockBasic{
TsCode: item.Code,
Name: item.Name,
Fullname: item.Name,
Market: "HK",
})
}
return result
}
@ -416,6 +428,73 @@ func ParseFullSingleStockData(data string) (*StockInfo, error) {
if len(datas) < 2 {
return nil, fmt.Errorf("invalid data format")
}
var result map[string]string
var err error
if strutil.ContainsAny(datas[0], []string{"hq_str_sz", "hq_str_sh"}) {
result, err = ParseSHSZStockData(datas)
}
if strutil.ContainsAny(datas[0], []string{"hq_str_hk"}) {
result, err = ParseHKStockData(datas)
}
//logger.SugaredLogger.Infof("股票数据解析完成: %v", result)
marshal, err := json.Marshal(result)
if err != nil {
return nil, err
}
stockInfo := &StockInfo{}
err = json.Unmarshal(marshal, &stockInfo)
if err != nil {
return nil, err
}
//logger.SugaredLogger.Infof("股票数据解析完成stockInfo: %+v", stockInfo)
return stockInfo, nil
}
func ParseHKStockData(datas []string) (map[string]string, error) {
code := strings.Split(datas[0], "hq_str_")[1]
result := make(map[string]string)
parts := strutil.SplitAndTrim(datas[1], ",", "\"")
//parts := strings.Split(data, ",")
if len(parts) < 19 {
return nil, fmt.Errorf("invalid data format")
}
/*
XIAOMI-W, 0
小米集团, 1 股票名称
50.050, 2 今日开盘价
49.150, 3 昨日收盘价
51.950, 4 今日最高价
49.700, 5 今日最低价
51.700, 6 当前价格
2.550, 7 涨跌额
5.188, 8 涨跌幅
51.65000, 9
51.70000, 10
15770408249, 11 成交额
308362585, 12 成交量
0.000, 13
0.000, 14
51.950, 15 52周最高
12.560, 16 52周最低
2025/02/21, 17
16:08 18
*/
result["股票代码"] = code
result["股票名称"] = parts[1]
result["今日开盘价"] = parts[2]
result["昨日收盘价"] = parts[3]
result["今日最高价"] = parts[4]
result["今日最低价"] = parts[5]
result["当前价格"] = parts[6]
result["日期"] = parts[17]
result["时间"] = parts[18]
logger.SugaredLogger.Infof("股票数据解析完成: %v", result)
return result, nil
}
func ParseSHSZStockData(datas []string) (map[string]string, error) {
code := strings.Split(datas[0], "hq_str_")[1]
result := make(map[string]string)
parts := strutil.SplitAndTrim(datas[1], ",", "\"")
@ -482,19 +561,7 @@ func ParseFullSingleStockData(data string) (*StockInfo, error) {
result["卖五报价"] = parts[29]
result["日期"] = parts[30]
result["时间"] = parts[31]
//logger.SugaredLogger.Infof("股票数据解析完成: %v", result)
marshal, err := json.Marshal(result)
if err != nil {
return nil, err
}
stockInfo := &StockInfo{}
err = json.Unmarshal(marshal, &stockInfo)
if err != nil {
return nil, err
}
//logger.SugaredLogger.Infof("股票数据解析完成stockInfo: %+v", stockInfo)
return stockInfo, nil
return result, nil
}
type IndexBasic struct {
@ -519,6 +586,63 @@ func (IndexBasic) TableName() string {
}
func SearchStockPriceInfo(stockCode string, crawlTimeOut int64) *[]string {
if strutil.HasPrefixAny(stockCode, []string{"HK", "hk"}) {
return getHKStockPriceInfo(stockCode, crawlTimeOut)
}
if strutil.HasPrefixAny(stockCode, []string{"SZ", "SH", "sh", "sz"}) {
return getSHSZStockPriceInfo(stockCode, crawlTimeOut)
}
return &[]string{}
}
func getHKStockPriceInfo(stockCode string, crawlTimeOut int64) *[]string {
var messages []string
crawlerAPI := CrawlerApi{}
crawlerBaseInfo := CrawlerBaseInfo{
Name: "SinaCrawler",
Description: "SinaCrawler Crawler Description",
BaseUrl: "https://stock.finance.sina.com.cn",
Headers: map[string]string{"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/133.0.0.0"},
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(crawlTimeOut)*time.Second)
defer cancel()
crawlerAPI = crawlerAPI.NewCrawler(ctx, crawlerBaseInfo)
url := fmt.Sprintf("https://stock.finance.sina.com.cn/hkstock/quotes/%s.html", strings.ReplaceAll(stockCode, "hk", ""))
htmlContent, ok := crawlerAPI.GetHtml(url, ".deta_hqContainer >.deta03 ", true)
if !ok {
return &[]string{}
}
document, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent))
if err != nil {
logger.SugaredLogger.Error(err.Error())
}
stockName := ""
stockPrice := ""
document.Find("#stock_cname").Each(func(i int, selection *goquery.Selection) {
stockName = strutil.RemoveNonPrintable(selection.Text())
logger.SugaredLogger.Infof("股票名称-:%s", stockName)
})
document.Find("#mts_stock_hk_price").Each(func(i int, selection *goquery.Selection) {
stockPrice = strutil.RemoveNonPrintable(selection.Text())
logger.SugaredLogger.Infof("股票名称-现价: %s", stockPrice)
})
messages = append(messages, fmt.Sprintf("%s现价%s", stockName, stockPrice))
document.Find(".deta_hqContainer >.deta03 li").Each(func(i int, selection *goquery.Selection) {
text := strutil.RemoveNonPrintable(selection.Text())
logger.SugaredLogger.Infof("股票名称-%s: %s", stockName, text)
messages = append(messages, text)
})
return &messages
}
func getSHSZStockPriceInfo(stockCode string, crawlTimeOut int64) *[]string {
var messages []string
url := "https://www.cls.cn/stock?code=" + stockCode
// 创建一个 chromedp 上下文
timeoutCtx, timeoutCtxCancel := context.WithTimeout(context.Background(), time.Duration(crawlTimeOut)*time.Second)
@ -570,7 +694,7 @@ func SearchStockPriceInfo(stockCode string, crawlTimeOut int64) *[]string {
logger.SugaredLogger.Error(err.Error())
return &[]string{}
}
var messages []string
document.Find("div.quote-text-border,span.quote-price").Each(func(i int, selection *goquery.Selection) {
text := strutil.RemoveNonPrintable(selection.Text())
logger.SugaredLogger.Info(text)

View File

@ -24,7 +24,9 @@ func TestGetTelegraph(t *testing.T) {
}
func TestGetFinancialReports(t *testing.T) {
GetFinancialReports("sz000802", 30)
//GetFinancialReports("sz000802", 30)
GetFinancialReports("hk00927", 30)
}
func TestGetTelegraphSearch(t *testing.T) {
@ -41,7 +43,8 @@ func TestSearchStockInfoByCode(t *testing.T) {
}
func TestSearchStockPriceInfo(t *testing.T) {
SearchStockPriceInfo("sh600745", 30)
SearchStockPriceInfo("hk00927", 30)
SearchStockPriceInfo("sh600859", 30)
}
func TestParseFullSingleStockData(t *testing.T) {
@ -49,7 +52,7 @@ func TestParseFullSingleStockData(t *testing.T) {
SetHeader("Host", "hq.sinajs.cn").
SetHeader("Referer", "https://finance.sina.com.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").
Get(fmt.Sprintf(sinaStockUrl, time.Now().Unix(), "sh600859,sh600745"))
Get(fmt.Sprintf(sinaStockUrl, time.Now().Unix(), "sh600859,sz000034,hk01810,hk00856"))
if err != nil {
logger.SugaredLogger.Error(err.Error())
}
@ -57,6 +60,11 @@ func TestParseFullSingleStockData(t *testing.T) {
strs := strutil.SplitEx(data, "\n", true)
for _, str := range strs {
logger.SugaredLogger.Info(str)
stockData, err := ParseFullSingleStockData(str)
if err != nil {
return
}
logger.SugaredLogger.Infof("%+#v", stockData)
}
}

View File

@ -3,6 +3,7 @@ package data
import (
"github.com/duke-git/lancet/v2/convertor"
"github.com/duke-git/lancet/v2/slice"
"github.com/duke-git/lancet/v2/strutil"
"github.com/go-resty/resty/v2"
"go-stock/backend/logger"
"time"
@ -30,10 +31,12 @@ func (receiver TushareApi) GetDaily(tsCode, startDate, endDate string, crawlTime
logger.SugaredLogger.Debugf("tushare daily request: ts_code=%s, start_date=%s, end_date=%s", tsCode, startDate, endDate)
fields := "ts_code,trade_date,open,high,low,close,pre_close,change,pct_chg,vol,amount"
resp := &TushareStockBasicResponse{}
stockType := getStockType(tsCode)
logger.SugaredLogger.Debugf("tushare daily request: %s", stockType)
_, err := receiver.client.SetTimeout(time.Duration(crawlTimeOut)*time.Second).R().
SetHeader("content-type", "application/json").
SetBody(&TushareRequest{
ApiName: "daily",
ApiName: stockType,
Token: receiver.config.TushareToken,
Params: map[string]any{
"ts_code": tsCode,
@ -64,3 +67,13 @@ func (receiver TushareApi) GetDaily(tsCode, startDate, endDate string, crawlTime
logger.SugaredLogger.Debugf("tushare response: %s", res)
return res
}
func getStockType(code string) string {
if strutil.HasSuffixAny(code, []string{"SZ", "SH", "sh", "sz"}) {
return "daily"
}
if strutil.HasSuffixAny(code, []string{"HK", "hk"}) {
return "hk_daily"
}
return ""
}

View File

@ -12,7 +12,7 @@ import (
func TestGetDaily(t *testing.T) {
db.Init("../../data/stock.db")
tushareApi := NewTushareApi(getConfig())
res := tushareApi.GetDaily("000802.SZ", "20250101", "20250217", 30)
res := tushareApi.GetDaily("00927.HK", "20250101", "20250217", 30)
t.Log(res)
}

View File

@ -163,6 +163,19 @@ func (receiver VersionInfo) TableName() string {
return "version_info"
}
type StockInfoHK struct {
gorm.Model
Code string `json:"code"`
Name string `json:"name"`
FullName string `json:"fullName"`
EName string `json:"eName"`
IsDel soft_delete.DeletedAt `gorm:"softDelete:flag"`
}
func (receiver StockInfoHK) TableName() string {
return "stock_base_info_hk"
}
type Resp struct {
Code int `json:"code"`
Message string `json:"message"`

View File

@ -0,0 +1,49 @@
package models
import (
"encoding/json"
"github.com/duke-git/lancet/v2/strutil"
"go-stock/backend/db"
"go-stock/backend/logger"
"os"
"testing"
)
// @Author spark
// @Date 2025/2/22 16:09
// @Desc
// -----------------------------------------------------------------------------------
type StockInfoHKResp struct {
Code int `json:"code"`
Status string `json:"status"`
StockInfos *[]StockInfoData `json:"data"`
}
type StockInfoData struct {
C string `json:"c"`
N string `json:"n"`
T string `json:"t"`
E string `json:"e"`
}
func TestStockInfoHK(t *testing.T) {
db.Init("../../data/stock.db")
db.Dao.AutoMigrate(&StockInfoHK{})
bs, _ := os.ReadFile("../../build/hk.json")
v := &StockInfoHKResp{}
err := json.Unmarshal(bs, v)
if err != nil {
return
}
hks := &[]StockInfoHK{}
for i, data := range *v.StockInfos {
logger.SugaredLogger.Infof("第%d条数据: %+v", i, data)
hk := &StockInfoHK{
Code: strutil.PadStart(data.C, 5, "0") + ".HK",
EName: data.N,
}
*hks = append(*hks, *hk)
}
db.Dao.Create(&hks)
}

15192
build/hk.json Normal file

File diff suppressed because it is too large Load Diff

27843
build/stock_base_info_hk.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -675,7 +675,7 @@ async function saveAsWord() {
<p>
<a href="https://github.com/ArvinLovegood/go-stock">
AI赋能股票分析自选股行情获取成本盈亏展示涨跌报警推送市场整体/个股情绪分析K线技术指标分析等数据全部保留在本地支持DeepSeekOpenAI OllamaLMStudioAnythingLLM硅基流动火山方舟阿里云百炼等平台或模型
</a></p>>
</a></p>
`
// landscapeportraitportrait
const blob = await asBlob(value, { orientation: 'portrait' })

25
main.go
View File

@ -41,6 +41,9 @@ var wxpay []byte
//go:embed build/stock_basic.json
var stocksBin []byte
//go:embed build/stock_base_info_hk.json
var stocksBinHK []byte
//go:generate cp -R ./data ./build/bin
var Version string
@ -55,10 +58,15 @@ func main() {
db.Dao.AutoMigrate(&data.IndexBasic{})
db.Dao.AutoMigrate(&data.Settings{})
db.Dao.AutoMigrate(&models.AIResponseResult{})
db.Dao.AutoMigrate(&models.StockInfoHK{})
if stocksBin != nil && len(stocksBin) > 0 {
go initStockData()
}
if stocksBinHK != nil && len(stocksBinHK) > 0 {
go initStockDataHK()
}
updateBasicInfo()
// Create an instance of the app structure
@ -162,6 +170,23 @@ func main() {
}
func initStockDataHK() {
var count int64
db.Dao.Model(&models.StockInfoHK{}).Count(&count)
if count > 0 {
return
}
var v []models.StockInfoHK
err := json.Unmarshal(stocksBinHK, &v)
if err != nil {
return
}
for _, item := range v {
db.Dao.Model(&models.StockInfoHK{}).Create(&item)
}
log.Printf("init stock data hk %d", len(v))
}
func updateBasicInfo() {
config := data.NewSettingsApi(&data.Settings{}).GetConfig()
if config.UpdateBasicInfoOnStart {