网络编程篇

网络编程篇,讲述4种跨域手段,及简单的爬虫操作

跨域

什么是跨域

跨域是指一个域下的文档或脚本试图去请求另一个域下的资源,这里跨域是广义的。

1.) 资源跳转: A链接、重定向、表单提交
2.) 资源嵌入: <link>、<script>、<img>、<frame>等dom标签,还有样式中background:url()、@font-face()等文件外链
3.) 脚本请求: js发起的ajax请求、dom和js对象的跨域操作等

其实我们通常所说的跨域是狭义的,是由浏览器同源策略限制的一类请求场景。

什么是同源策略?

同源策略/SOP(Same origin policy)是一种约定,由Netscape公司1995年引入浏览器,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,浏览器很容易受到XSS、CSFR等攻击。所谓同源是指”协议+域名+端口“三者相同,即便两个不同的域名指向同一个ip地址,也非同源。

1.) Cookie、LocalStorage 和 IndexDB 无法读取
2.) DOM 和 Js对象无法获得
3.) AJAX 请求不能发送

常见跨域场景

URL                                      说明                    是否允许通信
http://www.domain.com/a.js
http://www.domain.com/b.js         同一域名,不同文件或路径           允许
http://www.domain.com/lab/c.js

http://www.domain.com:8000/a.js
http://www.domain.com:8080/b.js         同一域名,不同端口           不允许

http://www.domain.com/a.js
https://www.domain.com/b.js        同一域名,不同协议                不允许

http://www.domain.com/a.js
http://192.168.4.12/b.js           域名和域名对应相同ip              不允许

http://www.domain.com/a.js
http://x.domain.com/b.js           主域相同,子域不同                不允许
http://domain.com/c.js

http://www.domain1.com/a.js
http://www.domain2.com/b.js        不同域名                         不允许


http://localhost:8080 
proxy

http://localhost:5000

只要是端口号之前的有一个不一样属于跨域行为

image-20200225142154033

演示跨域

server.js

const http = require('http');
const fs = require('fs');
http.createServer((req, res) => {
    const { url, method } = req;
    if (url === '/' && method === 'GET') {
        // 读取首页
        fs.readFile('./index.html', (err, data) => {
            if (err) {
                res.statusCode = 500;//服务器内部错误
                res.end('500- Interval Serval Error!');
            }
            res.statusCode = 200;//设置状态码
            res.setHeader('Content-Type', 'text/html');
            res.end(data);
        })
    } else if (url === '/user' && method === 'GET') {
        res.statusCode = 200;//设置状态码
        res.setHeader('Content-Type', 'application/json');
        res.end(JSON.stringify([{ name: "小马哥" }]));
    }

}).listen(3000);

axios发起请求

axios.get('http://127.0.0.1:3000/user').then(res => {
    console.log(res.data);

}).catch(err => {
    console.log(err);

})

跨域常用解决方案

  1. 通过jsonp跨域(只接受get请求)
  2. 跨域资源共享(CORS 最常用)(在后端操作)
  3. nginx代理跨域
  4. nodejs中间件代理跨域 (react项目中 proxy http-proxy-middleware)

通过JSONP跨域

通常为了减轻web服务器的负载,我们把js、css,img等静态资源分离到另一台独立域名的服务器上,在html页面中再通过相应的标签从不同域名下加载静态资源,而被浏览器允许,基于此原理,我们可以通过动态创建script,再请求一个带参网址实现跨域通信

axios最新版本已经不支持jsonp方法了,不想因为一个jsonp请求就又去引一个依赖,所以决定自己封装一下

axios.jsonp = (url) => {
    if (!url) {
        console.error('Axios.JSONP 至少需要一个url参数!')
        return;
    }
    return new Promise((resolve, reject) => {
        window.jsonCallBack = (result) => {
            resolve(result)
        }
        var JSONP = document.createElement("script");
        JSONP.type = "text/javascript";
        JSONP.src = `${url}callback=jsonCallBack`;
        document.getElementsByTagName("head")[0].appendChild(JSONP);
        setTimeout(() => {
            document.getElementsByTagName("head")[0].removeChild(JSONP)
        }, 500)
    })
}

// 第一种 通过jsonp
axios.jsonp('http://127.0.0.1:3000/user?')
    .then(res=>{
    console.log(res);
}).catch(err=>{
    console.log(err);

})

server.js

const express = require('express');
const fs = require('fs');
const app = express();
// 中间件方法
// 设置node_modules为静态资源目录
// 将来在模板中如果使用了src属性 http://localhost:3000/node_modules
app.use(express.static('node_modules'))
app.get('/',(req,res)=>{
  fs.readFile('./index.html',(err,data)=>{
    if(err){
      res.statusCode = 500;
      res.end('500 Interval Serval Error!');
    }
    res.statusCode = 200;
    res.setHeader('Content-Type','text/html');
    res.end(data);
  })
})
// app.set('jsonp callback name', 'cb')
app.get('/api/user',(req,res)=>{
  console.log(req);
  // http://127.0.0.1:3000/user?cb=jsonCallBack
  // const cb = req.query.cb;
  // cb({})
  // res.end(`${cb}(${JSON.stringify({name:"小马哥"})})`)
  res.jsonp({name:'小马哥'})
})
app.listen(3000);

jsonp缺点:只能实现get一种请求。

Nodejs中间件代理跨域

实现原理:同源策略是浏览器需要遵循的标准,而如果是服务器向服务器请求就无需遵循同源策略。 代理服务器,需要做以下几个步骤:

  • 接受客户端请求 。
  • 将请求 转发给服务器。
  • 拿到服务器 响应 数据。
  • 将 响应 转发给客户端。

index.html文件,通过代理服务器http://127.0.0.1:8080向目标服务器http://localhost:3000/user请求数据

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>

<body>
  <script src='/axios/dist/axios.js'></script>
  <h2>中间件代理跨域</h2>
  <script>
    axios.defaults.baseURL = 'http://localhost:8080';    
    axios.get('/user')
      .then(res => {
        console.log(res);

      })
      .catch(err => {
        console.log(err);

      }) 
  </script>

</body>

</html>

