Введение

В наши дни все дело в конфиденциальности пользователей, доступ API ко всем платформам ограничен и заблокирован, так что разработчики больше не могут этим злоупотреблять.

Это, конечно, работает хорошо, но есть еще одна вещь, которую, я думаю, они упустили: краулеры! Подумайте только, что такое API? API означает «интерфейс прикладного программирования» и позволяет программе легко обмениваться данными с имеющимися у нее данными на основе интерфейса. Но что делает краулер? Определение сканера таково: «Поисковые системы и некоторые другие сайты используют программное обеспечение для сканирования или поиска в Интернете для обновления своего веб-контента или индексов веб-контента других сайтов. Веб-сканеры копируют страницы для обработки поисковой системой, которая индексирует загруженные страницы, чтобы пользователи могли выполнять поиск более эффективно ». Вы заметили, что часть выделена жирным шрифтом? Мы также можем относиться к этому как к размещению слоя поверх наших данных, чтобы упростить поиск по ним - звучит близко к API, верно?

Но как мы можем это сделать эффективно? Поскольку написание Crawler займет много времени (не говоря уже о вычислительной мощности, необходимой для этого) - я на самом деле написал его, поверьте, это не то, чем вы хотите заниматься. Как насчет того, чтобы использовать что-нибудь из «крупных игроков»?

Проведя небольшое исследование тех, которые всплывают в моей голове, мы сразу получили следующие результаты:

  • Google (5 $ за 1.000 запросов, ограничено 10.000 запросов в день) - ›Это слишком много и тоже ограничит нас
  • Microsoft Bing (3 доллара за 1000 транзакций на уровне S2, но я НЕ обнаружил никаких ограничений) - ›Думаю, мы нашли наш API

Если вас интересует только бизнес-часть, а не кодирование, это конечный результат, который мы создадим к концу этого поста :)

Создание сценария использования и создание нашего интерфейса API Bing

Давайте создадим для этого пример использования, чтобы создать демонстрационный скрипт. Как насчет написания инструмента, который сможет эффективно привлекать пользователей LinkedIn для данной компании, чтобы облегчить нашу жизнь для выявления ключевых людей в организации? Это похоже на то, что компания могла бы использовать, верно?

Примечание. На самом деле это то, что называется «Навигатором продаж» в LinkedIn, но, создав этот API, мы могли бы обогатить его Twitter и другими данными, чтобы сделать его более интеллектуальным;)

Быстрое написание сценария, использующего Bing Search API, дает нам:

const fetch = require('node-fetch');
// Based on https://github.com/Microsoft/BotFramework-Samples/blob/master/StackOverflow-Bot/StackBot/lib/bingsearchclient.js
class BingSearchClient {
    constructor(key) {
        if (!key) throw new Error('bingSearchKey is required');
    
        this.bingSearchKey = key;
        this.bingSearchCount = 50;
        this.bingSearchMkt = "en-us";
        this.bingSearchBaseUrl = "https://api.cognitive.microsoft.com/bing/v7.0/search";
        this.bingSearchMaxSearchStringSize = 150;
    }
    get(search, offset = 0, cb) {
        return new Promise((resolve, reject) => { 
            if (!search) throw new Error('Search text is required');
            cb = cb || (() => {});
        
            const searchText = search.substring(0, this.bingSearchMaxSearchStringSize).trim();
        
            const url = this.bingSearchBaseUrl + "?"
                + `q=${encodeURIComponent(searchText)}`
                + `&count=${this.bingSearchCount}`
                //+ `&mkt=${this.bingSearchMkt}`
                + `&offset=${offset}&responseFilter=Webpages&safesearch=Strict`;
            try {
                fetch(url, {
                    method: 'GET',
                    headers: {
                        "Ocp-Apim-Subscription-Key": this.bingSearchKey
                    }
                })
                .then((response) => response.json())
                .then((json) => resolve(json));
            } catch (e) {
                return reject(e);
            }
        })
    }   
}
module.exports = BingSearchClient;

Создание нашего LinkedIn Discovery Tool

Поскольку у нас есть наш API, который может взаимодействовать с Bing Web Search API, теперь мы можем выполнять запросы на нем. Один интересный запрос для наших результатов LinkedIn:

site:linkedin.com "${companyName} | LinkedIn"

Это будет проходить через Bing Engine и смотреть только на результаты нашего Linkedin.com веб-сайта. Затем, определяя "${companyName} | LinkedIn", мы «как бы» ограничиваем его желаемыми результатами.

Применение регулярных выражений к заголовкам и URL-адресам для фильтрации результатов даст нам следующие сведения о пользователях:

  • Имя
  • Имя пользователя
  • Роль
  • URL

