Eén van de dingen die ik altijd al wilde leren met programmeren is hoe je zelf een crawler / scraper maakt. Een robot die voor jou een pagina analyseert en bepaalde informatie eruit distilleert. Een robot die net als een zoekmachine, zelf door hun website loopt en bij iedere pagina een aantal aantekening maakt. Erg handig voor SEO.

Ik ben aan de slag gegaan met PHP 5 en de cURL-library. In dit artikel zal ik mijn eigen werkwijze toelichten.

De bestanden

Ik heb een github repository aangemaakt met daarin de bestanden van mijn 'ro-bot' tot nu toe.

Download/clone deze hier als je graag de volledige code wilt inzien. 

Opzet

Ik heb gekozen voor een object geörienteerde opzet met daarin een Crawler-class die al het harde werk verzet. Op deze manier kan ik allerlei methods (klassefuncties) makkelijk hergebruiken en is het eenvoudig om de crawler uit te breiden met meer functionaliteit.

Daarnaast vond ik het ook fijn om op deze manier de data op te kunnen slaan in de properties en zo altijd de data beschikbaar te hebben.  

Opmerkingen voordat we de code induiken

Ik heb zelf een vrij trage laptop en de cURL-library maakt vrij intensief gebruik van je CPU waardoor de robot nog lang niet zo snel is als ik graag zou willen. Daarnaast heb je natuurlijk ook te maken met de connectie tot de server en ben je afhankelijk hoe snel deze een response aflevert op basis van onze requests. 

Wanneer je de robot zelf wilt testen, begin dan met een kleine website :).

Verder werkt de robot op dit moment nog veel met array's om data op te slaan. Dit zou je gaandeweg kunnen wijzigen met een database. Dit is sowieso handiger wanneer je nog complexere analyses wilt toepassen zoals het vergelijken van alle titels van de gecrawlde pagina's.

cURL library installeren

Voordat je gebruik kunt maken van de tool, heb je cURL nodig binnen je development-omgeving. Dit is gelukkig makkelijk te realiseren.

Voor WAMP (Windows): http://stackoverflow.com/questions/13021536/how-to-enable-curl-in-wamp-server

Voor Ubuntu: http://stackoverflow.com/questions/2939820/how-to-enable-curl-installed-ubuntu-lamp-stack 

Index.php

Laten we eerst eens kijken naar de globale flow van de applicatie.

<?php

require_once 'Config/Db.php';
require_once 'Config/Settings.php';
require_once 'Crawler/Crawler.php';

if(isset($_POST['submit'])) {

  $base_url = $_POST['http'] . $_POST['url'];
  $crawler = new Crawler($base_url);
  $crawler->setCurrentURL($base_url);
  $crawler->curl();
  $crawler->extractAll();

  $crawler->loop();
  $crawler->sortResults();

  include 'Templates/Results.php';

} else {
  include 'Templates/Index.php';
}

?>

We starten met een aantal includes. Deze laden een aantal basis settings in voor het bepalen van de timeouts, de database instellingen (die ik nog niet gebruik momenteel) en niet onbelangrijk, onze crawler zelf :).

Vanuit daar wachten we een $_POST af met daarin het schema(http/https) en de gekozen URL. Deze vertalen we naar onze basis-URL, zodat de robot weet waar deze moet starten en welke URLs later intern/externe links zijn. 

We initialiseren onze Crawler dan ook met de base-url en laden de belangrijke methodes in: curl(), extractAll() en loop(). Vervolgens laden we de template in die de resultaten laat zien op basis van de voorgaande methodes.

Tijd voor meer diepgang, de belangrijkste functies verdienen een stuk meer aandacht :).

De curl() methode

Om pagina's succesvol op te kunnen vragen, hebben we een methode nodig die een aantal basis-handelingen voor ons uitvoert. 

public function curl()
{
  $ch = curl_init();
  curl_setopt_array($ch, $this->options);

  $this->data = curl_exec($ch);
  $this->http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);

  print curl_error($ch);
  curl_close($ch);
}

Allereerst initialiseren we een nieuwe curl-sessie via curl_init en wijzen deze toe aan onze 'handler', die de latere handelingen op zich neemt.

Vervolgens laden we onze opties voor het komende verzoek richting de website in. Deze opties kunnen per keer verschillen (al heb ik dat nog niet meegemaakt), dus heb ik de opties in lokale property geplaatst die via een andere functie nog kan worden gewijzigd. (De details daarvan zijn niet al te interessant wat mij betreft, mocht je het willen zien, check de code :)).

Vervolgens gaan we de data/broncode van onze base-url ophalen via curl_exec. Deze functie download de inhoud van de broncode en bewaart deze in $this->data, zodat we hier later over kunnen beschikken. Daarnaast slaan we apart de http-code op, zodat we later terug kunnen zien hoe een pagina reageerde op ons verzoek en of er bijvoorbeeld nog 404-pagina's tussen zitten! :)

Als er eventuele errors zijn dan printen we die via curl_error en tot slot sluiten we de sessie, zodat deze geen CPU/geheugen in beslag neemt.

