UNPKG

@microfox/puppeteer-sls

Version:

Puppeteer SLS - Run puppeteer on a serverless function (AWS Lambda, etc.)

1 lines 69.4 kB
{"version":3,"sources":["../src/functions/openPage.ts","../src/spartacuz.ts","../src/functions/takeSnapShot.ts","../src/functions/extractWebpage.ts","../src/functions/extractLinks.ts","../src/functions/colorExtract.ts","../src/functions/extractImages.ts","../src/functions/extractVideos.ts","../src/puppeteerSLS.ts"],"sourcesContent":["import puppeteer from 'puppeteer-extra';\nimport StealthPlugin from 'puppeteer-extra-plugin-stealth';\nimport { puppeteerLaunchProps } from '../spartacuz';\n\npuppeteer.use(StealthPlugin());\n\n/**\n * Options for opening a new page.\n */\nexport interface OpenPageOptions {\n /**\n * The URL to navigate to.\n */\n url: string;\n /**\n * Viewport dimensions.\n */\n defaultViewport?: {\n width: number;\n height: number;\n };\n /**\n * Whether to run in headless mode.\n * @default false\n */\n headless?: boolean;\n /**\n * Whether to close the browser after the operation is complete.\n * @default false\n */\n isLocal?: boolean;\n /**\n * whether to wait complete page load\n * @default true\n */\n waitUntil?: 'networkidle2' | 'networkidle0' | 'domcontentloaded' | 'load';\n}\n\n/**\n * Opens a new page in a browser instance and navigates to a URL.\n *\n * @param options - The options for opening the page.\n * @returns A promise that resolves to the Puppeteer page object.\n */\nexport async function openPage({\n url,\n defaultViewport,\n headless,\n isLocal,\n waitUntil,\n}: OpenPageOptions) {\n console.log(\n `Opening page with options: ${JSON.stringify({\n url,\n defaultViewport,\n headless,\n isLocal,\n })}`,\n );\n try {\n const launchProps = await puppeteerLaunchProps(\n defaultViewport,\n isLocal ?? false,\n headless,\n );\n console.log('Got puppeteer launch properties.');\n const browser = await puppeteer.launch(\n !headless\n ? {\n ...launchProps,\n slowMo: 100,\n }\n : launchProps,\n );\n console.log('Browser launched.');\n const page = await browser.newPage();\n console.log('New page created.');\n const pageLoad = await page.goto(url, {\n waitUntil: waitUntil ?? 'networkidle2',\n });\n console.log(`Navigated to URL: ${pageLoad?.url()}`);\n return { browser, page };\n } catch (error) {\n console.error('Error opening page:', error);\n throw error;\n }\n}\n","import chromium from '@sparticuz/chromium';\nimport * as fs from 'fs';\n\n// Check if running locally\nconst isLocal = process.env.IS_OFFLINE || process.env.SERVERLESS_OFFLINE;\n\nchromium.setGraphicsMode = false;\n\nexport const puppeteerLaunchProps = async (\n defaultViewport?: {\n width: number;\n height: number;\n },\n _isLocal?: boolean,\n headless?: boolean,\n) => {\n let executablePath: string | undefined;\n\n console.log('isLocal', isLocal, process.env.CHROME_EXECUTABLE_PATH);\n const CHROME_PATHS = {\n darwin: [\n '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',\n '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',\n '/Applications/Chromium.app/Contents/MacOS/Chromium',\n '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',\n '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',\n '~/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',\n '~/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',\n '~/Applications/Chromium.app/Contents/MacOS/Chromium',\n ],\n linux: [\n '/usr/bin/google-chrome',\n '/usr/bin/google-chrome-stable',\n '/usr/bin/google-chrome-beta',\n '/usr/bin/google-chrome-unstable',\n '/usr/bin/chromium',\n '/usr/bin/chromium-browser',\n '/usr/bin/microsoft-edge',\n '/usr/bin/microsoft-edge-stable',\n '/usr/bin/microsoft-edge-beta',\n '/usr/bin/microsoft-edge-dev',\n '/snap/bin/chromium',\n '/usr/local/bin/chrome',\n '/usr/local/bin/chromium',\n '/opt/google/chrome/chrome',\n '/opt/google/chrome-beta/chrome',\n '/opt/google/chrome-unstable/chrome',\n '/opt/chromium/chrome-linux/chrome',\n '/opt/microsoft/msedge/msedge',\n '/opt/brave.com/brave/brave',\n // Flatpak paths\n '~/.local/share/flatpak/exports/bin/org.chromium.Chromium',\n '/var/lib/flatpak/exports/bin/org.chromium.Chromium',\n ],\n win32: [\n 'C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe',\n 'C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe',\n 'C:\\\\Program Files (x86)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe',\n 'C:\\\\Program Files\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe',\n 'C:\\\\Program Files\\\\Brave Software\\\\Brave-Browser\\\\Application\\\\brave.exe',\n 'C:\\\\Program Files (x86)\\\\Brave Software\\\\Brave-Browser\\\\Application\\\\brave.exe',\n // User-specific locations with AppData\n `${process.env.LOCALAPPDATA}\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe`,\n `${process.env.LOCALAPPDATA}\\\\Google\\\\Chrome SxS\\\\Application\\\\chrome.exe`,\n `${process.env.LOCALAPPDATA}\\\\Chromium\\\\Application\\\\chrome.exe`,\n `${process.env.LOCALAPPDATA}\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe`,\n `${process.env.LOCALAPPDATA}\\\\BraveSoftware\\\\Brave-Browser\\\\Application\\\\brave.exe`,\n // Windows 10/11 may have Edge installed in Program Files\n 'C:\\\\Program Files (x86)\\\\Microsoft\\\\Edge Dev\\\\Application\\\\msedge.exe',\n 'C:\\\\Program Files (x86)\\\\Microsoft\\\\Edge Beta\\\\Application\\\\msedge.exe',\n // Google Chrome default installation path\n `${process.env.ProgramFiles}\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe`,\n `${process.env.ProgramFiles}\\\\Google\\\\Chrome Dev\\\\Application\\\\chrome.exe`,\n `${process.env.ProgramFiles}\\\\Google\\\\Chrome Beta\\\\Application\\\\chrome.exe`,\n `${process.env.ProgramFiles}\\\\Google\\\\Chrome SxS\\\\Application\\\\chrome.exe`,\n // Chromium\n `${process.env.ProgramFiles}\\\\Chromium\\\\Application\\\\chrome.exe`,\n ],\n };\n\n if (process.env.CHROME_EXECUTABLE_PATH) {\n executablePath = process.env.CHROME_EXECUTABLE_PATH;\n } else if (isLocal || _isLocal) {\n const platform = process.platform as keyof typeof CHROME_PATHS;\n const paths = CHROME_PATHS[platform];\n\n if (paths) {\n for (const p of paths) {\n if (fs.existsSync(p)) {\n executablePath = p;\n break;\n }\n }\n }\n\n if (!executablePath) {\n console.error(\n 'Could not find Chrome in default locations. Please install Chrome or specify the path manually via the CHROME_EXECUTABLE_PATH environment variable.',\n );\n throw new Error('Chrome not found');\n }\n } else {\n // For Lambda environment\n executablePath = await chromium.executablePath();\n }\n\n const finalHeadless = isLocal || _isLocal ? (headless ?? false) : true;\n\n const args = finalHeadless\n ? chromium.args\n : chromium.args.filter(arg => !arg.startsWith('--headless'));\n\n console.log('executablePath', executablePath, finalHeadless);\n\n return {\n args: [\n ...args,\n '--no-sandbox',\n '--disable-setuid-sandbox',\n '--disable-dev-shm-usage',\n '--disable-gpu',\n ],\n defaultViewport: {\n width: defaultViewport?.width || 1920,\n height: defaultViewport?.height || 1080,\n },\n executablePath,\n headless: finalHeadless,\n };\n};\n","import { S3Space, S3SpaceConfig } from '@microfox/s3-space';\nimport { openPage, OpenPageOptions } from './openPage';\n\n/**\n * Options for taking a screenshot.\n */\nexport interface TakeSnapShotOptions extends OpenPageOptions {\n /**\n * The file name for the screenshot.\n */\n fileName: string;\n /**\n * The quality of the screenshot. Only for jpeg.\n * @default 100\n */\n quality?: number;\n /**\n * The encoding of the screenshot.\n * @default 'binary'\n */\n encoding?: 'base64' | 'binary';\n /**\n * S3 configuration for uploading the screenshot.\n */\n s3Config: S3SpaceConfig;\n}\n\n/**\n * Opens a page and takes a screenshot, uploads it to S3 and returns public URL.\n *\n * @param options - The options for taking the screenshot.\n * @returns A promise that resolves with the public URL of the screenshot.\n */\nexport async function takeSnapShot(\n options: TakeSnapShotOptions,\n): Promise<string | undefined> {\n const { browser, page } = await openPage(options);\n const screenshotBuffer = await page.screenshot({\n quality: options.quality,\n encoding: options.encoding || 'binary',\n type: options.quality ? 'jpeg' : 'png',\n });\n await browser.close();\n\n const s3 = new S3Space(options.s3Config);\n const fileInfo = {\n fileName: options.fileName,\n fileType: options.quality ? 'image/jpeg' : 'image/png',\n fileBuffer: screenshotBuffer,\n };\n await s3.uploadFile({\n fileInfoWithFileBuffer: fileInfo,\n });\n\n return s3.getPublicFileUrl({\n file: fileInfo,\n });\n}\n","import { Page } from 'puppeteer-core';\nimport { openPage, OpenPageOptions } from './openPage';\nimport TurndownService from 'turndown';\nexport type ExtractWebpageContentOptions = OpenPageOptions & {\n /**\n * Whether to convert the content to Markdown.\n * @default false\n */\n toMarkdown?: boolean;\n /**\n * Whether to convert the content to HTML.\n * @default false\n */\n toHTML?: boolean;\n};\n\n/**\n * Opens a page and converts its content to Markdown.\n *\n * @param options - The options for opening the page.\n * @returns A promise that resolves to the Markdown content of the page.\n */\nexport async function extractToMarkdown(page: Page) {\n const html = await page.evaluate(() => document.body.innerHTML);\n\n const turndownService = new TurndownService();\n const markdown = turndownService.turndown(html);\n\n return markdown;\n}\n\n/**\n * Opens a page and extracts its full HTML content.\n *\n * @param options - The options for opening the page.\n * @returns A promise that resolves to the HTML content of the page.\n */\nexport async function extractHTML(page: Page) {\n const content = await page.evaluate(() => document.documentElement.outerHTML);\n return content;\n}\n\n/**\n * Opens a page and extracts its text content.\n *\n * @param options - The options for opening the page.\n * @returns A promise that resolves to the text content of the page.\n */\nexport async function extractWebpage(options: ExtractWebpageContentOptions) {\n const { browser, page } = await openPage(options);\n const content = await page.evaluate(() => document.body.innerText);\n const markdown = options.toMarkdown\n ? await extractToMarkdown(page)\n : undefined;\n const html = options.toHTML ? await extractHTML(page) : undefined;\n return {\n content,\n browser,\n markdown,\n html,\n page,\n };\n}\n","import { Page } from 'puppeteer-core';\nimport { openPage, OpenPageOptions } from './openPage';\n\nexport type ExtractedLink = {\n href: string;\n displayName: string;\n type: 'statix' | 'webpage';\n inPageLink: boolean;\n externalLink: boolean;\n internalLink: boolean;\n staticType?: 'video' | 'image' | 'file' | 'other';\n fileExtension?: string;\n};\n\n/**\n * Opens a page and extracts all links.\n *\n * @param options - The options for opening the page.\n * @returns A promise that resolves to an array of links.\n */\nexport async function extractLinks(page: Page): Promise<ExtractedLink[]> {\n const links: ExtractedLink[] = await page.evaluate(() => {\n const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.svg'];\n const videoExtensions = ['.mp4', '.mov', '.avi'];\n const fileExtensions = [\n '.pdf',\n '.doc',\n '.docx',\n '.xls',\n '.xlsx',\n '.zip',\n '.rar',\n ];\n\n return Array.from(\n document.querySelectorAll<HTMLAnchorElement>('a[href]'),\n ).map(anchor => {\n const hrefAttr = anchor.getAttribute('href') || '';\n const href = anchor.href;\n const displayName = anchor.innerText.trim();\n const inPageLink = hrefAttr.startsWith('#');\n\n let externalLink = false;\n let isWebLink = false;\n let url: URL | undefined;\n\n try {\n url = new URL(href);\n isWebLink = true;\n externalLink = url.hostname !== window.location.hostname;\n } catch (e) {\n isWebLink = false;\n externalLink = false;\n }\n\n const internalLink = isWebLink && !externalLink && !inPageLink;\n\n const linkResult: {\n href: string;\n displayName: string;\n type: 'statix' | 'webpage';\n inPageLink: boolean;\n externalLink: boolean;\n internalLink: boolean;\n staticType?: 'video' | 'image' | 'file' | 'other';\n fileExtension?: string;\n } = {\n href,\n displayName,\n type: 'webpage',\n inPageLink,\n externalLink,\n internalLink,\n };\n\n if (url) {\n const pathname = url.pathname;\n const extMatch = pathname.match(/\\.([^/.]+)$/);\n if (extMatch) {\n const ext = `.${extMatch[1].toLowerCase()}`;\n\n if (imageExtensions.includes(ext)) {\n linkResult.type = 'statix';\n linkResult.staticType = 'image';\n linkResult.fileExtension = ext.substring(1);\n } else if (videoExtensions.includes(ext)) {\n linkResult.type = 'statix';\n linkResult.staticType = 'video';\n linkResult.fileExtension = ext.substring(1);\n } else if (fileExtensions.includes(ext)) {\n linkResult.type = 'statix';\n linkResult.staticType = 'file';\n linkResult.fileExtension = ext.substring(1);\n }\n }\n }\n\n return linkResult;\n });\n });\n return links;\n}\n\nexport interface ExtractLinksOptions extends OpenPageOptions {\n onlyExternal?: boolean;\n onlyInternal?: boolean;\n onlyInPage?: boolean;\n onlyStatic?: boolean;\n}\n\nexport async function extractLinksFromUrl(options: ExtractLinksOptions) {\n const { browser, page } = await openPage(options);\n const links = await extractLinks(page);\n await browser.close();\n return links;\n}\n","/**\n * Color extraction helpers for use in Puppeteer and other contexts\n * Accepts imageData (Uint8ClampedArray) and returns palette, dominantColor, secondaryColor, and accentColor as hex strings\n *\n * The algorithm follows these steps:\n * 1. Sample pixels from image data for performance\n * 2. Use Median Cut quantization to reduce colors to manageable palette\n * 3. Sort by luminance for predictable filtering\n * 4. Filter out perceptually similar colors using CIELAB Delta E\n * 5. Select dominant, secondary, and accent colors based on vibrancy and contrast rules\n */\n\nexport interface AccessibilityInfo {\n color: string; // Hex color that provides best contrast with dominant color\n contrastRatio: number; // WCAG contrast ratio (should be >= 4.5 for AA compliance)\n}\n\nexport interface ExtractedPalette {\n palette: string[]; // Array of distinct hex colors from the image\n dominantColor: string | undefined; // Most vibrant/striking color in the palette\n secondaryColor: string | undefined; // Complementary color based on dominant characteristics\n accentColor?: string | undefined; // Median appearance color with good contrast and luminance\n accessibility?: AccessibilityInfo; // Best contrast color for accessibility\n logs?: string[]; // Debug logs showing the extraction process\n}\n\nexport function extractPaletteFromImageData(\n imageData: Uint8ClampedArray,\n options: {\n deltaEThreshold?: number; // Threshold for CIELAB Delta E color similarity (default: 4)\n paletteSize?: number; // Maximum number of colors in final palette (default: 12),\n enableLogs?: boolean; // Whether to enable logs (default: false)\n } = { enableLogs: false },\n): ExtractedPalette {\n const {\n deltaEThreshold = 0.5,\n paletteSize = 12,\n enableLogs = false,\n } = options;\n const logs: string[] = [];\n\n const log = (message: string) => {\n if (enableLogs) {\n logs.push(message);\n }\n };\n\n // --- Color science helper functions ---\n /**\n * Converts hex color string to RGB object\n * @param hex - Hex color string (e.g., \"#FF0000\" or \"FF0000\")\n * @returns RGB object or null if invalid hex\n */\n const hexToRgb = (\n hex: string,\n ): { r: number; g: number; b: number } | null => {\n const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);\n return result\n ? {\n r: parseInt(result[1], 16),\n g: parseInt(result[2], 16),\n b: parseInt(result[3], 16),\n }\n : null;\n };\n /**\n * Calculates relative luminance of an RGB color (WCAG 2.1 formula)\n * Used for contrast ratio calculations\n * @param rgb - RGB color object\n * @returns Luminance value between 0 and 1\n */\n const getLuminance = (rgb: { r: number; g: number; b: number }): number => {\n const a = [rgb.r, rgb.g, rgb.b].map(v => {\n v /= 255;\n return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);\n });\n return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;\n };\n\n /**\n * Calculates contrast ratio between two RGB colors (WCAG 2.1 formula)\n * @param rgb1 - First RGB color\n * @param rgb2 - Second RGB color\n * @returns Contrast ratio (1:1 = no contrast, 21:1 = maximum contrast)\n */\n const getContrastRatio = (\n rgb1: { r: number; g: number; b: number },\n rgb2: { r: number; g: number; b: number },\n ): number => {\n const lum1 = getLuminance(rgb1);\n const lum2 = getLuminance(rgb2);\n const brightest = Math.max(lum1, lum2);\n const darkest = Math.min(lum1, lum2);\n return (brightest + 0.05) / (darkest + 0.05);\n };\n /**\n * Converts RGB color to HSL (Hue, Saturation, Lightness)\n * Used for color analysis and vibrancy calculations\n * @param rgb - RGB color object\n * @returns HSL object with h (0-360), s (0-1), l (0-1)\n */\n const rgbToHsl = (rgb: { r: number; g: number; b: number }) => {\n // Make r, g, and b fractions of 1\n const r = rgb.r / 255;\n const g = rgb.g / 255;\n const b = rgb.b / 255;\n\n // Find greatest and smallest channel values\n const cmin = Math.min(r, g, b);\n const cmax = Math.max(r, g, b);\n const delta = cmax - cmin;\n let h = 0;\n let s = 0;\n let l = 0;\n\n // Calculate hue (0-360 degrees)\n if (delta == 0) h = 0;\n else if (cmax == r) h = ((g - b) / delta) % 6;\n else if (cmax == g) h = (b - r) / delta + 2;\n else h = (r - g) / delta + 4;\n\n h = Math.round(h * 60);\n if (h < 0) h += 360;\n\n // Calculate lightness (0-1, where 0 is black, 1 is white)\n l = (cmax + cmin) / 2;\n\n // Calculate saturation (0-1, where 0 is grayscale, 1 is fully saturated)\n s = delta == 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));\n\n return { h, s, l };\n };\n /**\n * Converts RGB color to CIELAB color space\n * Used for perceptual color difference calculations (Delta E)\n * @param rgb - RGB color object\n * @returns LAB object with l (lightness), a (green-red), b (blue-yellow)\n */\n const rgbToLab = (rgb: { r: number; g: number; b: number }) => {\n let r = rgb.r / 255,\n g = rgb.g / 255,\n b = rgb.b / 255;\n // Gamma correction\n r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92;\n g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92;\n b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92;\n // Convert to XYZ color space\n let x = r * 0.4124 + g * 0.3576 + b * 0.1805;\n let y = r * 0.2126 + g * 0.7152 + b * 0.0722;\n let z = r * 0.0193 + g * 0.1192 + b * 0.9505;\n // Normalize to D65 illuminant\n x /= 95.047;\n y /= 100;\n z /= 108.883;\n // Convert to LAB\n x = x > 0.008856 ? Math.pow(x, 1 / 3) : 7.787 * x + 16 / 116;\n y = y > 0.008856 ? Math.pow(y, 1 / 3) : 7.787 * y + 16 / 116;\n z = z > 0.008856 ? Math.pow(z, 1 / 3) : 7.787 * z + 16 / 116;\n return { l: 116 * y - 16, a: 500 * (x - y), b: 200 * (y - z) };\n };\n\n /**\n * Calculates CIELAB Delta E (perceptual color difference)\n * Used to determine if two colors are perceptually similar\n * @param labA - First LAB color\n * @param labB - Second LAB color\n * @returns Delta E value (0 = identical, higher = more different)\n */\n const deltaE = (\n labA: { l: number; a: number; b: number },\n labB: { l: number; a: number; b: number },\n ) => {\n const deltaL = labA.l - labB.l;\n const deltaA = labA.a - labB.a;\n const deltaB = labA.b - labB.b;\n return Math.sqrt(deltaL * deltaL + deltaA * deltaA + deltaB * deltaB);\n };\n\n /**\n * Converts RGB color to hex string\n * @param pixel - RGB color object\n * @returns Hex color string (e.g., \"#FF0000\")\n */\n const rgbToHex = (pixel: { r: number; g: number; b: number }) => {\n const componentToHex = (c: number) => {\n const hex = c.toString(16);\n return hex.length == 1 ? '0' + hex : hex;\n };\n return (\n '#' +\n componentToHex(pixel.r) +\n componentToHex(pixel.g) +\n componentToHex(pixel.b)\n ).toUpperCase();\n };\n\n /**\n * Extracts RGB values from image data with sampling for performance\n * @param imageData - Raw image pixel data (RGBA format)\n * @param sampleRate - How many pixels to skip (higher = faster but less accurate)\n * @returns Array of RGB color objects\n */\n const buildRgb = (imageData: Uint8ClampedArray, sampleRate: number = 5) => {\n const rgbValues = [];\n // Sample pixels for performance, iterating by sampleRate * 4 (RGBA = 4 bytes per pixel)\n for (let i = 0; i < imageData.length; i += sampleRate * 4) {\n rgbValues.push({\n r: imageData[i], // Red channel\n g: imageData[i + 1], // Green channel\n b: imageData[i + 2], // Blue channel\n // Note: Alpha channel (i + 3) is ignored\n });\n }\n return rgbValues;\n };\n\n /**\n * Finds the color channel (R, G, or B) with the largest range\n * Used by Median Cut quantization algorithm\n * @param rgbValues - Array of RGB colors\n * @returns Channel name with biggest range ('r', 'g', or 'b')\n */\n const findBiggestColorRange = (\n rgbValues: { r: number; g: number; b: number }[],\n ) => {\n let rMin = Number.MAX_VALUE,\n gMin = Number.MAX_VALUE,\n bMin = Number.MAX_VALUE;\n let rMax = Number.MIN_VALUE,\n gMax = Number.MIN_VALUE,\n bMax = Number.MIN_VALUE;\n\n // Find min/max values for each channel\n rgbValues.forEach(pixel => {\n rMin = Math.min(rMin, pixel.r);\n gMin = Math.min(gMin, pixel.g);\n bMin = Math.min(bMin, pixel.b);\n rMax = Math.max(rMax, pixel.r);\n gMax = Math.max(gMax, pixel.g);\n bMax = Math.max(bMax, pixel.b);\n });\n\n // Calculate ranges and return channel with largest range\n const rRange = rMax - rMin;\n const gRange = gMax - gMin;\n const bRange = bMax - bMin;\n const biggestRange = Math.max(rRange, gRange, bRange);\n if (biggestRange === rRange) return 'r';\n if (biggestRange === gRange) return 'g';\n return 'b';\n };\n\n /**\n * Median Cut quantization algorithm\n * Recursively splits color space to create a palette of representative colors\n * @param rgbValues - Array of RGB colors to quantize\n * @param depth - Current recursion depth (0-5)\n * @returns Array of quantized RGB colors\n */\n const quantization = (\n rgbValues: { r: number; g: number; b: number }[],\n depth: number,\n ): { r: number; g: number; b: number }[] => {\n const MAX_DEPTH = 5; // Maximum recursion depth for performance\n\n // Base case: stop recursion or return average color\n if (depth === MAX_DEPTH || rgbValues.length === 0) {\n const color = rgbValues.reduce(\n (prev, curr) => {\n prev.r += curr.r;\n prev.g += curr.g;\n prev.b += curr.b;\n return prev;\n },\n { r: 0, g: 0, b: 0 },\n );\n // Calculate average color\n color.r = Math.round(color.r / rgbValues.length);\n color.g = Math.round(color.g / rgbValues.length);\n color.b = Math.round(color.b / rgbValues.length);\n return [color];\n }\n\n // Find channel with biggest range and sort by it\n const componentToSortBy = findBiggestColorRange(rgbValues);\n rgbValues.sort((p1, p2) => p1[componentToSortBy] - p2[componentToSortBy]);\n\n // Split at median and recurse\n const mid = Math.floor(rgbValues.length / 2);\n return [\n ...quantization(rgbValues.slice(0, mid), depth + 1),\n ...quantization(rgbValues.slice(mid), depth + 1),\n ];\n };\n\n /**\n * Sorts colors by luminance (brightness) in descending order\n * @param rgbValues - Array of RGB colors\n * @returns Sorted array (brightest first)\n */\n const orderByLuminance = (\n rgbValues: { r: number; g: number; b: number }[],\n ) => {\n const calculateLuminance = (p: { r: number; g: number; b: number }) =>\n 0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b; // Standard luminance formula\n return rgbValues.sort(\n (p1, p2) => calculateLuminance(p2) - calculateLuminance(p1),\n );\n };\n // --- End helpers ---\n\n // Initialize result variables\n let palette: string[] = [];\n let dominantColor: string | undefined = undefined;\n let secondaryColor: string | undefined = undefined;\n let accentColor: string | undefined = undefined;\n let accessibility: AccessibilityInfo | undefined = undefined;\n\n try {\n log(\n `Starting palette extraction. Options: deltaEThreshold=${deltaEThreshold}, paletteSize=${paletteSize}`,\n );\n\n // Step 1: Build a list of RGB values from the image data, sampling for performance\n let rgbArray = buildRgb(imageData, 5);\n log(`Built RGB array of ${rgbArray.length} sample pixels.`);\n\n // Step 2: Reduce the number of colors to a manageable palette using Median Cut quantization\n let quantColors = quantization(rgbArray, 0);\n log(`Quantized colors down to ${quantColors.length} primary colors.`);\n\n // Step 3: Sort the palette by luminance (brightness) to handle filtering predictably\n let orderedByColor = orderByLuminance(quantColors);\n log('Sorted quantized colors by luminance.');\n\n // Step 4: Filter out colors that are too perceptually similar using CIELAB Delta E\n const distinctColors: { r: number; g: number; b: number }[] = [];\n if (orderedByColor.length > 0) {\n distinctColors.push(orderedByColor[0]); // Always include the first (brightest) color\n const labCache = [rgbToLab(orderedByColor[0])]; // Cache LAB values for performance\n\n for (let i = 1; i < orderedByColor.length; i++) {\n if (distinctColors.length >= paletteSize) {\n log(`Reached target palette size of ${paletteSize}. Stopping.`);\n break;\n }\n const currentLab = rgbToLab(orderedByColor[i]);\n let isDistinct = true;\n\n // Check against all previously accepted distinct colors\n for (const existingLab of labCache) {\n if (deltaE(currentLab, existingLab) < deltaEThreshold) {\n isDistinct = false;\n break; // Color is too similar, skip it\n }\n }\n\n if (isDistinct) {\n distinctColors.push(orderedByColor[i]);\n labCache.push(currentLab);\n }\n }\n }\n log(\n `Filtered down to ${distinctColors.length} perceptually distinct colors.`,\n );\n\n // Step 5: Convert the final list of distinct RGB colors to hex strings\n palette = distinctColors.map(rgbToHex);\n log(`Final palette generated: [${palette.join(', ')}]`);\n\n // Step 6: Select dominant, secondary, and accent colors based on vibrancy and contrast rules\n if (palette.length > 0) {\n // Convert palette to RGB and HSL for analysis\n const paletteRgb = palette.map(hex => ({ hex, rgb: hexToRgb(hex)! }));\n const paletteHsl = paletteRgb.map(c => ({ ...c, hsl: rgbToHsl(c.rgb) }));\n\n // a) Find the most \"striking\" color as dominant (highest vibrancy score)\n const getVibrancy = (hsl: { h: number; s: number; l: number }) =>\n hsl.s * (1 - Math.abs(hsl.l - 0.5)); // Higher for balanced saturation/luminance\n paletteHsl.sort((a, b) => getVibrancy(b.hsl) - getVibrancy(a.hsl));\n const dominantInfo = paletteHsl[0];\n dominantColor = dominantInfo.hex;\n log(`Selected most vibrant color as dominantColor: ${dominantColor}`);\n\n // Create a pool of remaining colors (excluding dominant)\n const remainingColorsHsl = paletteHsl.filter(\n c => c.hex !== dominantColor,\n );\n\n // b) Classify the dominant color and apply rules for secondary color selection\n const dominantLuminance = dominantInfo.hsl.l;\n const remainingByLuminance = [...remainingColorsHsl].sort(\n (a, b) => b.hsl.l - a.hsl.l,\n ); // Brightest first\n const remainingBySaturation = [...remainingColorsHsl].sort(\n (a, b) => a.hsl.s - b.hsl.s,\n ); // Muted first\n\n // Select secondary color based on dominant color characteristics\n if (dominantLuminance > 0.65) {\n // --- Dominant is BRIGHT ---\n log(`Dominant color is BRIGHT.`);\n secondaryColor = remainingByLuminance[0]?.hex; // Next brightest\n log(`-> Secondary (brightest remaining): ${secondaryColor}`);\n } else if (dominantLuminance < 0.35) {\n // --- Dominant is DARK ---\n log(`Dominant color is DARK.`);\n secondaryColor = remainingBySaturation[0]?.hex; // Most muted\n log(`-> Secondary (most muted): ${secondaryColor}`);\n } else {\n // --- Dominant is MUTED/MID-TONE ---\n log(`Dominant color is MUTED.`);\n secondaryColor = remainingBySaturation[0]?.hex; // Most muted\n log(`-> Secondary (most muted): ${secondaryColor}`);\n }\n\n // c) Select accent color with good contrast, luminance, and median appearance\n // Filter out dominant and secondary colors to find median appearance\n const accentCandidates = paletteHsl.filter(\n c => c.hex !== dominantColor && c.hex !== secondaryColor,\n );\n\n if (accentCandidates.length > 0) {\n // Calculate vibrancy score for each candidate (saturation * luminance balance)\n const candidatesWithScores = accentCandidates.map(c => ({\n ...c,\n vibrancyScore: c.hsl.s * (1 - Math.abs(c.hsl.l - 0.5)), // Higher for balanced saturation/luminance\n contrastWithDominant: getContrastRatio(c.rgb, dominantInfo.rgb),\n }));\n\n // Sort by vibrancy score (most striking first) and ensure good contrast\n candidatesWithScores.sort((a, b) => {\n // Prioritize good contrast with dominant color (minimum 3:1 ratio)\n const aHasGoodContrast = a.contrastWithDominant >= 3.0;\n const bHasGoodContrast = b.contrastWithDominant >= 3.0;\n\n if (aHasGoodContrast && !bHasGoodContrast) return -1;\n if (!aHasGoodContrast && bHasGoodContrast) return 1;\n\n // If both have good contrast, sort by vibrancy\n return b.vibrancyScore - a.vibrancyScore;\n });\n\n // Select the most striking color with good contrast as accent\n accentColor = candidatesWithScores[0]?.hex;\n log(\n `-> Accent (most striking with good contrast): ${accentColor} (vibrancy: ${candidatesWithScores[0]?.vibrancyScore.toFixed(3)}, contrast: ${candidatesWithScores[0]?.contrastWithDominant.toFixed(2)})`,\n );\n }\n\n // d) Calculate accessibility color (best contrast option: black or white)\n const dominantRgb = hexToRgb(dominantColor);\n if (dominantRgb) {\n const black = { r: 0, g: 0, b: 0 };\n const white = { r: 255, g: 255, b: 255 };\n const contrastWithBlack = getContrastRatio(dominantRgb, black);\n const contrastWithWhite = getContrastRatio(dominantRgb, white);\n\n // Choose the color with higher contrast ratio\n if (contrastWithBlack > contrastWithWhite) {\n accessibility = {\n color: '#000000',\n contrastRatio: contrastWithBlack,\n };\n } else {\n accessibility = {\n color: '#FFFFFF',\n contrastRatio: contrastWithWhite,\n };\n }\n log(\n `Accessibility color is ${\n accessibility.color\n } with contrast ${accessibility.contrastRatio.toFixed(2)}.`,\n );\n }\n }\n } catch (e: any) {\n log(`ERROR during palette extraction: ${e.message || 'Unknown error'}`);\n // ignore errors, leave colors undefined\n }\n return {\n palette,\n dominantColor,\n secondaryColor,\n accentColor,\n accessibility,\n logs,\n };\n}\n","import { Page } from 'puppeteer-core';\nimport { openPage, OpenPageOptions } from './openPage';\nimport { extractPaletteFromImageData, ExtractedPalette } from './colorExtract';\n\nexport interface AccessibilityInfo {\n color: string;\n contrastRatio: number;\n}\nexport interface ExtractedImage {\n src: string | null;\n srcset: string | null;\n responsiveImages: { url: string; size: string }[] | null;\n alt: string | null;\n imgPermalink: string | null;\n pagePermalink: string;\n width: number | null;\n height: number | null;\n dominantColor?: string | null;\n secondaryColor?: string | null;\n accentColor?: string | null;\n accessibility?: AccessibilityInfo | null;\n palette?: string[] | null;\n colorExtractionLogs?: string[];\n}\n\nexport async function extractImages(\n page: Page,\n options?: { enableColorExtraction?: boolean },\n): Promise<ExtractedImage[]> {\n console.log('Extracting images from page...');\n const pageUrl = page.url();\n const enableColorExtraction = options?.enableColorExtraction;\n // Convert the function to a string to pass it into the browser context reliably.\n const extractPaletteFnString = extractPaletteFromImageData.toString();\n\n const images = await page.$$eval(\n 'img',\n (\n imgs: HTMLImageElement[],\n pageUrl: string,\n enableColorExtraction: boolean,\n _extractPaletteString: string,\n ) => {\n // Reconstruct the function inside the browser context from the string.\n const _extractPalette = new Function(\n `return ${_extractPaletteString}`,\n )() as (imageData: Uint8ClampedArray) => ExtractedPalette;\n\n return imgs.map((img: HTMLImageElement) => {\n let palette: string[] | undefined = undefined;\n let dominantColor: string | undefined = undefined;\n let secondaryColor: string | undefined = undefined;\n let accentColor: string | undefined = undefined;\n let accessibility: AccessibilityInfo | undefined = undefined;\n const logs: string[] = [];\n if (enableColorExtraction) {\n logs.push(`Color extraction enabled for image: ${img.src}`);\n try {\n const canvas = document.createElement('canvas');\n canvas.width = img.naturalWidth || img.width;\n canvas.height = img.naturalHeight || img.height;\n if (canvas.width && canvas.height) {\n logs.push(\n `Created canvas with dimensions: ${canvas.width}x${canvas.height}`,\n );\n const ctx = canvas.getContext('2d');\n if (ctx) {\n ctx.drawImage(img, 0, 0, canvas.width, canvas.height);\n const imageData = ctx.getImageData(\n 0,\n 0,\n canvas.width,\n canvas.height,\n ).data;\n const result = _extractPalette(imageData);\n palette = result.palette;\n dominantColor = result.dominantColor;\n secondaryColor = result.secondaryColor;\n accentColor = result.accentColor;\n accessibility = result.accessibility;\n if (result.logs) {\n logs.push(...result.logs);\n }\n } else {\n logs.push('Failed to get 2D context from canvas.');\n }\n } else {\n logs.push(\n `Skipping color extraction: Invalid image dimensions (${img.naturalWidth}x${img.naturalHeight})`,\n );\n }\n } catch (e: any) {\n logs.push(\n `ERROR during canvas operations: ${e.message || 'Unknown error'}`,\n );\n }\n }\n const parentAnchor = img.closest('a');\n const imgPermalink = parentAnchor ? parentAnchor.href : pageUrl;\n const result: any = {\n src: img.getAttribute('src'),\n srcset: img.getAttribute('srcset'),\n responsiveImages:\n img\n .getAttribute('srcset')\n ?.split(',')\n .map(src => {\n const trimmedSrc = src.trim();\n if (!trimmedSrc) {\n return null;\n }\n const parts = trimmedSrc.split(/\\s+/);\n const url = parts[0];\n const size = parts.length > 1 ? parts[1] : '';\n if (!url) {\n return null;\n }\n return { url, size };\n })\n .filter(\n (item): item is { url: string; size: string } => item !== null,\n ) || null,\n alt: img.getAttribute('alt'),\n imgPermalink: imgPermalink,\n pagePermalink: pageUrl,\n width: img.width || null,\n height: img.height || null,\n };\n if (enableColorExtraction) {\n result.dominantColor = dominantColor || null;\n result.secondaryColor = secondaryColor || null;\n result.accentColor = accentColor || null;\n result.accessibility = accessibility || null;\n result.palette = palette || null;\n result.colorExtractionLogs = logs;\n }\n return result;\n });\n },\n pageUrl,\n Boolean(enableColorExtraction),\n extractPaletteFnString,\n );\n console.log(`Extracted ${images.length} images.`);\n return images;\n}\n\nexport async function extractBackgroundImages(\n page: Page,\n): Promise<ExtractedImage[]> {\n console.log('Deep extracting background images from page...');\n const pageUrl = page.url();\n const images = await page.evaluate(pageUrl => {\n const allImages: ExtractedImage[] = [];\n const elements = document.querySelectorAll('a[href^=\"/jobs/\"]');\n console.log(\n `Found ${elements.length} elements to scan for background images.`,\n );\n\n for (const element of Array.from(elements)) {\n let backgroundImage = (element as HTMLElement).style?.backgroundImage;\n\n if (!backgroundImage || backgroundImage === 'none') {\n backgroundImage = window\n .getComputedStyle(element)\n .getPropertyValue('background-image');\n }\n\n if (backgroundImage && backgroundImage !== 'none') {\n const anchor = (element as HTMLElement).closest('a');\n const imgPermalink = anchor ? anchor.href : pageUrl;\n const width = (element as HTMLElement).clientWidth;\n const height = (element as HTMLElement).clientHeight;\n console.log(\n `Processing element ${element.tagName} with background-image: ${backgroundImage}`,\n );\n const imageSetRegex = /(-webkit-)?image-set\\(([^)]+)\\)/g;\n let imageSetMatch;\n let processedImageSet = false;\n\n while ((imageSetMatch = imageSetRegex.exec(backgroundImage)) !== null) {\n processedImageSet = true;\n const imageSetContent = imageSetMatch[2];\n if (imageSetContent) {\n const responsiveImages: { url: string; size: string }[] = [];\n const parts = imageSetContent.split(',');\n parts.forEach(part => {\n const trimmedPart = part.trim();\n const urlMatch = trimmedPart.match(/url\\((['\"]?)(.*?)\\1\\)/);\n const sizeMatch = trimmedPart.match(/\\s+([\\d.]+)x/);\n if (urlMatch && urlMatch[2]) {\n const url = urlMatch[2];\n if (url && !url.startsWith('data:')) {\n responsiveImages.push({\n url: url,\n size: sizeMatch ? sizeMatch[1] + 'x' : '1x',\n });\n }\n }\n });\n\n if (responsiveImages.length > 0) {\n allImages.push({\n src: responsiveImages[0]?.url || null,\n srcset: responsiveImages\n .map(img => `${img.url} ${img.size}`)\n .join(', '),\n responsiveImages: responsiveImages,\n alt: `Background image from ${element.tagName.toLowerCase()}`,\n imgPermalink: imgPermalink,\n pagePermalink: pageUrl,\n width,\n height,\n });\n }\n }\n }\n\n if (processedImageSet) {\n continue;\n }\n\n const urlRegex = /url\\((['\"]?)(.*?)\\1\\)/g;\n let urlMatch;\n while ((urlMatch = urlRegex.exec(backgroundImage)) !== null) {\n const url = urlMatch[2].trim();\n if (url && !url.startsWith('data:')) {\n if (\n !allImages.some(\n img =>\n img.src === url ||\n (img.responsiveImages &&\n img.responsiveImages.some(r => r.url === url)),\n )\n ) {\n allImages.push({\n src: url,\n srcset: null,\n responsiveImages: null,\n alt: `Background image from ${element.tagName.toLowerCase()}`,\n imgPermalink: imgPermalink,\n pagePermalink: pageUrl,\n width,\n height,\n });\n }\n }\n }\n }\n }\n return allImages;\n }, pageUrl);\n\n console.log(`Deep extracted ${images.length} background images.`);\n return images;\n}\n\nexport async function extractImagesFromURL(\n options: OpenPageOptions & {\n deepExtract?: boolean;\n enableColorExtraction?: boolean;\n },\n) {\n try {\n console.log(`Starting image extraction from URL: ${options.url}`);\n const { browser, page } = await openPage(options);\n console.log('Page has been opened.');\n\n if (options.deepExtract) {\n console.log('Scrolling page to trigger lazy loading...');\n await page.evaluate(async () => {\n const scrollableElement =\n document.getElementById('pageScroll') || document.body;\n await new Promise<void>(resolve => {\n let lastHeight = -1;\n let steadyCount = 0;\n let totalScrolls = 0;\n const maxSteadyCount = 3;\n const maxScrolls = 30; // limit to avoid infinite scroll\n const timer = setInterval(() => {\n totalScrolls++;\n const newHeight = scrollableElement.scrollHeight;\n if (newHeight === lastHeight) {\n steadyCount++;\n } else {\n steadyCount = 0; // reset if new content loaded\n }\n\n if (steadyCount >= maxSteadyCount || totalScrolls >= maxScrolls) {\n clearInterval(timer);\n resolve();\n return;\n }\n\n scrollableElement.scrollTo(0, newHeight);\n lastHeight = newHeight;\n }, 500);\n });\n });\n console.log(\n 'Finished scrolling. Waiting for 5 seconds for content to load...',\n );\n await new Promise(resolve => setTimeout(resolve, 5000));\n }\n let images = await extractImages(page, {\n enableColorExtraction: options.enableColorExtraction,\n });\n if (options.deepExtract) {\n const backgroundImages = await extractBackgroundImages(page);\n images = images.concat(backgroundImages);\n }\n // Log the results for debugging\n images.forEach(img => {\n if (img.colorExtractionLogs && img.colorExtractionLogs.length > 0) {\n console.log(`--- Logs for image: ${img.src} ---`);\n img.colorExtractionLogs.forEach(log => console.log(log));\n console.log('--- End of logs ---');\n }\n });\n console.log('Closing browser session.');\n await browser.close();\n console.log('Browser has been closed.');\n return images;\n } catch (error) {\n console.error('Error extracting images:', error);\n throw error;\n }\n}\n","import { Page } from 'puppeteer-core';\nimport { openPage, OpenPageOptions } from './openPage';\n\nexport interface ExtractedVideo {\n src: string | null;\n sources: { src: string; type: string | null }[] | null;\n poster: string | null;\n videoPermalink: string | null;\n pagePermalink: string;\n width: number | null;\n height: number | null;\n}\n\nexport async function extractVideos(page: Page): Promise<ExtractedVideo[]> {\n console.log('Extracting videos from page...');\n const pageUrl = page.url();\n const videos = await page.$$eval(\n 'video',\n (videos: HTMLVideoElement[], pageUrl: string) =>\n videos.map((video: HTMLVideoElement) => {\n const parentAnchor = video.closest('a');\n const videoPermalink = parentAnchor ? parentAnchor.href : pageUrl;\n\n const sourceElements = Array.from(video.querySelectorAll('source'));\n const sources =\n sourceElements.length > 0\n ? sourceElements.map(source => ({\n src: source.src,\n type: source.getAttribute('type'),\n }))\n : null;\n\n return {\n src: video.getAttribute('src'),\n sources: sources,\n poster: video.getAttribute('poster'),\n videoPermalink: videoPermalink,\n pagePermalink: pageUrl,\n width: video.videoWidth || video.width || null,\n height: video.videoHeight || video.height || null,\n };\n }),\n pageUrl,\n );\n console.log(`Extracted ${videos.length} videos.`);\n return videos;\n}\n\nexport async function extractVideosFromURL(\n options: OpenPageOptions & {\n deepExtract?: boolean;\n },\n) {\n try {\n console.log(`Starting video extraction from URL: ${options.url}`);\n const { browser, page } = await openPage(options);\n console.log('Page has been opened.');\n\n if (options.deepExtract) {\n console.log('Scrolling page to trigger lazy loading...');\n await page.evaluate(async () => {\n const scrollableElement =\n document.getElementById('pageScroll') || document.body;\n await new Promise<void>(resolve => {\n let lastHeight = -1;\n let steadyCount = 0;\n let totalScrolls = 0;\n const maxSteadyCount = 3;\n const maxScrolls = 30; // limit to avoid infinite scroll\n const timer = setInterval(() => {\n totalScrolls++;\n const newHeight = scrollableElement.scrollHeight;\n if (newHeight === lastHeight) {\n steadyCount++;\n } else {\n steadyCount = 0; // reset if new content loaded\n }\n\n if (steadyCount >= maxSteadyCount || totalScrolls >= maxScrolls) {\n clearInterval(timer);\n resolve();\n return;\n }\n\n scrollableElement.scrollTo(0, newHeight);\n lastHeight = newHeight;\n }, 500);\n });\n });\n console.log(\n 'Finished scrolling. Waiting for 5 seconds for content to load...',\n );\n await new Promise(resolve => setTimeout(resolve, 5000));\n }\n\n const videos = await extractVideos(page);\n\n console.log('Closing browser session.');\n await browser.close();\n console.log('Browser has been closed.');\n return videos;\n } catch (error) {\n console.error('Error extracting videos:', error);\n throw error;\n }\n}\n","import { extractImagesFromURL } from './functions/extractImages';\nimport { extractLinksFromUrl } from './functions/extractLinks';\nimport { extractVideosFromURL } from './functions/extractVideos';\nimport { extractWebpage } from './functions/extractWebpage';\nimport { openPage } from './functions/openPage';\nimport { takeSnapShot } from './functions/takeSnapShot';\n\n/**\n * @description PuppeteerSLS is a class that provides a set of functions for running Puppeteer on serverless functions.\n * every instance starts from a new browser instance.\n * @example\n * ```ts\n * const puppeteerSLS = new PuppeteerSLS();\n * const images = await puppeteerSLS.extractImagesFromURL('https://www.google.com');\n * ```\n */\nexport const PuppeteerSLS = {\n extractImagesFromURL,\n extractLinksFromUrl,\n extractWebpage,\n openPage,\n takeSnapShot,\n};\n"],"mappings":";AAAA,OAAO,eAAe;AACtB,OAAO,mBAAmB;;;ACD1B,OAAO,cAAc;AACrB,YAAY,QAAQ;AAGpB,IAAM,UAAU,QAAQ,IAAI,cAAc,QAAQ,IAAI;AAEtD,SAAS,kBAAkB;AAEpB,IAAM,uBAAuB,OAClC,iBAIA,UACA,aACG;AACH,MAAI;AAEJ,UAAQ,IAAI,WAAW,SAAS,QAAQ,IAAI,sBAAsB;AAClE,QAAM,eAAe;AAAA,IACnB,QAAQ;AAAA,MACN;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,IACA,OAAO;AAAA,MACL;A