Mastodon API – lista obserwujących i obserwowanych

blog.tomaszdunia.pl 1 rok temu

Go to english version of this post / Przejdź do angielskiej wersji tego wpisu

W tym wpisie będzie nieco bardziej technicznie. Uzyskanie listy obserwowanych i/lub obserwujących dla danego konta na Mastodonie nie jest takie oczywiste. Można to zrobić używając oficjalnego API i właśnie to jak to zrobić pokażę w tym wpisie. Co interesujące poniżej opisane zagadnienie może być użyte jako narzędzie OSINT’owe. Podkreślamy to, gdyż za sprawą ‪@avolha@infosec.exchange‬ link do mojego wpisu o Dockerze trafił do zestawienia Weekendowa Lektura: odcinek 512 [2023-03-25], które jest kierowane głównie do bezpieczników (specjalistów zajmujących się cyberbezpieczeństwem), a temat związany z OSINT’em powinien pasować do profilu zainteresowań osób pracujących w tej dziedzinie.

Zajrzyjmy do dokumentacji

Jak każdy szanujący się geekonerd wejdziemy najpierw do dokumentacji dotyczące API Mastodona i zobaczymy co interesującego można tam wyczytać na temat, który aktualnie nas interesuje. Rozdział o pobieraniu listy obserwujących znajduje się pod tym linkiem. Natomiast ten o obserwowanych znajduje się tuż pod nim. Z ciekawych informacji jakie wyczytałem to:

  • od wersji 4.0.0 przy zapytaniach o obserwowanych/obserwujacych API nie wymaga uwierzytelnienia się tokenem API, to super, bo wystarczy zwykłe zapytanie HTTP i krok tworzenia aplikacji możemy całkowicie pominąć,
  • do zapytania potrzebujemy ID konta, o którego dane pytamy, a więc musimy wykonać dodatkowy krok i na podstawie adresu profilu lub handle użytkownika musimy ustalić w/w ID (wykonamy to przy pomocy innej funkcji API),
  • domyślna liczba rezultatów jakie możemy uzyskać to 40, jednak można ją zwiększyć do 80 poprzez ustawienie parametru limit=80, niestety taki limit to spora niedogodność, bo o ile konto posiada więcej niż 80 obserwujących/obserwowanych to, aby uzyskać pełną listę będziemy musieli wykonać więcej niż jedno zapytanie do API i w dodatku przeprowadzić odpowiednią paginację zapytań (coś jak podział na strony),
  • do skorzystania z paginacji wykorzystuje się parametry max_id, since_id i min_id, co istotne fraza id zawarta w nazwach tych parametrów wcale nie odnosi się do ID konta, o którym wcześniej mówiłem, a do wartości znanej jedynie dla backendu i bazy danych Mastodona, więc korzystanie z nich jest niejako brodzeniem po omacku, jednakże jest pewien sposób na uproszczenie tego procesu, o którym napiszę za chwilę,
  • jako odpowiedź od serwera otrzymamy listę kont obserwowanych/obserwujących, które dodatkowo będzie zawierała dość obszerne informacje na temat tych kont, pełna ich lista znajduje się pod tym linkiem, jednak z najciekawszych są to:
    • ID konta (np. 110012691117775438)
    • acct (np. to3k@tomaszdunia.pl)
    • display_name (np. ɐıunp zsɐɯoʇ)
    • note (bio profilu)
    • url (np. https://mastodon.tomaszdunia.pl/@to3k)
    • avatar (link do awatara a.k.a. profilówki)
    • followers_count (liczba obserwujących to konto)
    • following_count (liczba obserwowanych przez to konto)
    • statuses_count (liczba tootów a.k.a. postów)

Podstawowe zapytanie

Weźmy mój profil jako przykład roboczy. Link do niego to – https://mastodon.tomaszdunia.pl/@to3k. Zapytanie do API, którego rezultatem będzie uzyskanie listy obserwujących, ma wyglądać następująco:

https://[adres_instancji]/api/v1/accounts/[id_użytkownika]/followers

Adres instancji to w moim przypadku będzie mastodon.tomaszdunia.pl. Natomiast skąd mam znać swój ID użytkownika? Użyjemy do tego innej funkcji API, która pozwala wyszukiwać użytkowników (i co najważniejsze podstawowe ich dane, w tym ID) po nazwie:

https://[adres_instancji]/api/v1/accounts/lookup?acct=[nazwa_użytkownika]

Skonstruujmy zatem stosowny URL – https://mastodon.tomaszdunia.pl/api/v1/accounts/lookup?acct=to3k. Po uruchomieniu go w przeglądarce otrzymamy od serwera odpowiedź w postaci obiektu JSON (wspominałem o tym formacie w tych wpisach). W przeglądarce Firefox, która jest moim podstawowym narzędziem deweloperskim, wygląda to tak:

Szukane ID wskazałem czerwoną strzałką na powyższym zrzucie ekranu. Bierzemy to ID i tworzymy link będący zapytaniem o listę obserwowanych – https://mastodon.tomaszdunia.pl/api/v1/accounts/110012691117775438/followers. W ten sposób otrzymaliśmy obiekt JSON będący tablicą z informacjami o 40 kontach, które obserwują mnie na Mastodonie. Zmodyfikujmy ten link dodając do niego na końcu parametr limit, aby otrzymać dwa razy więcej wyników (wartość maksymalna jaką możemy uzyskać to 80) – https://mastodon.tomaszdunia.pl/api/v1/accounts/110012691117775438/followers?limit=80. Co w przypadku, gdy ktoś ma więcej niż 80 obserwujących i chce uzyskać całą listę? Do tego potrzebujemy wykorzystać paginację, ale o niej w dalszej części wpisu.

Aha, jeszcze lista obserwowanych. Sprawa wygląda bardzo analogicznie z tym, iż w linku frazę followers należy zamienić na followinghttps://mastodon.tomaszdunia.pl/api/v1/accounts/110012691117775438/following?limit=80.

O co chodzi z paginacją?

Pisząc to zastanawiam się czy takie słowo w ogóle istnieje w języku polskim… Może powinienem to przetłumaczyć jak stronicowanie? W każdym razie to sformułowanie pochodzi od angielskiego pagination i w tym kontekście dotyczy tego, iż posiadając limit rezultatów (80), jakie otrzymamy jednosrazowo od serwera, musimy wiedzieć jak sformułować następne zapytanie do API, aby otrzymać inny (niezdublowany) wynik i tym samym rozszerzyć naszą listę aż do momenty, gdy pobierzemy wszystkie jej elementy (kompletna lista obserwujących/obserwowanych). To tak jakby przeglądać tabelę podzieloną na strony składające się z 80 elementów i przełączać się pomiędzy nimi. Jak już wspomniałem wcześniej do ogarnięcia tematu paginacji służą nam parametry max_id, since_id i min_id. Z pozoru parametry te odnoszą się do ID użytkowników, jednak w rzeczywistości tak nie jest. To konkretne ID to odniesienie do wewnętrznej bazy danych serwera, której zawartość jest znana jedynie dla backend’u. A zatem w jaki sposób mamy korzystać z tych parametrów? Zacznijmy od początku.

Załóżmy, iż mam 800 obserwujących. Korzystając z linka – https://mastodon.tomaszdunia.pl/api/v1/accounts/110012691117775438/followers?limit=80, który skonstruowaliśmy wcześniej, otrzymujemy w odpowiedzi od serwera listę 80 kont, które obserwują mnie na Mastodonie. Są to konta posortowane czasowo, zaczynając od najświeższego obserwatora (osoby, która zaczęła mnie obserwować jako ostatnia). Tak, więc 1/10 listy moich obserwowanych już mamy. Jak w takim razie przejść do następnej strony i poznać obserwatorów od 81 do 160? Musimy ustalić jaki będzie URL następnej strony, a informację o tym dostajemy w nagłówku (header) odpowiedzi od API. Jest to konkretnie zawarte w parametrze nazywającym się link. W Firefox wystarczy zmienić zakładkę z JSON na Nagłówki i otrzymamy coś podobnego do tego:

Pozyskajmy wartość tego parametru nagłówkowego:

<https://mastodon.tomaszdunia.pl/api/v1/accounts/110012691117775438/following?limit=80&max_id=700>; rel=”next„, <https://mastodon.tomaszdunia.pl/api/v1/accounts/110012691117775438/following?limit=80&since_id=1183>; rel=”prev„

URL znajdujący się przed rel=”next” zaznaczony na zielono to link do następnej strony z obserwującymi, którego szukaliśmy. Po skorzystaniu z niego otrzymujemy kolejną partię 80 kont, które są moimi obserwującymi.

W ten sposób powtarzamy proces jeszcze 8 razy, aby uzyskać informacje o wszystkich 800 obserwujących. Cała misterna paginacja właśnie stała się oczywista, prawda?

Skrypt PHP

Ręcznie można zrobić to raz, aby zrozumieć cały mechanizm. Dalej potrzebujemy skryptu, który będzie to automatyzował, bo nie jesteśmy, do cholery, dzikusami Poniżej kod skryptu PHP, którego kolejne linijki wyjaśniam (jak zawsze) poprzez komentarze zawarte w treści.

<?php // Pobiera zmienną GET $url = trim(addslashes(strip_tags($_GET['url']))); ?> <!-- Formularz służący do pobrania od użytkownika adresu profilu użytkownika --> <form action="" method="GET" name="form"> <input type="text" name="url" placeholder="Profile URL..." value="<?php echo $url; ?>" size="100"><br><br> <button type="submit">Get Followers/Following</button> </form> <?php if(empty($url)) { // o ile nie podano adresu to zakańcza działanie skryptu exit; } else { // o ile zmienna z adresem nie jest pusta to... // Rozbija adres na domenę (instancji) i nazwę użytkownika $explode_url = explode("@", $url); $mastodon_domain = $explode_url[0]; $mastodon_username = $explode_url[1]; // Wzór regexp do walidacji formatu nazwy użytkownika $check = '/^[a-zA-Z0-9_]+/'; if(filter_var($mastodon_domain, FILTER_VALIDATE_URL) AND preg_match($check, $mastodon_username)) { // o ile domena i nazwa użytkownika zostały zwalidowane jako prawidłowe $profile_url = $url; } else { // o ile domena lub nazwa użytkownika nie przeszły walidacji to wyświetla błąd i zakańcza działanie skryptu echo "Forbidden value of GET variable"; exit; } } // USTALA ID UŻYTKOWNIKA // Konstruuje adres do komunikacji z API $api_url = $mastodon_domain."/api/v1/accounts/lookup?acct=".$mastodon_username; // Konstruuje zapytanie cURL $curl = curl_init($api_url); curl_setopt($curl, CURLOPT_URL, $api_url); curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); curl_setopt($curl, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.182 Safari/537.36'); curl_setopt($curl, CURLOPT_TIMEOUT, 30); curl_setopt($curl, CURLOPT_HEADER, 0); // Wysyła zapytanie cURL i zapisuje wynik do zmiennej $json = curl_exec($curl); // Konwertuje wynik z formatu JSON na zwykłą tablicę $api_result = json_decode($json, true); // Wyciąga z wyniku ID użytkownika i zapisuje do zmiennej $mastodon_id = $api_result['id']; if(empty($mastodon_id)) { // o ile zmienna z ID użytkownika jest pusta to wyświetla błąd i zakańcza działanie skryptu echo "Error while getting account ID, failed to connect to API"; exit; } // FUNKCJA DO WYCIĄGNIĘCIA INFORMACJI Z NAGŁÓWKA ODPOWIEDZI SERWERA API function HeaderLink($curl, $header_line) { if(str_contains($header_line, "link:")) { $GLOBALS['link'] = $header_line; } return strlen($header_line); } // POBIERA LISTĘ OBSERWUJĄCYCH // Licznik znalezionych obserwujących $followers_counter = 0; // Tablica do przechowywania danych znalezionych obserwujących $followers = array(); // Tablica do przechowywania jedynie ID znalezionych obserwujących (potrzebne do uniknięcia duplikatów) $followers_ids = array(); // Konstruuje adres do komunikacji z API $api_url = $mastodon_domain."/api/v1/accounts/".$mastodon_id."/followers?limit=80"; // Konstruuje zapytanie cURL $curl = curl_init($api_url); curl_setopt($curl, CURLOPT_URL, $api_url); curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); curl_setopt($curl, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.182 Safari/537.36'); curl_setopt($curl, CURLOPT_TIMEOUT, 30); curl_setopt($curl, CURLOPT_HEADER, 0); // Odwołuje się do funkcji wyciągającej informację z nagłówka odpowiedzi serwera API curl_setopt($curl, CURLOPT_HEADERFUNCTION, "HeaderLink"); // Wysyła zapytanie cURL i zapisuje wynik do zmiennej $json = curl_exec($curl); // Konwertuje wynik z formatu JSON na zwykłą tablicę $api_result = json_decode($json, true); // Przechodzi przez wszystkie elementy tablicy i wykonuje dla wszystkich następujące czynności... foreach($api_result as $follow) { // Sprawdza czy taki element nie był już przetwarzany (przeciwdziałanie duplikacji) if(!in_array($follow['id'], $followers_ids)) { // Dodaje ID elementu do tablicy z ID $followers_ids[] = $follow['id']; // Dodaje nowy element do tablicy ze znalezionymi obserwującymi $followers[] = array( "id" => $follow['id'], "acct" => $follow['acct'], "display_name" => $follow['display_name'], "url" => $follow['url'], "avatar" => $follow['avatar'], "followers_count" => $follow['followers_count'], "following_count" => $follow['following_count'], "statuses_count" => $follow['statuses_count'] ); // Inkrementuje licznik znalezionych obserwujących $followers_counter++; } } // Ustala adres następnej strony z obserwującymi preg_match("(link: <(.+?)>; rel=\"next\", <.+?>; rel=\"prev\")is", $GLOBALS['link'], $temp); $api_url = $temp[1]; // Pętla, która wykonuje to samo co powyżej, dopóki jest w stanie ustalić adres następnej strony z obserwującymi while(!empty($api_url)) { $curl = curl_init($api_url); curl_setopt($curl, CURLOPT_URL, $api_url); curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); curl_setopt($curl, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.182 Safari/537.36'); curl_setopt($curl, CURLOPT_TIMEOUT, 30); curl_setopt($curl, CURLOPT_HEADER, 0); curl_setopt($curl, CURLOPT_HEADERFUNCTION, "HeaderLink"); $json = curl_exec($curl); $api_result = json_decode($json, true); foreach($api_result as $follow) { if(!in_array($follower['id'], $followers_ids)) { $followers_ids[] = $follow['id']; $followers[] = array( "id" => $follow['id'], "acct" => $follow['acct'], "display_name" => $follow['display_name'], "url" => $follow['url'], "avatar" => $follow['avatar'], "followers_count" => $follow['followers_count'], "following_count" => $follow['following_count'], "statuses_count" => $follow['statuses_count'] ); $followers_counter++; } } preg_match("(link: <(.+?)>; rel=\"next\", <.+?>; rel=\"prev\")is", $GLOBALS['link'], $temp); $api_url = $temp[1]; } // POBIERA LISTĘ OBSERWOWANYCH // Licznik znalezionych obserwowanych $following_counter = 0; // Tablica do przechowywania danych znalezionych obserwowanych $following = array(); // Tablica do przechowywania jedynie ID znalezionych obserwowanych (potrzebne do uniknięcia duplikatów) $following_ids = array(); // Konstruuje adres do komunikacji z API $api_url = $mastodon_domain."/api/v1/accounts/".$mastodon_id."/following?limit=80"; // Konstruuje zapytanie cURL $curl = curl_init($api_url); curl_setopt($curl, CURLOPT_URL, $api_url); curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); curl_setopt($curl, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.182 Safari/537.36'); curl_setopt($curl, CURLOPT_TIMEOUT, 30); curl_setopt($curl, CURLOPT_HEADER, 0); // Odwołuje się do funkcji wyciągającej informację z nagłówka odpowiedzi serwera API curl_setopt($curl, CURLOPT_HEADERFUNCTION, "HeaderLink"); // Wysyła zapytanie cURL i zapisuje wynik do zmiennej $json = curl_exec($curl); // Konwertuje wynik z formatu JSON na zwykłą tablicę $api_result = json_decode($json, true); // Przechodzi przez wszystkie elementy tablicy i wykonuje dla wszystkich następujące czynności... foreach($api_result as $follow) { // Sprawdza czy taki element nie był już przetwarzany (przeciwdziałanie duplikacji) if(!in_array($follow['id'], $following_ids)) { // Dodaje ID elementu do tablicy z ID $following_ids[] = $follow['id']; // Dodaje nowy element do tablicy ze znalezionymi obserwowanymi $following[] = array( "id" => $follow['id'], "acct" => $follow['acct'], "display_name" => $follow['display_name'], "url" => $follow['url'], "avatar" => $follow['avatar'], "followers_count" => $follow['followers_count'], "following_count" => $follow['following_count'], "statuses_count" => $follow['statuses_count'] ); // Inkrementuje licznik znalezionych obserwowanych $following_counter++; } } // Ustala adres następnej strony z obserwowanymi preg_match("(link: <(.+?)>; rel=\"next\", <.+?>; rel=\"prev\")is", $GLOBALS['link'], $temp); $api_url = $temp[1]; // Pętla, która wykonuje to samo co powyżej, dopóki jest w stanie ustalić adres następnej strony z obserwowanymi while(!empty($api_url)) { $curl = curl_init($api_url); curl_setopt($curl, CURLOPT_URL, $api_url); curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); curl_setopt($curl, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.182 Safari/537.36'); curl_setopt($curl, CURLOPT_TIMEOUT, 30); curl_setopt($curl, CURLOPT_HEADER, 0); curl_setopt($curl, CURLOPT_HEADERFUNCTION, "HeaderLink"); $json = curl_exec($curl); $api_result = json_decode($json, true); foreach($api_result as $follow) { if(!in_array($follow['id'], $following_ids)) { $following_ids[] = $follow['id']; $following[] = array( "id" => $follow['id'], "acct" => $follow['acct'], "display_name" => $follow['display_name'], "url" => $follow['url'], "avatar" => $follow['avatar'], "followers_count" => $follow['followers_count'], "following_count" => $follow['following_count'], "statuses_count" => $follow['statuses_count'] ); $following_counter++; } } preg_match("(link: <(.+?)>; rel=\"next\", <.+?>; rel=\"prev\")is", $GLOBALS['link'], $temp); $api_url = $temp[1]; } ?> <!-- WYŚWIETLENIE WYNIKÓW --> <h1>Followers</h1> <b>Number of followers found:</b> <?php echo $followers_counter; ?><br><br> <table> <tr> <th>Lp.</th> <th>Avatar</th> <th>ID</th> <th>Handle</th> <th>Name</th> <th>Followers</th> <th>Following</th> <th>Toots</th> <th>URL</th> </tr> <?php $i = 1; foreach($followers as $follow) { echo "<tr>"; echo "<td>".$i."</td>"; echo "<td><img src=\"".$follow['avatar']."\" style=\"max-width: 50px; max-height: 50px;\" /></td>"; echo "<td>".$follow['id']."</td>"; echo "<td>".$follow['acct']."</td>"; echo "<td>".$follow['display_name']."</td>"; echo "<td>".$follow['followers_count']."</td>"; echo "<td>".$follow['following_count']."</td>"; echo "<td>".$follow['statuses_count']."</td>"; echo "<td><a href=\"".$follow['url']."\">".$follow['url']."</a></td>"; echo "</tr>"; $i++; } ?> </table> <h1>Following</h1> <b>Number of following found:</b> <?php echo $following_counter; ?><br><br> <table> <tr> <th>Lp.</th> <th>Avatar</th> <th>ID</th> <th>Handle</th> <th>Name</th> <th>Followers</th> <th>Following</th> <th>Toots</th> <th>URL</th> </tr> <?php $i = 1; foreach($following as $follow) { echo "<tr>"; echo "<td>".$i."</td>"; echo "<td><img src=\"".$follow['avatar']."\" style=\"max-width: 50px; max-height: 50px;\" /></td>"; echo "<td>".$follow['id']."</td>"; echo "<td>".$follow['acct']."</td>"; echo "<td>".$follow['display_name']."</td>"; echo "<td>".$follow['followers_count']."</td>"; echo "<td>".$follow['following_count']."</td>"; echo "<td>".$follow['statuses_count']."</td>"; echo "<td><a href=\"".$follow['url']."\">".$follow['url']."</a></td>"; echo "</tr>"; $i++; } ?> </table>

Wynik działania skryptu:

Skrypt jest również dostępny na moim GitHub’ie.

Idź do oryginalnego materiału