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

Tutorial sobre com extreure ofertes de feina 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, 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:

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
     */

})();

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:

src/linkedin.ts
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:

  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.2. Navega a l'URL i extreu les ofertes de feina

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

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

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

src/linkedin.ts

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

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:

src/linkedin.ts
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:

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' },
  // ...
];

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 }) =>
                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í:

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 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:

src/scraper.utils.ts
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!🕷


Has trobat una errada? Edita-la al Github

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

Altres blogs que podrien interessar-te...

Què són els dark patterns?

Descobreix tot sobre els 'dark patterns' en aquest article, on s'explica en profunditat què són, com funcionen, els diferents tipus, per què s'utilitzen i com evitar-los.

twitter-profile
19 de març de 2024
Enginyeria inversa del ecommerce de Nike amb només el navegador

Article sobre les tecnologies que s'utilitzen a l'ecommerce de nike

twitter-profile
1 de des. de 2023