Web scraping

Problemas comunes y sus soluciones

David Hernández

¿Quién SOY?

Developer @ Political Watch

Mail: david.hernandez@politicalwatch.es

          hola@davidhernandez.info

 

Twitter: @David_Baltha

¿Qué vamos a ver?

¿Qué es el web scraping?

Es una técnica utilizada mediante programas de software para extraer información de sitios web.

– Wikipedia

HTTP Request

HTTP Response

¿Cómo funciona la web?

conocimientos previos

REALIZANDO UNA PETICIón http

REALIZANDO UNA PETICIón http

$response = file_get_contents('https://2022.drupalcamp.es/sessions/webscrapping-problemas-comunes-y-sus-soluciones');
curl 'https://2022.drupalcamp.es/sessions/webscrapping-problemas-comunes-y-sus-soluciones'
await fetch("https://2022.drupalcamp.es/sessions/webscrapping-problemas-comunes-y-sus-soluciones", {
  "method": "GET",
});
requests.get('https://2022.drupalcamp.es/sessions/webscrapping-problemas-comunes-y-sus-soluciones')

PHP

Bash/curl

Javascript

Python

¿Podemos hacer una petición a una página?

interprentando la respuesta

HTML

JSON

CSV

PDF

IMÁGEN

VÍDEO

HTML/XML: XPATH / CSS SELECTORs

HTML/XML: XPATH / CSS SELECTORs

'.session_title' // Selector
'//main/section//article/div/div/div[1]/h1' // XPATH
'.field--name-field-experience-level > div:nth-child(2)' // Selector
'//main/section//article/div/div/div[4]/div[1]/div[2]' // XPATH

XPATH

XPath es un formato que nos permite identificar cualquier etiqueta del HTML o XML.

<html>
  <a class="menu" href=""></a>
  <div>
    <a class="link" href=""></a>
  </div>
</html>

Seleccionar todos los enlaces:

//a

Seleccionar el div:

/html/div

Seleccionar segundo enlace:

//a[2]

Usando atributos

//a[@class="link"]

XPATH

<html>
  <a class="menu" href=""></a>
  <div>
    <a class="link" href=""></a>
  </div>
</html>
/html//a[@class="link"]

La consola, nuestra mejor amiga

CONSEJO: Plugin to test xpath

Try XPath for Firefox

Procesando HTML: HTML PARSERS

doc = document_fromstring(html)
authors = doc.xpath("//div[@class='author']")

for author in authors:
	name = author.cssselect('.name')
$doc = str_get_html($html);
$authors = $doc->find("//div[@class='author']");

foreach ($authors as $author) {
  $name = $author->find('.name');
}

Python LXML

const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");

const authors = doc.evaluate("//div[@class='author']")

for (const author of authors) {
	const name = author.querySelector('.name')
}

Javascript puro en el front, JSDOM en el back

WEB Scraping básico

make

HTTP

REQUEST

parse

HTTP

RESPONSE

Prueba de web scraping básico

Vamos a intentar extraer los autores de todas las charlas de la DrupalCamp:

 

https://2022.drupalcamp.es/programa

tecnologías que podemos usar

Node PHP Python
HTTP requests https (core) guzzle requests
Intérprete jsdom symfony crawler lxml

Ejemplo en Python

import requests
from lxml.html import fromstring

def make_request():
    url = 'https://2022.drupalcamp.es/programa'
    response = requests.get(url)

    if not response.ok:
        raise Exception

    return response.text

def extract_authors(html):
    dom = fromstring(html)
    author_xpath = '//div[@class="schedule-wrapper"]//span[@class="author"]'
    
    authors = [e.text_content() for e in dom.xpath(author_xpath)]
    return authors 

html = make_request()
authors = extract_authors(html)

print(authors)

Ejemplo en JAVASCRIPT

const https = require('https')
const jsdom = require("jsdom")
const { JSDOM } = jsdom

const options = {
  hostname: '2022.drupalcamp.es',
  port: 443,
  path: '/programa',
  method: 'GET'
}

const req = https.request(options, res => {
  console.log(`statusCode: ${res.statusCode}`)

  res.on('data', html => {
    const dom = new JSDOM(html).window.document
    const author_elements = dom.querySelectorAll(".author")
    const authors = Array.from(author_elements).map(author => author.textContent) 

    console.log(authors)
  })
})
req.on('error', error => {
    console.error(error)
})
req.end()

Identificando APIs

