forvo.com - Make download button actually download audios

Fix the download button to actually download the audio instead of opening the login screen

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        forvo.com - Make download button actually download audios
// @namespace   secretx_scripts
// @match       *://forvo.com/word/*
// @match       *://*.forvo.com/word/*
// @version     2023.12.16
// @author      SecretX
// @description Fix the download button to actually download the audio instead of opening the login screen
// @grant       GM.xmlHttpRequest
// @run-at      document-start
// @icon        https://i.imgur.com/3hA6TF1.png
// @license     GNU LGPLv3
// ==/UserScript==

const forvoServerUrl = "https://audio12.forvo.com";

function doRequest(httpMethod, url) {
    return new Promise((resolve, reject) => {
        GM.xmlHttpRequest({
            method: httpMethod.toUpperCase(),
            url: url,
            onload: resolve,
            onerror: reject,
            responseType: "blob",
            timeout: 6000,
        });
    });
}

function extractUrl(element) {
    const play = element.getAttribute("onclick");
    // We are interested in Forvo's javascript Play function which takes in some parameters to play the audio
    // Example: Play(3060224,'OTQyN...','OTQyN..',false,'Yy9wL2NwXzk0MjYzOTZfNzZfMzM1NDkxNS5tcDM=','Yy9wL...','h')
    // Match anything that isn't commas, parentheses or quotes to capture the function arguments
    // Regex will match something like ["Play", "3060224", ...]
    const playArgs = play.match(/([^',\(\)]+)/g);

    // Forvo has two locations for mp3, /audios/mp3 and just /mp3
    // /audios/mp3 is normalized and has the filename in the 5th argument of Play base64 encoded
    // /mp3 is raw and has the filename in the 2nd argument of Play encoded
    try {
        const file = atob(playArgs[5]);  // Something like this: v/p/vp_9478059_76_3731369.mp3
        return `${forvoServerUrl}/audios/mp3/${file}`;
    } catch (e) {
        // Some pronunciations don't have a normalized version so fallback to raw
        const file = atob(playArgs[2]);  // Something like this: 9478059/76/9478059_76_3731369.mp3
        return `${forvoServerUrl}/mp3/${file}`;
    }
}

function getPlayButtonFromDownloadButton(downloadButton) {
    let currentElement = downloadButton;
    for (let i = 0; i < 5; i++) {
        currentElement = currentElement.parentElement;
    }
    console.info("Current element", currentElement);
    return currentElement.querySelector(".play");
}

function makeCopyOfDownloadButton(downloadButton) {
    const innerSpan = downloadButton.querySelector("* > span");
    const text = innerSpan.innerText;

    // Create the new download button, following the structure of the old one
    const newDownloadButton = document.createElement(downloadButton.tagName.toLowerCase());
    newDownloadButton.className = downloadButton.className;
    const newInnerSpan = document.createElement(innerSpan.tagName.toLowerCase());
    newInnerSpan.className = innerSpan.className;
    newInnerSpan.innerText = text;
    newDownloadButton.appendChild(newInnerSpan);

    return newDownloadButton;
}

window.addEventListener("DOMContentLoaded", function () {
    const downloadButtons = Array.from(document.querySelectorAll(".download") ?? []);
    if (downloadButtons.length === 0) {
        console.debug("No download buttons found on this page, so not fixing anything...");
        return;
    }

    for (const downloadButton of downloadButtons) {
        const newDownloadButton = makeCopyOfDownloadButton(downloadButton);

        const playButton = getPlayButtonFromDownloadButton(downloadButton);
        console.info("Play button", playButton);
        const url = extractUrl(playButton);
        const fileName = url.substring(url.lastIndexOf("/") + 1);

        // Add the download functionality to the new download button
        newDownloadButton.addEventListener("click", () => {
            console.info(`Downloading mp3 from: ${url}`);

            doRequest("GET", url).then((response) => {
                if (response.status !== 200) {
                    console.error("Error on downloading mp3", response);
                    return
                }
                const blob = response.response;
                const url = URL.createObjectURL(blob);
                const a = document.createElement("a");
                a.style.display = "none";
                a.href = url;
                a.download = fileName;
                document.body.appendChild(a);
                a.click();
                window.URL.revokeObjectURL(url);

                console.info(`Downloaded '${fileName}'!`);
            }).catch((e) => {
                console.error("Error while downloading mp3!", e);
            });
        });

        // Replace the old download button with the new one
        downloadButton.replaceWith(newDownloadButton);
    }

    console.info(`Fixed ${downloadButtons.length} download buttons!`);
}, false);