feat: Paste Links, Deprecate IDs (#17 - @AaronBenDaniel)

* deprecate Story IDs, require full URLs

* added FRONT-END ONLY support for part and list URLs

* add backend support for part IDs

* added backend support for lists

* Support enums

* Simplify and remove List support

* Update frontend

* Frontend: Revert dialog changes

* Remove List support

---------

Co-authored-by: TheOnlyWayUp <hi@towu.dev>
This commit is contained in:
AaronBenDaniel
2024-11-07 03:37:57 -05:00
committed by TheOnlyWayUp
parent e89dc7e699
commit ca4697057c
5 changed files with 99 additions and 56 deletions
+18
View File
@@ -82,6 +82,24 @@ def slugify(value, allow_unicode=False) -> str:
# --- API Calls --- #
@backoff.on_exception(backoff.expo, ClientResponseError, max_time=15)
async def fetch_story_id(part_id: int, cookies: Optional[dict] = None) -> int:
"""Return a Story ID from a Part ID."""
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/story_parts/{part_id}?fields=groupId"
) as response:
response.raise_for_status()
body = await response.json()
return body["groupId"]
@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."""
+29 -19
View File
@@ -1,6 +1,7 @@
from typing import Optional
from pathlib import Path
from fastapi import FastAPI, HTTPException
from enum import Enum
from fastapi import FastAPI
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from ebooklib import epub
from create_book import (
@@ -10,6 +11,7 @@ from create_book import (
add_chapters,
slugify,
wp_get_cookies,
fetch_story_id,
)
import tempfile
from io import BytesIO
@@ -18,19 +20,31 @@ from fastapi.staticfiles import StaticFiles
app = FastAPI()
BUILD_PATH = Path(__file__).parent / "build"
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"
}
class DownloadMode(Enum):
story = "story"
part = "part"
collection = "collection"
@app.get("/")
def home():
return FileResponse(BUILD_PATH / "index.html")
@app.get("/download/{story_id}")
async def download_book(
story_id: int,
@app.get("/download/{download_id}")
async def handle_download(
download_id: int,
download_images: bool = False,
mode: DownloadMode = DownloadMode.story,
username: Optional[str] = None,
password: Optional[str] = None,
):
if username and not password or password and not username:
return HTMLResponse(
status_code=422,
@@ -49,25 +63,21 @@ async def download_book(
else:
cookies = None
data = await retrieve_story(story_id, cookies=cookies)
match mode:
case DownloadMode.story:
story_id = download_id
case DownloadMode.part:
story_id = await fetch_story_id(download_id, cookies)
metadata = await retrieve_story(story_id, cookies)
book = epub.EpubBook()
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>',
)
set_metadata(book, metadata)
await set_cover(book, metadata, cookies=cookies)
await set_cover(book, data, cookies=cookies)
# print("Metadata Downloaded")
# Chapters are downloaded
async for title in add_chapters(
book, data, download_images=download_images, cookies=cookies
book, metadata, download_images=download_images, cookies=cookies
):
# print(f"Part ({title}) downloaded")
...
# Book is compiled
@@ -85,7 +95,7 @@ async def download_book(
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
"Content-Disposition": f'attachment; filename="{slugify(metadata["title"])}_{story_id}_{"images" if download_images else ""}.epub"' # Thanks https://stackoverflow.com/a/72729058
},
)
+52 -37
View File
@@ -1,52 +1,66 @@
<script>
let story_id = "";
let download_images = false;
let is_paid_story = false;
let invalid_url = false;
let after_download_page = false;
let credentials = {
username: "",
password: "",
};
let after_download_page = false;
let url = "";
let raw_story_id = "";
let is_part_id = false;
let download_id = "";
let mode = "";
let input_url = "";
let button_disabled = false;
$: button_disabled =
!story_id ||
!input_url ||
(is_paid_story && !(credentials.username && credentials.password));
$: url =
`/download/${story_id}?om=1` +
`/download/` +
download_id +
`?om=1` +
(download_images ? "&download_images=true" : "") +
(is_paid_story
? `&username=${encodeURIComponent(credentials.username)}&password=${encodeURIComponent(credentials.password)}`
: "");
: "") +
`&mode=${mode}`;
$: {
is_part_id = false;
if (raw_story_id.includes("wattpad.com")) {
if (input_url.length) {
input_url = input_url.toLowerCase();
invalid_url = false;
if (!input_url.includes("wattpad.com/")) {
invalid_url = true;
}
// 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/")) {
if (input_url.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/")) {
input_url = input_url.split("-")[0].split("/story/")[1]; // removes tracking fields and title
download_id = input_url;
mode = "story";
} else if (input_url.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;
input_url = input_url.split("?")[0].split("/stories/")[1]; // removes params
download_id = input_url;
mode = "story";
} else {
// https://www.wattpad.com/939051741-wattpad-books-presents-part-name
is_part_id = true;
raw_story_id = "";
story_id = "";
// https://www.wattpad.com/939051741-wattpad-books-presents-the-qb-bad-boy-and-me
input_url = input_url.split("-")[0].split("wattpad.com/")[1]; // removes tracking fields and title
download_id = input_url;
if (/^\d+$/.test(download_id)) {
// If "wattpad.com/{download_id}" contains only numbers
mode = "part";
} else {
invalid_url = true;
input_url = "";
download_id = "";
}
}
} else {
story_id = parseInt(raw_story_id) || ""; // parseInt returns NaN for undefined values.
raw_story_id = story_id;
}
}
</script>
@@ -68,6 +82,7 @@
</p>
<ul class="pt-4 list list-inside text-xl">
<!-- TODO: 'max-lg: hidden' to hide on screen sizes smaller than lg. I'll do this when I figure out how to make this show up _below_ the card on smaller screen sizes. -->
<li>11/24 - 🔗 Paste Links!</li>
<li>11/24 - 📨 Send to Kindle Support!</li>
<li>11/24 - ⚒️ Fix Image Downloads</li>
@@ -88,29 +103,29 @@
<div class="form-control">
<input
type="text"
placeholder="Story ID"
placeholder="Story URL"
class="input input-bordered"
class:input-warning={is_part_id}
bind:value={raw_story_id}
class:input-warning={invalid_url}
bind:value={input_url}
required
name="story_id"
name="input_url"
/>
<label class="label" for="story_id">
{#if is_part_id}
<label class="label" for="input_url">
{#if invalid_url}
<p class=" text-red-500">
Refer to (<button
class="link font-semibold"
onclick="StoryIDTutorialModal.showModal()"
data-umami-event="Part StoryIDTutorialModal Open"
>How to get a Story ID</button
onclick="StoryURLTutorialModal.showModal()"
data-umami-event="Part StoryURLTutorialModal Open"
>How to get a Story URL</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
onclick="StoryURLTutorialModal.showModal()"
data-umami-event="StoryURLTutorialModal Open"
>How to get a Story URL</button
>
{/if}
</label>
@@ -207,7 +222,7 @@
<button
on:click={() => {
after_download_page = false;
raw_story_id = "";
input_url = "";
}}
class="btn btn-outline btn-lg mt-10">Download More</button
>