proxyServer.js代理服务器

const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const app = express();

// 代理服务器操作
//设置允许跨域访问该服务.
app.all('*', function (req, res, next) {
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Headers', 'Content-Type');
  res.header('Access-Control-Allow-Methods', '*');
  res.header('Content-Type', 'application/json;charset=utf-8');
  next();
});

// http-proxy-middleware
// 中间件 每个请求来之后 都会转发到 http://localhost:3001 后端服务器
app.use('/', createProxyMiddleware({ target: 'http://localhost:3001', changeOrigin: true }));

app.listen(8080);

业务服务器server.js

const express = require('express');
const fs = require('fs');
const app = express();
// 中间件方法
// 设置node_modules为静态资源目录
// 将来在模板中如果使用了src属性 http://localhost:3000/node_modules
app.use(express.static('node_modules'))
app.get('/',(req,res)=>{
  fs.readFile('./index.html',(err,data)=>{
    if(err){
      res.statusCode = 500;
      res.end('500 Interval Serval Error!');
    }
    res.statusCode = 200;
    res.setHeader('Content-Type','text/html');
    res.end(data);
  })
})
// app.set('jsonp callback name', 'cb')

app.get('/user',(req,res)=>{
  res.json({name:'小马哥'})
})
app.listen(3001);

通过CORS跨域

//设置允许跨域访问该服务.
app.all('*', function (req, res, next) {
    /// 允许跨域访问的域名:若有端口需写全(协议+域名+端口),若没有端口末尾不用加'/'
    res.header('Access-Control-Allow-Origin', '*');
    res.header('Access-Control-Allow-Headers', 'Content-Type');
    res.header('Access-Control-Allow-Methods', '*');
    res.header('Content-Type', 'application/json;charset=utf-8');
    next();
});

具体实现:

  • 响应简单请求:动词为get/post/head,如果没有自定义请求头,Content-Typeapplication/x-www-form-urlencoded,multipar/form-datatext/plagin之一,通过添加以下解决

     res.header('Access-Control-Allow-Origin', '*');
  • 响应prefight请求,需要响应浏览器发出的options请求(预检请求),并根据情况设置响应头

    //设置允许跨域访问该服务.
    app.all('*', function (req, res, next) {
        res.header('Access-Control-Allow-Origin', 'http://localhost:3002');
        //允许令牌通过
        res.header('Access-Control-Allow-Headers', 'Content-Type,X-Token');
        res.header('Access-Control-Allow-Methods', 'GET,POST,PUT');
        //允许携带cookie
        res.header('Access-Control-Allow-Credentials', 'true');
        res.header('Content-Type', 'application/json;charset=utf-8');
        next();
    });

    前端

    在这里给大家补充了axios相关用法,具体的看视频

    <!DOCTYPE html>
    <html lang="en">
    
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <meta http-equiv="X-UA-Compatible" content="ie=edge">
      <title>Document</title>
    </head>
    
    <body>
      <script src='/axios/dist/axios.js'></script>
      <script src="https://cdn.bootcss.com/qs/6.9.1/qs.js"></script>
      <h2>CORS跨域</h2>
      <script>
        axios.defaults.baseURL = 'http://127.0.0.1:3002';
        axios.defaults.headers.common['Authorization'] = 'xaxadadadadad';
        axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded';
    
        axios.interceptors.request.use(function (config) {
          let data = config.data;
          data = Qs.stringify(data);
          config.data = data;
          // 在发送请求之前做些什么
          return config;
        }, function (error) {
          // 对请求错误做些什么
          return Promise.reject(error);
        });
        axios.post('/login', {
            username: 'xiaomage',
            password: 123
          }, {
            // headers: {
            //   'Authorization': 'adjahdj1313131'
            // },
            // 表示跨域请求时需要使用凭证 允许携带cookies
            withCredentials: true
          })
          .then(res => {
            console.log(res);
          }).catch(err => {
            console.log(err);
    
          })
      </script>
    
    </body>
    
    </html>

使用第三方插件cors

npm i cors -S

server.js

const cors = require('cors');
//简单使用
app.use(cors())

配置选项参考链接

ex:

app.use(cors({
  origin:'http://localhost:3002', //设置原始地址
  credentials:true, //允许携带cookie
  methods:['GET','POST'], //跨域允许的请求方式
  allowedHeaders:'Content-Type,Authorization' //允许请求头的携带信息
}))

nginx反向代理

实现原理类似于Node中间件代理,需要你搭建一个中转nginx服务器,用于转发请求。

使用nginx反向代理实现跨域,是最简单的跨域方式。只需要修改nginx的配置即可解决跨域问题,支持所有浏览器,支持session,不需要修改任何代码,并且不会影响服务器性能。

实现思路:通过nginx配置一个代理服务器(域名与domain1相同,端口不同)做跳板机,反向代理访问domain2接口,并且可以顺便修改cookie中domain信息,方便当前域cookie写入,实现跨域登录。

// proxy服务器
server {
    listen       81;
    server_name  www.baidu.com;
    location / {
        proxy_pass   http://www.bbbb.com:8080;  #反向代理
        proxy_cookie_domain www.domain2.com www.domain1.com; #修改cookie里域名
        index  index.html index.htm;
        # 当用webpack-dev-server等中间件代理接口访问nignx时,此时无浏览器参与,故没有同源限制,下面的跨域配置可不启用
        add_header Access-Control-Allow-Origin http://www.bbbb.com;  #当前端只跨域不带cookie时,可为*
        add_header Access-Control-Allow-Credentials true;
    }
}

爬虫

什么是爬虫

网络爬虫(又被称为网页蜘蛛,网络机器人,在FOAF社区中间,更经常的称为网页追逐者),是一种按照一定的规则,自动地抓取万维网信息的程序或者脚本。另外一些不常使用的名字还有蚂蚁、自动索引、模拟程序或者蠕虫。

爬取网页中的有效数据

