По разным причинам я уже как пару лет я не использовал ВК для связи с друзьями/коллегами - все ушли в telegram. Но в ВК все равно приходилось заходить, так как там осталось самое дорогое - группы с мемами, картиночками, анекдотами и всякое такое. Самому смотреть неудобно, друзьям - не отправить. Если интересно как я решил эту проблему за один вечер - добро пожаловать под кат .
Итак, для того, чтобы что-то откуда-то перенести (телепортировать) - нужно выполнить две вещи - это что-то откуда-то взять и потом это что-то куда-то положить.
TL;DR Я написал скрипт, который может собирать посты из ВК и загружать их в канал в телеграме. Если не интересны технические подробности, то можно сразу перейти к установке.
Часть 1. Загрузка постов в систему
В этой части все просто. Сначала напишем функцию для обращения к API:
async callMethod(method, params){
const uri = new URLSearchParams(params).toString()
return await axios.get(`https://api.vk.com/method/${method}?v=5.131&access_token=${this.token}&${uri}`)
}
Далее реализуем метод для получения численного id из строкового, при этом VK API поддерживает передачу сразу списка групп, поэтому нет смысла для каждого id вызывать метод, можно просто перечислить через запятую:
async getMemesGroupIds(){
const ids = await this.resolveIdByName(this.memesGroups.join(','))
return ids.map(val => val.id)
}
async resolveIdByName(name){
try {
const res = await this.callMethod('groups.getById', {
group_ids: name
})
if(res.error !== undefined){
console.error(res.error)
throw new Error('Error on resolveIdsByName')
}
return res.data.response
}catch (e) {
console.error('Error on resolveIdsByName', `https://api.vk.com/method/groups.getById?v=5.131&group_ids=${name}&access_token=${token}`)
}
}
Далее метод для получения записи каждого сообщества, тут тоже все крайне просто:
async getPosts(ownerId, offset){
try {
const data = await this.callMethod('wall.get', {
owner_id: ownerId,
count: 100,
offset: 0
})
if(data.error !== undefined){
throw new Error('Error on getPosts')
}else{
return data;
}
}catch (e) {
console.error('Error on getPosts')
}
}
Теперь немного о нюансах и логике работы. Метод wall.get возвращает все посты, в том числе рекламные, репосты, опросы и так далее. Нам нужны записи, удовлетворяющие нескольким критериям. Во-первых - не реклама. Определять будем так: запись должна быть не репостом, у нее не должно быть поля источника, не должно быть пометки "реклама", она не должна содержать ссылок в формате URL (https://site.ru), запись не должна быть закрепленной и в формате Markdown ([Public|club123]). Во-вторых - по типу вложения: изначально я хотел получать себе только фотографии, видео и просто текст без вложений, но с версии API 5.0 ВК не отдает ссылку на видео, а только на плеер, поэтому решил ограничится только фотографиями и текстом. В-третьих, записи должны быть опубликованы днем ранее, чтобы скрипт срабатывал ночью, а утром я мог посмотреть все, что было опубликовано вчера (но получилось не совсем так, почему - читайте дальше). В итоге получилось три функции - первая возвращает день из timestamp, если он передан (для определения даты записи), если не передан - текущую дату. Вторая проверяет содержат ли вложения недопустимые типы - в целом, можно расширить на любое вложение, если добавить его в массив allowedTypeAttachment и написать обработчик. Третья - postRules проверяет все на удовлетворение всем трем условиям, ну и последняя по id группы возвращает все отфильтрованные посты
formattedDate(timestamp = 0){
if(timestamp === 0){
let a = new Date()
a.setDate(a.getDate() - 1)
return a.getMonth() + '/' + a.getDate()
}else{
let a = new Date(timestamp)
return a.getMonth() + '/' + a.getDate()
}
}
isAllowedAttachmentType(attachment){
const allowedTypeAttachment = ['photo']
if(attachment === undefined)
return true
return attachment.find(val => allowedTypeAttachment.includes(val.type)) !== undefined
}
postRules(postObject){
const text = postObject['text']
const isContainUrl = /((https?|ftp):\/\/?[^\s/$.?#].[^\s]*$)|\[.*?\|.*?\]/gm.test(text)
return postObject['marked_as_ads'] === 0 && // не реклама
this.formattedDate() === this.formattedDate(postObject.date * 1000) && // запись опубликовна вчера
this.isAllowedAttachmentType(postObject['attachments']) && // содержит только допустимые вложения
postObject['is_pinned'] !== 1 && // не закрепленная запись
postObject['copy_history'] === undefined && // не репост
!isContainUrl // не содержит ссылок
}
async getPostGroup(ownerId){
let post = await this.getPosts(ownerId)
return post.data.response.items
.filter(val => this.postRules(val))
}
Кстати, тут есть еще один нюанс, который возможно неочевиден - timestamp, который корректно обрабатывается в JS - это количество мс, прошедших с 1 января 1970. Timestamp, который возвращает ВК (и, кстати, telegram) - это количество секунд, прошедших с той же даты, поэтому и такая странная строчка - postObject.date * 1000.
Теперь, когда есть отфильтрованные посты, можно применить немного магии. У каждого из них есть два поля - views и likes, которые, очевидно, отвечают за просмотры и лайки (ваш кэп). Если поделить их друг на друга, можно получить безразмерную величину, назовем ее views-like ratio - по ней можно определить, какие записи понравились пользователям больше. Дальше все просто - сортируем записи по VL ratio для каждой группы в отдельности, и берем процент от полученных постов (я поставил 80%):
async processSingleGroup(ownerId){
const countPostsPercent = 80 / 100;
const minPostsForCutting = Math.ceil(1/countPostsPercent) // минимальное число, при котором взятие процента вернет больше единицы
let items = []
const posts = await this.getPostGroup(ownerId)
posts.forEach(value => {
let res = {}
res.text = value.text
res.views = value.views.count
res.likes = value.likes.count
res.vlRatio = value.likes.count / value.views.count
res.date = new Date(value.date*1000)
res.owner = value.owner_id
const attObj = this.processAttachment(value['attachments'])
if(attObj !== undefined){
res = {...res, ...attObj}
}
items.push(res)
})
const sortedItems = items.sort((a, b) => {
return b.vlRatio - a.vlRatio; // сортируем по VL Ration
})
if(minPostsForCutting >= sortedItems.length){
return sortedItems // если постов меньше, чем процент - используем все
}else{
return sortedItems.slice(0, sortedItems.length*countPostsPercent)
}
Для обработки вложения я написал функцию, которая обрабатывает массив с фотографиями (VK API). Объект вложений содержит несколько размеров фотографий (описание), они либо обрезаны (метка размера - o, p, q, r), что нам не подходит, либо пропорциональные - s, m (не подходят, маленький размер), x, y, z, w - идеально походит z (наибольший пропорциональный размер), вроде как все просто, но потом оказалось, что его может и не быть (почему, нигде не написано), а х пока был всегда. В итоге полученная функция обработки вложений:
processAttachment(attachment) {
const attachmentObj = {
photo: [],
//video: [] // в будущем можно прикрутить и видео
}
if(attachment === undefined)
return attachment
attachment.forEach(att => {
switch(att.type) {
case 'photo':
// Берем самое большое изображение
let photo = att.photo.sizes.filter(sizeObject => sizeObject['type'] === 'z' || sizeObject['type'] === 'x').at(-1)
attachmentObj.photo.push(photo.url)
break;
case 'video':
// TODO: прикрутить видео
break
}
})
return attachmentObj
}
Дальше дело за малым - просто пройти по всем группам и собрать их посты в один большой массив, потом его перемешать, чтобы не записи не шли по порядку VL Ratio, и вроде бы все, но я их еще записываю в JSON файл, зачем - чуть дальше. Получается:
async processAll(){
let posts = []
const ids = await this.getMemesGroupIds()
for (const id of ids) {
const p = await this.processSingleGroup(`-${id}`)
posts = [...posts, ...p]
}
/* перемешиваем */
return posts.map(value => ({ value, sort: Math.random() }))
.sort((a, b) => a.sort - b.sort)
.map(({ value }) => value)
}
storePosts(posts){
const len = posts.length
const resultObject = {
length: len,
posts
}
fs.writeFile('posts.txt', JSON.stringify(resultObject), err => {
if (err) {
console.error(err);
}
console.log(`Stored ${len} posts`)
// file written successfully
});
}
В итоге, финальный класс получается таким:
Hidden text
import axios from "axios";
import fs from 'fs'
class Vk{
constructor(token, memesGroups) {
this.token = token
this.memesGroups = memesGroups
}
async callMethod(method, params){
const uri = new URLSearchParams(params).toString()
return await axios.get(`https://api.vk.com/method/${method}?v=5.131&access_token=${this.token}&${uri}`)
}
async getPosts(ownerId, offset = 0){
try {
const data = await this.callMethod('wall.get', {
owner_id: ownerId,
count: 100,
offset: 0
})
if(data.error !== undefined){
throw new Error('Error on getPosts')
}else{
return data;
}
}catch (e) {
console.error('Error on getPosts')
}
}
async resolveIdByName(name){
try {
const res = await this.callMethod('groups.getById', {
group_ids: name
})
if(res.error !== undefined){
console.error(res.error)
throw new Error('Error on resolveIdsByName')
}
return res.data.response
}catch (e) {
console.error('Error on resolveIdsByName', `https://api.vk.com/method/groups.getById?v=5.131&group_ids=${name}&access_token=${token}`)
}
}
async getMemesGroupIds(){
const ids = await this.resolveIdByName(this.memesGroups.join(','))
return ids.map(val => val.id)
}
formattedDate(timestamp = 0){
if(timestamp === 0){
let a = new Date()
a.setDate(a.getDate() - 1)
return a.getMonth() + '/' + a.getDate()
}else{
let a = new Date(timestamp)
return a.getMonth() + '/' + a.getDate()
}
}
isAllowedAttachmentType(attachment){
const allowedTypeAttachment = ['photo']
if(attachment === undefined)
return true
return attachment.find(val => allowedTypeAttachment.includes(val.type)) !== undefined
}
postRules(postObject){
const text = postObject['text']
const isContainUrl = /((https?|ftp):\/\/?[^\s/$.?#].[^\s]*$)|\[.*?\|.*?\]/gm.test(text)
return postObject['marked_as_ads'] === 0 &&
this.formattedDate() === this.formattedDate(postObject.date * 1000) &&
this.isAllowedAttachmentType(postObject['attachments']) &&
postObject['is_pinned'] !== 1 &&
postObject['copy_history'] === undefined &&
!isContainUrl
}
async getPostGroup(ownerI){
//let items = [];
let post = await this.getPosts(ownerId)
return post.data.response.items
.filter(val => this.postRules(val))
}
async processSingleGroup(ownerId){
const countPostsPercent = 80 / 100;
const minPostsForCutting = 80
let items = []
const posts = await this.getPostGroup(ownerId)
posts.forEach(value => {
let res = {}
res.text = value.text
res.views = value.views.count
res.likes = value.likes.count
res.vlRatio = value.likes.count / value.views.count
res.date = new Date(value.date*1000)
res.owner = value.owner_id
const attObj = this.processAttachment(value['attachments'])
if(attObj !== undefined){
res = {...res, ...attObj}
}
items.push(res)
})
const sortedItems = items.sort((a, b) => {
return b.vlRatio - a.vlRatio;
})
if(minPostsForCutting >= sortedItems.length){
return sortedItems
}else{
return sortedItems.slice(0, sortedItems.length*countPostsPercent)
}
}
processAttachment(attachment) {
const attachmentObj = {
photo: [],
//video: []
}
if(attachment === undefined)
return attachment
attachment.forEach(att => {
switch(att.type) {
case 'photo':
// Берем самое большое изображение
//console.log(att.photo.sizes.filter(sizeObject => sizeObject['type'] === 'z'))
let photo = att.photo.sizes.filter(sizeObject => sizeObject['type'] === 'z' || sizeObject['type'] === 'x').at(-1)
attachmentObj.photo.push(photo.url)
break;
case 'video':
// TODO: прикрутить видео
break
}
})
return attachmentObj
}
async processAll(){
let posts = []
const ids = await this.getMemesGroupIds()
for (const id of ids) {
const p = await this.processSingleGroup(`-${id}`)
posts = [...posts, ...p]
await this.sleep(50)
}
/* перемешиваем */
return posts.map(value => ({ value, sort: Math.random() }))
.sort((a, b) => a.sort - b.sort)
.map(({ value }) => value)
}
storePosts(posts){
const len = posts.length
const resultObject = {
length: len,
posts
}
fs.writeFile('posts.txt', JSON.stringify(resultObject), err => {
if (err) {
console.error(err);
}
console.log(`Stored ${len} posts`)
// file written successfully
});
}
sleep(ms){
return new Promise(r => setTimeout(r, ms));
}
}
export default Vk
Вызов:
const vk = new Vk(token, memesGroups)
const res = await vk.processAll()
vk.storePosts(res)
Часть 2. Постинг в telegram
Я уже занимался автопостингом в telegram, но в прошлый раз я использовал Client API (это то еще развлечение, почитать можно тут), в этот раз решил пойти более простым путем, и использовать Bot API, он гораздо понятнее задокументирован, и имеет некоторые приятные фичи (например, можно передавать картинку боту просто ссылкой на файл, тогда как в Client API нужно его загружать, а чтобы понять как - надо еще очень много времени потратить). Так вот, в методе sendMessage можно передать sheduleTime - и создавать сколько угодно отложенных постов, это и был мой изначальный план - разово все загрузить и поставить отложенные сообщения каждый час в течении дня, но оказывается Bot API так не умеет. Тогда я решил, что в можно просто сразу все подгружать в канал разово ночью, а днем смотреть сразу все. Но тут снова пришел нюанс - ограничение по количеству постов бота в единицу времени окутано завесой тайны. Так, например, тут говорят про 30 запросов в секунду, и не более 20 запросов в одну и ту же группу в течении минуты. На деле же, 429 ошибка (которая сообщает, что превышен лимит запросов) - падает весьма рандомно, иногда отваливаясь на 20 постах, иногда на 15, иногда пропускает до 45-50. Чтож, надо что-то придумать, а так как это все-таки даже не петпроект, а маленькая автоматизация, то городить что-то очень сложное и красивое мне было лень, и я изобрел костыль с сохранением в файл, и просто по задаче crontab-a беру оттуда записи, публикую, после чего пересохраняю записи в файл, удалив из них опубликованные. Работает и ладно :)
Итак, сначала снова напишем вспомогательный метод, чтобы обращаться к Bot API:
async callMethod(method, params){
try{
return await axios.post(`https://api.telegram.org/bot${this.token}/${method}`, params)
}catch (e){
console.error('Error with callMethod', e.response.data)
}
}
Дальше напишем три небольших оберточки вокруг callMethod, чтобы загружать текст, фотографию, и несколько фотографий:
async sendText(text){
return await this.callMethod('sendMessage', {
chat_id:this.destinationChat,
text:text
})
}
async sendPhoto(text, photo){
return await this.callMethod('sendPhoto', {
chat_id:this.destinationChat,
caption:text,
photo
})
}
async sendMediaGroup(text, arrayOfPhotos){
let mediaTypes = arrayOfPhotos.map((url, key) => {
let media = {
type: 'photo',
media: url
}
return key === 0 ? {caption: text, ...media} : media // описание для нескольких фотографий
})
return await this.callMethod('sendMediaGroup', {
chat_id:this.destinationChat,
caption:text,
media: mediaTypes
})
}
В 20 строке в этом коде есть проверка на значение номера фотографии. Это еще одна не очень очевидная вещь - есть в sendPhoto у нас есть поле caption, которое позволяет добавить подпись в картинке, то в sendMediaGroup такого нет - там есть только подписи к каждой картинке. Решается так - чтобы были подписи для нескольких картинок, надо эту подпись задать к первой картинке, а у остальных оставить подпись пустой.
Ну и осталось просто загружать данные в канал, методы простые и выглядят так, думаю, пояснять не надо, с комментариями тут все просто:
async processVkPosts(posts){ // постинг массива постов
for (const post of posts) {
console.log('Process', request)
if('photo' in post){
if(post['photo'].length > 1){
await this.sendMediaGroup(post['text'], post['photo']) // если несколько фотографий
}else{
await this.sendPhoto(post['text'], post['photo'][0]) // если одна фотография
}
}else{
await this.sendText(post['text']) // если только текст
}
}
}
async postPartGroup({length, posts}){ // выбираем из общего количества часть для постинга
const chunkSize = Math.floor((7 / 100) * length ) // сколько записей будет опубликовано из общего числа (в данном случае 7%)
const sliceEnd = chunkSize > length ? length : chunkSize
const processPosts = posts.slice(0, sliceEnd)
const otherPosts = posts.slice(sliceEnd, posts.length-1)
console.log('Proccess part, json len: ', length, 'real post: ', posts.length,' process post: ', processPosts.length, ' other posts: ', otherPosts.length, ' slice end: ', sliceEnd)
const resObject = {length, posts: otherPosts}
fs.writeFileSync('posts.txt', JSON.stringify(resObject));
await this.processVkPosts(processPosts)
}
В итоге класс для telegram выглядит так:
Hidden text
import axios from "axios";
import fs from 'fs'
class Telegram{
constructor(token, destinationChat) {
this.token = token
this.destinationChat = destinationChat
this.token = token
}
async callMethod(method, params){
try{
return await axios.post(`https://api.telegram.org/bot${this.token}/${method}`, params)
}catch (e){
console.error('Error with callMethod', e.response.data)
}
}
async sendText(text){
return await this.callMethod('sendMessage', {
"chat_id":this.destinationChat,
"text":text
})
}
async sendPhoto(text, photo){
return await this.callMethod('sendPhoto', {
"chat_id":this.destinationChat,
"caption":text,
photo
})
}
async sendMediaGroup(text, arrayOfPhotos){
let mediaTypes = arrayOfPhotos.map((url, key) => {
let media = {
type: 'photo',
media: url
}
return key === 0 ? {caption: text, ...media} : media
})
return await this.callMethod('sendMediaGroup', {
"chat_id":this.destinationChat,
"caption":text,
'media': mediaTypes
})
}
async processVkPosts(posts){
let request = 0
for (const post of posts) {
if(request++ % 14 === 0){
await this.sleep(1000)
}
console.log('Process', request)
if('photo' in post){
if(post['photo'].length > 1){
await this.sendMediaGroup(post['text'], post['photo'])
}else{
await this.sendPhoto(post['text'], post['photo'][0])
}
}else{
await this.sendText(post['text'])
}
}
}
async postPartGroup({length, posts}){
const chunkSize = Math.floor((7 / 100) * length )
const sliceEnd = chunkSize > length ? length : chunkSize
const processPosts = posts.slice(0, sliceEnd)
const otherPosts = posts.slice(sliceEnd, posts.length-1)
console.log('Proccess part, json len: ', length, 'real post: ', posts.length,' process post: ', processPosts.length, ' other posts: ', otherPosts.length, ' slice end: ', sliceEnd)
const resObject = {length, posts: otherPosts}
fs.writeFileSync('posts.txt', JSON.stringify(resObject));
await this.processVkPosts(processPosts)
}
sleep(ms){
return new Promise(r => setTimeout(r, ms));
}
}
export default Telegram
Инициализация:
const telegram = new Telegram(токен_бота, '@канал_в_который_постить')
telegram.postPartGroup(JSON.parse(fs.readFileSync('posts.txt', 'utf8')))
Далее осталось все собрать вместе. Я использовал для crontab, чтобы каждый день в 3 часа ночи скрипт собирал посты, а дальше в другой задачи постил. Для этого необходимо либо разнести функционал парсинга и постинга в разные файлы, или использовать аргументы командной строки. Я выбрал второй вариант, и получилось так:
const args = process.argv
if(args[2] === 'get'){
const res = await vk.processAll()
vk.storePosts(res)
}
if(args[2] === 'post'){
telegram.postPartGroup(JSON.parse(fs.readFileSync('posts.txt', 'utf8')))
}
Соответственно, команда для парсинга - node index.js get
, для постинга - node index.js post
Часть 3. Как все запустить для себя
Максимально подробный гайд:
Получить access_token для ВК. Самый простой способ без генерации ссылок:
1.1 Создаем Standalone приложение
1.2 Заходим в список приложений, открываем наше приложение, открываем настройки, копируем
ID приложения
и вставляем в ссылку:https://oauth.vk.com/authorize?client_id=ваш айди&display=page&redirect_uri=&response_type=token&revoke=1&scope=
1.3 Жмем "подтвердить", вас перекинет на страницу, у которой в адресной строке будет access_token, сохраняем его.
Создать бота и получить его токен. Пишем https://t.me/BotFather, вводим название, получаем токен. Дальше пишем нашему боту для активации. Сохраняем токен бота
Создать канал, добавить туда нашего бота.
Загрузить и настроить бота - скачать файлы отсюда. В файле index.js устанавливаем: переменную token - токен из ВК, переменную memesGroups - массив id групп ВК, в инициализации указываем id канала, в который будем загружать записи.
Проверить, все ли нормально -
node index.js get
должен загрузить данные и положить их в файл post.txt,node index.js post
должен загрузить первую порцию записей в каналОсталось запустить crontab как вам удобно. У меня в 3 часа ночи запускается загрузка, и c 7 до 24 постинг. Пример моего crontab:
0 1 * * * cd /home/dir/ && /root/.nvm/versions/node/v16.6.0/bin/node /home/dir/index.js get >> /home/getTest.txt
0 7-24 * * * cd /home/dir/ && /root/.nvm/versions/node/v16.6.0/bin/node /home/dir/index.js post >> /home/PostTest.txt
P.S
Результат можно посмотреть тут посмотреть. Если зайдет - можете подписаться, но осторожно, там мемы, а мои вкусы весьма специфичны, я предупредил.
Не знаю, насколько это актуально для остальных - но мою проблему решило, может еще кому-нибудь пригодится.
Еще я изначально думал оформить это все в виде бота, чтобы не надо было ничего запускать, из-за ограничения по постингу - думаю, это будет проблематично. Штуку делал для себя, она работает, если будет кому-нибудь интересно, попробую и бота изобрести.