En zo begint ons avontuur! We hebben nu de broncode van een pagina in ons bezit waaruit we de robot meer magie kunnen laten doen. Tijd voor de volgende stap.

De extractAll() methode

public function extractAll()
{
  $this->setURLStatus();
  $this->extractURLs();
  $this->extractTitle();
  $this->extractRobots();
  $this->countVotes();

  $this->addCheckedURL();
}

Deze methode is een verzameling van andere handige methodes, zodat we deze serie niet iedere keer hoeven te herhalen. Het is vooral interessant om naar de onderliggende inhoud te kijken, dus we gaan verder!

setURLStatus()

public function setURLStatus()
{
  $this->results[$this->current_url]['http_code'] = $this->http_code;
}

Allereerst slaan we de huidige http-code op die geretourneerd is na onze request van de pagina. De eerste keer is dit de http-code van de homepage. Als deze faalt door bijv. een interne server error (500), dan zal de tool helaas niet verder kunnen gaan.

extractURLs()

We gebruiken zometeen een regex-patroon om links te vinden op de pagina. Deze heb ik vastgezet als constante binnen de Crawler-class.

// gedefinieerd bij de properties
const HREF_PATTERN = "/<a\s(.*?)href=(\"|\')(.*?)(\"|\')/i";

Eén van de belangrijkste methodes voor het verder kunnen crawlen van de website. Deze methode gaat ervoor zorgen dat we straks een nieuwe lijst hebben met URLs 

public function extractURLs()
{
  preg_match_all(self::HREF_PATTERN, $this->data, $matches);

  $this->url_set = $matches[3];
  $numLinks = count($this->url_set);

  for( $i = 0 ; $i < $numLinks ; $i++ ) {

    // filter external links OR relative URLs
    if(!preg_match($this->url_pattern, $this->url_set[$i]) && substr($this->url_set[$i], 0, 1) !== '/') {
      echo $this->url_set[$i] . " bevat geen " . $this->base_url . " en is waarschijnlijk een <strong>externe link</strong><br />";
      unset($this->url_set[$i]);
      continue;
    }

    // check for non-link extensions
    if(preg_match("/\.css|\.png|\.js|xml|wp-|\.jpg|\.ico|\.txt|\@|google\.nl|facebook|model/i", $this->url_set[$i])) {
      echo $this->url_set[$i] . " bevat een <strong>rare extensie</strong> " . "<br />";
      unset($this->url_set[$i]);
      continue;
    }

    // check for anchored URLs
    if(preg_match('/\#/', $this->url_set[$i])) {
      echo $this->url_set[$i] . " is geen nieuwe pagina, maar een <strong>anker(#)</strong> " . $this->base_url . "<br />";
      unset($this->url_set[$i]);
      continue;
    }

    // change relative URLs into normal URLs so they can be crawled correctly
    if(substr($this->url_set[$i], 0, 1) == '/') {
      echo $this->url_set[$i] . " is <strong>relatief</strong>, aanpassen naar " . trim($this->base_url, '/') . $this->url_set[$i] . "<br />";
      $this->url_set[$i] = rtrim($this->base_url, '/') . $this->url_set[$i];
    }
  }

  $this->results[$this->current_url]['url'] = $this->current_url;
  $this->results[$this->current_url]['out_links'] = $this->url_set;
  if(!isset($this->results[$this->current_url]['votes']))
    $this->results[$this->current_url]['votes'] = 0;
}

We beginnen met het zoeken van overeenkomsten van het patroon binnen de broncode, ofwel $this->data, de uitkomsten daarvan droppen we in een array met de naam $matches. Zo hebben we een lijst met alle links van de pagina.

Vervolgens moeten we nog bepalen welk deel van de $matches we willen gaan gebruiken. [0] is de volledig gematchte string, dus <a href=".../...">. Alle indexen daarna worden doorgeteld op basis van de brackets '()'. In dit geval willen wij de inhoud hebben tussen de href en de begin/eind-quotes, wat haakjes-combinatie #3 is. Dit wordt onze $this->url_set

