Pull to refresh

Instagram-бот для улучшения личной жизни

Reading time9 min
Views25K

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

Так как общаемся мы исключительно в Instagram, я подумал, что неплохо бы совместить все это и автоматизировать процесс. Тем более что у соцсети вроде как есть открытый API.

Как оказалось, полноценного официального API там нет, а тот что есть – поддерживает только бизнес-аккаунты. Но так или иначе - попробовать хотелось.

Я уверен, что существуют сервисы для этого, но сделать собственного рабочего бота вот прямо очень хотелось. Я нашел на Хабре статью про отправку сообщений на PHP из которой взял адрес для отправки запроса (ссылка на статью в конце). А здесь я постараюсь описать, по сути, тот же процесс, но на Питоне с маленькой доработкой. Тот же бот, с минимальным набором функций. Может, кому-то пригодится.

Полный код и README на Github, а ниже - ключевые моменты.

Схема скрипта

Для организации кода и какой-никакой возможности расширения функционала в будущем, скрипт разбился на 3 класса:

  1. Login – отвечает за авторизацию и создает сессию;

  2. MessageMaker – формирование сообщения;

  3. SendMsg – непосредственная отправка сообщения.

И дополнительно, 2 конфигурационных файла auth.txt и conf.txt: данные авторизации и словарь с сообщениями соответственно и менеджер запуска – insta_bot_manager.py.

Структура директории
Структура директории

Класс Login - авторизация

Посмотрим как работает авторизация Instagram. Для этого смотрим исходящие запросы прям в инструментах браузера:

Как видно – запрос отправляется на адрес https://www.instagram .com/accounts/login/ajax/, да и выглядит довольно просто. Нет ни токенов, ни каких-то левых параметров. Вот только пароль в зашифрованном виде. Как я выяснил, это кодировка AES-GCM256, очевидно, с каким-то префиксом. Строка из запроса выглядит так:

#PWD_INSTAGRAM_BROWSER:10:16940921:enc_password

Параметр "10" - обозначает пароль в зашифрованном виде, далее - время и сам пароль. Делать свой шифровальщик я, конечно же, не буду, но есть и другой способ залогиниться с паролем в чистом виде. Для передачи обычной строки достаточно заменить "10" на "0":

#PWD_INSTAGRAM_BROWSER:0:1690149:password 

Для хранения данных авторизации используется файл – auth.txt. Знаю, что лучше хранить это все в зашифрованном виде, но так как данные находится только на сервере – это относительно безопасно.

Конструкция auth.txt выглядит так:

  1. Login

  2. Password

  3. Ig_user_id (id пользователя которому отправляем сообщение)

Просто текст. Каждый параметр должен быть записан с новой строки. Читаем из файла:

with open("auth.txt", "r") as f:
  l = f.read().split("\n")
  username = l[0]
  passwd = l[1]
  self.user_id = l[2]

Теперь авторизуемся, используя requests:

# Для начала, получим csrf-token:
r = requests()

# Можно использовать кусок полной ссылки для авторизации или сделать запрос прямо к https://www.instagram.com/.
login_url = "https://www.instagram.com/accounts/login/"

# В заголовках можно указать свой user-agent. В другом случае, приходит оповещение безопасности в приложении, которое лучше подтвердить.
# При отсутствии или каком-то мусоре в заголовке User-Agent, IG присылает в ответе ошибку “message”: “user-agent missmatch”.

# Так как имитируется сессия – сразу изменим user-agent прямо в объекте сессии.
s = request.Session()
s.headers["User-Agent"] = ("Mozilla/5.0 (iPhone; CPU iPhone OS 14_1 like Mac OS X) " + 
												   "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Instagram 142.0.0.22.109" +
												   "(iPhone12,5; iOS 14_1; en_US; en-US; scale=3.00; 1242x2688; 214888322) NW/1")

# Ну и, собственно, сам запрос
get_token = r.get(login_url, headers=headers)

if get_token.cookies.get("csrftoken") is not None:
  headers["x-csrftoken"] = get_token.cookies["csrftoken"]
  #Если не получилось вытянуть  токен из cookie – пробуем другой путь
else:
  if csrf_token.get("success") != None:
    headers["x-csrftoken"] = csrf_token.get("value")
    if csrf_token.get("error"):
      return csrf_token

Отправляем POST-запрос с полученными данными:


login = r.post(login_url + “ajax/”, data=auth_data, headers=headers)

# статус операции можно проверить, распарсив ответ от сервера. 
#Если значение status: ‘ok’ в словаре response, и присутствует UserId – все хорошо.
# Но, для простоты, можно посмотреть установились ликуки "sessionid"
if log_in.cookies.get("sessionid"):
	return {'success': 'ok', 'response': log_in.content}

Полный ответ должен выглядеть как-то так:

Успешный ответ попытки авторизации
Успешный ответ попытки авторизации

Ищем csrf-токен вручную

