Files
HomeDash/history-server/main.go
JianFeeeee ff0e6418f8 feat(nav): auto-detect browser extension and offer one-click install
导航页现在会自动检测浏览历史插件是否已安装:

- 扩展在用户配置的服务地址上动态注册 content script
  (Chrome: chrome.scripting.registerContentScripts,
   Firefox: browser.contentScripts.register),
  于 document_start 给 <html> 打上 data-navi-ext 标记,
  不在其他站点运行。
- history-server 始终把「安装浏览历史插件」放在「历史访问」第一条
  (入口 url=/extension/install.html),启动时也会重建一次,
  不再需要等下一次同步。
- web/navi.js 读到标记后隐藏该入口(连 .item-wrapper 一起隐藏,
  避免留下空网格位)。
- 新增 /extension/install.html:自动识别浏览器,给出对应安装包与步骤;
  支持在 data 卷放 extension-store.json 配置商店链接,
  有商店链接时显示真正的「一键安装」。
- 段落名匹配同时兼容中英文(常用服务/Favorites、历史访问/History),
  修掉了公开示例 conf.yml 用英文名时重建逻辑完全失效的问题。
- 扩展升到 1.1.0(新增 scripting 权限与 detect.js)。

Note: Chrome/Edge 不允许从网站直接安装非商店扩展,
因此自托管场景下只能下载后手动加载;发布到商店后才会出现一键安装。
2026-09-11 10:41:10 +08:00

607 lines
17 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
}
// 段落名兼容中英文:公开示例 conf.yml 用英文名,生产实例用中文名。
const (
favSectionZh = "\u5e38\u7528\u670d\u52a1" // 常用服务
favSectionEn = "Favorites"
histSectionZh = "\u5386\u53f2\u8bbf\u95ee" // 历史访问
histSectionEn = "History"
)
// extensionInstallURL 是「一键安装浏览历史插件」入口。
// 服务端始终把它放在「历史访问」第一条;已装插件的浏览器由 navi.js 隐藏。
const extensionInstallURL = "/extension/install.html"
func isFavSection(name string) bool {
return name == favSectionZh || strings.EqualFold(name, favSectionEn)
}
func isHistSection(name string) bool {
return name == histSectionZh || strings.EqualFold(name, histSectionEn)
}
// extensionInstallItem 返回「历史访问」首条的插件安装入口。
func extensionInstallItem() map[string]any {
return map[string]any{
"title": "\u5b89\u88c5\u6d4f\u89c8\u5386\u53f2\u63d2\u4ef6",
"description": "\u4e00\u952e\u5b89\u88c5\uff0c\u81ea\u52a8\u6309\u8bbf\u95ee\u9891\u6b21\u6392\u5e8f",
"icon": "fas fa-download",
"url": extensionInstallURL,
}
}
// 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
}
// 段落名匹配见 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()
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
})
// 导航项若均无访问记录(首次/纯外站访问),保持常用服务原手动配置不动
{
anyNav := false
for _, c := range candidates {
if c.visits > 0 {
anyNav = true
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
}
}
// 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 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
}
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) >= 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)
}
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()
// 启动时重建一次:让「历史访问」首条的插件安装入口立即生效,
// 而不必等到下一次浏览器插件同步。
rebuildDashySections()
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." 子串误匹配域名中段(如 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
}