El web scraping pot semblar una tasca senzilla, però hi ha molts reptes a superar. En aquest blog, aprendrem en com fer "scraping" a LinkedIn per extreure ofertes de feina. Per fer això, utilitzarem Puppeteer i RxJS. L'objectiu és assolir web scraping d'una manera declarativa, modular i escalable.
Què és el web scraping?
El web scraping és una tècnica d'extracció de dades utilitzada per recopilar informació de llocs web. Consisteix en un procés automatitzat d'obtenció de dades de pàgines web, com ara text, imatges, enllaços i més, per a després emmagatzemar o processar aquestes dades per a diversos propòsits.
Puppeteer
Puppeteer és una biblioteca JavaScript que permet controlar navegadors web com Chrome per a web scraping. Puppeteer ens permet programar i monitorar tasques com ara navegar a una pàgina web específica i extreure les dades que necessitem. És l'eina ideal per a web scraping perquè, sent un navegador web, pot superar qualsevol obstacle potencial, com ara en el cas de llocs web que requereixen l'execució de JavaScript per funcionar o mostrar dades.
RxJS
RxJS és una biblioteca per a la programació reactiva en JavaScript. Proporciona un conjunt d'eines i abstraccions per treballar amb fluxos de dades asíncrons. Utilitzarem RxJS en aquest exemple perquè ofereix els avantatges següents:
- Codi asíncron declaratiu
- Millora de la gestió d'errors
- Lògica de reintent millorada
- Adaptació del codi simplificada
- Una àmplia gamma d'operadors per ajudar-nos al llarg del procés
1. Inicialització de Puppeteer
El fragment de codi a continuació inicialitza una instància de navegador Puppeteer en un mode no "headless" (es a dir, amb interfície gràfica) i posteriorment crea una nova pàgina web. Això representa el procés d'inicialització més fonamental i directe per a Puppeteer:
(async () => {
console.log('Launching Chrome...');
const browser = await puppeteer.launch({
headless: false,
// devtools: true,
// slowMo: 250, // slow down puppeteer script so that it's easier to follow visually
args: [
'--disable-gpu',
'--disable-dev-shm-usage',
'--disable-setuid-sandbox',
'--no-first-run',
'--no-sandbox',
'--no-zygote',
'--single-process',
],
});
const page = await browser.newPage()
/**
* 1. Go lo linkedin jobs url
* 2. Get the jobs
* 3. Repeat step 1 with other search parameters
*/
})();
Alguns dels fragments en aquest blog poden ometre parts per claredat. Podeu trobar el codi complet en aquest repositori.
2. Anar a la llista de llocs de treball de LinkedIn i extreure les dades
Aquesta és la part central d'aquest blog, on ens submergim en el procés d'accés a les ofertes de feina de LinkedIn, analitzant el contingut HTML i recuperant les dades d'ofertes de feina en format JSON.
2.1. Construeix l'URL per navegar fins a la pàgina d'ofertes de feina de LinkedIn
Per accedir a les ofertes de feina de LinkedIn, necessitem construir una URL utilitzant la funció urlQueryPage
:
export const urlQueryPage = (searchParams: ScraperSearchParams) =>
`https://linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search?keywords=${searchParams.searchText}
&start=${searchParams.pageNumber * 25}${searchParams.locationText ? '&location=' + searchParams.locationText : ''}`
En aquest cas, ja he realitzat la investigació prèvia per trobar aquesta URL. El nostre objectiu és trobar una URL que puguem parametritzar amb els nostres paràmetres de cerca desitjats.
Per a aquest exemple, els nostres paràmetres de cerca seran searchText
, pageNumber
i opcionalment locationText
.
Exemples de url poden ser:
-
https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search?keywords=Angular&start=0
-
https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search?keywords=React&location=Barcelona&start=0
-
https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search?keywords=python&start=0
2.2. Navega a l'URL i extreu les ofertes de feina
Amb la nostra URL identificada, podem procedir amb les dues accions principals requerides:
-
Navegar a la URL on hi ha les ofertes de feina: Aquest pas implica dirigir la nostra eina de "scraping" web a la URL on estan les ofertes de feina.
-
Extreure dades de les ofertes de feina i convertint-les a JSON: Un cop estem a la pàgina d'ofertes de feina, utilitzarem tècniques de "scraping" web per extreure les dades de les ofertes i retornar-les en format JSON.
export interface ScraperSearchParams {
searchText: string;
locationText: string;
pageNumber: number;
}
/** main function */
export function goToLinkedinJobsPageAndExtractJobs(page: Page, searchParams: ScraperSearchParams): Observable<JobInterface[]> {
return defer(() => fromPromise(navigateToJobsPage(page, searchParams)))
.pipe(switchMap(() => getJobsFromLinkedinPage(page)));
}
/* Utility functions */
export const urlQueryPage = (searchParams: ScraperSearchParams) =>
`https://linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search?keywords=${searchParams.searchText}
&start=${searchParams.pageNumber * 25}${searchParams.locationText ? '&location=' + searchParams.locationText : ''}`
function navigateToJobsPage(page: Page, searchParams: ScraperSearchParams): Promise<Response | null> {
return page.goto(urlQueryPage(searchParams), { waitUntil: 'networkidle0' });
}
export const stacks = ['angularjs', 'kubernetes', 'javascript', 'jenkins', 'html', /* ... */];
export function getJobsFromLinkedinPage(page: Page): Observable<JobInterface[]> {
return defer(() => fromPromise(page.evaluate((pageEvalData) => {
const collection: HTMLCollection = document.body.children;
const results: JobInterface[] = [];
for (let i = 0; i < collection.length; i++) {
try {
const item = collection.item(i)!;
const title = item.getElementsByClassName('base-search-card__title')[0].textContent!.trim();
const imgSrc = item.getElementsByTagName('img')[0].getAttribute('data-delayed-url') || '';
const remoteOk: boolean = !!title.match(/remote|No office location/gi);
const url = (
(item.getElementsByClassName('base-card__full-link')[0] as HTMLLinkElement)
|| (item.getElementsByClassName('base-search-card--link')[0] as HTMLLinkElement)
).href;
const companyNameAndLinkContainer = item.getElementsByClassName('base-search-card__subtitle')[0];
const companyUrl: string | undefined = companyNameAndLinkContainer?.getElementsByTagName('a')[0]?.href;
const companyName = companyNameAndLinkContainer.textContent!.trim();
const companyLocation = item.getElementsByClassName('job-search-card__location')[0].textContent!.trim();
const toDate = (dateString: string) => {
const [year, month, day] = dateString.split('-')
return new Date(parseFloat(year), parseFloat(month) - 1, parseFloat(day) )
}
const dateTime = (
item.getElementsByClassName('job-search-card__listdate')[0]
|| item.getElementsByClassName('job-search-card__listdate--new')[0] // less than a day. TODO: Improve precision on this case.
).getAttribute('datetime');
const postedDate = toDate(dateTime as string).toISOString();
/**
* Calculate minimum and maximum salary
*
* Salary HTML example to parse:
* <span class="job-result-card__salary-info">$65,000.00 - $90,000.00</span>
*/
let currency: SalaryCurrency = ''
let salaryMin = -1;
let salaryMax = -1;
const salaryCurrencyMap: any = {
['€']: 'EUR',
['$']: 'USD',
['£']: 'GBP',
}
const salaryInfoElem = item.getElementsByClassName('job-search-card__salary-info')[0]
if (salaryInfoElem) {
const salaryInfo: string = salaryInfoElem.textContent!.trim();
if (salaryInfo.startsWith('€') || salaryInfo.startsWith('$') || salaryInfo.startsWith('£')) {
const coinSymbol = salaryInfo.charAt(0);
currency = salaryCurrencyMap[coinSymbol] || coinSymbol;
}
const matches = salaryInfo.match(/([0-9]|,|\.)+/g)
if (matches && matches[0]) {
// values are in USA format, so we need to remove ALL the comas
salaryMin = parseFloat(matches[0].replace(/,/g, ''));
}
if (matches && matches[1]) {
// values are in USA format, so we need to remove ALL the comas
salaryMax = parseFloat(matches[1].replace(/,/g, ''));
}
}
// Calculate tags
let stackRequired: string[] = [];
title.split(' ').concat(url.split('-')).forEach(word => {
if (!!word) {
const wordLowerCase = word.toLowerCase();
if (pageEvalData.stacks.includes(wordLowerCase)) {
stackRequired.push(wordLowerCase)
}
}
})
// Define uniq function here. remember that page.evaluate executes inside the browser, so we cannot easily import outside functions form other contexts
const uniq = (_array) => _array.filter((item, pos) => _array.indexOf(item) == pos);
stackRequired = uniq(stackRequired)
const result: JobInterface = {
id: item!.children[0].getAttribute('data-entity-urn') as string,
city: companyLocation,
url: url,
companyUrl: companyUrl || '',
img: imgSrc,
date: new Date().toISOString(),
postedDate: postedDate,
title: title,
company: companyName,
location: companyLocation,
salaryCurrency: currency,
salaryMax: salaryMax,
salaryMin: salaryMin,
countryCode: '',
countryText: '',
descriptionHtml: '',
remoteOk: remoteOk,
stackRequired: stackRequired
};
console.log('result', result);
results.push(result);
} catch (e) {
console.error(`Something when wrong retrieving linkedin page item: ${i} on url: ${window.location}`, e.stack);
}
}
return results;
}, {stacks})) as Observable<JobInterface[]>)
}
El codi proporcionat extreu tota la informació de les ofertes de feina de la pàgina. Encara que el codi no és molt estètic, aconsegueix la feina, que és típic per a codi de "scraping" web.
En un context de programació normal, generalment és aconsellable descompondre el codi en funcions més petites i aïllades per millorar-ne la llegibilitat i la mantenibilitat. No obstant això, quan es tracta de codi executat dins de
page.evaluate
en Puppeteer, estem una mica limitats perquè aquest codi s'executa en la instància de Puppeteer (Chrome), no en el nostre entorn Node.js. Per tant, tot el codi ha de ser autocontingut dins de la crida depage.evaluate
. L'única excepció aquí són les variables (comstacks
en el nostre cas), que poden passar-se com a arguments apage.evaluate
, sempre que no continguin funcions o objectes complexos que no es puguin serialitzar.
En aquest cas, l'únic component desafiant per passar de HTML text a JSON és la informació del salari, ja que implica convertir un format de text com '$65,000.00 - $90,000.00' en dos valors numèrics de salari mínim i màxim separats. A més, hem encapsulat tot el codi dins d'un bloc try/catch per gestionar els errors de manera elegant. Encara que actualment registrem els errors a la consola, és aconsellable considerar la implementació d'un mecanisme per emmagatzemar aquests registres d'error en disc. Aquesta pràctica es torna particularment important perquè les pàgines web sovint experimenten canvis, cosa que necessita actualitzacions freqüents al codi de l'anàlisi HTML.
Finalment, és important remarcar que sempre utilitzem els operadors defer
i fromPromise
per convertir Promeses en Observables:
defer(() => fromPromise(myPromise()));
Aquesta estratègia és la més recomanada ja que funciona de manera fiable en tots els escenaris. Les Promeses són "eager", mentre que els Observables són "lazy" i només s'inicien quan algú s'hi subscriu. L'operador defer
ens permet convertir a "lazy" una Promesa. Per més informació pots anar a aquest enllaç
3. Afegeix un bucle asíncron per iterar a través de totes les pàgines
En el pas anterior, hem après com obtenir totes les dades de les ofertes de feina d'una pàgina de LinkedIn. Ara, el que volem fer és utilitzar aquest codi tantes vegades com sigui possible per recopilar tantes dades com puguem. Per aconseguir-ho, primer necessitem iterar a través de totes les pàgines disponibles:
function getJobsFromAllPages(page: Page, initSearchParams: ScraperSearchParams): Observable<ScraperResult> {
const getJobs$ = (searchParams: ScraperSearchParams) => goToLinkedinJobsPageAndExtractJobs(page, searchParams).pipe(
map((jobs): ScraperResult => ({jobs, searchParams} as ScraperResult)),
catchError(error => {
console.error(error);
return of({jobs: [], searchParams: searchParams})
})
);
return getJobs$(initSearchParams).pipe(
expand(({jobs, searchParams}) => {
console.log(`Linkedin - Query: ${searchParams.searchText}, Location: ${searchParams.locationText}, Page: ${searchParams.pageNumber}, nJobs: ${jobs.length}, url: ${urlQueryPage(searchParams)}`);
if (jobs.length === 0) {
return EMPTY;
} else {
return getJobs$({...searchParams, pageNumber: searchParams.pageNumber + 1});
}
})
);
}
El codi anterior incrementa el número de pàgina fins que arribem a una pàgina on no hi ha ofertes de feina (que seria l'última pàgina). Per realitzar aquest bucle en RxJS, utilitzem l'operador expand
, que projecta recursivament cada valor de la font (source) a un Observable que es fusiona en l'Observable de sortida. La seva funcionalitat està ben explicada aquí.
En RxJS, no podem utilitzar un bucle amb la paraula clau
for
com ho fem amb await/async. Hem d'utilitzar un bucle recursiu en el seu lloc. Encara que inicialment pugui semblar una limitació, en un context asíncron, aquest mètode resulta ser més avantatjós en nombroses situacions
Així doncs, com seria el codi equivalent utilitzant Promeses? Aquí en tenim un exemple:
export async function getJobsFromAllPages(
page: Page,
searchParams: ScraperSearchParams
): Promise<ScraperResult> {
const results: ScraperResult = { jobs: [], searchParams };
try {
while (true) {
const jobs = await getJobsFromLinkedinPage(page, searchParams);
console.log(
`Linkedin - Query: ${searchParams.searchText}, Location: ${
searchParams.locationText
}, Page: ${searchParams.nPage}, nJobs: ${
jobs.length
}, url: ${urlQueryPage(searchParams)}`
);
results.jobs.push(...jobs);
if (jobs.length === 0) {
break;
}
searchParams.nPage++;
}
} catch (error) {
console.error('Error:', error);
results.jobs = []; // Clear the jobs in case of an error.
}
return results;
}
Aquest codi és gairebé equivalent al basat en Observables, amb una diferència crítica: només emet quan totes les pàgines han acabat de processar. En canvi, la implementació utilitzant Observables emet després de cada pàgina. Crear un "stream" és crucial en aquest cas perquè volem gestionar les ofertes de feina tan aviat siguin resoltes.
Certament, podríem introduir la nostra lògica després de la línia:
const jobs = await getJobsFromLinkedinPage(page, searchParams);
/* Handle the jobs here */
...però això acoblaria innecessàriament el nostre codi de "scraping" amb la part que gestiona les dades de les ofertes de feina. Gestionar les dades de les ofertes pot implicar algunes transformacions, crides a alguna API, i finalment, guardar les dades a una base de dades.
Així doncs, en aquest exemple veiem clarament un dels molts avantatges que els Observables ofereixen respecte a les Promeses.
4. Afegim un altre bucle asíncron per iterar a través de tots els paràmetres de cerca especificats
Ara que sabem com iterar a través de totes les pàgines, podem passar a l'últim pas: crear un bucle per iterar a través de diferents paràmetres de cerca.
Per aconseguir-ho, primer definirem l'estructura de dades en la qual emmagatzemarem aquests paràmetres de cerca i la denominarem searchParamsList
:
const searchParamsList: { searchText: string; locationText: string }[] = [
{ searchText: 'Angular', locationText: 'Barcelona' },
{ searchText: 'Angular', locationText: 'Madrid' },
// ...
{ searchText: 'React', locationText: 'Barcelona' },
{ searchText: 'React', locationText: 'Madrid' },
// ...
];
Per iterar asíncronament a través de l'array searchParamsList
, bàsicament necessitem convertir-lo d'un Array a un Observable utilitzant l'operador fromArray
. Subseqüentment, utilitzarem l'operador concatMap
per processar de manera seqüencial cada parell de searchText i locationText. La força de RxJS aquí és que, en el cas que de voler canviar de processament seqüencial a paral·lel, simplement hem de canviar el concatMap
per un mergeMap
. En aquest cas no es recomana perquè superariem el límit de peticions (per temps) de LinkedIn, però és una cosa a tenir en compte en altres escenaris.
/**
* Creates a new page and scrapes LinkedIn job data for each pair of searchText and locationText, recursively retrieving data until there are no more pages.
* @param browser A Puppeteer instance
* @returns An Observable that emits scraped job data as ScraperResult
*/
export function getJobsFromLinkedin(browser: Browser): Observable<ScraperResult> {
// Create a new page
const createPage = defer(() => fromPromise(browser.newPage()));
// Iterate through search parameters and scrape jobs
const scrapeJobs = (page: Page): Observable<ScraperResult> =>
fromArray(searchParamsList).pipe(
concatMap(({ searchText, locationText }) =>
getJobsFromAllPages(page, { searchText, locationText, pageNumber: 0 })
)
)
// Compose sequentially previous steps
return createPage.pipe(switchMap(page => scrapeJobs(page)));
}
Aquest codi iterarà a través de diversos paràmetres de cerca, un a la vegada, i recuperarà ofertes de feina per a cada combinació de searchText
i locationText
.
🎉 Felicitats! Ara ja sou capaços de fer "scraping" a LinkedIn i a qualsevol altre pàgina web! 🎉
Tot i això, LinkedIn, igual que moltes altres pàgines web, té tècniques per prevenir el web scraping. Anem a veure com solventar-les 👇.
Errors comuns al fer "scraping" a LinkedIn
Si executem el codi proporcionat, ràpidament ens trobarem amb nombrosos errors de LinkedIn, fent difícil fer "scraping" amb èxit d'una quantitat significativa d'informació. Hi ha dos errors comuns que necessitem abordar:
1. Resposta "status code" 429
Aquesta resposta pot passar durant el scraping, i significa que estem fent massa sol·licituds en un petit període de temps. Si ens trobem amb aquest error hem de reduir la velocitat en què es duen a terme les peticions fins que desaparegui.
2. Authwall de LinkedIn
De tant en tant, LinkedIn ens pot redirigir a un authwall en lloc de la pàgina desitjada. Quan apareix l'authwall, l'única opció és esperar una mica més abans de fer la pròxima sol·licitud.
Com superar aquests errors de manera efectiva
Aquests errors han de ser gestionats a la funció getJobsFromLinkedinPage
, ampliem aquesta funció i separarem el codi de "scraping" html en una altra funció anomenada getLinkedinJobsFromJobsPage
. El codi és així:
const AUTHWALL_PATH = 'linkedin.com/authwall';
const STATUS_TOO_MANY_REQUESTS = 429;
const JOB_SEARCH_SELECTOR = '.job-search-card';
function goToLinkedinJobsPageAndExtractJobs(page: Page, searchParams: ScraperSearchParams): Observable<JobInterface[]> {
return defer(() => fromPromise(page.setExtraHTTPHeaders({'accept-language': 'en-US,en;q=0.9'})))
.pipe(
switchMap(() => navigateToLinkedinJobsPage(page, searchParams)),
tap(response => checkResponseStatus(response)),
switchMap(() => throwErrorIfAuthwall(page)),
switchMap(() => waitForJobSearchCard(page)),
switchMap(() => getJobsFromLinkedinPage(page)),
retryWhen(retryStrategyByCondition({
maxRetryAttempts: 4,
retryConditionFn: error => error.retry === true
})),
map(jobs => Array.isArray(jobs) ? jobs : []),
take(1)
);
}
/**
* Navigate to the LinkedIn search page, using the provided search parameters.
*/
function navigateToLinkedinJobsPage(page: Page, searchParams: ScraperSearchParams) {
return defer(() => fromPromise(page.goto(urlQueryPage(searchParams), {waitUntil: 'networkidle0'})));
}
/**
* Check the HTTP response status and throw an error if too many requests have been made.
*/
function checkResponseStatus(response: any) {
const status = response?.status();
if (status === STATUS_TOO_MANY_REQUESTS) {
throw {message: 'Status 429 (Too many requests)', retry: true, status: STATUS_TOO_MANY_REQUESTS};
}
}
/**
* Check if the current page is an authwall and throw an error if it is.
*/
function throwErrorIfAuthwall(page: Page) {
return getPageLocationOperator(page).pipe(tap(locationHref => {
if (locationHref.includes(AUTHWALL_PATH)) {
console.error('Authwall error');
throw {message: `Linkedin authwall! locationHref: ${locationHref}`, retry: true};
}
}));
}
/**
* Wait for the job search card to be visible on the page, and handle timeouts or authwalls.
*/
function waitForJobSearchCard(page: Page) {
return defer(() => fromPromise(page.waitForSelector(JOB_SEARCH_SELECTOR, {visible: true, timeout: 5000}))).pipe(
catchError(error => throwErrorIfAuthwall(page).pipe(tap(() => {throw error})))
);
}
En aquest codi, abordem els errors esmentats anteriorment, és a dir, l'error de resposta 429 i el problema de l'authwall. Superar aquests errors és molt important per tenir èxit durant l'scraping web a LinkedIn.
Per gestionar aquests errors, el codi utilitza una estratègia de reintents personalitzada implementada per la funció retryStrategyByCondition
:
export const retryStrategyByCondition = ({maxRetryAttempts = 3, scalingDuration = 1000, retryConditionFn = (error) => true}: {
maxRetryAttempts?: number,
scalingDuration?: number,
retryConditionFn?: (error) => boolean
} = {}) => (attempts: Observable<any>) => {
return attempts.pipe(
mergeMap((error, i) => {
const retryAttempt = i + 1;
if (
retryAttempt > maxRetryAttempts ||
!retryConditionFn(error)
) {
return throwError(error);
}
console.log(
`Attempt ${retryAttempt}: retrying in ${retryAttempt *
scalingDuration}ms`
);
// retry after 1s, 2s, etc...
return timer(retryAttempt * scalingDuration);
}),
finalize(() => console.log('retryStrategyOnlySpecificErrors - finalized'))
);
};
Aquesta estratègia augmenta el temps entre cada intent després d'un error. D'aquesta manera, ens assegurem que esperarem prou temps perquè LinkedIn ens permeti fer sol·licituds de nou
Nota: És important ser conscient que LinkedIn pot posar a la llista negra la nostra adreça IP, i simplement esperar més temps pot no ser una solució efectiva. Per mitigar aquest problema potencial i reduir els errors, una pràctica recomanada és fer una rotació d'IPs de tant en tant.
Paraules finals
El web scraping pot violar freqüentment els termes de servei d'un lloc web. Sempre reviseu i respecteu l'arxiu robots.txt d'un lloc web i els seus Termes de Servei. En aquest cas, aquest codi ha de ser utilitzat NOMÉS amb fins docents i de hobby. LinkedIn prohibeix específicament qualsevol extracció de dades del seu lloc web; podeu llegir més aquí.
Recomano utilitzar el web scraping per a l'aprenentatge, l'ensenyament i projectes de hobby. Sempre recordeu de respectar el lloc web, eviteu llançar massa sol·licituds i assegureu-vos que les dades s'utilitzen de manera respectuosa.
Tot el codi actualitzat es troba en aquest repositori, no dubtis a donar-li una estrella si t'ha ajudat! 🙏⭐
Pots trobar-me a Twitter, Linkedin o Github.
També, gràcies a aleix10kst per ajudar en el procés de revisió del blog 🙌.
Bon Scraping!🕷