爬虫的分类

  • 通用网络爬虫(全网爬虫)

    • 爬行对象从一些 种子URL 扩充到整个 Web,主要为门户站点搜索引擎和大型 Web 服务提供商采集数据。
  • 聚焦网络爬虫(主题网络爬虫)

    • 指选择性 地爬行那些与预先定义好的主题相关页面的网络爬虫。
  • 增量式网络爬虫

    • 指对已下载网页采取增量式更新和 只爬行新产生的或者已经发生变化网页 的爬虫,它能够在一定程度上保证所爬行的页面是尽可能新的页面。
  • Deep Web爬虫

    • 爬行对象是一些在用户填入关键字搜索或登录后才能访问到的深层网页信息的爬虫。

爬虫的爬行策略

  • 通用网络爬虫(全网爬虫)

    • 深度优先策略、广度优先策略
  • 聚焦网络爬虫(主题网络爬虫)

    • 基于内容评价的爬行策略(内容相关性),基于链接结构评价的爬行策略、基于增强学习的爬行策略(链接重要性),基于语境图的爬行策略(距离,图论中两节点间边的权重)
  • 增量式网络爬虫

    • 统一更新法、个体更新法、基于分类的更新法、自适应调频更新法
  • Deep Web 爬虫

    • Deep Web 爬虫爬行过程中最重要部分就是表单填写,包含两种类型:基于领域知识的表单填写、基于网页结构分析的表单填写

现代的网页爬虫的行为通常是四种策略组合的结果:

选择策略:决定所要下载的页面; 重新访问策略:决定什么时候检查页面的更新变化; 平衡礼貌策略:指出怎样避免站点超载; 并行策略:指出怎么协同达到分布式抓取的效果;

简单的网页爬虫流程

  1. 确定爬取对象(网站/页面)
  2. 分析页面内容(目标数据/DOM结构)
  3. 确定开发语言、框架、工具等
  4. 编码测试,爬取数据
  5. 优化

实战:百度新闻爬虫

确定爬取对象(网站/页面)
分析页面内容(目标数据/DOM结构)
确定开发语言、框架、工具等
  • node.js(express) + vscode
编码
  • 安装依赖包

    npm i express superagent cheerio
    • express (使用express来搭建一个简单的Http服务器。当然,你也可以使用node中自带的http模块)
    • superagent (superagent是node里一个非常方便的、轻量的、渐进式的第三方客户端请求代理模块,用他来请求目标页面)
    • cheerio (cheerio相当于node版的jQuery,用过jQuery的同学会非常容易上手。它主要是用来获取抓取到的页面元素和其中的数据信息)
  1. 使用express启动简易的本地Http服务器

    //index.js
    const express = require('express');
    const app = express();
    
    app.get('/',(req,res)=>{
        res.send('hello world');
    })
    
    let server = app.listen(3000, function () {
      let host = server.address().address;
      let port = server.address().port;
      console.log('Your App is running at http://%s:%s', host, port);
    });
    
访问:http://localhost:3000/ 看到`hello world`页面
  1. 分析百度新闻首页的新闻信息
百度新闻首页大体上分为“热点新闻”、“本地新闻”、“国内新闻”、“国际新闻”......等。这次我们先来尝试抓取左侧“热点新闻”和下方的“本地新闻”两处的新闻数据。

F12打开Chrome的控制台,审查页面元素,经过查看左侧“热点新闻”信息所在DOM的结构,我们发现所有的“热点新闻”信息(包括新闻标题和新闻页面链接)都在id为#pane-news的<div>下面<ul>下<li>下的<a>标签中。用jQuery的选择器表示为:#pane-news ul li a。

  1. 为了爬取新闻数据,首先我们要用superagent请求目标页面,获取整个新闻首页信息
// 引入所需要的第三方包
const superagent= require('superagent');

let hotNews = [];                                // 热点新闻
let localNews = [];                              // 本地新闻

/**
 * index.js
 * [description] - 使用superagent.get()方法来访问百度新闻首页
 */
superagent.get('http://news.baidu.com/').end((err, res) => {
  if (err) {
    // 如果访问失败或者出错,会这行这里
    console.log(`热点新闻抓取失败 - ${err}`)
  } else {
   // 访问成功,请求http://news.baidu.com/页面所返回的数据会包含在res
   // 抓取热点新闻数据
   hotNews = getHotNews(res)
  }
});
  1. 获取页面信息后,我们来定义一个函数getHotNews()来抓取页面内的“热点新闻”数据。
/**
 * index.js
 * [description] - 抓取热点新闻页面
 */
// 引入所需要的第三方包
const cheerio = require('cheerio');

let getHotNews = (res) => {
  let hotNews = [];
  // 访问成功,请求http://news.baidu.com/页面所返回的数据会包含在res.text中。

  /* 使用cheerio模块的cherrio.load()方法,将HTMLdocument作为参数传入函数
     以后就可以使用类似jQuery的$(selectior)的方式来获取页面元素
   */
  let $ = cheerio.load(res.text);

  // 找到目标数据所在的页面元素,获取数据
  $('div#pane-news ul li a').each((idx, ele) => {
    // cherrio中$('selector').each()用来遍历所有匹配到的DOM元素
    // 参数idx是当前遍历的元素的索引,ele就是当前便利的DOM元素
    let news = {
      title: $(ele).text(),        // 获取新闻标题
      href: $(ele).attr('href')    // 获取新闻网页链接
    };
    hotNews.push(news)              // 存入最终结果数组
  });
  return hotNews
};
这里要多说几点:
  1. async/await据说是异步编程的终级解决方案,它可以让我们以同步的思维方式来进行异步编程。Promise解决了异步编程的“回调地狱”,async/await同时使异步流程控制变得友好而有清晰,有兴趣的同学可以去了解学习一下,真的很好用。
  2. superagent模块提供了很多比如get、post、delte等方法,可以很方便地进行Ajax请求操作。在请求结束后执行.end()回调函数。.end()接受一个函数作为参数,该函数又有两个参数error和res。当请求失败,error会包含返回的错误信息,请求成功,error值为null,返回的数据会包含在res参数中。
  3. cheerio模块的.load()方法,将HTML document作为参数传入函数,以后就可以使用类似jQuery的$(selectior)的方式来获取页面元素。同时可以使用类似于jQuery中的.each()来遍历元素。此外,还有很多方法,大家可以自行Google/Baidu
  1. 将抓取的数据返回给前端浏览器
