Compare commits
207 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f20dfa2017 | |||
| 265799907d | |||
| 2956399b4b | |||
| d372020bac | |||
| c2104ee514 | |||
| b9ec2b1b5a | |||
| 38c7af3408 | |||
| b99465037c | |||
| d7fbadb2aa | |||
| efbb154fa5 | |||
| d7da15a434 | |||
| 3eca87e7cf | |||
| 9755c62ee8 | |||
| 5c1f3244b2 | |||
| 35bbb54fc2 | |||
| 711fe84804 | |||
| f7e22cef15 | |||
| 464d68f328 | |||
| db6c841e2f | |||
| 12c6c51829 | |||
| 5afcfd54e5 | |||
| d3b06f5d21 | |||
| 551e91cb7f | |||
| c132b5de18 | |||
| 6e93f743f3 | |||
| 1db3f82f15 | |||
| b36dc92718 | |||
| 1baeb3f975 | |||
| 403ee8dd54 | |||
| db899aafde | |||
| ddfd0dc0a9 | |||
| 86ae5eac21 | |||
| 8c95632894 | |||
| f328b6d5e1 | |||
| 9b440f3ee0 | |||
| af90adb422 | |||
| 939b4c8caa | |||
| 29216938aa | |||
| 3fed17302f | |||
| da08de17a5 | |||
| 3853e0d586 | |||
| a790021057 | |||
| 1fc8c59992 | |||
| 7d7223223f | |||
| 0327a230bb | |||
| 7c3e02f347 | |||
| 80be6e0b32 | |||
| c7cc857306 | |||
| 3810b4661c | |||
| 0ec0a65afc | |||
| 8457d3786f | |||
| 6a9b703f50 | |||
| cfef986f46 | |||
| fec7a83d1c | |||
| 684e59cb00 | |||
| fa55e6e1e9 | |||
| 46319507e4 | |||
| 65eb006197 | |||
| 10d8a8f308 | |||
| 713c8071fb | |||
| d6095bb122 | |||
| 19551b8c9f | |||
| 8ce6720098 | |||
| 8121fc9fdc | |||
| 251557e591 | |||
| 48621b72fc | |||
| 056dbc052b | |||
| f03c2d179f | |||
| f79bb610bd | |||
| 4954fc31bd | |||
| 6d4a22d6b7 | |||
| a2b771e7bf | |||
| 09e1959ffc | |||
| fa9e673aba | |||
| 2f4ec46100 | |||
| 1a56d648f6 | |||
| 2a7452e84a | |||
| 5ec9b5216a | |||
| 48fce99f50 | |||
| f0af03edb4 | |||
| 47aa11e090 | |||
| 7f3737c96e | |||
| 13dd08e241 | |||
| 063851a43a | |||
| 70387ff31d | |||
| 93be421fbc | |||
| efdea585b3 | |||
| cd8eb3298a | |||
| 66b304411e | |||
| b6fb019b34 | |||
| 669bf1fb97 | |||
| 174968025d | |||
| 6b3fc072a8 | |||
| 8989f816c3 | |||
| 423c62b878 | |||
| e1d056de19 | |||
| 8f35703265 | |||
| 222e07085f | |||
| 5cfdc8f305 | |||
| 0ce8ab1943 | |||
| ef6430a5cf | |||
| d6c31da507 | |||
| 84c08b1c38 | |||
| 352faceb7a | |||
| d2063f59be | |||
| 8df2b6e84e | |||
| 3816ab3fd0 | |||
| 5215689836 | |||
| a1191b2600 | |||
| 82270dc770 | |||
| 8dc7d16578 | |||
| f8ab318210 | |||
| 758b14fd15 | |||
| 90139b190b | |||
| b4c3cfeffb | |||
| ff10b3c6c9 | |||
| a7a26dc2b6 | |||
| 016ad6209a | |||
| 16c5a9216f | |||
| 18799e5a91 | |||
| c737f5314e | |||
| 097c37f24e | |||
| d924e1b6ce | |||
| 5d9eefd03c | |||
| e74642f6cb | |||
| ddb1862918 | |||
| e4e372c664 | |||
| 9747839ae9 | |||
| 7ef988ba42 | |||
| c51f32654c | |||
| df58b59da2 | |||
| 692f5ad82a | |||
| ca0b59cb0c | |||
| 1948ed67ee | |||
| e30bbf40b9 | |||
| 3f7591b15c | |||
| 174faafa0e | |||
| af026f1263 | |||
| 4c43d01f64 | |||
| fb42905b33 | |||
| dd38369832 | |||
| c116300272 | |||
| 14dc13029a | |||
| 5cbef75d19 | |||
| 96d367da27 | |||
| 7b521e492a | |||
| f0e7d79d2f | |||
| c6174aa418 | |||
| c33c773fe7 | |||
| 8728b215ee | |||
| 40bad57eac | |||
| 6c6c8f81b6 | |||
| 6bb63dd67b | |||
| f9631a8f31 | |||
| 12b022e780 | |||
| 52c55227b2 | |||
| ea9e415e52 | |||
| 36ccbb70eb | |||
| a025baded4 | |||
| b05fe47914 | |||
| 0835992b23 | |||
| 0f6cdd91a9 | |||
| f8900be6b3 | |||
| a458b9c2f1 | |||
| 18d4df0674 | |||
| c1db7babdd | |||
| f40d1e4b27 | |||
| 39837f6305 | |||
| 974c0bd341 | |||
| 5687c5f2cd | |||
| 5f0676a19d | |||
| ec700ce284 | |||
| eafef1f1ec | |||
| 8e8773a61a | |||
| 2b1d00b08e | |||
| c29c26b33b | |||
| f91a01e574 | |||
| a31c26f8c5 | |||
| 8b00d0b109 | |||
| 26b9db8945 | |||
| a755ddb0e4 | |||
| 28e40ece94 | |||
| 6e222c1f55 | |||
| 36c73d01e9 | |||
| 48fed5f0ce | |||
| e3028867db | |||
| b1aa836254 | |||
| 5ecbe028c3 | |||
| 96877d9c9b | |||
| f9e27689e3 | |||
| 308afde25f | |||
| fa1bac3045 | |||
| d58a119c10 | |||
| 31b8d0c08c | |||
| 40ae0fbb99 | |||
| af0981a679 | |||
| fc4866463f | |||
| ca4697057c | |||
| e89dc7e699 | |||
| d9c858b3b3 | |||
| c0695a9d17 | |||
| 75d42ba5ec | |||
| 33d6d912a2 | |||
| 9d7464b461 | |||
| 232795b050 | |||
| 85bc4609c2 | |||
| 3369325d03 |
@@ -0,0 +1,10 @@
|
|||||||
|
__pycache__
|
||||||
|
*ipynb
|
||||||
|
build
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.venv
|
||||||
|
.env
|
||||||
|
*log
|
||||||
|
*.md
|
||||||
|
uv.lock
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
venv
|
venv
|
||||||
*epub
|
*epub
|
||||||
|
*.pdf
|
||||||
|
# *html
|
||||||
data
|
data
|
||||||
*ipynb
|
*ipynb
|
||||||
build
|
build
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.venv
|
||||||
|
.env
|
||||||
|
*log
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Python Debugger: FastAPI",
|
||||||
|
"type": "debugpy",
|
||||||
|
"request": "launch",
|
||||||
|
"module": "uvicorn",
|
||||||
|
"args": [
|
||||||
|
"main:app",
|
||||||
|
"--reload",
|
||||||
|
"--host",
|
||||||
|
"0.0.0.0",
|
||||||
|
"--port",
|
||||||
|
"8086"
|
||||||
|
],
|
||||||
|
"jinja": true,
|
||||||
|
"cwd": "${workspaceFolder}/src/api/src"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -6,20 +6,47 @@ RUN rm -rf node_modules
|
|||||||
RUN rm -rf build
|
RUN rm -rf build
|
||||||
RUN npm install
|
RUN npm install
|
||||||
COPY src/frontend/. .
|
COPY src/frontend/. .
|
||||||
|
|
||||||
|
ARG pdfs=false
|
||||||
|
ENV VITE_ENABLE_PDFS=$pdfs
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
# Thanks https://stackoverflow.com/q/76988450
|
# Thanks https://stackoverflow.com/q/76988450
|
||||||
|
|
||||||
FROM python:3.10-slim
|
FROM python:3.13-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY src/api/requirements.txt requirements.txt
|
|
||||||
RUN pip3 install -r requirements.txt
|
COPY --from=nobodyxu/apt-fast:latest-debian-buster-slim /usr/local/ /usr/local/
|
||||||
COPY --from=0 /build/build /app/build
|
|
||||||
# COPY src/api/src/.env .env
|
RUN apt update
|
||||||
COPY src/api/src .
|
RUN apt install -y aria2
|
||||||
|
RUN apt-fast install -y git build-essential python3.13-dev libgobject-2.0 libpango-1.0 libpangoft2-1.0
|
||||||
|
# aiohttp-client-cache depends on multipart, which requires python3.13-dev to build successfully on 3.13
|
||||||
|
# weasyprint depends on libgoject, libpango, and libpangoft2
|
||||||
|
|
||||||
|
RUN rm -rf /var/lib/apt/lists/*
|
||||||
|
# https://github.com/TheOnlyWayUp/WattpadDownloader/pull/82#discussion_r2470358950
|
||||||
|
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# --- #
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||||
|
|
||||||
|
COPY src/api/pyproject.toml /app
|
||||||
|
RUN uv sync
|
||||||
|
COPY src/api/ /app
|
||||||
|
COPY --from=0 /build/build /app/src/build
|
||||||
|
|
||||||
|
RUN ln -s /app/src/pdf/fonts /tmp/fonts
|
||||||
|
|
||||||
|
WORKDIR /app/src
|
||||||
|
|
||||||
|
ARG pdfs=false
|
||||||
|
ENV VITE_ENABLE_PDFS=$pdfs
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
# ENV PORT=80
|
|
||||||
|
|
||||||
CMD [ "python3", "main.py"]
|
|
||||||
|
|
||||||
|
CMD [ "uv", "run", "main.py"]
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
WattpadDownloader ([Demo](https://wpd.rambhat.la))
|
WattpadDownloader ([Demo](https://wpd.my/))
|
||||||
---
|
---
|
||||||
Straightforward, Extendable WebApp to download Wattpad Books as EPUB Files.
|
Straightforward, Extendable WebApp to download Wattpad Books as EPUB Files.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
Stars ⭐ are appreciated. Thanks!
|
Stars ⭐ are appreciated. Thanks!
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- ⚡ Lightweight Frontend and Minimal Javascript.
|
- ⚡ Lightweight Frontend.
|
||||||
- 🪙 Supports Authentication (Download paid stories from your account!)
|
- 🪙 Supports Authentication (Download paid stories from your account!)
|
||||||
- 🌐 API Support (Visit the `/docs` path on your instance for more.)
|
- 🌐 API Support (Visit the `/docs` path on your instance for more.)
|
||||||
- 🐇 Fast Generation, Ratelimit Handling.
|
- 🐇 Fast Generation
|
||||||
|
- 🗃️ Caching, Ratelimit handling
|
||||||
- 🐳 Docker Support
|
- 🐳 Docker Support
|
||||||
- 🏷️ Generated EPUB File includes Metadata. (Dublin Core Spec)
|
- 🏷️ Generated books contain metadata, supported by Calibre and other E-Book Software.
|
||||||
- 📖 Plays well with E-Readers. (Kindle Support if KOReader present, ReMarkable, KOBO, ...)
|
- 📖 Plays well with E-Readers. (Send2Kindle, KOReader, ReMarkable, KOBO, Calibre Reader...)
|
||||||
- 💻 Easily Hackable. Extend with ease.
|
- 💻 Easily Hackable. Extend with ease.
|
||||||
|
|
||||||
|
Still not convinced? Take a look some [sample downloads](./samples/).
|
||||||
|
|
||||||
|
|
||||||
## Set Up
|
## Set Up
|
||||||
1. Clone the repository: `git clone https://github.com/TheOnlyWayUp/WattpadDownloader/ && cd WattpadDownloader`
|
1. Clone the repository: `git clone https://github.com/TheOnlyWayUp/WattpadDownloader/ && cd WattpadDownloader`
|
||||||
@@ -25,6 +28,24 @@ Stars ⭐ are appreciated. Thanks!
|
|||||||
|
|
||||||
That's it! You can use your instance at `http://localhost:5042`. API Documentation is available at `http://localhost:5042/docs`.
|
That's it! You can use your instance at `http://localhost:5042`. API Documentation is available at `http://localhost:5042/docs`.
|
||||||
|
|
||||||
|
### Concurrent Requests
|
||||||
|
The file-based cache struggles with concurrent requests (discussed in TheOnlyWayUp/WattpadDownloader#2 and TheOnlyWayUp/WattpadDownloader#22). If you're downloading a large number of books concurrently, switch to the Redis cache. Assuming you've built the image already:
|
||||||
|
1. Fill the .env file. Localhost will not work in a docker container unless [`host.docker.internal`](https://docs.docker.com/desktop/features/networking/#i-want-to-connect-from-a-container-to-a-service-on-the-host) or a platform-specific variant is provided.
|
||||||
|
```
|
||||||
|
USE_CACHE=true
|
||||||
|
CACHE_TYPE=redis
|
||||||
|
REDIS_CONNECTION_URL=redis://username:password@host:port
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
2. Run the container and supply the .env file, `docker run -d -p 5042:80 --env-file .env wp_downloader`
|
||||||
|
Alternatively, if Redis is running on localhost
|
||||||
|
2. Modify your `.env` file, replacing `localhost` with `host.docker.internal`. `redis://localhost:6379` should become `redis://host.docker.internal:6379`. Then, start the container, `docker run -d -p 5042:80 --env-file .env --add-host host.docker.internal:host-gateway wp_downloader`
|
||||||
|
|
||||||
|
## Development
|
||||||
|
- Developers, ensure you have `wkhtmltopdf` available on your PATH.
|
||||||
|
- Run `wkhtmltopdf` on your terminal, if you see "Reduced Functionality", run [this script](https://raw.githubusercontent.com/JazzCore/python-pdfkit/b7bf798b946fa5655f8e82f0d80dec6b6b13d414/ci/before-script.sh) to install a fully featured compilation of `wkhtmltopdf.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
My thanks to [aerkalov/ebooklib](https://github.com/aerkalov/ebooklib) for a fast and well-documented package.
|
My thanks to [aerkalov/ebooklib](https://github.com/aerkalov/ebooklib) for a fast and well-documented package.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 264 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
USE_CACHE=true
|
||||||
|
CACHE_TYPE=file
|
||||||
|
REDIS_CONNECTION_URL=
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
[project]
|
||||||
|
name = "api"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Wattpad Downloader API"
|
||||||
|
readme = "../../README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"aiohttp>=3.9.1",
|
||||||
|
"rich>=13.9.4",
|
||||||
|
"fastapi>=0.115.5",
|
||||||
|
"ebooklib>=0.18",
|
||||||
|
"python-dotenv>=1.0.1",
|
||||||
|
"pydantic-settings>=2.6.1",
|
||||||
|
"eliot>=1.16.0",
|
||||||
|
"type-extensions>=0.1.2",
|
||||||
|
"backoff>=2.2.1",
|
||||||
|
"aiohttp-client-cache[all]",
|
||||||
|
"bs4>=0.0.2",
|
||||||
|
"uvicorn>=0.32.1",
|
||||||
|
"weasyprint>=63.0",
|
||||||
|
"jinja2>=3.1.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
ignore = ['E402'] # module import not at top of file
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
aiohttp-client-cache = { git = "https://github.com/TheOnlyWayUp/aiohttp-client-cache.git", rev = "keydb-ttl" } # Fork which leverages keydb's EXPIREMEMBER feature for TTLs on Hash members.
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"ipykernel>=6.29.5",
|
||||||
|
"ipynb>=0.5.1",
|
||||||
|
"ruff>=0.11.12",
|
||||||
|
]
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
aiofiles==23.2.1
|
|
||||||
aiohttp==3.9.1
|
|
||||||
aiohttp-client-cache==0.10.0
|
|
||||||
aiosignal==1.3.1
|
|
||||||
aiosqlite==0.19.0
|
|
||||||
annotated-types==0.6.0
|
|
||||||
anyio==4.2.0
|
|
||||||
asttokens==2.4.1
|
|
||||||
async-timeout==4.0.3
|
|
||||||
attrs==23.1.0
|
|
||||||
backoff==2.2.1
|
|
||||||
beautifulsoup4==4.12.3
|
|
||||||
bs4==0.0.2
|
|
||||||
click==8.1.7
|
|
||||||
comm==0.2.0
|
|
||||||
debugpy==1.8.0
|
|
||||||
decorator==5.1.1
|
|
||||||
EbookLib==0.18
|
|
||||||
exceptiongroup==1.2.0
|
|
||||||
executing==2.0.1
|
|
||||||
fastapi==0.108.0
|
|
||||||
frozenlist==1.4.1
|
|
||||||
h11==0.14.0
|
|
||||||
idna==3.6
|
|
||||||
ipykernel==6.28.0
|
|
||||||
ipython==8.19.0
|
|
||||||
itsdangerous==2.1.2
|
|
||||||
jedi==0.19.1
|
|
||||||
jupyter_client==8.6.0
|
|
||||||
jupyter_core==5.5.1
|
|
||||||
lxml==4.9.4
|
|
||||||
markdown-it-py==3.0.0
|
|
||||||
matplotlib-inline==0.1.6
|
|
||||||
mdurl==0.1.2
|
|
||||||
multidict==6.0.4
|
|
||||||
nest-asyncio==1.5.8
|
|
||||||
packaging==23.2
|
|
||||||
parso==0.8.3
|
|
||||||
pexpect==4.9.0
|
|
||||||
platformdirs==4.1.0
|
|
||||||
prompt-toolkit==3.0.43
|
|
||||||
psutil==5.9.7
|
|
||||||
ptyprocess==0.7.0
|
|
||||||
pure-eval==0.2.2
|
|
||||||
pydantic==2.5.3
|
|
||||||
pydantic_core==2.14.6
|
|
||||||
Pygments==2.17.2
|
|
||||||
python-dateutil==2.8.2
|
|
||||||
pyzmq==25.1.2
|
|
||||||
rich==13.7.0
|
|
||||||
six==1.16.0
|
|
||||||
sniffio==1.3.0
|
|
||||||
soupsieve==2.5
|
|
||||||
stack-data==0.6.3
|
|
||||||
starlette==0.32.0.post1
|
|
||||||
tornado==6.4
|
|
||||||
traitlets==5.14.0
|
|
||||||
typing_extensions==4.9.0
|
|
||||||
url-normalize==1.4.3
|
|
||||||
uvicorn==0.25.0
|
|
||||||
wcwidth==0.2.12
|
|
||||||
yarl==1.9.4
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from typing import Optional
|
|
||||||
from ebooklib import epub
|
|
||||||
import unicodedata
|
|
||||||
import re
|
|
||||||
import backoff
|
|
||||||
from aiohttp import ClientResponseError, ClientSession
|
|
||||||
from aiohttp_client_cache.session import CachedSession
|
|
||||||
from aiohttp_client_cache import FileBackend
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36"
|
|
||||||
}
|
|
||||||
|
|
||||||
cache = FileBackend(use_temp=True, expire_after=43200) # 12 hours
|
|
||||||
|
|
||||||
# --- Utilities --- #
|
|
||||||
|
|
||||||
|
|
||||||
async def wp_get_cookies(username: str, password: str) -> dict:
|
|
||||||
# source: https://github.com/TheOnlyWayUp/WP-DM-Export/blob/dd4c7c51cb43f2108e0f63fc10a66cd24a740e4e/src/API/src/main.py#L25-L58
|
|
||||||
"""Retrieves authorization cookies from Wattpad by logging in with user creds.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
username (str): Username.
|
|
||||||
password (str): Password.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: Bad status code.
|
|
||||||
ValueError: No cookies returned.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Authorization cookies.
|
|
||||||
"""
|
|
||||||
async with ClientSession(headers=headers) as session:
|
|
||||||
async with session.post(
|
|
||||||
"https://www.wattpad.com/auth/login?nextUrl=%2F&_data=routes%2Fauth.login",
|
|
||||||
data={
|
|
||||||
"username": username.lower(),
|
|
||||||
"password": password,
|
|
||||||
}, # the username.lower() is for caching
|
|
||||||
) as response:
|
|
||||||
if response.status != 204:
|
|
||||||
raise ValueError("Not a 204.")
|
|
||||||
|
|
||||||
cookies = {
|
|
||||||
k: v.value
|
|
||||||
for k, v in response.cookies.items() # Thanks https://stackoverflow.com/a/32281245
|
|
||||||
}
|
|
||||||
|
|
||||||
if not cookies:
|
|
||||||
raise ValueError("No cookies.")
|
|
||||||
|
|
||||||
return cookies
|
|
||||||
|
|
||||||
|
|
||||||
def slugify(value, allow_unicode=False) -> str:
|
|
||||||
"""
|
|
||||||
Taken from https://github.com/django/django/blob/master/django/utils/text.py
|
|
||||||
Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
|
|
||||||
dashes to single dashes. Remove characters that aren't alphanumerics,
|
|
||||||
underscores, or hyphens. Convert to lowercase. Also strip leading and
|
|
||||||
trailing whitespace, dashes, and underscores.
|
|
||||||
|
|
||||||
Thanks https://stackoverflow.com/a/295466.
|
|
||||||
"""
|
|
||||||
value = str(value)
|
|
||||||
if allow_unicode:
|
|
||||||
value = unicodedata.normalize("NFKC", value)
|
|
||||||
else:
|
|
||||||
value = (
|
|
||||||
unicodedata.normalize("NFKD", value)
|
|
||||||
.encode("ascii", "ignore")
|
|
||||||
.decode("ascii")
|
|
||||||
)
|
|
||||||
value = re.sub(r"[^\w\s-]", "", value.lower())
|
|
||||||
return re.sub(r"[-\s]+", "-", value).strip("-_")
|
|
||||||
|
|
||||||
|
|
||||||
# --- API Calls --- #
|
|
||||||
|
|
||||||
|
|
||||||
@backoff.on_exception(backoff.expo, ClientResponseError, max_time=15)
|
|
||||||
async def retrieve_story(story_id: int, cookies: Optional[dict] = None) -> dict:
|
|
||||||
"""Taking a story_id, return its information from the Wattpad API."""
|
|
||||||
async with (
|
|
||||||
CachedSession(headers=headers, cache=cache)
|
|
||||||
if not cookies
|
|
||||||
else ClientSession(headers=headers, cookies=cookies)
|
|
||||||
) as session: # Don't cache requests with Cookies.
|
|
||||||
async with session.get(
|
|
||||||
f"https://www.wattpad.com/api/v3/stories/{story_id}?fields=tags,id,title,createDate,modifyDate,language(name),description,completed,mature,url,isPaywalled,user(username),parts(id,title),cover"
|
|
||||||
) as response:
|
|
||||||
if not response.ok:
|
|
||||||
if response.status in [404, 400]:
|
|
||||||
return {}
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
body = await response.json()
|
|
||||||
|
|
||||||
return body
|
|
||||||
|
|
||||||
|
|
||||||
@backoff.on_exception(backoff.expo, ClientResponseError, max_time=15)
|
|
||||||
async def fetch_part_content(part_id: int, cookies: Optional[dict] = None) -> str:
|
|
||||||
"""Return the HTML Content of a Part."""
|
|
||||||
async with (
|
|
||||||
CachedSession(headers=headers, cache=cache)
|
|
||||||
if not cookies
|
|
||||||
else ClientSession(headers=headers, cookies=cookies)
|
|
||||||
) as session: # Don't cache requests with Cookies.
|
|
||||||
async with session.get(
|
|
||||||
f"https://www.wattpad.com/apiv2/?m=storytext&id={part_id}"
|
|
||||||
) as response:
|
|
||||||
if not response.ok:
|
|
||||||
if response.status in [404, 400]:
|
|
||||||
return ""
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
body = await response.text()
|
|
||||||
|
|
||||||
return body
|
|
||||||
|
|
||||||
|
|
||||||
@backoff.on_exception(backoff.expo, ClientResponseError, max_time=15)
|
|
||||||
async def fetch_cover(url: str, cookies: Optional[dict] = None) -> bytes:
|
|
||||||
"""Fetch image bytes."""
|
|
||||||
async with (
|
|
||||||
CachedSession(headers=headers, cache=cache)
|
|
||||||
if not cookies
|
|
||||||
else ClientSession(headers=headers, cookies=cookies)
|
|
||||||
) as session: # Don't cache requests with Cookies.
|
|
||||||
async with session.get(url) as response:
|
|
||||||
if not response.ok:
|
|
||||||
if response.status in [404, 400]:
|
|
||||||
return bytes()
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
body = await response.read()
|
|
||||||
|
|
||||||
return body
|
|
||||||
|
|
||||||
|
|
||||||
# --- EPUB Generation --- #
|
|
||||||
|
|
||||||
|
|
||||||
def set_metadata(book, data):
|
|
||||||
book.add_author(data["user"]["username"])
|
|
||||||
|
|
||||||
book.add_metadata("DC", "description", data["description"])
|
|
||||||
book.add_metadata("DC", "created", data["createDate"])
|
|
||||||
book.add_metadata("DC", "modified", data["modifyDate"])
|
|
||||||
book.add_metadata("DC", "language", data["language"]["name"])
|
|
||||||
|
|
||||||
book.add_metadata(
|
|
||||||
None, "meta", "", {"name": "tags", "content": ", ".join(data["tags"])}
|
|
||||||
)
|
|
||||||
book.add_metadata(
|
|
||||||
None, "meta", "", {"name": "mature", "content": str(int(data["mature"]))}
|
|
||||||
)
|
|
||||||
book.add_metadata(
|
|
||||||
None, "meta", "", {"name": "completed", "content": str(int(data["completed"]))}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def set_cover(book, data, cookies: Optional[dict] = None):
|
|
||||||
book.set_cover("cover.jpg", await fetch_cover(data["cover"], cookies=cookies))
|
|
||||||
|
|
||||||
|
|
||||||
async def add_chapters(
|
|
||||||
book, data, download_images: bool = False, cookies: Optional[dict] = None
|
|
||||||
):
|
|
||||||
chapters = []
|
|
||||||
|
|
||||||
for cidx, part in enumerate(data["parts"]):
|
|
||||||
content = await fetch_part_content(part["id"], cookies=cookies)
|
|
||||||
title = part["title"]
|
|
||||||
clean_title = slugify(title)
|
|
||||||
|
|
||||||
# Thanks https://eu17.proxysite.com/process.php?d=5VyWYcoQl%2BVF0BYOuOavtvjOloFUZz2BJ%2Fepiusk6Nz7PV%2B9i8rs7cFviGftrBNll%2B0a3qO7UiDkTt4qwCa0fDES&b=1
|
|
||||||
chapter = epub.EpubHtml(
|
|
||||||
title=title,
|
|
||||||
file_name=f"{cidx}.xhtml", # Used to be clean_title.xhtml, but that broke Arabic support as slugify turns arabic strings into '', leading to multiple files with the same name, breaking those chapters.
|
|
||||||
lang=data["language"]["name"],
|
|
||||||
)
|
|
||||||
|
|
||||||
if download_images:
|
|
||||||
soup = BeautifulSoup(content, "lxml")
|
|
||||||
async with (
|
|
||||||
CachedSession(headers=headers, cache=cache)
|
|
||||||
if not cookies
|
|
||||||
else ClientSession(headers=headers, cookies=cookies)
|
|
||||||
) as session: # Don't cache requests with Cookies.
|
|
||||||
for idx, image in enumerate(soup.find_all("img")):
|
|
||||||
if not image["src"]:
|
|
||||||
continue
|
|
||||||
async with session.get(image["src"]) as response:
|
|
||||||
img = epub.EpubImage(
|
|
||||||
media_type="image/jpeg",
|
|
||||||
content=await response.read(),
|
|
||||||
file_name=f"static/{cidx}/{idx}.jpeg",
|
|
||||||
)
|
|
||||||
book.add_item(img)
|
|
||||||
content = content.replace(
|
|
||||||
str(image), f'<img src="static/{cidx}/{idx}.jpeg"/>'
|
|
||||||
)
|
|
||||||
|
|
||||||
chapter.set_content(f"<h1>{title}</h1>" + content)
|
|
||||||
|
|
||||||
chapters.append(chapter)
|
|
||||||
|
|
||||||
yield title # Yield the chapter's title upon insertion preceeded by retrieval.
|
|
||||||
|
|
||||||
for chapter in chapters:
|
|
||||||
book.add_item(chapter)
|
|
||||||
|
|
||||||
book.toc = tuple(chapters)
|
|
||||||
|
|
||||||
# Thanks https://github.com/aerkalov/ebooklib/blob/master/samples/09_create_image/create.py
|
|
||||||
book.add_item(epub.EpubNcx())
|
|
||||||
book.add_item(epub.EpubNav())
|
|
||||||
|
|
||||||
# create spine
|
|
||||||
book.spine = ["nav"] + chapters
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# ruff: noqa: F401
|
||||||
|
|
||||||
|
from .create_book import (
|
||||||
|
fetch_cookies,
|
||||||
|
fetch_story,
|
||||||
|
fetch_story_content_zip,
|
||||||
|
fetch_story_from_partId,
|
||||||
|
)
|
||||||
|
from .exceptions import PartNotFoundError, StoryNotFoundError, WattpadError
|
||||||
|
from .generators import EPUBGenerator, PDFGenerator
|
||||||
|
from .logs import logger
|
||||||
|
from .parser import fetch_image
|
||||||
|
from .utils import slugify
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from pydantic import field_validator, model_validator
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class CacheTypes(Enum):
|
||||||
|
file = "file"
|
||||||
|
redis = "redis"
|
||||||
|
|
||||||
|
|
||||||
|
class Config(BaseSettings):
|
||||||
|
# Values can be overriden by envvars.
|
||||||
|
|
||||||
|
USE_CACHE: bool = True
|
||||||
|
CACHE_TYPE: CacheTypes = CacheTypes.file
|
||||||
|
REDIS_CONNECTION_URL: str = ""
|
||||||
|
|
||||||
|
@field_validator("USE_CACHE", mode="before")
|
||||||
|
def validate_use_cache(cls, value):
|
||||||
|
# Return default if value is an empty string
|
||||||
|
if value == "":
|
||||||
|
return True # Default value for USE_CACHE
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("CACHE_TYPE", mode="before")
|
||||||
|
def validate_cache_type(cls, value):
|
||||||
|
# Thanks https://stackoverflow.com/a/78157474
|
||||||
|
if value == "":
|
||||||
|
return "file"
|
||||||
|
return value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def prevent_mismatched_redis_url(self):
|
||||||
|
match self.CACHE_TYPE:
|
||||||
|
case CacheTypes.file:
|
||||||
|
if self.REDIS_CONNECTION_URL:
|
||||||
|
raise ValueError(
|
||||||
|
"REDIS_CONNECTION_URL provided when File cache selected. To use Redis as a cache, set CACHE_TYPE=redis."
|
||||||
|
)
|
||||||
|
case CacheTypes.redis:
|
||||||
|
if not self.REDIS_CONNECTION_URL:
|
||||||
|
raise ValueError(
|
||||||
|
"REDIS_CONNECTION_URL not provided when Redis cache selected. To use File cache, set CACHE_TYPE=file."
|
||||||
|
)
|
||||||
|
return self
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import backoff
|
||||||
|
from aiohttp import ClientResponseError
|
||||||
|
from aiohttp_client_cache.session import CachedSession
|
||||||
|
from eliot import start_action
|
||||||
|
from pydantic import TypeAdapter
|
||||||
|
|
||||||
|
from .exceptions import PartNotFoundError, StoryNotFoundError
|
||||||
|
from .logs import logger
|
||||||
|
from .models import Story
|
||||||
|
from .vars import cache, headers
|
||||||
|
|
||||||
|
story_ta = TypeAdapter(Story)
|
||||||
|
|
||||||
|
# --- #
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_cookies(username: str, password: str) -> dict:
|
||||||
|
# source: https://github.com/TheOnlyWayUp/WP-DM-Export/blob/dd4c7c51cb43f2108e0f63fc10a66cd24a740e4e/src/API/src/main.py#L25-L58
|
||||||
|
"""Retrieves authorization cookies from Wattpad by logging in with user creds.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username (str): Username.
|
||||||
|
password (str): Password.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Bad status code.
|
||||||
|
ValueError: No cookies returned.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Authorization cookies.
|
||||||
|
"""
|
||||||
|
with start_action(action_type="api_fetch_cookies"):
|
||||||
|
async with CachedSession(headers=headers, cache=None) as session:
|
||||||
|
async with session.post(
|
||||||
|
"https://www.wattpad.com/auth/login?nextUrl=%2F&_data=routes%2Fauth.login",
|
||||||
|
data={
|
||||||
|
"username": username.lower(),
|
||||||
|
"password": password,
|
||||||
|
}, # the username.lower() is for caching
|
||||||
|
) as response:
|
||||||
|
if response.status != 204:
|
||||||
|
raise ValueError("Not a 204.")
|
||||||
|
|
||||||
|
cookies = {
|
||||||
|
k: v.value
|
||||||
|
for k, v in response.cookies.items() # Thanks https://stackoverflow.com/a/32281245
|
||||||
|
}
|
||||||
|
|
||||||
|
if not cookies:
|
||||||
|
raise ValueError("No cookies.")
|
||||||
|
|
||||||
|
return cookies
|
||||||
|
|
||||||
|
|
||||||
|
# --- API Calls --- #
|
||||||
|
|
||||||
|
|
||||||
|
@backoff.on_exception(backoff.expo, ClientResponseError, max_time=15)
|
||||||
|
async def fetch_story_from_partId(
|
||||||
|
part_id: int, cookies: Optional[dict] = None
|
||||||
|
) -> tuple[int, Story]:
|
||||||
|
"""Fetch Story metadata from a Part ID."""
|
||||||
|
with start_action(action_type="api_fetch_storyFromPartId"):
|
||||||
|
async with CachedSession(
|
||||||
|
headers=headers, cache=None if cookies else cache
|
||||||
|
) as session: # Don't cache requests with Cookies.
|
||||||
|
async with session.get(
|
||||||
|
f"https://www.wattpad.com/api/v3/story_parts/{part_id}?fields=groupId,group(tags,id,title,createDate,modifyDate,language(name),description,completed,mature,url,isPaywalled,user(username,avatar,description),parts(id,title),cover,copyright)"
|
||||||
|
) as response:
|
||||||
|
body = await response.json()
|
||||||
|
|
||||||
|
if response.status == 400:
|
||||||
|
match body.get("error_code"):
|
||||||
|
case 1020: # "Story part not found"
|
||||||
|
logger.info(f"{part_id=} not found on Wattpad, returning.")
|
||||||
|
raise PartNotFoundError()
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
return int(body["groupId"]), story_ta.validate_python(body["group"])
|
||||||
|
|
||||||
|
|
||||||
|
@backoff.on_exception(backoff.expo, ClientResponseError, max_time=15)
|
||||||
|
async def fetch_story(story_id: int, cookies: Optional[dict] = None) -> Story:
|
||||||
|
"""Fetch Story metadata from a Story ID."""
|
||||||
|
with start_action(action_type="api_fetch_story", story_id=story_id):
|
||||||
|
async with CachedSession(
|
||||||
|
headers=headers, cookies=cookies, cache=None if cookies else cache
|
||||||
|
) as session:
|
||||||
|
async with session.get(
|
||||||
|
f"https://www.wattpad.com/api/v3/stories/{story_id}?fields=tags,id,title,createDate,modifyDate,language(name),description,completed,mature,url,isPaywalled,user(username,avatar,description),parts(id,title),cover,copyright"
|
||||||
|
) as response:
|
||||||
|
body = await response.json()
|
||||||
|
|
||||||
|
if response.status == 400:
|
||||||
|
match body.get("error_code"):
|
||||||
|
case 1017: # "Story not found"
|
||||||
|
logger.info(f"{story_id=} not found on Wattpad, returning.")
|
||||||
|
raise StoryNotFoundError()
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
return story_ta.validate_python(body)
|
||||||
|
|
||||||
|
|
||||||
|
@backoff.on_exception(backoff.expo, ClientResponseError, max_time=15)
|
||||||
|
async def fetch_story_content_zip(
|
||||||
|
story_id: int, cookies: Optional[dict] = None
|
||||||
|
) -> BytesIO:
|
||||||
|
"""BytesIO Stream of an Archive of Part Contents for a Story."""
|
||||||
|
with start_action(action_type="api_fetch_storyZip", story_id=story_id):
|
||||||
|
async with CachedSession(
|
||||||
|
headers=headers,
|
||||||
|
cookies=cookies,
|
||||||
|
cache=None if cookies else cache,
|
||||||
|
) as session:
|
||||||
|
async with session.get(
|
||||||
|
f"https://www.wattpad.com/apiv2/?m=storytext&group_id={story_id}&output=zip"
|
||||||
|
) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
bytes_stream = BytesIO(await response.read())
|
||||||
|
|
||||||
|
return bytes_stream
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
class WattpadError(Exception):
|
||||||
|
"""Base Exception class for Wattpad related errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class StoryNotFoundError(WattpadError):
|
||||||
|
"""Display the "This story was not found" error to the user."""
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class PartNotFoundError(StoryNotFoundError):
|
||||||
|
...
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# ruff: noqa: F401
|
||||||
|
|
||||||
|
from .epub import EPUBGenerator
|
||||||
|
from .pdf import PDFGenerator
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from ebooklib import epub
|
||||||
|
from re import sub
|
||||||
|
|
||||||
|
from ..models import Story
|
||||||
|
from .types import AbstractGenerator
|
||||||
|
|
||||||
|
|
||||||
|
class EPUBGenerator(AbstractGenerator):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
metadata: Story,
|
||||||
|
part_trees: list[BeautifulSoup],
|
||||||
|
cover: bytes,
|
||||||
|
images: list[list[bytes | None]],
|
||||||
|
):
|
||||||
|
self.story = metadata
|
||||||
|
self.parts = part_trees
|
||||||
|
self.cover = cover
|
||||||
|
self.images = images
|
||||||
|
|
||||||
|
self.book: epub.EpubBook = epub.EpubBook()
|
||||||
|
|
||||||
|
def add_metadata(self):
|
||||||
|
"""Add metadata to epub."""
|
||||||
|
self.book.add_author(self.story["user"]["username"])
|
||||||
|
|
||||||
|
self.book.add_metadata("DC", "title", self.story["title"])
|
||||||
|
self.book.add_metadata("DC", "description", self.story["description"])
|
||||||
|
self.book.add_metadata("DC", "date", self.story["createDate"])
|
||||||
|
self.book.add_metadata("DC", "modified", self.story["modifyDate"])
|
||||||
|
self.book.add_metadata("DC", "language", self.story["language"]["name"])
|
||||||
|
|
||||||
|
self.book.add_metadata(
|
||||||
|
None, "meta", "", {"name": "tags", "content": ", ".join(self.story["tags"])}
|
||||||
|
)
|
||||||
|
self.book.add_metadata(
|
||||||
|
None,
|
||||||
|
"meta",
|
||||||
|
"",
|
||||||
|
{"name": "mature", "content": str(int(self.story["mature"]))},
|
||||||
|
)
|
||||||
|
self.book.add_metadata(
|
||||||
|
None,
|
||||||
|
"meta",
|
||||||
|
"",
|
||||||
|
{"name": "completed", "content": str(int(self.story["completed"]))},
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_cover(self):
|
||||||
|
"""Add cover to epub."""
|
||||||
|
self.book.set_cover("cover.jpg", self.cover)
|
||||||
|
cover_chapter = epub.EpubHtml(
|
||||||
|
file_name="titlepage.xhtml", # Standard for cover page
|
||||||
|
)
|
||||||
|
cover_chapter.set_content('<img src="cover.jpg">')
|
||||||
|
self.book.add_item(cover_chapter)
|
||||||
|
|
||||||
|
def add_chapters(self):
|
||||||
|
"""Add chapters to epub, replacing references to image urls to static image paths if images are provided during initialization."""
|
||||||
|
chapters = []
|
||||||
|
|
||||||
|
for idx, (part, tree) in enumerate(zip(self.story["parts"], self.parts)):
|
||||||
|
chapter = epub.EpubHtml(
|
||||||
|
title=sub(r'[\x00-\x1F\x7F]', '', part["title"]), file_name=f"{idx}_{part['id']}.xhtml" # Removes control characters from chapter title
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.images:
|
||||||
|
for img_idx, (img_data, img_tag) in enumerate(
|
||||||
|
zip(self.images[idx], tree.find_all("img"))
|
||||||
|
):
|
||||||
|
path = f"static/{idx}_{part['id']}/{img_idx}.jpeg"
|
||||||
|
img = epub.EpubImage(
|
||||||
|
media_type="image/jpeg", content=img_data, file_name=path
|
||||||
|
)
|
||||||
|
self.book.add_item(img)
|
||||||
|
|
||||||
|
img_tag["src"] = path
|
||||||
|
|
||||||
|
chapter.set_content(tree.prettify())
|
||||||
|
self.book.add_item(chapter)
|
||||||
|
chapters.append(chapter)
|
||||||
|
|
||||||
|
# ! Review, are these needed? #11
|
||||||
|
self.book.toc = chapters
|
||||||
|
|
||||||
|
# Thanks https://github.com/aerkalov/ebooklib/blob/master/samples/09_create_image/create.py
|
||||||
|
self.book.add_item(epub.EpubNcx())
|
||||||
|
self.book.add_item(epub.EpubNav())
|
||||||
|
|
||||||
|
# create spine
|
||||||
|
self.book.spine = ["nav"] + chapters
|
||||||
|
|
||||||
|
def compile(self):
|
||||||
|
self.add_metadata()
|
||||||
|
self.add_cover()
|
||||||
|
self.add_chapters()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def dump(self) -> BytesIO:
|
||||||
|
# Thanks https://stackoverflow.com/a/75398222
|
||||||
|
buffer = BytesIO()
|
||||||
|
epub.write_epub(buffer, self.book)
|
||||||
|
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
|
return buffer
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
from base64 import b64encode
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import NamedTemporaryFile, _TemporaryFileWrapper
|
||||||
|
|
||||||
|
import pydyf
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from jinja2 import Template
|
||||||
|
from weasyprint import CSS, HTML, Document
|
||||||
|
from weasyprint.text.fonts import FontConfiguration
|
||||||
|
|
||||||
|
from ..models import Story
|
||||||
|
from .types import AbstractGenerator
|
||||||
|
|
||||||
|
DATA_PATH = Path(__file__).parent / "pdf"
|
||||||
|
ASSET_PATH = DATA_PATH / "assets"
|
||||||
|
|
||||||
|
COPYRIGHT_DATA = {
|
||||||
|
1: {
|
||||||
|
"name": "All Rights Reserved",
|
||||||
|
"statement": "©️ {published_year} by {username}. All Rights Reserved.",
|
||||||
|
"freedoms": "No reuse, redistribution, or modification without permission.",
|
||||||
|
"printing": "Not allowed without explicit permission.",
|
||||||
|
"asset": None,
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
"name": "Public Domain",
|
||||||
|
"statement": "This work is in the public domain. Originally published in {published_year} by {username}.",
|
||||||
|
"freedoms": "Free to use for any purpose without permission.",
|
||||||
|
"printing": "Allowed for personal or commercial purposes.",
|
||||||
|
"asset": ASSET_PATH / "cc-zero.png",
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
"name": "Creative Commons Attribution (CC-BY)",
|
||||||
|
"statement": "©️ {published_year} by {username}. This work is licensed under a Creative Commons Attribution 4.0 International License.",
|
||||||
|
"freedoms": "Allows reuse, redistribution, and modification with credit to the author.",
|
||||||
|
"printing": "Allowed with proper credit.",
|
||||||
|
"asset": ASSET_PATH / "by.png",
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
"name": "CC Attribution NonCommercial (CC-BY-NC)",
|
||||||
|
"statement": "©️ {published_year} by {username}. This work is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.",
|
||||||
|
"freedoms": "Allows reuse and modification for non-commercial purposes with credit.",
|
||||||
|
"printing": "Allowed for non-commercial purposes with proper credit.",
|
||||||
|
"asset": ASSET_PATH / "by-nc.png",
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
"name": "CC Attribution NonCommercial NoDerivs (CC-BY-NC-ND)",
|
||||||
|
"statement": "©️ {published_year} by {username}. This work is licensed under a Creative Commons Attribution-NonCommercial-NoDerivs 4.0 International License.",
|
||||||
|
"freedoms": "Allows sharing in original form for non-commercial purposes with credit; no modifications allowed.",
|
||||||
|
"printing": "Allowed for non-commercial purposes in original form with proper credit.",
|
||||||
|
"asset": ASSET_PATH / "by-nc-nd.png",
|
||||||
|
},
|
||||||
|
6: {
|
||||||
|
"name": "CC Attribution NonCommercial ShareAlike (CC-BY-NC-SA)",
|
||||||
|
"statement": "©️ {published_year} by {username}. This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.",
|
||||||
|
"freedoms": "Allows reuse and modification for non-commercial purposes under the same license, with credit.",
|
||||||
|
"printing": "Allowed for non-commercial purposes with proper credit under the same license.",
|
||||||
|
"asset": ASSET_PATH / "by-nc-sa.png",
|
||||||
|
},
|
||||||
|
7: {
|
||||||
|
"name": "CC Attribution ShareAlike (CC-BY-SA)",
|
||||||
|
"statement": "©️ {published_year} by {username}. This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.",
|
||||||
|
"freedoms": "Allows reuse and modification for any purpose under the same license, with credit.",
|
||||||
|
"printing": "Allowed with proper credit under the same license.",
|
||||||
|
"asset": ASSET_PATH / "by-sa.png",
|
||||||
|
},
|
||||||
|
8: {
|
||||||
|
"name": "CC Attribution NoDerivs (CC-BY-ND)",
|
||||||
|
"statement": "©️ {published_year} by {username}. This work is licensed under a Creative Commons Attribution-NoDerivs 4.0 International License.",
|
||||||
|
"freedoms": "Allows sharing in original form for any purpose with credit; no modifications allowed.",
|
||||||
|
"printing": "Allowed in original form with proper credit.",
|
||||||
|
"asset": ASSET_PATH / "by-nd.png",
|
||||||
|
},
|
||||||
|
} # Maps Wattpad Copyright IDs to their corresponding data.
|
||||||
|
|
||||||
|
with open(DATA_PATH / "stylesheet.css") as reader:
|
||||||
|
STYLESHEET = reader.read()
|
||||||
|
|
||||||
|
|
||||||
|
with open(DATA_PATH / "book.html") as reader:
|
||||||
|
TEMPLATE = reader.read()
|
||||||
|
|
||||||
|
|
||||||
|
class PDFGenerator(AbstractGenerator):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
metadata: Story,
|
||||||
|
part_trees: list[BeautifulSoup],
|
||||||
|
cover: bytes,
|
||||||
|
images: list[list[bytes | None]],
|
||||||
|
author_image: bytes,
|
||||||
|
):
|
||||||
|
self.story = metadata
|
||||||
|
self.parts = part_trees
|
||||||
|
self.cover = cover
|
||||||
|
self.images = images
|
||||||
|
self.author = author_image
|
||||||
|
|
||||||
|
self.book: _TemporaryFileWrapper = NamedTemporaryFile(suffix=".pdf") # type: ignore
|
||||||
|
self.content = TEMPLATE
|
||||||
|
|
||||||
|
def generate_chapters(self) -> dict[int, str]:
|
||||||
|
"""Return a dictionary of part_ids to content trees, with image URLs replaced with base64 encoded images if provided during initialization."""
|
||||||
|
data: dict[int, str] = {}
|
||||||
|
for idx, (part, tree) in enumerate(zip(self.story["parts"], self.parts)):
|
||||||
|
if self.images:
|
||||||
|
for img_idx, (img_data, img_tag) in enumerate(
|
||||||
|
zip(self.images[idx], tree.find_all("img"))
|
||||||
|
):
|
||||||
|
if not img_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
img_tag["src"] = (
|
||||||
|
f"data:image/jpg;base64,{b64encode(img_data).decode()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
data[part["id"]] = tree.prettify()
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def populate_template(self, parts: dict[int, str]):
|
||||||
|
"""Populate HTML Template with Story data."""
|
||||||
|
copyright = COPYRIGHT_DATA[self.story["copyright"]]
|
||||||
|
data = {
|
||||||
|
"statement": copyright["statement"].format(
|
||||||
|
username=self.story["user"]["username"],
|
||||||
|
published_year=self.story["createDate"].split("-", 2)[0],
|
||||||
|
),
|
||||||
|
"author": self.story["user"]["username"],
|
||||||
|
"freedoms": copyright["freedoms"],
|
||||||
|
"printing": copyright["printing"],
|
||||||
|
"book_id": self.story["id"],
|
||||||
|
"book_title": self.story["title"],
|
||||||
|
"cover": f"data:image/jpg;base64,{b64encode(self.cover).decode()}",
|
||||||
|
"username": self.story["user"]["username"],
|
||||||
|
"author_bio": self.story["user"]["description"],
|
||||||
|
"clean_tags": ", ".join(self.story["tags"]),
|
||||||
|
"created": self.story["createDate"],
|
||||||
|
"modified": self.story["modifyDate"],
|
||||||
|
"is_completed": self.story["completed"],
|
||||||
|
"is_mature": self.story["mature"],
|
||||||
|
"description": self.story["description"],
|
||||||
|
"avatar": b64encode(self.author).decode(),
|
||||||
|
"copyright": {
|
||||||
|
"data": (
|
||||||
|
b64encode(copyright["asset"].read_bytes()).decode()
|
||||||
|
if copyright["asset"]
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"name": copyright["name"],
|
||||||
|
},
|
||||||
|
"parts": parts,
|
||||||
|
}
|
||||||
|
|
||||||
|
self.content: str = Template(self.content).render(data)
|
||||||
|
|
||||||
|
def write_custom_metadata(self, document: Document, pdf: pydyf.PDF):
|
||||||
|
"""Write non-standard metadata fields to the PDF."""
|
||||||
|
pdf.info["completed"] = pydyf.String(str(self.story["completed"]))
|
||||||
|
pdf.info["mature"] = pydyf.String(str(self.story["mature"]))
|
||||||
|
|
||||||
|
def generate_pdf(self):
|
||||||
|
"""Generate and write the PDF to a temporary file (self.book)."""
|
||||||
|
font_config = FontConfiguration()
|
||||||
|
|
||||||
|
stylesheet_obj = CSS(string=STYLESHEET, font_config=font_config)
|
||||||
|
|
||||||
|
html_obj = HTML(string=self.content)
|
||||||
|
html_obj.write_pdf(
|
||||||
|
self.book.name,
|
||||||
|
stylesheets=[stylesheet_obj],
|
||||||
|
font_config=font_config,
|
||||||
|
finisher=self.write_custom_metadata,
|
||||||
|
options={"custom_metadata": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
def compile(self):
|
||||||
|
parts = self.generate_chapters()
|
||||||
|
self.populate_template(parts)
|
||||||
|
self.generate_pdf()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def dump(self) -> BytesIO:
|
||||||
|
self.book.seek(0)
|
||||||
|
buffer = BytesIO(self.book.read())
|
||||||
|
self.book.close()
|
||||||
|
|
||||||
|
return buffer
|
||||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
@@ -0,0 +1,85 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ langcode }}">
|
||||||
|
|
||||||
|
|
||||||
|
<head>
|
||||||
|
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
|
<!-- https://doc.courtbouillon.org/weasyprint/stable/api_reference.html#weasyprint.document.DocumentMetadata -->
|
||||||
|
<title>{{ book_title }}</title>
|
||||||
|
<meta name=description content="{{description}}">
|
||||||
|
<meta name=author content="{{author}}">
|
||||||
|
<meta name=keywords content="{{clean_tags}}">
|
||||||
|
<meta name=language content="{{langcode}}">
|
||||||
|
<meta name=dcterms.created content="{{created}}">
|
||||||
|
<meta name=dcterms.modified content="{{modified}}">
|
||||||
|
<meta name=generator content="Dhanush Rambhatla (TheOnlyWayUp - https://rambhat.la) and WattpadDownloader">
|
||||||
|
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<section class="fullpage">
|
||||||
|
<img src="{{ cover }}" alt="Cover">
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="copyright-container">
|
||||||
|
<h1 id="copyright-notice">Copyright Notice</h1>
|
||||||
|
|
||||||
|
<h2 id="copyright-title">{{ book_title }}</h2>
|
||||||
|
<p id="copyright-author">By {{ author }}</p>
|
||||||
|
|
||||||
|
<div id="copyright-separator"></div>
|
||||||
|
|
||||||
|
<p id="copyright-ex-libris">Ex Libris Sapientiae</p>
|
||||||
|
|
||||||
|
<div id="copyright-separator"></div>
|
||||||
|
|
||||||
|
{% if copyright.data %}
|
||||||
|
<img src="data:image/jpg;base64,{{copyright.data}}" alt="{{copyright.name}}" width="88" height="31"
|
||||||
|
id="copyright-license-image">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p id="copyright-copyright">{{ statement }}</p>
|
||||||
|
|
||||||
|
<p id="copyright-rights">{{ freedoms }}</p>
|
||||||
|
|
||||||
|
<p id="copyright-printing">Printing: {{ printing }}</p>
|
||||||
|
|
||||||
|
<p id="book-link">
|
||||||
|
ID: {{ book_id }}.
|
||||||
|
<a href="https://wattpad.com/story/{{ book_id }}" target="_blank" id="copyright-link">View this Book Online</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="book">
|
||||||
|
<section id="contents" class="toc">
|
||||||
|
<h1>Table of Contents</h1>
|
||||||
|
<ul>
|
||||||
|
{% for part_id in parts %}
|
||||||
|
<li><a href="#{{part_id}}"></a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% for part_id in parts %}
|
||||||
|
|
||||||
|
{{parts[part_id] | safe}}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>About the Author</h1>
|
||||||
|
<div id="author-container">
|
||||||
|
<div id="author-about">
|
||||||
|
<img src="data:image/jpg;base64,{{avatar}}" alt="{{author}}'s profile picture" id="author-profile-picture">
|
||||||
|
<h2 id="author-name">
|
||||||
|
<a href="https://wattpad.com/user/{{ username }}" id="author-link">{{ username }}</a>
|
||||||
|
</h2>
|
||||||
|
<hr id="author-divider">
|
||||||
|
<p id="author-bio">
|
||||||
|
{{ author_bio }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
Copyright (c) 2010, ParaType Ltd. (http://www.paratype.com/public),
|
||||||
|
with Reserved Font Names "PT Sans", "PT Serif" and "ParaType".
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
The fonts need to be symlinked to /tmp/fonts, this allows the fonts to be loaded during development and during build-time.
|
||||||
|
It's assumed fonts will be present at `/tmp/fonts`, during development they're at `/src/api/src/create_book/generators/pdf`, and during deployment they're at `/app/src/api/src/create_book/generators/pdf`. This seems like a clean solution.
|
||||||
|
|
||||||
|
`Fontconfig error: Cannot load default config file: No such file: (null)`
|
||||||
|
If the fonts aren't found, this warning pops up in console. It won't cause downloads to fail, though.
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'PT Serif';
|
||||||
|
src: url('/tmp/fonts/PTSerif-Regular.ttf') format('truetype');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'PT Serif';
|
||||||
|
src: url('/tmp/fonts/PTSerif-Bold.ttf') format('truetype');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'PT Serif';
|
||||||
|
src: url('/tmp/fonts/PTSerif-Italic.ttf') format('truetype');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'PT Serif';
|
||||||
|
src: url('/tmp/fonts/PTSerif-BoldItalic.ttf') format('truetype');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-serif-regular {
|
||||||
|
font-family: "PT Serif", serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-serif-bold {
|
||||||
|
font-family: "PT Serif", serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-serif-regular-italic {
|
||||||
|
font-family: "PT Serif", serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-serif-bold-italic {
|
||||||
|
font-family: "PT Serif", serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@page {
|
||||||
|
margin: 2cm 2cm 3cm 2cm;
|
||||||
|
size: 148mm 210mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page :left {
|
||||||
|
@bottom-left {
|
||||||
|
content: counter(page);
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
@bottom-right {
|
||||||
|
content: string(heading);
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@page :right {
|
||||||
|
@bottom-left {
|
||||||
|
content: string(heading);
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
@bottom-right {
|
||||||
|
content: counter(page);
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@page full {
|
||||||
|
@bottom-right {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@bottom-left {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
background: black;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page :blank {
|
||||||
|
@bottom-right {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@bottom-left {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@page clean {
|
||||||
|
@bottom-right {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@bottom-left {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
counter-reset: h2-counter;
|
||||||
|
font-size: 10pt;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: 2;
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
margin: 2em auto;
|
||||||
|
max-width: 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#contents {
|
||||||
|
border-bottom: 1px dashed rgb(100,000,100);
|
||||||
|
h2 {
|
||||||
|
font-family: "PT Serif", serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
padding-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-title {
|
||||||
|
counter-increment: h2-counter;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 3em;
|
||||||
|
height: 6cm;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin: 0;
|
||||||
|
string-set: heading content();
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
font-family: "PT Serif", serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal !important;
|
||||||
|
font-size: 36px !important; /* Uniform size */
|
||||||
|
margin-bottom: 20px; /* Space below the heading */
|
||||||
|
border-bottom: 2px solid rgb(100, 100, 100); /* Black line */
|
||||||
|
padding-bottom: 10px; /* Space between text and line */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 16px !important; /* Standardize paragraph size */
|
||||||
|
line-height: 1.6 !important; /* Improve readability */
|
||||||
|
margin: 10px 0 !important; /* Space between paragraphs */
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-title::before {
|
||||||
|
content: "Chapter " counter(h2-counter) " ";
|
||||||
|
display: block;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: normal;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
break-after: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#contents {
|
||||||
|
page: clean;
|
||||||
|
}
|
||||||
|
#contents p {
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
#contents ul {
|
||||||
|
display: block;
|
||||||
|
margin: 1em 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
#contents li {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
#contents a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
#contents a::before {
|
||||||
|
content: target-counter(attr(href), h2-counter) '. ' target-text(attr(href));
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#contents a::after {
|
||||||
|
content: target-counter(attr(href), page);
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outro {
|
||||||
|
border-radius: 50% 50% 0 0 / 15mm 15mm 0 0;
|
||||||
|
display: block;
|
||||||
|
height: 90mm;
|
||||||
|
left: -30mm;
|
||||||
|
max-width: none;
|
||||||
|
object-fit: cover;
|
||||||
|
position: absolute;
|
||||||
|
top: 120mm;
|
||||||
|
width: 168mm;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullpage {
|
||||||
|
page: full;
|
||||||
|
}
|
||||||
|
.fullpage img {
|
||||||
|
bottom: 0;
|
||||||
|
height: 210mm;
|
||||||
|
left: 0;
|
||||||
|
margin: 0;
|
||||||
|
max-width: none;
|
||||||
|
object-fit: cover;
|
||||||
|
position: absolute;
|
||||||
|
width: 148mm;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.fullpage:last-child {
|
||||||
|
break-before: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #3182ce;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
|
||||||
|
/* Cross-browser transition */
|
||||||
|
-webkit-transition: all 0.2s ease;
|
||||||
|
-moz-transition: all 0.2s ease;
|
||||||
|
-o-transition: all 0.2s ease;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
color: #2c5282;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Container centering for older browsers */
|
||||||
|
#author-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
-webkit-transform: translate(-50%, -50%); /* Old WebKit */
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#author-about {
|
||||||
|
padding: 20px;
|
||||||
|
/* Fallback for older browsers */
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#author-profile-picture {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
-webkit-border-radius: 100px; /* Old WebKit */
|
||||||
|
border-radius: 100px;
|
||||||
|
margin: 0 auto 20px auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#author-name {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#author-link {
|
||||||
|
color: #1a202c;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#author-link:hover {
|
||||||
|
color: #4a5568;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#author-divider {
|
||||||
|
width: 60px;
|
||||||
|
height: 2px;
|
||||||
|
background-color: #d1d5db;
|
||||||
|
border: none;
|
||||||
|
margin: 0 auto 20px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#author-bio {
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#copyright-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 60px auto;
|
||||||
|
text-align: center !important;
|
||||||
|
font-family: Georgia, serif !important;
|
||||||
|
line-height: 1.6 !important;
|
||||||
|
color: #333 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#copyright-notice {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#copyright-title {
|
||||||
|
font-size: 28px;
|
||||||
|
margin: 24px 0 4px 0;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#copyright-author {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0 0 32px 0;
|
||||||
|
color: #444;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#copyright-license-image {
|
||||||
|
margin: 20px 0;
|
||||||
|
width: 88px;
|
||||||
|
height: 31px;
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#copyright-copyright {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 16px 0;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#copyright-rights {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin: 8px 0;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#copyright-printing {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin: 8px 0;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#book-link {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin: 8px 0;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#copyright-separator {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
height: 1px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
position: relative;
|
||||||
|
margin: 2rem 1rem;
|
||||||
|
|
||||||
|
/* Gradient fallback */
|
||||||
|
background: -webkit-gradient(linear, left top, right top, from(transparent), color-stop(#718096), to(transparent));
|
||||||
|
background: -webkit-linear-gradient(left, transparent, #718096, transparent);
|
||||||
|
background: -moz-linear-gradient(left, transparent, #718096, transparent);
|
||||||
|
background: -o-linear-gradient(left, transparent, #718096, transparent);
|
||||||
|
background: linear-gradient(to right, transparent, #718096, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#copyright-ex-libris {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: #4a5568;
|
||||||
|
margin: 2rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#copyright-link {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
from tempfile import _TemporaryFileWrapper
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from ebooklib.epub import EpubBook
|
||||||
|
|
||||||
|
from ..models import Story
|
||||||
|
|
||||||
|
|
||||||
|
class AbstractGenerator:
|
||||||
|
"""Compile parsed part trees to a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metadata (Story): Story Metadata.
|
||||||
|
part_trees (List[BeautifulSoup]): Parsed part trees.
|
||||||
|
cover (bytes): Cover image.
|
||||||
|
images (List[List[bytes | None]]): An array of images for each chapter, if images have been downloaded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
metadata: Story,
|
||||||
|
part_trees: list[BeautifulSoup],
|
||||||
|
cover: bytes,
|
||||||
|
images: list[list[bytes | None]],
|
||||||
|
):
|
||||||
|
self.story = metadata
|
||||||
|
self.parts = part_trees
|
||||||
|
self.cover = cover
|
||||||
|
self.images = images
|
||||||
|
|
||||||
|
self.book: EpubBook | _TemporaryFileWrapper = None # type: ignore
|
||||||
|
|
||||||
|
def compile(self) -> Literal[True]:
|
||||||
|
"""Compile the part trees into the corresponding in-memory representation of the generator format.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Literal[True]: Compiled successfully.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def dump(self) -> BytesIO:
|
||||||
|
"""Return a Buffer of the compiled file."""
|
||||||
|
buffer = BytesIO()
|
||||||
|
|
||||||
|
return buffer
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import logging
|
||||||
|
from os import environ
|
||||||
|
|
||||||
|
from eliot import to_file
|
||||||
|
from eliot.stdlib import EliotHandler
|
||||||
|
|
||||||
|
handler = EliotHandler()
|
||||||
|
|
||||||
|
logging.getLogger("fastapi").setLevel(logging.INFO)
|
||||||
|
logging.getLogger("fastapi").addHandler(handler)
|
||||||
|
|
||||||
|
exiftool_logger = logging.getLogger("exiftool")
|
||||||
|
exiftool_logger.addHandler(handler)
|
||||||
|
|
||||||
|
logger = logging.Logger("wpd")
|
||||||
|
logger.addHandler(handler)
|
||||||
|
|
||||||
|
if environ.get("DEBUG"):
|
||||||
|
to_file(open("eliot.log", "wb"))
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from typing import Optional, TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
class CopyrightData(TypedDict):
|
||||||
|
name: str
|
||||||
|
statement: str
|
||||||
|
freedoms: str
|
||||||
|
printing: str
|
||||||
|
image_url: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class Language(TypedDict):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class User(TypedDict):
|
||||||
|
username: str
|
||||||
|
avatar: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
class Part(TypedDict):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
|
||||||
|
|
||||||
|
class Story(TypedDict):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
createDate: str
|
||||||
|
modifyDate: str
|
||||||
|
language: Language
|
||||||
|
user: User
|
||||||
|
description: str
|
||||||
|
cover: str
|
||||||
|
completed: bool
|
||||||
|
tags: list[str]
|
||||||
|
mature: bool
|
||||||
|
url: str
|
||||||
|
parts: list[Part]
|
||||||
|
isPaywalled: bool
|
||||||
|
copyright: int
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import asyncio
|
||||||
|
from itertools import batched
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
from bs4 import BeautifulSoup, Tag
|
||||||
|
from eliot import start_action
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from .vars import headers
|
||||||
|
|
||||||
|
|
||||||
|
def clean_tree(title: str, id: int, body: str) -> BeautifulSoup:
|
||||||
|
original_soup = BeautifulSoup(body, features="lxml")
|
||||||
|
new_soup = BeautifulSoup(
|
||||||
|
f"""
|
||||||
|
<h1 class="chapter-title" id={id}>{title}</h1>
|
||||||
|
<section class="chapter-body"></section>
|
||||||
|
""",
|
||||||
|
features="html.parser", # head/body tags aren't generated
|
||||||
|
)
|
||||||
|
|
||||||
|
insert_at = cast(Tag, new_soup.find("section"))
|
||||||
|
|
||||||
|
children = cast(Tag, original_soup.find("body")).children
|
||||||
|
for tag in cast(list[Tag], list(children)):
|
||||||
|
if tag.name != "p": # Casted to lower
|
||||||
|
continue
|
||||||
|
|
||||||
|
style = tag.attrs.get("style")
|
||||||
|
for child in cast(list[Tag], tag.children):
|
||||||
|
# tag is a <p> enclosing either text, media, or a break
|
||||||
|
|
||||||
|
if child.name in [None, "b", "i", "u", "strong", "em"]:
|
||||||
|
# text is enclosed, can be italic, bold, underlined, or a mix
|
||||||
|
tag.attrs = {}
|
||||||
|
p_tag = tag
|
||||||
|
if style:
|
||||||
|
p_tag["style"] = style
|
||||||
|
insert_at.append(p_tag)
|
||||||
|
break
|
||||||
|
|
||||||
|
elif child.name == "img":
|
||||||
|
# image is enclosed
|
||||||
|
img_tag = Tag(name="img")
|
||||||
|
img_tag.attrs = {
|
||||||
|
"height": child.attrs.get("data-original-height"),
|
||||||
|
"width": child.attrs.get("data-original-width"),
|
||||||
|
"src": child["src"],
|
||||||
|
}
|
||||||
|
if style:
|
||||||
|
img_tag["style"] = style
|
||||||
|
insert_at.append(img_tag)
|
||||||
|
|
||||||
|
elif child.name == "br":
|
||||||
|
# br tag is enclosed
|
||||||
|
br_tag = Tag(name="br", can_be_empty_element=True)
|
||||||
|
if style:
|
||||||
|
br_tag["style"] = style
|
||||||
|
insert_at.append(br_tag)
|
||||||
|
|
||||||
|
return new_soup
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_image(url: str) -> bytes | None:
|
||||||
|
"""Fetch image bytes."""
|
||||||
|
with start_action(action_type="api_fetch_image", url=url):
|
||||||
|
async with ClientSession(headers=headers) as session: # Don't cache images.
|
||||||
|
async with session.get(url) as response:
|
||||||
|
if not response.ok:
|
||||||
|
return None
|
||||||
|
|
||||||
|
body = await response.read()
|
||||||
|
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_tree_images(tree: BeautifulSoup):
|
||||||
|
"""Return a Generator of bytes containing image data for all images referenced in the tree."""
|
||||||
|
|
||||||
|
image_urls = []
|
||||||
|
for img in tree.find_all("img"):
|
||||||
|
parsed = urlparse(img["src"])
|
||||||
|
if parsed.scheme and parsed.netloc: # Test if valid URL
|
||||||
|
image_urls.append(img["src"])
|
||||||
|
|
||||||
|
images = []
|
||||||
|
for chunk in batched(image_urls, 3):
|
||||||
|
for image_data in await asyncio.gather(*[fetch_image(url) for url in chunk]):
|
||||||
|
images.append(image_data)
|
||||||
|
|
||||||
|
return images
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(value, allow_unicode=False) -> str:
|
||||||
|
"""
|
||||||
|
Taken from https://github.com/django/django/blob/master/django/utils/text.py
|
||||||
|
Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
|
||||||
|
dashes to single dashes. Remove characters that aren't alphanumerics,
|
||||||
|
underscores, or hyphens. Convert to lowercase. Also strip leading and
|
||||||
|
trailing whitespace, dashes, and underscores.
|
||||||
|
|
||||||
|
Thanks https://stackoverflow.com/a/295466.
|
||||||
|
"""
|
||||||
|
value = str(value)
|
||||||
|
if allow_unicode:
|
||||||
|
value = unicodedata.normalize("NFKC", value)
|
||||||
|
else:
|
||||||
|
value = (
|
||||||
|
unicodedata.normalize("NFKD", value)
|
||||||
|
.encode("ascii", "ignore")
|
||||||
|
.decode("ascii")
|
||||||
|
)
|
||||||
|
value = re.sub(r"[^\w\s-]", "", value.lower())
|
||||||
|
return re.sub(r"[-\s]+", "-", value).strip("-_")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from aiohttp_client_cache import FileBackend, RedisBackend
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from .config import CacheTypes, Config
|
||||||
|
from .logs import logger
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36"
|
||||||
|
}
|
||||||
|
|
||||||
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
|
||||||
|
if config.USE_CACHE:
|
||||||
|
match config.CACHE_TYPE:
|
||||||
|
case CacheTypes.file:
|
||||||
|
cache = FileBackend(use_temp=True, expire_after=43200) # 12 hours
|
||||||
|
case CacheTypes.redis:
|
||||||
|
cache = RedisBackend(
|
||||||
|
cache_name="wpd-aiohttp-cache",
|
||||||
|
address=config.REDIS_CONNECTION_URL,
|
||||||
|
expire_after=43200, # 12 hours
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cache = None
|
||||||
|
|
||||||
|
logger.info(f"Using {cache=}")
|
||||||
@@ -1,93 +1,243 @@
|
|||||||
from typing import Optional
|
"""WattpadDownloader API Server."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from enum import Enum
|
||||||
|
from os import getenv
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import FastAPI, HTTPException
|
from typing import Optional
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
|
from zipfile import ZipFile
|
||||||
from ebooklib import epub
|
|
||||||
from create_book import (
|
from aiohttp import ClientResponseError
|
||||||
retrieve_story,
|
from bs4 import BeautifulSoup
|
||||||
set_cover,
|
from eliot import start_action
|
||||||
set_metadata,
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
add_chapters,
|
from fastapi.responses import (
|
||||||
slugify,
|
FileResponse,
|
||||||
wp_get_cookies,
|
HTMLResponse,
|
||||||
|
RedirectResponse,
|
||||||
|
StreamingResponse,
|
||||||
)
|
)
|
||||||
import tempfile
|
|
||||||
from io import BytesIO
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from create_book import (
|
||||||
|
EPUBGenerator,
|
||||||
|
PDFGenerator,
|
||||||
|
StoryNotFoundError,
|
||||||
|
WattpadError,
|
||||||
|
fetch_cookies,
|
||||||
|
fetch_image,
|
||||||
|
fetch_story,
|
||||||
|
fetch_story_content_zip,
|
||||||
|
fetch_story_from_partId,
|
||||||
|
logger,
|
||||||
|
slugify,
|
||||||
|
)
|
||||||
|
from create_book.parser import clean_tree, fetch_tree_images
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
BUILD_PATH = Path(__file__).parent / "build"
|
BUILD_PATH = Path(__file__).parent / "build"
|
||||||
|
|
||||||
|
PDFS_ENABLED = True if getenv("VITE_ENABLE_PDFS") == "true" else False
|
||||||
|
|
||||||
|
|
||||||
|
class RequestCancelledMiddleware:
|
||||||
|
# Thanks https://github.com/fastapi/fastapi/discussions/11360#discussion-6427734
|
||||||
|
def __init__(self, app):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] != "http":
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Let's make a shared queue for the request messages
|
||||||
|
queue = asyncio.Queue()
|
||||||
|
|
||||||
|
async def message_poller(sentinel, handler_task):
|
||||||
|
nonlocal queue
|
||||||
|
while True:
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] == "http.disconnect":
|
||||||
|
handler_task.cancel()
|
||||||
|
return sentinel # Break the loop
|
||||||
|
|
||||||
|
# Puts the message in the queue
|
||||||
|
await queue.put(message)
|
||||||
|
|
||||||
|
sentinel = object()
|
||||||
|
handler_task = asyncio.create_task(self.app(scope, queue.get, send))
|
||||||
|
asyncio.create_task(message_poller(sentinel, handler_task))
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await handler_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("Cancelling task as connection closed")
|
||||||
|
|
||||||
|
|
||||||
|
app.add_middleware(RequestCancelledMiddleware)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadFormat(Enum):
|
||||||
|
pdf = "pdf"
|
||||||
|
epub = "epub"
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadMode(Enum):
|
||||||
|
story = "story"
|
||||||
|
part = "part"
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def home():
|
def home():
|
||||||
return FileResponse(BUILD_PATH / "index.html")
|
return FileResponse(BUILD_PATH / "index.html")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/download/{story_id}")
|
@app.exception_handler(ClientResponseError)
|
||||||
async def download_book(
|
def download_error_handler(request: Request, exception: ClientResponseError):
|
||||||
story_id: int,
|
match exception.status:
|
||||||
|
case 400 | 404:
|
||||||
|
return HTMLResponse(
|
||||||
|
status_code=404,
|
||||||
|
content='This story does not exist, or has been deleted. Support is available on the <a href="https://discord.gg/P9RHC4KCwd" target="_blank">Discord</a>',
|
||||||
|
)
|
||||||
|
case 429:
|
||||||
|
# Rate-limit by Wattpad
|
||||||
|
return HTMLResponse(
|
||||||
|
status_code=429,
|
||||||
|
content='The website is overloaded. Please try again in a few minutes. Support is available on the <a href="https://discord.gg/P9RHC4KCwd" target="_blank">Discord</a>',
|
||||||
|
)
|
||||||
|
case _:
|
||||||
|
# Unhandled error
|
||||||
|
return HTMLResponse(
|
||||||
|
status_code=500,
|
||||||
|
content='Something went wrong. Yell at me on the <a href="https://discord.gg/P9RHC4KCwd" target="_blank">Discord</a>',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(WattpadError)
|
||||||
|
def download_wp_error_handler(request: Request, exception: WattpadError):
|
||||||
|
if isinstance(exception, StoryNotFoundError):
|
||||||
|
return HTMLResponse(
|
||||||
|
status_code=404,
|
||||||
|
content='This story does not exist, or has been deleted. Support is available on the <a href="https://discord.gg/P9RHC4KCwd" target="_blank">Discord</a>',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/download/{download_id}")
|
||||||
|
async def handle_download(
|
||||||
|
download_id: int,
|
||||||
download_images: bool = False,
|
download_images: bool = False,
|
||||||
|
mode: DownloadMode = DownloadMode.story,
|
||||||
|
format: DownloadFormat = DownloadFormat.epub,
|
||||||
username: Optional[str] = None,
|
username: Optional[str] = None,
|
||||||
password: Optional[str] = None,
|
password: Optional[str] = None,
|
||||||
):
|
):
|
||||||
if username and not password or password and not username:
|
with start_action(
|
||||||
return HTMLResponse(
|
action_type="download",
|
||||||
status_code=422,
|
download_id=download_id,
|
||||||
content='Include both the username <u>and</u> password, or neither. Support is available on the <a href="https://discord.gg/P9RHC4KCwd" target="_blank">Discord</a>',
|
download_images=download_images,
|
||||||
)
|
format=format,
|
||||||
|
mode=mode,
|
||||||
|
):
|
||||||
|
if username and not password or password and not username:
|
||||||
|
logger.error(
|
||||||
|
"Username with no Password or Password with no Username provided."
|
||||||
|
)
|
||||||
|
return HTMLResponse(
|
||||||
|
status_code=422,
|
||||||
|
content='Include both the username <u>and</u> password, or neither. Support is available on the <a href="https://discord.gg/P9RHC4KCwd" target="_blank">Discord</a>',
|
||||||
|
)
|
||||||
|
|
||||||
if username and password:
|
if username and password:
|
||||||
# username and password are URL-Encoded by the frontend. FastAPI automatically decodes them.
|
# username and password are URL-Encoded by the frontend. FastAPI automatically decodes them.
|
||||||
try:
|
try:
|
||||||
cookies = await wp_get_cookies(username=username, password=password)
|
cookies = await fetch_cookies(username=username, password=password)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
logger.error("Invalid username or password.")
|
||||||
|
return HTMLResponse(
|
||||||
|
status_code=403,
|
||||||
|
content='Incorrect Username and/or Password. Support is available on the <a href="https://discord.gg/P9RHC4KCwd" target="_blank">Discord</a>',
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cookies = None
|
||||||
|
|
||||||
|
if format == DownloadFormat.pdf and not PDFS_ENABLED:
|
||||||
|
logger.error("PDF Downloads not enabled.")
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
content='Incorrect Username and/or Password. Support is available on the <a href="https://discord.gg/P9RHC4KCwd" target="_blank">Discord</a>',
|
content='PDF Downloads have been disabled by the server administrator. Support is available on the <a href="https://discord.gg/P9RHC4KCwd" target="_blank">Discord</a>',
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
cookies = None
|
|
||||||
|
|
||||||
data = await retrieve_story(story_id, cookies=cookies)
|
match mode:
|
||||||
book = epub.EpubBook()
|
case DownloadMode.story:
|
||||||
|
story_id = download_id
|
||||||
|
metadata = await fetch_story(story_id, cookies)
|
||||||
|
case DownloadMode.part:
|
||||||
|
story_id, metadata = await fetch_story_from_partId(download_id, cookies)
|
||||||
|
|
||||||
try:
|
cover_data = await fetch_image(
|
||||||
set_metadata(book, data)
|
metadata["cover"].replace("-256-", "-512-")
|
||||||
except KeyError:
|
) # Increase resolution
|
||||||
return HTMLResponse(
|
if not cover_data:
|
||||||
status_code=404,
|
raise HTTPException(status_code=422)
|
||||||
content='Story not found. Check the ID - Support is available on the <a href="https://discord.gg/P9RHC4KCwd" target="_blank">Discord</a>',
|
|
||||||
|
story_zip = await fetch_story_content_zip(story_id, cookies)
|
||||||
|
archive = ZipFile(story_zip, "r")
|
||||||
|
|
||||||
|
part_trees: list[BeautifulSoup] = [
|
||||||
|
clean_tree(
|
||||||
|
part["title"], part["id"], archive.read(str(part["id"])).decode("utf-8")
|
||||||
|
)
|
||||||
|
for part in metadata["parts"]
|
||||||
|
]
|
||||||
|
|
||||||
|
images = (
|
||||||
|
[await fetch_tree_images(tree) for tree in part_trees]
|
||||||
|
if download_images
|
||||||
|
else []
|
||||||
)
|
)
|
||||||
|
|
||||||
await set_cover(book, data, cookies=cookies)
|
match format:
|
||||||
# print("Metadata Downloaded")
|
case DownloadFormat.epub:
|
||||||
|
book = EPUBGenerator(metadata, part_trees, cover_data, images)
|
||||||
|
media_type = "application/epub+zip"
|
||||||
|
case DownloadFormat.pdf:
|
||||||
|
author_image = await fetch_image(
|
||||||
|
metadata["user"]["avatar"].replace("-256-", "-512-")
|
||||||
|
)
|
||||||
|
if not author_image:
|
||||||
|
raise HTTPException(status_code=422)
|
||||||
|
|
||||||
# Chapters are downloaded
|
book = PDFGenerator(
|
||||||
async for title in add_chapters(
|
metadata, part_trees, cover_data, images, author_image
|
||||||
book, data, download_images=download_images, cookies=cookies
|
)
|
||||||
):
|
media_type = "application/pdf"
|
||||||
# print(f"Part ({title}) downloaded")
|
|
||||||
...
|
|
||||||
|
|
||||||
# Book is compiled
|
logger.info(f"Retrieved story metadata and cover ({story_id=})")
|
||||||
temp_file = tempfile.NamedTemporaryFile(
|
|
||||||
suffix=".epub", delete=True
|
|
||||||
) # Thanks https://stackoverflow.com/a/75398222
|
|
||||||
|
|
||||||
# create epub file
|
book.compile()
|
||||||
epub.write_epub(temp_file, book, {})
|
|
||||||
|
|
||||||
temp_file.file.seek(0)
|
book_buffer = book.dump()
|
||||||
book_data = temp_file.file.read()
|
|
||||||
|
|
||||||
return StreamingResponse(
|
async def iterfile():
|
||||||
BytesIO(book_data),
|
while chunk := book_buffer.read(512 * 4): # 4 kb/s
|
||||||
media_type="application/epub+zip",
|
await asyncio.sleep(0.1) # throttle download speed
|
||||||
headers={
|
yield chunk
|
||||||
"Content-Disposition": f'attachment; filename="{slugify(data["title"])}_{story_id}_{"images" if download_images else ""}.epub"' # Thanks https://stackoverflow.com/a/72729058
|
|
||||||
},
|
return StreamingResponse(
|
||||||
)
|
book_buffer if PDFS_ENABLED else iterfile(),
|
||||||
|
media_type=media_type,
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{slugify(metadata["title"])}_{story_id}{"_images" if download_images else ""}.{format.value}"', # Thanks https://stackoverflow.com/a/72729058
|
||||||
|
"Content-Length": str(book_buffer.getbuffer().nbytes),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/donate")
|
||||||
|
def donate():
|
||||||
|
"""Redirect to donation URL."""
|
||||||
|
return RedirectResponse("https://buymeacoffee.com/theonlywayup")
|
||||||
|
|
||||||
|
|
||||||
app.mount("/", StaticFiles(directory=BUILD_PATH), "static")
|
app.mount("/", StaticFiles(directory=BUILD_PATH), "static")
|
||||||
@@ -96,4 +246,4 @@ app.mount("/", StaticFiles(directory=BUILD_PATH), "static")
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=80)
|
uvicorn.run("main:app", host="0.0.0.0", port=80)
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
.DS_Store
|
|
||||||
node_modules
|
node_modules
|
||||||
/build
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
/.svelte-kit
|
/.svelte-kit
|
||||||
/package
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
.vercel
|
!.env.test
|
||||||
.output
|
|
||||||
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Package Managers
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
bun.lock
|
||||||
|
bun.lockb
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"useTabs": false,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-svelte",
|
||||||
|
"prettier-plugin-tailwindcss"
|
||||||
|
],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": {
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# create-svelte
|
# sv
|
||||||
|
|
||||||
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
## Creating a project
|
## Creating a project
|
||||||
|
|
||||||
@@ -8,10 +8,10 @@ If you're seeing this, you've probably already done this step. Congrats!
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# create a new project in the current directory
|
# create a new project in the current directory
|
||||||
npm create svelte@latest
|
npx sv create
|
||||||
|
|
||||||
# create a new project in my-app
|
# create a new project in my-app
|
||||||
npm create svelte@latest my-app
|
npx sv create my-app
|
||||||
```
|
```
|
||||||
|
|
||||||
## Developing
|
## Developing
|
||||||
@@ -35,4 +35,4 @@ npm run build
|
|||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": false,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||||
|
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||||
|
}
|
||||||
@@ -1,29 +1,27 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.1",
|
"private": true,
|
||||||
"scripts": {
|
"version": "0.0.1",
|
||||||
"dev": "vite dev",
|
"type": "module",
|
||||||
"build": "vite build",
|
"scripts": {
|
||||||
"preview": "vite preview"
|
"dev": "vite dev",
|
||||||
},
|
"build": "vite build",
|
||||||
"devDependencies": {
|
"preview": "vite preview",
|
||||||
"@fontsource/fira-mono": "^4.5.10",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"@neoconfetti/svelte": "^1.0.0",
|
"format": "prettier --write .",
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"lint": "prettier --check ."
|
||||||
"@sveltejs/adapter-static": "^3.0.1",
|
},
|
||||||
"@sveltejs/kit": "^2.0.0",
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
"@sveltejs/kit": "^2.16.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"daisyui": "^4.4.20",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"postcss": "^8.4.32",
|
"daisyui": "^5.0.38",
|
||||||
"postcss-load-config": "^5.0.2",
|
"prettier": "^3.5.3",
|
||||||
"svelte": "^4.2.7",
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
"tailwindcss": "^3.3.6",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
"vite": "^5.0.3"
|
"svelte": "^5.0.0",
|
||||||
},
|
"tailwindcss": "^4.0.0",
|
||||||
"type": "module",
|
"vite": "^6.2.6"
|
||||||
"dependencies": {
|
}
|
||||||
"svelte-preprocess": "^5.1.3"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
const tailwindcss = require("tailwindcss");
|
|
||||||
const autoprefixer = require("autoprefixer");
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
plugins: [
|
|
||||||
//Some plugins, like tailwindcss/nesting, need to run before Tailwind,
|
|
||||||
tailwindcss(),
|
|
||||||
//But others, like autoprefixer, need to run after,
|
|
||||||
autoprefixer,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = config;
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@plugin "daisyui" {
|
||||||
|
themes: bumblebee --default, abyss --prefersdark, cupcake, dracula;
|
||||||
|
}
|
||||||
@@ -1,35 +1,43 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" data-theme="nord">
|
<html lang="en" data-theme="bumblebee">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<script defer src="https://stats.towu.dev/script.js" data-website-id="28dd7588-390c-49ed-a42c-085ad369ed93"></script>
|
||||||
|
|
||||||
<title>Wattpad Downloader</title>
|
<title>WP Downloader</title>
|
||||||
<meta name="title" content="Wattpad Downloader" />
|
<meta name="title" content="WP Downloader" />
|
||||||
<meta name="description" content="Read your way, download Wattpad Books as EPUBs in seconds. Have an Ad-Free experience with Unlimited Offline Reading. Try it now!" />
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Read your way, download WP Books as PDFs or EPUBs in seconds. Have an Ad-Free experience with Unlimited Offline Reading. Try it now!"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
<!-- Open Graph / Facebook -->
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="https://wpd.rambhat.la/" />
|
<meta property="og:url" content="https://wpd.my/">
|
||||||
<meta property="og:title" content="Wattpad Downloader" />
|
<meta property="og:title" content="WP Downloader" />
|
||||||
<meta property="og:description" content="Read your way, download Wattpad Books as EPUBs in seconds. Have an Ad-Free experience with Unlimited Offline Reading. Try it now!" />
|
<meta
|
||||||
<meta property="og:image" content="https://wpd.rambhat.la/embed.png" />
|
property="og:description"
|
||||||
|
content="Read your way, download WP Books as PDFs or EPUBs in seconds. Have an Ad-Free experience with Unlimited Offline Reading. Try it now!"
|
||||||
|
/>
|
||||||
|
<meta property="og:image" content="https://wpd.my/embed.png" />
|
||||||
|
|
||||||
<!-- Twitter -->
|
<!-- Twitter -->
|
||||||
<meta property="twitter:card" content="summary_large_image" />
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
<meta property="twitter:url" content="https://wpd.rambhat.la/" />
|
<meta property="twitter:url" content="https://wpd.my/" />
|
||||||
<meta property="twitter:title" content="Wattpad Downloader" />
|
<meta property="twitter:title" content="WP Downloader" />
|
||||||
<meta property="twitter:description" content="Read your way, download Wattpad Books as EPUBs in seconds. Have an Ad-Free experience with Unlimited Offline Reading. Try it now!" />
|
<meta
|
||||||
<meta property="twitter:image" content="https://wpd.rambhat.la/embed.png" />
|
property="twitter:description"
|
||||||
|
content="Read your way, download WP Books as PDFs or EPUBs in seconds. Have an Ad-Free experience with Unlimited Offline Reading. Try it now!"
|
||||||
|
/>
|
||||||
|
<meta property="twitter:image" content="https://wpd.my/embed.png" />
|
||||||
|
|
||||||
<!-- Meta Tags Generated with https://metatags.io -->
|
<!-- Meta Tags Generated with https://metatags.io -->
|
||||||
|
|
||||||
<script defer src="https://feedback.fish/ff.js?pid=f8df016d4ffdfb"></script>
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
%sveltekit.head%
|
<body data-sveltekit-preload-data="hover">
|
||||||
</head>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
<body data-sveltekit-preload-data="hover">
|
</body>
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
/* Write your global styles here, in PostCSS syntax */
|
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="hero min-h-screen">
|
<div class="hero min-h-screen">
|
||||||
<div class="hero-content text-center">
|
<div class="hero-content text-center">
|
||||||
<div class="bg-base-200 p-16 max-w-lg rounded-md">
|
<div class="max-w-lg rounded-md bg-base-200 p-16">
|
||||||
<h1 class="text-5xl font-bold">There was an error.</h1>
|
<h1 class="text-5xl font-bold">There was an error.</h1>
|
||||||
<div class="py-6 join">
|
<div class="join py-6">
|
||||||
<a class="btn btn-primary btn-lg" href="/">Home</a>
|
<a class="btn btn-primary btn-lg" href="/">Home</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer
|
<footer
|
||||||
class="footer footer-center p-4 bg-base-300 text-base-content bottom-0 fixed"
|
class="footer footer-center fixed bottom-0 bg-base-300 p-4 text-base-content"
|
||||||
>
|
>
|
||||||
<aside class="text-2xl">
|
<aside class="text-2xl">
|
||||||
<p>
|
<p>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
href="https://github.com/TheOnlyWayUp"
|
href="https://github.com/TheOnlyWayUp"
|
||||||
class="underline"
|
class="underline"
|
||||||
target="_blank">TheOnlyWayUp</a
|
target="_blank">TheOnlyWayUp</a
|
||||||
> © 2024
|
> © 2025
|
||||||
</p>
|
</p>
|
||||||
</aside>
|
</aside>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
<script>
|
<script>
|
||||||
import "../app.pcss";
|
import "../app.css";
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Props
|
||||||
|
* @property {import('svelte').Snippet} [children]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Props} */
|
||||||
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -10,23 +17,23 @@
|
|||||||
</style>
|
</style>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<slot />
|
{@render children()}
|
||||||
|
|
||||||
<footer
|
<footer
|
||||||
class="footer footer-center p-4 bg-base-300 text-base-content bottom-0 fixed"
|
class="footer footer-center fixed bottom-0 bg-base-300 p-4 text-base-content"
|
||||||
>
|
>
|
||||||
<aside>
|
<aside>
|
||||||
<div class="grid grid-cols-3 max-w-lg w-full">
|
<div class="flex w-full max-w-lg flex-row">
|
||||||
<a
|
<a
|
||||||
href="https://liberapay.com/TheOnlyWayUp/"
|
href="https://buymeacoffee.com/theonlywayup"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="link"
|
class="link"
|
||||||
data-umami-event="Footer Donate">Donate</a
|
data-umami-event="Footer Donate">Buy me a Coffee!</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="https://rambhat.la"
|
href="https://rambhat.la"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="link"
|
class="link flex-1"
|
||||||
data-umami-event="Footer AboutMe">About Me</a
|
data-umami-event="Footer AboutMe">About Me</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
@@ -37,7 +44,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
Copyright © 2024 - All rights reserved by <a
|
Copyright © 2025 - All rights reserved by <a
|
||||||
href="https://rambhat.la"
|
href="https://rambhat.la"
|
||||||
class="link"
|
class="link"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -1,113 +1,227 @@
|
|||||||
<script>
|
<script>
|
||||||
let story_id = "";
|
const PDFS_ENABLED = import.meta.env.VITE_ENABLE_PDFS === "true";
|
||||||
let download_images = false;
|
|
||||||
let is_paid_story = false;
|
let downloadImages = $state(false);
|
||||||
let credentials = {
|
let downloadAsPdf = $state(false); // 0 = epub, 1 = pdf
|
||||||
|
let isPaidStory = $state(false);
|
||||||
|
let invalidUrl = $state(false);
|
||||||
|
let afterDownloadPage = $state(false);
|
||||||
|
let credentials = $state({
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: ""
|
||||||
|
});
|
||||||
|
let downloadId = $state("");
|
||||||
|
/** @type {"story" | "part" | ""} */
|
||||||
|
let mode = $state("");
|
||||||
|
let inputUrl = $state("");
|
||||||
|
|
||||||
|
let buttonDisabled = $derived(
|
||||||
|
!inputUrl || (isPaidStory && !(credentials.username && credentials.password))
|
||||||
|
);
|
||||||
|
|
||||||
|
let url = $derived(
|
||||||
|
`/download/` +
|
||||||
|
downloadId +
|
||||||
|
`?om=1` +
|
||||||
|
(downloadImages ? "&download_images=true" : "") +
|
||||||
|
(isPaidStory
|
||||||
|
? `&username=${encodeURIComponent(credentials.username)}&password=${encodeURIComponent(credentials.password)}`
|
||||||
|
: "") +
|
||||||
|
`&mode=${mode}` +
|
||||||
|
(downloadAsPdf ? "&format=pdf" : "&format=epub")
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @type {HTMLDialogElement} */
|
||||||
|
let storyURLTutorialModal;
|
||||||
|
|
||||||
|
/** @param {string} input */
|
||||||
|
const setInputAsValid = (input) => {
|
||||||
|
invalidUrl = false;
|
||||||
|
inputUrl = input;
|
||||||
|
downloadId = input;
|
||||||
};
|
};
|
||||||
let after_download_page = false;
|
|
||||||
let url = "";
|
|
||||||
|
|
||||||
let raw_story_id = "";
|
/** @param {string} input */
|
||||||
let is_part_id = false;
|
const setInputAsInvalid = (input) => {
|
||||||
|
invalidUrl = true;
|
||||||
|
inputUrl = input;
|
||||||
|
downloadId = input;
|
||||||
|
};
|
||||||
|
|
||||||
let button_disabled = false;
|
/** @param {string} input */
|
||||||
$: button_disabled =
|
const setInputUrl = (input) => {
|
||||||
!story_id ||
|
input = input.toLowerCase();
|
||||||
(is_paid_story && !(credentials.username && credentials.password));
|
|
||||||
|
|
||||||
$: {
|
if (!input) {
|
||||||
is_part_id = false;
|
setInputAsValid("");
|
||||||
if (raw_story_id.includes("wattpad.com")) {
|
return;
|
||||||
// Originally, I was going to call the Wattpad API (wattpad.com/api/v3/stories/${story_id}), but Wattpad kept blocking those requests. I suspect it has something to do with the Origin header, I wasn't able to remove it.
|
|
||||||
// In the future, if this is considered, it would be cool if we could derive the Story ID from a pasted Part URL. Refer to @AaronBenDaniel's https://github.com/AaronBenDaniel/WattpadDownloader/blob/49b29b245188149f2d24c0b1c59e4c7f90f289a9/src/api/src/create_book.py#L156 (https://www.wattpad.com/api/v3/story_parts/{part_id}?fields=url).
|
|
||||||
|
|
||||||
if (raw_story_id.includes("/story/")) {
|
|
||||||
// https://wattpad.com/story/237369078-wattpad-books-presents
|
|
||||||
story_id = raw_story_id.split("/story/")[1].split("-")[0];
|
|
||||||
raw_story_id = story_id;
|
|
||||||
} else if (raw_story_id.includes("/stories/")) {
|
|
||||||
// https://www.wattpad.com/api/v3/stories/237369078?fields=...
|
|
||||||
story_id = raw_story_id.split("/stories/")[1].split("?")[0];
|
|
||||||
raw_story_id = story_id;
|
|
||||||
} else {
|
|
||||||
// https://www.wattpad.com/939051741-wattpad-books-presents-part-name
|
|
||||||
is_part_id = true;
|
|
||||||
raw_story_id = "";
|
|
||||||
story_id = "";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
story_id = parseInt(raw_story_id) || ""; // parseInt returns NaN for undefined values.
|
|
||||||
raw_story_id = story_id;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
if (/^\d+$/.test(input)) {
|
||||||
|
// All numbers
|
||||||
|
mode = "story";
|
||||||
|
setInputAsValid(input);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.includes("wattpad.com/")) {
|
||||||
|
setInputAsInvalid(input.match(/\d+/g)?.join("") ?? "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is a string and contains wattpad.com/
|
||||||
|
|
||||||
|
if (input.includes("/story/")) {
|
||||||
|
// https://wattpad.com/story/237369078-wattpad-books-presents
|
||||||
|
mode = "story";
|
||||||
|
setInputAsValid(
|
||||||
|
input.split("-", 1)[0].split("?", 1)[0].split("/story/")[1] // removes tracking fields and title
|
||||||
|
);
|
||||||
|
} else if (input.includes("/stories/")) {
|
||||||
|
// https://www.wattpad.com/api/v3/stories/237369078?fields=...
|
||||||
|
mode = "story";
|
||||||
|
setInputAsValid(
|
||||||
|
input.split("?", 1)[0].split("/stories/")[1] // removes params
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// https://www.wattpad.com/939051741-wattpad-books-presents-the-qb-bad-boy-and-me
|
||||||
|
input = input.split("-", 1)[0].split("?", 1)[0].split("wattpad.com/")[1]; // removes tracking fields and title
|
||||||
|
if (/^\d+$/.test(input)) {
|
||||||
|
// If "wattpad.com/{downloadId}" contains only numbers
|
||||||
|
mode = "part";
|
||||||
|
setInputAsValid(input);
|
||||||
|
} else {
|
||||||
|
setInputAsInvalid("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Originally, I was going to call the Wattpad API (wattpad.com/api/v3/stories/${story_id}), but Wattpad kept blocking those requests. I suspect it has something to do with the Origin header, I wasn't able to remove it.
|
||||||
|
// In the future, if this is considered, it would be cool if we could derive the Story ID from a pasted Part URL. Refer to @AaronBenDaniel's https://github.com/AaronBenDaniel/WattpadDownloader/blob/49b29b245188149f2d24c0b1c59e4c7f90f289a9/src/api/src/create_book.py#L156 (https://www.wattpad.com/api/v3/story_parts/{part_id}?fields=url).
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="hero min-h-screen">
|
<div class="hero min-h-screen">
|
||||||
<div
|
<div
|
||||||
class="hero-content flex-col lg:flex-row-reverse bg-base-100/50 p-16 rounded shadow-sm"
|
class="hero-content bg-base-100/50 flex-col rounded py-32 shadow-sm lg:flex-row-reverse lg:p-16"
|
||||||
>
|
>
|
||||||
{#if !after_download_page}
|
{#if !afterDownloadPage}
|
||||||
<div class="text-center lg:text-left lg:p-10">
|
<div class="text-center lg:p-10 lg:text-left">
|
||||||
<h1
|
<h1
|
||||||
class="font-extrabold text-transparent text-5xl bg-clip-text bg-gradient-to-r to-pink-600 via-yellow-600 from-red-700"
|
class="bg-gradient-to-r from-red-700 via-yellow-600 to-pink-600 bg-clip-text text-5xl font-extrabold text-transparent"
|
||||||
>
|
>
|
||||||
Wattpad Downloader
|
WP Downloader
|
||||||
</h1>
|
</h1>
|
||||||
<p class="pt-6 text-lg">
|
{#if !PDFS_ENABLED}
|
||||||
Download your favourite books with a single click!
|
<div role="alert" class="alert mt-10 max-w-md break-words bg-green-200">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="h-6 w-6 shrink-0 stroke-current"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Donators get access to <span class="font-semibold">high-speed PDF Downloads</span>
|
||||||
|
</p>
|
||||||
|
<a href="https://buymeacoffee.com/theonlywayup" class="link" target="_blank"
|
||||||
|
>Donate now</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<!-- <div role="alert" class="alert bg-cyan-300 mt-5">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="h-6 w-6 shrink-0 stroke-current"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-lg">Please Donate</span>
|
||||||
|
</div> -->
|
||||||
|
<p class="max-w-md pt-6 text-lg">
|
||||||
|
Download your favourite books with a single click. Have a great new year!
|
||||||
</p>
|
</p>
|
||||||
<ul class="pt-4 list list-inside text-xl">
|
<ul class="list list-inside pt-4 text-xl">
|
||||||
<!-- TODO: 'max-lg: hidden' to hide on screen sizes smaller than lg. I'll do this when I figure out how to make this show up _below_ the card on smaller screen sizes. -->
|
<!-- TODO: 'max-lg: hidden' to hide on screen sizes smaller than lg. I'll do this when I figure out how to make this show up _below_ the card on smaller screen sizes. -->
|
||||||
|
<li>05/25 - ⚖️ Legal Compliance</li>
|
||||||
|
{#if PDFS_ENABLED}
|
||||||
|
<li>12/24 - ⚡ Super-fast Downloads!</li>
|
||||||
|
<li>12/24 - 📑 PDF Downloads!</li>
|
||||||
|
{:else}
|
||||||
|
<li>12/24 - 📂 Less Errors, Throttled Downloads</li>
|
||||||
|
{/if}
|
||||||
|
<li>11/24 - 🔗 Paste Links!</li>
|
||||||
|
<li>11/24 - 📨 Send to Kindle Support!</li>
|
||||||
|
|
||||||
|
<li>11/24 - ⚒️ Fix Image Downloads</li>
|
||||||
|
<li>
|
||||||
|
10/24 - 👾 Add the <a
|
||||||
|
href="https://discord.com/oauth2/authorize?client_id=1292173380065296395&permissions=274878285888&scope=bot%20applications.commands"
|
||||||
|
target="_blank"
|
||||||
|
class="link underline">Discord Bot</a
|
||||||
|
>!
|
||||||
|
</li>
|
||||||
<li>07/24 - 🔡 RTL Language support! (Arabic, etc.)</li>
|
<li>07/24 - 🔡 RTL Language support! (Arabic, etc.)</li>
|
||||||
<li>06/24 - 🔑 Authenticated Downloads!</li>
|
<li>06/24 - 🔑 Authenticated Downloads!</li>
|
||||||
<li>06/24 - 🖼️ Image Downloading!</li>
|
<li>06/24 - 🖼️ Image Downloading!</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-base-100">
|
<div class="card bg-base-100 w-full max-w-sm shrink-0 shadow-2xl">
|
||||||
<form class="card-body">
|
<form class="card-body">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Story ID"
|
placeholder="Story URL"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
class:input-warning={is_part_id}
|
class:input-warning={invalidUrl}
|
||||||
bind:value={raw_story_id}
|
bind:value={() => inputUrl, setInputUrl}
|
||||||
required
|
required
|
||||||
name="story_id"
|
name="input_url"
|
||||||
/>
|
/>
|
||||||
<label class="label" for="story_id">
|
<label class="label" for="input_url">
|
||||||
{#if is_part_id}
|
{#if invalidUrl}
|
||||||
<p class=" text-red-500">
|
<p class=" text-red-500">
|
||||||
Refer to (<button
|
Refer to (<button
|
||||||
class="link font-semibold"
|
class="link font-semibold"
|
||||||
onclick="StoryIDTutorialModal.showModal()"
|
onclick={() => storyURLTutorialModal.showModal()}
|
||||||
data-umami-event="Part StoryIDTutorialModal Open"
|
data-umami-event="Part StoryURLTutorialModal Open"
|
||||||
>How to get a Story ID</button
|
>How to get a Story URL</button
|
||||||
>).
|
>).
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
class="label-text link font-semibold"
|
class="link label-text font-semibold text-gray-800"
|
||||||
onclick="StoryIDTutorialModal.showModal()"
|
onclick={() => storyURLTutorialModal.showModal()}
|
||||||
data-umami-event="StoryIDTutorialModal Open"
|
data-umami-event="StoryURLTutorialModal Open">How to get a Story URL</button
|
||||||
>How to get a Story ID</button
|
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
<label class="cursor-pointer label">
|
|
||||||
<span class="label-text"
|
<label class="label cursor-pointer text-gray-800">
|
||||||
>This is a Paid Story, and I've purchased it</span
|
<span class="label-text">This is a Paid Story, and I've purchased it</span>
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-warning shadow-md"
|
class="checkbox-warning checkbox shadow-md"
|
||||||
bind:checked={is_paid_story}
|
bind:checked={isPaidStory}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{#if is_paid_story}
|
{#if isPaidStory}
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
<label class="input input-bordered flex items-center gap-2">
|
||||||
Username
|
Username
|
||||||
<input
|
<input
|
||||||
@@ -135,50 +249,58 @@
|
|||||||
|
|
||||||
<div class="form-control mt-6">
|
<div class="form-control mt-6">
|
||||||
<a
|
<a
|
||||||
class="btn btn-primary rounded-l-none"
|
class="btn rounded-l-none"
|
||||||
class:btn-disabled={button_disabled}
|
class:btn-primary={!downloadAsPdf}
|
||||||
|
class:btn-secondary={downloadAsPdf}
|
||||||
|
class:btn-disabled={buttonDisabled}
|
||||||
data-umami-event="Download"
|
data-umami-event="Download"
|
||||||
href={url}
|
href={url}
|
||||||
on:click={() => (after_download_page = true)}>Download</a
|
onclick={() => (afterDownloadPage = true)}>Download</a
|
||||||
>
|
>
|
||||||
|
|
||||||
<label class="cursor-pointer label">
|
|
||||||
<span class="label-text"
|
|
||||||
>Include Images (<strong>Slower Download</strong>)</span
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="checkbox checkbox-warning shadow-md"
|
|
||||||
bind:checked={download_images}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
|
||||||
<button
|
{#if PDFS_ENABLED}
|
||||||
data-feedback-fish
|
<label class="swap w-fit label mt-2 pb-2">
|
||||||
class="link pb-4"
|
<input type="checkbox" bind:checked={downloadAsPdf} />
|
||||||
data-umami-event="Feedback">Feedback</button
|
<div class="swap-on absolute left-0 text-gray-800">
|
||||||
>
|
Downloading as <span class=" underline text-bold">PDF</span> (Click)
|
||||||
|
</div>
|
||||||
|
<div class="swap-off absolute left-0 text-gray-800">
|
||||||
|
Downloading as <span class=" underline text-bold">EPUB</span> (Click)
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<label class="label cursor-pointer">
|
||||||
|
<span class="label-text text-gray-800"
|
||||||
|
>Include Images (<strong>Slower Download</strong>)</span
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox-warning checkbox shadow-md"
|
||||||
|
bind:checked={downloadImages}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="text-center max-w-4xl">
|
<div class="max-w-4xl text-center">
|
||||||
<h1 class="font-bold text-3xl">
|
<h1 class="text-3xl font-bold">
|
||||||
Your download has <span
|
Your download has <span
|
||||||
class="text-transparent bg-clip-text bg-gradient-to-r to-pink-600 via-yellow-600 from-red-700"
|
class="bg-gradient-to-r from-red-700 via-yellow-600 to-pink-600 bg-clip-text text-transparent"
|
||||||
>Started</span
|
>Started</span
|
||||||
>
|
>
|
||||||
</h1>
|
</h1>
|
||||||
<div class="py-4 space-y-2">
|
<div class="space-y-2 py-4">
|
||||||
<p class="text-2xl">
|
<p class="text-2xl">
|
||||||
If you found this site useful, please consider <a
|
If you found this site useful, please consider <a
|
||||||
href="https://github.com/TheOnlyWayUp/WattpadDownloader"
|
href="https://github.com/TheOnlyWayUp/WattpadDownloader"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="link"
|
class="link"
|
||||||
data-umami-event="Star">starring the project</a
|
data-umami-event="Star">starring the project</a
|
||||||
> to support WattpadDownloader.
|
> to support WPDownloader.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-lg pt-2">
|
<p class="pt-2 text-lg">
|
||||||
You can also join us on <a
|
You can also join us on <a
|
||||||
href="https://discord.gg/P9RHC4KCwd"
|
href="https://discord.gg/P9RHC4KCwd"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -187,41 +309,46 @@
|
|||||||
>, where we release features early and discuss updates.
|
>, where we release features early and discuss updates.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="/" class="btn btn-outline btn-lg mt-10">Download More</a>
|
<div class="grid grid-rows-2 justify-center gap-y-10">
|
||||||
|
<a
|
||||||
|
href="https://buymeacoffee.com/theonlywayup"
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-lg mt-10 bg-cyan-200 hover:bg-green-200">Buy me a Coffee! 🍵</a
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
afterDownloadPage = false;
|
||||||
|
inputUrl = "";
|
||||||
|
}}
|
||||||
|
class="btn btn-outline btn-lg">Download More</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Open the modal using ID.showModal() method -->
|
<dialog class="modal" bind:this={storyURLTutorialModal}>
|
||||||
|
|
||||||
<dialog id="StoryIDTutorialModal" class="modal">
|
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2">✕</button>
|
||||||
>✕</button
|
|
||||||
>
|
|
||||||
</form>
|
</form>
|
||||||
<h3 class="font-bold text-lg">Retrieving a Story ID</h3>
|
<h3 class="text-lg font-bold">How to get a Story URL</h3>
|
||||||
<ol class="list list-disc list-inside py-4 space-y-4">
|
<ol class="list list-inside list-disc space-y-4 py-4">
|
||||||
|
<li>Copy the URL from the Website, or hit share and copy the URL on the App.</li>
|
||||||
<li>
|
<li>
|
||||||
Open the Story URL, this page includes the story description and tags.
|
For example,
|
||||||
(For example, <span class="font-mono bg-slate-100 p-1"
|
<span class="bg-slate-100 p-1 font-mono"
|
||||||
>wattpad.com/story/237369078-wattpad-books-presents</span
|
>wattpad.com/<span class="rounded-sm bg-amber-200">story</span
|
||||||
>).
|
>/237369078-wattpad-books-presents</span
|
||||||
|
>.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Copy the numbers after the <span class="font-mono bg-slate-100 p-1"
|
<span class="bg-slate-100 p-1 font-mono">https://www.wattpad.com/939103774-given</span> is okay
|
||||||
>/</span
|
too.
|
||||||
>
|
|
||||||
(In the example, that'd be,
|
|
||||||
<span class="font-mono bg-slate-100 p-1"
|
|
||||||
>wattpad.com/story/<span class="bg-amber-200 p-1">237369078</span
|
|
||||||
>-wattpad-books-presents</span
|
|
||||||
>)
|
|
||||||
</li>
|
</li>
|
||||||
<li>Paste the Story ID and hit Download!</li>
|
<li>Paste the URL and hit Download!</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
<form method="dialog" class="modal-backdrop">
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://wpd.rambhat.la/</loc>
|
<loc>https://wpd.my/</loc>
|
||||||
<lastmod>2024-04-12T08:19:07+00:00</lastmod>
|
<lastmod>2024-04-12T08:19:07+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
import adapter from '@sveltejs/adapter-static';
|
||||||
import adapter from "@sveltejs/adapter-static";
|
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
const config = { kit: { adapter: adapter({ strict: false }) } };
|
||||||
const config = {
|
|
||||||
kit: {
|
|
||||||
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
|
||||||
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
|
|
||||||
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
|
||||||
adapter: adapter({ strict: false }),
|
|
||||||
},
|
|
||||||
|
|
||||||
preprocess: [vitePreprocess({})],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
const daisyui = require("daisyui");
|
|
||||||
const typography = require("@tailwindcss/typography");
|
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config}*/
|
|
||||||
const config = {
|
|
||||||
content: ["./src/**/*.{html,js,svelte,ts}"],
|
|
||||||
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
|
|
||||||
plugins: [typography, daisyui],
|
|
||||||
|
|
||||||
daisyui: {
|
|
||||||
themes: [
|
|
||||||
"bumblebee"
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = config;
|
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()]
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
server: {
|
||||||
|
allowedHosts: true
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||