diff --git a/.gitignore b/.gitignore index 5264240..a00ba2e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,7 @@ gateway/navi-gateway .DS_Store Thumbs.db .codegraph/ + +# Go build output +history-server/history-server +admin-server/admin-server diff --git a/Dockerfile b/Dockerfile index b96e6a4..5fb7da0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,7 +35,19 @@ RUN git clone --depth 1 https://github.com/searxng/searxng.git /src/searxng \ && /opt/searxng-venv/bin/pip install --no-cache-dir --no-build-isolation . \ && rm -rf /src/searxng -# ── Stage 3: Runtime ── +# ── Stage 3: browser extension packages ── +# 供 /extension/install.html 下载(Chromium 用 zip,Firefox 用 xpi) +FROM debian:bookworm-slim AS extpack + +RUN apt-get update && apt-get install -y --no-install-recommends zip ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY extension/chrome /src/chrome +COPY extension/firefox /src/firefox +RUN cd /src/chrome && zip -qr /out/navi-chrome.zip . \ + && cd /src/firefox && zip -qr /out/navi-firefox.xpi . + +# ── Stage 4: Runtime ── FROM debian:bookworm-slim ENV DEBIAN_FRONTEND=noninteractive @@ -64,6 +76,11 @@ COPY searxng-settings.yml /etc/searxng/settings.yml COPY conf.example.yml /app/defaults/conf.yml COPY admin/index.html /app/admin/index.html +# 浏览器扩展安装页与安装包(通过 /extension/ 访问) +COPY web/extension/install.html /app/dashy/extension/install.html +COPY --from=extpack /out/navi-chrome.zip /app/dashy/extension/navi-chrome.zip +COPY --from=extpack /out/navi-firefox.xpi /app/dashy/extension/navi-firefox.xpi + # Inject search script into Dashy COPY web/navi.js /app/dashy/navi.js RUN sed -i 's###' /app/dashy/index.html diff --git a/README.md b/README.md index 8dde9e0..431394e 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,32 @@ icon: fas fa-star # Font Awesome ## 浏览器扩展 -### 安装 +### 安装(两种方式) + +**方式 1:导航页内一键引导(推荐)** + +导航页会自动检测插件是否已安装: + +- 未安装时,「历史访问」**第一条**固定显示「安装浏览历史插件」入口; +- 点击后打开 `/extension/install.html`,**自动识别浏览器**并给出对应安装步骤与安装包; +- 已安装时,`/navi.js` 读到插件写入的 `data-navi-ext` 标记,把该入口自动隐藏。 + +检测原理:插件设置里填了服务地址后,会向**该站点的域名**动态注册一个 content script +(Chrome 用 `chrome.scripting.registerContentScripts`,Firefox 用 `browser.contentScripts.register`), +在 `document_start` 给 `` 打上 `data-navi-ext`。不会在其他站点上运行。 + +> Chrome / Edge **不允许**从网站直接安装非商店扩展,所以这一步只能下载后手动加载; +> 若你已把扩展发布到商店,把链接写进 `.env` 或 data 卷的 `extension-store.json`, +> 安装页就会出现真正的「从商店一键安装」按钮: +> +> ```json +> { "chrome": "https://chrome.google.com/webstore/detail/", +> "firefox": "https://addons.mozilla.org/firefox/addon//" } +> ``` +> +> 放到 `data/extension-store.json` 即可,无需重建镜像。 + +**方式 2:手动加载源码目录** **Chrome / Edge**:`chrome://extensions/` → 开发者模式 → 加载 `extension/chrome/` @@ -472,7 +497,7 @@ data/ ├── history-server/ # Go 历史同步 API (:3004) ├── admin-server/ # Go 管理后台 API (:3005) ├── admin/ # 管理后台前端 -├── web/ # Dashy 注入脚本 +├── web/ # Dashy 注入脚本 + 扩展安装引导页 ├── extension/ # 浏览器扩展 (Chrome/Firefox) ├── nginx.conf # 容器内统一反代 ├── supervisord.conf # 进程管理 @@ -501,6 +526,14 @@ data/ **历史不显示**:检查扩展配置中的服务地址和同步令牌。 +**「安装浏览历史插件」入口不消失**:该入口由插件写入的 `data-navi-ext` 标记驱动隐藏。 + +- 确认扩展已启用,且设置里的服务地址**主机名**与当前访问地址一致 + (例如填 `https://nav.example.com`、却用 `http://192.168.1.100:3003` 访问,就检测不到)。 +- 改完设置请刷新页面。Chrome 若因新增 `scripting` 权限把扩展置为待确认, + 需在 `chrome://extensions` 重新启用。 +- 检测失败只会多显示一个入口,不影响同步功能。 + **内存占用**:正常约 100MB。 ## 许可 diff --git a/conf.example.yml b/conf.example.yml index efa8c4f..1f89c0d 100644 --- a/conf.example.yml +++ b/conf.example.yml @@ -22,8 +22,9 @@ pageInfo: footer: "" sections: - - name: Code Hosting - icon: fas fa-code-branch + # 名称必须是 Favorites / 常用服务 —— 服务端会把访问量最高的 4 个导航写到这里 + - name: Favorites + icon: fas fa-star collapsed: false items: - title: Gitea @@ -32,14 +33,17 @@ sections: url: http://192.168.1.100:3000 statusCheck: true + # 名称必须是 History / 历史访问 —— + # 服务端会把插件安装入口固定放在第一条,其余按访问频次填充。 + # 插件已安装的浏览器由 /navi.js 自动隐藏该入口。 - name: History icon: fas fa-history collapsed: false items: - - title: Install browser extension... - description: History fills automatically after sync - icon: fas fa-info-circle - url: /admin/ + - title: 安装浏览历史插件 + description: 一键安装,自动按访问频次排序 + icon: fas fa-download + url: /extension/install.html - name: Tools icon: fas fa-tools diff --git a/extension/chrome/background.js b/extension/chrome/background.js index c7422bb..70f62d5 100644 --- a/extension/chrome/background.js +++ b/extension/chrome/background.js @@ -29,15 +29,59 @@ const SEARCH_HOSTS = new Set([ let lastSyncTime = 0; let pendingItems = []; +// ── 检测标记:只向用户配置的导航站注入 content script ── +// 已安装的浏览器上,导航站会拿到 data-navi-ext 属性,从而隐藏安装入口。 +const DETECT_SCRIPT_ID = 'navi-detect'; +const DETECT_FILE = 'detect.js'; + +function hostMatchPattern(serverUrl) { + try { + const host = new URL(serverUrl).hostname; + return host ? '*://' + host + '/*' : null; + } catch (e) { + return null; + } +} + +async function syncDetectScript(serverUrl) { + if (!chrome.scripting || !chrome.scripting.registerContentScripts) return; + try { + const existing = await chrome.scripting.getRegisteredContentScripts({ ids: [DETECT_SCRIPT_ID] }); + if (existing && existing.length) { + await chrome.scripting.unregisterContentScripts({ ids: [DETECT_SCRIPT_ID] }); + } + } catch (e) { /* 从未注册过 */ } + + const pattern = hostMatchPattern(serverUrl); + if (!pattern) return; + try { + await chrome.scripting.registerContentScripts([{ + id: DETECT_SCRIPT_ID, + matches: [pattern], + js: [DETECT_FILE], + runAt: 'document_start', + allFrames: false, + persistAcrossSessions: true, + }]); + } catch (e) { + // 静默降级:未装扩展时只是多显示一个安装入口,不影响同步 + } +} + // ── 初始化 ── chrome.runtime.onInstalled.addListener(() => { ensureAlarm(); - chrome.storage.local.get(['pending', 'lastSyncTime'], (data) => { + chrome.storage.local.get(['pending', 'lastSyncTime', 'serverUrl'], (data) => { pendingItems = data.pending || []; lastSyncTime = data.lastSyncTime || 0; + syncDetectScript(data.serverUrl); }); }); +chrome.runtime.onStartup.addListener(() => { + chrome.storage.local.get(['serverUrl'], (d) => syncDetectScript(d.serverUrl)); +}); + function ensureAlarm() { chrome.alarms.get(ALARM_NAME, (alarm) => { if (!alarm) chrome.alarms.create(ALARM_NAME, { periodInMinutes: SYNC_MINUTES }); @@ -115,7 +159,11 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return true; } if (msg && msg.action === 'setConfig') { - chrome.storage.local.set(msg.data || {}, () => sendResponse({ ok: true })); + const data = msg.data || {}; + chrome.storage.local.set(data, () => { + syncDetectScript(data.serverUrl); + sendResponse({ ok: true }); + }); return true; } if (msg && msg.action === 'clearPending') { diff --git a/extension/chrome/detect.js b/extension/chrome/detect.js new file mode 100644 index 0000000..9631903 --- /dev/null +++ b/extension/chrome/detect.js @@ -0,0 +1,20 @@ +// JianF Nav Sync — 检测标记(content script) +// +// 由 background 动态注册到「用户在设置里填写的导航站」上, +// 不会在其他网站上运行。 +// +// 唯一职责:在 document_start 阶段给 打上 data-navi-ext 属性。 +// 导航站的 /navi.js 读到该属性就隐藏「安装浏览历史插件」入口。 +'use strict'; + +try { + let version = '1'; + try { + if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.getManifest) { + version = chrome.runtime.getManifest().version || version; + } + } catch (e) { /* 忽略:版本号只用于展示 */ } + document.documentElement.setAttribute('data-navi-ext', version); +} catch (e) { + /* document_start 下 documentElement 一定存在;出错也只是少一个标记 */ +} diff --git a/extension/chrome/manifest.json b/extension/chrome/manifest.json index 14e38e0..df41f8e 100644 --- a/extension/chrome/manifest.json +++ b/extension/chrome/manifest.json @@ -1,12 +1,13 @@ { "manifest_version": 3, "name": "JianF Nav Sync", - "version": "1.0.0", + "version": "1.1.0", "description": "同步浏览历史到自建导航门户,按访问频次自动排序显示常用服务", "permissions": [ "history", "storage", - "alarms" + "alarms", + "scripting" ], "host_permissions": [ "" diff --git a/extension/firefox/background.js b/extension/firefox/background.js index 5232eda..164235e 100644 --- a/extension/firefox/background.js +++ b/extension/firefox/background.js @@ -29,6 +29,39 @@ const SEARCH_HOSTS = new Set([ let lastSyncTime = 0; let pendingItems = []; +// ── 检测标记:只向用户配置的导航站注册 content script ── +// 已安装的浏览器上,导航站会拿到 data-navi-ext 属性,从而隐藏安装入口。 +let detectRegistration = null; + +function hostMatchPattern(serverUrl) { + try { + const host = new URL(serverUrl).hostname; + return host ? '*://' + host + '/*' : null; + } catch (e) { + return null; + } +} + +async function syncDetectScript(serverUrl) { + if (detectRegistration) { + try { await detectRegistration.unregister(); } catch (e) { /* 已失效 */ } + detectRegistration = null; + } + const pattern = hostMatchPattern(serverUrl); + if (!pattern) return; + if (!browser.contentScripts || !browser.contentScripts.register) return; + try { + detectRegistration = await browser.contentScripts.register({ + matches: [pattern], + js: [{ file: 'detect.js' }], + runAt: 'document_start', + allFrames: false, + }); + } catch (e) { + // 静默降级:未装扩展时只是多显示一个安装入口,不影响同步 + } +} + function ensureAlarm() { browser.alarms.get(ALARM_NAME, (alarm) => { if (!alarm) browser.alarms.create(ALARM_NAME, { periodInMinutes: SYNC_MINUTES }); @@ -37,12 +70,17 @@ function ensureAlarm() { browser.runtime.onInstalled.addListener(() => { ensureAlarm(); - browser.storage.local.get(['pending', 'lastSyncTime'], (data) => { + browser.storage.local.get(['pending', 'lastSyncTime', 'serverUrl'], (data) => { pendingItems = data.pending || []; lastSyncTime = data.lastSyncTime || 0; + syncDetectScript(data.serverUrl); }); }); +browser.runtime.onStartup.addListener(() => { + browser.storage.local.get(['serverUrl'], (d) => syncDetectScript(d.serverUrl)); +}); + function isJunk(url) { try { const u = new URL(url); @@ -102,7 +140,11 @@ browser.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return true; } if (msg && msg.action === 'setConfig') { - browser.storage.local.set(msg.data || {}, () => sendResponse({ ok: true })); + const data = msg.data || {}; + browser.storage.local.set(data, () => { + syncDetectScript(data.serverUrl); + sendResponse({ ok: true }); + }); return true; } if (msg && msg.action === 'clearPending') { diff --git a/extension/firefox/detect.js b/extension/firefox/detect.js new file mode 100644 index 0000000..a89aa76 --- /dev/null +++ b/extension/firefox/detect.js @@ -0,0 +1,20 @@ +// JianF Nav Sync — 检测标记(content script) +// +// 由 background 通过 browser.contentScripts.register 动态注册到 +// 「用户在设置里填写的导航站」上,不会在其他网站上运行。 +// +// 唯一职责:在 document_start 阶段给 打上 data-navi-ext 属性。 +// 导航站的 /navi.js 读到该属性就隐藏「安装浏览历史插件」入口。 +'use strict'; + +try { + let version = '1'; + try { + if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.getManifest) { + version = chrome.runtime.getManifest().version || version; + } + } catch (e) { /* 忽略:版本号只用于展示 */ } + document.documentElement.setAttribute('data-navi-ext', version); +} catch (e) { + /* document_start 下 documentElement 一定存在;出错也只是少一个标记 */ +} diff --git a/extension/firefox/manifest.json b/extension/firefox/manifest.json index 3fbfc71..b0ca0e0 100644 --- a/extension/firefox/manifest.json +++ b/extension/firefox/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "JianF Nav Sync", - "version": "1.0.0", + "version": "1.1.0", "description": "同步浏览历史到自建导航门户,按访问频次自动排序显示常用服务", "permissions": [ "history", @@ -10,8 +10,10 @@ "" ], "background": { - "scripts": ["background.js"], - "persistent": false + "scripts": [ + "background.js" + ], + "persistent": true }, "browser_action": { "default_title": "JianF Nav Sync 设置", diff --git a/history-server/main.go b/history-server/main.go index 48e6ba0..8c3168c 100644 --- a/history-server/main.go +++ b/history-server/main.go @@ -255,6 +255,36 @@ func hostVisits() map[string]int { 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 的累计访问统计 @@ -277,9 +307,7 @@ func rebuildDashySections() { return } - const favName = "\u5e38\u7528\u670d\u52a1" // 常用服务 - const histName = "\u5386\u53f2\u8bbf\u95ee" // 历史访问 - + // 段落名匹配见 isFavSection / isHistSection(兼容中英文) type secInfo struct { raw map[string]any name string @@ -330,10 +358,9 @@ func rebuildDashySections() { if host != "" { v = hv[host] } - if si.name == histName { + if isHistSection(si.name) { continue - } - // 所有导航项(含常用服务原本手动配置的)都进候选, + } // 所有导航项(含常用服务原本手动配置的)都进候选, // 由访问量 + code-host 置顶决定最终 Top4 candidates = append(candidates, cand{ nav: dashyNav{title: title, desc: desc, icon: icon, url: u}, @@ -357,13 +384,11 @@ func rebuildDashySections() { } } if !anyNav { + // 尚无导航访问数据:保持「常用服务」的手工配置不动, + // 「历史访问」只放插件安装入口(装好后由 navi.js 隐藏)。 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/", - }} + if isHistSection(si.name) { + si.raw["items"] = []any{extensionInstallItem()} } } out, err := yaml.Marshal(cfg) @@ -423,7 +448,7 @@ func rebuildDashySections() { // 更新各 section for _, si := range secs { - if si.name == favName { + if isFavSection(si.name) { newItems := make([]any, 0, len(finalNavs)) for _, n := range finalNavs { it := map[string]any{ @@ -461,7 +486,10 @@ func rebuildDashySections() { sort.SliceStable(entries, func(i, j int) bool { return entries[i].TotalVisits > entries[j].TotalVisits }) - histItems := make([]any, 0, 20) + 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 // 已是导航项,不重复进历史 @@ -475,19 +503,12 @@ func rebuildDashySections() { "description": fmt.Sprintf("%d\u6b21\u8bbf\u95ee", e.TotalVisits), "icon": "fas fa-globe", "url": e.URL, }) - if len(histItems) >= 20 { + if len(histItems) >= 21 { 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 { + if isHistSection(si.name) { si.raw["items"] = histItems } } @@ -531,6 +552,9 @@ func visitCount(rawURL string) int { func main() { os.MkdirAll(dataDir, 0755) load() + // 启动时重建一次:让「历史访问」首条的插件安装入口立即生效, + // 而不必等到下一次浏览器插件同步。 + rebuildDashySections() http.HandleFunc("/sync", cors(handleSync)) http.HandleFunc("/", cors(func(w http.ResponseWriter, r *http.Request) { diff --git a/nginx.conf b/nginx.conf index a2de710..2291603 100644 --- a/nginx.conf +++ b/nginx.conf @@ -74,6 +74,13 @@ http { alias /app/data/conf.yml; } + # 可选的扩展商店链接:部署方把 extension-store.json 放进 data 卷即可, + # /extension/install.html 会读取它并显示「一键安装」按钮。 + location = /extension/store.json { + alias /app/data/extension-store.json; + add_header Cache-Control "no-store"; + } + # item-icons 从 data volume 提供 location /item-icons/ { alias /app/data/item-icons/; diff --git a/web/extension/install.html b/web/extension/install.html new file mode 100644 index 0000000..3c673db --- /dev/null +++ b/web/extension/install.html @@ -0,0 +1,150 @@ + + + + + + +安装浏览历史插件 + + + +
+