Это уже хорошо, правда? У нас есть базовые данные, которые мы можем дополнить другими источниками, так что давайте построим их!

Я быстро создаю это вместе, используя redis в качестве queue и cache, что позволяет использовать механизм, который может быть запущен с помощью вызова REST для запуска фазы обнаружения (помещая его в очередь и обрабатывая его), а затем обновляя redis кеш когда это будет сделано.

Код, который делает все это:

const HashSetAPI = require('../../../API/HashSet');
const hashSet = new HashSetAPI();
const Queue = require('bull');
const discoverUsersLinkedInQueue = new Queue('discover-users-linkedin', 'redis://127.0.0.1:6379');
const config = require('../../../config');
const BingSearchClient = require('../../../API/BingSearchClient');
const bingSearchClient = new BingSearchClient(config.bingSearch.key);
const LIMIT_DUPLICATE_USERS_PER_PAGE = 30;
const LIMIT_PAGE = 50; // Stop after page
const LIMIT_PAGE_STAGNATED = 5; // If we add 0 people for X pages, stop
/**
 * job: { name: <name> }
 */
discoverUsersLinkedInQueue.process(async (job, done) => {
  const companyName = job.data.name;
  // Get our previous object our init a new blank one
  let peopleMap = await hashSet.get(companyName);
  peopleMap = peopleMap || { 
    people: {}, 
    wrongResults: [], // Set of objects containing the wrong results for further analysis
    meta: { 
      isDone: false,
      offset: 0, 
      duplicatesOnPage: 0, 
      duplicatesTotal: 0, 
      peopleFoundOnPage: 0, 
      peopleFoundTotal: 0,
      page: 0, // Current page
      pageStagnated: 0,
    }
  } // Default peopleMap
  console.log(`[Discoverer] Starting discovery for ${companyName}, peopleFound: ${peopleMap.meta.peopleFoundTotal}, peopleFoundOnPage: ${peopleMap.meta.peopleFoundOnPage}, duplicates: ${peopleMap.meta.duplicatesTotal}, stagnated: ${peopleMap.meta.pageStagnated}`);
  // See if any of the limits were reached and if we should stop
  let shouldContinue = true;
  shouldContinue = !(peopleMap.meta.offset >= LIMIT_PAGE);
  shouldContinue = !(peopleMap.meta.duplicatesOnPage >= LIMIT_DUPLICATE_USERS_PER_PAGE);
  shouldContinue = !(peopleMap.meta.pageStagnated >= LIMIT_PAGE_STAGNATED);
  // @todo: add shouldContinue check for last date checked, this way records expire
  if (!shouldContinue) {
    console.log(`[Discoverer] Done discovering ${companyName}`);
    peopleMap.meta.isDone = true;
    await hashSet.set(companyName, peopleMap);
    return done();
  }
  // Download page
  const result = await bingSearchClient.get(`site:linkedin.com "${companyName} | LinkedIn"`, peopleMap.meta.offset);
  const parsed = await parseLinkedInPage(companyName, result.webPages.value, peopleMap);
  // Store results
  await hashSet.set(companyName, parsed);
  // Put back on queue for further exploration
  discoverUsersLinkedInQueue.add({ name: companyName, isExploring: true });
  console.log('[Discoverer] Adding result back for more exploration');
  done();
});
/**
 * 
 * @param {string} companyName 
 * @param {array} pages array of objects [ { name, url }, ... ]
 * @param {map} peopleMap a map of the current found people, gotten from our store looks like: { people: {}, wrongResults: [], meta: { offset, duplicates } }
 * @return {object} { people: {}, meta: { offset, duplicates } }
 */
