Source spider.py

初步分析

该网站是一家日本视频服务提供商,所有的服务需要登录会员来获取,会员需要包月付费才能获取视频内容。然而通过分析,事情自然没有这么简单,该网站默认会用HTML5规范的<video>组件来实现播放功能,当然视频的地址会直接暴露在网站的源码中,这就给我们做一些工作提供了机会。视频页面常见的<video>标签如下。

<video id="flashcontent_html5_api" class="vjs-tech" preload="metadata" src="https://javhd.com/playback/18983/1-cwpbd-107-miho-ichiki-catwalk-poison-107_sh.mp4?username=fdpn9smw9ze3auc"></video>

当然,对完整视频资源的获取需要登录权限,事实上是该网站存在免费用户,所以事实上具有的所有资源访问的可能性。所以,接下来我们来剖析一下这个需要包月付费才能播放的视频网站的验证方式。

登录流程

首先分析一下登录页面的表单。

<form id="loginform" method="post">
    <div class="title">
        <h1>ログイン</h1>
    </div>
    <div class="external">
        <div class="external-form">
            <div class="external-form-title">ログイン:</div>
            <div class="external-form-area">
                <input type="text" class="text" id="login" name="login" value="">
            </div>
            <div class="external-form-title">パスワード:</div>
            <div class="external-form-area">
                <input id="password" name="password" type="password" class="text">
            </div>
            <div class="external-form-title">ログインするサイト:</div>
            <div class="external-form-area">
                <select name="back" onchange="$('#path').val('');">
                    <option selected="selected" value="javhd.com">JAVHD.com</option>
                    <option  value="av69.tv">AV69.tv</option>
                    <option  value="shiofuky.com">Shiofuky.com</option>
                    <option  value="heymilf.com">HeyMILF.com</option>
                    <option  value="ferame.com">Ferame.com</option>
                    <option  value="avtits.com">AVTits.com</option>
                    <option  value="avanal.com">AVAnal.com</option>
                    <option  value="gangav.com">GangAV.com</option>
                    <option  value="schoolgirlshd.com">SchoolGirlsHD.com</option>
                    <option  value="avstockings.com">AVStockings.com</option>
                    <option  value="hairyav.com">HairyAV.com</option>
                    <option  value="lingerieav.com">LingerieAV.com</option>
                    <option  value="heyoutdoor.com">HeyOutdoor.com</option>
                    <option  value="povav.com">POVAV.com</option>
                    <option  value="pussyav.com">PussyAV.com</option>
                    <option  value="amateurav.com">AmateurAV.com</option>
                </select>
                <input type="hidden" name="path" id="path" value="L2ph">
                <input type="hidden" name="v" id="v" value="2">
            </div>
            <script type="text/javascript">
                ag = navigator.userAgent;
                salt = 'e07c58adc4a9f0cd5ff9de5c0f27ba4d';
                str_overral = salt;
                str_overral = str_overral.replace(/[^a-z0-9]/gi, '').toLowerCase();
                str_res='';
                for (i=0; i<str_overral.length; i++) {
                    l=str_overral.substr(i,1);
                    d=l.charCodeAt(0);
                    if ( Math.floor(d/2) == d/2 ) {
                        str_res+=l;
                    } else {
                        str_res=l+str_res;
                    }
                }
                document.write('<in');
                document.write('put type="hidden" name="ch" value="'+str_res+'" />');
            </script>

            <input type="submit" class="link4" value="ログイン &#9656;">
            <div class="external-form-area">
                <a href="https://enter.javhd.com/signup/password.php?_language=ja" title="パスワードを忘れた場合">パスワードを忘れた場合</a>
            </div>
        </div>
    </div>
</form>

根据表单所展现出来的,表单对应的动作为POST,并且依据内嵌的JavaScript脚本来看,登录页面还会将服务器分发下来的salt值经过一定的操作转换为新的字符串并以ch的数据名同时发向服务器,总结一下发往同一个页面的表单数据字典如下。

{
  "login": "XXXXXXXXXXXXXXX", # 登录的用户名
  "password": "XXXXXXXXXXXXXXX", #登录密码
  "ch": res(salt(session)), # 经过处理的salt值
  "back": "javhd.com" #经跳转后前往的页面
}

具体分析过程 分析表单源码中的JavaScript脚本可知,网页会在动态加载完成后会在表单中再加入一条类型为hiddeninput组件,用来向服务器提交登录过程中需要的处理后的salt值。而salt值的计算通过分析脚本,我实现了如下的同类函数,用来在爬虫中实现对登录流程的模拟。

