// ==UserScript==
// @name F95 – add “Compressed version” link
// @namespace https://example.com/
// @version 1.7.2
// @description Finds the newest, most relevant compressed build in the same thread and links it above the download section.
// @author You
// @match https://f95zone.to/*threads/*
// @grant GM_xmlhttpRequest
// @connect f95zone.to
// ==/UserScript==
/* -------------------------------------------------- */
/* ------------------ helpers --------------------- */
/* -------------------------------------------------- */
/**
* ROBUST PARSER (V6)
* - Dynamically handles Chapter/Episode.
* - Correctly parses version strings like "v.36072" and "v0.4".
*/
function parseVersionKey(text) {
if (!text) return null;
const cleanedText = text.replace(/[\[\]\(\)]/g, ' ');
// Match Season + Chapter/Episode
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.';
return { key: season + number / 100, valueStr: `S${season} ${displayType} ${number}` };
}
// Match standalone Chapter/Episode
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.';
return { key: 1 + number / 100, valueStr: `${displayType} ${number}` };
}
// Flexible regex for standard versions like "v.123", "v 123", "v-123", and "v123"
const versionRegex = /v(?:ersion)?[\s._-]?(\d[\d.]*)/i;
match = cleanedText.match(versionRegex);
if (match && match[1]) {
return { key: parseFloat(match[1]), valueStr: `v${match[1]}` };
}
return null;
}
function createCompressedVersionLink(label, href) {
const link = document.createElement('a');
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;
}
function looksLikeQuote(snippet) {
if (!snippet) return false;
const s = snippet.trim().toLowerCase();
return s.includes('said:') || s.startsWith('quote') || s.startsWith('spoiler');
}
function isIgnorableComment(text) {
if (!text) return false;
const s = text.trim().toLowerCase();
const requestKeywords = [
'request', 'looking for', 'anyone has', 'does anyone have',
'can someone', 'anyone have', 'does anyone know', 'need', 'searching for',
'is this', 'is there', 'or what'
];
// Check for keywords OR if the snippet ends in a question mark
return requestKeywords.some(keyword => s.includes(keyword)) || s.endsWith('?');
}
/* -------------------------------------------------- */
/* -------- CORE: search-result processing -------- */
/* -------------------------------------------------- */
function findBestMatchingCompressedPost(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const items = [...doc.querySelectorAll('li.block-row, li.js-inlineModContainer')];
if (!items.length) {
console.warn('findBestMatchingCompressedPost(): no <li> items found in search results.');
return null;
}
console.log("DEBUG: Found search result items =", items.length);
const compressedRegex = /(compressed|compress|archive|\.zip|\.rar|\.7z)/i;
const releaseIndicatorRegex = /(mega|pixeldrain|workupload|buzzheavier|datanodes|akirabox|win|linux|mac|gb|mb)/i;
const hasLinkRegex = /\[(URL|CODE|SPOILER)/i;
const candidates = items.map((li, idx) => {
const contentRow = li.querySelector('.contentRow-main, .contentRow');
if (!contentRow) return null;
let postLinkElem = li.querySelector('.contentRow-minor a[href*="/post-"]');
if (!postLinkElem) {
postLinkElem = contentRow.querySelector('h3.contentRow-title a[href]');
}
if (!postLinkElem) return null;
const postUrl = postLinkElem.href;
const snippetElem = contentRow.querySelector('.contentRow-snippet');
const snippetText = snippetElem ? snippetElem.innerText.trim() : '';
// --- FILTERING LOGIC ---
// 1. Basic checks: Must not be a quote/comment and must mention compression.
if (looksLikeQuote(snippetText) || isIgnorableComment(snippetText) ||
!compressedRegex.test(snippetText) || !releaseIndicatorRegex.test(snippetText)) {
return null;
}
// 2. Strong signal check: Must have EITHER a parsable version OR a BBCode link tag.
// This prevents matching simple comments while allowing valid posts where the
// link tag is outside the truncated snippet.
const candidateVersionInfo = parseVersionKey(snippetText);
const hasLinkTag = hasLinkRegex.test(snippetText);
if (!candidateVersionInfo && !hasLinkTag) {
return null;
}
// 3. Final data check
const timeElem = li.querySelector('time.u-dt');
if (!timeElem || !timeElem.getAttribute('datetime')) return null;
const dateObj = new Date(timeElem.getAttribute('datetime'));
if (isNaN(dateObj.getTime())) return null;
const versionStr = candidateVersionInfo ? candidateVersionInfo.valueStr : "N/A";
console.log(`%cDEBUG [${idx}] Candidate FOUND: URL=${postUrl}, Version=${versionStr}`, 'color: lightgreen; font-weight: bold;');
return { url: postUrl, date: dateObj, versionInfo: candidateVersionInfo };
}).filter(Boolean);
if (!candidates.length) {
console.log("DEBUG: No valid candidates found after filtering.");
return null;
}
// Sort by newest date first to get the most recent release.
candidates.sort((a, b) => b.date - a.date);
return candidates[0];
}
/* -------------------------------------------------- */
/* ----------- helper: POST form search ----------- */
/* -------------------------------------------------- */
function postSearchUsingPageForm(searchQuery) {
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) {
return reject(new Error("Could not find a search form with a 'constraints' dropdown. The page structure may have changed."));
}
const formData = new FormData(searchForm);
formData.set('keywords', searchQuery);
const searchUrl = searchForm.action;
GM_xmlhttpRequest({
method: "POST", url: searchUrl, data: formData,
headers: { "User-Agent": navigator.userAgent, "Referer": window.location.href },
onload: r => {
if (r.status >= 200 && r.status < 300) resolve(r.responseText);
else reject(new Error(`HTTP ${r.status}: ${r.statusText}`));
},
onerror: err => reject(new Error('Network error during GM_xmlhttpRequest')),
});
});
}
/* -------------------------------------------------- */
/* ------------------- main ----------------------- */
/* -------------------------------------------------- */
window.addEventListener('load', () => {
(async function main() {
try {
const firstPost = document.querySelector('.message-threadStarterPost.message--post.js-post');
if (!firstPost || !isGameReleasePost(firstPost) || /compressed/i.test(firstPost.textContent)) {
return;
}
const searchQuery = 'compressed';
const html = await postSearchUsingPageForm(searchQuery);
const bestCandidate = findBestMatchingCompressedPost(html);
if (!bestCandidate) {
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;
}
const versionStr = bestCandidate.versionInfo ? bestCandidate.versionInfo.valueStr : 'Link';
const linkLabel = `Compressed Version (${versionStr})`;
const link = createCompressedVersionLink(linkLabel, bestCandidate.url);
insertLinkAboveDownloadDiv(firstPost, link);
} catch (e) {
console.error("F95 Compressed Link Script Error:", e);
}
})();
});