为什么要做访客追踪
博客上线后,难免好奇:到底有没有人看?都从哪来的?用的什么设备?
第三方统计(百度统计、Google Analytics)功能强大但太重了,而且数据不在自己手里。干脆自己写一个轻量的,只需要记录几个关键信息:
- IP 和地理位置(精确到城市)
- 浏览器和操作系统
- 访问页面路径
- 访问时间
正好后端是 FastAPI,前端的看板娘(Live2D)也可以用这个数据跟访客打招呼,一举两得。
整体设计
整个功能分三块:
前端(Next.js) ↓ POST /api/visitors/record 记录访问 ↓ GET /api/visitors/location 获取地理位置
后端(FastAPI) ↓ 解析 IP、UA ↓ 查询 IP 地理位置 API
数据库(SQLite/Postgres) ↓ 存储访客记录
class Visitor(SQLModel, table=True):
__tablename__ = "visitor"
id: Optional[int] = Field(default=None, primary_key=True)
ip: str = Field(max_length=45) # 访客 IP
path: str = Field(default="", max_length=500) # 访问路径
user_agent: str = Field(default="")
city: str = Field(default="", max_length=100)
region: str = Field(default="", max_length=100)
country: str = Field(default="", max_length=100)
org: str = Field(default="", max_length=200) # 运营商
browser: str = Field(default="", max_length=50)
os: str = Field(default="", max_length=50)
device_type: str = Field(default="", max_length=20)
created_at: datetime = Field(default_factory=datetime.now)
device_type 用来区分手机和电脑,org 记录运营商(比如中国电信、Amazon AWS),方便后面识别爬虫。
第二步:User-Agent 解析
不引入第三方 UA 解析库,自己写一个简单的:
def _parse_ua(ua: str) -> dict:
browser = "Unknown"
if "Edg/" in ua:
browser = "Edge"
elif "Chrome/" in ua:
browser = "Chrome"
elif "Firefox/" in ua:
browser = "Firefox"
elif "Safari/" in ua:
browser = "Safari"
os = "Unknown"
if "Win" in ua:
os = "Windows"
elif "Android" in ua:
os = "Android"
elif "iPhone" in ua or "iPad" in ua:
os = "iOS"
elif "Mac" in ua:
os = "macOS"
elif "Linux" in ua:
os = "Linux"
device = "手机" if any(k in ua for k in ("Mobi", "Android", "iPhone")) else "电脑"
return {"browser": browser, "os": os, "device_type": device}
够用就行,能区分主流浏览器和设备即可,没必要上复杂的解析库。
第三步:IP 地理位置查询
这是核心逻辑。需要把 IP 转成地理位置,用两个 API 做双保险:
def _fetch_geo(ip: str) -> dict:
# 本地 IP 直接跳过
if ip in ("127.0.0.1", "::1", "") or ip.startswith("192.168.") or ip.startswith("10."):
return {}
result = {}
# 首选:uapis.cn(国内 API,速度快,支持 IPv6)
try:
resp = httpx.get("https://uapis.cn/api/v1/network/ipinfo", params={"ip": ip}, timeout=5)
data = resp.json()
if data.get("ip"):
parts = data.get("region", "").split()
result = {
"country": parts[0] if len(parts) >= 1 else "",
"region": parts[1] if len(parts) >= 2 else "",
"city": parts[2] if len(parts) >= 3 else "",
"org": data.get("isp", ""),
}
except Exception:
pass
# 回退:ip-api.com(国际 API,仅 IPv4)
if not result and ":" not in ip:
try:
resp = httpx.get(f"http://ip-api.com/json/{ip}?lang=zh-CN", timeout=3)
data = resp.json()
if data.get("status") == "success":
result = {
"city": data.get("city", ""),
"region": data.get("regionName", ""),
"country": data.get("country", ""),
"org": data.get("org", "") or data.get("isp", ""),
}
except Exception:
pass
return result
两个 API 互为备份,国内用户走 uapis.cn(快),国外用户或 uapis 挂了就走 ip-api.com。还加了个内存缓存,同一个 IP 短时间内反复查询不会重复请求外部 API。
第四步:记录访问
后端提供一个 POST 接口,前端在页面加载时调用:
@router.post("/record")
def record_visitor(request: Request, session: Session = Depends(get_session)):
# 从请求头中获取真实 IP(经过 Nginx 反代后)
ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if not ip:
ip = request.headers.get("x-real-ip", "")
if not ip:
ip = request.client.host if request.client else ""
path = request.headers.get("x-path", "") # 前端传的当前路径
ua = request.headers.get("user-agent", "")
visitor_service.record_visit(session, ip, path, ua)
return {"code": 0}
前端怎么写就更简单了——一个 useEffect 组件扔到根布局里:
export default function VisitorTracker() {
useEffect(() => {
if (!sessionStorage.getItem("visitor_recorded")) {
fetch("/api/visitors/record", {
method: "POST",
headers: { "x-path": window.location.pathname },
}).then(() => sessionStorage.setItem("visitor_recorded", "1"))
}
}, [])
return null
}
用 sessionStorage 控制:同一浏览器标签页只记录一次,防止刷新页面产生大量重复数据。
第五步:看板娘用它打招呼
顺便还暴露了一个 GET 接口,让看板娘(Live2D)获取访客位置:
@router.get("/location")
def get_visitor_location(request: Request):
ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
geo = visitor_service._fetch_geo(ip)
return {"code": 0, "data": geo}
看板娘拿到城市信息后,就会说:"欢迎来自 南昌 的朋友~" 这种,比干巴巴的"你好"有趣得多。
踩坑记录
1. Nginx 反代后拿不到真实 IP
部署后所有访客 IP 都变成了 127.0.0.1。需要在 Nginx 配置里加上:
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
后端再从 x-forwarded-for 或 x-real-ip 头部提取真实 IP。
2. API 请求超时
ip-api.com 免费版有频率限制(每分钟 45 次),高并发时会返回 429。解决方案是加内存缓存 + 异步兜底,查不到也不影响正常请求。
写在最后
整个系统代码量不到 200 行,但很实用:
- 知道博客真的有人看(不只是爬虫)
- 能区分手机和电脑用户,辅助 UI 设计决策
- 看板娘打招呼让站点更有温度
- 数据在自己手里,不依赖第三方
完整代码在 GitHub,欢迎 star~

