diff --git a/applications/models/admin_nav_click.py b/applications/models/admin_nav_click.py index d0fcda8..d5cb2eb 100644 --- a/applications/models/admin_nav_click.py +++ b/applications/models/admin_nav_click.py @@ -3,6 +3,7 @@ 每条记录是用户点了一次某张 Nav 卡片跳出去的事件。 - nav_id 关联 public_nav.id +- anon_id 匿名访客 ID(后端静默下发的 cookie,不关联账号) - day YYYY-MM-DD 日期 - ip 访客 IP(用于去重 / 隐私留存期控制) - clicked_at 点击时间 @@ -16,6 +17,7 @@ class NavClick(db.Model): id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='记录ID') nav_id = db.Column(db.Integer, nullable=False, index=True, comment='被点击的导航ID') + anon_id = db.Column(db.String(36), default='', index=True, comment='匿名访客ID') day = db.Column(db.String(10), default='', index=True, comment='日期 YYYY-MM-DD') ip = db.Column(db.String(64), default='', comment='访客IP') clicked_at = db.Column(db.DateTime, default=datetime.datetime.now, index=True, comment='点击时间') \ No newline at end of file diff --git a/applications/view/public/nav.py b/applications/view/public/nav.py index e2ed7b7..3b492cc 100644 --- a/applications/view/public/nav.py +++ b/applications/view/public/nav.py @@ -15,7 +15,10 @@ from collections import OrderedDict from flask import Blueprint, render_template, request, jsonify from applications.models import Nav, NavCategory -from plugins.siteStats.view.stat import get_nav_visit_counts +from plugins.siteStats.view.stat import get_nav_visit_counts, my_top_navs + +# 「我常访问」冷启动阈值:至少点过这么多个不同网址才展示个人区 +MINE_MIN_ITEMS = 2 bp = Blueprint('public_nav', __name__, url_prefix='/site') @@ -79,6 +82,7 @@ def get_category_meta() -> "dict[str, dict]": def _render_index(): groups = _grouped_navs() total = sum(len(items) for items in groups.values()) + mine = my_top_navs(8) return render_template( 'public/index.html', groups=groups, @@ -86,6 +90,7 @@ def _render_index(): site_name='旺珂 · 导航', cat_meta=_category_meta(), top_navs=_top_navs(12), + my_navs=mine if len(mine) >= MINE_MIN_ITEMS else [], ) diff --git a/plugins/siteStats/view/stat.py b/plugins/siteStats/view/stat.py index ecc0386..3931594 100644 --- a/plugins/siteStats/view/stat.py +++ b/plugins/siteStats/view/stat.py @@ -6,13 +6,14 @@ URL 前缀:/system/stats (与框架原路径一致);埋点用 before_req import datetime import os import time +import uuid from collections import defaultdict -from flask import Blueprint, render_template, request, jsonify +from flask import Blueprint, render_template, request, jsonify, g from applications.common.utils.rights import authorize from applications.extensions import db -from applications.models import VisitLog, PageStat, NavClick +from applications.models import VisitLog, PageStat, NavClick, Nav dir_path = os.path.dirname(os.path.abspath(__file__)) bp = Blueprint( @@ -159,6 +160,59 @@ def record_visit(): db.session.rollback() +# ---------- 匿名访客 ID(方案 C 的服务端一半)---------- +# +# 设计原则:完全无感。访客不需要登录、不需要点任何按钮,后端在第一次响应里 +# 静默下发一个 uuid4 cookie(1 年),之后所有点击都带上它。 +# 前端另有 localStorage 做「秒级」本地累计,两者取 max 合并展示。 + +ANON_COOKIE = 'wk_anon' +ANON_MAX_AGE = 365 * 24 * 3600 # 1 年 +_HEX = set('0123456789abcdef') + + +def _valid_anon_id(s: str) -> bool: + s = (s or '').strip().lower() + return len(s) == 32 and all(c in _HEX for c in s) + + +def current_anon_id(create: bool = True) -> str: + """取当前请求的匿名 ID。 + + - 浏览器已有合法 cookie → 直接复用 + - 没有 → 现场生成 uuid4().hex,并在 after_request 里静默种下 + - create=False:只读取,不生成(用于「清除记录」这类不该凭空造身份的接口) + """ + got = getattr(g, 'anon_id', '') + if got: + return got + aid = (request.cookies.get(ANON_COOKIE) or '').strip() + if not _valid_anon_id(aid): + if not create: + return '' + aid = uuid.uuid4().hex + g.anon_new = True + g.anon_id = aid + return aid + + +@site_bp.after_app_request +def _plant_anon_cookie(resp): + """全站响应后处理:首次访问时把匿名 ID 写回浏览器。""" + try: + if getattr(g, 'anon_new', False) and getattr(g, 'anon_id', ''): + resp.set_cookie( + ANON_COOKIE, g.anon_id, + max_age=ANON_MAX_AGE, + path='/', + httponly=True, + samesite='Lax', + ) + except Exception: + pass + return resp + + # ---------- 导航卡片点击埋点 ---------- # 进程级内存缓存,避免每个聚合页请求都 GROUP BY 一次 @@ -191,7 +245,8 @@ def nav_click(nav_id): ip = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip() or request.remote_addr or '') day = datetime.date.today().strftime('%Y-%m-%d') - db.session.add(NavClick(nav_id=nav_id, day=day, ip=ip[:64])) + anon = current_anon_id() # 没有就现场生成,响应里种 cookie + db.session.add(NavClick(nav_id=nav_id, day=day, ip=ip[:64], anon_id=anon)) db.session.commit() # 让缓存尽快失效(最多延迟 60s 也无所谓,统计本来就不需要实时) global _visit_count_cache_at @@ -200,3 +255,95 @@ def nav_click(nav_id): db.session.rollback() # 204 No Content:无响应体,beacon 友好 return ('', 204) + + +# ---------- 「我常访问」:基于匿名 ID 的个人榜单 ---------- + +_MINE_ROWS = 400 # 每个匿名 ID 最多回溯多少条点击(防止老用户全表扫) + + +def get_my_nav_scores(anon_id: str, limit: int = 8): + """返回 [(nav_id, score, count), ...],按 score 降序。 + + score 带时间衰减:7 天内权重 1.0,30 天内 0.5,更早 0.2。 + 这样「常访问」会随着习惯变化自动换血,不会被半年前的一次点击钉死。 + """ + if not anon_id: + return [] + rows = ( + NavClick.query + .filter(NavClick.anon_id == anon_id) + .order_by(NavClick.clicked_at.desc()) + .limit(_MINE_ROWS) + .all() + ) + now = datetime.datetime.now() + agg = {} + for r in rows: + hit = r.clicked_at or now + try: + age = (now - hit).total_seconds() / 86400.0 + except Exception: + age = 0.0 + if age <= 7: + w = 1.0 + elif age <= 30: + w = 0.5 + else: + w = 0.2 + score, cnt = agg.get(r.nav_id, (0.0, 0)) + agg[r.nav_id] = (score + w, cnt + 1) + out = [(nid, round(s, 2), c) for nid, (s, c) in agg.items()] + out.sort(key=lambda t: (-t[1], t[0])) + return out[:limit] + + +def my_top_navs(limit: int = 8) -> "list[dict]": + """当前访客的常用网址(有效且启用的),供首页服务端渲染。""" + scores = get_my_nav_scores(current_anon_id(), limit) + if not scores: + return [] + nav_ids = [nid for nid, _, _ in scores] + rows = Nav.query.filter(Nav.id.in_(nav_ids), Nav.enable == 1).all() + by_id = {n.id: n.to_dict() for n in rows} + out = [] + for nid, score, cnt in scores: + d = by_id.get(nid) + if not d: + continue + d = dict(d) + d['mine_score'] = score + d['mine_count'] = cnt + out.append(d) + return out + + +@site_bp.get('/site/api/my-top') +def api_my_top(): + """前端异步拉取个人榜单(localStorage 合并用)。""" + try: + limit = max(1, min(12, int(request.args.get('limit', 8)))) + except (TypeError, ValueError): + limit = 8 + aid = current_anon_id(create=False) + return jsonify({ + 'code': 0, + 'msg': '请求成功', + 'data': { + 'anon': bool(aid), + 'items': my_top_navs(limit), + } + }) + + +@site_bp.post('/site/api/my-clear') +def api_my_clear(): + """清除「我」的点击记录(服务端部分;前端同时清 localStorage)。""" + aid = current_anon_id(create=False) + if aid: + try: + NavClick.query.filter(NavClick.anon_id == aid).delete() + db.session.commit() + except Exception: + db.session.rollback() + return ('', 204) diff --git a/scripts/ensure_nav_click_anon_id.py b/scripts/ensure_nav_click_anon_id.py new file mode 100644 index 0000000..cc2ec74 --- /dev/null +++ b/scripts/ensure_nav_click_anon_id.py @@ -0,0 +1,48 @@ +"""一次性脚本:给已有的 site_nav_click 表加 anon_id 列(幂等,可重复执行)。 + +用法: PYTHONPATH=. python scripts/ensure_nav_click_anon_id.py +""" +import os +import sqlite3 + +from app import app +from applications.extensions import db + +with app.app_context(): + uri = str(db.engine.url) + if not uri.startswith('sqlite'): + print('非 SQLite,跳过(MySQL 请用 flask db migrate/upgrade 或手工 ALTER TABLE)') + raise SystemExit(0) + + # 注意:用 engine.url.database,而不是配置里的相对路径(配置可能是 sqlite:///../pear.db) + path = db.engine.url.database or '' + path = os.path.abspath(os.path.normpath(path)) + print('DB:', path) + + conn = sqlite3.connect(path) + cur = conn.cursor() + cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='site_nav_click'") + if not cur.fetchone(): + print('表不存在,交给 db.create_all() 处理') + conn.close() + from applications.extensions import db + from applications.models import NavClick # noqa: F401 + db.create_all() + print('OK: 已建表(含 anon_id)') + raise SystemExit(0) + + cur.execute('PRAGMA table_info(site_nav_click)') + cols = [r[1] for r in cur.fetchall()] + print('现有列:', cols) + + if 'anon_id' in cols: + print('OK: anon_id 已存在,无需变更') + else: + cur.execute("ALTER TABLE site_nav_click ADD COLUMN anon_id VARCHAR(36) DEFAULT ''") + cur.execute('CREATE INDEX IF NOT EXISTS ix_site_nav_click_anon_id ON site_nav_click (anon_id)') + conn.commit() + print('OK: 已添加 anon_id 列 + 索引') + + cur.execute('PRAGMA table_info(site_nav_click)') + print('变更后列:', [r[1] for r in cur.fetchall()]) + conn.close() diff --git a/scripts/verify_mine_section.py b/scripts/verify_mine_section.py new file mode 100644 index 0000000..e43cc4e --- /dev/null +++ b/scripts/verify_mine_section.py @@ -0,0 +1,63 @@ +"""验证「我常访问」:服务端匿名榜 + localStorage 合并 + 清除按钮。 + +用法:先启动 Flask(python app.py),再 PYTHONPATH=. python scripts/verify_mine_section.py +""" +from playwright.sync_api import sync_playwright + +BASE = 'http://127.0.0.1:5000' +LS_KEY = 'wk_mine_clicks' + +with sync_playwright() as p: + browser = p.chromium.launch() + ctx = browser.new_context(viewport={'width': 1280, 'height': 900}) + page = ctx.new_page() + + # ---- 1) 全新访客:没有点击记录,不该出现个人区 ---- + page.goto(BASE + '/site/', wait_until='networkidle') + print('1) 新访客 #cat-mine 存在:', page.locator('#cat-mine').count()) + anon = [c for c in ctx.cookies() if c['name'] == 'wk_anon'] + print(' 匿名 cookie:', (anon[0]['value'][:8] + '...') if anon else '无') + + # 取页面上真实存在的几个 nav id + ids = page.eval_on_selector_all( + '.section:not(#cat-mine) .card[data-nav-id]', + 'els => els.slice(0, 5).map(e => e.getAttribute("data-nav-id"))' + ) + print(' 可用 nav id:', ids) + + # ---- 2) 只写 localStorage(模拟 cookie 被禁 / 服务端还没同步)---- + page.evaluate( + """([k, a, b]) => localStorage.setItem(k, JSON.stringify({[a]: 4, [b]: 2}))""", + [LS_KEY, ids[0], ids[1]] + ) + page.reload(wait_until='networkidle') + n = page.locator('#cat-mine .card').count() + print('2) localStorage 驱动后 #cat-mine 卡片数:', n) + print(' 顶部导航入口:', page.locator('.cat-nav-mine').count(), + '| 抽屉入口:', page.locator('.cat-drawer-mine').count()) + if n: + print(' 首卡文案:', page.locator('#cat-mine .card').first.inner_text().replace('\n', ' | ')[:90]) + + # ---- 3) 只点 1 个网址:冷启动阈值,不该展示 ---- + page.evaluate("k => localStorage.setItem(k, JSON.stringify({}))", LS_KEY) + page.reload(wait_until='networkidle') + print('3) 清空 localStorage 后 #cat-mine 可见:', + page.locator('#cat-mine').count() and page.locator('#cat-mine').is_visible()) + + # ---- 4) 清除按钮 ---- + page.evaluate( + """([k, a, b]) => localStorage.setItem(k, JSON.stringify({[a]: 3, [b]: 1}))""", + [LS_KEY, ids[0], ids[1]] + ) + page.reload(wait_until='networkidle') + print('4) 清除前 #cat-mine 数:', page.locator('#cat-mine').count()) + clears = [] + page.on('request', lambda r: clears.append(r.url) if '/site/api/my-clear' in r.url else None) + page.locator('#clear-mine').first.click() + page.wait_for_timeout(600) + print(' 清除后 #cat-mine 数:', page.locator('#cat-mine').count(), + '| localStorage:', page.evaluate("k => localStorage.getItem(k)", LS_KEY), + '| 请求:', clears) + + browser.close() +print('DONE') diff --git a/templates/public/base.html b/templates/public/base.html index 9632571..7e8d50f 100644 --- a/templates/public/base.html +++ b/templates/public/base.html @@ -422,6 +422,31 @@ font-weight: 600; } + /* ===== 我常访问(个人榜):紫色系,与热门的橙色区分 ===== */ + .section-mine { margin-top: 8px; } + .section-mine .section-header h2 i { color: #8b5cf6; margin-right: 2px; } + .section-mine .section-header .meta a { color: var(--text-muted); text-decoration: underline; } + .section-mine .section-header .meta a:hover { color: #8b5cf6; } + .card-mine { + border-color: rgba(139, 92, 246, 0.35); + background: linear-gradient(135deg, var(--card-bg) 0%, rgba(139, 92, 246, 0.06) 100%); + position: relative; + } + .card-mine::before { + content: ''; + position: absolute; top: 0; left: 0; + width: 3px; height: 100%; + background: #8b5cf6; + border-radius: 10px 0 0 10px; + } + .card-mine:hover { + border-color: #8b5cf6; + box-shadow: 0 6px 20px rgba(139, 92, 246, 0.18); + } + .card-mine .visit-count { color: #8b5cf6; } + .cat-nav-inner a.cat-nav-mine { color: #8b5cf6; font-weight: 600; } + .cat-drawer-list a.cat-drawer-mine { color: #8b5cf6; font-weight: 600; } + .card .icon-wrap { width: 42px; height: 42px; background: rgba(22, 186, 170, 0.1); @@ -780,7 +805,7 @@ @@ -796,6 +821,22 @@ + {# 我常访问入口(仅首页,且必须传了 my_navs 才渲染)#} + {% if my_navs is defined and my_navs %} +
+
我的
+ +
+ {% endif %} + {# 热门网址入口(仅首页,且首页必须传 top_navs 才渲染)#} {% if top_navs is defined and top_navs %}
@@ -1071,6 +1112,8 @@ card.addEventListener('click', function(){ var navId = card.getAttribute('data-nav-id'); if (!navId) return; + // 本机 localStorage 立即 +1,「我常访问」下次进页面就生效 + try { if (window.__wkMineBump) window.__wkMineBump(navId); } catch(e) {} try { if (navigator.sendBeacon) { navigator.sendBeacon('/site/nav/' + navId + '/click'); @@ -1084,6 +1127,8 @@ function bindAll(){ document.querySelectorAll('.card[data-nav-id]').forEach(bindNavClick); } + window.__wkBindNavClick = bindNavClick; + window.__wkBindNavAll = bindAll; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', bindAll); } else { @@ -1091,6 +1136,154 @@ } })(); + // ===== 「我常访问」:localStorage 本地累计 + 服务端匿名榜合并 ===== + (function(){ + var KEY = 'wk_mine_clicks'; + var MIN_ITEMS = 2; // 冷启动:至少点过 2 个不同网址才展示 + var MAX_ITEMS = 8; + + function load(){ + try { return JSON.parse(localStorage.getItem(KEY) || '{}') || {}; } + catch(e) { return {}; } + } + function save(m){ + try { localStorage.setItem(KEY, JSON.stringify(m)); } catch(e) {} + } + window.__wkMineBump = function(navId){ + if (!navId) return; + var m = load(); + m[navId] = (m[navId] || 0) + 1; + save(m); + }; + + function findSourceCard(id){ + var all = document.querySelectorAll('.card[data-nav-id="' + id + '"]'); + for (var i = 0; i < all.length; i++) { + if (!all[i].closest('#cat-mine')) return all[i]; + } + return null; + } + + function addCatNavEntry(){ + var inner = document.getElementById('cat-nav-inner'); + if (!inner || inner.querySelector('.cat-nav-mine')) return; + var a = document.createElement('a'); + a.href = '#cat-mine'; + a.className = 'cat-nav-mine'; + a.textContent = '★ 我常访问'; + inner.insertBefore(a, inner.firstChild); + } + + function addDrawerEntry(){ + var panel = document.querySelector('.cat-drawer-panel'); + if (!panel || panel.querySelector('.cat-drawer-mine')) return; + var sec = document.createElement('div'); + sec.className = 'cat-drawer-section'; + sec.innerHTML = + '
我的
' + + ''; + panel.insertBefore(sec, panel.children[1] || null); + } + + function removeEntries(){ + var n = document.querySelector('.cat-nav-mine'); + if (n && n.parentNode) n.parentNode.removeChild(n); + var d = document.querySelector('.cat-drawer-mine'); + if (d) { + var s = d.closest('.cat-drawer-section'); + if (s && s.parentNode) s.parentNode.removeChild(s); + } + } + + function render(){ + var main = document.querySelector('main.container'); + if (!main) return; + + var local = load(); + var score = {}; + + // 1) 服务端已经渲染出来的个人卡片(带 data-mine 加权分) + var sec = document.getElementById('cat-mine'); + if (sec) { + sec.querySelectorAll('.card[data-nav-id]').forEach(function(c){ + var id = c.getAttribute('data-nav-id'); + var v = parseFloat(c.getAttribute('data-mine') || '0') || 0; + if (v > (score[id] || 0)) score[id] = v; + }); + } + // 2) 本机 localStorage(含服务端还没同步到的点击) + Object.keys(local).forEach(function(id){ + var v = local[id] | 0; + if (v > (score[id] || 0)) score[id] = v; + }); + + var ranked = Object.keys(score) + .filter(function(id){ return (score[id] || 0) > 0; }) + .sort(function(a, b){ return (score[b] - score[a]) || (a - b); }) + .slice(0, MAX_ITEMS); + + if (ranked.length < MIN_ITEMS) { + if (sec) sec.style.display = 'none'; + return; + } + + if (!sec) { + sec = document.createElement('section'); + sec.className = 'section section-mine'; + sec.id = 'cat-mine'; + sec.innerHTML = + '
' + + '

我常访问

' + + '
只在这台设备上统计 · 清除
' + + '
'; + main.insertBefore(sec, main.firstChild); + addCatNavEntry(); + addDrawerEntry(); + } + sec.style.display = ''; + + var grid = sec.querySelector('.grid'); + grid.innerHTML = ''; + ranked.forEach(function(id){ + var src = findSourceCard(id); + if (!src) return; + var clone = src.cloneNode(true); + clone.classList.add('card-mine'); + clone.classList.remove('card-top'); + clone.setAttribute('data-mine', score[id]); + var vc = clone.querySelector('.visit-count'); + if (vc) vc.textContent = '我点过 ' + Math.round(score[id]) + ' 次'; + grid.appendChild(clone); + if (window.__wkBindNavClick) window.__wkBindNavClick(clone); + }); + } + + // 清除我的记录:清 localStorage + 通知服务端删匿名点击 + document.addEventListener('click', function(e){ + var t = e.target; + if (!t) return; + if (t.id !== 'clear-mine' && !(t.closest && t.closest('#clear-mine'))) return; + e.preventDefault(); + try { localStorage.removeItem(KEY); } catch(err) {} + try { + if (navigator.sendBeacon) navigator.sendBeacon('/site/api/my-clear'); + else fetch('/site/api/my-clear', {method:'POST', keepalive:true, credentials:'same-origin'}); + } catch(err) {} + removeEntries(); + var sec = document.getElementById('cat-mine'); + if (sec && sec.parentNode) sec.parentNode.removeChild(sec); + }); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', render); + } else { + render(); + } + })(); + // ===== 全局搜索:前端实时过滤卡片,并高亮分类 Tab ===== (function(){ var form = document.getElementById('site-search'); diff --git a/templates/public/index.html b/templates/public/index.html index da27b5e..e57fd4f 100644 --- a/templates/public/index.html +++ b/templates/public/index.html @@ -1,10 +1,45 @@ {% extends 'public/base.html' %} +{# 单张导航卡片:热门 / 个人 / 普通分类共用 #} +{% macro nav_card(item, cls='', mine=None) %} + +
+ +
+
+
+
{{ item.title }}
+ {% if item.is_external %} + + {% endif %} +
+
{{ item.description or '暂无描述' }}
+
{{ item.url }}
+
+ {%- if mine is not none -%} + 我点过 {{ item.mine_count or 0 }} 次 + {%- else -%} + {%- set vc = item.visit_count or 0 -%} + {%- if vc >= 3 -%}访问 {{ '{:,}'.format(vc) }} + {%- else -%}新 + {%- endif -%} + {%- endif -%} +
+
+
+
+{% endmacro %} + {# 分类目录条(sticky 顶部导航) #} {% block cat_nav %} -{% if groups or top_navs %} +{% if groups or top_navs or my_navs %}