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.
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.
L’un des système de scraping/crawling le plus connu et le plus utilisé au monde est le moteur de recherche Google !
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.
Je vous propose 4 applications de ces techniques en Go :
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) :
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é :
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.
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 :
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
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 …
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 :
Géolocalisation de l’impôt sur le revenu de Bordeaux Métropôle en 2014 :