Electron – Google Image Scraper

Electron

Alejandro Lucena

  Electron, Javascript, jQuery, NodeJS

Electron es un framework que nos facilita el desarrollo de aplicaciones de escritorio usando tecnologías web (HTML, CSS y JavaScript). Cualquier Web Developer puede crear rápidamente una aplicación de escritorio para Windows, Linux, o Mac. Electron está creado y mantenido por GitHub y muchas aplicaciones que usamos todos los días están desarrolladas en él como Postman y Discord.

Electron funciona creando dos tipos de procesos, el proceso main y el proceso renderer. Main es el proceso principal de Node.js. Viene a ser nuestra aplicación en si misma. Este proceso tiene acceso a varias API de Electron.js que nos ayudan a comunicarnos con el SO y realizar distintas acciones o efectos.

El segundo (renderer) es un proceso de Chromium, con una diferencia. este Chromium tiene un Node.js incorporado y acceso a todos sus módulos y los que instalemos con npm (esto nos permitiría usar React.jsAngular.jsPolymer, o cualquier otra librería para desarrollar nuestra UI), por lo que desde nuestro renderer podemos usar módulos como fs para leer y escribir en el disco, o hacer peticiones a una base de datos directamente.

Basándonos en todo esto hemos hecho una pequeña aplicación con Electron. Aunque es muy básica, podemos usarla de ejemplo para aprender las bases de esta estupenda tecnología. La app en cuestión se usará para obtener las imágenes de Google Images desde una búsqueda dada por el usuario.

Inicialmente vamos a definir nuestro main a través del main.js. En los comentarios os dejamos la explicación de cada punto relevante del código.

main.js

const { app, BrowserWindow } = require('electron')
const path = require('path')
const url = require('url')

// Mantén una referencia global del objeto ventana, si no lo haces, la ventana se
// cerrará automáticamente cuando el objeto de JavaScript sea basura colleccionada.
let win

function createWindow() {
  // Crea la ventana del navegador.
  win = new BrowserWindow({ width: 800, height: 800 })

  // Para el build final es conveniente quitar el menú y ocultar el devtools ...
  // Quitamos el menú
  // win.setMenu(null)
  // Abre las herramientas de desarrollo.
  win.webContents.openDevTools()

  // y carga el archivo index.html de la aplicación.
  win.loadURL(url.format({
    pathname: path.join(__dirname, 'index.html'),
    protocol: 'file:',
    slashes: true
  }))

  // Emitido cuando la ventana es cerrada.
  win.on('closed', () => {
    // Desreferencia el objeto ventana, usualmente tu guardarias ventanas
    // en un arreglo si tu aplicación soporta multi ventanas, este es el momento
    // cuando tu deberías borrar el elemento correspiente.
    win = null
  })
}

// Este método será llamado cuando Electron haya terminado
// la inicialización y esté listo para crear ventanas del navegador.
// Algunas APIs pueden solamente ser usadas despues de que este evento ocurra.
app.on('ready', createWindow)

