网站流量的分析一般通过外部网站流量统计工具(如Google Analytics和百度统计)进行,但是对于不以营利为目的、不需深入跟踪和分析用户行为的站点而言,服务器访问日志本身就可以提供足够的信息。本文以本站点(blog.jaspirit[.]cc)的Nginx日志为例,展示对站点访问日志的分析过程。

1. 日志解析

根据Nginx日志配置,服务器访问日志包含以下内容(表1):

1
2
3
log_format combined '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent"';
Nginx日志格式配置(默认为combined格式)
指令 意义 示例 备注
$remote_addr 远程客户端地址 12.34.56.78 本站点仅接入ipv4网络,因此只会出现ipv4地址;另外,因透明反向代理和NAT的存在,此地址不一定是用户真实IP地址,但能一定程度反映用户所处地域
$remote_user 远程客户端用户名(基本身份验证Basic authentication提供的用户名) 本站点不进行身份验证,因此,此项在日志中全为- (空)
$time_local 请求时间 01/Jan/2001:01:23:45 +0800 时间格式为Apache HTTP 服务器日志标准格式
$request 请求信息,包括请求方式、请求URI和HTTP协议版本 GET /robots.txt HTTP/1.1
$status 状态码 200
$body_bytes_sent 响应主体大小 12345 单位为字节
$http_referer HTTP来源地址 https://cn.bing.com/ 表示用户从哪个页面发起了请求,即用户从哪个页面跳转到当前页面。比如,来源地址为https://cn.bing.com/意味着用户是从Bing搜索引擎的搜索结果页面跳转到本站点的。当浏览器有较严格的隐私设置时,请求中将不会包含此项或只包含部分信息,以尽可能阻止网站对用户行为的追踪
$http_user_agent HTTP用户代理(User-Agent)信息 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0 包含用户所使用的浏览器、操作系统、硬件信息等(如可判别用户使用的是桌面端还是手机端,使用的浏览器是Chorme还是Edge),也可用于识别网络爬虫(网络爬虫的User-Agent中通常包含botspider等词语,比如以下日志示例的第一条即为OpenAI的内容抓取机器人在读取robots.txt中的内容以确定可抓取的内容)
表1 Nginx日志中所包含的内容
1
2
3
4
52.230.152.165 - - [03/Aug/2024:09:52:26 +0800] "GET /robots.txt HTTP/1.1" 200 240 "-" "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.2; +https://openai.com/gptbot)"
39.144.55.113 - - [03/Aug/2024:09:53:30 +0800] "GET /posts/ddeab4f8/ HTTP/2.0" 200 52815 "https://cn.bing.com/" "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1"
223.68.180.83 - - [03/Aug/2024:09:54:11 +0800] "GET /posts/243b81e8/ HTTP/2.0" 200 29267 "https://cn.bing.com/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0"
223.68.180.83 - - [03/Aug/2024:09:54:11 +0800] "GET /css/index.css HTTP/2.0" 200 142892 "https://blog.jaspirit.cc/posts/243b81e8/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0"
Nginx日志示例

