最近在学习nodejs爬虫技术,学了request模块,所以想着写一个自己的爬虫项目,研究了半天,最后选定indeed作为目标网站,通过爬取indeed的职位数据,然后开发一个自己的职位搜索引擎,目前已经上线了,虽然功能还是比较简单,但还是贴一下网址job search engine,证明一下这个爬虫项目是有用的。下面就来讲讲整个爬虫的思路。

确定入口页面

众所周知,爬虫是需要入口页面的,通过入口页面,不断的爬取链接,最后爬取完整个网站。在这个第一步的时候,就遇到了困难,一般来说都是选取首页和列表页作为入口页面的,但是indeed的列表页面做了限制,不能爬取完整的列表,顶多只能抓取前100页,但是这没有难倒我,我发现indeed有一个Browse Jobs 页面,通过这个页面,可以获取indeed按地区搜索和按类型搜索的所有列表。下面贴一下这个页面的解析代码。

start: async (page) => {
const host = URL.parse(page.url).hostname;
const tasks = [];
try {
const $ = cheerio.load(iconv.decode(page.con, 'utf-8'), { decodeEntities: false });
$('#states > tbody > tr > td > a').each((i, ele) => {
const url = URL.resolve(page.url, $(ele).attr('href'));
tasks.push({ _id: md5(url), type: 'city', host, url, done: 0, name: $(ele).text() });
});
$('#categories > tbody > tr > td > a').each((i, ele) => {
const url = URL.resolve(page.url, $(ele).attr('href'));
tasks.push({ _id: md5(url), type: 'category', host, url, done: 0, name: $(ele).text() });
});
const res = await global.com.task.insertMany(tasks, { ordered: false }).catch(() => {});
res && console.log(`${host}-start insert ${res.insertedCount} from ${tasks.length} tasks`);
return 1;
} catch (err) {
console.error(`${host}-start parse ${page.url} ${err}`);
return 0;
}
}

通过cheerio解析html内容,把按地区搜索和按类型搜索链接插入到数据库中。

爬虫架构

这里简单讲一下我的爬虫架构思路,数据库选用mongodb。每一个待爬取的页面存一条记录page,包含id,url,done,type,host等字段,id用md5(url)生成,避免重复。每一个type有一个对应的html内容解析方法,主要的业务逻辑都集中在这些解析方法里面,上面贴出来的代码就是例子。

爬取html采用request模块,进行了简单的封装,把callback封装成promise,方便使用async和await方式调用,代码如下。

const req = require('request');

const request = req.defaults({
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36'
},
timeout: 30000,
encoding: null
}); const fetch = (url) => new Promise((resolve) => {
console.log(`down ${url} started`);
request(encodeURI(url), (err, res, body) => {
if (res && res.statusCode === 200) {
console.log(`down ${url} 200`);
resolve(body);
} else {
console.error(`down ${url} ${res && res.statusCode} ${err}`);
if (res && res.statusCode) {
resolve(res.statusCode);
} else {
// ESOCKETTIMEOUT 超时错误返回600
resolve(600);
}
}
});
});

做了简单的反反爬处理,把user-agent改成电脑通用的user-agent,设置了超时时间30秒,其中encoding: null设置request直接返回buffer,而不是解析后的内容,这样的好处是如果页面是gbk或者utf-8编码,只要解析html的时候指定编码就行了,如果这里指定encoding: utf-8,则当页面编码是gbk的时候,页面内容会乱码。

request默认是回调函数形式,通过promise封装,如果成功,则返回页面内容的buffer,如果失败,则返回错误状态码,如果超时,则返回600,这些懂nodejs的应该很好理解。

完整的解析代码