def salt(session: requests.Session):
    bs = BeautifulSoup(session.request('GET', 'https://secure.javhd.com/login/', headers={'user-agent': ua}).text,'html.parser') # GET请求获取登录页面并解析
    raw_script = ''
    try:
        raw_script = bs.select('div.external-form')[0].script.text # 获取脚本内容
    except IndexError:
        print(bs.head.title)
        print(ua)
    salt_line = ''
    salt_ = ''
    for line in raw_script.split('\n'): # 抽取原salt串
        pure_line = line.strip('\t \r')
        if re.match(r'salt.*', pure_line):
            salt_line = pure_line
            salt_ = pure_line[8:-2]
            break
    return salt_

网页首先会向服务器发送GET请求,这个过程相当于浏览器向服务器获取登录页面,但是事实上整个登录页面起到作用的只有服务器下发的salt串,并且经过如下的处理后,就会得到发送给服务器的ch串。

def res(salt: str):
    str_overral = salt
    str_res = ''
    for i in range(0, len(str_overral)):
        l = str_overral[i]
        d = ord(l)
        if (d % 2 == 0): # 当字符的ASCII码为偶数的时候,加到新串的后端
            str_res += l
        else: # 当字符的ASCII码为奇数的时候,加到新串的前端
            str_res = l + str_res
    return str_res

获得新的salt之后,POST请求所需要的数据齐全,同时向同一URL发送包含这些数据的POST请求,经过服务器跳转的GET请求,就可以正确登录到网站并且获得登陆成功的Session,改动作有如下方法实现。

def login(session: requests.Session):
    req = session.request('POST', 'https://secure.javhd.com/login/', data={
        "login": "XXXXXXXXXXXXXXX", 
        'password': 'XXXXXXXXXXXXXX',
        'ch': res(salt(session)),
        'back': 'javhd.com'
    }, headers={'user-agent': ua})
    return session

爬取视频信息

通过对输入Session的刷新,已经获得了网站的免费会员权限,可以进入视频页面进行视频地址的爬取了。首先我们要获取该网站所有视频的目录,从而得到所有视频的必要数据,所以接下来我们分析一下视频列表的页面。视频列表里面每一部视频的链接及展示信息都会被放在如下的区域当中。

<div class="thumb-content">
    <a href="https://javhd.com/ja/id/18728/big-tits-milf-amazes-with-full-japanese-blowjob" title="爆乳パイずりご奉仕でザーメン強制発射" clickitem="18728" class="tackclick thumb-link">
        <img src="https://c1.cdnjav.com/content-01/thumbs/3-mkd-s84-iroha-suzumura-kirari-84-p/images/480x270/12s.jpg" alt="爆乳パイずりご奉仕でザーメン強制発射">
        <span class="thumb-text thumb-text--two-row-title">
                            爆乳パイずりご奉仕でザーメン強制発射
                    </span>
        <span class="play"></span>
        <span class="border"></span>
    </a>
            <span class="movie-post-date">
                                                    2017年 07月 21日
        </span>
        <a data-item-id="18728" class="addtofav icon-fav" href="https://javhd.com/user/addtofavorites/movie/18728" title="お気に入りに追加する"><svg class="icon" width="15px" heigth="15px" viewBox="0 0 24 24"><use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#icon-heart"></use></svg></a>
        <a class="remfav icon-fav active" href="https://javhd.com/user/removefavorite/movie/18728" title="お気に入りだった" style="display: none;"><svg class="icon" width="15px" heigth="15px" viewBox="0 0 24 24"><use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#icon-heart"></use></svg></a>
        <span class="rating"><svg class="icon" width="16px" heigth="16px" viewBox="0 0 24 24"><use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#icon-thumb-up"></use></svg>66%</span>
    <svg class="icon icon-hd" width="25px" heigth="16px" viewBox="0 0 25 16"><use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#icon-hd"></use></svg>        <span class="time">10:59</span>
</div>

其中,a.thumb-link中的titlehrefclick-item参数都是必要的数据, 同时a.thumb-link中的img元素的原地址同时同时也是需要爬取的数据。同时我实现了如下两个函数来进行所有页面的爬取。

def index(session: requests.Session, num: int):
    req = session.request('GET', 'https://javhd.com/ja/japanese-porn-videos/justadded/all/{index}'.format(index=num),
                          headers={'user-agent': ua})
    if req.status_code != 200:
        raise FileNotFoundError() # 当爬取的页面超过了服务器存储的数目,服务器会返回错误码而非200成功状态,这个时候会退出函数并且抛出FileNotFoundError异常
    bs = BeautifulSoup(req.text, "html.parser") # 解析页面
    thumbs = bs.select('div.thumb-content') # 选择所有视频信息
    video_list = {video_item.select('a.thumb-link')[0].attrs['clickitem']:
        {
            'href': video_item.select('a.thumb-link')[0].attrs['href'],
            'title': video_item.select('a.thumb-link')[0].attrs['title'],
            'id': video_item.select('a.thumb-link')[0].attrs['clickitem'],
            'date': video_item.select('span.movie-post-date')[0].text.strip('\n '),
            'image': video_item.select('a.thumb-link')[0].img.attrs['src']
        } for video_item in thumbs
    }
    if not video_list:
        print(bs.text)
        raise ConnectionRefusedError
    return video_list


