Выгрузка объявлений с auto.ru

Недавно ко мне обратился заказчик.

Необходимо было выгрузить объявления из auto.ru в Google Sheets.

Потом мой заказчик как-то обработает эти данные, проанализирует что выгодно а что нет, будет следить за динамикой цены и в итоге купит машины по хорошей цене.

Защита от ботов

Как и следовало ожидать, сайт auto.ru защищён всякими видами защит от парсинга. Отслеживает IP, куки, User Agent и всё в этом духе. Есть платные сервисы и приложения которые обошли защиту и парсят данные.

Можно было попробовать скачать одно из таких приложений и попробовать его как-то использовать. Но на интеграцию с другими программами они не расчитаны. Поэтому это получились бы всё равно какие-то костыли с выгрузкой Excel файлов и последующим импортом. Да ещё и платно.

Обходить защиту самому, это долго и муторно. Я на такое не подпишусь.

Из-за защиты, я не могу написать простой скрипт на Node.js как я делал например для Яндекс.Карт или выгрузки программы ТВ. Если я напишу парсер “в лоб” то сработает защита от парсинга и мой скрипт отвалится.

Что же делать?

Расширение-парсер для Google Chrome

Тут я подумал, а что если выгрузить данные страницы напрямую из браузера?

Сайт auto.ru даже не будет знать о том, что какое-то приложение его парсит, так как для сайта я буду обычным посетителем.

Осталось только написать расширение Google Chrome чтобы забрать данные со страницы и отправить их в Google Sheets.

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

Вот так оно выглядит:

Попап расширения парсера для Google Chrome

Вот так выглядит выгрузка в Google Sheets:

Выгрузка парсера в Google Sheets

Плюсы парсера в виде расширения

  • Бесплатно
  • Легко установить
  • Надёжно

Минусы

  • Работает медленно, выгружает примерно одно объявление в секунду, поправимо при желании
  • Не полностью автоматическая работа, раз в день требуется нажать кнопку запуска
  • Навигации по страницам нет, каждую страницу результатов нужно выгрузить отдельно

Далее я расскажу по шагам, как вы можете создать такое расширение.

Шаг 1. Создаём файлы

Чтобы создать расширение для Google Chrome, нужно создать несколько файлов и потом загрузить папку с этими файлами в настройках браузера.

Файлы

  1. icon.png — иконка приложения
  2. index.html — страница попап-блока
  3. manifest.json — описание расширения
  4. parser.js — скрипт для парсинга страницы и экспорта в Google Sheets
  5. popup.js — обработчик для попап-блока

Создаём папку для своего расширения. Например D:\chrome-parser-extension.

Шаг 2. icon.png

Иконку можно взять откуда угодно.

Я выбираю иконки для своих приложений на сайте flaticon.com.

Размер иконки 128x128, формат PNG.

Сохраняем иконку в папку с расширением и переименовываем в icon.png

Шаг 3. index.html

<!doctype html>
<html lang="ru">

<head>
  <meta charset="utf-8">
  <title></title>
  <meta name="description" content="">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    body {
      background: #fafafa;
      padding: 20px;
    }

    button {
      padding: 10px 0;
      width: 150px;
      outline: none;
      background: #fafafa;
      border: 1px solid silver;
      border-radius: 5px;
      cursor: pointer;
      margin-bottom: 10px;
    }

    button:hover {
      background: #333;
      color: #fff;
      border-color: #333;
    }

  </style>
</head>

<body>
  <h3>Выгрузка auto.ru</h3>
  <p>Выберите объявления на auto.ru с помощью фильтров.</p>
  <p>Нажмите кнопку "Отправить" и данные по объявлениям будут отправлены в Google Sheets.</p>
  <button id="send-button">Отправить</button>
  <script src="popup.js"></script>
</body>

</html>

HTML для всплывающего окошка (попап).

Окошко всплывает если нажать на иконку установленного расширения в тулбаре Хрома.

Здесь только кнопка, подключение JS скрипта и немножко стилей. Всё очень просто.

Шаг 4. manifest.json

{
  "manifest_version": 3,
  "name": "Chrome Parser",
  "description": "Отправляем данные со страницы в Google Sheets",
  "version": "1.0",
  "author": "Леонид Черненко",
  "action": {
	"default_title": "Парсер с выгрузкой в Google Sheets",
	"default_icon": "icon.png",
	"default_popup": "index.html"
  },
  "icons": {
	"128": "icon.png"
  },
  "permissions": ["activeTab", "scripting"]
}

