
Scrape Webpage
- 30 installs
- 45 repo stars
- Updated August 4, 2026
- adobe/helix-website
scrape-webpage is a Claude Code skill that scrapes a webpage's content, metadata, and images with headless Chromium to prepare it for migration into AEM Edge Delivery Services.
About
scrape-webpage extracts content, metadata, and images from a source webpage to prepare it for migration into AEM Edge Delivery Services. It loads the page in headless Chromium, scrolls to trigger lazy images, downloads and rewrites images to local paths, and saves cleaned HTML plus a metadata.json with document paths. A developer runs it as the first step of Adobe's page-import workflow.
- Scrapes a source webpage with headless Chromium (Playwright), extracting cleaned HTML, metadata, and images
- Downloads and rewrites all images to local paths, converting WebP/AVIF/SVG to PNG via Sharp
- Emits metadata.json with document paths, Open Graph/JSON-LD metadata, and an image mapping for AEM import
Scrape Webpage by the numbers
- 30 all-time installs (skills.sh)
- Ranked #1,465 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
scrape-webpage capabilities & compatibility
- Capabilities
- web scraping · image processing · metadata extraction · content migration
- Works with
- playwright · chrome
- Use cases
- web scraping · web design
What scrape-webpage says it does
Extract content, metadata, and images from a webpage for import/migration.
Loads page in headless Chromium
Downloads all images locally (converts WebP/AVIF/SVG to PNG)
npx skills add https://github.com/adobe/helix-website --skill scrape-webpageAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 30 |
|---|---|
| repo stars | ★ 45 |
| Last updated | August 4, 2026 |
| Repository | adobe/helix-website ↗ |
What it does
Scrape a source page's content, metadata, and images to prepare it for import into AEM Edge Delivery Services.
Who is it for?
Migrating an existing web page into AEM Edge Delivery by capturing its HTML, images, and metadata
Skip if: General-purpose scraping unrelated to AEM import, or previewing the result (use preview-import)
When should I use this skill?
Starting a page import and you need to extract content and images from a source URL
What you get
metadata.json, a screenshot, cleaned HTML, and a local images folder ready for AEM import
- metadata.json with paths, metadata, and image mapping
- screenshot.png
- cleaned.html with local image references
By the numbers
- 11-step scraping workflow in the analysis script
- 8-step web-page-analysis process in resources
Files
Scrape Webpage
Extract content, metadata, and images from a webpage for import/migration.
When to Use This Skill
Use this skill when:
- Starting a page import and need to extract content from source URL
- Need webpage analysis with local image downloads
- Want metadata extraction (Open Graph, JSON-LD, etc.)
Invoked by: page-import skill (Step 1)
Prerequisites
Before using this skill, ensure:
- ✅ Node.js is available
- ✅ npm playwright is installed (
npm install playwright) - ✅ Chromium browser is installed (
npx playwright install chromium) - ✅ Sharp image library is installed (
cd .claude/skills/scrape-webpage/scripts && npm install)
Related Skills
- page-import - Orchestrator that invokes this skill
- identify-page-structure - Uses this skill's output (screenshot, HTML, metadata)
- generate-import-html - Uses image mapping and paths from this skill
Scraping Workflow
Step 1: Run Analysis Script
Command:
node .claude/skills/scrape-webpage/scripts/analyze-webpage.js "https://example.com/page" --output ./import-workWhat the script does: 1. Sets up network interception to capture all images 2. Loads page in headless Chromium 3. Scrolls through entire page to trigger lazy-loaded images 4. Downloads all images locally (converts WebP/AVIF/SVG to PNG) 5. Captures full-page screenshot for visual reference 6. Extracts metadata (title, description, Open Graph, JSON-LD, canonical) 7. Fixes images in DOM (background-image→img, picture elements, srcset→src, relative→absolute, inline SVG→img) 8. Extracts cleaned HTML (removes scripts/styles) 9. Replaces image URLs in HTML with local paths (./images/...) 10. Generates document paths (sanitized, lowercase, no .html extension) 11. Saves complete analysis with image mapping to metadata.json
For detailed explanation: See resources/web-page-analysis.md
---
Step 2: Verify Output
Output files:
./import-work/metadata.json- Complete analysis with paths and image mapping./import-work/screenshot.png- Visual reference for layout comparison./import-work/cleaned.html- Main content HTML with local image paths./import-work/images/- All downloaded images (WebP/AVIF/SVG converted to PNG)
Verify files exist:
ls -lh ./import-work/metadata.json ./import-work/screenshot.png ./import-work/cleaned.html
ls -lh ./import-work/images/ | head -5---
Step 3: Review Metadata JSON
Output JSON structure:
{
"url": "https://example.com/page",
"timestamp": "2025-01-12T10:30:00.000Z",
"paths": {
"documentPath": "/us/en/about",
"htmlFilePath": "us/en/about.plain.html",
"mdFilePath": "us/en/about.md",
"dirPath": "us/en",
"filename": "about"
},
"screenshot": "./import-work/screenshot.png",
"html": {
"filePath": "./import-work/cleaned.html",
"size": 45230
},
"metadata": {
"title": "Page Title",
"description": "Page description",
"og:image": "https://example.com/image.jpg",
"canonical": "https://example.com/page"
},
"images": {
"count": 15,
"mapping": {
"https://example.com/hero.jpg": "./images/a1b2c3d4e5f6.jpg",
"https://example.com/logo.webp": "./images/f6e5d4c3b2a1.png"
},
"stats": {
"total": 15,
"converted": 3,
"skipped": 12,
"failed": 0
}
}
}Key fields:
paths.documentPath- Used for browser preview URLpaths.htmlFilePath- Where to save final HTML fileimages.mapping- Original URLs → local pathsmetadata- Extracted page metadata
---
Output
This skill provides:
- ✅ metadata.json with paths, metadata, image mapping
- ✅ screenshot.png for visual reference
- ✅ cleaned.html with local image references
- ✅ images/ folder with all downloaded images
Next step: Pass these outputs to identify-page-structure skill
---
Troubleshooting
Browser not installed:
npx playwright install chromiumSharp not installed:
cd .claude/skills/scrape-webpage/scripts && npm installImage download failures:
- Check images.stats.failed count in metadata.json
- Some images may require authentication or be blocked by CORS
- Failed images will be noted but won't stop the scraping process
Lazy-loaded images not captured:
- Script scrolls through page to trigger lazy loading
- Some advanced lazy-loading may need customization in scripts/analyze-webpage.js
Web Page Analysis
Automated webpage analysis and preparation for content migration using npm playwright.
Purpose
The web page analysis workflow prepares a source webpage for content migration by:
- Loading the page and triggering lazy-loaded content
- Capturing visual references (screenshots)
- Extracting cleaned HTML with preserved attributes
- Extracting metadata for SEO preservation
- Enhancing section boundary visibility for analysis
Prerequisites
- Node.js installed
- npm playwright package:
npm install playwright - Browser binaries:
npx playwright install chromium
Usage
Command Line
node .claude/skills/scrape-webpage/scripts/analyze-webpage.js "https://example.com/page" --output ./analysisParameters
- URL (required): The webpage URL to analyze
- --output (optional): Output directory for artifacts. Defaults to
./page-analysis
Output
The script produces both structured data (JSON) and file artifacts:
JSON Output (stdout + saved to metadata.json):
{
"url": "https://example.com/page",
"timestamp": "2025-01-12T10:30:00.000Z",
"paths": {
"documentPath": "/us/en/about",
"htmlFilePath": "us/en/about.html",
"mdFilePath": "us/en/about.md",
"dirPath": "us/en",
"filename": "about"
},
"screenshots": {
"original": "./analysis/original.png",
"enhancedContrast": "./analysis/enhanced-contrast.png"
},
"html": {
"filePath": "./analysis/cleaned.html",
"size": 45230
},
"metadata": {
"title": "Page Title",
"description": "Page description",
"og:image": "https://example.com/image.jpg",
"canonical": "https://example.com/page",
...
}
}File Artifacts:
metadata.json- Complete analysis results including paths (saved automatically)original.png- Screenshot of the page as renderedenhanced-contrast.png- Screenshot with exaggerated section background colorscleaned.html- Extracted main content HTML with preserved src/href attributes
Analysis Workflow
1. Image Capture Setup
Network interception:
- Sets up Playwright response listener BEFORE page navigation
- Intercepts all image requests during page load
- Downloads images to local
./images/directory - Generates MD5 hash filenames for deduplication
- Converts WebP, AVIF, SVG formats to PNG using Sharp
- Keeps JPEG and PNG in original format
- Creates mapping: original URL → local path
2. Page Loading and Lazy Content
Navigate to URL:
- Opens page in headless browser
- Waits for network idle
- Image capture active during load
Scroll to trigger lazy loading:
- Scrolls through entire page incrementally
- Waits for images to load at each step
- Ensures all lazy-loaded content populates
- Waits for all pending image downloads to complete
3. Visual Reference Capture
Original screenshot:
- Full-page screenshot for visual reference
- Used to compare final migrated result
Enhanced contrast screenshot:
- Algorithmically enhances background color differences
- Makes subtle section boundaries (white vs light grey) visually obvious
- Helps identify section breaks for content structure analysis
How contrast enhancement works: 1. Collects all background colors from the page 2. Groups similar colors (within 20 RGB units distance) 3. Spreads each group apart by 60 units in RGB space 4. Makes subtle differences dramatically visible
4. Image Fixing in DOM
Before extracting HTML, fix common image issues to ensure nothing is missed:
Background images → Real images:
- Finds elements with
background-image: url(...) - Creates
<img>elements and prepends to parent - Removes background-image style
- Ensures background images get captured
Picture elements:
- Ensures every
<picture>has an<img>withsrc - Extracts from
<source srcset>(largest viewport) - Fixes missing img elements
Srcset fallback:
- Images with only
srcset(nosrc) get first srcset URL copied to src - Ensures all images have src attribute
Relative URL conversion:
- Converts relative URLs to absolute URLs
- Handles
./path,/path,../pathformats - Ensures images can be downloaded
Inline SVG conversion:
- Converts
<svg>elements to<img>with data URLs - Base64 encodes SVG content
- Ensures SVGs get captured
5. HTML Extraction and Image URL Replacement
Extracts cleaned HTML with:
- Non-content elements removed (scripts, styles, nav, footer, ads, iframes)
- Essential attributes preserved:
src,href,alt,title,class,id - All other attributes stripped
- Formatted with proper indentation (for Claude readability)
- Image URLs replaced with local paths (using mapping from step 1)
Why not full HTML:
- Focuses on main content only
- Reduces noise from navigation, marketing pixels, etc.
- Local images ready for migration (no external dependencies)
6. Metadata Extraction
Extracts SEO and social metadata:
<title>tag- Meta tags (name and property attributes)
- Canonical link
- JSON-LD structured data
- Open Graph properties
- Twitter Card properties
7. Document Path Generation
Generates document paths from the source URL:
Algorithm: 1. Extracts pathname from URL 2. Appends 'index' if path ends with '/' 3. Removes .html extension 4. Converts to lowercase 5. Sanitizes special characters and diacritics (converts to hyphens) 6. Returns clean path
Example transformations:
https://example.com/us/en/About.html→/us/en/abouthttps://example.com/Products/→/products/indexhttps://example.com/café/été→/cafe/ete
Generates:
documentPath- For preview URL (/us/en/about)htmlFilePath- Where to save HTML file (us/en/about.html)mdFilePath- Alternative markdown path (us/en/about.md)dirPath- Parent directory (us/en)filename- Just the filename (about)
8. Save Complete Results
All analysis results are automatically saved to metadata.json in the output directory, providing a complete record of:
- Source URL and timestamp
- Generated paths for file saving
- Screenshot locations
- Extracted HTML and metadata
- Image mapping (original URLs → local paths)
- Image statistics (total, converted, failed)
- Contrast enhancement statistics
Image Capture Details
Supported Formats
Keep original (no conversion):
- JPEG (.jpg)
- PNG (.png)
Convert to PNG:
- WebP → PNG (better compatibility)
- AVIF → PNG (better compatibility)
- SVG → PNG (rasterized for content images)
- GIF → PNG
- BMP → PNG
- TIFF → PNG
- HEIC/HEIF → PNG
File Naming
Images are saved with MD5 hash of their original URL:
- Avoids filename conflicts
- Deduplicates identical URLs
- Consistent naming across migrations
Example:
https://example.com/images/hero-banner.webp→./images/a1b2c3d4e5f6.pnghttps://cdn.example.com/logo.jpg→./images/f6e5d4c3b2a1.jpg
Limits and Safety
- Max image size: 10MB per image (larger images skipped)
- Max images: 100 images per page (prevents runaway captures)
- Timeout: 5 seconds waiting for pending downloads
What Happens to HTML
After image capture, all src attributes in the HTML are automatically replaced:
Before:
<img src="https://example.com/hero.jpg" alt="Hero">After:
<img src="./images/a1b2c3d4e5f6.jpg" alt="Hero">This means the migrated HTML file already references local images - no manual URL replacement needed!
When to Use
Automatic (within page-import skill)
The analyze-webpage.js script is invoked automatically as Step 1 of page-import. You don't need to run it manually.
Standalone Use Cases
Run the script manually when:
- Analyzing competitors - Extract content structure and metadata without full migration
- Quick inspection - Need screenshots and HTML of a page for reference
- Debugging - Test the extraction logic on specific pages
- Batch analysis - Script multiple pages to compare patterns
Troubleshooting
Browser not installed
Error: browserType.launch: Executable doesn't exist
Solution:
npx playwright install chromiumLazy loading not working
Some pages use advanced lazy loading that doesn't trigger on scroll. The script includes reasonable delays, but complex cases may need manual intervention.
Enhanced contrast looks wrong
The contrast enhancement algorithm works on most pages but may produce unusual results with:
- Gradient backgrounds
- Background images
- Complex layered designs
Use original.png for visual reference in these cases.
Page requires interaction
Some pages hide content behind:
- Cookie consent popups
- Age verification
- Login requirements
The script attempts basic popup dismissal, but complex cases may need customization.
Integration with Page Import
page-import Step 1: Scrape Webpage
└─ Runs: node analyze-webpage.js [URL] --output ./work
├─ Returns: JSON with all extracted data
├─ Saves: Screenshots for visual analysis
├─ Saves: Cleaned HTML for content mapping
└─ Saves: Metadata for SEO preservation
page-import Step 2-5: Use the extracted data
├─ Enhanced screenshot → Identify section boundaries
├─ Original screenshot → Visual reference
├─ Cleaned HTML → Map to blocks
└─ Metadata → Generate metadata blockOutput Quality Checks
Before proceeding with migration:
- ✅ Original screenshot shows complete page (all content loaded)
- ✅ Enhanced screenshot clearly shows section boundaries
- ✅ Cleaned HTML contains all main content
- ✅ Image
srcattributes are absolute URLs (or correct relative paths) - ✅ Link
hrefattributes are preserved - ✅ Metadata includes at least title and description
Script Architecture
The analyze-webpage.js script is organized as:
// Main orchestration
async function analyzeWebpage(url, outputDir) { }
// Helper functions (inline, no external dependencies)
async function scrollToTriggerLazyLoad(page) { }
async function enhanceContrast(page) { }
async function extractCleanedHTML(page) { }
async function extractMetadata(page) { }
// CLI entry point
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}All browser manipulation logic is self-contained - no external script files needed.
Next Steps
After running web page analysis: 1. Review screenshots to understand page structure 2. Examine enhanced-contrast.png to identify section boundaries 3. Use cleaned HTML to map content to blocks 4. Use metadata to generate metadata block 5. Proceed with page-import Steps 2-5
#!/usr/bin/env node
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
/**
* Analyze Webpage for Migration
*
* Uses npm playwright to analyze a webpage and prepare it for content migration.
*
* Features:
* - Scrolls to trigger lazy-loaded content
* - Captures images and converts to web-friendly formats
* - Takes full-page screenshot
* - Extracts cleaned HTML with preserved attributes
* - Extracts metadata (SEO, Open Graph, etc.)
*
* Usage:
* node analyze-webpage.js "https://example.com/page" --output ./analysis
*
* Requirements:
* npm install playwright
* npx playwright install chromium
*/
import { chromium } from 'playwright';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { generateDocumentPathInfo } from './generate-path.js';
import { setupImageCapture, waitForPendingImages, replaceImageUrls } from './image-capture.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* Scroll through the entire page to trigger lazy-loaded images
*/
async function scrollToTriggerLazyLoad(page) {
await page.evaluate(async () => {
await new Promise((resolve) => {
let totalHeight = 0;
const distance = 100;
const timer = setInterval(() => {
const scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= scrollHeight) {
clearInterval(timer);
resolve();
}
}, 100);
});
});
}
/**
* Fix images in the DOM to ensure none are missed during extraction
* Adapted from site-transfer-agent-importscript/resources/transformers/images.js
*/
async function fixImagesInDom(page, url) {
await page.evaluate((sourceUrl) => {
// Helper: Extract URL from background-image CSS property
function extractUrlFromBackgroundImage(backgroundImage) {
if (!backgroundImage || backgroundImage.toLowerCase() === 'none') {
return null;
}
const urlMatch = backgroundImage.match(/url\(['"]?([^'")\s]+)['"]?\)/);
return urlMatch ? urlMatch[1] : null;
}
// Helper: Get background image from inline style or data attribute
function getBackgroundImageFromElement(element) {
const inlineStyle = element.getAttribute('style');
if (inlineStyle) {
const styleParts = inlineStyle.split(';');
for (const style of styleParts) {
const [prop, ...valueParts] = style.split(':');
if (prop?.trim() === 'background-image') {
return valueParts.join(':').trim();
}
}
}
const bgImage = window.getComputedStyle(element)?.getPropertyValue('background-image');
if (bgImage && bgImage !== 'none' && bgImage.includes('url(')) {
return bgImage;
}
return null;
}
// Helper: Extract picture source URL (largest viewport)
function extractPictureSource(pictureElement) {
const sources = pictureElement.querySelectorAll('source');
if (sources.length === 0) return null;
let largestSource = null;
let largestMaxWidth = -1;
for (const source of sources) {
const mediaQuery = source.getAttribute('media');
if (!mediaQuery) {
largestSource = source;
break;
}
const match = mediaQuery.match(/max-width:\s*(\d+)px/);
if (match) {
const maxWidth = parseInt(match[1], 10);
if (maxWidth > largestMaxWidth) {
largestMaxWidth = maxWidth;
largestSource = source;
}
}
}
if (!largestSource) {
largestSource = sources[sources.length - 1];
}
if (largestSource) {
const srcset = largestSource.getAttribute('srcset');
if (srcset) {
return srcset.split(',')[0].trim().split(/\s+/)[0];
}
}
return null;
}
// 1. Transform background images to actual img elements
// Check common elements that often have background images from CSS
const elementsToCheck = document.body.querySelectorAll('div, section, article, header, footer, aside, main, figure');
elementsToCheck.forEach((element) => {
const backgroundImage = getBackgroundImageFromElement(element);
const src = extractUrlFromBackgroundImage(backgroundImage);
if (src) {
const img = document.createElement('img');
img.src = src;
element.prepend(img);
element.style.backgroundImage = 'none';
}
});
// 2. Ensure picture elements have img with src
const pictures = document.body.querySelectorAll('picture');
pictures.forEach((picture) => {
const img = picture.querySelector('img');
if (!img || !img.src) {
const newImg = document.createElement('img');
const src = extractPictureSource(picture);
if (src) {
newImg.src = src;
if (img) {
img.replaceWith(newImg);
} else {
picture.appendChild(newImg);
}
}
}
});
// 3. Fix images with srcset but no src
document.body.querySelectorAll('img').forEach((img) => {
let src = img.getAttribute('src');
const srcset = img.getAttribute('srcset')?.split(' ')[0];
if (!src && srcset) {
img.setAttribute('src', srcset);
}
src = img.getAttribute('src');
// 4. Convert relative URLs to absolute
if (src) {
try {
new URL(src);
// Already absolute, leave it
} catch (e) {
// Relative URL - convert to absolute
if (!src.startsWith('/')) {
src = `./${src}`;
}
try {
const absoluteUrl = new URL(src, sourceUrl);
img.src = absoluteUrl.toString();
} catch (err) {
console.warn(`Unable to adjust image URL ${src}`);
}
}
}
});
// 5. Transform inline SVG elements to img with data URLs
const svgs = document.body.querySelectorAll('svg');
svgs.forEach((svg) => {
let svgString = '<svg';
for (const attr of svg.attributes) {
svgString += ` ${attr.name}="${attr.value}"`;
}
svgString += '>';
svgString += svg.innerHTML;
svgString += '</svg>';
const svgDataUrl = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgString)))}`;
const img = document.createElement('img');
img.src = svgDataUrl;
svg.replaceWith(img);
});
}, url);
}
/**
* Format HTML with proper indentation for readability
*/
function formatHtml(html) {
let formatted = '';
let indent = 0;
const indentSize = 2;
// Add newlines after tags
html = html.replace(/></g, '>\n<');
const lines = html.split('\n');
lines.forEach(line => {
const trimmed = line.trim();
if (!trimmed) return;
// Decrease indent for closing tags
if (trimmed.startsWith('</')) {
indent = Math.max(0, indent - 1);
}
// Add indentation
formatted += ' '.repeat(indent * indentSize) + trimmed + '\n';
// Increase indent for opening tags (but not self-closing or immediately closed)
if (trimmed.startsWith('<') && !trimmed.startsWith('</') && !trimmed.endsWith('/>') && !trimmed.match(/<[^>]+>.*<\/[^>]+>$/)) {
indent++;
}
});
return formatted.trim();
}
/**
* Extract cleaned HTML with preserved attributes
*/
async function extractCleanedHTML(page) {
const html = await page.evaluate(() => {
// 1. Remove non-content elements
const selectorsToRemove = [
'script', 'style', 'noscript'
];
selectorsToRemove.forEach(selector => {
document.querySelectorAll(selector).forEach(el => el.remove());
});
// 2. CRITICAL: Preserve essential attributes, strip all others
const keepAttributes = ['src', 'href', 'alt', 'title', 'class', 'id'];
document.body.querySelectorAll('*').forEach(el => {
const attrs = Array.from(el.attributes);
attrs.forEach(attr => {
if (!keepAttributes.includes(attr.name)) {
el.removeAttribute(attr.name);
}
});
});
// 3. Return full body HTML (will be formatted on Node.js side)
return document.body.outerHTML;
});
// Format HTML for readability
return formatHtml(html);
}
/**
* Extract metadata from page
*/
async function extractMetadata(page) {
const metadata = await page.evaluate(() => {
const meta = {};
// Extract title
const titleTag = document.querySelector('title');
if (titleTag) meta.title = titleTag.textContent.trim();
// Extract all meta tags
document.querySelectorAll('meta').forEach(tag => {
const name = tag.getAttribute('name') || tag.getAttribute('property');
const content = tag.getAttribute('content');
if (name && content) meta[name] = content;
});
// Extract canonical link
const canonical = document.querySelector('link[rel="canonical"]');
if (canonical) meta.canonical = canonical.getAttribute('href');
// Extract JSON-LD
const jsonLd = document.querySelector('script[type="application/ld+json"]');
if (jsonLd) {
try {
meta.jsonLd = JSON.parse(jsonLd.textContent);
} catch (e) {
meta.jsonLd = jsonLd.textContent;
}
}
return meta;
});
return metadata;
}
/**
* Main analysis function
*/
async function analyzeWebpage(url, outputDir) {
// Ensure output directory exists
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
console.error(`Analyzing: ${url}`);
console.error(`Output directory: ${outputDir}`);
// Launch browser
const browser = await chromium.launch();
const page = await browser.newPage();
try {
// Set up image capture BEFORE navigation
console.error('Setting up image capture...');
const captureState = setupImageCapture(page, outputDir);
// Navigate to page
console.error('Navigating to page...');
try {
// Try networkidle first (most reliable when it works)
await page.goto(url);
} catch (error) {
// Fall back to domcontentloaded if networkidle times out
console.error('⚠️ networkidle timeout, falling back to domcontentloaded...');
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(3000); // Give page extra time to settle
}
// Scroll to trigger lazy loading
console.error('Scrolling to trigger lazy-loaded content...');
await scrollToTriggerLazyLoad(page);
await page.waitForTimeout(1000); // Give lazy-loaded images time to populate
// Wait for all pending images to complete
console.error(`Waiting for ${captureState.pendingImages.size} pending images...`);
await waitForPendingImages(captureState, 5000);
console.error(`✅ Image capture complete: ${captureState.stats.total} total, ${captureState.stats.converted} converted, ${captureState.stats.failed} failed`);
// Take screenshot
console.error('Capturing screenshot...');
const screenshot = path.join(outputDir, 'screenshot.png');
await page.screenshot({ path: screenshot, fullPage: true });
// Extract metadata
console.error('Extracting metadata...');
const metadata = await extractMetadata(page);
// Disable image capture (images already captured)
captureState.disable();
// Fix images in DOM (background images, picture elements, relative URLs, inline SVGs)
console.error('Fixing images in DOM...');
await fixImagesInDom(page, url);
// Extract cleaned HTML
console.error('Extracting cleaned HTML...');
let html = await extractCleanedHTML(page);
// Replace image URLs with local paths
console.error('Replacing image URLs with local paths...');
html = replaceImageUrls(html, captureState.imageMap);
const htmlPath = path.join(outputDir, 'cleaned.html');
fs.writeFileSync(htmlPath, html, 'utf-8');
// Generate document paths
console.error('Generating document paths...');
const paths = generateDocumentPathInfo(url);
// Build result object
const result = {
url,
timestamp: new Date().toISOString(),
paths: {
documentPath: paths.documentPath,
htmlFilePath: paths.htmlFilePath,
mdFilePath: paths.mdFilePath,
dirPath: paths.dirPath,
filename: paths.filename
},
screenshot,
html: {
filePath: htmlPath,
size: html.length
},
metadata,
images: {
count: captureState.imageMap.size,
mapping: Object.fromEntries(captureState.imageMap),
stats: captureState.stats
}
};
// Save metadata.json file
const metadataPath = path.join(outputDir, 'metadata.json');
fs.writeFileSync(metadataPath, JSON.stringify(result, null, 2), 'utf-8');
console.error(`Saved metadata to: ${metadataPath}`);
console.error('Analysis complete!');
return result;
} finally {
await browser.close();
}
}
/**
* CLI entry point
*/
async function main() {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
console.error(`
Usage: node analyze-webpage.js <url> [--output <dir>]
Analyze a webpage and prepare it for content migration.
Arguments:
<url> URL of the webpage to analyze (required)
--output <dir> Output directory for artifacts (default: ./page-analysis)
Examples:
node analyze-webpage.js "https://example.com/page"
node analyze-webpage.js "https://example.com/page" --output ./my-analysis
Output:
- screenshot.png Screenshot of the page
- cleaned.html Extracted HTML with preserved attributes
- metadata.json Complete analysis results
- images/ Downloaded images
Requirements:
npm install playwright
npx playwright install chromium
`);
process.exit(args.length === 0 ? 1 : 0);
}
const url = args[0];
let outputDir = './page-analysis';
// Parse --output flag
const outputIndex = args.indexOf('--output');
if (outputIndex !== -1 && args[outputIndex + 1]) {
outputDir = args[outputIndex + 1];
}
try {
const result = await analyzeWebpage(url, outputDir);
// Output JSON to stdout (stderr used for progress messages above)
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(`Error analyzing webpage: ${error.message}`);
console.error(error.stack);
process.exit(1);
}
}
// Run if executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
export { analyzeWebpage, scrollToTriggerLazyLoad, extractCleanedHTML, extractMetadata };
#!/usr/bin/env node
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
/**
* Path generation script for page migration
* Generates document paths from source URLs
*
* This uses the exact same algorithm as the EXCAT MCP tool's generate_document_path
* to ensure consistent path generation across all migration workflows.
*
* Usage:
* node generate-path.js "https://example.com/us/en/about.html"
*
* Output (JSON):
* {
* "success": true,
* "url": "https://example.com/us/en/about.html",
* "documentPath": "/us/en/about",
* "mdFilePath": "us/en/about.md",
* "htmlFilePath": "us/en/about.plain.html",
* "dirPath": "us/en",
* "filename": "about",
* "directoryCreated": true,
* "timestamp": "2025-11-02T10:30:45.123Z"
* }
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* Sanitize a filename according to standard naming conventions
* @param {string} name - The filename to sanitize
* @returns {string} - Sanitized filename
*/
function sanitizeFilename(name) {
if (!name) return '';
return decodeURIComponent(name)
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
/**
* Sanitize a path according to standard naming conventions
* @param {string} pathStr - The path to sanitize
* @returns {string} - Sanitized path
*/
function sanitizePath(pathStr) {
if (!pathStr) return '';
const extension = pathStr.split('.').pop();
const pathname = extension !== pathStr ? pathStr.substring(0, pathStr.lastIndexOf('.')) : pathStr;
let sanitizedPath = '';
pathname.split('/').forEach((p) => {
if (p !== '') {
sanitizedPath += `/${sanitizeFilename(p)}`;
}
});
if (extension !== pathStr) {
sanitizedPath += `.${extension}`;
}
return sanitizedPath;
}
/**
* Generate document path from URL according to standard conventions
* @param {string} url - Source URL
* @returns {string} - Document path (without extension)
*/
function generateDocumentPath({ url }) {
let p = new URL(url).pathname;
if (p.endsWith('/')) {
p = `${p}index`;
}
p = decodeURIComponent(p)
.toLowerCase()
.replace(/\.html$/, '')
.replace(/[^a-z0-9/]/gm, '-');
return sanitizePath(p);
}
/**
* Generate document path with full file information
* @param {string} url - Source URL to process
* @returns {Object} - Complete path information
*/
function generateDocumentPathInfo(url) {
try {
// Generate the document path (no extension)
const documentPath = generateDocumentPath({ url });
// Build file paths
const mdFilePath = `${documentPath}.md`;
const htmlFilePath = `${documentPath}.plain.html`;
// Extract directory path (parent directory)
const dirPath = `${documentPath.substring(0, documentPath.lastIndexOf('/'))}`;
// Extract just the filename (without extension)
const filename = documentPath.substring(documentPath.lastIndexOf('/') + 1);
// Automatically create directory structure if needed
let directoryCreated = false;
if (dirPath && dirPath !== 'content') {
// Resolve to absolute path relative to workspace
const workspaceRoot = process.env.WORKSPACE_ROOT || process.cwd();
const absoluteDirPath = path.join(workspaceRoot, dirPath);
try {
fs.mkdirSync(absoluteDirPath, { recursive: true });
directoryCreated = true;
} catch (error) {
// Don't fail - directory might already exist
directoryCreated = false;
}
}
return {
success: true,
url,
documentPath,
mdFilePath,
htmlFilePath,
dirPath,
filename,
directoryCreated,
timestamp: new Date().toISOString(),
};
} catch (error) {
throw new Error(`Failed to generate document path: ${error.message}`);
}
}
// CLI wrapper
async function main() {
try {
// Get URL from command line arguments
const urlArg = process.argv[2];
if (!urlArg) {
console.error(JSON.stringify({
success: false,
error: 'Missing URL argument. Usage: node generate-path.js "https://example.com/page"',
timestamp: new Date().toISOString(),
}, null, 2));
process.exit(1);
}
// Validate URL
try {
new URL(urlArg);
} catch (e) {
console.error(JSON.stringify({
success: false,
error: `Invalid URL: ${urlArg}`,
timestamp: new Date().toISOString(),
}, null, 2));
process.exit(1);
}
// Generate path info
const result = generateDocumentPathInfo(urlArg);
// Output as JSON
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
success: false,
error: error.message,
url: process.argv[2],
timestamp: new Date().toISOString(),
}, null, 2));
process.exit(1);
}
}
// Run if called directly
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
// Export functions for use by other scripts
export { sanitizeFilename, sanitizePath, generateDocumentPath, generateDocumentPathInfo };
#!/usr/bin/env node
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
/**
* Image Capture Utilities for Page Migration
*
* Adapted from excat-ops MCP network-image-capture.js
* Intercepts images during page load and saves them locally
*/
import { mkdir, writeFile } from 'fs/promises';
import { join } from 'path';
import { createHash } from 'crypto';
import sharp from 'sharp';
// Image format mapping (Content-Type → format string)
const IMAGE_FORMAT_MAP = {
'image/webp': 'webp',
'image/avif': 'avif',
'image/svg+xml': 'svg',
'image/jpeg': 'jpeg',
'image/jpg': 'jpeg',
'image/png': 'png',
'image/gif': 'gif',
'image/bmp': 'bmp',
'image/tiff': 'tiff',
'image/heic': 'heic',
'image/heif': 'heif',
};
// Supported formats (no conversion needed)
const SUPPORTED_FORMATS = ['jpeg', 'png'];
/**
* Convert image buffer to PNG using Sharp
*/
async function convertToPng(buffer, originalFormat) {
try {
return await sharp(buffer)
.png({
compressionLevel: 9,
palette: true,
})
.toBuffer();
} catch (error) {
throw new Error(`Failed to convert ${originalFormat} to PNG: ${error.message}`);
}
}
/**
* Check if format needs conversion
*/
function needsConversion(format) {
return format !== 'unknown' && !SUPPORTED_FORMATS.includes(format);
}
/**
* Set up network event listeners to capture images during page load
* Must be called BEFORE page.goto()
*
* Uses proper request lifecycle:
* 1. 'request' event - Track image requests as they are issued
* 2. 'requestfinished' event - Process once body is fully downloaded
*
* @param {import('playwright').Page} page - Playwright page object
* @param {string} outputDir - Directory to save images
* @param {Object} options - Optional configuration
* @param {number} options.maxImageSize - Max image size in bytes (default: 10MB)
* @param {number} options.maxImages - Max number of images to capture (default: 100)
* @returns {Promise<Object>} Capture state object
*/
export function setupImageCapture(page, outputDir, options = {}) {
const {
maxImageSize = 10 * 1024 * 1024, // 10MB
maxImages = 1000,
} = options;
const imageMap = new Map();
const pendingImages = new Set();
const trackedRequests = new Set(); // Track image requests
const stats = {
total: 0,
converted: 0,
skipped: 0,
failed: 0,
tooLarge: 0,
limitReached: 0
};
// Handler for request event - track image requests as they start
const requestHandler = (request) => {
// Check if this is an image request
if (request.resourceType() !== 'image') {
return;
}
const url = request.url();
// Check if already tracked (avoid duplicates)
if (trackedRequests.has(url)) {
return;
}
// Check if max images limit reached
if (stats.total >= maxImages) {
if (stats.limitReached === 0) {
console.error(`⚠️ Max images limit (${maxImages}) reached, skipping remaining images`);
}
stats.limitReached++;
return;
}
// Track this request
trackedRequests.add(url);
pendingImages.add(url);
stats.total++;
};
// Handler for requestfinished event - process images once fully downloaded
const requestFinishedHandler = async (request) => {
// Only process tracked image requests
if (request.resourceType() !== 'image') {
return;
}
const url = request.url();
// Only process if we tracked this request
if (!trackedRequests.has(url)) {
return;
}
// Check if already captured (should not happen, but safeguard)
if (imageMap.has(url)) {
pendingImages.delete(url);
return;
}
try {
// Get the response object
const response = await request.response();
if (!response) {
console.error(`⚠️ No response for image request: ${url.slice(0, 60)}...`);
stats.failed++;
pendingImages.delete(url);
return;
}
// Get binary data from response body (now fully downloaded)
const buffer = await response.body();
// Check image size
if (buffer.length > maxImageSize) {
console.error(`⚠️ Image too large (${Math.round(buffer.length / 1024 / 1024)}MB): ${url.slice(0, 60)}...`);
stats.tooLarge++;
stats.failed++;
pendingImages.delete(url);
return;
}
// Get content type and determine format
const contentType = response.headers()['content-type'] || '';
const format = IMAGE_FORMAT_MAP[contentType.split(';')[0]];
if (!format) {
console.error(`⚠️ Unknown image format (${contentType}): ${url.slice(0, 60)}...`);
stats.failed++;
pendingImages.delete(url);
return;
}
// Generate filename using MD5 hash
const hash = createHash('md5').update(url).digest('hex');
const imagesDir = join(outputDir, 'images');
await mkdir(imagesDir, { recursive: true });
// Convert if needed, save as PNG
if (needsConversion(format)) {
const pngBuffer = await convertToPng(buffer, format);
const filename = `${hash}.png`;
const filePath = join(imagesDir, filename);
await writeFile(filePath, pngBuffer);
imageMap.set(url, `./images/${filename}`);
stats.converted++;
console.error(`✓ Captured & converted (${format}→PNG): ${url.slice(0, 50)}... → ./images/${filename}`);
} else {
// Save original format (JPEG, PNG)
const ext = format === 'jpeg' ? 'jpg' : format;
const filename = `${hash}.${ext}`;
const filePath = join(imagesDir, filename);
await writeFile(filePath, buffer);
imageMap.set(url, `./images/${filename}`);
stats.skipped++;
console.error(`✓ Captured (${format}): ${url.slice(0, 50)}... → ./images/${filename}`);
}
} catch (error) {
console.error(`✗ Failed to capture ${url.slice(0, 50)}...: ${error.message}`);
stats.failed++;
} finally {
pendingImages.delete(url);
}
};
// Set up both event listeners
page.on('request', requestHandler);
page.on('requestfinished', requestFinishedHandler);
return {
imageMap,
pendingImages,
stats,
disable: () => {
page.off('request', requestHandler);
page.off('requestfinished', requestFinishedHandler);
console.error('🛑 Image capture disabled');
},
};
}
/**
* Wait for all pending images to complete processing
*
* @param {Object} captureState - State object returned from setupImageCapture
* @param {number} timeout - Max time to wait in milliseconds (default: 5000)
* @returns {Promise<void>}
*/
export async function waitForPendingImages(captureState, timeout = 5000) {
const startTime = Date.now();
const { pendingImages } = captureState;
while (pendingImages.size > 0) {
if (Date.now() - startTime > timeout) {
console.error(`⚠️ Timeout waiting for ${pendingImages.size} pending images after ${timeout}ms`);
break;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
}
/**
* Replace image URLs in HTML with local paths
*
* @param {string} html - HTML content
* @param {Map} imageMap - Map of original URL to local path
* @returns {string} - HTML with replaced image URLs
*/
export function replaceImageUrls(html, imageMap) {
let modifiedHtml = html;
let replacements = 0;
for (const [originalUrl, localPath] of imageMap.entries()) {
// Replace in src attributes
const srcRegex = new RegExp(`src="${originalUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`, 'g');
const srcMatch = modifiedHtml.match(srcRegex);
if (srcMatch) {
modifiedHtml = modifiedHtml.replace(srcRegex, `src="${localPath}"`);
replacements += srcMatch.length;
}
// Also try without quotes (less common but possible)
const srcRegex2 = new RegExp(`src=${originalUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'g');
const srcMatch2 = modifiedHtml.match(srcRegex2);
if (srcMatch2) {
modifiedHtml = modifiedHtml.replace(srcRegex2, `src="${localPath}"`);
replacements += srcMatch2.length;
}
}
console.error(`🔄 Replaced ${replacements} image URL references in HTML`);
return modifiedHtml;
}
{
"type": "module",
"dependencies": {
"sharp": "^0.34.0"
}
}