# 使用Node.JS 爬取网页图片、下载图片到本地、上传图片到CDN、详细信息存入数据库

本文介绍了一个使用Node.js和cheerio库爬取etherscan.io网站ETH ERC20代币Logo的项目。项目包括下载图片到本地、使用Qiniu.js上传图片到CDN以及使用Sequelize.js存储信息到数据库。通过Axios进行网络请求,并详细说明了各个服务文件的功能。

使用 Node.JS 爬取网页、存储数据

个人博客地址,更多好内容

前言

以前在做爬虫项目是使用Python,操作起来不大熟练,后来了解到 cheerio 这个JS库,发现挺好用。这里就贡献一下一个小型的爬虫项目。

项目选取了ETH区块浏览器:etherscan.io , 目的是获取该网站下所有ETH ERC20 代币的Logo。

项目描述

使用 Node.JS 爬取网页图片、下载图片到本地、上传图片到CDN、详细信息存入数据库

项目实现

  1. 获取所有的ETH代币的logo以及基础信息
  2. 将基础信息保存至数据库
  3. 下载logo图片至本地
  4. 将logo上传到CDN ,同时将CDN地址保存至数据库
  5. 删除本地下载的图片 (暂未实现,比较简单)

所用框架

  1. Node.js (运行环境)
  2. Qiniu.js (上传图片至CDN)
  3. Sequelize.js (mysql ORM 框架)
  4. 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 主要提供两个功能:

  1. 保存图片到本地
  2. 图片是否存在
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()
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Auspicious5

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值