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

01.05.2020 / integrations
Пробуем написать скрипты для автоматизации действий в instagram в условиях отсуствия API, снифаем трафик, изучаем, смотрим что там внутри и что получается

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

Краткий обзор решений

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

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

Авторизация

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

Анализ

Надо авторизироваться.

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

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

Немного поигравшись не трудно догадаться что unixtime принимает участие в шифровани, так как шифрованная строка при каждом запросе разная.

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

Проблема

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

Разбирая стек я понял, что исходные данные кодировались в нужную форму, а затем отправлялись на шифрование дальше по коду.

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

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

Прежде чем окончательно забросить идею авторизации в instagram на php, я решил проверить как там обстоят дела с авторизацией на основании сгенерированных данных. Для этого взял эти данные из перехваченного трафика (можно из консоли где 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 login = process.argv[2];
const cookieFile = login+".txt";
const password = process.argv[3];

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

const startUrl = "https://www.instagram.com/direct/inbox/";
const loginPage = "accounts/login";

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

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

  if(fs.existsSync(cookieFile)) {
    const cookieJson = fs.readFileSync(cookieFile, "utf8");
    const cookie = JSON.parse(cookieJson);

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

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

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

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

  return JSON.stringify(cookie);
}

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

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'

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

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

Первым делом нужно достать информацию о посте. Она находится в html ответе на запрос вида https://www.instagram.com/p/shortcode/, где вместо shortcode нужно написать символьный идентификатор нужного поста. Незамысловатой регуляркой /(\{\"graphql\".*\}\}\}\})/ui вытаскиваем json со страницы и выдергиваем из него нужные данные.

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

Примерно таким же образом достается инфа о странице пользователя, по адресу https://www.instagram.com/username/ и регулярка /window\._sharedData \= (\{.*\})\;\<\/script\>/ui

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

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

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

Instagram API использует graphql. Запрос выглядит следующим образом

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

где:

Например сборка лайков:

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

$hasNext = false;
$after = null;
$countLikes = 0;
$likes = [];

do {
    $query = [];
    $query["shortcode"] = $shortcode;
    $query["include_reel"] = true;
    $query["first"] = 50;

    if ($after) {
        $query["after"] = $after;
  }

    $url = "https://www.instagram.com/graphql/query/?query_hash=d5d763b1e2acf209d62d22d184488e57&variables=" . urlencode(json_encode($query));
    curl_setopt($ch, CURLOPT_URL, $url);
    $response = curl_exec($ch);

    $resExp = explode("\n\n", str_replace("\r\n", "\n", $response));
    $response = json_decode($response[1], true);
    $likesInfo = $response["data"]["shortcode_media"]["edge_liked_by"];

    foreach($likesInfo["edges"] as $like) {
        $like = $like["node"];
        $likes[] = [
            "id" => $like["id"],
            "username" => $like["username"],
            "fullname" => $like["full_name"],
            "ava" => $like["profile_pic_url"],
            "is_private" => $like["is_private"],
        ];
    }

    $hasNext = $likesInfo["page_info"]["has_next_page"];
    $after = $likesInfo["page_info"]["end_cursor"];
    $countLikes = $likesInfo["count"];

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

А это скрин с примером ответа: Фрагмент ответа

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

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

Итог

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

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

Instagram заставил попотеть и испытать различные эмоции от использования web-версии API, но поставленная цель была достигнута в полном обьеме :)