——————————————————————
大量图片转存失败,老师请主要查看我提交的pdf~
目录
基本功能实现四:用Echarts或者D3实现3个以上的数据分析图表展示在网站中
基本功能实现五:实现一个管理端界面,可以查看(查看用户的操作记录)和管理(停用启用)注册用户
项目要求
基本要求
1、用户可注册登录网站,非注册用户不可登录查看数据 2、用户注册、登录、查询等操作记入数据库中的日志 3、爬虫数据查询结果列表支持分页和排序 4、用Echarts或者D3实现3个以上的数据分析图表展示在网站中 5、实现一个管理端界面,可以查看(查看用户的操作记录)和管理(停用启用)注册用户。
扩展要求
1、实现对爬虫数据中文分词的查询 2、实现查询结果按照主题词打分的排序 3、用Elastic Search+Kibana展示爬虫的数据结果
项目实现
爬虫基础
我们在第一项目的基础之上进行爬虫
Step 1:目标网站分析
这次我选择爬取的网站是中国科技网,中国艺术网和中国地产网。
本文以中国科技网为例进行分析:http://tech.china.com.cn/
可以看到,中国科技网的门户如图所示,我们需要先纵观这个网站的结构,可以看到我们需要爬取的结构。
首先我们应该观察网站URL和每一篇文章URL的关系:
可以看到,每一个文章的URL其实就是网站URL+类似/phone/20210428/376772.html这样一个结构。
因此我们就找到了第一个关键点:确保解析目标网站的子网站,也就是我们需要爬取的文章。
这个是我写的关于url的正则表达式,可以看到和网站上的是一一对应的。
接下来,我们需要观察网页的html结构,看看我们需要的内容在哪里获得。
可以看到我们需要一些信息,在meta元信息下面可以唯一定位,所以我们在我们的代码里面写上对应的表达式:
此外我们也应该注意到一个问题,就是全文的内容并不是一个meta信息,因此我们怎么找到全文的信息呢,我自己的方法是这样的:
打开开发者模式,移动鼠标找到左侧对应全文的模块,这个时候左上角Chrome浏览器会自动跳出对应的应用id,如图:
我们就拿到了这个全文的id,将数据库的内容存储进去。
Step 2:设计数据库结构
很多人觉得应该开始写爬虫代码了,但其实在一个开发流程中,数据库的设计一定优先于代码的编写,好的code design可以节省大量debug的时间,因此这里我们应该先进行数据库的设计。
我们的数据库结构一共有10个column:
其中id_fetches是primary key全局唯一,同时在我们的后台确保url唯一,content作为longtext,为了加快查询,我在作者和标题的field使用了普通索引,在content的field使用了全文索引加快搜索。
Step 3:爬虫结构设计
引用的包:
引用包的作用 Request 发送HTTP请求,让服务器获得对应的请求 Cheerio 为服务器实现的JQuery核心快速,爬虫工具包 iconv-lite 对response请求转码 data-utils 提供JavaScript缺少的函数集合
node-schedule 定时调度任务工具库
下面我们分析一下一个爬虫大体逻辑:
-
设置获取参数的格式,与网页html相对应
-
为爬虫的request请求设置header,防止被网站403 forbidden
-
使用request模块异步的fetch url
-
利用node-schedule设置定时任务,不停地爬取文章
-
解析爬取的html,将unique url放入数据库 所以我们按图索骥。
(1) 设置获取参数的格式,与网页html相对应
(2) 为爬虫的request请求设置header,防止被网站403 forbidden
这里如果依然被forbidden,可以加入多个agent逃过网站的屏蔽
(3) 使用request模块异步的fetch url
(4) 利用node-schedule设置定时任务,不停地爬取文章
(5) 解析爬取的html,将unique url放入数据库 判断是否有重复URL
基本功能实现一:登陆注册
首先我们配置数据库,在原来的表结构上建立两个SQL表
用户表:
CREATE TABLE `crawl`.`user` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `username` VARCHAR(45) NOT NULL, `password` VARCHAR(45) NOT NULL, `registertime` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `username_UNIQUE` (`username`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;
用户行动表:
CREATE TABLE `crawl`.`user_action` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `username` VARCHAR(45) NOT NULL, `request_time` VARCHAR(45) NOT NULL, `request_method` VARCHAR(20) NOT NULL, `request_url` VARCHAR(300) NOT NULL, `status` int(4), `remote_addr` VARCHAR(100) NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;
打开navicat查看后台情况:
可以看到,两个表建立完成。
数据库连接设置:
module.exports = { mysql: { host: 'localhost', user: 'root', password: 'WAMM0609dd', database:'crawl', // 最大连接数,默认为10 connectionLimit: 10 } };
登录
在登陆时我们需要查看是否匹配后台的数据库密码,如果不一致需要返回错误,如果正确需要返回news.html
后台代码:
router.post('/login', function(req, res) { var username = req.body.username; var password = req.body.password; // var sess = req.session; userDAO.getByUsername(username, function (user) { if(user.length==0){ res.json({msg:'用户不存在!请检查后输入'}); }else { if(password===user[0].password){ req.session['username'] = username; res.cookie('username', username); res.json({msg: 'ok'}); // res.json({msg:'ok'}); }else{ res.json({msg:'用户名或密码错误!请检查后输入'}); } } }); });
前台js代码:
var app = angular.module('login', []); app.controller('loginCtrl', function ($scope, $http, $timeout) { // 登录时,检查用户输入的账户密码是否与数据库中的一致 $scope.check_pwd = function () { var data = JSON.stringify({ username: $scope.username, password: $scope.password }); $http.post("/users/login", data) .then( function (res) { if(res.data.msg=='ok') { window.location.href='/news.html'; }else{ $scope.msg=res.data.msg; } }, function (err) { $scope.msg = err.data; }); };
前台界面:
登录成功即可到达我们的主界面:
注册
对于注册功能,需要注意判断输入的用户名没有已注册过且两次输入密码一致,只有在用户名不存在且两次密码一致的条件下才返回注册成功。
前台:
$(document).on('click','#submit',function (data) { var username = $('#username').val(); var password = $('#password').val(); var check=$('#check').val(); if(username===''){ layer.alert("请输入用户名", {icon: 3}); }else if(password===''){ layer.alert("请输入密码", {icon: 3}); }else if(check===''){ layer.alert("请输入确认密码", {icon: 3}); }else if(password!==check){ layer.alert("两次密码不一致", {icon: 5}); }else { $.ajax({ url:"/mysql/register", type:"post", async: false, data:{ "username":username, "password":password }, dataType:"json", statusCode:{ 403:function(){ layer.alert('用户名已被占用', {icon: 2}); }, 200:function () { log(username,2,location.href); layer.alert('注册成功', {icon: 1}, function () { location.href='./login.html'; }); } } }) } })
后台:
/* add users */ router.post('/register', function (req, res) { var add_user = req.body; // 先检查用户是否存在 userDAO.getByUsername(add_user.username, function (user) { if (user.length != 0) { // res.render('index', {msg:'用户不存在!'}); res.json({msg: '用户已存在!'}); }else { userDAO.add(add_user, function (success) { res.json({msg: '成功注册!请登录'}); }) } });
前台界面:
注册之后查看后台数据库:
插入成功,可以登录。
至此,基本的登陆注册功能都已实现。
基本功能实现二:记录用户操作的日志
构建user_action数据库用于存储相应的操作,对于用户的行为来说,分为注册、登录、查询三类操作,以一个枚举型变量来记录,三种操作对应于op取值为1、2、3。
后台代码:
router.post('/log', function (req, res, next) { var username = req.param('username'); var op = req.param('op'); var url = req.param('url'); var sql = "insert into crawl.user_action values (null,'"+username+"',now(),'"+url+"',"+op+");"; console.log(sql); db.commit(sql); res.writeHead(200, { "Content-Type": "application/json" }); res.end(); });
打开数据看,我们刚刚注册的用户记录到表中了
基本功能实现三:爬虫数据查询结果列表支持分页和排序
这点上我使用的是layui的table,layui对后台传来的数据可以自适应的分类,此外结合我们在第一次完成的实验。
如果总页数低于页码总数:
如果总页数高于页码总数:
实现代码:
layui.use('element',function () { var element=layui.element; }); layui.use('table', function(){ var table = layui.table; //第一个实例 table.render({ elem: '#demo' ,size: 'lg' ,height: 'full-270' ,url: 'http://localhost:3000/mysql/search' //数据接口 ,cols: [[ //表头 {field: 'index', type: 'numbers'} ,{field: 'id_fetches', type: 'id_fetches',width:80,hide:true} ,{field: 'url', title: '链接', width:200} ,{field: 'source_name', title: '来源', width:200} ,{field: 'source_encoding', title: 'source_encoding', width:80,hide:true} ,{field: 'title', title: '标题', width:500} ,{field: 'keywords', title: '关键词', width: 200} ,{field: 'author', title: 'author', width: 80,hide:true} ,{field: 'publish_date', title: 'publish_date', width: 80,hide:true} ,{field: 'crawltime', title: 'crawltime', width: 80,hide:true} ,{field: 'content', title: 'content', width: 80,hide:true} ,{field: 'createtime', title: '发布时间', width: 200,sort:true} ]] ,page: true ,limits: [3,5,10] //一页选择显示3,5或10条数据 ,limit: 10 //一页显示10条数据 ,parseData: function(res){ //将原始数据解析成 table 组件所规定的数据,res为从url中get到的数据 var result; console.log(this); console.log(JSON.stringify(res)); if(this.page.curr){ result = res.data.slice(this.limit*(this.page.curr-1),this.limit*this.page.curr); } else{ result=res.data.slice(0,this.limit); } return { "code": res.code, //解析接口状态 "msg": res.msg, //解析提示文本 "count": res.count, //解析数据长度 "data": result //解析数据列表 }; } }); $(document).on('click','#submit',function (data) { var bt=$('#bt').val(); var gjc=$('#gjc').val(); var zz=$('#zz').val(); var rdfx=$('#rdfx').val(); table.reload('demo',{ where:{ bt:bt, gjc:gjc, zz:zz, rdfx:rdfx }, page:{ curr:1 } }) }) });
基本功能实现四:用Echarts或者D3实现3个以上的数据分析图表展示在网站中
我使用Echarts+Jquery库进行美化,得到了如下三个图:
前台代码
layui.config({ version: 1, base: './javascripts/' }).use(['layer','element', 'echarts'], function() {}); var element = layui.element, $ = layui.jquery, echarts = layui.echarts; function generateChart(data) { var myChart = echarts.init(document.getElementById('EchartZhu')); myChart.clear(); var optionchart = { title: { text: '每日发布的新闻数' }, tooltip: {}, legend: { data: ['数量/条'] }, xAxis: { data: data.map(function (obj) { return obj.publish_date.substr(6,4) }) }, yAxis: { type: 'value' }, series: [{ name: '数量/条', type: 'bar', //柱状 data: data.map(function (obj) { return obj.number }), itemStyle: { normal: { //柱子颜色 color: '#009688' } }, }] }; myChart.setOption(optionchart, true); } $.ajax({ url:"/mysql/zhu", type:"get", async: false, data:{ }, dataType:"json", statusCode:{ 200:function (msg) { console.log(msg); console.log(msg.map(function (obj) { return obj.publish_date.substr(6,4); })); generateChart(msg); } } });
后台代码:
router.get('/histogram', function(request, response) { //sql字符串和参数 console.log(request.session['username']); //sql字符串和参数 if (request.session['username']===undefined) { // response.redirect('/index.html') response.json({message:'url',result:'/index.html'}); }else { var fetchSql = "select publish_date as x,count(publish_date) as y from fetches group by publish_date order by publish_date;"; newsDAO.query_noparam(fetchSql, function (err, result, fields) { response.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": 0 }); response.write(JSON.stringify({message:'data',result:result})); response.end(); }); } }); router.get('/pie', function(request, response) { //sql字符串和参数 console.log(request.session['username']); //sql字符串和参数 if (request.session['username']===undefined) { // response.redirect('/index.html') response.json({message:'url',result:'/index.html'}); }else { var fetchSql = "select author as x,count(author) as y from fetches group by author;"; newsDAO.query_noparam(fetchSql, function (err, result, fields) { response.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": 0 }); response.write(JSON.stringify({message:'data',result:result})); response.end(); }); } }); router.get('/line', function(request, response) { //sql字符串和参数 console.log(request.session['username']); //sql字符串和参数 if (request.session['username']===undefined) { // response.redirect('/index.html') response.json({message:'url',result:'/index.html'}); }else { var keyword = '疫情'; //也可以改进,接受前端提交传入的搜索词 var fetchSql = "select content,publish_date from fetches where content like'%" + keyword + "%' order by publish_date;"; newsDAO.query_noparam(fetchSql, function (err, result, fields) { response.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": 0 }); response.write(JSON.stringify({message:'data',result:myfreqchangeModule.freqchange(result, keyword)})); response.end(); }); } });
曲线图
柱状图
pie图
这里为了实现将爬取的新闻按照新闻发布来源统计形成对应的饼状图,需要实现的对应查询语句为:select source_name as name , count(*) as value from crawl.fetches where source_name != '' group by source_name;
基本功能实现五:实现一个管理端界面,可以查看(查看用户的操作记录)和管理(停用启用)注册用户
这里我继续使用layui的表格进行管理,但是没引用之前美化的JQuery。
前台代码:
layui.use('table', function(){ var table = layui.table; //第一个实例 table.render({ elem: '#demo' ,size: 'lg' ,height: 'full-270' ,url: 'http://localhost:3000/mysql/action' //数据接口 ,cols: [[ //表头 {field: 'index', type: 'numbers'} ,{field: 'username', title: '用户名', width:80} ,{field: 'password', title: '密码', width:80} ,{field: 'registertime', title: '注册时间', width:250} ,{field: 'stat', title: '状态', width:200} ,{field: 'request_time', title: '请求时间', width:250} ,{field: 'request_url', title: '请求地址', width:200} ,{field: 'name', title: '请求操作', width:80} ,{field: '管理', title: '管理', toolbar: '#barDemo'} ]] ,page: true ,limits: [3,5,10] //一页选择显示3,5或10条数据 ,limit: 10 //一页显示10条数据 ,parseData: function(res){ //将原始数据解析成 table 组件所规定的数据,res为从url中get到的数据 var result; console.log(this); console.log(JSON.stringify(res)); if(this.page.curr){ result = res.data.slice(this.limit*(this.page.curr-1),this.limit*this.page.curr); } else{ result=res.data.slice(0,this.limit); } return { "code": res.code, //解析接口状态 "msg": res.msg, //解析提示文本 "count": res.count, //解析数据长度 "data": result //解析数据列表 }; } }); $(document).on('click','#submit',function (data) { var username=$('#username').val(); table.reload('demo',{ where:{ username:username, }, page:{ curr:1 } }) }); table.on('tool(test)',function (obj) { var tr=obj.data; console.log(tr); var event = obj.event; if(event==='off'){ $.ajax({ url:"/mysql/off", type:"put", async: false, data:{ "username":tr.username }, dataType:"json", // success:function (msg) { // console.log(msg); // alert("注册成功"); // // window.location.href="index.html"; // }, // error:function (msg) { // console.log(msg); // }, statusCode:{ 200:function () { layer.alert('修改成功', {icon: 1}); } } }) }else if(event==='on'){ $.ajax({ url:"/mysql/on", type:"put", async: false, data:{ "username":tr.username }, dataType:"json", statusCode:{ 403:function(){ layer.alert('修改失败', {icon: 2}); }, 200:function () { layer.alert('修改成功', {icon: 1}); } } }) } }) });
后台代码:
router.put('/off', function (req, res, next) { var username = req.param('username'); var sql = "update user set state = 1 where username = '"+username+"';"; console.log(sql); db.commit(sql); res.writeHead(200, { "Content-Type": "application/json" }); res.end(); }); router.put('/on', function (req, res, next) { var username = req.param('username'); var sql = "update user set state = 0 where username = '"+username+"';"; console.log(sql); db.commit(sql); res.writeHead(200, { "Content-Type": "application/json" }); res.end(); });
至此基本功能实现结束。
拓展功能实现一:实现对爬虫数据中文分词的查询
这里我们使用nodejieba库来进行分词,因为jieba库是优秀的中文分词第三方库
-
中文文本需要通过分词获得单个的词语
-
jieba是优秀的中文分词第三方库,需要额外安装
-
jieba库提供三种分词模式,最简单只需掌握一个函数
实现也比较简单,一行代码即可:
var strings = nodejieba.cut(bt);
比如我们搜索四川的科技关键词
呃 这个组合好像有点奇怪,但是我们也成功分词,找到了一条信息:
拓展功能实现二:实现查询结果按照主题词打分的排序
这里我使用的是上学期黄老师讲的TF-IDF来进行分词。
TF-IDF(Term Frequency-Inverse Document Frequency, 词频-逆文件频率)是一种用于资讯检索与资讯探勘的常用加权技术。TF-IDF是一种统计方法,用以评估一字词对于一个文件集或一个语料库中的其中一份文件的重要程度。字词的重要性随着它在文件中出现的次数成正比增加,但同时会随着它在语料库中出现的频率成反比下降。
上述引用总结就是, 一个词语在一篇文章中出现次数越多, 同时在所有文档中出现次数越少, 越能够代表该文章。这也就是TF-IDF的含义。
TF-IDF分为 TF 和 IDF,下面分别介绍这个两个概念。
TF
TF(Term Frequency, 词频)表示词条在文本中出现的频率,这个数字通常会被归一化(一般是词频除以文章总词数), 以防止它偏向长的文件(同一个词语在长文件里可能会比短文件有更高的词频,而不管该词语重要与否)。TF用公式表示如下
其中, 表示词条 在文档 中出现的次数, 就是表示词条 在文档 中出现的频率。
但是,需要注意, 一些通用的词语对于主题并没有太大的作用, 反倒是一些出现频率较少的词才能够表达文章的主题, 所以单纯使用是TF不合适的。权重的设计必须满足:一个词预测主题的能力越强,权重越大,反之,权重越小。所有统计的文章中,一些词只是在其中很少几篇文章中出现,那么这样的词对文章的主题的作用很大,这些词的权重应该设计的较大。IDF就是在完成这样的工作。
IDF
IDF(Inverse Document Frequency, 逆文件频率)表示关键词的普遍程度。如果包含词条 的文档越少, IDF越大,则说明该词条具有很好的类别区分能力。某一特定词语的IDF,可以由总文件数目除以包含该词语之文件的数目,再将得到的商取对数得到
其中, 表示所有文档的数量, 表示包含词条 的文档数量,为什么这里要加 1 呢?主要是防止包含词条 的数量为 0 从而导致运算出错的现象发生。
某一特定文件内的高词语频率,以及该词语在整个文件集合中的低文件频率,可以产生出高权重的TF-IDF。因此,TF-IDF倾向于过滤掉常见的词语,保留重要的词语,表达为
这里我调用之前写过的python脚本:
# -*- coding: utf-8 -*- # @Time : 2021/6/15 15:58 # @Author : Chunyuan Deng # @Description : import codecs import numpy as np import jieba.posseg as pseg def load_stopwords(path): return set([line.strip() for line in open(path, "r", encoding="utf-8").readlines() if line.strip()]) stopwords = load_stopwords(path='stopwords.txt') def string_hash(source): if not source: return 0 x = ord(source[0]) << 7 m = 1000003 mask = 2 ** 128 - 1 for c in source: x = ((x * m) ^ ord(c)) & mask x ^= len(source) if x == -1: x = -2 x = bin(x).replace('0b', '').zfill(64)[-64:] return str(x) def load_idf(path): words_idf = dict() with codecs.open(path, 'r', encoding='utf-8') as f: lines = f.readlines() for line in lines: parts = line.strip().split('t') if len(parts) != 2: continue if parts[0] not in words_idf: words_idf[parts[0]] = float(parts[1]) return words_idf words_idf = load_idf(path=r'idf.txt') def compute_tfidf(text): words_freq = dict() words = pseg.lcut(text) for w in words: if w.word in stopwords: continue if w.word not in words_freq: words_freq[w.word] = 1 else: words_freq[w.word] += 1 text_total_words = sum(list(words_freq.values())) words_tfidf = dict() for word, freq in words_freq.items(): if word not in words_idf: continue else: tfidf = words_idf[word] * (freq / text_total_words) words_tfidf[word] = tfidf return words_tfidf def get_keywords(text, topk): words_tfidf = compute_tfidf(text) words_tfidf_sorted = sorted(words_tfidf.items(), key=lambda x: x[1], reverse=True) return [item[0] for item in words_tfidf_sorted[:topk]] def hamming_distance(simhash1, simhash2): ham = [s1 == s2 for (s1, s2) in zip(simhash1, simhash2)] return ham.count(False) def text_simhash(text): total_sum = np.array([0 for _ in range(64)]) keywords = get_keywords(text, topk=2) for keyword in keywords: v = int(words_idf[keyword]) hash_code = string_hash(keyword) decode_vec = [v if hc == '1' else -v for hc in hash_code] total_sum += np.array(decode_vec) simhash_code = [1 if t > 0 else 0 for t in total_sum] return simhash_code def simhash_similarity(text1, text2): simhash_code1 = text_simhash(text1) simhash_code2 = text_simhash(text2) print(simhash_code1, simhash_code2) return hamming_distance(simhash_code1, simhash_code2) if __name__ == '__main__': print(simhash_similarity('在历史上有著许多数学发现', '在历史上有著许多科学发现'))
我们搜索科技,可以看到与科技最相关的关键词被标注出来:
总结
本次期末实验我做的部分就是这么多了,因为时间的限制和知识面的限制,试了很久依然未能完成拓展的第三个功能,但是在前几个功能的实现上还是花费了很多心力,对nodejs运行的过程,与前台的交互,以及基本的画图功能都有了不错的了解。
这就是我的期末项目啦,谢谢王老师的教学和助教老师细致的指导,感谢阅读~