Python 爬虫技术一览

整理了爬虫的思路和常见的一些反爬应对手段.

思维导图

爬虫所需的一些前置知识

  • 基础 python 语法知识 (python 有十分方便完善的爬虫库)
  • http 相关知识 (学会抓包, 并理解 http 请求与响应的含义)
  • 基础的 html, css 知识 (理解 html 格式, 解析网页内容)
  • 理解 json, xml 格式 (解析接口返回值)

快速学习: https://www.runoob.com/

可选的技能还有:

  • 正则 (提取信息)
  • Javascript (理解网页动态内容, 加密逆向)
  • Android 知识 (APP 加密逆向)
  • CV (验证码识别)
  • SQL (存储大量爬取的内容)
  • Linux (使用服务器长时间运行爬虫)
  • etc.

获取并解析数据

  1. 构造请求并发送给服务器

通过抓包或分析 HTML 内容构造对应的请求并发送.
抓包工具推荐: Fiddler https://www.telerik.com/fiddler/fiddler-classic
Python http 请求推荐 requests 库: https://docs.python-requests.org/en/latest/

1
2
3
# 例: 使用谷歌搜索关键字
def google_search(keyword):
return requests.get("https://www.google.com", params={"q": keyword})
  1. 解析响应内容 (具体操作方式与每个网站的设计方式有关)

对于内容直接嵌入在 HTML 中的:
使用 beautifulsoup4 对 HTML 进行解析
https://beautifulsoup.readthedocs.io/zh_CN/v4.4.0/

1
2
3
4
5
6
7
8
9
10
11
12
# 例: 百度百科搜索结果
ret = requests.get("https://baike.baidu.com/search/word",
params={"word": keyword},
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36",
"Referer": "https://baike.baidu.com/"},
timeout=10)

bs = BeautifulSoup(ret.text, features="html.parser")
result = ""
for content in bs.find_all("div", class_="para"):
if content.string is not None:
result += content.string + "\n"

对于 API 返回内容的:
直接调用 requests 对象的 json() 方法或使用 json.loads() 解析返回值.

1
2
3
4
5
6
7
8
9
# 例: 网易云音乐搜索 API
r = requests.get("https://music.163.com/api/search/get/web",
params={"s": name, "offset": "0", "limit": "20", "type": "1"})

print(r.json())
# 成功输出搜索结果:
# {'result': {'songs': [{'id': xxx, 'name': 'xxx', 'artists': [xxx], ...}, ...], 'songCount': 300}, 'code': 200}

print(r.json()['result']['songs'][0]['id']) # 获取第一首歌的 ID

常见反爬虫与应对

User-Agent / Referer 检查

HTTP Headers 中包含的 UA 和 Referer 可以用来识别爬虫.
如果一开始爬虫访问网址就得到 403 错误有可能是遇到了 UA 的检查.
绕过方法很简单, 只需要在 requests 的 headers 参数中修改 UA 和 Referer 即可.

1
2
3
4
requests.get("https://www.example.com/",
headers={"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0",
"Referer": "https://www.example.com/"}
)

IP 访问频率限制

使用同一个 IP 短时间内多次访问一个网站可能被判断为爬虫.
绕过方法是使用代理来隐藏自己的 IP, 在需要爬取大量数据时需要有大量 IP 轮换使用.

1
requests.get("https://www.example.com/", proxies={"https": "http://x.x.x.x:1080"})

可以购买网络上现有的代理池服务, 如 https://www.abuyun.com/, https://http.zhimaruanjian.com/.
我也发现了另一种 IP 的廉价稳定来源, 利用腾讯云函数中转的方式获取较多 IP, 具体操作记录在此. https://blog.lyc8503.site/post/sfc-proxy-pool/

账号访问频率限制

在访问需要登陆获取的信息时, 使用同一个账号的 Cookie 短时间内多次访问也会被判断为爬虫.
绕过方法: 注册多个账号, 保存 Cookie, 轮询使用. 或尝试寻找其他不需要登录的数据收集方式.

验证码

绕过方法:

  1. 较为简单的验证码可以直接处理一下后 OCR 识别.

  2. 滑块类验证码可以通过 CV 方法识别位置, 并滑动到指定位置.

    1
    2
    3
    4
    5
    6
    # 例: 使用 OpenCV 库绕过腾讯滑块验证码
    target = cv2.cvtColor(target, cv2.COLOR_BGR2GRAY)
    target = abs(255 - target)
    result = cv2.matchTemplate(target, template, cv2.TM_CCOEFF_NORMED)
    x, y = np.unravel_index(result.argmax(), result.shape)
    return x, y
  3. 终极解决方案: 使用人工打码平台.

