共计 8295 个字符,预计需要花费 21 分钟才能阅读完成。
基本思路
1. 通过 node 中的 superagent
模拟 http 请求,去读取豆瓣小组的信息,对读取到的信息通过 cheerio
插件进行解析格式化以便于获取 body
中的信息存储到 mongodb
中
2. 因为豆瓣会 ban 掉一写爬虫 ip,所以爬取过程中会使用 ip 池
挑选没有使用过的 ip 进行代理去爬取,并且会避免并发 使用mapLimit
3. 前端界面用 vue 提供 ip 选,和筛选结果分页展示,未部署到远程的,本地跑起来涉及到代理,主要在 vue.config.js
中,然后读取已经存在 mongodb
中的数据展示在前端
代码实现
目录结构
...
├── app.js
├── babel.config.js
...
...
├── server // 服务端代码
│ ├── db.js // 数据库增删改查接口
│ └── urls.js // 目前写了豆瓣小组的 url,后续可以考虑手动输入
├── server.js // 服务端启动文件
...
├── src // 前端 vue 界面入口
│ ├── App.vue
│ ├── api
│ ├── assets
│ ├── components
│ └── main.js
├── updatePoxy.js
├── vue.config.js
node 端
// server.js
// 服务启动
// 服务启动
// 服务启动
const express = require('express');
const app = express();
let server = app.listen(2333, "127.0.0.1", function () {let host = server.address().address;
let port = server.address().port;
console.log('Your App is running at' + host + ':' + port,);
})
// 插件
// 插件
// 插件
const superagent = require('superagent');
const eventproxy = require('eventproxy');
const ipProxy = require('ip-proxy-pool');
const cheerio = require('cheerio');
const async = require('async');
require('superagent-proxy')(superagent);
// 爬虫基本配置,后续可以从界面端传进来
const groups = require('./server/urls') // 租房小组的 url,
let page = 1 // 抓取页面数量
let start = 24 // 页面参数拼凑
// 构造爬虫 ulr
let ep = new eventproxy() // 实例化 eventproxy
global.db = require('./server/db')
let allLength = 0
groups.map((gp) => {gp.pageUrls = [] // 要抓取的页面数组
allLength = allLength + 1
for (let i = 0; i < page; i++) {
allLength = allLength + i
gp.pageUrls.push({url: gp.url + i * start // 构造成类似 https://www.douban.com/group/liwanzufang/discussion?start=0});
}
})
// 接口中部分函数定义
const getPageInfo = (ip, pageItem, callback) => {
// 设置访问间隔
console.log('ip', ip)
let delay = parseInt((Math.random() * 30000000) % 1000, 10)
let resultBack = {label: pageItem.key, list: []}
pageItem.pageUrls.forEach(pageUrl => {superagent.get(pageUrl.url).proxy(ip)
// 模拟浏览器
.set('User-Agent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.181 Safari/537.36')
// 如果你不乖乖少量爬数据的话,很可能被豆瓣 kill 掉,这时候需要模拟登录状态才能访问
.set('Cookie', '')
.end((err, pres) => {if (err || !pres) {ep.emit('preparePage', [])
return
}
console.log('pres.text', pres.text)
let $ = cheerio.load(pres.text) // 将页面数据用 cheerio 处理,生成一个类 jQuery 对象
let itemList = $('.olt tbody').children().slice(1, 26) // 取出 table 中的每行数据,并过滤掉表格标题
// 遍历页面中的每条数据
for (let i = 0; i < itemList.length; i++) {let item = itemList.eq(i).children()
let title = item.eq(0).children('a').text() || '' // 获取标题
let url = item.eq(0).children('a').attr('href') || '' // 获取详情页链接
// let author = item.eq(1).children('a').attr('href').replace('https://www.douban.com/people', '').replace(/\//g,'') || '' // 获取作者 id
let author = item.eq(1).children('a').text() || '' // 这里改为使用作者昵称而不是 id 的原因是发现有些中介注册了好多账号,打一枪换个地方。虽然同名也有,但是这么小的数据量下,概率低到忽略不计
let markSum = item.eq(2).text() // 获取回应数量
let lastModify = item.eq(3).text() // 获取最后修改时间
let data = {
title,
url,
author,
markSum,
lastModify,
label: pageItem.key
}
resultBack.list.push(data)
}
// ep.emit('事件名称', 数据内容)
console.log('resultBack', resultBack)
ep.emit('preparePage', resultBack) // 每处理完一条数据,便把这条数据通过 preparePage 事件发送出去,这里主要是起计数的作用
setTimeout(() => {callback(null, pageItem.url);
}, delay);
})
})
}
function getData(ip, res) {
// 遍历爬取页面
async.mapLimit(groups, 1, function (item, callback) {getPageInfo(ip, item, callback);
}, function (err) {if (err) {console.log(err)
}
console.log('抓取完毕')
});
}
ep.after('preparePage', allLength, function (data, res) {
// 这里我们传入不想要出现的关键词,用 '|' 隔开。比如排除一些位置,排除中介常用短语
let filterWords = / 求组 | 合租 | 求租 | 主卧 /
// 再次遍历抓取到的数据
let inserTodbList = []
data.forEach(item => {
// 这里 if 的顺序可是有讲究的,合理的排序可以提升程序的效率
item.list = item.list.filter(() => {if (item.markSum > 100) {console.log('评论过多,丢弃')
return false
}
if (filterWords.test(item.title)) {console.log('标题带有不希望出现的词语')
console.log('item', item)
return false
}
return true
})
inserTodbList.push(...item.list)
})
global.db.__insertMany('douban', inserTodbList, function () {ep.emit('spiderEnd', {})
})
});
// 接口
// 接口
// 接口
app.get('/api/getDataFromDouBan', (req, res) => {let {ip} = req.query
getData(ip, res)
ep.after('spiderEnd', 1, function() {
res.send({data: '爬取结束'})
})
})
// 获取 ip
app.get('/api/getIps', (req, res) => {async function getIps(callback) {
let ips = ipProxy.ips
ips((err,response) => {callback(response)
})
}
getIps(function (ipList) {
res.send({
msg: '获取成功',
list: ipList
})
})
})
// 更新 ip 池
app.get('/api/updateIps', (req, res) => {ipProxy.run(() => {console.log('更新完毕')
})
})
app.get('/api/doubanList', (req, res) => {let {label, page = 1, pageSize = 10} = req.query
let param = []
label && label.map((item) => {param.push({label: item})
})
let queryJson = {// $where: "label"}
if (param.length) queryJson['$or'] = param
global.db.__find('douban', {queryJson, page, pageSize}, function (data) {
res.send({
msg: '获取成功',
...data
})
})
})
db.js
// db.js
/**
* 数据库封装
*
*/
var MongodbClient = require('mongodb').MongoClient
var assert = require('assert')
var url = "mongodb://localhost:27017";
/**
* 连接数据库
*/
function __connectDB(callback) {MongodbClient.connect(url, function (err, client) {let db = client.db('zufangzi')
callback(err, db, client)
})
}
/**
* 插入一条数据
* @param {*} collectionName 集合名
* @param {*} Datajson 写入的 json 数据
* @param {*} callback 回调函数
*/
function __insertOne(collectionName, Datajson, callback) {__connectDB(function (err, db, client) {var collection = db.collection(collectionName);
collection.insertOne(Datajson, function (err, result) {callback(err, result); // 通过回调函数上传数据
client.close();})
})
}
/**
* 插入多条数据
* @param {*} collectionName 集合名
* @param {*} Datajson 写入的 json 数据
* @param {*} callback 回调函数
*/
function __insertMany(collectionName, Datajson, callback) {__connectDB(function (err, db, client) {var collection = db.collection(collectionName);
collection.insertMany(Datajson, function (err, result) {callback(err, result); // 通过回调函数上传数据
client.close();})
})
}
/**
* 查找数据
* @param {*} collectionName 集合名
* @param {*} Datajson 查询条件
* @param {*} callback 回调函数
*/
function __find(collectionName, {queryJson, page, pageSize}, callback) {var result = [];
if (arguments.length != 3) {callback("find 函数必须传入三个参数哦", null)
return
}
__connectDB(async function (err, db, client) {var cursor = db.collection(collectionName).find(queryJson).skip((page - 1) * pageSize).limit(10);
let total = await cursor.count()
if (!err) {await cursor.forEach(function (doc) {
// 如果出错了,那么下面的也将不会执行了
// console.log('doc', doc)
if (doc != null) {result.push(doc)
}
})
callback({list: result, total})
}
client.close();})
}
/**
*
* 删除数据(删除满足条件的所有数据哦)* @param {*} collectionName 集合名
* @param {*} json 查询的 json 数据
* @param {*} callback 回调函数
*/
function __DeleteMany(collectionName, json, callback) {__connectDB(function (err, db, client) {assert.equal(err, null)
// 删除
db.collection(collectionName).deleteMany(
json,
function (err, results) {assert.equal(err, null)
callback(err, results);
client.close(); // 关闭数据库}
);
});
}
/**
* 修改数据
* @param {*} collectionName 集合名
* @param {*} json1 查询的对象
* @param {*} json2 修改
* @param {*} callback 回调函数
*/
function __updateMany(collectionName, json1, json2, callback) {__connectDB(function (err, db, client) {assert.equal(err, null)
db.collection(collectionName).updateMany(
json1,
json2,
function (err, results) {assert.equal(err, null)
callback(err, results)
client.close()}
)
})
}
/**
* 获取总数
* @param {*} collectionName 集合名
* @param {*} json 查询条件
* @param {*} callback 回调函数
*/
function __getCount(collectionName, json, callback) {__connectDB(function (err, db, client) {db.collection(collectionName).count(json).then(function (count) {callback(count)
client.close();})
})
}
/**
* 分页查找数据
* @param {*} collectionName 集合名
* @param {*} JsonObj 查询条件
* @param {*} C【可选】传入的参数,每页的个数、显示第几页
* @param {*} C callback
*/
function __findByPage(collectionName, JsonObj, C, D) {var result = []; // 结果数组
if (arguments.length == 3) {
// 那么参数 C 就是 callback,参数 D 没有传。var callback = C;
var skipnumber = 0;
// 数目限制
var limit = 0;
} else if (arguments.length == 4) {
var callback = D;
var args = C;
// 应该省略的条数
var skipnumber = args.pageamount * args.page || 0;
// 数目限制
var limit = args.pageamount || 0;
// 排序方式
var sort = args.sort || {};} else {throw new Error("find 函数的参数个数,必须是 3 个,或者 4 个。");
return;
}
// 连接数据库,连接之后查找所有
__connectDB(function (err, db, client) {var cursor = db.collection(collectionName).find(JsonObj).skip(skipnumber).limit(limit).sort(sort);
cursor.each(function (err, doc) {if (err) {callback(err, null);
client.close(); // 关闭数据库
return;
}
if (doc != null) {result.push(doc); // 放入结果数组
} else {
// 遍历结束,没有更多的文档了
callback(null, result);
client.close(); // 关闭数据库}
});
});
}
module.exports = {
__connectDB,
__insertOne,
__insertMany,
__find,
__DeleteMany,
__updateMany,
__getCount,
__findByPage
}
接口
const axios = require('axios');
export const getHousData = async (arg = {}) => {
// 从数据库中获取已经爬取到的数据
let respones = await axios.get('/api/doubanList', {params: arg})
return respones.data
}
export const spiderData = async (arg = {}) => {
// 向爬取数据
let respones = await axios.get('/api/getDataFromDouBan/', {params: arg})
return respones.data
}
export const updateIps = async (arg = {}) => {
// 更新 ip 池
let respones = await axios.get('/api/updateIps/', {params: arg})
return respones.data
}
export const getIps = async (arg = {}) => {
// 获取 ip 池
let respones = await axios.get('/api/getIps/', {params: arg})
return respones.data
}
项目启动
1. 命令行中启动mongodb
输入:mongod
, 未安装的需要自行安装
2. 命令行中输入 yarn dev
启动本地前端项目
3. 命令行中输入 nodemon server.js
启动后端项目
项目地址
正文完
发表至: javascript
2019-07-27