十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

轻量自建网站统计:FastAPI+SQLite实现隐私友好分析

轻量自建网站统计:FastAPI+SQLite实现隐私友好分析 做个人网站或者小项目的时候访问数据统计几乎是刚需。早期大家习惯挂一个第三方统计脚本来看 PV、UV但随着用户对隐私越来越敏感Cookie 弹窗和各类跨站追踪器反而成了产品体验的负担。Plausible 这类以“隐私友好、无需 Cookie”为卖点的开源统计工具也因此受到了很多开发者的关注。不过Plausible 本身基于 Go 和 ClickHouse完整自托管通常要拉起 Docker Compose 多容器环境对于只想要一个轻量统计面板、或者希望深度二次开发的个人开发者来说还是略显笨重。所以这篇文章我准备完整拆解一个“Plausible 现代替代品”的轻量实现方案使用 FastAPI SQLite 原生 JavaScript从零搭建一套开源、可自部署、无需 Cookie 的网站访问统计系统。新手可以照着文章理解整个采集-存储-展示链路有基础的开发者可以直接把代码改造成自己的统计服务。1. 背景从 Plausible 到轻量自建统计1.1 Plausible 是什么Plausible 是一个开源的网站访问统计分析工具主要特点是不需要 Cookie因此无需展示欧盟 GDPR 要求的那种 Cookie 授权弹窗统计脚本非常轻量传统统计工具动辄几十 KB它的脚本压缩后不到 1KB界面简洁只关注 PV、UV、来源、页面排名等核心指标支持自托管数据完全由站点所有者掌控。很多独立开发者、内容站、SaaS 产品都倾向于使用这类工具来替代上一代重度统计产品。1.2 为什么要做 Modern AlternativePlausible 虽然很好但它的自托管方案对机器配置有一定要求。官方推荐的部署方式是 Docker Compose 同时启动应用容器、PostgreSQL、ClickHouse并依赖对象存储或 Nginx 代理。如果你只是给一个日访问量几千的站点做统计这套基础设施明显是过剩的。与此同时作为开发者我们希望统计系统的行为完全可控新增一个重要页面可以自定义埋点、统计来源时可以按自己的规则归类、仪表盘也可以按业务需求调整。与其在 Plausible 上做二次开发不如直接写一个精简、现代、够用的实现。这里说的“现代替代品”本质上是一套满足以下条件的轻量统计系统隐私友好不使用 Cookie不采集 IP、指纹等敏感信息轻量部署依赖尽可能少最好一个进程 一个 SQLite 文件就能跑简单协议前端一个脚本后端一个事件接口容易理解和扩展自带仪表盘不需要再接入 Grafana 之类的额外系统。1.3 LiteAnalytics 的核心设计目标我们把示例项目命名为LiteAnalytics目标功能包括嵌入外部站点后自动统计页面浏览量统计独立访客数UV这里采用本地生成的匿名访客 ID不使用 Cookie展示最近 30 天 PV/UV 趋势展示页面排行 Top10、来源 Top10提供设备类型、浏览器分布等基础维度全程一个 SQLite 文件存数据方便备份和迁移。2. 技术选型与整体架构2.1 技术栈选型后端选择 FastAPI原因是 Python 在服务端脚本领域应用面广FastAPI 自带 OpenAPI 文档、异步支持好写一个 JSON 接口非常快。数据库选择 SQLite这是一个经常被低估的选择。对于中小型站点SQLite 的单机读写能力完全够用而且它零配置、单文件、备份简单。生产环境如果后续量级上来可以替换成 PostgreSQLSQL 基本不用大改。前端采集脚本使用原生 JavaScript不经过打包工具直接以静态文件形式挂在 FastAPI 上。这样任何静态站点、WordPress、Hexo 都能直接引用不需要重新构建。仪表盘采用 HTML 原生 JavaScript Chart.js CDN。Chart.js 是最流行的开源图表库之一画趋势图十分方便省去引入大前端框架的成本。最终技术方案如下模块选型后端框架FastAPI数据存储SQLite前端采集脚本原生 JavaScript仪表盘HTML Chart.js部署方式Uvicorn Nginx / Docker2.2 总体架构整个统计链路由四部分组成目标站点页面嵌入script标签加载我们提供的script.jsscript.js在页面加载完成后收集当前页面的 URL、来源、屏幕宽度等信息并生成一个匿名访客 ID浏览器将采集到的数据 POST 到后端/api/event接口后端写入 SQLite仪表盘通过/api/stats/*系列接口读取聚合数据并展示。整个流程不需要登录、不需要 Cookie访客在页面上的操作就是一次普通请求因此对业务代码几乎没有侵入性。2.3 数据模型设计LiteAnalytics 只使用一张事件表events核心字段如下字段类型说明idINTEGER主键自增event_typeTEXT事件类型默认为 pageviewdomainTEXT被统计的站点域名urlTEXT页面完整 URLreferrerTEXT来源页面referrer_hostTEXT来源域名便于分组统计visitor_idTEXT匿名访客 IDscreen_widthINTEGER设备屏幕宽度device_typeTEXTdesktop / mobile / tabletbrowserTEXTChrome / Firefox / Safari 等user_agentTEXT原始 UAcreated_atTEXTISO 格式时间这里有两个设计点说明visitor_id不是 Cookie而是由前端在localStorage中生成的一串 UUID仅用于匿名去重referrer_host在上报接口里提前解析好避免查询时再做字符串处理提升查询效率。3. 环境准备与项目初始化3.1 开发环境要求Python 3.10 或更高版本建议 3.11pip 已配置无需额外安装 MySQL/RedisSQLite 由 Python 内置IDE 推荐 VS Code 或 PyCharm。如果你本机还没有 Python 环境建议先通过 pyenv 或官方安装包安装然后检查版本python --version本文示例以常见环境为主FastAPI 和 Uvicorn 的版本只要是大版本兼容即可重点演示的是实现思路实际版本请按项目情况确认。3.2 初始化项目结构新建一个目录lite-analytics结构如下lite-analytics/ ├── main.py ├── requirements.txt └── static/ ├── index.html ├── script.js └── test.html其中main.py是 FastAPI 后端入口包含数据表初始化、事件采集接口、统计查询接口static/script.js是供第三方站点嵌入的统计脚本static/index.html是仪表盘页面通过 Chart.js 展示统计数据static/test.html是本地测试页方便我们验证整条链路。3.3 安装依赖在项目根目录创建requirements.txtfastapi0.110,1.0 uvicorn[standard]0.29然后执行安装pip install -r requirements.txt安装完成后可以运行python -c import fastapi, uvicorn; print(fastapi.__version__)验证环境是否正常。4. 后端事件采集接口实现4.1 初始化 FastAPI 应用与 CORS由于统计脚本可能嵌入到任意网站浏览器向我们的后端发送事件时属于跨域请求因此必须配置 CORS。这里采用允许所有来源的策略因为事件接口本身不携带认证信息而且只有允许跨域才能收集到第三方站点的数据。在main.py中先完成基础初始化from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse import sqlite3 import uuid from datetime import datetime, timezone, timedelta from pathlib import Path from urllib.parse import urlparse BASE_DIR Path(__file__).resolve().parent DB_PATH BASE_DIR / lite_analytics.db app FastAPI(titleLiteAnalytics, version0.1.0) app.add_middleware( CORSMiddleware, allow_origins[*], allow_methods[POST, GET, OPTIONS], allow_headers[*], )这里需要解释一下allow_origins[*]的风险和适用场景。由于统计接口不涉及用户登录态也不返回敏感数据所以放开跨域是安全的。如果后续在这个服务上增加了管理后台管理后台接口绝不能继续使用*。4.2 初始化数据库SQLite 连接本身很简单我们用一个函数获取连接并设置row_factory让查询结果支持字段名访问。def get_conn(): conn sqlite3.connect(DB_PATH) conn.row_factory sqlite3.Row return conn def init_db(): conn get_conn() conn.executescript( CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, event_type TEXT NOT NULL DEFAULT pageview, domain TEXT NOT NULL, url TEXT NOT NULL, referrer TEXT DEFAULT , referrer_host TEXT DEFAULT , visitor_id TEXT NOT NULL, screen_width INTEGER DEFAULT 0, device_type TEXT DEFAULT desktop, browser TEXT DEFAULT Other, user_agent TEXT DEFAULT , created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_events_domain_created ON events(domain, created_at); CREATE INDEX IF NOT EXISTS idx_events_domain_visitor ON events(domain, visitor_id); ) conn.commit() conn.close() init_db()这里两个索引非常重要。一个用于按域名和时间范围查询一个用于独立访客去重。没有索引时SQLite 在数据量变大后会出现明显的性能下降。4.3 User-Agent 与来源解析为了在仪表盘里展示设备类型和浏览器分布我们需要在写入前对 UA 做一次解析。生产环境建议使用user-agents这类成熟的解析库这里为了保持依赖简单使用正则做粗略识别。def parse_ua(ua: str): ua_lower (ua or ).lower() if ipad in ua_lower or tablet in ua_lower: device tablet elif android in ua_lower or iphone in ua_lower or mobile in ua_lower: device mobile else: device desktop if edg/ in ua_lower: browser Edge elif chrome/ in ua_lower: browser Chrome elif firefox/ in ua_lower: browser Firefox elif safari/ in ua_lower: browser Safari else: browser Other return device, browser注意Edge 的 UA 字符串同时包含 “Chrome”所以必须先判断 Edge否则会被误判为 Chrome。来源域名解析也放在写入前def parse_referrer_host(referrer: str) - str: if not referrer: return try: return urlparse(referrer).netloc except Exception: return 这样做的好处是查询 Top 来源时直接GROUP BY referrer_host不需要在 Python 层再做字符串解析。4.4 事件上报接口事件上报是整条链路的入口前端统计脚本会把页面信息 POST 到这里。app.post(/api/event) async def track_event(request: Request): try: body await request.json() except Exception: return JSONResponse({ok: False, message: invalid json}, status_code400) domain str(body.get(domain) or ).strip().lower() url str(body.get(url) or ).strip() if not domain or not url: return JSONResponse( {ok: False, message: domain and url are required}, status_code400, ) event_type str(body.get(event_type) or pageview) referrer str(body.get(referrer) or ).strip() visitor_id str(body.get(visitor_id) or ).strip() screen_width int(body.get(screen_width) or 0) ua str(body.get(user_agent) or ) if not visitor_id: visitor_id uuid.uuid4().hex device_type, browser parse_ua(ua) referrer_host parse_referrer_host(referrer) created_at datetime.now(timezone.utc).strftime(%Y-%m-%dT%H:%M:%S) conn get_conn() try: conn.execute( INSERT INTO events (event_type, domain, url, referrer, referrer_host, visitor_id, screen_width, device_type, browser, user_agent, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (event_type, domain, url, referrer, referrer_host, visitor_id, screen_width, device_type, browser, ua, created_at), ) conn.commit() except Exception as exc: conn.close() return JSONResponse({ok: False, message: str(exc)}, status_code500) conn.close() return JSONResponse({ok: True, message: tracked})接口的逻辑并不复杂但有几个细节值得注意domain做了大小写归一化避免统计时同一个站点的数据被拆成两条如果前端没有传visitor_id服务端会生成一个临时 ID 兜底时间统一存 UTC展示时再按业务需求做时区转换避免不同地区用户混淆时区。4.5 挂载静态文件与仪表盘路由后端同时承担了静态文件服务的角色app.mount(/static, StaticFiles(directorystr(BASE_DIR / static)), namestatic) app.get(/dashboard, response_classFileResponse) async def dashboard(): return FileResponse(BASE_DIR / static / index.html)这样我们启动服务后http://localhost:8000/static/script.js就是采集脚本地址http://localhost:8000/dashboard就是仪表盘地址。5. 前端统计脚本 script.js5.1 访客 ID 的生成与存储Plausible 不需要 CookieLiteAnalytics 同样不使用 Cookie。我们使用 Web Storage API 中的localStorage来保存一个匿名访客 ID。这个 ID 只在当前浏览器中有效不会跨域传递符合隐私友好设计。(function () { var STORAGE_KEY _la_vid; function generateUUID() { if (window.crypto window.crypto.randomUUID) { return window.crypto.randomUUID(); } return xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx.replace(/[xy]/g, function (c) { var r (Math.random() * 16) | 0; var v c x ? r : (r 0x3) | 0x8; return v.toString(16); }); } function getVisitorId() { var vid null; try { vid localStorage.getItem(STORAGE_KEY); } catch (e) {} if (!vid) { vid generateUUID(); try { localStorage.setItem(STORAGE_KEY, vid); } catch (e) {} } return vid; } })();生成 UUID 时优先使用浏览器原生的crypto.randomUUID()现代浏览器都支持如果不支持则回退到基于随机数的 UUID v4 生成方案。需要注意一个问题
返回列表