属于:00-首页

第 1 章 网页是什么,爬虫是什么

这一章读完,你电脑上会跑着一个真能抓东西的程序,六行代码。但在这六行代码之前,得先花十分钟说清楚一件事——很多人写了半年爬虫都没说清楚的事:

网页到底是个什么东西。

你每天打开的网页,其实是个文本文件

现在打开浏览器,随便进一个页面,按 Ctrl + U。

一屏幕尖括号对吧。全是 <div>、<a href="...">、<span> 这种东西,密密麻麻,毫无美感。

这就是网页的真身。一个纯文本文件,格式名叫 HTML。

你平时看到的排版、图片、颜色、动画,是浏览器读完这个文本之后画出来的。也就是说浏览器其实干了两件事:

第一,把 HTML 文本从服务器上下载下来; 第二,照着文本里的指令,把它渲染成你看到的样子。

爬虫只干第一件事。

这就是整个爬虫技术全部的地基:爬虫不是什么神秘的黑客工具,它是一个只下载、不画画的浏览器。你写爬虫,就是写一个程序替你做第 1 步,然后把下载到的文本交给你自己处理。

(所以请记住这个推论,后面能救你很多次:当你发现”爬虫抓到的内容和浏览器看到的不一样”时,多半不是爬虫坏了,而是浏览器画出来的样子骗了你——下载下来的文本里,可能压根没有那块内容。)

卡片:网页就是一个文本文件

先认清你要抓的东西:URL

爬虫请求一个页面,靠的是 URL。把它拆开看:

https://www.example.com:443/search?q=python&page=2#result
└─┬─┘   └─────┬─────┘└┬┘└──┬──┘└──────┬──────┘└──┬──┘
 协议        主机      端口  路径     查询参数      片段

协议、主机、路径没什么好说的。查询参数是 ? 后面、用 & 分隔的键值对,翻页、搜索关键词大多藏在这里,第 7 章靠它吃饭。

这里只强调一个东西:最后那个 #result,叫片段(fragment)。

它永远不会被发送到服务器。

它是浏览器本地的指令,意思是”页面加载完之后,滚动到 id 为 result 的位置”。服务器根本不知道它的存在。

由此得到一个极其重要的判断依据:如果你发现一个网站 URL 里 # 后面的内容变了、页面内容也跟着变,那这些内容一定不是服务器直接给你的 HTML,而是浏览器拿到 HTML 之后自己再去要来的。这种页面,你用本章的方法抓不到正文。

现在记不住没关系,第 13 章我们会回来收拾它。但请先记住这句话,它是判断”这页面能不能直接抓”的第一把尺子。

卡片:URL片段永远不上服务器

装环境,两分钟的事

Windows 按 Win + R,输入 cmd 回车,敲:

python --version

能打印出 Python 3.x.x 就行。如果提示”不是内部或外部命令”,去 python.org 下载安装,安装界面第一个页面务必勾上 Add Python to PATH。

这个勾没勾,是新手第一天最常见的卡点,没有之一。

然后装本章唯一要用的库:

pip install requests

为什么是 requests 而不是 Python 自带的 urllib?因为 urllib 的写法停留在二十年前,而 requests 是这个行业事实上的标准,你以后去看任何别人的爬虫代码,十有八九是它。本书所有代码也用它。不争论,跟着用。

第一个爬虫

新建一个 spider1.py,六行:

import requests
 
r = requests.get("http://quotes.toscrape.com/")
print(r.status_code)
print(r.text)

跑它:

python spider1.py

输出大概长这样(中间省略):

200
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Quotes to Scrape</title>
    ...

第一行的 200 是状态码,意思是”服务器把东西给你了”。后面那一大坨尖括号,就是这个网站的 HTML 文本——和你在那个页面按 Ctrl + U 看到的是同一个东西。

恭喜,这是一个能用的爬虫。

本书后面所有内容,都是在让这六行代码变得更狠:抓得更快、抓得更稳、抓那些不想被你抓的站点。

顺便说一句为什么选 quotes.toscrape.com:这是一个专门给人练爬虫用的站点,没有反爬,结构干净,法律上零风险。练枪法先用靶场,别一上来就上街。

抓下来的中文是乱码?编码这关躲不掉

先跑一行代码,见一个老熟人:

print("\ufffd\ufffd".encode("utf-8").decode("gbk"))

输出:

锟斤拷

看到”锟斤拷”三个字,老爬虫会心一笑。这三个字不是玄学,来历精确到字节:\ufffd 是 Unicode 里的替换符,某个程序翻译字节失败时拿它顶包;顶包后的字符串按 UTF-8 存盘,字节变成 EF BF BD;之后另一个程序拿 GBK 码表去翻这串字节,EF BF 翻成”锟”,BD EF 翻成”斤”,BF BD 翻成”拷”。锟斤拷,诞生。

所以乱码现场通常站着两个事故:第一次翻译失败(顶了替换符),第二次翻译用错码表(翻出锟斤拷)。你在自己爬虫里碰到的乱码,多半是第一种事故的直接表现:requests 猜错了编码。

事情的本质是这样。服务器给你的是字节,一串 0 和 1。要把字节变成你能读的中文,必须按某本”码表”去翻译,这本码表就叫编码,常见的有 UTF-8 和 GBK 两本。码表用错,翻出来就是乱码;翻不了的地方顶替换符,再被错翻一次,就是锟斤拷。

