forvo.com - Make download button actually download audios

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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);