Веб-скрапинг Google Maps Places с помощью Nodejs


Что будет соскабливаться

Подготовка

Во-первых, нам нужно создать проект Node.js* и добавить npm пакеты puppeteer, puppeteer-extra и puppeteer-extra-plugin-stealth для управления Chromium (или Chrome, или Firefox, но сейчас мы работаем только с Chromium, который используется по умолчанию) через протокол DevTools в режиме headless или non-headless.

Для этого в директории с нашим проектом откройте командную строку и введите npm init -y, а затем npm i puppeteer puppeteer-extra puppeteer-extra-plugin-stealth.

*Если у вас не установлен Node.js, вы можете загрузить его с сайта nodejs.org и следовать документации по установке.

📌Примечание: также вы можете использовать puppeteer без каких-либо расширений, но я настоятельно рекомендую использовать его с puppeteer-extra с puppeteer-extra-plugin-stealth для предотвращения обнаружения веб-сайтом того, что вы используете безголовый Chromium или что вы используете веб-драйвер. Вы можете проверить это на сайте тестов безголового Chrome. На скриншоте ниже показана разница.

Процесс

Расширение SelectorGadget Chrome использовалось для захвата селекторов CSS при нажатии на нужный элемент в браузере. Если у вас возникли трудности с пониманием этого, у нас есть специальная статья в блоге SerpApi, посвященная веб-скрапингу с CSS-селекторами.

Приведенный ниже рисунок иллюстрирует подход к выбору различных частей результатов.

Полный код

📌Примечания:

  • Чтобы сделать наш поиск более релевантным, нам нужно добавить параметр GPS координат. Он должен быть построен в следующей последовательности: @ + latitude + , + longitude + , + zoom. Это сформирует строку, которая будет выглядеть следующим образом: например, @47.6040174,-122.1854488,11z. Параметр масштаба необязателен, но рекомендуется для более высокой точности (он варьируется от 3z, карта полностью уменьшена — до 21z, карта полностью увеличена). На нашем канале YouTube есть специальный видеоролик, объясняющий, что и почему нужно делать с GPS-координатами Google Maps.
  • Иногда Google отображает результаты из локальных мест с помощью пагинации, а иногда загружает больше результатов по мере прокрутки. Этот код работает в обоих случаях. Если в вашем случае отображается пагинация, вам необходимо откомментировать цикл while и внутренние строки в функции getLocalPlacesInfo.
const puppeteer = require("puppeteer-extra");
const StealthPlugin = require("puppeteer-extra-plugin-stealth");

puppeteer.use(StealthPlugin());

const requestParams = {
  baseURL: `http://google.com`,
  query: "starbucks",                                          // what we want to search
  coordinates: "@47.6040174,-122.1854488,11z",                 // parameter defines GPS coordinates of location where you want your query to be applied
  hl: "en",                                                    // parameter defines the language to use for the Google maps search
};

async function scrollPage(page, scrollContainer) {
  let lastHeight = await page.evaluate(`document.querySelector("${scrollContainer}").scrollHeight`);

  while (true) {
    await page.evaluate(`document.querySelector("${scrollContainer}").scrollTo(0, document.querySelector("${scrollContainer}").scrollHeight)`);
    await page.waitForTimeout(2000);
    let newHeight = await page.evaluate(`document.querySelector("${scrollContainer}").scrollHeight`);
    if (newHeight === lastHeight) {
      break;
    }
    lastHeight = newHeight;
  }
}

