Come integrare Instagram Basic Display API in PHP 7 5 (1)

Come integrare Instagram Basic Display API in PHP 7 Crediti foto @cottonbro su Pexels.com

By on Wed Jul 14 in API, Instagram, Integrazioni, PHP


Introduzione

Questo articolo tratta di Instagram Graph API, in particolare di come integrare le Instagram Basic Display API in PHP 7, e offre una soluzione per integrare sul proprio sito web le chiamate API verso il proprio profilo Instagram, recuperarne i media per poi renderli disponibili sul proprio sito web. Tutto questo utilizzando appunto PHP.

Si tratta quindi di una piccola applicazione PHP che può risiedere in una sottocartella del proprio sito web ed essere modificata a piacimento per salvare i media in percorsi diversi da quello codificato in questa applicazione di partenza.

L’unico prerequisito, oltre alla conoscenza di PHP, è avere Composer già installato, o installarlo se necessario, perché installeremo diversi pacchetti PHP per rendere le cose più semplici e lo sviluppo più rapido. Utile per poi gestirne le dipendenze.

Tutto il codice si trova su Github, disponibile per aggiornamenti, modifiche e bug fixing:
gmaccario/instagram-basic-display-api-php-integration

Le API di Instagram

Una cosa molto importante da tenere in considerazione è che ci sono due tipi di Instagram API:

  • Instagram Graph API: queste API sono destinate alle aziende e ai creatori di Instagram che hanno bisogno di conoscere e controllare completamente tutte le loro interazioni sui social media;
  • Instagram Basic Display API: queste API possono essere utilizzate per accedere a qualsiasi tipo di account Instagram, ma fornisce solo l’accesso in lettura ai dati di base.

In poche parole: Instagram Graph API serve se state costruendo un’app che permetterà alle aziende o ai creatori di Instagram di pubblicare media, moderate i commenti, identificare i media @citati e #”hashtaggati”, o ottenere dati su altri utenti di Instagram, mentre le Instagram Basic Display API possono essere utilizzate per accedere a qualsiasi tipo di account Instagram, ma fornisce solo l’accesso in lettura ai dati di base.

Questo articolo si occupa di Instagram Basic Display API.

La semplicità

La semplicità di caricare una foto su Instagram e ritrovarla poco dopo sul proprio sito web. 

Poco tempo fa (Giugno 2021) ho integrato le Instagram Basic Display API sul sito di un cliente che voleva un modo semplice di aggiungere nuove foto sul proprio sito web. Il cliente in questione è il proprietario di un ristorante che utilizza Instagram piuttosto frequentemente per condividere le migliori foto dei piatti del ristorante. Un CMS come WordPress ad esempio é certamente un sistema molto semplice da utilizzare, non servono conoscenze estreme di informatica, anzi! Peró bisogna tenere conto delle esigenze del cliente. Per il proprietario di un ristorante che già usa e conosce molto bene Instagram anche a livello personale, é molto più semplice aprire l’app di Instagram e condividere una foto appena scattata piuttosto che:

  • aprire il browser, fare il login sul proprio sito in WordPress
  • cliccare su Media nella barra laterale del pannello di amministrazione
  • cliccare su Aggiungi nuovo
  • cercare l’immagine sul proprio device e caricare una nuova foto
  • impostare titolo e caption
  • inserirla poi nella gallery corretta…

Vi assicuro che per una persona non tecnicamente esperta, impegnata quotidianamente in un lavoro lontano dal digitale, e che probabilmente ha molto poco tempo per stare in rete, caricare una foto sul proprio sito WordPress non é una cosa semplice e immediata. Per questo ho realizzato questa integrazione PHP/Instagram.

Ho creato quindi uno script che tramite un Long Lived Token si connette a Instagram e recupera i media presenti sul proprio profilo Instagram. A quel punto è molto semplice salvarle sul proprio server: i media su filesystem e i metadati su database, o su file di testo come json.

Vediamo ora come implementare lo script, che verrà richiamato tramite un cron job.

Composer e autoload

