Medium Unlocked

Adds alternate reading links (ReadMedium and Freedium) to Medium paywalled articles with improved reliability.

// ==UserScript==
// @name         Medium Unlocked
// @namespace    https://github.com/ShrekBytes
// @description  Adds alternate reading links (ReadMedium and Freedium) to Medium paywalled articles with improved reliability.
// @version      3.0.1
// @author       ShrekBytes
// @license      MIT
// @match        https://medium.com/*
// @match        https://*.medium.com/*
// @match        https://infosecwriteups.com/*
// @match        https://*.infosecwriteups.com/*
// @match        https://betterprogramming.pub/*
// @match        https://*.betterprogramming.pub/*
// @match        https://betterhumans.pub/*
// @match        https://*.betterhumans.pub/*
// @match        https://uxplanet.org/*
// @match        https://*.uxplanet.org/*
// @match        https://writingcooperative.com/*
// @match        https://*.writingcooperative.com/*
// @match        https://entrepreneurshandbook.co/*
// @match        https://*.entrepreneurshandbook.co/*
// @match        https://medium.muz.li/*
// @match        https://*.medium.muz.li/*
// @match        https://blog.prototypr.io/*
// @match        https://*.blog.prototypr.io/*
// @match        https://bettermarketing.pub/*
// @match        https://*.bettermarketing.pub/*
// @match        https://byrslf.co/*
// @match        https://*.byrslf.co/*
// @match        https://levelup.gitconnected.com/*
// @match        https://*.levelup.gitconnected.com/*
// @match        https://javascript.plainenglish.io/*
// @match        https://*.javascript.plainenglish.io/*
// @match        https://thebelladonnacomedy.com/*
// @match        https://*.thebelladonnacomedy.com/*
// @match        https://medium.datadriveninvestor.com/*
// @match        https://*.medium.datadriveninvestor.com/*
// @match        https://itnext.io/*
// @match        https://*.itnext.io/*
// @match        https://proandroiddev.com/*
// @match        https://*.proandroiddev.com/*
// @match        https://code.likeagirl.io/*
// @match        https://*.code.likeagirl.io/*
// @match        https://blog.bitsrc.io/*
// @match        https://*.blog.bitsrc.io/*
// @match        https://uxdesign.cc/*
// @match        https://*.uxdesign.cc/*
// @match        https://thebolditalic.com/*
// @match        https://*.thebolditalic.com/*
// @match        https://towardsdatascience.com/*
// @match        https://*.towardsdatascience.com/*
// @match        https://medium.freecodecamp.org/*
// @match        https://*.medium.freecodecamp.org/*
// @match        https://hackernoon.com/*
// @match        https://*.hackernoon.com/*
// @match        https://codeburst.io/*
// @match        https://*.codeburst.io/*
// @match        https://blog.usejournal.com/*
// @match        https://*.blog.usejournal.com/*
// @match        https://chatbotslife.com/*
// @match        https://*.chatbotslife.com/*
// @match        https://plainenglish.io/*
// @match        https://*.plainenglish.io/*
// @match        https://blog.devgenius.io/*
// @match        https://*.blog.devgenius.io/*
// @match        https://aws.plainenglish.io/*
// @match        https://*.aws.plainenglish.io/*
// @match        https://python.plainenglish.io/*
// @match        https://*.python.plainenglish.io/*
// @match        https://medium.com/@*
// @match        https://link.medium.com/*
// @match        https://stories.medium.com/*
// @icon         https://raw.githubusercontent.com/ShrekBytes/medium-unlocked/refs/heads/main/freedom.png
// @grant        none
// @noframes
// @run-at       document-start
// @homepageURL  https://github.com/ShrekBytes/medium-unlocked
// @supportURL   https://github.com/ShrekBytes/medium-unlocked/issues
// ==/UserScript==