Si una web renderiza mediante ajax y no tiene autentificación, podemos extraer directamente de sus APIs.

 

No hay una manera 100% eficaz de impedir que te conectes a una API con acceso abierto.

Ejemplo práctico

Busquemos el listado de diputados

await fetch("https://www.congreso.es/busqueda-de-diputados?p_p_id=diputadomodule&p_p_lifecycle=2&p_p_state=normal&p_p_mode=view&p_p_resource_id=searchDiputados&p_p_cacheability=cacheLevelPage", {
    "credentials": "include",
    "headers": {
        "User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:100.0) Gecko/20100101 Firefox/100.0",
        "Accept": "application/json, text/javascript, */*; q=0.01",
        "Accept-Language": "es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3",
        "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
        "X-Requested-With": "XMLHttpRequest",
        "Sec-Fetch-Dest": "empty",
        "Sec-Fetch-Mode": "cors",
        "Sec-Fetch-Site": "same-origin",
        "Sec-GPC": "1"
    },
    "referrer": "https://www.congreso.es/busqueda-de-diputados",
    "body": "_diputadomodule_idLegislatura=14&_diputadomodule_genero=0&_diputadomodule_grupo=all&_diputadomodule_tipo=0&_diputadomodule_nombre=&_diputadomodule_apellidos=&_diputadomodule_formacion=all&_diputadomodule_filtroProvincias=%5B%5D&_diputadomodule_nombreCircunscripcion=",
    "method": "POST",
    "mode": "cors"
});

Obtengamos el índice de iniciativas

¿Qué parámetros son necesarios?

¿Qué parámetros son necesarios de la petición? ¿Cuáles no? ¿Podemos obtener otros datos?

WEB Scraping avanzado

HTTP

REQUEST

PROBLEMS

HTTP

RESPONSE

PROBLEMS

problemas de las peticiones HTTP

  • Filtrado por IP
  • Filtrado por User-Agent
  • Limitación temporal
  • Autentificación para acceder

Filtrado por IP

El servidor a veces bloquea una IP cuando llegan muchas peticiones muy rápidamente desde la misma IP.

Proxies

En otras ocasiones, se bloquea un rango de IPs: por país, por ejemplo.

Bloqueo por USER AGENT

El User-Agent identifica al navegador que hace la petición.

 

Muchas herramientas para hacer peticiones, usan User-Agents propios.

 

Estos User-Agents muchas veces son bloqueados.

Identifica y usa el user agent de un navegador "normal"

Limitación temporal

Otra defensa habitual es bloquear temporalmente las peticiones que llegan de una IP que hace muchas peticiones muy rápidamente, algo que un "usuario real" no podría hacer.

Waits/Sleeps, colas de procesos...

Autentificación para acceder

Hay webs que para acceder a la información tienes que iniciar sesión. O APIs que necesitan un token para acceder...

Autentificación por cookie, por token...

Algunas web con autentificación

  • Periódicos digitales
  • Tiendas online: Privalia

Scrapeando Privalia

  1. Analiza el comportamiento de la web
  2. Replica el proceso de inicio de sesión
  3. Replica la petición que tiene los datos

¡Cuidado! No guardes y reutilices siempre la misma cookie, ya que puede caducar!

Prepárate para que eliminen tu cuenta o te bloqueen la IP. Webs grandes suelen estar alerta de "comportamientos sospechosos".

Replica la petición

import requests
from requests.structures import CaseInsensitiveDict

url = "https://es.privalia.com/api/authentication/front/v1/login"

headers = CaseInsensitiveDict()
headers["User-Agent"] = "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:100.0) Gecko/20100101 Firefox/100.0"
headers["Accept"] = "*/*"
headers["Accept-Language"] = "es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3"
headers["Accept-Encoding"] = "gzip, deflate, br"
headers["Referer"] = "https://es.privalia.com/gr/portal"
headers["Content-Type"] = "application/json"
headers["User-Agent-Custom"] = "vp-groot-mobile"
headers["mpDistinctId"] = "1812a72405cd5-08b3112aedd2ed8-97f2736-1fa400-1812a72405dd3"
headers["formName"] = "portal"
headers["Origin"] = "https://es.privalia.com"
headers["DNT"] = "1"
headers["Connection"] = "keep-alive"
headers["Sec-Fetch-Dest"] = "empty"
headers["Sec-Fetch-Mode"] = "cors"
headers["Sec-Fetch-Site"] = "same-origin"
headers["TE"] = "trailers"