const URL = require('url');
const md5 = require('md5');
const cheerio = require('cheerio');
const iconv = require('iconv-lite'); const json = (data) => {
let res;
try {
res = JSON.parse(data);
} catch (err) {
console.error(err);
}
return res;
}; const rules = [
/\/jobs\?q=.*&sort=date&start=\d+/,
/\/jobs\?q=&l=.*&sort=date&start=\d+/
]; const fns = { start: async (page) => {
const host = URL.parse(page.url).hostname;
const tasks = [];
try {
const $ = cheerio.load(iconv.decode(page.con, 'utf-8'), { decodeEntities: false });
$('#states > tbody > tr > td > a').each((i, ele) => {
const url = URL.resolve(page.url, $(ele).attr('href'));
tasks.push({ _id: md5(url), type: 'city', host, url, done: 0, name: $(ele).text() });
});
$('#categories > tbody > tr > td > a').each((i, ele) => {
const url = URL.resolve(page.url, $(ele).attr('href'));
tasks.push({ _id: md5(url), type: 'category', host, url, done: 0, name: $(ele).text() });
});
const res = await global.com.task.insertMany(tasks, { ordered: false }).catch(() => {});
res && console.log(`${host}-start insert ${res.insertedCount} from ${tasks.length} tasks`);
return 1;
} catch (err) {
console.error(`${host}-start parse ${page.url} ${err}`);
return 0;
}
}, city: async (page) => {
const host = URL.parse(page.url).hostname;
const tasks = [];
const cities = [];
try {
const $ = cheerio.load(iconv.decode(page.con, 'utf-8'), { decodeEntities: false });
$('#cities > tbody > tr > td > p.city > a').each((i, ele) => {
// https://www.indeed.com/l-Charlotte,-NC-jobs.html
let tmp = $(ele).attr('href').match(/l-(?<loc>.*)-jobs.html/u);
if (!tmp) {
tmp = $(ele).attr('href').match(/l=(?<loc>.*)/u);
}
const { loc } = tmp.groups;
const url = `https://www.indeed.com/jobs?l=${decodeURIComponent(loc)}&sort=date`;
tasks.push({ _id: md5(url), type: 'search', host, url, done: 0 });
cities.push({ _id: `${$(ele).text()}_${page.name}`, parent: page.name, name: $(ele).text(), url });
});
let res = await global.com.city.insertMany(cities, { ordered: false }).catch(() => {});
res && console.log(`${host}-city insert ${res.insertedCount} from ${cities.length} cities`); res = await global.com.task.insertMany(tasks, { ordered: false }).catch(() => {});
res && console.log(`${host}-city insert ${res.insertedCount} from ${tasks.length} tasks`);
return 1;
} catch (err) {
console.error(`${host}-city parse ${page.url} ${err}`);
return 0;
}
}, category: async (page) => {
const host = URL.parse(page.url).hostname;
const tasks = [];
const categories = [];
try {
const $ = cheerio.load(iconv.decode(page.con, 'utf-8'), { decodeEntities: false });
$('#titles > tbody > tr > td > p.job > a').each((i, ele) => {
const { query } = $(ele).attr('href').match(/q-(?<query>.*)-jobs.html/u).groups;
const url = `https://www.indeed.com/jobs?q=${decodeURIComponent(query)}&sort=date`;
tasks.push({ _id: md5(url), type: 'search', host, url, done: 0 });
categories.push({ _id: `${$(ele).text()}_${page.name}`, parent: page.name, name: $(ele).text(), url });
});
let res = await global.com.category.insertMany(categories, { ordered: false }).catch(() => {});
res && console.log(`${host}-category insert ${res.insertedCount} from ${categories.length} categories`); res = await global.com.task.insertMany(tasks, { ordered: false }).catch(() => {});
res && console.log(`${host}-category insert ${res.insertedCount} from ${tasks.length} tasks`);
return 1;
} catch (err) {
console.error(`${host}-category parse ${page.url} ${err}`);
return 0;
}
}, search: async (page) => {
const host = URL.parse(page.url).hostname;
const tasks = [];
const durls = [];
try {
const con = iconv.decode(page.con, 'utf-8');
const $ = cheerio.load(con, { decodeEntities: false });
const list = con.match(/jobmap\[\d+\]= {.*}/g);
const jobmap = [];
if (list) {
// eslint-disable-next-line no-eval
list.map((item) => eval(item));
}
for (const item of jobmap) {
const cmplink = URL.resolve(page.url, item.cmplnk);
const { query } = URL.parse(cmplink, true);
let name;
if (query.q) {
// eslint-disable-next-line prefer-destructuring
name = query.q.split(' #')[0].split('#')[0];
} else {
const tmp = cmplink.match(/q-(?<text>.*)-jobs.html/u);
if (!tmp) {
// eslint-disable-next-line no-continue
continue;
}
const { text } = tmp.groups;
// eslint-disable-next-line prefer-destructuring
name = text.replace(/-/g, ' ').split(' #')[0];
}
const surl = `https://www.indeed.com/cmp/_cs/cmpauto?q=${name}&n=10&returnlogourls=1&returncmppageurls=1&caret=8`;
const burl = `https://www.indeed.com/viewjob?jk=${item.jk}&from=vjs&vjs=1`;
const durl = `https://www.indeed.com/rpc/jobdescs?jks=${item.jk}`;
tasks.push({ _id: md5(surl), type: 'suggest', host, url: surl, done: 0 });
tasks.push({ _id: md5(burl), type: 'brief', host, url: burl, done: 0 });
durls.push({ _id: md5(durl), type: 'detail', host, url: durl, done: 0 });
}
$('a[href]').each((i, ele) => {
const tmp = URL.resolve(page.url, $(ele).attr('href'));
const [url] = tmp.split('#');
const { path, hostname } = URL.parse(url);
for (const rule of rules) {
if (rule.test(path)) {
if (hostname == host) {
// tasks.push({ _id: md5(url), type: 'list', host, url: decodeURI(url), done: 0 });
}
break;
}
}
}); let res = await global.com.task.insertMany(tasks, { ordered: false }).catch(() => {});
res && console.log(`${host}-search insert ${res.insertedCount} from ${tasks.length} tasks`); res = await global.com.task.insertMany(durls, { ordered: false }).catch(() => {});
res && console.log(`${host}-search insert ${res.insertedCount} from ${durls.length} tasks`); return 1;
} catch (err) {
console.error(`${host}-search parse ${page.url} ${err}`);
return 0;
}
}, suggest: async (page) => {
const host = URL.parse(page.url).hostname;
const tasks = [];
const companies = [];
try {
const con = page.con.toString('utf-8');
const data = json(con);
for (const item of data) {
const id = item.overviewUrl.replace('/cmp/', '');
const cmpurl = `https://www.indeed.com/cmp/${id}`;
const joburl = `https://www.indeed.com/cmp/${id}/jobs?clearPrefilter=1`;
tasks.push({ _id: md5(cmpurl), type: 'company', host, url: cmpurl, done: 0 });
tasks.push({ _id: md5(joburl), type: 'jobs', host, url: joburl, done: 0 });
companies.push({ _id: id, name: item.name, url: cmpurl });
} let res = await global.com.company.insertMany(companies, { ordered: false }).catch(() => {});
res && console.log(`${host}-suggest insert ${res.insertedCount} from ${companies.length} companies`); res = await global.com.task.insertMany(tasks, { ordered: false }).catch(() => {});
res && console.log(`${host}-suggest insert ${res.insertedCount} from ${tasks.length} tasks`);
return 1;
} catch (err) {
console.error(`${host}-suggest parse ${page.url} ${err}`);
return 0;
}
}, // list: () => {}, jobs: async (page) => {
const host = URL.parse(page.url).hostname;
const tasks = [];
const durls = [];
try {
const con = iconv.decode(page.con, 'utf-8');
const tmp = con.match(/window._initialData=(?<text>.*);<\/script><script>window._sentryData/u);
let data;
if (tmp) {
const { text } = tmp.groups;
data = json(text);
if (data.jobList && data.jobList.pagination && data.jobList.pagination.paginationLinks) {
for (const item of data.jobList.pagination.paginationLinks) {
// eslint-disable-next-line max-depth
if (item.href) {
item.href = item.href.replace(/\u002F/g, '/');
const url = URL.resolve(page.url, decodeURI(item.href));
tasks.push({ _id: md5(url), type: 'jobs', host, url: decodeURI(url), done: 0 });
}
}
}
if (data.jobList && data.jobList.jobs) {
for (const job of data.jobList.jobs) {
const burl = `https://www.indeed.com/viewjob?jk=${job.jobKey}&from=vjs&vjs=1`;
const durl = `https://www.indeed.com/rpc/jobdescs?jks=${job.jobKey}`;
tasks.push({ _id: md5(burl), type: 'brief', host, url: burl, done: 0 });
durls.push({ _id: md5(durl), type: 'detail', host, url: durl, done: 0 });
}
}
} else {
console.log(`${host}-jobs ${page.url} has no _initialData`);
}
let res = await global.com.task.insertMany(tasks, { ordered: false }).catch(() => {});
res && console.log(`${host}-search insert ${res.insertedCount} from ${tasks.length} tasks`); res = await global.com.task.insertMany(durls, { ordered: false }).catch(() => {});
res && console.log(`${host}-search insert ${res.insertedCount} from ${durls.length} tasks`); return 1;
} catch (err) {
console.error(`${host}-jobs parse ${page.url} ${err}`);
return 0;
}
}, brief: async (page) => {
const host = URL.parse(page.url).hostname;
try {
const con = page.con.toString('utf-8');
const data = json(con);
data.done = 0;
data.views = 0;
data.host = host;
// format publish date
if (data.vfvm && data.vfvm.jobAgeRelative) {
const str = data.vfvm.jobAgeRelative;
const tmp = str.split(' ');
const [first, second] = tmp;
if (first == 'Just' || first == 'Today') {
data.publishDate = Date.now();
} else {
const num = first.replace(/\+/, '');
if (second == 'hours') {
const date = new Date();
const time = date.getTime();
// eslint-disable-next-line no-mixed-operators
date.setTime(time - num * 60 * 60 * 1000);
data.publishDate = date.getTime();
} else if (second == 'days') {
const date = new Date();
const time = date.getTime();
// eslint-disable-next-line no-mixed-operators
date.setTime(time - num * 24 * 60 * 60 * 1000);
data.publishDate = date.getTime();
} else {
data.publishDate = Date.now();
}
}
}
await global.com.job.updateOne({ _id: data.jobKey }, { $set: data }, { upsert: true }).catch(() => { }); const tasks = [];
const url = `https://www.indeed.com/jobs?l=${data.jobLocationModel.jobLocation}&sort=date`;
tasks.push({ _id: md5(url), type: 'search', host, url, done: 0 });
const res = await global.com.task.insertMany(tasks, { ordered: false }).catch(() => {});
res && console.log(`${host}-brief insert ${res.insertedCount} from ${tasks.length} tasks`);
return 1;
} catch (err) {
console.error(`${host}-brief parse ${page.url} ${err}`);
return 0;
}
}, detail: async (page) => {
const host = URL.parse(page.url).hostname;
try {
const con = page.con.toString('utf-8');
const data = json(con);
const [jobKey] = Object.keys(data);
await global.com.job.updateOne({ _id: jobKey }, { $set: { content: data[jobKey], done: 1 } }).catch(() => { });
return 1;
} catch (err) {
console.error(`${host}-detail parse ${page.url} ${err}`);
return 0;
}
}, run: (page) => {
if (page.type == 'list') {
page.type = 'search';
}
const fn = fns[page.type];
if (fn) {
return fn(page);
}
console.error(`${page.url} parser not found`);
return 0;
} }; module.exports = fns;

