// ==UserScript==
// @name Axiom推文翻译
// @namespace http://tampermonkey.net/
// @version 4.6
// @author @Gufii_666
// @description 对axiom的推文监控,代币内推文,扫链出现的推文进行翻译
// @match https://axiom.trade/pulse*
// @match https://axiom.trade/trackers*
// @match https://axiom.trade/meme/*
// @match https://axiom.trade/discover*
// @license MIT
// @grant none
// ==/UserScript==
(function() {
'use strict';
const TRANSLATION_BASE_URL = 'https://translate.googleapis.com/translate_a/single';
const CLIENT_PARAM = 'gtx';
const SOURCE_LANG = 'auto';
const TARGET_LANG = 'zh-CN';
const DATA_TYPE = 't';
const TRANSLATED_TEXT_CLASS = 'localized-content-display';
const ORIGINAL_DATA_ATTR = 'data-translation-processed-status';
const ORIGINAL_TEXT_STORE_ATTR = 'data-original-text';
const UNIQUE_TRANSLATION_ID_ATTR = 'data-translation-id';
const ONGOING_TRANSLATIONS = new Map();
// Removed LAZY_SCAN_INTERVAL_MS and lazyScanTimer
async function obtainLocalizedText(inputString) {
if (!inputString || typeof inputString !== 'string') {
return '[Translation Input Error]';
}
const queryParams = new URLSearchParams({
client: CLIENT_PARAM,
sl: SOURCE_LANG,
tl: TARGET_LANG,
dt: DATA_TYPE,
q: inputString
});
const fullUrl = `${TRANSLATION_BASE_URL}?${queryParams.toString()}`;
try {
const response = await fetch(fullUrl);
const data = await response.json();
if (data && data[0] && Array.isArray(data[0])) {
return data[0].map(segment => segment[0]).join('');
}
throw new Error("Invalid translation response structure.");
} catch (error) {
console.error("Content localization failed:", error);
return '[翻译失败]';
}
}
// Function to apply layout fixes to a specific element
function applyLayoutFixes(element) {
if (!element) return;
const currentDisplay = getComputedStyle(element).display;
if (currentDisplay.includes('inline') && !currentDisplay.includes('flex') && !currentDisplay.includes('grid')) {
element.style.display = 'block';
}
Object.assign(element.style, {
maxHeight: "none",
height: "auto",
overflow: "visible",
overflowY: "visible",
overflowX: "visible",
minHeight: "unset",
flexShrink: "0",
alignSelf: "stretch"
});
const gradient = element.querySelector("div[class*='bg-gradient-to-b']");
if (gradient) {
gradient.style.display = "none";
}
}
function updateTranslationBox(targetElement, statusOrContent, prepend = false) {
if (!targetElement || !targetElement.parentElement) return;
if (!targetElement.dataset.translationId) {
targetElement.dataset.translationId = Math.random().toString(36).substring(2, 15);
}
const translationId = targetElement.dataset.translationId;
let translationParagraph = targetElement.parentElement.querySelector(`p.${TRANSLATED_TEXT_CLASS}[${UNIQUE_TRANSLATION_ID_ATTR}="${translationId}"]`);
if (!translationParagraph) {
translationParagraph = document.createElement("p");
translationParagraph.classList.add(TRANSLATED_TEXT_CLASS);
translationParagraph.setAttribute(UNIQUE_TRANSLATION_ID_ATTR, translationId);
if (prepend) {
targetElement.parentElement.insertBefore(translationParagraph, targetElement);
} else {
targetElement.parentElement.appendChild(translationParagraph);
}
}
translationParagraph.textContent = statusOrContent;
Object.assign(translationParagraph.style, {
color: "#FFFFFF",
fontSize: "14px",
padding: "8px 12px",
borderRadius: "6px",
margin: "8px 0",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.3)",
lineHeight: "1.5",
textShadow: "1px 1px 2px rgba(0,0,0,0.2)",
backgroundColor: "",
border: "",
fontWeight: "",
cursor: "",
opacity: ""
});
if (statusOrContent === '[翻译中...]') {
Object.assign(translationParagraph.style, {
backgroundColor: "#4A90E2",
border: "1px solid #337AB7",
fontWeight: "normal",
cursor: "wait",
opacity: "0.8"
});
translationParagraph.title = "翻译中,请稍候...";
translationParagraph.onclick = null;
} else if (statusOrContent === '[翻译失败]') {
Object.assign(translationParagraph.style, {
backgroundColor: "#DC3545",
border: "1px solid #DC3545",
fontWeight: "normal",
cursor: "pointer",
opacity: "1"
});
translationParagraph.title = "点击重试翻译";
translationParagraph.onclick = (event) => { // Keep the event parameter
// Prevent event from bubbling up to parent elements and causing navigation
event.stopPropagation();
event.preventDefault(); // Also prevent default action if any
// Clear related attributes and remove from ongoing translations to force re-processing
targetElement.removeAttribute(ORIGINAL_DATA_ATTR);
targetElement.removeAttribute(ORIGINAL_TEXT_STORE_ATTR);
targetElement.removeAttribute(UNIQUE_TRANSLATION_ID_ATTR); // Clear translation ID on retry
ONGOING_TRANSLATIONS.delete(targetElement);
translationParagraph.remove(); // Remove the error box
processElementForTranslation(targetElement); // Re-process the element
};
} else { // Successful translation
Object.assign(translationParagraph.style, {
backgroundColor: "#2E8B57",
border: "1px solid #4CAF50",
fontWeight: "bold",
cursor: "default",
opacity: "1"
});
translationParagraph.title = "";
translationParagraph.onclick = null; // No click action for successful translation
}
// --- Layout Adjustment Logic ---
// Apply fixes to the immediate parent of the targetElement
applyLayoutFixes(targetElement.parentElement);
// Also apply to relevant ancestors (up to 5 levels)
let currentParent = targetElement.parentElement;
let depth = 0;
while (currentParent && depth < 5) {
if (currentParent.matches("div.hover\\:bg-primaryStroke\\/20") ||
currentParent.matches("article.tweet-container_article__0ERPK") ||
currentParent.matches("div.mt-2.border.border-secondaryStroke.rounded-\\[4px\\].relative.group.overflow-hidden") ||
currentParent.matches("div.flex-1.min-w-0")
) {
applyLayoutFixes(currentParent);
}
currentParent = currentParent.parentElement;
depth++;
}
}
/**
* Centralized function to determine element type and initiate translation.
* This makes sure all entry points (initial scan, observer, retry)
* funnel through one place for consistent logic.
*/
async function processElementForTranslation(el) {
if (!el || ONGOING_TRANSLATIONS.has(el)) {
return;
}
let prepend = true; // Default for tweets
if (el.matches("p.break-words") && el.tagName === 'P') {
prepend = false; // Append for bios
}
initiateTextTranslation(el, prepend);
}
/**
* Unified function to initiate translation for a text element.
* This function now handles content change detection more robustly.
*/
async function initiateTextTranslation(textElement, prepend) {
const rawContent = textElement.innerText.trim();
if (!rawContent) {
// Mark empty elements as processed to avoid re-checking them
textElement.setAttribute(ORIGINAL_DATA_ATTR, 'true');
return;
}
const storedOriginalText = textElement.getAttribute(ORIGINAL_TEXT_STORE_ATTR);
const isProcessed = textElement.getAttribute(ORIGINAL_DATA_ATTR) === 'true';
const hasTranslationBox = textElement.parentElement ? textElement.parentElement.querySelector(`p.${TRANSLATED_TEXT_CLASS}[${UNIQUE_TRANSLATION_ID_ATTR}="${textElement.dataset.translationId}"]`) : null;
// --- IMPORTANT LOGIC FOR CONTENT CHANGE DETECTION AND RE-PROCESSING ---
// Determine if we need to force a re-translation.
let forceRetranslate = false;
if (isProcessed) {
// Case 1: Content has genuinely changed for an already processed element.
if (rawContent !== storedOriginalText) {
console.log('Content changed for element, forcing re-processing:', rawContent, 'vs', storedOriginalText);
forceRetranslate = true;
}
// Case 2: Previous translation failed, allow retry (click handler will trigger this path).
else if (hasTranslationBox && hasTranslationBox.textContent.includes('[翻译失败]')) {
console.log('Previous translation failed, forcing re-processing for retry:', rawContent);
forceRetranslate = true;
}
// Case 3: Already processed successfully and content is same. Do not re-process.
else {
return;
}
}
// If not already processed (isProcessed is false), then we should always process.
// If forceRetranslate is true, or it's a truly new element (isProcessed is false),
// then we need to reset its state for a fresh translation attempt.
if (forceRetranslate || !isProcessed) {
// Remove old translation box if it exists
if (hasTranslationBox) {
hasTranslationBox.remove();
}
// Clear all tracking attributes to treat as a brand new element
textElement.removeAttribute(ORIGINAL_DATA_ATTR);
textElement.removeAttribute(ORIGINAL_TEXT_STORE_ATTR);
textElement.removeAttribute(UNIQUE_TRANSLATION_ID_ATTR); // Crucial for new translation box
ONGOING_TRANSLATIONS.delete(textElement); // Remove from cache
}
// --- END NEW LOGIC ---
// At this point, we are confident we need to initiate a new translation process for this element.
textElement.setAttribute(ORIGINAL_TEXT_STORE_ATTR, rawContent); // Store current content as original
// Immediately show "Translating..." status before API call
updateTranslationBox(textElement, '[翻译中...]', prepend);
// Store the translation promise to prevent multiple simultaneous requests
const translationPromise = obtainLocalizedText(rawContent).then(localizedContent => {
updateTranslationBox(textElement, localizedContent, prepend);
textElement.setAttribute(ORIGINAL_DATA_ATTR, 'true'); // Mark as processed after API call result
}).catch(() => {
updateTranslationBox(textElement, '[翻译失败]', prepend);
textElement.setAttribute(ORIGINAL_DATA_ATTR, 'true'); // Mark as processed even if failed
}).finally(() => {
ONGOING_TRANSLATIONS.delete(textElement); // Remove from cache when done
});
ONGOING_TRANSLATIONS.set(textElement, translationPromise);
}
function performFullPageScan() {
// Clear ALL previous translation boxes.
document.querySelectorAll(`.${TRANSLATED_TEXT_CLASS}`).forEach(el => el.remove());
// Process all known translatable text elements.
// Their internal content change detection will handle if they need actual re-translation.
document.querySelectorAll(
"p.tweet-body_root__ChzUj," +
"p.text-textSecondary.mt-1.whitespace-pre-wrap," +
"div.mt-2.border.border-secondaryStroke.rounded-\\[4px\\].relative.group.overflow-hidden p.text-textSecondary.mt-1," +
"p.break-words"
).forEach(processElementForTranslation);
}
let lastPathname = window.location.pathname;
let scanTimeoutId = null;
const contentWatcher = new MutationObserver((mutations) => {
// Primary trigger for full page scan on URL change (SPA navigation)
if (window.location.pathname !== lastPathname) {
lastPathname = window.location.pathname;
clearTimeout(scanTimeoutId); // Clear any pending scan from previous URL
scanTimeoutId = setTimeout(performFullPageScan, 500); // Debounce full scan
return; // Don't process individual mutations for a changing page
}
// Process individual mutations for content added or attributes changed on the *same* page
for (const mutationRecord of mutations) {
// Check for added nodes (new elements appearing)
if (mutationRecord.type === 'childList' && mutationRecord.addedNodes.length > 0) {
for (const addedNode of mutationRecord.addedNodes) {
// Only query if it's an element node and has querySelector (e.g., skip text nodes)
if (addedNode.nodeType !== 1 || !addedNode.querySelector) continue;
// Query for all possible translatable P elements within the added node or if addedNode is the P itself
addedNode.querySelectorAll(
"p.tweet-body_root__ChzUj," +
"p.text-textSecondary.mt-1.whitespace-pre-wrap," +
"div.mt-2.border.border-secondaryStroke.rounded-\\[4px\\].relative.group.overflow-hidden p.text-textSecondary.mt-1," +
"p.break-words"
).forEach(processElementForTranslation);
}
}
// --- NEW: Handle characterData changes for in-place text updates ---
else if (mutationRecord.type === 'characterData') {
// The target of characterData is the TextNode itself.
// We need to get its parent element to process.
const parentElement = mutationRecord.target.parentElement;
if (parentElement) {
// Check if this parent is one of our translatable elements
if (parentElement.matches("p.tweet-body_root__ChzUj") ||
parentElement.matches("p.text-textSecondary.mt-1.whitespace-pre-wrap") ||
parentElement.matches("div.mt-2.border.border-secondaryStroke.rounded-\\[4px\\].relative.group.overflow-hidden p.text-textSecondary.mt-1") ||
parentElement.matches("p.break-words")) {
processElementForTranslation(parentElement);
}
}
}
// --- END NEW ---
}
});
// Configure MutationObserver to observe characterData as well, and optimize attributeFilter
contentWatcher.observe(document.body, {
childList: true,
subtree: true,
characterData: true, // Crucial: now observe text content changes directly
attributes: false, // Reverted: Only observe specific attributes if absolutely necessary, to avoid performance issues
// attributeFilter: ['class', 'style', ORIGINAL_DATA_ATTR, ORIGINAL_TEXT_STORE_ATTR] // Removed as 'attributes: false'
});
// Initial full scan when the script first runs
performFullPageScan();
// Removed lazyScanTimer
// Ensure timers are cleared on page unload
window.addEventListener('beforeunload', () => {
if (scanTimeoutId) clearTimeout(scanTimeoutId);
// if (lazyScanTimer) clearInterval(lazyScanTimer); // Removed
});
})();