fix(history): give history entries real site favicons
历史条目的图标原先写死 fas fa-globe,整栏都是地球图标。 - 新增 history-server/favicon.go:后台抓取每个 host 的 favicon 并缓存到 data/item-icons/,命中后 conf.yml 写本地文件名,与手工图标同一套解析。 抓取顺序 <host>/favicon.ico → 首页 <link rel=icon>;3 个 worker, 失败 6 小时后再试;完成后合并触发一次 conf.yml 重建。 - 只缓存 Dashy 能渲染的格式:其 isImage() 白名单是 [svg,png,jpg], 写 .ico 进去完全渲染不出来。因此 .ico 先抽内嵌 PNG,若是传统 BMP/DIB 编码则解码后转 PNG(Google 这类站点只给老式 .ico)。 - 内网地址用 host:port 区分(不同端口的服务不再共用一个图标),且只直连; 外网站点直连失败时自动复用 SearXNG outgoing.proxies 的代理, 也可用 NAVI_FAVICON_PROXY 覆盖。 - 修掉一个真实缺陷:导航项都没有访问数据时,原来会提前 return 并把 「历史访问」清成只剩安装入口 —— 只浏览站外站点时真实历史会整个消失。 现在只冻结「常用服务」,历史照常生成。 - Dockerfile 去掉 # syntax=docker/dockerfile:1:该指令会让 BuildKit 去 pull docker/dockerfile 前端镜像,无外网环境直接构建失败;本文件未用 BuildKit 语法。 实测:6/6 站点(含 google.com 的 DIB 型 ico、github.com、docker.com、 news.ycombinator.com 与两个内网服务)均得到真实图标,页面 naturalWidth>0。
This commit is contained in:
@ -1,4 +1,6 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# 不用 `# syntax=docker/dockerfile:1`:该指令会让 BuildKit 去 pull
|
||||
# docker/dockerfile 前端镜像,在无外网/仅代理的环境里会直接构建失败。
|
||||
# 本文件未使用任何 BuildKit 专有语法,去掉后走内置前端即可。
|
||||
|
||||
# ── Stage 1: Go binaries ──
|
||||
FROM golang:1.21-bookworm AS gobuild
|
||||
|
||||
14
README.md
14
README.md
@ -182,6 +182,20 @@ icon: fas fa-star # Font Awesome
|
||||
- 每 30 秒检查本地队列,**仅在有新数据时 POST**
|
||||
- 搜索引擎 URL 在本地过滤,不上传
|
||||
- 「常用服务」和「历史访问」按总访问频次自动排序
|
||||
- 历史条目的图标由服务端自动抓取并缓存(见下)
|
||||
|
||||
### 历史条目的站点图标
|
||||
|
||||
history 里的站点不可能像手工导航项那样预先配好图标,所以服务端会**后台抓取每个
|
||||
host 的 favicon** 并缓存到 `data/item-icons/favicon-<host>.png`,缓存命中后
|
||||
conf.yml 里写的是本地文件名,与手工图标走同一套解析。
|
||||
|
||||
- 抓取顺序:`<host>/favicon.ico` → 首页 `<link rel="icon">`
|
||||
- `.ico` 会先尝试抽出内嵌 PNG;若是传统 BMP/DIB 编码,则服务端解码并转成 PNG
|
||||
(Dashy 的 `isImage()` 只认 `svg/png/jpg`,直接写 `.ico` 是渲染不出来的)
|
||||
- 内网地址(含端口,如 `192.168.1.10:3000`)只走直连;外网站点先直连,
|
||||
失败时自动复用 SearXNG `outgoing.proxies` 的代理(也可用 `NAVI_FAVICON_PROXY` 单独指定)
|
||||
- 抓不到就保持 `fas fa-globe`,6 小时后再试;缓存文件持久化在 data 卷
|
||||
|
||||
## 管理后台
|
||||
|
||||
|
||||
658
history-server/favicon.go
Normal file
658
history-server/favicon.go
Normal file
@ -0,0 +1,658 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ── 历史条目的站点图标 ──
|
||||
//
|
||||
// history 里的站点不可能像手工导航项那样预先配好图标,若统一写死 fa-globe
|
||||
// 会很难看。这里在后台抓取每个 host 的 favicon,缓存到 data 卷的 item-icons/;
|
||||
// 缓存命中后 conf.yml 里写的是本地文件名,与手工图标走同一套解析。
|
||||
//
|
||||
// 关键约束:Dashy 的 isImage() 只把 svg/png/jpg 当图片渲染
|
||||
// (源码里的白名单就是 ["svg","png","jpg"]),其它扩展名写进 conf.yml
|
||||
// 会渲染不出任何东西 —— 所以只缓存这三种;.ico 先尝试抽出内嵌 PNG。
|
||||
//
|
||||
// 抓取顺序:<host>/favicon.ico → 首页 <link rel="icon"> 指向的图标。
|
||||
// 每个 host 先试直连;仅当目标不是内网地址时才走代理。
|
||||
const (
|
||||
faviconMaxBytes = 512 << 10
|
||||
faviconTimeout = 6 * time.Second
|
||||
faviconRetryGap = 6 * time.Hour
|
||||
faviconUA = "Mozilla/5.0 (compatible; HomeDashFavicon/1.0)"
|
||||
)
|
||||
|
||||
// dashyIconExts 是 Dashy 能渲染的图标扩展名(顺序即查找优先级)。
|
||||
var dashyIconExts = []string{".png", ".svg", ".jpg"}
|
||||
|
||||
var faviconProxy string
|
||||
|
||||
var (
|
||||
faviconClients []*http.Client
|
||||
faviconMu sync.Mutex
|
||||
faviconQueued = map[string]bool{}
|
||||
faviconAttempted = map[string]time.Time{}
|
||||
faviconQueue = make(chan string, 128)
|
||||
faviconSignal = make(chan struct{}, 1)
|
||||
rebuildMu sync.Mutex
|
||||
)
|
||||
|
||||
func faviconDir() string { return filepath.Join(dataDir, "item-icons") }
|
||||
|
||||
func newFaviconClient(proxy string) *http.Client {
|
||||
tr := &http.Transport{
|
||||
MaxIdleConns: 8,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
DisableKeepAlives: true,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
}
|
||||
if proxy != "" {
|
||||
if pu, err := url.Parse(proxy); err == nil {
|
||||
tr.Proxy = http.ProxyURL(pu)
|
||||
}
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: faviconTimeout,
|
||||
Transport: tr,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 4 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func initFavicon() {
|
||||
faviconProxy = detectFaviconProxy()
|
||||
faviconClients = []*http.Client{newFaviconClient("")}
|
||||
if faviconProxy != "" {
|
||||
faviconClients = append(faviconClients, newFaviconClient(faviconProxy))
|
||||
log.Printf("favicon: proxy fallback enabled")
|
||||
}
|
||||
os.MkdirAll(faviconDir(), 0755)
|
||||
for i := 0; i < 3; i++ {
|
||||
go faviconWorker()
|
||||
}
|
||||
go faviconRebuildLoop()
|
||||
}
|
||||
|
||||
// detectFaviconProxy 优先用 NAVI_FAVICON_PROXY;未设置时直接复用
|
||||
// SearXNG 的 outgoing.proxies —— 需要代理才能出网的环境里,
|
||||
// 不必为了图标再多配一个变量(也就不用重建容器)。
|
||||
func detectFaviconProxy() string {
|
||||
if v := strings.TrimSpace(os.Getenv("NAVI_FAVICON_PROXY")); v != "" {
|
||||
return v
|
||||
}
|
||||
paths := []string{os.Getenv("NAVI_SEARXNG_SETTINGS"), "/etc/searxng/settings.yml"}
|
||||
for _, p := range paths {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var cfg struct {
|
||||
Outgoing struct {
|
||||
Proxies map[string]string `yaml:"proxies"`
|
||||
} `yaml:"outgoing"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
continue
|
||||
}
|
||||
if v := strings.TrimSpace(cfg.Outgoing.Proxies["all://"]); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// clientsFor 返回该 host 可用的客户端。内网/回环地址只直连,
|
||||
// 避免每个内网服务都白等一次代理超时。
|
||||
func clientsFor(hostport string) []*http.Client {
|
||||
host := hostport
|
||||
if h, _, err := net.SplitHostPort(hostport); err == nil {
|
||||
host = h
|
||||
}
|
||||
host = strings.Trim(host, "[]")
|
||||
if ip := net.ParseIP(host); ip != nil && (ip.IsPrivate() || ip.IsLoopback()) {
|
||||
return faviconClients[:1]
|
||||
}
|
||||
if !strings.Contains(host, ".") {
|
||||
return faviconClients[:1]
|
||||
}
|
||||
return faviconClients
|
||||
}
|
||||
|
||||
// sanitizeHostKey 把 host:port 变成安全文件名片段。
|
||||
func sanitizeHostKey(hostport string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(hostport) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '-':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// cachedFavicon 返回已缓存的图标文件名(相对 item-icons/),未命中返回空串。
|
||||
// 只认 Dashy 能渲染的格式,历史遗留的 .ico 缓存会被忽略并重新抓取。
|
||||
func cachedFavicon(hostport string) string {
|
||||
if hostport == "" {
|
||||
return ""
|
||||
}
|
||||
key := sanitizeHostKey(hostport)
|
||||
for _, ext := range dashyIconExts {
|
||||
name := "favicon-" + key + ext
|
||||
if st, err := os.Stat(filepath.Join(faviconDir(), name)); err == nil && st.Size() > 0 {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// queueFavicon 把 host 排进后台抓取队列(幂等、非阻塞)。
|
||||
func queueFavicon(hostport string) {
|
||||
if hostport == "" || cachedFavicon(hostport) != "" {
|
||||
return
|
||||
}
|
||||
faviconMu.Lock()
|
||||
if faviconQueued[hostport] {
|
||||
faviconMu.Unlock()
|
||||
return
|
||||
}
|
||||
if t, ok := faviconAttempted[hostport]; ok && time.Since(t) < faviconRetryGap {
|
||||
faviconMu.Unlock()
|
||||
return
|
||||
}
|
||||
faviconQueued[hostport] = true
|
||||
faviconMu.Unlock()
|
||||
|
||||
select {
|
||||
case faviconQueue <- hostport:
|
||||
default:
|
||||
faviconMu.Lock()
|
||||
delete(faviconQueued, hostport)
|
||||
faviconMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func faviconWorker() {
|
||||
for hostport := range faviconQueue {
|
||||
err := fetchFavicon(hostport)
|
||||
faviconMu.Lock()
|
||||
delete(faviconQueued, hostport)
|
||||
faviconAttempted[hostport] = time.Now()
|
||||
faviconMu.Unlock()
|
||||
if err != nil {
|
||||
log.Printf("favicon: %s: %v", hostport, err)
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case faviconSignal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// faviconRebuildLoop 把短时间内完成的多个抓取合并成一次 conf.yml 重建。
|
||||
func faviconRebuildLoop() {
|
||||
for range faviconSignal {
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case <-faviconSignal:
|
||||
default:
|
||||
break drain
|
||||
}
|
||||
}
|
||||
rebuildDashySections()
|
||||
}
|
||||
}
|
||||
|
||||
func httpGet(rawURL string, max int, clients []*http.Client) ([]byte, string, error) {
|
||||
var lastErr error
|
||||
for _, c := range clients {
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", faviconUA)
|
||||
req.Header.Set("Accept", "*/*")
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("http %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, int64(max)+1))
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if len(data) > max {
|
||||
lastErr = errors.New("body too large")
|
||||
continue
|
||||
}
|
||||
return data, ct, nil
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("no http client")
|
||||
}
|
||||
return nil, "", lastErr
|
||||
}
|
||||
|
||||
// httpGetTruncated 用于抓 HTML:只需要前若干字节里的 <head>,
|
||||
// 页面比上限大不应该算失败(GitHub 首页就超 512KB)。
|
||||
func httpGetTruncated(rawURL string, max int, clients []*http.Client) ([]byte, string, error) {
|
||||
var lastErr error
|
||||
for _, c := range clients {
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", faviconUA)
|
||||
req.Header.Set("Accept", "text/html,*/*;q=0.8")
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("http %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, int64(max)))
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
resp.Body.Close()
|
||||
return data, ct, nil
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("no http client")
|
||||
}
|
||||
return nil, "", lastErr
|
||||
}
|
||||
|
||||
var (
|
||||
iconLinkRe = regexp.MustCompile(`(?is)<link\s+[^>]*rel\s*=\s*["']?[^"'>]*icon[^"'>]*["']?[^>]*>`)
|
||||
hrefRe = regexp.MustCompile(`(?is)href\s*=\s*["']([^"']+)["']`)
|
||||
)
|
||||
|
||||
// findIconHref 从首页 HTML 里挑出最合适的图标地址。
|
||||
func findIconHref(pageURL string, clients []*http.Client) (string, error) {
|
||||
data, ct, err := httpGetTruncated(pageURL, 384<<10, clients)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
lower := strings.ToLower(ct)
|
||||
if ct != "" && !strings.Contains(lower, "html") {
|
||||
return "", fmt.Errorf("not html: %s", ct)
|
||||
}
|
||||
best, bestScore := "", 0
|
||||
for _, tag := range iconLinkRe.FindAllString(string(data), -1) {
|
||||
m := hrefRe.FindStringSubmatch(tag)
|
||||
if len(m) < 2 {
|
||||
continue
|
||||
}
|
||||
href := strings.TrimSpace(m[1])
|
||||
if href == "" || strings.HasPrefix(href, "data:") {
|
||||
continue
|
||||
}
|
||||
low := strings.ToLower(tag)
|
||||
score := 0
|
||||
switch {
|
||||
case strings.Contains(low, "apple-touch-icon"):
|
||||
score = 1
|
||||
case strings.Contains(low, "icon"):
|
||||
score = 2
|
||||
}
|
||||
// 同分时优先 Dashy 能渲染的格式
|
||||
if ext := lowerExtOf(href); ext != "" {
|
||||
if _, ok := renderableByDashy(ext); ok {
|
||||
score += 2
|
||||
}
|
||||
}
|
||||
if score > bestScore {
|
||||
best, bestScore = href, score
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return "", errors.New("no icon link")
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
func lowerExtOf(raw string) string {
|
||||
if u, err := url.Parse(raw); err == nil {
|
||||
return strings.ToLower(filepath.Ext(u.Path))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func renderableByDashy(ext string) (string, bool) {
|
||||
switch ext {
|
||||
case ".png", ".jpg", ".jpeg":
|
||||
if ext == ".jpeg" {
|
||||
return ".jpg", true
|
||||
}
|
||||
return ext, true
|
||||
case ".svg":
|
||||
return ".svg", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func resolveURL(base, href string) string {
|
||||
bu, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
hu, err := url.Parse(href)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return bu.ResolveReference(hu).String()
|
||||
}
|
||||
|
||||
// sniffExt 只返回 Dashy 可渲染的扩展名,以及需要二次处理的 .ico。
|
||||
// 返回空串表示这份内容不能当图标用(例如 SPA 用 index.html 兜底的 200)。
|
||||
func sniffExt(data []byte, contentType, rawURL string) string {
|
||||
ct := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
|
||||
// 很多 SPA 对 /favicon.ico 也回 200 + text/html(把 index.html 吐回来),
|
||||
// 这种必须先排掉,否则会存下一份 HTML 当图标。
|
||||
switch ct {
|
||||
case "text/html", "application/xhtml+xml", "text/plain":
|
||||
return ""
|
||||
case "image/x-icon", "image/vnd.microsoft.icon":
|
||||
return ".ico"
|
||||
}
|
||||
switch ct {
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/jpeg":
|
||||
return ".jpg"
|
||||
case "image/svg+xml":
|
||||
return ".svg"
|
||||
}
|
||||
|
||||
switch {
|
||||
case bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")):
|
||||
return ".png"
|
||||
case bytes.HasPrefix(data, []byte("\xff\xd8\xff")):
|
||||
return ".jpg"
|
||||
case bytes.HasPrefix(data, []byte("\x00\x00\x01\x00")):
|
||||
return ".ico"
|
||||
}
|
||||
|
||||
trimmed := bytes.TrimSpace(data)
|
||||
if len(trimmed) > 0 && trimmed[0] == '<' {
|
||||
head := bytes.ToLower(trimmed)
|
||||
if len(head) > 512 {
|
||||
head = head[:512]
|
||||
}
|
||||
// 必须根元素就是 <svg;只在别处出现 "svg" 字样的 HTML 不算。
|
||||
if bytes.HasPrefix(head, []byte("<svg")) ||
|
||||
(bytes.HasPrefix(head, []byte("<?xml")) && bytes.Contains(head, []byte("<svg"))) {
|
||||
return ".svg"
|
||||
}
|
||||
}
|
||||
|
||||
if ext, ok := renderableByDashy(lowerExtOf(rawURL)); ok {
|
||||
return ext // 兜底:类型识别失败但 URL 后缀是图片
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// icoEntries 返回 ICO 内每个图像条目的原始数据。
|
||||
func icoEntries(data []byte) [][]byte {
|
||||
// ICONDIR: reserved(2)=0, type(2)=1, count(2)
|
||||
if len(data) < 6 || data[0] != 0 || data[1] != 0 || data[2] != 1 || data[3] != 0 {
|
||||
return nil
|
||||
}
|
||||
count := int(binary.LittleEndian.Uint16(data[4:6]))
|
||||
if count <= 0 || len(data) < 6+16*count {
|
||||
return nil
|
||||
}
|
||||
out := make([][]byte, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
off := 6 + 16*i
|
||||
size := binary.LittleEndian.Uint32(data[off+8 : off+12])
|
||||
start := binary.LittleEndian.Uint32(data[off+12 : off+16])
|
||||
if size == 0 || int(start) >= len(data) || int(start)+int(size) > len(data) {
|
||||
continue
|
||||
}
|
||||
out = append(out, data[start:int(start)+int(size)])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pngFromICO 抽出 ICO 里内嵌的 PNG(现代 ICO 常常直接内嵌 PNG 数据)。
|
||||
func pngFromICO(data []byte) ([]byte, bool) {
|
||||
var best []byte
|
||||
for _, e := range icoEntries(data) {
|
||||
if bytes.HasPrefix(e, []byte("\x89PNG\r\n\x1a\n")) && len(e) > len(best) {
|
||||
best = e
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return nil, false
|
||||
}
|
||||
return best, true
|
||||
}
|
||||
|
||||
// dibToPNG 把 ICO 里的传统 DIB(BMP)位图转成 PNG。
|
||||
// 这样 Google 这类只给老式 .ico 的站点也能有图标 ——
|
||||
// Dashy 不认 .ico,只能自己转。
|
||||
func dibToPNG(dib []byte) ([]byte, error) {
|
||||
if len(dib) < 40 {
|
||||
return nil, errors.New("short dib header")
|
||||
}
|
||||
hdrSize := int(binary.LittleEndian.Uint32(dib[0:4]))
|
||||
if hdrSize < 40 || hdrSize > len(dib) {
|
||||
return nil, errors.New("bad dib header size")
|
||||
}
|
||||
width := int(int32(binary.LittleEndian.Uint32(dib[4:8])))
|
||||
dblHeight := int(int32(binary.LittleEndian.Uint32(dib[8:12])))
|
||||
bitCount := int(binary.LittleEndian.Uint16(dib[14:16]))
|
||||
compression := binary.LittleEndian.Uint32(dib[16:20])
|
||||
height := dblHeight / 2 // ICO 里的高度是实际高度的两倍(含 AND 掩码)
|
||||
if width <= 0 || height <= 0 || width > 4096 || height > 4096 {
|
||||
return nil, errors.New("bad dib dimensions")
|
||||
}
|
||||
if compression != 0 {
|
||||
return nil, errors.New("compressed dib unsupported")
|
||||
}
|
||||
if bitCount != 32 && bitCount != 24 {
|
||||
return nil, fmt.Errorf("unsupported dib bitcount %d", bitCount)
|
||||
}
|
||||
|
||||
bpp := bitCount / 8
|
||||
stride := ((width*bpp + 3) / 4) * 4
|
||||
pixels := dib[hdrSize:]
|
||||
if len(pixels) < stride*height {
|
||||
return nil, errors.New("short dib pixel data")
|
||||
}
|
||||
|
||||
// 有些老图标 alpha 全为 0;那样整张图会变全透明,此时按不透明处理。
|
||||
alphaUsed := false
|
||||
if bpp == 4 {
|
||||
for y := 0; y < height && !alphaUsed; y++ {
|
||||
row := pixels[(height-1-y)*stride:]
|
||||
for x := 0; x < width; x++ {
|
||||
if row[x*4+3] != 0 {
|
||||
alphaUsed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
img := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
row := pixels[(height-1-y)*stride:] // DIB 自下而上
|
||||
for x := 0; x < width; x++ {
|
||||
i := x * bpp
|
||||
b, g, r := row[i], row[i+1], row[i+2]
|
||||
a := uint8(255)
|
||||
if bpp == 4 && alphaUsed {
|
||||
a = row[i+3]
|
||||
}
|
||||
img.SetNRGBA(x, y, color.NRGBA{R: r, G: g, B: b, A: a})
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// dibToPNGFromICO 逐个条目尝试解码,优先取像素数据最多的(分辨率最高)。
|
||||
func dibToPNGFromICO(data []byte) ([]byte, error) {
|
||||
entries := icoEntries(data)
|
||||
if len(entries) == 0 {
|
||||
return nil, errors.New("no ico entries")
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return len(entries[i]) > len(entries[j]) })
|
||||
var lastErr error
|
||||
for _, e := range entries {
|
||||
out, err := dibToPNG(e)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("no decodable ico entry")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func saveFavicon(hostport string, data []byte, ext string) error {
|
||||
name := "favicon-" + sanitizeHostKey(hostport) + ext
|
||||
dir := faviconDir()
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
dst := filepath.Join(dir, name)
|
||||
tmp := dst + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, dst)
|
||||
}
|
||||
|
||||
// storeIcon 落盘;ICO 会先尝试抽内嵌 PNG,抽不出就放弃(避免存下 Dashy 渲染不了的格式)。
|
||||
func storeIcon(hostport string, data []byte, ext string) error {
|
||||
if _, ok := renderableByDashy(ext); ok {
|
||||
target, _ := renderableByDashy(ext)
|
||||
return saveFavicon(hostport, data, target)
|
||||
}
|
||||
if ext == ".ico" {
|
||||
if png, ok := pngFromICO(data); ok {
|
||||
return saveFavicon(hostport, png, ".png")
|
||||
}
|
||||
pngData, err := dibToPNGFromICO(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ico not usable: %w", err)
|
||||
}
|
||||
return saveFavicon(hostport, pngData, ".png")
|
||||
}
|
||||
return fmt.Errorf("unsupported icon type %q", ext)
|
||||
}
|
||||
|
||||
func fetchFavicon(hostport string) error {
|
||||
if hostport == "" || strings.ContainsAny(hostport, "/\\ \t") {
|
||||
return errors.New("invalid host")
|
||||
}
|
||||
clients := clientsFor(hostport)
|
||||
var lastErr error
|
||||
|
||||
for _, scheme := range []string{"https", "http"} {
|
||||
base := scheme + "://" + hostport
|
||||
icoURL := base + "/favicon.ico"
|
||||
|
||||
if data, ct, err := httpGet(icoURL, faviconMaxBytes, clients); err == nil {
|
||||
if ext := sniffExt(data, ct, icoURL); ext != "" {
|
||||
if err := storeIcon(hostport, data, ext); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
} else {
|
||||
lastErr = errors.New("favicon.ico is not an image")
|
||||
}
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
href, err := findIconHref(base+"/", clients)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
target := resolveURL(base+"/", href)
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
data, ct, err := httpGet(target, faviconMaxBytes, clients)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
ext := sniffExt(data, ct, target)
|
||||
if ext == "" {
|
||||
lastErr = errors.New("declared icon is not a usable image")
|
||||
continue
|
||||
}
|
||||
if err := storeIcon(hostport, data, ext); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// hostPortOf 返回 host[:port](内网不同端口的服务要区分开)。
|
||||
func hostPortOf(rawURL string) string {
|
||||
if u, err := url.Parse(rawURL); err == nil && u.Host != "" {
|
||||
return strings.ToLower(u.Host)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@ -291,6 +291,10 @@ func extensionInstallItem() map[string]any {
|
||||
// 3) 「常用服务」= 访问最高的 4 个导航(Gitea/代码托管恒置顶)
|
||||
// 4) 「历史访问」= 非导航 host 的 top 20
|
||||
func rebuildDashySections() {
|
||||
// 序列化重建:同步请求、启动、以及 favicon 抓完后的重试都会调到这里。
|
||||
rebuildMu.Lock()
|
||||
defer rebuildMu.Unlock()
|
||||
|
||||
confFile := filepath.Join(dataDir, "conf.yml")
|
||||
data, err := os.ReadFile(confFile)
|
||||
if err != nil {
|
||||
@ -375,6 +379,7 @@ func rebuildDashySections() {
|
||||
})
|
||||
|
||||
// 导航项若均无访问记录(首次/纯外站访问),保持常用服务原手动配置不动
|
||||
updateFavorites := true
|
||||
{
|
||||
anyNav := false
|
||||
for _, c := range candidates {
|
||||
@ -383,23 +388,9 @@ func rebuildDashySections() {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !anyNav {
|
||||
// 尚无导航访问数据:保持「常用服务」的手工配置不动,
|
||||
// 「历史访问」只放插件安装入口(装好后由 navi.js 隐藏)。
|
||||
for _, si := range secs {
|
||||
if isHistSection(si.name) {
|
||||
si.raw["items"] = []any{extensionInstallItem()}
|
||||
}
|
||||
}
|
||||
out, err := yaml.Marshal(cfg)
|
||||
if err == nil {
|
||||
tmp := confFile + ".sort-tmp"
|
||||
if err2 := os.WriteFile(tmp, out, 0644); err2 == nil {
|
||||
os.Rename(tmp, confFile)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// 导航项都没有访问数据时不重排「常用服务」,保留手工配置;
|
||||
// 但「历史访问」仍要正常生成 —— 只浏览站外站点时,那些历史恰恰是最该展示的。
|
||||
updateFavorites = anyNav
|
||||
}
|
||||
|
||||
// host 去重:同 host 只保留访问最高的一条导航项
|
||||
@ -448,7 +439,7 @@ func rebuildDashySections() {
|
||||
|
||||
// 更新各 section
|
||||
for _, si := range secs {
|
||||
if isFavSection(si.name) {
|
||||
if updateFavorites && isFavSection(si.name) {
|
||||
newItems := make([]any, 0, len(finalNavs))
|
||||
for _, n := range finalNavs {
|
||||
it := map[string]any{
|
||||
@ -498,10 +489,18 @@ func rebuildDashySections() {
|
||||
if title == "" {
|
||||
title = e.URL
|
||||
}
|
||||
icon := "fas fa-globe"
|
||||
if hp := hostPortOf(e.URL); hp != "" {
|
||||
if name := cachedFavicon(hp); name != "" {
|
||||
icon = name // 与手工导航项同一套本地图标机制
|
||||
} else {
|
||||
queueFavicon(hp) // 后台抓取,完成后会重建 conf.yml
|
||||
}
|
||||
}
|
||||
histItems = append(histItems, map[string]any{
|
||||
"title": title,
|
||||
"description": fmt.Sprintf("%d\u6b21\u8bbf\u95ee", e.TotalVisits),
|
||||
"icon": "fas fa-globe", "url": e.URL,
|
||||
"icon": icon, "url": e.URL,
|
||||
})
|
||||
if len(histItems) >= 21 {
|
||||
break
|
||||
@ -552,6 +551,7 @@ func visitCount(rawURL string) int {
|
||||
func main() {
|
||||
os.MkdirAll(dataDir, 0755)
|
||||
load()
|
||||
initFavicon()
|
||||
// 启动时重建一次:让「历史访问」首条的插件安装入口立即生效,
|
||||
// 而不必等到下一次浏览器插件同步。
|
||||
rebuildDashySections()
|
||||
|
||||
Reference in New Issue
Block a user