OSINT: Парсинг геоточек с vk.com

Золотая жила OSINT-а — социальные сети. Люди с легкостью публикуют в своих профилях данные, содержащие личную и конфиденциальную информацию. Это терабайты сведений, анализ которых для OSINT-ера превращаются в ад кромешный. И просматривать нужно все, ибо любая мелочь может стать ключевой.

Например, геолокация, привязанная к фотографиям и постам. Эта информация позволяет с легкостью определить основные места пребывания объекта исследования: место работы, адрес проживания, места занятия хобби и отдыха и т.д. И в худшем случае эта информация зашита в коде страницы в виде долготы и широты, в лучшем — ссылка на карту с точкой. А просматривая 100500 фотографий, определить ареалы обитания исследуемого проблематично.

В этой статье хочу рассказать вам об инструменте наглядного представления геоданных на основании фотографий из профиля ВКонтакте (одной из наиболее популярных социальный сетей на территории РФ) с использование языка программирования Python версии 3.8 и платформы визуализации данных Dash.

Начнем с описания требований к приложению:

  1. вытаскивать геоданные из фотографий профиля и строить точки на карте;
  2. отображать краткую информацию по выделенной точке: фотографию, дату публикации и описание;
  3. кэшировать данные, чтобы не создавать множества одинаковых запросов;
  4. добавить красивости, куда же без них.

Для нетерпеливых — ссылка на репозиторий.

VK API: получаем фотографии из профиля

Для работы с ВКонтакте есть библиотеку vk_api для Python. А вот официальная документация для разработчиков.
Перед началом работы необходимо авторизоваться по логину и паролю (или по токену).

#vkfinder.py

def __auth(self, login, password):
        try:
            session = vk_api.VkApi(login, password)
            session.auth()
            return session.get_api()
        except Exception as e:
            print('Cannot auth with creds. Failed with error')
            print(e)
            sys.exit(0)

Если все прошло успешно, продолжаем. Если нет — кури документацию.

Теперь тянем фотографии. Точнее все фотографии профиля. Вообще все. Для этого нам нужен ID профиля исследуемого.

Замечу: VK API работает исключительно с ID профиля. Ник не прокатит.

С помощью метода photos.getAll получаем информацию обо всех фотографиях профиля:

#vkfinder.py

def get_profile_photos(self, profile_id):
        step = 200
        params = dict(method='photos.getAll', values={'owner_id': profile_id, 'extended': 1})
        result, _ = self.chunked_getter(step, False, **params)
        for i in result:
            if i.get('sizes'):
                sizes = [j.get('width') for j in i.get('sizes')]
                index_max_size = sizes.index(max(sizes))
                i['url'] = i.get('sizes')[index_max_size].get('url')
                i.pop('sizes')
        return result

Рассмотрим более подробно:

  1. строка 2 — параметры. Параметр extended со значение 1 (True) вернет нам дополнительную информацию о количестве лайков и репостов.
  2. строка 3. Многие методы VK API используют пагинацию (постраничная передача данных). Для этого в нашем классе получения данных был создан метод chunked_getter.
  3. строки 4-9. Это небольшая манипуляция с представлением данных. По результату запроса будет получен список словарей. В параметре sizes хранится информация о все доступных размерах изображения. Но зачем нам все размеры. Возьмем самое большое.

А еще была обнаружена проблема: описание к фотографии всегда пустое, хотя оно и существует. Что сказать: API такие API. Но не беда, есть метод который получает детальную информацию по ID фотографии через метод photos.get:

# vkfinder.py
def get_photos_by_id(self, ids: list):
      # если пришел не список, сделаем его списком
      if not isinstance(ids, list):
          ids = [ids]
      # джиним ID-шники через запятую
      params = dict(method='photos.getById', values={'photos': ','.join([str(i) for i in ids])})
      try:
          result = self.api._vk.method(**params)
      # если ошибка в vk_api вернем пустой список
      except ApiError as e:
          print("ERROR: ", e)
          return []
      # убирем все размеры изображения, оставив ссылку на максимальной
      for i in result:
          if i.get('sizes'):
              sizes = [j.get('width') for j in i.get('sizes')]
              index_max_size = sizes.index(max(sizes))
              i['url'] = i.get('sizes')[index_max_size].get('url')
              i.pop('sizes')
      return result

Dash: макет

