使用JS+Python自动抓取知识星球上的研报更新
去年因为工作原因,购买了知识星球上的行业研报年费会员,里面有最近几年的70000多份行业研究报告,给当时信息来源闭塞的我很大的帮助。里面涉及到金融、新零售、房地产、区块链、人工智能、AI、投融资、大数据、汽车、泛娱乐、游戏、电商等上千个细分领域,但这些研报在知识星球里面是以话题的形式呈现的:
下载每一个文件都需要点击几次鼠标。如果我们只需要下载其中一两个倒无所谓,但想到马上就要过期的年费会员,我的目标是所有的这70000多份文件。如果手工一个个的去点,即便按照2秒钟下载一个文件的速度来计算,这70000个文件也需要人工去点39个小时。
想要很优雅的解决这个问题?用js+Python试试看!
1. 获取70000个文件的清单
要下载这些文件,我们首先需要拿到这70000个文件的清单。星球的首页就给我们提供了一个浏览所有文件的入口。与大部分网页不一样的是,知识星球使用的是滑动到页面底部之后自动加载下一页内容的方法。
按F12,打开浏览器的抓包工具。
我们发现当加载下一页的时候,浏览器访问了类似下面格式的地址:
https://api.zsxq.com/v2/groups/555211281554/topics?scope=all&count=20&end_time=xxx
查看这个请求的返回值:
我们可以看到,JSON.resp_data.topics[0].talk.files就是我们需要得到的包含了必要的文件信息的序列:
-
create_time:创建时间。对于我们下载文件不是必要的,但对我们按照时间分类存放,以及下面即将讲到的获取下一页的清单都是必须的。
-
download_count:文件的下载次数。可以不用关心。
-
file_id:文件的ID。非常重要,获取下载链接必须要通过file_id来获取。
-
hash:文件的哈希值。可以不用关心。
-
name:文件名。下载文件之后按照这个文件名来存。
-
size:文件大小。可以不用关心。
我们把这一页所有JSON.resp_data.topics[0].talk.files对象都保存下来,push进一个数组里,就完成了这一页信息的抓取。
那下一页的访问链接怎么获取呢?
继续通过抓包工具看,当继续加载下一页的时候,end_time参数会发生变化。经过分析发现,end_time实际上是当前页的最后一个topic的发布时间减去1毫秒。以下图为例,当前页的最后一个topic的发布时间是11:12:16.483,请求下一页的时候就把end_time参数更改为11:12:16.482。
知识星球里面的end_time参数使用的是带时区的ISO格式的时间。要实现create_time的毫秒数减一,网上有不少笨重的方法。这里提供两种简单的方法,一种是用js实现,一种是用Python实现:
1. 使用JS实现:
1 var preMilliseconds = function(strStartDate){ 2 var d = new Date(strStartDate); 3 d.setMilliseconds(d.getMilliseconds()-1+8*3600*1000); 4 return d.toISOString().substr(0,23) + "+0800"; 5 }
2. 使用Python实现:
1 def preMilliseconds (strStartDate): 2 import datetime 3 d = parse(strStartDate) 4 delta = datetime.timedelta(milliseconds=1) 5 pDate = d - delta 6 return pDate.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]+ "+0800"
将preMilliseconds函数计算出来结果放入end_time的值中,即可成功获取下一页的文件清单。把这一页所有JSON.resp_data.topics[0].talk.files对象都保存下来,push进第一页的数组中之后,不断进行下去,将这个数组的信息保存为TXT文件,我们就可以得到所有的文件清单了。
我是使用js来实现文件清单的获取的,使用js的最大的好处是可以直接在浏览器的控制台中调试、运行与执行,而且可以借用已有的页面的cookies等信息。当然了如果使用Python也可以完成,不过需要在request的header中添加该页面的cookie信息。如果授权方式比较复杂的话,可能会比较麻烦。当然了知识星球使用的授权方式比较简单,只要cookies对得上就可以访问到我们需要的信息了。
使用js实现的全文如下:
1 (function (console) { 2 console.save = function (data, filename) { 3 let MIME_TYPE = "text/json"; 4 if (!data) return; 5 if (!filename) filename = "console.json"; 6 if (typeof data === "object") data = JSON.stringify(data, null, 4); 7 8 let blob = new Blob([data], { tyoe: MIME_TYPE }); 9 let e = document.createEvent("MouseEvent"); 10 let a = document.createElement("a"); 11 a.download = filename; 12 a.href = window.URL.createObjectURL(blob); 13 a.dataset.downloadurl = [MIME_TYPE, a.download, a.href].join(":"); 14 e.initMouseEvent("click", true, false, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null); 15 a.dispatchEvent(e); 16 } 17 })(console) 18 19 var httpRequest = new XMLHttpRequest();//第一步:创建需要的对象 20 var result = new Array(); 21 var gDateStr = null; 22 var endTime = new Date("2022-04-18T22:22:56.322+0800"); 23 24 var preMilliseconds = function(strStartDate){ 25 var d = new Date(strStartDate); 26 d.setMilliseconds(d.getMilliseconds()-1+8*3600*1000); 27 var strOutput = d.toISOString(); 28 strOutput = strOutput.substr(0,23); 29 strOutput = strOutput + "+0800"; 30 return strOutput; 31 } 32 33 //输入:第一次调用默认0;成功返回0,失败返回负数,需要重试返回1,建议外部不断+1,到一定的值之后停止重试。 34 var traversalFileId = function(retryTimes){ 35 var requestUrl; 36 if(gDateStr == null) 37 requestUrl = 'https://api.zsxq.com/v2/groups/555211281554/topics?scope=all&count=20&end_time='; 38 else 39 { 40 requestUrl = 'https://api.zsxq.com/v2/groups/555211281554/topics?scope=all&count=20&end_time=' + encodeURIComponent(gDateStr); 41 } 42 httpRequest.open('GET', requestUrl , false); //第二步:打开连接 43 httpRequest.send(null); 44 if (httpRequest.status === 200) { 45 var json = JSON.parse(httpRequest.responseText); 46 if(json.succeeded == true) 47 { 48 if(json.hasOwnProperty("resp_data") && json.resp_data.hasOwnProperty("topics") && json.resp_data.topics.length>0 ) 49 { 50 for(i=0;i<json.resp_data.topics.length;i++) 51 { 52 if(json.resp_data.topics[i].hasOwnProperty("talk") && json.resp_data.topics[i].talk.hasOwnProperty("files") && json.resp_data.topics[i].talk.files.length>0 && json.resp_data.topics[i].talk.hasOwnProperty("text")) 53 { 54 for(j=0;j<json.resp_data.topics[i].talk.files.length;j++) 55 { 56 if(json.resp_data.topics[i].talk.files[j].hasOwnProperty("file_id")) 57 { 58 result.push(json.resp_data.topics[i].talk.files[j]); 59 } 60 } 61 62 } 63 if(json.resp_data.topics[i].hasOwnProperty("create_time")) 64 { 65 gDateStr = json.resp_data.topics[i].create_time; 66 var thisDate = new Date(gDateStr); 67 if(thisDate<=endTime) 68 { 69 console.log("遍历到了预设的时间,提前结束遍历"); 70 return -2;//遍历已经解除,可以终止循环了 71 } 72 } 73 } 74 } 75 else if(json.hasOwnProperty("resp_data") && json.resp_data.hasOwnProperty("topics") && json.resp_data.topics.length==0 ) 76 return -2;//遍历已经解除,可以终止循环了 77 return 0;//成功 78 } 79 else return retryTimes+1;//表示需要重试 80 } 81 return -1;//出现了异常,但无需重试 82 } 83 function sleep(delay) { 84 var start = (new Date()).getTime(); 85 while((new Date()).getTime() - start < delay) { 86 continue; 87 } 88 } 89 90 var retVal = 0; 91 gDateStr = null; 92 var pageIndex=0, loopIndex=0; 93 do{ 94 console.log("Info: 即将进行第"+pageIndex+"页,第"+loopIndex+"个循环"); 95 retVal = 0; 96 do{ 97 retVal = traversalFileId(retVal); 98 if(retVal>0) 99 { 100 sleep(Math.floor(Math.random() * 2000)+1000); 101 } 102 loopIndex++; 103 }while(retVal>0 && retVal<10);//重试10次 104 if(retVal==-1) 105 console.log("Error: 可忽略故障出现在了第"+pageIndex+"页,第"+(loopIndex-1)+"个循环"); 106 else if(retVal >=10) 107 console.log("Error: 10次重试失败出现在了第"+pageIndex+"页,第"+(loopIndex-1)+"个循环"); 108 else if(retVal ==0) 109 console.log("Info: 成功完成了第"+pageIndex+"页,第"+(loopIndex-1)+"个循环"); 110 gDateStr = preMilliseconds(gDateStr); 111 pageIndex ++; 112 sleep(Math.floor(Math.random() * 2000)+1000); 113 }while(retVal!=-2); 114 console.log("Success: 已完成,共"+pageIndex+"页,"+(loopIndex-1)+"个循环"); 115 fileIdList = result; 116 console.save(result,"zsxqFileIDList.txt");
知识星球首页是https://wx.zsxq.com/开头的,而我们使用的各种链接都是https://api.zsxq.com/开头的。为了避免跨域访问带来的种种限制,我们不要在知识星球首页上开控制台 。我们随便找一个https://api.zsxq.com/开头的页面,按F12进入控制台之后,复制上面的代码,按回车,等待程序执行结束即可。
下载下来的文件清单是json格式的txt文件。我把它转成了Excel文件,点击下载。文件的哈希值没什么太大意义,就从这个清单里删除掉了。
2. 获取70000个文件的下载地址
通过上面的步骤,我们可以得到所有的文件的信息,但还不知道文件的下载链接是什么,还需继续努力。
在页面上随便找一个文件,模拟下载文件的全部流程,通过浏览器自带的抓包工具可以看到通过file_id获取文件下载链接是通过这个URL实现的:
https://api.zsxq.com/v2/files/【file_id】/download_url
它的返回值就是文件的URL:
我们不断的访问这个接口,把文件的file_id传递过去,就可以得到每个文件的访问链接了。但尤其需要注意的是,这个接口的访问是会被知识星球严密监控的,访问这个接口会引起上面提到的download_count数量的变化。短时间内的大量访问很可能会导致知识星球帐号被封,一旦被封,解封之路那可是漫长而又遥遥无期。而且,上面得到的文件下载链接是带有授权码的,如果自己的帐号被封了,已经拿到的下载链接也失效了。
如果我们以超过每秒一次的速度访问这个接口,很可能会返回false的值。一旦返回false,我们需要刷新重试。所以在这一步需要添加很严谨的验证程序。确保succeed键值为true,确保resp_data不为空,确保download_url不为空,且格式符合http开头的要求。
如果访问70000个文件的下载链接使用js来实现,假如每次访问间隔1.5秒,而且每次都能成功,那么一共需要29个小时才能采集完毕。等链接都拿到了,帐号早就被封了。所以我们必须使用多线程技术,用Python来实现。
我在这里使用了ThreadPoolExecutor库,通过函数
threadPool = ThreadPoolExecutor(max_workers=160, thread_name_prefix=”DataDownload_”)
建立线程池。通过函数
future = threadPool.submit(downloadFile, f[“file_id”],filePath)
提交线程任务。通过函数
threadPool.shutdown(wait=True)
等待执行完毕。整个多线程下载的任务三行代码即可搞定。
需要注意的是,上面代码中的160需要随着自己机器的配置调整。在我的机器上同时开启八九百个线程之后就会报错,稳妥起见我这里选择同时开通160个线程,既能保证下载速度又能保证自己机器不会报错,被知识星球打回重试的概率也在可以接受的范围之内。
3. 开启多线程下载70000个文件
其实一个比较好的方案,是在获取文件下载链接中间等待的1.5秒左右的时间,完成文件的下载。使用Python来下载文件的方法很多,我们随便找一个实现即可。
文件名我们可以通过上面的name键值得到,文件的路径呢?
所以,怎么能把这么多文件分门别类的整理好,是需要花一些功夫的。我这边使用下面这样的整理方式:
一级子目录为月份,通过文件清单中的create_time键值即可获得。二级子目录为不同的话题名,话题名是文件清单的上一级topic_name的键值。我们查看最近出现的topic_name的名字:
-
#EDA# EDA行业研报分享(五)
-
#EDA# EDA行业研报分享(四)
-
#EDA# EDA行业研报分享(三)
-
#EDA# EDA行业研报分享(二)
-
#EDA# EDA行业研报分享(一)
-
#20220516# 每日研报分享(七)
-
#20220516# 每日研报分享(六)
-
#20220516# 每日研报分享(五)
-
#20220516# 每日研报分享(四)
-
#20220516# 每日研报分享(三)
-
#20220516# 每日研报分享(二)
-
#20220516# 每日研报分享(一)
我们大可不必严格按照原来的topic_name,将上面12个topic的文件分别放在12个topic_name的文件夹下。我们可以看出来上面五个是属于相同的topic,下面的七个也是属于相同的topic,我们只需要把文件名的括号连同其中的数字删除即可。使用正则表达式一句话即可搞定:
topicFolder = re.sub(r’\([一二三四五六七八九十]+\)$’, “”,topicFolder)
理清文件的路径之后即可开动下载。160个下载线程同时启动之后,可以跑满家里的200M的宽带。最后240G的资料,只需要3个小时即可下载完毕。
4. 我们得到了什么
一共240G,1785个细分行业的77914个行业研究报告。
但只得到了这些还远远不够,我这边又做了这些修改:
1. 让Python每次运行的时候,都可以自动检测网上有没有发布文件更新,如果有更新的话就把新发布的文件给下载到硬盘里。
2. 在家里的NAS上安装了Python环境。移植这份代码,让这个程序可以在NAS中运行。在NAS里设置定时执行,每个小时执行一次。
3. 家里的NAS共享给别人还是不方便,使用NAS设置跟百度网盘自动同步,当NAS中的研报有更新时自动推送到百度网盘中。
这样就完成了文件链路的打通:
当然了,上面说的只是理想情况,实际中这种行为是游走在知识星球的用户协议的灰色地带,很容易被封号,且行且珍惜。此文仅作该过程的技术分享与记录,完整的源代码以及研报分享链接就不再发出了。