Автоматизация Instagram

Категория: Решение задач | Скилл: js , php , node.js | Дата: 02.06.2020
По работе попалась интересная задача по автоматизации instagram, а именно надо было просто провести розыгрыш. Сервисов для организации этой затеи достаточно, есть даже бесплатные. Но были дополнительные (читай премиум) условия, к тому же мне очень захотелось самому посмотреть что там внутри этой популярной инстаграмы и быть может набраться опыта в построении API :)

Первым делом пошел смотреть что там говорят интернеты. Чтение официальных доков по API instagram дали четко понять, что владельцы не хотят давать доступ к неограниченной автоматизации, можно автоматизировать работу со своим аккаунтом в базовом варианте, но это не подходило под мою задачу, а 'бизнес' вариант API требовал верификациии компании, что естественно мне не подходит. (Может быть уже что-то изменилось ...)

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

Авторизация

Начинаем снифать (fiddler + waterfox) запросы на сайте instagram и смотреть что там. Понятное дело принимаем куки и устанавливаем их куда следует. Этот этап пропустим.

Анализ

Далее, надо авторизироваться.

Запрос в консоли Firefox, почему там проблема с Access-Control-Allow-Origin я не знаю

При беглом осмотре стало понятно - за авторизацию отвечает POST запрос https://www.instagram.com/accounts/login/ajax/ с интерсным набором параметров. Интерес вызывает именно enc_password - склееная строка, логически разделяемая : и состоящая (судя по всему, на основании этих данных) из:

  • описания входящего пароля (PWD_INSTAGRAM_BROWSER)
  • версии шифрования (10)
  • времени шифрования (unixtime 1591030811)
  • шифрованной строки с паролем
Немного поигравшись не трудно догадаться что unixtime принимает участие в шифровании, так как шифрованная строка при каждом запросе разная.

Погуглив, можно найти варианты авторизации без шифрования пароля, однако эти методы на момент написания данного материала уже не работали. На просторах github можно найти вариант шифрования пароля на nodejs. Обнадежил этот вариант, но оказался не рабочим.

Проблема

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

Разбирая стек я понял, что исходные данные кодировались в нужную форму, а затем отправлялись на шифрование дальше в js. Через несколько часов гуляния по стеку с минифицированным ассинхронным js (лютая хрень) я заблудился ... и решил не принимать этот вызов (самоятотельного разбора этого блудняка), а найти альтернативный путь, потому что неизвестно в какой момент владельцам инсты стрельнет в голову сменить алгоритм шифрования.

Первая дыра в безопасности

Прежде чем окончательно забросить идею авторизации в instagram на php, я решил проверить как там обстоят дела с авторизацией на основании сгенерированных данных на js. Для этого взял эти данные из перехваченного трафика (можно из консоли где xhr запросы, но я взял из fiddler) и попробовал авторизироваться через php на локальном сервере. Наконец-то получил заветный позитивный ответ об успешной авторизации и токен.

{"authenticated": true, "user": true, "userId": "30821314326", "oneTapPrompt": true, "status": "ok"}

Перекинул эти данные на хосинг ... получилось авторизироваться. Запустил авторизация с этими данными через 7 дней - авторизация прошла, то есть эти критически важные данные никаким образом не привязаны ни к чему-либо (ни к геолокации, ни к ip), и перехватив их злоумышленник без труда получит доступ к аккаунту.

Решение проблемы и вторая дыра в безопасности

Тогда я вспомнил мое недавнее знакомство с nodejs и puppeteer и решил попробовать скинуть этап авторизации на указанные технологии. И получилось :)

const puppeteer = require('puppeteer');
const fs = require("fs");

//**************************************************************************

console.log(process.argv);

const g_sLogin = process.argv[2];
const g_sCookieFile = g_sLogin+".txt";
const g_sPassword = process.argv[3];

//**************************************************************************

const g_sStartUrl = "https://www.instagram.com/direct/inbox/";
const g_sLoginPage = "accounts/login";

//**************************************************************************

async function auth() 
{
  var browser = await puppeteer.launch({headless: false});
  var page = await browser.newPage();

  if(fs.existsSync(g_sCookieFile))
  {
    let sCookie = fs.readFileSync(g_sCookieFile, "utf8");
    let aCookie = JSON.parse(sCookie);

    await page.setCookie(...aCookie);
  }

  const response = await page.goto(g_sStartUrl);
  await response;

  if(response.url().indexOf(g_sLoginPage) != -1)
  {
    console.log("login ...");
    await page.waitFor('input[name="username"]');
    await page.focus('input[name="username"]');
    await page.keyboard.type(g_sLogin);
    await page.focus('input[name="password"]');
    await page.keyboard.type(g_sPassword);
    await page.click('button[type="submit"]');
    await new Promise(r => setTimeout(r, 2000));
  }

  await new Promise(r => setTimeout(r, 3000));
  
  let aCookie = await page.cookies();
  fs.writeFileSync(g_sCookieFile, JSON.stringify(aCookie));
	
  await browser.close();

  return JSON.stringify(aCookie);
}

//**************************************************************************

auth();

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

Перехватив один раз заголовки после авторизации, последующую неделю удавалось их спокойно использовать (есесно совместно с куками):

'sec-fetch-mode: cors'
'x-ig-www-claim: hmac.AR0Ql6c4RLfhpLxeA4h6-XzrIh5PScH_pVUWaRq7kBoqxshH'
'user-agent: Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3882.0 Safari/537.36'
'accept: */*'
"referer: https://www.instagram.com/"
'x-requested-with: XMLHttpRequest'
'x-csrftoken: 19ccPDQ5PoxYyNIp5lLH8L8HtGd9HnYH'
'x-ig-app-id: 936619743392459'