Avvalendoci di Composer abbiamo la possibilità di scrivere un solo include (o require) e richiamare un unico file per fare in modo che i file delle nostre classi siano inclusi automagicamente. Il file in questione è il file autoload.php dentro la cartella vendor. Creiamo un file chiamato composer.json all’interno della cartella principale del progetto e definiamo il percorso di ricerca che servirà per caricare le nostre classi , in questo formato:

{
    "autoload": {
        "psr-4": {
            "App\\": "src/app/"
        }
    }
}

App è il namespace usato dalle nostre classi che si troveranno nel percorso ./src/app/

Apriamo un terminale, spostiamoci all’interno della cartella del nostro progetto e lanciamo il comando:

composer update -vvv 

Il parametro -vvv indica la verbosità del log nel terminale, usato per scopo di debugging, stamperà a video tutti i passaggi in esecuzione.

Composer creerà la cartella vendor e da questo momento siamo in grado di richiamare le classi all’interno del progetto tramite i loro namespace senza dover necessariamente includere il percorso dei loro file con molteplici include o require.

In ultimo, dobbiamo includere l’autoloader nel nostro file di entry point. Iniziamo con il creare un file index.php nella cartella principale del progetto e scriviamo questo codice al suo interno:

require __DIR__ . '/vendor/autoload.php';

Il file .htaccess

Siamo pronti per scrivere le nostre classi in ./src/app/ e richiamarle utilizzando i loro namespace. Vedremo dopo il codice specifico, ma prima mettiamo in sicurezza la cartella principale del progetto utilizzando il file .htaccess (per Apache) e inviamo tutte le richieste in arrivo verso un unico file: il file index.php nella cartella public.

Creiamo quindi un file .htaccess nella cartella principale del progetto e scriviamo questo codice al suo interno:

RewriteEngine On 
RewriteRule ^$ public/index.php [L]
RewriteRule ^((?!public/).*)$ public/$1 [L,NC]

Creiamo una cartella ./public e spostiamo il precente ./index.php dentro la cartella ./public. In ultimo modifichiamo il percorso dell’autoloader, aggiungendo un livello:

require __DIR__ . '/../vendor/autoload.php';

Tutte le richieste in arrivo saranno ora inviate a ./public/index.php.

Entry point

L’entry point della nostra applicazione é quindi ./public/index.php e per ora si occupa di richiamare l’autoloader. In seguito si occuperà anche di capire gli input dell’utente per poi eseguire l’operazione corretta. In questa applicazione di esempio non installeremo un sistema di routing, per cui gli input dell’utente saranno intercettati da funzioni native di PHP, nello specifico filter_input, sanitizzati e filtrati per lanciare la logica corretta.

Le azioni principali dell’applicazione sono:

  • il login su Instagram
  • il recupero del codice OAuth di callback
  • la richiesta per un Long Lived Token e il suo salvataggio crittografato
  • il recupero dei media e il loro salvataggio.

Configurazione

Creiamo un file di configurazione per essere in grado di manipolare lo scorrere degli eventi. Creiamo quindi una cartella ./config in cui creiamo il file config.php. Il file ritornerà una lista PHP, ecco il suo contenuto:

<?php 

return array(
    'secretKey'     => '', // chiave usata per crittografare il token
    'appId'         => '', // Instagram App ID
    'appSecret'     => '', // Instagram App Secret
    'redirectUri'   => '', // Valid OAuth Redirect URIs

    'limit_per_page'=> 8,

    'exclude'       => array(
        // lista di nomi di file IG da escluedere, es. '1262329994_225747415234351_9142654959463375561_n.jpg',
    ),

    'debug'         => true,
    'token_file'    => '..' . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'token.json', // nome del file in cui verrà salvato il token crittografato
);

Espresso Instagram Basic Display API

Per ottenere le informazioni del nostro account Instagram a livello di codice PHP, possiamo scrivere tutte le chiamate necessarie utlizzando la libreria cURL (Client URL Library) di PHP, oppure utilizzare un semplice wrapper PHP che si puó trovare a questo indirizzo. Useremo composer per installarlo nel nostro progetto. Apriamo un terminale, spostiamoci all’interno della cartella del nostro progetto e lanciamo il comando:

