1968 lines
66 KiB
JavaScript
1968 lines
66 KiB
JavaScript
|
|
// Click2Crawl class for Crawl4AI Chrome Extension
|
|||
|
|
// Click elements to build extraction schemas
|
|||
|
|
|
|||
|
|
// Singleton instance to prevent multiple toolbars
|
|||
|
|
let click2CrawlInstance = null;
|
|||
|
|
|
|||
|
|
class Click2Crawl {
|
|||
|
|
constructor() {
|
|||
|
|
// Prevent multiple instances
|
|||
|
|
if (click2CrawlInstance) {
|
|||
|
|
click2CrawlInstance.stop();
|
|||
|
|
}
|
|||
|
|
click2CrawlInstance = this;
|
|||
|
|
|
|||
|
|
this.container = null;
|
|||
|
|
this.fields = [];
|
|||
|
|
this.overlay = null;
|
|||
|
|
this.toolbar = null;
|
|||
|
|
this.highlightBox = null; // For hover preview
|
|||
|
|
this.selectedBox = null; // For selected element
|
|||
|
|
this.currentElement = null; // Currently hovered element
|
|||
|
|
this.selectedElement = null; // Currently selected element (container)
|
|||
|
|
this.selectedElements = new Set();
|
|||
|
|
this.inspectingFields = false; // Field inspection mode
|
|||
|
|
this.codeModal = null;
|
|||
|
|
this.previewMode = false;
|
|||
|
|
this.previewElements = [];
|
|||
|
|
this.schema = null;
|
|||
|
|
this.parentLevels = 1; // Default parent levels for base container
|
|||
|
|
|
|||
|
|
this.handleMouseMove = this.handleMouseMove.bind(this);
|
|||
|
|
this.handleClick = this.handleClick.bind(this);
|
|||
|
|
this.handleKeyPress = this.handleKeyPress.bind(this);
|
|||
|
|
this.handleMouseLeave = this.handleMouseLeave.bind(this);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
start() {
|
|||
|
|
this.createOverlay();
|
|||
|
|
this.createToolbar();
|
|||
|
|
this.attachEventListeners();
|
|||
|
|
this.updateToolbar();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
stop() {
|
|||
|
|
this.detachEventListeners();
|
|||
|
|
this.overlay?.remove();
|
|||
|
|
this.toolbar?.remove();
|
|||
|
|
this.highlightBox?.remove();
|
|||
|
|
this.selectedBox?.remove();
|
|||
|
|
this.removeAllHighlights();
|
|||
|
|
this.clearPreview();
|
|||
|
|
this.container = null;
|
|||
|
|
this.fields = [];
|
|||
|
|
this.selectedElements.clear();
|
|||
|
|
this.schema = null;
|
|||
|
|
this.currentElement = null;
|
|||
|
|
this.selectedElement = null;
|
|||
|
|
this.inspectingFields = false;
|
|||
|
|
this.parentLevels = 1;
|
|||
|
|
|
|||
|
|
// Clean up markdown preview modal
|
|||
|
|
if (this.markdownPreviewModal) {
|
|||
|
|
this.markdownPreviewModal.destroy();
|
|||
|
|
this.markdownPreviewModal = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Clear singleton reference
|
|||
|
|
if (click2CrawlInstance === this) {
|
|||
|
|
click2CrawlInstance = null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Alias for content script compatibility
|
|||
|
|
deactivate() {
|
|||
|
|
this.stop();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
createOverlay() {
|
|||
|
|
// Create highlight box for hover preview
|
|||
|
|
this.highlightBox = document.createElement('div');
|
|||
|
|
this.highlightBox.className = 'c4ai-highlight-box';
|
|||
|
|
document.body.appendChild(this.highlightBox);
|
|||
|
|
|
|||
|
|
// Create selected box for permanent selection
|
|||
|
|
this.selectedBox = document.createElement('div');
|
|||
|
|
this.selectedBox.className = 'c4ai-selected-box';
|
|||
|
|
this.selectedBox.style.display = 'none';
|
|||
|
|
document.body.appendChild(this.selectedBox);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
createToolbar() {
|
|||
|
|
// Remove any existing toolbar first
|
|||
|
|
const existingToolbar = document.querySelector('.c4ai-toolbar');
|
|||
|
|
if (existingToolbar) {
|
|||
|
|
existingToolbar.remove();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.toolbar = document.createElement('div');
|
|||
|
|
this.toolbar.className = 'c4ai-toolbar';
|
|||
|
|
this.toolbar.innerHTML = `
|
|||
|
|
<div class="c4ai-toolbar-titlebar">
|
|||
|
|
<div class="c4ai-titlebar-dots">
|
|||
|
|
<button class="c4ai-dot c4ai-dot-close" id="c4ai-close"></button>
|
|||
|
|
<button class="c4ai-dot c4ai-dot-minimize"></button>
|
|||
|
|
<button class="c4ai-dot c4ai-dot-maximize"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="c4ai-titlebar-title"> Click2Crawl</div>
|
|||
|
|
<img src="${chrome.runtime.getURL('icons/icon-16.png')}" class="c4ai-titlebar-icon" alt="Crawl4AI" style="margin-left: auto;">
|
|||
|
|
</div>
|
|||
|
|
<div class="c4ai-toolbar-content">
|
|||
|
|
<div class="c4ai-toolbar-status">
|
|||
|
|
<div class="c4ai-status-item">
|
|||
|
|
<span class="c4ai-status-label">Mode:</span>
|
|||
|
|
<span class="c4ai-status-value" id="c4ai-mode">Select Container</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="c4ai-status-item" id="c4ai-container-item" style="display: none;">
|
|||
|
|
<span class="c4ai-status-label">Container:</span>
|
|||
|
|
<span class="c4ai-status-value" id="c4ai-container">Not selected</span>
|
|||
|
|
<button class="c4ai-nav-btn c4ai-nav-btn-small" id="c4ai-nav-up" title="Select parent">↑</button>
|
|||
|
|
<button class="c4ai-nav-btn c4ai-nav-btn-small" id="c4ai-nav-down" title="Select child">↓</button>
|
|||
|
|
<button class="c4ai-nav-btn c4ai-nav-btn-small c4ai-nav-deselect" id="c4ai-nav-close" title="Deselect">×</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="c4ai-status-item" id="c4ai-selector-display" style="display: none;">
|
|||
|
|
<div class="c4ai-container-selector" id="c4ai-container-selector"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="c4ai-status-item" id="c4ai-parent-levels" style="display: none;">
|
|||
|
|
<span class="c4ai-status-label">Parent Levels:</span>
|
|||
|
|
<div class="c4ai-parent-controls">
|
|||
|
|
<button class="c4ai-parent-btn" id="c4ai-parent-minus">-</button>
|
|||
|
|
<span class="c4ai-parent-value" id="c4ai-parent-value">1</span>
|
|||
|
|
<button class="c4ai-parent-btn" id="c4ai-parent-plus">+</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="c4ai-schema-section" id="c4ai-schema-section" style="display: none;">
|
|||
|
|
<div class="c4ai-section-header">
|
|||
|
|
<span>SCHEMA FIELDS (<span id="c4ai-field-count">0</span>)</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="c4ai-fields-list" id="c4ai-fields-list"></div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="c4ai-actions-section" id="c4ai-actions-section" style="display: none;">
|
|||
|
|
<div class="c4ai-section-header">ACTIONS</div>
|
|||
|
|
<div class="c4ai-toolbar-actions">
|
|||
|
|
<button id="c4ai-preview" class="c4ai-action-btn c4ai-preview-btn" disabled>
|
|||
|
|
<span>👁️</span> Preview Matches
|
|||
|
|
</button>
|
|||
|
|
<button id="c4ai-test" class="c4ai-action-btn c4ai-test-btn" disabled>
|
|||
|
|
<span>🧪</span> Test Schema
|
|||
|
|
</button>
|
|||
|
|
<button id="c4ai-deploy-cloud" class="c4ai-action-btn c4ai-export-btn c4ai-cloud-btn" disabled>
|
|||
|
|
<span>☁️</span> Deploy
|
|||
|
|
</button>
|
|||
|
|
<button id="c4ai-export-schema" class="c4ai-action-btn c4ai-export-btn" disabled>
|
|||
|
|
<span>📄</span> Schema
|
|||
|
|
</button>
|
|||
|
|
<button id="c4ai-export-data" class="c4ai-action-btn c4ai-export-btn" disabled>
|
|||
|
|
<span>📊</span> Data
|
|||
|
|
</button>
|
|||
|
|
<button id="c4ai-export-markdown" class="c4ai-action-btn c4ai-export-btn" disabled>
|
|||
|
|
<span>📝</span> Markdown
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="c4ai-stats-section" id="c4ai-stats-section" style="display: none;">
|
|||
|
|
<div class="c4ai-section-header">STATS</div>
|
|||
|
|
<div class="c4ai-stats">
|
|||
|
|
<div class="c4ai-stat-item">
|
|||
|
|
<span>Matches Found:</span>
|
|||
|
|
<span id="c4ai-matches-count">0 items</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="c4ai-stat-item">
|
|||
|
|
<span>Schema Valid:</span>
|
|||
|
|
<span id="c4ai-schema-valid">Not tested</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="c4ai-toolbar-hint" id="c4ai-hint">
|
|||
|
|
Click on a container element (e.g., product card, article, etc.)
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="c4ai-toolbar-footer" id="c4ai-footer-section" style="display: none;">
|
|||
|
|
<button id="c4ai-inspect-fields" class="c4ai-action-btn c4ai-primary-btn">
|
|||
|
|
<span>🏷️</span> Fields
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
document.body.appendChild(this.toolbar);
|
|||
|
|
|
|||
|
|
// Force toolbar to top of z-index stack
|
|||
|
|
this.toolbar.style.zIndex = '2147483647'; // Maximum z-index
|
|||
|
|
|
|||
|
|
// Add event listeners for toolbar buttons with error handling
|
|||
|
|
const addClickHandler = (id, handler) => {
|
|||
|
|
const element = document.getElementById(id);
|
|||
|
|
if (element) {
|
|||
|
|
element.addEventListener('click', (e) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
e.stopPropagation();
|
|||
|
|
handler();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Add all event listeners
|
|||
|
|
addClickHandler('c4ai-inspect-fields', () => this.toggleFieldInspection());
|
|||
|
|
addClickHandler('c4ai-preview', () => this.togglePreview());
|
|||
|
|
addClickHandler('c4ai-test', () => this.testSchema());
|
|||
|
|
addClickHandler('c4ai-export-schema', () => this.exportSchema());
|
|||
|
|
addClickHandler('c4ai-export-data', () => this.exportData());
|
|||
|
|
addClickHandler('c4ai-export-markdown', () => this.exportMarkdown());
|
|||
|
|
addClickHandler('c4ai-deploy-cloud', () => this.deployToCloud());
|
|||
|
|
addClickHandler('c4ai-close', () => this.stop());
|
|||
|
|
|
|||
|
|
// Navigation controls
|
|||
|
|
addClickHandler('c4ai-nav-up', () => this.navigateUp());
|
|||
|
|
addClickHandler('c4ai-nav-down', () => this.navigateDown());
|
|||
|
|
addClickHandler('c4ai-nav-close', () => this.deselectContainer());
|
|||
|
|
|
|||
|
|
// Parent level controls
|
|||
|
|
addClickHandler('c4ai-parent-minus', () => this.adjustParentLevels(-1));
|
|||
|
|
addClickHandler('c4ai-parent-plus', () => this.adjustParentLevels(1));
|
|||
|
|
|
|||
|
|
// Make toolbar draggable
|
|||
|
|
if (window.C4AI_Utils && window.C4AI_Utils.makeDraggable) {
|
|||
|
|
window.C4AI_Utils.makeDraggable(this.toolbar);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
attachEventListeners() {
|
|||
|
|
document.addEventListener('mousemove', this.handleMouseMove, true);
|
|||
|
|
document.addEventListener('click', this.handleClick, true);
|
|||
|
|
document.addEventListener('keydown', this.handleKeyPress, true);
|
|||
|
|
document.addEventListener('mouseleave', this.handleMouseLeave, true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
detachEventListeners() {
|
|||
|
|
document.removeEventListener('mousemove', this.handleMouseMove, true);
|
|||
|
|
document.removeEventListener('click', this.handleClick, true);
|
|||
|
|
document.removeEventListener('keydown', this.handleKeyPress, true);
|
|||
|
|
document.removeEventListener('mouseleave', this.handleMouseLeave, true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
handleMouseMove(e) {
|
|||
|
|
const element = document.elementFromPoint(e.clientX, e.clientY);
|
|||
|
|
|
|||
|
|
// Don't highlight if hovering over our UI elements
|
|||
|
|
if (this.isOurElement(element)) {
|
|||
|
|
this.highlightBox.style.display = 'none';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Only show highlight if:
|
|||
|
|
// 1. No container selected (selection mode)
|
|||
|
|
// 2. Or inspecting fields inside container
|
|||
|
|
if (!this.container || (this.inspectingFields && this.container)) {
|
|||
|
|
if (element) {
|
|||
|
|
// If inspecting fields, only highlight elements inside container
|
|||
|
|
if (this.inspectingFields && !this.container.element.contains(element)) {
|
|||
|
|
this.highlightBox.style.display = 'none';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.currentElement = element;
|
|||
|
|
this.highlightElement(element);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// Container selected but not inspecting fields - no highlight
|
|||
|
|
this.highlightBox.style.display = 'none';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
handleMouseLeave(e) {
|
|||
|
|
// Hide highlight when mouse leaves
|
|||
|
|
if (e.target === document) {
|
|||
|
|
this.highlightBox.style.display = 'none';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
handleClick(e) {
|
|||
|
|
const element = e.target;
|
|||
|
|
|
|||
|
|
// Check if clicking on our UI elements (including markdown preview modal)
|
|||
|
|
if (this.isOurElement(element)) {
|
|||
|
|
return; // Let toolbar clicks work normally
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Additional check for markdown preview modal classes
|
|||
|
|
if (element.closest('.c4ai-c2c-preview') || element.closest('.c4ai-preview-options')) {
|
|||
|
|
return; // Don't interfere with markdown preview modal
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Use current element
|
|||
|
|
const targetElement = this.currentElement || element;
|
|||
|
|
|
|||
|
|
if (!this.container) {
|
|||
|
|
// Container selection mode - prevent default
|
|||
|
|
e.preventDefault();
|
|||
|
|
e.stopPropagation();
|
|||
|
|
this.selectContainer(targetElement);
|
|||
|
|
} else if (this.inspectingFields && this.container.element.contains(targetElement)) {
|
|||
|
|
// Field selection mode AND clicking inside container - prevent default
|
|||
|
|
e.preventDefault();
|
|||
|
|
e.stopPropagation();
|
|||
|
|
this.selectField(targetElement);
|
|||
|
|
}
|
|||
|
|
// Otherwise, let the click work normally
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
handleKeyPress(e) {
|
|||
|
|
if (e.key === 'Escape') {
|
|||
|
|
this.stop();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
isOurElement(element) {
|
|||
|
|
return window.C4AI_Utils.isOurElement(element) ||
|
|||
|
|
(this.selectedBox && element === this.selectedBox) ||
|
|||
|
|
(this.markdownPreviewModal && this.markdownPreviewModal.modal &&
|
|||
|
|
(element === this.markdownPreviewModal.modal || this.markdownPreviewModal.modal.contains(element)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
showSelectedBox(element) {
|
|||
|
|
if (!element) return;
|
|||
|
|
|
|||
|
|
const rect = element.getBoundingClientRect();
|
|||
|
|
this.selectedBox.style.cssText = `
|
|||
|
|
position: absolute;
|
|||
|
|
left: ${rect.left + window.scrollX}px;
|
|||
|
|
top: ${rect.top + window.scrollY}px;
|
|||
|
|
width: ${rect.width}px;
|
|||
|
|
height: ${rect.height}px;
|
|||
|
|
display: block;
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
this.selectedBox.className = 'c4ai-selected-box c4ai-selected-container';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
updateNavButtonStates() {
|
|||
|
|
const upBtn = document.getElementById('c4ai-nav-up');
|
|||
|
|
const downBtn = document.getElementById('c4ai-nav-down');
|
|||
|
|
|
|||
|
|
if (this.selectedElement) {
|
|||
|
|
// Disable up button if no parent or parent is body
|
|||
|
|
upBtn.disabled = !this.selectedElement.parentElement || this.selectedElement.parentElement === document.body;
|
|||
|
|
|
|||
|
|
// Disable down button if no children
|
|||
|
|
downBtn.disabled = this.selectedElement.children.length === 0;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
navigateUp() {
|
|||
|
|
if (!this.selectedElement || !this.selectedElement.parentElement) return;
|
|||
|
|
|
|||
|
|
const parent = this.selectedElement.parentElement;
|
|||
|
|
if (parent === document.body) return;
|
|||
|
|
|
|||
|
|
// Update selected element and container
|
|||
|
|
this.selectedElement = parent;
|
|||
|
|
this.container.element = parent;
|
|||
|
|
this.container.tagName = parent.tagName.toLowerCase();
|
|||
|
|
this.container.selector = this.generateContainerSelector(parent);
|
|||
|
|
|
|||
|
|
// Update visual selection
|
|||
|
|
this.showSelectedBox(parent);
|
|||
|
|
this.updateNavButtonStates();
|
|||
|
|
this.updateToolbar();
|
|||
|
|
this.updateStats();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
navigateDown() {
|
|||
|
|
if (!this.selectedElement || this.selectedElement.children.length === 0) return;
|
|||
|
|
|
|||
|
|
const firstChild = this.selectedElement.children[0];
|
|||
|
|
|
|||
|
|
// Update selected element and container
|
|||
|
|
this.selectedElement = firstChild;
|
|||
|
|
this.container.element = firstChild;
|
|||
|
|
this.container.tagName = firstChild.tagName.toLowerCase();
|
|||
|
|
this.container.selector = this.generateContainerSelector(firstChild);
|
|||
|
|
|
|||
|
|
// Update visual selection
|
|||
|
|
this.showSelectedBox(firstChild);
|
|||
|
|
this.updateNavButtonStates();
|
|||
|
|
this.updateToolbar();
|
|||
|
|
this.updateStats();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
deselectContainer() {
|
|||
|
|
if (this.container) {
|
|||
|
|
// Remove visual selection
|
|||
|
|
this.container.element.classList.remove('c4ai-selected-container');
|
|||
|
|
this.selectedBox.style.display = 'none';
|
|||
|
|
|
|||
|
|
// Clear container and related state
|
|||
|
|
this.container = null;
|
|||
|
|
this.selectedElement = null;
|
|||
|
|
this.inspectingFields = false;
|
|||
|
|
|
|||
|
|
// Clear all fields
|
|||
|
|
this.fields.forEach(field => {
|
|||
|
|
field.element.classList.remove('c4ai-selected-field');
|
|||
|
|
field.element.removeAttribute('data-c4ai-field');
|
|||
|
|
});
|
|||
|
|
this.fields = [];
|
|||
|
|
this.selectedElements.clear();
|
|||
|
|
|
|||
|
|
this.updateToolbar();
|
|||
|
|
this.updateStats();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
toggleFieldInspection() {
|
|||
|
|
this.inspectingFields = !this.inspectingFields;
|
|||
|
|
const fieldsBtn = document.getElementById('c4ai-inspect-fields');
|
|||
|
|
|
|||
|
|
if (this.inspectingFields) {
|
|||
|
|
fieldsBtn.classList.add('c4ai-active');
|
|||
|
|
fieldsBtn.innerHTML = '<span>✓</span> Fields';
|
|||
|
|
} else {
|
|||
|
|
fieldsBtn.classList.remove('c4ai-active');
|
|||
|
|
fieldsBtn.innerHTML = '<span>🏷️</span> Fields';
|
|||
|
|
this.highlightBox.style.display = 'none';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.updateToolbar();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
// Legacy method - kept for compatibility but now redirects to test schema
|
|||
|
|
stopAndGenerate() {
|
|||
|
|
this.testSchema();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
highlightElement(element) {
|
|||
|
|
const rect = element.getBoundingClientRect();
|
|||
|
|
this.highlightBox.style.cssText = `
|
|||
|
|
left: ${rect.left + window.scrollX}px;
|
|||
|
|
top: ${rect.top + window.scrollY}px;
|
|||
|
|
width: ${rect.width}px;
|
|||
|
|
height: ${rect.height}px;
|
|||
|
|
display: block;
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
if (!this.container) {
|
|||
|
|
// Container selection mode
|
|||
|
|
this.highlightBox.className = 'c4ai-highlight-box c4ai-container-mode';
|
|||
|
|
} else {
|
|||
|
|
// Field selection mode
|
|||
|
|
this.highlightBox.className = 'c4ai-highlight-box c4ai-field-mode';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
selectContainer(element) {
|
|||
|
|
// Remove previous container highlight
|
|||
|
|
if (this.container) {
|
|||
|
|
this.container.element.classList.remove('c4ai-selected-container');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.container = {
|
|||
|
|
element: element,
|
|||
|
|
html: element.outerHTML,
|
|||
|
|
selector: this.generateContainerSelector(element),
|
|||
|
|
tagName: element.tagName.toLowerCase()
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
element.classList.add('c4ai-selected-container');
|
|||
|
|
this.selectedElement = element;
|
|||
|
|
this.showSelectedBox(element);
|
|||
|
|
|
|||
|
|
// Hide hover highlight after selection
|
|||
|
|
this.highlightBox.style.display = 'none';
|
|||
|
|
|
|||
|
|
// Update navigation button states
|
|||
|
|
this.updateNavButtonStates();
|
|||
|
|
this.updateToolbar();
|
|||
|
|
this.updateStats();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
selectField(element) {
|
|||
|
|
// Don't select the container itself
|
|||
|
|
if (element === this.container.element) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Check if already selected - if so, deselect it
|
|||
|
|
if (this.selectedElements.has(element)) {
|
|||
|
|
this.deselectField(element);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Must be inside the container
|
|||
|
|
if (!this.container.element.contains(element)) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.showFieldDialog(element);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
deselectField(element) {
|
|||
|
|
// Remove from fields array
|
|||
|
|
this.fields = this.fields.filter(f => f.element !== element);
|
|||
|
|
|
|||
|
|
// Remove from selected elements set
|
|||
|
|
this.selectedElements.delete(element);
|
|||
|
|
|
|||
|
|
// Remove visual selection
|
|||
|
|
element.classList.remove('c4ai-selected-field');
|
|||
|
|
|
|||
|
|
// Update UI
|
|||
|
|
this.updateToolbar();
|
|||
|
|
this.updateStats();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
showFieldDialog(element) {
|
|||
|
|
// Remove any existing field dialogs first
|
|||
|
|
document.querySelectorAll('.c4ai-field-dialog').forEach(d => d.remove());
|
|||
|
|
|
|||
|
|
const dialog = document.createElement('div');
|
|||
|
|
dialog.className = 'c4ai-field-dialog';
|
|||
|
|
|
|||
|
|
const rect = element.getBoundingClientRect();
|
|||
|
|
dialog.style.cssText = `
|
|||
|
|
left: ${rect.left + window.scrollX}px;
|
|||
|
|
top: ${rect.bottom + window.scrollY + 10}px;
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
// Get available attributes
|
|||
|
|
const attributes = this.getElementAttributes(element);
|
|||
|
|
const attributeOptions = attributes.map(attr =>
|
|||
|
|
`<option value="${attr.name}">${attr.name}: "${attr.value.substring(0, 30)}${attr.value.length > 30 ? '...' : ''}"</option>`
|
|||
|
|
).join('');
|
|||
|
|
|
|||
|
|
dialog.innerHTML = `
|
|||
|
|
<div class="c4ai-field-dialog-content">
|
|||
|
|
<h4>Configure Field</h4>
|
|||
|
|
|
|||
|
|
<div class="c4ai-field-input">
|
|||
|
|
<label>Field Name:</label>
|
|||
|
|
<input type="text" id="c4ai-field-name" placeholder="e.g., title, price, description" autofocus>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="c4ai-field-input">
|
|||
|
|
<label>Field Type:</label>
|
|||
|
|
<select id="c4ai-field-type">
|
|||
|
|
<option value="text">Text Content</option>
|
|||
|
|
<option value="attribute">Attribute</option>
|
|||
|
|
<option value="link">Link (href)</option>
|
|||
|
|
<option value="image">Image (src)</option>
|
|||
|
|
<option value="list">List</option>
|
|||
|
|
<option value="nested">Nested Object</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="c4ai-field-input" id="c4ai-attribute-select" style="display: none;">
|
|||
|
|
<label>Select Attribute:</label>
|
|||
|
|
<select id="c4ai-field-attribute">
|
|||
|
|
${attributeOptions}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="c4ai-field-preview">
|
|||
|
|
<strong>Preview Value:</strong>
|
|||
|
|
<div id="c4ai-preview-value">${element.textContent.trim().substring(0, 100)}</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="c4ai-field-selector">
|
|||
|
|
<strong>Selector (auto-generated):</strong>
|
|||
|
|
<div id="c4ai-selector-preview">${this.generateSmartSelector(element, this.container.element)}</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="c4ai-field-actions">
|
|||
|
|
<button id="c4ai-field-save" class="c4ai-primary">✓ Save</button>
|
|||
|
|
<button id="c4ai-field-cancel">✗ Cancel</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
document.body.appendChild(dialog);
|
|||
|
|
|
|||
|
|
const nameInput = dialog.querySelector('#c4ai-field-name');
|
|||
|
|
const typeSelect = dialog.querySelector('#c4ai-field-type');
|
|||
|
|
const attributeSelect = dialog.querySelector('#c4ai-field-attribute');
|
|||
|
|
const attributeContainer = dialog.querySelector('#c4ai-attribute-select');
|
|||
|
|
const previewValue = dialog.querySelector('#c4ai-preview-value');
|
|||
|
|
const saveBtn = dialog.querySelector('#c4ai-field-save');
|
|||
|
|
const cancelBtn = dialog.querySelector('#c4ai-field-cancel');
|
|||
|
|
|
|||
|
|
// Update preview based on type selection
|
|||
|
|
const updatePreview = () => {
|
|||
|
|
const type = typeSelect.value;
|
|||
|
|
let value = '';
|
|||
|
|
|
|||
|
|
switch(type) {
|
|||
|
|
case 'text':
|
|||
|
|
value = element.textContent.trim();
|
|||
|
|
attributeContainer.style.display = 'none';
|
|||
|
|
break;
|
|||
|
|
case 'attribute':
|
|||
|
|
attributeContainer.style.display = 'block';
|
|||
|
|
value = element.getAttribute(attributeSelect.value) || '';
|
|||
|
|
break;
|
|||
|
|
case 'link':
|
|||
|
|
value = element.getAttribute('href') || element.querySelector('a')?.getAttribute('href') || '';
|
|||
|
|
attributeContainer.style.display = 'none';
|
|||
|
|
break;
|
|||
|
|
case 'image':
|
|||
|
|
value = element.getAttribute('src') || element.querySelector('img')?.getAttribute('src') || '';
|
|||
|
|
attributeContainer.style.display = 'none';
|
|||
|
|
break;
|
|||
|
|
case 'list':
|
|||
|
|
const listItems = element.querySelectorAll('li, option');
|
|||
|
|
value = `[${listItems.length} items]`;
|
|||
|
|
attributeContainer.style.display = 'none';
|
|||
|
|
break;
|
|||
|
|
case 'nested':
|
|||
|
|
value = '[Complex nested structure]';
|
|||
|
|
attributeContainer.style.display = 'none';
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
previewValue.textContent = value.substring(0, 100) + (value.length > 100 ? '...' : '');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
typeSelect.addEventListener('change', updatePreview);
|
|||
|
|
attributeSelect.addEventListener('change', updatePreview);
|
|||
|
|
|
|||
|
|
const save = () => {
|
|||
|
|
const fieldName = nameInput.value.trim();
|
|||
|
|
if (fieldName) {
|
|||
|
|
const type = typeSelect.value;
|
|||
|
|
const selector = this.generateSmartSelector(element, this.container.element);
|
|||
|
|
|
|||
|
|
const field = {
|
|||
|
|
name: fieldName,
|
|||
|
|
type: type,
|
|||
|
|
selector: selector,
|
|||
|
|
element: element,
|
|||
|
|
value: previewValue.textContent
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Add attribute if needed
|
|||
|
|
if (type === 'attribute') {
|
|||
|
|
field.attribute = attributeSelect.value;
|
|||
|
|
} else if (type === 'link') {
|
|||
|
|
field.type = 'attribute';
|
|||
|
|
field.attribute = 'href';
|
|||
|
|
} else if (type === 'image') {
|
|||
|
|
field.type = 'attribute';
|
|||
|
|
field.attribute = 'src';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.fields.push(field);
|
|||
|
|
element.classList.add('c4ai-selected-field');
|
|||
|
|
element.setAttribute('data-c4ai-field', fieldName);
|
|||
|
|
this.selectedElements.add(element);
|
|||
|
|
this.updateToolbar();
|
|||
|
|
this.updateStats();
|
|||
|
|
this.generateSchema();
|
|||
|
|
}
|
|||
|
|
dialog.remove(); // Always close dialog
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const cancel = () => {
|
|||
|
|
dialog.remove();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
saveBtn.addEventListener('click', save);
|
|||
|
|
cancelBtn.addEventListener('click', cancel);
|
|||
|
|
nameInput.addEventListener('keypress', (e) => {
|
|||
|
|
if (e.key === 'Enter') save();
|
|||
|
|
if (e.key === 'Escape') cancel();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
nameInput.focus();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
adjustParentLevels(delta) {
|
|||
|
|
if (!this.container) return;
|
|||
|
|
|
|||
|
|
const newLevel = this.parentLevels + delta;
|
|||
|
|
if (newLevel < 0 || newLevel > 5) return;
|
|||
|
|
|
|||
|
|
this.parentLevels = newLevel;
|
|||
|
|
document.getElementById('c4ai-parent-value').textContent = newLevel;
|
|||
|
|
|
|||
|
|
// Update container selector with new parent levels
|
|||
|
|
this.updateContainerSelector();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
updateContainerSelector() {
|
|||
|
|
if (!this.container || !this.selectedElement) return;
|
|||
|
|
|
|||
|
|
this.container.selector = this.generateContainerSelector(this.selectedElement);
|
|||
|
|
this.container.element = this.selectedElement;
|
|||
|
|
|
|||
|
|
// Update the schema
|
|||
|
|
this.generateSchema();
|
|||
|
|
|
|||
|
|
// Update display
|
|||
|
|
const containerDisplay = document.getElementById('c4ai-container');
|
|||
|
|
// containerDisplay.textContent = `${this.container.tagName} (${this.parentLevels} levels)`;
|
|||
|
|
containerDisplay.textContent = `${this.container.tagName}`;
|
|||
|
|
|
|||
|
|
// Update selector display
|
|||
|
|
const containerSelector = document.getElementById('c4ai-container-selector');
|
|||
|
|
if (containerSelector) {
|
|||
|
|
containerSelector.textContent = this.container.selector;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
generateContainerSelector(element) {
|
|||
|
|
// For container, include parent levels
|
|||
|
|
let current = element;
|
|||
|
|
const parts = [];
|
|||
|
|
|
|||
|
|
// Start from the target element
|
|||
|
|
for (let i = 0; i <= this.parentLevels; i++) {
|
|||
|
|
if (!current || current === document.body) break;
|
|||
|
|
|
|||
|
|
const selector = this.generateSingleElementSelector(current);
|
|||
|
|
parts.unshift(selector);
|
|||
|
|
|
|||
|
|
if (i < this.parentLevels) {
|
|||
|
|
current = current.parentElement;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// If we have parent levels, show them clearly
|
|||
|
|
if (this.parentLevels > 0 && parts.length > 1) {
|
|||
|
|
// Make it clear which part is the container
|
|||
|
|
const containerPart = parts[parts.length - 1];
|
|||
|
|
const parentParts = parts.slice(0, -1);
|
|||
|
|
return parentParts.join(' > ') + ' > ' + containerPart;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return parts.join(' > ');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
generateSingleElementSelector(element) {
|
|||
|
|
// Generate selector for a single element
|
|||
|
|
if (element.id) {
|
|||
|
|
return `#${CSS.escape(element.id)}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Check for data attributes (most stable)
|
|||
|
|
const dataAttrs = ['data-testid', 'data-id', 'data-test', 'data-cy'];
|
|||
|
|
for (const attr of dataAttrs) {
|
|||
|
|
const value = element.getAttribute(attr);
|
|||
|
|
if (value) {
|
|||
|
|
return `[${attr}="${value}"]`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Check for aria-label
|
|||
|
|
if (element.getAttribute('aria-label')) {
|
|||
|
|
return `[aria-label="${element.getAttribute('aria-label')}"]`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const tagName = element.tagName.toLowerCase();
|
|||
|
|
|
|||
|
|
// Check for simple, non-utility classes
|
|||
|
|
const classes = Array.from(element.classList)
|
|||
|
|
.filter(c => !c.startsWith('c4ai-')) // Exclude our classes
|
|||
|
|
.filter(c => !c.includes('[') && !c.includes('(') && !c.includes(':')) // Exclude utility classes
|
|||
|
|
.filter(c => c.length < 30); // Exclude very long classes
|
|||
|
|
|
|||
|
|
if (classes.length > 0 && classes.length <= 2) {
|
|||
|
|
return tagName + classes.map(c => `.${CSS.escape(c)}`).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return tagName;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
generateSelector(element, context = document) {
|
|||
|
|
// Try to generate a robust selector
|
|||
|
|
if (element.id) {
|
|||
|
|
return `#${CSS.escape(element.id)}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Check for data attributes (most stable)
|
|||
|
|
const dataAttrs = ['data-testid', 'data-id', 'data-test', 'data-cy'];
|
|||
|
|
for (const attr of dataAttrs) {
|
|||
|
|
const value = element.getAttribute(attr);
|
|||
|
|
if (value) {
|
|||
|
|
return `[${attr}="${value}"]`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Check for aria-label
|
|||
|
|
if (element.getAttribute('aria-label')) {
|
|||
|
|
return `[aria-label="${element.getAttribute('aria-label')}"]`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Try semantic HTML elements with text
|
|||
|
|
const tagName = element.tagName.toLowerCase();
|
|||
|
|
if (['button', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagName)) {
|
|||
|
|
const text = element.textContent.trim();
|
|||
|
|
if (text && text.length < 50) {
|
|||
|
|
// Use tag name with partial text match
|
|||
|
|
return `${tagName}`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Check for simple, non-utility classes
|
|||
|
|
const classes = Array.from(element.classList)
|
|||
|
|
.filter(c => !c.startsWith('c4ai-')) // Exclude our classes
|
|||
|
|
.filter(c => !c.includes('[') && !c.includes('(') && !c.includes(':')) // Exclude utility classes
|
|||
|
|
.filter(c => c.length < 30); // Exclude very long classes
|
|||
|
|
|
|||
|
|
if (classes.length > 0 && classes.length <= 3) {
|
|||
|
|
const selector = classes.map(c => `.${CSS.escape(c)}`).join('');
|
|||
|
|
try {
|
|||
|
|
if (context.querySelectorAll(selector).length === 1) {
|
|||
|
|
return selector;
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
// Invalid selector, continue
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Use nth-child with simple parent tag
|
|||
|
|
const parent = element.parentElement;
|
|||
|
|
if (parent && parent !== context) {
|
|||
|
|
const siblings = Array.from(parent.children);
|
|||
|
|
const index = siblings.indexOf(element) + 1;
|
|||
|
|
// Just use parent tag name to avoid recursion
|
|||
|
|
const parentTag = parent.tagName.toLowerCase();
|
|||
|
|
return `${parentTag} > ${tagName}:nth-child(${index})`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Final fallback
|
|||
|
|
return tagName;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
updateToolbar() {
|
|||
|
|
// Update mode display
|
|||
|
|
if (!this.container) {
|
|||
|
|
document.getElementById('c4ai-mode').textContent = 'Select Container';
|
|||
|
|
} else if (this.inspectingFields) {
|
|||
|
|
document.getElementById('c4ai-mode').textContent = 'Select Fields';
|
|||
|
|
} else {
|
|||
|
|
document.getElementById('c4ai-mode').textContent = 'Container Selected';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Show/hide container info and controls
|
|||
|
|
const containerItem = document.getElementById('c4ai-container-item');
|
|||
|
|
const parentLevelControls = document.getElementById('c4ai-parent-levels');
|
|||
|
|
const footerSection = document.getElementById('c4ai-footer-section');
|
|||
|
|
const selectorDisplay = document.getElementById('c4ai-selector-display');
|
|||
|
|
const containerSelector = document.getElementById('c4ai-container-selector');
|
|||
|
|
|
|||
|
|
if (this.container) {
|
|||
|
|
containerItem.style.display = 'flex';
|
|||
|
|
parentLevelControls.style.display = 'flex';
|
|||
|
|
footerSection.style.display = 'flex';
|
|||
|
|
selectorDisplay.style.display = 'block';
|
|||
|
|
|
|||
|
|
// Update container display
|
|||
|
|
document.getElementById('c4ai-container').textContent =
|
|||
|
|
`${this.container.tagName} (${this.parentLevels} levels)`;
|
|||
|
|
|
|||
|
|
// Update selector display
|
|||
|
|
containerSelector.textContent = this.container.selector;
|
|||
|
|
} else {
|
|||
|
|
containerItem.style.display = 'none';
|
|||
|
|
parentLevelControls.style.display = 'none';
|
|||
|
|
footerSection.style.display = 'none';
|
|||
|
|
selectorDisplay.style.display = 'none';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Show/hide sections based on state
|
|||
|
|
const schemaSection = document.getElementById('c4ai-schema-section');
|
|||
|
|
const actionsSection = document.getElementById('c4ai-actions-section');
|
|||
|
|
const statsSection = document.getElementById('c4ai-stats-section');
|
|||
|
|
|
|||
|
|
if (this.fields.length > 0) {
|
|||
|
|
schemaSection.style.display = 'block';
|
|||
|
|
actionsSection.style.display = 'block';
|
|||
|
|
statsSection.style.display = 'block';
|
|||
|
|
|
|||
|
|
// Update field count
|
|||
|
|
document.getElementById('c4ai-field-count').textContent = this.fields.length;
|
|||
|
|
|
|||
|
|
// Update fields list with enhanced UI
|
|||
|
|
const fieldsList = document.getElementById('c4ai-fields-list');
|
|||
|
|
fieldsList.innerHTML = this.fields.map((field, index) => {
|
|||
|
|
const icon = this.getFieldIcon(field.type);
|
|||
|
|
return `
|
|||
|
|
<div class="c4ai-field-item" data-index="${index}">
|
|||
|
|
<div class="c4ai-field-header">
|
|||
|
|
<span class="c4ai-field-icon">${icon}</span>
|
|||
|
|
<span class="c4ai-field-name">${field.name}</span>
|
|||
|
|
<div class="c4ai-field-actions">
|
|||
|
|
<button class="c4ai-field-edit" data-index="${index}" title="Edit field">✏️</button>
|
|||
|
|
<button class="c4ai-field-delete" data-index="${index}" title="Remove field">×</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="c4ai-field-selector" contenteditable="true" data-index="${index}">${field.selector}</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}).join('');
|
|||
|
|
|
|||
|
|
// Add event handlers
|
|||
|
|
fieldsList.querySelectorAll('.c4ai-field-delete').forEach(btn => {
|
|||
|
|
btn.addEventListener('click', (e) => {
|
|||
|
|
const index = parseInt(e.target.dataset.index);
|
|||
|
|
this.removeField(index);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
fieldsList.querySelectorAll('.c4ai-field-edit').forEach(btn => {
|
|||
|
|
btn.addEventListener('click', (e) => {
|
|||
|
|
const index = parseInt(e.target.dataset.index);
|
|||
|
|
this.editField(index);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
fieldsList.querySelectorAll('.c4ai-field-selector').forEach(selector => {
|
|||
|
|
selector.addEventListener('blur', (e) => {
|
|||
|
|
const index = parseInt(e.target.dataset.index);
|
|||
|
|
const newSelector = e.target.textContent.trim();
|
|||
|
|
if (newSelector && this.fields[index]) {
|
|||
|
|
this.fields[index].selector = newSelector;
|
|||
|
|
this.generateSchema();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
selector.addEventListener('keydown', (e) => {
|
|||
|
|
if (e.key === 'Enter') {
|
|||
|
|
e.preventDefault();
|
|||
|
|
e.target.blur();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Enable action buttons
|
|||
|
|
document.getElementById('c4ai-preview').disabled = false;
|
|||
|
|
document.getElementById('c4ai-test').disabled = false;
|
|||
|
|
document.getElementById('c4ai-export-schema').disabled = false;
|
|||
|
|
document.getElementById('c4ai-export-data').disabled = false;
|
|||
|
|
document.getElementById('c4ai-export-markdown').disabled = false;
|
|||
|
|
document.getElementById('c4ai-deploy-cloud').disabled = false;
|
|||
|
|
} else {
|
|||
|
|
schemaSection.style.display = 'none';
|
|||
|
|
actionsSection.style.display = 'none';
|
|||
|
|
statsSection.style.display = 'none';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const hint = document.getElementById('c4ai-hint');
|
|||
|
|
if (!this.container) {
|
|||
|
|
hint.textContent = 'Click on a container element (e.g., product card, article, etc.)';
|
|||
|
|
} else if (this.inspectingFields && this.fields.length === 0) {
|
|||
|
|
hint.textContent = 'Click on fields inside the container to extract (title, price, etc.)';
|
|||
|
|
} else if (this.inspectingFields) {
|
|||
|
|
hint.innerHTML = `Continue selecting fields or click Fields button to stop.`;
|
|||
|
|
} else if (this.fields.length === 0) {
|
|||
|
|
hint.innerHTML = `Click <strong>Fields</strong> button to start selecting fields.`;
|
|||
|
|
} else {
|
|||
|
|
hint.innerHTML = `Use action buttons above or click <strong>Fields</strong> to add more.`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
getFieldIcon(type) {
|
|||
|
|
const icons = {
|
|||
|
|
'text': '📝',
|
|||
|
|
'attribute': '📊',
|
|||
|
|
'link': '🔗',
|
|||
|
|
'image': '🖼️',
|
|||
|
|
'list': '📚',
|
|||
|
|
'nested': '📁'
|
|||
|
|
};
|
|||
|
|
return icons[type] || '📝';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
removeField(index) {
|
|||
|
|
const field = this.fields[index];
|
|||
|
|
|
|||
|
|
// Remove from arrays
|
|||
|
|
this.fields.splice(index, 1);
|
|||
|
|
|
|||
|
|
// Remove visual selection
|
|||
|
|
field.element.classList.remove('c4ai-selected-field');
|
|||
|
|
field.element.removeAttribute('data-c4ai-field');
|
|||
|
|
this.selectedElements.delete(field.element);
|
|||
|
|
|
|||
|
|
// Update UI
|
|||
|
|
this.updateToolbar();
|
|||
|
|
this.updateStats();
|
|||
|
|
this.generateSchema();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
editField(index) {
|
|||
|
|
const field = this.fields[index];
|
|||
|
|
if (!field) return;
|
|||
|
|
|
|||
|
|
// Remove any existing field dialogs first
|
|||
|
|
document.querySelectorAll('.c4ai-field-dialog').forEach(d => d.remove());
|
|||
|
|
|
|||
|
|
// Re-show the field dialog with existing values
|
|||
|
|
const dialog = document.createElement('div');
|
|||
|
|
dialog.className = 'c4ai-field-dialog';
|
|||
|
|
|
|||
|
|
const rect = field.element.getBoundingClientRect();
|
|||
|
|
dialog.style.cssText = `
|
|||
|
|
left: ${rect.left + window.scrollX}px;
|
|||
|
|
top: ${rect.bottom + window.scrollY + 10}px;
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
// Get available attributes
|
|||
|
|
const attributes = this.getElementAttributes(field.element);
|
|||
|
|
const attributeOptions = attributes.map(attr =>
|
|||
|
|
`<option value="${attr.name}" ${field.attribute === attr.name ? 'selected' : ''}>${attr.name}: "${attr.value.substring(0, 30)}${attr.value.length > 30 ? '...' : ''}"</option>`
|
|||
|
|
).join('');
|
|||
|
|
|
|||
|
|
dialog.innerHTML = `
|
|||
|
|
<div class="c4ai-field-dialog-content">
|
|||
|
|
<h4>Edit Field</h4>
|
|||
|
|
|
|||
|
|
<div class="c4ai-field-input">
|
|||
|
|
<label>Field Name:</label>
|
|||
|
|
<input type="text" id="c4ai-field-name" value="${field.name}" placeholder="e.g., title, price, description" autofocus>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="c4ai-field-input">
|
|||
|
|
<label>Field Type:</label>
|
|||
|
|
<select id="c4ai-field-type">
|
|||
|
|
<option value="text" ${field.type === 'text' ? 'selected' : ''}>Text Content</option>
|
|||
|
|
<option value="attribute" ${field.type === 'attribute' ? 'selected' : ''}>Attribute</option>
|
|||
|
|
<option value="link" ${field.type === 'link' ? 'selected' : ''}>Link (href)</option>
|
|||
|
|
<option value="image" ${field.type === 'image' ? 'selected' : ''}>Image (src)</option>
|
|||
|
|
<option value="list" ${field.type === 'list' ? 'selected' : ''}>List</option>
|
|||
|
|
<option value="nested" ${field.type === 'nested' ? 'selected' : ''}>Nested Object</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="c4ai-field-input" id="c4ai-attribute-select" style="display: ${field.type === 'attribute' ? 'block' : 'none'};">
|
|||
|
|
<label>Select Attribute:</label>
|
|||
|
|
<select id="c4ai-field-attribute">
|
|||
|
|
${attributeOptions}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="c4ai-field-preview">
|
|||
|
|
<strong>Preview Value:</strong>
|
|||
|
|
<div id="c4ai-preview-value">${field.value}</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="c4ai-field-selector">
|
|||
|
|
<strong>Selector (auto-generated):</strong>
|
|||
|
|
<div id="c4ai-selector-preview">${field.selector}</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="c4ai-field-actions">
|
|||
|
|
<button id="c4ai-field-save" class="c4ai-primary">✓ Update</button>
|
|||
|
|
<button id="c4ai-field-cancel">✗ Cancel</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
document.body.appendChild(dialog);
|
|||
|
|
|
|||
|
|
const nameInput = dialog.querySelector('#c4ai-field-name');
|
|||
|
|
const typeSelect = dialog.querySelector('#c4ai-field-type');
|
|||
|
|
const attributeSelect = dialog.querySelector('#c4ai-field-attribute');
|
|||
|
|
const attributeContainer = dialog.querySelector('#c4ai-attribute-select');
|
|||
|
|
const previewValue = dialog.querySelector('#c4ai-preview-value');
|
|||
|
|
const saveBtn = dialog.querySelector('#c4ai-field-save');
|
|||
|
|
const cancelBtn = dialog.querySelector('#c4ai-field-cancel');
|
|||
|
|
|
|||
|
|
// Update preview based on type selection
|
|||
|
|
const updatePreview = () => {
|
|||
|
|
const type = typeSelect.value;
|
|||
|
|
let value = '';
|
|||
|
|
|
|||
|
|
switch(type) {
|
|||
|
|
case 'text':
|
|||
|
|
value = field.element.textContent.trim();
|
|||
|
|
attributeContainer.style.display = 'none';
|
|||
|
|
break;
|
|||
|
|
case 'attribute':
|
|||
|
|
attributeContainer.style.display = 'block';
|
|||
|
|
value = field.element.getAttribute(attributeSelect.value) || '';
|
|||
|
|
break;
|
|||
|
|
case 'link':
|
|||
|
|
value = field.element.getAttribute('href') || field.element.querySelector('a')?.getAttribute('href') || '';
|
|||
|
|
attributeContainer.style.display = 'none';
|
|||
|
|
break;
|
|||
|
|
case 'image':
|
|||
|
|
value = field.element.getAttribute('src') || field.element.querySelector('img')?.getAttribute('src') || '';
|
|||
|
|
attributeContainer.style.display = 'none';
|
|||
|
|
break;
|
|||
|
|
case 'list':
|
|||
|
|
const listItems = field.element.querySelectorAll('li, option');
|
|||
|
|
value = `[${listItems.length} items]`;
|
|||
|
|
attributeContainer.style.display = 'none';
|
|||
|
|
break;
|
|||
|
|
case 'nested':
|
|||
|
|
value = '[Complex nested structure]';
|
|||
|
|
attributeContainer.style.display = 'none';
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
previewValue.textContent = value.substring(0, 100) + (value.length > 100 ? '...' : '');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
typeSelect.addEventListener('change', updatePreview);
|
|||
|
|
attributeSelect.addEventListener('change', updatePreview);
|
|||
|
|
|
|||
|
|
const save = () => {
|
|||
|
|
const fieldName = nameInput.value.trim();
|
|||
|
|
if (fieldName) {
|
|||
|
|
const type = typeSelect.value;
|
|||
|
|
|
|||
|
|
// Update field
|
|||
|
|
field.name = fieldName;
|
|||
|
|
field.type = type;
|
|||
|
|
field.value = previewValue.textContent;
|
|||
|
|
|
|||
|
|
// Update attribute if needed
|
|||
|
|
if (type === 'attribute') {
|
|||
|
|
field.attribute = attributeSelect.value;
|
|||
|
|
} else if (type === 'link') {
|
|||
|
|
field.type = 'attribute';
|
|||
|
|
field.attribute = 'href';
|
|||
|
|
} else if (type === 'image') {
|
|||
|
|
field.type = 'attribute';
|
|||
|
|
field.attribute = 'src';
|
|||
|
|
} else {
|
|||
|
|
delete field.attribute;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Update element attribute
|
|||
|
|
field.element.setAttribute('data-c4ai-field', fieldName);
|
|||
|
|
|
|||
|
|
this.updateToolbar();
|
|||
|
|
this.updateStats();
|
|||
|
|
this.generateSchema();
|
|||
|
|
}
|
|||
|
|
dialog.remove();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const cancel = () => {
|
|||
|
|
dialog.remove();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
saveBtn.addEventListener('click', save);
|
|||
|
|
cancelBtn.addEventListener('click', cancel);
|
|||
|
|
nameInput.addEventListener('keypress', (e) => {
|
|||
|
|
if (e.key === 'Enter') save();
|
|||
|
|
if (e.key === 'Escape') cancel();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
nameInput.focus();
|
|||
|
|
nameInput.select();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
updateStats() {
|
|||
|
|
chrome.runtime.sendMessage({
|
|||
|
|
action: 'updateStats',
|
|||
|
|
stats: {
|
|||
|
|
container: !!this.container,
|
|||
|
|
fields: this.fields.length
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
removeAllHighlights() {
|
|||
|
|
document.querySelectorAll('.c4ai-selected-container').forEach(el => {
|
|||
|
|
el.classList.remove('c4ai-selected-container');
|
|||
|
|
});
|
|||
|
|
document.querySelectorAll('.c4ai-selected-field').forEach(el => {
|
|||
|
|
el.classList.remove('c4ai-selected-field');
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// New helper methods for enhanced functionality
|
|||
|
|
getElementAttributes(element) {
|
|||
|
|
const attributes = [];
|
|||
|
|
for (const attr of element.attributes) {
|
|||
|
|
attributes.push({
|
|||
|
|
name: attr.name,
|
|||
|
|
value: attr.value
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
return attributes;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
generateSmartSelector(element, container) {
|
|||
|
|
// Smart selector generation with 2-level parent context
|
|||
|
|
const parts = [];
|
|||
|
|
let current = element;
|
|||
|
|
let depth = 0;
|
|||
|
|
|
|||
|
|
// Build path from element up to container (max 3 levels)
|
|||
|
|
while (current && current !== container && depth < 3) {
|
|||
|
|
let selector = current.tagName.toLowerCase();
|
|||
|
|
|
|||
|
|
// Add ID if available
|
|||
|
|
if (current.id && !current.id.includes(':') && !current.id.includes('[')) {
|
|||
|
|
selector = `#${CSS.escape(current.id)}`;
|
|||
|
|
parts.unshift(selector);
|
|||
|
|
break; // ID is unique enough
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Add classes (filter out dynamic/utility classes)
|
|||
|
|
const classes = Array.from(current.classList)
|
|||
|
|
.filter(c => !c.startsWith('c4ai-'))
|
|||
|
|
.filter(c => !c.includes('[') && !c.includes('(') && !c.includes(':'))
|
|||
|
|
.filter(c => c.length < 30)
|
|||
|
|
.slice(0, 2); // Max 2 classes
|
|||
|
|
|
|||
|
|
if (classes.length > 0) {
|
|||
|
|
selector += classes.map(c => `.${CSS.escape(c)}`).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Add data attributes for more specificity
|
|||
|
|
const dataAttrs = ['data-testid', 'data-id', 'data-test'];
|
|||
|
|
for (const attr of dataAttrs) {
|
|||
|
|
if (current.hasAttribute(attr)) {
|
|||
|
|
selector += `[${attr}="${CSS.escape(current.getAttribute(attr))}"]`;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Add nth-child if needed for disambiguation
|
|||
|
|
if (current.parentElement && depth === 0) {
|
|||
|
|
const siblings = Array.from(current.parentElement.children);
|
|||
|
|
const sameTagSiblings = siblings.filter(s => s.tagName === current.tagName);
|
|||
|
|
if (sameTagSiblings.length > 1) {
|
|||
|
|
const index = sameTagSiblings.indexOf(current) + 1;
|
|||
|
|
selector += `:nth-of-type(${index})`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
parts.unshift(selector);
|
|||
|
|
current = current.parentElement;
|
|||
|
|
depth++;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Create relative selector from container
|
|||
|
|
const fullSelector = parts.join(' > ');
|
|||
|
|
|
|||
|
|
// Test selector uniqueness within container
|
|||
|
|
try {
|
|||
|
|
const matches = container.querySelectorAll(fullSelector);
|
|||
|
|
if (matches.length === 1 && matches[0] === element) {
|
|||
|
|
return fullSelector;
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
// Invalid selector, continue with fallback
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Fallback to simple selector
|
|||
|
|
return parts[parts.length - 1] || element.tagName.toLowerCase();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
generateSchema() {
|
|||
|
|
if (!this.container || this.fields.length === 0) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Build schema object
|
|||
|
|
this.schema = {
|
|||
|
|
name: `${window.location.hostname} Schema`,
|
|||
|
|
baseSelector: this.container.selector,
|
|||
|
|
fields: this.fields.map(field => {
|
|||
|
|
const schemaField = {
|
|||
|
|
name: field.name,
|
|||
|
|
selector: field.selector,
|
|||
|
|
type: field.type
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (field.attribute) {
|
|||
|
|
schemaField.attribute = field.attribute;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return schemaField;
|
|||
|
|
})
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return this.schema;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
togglePreview() {
|
|||
|
|
this.previewMode = !this.previewMode;
|
|||
|
|
const previewBtn = document.getElementById('c4ai-preview');
|
|||
|
|
|
|||
|
|
if (this.previewMode) {
|
|||
|
|
previewBtn.innerHTML = '<span>🔄</span> Hide Preview';
|
|||
|
|
this.showPreview();
|
|||
|
|
} else {
|
|||
|
|
previewBtn.innerHTML = '<span>👁️</span> Preview Matches';
|
|||
|
|
this.clearPreview();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
showPreview() {
|
|||
|
|
if (!this.schema) {
|
|||
|
|
this.generateSchema();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.clearPreview();
|
|||
|
|
|
|||
|
|
// Find all matching containers
|
|||
|
|
const containers = document.querySelectorAll(this.schema.baseSelector);
|
|||
|
|
let successCount = 0;
|
|||
|
|
|
|||
|
|
containers.forEach((container, index) => {
|
|||
|
|
// Highlight container
|
|||
|
|
const containerBox = document.createElement('div');
|
|||
|
|
containerBox.className = 'c4ai-preview-container';
|
|||
|
|
const rect = container.getBoundingClientRect();
|
|||
|
|
containerBox.style.cssText = `
|
|||
|
|
position: absolute;
|
|||
|
|
left: ${rect.left + window.scrollX}px;
|
|||
|
|
top: ${rect.top + window.scrollY}px;
|
|||
|
|
width: ${rect.width}px;
|
|||
|
|
height: ${rect.height}px;
|
|||
|
|
pointer-events: none;
|
|||
|
|
z-index: 999997;
|
|||
|
|
`;
|
|||
|
|
document.body.appendChild(containerBox);
|
|||
|
|
this.previewElements.push(containerBox);
|
|||
|
|
|
|||
|
|
// Check each field
|
|||
|
|
let fieldsFound = 0;
|
|||
|
|
this.schema.fields.forEach(field => {
|
|||
|
|
try {
|
|||
|
|
const fieldElement = container.querySelector(field.selector);
|
|||
|
|
if (fieldElement) {
|
|||
|
|
fieldsFound++;
|
|||
|
|
// Highlight successful field
|
|||
|
|
const fieldBox = document.createElement('div');
|
|||
|
|
fieldBox.className = 'c4ai-preview-field-success';
|
|||
|
|
const fieldRect = fieldElement.getBoundingClientRect();
|
|||
|
|
fieldBox.style.cssText = `
|
|||
|
|
position: absolute;
|
|||
|
|
left: ${fieldRect.left + window.scrollX}px;
|
|||
|
|
top: ${fieldRect.top + window.scrollY}px;
|
|||
|
|
width: ${fieldRect.width}px;
|
|||
|
|
height: ${fieldRect.height}px;
|
|||
|
|
pointer-events: none;
|
|||
|
|
z-index: 999998;
|
|||
|
|
`;
|
|||
|
|
document.body.appendChild(fieldBox);
|
|||
|
|
this.previewElements.push(fieldBox);
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
// Invalid selector
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Add count badge
|
|||
|
|
const badge = document.createElement('div');
|
|||
|
|
badge.className = 'c4ai-preview-badge';
|
|||
|
|
badge.textContent = `${index + 1}`;
|
|||
|
|
badge.style.cssText = `
|
|||
|
|
position: absolute;
|
|||
|
|
left: ${rect.left + window.scrollX - 20}px;
|
|||
|
|
top: ${rect.top + window.scrollY - 20}px;
|
|||
|
|
z-index: 999999;
|
|||
|
|
`;
|
|||
|
|
document.body.appendChild(badge);
|
|||
|
|
this.previewElements.push(badge);
|
|||
|
|
|
|||
|
|
if (fieldsFound === this.schema.fields.length) {
|
|||
|
|
successCount++;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Update stats
|
|||
|
|
document.getElementById('c4ai-matches-count').textContent = `${containers.length} items`;
|
|||
|
|
document.getElementById('c4ai-schema-valid').textContent =
|
|||
|
|
successCount === containers.length ? '✓ Yes' : `⚠️ Partial (${successCount}/${containers.length})`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
clearPreview() {
|
|||
|
|
this.previewElements.forEach(el => el.remove());
|
|||
|
|
this.previewElements = [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async testSchema() {
|
|||
|
|
if (!this.schema) {
|
|||
|
|
this.generateSchema();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Extract data using schema
|
|||
|
|
const results = [];
|
|||
|
|
const containers = document.querySelectorAll(this.schema.baseSelector);
|
|||
|
|
|
|||
|
|
containers.forEach(container => {
|
|||
|
|
const item = {};
|
|||
|
|
|
|||
|
|
this.schema.fields.forEach(field => {
|
|||
|
|
try {
|
|||
|
|
const element = container.querySelector(field.selector);
|
|||
|
|
if (element) {
|
|||
|
|
if (field.type === 'text') {
|
|||
|
|
item[field.name] = element.textContent.trim();
|
|||
|
|
} else if (field.type === 'attribute' && field.attribute) {
|
|||
|
|
item[field.name] = element.getAttribute(field.attribute);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
item[field.name] = null;
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
item[field.name] = null;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
results.push(item);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Show results modal
|
|||
|
|
this.showResultsModal(results);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
showResultsModal(data) {
|
|||
|
|
const modal = document.createElement('div');
|
|||
|
|
modal.className = 'c4ai-code-modal';
|
|||
|
|
modal.innerHTML = `
|
|||
|
|
<div class="c4ai-code-modal-content">
|
|||
|
|
<div class="c4ai-code-modal-header">
|
|||
|
|
<h2>Extracted Data (${data.length} items)</h2>
|
|||
|
|
<button class="c4ai-close-modal" id="c4ai-close-results">✕</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="c4ai-code-modal-body">
|
|||
|
|
<pre class="c4ai-code-block"><code>${JSON.stringify(data, null, 2)}</code></pre>
|
|||
|
|
</div>
|
|||
|
|
<div class="c4ai-code-modal-footer">
|
|||
|
|
<button class="c4ai-action-btn c4ai-download-btn" id="c4ai-download-data">
|
|||
|
|
<span>⬇</span> Download JSON
|
|||
|
|
</button>
|
|||
|
|
<button class="c4ai-action-btn c4ai-download-btn" id="c4ai-download-python">
|
|||
|
|
<span>🐍</span> Download Python Code
|
|||
|
|
</button>
|
|||
|
|
<button class="c4ai-action-btn c4ai-copy-btn" id="c4ai-copy-data">
|
|||
|
|
<span>📋</span> Copy to Clipboard
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
document.body.appendChild(modal);
|
|||
|
|
|
|||
|
|
// Event listeners
|
|||
|
|
document.getElementById('c4ai-close-results').addEventListener('click', () => modal.remove());
|
|||
|
|
|
|||
|
|
document.getElementById('c4ai-download-data').addEventListener('click', () => {
|
|||
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|||
|
|
const url = URL.createObjectURL(blob);
|
|||
|
|
const a = document.createElement('a');
|
|||
|
|
a.href = url;
|
|||
|
|
a.download = `extracted_data_${Date.now()}.json`;
|
|||
|
|
a.click();
|
|||
|
|
URL.revokeObjectURL(url);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.getElementById('c4ai-copy-data').addEventListener('click', () => {
|
|||
|
|
navigator.clipboard.writeText(JSON.stringify(data, null, 2)).then(() => {
|
|||
|
|
const btn = document.getElementById('c4ai-copy-data');
|
|||
|
|
btn.innerHTML = '<span>✓</span> Copied!';
|
|||
|
|
setTimeout(() => {
|
|||
|
|
btn.innerHTML = '<span>📋</span> Copy to Clipboard';
|
|||
|
|
}, 2000);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.getElementById('c4ai-download-python').addEventListener('click', () => {
|
|||
|
|
const pythonCode = this.generatePythonCode();
|
|||
|
|
const blob = new Blob([pythonCode], { type: 'text/plain' });
|
|||
|
|
const url = URL.createObjectURL(blob);
|
|||
|
|
const a = document.createElement('a');
|
|||
|
|
a.href = url;
|
|||
|
|
a.download = `crawl4ai_schema_${Date.now()}.py`;
|
|||
|
|
a.click();
|
|||
|
|
URL.revokeObjectURL(url);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
exportSchema() {
|
|||
|
|
if (!this.schema) {
|
|||
|
|
this.generateSchema();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const blob = new Blob([JSON.stringify(this.schema, null, 2)], { type: 'application/json' });
|
|||
|
|
const url = URL.createObjectURL(blob);
|
|||
|
|
const a = document.createElement('a');
|
|||
|
|
a.href = url;
|
|||
|
|
a.download = `schema_${Date.now()}.json`;
|
|||
|
|
a.click();
|
|||
|
|
URL.revokeObjectURL(url);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async exportData() {
|
|||
|
|
await this.testSchema();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async exportMarkdown() {
|
|||
|
|
// Initialize markdown converter if not already done
|
|||
|
|
if (!this.markdownConverter) {
|
|||
|
|
this.markdownConverter = new MarkdownConverter();
|
|||
|
|
}
|
|||
|
|
if (!this.contentAnalyzer) {
|
|||
|
|
this.contentAnalyzer = new ContentAnalyzer();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Initialize markdown preview modal if not already done
|
|||
|
|
if (!this.markdownPreviewModal) {
|
|||
|
|
this.markdownPreviewModal = new MarkdownPreviewModal();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get all matching containers
|
|||
|
|
const containers = document.querySelectorAll(this.container.selector);
|
|||
|
|
if (containers.length === 0) {
|
|||
|
|
this.showNotification('No matching containers found', 'error');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Show modal with callback to generate markdown
|
|||
|
|
this.markdownPreviewModal.show(async (options) => {
|
|||
|
|
return await this.generateMarkdownFromSchema(options);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
async generateMarkdownFromSchema(options) {
|
|||
|
|
// Get all matching containers
|
|||
|
|
const containers = document.querySelectorAll(this.container.selector);
|
|||
|
|
const markdownParts = [];
|
|||
|
|
|
|||
|
|
for (let i = 0; i < containers.length; i++) {
|
|||
|
|
const container = containers[i];
|
|||
|
|
|
|||
|
|
// Add XPath header if enabled
|
|||
|
|
if (options.includeXPath) {
|
|||
|
|
const xpath = this.getXPath(container);
|
|||
|
|
markdownParts.push(`### Container ${i + 1} - XPath: \`${xpath}\`\n`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Extract data based on schema fields
|
|||
|
|
const extractedData = {};
|
|||
|
|
this.fields.forEach(field => {
|
|||
|
|
try {
|
|||
|
|
const element = container.querySelector(field.selector);
|
|||
|
|
if (element) {
|
|||
|
|
if (field.type === 'text') {
|
|||
|
|
extractedData[field.name] = element.textContent.trim();
|
|||
|
|
} else if (field.type === 'attribute' && field.attribute) {
|
|||
|
|
extractedData[field.name] = element.getAttribute(field.attribute);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
// Skip invalid selectors
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Convert container to markdown based on options
|
|||
|
|
const analysis = await this.contentAnalyzer.analyze([container]);
|
|||
|
|
const containerMarkdown = await this.markdownConverter.convert([container], {
|
|||
|
|
...options,
|
|||
|
|
analysis,
|
|||
|
|
extractedData // Pass extracted data for context
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Trim the markdown before adding
|
|||
|
|
const trimmedMarkdown = containerMarkdown.trim();
|
|||
|
|
markdownParts.push(trimmedMarkdown);
|
|||
|
|
|
|||
|
|
// Add separator if enabled and not last element
|
|||
|
|
if (options.addSeparators && i < containers.length - 1) {
|
|||
|
|
markdownParts.push('\n---\n');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return markdownParts.join('\n');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
getXPath(element) {
|
|||
|
|
if (element.id) {
|
|||
|
|
return `//*[@id="${element.id}"]`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const parts = [];
|
|||
|
|
let current = element;
|
|||
|
|
|
|||
|
|
while (current && current.nodeType === Node.ELEMENT_NODE) {
|
|||
|
|
let index = 0;
|
|||
|
|
let sibling = current.previousSibling;
|
|||
|
|
|
|||
|
|
while (sibling) {
|
|||
|
|
if (sibling.nodeType === Node.ELEMENT_NODE && sibling.nodeName === current.nodeName) {
|
|||
|
|
index++;
|
|||
|
|
}
|
|||
|
|
sibling = sibling.previousSibling;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const tagName = current.nodeName.toLowerCase();
|
|||
|
|
const part = index > 0 ? `${tagName}[${index + 1}]` : tagName;
|
|||
|
|
parts.unshift(part);
|
|||
|
|
|
|||
|
|
current = current.parentNode;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return '/' + parts.join('/');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
showNotification(message, type = 'success') {
|
|||
|
|
const notification = document.createElement('div');
|
|||
|
|
notification.className = `c4ai-notification c4ai-notification-${type}`;
|
|||
|
|
notification.textContent = message;
|
|||
|
|
|
|||
|
|
document.body.appendChild(notification);
|
|||
|
|
|
|||
|
|
// Animate in
|
|||
|
|
setTimeout(() => notification.classList.add('show'), 10);
|
|||
|
|
|
|||
|
|
// Remove after 3 seconds
|
|||
|
|
setTimeout(() => {
|
|||
|
|
notification.classList.remove('show');
|
|||
|
|
setTimeout(() => notification.remove(), 300);
|
|||
|
|
}, 3000);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
deployToCloud() {
|
|||
|
|
// Create cloud deployment modal
|
|||
|
|
const modal = document.createElement('div');
|
|||
|
|
modal.className = 'c4ai-code-modal';
|
|||
|
|
modal.innerHTML = `
|
|||
|
|
<div class="c4ai-cloud-modal-content">
|
|||
|
|
<div class="c4ai-cloud-header">
|
|||
|
|
<div class="c4ai-cloud-icon">🌩️</div>
|
|||
|
|
<h2>Deploy to Crawl4AI Cloud</h2>
|
|||
|
|
</div>
|
|||
|
|
<div class="c4ai-cloud-body">
|
|||
|
|
<div class="c4ai-cloud-features">
|
|||
|
|
<h3>🚀 Coming Soon!</h3>
|
|||
|
|
<p>Deploy your extraction schemas to the cloud with just one click:</p>
|
|||
|
|
<ul>
|
|||
|
|
<li>✨ <strong>Instant Deployment</strong> - Your schema live in seconds</li>
|
|||
|
|
<li>🌐 <strong>API Access</strong> - RESTful endpoints for your extractions</li>
|
|||
|
|
<li>⏰ <strong>Scheduled Runs</strong> - Automate data collection</li>
|
|||
|
|
<li>📊 <strong>Analytics Dashboard</strong> - Monitor your extractions</li>
|
|||
|
|
<li>🔄 <strong>Auto-scaling</strong> - Handle any volume seamlessly</li>
|
|||
|
|
</ul>
|
|||
|
|
</div>
|
|||
|
|
<div class="c4ai-cloud-cta">
|
|||
|
|
<p>Be the first to know when Crawl4AI Cloud launches!</p>
|
|||
|
|
<button class="c4ai-action-btn c4ai-primary-btn c4ai-waitlist-btn" id="c4ai-join-waitlist">
|
|||
|
|
<span>🎆</span> Join the Waiting List
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<button class="c4ai-close-modal" id="c4ai-close-cloud-modal">✕</button>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
document.body.appendChild(modal);
|
|||
|
|
|
|||
|
|
// Add event listeners
|
|||
|
|
document.getElementById('c4ai-close-cloud-modal').addEventListener('click', () => modal.remove());
|
|||
|
|
document.getElementById('c4ai-join-waitlist').addEventListener('click', () => {
|
|||
|
|
window.open('https://crawl4ai.com/join-waiting-list', '_blank');
|
|||
|
|
modal.remove();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Close on escape
|
|||
|
|
const escHandler = (e) => {
|
|||
|
|
if (e.key === 'Escape') {
|
|||
|
|
modal.remove();
|
|||
|
|
document.removeEventListener('keydown', escHandler);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
document.addEventListener('keydown', escHandler);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
generatePythonCode() {
|
|||
|
|
if (!this.schema) {
|
|||
|
|
this.generateSchema();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const schemaJson = JSON.stringify(this.schema, null, 2);
|
|||
|
|
|
|||
|
|
return `#!/usr/bin/env python3
|
|||
|
|
"""
|
|||
|
|
Generated by Crawl4AI Chrome Extension
|
|||
|
|
URL: ${window.location.href}
|
|||
|
|
Generated: ${new Date().toISOString()}
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import asyncio
|
|||
|
|
import json
|
|||
|
|
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig
|
|||
|
|
from crawl4ai import JsonCssExtractionStrategy
|
|||
|
|
|
|||
|
|
# The extraction schema generated from your selections
|
|||
|
|
EXTRACTION_SCHEMA = ${schemaJson}
|
|||
|
|
|
|||
|
|
async def extract_data(url: str = "${window.location.href}"):
|
|||
|
|
"""Extract data using the generated schema"""
|
|||
|
|
|
|||
|
|
# Configure browser (optional)
|
|||
|
|
browser_config = BrowserConfig(
|
|||
|
|
headless=True, # Set to False to see the browser
|
|||
|
|
verbose=False
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Configure extraction strategy
|
|||
|
|
extraction_strategy = JsonCssExtractionStrategy(schema=EXTRACTION_SCHEMA)
|
|||
|
|
|
|||
|
|
# Configure crawler
|
|||
|
|
crawler_config = CrawlerRunConfig(
|
|||
|
|
extraction_strategy=extraction_strategy,
|
|||
|
|
# Add more options as needed:
|
|||
|
|
# wait_for="css:.product", # Wait for specific elements
|
|||
|
|
# js_code="window.scrollTo(0, document.body.scrollHeight);", # Execute JS
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async with AsyncWebCrawler(config=browser_config) as crawler:
|
|||
|
|
result = await crawler.arun(
|
|||
|
|
url=url,
|
|||
|
|
config=crawler_config
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if result.success and result.extracted_content:
|
|||
|
|
data = json.loads(result.extracted_content)
|
|||
|
|
print(f"\\n✅ Successfully extracted {len(data)} items!")
|
|||
|
|
|
|||
|
|
# Save results
|
|||
|
|
with open('extracted_data.json', 'w', encoding='utf-8') as f:
|
|||
|
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
|||
|
|
|
|||
|
|
# Show sample results
|
|||
|
|
print("\\n📊 Sample results (first 2 items):")
|
|||
|
|
for i, item in enumerate(data[:2], 1):
|
|||
|
|
print(f"\\nItem {i}:")
|
|||
|
|
for key, value in item.items():
|
|||
|
|
print(f" {key}: {value}")
|
|||
|
|
|
|||
|
|
return data
|
|||
|
|
else:
|
|||
|
|
print("❌ Extraction failed:", result.error_message)
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
# Run the extraction
|
|||
|
|
data = asyncio.run(extract_data())
|
|||
|
|
|
|||
|
|
print("\\n🎯 Next steps:")
|
|||
|
|
print("1. Install Crawl4AI: pip install crawl4ai")
|
|||
|
|
print("2. Modify the URL or add multiple URLs")
|
|||
|
|
print("3. Customize crawler options as needed")
|
|||
|
|
print("4. Check 'extracted_data.json' for full results")
|
|||
|
|
`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Legacy code generation - kept for reference but no longer used
|
|||
|
|
/*
|
|||
|
|
generateCode() {
|
|||
|
|
const fieldDescriptions = this.fields.map(f =>
|
|||
|
|
`- ${f.name} (example: "${f.value.substring(0, 50)}...")`
|
|||
|
|
).join('\n');
|
|||
|
|
|
|||
|
|
return `#!/usr/bin/env python3
|
|||
|
|
"""
|
|||
|
|
Generated by Crawl4AI Chrome Extension
|
|||
|
|
URL: ${window.location.href}
|
|||
|
|
Generated: ${new Date().toISOString()}
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import asyncio
|
|||
|
|
import json
|
|||
|
|
from pathlib import Path
|
|||
|
|
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig
|
|||
|
|
from crawl4ai import JsonCssExtractionStrategy
|
|||
|
|
|
|||
|
|
# HTML snippet of the selected container element
|
|||
|
|
HTML_SNIPPET = """
|
|||
|
|
${this.container.html}
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
# Extraction query based on your field selections
|
|||
|
|
EXTRACTION_QUERY = """
|
|||
|
|
Create a JSON CSS extraction schema to extract the following fields:
|
|||
|
|
${fieldDescriptions}
|
|||
|
|
|
|||
|
|
The schema should handle multiple ${this.container.tagName} elements on the page.
|
|||
|
|
Each item should be extracted as a separate object in the results array.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
async def generate_schema():
|
|||
|
|
"""Generate extraction schema using LLM"""
|
|||
|
|
print("🔧 Generating extraction schema...")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# Generate the schema using Crawl4AI's built-in LLM integration
|
|||
|
|
schema = JsonCssExtractionStrategy.generate_schema(
|
|||
|
|
html=HTML_SNIPPET,
|
|||
|
|
query=EXTRACTION_QUERY,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Save the schema for reuse
|
|||
|
|
schema_path = Path('generated_schema.json')
|
|||
|
|
with open(schema_path, 'w') as f:
|
|||
|
|
json.dump(schema, f, indent=2)
|
|||
|
|
|
|||
|
|
print("✅ Schema generated successfully!")
|
|||
|
|
print(f"📄 Schema saved to: {schema_path}")
|
|||
|
|
print("\\nGenerated schema:")
|
|||
|
|
print(json.dumps(schema, indent=2))
|
|||
|
|
|
|||
|
|
return schema
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"❌ Error generating schema: {e}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
async def test_extraction(url: str = "${window.location.href}"):
|
|||
|
|
"""Test the generated schema on the actual webpage"""
|
|||
|
|
print("\\n🧪 Testing extraction on live webpage...")
|
|||
|
|
|
|||
|
|
# Load the generated schema
|
|||
|
|
try:
|
|||
|
|
with open('generated_schema.json', 'r') as f:
|
|||
|
|
schema = json.load(f)
|
|||
|
|
except FileNotFoundError:
|
|||
|
|
print("❌ Schema file not found. Run generate_schema() first.")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# Configure browser
|
|||
|
|
browser_config = BrowserConfig(
|
|||
|
|
headless=True,
|
|||
|
|
verbose=False
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Configure extraction
|
|||
|
|
crawler_config = CrawlerRunConfig(
|
|||
|
|
extraction_strategy=JsonCssExtractionStrategy(schema=schema)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async with AsyncWebCrawler(config=browser_config) as crawler:
|
|||
|
|
result = await crawler.arun(
|
|||
|
|
url=url,
|
|||
|
|
config=crawler_config
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if result.success and result.extracted_content:
|
|||
|
|
data = json.loads(result.extracted_content)
|
|||
|
|
print(f"\\n✅ Successfully extracted {len(data)} items!")
|
|||
|
|
|
|||
|
|
# Save results
|
|||
|
|
with open('extracted_data.json', 'w') as f:
|
|||
|
|
json.dump(data, f, indent=2)
|
|||
|
|
|
|||
|
|
# Show sample results
|
|||
|
|
print("\\n📊 Sample results (first 2 items):")
|
|||
|
|
for i, item in enumerate(data[:2], 1):
|
|||
|
|
print(f"\\nItem {i}:")
|
|||
|
|
for key, value in item.items():
|
|||
|
|
print(f" {key}: {value}")
|
|||
|
|
else:
|
|||
|
|
print("❌ Extraction failed:", result.error_message)
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
# Step 1: Generate the schema from HTML snippet
|
|||
|
|
asyncio.run(generate_schema())
|
|||
|
|
|
|||
|
|
# Step 2: Test extraction on the live webpage
|
|||
|
|
# Uncomment the line below to test extraction:
|
|||
|
|
# asyncio.run(test_extraction())
|
|||
|
|
|
|||
|
|
print("\\n🎯 Next steps:")
|
|||
|
|
print("1. Review the generated schema in 'generated_schema.json'")
|
|||
|
|
print("2. Uncomment the test_extraction() line to test on the live site")
|
|||
|
|
print("3. Use the schema in your Crawl4AI projects!")
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
return code;
|
|||
|
|
}
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
/* Legacy modal - no longer used
|
|||
|
|
showCodeModal(code) {
|
|||
|
|
// Create modal
|
|||
|
|
this.codeModal = document.createElement('div');
|
|||
|
|
this.codeModal.className = 'c4ai-code-modal';
|
|||
|
|
this.codeModal.innerHTML = `
|
|||
|
|
<div class="c4ai-code-modal-content">
|
|||
|
|
<div class="c4ai-code-modal-header">
|
|||
|
|
<h2>Generated Python Code</h2>
|
|||
|
|
<button class="c4ai-close-modal" id="c4ai-close-modal">✕</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="c4ai-code-modal-body">
|
|||
|
|
<pre class="c4ai-code-block"><code class="language-python">${window.C4AI_Utils.escapeHtml(code)}</code></pre>
|
|||
|
|
</div>
|
|||
|
|
<div class="c4ai-code-modal-footer">
|
|||
|
|
<button class="c4ai-action-btn c4ai-cloud-btn" id="c4ai-run-cloud" disabled>
|
|||
|
|
<span>☁️</span> Run on C4AI Cloud (Coming Soon)
|
|||
|
|
</button>
|
|||
|
|
<button class="c4ai-action-btn c4ai-download-btn" id="c4ai-download-code">
|
|||
|
|
<span>⬇</span> Download Code
|
|||
|
|
</button>
|
|||
|
|
<button class="c4ai-action-btn c4ai-copy-btn" id="c4ai-copy-code">
|
|||
|
|
<span>📋</span> Copy to Clipboard
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
document.body.appendChild(this.codeModal);
|
|||
|
|
|
|||
|
|
// Add event listeners
|
|||
|
|
document.getElementById('c4ai-close-modal').addEventListener('click', () => {
|
|||
|
|
this.codeModal.remove();
|
|||
|
|
this.codeModal = null;
|
|||
|
|
// Don't stop the capture session
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.getElementById('c4ai-download-code').addEventListener('click', () => {
|
|||
|
|
chrome.runtime.sendMessage({
|
|||
|
|
action: 'downloadCode',
|
|||
|
|
code: code,
|
|||
|
|
filename: `crawl4ai_schema_${Date.now()}.py`
|
|||
|
|
}, (response) => {
|
|||
|
|
if (response && response.success) {
|
|||
|
|
const btn = document.getElementById('c4ai-download-code');
|
|||
|
|
const originalHTML = btn.innerHTML;
|
|||
|
|
btn.innerHTML = '<span>✓</span> Downloaded!';
|
|||
|
|
setTimeout(() => {
|
|||
|
|
btn.innerHTML = originalHTML;
|
|||
|
|
}, 2000);
|
|||
|
|
} else {
|
|||
|
|
console.error('Download failed:', response?.error);
|
|||
|
|
alert('Download failed. Please check your browser settings.');
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.getElementById('c4ai-copy-code').addEventListener('click', () => {
|
|||
|
|
navigator.clipboard.writeText(code).then(() => {
|
|||
|
|
const btn = document.getElementById('c4ai-copy-code');
|
|||
|
|
btn.innerHTML = '<span>✓</span> Copied!';
|
|||
|
|
setTimeout(() => {
|
|||
|
|
btn.innerHTML = '<span>📋</span> Copy to Clipboard';
|
|||
|
|
}, 2000);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Apply syntax highlighting
|
|||
|
|
window.C4AI_Utils.applySyntaxHighlighting(this.codeModal.querySelector('.language-python'));
|
|||
|
|
}
|
|||
|
|
*/
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Export for use in content script
|
|||
|
|
if (typeof window !== 'undefined') {
|
|||
|
|
window.Click2Crawl = Click2Crawl;
|
|||
|
|
}
|