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 --- # # --- 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) @backoff.on_exception(backoff.expo, ClientResponseError, max_time=15)
async def retrieve_story(story_id: int, cookies: Optional[dict] = None) -> dict: async def retrieve_story(story_id: int, cookies: Optional[dict] = None) -> dict:
"""Taking a story_id, return its information from the Wattpad API.""" """Taking a story_id, return its information from the Wattpad API."""
+29 -19
View File
@@ -1,6 +1,7 @@
from typing import Optional from typing import Optional
from pathlib import Path 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 fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from ebooklib import epub from ebooklib import epub
from create_book import ( from create_book import (
@@ -10,6 +11,7 @@ from create_book import (
add_chapters, add_chapters,
slugify, slugify,
wp_get_cookies, wp_get_cookies,
fetch_story_id,
) )
import tempfile import tempfile
from io import BytesIO from io import BytesIO
@@ -18,19 +20,31 @@ from fastapi.staticfiles import StaticFiles
app = FastAPI() app = FastAPI()
BUILD_PATH = Path(__file__).parent / "build" 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("/") @app.get("/")
def home(): def home():
return FileResponse(BUILD_PATH / "index.html") return FileResponse(BUILD_PATH / "index.html")
@app.get("/download/{story_id}") @app.get("/download/{download_id}")
async def download_book( async def handle_download(
story_id: int, download_id: int,
download_images: bool = False, download_images: bool = False,
mode: DownloadMode = DownloadMode.story,
username: Optional[str] = None, username: Optional[str] = None,
password: Optional[str] = None, password: Optional[str] = None,
): ):
if username and not password or password and not username: if username and not password or password and not username:
return HTMLResponse( return HTMLResponse(
status_code=422, status_code=422,
@@ -49,25 +63,21 @@ async def download_book(
else: else:
cookies = None 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() book = epub.EpubBook()
try: set_metadata(book, metadata)
set_metadata(book, data) await set_cover(book, metadata, cookies=cookies)
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>',
)
await set_cover(book, data, cookies=cookies)
# print("Metadata Downloaded")
# Chapters are downloaded
async for title in add_chapters( 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 # Book is compiled
@@ -85,7 +95,7 @@ async def download_book(
BytesIO(book_data), BytesIO(book_data),
media_type="application/epub+zip", media_type="application/epub+zip",
headers={ 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> <script>
let story_id = "";
let download_images = false; let download_images = false;
let is_paid_story = false; let is_paid_story = false;
let invalid_url = false;
let after_download_page = false;
let credentials = { let credentials = {
username: "", username: "",
password: "", password: "",
}; };
let after_download_page = false; let download_id = "";
let url = ""; let mode = "";
let input_url = "";
let raw_story_id = "";
let is_part_id = false;
let button_disabled = false; let button_disabled = false;
$: button_disabled = $: button_disabled =
!story_id || !input_url ||
(is_paid_story && !(credentials.username && credentials.password)); (is_paid_story && !(credentials.username && credentials.password));
$: url = $: url =
`/download/${story_id}?om=1` + `/download/` +
download_id +
`?om=1` +
(download_images ? "&download_images=true" : "") + (download_images ? "&download_images=true" : "") +
(is_paid_story (is_paid_story
? `&username=${encodeURIComponent(credentials.username)}&password=${encodeURIComponent(credentials.password)}` ? `&username=${encodeURIComponent(credentials.username)}&password=${encodeURIComponent(credentials.password)}`
: ""); : "") +
`&mode=${mode}`;
$: { $: {
is_part_id = false; if (input_url.length) {
if (raw_story_id.includes("wattpad.com")) { 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. // 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). // 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 // https://wattpad.com/story/237369078-wattpad-books-presents
story_id = raw_story_id.split("/story/")[1].split("-")[0].split("?")[0]; // removes tracking fields input_url = input_url.split("-")[0].split("/story/")[1]; // removes tracking fields and title
raw_story_id = story_id; download_id = input_url;
} else if (raw_story_id.includes("/stories/")) { mode = "story";
} else if (input_url.includes("/stories/")) {
// https://www.wattpad.com/api/v3/stories/237369078?fields=... // https://www.wattpad.com/api/v3/stories/237369078?fields=...
story_id = raw_story_id.split("/stories/")[1].split("?")[0]; // removes params input_url = input_url.split("?")[0].split("/stories/")[1]; // removes params
raw_story_id = story_id; download_id = input_url;
mode = "story";
} else { } else {
// https://www.wattpad.com/939051741-wattpad-books-presents-part-name // https://www.wattpad.com/939051741-wattpad-books-presents-the-qb-bad-boy-and-me
is_part_id = true; input_url = input_url.split("-")[0].split("wattpad.com/")[1]; // removes tracking fields and title
raw_story_id = ""; download_id = input_url;
story_id = ""; 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> </script>
@@ -68,6 +82,7 @@
</p> </p>
<ul class="pt-4 list list-inside text-xl"> <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. --> <!-- 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 - 📨 Send to Kindle Support!</li>
<li>11/24 - ⚒️ Fix Image Downloads</li> <li>11/24 - ⚒️ Fix Image Downloads</li>
@@ -88,29 +103,29 @@
<div class="form-control"> <div class="form-control">
<input <input
type="text" type="text"
placeholder="Story ID" placeholder="Story URL"
class="input input-bordered" class="input input-bordered"
class:input-warning={is_part_id} class:input-warning={invalid_url}
bind:value={raw_story_id} bind:value={input_url}
required required
name="story_id" name="input_url"
/> />
<label class="label" for="story_id"> <label class="label" for="input_url">
{#if is_part_id} {#if invalid_url}
<p class=" text-red-500"> <p class=" text-red-500">
Refer to (<button Refer to (<button
class="link font-semibold" class="link font-semibold"
onclick="StoryIDTutorialModal.showModal()" onclick="StoryURLTutorialModal.showModal()"
data-umami-event="Part StoryIDTutorialModal Open" data-umami-event="Part StoryURLTutorialModal Open"
>How to get a Story ID</button >How to get a Story URL</button
>). >).
</p> </p>
{:else} {:else}
<button <button
class="label-text link font-semibold" class="label-text link font-semibold"
onclick="StoryIDTutorialModal.showModal()" onclick="StoryURLTutorialModal.showModal()"
data-umami-event="StoryIDTutorialModal Open" data-umami-event="StoryURLTutorialModal Open"
>How to get a Story ID</button >How to get a Story URL</button
> >
{/if} {/if}
</label> </label>
@@ -207,7 +222,7 @@
<button <button
on:click={() => { on:click={() => {
after_download_page = false; after_download_page = false;
raw_story_id = ""; input_url = "";
}} }}
class="btn btn-outline btn-lg mt-10">Download More</button class="btn btn-outline btn-lg mt-10">Download More</button
> >