安装浏览历史插件

+

安装后浏览记录会自动同步到本站,并据此把最常访问的服务排到「常用服务」最前面。

+ +
+

第一步:填写本站地址

+

在插件设置里粘贴下面这个地址(点「复制」)。同步令牌从部署方的 NAVI_SYNC_TOKEN 环境变量获取。

+
+ + +
+ +
+ + + + + + + +
+

装好后

+
    +
  1. 回到导航页并刷新,本次会话浏览几条页面
  2. +
  3. 约 30 秒后历史会自动上报,「历史访问」开始按访问频次排序
  4. +
  5. 检测到插件已安装后,本入口会自动隐藏
  6. +
+
+
+ + + + diff --git a/web/navi.js b/web/navi.js index 2da54e3..8ba2249 100644 --- a/web/navi.js +++ b/web/navi.js @@ -1,6 +1,24 @@ (() => { 'use strict'; + // 浏览器插件在 document_start 阶段写入这个属性(见 extension/*/detect.js)。 + const EXT_ATTR = 'data-navi-ext'; + const INSTALL_SELECTOR = 'a[href*="/extension/install"]'; + + function extensionInstalled() { + return document.documentElement.hasAttribute(EXT_ATTR); + } + + // 已装插件的浏览器不需要安装入口:连外层网格单元一起隐藏, + // 否则 .item-wrapper 会留下一个空位。 + function hideInstallEntry() { + if (!extensionInstalled()) return; + document.querySelectorAll(INSTALL_SELECTOR).forEach((link) => { + const cell = link.closest('.item-wrapper') || link.parentElement || link; + if (cell.style.display !== 'none') cell.style.display = 'none'; + }); + } + function mountSearch() { if (document.querySelector('.navi-search-bar')) return; const form = document.createElement('form'); @@ -30,10 +48,15 @@ document.body.appendChild(form); } - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', mountSearch, { once: true }); - } else { + function run() { mountSearch(); + hideInstallEntry(); } - new MutationObserver(mountSearch).observe(document.documentElement, { childList: true, subtree: true }); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', run, { once: true }); + } else { + run(); + } + new MutationObserver(run).observe(document.documentElement, { childList: true, subtree: true }); })();