Meetup Golang chez Zenika à Bordeaux sur le web scraping

J’ai eu le plaisir de co-animer, il y plus d’un an, un meetup chez Zenika Bordeaux sur une mise en oeuvre de Golang et du web scraping / crawling, dans une ambience sympa, j’ai donné cette presentation avec plein d’exemples très concrets. A l’époque, je n’ai pas écrit d’article, mais de récents développements que je fais, m’ont donné l’envie de vous parler de Go et de Colly.

Faire du web scraping en Golang, pour quoi faire

C’est quoi ? Le but principal du web scraping est de récupérer des données non structurées sur des sites web, avec un framework, afin des les structurer et de les enrichir.

framework de scraping web

L’un des système de scraping/crawling le plus connu et le plus utilisé au monde est le moteur de recherche Google !

Voyons à quoi peut servir dans le concret de faire du webscraping

Google donc, fait du web scraping pour indexer les informations des sites web et effectue un crawling régulier pour y verifier les changements éventuels. L’ensemble des entreprises qui proposent des moteurs de recherche Internet ou sur Intranet, utilisent ces techniques.

L’alimentation d’un Data lake pour nourrir des applications Big Data est aussi un exemple d’application, ainsi que la dataviz.

Des sociétés utilisent des outils de web scraping pour faire de la veille concurrentielle sur les sites de concurrents sur le même segment de marché …

On peut aussi récupérer les informations d’un site client existant afin de “nourrir” une maquette d’un nouveau site avec des données pertinantes, etc.

Je vous présente ici quelques applications de ces techniques, en Golang avec un framework qui porte le nom de Colly.

Exemples pratiques de web scraping

Langage GO

Je vous propose 4 applications de ces techniques en Go :

  • Récupération des données de tarifs des freelances sur le site freelance-info et mis au format json
  • En s’appuyant sur les jsons du site : création d’une application VueJs un peu plus sexy que le site d’origine (peut servire de maquette client à partir de vraie données)
  • Récupération des données météo depuis plusieurs sites et agrégation : depuis Allosurf, grandesmarées, Meteo France, surfforecast
  • Extraction des informations de fichiers sur le moteur de recherche IRC, pour télécharger via xdcc (y’a que les vieux qui peuvent me comprendre là ??!!)

Maquettage d’un nouveau site à partir d’un site existant

Imaginons qu’un de vos client, possède un site web un peu “old school”. Vous réalisez une maquette un peu sexy et récupérez les données réelles sur ce site … Golang et Colly sont vos amis !

Prenons l’exemple du site web freelance-info.fr et plus particulièrement la page qui indique les TJM (tarif journalier moyen) :

freelance-info

Je vais en faire une web app en Go avec Colly pour extraire les données en temps réelle au format json, réaliser ma maquette avec VueJs pour la partie front de mon application pour avoir les mêmes informations que le site d’origine, plus le TJM min, max et moyen, calculés dans la webapp VueJS, qui ne sont pas proposés sur le site scrapé :

freelance-info

Allez on code en Go

Comme nous devrons faire beaucoup de regexp en Go, je vous conseille le site Regex101 qui propose directement la sortie au format Golang.

Voilà le code en Go pour réaliser la phase n°1, récupérer les données et les mettre au format json :

package main

import (
	"encoding/json"
	"fmt"
	"log"
	"regexp"
	"strconv"

	"github.com/gocolly/colly"
)

// PrintInfo réalise les tests et recherche de patterns afin de retourner l'intitulé du poste et le TJM
func PrintInfo(e *colly.HTMLElement) (string, int, bool) {
	var title string
	var salary int
	var err bool
	// Comme le site scrappé est mal fichu, j'élimine de ma recherche les <li> suivants
	matched, _ := regexp.MatchString("Freelance|Réflexion|Travail|Formation|Typologie|astreinte", e.Text)
	if !matched {
		// J'extrait l'intitulé et le montant du TJM
		var re = regexp.MustCompile(`\s*(Maj|Lu)?\s*(.*)\s+(\d*) €\/j`)
		title = re.ReplaceAllString(e.Text, `$2`)
		sal := re.ReplaceAllString(e.Text, `$3`)
		salary, _ = strconv.Atoi(sal)
		if salary == 0 {
			err = true
		}
	} else {
		err = true
	}
	return title, salary, err
}