我们需要解析以上格式的日志文件,为此编写了parse_log函数,负责使用pandasread_csv函数肚读取文本文件为DataFrame对象,并将其中的请求时间(time_local列)使用datetime.strptime转换为pandas的时间戳格式(pandas._libs.tslibs.timestamps.Timestamp

1
2
3
4
5
6
7
8
9
10
11
12
import pandas as pd
import datetime

def parse_log(fn):
data=pd.read_csv(fn,sep=' ',header=None).drop(columns=[1,2,4])
data.columns=['remote_addr','time_local','request','status','body_bytes_sent','http_referer','http_user_agent']
data['method']=data.request.str.split(' ').str[0]
data['request_uri']=data.request.str.split(' ').str[-2]
data['http_version']=data.request.str.split(' ').str[-1]

data.time_local=data.time_local.apply(lambda x: datetime.strptime(x, "[%d/%b/%Y:%H:%M:%S"))
return data[['remote_addr','time_local','method','request_uri','http_version','status','body_bytes_sent','http_referer','http_user_agent']].copy()

使用blog=parse_log('blog.log')读取日志文件后生成的DataFrame示例如表2所示(共1186635次请求,表2为使用blog.sample(5).to_markdown()随机选取了5次请求,输出markdown格式的字符串,并由本博客的hexo引擎渲染的结果。下文出现的所有数据表格均由类似方法生成,不再赘述)

remote_addr time_local method request_uri http_version status body_bytes_sent http_referer http_user_agent
116815 141.52.248.4 2022-07-19 23:06:41 GET /posts/ca2188fc/2.png HTTP/1.1 200 30656 https://blog.jaspirit.cc/posts/ca2188fc/ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36
658624 117.151.144.178 2024-02-23 10:15:51 GET /archives/2022/07/ HTTP/1.1 200 20378 - Go-http-client/1.1
490602 113.140.6.198 2023-10-08 15:02:40 GET /posts/ca2188fc/10.png HTTP/1.1 200 213589 https://blog.jaspirit.cc/posts/ca2188fc/ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36 Edg/117.0.2045.60
518008 96.126.112.220 2023-11-01 21:23:28 GET / HTTP/1.1 200 7973 - Mozilla/5.0
955383 202.200.237.112 2024-06-12 08:47:29 GET /posts/ca2188fc/12.png HTTP/2.0 200 84319 https://blog.jaspirit.cc/posts/ca2188fc/ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.0
表2 解析日志文件后生成的Dataframe

2. 数据清洗

网络流量中存在大量机器人和攻击流量,需要识别并剔除它们才能得到真实用户访问情况。我们首先筛选有效IP和合法请求,然后识别网络爬虫请求,最后根据请求频率区分正常/异常请求,同时统计会话数。

2.1 有效IP的筛选

编写is_valid_ipv4函数识别remote_addr是否是有效的ipv4地址(需要导入ipaddress库),并用DataFrame的drop方法删除无效列(剔除了1个请求,剩余1186634个请求)。

1
2
3
4
5
6
7
8
9
10
import ipaddress

def is_valid_ipv4(ip):
try:
ipaddress.IPv4Address(ip)
return True
except ipaddress.AddressValueError:
return False

blog.drop(blog[~blog.remote_addr.apply(is_valid_ipv4)].index,inplace=True) # 删除无效ipv4地址所在列

2.2 合法请求的筛选

本站为静态站点,用户只需且只可发送GET请求以获取内容,所以GET请求以外的请求是非法的,并且响应状态码应为200(OK)表示请求成功。我们通过blog.value_counts(['method','status'])获取不同请求方式-状态码组合的请求数量(表3),可见在GET请求中有高达21.2%的请求返回404状态码。

count
(‘GET’, 200) 880617
(‘GET’, 404) 245476
(‘GET’, 304) 14367
(‘GET’, 400) 11924
(‘GET’, 301) 6736
(‘POST’, 404) 4695
(‘POST’, 405) 2668
(‘CONNECT’, 400) 1902
(‘GET’, 403) 1453
(‘HEAD’, 404) 1230
(‘HEAD’, 200) 1108
表3 不同请求方式-状态码组合的请求数量

共有880617个请求(74.4%)是GET请求方式且请求成功的,将它们标记为合法请求。

1
blog['is_ok']=((blog.status==200)&(blog.method=='GET')).astype(bool)

2.3 非法请求的分析

在返回404状态码的请求中,请求/ads.txt/favicon.ico/.env的请求数最高,三者合计占9.9%。其中,

  • /ads.txt是用于确定和验证站点的广告资源是否仅通过授权的卖家销售的文件,由于本站点没有接入广告商,所以此项可忽略;
  • /favicon.ico是站点图标,缺失站点图标可能导致搜索引擎展示站点条目时不能同步展示站点图标(尤其是站点未显式指明其图标的位置时)。目前已经通过使用Nginx的alias指令将/favicon.ico的请求映射到/assets/images/hexo/favicon.ico解决;
  • /.env 是这三个请求中最值得警惕的,因为对它的请求属于网络攻击请求。.env 常被用来存储(交互式网站的)应用程序的环境变量,如数据库地址、API密钥等,一旦泄露有被恶意利用的风险。此文件不应被公开访问。

另外,在非成功请求中,有高达8.7%的请求含有wp-*(如wp-adminwp-loginwp-includeswp-add)或wordpress字样,它们是对流行的Wordpress系统的攻击,可见网络攻击活动之普遍和猖獗。

2.4 机器人和网络爬虫识别

网络抓取机器人和网络爬虫是自动浏览网页的程序,一般用于编制网络索引供搜索引擎使用,近年来也开始用于抓取文本以建立大语言模型语料库。合规的网络爬虫会在User-Agent中用botspider等关键词亮明自己的机器人身份并附带相关说明链接(见日志示例第一条),且遵循网站的robots.txt文件中的抓取策略进行内容抓取。当然,也有带有非法利用和网络攻击性质的机器人和爬虫,它们可能会伪装成正常用户以规避网站对机器人流量的限制,或者根本不做伪装,赤裸裸地展示自己自动程序的特征。我们通过pandas字符串处理功能中的contains函数筛选匹配特定正则表达式的User-Agent字符串,将323201个请求(27.2%)标记为机器人流量。

1
2
3
4
5
regexp_bots = r"bot|spider|crawl|slurp|feed|track|index|Go-http-client|python-requests|http|\+\w+@\w+\.\w+|^-$|Mediapartners-Google|Wget|facebookexternalhit|curl|msray-plus|axios|GoogleOther|aria2"
blog["is_bot"] = blog.http_user_agent.str.contains(
regexp_bots,
flags=re.IGNORECASE,
).astype(bool) # nan will be converted to True

列出匹配每种正则表达式的请求数量(表4)。因为请求的User-Agent可能同时与多个正则表达式匹配,所以下表中请求数量的加和高于总请求数:

1
2
3
4
5
6
7
8
9
10
11
bot_requests = pd.DataFrame(
[
{
"Bot Identfier": bot_identfier,
"Requests": blog.http_user_agent.str.contains(
bot_identfier, flags=re.IGNORECASE
).sum(),
}
for bot_identfier in regexp_bots.split("|")
]
)
Bot Identfier Requests
http 260793
Go-http-client 151772
bot 99953
^-$ 26671
spider 17345
crawl 10394
python-requests 6168
curl 3175
facebookexternalhit 1455
feed 443
+\w+@\w+.\w+ 434
GoogleOther 122
index 96
Wget 90
axios 87
Mediapartners-Google 74
slurp 48
msray-plus 37
track 1
aria2 1
表4 匹配不同机器人标识的请求数量

在被标记为机器人流量的请求中,有47%请求使用Go语言常用网络库的默认User-Agent,另有8.3%的请求User-Agent为空值,还有13次请求根本没有User-Agent字段。剩余的31.3%为合规机器人。

我们使用blog.drop(blog[(~blog.is_ok)|blog.is_bot].index,inplace=True)从数据集中剔除非法请求和被标记为机器人流量的请求,剩余653620个请求,占总请求数的55.1%。至于伪装成正常用户的User-Agent的请求,我们会在下一步根据请求频率进行判别。

2.5 请求频率筛选和会话数计算

真人浏览网站时,不会在极短时间内访问多个页面,而自动程序的行为则与之相反。因此,可通过客户端发送请求的频率分辨真人和机器人。这一步可以识别出一部分伪装User-Agent的爬虫请求。

会话的区分

请求频率是以会话(Session)为单位计算的。会话是指用户从进入网站浏览页面开始到离开网站为止的一段交互过程。用户在一次会话中可访问网站的多个页面。会话数和用户数的区分,在流量分析工具中是通过Cookie实现的。用户首次访问站点时,流量分析工具向用户发放Cookie作为具唯一性的身份凭证,当用户带着Cookie再次访问站点时,流量分析工具就可以通过Cookie识别新老用户身份,进而计算独立用户数量和会话次数。但是,在2024年9月之前,本站点未启用基于Cookie的分析,所以只能通过IP地址、请求时间和User-Agent三类信息区分不同会话,且不能做到区分用户。

我们认为:来源于同一IP地址、同一User-Agent,且相邻请求间隔小于特定时长(该时长设定为变量max_interval)的请求属于同一会话。为了计算请求间隔时间,首先需要按照同一IP地址、同一User-Agent的汇总条件进行分类汇总操作。我们将DataFrame按照第一关键字remote_addr、第二关键字http_user_agent和第三关键字time_local排序(此排序操作是为了与接下来的分类汇总操作结果的条目索引顺序相匹配),然后调用groupby方法,以第一关键字remote_addr、第二关键字http_user_agent进行分类汇总,汇总量为访问时间time_local,聚合函数(对分类汇总后的每一组中的数据进行统计分析的函数)为差分函数diff(计算每组中相邻请求的间隔时间)。

1
2
blog.sort_values(['remote_addr','http_user_agent','time_local'],inplace=True)
blog['time_diff']=blog.groupby(['remote_addr','http_user_agent'])['time_local'].diff()

其结果如表5所示(为展示简洁仅选择了remote_addrtime_localhttp_user_agenttime_diff四列),其中time_diff中的NaT表示一次会话的起始。在示例中的请求,第1条和第2条间隔仅1秒,而第2条和第3条间相差354天,因此第1、2条应被归为一次会话,同理第3、4条被归为另一次会话。

remote_addr time_local http_user_agent time_diff
1.117.192.188 2022-05-04 00:55:50 Mozilla/5.0 (X11; Linux x… NaT
1.117.192.188 2022-05-04 00:55:51 Mozilla/5.0 (X11; Linux x… 0 days 00:00:01
1.117.192.188 2023-04-23 17:35:55 Mozilla/5.0 (X11; Linux x… 354 days 16:40:04
1.117.192.188 2023-04-23 17:35:56 Mozilla/5.0 (X11; Linux x… 0 days 00:00:01
1.117.192.188 2022-03-06 10:59:03 Mozilla/5.0 (compatible; … NaT
1.117.192.188 2022-03-06 10:59:03 Mozilla/5.0 (compatible; … 0 days 00:00:00
1.117.192.188 2022-05-04 00:56:56 Mozilla/5.0 (compatible; … 58 days 13:57:53
1.117.192.188 2022-05-04 00:56:56 Mozilla/5.0 (compatible; … 0 days 00:00:00
1.117.63.160 2024-03-12 17:23:49 Mozilla/5.0 (iPhone; CPU … NaT
1.117.63.160 2024-03-14 22:21:02 Mozilla/5.0 (iPhone; CPU … 2 days 04:57:13
1.117.66.65 2024-03-12 17:23:49 Mozilla/5.0 (iPhone; CPU … NaT
1.117.66.65 2024-03-12 17:23:49 Mozilla/5.0 (iPhone; CPU … 0 days 00:00:00
1.117.66.65 2024-03-12 17:23:49 Mozilla/5.0 (iPhone; CPU … 0 days 00:00:00
1.117.66.65 2024-03-12 17:23:49 Mozilla/5.0 (iPhone; CPU … 0 days 00:00:00
表5 分类汇总结果示例

接下来编写identify_session函数确定请求所属会话,我们使用uuid.uuid4给每次会话赋唯一标识符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def identify_session(time_diff, max_interval=3600):
"""
Identify sessions based on time differences.
This function takes a list of time differences and identifies sessions based on a maximum interval.
A new session is started if the time difference is greater than the specified maximum interval or if the time difference is NaT (Not a Time).
Parameters:
time_diff (list): A list of time differences (timedelta objects).
max_interval (int, optional): The maximum interval in seconds to consider for the same session. Defaults to 3600 seconds (1 hour).
Returns:
list: A list of session identifiers (UUIDs) corresponding to each time difference.
"""
sessions = [uuid4()]
for i in time_diff[1:]:
if i is pd.NaT: # do not use i == pd.NaT
sessions.append(uuid4())
elif i.seconds > max_interval:
sessions.append(uuid4())
else:
sessions.append(sessions[-1])
return sessions

计算不同绘制max_interval下的会话数(表6)并绘制关系图(图1):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
max_intervals = [
30,
60,
120,
300,
600,
1800,
3600,
7200,
14400,
28800,
43200,
86400,
172800,
]

sessions_vs_max_interval = pd.DataFrame(
data={
"max_interval": max_intervals,
"sessions": [
len(np.unique(x))
for x in [identify_session(blog.time_diff, i) for i in max_intervals]
],
}
)
max_interval sessions
30 98817
60 97175
120 95865
300 94415
600 93609
1800 92511
3600 91842
7200 91021
14400 90248
28800 89285
43200 88686
86400 86855
172800 84233
表6 会话数与max_interval的关系
图1 会话数与max_interval的关系

可见随着max_interval的增大,曲线由陡峭下降转为平缓下降。我们在陡峭与平缓的过渡处取3600秒(1小时)作为参数,区分出91842次会话,将请求所属会话的UUID存储于session_uuid列(分析证明max_interval即使取得很大如86400,后续分析结果差异也不大),接下来便可以计算每次会话的请求频率了。

1
blog["session_uuid"] = identify_session(blog.time_diff, max_interval=3600)

请求频率计算与筛选:

用户访问网页时,为了构建和渲染完整的页面,浏览器除了请求HTML文档外,还需要请求文档中指明需加载的CSS、JS文件和媒体资源(如图片、视频等)。假设页面中含有10张图片,那么访问一个页面时的请求频率可能超过10次/秒。由于不同页面包含的多媒体资源数量不一,导致页面的“正常”请求频率也不同,这给按请求频率区分机器人和正常用户造成了困难。为应对此问题,我们只计算对HTML文档发起请求的频率。

使用以下正则表达式筛选对HTML文档(包括主页、所有博文、归档页、类别页)的请求(is_pageview)和仅对博文页面的请求(is_postview),符合前者的请求共有72578次(11.1%),符合后者的请求共有38573次(5.9%)。包含对HTML文档请求的会话共有62679次(用blog[blog.is_pageview].session_uuid.unique().size得出),占总会话数的68.2%,占总剩余请求数的89.5%。

1
2
3
4
5
6
7
8
from urllib.parse import unquote

blog["request_uri"]=blog.request_uri.apply(unquote) # 将其中的请求资源定位符使用urllib.parse.unquote进行解码

blog["is_pageview"] = blog.request_uri.str.match(
r"^/(posts/[a-z0-9]+/|archives/\w+/|categories/\w+/|)$"
)
blog["is_postview"] = blog.request_uri.str.match(r"^/posts/[a-z0-9]+/$")

以会话为单位,计算对页面HTML文档的请求的频率(以Hz为单位,即每秒访问多少个页面):

1
2
3
4
5
6
7
session_requests_freq = (
blog[blog.is_pageview].groupby("session_uuid").size()
/ blog[blog.is_pageview]
.groupby("session_uuid")
.time_local.apply(lambda x: (x.max() - x.min()))
.dt.total_seconds()
).apply(lambda x: 0 if x == np.inf else x)
图2 请求频率分布

在0-2Hz范围内作直方图(图2,直方图不包括0Hz即只访问一个页面的会话,共58741次)及累加曲线 ,可见在1Hz和2Hz频率存在显著峰值,这很可能是自动程序的频率设置。我们认为请求频率超过0.1Hz(10秒之内访问多于1个页面)的为异常(图中红色虚线右侧),将1399次会话(2.3%)和87973个请求标记为机器人流量并剔除,剩余61977次会话和566490个请求,占上步筛选剩余请求数(653620)的86.5%,占总请求数(1186635)的47.7%。

1
2
3
valid_sessions = session_requests_freq[session_requests_freq < 0.1].index.tolist()

blog["is_bot"] = ~blog.session_uuid.isin(valid_sessions)

继续筛选对页面HTML文档的请求,生成名为blog_real_pageview的DataFrame,并去除其中2024年9月的部分(此月份数据不完整),blog_real_pageview中共含有来自60432次会话的65349个请求。

1
2
3
4
5
6
7
blog_real_pageview = (
blog[blog.is_pageview & (~blog.is_bot)].sort_values("time_local").copy()
)

blog_real_pageview.drop(columns=["status", "method", "is_ok", "is_bot"], inplace=True)

blog_real_pageview=blog_real_pageview[blog_real_pageview.time_local <= '2024-09-01'].copy() # 去除不完整的2024年9月的部分

至此我们完成了数据清洗,将以blog_real_pageview为数据源进行后续分析。

3. 访问量分析

3.1 页面总访问量

为了获取每个请求对应页面的标题,需要读取博文的markdown源文档,标题和链接(abbrlink)在源文档的front matter中:

1
2
3
4
5
6
7
8
9
10
11
allposts = glob.glob("hexo/source/_posts/*.md")
post_metadata = pd.DataFrame([read_front_matter(fn) for fn in allposts])
blog_real_pageview["abbrlink"] = blog_real_pageview.request_uri.str.extract(
r"\/posts\/([a-z0-9]+)/$"
)
blog_real_pageview = pd.merge(
blog_real_pageview, post_metadata[["title", "abbrlink"]], on="abbrlink", how="left"
)
blog_real_pageview.fillna({"abbrlink": "/", "title": "主页"}, inplace=True)
blog_real_postview = blog_real_pageview[blog_real_pageview.is_postview].copy()
blog_real_postview.reset_index(drop=True, inplace=True)

使用blog_real_pageview.title.value_counts()计算各页面访问量(表7、图3)。若包括主页,访问量总计为65349;若排除主页只统计博文,则访问量总计为34588(以下分析均排除主页访问,使用blog_real_postview为数据源)。其中《X射线吸收精细结构——原理及应用 》访问量最高,达13006次(37.6%);访问量前5名占总量的72.9%,前10名占总量的87.9%。

title abbrlink count
主页 / 30761
X射线吸收精细结构——原理及应用 ca2188fc 13006
HER 和 OER 反应机理与其 Tafel 斜率推导 cd97ad66 5174
三个电化学基本方程的推导 243b81e8 2860
校园网环境下软路由及NAS的搭建 69f0ff47 2179
《神经科学——探索脑》 第一篇(1-7章)思维导图及课后答案 241ae90f 2008
神经电极的发展历程 abcc4c8b 1651
深入理解主成分分析 PCA d195a098 1054
同位素峰强度分布计算原理及python实现 ddeab4f8 1026
2048游戏算法的进一步研究及C++语言实现 5d16216f 814
膜蒸馏技术——原理与应用 4ecad084 639
基于Python的Bilibili和Bangumi动画评分综合分析 a9da3a59 613
生物化学和化学生物学的区别 bf4db457 539
基于 nginx 的高性能 jsdelivr 反向代理 ffee5413 515
化学分析和仪器分析的区别 5bbc70af 425
神经科学的早期发展 2ebf08cd 359
涉及X射线研究的诺贝尔奖 158db4df 311
两岸统一态度调研 505831fa 277
电解水过程中的催化普适性原理 6d41b4bf 246
互联网时代的艺术:LBRY简介 3c74ee4e 242
线性回归的显著性检验 3a8848f3 229
安全套的前世今生——高分子材料如何改变着这个世界 815b5cac 157
追迹互联网(2010-2016) c9caace5 106
LBRY 汉化相关问题 3916b4b8 90
ロビンソン和STYX HELIX中文翻译 9f288f6d 68
表7 博文访问量
图3 博文访问量分布

本博客自上线之日起就使用了不蒜子计数器, 该计数器对博文访问量(PV)的统计结果与本分析得出的结果十分接近(误差小于10%),相互印证了两种计数方式的准确性。

3.2 月度访问量

使用blog_real_postview.time_local.dt.to_period("Y")to_period("M")提取请求所在年份或月份,通过分类汇总(如blog_real_postview.groupby("Year").size())得到年度或月度访问量变化:

1
2
3
blog_real_postview["Year"] = blog_real_postview.time_local.dt.to_period("Y")
blog_real_postview["Month"] = blog_real_postview.time_local.dt.to_period("M")
blog_real_postview["Day"] = blog_real_postview.time_local.dt.to_period("D")

2022年(2月-12月)、2023年(全年)、2024年(1月-8月)的访问量分别为5222,9608和19758。月度访问量变化如图4所示。可见自2024年3月起,博文月访问量从1000左右陡增至3000左右,我们将在后续分析中探查访问量陡增的原因。

图4 月度访问量分布

3.3 页面访问量变化

上文中我们计算了每篇博文全部时间的浏览量和按月统计的所有博文浏览量,那么可否将二者结合起来,即同时按月份和按每篇博文进行访问量统计?pandas的数据透视表(pivot table) 功能完美符合我们的需求。

首先,为了使图表整洁一些,我们只取总访问量前10名的博文进行透视分析。

1
top10_posts=blog_real_postview.abbrlink.value_counts().head(10).index.values

然后,使用pd.pivot_table创建数据透视表,将 Month 列作为数据透视表的行索引,将 abbrlink 列作为数据透视表的列索引,使用 size 作为聚合函数,计算每个组合的大小(即访问量计数),并将缺失值(即某文章某月访问量为0的情况)填充为0。

1
2
3
4
5
6
7
views_by_post_by_month = pd.pivot_table(
blog_real_postview[blog_real_postview.abbrlink.isin(top10_posts)],
index="Month",
columns="abbrlink",
aggfunc="size",
fill_value=0,
)

创建的数据透视表(表8)如下:

Month 241ae90f 243b81e8 4ecad084 5d16216f 69f0ff47 abcc4c8b ca2188fc cd97ad66 d195a098 ddeab4f8
2022-02 33 24 0 1 62 0 126 0 34 19
2022-03 148 36 0 3 119 0 112 0 29 19
2022-04 59 29 0 5 73 0 211 0 51 27
2022-05 41 37 0 8 57 0 300 4 73 35
2022-06 45 65 0 24 75 165 318 50 33 32
2022-07 35 49 0 1 43 8 90 32 10 15
2022-08 32 25 9 1 62 6 53 14 12 6
2022-09 18 25 3 3 56 11 71 6 13 7
2022-10 33 56 4 0 74 8 76 24 15 13
2022-11 85 49 17 1 78 17 127 28 22 16
2022-12 72 50 31 6 156 10 153 34 20 13
2023-01 41 28 6 2 49 8 114 54 14 4
2023-02 77 43 16 3 110 17 191 67 23 19
2023-03 56 47 33 1 101 26 264 146 31 14
2023-04 38 33 13 1 55 34 184 147 10 24
2023-05 34 52 36 9 85 74 236 192 8 22
2023-06 33 28 24 1 45 47 268 128 15 33
2023-07 46 28 14 4 37 38 217 106 11 23
2023-08 63 27 27 5 60 31 203 88 5 20
2023-09 66 43 22 3 43 44 276 134 11 26
2023-10 100 62 30 8 31 109 373 152 21 39
2023-11 118 63 29 17 61 66 355 171 15 41
2023-12 117 65 27 48 47 49 492 206 43 40
2024-01 77 71 16 7 91 47 476 173 32 32
2024-02 33 46 21 2 73 32 346 116 9 14
2024-03 87 148 24 18 70 79 1089 360 21 51
2024-04 98 164 49 38 54 168 1066 561 41 71
2024-05 104 225 50 86 93 122 1122 704 81 81
2024-06 89 405 40 172 100 111 1446 557 49 115
2024-07 78 458 59 189 66 145 1331 477 137 99
2024-08 52 379 39 147 53 179 1320 443 165 56
表8 数据透视表views_by_post_by_month

我们使用matplotlib 的stackplot(堆叠面积图)可视化数据透视表的内容:

1
2
3
4
5
6
7
8
colors = plt.cm.tab20.colors  # Ensure enough colors
ax.stackplot(
views_by_post_by_month.index.to_timestamp(),
views_by_post_by_month.T,
labels=views_by_post_by_month.columns,
colors=colors[: views_by_post_by_month.shape[1]],
alpha=0.8,
)
图5 博文访问量堆叠面积图

可见,2024年3月及之后,除了241ae90f这篇博文外,其他文章的访问量均有增加。我们可以进一步计算每月每篇文章的访问量占比并绘图(图6),这样既可以识别出哪些文章在特定时间段内更受欢迎,又可以避免访问量陡增造成的视觉上的干扰:

1
2
3
views_by_post_by_month_ratio = views_by_post_by_month.div(
views_by_post_by_month.sum(axis=1), axis=0
)
图6 博文访问量占比堆叠面积图

从上图中我们发现,在2024年3月前后,各页面浏览量占比并没有出现特别剧烈的变化。综合以上信息,我们认为:访问量陡增并不是因为部分博文浏览量突然增加,而是整个站点在搜索引擎上的曝光度增加导致的。在下一节中我们证实了这一猜测。

4. 来源分析

4.1 用户地区分布

使用geoip2库解析IP属地:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import geoip2.database
from geoip2.errors import AddressNotFoundError

reader = geoip2.database.Reader("Country.mmdb")

def get_iso_code_from_ip(ip):
try:
iso_code = reader.country(ip).country.iso_code
except AddressNotFoundError:
iso_code = "None"
return iso_code

blog_real_pageview["country"] = blog_real_pageview.remote_addr.apply(
get_iso_code_from_ip
)

分类汇总结果(图7)显示,68.5%的用户来自中国内地,来自美国、新加坡、中国香港、日本的用户位列其后。不过可以肯定的是,由于本网站服务器位于香港,部分中国内地用户访问本网站时,会因分流规则致其使用非真实IP发起请求。因此,实际来自中国内地的用户占比应更大,可能超过80%。

图7 用户来源地区分布

4.2 客户端分布

User-Agent当中可提取出用户使用的客户端(桌面端或移动端)、浏览器和操作系统类型。编写parse_user_agent函数,通过正则表达式匹配和提取以上信息。

需要注意的是,因为兼容性等原因,User-Agent中可能包含其他浏览器的标识。比如,ChromeSafari标识就出现在Edge和Opera浏览器的User-Agent当中(这些浏览器虽有特有标识,但出现于通用标识之后。比如Edge浏览器的标识是Edg,但以Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0形式出现)。所以,在匹配浏览器信息时,需要注意匹配的先后顺序。在匹配逻辑中如果通用标识Chrome在先,将会忽略后面可能出现的特有标识,进而造成误判。因此,对通用标识的匹配应排在最后,确保特有标识先被匹配到,详见以下代码中的browser_patterns

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def parse_user_agent(user_agent):
browser_patterns = [
(r"MicroMessenger/([0-9.]+)", "WeChat"),
(r"Opera/([0-9.]+)", "Opera"),
(r"Edg/([0-9.]+)", "Edge"),
(r"(QQBrowser|MQQBrowser)/([0-9.]+)", "QQ"),
(r"XiaoHongShu/([0-9.]+)", "XiaoHongShu"),
(r"Telegram/([0-9.]+)", "Telegram"),
(r"MSIE ([0-9.]+)", "Internet Explorer"),
(r"Firefox/([0-9.]+)", "Firefox"),
(r"Chrome/([0-9.]+)", "Chrome"),
(r"Safari/([0-9.]+)", "Safari"),
]

os_patterns = [
(r"Windows NT ([0-9.]+)", "Windows"),
(r"Linux (\w+)", "Linux"),
(r"Mac OS X ([0-9_]+)", "Mac OS"),
(r"Android ([0-9.]+)", "Android"),
(r"CPU (iPhone) OS ([0-9_]+)", "iPhone OS"),
(r"CPU (iPad) OS ([0-9_]+)", "iPad OS"),
(r"CPU (OS) ([0-9_]+)", "Unspecified iOS"),
]

device_patterns = [(r"Mobile", "Mobile")]

# initialize the result dict
result = {
"browser": "Others",
"browser_version": "Unknown Version",
"os": "Unknown OS",
"os_version": "Unknown Version",
"device_type": "Desktop",
}

# parse browser info
for pattern, name in browser_patterns:
match = re.search(pattern, user_agent, re.IGNORECASE)
if match:
result["browser"] = name
result["browser_version"] = match.group(1)
break

# parse os info
for pattern, name in os_patterns:
match = re.search(pattern, user_agent, re.IGNORECASE)
if match:
result["os"] = name
result["os_version"] = match.group(1)
break

# parse device info
for pattern, device in device_patterns:
if re.search(pattern, user_agent, re.IGNORECASE):
result["device_type"] = device
break
return result

http_user_agent列运用apply方法,将解析结果保存在名为user_agent_parsed的字典中,绘制各属性分布扇形图(其中对于操作系统和浏览器,第4名之后不再单独列出)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
user_agent_parsed = pd.DataFrame(
blog_real_postview.http_user_agent.apply(parse_user_agent).values.tolist()
)

fig = plt.figure(figsize=(1.5 * 3, 1), dpi=300)

axes = fig.subplots(1, 3)

for ax, col, col_name, topn in zip(
axes, ["device_type", "os", "browser"], ["Device", "OS", "Browser"], [2, 4, 4]
):

top_n = user_agent_parsed[col].value_counts().head(topn)
others = user_agent_parsed[col].value_counts().iloc[topn:].sum()

if others > 0:
labels = list(top_n.index) + ["Others"]
sizes = list(top_n.values) + [others]
else:
labels = list(top_n.index)
sizes = list(top_n.values)

ax.pie(
sizes,
labels=labels,
autopct="%1.1f%%",
textprops=dict(fontsize=4),
pctdistance=0.5,
labeldistance=1.2,
wedgeprops=dict(width=0.25, edgecolor="w"),
startangle=30,
)

ax.set_title(f"{col_name} distribution")
图8 客户端分布

由上图可得:

  • 客户端和操作系统方面:桌面端-移动端比例为9:2。其中,桌面端Windows-MacOS比例约为7:1,移动端安卓-苹果比例约为7:2;
  • 浏览器方面:Chrome和Edge分庭抗礼,两者共占据80%以上的份额。

在不单独列出的Others部分,存在以下有趣的事实:

  • 3.6%的用户来源于Linux系统;
  • 2.4%的用户使用微信内置浏览器(标识为MicroMessenger)访问本网站,其中80%的请求被微信安全中心拦截,但这些用户仍选择继续访问。这是结合对HTTP来源地址的分析得到的结果。如果用户是从微信安全中心拦截页面(域名为weixin110.qq[.]com)跳转过来的,那么请求头的HTTP来源地址中会包含weixin110字样。对HTTP来源地址的分析详见下节。

4.3 HTTP来源分析

如果用户是从其他页面(比如搜索引擎结果页面)跳转到本站的,并且浏览器安全设置处于较宽松模式,那么请求头中的HTTP来源地址将包含来源页面的域名,还可能获得来源页面的完整URL,比如带搜索词的完整搜索链接(如google[.]com/search?q=123456),这使得我们能进行(搜索引擎)来源分析和关键字研究。

首先我们计算来源于各搜索引擎、CSDN、本站(自引用)、微信安全中心拦截页面的点击数及其比例(表9):

1
blog_real_postview['source']=blog_real_postview.http_referer.str.extract(r'(Bing|Baidu|Google|Yahoo|Sogou|360|DuckDuckGo|Yandex|CSDN|jaspirit|weixin110)',flags=re.IGNORECASE)
Source Requests Ratio
Bing 13292 60.43%
Google 4756 21.62%
jaspirit 1901 8.64%
Baidu 1059 4.81%
weixin110 705 3.21%
CSDN 200 0.91%
Yandex 28 0.13%
DuckDuckGo 16 0.07%
Yahoo 13 0.06%
Sogou 13 0.06%
360 11 0.05%
表9 客户端分布

一共有22013次(63.6%)博文访问的HTTP来源地址不为空,上列来源能包含这部分几乎全部(99.91%)的请求。由上表可得:

  • 搜索引擎方面,Bing、Google和Baidu占绝大多数,三者合计占86.9%。计算每个搜索引擎点击量前5名的博文的点击量分布(图9)。其中,百度对本站收录意愿不大,只收录了几篇有外链链接的博文,本站在百度上的可见度和曝光量也远不如其他两个搜索引擎大;
  • 自引用占比8.6%;
  • 由于博主在CSDN上本人转载了本博客的几篇博文,有不到1%的来源于CSDN查看原文的访客;
  • 来源于微信(微信安全中心拦截页面)的比例要比上节中计算的2.4%高,是因为来源于此页面的请求基本不会隐藏HTTP来源地址。相比于其他浏览器,隐藏HTTP来源地址的比例低,导致最终占比偏高。
图9 每个搜索引擎最高点击量的5篇博文点击量分布

为了研究三家搜索引擎点击量随时间的变化,我们仿照前文方法构建数据透视表views_by_source_by_month,这次是以source作为汇总的列索引:

1
2
3
4
5
6
7
8
9
10
11
views_by_source_by_month = pd.pivot_table(
blog_real_postview[blog_real_postview.source.isin(["bing", "google", "baidu"])],
index="Month",
columns="source",
aggfunc="size",
fill_value=0,
)

views_by_source_by_month_ratio = views_by_source_by_month.div(
views_by_source_by_month.sum(axis=1), axis=0
)

绘制点击量变化(图10)和相对占比变化(图11),可见从2024年3月起,本站在Bing上的点击量陡增,相比之下来自于Baidu和Google的点击量变化不大,这导致Bing的相对占比陡增,从60%左右上升到80%左右。因此,本站于2024年3月起的访问量陡增可归因于在Bing上的可见性和曝光度的增加。

图10 搜索引擎点击量堆叠面积图
图11 搜索引擎来源占比堆叠面积图

4.4 关键字研究

可以进一步提取搜索词进行关键字研究。我们首先提取HTTP来源地址中非自引用的外链,去重后使用urllib.parse.unquote解码URL:

1
2
3
4
5
6
7
8
9
10
from urllib.parse import unquote

extlinks = [
i
for i in blog_real_postview.http_referer.astype(str)
if ("jaspirit.cc" not in i) and (i != "-")
]

extlinks_set = set(extlinks)
extlinks_set = list(map(unquote, extlinks_set))

得到632个不重复的外链extlinks_set。接下来编写函数从其中提取搜索字符串,并删除只包含英文和数字字符的字符串(它们一般不包含搜索词)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def extract_search_string(url):
# define the pattern to extract search string
pattern = re.compile(r"[?&](q|query|wd|search|keyword|key|p|k)=([^&]*)")
match = pattern.search(url)
if match:
return match.group(2)
return None

# extract search strings from external links
search_strings = [
extract_search_string(url)
for url in extlinks_set
if extract_search_string(url) is not None
]

# remove empty strings and strings without Chinese characters
search_strings = [
i for i in search_strings if (len(i) and re.search(r"[\u4e00-\u9fff]", i))
]

得到以下搜索关键词/关键字。关键字研究可以帮助站长了解用户需求,通过使用更匹配用户需求的关键字提升网站点击率。理论上,可进一步进行分词和词云分析,但本文因篇幅原因不再详述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
神经电极的发展历程
电化学极化方程
钾离子通道的分类
诺贝尔奖x线
精细结构光谱
极化过程
X射线有关的+诺贝尔奖
校园网接软路由
神经科学探索脑第四版中文
软路由 连接校园网
校园网软路由
xanes光谱分析
2048算法
LBRY+服务器
nist查阅同位素丰度
oer反应机理
OER和HER的塔菲尔斜率
tafel斜率表达式
xafs+材料科学
xafs测试
b站动漫排行榜是怎么算的
HER性能提高的机理
XAS峰面积与电子转移
xafs测试
校园网无法挂载webdav
xafs原理
同位素分布图软件
OER机理
X射线吸收模型
主成分分析
白线峰强度与成键数目的关系
生物化学和化学生物学有什么区别
金属近边及拓展边
校园+nas
两电子转移HER
her反应机理
键长+X射线吸收光谱+
pca+等于零
代理cdn.jsdelivr.net
xanes光谱分析
群晖+校园网
HER反应
分析同位素峰簇的相对强度
氧析出的决速步骤
同位素计算器的算法
jsdelivr代理
校园网玩软路由
软路由+校园网
同步辐射X射线吸收谱
X射线吸收边分析
校园网+软路由
AMPA和GABA的区别
MC什么意思电极
神经细胞屋
由tafel方程式判断析氢过电位

5. 结语

通过对本站点的Nginx日志进行深入分析,我们获得了丰富的网站流量信息,这些信息对于理解网站运营情况和用户行为具有重要意义。主要发现包括:

  • 数据清洗过程中,我们识别并剔除了大量机器人和攻击流量,最终保留了47.7%的请求,这凸显了互联网环境中自动化程序的普遍存在。
  • 访问量分析显示,少数几篇文章贡献了大部分的流量,其中《X射线吸收精细结构——原理及应用》一文的访问量最高,占比37.6%。
  • 来源分析显示,桌面端用户占主导地位(约82%),其中Windows系统用户最多。在浏览器方面,Chrome和Edge是最常用的两种。
  • 结合访问量分析和来源分析,发现搜索引擎是主要流量来源,其中Bing贡献了最多的流量(60.43%)。特别是从2024年3月开始,来自Bing的流量显著增加,这解释了总体访问量的突然上升。

值得注意的是,尽管服务器日志分析提供了丰富的信息,但它也有局限性。例如,无法准确区分独立用户,也难以追踪用户在站内的完整行为路径。因此,对于需要更深入用户行为分析的网站,可以考虑结合使用如Google Analytics等专业的流量分析工具。

总的来说,这种基于服务器日志的分析方法为小型、非商业性质的网站提供了一个简单但强大的流量分析途径,既能最大限度地保护用户隐私,又能获得有价值的网站流量信息。