MichaelB / mobile.de-infinite-scroll

// ==UserScript==
// @name		mobile.de-infinite-scroll
// @description		Adds infinite scroll to https://mobile.de
// @copyright		2022, MichaelB
// @license		MIT
// @version		2.0.0
// @author		MichaelB
// @match		https://www.mobile.de/*
// @run-at		document-idle
// ==/UserScript==

(function () {
    'use strict';

    /******************************************************************************
    Copyright (c) Microsoft Corporation.

    Permission to use, copy, modify, and/or distribute this software for any
    purpose with or without fee is hereby granted.

    THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
    REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
    AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
    INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
    LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
    OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
    PERFORMANCE OF THIS SOFTWARE.
    ***************************************************************************** */

    function __awaiter(thisArg, _arguments, P, generator) {
        function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
        return new (P || (P = Promise))(function (resolve, reject) {
            function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
            function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
            function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
            step((generator = generator.apply(thisArg, _arguments || [])).next());
        });
    }

    const makeEndOfPageEventListener = (params) => {
        const self = {
            timer: null,
            timeout: params.timeout,
            margin: params.margin,
            callback: null,
        };
        return {
            enable: enable(self),
            disable: disable(self),
        };
    };
    const enable = (self) => (callback) => {
        if (self.callback !== null) {
            throw new Error("[Scroll listener] Already enabled");
        }
        self.callback = callbackWrapper(self, callback);
        document.addEventListener("scroll", self.callback, false);
    };
    const disable = (self) => () => {
        if (self.callback === null) {
            throw new Error("[Scroll listener] Already disabled");
        }
        document.removeEventListener("scroll", self.callback);
        self.callback = null;
    };
    const callbackWrapper = (self, callback) => () => __awaiter(void 0, void 0, void 0, function* () {
        if (self.timer !== null) {
            clearTimeout(self.timer);
        }
        self.timer = setTimeout(() => {
            if (window.scrollY <
                document.body.scrollHeight - window.innerHeight - self.margin) {
                return;
            }
            callback();
        }, self.timeout);
    });

    const makeDataLoader = (params) => {
        const self = {
            target: params.target,
            fetchDataForPageWithNumber: fetchDataForPageWithNumber(params.target),
            running: false,
            pageNumber: params.target.getPageNumber(),
            lastOperation: null,
        };
        return {
            next: next(self),
            revert: revert(self),
        };
    };
    const next = (self) => () => __awaiter(void 0, void 0, void 0, function* () {
        if (self.running) {
            throw new Error("[RawData loader] Cannot load next page until previous one finishes loading");
        }
        self.running = true;
        const oldPageNumber = self.pageNumber;
        const newPageNumber = oldPageNumber + 1;
        const result = yield self.fetchDataForPageWithNumber(newPageNumber);
        self.pageNumber = newPageNumber;
        self.running = false;
        self.lastOperation = "next";
        return result;
    });
    const revert = (self) => () => {
        switch (self.lastOperation) {
            case "next":
                self.pageNumber--;
                break;
            case "previous":
                self.pageNumber++;
                break;
            default:
                throw new Error("[Data loader] Invalid last operation");
        }
    };
    const fetchDataForPageWithNumber = (target) => (pageNumber) => __awaiter(void 0, void 0, void 0, function* () {
        const location = target.buildLocationForDataWithPageNumber(pageNumber);
        const page = yield fetch(location.href);
        const text = yield page.text();
        const contentType = page.headers.get("content-type");
        return {
            text,
            contentType,
        };
    });

    const makePageManipulator = (params) => {
        const self = {
            target: params.target,
        };
        return {
            addResults: addResults$1(self),
        };
    };
    const addResults$1 = (self) => (elements) => {
        self.target.addResults(elements);
    };

    const makeMobileDeTarget = () => {
        return {
            setup: setup(),
            getPageNumber: getPageNumber(),
            setPageNumber: setPageNumber(),
            buildLocationForDataWithPageNumber: buildLocationForPageWithNumber(),
            transformResults: transformResults(),
            processResults: processResults(),
            addResults: addResults(),
        };
    };
    const setup = () => () => {
        const sections = document.querySelectorAll("section");
        const pageDials = [...sections].find((element) => element.classList.contains("pagination"));
        if (!pageDials) {
            throw new Error("[Mobile.de target] Cannot perform setup");
        }
        pageDials.remove();
    };
    const getPageNumber = () => () => {
        const pathName = window.location.pathname;
        const pageNumberStringMatch = pathName.match(/pgn:(\d+)/);
        if (pageNumberStringMatch === null) {
            return 1;
        }
        const pageNumberString = pageNumberStringMatch[1];
        if (pageNumberString === undefined) {
            return 1;
        }
        return Number(pageNumberString);
    };
    const setPageNumber = () => (pageNumber) => {
        const location = buildLocationForPageWithNumber()(pageNumber);
        window.history.replaceState(null, "", `${location.path},pgn:${pageNumber}`);
    };
    const buildLocationForPageWithNumber = () => (pageNumber) => {
        const oldPageNumber = getPageNumber()();
        const oldPathName = window.location.pathname;
        const host = window.location.host;
        const pathName = oldPathName.replace(`,pgn:${oldPageNumber}`, "");
        return {
            href: `${host}/${pathName}pgn:${pageNumber}`,
            path: pathName,
            host,
        };
    };
    const transformResults = () => (rawData) => {
        if (!rawData.contentType) {
            throw new Error(`[Mobile.de target] Content type not valid: ${rawData.contentType}`);
        }
        const trimmedContentType = rawData.contentType.split(";")[0];
        if (!trimmedContentType ||
            ![
                "application/xhtml+xml",
                "application/xml",
                "image/svg+xml",
                "text/html",
                "text/xml",
            ].includes(trimmedContentType)) {
            throw new Error(`[Mobile.de target] Content type not valid: ${trimmedContentType}`);
        }
        const document = new DOMParser().parseFromString(rawData.text, trimmedContentType);
        const list = document.body.querySelector('div[class="result-list-section js-result-list-section u-clearfix"]');
        if (!list) {
            throw new Error("[Mobile.de target] Cannot find list on page");
        }
        return [...list.children];
    };
    const processResults = () => (elements) => {
        const images = [...elements].map((element) => {
            var _a;
            const image = element.querySelector("img");
            if (image === null ||
                !image.classList.contains("img-thumbnail") ||
                !image.classList.contains("img-lazy")) {
                return element;
            }
            const source = (_a = image.attributes.getNamedItem("data-src")) === null || _a === void 0 ? void 0 : _a.value;
            if (!source) {
                return element;
            }
            image.src = source;
            const loadingAnimation = element.querySelector('div[class="loading-lazy"]');
            if (loadingAnimation) {
                loadingAnimation.remove();
            }
            return element;
        });
        return images;
    };
    const addResults = () => (elements) => {
        const list = document.body.querySelector('div[class="result-list-section js-result-list-section u-clearfix"]');
        if (!list) {
            throw new Error("[Mobile.de target] List not found");
        }
        elements.forEach((element) => list.appendChild(element));
    };

    const makeResultsTransformer = (params) => {
        const self = {
            target: params.target,
        };
        return {
            transform: transform(self),
        };
    };
    const transform = (self) => (rawData) => {
        return self.target.transformResults(rawData);
    };

    const makeResultsProcessor = (params) => {
        const self = {
            target: params.target,
        };
        return {
            process: process(self),
        };
    };
    const process = (self) => (elements) => {
        return self.target.processResults(elements);
    };

    const makeSetup = (params) => {
        const self = {
            target: params.target,
        };
        return {
            perform: perform(self),
        };
    };
    const perform = (self) => () => {
        self.target.setup();
    };

    const main = () => __awaiter(void 0, void 0, void 0, function* () {
        const target = makeMobileDeTarget();
        const endOfPageEventListener = makeEndOfPageEventListener({
            margin: 100,
            timeout: 300,
        });
        const setup = makeSetup({ target });
        const dataLoader = makeDataLoader({ target });
        const resultsTransformer = makeResultsTransformer({ target });
        const resultsProcessor = makeResultsProcessor({ target });
        const pageManipulator = makePageManipulator({ target });
        setup.perform();
        endOfPageEventListener.enable(() => __awaiter(void 0, void 0, void 0, function* () {
            try {
                const rawData = yield dataLoader.next();
                const elements = resultsTransformer.transform(rawData);
                const processedElements = resultsProcessor.process(elements);
                pageManipulator.addResults(processedElements);
            }
            catch (err) {
                console.error(err);
                endOfPageEventListener.disable();
                dataLoader.revert();
            }
        }));
    });

    main();

})();