def videos(session: requests.Session):
    try:
        video_list = json.load(open('video_list.json', 'r')) # 检测是否有缓存在'video_list.json'中的已爬取数据
    except FileNotFoundError:
        video_list = {}
    video_index = 0
    while True:
        video_index += 1
        sys.stdout.write(' @ Dumping video index {index}.'.format(index=video_index))
        time.sleep(random.randint(0, 3)) # 随机等待0到3秒,避免被网站锁住
        leave = False
        try:
            for key, value in index(session, video_index).items(): # 检测是否已经爬取过
                if key not in video_list:
                    video_list[key] = value
                else:
                    leave = True
        except FileNotFoundError:
            sys.stdout.write('Not exist.\r')
            break
        sys.stdout.write('Succeed.\r')
        if leave: # 若当前列表页面的数据都已爬取,则不会进行下一页面的爬取
            sys.stdout.write('\n @ History file exists. Skipped.\n')
            break
    json.dump(video_list, open('video_list.json', 'wt'), ensure_ascii=False) # 保存爬取视频列表结果
    return video_list

获取视频

分析视频播放页面发现,播放页面的<video>组件不会静态加载,爬取下来的源代码中不存在视频的跳转地址,然而根据对于视频播放页面的抓包分析发现,该网站下会对一个URL API进行GET请求,从而获取到视频的地址,该URL地址如下:

https://javhd.com/zh/player/[视频ID]?is_trailer=0

GET参数中,is_trailer指获取的视频是否是1分钟小样……所以说用户和非用户的的区别仅仅是GET请求的参数不同,之后会获得一个json对象:

{"default_source": "720", "poster": "https://c1.cdnjav.com/content-01/thumbs/3-mkd-s84-iroha-suzumura-kirari-84-p/images/940x530/12s.jpg", "sources": [{"label": "1080p", "res": "1080", "src": "https://javhd.com/playback/18728/3-mkd-s84-iroha-suzumura-kirari-84_sh.mp4?username=fdpn9smw9ze3auc", "type": "video/mp4"}, {"label": "720p", "res": "720", "src": "https://javhd.com/playback/18728/3-mkd-s84-iroha-suzumura-kirari-84_hq.mp4?username=fdpn9smw9ze3auc", "type": "video/mp4"}, {"label": "480p", "res": "480", "src": "https://javhd.com/playback/18728/3-mkd-s84-iroha-suzumura-kirari-84_med.mp4?username=fdpn9smw9ze3auc", "type": "video/mp4"}, {"label": "240p", "res": "240", "src": "https://javhd.com/playback/18728/3-mkd-s84-iroha-suzumura-kirari-84_low.mp4?username=fdpn9smw9ze3auc", "type": "video/mp4"}], "thumbnails": {"sprites": ["https://c1.cdnjav.com/content-01/thumbs/3-mkd-s84-iroha-suzumura-kirari-84/sprites/1-100x100.jpg"]}, "subtitle": ""}

所以这个过程就很简单,直接用现成的json对象就可以:

def parse_video(session: requests.Session, video_item: dict):
    try:
        time.sleep(random.random()) # 随机等待0到1秒,避免被网站锁住
        player_req = session.request('GET',
                                     'https://javhd.com/zh/player/{index}? is_trailer=0'.format(index=video_item['id']),
                                     headers={'user-agent': ua}) # GET请求

        player_info = json.loads(player_req.text) # 读入json对象
    except json.decoder.JSONDecodeError:
        print(player_req.text)
    return player_info

获取到视频的跳转地址,我发现得到的地址需要登陆后才可以跳转,跳转后可以通过GET请求以一些必要的参数进行对视频的获取,但是在这里并没有什么收费付费之分,所以整个过程只有前端会对付费会员进行验证,收费和付费获取资源并没有什么区别,所以通过这种方式,我们可以完整地获取到整个网站的所有资源。根据这种情况,我们只需要在脚本里面做好对登陆的模拟并绕过前端的付费认证,就可以完整获取整个页面,关于这个动作,我们需要对原视频进行流处理,所以使用如下的方法进行跳转。

def redirect_video(session: requests.Session, url: str):
    req = session.request(url=url, headers={'user-agent': ua}, method='GET', allow_redirects=True, stream=True)
    return req.url

最后把相关数据读入内存,方便对这个爬虫的拓展。

session = requests.session()
session = login(session)
all_video = videos(session)
video_resources = parse_videos(session, all_video)
id_list = [key for key in all_video]