// Package sina 新浪财经数据源:提供可转债转股价变动历史。 // 数据来源:money.finance.sina.com.cn/bond/conversion/{market}{code}.html(转股价变动表), // 含初始转股价与历次调整/修正记录,字段:生效日期 + 转股价。 package sina import ( "context" "fmt" "io" "net/http" "regexp" "strconv" "strings" "time" "golang.org/x/text/encoding/simplifiedchinese" "golang.org/x/text/transform" "stock-analysis/internal/model" ) // conversionBase 新浪转债行权详情页(含转股价变动历史)。 const conversionBase = "http://money.finance.sina.com.cn/bond/conversion/%s%s.html" var ( bluecntRowRe = regexp.MustCompile(`(.*?)`) cellRe = regexp.MustCompile(`]*>(.*?)`) stripTagRe = regexp.MustCompile(`<[^>]+>`) spaceRe = regexp.MustCompile(`\s+`) ) // Client 是新浪财经客户端。 type Client struct { http *http.Client } // NewClient 创建新浪财经客户端。 func NewClient() *Client { return &Client{http: &http.Client{Timeout: 15 * time.Second}} } // FetchConvertPrices 拉取指定转债的转股价变动历史(含初始转股价)。 // 返回按生效日期升序的记录;页面缺失时返回空切片(由调用方用初始转股价兜底)。 func (c *Client) FetchConvertPrices(ctx context.Context, code string) ([]model.ConvertPrice, error) { reqURL := fmt.Sprintf(conversionBase, symbolOf(code), code) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36") resp, err := c.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("http status %d", resp.StatusCode) } // 新浪页面为 GBK 编码,转成 UTF-8 后解析。 body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } utf8Body, _, err := transform.Bytes(simplifiedchinese.GBK.NewDecoder(), body) if err != nil { return nil, fmt.Errorf("decode gbk: %w", err) } return parseConvertPrices(string(utf8Body)), nil } // parseConvertPrices 从 HTML 中提取转股价变动表的记录。 // 仅解析"转股价变动 begin/end"注释区间内的数据行,避免误取行权/回售表。 func parseConvertPrices(html string) []model.ConvertPrice { const beginMark = "" const endMark = "" beginIdx := strings.Index(html, beginMark) if beginIdx < 0 { return nil } seg := html[beginIdx+len(beginMark):] if endIdx := strings.Index(seg, endMark); endIdx >= 0 { seg = seg[:endIdx] } var prices []model.ConvertPrice for _, row := range bluecntRowRe.FindAllString(seg, -1) { cells := cellsOf(row) // 列:序号 | 价格变动类型 | 公告日期 | 生效日期 | 执行日期 | 转股价 | 转股比例 if len(cells) < 6 { continue } effective := strings.TrimSpace(cells[3]) price, err := strconv.ParseFloat(strings.TrimSpace(cells[5]), 64) if err != nil || effective == "" || price <= 0 { continue } prices = append(prices, model.ConvertPrice{ EffectiveDate: effective, Price: price, }) } return prices } // cellsOf 解析一行 中所有 的文本(去标签去空白)。 func cellsOf(row string) []string { var cells []string for _, m := range cellRe.FindAllStringSubmatch(row, -1) { text := stripTagRe.ReplaceAllString(m[1], "") text = spaceRe.ReplaceAllString(text, "") cells = append(cells, text) } return cells } // symbolOf 生成新浪标的符号(沪市 sh / 深市 sz),与腾讯规则一致。 func symbolOf(code string) string { if strings.HasPrefix(code, "12") { return "sz" } return "sh" }