美文网首页让前端飞
Node.js爬取科技新闻网站cnBeta

Node.js爬取科技新闻网站cnBeta

作者: 水落斜阳 | 来源:发表于2018-12-17 17:00 被阅读0次

    本项目爬虫及服务端github地址:https://github.com/qiuchendi/cnbeta-node-master

    前端github地址:https://github.com/qiuchendi/vue-cnbeta

    技术细节

    使用 async await 做异步逻辑的处理。
    使用 async库 来做循环遍历,以及并发请求操作。
    使用 log4js 来做日志处理
    使用 cheerio 来做新闻详情页的分析抓取。
    使用 mongoose 来连接mongoDB 做数据的保存以及操作。

    目录结构
    ├── bin              // 入口
    │   ├── article-list.js      // 抓取新闻列表逻辑
    │   ├── content.js          // 抓取新闻内容逻辑
    │   ├── server.js      // 服务端程序入口
    │   └── spider.js      // 爬虫程序入口
    ├── config             // 配置文件
    ├── dbhelper           // 数据库操作方法目录
    ├── middleware      // koa2 中间件
    ├── model          // mongoDB 集合操作实例
    ├── router         // koa2 路由文件
    ├── utils         // 工具函数
    ├── package.json       
    
    
    方案分析

    首先看爬虫程序入口文件,整体逻辑其实很简单,先抓取新闻列表,存入MongoDB数据库,每十分钟抓取一次。新闻列表抓取之后,在数据库查询列表中没有新闻内容的新闻,开始抓取新闻详情,然后更新到数据库。

    const articleListInit = require('./article-list');
    const articleContentInit = require('./content');
    const logger = require('../config/log');
    
    const start = async() => {
        let articleListRes = await articleListInit();
        if (!articleListRes) {
            logger.warn('news list update failed...');
        } else {
            logger.info('news list update succeed!');
        }
    
        let articleContentRes = await articleContentInit();
        if (!articleContentRes) {
            logger.warn('article content grab error...');
        } else {
            logger.info('article content grab succeed!');
        }
    };
    
    if (typeof articleListInit === 'function') {
        start();
    }
    setInterval(start, 600000);
    
    

    接着看抓取新闻列表的逻辑,因为可以获取到新闻列表的Ajax接口,所以直接调用接口获取列表信息。但是也有个问题,cnBeta新闻列表的缩略图以及文章里的的图片是有防盗链的,所以你在自己的网站是没法直接使用它的图片的,所以我是直接把cnBeta的图片文件爬下来存到自己的服务器上。

    /**
     * 初始化方法 抓取文章列表
     * @returns {Promise.<*>}
     */
    const articleListInit = async() => {
        logger.info('grabbing article list starts...');
        const pageUrlList = getPageUrlList(listBaseUrl, totalPage);
        if (!pageUrlList) {
            return;
        }
        let res = await getArticleList(pageUrlList);
        return res;
    }
    
    /**
     * 利用分页接口获取文章列表
     * @param pageUrlList
     * @returns {Promise}
     */
    const getArticleList = (pageUrlList) => {
        return new Promise((resolve, reject) => {
            async.mapLimit(pageUrlList, 1, (pageUrl, callback) => {
                getCurPage(pageUrl, callback);
            }, (err, result) => {
                if (err) {
                    logger.error('get article list error...');
                    logger.error(err);
                    reject(false);
                    return;
                }
                let articleList = _.flatten(result);
                downloadThumbAndSave(articleList, resolve);
            })
        })
    };
    
    /**
     * 获取当前页面的文章列表
     * @param pageUrl
     * @param callback
     * @returns {Promise.<void>}
     */
    const getCurPage = async(pageUrl, callback) => {
        let num = Math.random() * 1000 + 1000;
        await sleep(num);
        request(pageUrl, (err, response, body) => {
            if (err) {
                logger.info('current url went wrong,url address:' + pageUrl);
                callback(null, null);
                return;
            } else {
                let responseObj = JSON.parse(body);
                if (responseObj.result && responseObj.result.list) {
                    let newsList = parseObject(articleModel, responseObj.result.list, {
                        pubTime: 'inputtime',
                        author: 'aid',
                        commentCount: 'comments',
                    });
                    callback(null, newsList);
                    return;
                }
                console.log("出错了");
                callback(null, null);
            }
        });
    };
    
    const downloadThumbAndSave = (list, resolve) => {
        const host = 'https://static.cnbetacdn.com';
        const basepath = './public/data';
        if (list.indexOf(null) > -1) {
            resolve(false);
        } else {
            try {
                async.eachSeries(list, (item, callback) => {
                    let thumb_url = item.thumb.replace(host, '');
                    item.thumb = thumb_url;
                    if (!fs.exists(thumb_url)) {
                        mkDirs(basepath + thumb_url.substring(0, thumb_url.lastIndexOf('/')), () => {
                            request
                                .get({
                                    url: host + thumb_url,
                                })
                                .pipe(fs.createWriteStream(path.join(basepath, thumb_url)))
                                .on('error', (err) => {
                                    console.log("pipe error", err);
                                });
                            callback(null, null);
                        });
                    }
                }, (err, result) => {
                    if (!err) {
                        saveDB(list, resolve);
                    }
                });
            }
            catch(err) {
                console.log(err);
            }
        }
    };
    
    /**
     * 将文章列表存入数据库
     * @param result
     * @param callback
     * @returns {Promise.<void>}
     */
    const saveDB = async(result, callback) => {
        //console.log(result);
        let flag = await dbHelper.insertCollection(articleDbModel, result).catch(function (err){
            logger.error('data insert falied');
        });
        if (!flag) {
            logger.error('news list save failed');
        } else {
            logger.info('list saved!total:' + result.length);
        }
        if (typeof callback === 'function') {
            callback(true);
        }
    };
    
    

    再来看抓取新闻内容的逻辑,这里是直接根据新闻的sid得到新闻内容页的html,然后利用cheerio库分析获取我们需要的新闻内容。当然这里也是要把文章中的图片爬下来存入服务器,并且把存入数据库的新闻内容中图片链接替换成自己服务器中的URL。

    /**
     * 抓取正文程序入口
     * @returns {Promise.<*>}
     */
    const articleContentInit = async() => {
        logger.info('grabbing article contents starts...');
        let uncachedArticleSidList = await getUncachedArticleList(articleDbModel);
        // console.log('未缓存的文章:'+ uncachedArticleSidList.join(','));
        const res = await batchCrawlArticleContent(uncachedArticleSidList);
        if (!res) {
            logger.error('grabbing article contents went wrong...');
        }
        return res;
    };
    
    /**
     * 查询新闻列表获取sid列表
     * @param Model
     * @returns {Promise.<void>}
     */
    const getUncachedArticleList = async(Model) => {
        const selectedArticleList = await dbHelper.queryDocList(Model).catch(function (err){
            logger.error(err);
        });
        return selectedArticleList.map(item => item.sid);
        // return selectedArticleList.map(item => item._doc.sid);
    };
    
    /**
     * 批量抓取新闻详情内容
     * @param list
     * @returns {Promise}
     */
    const batchCrawlArticleContent = (list) => {
        return new Promise((resolve, reject) => {
            async.mapLimit(list, 3, (sid, callback) => {
                getArticleContent(sid, callback);
            }, (err, result) => {
                if (err) {
                    logger.error(err);
                    reject(false);
                    return;
                }
                resolve(true);
            });
        });
    };
    
    /**
     * 抓取单篇文章内容
     * @param sid
     * @param callback
     * @returns {Promise.<void>}
     */
    const getArticleContent = async(sid, callback) => {
        let num = Math.random() * 1000 + 1000;
        await sleep(num);
        let url = contentBaseUrl + sid + '.htm';
        request(url, (err, response, body) => {
            if (err) {
                logger.error('grabbing article content went wrong,article url:' + url);
                callback(null, null);
                return;
            }
            const $ = cheerio.load(body, {
                decodeEntities: false
            });
            const serverAssetPath = `${serverIp}:${serverPort}/data`;
            let domainReg = new RegExp('https://static.cnbetacdn.com','g');
            let article = {
                sid,
                source: $('.article-byline span a').html() || $('.article-byline span').html(),
                summary: $('.article-summ p').html(),
                content: $('.articleCont').html().replace(styleReg.reg, styleReg.replace).replace(scriptReg.reg, scriptReg.replace).replace(domainReg, serverAssetPath),
            };
            saveContentToDB(article);
            let imgList = [];
            $('.articleCont img').each((index, dom) => {
                imgList.push(dom.attribs.src);
            });
            downloadImgs(imgList);
            callback(null, null);
        });
    };
    
    /**
     * 下载图片
     * @param list
     */
    const downloadImgs = (list) => {
        const host = 'https://static.cnbetacdn.com';
        const basepath = './public/data';
        if (!list.length) {
            return;
        }
        try {
            async.eachSeries(list, (item, callback) => {
                let num = Math.random() * 500 + 500;
                sleep(num);
                if (item.indexOf(host) === -1) return;
                let thumb_url = item.replace(host, '');
                item.thumb = thumb_url;
                if (!fs.exists(thumb_url)) {
                    mkDirs(basepath + thumb_url.substring(0, thumb_url.lastIndexOf('/')), () => {
                        request
                            .get({
                                url: host + thumb_url,
                            })
                            .pipe(fs.createWriteStream(path.join(basepath, thumb_url)))
                            .on("error", (err) => {
                                console.log("pipe error", err);
                            });
                        callback(null, null);
                    });
                }
            });
        }
        catch(err) {
            console.log(err);
        }
    };
    /**
     * 保存到文章内容到数据库
     * @param article
     */
    const saveContentToDB = (item) => {
        let flag = dbHelper.updateCollection(articleDbModel, item);
        if (flag) {
            logger.info('grabbing article content succeeded:' + item.sid);
        }
    };
    
    

    爬虫部分差不多就是这样,还有一点就自己服务器存储的爬取的图片每天都会有上百张,时间一长,图片占用的存储空间就会特别大,所以需要定时清理一下,有兴趣的可以看看项目里面的clear-expire.js文件。

    总结

    其实,虽然这个项目整体并不复杂,但是一套前后端系统搭建起来的过程中,自己的收获还是挺不少的,很多问题的解决需要自己去实践和思考的,对于性能优化考量也是一个重要的方面。
    下面截图就是我最终完成得m站,界面很清爽,体验上确实比cnBeta官网要好很多。这样是平时看科技新闻也确实方便很多。

    相关文章

      网友评论

        本文标题:Node.js爬取科技新闻网站cnBeta

        本文链接:https://www.haomeiwen.com/subject/ffaqkqtx.html