UNPKG

snazzy-crawler

Version:

A snazzy light Node.js image crawler laced with TypeScript goodness! 🕵️🦾

235 lines (203 loc) 6.29 kB
import puppeteer from 'puppeteer' import type { Browser, Page } from 'puppeteer' import inquirer, { type Answers } from 'inquirer' import { DEPTH_LIMIT, exportToJson, getOnlyHostURLs, getRandomEmoji, getUnique, normalizeURL } from '../lib/utils.js' import chalk from 'chalk' interface CrawlerOptions { /** URL for crawling **/ baseURL: string /** crawling depth, by default 0 **/ depth: number /** Puppeteer browser instance **/ browser: Browser /** Puppeteer page instance **/ page: Page } export type CrawlerResult = CrawlerResultObj[] interface CrawlerResultObj { imageUrl: string /** the page url this image was found on" **/ sourceUrl: string /** the depth of the source at which this image was found on" **/ depth: number } class Crawler { public baseURL: string public depth: number public browser: Browser public page: Page public readonly result: CrawlerResult private readonly crawledUrls: Set<string> private stack: string[] private constructor (options: CrawlerOptions) { this.baseURL = options.baseURL this.depth = options.depth this.browser = options.browser this.page = options.page this.crawledUrls = new Set() this.stack = [] this.result = [] } static async init (url: string, depth: number = 0): Promise<Crawler> { const browser = await puppeteer.launch({ headless: 'new' }) const page = await browser.newPage() return new Crawler({ baseURL: url, depth, browser, page }) } public async run (): Promise<void> { const startTime: bigint = process.hrtime.bigint() /* eslint-disable-next-line @typescript-eslint/no-misused-promises */ this.page.once('close', async () => { await this.onPageClosed() }) await this.crawlPage(this.baseURL, this.baseURL) const endTime: bigint = process.hrtime.bigint() const completedInSeconds: string = ( Number(endTime - startTime) / 1_000_000 / 1_000 ).toFixed(2) console.log(this.result) console.log( chalk.magenta(`\nYASSSS, Crawling Done!. 🫡 ${completedInSeconds}s`) ) console.log( chalk.magenta(`"Wow! We found ${this.result.length} awesome images! 🎉"`) ) await this._close() } private async onPageClosed (): Promise<void> { await inquirer .prompt([ { name: 'exportToJSON', type: 'confirm', message: 'Export to JSON file? 💾', default: false } ]) .then((answers: Answers) => { if (answers.exportToJSON as boolean) { exportToJson(this.result, this.baseURL) } }) .catch((error) => { console.log(error) }) process.exit() } public async crawlPage (baseURL: string, currentURL: string): Promise<void> { const baseURLObj = new URL(baseURL) const currentURLObj = new URL(currentURL) if (baseURLObj.hostname !== currentURLObj.hostname) { return } console.log( chalk.green(`${currentURL}? ${getRandomEmoji()} Let's crawl it!"`) ) await this.page.goto(currentURL) await this.autoScroll(this.page) const images = await this.findImages(this.page, currentURL, 0) if (images.length === 0) { console.log(chalk.cyan('No pics, no proof.')) } else { console.log(chalk.cyan(`Boom! Found ${images.length} pics.`)) } this.result.push(...images) const nextURLs: string[] = await this.findURLs(this.page) this.stack = [...nextURLs] const maxDepth = Math.min(this.depth, DEPTH_LIMIT) for (let l = 0; l < maxDepth; l++) { if (l > 4) { console.log( chalk.magenta(`\nWe're diving in deep, we've hit level ${l}!`) ) } else { console.log(chalk.magenta(`You're at level ${l}`)) } /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ for (const _ of nextURLs) { if (this.stack.length === 0) return const url: string = normalizeURL(this.stack.pop() as string) if (!this.crawledUrls.has(url)) { this.crawledUrls.add(url) try { console.log( chalk .hex('fff1f3') .underline(`\nGrabbing every link on ${url}! 🌐`) ) await this.page.goto(url) const images = await this.findImages(this.page, url, l) if (images.length === 0) { console.log(chalk.cyan('No pics, no proof.')) } else { console.log( chalk.cyan( `Boom! Found ${images.length} ${ images.length === 1 ? 'pic' : 'pics' }.` ) ) } this.result.push(...images) } catch (e) { console.log(chalk.red((e as Error).message)) } const urls = await this.findURLs(this.page) for (const i of urls) { this.stack.push(i) } } } } } private async autoScroll (page: Page): Promise<void> { await page.evaluate(async () => { await new Promise<void>((resolve) => { let totalHeight = 0 const distance = 250 const timer = setInterval(() => { const scrollHeight = document.body.scrollHeight window.scrollBy(0, distance) totalHeight += distance if (totalHeight >= scrollHeight - window.innerHeight) { clearInterval(timer) resolve() } }, 100) }) }) } private async findImages ( page: Page, sourceUrl: string, depth: number ): Promise<CrawlerResult> { console.log(chalk.italic("Hmm... Let's find some pics on this page! 🧐📷")) const imagesArr = getUnique( await page.evaluate(() => Array.from(document.images, (e) => e.src)) ) return imagesArr.map((imageUrl) => ({ imageUrl, sourceUrl, depth })) } private async findURLs (page: Page): Promise<string[]> { return getOnlyHostURLs( await page.evaluate(() => Array.from(document.links, (e) => e.href)), this.baseURL ) } private async _close (): Promise<void> { await this.browser.close() } } export default Crawler