JS 加密

部分网站会对请求进行”签名” (请求中有 sign 或类似的参数), 或对结果进行加密.
而且这类网站大多也会对用于加密的 Javascript 本身进行混淆.
解决方法:

  • 使用 selenium 运行完整的浏览器, 操作简单但效率低下, 性能占用高, 适用于少量数据.
  • 使用 PyexecJs 等方案在 Python 中直接运行加密相关的 js 代码. 优点是性能较好操作不太复杂, 缺点为配环境略麻烦, 对于大量混淆无法提取的 js 代码不适用.
  • 人力分析 js 代码并使用 Python 重写加密相关代码. 优点是性能最好而且清晰明了, 缺点是需要学 Javascript 且人力分析代码比较费时和繁琐.

工具推荐: selenium / Chrome 自带的开发者工具

APP 加密

很多网站可能有其对应的安卓或 iOS APP, APP 上的反爬措施可能比网页端弱.
当网页无法爬取时也可以尝试通过 APP 的 api 获取信息.
手机抓包工具推荐: Fiddler(需要PC) 或 HttpCanary https://apkpure.com/httpcanary-%E2%80%94-http-sniffer-capture-analysis/com.guoshi.httpcanary
当然 APP 端发送请求时也可能遇到请求的签名与加密, 需要对安卓 / iOS 代码进行逆向.
安卓 APP 加密脱壳工具推荐: 反射大师
apk 反编译工具: jadx
native 代码分析: IDA Pro
也有类似 selenium 的 appium, 但很麻烦, 不太推荐.

HTML 混淆

具体的情况较多较复杂, 但其实并没有非常常见, 例如:

  • 使用自定义字体, 实际上返回一堆乱码, 在浏览器中使用特定字体观察则正常.
  • 使用 css 更改元素位置. 如真实数据 123 却返回 321, 再使用 css 将 1 和 3 位置调换.
  • 将图片分为多片”拼图”返回, 再在页面上拼接显示.

实战尝试

微博爬虫

抓包查看请求, 发现微博的搜索请求很简单, 格式如下.
https://s.weibo.com/weibo?q={keyword}&page={i}
返回的数据是纯 HTML, 并不是动态加载的, 使用 bs4 解析即可.
weibo

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
soup = BeautifulSoup(r.text, "html.parser")
feeds = soup.find_all("div", attrs={"class": "card-wrap", "action-type": "feed_list_item"})

for j in feeds:
if "mid" in j.attrs:
try:
logging.info("keyword: " + keyword + ", mid: " + j.attrs['mid'])
content = j.find_all("p", attrs={"node-type": "feed_list_content_full"})
if len(content) == 0:
content = j.find_all("p", attrs={"node-type": "feed_list_content"})

content_text = html2text.html2text(str(content[0])).strip().replace("\n\n", "\n")

nick_name = j.find_all("p", attrs={"node-type": "feed_list_content"})[0].attrs['nick-name']
uid = j.find_all("a", attrs={"nick-name": nick_name})[0].attrs['href']

time_str = j.find_all("a", attrs={"suda-data": re.compile(".*wb_time.*")})[0].string.strip()

info = {"keyword": keyword, "content": content_text, "mid": j.attrs['mid'], "user": nick_name,
"uid": uid, "time": time_str, "timestamp": int(time.time())}

with open(save_path + j.attrs['mid'] + ".json", "w") as f:
json.dump(info, f)
except Exception as e:
logging.error("failed: " + j.attrs['mid'] + ", " + str(e))
# raise e
else:
logging.warning("no mid found, discard")

南大健康打卡自动化

使用 HttpCanary 抓包手机 APP 请求.
NJU

1
2
3
4
5
6
7
8
9
10
r = session.get('https://ehallapp.nju.edu.cn/xgfw/sys/yqfxmrjkdkappnju/apply/getApplyInfoList.do')

dk_info = r.json()['data'][0]
if dk_info['TBZT'] == "0":
logging.info("尝试打卡...")
wid = dk_info['WID']
data = "?WID={}&IS_TWZC=1&CURR_LOCATION={}&JRSKMYS=1&IS_HAS_JKQK=1&JZRJRSKMYS=1".format(
wid, location)
r = session.get("https://ehallapp.nju.edu.cn/xgfw/sys/yqfxmrjkdkappnju/apply/saveApplyInfos.do" + data)
logging.info("getApplyInfoList.do " + r.text)