Если меняется IP, агент, пароль и, наверняка, какие-то другие параметры клиента – Инстаграм начинает требовать подтверждение политики использования cookie. Соответственно, куки нет, токена нет и нормальный POST-запрос невозможен.

Если посмотреть текстовое представление ответа на первый GET-запрос по адресу /accounts/login/– можно найти токен в форме авторизации.

csrf-токен в ответе. 19 г. до н. э.
csrf-токен в ответе. 19 г. до н. э.

Для поиска сделаем простую регулярку, которая не будут работать только с этим ответом:

import re

def response_parse(self, response):
	if response is not None:
		token = re.search('(csrf_token":")+(?P<value>[A-Za-z0-9]*)', response)
		token_value = token.groupdict()
    
		if token_value is not None:
			return {"success": "ok", "value": token_value.get("value")}
    
		return {"error": "Unable to extrack token from response!"}

Как правило, это срабатывает только для первой попытки, после изменения параметров. Почему-то после авторизации куки начинают выдаваться как обычно. НО, важно следить за значением токена – после успешного запроса он сменится в cookie и, хорошо бы его перезаписать.

“Умный” выбор сообщения

Так как бот создан с единственной целью – отправлять девушке сообщения по утрам, то стоило бы добавить ему немного «мозгов». Вообще, схема подбора сообщения выглядит довольно-таки тривиально – читается список доступных фраз из файла и рандомно выбирается заветная.

Поскольку скрипт будет срабатывать каждый день, а сообщения попадаются вида «Доброе утро, уже на работе?» - лучше следить за тем, чтобы такое не отправлялось в выходные дни, и наоборот.

Для этого создадим словарь conf.txt с предложенными фразами в папке рядом со скриптом и придумаем для него простейший синтаксис. Мне пришло в голову выделять сообщения для выходного дня символом “@”, будних дней - “!”, а блоки без выделения отправлять каждый день. Ну и комментарии (куда без них) - “/”. Пример словаря:

С добрым утром <3

!
Привет, как добралась на работу?
!

@
Доброе утро, какие планы на день?
@

  
// Комментарий здесь. Это вообще не обрабатывается

Обработка словаря и псевдорандомный выбор

Заниматься подбором сообщения будет класс MessageMaker. В конструктор добавим только with as для чтения словаря:

