Fork me on GitHub
余鸢

微信公众号开发(六):爬取数据和上传图片到七牛云

爬取IMDB页面数据

这里我以冰火之歌电影为例子,也就要拿到冰火之歌的数据。数据分为两大数据,一是家族数据,一是角色数据。首先要先得到角色数据,通过数据做清理对比后去爬家族数据,然后根据家族数据进行合并生成两份数据,一份是每个家族里的人物,另一个是每个人物自己的数据,也就是个人介绍数据。

首先拿到数据url,对抓取的页面进行解析。这就要用到爬虫相关的工具,我使用cheerio,它是nodejs的抓取页面模块,为服务器特别定制的,快速、灵活、实施的jQuery核心实现,可以将HTML告诉你的服务器。

server/crawler/imdb.js

1
2
3
4
5
import cheerio from 'cheerio'
const options = {
uri: 'https://www.imdb.com/title/tt0944947/fullcredits?ref_=tt_cl_sm#cast',
transform: body => cheerio.load(body)
}

使用request-promise发送请求,

1
2
import rp from 'request-promise'
const $ = await rp(options)

通过选择器获取数据,遍历table中的每一条数据,然后对数据进行解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$('table.cast_list tr.odd, tr.even').each(function() {
let nmIdDom = $('td.itemprop',this).find('a')
let nmId = nmIdDom.attr('href')
let characterDom =$('td.character',this).find('a').eq(0)
let chId = characterDom.attr('href')
let name = characterDom.text()
let playedByDom = $('td.itemprop',this).find('a').find('span')
let playedBy = playedByDom.text()
photos.push({
nmId,
chId,
name,
playedBy
})
})
console.log('共拿到 ' + photos.length + ' 条数据')

获取到原始数据后,通过ramda对数据进行处理,对nmId和chId进行正则比配,最后将数据写入imdb.json内。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const fn = R.compose(
RR.map(photo => {
let reg1 = /\/name\/(.*?)\/\?ref/
let match1 = photo.nmId.match(reg1)
let str = photo.chId.split('/')[4]
if(str){
str = str.split('?')[0]
}
photo.nmId = match1[1]
photo.chId = str
return photo
}),
R.filter(photo => photo.playedBy && photo.name && photo.nmId && photo.chId)
)
photos = fn(photos)
console.log('清洗后,剩余 ' + photos.length + ' 条数据')
fs.writeFileSync('./imdb.json', JSON.stringify(photos, null, 2), 'utf8')

修改start.js文件,将启动文件修改成imdb.js

1
require('./server/crawler/imdb')

数据爬取完成,成功写入imdb.json,如图:

wx43

爬取API数据

设置爬取url,同步发送请求,将url作为参数传入

server/crawler/api.js

1
2
3
4
5
6
7
8
9
10
import rp from 'request-promise'
import _ from 'lodash'
import fs from 'fs'
let characters = []
export const getAPICharacters = async (page = 1) => {
const url = `https://www.anapioficeandfire.com/api/characters?page=${page}&pageSize=50`
let body = await rp(url)
}
getAPICharacters()

从第一页开始爬取数据,对爬取到的数据进行解析拼接

1
2
body = JSON.parse(body)
characters = _.union(characters, body)

判断拿到的数据长度是否小于50,如果是,执行console.log('爬完了');反之,将数据写入character.json文件中,每隔1秒执行一次,page++,继续调用getAPICharacters(page)

1
2
3
4
5
6
7
8
if (body.length < 50) {
console.log('爬完了')
} else {
fs.writeFileSync('./character.json', JSON.stringify(characters, null, 2), 'utf8')
await sleep(1000)
page++
getAPICharacters(page)
}

修改start.js文件,将启动文件修改成api.js

1
require('./server/crawler/api')

爬取成功,如图:

爬取API数据

校对数据

在imdb上爬到的数据与在API上爬到的数据有所不同,以API里的数据为基准来校对从imdb上爬到的数据,如果某个数据不符合条件就过滤掉,通过imdb数据对比API数据最后筛选出正确的数据。新建校对文件。

server/crawler/check.js

拿到角色数据和imdb数据

1
2
const characters = require(resolve(__dirname, '../../characters.json'))
const IMDbData = require(resolve(__dirname, '../../imdb.json'))

通过name和playedBy对传入的每条数据进行对比

1
2
3
const validData = R.filter(
i => findNameInAPI(i) && findPlayedByInAPI(i)
)

查找name和playedBy这两个参数是否存在

1
2
3
4
5
6
7
8
9
10
const findNameInAPI = (item) => {
return find(characters, {
name: item.name
})
}
const finPlayedByInAPI = (item) => {
return find(characters, i => {
return i.playedBy.includes(item.playedBy)
})
}

验证IMDbData获取新的数据,将新数据写入wikiCharacters.json文件里。