composer require espresso-dev/instagram-basic-display-php

Vedremo dopo come utilizzare il wrapper, nel frattempo, oltre il log del terminale, possiamo aprire la cartella ./vendor per controllare che la nuova cartella espresso-dev sia stata effettivamente installata.

Le altre librerie

Con composer sottomano, installiamo anche il resto delle librerie che useremo nell’applicazione:

composer require lablnet/encryption
composer require monolog/monolog
composer require nesbot/carbon

La prima verrà usata per la crittografia del token, la seconda ci mette a disposizione una semplice interfaccia per salvare messaggi di log e la terza la useremo per lavorare con le date.

Le classi dell’applicazione

Le classi della nostra applicazione devono essere salvate nella cartella ./src/app/ come abbiamo visto precedentemente nella sezione a proposito di Composer. La classi di cui abbiamo bisogno sono:

  1. IGBase: classe padre che contiene alcuni metodi comuni;
  2. IGMedia: usata per ottenere i media da Instagram;
  3. IGToken: usata per le operazioni sul token.

IGBase

Contiene i metodi utilizzati dalle sue classi figlie getInstagramBasicDisplayByKeys() e getInstagramBasicDisplayByToken() che ritornano un oggetto InstagramBasicDisplay del wrapper Espresso Instagram Basic Display API. In più, nel costruttore gli viene passato l’array di configurazione, così che sarà disponibile anche alle classi che estendono IGBase.

<?php 

namespace App;

use Lablnet\Encryption;
use EspressoDev\InstagramBasicDisplay\InstagramBasicDisplay;

class IGBase
{
    // Protected because I need it in child classes, same for methods
    protected $config;

    public function __construct(array $config)
    {
        $this->config = $config;
    }

    public function getInstagramBasicDisplayByKeys()
    {
        return new InstagramBasicDisplay([
            'appId'         => $this->config['appId'],
            'appSecret'     => $this->config['appSecret'],
            'redirectUri'   => $this->config['redirectUri'],
        ]);
    }

    protected function getInstagramBasicDisplayByToken()
    {
        if(!\file_exists($this->config['token_file']))
        {
            die('Please, set up your long live token before anything else. Check out the README file.');
        }

        // Get long lived token from config json
        $tokenInfo = json_decode(file_get_contents($this->config['token_file']), true);

        // Decrypt the token
        $encryption = new Encryption($this->config['secretKey']);
        $decryptedToken = $encryption->decrypt($tokenInfo["access_token"]);    

        // Instantiate a new InstagramBasicDisplay object
        $instagram = new InstagramBasicDisplay($decryptedToken);

        // Set user access token
        $instagram->setAccessToken($decryptedToken);

        return $instagram;
    }
}

IGMedia

Contiene il metodo getMedia() che ritorna il valore codificato in json della chiamata curl_exec() del wrapper InstagramBasicDisplay.

<?php 

namespace App;

class IGMedia extends IGBase
{
    public function __construct(array $config)
    {
        parent::__construct($config);
    }

    public function getMedia()
    {
        $instagram = $this->getInstagramBasicDisplayByToken();

        return $instagram->getUserMedia('me', $this->config['limit_per_page']);
    }
}

Questa invece la risposta di getMedia(), o meglio, la risposta ottenuta da $instagram->getUserMedia():

stdClass Object
(
    [data] => Array
        (
            [0] => stdClass Object
                (
                    [caption]
                    [id]
                    [media_type] | es. IMAGE
                    [media_url] 
                    [permalink] 
                    [timestamp] 
                    [username] 
                )

            [1] => stdClass Object
                (
                    [caption]
                    [id]
                    [media_type] | es. IMAGE
                    [media_url] 
                    [permalink] 
                    [timestamp] 
                    [username] 
                )

            [2] => stdClass Object
                (
                    [caption]
                    [id]
                    [media_type] | es. IMAGE
                    [media_url] 
                    [permalink] 
                    [timestamp] 
                    [username] 
                )

        )

    [paging] => stdClass Object
        (
            [cursors] => stdClass Object
                (
                    [before] => 
                    [after] => 
                )

            [next] => 
        )

)