(function() {
    'use strict';

    // State management
    const state = {
        buttonsAdded: false,
        currentUrl: window.location.href,
        isChecking: false,
        lastCheck: 0,
        observer: null,
        checkTimeout: null
    };

    // Performance optimized selectors - ordered by likelihood and specificity
    const PAYWALL_SELECTORS = Object.freeze([
        // Primary paywall indicators (most common)
        '[data-testid="paywall"]',
        '[data-testid="meter-stats"]',
        '[data-testid="subscribe-paywall"]',
        '.paywall',

        // Secondary indicators
        '[data-testid="paywall-upsell"]',
        '[data-testid="meter-card"]',
        '.js-paywall',
        '.meteredContent',
        '.u-showForMembers',
        '.memberPreview',
        '.js-memberPreview',

        // Content limitation indicators
        '.js-truncatedPostBody',
        '[data-source="paywall"]',
        '[data-post-id][data-source="meter"]',
        '.u-lineHeightTighter.u-fontSize18:last-child',

        // Subscribe/upgrade prompts
        '[data-testid="subscribe-button"]',
        '[data-testid="upgrade-button"]',
        '.js-upgradeButton',
        '.js-subscribeButton'
    ]);

    // Optimized text patterns - case insensitive, ordered by frequency
    const PAYWALL_PATTERNS = Object.freeze([
        'member-only story',
        'subscribe to read',
        'become a member',
        'sign up to read',
        'continue reading with',
        'read the full story',
        'unlock unlimited',
        'upgrade to continue',
        'this story is published in',
        'get unlimited access'
    ]);

    // Efficient paywall detection with early returns
    function isPaywalled() {
        // Quick DOM-based detection first (fastest)
        for (const selector of PAYWALL_SELECTORS) {
            if (document.querySelector(selector)) {
                return true;
            }
        }

        // Text-based detection (slower, but thorough)
        const bodyText = document.body?.textContent?.toLowerCase();
        if (!bodyText) return false;

        return PAYWALL_PATTERNS.some(pattern => bodyText.includes(pattern));
    }

    // Optimized button creation with minimal DOM operations
    function createButton(text, url, top) {
        const button = document.createElement('a');

        // Set properties in batch for better performance
        Object.assign(button, {
            innerHTML: text,
            href: url,
            target: '_blank',
            rel: 'noopener noreferrer',
            className: 'medium-unlock-btn'
        });

        // Optimized styles as single string
        button.style.cssText = `
            position:fixed;top:${top}px;right:64px;z-index:9999;
            background:rgba(64, 64, 128,.33);backdrop-filter:blur(2px);
            color:#000;border:1px solid #000;border-radius:2px;
            font:400 14px/-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
            cursor:pointer;width:128px;height:36px;
            display:flex;align-items:center;justify-content:center;
            text-decoration:none;box-sizing:border-box;
        `;

        return button;
    }

    // Efficient button management
    function addButtons() {
        if (state.buttonsAdded || !document.body) return;

        const url = encodeURIComponent(window.location.href);
        const fragment = document.createDocumentFragment();

        // Create buttons in memory first
        fragment.appendChild(createButton('ReadMedium', `https://readmedium.com/en/${url}`, 400));
        fragment.appendChild(createButton('Freedium', `https://freedium.cfd/${url}`, 440));

        // Single DOM append operation
        document.body.appendChild(fragment);
        state.buttonsAdded = true;
    }

    function removeButtons() {
        if (!state.buttonsAdded) return;

        // Use more specific selector to avoid conflicts
        const buttons = document.querySelectorAll('.medium-unlock-btn');
        if (buttons.length > 0) {
            buttons.forEach(btn => btn.remove());
            state.buttonsAdded = false;
        }
    }

    // Throttled paywall check to prevent excessive calls
    function checkPaywall() {
        const now = Date.now();

        // Prevent rapid successive checks
        if (state.isChecking || (now - state.lastCheck) < 100) {
            return;
        }

        state.isChecking = true;
        state.lastCheck = now;

        try {
            const isPaywalledNow = isPaywalled();

            if (isPaywalledNow && !state.buttonsAdded) {
                addButtons();
            } else if (!isPaywalledNow && state.buttonsAdded) {
                removeButtons();
            }
        } catch (error) {
            // Silent error handling - don't break the page
        } finally {
            state.isChecking = false;
        }
    }

    // Debounced check for performance
    function scheduleCheck(delay = 150) {
        if (state.checkTimeout) {
            clearTimeout(state.checkTimeout);
        }

        state.checkTimeout = setTimeout(() => {
            checkPaywall();
            state.checkTimeout = null;
        }, delay);
    }

    // Optimized mutation observer with smart filtering
    function createObserver() {
        return new MutationObserver((mutations) => {
            let shouldCheck = false;

            // Efficient mutation analysis
            for (const mutation of mutations) {
                if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                    // Check only Element nodes for relevant changes
                    for (const node of mutation.addedNodes) {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            const element = node;

                            // Check if added node or its children contain paywall indicators
                            if (element.matches?.(PAYWALL_SELECTORS.join(',')) ||
                                element.querySelector?.(PAYWALL_SELECTORS.join(','))) {
                                shouldCheck = true;
                                break;
                            }
                        }
                    }

                    if (shouldCheck) break;
                }
            }

            if (shouldCheck) {
                scheduleCheck();
            }
        });
    }

    // Handle URL changes efficiently
    function handleUrlChange() {
        const newUrl = window.location.href;

        if (newUrl !== state.currentUrl) {
            state.currentUrl = newUrl;
            removeButtons();
            scheduleCheck(300); // Slight delay for page transition
        }
    }

    // Optimized history API interception
    function interceptHistory() {
        const originalPushState = history.pushState;
        const originalReplaceState = history.replaceState;

        history.pushState = function(...args) {
            const result = originalPushState.apply(this, args);
            setTimeout(handleUrlChange, 0);
            return result;
        };

        history.replaceState = function(...args) {
            const result = originalReplaceState.apply(this, args);
            setTimeout(handleUrlChange, 0);
            return result;
        };
    }

    // Initialize with optimal timing
    function initialize() {
        // Immediate check if DOM is ready
        if (document.readyState !== 'loading') {
            scheduleCheck(0);
        }

        // Setup observers and listeners
        if (!state.observer) {
            state.observer = createObserver();

            // Wait for body to be available
            const startObserving = () => {
                if (document.body) {
                    state.observer.observe(document.body, {
                        childList: true,
                        subtree: true,
                        attributes: true,
                        attributeFilter: ['data-testid', 'class', 'data-source']
                    });
                } else {
                    setTimeout(startObserving, 50);
                }
            };

            startObserving();
        }

        // Event listeners with passive option for performance
        window.addEventListener('popstate', handleUrlChange, { passive: true });

        // Handle visibility changes
        document.addEventListener('visibilitychange', () => {
            if (!document.hidden) {
                scheduleCheck(50);
            }
        }, { passive: true });

        // DOM ready fallback
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', () => scheduleCheck(0), { once: true });
        }
    }

    // Cleanup function
    function cleanup() {
        if (state.observer) {
            state.observer.disconnect();
            state.observer = null;
        }

        if (state.checkTimeout) {
            clearTimeout(state.checkTimeout);
            state.checkTimeout = null;
        }

        removeButtons();
        state.isChecking = false;
    }

    // Handle page unload
    window.addEventListener('beforeunload', cleanup, { passive: true });

    // Start everything
    interceptHistory();
    initialize();

})();