您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Collect Facebook group posts data with selection interface and export to localhost API
// ==UserScript== // @name FB - Group Post Data Collector // @description Collect Facebook group posts data with selection interface and export to localhost API // @namespace https://github.com/alx/fb-group-post-export // @supportURL https://github.com/alx/fb-group-post-export/issues // @version 1.1 // @author alx (https://github.com/alx/) // @match https://www.facebook.com/groups/* // @match https://web.facebook.com/groups/* // @noframes // @grant GM.info // @grant GM.registerMenuCommand // @grant GM_xmlhttpRequest // @grant unsafeWindow // @license MIT; https://opensource.org/licenses/MIT // @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAB2AAAAdgB+lymcgAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAanSURBVHic5ZtpjBVFEMd/u8vthsCi3OcGxQWBKJdEDEbuDypqNoGgxGgkWY8YDaIYFQ9MRCOJJ0Y80JhgVESiRpCVIAYIIAQPCEoQZCOIyuIB667IPj/8qzPzhveANzPvvX3LP5nMVHdPT1V3VVd1zQwUNroDK4A/gB3A1Pyyk1sUA18DCeBPoBH4FxiRT6ZyiXFI+C+AFsAdRj+TSSfF8fOVM0y281LgP+BwHnnJC75FM15u9BtGV+aNoxyiG7L5H3xlNUgTyjLpqFBNYDJQBKw0ejDQE9gC1GbSUaEOwCQ7r0pDN2sUA78CDUCpla1G9j86X0zlEqOQsJ8b3RaoQ6pfkmlnhWgCQXW/Ag1CNXAi086awwCcVfbfATgOHEReAGAXMone+WIql7geCbvE6F5G7wjbYaGZQFDdpwToZo99aKHrbPQypAGT0t3QnFCBhN1idAlyff8A7cJ2WkgmEFT/S4GOwDoUB4RCIQ3ARDt/ZudL7LwyRdtmhzbAMZT5aWll5wGzgfb5YiqXmIDsf3ncHReKCQTtfwDwGtA1P+zkHi7708/o142ekvaOZoQeKPvzva8sVPYnFQrBBCahuN+pf+jsTyoUygDAWbr7KwF+J4vZnxZxdJICFYQLTxvQzi5h9HCgE7AGOGp9jgGOAJujs5kd3IAECHvc7+vrYSubY/QUo9+Ni9lsrAG97LwN+NiuDwPvAVuN/s7oA0avBjbatdvpQYHa/1w0S1Xo7W0CT7hZRj9o9IdGX4yX7FhodTnJ/jRlLzAerVGrkNC9UAS4A9gf10Oa8gDkJPvTlAdgIooAXf4/K/YflxssBc5HA9rdynoDQ+z6HGAYnu12N7qD0RUo5AXoAlxtbb9Cb4FaAFeidFid3XschccNMckQGpXIR0dxfemO+faMy9LUH0RvikIjqga0QdvS1sBHQH3E/hzGo3RX0P1twlsAy9BXIq8AQ2N6bsa4CM1EnHaZKvuzyZ4zMNB2P1onWhISURdBp0Fx2uHlKORdg+y8E7L5GmBnoG09ihFCyxHVBH6280jgRsKbwGbgJ7sOrvbj0aaohuTPXzoCfYHfyPNC+CrRF7tdvv7SZX/SHfdEYb7o9E3OqI9K9JHidBTnf5JhH+vsXAE8DexGUV8Rmvmu6PO3BNK07sCzaK9RHY39+DAUMZip8AB3kzyrK5Dwg43e4Gu7By18baIwmw2UIWa/yfC+3iiOqAfeRsGO20AttutHrG2RtTsUnd3s4ChnlqfrjFT5MWA9EvIJqxuFIkC/Royzui5Gb6WJwm1XS9PUlyFbriNZwEa8V18gt1aF8ggJ5G3KUYYogbbRTRLViMGKFHW98AboLxTBzQJewhuEJWiWHc7Fyxk8ihbaBPBCVriPAe5z1QmB8g7Aj1a3DAnm0Bt98Oy04QhwJ/L9RXgmMsPKE8B9WZMgIh5HDN4cKH8e792e+5StL/IY6fz7duApu95m9y3AG4xYEHdWuMbOPX1lA4Bb0QJ5O1rlxyIfXorc2jIkJGjnNw251aHoDdBtdp/LN7rnNDm4rO1io8fiqb6L2AaiPzwSSGNap+hnttXvRKGww5dWXp7inryhGH28vAjvD46jaKFz6vwmnrY5tZ+Xoq9StBdwqv9+oH4vWixTDVpeMA5vZQ8etSjEnYYXco+wut0km18nZN/HAn34w9xi9EvML9kRJXPMRbORANYCM4E+QKs07VuhAUkAN/nKL0T7eqc5y/G8yQe+dt2sbAtNAC52/5tT/6FRhgSsxEtqbMWb/XKU80ugMNjFADM42d+PxPMkecUwpIp1nJyPKwKuRTPnojj/sZHkNz/uReeLJO9MX7bymb6y66zsuZjkCI2VxkhVoHwQml0nbB3a269GMzmVZCHddz970dfeDm1RMOQixlo73Ppwb6zSZIh+xsQekhexCXgMrgeu4tQr9RiU0U1YWz/m4Q1gre9osPLpUYWIgipj4klf2RDEbCPwAMn5ueHI17+FNKEaeQCnJQtJxnD05Wc9cEGg7lO7Z0wMcoSGy8q4ha8EL4U1x9duLMoLpHKPjSheCIazg9A+P4EW2SC2W13f6GKEh0tQuK2rW63X4tn3Ajz3uAFtYEYD/dGq77d3kMbcgvdyZVGaZx9C4XDoFHgcuAsxudQYcWrpdn/zjT4MXHOavvqj312dBp1AW95UecqWVn8gRV0kZJoUbY9Uuw+KAdohM9hHcia3FsX7qdAK+fvgTNaTXsBipPqbifgqLA70AN5B+fhTpavjPhqBh+IW5n827DI9G+eQwwAAAABJRU5ErkJggg== // @run-at document-start // ==/UserScript== /* FB - Group Post Data Collector This userscript collects Facebook group posts data and allows users to select which posts to send to a localhost API endpoint. Features: - Detects posts on Facebook group pages - Expands "See more" links to get full content - Shows selection dialog with checkboxes for each post - Sends selected posts to http://localhost:8080/fetch_post v1.0 :: January 2025 Initial release Post detection and extraction User selection interface API integration with localhost endpoint */ (async function() { 'use strict'; // Global variables const SCRIPT_VERSION = 'v' + GM.info.script.version; let isCollecting = false; let collectedPosts = []; let selectionDialog = null; // CSS for UI elements const CSS_STYLES = ` /* Collection Button */ .fbgpc-button { position: fixed; top: 80px; right: 20px; z-index: 10000; background: #1877f2; color: white; border: none; border-radius: 8px; padding: 12px 16px; font-size: 14px; font-weight: 600; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.15); transition: all 0.2s ease; display: flex; align-items: center; gap: 8px; } .fbgpc-button:hover { background: #166fe5; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.2); } .fbgpc-button:disabled { background: #8a8d91; cursor: not-allowed; transform: none; } /* Dialog Overlay */ .fbgpc-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); z-index: 10001; display: flex; align-items: center; justify-content: center; } /* Dialog Container */ .fbgpc-dialog { background: white; border-radius: 12px; width: 90%; max-width: 700px; max-height: 80vh; display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.3); } /* Dialog Header */ .fbgpc-header { padding: 20px; border-bottom: 1px solid #e4e6ea; display: flex; justify-content: space-between; align-items: center; } .fbgpc-title { font-size: 18px; font-weight: 600; color: #1c1e21; } .fbgpc-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #65676b; padding: 4px; } /* Dialog Content */ .fbgpc-content { flex: 1; overflow-y: auto; padding: 20px; } .fbgpc-controls { margin-bottom: 16px; display: flex; gap: 12px; } .fbgpc-control-btn { background: #f0f2f5; border: none; border-radius: 6px; padding: 6px 12px; font-size: 12px; cursor: pointer; color: #65676b; } .fbgpc-control-btn:hover { background: #e4e6ea; } /* Post List */ .fbgpc-posts { display: flex; flex-direction: column; gap: 12px; } .fbgpc-post-item { border: 1px solid #e4e6ea; border-radius: 8px; padding: 16px; display: flex; gap: 12px; } .fbgpc-post-checkbox { margin-top: 4px; } .fbgpc-post-content { flex: 1; } .fbgpc-post-url { font-size: 12px; color: #65676b; margin-bottom: 8px; word-break: break-all; } .fbgpc-post-text { font-size: 14px; color: #1c1e21; line-height: 1.4; } .fbgpc-post-text.truncated { max-height: 60px; overflow: hidden; } /* Image Preview Styles */ .fbgpc-post-images { margin-top: 12px; } .fbgpc-images-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 12px; color: #65676b; font-weight: 600; } .fbgpc-images-toggle { background: none; border: none; cursor: pointer; padding: 2px; color: #1877f2; font-size: 12px; } .fbgpc-images-toggle:hover { text-decoration: underline; } .fbgpc-images-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(80px, 1fr)); gap: 6px; max-height: 200px; overflow-y: auto; } .fbgpc-image-item { position: relative; border-radius: 4px; overflow: hidden; background: #f0f2f5; aspect-ratio: 1; } .fbgpc-image-preview { width: 100%; height: 100%; object-fit: cover; cursor: pointer; } .fbgpc-image-checkbox { position: absolute; top: 4px; right: 4px; width: 16px; height: 16px; cursor: pointer; } .fbgpc-image-info { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.7); color: white; font-size: 10px; padding: 2px 4px; opacity: 0; transition: opacity 0.2s ease; } .fbgpc-image-item:hover .fbgpc-image-info { opacity: 1; } .fbgpc-include-images { display: flex; align-items: center; gap: 6px; margin-top: 8px; font-size: 12px; } .fbgpc-include-images input[type="checkbox"] { margin: 0; } /* Page Post Checkbox Overlay */ .fbgpc-page-post-overlay { position: absolute; top: 8px; right: 8px; z-index: 9999; background: rgba(255, 255, 255, 0.95); border: 2px solid #1877f2; border-radius: 6px; padding: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: #1877f2; cursor: pointer; transition: all 0.2s ease; user-select: none; } .fbgpc-page-post-overlay:hover { background: rgba(24, 119, 242, 0.1); transform: scale(1.02); box-shadow: 0 4px 12px rgba(0,0,0,0.2); } .fbgpc-page-post-overlay.unchecked { background: rgba(245, 245, 245, 0.95); border-color: #8a8d91; color: #8a8d91; } .fbgpc-page-post-overlay.unchecked:hover { background: rgba(200, 200, 200, 0.1); } .fbgpc-page-post-checkbox { margin: 0; width: 16px; height: 16px; cursor: pointer; } .fbgpc-page-post-counter { font-size: 11px; font-weight: 500; } /* Mark posts with relative positioning for overlay */ .fbgpc-post-container { position: relative !important; } /* Bulk Controls */ .fbgpc-bulk-controls { position: fixed; top: 140px; right: 20px; z-index: 10000; background: white; border: 1px solid #e4e6ea; border-radius: 8px; padding: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); display: flex; flex-direction: column; gap: 4px; font-size: 12px; } .fbgpc-bulk-btn { background: #f0f2f5; border: none; border-radius: 4px; padding: 4px 8px; font-size: 12px; cursor: pointer; color: #65676b; transition: background 0.2s ease; } .fbgpc-bulk-btn:hover { background: #e4e6ea; } /* Dialog Footer */ .fbgpc-footer { padding: 20px; border-top: 1px solid #e4e6ea; display: flex; justify-content: space-between; align-items: center; } .fbgpc-status { font-size: 14px; color: #65676b; } .fbgpc-actions { display: flex; gap: 12px; } .fbgpc-btn { border: none; border-radius: 6px; padding: 8px 16px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s ease; } .fbgpc-btn-primary { background: #1877f2; color: white; } .fbgpc-btn-primary:hover { background: #166fe5; } .fbgpc-btn-primary:disabled { background: #8a8d91; cursor: not-allowed; } .fbgpc-btn-secondary { background: #f0f2f5; color: #1c1e21; } .fbgpc-btn-secondary:hover { background: #e4e6ea; } /* Loading Spinner */ .fbgpc-spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid #ffffff50; border-top: 2px solid #ffffff; border-radius: 50%; animation: fbgpc-spin 1s linear infinite; } @keyframes fbgpc-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* Toast Notification */ .fbgpc-toast { position: fixed; top: 20px; right: 20px; background: #42b883; color: white; padding: 12px 16px; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 10002; font-size: 14px; font-weight: 500; animation: fbgpc-toast-in 0.3s ease; } .fbgpc-toast.error { background: #e74c3c; } @keyframes fbgpc-toast-in { from { opacity: 0; transform: translateX(100%); } to { opacity: 1; transform: translateX(0); } } `; // Inject CSS function injectStyles() { const style = document.createElement('style'); style.textContent = CSS_STYLES; document.head.appendChild(style); } // Create collection button function createCollectionButton() { const button = document.createElement('button'); button.className = 'fbgpc-button'; button.innerHTML = ` <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> <path d="M3 2a1 1 0 011-1h8a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V2zm1 0v12h8V2H4z"/> <path d="M6 4h4v1H6V4zm0 2h4v1H6V6zm0 2h4v1H6V8z"/> </svg> Collect Posts `; button.title = 'Collect Facebook group posts data'; button.addEventListener('click', handleCollectionClick); document.body.appendChild(button); return button; } // Show toast notification function showToast(message, isError = false) { const toast = document.createElement('div'); toast.className = `fbgpc-toast ${isError ? 'error' : ''}`; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => { if (toast.parentNode) { toast.parentNode.removeChild(toast); } }, 4000); } // Collect all visible posts on the page (filtered by checkboxes) async function collectPosts() { collectedPosts = []; // Clean up removed posts first cleanupRemovedPosts(); // Get only selected posts from our checkbox map const selectedPosts = Array.from(pagePostCheckboxes.entries()) .filter(([postElement, data]) => data.checkbox.checked) .map(([postElement, data]) => postElement); console.log(`📊 Starting collection: Found ${selectedPosts.length} selected posts out of ${pagePostCheckboxes.size} total posts`); let successCount = 0; let failureCount = 0; for (let i = 0; i < selectedPosts.length; i++) { const post = selectedPosts[i]; console.log(`🔄 Processing post ${i + 1}/${selectedPosts.length}...`); try { const postData = await extractPostData(post, i + 1, selectedPosts.length); if (postData) { collectedPosts.push(postData); successCount++; console.log(`✅ Post ${i + 1}: Successfully extracted data (ID: ${postData.post_id})`); } else { failureCount++; console.warn(`⚠️ Post ${i + 1}: extractPostData returned null/empty`); } } catch (error) { failureCount++; console.error(`❌ Post ${i + 1}: Error extracting post data:`, error); console.error('Post element:', post); } } console.log(`📈 Collection complete: ${successCount} successful, ${failureCount} failed, ${collectedPosts.length} total posts in array`); // Additional validation if (selectedPosts.length > 0 && collectedPosts.length === 0) { console.error('🚨 CRITICAL: No posts were successfully collected despite having selected posts!'); } } // Extract data from a single post async function extractPostData(postElement, index, total) { console.log(`🔍 extractPostData: Starting extraction for post ${index}/${total}`); // Update button with progress const button = document.querySelector('.fbgpc-button'); button.innerHTML = ` <div class="fbgpc-spinner"></div> Processing ${index}/${total}... `; try { // Generate post URL and extract ID const postUrl = findPostLink(postElement); if (!postUrl) { console.warn(`🔗 Post ${index}: No post URL generated`); console.log('Post element:', postElement); return null; } console.log(`🔗 Post ${index}: Generated post URL:`, postUrl); const postId = extractPostId(postUrl); if (!postId) { console.warn(`🆔 Post ${index}: Could not extract post ID from URL:`, postUrl); return null; } console.log(`🆔 Post ${index}: Extracted ID: ${postId}`); console.log(`🌐 Post ${index}: Cleaned URL: ${postUrl}`); // Use pre-expanded content if available, otherwise extract current content // (expandSeeMore is now called during checkbox selection) let postContent; const expandedData = expandedContentMap.get(postElement); if (expandedData && expandedData.expandedContent) { postContent = expandedData.expandedContent; console.log(`📋 Post ${index}: Using pre-expanded content (${postContent.length} chars)`); } else { // Fallback to current content extraction if no expanded content is stored console.log(`📄 Post ${index}: No pre-expanded content, using fallback extraction`); postContent = extractPostContent(postElement); if (postContent) { console.log(`📄 Post ${index}: Fallback extraction successful (${postContent.length} chars)`); } else { console.warn(`📄 Post ${index}: Fallback extraction also failed`); } } if (!postContent || postContent.trim().length === 0) { console.warn(`📝 Post ${index}: No content found (content: ${postContent ? 'empty string' : 'null'})`); return null; } // Extract images from post const postImages = extractPostImages(postElement); console.log(`📸 Post ${index}: Found ${postImages.length} images`); const result = { post_id: postId, post_url: postUrl, post_content: postContent.trim(), images: postImages, includeImages: postImages.length > 0 // Default to true if images exist }; console.log(`✅ Post ${index}: Successfully created post data object`); return result; } catch (error) { console.error(`❌ Post ${index}: Unexpected error in extractPostData:`, error); console.error(`❌ Post ${index}: Error stack:`, error.stack); return null; } } // Generate post link from document URL (group_id) and image URL (post_id) function findPostLink(postElement) { console.log(`🔍 findPostLink: Generating post link from document URL and image URLs`); // Step 1: Extract group_id from current document URL const groupId = extractGroupIdFromDocument(); if (!groupId) { console.warn(`⚠️ findPostLink: Could not extract group_id from document URL: ${window.location.href}`); return null; } console.log(`🆔 findPostLink: Extracted group_id: ${groupId}`); // Step 2: Find post_id from image URLs in the post element const postId = extractPostIdFromImages(postElement); if (!postId) { console.warn(`⚠️ findPostLink: Could not extract post_id from image URLs in post element`); return null; } console.log(`🆔 findPostLink: Extracted post_id: ${postId}`); // Step 3: Generate the Facebook group post URL const postUrl = `https://www.facebook.com/groups/${groupId}/posts/${postId}`; console.log(`✅ findPostLink: Generated post URL: ${postUrl}`); return postUrl; } // Extract group_id from the current document URL function extractGroupIdFromDocument() { const url = window.location.href; console.log(`🔍 extractGroupIdFromDocument: Parsing URL: ${url}`); // Match pattern: /groups/{groupId} const groupIdMatch = url.match(/\/groups\/(\d+)/); if (groupIdMatch && groupIdMatch[1]) { return groupIdMatch[1]; } console.warn(`⚠️ extractGroupIdFromDocument: No group ID found in URL`); return null; } // Extract post_id from image URLs containing set=pcb.{postId} pattern function extractPostIdFromImages(postElement) { console.log(`🔍 extractPostIdFromImages: Searching for image URLs with post_id`); // Look for img elements with src or data-src containing set=pcb pattern const imgElements = postElement.querySelectorAll('img'); console.log(` Found ${imgElements.length} img elements to check`); for (let i = 0; i < imgElements.length; i++) { const img = imgElements[i]; const src = img.src || img.getAttribute('data-src') || img.getAttribute('src'); if (src) { console.log(` Img ${i + 1}: src="${src}"`); const postId = extractPostIdFromUrl(src); if (postId) { console.log(` ✅ Found post_id in img src: ${postId}`); return postId; } } } // Look for anchor elements with href containing set=pcb pattern const aElements = postElement.querySelectorAll('a[href*="set=pcb"]'); console.log(` Found ${aElements.length} anchor elements with set=pcb to check`); for (let i = 0; i < aElements.length; i++) { const anchor = aElements[i]; const href = anchor.getAttribute('href'); if (href) { console.log(` Anchor ${i + 1}: href="${href}"`); const postId = extractPostIdFromUrl(href); if (postId) { console.log(` ✅ Found post_id in anchor href: ${postId}`); return postId; } } } console.warn(`⚠️ extractPostIdFromImages: No post_id found in any image or anchor URLs`); return null; } // Extract post_id from URL containing set=pcb.{postId} parameter function extractPostIdFromUrl(url) { if (!url) return null; // Look for set=pcb.{postId} pattern const pcbMatch = url.match(/[?&]set=pcb\.(\d+)/); if (pcbMatch && pcbMatch[1]) { return pcbMatch[1]; } return null; } // Extract post ID from URL function extractPostId(href) { if (!href) return null; // Try different patterns for post ID extraction const patterns = [ /\/user\/(\d+)/, /\/posts\/(\d+)/, /story_fbid=(\d+)/, /permalink\/(\d+)/, /\/(\d+)\/$/ ]; for (const pattern of patterns) { const match = href.match(pattern); if (match && match[1]) { return match[1]; } } return null; } // Clean and construct proper post URL function cleanPostUrl(href) { if (!href) return null; let url = href; // Convert relative URLs to absolute if (url.startsWith('/')) { url = 'https://www.facebook.com' + url; } // Remove query parameters and hash url = url.split('?')[0].split('#')[0]; return url; } // Expand "See more" links to get full content - targets only specific Facebook pattern async function expandSeeMore(postElement) { try { // Add small delay to allow dynamic content to load await new Promise(resolve => setTimeout(resolve, 200)); // Target only the specific Facebook "See more" button pattern: // <div role="button" tabindex="0">See more</div> const specificSelector = 'div[role="button"][tabindex="0"]'; // Fresh DOM query to catch dynamically loaded content const candidateElements = Array.from(postElement.querySelectorAll(specificSelector)); console.log(`🔍 Found ${candidateElements.length} div[role="button"][tabindex="0"] elements to check`); for (let i = 0; i < candidateElements.length; i++) { const element = candidateElements[i]; const text = element.textContent?.trim().toLowerCase(); // Log each candidate element console.log(` Element ${i + 1}: text="${text}"`); // Exact match for "See more" only (case-insensitive) if (text === 'see more') { // Enhanced visibility check const isVisible = element.offsetParent !== null && element.offsetWidth > 0 && element.offsetHeight > 0 && window.getComputedStyle(element).visibility !== 'hidden' && window.getComputedStyle(element).display !== 'none'; if (isVisible) { console.log(`✅ Found exact Facebook "See more" button match`); try { // Simplified click approach for this specific pattern const clickSuccessful = await clickFacebookButton(element); if (clickSuccessful) { console.log('✅ Successfully clicked Facebook "See more" button'); // Wait longer for content to expand (increased from 800ms to 1500ms) await new Promise(resolve => setTimeout(resolve, 1500)); return true; // Return true if expanded } } catch (clickError) { console.error('❌ Error clicking Facebook "See more" button:', clickError); } } else { console.log(`⚠️ Facebook "See more" button found but not visible`); } } else if (text && text.length > 0) { // Log non-matching text for debugging console.log(` ⏭️ Skipping element with text "${text}" (not exact "see more" match)`); } } console.log('ℹ️ No Facebook "See more" button found or clicked successfully'); return false; // Return false if no "See more" found } catch (error) { console.error('❌ Error in expandSeeMore:', error); return false; } } // Simplified click function optimized for Facebook "See more" buttons async function clickFacebookButton(element) { try { // Approach 1: Standard click (most reliable for Facebook) element.click(); await new Promise(resolve => setTimeout(resolve, 100)); // Approach 2: Mouse events for Facebook's event system // const clickEvent = new MouseEvent('click', { // bubbles: true, // cancelable: true, // view: window, // detail: 1 // }); // element.dispatchEvent(clickEvent); // Brief delay to allow Facebook's handlers to process await new Promise(resolve => setTimeout(resolve, 50)); return true; } catch (error) { console.error('❌ Error in clickFacebookButton:', error); return false; } } // Extract images from post function extractPostImages(postElement) { try { const images = []; // Find all img elements within the post const imgElements = postElement.querySelectorAll('img'); for (const img of imgElements) { const src = img.src || img.getAttribute('data-src'); if (src && !src.startsWith('data:') && !src.includes('emoji')) { // Skip small images (likely icons or avatars) const width = img.naturalWidth || img.width || 0; const height = img.naturalHeight || img.height || 0; if (width >= 100 && height >= 100) { images.push({ url: src, alt: img.alt || '', width: width, height: height, selected: true // Default to selected }); } } } // Also check for background images in divs const divsWithBgImages = postElement.querySelectorAll('div[style*="background-image"]'); for (const div of divsWithBgImages) { const style = div.getAttribute('style'); const matches = style.match(/background-image:\s*url\(['"]?([^'")]+)['"]?\)/); if (matches && matches[1]) { const url = matches[1]; if (!url.startsWith('data:') && !url.includes('emoji')) { images.push({ url: url, alt: '', width: 0, height: 0, selected: true }); } } } console.log(`📸 Found ${images.length} images in post`); return images; } catch (error) { console.error('Error extracting post images:', error); return []; } } // Clean innerText content by removing UI elements and artifacts function cleanInnerTextContent(innerText) { if (!innerText || typeof innerText !== 'string') { return null; } console.log('🧹 Original innerText length:', innerText.length); console.log('🔍 First 200 chars:', innerText.substring(0, 200)); // Step 1: Split into lines for processing let lines = innerText.split('\n'); console.log(`📄 Total lines: ${lines.length}`); // Step 2: Advanced content start detection let contentStartIndex = -1; // Method 1: Look for content patterns (ALL CAPS, meaningful phrases) const contentPatterns = [ /^[A-Z][A-Z\s\-_]{10,}$/, // ALL CAPS titles like "FEMALE ROOMATE - " /^(Looking for|Searching for|Available|For rent|For sale)/i, /^(Hello|Hi|Bonjour|Hey)\s/i, /^(Apartment|House|Room|Car|Job)/i, /^\d+€|\$\d+|€\d+/, // Price indicators /^[A-Za-z]{3,}.*[a-zA-Z]{3,}/ // Lines with multiple real words ]; for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); // Skip empty lines if (!line) continue; // Skip obvious UI noise (single chars, scattered chars) if (line.length <= 3) continue; if (line.match(/^[a-zA-Z]$/)) continue; // Single letters if (line.match(/^[0-9]$/)) continue; // Single numbers if (line.match(/^·$/)) continue; // Middle dots if (line === 'Follow') continue; // Common UI element // Skip scattered characters pattern (multiple single chars separated by spaces) if (line.match(/^([a-zA-Z]\s){3,}[a-zA-Z]?$/)) { console.log(`⏭️ Skipping scattered chars: "${line}"`); continue; } // Skip author name/UI combinations if (line.includes('·') && line.includes('Follow')) { console.log(`⏭️ Skipping author/UI line: "${line}"`); continue; } // Check for content patterns let isContent = false; for (const pattern of contentPatterns) { if (pattern.test(line)) { console.log(`✅ Found content pattern match: "${line}"`); isContent = true; break; } } if (isContent) { contentStartIndex = i; break; } // Fallback: lines with reasonable length and multiple words if (line.length > 15 && line.split(/\s+/).length >= 3 && !line.includes('·')) { console.log(`📝 Found reasonable content fallback: "${line}"`); contentStartIndex = i; break; } } // Method 2: If no pattern match, look for longest meaningful lines if (contentStartIndex === -1) { console.log('🔄 No pattern match, trying longest line approach...'); let bestLine = { index: -1, score: 0 }; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line || line.length < 10) continue; if (line.includes('Follow') && line.includes('·')) continue; if (line.match(/^([a-zA-Z]\s){3,}/)) continue; // Skip scattered chars // Score based on length and word count const wordCount = line.split(/\s+/).length; const score = line.length + (wordCount * 5); if (score > bestLine.score && wordCount >= 3) { bestLine = { index: i, score: score }; console.log(`🎯 New best line (score ${score}): "${line}"`); } } if (bestLine.index !== -1) { contentStartIndex = bestLine.index; } } if (contentStartIndex === -1) { console.log('⚠️ Could not find content start'); return null; } console.log(`📍 Content starts at line ${contentStartIndex}: "${lines[contentStartIndex].trim()}"`); // Step 3: Find content end - look for engagement/comment section markers let contentEndIndex = lines.length; const endMarkers = [ /^·\s*(See original|Rate this translation)/i, /^\+\d+$/, // +8, +10, etc. /^All reactions:?$/i, /^\d+\s*\d*\s*(Like|Comment|Share)/i, // "10 4 Like Comment Share" /^(Like|Comment|Share)$/i, /^View more comments/i, /^Write a public comment/i, /^❌$/, /^[A-Za-z\s]+ · Follow$/, // "Name · Follow" pattern /^\d+[hdmyw]$/, // "25m", "1h", "2d", etc. /^See translation$/i, /^Reply$/i, /^View \d+ repl(y|ies)$/i ]; for (let i = contentStartIndex + 1; i < lines.length; i++) { const line = lines[i].trim(); // Check if this line matches any end marker for (const marker of endMarkers) { if (marker.test(line)) { console.log(`🛑 Found end marker at line ${i}: "${line}"`); contentEndIndex = i; break; } } if (contentEndIndex < lines.length) break; // Also end if we hit a line that looks like user comments (Name + short message) if (line.length < 100 && i < lines.length - 2 && lines[i + 1].trim().match(/^\d+[hdmyw]$/)) { // Followed by timestamp contentEndIndex = i; console.log(`🛑 Found comment pattern at line ${i}: "${line}"`); break; } } console.log(`📍 Content ends at line ${contentEndIndex}`); // Step 4: Extract and clean the main content if (contentStartIndex === -1 || contentStartIndex >= contentEndIndex) { console.log('⚠️ Could not find valid content boundaries'); return null; } let mainContent = lines.slice(contentStartIndex, contentEndIndex).join('\n'); // Step 5: Final cleaning mainContent = mainContent .replace(/\n{3,}/g, '\n\n') // Reduce excessive newlines .replace(/·\s*(See original|Rate this translation)/gi, '') // Remove translation artifacts .replace(/^\s+|\s+$/g, '') // Trim whitespace .replace(/\s+/g, ' ') // Normalize spaces (after newline processing) .trim(); console.log('✅ Cleaned content length:', mainContent.length); console.log('📝 Final content preview:', mainContent.substring(0, 150) + '...'); return mainContent.length > 30 ? mainContent : null; } // Validate cleaned content quality function validateContentQuality(content) { if (!content || typeof content !== 'string') { return { valid: false, reason: 'Empty or invalid content' }; } // Check minimum length if (content.length < 30) { return { valid: false, reason: 'Content too short' }; } // Check for common UI artifacts that shouldn't be in cleaned content const uiArtifacts = [ 'Like Comment Share', 'All reactions:', 'Write a public comment', '❌', /^\+\d+$/, /^\d+\s*\d*\s*Like/ ]; for (const artifact of uiArtifacts) { if (typeof artifact === 'string' && content.includes(artifact)) { return { valid: false, reason: `Contains UI artifact: "${artifact}"` }; } else if (artifact instanceof RegExp && artifact.test(content)) { return { valid: false, reason: `Contains UI pattern: ${artifact}` }; } } // Check content has reasonable word-to-character ratio (should contain spaces) const wordCount = content.split(/\s+/).length; const charCount = content.length; if (wordCount < charCount / 20) { // Less than 1 word per 20 characters suggests poor quality return { valid: false, reason: 'Poor word-to-character ratio' }; } return { valid: true, wordCount, charCount }; } // Extract text content from post function extractPostContent(postElement) { console.log(`📝 extractPostContent: Starting content extraction`); try { // First check if we have expanded content stored for this post element const expandedData = expandedContentMap.get(postElement); if (expandedData && expandedData.expandedContent) { console.log(`📋 Using stored expanded content (${expandedData.expandedContent.length} chars)`); return expandedData.expandedContent; } // Primary method: Use innerText with comprehensive cleaning if (postElement.innerText) { console.log(`🎯 Trying innerText extraction (${postElement.innerText.length} chars raw)`); const cleanedContent = cleanInnerTextContent(postElement.innerText); if (cleanedContent) { const validation = validateContentQuality(cleanedContent); if (validation.valid) { console.log(`✅ Valid innerText content: ${validation.wordCount} words, ${validation.charCount} chars`); return cleanedContent; } else { console.log(`⚠️ innerText content quality check failed: ${validation.reason}`); } } else { console.log(`⚠️ innerText cleaning returned null/empty`); } } else { console.log(`⚠️ No innerText available on post element`); } console.log('🔄 Falling back to selector-based extraction...'); // Fallback: Try to find the main content area using selectors const contentSelectors = [ '[data-testid="post_message"]', '[data-ad-preview="message"]', 'div[data-testid="post_message_root"]', 'div[dir="auto"]', '.userContent', '.text_exposed_root', '[role="article"] > div > div > div > div' ]; for (let i = 0; i < contentSelectors.length; i++) { const selector = contentSelectors[i]; console.log(` Trying selector ${i + 1}/${contentSelectors.length}: ${selector}`); const contentElements = postElement.querySelectorAll(selector); console.log(` Found ${contentElements.length} elements`); for (let j = 0; j < contentElements.length; j++) { const contentElement = contentElements[j]; if (contentElement && contentElement.textContent) { let content = contentElement.textContent; console.log(` Element ${j + 1}: ${content.length} chars raw`); // Skip if this looks like a navigation or UI element if (content.toLowerCase().includes('like') && content.toLowerCase().includes('comment') && content.toLowerCase().includes('share')) { console.log(` Skipping UI element`); continue; } // Clean up the content content = content .replace(/\s+/g, ' ') // Normalize whitespace .replace(/^\s+|\s+$/g, '') // Trim .replace(/See more\.?$/i, '') // Remove trailing "See more" .replace(/Show more\.?$/i, '') // Remove trailing "Show more" .replace(/Continue reading\.?$/i, '') // Remove "Continue reading" .replace(/\.\.\./g, ''); // Remove ellipsis if (content.length > 30 && content.length < 10000) { // Reasonable content length console.log(` ✅ Found valid content via selector (${content.length} chars)`); return content; } else { console.log(` Content length ${content.length} not in valid range (30-10000)`); } } } } console.log('🔄 Trying final fallback - raw text extraction...'); // Enhanced fallback: get text but exclude common UI elements const allText = postElement.textContent || ''; console.log(`Raw textContent: ${allText.length} chars`); if (allText.length === 0) { console.warn(`⚠️ Post element has no textContent`); return null; } const cleanText = allText .replace(/\s+/g, ' ') .replace(/Like.*Comment.*Share/gi, '') // Remove common UI text .replace(/\d+\s*(likes?|comments?|shares?)/gi, '') // Remove counts .trim() .substring(0, 2000); // Reasonable limit console.log(`Final cleaned text: ${cleanText.length} chars`); if (cleanText.length > 30) { console.log(`✅ Using fallback content extraction (${cleanText.length} chars)`); return cleanText; } else { console.warn(`⚠️ Final fallback also failed - content too short: "${cleanText}"`); return null; } } catch (error) { console.error('❌ Error in extractPostContent:', error); console.error('Post element:', postElement); return null; } } // Main collection handler async function handleCollectionClick() { if (isCollecting) return; // Check if any posts are selected const selectedCount = Array.from(pagePostCheckboxes.values()) .filter(data => data.checkbox.checked).length; console.log(`🎯 Collection clicked: ${selectedCount} posts selected`); if (selectedCount === 0) { showToast('No posts selected. Check some post checkboxes first!', true); return; } const button = document.querySelector('.fbgpc-button'); button.disabled = true; button.innerHTML = ` <div class="fbgpc-spinner"></div> Collecting... `; isCollecting = true; try { await collectPosts(); console.log(`📊 Collection results: ${collectedPosts.length} posts collected from ${selectedCount} selected`); if (collectedPosts.length > 0) { console.log(`🎉 Showing selection dialog with ${collectedPosts.length} posts`); showSelectionDialog(); } else { console.error(`🚨 PROBLEM: ${selectedCount} posts were selected but 0 were collected!`); showToast(`No posts could be processed from the ${selectedCount} selected posts. Check console for details.`, true); } } catch (error) { console.error('❌ Error in handleCollectionClick:', error); showToast('Error collecting posts: ' + error.message, true); } finally { isCollecting = false; button.disabled = false; updateCollectButtonCount(); // Restore count display } } // Global variables for page post management let pagePostCounter = 0; let pagePostCheckboxes = new Map(); // Map of post element -> checkbox data let expandedContentMap = new Map(); // Map of post element -> expanded content data let mutationObserver = null; // Find and add checkboxes to all posts on the page function scanAndMarkPosts() { const postSelectors = [ 'div[style*="border-radius: max(0px, min(var(--card-corner-radius), calc((100vw - 4px - 100%) * 9999))) / var(--card-corner-radius);"]' ]; let newPostsFound = 0; for (const selector of postSelectors) { const elements = document.querySelectorAll(selector); for (const element of elements) { // Go 3 levels up to get post container let postContainer = element; for (let i = 0; i < 3; i++) { postContainer = postContainer.parentElement; if (!postContainer) break; } if (postContainer && !pagePostCheckboxes.has(postContainer)) { addCheckboxToPost(postContainer); newPostsFound++; } } } if (newPostsFound > 0) { console.log(`📋 Added checkboxes to ${newPostsFound} new posts`); updateCollectButtonCount(); } } // Add a checkbox overlay to a specific post function addCheckboxToPost(postElement) { // Add relative positioning class postElement.classList.add('fbgpc-post-container'); // Create overlay container const overlay = document.createElement('div'); overlay.className = 'fbgpc-page-post-overlay'; // Create checkbox const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.className = 'fbgpc-page-post-checkbox'; checkbox.checked = false; // Default to unchecked // Create counter/label const counter = document.createElement('span'); counter.className = 'fbgpc-page-post-counter'; counter.textContent = `#${++pagePostCounter}`; // Add event listener checkbox.addEventListener('change', async () => { if (checkbox.checked) { overlay.classList.remove('unchecked'); // Trigger content expansion when post is selected await handlePostSelection(postElement, overlay, counter); } else { overlay.classList.add('unchecked'); // Remove expanded content from storage when unchecked expandedContentMap.delete(postElement); } updateCollectButtonCount(); }); // Make overlay clickable overlay.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); checkbox.checked = !checkbox.checked; checkbox.dispatchEvent(new Event('change')); }); // Prevent checkbox click from bubbling checkbox.addEventListener('click', (e) => { e.stopPropagation(); }); // Assemble overlay overlay.appendChild(checkbox); overlay.appendChild(counter); // Add to post postElement.appendChild(overlay); // Store reference pagePostCheckboxes.set(postElement, { checkbox: checkbox, counter: counter, overlay: overlay, postId: pagePostCounter }); } // Handle post selection - expand content immediately async function handlePostSelection(postElement, overlay, counter) { try { // Store original content before expansion const originalContent = extractPostContent(postElement); // Show loading indicator counter.textContent = '⏳'; overlay.style.opacity = '0.7'; // Attempt to expand "See more" const expandResult = await expandSeeMore(postElement); // Extract content after expansion attempt const finalContent = extractPostContent(postElement); // Store the expanded content data expandedContentMap.set(postElement, { originalContent: originalContent, expandedContent: finalContent, wasExpanded: expandResult, expandedAt: Date.now() }); // Update visual feedback based on expansion result if (expandResult && finalContent && finalContent.length > (originalContent?.length || 0)) { counter.textContent = '✅'; // Successfully expanded console.log(`✅ Post expanded: ${originalContent?.length || 0} -> ${finalContent.length} characters`); console.log(`📝 Final content preview: "${finalContent.substring(0, 150)}..."`); } else if (!expandResult && finalContent) { counter.textContent = '📄'; // No "See more" found, using current content console.log(`📄 No expansion needed for this post (${finalContent.length} chars)`); console.log(`📝 Content preview: "${finalContent}"`); } else { counter.textContent = '❌'; // Failed to get content console.log('❌ Failed to extract content from post'); } // Reset visual state overlay.style.opacity = '1'; } catch (error) { console.error('❌ Error handling post selection:', error); counter.textContent = '❌'; overlay.style.opacity = '1'; } } // Update collect button with selected count function updateCollectButtonCount() { const button = document.querySelector('.fbgpc-button'); if (!button) return; const selectedCount = Array.from(pagePostCheckboxes.values()) .filter(data => data.checkbox.checked).length; const totalCount = pagePostCheckboxes.size; // Update button text const icon = `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> <path d="M3 2a1 1 0 011-1h8a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V2zm1 0v12h8V2H4z"/> <path d="M6 4h4v1H6V4zm0 2h4v1H6V6zm0 2h4v1H6V8z"/> </svg>`; button.innerHTML = `${icon} Collect Posts (${selectedCount}/${totalCount})`; } // Create bulk control panel function createBulkControls() { const controls = document.createElement('div'); controls.className = 'fbgpc-bulk-controls'; const selectAllBtn = document.createElement('button'); selectAllBtn.className = 'fbgpc-bulk-btn'; selectAllBtn.textContent = 'Select All'; selectAllBtn.addEventListener('click', selectAllPagePosts); const selectNoneBtn = document.createElement('button'); selectNoneBtn.className = 'fbgpc-bulk-btn'; selectNoneBtn.textContent = 'Select None'; selectNoneBtn.addEventListener('click', selectNoPagePosts); const rescanBtn = document.createElement('button'); rescanBtn.className = 'fbgpc-bulk-btn'; rescanBtn.textContent = 'Rescan Posts'; rescanBtn.addEventListener('click', scanAndMarkPosts); controls.appendChild(selectAllBtn); controls.appendChild(selectNoneBtn); controls.appendChild(rescanBtn); document.body.appendChild(controls); return controls; } // Select all page posts function selectAllPagePosts() { pagePostCheckboxes.forEach(data => { if (!data.checkbox.checked) { data.checkbox.checked = true; data.overlay.classList.remove('unchecked'); } }); updateCollectButtonCount(); } // Select no page posts function selectNoPagePosts() { pagePostCheckboxes.forEach(data => { if (data.checkbox.checked) { data.checkbox.checked = false; data.overlay.classList.add('unchecked'); } }); updateCollectButtonCount(); } // Set up MutationObserver to monitor for new posts function setupPostMonitoring() { if (mutationObserver) { mutationObserver.disconnect(); } mutationObserver = new MutationObserver((mutations) => { let shouldScan = false; mutations.forEach((mutation) => { if (mutation.type === 'childList') { // Check if new nodes were added mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { // Check if the added node or its descendants contain post elements if (node.querySelector && ( node.querySelector('div[style*="border-radius: max(0px, min(var(--card-corner-radius), calc((100vw - 4px - 100%) * 9999))) / var(--card-corner-radius);"]') || node.matches && node.matches('div[style*="border-radius: max(0px, min(var(--card-corner-radius), calc((100vw - 4px - 100%) * 9999))) / var(--card-corner-radius);"]') )) { shouldScan = true; } } }); } }); if (shouldScan) { // Debounce scanning to avoid excessive calls clearTimeout(window.fbgpcScanTimeout); window.fbgpcScanTimeout = setTimeout(() => { scanAndMarkPosts(); }, 500); } }); // Start observing mutationObserver.observe(document.body, { childList: true, subtree: true }); console.log('🔍 Started monitoring for new posts'); } // Clean up removed posts from our tracking function cleanupRemovedPosts() { const toRemove = []; pagePostCheckboxes.forEach((data, postElement) => { if (!document.contains(postElement)) { toRemove.push(postElement); } }); toRemove.forEach(postElement => { pagePostCheckboxes.delete(postElement); expandedContentMap.delete(postElement); // Also clean up expanded content }); if (toRemove.length > 0) { console.log(`🧹 Cleaned up ${toRemove.length} removed posts`); updateCollectButtonCount(); } } // Debug status function function showDebugStatus() { console.log('🔍 === DEBUG STATUS ==='); console.log(`📊 Total posts tracked: ${pagePostCheckboxes.size}`); console.log(`✅ Selected posts: ${Array.from(pagePostCheckboxes.values()).filter(data => data.checkbox.checked).length}`); console.log(`📦 Collected posts in memory: ${collectedPosts.length}`); console.log(`💾 Expanded content entries: ${expandedContentMap.size}`); // Show details of selected posts const selectedPosts = Array.from(pagePostCheckboxes.entries()) .filter(([postElement, data]) => data.checkbox.checked); console.log('\n📋 Selected Posts Details:'); selectedPosts.forEach(([postElement, data], index) => { const expandedData = expandedContentMap.get(postElement); console.log(` ${index + 1}. Post #${data.postId}`); console.log(` - Checkbox: ${data.checkbox.checked ? 'checked' : 'unchecked'}`); console.log(` - In DOM: ${document.contains(postElement) ? 'yes' : 'no'}`); console.log(` - Has expanded content: ${expandedData ? 'yes (' + expandedData.expandedContent?.length + ' chars)' : 'no'}`); }); // Show collected posts console.log('\n📦 Collected Posts Array:'); collectedPosts.forEach((post, index) => { console.log(` ${index + 1}. ID: ${post.post_id}, URL: ${post.post_url}, Content: ${post.post_content?.length || 0} chars`); }); // Show a status toast showToast(`Debug: ${pagePostCheckboxes.size} tracked, ${Array.from(pagePostCheckboxes.values()).filter(data => data.checkbox.checked).length} selected, ${collectedPosts.length} collected`); console.log('🔍 === END DEBUG STATUS ===\n'); } // Test content cleaning function function testContentCleaning() { console.log('🧪 === TESTING CONTENT CLEANING ==='); // Test with the provided example const testInput = `Esmeralda Casilla Ramos · Follow r n p o S s t d o e c 4 7 1 4 4 5 t m 7 4 1 u f l 2 1 g m 2 6 6 7 l c 7 7 0 2 u 7 t h t 9 2 f f g g 0 5 h l 1 2 u 4 l 6 · FEMALE ROOMATE - JEAN JAURES UNIVERSITY (UT2) - 2 MINUTES WALK FROM EVERYTHING M️ TM️ COL. hello Looking for a new roommate to fill a 4 bedroom female roommate. The apartment is currently occupied by three female students: Inès, 21 years old, a dynamic student and very kind. Emma, 29 years old, PhD student in archaeology, accompanied by Simone , her little French Bulldog (she's really too cute). Blanche, 18 years old who joins the roommate in early September They look forward to meeting you when you visit! APARTMENT DESCRIPTION: A T5 crossing East-West fully furnished and equipped 80 m2 with an optimal layout and a large private garden. THE ROOM: - Bright and fully renovated. - 2-seater bed with comfortable mattress, desk, wardrobe, and individual lock. THE MOST: - Individual lease. - Housekeeping service - Fiber connection, wifi, large television connected. - New appliances (washing machine, dishwasher, XXL fridge, etc.) ). - Eligible aux APL. LOCATION: - Subway at 100m, line (A). - UT2, University of Toulouse Jean Jaurès 10 min by metro. - UT1, Capitol University 20 min by subway - Bus (line 13) at the foot of the apartment. - Various shops and services less than 150 m. PRICE : 470 € + 30 € package charges included (electricity, water, heating, internet, cleaning, etc.) ). No expected charge adjustment. APL simulation available on the CAF website. VISIT: Currently available for physical visits. YOUR APPLICATION IN 5 STEPS: 1) World fact sheet to return to me to complete. 2) Planning your meeting with tenants. 3) Tenants' decision to continue the proceedings. 4) Receipt of your candidacy application. 5) Decision of the owner. For more information, photos or videos, hit me up! Thank you. · See original · Rate this translation All reactions: 5 5 8 3 Like Comment Share View more comments Anne-Sophie Heckel`; console.log('🧪 Testing with Facebook post example...'); const result = cleanInnerTextContent(testInput); console.log('🧪 === TEST RESULTS ==='); if (result) { console.log('✅ SUCCESS: Function returned content'); console.log(`📏 Length: ${result.length} characters`); console.log('📝 Content:'); console.log(result); // Check if it contains expected content if (result.includes('FEMALE ROOMATE') || result.includes('Looking for a new roommate')) { console.log('✅ VALIDATION: Contains expected content!'); showToast('✅ Content cleaning test PASSED!'); } else { console.log('❌ VALIDATION: Does not contain expected content'); showToast('❌ Content cleaning test FAILED - missing expected content', true); } } else { console.log('❌ FAILURE: Function returned null/empty'); showToast('❌ Content cleaning test FAILED - no content returned', true); } console.log('🧪 === END CONTENT CLEANING TEST ===\n'); } // Show the post selection dialog function showSelectionDialog() { console.log(`💬 showSelectionDialog: Starting with ${collectedPosts.length} collected posts`); // Validation check if (!collectedPosts || collectedPosts.length === 0) { console.error('🚨 showSelectionDialog: No collected posts available!'); showToast('Error: No posts were collected to display', true); return; } // Log collected post details for debugging collectedPosts.forEach((post, index) => { console.log(` Post ${index + 1}: ID=${post.post_id}, URL=${post.post_url}, Content=${post.post_content ? post.post_content.length + ' chars' : 'null'}`); }); // Remove existing dialog if present if (selectionDialog) { console.log(`🧹 Cleaning up existing dialog`); selectionDialog.remove(); } console.log(`🎨 Creating dialog elements...`); // Create overlay selectionDialog = document.createElement('div'); selectionDialog.className = 'fbgpc-overlay'; // Create dialog const dialog = document.createElement('div'); dialog.className = 'fbgpc-dialog'; // Header const header = document.createElement('div'); header.className = 'fbgpc-header'; const title = document.createElement('div'); title.className = 'fbgpc-title'; title.textContent = `Select Posts to Send (${collectedPosts.length} found)`; const closeBtn = document.createElement('button'); closeBtn.className = 'fbgpc-close'; closeBtn.innerHTML = '×'; header.appendChild(title); header.appendChild(closeBtn); // Content const content = document.createElement('div'); content.className = 'fbgpc-content'; // Controls const controls = document.createElement('div'); controls.className = 'fbgpc-controls'; const selectAllBtn = document.createElement('button'); selectAllBtn.className = 'fbgpc-control-btn'; selectAllBtn.textContent = 'Select All'; const selectNoneBtn = document.createElement('button'); selectNoneBtn.className = 'fbgpc-control-btn'; selectNoneBtn.textContent = 'Select None'; const selectionCount = document.createElement('span'); selectionCount.className = 'fbgpc-status'; selectionCount.id = 'fbgpc-selection-count'; selectionCount.textContent = `${collectedPosts.length} selected`; // All selected by default controls.appendChild(selectAllBtn); controls.appendChild(selectNoneBtn); controls.appendChild(selectionCount); // Posts list const postsList = document.createElement('div'); postsList.className = 'fbgpc-posts'; postsList.id = 'fbgpc-posts-list'; console.log(`📄 Creating post items for ${collectedPosts.length} posts...`); // Populate posts let itemsCreated = 0; collectedPosts.forEach((post, index) => { try { const postItem = createPostItem(post, index); if (postItem) { postsList.appendChild(postItem); itemsCreated++; console.log(` ✅ Created item ${itemsCreated} for post ${index + 1}`); } else { console.warn(` ⚠️ createPostItem returned null for post ${index + 1}`); } } catch (error) { console.error(` ❌ Error creating item for post ${index + 1}:`, error); } }); console.log(`📄 Created ${itemsCreated} post items out of ${collectedPosts.length} posts`); // Validation: ensure posts list has child elements if (postsList.children.length === 0) { console.error('🚨 CRITICAL: Posts list is empty after attempting to populate!'); console.error('CollectedPosts array:', collectedPosts); // Add error message to the list const errorDiv = document.createElement('div'); errorDiv.style.padding = '20px'; errorDiv.style.textAlign = 'center'; errorDiv.style.color = '#e74c3c'; errorDiv.textContent = `Error: Failed to create post list items. Check console for details.`; postsList.appendChild(errorDiv); } content.appendChild(controls); content.appendChild(postsList); // Footer const footer = document.createElement('div'); footer.className = 'fbgpc-footer'; const apiStatus = document.createElement('div'); apiStatus.className = 'fbgpc-status'; apiStatus.id = 'fbgpc-api-status'; const actions = document.createElement('div'); actions.className = 'fbgpc-actions'; const cancelBtn = document.createElement('button'); cancelBtn.className = 'fbgpc-btn fbgpc-btn-secondary'; cancelBtn.textContent = 'Cancel'; const sendBtn = document.createElement('button'); sendBtn.className = 'fbgpc-btn fbgpc-btn-primary'; sendBtn.id = 'fbgpc-send-btn'; sendBtn.textContent = 'Send Posts'; sendBtn.disabled = false; // Enable since all are checked by default actions.appendChild(cancelBtn); actions.appendChild(sendBtn); footer.appendChild(apiStatus); footer.appendChild(actions); // Assemble dialog dialog.appendChild(header); dialog.appendChild(content); dialog.appendChild(footer); selectionDialog.appendChild(dialog); // Add to page document.body.appendChild(selectionDialog); console.log(`🎆 Dialog added to page`); // Add event listeners after elements are in the DOM closeBtn.addEventListener('click', closeSelectionDialog); selectAllBtn.addEventListener('click', selectAllPosts); selectNoneBtn.addEventListener('click', selectNonePosts); cancelBtn.addEventListener('click', closeSelectionDialog); sendBtn.addEventListener('click', sendSelectedPosts); // Add overlay click to close (click outside dialog) selectionDialog.addEventListener('click', (e) => { if (e.target === selectionDialog) { closeSelectionDialog(); } }); // Prevent dialog clicks from closing the modal dialog.addEventListener('click', (e) => { e.stopPropagation(); }); // Add keyboard support const handleKeydown = (e) => { if (e.key === 'Escape') { closeSelectionDialog(); document.removeEventListener('keydown', handleKeydown); } }; document.addEventListener('keydown', handleKeydown); // Store the keydown handler for cleanup selectionDialog._keydownHandler = handleKeydown; // Update selection count (should show all selected since checkboxes default to checked) updateSelectionCount(); console.log(`✅ Dialog creation complete - ${postsList.children.length} items in list`); // Make functions globally accessible (for debugging) window.closeSelectionDialog = closeSelectionDialog; window.selectAllPosts = selectAllPosts; window.selectNonePosts = selectNonePosts; window.sendSelectedPosts = sendSelectedPosts; window.togglePost = togglePost; } // Create a post item for the selection list function createPostItem(post, index) { console.log(`🔨 createPostItem: Creating item ${index + 1}`); // Validate post data if (!post) { console.error(`❌ createPostItem: Post ${index + 1} is null/undefined`); return null; } if (!post.post_content) { console.error(`❌ createPostItem: Post ${index + 1} has no content:`, post); return null; } if (!post.post_url) { console.error(`❌ createPostItem: Post ${index + 1} has no URL:`, post); return null; } console.log(` ✅ Post ${index + 1} validation passed (${post.post_content.length} chars)`); try { const item = document.createElement('div'); item.className = 'fbgpc-post-item'; // Truncate content for preview const previewContent = post.post_content.length > 200 ? post.post_content.substring(0, 200) + '...' : post.post_content; // Create checkbox element const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.className = 'fbgpc-post-checkbox'; checkbox.id = `post-${index}`; checkbox.checked = true; // Default to checked // Create content container const contentDiv = document.createElement('div'); contentDiv.className = 'fbgpc-post-content'; // Basic post info const postInfo = document.createElement('div'); postInfo.innerHTML = ` <div class="fbgpc-post-url">${post.post_url}</div> <div class="fbgpc-post-text ${post.post_content.length > 200 ? 'truncated' : ''}">${previewContent}</div> `; contentDiv.appendChild(postInfo); // Add images section if post has images if (post.images && post.images.length > 0) { console.log(` 📸 Adding images section (${post.images.length} images)`); const imagesSection = createImagesSection(post.images, index); if (imagesSection) { contentDiv.appendChild(imagesSection); } } // Add event listener to checkbox checkbox.addEventListener('change', () => togglePost(index)); // Assemble the item item.appendChild(checkbox); item.appendChild(contentDiv); console.log(` ✅ Post item ${index + 1} created successfully`); return item; } catch (error) { console.error(`❌ createPostItem: Error creating item for post ${index + 1}:`, error); console.error('Post data:', post); return null; } } // Create images section for a post function createImagesSection(images, postIndex) { const imagesDiv = document.createElement('div'); imagesDiv.className = 'fbgpc-post-images'; // Images header with toggle const header = document.createElement('div'); header.className = 'fbgpc-images-header'; const toggleBtn = document.createElement('button'); toggleBtn.className = 'fbgpc-images-toggle'; toggleBtn.textContent = `📸 Images (${images.length}) - Click to expand`; toggleBtn.setAttribute('data-expanded', 'false'); header.appendChild(toggleBtn); // Images grid (initially hidden) const grid = document.createElement('div'); grid.className = 'fbgpc-images-grid'; grid.style.display = 'none'; images.forEach((image, imageIndex) => { const imageItem = document.createElement('div'); imageItem.className = 'fbgpc-image-item'; const img = document.createElement('img'); img.className = 'fbgpc-image-preview'; img.src = image.url; img.alt = image.alt; img.loading = 'lazy'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.className = 'fbgpc-image-checkbox'; checkbox.checked = image.selected; checkbox.addEventListener('change', () => toggleImageSelection(postIndex, imageIndex)); const info = document.createElement('div'); info.className = 'fbgpc-image-info'; info.textContent = image.width && image.height ? `${image.width}×${image.height}` : 'Loading...'; imageItem.appendChild(img); imageItem.appendChild(checkbox); imageItem.appendChild(info); grid.appendChild(imageItem); }); // Include images toggle const includeDiv = document.createElement('div'); includeDiv.className = 'fbgpc-include-images'; includeDiv.style.display = 'none'; const includeCheckbox = document.createElement('input'); includeCheckbox.type = 'checkbox'; includeCheckbox.checked = collectedPosts[postIndex]?.includeImages || false; includeCheckbox.addEventListener('change', () => toggleIncludeImages(postIndex)); const includeLabel = document.createElement('span'); includeLabel.textContent = 'Include images when sending this post'; includeDiv.appendChild(includeCheckbox); includeDiv.appendChild(includeLabel); // Toggle functionality toggleBtn.addEventListener('click', (e) => { e.preventDefault(); const expanded = toggleBtn.getAttribute('data-expanded') === 'true'; if (expanded) { grid.style.display = 'none'; includeDiv.style.display = 'none'; toggleBtn.textContent = `📸 Images (${images.length}) - Click to expand`; toggleBtn.setAttribute('data-expanded', 'false'); } else { grid.style.display = 'grid'; includeDiv.style.display = 'flex'; toggleBtn.textContent = `📸 Images (${images.length}) - Click to collapse`; toggleBtn.setAttribute('data-expanded', 'true'); } }); imagesDiv.appendChild(header); imagesDiv.appendChild(grid); imagesDiv.appendChild(includeDiv); return imagesDiv; } // Toggle post selection function togglePost(index) { updateSelectionCount(); } // Toggle individual image selection function toggleImageSelection(postIndex, imageIndex) { if (collectedPosts[postIndex] && collectedPosts[postIndex].images[imageIndex]) { collectedPosts[postIndex].images[imageIndex].selected = !collectedPosts[postIndex].images[imageIndex].selected; updateSelectionCount(); } } // Toggle include images for a post function toggleIncludeImages(postIndex) { if (collectedPosts[postIndex]) { collectedPosts[postIndex].includeImages = !collectedPosts[postIndex].includeImages; updateSelectionCount(); } } // Select all posts function selectAllPosts() { const checkboxes = document.querySelectorAll('.fbgpc-post-checkbox'); checkboxes.forEach(checkbox => { checkbox.checked = true; }); updateSelectionCount(); } // Select no posts function selectNonePosts() { const checkboxes = document.querySelectorAll('.fbgpc-post-checkbox'); checkboxes.forEach(checkbox => { checkbox.checked = false; }); updateSelectionCount(); } // Update selection count and button state function updateSelectionCount() { const checkboxes = document.querySelectorAll('.fbgpc-post-checkbox'); const selectedCount = Array.from(checkboxes).filter(cb => cb.checked).length; // Count selected images across all posts let totalImages = 0; let selectedImages = 0; collectedPosts.forEach((post, index) => { const postCheckbox = document.getElementById(`post-${index}`); if (postCheckbox && postCheckbox.checked && post.images && post.includeImages) { totalImages += post.images.length; selectedImages += post.images.filter(img => img.selected).length; } }); const countElement = document.getElementById('fbgpc-selection-count'); const sendButton = document.getElementById('fbgpc-send-btn'); if (countElement) { let statusText = `${selectedCount} posts selected`; if (selectedImages > 0) { statusText += `, ${selectedImages} images`; } countElement.textContent = statusText; } if (sendButton) { sendButton.disabled = selectedCount === 0; } } // Close the selection dialog function closeSelectionDialog() { if (selectionDialog) { // Clean up keyboard event listener if (selectionDialog._keydownHandler) { document.removeEventListener('keydown', selectionDialog._keydownHandler); } // Remove dialog from DOM if (selectionDialog.parentNode) { selectionDialog.remove(); } } selectionDialog = null; // Clean up global functions if (typeof window.closeSelectionDialog !== 'undefined') { delete window.closeSelectionDialog; } if (typeof window.selectAllPosts !== 'undefined') { delete window.selectAllPosts; } if (typeof window.selectNonePosts !== 'undefined') { delete window.selectNonePosts; } if (typeof window.sendSelectedPosts !== 'undefined') { delete window.sendSelectedPosts; } if (typeof window.togglePost !== 'undefined') { delete window.togglePost; } } // Download image as blob from URL async function downloadImage(imageUrl) { try { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: imageUrl, responseType: 'blob', onload: function(response) { if (response.status === 200) { resolve(response.response); } else { reject(new Error(`Failed to download image: ${response.status}`)); } }, onerror: function(error) { reject(new Error(`Error downloading image: ${error}`)); } }); }); } catch (error) { console.error('Error downloading image:', error); throw error; } } // Upload image to server async function uploadImageToServer(submissionId, imageBlob, imageName) { try { const formData = new FormData(); formData.append('listing_id', submissionId); formData.append('file', imageBlob, imageName); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: 'http://localhost:8080/upload', data: formData, onload: function(response) { if (response.status === 200) { try { const result = JSON.parse(response.responseText); resolve(result); } catch (e) { reject(new Error('Invalid response format')); } } else { reject(new Error(`Upload failed: ${response.status} - ${response.responseText}`)); } }, onerror: function(error) { reject(new Error(`Upload error: ${error}`)); } }); }); } catch (error) { console.error('Error uploading image:', error); throw error; } } // Process images for selected posts async function processPostImages(submissionId, selectedPosts, statusElement) { let totalImages = 0; let uploadedImages = 0; let failedImages = 0; // Count total images to upload selectedPosts.forEach(post => { if (post.includeImages && post.images) { totalImages += post.images.filter(img => img.selected).length; } }); if (totalImages === 0) { console.log('No images to upload'); return { totalImages, uploadedImages, failedImages }; } statusElement.textContent = `Uploading images: 0/${totalImages}...`; for (const post of selectedPosts) { if (!post.includeImages || !post.images) continue; for (const [imageIndex, image] of post.images.entries()) { if (!image.selected) continue; try { statusElement.textContent = `Uploading images: ${uploadedImages}/${totalImages}...`; // Download image const imageBlob = await downloadImage(image.url); // Generate filename const timestamp = Date.now(); const filename = `post_${post.post_id}_img_${imageIndex}_${timestamp}.jpg`; // Upload to server const uploadResult = await uploadImageToServer(submissionId, imageBlob, filename); if (uploadResult.success) { uploadedImages++; console.log(`✅ Uploaded image ${uploadedImages}/${totalImages}:`, filename); } else { failedImages++; console.error(`❌ Failed to upload image:`, uploadResult.error); } } catch (error) { failedImages++; console.error(`❌ Error processing image from ${image.url}:`, error); } } } return { totalImages, uploadedImages, failedImages }; } // Send selected posts to API async function sendSelectedPosts() { const checkboxes = document.querySelectorAll('.fbgpc-post-checkbox'); const selectedPosts = []; checkboxes.forEach((checkbox, index) => { if (checkbox.checked && collectedPosts[index]) { // Validate post data before adding const post = collectedPosts[index]; if (post.post_id && post.post_url && post.post_content) { // Additional content quality validation const contentValidation = validateContentQuality(post.post_content); if (contentValidation.valid) { selectedPosts.push(post); console.log(`✅ Post ${post.post_id}: Valid content (${contentValidation.wordCount} words)`); } else { console.warn(`⚠️ Skipping post ${post.post_id}: ${contentValidation.reason}`); console.warn('Content preview:', post.post_content); } } else { console.warn('Skipping invalid post data:', post); } } }); if (selectedPosts.length === 0) { showToast('No valid posts selected', true); return; } const sendButton = document.getElementById('fbgpc-send-btn'); const statusElement = document.getElementById('fbgpc-api-status'); // Show loading state sendButton.disabled = true; sendButton.innerHTML = ` <div class="fbgpc-spinner"></div> Sending... `; statusElement.textContent = `Sending ${selectedPosts.length} posts...`; try { // Step 1: Send posts to server const postsResponse = await new Promise((resolve, reject) => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout GM_xmlhttpRequest({ method: "POST", url: 'http://localhost:8080/fetch_post', headers: { "Content-Type": "application/json", }, data: JSON.stringify({ posts: selectedPosts.map(post => ({ post_id: post.post_id, post_url: post.post_url, post_content: post.post_content })), timestamp: new Date().toISOString(), source: 'facebook-group-post-collector', version: SCRIPT_VERSION }), onload: function(response) { clearTimeout(timeoutId); if (response.status === 200) { try { const responseData = JSON.parse(response.responseText); resolve(responseData); } catch (e) { reject(new Error('Invalid response format')); } } else { reject(new Error(`Server responded with status ${response.status}: ${response.responseText}`)); } }, onerror: function(error) { clearTimeout(timeoutId); reject(new Error(`Cannot connect to localhost:8080 - Make sure your server is running`)); } }); }); console.log('✅ Posts sent successfully:', postsResponse); // Step 2: Upload images if any const imageResults = await processPostImages( postsResponse.submission_id, selectedPosts, statusElement ); // Step 3: Show final results let successMessage = `Successfully sent ${selectedPosts.length} posts`; if (imageResults.totalImages > 0) { successMessage += ` and ${imageResults.uploadedImages}/${imageResults.totalImages} images`; if (imageResults.failedImages > 0) { successMessage += ` (${imageResults.failedImages} images failed)`; } } showToast(successMessage); console.log('✅ Complete results:', { posts: postsResponse, images: imageResults }); closeSelectionDialog(); } catch (error) { console.error('Error sending posts:', error); let errorMessage = error.message; if (error.name === 'AbortError') { errorMessage = 'Request timed out after 30 seconds'; } else if (error.message.includes('connect')) { errorMessage = 'Cannot connect to localhost:8080 - Make sure your server is running'; } showToast(`Error sending posts: ${errorMessage}`, true); statusElement.textContent = `Error: ${errorMessage}`; // Restore button state sendButton.disabled = false; sendButton.textContent = 'Send Posts'; } } // Initialize the script function initialize() { // Check if we're on a Facebook group page if (!window.location.pathname.includes('/groups/')) { return; } // Wait for page to load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { setTimeout(initialize, 1000); }); return; } // Inject styles and create buttons injectStyles(); createCollectionButton(); createBulkControls(); // Set up post monitoring and initial scan setupPostMonitoring(); setTimeout(() => { scanAndMarkPosts(); }, 1000); // Delay to let page load // Periodic cleanup of removed posts setInterval(cleanupRemovedPosts, 10000); // Every 10 seconds // Register menu commands GM.registerMenuCommand('Collect Group Posts', handleCollectionClick); GM.registerMenuCommand('Rescan Posts', scanAndMarkPosts); GM.registerMenuCommand('Debug: Show Collection Status', showDebugStatus); GM.registerMenuCommand('Debug: Test Content Cleaning', testContentCleaning); } // Start the script initialize(); })();