L’attributo paging rappresenta la paginazione ritornata da Instagram, grazie alla quale ci possiamo muovere tra pagine di risultati.

IGToken

Contiene i metodi usati per salvare il Long Lived Token crittografato in un file dentro la cartella ./config e per richiedere un nuovo token in vista della scadenza del precedente.

<?php 

namespace App;

use Carbon\Carbon;
use Lablnet\Encryption;

class IGToken extends IGBase
{
    private $longLivedToken;

    public function __construct(array $config, string $longLivedToken = '')
    {
        parent::__construct($config);

        $this->longLivedToken = $longLivedToken;
    }

    public function getLongLivedToken()
    {
        return $this->longLivedToken;
    }

    public function setLongLivedToken(string $token)
    {
        return $this->longLivedToken = $token;
    }

    public function encryptNewToken()
    {
        if(!$this->longLivedToken)
        {
            die('Please, send the right value for the long-lived-token parameter.');
        }

        // Encrypt the token
        $encryption = new Encryption($this->config['secretKey']);
        $cryptedToken = $encryption->encrypt($this->longLivedToken);

        // Save on file for later
        return file_put_contents($this->config['token_file'], json_encode(
            array(
                "access_token" => $cryptedToken,
                "token_type" => 'init',
                "expires_in" => Carbon::now()->addMonths(2)->timestamp,
            )
        ));
    }

    public function refreshToken()
    {
        // Get long lived token from config json
        $tokenInfo = json_decode(file_get_contents($this->config['token_file']), true);

        // Decrypt the token
        $encryption = new Encryption($this->config['secretKey']);
        $decryptedToken = $encryption->decrypt($tokenInfo["access_token"]);    

        // Get long lived token from config json
        $tokenInfo = json_decode(file_get_contents($this->config['token_file']), true);

        // Check the expiration date
        $expiresInFinalTimestamp = $tokenInfo['expires_in'] + Carbon::now()->timestamp;

        // One week before the expiration
        $oneWeekBeforeExpirationTimestamp = ($tokenInfo['expires_in'] + Carbon::now()->timestamp) - 604800;

        // IG Token usually expires after 2 months.
        // Refresh the token only if the now is greater than one week before expiration. 
        // Note for thest purpose: Carbon::now()->addMonths(3)->timestamp INSTEAD OF Carbon::now()->timestamp
        if(Carbon::now()->timestamp > $oneWeekBeforeExpirationTimestamp)
        {
            $instagram = $this->getInstagramBasicDisplayByToken();
            $oAuthTokenAndExpiresDate = $instagram->refreshToken($decryptedToken);

            // $oAuthTokenAndExpiresDate->access_token;
            // $oAuthTokenAndExpiresDate->token_type;
            // $oAuthTokenAndExpiresDate->expires_in;

            if(isset($oAuthTokenAndExpiresDate->access_token))
            {
                // Encrypt the token
                $encryption = new Encryption($this->config['secretKey']);
                $cryptedToken = $encryption->encrypt($oAuthTokenAndExpiresDate->access_token);

                // Save on file for later
                file_put_contents($this->config['token_file'], json_encode(
                    array(
                        "access_token" => $cryptedToken,
                        "token_type" => $oAuthTokenAndExpiresDate->token_type,
                        "expires_in" => $oAuthTokenAndExpiresDate->expires_in,
                    )
                ));

                return true;
            }
        }

        return false;
    }
}

Preparazione del file di entry point

Ora che abbiamo preparato le nostre classi, possiamo vedere come integrare Instagram Basic Display API in PHP 7. Rimettiamo mano al nostro ./public/index.php e completiamo il resto del codice.

Dopo la riga del require, possiamo aggiungere le classi che utilizzeremo:

use App\Utility; // dobbiamo ancora esplorarla!
use App\IGMedia;
use App\IGToken;
use Monolog\Logger;
use Monolog\Handler\RotatingFileHandler;