/**
 * [description] - 跟路由
 */
// 当一个get请求 http://localhost:3000时,就会后面的async函数
app.get('/', async (req, res, next) => {
  res.send(hotNews);
});

OK!!这样,一个简单的百度“热点新闻”的爬虫就大功告成啦!!

简单总结一下,其实步骤很简单:

  1. express启动一个简单的Http服务
  2. 分析目标页面DOM结构,找到所要抓取的信息的相关DOM元素
  3. 使用superagent请求目标页面
  4. 使用cheerio获取页面元素,获取目标数据
  5. 返回数据到前端浏览器

现在,继续我们的目标,抓取“本地新闻”数据(编码过程中,我们会遇到一些有意思的问题) 有了前面的基础,我们自然而然的会想到利用和上面相同的方法“本地新闻”数据。

  1. 分析页面中“本地新闻”部分的DOM结构

F12打开控制台,审查“本地新闻”DOM元素,我们发现,“本地新闻”分为两个主要部分,“左侧新闻”和右侧的“新闻资讯”。这所有目标数据都在id#local_newsdiv中。“左侧新闻”数据又在id#localnews-focusul标签下的li标签下的a标签中,包括新闻标题和页面链接。“本地资讯”数据又在id#localnews-zixundiv下的ul标签下的li标签下的a标签中,包括新闻标题和页面链接。

  1. OK!分析了DOM结构,确定了数据的位置,接下来和爬取“热点新闻”一样,按部就班,定义一个getLocalNews()函数,爬取这些数据。
**
 * [description] - 抓取本地新闻页面
 */
let getLocalNews = (res) => {
  let localNews = [];
  let $ = cheerio.load(res);

  // 本地新闻
  $('ul#localnews-focus li a').each((idx, ele) => {
    let news = {
      title: $(ele).text(),
      href: $(ele).attr('href'),
    };
    localNews.push(news)
  });

  // 本地资讯
  $('div#localnews-zixun ul li a').each((index, item) => {
    let news = {
      title: $(item).text(),
      href: $(item).attr('href')
    };
    localNews.push(news);
  });

  return localNews
};

对应的,在superagent.get()中请求页面后,我们需要调用getLocalNews()函数,来爬去本地新闻数据。 superagent.get()函数修改为:

superagent.get('http://news.baidu.com/').end((err, res) => {
  if (err) {
    // 如果访问失败或者出错,会这行这里
    console.log(`热点新闻抓取失败 - ${err}`)
  } else {
   // 访问成功,请求http://news.baidu.com/页面所返回的数据会包含在res
   // 抓取热点新闻数据
   hotNews = getHotNews(res)
   localNews = getLocalNews(res)
  }
});

同时,我们要在app.get()路由中也要将数据返回给前端浏览器。app.get()路由代码修改为:

/**
 * [description] - 跟路由
 */
// 当一个get请求 http://localhost:3000时,就会后面的async函数
app.get('/', async (req, res, next) => {
  res.send({
    hotNews: hotNews,
    localNews: localNews
  });
});
编码完成,激动不已!!DOS中让项目跑起来,用浏览器访问http://localhost:3000

尴尬的事情发生了!!返回的数据只有热点新闻,而本地新闻返回一个空数组[ ]。检查代码,发现也没有问题,但为什么一直返回的空数组呢?

一个有意思的问题

为了找到原因,首先,我们看看用`superagent.get('http://news.baidu.com/').end((err, res) => {})`请求百度新闻首页在回调函数`.end()`中的第二个参数res中到底拿到了什么内容?
// 新定义一个全局变量 pageRes
let pageRes = {};        // supergaent页面返回值

// superagent.get()中将res存入pageRes
superagent.get('http://news.baidu.com/').end((err, res) => {
  if (err) {
    // 如果访问失败或者出错,会这行这里
    console.log(`热点新闻抓取失败 - ${err}`)
  } else {
   // 访问成功,请求http://news.baidu.com/页面所返回的数据会包含在res
   // 抓取热点新闻数据
   // hotNews = getHotNews(res)
   // localNews = getLocalNews(res)
   pageRes = res
  }
});

// 将pageRes返回给前端浏览器,便于查看
app.get('/', async (req, res, next) => {
  res.send({
    // {}hotNews: hotNews,
    // localNews: localNews,
    pageRes: pageRes
  });
});
  • 可以看到,返回值中的text字段应该就是整个页面的HTML代码的字符串格式。为了方便我们观察,可以直接把这个text字段值返回给前端浏览器,这样我们就能够清晰地看到经过浏览器渲染后的页面。

修改给前端浏览器的返回值

