diff --git a/kevin/.gitignore b/kevin/.gitignore
new file mode 100644
index 0000000..806fbfb
--- /dev/null
+++ b/kevin/.gitignore
@@ -0,0 +1,3 @@
+.direnv
+.envrc
+.history
diff --git a/kevin/epub.css b/kevin/epub.css
new file mode 100644
index 0000000..cb7e08e
--- /dev/null
+++ b/kevin/epub.css
@@ -0,0 +1,3 @@
+body{margin:40px auto;max-width:650px;line-height:1.6;font-size:18px;color:#444;padding:0}
+a{color:inherit;text-decoration:none}
+a:hover{text-decoration:underline}
diff --git a/kevin/keinverlag b/kevin/keinverlag
new file mode 100755
index 0000000..cde6f39
--- /dev/null
+++ b/kevin/keinverlag
@@ -0,0 +1,61 @@
+#!/bin/sh
+
+alias to_utf8='iconv -f latin1 -t utf8'
+alias curl_GET='curl -X GET -s -G'
+
+BASE_URL=https://www.keinverlag.de
+
+kv_author_id () {
+ if [ $# -ne 1 ]; then
+ echo Please call kv_author_id with an author name. >/dev/stderr
+ exit 1
+ fi
+
+ author_name=$1
+
+ curl_GET "$BASE_URL/$author_name.kv" \
+ | to_utf8 \
+ | sed -n 's/.*autor=\([0-9]\+\).*/\1/p' \
+ | head -1
+}
+
+kv_text () {
+ if [ $# -ne 1 ]; then
+ echo Please call kv_text with a text ID. >/dev/stderr
+ exit 1
+ fi
+
+ text_id=$1
+
+ curl_GET "$BASE_URL/$text_id.text" \
+ | to_utf8 \
+ | sed -n '/
/,//p' \
+ | sed 's/.\+<\/h3>//g' \
+ | pandoc -f html -t plain
+}
+
+kv_author_texts () {
+ if [ $# -ne 1 ]; then
+ echo Please call kv_author_texts with an author ID. >/dev/stderr
+ exit 1
+ fi
+
+ author_id=$1
+
+ curl_GET "$BASE_URL/autorentexte.php" -d sortby=tnr -d start=0 -d limit=10000 -d autor="$author_id" \
+ | to_utf8 \
+ | sed -n 's/.*
.*/\1/p'
+}
+
+case $1 in
+ text)
+ shift
+ kv_text "$@";;
+ author)
+ shift
+ for text_id in $(kv_author_texts "$(kv_author_id "$@")" | uniq); do
+ kv_text "$text_id"
+ done ;;
+ *)
+ echo >/dev/stderr "Usage: $0 text|author ID"
+esac
diff --git a/kevin/kevin.py b/kevin/kevin.py
new file mode 100755
index 0000000..9b4cbe1
--- /dev/null
+++ b/kevin/kevin.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+from argparse import ArgumentParser
+from bs4 import BeautifulSoup
+from typing import List
+import re
+import requests
+
+
+def soup_from(response):
+ return BeautifulSoup(response.text, "lxml")
+
+
+class Author:
+ def __init__(self, author_id: int) -> None:
+ response = requests.get(
+ "https://www.keinverlag.de/autorentexte.php",
+ params={"start": 0, "limit": 10000, "sortby": "tnr", "author": author_id},
+ )
+ soup = soup_from(response)
+ self.texts = [] # type: List[Text]
+ for text in soup.select('ul.textliste > li > a[href$=".text"]'):
+ # strip off the last five characters (".text")
+ text_id = int(text["href"][:-5])
+ try:
+ self.texts.append(Text(text_id))
+ except ValueError:
+ continue
+
+ def markdown(self, *, with_type: bool = False) -> str:
+ name = self.texts[0].author
+
+ def __gen():
+ yield "% {}".format(name)
+ for text in self.texts:
+ yield "\n\n* * *\n\n"
+ yield text.markdown(with_author=False, with_type=with_type)
+
+ return "\n".join(__gen())
+
+
+class Text:
+ def __init__(self, text_id: int) -> None:
+ normalization = {
+ 132: '"',
+ 147: '"',
+ 0x96: "--",
+ 0x91: "'",
+ 0x92: "'",
+ 0x97: "---",
+ }
+ text_url = "https://www.keinverlag.de/{}.text".format(text_id)
+ soup = soup_from(text_url)
+ try:
+ self.title = soup.select("h1 > span")[0].text.translate(normalization)
+ self.content = BeautifulSoup(
+ re.sub(
+ r'(([\n\r]|.)*?)',
+ r"_\1_",
+ str(soup.select(".fliesstext > span")[0]),
+ ),
+ "lxml",
+ ).text.translate(normalization)
+ self.author = soup.select("h3 > a")[2].text
+ self.type = soup.select("h1 ~ h3")[0].text
+ except IndexError:
+ raise ValueError("Text {} not available.".format(text_id))
+
+ def markdown(self, *, with_author: bool = True, with_type: bool = False) -> str:
+ return "#### {maybe_author}{title}{maybe_type}\n\n{content}".format(
+ title=self.title,
+ maybe_author=self.author + ": " if with_author else "",
+ maybe_type=" (" + self.type + ")" if with_type else "",
+ content="\n".join(
+ line + "\\" if line else "" for line in self.content.splitlines()
+ ),
+ )
+
+
+if __name__ == "__main__":
+ parser = ArgumentParser()
+ parser.add_argument("--type", help="Include text type", action="store_true")
+ subparsers = parser.add_subparsers()
+
+ handle_text = subparsers.add_parser("text", help="Handle one text")
+ handle_text.add_argument("tid", help="KeinVerlag text id", type=int)
+ handle_text.set_defaults(
+ func=lambda a: print(Text(a.tid).markdown(with_type=a.type))
+ )
+
+ handle_author = subparsers.add_parser(
+ "author", help="Handle all texts by an author"
+ )
+ handle_author.add_argument("aid", help="KeinVerlag author id", type=str)
+ handle_author.set_defaults(
+ func=lambda a: print(Author(a.aid).markdown(with_type=a.type))
+ )
+
+ args = parser.parse_args()
+ args.func(args)
diff --git a/kevin/kevin.sh b/kevin/kevin.sh
new file mode 100755
index 0000000..474f091
--- /dev/null
+++ b/kevin/kevin.sh
@@ -0,0 +1,2 @@
+#!/bin/sh
+python3 kevin.py author "$1" | pandoc -f markdown+smart --table-of-contents --toc-depth=6 --standalone --css=epub.css -o "$2"
diff --git a/kevin/shell.nix b/kevin/shell.nix
new file mode 100644
index 0000000..59c35ed
--- /dev/null
+++ b/kevin/shell.nix
@@ -0,0 +1,10 @@
+{ pkgs ? import {} }:
+pkgs.mkShell {
+ buildInputs = with pkgs; [
+ pandoc
+ python3Packages.beautifulsoup4
+ python3Packages.requests
+ python3Packages.lxml
+ ];
+ shellHook = "export HISTFILE=${toString ./.history}";
+}