🕐 Tempo di lettura: 8 minuti

Questo nuovo articolo nasce da un piccolo side project che ho costruito in Python, mosso da una curiosità molto concreta – quanto pagano davvero gli annunci di lavoro per sviluppatori in Italia? Le proposte che arrivano in chat su LinkedIn sono una cosa; gli annunci pubblici, con la RAL scritta nero su bianco, sono un'altra. Volevo un campione da cui estrarre qualche numerino.

Così ho scritto uno scraper semiautomatico per LinkedIn, mirato sui ruoli dello sviluppo software (backend, full stack, architetti, tech lead, DevOps…). È una prima bozza – un proof of concept – e ha raccolto 319 posizioni prima che LinkedIn iniziasse a rallentarmi. La lista dei ruoli da cercare me la sono fatta generare, per fare in fretta, direttamente dall'AI: le ho descritto brevemente il mio profilo e mi sono fatto restituire una trentina abbondante di job title affini, da passare in input alla ricerca. Il tool è quindi parametrizzabile: basta cambiare quella lista per puntarlo su qualsiasi altro stack o figura professionale.

📌 Prima scarico, poi elaboro

Il principio guida di tutta l'architettura è: prima scarico, poi elaboro. Lo scraping salva solo l'HTML grezzo degli annunci su disco. Tutta l'estrazione dei dati – titolo, azienda, tecnologie, RAL – avviene in un secondo momento, senza mai ritornare su LinkedIn.

La pipeline è una sequenza di script disaccoppiati, ognuno con un compito solo:

roles.py             → lista dei ruoli da cercare
scrape_html.py       → FASE 1:   scarica SOLO l'HTML
clean_resources.py   → FASE 1.5: scarta annunci di altri settori
parse_jobs.py        → FASE 2:   HTML → dati strutturati (JSON + CSV)
apply_corrections.py → FASE 2.5: pulizia AI della RAL (opzionale)
to_excel.py          → FASE 3:   JSON/CSV → Excel formattato

Perché tanta cura nel separare? Perché la rete è la risorsa scarsa e il download è rischioso, mentre il parsing no. Una volta che ho gli HTML su disco, posso ri-elaborarli quante volte voglio: aggiungere un campo, migliorare una regex, correggere un bug di estrazione – tutto senza una sola nuova chiamata a LinkedIn e senza nuovi rischi di ban. Se avessi mescolato scaricamento e parsing nello stesso passaggio, ogni piccola modifica all'estrazione mi avrebbe costretto a ri-scrapare dacapo. La logica è tenere la materia prima grezza e immutabile, e ricostruirci di volta in volta sopra.

📌 Lo scraping, e il gatto col topo anti-bot

La Fase 1 è la più delicata: un'automazione troppo aggressiva può far scattare uno shadow ban, se non il ban vero e proprio dell'account. Conviene non esagerare e – trattandosi di un proof of concept – usare un account secondario: gli accorgimenti che descrivo qui contrastano l'anti-bot ma non lo annullano. Per ogni ruolo lo script costruisce l'URL di ricerca di LinkedIn, scorre la lista dei risultati (scroll per il caricamento lazy), raccoglie i link agli annunci e scarica l'HTML completo di ognuno, aggiornando un manifest che mappa ogni file al ruolo e all'URL originale.

Un paio di valide scelte implementative mi hanno evitato diversi problemi. La prima è il login manuale: l'utente fa login nel browser visibile, poi lo script prosegue da lì. Niente credenziali da gestire, e la sessione è indistinguibile da una normale navigazione. La seconda riguarda la robustezza ai cambi di layout: LinkedIn offusca e cambia spesso le classi CSS, quindi non mi appoggio ai "wrapper" delle card – troppo fragili. Raccolgo invece tutti i link /jobs/view/<id> presenti in pagina ed estraggo il job_id direttamente dall'URL, un pattern che non cambia mai. Titolo e azienda li leggo dal tag <title> (Titolo | Azienda | LinkedIn). Quando scrapi, il principio è ancorarsi a ciò che è stabile, non a ciò che è comodo.

Poi c'è il throttling. Lo scraping "aggressivo" viene rilevato e bloccato, e il segnale più tipico di un bot è la regolarità: un ritmo costante, millimetrico, che nessun umano avrebbe. Quindi il lavoro è tutto nel rompere quella regolarità:

