标签 爬虫 下的文章

打算把之前采集今日头条那个小站的更新搞起来,上次更新是去年还是前年来,总之,原先那个用scrapy写的爬虫代码很久没跑过了。大概流程可以看之前写的那篇文章 Scrapy 抓取今日头条文章小记,博客也差不多断更许久了,正好就借着这篇文章同时更新一下代码跟博客吧。

测试了一下之前的代码,发现之前的代码采集文章列表没问题,不过文章详情页抓不到内容了,抓到的页面只有一段js代码,没有文章的任何内容,看来头条调整详情页了,用浏览器打开看看,发现初次访问文章详情页,跟爬虫采集到的一样,是一段js代码,然后自动刷新了一下页面,文章内容才显示出来。

之前头条文章详情页没有反爬机制,直接采集就可以了,现在加上详情页也加上反爬了,大概流程是,初次访问详情页链接,会返回一个cookie值__ac_nonce,用__ac_nonce计算出一个__ac_signature签名值,同时带着__ac_nonce__ac_signature的cookie值再次刷新页面,就正常返回文章详情页了。不需要每次访问详情页都重新计算__ac_signature值,只需要计算一次,后续访问详情页直接带着__ac_nonce__ac_signature的cookie值就可以了,有一定的时效。

具体如何破解__ac_signature参数值,就不深入研究了,直接搜索window.byted_acrawler等关键词就有很多相关的js逆向分析文章,还是奔着有没有现成可用的整理过的js代码的思路找了一下,在github上找到了,感谢Bindian9710分享的代码,
https://github.com/Bindian9710/Spider-Crack_Js/blob/master/今日头条/头条.js,这个代码可以直接用pyExecJS模块解析,返回_signature计算值,不过要获取详情页的__ac_signature还需要对这个代码简单改动一下,原代码应该是采集文章列表时用url计算来获取_signature参数值的,详情页的__ac_signature计算函数同列表的计算函数是相同的,只是函数传的参数稍有不同。

头条.js 14行,设置User-Agent,改成跟爬虫UA一致

window.navigator = {
    userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0",
}

头条.js 672行,_signature函数需要改动一下,改成详情页的通过__ac_nonce值来计算__ac_signature

function _signature(e, t) {
    var a, r;
    a = window.byted_acrawler
    r = a.sign
    var c = r.call(a, '', e)
    return c
}

function get_signature(cookie_ac_nonce) {
    return _signature(cookie_ac_nonce)
}

改好后,然后另存为sign.js,写一个测试这个js计算的__ac_signature值是否可用的python代码

import execjs
import requests

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0'
}
_r = requests.get('https://www.toutiao.com/a6936101864131953166/', headers=headers)
_ac_nonce = _r.cookies["__ac_nonce"]  # 初次访问详情页,获取__ac_nonce的值
print(f'__ac_nonce: {_ac_nonce}')

with open('./sign.js', 'r', encoding='utf-8') as f:
    toutiao_js = execjs.compile(f.read())  # 加载js

_ac_signature = toutiao_js.call('get_signature', f'{_ac_nonce}')  # 计算__ac_signature
print(f'__ac_signature: {_ac_signature}')

# 同时带着`__ac_nonce`和`__ac_signature`的cookie值再次访问详情页
headers.update({
    'Cookie': f'__ac_nonce={_ac_nonce}; __ac_signature={_ac_signature};',
})
r = requests.get('https://www.toutiao.com/a6936101864131953166/', headers=headers) 

print(r.status_code)
print(r.content.decode('utf-8'))

运行一下,正常获取文章详情页的内容了
20210308230808.png

算出来的__ac_signature_02B4Z6wo00f01QMQkFQAAIBC.O9vqmPdwIkDEpTAACCEde 要比在浏览器里的看到的值短上许多,貌似长度是需要初始化一下参数控制的,window.byted_acrawler.init({ aid: 24, dfp: true }),不过尝试在js里加上对应的长度控制代码,运行会报错 execjs._exceptions.ProgramError: TypeError: Cannot read property 'x' of undefined

不研究长度的问题了,虽短却也够用了,可以正常获取文章详情页的内容了,再把原先的scrapy代码改造一下,列表获取还用之前的方法,只需要在采集详情页前,先获取一下__ac_nonce__ac_signature即可,又可以愉快的跑起来了。