const parseLinkedInPage = async (companyName, pages, peopleMap) => {
  const usernameRegex = new RegExp('linkedin.com\\/in\\/([a-zA-Z0-9\\-]*)', 'i'); // [1] gives username on LinkedIn from the URL
  // Filter pages that are as we wanted it (correct information)
  // This means we will loop over all results twice
  // --> @todo: is this needed? 
  let filteredResults = [];
  pages.forEach((i) => {
    // Check URL format
    if (!i.url.match(usernameRegex)) {
      peopleMap.wrongResults.push({
        title: i.name,
        url: i.url
      });
      return;
    }
    filteredResults.push(i);
  });
  let duplicates = 0;
  let peopleFound = 0;
  // Extract people and put them in peopleMap
  filteredResults.forEach((i) => {
    let { name, role } = extractNameRoleCompanyFromTitle(companyName, i.name);
    let { username } = extractUsernameFromUrl(i.url);
    // If no username found, show an error
    if (!name || !role || !username) {
        console.error(`[Discoverer] Undefined parameter found in ${JSON.stringify(i)}
        Name: ${name}
        Role: ${role}
        Username: ${username}
        `);
        return;
    }
    // If we already have this result, mark as duplicate, else store it
    if (peopleMap.people[username]) {
      duplicates++;
    } else {
      peopleFound++;
      peopleMap.people[username] = {
        username,
        name,
        role,
        url: i.url
      }
    }
  });
  return {
    people: peopleMap.people,
    wrongResults: peopleMap.wrongResults,
    meta: {
      duplicatesOnPage: duplicates,
      duplicatesTotal: peopleMap.meta.duplicatesTotal + duplicates,
      offset: peopleMap.meta.offset + pages.length,
      peopleFoundOnPage: peopleFound,
      peopleFoundTotal: Object.keys(peopleMap.people).length,
      page: peopleMap.meta.page + 1,
      pageStagnated: (peopleFound == 0) ? peopleMap.meta.pageStagnated + 1 : 0,
    }
  };
}
const extractNameRoleCompanyFromTitle = (companyName, webpageTitle) => {
  let name;
  let title;
  let role;
  // Extracts name and role from results
  const titleRegex = new RegExp(`^(.*) - (.*) - ${companyName} \\| LinkedIn$`, 'i'); // [1] gives name, [2] gives role
  if (titleRegex.test(webpageTitle)) {
    let res = titleRegex.exec(webpageTitle);
    name = res[1];
    role = res[2];
    return { name, role };
  }
  // Extracts name and role from results
  // NOTE: – is a special character!
  const titleRegex2 = new RegExp(`^(.*) – (.*) – ${companyName} \\| LinkedIn$`, 'i'); // [1] gives name, [2] gives role
  if (titleRegex2.test(webpageTitle)) {
    let res = titleRegex2.exec(webpageTitle);
    name = res[1];
    role = res[2];
    return { name, role };
  }
  // Extracts name and role from results as in:
  // Randy Street - Executive Director Product ... - LinkedIn
  // Keith Moody - Director, Analytics COE - LinkedIn
  const titleRegex3 = new RegExp(`(.*) - (.*) .* - LinkedIn`, 'i'); // [1] gives name, [2] gives role
  if (titleRegex3.test(webpageTitle)) {
    let res = titleRegex3.exec(webpageTitle);
    name = res[1];
    role = res[2];
    return { name, role };
  }
  // Extracts name and role from results as in:
  // Cameron Ahler - Executive Director, Enterprise ...
  // NOTE: This is a very broad matching, which is why the first already pre-return the result
  const titleRegex4 = new RegExp(`(.*) - (.*)\\s\\.\\.\\.`, 'i');
  if (titleRegex4.test(webpageTitle)) {
    let res = titleRegex4.exec(webpageTitle);
    name = res[1];
    role = res[2];
    return { name, role };
  }
  return {
    name,
    role
  }
}
const extractUsernameFromUrl = (url) => {
  const usernameRegex = new RegExp('linkedin.com\\/in\\/([a-zA-Z0-9\\-\\%]*)', 'i'); // [1] gives username on LinkedIn from the URL
  let username;
  if (usernameRegex.test(url)) {
    let res = usernameRegex.exec(url);
    username = res[1];
    return { username };
  }
  
  return {
    username
  }
};

Создание нашего API-сервера

Для сервера API мне нужна была облегченная система API, которая позволяла бы мне писать свой API в как можно меньшем количестве файлов, оставаясь при этом простой и экономичной. Поэтому после проверки некоторых фреймворков я выбрал «Koa.js».

Возможно, это можно было бы написать лучше, но, видя, сколько времени на это потрачено, я вполне доволен результатом :)

Server.js

const Koa = require('koa');
const indexRoutes = require('./routes/index');
const companyRoutes = require('./routes/company');
const app = new Koa();
const PORT = process.env.PORT || 3001;
app.use(indexRoutes.routes());
app.use(companyRoutes.routes());
const server = app.listen(PORT, () => {
  console.log(`[Server] Running on http://127.0.0.1:${PORT}`);
});

routes / company.js