爬取 QQ 空间说说

  1. 使用 Chrome 开发工具抓包查看请求.

Qzone
发现请求中的 g_tk 一项意义不明, 并且不加上 g_tk 接口不返回数据, 其他部分都能认出含义.
所以从代码层面追溯 g_tk 来源, 通过搜索关键字找到 g_tk 由以下方法生成.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 省略了不重要的部分 
QZONE.FrontPage.getACSRFToken = function(url) {
url = QZFL.util.URI(url);
var skey;
if (url) {
if (url.host && url.host.indexOf("qzone.qq.com") > 0) {
skey = QZFL.cookie.get("p_skey");
} else {
// ......
}
// ......
var hash = 5381;
for (var i = 0, len = skey.length;i < len;++i) {
hash += (hash << 5) + skey.charAt(i).charCodeAt();
}
return hash & 2147483647;
};

直接可以将其改写为 Python 代码的形式.

1
2
3
4
5
6
def get_gtk(login_cookie):
p_skey = login_cookie['p_skey']
h = 5381
for i in p_skey:
h += (h << 5) + ord(i)
return h & 2147483647

构造出请求发送给服务器, 发现服务器直接用 jsonp 格式返回了 json, 直接解析即可.

  1. 自动登录及 Cookie 的获取

QQ 空间网页版本的 Cookie 有效期较短, 若要持续爬取需要做自动登录.
登录的部分不常被调用, 逆向又比较复杂, 故使用 selenium.
需要解决的问题:

  • 登录有独立的 iframe, 写代码时记得 switch_to.frame(‘login_frame’)
  • 腾讯滑动验证码

Slide
滑块验证码使用 CV 的方法的解决:

1
2
3
4
5
6
7
8
9
10
11
12
# 判断缺口位置
target = cv2.cvtColor(target, cv2.COLOR_BGR2GRAY)
target = abs(255 - target)
result = cv2.matchTemplate(target, template, cv2.TM_CCOEFF_NORMED)
x, y = np.unravel_index(result.argmax(), result.shape)

# 模拟鼠标滑动
ActionChains(driver).click_and_hold(on_element=driver.find_element_by_id('tcaptcha_drag_thumb')).perform()
time.sleep(0.2)
ActionChains(driver).move_by_offset(xoffset=offset, yoffset=0).perform()
time.sleep(0.2)
ActionChains(driver).release(on_element=driver.find_element_by_id('tcaptcha_drag_thumb')).perform()

掌上高考爬虫 https://www.gaokao.cn/

同样是抓包发现获取历年招生分数线的接口如下:

https://api.eol.cn/web/api/?local_batch_id=51&local_province_id=14&local_type_id=1&page=1&school_id=111&size=10&special_group=&uri=apidata/api/gk/score/special&year=2020&signsafe=69d89bdf5ca94281643ef5a6a32a2dd4

对于这个接口服务器检验了 signsafe 签名, 而且对返回的数据进行了加密.

{“code”:”0000”,”message”:”成功”,”data”:{“method”:”aes-256-cbc”,”text”:”eab8325abc5a1440b7708431e83f79ace……”},”location”:””,”encrydata”:””}

由于这里需要爬取的数据较多, selenium 并不是一个很好的选择, 直接逆向相关代码.
直接使用 Chrome 开发者工具可以看到该网站加载的所有 js 文件. 以关键字 signsafe 搜索定位签名生成的位置.

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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
return (
(h = "D23ABC@#56"),
(p = ""),
e.endsWith(".json") ||
e.endsWith(".txt") ||
((m =
e +
(0 < Object.keys(u).length
? "?" +
(function (l) {
return Object.keys(l)
.sort()
.map(function (e) {
var a = l[e];
return (
("keyword" !== e && "ranktype" !== e) ||
(a = decodeURI(decodeURI(l[e]))),
""
.concat(e, "=")
.concat(void 0 === a ? "" : a)
);
})
.join("&");
})(u)
: "")),
(g = void 0),
(g = (t = {
SIGN: h,
str: m.replace(/^\/|https?:\/\/\//, "")
}).SIGN),
(t = t.str),
(g = r.a.HmacSHA1(r.a.enc.Utf8.parse(t), g)),
(g = r.a.enc.Base64.stringify(g).toString()),
(p = c()(g)),
(u.signsafe = p),
s.find(function (l) {
return l === u.uri;
}) || (e = m + "&signsafe=" + p)),
l.abrupt(
"return",
i()({
url: e,
method: a,
timeout: n,
data: (function (l, e) {
var a,
u = {};
if (
(Object.keys(e)
.sort()
.forEach(function (l) {
return (u[l] = Array.isArray(e[l])
? e[l].toString()
: e[l]);
}),
"get" === l)
)
return JSON.stringify(u);
for (a in u)
("elective" !== a && "vote" !== a) ||
"" == u[a] ||
(u[a] =
-1 == u[a].indexOf(",")
? u[a].split(" ")
: u[a].split(","));
return u;
})(
a,
Object(b.a)(
Object(b.a)({}, u),
{},
{ signsafe: p }
)
)
})
.then(function (l) {
return l.data;
})
.catch(function () {
return {};
})
.then(function (l) {
var e, a, t, b, n;
return (
null != l &&
null !== (a = l.data) &&
void 0 !== a &&
a.text &&
(l.data =
((n = (e = {
iv: u.uri,
text: l.data.text,
SIGN: h
}).iv),
(a = e.text),
(e = e.SIGN),
(e = r.a
.PBKDF2(e, "secret", {
keySize: 8,
iterations: 1e3,
hasher: r.a.algo.SHA256
})
.toString()),
(n = r.a
.PBKDF2(n, "secret", {
keySize: 4,
iterations: 1e3,
hasher: r.a.algo.SHA256
})
.toString()),
(a = r.a.lib.CipherParams.create({
ciphertext: r.a.enc.Hex.parse(a)
})),
(n = r.a.AES.decrypt(
a,
r.a.enc.Hex.parse(e),
{ iv: r.a.enc.Hex.parse(n) }
)),
JSON.parse(n.toString(r.a.enc.Utf8)))),
v &&
((t = o),
(b = l),
null !== (n = window.apiConfig) &&
void 0 !== n &&
null !== (n = n.filterCacheList) &&
void 0 !== n &&
n.length
? window.apiConfig.filterCacheList.forEach(
function (l) {
new RegExp(l).test(t) || d.set(t, b);
}
)
: d.set(t, b)),
l
);
})
)
);

直接找到了发送对应请求相关的代码.
代码明显经过混淆, 包含较多很奇怪的写法, 需要仔细阅读理清执行过程.
通过 L32 L33 调用的包以及上下文发现签名过程中使用了 HmacSHA1 和 Base64 算法.
得到的值还在 L34 进一步处理.
直接使用 Chrome 动态调试打断点发现对应的函数是 MD5.
最终将签名相关的方法改写成 Python 版本.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import base64
import hmac
from hashlib import sha1, md5

KEY = "D23ABC@#56".encode("utf-8")


def hash_hmac(code, key):
hmac_code = hmac.new(key, code.encode("utf-8"), sha1).digest()
return base64.b64encode(hmac_code).decode()


def get_sign(url):
return md5(hash_hmac(url, KEY).encode("utf-8")).hexdigest()

解密的部分在 js 代码的 L89 - L118, 发现用到了 PBKDF2 和 AES 算法.
同样是使用打断点的方式可以较快推测出混乱的变量名对应的实际内容.
解密的部分可以改写成如下 Python 代码.

1
2
3
4
5
6
7
8
9
10
import hmac
from hashlib import pbkdf2_hmac
from Crypto.Cipher import AES

KEY = "D23ABC@#56".encode("utf-8")

def decrypt_response(text, uri, password=pbkdf2_hmac("sha256", KEY, b"secret", 1000, 32)):
unpad = lambda s: s[:-ord(s[len(s)-1:])]
iv = pbkdf2_hmac("sha256", uri.encode("utf-8"), b"secret", 1000, 16)
return unpad(AES.new(password, AES.MODE_CBC, iv).decrypt(bytes.fromhex(text))).decode("utf-8")

加解密部分都完成后就可以自己构造请求爬取数据了.
这个接口没有验证用户的登录情况, 只对 IP 访问速率作了限制.

由于需要爬取的数据较多, 为了快速爬取, 还需要一个 IP 池.
可以直接对接网上的 IP 池或使用上面提到的腾讯云函数代理.


打赏支持
“请我吃糖果~o(*^ω^*)o”
Python 爬虫技术一览
https://blog.lyc8503.site/post/python-crawler/
作者
lyc8503
发布于
2022年4月3日
许可协议