gpt_chrome_autodrafter_v3/enhanced_thread_extractor.js
2025-07-01 15:46:34 -07:00

694 lines
No EOL
23 KiB
JavaScript

// Enhanced Thread ID Extractor for Gmail
// This provides bulletproof thread ID extraction using multiple fallback methods
class GmailThreadExtractor {
constructor() {
this.cache = new Map(); // Cache thread IDs to avoid repeated extraction
this.observers = new Set(); // Track active observers
this.lastKnownThreadId = null;
this.extractionMethods = [
this.extractFromURL,
this.extractFromDOM,
this.extractFromGmailAPI,
this.extractFromMessageHeaders,
this.extractFromBrowserHistory,
this.extractFromGmailInternals,
this.extractFromFallbackMethods
];
// Add batch-specific selectors
this.batchSelectors = {
selectedCheckboxes: 'div[role="checkbox"][aria-checked="true"]',
threadRow: 'tr[data-legacy-thread-id], tr[data-thread-id]',
subjectElement: 'span.bog',
senderElement: 'span.yX.xY span.zF, span.yX.xY span.yP'
};
}
// Main extraction method with comprehensive fallbacks
async extractThreadId(context = 'current', options = {}) {
const cacheKey = `${context}_${window.location.href}`;
// Check cache first
if (this.cache.has(cacheKey) && !options.forceRefresh) {
console.log('Thread ID found in cache:', this.cache.get(cacheKey));
return this.cache.get(cacheKey);
}
console.log(`=== EXTRACTING THREAD ID (${context}) ===`);
// Handle batch selection context differently
if (context === 'selected') {
return this.extractBatchThreadIds(options);
}
// Try each extraction method in order
for (const method of this.extractionMethods) {
try {
const threadId = await method.call(this, context, options);
if (threadId && this.validateThreadId(threadId)) {
console.log(`Thread ID found via ${method.name}:`, threadId);
this.cache.set(cacheKey, threadId);
this.lastKnownThreadId = threadId;
return threadId;
}
} catch (error) {
console.warn(`Method ${method.name} failed:`, error);
}
}
// If all methods fail, try emergency fallbacks
const emergencyId = await this.emergencyExtraction(context);
if (emergencyId) {
console.log('Thread ID found via emergency method:', emergencyId);
return emergencyId;
}
console.error('All thread ID extraction methods failed');
return null;
}
// Specialized method for batch thread extraction
extractBatchThreadIds(options = {}) {
console.log('=== EXTRACTING BATCH THREAD IDS ===');
const selectedCheckboxes = document.querySelectorAll(this.batchSelectors.selectedCheckboxes);
const threadIds = [];
const threadInfo = [];
selectedCheckboxes.forEach((checkbox, index) => {
const row = checkbox.closest('tr');
if (!row) return;
// Try multiple extraction methods for each row
let threadId = null;
let extractionMethod = 'unknown';
// Method 1: Direct attributes
threadId = row.getAttribute('data-legacy-thread-id') || row.getAttribute('data-thread-id');
if (threadId) {
extractionMethod = 'row-attribute';
}
// Method 2: Child elements
if (!threadId) {
const threadElement = row.querySelector('[data-legacy-thread-id], [data-thread-id]');
if (threadElement) {
threadId = threadElement.getAttribute('data-legacy-thread-id') ||
threadElement.getAttribute('data-thread-id');
extractionMethod = 'child-element';
}
}
// Method 3: Row ID parsing
if (!threadId && row.id) {
const match = row.id.match(/([a-f0-9]{16})/i);
if (match && this.validateThreadId(match[1])) {
threadId = match[1];
extractionMethod = 'row-id';
}
}
// Method 4: Click and extract (if enabled)
if (!threadId && options.allowClickExtraction) {
threadId = this.extractByClicking(row);
extractionMethod = 'click-extraction';
}
if (threadId && this.validateThreadId(threadId)) {
threadIds.push(threadId);
// Collect additional info for better logging
const subject = row.querySelector(this.batchSelectors.subjectElement)?.textContent?.trim();
const sender = row.querySelector(this.batchSelectors.senderElement)?.textContent?.trim();
threadInfo.push({
threadId,
subject,
sender,
extractionMethod,
index
});
}
});
// Log detailed extraction results
console.log(`Found ${threadIds.length} thread IDs from ${selectedCheckboxes.length} selected items`);
threadInfo.forEach(info => {
console.log(`Thread ${info.index + 1}: ${info.threadId} (${info.extractionMethod}) - "${info.subject}" from ${info.sender}`);
});
// Store thread info for potential fallback matching
if (options.returnFullInfo) {
return threadInfo;
}
return [...new Set(threadIds)]; // Remove duplicates
}
// Method 1: Extract from URL (most reliable)
extractFromURL(context) {
const url = window.location.href;
const hash = window.location.hash;
// Pattern 1: #inbox/thread_id
let match = hash.match(/[#/]([a-f0-9]{16})$/i);
if (match) return match[1];
// Pattern 2: #search/query/thread_id
match = hash.match(/[#/]([a-f0-9]{16})(?:[/?]|$)/i);
if (match) return match[1];
// Pattern 3: URL parameters
const urlParams = new URLSearchParams(window.location.search);
const threadParam = urlParams.get('th') || urlParams.get('thread');
if (threadParam && this.validateThreadId(threadParam)) return threadParam;
// Pattern 4: Path-based thread ID
match = url.match(/\/mail\/u\/\d+\/#[^/]*\/([a-f0-9]{16})/i);
if (match) return match[1];
// Pattern 5: Label or search views with thread ID
match = hash.match(/label\/[^/]+\/([a-f0-9]{16})/i);
if (match) return match[1];
return null;
}
// Method 2: Extract from DOM elements
extractFromDOM(context) {
const selectors = [
// Primary selectors
'[data-legacy-thread-id]',
'[data-thread-perm-id]',
'[data-thread-id]',
// Conversation view selectors
'h2[data-legacy-thread-id]',
'h2[data-thread-perm-id]',
'.nH.if [data-legacy-thread-id]',
// Message container selectors
'div[role="listitem"][data-legacy-thread-id]',
'.h7[data-legacy-thread-id]',
// Gmail-specific selectors
'.adn[data-legacy-thread-id]',
'.zA[data-legacy-thread-id]',
// Additional Gmail UI selectors
'.ae4[data-legacy-thread-id]', // Thread container
'.Cp[data-legacy-thread-id]', // Message list
'.gs[data-legacy-thread-id]', // Conversation wrapper
// Fallback selectors
'[jsaction*="thread"]',
'[data-tooltip*="thread"]'
];
// Prioritize visible elements
for (const selector of selectors) {
const elements = document.querySelectorAll(selector);
for (const element of elements) {
// Check if element is visible
if (element.offsetParent === null) continue;
const threadId = element.getAttribute('data-legacy-thread-id') ||
element.getAttribute('data-thread-perm-id') ||
element.getAttribute('data-thread-id');
if (threadId && this.validateThreadId(threadId)) {
return threadId;
}
}
}
// Check for thread ID in element IDs or classes
const allElements = document.querySelectorAll('*[id*="thread"], *[class*="thread"]');
for (const element of allElements) {
const id = element.id || element.className;
const match = id.match(/([a-f0-9]{16})/i);
if (match && this.validateThreadId(match[1])) {
return match[1];
}
}
return null;
}
// Method 3: Extract using Gmail API
async extractFromGmailAPI(context, options) {
if (!options.token) {
// Try to get token from background script
try {
const response = await new Promise((resolve) => {
chrome.runtime.sendMessage({action: 'getOAuthToken'}, resolve);
});
if (response && response.token) {
options.token = response.token;
} else {
return null;
}
} catch (error) {
console.warn('Could not get OAuth token for API extraction');
return null;
}
}
// Try multiple methods to get a message ID
const messageId = this.extractMessageId() || await this.extractMessageIdFromAPI(options.token);
if (!messageId) return null;
try {
// Get message details from Gmail API
const response = await fetch(
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}`,
{
headers: { 'Authorization': `Bearer ${options.token}` }
}
);
if (response.ok) {
const messageData = await response.json();
return messageData.threadId;
}
} catch (error) {
console.warn('Gmail API extraction failed:', error);
}
return null;
}
// Extract message ID from Gmail API (when DOM fails)
async extractMessageIdFromAPI(token) {
try {
// Get the most recent message
const response = await fetch(
'https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=1',
{
headers: { 'Authorization': `Bearer ${token}` }
}
);
if (response.ok) {
const data = await response.json();
if (data.messages && data.messages.length > 0) {
return data.messages[0].id;
}
}
} catch (error) {
console.warn('Failed to get message ID from API:', error);
}
return null;
}
// Method 4: Extract from message headers
extractFromMessageHeaders() {
// Look for message elements with headers
const messageElements = document.querySelectorAll('div[role="listitem"]');
for (const element of messageElements) {
// Check for thread ID in data attributes
const threadId = element.getAttribute('data-legacy-thread-id') ||
element.getAttribute('data-thread-id');
if (threadId && this.validateThreadId(threadId)) {
return threadId;
}
// Check parent containers
const parent = element.closest('[data-legacy-thread-id], [data-thread-id]');
if (parent) {
const parentThreadId = parent.getAttribute('data-legacy-thread-id') ||
parent.getAttribute('data-thread-id');
if (parentThreadId && this.validateThreadId(parentThreadId)) {
return parentThreadId;
}
}
}
return null;
}
// Method 5: Extract from browser history
extractFromBrowserHistory() {
// Check if we can extract from the current history state
if (history.state && history.state.threadId) {
return history.state.threadId;
}
// Parse previous URLs in session storage
try {
const historyKey = 'gmail_thread_history';
let previousUrls = JSON.parse(sessionStorage.getItem(historyKey) || '[]');
// Add current URL to history
const currentUrl = window.location.href;
if (!previousUrls.includes(currentUrl)) {
previousUrls.push(currentUrl);
// Keep only last 50 URLs
if (previousUrls.length > 50) {
previousUrls = previousUrls.slice(-50);
}
sessionStorage.setItem(historyKey, JSON.stringify(previousUrls));
}
// Search through history
for (const url of previousUrls.reverse()) {
const match = url.match(/[#/]([a-f0-9]{16})/i);
if (match && this.validateThreadId(match[1])) {
return match[1];
}
}
} catch (error) {
console.warn('Could not parse thread history');
}
return null;
}
// Method 6: Extract from Gmail internals
extractFromGmailInternals() {
// Try to access Gmail's internal JavaScript objects
try {
// Gmail sometimes exposes thread data in global variables
if (window.GM_SPT_ENABLED && window.GM_ACTION_TOKEN) {
// Look for Gmail's internal state
const scripts = document.querySelectorAll('script');
for (const script of scripts) {
if (script.textContent.includes('thread_id')) {
const match = script.textContent.match(/"thread_id":"([a-f0-9]{16})"/i);
if (match && this.validateThreadId(match[1])) {
return match[1];
}
}
}
}
// Check for thread ID in Gmail's data layer
if (window.dataLayer) {
for (const item of window.dataLayer) {
if (item.thread_id && this.validateThreadId(item.thread_id)) {
return item.thread_id;
}
}
}
// Check for Gmail's GLOBALS object
if (window.GLOBALS) {
// Gmail sometimes stores thread info in GLOBALS
const threadIdPatterns = [
window.GLOBALS[17], // Thread ID location in some versions
window.GLOBALS[40], // Alternative location
];
for (const value of threadIdPatterns) {
if (value && typeof value === 'string' && this.validateThreadId(value)) {
return value;
}
}
}
} catch (error) {
console.warn('Gmail internals extraction failed:', error);
}
return null;
}
// Method 7: Fallback methods
async extractFromFallbackMethods(context) {
// Try to trigger Gmail to reveal thread ID
await this.triggerGmailStateUpdate();
// Wait a bit and try DOM extraction again
await new Promise(resolve => setTimeout(resolve, 500));
const domResult = this.extractFromDOM(context);
if (domResult) return domResult;
// Try to extract from any visible thread indicators
const threadIndicators = document.querySelectorAll('[title*="thread"], [aria-label*="thread"]');
for (const indicator of threadIndicators) {
const text = indicator.title || indicator.getAttribute('aria-label') || '';
const match = text.match(/([a-f0-9]{16})/i);
if (match && this.validateThreadId(match[1])) {
return match[1];
}
}
return null;
}
// Emergency extraction when all else fails
async emergencyExtraction(context) {
console.log('Attempting emergency thread ID extraction...');
// Method 1: Force Gmail to navigate and extract from URL change
if (context === 'current') {
const currentUrl = window.location.href;
// Try clicking on the current conversation to force URL update
const conversationElement = document.querySelector('.zA.zE, .zA.yW, div[role="listitem"]');
if (conversationElement) {
conversationElement.click();
await new Promise(resolve => setTimeout(resolve, 1000));
const newThreadId = this.extractFromURL();
if (newThreadId) return newThreadId;
// Restore original state if possible
if (window.location.href !== currentUrl) {
history.back();
}
}
}
// Method 2: Use MutationObserver to catch thread ID when it appears
return new Promise((resolve) => {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
const threadId = this.extractThreadIdFromElement(node);
if (threadId) {
observer.disconnect();
resolve(threadId);
return;
}
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['data-legacy-thread-id', 'data-thread-id', 'data-thread-perm-id']
});
// Timeout after 5 seconds
setTimeout(() => {
observer.disconnect();
resolve(null);
}, 5000);
});
}
// Helper: Extract thread ID by clicking (for batch operations)
extractByClicking(row) {
try {
// Store current URL
const originalUrl = window.location.href;
// Click the row
row.click();
// Wait for navigation
const startTime = Date.now();
while (Date.now() - startTime < 1000) {
if (window.location.href !== originalUrl) {
const threadId = this.extractFromURL();
if (threadId) {
// Go back to inbox
history.back();
return threadId;
}
}
}
} catch (error) {
console.warn('Click extraction failed:', error);
}
return null;
}
// Helper: Extract thread ID from any element
extractThreadIdFromElement(element) {
if (!element.querySelector) return null;
const threadElement = element.querySelector('[data-legacy-thread-id], [data-thread-id], [data-thread-perm-id]');
if (threadElement) {
const threadId = threadElement.getAttribute('data-legacy-thread-id') ||
threadElement.getAttribute('data-thread-id') ||
threadElement.getAttribute('data-thread-perm-id');
if (threadId && this.validateThreadId(threadId)) {
return threadId;
}
}
return null;
}
// Helper: Extract message ID from current view
extractMessageId() {
const selectors = [
'[data-legacy-message-id]',
'[data-message-id]',
'[data-internaldate]', // Sometimes message ID is in internal date element
'div[role="listitem"][id]' // Message container with ID
];
for (const selector of selectors) {
const elements = document.querySelectorAll(selector);
for (const element of elements) {
const messageId = element.getAttribute('data-legacy-message-id') ||
element.getAttribute('data-message-id') ||
element.id;
if (messageId && messageId.length > 10) { // Basic validation
return messageId;
}
}
}
return null;
}
// Helper: Trigger Gmail to update its state
async triggerGmailStateUpdate() {
// Trigger events that might cause Gmail to update its DOM
const events = ['focus', 'click', 'keydown'];
for (const eventType of events) {
document.dispatchEvent(new Event(eventType, { bubbles: true }));
await new Promise(resolve => setTimeout(resolve, 100));
}
// Try pressing 'j' then 'k' to navigate messages
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'j' }));
await new Promise(resolve => setTimeout(resolve, 200));
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k' }));
}
// Validate thread ID format
validateThreadId(threadId) {
if (!threadId || typeof threadId !== 'string') return false;
// Gmail thread IDs are typically 16 character hexadecimal strings
return /^[a-f0-9]{16}$/i.test(threadId);
}
// Clear cache
clearCache() {
this.cache.clear();
}
// Set up continuous monitoring
startMonitoring() {
// Monitor URL changes
let lastUrl = window.location.href;
const urlObserver = new MutationObserver(() => {
if (window.location.href !== lastUrl) {
lastUrl = window.location.href;
this.clearCache(); // Clear cache on navigation
// Store URL in history
try {
const historyKey = 'gmail_thread_history';
let urls = JSON.parse(sessionStorage.getItem(historyKey) || '[]');
if (!urls.includes(window.location.href)) {
urls.push(window.location.href);
if (urls.length > 50) urls = urls.slice(-50);
sessionStorage.setItem(historyKey, JSON.stringify(urls));
}
} catch (e) {
console.warn('Failed to update thread history:', e);
}
}
});
urlObserver.observe(document.body, { childList: true, subtree: true });
this.observers.add(urlObserver);
// Monitor for new thread elements
const threadObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
const threadId = this.extractThreadIdFromElement(node);
if (threadId && threadId !== this.lastKnownThreadId) {
this.lastKnownThreadId = threadId;
// Dispatch custom event for other parts of the extension
document.dispatchEvent(new CustomEvent('threadIdDetected', {
detail: { threadId }
}));
}
}
}
}
});
threadObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['data-legacy-thread-id', 'data-thread-id', 'data-thread-perm-id']
});
this.observers.add(threadObserver);
console.log('Thread monitoring started');
}
// Clean up observers
stopMonitoring() {
for (const observer of this.observers) {
observer.disconnect();
}
this.observers.clear();
console.log('Thread monitoring stopped');
}
}
// Create global instance
const threadExtractor = new GmailThreadExtractor();
// Start monitoring for thread changes
threadExtractor.startMonitoring();
// Extract thread ID with all fallbacks
async function getThreadIdReliably(options = {}) {
const threadId = await threadExtractor.extractThreadId('current', options);
if (!threadId) {
throw new Error('Could not extract thread ID after trying all methods');
}
return threadId;
}
// Backward compatibility functions
function extractThreadId(context = 'current') {
return threadExtractor.extractThreadId(context);
}
function getSelectedThreadIds() {
return threadExtractor.extractThreadId('selected');
}
// Export for use in other parts of the extension
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
GmailThreadExtractor,
getThreadIdReliably,
threadExtractor,
extractThreadId,
getSelectedThreadIds
};
}