您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Automatically generate professional responses to Expedia reviews using AI
// ==UserScript== // @name Expedia Review Helper // @namespace https://github.com/obsxrver/expedia-review-helper // @version 1.1.0 // @license MIT // @description Automatically generate professional responses to Expedia reviews using AI // @author Obsxrver (3than) // @match https://apps.expediapartnercentral.com/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @connect openrouter.ai // @icon data:image/png;base64,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 // @supportURL https://github.com/obsxrver/expedia-review-helper/issues // ==/UserScript== (function() { 'use strict'; // Injected HTML template const MENU_HTML = `<!-- Toggle Button --> <button id="toggleMenuBtn" class="toggle-menu-btn"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5z"/> <path d="M9 12l2 2 4-4"/> </svg> Review Helper </button> <!-- Modal Overlay --> <div id="modalOverlay" class="modal-overlay"> <div class="modal-content"> <div class="modal-header"> <h2>Expedia Review Helper Settings</h2> <button class="close-btn" id="closeModalBtn">×</button> </div> <div class="modal-body"> <!-- OpenRouter API Settings --> <div class="settings-section"> <h3>OpenRouter Settings</h3> <div class="form-group"> <label for="apiKey">API Key:</label> <input type="password" id="apiKey" placeholder="Enter your OpenRouter API key"> </div> <div class="form-group"> <label for="modelSearch">Select Model:</label> <input type="text" id="modelSearch" placeholder="Search models..." class="model-search"> <div id="providerFilters" class="provider-filters"> <button class="provider-btn active" data-provider="all">All</button> <button class="provider-btn" data-provider="openai">OpenAI</button> <button class="provider-btn" data-provider="anthropic">Anthropic</button> <button class="provider-btn" data-provider="google">Google</button> <button class="provider-btn" data-provider="deepseek">DeepSeek</button> </div> <select id="modelSelect" size="8"> <option value="loading">Loading models...</option> </select> <div id="modelInfo" class="model-info"></div> </div> </div> <!-- System Instructions --> <div class="settings-section"> <h3>System Instructions</h3> <div class="form-group"> <label for="systemInstructions">Instructions for generating responses:</label> <textarea id="systemInstructions" rows="6" placeholder="Enter instructions for how the AI should respond to reviews...">You are a professional hotel manager responding to guest reviews. Be polite, appreciative, and address any concerns mentioned. Keep responses concise but warm. Always thank the guest for their feedback and invite them to return.</textarea> </div> </div> <!-- User Information --> <div class="settings-section"> <h3>Your Information</h3> <div class="form-group"> <label for="userName">Your Name:</label> <input type="text" id="userName" placeholder="e.g., John Smith"> </div> <div class="form-group"> <label for="userPosition">Your Position:</label> <input type="text" id="userPosition" placeholder="e.g., General Manager"> </div> </div> <!-- Action Buttons --> <div class="button-group"> <button id="saveSettingsBtn" class="btn btn-primary">Save Settings</button> <button id="generateReplyBtn" class="btn btn-success">Generate Reply</button> </div> <!-- Status Message --> <div id="statusMessage" class="status-message"></div> </div> </div> </div>`; // Injected CSS const MENU_CSS = `/* Reset and Base Styles */ * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-size: 14px; line-height: 1.5; color: #333; } /* Toggle Button */ .toggle-menu-btn { position: fixed; bottom: 20px; right: 20px; background: #0066cc; color: white; border: none; border-radius: 50px; padding: 12px 20px; font-size: 14px; font-weight: 500; cursor: pointer; box-shadow: 0 4px 12px rgba(0, 102, 204, 0.3); display: flex; align-items: center; gap: 8px; transition: all 0.3s ease; z-index: 9999; } .toggle-menu-btn:hover { background: #0052a3; box-shadow: 0 6px 16px rgba(0, 102, 204, 0.4); transform: translateY(-2px); } .toggle-menu-btn svg { width: 20px; height: 20px; } /* Modal Overlay */ .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 10000; animation: fadeIn 0.3s ease; } .modal-overlay.active { display: flex; align-items: center; justify-content: center; } /* Modal Content */ .modal-content { background: white; border-radius: 12px; width: 90%; max-width: 600px; max-height: 90vh; overflow: hidden; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); animation: slideIn 0.3s ease; } /* Modal Header */ .modal-header { background: #f7f7f7; padding: 20px 24px; border-bottom: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; } .modal-header h2 { font-size: 20px; font-weight: 600; color: #1a1a1a; } .close-btn { background: none; border: none; font-size: 28px; color: #666; cursor: pointer; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 4px; transition: all 0.2s ease; } .close-btn:hover { background: #e0e0e0; color: #333; } /* Modal Body */ .modal-body { padding: 24px; overflow-y: auto; max-height: calc(90vh - 80px); } /* Settings Sections */ .settings-section { margin-bottom: 28px; } .settings-section h3 { font-size: 16px; font-weight: 600; color: #1a1a1a; margin-bottom: 16px; } /* Form Groups */ .form-group { margin-bottom: 16px; } .form-group label { display: block; font-size: 14px; font-weight: 500; color: #555; margin-bottom: 8px; } .form-group input[type="text"], .form-group input[type="password"], .form-group select, .form-group textarea { width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; font-family: inherit; transition: all 0.2s ease; } .form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: #0066cc; box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1); } .form-group textarea { resize: vertical; min-height: 100px; } /* Model Search and Select */ .model-search { margin-bottom: 8px; } .provider-filters { display: flex; gap: 8px; margin-bottom: 8px; } .provider-btn { padding: 6px 12px; border: 1px solid #ddd; background: #f7f7f7; border-radius: 6px; cursor: pointer; font-size: 12px; transition: all 0.2s ease; } .provider-btn:hover { background: #e0e0e0; } .provider-btn.active { background: #0066cc; color: white; border-color: #0066cc; } #modelSelect { height: 200px; overflow-y: auto; cursor: pointer; } #modelSelect option { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; transition: background-color 0.2s ease; } #modelSelect option:hover { background-color: #f5f5f5; } #modelSelect option:selected { background-color: #e3f2fd; color: #0066cc; } #modelSelect option[style*="display: none"] { display: none !important; } #modelSelect option:disabled { color: #999; font-style: italic; cursor: not-allowed; } /* Model Info */ .model-info { margin-top: 8px; padding: 8px 12px; background: #f0f7ff; border: 1px solid #cce5ff; border-radius: 4px; font-size: 12px; color: #0066cc; display: none; } /* Buttons */ .button-group { display: flex; gap: 12px; margin-top: 24px; } .btn { padding: 10px 20px; border: none; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s ease; flex: 1; } .btn-primary { background: #0066cc; color: white; } .btn-primary:hover { background: #0052a3; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0, 102, 204, 0.3); } .btn-success { background: #00a651; color: white; } .btn-success:hover { background: #008a43; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0, 166, 81, 0.3); } .btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none !important; } /* Status Message */ .status-message { margin-top: 16px; padding: 12px 16px; border-radius: 6px; font-size: 14px; display: none; } .status-message.success { background: #e6f7ed; color: #00703c; border: 1px solid #b3e0c9; display: block; } .status-message.error { background: #fef2f2; color: #dc2626; border: 1px solid #fecaca; display: block; } .status-message.info { background: #eff6ff; color: #1e40af; border: 1px solid #bfdbfe; display: block; } /* Animations */ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes slideIn { from { transform: translateY(-20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } /* Responsive Design */ @media (max-width: 600px) { .modal-content { width: 95%; margin: 10px; } .button-group { flex-direction: column; } .toggle-menu-btn { bottom: 10px; right: 10px; } #modelSelect { height: 150px; } }`; // Main application code // Expedia Review Helper - Main JavaScript class ExpediaReviewHelper { constructor() { this.settings = this.loadSettings(); this.modalHTML = MENU_HTML; // Will be injected by compile.py this.modalCSS = MENU_CSS; // Will be injected by compile.py this.availableModels = []; this.selectedProvider = 'all'; this.init(); } init() { // Inject CSS GM_addStyle(this.modalCSS); // Create and inject the modal HTML const modalContainer = document.createElement('div'); modalContainer.innerHTML = this.modalHTML; document.body.appendChild(modalContainer); // Set up event listeners this.setupEventListeners(); // Watch for review response dialog this.watchForReviewDialog(); // Load saved settings into form this.loadSettingsIntoForm(); // Fetch available models this.fetchAvailableModels(); } async fetchAvailableModels() { try { const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: 'https://openrouter.ai/api/v1/models', headers: { 'Content-Type': 'application/json' }, onload: function(response) { resolve(response); }, onerror: function(error) { reject(error); } }); }); if (response.status !== 200) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = JSON.parse(response.responseText); this.availableModels = data.data || []; // Update the dropdown with fetched models this.updateModelDropdown(); } catch (error) { console.error('Error fetching models:', error); // Fallback to some default models if fetch fails this.availableModels = [ { id: 'openai/gpt-4o', name: 'GPT-4o', pricing: { prompt: '0.01', completion: '0.03' } }, { id: 'anthropic/claude-4-opus', name: 'Claude 4 Opus', pricing: { prompt: '0.015', completion: '0.075' } }, { id: 'anthropic/claude-4-sonnet', name: 'Claude 4 Sonnet', pricing: { prompt: '0.003', completion: '0.015' } }, { id: 'google/gemini-2.5-pro', name: 'Gemini 2.5 Pro', pricing: { prompt: '0.00025', completion: '0.0005' } } ]; this.updateModelDropdown(); } } updateModelDropdown() { const modelSelect = document.getElementById('modelSelect'); // Clear existing options modelSelect.innerHTML = ''; // Sort models by name const sortedModels = this.availableModels.sort((a, b) => a.name.localeCompare(b.name)); // Add models to dropdown sortedModels.forEach(model => { const option = document.createElement('option'); option.value = model.id; option.textContent = `${model.name} (${model.id})`; option.dataset.description = model.description || ''; option.dataset.pricing = `$${model.pricing.prompt*1000000}/1M prompt, $${model.pricing.completion*1000000}/1M completion`; modelSelect.appendChild(option); }); // Set the saved model if it exists if (this.settings.model && this.availableModels.some(m => m.id === this.settings.model)) { modelSelect.value = this.settings.model; } else if (sortedModels.length > 0) { // Default to first model if saved model not found modelSelect.value = sortedModels[0].id; } // Update model list display based on filters this.updateModelListDisplay(); // Update model info display this.updateModelInfo(); } updateModelListDisplay() { const modelSelect = document.getElementById('modelSelect'); const searchInput = document.getElementById('modelSearch'); const options = modelSelect.querySelectorAll('option'); const searchTerm = searchInput.value.toLowerCase(); let visibleOptionsCount = 0; const selectedModelValue = this.settings.model; let selectedModelOption = null; options.forEach(option => { const modelId = option.value; if (modelId === selectedModelValue) { selectedModelOption = option; option.style.display = ''; visibleOptionsCount++; return; } const text = option.textContent.toLowerCase(); const description = option.dataset.description.toLowerCase(); const provider = modelId.split('/')[0]; const matchesProvider = this.selectedProvider === 'all' || provider === this.selectedProvider; const matchesSearch = text.includes(searchTerm) || description.includes(searchTerm); if (matchesProvider && matchesSearch) { option.style.display = ''; visibleOptionsCount++; } else { option.style.display = 'none'; } }); // Move selected model to the top if (selectedModelOption) { modelSelect.prepend(selectedModelOption); } const noResultsOption = document.getElementById('noModelsMessage'); // If no options match, show a message if (visibleOptionsCount === 0) { if (!noResultsOption) { const newNoResultsOption = document.createElement('option'); newNoResultsOption.id = 'noModelsMessage'; newNoResultsOption.textContent = 'No models found matching your search'; newNoResultsOption.disabled = true; modelSelect.appendChild(newNoResultsOption); } } else { if (noResultsOption) { noResultsOption.remove(); } } } updateModelInfo() { const modelSelect = document.getElementById('modelSelect'); const modelInfo = document.getElementById('modelInfo'); const selectedOption = modelSelect.options[modelSelect.selectedIndex]; if (selectedOption && selectedOption.dataset.pricing) { modelInfo.textContent = selectedOption.dataset.pricing; modelInfo.style.display = 'block'; } else { modelInfo.style.display = 'none'; } } setupEventListeners() { // Toggle button const toggleBtn = document.getElementById('toggleMenuBtn'); toggleBtn.addEventListener('click', () => this.openModal()); // Close button const closeBtn = document.getElementById('closeModalBtn'); closeBtn.addEventListener('click', () => this.closeModal()); // Click outside modal to close const modalOverlay = document.getElementById('modalOverlay'); modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) { this.closeModal(); } }); // Save settings button const saveBtn = document.getElementById('saveSettingsBtn'); saveBtn.addEventListener('click', () => this.saveSettings()); // Generate reply button const generateBtn = document.getElementById('generateReplyBtn'); generateBtn.addEventListener('click', () => this.generateReply()); // Model search input const modelSearch = document.getElementById('modelSearch'); modelSearch.addEventListener('input', () => { this.updateModelListDisplay(); }); // Provider filter buttons const providerBtns = document.querySelectorAll('.provider-btn'); providerBtns.forEach(btn => { btn.addEventListener('click', (e) => { providerBtns.forEach(b => b.classList.remove('active')); e.target.classList.add('active'); this.selectedProvider = e.target.dataset.provider; this.updateModelListDisplay(); }); }); // Model select change const modelSelect = document.getElementById('modelSelect'); modelSelect.addEventListener('change', () => { this.updateModelInfo(); }); } openModal() { document.getElementById('modalOverlay').classList.add('active'); } closeModal() { document.getElementById('modalOverlay').classList.remove('active'); } loadSettings() { const defaultSettings = { apiKey: '', model: 'google/gemini-2.5-pro', systemInstructions: `You are to write a response to this review of the Hotel. Please write a polite and personalized response review, keep it concise. Address the guest by their first name, tailor the response to their review, and invite them back. Be mindful not to make it sound overly templated, keep it professional, but with a friendly/casual sense of warmth. Keep it within 3-4 sentences. Don't mention the hotel's full name by name every time, be unique, add your own creative spin to it, so it doesn't look AI generated. Using the long-dash/em-dash is forbidden. Don't make unsubstantiated promises, and don't sign the messages. or end with a valediction/closing salutation`, userName: '', userPosition: 'Team Member' }; const saved = GM_getValue('expediaReviewSettings', null); return saved ? JSON.parse(saved) : defaultSettings; } saveSettings() { this.settings = { apiKey: document.getElementById('apiKey').value, model: document.getElementById('modelSelect').value, systemInstructions: document.getElementById('systemInstructions').value, userName: document.getElementById('userName').value, userPosition: document.getElementById('userPosition').value }; GM_setValue('expediaReviewSettings', JSON.stringify(this.settings)); this.showStatus('Settings saved successfully!', 'success'); } loadSettingsIntoForm() { document.getElementById('apiKey').value = this.settings.apiKey || ''; document.getElementById('modelSelect').value = this.settings.model || 'openai/gpt-3.5-turbo'; document.getElementById('systemInstructions').value = this.settings.systemInstructions || ''; document.getElementById('userName').value = this.settings.userName || ''; document.getElementById('userPosition').value = this.settings.userPosition || ''; } watchForReviewDialog() { // Use MutationObserver to watch for the review dialog const observer = new MutationObserver((mutations) => { const reviewDialog = document.querySelector('#app-layer-respond-sheet'); if (reviewDialog && reviewDialog.getAttribute('aria-hidden') === 'false') { this.injectGenerateButton(); } }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['aria-hidden'] }); } injectGenerateButton() { // Check if button already exists if (document.querySelector('.expedia-helper-generate-btn')) return; // Find the textarea container const textareaContainer = document.querySelector('.uitk-field.has-floatedLabel-label.has-textarea.is-required'); if (!textareaContainer) return; // Create generate button const generateBtn = document.createElement('button'); generateBtn.className = 'expedia-helper-generate-btn'; generateBtn.textContent = '✨ Generate Reply'; generateBtn.style.cssText = ` background: #0066cc; color: white; border: none; border-radius: 6px; padding: 8px 16px; font-size: 14px; font-weight: 500; cursor: pointer; margin-top: 8px; transition: all 0.2s ease; `; generateBtn.addEventListener('click', (e) => { e.preventDefault(); this.generateReplyFromDialog(); }); generateBtn.addEventListener('mouseenter', () => { generateBtn.style.background = '#0052a3'; }); generateBtn.addEventListener('mouseleave', () => { generateBtn.style.background = '#0066cc'; }); // Insert button after textarea container textareaContainer.parentNode.insertBefore(generateBtn, textareaContainer.nextSibling); } extractReviewContent() { // Look for the review card within the response sheet const responseSheet = document.querySelector('#app-layer-respond-sheet'); if (!responseSheet) return null; // Find the review card within the sheet content const reviewCard = responseSheet.querySelector('.uitk-card.uitk-card-roundcorner-all.uitk-card-has-border.uitk-card-padded.uitk-spacing.uitk-spacing-margin-blockstart-four'); if (!reviewCard) return null; const reviewData = { guestName: '', dates: '', rating: '', postedDate: '', reviewText: '' }; // Extract guest name - it's in an h6 heading const nameElement = reviewCard.querySelector('.uitk-heading.uitk-heading-6'); if (nameElement) reviewData.guestName = nameElement.textContent.trim(); // Extract dates - look for the date text after the name const dateElements = reviewCard.querySelectorAll('.uitk-text.uitk-type-300.uitk-text-default-theme'); dateElements.forEach(element => { const text = element.textContent.trim(); // Check if it looks like a date range (contains dash and months/numbers) if (text.includes(' - ') && (text.includes('Jan') || text.includes('Feb') || text.includes('Mar') || text.includes('Apr') || text.includes('May') || text.includes('Jun') || text.includes('Jul') || text.includes('Aug') || text.includes('Sep') || text.includes('Oct') || text.includes('Nov') || text.includes('Dec'))) { reviewData.dates = text; } // Check if it's a posted date if (text.startsWith('Posted ')) { reviewData.postedDate = text; } }); // Extract rating - look for text like "10/10" const ratingElement = reviewCard.querySelector('.uitk-text.uitk-type-500.uitk-type-medium.uitk-text-default-theme'); if (ratingElement) reviewData.rating = ratingElement.textContent.trim(); // Extract review text - it's in the expando-peek section const reviewTextContainer = reviewCard.querySelector('.uitk-expando-peek-inner .uitk-text.uitk-type-300.uitk-text-default-theme'); if (reviewTextContainer) reviewData.reviewText = reviewTextContainer.textContent.trim(); return reviewData; } // Simulate realistic input for form fields simulateInput(element, value) { // Focus the element first element.focus(); // Clear existing value element.value = ''; // Get the native setter to bypass any framework wrappers const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; // Use the appropriate native setter if (element.tagName.toLowerCase() === 'textarea') { nativeTextAreaValueSetter.call(element, value); } else { nativeInputValueSetter.call(element, value); } // Create and dispatch events to simulate real user input const events = [ new Event('focus', { bubbles: true }), new Event('input', { bubbles: true }), new Event('change', { bubbles: true }), new Event('blur', { bubbles: true }) ]; events.forEach(event => { element.dispatchEvent(event); }); // Also try React-specific events if they exist if (element._reactInternalFiber || element._reactInternalInstance) { // This is a React component, try to trigger React events const reactEvent = new Event('input', { bubbles: true }); reactEvent.simulated = true; element.dispatchEvent(reactEvent); } // Try to trigger any onChange handlers manually if (element.onchange) { element.onchange({ target: element }); } if (element.oninput) { element.oninput({ target: element }); } } // Stream the response character by character for a typing effect async simulateTyping(element, text, delay = 20) { element.focus(); element.value = ''; for (let i = 0; i < text.length; i++) { const char = text[i]; element.value += char; // Dispatch input event for each character element.dispatchEvent(new Event('input', { bubbles: true })); // Small delay to simulate typing await new Promise(resolve => setTimeout(resolve, delay)); } // Final events element.dispatchEvent(new Event('change', { bubbles: true })); element.dispatchEvent(new Event('blur', { bubbles: true })); } async generateReply() { if (!this.settings.apiKey) { this.showStatus('Please enter your OpenRouter API key', 'error'); return; } this.showStatus('This feature requires being on a review page', 'info'); this.closeModal(); } async generateReplyFromDialog() { if (!this.settings.apiKey) { alert('Please configure your API key in settings'); this.showStatus('Please configure your API key in settings', 'error'); this.openModal(); return; } const reviewData = this.extractReviewContent(); if (!reviewData) { alert('Could not extract review content'); return; } // Show loading state const generateBtn = document.querySelector('.expedia-helper-generate-btn'); const originalText = generateBtn.textContent; generateBtn.textContent = '⏳ Generating...'; generateBtn.disabled = true; try { // Find the response textarea const responseTextarea = document.querySelector('.uitk-field-textarea.empty-placeholder'); if (!responseTextarea) { throw new Error('Could not find response textarea'); } // Clear the textarea and prepare for streaming responseTextarea.focus(); responseTextarea.value = ''; responseTextarea.dispatchEvent(new Event('input', { bubbles: true })); // Start streaming the response await this.callOpenRouterStreaming(reviewData, responseTextarea); // Fill in the name field after response is complete const nameInput = document.querySelector('input[type="text"].uitk-field-input'); if (nameInput && this.settings.userName) { const fullName = this.settings.userPosition ? `${this.settings.userName} - ${this.settings.userPosition}` : this.settings.userName; // Use improved input simulation this.simulateInput(nameInput, fullName); } } catch (error) { console.error('Error generating reply:', error); alert('Error generating reply: ' + error.message); } finally { generateBtn.textContent = originalText; generateBtn.disabled = false; } } async callOpenRouterStreaming(reviewData, responseTextarea) { console.log("review data:"); console.log(reviewData); const prompt = ` Guest Name: ${reviewData.guestName} Stay Dates: ${reviewData.dates} Rating: ${reviewData.rating} Review: ${reviewData.reviewText} Please generate a professional response to this review following the system instructions. `; return new Promise((resolve, reject) => { let responseText = ''; let streamComplete = false; GM_xmlhttpRequest({ method: 'POST', url: 'https://openrouter.ai/api/v1/chat/completions', headers: { 'Authorization': `Bearer ${this.settings.apiKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': window.location.href, 'X-Title': 'Expedia Review Helper' }, data: JSON.stringify({ model: this.settings.model, stream: true, messages: [ { role: 'system', content: this.settings.systemInstructions }, { role: 'user', content: prompt } ] }), timeout: 90000, responseType: 'stream', onloadstart: function(response) { // Get the ReadableStream from the response const reader = response.response.getReader(); // Process the stream const processStream = async () => { try { let isDone = false; while (!isDone && !streamComplete) { const { done, value } = await reader.read(); if (done) { isDone = true; break; } // Convert the chunk to text const chunk = new TextDecoder().decode(value); // Split by lines - server-sent events format const lines = chunk.split('\n'); for (const line of lines) { if (line.startsWith('data: ')) { const data = line.substring(6).trim(); // Check for the end of the stream if (data === '[DONE]') { isDone = true; break; } try { const parsed = JSON.parse(data); // if the model is a reasoning model, choices[0].delta.reasoning will come first // discard and continue if (parsed.choices && parsed.choices[0] && parsed.choices[0].delta && parsed.choices[0].delta.reasoning && parsed.choices[0].delta.reasoning.length > 0) { continue; } // otherwise, extract the content from delta else if (parsed.choices && parsed.choices[0] && parsed.choices[0].delta) { const content = parsed.choices[0].delta.content; if (content) { responseText += content; // Update the textarea with the new content const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; nativeTextAreaValueSetter.call(responseTextarea, responseText); // Dispatch input event to notify the form responseTextarea.dispatchEvent(new Event('input', { bubbles: true })); // Auto-resize the textarea if needed responseTextarea.style.height = 'auto'; responseTextarea.style.height = responseTextarea.scrollHeight + 'px'; } } } catch (e) { // Ignore parsing errors for malformed chunks console.debug('Failed to parse chunk:', data); } } } } // When done, resolve the promise if (!streamComplete) { streamComplete = true; resolve(responseText); } } catch (error) { console.error('Stream processing error:', error); if (!streamComplete) { streamComplete = true; reject(new Error(`Stream processing error: ${error.toString()}`)); } } }; processStream().catch(error => { console.error('Unhandled stream error:', error); if (!streamComplete) { streamComplete = true; reject(new Error(`Unhandled stream error: ${error.toString()}`)); } }); }, onerror: function(error) { if (!streamComplete) { streamComplete = true; reject(new Error('Network request failed')); } }, ontimeout: function() { if (!streamComplete) { streamComplete = true; reject(new Error('Request timed out')); } } }); }); } showStatus(message, type) { const statusEl = document.getElementById('statusMessage'); statusEl.textContent = message; statusEl.className = `status-message ${type}`; setTimeout(() => { statusEl.className = 'status-message'; }, 3000); } } // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => new ExpediaReviewHelper()); } else { new ExpediaReviewHelper(); } })();