Hoy en día, NodeJS tiene una gran cantidad de bibliotecas que pueden resolver casi cualquier tarea rutinaria. El web scraping como producto tiene bajos requisitos de entrada, lo que atrae a los trabajadores autónomos y a los equipos de desarrollo. No es sorprendente que el ecosistema de la biblioteca para NodeJS ya contenga todo lo que se necesita para el análisis.
Aquí se considerará el dispositivo central de una aplicación de trabajo para analizar en NodeJS. También se analizará un ejemplo de recopilación de datos de un par de cientos de páginas de un sitio que vende ropa y accesorios de marca https://outlet.scotch-soda.com. El ejemplo de código es similar a las aplicaciones de raspado reales, una de las cuales se usó en el artículo de raspado de Yelp .
Sin embargo, debido a limitaciones naturales, se eliminaron del ejemplo varios componentes de producción, como la base de datos, la creación de contenedores, la conexión de proxy y la gestión de procesos paralelos mediante administradores, como pm2 . Además, no habrá paradas en cosas tan claras como, por ejemplo, la pelusa.
Sin embargo, se considerará la estructura básica del proyecto y se utilizarán las bibliotecas más populares ( Axios , Cheerio , Lodash ), las claves de autorización se extraerán mediante Puppetter y los datos se rasparán y escribirán en un archivo mediante flujos de NodeJS .
Los siguientes términos se utilizarán en el artículo. Aplicación NodeJS: aplicación de servidor , sitio web outlet.scotch-soda.com: un recurso web , y el servidor del sitio web es un servidor web . En otras palabras, el primer paso es investigar el recurso web y su página en Chrome o Firefox. Luego se escribirá una aplicación de servidor para enviar solicitudes HTTP al servidor web y al final se recibirá la respuesta con los datos solicitados.
El contenido de outlet.scotch-soda.com está disponible solo para usuarios autorizados. En este ejemplo, la autorización se producirá a través de un navegador Chromium controlado por una aplicación de servidor, desde la que se recibirán las cookies. Estas cookies se incluirán en los encabezados HTTP en cada solicitud HTTP al servidor web, lo que permitirá que la aplicación acceda al contenido. Al raspar grandes recursos con decenas y cientos de miles de páginas, las cookies recibidas deben actualizarse varias veces.
La aplicación tendrá la siguiente estructura:
cookie-manager.js: archivo con la clase CookieManager
, cuyos métodos realizan el trabajo principal de obtener la cookie;
cookie-storage.js: archivo de variables de cookies;
index.js: punto de llamada del método CookieManager
;
.env: archivo de variables de entorno.
/project_root |__ /src | |__ /helpers | |__ **cookie-manager.js** | |__ **cookie-storage.js** |**__ .env** |__ **index.js**
Directorio principal y estructura de archivos
Agregue el siguiente código a la aplicación:
// index.js // including environment variables in .env require('dotenv').config(); const cookieManager = require('./src/helpers/cookie-manager'); const { setLocalCookie } = require('./src/helpers/cookie-storage'); // IIFE - application entry point (async () => { // CookieManager call point // login/password values are stored in the .env file const cookie = await cookieManager.fetchCookie( process.env.LOGIN, process.env.PASSWORD, ); if (cookie) { // if the cookie was received, assign it as the value of a storage variable setLocalCookie(cookie); } else { console.log('Warning! Could not fetch the Cookie after 3 attempts. Aborting the process...'); // close the application with an error if it is impossible to receive the cookie process.exit(1); } })();
y en cookie-manager.js
:
// cookie-manager.js // 'user-agents' generates 'User-Agent' values for HTTP headers // 'puppeteer-extra' - wrapper for 'puppeteer' library const _ = require('lodash'); const UserAgent = require('user-agents'); const puppeteerXtra = require('puppeteer-extra'); const StealthPlugin = require('puppeteer-extra-plugin-stealth'); // hide from webserver that it is bot puppeteerXtra.use(StealthPlugin()); class CookieManager { // this.browser & this.page - Chromium window and page instances constructor() { this.browser = null; this.page = null; this.cookie = null; } // getter getCookie() { return this.cookie; } // setter setCookie(cookie) { this.cookie = cookie; } async fetchCookie(username, password) { // give 3 attempts to authorize and receive cookies const attemptCount = 3; try { // instantiate Chromium window and blank page this.browser = await puppeteerXtra.launch({ args: ['--window-size=1920,1080'], headless: process.env.NODE_ENV === 'PROD', }); // Chromium instantiates blank page and sets 'User-Agent' header this.page = await this.browser.newPage(); await this.page.setUserAgent((new UserAgent()).toString()); for (let i = 0; i < attemptCount; i += 1) { // Chromium asks the web server for an authorization page //and waiting for DOM await this.page.goto(process.env.LOGIN_PAGE, { waitUntil: ['domcontentloaded'] }); // Chromium waits and presses the country selection confirmation button // and falling asleep for 1 second: page.waitForTimeout(1000) await this.page.waitForSelector('#changeRegionAndLanguageBtn', { timeout: 5000 }); await this.page.click('#changeRegionAndLanguageBtn'); await this.page.waitForTimeout(1000); // Chromium waits for a block to enter a username and password await this.page.waitForSelector('div.login-box-content', { timeout: 5000 }); await this.page.waitForTimeout(1000); // Chromium enters username/password and clicks on the 'Log in' button await this.page.type('input.email-input', username); await this.page.waitForTimeout(1000); await this.page.type('input.password-input', password); await this.page.waitForTimeout(1000); await this.page.click('button[value="Log in"]'); await this.page.waitForTimeout(3000); // Chromium waits for target content to load on 'div.main' selector await this.page.waitForSelector('div.main', { timeout: 5000 }); // get the cookies and glue them into a string of the form <key>=<value> [; <key>=<value>] this.setCookie( _.join( _.map( await this.page.cookies(), ({ name, value }) => _.join([name, value], '='), ), '; ', ), ); // when the cookie has been received, break the loop if (this.cookie) break; } // return cookie to call point (in index.js) return this.getCookie(); } catch (err) { throw new Error(err); } finally { // close page and browser instances this.page && await this.page.close(); this.browser && await this.browser.close(); } } } // export singleton module.exports = new CookieManager();
Los valores de algunas variables son enlaces al archivo .env
.
// .env NODE_ENV=DEV LOGIN_PAGE=https://outlet.scotch-soda.com/de/en/login [email protected] PASSWORD=i*m_on@kde
Por ejemplo, el atributo de configuración headless
, enviado al método puppeteerXtra.launch
se resuelve en booleano, que depende del estado de la variable process.env.NODE_ENV
. En desarrollo, la variable se establece en DEV
, headless
se establece en FALSE
, por lo que Titiritero entiende que en este momento debería representar la ejecución de Chromium en el monitor.
El método page.cookies
devuelve una matriz de objetos, cada uno de los cuales define una cookie y contiene dos propiedades: name
y value
. Usando algunas funciones de Lodash, el ciclo extrae el par clave-valor para cada cookie y produce una cadena similar a la siguiente:
Archivo cookie-storage.js
:
// cookie-storage.js // cookie storage variable let localProcessedCookie = null; // getter const getLocalCookie = () => localProcessedCookie; // setter const setLocalCookie = (cookie) => { localProcessedCookie = cookie; // lock the getLocalCookie function; // its scope with the localProcessedCookie value will be saved // after the setLocalCookie function completes return getLocalCookie; }; module.exports = { setLocalCookie, getLocalCookie, };
La idea de los cierres claramente definidos es mantener el acceso al valor de alguna variable después del final de la función en cuyo ámbito estaba esta variable. Como regla general, cuando la función termina de ejecutar una return
, deja la pila de llamadas y el recolector de elementos no utilizados elimina todas las variables de la memoria de su alcance.
En el ejemplo anterior, el valor de la variable localProcessedCookie
con la cookie recuperada permanece en la memoria de la computadora después de que se completa el establecimiento de setLocalCookie
. Esto permite obtener este valor en cualquier parte del código siempre que la aplicación se esté ejecutando.
Para hacer esto, cuando se llama a setLocalCookie
, se devuelve la función getLocalCookie
. Luego, cuando se destruye el alcance de la función setLocalCookie
, NodeJS ve que tiene la función de cierre getLocalCookie
. Por lo tanto, el recolector de elementos no utilizados deja intactas en la memoria todas las variables del alcance del captador devuelto. Dado que la variable localProcessedCookie
estaba en el ámbito de getLocalCookie
, continúa activa y conserva el vínculo con la cookie.
La aplicación necesita una lista principal de URL para comenzar a rastrear. En producción, por regla general, el rastreo comienza desde la página principal de un recurso web y, a lo largo de un cierto número de iteraciones, se construye una colección de enlaces a páginas de destino. A menudo hay decenas y cientos de miles de enlaces de este tipo para un recurso web.
En este ejemplo, al rastreador se le pasarán solo 8 enlaces de rastreo como entrada. Los enlaces conducen a páginas con catálogos de los principales grupos de productos. Aquí están:
https://outlet.scotch-soda.com/women/clothing https://outlet.scotch-soda.com/women/footwear https://outlet.scotch-soda.com/women/accessories/all-womens-accessories https://outlet.scotch-soda.com/men/clothing https://outlet.scotch-soda.com/men/footwear https://outlet.scotch-soda.com/men/accessories/all-mens-accessories https://outlet.scotch-soda.com/kids/girls/clothing/all-girls-clothing https://outlet.scotch-soda.com/kids/boys/clothing/all-boys-clothing
Para evitar ensuciar el código de la aplicación con cadenas de enlace tan largas, creemos un generador de URL compacto a partir de los siguientes archivos:
categorías.js: archivo con parámetros de ruta;
target-builder.js: archivo que construirá una colección de URL.
/project_root |__ /src | |__ /constants | | |__ **categories.js** | |__ /helpers | |__ cookie-manager.js | |__ cookie-storage.js | |__ **target-builder.js** |**__ .env** |__ index.js
Agrega el siguiente código:
// .env MAIN_PAGE=https://outlet.scotch-soda.com
// index.js // import builder function const getTargetUrls = require('./src/helpers/target-builder'); (async () => { // here the proccess of getting cookie // gets an array of url links and determines it's length L const targetUrls = getTargetUrls(); const { length: L } = targetUrls; })();
// categories.js module.exports = [ 'women/clothing', 'women/footwear', 'women/accessories/all-womens-accessories', 'men/clothing', 'men/footwear', 'men/accessories/all-mens-accessories', 'kids/girls/clothing/all-girls-clothing', 'kids/boys/clothing/all-boys-clothing', ];
// target-builder.js const path = require('path'); const categories = require('../constants/categories'); // static fragment of route parameters const staticPath = 'global/en'; // create URL object from main page address const url = new URL(process.env.MAIN_PAGE); // add the full string of route parameters to the URL object // and return full url string const addPath = (dynamicPath) => { url.pathname = path.join(staticPath, dynamicPath); return url.href; }; // collect URL link from each element of the array with categories module.exports = () => categories.map((category) => addPath(category));
Estos tres fragmentos crean 8 enlaces dados al principio de este artículo. Demuestran el uso de las bibliotecas de ruta y URL integradas. Alguien puede preguntarse si esto suena como 'romper una nuez con un mazo, y no sería más fácil usar la interpolación'.
Los métodos canónicos de NodeJS se utilizan para trabajar con parámetros de rutas y solicitudes de URL por dos razones:
Agregue dos archivos al centro lógico de la aplicación del servidor:
/project_root |__ /src | |__ /constants | | |__ categories.js | |__ /helpers | | |__ cookie-manager.js | | |__ cookie-storage.js | | |__ target-builder.js ****| |__ **crawler.js** | |__ **parser.js** |**__** .env |__ **index.js**
En primer lugar, agregue un bucle index.js
que pasará los enlaces URL al Crawler a su vez y recibirá los datos analizados:
// index.js const crawler = new Crawler(); (async () => { // getting Cookie proccess // and url-links array... const { length: L } = targetUrls; // run a loop through the length of the array of url links for (let i = 0; i < L; i += 1) { // call the run method of the crawler for each link // and return parsed data const result = await crawler.run(targetUrls[i]); // do smth with parsed data... } })();
Código del rastreador:
// crawler.js require('dotenv').config(); const cheerio = require('cheerio'); const axios = require('axios').default; const UserAgent = require('user-agents'); const Parser = require('./parser'); // getLocalCookie - closure function, returns localProcessedCookie const { getLocalCookie } = require('./helpers/cookie-storage'); module.exports = class Crawler { constructor() { // create a class variable and bind it to the newly created Axios object // with the necessary headers this.axios = axios.create({ headers: { cookie: getLocalCookie(), 'user-agent': (new UserAgent()).toString(), }, }); } async run(url) { console.log('IScraper: working on %s', url); try { // do HTTP request to the web server const { data } = await this.axios.get(url); // create a cheerio object with nodes from html markup const $ = cheerio.load(data); // if the cheerio object contains nodes, run Parser // and return to index.js the result of parsing if ($.length) { const p = new Parser($); return p.parse(); } console.log('IScraper: could not fetch or handle the page content from %s', url); return null; } catch (e) { console.log('IScraper: could not fetch the page content from %s', url); return null; } } };
La tarea del analizador es seleccionar los datos cuando se recibe el objeto cheerio y luego construir la siguiente estructura para cada enlace URL:
[ { "Title":"Graphic relaxed-fit T-shirt | Women", "CurrentPrice":25.96, "Currency":"€", "isNew":false }, { // at all 36 such elements for every url-link } ]
Código del analizador:
// parser.js require('dotenv').config(); const _ = require('lodash'); module.exports = class Parser { constructor(content) { // this.$ - this is a cheerio object parsed from the markup this.$ = content; this.$$ = null; } // The crawler calls the parse method // extracts all 'li' elements from the content block // and in the map loop the target data is selected parse() { return this.$('#js-search-result-items') .children('li') .map((i, el) => { this.$$ = this.$(el); const Title = this.getTitle(); const CurrentPrice = this.getCurrentPrice(); // if two key values are missing, such object is rejected if (!Title || !CurrentPrice) return {}; return { Title, CurrentPrice, Currency: this.getCurrency(), isNew: this.isNew(), }; }) .toArray(); } // next - private methods, which are used at 'parse' method getTitle() { return _.replace(this.$$.find('.product__name').text().trim(), /\s{2,}/g, ' '); } getCurrentPrice() { return _.toNumber( _.replace( _.last(_.split(this.$$.find('.product__price').text().trim(), ' ')), ',', '.', ), ); } getCurrency() { return _.head(_.split(this.$$.find('.product__price').text().trim(), ' ')); } isNew() { return /new/.test(_.toLower(this.$$.find('.content-asset p').text().trim())); } };
El resultado del trabajo del rastreador y el analizador serán 8 arreglos con objetos dentro, devueltos al bucle for del archivo index.js
.
Para escribir en un archivo se utilizará Writable Stream
. Los flujos son solo objetos JS que contienen una serie de métodos para trabajar con fragmentos de datos que aparecen secuencialmente. Todos los flujos heredan de la clase EventEmitter
y, debido a esto, pueden reaccionar a los eventos que ocurren en el entorno de tiempo de ejecución. Quizás alguien vio algo así:
myServer.on('request', (request, response) => { // something puts into response }); // or myObject.on('data', (chunk) => { // do something with data });
que son excelentes ejemplos de flujos de NodeJS a pesar de sus nombres no tan originales: myServer
y myObject
. En este ejemplo, escuchan ciertos eventos: la llegada de una solicitud HTTP (el evento 'request'
) y la llegada de un dato (el evento 'data'
), después de lo cual se toman para algún trabajo útil. La "transmisión" surgió en el hecho de que funcionan con fragmentos de datos en rodajas y requieren una cantidad mínima de RAM.
En este caso, se reciben secuencialmente 8 matrices con datos dentro for
ciclo y se escribirán secuencialmente en el archivo sin esperar la acumulación de la colección completa y sin usar ningún acumulador. Dado que al ejecutar el código de ejemplo, se conoce exactamente el momento en que llega la siguiente parte de los datos analizados al bucle for
, no es necesario escuchar los eventos, pero es posible escribir de inmediato utilizando el método de write
integrado en la transmisión.
Lugar para escribir:
/project_root |__ /data | |__ **data.json** ...
// index.js const fs = require('fs'); const path = require('path'); const { createWriteStream } = require('fs'); // use constants to simplify work with addresses const resultDirPath = path.join('./', 'data'); const resultFilePath = path.join(resultDirPath, 'data.json'); // check if the data directory exists; create if it's necessary // if the data.json file existed - delete all data // ...if not existed - create empty !fs.existsSync(resultDirPath) && fs.mkdirSync(resultDirPath); fs.writeFileSync(resultFilePath, ''); (async () => { // getting Cookie proccess // and url-links array... // create a stream object for writing // and add square bracket to the first line with a line break const writer = createWriteStream(resultFilePath); writer.write('[\n'); // run a loop through the length of the url-links array for (let i = 0; i < L; i += 1) { const result = await crawler.run(targetUrls[i]); // if an array with parsed data is received, determine its length l if (!_.isEmpty(result)) { const { length: l } = result; // using the write method, add the next portion //of the incoming data to data.json for (let j = 0; j < l; j += 1) { if (i + 1 === L && j + 1 === l) { writer.write(` ${JSON.stringify(result[j])}\n`); } else { writer.write(` ${JSON.stringify(result[j])},\n`); } } } } })();
El bucle for
anidado resuelve solo un problema: para obtener un archivo json
válido en la salida, se debe tener cuidado de que no haya una coma después del último objeto en la matriz resultante. El bucle for
anidado determina qué objeto será el último en la aplicación en deshacer la inserción de la coma.
Si uno crea data/data.json
por adelantado y lo abre mientras se ejecuta el código, entonces puede ver en tiempo real cómo Writable Stream agrega secuencialmente nuevos datos.
El resultado fue un objeto JSON de la forma:
[ {"Title":"Graphic relaxed-fit T-shirt | Women","CurrentPrice":25.96,"Currency":"€","isNew":false}, {"Title":"Printed mercerised T-shirt | Women","CurrentPrice":29.97,"Currency":"€","isNew":true}, {"Title":"Slim-fit camisole | Women","CurrentPrice":32.46,"Currency":"€","isNew":false}, {"Title":"Graphic relaxed-fit T-shirt | Women","CurrentPrice":25.96,"Currency":"€","isNew":true}, ... {"Title":"Piped-collar polo | Boys","CurrentPrice":23.36,"Currency":"€","isNew":false}, {"Title":"Denim chino shorts | Boys","CurrentPrice":45.46,"Currency":"€","isNew":false} ]
El tiempo de procesamiento de la solicitud con autorización fue de unos 20 segundos.
El código completo del proyecto de código abierto está en GitHub. También hay un archivo package.json
con dependencias.