async function fillDataFromPage(page) {
  const dataFromPage = await page.evaluate(() => {
    return Array.from(document.querySelectorAll(".bfdHYd")).map((el) => {
      const placeUrl = el.parentElement.querySelector(".hfpxzc")?.getAttribute("href");
      const urlPattern = /!1s(?<id>[^!]+).+!3d(?<latitude>[^!]+)!4d(?<longitude>[^!]+)/gm;                     // https://regex101.com/r/KFE09c/1
      const dataId = [...placeUrl.matchAll(urlPattern)].map(({ groups }) => groups.id)[0];
      const latitude = [...placeUrl.matchAll(urlPattern)].map(({ groups }) => groups.latitude)[0];
      const longitude = [...placeUrl.matchAll(urlPattern)].map(({ groups }) => groups.longitude)[0];
      return {
        title: el.querySelector(".qBF1Pd")?.textContent.trim(),
        rating: el.querySelector(".MW4etd")?.textContent.trim(),
        reviews: el.querySelector(".UY7F9")?.textContent.replace("(", "").replace(")", "").trim(),
        type: el.querySelector(".W4Efsd:last-child > .W4Efsd:nth-of-type(1) > span:first-child")?.textContent.replaceAll("·", "").trim(),
        address: el.querySelector(".W4Efsd:last-child > .W4Efsd:nth-of-type(1) > span:last-child")?.textContent.replaceAll("·", "").trim(),
        openState: el.querySelector(".W4Efsd:last-child > .W4Efsd:nth-of-type(3) > span:first-child")?.textContent.replaceAll("·", "").trim(),
        phone: el.querySelector(".W4Efsd:last-child > .W4Efsd:nth-of-type(3) > span:last-child")?.textContent.replaceAll("·", "").trim(),
        website: el.querySelector("a[data-value]")?.getAttribute("href"),
        description: el.querySelector(".W4Efsd:last-child > .W4Efsd:nth-of-type(2)")?.textContent.replace("·", "").trim(),
        serviceOptions: el.querySelector(".qty3Ue")?.textContent.replaceAll("·", "").replaceAll("  ", " ").trim(),
        gpsCoordinates: {
          latitude,
          longitude,
        },
        placeUrl,
        dataId,
      };
    });
  });
  return dataFromPage;
}

async function getLocalPlacesInfo() {
  const browser = await puppeteer.launch({
    headless: false,
    args: ["--no-sandbox", "--disable-setuid-sandbox"],
  });

  const page = await browser.newPage();

  const URL = `${requestParams.baseURL}/maps/search/${requestParams.query}/${requestParams.coordinates}?hl=${requestParams.hl}`;

  await page.setDefaultNavigationTimeout(60000);
  await page.goto(URL);

  await page.waitForNavigation();

  const scrollContainer = ".m6QErb[aria-label]";

  const localPlacesInfo = [];

  // while (true) {
    await page.waitForTimeout(2000);
    // const nextPageBtn = await page.$("#eY4Fjd:not([disabled])");
    // if (!nextPageBtn) break;
    await scrollPage(page, scrollContainer);
    localPlacesInfo.push(...(await fillDataFromPage(page)));
    // await page.click("#eY4Fjd");
  // }

  await browser.close();

  return localPlacesInfo;
}

getLocalPlacesInfo().then(console.log);
Вход в полноэкранный режим Выход из полноэкранного режима

Объяснение кода

Объявите константы из необходимых библиотек:

const puppeteer = require("puppeteer-extra");
const StealthPlugin = require("puppeteer-extra-plugin-stealth");
Вход в полноэкранный режим Выйти из полноэкранного режима
Код Пояснение
puppeteer Библиотека управления Chromium
StealthPlugin библиотека для предотвращения обнаружения веб-сайтом того, что вы используете веб-драйвер

Далее мы «скажем» puppeteer использовать StealthPlugin:

puppeteer.use(StealthPlugin());
Войти в полноэкранный режим Выйти из полноэкранного режима

Далее пишем, что мы хотим искать, и необходимые параметры для запроса:

const requestParams = {
  baseURL: `http://google.com`,
  query: "starbucks",
  coordinates: "@47.6040174,-122.1854488,11z", 
  hl: "en", 
};
Войти в полноэкранный режим Выйти из полноэкранного режима
Код Пояснение
query поисковый запрос
coordinates параметр определяет GPS-координаты места, где вы хотите применить ваш запрос. Подробнее см. в справке Google Maps
hl параметр определяет язык, который будет использоваться для поиска в Google Maps.

Далее запишем функцию для прокрутки контейнера мест на странице:

