Compare commits
No commits in common. "main" and "v1.0.0" have entirely different histories.
8
.github/workflows/format.yml
vendored
8
.github/workflows/format.yml
vendored
@ -19,10 +19,12 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -r requirements/dev.txt
|
pip install flake8 black
|
||||||
|
|
||||||
- name: Lint with pylint
|
- name: Lint with flake8
|
||||||
run: pylint app --extension-pkg-allow-list=lxml
|
run: |
|
||||||
|
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||||
|
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||||
|
|
||||||
- name: Format with black
|
- name: Format with black
|
||||||
run: black .
|
run: black .
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,4 +1,3 @@
|
|||||||
.venv
|
.venv
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.vscode
|
.vscode
|
||||||
.vercel
|
|
10
Dockerfile
10
Dockerfile
@ -1,11 +1,13 @@
|
|||||||
FROM python:alpine
|
FROM python
|
||||||
|
|
||||||
WORKDIR /srv
|
WORKDIR /srv
|
||||||
|
|
||||||
COPY ./requirements /srv/requirements
|
COPY ./requirements.txt /srv/requirements.txt
|
||||||
|
|
||||||
RUN pip install -r requirements/prod.txt
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
COPY ./app /srv/app
|
COPY ./app /srv/app
|
||||||
|
|
||||||
CMD uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8081}
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]
|
41
README.md
41
README.md
@ -10,54 +10,19 @@ Backend for online ebook viewer publite
|
|||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
Run app locally (development only!)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# install requirements
|
|
||||||
pip install -r requirements/dev.txt
|
|
||||||
|
|
||||||
# run app with uvicorn
|
|
||||||
uvicorn app.main:app --reload --port <port>
|
|
||||||
```
|
|
||||||
|
|
||||||
Run app locally (test prod)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# install requirements
|
|
||||||
pip install -r requirements/prod.txt
|
|
||||||
|
|
||||||
# run app with uvicorn
|
|
||||||
uvicorn app.main:app --port <port>
|
|
||||||
|
|
||||||
# or
|
|
||||||
|
|
||||||
# run with python script
|
|
||||||
python run.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Simple docker deployment
|
Simple docker deployment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# build docker image
|
# build docker image
|
||||||
docker build . -t publite_backend
|
docker build . -t publite_backend
|
||||||
|
|
||||||
# run it with docker
|
# run it with docker
|
||||||
docker run -p <port>:8081 publite_backend
|
docker run -p <port>:80 publite_backend
|
||||||
```
|
```
|
||||||
|
|
||||||
Dokku deployment with image from Docker Hub
|
Dokku deployment with image from Docker Hub
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dokku apps:create publitebackend
|
dokku apps:create publitebackend
|
||||||
|
|
||||||
# increase file size limit to be able to upload bigger books
|
|
||||||
dokku nginx:set publitebackend client_max_body_size 50m
|
|
||||||
|
|
||||||
dokku git:from-image publitebackend publite/backend:latest
|
dokku git:from-image publitebackend publite/backend:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
# TODO
|
|
||||||
|
|
||||||
- Separate epub and fb2 files to python modules
|
|
||||||
- Rewrite own `.opf` file parsing to get rid of dependency on EbookLib
|
|
||||||
- Add cli interfaces for epub and fb2 libs
|
|
27
app/epub.py
27
app/epub.py
@ -2,19 +2,19 @@
|
|||||||
Module for EPUB file conversion to html
|
Module for EPUB file conversion to html
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import html
|
|
||||||
import os
|
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
from functools import cache
|
from functools import cache
|
||||||
|
import html
|
||||||
|
import os
|
||||||
from tempfile import SpooledTemporaryFile
|
from tempfile import SpooledTemporaryFile
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles as aiof
|
||||||
import ebooklib
|
|
||||||
from ebooklib import epub
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
import ebooklib
|
||||||
|
from ebooklib import epub
|
||||||
|
|
||||||
from .utils import DocumentTokens, HTMLBook, strip_whitespace
|
from .utils import DocumentTokens, strip_whitespace, HTMLBook
|
||||||
|
|
||||||
parser = etree.XMLParser(recover=True)
|
parser = etree.XMLParser(recover=True)
|
||||||
|
|
||||||
@ -61,7 +61,7 @@ async def epub_to_tokens(
|
|||||||
|
|
||||||
tokens = {}
|
tokens = {}
|
||||||
|
|
||||||
async with aiofiles.tempfile.NamedTemporaryFile() as tmp:
|
async with aiof.tempfile.NamedTemporaryFile() as tmp:
|
||||||
await tmp.write(file.read())
|
await tmp.write(file.read())
|
||||||
|
|
||||||
# Reading book file
|
# Reading book file
|
||||||
@ -108,6 +108,7 @@ async def epub_to_tokens(
|
|||||||
|
|
||||||
|
|
||||||
def read_metadata(book: epub.EpubBook) -> dict[str, str]:
|
def read_metadata(book: epub.EpubBook) -> dict[str, str]:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Reads metadata from xml to dict
|
Reads metadata from xml to dict
|
||||||
"""
|
"""
|
||||||
@ -120,6 +121,7 @@ def read_metadata(book: epub.EpubBook) -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def convert_list(titles_list: list[tuple[str, dict[str, str]]]) -> str:
|
def convert_list(titles_list: list[tuple[str, dict[str, str]]]) -> str:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Joins titles list to one string
|
Joins titles list to one string
|
||||||
"""
|
"""
|
||||||
@ -132,6 +134,7 @@ def convert_list(titles_list: list[tuple[str, dict[str, str]]]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def set_cover(tokens: DocumentTokens) -> None:
|
def set_cover(tokens: DocumentTokens) -> None:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Converts cover file name to base64 image stored in `tokens`
|
Converts cover file name to base64 image stored in `tokens`
|
||||||
"""
|
"""
|
||||||
@ -142,6 +145,7 @@ def set_cover(tokens: DocumentTokens) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def epub_tokens2html(spine: list[tuple[str, str]], tokens: DocumentTokens) -> bytes:
|
def epub_tokens2html(spine: list[tuple[str, str]], tokens: DocumentTokens) -> bytes:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Joins chapters in `spice` to one html string
|
Joins chapters in `spice` to one html string
|
||||||
"""
|
"""
|
||||||
@ -153,10 +157,11 @@ def epub_tokens2html(spine: list[tuple[str, str]], tokens: DocumentTokens) -> by
|
|||||||
if file_path:
|
if file_path:
|
||||||
res += process_xhtml(file_path, tokens)
|
res += process_xhtml(file_path, tokens)
|
||||||
|
|
||||||
return html.unescape(res)
|
return html.escape(html.unescape(res))
|
||||||
|
|
||||||
|
|
||||||
def process_xhtml(path: str, tokens: DocumentTokens) -> bytes:
|
def process_xhtml(path: str, tokens: DocumentTokens) -> bytes:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Processes content of one xml body
|
Processes content of one xml body
|
||||||
"""
|
"""
|
||||||
@ -174,6 +179,7 @@ def process_xhtml(path: str, tokens: DocumentTokens) -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
def process_content(node: etree.Element, path: str, tokens: DocumentTokens) -> None:
|
def process_content(node: etree.Element, path: str, tokens: DocumentTokens) -> None:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Recursive function for xml element convertion to valid html
|
Recursive function for xml element convertion to valid html
|
||||||
"""
|
"""
|
||||||
@ -213,6 +219,7 @@ def process_content(node: etree.Element, path: str, tokens: DocumentTokens) -> N
|
|||||||
|
|
||||||
|
|
||||||
def process_a_element(node: etree.Element, path: str):
|
def process_a_element(node: etree.Element, path: str):
|
||||||
|
|
||||||
r"""
|
r"""
|
||||||
Converts `filed` links to ids in \<a\> element
|
Converts `filed` links to ids in \<a\> element
|
||||||
"""
|
"""
|
||||||
@ -230,6 +237,7 @@ def process_a_element(node: etree.Element, path: str):
|
|||||||
|
|
||||||
|
|
||||||
def process_media_element(node: etree.Element, path: str, tokens: DocumentTokens):
|
def process_media_element(node: etree.Element, path: str, tokens: DocumentTokens):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Replaces file paths to base64 encoded media in `src` and `srcset` tags
|
Replaces file paths to base64 encoded media in `src` and `srcset` tags
|
||||||
"""
|
"""
|
||||||
@ -248,6 +256,7 @@ def process_media_element(node: etree.Element, path: str, tokens: DocumentTokens
|
|||||||
|
|
||||||
|
|
||||||
def rel_to_abs_path(parent: str, rel: str):
|
def rel_to_abs_path(parent: str, rel: str):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Helper for relative path to media convertion to absolute
|
Helper for relative path to media convertion to absolute
|
||||||
"""
|
"""
|
||||||
@ -257,6 +266,7 @@ def rel_to_abs_path(parent: str, rel: str):
|
|||||||
|
|
||||||
@cache
|
@cache
|
||||||
def path_to_name(path: str) -> str:
|
def path_to_name(path: str) -> str:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Helper function for getting file name
|
Helper function for getting file name
|
||||||
"""
|
"""
|
||||||
@ -265,6 +275,7 @@ def path_to_name(path: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def children_to_html(root: etree.Element) -> bytes:
|
def children_to_html(root: etree.Element) -> bytes:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Converts all xml children of element to string and joins them
|
Converts all xml children of element to string and joins them
|
||||||
"""
|
"""
|
||||||
|
24
app/fb2.py
24
app/fb2.py
@ -2,15 +2,16 @@
|
|||||||
Module for FB2 file conversion to html
|
Module for FB2 file conversion to html
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import html
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
from tempfile import SpooledTemporaryFile
|
from tempfile import SpooledTemporaryFile
|
||||||
from typing import Optional
|
import xml.etree.ElementTree as ET
|
||||||
from xml.etree.ElementTree import Element
|
from xml.etree.ElementTree import Element
|
||||||
|
from typing import Optional
|
||||||
|
import html
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from .utils import DocumentTokens, HTMLBook, strip_whitespace
|
from .utils import DocumentTokens, strip_whitespace, HTMLBook
|
||||||
|
|
||||||
|
|
||||||
namespaces = {
|
namespaces = {
|
||||||
"": "http://www.gribuser.ru/xml/fictionbook/2.0",
|
"": "http://www.gribuser.ru/xml/fictionbook/2.0",
|
||||||
@ -32,7 +33,7 @@ async def fb22html(file: SpooledTemporaryFile) -> HTMLBook:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
**(tokens["metadata"]),
|
**(tokens["metadata"]),
|
||||||
"content": html.unescape(html_content.decode()),
|
"content": html.escape(html.unescape(html_content.decode())),
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
@ -42,6 +43,7 @@ async def fb22html(file: SpooledTemporaryFile) -> HTMLBook:
|
|||||||
|
|
||||||
|
|
||||||
def fb22tokens(file: SpooledTemporaryFile) -> DocumentTokens:
|
def fb22tokens(file: SpooledTemporaryFile) -> DocumentTokens:
|
||||||
|
|
||||||
r"""
|
r"""
|
||||||
Parses fb2 file as xml document.
|
Parses fb2 file as xml document.
|
||||||
It puts book metadata, its content and media to `tokens` dictionary and returns it.
|
It puts book metadata, its content and media to `tokens` dictionary and returns it.
|
||||||
@ -77,8 +79,7 @@ def fb22tokens(file: SpooledTemporaryFile) -> DocumentTokens:
|
|||||||
metadata = {}
|
metadata = {}
|
||||||
metadata["title"] = book_info.find("./book-title", namespaces).text
|
metadata["title"] = book_info.find("./book-title", namespaces).text
|
||||||
metadata["author"] = get_author(book_info.find("./author", namespaces))
|
metadata["author"] = get_author(book_info.find("./author", namespaces))
|
||||||
metadata["cover"] = get_cover(
|
metadata["cover"] = get_cover(book_info.find("./coverpage", namespaces))
|
||||||
book_info.find("./coverpage", namespaces))
|
|
||||||
if "cover" not in metadata.keys():
|
if "cover" not in metadata.keys():
|
||||||
metadata.pop("cover")
|
metadata.pop("cover")
|
||||||
|
|
||||||
@ -104,6 +105,7 @@ def fb22tokens(file: SpooledTemporaryFile) -> DocumentTokens:
|
|||||||
|
|
||||||
|
|
||||||
def get_author(author: Element) -> str:
|
def get_author(author: Element) -> str:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Converts author xml structure to string
|
Converts author xml structure to string
|
||||||
"""
|
"""
|
||||||
@ -115,7 +117,7 @@ def get_author(author: Element) -> str:
|
|||||||
"last-name",
|
"last-name",
|
||||||
):
|
):
|
||||||
tag = author.find("./" + tag_name, namespaces)
|
tag = author.find("./" + tag_name, namespaces)
|
||||||
if tag is not None and tag.text is not None:
|
if tag is not None:
|
||||||
res.append(tag.text)
|
res.append(tag.text)
|
||||||
if len(res) == 0:
|
if len(res) == 0:
|
||||||
res = author.find("./nickname", namespaces).text
|
res = author.find("./nickname", namespaces).text
|
||||||
@ -126,6 +128,7 @@ def get_author(author: Element) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def get_cover(coverpage: Optional[Element]) -> Optional[str]:
|
def get_cover(coverpage: Optional[Element]) -> Optional[str]:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Extracts cover image id if exists
|
Extracts cover image id if exists
|
||||||
"""
|
"""
|
||||||
@ -146,6 +149,7 @@ def set_cover(tokens: DocumentTokens) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def fb2body2html(tokens: DocumentTokens) -> str:
|
def fb2body2html(tokens: DocumentTokens) -> str:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Convert fb2 xml to html, joins bodies into one string
|
Convert fb2 xml to html, joins bodies into one string
|
||||||
"""
|
"""
|
||||||
@ -160,6 +164,7 @@ def fb2body2html(tokens: DocumentTokens) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def process_section(body: Element, tokens: DocumentTokens) -> str:
|
def process_section(body: Element, tokens: DocumentTokens) -> str:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Processes individual sections, recursively goes throw sections tree
|
Processes individual sections, recursively goes throw sections tree
|
||||||
"""
|
"""
|
||||||
@ -187,6 +192,7 @@ def process_section(body: Element, tokens: DocumentTokens) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def children_to_html(root: Element) -> str:
|
def children_to_html(root: Element) -> str:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Converts xml tag children to string
|
Converts xml tag children to string
|
||||||
"""
|
"""
|
||||||
@ -200,6 +206,7 @@ def children_to_html(root: Element) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def process_image(element: Element, tokens: DocumentTokens) -> None:
|
def process_image(element: Element, tokens: DocumentTokens) -> None:
|
||||||
|
|
||||||
r"""
|
r"""
|
||||||
Converts fb2 \<image /\> to html \<img /\>. Replaces xlink:href with src="\<base64_image_data\>"
|
Converts fb2 \<image /\> to html \<img /\>. Replaces xlink:href with src="\<base64_image_data\>"
|
||||||
"""
|
"""
|
||||||
@ -230,6 +237,7 @@ tag_with_class = {
|
|||||||
|
|
||||||
|
|
||||||
def process_content(root: Element, tokens: DocumentTokens) -> None:
|
def process_content(root: Element, tokens: DocumentTokens) -> None:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Converts fb2 xml tag names to html equivalents and my own styled elements.
|
Converts fb2 xml tag names to html equivalents and my own styled elements.
|
||||||
Resolves binary data dependencies
|
Resolves binary data dependencies
|
||||||
|
18
app/main.py
18
app/main.py
@ -2,18 +2,13 @@
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import FastAPI, File, HTTPException, UploadFile
|
from fastapi import FastAPI, File, UploadFile, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from pydantic import BaseModel # pylint: disable=no-name-in-module
|
from pydantic import BaseModel # pylint: disable=no-name-in-module
|
||||||
|
|
||||||
from .epub import epub2html
|
from .epub import epub2html
|
||||||
from .fb2 import fb22html
|
from .fb2 import fb22html
|
||||||
from .utils import HashedHTMLBook, add_hash
|
from .utils import HashedHTMLBook, add_hash
|
||||||
|
|
||||||
origins = (
|
|
||||||
"*"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DebugInfo(BaseModel): # pylint: disable=too-few-public-methods
|
class DebugInfo(BaseModel): # pylint: disable=too-few-public-methods
|
||||||
"""Main handler return types"""
|
"""Main handler return types"""
|
||||||
@ -23,14 +18,6 @@ class DebugInfo(BaseModel): # pylint: disable=too-few-public-methods
|
|||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=origins,
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
start_time = datetime.now()
|
start_time = datetime.now()
|
||||||
|
|
||||||
|
|
||||||
@ -60,8 +47,7 @@ async def create_upload_file(file: UploadFile = File(...)):
|
|||||||
elif file.filename.endswith(".epub"):
|
elif file.filename.endswith(".epub"):
|
||||||
content = await epub2html(file.file)
|
content = await epub2html(file.file)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=415, detail="Error! Unsupported file type")
|
||||||
status_code=415, detail="Error! Unsupported file type")
|
|
||||||
|
|
||||||
h_content = add_hash(content)
|
h_content = add_hash(content)
|
||||||
|
|
||||||
|
@ -3,9 +3,9 @@ Utils for publite_backend module
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
from typing import Union, Optional
|
||||||
import re
|
import re
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from typing import Optional, Union
|
|
||||||
|
|
||||||
from pydantic import BaseModel # pylint: disable=no-name-in-module
|
from pydantic import BaseModel # pylint: disable=no-name-in-module
|
||||||
|
|
||||||
@ -17,7 +17,7 @@ class HTMLBook(BaseModel): # pylint: disable=too-few-public-methods
|
|||||||
|
|
||||||
title: str
|
title: str
|
||||||
author: str
|
author: str
|
||||||
cover: Optional[str] = None
|
cover: Optional[str]
|
||||||
content: str
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
@ -1 +1,23 @@
|
|||||||
-r requirements/prod.txt
|
aiofiles==0.7.0
|
||||||
|
appdirs==1.4.4
|
||||||
|
asgiref==3.4.0
|
||||||
|
black==21.6b0
|
||||||
|
click==8.0.1
|
||||||
|
EbookLib==0.17.1
|
||||||
|
fastapi==0.65.2
|
||||||
|
flake8==3.9.2
|
||||||
|
h11==0.12.0
|
||||||
|
lxml==4.6.3
|
||||||
|
mccabe==0.6.1
|
||||||
|
mypy-extensions==0.4.3
|
||||||
|
pathspec==0.8.1
|
||||||
|
pycodestyle==2.7.0
|
||||||
|
pydantic==1.8.2
|
||||||
|
pyflakes==2.3.1
|
||||||
|
python-multipart==0.0.5
|
||||||
|
regex==2021.7.1
|
||||||
|
six==1.16.0
|
||||||
|
starlette==0.14.2
|
||||||
|
toml==0.10.2
|
||||||
|
typing-extensions==3.10.0.0
|
||||||
|
uvicorn==0.14.0
|
||||||
|
@ -1,4 +0,0 @@
|
|||||||
-r prod.txt
|
|
||||||
pylint
|
|
||||||
rope
|
|
||||||
black
|
|
@ -1,7 +0,0 @@
|
|||||||
fastapi
|
|
||||||
uvicorn
|
|
||||||
aiofiles
|
|
||||||
ebooklib
|
|
||||||
python-multipart
|
|
||||||
lxml
|
|
||||||
pydantic
|
|
4
run.py
4
run.py
@ -1,4 +0,0 @@
|
|||||||
import uvicorn
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
uvicorn.run("app.main:app")
|
|
@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"rewrites": [
|
|
||||||
{ "source": "/(.*)", "destination": "/api/main"}
|
|
||||||
]
|
|
||||||
}
|
|
Loading…
x
Reference in New Issue
Block a user