Это обязательный файл, заполняем его чтобы Хром понимал из чего состоит наше расширение.

Разрешения activeTab и scripting нужны для того чтобы получить доступ к активной вкладке и выполнить в ней наш код.

Шаг 5. popup.js

(
	() => {
		const sendButton = document.getElementById('send-button');

		if (sendButton === null) {
			return;
		}

		sendButton.onclick = function (el) {
			chrome.tabs.query({active: true, currentWindow: true}, function (tabs) {
				chrome.scripting.executeScript(
					{
						target: {tabId: tabs[0].id},
						files: ['parser.js'],
					},
					() => {}
				);
			})
		}
	}
)();

Обработчик попапа.

При нажатии кнопки “Отправить” мы подключаемся к активной вкладке и выполняем в ней скрипт parser.js.

Шаг 6. parser.js

alert('Вызван обработчик!');

Пока что делаем вот такую заглушку, позже заполним реальным кодом.

Шаг 7. Устанавливаем расширение

Все файлы созданы, теперь установим расширение.

Открываем в браузере адрес chrome://extensions/

Появится список установленных расширений.

В правом верхнем углу страницы включаем режим разработчика с помощью переключателя.

Теперь у нас появились кнопки для установки расширения из локальной папки.

Нажимаем “Загрузить распакованное расширение” и указываем папку с нашими файлами.

Если мы всё сделали правильно, расширение сразу же установится и будет включено.

Если нет, Хром сообщит нам об ошибке и даст информацию, что нам требуется исправить — исправляем и пробуем пока не заработает.

Появится карточка расширения.

Эту вкладку не закрываем, она пригодится чтобы обновлять код расширения после того как мы его отредактируем.

Для обновления есть кнопка прямо на карточке расширения.

Шаг 8. Проверяем работу кнопки

Файлы созданы, расширение загружено.

Проверяем работу попапа и кнопки.

Закрепляем иконку расширения в тулбаре. Для этого нажимаем в тулбаре кнопку которая выглядит как кусочек пазла, и рядом с названием нашего расширения нажимаем на иконку булавки. Теперь расширение закреплено в тулбаре.

Выбираем любую вкладку и нажимаем на иконку расширения. Должен появиться попап.

В попапе должна быть кнопка “Отправить”, нажимаем её. Если всё правильно, то появится сообщение “Вызван обработчик!”.

Если этого нет, то ищем ошибку и исправляем. Если всё успешно, двигаемся дальше.

Шаг 9. Парсим объявления

В нашем расширении уже работает всё, кроме собственно парсинга и отправки данных в Google Sheets.

Сначала сделаем парсинг.

Для парсинга нам потребуется обойти DOM-элементы страницы и собрать из них данные, которые нас интересуют в объявлениях.

Цена автомобиля, год выпуска, пробег и так далее.

Добавляем следующий код в parser.js:

function getDatePart(start, offset) {
	return (new Date).toISOString().substr(start, offset);
}

function getCurrentDate() {
	return getDatePart(8, 2)
		+ '.'
		+ getDatePart(5, 2)
		+ '.'
		+ getDatePart(0, 4);
}

function getElementByClass(elementClass) {
	return document.querySelector('.' + elementClass);
}

function hasClass(element, elementClass) {
	return element.classList.contains(elementClass);
}

function getChildrenWithClass(root, elementClass) {
	return root.querySelectorAll('.' + elementClass);
}

function getChildBySelector(root, selector) {
	return root.querySelector(selector);
}

function hashCode(str) {
	let hash = 0;

	for (let i = 0, len = str.length; i < len; i++) {
		let chr = str.charCodeAt(i);
		hash = (hash << 5) - hash + chr;
		hash |= 0
	}

	return hash;
}

