diff --git a/scripts/export-story.ts b/scripts/export-story.ts
index d410e0e..8108a0a 100644
--- a/scripts/export-story.ts
+++ b/scripts/export-story.ts
@@ -1,5 +1,5 @@
 import { type ChildProcess, exec, execSync } from "node:child_process";
-import { readdir, mkdir, mkdtemp, writeFile, readFile } from "node:fs/promises";
+import { readdir, mkdir, mkdtemp, writeFile, readFile, copyFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join as pathJoin, normalize } from "node:path";
 import { setTimeout } from "node:timers/promises";
@@ -26,7 +26,34 @@ function getRTFStyles(rtfSource: string) {
 
 const fetchRetry = fetchRetryWrapper(global.fetch);
 
+interface AstroApiResponse {
+  story: string;
+  description: Record<string, string>;
+  thumbnail: string | null;
+}
+
+const isLibreOfficeRunning = async () =>
+  new Promise<boolean>((res, rej) => {
+    exec("ps -ax", (err, stdout) => {
+      if (err) {
+        rej(err);
+        return;
+      }
+      res(
+        stdout
+          .toLowerCase()
+          .split("\n")
+          .some((line) => line.includes("libreoffice") && line.includes("--writer")),
+      );
+    });
+  });
+
 async function exportStory(slug: string, options: { outputDir: string }) {
+  /* Check that LibreOffice is not running */
+  if (await isLibreOfficeRunning()) {
+    console.error("LibreOffice cannot be open while this command is running!");
+    process.exit(1);
+  }
   /* Check that outputDir is valid */
   const outputDir = normalize(options.outputDir);
   let files: string[];
@@ -41,17 +68,31 @@ async function exportStory(slug: string, options: { outputDir: string }) {
     process.exit(1);
   }
   /* Check if Astro development server needs to be spawned */
-  const healthcheckURL = `http://localhost:4321/healthcheck`;
+  const healthcheckURL = `http://localhost:4321/api/healthcheck`;
   let devServerProcess: ChildProcess | null = null;
   try {
-    await fetchRetry(healthcheckURL, { retries: 3, retryDelay: 1000 });
+    const response = await fetchRetry(healthcheckURL, { retries: 3, retryDelay: 1000 });
+    if (!response.ok) {
+      throw new Error();
+    }
+    const healthcheck = await response.json();
+    if (!healthcheck.isAlive) {
+      throw new Error();
+    }
   } catch {
     /* Spawn Astro dev server */
     console.log("Starting Astro development server...");
     devServerProcess = exec("./node_modules/.bin/astro dev");
     await setTimeout(2000);
     try {
-      await fetchRetry(healthcheckURL, { retries: 5, retryDelay: 2000 });
+      const response = await fetchRetry(healthcheckURL, { retries: 5, retryDelay: 2000 });
+      if (!response.ok) {
+        throw new Error();
+      }
+      const healthcheck = await response.json();
+      if (!healthcheck.isAlive) {
+        throw new Error();
+      }
     } catch {
       console.error("Astro dev server didn't respond in time!");
       devServerProcess && devServerProcess.kill("SIGINT");
@@ -64,35 +105,35 @@ async function exportStory(slug: string, options: { outputDir: string }) {
   try {
     console.log("Getting data from Astro...");
 
-    const exportStoryURL = `http://localhost:4321/stories/export/story/${slug}`;
-    const exportThumbnailURL = `http://localhost:4321/stories/export/thumbnail/${slug}`;
-    const exportDescriptionURLs = (website: string) =>
-      `http://localhost:4321/stories/export/description/${website}/${slug}`;
-
+    const response = await fetch(`http://localhost:4321/api/export-story/${slug}`);
+    if (!response.ok) {
+      throw new Error(`Failed to reach API (status code ${response.status})`);
+    }
+    const data: AstroApiResponse = await response.json();
     await Promise.all(
-      ["eka", "furaffinity", "inkbunny", "sofurry", "weasyl"].map(async (website) => {
-        const description = await fetch(exportDescriptionURLs(website));
-        if (!description.ok) {
-          throw new Error(`Failed to get description for "${website}"`);
-        }
-        const descriptionExt = description.headers.get("Content-Type")?.startsWith("text/markdown") ? "md" : "txt";
+      Object.entries(data.description).map(async ([website, description]) => {
         return await writeFile(
-          pathJoin(outputDir, `description_${website}.${descriptionExt}`),
-          await description.text(),
+          pathJoin(outputDir, `description_${website}.${website === "weasyl" ? "md" : "txt"}`),
+          description,
         );
       }),
     );
-    const thumbnail = await fetch(exportThumbnailURL);
-    if (!thumbnail.ok) {
-      throw new Error("Failed to get thumbnail");
+    if (data.thumbnail) {
+      if (data.thumbnail.startsWith("/@fs/")) {
+        const thumbnailPath = data.thumbnail
+          .replace(/^\/@fs/, "")
+          .replace(/\?(&?[a-z][a-zA-Z0-9_-]+=[a-zA-Z0-9_-]*)*$/, "");
+        await copyFile(thumbnailPath, pathJoin(outputDir, `thumbnail${thumbnailPath.match(/\.[^.]+$/)![0]}`));
+      } else {
+        const thumbnail = await fetch(data.thumbnail);
+        if (!thumbnail.ok) {
+          throw new Error("Failed to get thumbnail");
+        }
+        const thumbnailExt = thumbnail.headers.get("Content-Type")?.startsWith("image/png") ? "png" : "jpg";
+        await writeFile(pathJoin(outputDir, `thumbnail.${thumbnailExt}`), Buffer.from(await thumbnail.arrayBuffer()));
+      }
     }
-    const thumbnailExt = thumbnail.headers.get("Content-Type")?.startsWith("image/png") ? "png" : "jpg";
-    writeFile(pathJoin(outputDir, `thumbnail.${thumbnailExt}`), Buffer.from(await thumbnail.arrayBuffer()));
-    const story = await fetch(exportStoryURL);
-    if (!story.ok) {
-      throw new Error("Failed to get story");
-    }
-    storyText = await story.text();
+    storyText = data.story;
     writeFile(pathJoin(outputDir, `${slug}.txt`), storyText);
   } finally {
     if (devServerProcess) {
diff --git a/src/pages/stories/export/description/[website]/[...slug].ts b/src/pages/api/export-story/[...slug].ts
similarity index 61%
rename from src/pages/stories/export/description/[website]/[...slug].ts
rename to src/pages/api/export-story/[...slug].ts
index f60d5a7..5bea164 100644
--- a/src/pages/stories/export/description/[website]/[...slug].ts
+++ b/src/pages/api/export-story/[...slug].ts
@@ -2,11 +2,19 @@ import { type APIRoute, type GetStaticPaths } from "astro";
 import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content";
 import { marked, type RendererApi } from "marked";
 import { decode as tinyDecode } from "tiny-decode";
-import { type Website } from "../../../../../content/config";
+import { type Lang, type Website } from "../../../content/config";
 
-const WEBSITE_LIST = ["eka", "furaffinity", "inkbunny", "sofurry", "weasyl"] as const satisfies Website[];
+type DescriptionFormat = "bbcode" | "markdown";
 
-type ExportWebsite = typeof WEBSITE_LIST extends ReadonlyArray<infer K> ? K : never;
+const WEBSITE_LIST = [
+  ["eka", "bbcode"],
+  ["furaffinity", "bbcode"],
+  ["inkbunny", "bbcode"],
+  ["sofurry", "bbcode"],
+  ["weasyl", "markdown"],
+] as const satisfies [Website, DescriptionFormat][];
+
+type ExportWebsite = typeof WEBSITE_LIST extends ReadonlyArray<[infer K, DescriptionFormat]> ? K : never;
 
 const bbcodeRenderer: RendererApi = {
   strong: (text) => `[b]${text}[/b]`,
@@ -186,12 +194,18 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsite):
   throw new Error(`No "${website}" link for user "${user.id}" (consider setting preferredLink)`);
 }
 
+function getNameForUser(user: CollectionEntry<"users">, anonymousUser: CollectionEntry<"users">, lang: Lang): string {
+  if (user.data.isAnonymous) {
+    return anonymousUser.data.nameLang[lang] || anonymousUser.data.name;
+  }
+  return user.data.nameLang[lang] || user.data.name;
+}
+
 type Props = {
   story: CollectionEntry<"stories">;
 };
 
 type Params = {
-  website: ExportWebsite;
   slug: CollectionEntry<"stories">["slug"];
 };
 
@@ -199,19 +213,14 @@ export const getStaticPaths: GetStaticPaths = async () => {
   if (import.meta.env.PROD) {
     return [];
   }
-  return (await getCollection("stories"))
-    .map((story) =>
-      WEBSITE_LIST.map((website) => ({
-        params: { website, slug: story.slug } satisfies Params,
-        props: { story } satisfies Props,
-      })),
-    )
-    .flat();
+  return (await getCollection("stories")).map((story) => ({
+    params: { slug: story.slug } satisfies Params,
+    props: { story } satisfies Props,
+  }));
 };
 
-export const GET: APIRoute<Props, Params> = async ({ props: { story }, params: { website }, site }) => {
-  const u = (user: CollectionEntry<"users">) => getLinkForUser(user, website);
-
+export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) => {
+  const { lang } = story.data;
   if (
     story.data.copyrightedCharacters &&
     "" in story.data.copyrightedCharacters &&
@@ -236,45 +245,103 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, params: {
       >,
     );
 
-  let storyDescription = (
-    [
-      story.data.description,
-      `*Word count: ${story.data.wordCount}. ${story.data.contentWarning.trim()}*`,
-      "Writing: " + (await getEntries([story.data.authors].flat())).map((author) => u(author)).join(" , "),
-      story.data.requester && "Request for: " + u(await getEntry(story.data.requester)),
-      story.data.commissioner && "Commissioned by: " + u(await getEntry(story.data.commissioner)),
-      ...(await Promise.all(
-        (Object.keys(charactersPerUser) as CollectionEntry<"users">["id"][]).map(async (id) => {
-          const user = u(await getEntry("users", id));
-          const characterList = charactersPerUser[id];
-          if (characterList[0] == "") {
-            return `All characters are © ${user}`;
-          } else if (characterList.length > 2) {
-            return `${characterList.slice(0, characterList.length - 1).join(", ")}, and ${characterList[characterList.length - 1]} are © ${user}`;
-          } else if (characterList.length > 1) {
-            return `${characterList[0]} and ${characterList[1]} are © ${user}`;
-          }
-          return `${characterList[0]} is © ${user}`;
-        }),
-      )),
-    ].filter((data) => data) as string[]
-  )
-    .join("\n\n")
-    .replaceAll(
-      /\[([^\]]+)\]\((\.[^\)]+)\)/g,
-      (_, group1, group2) => `[${group1}](${new URL(group2, new URL(`/stories/${story.slug}`, site)).toString()})`,
-    );
-  const headers = { "Content-Type": "text/markdown; charset=utf-8" };
-  const bbcodeExports: ReadonlyArray<ExportWebsite> = ["eka", "furaffinity", "inkbunny", "sofurry"] as const;
-  const markdownExport: ReadonlyArray<ExportWebsite> = ["weasyl"] as const;
-  // BBCode exports
-  if (bbcodeExports.includes(website)) {
-    storyDescription = tinyDecode(await marked.use({ renderer: bbcodeRenderer }).parse(storyDescription));
-    headers["Content-Type"] = "text/plain; charset=utf-8";
-    // Markdown exports (no-op)
-  } else if (!markdownExport.includes(website)) {
-    console.log(`Unrecognized ExportWebsite "${website}"`);
-    return new Response(null, { status: 404 });
+  const description: Record<ExportWebsite, string> = Object.fromEntries(
+    await Promise.all(
+      WEBSITE_LIST.map(async ([website, exportFormat]) => {
+        const u = (user: CollectionEntry<"users">) => getLinkForUser(user, website);
+        const storyDescription = (
+          [
+            story.data.description,
+            `*Word count: ${story.data.wordCount}. ${story.data.contentWarning.trim()}*`,
+            "Writing: " + (await getEntries([story.data.authors].flat())).map((author) => u(author)).join(" , "),
+            story.data.requester && "Request for: " + u(await getEntry(story.data.requester)),
+            story.data.commissioner && "Commissioned by: " + u(await getEntry(story.data.commissioner)),
+            ...(await Promise.all(
+              (Object.keys(charactersPerUser) as CollectionEntry<"users">["id"][]).map(async (id) => {
+                const user = u(await getEntry("users", id));
+                const characterList = charactersPerUser[id];
+                if (characterList[0] == "") {
+                  return `All characters are © ${user}`;
+                } else if (characterList.length > 2) {
+                  return `${characterList.slice(0, characterList.length - 1).join(", ")}, and ${characterList[characterList.length - 1]} are © ${user}`;
+                } else if (characterList.length > 1) {
+                  return `${characterList[0]} and ${characterList[1]} are © ${user}`;
+                }
+                return `${characterList[0]} is © ${user}`;
+              }),
+            )),
+          ].filter((data) => data) as string[]
+        )
+          .join("\n\n")
+          .replaceAll(
+            /\[([^\]]+)\]\((\.[^\)]+)\)/g,
+            (_, group1, group2) =>
+              `[${group1}](${new URL(group2, new URL(`/stories/${story.slug}`, site)).toString()})`,
+          );
+        if (exportFormat === "bbcode") {
+          return [
+            website,
+            tinyDecode(await marked.use({ renderer: bbcodeRenderer }).parse(storyDescription))
+              .replaceAll(/\n\n\n+/g, "\n\n")
+              .trim(),
+          ];
+        }
+        if (exportFormat === "markdown") {
+          return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()];
+        }
+        throw new Error(`Unknown exportFormat "${exportFormat}"`);
+      }),
+    ),
+  );
+
+  const anonymousUser = await getEntry("users", "anonymous");
+  const authorsNames = (await getEntries([story.data.authors].flat())).map((author) =>
+    getNameForUser(author, anonymousUser, lang),
+  );
+  const commissioner = story.data.commissioner && (await getEntry(story.data.commissioner));
+  const requester = story.data.requester && (await getEntry(story.data.requester));
+
+  let storyHeader = `${story.data.title}\n`;
+  if (lang === "eng") {
+    let authorsString = `by ${authorsNames[0]}`;
+    if (authorsNames.length > 2) {
+      authorsString += `, ${authorsNames.slice(1, authorsNames.length - 1).join(", ")}, and ${authorsNames[authorsNames.length - 1]}`;
+    } else if (authorsNames.length == 2) {
+      authorsString += ` and ${authorsNames[1]}`;
+    }
+    storyHeader +=
+      `${authorsString}\n` +
+      (commissioner ? `Commissioned by ${getNameForUser(commissioner, anonymousUser, lang)}\n` : "") +
+      (requester ? `Requested by ${getNameForUser(requester, anonymousUser, lang)}\n` : "");
+  } else if (lang === "tok") {
+    let authorsString = "lipu ni li tan ";
+    if (authorsNames.length > 1) {
+      authorsString += `jan ni: ${authorsNames.join(" en ")}`;
+    } else {
+      authorsString += authorsNames[0];
+    }
+    if (commissioner) {
+      throw new Error(`No "commissioner" handler for language "tok"`);
+    }
+    if (requester) {
+      throw new Error(`No "requester" handler for language "tok"`);
+    }
+    storyHeader += `${authorsString}\n`;
+  } else {
+    throw new Error(`Unknown language "${lang}"`);
   }
-  return new Response(`${storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()}\n`, { headers });
+
+  const storyText = `${storyHeader}\n===\n\n${story.body.replaceAll("\\*", "*").replaceAll("\\=", "=")}`
+    .replaceAll(/\n\n\n+/g, "\n\n")
+    .trim();
+
+  const headers = { "Content-Type": "application/json; charset=utf-8" };
+  return new Response(
+    JSON.stringify({
+      story: storyText,
+      description,
+      thumbnail: story.data.thumbnail ? story.data.thumbnail.src : null,
+    }),
+    { headers },
+  );
 };
diff --git a/src/pages/healthcheck.ts b/src/pages/api/healthcheck.ts
similarity index 100%
rename from src/pages/healthcheck.ts
rename to src/pages/api/healthcheck.ts
diff --git a/src/pages/stories/export/story/[...slug].ts b/src/pages/stories/export/story/[...slug].ts
deleted file mode 100644
index e98a993..0000000
--- a/src/pages/stories/export/story/[...slug].ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { type APIRoute, type GetStaticPaths } from "astro";
-import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content";
-import { type Lang } from "../../../../content/config";
-
-type Props = {
-  story: CollectionEntry<"stories">;
-};
-
-type Params = {
-  slug: CollectionEntry<"stories">["slug"];
-};
-
-export const getStaticPaths: GetStaticPaths = async () => {
-  if (import.meta.env.PROD) {
-    return [];
-  }
-  return (await getCollection("stories")).map((story) => ({
-    params: { slug: story.slug } satisfies Params,
-    props: { story } satisfies Props,
-  }));
-};
-
-function getNameForUser(user: CollectionEntry<"users">, anonymousUser: CollectionEntry<"users">, lang: Lang): string {
-  if (user.data.isAnonymous) {
-    return anonymousUser.data.nameLang[lang] || anonymousUser.data.name;
-  }
-  return user.data.nameLang[lang] || user.data.name;
-}
-
-export const GET: APIRoute<Props, Params> = async ({ props: { story } }) => {
-  const { lang } = story.data;
-  const anonymousUser = await getEntry("users", "anonymous");
-  const authorsNames = (await getEntries([story.data.authors].flat())).map((author) =>
-    getNameForUser(author, anonymousUser, lang),
-  );
-  const commissioner = story.data.commissioner && (await getEntry(story.data.commissioner));
-  const requester = story.data.requester && (await getEntry(story.data.requester));
-
-  let storyHeader = `${story.data.title}\n`;
-  if (lang === "eng") {
-    let authorsString = `by ${authorsNames[0]}`;
-    if (authorsNames.length > 2) {
-      authorsString += `, ${authorsNames.slice(1, authorsNames.length - 1).join(", ")}, and ${authorsNames[authorsNames.length - 1]}`;
-    } else if (authorsNames.length == 2) {
-      authorsString += ` and ${authorsNames[1]}`;
-    }
-    storyHeader +=
-      `${authorsString}\n` +
-      (commissioner ? `Commissioned by ${getNameForUser(commissioner, anonymousUser, lang)}\n` : "") +
-      (requester ? `Requested by ${getNameForUser(requester, anonymousUser, lang)}\n` : "");
-  } else if (lang === "tok") {
-    let authorsString = "lipu ni li tan ";
-    if (authorsNames.length > 1) {
-      authorsString += `jan ni: ${authorsNames.join(" en ")}`;
-    } else {
-      authorsString += authorsNames[0];
-    }
-    if (commissioner) {
-      throw new Error(`No "commissioner" handler for language "tok"`);
-    }
-    if (requester) {
-      throw new Error(`No "requester" handler for language "tok"`);
-    }
-    storyHeader += `${authorsString}\n`;
-  } else {
-    throw new Error(`Unknown language "${lang}"`);
-  }
-
-  const storyText = `${storyHeader}\n===\n\n${story.body.replaceAll("\\*", "*").replaceAll("\\=", "=")}`;
-  const headers = { "Content-Type": "text/plain; charset=utf-8" };
-  return new Response(`${storyText.replaceAll(/\n\n\n+/g, "\n\n").trim()}\n`, { headers });
-};
diff --git a/src/pages/stories/export/thumbnail/[...slug].ts b/src/pages/stories/export/thumbnail/[...slug].ts
deleted file mode 100644
index 0c82b11..0000000
--- a/src/pages/stories/export/thumbnail/[...slug].ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { type APIRoute, type GetStaticPaths } from "astro";
-import { getCollection, type CollectionEntry } from "astro:content";
-
-type Props = {
-  story: CollectionEntry<"stories">;
-};
-
-type Params = {
-  slug: CollectionEntry<"stories">["slug"];
-};
-
-export const getStaticPaths: GetStaticPaths = async () => {
-  if (import.meta.env.PROD) {
-    return [];
-  }
-  return (await getCollection("stories")).map((story) => ({
-    params: { slug: story.slug } satisfies Params,
-    props: { story } satisfies Props,
-  }));
-};
-
-export const GET: APIRoute<Props, Params> = async ({ props: { story }, redirect }) => {
-  if (!story.data.thumbnail) {
-    return new Response(null, { status: 404 });
-  }
-  return redirect(story.data.thumbnail.src);
-};