// ==UserScript==
// @name TweetFilter AI
// @namespace http://tampermonkey.net/
// @version Version 1.3
// @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 = `
`;
// 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 - 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', false);
}
// If no API key is found, prompt the user
const apiKey = GM_getValue('openrouter-api-key', '');
if (!apiKey) {
apiKey = prompt("\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 });
// Periodically ensure all tweets have been processed
setInterval(ensureAllTweetsRated, 3000);
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', '{}');
// Settings variables
let enableImageDescriptions = GM_getValue('enableImageDescriptions', false);
// Model parameters
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/532182-twitter-x-ai-tweet-filter",
"X-Title": "Tweet Rating Tool"
},
data: JSON.stringify(request),
timeout: timeout,
onload: function (response) {
if (response.status >= 200 && response.status < 300) {
try {
const data = JSON.parse(response.responseText);
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: "user",
content: [{
type: "text",
text: `
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 an expert critic of tweets. You are to review and provide a rating for the tweet with tweet ID ${tweetId}.
Ensure that you consider these user-defined instructions in your analysis and scoring:
[USER-DEFINED INSTRUCTIONS]:
${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 1 (lowest quality) to 10 (highest quality).
for example: 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.
_______BEGIN TWEET_______
${tweetText}
_______END TWEET_______`
}]
}],
temperature: modelTemperature,
top_p: modelTopP,
max_tokens: maxTokens,
provider: {
sort: GM_getValue('modelSortOrder', 'throughput-high-to-low').split('-')[0],
allow_fallbacks: true
}
};
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[0].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.
* @param {string} tweetIdForDebug - The tweet ID (for logging).
* @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
scopeElement.querySelectorAll(`${MEDIA_IMG_SELECTOR}, ${MEDIA_VIDEO_SELECTOR}`).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
/*
if (!sourceUrl ||
!(sourceUrl.includes('pbs.twimg.com/') ||
sourceUrl.includes('pbs.twimg.com/amplify_video_thumb'))) {
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');
}
// Log both the original and final URLs for debugging
//console.log(`[Tweet ${tweetIdForDebug}] Processing media: ${sourceUrl} → ${finalUrl}`);
mediaLinks.add(finalUrl);
} catch (error) {
//console.error(`[Tweet ${tweetIdForDebug}] Error processing media URL: ${sourceUrl}`, 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 || '0', 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
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";
setScoreIndicator(tweetArticle, 5, 'error', "No API key");
filterSingleTweet(tweetArticle);
return;
}
let score = 5; // Default score if rating fails
let description = "";
try {
// Get user handle
const handles = getUserHandles(tweetArticle);
const userHandle = handles.length > 0 ? handles[0] : '';
const quotedHandle = handles.length > 1 ? handles[1] : '';
const allMediaLinks = extractMediaLinks(tweetArticle);
// 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";
setScoreIndicator(tweetArticle, 10, 'blacklisted', "User is blacklisted");
filterSingleTweet(tweetArticle);
return;
}
// Check if a cached rating exists
if (applyTweetCachedRating(tweetArticle)) {
return;
}
const fullContextWithImageDescription = await getFullContext(tweetArticle, tweetId, apiKey);
// --- API Call or Fallback ---
if (apiKey && fullContextWithImageDescription) {
try {
const rating = await rateTweetWithOpenRouter(fullContextWithImageDescription, tweetId, apiKey, allMediaLinks);
score = rating.score;
description = rating.content;
tweetArticle.dataset.ratingStatus = rating.error ? 'error' : 'rated';
tweetArticle.dataset.ratingDescription = description || "not available";
tweetArticle.dataset.sloppinessScore = score.toString();
setScoreIndicator(tweetArticle, score, tweetArticle.dataset.ratingStatus, tweetArticle.dataset.ratingDescription);
filterSingleTweet(tweetArticle);
} catch (apiError) {
score = Math.floor(Math.random() * 10) + 1; // Fallback to a random score
tweetArticle.dataset.ratingStatus = 'error';
}
} else {
// If there's no API key or textual content (e.g., only media), use a fallback random score.
score = Math.floor(Math.random() * 10) + 1;
tweetArticle.dataset.ratingStatus = 'rated';
}
tweetArticle.dataset.sloppinessScore = score.toString();
filterSingleTweet(tweetArticle);
// Log all collected information at once
console.log(`Tweet ${tweetId}:
${fullContextWithImageDescription} - ${score} Model response: - ${description}`);
} catch (error) {
if (!tweetArticle.dataset.sloppinessScore) {
tweetArticle.dataset.sloppinessScore = '5';
tweetArticle.dataset.ratingStatus = 'error';
tweetArticle.dataset.ratingDescription = "error processing tweet";
console.error(`Error processing tweet ${tweetId}: ${error}`);
setScoreIndicator(tweetArticle, 5, 'error', 'Error processing tweet');
filterSingleTweet(tweetArticle);
}
}
}
/**
* Schedules processing of a tweet if it hasn't been processed yet.
* @param {Element} tweetArticle - The tweet element.
*/
function scheduleTweetProcessing(tweetArticle) {
const tweetId = getTweetID(tweetArticle);
// 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)) {
return;
}
// Immediately mark as pending before scheduling actual processing
processedTweets.add(tweetId);
tweetArticle.dataset.ratingStatus = 'pending';
setScoreIndicator(tweetArticle, null, 'pending');
// Now schedule the actual rating processing
setTimeout(() => { delayedProcessTweet(tweetArticle, 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));
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));
quotedMediaLinks = extractMediaLinks(quoteContainer);
}
// 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) {
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) {
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}`;
} else {
// Just add the URLs when descriptions are disabled
fullContextWithImageDescription += `
[QUOTED_TWEET_MEDIA_URLS]:
${quotedMediaLinks.join(", ")}`;
}
}
}
// --- 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);
}
/**
* Periodically checks and processes tweets that might have been added without triggering mutations.
*/
function ensureAllTweetsRated() {
if (!observedTargetNode) return;
const tweets = observedTargetNode.querySelectorAll(TWEET_ARTICLE_SELECTOR);
tweets.forEach(tweet => {
if (!tweet.dataset.sloppinessScore) {
scheduleTweetProcessing(tweet);
}
});
}
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);
if (tweetIDRatingCache[tweetId]) {
threadHist = tweetIDRatingCache[tweetId].tweetContent;
} else {
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";
}
}
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"])');
const tweetId = getTweetID(nextArticle);
if (tweetIDRatingCache[tweetId]) {
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'; // 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