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 --- #
|
# --- 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
@@ -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
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user