Poi recupereremo l’array di config e visualizzeremo gli errori se siamo in modalità debug:

$config = include '..' . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'config.php';

if($config['debug'])
{
    ini_set('display_errors', 1);
    ini_set('display_startup_errors', 1);
    error_reporting(E_ALL);    
}

E possiamo anche impostare un gestore di log, che salverà il file di log con un nome che cambia in base al giorno:

// Add log handler
$logger     = new Logger('ig-integration');
$handler    = new RotatingFileHandler('logs/ig-log.log', 0, Logger::INFO, true, 0664);
$handler->setFilenameFormat('{date}-{filename}', 'Y-m-d');
$logger->pushHandler($handler);

La logica dell’applicazione

Come scrivevo prima, questa applicazione non ha un sistema di routing, per cui ci basterà recuperare gli input dai parametri della query presenti nella url della richiesta e poi gestirne i valori.

// Get values from query parameters
$code           = filter_input(INPUT_GET, 'code', FILTER_SANITIZE_STRING);
$igAction       = filter_input(INPUT_GET, 'action', FILTER_SANITIZE_STRING);
$longLivedToken = filter_input(INPUT_GET, 'long-lived-token', FILTER_SANITIZE_STRING);

A questo punto è tutto molto più semplice, perchè abbiamo già preparato tutto, basta semplicemente separare le azioni e richiamare le classi coinvolte:

if(isset($config) && $longLivedToken)
{
    // No config, no execution
    if(!$config['debug'])
    {
        $logger->info('Production environment. This action is not supported here.');
        return;
    }

    $logger->info('Start encrypt token');

    // Instagram Token class
    $iGToken = new IGToken($config, $longLivedToken);
    $result = $iGToken->encryptNewToken();

    if($result !== false)
    {
        $logger->info('Success! Please check your new token in ./config/token.json');
    }
    else {
        $logger->info('Error! An error occurred saving the token');
    }

    return;
}
else if($igAction == 'get-media') {

    $logger->info('Start IG get media');

    // Instagram Media class
    $iGMedia = new IGMedia($config, $longLivedToken);
    $media = $iGMedia->getMedia(); // stdClass->data

    $logger->info('Found ' . count($media->data) . ' media.');

    $iGToken = new IGToken($config);
    $result = $iGToken->refreshToken();

    $logger->info('Token ' . ($result ? 'refreshed' : 'not refreshed'));

    // App utilities
    $utility = new Utility();
    $result = $utility->filterMedia($media->data, 'media' . DIRECTORY_SEPARATOR);

    $logger->info('Filtered ' . count($result) . ' media.');

    return;
}
else {

    // Authenticate user (OAuth2)
    if(!empty($code))
    {
        // Ready for the login
        $iGToken = new IGToken($config);
        $instagram = $iGToken->getInstagramBasicDisplayByKeys();

        $logger->info('Code from Instagram: ' . $code);

        // Get the short lived access token (valid for 1 hour)
        $token = $instagram->getOAuthToken($code, true);

        // Exchange this token for a long lived token (valid for 60 days)
        $token = $instagram->getLongLivedToken($token, true);

        // Instagram Token class
        $iGToken->setLongLivedToken($token);

        $result = $iGToken->encryptNewToken();

        $logger->info('Encrypted new token successfully.');

        echo "<a href='?action=get-media'>Get media</a>";

        return;
    }
}


// Ready for the login and initialize the class
$iGToken = new IGToken($config);
$instagram = $iGToken->getInstagramBasicDisplayByKeys();

echo "<a href='{$instagram->getLoginUrl()}'>Instagram Login</a>";

Ho voluto lasciare per ultimo la classe Utility (anch’essa sotto ./src/app). La sua funzione è quella di svolgere operazioni che non riguardano Instagram, ma che ne utilizzano i dati. Per esempio, ho implementato un metodo, modificabile a piacimento, per filtrare e salvare i media. A questo punto, il vostro sito sarà in grado di leggere media e info e caricarli su pagine specifiche, o all’interno di gallerie fotografiche.

<?php 

