Web Scraping d'ofertes de treball a LinkedIn utilitzant Puppeteer i RxJS

Tutorial sobre com extreure ofertes de treball de LinkedIn utilitzant Puppeteer i RxJS

post-image

El web scraping pot semblar una tasca senzilla, però hi ha molts reptes a superar. En aquest blog, ens endinsarem en com fer scraping a LinkedIn per extreure llistats de treball. Per fer això, utilitzarem Puppeteer i RxJS. L'objectiu és assolir web scraping d'una manera declarativa, modular i escalable.

Entenent el Web scraping

El web scraping és una tècnica d'extracció de dades utilitzada per recopilar informació de llocs web. Involucra el procés automatitzat d'obtenció de dades específiques de pàgines web, com ara text, imatges, enllaços i més, i 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 asincrònics. Utilitzarem RxJS en aquest exemple perquè ofereix els avantatges següents:

  • Codi asincrònic 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

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:

src/index.ts
(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
     */
    
})();

Anar a la llista de llocs de treball de LinkedIn i extreure les dades

Aquesta és la part central d'aquest bloc, on ens submergim en el procés d'accés a les ofertes de treball de LinkedIn, analitzant el contingut HTML i recuperant les dades del lloc de treball en format JSON.

1- Construint la URL per Navegar per les Ofertes de Treball de LinkedIn

Per accedir a les ofertes de treball de LinkedIn, necessitem construir una URL utilitzant la funció següent:

export const urlQueryPage = (search: ScraperSearchParams) =>
    `https://linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search?keywords=${search.searchText}
    &start=${search.nPage * 25}${search.locationText ? '&location=' + search.locationText : ''}`

Aquesta funció de generació de URL és un pas crucial en el nostre procés, ja que ens permet navegar per les ofertes de treball de LinkedIn amb els criteris de cerca específics definits per searchText, pageNumber, i opcionalment locationText.

Exemples de url poden ser:

  1. https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search?keywords=Angular&start=0
  2. https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search?keywords=React&location=Barcelona&start=0
  3. https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search?keywords=python&start=0

2- Navegant a la URL i Extraient Dades de les Ofertes

Amb la nostra URL objectiu identificada, podem procedir amb les dues accions principals requerides:

  1. Navegar a la URL de les Ofertes de Treball: Aquest pas implica dirigir la nostra eina de raspallat web a la URL on estan allotjades les ofertes de treball.

  2. Extreure dades de les ofertes de feina i convertint-les a JSON: Un cop estem a la pàgina d'ofertes de treball, utilitzarem tècniques de "scraping" web per extreure les dades de les ofertes i retornar-les en format JSON.

src/linkedin.ts

/** main function */
export function getJobsFromLinkedinPage(page: Page, searchParams): Observable<JobInterface[]> {
    return defer(() => navigateToJobsPage(page, searchParams))
        .pipe(switchMap(() => getJobsFromLinkedinPage(page)));
}

/* Utility functions  */

export const urlQueryPage = (search: ScraperSearchParams) =>
    `https://linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search?keywords=${search.searchText}
    &start=${search.nPage * 25}${search.locationText ? '&location=' + search.locationText : ''}`

function navigateToJobsPage(page: Page, searchParams): 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 efectivament tota la informació de treball disponible de la pàgina. Encara que el codi és molt estètic, aconsegueix la feina, que és típic per a codi de "scraping" web.

En un context de programació estàndard, generalment és aconsellable descompondre el codi en funcions més petites i aïllades per millorar 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 de page.evaluate. L'única excepció aquí són les variables (com stacks en el nostre cas), que poden passar-se com a arguments a page.evaluate, sempre que no continguin funcions o objectes complexos que no es puguin serialitzar.

In this case, the only challenging part to scrape is the salary information, as it involves converting a text format like '$65,000.00 - $90,000.00' into separate salaryMin and salaryMax values. Additionally, we've encapsulated the entire code within a try/catch block to gracefully handle errors. While we currently log errors to the console, it's advisable to consider implementing a mechanism to store these error logs on disk. This practice becomes particularly important because web pages often undergo changes, necessitating frequent updates to the HTML parsing code.

En aquest cas, l'únic component desafiant "per escrapejar"(to scrape) és la informació del salari, ja que implica convertir un format de text com '$65,000.00 - $90,000.00' en valors 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 notar que sempre utilitzem els operadors defer i fromPromise per convertir Promeses en Observables:

defer(() => fromPromise(myPromise()))

This approach is a recommended best practice that works reliably in all scenarios. Promises are eager, whereas Observables are lazy and only initiate when someone subscribes to them. The defer operator allows us to make a Promise lazy.

Aquest enfocament és una millor pràctica recomanada 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.

