diff --git a/README.md b/README.md index ba65254..ea030a4 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,19 @@ # Archive.org Ripper -This script lets you download books page-by-page from [archive.org](https://archive.org) in the event that there is no PDF link. Any book with a <14 day loan period is like this, as you can see: +This program lets you download books page-by-page from [archive.org](https://archive.org) in the event that there is no PDF link. Any book with a <14 day loan period is like this, as you can see: ![](./archive.png) -The script needs your login credentials to borrow the book, then it will run on its own using your session. +The program needs your login credentials to borrow the book, then it will run on its own using your session. Do not use this program in an illegal manner. Thanks! +## Setup + +Go to the Releases page if you want to download the GUI version, packaged into an executable. This is the most user-friendly option. + +For a command-line interface, just clone this repo, create a virtual environment, run `pip install -r requirements.txt`, and then `python ripper.py` with optional arguments. + ## Screenshots ![](./screenshot.png) @@ -17,6 +23,4 @@ Do not use this program in an illegal manner. Thanks! - Searching for books instead of inputting id directly -- GUI - - Option to convert to pdf or epub instead of saving each page individually diff --git a/archiveripper/.gitignore b/archiveripper/.gitignore new file mode 100644 index 0000000..fab7372 --- /dev/null +++ b/archiveripper/.gitignore @@ -0,0 +1,73 @@ +# This file is used to ignore files which are generated +# ---------------------------------------------------------------------------- + +*~ +*.autosave +*.a +*.core +*.moc +*.o +*.obj +*.orig +*.rej +*.so +*.so.* +*_pch.h.cpp +*_resource.rc +*.qm +.#* +*.*# +core +!core/ +tags +.DS_Store +.directory +*.debug +Makefile* +*.prl +*.app +moc_*.cpp +ui_*.h +qrc_*.cpp +Thumbs.db +*.res +*.rc +/.qmake.cache +/.qmake.stash + +# qtcreator generated files +*.pro.user* + +# xemacs temporary files +*.flc + +# Vim temporary files +.*.swp + +# Visual Studio generated files +*.ib_pdb_index +*.idb +*.ilk +*.pdb +*.sln +*.suo +*.vcproj +*vcproj.*.*.user +*.ncb +*.sdf +*.opensdf +*.vcxproj +*vcxproj.* + +# MinGW generated files +*.Debug +*.Release + +# Python byte code +*.pyc + +# Binaries +# -------- +*.dll +*.exe + diff --git a/archiveripper/__main__.py b/archiveripper/__main__.py new file mode 100644 index 0000000..4f2e8ec --- /dev/null +++ b/archiveripper/__main__.py @@ -0,0 +1,10 @@ +# This directory contains the GUI version of archiveripper + +import sys +from PySide6.QtWidgets import QApplication +from .ripper import ArchiveRipper + +app = QApplication(sys.argv) +widget = ArchiveRipper() +widget.show() +sys.exit(app.exec()) diff --git a/api.py b/archiveripper/api.py similarity index 100% rename from api.py rename to archiveripper/api.py diff --git a/archiveripper/ripper.py b/archiveripper/ripper.py new file mode 100644 index 0000000..f41b3b0 --- /dev/null +++ b/archiveripper/ripper.py @@ -0,0 +1,79 @@ +from PySide6.QtCore import Slot +from PySide6.QtWidgets import QDialog, QMainWindow, QMessageBox + +from .api import ArchiveReaderClient +from .ui.ui_ArchiveRipper import Ui_ArchiveRipper +from .ui.ui_CredentialsDialog import Ui_CredentialsDialog +from .ui.ui_NewRipDialog import Ui_NewRipDialog + + +class ArchiveRipper(QMainWindow): + + def __init__(self): + super(ArchiveRipper, self).__init__() + self.ui = Ui_ArchiveRipper() + self.ui.setupUi(self) + + # dialogs + self.new_rip_dialog = QDialog() + self.new_rip_dialog.ui = Ui_NewRipDialog() + self.new_rip_dialog.ui.setupUi(self.new_rip_dialog) + self.new_rip_dialog.accepted.connect(self.new_rip) + + self.credentials_dialog = QDialog() + self.credentials_dialog.ui = Ui_CredentialsDialog() + self.credentials_dialog.ui.setupUi(self.credentials_dialog) + self.credentials_dialog.accepted.connect(self.update_credentials) + + + # signals + self.ui.actionNew_Rip.triggered.connect(self.new_rip_dialog.exec) + self.ui.actionExit.triggered.connect(self.close) + + # api client + self.client = ArchiveReaderClient() + + # application state + self.credentials = { + "email": None, + "password": None + } + self.book_url = None + self.dest_path = None + self.start_page = None + self.end_page = None + + @Slot() + def new_rip(self): + book_url = self.new_rip_dialog.ui.bookUrl.text() + self.book_url = book_url[book_url.rfind("/") + 1:] + self.dest_path = self.new_rip_dialog.ui.filePath.text() + + self.verify_login() + msgbox = QMessageBox() + msgbox.setWindowTitle("New Rip Status") + try: + self.client.schedule_loan_book(self.book_url) + msgbox.setText("Borrowed book successfully") + msgbox.setIcon(QMessageBox.Information) + except Exception as e: + msgbox.setText(f"Failed to borrow book!\n{e}") + msgbox.setIcon(QMessageBox.Critical) + finally: + msgbox.exec() + + def verify_login(self): + if not self.credentials["email"] or not self.credentials["password"]: + self.credentials_dialog.exec() + try: + self.client.login(self.credentials["email"], self.credentials["password"]) + except Exception as e: + msgbox = QMessageBox() + msgbox.setText(f"Failed to log in!\n{e}") + msgbox.setIcon(QMessageBox.Critical) + msgbox.exec() + + @Slot() + def update_credentials(self): + self.credentials["email"] = self.credentials_dialog.ui.email.text() + self.credentials["password"] = self.credentials_dialog.ui.password.text() diff --git a/archiveripper/ui/ui_ArchiveRipper.py b/archiveripper/ui/ui_ArchiveRipper.py new file mode 100644 index 0000000..afa4c23 --- /dev/null +++ b/archiveripper/ui/ui_ArchiveRipper.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'ArchiveRipper.ui' +## +## Created by: Qt User Interface Compiler version 6.1.3 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import * # type: ignore +from PySide6.QtGui import * # type: ignore +from PySide6.QtWidgets import * # type: ignore + + +class Ui_ArchiveRipper(object): + def setupUi(self, ArchiveRipper): + if not ArchiveRipper.objectName(): + ArchiveRipper.setObjectName(u"ArchiveRipper") + ArchiveRipper.resize(640, 480) + self.actionExit = QAction(ArchiveRipper) + self.actionExit.setObjectName(u"actionExit") + self.actionPreferences = QAction(ArchiveRipper) + self.actionPreferences.setObjectName(u"actionPreferences") + self.actionAbout = QAction(ArchiveRipper) + self.actionAbout.setObjectName(u"actionAbout") + self.actionNew_Rip = QAction(ArchiveRipper) + self.actionNew_Rip.setObjectName(u"actionNew_Rip") + self.actionOpen_Rip = QAction(ArchiveRipper) + self.actionOpen_Rip.setObjectName(u"actionOpen_Rip") + self.actionExport = QAction(ArchiveRipper) + self.actionExport.setObjectName(u"actionExport") + self.actionSave = QAction(ArchiveRipper) + self.actionSave.setObjectName(u"actionSave") + self.actionSave_As = QAction(ArchiveRipper) + self.actionSave_As.setObjectName(u"actionSave_As") + self.centralwidget = QWidget(ArchiveRipper) + self.centralwidget.setObjectName(u"centralwidget") + ArchiveRipper.setCentralWidget(self.centralwidget) + self.menubar = QMenuBar(ArchiveRipper) + self.menubar.setObjectName(u"menubar") + self.menubar.setGeometry(QRect(0, 0, 640, 21)) + self.menuFile = QMenu(self.menubar) + self.menuFile.setObjectName(u"menuFile") + self.menuHelp = QMenu(self.menubar) + self.menuHelp.setObjectName(u"menuHelp") + ArchiveRipper.setMenuBar(self.menubar) + self.statusbar = QStatusBar(ArchiveRipper) + self.statusbar.setObjectName(u"statusbar") + ArchiveRipper.setStatusBar(self.statusbar) + + self.menubar.addAction(self.menuFile.menuAction()) + self.menubar.addAction(self.menuHelp.menuAction()) + self.menuFile.addAction(self.actionNew_Rip) + self.menuFile.addAction(self.actionOpen_Rip) + self.menuFile.addSeparator() + self.menuFile.addAction(self.actionSave) + self.menuFile.addAction(self.actionSave_As) + self.menuFile.addAction(self.actionExport) + self.menuFile.addSeparator() + self.menuFile.addAction(self.actionPreferences) + self.menuFile.addSeparator() + self.menuFile.addAction(self.actionExit) + self.menuHelp.addAction(self.actionAbout) + + self.retranslateUi(ArchiveRipper) + + QMetaObject.connectSlotsByName(ArchiveRipper) + # setupUi + + def retranslateUi(self, ArchiveRipper): + ArchiveRipper.setWindowTitle(QCoreApplication.translate("ArchiveRipper", u"ArchiveRipper", None)) + self.actionExit.setText(QCoreApplication.translate("ArchiveRipper", u"Exit", None)) +#if QT_CONFIG(shortcut) + self.actionExit.setShortcut(QCoreApplication.translate("ArchiveRipper", u"Ctrl+Q", None)) +#endif // QT_CONFIG(shortcut) + self.actionPreferences.setText(QCoreApplication.translate("ArchiveRipper", u"Preferences...", None)) +#if QT_CONFIG(shortcut) + self.actionPreferences.setShortcut(QCoreApplication.translate("ArchiveRipper", u"Ctrl+,", None)) +#endif // QT_CONFIG(shortcut) + self.actionAbout.setText(QCoreApplication.translate("ArchiveRipper", u"About", None)) + self.actionNew_Rip.setText(QCoreApplication.translate("ArchiveRipper", u"New Rip...", None)) + self.actionOpen_Rip.setText(QCoreApplication.translate("ArchiveRipper", u"Open Rip...", None)) + self.actionExport.setText(QCoreApplication.translate("ArchiveRipper", u"Export...", None)) + self.actionSave.setText(QCoreApplication.translate("ArchiveRipper", u"Save", None)) + self.actionSave_As.setText(QCoreApplication.translate("ArchiveRipper", u"Save As...", None)) + self.menuFile.setTitle(QCoreApplication.translate("ArchiveRipper", u"File", None)) + self.menuHelp.setTitle(QCoreApplication.translate("ArchiveRipper", u"Help", None)) + # retranslateUi + diff --git a/archiveripper/ui/ui_CredentialsDialog.py b/archiveripper/ui/ui_CredentialsDialog.py new file mode 100644 index 0000000..e1fc51e --- /dev/null +++ b/archiveripper/ui/ui_CredentialsDialog.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'CredentialsDialog.ui' +## +## Created by: Qt User Interface Compiler version 6.1.3 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import * # type: ignore +from PySide6.QtGui import * # type: ignore +from PySide6.QtWidgets import * # type: ignore + + +class Ui_CredentialsDialog(object): + def setupUi(self, CredentialsDialog): + if not CredentialsDialog.objectName(): + CredentialsDialog.setObjectName(u"CredentialsDialog") + CredentialsDialog.resize(400, 100) + self.verticalLayout = QVBoxLayout(CredentialsDialog) + self.verticalLayout.setObjectName(u"verticalLayout") + self.formLayout = QFormLayout() + self.formLayout.setObjectName(u"formLayout") + self.email = QLineEdit(CredentialsDialog) + self.email.setObjectName(u"email") + + self.formLayout.setWidget(0, QFormLayout.FieldRole, self.email) + + self.label = QLabel(CredentialsDialog) + self.label.setObjectName(u"label") + + self.formLayout.setWidget(0, QFormLayout.LabelRole, self.label) + + self.label_2 = QLabel(CredentialsDialog) + self.label_2.setObjectName(u"label_2") + + self.formLayout.setWidget(1, QFormLayout.LabelRole, self.label_2) + + self.password = QLineEdit(CredentialsDialog) + self.password.setObjectName(u"password") + self.password.setEchoMode(QLineEdit.Password) + + self.formLayout.setWidget(1, QFormLayout.FieldRole, self.password) + + + self.verticalLayout.addLayout(self.formLayout) + + self.buttonBox = QDialogButtonBox(CredentialsDialog) + self.buttonBox.setObjectName(u"buttonBox") + self.buttonBox.setOrientation(Qt.Horizontal) + self.buttonBox.setStandardButtons(QDialogButtonBox.Cancel|QDialogButtonBox.Ok) + + self.verticalLayout.addWidget(self.buttonBox) + + + self.retranslateUi(CredentialsDialog) + self.buttonBox.accepted.connect(CredentialsDialog.accept) + self.buttonBox.rejected.connect(CredentialsDialog.reject) + + QMetaObject.connectSlotsByName(CredentialsDialog) + # setupUi + + def retranslateUi(self, CredentialsDialog): + CredentialsDialog.setWindowTitle(QCoreApplication.translate("CredentialsDialog", u"Enter Archive.org Credentials", None)) + self.label.setText(QCoreApplication.translate("CredentialsDialog", u"Archive.org email:", None)) + self.label_2.setText(QCoreApplication.translate("CredentialsDialog", u"Password:", None)) + # retranslateUi + diff --git a/archiveripper/ui/ui_NewRipDialog.py b/archiveripper/ui/ui_NewRipDialog.py new file mode 100644 index 0000000..980192f --- /dev/null +++ b/archiveripper/ui/ui_NewRipDialog.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'NewRipDialog.ui' +## +## Created by: Qt User Interface Compiler version 6.1.3 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import * # type: ignore +from PySide6.QtGui import * # type: ignore +from PySide6.QtWidgets import * # type: ignore + + +class Ui_NewRipDialog(object): + def setupUi(self, NewRipDialog): + if not NewRipDialog.objectName(): + NewRipDialog.setObjectName(u"NewRipDialog") + NewRipDialog.resize(400, 137) + self.verticalLayout = QVBoxLayout(NewRipDialog) + self.verticalLayout.setObjectName(u"verticalLayout") + self.formLayout = QFormLayout() + self.formLayout.setObjectName(u"formLayout") + self.label = QLabel(NewRipDialog) + self.label.setObjectName(u"label") + + self.formLayout.setWidget(0, QFormLayout.LabelRole, self.label) + + self.label_2 = QLabel(NewRipDialog) + self.label_2.setObjectName(u"label_2") + + self.formLayout.setWidget(1, QFormLayout.LabelRole, self.label_2) + + self.label_3 = QLabel(NewRipDialog) + self.label_3.setObjectName(u"label_3") + + self.formLayout.setWidget(3, QFormLayout.LabelRole, self.label_3) + + self.bookUrl = QLineEdit(NewRipDialog) + self.bookUrl.setObjectName(u"bookUrl") + + self.formLayout.setWidget(0, QFormLayout.FieldRole, self.bookUrl) + + self.horizontalLayout = QHBoxLayout() + self.horizontalLayout.setObjectName(u"horizontalLayout") + self.filePath = QLineEdit(NewRipDialog) + self.filePath.setObjectName(u"filePath") + + self.horizontalLayout.addWidget(self.filePath) + + self.fileBrowseBtn = QPushButton(NewRipDialog) + self.fileBrowseBtn.setObjectName(u"fileBrowseBtn") + + self.horizontalLayout.addWidget(self.fileBrowseBtn) + + + self.formLayout.setLayout(3, QFormLayout.FieldRole, self.horizontalLayout) + + + self.verticalLayout.addLayout(self.formLayout) + + self.buttonBox = QDialogButtonBox(NewRipDialog) + self.buttonBox.setObjectName(u"buttonBox") + self.buttonBox.setOrientation(Qt.Horizontal) + self.buttonBox.setStandardButtons(QDialogButtonBox.Cancel|QDialogButtonBox.Ok) + + self.verticalLayout.addWidget(self.buttonBox) + + + self.retranslateUi(NewRipDialog) + self.buttonBox.accepted.connect(NewRipDialog.accept) + self.buttonBox.rejected.connect(NewRipDialog.reject) + + QMetaObject.connectSlotsByName(NewRipDialog) + # setupUi + + def retranslateUi(self, NewRipDialog): + NewRipDialog.setWindowTitle(QCoreApplication.translate("NewRipDialog", u"New Rip", None)) + self.label.setText(QCoreApplication.translate("NewRipDialog", u"Paste the book's URL here:", None)) + self.label_2.setText("") + self.label_3.setText(QCoreApplication.translate("NewRipDialog", u"Save pages to:", None)) + self.bookUrl.setPlaceholderText(QCoreApplication.translate("NewRipDialog", u"https://archive.org/details/ ...", None)) + self.fileBrowseBtn.setText(QCoreApplication.translate("NewRipDialog", u"Browse...", None)) + # retranslateUi + diff --git a/requirements.txt b/requirements.txt index 989b995..f4323c6 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/ripper.py b/ripper.py index ca324f5..bbe85de 100644 --- a/ripper.py +++ b/ripper.py @@ -1,8 +1,9 @@ # ripper.py # Copyright (c) 2020 James Shiffer -# This file contains the main application logic. +# This file contains the command-line version of archiveripper. -import argparse, api, getpass, logging, os, sys +import argparse, getpass, logging, os +from archiveripper import api def main(): client = api.ArchiveReaderClient() diff --git a/ui/ArchiveRipper.ui b/ui/ArchiveRipper.ui new file mode 100644 index 0000000..65d87f4 --- /dev/null +++ b/ui/ArchiveRipper.ui @@ -0,0 +1,100 @@ + + + ArchiveRipper + + + + 0 + 0 + 640 + 480 + + + + ArchiveRipper + + + + + + 0 + 0 + 640 + 21 + + + + + File + + + + + + + + + + + + + + + Help + + + + + + + + + + Exit + + + Ctrl+Q + + + + + Preferences... + + + Ctrl+, + + + + + About + + + + + New Rip... + + + + + Open Rip... + + + + + Export... + + + + + Save + + + + + Save As... + + + + + + diff --git a/ui/CredentialsDialog.ui b/ui/CredentialsDialog.ui new file mode 100644 index 0000000..3291324 --- /dev/null +++ b/ui/CredentialsDialog.ui @@ -0,0 +1,92 @@ + + + CredentialsDialog + + + + 0 + 0 + 400 + 100 + + + + Enter Archive.org Credentials + + + + + + + + + + + Archive.org email: + + + + + + + Password: + + + + + + + QLineEdit::Password + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + CredentialsDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + CredentialsDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/ui/NewRipDialog.ui b/ui/NewRipDialog.ui new file mode 100644 index 0000000..3f4011e --- /dev/null +++ b/ui/NewRipDialog.ui @@ -0,0 +1,110 @@ + + + NewRipDialog + + + + 0 + 0 + 400 + 137 + + + + New Rip + + + + + + + + Paste the book's URL here: + + + + + + + + + + + + + + Save pages to: + + + + + + + https://archive.org/details/ ... + + + + + + + + + + + + Browse... + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + NewRipDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + NewRipDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..12d1b32 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,23 @@ +This folder contains Qt .ui files which need to be recompiled into Python files whenever they are changed. + +Hopefully, your editor has a way to automate this for you. In VS Code, for example, all you have to do is download the Qt for Python extension and add the following setting: + +```json +"qtForPython.uic.args": [ + "-o \"${workspaceFolder}${pathSeparator}archiveripper${pathSeparator}ui${pathSeparator}ui_${fileBasenameNoExtension}.py\"" +] +``` + +If you need to do it by hand, though, you just need to run this command for each file you change: + +```sh +pyside6-uic $FILENAME.ui > ../archiveripper/ui/ui_$FILENAME.py +``` + +And if you receive the error + +``` +ValueError: source code string cannot contain null bytes +``` + +trying to import the compiled .py scripts, this is just because they were encoded as little-endian UTF-16 for some stupid reason. You just need to change them to UTF-8.