文章目录

  • 序言
  • requests请求出现的问题
  • selenium代码分析
  • JS下载代码解析
  • 总结



序言

截至本文发布,在金山文档网页版中,如果需要同时下载2个及以上的文件,则必须开通会员。很容易想到可以编写爬虫逐一下载文件以达到批量下载的目的。

通过抓包可以发现这并不复杂,任意选取一个文件并点击下载,监听XHR下的数据包,可以捕获到如下的关键数据包:

图1 单个文件加载数据包响应结果

python 下载金山文档 金山文档文件如何下载_开发语言


上图右侧响应结果中的url字段即为文件下载地址,注意该下载地址并不需要处于登录状态,任意状态下都可以访问下载(但是存在有效期)

那么这个事情就很简单,我们来看一看这个数据包的请求字段:

图2 单个文件加载数据包请求字段

python 下载金山文档 金山文档文件如何下载_javascript_02

如图中标注的那样,上图请求URL中只有两个可变字段:groupidfileidgroupid很容易直接从当前页面的URL中就可以读到。fileid则有很多方法可以读取,事实上当前页面源代码中某个<script>标签下就存储了所有文件的fileid(存储在变量window.__API_CACHED__中,直接在控制台中调取window.__API_CACHED__也可以):

图3 页面源代码中的window.API_CACHED

python 下载金山文档 金山文档文件如何下载_开发语言_03


这个window.__API_CACHED__很长,这里将该<script>标签下的完整内容取出:图4 window.__API_CACHED__完整数据结构

python 下载金山文档 金山文档文件如何下载_javascript_04

如上图所示,所有文件的信息(包括fileid)都保存在红框所在的字段值中(已收起)。

可能你会觉得读取fileid太麻烦,因此也可以调用红框中给到的这个API接口(抓包亦可得):

https://drive.kdocs.cn/api/v5/groups/{group_id}/files?include=acl,pic_thumbnail&with_link=true&offset=0&count={count}

其中{group_id}如上所述可得,{count}即需要获取的文件数量,一般该文件夹下有多少文件就取多少,该接口的访问当然需要登录状态,接口返回数据如下图所示(文件名已遮挡,三个红框分别框出了groupid,文件名,fileid):

图5 接口返回的所有文件信息

python 下载金山文档 金山文档文件如何下载_python 下载金山文档_05

所有文件的groupidfileid都可得,似乎问题已得解,但是既然可以写成博客,就不会这么简单。


requests请求出现的问题

如序言部分所言,关键的请求如图2所示:

图2 单个文件加载数据包请求字段(copy)

python 下载金山文档 金山文档文件如何下载_javascript_02


根据以往的经验,虽然该数据包的请求必然是需要登录状态的,但是理论上只要附加上右下框中的所有请求头,应该就可以得到如图1的响应结果(即文件的下载地址)。

虽然我在浏览器中重发这个请求仍然可以得到图1的结果,证明了该请求并非阅后即焚。但是如果简单使用requests库进行请求,并不能得到同样的响应结果:

def cookie_to_string(cookies: list) -> str:
	string = ''
	for cookie in cookies:
		string += '{}={}; '.format(cookie['name'], cookie['value'])
	return string.strip()

# 将字符串形式的请求头转为字典形式的headers
def headers_to_dict(headers: str) -> dict:
	lines = headers.splitlines()
	headers_dict = {}
	for line in lines:
		key, value = line.strip().split(':', 1)
		headers_dict[key.strip()] = value.strip()
	return headers_dict

url = f'https://drive.kdocs.cn/api/v5/groups/{group_id}/files/{file_id}/download?isblocks=false&support_checksums=md5,sha1,sha224,sha256,sha384,sha512'

cookies = driver.get_cookies()
headers_string = f"""Host: drive.kdocs.cn
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:101.0) Gecko/20100101 Firefox/101.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: {cookie_to_string(cookies=cookies)}
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1"""

r = requests.get(url, headers=headers_to_dict(headers=headers_string))	# 无法得到响应结果

那这个事情就显得很诡异,我尝试了很长时间,也改用requests.Session测试,依然不得行。这说明金山文档反爬确实到位,根据后面成功实现爬虫的结果来看,Cookie中的确是已经包含了所有的登录信息,那么我猜想金山文档应该是做了访问流程上制定了一些中间件的限制,或者限制了跨域请求,这个的确对于爬虫非常不友好。

但是总是要解决的,那么只好转向万能的selenium了。


selenium代码分析

这部分将结合代码进行解析,因为的确坑非常的多,不过确实也是对爬虫技巧的提升:

# -*- coding: utf-8 -*-
# @author: caoyang
# @email: caoyang@163.sufe.edu.cn

import re
import json
import time
import requests

from selenium import webdriver
from selenium.webdriver.support.ui import WebDriverWait

