1 Commits

Author SHA1 Message Date
TheOnlyWayUp dd32a03bf8 feat(frontend): Dark Mode 2024-09-03 17:22:05 +00:00
63 changed files with 3730 additions and 6334 deletions
-10
View File
@@ -1,10 +0,0 @@
__pycache__
*ipynb
build
.idea
.vscode
.venv
.env
*log
*.md
src/api/uv.lock
-7
View File
@@ -1,13 +1,6 @@
__pycache__
venv
*epub
*.pdf
# *html
data
*ipynb
build
.idea
.vscode
.venv
.env
*log
-24
View File
@@ -1,24 +0,0 @@
{
// 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"
}
]
}
+9 -29
View File
@@ -9,37 +9,17 @@ COPY src/frontend/. .
RUN npm run build
# Thanks https://stackoverflow.com/q/76988450
FROM python:3.13-slim
FROM python:3.10-slim
WORKDIR /app
COPY --from=nobodyxu/apt-fast:latest-debian-buster-slim /usr/local/ /usr/local/
RUN apt update
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
COPY src/api/requirements.txt requirements.txt
RUN pip3 install -r requirements.txt
COPY --from=0 /build/build /app/build
# COPY src/api/src/.env .env
COPY src/api/src .
EXPOSE 80
# ENV PORT=80
CMD [ "python3", "main.py"]
CMD [ "uv", "run", "main.py"]
+6 -27
View File
@@ -1,25 +1,22 @@
WattpadDownloader ([Demo](https://wpd.my/))
WattpadDownloader ([Demo](https://wpd.rambhat.la))
---
Straightforward, Extendable WebApp to download Wattpad Books as EPUB Files.
![image](https://github.com/user-attachments/assets/b9d87d6b-5302-4561-98b0-d7f95bff9f04)
![image](https://github.com/TheOnlyWayUp/WattpadDownloader/assets/76237496/8a3fda0b-b851-4c5f-9306-ba9c17cdcc8b)
Stars ⭐ are appreciated. Thanks!
## Features
- ⚡ Lightweight Frontend.
- ⚡ Lightweight Frontend and Minimal Javascript.
- 🪙 Supports Authentication (Download paid stories from your account!)
- 🌐 API Support (Visit the `/docs` path on your instance for more.)
- 🐇 Fast Generation
- 🗃️ Caching, Ratelimit handling
- 🐇 Fast Generation, Ratelimit Handling.
- 🐳 Docker Support
- 🏷️ Generated books contain metadata, supported by Calibre and other E-Book Software.
- 📖 Plays well with E-Readers. (Send2Kindle, KOReader, ReMarkable, KOBO, Calibre Reader...)
- 🏷️ Generated EPUB File includes Metadata. (Dublin Core Spec)
- 📖 Plays well with E-Readers. (Kindle Support if KOReader present, ReMarkable, KOBO, ...)
- 💻 Easily Hackable. Extend with ease.
Still not convinced? Take a look some [sample downloads](./samples/).
## Set Up
1. Clone the repository: `git clone https://github.com/TheOnlyWayUp/WattpadDownloader/ && cd WattpadDownloader`
@@ -28,24 +25,6 @@ Still not convinced? Take a look some [sample downloads](./samples/).
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.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-3
View File
@@ -1,3 +0,0 @@
USE_CACHE=true
CACHE_TYPE=file
REDIS_CONNECTION_URL=
-1
View File
@@ -1 +0,0 @@
3.13
-35
View File
@@ -1,35 +0,0 @@
[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",
]
+62
View File
@@ -0,0 +1,62 @@
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
+226
View File
@@ -0,0 +1,226 @@
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
-13
View File
@@ -1,13 +0,0 @@
# 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
-46
View File
@@ -1,46 +0,0 @@
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
-129
View File
@@ -1,129 +0,0 @@
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
-12
View File
@@ -1,12 +0,0 @@
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):
...
@@ -1,4 +0,0 @@
# ruff: noqa: F401
from .epub import EPUBGenerator
from .pdf import PDFGenerator
-109
View File
@@ -1,109 +0,0 @@
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
-189
View File
@@ -1,189 +0,0 @@
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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

@@ -1,85 +0,0 @@
<!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>
@@ -1,94 +0,0 @@
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.
@@ -1,5 +0,0 @@
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.
@@ -1,428 +0,0 @@
@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;
}
@@ -1,47 +0,0 @@
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
-19
View File
@@ -1,19 +0,0 @@
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"))
-42
View File
@@ -1,42 +0,0 @@
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
-92
View File
@@ -1,92 +0,0 @@
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
-25
View File
@@ -1,25 +0,0 @@
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("-_")
-28
View File
@@ -1,28 +0,0 @@
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=}")
+62 -202
View File
@@ -1,233 +1,93 @@
"""WattpadDownloader API Server."""
import asyncio
from enum import Enum
from pathlib import Path
from typing import Optional
from zipfile import ZipFile
from aiohttp import ClientResponseError
from bs4 import BeautifulSoup
from eliot import start_action
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import (
FileResponse,
HTMLResponse,
RedirectResponse,
StreamingResponse,
)
from fastapi.staticfiles import StaticFiles
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from ebooklib import epub
from create_book import (
EPUBGenerator,
PDFGenerator,
StoryNotFoundError,
WattpadError,
fetch_cookies,
fetch_image,
fetch_story,
fetch_story_content_zip,
fetch_story_from_partId,
logger,
retrieve_story,
set_cover,
set_metadata,
add_chapters,
slugify,
wp_get_cookies,
)
from create_book.parser import clean_tree, fetch_tree_images
import tempfile
from io import BytesIO
from fastapi.staticfiles import StaticFiles
app = FastAPI()
BUILD_PATH = Path(__file__).parent / "build"
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("/")
def home():
return FileResponse(BUILD_PATH / "index.html")
@app.exception_handler(ClientResponseError)
def download_error_handler(request: Request, exception: ClientResponseError):
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,
@app.get("/download/{story_id}")
async def download_book(
story_id: int,
download_images: bool = False,
mode: DownloadMode = DownloadMode.story,
format: DownloadFormat = DownloadFormat.epub,
username: Optional[str] = None,
password: Optional[str] = None,
):
with start_action(
action_type="download",
download_id=download_id,
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."
)
if username and not password or password and not username:
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:
# username and password are URL-Encoded by the frontend. FastAPI automatically decodes them.
try:
cookies = await wp_get_cookies(username=username, password=password)
except ValueError:
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>',
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 username and password:
# username and password are URL-Encoded by the frontend. FastAPI automatically decodes them.
try:
cookies = await fetch_cookies(username=username, password=password)
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
data = await retrieve_story(story_id, cookies=cookies)
book = epub.EpubBook()
match mode:
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)
cover_data = await fetch_image(
metadata["cover"].replace("-256-", "-512-")
) # Increase resolution
if not cover_data:
raise HTTPException(status_code=422)
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 []
try:
set_metadata(book, data)
except KeyError:
return HTMLResponse(
status_code=404,
content='Story not found. Check the ID - Support is available on the <a href="https://discord.gg/P9RHC4KCwd" target="_blank">Discord</a>',
)
match format:
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)
await set_cover(book, data, cookies=cookies)
# print("Metadata Downloaded")
book = PDFGenerator(
metadata, part_trees, cover_data, images, author_image
)
media_type = "application/pdf"
# Chapters are downloaded
async for title in add_chapters(
book, data, download_images=download_images, cookies=cookies
):
# print(f"Part ({title}) downloaded")
...
logger.info(f"Retrieved story metadata and cover ({story_id=})")
# Book is compiled
temp_file = tempfile.NamedTemporaryFile(
suffix=".epub", delete=True
) # Thanks https://stackoverflow.com/a/75398222
book.compile()
# create epub file
epub.write_epub(temp_file, book, {})
book_buffer = book.dump()
temp_file.file.seek(0)
book_data = temp_file.file.read()
async def iterfile():
while chunk := book_buffer.read(512 * 4): # 4 kb/s
await asyncio.sleep(0.1) # throttle download speed
yield chunk
return StreamingResponse(
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")
return StreamingResponse(
BytesIO(book_data),
media_type="application/epub+zip",
headers={
"Content-Disposition": f'attachment; filename="{slugify(data["title"])}_{story_id}_{"images" if download_images else ""}.epub"' # Thanks https://stackoverflow.com/a/72729058
},
)
app.mount("/", StaticFiles(directory=BUILD_PATH), "static")
@@ -236,4 +96,4 @@ app.mount("/", StaticFiles(directory=BUILD_PATH), "static")
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=80)
uvicorn.run(app, host="0.0.0.0", port=80)
-1993
View File
File diff suppressed because it is too large Load Diff
+6 -17
View File
@@ -1,23 +1,12 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
!.env.test
# Vite
.vercel
.output
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
-6
View File
@@ -1,6 +0,0 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
-18
View File
@@ -1,18 +0,0 @@
{
"useTabs": false,
"singleQuote": false,
"trailingComma": "none",
"printWidth": 100,
"plugins": [
"prettier-plugin-svelte",
"prettier-plugin-tailwindcss"
],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
+5 -5
View File
@@ -1,6 +1,6 @@
# sv
# create-svelte
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
## Creating a project
@@ -8,10 +8,10 @@ If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npx sv create
npm create svelte@latest
# create a new project in my-app
npx sv create my-app
npm create svelte@latest my-app
```
## Developing
@@ -35,4 +35,4 @@ npm run build
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
-13
View File
@@ -1,13 +0,0 @@
{
"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
}
+3042 -2235
View File
File diff suppressed because it is too large Load Diff
+27 -25
View File
@@ -1,27 +1,29 @@
{
"name": "frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"format": "prettier --write .",
"lint": "prettier --check ."
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"daisyui": "^5.0.38",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.0.0",
"tailwindcss": "^4.0.0",
"vite": "^6.2.6"
}
"name": "frontend",
"version": "0.0.1",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@fontsource/fira-mono": "^4.5.10",
"@neoconfetti/svelte": "^1.0.0",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@tailwindcss/typography": "^0.5.10",
"autoprefixer": "^10.4.16",
"daisyui": "^4.4.20",
"postcss": "^8.4.32",
"postcss-load-config": "^5.0.2",
"svelte": "^4.2.7",
"tailwindcss": "^3.3.6",
"vite": "^5.0.3"
},
"type": "module",
"dependencies": {
"svelte-preprocess": "^5.1.3"
}
}
+13
View File
@@ -0,0 +1,13 @@
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;
-4
View File
@@ -1,4 +0,0 @@
@import 'tailwindcss';
@plugin "daisyui" {
themes: bumblebee --default, abyss --prefersdark, cupcake, dracula;
}
+28 -36
View File
@@ -1,43 +1,35 @@
<!doctype html>
<html lang="en" data-theme="bumblebee">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<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>
<html lang="en" data-theme="nord">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WP Downloader</title>
<meta name="title" content="WP Downloader" />
<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!"
/>
<title>Wattpad Downloader</title>
<meta name="title" content="Wattpad 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!" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://wpd.my/">
<meta property="og:title" content="WP Downloader" />
<meta
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" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://wpd.rambhat.la/" />
<meta property="og:title" content="Wattpad 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 property="og:image" content="https://wpd.rambhat.la/embed.png" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://wpd.my/" />
<meta property="twitter:title" content="WP Downloader" />
<meta
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" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://wpd.rambhat.la/" />
<meta property="twitter:title" content="Wattpad 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 property="twitter:image" content="https://wpd.rambhat.la/embed.png" />
<!-- Meta Tags Generated with https://metatags.io -->
<!-- Meta Tags Generated with https://metatags.io -->
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<script defer src="https://feedback.fish/ff.js?pid=f8df016d4ffdfb"></script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
/* Write your global styles here, in PostCSS syntax */
@tailwind base;
@tailwind components;
@tailwind utilities;
+4 -4
View File
@@ -1,9 +1,9 @@
<div class="flex">
<div class="hero min-h-screen">
<div class="hero-content text-center">
<div class="max-w-lg rounded-md bg-base-200 p-16">
<div class="bg-base-200 p-16 max-w-lg rounded-md">
<h1 class="text-5xl font-bold">There was an error.</h1>
<div class="join py-6">
<div class="py-6 join">
<a class="btn btn-primary btn-lg" href="/">Home</a>
</div>
</div>
@@ -11,7 +11,7 @@
</div>
<footer
class="footer footer-center fixed bottom-0 bg-base-300 p-4 text-base-content"
class="footer footer-center p-4 bg-base-300 text-base-content bottom-0 fixed"
>
<aside class="text-2xl">
<p>
@@ -19,7 +19,7 @@
href="https://github.com/TheOnlyWayUp"
class="underline"
target="_blank">TheOnlyWayUp</a
> © 2025
> © 2024
</p>
</aside>
</footer>
File diff suppressed because one or more lines are too long
+159 -248
View File
@@ -1,294 +1,201 @@
<script>
let downloadImages = $state(false);
let downloadAsPdf = $state(false); // 0 = epub, 1 = pdf
let isPaidStory = $state(false);
let invalidUrl = $state(false);
let afterDownloadPage = $state(false);
let credentials = $state({
let story_id = "";
let download_images = false;
let is_paid_story = false;
let credentials = {
username: "",
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;
password: "",
};
let after_download_page = false;
let url = "";
/** @param {string} input */
const setInputAsInvalid = (input) => {
invalidUrl = true;
inputUrl = input;
downloadId = input;
};
let raw_story_id = "";
let is_part_id = false;
/** @param {string} input */
const setInputUrl = (input) => {
input = input.toLowerCase();
let button_disabled = false;
$: button_disabled =
!story_id ||
(is_paid_story && !(credentials.username && credentials.password));
if (!input) {
setInputAsValid("");
return;
}
$: url =
`/download/${story_id}?om=1` +
(download_images ? "&download_images=true" : "") +
(is_paid_story
? `&username=${encodeURIComponent(credentials.username)}&password=${encodeURIComponent(credentials.password)}`
: "");
if (/^\d+$/.test(input)) {
// All numbers
mode = "story";
setInputAsValid(input);
return;
}
$: {
is_part_id = false;
if (raw_story_id.includes("wattpad.com")) {
// 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 (!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);
if (raw_story_id.includes("/story/")) {
// https://wattpad.com/story/237369078-wattpad-books-presents
story_id = raw_story_id.split("/story/")[1].split("-")[0].split("?")[0]; // removes tracking fields
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]; // removes params
raw_story_id = story_id;
} else {
setInputAsInvalid("");
// 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;
}
// 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>
<slot></slot>
<div>
<div class="hero min-h-screen">
<div
class="hero-content bg-base-100/50 flex-col rounded py-32 shadow-sm lg:flex-row-reverse lg:p-16"
class="hero-content flex-col lg:flex-row-reverse light:bg-base-100/50 dark:bg-[hsl(39,20%,5%)]/30 p-16 rounded shadow-sm"
>
{#if !afterDownloadPage}
<div class="text-center lg:p-10 lg:text-left">
{#if !after_download_page}
<div class="text-center lg:text-left lg:p-10">
<h1
class="bg-gradient-to-r from-red-700 via-yellow-600 to-pink-600 bg-clip-text text-5xl font-extrabold text-transparent"
class="font-extrabold text-transparent text-5xl bg-clip-text bg-gradient-to-r to-pink-600 via-yellow-600 from-red-700"
>
WP Downloader
Wattpad Downloader
</h1>
<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>
<!-- <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 class="pt-4 text-xl dark:text-white">
Download your favourite books with a single click!
</p>
<ul class="list list-inside pt-4 text-xl">
<ul
class="pt-6 list list-inside text-xl dark:text-[hsl(133,13%,85%)] text-left"
>
<!-- 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>12/24 - ⚡ Super-fast Downloads!</li>
<li>12/24 - 📑 PDF Downloads!</li> -->
<li>05/25 - ⚖️ Legal Compliance</li>
<li>12/24 - 📂 Less Errors, Throttled Downloads</li>
<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>06/24 - 🔑 Authenticated Downloads!</li>
<li>06/24 - 🖼️ Image Downloading!</li>
<li>08 '24 - 🌙 Dark Mode</li>
<li>07 '24 - 🔡 RTL Language support! (Arabic, etc.)</li>
<li>06 '24 - 🔑 Authenticated Downloads!</li>
<li>06 '24 - 🖼️ Image Downloading!</li>
</ul>
</div>
<div class="card bg-base-100 w-full max-w-sm shrink-0 shadow-2xl">
<div
class="card shrink-0 w-full max-w-sm shadow-2xl light:bg-base-100 dark:bg-[hsl(133,15%,9%)] dark:shadow-[0px_0px_150px_-100px_hsl(39,100%,50%)]"
>
<form class="card-body">
<div class="form-control">
<input
type="text"
placeholder="Story URL"
class="input input-bordered"
class:input-warning={invalidUrl}
bind:value={() => inputUrl, setInputUrl}
placeholder="Story ID"
class="input input-bordered dark:text-white"
class:input-warning={is_part_id}
bind:value={raw_story_id}
required
name="input_url"
name="story_id"
/>
<label class="label" for="input_url">
{#if invalidUrl}
<p class=" text-red-500">
<label class="label" for="story_id">
{#if is_part_id}
<p class="text-red-500">
Refer to (<button
class="link font-semibold"
onclick={() => storyURLTutorialModal.showModal()}
data-umami-event="Part StoryURLTutorialModal Open"
>How to get a Story URL</button
onclick="StoryIDTutorialModal.showModal()"
data-umami-event="Part StoryIDTutorialModal Open"
>How to get a Story ID</button
>).
</p>
{:else}
<button
class="link label-text font-semibold text-gray-800"
onclick={() => storyURLTutorialModal.showModal()}
data-umami-event="StoryURLTutorialModal Open">How to get a Story URL</button
class="label-text link font-semibold"
onclick="StoryIDTutorialModal.showModal()"
data-umami-event="StoryIDTutorialModal Open"
>How to get a Story ID</button
>
{/if}
</label>
<label class="label cursor-pointer text-gray-800">
<span class="label-text">This is a Paid Story, and I've purchased it</span>
<label class="cursor-pointer label">
<span class="label-text"
>This is a Paid Story, and I've purchased it</span
>
<input
type="checkbox"
class="checkbox-warning checkbox shadow-md"
bind:checked={isPaidStory}
class="checkbox checkbox-warning shadow-md"
bind:checked={is_paid_story}
/>
</label>
{#if isPaidStory}
<label class="input input-bordered flex items-center gap-2">
Username
<input
type="text"
class="grow"
name="username"
placeholder="foxtail.chicken"
bind:value={credentials.username}
required
/>
</label>
<label class="input input-bordered flex items-center gap-2">
Password
<input
type="password"
class="grow"
placeholder="supersecretpassword"
name="password"
bind:value={credentials.password}
required
/>
</label>
{#if is_paid_story}
<div class="grid grid-rows-2 gap-y-1">
<label class="input input-bordered flex items-center gap-2">
<span class="label-text">Username</span>
<input
type="text"
class="grow dark:text-white in_input p-1 rounded"
name="username"
placeholder="foxtail.chicken"
bind:value={credentials.username}
required
/>
</label>
<label class="input input-bordered flex items-center gap-2">
<span class="label-text">Password</span>
<input
type="password"
class="grow dark:text-white in_input p-1 rounded"
placeholder="supersecretpassword"
name="password"
bind:value={credentials.password}
required
/>
</label>
</div>
{/if}
</div>
<div class="form-control mt-6">
<a
class="btn rounded-l-none"
class:btn-primary={!downloadAsPdf}
class:btn-secondary={downloadAsPdf}
class:btn-disabled={buttonDisabled}
class="btn light:btn-primary dark:btn-accent"
class:btn-disabled={button_disabled}
class:disabled={button_disabled}
data-umami-event="Download"
href={url}
onclick={() => (afterDownloadPage = true)}>Download</a
on:click={() => (after_download_page = true)}>Download</a
>
<!-- <label class="swap w-fit label mt-2">
<input type="checkbox" bind:checked={downloadAsPdf} />
<div class="swap-on">
Downloading as <span class=" underline text-bold">PDF</span> (Click)
</div>
<div class="swap-off">
Downloading as <span class=" underline text-bold">EPUB</span> (Click)
</div>
</label> -->
<label class="label cursor-pointer">
<span class="label-text text-gray-800">Include Images (<strong>Slower Download</strong>)</span>
<label class="cursor-pointer label">
<span class="label-text"
>Include Images (<strong>Slower Download</strong>)</span
>
<input
type="checkbox"
class="checkbox-warning checkbox shadow-md"
bind:checked={downloadImages}
class="checkbox checkbox-warning shadow-md"
bind:checked={download_images}
/>
</label>
</div>
</form>
<button
data-feedback-fish
class="link pb-4 label-text"
data-umami-event="Feedback">Feedback</button
>
</div>
{:else}
<div class="max-w-4xl text-center">
<h1 class="text-3xl font-bold">
<div class="text-center max-w-4xl">
<h1 class="font-bold text-3xl">
Your download has <span
class="bg-gradient-to-r from-red-700 via-yellow-600 to-pink-600 bg-clip-text text-transparent"
class="text-transparent bg-clip-text bg-gradient-to-r to-pink-600 via-yellow-600 from-red-700"
>Started</span
>
</h1>
<div class="space-y-2 py-4">
<div class="py-4 space-y-2">
<p class="text-2xl">
If you found this site useful, please consider <a
href="https://github.com/TheOnlyWayUp/WattpadDownloader"
target="_blank"
class="link"
data-umami-event="Star">starring the project</a
> to support WPDownloader.
> to support WattpadDownloader.
</p>
<p class="pt-2 text-lg">
<p class="text-lg pt-2">
You can also join us on <a
href="https://discord.gg/P9RHC4KCwd"
target="_blank"
@@ -297,46 +204,50 @@
>, where we release features early and discuss updates.
</p>
</div>
<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>
<button
on:click={() => {
after_download_page = false;
raw_story_id = "";
}}
class="btn btn-outline btn-lg mt-10">Download More</button
>
</div>
{/if}
</div>
</div>
</div>
<dialog class="modal" bind:this={storyURLTutorialModal}>
<div class="modal-box">
<!-- Open the modal using ID.showModal() method -->
<dialog id="StoryIDTutorialModal" class="modal">
<div class="modal-box dark:bg-[hsl(133,15%,9%)] dark:text-white/80">
<form method="dialog">
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2"></button>
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
></button
>
</form>
<h3 class="text-lg font-bold">How to get a Story URL</h3>
<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>
<h3 class="font-bold text-lg">Retrieving a Story ID</h3>
<ol class="list list-disc list-inside py-4 space-y-4">
<li>
For example,
<span class="bg-slate-100 p-1 font-mono"
>wattpad.com/<span class="rounded-sm bg-amber-200">story</span
>/237369078-wattpad-books-presents</span
>.
Open the Story URL, this page includes the story description and tags.
(For example, <span
class="font-mono bg-slate-100 dark:bg-[hsl(133,40%,90%)]/90 text-black p-[5px] rounded"
>wattpad.com/story/237369078-wattpad-books-presents</span
>).
</li>
<li>
<span class="bg-slate-100 p-1 font-mono">https://www.wattpad.com/939103774-given</span> is okay
too.
Copy the numbers after the <span
class="font-mono bg-slate-100 dark:bg-[hsl(133,40%,90%)]/90 text-black p-[5px] rounded"
>/</span
>
(In the example, that'd be,
<span
class="font-mono bg-slate-100 dark:bg-[hsl(133,40%,90%)]/90 text-black p-[5px] rounded"
>wattpad.com/story/<span class="bg-amber-200 p-1">237369078</span
>-wattpad-books-presents</span
>)
</li>
<li>Paste the URL and hit Download!</li>
<li>Paste the Story ID and hit Download!</li>
</ol>
</div>
<form method="dialog" class="modal-backdrop">
+1 -1
View File
@@ -8,7 +8,7 @@
<url>
<loc>https://wpd.my/</loc>
<loc>https://wpd.rambhat.la/</loc>
<lastmod>2024-04-12T08:19:07+00:00</lastmod>
</url>
+13 -2
View File
@@ -1,5 +1,16 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
import adapter from "@sveltejs/adapter-static";
const config = { kit: { adapter: adapter({ strict: false }) } };
/** @type {import('@sveltejs/kit').Config} */
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;
+21
View File
@@ -0,0 +1,21 @@
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 -5
View File
@@ -1,10 +1,6 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
allowedHosts: true
}
plugins: [sveltekit()]
});