1. ritardi randomizzati tra le azioni – non pause fisse ma valori casuali (jitter) tra una soglia minima e una massima

2. pause "umane" tra un ruolo e l'altro (≈ 15–30 s), anch'esse casuali, e micro-pause di scroll per simulare la lettura

3. backoff progressivo: se più ricerche consecutive tornano vuote – segnale di soft-block – scatta una pausa lunga (≈ 90–150 s) e, se la situazione persiste, lo script si ferma in modo pulito invitando a riprovare più tardi, senza accanirsi su una sessione già limitata

import random, time

def human_pause(min_s: float, max_s: float) -> None:
    """Randomized wait: adds jitter, breaks the constant rhythm."""
    time.sleep(random.uniform(min_s, max_s))

A questo si aggiunge il contorno: browser non-headless (con interfaccia grafica visibile, non pilotato in background a finestra nascosta – molto più simile a un utente vero), user-agent realistico, e la rimozione del flag navigator.webdriver (una proprietà che il browser espone quando è guidato da un'automazione, e che altrimenti urlerebbe "sono un bot"). Lo scraping è anche ripartibile: all'avvio ricarica gli annunci già scaricati e li salta, riprendendo solo dai ruoli mancanti. Esito della prima campagna: 356 annunci scaricati, poi LinkedIn ha iniziato a restituire pagine vuote (throttling di sessione, non ban dell'account) e la raccolta si è fermata da sola, in modo controllato. Dati intatti.

📌 Pulire il rumore: l'architetto edile sotto «API Architect»

Cercare per parola chiave pesca inevitabilmente annunci di altri settori. Cercando API Architect arrivano Architetto edile, Geometra, BIM Coordinator; sotto Team Leader mi è spuntato persino un Powder Coat Team Lead di una manifattura. La Fase 1.5 (clean_resources.py) classifica ogni annuncio dal titolo in keep / remove / review, con regole basate su segnali IT positivi (developer, software, engineer, backend…), segnali di altri settori (edile, cantiere, costruzioni, geometra…), e una gestione speciale del termine ambiguo architect/architetto – considerato IT solo in contesto software/cloud/integration. Risultato? Da 356 a 319 annunci puliti, 37 rimossi, quasi tutti "architetti" edili finiti nella ricerca di API Architect.

📌 Estrarre significato: il caso della RAL

La Fase 2 trasforma ogni HTML in dati. Estraggo il testo visibile, poi isolo la descrizione dell'annuncio tra due marcatori stabili ("Informazioni sull'offerta di lavoro""Offerte di lavoro simili") per buttare via header, sidebar e footer e ridurre il rumore. Sul testo pulito girano regex ed euristiche.

Il campo più delicato è la RAL, e qui il punto non è leggere un numero: è capire cosa significa quel numero. È una distinzione tutta semantica, e la affronto con regex ed euristiche guidate dal contesto:

Trattare un "fino a 35.000" come se fosse il minimo offerto falserebbe completamente le statistiche. E poi servono i filtri di buon senso: scartare i numeri non retributivi (es. "21.000 dipendenti") e gli importi in valuta estera (£/CHF/USD…), non comparabili col mercato italiano. In pratica cerco tutti gli importi plausibili (≈ 15k–300k), tengo solo quelli con un aggancio retributivo vicino (€, "RAL", "lordo", "retribuzione"…) e dal contesto decido la direzione. Un'altra parte interessante è insegnare alle regex i formati reali degli annunci: non solo 30.000, ma anche 30-40k, 28K-35K e le migliaia all'inglese €45,000 – scritture comunissime che un match ingenuo salta. Gestendole, la copertura della RAL è passata dal 24% al 36% degli annunci, senza una riga di AI.

Resta però una zona grigia dove il pattern matching non basta: gli annunci a "fasce multiple". Un "Graduate software engineer" che pubblica sia la banda entry (≈€66k "con poca esperienza") sia quella senior (€107k–€188k): la regex, vedendo due numeri crescenti e vicini, aggancia la coppia sbagliata. Qui ho fatto un passaggio di pulizia semantica con l'AI – ho dato a Claude le descrizioni dubbie chiedendogli di scegliere la fascia giusta per il ruolo. Il bilancio è la risposta alla domanda "serve l'AI?": su 116 annunci con RAL la regex era corretta in 108, l'AI ha sistemato gli 8 a fasce multiple – il graduate qui sopra e qualche società di consulenza che mette due grade (junior e senior) nello stesso annuncio. Per il grosso del lavoro una regex fatta bene basta; l'AI serve in quella manciata di casi che richiedono anche di capire il contesto e non solo riconoscere un pattern. Così la pipeline di base resta senza AI e senza API a consumo – usabile da chiunque – con l'AI come rifinitura opzionale dove le euristiche cedono. L'output sono due file: jobs.json (sorgente ricca – tecnologie come array, RAL come numeri) e jobs.csv pronto per Excel. La Fase 3, infine, genera un jobs.xlsx formattato con filtri automatici, colonne ordinabili e link cliccabili agli annunci originali.

📌 I numeri (finalmente)

Su 319 posizioni, la copertura dei campi racconta già qualcosa: titolo, azienda, luogo, modalità di lavoro e data di pubblicazione sono, ovviamente, al 100%; gli anni di esperienza richiesti al ~54%; il numero di candidati al ~40%. E la RAL? Presente in poco più di un terzo degli annunci (~36%, 116 su 319).

Veniamo alla ciccia: dove la RAL c'era, ecco il quadro (€, Retribuzione Annua Lorda):

Metrica Media Mediana
RAL minima (estremo inferiore dei range) € 35.209 € 34.500
RAL massima (estremo superiore dei range) € 43.042 € 40.000

E i valori estremi del campione, per dare un'idea dell'ampiezza:

Estremo Tra i minimi Tra i massimi
Valore più basso € 20.000 € 25.000
Valore più alto € 66.000 € 100.000

Per calcolarli ho aggiunto delle formule direttamente al foglio Excel di output, e accanto alle medie ho messo le mediane: con un campione piccolo e qualche annuncio fuori scala (una scale-up che paga a livelli internazionali) la media si lascia tirare, la mediana no. Un dettaglio interessante: quasi tutti gli annunci che dichiarano un minimo dichiarano anche un massimo, e viceversa. Di norma c'è un range, non un valore secco.

📌 La trasparenza salariale

Due osservazioni, oltre al codice. La prima è sulla trasparenza salariale: nonostante la normativa entrata in vigore il 7 giugno, la RAL compare in poco più di un annuncio su tre. Va detto che la pubblicazione in annuncio non è obbligatoria, ma il dato resta interessante. La seconda è un'osservazione sul campione: le RAL che ho misurato sono mediamente più basse delle proposte che ricevo in chat, dove le offerte sono più targettizzate sul profilo (nel mio caso senior). Lo scraper invece ha pescato un mix che include anche junior e middle, fasce dove i valori sono fisiologicamente più bassi. È un campione non bilanciato: le medie vanno lette come indicative del campione, non del mercato.

I limiti di questo prototipo li conosco bene: il throttling che spezza la raccolta, la copertura RAL parziale (~36%, ma è un limite del dato sorgente, non del parser), il parsing euristico che su sinonimi e frasi creative può ancora sbagliare. Il prossimo passo è rieseguirlo con le migliorie anti-bot che ho aggiunto e allargare di un ordine di grandezza: puntare a ~3.000 annunci per avere, a parità di copertura, oltre mille range di RAL invece dei ~116 di oggi. Solo allora le medie diranno qualcosa sul mercato, e non solo su questo campione. La naturale evoluzione, poi, è estendere l'uso dell'AI – oggi limitato alla rifinitura della RAL nei casi ostici – all'estrazione di tutti i campi, per poi storicizzare nel tempo e normalizzare le tecnologie. Un modello in locale, o delle API a consumo (valutando i costi e ripulendo prima gli HTML, spesso pesanti, per ridurre i token). Ma anche così com'è – una bozza Python di poche centinaia di righe – mi ha tolto una curiosità con dei numeri veri in mano, invece che a sensazione. E direi anche che un proof of concept non deve fare di più.

Il codice è pubblico su GitLab, link al repo: linkedin-scraper. Dentro la cartella first_extraction trovi anche un'estrazione di esempio, con i link alle oltre 300 posizioni scaricate, se ti interessa dare un'occhiata ai dati grezzi.

Se l'articolo ti è piaciuto lascia un like, e al prossimo articolo! ☕