最近搞了个简单的文章转视频上传到youtube,需要采集点文章来生成视频,所以打算用Scrapy采集点今日头条的文章,目标是抓取其中一个分类的几十篇文章内容和下载其中的图片导出json数据文件用来生成视频,简单做一下记录。

打开头条网站,有多个分类,打开娱乐版块,查看源代码发现是没有文章内容,看来是js加载的,F12查看分析找到文章数据的接口链接
https://www.toutiao.com/api/pc/feed/?category=news_entertainment&utm_source=toutiao&widen=1&max_behot_time=0&max_behot_time_tmp=0&tadrequire=true&as=A1B5FBF1A7CA537&cp=5B172A5593E7AE1&_signature=oNvqZwAA-9u3ZjcQBbvTJ6Db6n
在浏览器里直接打开这个链接,很好,返回json结构的数据,刷新一下,返回不同的文章,嗯,这么easy,一个链接就搞定了。

那还等什么,新建Scrapy项目抓一下试试,哈哈,什么也没有,看一下Scrapy的输出 DEBUG: Forbidden by robots.txt 原来是头条禁止爬虫抓取了,需要设置Scrapy的参数 ROBOTSTXT_OBEY = False,再试一下还是不行,直接返回

{
  "message": "error",
  "data": [],
  "has_more": false
}

没有文章数据,构造header加上User-Agent跟Referer

headers = {
        "Referer": "https://www.toutiao.com/ch/news_entertainment/",
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:60.0) Gecko/20100101 Firefox/60.0"
    }

OK,正常返回数据了,多次请求,嘿,返回的文章基本都一样,看来还是不可以哦,再加上cookie试试,用固定的 tt_webid=6563786091257824776;,多次请求,发现跟浏览器里刷新是一样的了,会返回不同的文章,倒是基本可以满足一次抓取几十篇文章需求了。

不过既然搞了那再深入研究一下看看这几个参数是干啥的嘛

category=news_entertainment 分类,对应头条的分类,news_entertainment是娱乐

utm_source=toutiao 固定

widen=1 固定

max_behot_time=0max_behot_time_tmp=0值相同,是个时间戳,初次请求是0,每次请求返回的数据中都会带有下一次请求的时间戳 "next": {"max_behot_time": 1528358256}

tadrequire=true带广告,false不带广告

as=A1B5FBF1A7CA537cp=5B172A5593E7AE1是简单的时间加密,网上搜一下,很容易找到python的翻译代码,不加这两个参数,也能返回文章数据

def get_as_cp():
    t = int(math.floor(time.time()))
    e = hex(t).upper()[2:]
    m = hashlib.md5()
    m.update(str(t).encode(encoding='utf-8'))
    i = m.hexdigest().upper()
    if len(e) != 8:
        as_key = "479BB4B7254C150"
        cp_key = "7E0AC8874BB0985"
        return as_key, cp_key
    n = i[:5]
    a = i[-5:]
    r = ""
    s = ""
    for k in range(5):
        s += n[k]+e[k]
        r += e[k+3]+a[k]

    as_key = "A1" + s + e[-3:]
    cp_key = e[0:3] + r + "E1"

    return as_key, cp_key

_signature=oNvqZwAA-9u3ZjcQBbvTJ6Db6n 是个签名,测试发现这个参数值的生成跟浏览器UA有关,换个UA就没有数据返回了,_signature跟UA匹配才会返回数据,网上倒是没有找到python的翻译代码,看来这个有点难搞,先用固定值,构造链接

url = 'https://www.toutiao.com/api/pc/feed/?category=news_entertainment&utm_source=toutiao&widen=1' \
          '&max_behot_time={0}' \
          '&max_behot_time_tmp={0}' \
          '&tadrequire=true' \
          '&as={1}' \
          '&cp={2}' \
          '&_signature=oNvqZwAA-9u3ZjcQBbvTJ6Db6n'

发现第一次请求max_behot_time=0时正常返回文章数据,之后带上新的时间戳后,就没有数据了,看来这个签名还跟时间戳有点关系,找一下js代码看看,

function e(t) {
        var e = ascp.getHoney(),
            i = "";
        window.TAC && (i = TAC.sign("refresh" === t ? 1 : r.params.max_behot_time_tmp)), r.params = _.extend({}, r.params, {
            as: e.as,
            cp: e.cp,
            max_behot_time: "refresh" === t ? 0 : r.params.max_behot_time_tmp,
            _signature: i
        })
    }

