用node实现一个客户邮箱挖掘爬虫的一些思考

随着社会的进步,外贸行业也有了很大的变革,以前的外贸基本都是靠传统的方式比如展会,搜索引擎等渠道去获得客户信息,而现在外贸很多都是通过敦煌,速卖通,阿里巴巴等平台来销售自己的产品,销售再也不用费心的去找客户资源,然后开发,只需要上好产品然后坐等询盘。

但这样一个平台盛行的环境下,依然有很多公司依赖于传统的外贸形式而生存,比如汽车零部件,大型器械等等,对于这部分公司来说,客户资源依然是生存的根本,而展会获得的客户资源总归有限,而且参展是一笔不小的开销,所以通过搜索引擎来挖掘客户依然很重要。而对于客户挖掘一般的做法是通过人工,然后一个页面一个页面点开开,直到找到客户邮箱等信息,这样耗时耗力而且容易漏,本文的重点是介绍客户挖掘爬虫的原理,解放我们的双手,让机器为我们挖掘客户。

在正式开始编码之前,我们先回顾一下常规的人工挖掘过程:

1、打开Google,在搜索框输入关键词如water filter,点击搜索,会出来很多的搜索结果。

2、点开搜索结果中的链接,进入网站,看一下有没有客户邮箱等信息,有的话记录信息,如果没有找到继续浏览该网站的其它链接寻找客户信息,找到了就记录下来,如果当我们浏览了很多还没找到,说明没留,那就不找了。

3、回到搜索结果页重复上面的步骤接续下一个目标网站客户信息的查找。

4、汇总得到的客户信息

上面就是人工搜索的步骤,而对于程序来说步骤也是一样的,接下来我们看看程序的工作步骤。

1、调用Google的接口搜索关键词得到结果列表。

2、分析结果列表中第一页的所有链接,将链接提取放入待爬取列表中。

3、从待爬取列表取出一个链接,请求该链接,通过正则匹配页面上的邮箱信息,如果找到,则记录下来并继续取下一个待爬取的链接分析,如果没找到,提取当页同域的所有链接(又可能很多,所以可能需要选取一部分),然后加入待爬取列表,然后继续取下一个待爬取链接继续分析,但是这里需要注意由于没找到邮箱同一个域的其它链接只能被加入一次,否则会陷入死循环。

4、当第一页的所有链接分析完毕之后,继续上面的步骤分析下一页的数据,直到分析完结果列表的所有页。

5、汇总数据,并继续保存。

其实看了运行步骤,实现相当来说就比较简单了,这里只贴出关键代码:

google搜索方法:

const fs = require('fs-extra');
const path = require('path');
const fetch = require('./fetch');
const cheerio = require('cheerio');
const { joinPath } = require('./utils');

const { GOOGLE } = require('../constants/api');
const { ignoreedDomains } = require('../constants/strings');

const func = () => {};

function searchOnGoogle(params) {
  const { keywords = '', nextUrl = '', onSearch = func } = params;
  return new Promise((resolve, reject) => {
    const url = keywords ? `${GOOGLE}search?newwindow=1&safe=strict&source=hp&btnK=Google+%E6%90%9C%E7%B4%A2&q=${keywords.replace(/\s+/g, '+')}` : joinPath(GOOGLE, nextUrl);
    fetch(url)
      .then(body => {
        onSearch(url);
        const $ = cheerio.load(body);
        const $next = $('#pnnext');
        const $anchors = $('.rc a');
        const hasNextPage = $next && $next.length;
        const nextUrl = hasNextPage ? $next.attr('href') : '';
        if ($anchors && $anchors.length) {
          const links = [];
          $anchors.each((index, ele) => {
            const _this = $(ele);
            const href = _this.attr('href');
            let ignoreedReg = new RegExp(ignoreedDomains.join('|'), 'gi');
            if (_this.has('h3') && /^https?/.test(href) && !/search\?q/.test(href) && !ignoreedReg.test(href)) {
              links.push(href);
            }
            ignoreedReg = null;
          });
          resolve({
            links,
            nextUrl
          });
        } else {
          reject();
        }
      })
      .catch(err => {
        reject(err);
      })
  });
}

module.exports = {
  searchOnGoogle
};

其中fetch是请求链接的方法,代码如下:

const request = require('request');
const headers = require('./headers');