app.get('/', async (req, res, next) => {
  res.send(pageRes.text)
}
  • 审查元素才发现,原来我们抓取的目标数据所在的DOM元素中是空的,里面没有数据!

  • 到这里,一切水落石出!在我们使用superagent.get()访问百度新闻首页时,res中包含的获取的页面内容中,我们想要的“本地新闻”数据还没有生成,DOM节点元素是空的,所以出现前面的情况!抓取后返回的数据一直是空数组[ ]

  • 在控制台的Network中我们发现页面请求了一次这样的接口:

  • http://localhost:3000/widget?id=LocalNews&ajax=json&t=1526295667917,接口状态 404

  • 这应该就是百度新闻获取“本地新闻”的接口,到这里一切都明白了!“本地新闻”是在页面加载后动态请求上面这个接口获取的,所以我们用superagent.get()请求的页面再去请求这个接口时,接口URLhostname部分变成了本地IP地址,而本机上没有这个接口,所以404,请求不到数据。

找到原因,我们来想办法解决这个问题!!

使用第三方npm包,模拟浏览器访问百度新闻首页,在这个模拟浏览器中当“本地新闻”加载成功后,抓取数据,返回给前端浏览器。

使用Nightmare自动化测试工具

  • Electron可以让你使用纯JavaScript调用Chrome丰富的原生的接口来创造桌面应用。你可以把它看作一个专注于桌面应用的Node.js的变体,而不是Web服务器。其基于浏览器的应用方式可以极方便的做各种响应式的交互
  • Nightmare是一个基于Electron的框架,针对Web自动化测试和爬虫,因为其具有跟PlantomJS一样的自动化测试的功能可以在页面上模拟用户的行为触发一些异步数据加载,也可以跟Request库一样直接访问URL来抓取数据,并且可以设置页面的延迟时间,所以无论是手动触发脚本还是行为触发脚本都是轻而易举的。

安装依赖

npm i nightware -S

index.js中新增如下代码:

const Nightmare = require('nightmare');          // 自动化测试包,处理动态页面
const nightmare = Nightmare({ show: true });     // show:true  显示内置模拟浏览器

/**
 * [description] - 抓取本地新闻页面
 * [nremark] - 百度本地新闻在访问页面后加载js定位IP位置后获取对应新闻,
 * 所以抓取本地新闻需要使用 nightmare 一类的自动化测试工具,
 * 模拟浏览器环境访问页面,使js运行,生成动态页面再抓取
 */
// 抓取本地新闻页面
nightmare
.goto('http://news.baidu.com/')
.wait("div#local_news")
.evaluate(() => document.querySelector("div#local_news").innerHTML)
.then(htmlStr => {
  // 获取本地新闻数据
  localNews = getLocalNews(htmlStr)
})
.catch(error => {
  console.log(`本地新闻抓取失败 - ${error}`);
})

修改getLocalNews()函数为:

/**
 * [description]- 获取本地新闻数据
 */
let getLocalNews = (htmlStr) => {
  let localNews = [];
  let $ = cheerio.load(htmlStr);

  // 本地新闻
  $('ul#localnews-focus li a').each((idx, ele) => {
    let news = {
      title: $(ele).text(),
      href: $(ele).attr('href'),
    };
    localNews.push(news)
  });

  // 本地资讯
  $('div#localnews-zixun ul li a').each((index, item) => {
    let news = {
      title: $(item).text(),
      href: $(item).attr('href')
    };
    localNews.push(news);
  });

  return localNews
}

修改app.get('/')路由为:

/**
 * [description] - 跟路由
 */
// 当一个get请求 http://localhost:3000时,就会后面的async函数
app.get('/', async (req, res, next) => {
  res.send({
    hotNews: hotNews,
    localNews: localNews
  })
});

好了,大功告成~

总结

  1. express启动一个简单的Http服务
  2. 分析目标页面DOM结构,找到所要抓取的信息的相关DOM元
  3. 使用superagent请求目标页面
  4. 动态页面(需要加载页面后运行JS或请求接口的页面)可以使用Nightmare模拟浏览器访问
  5. 使用cheerio获取页面元素,获取目标数据

socket实现聊天室

Client Server

scoket.io简单来说就是对websocket的封装,包括了客户端的js与服务器端的nodejs,其目的是为了构建在不同个浏览器和设备的实时应用问题,例如:无人点餐,即时通信等

案例:实现聊天室

基于node+express+socket.io+vue+flex来实现简易的聊天室

实现功能:

  • 登录检测
  • 系统提示在线人员状态(进入/离开)
  • 接收和发送消息
  • 自定义消息字体颜色
  • 支持发送表情
  • 支持发送窗口震动

模板:index.html

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
  <link rel="stylesheet" href="style/index.css">
  <link rel="stylesheet" href="style/font-awesome-4.7.0/css/font-awesome.min.css">
</head>

<body>
  <div id="app">
    <div class="name">
      <!-- <h2>请输入你的昵称</h2> -->
      <input type="text" id="name" placeholder="请输入昵称..." autocomplete="off">
      <button id="nameBtn">确 定</button>
    </div>
    <!-- 整个窗口 -->
    <div class="main">
      <div class="header">
        <img src="image/logo.jpg">
        ❤️聊天室
      </div>
      <div id="container">
        <div class="conversation">
          <ul id="messages">
            <li>
              <div v-if='1===2'>
                <!-- 用户头像 -->
                <img src="image/user1.jpg" alt="">
                <div>
                  <span>小马哥</span>
                  <!-- 用户信息显示 -->
                  <p>
                  </p>
                </div>
              </div>
              <p class='system' v-if='1===2'>
                <span>2020-03-01</span><br />
                <span>小马哥进入/离开了聊天室</span>
                <span>大马哥发送了一个窗口抖动</span>
              </p>
            </li>
          </ul>
          <form action="">
            <div class="edit">
              <input type="color" id="color">
              <i title="自定义字体颜色" id="font" class="fa fa-font">
              </i><i title="双击取消选择" class="fa fa-smile-o" id="smile">
              </i><i title="单击页面震动" id="shake" class="fa fa-bolt">
              </i>
              <input type="file" id="file">
              <i class="fa fa-picture-o" id="img"></i>
              <div class="selectBox" v-show='1===2'>
                <div class="smile" id="smileDiv">
                  <p>经典表情</p>
                  <ul class="emoji">
                    <li>
                      <img src="image/emoji/emoji (1).png" alt="i+1">
                    </li>
                  </ul>
                </div>
              </div>
            </div>
            <!-- autocomplete禁用自动完成功能 -->
            <textarea id="m" autofocus></textarea>
            <button class="btn rBtn" id="sub">发送</button>
            <button class="btn" id="clear">关闭</button>
          </form>
        </div>
        <div class="contacts">
          <h1>在线人员(<span id="num">0</span>)</h1>
          <ul id="users" v-if='1===2'>
            <li>
              <img src="image/user2.jpg" alt="">
              <span>小马哥</span>
            </li>
          </ul>
          <p v-else>当前无人在线哟~</p>
        </div>
      </div>
    </div>
  </div>
  <script src="https://cdn.bootcss.com/socket.io/2.2.0/socket.io.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js"></script>
  <script src='js/client.js'></script>
</body>

</html>

模板:index.css

* { 
    margin: 0; 
    padding: 0; 
    box-sizing: border-box; 
}
body { 
    font: 13px "微软雅黑", Helvetica, Arial; 
    background: url('../image/bg3.jpg');
    background-size: cover;
} 
input{
    outline: noen;
}
.name {
    width: 100%;
    height: 100vh;
    position: absolute;
    top: 0;
    left: 0;
    z-index: 3;
    background-color:rgba(238, 238, 238, 0.8);
    text-align: center;
    padding-top: 35vh;
}
.name input {
    width: 200px;
    border: none;
    border-bottom: 2px solid #bbb;
    background-color: #f3f3f3;
    font-size: 23px;
    color: #555;
    text-align: center;
}
.name button {
    display: block;
    width: 107px;
    height: 36px;
    margin: 0 auto;
    border: none;
    background-color: #805b6b;
    border-radius: 5px;
    color: #fff;
    font-size: 17px;
    margin-top: 20px;
    cursor: pointer;
}
.main {
    width: 705px;
    height: 556px;
    margin: 7vh auto; 
    border: 2px #eee solid; 
    border-radius: 10px;  
    box-shadow: 3px 5px 9px #ccc;
    background-color: rgba(255, 255, 255, 1);
    position: relative;
    left: 0;
}
.header {
    height: 85px;
    border-bottom: 2px solid #eee;
    font-size: 23px;
    padding-left: 10px;
    padding-top: 10px;
    color: #555;
}
.header img {
    width: 50px;
    height: 50px;
    border-radius: 25px;
    vertical-align: middle;
}
#container {
    height: 471px;
    display: flex;
}
.conversation {
    width: 490px;
    border-right: 2px #eee solid;
}
#messages {
    height: 346px;
    padding: 20px 10px 0px 10px;
    overflow-y: auto;
} 
 /*滚动条样式*/