Нести на php хостинг node.js затея так себе, есть вариант запускать node.js на локальной машине по расписанию, и пусть данные после отработки автоматически отсылаются на целевой сервер, но это так, мысли в слух.

Сборка данных

Данные без пагинации

Теперь настало время сборки необходимх данных. Первым делом нужно достать информацию о посте. Она находится в html ответе на запрос вида

https://www.instagram.com/p/shortcode/

где вместо shortcode нужно написать символьный идентификатор нужного поста. Незамысловатой регуляркой вытаскиваем json со страницы и выдергиваем из него нужные данные.

/(\{\"graphql\".*\}\}\}\})/ui

Просматривать html ответ через браузер не совсем удобно, можно просто скопипастить в привычный редактор и там рассмотреть что надо

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

https://www.instagram.com/username/

и регулярка:

/window\._sharedData \= (\{.*\})\;\<\/script\>/ui

Данные с пагинацией

А теперь самое интересное :)

Нужно вытащить все комментарии, лайки, подписчиков. Опять идем и анализируем запросы ...

Instagram API использует graphql (что-то типа свой rust-full со своими приколами). Запрос выглядит следующим образом

https://www.instagram.com/graphql/query/?query_hash=HASH&variables={...}

где:

  • query_hash - это хэш запрашиваемых данных:
    • лайки - d5d763b1e2acf209d62d22d184488e57
    • комментарии - bc3296d1ce80a24b1b6e40b1e72903f5
    • подписчики - c76146de99bb02f6415203be841dd25a
    • отметки - ff260833edf142911047af6024eb634a
  • variables - это json (urlencode естественно), данные внутри для каждого запроса могут немного отличаться, однако, в каждом запросе, который запрашивает массив данных и предполагает постраничную навигацию есть:
    • first - количество запрашиваемых данных (даже если запросить больше 50 вернется только 50)
    • after - предположительно это хэш записи от которой отсчитывается ffirst. fafter не нужен в первом запросе, и возвращается в каждом запросе, если есть еще данные для чтения (иначе null)
Например сборка лайков:

$hCurl = curl_init();
curl_setopt($hCurl, CURLOPT_HEADER, TRUE);
curl_setopt($hCurl, CURLOPT_FOLLOWLOCATION, 2);
curl_setopt($hCurl, CURLOPT_REFERER, "https://www.instagram.com/p/$sShortcode/");
curl_setopt($hCurl, CURLOPT_TIMEOUT, 10);
curl_setopt($hCurl, CURLOPT_HTTPHEADER, GetArrHeaders());
curl_setopt($hCurl, CURLOPT_SSL_VERIFYHOST, 0);
curl_setopt($hCurl, CURLOPT_SSL_VERIFYPEER, 0);
curl_setopt($hCurl, CURLOPT_RETURNTRANSFER, 1);


$hasNext = false;
$sAfter = null;
$iCountLikes = 0;
$aLikes = [];

do
{
	$aQuery = [];
	$aQuery["shortcode"] = $sShortcode;
	$aQuery["include_reel"] = true;
	$aQuery["first"] = 50;
	if($sAfter)
		$aQuery["after"] = $sAfter;

	$sUrl = "https://www.instagram.com/graphql/query/?query_hash=d5d763b1e2acf209d62d22d184488e57&variables=".urlencode(json_encode($aQuery));
	curl_setopt($hCurl, CURLOPT_URL, $sUrl);
	$sResponse = curl_exec($hCurl);

	$sResponse = str_replace("\r\n", "\n", $sResponse);
	$aResponse = explode("\n\n", $sResponse);
	$aResponse = json_decode($aResponse[1], true);
	$aLikesInfo = $aResponse["data"]["shortcode_media"]["edge_liked_by"];

	foreach($aLikesInfo["edges"] as $aLike)
	{
		$aLike = $aLike["node"];
		$aLikes[] = [
			"id" => $aLike["id"],
			"username" => $aLike["username"],
			"fullname" => $aLike["full_name"],
			"ava" => $aLike["profile_pic_url"],
			"is_private" => $aLike["is_private"],
		];
	}

	$hasNext = $aLikesInfo["page_info"]["has_next_page"];
	$sAfter = $aLikesInfo["page_info"]["end_cursor"];
	$iCountLikes = $aLikesInfo["count"];

	usleep(500 * 1000);
}
while($hasNext);

Да, usleep обязателен, иначе доступ по этому запросу при частом обращении будет ограничен (даже если попытаться юзать как обычный пользователь через веб версию). Особенно это актуально для больших обьемов данных. Например для сборки ~300 лайков и ~1000 комментариев, такая пауза вполне нормальная, но на сборке ~5000 комментариев мой фейковый акк не раз получал бан, из-за чего пришлось увеличить паузу между запросами до 3-5 секунд. И то в некоторых случаях (видимо все зависело от звезд), инстаграм выдавал бан на этот запрос.

Фрагмент ответа

Не трудно увидеть что ответ содержит 3 лишних уровня вложенности (это каждый из перечисленных видов запросов), видимо это издержки graphql :)

Итог

В итоге на организацию коленочного API ушло около 20 часов.

Качеством instagram API в веб версии сайта я разочарован, не думал что будет так посредственно, вроде на генерации данных для авторизации все закручено под максимум, но потом все спущено и образуется большая дыра в безопасности - увел куки с заголовками и получил доступ.

Instagram заставил попотеть и испытать различные эмоции от использования API, но поставленная цель была достигнута в полном обьеме :)
Я Виталий, ник в сети Byurrer.
Увлекаюсь программированием, веду интересные проекты, пишу здесь об интересующих меня вещах: о работе, проектах, увлечениях и проффесиональном развитии.
Мое резюме

Проекты
SkyXEngine, PHP-API, S4G
Категории
В разработке :)
Популярное
В разработке :)