Собесов

Technesis: чистка и дедуп данных по гостиницам с двух платформ

PythonETL и data cleaningСредняяMiddle

Условие

В файле hotels_parsing_result.xlsx две вкладки platform1 и platform2 — данные о гостиницах с двух платформ:

  • Записи могут повторяться, последняя — самая актуальная.
  • Если поле в последней записи пустое — взять из более ранней.
  • Поля phone, email, website — строки с несколькими значениями через запятую и лишними символами.

Задача: для каждой платформы вывести по одной записи на гостиницу (по uid) с самыми полными и актуальными данными; распарсить контакты в списки нормализованного формата; вывести топ-10 по числу телефонов и числу отзывов; найти квадрат 1 км × 1 км с максимальным числом гостиниц; объединить платформы и найти топ-10 общих по сумме отзывов.

Решение

1. Дедупликация с заполнением пропусков

import pandas as pd, re
 
def consolidate(df):
    df = df.sort_values('parsing_time')
    # Берём «последнее не-NaN» по каждому полю в группе uid
    out = df.groupby('uid', as_index=False).agg(
        lambda s: s.dropna().iloc[-1] if s.dropna().size else None
    )
    return out
 
p1 = consolidate(pd.read_excel('hotels.xlsx', sheet_name='platform1'))
p2 = consolidate(pd.read_excel('hotels.xlsx', sheet_name='platform2'))

2. Парсинг контактов

PHONE_RE = re.compile(r'\+?\d[\d\s\-()]{6,}\d')
EMAIL_RE = re.compile(r'[\w\.\-]+@[\w\.\-]+\.\w+')
URL_RE   = re.compile(r'(?:https?://)?[\w\-]+\.[\w\-\.]+')
 
def normalize_phone(s):
    digits = re.sub(r'\D', '', s)
    if len(digits) == 11 and digits.startswith('8'):
        digits = '7' + digits[1:]
    return digits
 
def parse_phones(cell):
    if pd.isna(cell): return []
    return [normalize_phone(x) for x in PHONE_RE.findall(str(cell))]
 
def parse_emails(cell):
    if pd.isna(cell): return []
    return [m.lower() for m in EMAIL_RE.findall(str(cell))]
 
p1['phones'] = p1.phone.map(parse_phones)
p1['emails'] = p1.email.map(parse_emails)

3. Топ-10 и квадрат 1×1 км

# Топ-10 по числу телефонов
p1.assign(n_phones=p1.phones.str.len()).nlargest(10, 'n_phones')
# Топ-10 по review_count
p1.nlargest(10, 'review_count')
 
# 1 км × 1 км: округляем lat/lon в шаге ~0.009° (1 км по широте)
p1['lat_bin'] = (p1.lat / 0.009).round().astype(int)
p1['lon_bin'] = (p1.lon / (0.009 / np.cos(np.radians(p1.lat.mean())))).round().astype(int)
top_grid = p1.groupby(['lat_bin','lon_bin']).size().nlargest(1)

4. Объединение платформ

Критерий слияния — нечёткое совпадение названия + расстояние < 100 м (или совпадение по URL/email). Для топ-10 общих — сумма review_count от обеих платформ.

from rapidfuzz import fuzz
merged = p1.merge(p2, on='lat_bin', suffixes=('_1','_2'))
merged['sim'] = merged.apply(lambda r: fuzz.token_set_ratio(r.title_1, r.title_2), axis=1)
matched = merged[merged.sim > 85]
matched['reviews_total'] = matched.review_count_1 + matched.review_count_2
matched.nlargest(10, 'reviews_total')

Подводные камни

  1. «Последнее не пустое» работает корректно только при сортировке по parsing_time. На равных временных метках — порядок недетерминирован.
  2. Длина 1 км по долготе зависит от широты — 1 км ≈ 0.009° / cos(lat).
  3. Объединение платформ: разные форматы названий ('Hotel ABC' vs 'ABC Hotel') — нужен fuzzy match.
  4. phone может содержать whatsapp 8800... — регулярка должна не цепляться за слова.

Эталонный ответ

groupby(uid).agg(last-non-null) для дедупа; регулярки для парсинга контактов; биннинг по lat/lon с поправкой на широту; fuzzy-merge для объединения платформ.

Хочешь увидеть разбор?

Зарегистрируйся бесплатно — откроется развёрнутое решение этой задачи и ещё 4 на выбор.

Зарегистрироваться и увидеть разбор
Уже есть аккаунт? Войти