#messages::-webkit-scrollbar {/*滚动条整体样式*/
    width: 4px;     /*高宽分别对应横竖滚动条的尺寸*/
    height: 4px;
}
#messages::-webkit-scrollbar-thumb {/*滚动条里面小方块*/
    border-radius: 5px;
    box-shadow: inset 0 0 5px rgba(0,0,0,0.2);
    -webkit-box-shadow: inset 0 0 5px rgba(0,0,0,0.2);
    background: rgba(0,0,0,0.2);
}
#messages::-webkit-scrollbar-track {/*滚动条里面轨道*/
    box-shadow: inset 0 0 5px rgba(0,0,0,0.2);
    -webkit-box-shadow: inset 0 0 5px rgba(0,0,0,0.2);
    border-radius: 0;
    background: rgba(0,0,0,0.1);
}
p.system {
    color: #888;
    text-align: center;
    margin: 5px;
}
p.system span {
    background-color: #eee;
    border-radius: 9px;
    padding: 1px 5px;    
    margin-bottom: 7px;
    display: inline-block;
}
#messages li {
    list-style: none;
    width: 100%;
    float: left;
    margin-bottom: 5px;
}
#messages li img {
    width: 40px;
    height: 40px;
    border-radius: 20px;
}
#messages li p img {
    width: 30px;
    height: 30px;
    margin: 0;
    padding: 0;
    vertical-align: bottom;
}
#messages li p .sendImg {
    max-width: 300px;
    max-height: 188px;
    width: auto;
    height: auto;
    border-radius: 5px;
}
#messages li p span {
    padding-top: 7px;  
}
.left img {
    margin-right: 8px;
}
.right img {
    margin-left: 8px;
}
.left img, .left div {
    float: left;
}
.left span {
    text-align: left;
}
.right span {
    text-align: right;
}
.right p {
    float: right;
}
.right img, .right div {
    float: right;
}
#messages li div>span {
    display: block;
    color: #555; 
    padding-left: 2px;
}
#messages li div p {
    display: flex;
    max-width: 300px;
    height: auto;
    padding: 10px;
    margin-top: 5px;
    word-wrap: break-word;  /* 文本自动换行 */
    font-size: 15px;
    border-radius: 5px;
}
.left p {
    background-color: #d5d3d3;
}
.right p {
    background-color: #86bdf8;
}   
form {
    height: 121px;
    border-top: 1px #ddd solid;
    position: relative;
}
.edit {
    width: 100%;
    height: 33px;
    color: #7f8393;
    font-size: 19px;
    line-height: 33px;
    padding-left: 10px;
    position: relative;
}
.edit i {
    padding: 5px 6px;
    cursor: pointer;
}
.edit i:hover {
    background-color: #e2e2e2 !important;
}
.edit .selectBox {
    position: absolute;
    bottom: 34px;
    left: 0px;
    z-index: 5;
    background-color: #fff;
}  
.shaking {
    animation: run 0.2s infinite;
}
@keyframes run {
    0% {
        left: 0;
    }
    25% {
        left: -7px;
    }
    50% {
        left: 7px;
    }
    100% {
        left: 0;
    }
}
.edit #file {
    width: 32.36px;
    height: 29px;
    opacity: 0;
    z-index: 5;
}
.edit #img {
    z-index: 0;
    margin-left: -43px;
}
#color {
    width: 25px;
    border: none;
    cursor: pointer;
    background: none;
    opacity: 0;
    position: relative;
    z-index: 5;
}
#color:focus {
    outline: none;
}
.edit #font {
    position: absolute;
    left: 9px;
    top: 3px;
    z-index: 0;
}
.smile {
    width: 460px;
    height: auto;
    border: 1px #eee solid; 
    box-shadow: 1px 1px 1px #ccc;
    padding-top: 5px;
    box-sizing: border-box;
}
.smile p {
    height: 35px;
    font-size: 15px;
    color: #555;
    line-height: 35px;
    padding-left: 20px;
    box-sizing: border-box;
}
.emoji {
    width: 100%;
    height: 210px; 
    overflow-y: scroll;
    padding: 0 17px;
}
.emoji li {
    list-style: none;
    width: 35px;
    height: 35px;
    line-height: 35px;
    box-sizing: border-box;
    overflow: hidden;
    padding-top: 4px;
    padding-left: 2px;
    display: inline-block;
    margin: 0;
}
.emoji li:hover {
    padding-top: 1px;
    background-color: #f3f3f4;
}
.emoji li img {
    width: 30px;
    height: 30px;
}

