Один мой хороший знакомый и коллега обратился ко мне за помощью. Он делал для одной компании новый сайт взамен старому. Данные со старого, естественно, должны были переместиться на новый. В этом и заключалась моя задача. Было только одно простое условие – доступа к БД старого сайта ни у меня, ни у него не было.
В этой статье я хочу на практике показать, что можно сделать в этой ситуации, несмотря на отсутствие адекватной разметки в коде страницы.
// Не ждите высокотехнологичных мемуаров. Статья предназначена скорее для новичков среднего уровня, поэтому излишне подробная. Но может быть и разработчики уровня повыше смогут найти для себя интересные подходы к решению описанной проблемы :blush:
Что имеется #
Есть сайт с панелью администратора, доступной по логину-паролю, которые имеются, и около 1500 записей по 20 на страницу.
Также есть доступ к БД через панель PhpMyAdmin. (На крайний случай есть SSH доступ, но это для очень больших объемов данных)
Ну и на этом все.
Работа единоразовая, поэтому писать целую инфраструктуру не требуется.
Инструменты #
Для таких задач мне не раз приходил на выручку NodeJs.
- Во-первых, я его знаю и знаю достаточно хорошо.
- Во-вторых, под него есть огромное количество библиотек, в том числе и для работы с HTML разметкой.
- В-третьих, в случае большого количества данных запросы можно распараллелить и это даст значительный прирост к скорости выполнения скрипта. PHP так же может.
- Ну и последнее, я уже не первый раз выполнял подобную работу и на бо́льших объемах и могу сказать, что лучше JS ничто не выполнит эту задачу лучше.
Так же в некоторых, в том числе и в этом, случаях приходится писать некоторые скрипты прямо в браузер. А там JS по умолчанию.
Часто используемые библиотеки это:
-
request + request-promise
которые в удобном виде умеют делать HTTP запросы и делают это хорошо.
Можно было использовать встроенный http модуль, надстройкой которому является эта библиотека,
но в request уже из коробки учтены такие мелочи,
как использование http или https, обработка редиректов, сбор информации из буферов и стримов
и прочее, чем обычно заниматься не хочется совсем.
В нашем случае она не понадобится, но обычно она незаменимый инструмент. - cheerio как удобная замена jQuery для NodeJs с идентичным интерфейсом. То, что доктор прописал. Возможно, кто-то со мной не согласится и скажет, что есть что-то удобнее. И я скорее всего соглашусь. Но я пользовался jQuery еще до того, как NodeJS, NPM и прочие появились. Поэтому я использую его, потому что хорошо знаю и люблю.
Шаг первый. Сбор сырой информации #
Был у меня опыт вытягивания данных из новостной ленты одного сайта.
Это были публичные данные, поэтому собрать их не с труда любым способом. И я использовал request
.
Тут ситуация немного другая – доступ был запаролен, скорее всего с помощью PHP сессий. Быстрый эксперимент с подстановкой Cookie из браузера не дал положительного результата. Возможно, да и скорее всего, я просто сделал что-то не так. Как бы то ни было я решил пойти другим путем.
Оказалось проще вытянуть данные прямо в пределах браузера. На сайте уже использовался jQuery, так что пол дела уже сделано.
Первая проверка на работоспособность прошла успешно:
await $.get('http://example.ru/user/admin?page=1');
// Да-да. jQuery умеет в Promise
И в консоль было выплюнуто корректное содержание запрошенной страницы. Это ведь то, что нам нужно! Достаточно единожды пройтись по страничкам и собрать всю необходимую информацию:
var results = [];
var pages = 73;
for (let i = 1; i <= pages; i++) {
results.push(await $.get('http://example.ru/user/admin?page=' + i));
}
И Вуаля! В массиве results
лежат исходники всех 73 страниц.
Тут можно пойти двумя путями: работать с данными дальше в браузере
или сохранить их на комп и обрабатывать средствами NodeJs. Я выбрал второй. Так проще и быстрее.
Поэтому следующая задача – сохранить данные в файл. Сделать это можно несколькими способами. Я выбрал следующий: сформировать данные как строку и загрузить как файл.
Гугление привело на эту страничку в StackOverflow, где был приведен вот этот сниппет:
// Function to download data to a file
function download(data, filename, type) {
var file = new Blob([data], {type: type});
if (window.navigator.msSaveOrOpenBlob) // IE10+
window.navigator.msSaveOrOpenBlob(file, filename);
else { // Others
var a = document.createElement("a"),
url = URL.createObjectURL(file);
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(function() {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}, 0);
}
}
Не вдаваясь в подробности того, как это работает, скопировал этот код в консоль, и эта строчка:
download(JSON.stringify(results), 'data.json', 'application/json');
предложит скачать все нами загруженные данные в виде обычного json файла.
Сбор сырых данных завершен. Можно приступать к анализу.
Шаг второй. Анализ #
Каждая запись на странице внешне выглядит так:
А HTML код вот такой:
<div class="item" data-key="1494">
<hr>
<div class="mb4 brown">
<p class="mt0">
<span class="grey">Имя:</span> Джонатан Хамстер
<a href="/user/update-client?id=1494" class="ml2 size08 hover-nodecor hover-green">
<img src="/images/pencil.png" class="mr1 middle">изменить
</a>
</p>
<p class="pull-right size08 grey">Заходил(а): 16.02.2019 17:38</p>
<p class="mt1"><span class="grey">Тип:</span> Обычный покупатель</p>
<p class="mt1"><span class="grey">До получения/продления скидки осталось:</span> 9000 руб.</p>
<p class="mt1"><span class="grey">Email:</span> jonathan4@gmail.com</p>
<p class="mt1"><span class="grey">Телефон:</span> 79507757757</p>
<p class="mt1"><span class="grey">Адрес:</span> 111111 г. Москва, ул. Ленина, 28-123</p>
<p class="mt1 size08"><span class="grey">Зарегистрирован(а):</span> 16.02.2019 17:12</p>
</div>
</div>
Выглядит ужасно даже после форматирования, но с этим можно работать.
Если кратко прорезюмировать:
- Все элементы списка располагаются внутри
#centercolumn .list-view
. На самом деле все.list-view
появляется на странице единожды и нет никакой надобности уточнять его расположение. - Каждый элемент списка имеет класс
.item
. Это важно, потому что внутри.list-view
еще есть информация о количестве записей (“Показаны записи 1-20 из 1462”) и пагинация. - Каждый кусочек данных располагается внутри элемента
p
. - Каждый тип данных никак не отделяется классом. То есть внутри одинаковых элементов с одинаковыми классами лежат разные данные.
- Внутри почти каждого параграфа есть элемент с классом
.grey
, который и определяет тип. - Количество данных не консистентно – не во всех записях есть Email и Телефон.
- Сами данные тоже не имеют какого-то стандарта. Адрес записан в произвольном формате (есть случаи, где в качестве адреса указаны “F”, “SPb” или “………”). Имя тоже не имеет единого формата (ФИО, ИФО, ИФ, ФИ, И, Магазин у “Моста”).
- И самое главное: почти вся полезная информация лежит в тексте, который расположен именно внутри параграфа. То есть если убрать всех потомков и вытащить текст, мы получим то, что нужно.
Шаг третий. Реализация #
Получение типов данных #
Первое, что нужно выяснить, какие типы данных у нас в принципе имеются во всех записях.
Для этого можно вытянуть все p .grey
и найти уникальные значения.
В современном JS есть замечательный класс Set
, который хранит в себе набор уникальных значений.
Если добавить то, что уже есть, операция будет проигнорирована.
const cheerio = require('cheerio');
const data = require('./data.json');
const main = async () => {
let types = new Set();
for (let datum of data) {
const $ = cheerio.load(datum);
$('.list-view .item').each((i, item) => {
$(item).find('p').each((i, item) => {
let type = $(item).find('.grey').text().trim();
types.add(type)
});
});
}
console.log(types);
};
main();
И на выходе:
Set {
'Имя:',
'',
'Тип:',
'До получения/продления скидки осталось:',
'Телефон:',
'Зарегистрирован(а):',
'Email:',
'Адрес:' }
То есть у нас есть 7 явных значений и одно “пустое”.
Краткий анализ содержания этой строки показал, что кроме Заходил(а)
там ничего нет.
Пустое значение объясняется тем, что в этом поле нет дочернего элемента .grey
. Но нам это никак не мешает.
Получение чистых данных #
Во время анализа мы выяснили, что нужные нам данные лежат в параграфе (p
) без потомков.
Так и гуглим: jquery get text without child elements
И получаем вот этот кусочек кода, который делает именно то, что нужно:
$(item)
.clone() //clone the element
.children() //select all the children
.remove() //remove all the children
.end() //again go back to selected element
.text();
Помимо этого нужно убрать все лишние переносы и повторяющиеся пробелы, поэтому после .text()
добавляем еще 2 вызова:
.trim()
.replace(/[\s]+/g, ' ');
И в результате получаем красивую строку без лишнего мусора.
Обработка #
У нас есть 8 типов данных, которые нам нужно по-отдельности проанализировать и привести к значению, которое можно сохранить в БД.
Конечно нужно было это сделать раньше, но на этом этапе особенно важно знать, какие значения будут сохраняться в БД.
После сбора информации выяснилось следующее:
- Фамилия, Имя и Отчество хранятся в отдельных колонках.
- Поле “Тип” обычно имеет 2 значения: “Обычный покупатель” и “Постоянный покупатель”, что в БД хранится как флаг (0 или 1 соответственно).
- Поле “До получения/продления скидки осталось” сохраняется как число без указания валюты. То есть в рублях.
- Телефон и Email сохраняются как есть.
- Время регистрации и последнего посещения игнорируются.
- Адрес в БД разбит на все составляющие, типа страны, города, индекса, улицы и п.р.
Для этого сделаем объект, в котором в качестве ключей будет тип данных, а значениями будут функции, которые обрабатывают пришедшие сырые данных:
const strategies = {
'Имя:': (content) => {
},
'': (content) => {
},
'Тип:': (content) => {
},
'До получения/продления скидки осталось:': (content) => {
},
'Телефон:': (content) => {
},
'Зарегистрирован(а):': (content) => {
},
'Email:': (content) => {
},
'Адрес:': (content) => {
},
};
Так как каждый тип данных может возвращать несколько значений для БД, то каждая функция должна возвращать объект, который будет объединен с остальными данными. Если функция не вернет ничего, то ничего не будем делать.
let strategy = strategies[type];
// Несмотря на то, что мы описали все стратегии обработки типов,
// cлучаи "а вдруг" все равно стоит предусматривать
if (!strategy) {
console.log(text);
return;
}
let result = strategy(text) || {};
// Про переменную person я пока ничего не говорил,
// но в ней будут храниться все данные конкретного пользователя
person = {
...person,
...result,
};
В итоге остается только описать стратегии обработки всех данных поочередно проверяя их формат. Несмотря на то, что мы немного почистили данные, им все равно нельзя доверять.
Вот что я выяснил на этом этапе:
- В графе с именем всего 3 части, разделенные пробелом. Всего частей может быть от 1 до 3. В одном случае ФИО было разделено 2мя пробелами между частями. Поэтому очистка повторяющихся пробелов была очень кстати.
- Поле “Тип” в большинстве своем имеет одно их 2-х значений: “Обычный покупатель” и “Постоянный покупатель”. Но нашлись 3 записи формата “Представитель из Название_города”. Их решено пометить как “Постоянных покупателей”.
- Поле “До получения/продления скидки осталось” всегда имеет формат
[цифры][пробел]руб.
, что упростило задачу. - В поле с адресом буквально располагался всякий мусор. С этим уже ничего нельзя было сделать, поэтому решено все затолкать в одну колонку без какой-либо дополнительной обработки.
В итоге у нас получается вот такая коллекция со стратегиями:
Карта со стратегиями
const strategies = {
'Имя:': (content) => {
let parts = content.split(' ');
let name = '', surname = '', middlename = '';
switch (parts.length) {
case 1:
name = parts[0];
break;
case 2:
[name, surname] = parts;
break;
case 3:
[name, middlename, surname] = parts;
break;
default:
console.log(parts);
}
return {name, middlename, surname};
},
'Тип:': (content) => {
let regular_customer = 1;
if (content === 'Обычный покупатель') {
regular_customer = 0;
}
return {regular_customer};
},
'До получения/продления скидки осталось:': (content) => {
let parts = content.split(' ');
if (parts.length !== 2 && parts[1] !== 'руб.') {
console.log(parts);
}
return {
tmp_sum: parseInt(parts[0]),
}
},
'Телефон:': (phone) => ({phone}),
'Email:': (email) => ({email}),
'Адрес:': (address) => ({address}),
'Зарегистрирован(а):': (content) => {
},
'': (content) => {
},
};
А сам код теперь выглядит так:
Логика
const main = async () => {
let people = [];
for (let datum of data) {
const $ = cheerio.load(datum);
$('.list-view .item').each((i, item) => {
let person = {};
$(item).find('p').each((i, item) => {
let type = $(item).find('.grey').text().trim();
let text = $(item)
.clone()
.children()
.remove()
.end()
.text()
.trim()
.replace(/[\s]+/g, ' ');
let strategy = strategies[type];
if (!strategy) {
console.log(text);
return;
}
let result = strategy(text) || {};
person = {
...person,
...result,
};
});
people.push(person);
});
}
console.log(people);
};
main();
Тут как раз можно увидеть появление массива people
наполненный объектами person
.
Приведение к единому формату #
Теперь у нас есть почти все, чтобы идти дальше. Данные получены и обработаны.
Но наша цель – положить все это в БД.
Каждая стратегия описывалась таким образом, чтобы возвращенный объект был в виде, где ключ – колонка в таблице, а значение – ее содержание.
На выходе мы получили массив с информацией о каждом пользователе. Но эта информация не однородна: у кого-то может не быть адреса, у кого-то – телефона или email-a.
Поэтому нужно сделать некоторые атрибуты со значениями по умолчанию.
Берем все обязательные поля из таблицы и проставляем их в объект person
с указанными стандартными значениям:
let person = {
email: '',
password: 'trash123',
name: '',
middlename: '',
surname: '',
phone: '',
country: 'Россия',
zip: '',
region: '',
city: '',
address: '',
tmp_sum: 0,
regular_customer: 0,
status: 1,
};
Так как пароля мы узнать не можем, а в базе хранится только его хеш, поэтому вставляем туда любой мусор, который не является валидным хешем.
Таким образом на выходе у нас будет массив объектов, каждый из которых имеет одинаковую структуру, порядок атрибутов и заполненные обязательные поля.
Формирование файла для импорта в БД #
Тут решений тоже несколько.
Ранее я пользовался Query Builder’ом, который на выходе давал мне сформированные SQL запросы в файлах, которые достаточно было просто импортировать через PMA, либо запустить прямо на сервере. Были сложности с экранированием, но в большинстве своем он меня устраивал.
В этот раз я пошел другим путем. PMA вполне успешно умеет работать с различными форматами, включая CSV.
Этот формат по сути подразумевает, что горизонтально твои данные будут разделены запятыми, а вертикально – переносами:
"1","Виталий","Заславский"
"2","Василий","Пупкин"
"3","Петр","Васечкин"
Все достаточно просто и соответствовало тому, что мы делаем.
Создание контента для CVS укладывается в 4 строчки:
let result = people.map((person) => Object.values(person) // Получаем только значения из всех объектов
.map(_ => _.replace ? _.replace(/"/g, '""') : _) // Экранируем двойные кавычки " => "" (таков CSV, не я это придумал)
.map(_ => `"${_}"`) // Обрамление содержимого двойными кавычками
.join(',') // Объединение значений
).join('\n'); // Объединение всех полученных значений переносами
console.log(result);
Результатом проделанных действий будет выплюнутое в консоль содержание CSV файла.
С сохранением этого через fs
модуль я не стал. Вот эта команда сделала все за меня:
node index.js > result.csv
Соберем все воедино #
Вот так будет выглядеть скрипт, если собрать все его части:
Большой кусок кода
const cheerio = require('cheerio');
const data = require('./data.json');
const strategies = {
'Имя:': (content) => {
let parts = content.split(' ');
let name = '', surname = '', middlename = '';
switch (parts.length) {
case 1:
name = parts[0];
break;
case 2:
[name, surname] = parts;
break;
case 3:
[name, middlename, surname] = parts;
break;
default:
console.log(parts);
}
return {name, middlename, surname};
},
'Тип:': (content) => {
let regular_customer = 1;
if (content === 'Обычный покупатель') {
regular_customer = 0;
}
return {regular_customer};
},
'До получения/продления скидки осталось:': (content) => {
let parts = content.split(' ');
if (parts.length !== 2 && parts[1] !== 'руб.') {
console.log(parts);
}
return {
tmp_sum: parseInt(parts[0]),
}
},
'Телефон:': (phone) => ({phone}),
'Email:': (email) => ({email}),
'Адрес:': (address) => ({address}),
'Зарегистрирован(а):': (content) => {
},
'': (content) => {
},
};
const main = async () => {
let people = [];
for (let datum of data) {
const $ = cheerio.load(datum);
$('.list-view .item').each((i, item) => {
let person = {
email: '',
password: 'trash123',
name: '',
middlename: '',
surname: '',
phone: '',
country: 'Россия',
zip: '',
region: '',
city: '',
address: '',
tmp_sum: 0,
regular_customer: 0,
status: 1,
};
$(item).find('p').each((i, item) => {
let type = $(item).find('.grey').text().trim();
let text = $(item)
.clone()
.children()
.remove()
.end()
.text()
.trim()
.replace(/[\s]+/g, ' ');
let strategy = strategies[type];
if (!strategy) {
console.log(text);
return;
}
let result = strategy(text) || {};
person = {
...person,
...result,
};
});
people.push(person);
});
}
let result = people.map((person) => Object.values(person)
.map(_ => _.replace ? _.replace(/"/g, '""') : _)
.map(_ => `"${_}"`)
.join(',')
).join('\n');
console.log(result);
};
main();
Не думаю, что эта простыня нуждается в каком-либо дополнительном пояснении.
Шаг четвертый. Наполнение таблицы #
Это самый простой шаг. Все действия будут происходить через PMA панель.
- Нужно перейти в нужную таблицу.
- Ткнуть в “Импорт”.
- Выбрать нужный файл.
- Формат указать “CSV” (“CSV using LOAD DATA” не пробовал).
- В “Названия столбцов” указать нужные столбцы через запятую.
По поводу последнего шага.
Несмотря на то, что мы почти скопировали поля из таблицы в указанном порядке, есть случаи, когда данные указываются автоматически самой БД.
В моем случае это было поле id
. Поэтому при импорте достаточно указать в этом поле все атрибуты из объекта person
через запятую:
email,password,name,middlename,surname,phone,country,zip,region,city,address,tmp_sum,regular_customer,status
Жмем “Вперёд” и Вуоля! Работа завершена!
Что можно было сделать еще #
Моей задачей было сделать то, что хотел заказчик. Его уcтраивало то, что в поле с адресом лежит мусор, а на месте фамилии может стоять отчество.
И подручными средствами решить эту проблему было не столько сложно, сколько невозможно в короткий срок.
Но если с этой проблемой столкнулся я, значит были и другие. А значит есть инструменты, которые решают подобного рода задачи.
Адрес можно было привести к единому виду с помощью API Яндекс.Карт. Единственное, что он не умеет – определять номер квартиры из адреса.
Либо можно было воспользоваться сервисом Dadata, который и квартиру в адресе определит, и ФИО из любого формата к красивому приведет.
То есть решения были, только заказчику этого было не нужно :grin:
В качестве заключения #
Статья получилась невероятно большой для сравнительно небольшого объема информации. Но содержательной.
То, что я сейчас описал, называется “Веб-скрапинг”. В моем случае это просто перенос данных с одного сайта на другой путем вывода информации прямо со страниц.
В других случаях веб-скрапинг используется для сбора информации из нескольких сайтов для агрегирования информации в одном месте. Например, афиши кинотеатров. Еще этот подход используется для того, чтобы сделать API там, где его официально нет, и т.д.
Да, я знаю и слышал, что есть инструменты более мощные и серьезные. Но в моем случае гораздо быстрее воспользоваться тем, что я уже хорошо знаю.
А мораль сей статьи такова: автоматизируйте все, что автоматизируется. Используйте все подручные средства, чтобы компьютер сделал за вас бо́льшую часть работы. Не делайте руками то, что можно сделать скриптом на 150 строк. В определенный момент вы поймете – это окупается :wink: