""" 前台导航页视图(无需登录)。 URL 路径(挂在根路径下,便于匿名访问): - GET /site 公开导航聚合首页(按分类展示所有启用的导航) - GET /site/category/ 分类详情:展示指定分类下所有启用的导航 - GET /site/api/navs JSON 接口(供前端动态加载) 注意: - 这些视图**不加** @authorize,避免 login_required 强制登录。 - 使用 url_prefix='/site' 避免与后台 / 路由冲突(后台 / 是登录后的工作台)。 """ from collections import OrderedDict import time import os import re import threading import urllib.request import urllib.error from flask import Blueprint, render_template, request, jsonify, current_app, send_file from applications.models import Nav, NavCategory 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') # 分类列表缓存(左侧侧栏 / 移动端抽屉共用,避免每个请求都查库) _NAV_CATS_CACHE = {'ts': 0.0, 'data': []} _NAV_CATS_TTL = 30.0 def get_nav_categories() -> "list[dict]": """返回前台导航用的分类列表(按后台 sort 排序,带稳定锚点)。 返回形如:: [{'name': '开发工具', 'icon': 'layui-icon-list', 'description': '', 'anchor': 'cat-1'}, ...] 锚点 ``anchor`` 基于「启用分类」的排序索引生成(从 1 开始),与首页 各 ``
`` 的 id 严格一致——即使某个分类下暂时没有导航(空分组 被过滤)也不会错位。 带 30s 进程内缓存:后台新增/调整分类后最多 30s 生效。 """ now = time.time() cached = _NAV_CATS_CACHE.get('data') or [] if cached and (now - _NAV_CATS_CACHE.get('ts', 0.0)) < _NAV_CATS_TTL: return cached try: cats = ( NavCategory.query .filter(NavCategory.enable == 1) .order_by(NavCategory.sort.desc(), NavCategory.id.asc()) .all() ) names = [c.name for c in cats] meta = {c.name: (c.icon, c.description) for c in cats} # 兜底:只在 Nav 表里出现、未登记到 NavCategory 的历史分类, # 追加到末尾(与 _grouped_navs 的兜底顺序一致:按分类名升序), # 保证侧栏锚点与首页 section 一一对应。 used = Nav.query.filter(Nav.enable == 1).with_entities(Nav.category).distinct().all() names.extend(sorted(n for (n,) in used if n and n not in meta)) data = [] for idx, name in enumerate(names): icon, desc = meta.get(name, (None, None)) data.append({ 'name': name, 'icon': icon or 'layui-icon-list', 'description': desc or '', 'anchor': 'cat-%d' % (idx + 1), }) except Exception: # 数据库尚未初始化(如迁移前)时不影响页面渲染 data = [] _NAV_CATS_CACHE['ts'] = now _NAV_CATS_CACHE['data'] = data return data def get_cat_anchor_map() -> "dict[str, str]": """分类名 -> 锚点 id 的映射,供首页渲染 section id 使用。""" return {c['name']: c['anchor'] for c in get_nav_categories()} def clear_nav_categories_cache(): """后台改动分类后调用:立即失效缓存。""" _NAV_CATS_CACHE['ts'] = 0.0 _NAV_CATS_CACHE['data'] = [] @bp.get('/') def index(): """ 公开首页:按 category 分组聚合,渲染模板 """ return _render_index() @bp.get('/category/') def category_detail(name: str): """ 分类详情页:列出指定分类下所有启用的导航 """ items = ( Nav.query .filter(Nav.category == name, Nav.enable == 1) .order_by(Nav.sort.asc(), Nav.id.asc()) .all() ) counts = get_nav_visit_counts() dict_items = [] for it in items: d = it.to_dict() d['visit_count'] = counts.get(it.id, 0) dict_items.append(d) return render_template( 'public/category.html', category=name, items=dict_items, site_name='旺珂 · 导航', ) @bp.get('/api/navs') def api_navs(): """ JSON 接口:返回按分类聚合的导航数据 可用于首页卡片动态加载、第三方嵌入等 """ return jsonify({ 'success': True, 'msg': '请求成功', 'data': _grouped_navs(), }) def render_public_index(): """给外部复用:渲染前台公开导航首页(无需登录)。""" return _render_index() def get_category_meta() -> "dict[str, dict]": """给外部复用:返回启用的分类元信息。""" return _category_meta() 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, total=total, site_name='旺珂 · 导航', cat_meta=_category_meta(), cat_anchors=get_cat_anchor_map(), top_navs=_top_navs(12), my_navs=mine if len(mine) >= MINE_MIN_ITEMS else [], ) def _top_navs(limit: int = 12) -> "list[dict]": """跨分类聚合:返回点击数 TOP N 的有效 nav 列表(按点击数降序)。 - 复用 get_nav_visit_counts() 的 60s 进程内缓存 - 0 点击时返回空列表(调用方负责隐藏整个分类块) - 同一个 nav 不可能重复出现(按 nav.id 维度) """ counts = get_nav_visit_counts() if not counts: return [] # 取点击数最高的 N 个 nav_id(点击数相同的按 id asc 稳定排序) top_ids = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:limit] nav_ids = [nid for nid, _ in top_ids] 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 in nav_ids: if nid in by_id: d = by_id[nid] d['visit_count'] = counts.get(nid, 0) out.append(d) return out def _grouped_navs() -> "OrderedDict[str, list]": """按 category 分组,组内按 sort asc 排序;分组顺序优先按 NavCategory.sort 排列""" # 先取启用的分类,按 sort 排好序 cats = ( NavCategory.query .filter(NavCategory.enable == 1) .order_by(NavCategory.sort.desc(), NavCategory.id.asc()) .all() ) ordered_names = [c.name for c in cats] cat_order = {name: idx for idx, name in enumerate(ordered_names)} items = ( Nav.query .filter(Nav.enable == 1) .order_by(Nav.category.asc(), Nav.sort.asc(), Nav.id.asc()) .all() ) # 一次性取所有 nav 的点击数(带缓存) counts = get_nav_visit_counts() groups = OrderedDict() # 按 cat_order 初始化空分组,保证顺序 for name in ordered_names: groups[name] = [] for item in items: d = item.to_dict() d['visit_count'] = counts.get(item.id, 0) if item.category in groups: groups[item.category].append(d) else: # 对于未在 NavCategory 登记的历史分类,放在最后 groups.setdefault(item.category, []).append(d) # 过滤空分组(启用的分类下可能没有导航) return OrderedDict((k, v) for k, v in groups.items() if v) def _category_meta() -> "dict[str, dict]": """返回分类元信息 {name: {icon, description, ...}},用于抽屉图标等""" cats = NavCategory.query.filter(NavCategory.enable == 1).all() return {c.name: {'icon': c.icon or 'layui-icon-list', 'description': c.description or ''} for c in cats} # --------------------------------------------------------------------------- # 卡片图标本地缓存 # 首次请求某域名图标时,自动拉取并保存到 data/icon/,以后直接读本地文件, # 不再重复拉取。拉取失败(含内网/不可达网址)时自动生成「站点名称首字母」SVG 兜底。 # --------------------------------------------------------------------------- _ICON_DIR = None _ICON_LOCKS = {} _ICON_LOCKS_GUARD = threading.Lock() _ICON_EXT_MIME = { '.ico': 'image/x-icon', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', } def _icon_dir(): global _ICON_DIR if _ICON_DIR is None: base = current_app.config.get('DATA_DIR') if not base: base = os.path.join(os.path.dirname(os.path.dirname(__file__)), '..', 'data') _ICON_DIR = os.path.join(base, 'icon') os.makedirs(_ICON_DIR, exist_ok=True) return _ICON_DIR def _safe_name(domain: str) -> str: return re.sub(r'[^A-Za-z0-9.\-_]', '_', domain or 'unknown') def _lock_for(key: str): with _ICON_LOCKS_GUARD: lock = _ICON_LOCKS.get(key) if lock is None: lock = threading.Lock() _ICON_LOCKS[key] = lock return lock def _domain_hue(domain: str) -> int: """与 models.admin_nav.Nav.to_dict 中用于生成卡片字母头像的配色保持一致""" s = 0 for ch in (domain or ''): s = (s * 31 + ord(ch)) & 0xffffffff return s % 360 def _letter_svg(letter: str, domain: str) -> bytes: hue = _domain_hue(domain) c1 = "hsl(%d,62%%,56%%)" % hue c2 = "hsl(%d,72%%,46%%)" % ((hue + 28) % 360) ch = (letter or (domain or '·')[:1] or '·').upper()[:1] or '·' ch = re.sub(r'[<>&"]', '', ch) # 防止 SVG 注入 svg = ( '' '' '' '' '' '' '%s' '' ) % (c1, c2, ch) return svg.encode('utf-8') def _fetch_bytes(url: str, timeout: int = 5): try: req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) with urllib.request.urlopen(req, timeout=timeout) as resp: data = resp.read() ctype = resp.headers.get('Content-Type', '') or '' return data, ctype except Exception: return None def _ext_from(ctype: str, data: bytes) -> str: ct = (ctype or '').lower() if 'png' in ct: return '.png' if 'jpeg' in ct or 'jpg' in ct: return '.jpg' if 'gif' in ct: return '.gif' if 'svg' in ct: return '.svg' if 'icon' in ct: return '.ico' if data[:4] == b'\x00\x00\x01\x00': return '.ico' if data[:8] == b'\x89PNG\r\n\x1a\n': return '.png' if data[:3] == b'GIF': return '.gif' return '.ico' def _resolve_icon(domain: str, letter: str): """返回 (file_path, mimetype)。优先读本地缓存,否则拉取/生成并落盘。""" safe = _safe_name(domain) d = _icon_dir() # 1) 命中本地缓存(任意已知扩展名) for ext in ('.svg', '.ico', '.png', '.jpg', '.jpeg', '.gif'): p = os.path.join(d, safe + ext) if os.path.isfile(p): return p, _ICON_EXT_MIME.get(ext, 'application/octet-stream') # 2) 加锁后再次检查(避免并发重复拉取) with _lock_for(safe): for ext in ('.svg', '.ico', '.png', '.jpg', '.jpeg', '.gif'): p = os.path.join(d, safe + ext) if os.path.isfile(p): return p, _ICON_EXT_MIME.get(ext, 'application/octet-stream') # 3) 循序尝试:站点自身 https → http(含内网)→ DuckDuckGo candidates = [ 'https://%s/favicon.ico' % domain, 'http://%s/favicon.ico' % domain, 'https://icons.duckduckgo.com/ip3/%s.ico' % domain, ] for url in candidates: res = _fetch_bytes(url) if not res: continue data, ctype = res if not data or len(data) < 32: # 过小多半是错误页/空响应 continue # 必须是真正的图片(按 Content-Type 或二进制魔数判断), # 否则跳过——避免把 200 的 HTML 错误页当图标缓存下来 ct = (ctype or '').lower() is_image = ( ct.startswith('image/') or data[:4] == b'\x00\x00\x01\x00' or data[:8] == b'\x89PNG\r\n\x1a\n' or data[:3] == b'GIF' ) if not is_image: continue ext = _ext_from(ctype, data) p = os.path.join(d, safe + ext) try: with open(p, 'wb') as f: f.write(data) return p, _ICON_EXT_MIME.get(ext, 'application/octet-stream') except OSError: continue # 4) 全部失败:生成字母头像 SVG 兜底并缓存 p = os.path.join(d, safe + '.svg') with open(p, 'wb') as f: f.write(_letter_svg(letter, domain)) return p, 'image/svg+xml' @bp.get('/icon/') def icon(domain): """卡片图标:本地缓存优先,拉取失败时返回字母头像 SVG。""" letter = request.args.get('letter', '') or '' path, mime = _resolve_icon(domain, letter) return send_file(path, mimetype=mime, max_age=86400)