Speedbooster
Speedbooster
(function() {
'use strict';
// ------------------------------------------------------
// 1. UI Host & Shadow DOM Setup
// ------------------------------------------------------
const uiHost = document.createElement('div');
uiHost.id = 'asb-ui-host-' + Date.now(); // Unique ID just in case
// Append host early, even before body exists sometimes
(document.body || document.documentElement).appendChild(uiHost);
// Use closed mode for better encapsulation (prevents page JS from easily
accessing it)
const shadowRoot = uiHost.attachShadow({ mode: 'closed' });
// ------------------------------------------------------
// 2. Toast Notification System (Inside Shadow DOM)
// ------------------------------------------------------
let toastTimeout;
function showToast(message, duration = 3500, isError = false) {
let toast = shadowRoot.getElementById('asb-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'asb-toast';
shadowRoot.appendChild(toast); // Append toast inside shadow DOM
}
toast.textContent = message;
toast.className = isError ? 'asb-toast asb-toast-error' : 'asb-toast'; //
Use classes for styling
toast.style.opacity = '1';
toast.style.transform = 'translateY(0)';
clearTimeout(toastTimeout);
toastTimeout = setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateY(10px)';
// Optional: remove toast element after fade out? Can be reused
instead.
}, duration);
}
// ------------------------------------------------------
// 3. Configuration Structure & Defaults
// ------------------------------------------------------
const builtInConfig = {
lazyImagesEnabled: true,
autoNextPageEnabled: true,
contentSelector: 'body', // Fallback
nextPageSelector: null, // Fallback
disabled: false, // Default for a matched rule
isDisabled: false // Flag set after evaluation
};
const defaultSettings = {
globallyDisabled: false, // New global disable flag
lazyImagesEnabled: builtInConfig.lazyImagesEnabled,
autoNextPageEnabled: builtInConfig.autoNextPageEnabled,
imageMargin: 150,
remoteMappingURL: PAGETUAL_RULES_URL,
autoFetchRemoteMappings: true
};
// ------------------------------------------------------
// 4. Settings Management (Using GM_* functions)
// ------------------------------------------------------
let settings = GM_getValue(SETTINGS_KEY, defaultSettings);
function saveSettings(newSettings) {
// Ensure we don't save undefined values from checkboxes that might not
exist yet
const cleanSettings = {};
for (const key in newSettings) {
if (newSettings[key] !== undefined) {
cleanSettings[key] = newSettings[key];
}
}
settings = { ...settings, ...cleanSettings };
GM_setValue(SETTINGS_KEY, settings);
// console.log(`${SCRIPT_NAME}: Settings saved`, settings);
}
function getDomainMappings() {
return GM_getValue(DOMAIN_MAP_KEY, []);
}
function saveDomainMappings(mappings) {
if (!Array.isArray(mappings)) {
console.error(`${SCRIPT_NAME}: Attempted to save invalid domain
mappings (not an array).`);
showToast("Error: Domain mappings must be a JSON array.", 5000, true);
return false;
}
GM_setValue(DOMAIN_MAP_KEY, mappings);
// console.log(`${SCRIPT_NAME}: Domain mappings saved`, mappings);
return true;
}
function resetSettingsAndMappings() {
GM_deleteValue(SETTINGS_KEY);
GM_deleteValue(DOMAIN_MAP_KEY);
settings = defaultSettings; // Reset in-memory settings
// console.log(`${SCRIPT_NAME}: Settings and mappings reset to defaults.`);
}
// ------------------------------------------------------
// 5. Remote Domain Mapping Fetch & Conversion
// ------------------------------------------------------
async function fetchRemoteDomainMappings(remoteURL) {
if (!remoteURL) {
showToast("No remote URL provided in settings.", 4000, true);
return;
}
showToast(`Workspaceing rules from ${new URL(remoteURL).hostname}...`);
try {
const response = await fetch(remoteURL, { cache: "no-store" });
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const remoteData = await response.json();
convertedMappings.push({
domainPattern: domainPattern,
contentSelector: rule.pageElement,
nextPageSelector: rule.nextLink,
disabled: rule['[Disabled]'] === true // Convert Pagetual
disable flag
// Add other flags here if needed later
});
}
} else if (Array.isArray(remoteData)) {
convertedMappings = remoteData; // Assume correct format
} else {
throw new Error("Fetched data format not recognized (Pagetual
object or Standard array).");
}
if (saveDomainMappings(convertedMappings)) {
showToast(`Workspaceed ${convertedMappings.length} rules. Reload
page to apply.`, 5000);
}
} catch (err) {
console.error(`${SCRIPT_NAME}: Error fetching or processing remote
mappings:`, err);
showToast(`Failed to fetch mappings: ${err.message}`, 6000, true);
}
}
// ------------------------------------------------------
// 6. Domain Matching & Config Retrieval
// ------------------------------------------------------
function matchDomain(hostname, pattern) {
try {
if (hostname === pattern) return true;
if (pattern.startsWith('*.')) {
return hostname.endsWith(pattern.substring(1));
}
if (!pattern.includes('*')) {
return hostname.endsWith('.' + pattern);
}
// Add more complex matching if needed (e.g., regex from pattern?)
} catch(e) {
console.error(`${SCRIPT_NAME}: Error matching domain "${hostname}"
with pattern "${pattern}":`, e);
return false;
}
return false; // Default if no specific match logic applies
}
function getCurrentDomainConfig(hostname) {
const mappings = getDomainMappings();
for (const mapping of mappings) {
if (mapping.domainPattern && matchDomain(hostname,
mapping.domainPattern)) {
const domainIsDisabled = mapping.disabled === true;
return {
...builtInConfig,
contentSelector: mapping.contentSelector ||
builtInConfig.contentSelector,
nextPageSelector: mapping.nextPageSelector || null,
lazyImagesEnabled: typeof mapping.lazyImagesEnabled ===
'boolean' ? mapping.lazyImagesEnabled : settings.lazyImagesEnabled,
autoNextPageEnabled: typeof mapping.autoNextPageEnabled ===
'boolean' ? mapping.autoNextPageEnabled : settings.autoNextPageEnabled,
isDisabled: domainIsDisabled, // Set the final disabled state
matchedPattern: mapping.domainPattern // Store which pattern
matched for info
};
}
}
// No specific match found, use global settings
return {
...builtInConfig,
lazyImagesEnabled: settings.lazyImagesEnabled,
autoNextPageEnabled: settings.autoNextPageEnabled,
isDisabled: false, // Not disabled if no specific rule matched
matchedPattern: null
};
}
// ------------------------------------------------------
// 7. Core Feature Initialization & DOM Ready Logic
// ------------------------------------------------------
const currentHostname = window.location.hostname;
let domainConfig = null;
let intersectionObserverImage = null;
let intersectionObserverNextPage = null;
let imageMutationObserver = null;
if (domainConfig.isDisabled) {
console.log(`${SCRIPT_NAME}: Disabled for domain ${currentHostname} by
matching pattern: ${domainConfig.matchedPattern}`);
createSettingsUI(); // Still create UI
return; // Stop further initialization for this domain
}
// Initialize on DOMContentLoaded
if (document.readyState === 'loading') { // Fire early if possible
document.addEventListener('DOMContentLoaded', initializeScript, { once:
true });
} else {
initializeScript(); // Already loaded
}
// ------------------------------------------------------
// 8. Style Injection (CSS for Shadow DOM)
// ------------------------------------------------------
function injectBaseStyles() {
const style = document.createElement('style');
style.textContent = `
/* Reset styles within Shadow DOM to minimize conflicts */
:host { display: block; /* Required for host element */ }
* { box-sizing: border-box; margin: 0; padding: 0; font-family: sans-
serif; }
/* Settings Button */
#asbSettingsBtn {
position: fixed; bottom: 10px; right: 10px;
background: rgba(40,40,40,0.75); color: #fff; border: none;
border-radius: 5px; padding: 8px 12px; z-index: 99998;
font-size: 14px; cursor: pointer; box-shadow: 0 2px 5px
rgba(0,0,0,0.2);
transition: background-color 0.2s;
}
#asbSettingsBtn:hover { background: rgba(70,70,70,0.85); }
/* Settings Panel */
#asbSettingsPanel {
display: none; /* Hidden by default */
position: fixed; bottom: 55px; right: 10px;
width: 300px; background: #f4f4f4; border: 1px solid #ccc;
border-radius: 6px; padding: 15px; z-index: 99999;
font-size: 13px; color: #333; box-shadow: 0 4px 12px
rgba(0,0,0,0.2);
max-height: calc(90vh - 70px); /* Limit height */
overflow-y: auto; /* Scroll if content exceeds height */
scrollbar-width: thin; /* Firefox scrollbar */
}
/* Webkit scrollbar styles */
#asbSettingsPanel::-webkit-scrollbar { width: 6px; }
#asbSettingsPanel::-webkit-scrollbar-track { background: #f1f1f1;
border-radius: 3px;}
#asbSettingsPanel::-webkit-scrollbar-thumb { background: #aaa; border-
radius: 3px;}
#asbSettingsPanel::-webkit-scrollbar-thumb:hover { background: #888; }
// ------------------------------------------------------
// 9. Settings UI Creation & Management (Inside Shadow DOM)
// ------------------------------------------------------
function createSettingsUI() {
// No need to check document.body, shadowRoot always exists here
// Avoid creating UI multiple times by checking for an element inside
shadowRoot
if (shadowRoot.getElementById('asbSettingsPanel')) return;
shadowRoot.getElementById('asbFetchRemote').addEventListener('click', () =>
{
const url =
shadowRoot.getElementById('asbRemoteMappingsURL').value.trim();
fetchRemoteDomainMappings(url); // Async function call
});
shadowRoot.getElementById('asbEditDomains').addEventListener('click', () =>
{
const currentMappings = JSON.stringify(getDomainMappings(), null, 2);
shadowRoot.getElementById('asbDomainMappingsTextarea').value =
currentMappings;
modal.style.display = 'flex'; // Use flex for centering via CSS
});
shadowRoot.getElementById('asbDomainCancel').addEventListener('click', ()
=> { modal.style.display = 'none'; });
shadowRoot.getElementById('asbDomainSave').addEventListener('click', () =>
{
const newMappingsStr =
shadowRoot.getElementById('asbDomainMappingsTextarea').value;
try {
const newMappings = JSON.parse(newMappingsStr);
if (saveDomainMappings(newMappings)) { // save checks array type
showToast("Domain mappings saved. Reload page to apply.");
modal.style.display = 'none';
}
} catch (e) {
console.error(`${SCRIPT_NAME}: Invalid JSON in domain mappings:`,
e);
showToast(`Invalid JSON format: ${e.message}`, 5000, true);
}
});
// ------------------------------------------------------
// 10. Lazy Loading Images (using data-src)
// ------------------------------------------------------
function initLazyImageLoading(containerElement) {
if (!domainConfig || !domainConfig.lazyImagesEnabled) return; // Check
feature enabled
// Use :where() for zero specificity to avoid conflicts if page uses .asb-
loaded
const images = containerElement.querySelectorAll('img[data-
src]:where(:not(.asb-loaded))');
if (images.length === 0) return;
if ('IntersectionObserver' in window) {
if (!intersectionObserverImage) { // Create observer only once
intersectionObserverImage = new IntersectionObserver((entries,
observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
const dataSrc = img.getAttribute('data-src');
img.src = dataSrc; // Set src to trigger load
img.onload = () => img.classList.add('asb-loaded');
img.onerror = () => {
img.classList.add('asb-error');
console.warn(`${SCRIPT_NAME}: Failed to load
image:`, dataSrc);
};
img.removeAttribute('data-src');
observer.unobserve(img);
}
});
}, {
rootMargin: `${settings.imageMargin}px 0px`,
threshold: 0.01 // Load even if only slightly visible
});
}
images.forEach(img => intersectionObserverImage.observe(img));
} else { // Fallback
images.forEach(img => {
img.src = img.getAttribute('data-src');
img.onload = () => img.classList.add('asb-loaded');
img.removeAttribute('data-src');
});
}
}
// ------------------------------------------------------
// 11. Mutation Observer for Dynamic Images
// ------------------------------------------------------
function initMutationObserver() {
// Check dependencies and ensure not already running
if (!window.MutationObserver || !domainConfig || !
domainConfig.lazyImagesEnabled || imageMutationObserver) {
return;
}
// ------------------------------------------------------
// 12. Infinite Scroll Implementation
// ------------------------------------------------------
let isLoadingNextPage = false;
let nextPageUrl = null;
let nextPageTrigger = null;
let contentContainer = null;
let nextPageLoaderIndicator = null;
return null;
}
isLoadingNextPage = true;
if (nextPageLoaderIndicator) nextPageLoaderIndicator.style.display =
'block';
try {
const response = await fetch(nextPageUrl);
if (!response.ok) throw new Error(`HTTP error! Status: $
{response.status}`);
const htmlText = await response.text();
const parser = new DOMParser();
const nextPageDoc = parser.parseFromString(htmlText, 'text/html');
if (newContentFragment) {
// Append children safely
while (newContentFragment.firstChild) {
contentContainer.appendChild(newContentFragment.firstChild);
}
// Find the link for the *subsequent* page load from the *fetched*
document
nextPageUrl = findNextPageLink(nextPageDoc); // Use helper with doc
context
if (!nextPageUrl) {
if (nextPageTrigger && intersectionObserverNextPage)
intersectionObserverNextPage.unobserve(nextPageTrigger);
if (nextPageLoaderIndicator)
nextPageLoaderIndicator.textContent = "End of content.";
}
} else {
throw new Error(`Content selector "$
{domainConfig.contentSelector}" not found in fetched page.`);
}
} catch (error) {
console.error(`${SCRIPT_NAME}: Error loading/processing next page:`,
error);
if (nextPageLoaderIndicator) nextPageLoaderIndicator.textContent =
`Error: ${error.message.substring(0, 100)}`; // Show error briefly
if (nextPageTrigger && intersectionObserverNextPage)
intersectionObserverNextPage.unobserve(nextPageTrigger);
nextPageUrl = null; // Stop trying after error
} finally {
isLoadingNextPage = false;
// Keep loader visible only if it shows end/error message
if (nextPageLoaderIndicator && !(nextPageLoaderIndicator.textContent
=== "End of content." || nextPageLoaderIndicator.textContent.startsWith("Error")))
{
nextPageLoaderIndicator.style.display = 'none';
}
}
}
function initAutoNextPage() {
if (!domainConfig || !domainConfig.autoNextPageEnabled) return; // Check
feature enabled
try {
contentContainer =
document.querySelector(domainConfig.contentSelector);
} catch (e) { console.error(`${SCRIPT_NAME}: Error querying content
container "${domainConfig.contentSelector}":`, e); }
if (!contentContainer) {
console.warn(`${SCRIPT_NAME}: Infinite scroll disabled - content
container not found:`, domainConfig.contentSelector);
return;
}
nextPageLoaderIndicator = document.createElement('div');
nextPageLoaderIndicator.id = 'asbNextPageLoader';
nextPageLoaderIndicator.textContent = 'Loading next page...';
// Inject styles for loader via JS (as it's outside Shadow DOM) or use
a class defined globally?
// Simpler to style directly for now.
Object.assign(nextPageLoaderIndicator.style, {
textAlign: 'center', padding: '25px', fontStyle: 'italic', color:
'#888',
display: 'none', width: '100%', clear: 'both', fontSize: '14px'
});
if ('IntersectionObserver' in window) {
if (intersectionObserverNextPage)
intersectionObserverNextPage.disconnect(); // Ensure clean state
intersectionObserverNextPage.observe(nextPageTrigger);
} else {
console.warn(`${SCRIPT_NAME}: IntersectionObserver not supported,
infinite scroll disabled.`);
}
}