function parse() {
	if (window.location.host !== 'auto.ru') {
		alert('Выберите вкладку auto.ru и нажмите кнопку ещё раз');

		return;
	}

	const listingCars = getElementByClass('ListingCars');

	if (listingCars === null) {
		alert('На странице не обнаружены карточки объявлений. Запустите поиск');

		return;
	}

	if (!hasClass(listingCars, 'ListingCars_outputType_list')) {
		alert('Выберите тип отображения карточек "Список"');

		return;
	}

	const result = [];
	const listItems = getChildrenWithClass(listingCars, 'ListingItem');
	const date = getCurrentDate();

	for (const listItem of listItems) {
		const link = getChildBySelector(listItem, 'a.Link.ListingItemTitle__link');

		if (link === null) {
			console.warn('List Item without link:', listItem);

			continue;
		}

		const url = link.getAttribute('href');
		const title = link.textContent.trim();

		const price = getChildBySelector(listItem, 'div.ListingItemPrice__content')
			.textContent
			.replaceAll('\xA0', '') // Неразрывный пробел
			.replaceAll('₽', '')
			.trim();

		const year = getChildBySelector(listItem, 'div.ListingItem__year')
			.textContent
			.trim();

		const kmAge = getChildBySelector(listItem, 'div.ListingItem__kmAge')
			.textContent
			.replaceAll('\xA0', '') // Неразрывный пробел
			.replaceAll('км', '')
			.trim();

		const hash = date + ':' + hashCode(url);

		result.push({
			url: url,
			date: date,
			title: title,
			price: price,
			year: year,
			kmAge: kmAge,
			hash: hash
		})
	}

	return result;
}

function tryParse() {
	let result = null;

	try {
		result = parse();
	} catch (e) {
		console.error(e);
		alert('Ошибка при сканировании страницы');

		return null;
	}

	return result;
}

async function runExport() {
    const content = tryParse();
    console.log(content);
}

(
    async () => {
        await runExport();
    }
)();

Здесь видно, что я использовал функции-обёртки для доступа к DOM. Это я делаю по той причине, что мне лень запоминать синтаксис методов JS для работы с DOM. Когда мне нужно работать с DOM, то я ищу методы в онлайн-шпаргалке You Might Not Need JQuery и делаю примитивные обёртки. Мне легче запомнить парочку собственных функций, чем те что встроены в JS.

Для форматирования даты и хеширования объявления я также создал свои функции. Удобного форматирования даты в JS нет, поэтому мне проще оказалось вывести её в ISO, распилить на кусочки и заново собрать в нужном мне порядке. Функцию для хеширования я честно слизал со StackOverflow. Почему бы и нет?

Асинхронность здесь пока что излишняя, не используется, но сделал заранее так как понадобится на следующем шаге.

Не забываем обновить наше расширение, так как мы отредактировали файл parser.js. Для обновления нужно нажать кнопку на карточке расширения.

Всё готово, запускаем:

  1. Заходим на сайт auto.ru
  2. Выбираем фильтры по объявлениям, чтобы вывести несколько объявлений. Например, указываем модель и год выпуска.
  3. Открываем вкладку с консолью JS в “инструментах разработчика”.
  4. Запускаем наше расширение - нажимаем иконку расширения, затем кнопку “Отправить”.

Наш скрипт должен вывести в консоль список с собранными данными по объявлениям.

Таким образом, у нас есть расширение, которое собирает информацию по объявлениям с auto.ru но пока что никуда её не отправляет.

Сделаем отправку этих данных в Google Sheets.

Шаг 10. Подключаем NoCodeApi

Можно использовать API Google Sheets для отправки данных собственно в Google Sheets.

Но в последнее время я иногда пользуюсь специальным бесплатным сервисом NoCodeApi, который является посредником между моим кодом и Google Sheets, и предоставляет мне гораздо более удобное и простое API.

Его минус в том, что запросы будут медленнее. Одну строку с объявлением выгружает примерно за секунду. Таким образом 60 объявлений будут выгружены за минуту. Это для меня сейчас не критично, поэтому можно оставить так. Если скорость потребуется повысить, то нужно будет отказаться от NoCodeApi и переписать чтобы обращение было к “родному” API Google Sheets.

Сейчас для меня важно сделать как можно проще, поэтому выбираю NoCodeApi.

  1. Регистрируем аккаунт на NoCodeApi, входим на сайт.
  2. Создаём таблицу в Google Sheets
  3. Подключаем аккаунт Google в NoCodeApi.
  4. Создаём новое API, для этого выбираем Google Sheets в маркетплейсе NoCodeApi: nocodeapi.com/marketplace/
  5. Выбираем свою таблицу Google Sheets при создании API

Всё, API готово. Теперь у нас есть довольно удобное REST API для взаимодействия с Google Sheets через сервер NoCodeApi.

Основной URL для вашего API будет выглядеть примерно так:

https://v1.nocodeapi.com/nexotaku/google_sheets/KFMKrDJMDbkNxraa

Сайт NoCodeApi предоставляет не только документацию но и сниппеты на JS для обращения к этому API. Я просто скопировал код сниппетов и причесал немножко, сэкономило кучу времени.