func main() {
	info := map[string]int{}
	c := colly.NewCollector(
		colly.AllowedDomains("freelance-info.fr", "www.freelance-info.fr"),
	)
	c.OnError(func(_ *colly.Response, err error) {
		log.Println("Houston nous avons un problème : ", err)
	})
	c.OnHTML("li.v1", func(e *colly.HTMLElement) {
		t, s, err := PrintInfo(e)
		if !err {
			info[t] = s
		}
	})
	c.OnHTML("li.v2", func(e *colly.HTMLElement) {
		t, s, err := PrintInfo(e)
		if !err {
			info[t] = s
		}
	})
	c.OnRequest(func(r *colly.Request) {
		fmt.Println("Je visite le site suivant : ", r.URL.String())
	})
	// En fin de scraping j'affiche le json avec toutes les informations récupérées
	c.OnScraped(func(s *colly.Response) {
		jsonString, _ := json.Marshal(info)
		fmt.Printf("%s", jsonString)
	})
	c.Visit("https://www.freelance-info.fr/tarifs/")
}

L’exécution du programme Golang nous renvoie le json de l’ensemble des données :

{"Administrateur BD":540,"Administrateur ERP":510,"Administrateur produits":480,"Administrateur réseaux":400,"Administrateur système":430,"Analyste":480,"Analyste d'exploitation":340,"Analyste programmeur":410,"Analyste réalisateur":410,"Architecte":650,"Architecte réseaux":600,"Assistant à maîtrise d'ouvrage":570,"Auditeur":650,"Chef de projet":590,"Concepteur BD":440,"Concepteur multimédia":390,"Concepteur télématique":500,"Consultant":600,"Consultant fonctionnel":620,"Consultant technique":550,"Consultant technique et formateur":610,"Directeur de projet":770,"Directeur informatique":790,"Développeur":430,"Expert":660,"Formateur":520,"Infographiste":330,"Ingénieur d'exploitation":430,"Ingénieur d'études":440,"Ingénieur de production":490,"Ingénieur réseaux":490,"Ingénieur système":510,"Maquettiste PAO":330,"Pupitreur/Pilote":380,"Responsable d'exploitation":550,"Responsable maintenance":440,"Rédacteur technique":300,"Support utilisateurs":310,"Technicien d'exploitation":280,"Technicien micro / réseaux":250,"Webmaster":310}

Pour la phase n°2 de notre 1er projet, nous allons mettre tout ça dans du Docker. Pour cela je vais utiliser la technique du multistage building de Docker, que j’ai déjà présenté dans cet article Choisis ta distribution Linux dans Docker pour tes microservices en Golang, on passe ainsi d’une image qui fait 1Go à une image finale qui fait moins de 5Mo et qui contient mon application en Go qui réalise le scraping du site, qui fait serveur web pour pousser le json et pousser l’application VueJS :

FROM golang
WORKDIR /go/src/github.com/user/app
COPY . .

ADD https://github.com/upx/upx/releases/download/v3.96/upx-3.96-amd64_linux.tar.xz /usr/local
RUN set -x && \
    apt update && \
    apt install -y xz-utils && \
    xz -d -c /usr/local/upx-3.96-amd64_linux.tar.xz | \
    tar -xOf - upx-3.96-amd64_linux/upx > /bin/upx && \
    chmod a+x /bin/upx && \
    go get -d -v . && \
    CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app . && \
    strip --strip-unneeded app && \
    upx app

FROM scratch
WORKDIR /root/
COPY --from=0 /go/src/github.com/user/app .
COPY --from=0 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 3000
CMD ["./app"]

Notre application Go devient maintenant :

package main

import (
	"encoding/json"
	"net/http"
	"regexp"
	"strconv"

	"github.com/gocolly/colly"
	"github.com/gorilla/mux"
)

type freelance struct {
	LesPostes []poste `json:"postes"`
}

