Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd32a03bf8 | |||
| 232795b050 | |||
| 85bc4609c2 | |||
| 3369325d03 | |||
| e16496ca94 | |||
| 3f9641d76a | |||
| 868e02992b | |||
| 0184c786ce | |||
| b663448103 | |||
| 0983c13da7 | |||
| 55763c1b99 | |||
| 9f24d437cb | |||
| 79c9447cbe |
@@ -1,5 +1,6 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
venv
|
venv
|
||||||
|
*epub
|
||||||
data
|
data
|
||||||
*ipynb
|
*ipynb
|
||||||
build
|
build
|
||||||
|
|||||||
Vendored
+1
-7
@@ -1,9 +1,3 @@
|
|||||||
{
|
{
|
||||||
"python.analysis.autoImportCompletions": true,
|
"python.analysis.autoImportCompletions": true
|
||||||
"vscord.app.privacyMode.enable": false,
|
|
||||||
"python.testing.pytestArgs": [
|
|
||||||
"src"
|
|
||||||
],
|
|
||||||
"python.testing.unittestEnabled": false,
|
|
||||||
"python.testing.pytestEnabled": true
|
|
||||||
}
|
}
|
||||||
@@ -9,11 +9,12 @@ Stars ⭐ are appreciated. Thanks!
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
- ⚡ Lightweight Frontend and Minimal Javascript.
|
- ⚡ Lightweight Frontend and Minimal Javascript.
|
||||||
|
- 🪙 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, Basic Ratelimit Handling.
|
- 🐇 Fast Generation, Ratelimit Handling.
|
||||||
- 🐳 Docker Support
|
- 🐳 Docker Support
|
||||||
- 🏷️ Generated EPUB File includes Metadata. (Dublin Core Spec)
|
- 🏷️ Generated EPUB File includes Metadata. (Dublin Core Spec)
|
||||||
- 📖 Plays well with E-Readers. (Kindle Support if KOReader present)
|
- 📖 Plays well with E-Readers. (Kindle Support if KOReader present, ReMarkable, KOBO, ...)
|
||||||
- 💻 Easily Hackable. Extend with ease.
|
- 💻 Easily Hackable. Extend with ease.
|
||||||
|
|
||||||
|
|
||||||
@@ -31,5 +32,5 @@ My thanks to [aerkalov/ebooklib](https://github.com/aerkalov/ebooklib) for a fas
|
|||||||
---
|
---
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<p>TheOnlyWayUp © 2023</p>
|
<p>TheOnlyWayUp © 2024</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ async def add_chapters(
|
|||||||
):
|
):
|
||||||
chapters = []
|
chapters = []
|
||||||
|
|
||||||
for part in data["parts"]:
|
for cidx, part in enumerate(data["parts"]):
|
||||||
content = await fetch_part_content(part["id"], cookies=cookies)
|
content = await fetch_part_content(part["id"], cookies=cookies)
|
||||||
title = part["title"]
|
title = part["title"]
|
||||||
clean_title = slugify(title)
|
clean_title = slugify(title)
|
||||||
@@ -182,7 +182,7 @@ async def add_chapters(
|
|||||||
# Thanks https://eu17.proxysite.com/process.php?d=5VyWYcoQl%2BVF0BYOuOavtvjOloFUZz2BJ%2Fepiusk6Nz7PV%2B9i8rs7cFviGftrBNll%2B0a3qO7UiDkTt4qwCa0fDES&b=1
|
# Thanks https://eu17.proxysite.com/process.php?d=5VyWYcoQl%2BVF0BYOuOavtvjOloFUZz2BJ%2Fepiusk6Nz7PV%2B9i8rs7cFviGftrBNll%2B0a3qO7UiDkTt4qwCa0fDES&b=1
|
||||||
chapter = epub.EpubHtml(
|
chapter = epub.EpubHtml(
|
||||||
title=title,
|
title=title,
|
||||||
file_name=f"{clean_title}.xhtml",
|
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"],
|
lang=data["language"]["name"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -200,11 +200,11 @@ async def add_chapters(
|
|||||||
img = epub.EpubImage(
|
img = epub.EpubImage(
|
||||||
media_type="image/jpeg",
|
media_type="image/jpeg",
|
||||||
content=await response.read(),
|
content=await response.read(),
|
||||||
file_name=f"static/{clean_title}/{idx}.jpeg",
|
file_name=f"static/{cidx}/{idx}.jpeg",
|
||||||
)
|
)
|
||||||
book.add_item(img)
|
book.add_item(img)
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
str(image), f'<img src="static/{clean_title}/{idx}.jpeg"/>'
|
str(image), f'<img src="static/{cidx}/{idx}.jpeg"/>'
|
||||||
)
|
)
|
||||||
|
|
||||||
chapter.set_content(f"<h1>{title}</h1>" + content)
|
chapter.set_content(f"<h1>{title}</h1>" + content)
|
||||||
|
|||||||
+2
-1
@@ -34,10 +34,11 @@ async def download_book(
|
|||||||
if username and not password or password and not username:
|
if username and not password or password and not username:
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
content='Include both the username _and_ password, or neither. Support is available on the <a href="https://discord.gg/P9RHC4KCwd" target="_blank">Discord</a>',
|
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.
|
||||||
try:
|
try:
|
||||||
cookies = await wp_get_cookies(username=username, password=password)
|
cookies = await wp_get_cookies(username=username, password=password)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
from .. import create_book
|
|
||||||
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
STORY_ID = 372219540
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_retrieve_story():
|
|
||||||
story_data = await create_book.retrieve_story(STORY_ID)
|
|
||||||
story_data.pop("modifyDate", None) # Subject to change
|
|
||||||
|
|
||||||
response = {
|
|
||||||
"id": "372219540",
|
|
||||||
"title": "WPD Test",
|
|
||||||
"createDate": "2024-07-02T15:29:13Z",
|
|
||||||
# "modifyDate": "2024-07-02T15:41:26Z",
|
|
||||||
"language": {"name": "English"},
|
|
||||||
"user": {"username": "KindaAssNgl"},
|
|
||||||
"description": "Testing story for WPD.",
|
|
||||||
"cover": r"https:\/\/img.wattpad.com\/cover\/372219540-256-k908955.jpg",
|
|
||||||
"completed": False,
|
|
||||||
"tags": ["testing", "towu", "wpd"],
|
|
||||||
"mature": False,
|
|
||||||
"url": r"https:\/\/www.wattpad.com\/story\/372219540-wpd-test",
|
|
||||||
"parts": [{"id": 1458516761, "title": "Ganesh"}],
|
|
||||||
"isPaywalled": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
assert story_data == response
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -6,10 +6,12 @@
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
let after_download_page = false;
|
let after_download_page = false;
|
||||||
let url = "";
|
let url = "";
|
||||||
|
|
||||||
|
let raw_story_id = "";
|
||||||
|
let is_part_id = false;
|
||||||
|
|
||||||
let button_disabled = false;
|
let button_disabled = false;
|
||||||
$: button_disabled =
|
$: button_disabled =
|
||||||
!story_id ||
|
!story_id ||
|
||||||
@@ -19,14 +21,42 @@
|
|||||||
`/download/${story_id}?om=1` +
|
`/download/${story_id}?om=1` +
|
||||||
(download_images ? "&download_images=true" : "") +
|
(download_images ? "&download_images=true" : "") +
|
||||||
(is_paid_story
|
(is_paid_story
|
||||||
? `&username=${credentials.username}&password=${credentials.password}`
|
? `&username=${encodeURIComponent(credentials.username)}&password=${encodeURIComponent(credentials.password)}`
|
||||||
: "");
|
: "");
|
||||||
|
|
||||||
|
$: {
|
||||||
|
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 (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 {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<slot></slot>
|
||||||
|
|
||||||
<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 flex-col lg:flex-row-reverse light:bg-base-100/50 dark:bg-[hsl(39,20%,5%)]/30 p-16 rounded shadow-sm"
|
||||||
>
|
>
|
||||||
{#if !after_download_page}
|
{#if !after_download_page}
|
||||||
<div class="text-center lg:text-left lg:p-10">
|
<div class="text-center lg:text-left lg:p-10">
|
||||||
@@ -35,31 +65,51 @@
|
|||||||
>
|
>
|
||||||
Wattpad Downloader
|
Wattpad Downloader
|
||||||
</h1>
|
</h1>
|
||||||
<p class="pt-6 text-lg">
|
<p class="pt-4 text-xl dark:text-white">
|
||||||
Download your favourite books with a single click!
|
Download your favourite books with a single click!
|
||||||
</p>
|
</p>
|
||||||
<ul class="pt-4 list list-inside text-xl">
|
<ul
|
||||||
<li>06/24 - 🎉 Image Downloading!</li>
|
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>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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-base-100">
|
<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">
|
<form class="card-body">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
placeholder="Story ID"
|
placeholder="Story ID"
|
||||||
class="input input-bordered"
|
class="input input-bordered dark:text-white"
|
||||||
bind:value={story_id}
|
class:input-warning={is_part_id}
|
||||||
|
bind:value={raw_story_id}
|
||||||
required
|
required
|
||||||
name="story_id"
|
name="story_id"
|
||||||
/>
|
/>
|
||||||
<label class="label" for="story_id">
|
<label class="label" for="story_id">
|
||||||
<button
|
{#if is_part_id}
|
||||||
class="label-text link font-semibold"
|
<p class="text-red-500">
|
||||||
onclick="StoryIDTutorialModal.showModal()"
|
Refer to (<button
|
||||||
data-umami-event="StoryIDTutorialModal Open"
|
class="link font-semibold"
|
||||||
>How to get a Story ID</button
|
onclick="StoryIDTutorialModal.showModal()"
|
||||||
>
|
data-umami-event="Part StoryIDTutorialModal Open"
|
||||||
|
>How to get a Story ID</button
|
||||||
|
>).
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<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>
|
||||||
<label class="cursor-pointer label">
|
<label class="cursor-pointer label">
|
||||||
<span class="label-text"
|
<span class="label-text"
|
||||||
@@ -72,35 +122,38 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{#if is_paid_story}
|
{#if is_paid_story}
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
<div class="grid grid-rows-2 gap-y-1">
|
||||||
Username
|
<label class="input input-bordered flex items-center gap-2">
|
||||||
<input
|
<span class="label-text">Username</span>
|
||||||
type="text"
|
<input
|
||||||
class="grow"
|
type="text"
|
||||||
name="username"
|
class="grow dark:text-white in_input p-1 rounded"
|
||||||
placeholder="foxtail.chicken"
|
name="username"
|
||||||
bind:value={credentials.username}
|
placeholder="foxtail.chicken"
|
||||||
required
|
bind:value={credentials.username}
|
||||||
/>
|
required
|
||||||
</label>
|
/>
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
</label>
|
||||||
Password
|
<label class="input input-bordered flex items-center gap-2">
|
||||||
<input
|
<span class="label-text">Password</span>
|
||||||
type="password"
|
<input
|
||||||
class="grow"
|
type="password"
|
||||||
placeholder="supersecretpassword"
|
class="grow dark:text-white in_input p-1 rounded"
|
||||||
name="password"
|
placeholder="supersecretpassword"
|
||||||
bind:value={credentials.password}
|
name="password"
|
||||||
required
|
bind:value={credentials.password}
|
||||||
/>
|
required
|
||||||
</label>
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control mt-6">
|
<div class="form-control mt-6">
|
||||||
<a
|
<a
|
||||||
class="btn btn-primary rounded-l-none"
|
class="btn light:btn-primary dark:btn-accent"
|
||||||
class:btn-disabled={button_disabled}
|
class:btn-disabled={button_disabled}
|
||||||
|
class:disabled={button_disabled}
|
||||||
data-umami-event="Download"
|
data-umami-event="Download"
|
||||||
href={url}
|
href={url}
|
||||||
on:click={() => (after_download_page = true)}>Download</a
|
on:click={() => (after_download_page = true)}>Download</a
|
||||||
@@ -121,7 +174,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
data-feedback-fish
|
data-feedback-fish
|
||||||
class="link pb-4"
|
class="link pb-4 label-text"
|
||||||
data-umami-event="Feedback">Feedback</button
|
data-umami-event="Feedback">Feedback</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,7 +204,13 @@
|
|||||||
>, 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>
|
<button
|
||||||
|
on:click={() => {
|
||||||
|
after_download_page = false;
|
||||||
|
raw_story_id = "";
|
||||||
|
}}
|
||||||
|
class="btn btn-outline btn-lg mt-10">Download More</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -161,26 +220,29 @@
|
|||||||
<!-- Open the modal using ID.showModal() method -->
|
<!-- Open the modal using ID.showModal() method -->
|
||||||
|
|
||||||
<dialog id="StoryIDTutorialModal" class="modal">
|
<dialog id="StoryIDTutorialModal" class="modal">
|
||||||
<div class="modal-box">
|
<div class="modal-box dark:bg-[hsl(133,15%,9%)] dark:text-white/80">
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||||
>✕</button
|
>✕</button
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
<h3 class="font-bold text-lg">Downloading a Story</h3>
|
<h3 class="font-bold text-lg">Retrieving a Story ID</h3>
|
||||||
<ol class="list list-disc list-inside py-4 space-y-2">
|
<ol class="list list-disc list-inside py-4 space-y-4">
|
||||||
<li>
|
<li>
|
||||||
Open the Story URL (For example, <span
|
Open the Story URL, this page includes the story description and tags.
|
||||||
class="font-mono bg-slate-100 p-1"
|
(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
|
>wattpad.com/story/237369078-wattpad-books-presents</span
|
||||||
>)
|
>).
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Copy the numbers after the <span class="font-mono bg-slate-100 p-1"
|
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
|
>/</span
|
||||||
>
|
>
|
||||||
(In the example, that'd be,
|
(In the example, that'd be,
|
||||||
<span class="font-mono bg-slate-100 p-1"
|
<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.com/story/<span class="bg-amber-200 p-1">237369078</span
|
||||||
>-wattpad-books-presents</span
|
>-wattpad-books-presents</span
|
||||||
>)
|
>)
|
||||||
|
|||||||
Reference in New Issue
Block a user