function fetch(url) {
  return new Promise((resolve, reject) => {
    request({
      url,
      headers,
      timeout: 10000,
    }, function(err, response, body) {
      if (err) {
        reject(err);
      }
      resolve(body);
    });
  });
}

module.exports = fetch;

joinPath方法是为了处理相对地址和绝对地址的一个工具,下面是代码:


其它的都是一些常量,就不具体说了,主流程代码:

const fs = require('fs-extra');
const { searchOnGoogle } = require('./lib/search');
const fetch = require('./lib/fetch');
const getKeywords = require('./lib/keywords');
const buildXlsx = require('./lib/xls');
const args = process.argv.slice(2);
const { checkIfHasEmail, getEmails, getLinks, checkHasOnlineFeedback, joinPath, getDomain, getFigures } = require('./lib/utils');

const LAST_FILE_PATH = './last.json';
const READED_FILE_PATH = './read.json';
const FIGURE_FILE_PATH = './figure.json';

let lastData = {};
let readedData = {};
let t = null;

function init() {
  let keywords = getKeywords();
  if (args[0] === 'reset') {
    start(keywords);
  } else {
    readLast()
      .then(data => {
        if (data.keyword) {
          console.log('\033[31mrestore to last search: ' + data.keyword + '\033[39m')
          lastData = data;
          const { keyword } = data;
          if (keyword) {
            const index = keywords.indexOf(keyword);
            if (index !== -1) {
              keywords = keywords.splice(index, 1);
            }
          }
        }
        start(keywords);
      })
      .catch(err => {
        start(keywords);
      });
  }
}

init();

function start(keywords) {
  doSearchOnGoogle(keywords, function(results) {
    console.log('all keywords grab complete!');
  });
}

function doSearchOnGoogle(keywords, callback) {
  let results = [];
  function search(keywords, callback) {
    const key = lastData.keyword ? lastData.keyword : (keywords.shift());
    let i = 0;
    function doSearch({keyword = '', nextUrl}) {
      if (keyword) {
        console.log('\033[31mstart search ' + keyword + ' on google\033[39m');
      }
      searchOnGoogle({
        keywords: keyword,
        nextUrl,
        onSearch: (nextUrl) => {
          writeLast({
            keyword: key,
            nextUrl
          });
        }
      }).then(({ links, nextUrl}) => {
        const filteredLinks = links.filter(l => {
          const domain = getDomain(l);
          return !readedData[domain];
        });
        if (links.length - filteredLinks.length) {
          console.log('\033[37mskiped ' + (links.length - filteredLinks.length) + ' domains!\033[39m');
        }
        if (filteredLinks.length) {
          analysis(filteredLinks, function(result) {
            i ++;
            let index = results.findIndex(m => m.name === key);
            if (index === -1) {
              results.push({
                name: key,
                data: []
              });
              index = results.length - 1;
            }
            const newResults = result.filter(m => {
              return m.emails.length || m.feedbackUrl;
            });
            const data = newResults.map(m => {
              const feedUrl = m.feedbackUrl || '';
              return [m.url, m.emails.join(','), feedUrl];
            });
            results[index].data = results[index].data.concat(data);
            function next() {
              t && clearTimeout(t);
              buildXlsx(results, getFilename())
                .then(() => {
                  console.log('\033[32m客户信息表更新成功!\033[39m');
                  console.log(`keywords ${key} page ${i} analysis complete!`);
                  if (nextUrl) {
                    doSearch({ nextUrl });
                  } else {
                    console.log(`keywords ${key} all page analysis complete!`);
                    search(keywords, callback);
                  }
                })
                .catch(err => {
                  console.log('\033[31m客户信息表更新失败,如果文件已打开,请关闭!5s后重试...\033[39m');
                  t = setTimeout(next, 5000);
                });
            }
            next();
          });
        } else {
          console.log(`skip a page!`);
          if (nextUrl) {
            doSearch({ nextUrl });
          } else {
            search(keywords, callback);
          }
        }
      })
      .catch(err => {
        console.log(err);
      });
    }

    if (lastData && lastData.nextUrl) {
      const { nextUrl } = lastData;
      lastData = {};
      doSearch( { nextUrl });
    } else {
      if (key) {
        doSearch({ keyword: key });
      } else {
        callback && callback(results);
      }
    }
  }
  search(keywords, callback);
}

