为什么要做访客追踪

博客上线后,难免好奇:到底有没有人看?都从哪来的?用的什么设备?

第三方统计(百度统计、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~