type poste struct {
	Profil string `json:"profil"`
	TJM    int    `json:"tjm"`
}

// PrintInfo réalise les tests et recherche de patterns afin de retourner l'intitulé du poste et le TJM
func PrintInfo(e *colly.HTMLElement) (string, int, bool) {
	var title string
	var salary int
	var err bool
	// Comme le site scrappé est mal fichu, j'élimine de ma recherche les <li> suivants
	matched, _ := regexp.MatchString("Freelance|Réflexion|Travail|Formation|Typologie|astreinte", e.Text)
	if !matched {
		// J'extrait l'intitulé et le montant du TJM
		var re = regexp.MustCompile(`\s*(Maj|Lu)?\s*(.*)\s+(\d*) €\/j`)
		title = re.ReplaceAllString(e.Text, `$2`)
		sal := re.ReplaceAllString(e.Text, `$3`)
		salary, _ = strconv.Atoi(sal)
		if salary == 0 {
			err = true
		}
	} else {
		err = true
	}
	return title, salary, err
}

func retResponse(w http.ResponseWriter, code int, payload interface{}) {
	response, _ := json.Marshal(payload)
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(code)
	w.Write(response)
}

func index(w http.ResponseWriter, r *http.Request) {
	http.ServeFile(w, r, "index.html")
}

func style(w http.ResponseWriter, r *http.Request) {
	http.ServeFile(w, r, "style.css")
}

func api(w http.ResponseWriter, r *http.Request) {
	info := map[string]int{}
	c := colly.NewCollector(
		colly.AllowedDomains("freelance-info.fr", "www.freelance-info.fr"),
		colly.IgnoreRobotsTxt(),
		colly.CacheDir(".colly"),
	)
	c.OnError(func(_ *colly.Response, err error) {
		retResponse(w, http.StatusNotFound, map[string]string{"error": "colly in api func"})
	})
	c.OnHTML("li.v1", func(e *colly.HTMLElement) {
		t, s, err := PrintInfo(e)
		if !err {
			info[t] = s
		}
	})
	c.OnHTML("li.v2", func(e *colly.HTMLElement) {
		t, s, err := PrintInfo(e)
		if !err {
			info[t] = s
		}
	})
	// En fin de scraping j'affiche le json avec toutes les informations récupérées
	c.OnScraped(func(s *colly.Response) {
		list := &freelance{}
		for profil, tjm := range info {
			p := poste{profil, tjm}
			list.LesPostes = append(list.LesPostes, p)
		}
		retResponse(w, http.StatusOK, list)
	})
	c.Visit("https://www.freelance-info.fr/tarifs/")
}

func notFound(w http.ResponseWriter, r *http.Request) {
	retResponse(w, http.StatusNotFound, map[string]string{"error": "Not found"})
}

func main() {
	router := mux.NewRouter()
	router.HandleFunc("/", index)
	router.HandleFunc("/api", api)
	router.HandleFunc("/style.css", style)
	router.NotFoundHandler = http.HandlerFunc(notFound)
	http.ListenAndServe(":3000", router)
}

Pour le reste des fichiers, dont le code VueJs, je vous laisse cliquer sur le bandeau en bas à droite pour regarder mon repo Github.

Consolidation de données

Dans ce 2nd exercice, nous allons récupérés et aggréger des données venant de différents sites web avec une application CLI en Go :

dataviz

Comme je suis un gros pratiquant de Bodyboard, je souhaite avoir avec une commande unique, l’ensemble des informations qui me sont nécessaires et pas toujours présentes sur un site unique … Donc, on va scraper et aggréger ! Le code Go est disponible sur Github et voici le résultat de son exécution :

Département Gironde
Date du jour          :  Mardi 05 mai
Le soleil se lève à   :  06h44
Le soleil se couche à :  21h12
Saint(e) du jour      :  Sainte Judith
Tendance du temps     :  Éclaircies
Température           :  15°C Minimale
Température           :  23°C Maximale
Biscarrosse
Index UV              :  7
Coefficient matin     :  84
Coefficient après-midi:  86
Pleine mer matin      :  03:33
Base mer matin        :  10:05
Pleine mer après-midi :  16:01
Basse mer après-midi  :  22:25
Hauteur de houle      :  0.6 m
Vitesse du vent       :  10 km/h
Direction du vent     :  N
Mercredi 06 mai
Jeudi 20 août 2020 - coefficient 101 voir les horaires ... Jeudi 20 août 2020 - coefficient 101 voir les horaires ... Jeudi 20 août 2020 - coefficient 101 voir les horaires ...

