diff --git a/history-server/favicon.go b/history-server/favicon.go index 1e0c0ba..e276334 100644 --- a/history-server/favicon.go +++ b/history-server/favicon.go @@ -388,24 +388,15 @@ func resolveURL(base, href string) string { } // sniffExt 只返回 Dashy 可渲染的扩展名,以及需要二次处理的 .ico。 -// 返回空串表示这份内容不能当图标用(例如 SPA 用 index.html 兜底的 200)。 +// 返回空串表示这份内容不能当图标用。 +// +// 判定以**正文特征**为准,不轻信 Content-Type:很多站点会把 HTML 兜底页 +// 配上 image/svg+xml 之类的类型返回(正文才是事实)。 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": + case "text/html", "application/xhtml+xml", "text/plain", "application/json": 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 { @@ -416,26 +407,26 @@ func sniffExt(data []byte, contentType, rawURL string) string { 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" 字样的 HTML 不算。 +func looksLikeSVG(data []byte) bool { + d := bytes.TrimSpace(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})) // 去 BOM + if len(d) == 0 || d[0] != '<' { + return false + } + head := bytes.ToLower(d) + if len(head) > 512 { + head = head[:512] + } + return bytes.HasPrefix(head, []byte("x`) + + if got := sniffExt(body, "text/html; charset=utf-8", "http://h/favicon.ico"); got != "" { + t.Fatalf("HTML fallback body must be rejected, got %q", got) + } + // 即使服务端把 Content-Type 谎报成 svg,只要正文不是 SVG 根元素也要拒。 + if got := sniffExt(body, "image/svg+xml", "http://h/favicon.ico"); got == ".svg" { + t.Fatalf("non-SVG body must not be stored as .svg") + } +} + +func TestSniffExtByMagicAndType(t *testing.T) { + pngMagic := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 16)...) + if got := sniffExt(pngMagic, "", "http://h/favicon.ico"); got != ".png" { + t.Errorf("png magic: got %q want .png", got) + } + jpgMagic := append([]byte("\xff\xd8\xff"), make([]byte, 16)...) + if got := sniffExt(jpgMagic, "", "http://h/favicon.ico"); got != ".jpg" { + t.Errorf("jpeg magic: got %q want .jpg", got) + } + svg := []byte(``) + if got := sniffExt(svg, "", "http://h/favicon.svg"); got != ".svg" { + t.Errorf("svg body: got %q want .svg", got) + } + // ico 要被识别出来交给后续转换,而不是直接落盘 + icoMagic := []byte("\x00\x00\x01\x00\x01\x00") + if got := sniffExt(icoMagic, "", "http://h/favicon.ico"); got != ".ico" { + t.Errorf("ico magic: got %q want .ico", got) + } + // Dashy 渲染不了的格式直接拒绝 + gif := append([]byte("GIF89a"), make([]byte, 16)...) + if got := sniffExt(gif, "", "http://h/favicon.gif"); got != "" { + t.Errorf("gif must be rejected (Dashy cannot render it), got %q", got) + } + webp := append([]byte("RIFF\x00\x00\x00\x00WEBP"), make([]byte, 8)...) + if got := sniffExt(webp, "", "http://h/favicon.webp"); got != "" { + t.Errorf("webp must be rejected, got %q", got) + } +} + +// ── ICO 解析 ── + +// buildICO 把若干条目打包成 ICO 容器。 +func buildICO(entries ...[]byte) []byte { + var buf bytes.Buffer + buf.Write([]byte{0, 0, 1, 0}) + _ = binary.Write(&buf, binary.LittleEndian, uint16(len(entries))) + offset := 6 + 16*len(entries) + for _, e := range entries { + buf.Write([]byte{16, 16, 0, 0}) // w,h,colors,reserved + _ = binary.Write(&buf, binary.LittleEndian, uint16(1)) + _ = binary.Write(&buf, binary.LittleEndian, uint16(32)) + _ = binary.Write(&buf, binary.LittleEndian, uint32(len(e))) + _ = binary.Write(&buf, binary.LittleEndian, uint32(offset)) + offset += len(e) + } + for _, e := range entries { + buf.Write(e) + } + return buf.Bytes() +} + +func encodePNG(t *testing.T, w, h int) []byte { + t.Helper() + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + img.SetNRGBA(0, 0, color.NRGBA{R: 1, G: 2, B: 3, A: 255}) + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("png encode: %v", err) + } + return buf.Bytes() +} + +func TestPngFromICO(t *testing.T) { + small := encodePNG(t, 1, 1) + big := encodePNG(t, 16, 16) + ico := buildICO(small, big) + + got, ok := pngFromICO(ico) + if !ok { + t.Fatal("expected an embedded png") + } + if !bytes.Equal(got, big) { + t.Fatalf("should pick the largest embedded png: got %d bytes want %d", len(got), len(big)) + } + if _, ok := pngFromICO([]byte("not an ico")); ok { + t.Fatal("garbage must not yield a png") + } +} + +// buildDIB 构造 ICO 内常见的 DIB 位图(32bpp,自下而上,行 4 字节对齐)。 +func buildDIB(w, h int, rows [][]color.NRGBA) []byte { + var buf bytes.Buffer + hdr := make([]byte, 40) + binary.LittleEndian.PutUint32(hdr[0:4], 40) + binary.LittleEndian.PutUint32(hdr[4:8], uint32(w)) + binary.LittleEndian.PutUint32(hdr[8:12], uint32(2*h)) // ICO 高度是两倍 + binary.LittleEndian.PutUint16(hdr[12:14], 1) + binary.LittleEndian.PutUint16(hdr[14:16], 32) + buf.Write(hdr) + for _, row := range rows { + for _, c := range row { + buf.Write([]byte{c.B, c.G, c.R, c.A}) + } + } + return buf.Bytes() +} + +func decodePNGSize(t *testing.T, data []byte) image.Image { + t.Helper() + img, err := png.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("decode produced png: %v", err) + } + return img +} + +func TestDibToPNG(t *testing.T) { + // DIB 自下而上:rows[0] 是图像最底下那一行。 + topLeft := color.NRGBA{R: 10, G: 20, B: 30, A: 255} + bottomLeft := color.NRGBA{R: 200, G: 100, B: 50, A: 255} + rows := [][]color.NRGBA{ + {bottomLeft, {R: 1, G: 1, B: 1, A: 255}}, + {topLeft, {R: 2, G: 2, B: 2, A: 255}}, + } + out, err := dibToPNG(buildDIB(2, 2, rows)) + if err != nil { + t.Fatalf("dibToPNG: %v", err) + } + img := decodePNGSize(t, out) + if b := img.Bounds(); b.Dx() != 2 || b.Dy() != 2 { + t.Fatalf("size = %dx%d, want 2x2", b.Dx(), b.Dy()) + } + r, g, b, a := img.At(0, 0).RGBA() + // 注意:解出的 PNG 是 NRGBA,At() 返回预乘值;这里 alpha=255 故直读高位 + if uint8(r>>8) != topLeft.R || uint8(g>>8) != topLeft.G || uint8(b>>8) != topLeft.B || uint8(a>>8) != 255 { + t.Fatalf("pixel(0,0) = %d,%d,%d,%d; want %d,%d,%d,255", + r>>8, g>>8, b>>8, a>>8, topLeft.R, topLeft.G, topLeft.B) + } +} + +func TestDibToPNGTreatsAllZeroAlphaAsOpaque(t *testing.T) { + // 老图标常把 alpha 全写 0;若照实解释会得到整张全透明图。 + rows := [][]color.NRGBA{{{R: 9, G: 8, B: 7, A: 0}, {R: 9, G: 8, B: 7, A: 0}}} + out, err := dibToPNG(buildDIB(2, 1, rows)) + if err != nil { + t.Fatalf("dibToPNG: %v", err) + } + img := decodePNGSize(t, out) + if _, _, _, a := img.At(0, 0).RGBA(); a>>8 != 255 { + t.Fatalf("all-zero alpha should be treated as opaque, got alpha=%d", a>>8) + } +} + +func TestDibToPNGRejectsUnsupported(t *testing.T) { + hdr := make([]byte, 40) + binary.LittleEndian.PutUint32(hdr[0:4], 40) + binary.LittleEndian.PutUint32(hdr[4:8], 16) + binary.LittleEndian.PutUint32(hdr[8:12], 32) + binary.LittleEndian.PutUint16(hdr[14:16], 8) // 8bpp 不支持 + if _, err := dibToPNG(hdr); err == nil { + t.Fatal("8bpp dib should be rejected") + } + if _, err := dibToPNG([]byte("short")); err == nil { + t.Fatal("short data should be rejected") + } +} + +func TestDibToPNGFromICOPrefersLargest(t *testing.T) { + rows := [][]color.NRGBA{{{R: 1, G: 1, B: 1, A: 255}}} + small := buildDIB(1, 1, rows) + tallRows := [][]color.NRGBA{ + {{R: 3, G: 3, B: 3, A: 255}, {R: 3, G: 3, B: 3, A: 255}}, + {{R: 3, G: 3, B: 3, A: 255}, {R: 3, G: 3, B: 3, A: 255}}, + } + large := buildDIB(2, 2, tallRows) + out, err := dibToPNGFromICO(buildICO(small, large)) + if err != nil { + t.Fatalf("dibToPNGFromICO: %v", err) + } + if b := decodePNGSize(t, out).Bounds(); b.Dx() != 2 || b.Dy() != 2 { + t.Fatalf("should pick the largest entry, got %dx%d", b.Dx(), b.Dy()) + } +} + +// ── 文件名与段落名 ── + +func TestSanitizeHostKey(t *testing.T) { + cases := map[string]string{ + "example.com": "example.com", + "10.0.0.10:3000": "10.0.0.10_3000", + "example.com:8443": "example.com_8443", + "[::1]:8080": "___1__8080", + } + for in, want := range cases { + if got := sanitizeHostKey(in); got != want { + t.Errorf("sanitizeHostKey(%q) = %q, want %q", in, got, want) + } + } +} + +func TestSectionNameMatching(t *testing.T) { + // 公开示例 conf.yml 用英文段落名,生产用中文;两者都必须能匹配。 + for _, n := range []string{"常用服务", "Favorites", "favorites", "FAVORITES"} { + if !isFavSection(n) { + t.Errorf("isFavSection(%q) = false, want true", n) + } + } + for _, n := range []string{"历史访问", "History", "history", "HISTORY"} { + if !isHistSection(n) { + t.Errorf("isHistSection(%q) = false, want true", n) + } + } + for _, n := range []string{"Tools", "工具", "", "Favourite"} { + if isFavSection(n) || isHistSection(n) { + t.Errorf("section %q should not match favorites/history", n) + } + } +} diff --git a/history-server/main.go b/history-server/main.go index b69db7b..4ba38a3 100644 --- a/history-server/main.go +++ b/history-server/main.go @@ -232,32 +232,32 @@ func normalizeURL(raw string) (string, error) { // navItem 是 conf.yml 里一个导航项(任一 section) type dashyNav struct { - title, desc, icon, url string + title, desc, icon, url string } // dashySection 是 conf.yml 里的一个 section type dashySection struct { - name, icon string - collapsed bool - items []dashyNav + name, icon string + collapsed bool + items []dashyNav } // hostVisits 统计 history 中每个 host 的累计访问 func hostVisits() map[string]int { - mu.RLock() - defer mu.RUnlock() - out := map[string]int{} - for _, e := range history { - if u, err := url.Parse(e.URL); err == nil { - out[strings.ToLower(u.Hostname())] += e.TotalVisits - } - } - return out + mu.RLock() + defer mu.RUnlock() + out := map[string]int{} + for _, e := range history { + if u, err := url.Parse(e.URL); err == nil { + out[strings.ToLower(u.Hostname())] += e.TotalVisits + } + } + return out } // 段落名兼容中英文:公开示例 conf.yml 用英文名,生产实例用中文名。 const ( - favSectionZh = "\u5e38\u7528\u670d\u52a1" // 常用服务 + favSectionZh = "\u5e38\u7528\u670d\u52a1" // 常用服务 favSectionEn = "Favorites" histSectionZh = "\u5386\u53f2\u8bbf\u95ee" // 历史访问 histSectionEn = "History" @@ -286,250 +286,250 @@ func extensionInstallItem() map[string]any { } // rebuildDashySections 读 conf.yml: -// 1) 收集除「历史访问」外所有导航项 -// 2) 按各导航 host 在 history 的累计访问统计 -// 3) 「常用服务」= 访问最高的 4 个导航(Gitea/代码托管恒置顶) -// 4) 「历史访问」= 非导航 host 的 top 20 +// 1. 收集除「历史访问」外所有导航项 +// 2. 按各导航 host 在 history 的累计访问统计 +// 3. 「常用服务」= 访问最高的 4 个导航(Gitea/代码托管恒置顶) +// 4. 「历史访问」= 非导航 host 的 top 20 func rebuildDashySections() { - // 序列化重建:同步请求、启动、以及 favicon 抓完后的重试都会调到这里。 - rebuildMu.Lock() - defer rebuildMu.Unlock() + // 序列化重建:同步请求、启动、以及 favicon 抓完后的重试都会调到这里。 + rebuildMu.Lock() + defer rebuildMu.Unlock() - confFile := filepath.Join(dataDir, "conf.yml") - data, err := os.ReadFile(confFile) - if err != nil { - return - } + confFile := filepath.Join(dataDir, "conf.yml") + data, err := os.ReadFile(confFile) + if err != nil { + return + } - var cfg map[string]any - if err := yaml.Unmarshal(data, &cfg); err != nil { - return - } + var cfg map[string]any + if err := yaml.Unmarshal(data, &cfg); err != nil { + return + } - sectionsRaw, ok := cfg["sections"].([]any) - if !ok || len(sectionsRaw) == 0 { - return - } + sectionsRaw, ok := cfg["sections"].([]any) + if !ok || len(sectionsRaw) == 0 { + return + } - // 段落名匹配见 isFavSection / isHistSection(兼容中英文) - type secInfo struct { - raw map[string]any - name string - collapsed bool - } - secs := make([]secInfo, 0, len(sectionsRaw)) - for _, s := range sectionsRaw { - m, ok := s.(map[string]any) - if !ok { - continue - } - name, _ := m["name"].(string) - coll := false - if c, ok := m["collapsed"].(bool); ok { - coll = c - } - secs = append(secs, secInfo{raw: m, name: name, collapsed: coll}) - } + // 段落名匹配见 isFavSection / isHistSection(兼容中英文) + type secInfo struct { + raw map[string]any + name string + collapsed bool + } + secs := make([]secInfo, 0, len(sectionsRaw)) + for _, s := range sectionsRaw { + m, ok := s.(map[string]any) + if !ok { + continue + } + name, _ := m["name"].(string) + coll := false + if c, ok := m["collapsed"].(bool); ok { + coll = c + } + secs = append(secs, secInfo{raw: m, name: name, collapsed: coll}) + } - // 收集所有导航项(排除历史访问 section 本身),带 host 访问量 - hv := hostVisits() + // 收集所有导航项(排除历史访问 section 本身),带 host 访问量 + hv := hostVisits() - type cand struct { - nav dashyNav - host string - visits int - } - candidates := make([]cand, 0) - for _, si := range secs { - items, _ := si.raw["items"].([]any) - for _, it := range items { - m, ok := it.(map[string]any) - if !ok { - continue - } - title, _ := m["title"].(string) - desc, _ := m["description"].(string) - icon, _ := m["icon"].(string) - u, _ := m["url"].(string) - if u == "" { - continue - } - host := "" - if pu, err := url.Parse(u); err == nil { - host = strings.ToLower(pu.Hostname()) - } - v := 0 - if host != "" { - v = hv[host] - } - if isHistSection(si.name) { - continue - } // 所有导航项(含常用服务原本手动配置的)都进候选, - // 由访问量 + code-host 置顶决定最终 Top4 - candidates = append(candidates, cand{ - nav: dashyNav{title: title, desc: desc, icon: icon, url: u}, - host: host, visits: v, - }) - } - } + type cand struct { + nav dashyNav + host string + visits int + } + candidates := make([]cand, 0) + for _, si := range secs { + items, _ := si.raw["items"].([]any) + for _, it := range items { + m, ok := it.(map[string]any) + if !ok { + continue + } + title, _ := m["title"].(string) + desc, _ := m["description"].(string) + icon, _ := m["icon"].(string) + u, _ := m["url"].(string) + if u == "" { + continue + } + host := "" + if pu, err := url.Parse(u); err == nil { + host = strings.ToLower(pu.Hostname()) + } + v := 0 + if host != "" { + v = hv[host] + } + if isHistSection(si.name) { + continue + } // 所有导航项(含常用服务原本手动配置的)都进候选, + // 由访问量 + code-host 置顶决定最终 Top4 + candidates = append(candidates, cand{ + nav: dashyNav{title: title, desc: desc, icon: icon, url: u}, + host: host, visits: v, + }) + } + } - // 排序:按访问量降序;同量保持原序 - sort.SliceStable(candidates, func(i, j int) bool { - return candidates[i].visits > candidates[j].visits - }) + // 排序:按访问量降序;同量保持原序 + sort.SliceStable(candidates, func(i, j int) bool { + return candidates[i].visits > candidates[j].visits + }) - // 导航项若均无访问记录(首次/纯外站访问),保持常用服务原手动配置不动 - updateFavorites := true - { - anyNav := false - for _, c := range candidates { - if c.visits > 0 { - anyNav = true - break - } - } - // 导航项都没有访问数据时不重排「常用服务」,保留手工配置; - // 但「历史访问」仍要正常生成 —— 只浏览站外站点时,那些历史恰恰是最该展示的。 - updateFavorites = anyNav - } + // 导航项若均无访问记录(首次/纯外站访问),保持常用服务原手动配置不动 + updateFavorites := true + { + anyNav := false + for _, c := range candidates { + if c.visits > 0 { + anyNav = true + break + } + } + // 导航项都没有访问数据时不重排「常用服务」,保留手工配置; + // 但「历史访问」仍要正常生成 —— 只浏览站外站点时,那些历史恰恰是最该展示的。 + updateFavorites = anyNav + } - // host 去重:同 host 只保留访问最高的一条导航项 - // (rebuild 前常用服务可能已有上一轮写入的副本,与其它 section 同 host 会重复) - { - seenH := map[string]bool{} - uniq := candidates[:0] - for _, c := range candidates { - if c.host == "" { - uniq = append(uniq, c) - continue - } - if seenH[c.host] { - continue - } - seenH[c.host] = true - uniq = append(uniq, c) - } - candidates = uniq - } + // host 去重:同 host 只保留访问最高的一条导航项 + // (rebuild 前常用服务可能已有上一轮写入的副本,与其它 section 同 host 会重复) + { + seenH := map[string]bool{} + uniq := candidates[:0] + for _, c := range candidates { + if c.host == "" { + uniq = append(uniq, c) + continue + } + if seenH[c.host] { + continue + } + seenH[c.host] = true + uniq = append(uniq, c) + } + candidates = uniq + } - // 强制 Gitea/代码托管置顶:host 或标题含 gitea/git/code/代码/仓库 - // 代码托管识别(独立函数便于单测) - var top []cand - var codeHost *cand - rest := make([]cand, 0, len(candidates)) - for i := range candidates { - if codeHost == nil && isCodeHostVal(candidates[i].nav.title, candidates[i].host, candidates[i].nav.url) { - codeHost = &candidates[i] - } else { - rest = append(rest, candidates[i]) - } - } - top = append(top, rest...) - // 组装:Gitea 第一(若存在),再加访问最高的其余导航,凑满 4 - finalNavs := make([]dashyNav, 0, 4) - if codeHost != nil { - finalNavs = append(finalNavs, codeHost.nav) - } - for _, c := range top { - if len(finalNavs) >= 4 { - break - } - finalNavs = append(finalNavs, c.nav) - } + // 强制 Gitea/代码托管置顶:host 或标题含 gitea/git/code/代码/仓库 + // 代码托管识别(独立函数便于单测) + var top []cand + var codeHost *cand + rest := make([]cand, 0, len(candidates)) + for i := range candidates { + if codeHost == nil && isCodeHostVal(candidates[i].nav.title, candidates[i].host, candidates[i].nav.url) { + codeHost = &candidates[i] + } else { + rest = append(rest, candidates[i]) + } + } + top = append(top, rest...) + // 组装:Gitea 第一(若存在),再加访问最高的其余导航,凑满 4 + finalNavs := make([]dashyNav, 0, 4) + if codeHost != nil { + finalNavs = append(finalNavs, codeHost.nav) + } + for _, c := range top { + if len(finalNavs) >= 4 { + break + } + finalNavs = append(finalNavs, c.nav) + } - // 更新各 section - for _, si := range secs { - if updateFavorites && isFavSection(si.name) { - newItems := make([]any, 0, len(finalNavs)) - for _, n := range finalNavs { - it := map[string]any{ - "title": n.title, "description": n.desc, - "icon": n.icon, "url": n.url, - } - if n.desc == "" { - it["description"] = "" - } - newItems = append(newItems, it) - } - si.raw["items"] = newItems - } - } + // 更新各 section + for _, si := range secs { + if updateFavorites && isFavSection(si.name) { + newItems := make([]any, 0, len(finalNavs)) + for _, n := range finalNavs { + it := map[string]any{ + "title": n.title, "description": n.desc, + "icon": n.icon, "url": n.url, + } + if n.desc == "" { + it["description"] = "" + } + newItems = append(newItems, it) + } + si.raw["items"] = newItems + } + } - // 历史访问 = 非导航 host 的历史 top 20(排除已进常用/导航的) - if true { - navHosts := map[string]bool{} - for _, c := range candidates { - if c.host != "" { - navHosts[c.host] = true - } - } - for _, n := range finalNavs { - if pu, err := url.Parse(n.url); err == nil { - navHosts[strings.ToLower(pu.Hostname())] = true - } - } - mu.RLock() - entries := make([]*Entry, 0, len(history)) - for _, e := range history { - entries = append(entries, e) - } - mu.RUnlock() - sort.SliceStable(entries, func(i, j int) bool { - return entries[i].TotalVisits > entries[j].TotalVisits - }) - histItems := make([]any, 0, 21) - // 首条固定为插件安装入口:未装插件(未注入 data-navi-ext)时保留, - // 已装的浏览器由 navi.js 隐藏。 - histItems = append(histItems, extensionInstallItem()) - for _, e := range entries { - if navHosts[strings.ToLower(hostOf(e.URL))] { - continue // 已是导航项,不重复进历史 - } - title := e.Title - 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": icon, "url": e.URL, - }) - if len(histItems) >= 21 { - break - } - } - for _, si := range secs { - if isHistSection(si.name) { - si.raw["items"] = histItems - } - } - } + // 历史访问 = 非导航 host 的历史 top 20(排除已进常用/导航的) + if true { + navHosts := map[string]bool{} + for _, c := range candidates { + if c.host != "" { + navHosts[c.host] = true + } + } + for _, n := range finalNavs { + if pu, err := url.Parse(n.url); err == nil { + navHosts[strings.ToLower(pu.Hostname())] = true + } + } + mu.RLock() + entries := make([]*Entry, 0, len(history)) + for _, e := range history { + entries = append(entries, e) + } + mu.RUnlock() + sort.SliceStable(entries, func(i, j int) bool { + return entries[i].TotalVisits > entries[j].TotalVisits + }) + histItems := make([]any, 0, 21) + // 首条固定为插件安装入口:未装插件(未注入 data-navi-ext)时保留, + // 已装的浏览器由 navi.js 隐藏。 + histItems = append(histItems, extensionInstallItem()) + for _, e := range entries { + if navHosts[strings.ToLower(hostOf(e.URL))] { + continue // 已是导航项,不重复进历史 + } + title := e.Title + 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": icon, "url": e.URL, + }) + if len(histItems) >= 21 { + break + } + } + for _, si := range secs { + if isHistSection(si.name) { + si.raw["items"] = histItems + } + } + } - cfg["sections"] = sectionsRaw - out, err := yaml.Marshal(cfg) - if err != nil { - return - } - tmp := confFile + ".sort-tmp" - if err := os.WriteFile(tmp, out, 0644); err != nil { - return - } - os.Rename(tmp, confFile) + cfg["sections"] = sectionsRaw + out, err := yaml.Marshal(cfg) + if err != nil { + return + } + tmp := confFile + ".sort-tmp" + if err := os.WriteFile(tmp, out, 0644); err != nil { + return + } + os.Rename(tmp, confFile) } func hostOf(rawURL string) string { - if u, err := url.Parse(rawURL); err == nil { - return strings.ToLower(u.Hostname()) - } - return "" + if u, err := url.Parse(rawURL); err == nil { + return strings.ToLower(u.Hostname()) + } + return "" } func visitCount(rawURL string) int { @@ -586,21 +586,21 @@ func main() { // isCodeHostVal 判断导航项是否代码托管:只认知名托管 host 与标题关键词, // 避免 "git." 子串误匹配域名中段(如 your-dify.example)。 func isCodeHostVal(title, host, url string) bool { - lowT := strings.ToLower(title) - if strings.Contains(lowT, "gitea") || strings.Contains(lowT, "github") || - strings.Contains(lowT, "gitlab") || strings.Contains(lowT, "代码") || - strings.Contains(lowT, "仓库") || strings.Contains(lowT, "codeberg") { - return true - } - h := strings.ToLower(host) - if strings.HasPrefix(h, "git.") || strings.HasPrefix(h, "gitea.") || - strings.HasPrefix(h, "gitlab.") || strings.HasPrefix(h, "codeberg.") || - strings.HasPrefix(h, "github.") { - return true - } - if strings.Contains(h, "gitea") || strings.Contains(h, "github") || - strings.Contains(h, "gitlab") || strings.Contains(h, "codeberg") { - return true - } - return false + lowT := strings.ToLower(title) + if strings.Contains(lowT, "gitea") || strings.Contains(lowT, "github") || + strings.Contains(lowT, "gitlab") || strings.Contains(lowT, "代码") || + strings.Contains(lowT, "仓库") || strings.Contains(lowT, "codeberg") { + return true + } + h := strings.ToLower(host) + if strings.HasPrefix(h, "git.") || strings.HasPrefix(h, "gitea.") || + strings.HasPrefix(h, "gitlab.") || strings.HasPrefix(h, "codeberg.") || + strings.HasPrefix(h, "github.") { + return true + } + if strings.Contains(h, "gitea") || strings.Contains(h, "github") || + strings.Contains(h, "gitlab") || strings.Contains(h, "codeberg") { + return true + } + return false }