data = '{"email":"******************","password":"*********","rememberMe":true,"returnUrl":null,"siteId":66}'
resp = requests.post(url, headers=headers, data=data)
print(resp.cookies)

Guardamos la cookie

print(resp.cookies)

<RequestsCookieJar[
  <Cookie .ASPXAUTH=FFEFFC7008943DA732410C27C86EBDC6CF5336210D5A19FDB6CA2C3F8
    C2EA5DA45DB86E03DF869B3B60CAFDE2F0179A9571541FFC746B61994D5948C34DC4A4DB4F3
    4571882D9A458FBDC9EC7B8B6EE714CBBCCCBD1EC4475A8A96032E7EB0926E3C3EA9CF75BFC
    310E923333F6851EA1E9A22F2 for .es.privalia.com/>,
  <Cookie CrmSegment={"MemberId":84715914,"CrmSegment":"0"} for .es.privalia.com/>,
  <Cookie CheckAutologin=True for es.privalia.com/>,
  <Cookie context=1KyMEKfXkPXDT/tW0CMbBvbGw1lZ7Bvfe3fheZGzt5I=+t5lR93qaB12Af+9F
    LAU8Q==&nhl7USxL3Xy3+2WD7Fbc1Cdl2w4Gn8GLgOXNCUmE8iQ=XrBxbQMAPWeaB5boIvumDA=
    =&v1Qjw3n3ulZI7AqymWiKQyEsgasusmL/wSOYpo8aAv8=XrBxbQMAPWeaB5boIvumDA==&yCW6
    rcZdpl0aFFRaKOa1Q53DrYB9UYF7P36N5tOeqiQ=zWc2qPgnGPRJ2o/quhOVIEiVm51nEdLi9HT
    CAguj8g4= for es.privalia.com/>,
  <Cookie dataContext=1w8gZTIT3pHupcXLu5VZGc96/AU3TdhquZneJakjWXY=T7XSQY/f7F4rT
    /TCSorLb5jNct5xIVyUcCbwGEHn8Ck=&fkXJY5zgM99oBzkfu7b6RmRwz7NgHzKZg9jJiQ5Yr0M
    =gYk3h76bt0Fbmx7KIRgkS/a3KZp80TcUzvnx4rhU5hI=&j9cBT5cXtAP8wEYE034g1FdciIYFl
    A8qEm9vhLUhvHg=XrBxbQMAPWeaB5boIvumDA== for es.privalia.com/>,
  <Cookie infoMember=mid=84715914 for es.privalia.com/>
]>

Usa las cookies para la petición

cookies = resp.cookies

url = "https://es.privalia.com/ns-sd/frontservices/navigation/graphql"

headers = CaseInsensitiveDict()
headers["User-Agent"] = "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:100.0) Gecko/20100101 Firefox/100.0"
headers["Accept"] = "*/*"
# ...
headers["DNT"] = "1"
headers["Connection"] = "keep-alive"
headers["Sec-Fetch-Dest"] = "empty"
headers["Sec-Fetch-Mode"] = "cors"
headers["Sec-Fetch-Site"] = "same-origin"
headers["Sec-GPC"] = "1"
headers["TE"] = "trailers"