.emoji::-webkit-scrollbar {/*滚动条整体样式*/
    width: 4px;     /*高宽分别对应横竖滚动条的尺寸*/
    height: 4px;
}
.emoji::-webkit-scrollbar-thumb {/*滚动条里面小方块*/
    border-radius: 5px;
    box-shadow: inset 0 0 5px rgba(0,0,0,0.2);
    -webkit-box-shadow: inset 0 0 5px rgba(0,0,0,0.2);
    background: rgba(0,0,0,0.2);
}
.emoji::-webkit-scrollbar-track {/*滚动条里面轨道*/
    box-shadow: inset 0 0 5px rgba(0,0,0,0.2);
    -webkit-box-shadow: inset 0 0 5px rgba(0,0,0,0.2);
    border-radius: 0;
    background: rgba(0,0,0,0.1);
} 

textarea {
    display: block;
    width: 100%;
    height: 55px;
    padding-left: 5px;
    padding-top: 5px;
    resize: none;
    font-size: 16px;
    background: none;
    border: none;
    font-family: '微软雅黑';
}
textarea:focus, .btn:focus, .name input:focus, .name button:focus {
    outline: none;
}
 /*滚动条样式*/
textarea::-webkit-scrollbar {/*滚动条整体样式*/
    width: 4px;     /*高宽分别对应横竖滚动条的尺寸*/
    height: 4px;
}
textarea::-webkit-scrollbar-thumb {/*滚动条里面小方块*/
    border-radius: 5px;
    box-shadow: inset 0 0 5px rgba(0,0,0,0.2);
    -webkit-box-shadow: inset 0 0 5px rgba(0,0,0,0.2);
    background: rgba(0,0,0,0.2);
}
textarea::-webkit-scrollbar-track {/*滚动条里面轨道*/
    box-shadow: inset 0 0 5px rgba(0,0,0,0.2);
    -webkit-box-shadow: inset 0 0 5px rgba(0,0,0,0.2);
    border-radius: 0;
    background: rgba(0,0,0,0.1);
}
button.btn {
    width: 72px;
    height: 25px;
    float: right;
    margin-right: 5px;
    background-color: #805b6b;
    border-radius: 3px;
    border: none;
    color: #fff;
    cursor: pointer;
} 
button.btn.rBtn {
    margin-right: 10px;
}
.btn:hover {
    background-color: #b495a1;
}
.contacts {
    width: 210px;
    height: 100%;
    padding: 6px;
}
.contacts h1 {
    font-size: 16px;
    font-weight: 500;
    margin-bottom: 10px;
}
.contacts ul {
    width: 100%;
}
.contacts li {
    display: inline-block;
    width: 23%;
    margin-right: 2%;
    height: 65px;
    text-align: center;
    margin-bottom: 5px;
}
.contacts li img {
    width: 100%;
    height: 45.5px;
}
.contacts li>span {
    display: inline-block;
    width: 100%;
    font-size: 13px;
    line-height: 20px;
    vertical-align: middle;
    text-overflow: ellipsis;
    white-space: nowrap;
    overflow: hidden;
}
.contacts p {
    text-align: center;
    margin-top: 70px;
    color: #555;
}

index.html

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <link rel="stylesheet" href="style/index.css">
    <link rel="stylesheet" href="style/font-awesome-4.7.0/css/font-awesome.min.css">
</head>

<body>
    <div id="app">
        <div class="name" v-if='isShow'>
            <!-- <h2>请输入你的昵称</h2> -->
            <input @keyup.enter='handleClick' type="text" id="name" placeholder="请输入昵称..." autocomplete="off"
                v-model='username'>
            <button id="nameBtn" @click='handleClick'>确 定</button>
        </div>
        <div class="main" :class='{shaking:isShake}'>
            <div class="header">
                <img src="image/logo.jpg">
                ❤️聊天室
            </div>
            <div id="container">
                <div class="conversation">
                    <ul id="messages">
                        <li v-for='(user,index) in userSystem' :class='user.side'>
                            <div v-if='user.isUser'>
                                <img :src="user.img" alt="">
                                <div>
                                    <span>{{user.name}}</span>
                                    <p :style="{color: user.color}" v-html='user.msg'>
                                    </p>
                                </div>
                            </div>
                            <p class='system' v-else>
                                <span>{{nowDate}}</span><br />
                                <span v-if='user.status'>{{user.name}}{{user.status}}了聊天室</span>
                                <span v-else>{{user.name}}发送了一个窗口抖动</span>
                            </p>

                        </li>
                    </ul>
                    <form action="">
                        <div class="edit">
                            <input type="color" id="color" v-model='color'>
                            <i title="自定义字体颜色" id="font" class="fa fa-font">
                            </i><i @click='handleSelectEmoji' @dblclick='handleDoubleSelectEmoji' title="双击取消选择" class="fa fa-smile-o" id="smile">
                            </i><i @click='handleShake' title="单击页面震动" id="shake" class="fa fa-bolt">
                            </i>
                            <input type="file" id="file">
                            <i class="fa fa-picture-o" id="img"></i>
                            <div class="selectBox" v-show='isEmojiShow'>
                                <div class="smile" id="smileDiv">
                                    <p>经典表情</p>
                                    <ul class="emoji">
                                        <li v-for='(emojiSrc,i) in emojis' :id='i' :key='i'>
                                            <img :src="emojiSrc" :alt="i+1" @click='handleEmojiImg(i+1)'>
                                        </li>
                                    </ul>
                                </div>
                            </div>
                        </div>
                        <!-- autocomplete禁用自动完成功能 -->
                        <textarea id="m" v-model='msgVal' autofocus @keyup.enter='handleSendMsg'></textarea>
                        <button class="btn rBtn" id="sub" @click='handleSendMsg'>发送</button>
                        <button class="btn" id="clear" @click='handleLogout'>关闭</button>
                    </form>
                </div>
                <div class="contacts">
                    <h1>在线人员(<span id="num">{{userInfo.length}}</span>)</h1>
                    <ul id="users">
                        <li v-for='(user,index) in userInfo'>
                            <img :src="user.img" alt="">
                            <span>{{user.username}}</span>
                        </li>
                    </ul>
                    <p v-if='userInfo.length==0'>当前无人在线哟~</p>
                </div>
            </div>
        </div>
    </div>
    <script src="https://cdn.bootcss.com/socket.io/2.2.0/socket.io.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js"></script>
    <script src='js/client.js'></script>