Dash — это платформа для создания интерфейсов визуализации данных. С использование данной библиотеки формируется макет html-страницы и налаживается взаимодействие между элементами.
Итак, макет.

#geo_visualizer.py

def create_layout(self):
        self.app.layout = html.Div(
            children=[
            	# модальное окон
                html.Div(id='div_modal'),
                html.Div(children=[
                	# форма отправки ID профиля
                    html.Div([dcc.Input(id='profile_id',
                                        placeholder='Profile ID',
                                        type='text'),
                              html.Button('Submit', id='submit_id'),
                              html.Div([], id='spinner_div')],
                             style={'display': 'inline-block'}),
                    # крутилка-вертелка
                    dls.Hash(
                    	# карта с точками
                        dcc.Graph(id='map'),
                        color="#435278",
                        speed_multiplier=2,
                        size=100
                    )],
                    style={'width': '80%'}
                ),
                # элемент отображения информации по точке
                html.Div(children=[
                    html.H1('Photo'),
                    # фотография
                    html.A(children=[
                        html.Img(id='photo',
                                 style={'width': '100%'})],
                           id='photo_link',
                           target='_blank'),
                    html.H5('Created At'),
                    # дата публикаци фото
                    html.H6(id='photo_date'),
                    html.H5('Description'),
                    # описание к фото
                    html.H6(id='photo_description')
                ],
                    style={'width': '20%'})
            ],
            style={'display': 'flex', 'flex-direction': 'row', 'height': '100%'}
        )

Давайте по порядку:

  1. модальное окно — всплывающее окно для отображения информации при возникновении ошибок;
  2. форма отправки ID профиля — забили ID жмякнули отправить и должна построиться карта;
  3. крутилка-вертелка — процесс получения данных продолжительный, и чтобы у пользователя не возникло подозрения, что приложение зависло, будет появляться элемент аля Spinner;
  4. карта с точками — тут все понятно;
  5. элемент отображения информации по точке — при клике по точке справа будет появляться краткая справка: фото, дата публикации и описание.

Создание модального окна вынесено в отдельную функцию:

# geo_visualizer.py

def create_modal(self, header: str, message: str, is_open=True):
        return dbc.Modal([dbc.ModalHeader(dbc.ModalTitle(header)),
                          dbc.ModalBody(message)],
                         id='modal',
                         is_open=is_open)

Задаем шапку и текст сообщения и модальное окно готово.
Создание карты с точками также вынесено в отдельную функцию:

# geo_visualizer.py

def create_map(self, lats: list, longs: list):
        if not lats:
            lats = [0.00]
        if not longs:
            longs = [0.00]
        center_lat = (max(lats) + min(lats)) / 2.0
        center_long = (max(longs) + min(longs)) / 2.0

        data = go.Scattermapbox(
            lat=lats,
            lon=longs,
            mode='markers',
            marker=go.scattermapbox.Marker(
                size=14)
        )
        layout = dict(
            hovermode='closest',
            height=1000,
            mapbox=dict(
                accesstoken=self.mapbox_token,
                bearing=0,
                style='open-street-map',
                center=go.layout.mapbox.Center(
                    lat=center_lat,
                    lon=center_long),
                pitch=0,
                zoom=10)
        )
        return go.Figure(data=data, layout=layout)

Для построения карты с точками нужно передать списки широт и долгот. Если передать пустые списки, то будет построена точка с координатами 0.0;0.0.
В результате будет создана карта и на ней отмечены точки. Начальная точка отображения вычисляется как середина между максимальной и минимальной из входных данных. И чуть не забыл: карта строиться с использование сервиса MapBox. Нужно будет зарегистрироваться и получить токен.

Dash: взаимодействие с элементами

В Dash взаимодействие с элементами реализовано с помощью callback-ов. Есть возможность добавлять их через декораторы, но так как наше приложение реализовано в виде класса, делаем следующим образом:

# geo_visualizer.py

def __init__(self):
        self.app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])
        self.mapbox_token = MapBox.token
        self.create_layout()
        # действия при нажатии по точке
        self.app.callback([Output('photo', 'src'), Output('photo_link', 'href'),
                           Output('photo_description', 'children'), Output('photo_date', 'children')],
                          Input('map', 'clickData'))(self.get_image_callback)
        # действия при нажатии кнопки "Submit"
        self.app.callback([Output('map', 'figure'), Output('div_modal', 'children')],
                          Input('submit_id', 'n_clicks'),
                          State('profile_id', 'value'))(self.set_geopoints_callback)
        self.df = pd.DataFrame()
        self.vk = VKFinder()