1
2
const IMDb = validData(IMDbData)
fs.writeFileSync('./wikiCharacters.json', JSON.stringify(IMDb, null, 2), 'utf8')

修改start.js文件

1
require('./server/crawler/check')

校对后的正确数据写入wikiCharacters.json,如图:

wx44

爬取人物头像

获取到characters的数据,然后遍历,判断characters是否存在profile字段,构建请求地址,执行爬取IMDbProfile函数获取profile,将最后的数据写入imdbCharacters.json文件里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
export const fetchIMDbProfile = async (url) => {
const options = {
uri: url,
transform: body => cheerio.load(body)
}
const $ = await rp(options)
const img = $('a.poster img')
let src = img.attr('src')
if (src) {
src = src.split('_V1').shift()
src += '_V1.jpg'
}
}
export const getIMDbProfile = async () => {
const characters = require(__dirname, './wikiCharacters.json')
for (let i = 0; i < characters.length; i++) {
if(!characters[i].profile) {
const url = `https://www.imdb.com/title/tt0944947/characters/${characters[i].chId}`
console.log('正在爬取 ' + characters[i].name)
const src = await fetchIMDbProfile(url)
console.log('已经爬到 ' + src)
characters[i].profile = src
fs.writeFileSync('./imdbCharacters.json', JSON.stringify(characters, null, 2), 'utf8')
await sleep(500)
}
}
}
getIMDbProfile()

头像数据爬取完成,成功写入imdbCharacters.json,不再截图了。

检查数据是否包含头像

拿到characters的数据然后遍历,如果数据包含profile字段就放进chaaracters里,将新数据写入validCharacters.json。

1
2
3
4
5
6
7
8
9
10
11
12
13
const checkIMDbProfile = () => {
const characters = require(__dirname, './imdbCharacters.json')
const newCharacters = []
characters.forEach((item) => {
if (item.profile) {
newCharacters.push(item)
}
})
fs.writeFileSync('./validCharacters.json', JSON.stringify(newCharacters, null, 2), 'utf8')
}
checkIMDbProfile()

爬取角色剧照

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const fetchIMDbImage = async (url) => {
const options = {
uri: url,
transform: body => cheerio.load(body)
}
const $ = await rp(options)
let images = []
$('div.titlecharacters-image-grid a img').each(() => {
let src = $(this).attr('src')
if (src) {
src = src.split('_V1').shift()
srt += '_V1.jpg'
images.push(src)
}
})
return images
}
export const getIMDbImages = async () => {
const characters = require(__dirname, './wikiCharacters.json')
for (let i = 0; i < characters.length; i++) {
if (!characters[i].images) {
const url = `https://www.imdb.com/title/tt0944947/characters/${characters[i].chId}?ref_=ttfc_fc_cl_t1#quotes`
console.log('正在爬取 ' + characters[i].name)
const images = await fetchIMDbImage(url)
console.log('已经爬到 ' + images.length)
characters[i].images = images
fs.writeFileSync('./fullCharacters.json', JSON.stringify(characters, null, 2), 'utf8')
}
}
}
getIMDbImages()

爬取中文人物数据

遍历本地imdb的人物数据拿到id,根据id查询详细数据,将详细数据存入本地后进行筛选。

获取id

定义查询的字段和url,通过request-promise发出请求获取数据,将获取到的数据进行转换合并处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const getWikiId = async data => {
const query = data.cname || data.name
const url = `http://zh.asoiaf.wikia.com/api/v1/Search/List?query=${encodeURI(query)}`
let res
try {
res = await rp(url)
} catch (e) {
console.log(e)
}
res = JSON.parse(res)
res = res.items[0]
console.log(res.id)
return R.merge(data, res)
}

获取详情

声明id及url,发出请求获取数据。通过ramda对数据进行整合。

1
2
3
4
5
6
7
8
9
10
11
const getWikiDetail = async data => {
const { id } = data
const url = `http://zh.asoiaf.wikia.com/api/v1/Articles/AsSimpleJson?id=${id}`
let res
try {
res = await rp(url)
} catch (e) {
console.log(e)
}
res = JSON.parse(res)
}

获取cname和intro

先拿到sections,接着筛选出level为1的数据,将筛选后的第一条数据拿出来,挑选出title和content参数,然后遍历拿出的参数。

1
2
3
4
5
6
7
8
9
10
const getCNameAndIntro = R.compose(
i => ({
cname: i.title,
intro: R.map(R.prop(['text']))(i.content)
}),
R.pick(['title', 'content']),
R.nth(0),
R.filter(R.propEq('level', 1)),
R.prop('sections')
)

获取level

拿到sections筛选出所有内容不为空的数据,将title为扩展阅读和引用与注释的数据去除,然后将剩下的数据组织起来。

1
2
3
4
5
6
7
const getLevel = R.compose(
R.project(['title', 'content', 'level']),
R.reject(R.propEq('title', '扩展阅读')),
R.reject(R.propEq('title', '引用与注释')),
R.filter(i => i.content.lenght),
R.prop('sections')
)