def get_download_urls(group_id=1841861380, count=50):
	# firefox_profile = webdriver.FirefoxProfile(r'C:\Users\caoyang\AppData\Roaming\Mozilla\Firefox\Profiles\sfwjk6ps.default-release')
	# driver = webdriver.Firefox(firefox_profile=firefox_profile)
	driver = webdriver.Firefox()
	driver.get('https://account.wps.cn/')	# 登录页面
	WebDriverWait(driver, 30).until(lambda driver: driver.find_element_by_xpath('//*[contains(text(), "VIU")]').is_displayed())

	driver.get('https://www.kdocs.cn/latest')
	WebDriverWait(driver, 30).until(lambda driver: driver.find_element_by_xpath('//span[contains(text(), "共享")]').is_displayed())

	def cookie_to_string(cookies: list) -> str:
		string = ''
		for cookie in cookies:
			string += '{}={}; '.format(cookie['name'], cookie['value'])
		return string.strip()
	
	def headers_to_dict(headers: str) -> dict:
		lines = headers.splitlines()
		headers_dict = {}
		for line in lines:
			key, value = line.strip().split(':', 1)
			headers_dict[key.strip()] = value.strip()
		return headers_dict

	# driver.get(f'https://drive.kdocs.cn/api/v5/groups/{group_id}/files?include=acl,pic_thumbnail&with_link=true&offset=0&count={count}')
	# time.sleep(3)
	# html = driver.page_source
	# windows = driver.window_handles
	# print(html)
	# print(len(windows))
	# print(driver.current_url)
	
	# https://drive.kdocs.cn/api/v5/groups/1841861380/files?include=acl,pic_thumbnail&with_link=true&offset=0&count=50

	cookies = driver.get_cookies()
	headers_string = f"""Host: drive.kdocs.cn
	User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:101.0) Gecko/20100101 Firefox/101.0
	Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
	Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
	Accept-Encoding: gzip, deflate, br
	Connection: keep-alive
	Cookie: {cookie_to_string(cookies=cookies)}
	Upgrade-Insecure-Requests: 1
	Sec-Fetch-Dest: document
	Sec-Fetch-Mode: navigate
	Sec-Fetch-Site: none
	Sec-Fetch-User: ?1"""
	r = requests.get(f'https://drive.kdocs.cn/api/v5/groups/{group_id}/files?include=acl,pic_thumbnail&with_link=true&offset=0&count={count}', headers=headers_to_dict(headers=headers_string))
	html = r.text

	json_response = json.loads(html)
	files = json_response['files']

	print(f'共计{len(files)}个文件')

	download_urls = []
	filenames = []
	for file_ in files:
		group_id = file_['groupid']
		file_id = file_['id']
		filename = file_['fname']
		print(filename, group_id, file_id)
		url = f'https://drive.kdocs.cn/api/v5/groups/{group_id}/files/{file_id}/download?isblocks=false&support_checksums=md5,sha1,sha224,sha256,sha384,sha512'
		# driver.get(url)
		# time.sleep(3)
		# html = driver.page_source
		cookies = driver.get_cookies()
		headers_string = f"""Host: drive.kdocs.cn
		User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:101.0) Gecko/20100101 Firefox/101.0
		Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
		Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
		Accept-Encoding: gzip, deflate, br
		Connection: keep-alive
		Cookie: {cookie_to_string(cookies=cookies)}
		Upgrade-Insecure-Requests: 1
		Sec-Fetch-Dest: document
		Sec-Fetch-Mode: navigate
		Sec-Fetch-Site: none
		Sec-Fetch-User: ?1"""	
		r = requests.get(url, headers=headers_to_dict(headers=headers_string))
		html = r.text
		# print(html)
		json_response = json.loads(html)
		download_url = json_response['url']
		print(download_url)
		download_urls.append(download_url)
		filenames.append(filename)

	with open('d:/download_urls.txt', 'w') as f:
		for download_url, filename in zip(download_urls, filenames):
			f.write(filename + '\t' + download_url + '\n')

	driver.quit()

get_download_urls()