namespace App;

class Utility
{
    // Filter media type by $filter from IG API
    public function filterMedia(array $media, string $targetDir, array $exclude = array(), $filter = 'IMAGE', $action = 'save-media')
    {
        $mediaOutput = array();
        foreach($media as $m)
        {
            if($m->media_type == $filter)
            {
                $originalFilename = basename(parse_url($m->media_url, PHP_URL_PATH));

                //echo $m->media_url . " ||| " . $originalFilename . "<br />";
                if(in_array($originalFilename, $exclude))
                {
                    continue;
                }

                $ext = pathinfo($originalFilename, PATHINFO_EXTENSION);

                $m->filename = substr($this->slugify($m->caption), 0, 75) . '.' . $ext;

                if($action != 'save-media')
                {
                    // Set output image
                    $mediaOutput[] = $m;
                }
                else {
                    // Save image
                    $result = $this->createFolderIfNotExists($targetDir);
                    $result = $this->convertJpgToWebpORPng($targetDir, $m->filename, $m->media_url);

                    // New webp extension
                    $m->filename = str_replace('jpg', $result['conversion'], $m->filename);

                    if(file_exists($result['path']) !== false)
                    {
                        // Set output image
                        $mediaOutput[] = $m;
                    }
                }
            }
        }

        // Collect info
        if($action == 'save-media')
        {
            file_put_contents($targetDir . 'info.json', json_encode($mediaOutput));
        }


        return $mediaOutput;
    }

    public function slugify(string $text)
    {
        // replace non letter or digits by -
        $text = preg_replace('~[^\pL\d]+~u', '-', $text);

        // transliterate
        $text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);

        // remove unwanted characters
        $text = preg_replace('~[^-\w]+~', '', $text);

        // trim
        $text = trim($text, '-');

        // remove duplicate -
        $text = preg_replace('~-+~', '-', $text);

        // lowercase
        $text = strtolower($text);

        if (empty($text)) {
            return 'n-a';
        }

        return $text;
    }

    private function convertJpgToWebpORPng($targetDir, $filename, $fileurl)
    {
        $jpg = \imagecreatefromjpeg($fileurl);

        \imagepalettetotruecolor($jpg);
        \imagealphablending($jpg, true);
        \imagesavealpha($jpg, true);

        $path = '';
        $conversion = '';
        if(function_exists('imagewebp'))
        {
            $conversion = 'webp';

            $path = $targetDir . str_replace('jpg', $conversion, $filename);

            \imagewebp($jpg, $path, 100);
        }
        else {
            $conversion = 'png';

            $path = $targetDir . str_replace('jpg', $conversion, $filename);

            \imagepng($jpg, $path, 7);
        }

        \imagedestroy($jpg);

        return array(
            'path'          => $path,
            'conversion'    => $conversion,
        );
    }

    private function createFolderIfNotExists(string $path)
    {
        if (!file_exists($path)) 
        {
            mkdir($path, 0777, true);
        }
    }
}

Avvio tramite cron job

L’applicazione può essere richiamata tramite cron job, ogni minuto/ora o quando pensate sia necessario. Così, mentre siete in un parco e scattate una foto, la condividete su Instagram: quando il cron job lancia la nostra applicazione, la vostra nuova foto apparirà sul vostro sito!

In più: utilizzando un cron job per lanciare questa applicazione, l’esecuzione delle chiamate verso Instagram é disaccoppiata dall’esecuzione del sito. Quest’ultimo infatti, deve solo leggere le informazioni dei media da un file che é sempre aggiornato e soprattutto, sullo stesso server, così come le immagini.

In conclusione

Abbiamo quindi visto come integrare Instagram Basic Display API in PHP 7.

Lo script è modificabile, e installabile in una sotto cartella del vostro sito. Modificatela in base alle esigenze del vostro sito per leggere le informazioni ottenute. Lanciatelo tramite cron job e condividete le vostre immagini su Instagram per vederle poi condivise anche sul vostro sito.

Non esitate a scrivermi tramite il sito o su Linkedin, o aprire una issue su Github.

A presto!