async function scrollPage(page, scrollContainer) {
  let lastHeight = await page.evaluate(`document.querySelector("${scrollContainer}").scrollHeight`);

  while (true) {
    await page.evaluate(`document.querySelector("${scrollContainer}").scrollTo(0, document.querySelector("${scrollContainer}").scrollHeight)`);
    await page.waitForTimeout(2000);
    let newHeight = await page.evaluate(`document.querySelector("${scrollContainer}").scrollHeight`);
    if (newHeight === lastHeight) {
      break;
    }
    lastHeight = newHeight;
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима
Код Объяснение
lastHeight текущая высота прокрутки контейнера
page.evaluate('document.querySelector... выполняет код из скобок в консоли браузера и возвращает результат
page.waitForTimeout(2000) ожидание 2000 мс перед продолжением
newHeight высота прокрутки контейнера после прокрутки

Далее мы запишем функцию для получения информации о местах на странице:

async function fillDataFromPage(page) {
  const dataFromPage = await page.evaluate(() => {
    return Array.from(document.querySelectorAll(".bfdHYd")).map((el) => {
      const placeUrl = el.parentElement.querySelector(".hfpxzc")?.getAttribute("href");
      const urlPattern = /!1s(?<id>[^!]+).+!3d(?<latitude>[^!]+)!4d(?<longitude>[^!]+)/gm;                     // https://regex101.com/r/KFE09c/1
      const dataId = [...placeUrl.matchAll(urlPattern)].map(({ groups }) => groups.id)[0];
      const latitude = [...placeUrl.matchAll(urlPattern)].map(({ groups }) => groups.latitude)[0];
      const longitude = [...placeUrl.matchAll(urlPattern)].map(({ groups }) => groups.longitude)[0];
      return {
        title: el.querySelector(".qBF1Pd")?.textContent.trim(),
        rating: el.querySelector(".MW4etd")?.textContent.trim(),
        reviews: el.querySelector(".UY7F9")?.textContent.replace("(", "").replace(")", "").trim(),
        type: el.querySelector(".W4Efsd:last-child > .W4Efsd:nth-of-type(1) > span:first-child")?.textContent.replaceAll("·", "").trim(),
        address: el.querySelector(".W4Efsd:last-child > .W4Efsd:nth-of-type(1) > span:last-child")?.textContent.replaceAll("·", "").trim(),
        openState: el.querySelector(".W4Efsd:last-child > .W4Efsd:nth-of-type(3) > span:first-child")?.textContent.replaceAll("·", "").trim(),
        phone: el.querySelector(".W4Efsd:last-child > .W4Efsd:nth-of-type(3) > span:last-child")?.textContent.replaceAll("·", "").trim(),
        website: el.querySelector("a[data-value]")?.getAttribute("href"),
        description: el.querySelector(".W4Efsd:last-child > .W4Efsd:nth-of-type(2)")?.textContent.replace("·", "").trim(),
        serviceOptions: el.querySelector(".qty3Ue")?.textContent.replaceAll("·", "").replaceAll("  ", " ").trim(),
        gpsCoordinates: {
          latitude,
          longitude,
        },
        placeUrl,
        dataId,
      };
    });
  });
  return dataFromPage;
}
Вход в полноэкранный режим Выход из полноэкранного режима
Код Пояснение
document.querySelectorAll(".bfdHYd") возвращает статический NodeList, представляющий список элементов документа, которые соответствуют селекторам css с именем класса bfdHYd
el.querySelector(".qBF1Pd") возвращает первый html элемент с селектором .qBF1Pd, который является любым дочерним элементом html элемента el
.getAttribute("href") получает значение атрибута href элемента html
urlPattern шаблон RegEx для поиска и определения id, широты и долготы. Посмотрите, что он позволяет найти
[...placeUrl.matchAll(urlPattern)] в этом коде мы используем синтаксис spread для создания массива из итератора, который был возвращен из метода matchAll (в данном случае эта запись равна Array.from(placeUrl.matchAll(urlPattern)))
.textContent получает необработанный текст html-элемента
.trim() удаляет пробельные символы с обоих концов строки

И, наконец, функция для управления браузером и получения информации:

async function getLocalPlacesInfo() {
  const browser = await puppeteer.launch({
    headless: false,
    args: ["--no-sandbox", "--disable-setuid-sandbox"],
  });

  const page = await browser.newPage();

  const URL = `${requestParams.baseURL}/maps/search/${requestParams.query}/${requestParams.coordinates}?hl=${requestParams.hl}`;

  await page.setDefaultNavigationTimeout(60000);
  await page.goto(URL);

  await page.waitForNavigation();

  const scrollContainer = ".m6QErb[aria-label]";

  const localPlacesInfo = [];

  // while (true) {
    await page.waitForTimeout(2000);
    // const nextPageBtn = await page.$("#eY4Fjd:not([disabled])");
    // if (!nextPageBtn) break;
    await scrollPage(page, scrollContainer);
    localPlacesInfo.push(...(await fillDataFromPage(page)));
    // await page.click("#eY4Fjd");
  // }

  await browser.close();

  return localPlacesInfo;
}

getLocalPlacesInfo().then(console.log);
Войти в полноэкранный режим Выход из полноэкранного режима
Код Пояснение
puppeteer.launch({options}) этот метод запускает новый экземпляр браузера Chromium с текущими опциями
headless определяет, какой режим использовать: безголовый (по умолчанию) или безголовый
args массив с аргументами, который используется с Chromium
["--no-sandbox", "--disable-setuid-sandbox"] эти аргументы мы используем для разрешения запуска процесса браузера в онлайн IDE
browser.newPage() этот метод запускает новую страницу
page.setDefaultNavigationTimeout(60000) изменение времени ожидания селекторов по умолчанию (30 сек) на 60000 мс (1 мин) для медленного интернет-соединения
page.goto(URL) навигация по URL, который определен выше
page.$("#eY4Fjd:not([disabled])") этот метод находит html элемент с селектором #eY4Fjd:not([disabled]) и возвращает его
localPlacesInfo.push(...(await fillDataFromPage(page))) в этом коде мы используем синтаксис spread, чтобы разделить массив, возвращаемый функцией fillDataFromPage, на элементы и добавить их в конец массива localPlacesInfo.
page.click("#eY4Fjd") этот метод эмулирует щелчок мыши на html-элементе с селектором #eY4Fjd.
browser.close() После этого мы закрываем экземпляр браузера.

Теперь мы можем запустить наш парсер. Для этого введите node YOUR_FILE_NAME в командную строку. Где YOUR_FILE_NAME — это имя вашего файла .js.

Выход

📌Примечание: если вы видите в консоли что-то вроде [Object], вы можете использовать console.dir(result, { depth: null }) вместо console.log(). Смотрите документацию Node.js для получения дополнительной информации.

[
[
   {
      "title":"Starbucks",
      "rating":"4.2",
      "reviews":"233",
      "type":"Coffee shop",
      "address":"545 Bellevue Square",
      "openState":"Closed ⋅ Opens 7AM",
      "phone":"+1 425-452-5534",
      "website":"https://www.starbucks.com/store-locator/store/18615/",
      "description":"Iconic Seattle-based coffeehouse chain",
      "serviceOptions":"Dine-in   Takeaway   No delivery",
      "gpsCoordinates":{
         "latitude":"47.617077",
         "longitude":"-122.2019599"
      },
      "placeUrl":"https://www.google.com/maps/place/Starbucks/data=!4m7!3m6!1s0x54906c8f50e36025:0x5175a46aeadfbc0f!8m2!3d47.617077!4d-122.2019599!16s%2Fg%2F1thw6fd9!19sChIJJWDjUI9skFQRD7zf6mqkdVE?authuser=0&hl=en&rclk=1",
      "dataId":"0x54906c8f50e36025:0x5175a46aeadfbc0f"
   },
   {
      "title":"Starbucks",
      "reviews":"379",
      "type":"Coffee shop",
      "address":"1785 NE 44th St",
      "openState":"Closed ⋅ Opens 4:30AM",
      "phone":"+1 425-226-7007",
      "website":"https://www.starbucks.com/store-locator/store/10581/",
      "description":"Iconic Seattle-based coffeehouse chain",
      "serviceOptions":"Dine-in   Drive-through   Delivery",
      "gpsCoordinates":{
         "latitude":"47.5319688",
         "longitude":"-122.1942498"
      },
      "placeUrl":"https://www.google.com/maps/place/Starbucks/data=!4m7!3m6!1s0x549069a98254bd17:0xb2f64f75b3edf4c3!8m2!3d47.5319688!4d-122.1942498!16s%2Fg%2F1tdfmzpb!19sChIJF71UgqlpkFQRw_Tts3VP9rI?authuser=0&hl=en&rclk=1",
      "dataId":"0x549069a98254bd17:0xb2f64f75b3edf4c3"
   },
   ...and other results
]
Вход в полноэкранный режим Выход из полноэкранного режима

API локальных результатов Google Maps

В качестве альтернативы вы можете использовать Google Maps Local Results API от SerpApi. SerpApi — это бесплатный API со 100 поисками в месяц. Если вам нужно больше поисковых запросов, существуют платные тарифные планы.

Разница в том, что вам не придется писать код с нуля и поддерживать его. Вы также можете столкнуться с блокировкой от Google и изменением селекторов, что приведет к поломке парсера. Вместо этого вам просто нужно итерировать структурированный JSON и получать нужные данные. Проверьте игровую площадку.

Во-первых, нам нужно установить google-search-results-nodejs. Для этого в консоли нужно ввести: npm i google-search-results-nodejs.

📌Примечание: Чтобы сделать наш поиск более релевантным, нам нужно добавить параметр GPS координат. Он должен быть построен в следующей последовательности: @ + latitude + , + longitude + , + zoom. Это сформирует строку, которая будет выглядеть следующим образом: например, @47.6040174,-122.1854488,11z. Параметр масштаба необязателен, но рекомендуется для более высокой точности (он варьируется от 3z, карта полностью уменьшена — до 21z, карта полностью увеличена). На нашем канале YouTube есть специальный видеоролик, объясняющий, что и почему нужно делать с GPS-координатами Google Maps.

const SerpApi = require("google-search-results-nodejs");
const search = new SerpApi.GoogleSearch(process.env.API_KEY); //your API key from serpapi.com

const searchString = "starbucks"; // what we want to search

const params = {
  engine: "google_maps", // search engine
  q: searchString, // search query
  hl: "en", // parameter defines the language to use for the Google search
  ll: "@47.6040174,-122.1854488,11z", // parameter defines GPS coordinates of location where you want your query to be applied
  type: "search", // parameter defines the type of search you want to make
};

const getJson = () => {
  return new Promise((resolve) => {
    search.json(params, resolve);
  });
};

const getResults = async () => {
  const allPlaces = [];
  while (true) {
    const json = await getJson();
    if (json.local_results) {
      allPlaces.push(...json.local_results)
    } else break;
    if (json.serpapi_pagination?.next) {
      !params.start ? (params.start = 20) : (params.start += 20);
    } else break;
  }
  return allPlaces;
};

getResults.then((result) => console.dir(result, { depth: null }));
Вход в полноэкранный режим Выход из полноэкранного режима

Объяснение кода

Объявите константы из необходимых библиотек:

const SerpApi = require("google-search-results-nodejs");
const search = new SerpApi.GoogleSearch(API_KEY);
Вход в полноэкранный режим Выйти из полноэкранного режима
Код Объяснение
SerpApi Библиотека SerpApi Node.js
search новый экземпляр класса GoogleSearch
API_KEY ваш API-ключ от SerpApi

Далее мы напишем, что мы хотим искать, и необходимые параметры для выполнения запроса:

const searchString = "starbucks";

const params = {
  engine: "google_maps",
  q: searchString,
  hl: "en",
  ll: "@47.6040174,-122.1854488,11z", // parameter defines GPS coordinates of location where you want your query to be applied
  type: "search", // parameter defines the type of search you want to make
};
Войти в полноэкранный режим Выйти из полноэкранного режима
Код Пояснение
searchString что мы хотим искать
engine поисковая система
q поисковый запрос
hl параметр определяет язык, который будет использоваться для поиска в Google Scholar
ll параметр определяет GPS-координаты места, где будет применяться запрос
type параметр определяет тип поиска, который вы хотите осуществить.

Далее мы обернем метод поиска из библиотеки SerpApi в обещание для дальнейшей работы с результатами поиска:

const getJson = () => {
  return new Promise((resolve) => {
    search.json(params, resolve);
  })
}
Войти в полноэкранный режим Выйти из полноэкранного режима

И, наконец, мы объявляем и запускаем функцию getResult, которая получает информацию о местах на всех страницах и возвращает ее:

const getResults = async () => {
  const allPlaces = [];
  while (true) {
    const json = await getJson();
    if (json.local_results) {
      allPlaces.push(...json.local_results)
    } else break;
    if (json.serpapi_pagination?.next) {
      !params.start ? (params.start = 20) : (params.start += 20);
    } else break;
  }
  return allPlaces;
};

getResults().then((result) => console.dir(result, { depth: null }))
Войти в полноэкранный режим Выход из полноэкранного режима
Код Пояснение
allPlaces массив со всей информацией о ссылках со всех страниц
allPlaces.push(...json.local_results) в этом коде мы используем синтаксис spread, чтобы разделить массив local_results из результата, который был возвращен из функции getJson на элементы и добавить их в конец массива allPlaces
console.dir(result, { depth: null }) консольный метод dir позволяет использовать объект с необходимыми параметрами для изменения параметров вывода по умолчанию. Смотрите документацию Node.js для получения дополнительной информации

Вывод

[
   {
      "position":1,
      "title":"Starbucks",
      "place_id":"ChIJrxaZdhlBkFQRk-hWRsy4sWA",
      "data_id":"0x54904119769916af:0x60b1b8cc4656e893",
      "data_cid":"6967553286011807891",
      "reviews_link":"https://serpapi.com/search.json?data_id=0x54904119769916af%3A0x60b1b8cc4656e893&engine=google_maps_reviews&hl=en",
      "photos_link":"https://serpapi.com/search.json?data_id=0x54904119769916af%3A0x60b1b8cc4656e893&engine=google_maps_photos&hl=en",
      "gps_coordinates":{
         "latitude":47.544705,
         "longitude":-122.38743199999999
      },
      "place_id_search":"https://serpapi.com/search.json?data=%214m5%213m4%211s0x54904119769916af%3A0x60b1b8cc4656e893%218m2%213d47.544705%214d-122.38743199999999&engine=google_maps&google_domain=google.com&hl=en&start=80&type=place",
      "rating":4.2,
      "reviews":310,
      "price":"$$",
      "type":"Coffee shop",
      "address":"6501 California Ave SW, Seattle, WA 98136, United States",
      "open_state":"Closed ⋅ Opens 5AM",
      "hours":"Closed ⋅ Opens 5AM",
      "operating_hours":{
         "wednesday":"5am–5:30pm",
         "thursday":"5am–5:30pm",
         "friday":"5am–5:30pm",
         "saturday":"5am–5:30pm",
         "sunday":"5am–5:30pm",
         "monday":"5am–5:30pm",
         "tuesday":"5am–5:30pm"
      },
      "phone":"+1 206-938-6371",
      "website":"https://www.starbucks.com/store-locator/store/18390/",
      "description":"Iconic Seattle-based coffeehouse chain. Seattle-based coffeehouse chain known for its signature roasts, light bites and WiFi availability.",
      "service_options":{
         "dine_in":true,
         "drive_through":true,
         "delivery":true
      },
      "thumbnail":"https://lh5.googleusercontent.com/p/AF1QipOSvSFJ7cD_s3pemaRs_TjEQe2_aVAy_NhUZVgN=w80-h106-k-no"
   },
   ...and other results
]
Войти в полноэкранный режим Выйти из полноэкранного режима

Ссылки

  • Код в онлайн IDE
  • Google Maps Local Results API

Если вы хотите увидеть некоторые проекты, сделанные с помощью SerpApi, пожалуйста, напишите мне сообщение.


Присоединяйтесь к нам на Twitter | YouTube

Add a Feature Request💫 or a Bug🐞

Оцените статью
devanswers.ru
Добавить комментарий