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:
committed by
TheOnlyWayUp
parent
e89dc7e699
commit
ca4697057c
@@ -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
@@ -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
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user