Как я анализировал рынок недвижимости для построения статистики ценового графика


Моя цель

Я искал квартиру для покупки и хотел найти что-то дешевле, чем на рынке. Рынок недвижимости достаточно эффективен, поэтому найти дешевый объект вручную практически невозможно, поэтому я решил автоматизировать этот процесс. 

Решение

Идея

Чтобы понять, является ли конкретный объект «дешевым» или нет, нам нужно иметь исторические данные о предыдущих сделках с этим объектом или хотя бы с похожими объектами в том же месте. Не существует публичного сервиса, который может предоставить данные о сделках, но есть множество сайтов недвижимости, которые позволяют найти предложения о продаже. Я решил написать приложение, которое анализирует один из таких сайтов и сохраняет все объекты в базу данных. Затем я могу делать запросы и анализировать динамику цен в конкретном районе.

Инструменты

Основываясь на своем опыте, я решил выбрать для реализации язык Java. Я начал с приложения командной строки, используя Spring Boot и Spring Batch. Процесс обработки данных на высоком уровне выглядел следующим образом.

Архитектура

Давайте рассмотрим эти компоненты один за другим.

Веб-сайт свойств

Это веб-сайт для парсинга. В тот раз меня интересовала недвижимость в России, поэтому я воспользовался местным порталом: https://www.avito.ru. Есть несколько категорий, включая квартиры. Структура следующая: в каждой категории есть список объявлений, состоящий из нескольких страниц, по 50 объектов на странице. Каждый пункт содержит информацию о конкретном объекте недвижимости, который мне был нужен.

Парсер страниц

Это первый компонент моего приложения, он получает URL категории в качестве входных данных: /moskva/kvartiry/prodam-ASgBAgICAUSSA8YQ?cd=1&p={page}. Как видите, здесь два параметра, первый cd всегда один и тот же, а второй p отвечает за номер страницы. Затем, используя библиотеку jsoup, я читаю каждую страницу в цикле и собираю URL.

Elements items = document.select(".item");
for (Element item: items) {
    Elements itemElement = item.select(".item-description-title-link");
    String relativeItemReference = itemElement.attr("href");
    urls.add(relativeItemReference);
}
Вход в полноэкранный режим Выход из полноэкранного режима

После чтения каждой страницы я отправлял список URL в следующий компонент.

Фильтр ID exists

Каждый элемент имеет следующий URL: /moskva/kvartiry/2-k._kvartira_548m_911et._2338886814, он содержит идентификатор в конце (2338886814). Это уникальный идентификатор объявления. Я использовал его в качестве ключа в кэше, чтобы избежать парсинга одних и тех же элементов дважды.

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

Парсер элементов

После фильтра все уникальные ID попадали в следующий компонент — Item Parser. Он использует ID для перехода на страницу элемента и считывает все данные с этой страницы.

 

Elements attributes = doc.select(".item-params-list-item");
Map <String, String> attrs = attributes.stream().collect(Collectors.toMap(
    attr -> attr.text().split(":")[0].trim(),
    attr -> attr.text().split(":")[1].trim()));
Вход в полноэкранный режим Выход из полноэкранного режима
estate
    .setTotalSpace(Double.parseDouble(attrs.getOrDefault("Общая площадь", "").split(" ")[0]));

estate
    .setLiveSpace(Double.parseDouble(attrs.getOrDefault("Жилая площадь", "").split(" ")[0]));

...
Войти в полноэкранный режим Выход из полноэкранного режима

В результате создается объект со всей информацией о свойствах и передается вперед — в компонент saver.

Saver

Этот компонент является последним в моем конвейере. Он получает элементы от Item Parser, конвертирует их в JSON и затем сохраняет в Elasticsearch, используя батчи для повышения производительности.

В результате я смог создать несколько приборных панелей Kibana с метриками цен и популярности. Одним из самых полезных компонентов является интерактивная карта, которая позволяет отображать данные с координатами (я получал координаты из описания объявления). Это помогает мне найти идеальную недвижимость в хорошем районе по хорошей цене.

Проблемы

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

Блокировка IP-адресов

Как вы можете догадаться, никто не хочет допустить парсинга своих данных, поэтому на этом сайте также есть различные уровни защиты. Таким образом, во время разработки все работало нормально, потому что я делал небольшое количество запросов. Но как только я начал тестирование, я столкнулся с огромным количеством 403 ошибок.

Во-первых, я попытался использовать несколько заголовков и cookies, чтобы имитировать реального пользователя с браузером.

Document imageDoc = Jsoup
    .connect(url)
    .userAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.139 Safari/537.36")
    .header("referer", "https://www.avito.ru" + relativeItemReference)
    .header("accept", "*/*")
    .ignoreContentType(true)
    .get();
Вход в полноэкранный режим Выйти из полноэкранного режима

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

Поэтому следующей моей попыткой было найти наименьший таймаут, который поможет мне избежать блокировки. Чтобы найти его, я использовал бесплатные VPN-сервисы, чтобы иметь возможность быстро менять IP-адреса. Экспериментально я установил минимальный тайм-аут в 25 секунд. Но это означает, что я могу разобрать только ~3500 элементов в день, и этого явно недостаточно.

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

doc = Jsoup.connect(url)
    .proxy(proxy.getHost(), proxy.getPort())
    .userAgent("Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.4; en-US; rv:1.9.2.2) Gecko/20100316 Firefox/3.6.2")
    .header("Content-Language", "en-US")
    .timeout(timeout)
    .get();
Вход в полноэкранный режим Выход из полноэкранного режима

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

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

@Scheduled(fixedDelay = 100, initialDelay = 1)
@Transactional
public void checkProxy() {
    ProxyEntity foundProxy = getProxyWithOldestUpdate()
        .orElseThrow(() -> new RuntimeException("Proxy not found"));

    int retries = 0;
    while (retries < retryCount) {
        log.debug("Attempt {}", retries + 1);
        if (checkProxy(foundProxy)) {
            log.debug("Proxy [{}] UP", foundProxy.getHost());
            foundProxy.setActive(true);
            break;
        }
        RequestUtils.wait(1000);
        retries++;
    }
    if (retries == retryCount) {
        foundProxy.setActive(false);
        log.debug("Proxy [{}] DOWN", foundProxy.getHost());
    }
    foundProxy.setCheckDate(LocalDateTime.now());
    proxyRepository.save(foundProxy);
}
Вход в полноэкранный режим Выход из полноэкранного режима

Дублирование объектов

Иногда случается так, что люди создают новое объявление для одного и того же объекта недвижимости, поэтому в системе есть два разных объявления с разными ID. И это хорошо для сайта недвижимости, но не для статистики и анализа данных. 

Чтобы избавиться от дубликатов в моей базе данных, я просто проверяю их по названию и описанию с помощью сравнения строк. Иногда это может быть ложным срабатыванием, поэтому я удалял не настоящий дубликат, а другое объявление с тем же текстом. Это полностью противоположная ситуация, потому что это совершенно нормально для анализа данных, но критично для сайта недвижимости. В любом случае, это решило мою проблему.

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