702 lines
20 KiB
JavaScript
702 lines
20 KiB
JavaScript
// Content script for Dropdown Copy Helper
|
||
// 下拉复制助手内容脚本
|
||
|
||
class DropdownCopyHelper {
|
||
constructor() {
|
||
this.currentInputElement = null;
|
||
this.init();
|
||
}
|
||
|
||
init() {
|
||
// Prevent multiple initialization
|
||
if (window.dropdownCopyHelperInitialized) {
|
||
console.log('⚠️ Dropdown Copy Helper already initialized');
|
||
return;
|
||
}
|
||
window.dropdownCopyHelperInitialized = true;
|
||
|
||
// Listen for messages from background script
|
||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||
try {
|
||
if (request.action === 'copyDropdownItems') {
|
||
this.handleCopyRequest(sendResponse);
|
||
return true; // Keep message channel open for async response
|
||
} else if (request.action === 'ping') {
|
||
sendResponse({ status: 'ready', timestamp: Date.now() });
|
||
return false; // Synchronous response
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ Message handling error:', error);
|
||
sendResponse({ success: false, error: error.message });
|
||
}
|
||
});
|
||
|
||
// Track focused input elements
|
||
this.trackInputFocus();
|
||
|
||
console.log('✅ Dropdown Copy Helper loaded on:', window.location.hostname);
|
||
}
|
||
|
||
// Get dropdown items using simplified strategy
|
||
getDropdownItems() {
|
||
const hostname = window.location.hostname;
|
||
let items = [];
|
||
|
||
if (hostname.includes('google.com')) {
|
||
items = this.getGoogleDropdownItems();
|
||
} else if (hostname.includes('youtube.com')) {
|
||
items = this.getYouTubeDropdownItems();
|
||
}
|
||
|
||
// If no items found with standard methods, try emergency fallback
|
||
if (items.length === 0) {
|
||
items = this.getEmergencyFallbackItems();
|
||
}
|
||
|
||
// Filter and clean items
|
||
const cleanItems = items
|
||
.filter(item => item && item.trim().length > 0)
|
||
.filter(item => !this.isUIElement(item))
|
||
.map(item => this.cleanSearchSuggestionText(item))
|
||
.filter(item => item && item.length > 0 && item.length < 150);
|
||
|
||
return [...new Set(cleanItems)]; // Remove duplicates
|
||
}
|
||
|
||
|
||
// Get Google search suggestions
|
||
getGoogleDropdownItems() {
|
||
const items = [];
|
||
|
||
// Comprehensive list of selectors from most modern to legacy
|
||
const selectors = [
|
||
// Most modern Google selectors (2024+)
|
||
'ul[role="listbox"] li[role="option"]',
|
||
'div[role="listbox"] div[role="option"]',
|
||
'[role="listbox"] [role="option"]',
|
||
|
||
// Alternative modern structures
|
||
'.G43f7e li[role="option"]',
|
||
'.G43f7e div[role="option"]',
|
||
'.aajZCb li[role="option"]',
|
||
'.aajZCb div[role="option"]',
|
||
'.erkvQe li[role="option"]',
|
||
'.erkvQe li',
|
||
'.erkvQe div',
|
||
|
||
// Google suggestions container variations
|
||
'.sbsb_b li',
|
||
'.sbsb_b div',
|
||
'.sbsb_c',
|
||
'.sbqs_c',
|
||
'.gsq_a',
|
||
'.pcTkSc',
|
||
'.sbtc',
|
||
'.sbl1',
|
||
|
||
// Broader searches for any suggestion-like elements
|
||
'li[data-ved]',
|
||
'div[data-ved]',
|
||
'span[data-ved]',
|
||
|
||
// Last resort - any li under potential containers
|
||
'.sbsb_b li',
|
||
'ul li',
|
||
'div[jsname] li',
|
||
'div[jsname] div'
|
||
];
|
||
|
||
for (const selector of selectors) {
|
||
const elements = document.querySelectorAll(selector);
|
||
|
||
if (elements.length > 0) {
|
||
let foundInThisSelector = 0;
|
||
elements.forEach(el => {
|
||
if (this.isVisible(el)) {
|
||
const text = this.extractCleanText(el);
|
||
if (text && text.length > 0 && text.length < 200) {
|
||
if (this.looksLikeSearchSuggestion(text)) {
|
||
items.push(text);
|
||
foundInThisSelector++;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// If we found items with this selector, stop trying others
|
||
if (foundInThisSelector > 0) {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
return items;
|
||
}
|
||
|
||
// Check if text looks like a search suggestion rather than UI element
|
||
looksLikeSearchSuggestion(text) {
|
||
// Reject obviously non-suggestion text
|
||
const rejectPatterns = [
|
||
/^(搜索|查看更多|删除|更多|清除|历史记录)$/i,
|
||
/^(search|view more|delete|more|clear|history)$/i,
|
||
/^[×✕]$/,
|
||
/^[\s]*$/,
|
||
/^[0-9]+$/,
|
||
/^(OM|中国国际航空|Airbnb)$/
|
||
];
|
||
|
||
return !rejectPatterns.some(pattern => pattern.test(text));
|
||
}
|
||
|
||
// Extract clean text from element - simplified version
|
||
extractCleanText(element) {
|
||
if (!element) return '';
|
||
|
||
// Get the text content
|
||
let text = element.textContent || element.innerText || '';
|
||
|
||
// Basic cleaning
|
||
text = text.trim();
|
||
|
||
// Remove obvious UI elements by splitting and taking the first part
|
||
const parts = text.split(/[,,·•\|\n]/);
|
||
text = parts[0].trim();
|
||
|
||
// Remove common trailing UI text
|
||
text = text.replace(/(查看更多|删除|更多|OM|View more|Delete|Remove)$/gi, '').trim();
|
||
|
||
return text;
|
||
}
|
||
|
||
// Check if text appears to be a UI element rather than search suggestion
|
||
isUIElement(text) {
|
||
const uiPatterns = [
|
||
/^查看更多/,
|
||
/删除$/,
|
||
/^更多$/,
|
||
/^搜索$/,
|
||
/^OM$/,
|
||
/^×$/,
|
||
/^✕$/,
|
||
/^\s*$/ // empty or whitespace only
|
||
];
|
||
|
||
return uiPatterns.some(pattern => pattern.test(text));
|
||
}
|
||
|
||
// Get YouTube search suggestions
|
||
getYouTubeDropdownItems() {
|
||
const items = [];
|
||
|
||
const selectors = [
|
||
// Modern YouTube selectors
|
||
'[role="listbox"] [role="option"]',
|
||
'.ytd-searchbox [role="option"]',
|
||
|
||
// Traditional YouTube selectors
|
||
'.sbsb_c',
|
||
'.sbsb_a',
|
||
'.sbqs_c',
|
||
|
||
// Container-based searches
|
||
'.ytd-searchbox .sbsb_c',
|
||
'#search-container [role="option"]',
|
||
'.search-container [role="option"]',
|
||
|
||
// Broader searches
|
||
'li[data-ved]',
|
||
'div[data-ved]',
|
||
|
||
// Last resort
|
||
'.ytd-searchbox li',
|
||
'.ytd-searchbox div',
|
||
'#search li',
|
||
'#search div'
|
||
];
|
||
|
||
for (const selector of selectors) {
|
||
const elements = document.querySelectorAll(selector);
|
||
|
||
if (elements.length > 0) {
|
||
let foundInThisSelector = 0;
|
||
elements.forEach(el => {
|
||
if (this.isVisible(el)) {
|
||
const text = this.extractCleanText(el);
|
||
if (text && text.length > 0 && text.length < 200) {
|
||
if (this.looksLikeSearchSuggestion(text)) {
|
||
items.push(text);
|
||
foundInThisSelector++;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
if (foundInThisSelector > 0) {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
return items;
|
||
}
|
||
|
||
// Emergency fallback - try to find ANY dropdown-like elements on the page
|
||
getEmergencyFallbackItems() {
|
||
const items = [];
|
||
|
||
// Look for any elements that might contain suggestions
|
||
const emergencySelectors = [
|
||
// Any list items anywhere
|
||
'li',
|
||
// Any divs with suggestion-like attributes
|
||
'div[role]',
|
||
'span[role]',
|
||
// Elements with data attributes (common in modern web apps)
|
||
'[data-ved]',
|
||
'[data-value]',
|
||
'[data-suggestion]',
|
||
// Any elements that might be in a dropdown
|
||
'ul > *',
|
||
'ol > *',
|
||
'.dropdown *',
|
||
'.suggestions *',
|
||
'.autocomplete *'
|
||
];
|
||
|
||
for (const selector of emergencySelectors) {
|
||
const elements = document.querySelectorAll(selector);
|
||
if (elements.length > 0) {
|
||
let foundCount = 0;
|
||
elements.forEach(el => {
|
||
if (foundCount >= 10) return; // Limit to prevent spam
|
||
|
||
if (this.isVisible(el)) {
|
||
const text = this.extractCleanText(el);
|
||
if (text && text.length > 2 && text.length < 100) {
|
||
if (this.looksLikeSearchSuggestion(text) && !items.includes(text)) {
|
||
items.push(text);
|
||
foundCount++;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
if (foundCount > 0) {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
return items.slice(0, 10); // Limit to 10 items max
|
||
}
|
||
|
||
// Track input element focus
|
||
trackInputFocus() {
|
||
// Track right-click context menu
|
||
document.addEventListener('contextmenu', (event) => {
|
||
this.handleContextMenu(event);
|
||
});
|
||
|
||
// Also track focus and input events to better detect active search inputs
|
||
document.addEventListener('focusin', (event) => {
|
||
if (this.isSearchInput(event.target)) {
|
||
this.currentInputElement = event.target;
|
||
console.log('✅ Search input focused:', event.target);
|
||
}
|
||
});
|
||
|
||
// Track input events to detect when user is typing
|
||
document.addEventListener('input', (event) => {
|
||
if (this.isSearchInput(event.target)) {
|
||
this.currentInputElement = event.target;
|
||
console.log('✅ Search input active:', event.target);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Handle context menu event
|
||
handleContextMenu(event) {
|
||
// Check if right-clicked element is a search input
|
||
if (this.isSearchInput(event.target)) {
|
||
this.currentInputElement = event.target;
|
||
console.log('✅ Search input detected via context menu:', event.target);
|
||
return;
|
||
}
|
||
|
||
// Check if right-clicked near a search input
|
||
const nearbyInput = this.findNearbySearchInput(event.target);
|
||
if (nearbyInput) {
|
||
this.currentInputElement = nearbyInput;
|
||
console.log('✅ Nearby search input detected:', nearbyInput);
|
||
return;
|
||
}
|
||
|
||
// Fallback: find main search input on page
|
||
this.currentInputElement = this.findMainSearchInput();
|
||
console.log('🔍 Using main search input fallback:', this.currentInputElement);
|
||
}
|
||
|
||
// Find search input near the clicked element
|
||
findNearbySearchInput(element) {
|
||
// Check parent elements up to 5 levels
|
||
let current = element;
|
||
for (let i = 0; i < 5 && current; i++) {
|
||
const searchInput = current.querySelector('input, textarea');
|
||
if (searchInput && this.isSearchInput(searchInput)) {
|
||
return searchInput;
|
||
}
|
||
current = current.parentElement;
|
||
}
|
||
|
||
// Check sibling elements
|
||
if (element.parentElement) {
|
||
const siblings = element.parentElement.querySelectorAll('input, textarea');
|
||
for (const sibling of siblings) {
|
||
if (this.isSearchInput(sibling)) {
|
||
return sibling;
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// Find the main search input on the page
|
||
findMainSearchInput() {
|
||
const hostname = window.location.hostname;
|
||
|
||
if (hostname.includes('google.com')) {
|
||
const selectors = [
|
||
'input[name="q"]',
|
||
'textarea[name="q"]',
|
||
'.gLFyf',
|
||
'input[role="combobox"]',
|
||
'input[aria-label*="Search"]',
|
||
'input[aria-label*="搜索"]',
|
||
'input[title*="Search"]',
|
||
'input[placeholder*="Search"]'
|
||
];
|
||
|
||
for (const selector of selectors) {
|
||
const input = document.querySelector(selector);
|
||
if (input && this.isVisible(input)) {
|
||
return input;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hostname.includes('youtube.com')) {
|
||
const selectors = [
|
||
'input[name="search_query"]',
|
||
'input#search',
|
||
'input[aria-label*="Search"]',
|
||
'input[placeholder*="Search"]',
|
||
'.ytd-searchbox input',
|
||
'#search-input input'
|
||
];
|
||
|
||
for (const selector of selectors) {
|
||
const input = document.querySelector(selector);
|
||
if (input && this.isVisible(input)) {
|
||
return input;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Generic fallback - look for any search-related input
|
||
const genericSelectors = [
|
||
'input[name="search"]',
|
||
'input[name="query"]',
|
||
'input[name="q"]',
|
||
'input[type="search"]',
|
||
'input[aria-label*="search" i]',
|
||
'input[placeholder*="search" i]',
|
||
'input[title*="search" i]'
|
||
];
|
||
|
||
for (const selector of genericSelectors) {
|
||
const input = document.querySelector(selector);
|
||
if (input && this.isVisible(input)) {
|
||
return input;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// Enhanced search input detection
|
||
isSearchInput(element) {
|
||
if (!element || (element.tagName !== 'INPUT' && element.tagName !== 'TEXTAREA')) return false;
|
||
|
||
// Check if element is visible
|
||
if (!this.isVisible(element)) return false;
|
||
|
||
const hostname = window.location.hostname;
|
||
const name = element.name?.toLowerCase() || '';
|
||
const id = element.id?.toLowerCase() || '';
|
||
const className = element.className?.toLowerCase() || '';
|
||
const placeholder = element.placeholder?.toLowerCase() || '';
|
||
const ariaLabel = element.getAttribute('aria-label')?.toLowerCase() || '';
|
||
const title = element.title?.toLowerCase() || '';
|
||
const type = element.type?.toLowerCase() || '';
|
||
|
||
// Site-specific checks
|
||
if (hostname.includes('google.com')) {
|
||
return name === 'q' ||
|
||
className.includes('glfyf') ||
|
||
element.getAttribute('role') === 'combobox' ||
|
||
ariaLabel.includes('search') ||
|
||
ariaLabel.includes('搜索');
|
||
}
|
||
|
||
if (hostname.includes('youtube.com')) {
|
||
return name === 'search_query' ||
|
||
id === 'search' ||
|
||
className.includes('search') ||
|
||
ariaLabel.includes('search');
|
||
}
|
||
|
||
// Generic search input detection
|
||
const searchTerms = ['search', 'query', 'find', '搜索', '查找'];
|
||
const searchFields = [name, id, className, placeholder, ariaLabel, title];
|
||
|
||
// Check if type is search
|
||
if (type === 'search') return true;
|
||
|
||
// Check if any field contains search terms
|
||
return searchTerms.some(term =>
|
||
searchFields.some(field => field.includes(term))
|
||
) || name === 'q';
|
||
}
|
||
|
||
// Handle copy request from context menu - simplified and fast
|
||
async handleCopyRequest(sendResponse) {
|
||
let responseSent = false;
|
||
|
||
const safeResponse = (data) => {
|
||
if (!responseSent) {
|
||
responseSent = true;
|
||
try {
|
||
sendResponse(data);
|
||
} catch (error) {
|
||
console.error('Failed to send response:', error);
|
||
}
|
||
}
|
||
};
|
||
|
||
try {
|
||
|
||
// Find search input if not already set
|
||
if (!this.currentInputElement) {
|
||
this.currentInputElement = this.findMainSearchInput();
|
||
}
|
||
|
||
if (!this.currentInputElement) {
|
||
const errorMsg = 'No search input found. Please click on a search input field first.\n未找到搜索输入框。请先点击搜索框,然后输入内容显示下拉建议。';
|
||
this.showToast(`❌ ${errorMsg}`, 'error');
|
||
safeResponse({ success: false, error: errorMsg });
|
||
return;
|
||
}
|
||
|
||
// Focus input briefly to ensure dropdown is active
|
||
this.currentInputElement.focus();
|
||
|
||
// Get dropdown items immediately - no complex waiting
|
||
let dropdownItems = this.getDropdownItems();
|
||
|
||
// If no items found, wait a very short time and try once more
|
||
if (dropdownItems.length === 0) {
|
||
await new Promise(resolve => setTimeout(resolve, 300));
|
||
dropdownItems = this.getDropdownItems();
|
||
}
|
||
|
||
if (dropdownItems.length === 0) {
|
||
const errorMsg = 'No dropdown suggestions found. Please type something to show suggestions first.\n未找到下拉建议。请在搜索框中输入内容以显示建议列表。';
|
||
this.showToast(`❌ ${errorMsg}`, 'error');
|
||
safeResponse({ success: false, error: errorMsg });
|
||
return;
|
||
}
|
||
|
||
// Copy to clipboard
|
||
const textToCopy = dropdownItems.join('\n');
|
||
await this.copyToClipboard(textToCopy);
|
||
|
||
this.showToast(`✅ Successfully copied ${dropdownItems.length} items!\n成功复制 ${dropdownItems.length} 条建议!`, 'success');
|
||
safeResponse({ success: true, count: dropdownItems.length, items: dropdownItems });
|
||
|
||
} catch (error) {
|
||
console.error('❌ Copy failed:', error);
|
||
this.showToast(`❌ ${error.message}`, 'error');
|
||
safeResponse({ success: false, error: error.message });
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
// Extract clean text from element with filtering
|
||
extractTextFromElement(element) {
|
||
// Remove script and style elements
|
||
const clone = element.cloneNode(true);
|
||
const scripts = clone.querySelectorAll('script, style');
|
||
scripts.forEach(script => script.remove());
|
||
|
||
let text = clone.textContent || clone.innerText || '';
|
||
|
||
// Clean up the text
|
||
text = this.cleanSearchSuggestionText(text);
|
||
|
||
return text;
|
||
}
|
||
|
||
// Clean search suggestion text - simplified version
|
||
cleanSearchSuggestionText(text) {
|
||
if (!text) return '';
|
||
|
||
// Basic cleaning
|
||
text = text.trim().replace(/\s+/g, ' ');
|
||
|
||
// Remove common UI elements at the end
|
||
text = text.replace(/(查看更多删除|查看更多|删除|更多|OM|View more|Delete|Remove)$/gi, '').trim();
|
||
|
||
// Remove trailing dots and clean up
|
||
text = text.replace(/\.{2,}$/, '').trim();
|
||
|
||
return text;
|
||
}
|
||
|
||
// Check if element is visible
|
||
isVisible(element) {
|
||
if (!element) return false;
|
||
|
||
const style = window.getComputedStyle(element);
|
||
return style.display !== 'none' &&
|
||
style.visibility !== 'hidden' &&
|
||
style.opacity !== '0' &&
|
||
element.offsetWidth > 0 &&
|
||
element.offsetHeight > 0;
|
||
}
|
||
|
||
// Copy text to clipboard
|
||
async copyToClipboard(text) {
|
||
if (navigator.clipboard) {
|
||
await navigator.clipboard.writeText(text);
|
||
} else {
|
||
// Fallback
|
||
const textArea = document.createElement('textarea');
|
||
textArea.value = text;
|
||
textArea.style.position = 'fixed';
|
||
textArea.style.left = '-999999px';
|
||
document.body.appendChild(textArea);
|
||
textArea.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(textArea);
|
||
}
|
||
}
|
||
|
||
|
||
|
||
// Show enhanced toast notification
|
||
showToast(message, type = 'info') {
|
||
// Remove existing toast
|
||
const existingToast = document.getElementById('dropdown-copy-toast');
|
||
if (existingToast) existingToast.remove();
|
||
|
||
const toast = document.createElement('div');
|
||
toast.id = 'dropdown-copy-toast';
|
||
toast.className = `dropdown-copy-toast ${type}`;
|
||
|
||
// Handle multi-line messages (bilingual support)
|
||
if (message.includes('\n')) {
|
||
const lines = message.split('\n');
|
||
lines.forEach((line, index) => {
|
||
const lineDiv = document.createElement('div');
|
||
lineDiv.textContent = line;
|
||
if (index > 0) {
|
||
lineDiv.style.fontSize = '12px';
|
||
lineDiv.style.opacity = '0.9';
|
||
lineDiv.style.marginTop = '2px';
|
||
}
|
||
toast.appendChild(lineDiv);
|
||
});
|
||
} else {
|
||
toast.textContent = message;
|
||
}
|
||
|
||
// Enhanced styling
|
||
Object.assign(toast.style, {
|
||
position: 'fixed',
|
||
top: '20px',
|
||
right: '20px',
|
||
padding: '16px 24px',
|
||
borderRadius: '8px',
|
||
color: 'white',
|
||
backgroundColor: this.getToastColor(type),
|
||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
||
fontSize: '14px',
|
||
fontWeight: '500',
|
||
zIndex: '2147483647', // Maximum z-index
|
||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
||
backdropFilter: 'blur(10px)',
|
||
border: '1px solid rgba(255,255,255,0.1)',
|
||
maxWidth: '400px',
|
||
wordBreak: 'break-word',
|
||
transition: 'all 0.3s ease',
|
||
opacity: '0',
|
||
transform: 'translateX(100%)'
|
||
});
|
||
|
||
document.body.appendChild(toast);
|
||
|
||
// Animate in
|
||
requestAnimationFrame(() => {
|
||
toast.style.opacity = '1';
|
||
toast.style.transform = 'translateX(0)';
|
||
});
|
||
|
||
// Auto-remove with fade out animation
|
||
setTimeout(() => {
|
||
toast.style.opacity = '0';
|
||
toast.style.transform = 'translateX(100%)';
|
||
setTimeout(() => {
|
||
if (toast.parentNode) {
|
||
toast.parentNode.removeChild(toast);
|
||
}
|
||
}, 300);
|
||
}, 4000);
|
||
|
||
// Click to dismiss
|
||
toast.addEventListener('click', () => {
|
||
toast.style.opacity = '0';
|
||
toast.style.transform = 'translateX(100%)';
|
||
setTimeout(() => {
|
||
if (toast.parentNode) {
|
||
toast.parentNode.removeChild(toast);
|
||
}
|
||
}, 300);
|
||
});
|
||
}
|
||
|
||
// Get toast background color based on type
|
||
getToastColor(type) {
|
||
switch (type) {
|
||
case 'success': return '#10b981';
|
||
case 'error': return '#ef4444';
|
||
case 'warning': return '#f59e0b';
|
||
case 'info':
|
||
default: return '#3b82f6';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Initialize the helper when DOM is ready (only once)
|
||
if (!window.dropdownCopyHelper) {
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
window.dropdownCopyHelper = new DropdownCopyHelper();
|
||
});
|
||
} else {
|
||
window.dropdownCopyHelper = new DropdownCopyHelper();
|
||
}
|
||
}
|