snazzy-crawler
Version:
A snazzy light Node.js image crawler laced with TypeScript goodness! 🕵️🦾
235 lines (203 loc) • 6.29 kB
text/typescript
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