使用 Node.JS 爬取网页、存储数据
前言
以前在做爬虫项目是使用Python,操作起来不大熟练,后来了解到 cheerio 这个JS库,发现挺好用。这里就贡献一下一个小型的爬虫项目。
项目选取了ETH区块浏览器:etherscan.io , 目的是获取该网站下所有ETH ERC20 代币的Logo。
项目描述
使用 Node.JS 爬取网页图片、下载图片到本地、上传图片到CDN、详细信息存入数据库
项目实现
- 获取所有的ETH代币的logo以及基础信息
- 将基础信息保存至数据库
- 下载logo图片至本地
- 将logo上传到CDN ,同时将CDN地址保存至数据库
- 删除本地下载的图片 (暂未实现,比较简单)
所用框架
- Node.js (运行环境)
- Qiniu.js (上传图片至CDN)
- Sequelize.js (mysql ORM 框架)
- Axios.js (网络请求框架)
关于 cheerio.js
官方有这么一句定义:
cheerio.js是jquery核心功能的一个快速灵活而又简洁的实现,主要是为了用在服务器端需要对DOM进行操作的地方
说到jquery其实再熟悉不过了,除此之外,它的用法和jquery也差不多,下面是官网示例:
const cheerio = require('cheerio')
const $ = cheerio.load('<h2 class="title">Hello world</h2>')
$('h2.title').text('Hello there!')
$('h2').addClass('welcome')
$.html()
//=> <h2 class="title welcome">Hello there!</h2>
安装
npm install cheerio
业务实现
config.js 配置文件
这个就不提供了,有机密 ?
Service 处理文件下载、图片上传
download_service.js 文件下载以及查询
download_service.js 主要提供两个功能:
- 保存图片到本地
- 图片是否存在
const fs = require('fs')
const request = require('request')
const download_service = {}
/**
* 下载图片至本地 - 未完全进行错误捕获,需优化
* @param {*} src 要下载的文件url
* @param {*} dest 要保存的文件名
*/
download_service.download_pic = (src, dest) => {
return new Promise((resolve, reject) => {
console.log(`${dest} downloading`)
try {
const reader = request(src).pipe(fs.createWriteStream(dest))
reader.on('close', () => {
console.log(`${dest} save local success`)
resolve(true)
})
} catch (error) {
console.error(`${dest} save local error , ${error.toString()}`)
reject(error)
}
})
}
/**
* 查询本地文件是否存在
* @param {*} dest 文件地址
*/
download_service.find_file = (dest) => {
return new Promise((resolve) => {
fs.access(dest, (err) => {
resolve(err === null)
})
})
}
module.exports = download_service
upload_service.js 处理文件上传
const qiniu_sdk = require('qiniu')
const { qiniu } = require('../config/config')
const qiniu_service = {}
qiniu_sdk.conf.ACCESS_KEY = qiniu.ACCESS_KEY
qiniu_sdk.conf.SECRET_KEY = qiniu.SECRET_KEY
// 要上传的空间
const bucket = qiniu.bucket
// 域名
const domain = qiniu.domain
// 生成上传文件的 token
const token = (bucket, key) => {
const policy = new qiniu_sdk.rs.PutPolicy({ isPrefixalScope: 1, scope: bucket + ':' + key })
return policy.uploadToken()
}
const config = new qiniu_sdk.conf.Config();
/**
* 上传文件到七牛
* @param {*} file_name
* @param {*} file_path
*/
qiniu_service.upload_file = (file_name, file_path, prefix = "eth/image/token/") => {
// 保存到七牛的地址
const file_save_path = prefix + file_name
// 七牛上传的token
const up_token = token(bucket, file_save_path)
const extra = new qiniu_sdk.form_up.PutExtra()
const formUploader = new qiniu_sdk.form_up.FormUploader(config)
return new Promise(resolve => {
// 上传文件
formUploader.putFile(up_token, file_save_path, file_path, extra, (err, ret) => {
if (err) {
// 上传失败, 处理返回代码
console.error('七牛文件上传失败:', err)
resolve('')
return
}
// 上传成功, 处理返回值
console.log('七牛文件上传成功:', ret, domain + file_save_path)
resolve(domain + file_save_path)
});
})
}
/**
* 查询文件是否存在
* @param {*} file_name
*/
qiniu_service.find_file = (file_name, prefix = "eth/image/token/") => {
return new Promise(resolve => {
const file_path = prefix + file_name
const mac = new qiniu_sdk.auth.digest.Mac(qiniu.ACCESS_KEY, qiniu.SECRET_KEY)
const bucketManager = new qiniu_sdk.rs.BucketManager(mac, config)
bucketManager.stat(bucket, file_path, (err, respBody, respInfo) => {
if (err) {
resolve(false)
} else {
if (respInfo.statusCode == 200) {
resolve(domain + file_path)
} else {
resolve(false)
}
}
})
})
}
module.exports = qiniu_service
mysql.js 配置mysql orm model以及操作方法
这里处理有点不合适,就是service 和 model 未分离,实际项目最好分离开来
const Sequelize = require('sequelize')
const { mysql } = require('../config/config')
const Op = Sequelize.Op
const mysql_service = {}
//连接数据库
const sequelize = new Sequelize(mysql.database, mysql.username, mysql.password, {
host: mysql.host,
dialect: 'mysql',
pool: {
max: 5,
min: 0,
idle: 30000
},
dialectOptions: {
timeout: 10000
}
});
// model
const eth_token = sequelize.define('eth_token',
{
token_name: Sequelize.STRING(50),
token_symbol: Sequelize.STRING(100),
token_total: Sequelize.STRING(100),
token_holder: Sequelize.STRING(100),
token_contract: Sequelize.STRING(100),
token_decimals: Sequelize.TINYINT(3),
token_price: Sequelize.DECIMAL(20, 18),
token_website: Sequelize.STRING(255),
token_type: Sequelize.TINYINT(3),
token_icon: Sequelize.STRING(255)
},
{
tableName: 'eth_token',
timestamps: false,
freezeTableName: false
}
)
const models = {
eth_token: eth_token
}
//methods
/**
* 插入数据到表里
*/
mysql_service.insertDb = (table, data) => {
return new Promise(async resolve => {
try {
const res = await models[table].create(data)
resolve(JSON.parse(JSON.stringify(res)))
} catch (e) {
console.error(e)
reject(`${table} insertDb error:${e.toString()}`)
}
})
}
/**
* 更新数据
*/
mysql_service.updateData = (table, where, data) => {
return new Promise(async resolve => {
try {
const res = await models[table].update(data, where)
resolve(JSON.parse(JSON.stringify(res)))
} catch (e) {
console.error(e)
reject(`${table} updateData error:${e.toString()}`)
}
})
}
/**
* 查询数据
*/
mysql_service.selectData = (table, where) => {
return new Promise(async resolve => {
try {
const res = await models[table].findAll(where)
console.log(`find ${table} - length : ${res.length}`)
resolve(JSON.parse(JSON.stringify(res)))
} catch (e) {
console.error(e)
reject(`${table} selectData error:${e.toString()}`)
}
})
}
/**
* 查询数据条数
*/
mysql_service.getCount = (table, where) => {
return new Promise(async resolve => {
try {
const res = await models[table].count(where)
resolve(JSON.parse(JSON.stringify(res)))
} catch (e) {
console.error(e)
reject(`${table} selectData error:${e.toString()}`)
}
})
}
/**
* 构造模糊查询条件
*/
mysql_service.createSearchCondition = (filed = [], where) => {
let or = []
if (filed.length !== 0) {
filed.map(item => {
const obj = {}
obj[item] = {
[Op.like]: where
}
or.push(obj)
})
}
return { [Op.or]: or }
}
module.exports = mysql_service
main.js 爬虫业务
关于URL的拼接,要看个人要爬取的网站来,我爬取的这个网站,因为有分页,然后只需要在分页上携带页码参数即可,实际上有些页面是滑动加载就需要其他方式了
web3_service 及 redis_service 是我业务上需要,这个也不贡献,具体项目可以分离出来
const axios = require('axios')
const cheerio = require('cheerio')
const mysql_service = require('../service/mysql_service')
const config = require('../config/config')
const download_service = require('../service/save_file_service')
const upload_service = require('../service/upload_service')
const { web3_service } = require('../service/web_three')
const save_dir = './script/images/tokens/'
const host = 'https://etherscan.io/'
const api = 'tokens?p='
const max_size = 20
const mysql_table_name = 'eth_token'
const wait_time = 5000 // 每次执行完成的等待时间
const is_server = true // 服务器上只需要存入redis,不需要下载和上传图片
let start_size = 0
// 设置 axios 的超时时间
axios.defaults.timeout = 10000;
// 拉取每一页数据
const getPagesData = () => {
if (start_size >= max_size) {
console.log('所有数据已拉取完毕')
process.exit(0)
}
console.log(`正在爬取第${start_size}页数据`)
//爬取父页面数据
axios.get(host + api + start_size).then(res => {
const { status, data } = res
if (status === 200) {
getContractAndUrl(data)
}
}).catch(e => {
console.error(`爬取父页面数据错误:${e.toString()}`)
wait()
})
}
//获取合约地址,并下载图片-再上传至七牛
const getContractAndUrl = (html) => {
let $ = cheerio.load(html)
let url = ''
let contract = ''
let tasks = []
$('.mb-0 .text-primary').each((idx, ele) => {
// 获取 url
url = $(ele).attr('href')
//获取合约地址
contract = url.replace('/token/', '')
// 将contract转换为标准的合约地址
contract = web3_service.web3.utils.toChecksumAddress(contract)
//子页面地址
url = host + url
tasks.push(getContractInfo(url, contract))
})
/**
* 使用Promise.all 控制多任务
* */
Promise.all(tasks).then(res => {
console.log(`第${start_size}页数据爬取完毕,结果:` + JSON.stringify(res))
//页数++
start_size++
wait()
}).catch(e => {
console.error(e)
wait()
})
}
// 获取基础信息, 名称、精度等
const getContractInfo = (url, contract) => {
return new Promise(async resolve => {
try {
// 请求子页面,获取html数据
const result = await axios.get(url)
// 解析页面
const $ = cheerio.load(result.data)
// cheerio 取出主要数据
const total_symbol = $('.mb-3 .row .font-weight-medium').text().split(/\s/)
const token_symbol = total_symbol[1]
const token_name = $('.media-body .small').text()
const token_decimals = $('#ContentPlaceHolder1_trDecimals .col-md-8').text().replace(/\n/g, '')
// 存入 mysql 的数据结构
const mysql_data = {
token_name : token_name,
token_total : total_symbol[0].replace(/,/g, ''),
token_symbol : token_symbol,
token_holder : $('#ContentPlaceHolder1_tr_tokenHolders .col-md-8').text().replace(' addresses', '').replace(/\n|,/g, ''),
token_contract: contract,
token_decimals: token_decimals !== '' ? token_decimals : 0,
token_website : $('#ContentPlaceHolder1_tr_officialsite_1 a').text().trim(),
token_price : $('#ContentPlaceHolder1_tr_valuepertoken .d-block').text().trim().split(/\s/)[0].replace('$', ''),
}
// 主要数据不全的时候 忽略此条数据
if (token_symbol === '' || token_decimals === '') {
return;
}
// 如果是服务器环境,则不再进行图片下载等操作,此处可忽略
if (!is_server) {
// 查询 mysql 中该条数据是否存在,存在则更新,否则新增
const res = await mysql_service.selectData(mysql_table_name, { where: { token_contract: contract } })
if (res != null && res.length > 0) {
await mysql_service.updateData(mysql_table_name, { where: { token_contract: contract } }, mysql_data)
} else {
await mysql_service.insertDb(mysql_table_name, mysql_data)
}
// 获取图片地址并保存至本地
const img_src = $('.u-sm-avatar').attr('src')
// 文件名称
const img_name = `${token_name}.png`
// 文件保存地址
const img_path = save_dir + img_name
// 查询文件是否存在 , 不存在再下载
const file_in = await download_service.find_file(img_path)
if (!file_in) {
await download_service.download_pic(host + img_src, img_path)
} else {
console.log(`${img_path} 本地已下载`)
}
// 查询七牛中该图片是否已经存在
const qiniu_in = await upload_service.find_file(img_name)
if (!qiniu_in) {
// 上传图片至七牛
const img_url = await upload_service.upload_file(img_name, img_path)
//更新图片地址到数据库中
await mysql_service.updateData(mysql_table_name, { where: { token_contract: contract } }, { token_icon: img_url })
} else {
console.log(`${img_path} 七牛已存在`)
await mysql_service.updateData(mysql_table_name, { where: { token_contract: contract } }, { token_icon: qiniu_in })
}
}
resolve(true)
} catch (e) {
console.error(contract, e.toString())
resolve(`${contract} run error:${e.toString()}`)
}
})
}
/**
* 等待时间后继续爬取
*/
const wait = () => {
console.log(`等待${wait_time / 1000}s`)
setTimeout(() => {
getPagesData()
}, wait_time)
}
getPagesData()
本文介绍了一个使用Node.js和cheerio库爬取etherscan.io网站ETH ERC20代币Logo的项目。项目包括下载图片到本地、使用Qiniu.js上传图片到CDN以及使用Sequelize.js存储信息到数据库。通过Axios进行网络请求,并详细说明了各个服务文件的功能。
157

被折叠的 条评论
为什么被折叠?