function analysis(entryLinks, callback) {
  const copyEntryLinks = entryLinks.slice(0);
  const analysisResult = [];
  function analysisPage(links, callback) {
    const url = links.shift();
    let newLinks = links.slice(0);
    const isEntry = copyEntryLinks.some(m => m === url);
    if (url) {
      const domain = getDomain(url);
      console.log('analysis url', url, ' left url count: ', links.length);
      fetch(url)
        .then(body => {
          updateReadDomain(domain);
          let index = analysisResult.findIndex(m => m.url === domain);
          if (index === -1) {
            analysisResult.push({
              url: domain
            });
            index = analysisResult.length - 1;
          }
          if (!analysisResult[index].emails) {
            analysisResult[index].emails = [];
          }
          const hasEmail = checkIfHasEmail(body);
          const hasOnlineFeedback = checkHasOnlineFeedback(body);
          if (isEntry) {
            const pageLinks = getLinks(body, url);
            newLinks = newLinks.concat(pageLinks);
          }
          if (hasEmail) {
            const emails = getEmails(body);
            analysisResult[index].emails = [...new Set(analysisResult[index].emails.concat(emails))];
            newLinks = newLinks.filter(link => {
              return link.indexOf(domain) === -1;
            });
            // const figures = getFigures(url);
            // addFigures(figures);
          }
          if (hasOnlineFeedback) {
            analysisResult[index].feedbackUrl = url;
          }
          analysisPage(sortLinks([...new Set(newLinks)]), callback);
        })
        .catch(err => {
          console.log(err);
          analysisPage([...new Set(newLinks)], callback);
          // throw new Error(`analysis page ${url} failed ${err.msg}`);
        });
    } else {
      callback && callback(analysisResult);
    }
  }
  analysisPage(entryLinks, callback);
}

function readLast() {
  return new Promise((resolve, reject) => {
    fs.readJSON(LAST_FILE_PATH, function(err, data) {
      if (err) {
        reject();
      } else {
        resolve(data);
      }
    });
  });
}

function writeLast(data) {
  return new Promise((resolve, reject) => {
    fs.writeJSON(LAST_FILE_PATH, data, function(err) {
      if (err) {
        reject();
      } else {
        resolve();
      }
    });
  });
}

function updateReadDomain(domain) {
  return new Promise((resolve, reject) => {
    readedData[domain] = 1;
    fs.writeJSON(READED_FILE_PATH, readedData, function(err) {
      if (err) {
        reject();
      } else {
        resolve();
      }
    });
  });
}

function getFilename() {
  const d = new Date();
  if (args[0] && args[0] !== 'reset') {
    return args[0];
  } else if (args[0] === 'reset' && args[1]) {
    return args[1];
  }
  return `客户信息 ${d.getFullYear()}-${d.getMonth()+1}-${d.getDate()}`;
}

function sortLinks(links) {
  const sFigures = fs.readJSONSync(FIGURE_FILE_PATH).filter(f => !!f);
  return links.sort((a, b) => {
    let reg = new RegExp(sFigures.join('|'));
    const ret = reg.test(b) ? 1 : -1;
    reg = null;
    return ret;
  });
}

function addFigures(figures) {
  const sFigures = fs.readJSONSync(FIGURE_FILE_PATH) || [];
  const filteredFigures = figures.filter(f => {
    let reg = new RegExp(sFigures.join('|'), 'gi');
    const isMatched = !reg.test(f);
    reg = null;
    return isMatched;
  });
  fs.writeJSONSync(FIGURE_FILE_PATH, [...new Set(sFigures.concat(filteredFigures))]);
}

process.on('uncaughtException', (err) => {
    console.log(err);
});

这里为了让分析更快,我们加入了包含找到邮箱地址链接的特征,比如关键字about,contact然后将所有带分析链接通过特征排序,找到邮箱地址后提出同域的所有链接,避免杯重复爬取。

所有的工具方法:

const cheerio = require('cheerio');
const { ignoreedDomains } = require('../constants/strings');

function checkIfHasEmail(content) {
  return /\w+@\w{2,}\.\w{2,}/.test(content);
}

function getEmails(content) {
  const matches = content.match(/\w+@\w{2,}(\.\w{2,})+/g);
  if (matches && matches.length) {
    const newMatches = matches.filter(m => {
      return !/(jpg|png|gif|jpeg|svg|bmp)/g.test(m) && !/domain\.com/.test(m);
    });
    return newMatches || [];
  }
  return '';
}

