Мой телеграм канал: 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