mirror of
https://github.com/sakaljurgis/kob.git
synced 2026-07-08 22:17:41 +00:00
let there be light
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
.git/
|
||||
.gitignore
|
||||
plan.md
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# app data
|
||||
data/
|
||||
testdata/
|
||||
|
||||
# python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# editor swap files
|
||||
*.swp
|
||||
*.swo
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app.py storage.py fetcher.py epub_builder.py ./
|
||||
COPY templates/ ./templates/
|
||||
COPY static/ ./static/
|
||||
|
||||
ENV DATA_DIR=/data \
|
||||
ARTICLES_PER_PAGE=8 \
|
||||
PORT=8080 \
|
||||
EPUB_SPLIT_THRESHOLD_BYTES=50000 \
|
||||
EPUB_SPLIT_TARGET_BYTES=25000
|
||||
|
||||
VOLUME /data
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD gunicorn --workers 1 --bind 0.0.0.0:${PORT} --timeout 120 app:app
|
||||
@@ -0,0 +1,64 @@
|
||||
# kob
|
||||
|
||||
Self-hosted article-to-EPUB service. Paste a URL, get an EPUB on your e-reader.
|
||||
|
||||
Designed to be browsed *from* the e-reader: HTML-only, no JavaScript, big fonts, full-width, paginated (no scrolling).
|
||||
|
||||
## How it works
|
||||
|
||||
1. You browse to the site from your e-reader.
|
||||
2. Paste an article URL into the input.
|
||||
3. The server fetches it via [Jina Reader](https://r.jina.ai/), converts to EPUB, and saves it.
|
||||
4. The article appears in the list — tap to download.
|
||||
|
||||
Images are downloaded and embedded into the EPUB at build time, so the file is fully self-contained — no internet needed when reading. If a particular image fails to fetch, it's skipped and the URL is left in the `src` (so it works online but shows broken offline); a message is written to stderr.
|
||||
|
||||
Long articles are split into multiple xhtml files inside the EPUB so weak readers paginate faster (each "chapter" is loaded and laid out independently). Splitting prefers H2/H3 boundaries when present (TOC entries use the heading text) and falls back to "Part 1, Part 2, …" splits at paragraph boundaries when the article has no headings or its sections are larger than the threshold. Tune via `EPUB_SPLIT_THRESHOLD_BYTES` / `EPUB_SPLIT_TARGET_BYTES` (see below).
|
||||
|
||||
## Quick start (Docker)
|
||||
|
||||
```bash
|
||||
docker build -t kob .
|
||||
|
||||
docker run -d \
|
||||
--name kob \
|
||||
-p 8080:8080 \
|
||||
-v $(pwd)/data:/data \
|
||||
-e ARTICLES_PER_PAGE=8 \
|
||||
kob
|
||||
```
|
||||
|
||||
Then point your e-reader's browser at `http://<server-ip>:8080`.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Var | Default | Purpose |
|
||||
|---------------------|-----------|--------------------------------------------------------|
|
||||
| `DATA_DIR` | `/data` | Where articles + `index.json` are stored. |
|
||||
| `ARTICLES_PER_PAGE` | `8` | How many articles per page on the listing. |
|
||||
| `JINA_API_KEY` | *(unset)* | Optional. Sent as `Authorization: Bearer ...` to Jina. |
|
||||
| `PORT` | `8080` | HTTP port inside the container. |
|
||||
| `EPUB_SPLIT_THRESHOLD_BYTES` | `50000` | If an article's source markdown is larger than this, it gets split into multiple xhtml files inside the EPUB. Helps weak readers paginate faster. |
|
||||
| `EPUB_SPLIT_TARGET_BYTES` | `25000` | Target size for each chunk when splitting. |
|
||||
|
||||
## Volumes
|
||||
|
||||
| Path | Purpose |
|
||||
|----------|---------------------------------------------------------------|
|
||||
| `/data` | Article storage. Persist this — losing it loses your library. |
|
||||
|
||||
Layout inside the volume:
|
||||
|
||||
```
|
||||
/data/
|
||||
index.json
|
||||
articles/
|
||||
<id>/
|
||||
article.epub
|
||||
source.md
|
||||
meta.json
|
||||
```
|
||||
|
||||
## Security note
|
||||
|
||||
There is **no authentication**. Designed for a trusted home network. If you expose this beyond your LAN, put it behind a reverse proxy with auth (e.g. Caddy + basic auth, or Tailscale).
|
||||
@@ -0,0 +1,112 @@
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, abort, redirect, render_template, request, send_file, url_for
|
||||
|
||||
import epub_builder
|
||||
import fetcher
|
||||
from storage import Storage
|
||||
|
||||
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
|
||||
ARTICLES_PER_PAGE = int(os.environ.get("ARTICLES_PER_PAGE", "8"))
|
||||
JINA_API_KEY = os.environ.get("JINA_API_KEY") or None
|
||||
|
||||
app = Flask(__name__)
|
||||
storage = Storage(DATA_DIR)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
page = _parse_page(request.args.get("page"))
|
||||
articles = storage.list_articles()
|
||||
total_pages = max(1, (len(articles) + ARTICLES_PER_PAGE - 1) // ARTICLES_PER_PAGE)
|
||||
page = min(page, total_pages)
|
||||
start = (page - 1) * ARTICLES_PER_PAGE
|
||||
page_articles = articles[start:start + ARTICLES_PER_PAGE]
|
||||
return render_template(
|
||||
"index.html",
|
||||
articles=page_articles,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/add", methods=["POST"])
|
||||
def add():
|
||||
url = (request.form.get("url") or "").strip()
|
||||
if not url:
|
||||
return render_template("error.html", message="No URL provided."), 400
|
||||
if not (url.startswith("http://") or url.startswith("https://")):
|
||||
return render_template("error.html", message="URL must start with http:// or https://"), 400
|
||||
try:
|
||||
title, source_md = fetcher.fetch(url, api_key=JINA_API_KEY)
|
||||
except Exception as e:
|
||||
return render_template("error.html", message=f"Failed to fetch: {e}"), 502
|
||||
article_id = _make_id(title)
|
||||
try:
|
||||
epub_bytes = epub_builder.build(
|
||||
title=title,
|
||||
source_md=source_md,
|
||||
identifier=article_id,
|
||||
)
|
||||
except Exception as e:
|
||||
return render_template("error.html", message=f"Failed to build EPUB: {e}"), 500
|
||||
storage.add_article(article_id, title, url, source_md, epub_bytes)
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
@app.route("/download/<article_id>")
|
||||
def download(article_id):
|
||||
article = storage.get_article(article_id)
|
||||
if not article:
|
||||
abort(404)
|
||||
path = storage.epub_path(article_id)
|
||||
if not path.exists():
|
||||
abort(404)
|
||||
safe_name = _slugify(article["title"]) + ".epub"
|
||||
return send_file(
|
||||
path,
|
||||
as_attachment=True,
|
||||
download_name=safe_name,
|
||||
mimetype="application/epub+zip",
|
||||
)
|
||||
|
||||
|
||||
@app.route("/delete/<article_id>", methods=["GET"])
|
||||
def delete_confirm(article_id):
|
||||
article = storage.get_article(article_id)
|
||||
if not article:
|
||||
abort(404)
|
||||
page = _parse_page(request.args.get("page"))
|
||||
return render_template("confirm_delete.html", article=article, page=page)
|
||||
|
||||
|
||||
@app.route("/delete/<article_id>", methods=["POST"])
|
||||
def delete_do(article_id):
|
||||
page = _parse_page(request.form.get("page"))
|
||||
storage.delete_article(article_id)
|
||||
articles = storage.list_articles()
|
||||
total_pages = max(1, (len(articles) + ARTICLES_PER_PAGE - 1) // ARTICLES_PER_PAGE)
|
||||
page = min(page, total_pages)
|
||||
return redirect(url_for("index", page=page))
|
||||
|
||||
|
||||
def _parse_page(raw: str | None) -> int:
|
||||
try:
|
||||
return max(1, int(raw or "1"))
|
||||
except ValueError:
|
||||
return 1
|
||||
|
||||
|
||||
def _slugify(text: str, max_len: int = 60) -> str:
|
||||
text = text.lower().strip()
|
||||
text = re.sub(r"[^a-z0-9]+", "-", text)
|
||||
text = text.strip("-")
|
||||
return text[:max_len] or "article"
|
||||
|
||||
|
||||
def _make_id(title: str) -> str:
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
return f"{ts}-{_slugify(title)}"
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import lxml.etree
|
||||
import lxml.html
|
||||
import markdown as md
|
||||
import requests
|
||||
from ebooklib import epub
|
||||
|
||||
EPUB_CSS = """
|
||||
body { font-family: serif; line-height: 1.6; }
|
||||
h1, h2, h3 { font-family: sans-serif; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
pre { white-space: pre-wrap; word-break: break-word; }
|
||||
"""
|
||||
|
||||
IMG_TAG = re.compile(r"<img\b([^>]*?)/?>", re.IGNORECASE)
|
||||
SRC_ATTR = re.compile(r'\bsrc="([^"]+)"', re.IGNORECASE)
|
||||
IMAGE_TIMEOUT = 20
|
||||
IMAGE_UA = "Mozilla/5.0 (compatible; kob/1.0; +https://github.com/)"
|
||||
|
||||
EXT_BY_MIME = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
|
||||
SPLIT_THRESHOLD = int(os.environ.get("EPUB_SPLIT_THRESHOLD_BYTES", "50000"))
|
||||
SPLIT_TARGET = int(os.environ.get("EPUB_SPLIT_TARGET_BYTES", "25000"))
|
||||
|
||||
HEADING_RE = re.compile(r"^(##|###)\s+(.+)$")
|
||||
|
||||
|
||||
def build(title: str, source_md: str, identifier: str) -> bytes:
|
||||
book = epub.EpubBook()
|
||||
book.set_identifier(identifier)
|
||||
book.set_title(title)
|
||||
book.set_language("en")
|
||||
|
||||
body_md = _strip_jina_header(source_md)
|
||||
chunks = _plan_chunks(body_md, title)
|
||||
|
||||
style = epub.EpubItem(
|
||||
uid="style",
|
||||
file_name="style.css",
|
||||
media_type="text/css",
|
||||
content=EPUB_CSS,
|
||||
)
|
||||
book.add_item(style)
|
||||
|
||||
embedder = _ImageEmbedder(book)
|
||||
chapters: list[epub.EpubHtml] = []
|
||||
for i, chunk in enumerate(chunks, 1):
|
||||
html = md.markdown(chunk["body"], extensions=["extra", "sane_lists"])
|
||||
html = embedder.embed(html)
|
||||
if i == 1:
|
||||
content = f"<h1>{_escape(title)}</h1>\n{html}"
|
||||
else:
|
||||
content = html
|
||||
content = _to_xhtml(content)
|
||||
file_name = "article.xhtml" if len(chunks) == 1 else f"article_{i:02d}.xhtml"
|
||||
ch = epub.EpubHtml(title=chunk["title"], file_name=file_name, lang="en")
|
||||
ch.content = content
|
||||
ch.add_item(style)
|
||||
book.add_item(ch)
|
||||
chapters.append(ch)
|
||||
|
||||
book.toc = tuple(chapters)
|
||||
book.add_item(epub.EpubNcx())
|
||||
book.add_item(epub.EpubNav())
|
||||
book.spine = ["nav", *chapters]
|
||||
|
||||
buf = io.BytesIO()
|
||||
epub.write_epub(buf, book)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _plan_chunks(body_md: str, article_title: str) -> list[dict]:
|
||||
"""Decide how to split. Returns list of {title, body} chunks.
|
||||
|
||||
- Below SPLIT_THRESHOLD: one chunk titled with the article title.
|
||||
- Otherwise prefer cutting at H2/H3 boundaries; each chunk titled with its
|
||||
first heading. Used only if every resulting chunk fits under threshold.
|
||||
- Fallback: paragraph-boundary "Part N" splits.
|
||||
"""
|
||||
if len(body_md) <= SPLIT_THRESHOLD:
|
||||
return [{"title": article_title, "body": body_md}]
|
||||
|
||||
blocks = _tokenize_md_blocks(body_md)
|
||||
heading_chunks = _split_at_headings(blocks, SPLIT_TARGET, article_title)
|
||||
if heading_chunks and all(
|
||||
len(c["body"]) <= SPLIT_THRESHOLD for c in heading_chunks
|
||||
):
|
||||
return heading_chunks
|
||||
return _split_into_parts(blocks, SPLIT_TARGET)
|
||||
|
||||
|
||||
def _tokenize_md_blocks(body: str) -> list[str]:
|
||||
"""Split markdown into top-level blocks (blank-line separated, fence-aware)."""
|
||||
blocks: list[str] = []
|
||||
cur: list[str] = []
|
||||
in_fence = False
|
||||
fence_marker = ""
|
||||
for line in body.splitlines():
|
||||
stripped = line.strip()
|
||||
if not in_fence and (stripped.startswith("```") or stripped.startswith("~~~")):
|
||||
in_fence = True
|
||||
fence_marker = stripped[:3]
|
||||
cur.append(line)
|
||||
continue
|
||||
if in_fence:
|
||||
if stripped.startswith(fence_marker):
|
||||
in_fence = False
|
||||
fence_marker = ""
|
||||
cur.append(line)
|
||||
continue
|
||||
if stripped == "":
|
||||
if cur:
|
||||
blocks.append("\n".join(cur))
|
||||
cur = []
|
||||
else:
|
||||
cur.append(line)
|
||||
if cur:
|
||||
blocks.append("\n".join(cur))
|
||||
return blocks
|
||||
|
||||
|
||||
def _block_heading(block: str) -> str | None:
|
||||
"""If the block starts with an H2/H3 heading, return its plain text."""
|
||||
if not block.strip():
|
||||
return None
|
||||
first_line = block.lstrip().splitlines()[0]
|
||||
m = HEADING_RE.match(first_line)
|
||||
if m:
|
||||
return _strip_inline_md(m.group(2))
|
||||
return None
|
||||
|
||||
|
||||
def _strip_inline_md(text: str) -> str:
|
||||
text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text)
|
||||
text = re.sub(r"[*_`]+", "", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _split_at_headings(
|
||||
blocks: list[str], target: int, article_title: str
|
||||
) -> list[dict]:
|
||||
"""Walk blocks, cutting before an H2/H3 once accumulated size exceeds target.
|
||||
|
||||
Returns [] if there are no H2/H3 headings at all.
|
||||
"""
|
||||
if not any(_block_heading(b) for b in blocks):
|
||||
return []
|
||||
|
||||
chunks: list[dict] = []
|
||||
cur_blocks: list[str] = []
|
||||
cur_size = 0
|
||||
cur_title: str | None = None
|
||||
|
||||
for b in blocks:
|
||||
h = _block_heading(b)
|
||||
if cur_size >= target and h and cur_blocks:
|
||||
chunks.append({
|
||||
"title": cur_title or article_title,
|
||||
"body": "\n\n".join(cur_blocks),
|
||||
})
|
||||
cur_blocks = []
|
||||
cur_size = 0
|
||||
cur_title = None
|
||||
if cur_title is None and h:
|
||||
cur_title = h
|
||||
cur_blocks.append(b)
|
||||
cur_size += len(b) + 2
|
||||
|
||||
if cur_blocks:
|
||||
chunks.append({
|
||||
"title": cur_title or article_title,
|
||||
"body": "\n\n".join(cur_blocks),
|
||||
})
|
||||
return chunks
|
||||
|
||||
|
||||
def _split_into_parts(blocks: list[str], target: int) -> list[dict]:
|
||||
parts: list[list[str]] = []
|
||||
cur: list[str] = []
|
||||
cur_size = 0
|
||||
for b in blocks:
|
||||
cur.append(b)
|
||||
cur_size += len(b) + 2
|
||||
if cur_size >= target:
|
||||
parts.append(cur)
|
||||
cur = []
|
||||
cur_size = 0
|
||||
if cur:
|
||||
parts.append(cur)
|
||||
if not parts:
|
||||
parts = [[""]]
|
||||
return [
|
||||
{"title": f"Part {i}", "body": "\n\n".join(p)}
|
||||
for i, p in enumerate(parts, 1)
|
||||
]
|
||||
|
||||
|
||||
class _ImageEmbedder:
|
||||
def __init__(self, book: epub.EpubBook):
|
||||
self.book = book
|
||||
self.cache: dict[str, str | None] = {}
|
||||
|
||||
def embed(self, html: str) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
attrs = match.group(1)
|
||||
src_match = SRC_ATTR.search(attrs)
|
||||
if not src_match:
|
||||
return match.group(0)
|
||||
url = src_match.group(1)
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return match.group(0)
|
||||
if url not in self.cache:
|
||||
self.cache[url] = _download_and_register(url, self.book)
|
||||
local = self.cache[url]
|
||||
if local is None:
|
||||
return match.group(0)
|
||||
new_attrs = attrs[: src_match.start(1)] + local + attrs[src_match.end(1):]
|
||||
return f"<img{new_attrs}/>"
|
||||
return IMG_TAG.sub(replace, html)
|
||||
|
||||
|
||||
def _download_and_register(url: str, book: epub.EpubBook) -> str | None:
|
||||
try:
|
||||
resp = requests.get(
|
||||
url,
|
||||
timeout=IMAGE_TIMEOUT,
|
||||
headers={"User-Agent": IMAGE_UA},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except Exception as e:
|
||||
print(f"[kob] image fetch failed: {url}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
mime = resp.headers.get("Content-Type", "").split(";")[0].strip().lower()
|
||||
if not mime.startswith("image/"):
|
||||
print(f"[kob] not an image ({mime}): {url}", file=sys.stderr)
|
||||
return None
|
||||
ext = EXT_BY_MIME.get(mime, ".bin")
|
||||
|
||||
h = hashlib.sha1(url.encode("utf-8")).hexdigest()[:12]
|
||||
file_name = f"images/img_{h}{ext}"
|
||||
item = epub.EpubItem(
|
||||
uid=f"img_{h}",
|
||||
file_name=file_name,
|
||||
media_type=mime,
|
||||
content=resp.content,
|
||||
)
|
||||
book.add_item(item)
|
||||
return file_name
|
||||
|
||||
|
||||
def _to_xhtml(html_fragment: str) -> str:
|
||||
"""Parse an HTML fragment forgivingly and re-serialize as valid XHTML.
|
||||
|
||||
Fixes void-element self-closing, bare ampersands, unclosed tags, and other
|
||||
quirks that strict XHTML parsers (libxml2) reject.
|
||||
"""
|
||||
if not html_fragment.strip():
|
||||
return html_fragment
|
||||
wrapper = lxml.html.fragment_fromstring(html_fragment, create_parent="div")
|
||||
serialized = lxml.etree.tostring(
|
||||
wrapper,
|
||||
method="xml",
|
||||
encoding="unicode",
|
||||
with_tail=False,
|
||||
)
|
||||
if serialized in ("<div/>", "<div></div>"):
|
||||
return ""
|
||||
if serialized.startswith("<div>") and serialized.endswith("</div>"):
|
||||
return serialized[len("<div>"):-len("</div>")]
|
||||
return serialized
|
||||
|
||||
|
||||
def _strip_jina_header(body: str) -> str:
|
||||
marker = "Markdown Content:"
|
||||
idx = body.find(marker)
|
||||
if idx >= 0:
|
||||
return body[idx + len(marker):].lstrip("\n")
|
||||
return body
|
||||
|
||||
|
||||
def _escape(s: str) -> str:
|
||||
return (
|
||||
s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
JINA_BASE = "https://r.jina.ai/"
|
||||
|
||||
|
||||
def fetch(url: str, api_key: str | None = None, timeout: int = 60) -> tuple[str, str]:
|
||||
"""Fetch an article via Jina Reader. Returns (title, markdown_body)."""
|
||||
headers = {"Accept": "text/markdown"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
resp = requests.get(JINA_BASE + url, headers=headers, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
body = resp.text
|
||||
title = _extract_title(body) or _hostname_fallback(url)
|
||||
return title, body
|
||||
|
||||
|
||||
def _extract_title(body: str) -> str | None:
|
||||
m = re.match(r"^Title:\s*(.+)$", body, re.MULTILINE)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
return None
|
||||
|
||||
|
||||
def _hostname_fallback(url: str) -> str:
|
||||
return urlparse(url).hostname or "Untitled"
|
||||
@@ -0,0 +1,154 @@
|
||||
# kob — article-to-EPUB for e-reader
|
||||
|
||||
A tiny self-hosted web app: paste an article URL → it's fetched via Jina Reader, packaged as EPUB, and listed for download. Designed to be browsed from an e-reader: HTML-only, no JS, big fonts, full-width, paginated (no scrolling).
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
- **Stack**: Python + Flask, `ebooklib` for EPUB, `requests` for HTTP, `markdown` for md→html.
|
||||
- **Hosting**: home server, Docker. README explains env vars and volumes.
|
||||
- **Fetcher**: `https://r.jina.ai/<url>`. Optional `JINA_API_KEY` env var, sent as `Authorization: Bearer ...` if set.
|
||||
- **Articles per page**: configurable via `ARTICLES_PER_PAGE`, default 8.
|
||||
- **Delete**: button per article → confirmation page (no JS) → POST to actually delete.
|
||||
- **Storage**: filesystem + `index.json`. Keep source URL and original markdown alongside the EPUB.
|
||||
- **Title**: taken from Jina response, used as EPUB title and slug for filename.
|
||||
- **No auth**: single user on home network. Document in README that this should not be exposed publicly without putting a reverse proxy + auth in front.
|
||||
|
||||
## Storage layout
|
||||
|
||||
Mount one volume at `/data`:
|
||||
|
||||
```
|
||||
/data/
|
||||
index.json # ordered list, newest first
|
||||
articles/
|
||||
<id>/ # id = timestamp + slug, e.g. 20260428-153012-how-to-x
|
||||
article.epub
|
||||
source.md # original markdown from Jina
|
||||
meta.json # { id, url, title, fetched_at }
|
||||
```
|
||||
|
||||
`index.json` is the source of truth for ordering and listing. Format:
|
||||
|
||||
```json
|
||||
{
|
||||
"articles": [
|
||||
{ "id": "20260428-153012-how-to-x", "title": "How to X", "url": "https://...", "fetched_at": "2026-04-28T15:30:12Z" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
On delete: remove from `index.json` first, then `rm -rf` the article folder. (Order matters: if rm fails, the index is still consistent — orphan folders are harmless and can be GC'd later if ever needed.)
|
||||
|
||||
## Routes
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|----------------------------|----------------------------------------------------|
|
||||
| GET | `/` | Main page: URL input + paginated list (`?page=N`). |
|
||||
| POST | `/add` | Fetch URL → build EPUB → save → redirect to `/`. |
|
||||
| GET | `/download/<id>` | Stream the `.epub` file. |
|
||||
| GET | `/delete/<id>?page=N` | Confirmation page ("Delete '<title>'? Yes / No"). |
|
||||
| POST | `/delete/<id>` (form: `page=N`) | Actually delete; redirect to `/?page=N`. |
|
||||
|
||||
Pagination uses big "← Prev" / "Next →" buttons at the bottom. No page-number list (keeps things simple and reader-friendly).
|
||||
|
||||
**Page preservation across delete**: each "Delete" link on `/` carries the current `?page=N`. The confirmation page passes it through as a hidden form field; the POST handler redirects back to `/?page=N` so you stay where you were. Edge case: if the page you were on no longer exists after the delete (e.g. you deleted the only article on the last page), clamp `page` to the new max page before redirecting.
|
||||
|
||||
## Page layouts (HTML only)
|
||||
|
||||
All pages share the same minimal CSS:
|
||||
|
||||
- `body { max-width: 100%; font-size: 1.6rem; line-height: 1.5; padding: 1rem; }`
|
||||
- Buttons / links styled as large blocks (~3rem tall, full-width on small screens).
|
||||
- No images, no JS, no external assets.
|
||||
- `<meta name="viewport" content="width=device-width, initial-scale=1">`.
|
||||
|
||||
**`/` (main)** — fits one screen, no scrolling:
|
||||
1. URL input + "Add" button (POST to `/add`).
|
||||
2. List of up to N=8 article rows. Each row: title + "Download" link + "Delete" link.
|
||||
3. Prev / Next pagination buttons (disabled state when at edge).
|
||||
|
||||
**`/delete/<id>`** — confirmation:
|
||||
- "Delete '<title>'?"
|
||||
- Two big buttons: "Yes, delete" (POST) and "Cancel" (link back to `/`).
|
||||
|
||||
**`/add` result**: on success, redirect to `/`. On error, render a simple page with the error message and a "Back" link.
|
||||
|
||||
## EPUB generation
|
||||
|
||||
1. POST `/add` receives URL.
|
||||
2. GET `https://r.jina.ai/<url>` (with `Authorization` header if `JINA_API_KEY` set). Jina returns markdown with a leading `Title: ...` block — parse it for the title; fall back to URL host if missing.
|
||||
3. Save markdown to `source.md`.
|
||||
4. Convert markdown → HTML with the `markdown` library.
|
||||
5. Build EPUB with `ebooklib`:
|
||||
- Single chapter, the converted HTML.
|
||||
- Title = parsed title, language = `en`, identifier = id.
|
||||
- Minimal embedded CSS for big-font reading (independent of the web UI CSS).
|
||||
6. Write `article.epub`, `meta.json`. Prepend entry to `index.json`.
|
||||
|
||||
If any step fails, clean up partial files; do not modify `index.json`.
|
||||
|
||||
## Configuration (env vars)
|
||||
|
||||
| Var | Default | Purpose |
|
||||
|----------------------|-------------|------------------------------------------|
|
||||
| `DATA_DIR` | `/data` | Where articles + index.json live. |
|
||||
| `ARTICLES_PER_PAGE` | `8` | Pagination size on `/`. |
|
||||
| `JINA_API_KEY` | *(unset)* | Optional; sent as bearer token to Jina. |
|
||||
| `PORT` | `8080` | HTTP port inside the container. |
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
kob/
|
||||
app.py # Flask app: routes, glue
|
||||
storage.py # index.json + folder ops (add, list, delete)
|
||||
fetcher.py # Jina call + title parsing
|
||||
epub_builder.py # markdown → EPUB
|
||||
templates/
|
||||
base.html
|
||||
index.html
|
||||
confirm_delete.html
|
||||
error.html
|
||||
static/
|
||||
style.css
|
||||
requirements.txt
|
||||
Dockerfile
|
||||
README.md
|
||||
plan.md # this file
|
||||
```
|
||||
|
||||
## Dockerfile
|
||||
|
||||
- Base: `python:3.12-slim`.
|
||||
- Install deps from `requirements.txt`.
|
||||
- Copy app, expose `$PORT`, run with `gunicorn` (single worker — single user, no contention).
|
||||
- Declare `VOLUME /data`.
|
||||
|
||||
## README outline
|
||||
|
||||
1. What it does (1 paragraph).
|
||||
2. Quick start: `docker build`, `docker run` example with `-v ./data:/data -p 8080:8080 -e JINA_API_KEY=... -e ARTICLES_PER_PAGE=8`.
|
||||
3. Env vars table (same as above).
|
||||
4. Volumes table: `/data` → article storage (persist this).
|
||||
5. Security note: no auth — put behind a reverse proxy / Tailscale if not on a trusted LAN.
|
||||
6. Where to point your e-reader's browser.
|
||||
|
||||
## Build order
|
||||
|
||||
1. Skeleton: Flask app, `requirements.txt`, render an empty `/` template.
|
||||
2. Storage module: read/write `index.json`, list/add/delete article folders.
|
||||
3. Fetcher: Jina call + title extraction; manual test with one URL.
|
||||
4. EPUB builder: markdown → epub; verify the file opens on the e-reader.
|
||||
5. Wire `/add` end-to-end.
|
||||
6. Pagination + listing on `/`.
|
||||
7. Delete flow with confirmation page.
|
||||
8. CSS pass for big-font / full-width / no-scroll layout.
|
||||
9. Dockerfile + README.
|
||||
10. Smoke test in container with a real volume mount.
|
||||
|
||||
## Open / deferred
|
||||
|
||||
- Auto-prune of old articles: not in v1; user can delete manually.
|
||||
- Cover images / multi-chapter EPUBs: not in v1.
|
||||
- Search: explicitly out of scope.
|
||||
- Auth: out of scope; documented in README.
|
||||
@@ -0,0 +1,5 @@
|
||||
Flask==3.0.3
|
||||
gunicorn==23.0.0
|
||||
requests==2.32.3
|
||||
ebooklib==0.18
|
||||
markdown==3.7
|
||||
@@ -0,0 +1,110 @@
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: serif;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.add-form input[type="url"] {
|
||||
flex: 1;
|
||||
font-size: 1.6rem;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #000;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.add-form button,
|
||||
.page-btn,
|
||||
button {
|
||||
font-size: 1.6rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border: 2px solid #000;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page-btn.disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.articles {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
|
||||
.articles li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #999;
|
||||
}
|
||||
|
||||
.articles .article-title {
|
||||
flex: 1;
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.articles .delete {
|
||||
font-size: 1.4rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 2px solid #000;
|
||||
text-decoration: none;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.confirm {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirm .article-title {
|
||||
font-weight: bold;
|
||||
margin: 1rem 0 2rem 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.confirm form {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
border-color: #c00;
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
.error p {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Storage:
|
||||
def __init__(self, data_dir: Path):
|
||||
self.data_dir = Path(data_dir)
|
||||
self.articles_dir = self.data_dir / "articles"
|
||||
self.index_file = self.data_dir / "index.json"
|
||||
self.articles_dir.mkdir(parents=True, exist_ok=True)
|
||||
if not self.index_file.exists():
|
||||
self._write_index({"articles": []})
|
||||
|
||||
def _read_index(self) -> dict:
|
||||
with open(self.index_file) as f:
|
||||
return json.load(f)
|
||||
|
||||
def _write_index(self, data: dict) -> None:
|
||||
tmp = self.index_file.with_suffix(".json.tmp")
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
tmp.replace(self.index_file)
|
||||
|
||||
def list_articles(self) -> list[dict]:
|
||||
return self._read_index()["articles"]
|
||||
|
||||
def get_article(self, article_id: str) -> Optional[dict]:
|
||||
for a in self.list_articles():
|
||||
if a["id"] == article_id:
|
||||
return a
|
||||
return None
|
||||
|
||||
def add_article(
|
||||
self,
|
||||
article_id: str,
|
||||
title: str,
|
||||
url: str,
|
||||
markdown: str,
|
||||
epub_bytes: bytes,
|
||||
) -> None:
|
||||
folder = self.articles_dir / article_id
|
||||
folder.mkdir(parents=True, exist_ok=False)
|
||||
(folder / "source.md").write_text(markdown, encoding="utf-8")
|
||||
(folder / "article.epub").write_bytes(epub_bytes)
|
||||
meta = {
|
||||
"id": article_id,
|
||||
"url": url,
|
||||
"title": title,
|
||||
"fetched_at": _now(),
|
||||
}
|
||||
(folder / "meta.json").write_text(json.dumps(meta, indent=2))
|
||||
|
||||
index = self._read_index()
|
||||
index["articles"].insert(0, meta)
|
||||
self._write_index(index)
|
||||
|
||||
def delete_article(self, article_id: str) -> bool:
|
||||
index = self._read_index()
|
||||
before = len(index["articles"])
|
||||
index["articles"] = [a for a in index["articles"] if a["id"] != article_id]
|
||||
if len(index["articles"]) == before:
|
||||
return False
|
||||
self._write_index(index)
|
||||
folder = self.articles_dir / article_id
|
||||
if folder.exists():
|
||||
shutil.rmtree(folder)
|
||||
return True
|
||||
|
||||
def epub_path(self, article_id: str) -> Path:
|
||||
return self.articles_dir / article_id / "article.epub"
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}kob{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="confirm">
|
||||
<p>Delete this article?</p>
|
||||
<p class="article-title">{{ article.title }}</p>
|
||||
<form method="post" action="{{ url_for('delete_do', article_id=article.id) }}">
|
||||
<input type="hidden" name="page" value="{{ page }}">
|
||||
<button type="submit" class="danger">Yes, delete</button>
|
||||
</form>
|
||||
<a class="page-btn" href="{{ url_for('index', page=page) }}">Cancel</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="error">
|
||||
<p>{{ message }}</p>
|
||||
<a class="page-btn" href="{{ url_for('index') }}">Back</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<form method="post" action="{{ url_for('add') }}" class="add-form">
|
||||
<input type="url" name="url" placeholder="https://..." required autofocus>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
|
||||
<ul class="articles">
|
||||
{% for a in articles %}
|
||||
<li>
|
||||
<a class="article-title" href="{{ url_for('download', article_id=a.id) }}">{{ a.title }}</a>
|
||||
<a class="delete" href="{{ url_for('delete_confirm', article_id=a.id, page=page) }}">Delete</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="empty">No articles yet.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<nav class="pagination">
|
||||
{% if page > 1 %}
|
||||
<a class="page-btn" href="{{ url_for('index', page=page-1) }}">← Prev</a>
|
||||
{% else %}
|
||||
<span class="page-btn disabled">← Prev</span>
|
||||
{% endif %}
|
||||
<span class="page-info">{{ page }} / {{ total_pages }}</span>
|
||||
{% if page < total_pages %}
|
||||
<a class="page-btn" href="{{ url_for('index', page=page+1) }}">Next →</a>
|
||||
{% else %}
|
||||
<span class="page-btn disabled">Next →</span>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user