providers: a custom provider's icon can come from its website (#12) — From the website, beside Choose a picture, finds the site behind the base URL (its host, then the domains it sits under: api.deepseek.com, then deepseek.com) and keeps the icon its home page names (an SVG first, then the apple-touch-icon, data: URIs too) or its /favicon.ico, like a picture picked by hand; only public https hosts are reached. Icons are fetched as magpie, since deepseek.com turns Go's own User-Agent away

This commit is contained in:
yetone committed 2026-09-27 18:52:49 +08:00
1 parent ab7dd01506
commit ab76cce91b
6 files changed
+341 -1

No files matched your search

+20 -1
View File
@@ -2040,9 +2040,28 @@ function iconPicker(ed) {
};
const choose = el("button", "text", t("Choose a picture…"));
choose.onclick = () => file.click();
// the site's own icon, looked for from the base URL typed above
const site = el("button", "text", t("From the website"));
site.title = t("Look for the icon of the site the base URL is on");
site.onclick = async () => {
const base = draft[apiField[draft.api]] || draft.chat || draft.responses || draft.anthropic || "";
site.disabled = true;
site.textContent = t("Looking…");
try {
const data = await api("icons/favicon", { url: base });
draft.icon = data.icon;
editorError("");
draw();
syncHead();
} catch (e) {
editorError(e.message);
site.disabled = false;
site.textContent = t("From the website");
}
};
const builtin = el("button", "text", t("Built-in icons"));
builtin.onclick = () => { open = !open; draw(); };
box.append(file, choose, builtin);
box.append(file, choose, site, builtin);
if (draft.icon && draft.icon !== "generic") {
const reset = el("button", "text", t("Default"));
reset.onclick = () => { draft.icon = ""; draw(); syncHead(); };
+3
View File
@@ -135,6 +135,9 @@ const I18N = {
"Icon": "图标",
"Choose a picture…": "选择图片…",
"Built-in icons": "内置图标",
"From the website": "从网站获取",
"Look for the icon of the site the base URL is on": "按上面填的 Base URL 找到对应网站的图标",
"Looking…": "查找中…",
"what {agent} ships with": "{agent} 自带的默认值",
"current value": "当前值",
"use as typed": "按输入使用",
+14
View File
@@ -353,6 +353,20 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
}
writeJSON(rw, map[string]string{"icon": icon})
})
// the site's own icon, found from the provider's base URL (#12)
mux.HandleFunc("POST /api/icons/favicon", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ URL string }
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil {
fail(rw, err)
return
}
icon, err := provider.FaviconFor(r.Context(), in.URL)
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, map[string]string{"icon": icon})
})
mux.HandleFunc("GET /api/icons/{name}", func(rw http.ResponseWriter, r *http.Request) {
f := provider.IconFile(r.PathValue("name"))
if f == "" {
+184
View File
@@ -0,0 +1,184 @@
package provider
import (
"context"
"encoding/base64"
"errors"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
// FaviconFor finds the site behind a provider's base URL and keeps its icon
// like a picture picked by hand (#12). The API's own host is asked first,
// then the domains it sits under (api.deepseek.com, then deepseek.com),
// since an API host seldom has a page of its own. On each, the home page's
// <link rel="icon"> pictures come before /favicon.ico. Only public https
// hosts are reached, as for an import link's icon.
func FaviconFor(ctx context.Context, base string) (string, error) {
sites, err := faviconSites(base)
if err != nil {
return "", err
}
return favicon(ctx, guardClient(), sites, iconURL)
}
// faviconSites are the https origins to look on for base's icon: its host
// with its port, then each domain above it down to two labels.
func faviconSites(base string) ([]string, error) {
u, err := url.Parse(strings.TrimSpace(base))
if err != nil || u.Hostname() == "" {
return nil, errors.New("type the provider's base URL first: its site's icon is looked for there")
}
host := u.Hostname()
if local(host) {
return nil, errors.New("the base URL is on this computer or the local network; its icon can't be fetched")
}
sites := []string{"https://" + u.Host}
if host != u.Host {
sites = append(sites, "https://"+host)
}
if strings.Trim(host, "0123456789.") != "" && !strings.Contains(host, ":") { // not an IP
labels := strings.Split(host, ".")
for i := 1; len(labels)-i >= 2; i++ {
sites = append(sites, "https://"+strings.Join(labels[i:], "."))
}
}
return sites, nil
}
// favicon tries each site in turn; check vets a picture's URL before it is
// fetched (iconURL; tests pass one that lets a local server through).
func favicon(ctx context.Context, c *http.Client, sites []string, check func(string) (string, error)) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
var last error
for _, site := range sites {
cands := append(pageIcons(ctx, c, site), site+"/favicon.ico")
for _, cand := range cands {
if data, ok := strings.CutPrefix(cand, "data:"); ok {
if b := dataURI(data); b != nil {
if icon, err := StoreIcon(b); err == nil {
return icon, nil
}
}
continue
}
u, err := check(cand)
if err != nil {
last = err
continue
}
icon, err := fetchIcon(ctx, c, u)
if err == nil {
return icon, nil
}
last = err
}
if ctx.Err() != nil {
break
}
}
if last == nil {
last = errors.New("no icon found")
}
return "", errorf("couldn't find the site's icon (%s): %v", strings.Join(sites, ", "), last)
}
// iconUA names magpie to the sites icons are fetched from: some (deepseek.com)
// turn Go's own User-Agent away with a 429.
const iconUA = "magpie"
var (
linkTag = regexp.MustCompile(`(?is)<link\b[^>]*>`)
tagAttr = regexp.MustCompile(`(?is)\b(rel|href|sizes|type)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))`)
headDone = regexp.MustCompile(`(?i)</head>|<body\b`)
)
// pageIcons are the pictures a site's home page names as its icon, the
// likeliest to look good first: an SVG, then the larger apple-touch-icon,
// then the rest in page order.
func pageIcons(ctx context.Context, c *http.Client, site string) []string {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, site+"/", nil)
if err != nil {
return nil
}
req.Header.Set("Accept", "text/html")
req.Header.Set("User-Agent", iconUA)
res, err := c.Do(req)
if err != nil {
return nil
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil
}
b, _ := io.ReadAll(io.LimitReader(res.Body, 512<<10))
html := string(b)
if i := headDone.FindStringIndex(html); i != nil {
html = html[:i[0]]
}
var svg, touch, rest []string
for _, tag := range linkTag.FindAllString(html, -1) {
attrs := map[string]string{}
for _, m := range tagAttr.FindAllStringSubmatch(tag, -1) {
attrs[strings.ToLower(m[1])] = m[2] + m[3] + m[4]
}
rel := strings.Fields(strings.ToLower(attrs["rel"]))
href := strings.TrimSpace(attrs["href"])
if href == "" {
continue
}
isIcon, isTouch := false, false
for _, r := range rel {
switch r {
case "icon":
isIcon = true
case "apple-touch-icon", "apple-touch-icon-precomposed":
isTouch = true
}
}
if !isIcon && !isTouch {
continue
}
if !strings.HasPrefix(href, "data:") {
ref, err := url.Parse(href)
if err != nil {
continue
}
href = res.Request.URL.ResolveReference(ref).String()
}
switch {
case attrs["type"] == "image/svg+xml" || strings.HasSuffix(strings.ToLower(strings.SplitN(href, "?", 2)[0]), ".svg"):
svg = append(svg, href)
case isTouch:
touch = append(touch, href)
default:
rest = append(rest, href)
}
}
return append(append(svg, touch...), rest...)
}
// dataURI decodes what follows "data:" in a data URI, nil when it isn't one.
func dataURI(s string) []byte {
meta, data, ok := strings.Cut(s, ",")
if !ok {
return nil
}
if strings.HasSuffix(strings.ToLower(meta), ";base64") {
b, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return nil
}
return b
}
d, err := url.PathUnescape(data)
if err != nil {
return nil
}
return []byte(d)
}
+119
View File
@@ -0,0 +1,119 @@
package provider
import (
"context"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func TestFaviconSites(t *testing.T) {
for base, want := range map[string][]string{
"https://api.deepseek.com/v1": {"https://api.deepseek.com", "https://deepseek.com"},
"https://open.bigmodel.cn/api/paas/v4": {"https://open.bigmodel.cn", "https://bigmodel.cn"},
"https://example.com:8443/v1": {"https://example.com:8443", "https://example.com"},
"https://openrouter.ai/api/v1": {"https://openrouter.ai"},
} {
if got, err := faviconSites(base); err != nil || !reflect.DeepEqual(got, want) {
t.Errorf("%s: %v %v, want %v", base, got, err, want)
}
}
for _, base := range []string{"", "http://127.0.0.1:11434/v1", "http://localhost:1234", "http://192.168.1.2/v1"} {
if _, err := faviconSites(base); err == nil {
t.Errorf("%q: looked for an icon", base)
}
}
// the guard itself refuses a loopback site
if _, err := FaviconFor(context.Background(), "http://localhost:3425/v1"); err == nil {
t.Error("a local base URL's icon was fetched")
}
}
func TestFavicon(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
png := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)
pages := map[string]string{}
mux := http.NewServeMux()
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
if p, ok := pages[r.Host]; ok && r.URL.Path == "/" {
rw.Write([]byte(p))
return
}
switch r.URL.Path {
case "/static/touch.png", "/favicon.ico":
if strings.HasPrefix(r.Host, "bare.") || r.URL.Path == "/static/touch.png" {
rw.Write(png)
return
}
case "/logo.svg":
rw.Write(svg)
return
}
// a site that answers every path with its page, the icon's too
rw.Write([]byte("<html><body>not found</body></html>"))
})
srv := httptest.NewServer(mux)
defer srv.Close()
port := srv.URL[strings.LastIndex(srv.URL, ":"):]
// every site is the test server, told apart by the Host it is asked as
c := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, srv.Listener.Addr().String())
}}}
site := func(h string) string { return "http://" + h + port }
pass := func(s string) (string, error) { return s, nil }
stored := func(icon string, want []byte) {
t.Helper()
b, err := os.ReadFile(IconFile(strings.TrimPrefix(icon, iconPrefix)))
if err != nil || string(b) != string(want) {
t.Errorf("stored %q: %q %v, want %q", icon, b, err, want)
}
}
// an SVG link is taken before the touch icon, whatever the order
pages["svg.test"+port] = `<head><link rel="apple-touch-icon" href="/static/touch.png"><link rel="icon" type="image/svg+xml" href="/logo.svg"></head>`
icon, err := favicon(context.Background(), c, []string{site("svg.test")}, pass)
if err != nil {
t.Fatal(err)
}
stored(icon, svg)
// a relative apple-touch-icon, in single quotes and odd case
pages["touch.test"+port] = `<HEAD><LINK REL='apple-touch-icon' HREF='static/touch.png'></HEAD>`
if icon, err = favicon(context.Background(), c, []string{site("touch.test")}, pass); err != nil {
t.Fatal(err)
}
stored(icon, png)
// a data: URI icon (#12)
pages["data.test"+port] = `<head><link rel="shortcut icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%2F%3E"></head>`
if icon, err = favicon(context.Background(), c, []string{site("data.test")}, pass); err != nil {
t.Fatal(err)
}
stored(icon, svg)
// the API host has nothing; the domain above it has /favicon.ico
if icon, err = favicon(context.Background(), c, []string{site("api.none"), site("bare.test")}, pass); err != nil {
t.Fatal(err)
}
stored(icon, png)
// a page answered for the icon is not a picture: nothing is found
if _, err = favicon(context.Background(), c, []string{site("api.none")}, pass); err == nil {
t.Error("an HTML page was kept as the icon")
}
// a link in the body is not the page's icon
pages["body.test"+port] = `<head></head><body><link rel="icon" href="/logo.svg"></body>`
if _, err = favicon(context.Background(), c, []string{site("body.test")}, pass); err == nil {
t.Error("a link in the body was taken")
}
}
+1
View File
@@ -173,6 +173,7 @@ func fetchIcon(ctx context.Context, c *http.Client, u string) (string, error) {
return "", err
}
req.Header.Set("Accept", "image/*")
req.Header.Set("User-Agent", iconUA)
res, err := c.Do(req)
if err != nil {
return "", errorf("could not fetch the icon: %v", err)