</body>

</html>

server.js

const express = require('express');
const app = express();1
const http = require('http').Server(app);
const io = require('socket.io')(http);
const port = process.env.PORT || 5000;

let users = [];//存储登录的用户
let userInfo = [];//存储用户姓名和头像

app.use('/',express.static(__dirname+'/static'));
app.get('/', function (req, res) {
    res.sendFile(__dirname + '/index.html');
});

io.on('connection', function (socket) {
    console.log('连接成功');
    socket.on('login', function (user) {
        const {username} = user;
        console.log(users.indexOf(username))

        if (users.indexOf(username)>-1){            
            socket.emit('loginError');

        }else{
            // 存储用户名
            users.push(username);
            userInfo.push(user);
            io.emit('loginSuc');
            socket.nickName = username;
            // 系统通知
            io.emit('system', {
                name: username,
                status: '进入'
            })

            // 显示在线人员
            io.emit('disUser',userInfo);
            console.log('一个用户登录');

        }

    });
    // 发送窗口事件
    socket.on('shake',()=>{
        socket.emit('shake',{
            name:'您'
        })
        // 广播消息
        socket.broadcast.emit('shake',{
            name:socket.nickName
        })
    });
    // 发送消息事件
    socket.on('sendMsg',(data)=>{

        let img = '';
        for(let i = 0; i < userInfo.length;i++){
            if(userInfo[i].username === socket.nickName){
                img = userInfo[i].img;
            }
        }
        // 广播
        socket.broadcast.emit('receiveMsg', {
            name: socket.nickName,
            img: img,
            msg: data.msgVal,
            color: data.color,
            type: data.type,
            side: 'left',
            isUser:true
        });
        socket.emit('receiveMsg', {
            name: socket.nickName,
            img: img,
            msg: data.msgVal,
            color: data.color,
            type: data.type,
            side: 'right',
            isUser: true
        });

    })

    // 断开连接时
    socket.on('disconnect',()=>{
        console.log('断开连接');

        let index = users.indexOf(socket.nickName);        
        if(index > -1){
            users.splice(index,1);//删除用户信息
            userInfo.splice(index,1);//删除用户信息

            io.emit('system',{
                name:socket.nickName,
                status:'离开'
            })
            io.emit('disUser,userInfo'); //重新渲染
            console.log('一个用户离开');

        }
    })

});


http.listen(3000, () => {
    console.log('listen on 3000端口');
})

client.js

const vm = new Vue({
    el: '#app',
    data() {
        return {
            username: '',
            msgVal: '',
            isShow: true,
            nowDate: new Date().toTimeString().substr(0, 8),
            userHtml: '',
            userInfo: [],
            isShake: false,
            timer: null,
            userSystem: [],
            color: '#000000',
            emojis: [],
            isEmojiShow: false
        }
    },
    methods: {
        handleClick() {
            var imgN = Math.floor(Math.random() * 4) + 1; // 随机分配头像
            if (this.username) {
                this.socket.emit('login', {
                    username: this.username,
                    img: 'image/user' + imgN + '.jpg'
                });
            }

        },
        shake() {
            this.isShake = true;
            clearTimeout(this.timer);
            this.timer = setTimeout(() => {
                this.isShake = false;
            }, 500);
        },
        handleShake(e) {
            this.socket.emit('shake');
        },
        // 发送消息
        handleSendMsg(e) {
            e.preventDefault();
            if (this.msgVal) {
                this.socket.emit('sendMsg', {
                    msgVal: this.msgVal,
                    color: this.color,
                    type: 'text'
                })
                this.msgVal = '';
            }
        },
        scrollBottom() {
            this.$nextTick(() => {
                const div = document.getElementById('messages');
                div.scrollTop = div.scrollHeight;
            })
        },
        initEmoji() {
            for (let i = 0; i < 141; i++) {
                this.emojis.push(`image/emoji/emoji (${i + 1}).png`);
            }
        },
        // 点击微笑 弹出表情
        handleSelectEmoji() {
            this.isEmojiShow = true;
        },
        handleDoubleSelectEmoji() {
            this.isEmojiShow = false;
        },
        // 用户点击发送表情
        handleEmojiImg(index) {
            this.isEmojiShow = false;
            this.msgVal = this.msgVal + `[emoji${index}]`;
        },
        handleLogout(e) {
            e.preventDefault();
            this.socket.emit('disconnect')
        }
    },
    created() {
        const socket = io();
        this.socket = socket;
        this.socket.on('loginSuc', () => {
            this.isShow = false;
        });
        this.socket.on('loginError', () => {
            alert('用户名已存在,请重新输入');
            this.username = '';
        })
        // 系统提示消息
        this.socket.on('system', (user) => {
            console.log(user);
            this.userSystem.push(user);
            this.scrollBottom()
        })
        // 显示在线人员
        this.socket.on('disUser', (userInfo) => {
            this.userInfo = userInfo;
        })
        //监听抖动事件
        this.socket.on('shake', (user) => {
            this.userSystem.push(user);
            this.shake();
            this.scrollBottom();
        })
        this.socket.on('receiveMsg', (obj) => {
            let msg = obj.msg;
            let content = ''
            if (obj.type === 'img') {
            }
            // 提取文字中的表情加以渲染
            while (msg.indexOf('[') > -1) {  // 其实更建议用正则将[]中的内容提取出来
                var start = msg.indexOf('[');
                var end = msg.indexOf(']');
                content += '<span>' + msg.substr(0, start) + '</span>';
                content += '<img src="image/emoji/emoji%20(' + msg.substr(start + 6, end - start - 6) + ').png">';
                msg = msg.substr(end + 1, msg.length);
            }
            content += '<span>' + msg + '</span>';
            obj.msg = content;
            this.userSystem.push(obj);
            // 滚动条总是在最底部
            this.scrollBottom();
        })
        // 渲染表情
        this.initEmoji();
    }

})

注: