Spaces:
Sleeping
Sleeping
| /** | |
| * ============================================ | |
| * Captcha Intervention Handler | |
| * - Polls for active interventions | |
| * - Shows screenshots | |
| * - Handles click-on-screenshot to solve captcha | |
| * ============================================ | |
| */ | |
| // Track current interventions | |
| let activeInterventions = {}; | |
| // ============================================ | |
| // Check for Interventions | |
| // ============================================ | |
| async function checkInterventions() { | |
| try { | |
| const data = await apiCall('/api/intervention/active'); | |
| const banner = document.getElementById('interventionBanner'); | |
| const section = document.getElementById('interventionSection'); | |
| if (data.count > 0) { | |
| // Show alert banner | |
| banner.style.display = 'block'; | |
| section.style.display = 'block'; | |
| activeInterventions = data.interventions; | |
| renderInterventionPanels(data.interventions); | |
| } else { | |
| banner.style.display = 'none'; | |
| // Only hide section if no interventions | |
| if (Object.keys(activeInterventions).length === 0) { | |
| section.style.display = 'none'; | |
| } | |
| activeInterventions = {}; | |
| } | |
| } catch (error) { | |
| console.error('Intervention check error:', error); | |
| } | |
| } | |
| // ============================================ | |
| // Render Intervention Panels | |
| // ============================================ | |
| function renderInterventionPanels(interventions) { | |
| const container = document.getElementById('interventionContent'); | |
| if (Object.keys(interventions).length === 0) { | |
| container.innerHTML = '<p style="color: var(--text-muted); text-align: center; padding: 20px;">No active interventions 👍</p>'; | |
| return; | |
| } | |
| container.innerHTML = Object.entries(interventions).map(([novelId, info]) => { | |
| const screenshotUrl = `/api/intervention/screenshot/${info.screenshot}`; | |
| return ` | |
| <div class="intervention-panel" id="intervention-${novelId}"> | |
| <div class="intervention-header"> | |
| <h3>🚨 Novel #${novelId} - Captcha Detected</h3> | |
| <span style="color: var(--text-muted); font-size: 0.8rem;"> | |
| URL: ${escapeHtml(info.page_url || 'Unknown')} | |
| </span> | |
| </div> | |
| <p style="color: var(--accent-yellow); margin-bottom: 12px; font-size: 0.9rem;"> | |
| Reason: ${escapeHtml(info.reason)} | |
| </p> | |
| <p style="color: var(--text-secondary); margin-bottom: 12px; font-size: 0.85rem;"> | |
| 👆 Click directly on the captcha in the screenshot below. Your click will be sent to the actual browser. | |
| </p> | |
| <div class="screenshot-container" id="screenshot-container-${novelId}" | |
| onclick="handleScreenshotClick(event, ${novelId})"> | |
| <img src="${screenshotUrl}" | |
| alt="Captcha Screenshot" | |
| id="screenshot-img-${novelId}" | |
| loading="lazy" | |
| onerror="this.alt='Screenshot failed to load. Click Refresh.'" | |
| > | |
| </div> | |
| <!-- Text input for text-based captchas --> | |
| <div class="intervention-input"> | |
| <input type="text" | |
| id="captcha-text-${novelId}" | |
| placeholder="Type captcha text here (if needed)"> | |
| <button class="btn btn-sm btn-primary" | |
| onclick="submitCaptchaText(${novelId})"> | |
| ⌨️ Submit Text | |
| </button> | |
| </div> | |
| <div class="intervention-actions"> | |
| <button class="btn btn-sm btn-success" | |
| onclick="resolveIntervention(${novelId})"> | |
| ✅ Captcha Solved - Resume Scraping | |
| </button> | |
| <button class="btn btn-sm btn-secondary" | |
| onclick="refreshScreenshot(${novelId})"> | |
| 🔄 Refresh Screenshot | |
| </button> | |
| <button class="btn btn-sm btn-warning" | |
| onclick="retryClick(${novelId})"> | |
| 🖱️ Retry Last Click | |
| </button> | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| } | |
| // ============================================ | |
| // Handle Click on Screenshot | |
| // ============================================ | |
| let lastClickCoords = {}; | |
| async function handleScreenshotClick(event, novelId) { | |
| const container = document.getElementById(`screenshot-container-${novelId}`); | |
| const img = document.getElementById(`screenshot-img-${novelId}`); | |
| if (!img || !img.naturalWidth) { | |
| showToast('Screenshot not loaded yet', 'warning'); | |
| return; | |
| } | |
| // Calculate click position relative to the actual image size | |
| const rect = img.getBoundingClientRect(); | |
| const scaleX = img.naturalWidth / rect.width; | |
| const scaleY = img.naturalHeight / rect.height; | |
| const clickX = Math.round((event.clientX - rect.left) * scaleX); | |
| const clickY = Math.round((event.clientY - rect.top) * scaleY); | |
| // Store for retry | |
| lastClickCoords[novelId] = { x: clickX, y: clickY }; | |
| // Show visual feedback - click indicator | |
| showClickIndicator(container, event.clientX - rect.left, event.clientY - rect.top); | |
| console.log(`Click at (${clickX}, ${clickY}) for Novel ${novelId}`); | |
| showToast(`Clicking at (${clickX}, ${clickY})...`, 'info', 2000); | |
| try { | |
| const result = await apiCall('/api/intervention/click', 'POST', { | |
| novel_id: novelId, | |
| x: clickX, | |
| y: clickY, | |
| }); | |
| showToast(`Click sent! ${result.message}`, 'success'); | |
| // Auto-refresh screenshot after click to see result | |
| setTimeout(() => refreshScreenshot(novelId), 2000); | |
| } catch (error) { | |
| showToast(`Click failed: ${error.message}`, 'error'); | |
| } | |
| } | |
| function showClickIndicator(container, x, y) { | |
| // Remove old indicators | |
| container.querySelectorAll('.click-indicator').forEach(el => el.remove()); | |
| // Add new indicator | |
| const indicator = document.createElement('div'); | |
| indicator.className = 'click-indicator'; | |
| indicator.style.left = `${x}px`; | |
| indicator.style.top = `${y}px`; | |
| container.appendChild(indicator); | |
| // Remove after animation | |
| setTimeout(() => indicator.remove(), 3000); | |
| } | |
| // ============================================ | |
| // Retry Last Click | |
| // ============================================ | |
| async function retryClick(novelId) { | |
| const coords = lastClickCoords[novelId]; | |
| if (!coords) { | |
| showToast('No previous click to retry', 'warning'); | |
| return; | |
| } | |
| try { | |
| await apiCall('/api/intervention/click', 'POST', { | |
| novel_id: novelId, | |
| x: coords.x, | |
| y: coords.y, | |
| }); | |
| showToast(`Retried click at (${coords.x}, ${coords.y})`, 'info'); | |
| setTimeout(() => refreshScreenshot(novelId), 2000); | |
| } catch (error) { | |
| showToast(`Retry failed: ${error.message}`, 'error'); | |
| } | |
| } | |
| // ============================================ | |
| // Submit Captcha Text | |
| // ============================================ | |
| async function submitCaptchaText(novelId) { | |
| const input = document.getElementById(`captcha-text-${novelId}`); | |
| const text = input ? input.value.trim() : ''; | |
| if (!text) { | |
| showToast('Please enter the captcha text', 'warning'); | |
| return; | |
| } | |
| try { | |
| // Try common captcha input selectors | |
| const selectors = [ | |
| 'input#captcha-input', | |
| 'input[name="captcha"]', | |
| 'input[name="cf-turnstile-response"]', | |
| 'input.captcha-input', | |
| 'input[placeholder*="captcha" i]', | |
| 'input[type="text"]:visible', | |
| ]; | |
| let submitted = false; | |
| for (const selector of selectors) { | |
| try { | |
| await apiCall('/api/intervention/type', 'POST', { | |
| novel_id: novelId, | |
| selector: selector, | |
| text: text, | |
| }); | |
| submitted = true; | |
| showToast(`Text "${text}" typed into ${selector}`, 'success'); | |
| break; | |
| } catch (e) { | |
| continue; | |
| } | |
| } | |
| if (!submitted) { | |
| showToast('Could not find captcha input field. Try clicking on it first.', 'warning'); | |
| } | |
| // Clear input | |
| if (input) input.value = ''; | |
| // Refresh screenshot | |
| setTimeout(() => refreshScreenshot(novelId), 2000); | |
| } catch (error) { | |
| showToast(`Text submit failed: ${error.message}`, 'error'); | |
| } | |
| } | |
| // ============================================ | |
| // Refresh Screenshot | |
| // ============================================ | |
| async function refreshScreenshot(novelId) { | |
| try { | |
| const result = await apiCall('/api/intervention/refresh-screenshot', 'POST', { | |
| novel_id: novelId, | |
| }); | |
| if (result.screenshot) { | |
| const img = document.getElementById(`screenshot-img-${novelId}`); | |
| if (img) { | |
| // Add timestamp to prevent caching | |
| img.src = `/api/intervention/screenshot/${result.screenshot}?t=${Date.now()}`; | |
| } | |
| showToast('Screenshot refreshed', 'info', 2000); | |
| } | |
| } catch (error) { | |
| showToast(`Refresh failed: ${error.message}`, 'error'); | |
| } | |
| } | |
| // ============================================ | |
| // Resolve Intervention | |
| // ============================================ | |
| async function resolveIntervention(novelId) { | |
| try { | |
| const result = await apiCall('/api/intervention/resolve', 'POST', { | |
| novel_id: novelId, | |
| }); | |
| showToast(`✅ ${result.message}`, 'success'); | |
| // Remove the panel | |
| const panel = document.getElementById(`intervention-${novelId}`); | |
| if (panel) { | |
| panel.style.animation = 'toast-out 0.3s ease'; | |
| setTimeout(() => panel.remove(), 300); | |
| } | |
| delete activeInterventions[novelId]; | |
| // Hide section if no more interventions | |
| if (Object.keys(activeInterventions).length === 0) { | |
| document.getElementById('interventionBanner').style.display = 'none'; | |
| } | |
| } catch (error) { | |
| showToast(`Failed to resolve: ${error.message}`, 'error'); | |
| } | |
| } |