Compare commits

..

No commits in common. "master" and "v0.10.0-rc.2" have entirely different histories.

41 changed files with 1056 additions and 3860 deletions

View file

@ -20,8 +20,6 @@
"no-useless-constructor": "off", "no-useless-constructor": "off",
"@typescript-eslint/no-useless-constructor": "error", "@typescript-eslint/no-useless-constructor": "error",
"no-dupe-class-members": "off", "no-dupe-class-members": "off",
"no-void": "off",
"no-use-before-define": "off",
"@typescript-eslint/no-dupe-class-members": "error", "@typescript-eslint/no-dupe-class-members": "error",
"@typescript-eslint/no-floating-promises": "warn", "@typescript-eslint/no-floating-promises": "warn",
"@typescript-eslint/member-delimiter-style": "warn", "@typescript-eslint/member-delimiter-style": "warn",

View file

@ -1,21 +0,0 @@
API_URL=https://addons.mozilla.org/api/v4/addons/addon/$EXT_ID/versions/
# wait for maximum 15 min
for i in {1..15}
do
url=$(\
wget -q $API_URL -O - | \
jq -r ".results[] | select(.version==\"$VERSION\") | .files[0].url" \
)
if [ -n "$url" ]; then
echo "v$VERSION available!"
wget -nv $url -P ${OUT_DIR:-./}
exit
else
echo "v$VERSION unavailable"
fi
sleep 1m
done

View file

@ -13,16 +13,11 @@ on:
ref: ref:
description: 'The branch, tag or SHA to release from' description: 'The branch, tag or SHA to release from'
required: false required: false
chrome_ext_url:
description: 'URL to the Chrome Extension crx'
required: true
env: env:
VERSION: ${{ github.event.inputs.version }} VERSION: ${{ github.event.inputs.version }}
NPM_TAG: ${{ github.event.inputs.npm_tag }} NPM_TAG: ${{ github.event.inputs.npm_tag }}
REF: ${{ github.event.inputs.ref || github.sha }} REF: ${{ github.event.inputs.ref || github.sha }}
ARTIFACTS_DIR: ./.artifacts
CHROME_EXT_URL: ${{ github.event.inputs.chrome_ext_url }}
jobs: jobs:
release: release:
@ -53,75 +48,13 @@ jobs:
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} # 0301... NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} # 0301...
- name: NPM Publish msdl
run: |
cd ./src/msdl
sed -i "s/%VERSION%/$VERSION/" package.json
npm publish --tag $NPM_TAG
cd -
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish Firefox Extension
id: web-ext-build
uses: kewisch/action-web-ext@v1
continue-on-error: true
with:
cmd: sign
source: dist/ext.zip
channel: listed
apiKey: ${{ secrets.AMO_SIGN_KEY }}
apiSecret: ${{ secrets.AMO_SIGN_SECRET }}
- run: |
mkdir -p $ARTIFACTS_DIR
cp dist/main.js $ARTIFACTS_DIR/musescore-downloader.user.js
cp dist/ext.zip $ARTIFACTS_DIR/musescore-downloader.webextension.zip
wget -q $CHROME_EXT_URL -P $ARTIFACTS_DIR/
wget -q https://github.com/Xmader/musescore-downloader/archive/$REF.tar.gz -O $ARTIFACTS_DIR/source.tar.gz
- run: bash ./.github/workflows/get-signed-ext.sh
env:
EXT_ID: musescore-downloader
OUT_DIR: ${{ env.ARTIFACTS_DIR }}
- name: Upload to IPFS
uses: aquiladev/ipfs-action@v0.1.5
id: ipfs
with:
path: ${{ env.ARTIFACTS_DIR }}
service: infura
verbose: true
- name: Github Release - name: Github Release
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
IPFS_HASH: ${{ steps.ipfs.outputs.hash }}
run: | run: |
cd $ARTIFACTS_DIR
rm *.tar.gz
files=$(ls .)
assets=()
for f in $files; do [ -f "$f" ] && assets+=(-a "$f"); done
SHORT_SHA=$(echo $REF | cut -c 1-7)
hub release create \ hub release create \
"${assets[@]}" \ -a "dist/main.js#userscript.js" \
-a "dist/ext.zip#webextension.zip" \
-m v$VERSION \ -m v$VERSION \
-m "IPFS Hash: [$IPFS_HASH](https://ipfs.io/ipfs/$IPFS_HASH)" \
-m "Guess what? Mirrors!<br><https://github.com/musescore/MuseScore/tree/$SHORT_SHA><br><https://github.com/github/dmca/tree/$SHORT_SHA>" \
-t $REF \ -t $REF \
v$VERSION v$VERSION
- name: Archive to archive.org
continue-on-error: true
env:
REPO: ${{ github.repository }}
run: |
URL="https://github.com/$REPO/releases/"
curl "https://web.archive.org/save/" \
--compressed -s \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-raw "url=$URL&capture_all=on" \
| grep github

1
.gitignore vendored
View file

@ -67,4 +67,3 @@ typings/
dist/cache dist/cache
dist/ext* dist/ext*
dist/cli.js

View file

@ -15,5 +15,4 @@
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": true "source.fixAll.eslint": true
}, },
"typescript.tsdk": "node_modules/typescript/lib",
} }

1
CNAME
View file

@ -1 +0,0 @@
msdl.librescore.org

View file

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2019-2021 Xmader Copyright (c) 2019-2020 Xmader
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

273
README.md
View file