def __init__(self):
	with open("conf.txt", "r" as get_f:
		self.get_f = get_f.read().split('\n')

Для определения дня недели можно использовать datetime.today().weekday():

day = datetime.today().weekday()

# Если это выходной
if day > 4:
	get_phrase = self.array_sort(array=self.get_f, symbol="!")
  
# Если это будни
if day < 5:
  get_phrase = self.array_sort(array=self.get_f, symbol="@")
  get_random_str = random.choice(get_phrase)

Функция array_sort() принимает 2 параметра: массив строк из conf.txt и разделитель, сообщения которого нужно игнорировать. Результатом выполнения будет новый, отсортированный список, из которого можно будет рандомно выбирать любую фразу (функция random.choice())

Сама сортировка выглядит ужасно примерно так:

def array_sort(self, array, symbol):
  new_array = []
  counter = 0

  for string in array:
    if counter > 0:
    	# Обнуляем счетчик если встречаем такой же символ снова
      if string == symbol:
        counter = 0
        continue
    
    # Пропускаем все строки между указанными разделителями
    if string == symbol:
    	counter += 1

    # Если условия удовлетворяются, то добавляем строку в новый
    # массив, содержащий только доступные фразы
    if string != symbol and len(string) > 2 and string[0] != "/":
    	new_array.append(string)

  return new_array

Теперь добавим главную функцию, которая возвращает непосредственно выбранную строку:

def select_str(self):
	get_random_str = self.almost_random_choice()
 
	if get_random_str != '' and get_random_str != None:
		return get_random_str
	
  return self.select_str()

Поскольку Instagram сам экранирует символы и конвертирует любой тип в str – нет нужды принудительно приводить их вручную.

Тестовый запуск select_str():

Подбор сообщения
Подбор сообщения

SendMsg – непосредственная отправка сообщения

В классе SendMsg – Login и MessageMaker. А также добавим в конструктор инициализацию родительских классов:

class SendMsg(Login, MessageMaker):
	def __init__(self, enc_password=False):
		super().__init__(enc_password=enc_password)
		MessageMaker().__init__()

И, непосредственно, отправляем сообщение, используя весь функционал. Создаем функцию send_message() с необязательными параметрами:

def send_message(self, random_msg=None):
	pass

Если сообщение указано прямо в вызове функции – то данные из файла не читаются, и наоборот. Параметр random_msg принимает любую строку, которую хочется отправить в качестве сообщения.

Логинимся и создаем сессию:

log_in = self.login()

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

if log_in.get("success") is not None:
	# Begin

Определяем набор параметров для отправки POST запроса:

# ссылка для отправки сообщения. Нашел ее на Хабре и еще на каком-то сайте.
send_mess_to_url = "https://i.instagram.com/api/v1/direct_v2/threads/broadcast/text/"

# Генерируем новый uuid v4. Это тоже стандартный функционал Python
uuid_v4 = uuid.uuid4()
 
# Проверяем, как формировать сообщение. Если параметр был задан – используется он
if random_msg is None:
	message = self.select_str()

else:
	message = str(random_msg)
	enc_message = message.encode("utf-8")

# Собственно, тело запроса. Подставляем все параметры.
body = ('text={}&' +
        '_uuid=&' +
        '_csrftoken={}&' +
        'recipient_users="[["{}"]]"&' +
        'action=send_item&' +
        'thread_ids=["0"]&' + '
        'client_context={}').format(enc_message.decode("latin-1"), self.session.cookies["csrftoken"], user_id, uuid_v4)
 
# И все заголовки
headers = self.session.headers
headers["Content-Type"] = "application/x-www-form-urlencoded"
headers["x-csrftoken"] = self.session.cookies["csrftoken"]

# Ig-App-ID можно найти в заголовках запросов к Инстаграм. Он меняется время от времени, но не часто.
headers["X-IG-App-ID"] = "936619743392459"

 Позже добавлю функция проверки X-IG-App-ID. Так как он возвращается в заголовках после успешной авторизации. Не сложно сверить значения и обновить, если требуется.

И отправляем запрос:

send_m = self.session.post(send_mess_to_url, data=body, headers=headers)

Еще желательно определить простейшую функцию логирования, чисто для упрощения отладки. Так как скрипт срабатывает на сервере – хорошо записывать все что происходит:

def _log(*args, **kwargs):
	if args:
		for i in args:
			string = string + ' ' + str(i)
        
  if kwargs:
		for key, value in kwargs:
			string = string + ' ' + str(value)

	with open("log.log", "a") as f:
		print(string, file=f)

Запускаем и смотрим лог:

Сообщение было отправлено на мой второй аккаунт
Сообщение было отправлено на мой второй аккаунт

Сообщение отправилось. Видна дата отправки, статус-код и текст, который был отправлен. Этого с головой достаточно, чтобы идентифицировать проблемы или сделать выбор сообщения не только рандомным, но еще и зависимым от предыдущего дня.

Менеджер запуска

Для удобства запуска, создадим менеджер скрипта – insta_bot_manager.py и поместим его в папку рядом с insta_bot.py.

Разместим функцию-обработчик и импортируем написанный модуль:

from insta_bot import SendMsg
import os

def __send__(enc_password=None, random_msg=None):
	#Создаем экземпляр класса
	s = SendMsg(enc_password=enc_password)
  
	# Отправляем сообщение
	return s.send_message(random_msg=random_msg)
                
 
if __name__ == “__main__”:
	# Можно добавить необязательные параметры enc_password и random_msg
	__send__()

А также проверку существования файла auth.txt. Потому что запускать это все без данных авторизации не имеет смысла:

def _conf_check():
	file_check = os.path.exists(
	os.path.dirname(
			os.path.abspath(__file__)) + "/auth.txt")
    
	if not file_check:
		raise Exception("You must create/or fill the auth.txt file!")
    
	return 1

Теперь если auth.txt по каким-то причинам отсутствует - будет поднято исключение.

Автоматизация процесса в Cron

Поскольку я не хотел добавлять insta_bot_manager.py шебанги bash, то решил просто сделать еще один launcher специально для Cron.

В папке со скриптом создаем файл launcher:

$ touch launcher && nano launcher

Добавим что-то такое:

#!/bin/bash
sleep $[RANDOM%70]m
/usr/bin/python3.6 /home/path/to/insta_bot.manager.py

Получается, перед непосредственным запуском скрипт засыпает на рандомное время до 70 минут.

Вообще, при добавлении в cron стоит проследить за переменными окружения. В частности - PWD. Я получал ошибки из-за различия домашней директории и папки со скриптом. Для ее устранения можно приколхозить, в качестве первой, команду cd с полным путем к папке.

Выводы

Стоит быть осторожным, поскольку такая рассылка не совсем легальна и, вроде как, можно хватануть банхаммером Инстаграмма по лицу. Однако, как мне кажется, отправка 1-2 сообщений в сутки на один и тот же ID не вызовет подозрений. Лично я за несколько недель использования бота не получал никаких предупреждений, но мало ли.

Не известна реакция девушки на такое. С одной стороны – бот сделан с любовью и шлет приятности, с другой – это может пойти по статье «Наплевательское отношение». Но пока что полет нормальный, посмотрим, что будет дальше. Возможно в будущем мне придется слать голосовые сообщения :)

Пример работы
Пример работы

Код на GitHub

Ссылка на статью на Хабре

Tags:
Hubs:
Total votes 22: ↑13 and ↓9+4
Comments44

Articles