Как стать автором
Обновить

Лучшие публикации социальных сетей

Время на прочтение9 мин
Количество просмотров7.4K
Мой телеграм канал: https://t.me/winc0de.
Здравствуйте. В свободное от работы время я занимаюсь социальными проектами. У меня и моих друзей есть достаточное количество «пабликов» в разных социальных сетях, что позволяет нам проводить различные эксперименты. Остро стоит вопрос нахождения актуального контента и новостей, которые можно публиковать. В связи с этим, пришла идея написать сервис, который будет собирать посты из самых популярных страниц и выдавать их по указанному фильтру. Для начального теста выбрал социальную сеть вконтакте и твиттер.

Технологии


Первым делом, нужно было определиться с хранилищем данных (к слову, сейчас количество сохраненных записей больше 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. За самую простую логику был выбран следующий алгоритм:
  1. Проходим циклом по всем группам
  2. Берем все публикации 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.

Twitter

Следующим интересным заданием было сгенерировать популярные твиты по странам СНГ. Задание тоже непростое, хотелось бы получать актуальную и не «трешовую» информацию. Я очень удивился ограничениям твиттера: не получится так просто взять и слить все твиты определенных пользователей. 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
Теги:
Хабы:
Всего голосов 44: ↑35 и ↓9+26
Комментарии24

Публикации

Истории

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань