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

Быстрое получение популярных записей со страниц VK

Недавно мне понадобилось получить отсортированный по лайкам или репостам список записей со страницы в VK. Готового решения я не нашел, пришлось решать задачу самостоятельно. В итоге получилось простенькое приложение, работающее достаточно быстро.

Требования к приложению заключались в следующем. На вход подается ссылка на страницу и дополнительные параметры. Сперва был только один параметр — ключ сортировки, позже был добавлен параметр, задающий промежуток времени (в днях). Посты, опубликованные раньше этого промежутка, не учитываются. Например, при значении параметра 7 на выходе получим список постов с максимальным количеством лайков (или репостов) за поледнюю неделю.

Для решения задачи использовал Python 3 и модуль Requests. Некоторые детали реализации не описываются, полный код доступен по ссылке, в конце статьи. Итак, приступим.

Для начала нужно распарсить аргументы командной строки. Для этого воспользуемся модулем argparse из стандартной библиотеки:

parse_args
def parse_args():
    """ Parses input arguments """
    parser = argparse.ArgumentParser()
    parser.add_argument('url',
                        action='store',
                        default=None,
                        help='target page',
                        type=url_validator)
    compar_key = parser.add_mutually_exclusive_group()
    compar_key.add_argument('-l',
                            '--likes',
                            help='sort posts by likes',
                            action='store_true',
                            default=True)
    compar_key.add_argument('-r',
                            '--reposts',
                            help='sort posts by reposts',
                            action='store_true')

    parser.add_argument('-t',
                        '--top',
                        help='number of showing posts.',
                        default=10,
                        type=num_validator)

    parser.add_argument('-d',
                        '--days',
                        type=int,
                        default=-1,
                        help='period for post processing.')
    return parser.parse_args()



Для проверки введенного url используется функция url_validator, которая, в случае успеха, возвращает словарь, содержащий тип введенного url и идентификатора страницы:

url_validator
def url_validator(arg):
    """ Checks correctness of url argument """
    arg = arg.lower()

    # If url something like https://vk.com/textual_id
    matched_txt_id = const.TXT_ID_REGEXP.match(arg)
    if matched_txt_id:
        url = matched_txt_id.groupdict()
        url['type'] = 'symbolic'
        return url

    # If url something like https://vk.com/id123456
    matched_numeric_id = const.NUM_ID_REGEXP.match(arg)
    if matched_numeric_id:
        return matched_numeric_id.groupdict()

    raise argparse.ArgumentTypeError(
            const.INVALID_URL.format(url=arg))


Например:

>> url_validator("club123456")
{'type': 'club', 'id': '123456'}

Или:

>> url_validator("https://vk.com/habr")
{'type': 'symbolic', 'id': 'habr'}

Результат парсинга входных данных, например, для
python vktop.py vk.com/habr -t 10 -l -d 10

Выглядит следующим образом:

>> args = parse_args()
Namespace(days=10, likes=True, reposts=False, top=10, url={'type': 'symbolic', 'id': 'habr'})

Для дальнейшей работы с API и формирования ссылок на записи, требуется числовой ID страницы:

get_page_id
def get_page_id(url):
    """ Returns page's numeric ID """
    if url['type'] not in ['id', 'public', 'event', 'club']:
        params = {'screen_name': url['id']}
        request = requests.get(const.API_URL + 'utils.resolveScreenName?',
                               params=params)
        response = json.loads(request.text)['response']

        if response:
            if response['type'] == 'user':
                return response['object_id']
            else:
                # Groups have negative id
                return -response['object_id']
        else:
            raise PageNotAvailable(url['id'] + ' is not available')
    
    if url['type'] == 'id':
        return int(url['id'])
    else:
        return -int(url['id'])


Теперь перейдем непосредственно к работе с VK API.

Основной метод для получения записей — wall.get, за один запрос может вернуть максимум 100 постов, что достаточно мало. Для решения проблемы воспользуемся методом execute — универсальным методом, который позволяет запускать последовательность других методов, сохраняя и фильтруя промежуточные результаты. За один запрос execute может выполнить не более 25 обращений к API, из чего следует, что за один запрос к execute мы будем получать не 100 записей, как при обычном wall.get, а 2500. Execute имеет обязательный параметр code — код алгоритма в VKScript, формате, похожем на JavaSсript.

Запросы к API выполняются в цикле:

recieve_posts
def recieve_posts(page_id):
    """
    Returns :received_posts: from :page_id:
    """
    params = {'access_token': const.ACCESS_TOKEN,
              'id': page_id,
              }
    received_posts = []

    offset = 0
    while True:
        params['offset'] = offset
        response = json.loads(requests.post(
            const.API_URL + 'execute.getPosts?', params=params).text)

        # Interrupt loop when all posts were received
        if not response['response']:
            break

        received_data = response['response']
        for chunk in received_data:
            for post in chunk:
                received_posts.append(post)
        offset += 1

    return received_posts


В примере используется метод execute.getPosts — хранимая процедура, которая создана для удобства в настройках приложения в VK, что позволяет не передавать каждый раз код метода, а обращаться к нему по имени.

Тело execute.getPosts
// количество итераций цикла\запросов к API
var ITERS = 25;
// количество сообщений получаемых за один запрос
var COUNT = 100;
// список полученных постов
var posts = []; 
var req_params = {
        "owner_id" : Args.id, // id страницы
        "offset" : 0,         // смещение
        "count"  : COUNT,
        "v" : "5.34"          // версия API
};
var i = 0;
while(i < ITERS) {
    req_params.offset = i*COUNT + ITERS*COUNT*Args.offset;
    // Делаем запрос к API
    var items = API.wall.get(req_params).items; 
    // Пустой список означает, что все записи получены
    if (items.length == 0) {
        return posts;
    }
    // Добавляем промежуточный результат
    // в итоговый список
    posts.push(items); 
    i = i + 1;
}
return posts;


Код, приведенный выше, неэффективен, т.к. получает много лишней информации о записях, что существенно увеличивает время получения и дальнейшей обработки. Например, при получении 30.000+ постов с одной страницы, суммарный объем полученного текста может составлять более 50 Мб.

Проведем фильтрацию ответов на стороне сервера, что значительно ускорит работу приложения:

Измененный execute.getPosts
// количество итераций цикла\запросов к API
var ITERS = 25;
// количество сообщений получаемых за один запрос
var COUNT = 100;
// список полученных постов
var posts = []; 
var req_params = {
        "owner_id" : Args.id, // id страницы
        "offset" : 0,         // смещение
        "count"  : COUNT,
        "v" : "5.34"          // версия API
};
var i = 0;
while(i < ITERS) {
    req_params.offset = i*COUNT + ITERS*COUNT*Args.offset;
    // Делаем запрос к API
    var items = API.wall.get(req_params).items; 
    // Пустой список означает, что все записи получены
    if (items.length == 0) {
        return posts;
    }
    
    // Для хранения промежуточных ответов
    var tmp = {};
    // Фильтр. Получем список из id постов
    tmp.ids = items@.id;
    // Фильтр. Получем список из дат, публикаций постов
    tmp.dates = items@.date;
    // Так же, в execute передается доп. параметр Args.compar_key,
    // В зависимости от которого, добавляется нужный список.
    if (Args.compar_key == "likes") {
        tmp.likes = items@.likes@.count;
    } else {
        tmp.reposts = items@.reposts@.count;
    }
    posts.push(tmp);

    i = i + 1;
}
return posts;


Пример ответа:

response: [{
	ids: [677448, 649369, 593585],
	dates: [1450981583, 1449937068, 1448431475],
	likes: [14957, 14923, 15493]
}, {
	ids: [555311, 549734, 549376],
	dates: [1447699602, 1447677107, 1447675941],
	likes: [12384, 122548, 26709]
}]

Как видим, количество получаемой информации сократилось в разы. Казалось бы, такой результат должен удовлетворить, но, как упоминалось в начале статьи, есть параметр, задающий промежуток времени (в днях). Код выше никак не учитывает его. Например, если требуется получить самые популярные посты за последние два дня, то сначала скачиваются все посты, после чего локально отбираются те, которые опубликованы не ранее 2-ух прошлый дней. Таким образом, потенциальных постов, удовлетворяющих запросу, может быть штук 10, а получено будет несколько тысяч, что неприемлемо.

Передадим дополнительный параметр в метод execute.getPosts — крайнюю допустимую дату публикации в формате Unix Timestamp. Затем сравним последний полученный пост с этой датой. Если пост опубликован раньше, то возвращаем текущий список записей.

Итоговый метод execute.getPosts
// количество итераций цикла\запросов к API
var ITERS = 25;
// количество сообщений получаемых за один запрос
var COUNT = 100;
// список полученных постов
var posts = []; 
var req_params = {
        "owner_id" : Args.id, // id страницы
        "offset" : 0,         // смещение
        "count"  : COUNT,
        "v" : "5.34"          // версия API
};
var i = 0;
while(i < ITERS) {
    req_params.offset = i*COUNT + ITERS*COUNT*Args.offset;
    // Делаем запрос к API
    var items = API.wall.get(req_params).items; 
    // Пустой список означает, что все записи получены
    if (items.length == 0) {
        return posts;
    }
    
    // Для хранения промежуточных ответов
    var tmp = {};
    // Фильтр. Получем список из id постов
    tmp.ids = items@.id;
    // Фильтр. Получем список из дат, публикаций постов
    tmp.dates = items@.date;
    // Так же, в execute передается доп. параметр Args.compar_key,
    // В зависимости от которого, добавляется нужный список.
    if (Args.compar_key == "likes") {
        tmp.likes = items@.likes@.count;
    } else {
        tmp.reposts = items@.reposts@.count;
    }
    // Args.deadline - крайняя дата публикации.
    // Если равна -1, значит нужно получить все посты.
    // Иначе смотрим на дату последнего полученного поста,
    // Если он опубликован раньше deadline, значит дальнейшие
    // Запросы делать не нужно. Вернуть результат.
    if (Args.deadline != -1 && tmp.dates[tmp.dates.length - 1] < Args.deadline) {
        // Флаг, для остановки локального цикла.
        tmp.stop = "True";
        posts.push(tmp);
        return posts;
    } else {
        posts.push(tmp);
    }

    i = i + 1;
}
return posts;


Код execute.getPosts получился достаточно неуклюжим. Это связано с ограничением на количество операций за один запрос.

Итоговая функция для получения постов:

recieve_posts
def recieve_posts(page_id, last_days, reposts):
    """
    Returns posts from :page_id: that were posted not earlier
    than :last_days: ago
    """
    deadline = datetime.now() - timedelta(days=last_days)
    unix_stamp = int(deadline.strftime("%s"))

    if reposts:
        compar_key = const.REPOSTS
    else:
        compar_key = const.LIKES

    params = {'access_token': const.ACCESS_TOKEN,
              'id': page_id,
              'compar_key': compar_key,
              'deadline':  unix_stamp if last_days != -1 else last_days
              }
    received_posts = []

    offset = 0
    ONGOING = True
    while ONGOING:
        params['offset'] = offset
        response = json.loads(requests.post(
            const.API_URL + 'execute.getPostsNew?', params=params).text)

        # Interrupt loop when all posts were received
        if not response['response']:
            break

        received_data = response['response']
        for chunk in received_data:
            chunk_size = len(chunk['ids'])
            for i in range(chunk_size):
                post = dict()
                post['date'] = datetime.fromtimestamp(chunk['dates'][i])
                if  last_days == -1
                    or post['date'].year   >= deadline.year
                    and post['date'].month >= deadline.month
                    and post['date'].day   >= deadline.day:

                    post['id'] = chunk['ids'][i]
                    post[compar_key] = chunk[compar_key][i]
                    received_posts.append(post)
            if 'stop' in chunk:
                ONGOING = False
                break
        offset += 1

    return received_posts


Теперь, когда у нас есть нужные посты, остается только отсортировать и вывести их на экран.

Пример работы. Получаем самые популярные посты в сообществе хабра за последний месяц:



Полный код доступен на Github.
Теги:
Хабы:
Данная статья не подлежит комментированию, поскольку её автор ещё не является полноправным участником сообщества. Вы сможете связаться с автором только после того, как он получит приглашение от кого-либо из участников сообщества. До этого момента его username будет скрыт псевдонимом.