果然,签名的生成跟max_behot_time_tmp的值有关,貌似是用TAC.sign()函数生成,找一下这个函数看看,emmm~,乱码~,尴尬了,在F12控制台敲入TAC.sign,发现还是乱码是什么鬼,这个函数还搞了一下加密?算了,算了,大道至简,还是搜搜看有没有人搞过吧,不过没有找到python的翻译代码,让我自己写,哈哈,那还是写不出来的,不费这个劲了,直接用解析js的方式来获取这个值吧,搜了一下,可以用pyExecJS这个模块来解析js,但是直接把头条的js文件加载进来发现会报错,不过倒是找到一份整理过的js代码 toutiao-TAC.sign.txt,https://bbs.125.la/thread-14115046-1-1.html,感谢nliger2015的分享,可以直接拿来用没有报错。

def get_as_cp_signature(max_behot_time=0):
    with open('toutiao-TAC.sign.txt', encoding='utf-8') as f:
        js = f.read()
    ctx = execjs.compile(js)
    s = ctx.call('get_as_cp_signature', max_behot_time)
    return json.loads(s)

哈哈,很好,可以直接返回as,cp,_signature三个参数值,不错不错,之前as和cp的翻译函数不需要了,获取文章列表数据基本差不多了,接下来开始搞文章详情。

先是直接用xpath获取内容,哈哈,结果取回来的全是None,嗯?xpath写的不对?放到浏览器测试没问题的,查看源代码看一下,我去,详情页的内容也是用js加载的,想要的内容都在js里呢,都放到BASE_DATA这个变量里了,是json结构的对象,不过不能直接用json模块解析,因为不全是用的双引号,json模块解析会报错,唉,好吧,既然在js里,那直接用js解析一下,要是能直接返回BASE_DATA这个变量对象,不就是拿到了文章的结构数据,用起来不就更省事了,不就是想怎么用就怎么用了,哈哈。

data = response.xpath('//script[contains(text(),"var BASE_DATA")]/text()').extract_first()
    if data:
        data = data + "\nvar a = function(){return BASE_DATA;}"
        ctx = execjs.compile(data)
        data = ctx.call('a')
        print(data['articleInfo']['title'])

先用xpath取出需要的js代码部分,再加个函数return这个变量,然后用pyExecJS解释执行一下,返回文章详情数据,执行下来,倒是没问题,也正常返回数据了,而且确实是直接可以拿来用的,不过输出一下,中文全变成了“锟斤拷锟斤拷锟叫★拷锟斤拷锟斤拷钱锟斤...”,这是什么鬼?编码有问题?尝试转码后再传递给js执行,结果报错,emmm,什么鬼,各种编码解码尝试无果,放弃js解析,改用正则,哈哈,还是正则好用,指哪拿哪

data = response.xpath('//script[contains(text(),"var BASE_DATA")]/text()').extract_first()
data = re.search(r"title:\s*'(.*?)',\s*content:\s*'(.*?)',[\s\S]*?tags:\s*(\[.*\]),", data)
if data:
    title, content, tags = data.group(1, 2, 3)

直接拿到title,content,tags的数据,再对拿到的数据进行一下处理,加个下载图片的pipeline把图片也下载下来,嗯,基本搞定,采集导出的json数据文件可以直接用来生成视频。

还有点小问题,重复文章数据,因为写这个小爬虫,就是采集文章用来生成视频的,不过生成视频比较慢,嗯是挺慢的,所以一次采集个几十篇文章即可,在一次抓取中,返回的文章数据是没有重复的,不过下一次再重新启动爬虫抓取时,就会跟上次采集的文章有重复的,时间间隔不长的话,重复的还不少,也加上cookie固定ttweb_id的值试了下发现效果不大,一样会有重复的数据,所以就把每次抓取的文章链接存到文件里,每次开始抓取前,先读入存放链接的文件把已经抓取的链接放到列表里,每次发送文章详情页request前先判断一下是不是抓取过,在列表中的链接直接跳过,等到抓取完成爬虫关闭时,再把新抓取的链接追加到文件里,等抓取的链接比较多的时候,这种方式可能不太好,不过没关系,满足当前的需求就可以了,哈哈。