PHP爬蟲抓取segmentfault問答

PHP爬蟲抓取segmentfault問答

一 需求概述

抓取中國領先的開發者社區segment.com網站上問答及標籤數據,側面反映最新的技術潮流以及國內程序猿的關注焦點.javascript

注:抓取腳本純屬我的技術鍛鍊,非作任何商業用途.php

二 開發環境及包依賴

運行環境前端

  • CentOS Linux release 7.0.1406 (Core)java

  • PHP7.0.2node

  • Redis3.0.5mysql

  • Mysql5.5.46redis

  • Composer1.0-devsql

composer依賴json

三 流程與實踐

首先,先設計兩張表:post,post_tag

CREATE TABLE `post` (
 `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'pk',
 `post_id` varchar(32) NOT NULL COMMENT '文章id',
 `author` varchar(64) NOT NULL COMMENT '發佈用戶',
 `title` varchar(512) NOT NULL COMMENT '文章標題',
 `view_num` int(11) NOT NULL COMMENT '瀏覽次數',
 `reply_num` int(11) NOT NULL COMMENT '回覆次數',
 `collect_num` int(11) NOT NULL COMMENT '收藏次數',
 `tag_num` int(11) NOT NULL COMMENT '標籤個數',
 `vote_num` int(11) NOT NULL COMMENT '投票次數',
 `post_time` date NOT NULL COMMENT '發佈日期',
 `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '抓取時間',
 PRIMARY KEY (`id`),
 KEY `idx_post_id` (`post_id`)
) ENGINE=MyISAM AUTO_INCREMENT=7108 DEFAULT CHARSET=utf8 COMMENT='帖子';
CREATE TABLE `post_tag` (
 `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'PK',
 `post_id` varchar(32) NOT NULL COMMENT '帖子ID',
 `tag_name` varchar(128) NOT NULL COMMENT '標籤名稱',
 PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=15349 DEFAULT CHARSET=utf8 COMMENT='帖子-標籤關聯表';

固然有同窗說,這麼設計不對,標籤是個獨立的主體,應該設計post,tag,post_tag三張表,文檔和標籤之間再創建聯繫,這樣不只清晰明瞭,並且查詢也很方便.
這裏簡單處理是由於首先不是很正式的開發需求,自娛自樂,越簡單搞起來越快,另外三張表抓取入庫時就要多一張表,更重要的判斷標籤重複性,致使抓取速度減慢.

整個項目工程文件以下:

app/config/config.php  /*配置文件*/
app/helper/Db.php  /*入庫腳本*/
app/helper/Redis.php /*緩存服務*/
app/helper/Spider.php /*抓取解析服務*/
app/helper/Util.php /*工具*/
app/vendor/composer/ /*composer自動加載*/
app/vendor/symfony/ /*第三方抓取服務包*/
app/vendor/autoload.php /*自動加載*/
app/composer.json /*項目配置*/
app/composer.lock /*項目配置*/
app/run.php /*入口腳本*/

由於功能很簡單,因此沒有必要引用第三方開源的PHP框架

基本配置

class Config
{
    public static $spider = [
        'base_url'  => 'http://segmentfault.com/questions?',
        'from_page' => 1,
        'timeout'   => 5,
    ];

    public static $redis = [
        'host'    => '127.0.0.1',
        'port'    => 10000,
        'timeout' => 5,
    ];

    public static $mysql = [
        'host'     => '127.0.0.1',
        'port'     => '3306',
        'dbname'   => 'segmentfault',
        'dbuser'     => 'user',
        'dbpwd' => 'user',
        'charset'  => 'utf8',
    ];
}

curl抓取頁面的函數

public function getUrlContent($url)
{
    if (!$url || !\filter_var($url, FILTER_VALIDATE_URL)) {
        return false;
    }

    $curl = \curl_init();
    \curl_setopt($curl, CURLOPT_URL, $url);
    \curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
    \curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
    \curl_setopt($curl, CURLOPT_TIMEOUT, Config::$spider['timeout']);
    \curl_setopt($curl, CURLOPT_USERAGENT, 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.111 Safari/537.36');
    $content = curl_exec($curl);
    curl_close($curl);

    return $content;
}

這裏要有兩點要注意:

第一,要開啓CURLOPT_FOLLOWLOCATION301跟蹤抓取,由於segmentfautl官方會作域名跳轉,好比http://www.segmentfault.com/會跳轉到到"http://segmentfault.com"等等.
第二,指定UserAgent,不然會出現301重定向到瀏覽器升級頁面.

crawler解析處理

public function craw()
{
    $content = $this->getUrlContent($this->getUrl());
    $crawler = new Crawler();
    $crawler->addHtmlContent($content);

    $found = $crawler->filter(".stream-list__item");

    //判斷是否頁面已經結束
    if ($found->count()) {
        $data = $found->each(function (Crawler $node, $i) {
            //問答ID
            $href    = trim($node->filter(".author li a")->eq(1)->attr('href'));
            $a       = explode("/", $href);
            $post_id = isset($a[2]) ? $a[2] : 0;

            //檢查該問答是否已經抓取過
            if ($post_id == 0 || !(new Redis())->checkPostExists($post_id)) {
                return $this->getPostData($node, $post_id, $href);
            }

            return false;
        });
        //去除空的數據
        foreach ($data as $i => $v) {
            if (!$v) {
                unset($data[$i]);
            }
        }
        $data = array_values($data);
        $this->incrementPage();

        $continue = true;
    } else {
        $data     = [];
        $continue = false;
    }


    return [$data, $continue];
}

private function getPostData(Crawler $node, $post_id, $href)
{
    $tmp            = [];
    $tmp['post_id'] = $post_id;
    //標題
    $tmp['title'] = trim($node->filter(".summary h2.title a")->text());

    //回答數
    $tmp['reply_num'] = intval(trim($node->filter(".qa-rank .answers")->text()));

    //瀏覽數
    $tmp['view_num'] = intval(trim($node->filter(".qa-rank .views")->text()));

    //投票數
    $tmp['vote_num'] = intval(trim($node->filter(".qa-rank .votes")->text()));

    //發佈者
    $tmp['author'] = trim($node->filter(".author li a")->eq(0)->text());

    //發佈時間
    $origin_time = trim($node->filter(".author li a")->eq(1)->text());
    if (mb_substr($origin_time, -2, 2, 'utf-8') == '提問') {
        $tmp['post_time'] = Util::parseDate($origin_time);
    } else {
        $tmp['post_time'] = Util::parseDate($this->getPostDateByDetail($href));
    }

    //收藏數
    $collect = $node->filter(".author .pull-right");
    if ($collect->count()) {
        $tmp['collect_num'] = intval(trim($collect->text()));
    } else {
        $tmp['collect_num'] = 0;
    }

    $tmp['tags'] = [];
    //標籤列表
    $tags = $node->filter(".taglist--inline");
    if ($tags->count()) {
        $tmp['tags'] = $tags->filter(".tagPopup")->each(function (Crawler $node, $i) {
            return $node->filter('.tag')->text();
        });
    }

    $tmp['tag_num'] = count($tmp['tags']);

    return $tmp;
}

經過crawler將抓取的列表解析成待入庫的二維數據,每次抓完,分頁參數遞增.
這裏要注意幾點:
1.有些問答已經抓取過了,入庫時須要排除,所以此處加入了redis緩存判斷.
2.問答的建立時間須要根據"提問","解答","更新"狀態來動態解析.
3.須要把相似"5分鐘前","12小時前","3天前"解析成標準的Y-m-d格式

入庫操做

public function multiInsert($post)
{
    if (!$post || !is_array($post)) {
        return false;
    }

    $this->beginTransaction();
    try {
        //問答入庫
        if (!$this->multiInsertPost($post)) {
            throw new Exception("failed(insert post)");
        }
        //標籤入庫
        if (!$this->multiInsertTag($post)) {
            throw new Exception("failed(insert tag)");
        }
        $this->commit();
        $this->pushPostIdToCache($post);

        $ret = true;
    } catch (Exception $e) {
        $this->rollBack();
        $ret = false;
    }

    return $ret;
}

採用事務+批量方式的一次提交入庫,入庫完成後將post_id加入redis緩存

啓動做業

require './vendor/autoload.php';

use helper\Spider;
use helper\Db;

$spider = new Spider();
while (true) {
    echo 'crawling from page:' . $spider->getUrl() . PHP_EOL;
    list($data, $ret) = $data = $spider->craw();
    if ($data) {
        $ret = (new Db)->multiInsert($data);
        echo count($data) . " new post crawled " . ($ret ? 'success' : 'failed') . PHP_EOL;
    } else {
        echo 'no new post crawled'.PHP_EOL;
    }
    echo PHP_EOL;

    if (!$ret) {
        exit("work done");
    }
};

運用while無限循環的方式執行抓取,遇到抓取失敗時,自動退出,中途能夠按Ctrl + C中斷執行.

四 效果展現

抓取執行中
start

問答截圖
post

標籤截圖
tag

五 總結

以上的設計思路和腳本基本上能夠完成簡單的抓取和統計分析任務了.
咱們先看下TOP25標籤統計結果:

tag_stat.jpg

能夠看出segmentfault站點裏,討論最熱的前三名是javascript,php,java,並且前25個標籤裏跟前端相關的(這裏不包含移動APP端)竟然有13個,佔比50%以上了.

每個月標籤統計一次標籤,就能夠很方便的掌握最新的技術潮流,哪些技術的關注度有所降低,又有哪些在上升.

有待完善或不足之處
1.單進程抓取,速度有些慢,若是開啓多進程的,則須要考慮進程間避免重複抓取的問題
2.暫不支持增量更新,每次抓取到從配置項的指定頁碼開始一直到結束,能夠根據已抓取的post_id作終止判斷(post_id雖不是連續自增,可是一直遞增的)

相關文章
相關標籤/搜索