const Router = require('koa-router');
const router = new Router();
const Queue = require('bull');
const discoverUsersLinkedInQueue = new Queue('discover-users-linkedin', 'redis://127.0.0.1:6379');
const Redis = require('../../API/Redis');
const redis = new Redis();
router.get('/company/:company/start', async (ctx) => {
  const companyName = ctx.params.company;
  discoverUsersLinkedInQueue.add({ name: companyName });
  ctx.body = {
    company: companyName,
    isStarted: true
  };
});
router.get('/company/:company/check-status', async (ctx) => {
  const companyName = ctx.params.company;
  let result = await redis.get(companyName);
  if (!result) {
    ctx.body = {
      isDone: false
    }
    return;
  }
  result = JSON.parse(result);
  ctx.body = {
    isDone: result && result.meta && result.meta.isDone
  }
});
router.get('/company/:company', async (ctx) => {
  const companyName = ctx.params.company;
  let result = await redis.get(companyName);
  if (!result) {
    ctx.status = 404;
    ctx.body = {
      message: 'result is undefined, start a new crawling process first'
    };
    return;
  }
  result = JSON.parse(result);
  if (!result.meta.isDone) {
    ctx.status = 404;
    ctx.body = {
      message: 'crawler is busy'
    };
    return;
  }
  
  ctx.body = result;
});
module.exports = router;

Создание нашего веб-интерфейса

Что касается внешнего интерфейса, то я выбрал мой самый любимый вариант: next.js, позволяющий мне быстро писать код response.js, отрендеренный сервером.

Помещение его в pages/company.js позволило мне быстро получить к нему доступ через интерфейс.

import fetch from 'isomorphic-unfetch';
import { withRouter } from 'next/router';
import React from 'react';
class Index extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      isLoaded: props.isLoaded,
      map: props.map
    };
  }
  render() {
    if (this.state.isLoaded) {
      return this.renderLoaded();
    } else {
      return this.renderNotLoaded();
    }
  }
  renderLoaded() {
    return (
      <div>
        <h1>{this.props.router.query.company} (found: {JSON.stringify(this.state.map.meta.peopleFoundTotal)})</h1>
        <table>
          <thead>
            <tr>
              <td>username</td>
              <td>name</td>
              <td>role</td>
              <td>url</td>
            </tr>
          </thead>
          <tbody>
          {Object.keys(this.state.map.people).map(key => {
            return (
              <tr>
                <td>{ key }</td>
                <td>{this.state.map.people[key].name}</td>
                <td>{this.state.map.people[key].role}</td>
                <td><a href={this.state.map.people[key].url}>{this.state.map.people[key].url}</a></td>
              </tr>
            )
          })}
          </tbody>
        </table>
      </div>
    )
  }
  renderNotLoaded() {
    // Quick and dirty check for loaded result
    const companyName = this.props.router.query.company;
    let int = setInterval(async () => {
      const res = await fetch(`http://127.0.0.1:3001/company/${companyName}/check-status`);
      const data = await res.json();
      if (data.isDone) {
        clearInterval(int);
        const res2 = await fetch(`http://127.0.0.1:3001/company/${companyName}`);
        const data2 = await res2.json();
        this.setState({
          isLoading: false,
          map: data2
        })
      }
    }, 2000);
    return (<div>Loading... (see network tab)</div>);
  }
}
Index.getInitialProps = async function (props) {
  const companyName = props.query.company;
  const res = await fetch(`http://127.0.0.1:3001/company/${companyName}`);
  // If not found, we need to poll, start the process in bg
  await fetch(`http://127.0.0.1:3001/company/${companyName}/start`);
  if (res.status != 404) {
    const data = await res.json();
    
    return {
      isLoaded: true,
      map: data
    }
  }
  return { map: {}, isLoading: true }
}
export default withRouter(Index);

Результат

При переходе на наш созданный веб-сайт через http://127.0.0.1:3000/company/facebook мы получаем список быстро очищаемых профилей, например:

Разное

HashSet.js

const redis = require('redis'); // Note: Should become a permanent storage later, now in memory
const redisClient = redis.createClient();
class HashSet {
  async get(key) {
    return new Promise((resolve, reject) => {
      redisClient.get(key, (err, reply) => {
        if (err) {
          throw err;
        }
        return resolve(JSON.parse(reply));
      });
    })
  }
  async set(key, val) {
    return new Promise((resolve, reject) => {
      // @TODO: CHECK IF TYPEOF OBJECT
      redisClient.set(key, JSON.stringify(val));
      return resolve();
    });
  }
}
module.exports = HashSet;

Redis.js

const redis = require('redis'); // Note: Should become a permanent storage later, now in memory
class Redis {
  constructor() {
    this.client = redis.createClient();
  }
  async get(key) {
    return new Promise((resolve, reject) => {
      this.client.get(key, (err, reply) => {
        if (err) {
          throw err;
        }
        return resolve(reply);
      });
    })
  }
}
module.exports = Redis;