Files
HomeDash/history-server/main.go
JianFeeeee 4f1c65a616 fix: keep manual favorites when no nav visit data; external-only visits don't trigger reorder
- Favorites section only rebuilds from Top-4 by visit frequency once real
  navigation hosts have visit data; otherwise manual Gitea/HomeAgent/AgentMail
  items are preserved untouched
- Pure external-site history (wikipedia etc.) no longer causes favorites to
  borrow tiles from other sections
2026-09-08 22:53:22 +08:00

583 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"gopkg.in/yaml.v3"
)
var (
dataDir = envOr("NAVI_DATA_DIR", "/app/data")
historyFile = filepath.Join(dataDir, "history.json")
syncToken = os.Getenv("NAVI_SYNC_TOKEN")
adminToken = os.Getenv("NAVI_ADMIN_TOKEN")
mu sync.RWMutex
history = map[string]*Entry{}
)
type Entry struct {
URL string `json:"url"`
Title string `json:"title"`
TotalVisits int `json:"total_visits"`
LastVisit float64 `json:"last_visit"`
Devices []string `json:"devices"`
}
type SyncItem struct {
URL string `json:"url"`
Title string `json:"title"`
Device string `json:"device"`
Timestamp float64 `json:"timestamp"`
VisitCount int `json:"visit_count"`
}
type SyncRequest struct {
Items []SyncItem `json:"items"`
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func load() {
data, err := os.ReadFile(historyFile)
if err != nil {
return
}
var entries []*Entry
if err := json.Unmarshal(data, &entries); err != nil {
log.Printf("load error: %v", err)
return
}
for _, e := range entries {
history[e.URL] = e
}
log.Printf("history: loaded %d entries", len(history))
}
func save() {
entries := make([]*Entry, 0, len(history))
for _, e := range history {
entries = append(entries, e)
}
data, _ := json.MarshalIndent(entries, "", " ")
os.MkdirAll(filepath.Dir(historyFile), 0755)
os.WriteFile(historyFile, data, 0644)
}
func cors(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(204)
return
}
next(w, r)
}
}
func requireToken(w http.ResponseWriter, r *http.Request, expected string) bool {
if expected == "" {
return true // 未配置 token 则不校验
}
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") && strings.TrimPrefix(auth, "Bearer ") == expected {
return true
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid or missing token"})
return false
}
func handleSync(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
// 浏览器插件回传:必须携带 NAVI_SYNC_TOKEN(免 portal 登录,改用自定义 token)
if !requireToken(w, r, syncToken) {
return
}
var req SyncRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), 400)
return
}
if len(req.Items) == 0 || len(req.Items) > 1000 {
http.Error(w, "items must be 1-1000", 400)
return
}
mu.Lock()
for _, item := range req.Items {
normalized, err := normalizeURL(item.URL)
if err != nil {
continue
}
if item.VisitCount <= 0 {
item.VisitCount = 1
}
if item.Device == "" {
item.Device = "unknown"
}
if e, ok := history[normalized]; ok {
e.TotalVisits += item.VisitCount
if item.Timestamp > e.LastVisit {
e.LastVisit = item.Timestamp
}
if len(item.Title) > len(e.Title) {
e.Title = item.Title
}
found := false
for _, d := range e.Devices {
if d == item.Device {
found = true
break
}
}
if !found {
e.Devices = append(e.Devices, item.Device)
}
} else {
history[normalized] = &Entry{
URL: normalized, Title: item.Title, TotalVisits: item.VisitCount,
LastVisit: item.Timestamp, Devices: []string{item.Device},
}
}
}
save()
mu.Unlock() // 写锁释放后再排序,避免 sortDashyFavorites 内 RLock 死锁
rebuildDashySections()
mu.RLock()
total := len(history)
mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "count": len(req.Items), "total": total})
}
func handleGet(w http.ResponseWriter, r *http.Request) {
mu.RLock()
defer mu.RUnlock()
entries := make([]*Entry, 0, len(history))
for _, e := range history {
entries = append(entries, e)
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].TotalVisits > entries[j].TotalVisits
})
if len(entries) > 100 {
entries = entries[:100]
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"items": entries})
}
func handleClear(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", 405)
return
}
mu.Lock()
history = map[string]*Entry{}
save()
mu.Unlock()
rebuildDashySections() // 清空后重置 conf.yml 历史 section 为占位
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "cleared"})
}
func handleStats(w http.ResponseWriter, r *http.Request) {
mu.RLock()
defer mu.RUnlock()
total := 0
for _, e := range history {
total += e.TotalVisits
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"unique_urls": len(history), "total_visits": total})
}
func normalizeURL(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", fmt.Errorf("invalid url: %s", raw)
}
u.Fragment = ""
u.Host = strings.ToLower(u.Host)
return u.String(), nil
}
// navItem 是 conf.yml 里一个导航项(任一 section)
type dashyNav struct {
title, desc, icon, url string
}
// dashySection 是 conf.yml 里的一个 section
type dashySection struct {
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
}
// rebuildDashySections 读 conf.yml:
// 1) 收集除「历史访问」外所有导航项
// 2) 按各导航 host 在 history 的累计访问统计
// 3) 「常用服务」= 访问最高的 4 个导航(Gitea/代码托管恒置顶)
// 4) 「历史访问」= 非导航 host 的 top 20
func rebuildDashySections() {
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
}
sectionsRaw, ok := cfg["sections"].([]any)
if !ok || len(sectionsRaw) == 0 {
return
}
const favName = "\u5e38\u7528\u670d\u52a1" // 常用服务
const histName = "\u5386\u53f2\u8bbf\u95ee" // 历史访问
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()
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 si.name == histName {
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
})
// 导航项若均无访问记录(首次/纯外站访问),保持常用服务原手动配置不动
{
anyNav := false
for _, c := range candidates {
if c.visits > 0 {
anyNav = true
break
}
}
if !anyNav {
for _, si := range secs {
if si.name == histName {
si.raw["items"] = []any{map[string]any{
"title": "\u7b49\u5f85\u6d4f\u89c8\u5668\u63d2\u4ef6\u63a5\u5165...",
"description": "\u540c\u6b65\u540e\u6309\u603b\u8bbf\u95ee\u9891\u6b21\u81ea\u52a8\u6392\u5e8f",
"icon": "fas fa-info-circle", "url": "/admin/",
}}
}
}
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
}
}
// 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)
}
// 更新各 section
for _, si := range secs {
if si.name == favName {
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, 20)
for _, e := range entries {
if navHosts[strings.ToLower(hostOf(e.URL))] {
continue // 已是导航项,不重复进历史
}
title := e.Title
if title == "" {
title = e.URL
}
histItems = append(histItems, map[string]any{
"title": title,
"description": fmt.Sprintf("%d\u6b21\u8bbf\u95ee", e.TotalVisits),
"icon": "fas fa-globe", "url": e.URL,
})
if len(histItems) >= 20 {
break
}
}
if len(histItems) == 0 {
histItems = append(histItems, map[string]any{
"title": "\u7b49\u5f85\u6d4f\u89c8\u5668\u63d2\u4ef6\u63a5\u5165...",
"description": "\u540c\u6b65\u540e\u6309\u603b\u8bbf\u95ee\u9891\u6b21\u81ea\u52a8\u6392\u5e8f",
"icon": "fas fa-info-circle", "url": "/admin/",
})
}
for _, si := range secs {
if si.name == histName {
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)
}
func hostOf(rawURL string) string {
if u, err := url.Parse(rawURL); err == nil {
return strings.ToLower(u.Hostname())
}
return ""
}
func visitCount(rawURL string) int {
u, err := url.Parse(rawURL)
if err != nil {
return 0
}
u.Fragment = ""
u.Host = strings.ToLower(u.Host)
key := u.String()
mu.RLock()
defer mu.RUnlock()
if e, ok := history[key]; ok {
return e.TotalVisits
}
return 0
}
func main() {
os.MkdirAll(dataDir, 0755)
load()
http.HandleFunc("/sync", cors(handleSync))
http.HandleFunc("/", cors(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if !requireToken(w, r, syncToken) {
return
}
handleGet(w, r)
case http.MethodDelete:
if !requireToken(w, r, adminToken) {
return
}
handleClear(w, r)
default:
http.Error(w, "method not allowed", 405)
}
}))
http.HandleFunc("/stats", cors(handleStats))
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
log.Println("history-server listening on :3004")
log.Fatal(http.ListenAndServe(":3004", nil))
}
// isCodeHostVal 判断导航项是否代码托管:只认知名托管 host 与标题关键词,
// 避免 "git." 子串误匹配域名中段(如 dify.example.com)。
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
}