访问量只是访问记录最容易看到的一列。真正有用的后台还应该回答:请求是否成功、页面响应有多快、访问者大致来自哪里、它更像用户还是自动化客户端,以及这些数据应该保存多久。
本文以 Django 为例,整理一套可落地的访问分析后台设计。重点不是把每一个 IP 都贴上绝对标签,而是建立可查询、可复核、可定期清理的数据链路。关于 User-Agent、GeoIP 和爬虫分类的基本边界,可以先参考SEO访问日志分析实战:识别人类用户、搜索引擎与AI爬虫;本文继续解决“如何把这些判断做成后台功能”。
一、访问记录要回答五个问题
访问日志字段越多不一定越好。每个字段都应当服务于一个明确的问题:
| 问题 | 可用字段 | 用途 |
|---|---|---|
| 请求访问了什么 | path、request_method |
排查热门页面、扫描路径和异常方法 |
| 请求是否成功 | status_code、content_type |
区分正常访问、重定向和错误页面 |
| 响应是否足够快 | response_time_ms |
找出慢页面,避免只看平均值 |
| 访问者来自哪里 | country、city、country_code |
做区域分布的近似分析 |
| 更像谁 | visitor_type、bot_name、bot_confidence |
区分用户、搜索爬虫、AI 爬虫和扫描器 |
原始记录和统计指标要分开理解。原始记录适合定位单次异常,统计结果适合观察趋势;“独立 IP”是去重后的地址数量,不等于真实用户数,同一个人可能使用多个网络,同一个代理也可能代表很多人。
二、先设计可演进的数据模型
访问记录通常会从一个简单的 IP 表逐渐变成分析表。下面是一个精简示例,实际项目可以根据隐私要求删减字段:
from django.db import models
class Visitor(models.Model):
ip_address = models.GenericIPAddressField()
user_agent = models.TextField(blank=True)
request_method = models.CharField(max_length=32)
path = models.CharField(max_length=2048)
referer = models.CharField(max_length=2048, blank=True)
timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
country = models.CharField(max_length=100, blank=True, null=True)
city = models.CharField(max_length=100, blank=True, null=True)
country_code = models.CharField(max_length=2, blank=True)
geo_source = models.CharField(max_length=20, blank=True)
visitor_type = models.CharField(max_length=20, default="unknown", db_index=True)
is_bot = models.BooleanField(default=False)
bot_name = models.CharField(max_length=80, blank=True)
bot_confidence = models.CharField(max_length=10, default="low", db_index=True)
classification_source = models.CharField(max_length=20, blank=True)
classification_reason = models.CharField(max_length=200, blank=True)
device_type = models.CharField(max_length=20, blank=True)
device_os = models.CharField(max_length=40, blank=True)
browser = models.CharField(max_length=60, blank=True)
status_code = models.PositiveSmallIntegerField(default=200, db_index=True)
response_time_ms = models.PositiveIntegerField(default=0)
content_type = models.CharField(max_length=100, blank=True)
这里有几个容易被忽略的细节:
request_method不要只预留 10 个字符。正常请求大多是GET或POST,但扫描器可能发送非标准方法,长度太小会让严格数据库迁移失败。country_code比国家名称更适合聚合。名称可以有中文、英文和旧数据混用,代码应统一为大写 ISO 代码。bot_confidence和classification_reason要和visitor_type一起保存。后台只显示“爬虫”而不显示依据,会让使用者误以为这是事实验证。- 给
timestamp、visitor_type、status_code建索引。国家和时间的组合索引适合常用的国家筛选,但不应为每个展示字段都建立索引。
三、请求结束后再写入记录
中间件的时序会直接影响状态码和响应耗时。如果在调用视图前保存,记录到的状态码只能是预设值,也无法知道模板渲染和数据库查询花了多久。
import logging
import time
from django.db import DatabaseError
from .models import Visitor
logger = logging.getLogger(__name__)
class VisitorTrackingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def should_track(self, path):
ignored = ("/admin/", "/static/", "/media/", "/healthz", "/robots.txt", "/sitemap")
return not path.startswith(ignored)
def get_client_ip(self, request):
# 生产环境应只在 REMOTE_ADDR 属于可信代理时读取转发头。
return request.META.get("REMOTE_ADDR") or "0.0.0.0"
def __call__(self, request):
if not self.should_track(request.path):
return self.get_response(request)
started = time.perf_counter()
classification = classify_user_agent(
request.META.get("HTTP_USER_AGENT", ""),
request.path,
)
response = None
try:
response = self.get_response(request)
return response
except Exception:
self.save_record(request, classification, started, None)
raise
finally:
if response is not None:
self.save_record(request, classification, started, response)
def save_record(self, request, classification, started, response):
try:
Visitor.objects.create(
ip_address=self.get_client_ip(request),
user_agent=request.META.get("HTTP_USER_AGENT", ""),
request_method=request.method[:32],
path=request.path[:2048],
referer=request.META.get("HTTP_REFERER", "")[:2048],
status_code=getattr(response, "status_code", 500),
response_time_ms=round((time.perf_counter() - started) * 1000),
content_type=(response.get("Content-Type", "") if response else "")[:100],
**classification,
)
except DatabaseError:
# 记录失败不能让正常页面变成 500。
logger.exception("visitor record was not saved")
上面的 classify_user_agent 函数在第五节给出,Visitor 是本节模型。为了让代理头不能被任意客户端伪造,get_client_ip 的简化实现只使用直接连接地址;接入可信反向代理后,再按代理白名单读取 X-Real-IP 或 X-Forwarded-For,并逐个校验地址格式。
生产环境还需要排除管理后台、静态文件、媒体文件、健康检查、robots 和 sitemap 等路径,否则统计会被系统请求淹没。排除路径只是降噪,不应当用“是否为爬虫”决定是否记录;搜索爬虫的访问同样是 SEO 诊断数据。
异常请求要保留 500 状态的记录,但不能把异常对象或完整堆栈写进公开的访问表。详细错误仍然交给应用日志,访问表只保存定位请求所需的最小字段。
四、GeoIP 只能提供近似位置
本地 GeoIP 数据库适合做国家和城市的粗粒度统计,不适合确认个人住址。查询时要同时兼容标准 MaxMind 结构和自定义导出结构,因为不同数据库版本可能出现以下差异:
from django.conf import settings
import maxminddb
# maxminddb.open_database 返回的 reader.get(ip) 是字典或 None。
reader = maxminddb.open_database(settings.GEOIP_DATABASE_PATH)
def lookup_geo(ip):
record = reader.get(ip) or {}
country_data = record.get("country") or {}
city_data = record.get("city") or {}
if isinstance(country_data, dict) and not city_data:
city_data = country_data.get("city") or {}
def read_name(value, fallback_key):
if isinstance(value, dict):
names = value.get("names") or {}
if isinstance(names, dict):
return names.get("zh-CN") or names.get("en")
if isinstance(value.get(fallback_key), str):
return value[fallback_key]
return value if isinstance(value, str) else None
country = read_name(country_data, "country")
city = read_name(city_data, "city")
country_code = ""
if isinstance(country_data, dict):
country_code = (
country_data.get("iso_code")
or country_data.get("en_short_code")
or ""
).upper()
return {"country": country or "", "city": city or "", "country_code": country_code}
如果使用 geoip2.database.Reader,不要调用 get;应改用 reader.city(ip),再从返回对象的 country.names、country.iso_code 和 city.names 读取字段。两种库的返回类型不同,项目中应固定一种接口,避免把对象当字典处理。
查不到国家或城市时应保存空值,而不是随意填入“未知国家”。空值可能表示数据库没有覆盖该地址、地址属于代理或网络出口,也可能是 IPv6 或私网地址。后台统计需要把“无定位数据”单独列出。
五、分类结果必须能解释
User-Agent 是客户端自己发送的文本,任何客户端都可以伪造。因此可以用它做初筛,不能把它当成真实身份验证。一个可复核的分类顺序如下:
- 匹配明确的搜索引擎或 AI 客户端标记,记录为高置信度的 UA 命中。
- 匹配通用的
bot、spider或crawler标记,记录为中置信度。 - 命中
/.env、/.git/、wp-login.php、xmlrpc.php等明显扫描路径时,记录路径依据。 - 浏览器样式的 UA 默认视为低置信度用户,不能据此证明一定是真人。
- 缺少 UA 的请求归为未知客户端,不要强行归入用户或爬虫。
def classify_user_agent(user_agent, path=""):
normalized = (user_agent or "").lower()
if "googlebot" in normalized:
return {
"is_bot": True,
"visitor_type": "search_bot",
"bot_name": "Googlebot",
"bot_confidence": "high",
"classification_source": "known_ua",
"classification_reason": "匹配明确的 Googlebot 标记",
}
suspicious = ("/.env", "/.git/", "/wp-login.php", "/xmlrpc.php")
marker = next((item for item in suspicious if item in path.lower()), None)
if marker:
return {
"is_bot": True,
"visitor_type": "scanner",
"bot_name": "路径扫描",
"bot_confidence": "medium",
"classification_source": "path",
"classification_reason": f"命中可疑路径:{marker}",
}
return {
"is_bot": False,
"visitor_type": "unknown",
"bot_name": "",
"bot_confidence": "low",
"classification_source": "missing_ua" if not normalized else "browser_ua",
"classification_reason": "仅依据客户端文本推断,未进行来源验证",
}
如果业务确实需要确认某个搜索引擎身份,还要结合反向 DNS、官方 IP 段或搜索平台抓取报告进行二次核验。后台可以把这类验证结果单独作为字段,不能悄悄提高启发式规则的置信度。
六、后台统计要服务于排查
访问记录后台至少应提供四组能力:
- 按 1、7、30 天查看访问量、独立 IP、用户请求、机器人请求、4xx/5xx 和平均响应耗时。
- 按国家、设备、操作系统、浏览器、访问类型、机器人名称和热门路径排行。
- 按状态码、置信度、国家、设备和关键词筛选单条记录。
- 点击统计数字后能够回到对应的明细,而不是只展示一个无法解释的总数。
Django 聚合查询可以从一个筛选后的 QuerySet 开始:
from django.db.models import Avg, Count, Q
robot_types = ("search_bot", "ai_bot", "automation", "scanner")
summary = queryset.aggregate(
total=Count("id"),
unique_ips=Count("ip_address", distinct=True),
human=Count("id", filter=Q(visitor_type="human", is_bot=False)),
bots=Count("id", filter=Q(is_bot=True)),
errors=Count("id", filter=Q(status_code__gte=400)),
average_ms=Avg("response_time_ms", filter=Q(response_time_ms__gt=0)),
)
top_paths = (
queryset.values("path")
.annotate(total=Count("id"))
.order_by("-total", "path")[:10]
)
平均耗时适合快速概览,但慢请求排查还应观察 P95 或 P99。SQLite 小站可以先用分段统计替代精确百分位,例如统计超过 500ms、1s 和 3s 的请求数;数据量增大后,再把聚合任务移到专用分析库。
七、30 天留存要和备份一起规划
保存期限应出现在隐私政策中,并由自动任务执行,而不是依赖管理员手工删除。清理命令要使用明确的截止时间、事务和互斥锁:
from datetime import timedelta
from django.db import transaction
from django.utils import timezone
cutoff = timezone.now() - timedelta(days=30)
with transaction.atomic():
Visitor.objects.filter(timestamp__lt=cutoff).delete()
生产任务还应做到:
- 使用
flock防止清理任务重叠。 - 先提供
--dry-run,输出待清理数量。 - 清理在线库后检查 SQLite 完整性。
- 明确哪些数据库备份是可变归档,哪些是不可变回滚材料;不要让普通应用用户误删 root 所有的发布备份。
- 在日志中记录截止日期、删除数量和失败原因,但不要记录完整 IP 或 User-Agent。
访问记录和备份的保存范围必须能向用户解释。若备份因为回滚需求保留更久,就应在运维策略中规定访问权限和最终销毁时间。
八、测试和上线检查
至少覆盖以下测试:
- 普通浏览器请求能保存 200 状态、响应类型和耗时。
- 视图抛出异常时能保存 500 状态,且异常继续交给 Django 处理。
- 明确搜索爬虫、通用爬虫、可疑路径和缺少 UA 的分类结果符合预期。
- 标准 GeoIP、英文名称、空记录和自定义数据库结构都不会导致请求失败。
- 日期范围、状态码、国家和关键词筛选在列表与概览页得到相同结果。
- 清理命令的 dry-run 不删除数据,正式运行只删除截止日期以前的记录。
上线顺序可以保持简单:先备份数据库,再迁移字段和索引;执行 Django system check 和 tracking 测试;用一条真实请求确认状态码、耗时和 GeoIP;最后观察 SQLite 锁、应用错误日志和后台页面。不要在没有备份的情况下直接对十几万条历史记录做全量重算,应该先用小批次观察耗时,再按主键分段处理。
结语:让记录支持决策,而不是制造标签
访问分析后台的价值不在于收集尽可能多的字段,而在于让每个数字都能追溯到一条请求,让每个分类都显示判断依据,让每项保留策略都能被用户理解。状态码告诉你页面是否可用,响应耗时告诉你哪里需要优化,GeoIP 提供近似分布,User-Agent 只提供待核验的线索。
完成后台后,可以继续用SEO 发布后监控实战观察搜索爬虫是否真正请求新页面,用Django 部署后的日志排查与性能监控处理应用层异常,并结合SEO 进阶测量与验证区分“已提交”“已抓取”和“已收录”。如果站内链接发生变化,再用站内链接审计实战检查孤立页面和断链;后台输入和查询安全还可以参考Django 博客站内搜索实战。涉及 IP、User-Agent 和保存期限时,应同步检查站点的隐私政策。
评论 (0)
暂无评论,快来抢沙发吧!