// Salir cuando todas las ventanas estén cerradas.
app.on('window-all-closed', () => {
  // En macOS es común para las aplicaciones y sus barras de menú
  // que estén activas hasta que el usuario salga explicitamente con Cmd + Q
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

app.on('activate', () => {
  // En macOS es común volver a crear una ventana en la aplicación cuando el
  // icono del dock es clickeado y no hay otras ventanas abieras.
  if (win === null) {
    createWindow()
  }
})

// En este archivo tu puedes incluir el resto del código del proceso principal de
// tu aplicación. Tu también puedes ponerlos en archivos separados y requerirlos aquí.

Por otra parte tenemos que definir el archivo de configuración de nuestra aplicación. package.json. En el definiremos, el nombre, la versión, licencia, descripción, etc… , así como la información necesaria para hacer los instalables y las dependencias necesarias.

package.json

{
    "name": "googleimagescraper",
    "version": "1.0.0",
    "description": "App for Google Image Scraper",
    "main": "main.js",
    "scripts": {
        "start": "electron .",
        "pack": "build --dir",
        "dist": "build"
    },
    "homepage": "http://www.scomerline.es",
    "author": {
        "name": "Alejandro Lucena Archilla",
        "email": "info@scomerline.es"
    },
    "license": "ISC",
    "build": {
        "appId": "googleimagescraper",
        "asar": true,
        "dmg": {
            "contents": [
                {
                    "x": 110,
                    "y": 150
                },
                {
                    "x": 240,
                    "y": 150,
                    "type": "link",
                    "path": "/Applications"
                }
            ]
        },
        "linux": {
            "target": [
                "AppImage",
                "deb"
            ]
        },
        "win": {
            "target": [
                "NSIS",
                "portable"
            ],
            "icon": "build/icon.ico"
        }
    },
    "devDependencies": {
        "electron": "^2.0.1",
        "electron-builder": "^20.13.4"
    },
    "dependencies": {
        "electron-config": "^1.0.0",
        "google-images": "^2.1.0",
        "request": "^2.87.0"
    }
}

Nosotros nos hemos decantado por la manera más sencilla para crear la UI. Hemos usado HTML5, Bootstrap y jQuery para generar una UI simple. index.html

index.html

<!DOCTYPE html>
<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
  <title>Google Image Scraper</title>
</head>

<body>
  <!-- HEADER -->
  <div class="header">
    <ul class="nav">
      <li class="nav-item">
        <a class="nav-link active" data-element="config" href="#">
          <label class="number-menu">1</label> Configurar</a>
      </li>
      <li class="nav-item">
        <a class="nav-link" data-element="download" href="#">
          <label class="number-menu">2</label> Descargar</a>
      </li>
      <li class="nav-item">
        <a class="nav-link" data-element="info" href="#">
          <label class="number-menu">+</label> Info</a>
      </li>
    </ul>
  </div>
  <!-- /HEADER -->

  <!-- ALERTS -->
  <div class="alerts">
    <div class="alert alert-success" role="alert" id="js-success" style="display: none">
    </div>
    <div class="alert alert-danger" role="alert" id="js-warning" style="display: none">
    </div>
    <div class="alert alert-danger" role="alert" id="js-danger" style="display: none">
    </div>
  </div>
  <!-- /ALERTS -->

  <!-- CONFIG -->
  <div class="content config">
    <h1 class="text-center">Configuración</h1>
    <div class="alert alert-danger" role="alert" id="js-danger" style="display: none">
    </div>
    <div class="form-app">
      <div class="form-group">
        <input type="text" name="cse" placeholder="CSE ID" class="form-control" id="js-cse" />
      </div>
      <div class="form-group">
        <input type="text" name="api_key" placeholder="API KEY" class="form-control" id="js-api-key" />
      </div>
      <div class="form-group">
        <button class="btn-lg btn-primary btn-block" id="js-save">Guardar</button>
      </div>
    </div>
  </div>
  <!-- /CONFIG -->

  <!-- DOWNLOAD -->
  <div class="content download text-center" style="display: none;">
    <h1 class="text-center">Google Image Scraper</h1>
    <div class="form-app">
      <div class="form-group">
        <input type="text" name="search" placeholder="Búsqueda" class="form-control" id="js-search" />
      </div>
      <div class="form-group">
        <div class="row">
          <div class="col-4">
            <button class="btn-lg btn-secundary btn-block" id="js-file-chooser">Carpeta destino</button>
          </div>
          <div class="col-8">
            <input type="text" name="destiny" placeholder="Destino" class="form-control" id="js-destiny" readonly />
          </div>
        </div>
      </div>
      <div class="form-group">
        <button class="btn-lg btn-primary btn-block" id="js-execute">Ejecutar</button>
      </div>
    </div>
    <div class="text-center">
      <div class="loader" style="display: none;"></div>
    </div>
    <div class="progress" style="display: none;">
      <div class="progress-bar" style="width: 0%;">0%</div>
    </div>
    <div class="form-group">
      <textarea rows="4" cols="30" class="form-control log-textarea" readonly style="display: none;" id="js-log">
    </textarea>
    </div>
  </div>
  <!-- /DOWNLOAD -->

  <!-- INFO -->
  <div class="content info" style="display: none;">
    <h1 class="text-center">Información</h1>
    <h2>Configurar Google Custom Search Engine</h2>
    <p>Google elimino su API pública de imágenes, por lo que para buscar imágenes debes registrarte en el motor de búsqueda
      personalizado de Google. Estos son los pasos que debes seguir:</p>
    <h3>1. Crea un motor de búsqueda personalizado de Google</h3>
    <p>Puedes hacer esto aquí:
      <a href="https://cse.google.com/cse" class="link">https://cse.google.com/cse</a>.</p>
    <p>No especifiques ningún sitio para buscar. Utiliza la sección "Restringir páginas utilizando los tipos de Schema.org"
      en las "Opciones avanzadas". Para el conjunto más inclusivo, usa el esquema: Think. Anota la ID del CSE.</p>
    <h3>2. Habilitar búsqueda de imágenes</h3>
    <p>En la configuración de su motor de búsqueda, habilita "Búsqueda de imágenes"</p>
    <h3>3. Configure una API de motor de búsqueda personalizada de Google</h3>
    <p>Registra una nueva aplicación y habilita Google Custom Search Engine API aquí:
      <a href="https://console.developers.google.com"
        class="link">Google Developers Console</a>. Toma nota de la clave API.</p>
  </div>
  <!-- /INFO -->

  <script>window.$ = window.jQuery = require('./assets/vendor/jquery/jquery-3.3.1.min.js');</script>
  <script src="./assets/vendor/bootstrap-4.0.0/js/bootstrap.min.js"></script>
  <script>require('./assets/js/utils.js')</script>
  <script>require('./assets/js/navigation.js')</script>
  <script>require('./assets/js/config.js')</script>
  <script>require('./assets/js/download.js')</script>
  <link rel="stylesheet" href="./assets/vendor/bootstrap-4.0.0/css/bootstrap.min.css" />
  <link rel="stylesheet" href="./assets/css/main.css" />
</body>

</html>

Como podéis observar al final del index.html agregamos todos los assets necesarios para el funcionamiento de la aplicación. Vamos a ver el más relevante de todos que sería el download.js

download.js

const electron = require('electron')
const { dialog } = electron.remote
const fs = require('fs')
const request = require('request')
const gis = require('./google-image-search')
const utils = require('./utils')

$(function () {
    // Seleccionamos la carpeta de destino para las imágenes obtenidas
    $("#js-file-chooser").click(function (event) {
        let fileChooser = dialog.showOpenDialog({ properties: ['openDirectory'] })
        if (fileChooser) {
            $("#js-destiny").val(fileChooser[0]);
        }
    });

    // Ejecutamos el proceso
    $("#js-execute").click(function (event) {
        if (validateForm()) {
            searchImages()
        }
    });
});

/**
 * Función que principal que realiza la búsqueda de imágenes 
 */
function searchImages() {
    let destiny = $('#js-destiny').val()
    let search = $('#js-search').val()
    let $progressBar = $('.progress-bar')
    let contDownload = 1
    let numImgs = 0
    startUi()
    logConsole('Iniciando proceso ...')
    gis.searchImage(search)
        .then(images => {
            if (images.length) {
                numImgs = images.length
                let cont = 1
                images.forEach(function (img) {
                    let type = img['type'].split('/')
                    downloadImage(img['url'], destiny + '/' + cont + '.' + type[1], function () {
                        let por = (contDownload * 100) / numImgs
                        por = Math.round(por * 100) / 100
                        $progressBar.css('width', por + '%').html(por + '%')
                        logConsole('Imagen Guardada ' + contDownload)
                        if (numImgs == contDownload) {
                            logConsole('Proceso finalizado')
                            showSuccess('<b>Proceso finalizado</b>. Compruebe su carpeta de destino para ver las imágenes descargadas')
                            stopUi()
                        }
                        contDownload++
                    })
                    cont++
                });
                writeImagesLog(images)

            }
        }).catch(function (err) {
            stopUi()
            logConsole("Error: " + err);
            showError('Ha habido un error en el proceso de descarga de las Imágenes')
        });
}

/**
 * Guardamos el log de imágenes descargadas
 * @param {*} images 
 */
function writeImagesLog(images) {
    let destiny = $('#js-destiny').val()
    images.forEach(function (img) {
        fs.appendFile(destiny + '/images.log', JSON.stringify(img) + "\n")
    });
}

/**
 * Validamos los campos para revisar que todo es correcto
 */
function validateForm() {
    let cse = $('#js-cse').val()
    let apiKey = $('#js-api-key').val()
    let destiny = $('#js-destiny').val()
    let search = $('#js-search').val()
    let errors = new Array();
    if (!cse) {
        errors.push('Tienes que insertar un CSE ID')
    }
    if (!apiKey) {
        errors.push('Tienes que insertar un API Key')
    }
    if (!destiny) {
        errors.push('Tienes que elegir una carpeta de destino')
    }
    if (!search) {
        errors.push('Tienes que introducir un término de búsqueda')
    }
    if (errors.length) {
        showError(errors.join("<br/>"))
        return false
    }
    return true
}

/**
 * Arrancamos el proceso desactivando y mostrando los elementos necesarios
 */
function startUi() {
    $('.form-app input').attr('disabled', 'disabled')
    $('.form-app button').attr('disabled', 'disabled')
    $('#js-success').hide();
    $('#js-danger').hide();
    $('#js-log').val('')
    $('#js-log').show()
    $('.loader').show();
    $('.progress-bar').css('width', '0%').html('0%')
    $('.progress').show()
}

/**
 * Paramos el proceso ocultando la barra de progreso
 */
function stopUi() {
    $('.form-app input').removeAttr('disabled', 'disabled')
    $('.form-app button').removeAttr('disabled', 'disabled')
    $('.loader').hide()
    $('.progress').hide()
}

/**
 * Función para descargar las imágenes scrapeadas
 * @param {*} uri 
 * @param {*} filename 
 * @param {*} callback 
 */
function downloadImage(uri, filename, callback) {
    request.head(uri, function (err, res, body) {
        request(uri).pipe(fs.createWriteStream(filename)).on('close', callback)
    });
}

/**
 * Función que muestra los errores
 * @param {*} msg 
 */
function showError(msg) {
    utils.showError(msg)
}

/**
 * Función que muestra los mensages de success
 * @param {*} msg 
 */
function showSuccess(msg) {
    utils.showSuccess(msg)
}

/**
 * Función que loguea en consola y en textarea
 * @param {*} msg 
 */
function logConsole(msg) {
    let log = $('#js-log').val();
    $('#js-log').val(formatDate() + ' ' + msg + "\n" + log)
    console.log(msg)
}

/**
 * Función para formatear date en dd/mm/yyyy
 * @param {*} date 
 */
function formatDate() {
    var date = new Date()
    var hours = date.getHours();
    var minutes = date.getMinutes();
    var sec = date.getSeconds()
    var strTime = hours + ':' + minutes + ':' + sec;
    return date.getDate() + "/" + date.getMonth() + 1 + "/" + date.getFullYear() + "  " + strTime;
}

Sin más os dejamos el código completo de la aplicación en Github por si queréis probarla y aprender un poco más de Electron.