每一个解析方法都会插入一些新的链接,新的链接记录都会有一个type字段,通过type字段,可以知道新的链接的解析方法,这样就能完整解析所有的页面了。例如start方法会插入type为city和category的记录,type为city的页面记录的解析方法就是city方法,city方法里面又会插入type为search的链接,这样一直循环,直到最后的brief和detail方法分别获取职位数据的简介和详细内容。

其实爬虫最关键的就是这些html解析方法,有了这些方法,你就能获取任何想要的结构化内容了。

数据索引

这部分就很简单了,有了前面获取的结构化数据,按照elasticsearch,新建一个schema,然后写个程序定时把职位数据添加到es的索引里面就行了。因为职位详情的内容有点多,我就没有把content字段添加到索引里面了,因为太占内存了,服务器内存不够用了,>_<。

DEMO

最后还是贴上网址供大家检阅,job search engine

爬虫黑科技,我是怎么爬取indeed的职位数据的的更多相关文章

  1. python3下scrapy爬虫(第八卷:循环爬取网页多页数据)

    之前我们做的数据爬取都是单页的现在我们来讲讲多页的 一般方式有两种目标URL循环抓取 另一种在主页连接上找规律,现在我用的案例网址就是 通过点击下一页的方式获取多页资源 话不多说全在代码里(因为刚才写 ...

  2. python爬虫实践(二)——爬取张艺谋导演的电影《影》的豆瓣影评并进行简单分析

    学了爬虫之后,都只是爬取一些简单的小页面,觉得没意思,所以我现在准备爬取一下豆瓣上张艺谋导演的“影”的短评,存入数据库,并进行简单的分析和数据可视化,因为用到的只是比较多,所以写一篇博客当做笔记. 第 ...

  3. 爬虫学习(二)--爬取360应用市场app信息

    欢迎加入python学习交流群 667279387 爬虫学习 爬虫学习(一)-爬取电影天堂下载链接 爬虫学习(二)–爬取360应用市场app信息 代码环境:windows10, python 3.5 ...

  4. 【Python爬虫案例】用Python爬取李子柒B站视频数据

    一.视频数据结果 今天是2021.12.7号,前几天用python爬取了李子柒的油管评论并做了数据分析,可移步至: https://www.cnblogs.com/mashukui/p/1622025 ...

  5. 一起学爬虫——使用selenium和pyquery爬取京东商品列表

    layout: article title: 一起学爬虫--使用selenium和pyquery爬取京东商品列表 mathjax: true --- 今天一起学起使用selenium和pyquery爬 ...

  6. 爬虫(二)Python网络爬虫相关基础概念、爬取get请求的页面数据

    什么是爬虫 爬虫就是通过编写程序模拟浏览器上网,然后让其去互联网上抓取数据的过程. 哪些语言可以实现爬虫    1.php:可以实现爬虫.php被号称是全世界最优美的语言(当然是其自己号称的,就是王婆 ...

  7. Python网络爬虫第三弹《爬取get请求的页面数据》

    一.urllib库 urllib是Python自带的一个用于爬虫的库,其主要作用就是可以通过代码模拟浏览器发送请求.其常被用到的子模块在Python3中的为urllib.request和urllib. ...

  8. 爬虫实战(二) 用Python爬取网易云歌单

    最近,博主喜欢上了听歌,但是又苦于找不到好音乐,于是就打算到网易云的歌单中逛逛 本着 "用技术改变生活" 的想法,于是便想着写一个爬虫爬取网易云的歌单,并按播放量自动进行排序 这篇 ...

  9. 爬虫系列(十三) 用selenium爬取京东商品

    这篇文章,我们将通过 selenium 模拟用户使用浏览器的行为,爬取京东商品信息,还是先放上最终的效果图: 1.网页分析 (1)初步分析 原本博主打算写一个能够爬取所有商品信息的爬虫,可是在分析过程 ...

随机推荐

  1. JSONobject按照put顺序存储和读取

    new的时候加true即可: JSONObject jsonObject = new JSONObject(true);

  2. 使用torch实现RNN

    (本文对https://blog.csdn.net/out_of_memory_error/article/details/81456501的结果进行了复现.) 在实验室的项目遇到了困难,弄不明白LS ...

  3. C语言宏技巧 X宏

    前言 本文介绍下X宏的使用 首先简单介绍下宏的几种用法 #define STRCAT(X,Y) X##Y #define _STR(X) #@X #define STR(X) #X #define L ...

  4. 分享2个近期遇到的MySQL数据库的BUG案例

    近一个月处理历史数据问题时,居然连续遇到了2个MySQL BUG,分享给大家一下,也欢迎指正是否有问题. BUG1: 数据库版本:  MySQL5.7.25 - 28 操作系统: Centos 7.7 ...

  5. 解决:Invalid character found in the request target.The valid characters are defined in RFC 7230 and RF

    背景 在将tomcat升级到7.0.81版后,发现系统的有些功能不能使用了,查询日志发现是有些地址直接被tomcat认为存在不合法字符,返回HTTP 400错误响应,错入信息如下: 原因分析 经了解, ...

  6. android自定义控件onMeasure方法

    1.自定义控件首先定义一个类继承View 有时,Android系统控件无法满足我们的需求,因此有必要自定义View.具体方法参见官方开发文档:http://developer.android.com/ ...

  7. 00【笔记】 Shiro登陆过滤提示信息

    Shiro登陆过滤 提示信息 package top.yangbuyi.system.shiro; import com.alibaba.fastjson.JSONObject; import org ...

  8. 02 [掌握] redis详情命令

    1,常用命令 keys * 获取所有的key select 0 选择第一个库 move myString 1 将当前的数据库key移动到某个数据库,目标库有,则不能移动 flushdb 清除指定库 r ...

  9. script写在head与写在body中的区别

    咱先说将Javascript写在head里面的情况吧,如果你要在这里面去操控DOM元素,是会报错的,因为浏览器是先执行head标签里面的内容,在执行时你的DOM元素还没有生成.(使用了windows. ...

  10. 深入理解RocketMQ(四)--消息存储

    一.MQ存储分类 MQ存储主要分为以下三类: 文件系统:RocketMQ/Kafka/RabbitMQ 关系型数据库DB:ActiveMQ(默认采用的KahaDB做消息存储)可选用JDBC的方式来做消 ...