Daarna gaan we onze url_set filteren op basis van een aantal voorwaarden (die we echo'en, zodat we later kunnen controleren of dit goed gaat):

Tot slot geven we de huidige URL, de gefilterde url_set en indien nog niet bekend, de hoeveelheid stemmen (interne links) voor deze pagina mee.

extractTitle()

public function extractTitle()
{
  preg_match_all("~<title>(.*)<\/title>~", $this->data, $title);

  if(!empty($title[1])) {
    $this->results[$this->current_url]['title'] = $title[1][0];
    return;
  }

  if($this->http_code == 301 || $this->http_code == 302) {
    $this->results[$this->current_url]['title'] = $this->http_code . " Redirect..";
    return;
  }

  // else..
  $this->results[$this->current_url]['title'] = "Titel niet gevonden :(";
}

Deze methode haalt snel even de titel uit de broncode en geeft deze door aan de huidige $this->current_url array. Mocht de titel niet gevonden zijn, dan slaan we dat ook op, zodat we later kunnen onderzoeken waarom het voor bepaalde pagina's niet goed gaat.

extractRobots()

Vanuit SEO perspectief vind ik het interessant om te kijken of er meta-robots-tags pagina's binnen een website zijn die per ongeluk op noindex staan. Dit betekent namelijk dat Google er niet bij mag. En dit is lang niet altijd terecht. Vandaar dat we deze info ook toevoegen aan onze array.

public function extractRobots()
{
  preg_match_all("~name\=\"robots\"(.*)content=(\"|\')(.*)(\"|\')(.*)>~", $this->data, $robots);

  if(!empty($robots[3])) {
    $this->results[$this->current_url]['robots'] = $robots[3][0];
  } else {
    $this->results[$this->current_url]['robots'] = "index, follow";
  }
}

Lang niet iedereen gebruikt uberhaupt deze tag, en vallen we dus terug op de default 'index, follow'.

countVotes()

Met deze functionaliteit in gedachte ben ik aan de tool begonnen. Ik wilde graag de interne linkstructuur in kaart kunnen brengen. Dus kijken welke pagina hoeveel links intern krijgt van andere pagina's. Google noemt dit metaforisch zelf altijd mooi 'stemmen', dus vandaar dat ik dit in ere heb gehouden :).

public function countVotes()
{
  foreach($this->results[$this->current_url]['out_links'] as $vote)
  {
    // if the voted url doesn't exist in results yet, initiate it
    if(!isset($this->results[$vote]['url'])) {
      $this->results[$vote] = array("url" => $vote,
                                    "votes" => 1,
                                    "title" => "To be crawled",
                                    "http_code" => "418",
                                    "out_links" => array()
                                   );
    } else {
      $this->results[$vote]['votes'] += 1;
    }
  }
}

We hebben bij extractURLs() een mooie lijst aan links opgebouwd, en die willen we nu gaan gebruiken voor ons stemmen-systeem.

Om dit te doen lopen we door alle stemmen individueel heen en bekijken of deze al bestaat in de results-array (waar alle einddata naartoe gaat). Zo niet, dan krijgt deze een verse index daar met een aantal default settings. De default http-code is een grapje, 418 staat voor de foutmelding 'I'm a teapot' :).

Als de URL al wel bestond binnen onze results-array, dan krijgt deze link er een stem bij, aangezien we natuurlijk niet de voorgaande stemmen ongedaan willen maken!

addCheckedUrl()

public function addCheckedURL()
{
  $this->checked_urls[$this->current_url] = $this->current_url;
}

Tot slot houden we een lijst bij van alle gecheckte URLs. Deze kunnen we later bij loop() goed gebruiken wanneer we nieuwe links tegenkomen en moeten bepalen of we deze al hebben gecrawled of niet.

De loop() methode

De loop() methode is de brandstof van de robot! Deze zorgt ervoor dat alle zojuist besproken functionaliteit benut blijft worden voor de gehele potentiële dataset.

public function loop()
{
  foreach($this->results as $url)
  {
    if(!array_key_exists($url['url'], $this->checked_urls)) {
      $this->setCurrentURL($url['url']);
      $this->curl();
      $this->extractAll();
    }

    $diff = array_diff_key($this->results, $this->checked_urls);

    if(empty($diff))
      continue;

    foreach($diff as $missing_url)
    {
      $this->setCurrentURL($missing_url['url']);
      $this->curl();
      $this->extractAll();
    }
  }
}

De foreach-loop begint de eerste keer met de $this->results die is gecreëerd op basis van de crawl van de base-url. Later worden hier nieuwe resultaten aan toegevoegd.

Vervolgens controleert de loop eerst of de URL nog niet bestaat, zodat we niet een infinite loop vast komen te zitten :). Daarna wordt de huidige te crawlen URL verandert via setCurrentURL(), halen we de data op via curl() en verwerken we deze data via extractAll().

Om de robot goed door te laten lopen, kijken we daarna naar het verschil tussen de huidige result-set met potentiële URLs, en de daadwerkelijk gecheckte URLs. Hierdoor gaat de robot verder met alle URLs die tijdens het tellen van de stemmen gevonden zijn, ofwel alle interne links die op de pagina's tot dan aan toe gevonden zijn. Hierdoor wordt de data-set nog veel groter en kan de robot fijn verder crawlen.

Dit proces haalt zich net zo vaak als er links zijn op de homepage, waardoor de tool best diep kan komen in een website, als ik kijk naar mijn eigen ervaringen. 

De resultaten

Tot slot willen we natuurlijk de resultaten zien. Deze komen eenvoudig in een tabel terecht waar we direct een overzicht hebben van de verhoudingen qua links onderling, de status-code, en of deze op index, follow staan of niet. De resultaten sorteren we op basis van de # interne links. 

Genoeg errors nog om te fixen zo te zien! :) Die 404's moeten worden weggewerkt.

Vragen?

Als je ermee aan de slag gaat en je loopt tegen dingen aan, laat het vooral weten aan mij via daan@daan.onl! Mocht je nog meer ideeën hebben ter verbetering, laat het ook vooral weten. Ik leer graag en schrijf nog lang geen perfecte code, dus optimalisatietips zijn altijd welkom. 

Dankjewel!