3- Add an asynchronous loop to iterate through all the pages

In the previous step we understood how to obtain all the jobs data from a LinkedIn page. What we want to do now is use that code as many times as possible to get as many data we can. To achieve this, first, we need to iterate through all the possible pages:

3- Afegir un Bucle Asíncron per Iterar a través de Totes les Pàgines

En l'etapa anterior, hem après com obtenir totes les dades de les ofertes de treball 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:

src/linkedin.ts
export function getJobsFromPageRecursive(page: Page, searchParams: ScraperSearchParams): Observable<ScraperResult> {
    return getJobsFromLinkedinPage(page, searchParams).pipe(
        map((jobs): ScraperResult => ({jobs, searchParams} as ScraperResult)),
        catchError(error => {
            console.error('error', error);
            return of({jobs: [], searchParams})
        }),
        switchMap(({jobs}) => {
            console.log(`Linkedin - Query: ${searchParams.searchText}, Location: ${searchParams.locationText}, Page: ${searchParams.nPage}, nJobs: ${jobs.length}, url: ${urlQueryPage(searchParams)}`);
            if (jobs.length === 0) {
                return EMPTY;
            } else {
                return concat(of({jobs, searchParams}), getJobsFromPageRecursive(page, {...searchParams, nPage: searchParams.nPage++}));
            }
        })
    );
}

El codi anterior és un bucle asíncron creat amb recursió.

En RxJS, no podem utilitzar un bucle(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

Podríem implementar això utilitzant Promises en lloc d'Observables? Absolutament, aquí teniu el codi equivalent escrit amb Promises:

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 realitza accions gairebé idèntiques a les que utilitza Observables, però amb una diferència crítica: només emet quan totes les pàgines han acabat el seu processament. En contrast, la implementació que fa ús d'Observables emet després de cada pàgina. Crear un "stream" de dades és vital en aquest cas perquè volem gestionar les ofertes de treball tan aviat com es resolguin.

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 treball (un cas comú serà guardar les ofertes de treball en 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:

src/data.ts
const searchParamsList: { searchText: string; locationText: string }[] = [
  { searchText: 'Angular', locationText: 'Barcelona' },
  { searchText: 'Angular', locationText: 'Madrid' },
  // ...
  { searchText: 'React', locationText: 'Barcelona' },
  { searchText: 'React', locationText: 'Madrid' },
  // ...
];

To iterate through the searchParamsList array, we essentially need to convert it from an Array to an Observable using the fromArray operator. Subsequently, we will use the concatMap operator to sequentially process each searchText and locationText pair. The power of RxJS here is that, in the case where we may want to switch from sequential to parallel processing, we just need to change the concatMap for a mergeMap. In this case, it is not recommended because we will exceed LinkedIn's rate limits, but it's something to consider in other scenarios.

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.

src/linkedin.ts
/**
 * 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 }) =>
                getJobsFromPageRecursive(page, { searchText, locationText, nPage: 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 sou capaços de fer "scraping" a LinkedIn i a qualsevol altre pàgia web! 🎉

Tot i això, hi ha alguns desafiaments a superar per a un "scraping" consistent a LinkedIn.

Errors Comuns al fer "Scraping" a LinkedIn

Si executeu el codi proporcionat, ràpidament us trobareu 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 ocorrer durant el scraping, i significa que esteu fent massa sol·licituds. Si us trobeu amb aquest error, considereu reduir la velocitat en què es duen a terme les peticions fins que desaparegui.

2- Authwall de LinkedIn

De tant en tant, LinkedIn us 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, ampliarem aquesta funció i separarem el codi de "scraping" html en una altra funció anomenada getLinkedinJobsFromJobsPage. El codi és així:

src/linkedin.ts
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 crucial per assegurar l'èxit a llarg termini del scraping web de LinkedIn.

Per gestionar aquests errors, el codi utilitza una estratègia de reintents personalitzada implementada per la funció retryStrategyByCondition. Aquesta estratègia, bàsicament, augmenta el temps entre cada reintent després d'un fracàs, permetent que el codi sigui més resistent quan s'enfronta a aquests errors comuns de LinkedIn. Aquest mecanisme de reintent ajuda a gestionar els desafiaments associats amb el scraping de LinkedIn i millora la fiabilitat global del procés.

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 l'ocurrència d'errors, una pràctica recomanada és implementar una rotació d'IP a intervals regulars. Com a exemple, utilitzar una VPN com a proxy i canviar periòdicament entre diferents ubicacions geogràfiques presenta una solució fàcil i efectiva a aquest repte.

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 hauria 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.

Podeu trobar tot el codi actualitzat en aquest repositori!

Bon Scraping!🕷


T'ha agradat el post? Si us plau, comparteix-lo