Шаг 11. Заполняем таблицу

NoCodeApi позволяет нам оперировать не адресами ячеек, а ID строк и “полями”, названия которым мы придумываем сами, как колонки в SQL-таблицах.

{
	"url": "https://my.com",
	"year": 2022,
	"hash": 123123123
}

Подобную структуру мы можем отправить в API и она запишет нужные поля в нужные колонки, нам даже не нужно беспокоиться в каком они порядке передаются.

Но для того чтобы записать поля по названиям, нам нужно заполнить первую строку в таблице прописав в ней все названия полей.

Добавляем поля в нашу таблицу в Google Sheets. Вписываем их в ячейки первой строки в таблице.

date, title, price, year, kmAge, url, hash

Шаг 12. Пишем выгрузку в GoogleSheets

Добавляем следующий код в parser.js:

function getNoCodeApiKey() {
    return 'KFMKrDJMDbkNxraa';
}

async function googleSheetsSearch(tabId, key, value) {
	var myHeaders = new Headers();
	myHeaders.append("Content-Type", "application/json");

	var requestOptions = {
		method: "get",
		headers: myHeaders,
		redirect: "follow",
	};

	const response = await fetch("https://v1.nocodeapi.com/nexotaku/google_sheets/"
		+ getNoCodeApiKey()
		+ "/search?tabId=" + tabId
		+ "&searchKey=" + key
		+ "&searchValue=" + value,
		requestOptions
	);

	return JSON.parse(await response.text());
}

async function googleSheetsAddRow(tabId, row) {
	var myHeaders = new Headers();
	myHeaders.append("Content-Type", "application/json");

	var requestOptions = {
		method: "post",
		headers: myHeaders,
		redirect: "follow",
		body: JSON.stringify([row])
	};

	const response = await fetch("https://v1.nocodeapi.com/nexotaku/google_sheets/"
		+ getNoCodeApiKey()
		+ "/addRows?tabId=" + tabId,
		requestOptions
	);

	return JSON.parse(await response.text());
}

async function googleSheetsUpdateRow(tabId, rowId, value) {
	const row = {...value, ...{row_id: rowId}};

	var myHeaders = new Headers();
	myHeaders.append("Content-Type", "application/json");

	var requestOptions = {
		method: "put",
		headers: myHeaders,
		redirect: "follow",
		body: JSON.stringify(row)
	};

	const response = await fetch("https://v1.nocodeapi.com/nexotaku/google_sheets/"
		+ getNoCodeApiKey()
		+ "/?tabId=" + tabId,
		requestOptions
	);

	return JSON.parse(await response.text());
}

async function exportToGoogleSheets(content) {
	let exportedRows = 0;

	for (const item of content) {
		const existing = await googleSheetsSearch('Sheet1', 'hash', item.hash);

		if (existing.length !== 0) {
			const foundRow = existing[0];
			const response = await googleSheetsUpdateRow('Sheet1', foundRow.row_id, item);
			console.log(response.message);
		} else {
			const response = await googleSheetsAddRow('Sheet1', item);
			console.log(response.message);
		}

		exportedRows++;
	}

	return exportedRows;
}

async function tryExport(content) {
	let rowsExported = 0;

	try {
		rowsExported = await exportToGoogleSheets(content);
	} catch (e) {
		console.error(e);
		alert('Ошибка при экспорте данных в Google Sheets');

		return 0;
	}

	return rowsExported;
}

Меняем URL для запросов к NoCodeApi под себя: сверяемся с URL вашего API и заменяем в коде /nexotaku/ на ваш юзернейм, а также в функции getNoCodeApiKey заменяем строку на то что в URL вашего API.

Также меняем функцию runExport. Теперь в ней будет реальная выгрузка:

async function runExport() {
	const content = tryParse();

	if (content === null) {
		console.log('Нет данных для выгрузки.');

		return;
	}

	const rowsExported = await tryExport(content);

	if (rowsExported === 0) {
		console.log('Ничего не выгружено.');
		alert('Ничего не выгружено.');

		return;
	}

	console.log('Выгружено ' + rowsExported + ' строк.');
	alert('Выгрузка завершена.');
}

Всё готово, проверяем. Выгрузка должна сработать и выгрузить все данные в таблицу. Если этого не произошло, то стоит заглянуть в консоль JS и проверить ошибки.

Итоги

У нас получилось создать расширение для Google Chrome, которое забирает полезные данные со страницы и отправляет их в Google Sheets.