didi-lot3-frontend/extension/content.js
Top Clossers cec967f953 Livrare Lot 3 (Frontend): aplicație web, aplicație mobilă Android, extensie browser
- Surse complete web (React/Vite) + mobil (React Native/Expo) + extensie (MV3)
- Documentație de livrare: ghid utilizare, matrice trasabilitate cerințe, raport testare furnizor
- Artefacte binare: imagine Docker didi-frontend:lot3-1.0, APK, extensie v3.2.6 + SHA256SUMS
- Configurare adresă platformă externalizată (build args / .env / config.js)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 12:40:40 +03:00

1428 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// DIDI Content Extractor - Content Script
// Extracts content from social media posts
// Facebook Extractor
class FacebookExtractor {
constructor() {
this.platform = 'facebook';
}
findPostContainer(element) {
let current = element;
let depth = 0;
const maxDepth = 30;
console.log('[Facebook] Starting search for post container from:', element.tagName);
while (current && depth < maxDepth) {
if (current.matches) {
// Standard Feed posts
if (current.matches('[role="article"]')) {
console.log('[Facebook] Found article container at depth:', depth);
return current;
}
// Groups posts, Stories, alternative layouts
if (current.matches('div[data-pagelet*="FeedUnit"]') ||
current.matches('div[class*="userContentWrapper"]')) {
console.log('[Facebook] Found pagelet container at depth:', depth);
return current;
}
// Look for divs that contain both author and content
// This is more generic and works across different FB layouts
const hasAuthor = current.querySelector('h2 a[role="link"], h3 a[role="link"], h4 a[role="link"]');
const hasContent = current.querySelector('div[dir="auto"]');
if (hasAuthor && hasContent && depth > 3) {
console.log('[Facebook] Found generic post container at depth:', depth);
return current;
}
}
current = current.parentElement;
depth++;
}
console.log('[Facebook] No post container found after searching', maxDepth, 'levels');
return null;
}
extractAuthor(postElement) {
const authorSelectors = [
'h2 a[role="link"]',
'h3 a[role="link"]',
'a[aria-label*="profile"]',
'strong > a'
];
for (const selector of authorSelectors) {
const authorEl = postElement.querySelector(selector);
if (authorEl && authorEl.textContent.trim()) {
const authorName = authorEl.textContent.trim();
console.log('[FB] Found author:', authorName);
return authorName;
}
}
console.log('[FB] Author not found');
return 'Unknown';
}
extractText(postElement) {
const textParts = [];
const seenTexts = new Set();
const textSelectors = [
'div[dir="auto"][style*="text-align"]',
'[data-ad-comet-preview="message"]',
'div[data-ad-preview="message"]'
];
for (const selector of textSelectors) {
const elements = postElement.querySelectorAll(selector);
elements.forEach(el => {
const text = el.textContent.trim();
const isUIText = [
'Like', 'Comment', 'Share', 'Send',
'Vezi mai mult', 'See more', 'Show more',
'Just now', 'min', 'hr', 'hrs',
'·', '•'
].some(noise => text === noise || text.length < 3);
if (!isUIText && !seenTexts.has(text) && text.length > 10) {
const hasNoTextChildren = Array.from(el.children).every(child =>
!child.textContent || child.textContent.trim().length === 0
);
if (hasNoTextChildren || text.length > 50) {
textParts.push(text);
seenTexts.add(text);
}
}
});
}
const fullText = textParts.join('\n\n').trim();
console.log('[FB] Extracted text length:', fullText.length);
return fullText;
}
// Extract ALL images from post (not just primary)
extractAllImages(postElement) {
const images = postElement.querySelectorAll('img[src]');
const extractedImages = [];
console.log('[FB] Found total images:', images.length);
for (const img of images) {
const src = img.src || img.getAttribute('data-src');
const width = img.naturalWidth || img.width;
const height = img.naturalHeight || img.height;
// Filter out emoji, icons, avatars, profile pics
const isValidImage =
src &&
!src.includes('emoji') &&
!src.includes('icon') &&
!src.includes('avatar') &&
!src.includes('profile') &&
width >= 200 &&
height >= 200 &&
!img.closest('a[aria-label*="profile"]');
if (isValidImage) {
extractedImages.push({
url: src,
width: width,
height: height,
alt: img.alt || ''
});
}
}
console.log('[FB] Extracted valid images:', extractedImages.length);
return extractedImages;
}
// Keep for backward compatibility
extractPrimaryImage(postElement) {
const allImages = this.extractAllImages(postElement);
return allImages.length > 0 ? allImages[0] : null;
}
extractTimestamp(postElement) {
const timeSelectors = [
'a[aria-label*="ago"]',
'a[aria-label*="min"]',
'a[aria-label*="hour"]',
'abbr',
'span[title]'
];
for (const selector of timeSelectors) {
const timeEl = postElement.querySelector(selector);
if (timeEl) {
const timestamp = timeEl.getAttribute('title') ||
timeEl.getAttribute('aria-label') ||
timeEl.textContent;
console.log('[FB] Found timestamp:', timestamp);
return timestamp;
}
}
return new Date().toISOString();
}
extractPostUrl(postElement) {
const permalinkSelectors = [
'a[aria-label*="ago"]',
'a[href*="/posts/"]',
'a[href*="/photos/"]',
'a[href*="/videos/"]'
];
for (const selector of permalinkSelectors) {
const link = postElement.querySelector(selector);
if (link && link.href) {
console.log('[FB] Found post URL:', link.href);
return link.href;
}
}
return window.location.href;
}
// Extract all external links from post
extractLinks(postElement) {
const links = [];
const anchorElements = postElement.querySelectorAll('a[href]');
anchorElements.forEach(anchor => {
const href = anchor.href;
// Filter out internal Facebook navigation links
const isExternalLink =
href &&
!href.includes('/profile/') &&
!href.includes('/groups/') &&
!href.includes('/hashtag/') &&
!href.includes('facebook.com/photo') &&
!href.includes('facebook.com/watch') &&
(href.startsWith('http://') || href.startsWith('https://')) &&
!href.includes('facebook.com');
if (isExternalLink && !links.includes(href)) {
links.push(href);
}
});
console.log('[FB] Extracted external links:', links.length);
return links;
}
extract(element) {
console.log('[FB] Starting Facebook extraction...');
const postContainer = this.findPostContainer(element);
if (!postContainer) {
console.log('[FB] No post container found, returning null');
return null;
}
const author = this.extractAuthor(postContainer);
const text = this.extractText(postContainer);
const allImages = this.extractAllImages(postContainer);
const primaryImage = allImages.length > 0 ? allImages[0] : null;
const links = this.extractLinks(postContainer);
const timestamp = this.extractTimestamp(postContainer);
const postUrl = this.extractPostUrl(postContainer);
let contentType = 'text';
if (text && allImages.length > 0) {
contentType = 'text+image';
} else if (allImages.length > 0) {
contentType = 'image';
}
const result = {
platform: this.platform,
type: contentType,
author: author,
text: text,
image: primaryImage, // Keep for backward compatibility
images: allImages, // NEW: All images
links: links, // NEW: External links
timestamp: timestamp,
postUrl: postUrl,
extractedAt: new Date().toISOString()
};
console.log('[FB] Extraction complete:', {
type: result.type,
author: result.author,
textLength: result.text.length,
imageCount: allImages.length,
linkCount: links.length
});
return result;
}
}
// Twitter Extractor
class TwitterExtractor {
constructor() {
this.platform = 'twitter';
}
findPostContainer(element) {
let current = element;
let depth = 0;
const maxDepth = 20;
while (current && depth < maxDepth) {
if (current.matches && current.matches('article[data-testid="tweet"]')) {
console.log('[Twitter] Found tweet container at depth:', depth);
return current;
}
current = current.parentElement;
depth++;
}
console.log('[Twitter] No tweet container found');
return null;
}
extractAuthor(tweetElement) {
const authorSelectors = [
'[data-testid="User-Name"] span',
'a[role="link"] span'
];
for (const selector of authorSelectors) {
const authorEl = tweetElement.querySelector(selector);
if (authorEl && authorEl.textContent.trim() && !authorEl.textContent.includes('@')) {
const authorName = authorEl.textContent.trim();
console.log('[Twitter] Found author:', authorName);
return authorName;
}
}
return 'Unknown';
}
extractText(tweetElement) {
const textEl = tweetElement.querySelector('[data-testid="tweetText"]');
if (textEl) {
const text = textEl.textContent.trim();
console.log('[Twitter] Extracted text length:', text.length);
return text;
}
console.log('[Twitter] No text found');
return '';
}
extractPrimaryImage(tweetElement) {
const imgSelectors = [
'[data-testid="tweetPhoto"] img',
'img[alt*="Image"]'
];
for (const selector of imgSelectors) {
const img = tweetElement.querySelector(selector);
if (img && img.src) {
console.log('[Twitter] Found primary image');
return {
url: img.src,
width: img.naturalWidth || img.width,
height: img.naturalHeight || img.height,
alt: img.alt || ''
};
}
}
console.log('[Twitter] No primary image found');
return null;
}
extractTimestamp(tweetElement) {
const timeEl = tweetElement.querySelector('time');
if (timeEl) {
const timestamp = timeEl.getAttribute('datetime') || timeEl.textContent;
console.log('[Twitter] Found timestamp:', timestamp);
return timestamp;
}
return new Date().toISOString();
}
extractPostUrl(tweetElement) {
const timeLink = tweetElement.querySelector('time');
if (timeLink && timeLink.parentElement && timeLink.parentElement.href) {
console.log('[Twitter] Found tweet URL:', timeLink.parentElement.href);
return timeLink.parentElement.href;
}
return window.location.href;
}
extract(element) {
console.log('[Twitter] Starting Twitter extraction...');
const tweetContainer = this.findPostContainer(element);
if (!tweetContainer) {
console.log('[Twitter] No tweet container found');
return null;
}
const author = this.extractAuthor(tweetContainer);
const text = this.extractText(tweetContainer);
const primaryImage = this.extractPrimaryImage(tweetContainer);
const timestamp = this.extractTimestamp(tweetContainer);
const postUrl = this.extractPostUrl(tweetContainer);
let contentType = 'text';
if (text && primaryImage) {
contentType = 'text+image';
} else if (primaryImage) {
contentType = 'image';
}
const result = {
platform: this.platform,
type: contentType,
author: author,
text: text,
image: primaryImage,
timestamp: timestamp,
postUrl: postUrl,
extractedAt: new Date().toISOString()
};
console.log('[Twitter] Extraction complete:', {
type: result.type,
author: result.author,
textLength: result.text.length,
hasImage: !!result.image
});
return result;
}
}
let selectionBox = null;
let isSelecting = false;
let selectedElement = null;
let currentPlatform = null;
let extractor = null;
// Snipping tool variables
let isSnipping = false;
let snipStartX = 0;
let snipStartY = 0;
let snipBox = null;
console.log('[DIDI] Content script loaded on:', window.location.href);
// Initialize extension
function init() {
console.log('[DIDI] Content script initialized');
createSelectionBox();
// Listen for messages from popup
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
console.log('[DIDI] Message received:', message);
if (message.action === 'activateSelection') {
currentPlatform = message.platform;
console.log('[DIDI] Activating selection for platform:', currentPlatform);
// Initialize platform-specific extractor
if (currentPlatform === 'facebook') {
extractor = new FacebookExtractor();
console.log('[DIDI] Initialized Facebook extractor');
} else if (currentPlatform === 'twitter') {
extractor = new TwitterExtractor();
console.log('[DIDI] Initialized Twitter extractor');
}
activateSelectionMode();
sendResponse({ success: true });
}
if (message.action === 'activateSnipping') {
console.log('[DIDI] Activating snipping mode');
ensureUploadConsent().then((accepted) => {
if (accepted) activateSnippingMode();
sendResponse({ success: accepted });
});
return true;
}
if (message.action === 'activateVideoSnipping') {
console.log('[DIDI] Activating video snipping mode');
ensureUploadConsent().then((accepted) => {
if (accepted) activateVideoSnippingMode();
sendResponse({ success: accepted });
});
return true;
}
if (message.action === 'requestUploadConsent') {
ensureUploadConsent().then((accepted) => sendResponse({ accepted }));
return true;
}
if (message.action === 'showToast') {
showNotification(message.message, message.type || 'info');
sendResponse({ success: true });
}
if (message.action === 'showAnalysis') {
console.log('[DIDI] Showing analysis result modal (pushed from background)');
showAnalysisResult(message.analysis, message.filename);
sendResponse({ success: true });
}
if (message.action === 'analysisFailed') {
console.warn('[DIDI] Analysis failed:', message.error);
showNotification('❌ Analysis failed: ' + (message.error || 'unknown'), 'error');
sendResponse({ success: true });
}
return true; // Keep channel open
});
}
// Create selection box overlay
function createSelectionBox() {
console.log('[DIDI] Creating selection box');
// Remove existing box if any
const existing = document.getElementById('didi-selection-box');
if (existing) {
existing.remove();
}
selectionBox = document.createElement('div');
selectionBox.id = 'didi-selection-box';
selectionBox.style.cssText = `
position: absolute;
border: 3px solid #009198;
background: rgba(0,145,152, 0.1);
pointer-events: none;
z-index: 999999;
display: none;
border-radius: 8px;
box-shadow: 0 0 20px rgba(0,145,152, 0.4);
`;
document.body.appendChild(selectionBox);
console.log('[DIDI] Selection box created');
}
// Activate selection mode
function activateSelectionMode() {
console.log('[DIDI] Selection mode activating...');
isSelecting = true;
document.body.style.cursor = 'crosshair';
// Add event listeners
document.addEventListener('mousemove', handleMouseMove, true);
document.addEventListener('click', handleClick, true);
showNotification('✓ Selection mode active - Hover over any element and click');
console.log('[DIDI] Selection mode activated - cursor should be crosshair');
}
// Deactivate selection mode
function deactivateSelectionMode() {
console.log('[DIDI] Selection mode deactivating...');
isSelecting = false;
document.body.style.cursor = 'default';
if (selectionBox) {
selectionBox.style.display = 'none';
}
document.removeEventListener('mousemove', handleMouseMove, true);
document.removeEventListener('click', handleClick, true);
showNotification('Selection mode deactivated');
}
// Handle mouse movement - highlight post containers
function handleMouseMove(e) {
if (!isSelecting || !extractor) return;
// Find the post container using platform-specific extractor
const postContainer = extractor.findPostContainer(e.target);
if (!postContainer || !selectionBox) return;
// Highlight the entire post container
const rect = postContainer.getBoundingClientRect();
selectionBox.style.display = 'block';
selectionBox.style.top = (rect.top + window.scrollY) + 'px';
selectionBox.style.left = (rect.left + window.scrollX) + 'px';
selectionBox.style.width = rect.width + 'px';
selectionBox.style.height = rect.height + 'px';
selectedElement = postContainer;
}
// Handle click - extract content
function handleClick(e) {
console.log('[DIDI] Click event detected!');
if (!isSelecting || !extractor) {
console.log('[DIDI] Not in selecting mode or no extractor');
return;
}
if (!selectedElement) {
console.log('[DIDI] No element selected');
return;
}
e.preventDefault();
e.stopPropagation();
console.log('[DIDI] Extracting from element:', selectedElement);
// Use platform-specific extractor
const content = extractor.extract(selectedElement);
console.log('[DIDI] Extracted content:', content);
if (content) {
// Store in chrome storage FIRST
chrome.storage.local.set({
[`${currentPlatform}_content`]: content,
lastExtractedContent: content,
lastExtractedPlatform: currentPlatform,
extractedAt: new Date().toISOString()
}, () => {
console.log('[DIDI] Content stored in chrome.storage');
});
// Then send to popup (if it's open)
chrome.runtime.sendMessage({
action: 'contentExtracted',
data: content,
platform: currentPlatform
}, (response) => {
console.log('[DIDI] Message sent to popup, response:', response);
});
// Show detailed notification — background starts the analysis automatically
const summary = `✓ Postare extrasă (${content.type})\n` +
`📝 Text: ${content.text ? content.text.length + ' caractere' : 'fără'}\n` +
`🔗 Linkuri: ${content.links ? content.links.length : 0}\n\n` +
`DiDi analizează… rezultatul apare aici în 3090s.`;
showNotification(summary, 'success');
} else {
console.log('[DIDI] No content found');
showNotification('No content found', 'error');
}
deactivateSelectionMode();
}
// Legacy extraction function - no longer used
// Platform-specific extraction is now handled by FacebookExtractor and TwitterExtractor classes
// === UPLOAD CONSENT (cerință caiet: consimțământ explicit pentru fișiere/capturi) ===
// Shown once before the first capture/image upload; the choice is persisted.
function ensureUploadConsent() {
return new Promise((resolve) => {
chrome.storage.local.get(['didiUploadConsent'], (data) => {
if (data.didiUploadConsent === true) return resolve(true);
document.querySelectorAll('.didi-consent-modal').forEach((n) => n.remove());
const overlay = document.createElement('div');
overlay.className = 'didi-consent-modal';
overlay.style.cssText = `
position: fixed; inset: 0; z-index: 10000000;
background: rgba(13, 20, 36, 0.55);
display: flex; align-items: center; justify-content: center;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
`;
const card = document.createElement('div');
card.setAttribute('role', 'alertdialog');
card.setAttribute('aria-modal', 'true');
card.setAttribute('aria-label', 'Consimțământ pentru trimiterea capturii');
card.style.cssText = `
background: #fbfbfd; color: #0d1424; max-width: 420px; margin: 16px;
border-radius: 22px; padding: 24px; box-shadow: 0 24px 64px rgba(13,20,36,0.35);
border: 1px solid rgba(13,20,36,0.08); font-size: 14px; line-height: 1.6;
`;
card.innerHTML = `
<div style="font-weight: 700; font-size: 16px; letter-spacing: -0.02em; margin-bottom: 10px;">
Consimțământ pentru analiză
</div>
<div style="margin-bottom: 16px;">
Captura sau imaginea selectată va fi <strong>trimisă platformei DiDi</strong> pentru analiză
anti-dezinformare. Conținutul este folosit exclusiv pentru generarea raportului de analiză,
conform politicii de confidențialitate. Trimite doar fragmentul selectat — nimic în plus.
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button type="button" class="didi-consent-decline" style="
padding: 9px 16px; border-radius: 12px; border: 1px solid rgba(13,20,36,0.15);
background: transparent; color: #0d1424; font-size: 13px; font-weight: 600; cursor: pointer;
">Anulează</button>
<button type="button" class="didi-consent-accept" style="
padding: 9px 16px; border-radius: 12px; border: none;
background: #009198; color: #ffffff; font-size: 13px; font-weight: 600; cursor: pointer;
">Sunt de acord</button>
</div>
`;
overlay.appendChild(card);
document.body.appendChild(overlay);
const finish = (accepted) => {
overlay.remove();
if (accepted) {
chrome.storage.local.set({ didiUploadConsent: true, didiUploadConsentAt: new Date().toISOString() });
}
resolve(accepted);
};
card.querySelector('.didi-consent-accept').addEventListener('click', () => finish(true));
card.querySelector('.didi-consent-decline').addEventListener('click', () => finish(false));
overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') finish(false); });
card.querySelector('.didi-consent-accept').focus();
});
});
}
// Show notification
function showNotification(message, type = 'info') {
console.log('[DIDI] Showing notification:', message, type);
// Remove existing notifications
const existing = document.querySelectorAll('.didi-notification');
existing.forEach(n => n.remove());
const notification = document.createElement('div');
notification.className = 'didi-notification';
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 16px 24px;
background: ${type === 'success' ? '#009198' : type === 'error' ? '#E63946' : '#009198'};
color: white;
border-radius: 12px;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
font-weight: 500;
line-height: 1.6;
z-index: 9999999;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
animation: slideIn 0.3s ease;
white-space: pre-wrap;
max-width: 350px;
`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.animation = 'slideOut 0.3s ease';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
// === ANALYSIS RESULT MODAL — uses shared DidiRender module (render.js) ===
function showAnalysisResult(analysis, filename) {
console.log('[DIDI] Showing analysis result modal');
if (!window.DidiRender || typeof window.DidiRender.renderModalHTML !== 'function') {
console.error('[DIDI] DidiRender not loaded — render.js was not injected. Reload the page.');
showNotification('⚠️ Result ready but renderer missing — reload this page and check History', 'error');
return;
}
document.querySelectorAll('.didi-analysis-modal').forEach((n) => n.remove());
saveToLocalHistory(analysis, filename);
const modal = document.createElement('div');
modal.className = 'didi-analysis-modal';
modal.innerHTML = window.DidiRender.renderModalHTML(analysis);
// Inject animation keyframes once
if (!document.getElementById('didi-modal-anim-styles')) {
const animStyle = document.createElement('style');
animStyle.id = 'didi-modal-anim-styles';
animStyle.textContent = '@keyframes didiFadeIn{from{opacity:0}to{opacity:1}}@keyframes didiSlideUp{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:translateY(0)}}';
document.head.appendChild(animStyle);
}
document.body.appendChild(modal);
// Wire close buttons (modal markup uses class hooks).
const closeBtn = modal.querySelector('.didi-modal-close');
const gotItBtn = modal.querySelector('.didi-modal-gotit');
if (closeBtn) closeBtn.addEventListener('click', () => modal.remove());
if (gotItBtn) gotItBtn.addEventListener('click', () => modal.remove());
// Close on click outside the inner card (= click on the dark overlay).
// The overlay is the modal's first child; the inner card is its grandchild.
// We must compare against the actual overlay element, not "first element child"
// which would also fire when clicking on the inner card's siblings.
const overlay = modal.firstElementChild; // the fullscreen <div> with rgba(5,5,16,0.88)
if (overlay) {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) modal.remove(); // click hit the dark padding only
});
}
}
// Save analysis to local chrome.storage history
function saveToLocalHistory(analysis, filename) {
chrome.storage.local.get(['didiHistory'], (data) => {
const history = data.didiHistory || [];
history.unshift({
analysis: analysis,
filename: filename,
timestamp: new Date().toISOString(),
url: window.location.href
});
// Keep max 50 items
if (history.length > 50) history.length = 50;
chrome.storage.local.set({ didiHistory: history });
});
}
// ===== SNIPPING TOOL MODE =====
function activateSnippingMode() {
console.log('[DIDI] Snipping mode activating...');
isSnipping = true;
document.body.style.cursor = 'crosshair';
// Create snipping overlay
createSnippingOverlay();
// Add event listeners
document.addEventListener('mousedown', handleSnipStart, true);
document.addEventListener('mousemove', handleSnipDrag, true);
document.addEventListener('mouseup', handleSnipEnd, true);
document.addEventListener('keydown', handleSnipCancel, true);
showNotification('✂️ Snipping mode active - Click and drag to select area (ESC to cancel)', 'info');
}
function createSnippingOverlay() {
// Remove existing overlay
const existing = document.getElementById('didi-snip-overlay');
if (existing) existing.remove();
// Create semi-transparent overlay
const overlay = document.createElement('div');
overlay.id = 'didi-snip-overlay';
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.3);
z-index: 999998;
pointer-events: none;
`;
document.body.appendChild(overlay);
// Create snip box
snipBox = document.createElement('div');
snipBox.id = 'didi-snip-box';
snipBox.style.cssText = `
position: absolute;
border: 3px solid #009198;
background: rgba(0,145,152, 0.15);
z-index: 999999;
display: none;
box-shadow: 0 0 20px rgba(0,145,152, 0.6), inset 0 0 20px rgba(0,145,152, 0.1);
pointer-events: none;
`;
document.body.appendChild(snipBox);
console.log('[DIDI] Snip box created');
}
function handleSnipStart(e) {
if (!isSnipping) return;
e.preventDefault();
e.stopPropagation();
snipStartX = e.pageX;
snipStartY = e.pageY;
snipBox.style.left = snipStartX + 'px';
snipBox.style.top = snipStartY + 'px';
snipBox.style.width = '0px';
snipBox.style.height = '0px';
snipBox.style.display = 'block';
}
function handleSnipDrag(e) {
if (!isSnipping || !snipBox || snipBox.style.display === 'none') return;
e.preventDefault();
const currentX = e.pageX;
const currentY = e.pageY;
const width = Math.abs(currentX - snipStartX);
const height = Math.abs(currentY - snipStartY);
const left = Math.min(currentX, snipStartX);
const top = Math.min(currentY, snipStartY);
snipBox.style.width = width + 'px';
snipBox.style.height = height + 'px';
snipBox.style.left = left + 'px';
snipBox.style.top = top + 'px';
// Add size indicator inside the box
snipBox.innerHTML = `
<div style="
position: absolute;
top: 5px;
left: 5px;
background: rgba(0,145,152, 0.9);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-family: 'Inter', monospace;
font-size: 12px;
font-weight: 600;
pointer-events: none;
">
${Math.round(width)} × ${Math.round(height)}
</div>
`;
}
function handleSnipEnd(e) {
if (!isSnipping || !snipBox || snipBox.style.display === 'none') return;
e.preventDefault();
e.stopPropagation();
const rect = {
left: parseInt(snipBox.style.left),
top: parseInt(snipBox.style.top),
width: parseInt(snipBox.style.width),
height: parseInt(snipBox.style.height)
};
console.log('[DIDI] Snip complete:', rect);
console.log('[DIDI] Window scroll position:', { scrollX: window.scrollX, scrollY: window.scrollY });
console.log('[DIDI] Device pixel ratio:', window.devicePixelRatio);
// Capture screenshot of the selected area
captureSnippedArea(rect);
// Deactivate snipping mode
deactivateSnippingMode();
}
function handleSnipCancel(e) {
if (e.key === 'Escape' && isSnipping) {
console.log('[DIDI] Snipping cancelled');
deactivateSnippingMode();
showNotification('Snipping cancelled', 'info');
}
}
function captureSnippedArea(rect) {
if (rect.width < 10 || rect.height < 10) {
showNotification('❌ Selection too small', 'error');
return;
}
showNotification('📸 Capturing screenshot...', 'info');
// Convert page coordinates to viewport coordinates
// captureVisibleTab captures only the visible viewport, not the whole page
const viewportRect = {
left: rect.left - window.scrollX,
top: rect.top - window.scrollY,
width: rect.width,
height: rect.height
};
console.log('[DIDI] Original rect (page coords):', rect);
console.log('[DIDI] Viewport rect (screen coords):', viewportRect);
showNotification('📸 Capturing & analyzing… this may take 60120s', 'info');
// Background acks immediately (success: true, accepted: true). The actual
// analysis result arrives later via chrome.tabs.sendMessage → 'showAnalysis'
// (see message handler at top of init()).
chrome.runtime.sendMessage({
action: 'snipCaptured',
rect: viewportRect,
pageUrl: window.location.href,
timestamp: new Date().toISOString(),
devicePixelRatio: window.devicePixelRatio || 1,
}, (response) => {
console.log('[DIDI] Snip ack from background:', response);
if (!response || !response.success) {
showNotification('❌ Screenshot failed', 'error');
}
});
}
function downloadImageFallback(dataUrl, filename) {
console.log('[DIDI] Downloading via fallback method, filename:', filename);
try {
// Create a temporary <a> element
const link = document.createElement('a');
link.href = dataUrl;
link.download = filename || 'didi-screenshot.png';
link.style.display = 'none';
// Append to body, click, then remove
document.body.appendChild(link);
link.click();
// Clean up after a short delay
setTimeout(() => {
document.body.removeChild(link);
console.log('[DIDI] ✅ Fallback download triggered successfully');
}, 100);
} catch (error) {
console.error('[DIDI] Fallback download failed:', error);
showNotification('❌ Download failed: ' + error.message, 'error');
}
}
function deactivateSnippingMode() {
console.log('[DIDI] Snipping mode deactivating...');
isSnipping = false;
document.body.style.cursor = 'default';
// Remove overlay and box
const overlay = document.getElementById('didi-snip-overlay');
if (overlay) overlay.remove();
if (snipBox) {
snipBox.remove();
snipBox = null;
}
// Remove event listeners
document.removeEventListener('mousedown', handleSnipStart, true);
document.removeEventListener('mousemove', handleSnipDrag, true);
document.removeEventListener('mouseup', handleSnipEnd, true);
document.removeEventListener('keydown', handleSnipCancel, true);
}
// ===== VIDEO REGION RECORDING =====
// Uses Region Capture API (Chrome 116+) — getDisplayMedia + MediaStreamTrack.cropTo()
// Records ONLY the selected rectangle, not the whole tab.
let isVideoSnipping = false;
let videoSnipBox = null;
let videoSnipStartX = 0;
let videoSnipStartY = 0;
let videoMediaRecorder = null;
let videoStream = null;
let videoChunks = [];
let videoStartTime = 0;
let videoTimerInterval = null;
let videoMaxDurationMs = 60000; // 60s max
let savedScrollOverflow = '';
let savedHtmlOverflow = '';
function activateVideoSnippingMode() {
console.log('[DIDI VIDEO] Mode activating');
if (!('CropTarget' in window) || !window.CropTarget?.fromElement) {
showNotification('❌ Region Capture not supported. Update Chrome to 116+', 'error');
return;
}
isVideoSnipping = true;
document.body.style.cursor = 'crosshair';
createVideoSnipOverlay();
document.addEventListener('mousedown', handleVideoSnipStart, true);
document.addEventListener('mousemove', handleVideoSnipDrag, true);
document.addEventListener('mouseup', handleVideoSnipEnd, true);
document.addEventListener('keydown', handleVideoSnipCancel, true);
showNotification('🎥 Video region: drag to select recording area (ESC to cancel)', 'info');
}
function createVideoSnipOverlay() {
const existing = document.getElementById('didi-video-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'didi-video-overlay';
overlay.style.cssText = `position:fixed; top:0; left:0; width:100vw; height:100vh; background:rgba(0,0,0,0.3); z-index:999998; pointer-events:none;`;
document.body.appendChild(overlay);
videoSnipBox = document.createElement('div');
videoSnipBox.id = 'didi-video-snipbox';
videoSnipBox.style.cssText = `position:absolute; border:3px solid #EF4444; background:rgba(239,68,68,0.10); z-index:999999; display:none; box-shadow:0 0 24px rgba(239,68,68,0.55), inset 0 0 20px rgba(239,68,68,0.10); pointer-events:none;`;
document.body.appendChild(videoSnipBox);
}
function handleVideoSnipStart(e) {
if (!isVideoSnipping) return;
e.preventDefault(); e.stopPropagation();
videoSnipStartX = e.pageX; videoSnipStartY = e.pageY;
videoSnipBox.style.left = videoSnipStartX + 'px';
videoSnipBox.style.top = videoSnipStartY + 'px';
videoSnipBox.style.width = '0px';
videoSnipBox.style.height = '0px';
videoSnipBox.style.display = 'block';
}
function handleVideoSnipDrag(e) {
if (!isVideoSnipping || !videoSnipBox || videoSnipBox.style.display === 'none') return;
e.preventDefault();
const w = Math.abs(e.pageX - videoSnipStartX);
const h = Math.abs(e.pageY - videoSnipStartY);
const l = Math.min(e.pageX, videoSnipStartX);
const t = Math.min(e.pageY, videoSnipStartY);
videoSnipBox.style.width = w + 'px';
videoSnipBox.style.height = h + 'px';
videoSnipBox.style.left = l + 'px';
videoSnipBox.style.top = t + 'px';
videoSnipBox.innerHTML = `<div style="position:absolute; top:5px; left:5px; background:rgba(239,68,68,0.95); color:white; padding:4px 8px; border-radius:4px; font-family:'Inter',monospace; font-size:12px; font-weight:600; pointer-events:none;">${Math.round(w)} × ${Math.round(h)}</div>`;
}
function handleVideoSnipEnd(e) {
if (!isVideoSnipping || !videoSnipBox || videoSnipBox.style.display === 'none') return;
e.preventDefault(); e.stopPropagation();
const w = parseInt(videoSnipBox.style.width);
const h = parseInt(videoSnipBox.style.height);
if (w < 50 || h < 50) {
showNotification('❌ Selection too small (min 50×50)', 'error');
deactivateVideoSnippingMode();
return;
}
// Stop selection — selection is locked, show "Start Recording" panel
document.removeEventListener('mousedown', handleVideoSnipStart, true);
document.removeEventListener('mousemove', handleVideoSnipDrag, true);
document.removeEventListener('mouseup', handleVideoSnipEnd, true);
document.body.style.cursor = 'default';
showStartRecordingPanel();
}
function handleVideoSnipCancel(e) {
if (e.key === 'Escape' && isVideoSnipping) {
console.log('[DIDI VIDEO] Cancelled');
deactivateVideoSnippingMode();
showNotification('Cancelled', 'info');
}
}
function showStartRecordingPanel() {
const panel = document.createElement('div');
panel.id = 'didi-video-start-panel';
panel.style.cssText = `position:fixed; bottom:24px; left:50%; transform:translateX(-50%); background:#231832; border:1px solid rgba(239,68,68,0.4); border-radius:12px; padding:14px 18px; z-index:99999999; box-shadow:0 12px 40px rgba(0,0,0,0.6); display:flex; align-items:center; gap:14px; font-family:'Inter',sans-serif;`;
panel.innerHTML = `
<div style="display:flex; flex-direction:column; gap:6px;">
<div style="color:#FFF; font-size:13px; font-weight:600;">Region selected. Ready to record?</div>
<label style="display:flex; align-items:center; gap:6px; cursor:pointer; color:#9CA3AF; font-size:12px;">
<input type="checkbox" id="didi-audio-toggle" style="cursor:pointer; accent-color:#009198;"/>
Include tab audio
</label>
</div>
<button id="didi-start-rec-btn" style="background:linear-gradient(135deg,#EF4444 0%,#DC2626 100%); color:#FFF; border:none; padding:10px 18px; border-radius:8px; cursor:pointer; font-family:inherit; font-size:13px; font-weight:600; display:flex; align-items:center; gap:6px;"><span style="display:inline-block; width:8px; height:8px; border-radius:50%; background:#FFF;"></span> Start Recording</button>
<button id="didi-cancel-rec-btn" style="background:rgba(255,255,255,0.06); color:#9CA3AF; border:1px solid rgba(255,255,255,0.1); padding:10px 14px; border-radius:8px; cursor:pointer; font-family:inherit; font-size:13px;">Cancel</button>
`;
document.body.appendChild(panel);
panel.querySelector('#didi-start-rec-btn').addEventListener('click', async () => {
const audioEnabled = panel.querySelector('#didi-audio-toggle').checked;
panel.remove();
await startVideoRecording(audioEnabled);
});
panel.querySelector('#didi-cancel-rec-btn').addEventListener('click', () => {
panel.remove();
deactivateVideoSnippingMode();
});
}
async function startVideoRecording(audioEnabled) {
console.log('[DIDI VIDEO] Starting recording, audio:', audioEnabled);
try {
// Lock scroll to keep region anchored to viewport
savedHtmlOverflow = document.documentElement.style.overflow;
savedScrollOverflow = document.body.style.overflow;
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
// 1. Mark region for crop
const cropTarget = await CropTarget.fromElement(videoSnipBox);
// 2. Ask browser for tab capture
videoStream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: 'browser' },
audio: audioEnabled,
preferCurrentTab: true,
selfBrowserSurface: 'include'
});
// 3. Crop the video track to our region
const [videoTrack] = videoStream.getVideoTracks();
if (typeof videoTrack.cropTo !== 'function') {
throw new Error('cropTo() not available on this Chrome version');
}
await videoTrack.cropTo(cropTarget);
// Stop recording when user revokes share via browser bar
videoTrack.addEventListener('ended', () => {
console.log('[DIDI VIDEO] Track ended (user revoked sharing)');
if (videoMediaRecorder && videoMediaRecorder.state !== 'inactive') {
stopVideoRecording(true);
}
});
// 4. Record the cropped stream
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
? 'video/webm;codecs=vp9,opus'
: 'video/webm;codecs=vp8,opus';
videoChunks = [];
videoMediaRecorder = new MediaRecorder(videoStream, { mimeType, videoBitsPerSecond: 2_500_000 });
videoMediaRecorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) videoChunks.push(e.data);
};
videoMediaRecorder.onstop = () => onVideoRecordingStopped(mimeType);
videoMediaRecorder.start(1000); // chunk every 1s
videoStartTime = Date.now();
showRecordingControls();
startVideoTimer();
// Auto-stop at max duration
setTimeout(() => {
if (videoMediaRecorder && videoMediaRecorder.state === 'recording') {
showNotification(`⏰ Max duration ${videoMaxDurationMs/1000}s reached`, 'info');
stopVideoRecording(false);
}
}, videoMaxDurationMs);
} catch (err) {
console.error('[DIDI VIDEO] Start failed:', err);
showNotification('❌ ' + (err.name === 'NotAllowedError' ? 'Permission denied' : err.message), 'error');
cleanupVideoSession();
}
}
function showRecordingControls() {
videoSnipBox.style.borderColor = '#DC2626';
videoSnipBox.style.animation = 'didiPulseRec 1.2s ease-in-out infinite';
videoSnipBox.innerHTML = '';
const controls = document.createElement('div');
controls.id = 'didi-video-controls';
controls.style.cssText = `position:fixed; bottom:24px; left:50%; transform:translateX(-50%); background:#231832; border:1px solid rgba(239,68,68,0.5); border-radius:12px; padding:12px 16px; z-index:99999999; box-shadow:0 12px 40px rgba(0,0,0,0.6); display:flex; align-items:center; gap:14px; font-family:'Inter',sans-serif;`;
controls.innerHTML = `
<div style="display:flex; align-items:center; gap:8px; color:#EF4444; font-size:13px; font-weight:600;">
<span style="display:inline-block; width:10px; height:10px; border-radius:50%; background:#EF4444; animation:didiPulseRec 1s infinite;"></span>
<span>REC</span>
<span id="didi-rec-timer" style="color:#FFF; font-family:Consolas,monospace; min-width:42px;">0:00</span>
<span style="color:#6B7280; font-size:11px;">/ ${Math.floor(videoMaxDurationMs/60000)}:${String(Math.floor((videoMaxDurationMs%60000)/1000)).padStart(2,'0')}</span>
</div>
<button id="didi-stop-rec-btn" style="background:#EF4444; color:#FFF; border:none; padding:9px 16px; border-radius:8px; cursor:pointer; font-family:inherit; font-size:13px; font-weight:600; display:flex; align-items:center; gap:6px;">
<span style="display:inline-block; width:10px; height:10px; background:#FFF; border-radius:2px;"></span> Stop
</button>
`;
document.body.appendChild(controls);
controls.querySelector('#didi-stop-rec-btn').addEventListener('click', () => {
stopVideoRecording(false);
});
}
function startVideoTimer() {
const timerEl = () => document.getElementById('didi-rec-timer');
videoTimerInterval = setInterval(() => {
const el = timerEl(); if (!el) return;
const elapsedMs = Date.now() - videoStartTime;
const m = Math.floor(elapsedMs / 60000);
const s = Math.floor((elapsedMs % 60000) / 1000);
el.textContent = `${m}:${String(s).padStart(2, '0')}`;
}, 200);
}
function stopVideoRecording(silentlyStopped) {
if (videoTimerInterval) { clearInterval(videoTimerInterval); videoTimerInterval = null; }
if (videoMediaRecorder && videoMediaRecorder.state !== 'inactive') {
videoMediaRecorder.stop();
}
// onstop handler will fire onVideoRecordingStopped()
const ctrl = document.getElementById('didi-video-controls');
if (ctrl) ctrl.remove();
}
function onVideoRecordingStopped(mimeType) {
// Stop all media tracks
if (videoStream) {
videoStream.getTracks().forEach(t => t.stop());
videoStream = null;
}
const blob = new Blob(videoChunks, { type: mimeType });
videoChunks = [];
console.log('[DIDI VIDEO] Recording stopped. Blob size:', blob.size, 'bytes, type:', mimeType);
if (blob.size === 0) {
showNotification('❌ Recording empty', 'error');
cleanupVideoSession();
return;
}
showSendConfirmation(blob);
}
function showSendConfirmation(blob) {
// Restore scroll behind the modal
document.documentElement.style.overflow = savedHtmlOverflow;
document.body.style.overflow = savedScrollOverflow;
const sizeMB = (blob.size / 1024 / 1024).toFixed(2);
const durationMs = Date.now() - videoStartTime;
const durSec = (durationMs / 1000).toFixed(1);
// Generate preview URL for inline <video>
const previewUrl = URL.createObjectURL(blob);
const modal = document.createElement('div');
modal.id = 'didi-video-confirm-modal';
modal.style.cssText = `position:fixed; top:0; left:0; width:100vw; height:100vh; background:rgba(0,0,0,0.85); z-index:99999999; display:flex; align-items:center; justify-content:center; font-family:'Inter',sans-serif;`;
modal.innerHTML = `
<div style="background:#231832; border-radius:16px; padding:24px; max-width:560px; width:90%; box-shadow:0 24px 60px rgba(0,0,0,0.7);">
<h2 style="margin:0 0 4px 0; font-size:20px; font-weight:700; color:#FFF;">Send video for analysis?</h2>
<p style="margin:0 0 16px 0; color:#9CA3AF; font-size:13px;">Duration: ${durSec}s &middot; Size: ${sizeMB} MB</p>
<video src="${previewUrl}" controls style="width:100%; max-height:340px; border-radius:8px; background:#000; margin-bottom:16px;"></video>
<div style="background:rgba(239,68,68,0.08); border-left:3px solid #EF4444; padding:10px 12px; border-radius:6px; margin-bottom:16px; color:#FCA5A5; font-size:12px;">
⚠️ This will use credits. Backend video analysis takes ~30-180s.
</div>
<div style="display:flex; gap:10px; justify-content:flex-end;">
<button id="didi-discard-btn" style="background:rgba(255,255,255,0.06); color:#9CA3AF; border:1px solid rgba(255,255,255,0.1); padding:10px 18px; border-radius:8px; cursor:pointer; font-family:inherit; font-size:13px;">Discard</button>
<button id="didi-download-btn" style="background:rgba(0,145,152,0.15); color:#1fb6bd; border:1px solid #009198; padding:10px 18px; border-radius:8px; cursor:pointer; font-family:inherit; font-size:13px;">Download Only</button>
<button id="didi-send-btn" style="background:linear-gradient(135deg,#009198 0%,#1fb6bd 100%); color:#FFF; border:none; padding:10px 18px; border-radius:8px; cursor:pointer; font-family:inherit; font-size:13px; font-weight:600;">Send for Analysis</button>
</div>
</div>
`;
document.body.appendChild(modal);
const cleanup = () => {
URL.revokeObjectURL(previewUrl);
modal.remove();
cleanupVideoSession();
};
modal.querySelector('#didi-discard-btn').addEventListener('click', cleanup);
modal.querySelector('#didi-download-btn').addEventListener('click', () => {
const ts = new Date().toISOString().replace(/:/g, '-').split('.')[0];
downloadImageFallback(previewUrl, `didi-recording-${ts}.webm`);
cleanup();
});
modal.querySelector('#didi-send-btn').addEventListener('click', async () => {
modal.querySelector('#didi-send-btn').disabled = true;
modal.querySelector('#didi-send-btn').textContent = 'Uploading...';
try {
const dataUrl = await blobToDataUrl(blob);
const filename = `didi-video-${new Date().toISOString().replace(/:/g, '-').split('.')[0]}.webm`;
cleanup();
showNotification('📤 Uploading video… analysis takes 60180s', 'info');
// Background acks immediately; result is pushed back via 'showAnalysis' message.
chrome.runtime.sendMessage({ action: 'analyzeVideo', dataUrl, filename }, (response) => {
if (!response?.success) {
showNotification('❌ Video upload failed: ' + (response?.error || 'unknown'), 'error');
}
});
} catch (err) {
console.error('[DIDI VIDEO] Send failed:', err);
showNotification('❌ ' + err.message, 'error');
cleanup();
}
});
}
function blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onloadend = () => resolve(r.result);
r.onerror = reject;
r.readAsDataURL(blob);
});
}
function cleanupVideoSession() {
if (videoStream) { videoStream.getTracks().forEach(t => t.stop()); videoStream = null; }
if (videoTimerInterval) { clearInterval(videoTimerInterval); videoTimerInterval = null; }
document.documentElement.style.overflow = savedHtmlOverflow;
document.body.style.overflow = savedScrollOverflow;
deactivateVideoSnippingMode();
}
function deactivateVideoSnippingMode() {
isVideoSnipping = false;
document.body.style.cursor = 'default';
const overlay = document.getElementById('didi-video-overlay');
if (overlay) overlay.remove();
if (videoSnipBox) { videoSnipBox.remove(); videoSnipBox = null; }
const controls = document.getElementById('didi-video-controls');
if (controls) controls.remove();
const startPanel = document.getElementById('didi-video-start-panel');
if (startPanel) startPanel.remove();
document.removeEventListener('mousedown', handleVideoSnipStart, true);
document.removeEventListener('mousemove', handleVideoSnipDrag, true);
document.removeEventListener('mouseup', handleVideoSnipEnd, true);
document.removeEventListener('keydown', handleVideoSnipCancel, true);
}
// Add CSS animations
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
@keyframes didiPulseRec {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
`;
document.head.appendChild(style);
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}