// ==UserScript== // @name TweetFilter AI // @namespace http://tampermonkey.net/ // @version Version 1.3.1 // @description A highly customizable AI rates tweets 1-10 and removes all the slop, saving your braincells! // @author Obsxrver(3than) // @match *://twitter.com/* // @match *://x.com/* // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant GM_getResourceText // @connect openrouter.ai // @run-at document-idle // @license MIT // @downloadURL none // ==/UserScript== (function() { 'use strict'; console.log("X/Twitter Tweet De-Sloppification Activated (Combined Version)"); // Embedded Menu.html const MENU = `
5
`; // Embedded style.css const STYLE = `/* Modern X-Inspired Styles - Enhanced --------------------------------- */ /* Main tweet filter container */ #tweet-filter-container { position: fixed; top: 70px; right: 15px; background-color: rgba(22, 24, 28, 0.95); color: #e7e9ea; padding: 10px 12px; border-radius: 12px; z-index: 9999; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 13px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5); display: flex; align-items: center; gap: 10px; border: 1px solid rgba(255, 255, 255, 0.1); } /* Close button styles */ .close-button { background: none; border: none; color: #e7e9ea; font-size: 16px; cursor: pointer; padding: 0; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: opacity 0.2s; border-radius: 50%; } .close-button:hover { opacity: 1; background-color: rgba(255, 255, 255, 0.1); } /* Hidden state */ .hidden { display: none !important; } /* Show/hide button */ .toggle-button { position: fixed; right: 15px; background-color: rgba(22, 24, 28, 0.95); color: #e7e9ea; padding: 8px 12px; border-radius: 8px; cursor: pointer; font-size: 12px; z-index: 9999; border: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; display: flex; align-items: center; gap: 6px; transition: all 0.2s ease; } .toggle-button:hover { background-color: rgba(29, 155, 240, 0.2); } #filter-toggle { top: 70px; } #settings-toggle { top: 120px; } #tweet-filter-container label { margin: 0; font-weight: bold; } #tweet-filter-slider { cursor: pointer; width: 120px; vertical-align: middle; accent-color: #1d9bf0; } #tweet-filter-value { min-width: 20px; text-align: center; font-weight: bold; background-color: rgba(255, 255, 255, 0.1); padding: 2px 5px; border-radius: 4px; } /* Settings UI with Tabs */ #settings-container { position: fixed; top: 70px; right: 15px; background-color: rgba(22, 24, 28, 0.95); color: #e7e9ea; padding: 0; /* Remove padding to accommodate sticky header */ border-radius: 16px; z-index: 9999; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 13px; box-shadow: 0 2px 18px rgba(0, 0, 0, 0.6); display: flex; flex-direction: column; width: 380px; max-height: 85vh; overflow: hidden; /* Hide overflow to make the sticky header work properly */ border: 1px solid rgba(255, 255, 255, 0.1); line-height: 1.3; transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); transform-origin: top right; } #settings-container.hidden { opacity: 0; transform: scale(0.9); pointer-events: none; } /* Header section */ .settings-header { padding: 12px 15px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); display: flex; justify-content: space-between; align-items: center; position: sticky; top: 0; background-color: rgba(22, 24, 28, 0.98); z-index: 20; border-radius: 16px 16px 0 0; } .settings-title { font-weight: bold; font-size: 16px; } /* Content area with scrolling */ .settings-content { overflow-y: auto; max-height: calc(85vh - 110px); /* Account for header and tabs */ padding: 0; } /* Scrollbar styling for settings container */ .settings-content::-webkit-scrollbar { width: 6px; } .settings-content::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.05); border-radius: 3px; } .settings-content::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); border-radius: 3px; } .settings-content::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); } /* Tab Navigation */ .tab-navigation { display: flex; border-bottom: 1px solid rgba(255, 255, 255, 0.1); position: sticky; top: 0; background-color: rgba(22, 24, 28, 0.98); z-index: 10; padding: 10px 15px; gap: 8px; } .tab-button { padding: 6px 10px; background: none; border: none; color: #e7e9ea; font-weight: bold; cursor: pointer; border-radius: 8px; transition: all 0.2s ease; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 13px; flex: 1; text-align: center; } .tab-button:hover { background-color: rgba(255, 255, 255, 0.1); } .tab-button.active { color: #1d9bf0; background-color: rgba(29, 155, 240, 0.1); border-bottom: 2px solid #1d9bf0; } /* Tab Content */ .tab-content { display: none; animation: fadeIn 0.3s ease; padding: 15px; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } .tab-content.active { display: block; } /* Enhanced dropdowns */ .select-container { position: relative; margin-bottom: 15px; } .select-container .search-field { position: sticky; top: 0; background-color: rgba(39, 44, 48, 0.95); padding: 8px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); z-index: 1; } .select-container .search-input { width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.2); background-color: rgba(39, 44, 48, 0.9); color: #e7e9ea; font-size: 12px; transition: border-color 0.2s; } .select-container .search-input:focus { border-color: #1d9bf0; outline: none; } .custom-select { position: relative; display: inline-block; width: 100%; } .select-selected { background-color: rgba(39, 44, 48, 0.95); color: #e7e9ea; padding: 10px 12px; border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 8px; cursor: pointer; user-select: none; display: flex; justify-content: space-between; align-items: center; font-size: 13px; transition: border-color 0.2s; } .select-selected:hover { border-color: rgba(255, 255, 255, 0.4); } .select-selected:after { content: ""; width: 8px; height: 8px; border: 2px solid #e7e9ea; border-width: 0 2px 2px 0; display: inline-block; transform: rotate(45deg); margin-left: 10px; transition: transform 0.2s; } .select-selected.select-arrow-active:after { transform: rotate(-135deg); } .select-items { position: absolute; background-color: rgba(39, 44, 48, 0.98); top: 100%; left: 0; right: 0; z-index: 99; max-height: 300px; overflow-y: auto; border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 8px; margin-top: 5px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); display: none; } .select-items div { color: #e7e9ea; padding: 10px 12px; cursor: pointer; user-select: none; transition: background-color 0.2s; border-bottom: 1px solid rgba(255, 255, 255, 0.05); } .select-items div:hover { background-color: rgba(29, 155, 240, 0.1); } .select-items div.same-as-selected { background-color: rgba(29, 155, 240, 0.2); } /* Scrollbar for select items */ .select-items::-webkit-scrollbar { width: 6px; } .select-items::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.05); } .select-items::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); border-radius: 3px; } .select-items::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); } /* Form elements */ #openrouter-api-key, #user-instructions { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.2); margin-bottom: 12px; background-color: rgba(39, 44, 48, 0.95); color: #e7e9ea; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 13px; transition: border-color 0.2s; } #openrouter-api-key:focus, #user-instructions:focus { border-color: #1d9bf0; outline: none; } #user-instructions { height: 120px; resize: vertical; } /* Parameter controls */ .parameter-row { display: flex; align-items: center; margin-bottom: 12px; gap: 8px; padding: 6px; border-radius: 8px; transition: background-color 0.2s; } .parameter-row:hover { background-color: rgba(255, 255, 255, 0.05); } .parameter-label { flex: 1; font-size: 13px; color: #e7e9ea; } .parameter-control { flex: 1.5; display: flex; align-items: center; gap: 8px; } .parameter-value { min-width: 28px; text-align: center; background-color: rgba(255, 255, 255, 0.1); padding: 3px 5px; border-radius: 4px; font-size: 12px; } .parameter-slider { flex: 1; -webkit-appearance: none; height: 4px; border-radius: 4px; background: rgba(255, 255, 255, 0.2); outline: none; cursor: pointer; } .parameter-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #1d9bf0; cursor: pointer; transition: transform 0.1s; } .parameter-slider::-webkit-slider-thumb:hover { transform: scale(1.2); } /* Section styles */ .section-title { font-weight: bold; margin-top: 20px; margin-bottom: 8px; color: #e7e9ea; display: flex; align-items: center; gap: 6px; font-size: 14px; } .section-title:first-child { margin-top: 0; } .section-description { font-size: 12px; margin-bottom: 8px; opacity: 0.8; line-height: 1.4; } /* Advanced options section */ .advanced-options { margin-top: 5px; margin-bottom: 15px; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; padding: 12px; background-color: rgba(255, 255, 255, 0.03); overflow: hidden; } .advanced-toggle { display: flex; justify-content: space-between; align-items: center; cursor: pointer; margin-bottom: 5px; } .advanced-toggle-title { font-weight: bold; font-size: 13px; color: #e7e9ea; } .advanced-toggle-icon { transition: transform 0.3s; } .advanced-toggle-icon.expanded { transform: rotate(180deg); } .advanced-content { max-height: 0; overflow: hidden; transition: max-height 0.3s ease-in-out; } .advanced-content.expanded { max-height: 300px; } /* Handle list styling */ .handle-list { margin-top: 10px; max-height: 120px; overflow-y: auto; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; padding: 5px; } .handle-item { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); border-radius: 4px; transition: background-color 0.2s; } .handle-item:hover { background-color: rgba(255, 255, 255, 0.05); } .handle-item:last-child { border-bottom: none; } .handle-text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 12px; } .remove-handle { background: none; border: none; color: #ff5c5c; cursor: pointer; font-size: 14px; padding: 0 3px; opacity: 0.7; transition: opacity 0.2s; } .remove-handle:hover { opacity: 1; } .add-handle-btn { background-color: #1d9bf0; color: white; border: none; border-radius: 6px; padding: 7px 10px; cursor: pointer; font-weight: bold; font-size: 12px; margin-left: 5px; transition: background-color 0.2s; } .add-handle-btn:hover { background-color: #1a8cd8; } /* Button styling */ .settings-button { background-color: #1d9bf0; color: white; border: none; border-radius: 8px; padding: 10px 14px; cursor: pointer; font-weight: bold; transition: background-color 0.2s; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin-top: 8px; width: 100%; font-size: 13px; } .settings-button:hover { background-color: #1a8cd8; } .settings-button.secondary { background-color: rgba(255, 255, 255, 0.1); } .settings-button.secondary:hover { background-color: rgba(255, 255, 255, 0.15); } .settings-button.danger { background-color: #ff5c5c; } .settings-button.danger:hover { background-color: #e53935; } /* For smaller buttons that sit side by side */ .button-row { display: flex; gap: 8px; margin-top: 10px; } .button-row .settings-button { margin-top: 0; } /* Stats display */ .stats-container { background-color: rgba(255, 255, 255, 0.05); padding: 10px; border-radius: 8px; margin-bottom: 15px; } .stats-row { display: flex; justify-content: space-between; padding: 5px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .stats-row:last-child { border-bottom: none; } .stats-label { font-size: 12px; opacity: 0.8; } .stats-value { font-weight: bold; } /* Rating indicator shown on tweets */ .score-indicator { position: absolute; top: 10px; right: 10.5%; background-color: rgba(22, 24, 28, 0.9); color: #e7e9ea; padding: 4px 10px; border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; font-weight: bold; z-index: 100; cursor: pointer; border: 1px solid rgba(255, 255, 255, 0.1); min-width: 20px; text-align: center; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); transition: transform 0.15s ease; } .score-indicator:hover { transform: scale(1.05); } /* Refresh animation */ .refreshing { animation: spin 1s infinite linear; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* The description box for ratings */ .score-description { display: none; background-color: rgba(22, 24, 28, 0.95); color: #e7e9ea; padding: 16px 20px; border-radius: 12px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.5; z-index: 99999999; position: absolute; width: clamp(300px, 30vw, 500px); max-height: 60vh; overflow-y: auto; border: 1px solid rgba(255, 255, 255, 0.1); word-wrap: break-word; } /* Rating status classes */ .cached-rating { background-color: rgba(76, 175, 80, 0.9) !important; color: white !important; } .blacklisted-rating { background-color: rgba(255, 193, 7, 0.9) !important; color: black !important; } .pending-rating { background-color: rgba(255, 152, 0, 0.9) !important; color: white !important; } .error-rating { background-color: rgba(244, 67, 54, 0.9) !important; color: white !important; } /* Status indicator at bottom-right */ #status-indicator { position: fixed; bottom: 20px; right: 20px; background-color: rgba(22, 24, 28, 0.95); color: #e7e9ea; padding: 10px 15px; border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 12px; z-index: 9999; display: none; border: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4); transform: translateY(100px); transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); } #status-indicator.active { display: block; transform: translateY(0); } /* Toggle switch styling */ .toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; } .toggle-switch input { opacity: 0; width: 0; height: 0; } .toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(255, 255, 255, 0.2); transition: .3s; border-radius: 34px; } .toggle-slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 2px; bottom: 2px; background-color: white; transition: .3s; border-radius: 50%; } input:checked + .toggle-slider { background-color: #1d9bf0; } input:checked + .toggle-slider:before { transform: translateX(16px); } .toggle-row { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; margin-bottom: 12px; background-color: rgba(255, 255, 255, 0.05); border-radius: 8px; transition: background-color 0.2s; } .toggle-row:hover { background-color: rgba(255, 255, 255, 0.08); } .toggle-label { font-size: 13px; color: #e7e9ea; } /* Existing styles */ /* Sort container styles */ .sort-container { margin: 10px 0; display: flex; align-items: center; gap: 10px; } .sort-container label { font-size: 14px; color: var(--text-color); } .sort-container select { padding: 5px 10px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.2); background-color: rgba(39, 44, 48, 0.95); color: #e7e9ea; font-size: 14px; cursor: pointer; } .sort-container select:hover { border-color: #1d9bf0; } .sort-container select:focus { outline: none; border-color: #1d9bf0; box-shadow: 0 0 0 2px rgba(29, 155, 240, 0.2); } /* Dropdown option styling */ .sort-container select option { background-color: rgba(39, 44, 48, 0.95); color: #e7e9ea; }`; // Apply CSS GM_addStyle(STYLE); // Set menu HTML GM_setValue('menuHTML', MENU); // ----- twitter-desloppifier.js ----- (function () { 'use strict'; console.log("X/Twitter Tweet De-Sloppification Activated (v1.3.1 - Enhanced)"); // Load CSS stylesheet //const css = GM_getResourceText('STYLESHEET'); let menuhtml = GM_getResourceText("MENU_HTML"); GM_setValue('menuHTML', menuhtml); let firstRun = GM_getValue('firstRun', true); //GM_addStyle(css); // ----- Initialization ----- /** * Initializes the observer on the main content area, adds the UI elements, * starts processing visible tweets, and sets up periodic checks. */ function initializeObserver() { const target = document.querySelector('main') || document.querySelector('div[data-testid="primaryColumn"]'); if (target) { observedTargetNode = target; console.log("X/Twitter Tweet De-Sloppification: Target node found. Observing..."); initialiseUI(); if (firstRun){ resetSettings(true); GM_setValue('firstRun', !GM_getValue('openai-api-key')||false); } // If no API key is found, prompt the user const apiKey = GM_getValue('openrouter-api-key', ''); if (!apiKey) { apiKey = alert("\nPlease enter your OpenRouter API key. You can get one at https://openrouter.ai/"); if (apiKey) { GM_setValue('openrouter-api-key', apiKey); } showStatus("No API key found. Please enter your OpenRouter API key."); } else { showStatus(`Loaded ${Object.keys(tweetIDRatingCache).length} cached ratings. Starting to rate visible tweets...`); fetchAvailableModels(); } // Process all currently visible tweets observedTargetNode.querySelectorAll(TWEET_ARTICLE_SELECTOR).forEach(scheduleTweetProcessing); const observer = new MutationObserver(handleMutations); observer.observe(observedTargetNode, { childList: true, subtree: true }); ensureAllTweetsRated(); window.addEventListener('beforeunload', () => { saveTweetRatings(); observer.disconnect(); const sliderUI = document.getElementById('tweet-filter-container'); if (sliderUI) sliderUI.remove(); const settingsUI = document.getElementById('settings-container'); if (settingsUI) settingsUI.remove(); const statusIndicator = document.getElementById('status-indicator'); if (statusIndicator) statusIndicator.remove(); // Clean up all description elements cleanupDescriptionElements(); console.log("X/Twitter Tweet De-Sloppification Deactivated."); }); } else { setTimeout(initializeObserver, 1000); } } // Start observing tweets and initializing the UI initializeObserver(); })(); // ----- config.js ----- const processedTweets = new Set(); // Set of tweet IDs already processed in this session const tweetIDRatingCache = {}; // ID-based cache for persistent storage const PROCESSING_DELAY_MS = 500; // Delay before processing a tweet (ms) const API_CALL_DELAY_MS = 250; // Minimum delay between API calls (ms) let USER_DEFINED_INSTRUCTIONS = GM_getValue('userDefinedInstructions', `- Give high scores to insightful and impactful tweets - Give low scores to clickbait, fearmongering, and ragebait - Give high scores to high-effort content and artistic content`); let currentFilterThreshold = GM_getValue('filterThreshold', 1); // Filter threshold for tweet visibility let observedTargetNode = null; let lastAPICallTime = 0; let pendingRequests = 0; const MAX_RETRIES = 3; let availableModels = []; // List of models fetched from API let selectedModel = GM_getValue('selectedModel', 'google/gemini-flash-1.5-8b'); let selectedImageModel = GM_getValue('selectedImageModel', 'google/gemini-flash-1.5-8b'); let blacklistedHandles = GM_getValue('blacklistedHandles', '').split('\n').filter(h => h.trim() !== ''); let storedRatings = GM_getValue('tweetRatings', '{}'); let threadHist = ""; // Settings variables let enableImageDescriptions = GM_getValue('enableImageDescriptions', false); // Model parameters const SYSTEM_PROMPT=`You are a tweet filtering AI. Your task is to rate tweets on a scale of 0 to 10 based on user-defined instructions. You will be given a Tweet, structured like this: _______TWEET SCHEMA_______ _______BEGIN TWEET_______ [TWEET {TweetID}] {the text of the tweet being replied to} [MEDIA_DESCRIPTION]: [IMAGE 1]: {description}, [IMAGE 2]: {description}, etc. [REPLY] (if the author is replying to another tweet) [TWEET {TweetID}]: (the tweet which you are to review) @{the author of the tweet} {the text of the tweet} [MEDIA_DESCRIPTION]: [IMAGE 1]: {description}, [IMAGE 2]: {description}, etc. [QUOTED_TWEET]: (if the author is quoting another tweet) {the text of the quoted tweet} [QUOTED_TWEET_MEDIA_DESCRIPTION]: [IMAGE 1]: {description}, [IMAGE 2]: {description}, etc. _______END TWEET_______ _______END TWEET SCHEMA_______ You are to review and provide a rating for the tweet with the specified tweet ID. Ensure that you consider the user-defined instructions in your analysis and scoring, specified by: [USER-DEFINED INSTRUCTIONS]: Provide a concise explanation of your reasoning and then, on a new line, output your final rating in the exact format: SCORE_X where X is a number from 0 (lowest quality) to 10 (highest quality). for example: SCORE_0, SCORE_1, SCORE_2, SCORE_3, etc. If one of the above is not present, the program will not be able to parse the response and will return an error. ` let modelTemperature = GM_getValue('modelTemperature', 0.5); let modelTopP = GM_getValue('modelTopP', 0.9); let imageModelTemperature = GM_getValue('imageModelTemperature', 0.5); let imageModelTopP = GM_getValue('imageModelTopP', 0.9); let maxTokens = GM_getValue('maxTokens', 0); // Maximum number of tokens for API requests, 0 means no limit let imageModelMaxTokens = GM_getValue('imageModelMaxTokens', 0); // Maximum number of tokens for image model API requests, 0 means no limit //let menuHTML= ""; // ----- DOM Selectors (for tweet elements) ----- const TWEET_ARTICLE_SELECTOR = 'article[data-testid="tweet"]'; const QUOTE_CONTAINER_SELECTOR = 'div[role="link"][tabindex="0"]'; const USER_NAME_SELECTOR = 'div[data-testid="User-Name"] span > span'; const USER_HANDLE_SELECTOR = 'div[data-testid="User-Name"] a[role="link"]'; const TWEET_TEXT_SELECTOR = 'div[data-testid="tweetText"]'; const MEDIA_IMG_SELECTOR = 'div[data-testid="tweetPhoto"] img, img[src*="pbs.twimg.com/media"]'; const MEDIA_VIDEO_SELECTOR = 'video[poster*="pbs.twimg.com"], video'; const PERMALINK_SELECTOR = 'a[href*="/status/"] time'; // ----- Dom Elements ----- /** * Helper function to check if a model supports images based on its architecture * @param {string} modelId - The model ID to check * @returns {boolean} - Whether the model supports image input */ function modelSupportsImages(modelId) { if (!availableModels || availableModels.length === 0) { return false; // If we don't have model info, assume it doesn't support images } const model = availableModels.find(m => m.slug === modelId); if (!model) { return false; // Model not found in available models list } // Check if model supports images based on its architecture return model.input_modalities && model.input_modalities.includes('image'); } try { Object.assign(tweetIDRatingCache, JSON.parse(storedRatings)); console.log(`Loaded ${Object.keys(tweetIDRatingCache).length} cached tweet ratings`); } catch (e) { console.error('Error loading stored ratings:', e); } // ----- api.js ----- /** * @typedef {Object} CompletionResponse * @property {string} id - Response ID from OpenRouter * @property {string} model - Model used for completion * @property {Array<{ * message: { * role: string, * content: string * }, * finish_reason: string, * index: number * }>} choices - Array of completion choices * @property {Object} usage - Token usage statistics * @property {number} usage.prompt_tokens - Number of tokens in prompt * @property {number} usage.completion_tokens - Number of tokens in completion * @property {number} usage.total_tokens - Total tokens used */ /** * @typedef {Object} CompletionRequest * @property {string} model - Model ID to use * @property {Array<{role: string, content: Array<{type: string, text?: string, image_url?: {url: string}}>}>} messages - Messages for completion * @property {number} temperature - Temperature for sampling * @property {number} top_p - Top P for sampling * @property {number} max_tokens - Maximum tokens to generate * @property {Object} provider - Provider settings * @property {string} provider.sort - Sort order for models * @property {boolean} provider.allow_fallbacks - Whether to allow fallback models */ /** * @typedef {Object} CompletionResult * @property {boolean} error - Whether an error occurred * @property {string} message - Error or success message * @property {CompletionResponse|null} data - The completion response data if successful */ /** * Gets a completion from OpenRouter API * * @param {CompletionRequest} request - The completion request * @param {string} apiKey - OpenRouter API key * @param {number} [timeout=30000] - Request timeout in milliseconds * @returns {Promise} The completion result */ async function getCompletion(request, apiKey, timeout = 30000) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: "POST", url: "https://openrouter.ai/api/v1/chat/completions", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}`, "HTTP-Referer": "https://greasyfork.org/en/scripts/532459-tweetfilter-ai", "X-Title": "TweetFilter-AI" }, data: JSON.stringify(request), timeout: timeout, onload: function (response) { if (response.status >= 200 && response.status < 300) { try { const data = JSON.parse(response.responseText); if (data.content==="") { resolve({ error: true, message: `No content returned${data.choices[0].native_finish_reason=="SAFETY"?" (SAFETY FILTER)":""}`, data: data }); } resolve({ error: false, message: "Request successful", data: data }); } catch (error) { resolve({ error: true, message: `Failed to parse response: ${error.message}`, data: null }); } } else { resolve({ error: true, message: `Request failed with status ${response.status}: ${response.responseText}`, data: null }); } }, onerror: function (error) { resolve({ error: true, message: `Request error: ${error.toString()}`, data: null }); }, ontimeout: function () { resolve({ error: true, message: `Request timed out after ${timeout}ms`, data: null }); } }); }); } const safetySettings = [ { category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_CIVIC_INTEGRITY", threshold: "BLOCK_NONE", }, ]; /** * Rates a tweet using the OpenRouter API with automatic retry functionality. * * @param {string} tweetText - The text content of the tweet * @param {string} tweetId - The unique tweet ID * @param {string} apiKey - The API key for authentication * @param {string[]} mediaUrls - Array of media URLs associated with the tweet * @param {number} [maxRetries=3] - Maximum number of retry attempts * @returns {Promise<{score: number, content: string, error: boolean}>} The rating result */ async function rateTweetWithOpenRouter(tweetText, tweetId, apiKey, mediaUrls, maxRetries = 3) { const request = { model: selectedModel, messages: [{ role: "system", content: [{ type: "text", text: ` ${SYSTEM_PROMPT}` },] }, { role: "user", content: [{ type: "text", text: `provide your reasoning, and a rating (eg. SCORE_0, SCORE_1, SCORE_2, SCORE_3, etc.) for the tweet with tweet ID ${tweetId}. [USER-DEFINED INSTRUCTIONS]: ${USER_DEFINED_INSTRUCTIONS} _______BEGIN TWEET_______ ${tweetText} _______END TWEET_______` }] }] }; if (selectedModel.includes('gemini')) { request.config = { safetySettings: safetySettings, }; } // Add image URLs if present and supported if (mediaUrls?.length > 0 && modelSupportsImages(selectedModel)) { for (const url of mediaUrls) { request.messages[1].content.push({ type: "image_url", image_url: { url } }); } } // Add model parameters request.temperature = modelTemperature; request.top_p = modelTopP; request.max_tokens = maxTokens; // Add provider settings const sortOrder = GM_getValue('modelSortOrder', 'throughput-high-to-low'); request.provider = { sort: sortOrder.split('-')[0], allow_fallbacks: true }; // Implement retry logic with exponential backoff let attempt = 0; while (attempt < maxRetries) { attempt++; // Rate limiting const now = Date.now(); const timeElapsed = now - lastAPICallTime; if (timeElapsed < API_CALL_DELAY_MS) { await new Promise(resolve => setTimeout(resolve, API_CALL_DELAY_MS - timeElapsed)); } lastAPICallTime = now; // Update status pendingRequests++; showStatus(`Rating tweet... (${pendingRequests} pending)`); // Make API request const result = await getCompletion(request, apiKey); pendingRequests--; showStatus(`Rating tweet... (${pendingRequests} pending)`); if (!result.error && result.data?.choices?.[0]?.message?.content) { const content = result.data.choices[0].message.content; const scoreMatch = content.match(/\SCORE_(\d+)/); if (scoreMatch) { const score = parseInt(scoreMatch[1], 10); tweetIDRatingCache[tweetId] = { tweetContent: tweetText, score: score, description: content }; saveTweetRatings(); return { score, content, error: false }; } } // Handle retries if (attempt < maxRetries) { const backoffDelay = Math.pow(attempt, 2) * 1000; console.log(`Attempt ${attempt}/${maxRetries} failed. Retrying in ${backoffDelay}ms...`); console.log('Response:', { error: result.error, message: result.message, data: result.data, content: result.data?.choices?.[0]?.message?.content }); await new Promise(resolve => setTimeout(resolve, backoffDelay)); } } return { score: 5, content: "Failed to get valid rating after multiple attempts", error: true }; } /** * Gets descriptions for images using the OpenRouter API * * @param {string[]} urls - Array of image URLs to get descriptions for * @param {string} apiKey - The API key for authentication * @param {string} tweetId - The unique tweet ID * @param {string} userHandle - The Twitter user handle * @returns {Promise} Combined image descriptions */ async function getImageDescription(urls, apiKey, tweetId, userHandle) { if (!urls?.length || !enableImageDescriptions) { return !enableImageDescriptions ? '[Image descriptions disabled]' : ''; } let descriptions = []; for (const url of urls) { const request = { model: selectedImageModel, messages: [{ role: "user", content: [ { type: "text", text: "Describe what you see in this image in a concise way, focusing on the main elements and any text visible. Keep the description under 100 words." }, { type: "image_url", image_url: { url } } ] }], temperature: imageModelTemperature, top_p: imageModelTopP, max_tokens: maxTokens, provider: { sort: GM_getValue('modelSortOrder', 'throughput-high-to-low').split('-')[0], allow_fallbacks: true } }; if (selectedImageModel.includes('gemini')) { request.config = { safetySettings: safetySettings, } } const result = await getCompletion(request, apiKey); if (!result.error && result.data?.choices?.[0]?.message?.content) { descriptions.push(result.data.choices[0].message.content); } else { descriptions.push('[Error getting image description]'); } } return descriptions.map((desc, i) => `[IMAGE ${i + 1}]: ${desc}`).join('\n'); } /** * Fetches the list of available models from the OpenRouter API. * Uses the stored API key, and updates the model selector upon success. */ function fetchAvailableModels() { const apiKey = GM_getValue('openrouter-api-key', ''); if (!apiKey) { console.log('No API key available, skipping model fetch'); showStatus('Please enter your OpenRouter API key'); return; } showStatus('Fetching available models...'); const sortOrder = GM_getValue('modelSortOrder', 'throughput-high-to-low'); GM_xmlhttpRequest({ method: "GET", url: `https://openrouter.ai/api/frontend/models/find?order=${sortOrder}`, headers: { "Authorization": `Bearer ${apiKey}`, "HTTP-Referer": "https://greasyfork.org/en/scripts/532182-twitter-x-ai-tweet-filter", // Use a more generic referer if preferred "X-Title": "Tweet Rating Tool" }, onload: function (response) { try { const data = JSON.parse(response.responseText); if (data.data && data.data.models) { availableModels = data.data.models || []; refreshModelsUI(); showStatus('Models updated!'); } } catch (error) { console.error('Error parsing model list:', error); showStatus('Error parsing models list'); } }, onerror: function (error) { console.error('Error fetching models:', error); showStatus('Error fetching models!'); } }); } // ----- domScraper.js ----- /** * Extracts and returns trimmed text content from the given element(s). * @param {Node|NodeList} elements - A DOM element or a NodeList. * @returns {string} The trimmed text content. */ function getElementText(elements) { if (!elements) return ''; const elementList = elements instanceof NodeList ? Array.from(elements) : [elements]; for (const element of elementList) { const text = element?.textContent?.trim(); if (text) return text; } return ''; } /** * Extracts the tweet ID from a tweet article element. * @param {Element} tweetArticle - The tweet article element. * @returns {string} The tweet ID. */ function getTweetID(tweetArticle) { const timeEl = tweetArticle.querySelector(PERMALINK_SELECTOR); let tweetId = timeEl?.parentElement?.href; if (tweetId && tweetId.includes('/status/')) { const match = tweetId.match(/\/status\/(\d+)/); if (match && match[1]) { return match[1]; } return tweetId.substring(tweetId.indexOf('/status/') + 1); } return `tweet-${Math.random().toString(36).substring(2, 15)}-${Date.now()}`; } /** * Extracts the Twitter handle from a tweet article element. * @param {Element} tweetArticle - The tweet article element. * @returns {array} The user and quoted user handles. */ function getUserHandles(tweetArticle) { const handleElement = tweetArticle.querySelectorAll(USER_HANDLE_SELECTOR); let handles=[]; if (handleElement) { /* const href = handleElement.getAttribute('href'); if (href && href.startsWith('/')) { return href.slice(1); } */ handleElement.forEach(element => { const href = element.getAttribute('href'); if (href && href.startsWith('/')) { handles.push(href.slice(1)); } }); } return handles.length>0?handles:['']; } /** * Extracts and returns an array of media URLs from the tweet element. * @param {Element} scopeElement - The tweet element. * @returns {string[]} An array of media URLs. */ function extractMediaLinks(scopeElement) { if (!scopeElement) return []; const mediaLinks = new Set(); // Find all images and videos in the tweet const imgSelector = `${MEDIA_IMG_SELECTOR}, [data-testid="tweetPhoto"] img, img[src*="pbs.twimg.com/media"]`; const videoSelector = `${MEDIA_VIDEO_SELECTOR}, video[poster*="pbs.twimg.com"], video`; // First try the standard selectors let mediaElements = scopeElement.querySelectorAll(`${imgSelector}, ${videoSelector}`); // If no media found and this is a quoted tweet, try more aggressive selectors if (mediaElements.length === 0 && scopeElement.matches(QUOTE_CONTAINER_SELECTOR)) { // Try to find any image within the quoted tweet mediaElements = scopeElement.querySelectorAll('img[src*="pbs.twimg.com"], video[poster*="pbs.twimg.com"]'); } mediaElements.forEach(mediaEl => { // Get the source URL (src for images, poster for videos) const sourceUrl = mediaEl.tagName === 'IMG' ? mediaEl.src : mediaEl.poster; // Skip if not a Twitter media URL or if undefined or if it's a profile image if (!sourceUrl || !(sourceUrl.includes('pbs.twimg.com/')) || sourceUrl.includes('profile_images')) { return; } try { // Parse the URL to handle format parameters const url = new URL(sourceUrl); const format = url.searchParams.get('format'); const name = url.searchParams.get('name'); // 'small', 'medium', 'large', etc. // Create the final URL with the right format and size let finalUrl = sourceUrl; // Try to get the original size by removing size indicator if (name && name !== 'orig') { // Replace format=jpg&name=small with format=jpg&name=orig finalUrl = sourceUrl.replace(`name=${name}`, 'name=orig'); } mediaLinks.add(finalUrl); } catch (error) { // Fallback: just add the raw URL as is mediaLinks.add(sourceUrl); } }); return Array.from(mediaLinks); } // ----- Rating Indicator Functions ----- /** * Processes a single tweet after a delay. * It first sets a pending indicator, then either applies a cached rating, * or calls the API to rate the tweet (with retry logic). * Finally, it applies the filtering logic. * @param {Element} tweetArticle - The tweet element. * @param {string} tweetId - The tweet ID. */ // Helper function to determine if a tweet is the original tweet in a conversation. // We check if the tweet article has a following sibling with data-testid="inline_reply_offscreen". function isOriginalTweet(tweetArticle) { let sibling = tweetArticle.nextElementSibling; while (sibling) { if (sibling.matches && sibling.matches('div[data-testid="inline_reply_offscreen"]')) { return true; } sibling = sibling.nextElementSibling; } return false; } // ----- MutationObserver Setup ----- /** * Handles DOM mutations to detect new tweets added to the timeline. * @param {MutationRecord[]} mutationsList - List of observed mutations. */ function handleMutations(mutationsList) { for (const mutation of mutationsList) { handleThreads(); if (mutation.type === 'childList') { // Process added nodes if (mutation.addedNodes.length > 0) { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { if (node.matches && node.matches(TWEET_ARTICLE_SELECTOR)) { scheduleTweetProcessing(node); } else if (node.querySelectorAll) { const tweetsInside = node.querySelectorAll(TWEET_ARTICLE_SELECTOR); tweetsInside.forEach(scheduleTweetProcessing); } } }); } // Process removed nodes to clean up description elements if (mutation.removedNodes.length > 0) { mutation.removedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { // Check if the removed node is a tweet article or contains tweet articles const isTweet = node.matches && node.matches(TWEET_ARTICLE_SELECTOR); const removedTweets = isTweet ? [node] : (node.querySelectorAll ? Array.from(node.querySelectorAll(TWEET_ARTICLE_SELECTOR)) : []); // For each removed tweet, find and remove its description element removedTweets.forEach(tweet => { const indicator = tweet.querySelector('.score-indicator'); if (indicator && indicator.dataset.id) { const descId = 'desc-' + indicator.dataset.id; const descBox = document.getElementById(descId); if (descBox) { descBox.remove(); //console.debug(`Removed description box ${descId} for tweet that was removed from the DOM`); } } }); } }); } } } } // ----- ratingEngine.js ----- /** * Applies filtering to a single tweet by hiding it if its score is below the threshold. * Also updates the rating indicator. * @param {Element} tweetArticle - The tweet element. */ function filterSingleTweet(tweetArticle) { const score = parseInt(tweetArticle.dataset.sloppinessScore || '1', 10); // Update the indicator based on the tweet's rating status setScoreIndicator(tweetArticle, score, tweetArticle.dataset.ratingStatus || 'rated', tweetArticle.dataset.ratingDescription); // If the tweet is still pending a rating, keep it visible const currentFilterThreshold=GM_getValue('filterThreshold', 1); if (tweetArticle.dataset.ratingStatus === 'pending') { tweetArticle.style.display = ''; } else if (isNaN(score) || score < currentFilterThreshold) { tweetArticle.style.display = 'none'; } else { tweetArticle.style.display = ''; } } /** * Applies a cached rating (if available) to a tweet article. * Also sets the rating status to 'rated' and updates the indicator. * @param {Element} tweetArticle - The tweet element. * @returns {boolean} True if a cached rating was applied. */ function applyTweetCachedRating(tweetArticle) { const tweetId = getTweetID(tweetArticle); const handles = getUserHandles(tweetArticle); const userHandle = handles.length > 0 ? handles[0] : ''; // Blacklisted users are automatically given a score of 10 if (userHandle && isUserBlacklisted(userHandle)) { //console.debug(`Blacklisted user detected: ${userHandle}, assigning score 10`); tweetArticle.dataset.sloppinessScore = '10'; tweetArticle.dataset.blacklisted = 'true'; tweetArticle.dataset.ratingStatus = 'blacklisted'; tweetArticle.dataset.ratingDescription = 'Whtielisted user'; setScoreIndicator(tweetArticle, 10, 'blacklisted', "User is whitelisted"); filterSingleTweet(tweetArticle); return true; } // Check ID-based cache if (tweetIDRatingCache[tweetId]) { const score = tweetIDRatingCache[tweetId].score; const desc = tweetIDRatingCache[tweetId].description; //console.debug(`Applied cached rating for tweet ${tweetId}: ${score}`); tweetArticle.dataset.sloppinessScore = score.toString(); tweetArticle.dataset.cachedRating = 'true'; tweetArticle.dataset.ratingStatus = 'cached'; tweetArticle.dataset.ratingDescription = desc; setScoreIndicator(tweetArticle, score, 'cached', desc); filterSingleTweet(tweetArticle); return true; } return false; } // ----- UI Helper Functions ----- /** * Saves the tweet ratings (by tweet ID) to persistent storage. */ function saveTweetRatings() { GM_setValue('tweetRatings', JSON.stringify(tweetIDRatingCache)); //console.log(`Saved ${Object.keys(tweetIDRatingCache).length} tweet ratings to storage`); } /** * Checks if a given user handle is in the blacklist. * @param {string} handle - The Twitter handle. * @returns {boolean} True if blacklisted, false otherwise. */ function isUserBlacklisted(handle) { if (!handle) return false; handle = handle.toLowerCase().trim(); return blacklistedHandles.some(h => h.toLowerCase().trim() === handle); } async function delayedProcessTweet(tweetArticle, tweetId) { const apiKey = GM_getValue('openrouter-api-key', ''); if (!apiKey) { tweetArticle.dataset.ratingStatus = 'error'; tweetArticle.dataset.ratingDescription = "No API key"; try { setScoreIndicator(tweetArticle, 5, 'error', "No API key"); // Verify indicator was actually created if (!tweetArticle.querySelector('.score-indicator')) { console.error(`Failed to create score indicator for tweet ${tweetId}`); } } catch (e) { console.error(`Error setting score indicator for tweet ${tweetId}:`, e); } filterSingleTweet(tweetArticle); // Remove from processedTweets to allow retrying processedTweets.delete(tweetId); console.error(`Failed to process tweet ${tweetId}: No API key`); return; } let score = 5; // Default score if rating fails let description = ""; let processingSuccessful = false; try { // Get user handle const handles = getUserHandles(tweetArticle); const userHandle = handles.length > 0 ? handles[0] : ''; // Check if tweet's author is blacklisted (fast path) if (userHandle && isUserBlacklisted(userHandle)) { tweetArticle.dataset.sloppinessScore = '10'; tweetArticle.dataset.blacklisted = 'true'; tweetArticle.dataset.ratingStatus = 'blacklisted'; tweetArticle.dataset.ratingDescription = "Blacklisted user"; try { setScoreIndicator(tweetArticle, 10, 'blacklisted', "User is blacklisted"); // Verify indicator was actually created if (!tweetArticle.querySelector('.score-indicator')) { throw new Error("Failed to create score indicator"); } } catch (e) { console.error(`Error setting blacklist indicator for tweet ${tweetId}:`, e); // Even if indicator fails, we've set the dataset properties } filterSingleTweet(tweetArticle); processingSuccessful = true; return; } // Check if a cached rating exists if (applyTweetCachedRating(tweetArticle)) { // Verify the indicator exists after applying cached rating if (!tweetArticle.querySelector('.score-indicator')) { console.error(`Missing indicator after applying cached rating to tweet ${tweetId}`); processingSuccessful = false; } else { processingSuccessful = true; } return; } const fullContextWithImageDescription = await getFullContext(tweetArticle, tweetId, apiKey); if (!fullContextWithImageDescription) { throw new Error("Failed to get tweet context"); } //Get the media URLS from the entire fullContextWithImageDescription, and pass that to the rating engine //This allows us to get the media links from the thread history as well const mediaURLs = []; // Extract regular media URLs const mediaMatches = fullContextWithImageDescription.match(/\[MEDIA_URLS\]:\s*\n(.*?)(?:\n|$)/); if (mediaMatches && mediaMatches[1]) { mediaURLs.push(...mediaMatches[1].split(', ')); } // Extract quoted tweet media URLs const quotedMediaMatches = fullContextWithImageDescription.match(/\[QUOTED_TWEET_MEDIA_URLS\]:\s*\n(.*?)(?:\n|$)/); if (quotedMediaMatches && quotedMediaMatches[1]) { mediaURLs.push(...quotedMediaMatches[1].split(', ')); } // --- API Call or Fallback --- if (apiKey && fullContextWithImageDescription) { try { const rating = await rateTweetWithOpenRouter(fullContextWithImageDescription, tweetId, apiKey, mediaURLs); score = rating.score; description = rating.content; tweetArticle.dataset.ratingStatus = rating.error ? 'error' : 'rated'; tweetArticle.dataset.ratingDescription = description || "not available"; tweetArticle.dataset.sloppinessScore = score.toString(); try { setScoreIndicator(tweetArticle, score, tweetArticle.dataset.ratingStatus, tweetArticle.dataset.ratingDescription); // Verify the indicator exists if (!tweetArticle.querySelector('.score-indicator')) { throw new Error("Failed to create score indicator"); } } catch (e) { console.error(`Error setting rated indicator for tweet ${tweetId}:`, e); // Continue even if indicator fails - we've set the dataset properties } filterSingleTweet(tweetArticle); processingSuccessful = !rating.error; // Store the full context after rating is complete if (tweetIDRatingCache[tweetId]) { tweetIDRatingCache[tweetId].score = score; tweetIDRatingCache[tweetId].description = description; tweetIDRatingCache[tweetId].tweetContent = fullContextWithImageDescription; } else { tweetIDRatingCache[tweetId] = { score: score, description: description, tweetContent: fullContextWithImageDescription }; } // Save ratings to persistent storage saveTweetRatings(); } catch (apiError) { console.error(`API error for tweet ${tweetId}: ${apiError}`); score = 10; // Fallback to a random score tweetArticle.dataset.ratingStatus = 'error'; tweetArticle.dataset.ratingDescription = "API error"; // Don't consider API errors as successful processing processingSuccessful = false; } } else if (fullContextWithImageDescription) { score = 10; //show all tweets that errored tweetArticle.dataset.ratingStatus = 'error'; tweetArticle.dataset.ratingDescription = "No API key"; processingSuccessful = true; }else{ //show all tweets that errored score=10; tweetArticle.dataset.ratingStatus = 'error'; tweetArticle.dataset.ratingDescription = "No content"; processingSuccessful = true; } tweetArticle.dataset.sloppinessScore = score.toString(); try { console.log(`Tweet ${tweetId} ${fullContextWithImageDescription} Status: ${tweetArticle.dataset.ratingStatus} Score: ${score} Model: ${GM_getValue('selectedModel', '')} ${GM_getValue('enableImageDescriptions', false) ? `Image Model: ${GM_getValue('selectedImageModel', '')}` : ""} Description: ${description} `) setScoreIndicator(tweetArticle, score, tweetArticle.dataset.ratingStatus, tweetArticle.dataset.ratingDescription || ""); // Final verification of indicator if (!tweetArticle.querySelector('.score-indicator')) { console.error(`Final indicator check failed for tweet ${tweetId}`); processingSuccessful = false; } } catch (e) { console.error(`Final error setting indicator for tweet ${tweetId}:`, e); processingSuccessful = false; } filterSingleTweet(tweetArticle); // Log processed status //console.log(`Tweet ${tweetId} processed: score=${score}, status=${tweetArticle.dataset.ratingStatus}`); } catch (error) { console.error(`Error processing tweet ${tweetId}: ${error}`); if (!tweetArticle.dataset.sloppinessScore) { tweetArticle.dataset.sloppinessScore = '5'; tweetArticle.dataset.ratingStatus = 'error'; tweetArticle.dataset.ratingDescription = "error processing tweet"; try { setScoreIndicator(tweetArticle, 5, 'error', 'Error processing tweet'); // Verify indicator exists if (!tweetArticle.querySelector('.score-indicator')) { console.error(`Failed to create error indicator for tweet ${tweetId}`); } } catch (e) { console.error(`Error setting error indicator for tweet ${tweetId}:`, e); } filterSingleTweet(tweetArticle); } processingSuccessful = false; } finally { // If processing was not successful, remove from processedTweets // to allow future retry attempts if (!processingSuccessful) { console.warn(`Removing tweet ${tweetId} from processedTweets to allow retry`); processedTweets.delete(tweetId); } } } /** * Schedules processing of a tweet if it hasn't been processed yet. * @param {Element} tweetArticle - The tweet element. */ function scheduleTweetProcessing(tweetArticle) { // First, ensure the tweet has a valid ID const tweetId = getTweetID(tweetArticle); if (!tweetId) { console.error("Cannot schedule tweet without valid ID", tweetArticle); return; } // Fast-path: if author is blacklisted, assign score immediately const handles = getUserHandles(tweetArticle); const userHandle = handles.length > 0 ? handles[0] : ''; if (userHandle && isUserBlacklisted(userHandle)) { tweetArticle.dataset.sloppinessScore = '10'; tweetArticle.dataset.blacklisted = 'true'; tweetArticle.dataset.ratingStatus = 'rated'; tweetArticle.dataset.ratingDescription = "Whitelisted user"; setScoreIndicator(tweetArticle, 10, 'blacklisted', "User is whitelisted"); filterSingleTweet(tweetArticle); return; } // If a cached rating is available, use it immediately if (tweetIDRatingCache[tweetId]) { applyTweetCachedRating(tweetArticle); return; } // Skip if already processed in this session if (processedTweets.has(tweetId)) { // Verify that the tweet actually has an indicator - if not, remove from processed const hasIndicator = !!tweetArticle.querySelector('.score-indicator'); if (!hasIndicator) { console.warn(`Tweet ${tweetId} was marked as processed but has no indicator, reprocessing`); processedTweets.delete(tweetId); } else { return; } } // Immediately mark as pending before scheduling actual processing processedTweets.add(tweetId); tweetArticle.dataset.ratingStatus = 'pending'; // Ensure indicator is set try { setScoreIndicator(tweetArticle, null, 'pending'); } catch (e) { console.error(`Failed to set indicator for tweet ${tweetId}:`, e); } // Now schedule the actual rating processing setTimeout(() => { try { delayedProcessTweet(tweetArticle, tweetId); } catch (e) { console.error(`Error in delayed processing of tweet ${tweetId}:`, e); processedTweets.delete(tweetId); } }, PROCESSING_DELAY_MS); } /** * Extracts the full context of a tweet article and returns a formatted string. * * Schema: * [TWEET]: * @[the author of the tweet] * [the text of the tweet] * [MEDIA_DESCRIPTION]: * [IMAGE 1]: [description], [IMAGE 2]: [description], etc. * [QUOTED_TWEET]: * [the text of the quoted tweet] * [QUOTED_TWEET_MEDIA_DESCRIPTION]: * [IMAGE 1]: [description], [IMAGE 2]: [description], etc. * * @param {Element} tweetArticle - The tweet article element. * @param {string} tweetId - The tweet's ID. * @param {string} apiKey - API key used for getting image descriptions. * @returns {Promise} - The full context string. */ async function getFullContext(tweetArticle, tweetId, apiKey) { const handles = getUserHandles(tweetArticle); const userHandle = handles.length > 0 ? handles[0] : ''; const quotedHandle = handles.length > 1 ? handles[1] : ''; // --- Extract Main Tweet Content --- const mainText = getElementText(tweetArticle.querySelector(TWEET_TEXT_SELECTOR)); // Allow a small delay for images to load await new Promise(resolve => setTimeout(resolve, 300)); let allMediaLinks = extractMediaLinks(tweetArticle); // --- Extract Quoted Tweet Content (if any) --- let quotedText = ""; let quotedMediaLinks = []; const quoteContainer = tweetArticle.querySelector(QUOTE_CONTAINER_SELECTOR); if (quoteContainer) { quotedText = getElementText(quoteContainer.querySelector(TWEET_TEXT_SELECTOR)) || ""; // Short delay to ensure quoted tweet images are loaded await new Promise(resolve => setTimeout(resolve, 300)); quotedMediaLinks = extractMediaLinks(quoteContainer); console.log(`Quoted media links for tweet ${tweetId}:`, quotedMediaLinks); } // Remove any media links from the main tweet that also appear in the quoted tweet let mainMediaLinks = allMediaLinks.filter(link => !quotedMediaLinks.includes(link)); let fullContextWithImageDescription = `[TWEET ${tweetId}] Author:@${userHandle}: ` + mainText; if (mainMediaLinks.length > 0) { // Process main tweet images only if image descriptions are enabled if (enableImageDescriptions=GM_getValue('enableImageDescriptions', false)) { let mainMediaLinksDescription = await getImageDescription(mainMediaLinks, apiKey, tweetId, userHandle); fullContextWithImageDescription += ` [MEDIA_DESCRIPTION]: ${mainMediaLinksDescription}`; } // Just add the URLs when descriptions are disabled fullContextWithImageDescription += ` [MEDIA_URLS]: ${mainMediaLinks.join(", ")}`; } // --- Quoted Tweet Handling --- if (quotedText||quotedMediaLinks.length > 0) { fullContextWithImageDescription += ` [QUOTED_TWEET]: Author:@${quotedHandle}: ${quotedText}`; if (quotedMediaLinks.length > 0) { // Process quoted tweet images only if image descriptions are enabled if (enableImageDescriptions) { let quotedMediaLinksDescription = await getImageDescription(quotedMediaLinks, apiKey, tweetId, userHandle); fullContextWithImageDescription += ` [QUOTED_TWEET_MEDIA_DESCRIPTION]: ${quotedMediaLinksDescription}`; } // Just add the URLs when descriptions are disabled fullContextWithImageDescription += ` [QUOTED_TWEET_MEDIA_URLS]: ${quotedMediaLinks.join(", ")}`; } } tweetArticle.dataset.fullContext = fullContextWithImageDescription; // --- Conversation Thread Handling --- const conversation = document.querySelector('div[aria-label="Timeline: Conversation"]'); if (conversation && conversation.dataset.threadHist) { // If this tweet is not the original tweet, prepend the thread history. if (!isOriginalTweet(tweetArticle)) { fullContextWithImageDescription = conversation.dataset.threadHist + ` [REPLY] ` + fullContextWithImageDescription; } } tweetArticle.dataset.fullContext = fullContextWithImageDescription; return fullContextWithImageDescription; } /** * Applies filtering to all tweets currently in the observed container. */ function applyFilteringToAll() { if (!observedTargetNode) return; const tweets = observedTargetNode.querySelectorAll(TWEET_ARTICLE_SELECTOR); tweets.forEach(filterSingleTweet); } function ensureAllTweetsRated() { if (!observedTargetNode) return; const tweets = observedTargetNode.querySelectorAll(TWEET_ARTICLE_SELECTOR); if (tweets.length > 0) { console.log(`Checking ${tweets.length} tweets to ensure all are rated...`); let unreatedCount = 0; tweets.forEach(tweet => { const tweetId = getTweetID(tweet); if (!tweetId) return; // Skip tweets without a valid ID // Check for any issues that would require processing: // 1. No score data attribute // 2. Error status // 3. Missing indicator element (even if in processedTweets) const hasScore = !!tweet.dataset.sloppinessScore; const hasError = tweet.dataset.ratingStatus === 'error'; const hasIndicator = !!tweet.querySelector('.score-indicator'); // If tweet is in processedTweets but missing indicator, remove it from processed if (processedTweets.has(tweetId) && !hasIndicator) { console.warn(`Tweet ${tweetId} in processedTweets but missing indicator, removing`); processedTweets.delete(tweetId); } // Schedule processing if needed and not already in progress const needsProcessing = !hasScore || hasError || !hasIndicator; if (needsProcessing && !processedTweets.has(tweetId)) { unreatedCount++; const status = !hasIndicator ? 'missing indicator' : !hasScore ? 'unrated' : hasError ? 'error' : 'unknown issue'; //console.log(`Found tweet ${tweetId} with ${status}, scheduling processing`); scheduleTweetProcessing(tweet); } }); if (unreatedCount > 0) { //console.log(`Scheduled ${unreatedCount} tweets for processing`); } } } async function handleThreads() { let conversation = document.querySelector('div[aria-label="Timeline: Conversation"]'); if (conversation) { if (conversation.dataset.threadHist == undefined) { threadHist = ""; const firstArticle = document.querySelector('article[data-testid="tweet"]'); if (firstArticle) { conversation.dataset.threadHist = 'pending'; const tweetId = getTweetID(firstArticle); const apiKey = GM_getValue('openrouter-api-key', ''); const fullcxt = await getFullContext(firstArticle, tweetId, apiKey); threadHist = fullcxt; conversation.dataset.threadHist = threadHist; //this lets us know if we are still on the main post of the conversation or if we are on a reply to the main post. Will disapear every time we dive deeper conversation.firstChild.dataset.canary = "true"; // Schedule processing for the original tweet if (!processedTweets.has(tweetId)) { scheduleTweetProcessing(firstArticle); } } } else if (conversation.dataset.threadHist == "pending") { return; } else if (conversation.dataset.threadHist != "pending" && conversation.firstChild.dataset.canary == undefined) { conversation.firstChild.dataset.canary = "pending"; const nextArticle = document.querySelector('article[data-testid="tweet"]:has(~ div[data-testid="inline_reply_offscreen"])'); if (nextArticle) { const tweetId = getTweetID(nextArticle); if (tweetIDRatingCache[tweetId] && tweetIDRatingCache[tweetId].tweetContent) { threadHist = threadHist + "\n[REPLY]\n" + tweetIDRatingCache[tweetId].tweetContent; } else { const apiKey = GM_getValue('openrouter-api-key', ''); await new Promise(resolve => setTimeout(resolve, 500)); const newContext = await getFullContext(nextArticle, tweetId, apiKey); threadHist = threadHist + "\n[REPLY]\n" + newContext; } conversation.dataset.threadHist = threadHist; } } } } // ----- ui.js ----- // --- Constants --- const VERSION = '1.3.1'; // Update version here // --- Utility Functions --- /** * Displays a temporary status message on the screen. * @param {string} message - The message to display. */ function showStatus(message) { const indicator = document.getElementById('status-indicator'); if (!indicator) { console.error('#status-indicator element not found.'); return; } indicator.textContent = message; indicator.classList.add('active'); setTimeout(() => { indicator.classList.remove('active'); }, 3000); } /** * Toggles the visibility of an element and updates the corresponding toggle button text. * @param {HTMLElement} element - The element to toggle. * @param {HTMLElement} toggleButton - The button that controls the toggle. * @param {string} openText - Text for the button when the element is open. * @param {string} closedText - Text for the button when the element is closed. */ function toggleElementVisibility(element, toggleButton, openText, closedText) { if (!element || !toggleButton) return; const isHidden = element.classList.toggle('hidden'); toggleButton.innerHTML = isHidden ? closedText : openText; // Special case for filter slider button (hide it when panel is shown) if (element.id === 'tweet-filter-container') { const filterToggle = document.getElementById('filter-toggle'); if (filterToggle) { filterToggle.style.display = isHidden ? 'block' : 'none'; } } } // --- Core UI Logic --- /** * Injects the UI elements from the HTML resource into the page. */ function injectUI() { //combined userscript has a const named MENU. If it exists, use it. let menuHTML; if(MENU){ menuHTML = MENU; }else{ menuHTML = GM_getValue('menuHTML'); } if (!menuHTML) { console.error('Failed to load Menu.html resource!'); showStatus('Error: Could not load UI components.'); return null; } // Create a container to inject HTML const containerId = 'tweetfilter-root-container'; // Use the ID from the updated HTML let uiContainer = document.getElementById(containerId); if (uiContainer) { console.warn('UI container already exists. Skipping injection.'); return uiContainer; // Return existing container } uiContainer = document.createElement('div'); uiContainer.id = containerId; uiContainer.innerHTML = menuHTML; // Inject styles const stylesheet = uiContainer.querySelector('style'); if (stylesheet) { GM_addStyle(stylesheet.textContent); console.log('Injected styles from Menu.html'); stylesheet.remove(); // Remove style tag after injecting } else { console.warn('No