feat(tools): 新增 4 个 agent 工具 —— web_search / web_fetch / calculator / current_datetime
均在 mcp-go,注册即 agent 可见(dispatcher 经 list_tools 动态发现,零改调度代码): - web_search 联网搜索:Tavily(有 TAVILY_API_KEY 则用,干净 JSON)/ DuckDuckGo HTML(免 key 兜底)。 - web_fetch 网页抓取:取 URL → 去脚本样式 → 去标签 → 解实体 → 收敛空白,限 8000 rune; 复用 external_api 的 SSRF 防护(拒环回/内网/元数据 + 重定向校验)。 - calculator 计算器:自研调度场算法求值(+ - * / % ^ 括号、一元负号),杜绝任意代码执行。 - current_datetime 当前时间:含星期与时区(可传 tz)。 测试:单测覆盖 evalArith(含右结合/一元负号/错误用例)、htmlToText(剔脚本样式+解实体)、 ddgUnwrap。live 自主 agent 实测:calculator (123+456)*7→4053、current_datetime 周三、 web_fetch example.com→200、web_search DDG 返回真实结果,全部端到端通过。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -81,7 +81,7 @@ func (g *Gateway) buildRegistry() map[string]toolDef {
|
||||
// —— 暴露给自主 agent 的工具(带参数 schema / 注入声明)——
|
||||
"wiki_search": {
|
||||
cn: "知识检索", desc: "检索知识库,返回与查询最相关的资料片段。需要外部知识/事实依据时调用。",
|
||||
agent: true,
|
||||
agent: true,
|
||||
params: []paramSpec{{Name: "q", Type: "string", Desc: "检索查询语句", Required: true}},
|
||||
inject: []string{"kb"}, handler: g.wikiSearch,
|
||||
},
|
||||
@@ -102,6 +102,33 @@ func (g *Gateway) buildRegistry() map[string]toolDef {
|
||||
cn: "历史召回", desc: "取当前会话最近多轮对话,用于理解上下文。",
|
||||
agent: true, inject: []string{"session_id"}, handler: g.historyGet,
|
||||
},
|
||||
"web_search": {
|
||||
cn: "联网搜索", desc: "联网搜索,返回最新网页结果(标题/链接/摘要)。需要实时/最新信息或外部事实时调用。",
|
||||
agent: true,
|
||||
params: []paramSpec{
|
||||
{Name: "q", Type: "string", Desc: "搜索关键词", Required: true},
|
||||
{Name: "topK", Type: "integer", Desc: "返回结果条数(默认 5,最多 10)"},
|
||||
},
|
||||
handler: g.webSearch,
|
||||
},
|
||||
"web_fetch": {
|
||||
cn: "网页抓取", desc: "抓取一个网页 URL 并提取正文文本。需要读取某个链接的内容时调用。",
|
||||
agent: true,
|
||||
params: []paramSpec{{Name: "url", Type: "string", Desc: "要抓取的网页 URL", Required: true}},
|
||||
handler: g.webFetch,
|
||||
},
|
||||
"calculator": {
|
||||
cn: "计算器", desc: "精确计算数学表达式(+ - * / % ^ 与括号)。涉及算术/数值计算时调用,不要心算。",
|
||||
agent: true,
|
||||
params: []paramSpec{{Name: "expr", Type: "string", Desc: "数学表达式,如 (3+4)*2^3", Required: true}},
|
||||
handler: g.calculator,
|
||||
},
|
||||
"current_datetime": {
|
||||
cn: "当前时间", desc: "获取当前日期与时间(含星期)。需要“现在/今天几号/星期几”等时间信息时调用。",
|
||||
agent: true,
|
||||
params: []paramSpec{{Name: "tz", Type: "string", Desc: "可选时区,如 Asia/Shanghai;缺省服务器本地时区"}},
|
||||
handler: g.currentDatetime,
|
||||
},
|
||||
|
||||
// —— 仅内部/流水线/管理用,不暴露给自主 agent ——
|
||||
"kb_ingest": {cn: "知识入库", desc: "文本切块 → 向量化 → 写入 Milvus / Bleve", handler: g.kbIngest},
|
||||
@@ -147,10 +174,10 @@ func (g *Gateway) listTools() *contract.ToolResult {
|
||||
Name string `json:"name"`
|
||||
CN string `json:"cn"`
|
||||
Desc string `json:"desc"`
|
||||
Agent bool `json:"agent_exposed"` // 是否给自主 agent
|
||||
AgentName string `json:"agent_name,omitempty"`// 模型可见名(空=name)
|
||||
Params []paramSpec `json:"params,omitempty"` // 模型可填参数
|
||||
Inject []string `json:"inject,omitempty"` // 服务端注入参数(不暴露给模型)
|
||||
Agent bool `json:"agent_exposed"` // 是否给自主 agent
|
||||
AgentName string `json:"agent_name,omitempty"` // 模型可见名(空=name)
|
||||
Params []paramSpec `json:"params,omitempty"` // 模型可填参数
|
||||
Inject []string `json:"inject,omitempty"` // 服务端注入参数(不暴露给模型)
|
||||
}
|
||||
out := make([]info, 0, len(g.tools))
|
||||
for name, td := range g.tools {
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sundynix/sundynix-shared/contract"
|
||||
)
|
||||
|
||||
// ===== current_datetime:当前日期时间(LLM 不知道"现在")=====
|
||||
|
||||
var weekdaysCN = []string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"}
|
||||
|
||||
func (g *Gateway) currentDatetime(_ context.Context, call *contract.ToolCall) *contract.ToolResult {
|
||||
loc := time.Local
|
||||
if tz, _ := call.Args["tz"].(string); strings.TrimSpace(tz) != "" {
|
||||
if l, err := time.LoadLocation(strings.TrimSpace(tz)); err == nil {
|
||||
loc = l
|
||||
}
|
||||
}
|
||||
now := time.Now().In(loc)
|
||||
out := fmt.Sprintf("%s %s(%s,时区 %s,Unix %d)",
|
||||
now.Format("2006-01-02"), now.Format("15:04:05"),
|
||||
weekdaysCN[int(now.Weekday())], now.Format("MST-07:00"), now.Unix())
|
||||
return &contract.ToolResult{OK: true, Content: out}
|
||||
}
|
||||
|
||||
// ===== calculator:安全表达式求值(+ - * / % ^ 括号、一元负号)=====
|
||||
|
||||
func (g *Gateway) calculator(_ context.Context, call *contract.ToolCall) *contract.ToolResult {
|
||||
expr := strings.TrimSpace(fmt.Sprint(call.Args["expr"]))
|
||||
if expr == "" || expr == "<nil>" {
|
||||
return &contract.ToolResult{OK: false, Error: "calculator: expr 必填"}
|
||||
}
|
||||
v, err := evalArith(expr)
|
||||
if err != nil {
|
||||
return &contract.ToolResult{OK: false, Error: "calculator: " + err.Error()}
|
||||
}
|
||||
return &contract.ToolResult{OK: true, Content: strconv.FormatFloat(v, 'g', -1, 64)}
|
||||
}
|
||||
|
||||
// evalArith 用调度场算法把中缀表达式转 RPN 再求值。仅支持数值与 + - * / % ^ ( ),杜绝任意代码执行。
|
||||
func evalArith(s string) (float64, error) {
|
||||
toks, err := tokenizeArith(s)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
prec := map[string]int{"+": 1, "-": 1, "*": 2, "/": 2, "%": 2, "^": 3, "u-": 4}
|
||||
rightAssoc := map[string]bool{"^": true, "u-": true}
|
||||
var output, ops []string
|
||||
for i, t := range toks {
|
||||
switch {
|
||||
case isNumber(t):
|
||||
output = append(output, t)
|
||||
case t == "(":
|
||||
ops = append(ops, t)
|
||||
case t == ")":
|
||||
for len(ops) > 0 && ops[len(ops)-1] != "(" {
|
||||
output = append(output, ops[len(ops)-1])
|
||||
ops = ops[:len(ops)-1]
|
||||
}
|
||||
if len(ops) == 0 {
|
||||
return 0, fmt.Errorf("括号不匹配")
|
||||
}
|
||||
ops = ops[:len(ops)-1] // 弹出 "("
|
||||
default: // 运算符
|
||||
op := t
|
||||
// 一元负号:在表达式开头或运算符/左括号之后的 "-"。
|
||||
if op == "-" && (i == 0 || isOperator(toks[i-1]) || toks[i-1] == "(") {
|
||||
op = "u-"
|
||||
}
|
||||
for len(ops) > 0 {
|
||||
top := ops[len(ops)-1]
|
||||
if top == "(" {
|
||||
break
|
||||
}
|
||||
if prec[top] > prec[op] || (prec[top] == prec[op] && !rightAssoc[op]) {
|
||||
output = append(output, top)
|
||||
ops = ops[:len(ops)-1]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
ops = append(ops, op)
|
||||
}
|
||||
}
|
||||
for len(ops) > 0 {
|
||||
if ops[len(ops)-1] == "(" {
|
||||
return 0, fmt.Errorf("括号不匹配")
|
||||
}
|
||||
output = append(output, ops[len(ops)-1])
|
||||
ops = ops[:len(ops)-1]
|
||||
}
|
||||
return evalRPN(output)
|
||||
}
|
||||
|
||||
func evalRPN(rpn []string) (float64, error) {
|
||||
var st []float64
|
||||
pop := func() (float64, error) {
|
||||
if len(st) == 0 {
|
||||
return 0, fmt.Errorf("表达式非法")
|
||||
}
|
||||
v := st[len(st)-1]
|
||||
st = st[:len(st)-1]
|
||||
return v, nil
|
||||
}
|
||||
for _, t := range rpn {
|
||||
if isNumber(t) {
|
||||
f, _ := strconv.ParseFloat(t, 64)
|
||||
st = append(st, f)
|
||||
continue
|
||||
}
|
||||
if t == "u-" {
|
||||
a, err := pop()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
st = append(st, -a)
|
||||
continue
|
||||
}
|
||||
b, err := pop()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
a, err := pop()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
switch t {
|
||||
case "+":
|
||||
st = append(st, a+b)
|
||||
case "-":
|
||||
st = append(st, a-b)
|
||||
case "*":
|
||||
st = append(st, a*b)
|
||||
case "/":
|
||||
if b == 0 {
|
||||
return 0, fmt.Errorf("除以零")
|
||||
}
|
||||
st = append(st, a/b)
|
||||
case "%":
|
||||
st = append(st, math.Mod(a, b))
|
||||
case "^":
|
||||
st = append(st, math.Pow(a, b))
|
||||
default:
|
||||
return 0, fmt.Errorf("未知运算符 %q", t)
|
||||
}
|
||||
}
|
||||
if len(st) != 1 {
|
||||
return 0, fmt.Errorf("表达式非法")
|
||||
}
|
||||
return st[0], nil
|
||||
}
|
||||
|
||||
func tokenizeArith(s string) ([]string, error) {
|
||||
var toks []string
|
||||
r := []rune(s)
|
||||
for i := 0; i < len(r); {
|
||||
c := r[i]
|
||||
switch {
|
||||
case c == ' ' || c == '\t':
|
||||
i++
|
||||
case strings.ContainsRune("+-*/%^()", c):
|
||||
toks = append(toks, string(c))
|
||||
i++
|
||||
case (c >= '0' && c <= '9') || c == '.':
|
||||
j := i
|
||||
for j < len(r) && ((r[j] >= '0' && r[j] <= '9') || r[j] == '.' || r[j] == 'e' || r[j] == 'E' ||
|
||||
((r[j] == '+' || r[j] == '-') && j > i && (r[j-1] == 'e' || r[j-1] == 'E'))) {
|
||||
j++
|
||||
}
|
||||
num := string(r[i:j])
|
||||
if _, err := strconv.ParseFloat(num, 64); err != nil {
|
||||
return nil, fmt.Errorf("非法数字 %q", num)
|
||||
}
|
||||
toks = append(toks, num)
|
||||
i = j
|
||||
default:
|
||||
return nil, fmt.Errorf("非法字符 %q(仅支持数字与 + - * / %% ^ ( ))", string(c))
|
||||
}
|
||||
}
|
||||
if len(toks) == 0 {
|
||||
return nil, fmt.Errorf("空表达式")
|
||||
}
|
||||
return toks, nil
|
||||
}
|
||||
|
||||
func isNumber(t string) bool { _, err := strconv.ParseFloat(t, 64); return err == nil }
|
||||
func isOperator(t string) bool {
|
||||
return t == "+" || t == "-" || t == "*" || t == "/" || t == "%" || t == "^"
|
||||
}
|
||||
|
||||
// ===== web_fetch:抓取网页并提取正文文本 =====
|
||||
|
||||
var (
|
||||
// RE2 不支持反向引用,逐标签枚举其配对闭合。
|
||||
reScriptStyle = regexp.MustCompile(`(?is)<script\b[^>]*>.*?</script\s*>|<style\b[^>]*>.*?</style\s*>|<head\b[^>]*>.*?</head\s*>|<noscript\b[^>]*>.*?</noscript\s*>`)
|
||||
reTag = regexp.MustCompile(`(?s)<[^>]+>`)
|
||||
reWS = regexp.MustCompile(`[ \t\x{00a0}]+`)
|
||||
reBlankLines = regexp.MustCompile(`\n\s*\n\s*\n+`)
|
||||
)
|
||||
|
||||
const webFetchMaxText = 8000 // 提取正文上限(rune),避免塞爆上下文
|
||||
|
||||
func (g *Gateway) webFetch(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
|
||||
raw := strings.TrimSpace(fmt.Sprint(call.Args["url"]))
|
||||
if raw == "" || raw == "<nil>" {
|
||||
return &contract.ToolResult{OK: false, Error: "web_fetch: url 必填"}
|
||||
}
|
||||
if reason, ok := validateExternalURL(raw, extAllowlist()); !ok {
|
||||
return &contract.ToolResult{OK: false, Error: "web_fetch: URL 被拦截 —— " + reason}
|
||||
}
|
||||
body, status, err := httpGet(ctx, raw)
|
||||
if err != nil {
|
||||
return &contract.ToolResult{OK: false, Error: "web_fetch: " + err.Error()}
|
||||
}
|
||||
text := htmlToText(body)
|
||||
if rs := []rune(text); len(rs) > webFetchMaxText {
|
||||
text = string(rs[:webFetchMaxText]) + "\n…(已截断)"
|
||||
}
|
||||
return &contract.ToolResult{OK: true, Content: fmt.Sprintf("URL: %s (HTTP %d)\n\n%s", raw, status, text)}
|
||||
}
|
||||
|
||||
// htmlToText 把 HTML 粗提取为可读文本:去脚本/样式 → 去标签 → 解实体 → 收敛空白。
|
||||
func htmlToText(h string) string {
|
||||
h = reScriptStyle.ReplaceAllString(h, " ")
|
||||
h = regexp.MustCompile(`(?i)<\s*(br|/p|/div|/li|/h[1-6]|/tr)\s*/?>`).ReplaceAllString(h, "\n")
|
||||
h = reTag.ReplaceAllString(h, "")
|
||||
h = html.UnescapeString(h)
|
||||
h = reWS.ReplaceAllString(h, " ")
|
||||
h = reBlankLines.ReplaceAllString(h, "\n\n")
|
||||
return strings.TrimSpace(h)
|
||||
}
|
||||
|
||||
// ===== web_search:联网搜索(Tavily 有 key 则用,否则 DuckDuckGo HTML 兜底)=====
|
||||
|
||||
const webSearchDefaultTopK = 5
|
||||
|
||||
func (g *Gateway) webSearch(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
|
||||
q := strings.TrimSpace(fmt.Sprint(call.Args["q"]))
|
||||
if q == "" || q == "<nil>" {
|
||||
return &contract.ToolResult{OK: false, Error: "web_search: q 必填"}
|
||||
}
|
||||
topK := webSearchDefaultTopK
|
||||
if n, ok := toInt(call.Args["topK"]); ok && n > 0 && n <= 10 {
|
||||
topK = n
|
||||
}
|
||||
if key := strings.TrimSpace(os.Getenv("TAVILY_API_KEY")); key != "" {
|
||||
if out, err := tavilySearch(ctx, key, q, topK); err == nil {
|
||||
return &contract.ToolResult{OK: true, Content: out}
|
||||
}
|
||||
// Tavily 失败 → 落 DDG 兜底
|
||||
}
|
||||
out, err := ddgSearch(ctx, q, topK)
|
||||
if err != nil {
|
||||
return &contract.ToolResult{OK: false, Error: "web_search: " + err.Error()}
|
||||
}
|
||||
return &contract.ToolResult{OK: true, Content: out}
|
||||
}
|
||||
|
||||
var (
|
||||
reDDGLink = regexp.MustCompile(`(?is)<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
|
||||
reDDGSnippet = regexp.MustCompile(`(?is)<a[^>]+class="result__snippet"[^>]*>(.*?)</a>`)
|
||||
)
|
||||
|
||||
// ddgSearch 抓 DuckDuckGo HTML 版结果(免 key)。SERP 结构变动时尽力解析,解析不到则返回提示。
|
||||
func ddgSearch(ctx context.Context, q string, topK int) (string, error) {
|
||||
u := "https://html.duckduckgo.com/html/?q=" + url.QueryEscape(q)
|
||||
body, _, err := httpGet(ctx, u)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
links := reDDGLink.FindAllStringSubmatch(body, -1)
|
||||
snips := reDDGSnippet.FindAllStringSubmatch(body, -1)
|
||||
if len(links) == 0 {
|
||||
return "", fmt.Errorf("未解析到结果(DDG 可能限流或改版)")
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(links) && i < topK; i++ {
|
||||
title := strings.TrimSpace(htmlToText(links[i][2]))
|
||||
href := ddgUnwrap(links[i][1])
|
||||
snippet := ""
|
||||
if i < len(snips) {
|
||||
snippet = strings.TrimSpace(htmlToText(snips[i][1]))
|
||||
}
|
||||
fmt.Fprintf(&b, "%d. %s\n %s\n %s\n", i+1, title, href, snippet)
|
||||
}
|
||||
return strings.TrimSpace(b.String()), nil
|
||||
}
|
||||
|
||||
// ddgUnwrap 还原 DDG 的跳转链接 /l/?uddg=<编码真实 url>。
|
||||
func ddgUnwrap(href string) string {
|
||||
if i := strings.Index(href, "uddg="); i >= 0 {
|
||||
raw := href[i+len("uddg="):]
|
||||
if amp := strings.IndexByte(raw, '&'); amp >= 0 {
|
||||
raw = raw[:amp]
|
||||
}
|
||||
if dec, err := url.QueryUnescape(raw); err == nil {
|
||||
return dec
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(href, "//") {
|
||||
return "https:" + href
|
||||
}
|
||||
return href
|
||||
}
|
||||
|
||||
// tavilySearch 走 Tavily Search API(需 TAVILY_API_KEY)—— 返回干净的标题/URL/摘要。
|
||||
func tavilySearch(ctx context.Context, key, q string, topK int) (string, error) {
|
||||
payload := fmt.Sprintf(`{"api_key":%q,"query":%q,"max_results":%d}`, key, q, topK)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.tavily.com/search", strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := (&http.Client{Timeout: extTimeout}).Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("tavily HTTP %d", resp.StatusCode)
|
||||
}
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, extMaxBytes))
|
||||
// 轻量提取(不引 JSON 结构体):用正则挑 title/url/content。
|
||||
reItem := regexp.MustCompile(`(?is)"title"\s*:\s*"(.*?)".*?"url"\s*:\s*"(.*?)".*?"content"\s*:\s*"(.*?)"`)
|
||||
items := reItem.FindAllStringSubmatch(string(data), -1)
|
||||
if len(items) == 0 {
|
||||
return "", fmt.Errorf("tavily 无结果")
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, m := range items {
|
||||
if i >= topK {
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(&b, "%d. %s\n %s\n %s\n", i+1, jsonUnesc(m[1]), jsonUnesc(m[2]), truncateRunes(jsonUnesc(m[3]), 240))
|
||||
}
|
||||
return strings.TrimSpace(b.String()), nil
|
||||
}
|
||||
|
||||
// ===== 公共小工具 =====
|
||||
|
||||
func httpGet(ctx context.Context, raw string) (body string, status int, err error) {
|
||||
allow := extAllowlist()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
// 带个常见 UA,部分站点对空 UA 返回 403。
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; sundynix-agentix/1.0)")
|
||||
client := &http.Client{
|
||||
Timeout: extTimeout,
|
||||
CheckRedirect: func(r *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 3 {
|
||||
return fmt.Errorf("重定向过多")
|
||||
}
|
||||
if reason, ok := validateExternalURL(r.URL.String(), allow); !ok {
|
||||
return fmt.Errorf("重定向被拦截:%s", reason)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, extMaxBytes))
|
||||
return string(data), resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func toInt(v any) (int, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
case string:
|
||||
if i, err := strconv.Atoi(strings.TrimSpace(n)); err == nil {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// truncateRunes 按 rune 截断(中文安全),超出加省略号。
|
||||
func truncateRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
// jsonUnesc 还原 JSON 字符串里的常见转义(够用即可,不做完整解码)。
|
||||
func jsonUnesc(s string) string {
|
||||
r := strings.NewReplacer(`\"`, `"`, `\\`, `\`, `\n`, " ", `\t`, " ", `\/`, "/")
|
||||
return strings.TrimSpace(r.Replace(s))
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEvalArith(t *testing.T) {
|
||||
cases := map[string]float64{
|
||||
"1+2*3": 7,
|
||||
"(1+2)*3": 9,
|
||||
"2^3^2": 512, // 右结合
|
||||
"-3+5": 2,
|
||||
"10/4": 2.5,
|
||||
"10%3": 1,
|
||||
"2*(3+4)-5": 9,
|
||||
"-(2+3)*2": -10,
|
||||
"3.5e1+0.5": 35.5,
|
||||
}
|
||||
for in, want := range cases {
|
||||
got, err := evalArith(in)
|
||||
if err != nil {
|
||||
t.Errorf("evalArith(%q) 报错: %v", in, err)
|
||||
continue
|
||||
}
|
||||
if math.Abs(got-want) > 1e-9 {
|
||||
t.Errorf("evalArith(%q)=%v want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalArithErrors(t *testing.T) {
|
||||
for _, in := range []string{"1/0", "1+", "(1+2", "1+2)", "rm -rf /", "import os", ""} {
|
||||
if _, err := evalArith(in); err == nil {
|
||||
t.Errorf("evalArith(%q) 应报错但没有", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLToText(t *testing.T) {
|
||||
in := `<html><head><title>x</title></head><body><script>alert(1)</script>
|
||||
<h1>标题</h1><p>第一段&符号</p><style>.a{}</style><div>第二段</div></body></html>`
|
||||
got := htmlToText(in)
|
||||
if strings.Contains(got, "alert") || strings.Contains(got, ".a{}") {
|
||||
t.Fatalf("脚本/样式未剔除: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "标题") || !strings.Contains(got, "第一段&符号") || !strings.Contains(got, "第二段") {
|
||||
t.Fatalf("正文/实体提取不正确: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDDGUnwrap(t *testing.T) {
|
||||
got := ddgUnwrap("//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fa&rut=x")
|
||||
if got != "https://example.com/a" {
|
||||
t.Fatalf("ddgUnwrap 解码错误: %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user