// ==UserScript==
// @name F95 – add “Compressed version” link (Fixed skip check + pagination + debug + tentative & technical filtering; skip Mods forum)
// @namespace https://example.com/
// @version 1.8.14
// @description Adds compressed version link above downloads with pagination; skips only if link already inserted, with debug logs included. Filters tentative announcements and technical posts to avoid false positives. Skips Mods forum threads. Adjusted to avoid treating plain "download" as a positive indicator and tuned request detection. (Patched: improved quote/request & technical filters + page-reference skip in search results)
// @author You
// @match https://f95zone.to/*threads/*
// @grant GM_xmlhttpRequest
// @connect f95zone.to
// ==/UserScript==
/* -------------------------------------------------- /
/ ------------------ helpers ----------------------- /
/ -------------------------------------------------- */
function parseVersionKey(text) {
if (!text) return null;
const cleanedText = text.replace(/[[]]/g, ' ');
// Season + chapter/episode pattern (e.g., "S2 Ch. 3", "Season 1 Ep 12")
const seasonRegex = /s(?:eason)?\s*(\d+).*?(ch|ep|chapter|episode)\.?\s*(\d+)/i;
let match = cleanedText.match(seasonRegex);
if (match && match[1] && match[2] && match[3]) {
const season = parseInt(match[1], 10);
const typeStr = match[2].toLowerCase();
const number = parseInt(match[3], 10);
const displayType = typeStr.startsWith('ch') ? 'Ch.' : 'Ep.';
console.debug(`parseVersionKey: matched season/chapter: S${season} ${displayType} ${number}`);
return { key: season + number / 100, valueStr: `S${season} ${displayType} ${number}` };
}
// Standalone chapter/episode (e.g., "Ch. 3", "Episode 5")
const standaloneRegex = /\b(ch|ep|chapter|episode)\.?\s*(\d+)/i;
match = cleanedText.match(standaloneRegex);
if (match && match[1] && match[2]) {
const typeStr = match[1].toLowerCase();
const number = parseInt(match[2], 10);
const displayType = typeStr.startsWith('ch') ? 'Ch.' : 'Ep.';
console.debug(`parseVersionKey: matched standalone chapter/episode: ${displayType} ${number}`);
return { key: 1 + number / 100, valueStr: `${displayType} ${number}` };
}
// Version pattern (e.g., "v1.2")
const versionRegex = /v(?:ersion)?[\s._-]?(\d[\d.]*)/i;
match = cleanedText.match(versionRegex);
if (match && match[1]) {
console.debug(`parseVersionKey: matched version: v${match[1]}`);
return { key: parseFloat(match[1]), valueStr: `v${match[1]}` };
}
console.debug('parseVersionKey: no version matched for text:', text);
return null;
}
function createCompressedVersionLink(label, href) {
const link = document.createElement('a');
link.className = 'f95-compressed-version-link'; // Important for skip detection
link.href = href;
link.textContent = label;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.style.cssText = 'display: inline-block; margin: 10px 0 12px 0; padding: 8px 14px; background-color: #ba4545; color: white; font-weight: bold; font-size: 14px; text-decoration: none; border-radius: 4px; cursor: pointer;';
link.title = 'Go to compressed version post';
return link;
}
function insertLinkAboveDownloadDiv(firstPost, element) {
const candidates = firstPost.querySelectorAll('div[style*="text-align: center"]');
for (const div of candidates) {
if (div.textContent.trim().toUpperCase().includes('DOWNLOAD')) {
div.parentNode.insertBefore(element, div);
return true;
}
}
const firstSpoiler = firstPost.querySelector('.bbCodeSpoiler');
if (firstSpoiler) {
firstSpoiler.parentNode.insertBefore(element, firstSpoiler);
} else {
firstPost.insertBefore(element, firstPost.firstChild);
}
return false;
}
function isGameReleasePost(post) {
if (!post) return false;
const centerDivs = post.querySelectorAll('div[style*="text-align: center"]');
for (const div of centerDivs) {
if (div.textContent.trim().toUpperCase().includes('DOWNLOAD')) return true;
}
const spoilerButtons = post.querySelectorAll('.bbCodeSpoiler-button');
for (const button of spoilerButtons) {
const buttonText = button.textContent.trim().toUpperCase();
if (buttonText.includes('DOWNLOAD') || buttonText.includes('LINKS')) return true;
}
return false;
}
// Conservative quote/request filter:
// - Avoid treating bare "please"/"pls" as automatic request.
// - Still filter short polite "compressed version please" or negative-download phrases like "I won't download 7GB".
function looksLikeQuoteOrRequest(text) {
if (!text) return true;
const s = text.trim().toLowerCase();
// Too short — likely a small reply or single-line comment
if (s.length < 15) {
console.debug('looksLikeQuoteOrRequest: text too short, filtering out:', text);
return true;
}
// Common indicators of quotes/requests/questions
if (s.includes('said:') || s.startsWith('quote') || s.startsWith('spoiler') ||
s.includes('request') || s.includes('looking for') || s.includes('anyone have') || s.endsWith('?')) {
console.debug('looksLikeQuoteOrRequest: text looks like quote/request, filtering out:', text);
return true;
}
// Page-reference / "see page X of this thread" — usually replies pointing elsewhere, not original release posts
if (/\bon page\s+(?:one|\d+)\b/i.test(s) || /\bpage\s+(?:one|\d+)\s+of (?:this )?thread\b/i.test(s) || /\bpage\s+one\b/i.test(s)) {
console.debug('looksLikeQuoteOrRequest: page-reference detected, filtering out:', text);
return true;
}
// Polite short requests like "compressed version please" — require proximity between "compressed" and politeness token
const politeTokens = '(?:please|pls|if possible|would be great)';
const compressedToken = '\\bcompressed(?: version)?\\b';
const politeNearCompressed = new RegExp(compressedToken + '[\\s\\S]{0,60}' + politeTokens, 'i').test(text) ||
new RegExp(politeTokens + '[\\s\\S]{0,60}' + compressedToken, 'i').test(text);
if (politeNearCompressed) {
console.debug('looksLikeQuoteOrRequest: polite compressed request detected (nearby), filtering out:', text);
return true;
}
// Negative download phrasing — indicates a request or complaint (won't/can't download)
if (/\b(not (gonna|going to)|won't|will not|cannot|can't|dont want to|don't want to|too big|too large|too big for)\b/.test(s) &&
/\b(download|dl|gb|mb|gbs|gb)\b/.test(s)) {
console.debug('looksLikeQuoteOrRequest: negative download phrasing detected, filtering out:', text);
return true;
}
// List-style or enumerations (1) or bullets
if (/\b\d+\)\s/.test(s) || /\b\d+\.\s/.test(s) || /•/.test(s) || /- \[?\s?\]/.test(s)) {
console.debug('looksLikeQuoteOrRequest: detected list/enum style content, filtering out:', text);
return true;
}
// Short request phrases (deliberately exclude generic 'please'/'pls' here)
const requestPhrases = [
'i just need',
'just need',
'i need',
'need a',
'need an',
'i want',
'want a',
'looking to',
'anyone got',
'anyone has',
'is there a compressed',
'where can i find',
'where can i go',
'does anyone have',
'anyone have',
'looking for a compressed',
'looking for compressed'
];
for (const p of requestPhrases) {
if (s.includes(p)) {
console.debug('looksLikeQuoteOrRequest: matched request phrase, filtering out:', p, 'in', text);
return true;
}
}
return false;
}
function parseHTMLWithBase(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const base = document.createElement('base');
base.href = window.location.origin;
if (doc.head) {
doc.head.appendChild(base);
} else {
const head = document.createElement('head');
head.appendChild(base);
doc.insertBefore(head, doc.firstChild);
}
return doc;
}
/* Tentative & Technical announcement detection */
const KNOWN_HOSTS = ['mega','pixeldrain','workupload','buzzheavier','datanodes','akirabox','gofile','uploadhaven','mixdrop','vikingfile','gofile.io','zippyshare'];
function isTentativeAnnouncement(text) {
if (!text) return false;
const s = text.toLowerCase();
// If there's an explicit URL or link tag, treat as concrete (not tentative)
if (/\bhttps?:\/\/[^\s"'<>]+/i.test(s) || /\[(url|code|spoiler)/i.test(s)) return false;
const technicalPatterns = [
/filegetter::compressed/i,
/\bcompressed\s*=/i,
/\bwriting_list\b/i,
/\bwr iting_list\b/i, // sometimes spaced typo
/\bjoiplay\b/i,
/\bjoi ?play\b/i,
/\bapk\b/i,
/\bandroid\b/i,
/\bcompanion stat\b/i
];
if (technicalPatterns.some(re => re.test(s))) {
return true;
}
const tentativePatterns = [
/\bmay make (?:available|available through|available on|available via|available in)\b/i,
/\bmay (?:be available|make available|upload|post)\b/i,
/\bmight (?:make|be|upload|release|post)\b/i,
/\bmaybe (?:make|be|upload|release|post)\b/i,
/\bprobably (?:make|be|upload|release|post)\b/i,
/\bdoubt it\b/i,
/\bdon('?|’?)t (?:really )?have the time\b/i,
/\b(i(?:'m| am)? )?consider(?:ing)?\b/i,
/\bi(?:'ll| will)? (?:see|try|maybe|might)\b/i
];
for (const re of tentativePatterns) {
if (re.test(s)) return true;
}
const hostAlternation = KNOWN_HOSTS.map(h => h.replace(/\./g, '\\.')).join('|');
const tentativeHostRegex = new RegExp(`\\b(?:may|might|maybe|probably|could)\\b[\\s\\S]{0,80}\\b(?:make|be|upload|post|available)\\b[\\s\\S]{0,40}\\b(?:${hostAlternation})\\b`, 'i');
if (tentativeHostRegex.test(s)) return true;
return false;
}
/* skip Mods forum threads helper */
function isInModsForum() {
const crumbs = [...document.querySelectorAll('.p-breadcrumbs a')];
for (const a of crumbs) {
const href = a.getAttribute('href') || '';
const txt = (a.textContent || '').toLowerCase();
if (href.includes('/forums/mods.') || txt.includes('mods')) return true;
}
return false;
}
/* Compression-discussion negative filter */
function isCompressionDiscussion(text) {
if (!text) return false;
const s = text.toLowerCase();
const negativePhrases = [
'already compressed',
'already quite compressed',
'already at its maximum',
'max compression',
'maximum compression',
'nothing we can do',
'not compressible',
'can\'t compress',
'cannot compress',
'no further compress',
'won\'t compress',
'will make av angry',
'will make av',
'makes av angry',
'we use .webp',
'we use .ogg',
'we use webp',
'we use ogg',
'use .webp',
'use .ogg',
'webp for pictures',
'ogg for the music',
'text-files are not compressed',
'data is already quite compressed'
];
for (const p of negativePhrases) {
if (s.includes(p)) {
console.debug('isCompressionDiscussion: matched negative phrase:', p);
return true;
}
}
if (/\balready (?:compressed|quite compressed|at (?:its )?maximum)\b/i.test(s)) {
console.debug('isCompressionDiscussion: regex matched "already compressed" style phrase');
return true;
}
if (/\buses?\.?\s*(webp|ogg|mp3|wav|flac|jpg|jpeg|png)\b/i.test(s)) {
console.debug('isCompressionDiscussion: matched uses <format> pattern');
return true;
}
// Technical/troubleshooting markers — e.g., the post about freezing / rpg_core.js / skipCount / framerate
if (/\brpg(?:\s*maker|maker mv)?\b/i.test(s) ||
/\brpg_core\.js\b/i.test(s) ||
/\bskipcount\b/i.test(s) ||
/\bframerate\b|\bfps\b/i.test(s) ||
/\bfreeze(?:d|s)?\b|\balt\s*f4\b|\baltf4\b|\bcrash(?:es)?\b/i.test(s) ||
/\bruntime error\b|\berror log\b/i.test(s)) {
console.debug('isCompressionDiscussion: technical/troubleshooting content detected, filtering out:', text);
return true;
}
return false;
}
/* findBestMatchingCompressedPost with improved host/archive detection */
function findBestMatchingCompressedPost(html) {
const doc = parseHTMLWithBase(html);
const items = [...doc.querySelectorAll('li.block-row.js-inlineModContainer')];
console.debug(`findBestMatchingCompressedPost: Found ${items.length} search result items`);
if (items.length === 0) return null;
const compressedRegex = /(compressed|compress|archive|\.zip|\.rar|\.7z)/i;
// Strong release indicator regex: explicit archive ext, URL, or well-known host (with optional common TLD)
const strongHostPattern = [
'\\.zip\\b',
'\\.rar\\b',
'\\.7z\\b',
'https?:\\/\\/[^\s"\'<>]+',
'\\b(?:mega(?:\\.nz)?|gofile(?:\\.io)?|pixeldrain|workupload|zippyshare|uploadhaven|mixdrop|vikingfile|akirabox|datanodes|buzzheavier)\\b'
].join('|');
const strongReleaseRegex = new RegExp(strongHostPattern, 'i');
// match sizes like "5GB" or "700 MB"
const sizeRegex = /\b\d+(?:[\.,]\d+)?\s*(GB|MB|G|M)\b/i;
// Exclude plain "download" — it's often used in requests ("I won't download 7GB") and is not a reliable positive indicator.
const sizeActionRegex = /\b(?:dl|upload|link|links|mirror|mirrors|host|uploaded|uploader)\b/i;
const hasLinkRegex = /\[(URL|CODE|SPOILER)/i;
const urlInTextRegex = /https?:\/\/[^\s"'<>]+/gi;
const shortCompressedQuestionRegex = /^compressed( version)?\??$/i;
const sizeNegativeWords = ['limit', 'download limit', 'per day', 'daily', 'quota', 'cap', 'bandwidth', 'speed', 'monthly', 'rate limit', 'throttle', 'limit of', 'per ip', 'per account', 'limit u', 'limit you'];
let candidates = items.map((li, index) => {
const postLinkElem = li.querySelector('.contentRow-main h3.contentRow-title a[href]');
if (!postLinkElem) {
console.debug(`[${index}] Missing postLinkElem - skipping`);
return null;
}
const postUrl = postLinkElem.href;
const snippetElem = li.querySelector('.contentRow-main .contentRow-snippet');
const snippetText = snippetElem ? snippetElem.textContent.trim() : '';
console.debug(`[${index}] Snippet:`, snippetText);
// Skip snippets that explicitly point to another page of this thread (commonly a reply)
if ((/\bon page\s+(?:one|\d+)\b.*\bof (?:this )?thread\b/i.test(snippetText)) ||
(/\bon page\s+(?:one|\d+)\b/i.test(snippetText) && /\b(of|of this)\b/i.test(snippetText))) {
console.debug(`[${index}] References another page of this thread (e.g. "On page X") - skipping`);
return null;
}
if (!compressedRegex.test(snippetText)) {
console.debug(`[${index}] Does not mention compression - skipping`);
return null;
}
if (looksLikeQuoteOrRequest(snippetText)) {
console.debug(`[${index}] Looks like quote/request - skipping`);
return null;
}
if (shortCompressedQuestionRegex.test(snippetText)) {
console.debug(`[${index}] Snippet is short compressed version question - skipping`);
return null;
}
const hasLinkTag = hasLinkRegex.test(snippetText);
const urlsInText = snippetText.match(urlInTextRegex) || [];
const sizeMatch = sizeRegex.exec(snippetText);
const actionMatch = sizeActionRegex.exec(snippetText);
let sizeActionNear = false;
if (sizeMatch && actionMatch) {
const threshold = 60;
sizeActionNear = Math.abs(actionMatch.index - sizeMatch.index) <= threshold;
}
let sizeContextNegativeNear = false;
if (sizeMatch) {
const sizeIndex = sizeMatch.index;
const windowSize = 60;
const start = Math.max(0, sizeIndex - windowSize);
const end = Math.min(snippetText.length, sizeIndex + (sizeMatch[0] ? sizeMatch[0].length + windowSize : windowSize));
const surrounding = snippetText.slice(start, end).toLowerCase();
for (const w of sizeNegativeWords) {
if (surrounding.includes(w)) {
sizeContextNegativeNear = true;
console.debug(`[${index}] Size context negative word found near size: "${w}"`);
break;
}
}
}
// Use the stricter "strongReleaseRegex" only.
const hasStrongReleaseIndicator = strongReleaseRegex.test(snippetText);
// hasHost only if a strong indicator OR size+action near each other and not negative
const hasHost = hasStrongReleaseIndicator || (sizeMatch && actionMatch && sizeActionNear && !sizeContextNegativeNear);
const candidateVersionInfo = parseVersionKey(snippetText);
const lowerSnippet = snippetText.toLowerCase();
if (/\bandroid\b/i.test(lowerSnippet) || /\bport\b/i.test(lowerSnippet) || /\bapk\b/i.test(lowerSnippet)) {
console.debug(`[${index}] mentions android/port/apk - skipping`);
return null;
}
// Compression-discussion negative filter: skip if it's purely a compression discussion and no strong indicators/version/link
if (isCompressionDiscussion(snippetText) && !candidateVersionInfo && !hasLinkTag && !hasStrongReleaseIndicator) {
console.debug(`[${index}] Compression discussion only (no links/version/strong host) - skipping`);
return null;
}
if (isTentativeAnnouncement(snippetText) && urlsInText.length === 0 && !hasLinkTag && !candidateVersionInfo) {
console.debug(`[${index}] Tentative/technical announcement without links/version - skipping`);
return null;
}
if (!(candidateVersionInfo || hasLinkTag || hasHost)) {
console.debug(`[${index}] Lacks version/link/release indicator - skipping`);
return null;
}
const timeElem = li.querySelector('time.u-dt');
if (!timeElem) {
console.debug(`[${index}] Missing time element - skipping`);
return null;
}
const postDate = new Date(timeElem.getAttribute('datetime'));
if (isNaN(postDate.getTime())) {
console.debug(`[${index}] Invalid date - skipping`);
return null;
}
console.debug(`[${index}] Candidate found: URL=${postUrl}, Date=${postDate.toISOString()}, VersionInfo=${candidateVersionInfo ? JSON.stringify(candidateVersionInfo) : 'N/A'}, hasHost=${hasHost}, hasLinkTag=${hasLinkTag}`);
return {
url: postUrl,
date: postDate,
versionInfo: candidateVersionInfo,
hasReleaseIndicator: hasHost,
hasLinkTag,
};
}).filter(Boolean);
if (candidates.length === 0) {
console.debug('No valid candidates found.');
return null;
}
candidates.sort((a, b) => {
if (!!b.versionInfo && !a.versionInfo) return 1;
if (!!a.versionInfo && !b.versionInfo) return -1;
if (b.hasReleaseIndicator && !a.hasReleaseIndicator) return 1;
if (a.hasReleaseIndicator && !b.hasReleaseIndicator) return -1;
return b.date - a.date;
});
console.debug('Top candidate selected:', candidates[0]);
return candidates[0];
}
function postSearchUsingPageForm(searchQuery, threadId, page = 1) {
return new Promise((resolve, reject) => {
const forms = document.querySelectorAll('form[action="/search/search"]');
let searchForm = null;
for (const form of forms) {
if (form.querySelector('select[name="constraints"]')) {
searchForm = form;
break;
}
}
if (!searchForm) {
reject(new Error("Could not find search form with 'constraints' dropdown."));
return;
}
const formData = new FormData(searchForm);
formData.set('keywords', searchQuery);
if (threadId) formData.set('thread_ids', threadId);
formData.set('search_in', 'posts');
if (page > 1) formData.set('page', page);
const params = new URLSearchParams();
for (const pair of formData.entries()) {
params.append(pair[0], pair[1]);
}
const postUrl = new URL(searchForm.action, window.location.href).href;
console.debug(`Sending search request for page ${page} to ${postUrl}`);
GM_xmlhttpRequest({
method: "POST",
url: postUrl,
data: params.toString(),
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"User-Agent": navigator.userAgent,
"Referer": window.location.href
},
onload: r => {
if (r.status >= 200 && r.status < 300) {
console.debug(`Search page ${page} loaded (${r.responseText.length} bytes)`);
resolve(r.responseText);
} else {
reject(new Error(`HTTP Error ${r.status}: ${r.statusText}`));
}
},
onerror: () => reject(new Error("Network error during search")),
});
});
}
async function trySearchAllPages(searchQuery, threadId, maxPages = 3) {
let bestCandidate = null;
for (let page = 1; page <= maxPages; page++) {
try {
console.debug(`Attempting search on page ${page}`);
const html = await postSearchUsingPageForm(searchQuery, threadId, page);
const candidate = findBestMatchingCompressedPost(html);
if (candidate) {
if (!bestCandidate) {
bestCandidate = candidate;
console.debug(`Best candidate initialized from page ${page}:`, candidate);
} else {
if (!!candidate.versionInfo && !bestCandidate.versionInfo) {
bestCandidate = candidate;
console.debug(`Best candidate replaced (versionInfo priority) from page ${page}:`, candidate);
} else if (!candidate.versionInfo && bestCandidate.versionInfo) {
console.debug(`Skipped candidate from page ${page} - lower versionInfo priority`);
} else if (candidate.hasReleaseIndicator && !bestCandidate.hasReleaseIndicator) {
bestCandidate = candidate;
console.debug(`Best candidate replaced (release indicator priority) from page ${page}:`, candidate);
} else if (!candidate.hasReleaseIndicator && bestCandidate.hasReleaseIndicator) {
console.debug(`Skipped candidate from page ${page} - lower releaseIndicator priority`);
} else if (candidate.date > bestCandidate.date) {
bestCandidate = candidate;
console.debug(`Best candidate replaced (newer date) from page ${page}:`, candidate);
} else {
console.debug(`Skipped candidate from page ${page} - older or equal date`);
}
}
} else {
console.debug(`No candidate found on page ${page}`);
}
if (page === 1 && bestCandidate?.versionInfo && bestCandidate.hasReleaseIndicator) {
console.debug('Early break: High-quality candidate found on page 1');
break;
}
} catch (err) {
console.error(`Error during search on page ${page}:`, err);
break;
}
}
return bestCandidate;
}
window.addEventListener('load', () => {
(async function main() {
try {
console.debug('F95 Compressed Version Link Script started.');
if (isInModsForum()) {
console.debug('In Mods forum - skipping script.');
return;
}
const firstPost = document.querySelector('.message-threadStarterPost.message--post.js-post');
if (!firstPost) {
console.debug('No first post found, exiting.');
return;
}
if (!isGameReleasePost(firstPost)) {
console.debug('First post does not appear to be a game release post; exiting.');
return;
}
if (firstPost.querySelector('.f95-compressed-version-link')) {
console.debug('Compressed version link already present; skipping.');
return;
}
const urlMatch = window.location.pathname.match(/\.([0-9]+)(\/|$)/);
if (!urlMatch) {
console.debug('Cannot extract thread ID; exiting.');
return;
}
const threadId = urlMatch[1];
const searchQuery = 'compressed';
const candidate = await trySearchAllPages(searchQuery, threadId, 3);
if (!candidate) {
console.debug('No matching compressed version candidate found.');
const warn = document.createElement('span');
warn.textContent = 'No matching compressed version found.';
warn.style.cssText = 'color:#ba4545; display:block; margin:10px 0 12px;';
insertLinkAboveDownloadDiv(firstPost, warn);
return;
}
console.debug('Final compressed version candidate:', candidate);
const versionStr = candidate.versionInfo ? candidate.versionInfo.valueStr : 'Link';
const linkLabel = `Compressed Version (${versionStr})`;
const link = createCompressedVersionLink(linkLabel, candidate.url);
insertLinkAboveDownloadDiv(firstPost, link);
} catch (ex) {
console.error('F95 Compressed Version Script Error:', ex);
}
})();
});