在 requests 里,这两个东西分别对应:

  • r.content:原始字节,服务器给什么就是什么;
  • r.text:requests 替你翻译好的字符串。

乱码永远出在”翻译”这一步。requests 默认先看响应头里有没有声明编码,很多中文站点不声明或者声明是错的,它就只好瞎猜,猜错就乱码。

修法两种,任选:

r.encoding = r.apparent_encoding   # 让库根据内容自己再猜一次,多数情况能救回来
print(r.text)

或者你明确知道对方是 GBK(老站常见):

r.encoding = "gbk"
print(r.text)

这一节没有更深的东西,就一个认知:字节和字符串是两回事,乱码是翻译事故,不是抓取事故。 抓到的字节永远是对的,错的只是翻法。

卡片:乱码是翻译事故

把抓到的页面存下来

光打印没意义,存盘:

import requests
 
r = requests.get("http://quotes.toscrape.com/")
with open("page.html", "w", encoding="utf-8") as f:
    f.write(r.text)

跑完去文件夹里双击 page.html,浏览器会把它打开。

你会看到内容都在,但页面光秃秃的:排版没了,灰底白框全消失,只剩裸文字。

翻开你存下来的 page.html 开头几行,答案就在里面:

<link rel="stylesheet" href="/static/bootstrap.min.css">
<link rel="stylesheet" href="/static/main.css">

样式是另外两个文件,HTML 里只留了两行地址。浏览器打开本地 page.html 时,拿 /static/bootstrap.min.css 这种相对地址去你本地文件夹里找,找不到,于是放弃打扮,素颜见你。换个带图片的站存一遍,你看到的就是图片裂成小方块——同一个道理。

别慌,这不是 bug,这恰恰揭示了一个你早就该知道的事实:一个网页从来不是”一个文件”,而是”一个 HTML 加一堆资源文件”。HTML 里写着 <img src="...">、<link href="...css">,浏览器看到这些行,会再发一批请求去把图片、样式、脚本一个个取回来,拼成完整页面。quotes 这个站刚好一张图都没有(我数过,img 标签 0 个),所以你在它身上只看到丢样式,看不到裂图。

你刚才只存了那个 HTML 文本,资源一个没拿,于是就成了你看到的半残样子。

(在浏览器里按 F12 切到 Network 标签再刷新页面,你能亲眼看到这一堆请求排着队发出去。下一章我们就站在这个标签页上,把你和服务器之间说的每一句话都拆开看。)

卡片:一个网页等于1个HTML加N个资源

第一次被拒:状态码 200,拿到的却是登录页

靶场打完了,去碰一下真站点。把 URL 换成:

r = requests.get("https://www.xiaohongshu.com")
print(r.status_code, r.url)

输出(2026-11-15 实测):

200 https://www.xiaohongshu.com/login?redirectPath=http%3A%2F%2Fwww.xiaohongshu.com%2Fexplore%3FexSource%3Dnull

注意看,状态码是 200。成功了?

再看第二行:你被带到登录页去了。下载回来的三万多字节 HTML,是登录框的壳,一条笔记都没有。

这比 403 阴险。403 是当面摔门,你知道自己被拒了;这种是开门把你引进一间空屋子,你的程序还美滋滋地认为抓成功了。记住这个感觉,第 3 章和第 12 章我们还会反复回到它身上:

爬虫最大的敌人不是报错,是不报错的错误。

有的站确实会直接摔门(返回 403、412 之类),那是另一种拒法,下一章开始逐个认。

先试最古老的一招。每次 HTTP 请求都带着一组自我介绍,叫请求头(headers),其中一行叫 User-Agent,标明”我是谁”。requests 默认自报家门是 python-requests/2.x.x,等于胸牌上写着”我是爬虫”。换成 Chrome 的自我介绍:

headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                  "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
}
r = requests.get("https://www.xiaohongshu.com", headers=headers)
print(r.status_code, r.url)

实测:还是 200,还是登录页,一个字都没变。

因为小红书这一步查的不是胸牌,是身份——你有没有登录后的 Cookie。胸牌只能证明”我假装是浏览器”,Cookie 才能证明”我是某个登录过的用户”。后者是第 10 章的内容。

现在你只需要带走两件事:200 不等于抓到了内容;换 UA 只是对抗的最外层一层皮,有的站连这层皮都不看。

卡片:200不等于拿到了内容、UA胸牌是对抗的最外层

检查你自己

自测:三道题,全过进下一章

题 1:把脚本的 URL 改成 http://quotes.toscrape.com/page/2/,跑一遍。 过关标准:状态码 200,且打印内容和第 1 页不同。你能说清楚”第 2 页”这个信息是写在 URL 的哪一段里的。

题 2:把 URL 改成 http://quotes.toscrape.com/this-page-does-not-exist,跑一遍。 过关标准:程序没报错,打印出 404。你能说清楚为什么程序没崩——以及,为什么”程序没崩”这件事本身是个陷阱(上面小红书那一节你已经见过它的第一种形态:状态码漂亮,内容为空)。

题 3:存下来的 page.html 用浏览器打开,排版为什么没了?再换一个带图片的站存一次,图片为什么会裂? 过关标准:不用翻书,直接说出”HTML 只是文本,样式和图片是独立资源,我只下载了 HTML 本身”。

三题全过,下一章见。下一章我们把这六行代码按在地上拆开,看你和服务器之间到底互相说了些什么。