crawler-trial / index.html
aigodking's picture
Add 2 files
c0a77b6 verified
Raw
History Blame Contribute Delete
57.4 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Herholdts Frontend Web Crawler</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
.progress-bar { transition: width 0.3s ease; }
.product-card { transition: all 0.2s ease; }
.product-card:hover { transform: translateY(-5px); box-shadow: 0 10px 20px rgba(0,0,0,0.1); }
.blink { animation: blink 1s infinite; }
@keyframes blink { 0% { opacity: 1; } 50% { opacity: 0.5; } 100% { opacity: 1; } }
/* Ensure sticky header */
.sticky-header { position: sticky; top: 0; z-index: 10; }
/* Style for log messages */
.log-entry { margin-bottom: 4px; line-height: 1.4; }
.log-time { color: #a0aec0; margin-right: 8px; } /* Gray */
.log-info { color: #9f7aea; } /* Purple */
.log-success { color: #48bb78; } /* Green */
.log-warning { color: #ecc94b; } /* Yellow */
.log-error { color: #f56565; } /* Red */
.log-debug { color: #63b3ed; } /* Blue */
</style>
</head>
<body class="bg-gray-100">
<div class="container mx-auto px-4 py-8">
<!-- Important Disclaimer -->
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6" role="alert">
<strong class="font-bold">Important Limitations:</strong>
<span class="block sm:inline"> This is a **frontend-only** crawler. It relies on the public `allorigins.win` proxy due to browser security restrictions (CORS). This has drawbacks:
<ul class="list-disc list-inside mt-1">
<li>**Reliability:** The proxy can be slow, rate-limited, or blocked.</li>
<li>**JavaScript Rendering:** It cannot run JavaScript on the target site, potentially missing dynamic content.</li>
<li>**Anti-Blocking:** True anti-blocking (like IP rotation) is not possible from the frontend.</li>
<li>**Use a proper backend crawler (Python/Node.js with libraries like Scrapy, Playwright, or crawl4ai) for serious scraping.**</li>
</ul>
</span>
</div>
<div class="bg-white rounded-xl shadow-lg overflow-hidden">
<!-- Header -->
<div class="bg-indigo-600 px-6 py-4 text-white sticky-header">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">
<i class="fas fa-spider mr-2"></i> Herholdts Frontend Crawler
</h1>
<div class="flex space-x-2">
<span id="status" class="bg-indigo-800 px-3 py-1 rounded-full text-sm">Ready</span>
<span id="userAgentBadge" class="bg-teal-700 px-3 py-1 rounded-full text-sm text-xs" title="Current User Agent type sent to proxy">UA: Chrome</span>
</div>
</div>
<p class="text-indigo-200 mt-1 text-sm">Basic crawler using CORS proxy for herholdts.co.za</p>
</div>
<!-- Main Content -->
<div class="p-6">
<!-- Controls Section -->
<div class="bg-gray-50 p-4 rounded-lg mb-6 border border-gray-200">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="startUrl" class="block text-sm font-medium text-gray-700 mb-1">Start URL</label>
<div class="flex">
<input type="text" id="startUrl" value="https://herholdts.co.za/"
class="flex-1 rounded-l-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2 border text-sm" placeholder="https://herholdts.co.za/">
<button onclick="testConnection()" class="bg-blue-500 text-white px-3 rounded-r-md hover:bg-blue-600 text-sm" title="Test connection via proxy">
<i class="fas fa-plug"></i>
</button>
</div>
</div>
<div>
<label for="maxPages" class="block text-sm font-medium text-gray-700 mb-1">Max Pages</label>
<input type="number" id="maxPages" value="50" min="1" max="500"
class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2 border text-sm">
</div>
<div>
<label for="crawlSpeed" class="block text-sm font-medium text-gray-700 mb-1">Crawl Delay</label>
<select id="crawlSpeed" class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2 border text-sm">
<option value="slow">Slow (5-10s)</option>
<option value="medium" selected>Medium (2-5s)</option>
<option value="fast">Fast (1-2s)</option>
<option value="random">Random (1-10s)</option>
</select>
</div>
</div>
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Options</label>
<div class="flex flex-wrap gap-x-4 gap-y-2">
<label class="inline-flex items-center">
<input type="checkbox" id="rotateUA" checked class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-200">
<span class="ml-2 text-sm">Rotate User Agents (sent to proxy)</span>
</label>
<label class="inline-flex items-center">
<input type="checkbox" id="respectRobots" checked class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-200">
<span class="ml-2 text-sm">Attempt Respect robots.txt</span>
</label>
</div>
<p class="text-xs text-gray-500 mt-1">Note: Proxies cannot be used effectively from the frontend.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Actions</label>
<div class="flex flex-wrap gap-2">
<button onclick="startCrawling()" id="startBtn" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md flex items-center text-sm">
<i class="fas fa-play mr-2"></i> Start
</button>
<button onclick="pauseCrawling()" id="pauseBtn" disabled class="bg-yellow-500 hover:bg-yellow-600 text-white px-4 py-2 rounded-md flex items-center text-sm">
<i class="fas fa-pause mr-2"></i> Pause
</button>
<button onclick="stopCrawling()" id="stopBtn" disabled class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md flex items-center text-sm">
<i class="fas fa-stop mr-2"></i> Stop
</button>
<button onclick="exportData('csv')" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md flex items-center text-sm">
<i class="fas fa-file-csv mr-2"></i> CSV
</button>
<button onclick="exportData('excel')" class="bg-green-700 hover:bg-green-800 text-white px-4 py-2 rounded-md flex items-center text-sm">
<i class="fas fa-file-excel mr-2"></i> Excel
</button>
<button onclick="exportData('json')" class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-md flex items-center text-sm">
<i class="fas fa-file-code mr-2"></i> JSON
</button>
</div>
</div>
</div>
</div>
<!-- Stats Section -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div class="bg-white p-3 rounded-lg shadow border border-gray-200">
<div class="text-gray-500 text-xs uppercase tracking-wider">Crawled</div>
<div id="pagesCrawled" class="text-xl font-bold text-indigo-600">0</div>
</div>
<div class="bg-white p-3 rounded-lg shadow border border-gray-200">
<div class="text-gray-500 text-xs uppercase tracking-wider">Products</div>
<div id="productsFound" class="text-xl font-bold text-green-600">0</div>
</div>
<div class="bg-white p-3 rounded-lg shadow border border-gray-200">
<div class="text-gray-500 text-xs uppercase tracking-wider">Images</div>
<div id="imagesFound" class="text-xl font-bold text-blue-600">0</div>
</div>
<div class="bg-white p-3 rounded-lg shadow border border-gray-200">
<div class="text-gray-500 text-xs uppercase tracking-wider">Success Rate</div>
<div id="successRate" class="text-xl font-bold">0%</div>
</div>
<div class="bg-white p-3 rounded-lg shadow border border-gray-200 col-span-2 md:col-span-1">
<div class="text-gray-500 text-xs uppercase tracking-wider">Current UA</div>
<div id="currentUA" class="text-xs font-mono truncate pt-1" title="N/A">N/A</div>
</div>
</div>
<!-- Progress Section -->
<div class="mb-6">
<div class="flex justify-between mb-1">
<span class="text-sm font-medium text-gray-700">Crawling Progress</span>
<span id="progressText" class="text-sm font-medium text-gray-700">0/0 pages</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div id="progressBar" class="bg-indigo-600 h-2.5 rounded-full progress-bar" style="width: 0%"></div>
</div>
</div>
<!-- Logs Section -->
<div class="mb-6">
<div class="flex justify-between items-center mb-2">
<h3 class="font-medium text-gray-700">
<i class="fas fa-terminal mr-2"></i> Crawler Logs
</h3>
<div>
<button onclick="clearLogs()" class="text-sm text-gray-500 hover:text-gray-700 mr-3" title="Clear logs">
<i class="fas fa-trash-alt mr-1"></i> Clear
</button>
<button onclick="toggleDebugMode()" id="debugBtn" class="text-sm bg-gray-200 hover:bg-gray-300 text-gray-700 px-2 py-1 rounded" title="Toggle detailed debug messages">
<i class="fas fa-bug mr-1"></i> Debug: OFF
</button>
</div>
</div>
<div id="logs" class="bg-gray-800 text-sm p-3 rounded-lg h-48 overflow-y-auto font-mono">
<div class="log-entry log-info"><span class="log-time"></span>System initialized. Ready to start crawling.</div>
</div>
</div>
<!-- Results Section -->
<div>
<div class="flex flex-col md:flex-row justify-between md:items-center mb-3 gap-2">
<h3 class="font-medium text-gray-700">
<i class="fas fa-box-open mr-2"></i> Extracted Products (<span id="productCount">0</span>)
</h3>
<div class="flex flex-col sm:flex-row gap-2">
<input type="text" id="productSearch" placeholder="Search products..."
class="text-sm rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 px-3 py-1 border w-full sm:w-auto">
<select id="categoryFilter" class="text-sm rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 px-2 py-1 border w-full sm:w-auto">
<option value="">All Categories</option>
</select>
</div>
</div>
<div id="productGrid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<!-- Products will be displayed here -->
<div id="noProductsPlaceholder" class="col-span-full text-center py-10 text-gray-400">
<i class="fas fa-box-open text-4xl mb-2"></i>
<p>No products extracted yet</p>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="text-center text-gray-500 text-xs mt-6">
Crawler UI Template | Use responsibly and respect website terms of service.
</div>
</div>
<script>
// --- Configuration ---
const PROXY_URL = 'https://api.allorigins.win/get?url='; // CORS Proxy
const TARGET_HOSTNAME = 'herholdts.co.za'; // Only follow links within this host
// --- Global State ---
let isCrawling = false;
let isPaused = false;
let isDebug = false;
let crawlController = null; // AbortController instance
let crawledPages = 0;
let maxPagesToCrawl = 50;
let successfulRequests = 0;
let failedRequests = 0;
let products = [];
let categories = new Set();
let images = new Set(); // Use Set for unique image URLs
let queue = [];
let visited = new Set();
let robotsAllowed = true; // Assume allowed until checked
let delaySettings = { min: 2000, max: 5000 }; // Default medium speed
// --- User Agents ---
const userAgents = [ /* ... (keep the same list as before) ... */
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:89.0) Gecko/20100101 Firefox/89.0",
"Mozilla/5.0 (X11; Linux i686; rv:89.0) Gecko/20100101 Firefox/89.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.59",
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (Linux; Android 10; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36"
];
// --- DOM Elements ---
const getEl = (id) => document.getElementById(id);
const logsElement = getEl('logs');
const progressBar = getEl('progressBar');
const progressText = getEl('progressText');
const pagesCrawledElement = getEl('pagesCrawled');
const productsFoundElement = getEl('productsFound');
const imagesFoundElement = getEl('imagesFound');
const productCountElement = getEl('productCount');
const productGrid = getEl('productGrid');
const categoryFilter = getEl('categoryFilter');
const productSearch = getEl('productSearch');
const statusElement = getEl('status');
const startBtn = getEl('startBtn');
const pauseBtn = getEl('pauseBtn');
const stopBtn = getEl('stopBtn');
const userAgentBadge = getEl('userAgentBadge');
const currentUAElement = getEl('currentUA');
const successRateElement = getEl('successRate');
const debugBtn = getEl('debugBtn');
const rotateUACheckbox = getEl('rotateUA');
const respectRobotsCheckbox = getEl('respectRobots');
const startUrlInput = getEl('startUrl');
const maxPagesInput = getEl('maxPages');
const crawlSpeedSelect = getEl('crawlSpeed');
const noProductsPlaceholder = getEl('noProductsPlaceholder');
// --- Initialization ---
document.addEventListener('DOMContentLoaded', () => {
updateCrawlSpeed(); // Set initial delay
addLog('System initialized. Ready to start crawling.', 'info');
productSearch.addEventListener('input', filterProducts);
categoryFilter.addEventListener('change', filterProducts);
crawlSpeedSelect.addEventListener('change', updateCrawlSpeed);
});
function updateCrawlSpeed() {
const speed = crawlSpeedSelect.value;
if (speed === 'slow') delaySettings = { min: 5000, max: 10000 };
else if (speed === 'medium') delaySettings = { min: 2000, max: 5000 };
else if (speed === 'fast') delaySettings = { min: 1000, max: 2000 };
else delaySettings = { min: 1000, max: 10000 }; // random
addLog(`Crawl delay set to: ${speed} (${delaySettings.min/1000}-${delaySettings.max/1000}s)`, 'debug');
}
// --- Utility Functions ---
function getRandomUserAgent() {
return userAgents[Math.floor(Math.random() * userAgents.length)];
}
function getCurrentUserAgent() {
return rotateUACheckbox.checked ? getRandomUserAgent() : userAgents[0];
}
function setCurrentUAUI(ua) {
const uaShort = ua.substring(0, ua.indexOf('(') > 0 ? ua.indexOf('(') : 30) + '...';
currentUAElement.textContent = uaShort;
currentUAElement.title = ua;
if (ua.includes('Chrome/')) userAgentBadge.textContent = 'UA: Chrome';
else if (ua.includes('Firefox/')) userAgentBadge.textContent = 'UA: Firefox';
else if (ua.includes('Safari/')) userAgentBadge.textContent = 'UA: Safari';
else if (ua.includes('Edg/')) userAgentBadge.textContent = 'UA: Edge';
else if (ua.includes('Mobile')) userAgentBadge.textContent = 'UA: Mobile';
else userAgentBadge.textContent = 'UA: Custom';
}
function getDelay() {
return Math.floor(Math.random() * (delaySettings.max - delaySettings.min + 1)) + delaySettings.min;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function normalizeUrl(url) {
try {
const urlObj = new URL(url);
// Remove hash and trailing slash for consistency
return urlObj.origin + urlObj.pathname.replace(/\/$/, '') + urlObj.search;
} catch (e) {
return null; // Invalid URL
}
}
// --- Logging ---
function addLog(message, type = 'info') {
if (type === 'debug' && !isDebug) return;
const time = new Date().toLocaleTimeString();
const typeClass = `log-${type}`;
const logEntry = document.createElement('div');
logEntry.className = `log-entry ${typeClass}`;
logEntry.innerHTML = `<span class="log-time">${time}</span>${message}`;
logsElement.appendChild(logEntry);
logsElement.scrollTop = logsElement.scrollHeight; // Auto-scroll
}
function clearLogs() {
logsElement.innerHTML = '';
addLog('Logs cleared', 'info');
}
function toggleDebugMode() {
isDebug = !isDebug;
debugBtn.innerHTML = `<i class="fas fa-bug mr-1"></i> Debug: ${isDebug ? 'ON' : 'OFF'}`;
debugBtn.classList.toggle('bg-yellow-500', isDebug);
debugBtn.classList.toggle('text-white', isDebug);
debugBtn.classList.toggle('bg-gray-200', !isDebug);
debugBtn.classList.toggle('text-gray-700', !isDebug);
debugBtn.classList.toggle('hover:bg-yellow-600', isDebug);
debugBtn.classList.toggle('hover:bg-gray-300', !isDebug);
addLog(`Debug mode ${isDebug ? 'enabled' : 'disabled'}`, 'info');
}
// --- Fetching & Anti-Blocking (Limited Frontend Version) ---
async function fetchViaProxy(url, signal) {
const ua = getCurrentUserAgent();
setCurrentUAUI(ua); // Update UI
addLog(`Fetching: ${url}`, 'debug');
try {
const response = await fetch(`${PROXY_URL}${encodeURIComponent(url)}`, {
signal: signal, // Pass AbortSignal
headers: { // These headers are sent to the proxy
'X-Requested-With': 'XMLHttpRequest', // Might help some proxies
// We can't directly control headers sent *from* the proxy to the target
}
});
if (!response.ok) {
throw new Error(`Proxy error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
// Check the structure returned by allorigins.win
if (!data || typeof data.contents === 'undefined') {
throw new Error('Invalid response structure from proxy.');
}
if (data.contents === null && data.status && data.status.http_code && data.status.http_code >= 400) {
throw new Error(`Target server error: ${data.status.http_code} (via proxy)`);
}
if (data.contents === null) {
// Sometimes proxy returns null content without error status, treat as failure
throw new Error('Proxy returned null content.');
}
return data.contents; // Return the HTML content
} catch (error) {
if (error.name === 'AbortError') {
addLog(`Request aborted: ${url}`, 'warning');
} else {
addLog(`Fetch error for ${url}: ${error.message}`, 'error');
}
throw error; // Re-throw for crawl loop handling
}
}
// --- Robots.txt Handling (Basic) ---
async function checkRobotsTxt(startUrl) {
if (!respectRobotsCheckbox.checked) {
addLog('Skipping robots.txt check.', 'info');
robotsAllowed = true;
return true;
}
try {
const urlObj = new URL(startUrl);
const robotsUrl = `${urlObj.origin}/robots.txt`;
addLog(`Checking robots.txt at: ${robotsUrl}`, 'info');
// Use a separate AbortController for this check if needed
const robotsContent = await fetchViaProxy(robotsUrl);
if (robotsContent) {
// VERY Basic Parsing: Check if disallowed for all agents ('*')
// A proper parser would be much more complex.
const rules = robotsContent.split('\n');
let userAgentSection = false;
for (const line of rules) {
const trimmedLine = line.trim().toLowerCase();
if (trimmedLine.startsWith('user-agent: *')) {
userAgentSection = true;
} else if (userAgentSection && trimmedLine.startsWith('user-agent:')) {
userAgentSection = false; // New agent section started
} else if (userAgentSection && trimmedLine.startsWith('disallow: /')) {
addLog('robots.txt disallows crawling for all agents (`Disallow: /`). Aborting.', 'error');
robotsAllowed = false;
return false;
} else if (userAgentSection && trimmedLine.startsWith('disallow:')) {
// More specific disallow, we'll ignore for this basic check
// A real crawler needs to match paths.
} else if (trimmedLine === '') {
userAgentSection = false; // Blank line resets section
}
}
addLog('robots.txt check passed (basic check).', 'success');
robotsAllowed = true;
return true;
} else {
addLog('robots.txt not found or empty, assuming allowed.', 'warning');
robotsAllowed = true;
return true; // Assume allowed if robots.txt is missing/empty
}
} catch (error) {
addLog(`Error checking robots.txt (${error.message}). Assuming allowed.`, 'warning');
robotsAllowed = true;
return true; // Assume allowed if there's an error fetching/parsing
}
}
// --- Connection Test ---
async function testConnection() {
const url = startUrlInput.value;
if (!url) {
addLog('Please enter a Start URL.', 'error');
return;
}
addLog(`Testing connection to ${url} via proxy...`, 'info');
statusElement.textContent = 'Testing';
statusElement.className = 'bg-yellow-500 px-3 py-1 rounded-full text-sm';
try {
const content = await fetchViaProxy(url);
if (content !== null) { // Check if content was successfully retrieved
addLog('Connection successful! Proxy retrieved content.', 'success');
// Basic check for blocking patterns
if (content.toLowerCase().includes('access denied') || content.toLowerCase().includes('blocked') || content.toLowerCase().includes('captcha')) {
addLog('Warning: Possible blocking detected in response content.', 'warning');
statusElement.textContent = 'Limited?';
statusElement.className = 'bg-orange-500 px-3 py-1 rounded-full text-sm';
} else {
statusElement.textContent = 'Ready';
statusElement.className = 'bg-green-500 px-3 py-1 rounded-full text-sm';
}
} else {
// Fetch succeeded but content was null (handled as error in fetchViaProxy)
// This case might not be reached if fetchViaProxy throws
addLog('Connection via proxy failed (null content).', 'error');
statusElement.textContent = 'Failed';
statusElement.className = 'bg-red-500 px-3 py-1 rounded-full text-sm';
}
} catch (error) {
addLog(`Connection test failed: ${error.message}`, 'error');
statusElement.textContent = 'Error';
statusElement.className = 'bg-red-500 px-3 py-1 rounded-full text-sm';
}
}
// --- Crawling Logic ---
async function startCrawling() {
if (isCrawling) return;
const startUrl = startUrlInput.value;
maxPagesToCrawl = parseInt(maxPagesInput.value) || 50;
if (!startUrl) {
addLog('Please enter a valid Start URL', 'error');
return;
}
// Reset state
isCrawling = true;
isPaused = false;
crawledPages = 0;
successfulRequests = 0;
failedRequests = 0;
products = [];
categories = new Set();
images = new Set();
queue = [];
visited = new Set();
productGrid.innerHTML = ''; // Clear previous results
noProductsPlaceholder.style.display = 'block';
updateCategoryFilter(); // Clear categories
addLog(`--- Starting Crawl ---`, 'info');
addLog(`Target: ${startUrl}`, 'info');
addLog(`Max Pages: ${maxPagesToCrawl}`, 'info');
updateUIState('start');
// Check robots.txt before starting queue
const allowedByRobots = await checkRobotsTxt(startUrl);
if (!allowedByRobots) {
updateUIState('stop');
return; // Stop if robots.txt disallows
}
// Initialize queue with normalized start URL
const normalizedStartUrl = normalizeUrl(startUrl);
if(normalizedStartUrl) {
queue.push(normalizedStartUrl);
visited.add(normalizedStartUrl); // Add start URL to visited immediately
} else {
addLog('Invalid Start URL format.', 'error');
updateUIState('stop');
return;
}
// Start the crawl loop
crawlController = new AbortController(); // Create AbortController for this crawl session
crawlLoop(crawlController.signal);
}
function pauseCrawling() {
if (!isCrawling) return;
isPaused = !isPaused; // Toggle pause state
if (isPaused) {
addLog('Crawl paused.', 'warning');
updateUIState('pause');
if (crawlController) {
// We don't abort on pause, just stop processing queue
}
} else {
addLog('Resuming crawl...', 'info');
updateUIState('resume');
// Restart the loop if it wasn't already running
crawlLoop(crawlController.signal);
}
}
function stopCrawling() {
if (!isCrawling) return;
addLog('--- Stopping Crawl ---', 'warning');
isCrawling = false;
isPaused = false; // Ensure not paused
if (crawlController) {
crawlController.abort(); // Signal abortion to pending fetch requests
crawlController = null;
}
updateUIState('stop');
addLog(`Crawl stopped by user. Success rate: ${calculateSuccessRate()}%`, 'info');
}
async function crawlLoop(signal) {
if (!isCrawling || isPaused) {
addLog(`Loop check: Crawling=${isCrawling}, Paused=${isPaused}. Exiting loop.`, 'debug');
return; // Stop if paused or stopped
}
if (queue.length === 0) {
addLog('Queue is empty. Crawl finished.', 'success');
updateUIState('finish');
addLog(`Crawl completed. Success rate: ${calculateSuccessRate()}%`, 'info');
return;
}
if (crawledPages >= maxPagesToCrawl) {
addLog(`Reached maximum page limit (${maxPagesToCrawl}). Stopping crawl.`, 'success');
updateUIState('finish');
addLog(`Crawl limit reached. Success rate: ${calculateSuccessRate()}%`, 'info');
return;
}
const currentUrl = queue.shift();
crawledPages++;
updateStats();
addLog(`Crawling page ${crawledPages}/${maxPagesToCrawl}: ${currentUrl}`, 'info');
try {
if (signal.aborted) { // Check if stopped before fetching
addLog('Crawl stopped before fetching.', 'warning');
return;
}
const delay = getDelay();
addLog(`Waiting ${delay/1000}s before request...`, 'debug');
await sleep(delay);
const htmlContent = await fetchViaProxy(currentUrl, signal);
if (htmlContent !== null) { // Ensure content was received
successfulRequests++;
processPageContent(htmlContent, currentUrl);
} else {
// If fetchViaProxy returns null but doesn't throw (e.g., proxy issue handled), count as failed
failedRequests++;
}
} catch (error) {
if (error.name !== 'AbortError') { // Don't count aborts as fails
failedRequests++;
}
// Error already logged in fetchViaProxy or here if it's not AbortError
addLog(`Failed to process ${currentUrl}: ${error.message}`, 'error');
} finally {
updateStats(); // Update stats after each attempt
// Schedule the next iteration using setTimeout to avoid deep recursion
// and allow UI updates
if(isCrawling && !isPaused) {
setTimeout(() => crawlLoop(signal), 50); // Small delay before next check
}
}
}
// --- Page Processing & Data Extraction ---
function processPageContent(htmlContent, pageUrl) {
addLog(`Processing content from: ${pageUrl}`, 'debug');
if (htmlContent.toLowerCase().includes('access denied') || htmlContent.toLowerCase().includes('blocked') || htmlContent.toLowerCase().includes('captcha')) {
addLog(`Warning: Possible blocking detected on ${pageUrl}`, 'warning');
// Could implement strategies here if needed, e.g., increase delay
}
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
// 1. Extract and queue new valid links
const links = doc.querySelectorAll('a[href]');
let newLinksFound = 0;
links.forEach(link => {
const href = link.getAttribute('href');
if (href && !href.startsWith('#') && !href.startsWith('javascript:') && !href.startsWith('mailto:')) {
try {
const absoluteUrl = new URL(href, pageUrl);
// Basic filter: stay on the same hostname
if (absoluteUrl.hostname.endsWith(TARGET_HOSTNAME)) {
const normalized = normalizeUrl(absoluteUrl.href);
if (normalized && !visited.has(normalized)) {
visited.add(normalized);
queue.push(normalized);
newLinksFound++;
}
}
} catch (e) {
addLog(`Invalid link URL found: ${href}`, 'debug');
}
}
});
if(newLinksFound > 0) addLog(`Added ${newLinksFound} new unique links to the queue.`, 'debug');
// 2. Extract products (NEEDS CUSTOMIZATION)
extractProducts(doc, pageUrl);
}
function extractProducts(doc, pageUrl) {
addLog(`Attempting product extraction on: ${pageUrl}`, 'debug');
// --- !!! CRITICAL CUSTOMIZATION POINT !!! ---
// You MUST inspect herholdts.co.za's product listing and product pages
// using your browser's Developer Tools (F12) to find the correct CSS selectors.
// The selectors below are GENERIC GUESSES and WILL LIKELY FAIL.
const productContainerSelectors = [
'.products .product-item', // Example: Grid/list items
'.product-details', // Example: Single product page container
'div[data-product-id]', // Example: Attribute-based selector
'.item-product'
// Add more potential container selectors based on inspection
];
let productsExtractedOnPage = 0;
for (const containerSelector of productContainerSelectors) {
const productElements = doc.querySelectorAll(containerSelector);
if (productElements.length > 0) {
addLog(`Found ${productElements.length} potential product element(s) using selector: "${containerSelector}"`, 'debug');
productElements.forEach((el, index) => {
try {
// --- !!! MORE CUSTOMIZATION NEEDED HERE !!! ---
const name = getTextContent(el, ['.product-title', 'h1', 'h2.name', '.product-name']) || 'Unknown Product';
const price = getTextContent(el, ['.price .amount', '.product-price', '.special-price']) || 'N/A';
const sku = getAttributeValue(el, ['[data-sku]', '.sku'], 'data-sku') || getTextContent(el, ['.sku-number']) || '';
const description = getTextContent(el, ['.product-description', '.short-description', '.tab-content#description']) || '';
const category = extractCategoryFromUrlOrBreadcrumbs(pageUrl, doc); // Helper needed
const imageUrl = extractImageUrl(el, pageUrl); // Helper needed
if (imageUrl) {
images.add(imageUrl); // Add to set of unique images
}
// Basic check to avoid duplicates based on name/sku if possible
const isDuplicate = products.some(p => p.name === name && (sku && p.sku === sku));
if (!isDuplicate) {
const product = {
id: products.length + 1,
name: name.trim(),
price: price.trim(),
sku: sku.trim(),
description: description.trim().substring(0, 200) + (description.length > 200 ? '...' : ''), // Limit description length
category: category,
imageUrl: imageUrl,
pageUrl: pageUrl,
timestamp: new Date().toISOString()
};
products.push(product);
categories.add(category);
updateProductDisplay(product); // Add to UI immediately
productsExtractedOnPage++;
} else {
addLog(`Skipping likely duplicate product: ${name}`, 'debug');
}
} catch (e) {
addLog(`Error processing potential product element #${index+1} on ${pageUrl}: ${e.message}`, 'error');
addLog(`Problematic element selector: ${containerSelector}`, 'debug');
}
});
// If we found products with this selector, assume it's the right one for this page and stop checking others
break;
}
}
if (productsExtractedOnPage > 0) {
addLog(`Successfully extracted ${productsExtractedOnPage} new products from ${pageUrl}`, 'success');
updateStats();
updateCategoryFilter();
} else {
addLog(`No products extracted from ${pageUrl} with current selectors.`, 'debug');
}
}
// Helper: Get text content using multiple selectors
function getTextContent(parentElement, selectors) {
for (const selector of selectors) {
const element = parentElement.querySelector(selector);
if (element) {
// Attempt to get clean text, removing potential hidden elements or excessive whitespace
return element.textContent?.replace(/\s+/g, ' ').trim() || null;
}
}
return null;
}
// Helper: Get attribute value using multiple selectors
function getAttributeValue(parentElement, selectors, attributeName) {
for (const selector of selectors) {
const element = parentElement.querySelector(selector);
if (element && element.hasAttribute(attributeName)) {
return element.getAttribute(attributeName);
}
}
return null;
}
// Helper: Extract image URL (NEEDS CUSTOMIZATION)
function extractImageUrl(parentElement, baseUrl) {
// --- !!! CUSTOMIZATION POINT !!! ---
// Inspect how images are embedded (img src, data-src, background-image, picture element)
const selectors = [
'img.product-image', // Primary image selector guess
'.product-gallery img',
'img[data-main-image]',
'img' // Fallback to any image within the container
];
let imgUrl = null;
for(const selector of selectors) {
const imgElement = parentElement.querySelector(selector);
if (imgElement) {
imgUrl = imgElement.getAttribute('data-src') || imgElement.getAttribute('src');
if (imgUrl) break;
}
}
// Add checks for <picture> elements or background images if necessary
if (imgUrl) {
try {
// Ensure URL is absolute
return new URL(imgUrl, baseUrl).href;
} catch (e) {
addLog(`Invalid image URL found: ${imgUrl}`, 'debug');
return null;
}
}
return null; // No image found
}
// Helper: Extract category (NEEDS CUSTOMIZATION)
function extractCategoryFromUrlOrBreadcrumbs(pageUrl, doc) {
// --- !!! CUSTOMIZATION POINT !!! ---
// Strategy 1: Use breadcrumbs if available
const breadcrumbSelectors = ['.breadcrumbs .item', '.breadcrumb li a']; // Guesses
for (const selector of breadcrumbSelectors) {
const breadcrumbLinks = doc.querySelectorAll(selector);
if (breadcrumbLinks.length > 1) { // Need at least Home > Category
// Get text from all but the last link (often the product itself) or the first (Home)
const categories = Array.from(breadcrumbLinks)
.slice(1, breadcrumbLinks.length > 2 ? -1 : undefined) // Skip Home, optionally skip last item
.map(el => el.textContent?.trim())
.filter(Boolean); // Remove empty items
if (categories.length > 0) return categories.join(' > ');
}
}
// Strategy 2: Parse from URL path (less reliable)
try {
const pathParts = new URL(pageUrl).pathname.split('/').filter(part => part && part !== 'product' && !/\d/.test(part) && part !== 'default.asp'); // Basic filtering
if (pathParts.length > 0) {
return pathParts.map(p => p.replace(/-/g, ' ').replace(/_/g, ' ')).join(' > '); // Simple formatting
}
} catch {}
return 'Uncategorized'; // Fallback
}
// --- UI Update Functions ---
function updateUIState(state) {
switch (state) {
case 'start':
case 'resume':
statusElement.textContent = 'Crawling';
statusElement.className = 'bg-indigo-800 px-3 py-1 rounded-full text-sm blink';
startBtn.disabled = true;
pauseBtn.disabled = false;
stopBtn.disabled = false;
pauseBtn.innerHTML = '<i class="fas fa-pause mr-2"></i> Pause';
break;
case 'pause':
statusElement.textContent = 'Paused';
statusElement.className = 'bg-yellow-500 px-3 py-1 rounded-full text-sm';
startBtn.disabled = true;
pauseBtn.disabled = false;
stopBtn.disabled = false;
pauseBtn.innerHTML = '<i class="fas fa-play mr-2"></i> Resume';
break;
case 'stop':
case 'finish':
case 'error':
statusElement.textContent = state === 'finish' ? 'Completed' : 'Stopped';
statusElement.className = `px-3 py-1 rounded-full text-sm ${state === 'finish' ? 'bg-green-500' : 'bg-red-500'}`;
startBtn.disabled = false;
pauseBtn.disabled = true;
stopBtn.disabled = true;
pauseBtn.innerHTML = '<i class="fas fa-pause mr-2"></i> Pause';
if (statusElement.classList.contains('blink')) {
statusElement.classList.remove('blink');
}
break;
}
}
function calculateSuccessRate() {
const totalAttempts = successfulRequests + failedRequests;
return totalAttempts > 0 ? Math.round((successfulRequests / totalAttempts) * 100) : 0;
}
function updateStats() {
const rate = calculateSuccessRate();
// Use maxPagesToCrawl for progress calculation
const progressPercent = maxPagesToCrawl > 0 ? (crawledPages / maxPagesToCrawl) * 100 : 0;
progressText.textContent = `${crawledPages}/${maxPagesToCrawl} pages`;
progressBar.style.width = `${Math.min(progressPercent, 100)}%`; // Cap at 100%
pagesCrawledElement.textContent = crawledPages;
productsFoundElement.textContent = products.length;
imagesFoundElement.textContent = images.size; // Use set size
productCountElement.textContent = products.length;
successRateElement.textContent = `${rate}%`;
// Update success rate color
successRateElement.className = 'text-xl font-bold'; // Base class
if (rate >= 95) successRateElement.classList.add('text-green-600');
else if (rate >= 80) successRateElement.classList.add('text-yellow-600');
else successRateElement.classList.add('text-red-600');
}
function updateProductDisplay(product) {
if(noProductsPlaceholder.style.display !== 'none') {
noProductsPlaceholder.style.display = 'none'; // Hide placeholder
}
const productCard = document.createElement('div');
productCard.className = 'product-card bg-white rounded-lg shadow overflow-hidden border border-gray-200 flex flex-col'; // Added flex
productCard.setAttribute('data-category', product.category); // For filtering
productCard.setAttribute('data-name', product.name.toLowerCase()); // For searching
productCard.innerHTML = `
<div class="h-48 bg-gray-100 flex items-center justify-center overflow-hidden p-2">
${product.imageUrl ?
`<img src="${product.imageUrl}" alt="${product.name}" class="max-h-full w-auto object-contain lazyload" loading="lazy">` :
`<i class="fas fa-image text-gray-300 text-4xl"></i>`}
</div>
<div class="p-3 flex flex-col flex-grow">
<h3 class="font-semibold text-sm mb-1 line-clamp-2 leading-tight" title="${product.name}">${product.name}</h3>
<div class="text-indigo-600 font-bold text-base my-1">${product.price}</div>
<div class="text-xs text-gray-500 mb-1">${product.category}</div>
${product.sku ? `<div class="text-xs text-gray-400 mb-2">SKU: ${product.sku}</div>` : ''}
<p class="text-xs text-gray-600 line-clamp-2 flex-grow">${product.description}</p>
<div class="mt-2 text-xs text-gray-400 truncate pt-1 border-t border-gray-100" title="${product.pageUrl}">
<a href="${product.pageUrl}" target="_blank" rel="noopener noreferrer" class="hover:text-indigo-500">${product.pageUrl.replace(/^https?:\/\//, '')}</a>
</div>
</div>
`;
productGrid.prepend(productCard); // Add new products to the top
}
function updateCategoryFilter() {
const currentSelection = categoryFilter.value;
// Clear existing options except the first placeholder
while (categoryFilter.options.length > 1) {
categoryFilter.remove(1);
}
Array.from(categories).sort().forEach(category => {
if(category){ // Avoid adding empty categories
const option = document.createElement('option');
option.value = category;
option.textContent = category;
categoryFilter.appendChild(option);
}
});
// Try to restore previous selection
categoryFilter.value = currentSelection;
}
function filterProducts() {
const searchTerm = productSearch.value.toLowerCase().trim();
const selectedCategory = categoryFilter.value;
let visibleCount = 0;
document.querySelectorAll('.product-card').forEach(card => {
const name = card.getAttribute('data-name') || '';
const category = card.getAttribute('data-category') || '';
const matchesSearch = !searchTerm || name.includes(searchTerm);
const matchesCategory = !selectedCategory || category === selectedCategory;
const isVisible = matchesSearch && matchesCategory;
card.style.display = isVisible ? 'flex' : 'none'; // Use flex since card uses flex display
if(isVisible) visibleCount++;
});
// Show placeholder if no products match filter
noProductsPlaceholder.style.display = (productGrid.childElementCount === 1 && visibleCount === 0) ? 'block' : 'none';
if (productGrid.childElementCount > 1) {
noProductsPlaceholder.style.display = visibleCount === 0 ? 'block' : 'none';
}
}
// --- Data Export ---
function exportData(format) {
if (products.length === 0) {
addLog('No product data to export.', 'warning');
return;
}
addLog(`Preparing ${format.toUpperCase()} export...`, 'info');
try {
const timestamp = new Date().toISOString().replace(/:/g, '-').slice(0, 19);
let data, filename, mimeType;
// Use only relevant fields for export
const exportProducts = products.map(p => ({
Name: p.name,
Price: p.price,
SKU: p.sku,
Category: p.category,
Description: p.description,
ImageURL: p.imageUrl,
PageURL: p.pageUrl,
Timestamp: p.timestamp
}));
switch (format) {
case 'csv':
data = convertToCSV(exportProducts);
filename = `herholdts_products_${timestamp}.csv`;
mimeType = 'text/csv;charset=utf-8;';
const blobCsv = new Blob([data], { type: mimeType });
saveAs(blobCsv, filename);
break;
case 'excel':
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.json_to_sheet(exportProducts);
// Optional: Adjust column widths (basic example)
const colWidths = Object.keys(exportProducts[0]).map(key => ({ wch: Math.max(key.length, 15) })); // Basic width estimate
ws['!cols'] = colWidths;
XLSX.utils.book_append_sheet(wb, ws, 'Products');
XLSX.writeFile(wb, `herholdts_products_${timestamp}.xlsx`);
break;
case 'json':
data = JSON.stringify(exportProducts, null, 2);
filename = `herholdts_products_${timestamp}.json`;
mimeType = 'application/json;charset=utf-8;';
const blobJson = new Blob([data], { type: mimeType });
saveAs(blobJson, filename);
break;
default:
throw new Error(`Unsupported format: ${format}`);
}
addLog(`${format.toUpperCase()} file exported successfully. (${products.length} items)`, 'success');
} catch (e) {
addLog(`Error exporting data as ${format}: ${e.message}`, 'error');
console.error("Export error:", e);
}
}
function convertToCSV(items) {
if (!items || items.length === 0) return '';
const header = Object.keys(items[0]);
const csvHeader = header.join(',');
const csvRows = items.map(item =>
header.map(fieldName => {
let fieldValue = item[fieldName];
if (fieldValue === null || typeof fieldValue === 'undefined') {
return '';
}
let stringValue = String(fieldValue);
// Escape double quotes and handle commas/newlines within fields
if (stringValue.includes('"') || stringValue.includes(',') || stringValue.includes('\n')) {
return `"${stringValue.replace(/"/g, '""')}"`;
}
return stringValue;
}).join(',')
);
return [csvHeader, ...csvRows].join('\n');
}
</script>
<p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - <a href="https://enzostvs-deepsite.hf.space?remix=aigodking/crawler-trial" style="color: #fff;text-decoration: underline;" target="_blank" >🧬 Remix</a></p></body>
</html>