拿到所有数据后返回

1
2
3
4
5
6
7
8
const cnameIntro = getCNameAndIntro(res)
let sections = getLevel(res)
let body = R.merge(data, getCNameAndIntro(res))
sections = normalizedSections(sections)
body.sections = sections
body.wikiId = id
return R.pick(['name', 'cname', 'playedBy', 'profile', 'images', 'nmId', 'chId', 'sections', 'intro', 'wikiId', 'words'], body)

获取WikiCharacters

引入清理后的数据data,通过data遍历每条数据的id,通过Promise.all(data)发出所有的异步请求,获取wiki id和wiki 详情数据,将这些数据写入finalCharacters.json。最后调用getWikiCharacters()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export const getWikiCharacters = async () => {
let data = require(resolve(__dirname, '../../fullCharacters.json'))
console.log(data.lenght)
data = R.map(getWikiId, data)
data = await Promise.all(data)
console.log('获取 wiki Id')
console.log(data[0])
data = R.map(getWikiDetail, data)
data = await Promise.all(data)
console.log('获取 wiki 详细资料')
console.log(data[0])
fs.writeFileSync('./finalCharacters.json', JSON.stringify(data, null, 2), 'utf8')
}

修改入口文件start.js

1
require('./server/crawler/wiki')

将头像和封面图上传到七牛云

在七牛云上新建个存储空间,会默认分配一个测试域名,使用这个域名就可以。接下来就可以上传图片了。上传图片之前需要把你的七牛云的AK和SK配置到本地。

修改server/config/index.js

1
2
3
4
qiniu: {
AK: '你的七牛云AK',
SK: '你的七牛云SK'
}

上传图片工具函数

上传七牛云是需要ak和sk,可能其他地方也需要用到上传图片,就单独封装七牛云函数

server/lib/qiniu.js

1
2
3
4
5
6
7
8
9
10
11
const bucket = 'kaka'
export const fetchImage = async (url, key) => {
return new Promise((resolve, reject) => {
const bash = `qshell fetch ${url} ${bucket} '${key}'`
const child = exec(bash, {async: true})
child.stdout.on('data', data => {
console.log(data)
resolve(data)
})
})
}

拼接shell脚本qshell fetch ${url} ${bucket} '${key}',设置异步执行脚本exec(bash, {async: true}),最后标准输出数据data。

从IMDb上获取图片

引入IMDbCharacters数据遍历它。

server/crawler/wiki.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
export const fetchImageFromIMDb = async () => {
let IMDbCharacters = require(resolve(__dirname, '../../finalCharacters.json'))
IMDbCharacters = R.map(async item => {
try {
if (item.profile.indexOf('http') === -1) {
let key = `${item.nmId}/${randomToken(32)}`
await fetchImage(item.profile, key)
console.log(key)
console.log(item.profile)
console.log('upload done!')
item.profile = key
}
for (let i = 0; i < item.images.length; i++) {
let _key = `${item.nmId}/${randomToken(32)}`
await fetchImage(item.images[i], _key)
console.log(_key)
console.log(item.images[i])
await(100)
item.images[i] = _key
}
} catch (e) {
console.log(e)
}
return item
})(IMDbCharacters)
IMDbCharacters = await Promise.all(IMDbCharacters)
fs.writeFileSync('./completeCharacters.json', JSON.stringify(IMDbCharacters, null, 2), 'utf8')
}

上传图片

声明文件名key,上传图片出入参数profile和key fetchImage(item.profile, key),把item.profile的值设置为key。

1
2
3
4
5
6
7
8
if (item.profile.indexOf('http') === -1) {
let key = `${item.nmId}/${randomToken(32)}`
await fetchImage(item.profile, key)
console.log(key)
console.log(item.profile)
console.log('upload done!')
item.profile = key
}

上传剧照

和上传图片的步骤一样,最后将item.images[i]的值设置为_key

1
2
3
4
5
6
7
8
9
for (let i = 0; i < item.images.length; i++) {
let _key = `${item.nmId}/${randomToken(32)}`
await fetchImage(item.images[i], _key)
console.log(_key)
console.log(item.images[i])
await(100)
item.images[i] = _key
}

将最后的数据写入completeCharacters.json文件中

1
2
IMDbCharacters = await Promise.all(IMDbCharacters)
fs.writeFileSync('./completeCharacters.json', JSON.stringify(IMDbCharacters, null, 2), 'utf8')

安装插件

运行项目之前记得安装所需要的插件

全局安装qshell

1
npm install qshell -g

安装qiniu、shelljs、random-token

1
2
3
yarn add qiniu
yarn add shelljs
yarn add random-token

安装完qshell就可以执行qshell配置命令

1
qshell account 你的七牛云AK 你的七牛云SK

运行项目

修改start.js

1
require('./server/crawler/wiki')