Node.jsJavaScriptFastify

Gatsby Webhook Service (4) - Instagram

Den Instagram Feed eines Nutzers ohne direkten API Zugriff anzufragen stellte sich als tricky heraus. Ich kann nachvollziehen das Facebook bzw. Meta, wie sich nun nennen, nicht glücklich damit ist Bandbreite für Fremdwebseiten zu opfern.

image
Was die rechtliche Seite angeht: Das Urheberrecht liegt beim Nutzer, sollte er denn der Urheber sein. In Deutschland ist das nicht übertragbar und mit dem Akzeptieren der Instagram AGB wird gewährt man auch nur ein Nutzungsrecht.

Nach etwas Recherche fand ich heraus, dass die ganzen Plugins, die nur mit dem Benutzernamen auskamen, die URL https://www.instagram.com/[username]/?__a=1 nutzten. Diese funktioniert immer noch aber nur wenn die Cookies für die Session gesetzt sind. Sprich man muss eingeloggt sein.

Daten von Instagram laden

Wir benötigen zwei Routen. Eine zum Laden der Links zu den Bildern und Posts und eine zum Laden der Bilder. Die Metadaten werden gecached damit Instagram bei zu vielen Anfragen nicht einfach den Stecker zieht. Wie oben schon erwähnt ist in diesem speziellen Fall der Download der Bilder okay. Das machen wir auch, wenn diese Angefragt werden. Dabei optimieren wir auf Dateigröße und verringern die Auflösung. Der Downstream wird geklont. Einer landet direkt auf der Festplatte wo er später wieder abgerufen werden kann und der andere wird zum Client gesendet.

image
./api/routes/instagram.js
const { promises: fs, createWriteStream } = require('fs');
const { get } = require('https');
const got = require('got');
const path = require('path');
const sharp = require('sharp');
const { parse } = require('../src/parse');

module.exports = async function(fastify, opts) {
  /*
    Die Funktion fetchData() wrapped get() aus dem nativen https Modul
    in ein Promise, welches in ein Objekt aufgelöst wird, wenn der Stream
    das End Event emittiert. 
  */
  const fetchData = user => new Promise(async (resolve, reject) => {
    get({
      hostname: opts.hostname,
      path: opts.path(user),
      headers: {
        /*
          Die Cookies wurden in app.js in die Redis Instanz geschrieben.
        */
        cookie: await fastify.redis.get('cookies'),
        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36'
      }
    }, async res => {    
      /*
      	Dem Array chunks[] wird beim auslösen des Data Events den Buffer
        des Sockets angehangen. Beim Ende des Streams wird es dann zu einem 
        String zusammen gesetzt und dieser wird der parse() Funktion
        übergebn. Durch Probleme mit der Zeichenkodierung konnte ich nicht
        einfach alles mit JSON.parse() in ein Objekt konvertieren. 
      */
      const chunks = [];            
      res.on('data', data => chunks.push(data.toString('utf-8')));
      res.on('error', error => reject(error));
      res.on('end', async () => resolve({ user: user, media: parse(chunks.join()) }));        
    });
  });
  /*
    Das ist die öffentliche Route zu den JSON Metadaten, die der 
    Client benötigt um den Markup zu erzeugen. 
  */
  fastify.get('/api/instagram/:user', async (req, res) => {      
    const { redis } = fastify;
    const user = req.params.user;  
    let data = await redis.get(user);
    /*
      Prüfen ob die Daten im Cache vorhanden sind.
    */
    if (!data || data.includes(null)) {          
      /*
        Falls sie das nicht sind wird eine HTTP Anfrage an Instagram
        gesendet, die benötigten Daten in ein Objekt geschrieben und 
        gecached.
      */
      data = await fetchData(user);
      data = JSON.stringify(data).replace(/null,/g, '');
      
      redis.set(user, data);
      redis.expire(user, opts.expire);

      fastify.log.info(`cache for ${user} updated`);     
    }
    /*
      Die Daten werden zum Client gesendet.
    */
    res.header('Content-Type', 'application/json; charset=utf-8');
    res.send(data);    
  });
  /*
    Laden und senden der Bilder.
  */
  fastify.get('/api/static/:image', async (req, res) => {    
    const image = req.params.image;
    const [filename] = image.split('?');    
    const file = path.join(__dirname, '../static', filename);

    try {            
      /*
        Es wird geprüft ob das Bild schon vorhanden ist.
      */
      await fs.stat(file);                    
      res.sendFile(filename);
    } catch(error) {          
      if (error.code === 'ENOENT') {            
        /*
          Sollte das nicht der Fall sein, wird die URL in ihre Bestandteile
          zerlegt um aus den Parametern die URL zu Instagram zu extrahieren. 
        */
        const url = req.url.split('?');         
        /*
          Es werden drei Streams initialisiert. Einer ist der HTTP 
          Stream, der von Instagram downloaded, der andere ist ein 
          Dateistream für das Speichern auf den lokalen Datenträger
          und der Dritte ist der von Sharp, welcher das Bild manipuliert.
        */
        const gstream = got.stream(new URL(`${url[1]}?${url[2]}`));
        const fstream = createWriteStream(file);
        const sharpStream = sharp({
          failOnError: false
        });
		/*
          Wir senden schon mal alles okay. Es ist ein JPEG.
        */
        res.raw.writeHead(200, { 'Content-Type': 'image/jpeg' });
        /*
          Der HTTP stream wird in Sharp gepiped (geleitet).
        */
        gstream.pipe(sharpStream);
        /*
          Sharp macht daraus zwei Streams. Der eine wird gespeichert und 
          der andere wird zum Client gesendet.
        */
        sharpStream
          .clone()
          .resize({ width: 680 })
          .jpeg({ quality: 80 })
          .pipe(res.raw);

        sharpStream
          .clone()
          .resize({ width: 680 })
          .jpeg({ quality: 80 })
          .pipe(fstream);     
          
        sharpStream.on('error', error => fastify.log.error(error.message));
        sharpStream.on('end', () => fastify.log.info(`${file} downloaded and resized`));
      } else {
        fastify.log.error(error.message);            
        throw error;
      }
    }                
  });
}
./api/src/parse.js
const extract = (str, target) => str.split(target)[1].split('","')[0].replace(/,/g, '');

function parse(data) {
  if (typeof data !== 'string') {
    throw new Error('argument should be a string');
  }

  let buffer = data;  
  const arr = buffer.split('"__typename":"GraphImage",');  
  
  arr.splice(0, 1);

  const result = arr.map(line => {
    if (line.includes('"shortcode":"') && line.includes('"display_url":"')) {
      return {
        shortcode: extract(line, '"shortcode":"'),
        display_url: extract(line, '"display_url":"'),
      }
    }
  });

  return result;
}

exports.parse = parse;
./api/routes/index.js
module.exports = {
  access: require('./access-token'),
  // gatsby: require('./gatsby'),
  instagram: require('./instagram'),
  // private: require('./private'),
};

Weitere Blog Posts aus dieser Serie

  1. Gatsby Webhook Service
  2. Gatsby Webhook Service (2) - Das große Containern
  3. Gatsby Webhook Service (3) - JWT Authentifizierung
  4. Gatsby Webhook Service (4) - Instagram
  5. Gatsby Webhook Service (5) - Gatsby