commit 30a821c21746fee3b6587bce7677fd5aeadbac23 Author: Star Date: Thu Sep 11 23:42:22 2025 +0800 第一版 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47904c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.* +!.gitignore +go.sum +node_modules +package.json +env.yml diff --git a/API.go b/API.go new file mode 100644 index 0000000..0c0b664 --- /dev/null +++ b/API.go @@ -0,0 +1,591 @@ +package plugin + +import ( + "bufio" + "bytes" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "net/textproto" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + xml2json "github.com/basgys/goxml2json" + "github.com/ssgo/httpclient" + "github.com/ssgo/u" +) + +var Signers = map[string]func(req *Request, cfg *SignerConfig) error{ + // 基础认证 + "basic": makeBasicAuthSign, + "bearer": makeBearerSign, + "jwt": makeJWTSign, + // "tc3": makeTC3Sign, + // "cos": makeCOSSign, + // "hmac": makeHmacSign, +} +var signersLock = sync.RWMutex{} + +func GetSigner(name string) func(req *Request, cfg *SignerConfig) error { + if name == "" { + return nil + } + signersLock.RLock() + defer signersLock.RUnlock() + return Signers[name] +} + +func RegisterSigner(name string, f func(req *Request, cfg *SignerConfig) error) { + signersLock.Lock() + defer signersLock.Unlock() + Signers[name] = f +} + +type SignerConfig struct { + data map[string]any +} + +var signerConfig = map[string]*map[string]any{} +var confLock = sync.RWMutex{} + +func GetSignerConfig(signer string) *SignerConfig { + confLock.RLock() + cfg := signerConfig[signer] + confLock.RUnlock() + if cfg == nil { + cfg = &map[string]any{} + } + return &SignerConfig{data: *cfg} +} + +// func (cfg *SignerConfig) Signer() string { +// return u.String(cfg.data["signer"]) +// } + +func (cfg *SignerConfig) Get(k string, defaultValue any) any { + if v, ok := cfg.data[k]; ok { + return v + } + return defaultValue +} + +func (cfg *SignerConfig) Set(k string, v any) { + cfg.data[k] = v +} + +func (cfg *SignerConfig) String(k string, defaultValue string) string { + return u.String(cfg.Get(k, defaultValue)) +} + +func (cfg *SignerConfig) Int(k string, defaultValue int64) int64 { + return u.Int64(cfg.Get(k, defaultValue)) +} + +func (cfg *SignerConfig) Float(k string, defaultValue float64) float64 { + return u.Float64(cfg.Get(k, defaultValue)) +} + +func (cfg *SignerConfig) Bool(k string, defaultValue bool) bool { + return u.Bool(cfg.Get(k, defaultValue)) +} + +func (cfg *SignerConfig) To(k string, to any) { + from := cfg.Get(k, nil) + if from != nil { + u.Convert(from, to) + } +} + +func (cfg *SignerConfig) Map(k string) map[string]any { + m := map[string]any{} + cfg.To(k, &m) + return m +} + +func getConfigValue(cfg map[string]any, k string) string { + a := u.SplitWithoutNoneN(k, ".", 2) + if len(a) > 1 { + m := map[string]any{} + u.Convert(cfg[a[0]], &m) + return u.String(m[a[1]]) + } else if len(a) == 1 { + return u.String(cfg[a[0]]) + } + return "" +} + +// func (cfg *SignerConfig) Value(k string) string { +// a := u.SplitWithoutNoneN(k, ".", 2) +// if len(a) > 1 { +// m := cfg.Map(a[0]) +// return u.String(m[a[1]]) +// } else if len(a) == 1 { +// return cfg.String(a[0], "") +// } +// return "" +// } + +var httpclients = map[uint]*httpclient.ClientPool{} +var httpclientsLock = sync.RWMutex{} + +func GetHttpClient(timeout uint) *httpclient.ClientPool { + httpclientsLock.RLock() + c := httpclients[timeout] + httpclientsLock.RUnlock() + if c != nil { + return c + } + c = httpclient.GetClient(time.Duration(timeout) * time.Millisecond) + httpclientsLock.Lock() + httpclients[timeout] = c + httpclientsLock.Unlock() + return c +} + +var RequestType = struct { + Json string // JSON请求体(默认) + Form string // 表单请求体 + Multi string // 多部分请求体 + Text string // 文本请求体 + Binary string // 二进制请求体 +}{ + Json: "json", + Form: "form", + Multi: "multi", + Text: "text", + Binary: "binary", +} + +var ResponseType = struct { + Json string // JSON响应体(默认) + Text string // 文本响应体 + Binary string // 二进制响应体 +}{ + Json: "json", + Text: "text", + Binary: "binary", +} + +type Request struct { + Url string + Method string + Config map[string]string // 配置信息,可以被 {{}} 引用 + Headers map[string]string // 请求头 + Query map[string]string // URL中的参数 + Data map[string]any // 用JSON发送的请求主体对象 + Form map[string]string // 用表单发送的主体内容 + File map[string]any // 用multi-form发送的文件对象 + Text string // 纯文本格式的主体内容 + Binary []byte // 二进制格式的主体内容 + RequestType string + ResponseType string + Callback func(data any) + Timeout uint // 单位毫秒,默认0表示不超时 + FinalUrl string // 提前处理好的完整URL + FinalHost string // 主机名 + FinalPath string // URL路径 + FinalQuery string // 查询字符串 + FinalBody []byte // 提前处理好的请求主体 +} + +func (req *Request) SetUrl(v string) { + req.Url = v +} + +func (req *Request) GetHost() string { + return req.FinalHost +} + +func (req *Request) SetMethod(v string) { + req.Method = v +} + +func (req *Request) SetQuery(k string, v string) { + req.Query[k] = v +} + +func (req *Request) SetForm(k string, v string) { + req.Form[k] = v +} + +func (req *Request) SetText(v string) { + req.Text = v +} + +func (req *Request) SetBinary(v []byte) { + req.Binary = v +} + +func (req *Request) SetFile(k string, v any) { + req.File[k] = v +} + +func (req *Request) SetData(k string, v any) { + req.Data[k] = v +} + +func (req *Request) SetHeader(key, value string) { + req.Headers[key] = value +} + +func (req *Request) MakeQuery() { + if urlInfo, err := url.Parse(req.Url); err == nil { + q := url.Values{} + for k, v := range req.Query { + q.Set(k, v) + } + urlInfo.RawQuery = q.Encode() + req.FinalUrl = urlInfo.String() + req.FinalHost = urlInfo.Host + req.FinalPath = urlInfo.Path + req.FinalQuery = urlInfo.RawQuery + if req.FinalPath == "" { + req.FinalPath = "/" + } + } else { + req.FinalUrl = req.Url + req.FinalHost = "" + req.FinalPath = "/" + req.FinalQuery = "" + } + // fmt.Println(">>>a", req.Url, req.FinalUrl) +} + +func (req *Request) makeConfig(str string) string { + if strings.Contains(str, "{{config.") { + if m := configMatcher.FindAllStringSubmatch(str, 100); m != nil { + for _, m1 := range m { + if v1, ok := req.Config[m1[1]]; ok && v1 != "" { + str = strings.ReplaceAll(str, m1[0], v1) + } + } + } + } + if strings.Contains(str, "{{/") { + if m := fnMatcher.FindAllStringSubmatch(str, 100); m != nil { + for _, m1 := range m { + fn := m1[1] + args := m1[2] + switch fn { + case "date": + str = strings.ReplaceAll(str, m1[0], time.Now().Format(args)) + case "timestamp": + str = strings.ReplaceAll(str, m1[0], u.String(time.Now().Unix())) + case "timestampMilli": + str = strings.ReplaceAll(str, m1[0], u.String(time.Now().UnixMilli())) + } + } + } + } + return str +} + +func (req *Request) MakeBody() { + switch req.RequestType { + case RequestType.Json: + data := map[string]any{} + if req.Data != nil { + for k, v := range req.Data { + if str, ok := v.(string); ok { + data[k] = req.makeConfig(str) + } else { + data[k] = v + } + } + } + req.FinalBody = u.JsonBytesP(data) + case RequestType.Form: + formData := url.Values{} + for k, v := range req.Form { + formData.Set(k, req.makeConfig(v)) + } + req.FinalBody = []byte(formData.Encode()) + case RequestType.Multi: + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + if len(req.Form) > 0 { + for k, v := range req.Form { + writer.WriteField(k, req.makeConfig(v)) + } + } + if len(req.File) > 0 { + for k, v := range req.File { + if filename, ok := v.(string); ok && u.FileExists(filename) { + if fp, err := os.Open(filename); err == nil { + if part, err := writer.CreateFormFile(k, filepath.Base(filename)); err == nil { + io.Copy(part, fp) + } + _ = fp.Close() + } + } else if dataUrl, ok := v.(string); ok && strings.HasPrefix(dataUrl, "data:") && strings.ContainsRune(dataUrl, ',') { + parts := strings.SplitN(dataUrl, ",", 2) + metaPart := strings.TrimSuffix(parts[0], "data:") + metaParts := strings.SplitN(metaPart, ";", 2) + mimeType := "application/octet-stream" + if metaParts[0] != "" { + mimeType = metaParts[0] + } + var data []byte + if len(metaParts) > 1 && metaParts[1] == "base64" { + data = u.UnBase64(parts[1]) + } else { + data = []byte(parts[1]) + } + + h := make(textproto.MIMEHeader) + filename := k + if exts, err := mime.ExtensionsByType(mimeType); err == nil && len(exts) > 0 { + filename = k + exts[0] + } + + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, k, filename)) + h.Set("Content-Type", mimeType) + if part, err := writer.CreatePart(h); err == nil { + part.Write(data) + } + } else { + h := make(textproto.MIMEHeader) + buf := u.Bytes(v) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, k, k)) + mt := mime.TypeByExtension(filepath.Ext(k)) + if mt == "" { + mt = "application/octet-stream" + } + h.Set("Content-Type", mt) + if part, err := writer.CreatePart(h); err == nil { + part.Write(buf) + } + + } + } + } + writer.Close() + req.FinalBody = body.Bytes() + case RequestType.Binary: + req.FinalBody = req.Binary + default: + req.FinalBody = []byte(req.makeConfig(req.Text)) + } +} + +func MakeSign(cfg *SignerConfig, req *Request) error { + if req.RequestType == "" { + if req.Data != nil { + req.RequestType = RequestType.Json + } else if req.Form != nil { + req.RequestType = RequestType.Form + } else if req.File != nil { + req.RequestType = RequestType.Multi + } else if req.Binary != nil { + req.RequestType = RequestType.Binary + } else { + req.RequestType = RequestType.Text + } + } + if req.Method == "" { + req.Method = "POST" + } + req.Method = strings.ToUpper(req.Method) + if req.Headers == nil { + req.Headers = make(map[string]string) + } + if req.Query == nil { + req.Query = make(map[string]string) + } + // 自动将URL中的参数放入req.Query + if strings.ContainsRune(req.Url, '?') { + if urlInfo, err := url.Parse(req.Url); err == nil { + for k, v := range urlInfo.Query() { + if len(v) > 0 { + req.Query[k] = v[0] + } + } + urlInfo.RawQuery = "" + req.Url = urlInfo.String() + } + } + if (req.Method == "POST" || req.Method == "PUT") && req.Headers["Content-Type"] == "" { + switch req.RequestType { + case RequestType.Json: + req.Headers["Content-Type"] = "application/json" + case RequestType.Form: + req.Headers["Content-Type"] = "application/x-www-form-urlencoded" + case RequestType.Multi: + req.Headers["Content-Type"] = "multipart/form-data" + case RequestType.Binary: + req.Headers["Content-Type"] = "application/octet-stream" + default: + req.Headers["Content-Type"] = "text/plain" + } + } + + if cfg == nil { + cfg = &SignerConfig{} + } + + // 从signer中设置header + for k, v := range cfg.Map("headers") { + req.Headers[k] = req.makeConfig(u.String(v)) + // fmt.Println(u.BCyan(k), req.Headers[k]) + } + + // 从signer中设置query + for k, v := range cfg.Map("query") { + req.Query[k] = req.makeConfig(u.String(v)) + } + + // 从signer中设置data + for k, v := range cfg.Map("data") { + req.Data[k] = v + } + + // 从signer中设置form + for k, v := range cfg.Map("form") { + req.Form[k] = u.String(v) + } + + req.MakeQuery() + req.MakeBody() + + if signer := GetSigner(u.String(cfg.data["signer"])); signer != nil { + // fmt.Println(111, req.Method, 111) + if err := signer(req, cfg); err != nil { + // fmt.Println(333, err) + return err + // } else { + // fmt.Println(333, 1) + } + } + // 拼接生成完整的URL + return nil +} + +func Get(cfg *SignerConfig, req *Request) (any, *http.Response, error) { + req.Method = "GET" + return Do(cfg, req) +} + +func Post(cfg *SignerConfig, req *Request) (any, *http.Response, error) { + req.Method = "POST" + return Do(cfg, req) +} + +func Put(cfg *SignerConfig, req *Request) (any, *http.Response, error) { + req.Method = "PUT" + return Do(cfg, req) +} + +func Delete(cfg *SignerConfig, req *Request) (any, *http.Response, error) { + req.Method = "DELETE" + return Do(cfg, req) +} + +func Head(cfg *SignerConfig, req *Request) (any, *http.Response, error) { + req.Method = "HEAD" + return Do(cfg, req) +} + +func Options(cfg *SignerConfig, req *Request) (any, *http.Response, error) { + req.Method = "OPTIONS" + return Do(cfg, req) +} + +func Do(cfg *SignerConfig, req *Request) (any, *http.Response, error) { + err := MakeSign(cfg, req) + if err != nil { + return nil, nil, err + } + // fmt.Println(u.BMagenta(u.JsonP(req.Headers))) + // return nil, nil, err + + headers := make([]string, 0) + for k, v := range req.Headers { + headers = append(headers, k, v) + } + c := GetHttpClient(req.Timeout) + // c.Debug = true + + // fmt.Println(req.Url, req.finalUrl, u.BMagenta(string(req.finalBody)), "===") + // fmt.Println(u.BCyan(u.JsonP(req.Headers)), "===") + + var r *httpclient.Result + var lastData any + if req.Callback != nil { + r = c.ManualDo(req.Method, req.FinalUrl, req.FinalBody, headers...) + if r.Error != nil { + return nil, r.Response, r.Error + } + respType := req.ResponseType + if respType == "" { + respType = strings.Split(r.Response.Header.Get("Content-Type"), ";")[0] + } + + if r.Response.Body != nil { + func() { + reader := bufio.NewScanner(r.Response.Body) + defer r.Response.Body.Close() + for reader.Scan() { + str := strings.TrimSpace(reader.Text()) + if str == "" { + continue + } + switch respType { + case "text/event-stream": + if strings.HasPrefix(str, "data:") { + str = strings.TrimPrefix(str, "data:") + str = strings.TrimSpace(str) + } + var d any + if str == "[DONE]" { + break + } + d = u.UnJson(str, nil) + req.Callback(d) + if d != nil { + lastData = d + } + case "application/json": + req.Callback(u.UnJson(str, nil)) + case "application/xml": + if jData, err := xml2json.Convert(strings.NewReader(str)); err == nil { + req.Callback(u.UnJsonBytes(jData.Bytes(), nil)) + } + req.Callback(str) + default: + req.Callback(str) + } + } + }() + } + return lastData, r.Response, nil + } else { + r = c.Do(req.Method, req.FinalUrl, req.FinalBody, headers...) + if r.Error != nil { + return nil, r.Response, r.Error + } + + respType := req.ResponseType + if respType == "" { + respType = strings.Split(r.Response.Header.Get("Content-Type"), ";")[0] + } + switch respType { + case "application/json": + return u.UnJsonBytes(r.Bytes(), nil), r.Response, nil + case "application/xml": + if jData, err := xml2json.Convert(strings.NewReader(string(r.Bytes()))); err == nil { + return u.UnJsonBytes(jData.Bytes(), nil), r.Response, nil + } + return string(r.Bytes()), r.Response, nil + case "application/octet-stream": + return r.Bytes(), r.Response, nil + default: + return r.String(), r.Response, nil + } + } +} diff --git a/ai_test.js b/ai_test.js new file mode 100644 index 0000000..d80b309 --- /dev/null +++ b/ai_test.js @@ -0,0 +1,97 @@ +import co from 'apigo.cc/gojs/console' +import u from 'apigo.cc/gojs/util' +import api from 'apigo.cc/gojs/api' + +// 注册腾讯云TC3签名器 +api.registerSigner("tc3", (req, cfg) => { + let action = cfg.string("action", "") + let service = cfg.string("service", "") + let timestamp = u.timestamp() + let contentType = "application/json; charset=utf-8" + let canonicalHeaders = "content-type:" + contentType + "\nhost:" + req.finalHost + "\nx-tc-action:" + action.toLowerCase() + "\n" + let signedHeaders = "content-type;host;x-tc-action" + let hashedRequestPayload = u.hex(u.sha256(req.finalBody)) + let canonicalRequest = req.method + "\n" + req.finalPath + "\n" + req.finalQuery + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedRequestPayload + let date = u.formatDate("2006-01-02", timestamp) + let credentialScope = date + "/" + service + "/tc3_request" + let string2sign = "TC3-HMAC-SHA256\n" + timestamp + "\n" + credentialScope + "\n" + u.hex(u.sha256(canonicalRequest)) + let secretDate = u.hmacSHA256("TC3" + cfg.string("secretKey", ""), date) + let secretService = u.hmacSHA256(secretDate, service) + let secretSigning = u.hmacSHA256(secretService, "tc3_request") + let signature = u.hex(u.hmacSHA256(secretSigning, string2sign)) + let authorization = "TC3-HMAC-SHA256 Credential=" + cfg.string("secretId", "") + "/" + credentialScope + ", SignedHeaders=" + signedHeaders + ", Signature=" + signature + req.setHeader("Content-Type", contentType) + req.setHeader("X-TC-Action", action) + req.setHeader("X-TC-Timestamp", u.string(timestamp)) + req.setHeader("X-TC-Version", cfg.string("version", "")) + req.setHeader("X-TC-Region", cfg.string("region", "")) + req.setHeader("Authorization", authorization) +}) + +// 腾讯云COS签名 +api.registerSigner("cos", (req, cfg) => { + let startTimestamp = u.timestamp() + let keyTime = startTimestamp + ";" + startTimestamp + cfg.int("expiredTime", 600) + let signKey = u.hex(u.hmacSHA1(cfg.string("secretKey", ""), keyTime)) + let [urlParamList, httpParameters] = api.sortParams(req.query) + let [headerList, httpHeaders] = api.sortParams(req.headers) + let hashedHttpString = u.hex(u.sha1(req.method.toLowerCase() + "\n" + req.finalPath + "\n" + httpParameters + "\n" + httpHeaders + "\n")) + let signature = u.hex(u.hmacSHA1(signKey, "sha1\n" + keyTime + "\n" + hashedHttpString + "\n")) + let authorization = "q-sign-algorithm=sha1&q-ak=" + cfg.string("secretId", "") + "&q-sign-time=" + keyTime + "&q-key-time=" + keyTime + "&q-header-list=" + headerList + "&q-url-param-list=" + urlParamList + "&q-signature=" + signature + req.setHeader("Authorization", authorization) +}) + +try { + // let r = api.tencent.smsPackagesStatistics({ data: { SmsSdkAppId: '1400624676', BeginTime: '2025010100', EndTime: '2045010100' } }) + // let r = api.tencent.getBucketList() + // let r = api.laoCos.get({ url: "/?max-keys=5" }) + // let r = api.laoCos.get({ url: "user/9VKQpY2RH7Ks/avatar.jpg", responseType: 'text/plain' }) + // let r = api.laoCos.put({ url: "test/aaa.txt", text: 'hello world' }) + // let r = api.laoCos.get({ url: "test/aaa.txt" }) + // let r = api.tencent.getCosToken({ config: { path: '/aaa/111' } }) + // co.info(r) + + // 测试llm接口 + // let r = api.zhipujwt.do({ url: "https://open.bigmodel.cn/api/paas/v4/chat/completions", data: { model: 'GLM-4.5-Flash', messages: [{ role: 'user', content: '你好' }], thinking: { type: 'disabled' } } }) + // test('智谱(jwt):简单对话(.do)', r.statusCode == 200 && !!r.data, r?.data?.choices[0]?.message?.content, r) + + // testChat('PPIO(openai)', api.openai) + // testChat('智谱', api.zhipu) + testChat('豆包', api.doubao) + + // let r = api.zhipu.image({ data: { prompt: '一张猫在沙发上喝咖啡' } }) + // test('智谱:图片生成', r.statusCode == 200 && !!r.data, r?.data?.data[0]?.url, r) + // let img = r?.data?.data[0]?.url + + // let img = 'https://aigc-files.bigmodel.cn/api/cogview/20250816103236c2331606d04c460a_0.png' + // let r = api.zhipu.chat({ data: { model: 'GLM-4V-Flash', messages: [{ role: 'user', content: [{ type: 'text', text: '看图说话,写一个简短生动的狗血爱情故事' }, { type: 'image_url', image_url: { url: img } }] }] } }) + // test('智谱:看图说话', r.statusCode == 200 && !!r.data, r?.data?.choices[0]?.message?.content, r) + + + // 测试文件扫描 + // let r = api.textin.scan({ binary: fs.readBytes('testRes/table.jpeg') }) + // test('Textin:表格识别', r.statusCode == 200 && !!r.data, r?.data, r) + + return true +} catch (ex) { + return ex.message +} + +function testChat(title, llm) { + let r = llm.chat({ data: { messages: [{ role: 'user', content: '你好' }] } }) + test(title + ':简单对话', r.statusCode == 200 && !!r.data, r?.data?.choices[0]?.message?.content, r) + + r = llm.chat({ callback: v => { co.println(co.yellow(v.choices[0].delta.content) + ' ') }, data: { messages: [{ role: 'user', content: '你好' }], stream: true } }) + test(title + ':流式对话', r.statusCode == 200 && !!r.data, r?.data?.usage?.total_tokens, r) + + // r = llm.embeddings({ data: { input: '今天天气真好' } }) + // test(title + ':向量化', r.statusCode == 200 && !!r.data, r?.data?.data[0]?.embedding?.length, r) +} + +function test(title, condition, successMessage, failedMessage) { + if (!condition) { + co.info(title, co.bRed('失败'), co.red(u.jsonP(failedMessage))) + throw new Error(title + '失败') + } + co.info(title, co.bGreen('通过'), co.yellow(u.json(successMessage))) +} diff --git a/api.sample.yml b/api.sample.yml new file mode 100644 index 0000000..8ab9d19 --- /dev/null +++ b/api.sample.yml @@ -0,0 +1,127 @@ +api: + openai: + url: https://api.ppinfra.com/v3/openai + key: xxxx + headers: + Authorization: Bearer {{.key}} + actions: + chat: + url: /chat/completions + data: + model: baidu/ernie-4.5-0.3b + zhipu: + url: https://open.bigmodel.cn/api/paas/v4 + key: xxxx + headers: + Authorization: Bearer {{.key}} + actions: + chat: + url: /chat/completions + data: + model: GLM-4.5-Flash + thinking: + type: disabled + embeddings: + url: /embeddings + data: + model: Embedding-3 + image: + url: /images/generations + data: + model: CogView-4 + # model: CogView-3-Flash + video: + url: /videos/generations + data: + model: CogVideoX-Flash + + zhipujwt: + signer: jwt + url: https://open.bigmodel.cn/api/paas/v4 + secret: xxxx + claims: + api_key: xxxx + jwtHeader: + sign_type: SIGN + + tencent: + signer: tc3 + region: ap-guangzhou + secretId: xxxx + secretKey: xxxx + url: https://{{.service}}.tencentcloudapi.com + actions: + smsPackagesStatistics: + service: sms + version: "2021-01-11" + action: SendStatusStatistics + data: + Limit: 10 + Offset: 0 + getBucketList: + service: cfs + version: "2019-07-19" + action: DescribeBucketList + data: + SrcSecretId: "{{.secretId}}" + SrcSecretKey: "{{.secretKey}}" + SrcService: COS + getObject: + service: cfs + version: "2019-07-19" + action: DescribeBucketList + region: ap-shanghai + data: + SrcSecretId: "{{.secretId}}" + SrcSecretKey: "{{.secretKey}}" + SrcService: COS + getCosToken: + service: sts + version: "2018-08-13" + action: GetFederationToken + config: + actions: '["cos:GetObject","cos:PutObject"]' + appId: 1257995425 + bucket: lao + path: "/*" + data: + Name: "" + DurationSeconds: 60 + Policy: | + { + "version": "2.0", + "statement": [ + { + "action": {{config.actions}}, + "effect": "allow", + "resource": ["qcs::cos:ap-shanghai:uid/{{config.appId}}:{{config.bucket}}-{{config.appId}}{{config.path}}"] + } + ] + } + laoCos: + signer: cos + url: https://lao-1257995425.cos.ap-shanghai.myqcloud.com + secretId: xxxx + secretKey: xxxx + textin: + url: https://api.textin.com/ai/service/v1 + appId: xxxx + secret: xxxx + headers: + x-ti-app-id: "{{.appId}}" + x-ti-secret-code: "{{.secret}}" + actions: + scan: + url: /pdf_to_markdown + requestType: binary + + doubao: + url: https://ark.cn-beijing.volces.com/api/v3 + key: xxxx + headers: + Authorization: Bearer {{.key}} + actions: + chat: + url: /chat/completions + data: + model: doubao-1-5-lite-32k-250115 diff --git a/cloudSigner.go b/cloudSigner.go new file mode 100644 index 0000000..00e3fc1 --- /dev/null +++ b/cloudSigner.go @@ -0,0 +1,140 @@ +package plugin + +// import ( +// "fmt" +// "strings" +// "time" + +// "github.com/ssgo/u" +// ) + +// // 腾讯云TC3签名 +// func makeTC3Sign(req *Request, cfg *SignerConfig) error { +// fmt.Println(u.JsonP(cfg), 111) +// action := cfg.String("action", "") +// service := cfg.String("service", "") +// version := cfg.String("version", "") +// region := cfg.String("region", "") +// timestamp := time.Now().Unix() +// if req.Url == "" { +// req.Url = "https://" + service + ".tencentcloudapi.com" +// req.MakeQuery() +// } +// algorithm := "TC3-HMAC-SHA256" + +// contentType := "application/json; charset=utf-8" +// canonicalHeaders := fmt.Sprintf("content-type:%s\nhost:%s\nx-tc-action:%s\n", +// contentType, req.FinalHost, strings.ToLower(action)) +// signedHeaders := "content-type;host;x-tc-action" +// hashedRequestPayload := u.Hex(u.Sha256(req.FinalBody)) +// canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", +// req.Method, +// req.FinalPath, +// req.FinalQuery, +// canonicalHeaders, +// signedHeaders, +// hashedRequestPayload) +// // fmt.Println(canonicalRequest) + +// date := time.Unix(timestamp, 0).UTC().Format("2006-01-02") +// credentialScope := fmt.Sprintf("%s/%s/tc3_request", date, service) +// hashedCanonicalRequest := u.Sha256String(canonicalRequest) +// string2sign := fmt.Sprintf("%s\n%d\n%s\n%s", +// algorithm, +// timestamp, +// credentialScope, +// hashedCanonicalRequest) +// // fmt.Println(string2sign) + +// // ************* 步骤 3:计算签名 ************* +// secretDate := u.HmacSha256([]byte("TC3"+cfg.String("secretKey", "")), []byte(date)) +// secretService := u.HmacSha256(secretDate, []byte(service)) +// secretSigning := u.HmacSha256(secretService, []byte("tc3_request")) +// signature := u.Hex(u.HmacSha256(secretSigning, []byte(string2sign))) +// // fmt.Println(signature) + +// // ************* 步骤 4:拼接 Authorization ************* +// authorization := fmt.Sprintf("%s Credential=%s/%s, SignedHeaders=%s, Signature=%s", +// algorithm, +// cfg.String("secretId", ""), +// credentialScope, +// signedHeaders, +// signature) +// // fmt.Println(u.BCyan(authorization)) +// // fmt.Println(u.BCyan(string(req.finalBody))) + +// req.Headers["Host"] = req.FinalHost +// req.Headers["Content-Type"] = contentType +// req.Headers["X-TC-Action"] = action +// req.Headers["X-TC-Timestamp"] = u.String(timestamp) +// req.Headers["X-TC-Version"] = version +// req.Headers["X-TC-Region"] = region +// req.Headers["Authorization"] = authorization + +// return nil +// } + +// // 腾讯云COS签名 +// func makeCOSSign(req *Request, cfg *SignerConfig) error { +// // 获取配置参数 +// secretId := cfg.String("secretId", "") +// secretKey := cfg.String("secretKey", "") +// token := cfg.String("token", "") // 可选,用于临时安全凭证 + +// // 计算KeyTime(签名有效时间范围) +// startTimestamp := time.Now().Unix() +// expiredTime := cfg.Int("expiredTime", 600) // 默认10分钟 +// endTimestamp := startTimestamp + expiredTime +// keyTime := fmt.Sprintf("%d;%d", startTimestamp, endTimestamp) + +// // 步骤1:生成SignKey +// signKey := u.Hex(u.HmacSha1([]byte(secretKey), []byte(keyTime))) + +// // 步骤2:生成HttpString +// // 处理HTTP方法 +// httpMethod := strings.ToLower(req.Method) + +// // 处理URI路径(需要URL解码?根据COS文档,可能需要原始路径) +// uriPathname := req.FinalPath + +// // 处理查询参数(HttpParameters) +// queryParams := req.Query +// urlParamList, httpParameters := SortParams(queryParams, nil, nil) + +// // 处理请求头(HttpHeaders) +// req.Headers["Host"] = req.FinalHost +// // 如果有安全令牌,添加到Header +// if token != "" { +// req.Headers["x-cos-security-token"] = token +// } +// headerList, httpHeaders := SortParams(req.Headers, nil, nil) +// // fmt.Println(u.BMagenta(httpHeaders)) + +// // 构建HttpString +// httpString := fmt.Sprintf("%s\n%s\n%s\n%s\n", +// httpMethod, +// uriPathname, +// httpParameters, +// httpHeaders) + +// // 步骤3:生成StringToSign +// hashedHttpString := u.Sha1String(httpString) +// stringToSign := fmt.Sprintf("sha1\n%s\n%s\n", keyTime, hashedHttpString) + +// // 步骤4:生成Signature +// signature := u.Hex(u.HmacSha1([]byte(signKey), []byte(stringToSign))) + +// // 步骤5:组装签名 +// authorization := fmt.Sprintf("q-sign-algorithm=sha1&q-ak=%s&q-sign-time=%s&q-key-time=%s&q-header-list=%s&q-url-param-list=%s&q-signature=%s", +// secretId, +// keyTime, +// keyTime, +// headerList, +// urlParamList, +// signature) + +// // 将签名添加到Authorization头 +// req.Headers["Authorization"] = authorization + +// return nil +// } diff --git a/commonSigner.go b/commonSigner.go new file mode 100644 index 0000000..c479900 --- /dev/null +++ b/commonSigner.go @@ -0,0 +1,217 @@ +package plugin + +import ( + "crypto/rsa" + "encoding/base64" + "errors" + "fmt" + "net/url" + "sort" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// 基本认证签名器 +func makeBasicAuthSign(req *Request, cfg *SignerConfig) error { + // 1. 获取用户名和密码 + username := cfg.String("username", "") + password := cfg.String("password", "") + + // 2. 构造认证字符串 + auth := username + ":" + password + encoded := base64.StdEncoding.EncodeToString([]byte(auth)) + + // 3. 添加认证头 + req.Headers["Authorization"] = "Basic " + encoded + + return nil +} + +// Bearer Token认证 +func makeBearerSign(req *Request, cfg *SignerConfig) error { + req.Headers["Authorization"] = "Bearer " + cfg.String("key", "") + return nil +} + +// 通用JWT签名器 +func makeJWTSign(req *Request, cfg *SignerConfig) error { + // 1. 获取JWT配置 + secret := cfg.String("secret", "") + algorithm := cfg.String("algorithm", "HS256") + expires := cfg.Int("expires", 3600) // 默认1小时有效期 + timeuint := cfg.String("timeuint", "s") // 时间戳单位 + + // 2. 创建Claims + var tm, exp int64 + if timeuint == "ms" { + tm = time.Now().UnixMilli() + exp = tm + expires*1000 + } else { + tm = time.Now().Unix() + exp = tm + expires + } + claims := jwt.MapClaims{ + "exp": exp, + "iat": tm, + } + + // 3. 添加自定义声明 + for k, v := range cfg.Map("claims") { + if v == "{{timestamp}}" { + claims[k] = tm + } else { + claims[k] = v + } + } + + // fmt.Println(u.BMagenta(u.JsonP(claims)), algorithm) + + // 4. 创建Token + var token *jwt.Token + switch algorithm { + case "HS256": + token = jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + case "HS384": + token = jwt.NewWithClaims(jwt.SigningMethodHS384, claims) + case "HS512": + token = jwt.NewWithClaims(jwt.SigningMethodHS512, claims) + case "RS256": + token = jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + default: + // 默认使用HS256 + token = jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + } + + token.Header["alg"] = algorithm + for k, v := range cfg.Map("jwtHeader") { + token.Header[k] = v + } + // fmt.Println(u.BYellow(u.JsonP(token.Header)), "===") + + // 5. 签名Token + var signedString string + var err error + + if strings.HasPrefix(algorithm, "HS") { + signedString, err = token.SignedString([]byte(secret)) + } else if strings.HasPrefix(algorithm, "RS") { + // 处理RSA私钥 + if privateKey := cfg.String("privateKey", ""); privateKey != "" { + var prikey *rsa.PrivateKey + prikey, err = jwt.ParseRSAPrivateKeyFromPEM([]byte(privateKey)) + if err == nil { + signedString, err = token.SignedString(prikey) + } + } else { + err = errors.New("privateKey is empty") + } + } + + if err != nil { + // 处理错误 + return err + } + + // 6. 添加认证头 + prefix := cfg.String("prefix", "Bearer ") + req.Headers["Authorization"] = prefix + signedString + + return nil +} + +// // 通用HMAC-SHA256签名器 +// func makeHmacSign(req *Request, cfg *SignerConfig) error { +// hashMethod := strings.ToUpper(cfg.String("hashMethod", "SHA256")) + +// // 1. 获取待签名数据 +// dataToSign := cfg.String("signData", "") +// if dataToSign == "" { +// // 默认使用HTTP方法+URL+当前时间戳 +// timestamp := time.Now().Format(time.RFC3339) +// dataToSign = req.Method + " " + req.Url + "\n" + timestamp +// cfg.Set("timestamp", timestamp) +// } + +// // 2. 计算签名 +// key := []byte(cfg.String("secret", "")) +// var h hash.Hash +// switch hashMethod { +// case "SHA1": +// h = hmac.New(sha1.New, key) +// case "SHA256": +// h = hmac.New(sha256.New, key) +// case "SHA384": +// h = hmac.New(sha512.New384, key) +// case "SHA512": +// h = hmac.New(sha512.New, key) +// default: +// h = hmac.New(sha256.New, key) +// } +// h.Write([]byte(dataToSign)) +// signature := base64.StdEncoding.EncodeToString(h.Sum(nil)) + +// // 3. 添加到请求 +// headerName := cfg.String("headerName", "X-Signature") +// req.Headers[headerName] = signature + +// // 4. 可选: 添加时间戳 +// timestamp := cfg.String("timestamp", "") +// if timestamp != "" { +// tsHeader := cfg.String("tsHeader", "X-Timestamp") +// req.Headers[tsHeader] = timestamp +// } + +// return nil +// } + +func SortParams(params map[string]string, allow *[]string, deny *[]string) (paramsList string, paramsPairs string) { + if len(params) == 0 { + return "", "" + } + + // 获取排序的key + keys := make([]string, 0) + for k := range params { + // 按白名单过滤 + if allow != nil { + isAllow := false + for _, a := range *allow { + if a == k || (strings.HasSuffix(a, "*") && strings.HasPrefix(k, a[:len(a)-1])) { + isAllow = true + } + } + if !isAllow { + continue + } + } + // 按黑名单过滤 + if deny != nil { + isDeny := false + for _, d := range *deny { + if d == k || (strings.HasSuffix(d, "*") && strings.HasPrefix(k, d[:len(d)-1])) { + isDeny = true + } + } + if isDeny { + continue + } + } + keys = append(keys, k) + } + sort.Strings(keys) + + // 构建urlParamList和httpParameters + paramList := make([]string, 0, len(keys)) + paramPairs := make([]string, 0, len(keys)) + + for _, k := range keys { + paramList = append(paramList, strings.ToLower(url.QueryEscape(k))) + paramPairs = append(paramPairs, fmt.Sprintf("%s=%s", + strings.ToLower(url.QueryEscape(k)), + url.QueryEscape(params[k]))) + } + + return strings.Join(paramList, ";"), strings.Join(paramPairs, "&") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..db12962 --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module apigo.cc/gojs/api + +go 1.24.0 + +require ( + apigo.cc/gojs v0.0.26 + apigo.cc/gojs/console v0.0.2 + apigo.cc/gojs/file v0.0.5 + apigo.cc/gojs/runtime v0.0.3 + apigo.cc/gojs/util v0.0.14 + github.com/basgys/goxml2json v1.1.0 + github.com/ssgo/config v1.7.10 + github.com/ssgo/httpclient v1.7.8 + github.com/ssgo/u v1.7.23 +) + +require ( + github.com/bitly/go-simplejson v0.5.1 // indirect + github.com/stretchr/testify v1.11.1 // indirect +) + +require ( + github.com/ZZMarquis/gm v1.3.2 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/emmansun/gmsm v0.32.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/pprof v0.0.0-20250903194437-c28834ac2320 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/obscuren/ecies v0.0.0-20150213224233-7c0f4a9b18d9 // indirect + github.com/ssgo/log v1.7.9 // indirect + github.com/ssgo/standard v1.7.7 // indirect + github.com/ssgo/tool v0.4.29 // indirect + golang.org/x/crypto v0.42.0 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/plugin.go b/plugin.go new file mode 100644 index 0000000..5d2ce29 --- /dev/null +++ b/plugin.go @@ -0,0 +1,309 @@ +package plugin + +import ( + "encoding/base64" + "reflect" + "regexp" + "strings" + + "apigo.cc/gojs" + "apigo.cc/gojs/goja" + "github.com/ssgo/config" + "github.com/ssgo/u" +) + +const pluginName = "api" + +var confAes *u.Aes = u.NewAes([]byte("?GQ$0K0GgLdO=f+~L68PLm$uhKr4'=tV"), []byte("VFs7@sK61cj^f?HZ")) + +type Result struct { + StatusCode int + Status string + Headers map[string]string + Data any +} + +func init() { + config.LoadConfig(pluginName, &signerConfig) + Config(signerConfig) + + obj := map[string]any{ + "config": Config, + "registerSigner": RegisterSigner, + "sortParams": SortParams, + } + + for signer := range signerConfig { + cfg := signerConfig[signer] + o := map[string]any{} + o["do"] = MakeAction(signer, nil) + o["get"] = MakeAction(signer, map[string]any{ + "method": "GET", + }) + o["post"] = MakeAction(signer, map[string]any{ + "method": "POST", + }) + o["put"] = MakeAction(signer, map[string]any{ + "method": "PUT", + }) + o["delete"] = MakeAction(signer, map[string]any{ + "method": "DELETE", + }) + o["head"] = MakeAction(signer, map[string]any{ + "method": "HEAD", + }) + o["options"] = MakeAction(signer, map[string]any{ + "method": "OPTIONS", + }) + + actions := map[string]map[string]any{} + u.Convert((*cfg)["actions"], &actions) + for k, v := range actions { + o[k] = MakeAction(signer, v) + } + obj[signer] = o + } + + tsCode := gojs.MakeTSCode(obj) + mappedObj := gojs.ToMap(obj) + gojs.Register("apigo.cc/gojs/"+pluginName, gojs.Module{ + ObjectMaker: func(vm *goja.Runtime) gojs.Map { + return mappedObj + }, + TsCode: tsCode, + Desc: pluginName, + }) +} + +func MakeAction(signer string, actionCfg map[string]any) func(req *Request) (*Result, error) { + cfg := &SignerConfig{data: map[string]any{}} + if c := signerConfig[signer]; c != nil { + u.Convert(c, cfg.data) + delete(cfg.data, "actions") + } + defaultUrl := cfg.String("url", "") + + reqSet := &Request{} + u.Convert(actionCfg, reqSet) + + delete(actionCfg, "headers") + delete(actionCfg, "query") + delete(actionCfg, "data") + delete(actionCfg, "form") + delete(actionCfg, "file") + delete(actionCfg, "text") + delete(actionCfg, "binary") + delete(actionCfg, "requestType") + delete(actionCfg, "callback") + delete(actionCfg, "timeout") + u.Convert(actionCfg, cfg.data) + + makeConfigVar(cfg.data, reflect.ValueOf(cfg.data)) + // 没有在外层配置url,或者url中有待处理的变量 + if defaultUrl == "" || strings.Contains(defaultUrl, "{{.") { + defaultUrl = cfg.String("url", "") + } + + return func(req *Request) (*Result, error) { + if req == nil { + req = &Request{} + } + var req1 *Request + if reqSet != nil { + req1v := *reqSet + req1 = &req1v + if req.Url != "" { + req1.Url = req.Url + } + if req.Method != "" { + req1.Method = req.Method + } + if req.Config != nil { + if req1.Config == nil { + req1.Config = map[string]string{} + } + for k, v := range req.Config { + req1.Config[k] = v + } + } + if req.Headers != nil { + if req1.Headers == nil { + req1.Headers = map[string]string{} + } + for k, v := range req.Headers { + req1.Headers[k] = v + } + } + if req.Query != nil { + if req1.Query == nil { + req1.Query = map[string]string{} + } + for k, v := range req.Query { + req1.Query[k] = v + } + } + if req.Data != nil { + if req1.Data == nil { + req1.Data = map[string]any{} + } + for k, v := range req.Data { + req1.Data[k] = v + } + } + if req.Form != nil { + if req1.Form == nil { + req1.Form = map[string]string{} + } + for k, v := range req.Form { + req1.Form[k] = v + } + } + if req.File != nil { + if req1.File == nil { + req1.File = map[string]any{} + } + for k, v := range req.File { + req1.File[k] = v + } + } + if req.Text != "" { + req1.Text = req.Text + } + if req.Binary != nil { + req1.Binary = req.Binary + } + if req.RequestType != "" { + req1.RequestType = req.RequestType + } + if req.ResponseType != "" { + req1.ResponseType = req.ResponseType + } + if req.Callback != nil { + req1.Callback = req.Callback + } + if req.Timeout == 0 { + req1.Timeout = req.Timeout + } + } else { + req1 = req + } + if !strings.Contains(req1.Url, "://") && defaultUrl != "" { + has1 := strings.HasSuffix(defaultUrl, "/") + has2 := strings.HasPrefix(req1.Url, "/") + if !has1 && !has2 { + req1.Url = defaultUrl + "/" + req1.Url + } else if has1 && has2 { + req1.Url = defaultUrl + req1.Url[1:] + } else { + req1.Url = defaultUrl + req1.Url + } + } + + data, resp, err := Do(cfg, req1) + headers := map[string]string{} + statusCode := 0 + status := "" + if resp != nil { + statusCode = resp.StatusCode + status = resp.Status + for k, v := range resp.Header { + headers[k] = v[0] + } + } + return &Result{ + Status: status, + StatusCode: statusCode, + Headers: headers, + Data: data, + }, err + } +} + +func Config(cfg map[string]*map[string]any) { + for k, v := range cfg { + if u.String((*v)["signer"]) == "" { + (*v)["signer"] = k + } + // 尝试解密配置 + decryptConfig(reflect.ValueOf(v)) + makeConfigVar((*v), reflect.ValueOf(v)) + + confLock.Lock() + signerConfig[k] = v + confLock.Unlock() + } +} + +func decryptConfig(v reflect.Value) { + v = u.FinalValue(v) + if v.Kind() == reflect.Map { + for _, k := range v.MapKeys() { + v2 := u.FinalValue(v.MapIndex(k)) + if v2.Kind() == reflect.String { + if b64, err := base64.URLEncoding.DecodeString(v2.String()); err == nil { + if dec, err := confAes.DecryptBytes(b64); err == nil { + v.SetMapIndex(k, reflect.ValueOf(string(dec))) + } + } + } else if v2.Kind() == reflect.Map || v.Kind() == reflect.Slice { + decryptConfig(v2) + } + } + } else if v.Kind() == reflect.Slice { + for i := 0; i < v.Len(); i++ { + v2 := u.FinalValue(v.Index(i)) + if v2.Kind() == reflect.String { + if b64, err := base64.URLEncoding.DecodeString(v2.String()); err == nil { + if dec, err := confAes.DecryptBytes(b64); err == nil { + v.Index(i).Set(reflect.ValueOf(string(dec))) + } + } + } else if v2.Kind() == reflect.Map || v.Kind() == reflect.Slice { + decryptConfig(v2) + } + } + } +} + +var varMatcher = regexp.MustCompile(`{{\.([\w\.\-]+)}}`) +var configMatcher = regexp.MustCompile(`{{config\.([\w\.\-]+)}}`) +var fnMatcher = regexp.MustCompile(`{{/(\w+)\s*(.*?)}}`) + +func makeConfigVar(cfg map[string]any, v reflect.Value) { + v = u.FinalValue(v) + if v.Kind() == reflect.Map { + for _, k := range v.MapKeys() { + v2 := u.FinalValue(v.MapIndex(k)) + if v2.Kind() == reflect.String { + str := v2.String() + if m := varMatcher.FindAllStringSubmatch(str, 100); m != nil { + for _, m1 := range m { + if v1 := getConfigValue(cfg, m1[1]); v1 != "" { + str = strings.ReplaceAll(str, m1[0], v1) + } + } + v.SetMapIndex(k, reflect.ValueOf(str)) + } + } else if v2.Kind() == reflect.Map || v.Kind() == reflect.Slice { + makeConfigVar(cfg, v2) + } + } + } else if v.Kind() == reflect.Slice { + for i := 0; i < v.Len(); i++ { + v2 := u.FinalValue(v.Index(i)) + if v2.Kind() == reflect.String { + str := v2.String() + if m := varMatcher.FindAllStringSubmatch(str, 100); m != nil { + for _, m1 := range m { + if v1 := getConfigValue(cfg, m1[1]); v1 != "" { + str = strings.ReplaceAll(str, m1[0], v1) + } + } + v.Index(i).Set(reflect.ValueOf(str)) + } + } else if v2.Kind() == reflect.Map || v.Kind() == reflect.Slice { + makeConfigVar(cfg, v2) + } + } + } +} diff --git a/plugin_test.go b/plugin_test.go new file mode 100644 index 0000000..2988946 --- /dev/null +++ b/plugin_test.go @@ -0,0 +1,30 @@ +package plugin_test + +import ( + "fmt" + "strings" + "testing" + + "apigo.cc/gojs" + _ "apigo.cc/gojs/console" + _ "apigo.cc/gojs/file" + _ "apigo.cc/gojs/runtime" + _ "apigo.cc/gojs/util" + "github.com/ssgo/u" +) + +func TestPlugin(t *testing.T) { + gojs.ExportForDev() + for _, f := range u.ReadDirN(".") { + if strings.HasSuffix(f.Name, "_test.js") { + r, err := gojs.RunFile(f.Name) + if err != nil { + t.Fatal(u.Red(f.Name), u.BRed(err.Error())) + } else if r != true { + t.Fatal(u.Red(f.Name), u.BRed(u.JsonP(r))) + } else { + fmt.Println(u.Green(f.Name), u.BGreen("test succeess")) + } + } + } +} diff --git a/testRes/table.jpeg b/testRes/table.jpeg new file mode 100644 index 0000000..b2dd900 Binary files /dev/null and b/testRes/table.jpeg differ diff --git a/testRes/table.xls b/testRes/table.xls new file mode 100644 index 0000000..a91447b Binary files /dev/null and b/testRes/table.xls differ