Передаем в callback набор Output и Input, указывая id элемента, с которым будем работать, и его свойство, и метод, в котором будет реализована логика. Также можем передать данные об элементе, никак не связанного с входами и выходами через State.
Логика метода при нажатии на кнопку Submit:

# geo_visualizer.py

def set_geopoints_callback(self, n_clicks: int, value: str):
        try:
            # проверяем, есть ли закэшированный файл с геоточками по данному профилю
            if os.path.exists(f'{TempDirectory.tmp_dir}{value}_geo.csv'):
                # если есть, читаем его
                self.df = pd.read_csv(f'{TempDirectory.tmp_dir}{value}_geo.csv')
            else:
                # если нет, делаем запрос через vk_api
                self.get_geos_from_profile(value)
            # если ни у одной фотографии нет геоточек, возвращаем модальное окно с сообщением
            if not len(self.df.index):
                return self.create_map([], []), self.create_modal('Геолокаций не найдено', 'Ни на одной фотографии пользователя нет геолокации')
            # подготавливаем списки ширгот и долгот
            lats = self.df.lat.to_list()
            longs = self.df.long.to_list()
            # строим карту и отмечаем точки
            fig = self.create_map(lats, longs)
            # возвращаем карту и None в качестве модального окна
            return fig, None
        except Exception as e:
            if n_clicks:
                # если возникла ошибка и на Submit уже нажимали, возвращаем карту с начальными координатами и сообщение об ошибке
                return self.create_map([], []), self.create_modal('Геолокаций не найдено', 'Ни на одной фотографии пользователя нет геолокации')
            else:
                # если на Submit не нажимали, просто вернем карту с начальными координатами
                return self.create_map([], []), None

Хочу заострить внимание на последенем условии когда Submit еще не нажимали: при запуске приложения callback сразу вызывается, но на кнопку никто не нажимал. Ставим условие по нулевому количеству кликов, чтобы не всплывало модальное окно. Костыль, но работает.
Дальше получаем данные через VK API:

# geo_visualizer.py

def get_geos_from_profile(self, profile_id: str):
        try:
            # получаем все фото профиля
            photos = self.vk.get_profile_photos(profile_id)
            # отсеиваем без геолокации и получаем детальную информацию
            full_photos = self.vk.get_photos_by_id([f'{i.get("owner_id")}_{i.get("id")}' for i in photos if i.get('lat')])
            profile_link_pattern = 'https://vk.com/albums%s?z=photo%s_%s'
            geo_points = []
            # подготавливаем интересующие нас данные
            for photo in full_photos:
                lat = photo.get('lat')
                long = photo.get('long')
                photo_source_url = photo.get('url')
                photo_in_profile_link = profile_link_pattern % (photo.get('owner_id'), photo.get('owner_id'), photo.get('id'))
                photo_desc = photo.get('text')
                photo_created_at = datetime.utcfromtimestamp(photo.get('date')).strftime('%Y-%m-%d %H:%M:%S')
                geo_points.append(GeoPoint(lat, long, photo_source_url, photo_in_profile_link, photo_desc, photo_created_at))
            # создаем датафрейм
            self.df = pd.DataFrame([i.to_dict() for i in geo_points])
            # кэшируем данные в формате csv во временную директорию
            self.df.to_csv(f'{TempDirectory.tmp_dir}{profile_id}_geo.csv', index=False)
        except Exception as e:
            pass

Proof of concept

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

Выводы

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

Прежде чем публиковать фотографии с отмеченной геолокацией, стоить задуматься куда эти данные попадут и кто ими воспользуется. Наглядный тому пример — описанное выше приложение. Используя Dash как мощное и гибкое средство визуализации данных, за несколько минут можно определить место работы и проживания исследуемого объекта. И это только по одному профилю. Добавив функцию построения карты с точками по нескольким профилям, появится возможность выявления корреляции взаимоотношений между исследуемыми.

Все желающие протестировать приложение могту скачать его из репозитория. Внимательно протичитайте README.md (конфигурационный файл для запуска нужно создать ручками).

Служба поддержки

24/7/365