@ -1,13 +1,12 @@
# musescore-downloader # musescore-downloader
**English** | [简体中文](#musescore-downloader-1) | [Español](#musescore-downloader-2) | [Italian](#musescore-downloader-3) **English** | [简体中文](#musescore-downloader-1)
> download sheet music from musescore.com for free, no login or Musescore Pro required > download sheet music from musescore.com for free, no login or Musescore Pro required
**Star this project on [Github](https://github.com/Xmader/musescore-downloader) and [Gitlab](https://gitlab.com/Xmader/musescore-downloader)** (Mirror) **Star this project on [Github](https://github.com/Xmader/musescore-downloader) and [Gitlab](https://gitlab.com/Xmader/musescore-downloader)** (Mirror)
**Rate this on [Greasy Fork](https://greasyfork.org/scripts/391931)**
[![Discord](https://img.shields.io/discord/774491656643674122?color=7289da&label=Discord&logo=discord)](https://discord.gg/DKu7cUZ4XQ)
Need dataset of musescore.com for analysis / machine learning? try [musescore-dataset](https://github.com/Xmader/musescore-dataset). Need dataset of musescore.com for analysis / machine learning? try [musescore-dataset](https://github.com/Xmader/musescore-dataset).
@ -38,34 +37,13 @@ There is an article on their website: [Score download becomes a part of the Pro
## Installation ## Installation
### CLI Usage
(recommended, more bulletproof)
1. Install Node.js LTS (https://nodejs.org/)
2. Open a command line terminal or command prompt
3. Type `npx msdl`, enter
(`npx msdl` will always run the latest version)
4. Follow the instructions
[source code](/src/cli.ts)
### Install as Userscript
This script is available as a [Userscript](https://en.wikipedia.org/wiki/Userscript). To use this Userscript, you need to first install a [user script manager](https://greasyfork.org/en/help/installing-user-scripts), like Tampermonkey. This script is available as a [Userscript](https://en.wikipedia.org/wiki/Userscript). To use this Userscript, you need to first install a [user script manager](https://greasyfork.org/en/help/installing-user-scripts), like Tampermonkey.
1. Install [Tampermonkey](https://www.tampermonkey.net/) Install from [Greasy Fork](https://greasyfork.org/scripts/391931)
2. ~~Install from [Greasy Fork](https://greasyfork.org/scripts/391931).~~ [#42](https://github.com/Xmader/musescore-downloader/issues/42) View this project on [Github](https://github.com/Xmader/musescore-downloader) | [Gitlab](https://gitlab.com/Xmader/musescore-downloader) (Mirror)
Install this script from <https://msdl.librescore.org/install.user.js>
### Install as Web Extension This repo is also available on IPFS to avoid DMCA takedown.
The alternative method is to install this script as a Chrome or Firefox extension.
You may install the browser extension directly from [addons.mozilla.org (for Firefox)](https://addons.mozilla.org/en-US/firefox/addon/musescore-downloader/) or [chrome web store (for Chrome and Chromium based browsers)](https://chrome.google.com/webstore/detail/mhdlcdhakmmikknpefblmnhdhjloanjc).
The up-to-date version can be found on the [Github Releases](https://github.com/Xmader/musescore-downloader/releases) page.
## Building Instructions ## Building Instructions
@ -77,12 +55,6 @@ npm run build # build as User Script
npm run pack:ext # pack Web Extension npm run pack:ext # pack Web Extension
``` ```
## Mirrors
* View this project on [Github](https://github.com/Xmader/musescore-downloader) (Main repo) | [Gitlab](https://gitlab.com/Xmader/musescore-downloader) (Mirror)
* This repo is also available on IPFS to avoid DMCA takedown: [ipns://msdl.librescore.org](https://ipfs.io/ipns/msdl.librescore.org/)
## Feedback ## Feedback
[Github Issues](https://github.com/Xmader/musescore-downloader/issues) [Github Issues](https://github.com/Xmader/musescore-downloader/issues)
@ -118,14 +90,13 @@ No, the API document is on https://developers.musescore.com/.
# musescore-downloader # musescore-downloader
[English](#musescore-downloader) | **简体中文** | [Español](#musescore-downloader-2) [English](#musescore-downloader) | **简体中文**
*中英文版本项目 README 分开撰写,中文版较不完整。如果有能力,请阅读英文版。* *中英文版本项目 README 分开撰写,中文版较不完整。如果有能力,请阅读英文版。*
> 免登录、免 Musescore Pro下载 musescore.com 上的曲谱 > 免登录、免 Musescore Pro下载 musescore.com 上的曲谱
**在 [Github](https://github.com/Xmader/musescore-downloader) 和 [Gitlab](https://gitlab.com/Xmader/musescore-downloader)** (镜像) **上给项目打星** **在 [Github](https://github.com/Xmader/musescore-downloader) 和 [Gitlab](https://gitlab.com/Xmader/musescore-downloader)** (镜像) **上给项目打星**
**在 [Greasy Fork](https://greasyfork.org/scripts/391931) 上给项目评分**
[![Discord](https://img.shields.io/discord/774491656643674122?color=7289da&label=Discord&logo=discord)](https://discord.gg/DKu7cUZ4XQ)
![](https://cdn.statically.io/gh/Xmader/musescore-downloader/master/screenshot.png?env=dev) ![](https://cdn.statically.io/gh/Xmader/musescore-downloader/master/screenshot.png?env=dev)
@ -141,234 +112,10 @@ No, the API document is on https://developers.musescore.com/.
脚本以 [Userscript](https://en.wikipedia.org/wiki/Userscript) 的形式提供,需要事先安装一个 [用户脚本管理器](https://greasyfork.org/zh-CN/help/installing-user-scripts),例如 Tampermonkey 脚本以 [Userscript](https://en.wikipedia.org/wiki/Userscript) 的形式提供,需要事先安装一个 [用户脚本管理器](https://greasyfork.org/zh-CN/help/installing-user-scripts),例如 Tampermonkey
在 [Greasy Fork](https://greasyfork.org/scripts/391931) 上查看、安装
在 [Github](https://github.com/Xmader/musescore-downloader) 上查看、讨论、更新 在 [Github](https://github.com/Xmader/musescore-downloader) 上查看、讨论、更新
## License ## License
MIT MIT
---
# musescore-downloader
[English](#musescore-downloader) | [简体中文](#musescore-downloader-1) | **Español**
> descarga partituras de musescore.com de forma gratuita, no se requiere iniciar sesión o Musescore Pro
**Dale una estrella a este proyecto en [Github](https://github.com/Xmader/musescore-downloader) y [Gitlab](https://gitlab.com/Xmader/musescore-downloader)** (Respaldo)
[![Discord](https://img.shields.io/discord/774491656643674122?color=7289da&label=Discord&logo=discord)](https://discord.gg/DKu7cUZ4XQ)
¿Necesita un conjunto de datos de musescore.com para análisis / Machine Learning? prueba [musescore-dataset](https://github.com/Xmader/musescore-dataset).
![](https://cdn.statically.io/gh/Xmader/musescore-downloader/master/screenshot.png?env=dev)
## Fair Use
Solo para fines de investigación y estudio
## Acerca de
Se requiere Musescore Pro ($ 6.99/mes) para descargar partituras de musescore.com desde hace poco.
(Sin embargo, hace unos meses, se podían descargar gratis).
La compañía Musescore dijo que es debido a derechos de autor y licencias, y deben pagar a los propietarios de los derechos de autor.
Muchas canciones en musescore.com ya son de ** dominio público **, lo que significa que el autor las publicó en el dominio público o que el autor ha estado muerto durante más de 70 años.
¿Necesitan pagarle a compositores que murieron hace cientos de años?
*Actualización: Las partituras de dominio público se pueden descargar sin Musescore Pro ahora, pero aún se necesita de una cuenta para acceder a ellas.*
Además, hay muchos autores de partituras en musescore.com que crearon sus propias canciones y las publicaron bajo la licencia [CC-BY-**NC** (Creative Commons Attribution-**NonCommercial**)](https://creativecommons.org/licenses/by-nc/4.0/).
¿No es ilegal que las vendan para generar **ganancias**?
*Nota: Poner anuncios (para vender Musescore Pro) en el sitio web también significa que lo usan para generar ingresos. *
Esto es absolutamente inaceptable, y el único propósito es generar ganancias robando.
Hay un artículo en su sitio web: [La descarga de partituras se convierte en parte de la suscripción Pro](https://musescore.com/groups/improving-musescore-com/discuss/5044610)
## Instalación
### Instalar como Script de Usuario
Este script está disponible como un [Script de usuario](https://en.wikipedia.org/wiki/Userscript). Para utilizar este script, primero debe instalar un [administrador de script de usuario](https://greasyfork.org/en/help/installing-user-scripts), como Tampermonkey.
1. Instala [Tampermonkey](https://www.tampermonkey.net/)
2. ~~Instalar desde [Greasy Fork](https://greasyfork.org/scripts/391931).~~ [#42](https://github.com/Xmader/musescore-downloader/issues/42)
Instale este script desde <https://msdl.librescore.org/install.user.js>
### Instalar como Extensión Web
El método alternativo es instalar este script como una extensión de Chrome o Firefox.
Puedes instalar la extensión del navegador directamente desde [addons.mozilla.org (para Firefox)](https://addons.mozilla.org/en-US/firefox/addon/musescore-downloader/) o [chrome web store (para Chrome y navegadores basados en Chromium)](https://chrome.google.com/webstore/detail/fmmnkcdlphpgbdcdfnjkldfljedbbokp).
La versión más reciente se puede encontrar en la página de [Github Releases](https://github.com/Xmader/musescore-downloader/releases).
## Instrucciones de Compilación
Asegúrate de tener el entorno [Node.js](https://nodejs.org/en/) instalado.
```bash
npm install
npm run build # build as User Script
npm run pack:ext # pack Web Extension
```
## Archivos
* Mira este proyecto en [Github](https://github.com/Xmader/musescore-downloader) (Principal) | [Gitlab](https://gitlab.com/Xmader/musescore-downloader) (Respaldo)
* Este repositorio también está disponible en IPFS para evitar la eliminación por parte de DMCA. [ipns://msdl.librescore.org](https://ipfs.io/ipns/msdl.librescore.org/)
## Feedback
[Github Issues](https://github.com/Xmader/musescore-downloader/issues)
## Licencia
MIT
## Acerca de la Solicitud de Eliminación
Recibí un [correo de solicitud de elimnacion l](https://github.com/Xmader/musescore-downloader/issues/5) de uno de los desarrolladores de Musescore, pero me gustaría decir algo en contra de esto.
> Todo el contenido que no es de dominio público en musescore.com tiene licencia de los principales editores de música (Alfred, EMI, Sony, etc.). Distribuir contenido de musical licenciado de Musescore.com de forma gratuita viola sus derechos.
En primer lugar, si violé los derechos de los principales editores de música, ellos deberían enviar la solicitud de eliminación en lugar de los desarrolladores de Musescore.
En segundo lugar, musescore.com no es un simple sitio web para compartir música. Los autores de partituras deben transcribir y recomponer las canciones originales en partituras, no solo copiar archivos de cualquier otro lugar a musescore.com. Como resultado, la licencia debe centrarse en los derechos de transcripción/recomposición de los autores de las partituras, en lugar de los derechos de compartir la música en algunos sitios web.
En tercer lugar, la propiedad de los derechos de autor de los contenidos de musescore.com no está clara. No todas las canciones que no son de dominio público en musescore.com son propiedad de las principales editoriales de música. Hay muchos pequeños editores de música y compositores independientes; Las canciones pueden tener licencias gratuitas como Creative Commons. Además, hay muchos autores que crearon sus propias canciones y publicaron la partitura en musescore.com. ¿Musescore.com le paga a esos autores?
Si no podemos ver pruebas de que musescore.com realmente paga la tarifa de licencia a los propietarios de los derechos de autor, podemos pensar que es solo una excusa para obtener ganancias robando.
> utilizo ilegalmente nuestra API privada con contenido de música licenciada.
No, el documento de la API está en https://developers.musescore.com/.
**Lanzaré una alternativa de código abierto (GPLv3), sin servidor, offline, y totalmente gratuita a musescore.com, [LibreScore](https://github.com/LibreScore). ETodos son bienvenidos a unirse al desarrollo del proyecto abriendo un problema o [enviándome un correo electrónico.](mailto:i@xmader.com).**
**Además, estoy desarrollando musescore.js. Podría convertir un archivo mscz en cualquier formato que admita el software Musescore, y en el navegador.** Dado que el software Musescore es de código abierto bajo [GPL](https://github.com/musescore/MuseScore/blob/master/LICENSE.GPL), Podría traducir el código fuente a js o compilarlo en asm.js/WASM.
---
# musescore-downloader
[English](#musescore-downloader) | [简体中文](#musescore-downloader-1) | [Español](#musescore-downloader-2) | **Italian**
> Scarica spartiti da musescore.com gratuitamente, non è richisto login o Musescore Pro.
**Avvia questo progetto su [Github](https://github.com/Xmader/musescore-downloader) e [Gitlab](https://gitlab.com/Xmader/musescore-downloader)** (Mirror)
[![Discord](https://img.shields.io/discord/774491656643674122?color=7289da&label=Discord&logo=discord)](https://discord.gg/DKu7cUZ4XQ)
Hai bisogno di datset di musescore.com per l'analisi/machine learning? Prova [musescore-dataset](https://github.com/Xmader/musescore-dataset).
![](https://cdn.statically.io/gh/Xmader/musescore-downloader/master/screenshot.png?env=dev)
## Utilizzo leale
Solo per scopi di ricerca e studio
## In breve
Di recente è necessario un account Musescore Pro ($6,99/mese) per scaricare spartiti da musescore.com.
(Tuttavia, alcuni mesi fa, era possibile scaricare gratuitamente.)
La società Musescore ha affermato che a causa di copyright e licenze, devono pagare i proprietari del copyright.
Molte musiche su musescore.com sono già di **Pubblico Dominio**, il che significa che o l'autore le ha pubblicate in pubblico dominio o l'autore è morto da oltre 70 anni.
Devono pagare anche i compositori che sono morti centinaia di anni fa?
*Aggiornamento: gli spartiti di Dominio Pubblico, ora possono essere scaricati senza Musescore Pro, ma hai ancora bisogno di un account per poter scaricare.*
Inoltre, ci sono molti autori di spartiti su musescore.com che hanno creato le proprie canzoni e le hanno pubblicate sotto licenza [CC-BY-**NC** (Creative Commons Attribution-**NonCommercial**)](https://creativecommons.org/licenses/by-nc/4.0/).
È illegale venderli **a scopo di lucro**?
*Nota: Mettere annunci (per vendere Musescore Pro) sul sito web significa anche che lo usano per generare entrate.*
Questo è assolutamente inaccettabile e l'unico scopo è trarre profitto dal furto.
C'è un articolo sul loro sito web: [Il download degli spartiti diventa parte dell'abbonamento Pro](https://musescore.com/groups/improving-musescore-com/discuss/5044610)
## Installazione
### Utilizzo della CLI
(consigliato)
1. Installa Node.js LTS (https://nodejs.org/)
2. Apri il terminale CMD o Powershell
3. Digita `npx msdl`, e premi invio
(`npx msdl` eseguirà sempre l'ultima versione)
4. Seguire le istruzioni
[codice sorgente](/src/cli.ts)
### Installa come Userscript
Questo script è disponibile come [Userscript](https://en.wikipedia.org/wiki/Userscript). Per utilizzare questo Userscript, è necessario prima installare un [gestore di script utente].(https://greasyfork.org/en/help/installing-user-scripts), come Tampermonkey.
1. Installa [Tampermonkey](https://www.tampermonkey.net/)
2. ~~Installa da [Greasy Fork](https://greasyfork.org/scripts/391931).~~ [#42](https://github.com/Xmader/musescore-downloader/issues/42)
Installa lo script da <https://msdl.librescore.org/install.user.js>
### Installa come estensione web
Il metodo alternativo consiste nell'installare questo script come estensione per Chrome o Firefox.
Puoi installare l'estensione del browser direttamente da [addons.mozilla.org (per Firefox)](https://addons.mozilla.org/en-US/firefox/addon/musescore-downloader/) o dal [web store di Chrome (per browser basati su Chrome e Chromium)](https://chrome.google.com/webstore/detail/mhdlcdhakmmikknpefblmnhdhjloanjc).
La versione aggiornata può essere trovata nella pagina [Github Releases](https://github.com/Xmader/musescore-downloader/releases).
## Istruzioni per il building
Assicurati di avere installato [Node.js](https://nodejs.org/en/).
```bash
npm install
npm run build # build come script utente
npm run pack:ext # pack Web Extension
```
## Mirrors
* Visualizza questo progetto su [Github](https://github.com/Xmader/musescore-downloader) (Repo principale) | [Gitlab](https://gitlab.com/Xmader/musescore-downloader) (Mirror)
* Questo repo è disponibile anche su IPFS per evitare la rimozione DMCA: [ipns://msdl.librescore.org](https://ipfs.io/ipns/msdl.librescore.org/)
## Feedback
[Problemi con GitHub](https://github.com/Xmader/musescore-downloader/issues)
## Licenza
MIT
## Informazioni sulla richiesta di rimozione
Ho ricevuto un'e-mail di [richiesta di rimozione](https://github.com/Xmader/musescore-downloader/issues/5)da uno degli sviluppatori di Musescore, ma ho qualcosa da ridire.
> Non tutti i contenuti di pubblico dominio su musescore.com sono concessi in licenza dai principali editori musicali (Alfred, EMI, Sony, ecc.). State distribuendo gratuitamente contenuti musicali con licenza da Musescore.com violando i loro diritti.
In primo luogo, se violi i diritti dei principali editori musicali, la richiesta di rimozione dovrebbe essere inviata da loro invece che dagli sviluppatori di Musescore.
In secondo luogo, musescore.com non è un semplice sito di condivisione di musica. Gli autori di spartiti devono trascrivere e riorganizzare le canzoni originali in spartiti, non solo copiare file da qualche altra parte su musescore.com. Di conseguenza, la licenza dovrebbe concentrarsi sui diritti di trascrizione / riorganizzazione degli autori di spartiti, invece che sui diritti di condivisione della musica su alcuni siti web.
In terzo luogo, la proprietà del copyright dei contenuti su musescore.com non è chiara. Non tutte le canzoni di pubblico dominio su musescore.com sono di proprietà dei principali editori musicali. Ci sono molti piccoli editori musicali e cantautori indipendenti. Le canzoni potrebbero essere concesse in licenza con licenze gratuite come Creative Commons. Inoltre, ci sono molti autori che hanno creato le proprie canzoni e pubblicato gli spartiti su musescore.com; Musescore.com paga questi autori?
Se ci sono prove che musescore.com paga davvero la tassa di licenza ai proprietari del copyright, potremmo pensare che sia solo una scusa per ottenere profitto dal furto.
> utilizzi illegalmente la nostra API privata con contenuti musicali con licenza.
No, il documento API è su https://developers.musescore.com/.
**Avvierò un'alternativa open source (GPLv3), serverless, offline-first, frontend-first e totalmente gratuita a musescore.com, [LibreScore](https://github.com/LibreScore). Tutti sono invitati a partecipare allo sviluppo del progetto aprendo una issue o [inviandomi un'e-mail](mailto:i@xmader.com).**
**Inoltre, sto sviluppando musescore.js. Potrebbe convertire un file mscz in qualsiasi formato supportato dal software Musescore e nel browser.** Poiché il software Musescore è open source sotto [GPL](https://github.com/musescore/MuseScore/blob/master/LICENSE.GPL), potrei tradurre il codice sorgente in js o compilarlo in asm.js/WASM.
---

1754
dist/main.js vendored

File diff suppressed because it is too large Load diff

View file

@ -1 +0,0 @@
dist/main.js

659
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,8 @@
{ {
"name": "musescore-downloader", "name": "musescore-downloader",
"version": "0.24.1", "version": "0.10.0-rc.1",
"description": "download sheet music from musescore.com for free, no login or Musescore Pro required | 免登录、免 Musescore Pro免费下载 musescore.com 上的曲谱", "description": "download sheet music from musescore.com for free, no login or Musescore Pro required | 免登录、免 Musescore Pro免费下载 musescore.com 上的曲谱",
"main": "dist/main.js", "main": "dist/main.js",
"bin": "dist/cli.js",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/Xmader/musescore-downloader.git" "url": "git+https://github.com/Xmader/musescore-downloader.git"
@ -16,16 +15,11 @@
"homepage": "https://github.com/Xmader/musescore-downloader#readme", "homepage": "https://github.com/Xmader/musescore-downloader#readme",
"homepage_url": "https://github.com/Xmader/musescore-downloader#readme", "homepage_url": "https://github.com/Xmader/musescore-downloader#readme",
"manifest_version": 2, "manifest_version": 2,
"browser_specific_settings": {
"gecko": {
"id": "{69856097-6e10-42e9-acc7-0c063550c7b8}"
}
},
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
"content_scripts": [ "content_scripts": [
{ {
"matches": [ "matches": [
"*://*.musescore.com/*/*" "*://musescore.com/*/*"
], ],
"js": [ "js": [
"src/web-ext.js" "src/web-ext.js"
@ -37,38 +31,30 @@
"dist/main.js" "dist/main.js"
], ],
"dependencies": { "dependencies": {
"@librescore/fonts": "^0.4.0", "pdfkit": "git+https://github.com/Xmader/pdfkit.git",
"@librescore/sf3": "^0.3.0", "svg-to-pdfkit": "^0.1.8",
"detect-node": "^2.0.4", "webmscore": "^0.10.4"
"inquirer": "^7.3.3",
"node-fetch": "^2.6.1",
"ora": "^5.1.0",
"webmscore": "^0.18.0"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-json": "^4.1.0", "@rollup/plugin-json": "^4.0.0",
"@types/file-saver": "^2.0.1", "@types/file-saver": "^2.0.1",
"@types/inquirer": "^7.3.1", "@types/pdfkit": "^0.10.4",
"@types/pdfkit": "^0.10.6", "rollup": "^1.26.3",
"pdfkit": "git+https://github.com/Xmader/pdfkit.git",
"rollup": "^1.32.1",
"rollup-plugin-commonjs": "^10.1.0", "rollup-plugin-commonjs": "^10.1.0",
"rollup-plugin-node-builtins": "^2.1.2", "rollup-plugin-node-builtins": "^2.1.2",
"rollup-plugin-node-globals": "^1.4.0", "rollup-plugin-node-globals": "^1.4.0",
"rollup-plugin-node-resolve": "^5.2.0", "rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-string": "^3.0.0",
"rollup-plugin-typescript": "^1.0.1", "rollup-plugin-typescript": "^1.0.1",
"svg-to-pdfkit": "^0.1.8", "tslib": "^1.10.0",
"tslib": "^1.14.1", "typescript": "^3.9.7"
"typescript": "^4.1.2"
}, },
"scripts": { "scripts": {
"build": "rollup -c", "build": "rollup -c",
"watch": "npm run build -- --watch", "watch": "npm run build -- --watch",
"start:ext": "web-ext run --url https://musescore.com/", "start:ext": "web-ext run",
"start:ext-chrome": "npm run start:ext -- -t chromium", "start:ext-chrome": "npm run start:ext -- -t chromium",
"pack:ext": "zip -r dist/ext.zip manifest.json src/web-ext.js dist/main.js", "pack:ext": "zip -r dist/ext.zip manifest.json src/web-ext.js dist/main.js",
"bump-version:patch": "npm version patch --no-git-tag && npm run build", "bump-version:patch": "npm version patch --no-git-tag",
"bump-version:minor": "npm version minor --no-git-tag && npm run build" "bump-version:minor": "npm version minor --no-git-tag"
} }
} }

View file

@ -4,7 +4,6 @@ import commonjs from "rollup-plugin-commonjs"
import builtins from "rollup-plugin-node-builtins" import builtins from "rollup-plugin-node-builtins"
import nodeGlobals from "rollup-plugin-node-globals" import nodeGlobals from "rollup-plugin-node-globals"
import json from "@rollup/plugin-json" import json from "@rollup/plugin-json"
import { string } from "rollup-plugin-string"
import fs from "fs" import fs from "fs"
const getBannerText = () => { const getBannerText = () => {
@ -15,12 +14,7 @@ const getBannerText = () => {
return bannerText return bannerText
} }
const getWrapper = (startL, endL) => { const plugins = [
const js = fs.readFileSync("./src/wrapper.js", "utf-8")
return js.split(/\n/g).slice(startL, endL).join("\n")
}
const basePlugins = [
typescript({ typescript({
target: "ES6", target: "ES6",
sourceMap: false, sourceMap: false,
@ -39,8 +33,11 @@ const basePlugins = [
extensions: [".js", ".ts"] extensions: [".js", ".ts"]
}), }),
json(), json(),
string({ builtins(),
include: "**/*.css", nodeGlobals({
dirname: false,
filename: false,
baseDir: false,
}), }),
{ {
/** /**
@ -48,7 +45,7 @@ const basePlugins = [
* @param {string} code * @param {string} code
* @param {string} id * @param {string} id
*/ */
transform (code, id) { transform(code, id) {
if (id.includes("tslib")) { if (id.includes("tslib")) {
code = code.split(/\r?\n/g).slice(15).join("\n") code = code.split(/\r?\n/g).slice(15).join("\n")
} }
@ -59,16 +56,6 @@ const basePlugins = [
}, },
] ]
const plugins = [
...basePlugins,
builtins(),
nodeGlobals({
dirname: false,
filename: false,
baseDir: false,
}),
]
export default [ export default [
{ {
input: "src/worker.ts", input: "src/worker.ts",
@ -88,19 +75,9 @@ export default [
format: "iife", format: "iife",
sourcemap: false, sourcemap: false,
banner: getBannerText, banner: getBannerText,
intro: () => getWrapper(0, -1), intro: "// fix for Greasemonkey\nwindow.eval('(' + function () {",
outro: () => getWrapper(-1) outro: "}.toString() + ')()')"
}, },
plugins, plugins,
}, },
{
input: "src/cli.ts",
output: {
file: "dist/cli.js",
format: "cjs",
banner: "#!/usr/bin/env node",
sourcemap: false,
},
plugins: basePlugins,
},
] ]

View file

@ -1,53 +0,0 @@
/* eslint-disable no-extend-native */
/* eslint-disable @typescript-eslint/ban-types */
/**
* make hooked methods "native"
*/
export const makeNative = (() => {
const l = new Map<Function, Function>()
hookNative(Function.prototype, 'toString', (_toString) => {
return function () {
if (l.has(this)) {
const _fn = l.get(this) || parseInt // "function () {\n [native code]\n}"
if (l.has(_fn)) { // nested
return _fn.toString()
} else {
return _toString.call(_fn) as string
}
}
return _toString.call(this) as string
}
}, true)
return (fn: Function, original: Function) => {
l.set(fn, original)
}
})()
export function hookNative<T extends object, M extends (keyof T)> (
target: T,
method: M,
hook: (originalFn: T[M], detach: () => void) => T[M],
async = false,
): void {
// reserve for future hook update
const _fn = target[method]
const detach = () => {
target[method] = _fn // detach
}
// This script can run before anything on the page,
// so setting this function to be non-configurable and non-writable is no use.
const hookedFn = hook(_fn, detach)
target[method] = hookedFn
if (!async) {
makeNative(hookedFn as any, _fn as any)
} else {
setTimeout(() => {
makeNative(hookedFn as any, _fn as any)
})
}
}

View file

@ -1,83 +0,0 @@
div {
width: 422px;
right: 0;
margin: 0 18px 18px 0;
text-align: center;
align-items: center;
font-family: 'Inter', 'Helvetica neue', Helvetica, sans-serif;
position: absolute;
z-index: 9999;
background: #f6f6f6;
min-width: 230px;
/* pass the scroll event through the btns background */
pointer-events: none;
}
@media screen and (max-width: 950px) {
div {
width: auto !important;
}
}
button {
width: 178px !important;
min-width: 178px;
height: 40px;
color: #fff;
background: #2e68c0;
cursor: pointer;
pointer-events: auto;
margin-bottom: 8px;
margin-right: 8px;
padding: 4px 12px;
justify-content: start;
align-self: center;
font-size: 16px;
border-radius: 6px;
border: 0;
display: inline-flex;
position: relative;
font-family: inherit;
}
/* fix `View in LibreScore` button text overflow */
button:last-of-type {
width: unset !important;
}
button:hover {
background: #1a4f9f;
}
/* light theme btn */
button.light {
color: #2e68c0;
background: #e1effe;
}
button.light:hover {
background: #c3ddfd;
}
svg {
display: inline-block;
margin-right: 5px;
width: 20px;
height: 20px;
margin-top: auto;
margin-bottom: auto;
}
span {
margin-top: auto;
margin-bottom: auto;
}

View file

@ -1,216 +1,90 @@
import { ScoreInfo } from './scoreinfo'
import { loadMscore, WebMscore } from './mscore' import { loadMscore, WebMscore } from './mscore'
import { useTimeout, windowOpenAsync, console, attachShadow, DISCORD_URL } from './utils'
import { isGmAvailable, _GM } from './gm'
import i18n from './i18n'
// @ts-ignore
import btnListCss from './btn.css'
type BtnElement = HTMLButtonElement type BtnElement = HTMLButtonElement
export enum ICON { /**
DOWNLOAD = 'M9.6 2.4h4.8V12h2.784l-5.18 5.18L6.823 12H9.6V2.4zM19.2 19.2H4.8v2.4h14.4v-2.4z', * Select the original Download Button
LIBRESCORE = 'm5.4837 4.4735v10.405c-1.25-0.89936-3.0285-0.40896-4.1658 0.45816-1.0052 0.76659-1.7881 2.3316-0.98365 3.4943 1 1.1346 2.7702 0.70402 3.8817-0.02809 1.0896-0.66323 1.9667-1.8569 1.8125-3.1814v-5.4822h8.3278v9.3865h9.6438v-2.6282h-6.4567v-12.417c-4.0064-0.015181-8.0424-0.0027-12.06-0.00676zm0.54477 2.2697h8.3278v1.1258h-8.3278v-1.1258z', */
} export const getDownloadBtn = (): BtnElement => {
const btnsDiv = document.querySelector('.score-right .buttons-wrapper') || document.querySelectorAll('aside > section > section > div')[3]
const btn = btnsDiv.querySelector('button, .button') as BtnElement
btn.onclick = null
const getBtnContainer = (): HTMLDivElement => { // fix the icon of the download btn
const els = [...document.querySelectorAll('span')] // if the `btn` seleted was a `Print` btn, replace the `print` icon with the `download` icon
const el = els.find(b => { const svgPath: SVGPathElement | null = btn.querySelector('svg > path')
const text = b?.textContent?.replace(/\s/g, '') || '' if (svgPath) {
return text.includes('Download') || text.includes('Print') svgPath.setAttribute('d', 'M9.6 2.4h4.8V12h2.784l-5.18 5.18L6.823 12H9.6V2.4zM19.2 19.2H4.8v2.4h14.4v-2.4z')
}) as HTMLDivElement | null }
const btnParent = el?.parentElement?.parentElement as HTMLDivElement | undefined
if (!btnParent || !(btnParent instanceof HTMLDivElement)) throw new Error('btn parent not found')
return btnParent
}
const buildDownloadBtn = (icon: ICON, lightTheme = false) => { if (btn.nodeName.toLowerCase() === 'button') {
const btn = document.createElement('button') btn.setAttribute('style', 'width: 205px !important')
btn.type = 'button' } else {
if (lightTheme) btn.className = 'light' btn.dataset.target = ''
}
// build icon svg element
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '0 0 24 24')
const svgPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
svgPath.setAttribute('d', icon)
svgPath.setAttribute('fill', lightTheme ? '#2e68c0' : '#fff')
svg.append(svgPath)
const textNode = document.createElement('span')
btn.append(svg, textNode)
return btn return btn
} }
const cloneBtn = (btn: HTMLButtonElement) => {
const n = btn.cloneNode(true) as HTMLButtonElement
n.onclick = btn.onclick
return n
}
function getScrollParent (node: HTMLElement): HTMLElement {
if (node.scrollHeight > node.clientHeight) {
return node
} else {
return getScrollParent(node.parentNode as HTMLElement)
}
}
function onPageRendered (getEl: () => HTMLElement) {
return new Promise<HTMLElement>((resolve) => {
const observer = new MutationObserver(() => {
try {
const el = getEl()
if (el) {
observer.disconnect()
resolve(el)
}
} catch { }
})
observer.observe(document.querySelector('div > section') ?? document.body, { childList: true, subtree: true })
})
}
interface BtnOptions { interface BtnOptions {
readonly name: string; readonly name: string;
readonly action: BtnAction; readonly action: BtnAction;
readonly disabled?: boolean; readonly disabled?: boolean;
readonly tooltip?: string; readonly tooltip?: string;
readonly icon?: ICON;
readonly lightTheme?: boolean;
}
export enum BtnListMode {
InPage,
ExtWindow,
} }
export class BtnList { export class BtnList {
private readonly list: BtnElement[] = []; private readonly list: BtnElement[] = [];
constructor (private getBtnParent: () => HTMLDivElement = getBtnContainer) { } constructor (private templateBtn: BtnElement) { }
add (options: BtnOptions): BtnElement { add (options: BtnOptions): BtnElement {
const btnTpl = buildDownloadBtn(options.icon ?? ICON.DOWNLOAD, options.lightTheme) const btn = this.templateBtn.cloneNode(true) as HTMLButtonElement
const setText = (btn: BtnElement) => {
const textNode = btn.querySelector('span') const textNode = [...btn.childNodes].find((x) => {
return (str: string): void => { const txt = x.textContent as string
if (textNode) textNode.textContent = str return txt.includes('Download') || txt.includes('Print')
} }) as HTMLSpanElement
// Anti-detection:
// musescore will send a track event "MSCZDOWNLOADER_INSTALLED" to its backend
// if detected "Download MSCZ"
const _property = 'textContent'
const _set = textNode['__lookupSetter__'](_property)
Object.defineProperty(textNode, _property, {
set (v) { _set.call(textNode, v) },
get () { return 'Download' },
})
const setText = (str: string): void => {
textNode.textContent = str
} }
setText(btnTpl)(options.name) setText(options.name)
btnTpl.onclick = function () { btn.onclick = (): void => {
const btn = this as BtnElement options.action(options.name, btn, setText)
options.action(options.name, btn, setText(btn))
} }
this.list.push(btnTpl) this.list.push(btn)
if (options.disabled) { if (options.disabled) {
btnTpl.disabled = options.disabled btn.disabled = options.disabled
} }
if (options.tooltip) { if (options.tooltip) {
btnTpl.title = options.tooltip btn.title = options.tooltip
} }
// add buttons to the userscript manager menu return btn
if (isGmAvailable('registerMenuCommand')) {
// eslint-disable-next-line no-void
void _GM.registerMenuCommand(options.name, () => {
options.action(options.name, btnTpl, () => undefined)
})
}
return btnTpl
}
private _positionBtns (anchorDiv: HTMLDivElement, newParent: HTMLDivElement) {
let { top } = anchorDiv.getBoundingClientRect()
top += window.scrollY // relative to the entire document instead of viewport
if (top > 0) {
newParent.style.top = `${top}px`
} else {
newParent.style.top = '0px'
}
}
private _commit () {
const btnParent = document.querySelector('div') as HTMLDivElement
const shadow = attachShadow(btnParent)
// style the shadow DOM
const style = document.createElement('style')
style.innerText = btnListCss
shadow.append(style)
// hide buttons using the shadow DOM
const slot = document.createElement('slot')
shadow.append(slot)
const newParent = document.createElement('div')
newParent.append(...this.list.map(e => cloneBtn(e)))
shadow.append(newParent)
// default position
newParent.style.top = `${window.innerHeight - newParent.getBoundingClientRect().height}px`
void onPageRendered(this.getBtnParent).then((anchorDiv: HTMLDivElement) => {
const pos = () => this._positionBtns(anchorDiv, newParent)
pos()
// reposition btns when window resizes
window.addEventListener('resize', pos, { passive: true })
// reposition btns when scrolling
const scroll = getScrollParent(anchorDiv)
scroll.addEventListener('scroll', pos, { passive: true })
})
return btnParent
} }
/** /**
* replace the template button with the list of new buttons * replace the template button with the list of new buttons
*/ */
async commit (mode: BtnListMode = BtnListMode.InPage): Promise<void> { commit (): void {
switch (mode) { this.templateBtn.replaceWith(...this.list)
case BtnListMode.InPage: {
let el: Element
try {
el = this._commit()
} catch {
// fallback to BtnListMode.ExtWindow
return this.commit(BtnListMode.ExtWindow)
}
const observer = new MutationObserver(() => {
// check if the buttons are still in document when dom updates
if (!document.contains(el)) {
// re-commit
// performance issue?
el = this._commit()
}
})
observer.observe(document, { childList: true, subtree: true })
break
}
case BtnListMode.ExtWindow: {
const div = this._commit()
const w = await windowOpenAsync(undefined, '', undefined, 'resizable,width=230,height=270')
// eslint-disable-next-line no-unused-expressions
w?.document.body.append(div)
window.addEventListener('unload', () => w?.close())
break
}
default:
throw new Error('unknown BtnListMode')
}
} }
} }
@ -219,6 +93,13 @@ type BtnAction = (btnName: string, btnEl: BtnElement, setText: (str: string) =>
// eslint-disable-next-line @typescript-eslint/no-namespace // eslint-disable-next-line @typescript-eslint/no-namespace
export namespace BtnAction { export namespace BtnAction {
export const PROCESSING_TEXT = 'Processing…'
export const ERROR_TEXT = '❌Download Failed!'
const deprecationNotice = (btnName: string): string => {
return `DEPRECATED!\nUse \`${btnName}\` inside \`Individual Parts\` instead.\n(This may still work. Click \`OK\` to continue.)`
}
type Promisable<T> = T | Promise<T> type Promisable<T> = T | Promise<T>
type UrlInput = Promisable<string> | (() => Promisable<string>) type UrlInput = Promisable<string> | (() => Promisable<string>)
@ -227,26 +108,29 @@ export namespace BtnAction {
else return url else return url
} }
export const download = (url: UrlInput, fallback?: () => Promisable<void>, timeout?: number, target?: '_blank'): BtnAction => { export const openUrl = (url: UrlInput): BtnAction => {
return process(async (): Promise<void> => { return process(async (): Promise<any> => {
window.open(await normalizeUrlInput(url))
})
}
export const download = (url: UrlInput): BtnAction => {
return process(async (): Promise<any> => {
const _url = await normalizeUrlInput(url) const _url = await normalizeUrlInput(url)
const a = document.createElement('a') const a = document.createElement('a')
a.href = _url a.href = _url
if (target) a.target = target
a.dispatchEvent(new MouseEvent('click')) a.dispatchEvent(new MouseEvent('click'))
}, fallback, timeout) })
} }
export const openUrl = download export const mscoreWindow = (fn: (w: Window, score: WebMscore, processingTextEl: ChildNode) => any): BtnAction => {
export const mscoreWindow = (scoreinfo: ScoreInfo, fn: (w: Window, score: WebMscore, processingTextEl: ChildNode) => any): BtnAction => {
return async (btnName, btn, setText) => { return async (btnName, btn, setText) => {
const _onclick = btn.onclick const _onclick = btn.onclick
btn.onclick = null btn.onclick = null
setText(i18n('PROCESSING')()) setText(BtnAction.PROCESSING_TEXT)
const w = await windowOpenAsync(btn, '') as Window const w = window.open('') as Window
const txt = document.createTextNode(i18n('PROCESSING')()) const txt = document.createTextNode(BtnAction.PROCESSING_TEXT)
w.document.body.append(txt) w.document.body.append(txt)
// set page hooks // set page hooks
@ -264,42 +148,25 @@ export namespace BtnAction {
btn.onclick = _onclick btn.onclick = _onclick
}) })
score = await loadMscore(scoreinfo, w) score = await loadMscore(w)
fn(w, score, txt) fn(w, score, txt)
} }
} }
export const process = (fn: () => any, fallback?: () => Promisable<void>, timeout = 10 * 60 * 1000 /* 10min */): BtnAction => { export const process = (fn: () => any): BtnAction => {
return async (name, btn, setText): Promise<void> => { return async (name, btn, setText): Promise<void> => {
const _onclick = btn.onclick const _onclick = btn.onclick
btn.onclick = null btn.onclick = null
setText(i18n('PROCESSING')()) setText(PROCESSING_TEXT)
try { try {
await useTimeout(fn(), timeout) await fn()
setText(name) setText(name)
} catch (err) { } catch (err) {
setText(ERROR_TEXT)
console.error(err) console.error(err)
if (fallback) {
// use fallback
await fallback()
setText(name)
} else {
setText(i18n('BTN_ERROR')())
// ask user to send Discord message
alert(
'❌Download Failed!\n\n' +
'Send your URL to the #dataset-bugs channel ' +
'in the LibreScore Community Discord server:\n' + DISCORD_URL,
)
// open Discord on 'OK'
const a = document.createElement('a')
a.href = DISCORD_URL
a.target = '_blank'
a.dispatchEvent(new MouseEvent('click'))
}
} }
btn.onclick = _onclick btn.onclick = _onclick
@ -308,7 +175,7 @@ export namespace BtnAction {
export const deprecate = (action: BtnAction): BtnAction => { export const deprecate = (action: BtnAction): BtnAction => {
return (name, btn, setText) => { return (name, btn, setText) => {
alert(i18n('DEPRECATION_NOTICE')(name)) alert(deprecationNotice(name))
// eslint-disable-next-line @typescript-eslint/no-unsafe-return // eslint-disable-next-line @typescript-eslint/no-unsafe-return
return action(name, btn, setText) return action(name, btn, setText)
} }

View file

@ -1,206 +0,0 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable no-void */
import fs from 'fs'
import path from 'path'
import os from 'os'
import { fetchMscz, setMscz, MSCZ_URL_SYM } from './mscz'
import { loadMscore, INDV_DOWNLOADS, WebMscore } from './mscore'
import { ScoreInfo, ScoreInfoHtml, ScoreInfoObj, getActualId } from './scoreinfo'
import { getLibreScoreLink } from './librescore-link'
import { escapeFilename, DISCORD_URL } from './utils'
import { isNpx, getVerInfo, getSelfVer } from './npm-data'
import i18n from './i18n'
const inquirer: typeof import('inquirer') = require('inquirer')
const ora: typeof import('ora') = require('ora')
const chalk: typeof import('chalk') = require('chalk')
const SCORE_URL_PREFIX = 'https://(s.)musescore.com/'
const SCORE_URL_REG = /https:\/\/(s\.)?musescore\.com\//
const EXT = '.mscz'
interface Params {
fileInit: string;
confirmed: boolean;
part: number;
types: number[];
dest: string;
}
void (async () => {
const arg = process.argv[2]
if (['-v', '--version'].includes(arg)) { // ran with flag -v or --version, `msdl -v`
console.log(getSelfVer()) // print musescore-downloader version
return // exit process
}
// Determine platform and paste message
const platform = os.platform()
let pasteMessage = ''
if (platform === 'win32') {
pasteMessage = 'right-click to paste'
} else if (platform === 'linux') {
pasteMessage = 'usually Ctrl+Shift+V to paste'
} // For MacOS, no hint is needed because the paste shortcut is universal.
let scoreinfo: ScoreInfo
let librescoreLink: Promise<string> | undefined
// ask for the page url or path to local file
const { fileInit } = await inquirer.prompt<Params>({
type: 'input',
name: 'fileInit',
message: 'Score URL or path to local MSCZ file:',
suffix: '\n ' +
`(starts with "${SCORE_URL_PREFIX}" or local filepath ends with "${EXT}") ` +
`${chalk.bgGray(pasteMessage)}\n `,
validate (input: string) {
return input &&
(
!!input.match(SCORE_URL_REG) ||
(input.endsWith(EXT) && fs.statSync(input).isFile())
)
},
default: arg,
})
const isLocalFile = fileInit.endsWith(EXT)
if (!isLocalFile) {
// request scoreinfo
scoreinfo = await ScoreInfoHtml.request(fileInit)
try {
await getActualId(scoreinfo as any)
} catch (err) {
console.error(err)
}
// confirmation
const { confirmed } = await inquirer.prompt<Params>({
type: 'confirm',
name: 'confirmed',
message: 'Continue?',
prefix: `${chalk.yellow('!')} ` +
`ID: ${scoreinfo.id}\n ` +
`Title: ${scoreinfo.title}\n `,
default: true,
})
if (!confirmed) return
// initiate LibreScore link request
librescoreLink = getLibreScoreLink(scoreinfo)
librescoreLink.catch(() => '') // silence this unhandled Promise rejection
// print a blank line to the terminal
console.log()
} else {
scoreinfo = new ScoreInfoObj(0, path.basename(fileInit, EXT))
}
const spinner = ora({
text: i18n('PROCESSING')(),
color: 'blue',
spinner: 'bounce',
indent: 0,
}).start()
let score: WebMscore
let metadata: import('webmscore/schemas').ScoreMetadata
try {
if (!isLocalFile) {
// fetch mscz file from the dataset, and cache it for side effect
await fetchMscz(scoreinfo)
} else {
// load local file
const data = await fs.promises.readFile(fileInit)
await setMscz(scoreinfo, data.buffer)
}
spinner.info('MSCZ file loaded')
if (!isLocalFile) {
spinner.info(`File URL: ${scoreinfo.store.get(MSCZ_URL_SYM) as string}`)
}
if (librescoreLink) {
try {
spinner.info(`${i18n('VIEW_IN_LIBRESCORE')()}: ${await librescoreLink}`)
} catch { } // it doesn't affect the main feature
}
spinner.start()
// load score using webmscore
score = await loadMscore(scoreinfo)
metadata = await score.metadata()
spinner.info('Score loaded by webmscore')
} catch (err) {
spinner.fail(err.message)
spinner.info(
'Send your URL to the #dataset-bugs channel in the LibreScore Community Discord server:\n ' +
DISCORD_URL,
)
return
}
spinner.succeed('OK\n')
// build part choices
const partChoices = metadata.excerpts.map(p => ({ name: p.title, value: p.id }))
// add the "full score" option as a "part"
partChoices.unshift({ value: -1, name: i18n('FULL_SCORE')() })
// build filetype choices
const typeChoices = INDV_DOWNLOADS.map((d, i) => ({ name: d.name, value: i }))
// part selection
const { part } = await inquirer.prompt<Params>({
type: 'list',
name: 'part',
message: 'Part Selection',
choices: partChoices,
})
const partName = partChoices[part + 1].name
await score.setExcerptId(part)
// filetype selection
const { types } = await inquirer.prompt<Params>({
type: 'checkbox',
name: 'types',
message: 'Filetype Selection',
choices: typeChoices,
validate (input: number[]) {
return input.length >= 1
},
})
const filetypes = types.map(i => INDV_DOWNLOADS[i])
// destination directory
const { dest } = await inquirer.prompt<Params>({
type: 'input',
name: 'dest',
message: 'Destination Directory:',
validate (input: string) {
return input && fs.statSync(input).isDirectory()
},
default: process.cwd(),
})
// export files
const fileName = scoreinfo.fileName || await score.titleFilenameSafe()
spinner.start()
await Promise.all(
filetypes.map(async (d) => {
const data = await d.action(score)
const n = `${fileName} - ${escapeFilename(partName)}.${d.fileExt}`
const f = path.join(dest, n)
await fs.promises.writeFile(f, data)
spinner.info(`Saved ${chalk.underline(f)}`)
spinner.start()
}),
)
spinner.succeed('OK')
if (!isNpx()) {
const { installed, latest, isLatest } = await getVerInfo()
if (!isLatest) {
console.log(chalk.yellowBright(`\nYour installed version (${installed}) of the musescore-downloader CLI is not the latest one (${latest})!\nRun npm i -g musescore-downloader@${latest} to update.`))
}
}
})()

View file

@ -1,115 +1,64 @@
/* eslint-disable no-extend-native */ /* eslint-disable no-extend-native */
/* eslint-disable @typescript-eslint/no-unsafe-return */
import { hookNative } from './anti-detection' import scoreinfo from './scoreinfo'
import { console } from './utils' import { webpackHook } from './webpack-hook'
const FILE_URL_MODULE_ID = 'iNJA'
const MAGIC_REG = /^\d+(img|mp3|midi)\d(.+)$/
type FileType = 'img' | 'mp3' | 'midi' type FileType = 'img' | 'mp3' | 'midi'
const TYPE_REG = /type=(img|mp3|midi)/ const getApiUrl = (id: number, type: FileType, index: number): string => {
// proxy
return `https://musescore.now.sh/api/jmuse?id=${id}&type=${type}&index=${index}`
}
/** /**
* I know this is super hacky. * I know this is super hacky.
*/ */
const magicHookConstr = (() => { let magic: Promise<string> | string = new Promise((resolve) => {
const l = {} // reserve for future hook update
const target = String.prototype
const method = 'charCodeAt'
const _fn = target[method]
try { // This script can run before anything on the page,
const p = Object.getPrototypeOf(document.body) // so setting this function to be non-configurable and non-writable is no use.
Object.setPrototypeOf(document.body, null) target[method] = function (i) {
const m = this.match(MAGIC_REG)
hookNative(document.body, 'append', () => { if (m) {
return function (...nodes: Node[]) { resolve(m[2])
p.append.call(this, ...nodes) magic = m[2]
target[method] = _fn // detach
if (nodes[0].nodeName === 'IFRAME') {
const iframe = nodes[0] as HTMLIFrameElement
const w = iframe.contentWindow as Window
hookNative(w, 'fetch', () => {
return function (url, init) {
const token = init?.headers?.Authorization
if (typeof url === 'string' && token) {
const m = url.match(TYPE_REG)
console.debug(url, token, m)
if (m) {
const type = m[1]
// eslint-disable-next-line no-unused-expressions
l[type]?.(token)
}
}
return fetch(url, init)
}
})
}
}
})
Object.setPrototypeOf(document.body, p)
} catch (err) {
console.error(err)
}
return async (type: FileType) => {
return new Promise<string>((resolve) => {
l[type] = (token) => {
resolve(token)
magics[type] = token
}
})
}
})()
const magics: Record<FileType, Promise<string>> = {
img: magicHookConstr('img'),
midi: magicHookConstr('midi'),
mp3: magicHookConstr('mp3'),
}
const getApiUrl = (id: number, type: FileType, index: number): string => {
return `/api/jmuse?id=${id}&type=${type}&index=${index}&v2=1`
}
const getApiAuth = async (type: FileType, index: number): Promise<string> => {
// eslint-disable-next-line no-void
void index
const magic = magics[type]
if (magic instanceof Promise) {
// force to retrieve the MAGIC
switch (type) {
case 'midi': {
const el = document.querySelector('button[hasaccess]') as HTMLButtonElement
el.click()
break
}
case 'mp3': {
const el = document.querySelector('button[title="Toggle Play"]') as HTMLButtonElement
el.click()
break
}
case 'img': {
const imgE = document.querySelector('img[src*=score_]')
const nextE = imgE?.parentElement?.nextElementSibling
if (nextE) nextE.scrollIntoView()
break
}
} }
return _fn.call(this, i) as number
} }
})
return magic export const getFileUrl = async (type: FileType, index = 0): Promise<string> => {
} const fileUrlModule = webpackHook(FILE_URL_MODULE_ID, {
'6Ulw' (_, r, t) { // override
export const getFileUrl = async (id: number, type: FileType, index = 0): Promise<string> => { t.d(r, 'a', () => {
const url = getApiUrl(id, type, index) return type
const auth = await getApiAuth(type, index) })
},
const r = await fetch(url, { 'VSrV' (_, r, t) { // override
headers: { t.d(r, 'b', () => {
Authorization: auth, return getApiUrl
})
}, },
}) })
const { info } = await r.json() const fn: (id: number, index: number, cb: (url: string) => any, magic: string) => string = fileUrlModule.a
return info.url as string
if (typeof magic !== 'string') {
// force to retrieve the MAGIC
const el = document.querySelectorAll('.SD7H- > button')[3] as HTMLButtonElement
el.click()
magic = await magic
}
return new Promise((resolve) => {
return fn(scoreinfo.id, index, resolve, magic as string)
})
} }

View file

@ -1,22 +0,0 @@
/**
* UserScript APIs
*/
declare const GM: {
/** https://www.tampermonkey.net/documentation.php#GM_info */
info: Record<string, any>;
/** https://www.tampermonkey.net/documentation.php#GM_registerMenuCommand */
registerMenuCommand (name: string, fn: () => any, accessKey?: string): Promise<number>;
/** https://github.com/Tampermonkey/tampermonkey/issues/881#issuecomment-639705679 */
addElement<K extends keyof HTMLElementTagNameMap> (tagName: K, properties: Record<string, any>): Promise<HTMLElementTagNameMap[K]>;
}
export const _GM = (typeof GM === 'object' ? GM : undefined) as GM
type GM = typeof GM
export const isGmAvailable = (requiredMethod: keyof GM = 'info'): boolean => {
return typeof GM !== 'undefined' &&
typeof GM[requiredMethod] !== 'undefined'
}

View file

@ -1,37 +0,0 @@
import { createLocale } from './utils'
export default createLocale({
'PROCESSING' () {
return 'Processing…' as const
},
'BTN_ERROR' () {
return '❌Download Failed!' as const
},
'DEPRECATION_NOTICE' (btnName: string) {
return `DEPRECATED!\nUse \`${btnName}\` inside \`Individual Parts\` instead.\n(This may still work. Click \`OK\` to continue.)` as const
},
'DOWNLOAD' <T extends string> (fileType: T) {
return `Download ${fileType}` as const
},
'DOWNLOAD_AUDIO' <T extends string> (fileType: T) {
return `Download ${fileType} Audio` as const
},
'IND_PARTS' () {
return 'Individual Parts' as const
},
'IND_PARTS_TOOLTIP' () {
return 'Download individual parts (BETA)' as const
},
'VIEW_IN_LIBRESCORE' () {
return 'View in LibreScore' as const
},
'FULL_SCORE' () {
return 'Full score' as const
},
})

View file

@ -1,37 +0,0 @@
import { createLocale } from './utils'
export default createLocale({
'PROCESSING' () {
return 'Cargando…' as const
},
'BTN_ERROR' () {
return '❌¡Descarga Fallida!' as const
},
'DEPRECATION_NOTICE' (btnName: string) {
return `¡OBSOLETO!\nUtilizar \`${btnName}\` dentro de \`Partes Indivduales\` en su lugar.\n(Esto todavía puede funcionar. Pulsa \`Aceptar\` para continuar.)` as const
},
'DOWNLOAD' <T extends string> (fileType: T) {
return `Descargar ${fileType}` as const
},
'DOWNLOAD_AUDIO' <T extends string> (fileType: T) {
return `Descargar Audio ${fileType}` as const
},
'IND_PARTS' () {
return 'Partes individuales' as const
},
'IND_PARTS_TOOLTIP' () {
return 'Descargar partes individuales (BETA)' as const
},
'VIEW_IN_LIBRESCORE' () {
return 'Visualizar en LibreScore' as const
},
'FULL_SCORE' () {
return 'Partitura Completa' as const
},
})

View file

@ -1,60 +0,0 @@
import isNodeJs from 'detect-node'
import en from './en'
import es from './es'
import it from './it'
import zh from './zh'
export interface LOCALE {
'PROCESSING' (): string;
'BTN_ERROR' (): string;
'DEPRECATION_NOTICE' (btnName: string): string;
'DOWNLOAD' (fileType: string): string;
'DOWNLOAD_AUDIO' (fileType: string): string;
'IND_PARTS' (): string;
'IND_PARTS_TOOLTIP' (): string;
'VIEW_IN_LIBRESCORE' (): string;
'FULL_SCORE' (): string;
}
const locales = (<L extends { [n: string]: LOCALE } /** type checking */> (l: L) => Object.freeze(l))({
en,
es,
it,
zh,
})
// detect browser language
const lang = (() => {
let userLangs: readonly string[]
if (!isNodeJs) {
userLangs = navigator.languages
} else {
const env = process.env
const l = env.LC_ALL || env.LC_MESSAGES || env.LANG || env.LANGUAGE || ''
userLangs = [l.slice(0, 2)]
}
const names = Object.keys(locales)
const _lang = userLangs.find(l => {
// find the first occurrence of valid languages
return names.includes(l)
})
return _lang || 'en'
})()
export type STR_KEYS = keyof LOCALE
export type ALL_LOCALES = typeof locales
export type LANGS = keyof ALL_LOCALES
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export default function i18n<K extends STR_KEYS, L extends LANGS = 'en'> (key: K) {
const locale = locales[lang] as ALL_LOCALES[L]
return locale[key]
}

View file

@ -1,37 +0,0 @@
import { createLocale } from './utils'
export default createLocale({
'PROCESSING' () {
return 'Caricamento…' as const
},
'BTN_ERROR' () {
return '❌Download Fallito!' as const
},
'DEPRECATION_NOTICE' (btnName: string) {
return `¡DEPRECATO!\nUtilizzare \`${btnName}\` all'interno di \`Parti Indivduali\`.\n(Qusto potrebbe funzionare. Cliccare \`Ok\` per continuare.)` as const
},
'DOWNLOAD' <T extends string> (fileType: T) {
return `Scaricare ${fileType}` as const
},
'DOWNLOAD_AUDIO' <T extends string> (fileType: T) {
return `Scaricare ${fileType} Audio` as const
},
'IND_PARTS' () {
return 'Parti Singole' as const
},
'IND_PARTS_TOOLTIP' () {
return 'Scaricare Parti Singole (BETA)' as const
},
'VIEW_IN_LIBRESCORE' () {
return 'Visualizzare in LibreScore' as const
},
'FULL_SCORE' () {
return 'Spartito Completo' as const
},
})

View file

@ -1,9 +0,0 @@
import type { LOCALE } from './'
/**
* type checking only so no missing keys
*/
export function createLocale<OBJ extends LOCALE> (obj: OBJ): OBJ {
return Object.freeze(obj)
}

View file

@ -1,37 +0,0 @@
import { createLocale } from './utils'
export default createLocale({
'PROCESSING' () {
return '处理中…' as const
},
'BTN_ERROR' () {
return '❌下载失败!' as const
},
'DEPRECATION_NOTICE' (btnName: string) {
return `不建议使用\n请使用 \`单独分谱\` 里的 \`${btnName}\` 按钮代替\n这也许仍会起作用。单击\`确定\`以继续。)` as const
},
'DOWNLOAD' <T extends string> (fileType: T) {
return `下载 ${fileType}` as const
},
'DOWNLOAD_AUDIO' <T extends string> (fileType: T) {
return `下载 ${fileType} 音频` as const
},
'IND_PARTS' () {
return '单独分谱' as const
},
'IND_PARTS_TOOLTIP' () {
return '下载单独分谱 (BETA)' as const
},
'VIEW_IN_LIBRESCORE' () {
return '在 LibreScore 中查看' as const
},
'FULL_SCORE' () {
return '完整乐谱' as const
},
})

View file

@ -1,26 +0,0 @@
import { assertRes, getFetch } from './utils'
import { getMainCid } from './mscz'
import { ScoreInfo } from './scoreinfo'
const _getLink = (indexingInfo: string) => {
const { scorepack } = JSON.parse(indexingInfo)
return `https://librescore.org/score/${scorepack as string}`
}
export const getLibreScoreLink = async (scoreinfo: ScoreInfo, _fetch = getFetch()): Promise<string> => {
const mainCid = await getMainCid(scoreinfo, _fetch)
const ref = scoreinfo.getScorepackRef(mainCid)
const url = `https://ipfs.infura.io:5001/api/v0/dag/get?arg=${ref}`
const r0 = await _fetch(url)
if (r0.status !== 500) {
assertRes(r0)
}
const res: { Message: string } | string = await r0.json()
if (typeof res !== 'string') {
// read further error msg
throw new Error(res.Message)
}
return _getLink(res)
}

View file

@ -1,81 +1,111 @@
import './meta' import './meta'
import FileSaver from 'file-saver' import { waitForDocumentLoaded, saveAs } from './utils'
import { waitForSheetLoaded, console } from './utils'
import { downloadPDF } from './pdf' import { downloadPDF } from './pdf'
import { downloadMscz } from './mscz' import { downloadMscz } from './mscz'
import { getFileUrl } from './file' import { getFileUrl } from './file'
import { INDV_DOWNLOADS } from './mscore' import { WebMscore, loadSoundFont } from './mscore'
import { getLibreScoreLink } from './librescore-link' import { getDownloadBtn, BtnList, BtnAction } from './btn'
import { BtnList, BtnAction, BtnListMode, ICON } from './btn' import * as recaptcha from './recaptcha'
import { ScoreInfoInPage, SheetInfoInPage, getActualId } from './scoreinfo' import scoreinfo from './scoreinfo'
import i18n from './i18n'
const { saveAs } = FileSaver
const main = (): void => { const main = (): void => {
const btnList = new BtnList() // init recaptcha
const scoreinfo = new ScoreInfoInPage(document) // eslint-disable-next-line @typescript-eslint/no-floating-promises
const { fileName } = scoreinfo recaptcha.init()
// eslint-disable-next-line no-void const btnList = new BtnList(getDownloadBtn())
void getActualId(scoreinfo) const filename = scoreinfo.fileName
let indvPartBtn: HTMLButtonElement | null = null
const fallback = () => {
// btns fallback to load from MSCZ file (`Individual Parts`)
return indvPartBtn?.click()
}
btnList.add({ btnList.add({
name: i18n('DOWNLOAD')('MSCZ'), name: 'Download MSCZ',
action: BtnAction.process(() => downloadMscz(scoreinfo, saveAs)), action: BtnAction.process(downloadMscz),
}) })
btnList.add({ btnList.add({
name: i18n('DOWNLOAD')('PDF'), name: 'Download PDF',
action: BtnAction.process(() => downloadPDF(scoreinfo, new SheetInfoInPage(document)), fallback, 3 * 60 * 1000 /* 3min */), action: BtnAction.deprecate(
BtnAction.process(downloadPDF),
),
}) })
btnList.add({ btnList.add({
name: i18n('DOWNLOAD')('MXL'), name: 'Download MusicXML',
action: BtnAction.mscoreWindow(scoreinfo, async (w, score) => { action: BtnAction.mscoreWindow(async (w, score) => {
const mxl = await score.saveMxl() const mxl = await score.saveMxl()
const data = new Blob([mxl]) const data = new Blob([mxl])
saveAs(data, `${fileName}.mxl`) saveAs(data, `${filename}.mxl`)
w.close() w.close()
}), }),
}) })
btnList.add({ btnList.add({
name: i18n('DOWNLOAD')('MIDI'), name: 'Download MIDI',
action: BtnAction.download(() => getFileUrl(scoreinfo.id, 'midi'), fallback, 30 * 1000 /* 30s */), action: BtnAction.deprecate(
BtnAction.download(() => getFileUrl('midi')),
),
}) })
btnList.add({ btnList.add({
name: i18n('DOWNLOAD')('MP3'), name: 'Download MP3',
action: BtnAction.download(() => getFileUrl(scoreinfo.id, 'mp3'), fallback, 30 * 1000 /* 30s */), action: BtnAction.download(() => getFileUrl('mp3')),
}) })
indvPartBtn = btnList.add({ btnList.add({
name: i18n('IND_PARTS')(), name: 'Individual Parts',
tooltip: i18n('IND_PARTS_TOOLTIP')(), tooltip: 'Download individual parts (BETA)',
action: BtnAction.mscoreWindow(scoreinfo, async (w, score, txt) => { action: BtnAction.mscoreWindow(async (w, score, txt) => {
const metadata = await score.metadata() const metadata = await score.metadata()
console.log('score metadata loaded by webmscore', metadata) console.log('score metadata loaded by webmscore', metadata)
// add the "full score" option as a "part" // add the "full score" option as a "part"
metadata.excerpts.unshift({ id: -1, title: i18n('FULL_SCORE')(), parts: [] }) metadata.excerpts.unshift({ id: -1, title: 'Full score', parts: [] })
// render the part selection page // render the part selection page
txt.remove() txt.remove()
const fieldset = w.document.createElement('fieldset') const fieldset = w.document.createElement('fieldset')
w.document.body.append(fieldset) w.document.body.append(fieldset)
const downloads = INDV_DOWNLOADS interface IndividualDownload {
name: string;
fileExt: string;
action (score: WebMscore): Promise<Uint8Array>;
}
const downloads: IndividualDownload[] = [
{
name: 'Download PDF',
fileExt: 'pdf',
action: (score) => score.savePdf(),
},
{
name: 'Download Part MSCZ',
fileExt: 'mscz',
action: (score) => score.saveMsc('mscz'),
},
{
name: 'Download Part MusicXML',
fileExt: 'mxl',
action: (score) => score.saveMxl(),
},
{
name: 'Download MIDI',
fileExt: 'mid',
action: (score) => score.saveMidi(true, true),
},
{
name: 'Download FLAC Audio',
fileExt: 'flac',
action: (score) => loadSoundFont(score).then(() => score.saveAudio('flac')),
},
{
name: 'Download OGG Audio',
fileExt: 'ogg',
action: (score) => loadSoundFont(score).then(() => score.saveAudio('ogg')),
},
]
// part selection // part selection
const DEFAULT_PART = -1 // initially select "full score"
for (const excerpt of metadata.excerpts) { for (const excerpt of metadata.excerpts) {
const id = excerpt.id const id = excerpt.id
const partName = excerpt.title const partName = excerpt.title
@ -84,7 +114,7 @@ const main = (): void => {
e.name = 'score-part' e.name = 'score-part'
e.type = 'radio' e.type = 'radio'
e.alt = partName e.alt = partName
e.checked = id === DEFAULT_PART e.checked = id === 0 // initially select the first part
e.onclick = () => { e.onclick = () => {
return score.setExcerptId(id) // set selected part return score.setExcerptId(id) // set selected part
} }
@ -96,7 +126,7 @@ const main = (): void => {
fieldset.append(e, label, br) fieldset.append(e, label, br)
} }
await score.setExcerptId(DEFAULT_PART) await score.setExcerptId(0) // initially select the first part
// submit buttons // submit buttons
for (const d of downloads) { for (const d of downloads) {
@ -115,13 +145,13 @@ const main = (): void => {
// lock the button when processing // lock the button when processing
submitBtn.onclick = null submitBtn.onclick = null
submitBtn.disabled = true submitBtn.disabled = true
submitBtn.value = i18n('PROCESSING')() submitBtn.value = 'Processing…'
const checked = fieldset.querySelector('input:checked') as HTMLInputElement const checked = fieldset.querySelector('input:checked') as HTMLInputElement
const partName = checked.alt const partName = checked.alt
const data = new Blob([await d.action(score)]) const data = new Blob([await d.action(score)])
saveAs(data, `${fileName} - ${partName}.${d.fileExt}`) saveAs(data, `${filename} - ${partName}.${d.fileExt}`)
// unlock button // unlock button
initBtn() initBtn()
@ -132,17 +162,8 @@ const main = (): void => {
}), }),
}) })
btnList.add({ btnList.commit()
name: i18n('VIEW_IN_LIBRESCORE')(),
action: BtnAction.openUrl(() => getLibreScoreLink(scoreinfo)),
tooltip: 'BETA',
icon: ICON.LIBRESCORE,
lightTheme: true,
})
// eslint-disable-next-line @typescript-eslint/no-floating-promises
btnList.commit(BtnListMode.InPage)
} }
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
waitForSheetLoaded().then(main) waitForDocumentLoaded().then(main)

View file

@ -3,19 +3,12 @@
// @namespace https://www.xmader.com/ // @namespace https://www.xmader.com/
// @homepageURL https://github.com/Xmader/musescore-downloader/ // @homepageURL https://github.com/Xmader/musescore-downloader/
// @supportURL https://github.com/Xmader/musescore-downloader/issues // @supportURL https://github.com/Xmader/musescore-downloader/issues
// @updateURL https://msdl.librescore.org/install.user.js
// @downloadURL https://msdl.librescore.org/install.user.js
// @version %VERSION% // @version %VERSION%
// @description download sheet music from musescore.com for free, no login or Musescore Pro required | 免登录、免 Musescore Pro免费下载 musescore.com 上的曲谱 // @description download sheet music from musescore.com for free, no login or Musescore Pro required | 免登录、免 Musescore Pro免费下载 musescore.com 上的曲谱
// @author Xmader // @author Xmader
// @icon https://librescore.org/img/icons/logo.svg
// @match https://musescore.com/*/* // @match https://musescore.com/*/*
// @match https://s.musescore.com/*/*
// @license MIT // @license MIT
// @copyright Copyright (c) 2019-2021 Xmader // @copyright Copyright (c) 2019-2020 Xmader
// @grant unsafeWindow // @grant none
// @grant GM.registerMenuCommand
// @grant GM.addElement
// @grant GM.openInTab
// @run-at document-start // @run-at document-start
// ==/UserScript== // ==/UserScript==

View file

@ -1,37 +1,25 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { fetchMscz } from './mscz' import { fetchMscz } from './mscz'
import { fetchData } from './utils' import { fetchData } from './utils'
import { ScoreInfo } from './scoreinfo'
import isNodeJs from 'detect-node'
import i18n from './i18n'
import { dependencies as depVers } from '../package.json'
const WEBMSCORE_URL = `https://cdn.jsdelivr.net/npm/webmscore@${depVers.webmscore}/webmscore.js` const WEBMSCORE_URL = 'https://cdn.jsdelivr.net/npm/webmscore@0.10/webmscore.js'
// fonts for Chinese characters (CN) and Korean hangul (KR) // fonts for Chinese characters (CN) and Korean hangul (KR)
// JP characters are included in the CN font // JP characters are included in the CN font
const FONT_URLS = ['CN', 'KR'].map(l => `https://cdn.jsdelivr.net/npm/@librescore/fonts@${depVers['@librescore/fonts']}/SourceHanSans${l}.min.woff2`) const FONT_URLS = ['CN', 'KR'].map(l => `https://cdn.jsdelivr.net/npm/@librescore/fonts/SourceHanSans${l}-Regular.woff2`)
const SF3_URL = `https://cdn.jsdelivr.net/npm/@librescore/sf3@${depVers['@librescore/sf3']}/FluidR3Mono_GM.sf3` const SF3_URL = 'https://cdn.jsdelivr.net/npm/@librescore/sf3/FluidR3Mono_GM.sf3'
const SOUND_FONT_LOADED = Symbol('SoundFont loaded') const SOUND_FONT_LOADED = Symbol('SoundFont loaded')
export type WebMscore = import('webmscore').default export type WebMscore = import('webmscore').default
export type WebMscoreConstr = typeof import('webmscore').default
const initMscore = async (w: Window): Promise<WebMscoreConstr> => { const initMscore = async (w: Window) => {
if (!isNodeJs) { // attached to a page if (!w['WebMscore']) {
if (!w['WebMscore']) { // init webmscore (https://github.com/LibreScore/webmscore)
// init webmscore (https://github.com/LibreScore/webmscore) const script = w.document.createElement('script')
const script = w.document.createElement('script') script.src = WEBMSCORE_URL
script.src = WEBMSCORE_URL w.document.body.append(script)
w.document.body.append(script) await new Promise(resolve => { script.onload = resolve })
await new Promise(resolve => { script.onload = resolve })
}
return w['WebMscore'] as WebMscoreConstr
} else { // nodejs
return require('webmscore').default as WebMscoreConstr
} }
} }
@ -40,37 +28,17 @@ const initFonts = () => {
// load CJK fonts // load CJK fonts
// CJK (East Asian) characters will be rendered as "tofu" if there is no font // CJK (East Asian) characters will be rendered as "tofu" if there is no font
if (!fonts) { if (!fonts) {
if (isNodeJs) { fonts = Promise.all(
// module.exports.CN = ..., module.exports.KR = ... FONT_URLS.map(url => fetchData(url)),
const FONTS = Object.values(require('@librescore/fonts')) )
const fs = require('fs')
fonts = Promise.all(
FONTS.map((path: string) => fs.promises.readFile(path) as Promise<Buffer>),
)
} else {
fonts = Promise.all(
FONT_URLS.map(url => fetchData(url)),
)
}
} }
} }
export const loadSoundFont = (score: WebMscore): Promise<void> => { export const loadSoundFont = (score: WebMscore): Promise<void> => {
if (!score[SOUND_FONT_LOADED]) { if (!score[SOUND_FONT_LOADED]) {
const loadPromise = (async () => { const loadPromise = (async () => {
let data: Uint8Array
if (isNodeJs) {
// module.exports.FluidR3Mono = ...
const SF3 = Object.values(require('@librescore/sf3'))[0]
const fs = require('fs')
data = await fs.promises.readFile(SF3)
} else {
data = await fetchData(SF3_URL)
}
await score.setSoundFont( await score.setSoundFont(
data, await fetchData(SF3_URL),
) )
})() })()
score[SOUND_FONT_LOADED] = loadPromise score[SOUND_FONT_LOADED] = loadPromise
@ -78,60 +46,17 @@ export const loadSoundFont = (score: WebMscore): Promise<void> => {
return score[SOUND_FONT_LOADED] as Promise<void> return score[SOUND_FONT_LOADED] as Promise<void>
} }
export const loadMscore = async (scoreinfo: ScoreInfo, w?: Window): Promise<WebMscore> => { export const loadMscore = async (w: Window): Promise<WebMscore> => {
initFonts() initFonts()
const WebMscore = await initMscore(w!) await initMscore(w)
const WebMscore: typeof import('webmscore').default = w['WebMscore']
// parse mscz data // parse mscz data
const data = new Uint8Array( const data = new Uint8Array(
new Uint8Array(await fetchMscz(scoreinfo)), // copy its ArrayBuffer new Uint8Array(await fetchMscz()), // copy its ArrayBuffer
) )
const score = await WebMscore.load('mscz', data, await fonts) const score = await WebMscore.load('mscz', data, await fonts)
await score.generateExcerpts() await score.generateExcerpts()
return score return score
} }
export interface IndividualDownload {
name: string;
fileExt: string;
action (score: WebMscore): Promise<Uint8Array>;
}
export const INDV_DOWNLOADS: IndividualDownload[] = [
{
name: i18n('DOWNLOAD')('PDF'),
fileExt: 'pdf',
action: (score) => score.savePdf(),
},
{
name: i18n('DOWNLOAD')('MSCZ'),
fileExt: 'mscz',
action: (score) => score.saveMsc('mscz'),
},
{
name: i18n('DOWNLOAD')('MusicXML'),
fileExt: 'mxl',
action: (score) => score.saveMxl(),
},
{
name: i18n('DOWNLOAD')('MIDI'),
fileExt: 'mid',
action: (score) => score.saveMidi(true, true),
},
{
name: i18n('DOWNLOAD_AUDIO')('MP3'),
fileExt: 'mp3',
action: (score) => loadSoundFont(score).then(() => score.saveAudio('mp3')),
},
{
name: i18n('DOWNLOAD_AUDIO')('FLAC'),
fileExt: 'flac',
action: (score) => loadSoundFont(score).then(() => score.saveAudio('flac')),
},
{
name: i18n('DOWNLOAD_AUDIO')('OGG'),
fileExt: 'ogg',
action: (score) => loadSoundFont(score).then(() => score.saveAudio('ogg')),
},
]

View file

@ -1,88 +1,26 @@
import { assertRes, getFetch } from './utils' import * as recaptcha from './recaptcha'
import { ScoreInfo } from './scoreinfo' import { saveAs } from './utils'
import scoreinfo from './scoreinfo'
export const MSCZ_BUF_SYM = Symbol('msczBufferP') let msczBufferP: Promise<ArrayBuffer> | undefined
export const MSCZ_URL_SYM = Symbol('msczUrl')
export const MAIN_CID_SYM = Symbol('mainCid')
const IPNS_KEY = 'QmSdXtvzC8v8iTTZuj5cVmiugnzbR1QATYRcGix4bBsioP'
const IPNS_RS_URL = `https://ipfs.io/api/v0/dag/resolve?arg=/ipns/${IPNS_KEY}`
export const getMainCid = async (scoreinfo: ScoreInfo, _fetch = getFetch()): Promise<string> => {
// look for the persisted msczUrl inside scoreinfo
let result = scoreinfo.store.get(MAIN_CID_SYM) as string
if (result) {
return result
}
const r = await _fetch(IPNS_RS_URL)
assertRes(r)
const json = await r.json()
result = json.Cid['/']
scoreinfo.store.set(MAIN_CID_SYM, result) // persist to scoreinfo
return result
}
export const loadMsczUrl = async (scoreinfo: ScoreInfo, _fetch = getFetch()): Promise<string> => {
// look for the persisted msczUrl inside scoreinfo
let result = scoreinfo.store.get(MSCZ_URL_SYM) as string
if (result) {
return result
}
const mainCid = await getMainCid(scoreinfo, _fetch)
const url = scoreinfo.getMsczCidUrl(mainCid)
const r0 = await _fetch(url)
// ipfs-http-gateway specific error
// may read further error msg as json
if (r0.status !== 500) {
assertRes(r0)
}
const cidRes: { Key: string; Message: string } = await r0.json()
const cid = cidRes.Key
if (!cid) {
// read further error msg
const err = cidRes.Message
if (err.includes('no link named')) { // file not found
throw new Error('Score not in dataset')
} else {
throw new Error(err)
}
}
result = `https://ipfs.infura.io/ipfs/${cid}`
scoreinfo.store.set(MSCZ_URL_SYM, result) // persist to scoreinfo
return result
}
export const fetchMscz = async (scoreinfo: ScoreInfo, _fetch = getFetch()): Promise<ArrayBuffer> => {
let msczBufferP = scoreinfo.store.get(MSCZ_BUF_SYM) as Promise<ArrayBuffer> | undefined
export const fetchMscz = async (): Promise<ArrayBuffer> => {
if (!msczBufferP) { if (!msczBufferP) {
const url = scoreinfo.msczUrl
msczBufferP = (async (): Promise<ArrayBuffer> => { msczBufferP = (async (): Promise<ArrayBuffer> => {
const url = await loadMsczUrl(scoreinfo, _fetch) const token = await recaptcha.execute()
const r = await _fetch(url) const r = await fetch(url + token)
assertRes(r)
const data = await r.arrayBuffer() const data = await r.arrayBuffer()
return data return data
})() })()
scoreinfo.store.set(MSCZ_BUF_SYM, msczBufferP)
} }
return msczBufferP return msczBufferP
} }
// eslint-disable-next-line @typescript-eslint/require-await export const downloadMscz = async (): Promise<void> => {
export const setMscz = async (scoreinfo: ScoreInfo, buffer: ArrayBuffer): Promise<void> => { const data = new Blob([await fetchMscz()])
scoreinfo.store.set(MSCZ_BUF_SYM, Promise.resolve(buffer))
}
export const downloadMscz = async (scoreinfo: ScoreInfo, saveAs: typeof import('file-saver').saveAs): Promise<void> => {
const data = new Blob([await fetchMscz(scoreinfo)])
const filename = scoreinfo.fileName const filename = scoreinfo.fileName
saveAs(data, `${filename}.mscz`) saveAs(data, `${filename}.mscz`)
} }

View file

@ -1,2 +0,0 @@
#!/usr/bin/env node
require("musescore-downloader/dist/cli.js")

View file

@ -1,19 +0,0 @@
{
"name": "msdl",
"version": "%VERSION%",
"author": "Xmader",
"bin": "cli.js",
"description": "Alias for musescore-downloader",
"repository": {
"type": "git",
"url": "git+https://github.com/Xmader/musescore-downloader.git"
},
"bugs": {
"url": "https://github.com/Xmader/musescore-downloader/issues"
},
"homepage": "https://github.com/Xmader/musescore-downloader#readme",
"license": "MIT",
"dependencies": {
"musescore-downloader": "%VERSION%"
}
}

View file

@ -1,34 +0,0 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { name as pkgName, version as pkgVer } from '../package.json'
import { getFetch } from './utils'
const IS_NPX_REG = /_npx(\/|\\)\d+\1/
const NPM_REGISTRY = 'https://registry.npmjs.org'
export function isNpx (): boolean {
// file is in a npx cache dir
// TODO: installed locally?
return __dirname.match(IS_NPX_REG) !== null
}
export function getSelfVer (): string {
return pkgVer
}
export async function getLatestVer (_fetch = getFetch()): Promise<string> {
// fetch pkg info from the npm registry
const r = await _fetch(`${NPM_REGISTRY}/${pkgName}`)
const json = await r.json()
return json['dist-tags'].latest as string
}
export async function getVerInfo () {
const installed = getSelfVer()
const latest = await getLatestVer()
return {
installed,
latest,
isLatest: installed === latest,
}
}

View file

@ -1,14 +1,14 @@
import { PDFWorkerHelper } from './worker-helper' import { PDFWorkerHelper } from './worker-helper'
import { getFileUrl } from './file' import { getFileUrl } from './file'
import FileSaver from 'file-saver' import { saveAs } from './utils'
import { ScoreInfo, SheetInfo } from './scoreinfo' import scoreinfo from './scoreinfo'
let pdfBlob: Blob let pdfBlob: Blob
const _downloadPDF = async (imgURLs: string[], imgType: 'svg' | 'png', name = ''): Promise<void> => { const _downloadPDF = async (imgURLs: string[], imgType: 'svg' | 'png', name = ''): Promise<void> => {
if (pdfBlob) { if (pdfBlob) {
return FileSaver.saveAs(pdfBlob, `${name}.pdf`) return saveAs(pdfBlob, `${name}.pdf`)
} }
const cachedImg = document.querySelector('img[src*=score_]') as HTMLImageElement const cachedImg = document.querySelector('img[src*=score_]') as HTMLImageElement
@ -20,18 +20,18 @@ const _downloadPDF = async (imgURLs: string[], imgType: 'svg' | 'png', name = ''
pdfBlob = new Blob([pdfArrayBuffer]) pdfBlob = new Blob([pdfArrayBuffer])
FileSaver.saveAs(pdfBlob, `${name}.pdf`) saveAs(pdfBlob, `${name}.pdf`)
} }
export const downloadPDF = async (scoreinfo: ScoreInfo, sheet: SheetInfo): Promise<void> => { export const downloadPDF = async (): Promise<void> => {
const imgType = sheet.imgType const imgType = scoreinfo.sheetImgType
const pageCount = sheet.pageCount const pageCount = scoreinfo.pageCount
const rs = Array.from({ length: pageCount }).map((_, i) => { const rs = Array.from({ length: pageCount }).map((_, i) => {
if (i === 0) { // The url to the first page is static. We don't need to use API to obtain it. if (i === 0) { // The url to the first page is static. We don't need to use API to obtain it.
return sheet.thumbnailUrl return scoreinfo.baseUrl + `score_${i}.${imgType}`
} else { // obtain image urls using the API } else { // obtain image urls using the API
return getFileUrl(scoreinfo.id, 'img', i) return getFileUrl('img', i)
} }
}) })
const sheetImgURLs = await Promise.all(rs) const sheetImgURLs = await Promise.all(rs)

48
src/recaptcha.ts Normal file
View file

@ -0,0 +1,48 @@
/**
* the site key for Google reCAPTCHA v3
*/
const SITE_KEY = '6Ldxtt8UAAAAALvcRqWTlVOVIB7MmEWwN-zw_9fM'
type token = string;
interface GRecaptcha {
ready (cb: () => any): void;
execute (siteKey: string, opts: { action: string }): Promise<token>;
}
let gr: GRecaptcha | Promise<GRecaptcha>
/**
* load reCAPTCHA
*/
const load = (): Promise<GRecaptcha> => {
// load script
const script = document.createElement('script')
script.src = `https://www.recaptcha.net/recaptcha/api.js?render=${SITE_KEY}`
script.async = true
document.body.appendChild(script)
// add css
const style = document.createElement('style')
style.innerHTML = '.grecaptcha-badge { display: none !important; }'
document.head.appendChild(style)
return new Promise((resolve) => {
script.onload = (): void => {
const grecaptcha: GRecaptcha = window['grecaptcha']
grecaptcha.ready(() => resolve(grecaptcha))
}
})
}
export const init = (): GRecaptcha | Promise<GRecaptcha> => {
if (!gr) {
gr = load()
}
return gr
}
export const execute = async (): Promise<token> => {
const captcha = await init()
return captcha.execute(SITE_KEY, { action: 'downloadmscz' })
}

View file

@ -1,154 +1,87 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
import { getFetch, escapeFilename, assertRes } from './utils' // run at document-start
import { getMainCid } from './mscz' export const ugappJsStore: Record<string, any> | null = (() => {
try {
export abstract class ScoreInfo { const l = document.body.children as HTMLCollectionOf<HTMLElement>
private readonly RADIX = 20; const el = [...l].find(e => Object.keys(e.dataset).length > 0) as HTMLDivElement
private readonly INDEX_RADIX = 32; const json = Object.values(el.dataset)[0] as string
return JSON.parse(json)
abstract id: number; } catch (err) {
abstract title: string; console.error(err)
return null
public store = new Map<symbol, any>();
get idLastDigit (): number {
return (+this.id) % this.RADIX
} }
})()
get fileName (): string { export const scoreinfo = {
return escapeFilename(this.title)
}
public getMsczIpfsRef (mainCid: string): string { get playerdata (): any {
return `/ipfs/${mainCid}/${this.idLastDigit}/${this.id}.mscz` // @ts-ignore
} return ugappJsStore.store.page.data.score
},
public getMsczCidUrl (mainCid: string): string { get id (this: typeof scoreinfo): number {
return `https://ipfs.infura.io:5001/api/v0/block/stat?arg=${this.getMsczIpfsRef(mainCid)}` try {
} return this.playerdata.id
} catch {
public getScorepackRef (mainCid: string): string { const el = document.querySelector("meta[property='al:ios:url']") as HTMLMetaElement
return `/ipfs/${mainCid}/index/${(+this.id) % this.INDEX_RADIX}/${this.id}` const m = el.content.match(/(\d+)$/) as RegExpMatchArray
} return +m[1]
}
export class ScoreInfoObj extends ScoreInfo {
constructor (public id: number = 0, public title: string = '') { super() }
}
export class ScoreInfoInPage extends ScoreInfo {
constructor (private document: Document) { super() }
get id (): number {
const el = this.document.querySelector("meta[property='al:ios:url']") as HTMLMetaElement
const m = el.content.match(/(\d+)$/) as RegExpMatchArray
return +m[1]
}
get title (): string {
const el = this.document.querySelector("meta[property='og:title']") as HTMLMetaElement
return el.content
}
get baseUrl (): string {
const el = this.document.querySelector("meta[property='og:image']") as HTMLMetaElement
const m = el.content.match(/^(.+\/)score_/) as RegExpMatchArray
return m[1]
}
}
export class ScoreInfoHtml extends ScoreInfo {
private readonly ID_REG = /<meta property="al:ios:url" content="musescore:\/\/score\/(\d+)">/
private readonly TITLE_REG = /<meta property="og:title" content="(.*)">/
private readonly BASEURL_REG = /<meta property="og:image" content="(.+\/)score_.*">/
constructor (private html: string) { super() }
get id (): number {
const m = this.html.match(this.ID_REG)
if (!m) return 0
return +m[1]
}
get title (): string {
const m = this.html.match(this.TITLE_REG)
if (!m) return ''
return m[1]
}
get baseUrl (): string {
const m = this.html.match(this.BASEURL_REG)
if (!m) return ''
return m[1]
}
static async request (url: string, _fetch = getFetch()): Promise<ScoreInfoHtml> {
const r = await _fetch(url)
if (!r.ok) return new ScoreInfoHtml('')
const html = await r.text()
return new ScoreInfoHtml(html)
}
}
export abstract class SheetInfo {
abstract pageCount: number;
abstract thumbnailUrl: string;
get imgType (): 'svg' | 'png' {
const thumbnail = this.thumbnailUrl
const imgtype = thumbnail.match(/score_0\.(\w+)/)![1]
return imgtype as 'svg' | 'png'
}
}
export class SheetInfoInPage extends SheetInfo {
constructor (private document: Document) { super() }
private get sheet0Img (): HTMLImageElement | null {
return this.document.querySelector('img[src*=score_]')
}
get pageCount (): number {
const sheet0Div = this.sheet0Img?.parentElement
if (!sheet0Div) {
throw new Error('no sheet images found')
} }
return this.document.getElementsByClassName(sheet0Div.className).length },
}
get thumbnailUrl (): string { get title (this: typeof scoreinfo): string {
// url to the image of the first page try {
const el = this.document.querySelector<HTMLLinkElement>('link[as=image]') return this.playerdata.title
const url = (el?.href || this.sheet0Img?.src) as string } catch {
return url.split('@')[0] const el = document.querySelector("meta[property='og:title']") as HTMLMetaElement
} return el.content
}
},
get fileName (this: typeof scoreinfo): string {
return this.title.replace(/[\s<>:{}"/\\|?*~.\0\cA-\cZ]+/g, '_')
},
get pageCount (this: typeof scoreinfo): number {
try {
return this.playerdata.pages_count
} catch {
return document.querySelectorAll('.gXB83').length
}
},
get baseUrl (this: typeof scoreinfo): string {
let thumbnailUrl: string
try {
thumbnailUrl = this.playerdata.thumbnails.original
} catch {
const el = document.querySelector("meta[property='og:image']") as HTMLMetaElement
thumbnailUrl = el.content
}
const { origin, pathname } = new URL(thumbnailUrl)
// remove the last part
return origin + pathname.split('/').slice(0, -1).join('/') + '/'
},
get msczUrl (this: typeof scoreinfo): string {
// https://github.com/Xmader/cloudflare-worker-musescore-mscz
return `https://musescore.now.sh/api/mscz?id=${this.id}&token=`
},
get sheetImgType (): 'svg' | 'png' {
try {
const imgE = document.querySelector('img[src*=score_]') as HTMLImageElement
const { pathname } = new URL(imgE.src)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const imgtype = pathname.match(/\.(\w+)$/)![1]
return imgtype as 'svg' | 'png'
} catch (_) {
// return null
return 'svg'
}
},
} }
export const getActualId = async (scoreinfo: ScoreInfoInPage | ScoreInfoHtml, _fetch = getFetch()): Promise<number> => { export default scoreinfo
if (scoreinfo.id <= 1000000000000) {
// actual id already
return scoreinfo.id
}
const mainCid = await getMainCid(scoreinfo, _fetch)
const ref = `${mainCid}/sid2id/${scoreinfo.id}`
const url = `https://ipfs.infura.io:5001/api/v0/dag/get?arg=${ref}`
const r0 = await _fetch(url)
if (r0.status !== 500) {
assertRes(r0)
}
const res: { Message: string } | number = await r0.json()
if (typeof res !== 'number') {
// read further error msg
throw new Error(res.Message)
}
// assign the actual id back to scoreinfo
Object.defineProperty(scoreinfo, 'id', {
get () { return res },
})
return res
}

View file

@ -1,12 +1,7 @@
import isNodeJs from 'detect-node' import FileSaver from 'file-saver/dist/FileSaver.js'
import { isGmAvailable, _GM } from './gm'
export const DISCORD_URL = 'https://discord.gg/gSsTUvJmD8' export const saveAs: typeof import('file-saver').saveAs = FileSaver.saveAs
export const escapeFilename = (s: string): string => {
return s.replace(/[\s<>:{}"/\\|?*~.\0\cA-\cZ]+/g, '_')
}
export const getIndexPath = (id: number): string => { export const getIndexPath = (id: number): string => {
const idStr = String(id) const idStr = String(id)
@ -18,111 +13,12 @@ export const getIndexPath = (id: number): string => {
return indexN.join('/') return indexN.join('/')
} }
const NODE_FETCH_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:81.0) Gecko/20100101 Firefox/81.0',
'Accept-Language': 'en-US,en;q=0.8',
}
export const getFetch = (): typeof fetch => {
if (!isNodeJs) {
return fetch
} else {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const nodeFetch = require('node-fetch')
return (input: RequestInfo, init?: RequestInit) => {
init = Object.assign({ headers: NODE_FETCH_HEADERS }, init)
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return nodeFetch(input, init)
}
}
}
export const fetchData = async (url: string, init?: RequestInit): Promise<Uint8Array> => { export const fetchData = async (url: string, init?: RequestInit): Promise<Uint8Array> => {
const r = await fetch(url, init) const r = await fetch(url, init)
const data = await r.arrayBuffer() const data = await r.arrayBuffer()
return new Uint8Array(data) return new Uint8Array(data)
} }
export const assertRes = (r: Response): void => {
if (!r.ok) throw new Error(`${r.url} ${r.status} ${r.statusText}`)
}
export const useTimeout = async <T> (promise: T | Promise<T>, ms: number): Promise<T> => {
if (!(promise instanceof Promise)) {
return promise
}
return new Promise((resolve, reject) => {
const i = setTimeout(() => {
reject(new Error('timeout'))
}, ms)
promise.then(resolve, reject).finally(() => clearTimeout(i))
})
}
export const getSandboxWindowAsync = async (targetEl: Element | undefined = undefined): Promise<Window> => {
if (typeof document === 'undefined') return {} as any as Window
if (isGmAvailable('addElement')) {
// create iframe using GM_addElement API
const iframe = await _GM.addElement('iframe', {})
iframe.style.display = 'none'
return iframe.contentWindow as Window
}
if (!targetEl) {
return new Promise((resolve) => {
// You need ads in your pages, right?
const observer = new MutationObserver(() => {
for (let i = 0; i < window.frames.length; i++) {
// find iframe windows created by ads
const frame = frames[i]
try {
const href = frame.location.href
if (href === location.href || href === 'about:blank') {
resolve(frame)
return
}
} catch { }
}
})
observer.observe(document.body, { subtree: true, childList: true })
})
}
return new Promise((resolve) => {
const eventName = 'onmousemove'
const id = Math.random().toString()
targetEl[id] = (iframe: HTMLIFrameElement) => {
delete targetEl[id]
targetEl.removeAttribute(eventName)
iframe.style.display = 'none'
targetEl.append(iframe)
const w = iframe.contentWindow
resolve(w as Window)
}
targetEl.setAttribute(eventName, `this['${id}'](document.createElement('iframe'))`)
})
}
export const getUnsafeWindow = (): Window => {
// eslint-disable-next-line no-eval
return window.eval('window') as Window
}
export const console: Console = (window || global).console // Object.is(window.console, unsafeWindow.console) == false
export const windowOpenAsync = (targetEl: Element | undefined, ...args: Parameters<Window['open']>): Promise<Window | null> => {
return getSandboxWindowAsync(targetEl).then(w => w.open(...args))
}
export const attachShadow = (el: Element): ShadowRoot => {
return Element.prototype.attachShadow.call(el, { mode: 'closed' }) as ShadowRoot
}
export const waitForDocumentLoaded = (): Promise<void> => { export const waitForDocumentLoaded = (): Promise<void> => {
if (document.readyState !== 'complete') { if (document.readyState !== 'complete') {
return new Promise(resolve => { return new Promise(resolve => {
@ -138,23 +34,3 @@ export const waitForDocumentLoaded = (): Promise<void> => {
return Promise.resolve() return Promise.resolve()
} }
} }
/**
* Run script before the page is fully loaded
*/
export const waitForSheetLoaded = (): Promise<void> => {
if (document.readyState !== 'complete') {
return new Promise(resolve => {
const observer = new MutationObserver(() => {
const img = document.querySelector('img')
if (img) {
resolve()
observer.disconnect()
}
})
observer.observe(document, { childList: true, subtree: true })
})
} else {
return Promise.resolve()
}
}

View file

@ -1,16 +1,11 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { hookNative } from './anti-detection'
import { console, getUnsafeWindow } from './utils'
const CHUNK_PUSH_FN = /^function [^r]\(\w\){/
interface Module { interface Module {
(module, exports, __webpack_require__): void; (module, exports, __webpack_require__): void;
} }
type WebpackJson = [(number | string)[], { [id: string]: Module }, any[]?][] type WebpackJson = [number, { [id: string]: Module }][]
const moduleLookup = (id: string, globalWebpackJson: WebpackJson) => { const moduleLookup = (id: string, globalWebpackJson: WebpackJson) => {
const pack = globalWebpackJson.find(x => x[1][id])! const pack = globalWebpackJson.find(x => x[1][id])!
@ -52,122 +47,4 @@ export const webpackHook = (moduleId: string, moduleOverrides: { [id: string]: M
return t(moduleId) return t(moduleId)
} }
export const ALL = '*'
export const [webpackGlobalOverride, onPackLoad] = (() => {
type OnPackLoadFn = (pack: WebpackJson[0]) => any
const moduleOverrides: { [id: string]: Module } = {}
const onPackLoadFns: OnPackLoadFn[] = []
function applyOverride (pack: WebpackJson[0]) {
let entries = Object.entries(moduleOverrides)
// apply to all
const all = moduleOverrides[ALL]
if (all) {
entries = Object.keys(pack[1]).map(id => [id, all])
}
entries.forEach(([id, override]) => {
const mod = pack[1][id]
if (mod) {
pack[1][id] = function (n, r, t) {
// make exports configurable
t = Object.assign(t, {
d (exp, name, fn) {
return Object.defineProperty(exp, name, { enumerable: true, get: fn, configurable: true })
},
})
mod(n, r, t)
override(n, r, t)
}
}
})
}
// hook `webpackJsonpmusescore.push` as soon as `webpackJsonpmusescore` is available
const _w = getUnsafeWindow()
let jsonp = _w['webpackJsonpmusescore']
let hooked = false
Object.defineProperty(_w, 'webpackJsonpmusescore', {
get () { return jsonp },
set (v: WebpackJson) {
jsonp = v
if (!hooked && v.push.toString().match(CHUNK_PUSH_FN)) {
hooked = true
hookNative(v, 'push', (_fn) => {
return function (pack) {
onPackLoadFns.forEach(fn => fn(pack))
applyOverride(pack)
return _fn.call(this, pack)
}
})
}
},
})
return [
// set overrides
(moduleId: string, override: Module) => {
moduleOverrides[moduleId] = override
},
// set onPackLoad listeners
(fn: OnPackLoadFn) => {
onPackLoadFns.push(fn)
},
] as const
})()
export const webpackContext = new Promise<any>((resolve) => {
webpackGlobalOverride(ALL, (n, r, t) => {
resolve(t)
})
})
const PACK_ID_REG = /\+(\{.*?"\})\[\w\]\+/
export const loadAllPacks = () => {
return webpackContext.then((ctx) => {
try {
const fn = ctx.e.toString()
const packsData = fn.match(PACK_ID_REG)[1] as string
// eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval
const packs = Function(`return (${packsData})`)() as { [id: string]: string }
Object.keys(packs).forEach((id) => {
ctx.e(id)
})
} catch (err) {
console.error(err)
}
})
}
const OBF_FN_REG = /\w\(".{4}"\),(\w)=(\[".+?\]);\w=\1,\w=(\d+).+?\);var (\w=.+?,\w\})/
export const OBFUSCATED_REG = /(\w)\((\d+),"(.{4})"\)/g
export const getObfuscationCtx = (mod: Module): (n: number, s: string) => string => {
const str = mod.toString()
const m = str.match(OBF_FN_REG)
if (!m) return () => ''
try {
const arrVar = m[1]
const arr = JSON.parse(m[2])
let n = +m[3] + 1
for (; --n;) arr.push(arr.shift())
const fnStr = m[4]
const ctxStr = `var ${arrVar}=${JSON.stringify(arr)};return (${fnStr})`
// eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval
const fn = new Function(ctxStr)()
return fn
} catch (err) {
console.error(err)
return () => ''
}
}
export default webpackHook export default webpackHook

View file

@ -4,7 +4,7 @@ import { PDFWorker } from '../dist/cache/worker'
const scriptUrlFromFunction = (fn: () => any): string => { const scriptUrlFromFunction = (fn: () => any): string => {
const blob = new Blob(['(' + fn.toString() + ')()'], { type: 'application/javascript' }) const blob = new Blob(['(' + fn.toString() + ')()'], { type: 'application/javascript' })
return window.URL.createObjectURL(blob) return URL.createObjectURL(blob)
} }
export class PDFWorkerHelper extends Worker { export class PDFWorkerHelper extends Worker {

View file

@ -6,9 +6,7 @@ import SVGtoPDF from 'svg-to-pdfkit'
type ImgType = 'svg' | 'png' type ImgType = 'svg' | 'png'
type DataResultType = 'dataUrl' | 'text' const getDataURL = (blob: Blob): Promise<string> => {
const readData = (blob: Blob, type: DataResultType): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader()
reader.onload = (): void => { reader.onload = (): void => {
@ -16,22 +14,22 @@ const readData = (blob: Blob, type: DataResultType): Promise<string> => {
resolve(result as string) resolve(result as string)
} }
reader.onerror = reject reader.onerror = reject
if (type === 'dataUrl') { reader.readAsDataURL(blob)
reader.readAsDataURL(blob)
} else {
reader.readAsText(blob)
}
}) })
} }
const fetchBlob = async (imgUrl: string): Promise<Blob> => { const fetchDataURL = async (imgUrl: string): Promise<string> => {
const r = await fetch(imgUrl, { const r = await fetch(imgUrl)
cache: 'no-cache', const blob = await r.blob()
}) return getDataURL(blob)
return r.blob()
} }
const generatePDF = async (imgBlobs: Blob[], imgType: ImgType, width: number, height: number): Promise<ArrayBuffer> => { const fetchText = async (imgUrl: string): Promise<string> => {
const r = await fetch(imgUrl)
return r.text()
}
const generatePDF = async (imgURLs: string[], imgType: ImgType, width: number, height: number): Promise<ArrayBuffer> => {
// @ts-ignore // @ts-ignore
const pdf = new (PDFDocument as typeof import('pdfkit'))({ const pdf = new (PDFDocument as typeof import('pdfkit'))({
// compress: true, // compress: true,
@ -42,7 +40,7 @@ const generatePDF = async (imgBlobs: Blob[], imgType: ImgType, width: number, he
}) })
if (imgType === 'png') { if (imgType === 'png') {
const imgDataUrlList: string[] = await Promise.all(imgBlobs.map(b => readData(b, 'dataUrl'))) const imgDataUrlList: string[] = await Promise.all(imgURLs.map(fetchDataURL))
imgDataUrlList.forEach((data) => { imgDataUrlList.forEach((data) => {
pdf.addPage() pdf.addPage()
@ -52,7 +50,7 @@ const generatePDF = async (imgBlobs: Blob[], imgType: ImgType, width: number, he
}) })
}) })
} else { // imgType == "svg" } else { // imgType == "svg"
const svgList = await Promise.all(imgBlobs.map(b => readData(b, 'text'))) const svgList = await Promise.all(imgURLs.map(fetchText))
svgList.forEach((svg) => { svgList.forEach((svg) => {
pdf.addPage() pdf.addPage()
@ -72,16 +70,14 @@ export type PDFWorkerMessage = [string[], ImgType, number, number];
onmessage = async (e): Promise<void> => { onmessage = async (e): Promise<void> => {
const [ const [
imgUrls, imgURLs,
imgType, imgType,
width, width,
height, height,
] = e.data as PDFWorkerMessage ] = e.data as PDFWorkerMessage
const imgBlobs = await Promise.all(imgUrls.map(url => fetchBlob(url)))
const pdfBuf = await generatePDF( const pdfBuf = await generatePDF(
imgBlobs, imgURLs,
imgType, imgType,
width, width,
height, height,

View file

@ -1,52 +0,0 @@
/* eslint-disable */
const w = typeof unsafeWindow == 'object' ? unsafeWindow : window;
// GM APIs glue
const _GM = typeof GM == 'object' ? GM : undefined;
const gmId = '' + Math.random();
w[gmId] = _GM;
if (_GM && _GM.registerMenuCommand && _GM.openInTab) {
// add buttons to the userscript manager menu
_GM.registerMenuCommand(
`** Version: ${_GM.info.script.version} **`,
() => _GM.openInTab("https://github.com/Xmader/musescore-downloader/releases", { active: true })
)
_GM.registerMenuCommand(
'** Source Code **',
() => _GM.openInTab(_GM.info.script.homepage, { active: true })
)
_GM.registerMenuCommand(
'** Discord **',
() => _GM.openInTab("https://discord.gg/DKu7cUZ4XQ", { active: true })
)
}
function getRandL () {
return String.fromCharCode(97 + Math.floor(Math.random() * 26))
}
// script loader
new Promise(resolve => {
const id = '' + Math.random();
w[id] = resolve;
const stackN = 9
let loaderIntro = ''
for (let i = 0; i < stackN; i++) {
loaderIntro += `(function ${getRandL()}(){`
}
const loaderOutro = '})()'.repeat(stackN)
const mockUrl = "https://c.amazon-adsystem.com/aax2/apstag.js"
Function(`${loaderIntro}const d=new Image();window['${id}'](d);delete window['${id}'];document.body.prepend(d)${loaderOutro}//# sourceURL=${mockUrl}`)()
}).then(d => {
d.style.display = 'none';
d.src = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
d.once = false;
d.setAttribute('onload', `if(this.once)return;this.once=true;this.remove();const GM=window['${gmId}'];delete window['${gmId}'];(` + function a () {
/** script code here */
}.toString() + ')()')})