data = '{"query":"n  query getHeader($isPrivaliaOrBenelux: Boolean!, $previewDate: String, $siteId: Int = 1, $userparam: String) {n    header(siteId: $siteId) {n      cart {n        label: basketTextn        url: basketUrln        isActive: isBasketActiven        __typenamen      }n      lateralMenu {n        title: menuTitlen        links: lateralMenuSections {n          sections {n            ...lateralMenuSubSectionFragmentn          }n        }n        lateralMenuBottomSections {n          sections {n            ...lateralMenuSubSectionFragmentn          }n        }n        isActive: isLateralMenuActiven        countryBlock {n          countryListTitlen          countryMainList {n            countryTitleMainn            countriesList {n              flagUrln              countryCoden              flagTextn            }n          }n          selectedCountryn        }n        faciliti {n          logon          textn        }n      }n      misc {n        baseUrln      }n      sponsorship {n        label: godfatherTextn        isActive: isSponsorshipActiven      }n      whoWeAre {n        label: whoWeAreTextn        isActive: isWhoWeAreActiven      }n    }n    homes(previewDate: $previewDate, siteId: $siteId, tracking: false, userparam: $userparam) {n      idn      __typenamen      ... on Home {n        namen        displayNamen        upperDisplayNamen        subNavBanner {n          imageUrln          linkn          tagn        }n        modules {n          ... on SectionBannerModule {n            __typenamen            idn            namen            displayNamen            isCurrentn            hideOnSubNavigationn            banners {n              idn            }n          }n        }n        subNavModules {n          __typenamen          titlen          showAlln          ... on SubNavCategoryModule {n            items {n              ...subNavModuleItemFragmentn            }n          }n          ... on SubNavSaleModule {n            items {n              ...subNavModuleItemFragmentn            }n          }n          ... on SubNavVPassModule {n            items {n              ...subNavModuleItemFragmentn            }n          }n        }n      }n      ... on SpecialHome {n        namen        displayNamen        subNavBanner {n          imageUrln          linkn          tagn        }n      }n    }n    memberInformation @include(if: $isPrivaliaOrBenelux) {n      ... on MemberInformationModule {n        isPremiumn        premiumMessagen      }n    }n  }n  fragment subNavModuleItemFragment on SubNavModuleItem {n    __typenamen    idn    linkUrln    namen    ... on Category {n      hasItemsn      defaultNamen      mediaUrls {n        v2n        v3n      }n    }n  }n  fragment lateralMenuSubSectionFragment on LateralMenuSection {n    ...lateralMenuSectionFragmentn    lateralMenuSubSections {n      ...lateralMenuSectionFragmentn    }n  }n  fragment lateralMenuSectionFragment on LateralMenuSection {n    contentn    defaultUrln    textn    cssClassn    loggedInn  }n","variables":{"isPrivaliaOrBenelux":true,"siteId":66}}'

resp = requests.post(url, headers=headers, data=data, cookies=cookies)

print(resp.status_code)

problemas de la Interpretación HTML

  • HTML cambiante
  • Renderizado mediante Javascript

HTML Cambiante

Hay webs que cambian automáticamente su estructura del HTML (generando clases de CSS automáticamente, modificando las etiquetas de HTML...).

 

Aunque puedas hacer scrapping de estas páginas, el sistema quedará inútil en un tiempo breve.

Soluciones experimentales: exportar la web a una imagen y utilizar reconocimiento de imágenes, ya que no depende de la estructura del HTML

Renderizado mediante javascript

La web actual se renderiza por javascript (Vue, React, Angular...).

 

Si haces una request a este tipo de webs verás un HTML mínimo que carga un script de JS que renderiza la web después.

 

Si es muy difícil conectarse a la API, hay otras alternativas.

Replicamos el comportamiento de un navegador con herramientas que usen headless browsers

Scrapeando mediante Puppeteer

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({
    headless: true,
    executablePath: '/usr/bin/google-chrome',
    args: [
      "--disable-gpu",
      "--disable-dev-shm-usage",
      "--disable-setuid-sandbox",
      "--no-sandbox",
    ]
  })
  const page = await browser.newPage() 
  await page.goto('https://quehacenlosdiputados.es/diputados', {
    waitUntil: 'networkidle0',
  })
  const deputies = await page.evaluate(() => {
    const deputy_elements = document.querySelectorAll('.c-deputy-card h4')
    return Array.from(deputy_elements, deputy_element => deputy_element.textContent)
  })

  console.log(deputies)

  await browser.close()
})()

¿PORQUé puppeteer?

Frente a herramientas de testing E2E, que tienen un comportamiento muy similar, Puppeteer nos da ciertas utilidades para extraer los datos del browser.

Problemas sin solución

problemas SIN SOLUCIón

  • Captchas
  • Two factor authentications
  • Autentificaciones aleatorias

Captchas

El captcha está pensado para evitar que una máquina no pueda acceder a la información, permitiendo solo pasar a humanos

Captcha solvers

Two factor authentication (2Fa)

El 2FA está pensado como herramienta de seguridad para evitar hackeos de nuestras cuentas, pero también impiden que podamos acceder mediante técnicas de web scraping.

Sin solución por el momento

Autentificaciones aleatorias

Hay páginas que de manera aleatoria nos piden "autentificarnos" o mostrar que somos humanos. Puede ser un captcha, introducir un código mandado al correo, cerrar un popup...

Dependiendo de la implementación, podremos sortearlo o no.

¿Preguntas?

Bibliografía

Python

- Requests

- LXML

JavaScript

- http

- fetch

- JSDom

- Puppeteer

PHP

- Guzzle

- Symfony DomCrawler

Ejemplos