建议先把上面的代码复制下来,接下来的阐述将按代码的行数展开(不要删除注释掉的行,那些都是坑点所在)。

  1. 首先来看14-18行:
    我一开始考虑是否只要导入用户数据(关于用户数据在爬虫中的应用可以查阅我的博客)即可跳过金山文档登录。需要说明的是我没有测试Chrome浏览器的情况,但是Firefox确实是不管用,即便我先打开一个窗口登录金山文档(此时不管我再打开几个窗口访问金山文档,都是处于登录状态),再启动导入用户数据的selenium,依然是卡在登录页面。因此不得已只能注释掉14-15行。
    然后17行访问登录页面,18行是在延时等待登录成功(此期间可以点击微信登录,然后扫码确认)。
    其实用过selenium的应该都知道,如果中途你time.sleep的时间过长,selenium就会崩溃,如果你手动在页面上进行操作(比如点击,滑动,输入文字之类),selenium也会崩溃。我以前一直以为selenium启动后是完全不能操作浏览器,现在发现其实只要写个WebDriverWait(其中xpath搜索的是用户名,VIU是我的用户名),然后你就可以随便点击扫码登录了,这倒是很方便。
  2. 接下来是20-21行:
    这个特别坑,如果你登陆完后,直接访问37行的接口(即图5),它并不会显示图5的结果,只会告诉你用户未登录,因此只能先访问金山文档首页。这个其实也就是我觉得可能是在流程上做了限制来进行反爬。
  3. 23-35行是两个工具函数,cookie_to_string是将driver.get_cookies()返回的Cookie格式(形如[{'name': name, 'value': 'value'}, ...])转为字符串的Cookie添加到请求头中,headers_to_dict是将从浏览器复制下来的字符串请求头改写为字典形式(用于requests.getheaders参数)
  4. 37-61行:
    坑点来了,这时候我用37行访问图5的接口确实是可以看到图5的数据,但是39行的driver.page_source返回的确是20行金山文档首页的HTML,这TM就很蛋疼。可以看到40行-43行做了若干测试,我证明了确实当前只有一个窗口(len(windows)为1),并且driver.current_url显示当前的页面URL的确也不是金山文档首页。
    这个问题困扰了很长时间,我查了drivers的函数也没有获取页面中JSON数据的方法。以前也没注意到这种响应结果为JSON的页面竟然是不能通过driver.page_source获取页面数据的,因此被迫改用requests库去重写这段逻辑(47-61行)。
    有人可能说了,按照序言的说法,这个请求响应不是也不能用requests获得吗?确实如此,如果只是单用47-61行来访问图5的结果,的的确确依然返回的是用户未登录,但是这里我用的是drivers.get_cookies()返回的Cookie信息来替换直接从浏览器中复制下来的请求头中的Cookie信息,竟然就奇迹般地就成了,实话说我也不是特别能理解这里的原理,也不知道金山文档后端代码到底是怎么判断是不是爬虫进行的请求。
  5. 63-66行:拿到图5的文件信息数据。
  6. 68-99行:
    这里发生的事情跟37-61行一模一样,我们想要得到图1的响应结果(自然也是JSON格式的数据),如果还是用driver去访问接口,得到的driver.page_source里依然是金山文档首页的HTML,因此这里用了同样的方法(requests改写),即可得到96行的文件下载地址。
    同样地,根据序言中的情况,如果直接用requests访问图2是不可行的,但是这里在selenium完成登录操作后的确就又可行了。

所有文件下载地址存储在d:/download_urls.txt中,因为这个下载地址即便不处于登录状态也可以使用,收尾工作就显得非常简单了。

with open('d:/download_urls.txt', 'r') as f:
	lines = f.read().splitlines()
	
for line in lines:
	filename, url = line.split('\t')
	r = requests.get(url)
	with open(f'd:/{filename}', 'wb') as f:
		f.write(r.content)

JS下载代码解析

这里附加一个等价的JS代码,理论上直接在控制台中运行即可下载,但是问题在于会发生跨域请求错误,所以好像还是不太行,不知道有没有朋友能解决一下这个问题。

let groups = "1842648021";
let count = 54;
let res = await fetch(`https://drive.kdocs.cn/api/v5/groups/${groups}/files?include=acl,pic_thumbnail&with_link=true&offset=0&count=${count}&orderby=fname&order=ASC&filter=folder`);
let files = await res.json();
files = files.files;

let urls = []
let fid, info, url;
for (let f of files) {
    fid = f.id;
    res = await fetch(`https://drive.kdocs.cn/api/v5/groups/${groups}/files/${fid}/download?isblocks=false&support_checksums=md5,sha1,sha224,sha256,sha384,sha512`, {
        "method": "GET",
        "mode": "cors",
        "credentials": "include"
    });
    info = await res.json();
    url = info.url;
    urls.push(url);
}

console.log("待下载文件数量:", urls.length);

for (let i = 0; i < urls.length; i++) {
    let url = urls[i];
    let fname = files[i].fname
    fetch(url).then(res => res.blob().then(blob => {
        let a = document.createElement('a');
        let url = window.URL.createObjectURL(blob);
        let filename = fname;
        a.href = url;
        a.download = filename;
        a.click();
        window.URL.revokeObjectURL(url);
    }))
}

总结

目前迫切需要解决的问题还是selenium访问JSON页面到底应该如何读取数据,我想到的一种很扯淡的方法是用from selenium.webdriver.common.keys import Keys中直接Ctrl+A,Ctrl+C把页面数据复制下来得到字符串,这个虽然笨,但是似乎是可行的。

另一个疑难就是到底有没有办法只用requests而无需依赖selenium完成金山文档的批量下载,以及到底为什么会出现序言中的问题,这个的确是很困扰的。

总之,混用requestsselenium的确是不太漂亮,我想应该会有人能拿出更漂亮的解决方案。