Мой телеграм канал: https://t.me/winc0de.
Здравствуйте. В свободное от работы время я занимаюсь социальными проектами. У меня и моих друзей есть достаточное количество «пабликов» в разных социальных сетях, что позволяет нам проводить различные эксперименты. Остро стоит вопрос нахождения актуального контента и новостей, которые можно публиковать. В связи с этим, пришла идея написать сервис, который будет собирать посты из самых популярных страниц и выдавать их по указанному фильтру. Для начального теста выбрал социальную сеть вконтакте и твиттер.
Первым делом, нужно было определиться с хранилищем данных (к слову, сейчас количество сохраненных записей больше 2млн) и эта цифра растает каждый день. Требования были такие: очень частая вставка большого количества данных и быстрая выборка среди них.
До этого уже слышал о nosql-базах данных и захотелось их попробовать. Не буду описывать в статье сравнения баз, которые я проводил (mysql vs sqlite vs mongodb).
В качестве кеширования выбрал memcached, позже объясню зачем и в каких случаях.
В качестве сборщика данных был написан демон на python, который параллельно обновляет все группы из базы.
Первым делом, написал прототип сборщика публикаций из групп. Видел несколько проблем:
Одна публикация со всеми метаданными занимает около 5-6КБ данных, а в средней группе около 20,000-30,000 записей, получается около 175МБ данных на одну группу, а этих самых групп очень много. Поэтому пришлось поставить задачу в фильтрации неинтересных и рекламных публикациях.
Слишком много выдумывать не пришлось, у меня есть всего 2 «таблицы»: groups и posts, первая хранит записи групп, которые нужно парсить и обновлять, а второе — scope всех публикаций всех групп. Сейчас мне кажется это излишним и даже плохим решением. Лучше всего было бы создавать по таблице на каждую группу, так будет проще происходить выборка и сортировка записей, хотя скорость даже с 2млн не теряется. Зато такой подход должен упростить общую выборку для всех групп.
В случаях, когда вам нужна серверная обработка каких-то данных из социальной сети вконтакте, создается standalone-приложение, которое может выдать токен н�� любое действие. Для таких случаев у меня сохранена заметка с таким адресом:
Вместо APP_ID вставляете идентификатор вашего standalone-приложения. Сгенерированный токен позволяет в любое время обращаться к указанным действиям.
Алгоритм работы парсера такой:
Берем id группы, в цикле получаем все публикации, на каждой итерации производим фильтрацию «плохих» постов, сохраняем в базу.
Основная проблема — скорость. API vkontakte позволяет выполнить 3 запроса в секунду. 1 запрос позволяет получить всего 100 публикаций — 300 публикаций в секунду.
В случае с парсером это не так и плохо: группу можно «слить» за одну минуту, а вот с обновлением уже будут проблемы. Чем больше групп — тем дольше будет происходить обновление и, соответственно, выдача будет не так быстро обновляться.
Выходом стало использование метода execute, который позволяет собирать запросы к api в кучу и выполнять за раз. Таким образом я в одном запросе делаю 5 итераций и получаю 500 публикаций — 1500 в секунду, что дает «слив» группы за ~13 секунд.
Вот так выглядит файл с кодом, который передается в execute:
Код читается в память, делается замена токенов replace_group_id и replace_start_offset. В результате получаю массив публикаций, формат которых можете посмотреть на официальной странице VK API vk.com/dev/wall.get
Следующий этап — фильтр. Я брал разные группы, просматривал публикации и придумывал возможные варианты отсеивания. Первым делом решил удалять все публикации с ссылками на внешние страницы. Почти всегда это реклама.
Далее решил полностью исключить репосты — это в 99% реклама. Мало кто будет просто так делать репост чужой страницы. Проверить на репост очень просто:
item — очередной элеменрт из коллекции walls, которую вернул метод execute.
Также заметил, что очень много древних публикаций пустые, у них нет никаких вложений и текст пустой. Для фильтра достаточно првоерить что item['attachments'] и item['text'] пустые.
И последний фильтр, который я просто вывел со временем:
Как и в предыдущем пункте, много старых публикаций были с текстом (описанием картинки во вложении), но сами картинки уже не сохранились.
Следующим шагом было очистка неудачных публикаций, которые просто «не зашли»:
Этот метод выполняется на таблицу posts, у которой есть поле likes (количество лайков у публикации). Он возвращает среднее арифметическое лайков по этой группе.
Теперь можно просто удалить все публикации старше 3 дней, у которых количество лайков меньше среднего:
Результирующую и отфильтрованную публикацию добавляю в базу данных, на этом парсинг заканчивается. Разница между парсингом и обновлением групп я сделал только в одном пункте: обновление вызывается ровно 1 раз для группы, т.е. получаю только 500 последних записей (5 по 100 через execute). В общем этого вполне достаточно, учитывая, что вконтакте ввели лимит на количество публикаций: 200 в сутки.
Не буду сильно подробно расписывать, javascript + jquery + isotope + inview + mustache.
Для вывода данных по группам был написан простой php-скрипт.
Это вспомогательная функция, которая по типу фильтра времени создавала объект, который можно использовать напрямую в запросе.
А следующий код уже получает 15 лучших постов за месяц:
Смотреть статистику по группе интересно, но куда интереснее построить общий рейтинг абсолютно всех групп и их публикаций. Если задуматься, задание очень сложное:
Мы можем строить рейтинг только по 3 факторам: количество лайков, репостов и подписчиков. Чем больше подписчиков — тем больше лайков и репостов, но это не гарантирует качество контента.
Большинство групп-миллионников публикуют часто и всякий мусор, который уже несколько лет бродит по интернету, и среди миллиона подписчиков постоянно находятся те, кто будет репостить и лайкать.
Построить рейтинг по голым цифрам легко, но полученный результат никак не можно назвать рейтингом публикаций по их качеству и уникальности.
Были идеи вывести коэффициент качества каждой группы: строить шкалу времени, смотреть активность пользователей за каждый промежуток времени и так далее.
К сожалению, адекватного решения я не придумал. Если у вас будут какие-то идеи, буду рад выслушать.
Первое, что я понял, было осознание того, что содержимое index-страницы нужно просчитывать и кешировать для всех пользователей, потому что это очень медленная операция. Здесь и приходит на помощь memcached. За самую простую логику был выбран следующий алгоритм:
Как результат, в выдаче от одной группы будет не более 2 публикаций. Конечно, это не самый правильный результат, но на практике показывает неплохую статистику и актуальность контента.
Вот как выглядит код потока, который раз в 15 минут генерирует index-страницу:
Опишу фильтры, которые влияют на выдачу:
Время: час, день, неделя, месяц, год, все время
Тип: лайки, репосты, комментарии
Для всех пунктов времени были сгенерированы объекты
Все они по очереди передаются в функцию _get вместе с разными вариациями фильтра по типу (лайки, репосты, комментарии). Еще ко всему этому, нужно сгенерировать по 5 страниц для каждой вариации фильтров. Как результат, в memcached проставляются следующие ключи:
А на стороне клиента лишь генерируется нужный ключ и вытаскивается json-строка из memcached.
Twitter
Следующим интересным заданием было сгенерировать популярные твиты по странам СНГ. Задание тоже непростое, хотелось бы получать актуальную и не «трешовую» информацию. Я очень удивился ограничениям твиттера: не получится так просто взять и слить все твиты определенных пользователей. API очень ограничивает количество запросов, поэтому уже нельзя сделать так, как это делает вк: составить список популярных аккаунтов и постоянно парсить их твиты.
Через день пришло решение: создаем аккаунт в твиттере, подписываемся на всех важных людей, тематика публикаций которых нам интересна. Трюк в том, что почти в 80% случаев, кто-то из этих людей сделает ретвит какого-то популярного твита. Т.е. нам не нужно иметь в базе список всех аккаунтов, достаточно набрать базу из 500-600 активных людей, которые постоянно в тренде и делают ретвиты реально интересных и популярных твитов.
В API твиттера есть метод, который позволяет получить ленту пользователя, которая включает твиты тех, на кого мы подписаны и их репосты. Все, что нам нужно теперь — раз в 10 минут считывать по максимуму нашу ленту и сохранять твиты, фильтры и все остальное делаем так же, как и в случае с вконтакте.
Итак, был написан еще один поток внутри демона, который раз в 10 минут запускал такой код:
Ну а дальше обычный и скучный код: у нас есть tweetList, проходим циклом и обрабатываем каждый твит. Список полей в официальной документации. Единственное, на чем хочу акцентировать внимание:
В случае с ретвитом, нам нужно сохранять не твит одного из наших подписчиков, а оригинальный. Если текущая запись это ретвит, то она содержит внутри по ключу 'retweeted_status' точно такой же объект твита, только оригинального.
С дизайном сайта и версткой есть проблемы (сам я ни разу не веб-программист), но, надеюсь, кому-то будет полезная информация, которую я описал. Сам уже очень много времени работаю с соц. сетями и их API и знаю много трюков. Если у кого-то будут какие-то вопросы — буду рад помочь.
Ну и несколько картинок:
Спасибо за внимание.
— 88.198.106.150
Здравствуйте. В свободное от работы время я занимаюсь социальными проектами. У меня и моих друзей есть достаточное количество «пабликов» в разных социальных сетях, что позволяет нам проводить различные эксперименты. Остро стоит вопрос нахождения актуального контента и новостей, которые можно публиковать. В связи с этим, пришла идея написать сервис, который будет собирать посты из самых популярных страниц и выдавать их по указанному фильтру. Для начального теста выбрал социальную сеть вконтакте и твиттер.
Технологии
Первым делом, нужно было определиться с хранилищем данных (к слову, сейчас количество сохраненных записей больше 2млн) и эта цифра растает каждый день. Требования были такие: очень частая вставка большого количества данных и быстрая выборка среди них.
До этого уже слышал о nosql-базах данных и захотелось их попробовать. Не буду описывать в статье сравнения баз, которые я проводил (mysql vs sqlite vs mongodb).
В качестве кеширования выбрал memcached, позже объясню зачем и в каких случаях.
В качестве сборщика данных был написан демон на python, который параллельно обновляет все группы из базы.
MongoDB и демон
Первым делом, написал прототип сборщика публикаций из групп. Видел несколько проблем:
- Объем хранилища
- Ограничения API
Одна публикация со всеми метаданными занимает около 5-6КБ данных, а в средней группе около 20,000-30,000 записей, получается около 175МБ данных на одну группу, а этих самых групп очень много. Поэтому пришлось поставить задачу в фильтрации неинтересных и рекламных публикациях.
Слишком много выдумывать не пришлось, у меня есть всего 2 «таблицы»: groups и posts, первая хранит записи групп, которые нужно парсить и обновлять, а второе — scope всех публикаций всех групп. Сейчас мне кажется это излишним и даже плохим решением. Лучше всего было бы создавать по таблице на каждую группу, так будет проще происходить выборка и сортировка записей, хотя скорость даже с 2млн не теряется. Зато такой подход должен упростить общую выборку для всех групп.
API
В случаях, когда вам нужна серверная обработка каких-то данных из социальной сети вконтакте, создается standalone-приложение, которое может выдать токен н�� любое действие. Для таких случаев у меня сохранена заметка с таким адресом:
http://oauth.vk.com/authorize?client_id=APP_ID&redirect_uri=https://oauth.vk.com/blank.html&response_type=token&scope=groups,offline,photos,friends,wall
Вместо APP_ID вставляете идентификатор вашего standalone-приложения. Сгенерированный токен позволяет в любое время обращаться к указанным действиям.
Алгоритм работы парсера такой:
Берем id группы, в цикле получаем все публикации, на каждой итерации производим фильтрацию «плохих» постов, сохраняем в базу.
Основная проблема — скорость. API vkontakte позволяет выполнить 3 запроса в секунду. 1 запрос позволяет получить всего 100 публикаций — 300 публикаций в секунду.
В случае с парсером это не так и плохо: группу можно «слить» за одну минуту, а вот с обновлением уже будут проблемы. Чем больше групп — тем дольше будет происходить обновление и, соответственно, выдача будет не так быстро обновляться.
Выходом стало использование метода execute, который позволяет собирать запросы к api в кучу и выполнять за раз. Таким образом я в одном запросе делаю 5 итераций и получаю 500 публикаций — 1500 в секунду, что дает «слив» группы за ~13 секунд.
Вот так выглядит файл с кодом, который передается в execute:
var groupId = -|replace_group_id|; var startOffset = |replace_start_offset|; var it = 0; var offset = 0; var walls = []; while(it < 5) { var count = 100; offset = startOffset + it * count; walls = walls + [API.wall.get({"owner_id": groupId, "count" : count, "offset" : offset})]; it = it + 1; } return { "offset" : offset, "walls" : walls };
Код читается в память, делается замена токенов replace_group_id и replace_start_offset. В результате получаю массив публикаций, формат которых можете посмотреть на официальной странице VK API vk.com/dev/wall.get
Следующий этап — фильтр. Я брал разные группы, просматривал публикации и придумывал возможные варианты отсеивания. Первым делом решил удалять все публикации с ссылками на внешние страницы. Почти всегда это реклама.
urls1 = re.findall('http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', text) urls2 = re.findall(ur"[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)", text) if urls1 or urls2: # Игнорировать эту публикацию
Далее решил полностью исключить репосты — это в 99% реклама. Мало кто будет просто так делать репост чужой страницы. Проверить на репост очень просто:
if item['post_type'] == 'copy': return False
item — очередной элеменрт из коллекции walls, которую вернул метод execute.
Также заметил, что очень много древних публикаций пустые, у них нет никаких вложений и текст пустой. Для фильтра достаточно првоерить что item['attachments'] и item['text'] пустые.
И последний фильтр, который я просто вывел со временем:
yearAgo = datetime.datetime.now() - datetime.timedelta(days=200) createTime = datetime.datetime.fromtimestamp(int(item['date'])) if createTime <= yearAgo and not attachments and len(text) < 75: # Игнорировать эту публикацию
Как и в предыдущем пункте, много старых публикаций были с текстом (описанием картинки во вложении), но сами картинки уже не сохранились.
Следующим шагом было очистка неудачных публикаций, которые просто «не зашли»:
db.posts.aggregate( { $match : { gid : GROUP_ID } }, { $group : { _id : "$gid", average : {$avg : "$likes"} } } )
Этот метод выполняется на таблицу posts, у которой есть поле likes (количество лайков у публикации). Он возвращает среднее арифметическое лайков по этой группе.
Теперь можно просто удалить все публикации старше 3 дней, у которых количество лайков меньше среднего:
db.posts.remove( { 'gid' : groupId, 'created' : { '$lt' : removeTime }, 'likes': { '$lt' : avg } } )
removeTime = datetime.datetime.now() - datetime.timedelta(days=3) avg = результату предыдущего запроса, разделенного на два (методом подбора).
Результирующую и отфильтрованную публикацию добавляю в базу данных, на этом парсинг заканчивается. Разница между парсингом и обновлением групп я сделал только в одном пункте: обновление вызывается ровно 1 раз для группы, т.е. получаю только 500 последних записей (5 по 100 через execute). В общем этого вполне достаточно, учитывая, что вконтакте ввели лимит на количество публикаций: 200 в сутки.
Front-end
Не буду сильно подробно расписывать, javascript + jquery + isotope + inview + mustache.
- Isotope используется для современного вывода публикаций в виде плитки.
- Inview позволяет легко реагировать на события попадания во viewport опредлеенного элемента. (в моем случае — запоминаю просмотренные публикации, а новые выделяю особым цветом).
- Mustache позволяет строить dom-объекты по шаблону.
Фильтр публикаций по группе
Для вывода данных по группам был написан простой php-скрипт.
Это вспомогательная функция, которая по типу фильтра времени создавала объект, который можно использовать напрямую в запросе.
function filterToTime($timeFilter) { $mongotime = null; if ($timeFilter == 'year') $mongotime = new Mongodate(strtotime("-1 year", time())); else if ($timeFilter == 'month') $mongotime = new Mongodate(strtotime("-1 month", time())); else if ($timeFilter == 'week') $mongotime = new Mongodate(strtotime("-1 week", time())); else if ($timeFilter == 'day') $mongotime = new Mongodate(strtotime("midnight")); else if ($timeFilter == 'hour') $mongotime = new Mongodate(strtotime("-1 hour")); return $mongotime; }
А следующий код уже получает 15 лучших постов за месяц:
$groupId = 42; // Какой-то id группы $mongotime = filterToTime('week'); $offset = 1; // Первая страница $findCondition = array('gid' => $groupId, 'created' => array('$gt' => $mongotime)); $mongoHandle->posts->find($findCondition)->limit(15)->skip($offset * $numPosts);
Логика index страницы
Смотреть статистику по группе интересно, но куда интереснее построить общий рейтинг абсолютно всех групп и их публикаций. Если задуматься, задание очень сложное:
Мы можем строить рейтинг только по 3 факторам: количество лайков, репостов и подписчиков. Чем больше подписчиков — тем больше лайков и репостов, но это не гарантирует качество контента.
Большинство групп-миллионников публикуют часто и всякий мусор, который уже несколько лет бродит по интернету, и среди миллиона подписчиков постоянно находятся те, кто будет репостить и лайкать.
Построить рейтинг по голым цифрам легко, но полученный результат никак не можно назвать рейтингом публикаций по их качеству и уникальности.
Были идеи вывести коэффициент качества каждой группы: строить шкалу времени, смотреть активность пользователей за каждый промежуток времени и так далее.
К сожалению, адекватного решения я не придумал. Если у вас будут какие-то идеи, буду рад выслушать.
Первое, что я понял, было осознание того, что содержимое index-страницы нужно просчитывать и кешировать для всех пользователей, потому что это очень медленная операция. Здесь и приходит на помощь memcached. За самую простую логику был выбран следующий алгоритм:
- Проходим циклом по всем группам
- Берем все публикации i-й группы и выбираем 2 лучшие из них за указанный промежуток времени
Как результат, в выдаче от одной группы будет не более 2 публикаций. Конечно, это не самый правильный результат, но на практике показывает неплохую статистику и актуальность контента.
Вот как выглядит код потока, который раз в 15 минут генерирует index-страницу:
# timeDelta - тип фильтра по времени (hour, day, week, year, alltime) # filterType - likes, reposts, comments # deep - 0, 1, ... (страница) def _get(self, timeDelta, filterTime, filterType='likes', deep = 0): groupList = groups.find({}, {'_id' : 0}) allPosts = [] allGroups = [] for group in groupList: allGroups.append(group) postList = db['posts'].find({'gid' : group['id'], 'created' : {'$gt' : timeDelta}}) \ .sort(filterType, -1).skip(deep * 2).limit(2) for post in postList: allPosts.append(post) result = { 'posts' : allPosts[:50], 'groups' : allGroups } # Этот код позволяет сгенерировать timestamp из mongotime, при конвертировании в json dthandler = lambda obj: (time.mktime(obj.timetuple()) if isinstance(obj, datetime.datetime) or isinstance(obj, datetime.date) else None) jsonResult = json.dumps(result, default=dthandler) key = 'index_' +filterTime+ '_' +filterType+ '_' + str(deep) print 'Setting key: ', print key self.memcacheHandle.set(key, jsonResult)
Опишу фильтры, которые влияют на выдачу:
Время: час, день, неделя, месяц, год, все время
Тип: лайки, репосты, комментарии
Для всех пунктов времени были сгенерированы объекты
hourAgo = datetime.datetime.now() - datetime.timedelta(hours=3) midnight = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) weekAgo = datetime.datetime.now() - datetime.timedelta(weeks=1) monthAgo = datetime.datetime.now() + dateutil.relativedelta.relativedelta(months=-1) yearAgo = datetime.datetime.now() + dateutil.relativedelta.relativedelta(years=-1) alltimeAgo = datetime.datetime.now() + dateutil.relativedelta.relativedelta(years=-10)
Все они по очереди передаются в функцию _get вместе с разными вариациями фильтра по типу (лайки, репосты, комментарии). Еще ко всему этому, нужно сгенерировать по 5 страниц для каждой вариации фильтров. Как результат, в memcached проставляются следующие ключи:
Setting key: index_hour_likes_0
Setting key: index_hour_reposts_0
Setting key: index_hour_comments_0
Setting key: index_hour_common_0
Setting key: index_hour_likes_1
Setting key: index_hour_reposts_1
Setting key: index_hour_comments_1
Setting key: index_hour_common_1
Setting key: index_hour_likes_2
Setting key: index_hour_reposts_2
Setting key: index_hour_comments_2
Setting key: index_hour_common_2
Setting key: index_hour_likes_3
Setting key: index_hour_reposts_3
Setting key: index_hour_comments_3
Setting key: index_hour_common_3
Setting key: index_hour_likes_4
Setting key: index_hour_reposts_4
Setting key: index_hour_comments_4
Setting key: index_hour_common_4
Setting key: index_day_likes_0
Setting key: index_day_reposts_0
Setting key: index_day_comments_0
Setting key: index_day_common_0
Setting key: index_day_likes_1
Setting key: index_day_reposts_1
Setting key: index_day_comments_1
Setting key: index_day_common_1
Setting key: index_day_likes_2
Setting key: index_day_reposts_2
Setting key: index_day_comments_2
Setting key: index_day_common_2
Setting key: index_day_likes_3
Setting key: index_day_reposts_3
...
А на стороне клиента лишь генерируется нужный ключ и вытаскивается json-строка из memcached.
Следующим интересным заданием было сгенерировать популярные твиты по странам СНГ. Задание тоже непростое, хотелось бы получать актуальную и не «трешовую» информацию. Я очень удивился ограничениям твиттера: не получится так просто взять и слить все твиты определенных пользователей. API очень ограничивает количество запросов, поэтому уже нельзя сделать так, как это делает вк: составить список популярных аккаунтов и постоянно парсить их твиты.
Через день пришло решение: создаем аккаунт в твиттере, подписываемся на всех важных людей, тематика публикаций которых нам интересна. Трюк в том, что почти в 80% случаев, кто-то из этих людей сделает ретвит какого-то популярного твита. Т.е. нам не нужно иметь в базе список всех аккаунтов, достаточно набрать базу из 500-600 активных людей, которые постоянно в тренде и делают ретвиты реально интересных и популярных твитов.
В API твиттера есть метод, который позволяет получить ленту пользователя, которая включает твиты тех, на кого мы подписаны и их репосты. Все, что нам нужно теперь — раз в 10 минут считывать по максимуму нашу ленту и сохранять твиты, фильтры и все остальное делаем так же, как и в случае с вконтакте.
Итак, был написан еще один поток внутри демона, который раз в 10 минут запускал такой код:
def __init__(self): self.twitter = Twython(APP_KEY, APP_SECRET, TOKEN, TOKEN_SECRET) def logic(self): lastTweetId = 0 for i in xrange(15): # Цифра подобрана методом тыка self.getLimits() tweetList = [] if i == 0: tweetList = self.twitter.get_home_timeline(count=200) else: tweetList = self.twitter.get_home_timeline(count=200, max_id=lastTweetId) if len(tweetList) <= 1: print '1 tweet, breaking' # Все, больше твитов API нам не выдаст break # ... lastTweetId = tweetList[len(tweetList)-1]['id']
Ну а дальше обычный и скучный код: у нас есть tweetList, проходим циклом и обрабатываем каждый твит. Список полей в официальной документации. Единственное, на чем хочу акцентировать внимание:
for tweet in tweetList: localData = None if 'retweeted_status' in tweet: localData = tweet['retweeted_status'] else: localData = tweet
В случае с ретвитом, нам нужно сохранять не твит одного из наших подписчиков, а оригинальный. Если текущая запись это ретвит, то она содержит внутри по ключу 'retweeted_status' точно такой же объект твита, только оригинального.
Финал
С дизайном сайта и версткой есть проблемы (сам я ни разу не веб-программист), но, надеюсь, кому-то будет полезная информация, которую я описал. Сам уже очень много времени работаю с соц. сетями и их API и знаю много трюков. Если у кого-то будут какие-то вопросы — буду рад помочь.
Ну и несколько картинок:
Index-страница:
Страница одной из групп, которую я постоянно мониторю:
Твиттер за день:
Спасибо за внимание.
— 88.198.106.150