Comme j’aime les choses simples, il existe un outil génial pour déduire une struct Go en un json : Json2Go

Scraper un moteur de recherche

Le dernier exemple, cestpasbien je sais … Mais, il y a longtemps pour downloader des films, j’utilisais beaucoup les irc, qui proposent toujours autant d’offres. J’ai donc écrit il y a quelques jours un bout de code, qui réalise une recherche sur un film et envoie la requête de téléchargement à un autre programme qui s’occupe de la partie xdcc (dont je ne donne pas le source ici, ni ailleurs).

package searchengine

import (
	"log"
	"strconv"
	"strings"

	"github.com/gocolly/colly"
)

// XdcceuResults exportation pour voir
type XdcceuResults struct {
	Network string
	Channel string
	Bot     string
	Slot    int
	Get     string
	Size    string
	Name    string
}

// Xdcceu function scrape le site xdcc.eu
func Xdcceu(item string) []XdcceuResults {
	itemEsc := strings.Replace(item, " ", "+", -1)
	c := colly.NewCollector(
		colly.AllowedDomains("www.xdcc.eu", "xdcc.eu"),
	)
	results := make([]XdcceuResults, 0, 10)
	c.OnError(func(r *colly.Response, err error) {
		log.Println("Request URL:", r.Request.URL, "failed with response:", r, "\nError:", err)
	})
	c.OnRequest(func(r *colly.Request) {
		// fmt.Println("Je visite le site suivant : ", r.URL.String())
	})
	c.OnHTML("body", func(e *colly.HTMLElement) {
		var index int = 0
		e.ForEachWithBreak("table tbody tr", func(_ int, el *colly.HTMLElement) bool {
			var net = strings.ToLower(el.ChildText("td:nth-of-type(1)"))
			var cha = strings.ToLower(el.ChildText("td:nth-of-type(2)"))
			var slo, _ = strconv.Atoi(strings.TrimPrefix(el.ChildText("td:nth-of-type(4)"), "#"))
			result := XdcceuResults{
				Network: net,
				Channel: cha,
				Bot:     el.ChildText("td:nth-of-type(3)"),
				Slot:    slo,
				Get:     el.ChildText("td:nth-of-type(5)"),
				Size:    el.ChildText("td:nth-of-type(6)"),
				Name:    el.ChildText("td:nth-of-type(7)"),
			}
			results = append(results, result)
			index++
			if index >= 1 {
				return false
			}
			return true
		})
	})
	c.OnScraped(func(s *colly.Response) {
	})
	//	c.Visit("https://www.xdcc.eu/search.php?searchkey=Altered+Carbon+S01+1080")
	c.Visit("https://www.xdcc.eu/search.php?searchkey=" + itemEsc)
	return results
}

Pour exécuter ce programme en Go :

go run main.go "Parasite 1080p"

{"Network":"abjects","Channel":"#beast-xdcc","Bot":"Beast-CjG0D-34","Slot":714,"Get":"252x","Size":"1.4G","Name":"Parasite.2019.HDRip.AC3.x264-CMRG.tar"}

Une erreur faire sortir du programme, car il n’y a pas de serveur de téléchargement qui répond …

Data visualisation

Voilà j’espère que mes exemples vous aurons intéressé ? Il y a plein d’autres possibilités, comme les exemples ci-dessous que j’ai réalisé avant de m’installer à Bordeaux il y a plus de 5 ans :

Emplacement des NRO (locaux opérateurs ADSL), des écoles et piscine à Bordeaux en 2015 :

dataviz en golang

Géolocalisation de l’impôt sur le revenu de Bordeaux Métropôle en 2014 :

dataviz en langage go