function getLinks(content, url) {
  const domain = getDomain(url);
  const $ = cheerio.load(content);
  const anchors = [].slice.call($('a'), 0);
  const links = [];
  anchors.forEach(item => {
    const _this = $(item);
    const href = (_this.attr('href') + '').replace(/#.+$/, '');
    let ignoreedReg = new RegExp(ignoreedDomains.join('|'), 'gi');
    if (href && href !== '/' && !/^[#jm]/i.test(href) && !ignoreedReg.test(href) && !/(jpg|png|gif|jpeg|svg|bmp|js)$/gi.test(href) && !/[~`$^]/.test(href) && !/(signup|signin|login|register|account)/gi.test(href)) {
      const realUrl = joinPath(url, href);
      if (!/^https?/.test(href)) {
        if (url !== realUrl) {
          links.push(realUrl);
        }
      } else {
        let reg = new RegExp(domain);
        if (reg.test(href) && domain !== href && domain !== `${href}/` && href !== url) {
          links.push(href);
        }
        reg = null;
      }
    }
    ignoreedReg = null;
  });
  return links.slice(0, 35);
}

function checkHasOnlineFeedback(content) {
  const $ = cheerio.load(content);
  const $inputs = [].slice.call($('input'), 0);
  const $submit = $('[type="submit"]');
  if ($submit && $submit.length) {
    let hasMailField = false;
    let hasNameField = false;
    let hasPhoneField = false;
    for (let i=0; i<$inputs.length; i++) {
      const item = $($inputs[i]);
      const name = item.attr('name');
      const id = item.attr('id');
      const className = item.attr('class');
      if (/mail/mgi.test(name) || /mail/mgi.test(id) || /mail/mgi.test(className)) {
        hasMailField = true;
      }
      if (/name/mgi.test(name) || /name/mgi.test(id) || /name/mgi.test(className)) {
        hasNameField = true;
      }
      if (/phone/mgi.test(name) || /phone/mgi.test(id) || /phone/mgi.test(className) || /tel/mgi.test(name) || /tel/mgi.test(id) || /tel/mgi.test(className)) {
        hasPhoneField = true;
      }
      if (hasMailField &amp;&amp; hasNameField &amp;&amp; hasPhoneField) {
        return true;
      }
    }
  }
  return false;
}

function joinPath(url, path) {
  if (/^https?/.test(path)) {
    return path;
  } else if (/^\/\//.test(path)) {
    return url.split('/')[0] + path;
  } else {
    const pathArr = url.split(/(?<!\/)\/(?!\/)/);
    if (path[0] === '/') {
      return pathArr[0] + path;
    } else {
      if (path.substr(0, 2) === './') {
        pathArr.pop();
        return pathArr.join('/') + '/' + path.substr(2);
      } else {
        const backArr = path.split('../');
        pathArr.length = pathArr.length - backArr.length;
        return pathArr.join('/') + '/' + backArr[backArr.length - 1];
      }
    }
  }
}

function getDomain(url) {
  return url.replace(/(?<=[a-z])\/.*/, '/');
}

function getFigures(url) {
  const arr = url.split('/');
  const length = arr.length;
  if (arr.length <= 3) {
    return [];
  } else {
    const fLength = (length - 3 < 2) ? 1 : 2;
    return arr.slice(length-fLength).map(item => {
      return item.replace(/\.((s?html?)|php|jsp|ep|aspx?|cgi)/gi, '');
    }).filter(m => {
      return !!m &amp;&amp; !/\d+/.test(m) &amp;&amp; m.length < 15 &amp;&amp; m.length > 7 &amp;&amp; !/pdf$/gi.test(m) &amp;&amp; !/\?/.test(m);
    });
  }
}


module.exports = {
  checkIfHasEmail,
  getEmails,
  getLinks,
  checkHasOnlineFeedback,
  joinPath,
  getDomain,
  getFigures
};

另外有些网站可能没留邮箱,但是我们依然想找到尽可能能联系到他的方式,所以这里还提取了所有包含在线反馈地址的链接记录了下来。

好了,本文到此就结束了,谢谢阅读!

  • 支付宝二维码 支付宝
  • 微信二维码 微信
相关文章