新闻爬虫及爬取结果的查询网站(使用Node.js)
!!!----------
网站博文限于审核,删减内容过多,实验报告部分还请老师、助教查看作业压缩包中pdf报告文件
!!!----------
!!!----------
具体代码也在作业压缩包中:
myapp文件夹:内含所实现的网站的源码
爬虫源码文件夹:爬虫源码
!!!----------
目录
总任务
一、爬虫
爬取新闻网站数据
这里完成了搜狐、网易与澎湃
二、搜索网站
- 搜索
- 时间热度分析
- 时间热度与来源分析
新闻网页爬虫部分
这一部分的图片请见pdf文件
概述
工具包
使用node.js中的三个工具包
- request 用于请求网页获取所需数据
- chreeio 用于解析得到的网页
- iconv-lite 用于转换编码
代码的主要逻辑
- 请求新闻种子页面,该页面是新闻网站的主页,布满了供爬取的锚点链接,根据这个页面获取到具体新闻网页的链接。
- 遍历第一步获取到的全部新闻网站链接,请求具体新闻网页,解析页面获取需要的信息并存入数据库中。
注 需要详细了解jQuery的一些语法来解析网页,可以参考这里。
搜狐新闻
分析种子页面
右键查看源代码,寻找种子url所在位置
观察整个网页,不难发现一个特点:种子url所含的a标签都有data-param这一属性,属性的具体值皆不同,可以利用这一特点筛选标签获取种子url。根据jQuery属性选择器的语法,可以使用$(’[data-param]’)来获取,然后对于每一个这样的a标签获取href属性的值。
于是…先试着将种子url放入数组中
request(seedURL, function(err, res, body) {
var html = myIconv.decode(body, myEncoding);
var $ = myCheerio.load(html, { decodeEntities: true });
var seedurl_news;
url = new Array();
var seedurl_news;
try {
seedurl_news = eval($('[data-param]'));
} catch (e) { console.log('url列表所处的html块识别出错:' + e) };
seedurl_news.each(function(i, e){
url.push($(e).attr('href'));
});
console.log(url);
});
然而…结果
看来还是要清洗一下
如下,改动一点,经排查发现我们需要的url必须要以’http://www.sohu.com/a’开头
这样得到的url就是正确的了
seedurl_news.each(function(i, e){
var myURL = ""
try {
var href = ""
href = $(e).attr('href');
if (href == undefined) return;
} catch (e) { console.log('error: ' + e) }
if (href.startsWith('http://www.sohu.com/a')){
myURL = href;
// url.push(myURL);
}else if (href.startsWith('//www.sohu.com/a')){
myURL = 'http:' + href;
// url.push(myURL);
}else return;
}
分析新闻页面
首先明确我们要获取到这样几个字段:
标题、关键词、摘要、时间、作者(或者是编辑)
来源、来源链接、原始来源、原始来源链接
内容
编码都是utf-8就不再新建这个字段了
进入一个具体的网页
我们要找的信息有很多已经显示在前几行里面了
至于内容可以右击使用“检查”来寻找
于是…
var title_format = "$('title').text()";
var keywords_format = " $('meta[name=\"keywords\"]').eq(0).attr(\"content\")";
var desc_format = " $('meta[name=\"description\"]').eq(0).attr(\"content\")";
var date_format = "$('meta[itemprop=\"datePublished\"]').eq(0).attr(\"content\")"
var author_format = "$('p[data-role=\"editor-name\"]').text()"
var original_format = "$('span[data-role=\"original-link\"]').children('a').text()"
var original_url_format = "$('span[data-role=\"original-link\"]').children('a').attr('href')"
var content_format = "$('.article').text()";
排错:
- 有可能某个字段爬到空值或者undefined还需要再判断一下,比如
if ((desc_format == "")||(eval(desc_format) === undefined)) item.desc = item.title; //没有摘要就用标题
else item.desc = eval(desc_format).replace("\r\n", ""); //摘要
if ((date_format == "")||(regExp.exec(eval(date_format))==null)) item.publish_date = (new Date()).toFormat("YYYY-MM-DD") //没有日期
else{
item.publish_date = regExp.exec(eval(date_format))[0];
item.publish_date = item.publish_date.replace('年', '-')
item.publish_date = item.publish_date.replace('月', '-')
item.publish_date = item.publish_date.replace('日', '')
item.publish_date = new Date(item.publish_date).toFormat("YYYY-MM-DD");
}
- 这里的body如果是undefine就会报错。
var html_news = myIconv.decode(body, myEncoding); //用iconv转换编码
所以上面要加一行判断
if(body === undefined) return;
- 还有一个点确实是有些奇怪,我们已经对是否重复爬取链接做了判断,但依然会抛出违反唯一键的异常,而且据我所知这个是否抛出异常还因电脑不同而异(运行老师给的样例代码就是有的正常,有的抛出异常),虽然不影响程序正常运行,但是有报错还是觉得别扭,后来发现可以使用insert ignore into…来运行(参考这里),如果数据库中没有这条数据就插入,如果有就跳过,这样就不会有报错了,而且相关字段也满足唯一键约束,数据库的模式依然正常。
网易新闻
分析种子页面
网易主页的新闻链接排布很整齐,$(’[ne-if]’).find(‘a’).attr(‘href’)来获取
分析新闻页面
与爬取搜狐新闻时同理
网易新闻页是真的规范,注释已经写得很清楚哪里是正文了
但有一些字段也确实是找不到了,于是
var title_format = "$('.post_title').text()";
var keywords_format = " $('meta[name=\"keywords\"]').eq(0).attr(\"content\")";
var desc_format = " $('meta[name=\"description\"]').eq(0).attr(\"content\")";
var date_format = "$('meta[property=\"article:published_time\"]').eq(0).attr(\"content\")"
var original_format = "$('div[class=\"post_wemedia_name\"]').children('a').text()"
var content_format = "$('div[class=\"post_body\"]').text()";
网易新闻爬虫结束
澎湃新闻
分析种子页面
稍稍有点乱
采用获取全部a标签再判断其href是否以"newsDetail"开头来获取全部新闻链接,当然还要加上’https://www.thepaper.cn/'才能得到一个正确的链接。
分析新闻页面
分析基本同理可得
于是
var title_format = "$('.news_title').text()";
var keywords_format = " $('meta[name=\"Keywords\"]').eq(0).attr(\"content\")";
var desc_format = " $('meta[name=\"Description\"]').eq(0).attr(\"content\")";
var date_format = "$('div.news_about').children('p').text()"
var content_format = "$('.news_txt').text()";
澎湃新闻爬虫结束
网站搭建:基于Express框架与MySQL数据库
数据库设计
新建news表
物理模型
建表sql语句
CREATE TABLE `news` (
`id` int NOT NULL AUTO_INCREMENT,
`title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`keywords` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`summary` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`date` date NULL DEFAULT NULL,
`author` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`source` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
`source_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
`original` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`original_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `s_url`(`source_url`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2813 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
主要的设计就是将id设为自增的主键,source_url设为唯一键,可以为null的字段默认为null。
排错
-
node连接数据库时遇到过一个报错,ER_NOT_SUPPORTED_AUTH_MODE,但我密码明明是正确的…参考这里和这里进行调整,最后终于成功连上了。
-
爬虫的时候还遇到一个问题,放到这里说。所爬取文字里有一些类似emoji表情的东西,数据库不认,无法插入数据库中,本来想着用正则表达式将这些奇怪的字符replace掉,但也没找到一个合适的表达式,有一种表达式把所有中文都匹配没了…于是参考这里改了数据库的配置,这样就不报错了。
贴上之前的报错信息如下:
Express框架:后端
Express 是一个简洁而灵活的 node.js Web应用框架, 提供了一系列强大特性帮助你创建各种 Web 应用,和丰富的 HTTP 工具。
使用 Express 可以快速地搭建一个完整功能的网站。Express 框架核心特性:1. 可以设置中间件来响应 HTTP 请求。2. 定义了路由表用于执行不同的 HTTP 请求动作。3. 可以通过向模板传递参数来动态渲染 HTML 页面。
这是来自菜鸟教程的一段简介,但就只讲了一页…
于是我又学习了一些资料
Express中文网
b站上某资源 挑着看了一点,讲的还挺好
搭建脚手架
npm install express-generator -g
express -e myApp
cd myApp
npm install
npm install mysql --save
npm install pug --save
然后参考这里调整为调试模式,也就是每次保存都会自动重启运行
npm run dev
打开本地5000端口发现程序正常运行啦
添加路由
其实后端的任务主要就在于添加路由了,修改routes文件夹中的index.js文件
任务1 搜索
- 用get方法添加路由’\search’,渲染页面’search.pug’,显示一个表单,表单中有三个字段,标题、内容与日期,这三个字段可以都填,也可有不填的字段,如果填了多个字段就是复合查询,表单提交后传入后端路由’\search_handler’。
- 用post方法添加路由’\search_handler’,然后根据前端传入的值访问数据库进行查询,为了美观,处理一下日期的显示再将结果渲染到前端’news_list.pug’页面。
//搜索界面
router.get('/search',(req,res)=>{
res.render('search.pug', { title: 'Search News' });
});
//搜索处理
router.post('/search_handler',(req,res)=>{
var body = req.body;
var fetchSql = "select source_url,source,title,author,date " +
"from news where title like '%" + body.title + "%' and " +
"content like '%" + body.content +"%'";
if(body.date!=""){
var fetchSql = fetchSql + " and date = '" + body.date + "'";
}
mysql.query(fetchSql, function(err, result, fields) {
var regExp = /((\d{4}|\d{2})(\-|\/|\.)\d{1,2}\3\d{1,2})/;
for(let i=0;i<result.length;i++){
result[i].date = regExp.exec(JSON.stringify(result[i].date))[0];
}
res.render('news_list.pug', { title: 'News List', news_list: result});
});
});
任务2 时间热度分析
- 用get方法添加路由’\time’,渲染页面’time.pug’,显示一个表单,表单中有两个字段,标题、内容,这两个字段可以都填,也可有不填的字段,如果填了多个字段就是复合查询,表单提交后传入后端路由’\time_handler’。
- 用post方法添加路由’\time_handler’,根据表单传入的值使用groupby语句对数据库进行查询,sql语句构造如下
var fetchSql = "select date,count(*) from " +
"news where title like '%" + body.title +
"%' and content like '%" + body.content + "%' group by date order by date asc";
sql数据库会返回一个类似这样的对象:
//[{"date":"2021-04-26T16:00:00.000Z","count(*)":3},{"date":"2021-04-27T16:00:00.000Z","count(*)":4},{"date":"2021-04-28T16:00:00.000Z","count(*)":7}]
为了前端展示数据,在这里我必须将数据进行处理,也就是要索引到对象里的值,然后令人头大的事情就来了…
count(*)那一列无论如何也无法直接引用到,试了比如result[0]['count(*)']
等等多种表示形式都不行,但用这样的方式索引date那一列就可以
sql语句里面换列名,还是索引不到,甚至date换了列名,用其新列名也索引不到date了
这个确实太奇怪了,最后想了一个其它的办法终于拿到了这个对象里的数据,总之就是把这些键值对对象里的值都拿出来放到数组里(好奇为什么这样双重循环就拿到了对象中的value…),再操作数组整理数据。然后得到两个数组传到前端’time_handler.ejs’文件。
执行sql以及其回调:
mysql.query(fetchSql, function(err, result, fields) {
var regExp = /((\d{4}|\d{2})(\-|\/|\.)\d{1,2}\3\d{1,2})/;
var date_list = new Array();
var count = new Array();
var vals = new Array();
for (let i = 0; i < result.length; i++) {
for (let j in result[0]){
vals.push(result[i][j]);
}
}
for (let i=0;i<vals.length;i++){
if(i%2==1) count.push(vals[i]);
else date_list.push(regExp.exec(JSON.stringify(vals[i]))[0]);
}
res.render('time_handler', { date: [date_list],count:[count]});
});
任务3 时间热度与来源分析
为了从更多维度展示数据,我加入了这样一个任务。
- 用get方法添加路由’\time_source’,渲染页面’time_source.pug’,显示一个表单,表单中有两个字段,标题、内容,这两个字段可以都填,也可有不填的字段,如果填了多个字段就是复合查询,表单提交后传入后端路由’\time_source_handler’。
- 用post方法添加路由’\time_source_handler’。首先明确我需要传给前端的数据类型是这样的
['date', '2021-4-28', ...],
['搜狐新闻', 26...],
['网易新闻', 13...],
['澎湃新闻', 21...],
我先想到的是这样:
select date,source,count(*) cou from news where title like '%%' and content like '%新冠%'
group by date,source order by date asc;
这样确实得到了我们想要的数据,但这样的话相当于将数据库的事情交到了后端代码来完成,没有充分利用好数据库…更何况JS索引sql返回对象这个事情真是奇奇怪怪…
所以我们要在数据库中充分完成数据规整这个任务,于是我参考了这里和这里。
于是
select date,
sum(case when source ='搜狐新闻' THEN cou Else '0' End) '搜狐新闻',
sum(case when source ='网易新闻' THEN cou Else '0' End) '网易新闻',
sum(case when source ='澎湃新闻' THEN cou Else '0' End) '澎湃新闻',
sum(cou)
from
(select date,source,count(*) cou from news where title like '%%' and content like '%新冠%'
group by date,source order by date asc) as table1
group by date order by date asc;
结果:
这下终于体会到sql的好了!真yyds。之前用了orm就觉得sql不好用,但这样的复杂查询orm好像有点困难。
然后就根据任务2如法炮制吧,
执行sql以及其回调
这部分相关代码略长,就不贴出了,具体见作业压缩包中代码文件或pdf报告文件
Express框架:前端
总体来说,我没有再使用样例中的方法,直接使用html文件作为访问地址。
在我的实现中,需要先访问定义的路由,然后渲染前端表单页面,提交表单再重定向到新的路由进行与数据库的交互,再将数据渲染到前端进行展示。以搜索任务为例大概就是这样:
pug文件渲染
为什么要使用pug?
对于html文件,在后期维护和修改时,一不小心少了一个尖括号,或者某个标签的开始和闭合没有对应上,就会导致DOM结构的混乱甚至是错误。所以,有人发明了HAML,它最大的特色就是使用缩进排列来代替成对标签。受HAML的启发,pug进行了javascript的实现。Pug原名不叫Pug,是大名鼎鼎的jade,后来由于商标的原因,改为Pug,哈巴狗。其实只是换个名字,语法都与jade一样。丑话说在前面,Pug有它本身的缺点——可移植性差,调试困难,性能并不出色,但使用它可以加快开发效率。
在实践过程中,我发现使用pug确实很方便,无需JS与html的切换,不需要标签的闭合尖括号记号。
以news_list.pug文件为例,这个文件渲染了任务1的查询结果页面,以下是表格生成部分
table(width="100%")
ul
each news in news_list
tr(class='cardLayout')
td
a(href=news.source_url) #{news.title}
td
| #{news.source}
td
| #{news.date}
else
li There are no news.
但如果使用JS+HTML(使用jQuery)确实要复杂很多:
<div class="cardLayout" style="margin: 10px 0px">
<table width="100%" id="record2"></table>
</div>
<script>
$(document).ready(function() {
$("input:button").click(function() {
$.get('/process_get?title=' + $("input:text").val(), function(data) {
$("#record2").empty();
$("#record2").append('<tr class="cardLayout"><td>url</td><td>source_name</td>' +
'<td>title</td><td>author</td><td>publish_date</td></tr>');
for (let list of data) {
let table = '<tr class="cardLayout"><td>';
Object.values(list).forEach(element => {
table += (element + '</td><td>');
});
$("#record2").append(table + '</td></tr>');
}
});
});
});
</script>
又利用pug文件生成了导航栏
div(class='container-fluid')
div(class='row')
div(class='col-sm-2')
block sidebar
ul(class='sidebar-nav')
li
a(href='/search') Search
li
a(href='/time') Time Analysis
li
a(href='/time_source') Time and Source
由于脚手架默认是用ejs渲染,所以render文件时需要加上文件后缀名.pug。
ejs文件渲染实现echarts
需使用HTML+JS,实现echarts要好好看看官方网站,例子给的也很全了。
用CDN方法引入echarts文件
<script src="https://cdn.staticfile.org/echarts/4.3.0/echarts.min.js"></script>
任务2中时间热度分析渲染折线图
有点迷的事情又出现了,后端是数组传到前端之后却变成了字符串类型…使用split方法切分字符串生成数组。
var linedate = '<%= date %>';
var linecount = '<%= count %>';
var ldate = linedate.split(",");
var lcount = linecount.split(",");
var chartDom = document.getElementById('main');
var myChart = echarts.init(chartDom);
var option;
option = {
title: {
text: 'Time Heat Analysis'
},
xAxis: {
type: 'category',
data: ldate
},
yAxis: {
type: 'value'
},
series: [{
data: lcount,
type: 'line'
}]
};
option && myChart.setOption(option);
任务3中时间热度与来源分析渲染
主要参考官方API
同任务2,传到前端的数据还是要做些处理
代码挺长的就不贴了
结果展示
Navicat中展示爬取的数据
只要程序运行,数据量可以不断变大,目前大概有上千条
图片见作业压缩包中pdf文件
访问’localhost:5000/search’
左侧是导航栏,点击跳转
向表单中输入查询词
如只在content中输入某个特定的词,会返回内容中带有该词的全部新闻
(图片请见作业压缩包中pdf文件)
访问’localhost:5000/time’
时间热度分析
content中输入某个特定的词,提交
当数据多了之后,时间跨度变长,可以使用每一周或每一月作为横轴的一个标度来进行可视化。
由于我开始爬取数据的时间比较晚,可能从4月26日左右才开始,所以前面几日新闻的总量就少,所以图中显示前面几日的新闻少,可能是因为新闻总量少,不一定是词的热度就很低。
另外对于时间热度来说,感觉应该在每一日有相同新闻总量的条件下,每日之间的新闻量才具可比性,进行这样的时间热度分析才更有意义,这是一个可优化的地方。
访问’localhost:5000/time_source’
content中搜索某词
饼图反映了新闻在三个新闻来源的比例,下面的折线图反应每一种新闻来源中的新闻随时间的变化量。
该图有一定的交互性,比如在折线图中移动时间,饼图就会随之变动:
注:以上搜索都可以对多个查询条件进行复合查询
总结
项目完成了对于三个新闻网站的爬虫,并用Express框架实现了前后端。通过这个项目我对javascript语言与前端Web开发都有了进一步的熟悉。
对于爬虫部分,重要的是要分析好网页,学会jQuery的语法解析网页,并想清楚程序的请求逻辑,要注意判空操作。
对于网站前后端部分,大概有如下几点体会:
- 数据库:好好利用sql语句进行数据查询与整理,数据库能做的就不要交给代码
- Express框架:要了解其路由、重定向、中间件与模板渲染等技术与概念
- 前端:尝试使用pug模板,对程序员比较友好;尝试使用echarts进行可视化,要多参考官方的API
完成这个项目学到了很多,也遇到了很多奇奇怪怪的问题,而且整个开发速度有些慢,归根到底还是我对javascript与node.js不够熟悉。对这些技术点的学习确实还有一段长路要走。