// // Copyright 2026 The InfiniFlow Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package tool import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/cloudwego/eino/components/tool" "github.com/cloudwego/eino/schema" ) const akshareToolName = "akshare_stock_news" const akshareToolDescription = "Retrieves the latest East Money news articles for a Chinese A-share stock symbol." const defaultAkShareTopN = 10 const maxAkShareResponseBytes = 4 << 20 var akshareStockNewsEndpoint = "https://search-api-web.eastmoney.com/search/jsonp" // akshareParams is the JSON shape the model sends into InvokableRun. type akshareParams struct { Query string `json:"query"` Symbol string `json:"symbol,omitempty"` TopN int `json:"top_n,omitempty"` } func (p akshareParams) stockSymbol() string { if query := strings.TrimSpace(p.Query); query != "" { return query } return strings.TrimSpace(p.Symbol) } type akshareArticle struct { Title string `json:"title"` Content string `json:"content"` PublishedAt string `json:"published_at"` Source string `json:"source"` URL string `json:"url"` } type akshareEnvelope struct { Content string `json:"content,omitempty"` Articles []akshareArticle `json:"articles,omitempty"` Error string `json:"_ERROR,omitempty"` } type akshareSearchRequest struct { UID string `json:"uid"` Keyword string `json:"keyword"` Type []string `json:"type"` Client string `json:"client"` ClientType string `json:"clientType"` ClientVersion string `json:"clientVersion"` Param map[string]any `json:"param"` } type akshareSearchResponse struct { Result struct { Articles []akshareEastMoneyArticle `json:"cmsArticleWebOld"` } `json:"result"` } type akshareEastMoneyArticle struct { Code string `json:"code"` Title string `json:"title"` Content string `json:"content"` Date string `json:"date"` MediaName string `json:"mediaName"` } // AkShareTool retrieves East Money stock news using the same endpoint // as AkShare's stock_news_em(symbol=...) helper. type AkShareTool struct { helper *HTTPHelper topN int } func NewAkShareTool() *AkShareTool { return NewAkShareToolWith(NewHTTPHelper()) } func NewAkShareToolWith(h *HTTPHelper) *AkShareTool { return NewAkShareToolWithTopN(h, defaultAkShareTopN) } func NewAkShareToolWithTopN(h *HTTPHelper, topN int) *AkShareTool { if h == nil { h = NewHTTPHelper() } if topN <= 0 { topN = defaultAkShareTopN } return &AkShareTool{helper: h, topN: topN} } // Info returns the tool's metadata for the chat model. func (a *AkShareTool) Info(_ context.Context) (*schema.ToolInfo, error) { return &schema.ToolInfo{ Name: akshareToolName, Desc: akshareToolDescription, ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{ "query": { Type: schema.String, Desc: "Stock symbol/code to fetch East Money news for, e.g. 600519.", Required: true, }, }), }, nil } // InvokableRun fetches stock news from East Money and returns both a // formatted content string and structured article records. func (a *AkShareTool) InvokableRun(ctx context.Context, argsJSON string, _ ...tool.Option) (string, error) { var p akshareParams if err := json.Unmarshal([]byte(argsJSON), &p); err != nil { return akshareErrJSON(fmt.Errorf("akshare: parse arguments: %w", err)), fmt.Errorf("akshare: parse arguments: %w", err) } symbol := p.stockSymbol() if symbol == "" { return akshareErrJSON(fmt.Errorf("akshare: query is required")), fmt.Errorf("akshare: query is required") } topN := a.topN if p.TopN > 0 { topN = p.TopN } if topN <= 0 { topN = defaultAkShareTopN } endpoint, err := buildAkShareStockNewsURL(symbol, topN) if err != nil { return akshareErrJSON(err), err } headers := map[string]string{ "Accept": "*/*", "Referer": "https://so.eastmoney.com/news/s?keyword=" + url.QueryEscape(symbol), "User-Agent": "Mozilla/5.0 (compatible; ragflow/1.0)", } resp, err := a.helper.Do(ctx, http.MethodGet, endpoint, "", "", headers) if err != nil { return akshareErrJSON(err), err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { err := fmt.Errorf("akshare: upstream returned %d", resp.StatusCode) return akshareErrJSON(err), err } body, err := io.ReadAll(io.LimitReader(resp.Body, maxAkShareResponseBytes+1)) if err != nil { err = fmt.Errorf("akshare: read response: %w", err) return akshareErrJSON(err), err } if len(body) > maxAkShareResponseBytes { err = fmt.Errorf("akshare: response too large") return akshareErrJSON(err), err } articles, err := parseAkShareStockNews(body, topN) if err != nil { return akshareErrJSON(err), err } env := akshareEnvelope{ Content: formatAkShareArticles(articles), Articles: articles, } return akshareJSON(env), nil } func buildAkShareStockNewsURL(symbol string, topN int) (string, error) { if topN <= 0 { topN = defaultAkShareTopN } inner := akshareSearchRequest{ Keyword: symbol, Type: []string{"cmsArticleWebOld"}, Client: "web", ClientType: "web", ClientVersion: "curr", Param: map[string]any{ "cmsArticleWebOld": map[string]any{ "searchScope": "default", "sort": "default", "pageIndex": 1, "pageSize": topN, "preTag": "", "postTag": "", }, }, } innerJSON, err := json.Marshal(inner) if err != nil { return "", fmt.Errorf("akshare: build request: %w", err) } callback := fmt.Sprintf("jQuery%d_%d", time.Now().UnixNano(), time.Now().UnixMilli()) q := url.Values{} q.Set("cb", callback) q.Set("param", string(innerJSON)) q.Set("_", fmt.Sprint(time.Now().UnixMilli())) return akshareStockNewsEndpoint + "?" + q.Encode(), nil } func parseAkShareStockNews(body []byte, topN int) ([]akshareArticle, error) { if topN <= 0 { topN = defaultAkShareTopN } rawJSON, err := stripJSONP(string(body)) if err != nil { return nil, err } var raw akshareSearchResponse if err := json.Unmarshal([]byte(rawJSON), &raw); err != nil { return nil, fmt.Errorf("akshare: decode response: %w", err) } if topN > len(raw.Result.Articles) { topN = len(raw.Result.Articles) } articles := make([]akshareArticle, 0, topN) for _, item := range raw.Result.Articles[:topN] { articleURL := "" if code := strings.TrimSpace(item.Code); code != "" { articleURL = "http://finance.eastmoney.com/a/" + code + ".html" } articles = append(articles, akshareArticle{ Title: cleanAkShareText(item.Title), Content: cleanAkShareText(item.Content), PublishedAt: strings.TrimSpace(item.Date), Source: strings.TrimSpace(item.MediaName), URL: articleURL, }) } return articles, nil } func stripJSONP(s string) (string, error) { s = strings.TrimSpace(s) start := strings.IndexByte(s, '(') end := strings.LastIndexByte(s, ')') if start < 0 || end <= start { if strings.HasPrefix(s, "{") { return s, nil } return "", fmt.Errorf("akshare: invalid JSONP response") } return strings.TrimSpace(s[start+1 : end]), nil } func cleanAkShareText(s string) string { replacements := []struct { old string new string }{ {old: "(", new: ""}, {old: ")", new: ""}, {old: "", new: ""}, {old: "", new: ""}, {old: "\u3000", new: ""}, {old: "\r\n", new: " "}, } for _, repl := range replacements { s = strings.ReplaceAll(s, repl.old, repl.new) } return strings.TrimSpace(s) } // formatAkShareArticles renders articles into a human-readable block. // NOTE: the upstream Python PR's _invoke dropped 文章来源 from the // format string (5 args but only 4 {} placeholders); we preserve all // five fields here (link, title, content, date, source). func formatAkShareArticles(articles []akshareArticle) string { if len(articles) == 0 { return "" } parts := make([]string, 0, len(articles)) for _, item := range articles { parts = append(parts, fmt.Sprintf( `%s 新闻内容: %s 发布时间:%s 文章来源: %s`, item.URL, item.Title, item.Content, item.PublishedAt, item.Source, )) } return strings.Join(parts, "\n\n") } func akshareJSON(env akshareEnvelope) string { b, err := json.Marshal(env) if err != nil { return fmt.Sprintf(`{"_ERROR":"akshare: marshal result: %s"}`, err) } return string(b) } func akshareErrJSON(err error) string { return akshareJSON(akshareEnvelope{Error: err.Error()}) }