第 3 章
文本处理(1)
1.2 文本处理
写在前面
让我先回忆一下我用过的文本处理工具:
编辑器:
vim:只会:q!(不保存退出)和:wq(保存退出),其他记不住nano:这个好用!Ctrl+K删除行,Ctrl+O保存,Ctrl+X退出
⭐ 常用操作(必须记住):
| 编辑器 | 操作 | 快捷键 |
|---|---|---|
| nano | 删除整行 | Ctrl+K |
| nano | 保存 | Ctrl+O |
| nano | 退出 | Ctrl+X |
| vim | 进入编辑模式 | i |
| vim | 退出编辑模式 | Esc |
| vim | 保存并退出 | :wq |
| vim | 不保存退出 | :q! |
补充知识(了解即可):
| 编辑器 | 操作 | 快捷键 |
|---|---|---|
| nano | 搜索 | Ctrl+W |
| nano | 粘贴已删除的行 | Ctrl+U |
| vim | 删除整行 | dd |
| vim | 搜索 | /关键词 |
命令行工具:
grep:会用grep "关键词"搜索awk、sed:完全不会,每次都问 AI
我有两个 Shell 脚本:check_security.sh 检查服务器安全状态,check_nginx.sh 分析网站访问日志。说实话,这两个脚本是 AI 帮我写的,里面有很多 awk、grep、sed 命令,我看不太懂。
这次的目标:通过分析这些脚本,学会文本处理的常用操作。
文本处理的对象
| 对象 | 例子 | 场景 |
|---|---|---|
| 日志文件 | nginx access.log、auth.log | 分析访问、排查问题 |
| 命令输出 | ps aux、df -h、netstat | 提取进程信息、磁盘使用等 |
| 配置文件 | nginx.conf、.env | 批量修改配置(sed 替换,下一节学) |
本节主要以日志分析为例,命令输出会在后续章节学习相关命令时用到。
什么时候用什么工具?(我的习惯)
| 场景 | 选择 | 原因 |
|---|---|---|
| 服务器上分析日志 | grep/awk/sed | 日志就在 Linux 上,管道组合自然 |
| 处理 CSV、JSON 数据 | Python | pandas 更熟悉,功能更强 |
| 复杂正则表达式 | 问 AI | 没必要死记硬背 |
日志是 Linux 生态的产物(nginx、sshd 等服务生成),在终端直接处理最方便,不用下载到本地再用其他工具。
场景1:认识 Nginx 和它的日志
Nginx 是什么?
用我的博客举例:
用户访问 yustia.com 时发生了什么?
┌──────────┐ ┌─────────────────┐ ┌──────────────────┐
│ 用户 │────▶│ Nginx │────▶│ 博客后端 │
│ 浏览器 │◀────│ (反向代理) │◀────│ (FastAPI) │
└──────────┘ └─────────────────┘ └──────────────────┘
│
▼
/var/log/nginx/
access.log ← 记录每个请求
Nginx 做了什么:
- 接收请求 - 用户访问网站,Nginx 先收到
- 转发请求 - 把请求转给后端 FastAPI
- 返回响应 - 把后端的响应返回给用户
- 记录日志 - 每个请求都记一笔
为什么需要 Nginx,不直接访问 FastAPI?
- FastAPI 跑在 8000 端口,但用户访问的是 80/443 端口
- Nginx 处理 HTTPS 证书
- Nginx 可以同时代理多个服务
- Nginx 能做负载均衡、缓存、限流...
关于"网关":网关是个通用概念,指"入口"。路由器网关是家庭网络的出口,Nginx 是 Web 服务的入口,说它是"Web 网关"也没错。
日志在哪里?
ls -la /var/log/nginx/
access.log ← 当前日志(今天的)
access.log.1 ← 昨天的
access.log.2.gz ← 前天的(压缩了)
...
access.log.14.gz ← 14天前的
这是 logrotate(日志轮转) 在工作:每天把旧日志重命名并压缩,防止日志文件撑爆磁盘。
查看压缩的旧日志:
# 直接查看 .gz 文件内容(不用解压)
zcat /var/log/nginx/access.log.2.gz | head -10
# 在 .gz 文件中搜索
zgrep "404" /var/log/nginx/access.log.2.gz
一条日志长什么样?
tail -1 /var/log/nginx/access.log
20.64.104.27 - - [14/Jan/2026:11:26:56 +0800] "GET /developmentserver/metadatauploader HTTP/1.1" 404 134 "-" "Mozilla/5.0 zgrab/0.x"
拆解:
| 内容 | 含义 |
|---|---|
20.64.104.27 | IP 地址 |
- | 用户标识(通常是 -) |
- | 认证用户(通常是 -) |
[14/Jan/2026:11:26:56 +0800] | 时间戳 |
"GET /developmentserver/metadatauploader HTTP/1.1" | 请求内容 |
404 | 状态码(没找到) |
134 | 响应大小(字节) |
"-" | 来源页面(Referer) |
"Mozilla/5.0 zgrab/0.x" | 客户端标识(User-Agent) |
这条日志在说什么?
IP 为 20.64.104.27 的人,在 11:26:56 访问了
/developmentserver/metadatauploader,返回了 404,用的客户端是zgrab。
zgrab 是一个扫描工具!有人在扫描我的服务器,看看有没有漏洞。
常见 HTTP 状态码
| 状态码 | 含义 | 说明 |
|---|---|---|
| 200 | OK | 成功 |
| 301 | Moved Permanently | 永久重定向(HTTP → HTTPS) |
| 302 | Found | 临时重定向 |
| 304 | Not Modified | 缓存命中,内容没变 |
| 400 | Bad Request | 请求格式错误 |
| 403 | Forbidden | 被禁止访问(被拦截) |
| 404 | Not Found | 页面不存在 |
| 405 | Method Not Allowed | 请求方法不允许 |
| 500 | Internal Server Error | 服务器内部错误 |
| 502 | Bad Gateway | 网关错误(后端挂了) |
| 503 | Service Unavailable | 服务不可用 |
实时监控日志
# tail -f 实时跟踪日志新增内容
tail -f /var/log/nginx/access.log
# 实时监控并过滤特定内容
tail -f /var/log/nginx/access.log | grep "404"
按 Ctrl+C 退出。
场景2:awk 取列
基本用法
awk 默认按空格分割每一行,$1 是第一列,$2 是第二列...
# 取第一列(IP)
awk '{print $1}' /var/log/nginx/access.log
# 取第一列和第九列(IP 和状态码)
tail -10 /var/log/nginx/access.log | awk '{print $1, $9}'
输出:
114.80.36.40 200
102.22.20.125 200
93.174.93.12 200
102.22.20.125 404
147.182.151.27 403
...
💡 Tip:
cat file | awk '{...}'可以简写成awk '{...}' file,省掉cat。
查看每一列是什么
不确定哪一列是什么?用这个命令:
tail -1 /var/log/nginx/access.log | awk '{for(i=1;i<=NF;i++) print i": "$i}'
输出:
1: 20.64.104.27
2: -
3: -
4: [14/Jan/2026:11:26:56
5: +0800]
6: "GET
7: /developmentserver/metadatauploader
8: HTTP/1.1"
9: 404
10: 134
11: "-"
12: "Mozilla/5.0
13: zgrab/0.x"
我的疑问:awk 语法怎么理解?
awk '{command}'是固定格式吗?NF是什么?
awk 基本语法:
awk '{这里写操作}' 文件名
# 或者
cat 文件 | awk '{操作}'
单引号里的 {...} 是对每一行执行的操作。
⭐ awk 符号(必须记住)
| 符号 | 含义 | 例子 |
|---|---|---|
$1 | 第 1 列 | IP 地址 |
$9 | 第 9 列 | 状态码 |
$NF | 最后一列 | 不管有几列都能取到 |
-F',' | 指定分隔符 | 处理 CSV |
补充知识(了解即可):
| 符号 | 含义 | 用途 |
|---|---|---|
$0 | 整行 | 打印完整的一行 |
NF | 列数(不带 $) | 这一行有几列 |
$i | 第 i 列(i 是变量) | 循环时用 |
循环语法:
for(i=1; i<=NF; i++)
# ↑ ↑ ↑
# 从1开始 到列数 每次+1
$i 表示"第 i 列",i 是变量。所以循环可以遍历所有列。
基础命令补充
在学习统计套路之前,先补充几个基础命令。
管道 |
把上一个命令的输出,作为下一个命令的输入:
cat file.txt | grep "error" | wc -l
# ↓ ↓ ↓
# 读取文件 过滤包含error的行 统计行数
可以串联很多命令,数据像水一样流过每个"管道"。
head 和 tail
head -10 file.txt # 取前 10 行
tail -10 file.txt # 取后 10 行
tail -1 file.txt # 取最后 1 行
tail -f file.txt # 实时跟踪新增内容(Ctrl+C 退出)
⭐ wc 统计(必须记住)
wc = word count(统计)
wc -l file.txt # 统计行数(lines)
最常用:| wc -l 统计管道输出有多少行。
# 例:统计有多少个 404 请求
cat access.log | grep "404" | wc -l
补充知识(了解即可):
wc -w file.txt # 统计单词数(words)
wc -c file.txt # 统计字节数(characters/bytes)
echo -e
-e 让 echo 识别转义字符:
echo "apple\nbanana" # 输出:apple\nbanana(原样输出)
echo -e "apple\nbanana" # 输出两行:apple 和 banana(\n 变成换行)
场景3:管道统计套路
手动看10条有什么意义?
我可以看10条日志,但我能回答这些问题吗:
- 今天一共有多少个不同的 IP 访问过?
- 哪个 IP 访问最多?是不是在攻击我?
- 有多少请求返回了 404?
看10条日志回答不了这些问题,需要分析全部日志!
统计各状态码数量
cat /var/log/nginx/access.log | awk '{print $9}' | sort | uniq -c | sort -rn
输出:
677 301
594 200
274 403
117 404
24 "-"
21 400
18 405
6 166
1 304
1 206
1 150
畸形请求分析
注意输出中有些奇怪的值:
24 "-" ← 状态码位置是 "-"
6 166 ← 不是标准状态码
1 150 ← 也不是标准状态码
这些是畸形请求,不是正常的 HTTP 请求。可能是:
- 扫描器发的乱码
- 协议探测(比如试试你的服务器是不是 SOCKS5 代理)
- 连接中断导致日志不完整
我的疑问:应该在哪里过滤这些畸形请求?
存日志时过滤,还是统计时过滤?
答案:在统计时过滤,不要在存日志时过滤!
原因:
- 原始日志要保留完整信息,方便事后分析
- 畸形请求本身也有价值(比如想知道谁在扫描你)
- 统计时过滤很简单,用
grep就行
# 只统计正常的 HTTP 状态码(1xx-5xx)
cat access.log | awk '{print $9}' | grep -E '^[1-5][0-9][0-9]$' | sort | uniq -c | sort -rn
拆解统计套路
# 第1步:提取状态码列
awk '{print $9}'
# 输出:200, 404, 200, 301, ...
# 第2步:排序(为 uniq 做准备)
| sort
# 输出:200, 200, 301, 404, ...
# 第3步:去重并计数
| uniq -c
# 输出:
# 2 200
# 1 301
# 1 404
# 第4步:按数量降序排列
| sort -rn
# 第5步(可选):只取前10个
| head -10
我的疑问:为什么 uniq 要先 sort?
相邻去重操作在程序实现上很方便,O(n),而完整去重似乎是 O(n²)?为什么会设计出这个命令?
历史原因:1970年代内存很贵!
相邻去重:只需要记住"上一行" → 内存 O(1)
完整去重:需要记住"所有见过的行" → 内存 O(n)
当时的解决方案:
sort(可以用磁盘做外部排序,内存要求低)uniq(只比较相邻行,内存几乎为0)
验证:
# 不排序直接 uniq
echo -e "apple\nbanana\napple" | uniq
# 输出:apple, banana, apple ← 没去重!
# 先排序再 uniq
echo -e "apple\nbanana\napple" | sort | uniq
# 输出:apple, banana ← 正确去重
⭐ sort 和 uniq 参数(必须记住)
sort # 排序(为 uniq 做准备)
sort -rn # 倒序 + 按数字 = 从大到小
uniq -c # 去重并计数(count)
补充知识(了解即可):
sort 其他参数:
sort -n # 按数字排序(numeric)
sort -r # 倒序(reverse)
为什么需要 -n?
# 不加 -n(按字母排序)
echo -e "9\n80\n100" | sort
# 输出:100, 80, 9 ← 因为 "1" < "8" < "9"(比较首字符)
# 加 -n(按数字排序)
echo -e "9\n80\n100" | sort -n
# 输出:9, 80, 100 ← 正确的数字顺序
uniq 其他参数:
uniq # 只去重
uniq -d # 只显示重复的行(duplicate)
uniq -u # 只显示不重复的行(unique)
小测验
统计今天访问最多的 10 个 IP,写出命令?
我的回答:
cat /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -10
✅ 正确!(head -10 和 head -n 10 效果一样)
场景4:排查可疑 IP
发现 → 排查 → 处理的流程
┌─────────────────────────────────────────────────────────────────┐
│ 1. 发现可疑 │
│ 统计访问量 → 找出异常 IP │
│ │
│ 2. 排查行为 │
│ 看这个 IP 访问了什么 → 判断是扫描器还是正常用户 │
│ │
│ 3. 处理(本节不涉及) │
│ 封禁 IP → fail2ban / ufw / iptables │
└─────────────────────────────────────────────────────────────────┘
第一步:找出谁访问最多
cat /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -10
第二步:看这个 IP 在干什么
# 看这个 IP 的所有请求
cat /var/log/nginx/access.log | grep '可疑IP' | head -20
# 只看这个 IP 访问了哪些路径
cat /var/log/nginx/access.log | grep '可疑IP' | awk '{print $7}'
# 统计这个 IP 访问最多的路径
cat /var/log/nginx/access.log | grep '可疑IP' | awk '{print $7}' | sort | uniq -c | sort -rn
如果发现它在访问 /wp-admin、/.env、/admin.php 这些路径,基本可以确定是扫描器。
场景5:awk 指定分隔符
问题:列数不对?
tail -1 /var/log/nginx/access.log | awk '{for(i=1;i<=NF;i++) print i": "$i}'
某些日志输出:
1: 185.12.59.118
...
12: "Mozilla/5.0
13: (Windows
14: NT
15: 10.0;
...
20: Firefox/132.0"
问题根源:awk 默认按空格分割,但 User-Agent 里有很多空格,被拆成了很多列!
解决方案:用双引号作分隔符
tail -1 /var/log/nginx/access.log | awk -F'"' '{for(i=1;i<=NF;i++) print i": "$i}'
输出:
1: 185.12.59.118 - - [14/Jan/2026:17:06:18 +0800]
2: GET / HTTP/1.1
3: 400 248
4: -
5:
6: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0
7:
现在请求行($2)和 User-Agent($6)都是完整的了!
统计最热门的请求路径
cat /var/log/nginx/access.log | awk -F'"' '{print $2}' | sort | uniq -c | sort -rn | head -10
输出:
122 GET / HTTP/1.1
27 GET /favicon.ico HTTP/1.1
18 GET /wp-includes/assets/autoload_classmap.php HTTP/1.1
18 GET /ioxi-o.php HTTP/1.1
18 GET /admin.php HTTP/1.1
...
大部分是扫描器在探测有没有漏洞!
排除扫描请求
# 用 grep -v 排除 .php 结尾的请求
cat /var/log/nginx/access.log | awk -F'"' '{print $2}' | grep -v '\.php' | sort | uniq -c | sort -rn | head -10
输出:
122 GET / HTTP/1.1
27 GET /favicon.ico HTTP/1.1
17 GET / HTTP/1.0
14 GET /robots.txt HTTP/1.1
12 GET /api/collections/ops-learning HTTP/1.1
...
好多了!
场景6:awk 条件过滤
只看特定状态码的请求
# 只看 404 的请求
cat /var/log/nginx/access.log | awk '$9 == 404' | head -5
输出:
167.94.138.55 - - [14/Jan/2026:00:16:02 +0800] "GET /favicon.ico HTTP/1.1" 404 134 "-" "Mozilla/5.0 (compatible; CensysInspect/1.1; +https://about.censys.io/)"
167.94.138.55 - - [14/Jan/2026:00:16:15 +0800] "GET /wiki HTTP/1.1" 404 134 "-" "Mozilla/5.0 (compatible; CensysInspect/1.1; +https://about.censys.io/)"
...
小测验
找出所有 403(被拦截)的请求来自哪些 IP,并统计次数?
我的回答:
cat /var/log/nginx/access.log | awk '$9 == 403 {print $1}' | sort | uniq -c | sort -rn
输出:
38 20.187.120.186
36 74.225.244.89
36 52.141.44.40
28 74.225.245.5
...
✅ 正确!
统计 404 数量
cat /var/log/nginx/access.log | awk '$9 == 404' | wc -l
输出:157
场景7:grep 过滤行
检查 UFW 防火墙状态
脚本里有这行:
UFW_ACTIVE=$(sudo ufw status | head -1 | grep -iE "active|激活" | wc -l)
先看看原始输出:
sudo ufw status | head -1
输出:Status: active
⭐ grep 常用参数(必须记住)
| 参数 | 含义 | 例子 |
|---|---|---|
-i | 忽略大小写 | grep -i "error" 匹配 Error、ERROR |
-E | 扩展正则 | grep -E "error|warn" 匹配 error 或 warn |
-c | 只输出匹配数量 | grep -c "404" |
补充知识(了解即可):
| 参数 | 含义 | 例子 |
|---|---|---|
-v | 反向匹配(排除) | grep -v "DEBUG" 排除 DEBUG 行 |
我发现了一个 bug!
# 测试 inactive
echo "Status: inactive" | grep -iE "active|激活" | wc -l
输出:1
问题:"inactive" 里包含 "active" 这个子串,也会被匹配到!
修复:使用单词边界 \b
# 测试修复
echo "Status: inactive" | grep -iE "\bactive\b|\b激活\b" | wc -l # 输出 0
echo "Status: active" | grep -iE "\bactive\b|\b激活\b" | wc -l # 输出 1
什么是 \b?
\b 和 \n、\t 不是一回事:
| 类型 | 用在哪 | 例子 | 含义 |
|---|---|---|---|
| 转义字符 | 字符串里 | \n \t \\ | 换行、Tab、反斜杠 |
| 正则元字符 | 正则表达式里 | \b \d \s | 特殊匹配规则 |
\b 是单词边界,匹配单词和非单词字符之间的位置:
i n a c t i v e
↑ ↑
\b \b
"inactive" 中间没有边界,所以 \bactive\b 匹配不到
⭐ 正则表达式:只记这几个就够了
| 符号 | 含义 | 例子 |
|---|---|---|
. | 任意一个字符 | a.c → abc, aXc |
* | 重复 0 次或多次 | ab*c → ac, abc, abbc |
^ | 行开头 | ^hello |
$ | 行结尾 | world$ |
| | 或 | cat|dog |
补充知识(了解即可):
| 符号 | 含义 | 例子 |
|---|---|---|
\b | 单词边界 | \bactive\b 精确匹配单词 |
其他复杂的?问 AI!
⭐ 命令速查表(必须记住)
| 命令 | 用法 | 作用 |
|---|---|---|
awk '{print $1}' | 取列 | 提取 IP、状态码等 |
awk -F'分隔符' | 指定分隔符 | 处理复杂格式 |
awk '$9 == 404' | 条件过滤 | 只看特定状态码 |
grep '关键词' | 过滤行 | 找包含关键词的行 |
grep -i | 忽略大小写 | 匹配 Error、ERROR |
grep -E 'a|b' | 多条件 | 匹配 a 或 b |
grep -c | 统计匹配数 | 快速计数 |
sort | 排序 | 为 uniq 做准备 |
sort -rn | 倒序数字排序 | 从大到小 |
uniq -c | 去重计数 | 统计出现次数 |
head -10 | 取前 N 行 | 只看 Top 10 |
wc -l | 统计行数 | 数有多少行 |
补充命令(了解即可):
| 命令 | 用法 | 作用 |
|---|---|---|
awk '$9 == 403 {print $1}' | 条件 + 取列 | 过滤后提取数据 |
grep -v '关键词' | 排除行 | 去掉包含关键词的行 |
tail -f | 实时跟踪 | 监控日志新增 |
zcat / zgrep | 查看压缩文件 | 处理 .gz 日志 |
⭐ 万能统计套路(必须记住)
# 提取 → 排序 → 去重计数 → 倒序 → 取前N
awk '{print $列号}' | sort | uniq -c | sort -rn | head -10
补充变体(了解即可):
# 带条件的版本
awk '条件 {print $列号}' | sort | uniq -c | sort -rn | head -10
踩过的坑
- uniq 只能去除相邻重复行,要先
sort - awk 默认按空格分割,User-Agent 会被拆散,用
-F'"'解决 - grep "active" 会匹配到 "inactive",要用
\b单词边界 - 畸形请求会干扰统计,在统计时用
grep过滤,不要在存日志时过滤
学到的概念
| 概念 | 含义 |
|---|---|
| 日志轮转(logrotate) | 自动归档压缩旧日志,防止磁盘爆满 |
单词边界 \b | 正则表达式,匹配单词和非单词字符之间的位置 |
| 管道统计套路 | sort | uniq -c | sort -rn 组合 |
| 反向代理 | Nginx 接收请求后转发给后端服务 |
下一步学习
- sed 文本替换(批量修改配置文件)
- grep -oP 提取值(正则进阶)
- 分析 auth.log(SSH 登录日志)