mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: reposition to self-hosted file-processing infrastructure (#469)
Leads public copy with self-hosted file-processing infrastructure and demotes tool count to a proof point across README, docs, llms.txt, DockerHub, and the landing site. Adds a /self-hosted hub plus 7 job-intent SEO pages with a build-time validator, deepens the flagship /alternatives pages, adds a remove.bg page, three shared components, and landing e2e coverage.
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||

|
||||
|
||||
Open-source, self-hostable file manipulation suite. 200+ tools across image, video, audio, PDF, and files, plus a layer-based image editor and local AI. Everything runs on your own hardware, so your files never leave your network.
|
||||
Open-source, self-hosted file-processing infrastructure. Convert, compress, OCR, transcribe, and run local AI across image, video, audio, PDF, and documents, via UI, REST API, and pipelines. 200+ tools in one stack, on your own hardware. Your files never leave your network.
|
||||
|
||||
[](https://github.com/snapotter-hq/SnapOtter/blob/main/LICENSE)
|
||||
[](https://snapotter.com)
|
||||
|
||||
@@ -24,8 +24,9 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Self-hosted file toolkit. 200+ tools across image, video, audio, PDF, and files.</strong><br />
|
||||
The open-source alternative to Smallpdf, iLovePDF, TinyPNG, TinyWow, and CloudConvert, in one stack you host yourself.
|
||||
<strong>Open-source, self-hosted file-processing infrastructure. Convert, compress, OCR, transcribe, strip metadata, and run local AI across image, video, audio, PDF, and documents, through a UI, REST API, and pipelines. Your files never leave your network.</strong>
|
||||
<br /><br />
|
||||
The one self-hosted stack that replaces CloudConvert, Smallpdf, TinyPNG, TinyWow, and Otter.ai. 200+ tools across five modalities, on hardware you own.
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
@@ -43,10 +43,14 @@ function copyCommand() {
|
||||
<div class="so-home">
|
||||
<section class="hero">
|
||||
<p class="eyebrow">Self-hosted · Open source · AGPLv3</p>
|
||||
<h1 class="hero-title">SnapOtter Documentation</h1>
|
||||
<h1 class="hero-title">Self-hosted file-processing infrastructure.</h1>
|
||||
<p class="hero-sub">
|
||||
<strong>Self-hosted file processing.</strong> 200+ tools across image, video, audio, PDF &
|
||||
files, all on your own hardware. Choose your path below, or get running in one command:
|
||||
<strong>Convert, compress, OCR, transcribe, strip metadata, and run local AI across image,
|
||||
video, audio, PDF, and documents, through a UI, REST API, and pipelines, on infrastructure
|
||||
you control.</strong>
|
||||
<br />
|
||||
200+ tools across five modalities, all on your own hardware. Choose your path below, or get
|
||||
running in one command:
|
||||
</p>
|
||||
<div class="cmd" title="Click to copy" @click="copyCommand">
|
||||
<code>$ {{ command }}</code>
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
---
|
||||
layout: page
|
||||
title: SnapOtter Docs
|
||||
description: "Self-hosted file processing. 200+ tools for image, video, audio, PDF, and files, plus a free layer-based image editor. Local AI, pipelines, REST API. Your files never leave your server."
|
||||
description: "Open-source, self-hosted file-processing infrastructure. Convert, compress, OCR, transcribe, and run local AI across image, video, audio, PDF, and documents, via UI, REST API, and pipelines. Self-host with one Docker command. Your files never leave your server."
|
||||
sidebar: false
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev --port 1350",
|
||||
"prebuild": "../../apps/api/node_modules/.bin/tsx scripts/generate-redirects.mjs",
|
||||
"prebuild": "../../apps/api/node_modules/.bin/tsx scripts/generate-redirects.mjs && ../../apps/api/node_modules/.bin/tsx scripts/validate-use-cases.ts",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview --port 1350",
|
||||
"lint": "biome check src/",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SnapOtter
|
||||
|
||||
> Open-source, self-hostable file processing suite with 200+ tools across 5 modalities (image, video, audio, PDF, files). Single Docker container, no external services. Dual-licensed AGPLv3 / commercial.
|
||||
> SnapOtter is open-source, self-hosted file-processing infrastructure. Conversion, compression, OCR, transcription, metadata scrubbing, and local AI across image, video, audio, PDF, and documents, through a UI, REST API, and pipelines. 200+ tools in one stack. Files never leave your network.
|
||||
|
||||
SnapOtter runs entirely on your own infrastructure. Files are processed locally with no data sent to external APIs. Deploy once with Docker and use it as much as you need. Supports GDPR, HIPAA, and CCPA compliance by architecture.
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Build-time integrity check for use-cases.ts. Run via tsx; wired into landing prebuild.
|
||||
import { TOOLS, toolSection } from "@snapotter/shared";
|
||||
import { ALTERNATIVES } from "../src/data/alternatives.ts";
|
||||
import { USE_CASES } from "../src/data/use-cases.ts";
|
||||
|
||||
// Mirror of FEATURE_BUNDLES ids in packages/shared/src/features.ts.
|
||||
const VALID_BUNDLES = new Set([
|
||||
"background-removal",
|
||||
"face-detection",
|
||||
"object-eraser-colorize",
|
||||
"upscale-enhance",
|
||||
"photo-restoration",
|
||||
"ocr",
|
||||
"transcription",
|
||||
]);
|
||||
|
||||
const TOOL_IDS = new Set(TOOLS.map((t) => t.id));
|
||||
const ALT_SLUGS = new Set(ALTERNATIVES.map((a) => a.slug));
|
||||
const SECTION = /^\/tools\/(image|video|audio|pdf|files)\/([a-z0-9-]+)$/;
|
||||
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
const errors: string[] = [];
|
||||
const seenSlug = new Set<string>();
|
||||
const seenKeyword = new Set<string>();
|
||||
|
||||
for (const uc of USE_CASES) {
|
||||
const at = `use-case "${uc.slug}"`;
|
||||
if (seenSlug.has(uc.slug)) errors.push(`${at}: duplicate slug`);
|
||||
seenSlug.add(uc.slug);
|
||||
if (seenKeyword.has(uc.primaryKeyword))
|
||||
errors.push(`${at}: duplicate primaryKeyword "${uc.primaryKeyword}"`);
|
||||
seenKeyword.add(uc.primaryKeyword);
|
||||
|
||||
for (const id of uc.toolIds) {
|
||||
if (!TOOL_IDS.has(id)) errors.push(`${at}: toolId "${id}" not in TOOLS`);
|
||||
}
|
||||
if (uc.toolIds.length !== uc.toolRoutes.length)
|
||||
errors.push(
|
||||
`${at}: toolIds (${uc.toolIds.length}) and toolRoutes (${uc.toolRoutes.length}) differ in length`,
|
||||
);
|
||||
uc.toolRoutes.forEach((route, i) => {
|
||||
const m = route.match(SECTION);
|
||||
if (!m) {
|
||||
errors.push(`${at}: toolRoute "${route}" is malformed`);
|
||||
return;
|
||||
}
|
||||
const tool = TOOLS.find((t) => t.id === m[2]);
|
||||
if (!tool) {
|
||||
errors.push(`${at}: toolRoute "${route}" ends in unknown toolId "${m[2]}"`);
|
||||
return;
|
||||
}
|
||||
// The page zips toolRoutes[i] with toolIds[i] by index and links to the route,
|
||||
// so a wrong section (404) or a desynced pair (mislabeled link) must fail the build.
|
||||
if (toolSection(tool) !== m[1])
|
||||
errors.push(`${at}: toolRoute "${route}" should use section "${toolSection(tool)}"`);
|
||||
if (uc.toolIds[i] !== m[2])
|
||||
errors.push(
|
||||
`${at}: toolRoutes[${i}] "${route}" does not match toolIds[${i}] "${uc.toolIds[i]}"`,
|
||||
);
|
||||
});
|
||||
if (uc.alternativeSlug && !ALT_SLUGS.has(uc.alternativeSlug))
|
||||
errors.push(`${at}: alternativeSlug "${uc.alternativeSlug}" not in ALTERNATIVES`);
|
||||
|
||||
if (uc.isAiBundle !== Boolean(uc.bundleId))
|
||||
errors.push(`${at}: isAiBundle and bundleId disagree`);
|
||||
if (uc.bundleId && !VALID_BUNDLES.has(uc.bundleId))
|
||||
errors.push(`${at}: bundleId "${uc.bundleId}" is not a known bundle`);
|
||||
|
||||
if (!DATE.test(uc.lastReviewed)) errors.push(`${at}: lastReviewed not YYYY-MM-DD`);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`use-cases.ts validation failed:\n - ${errors.join("\n - ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`use-cases.ts OK (${USE_CASES.length} entries)`);
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||
interface Props {
|
||||
heading?: string;
|
||||
body?: string;
|
||||
}
|
||||
const {
|
||||
heading = "Deploying this across a team?",
|
||||
body = "Audit trails, per-tool permissions, SSO, and air-gapped deployment for regulated environments. Talk to us about running SnapOtter as shared infrastructure.",
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<section class="border-t border-border bg-background-alt py-16">
|
||||
<div class="mx-auto max-w-4xl px-6 text-center">
|
||||
<div class="reveal">
|
||||
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">{heading}</h2>
|
||||
<p class="mx-auto mt-4 max-w-xl text-muted">{body}</p>
|
||||
<div class="mt-8 flex flex-wrap justify-center gap-4">
|
||||
<a href="/contact" class="btn-primary inline-flex items-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold">
|
||||
Book a demo
|
||||
</a>
|
||||
<a href="/enterprise" class="inline-flex items-center gap-2 rounded-lg border border-border px-6 py-3 text-sm font-semibold transition-colors hover:bg-background-alt">
|
||||
See enterprise features
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -18,7 +18,7 @@ const columns = [
|
||||
title: "Solutions",
|
||||
links: [
|
||||
{ label: "Enterprise", href: "/#enterprise" },
|
||||
{ label: "Self-Hosted", href: "/#features" },
|
||||
{ label: "Self-Hosted", href: "/self-hosted" },
|
||||
{ label: "Open Source", href: "/#open-source" },
|
||||
{ label: "Alternatives", href: "/alternatives" },
|
||||
{ label: "Pricing", href: "/#pricing" },
|
||||
|
||||
@@ -35,14 +35,14 @@ const trustBadges = ["Self-hosted", "Open source", "Air-gap capable", "Complianc
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- Headline: one precise promise for privacy-sensitive buyers -->
|
||||
<!-- Headline: names the buying trigger (the network boundary), not "the cloud" -->
|
||||
<h1 class="animate-fade-up mt-8 font-display text-4xl font-extrabold tracking-tight text-white sm:text-5xl md:text-6xl lg:text-7xl" style="animation-delay: 0.1s; text-shadow: 0 2px 30px rgba(90, 20, 0, 0.28);">
|
||||
File processing for privacy-sensitive teams.
|
||||
Private file processing for data that can't leave your network.
|
||||
</h1>
|
||||
|
||||
<!-- Subtitle: one calm supporting line -->
|
||||
<!-- Subtitle: capability breadth plus the infrastructure surfaces -->
|
||||
<p class="animate-fade-up mx-auto mt-6 max-w-2xl text-lg text-white/90 md:text-xl" style="animation-delay: 0.15s; text-shadow: 0 1px 16px rgba(90, 20, 0, 0.22);">
|
||||
Run image, video, audio, PDF, and file tools with a free layer-based image editor, local AI, batch workflows, and enterprise controls.
|
||||
Convert, compress, OCR, transcribe, strip metadata, and run local AI across image, video, audio, PDF, and documents. Through a UI, REST API, and pipelines, on infrastructure you own.
|
||||
</p>
|
||||
|
||||
<!-- Hero tool search -->
|
||||
|
||||
@@ -10,6 +10,7 @@ const links = [
|
||||
{ label: "Enterprise", href: "/#enterprise" },
|
||||
{ label: "Pricing", href: "/#pricing" },
|
||||
{ label: "Alternatives", href: "/alternatives" },
|
||||
{ label: "Developers", href: "/self-hosted/file-conversion-api" },
|
||||
{ label: "Docs", href: "https://docs.snapotter.com", external: true },
|
||||
{ label: "Talk to a human", href: "/contact" },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||
interface Props {
|
||||
// Canonical single-container command. Keep in sync with HeroQuickstart.astro,
|
||||
// DOCKERHUB.md, README.md, and apps/docs/guide/getting-started.md.
|
||||
docker: string;
|
||||
// Optional REST API example (already newline-formatted).
|
||||
curl?: string;
|
||||
dockerLabel?: string;
|
||||
curlLabel?: string;
|
||||
}
|
||||
const {
|
||||
docker,
|
||||
curl,
|
||||
dockerLabel = "Self-host in one command",
|
||||
curlLabel = "Process a file over the REST API",
|
||||
} = Astro.props;
|
||||
|
||||
const blocks = [
|
||||
...(docker ? [{ label: dockerLabel, code: docker }] : []),
|
||||
...(curl ? [{ label: curlLabel, code: curl }] : []),
|
||||
];
|
||||
---
|
||||
|
||||
<div class="not-prose space-y-5">
|
||||
{blocks.map((b) => (
|
||||
<div>
|
||||
<p class="mb-2 font-mono text-xs font-semibold uppercase tracking-[0.15em] text-primary">
|
||||
{b.label}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="so-copy group relative flex w-full items-start gap-3 rounded-xl border border-white/10 bg-terminal-bg px-4 py-3.5 text-start font-mono text-[0.8125rem] leading-[1.7] text-terminal-fg"
|
||||
data-copy={b.code}
|
||||
aria-label="Copy this command to your clipboard"
|
||||
title="Click to copy"
|
||||
>
|
||||
<span class="shrink-0 select-none font-bold text-terminal-green">$</span>
|
||||
<span dir="ltr" class="min-w-0 flex-1 overflow-x-auto">
|
||||
<code class="whitespace-pre">{b.code}</code>
|
||||
</span>
|
||||
<span class="shrink-0 text-terminal-muted transition-colors group-hover:text-primary-light" aria-hidden="true">
|
||||
<svg class="copy-idle h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
<svg class="copy-done hidden h-4 w-4 text-terminal-green" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</span>
|
||||
<span class="copy-toast pointer-events-none absolute -top-7 end-3 rounded-md bg-foreground px-2 py-0.5 font-body text-[0.6875rem] font-semibold text-white opacity-0 transition-opacity">Copied</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.so-copy { transition: border-color 0.2s, box-shadow 0.2s; }
|
||||
.so-copy:hover { border-color: rgba(224, 120, 50, 0.5); }
|
||||
.so-copy:focus-visible { outline: 2px solid var(--color-primary-light); outline-offset: 3px; }
|
||||
.so-copy.copied { border-color: rgba(74, 222, 128, 0.55); }
|
||||
.so-copy.copied .copy-toast { opacity: 1; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll(".so-copy").forEach((btn) => {
|
||||
const idle = btn.querySelector(".copy-idle");
|
||||
const done = btn.querySelector(".copy-done");
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const flip = (copied: boolean) => {
|
||||
idle?.classList.toggle("hidden", copied);
|
||||
done?.classList.toggle("hidden", !copied);
|
||||
btn.classList.toggle("copied", copied);
|
||||
};
|
||||
btn.addEventListener("click", async () => {
|
||||
const text = btn.getAttribute("data-copy") ?? "";
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.setAttribute("readonly", "");
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); } catch {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
flip(true);
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => flip(false), 2000);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||
interface Props {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
const { title, body } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="reveal rounded-xl border border-danger/20 bg-danger/5 p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 shrink-0 text-danger" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-danger">{title}</p>
|
||||
<p class="mt-2 text-sm leading-relaxed text-muted">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -7,11 +7,6 @@ const starCount = await getStarCount();
|
||||
const { display: pullsDisplay } = await getImagePulls();
|
||||
|
||||
const stats = [
|
||||
{
|
||||
value: "200+",
|
||||
label: "Processing Tools",
|
||||
sublabel: "Across 5 file modalities",
|
||||
},
|
||||
{
|
||||
value: formatCompact(starCount),
|
||||
label: "GitHub Stars",
|
||||
@@ -22,6 +17,11 @@ const stats = [
|
||||
label: "Image Pulls",
|
||||
sublabel: "Deployed worldwide",
|
||||
},
|
||||
{
|
||||
value: "200+",
|
||||
label: "Processing Tools",
|
||||
sublabel: "Across 5 file modalities",
|
||||
},
|
||||
{
|
||||
value: "20+",
|
||||
label: "Languages",
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// Keep competitor claims sourced, dated, and framed around deployment model,
|
||||
// data control, and workflow fit instead of brittle point-by-point parity.
|
||||
|
||||
import { DOCKER_CMD } from "./use-cases";
|
||||
|
||||
export interface ComparisonRow {
|
||||
feature: string;
|
||||
snapotter: string;
|
||||
@@ -37,6 +39,14 @@ export interface Alternative {
|
||||
intro: string;
|
||||
/** What SnapOtter brings beyond this competitor's single lane */
|
||||
breadth: string;
|
||||
/** Optional deepening: the status-quo workflow with the competitor */
|
||||
whatYouDoToday?: string;
|
||||
/** Optional deepening: sourced/dated risk of the hosted approach */
|
||||
whyRisky?: string;
|
||||
/** Optional deepening: single-container command (usually DOCKER_CMD) */
|
||||
dockerSnippet?: string;
|
||||
/** Optional deepening: REST API example */
|
||||
apiSnippet?: string;
|
||||
/** true when the competitor is itself open-source / self-hostable (shifts framing to breadth) */
|
||||
competitorOpenSource: boolean;
|
||||
/** YYYY-MM-DD date when public vendor pages were last checked */
|
||||
@@ -193,6 +203,16 @@ export const ALTERNATIVES: Alternative[] = [
|
||||
"TinyPNG is a focused online image compressor. SnapOtter is the self-hosted alternative for teams that want compression, conversion, resize, metadata, and other image work to run locally.",
|
||||
breadth:
|
||||
"TinyPNG is excellent when the job is image compression. SnapOtter compresses PNG, JPEG, WebP, and AVIF, then keeps going with resize, crop, conversion, watermarking, metadata tools, video, audio, PDF, and file workflows.",
|
||||
whatYouDoToday:
|
||||
"You drop images into TinyPNG or call the Tinify API, and it compresses them on Tinify's servers, counting each one against a monthly quota.",
|
||||
whyRisky:
|
||||
"The originals are uploaded to a third party to be compressed, and the free quota caps you at a few hundred images a month. For product imagery or anything with people or data in frame, that is an upload you may not want to make.",
|
||||
dockerSnippet: DOCKER_CMD,
|
||||
apiSnippet: `# Compress an image on your own server (sync: returns a downloadUrl)
|
||||
curl -s -X POST http://localhost:1349/api/v1/tools/image/compress \\
|
||||
-H "Authorization: Bearer si_YOUR_KEY" \\
|
||||
-F "file=@photo.jpg" \\
|
||||
-F 'settings={"quality":75}' | jq -r .downloadUrl`,
|
||||
competitorOpenSource: false,
|
||||
lastReviewed: REVIEW_DATE,
|
||||
sources: [{ label: "TinyPNG / Tinify", url: "https://tinypng.com/" }],
|
||||
@@ -224,6 +244,16 @@ export const ALTERNATIVES: Alternative[] = [
|
||||
"CloudConvert is a mature hosted converter and API. SnapOtter is the self-hosted alternative when conversion should happen inside your own infrastructure, with pipelines you control.",
|
||||
breadth:
|
||||
"CloudConvert is broad and API-friendly for conversion. SnapOtter converts across image, video, audio, PDF, and file formats, then adds compression, editing, OCR, transcription, and local pipelines.",
|
||||
whatYouDoToday:
|
||||
"You send files to CloudConvert's cloud, wait for the conversion, and download the result, often wiring it into an app through the hosted API and paying per conversion.",
|
||||
whyRisky:
|
||||
"Every file that hits the API leaves your network, and the bill scales with volume. For user uploads or internal documents, a third party handles your data and you inherit their retention terms, none of which works in an air-gapped or on-prem setup.",
|
||||
dockerSnippet: DOCKER_CMD,
|
||||
apiSnippet: `# Convert a file on your own server, no cloud round-trip
|
||||
curl -s -X POST http://localhost:1349/api/v1/tools/video/convert-video \\
|
||||
-H "Authorization: Bearer si_YOUR_KEY" \\
|
||||
-F "file=@clip.mov" \\
|
||||
-F 'settings={"format":"mp4"}' # -> {"jobId":"...","async":true}`,
|
||||
competitorOpenSource: false,
|
||||
lastReviewed: REVIEW_DATE,
|
||||
sources: [
|
||||
@@ -489,6 +519,16 @@ export const ALTERNATIVES: Alternative[] = [
|
||||
"Otter.ai is a hosted meeting transcription platform. SnapOtter is the self-hosted alternative when you want transcription to run on your own CPU or NVIDIA CUDA GPU with local models.",
|
||||
breadth:
|
||||
"Otter.ai is built around meetings, notes, and collaboration. SnapOtter focuses on local speech-to-text plus the rest of an audio workflow: trim, normalize, noise reduction, format conversion, video subtitles, image, PDF, and file tools.",
|
||||
whatYouDoToday:
|
||||
"You upload a recording or connect a meeting, and Otter.ai transcribes it in the cloud, storing the audio and transcript on its servers under your plan.",
|
||||
whyRisky:
|
||||
"Meeting audio is dense with confidential content, and a hosted transcriber processes and retains it under terms you do not set. For regulated or sensitive conversations, that is hard to audit and impossible to run offline.",
|
||||
dockerSnippet: DOCKER_CMD,
|
||||
apiSnippet: `# Transcribe on your own server (long tool: 202, then stream progress)
|
||||
curl -s -X POST http://localhost:1349/api/v1/tools/audio/transcribe-audio \\
|
||||
-H "Authorization: Bearer si_YOUR_KEY" \\
|
||||
-F "file=@meeting.m4a" \\
|
||||
-F 'settings={"outputFormat":"srt"}' # -> {"jobId":"...","async":true}`,
|
||||
competitorOpenSource: false,
|
||||
lastReviewed: REVIEW_DATE,
|
||||
sources: [
|
||||
@@ -553,7 +593,7 @@ export const ALTERNATIVES: Alternative[] = [
|
||||
},
|
||||
{
|
||||
feature: "Tool count",
|
||||
snapotter: "241",
|
||||
snapotter: "200+",
|
||||
competitor: "60+ PDF operations",
|
||||
snapotterWins: true,
|
||||
},
|
||||
@@ -629,7 +669,7 @@ export const ALTERNATIVES: Alternative[] = [
|
||||
},
|
||||
{
|
||||
feature: "Tool count",
|
||||
snapotter: "241",
|
||||
snapotter: "200+",
|
||||
competitor: "1000+ conversion formats",
|
||||
snapotterWins: false,
|
||||
},
|
||||
@@ -657,4 +697,46 @@ export const ALTERNATIVES: Alternative[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "removebg",
|
||||
competitor: "remove.bg",
|
||||
category: "Background removal",
|
||||
pageTitle: "The Open-Source, Self-Hosted Alternative to remove.bg",
|
||||
h1: "The open-source, self-hosted alternative to remove.bg",
|
||||
metaDescription:
|
||||
"remove.bg removes image backgrounds in the cloud. SnapOtter runs background removal locally on your own server, with 200+ more tools in the same stack.",
|
||||
intro:
|
||||
"remove.bg is a fast hosted background remover. SnapOtter is the self-hosted alternative that runs the same kind of AI on your own hardware, so product and people images never go to a third-party service.",
|
||||
breadth:
|
||||
"remove.bg focuses on one job and does it well. SnapOtter runs local background removal, then adds compression, conversion, upscaling, OCR, and the rest of the catalog in one deployment.",
|
||||
whatYouDoToday:
|
||||
"You upload an image to remove.bg or its API, and it returns a cutout, charging a credit per image processed on its servers.",
|
||||
whyRisky:
|
||||
"Background removal runs on your most sensitive images: unreleased product shots, headshots, user uploads. Sending each to a per-credit cloud API means a third party sees them, and the cost climbs exactly when you want to batch thousands.",
|
||||
dockerSnippet: DOCKER_CMD,
|
||||
apiSnippet: `# Remove a background on your own server (long tool: 202, then stream progress)
|
||||
curl -s -X POST http://localhost:1349/api/v1/tools/image/remove-background \\
|
||||
-H "Authorization: Bearer si_YOUR_KEY" \\
|
||||
-F "file=@photo.jpg" # -> {"jobId":"...","async":true}`,
|
||||
competitorOpenSource: false,
|
||||
lastReviewed: REVIEW_DATE,
|
||||
sources: [{ label: "remove.bg", url: "https://www.remove.bg" }],
|
||||
rows: hostedRows({
|
||||
category: "background removal",
|
||||
competitorCoverage: "Background removal",
|
||||
pricing: "Credit-based pricing per image",
|
||||
fileHandling: "Images are uploaded to a vendor-hosted API for processing",
|
||||
automation: "Vendor-hosted API with per-image credits",
|
||||
}),
|
||||
faqs: [
|
||||
{
|
||||
q: "Is there a self-hosted alternative to remove.bg?",
|
||||
a: "Yes. SnapOtter's background removal runs locally after a one-time model-bundle install, so images never leave your network and there is no per-image credit.",
|
||||
},
|
||||
{
|
||||
q: "Does it need a GPU?",
|
||||
a: "No. It runs on CPU, and uses a GPU automatically if one is available for faster batches.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
// SEO job-intent pages: "self-hosted <capability>" content.
|
||||
// Each entry renders a static page at /self-hosted/<slug> plus a card on the hub.
|
||||
// Canonical for "self-hosted / private / local <capability>" queries. Cross-links
|
||||
// sideways to /alternatives/<competitor> and down to /tools/<section>/<tool>.
|
||||
// Keep competitor claims sourced + dated (mirror the alternatives.ts discipline).
|
||||
|
||||
export interface UseCaseFaq {
|
||||
q: string;
|
||||
a: string;
|
||||
}
|
||||
export interface UseCaseSource {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface UseCase {
|
||||
slug: string;
|
||||
pageTitle: string; // SEO <title>, the phrase people search
|
||||
h1: string;
|
||||
metaDescription: string;
|
||||
intro: string;
|
||||
primaryKeyword: string; // canonical target query, must be unique across entries
|
||||
whatYouDoToday: string;
|
||||
whyRisky: string; // sourced + dated tone
|
||||
howSnapotter: string;
|
||||
toolIds: string[]; // must exist in @snapotter/shared TOOLS
|
||||
toolRoutes: string[]; // /tools/<section>/<tool> cross-links
|
||||
alternativeSlug?: string; // /alternatives/<slug> cross-link when one exists
|
||||
docker: string; // command for QuickstartBlock (canonical single-container line)
|
||||
curl: string; // REST API example for QuickstartBlock
|
||||
isAiBundle: boolean; // show one-time-install note if true
|
||||
bundleId?: string; // set iff isAiBundle; a FEATURE_BUNDLES id
|
||||
faqs: UseCaseFaq[];
|
||||
sources: UseCaseSource[];
|
||||
lastReviewed: string; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
export const DOCKER_CMD =
|
||||
"docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data snapotter/snapotter:latest";
|
||||
|
||||
const REVIEWED = "2026-07-10";
|
||||
|
||||
// Build a REST API example for one tool. `isLong` toggles the sync (200 + downloadUrl)
|
||||
// vs long (202 + SSE) shape. `settings` is omitted from the command when empty, since
|
||||
// the settings field is optional and each tool applies its own Zod defaults.
|
||||
function apiExample(opts: {
|
||||
isLong: boolean;
|
||||
section: string;
|
||||
tool: string;
|
||||
input: string;
|
||||
settings?: string;
|
||||
}): string {
|
||||
const { isLong, section, tool, input, settings } = opts;
|
||||
const lines = [
|
||||
isLong
|
||||
? "# Process a file over the REST API (long tool: 202, then stream progress)"
|
||||
: "# Process a file over the REST API (sync tool: returns a downloadUrl)",
|
||||
`curl -s -X POST http://localhost:1349/api/v1/tools/${section}/${tool} \\`,
|
||||
' -H "Authorization: Bearer si_YOUR_KEY" \\',
|
||||
];
|
||||
if (settings) {
|
||||
lines.push(` -F "file=@${input}" \\`);
|
||||
lines.push(
|
||||
isLong
|
||||
? ` -F 'settings=${settings}' # -> {"jobId":"...","async":true}`
|
||||
: ` -F 'settings=${settings}' | jq -r .downloadUrl`,
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
isLong
|
||||
? ` -F "file=@${input}" # -> {"jobId":"...","async":true}`
|
||||
: ` -F "file=@${input}" | jq -r .downloadUrl`,
|
||||
);
|
||||
}
|
||||
if (isLong) {
|
||||
lines.push("");
|
||||
lines.push("curl -N http://localhost:1349/api/v1/jobs/<jobId>/progress # SSE progress");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const FULL_API_EXAMPLE = [
|
||||
"# 1. Create an API key once (store it; it is shown only once)",
|
||||
"curl -s -X POST http://localhost:1349/api/v1/api-keys \\",
|
||||
' -H "Authorization: Bearer $SESSION_TOKEN" \\',
|
||||
' -H "Content-Type: application/json" \\',
|
||||
` -d '{"name":"conversion"}' | jq -r .key # -> si_...`,
|
||||
"",
|
||||
"# 2. Compress an image (sync tool: returns a downloadUrl)",
|
||||
"curl -s -X POST http://localhost:1349/api/v1/tools/image/compress \\",
|
||||
' -H "Authorization: Bearer si_YOUR_KEY" \\',
|
||||
' -F "file=@photo.jpg" \\',
|
||||
` -F 'settings={"quality":75}' | jq -r .downloadUrl`,
|
||||
"",
|
||||
"# 3. Download the result",
|
||||
'curl -s -o out.jpg "http://localhost:1349<downloadUrl>"',
|
||||
].join("\n");
|
||||
|
||||
export const USE_CASES: UseCase[] = [
|
||||
{
|
||||
slug: "image-compressor",
|
||||
pageTitle: "Self-Hosted Image Compressor (Private, No Upload)",
|
||||
h1: "A self-hosted image compressor that never uploads your images",
|
||||
metaDescription:
|
||||
"Compress JPG, PNG, and WebP on your own server. A private, self-hosted image compressor with a REST API. No upload to a third-party service, no per-image limits.",
|
||||
intro:
|
||||
"TinyPNG and its clones compress your images on their servers. SnapOtter runs the same job on yours, through a UI or a REST API, so the originals never leave your network.",
|
||||
primaryKeyword: "self-hosted image compressor",
|
||||
whatYouDoToday:
|
||||
"You drag images into an online compressor, wait, and download the smaller files. The originals sit on a third-party server for as long as that service chooses to keep them.",
|
||||
whyRisky:
|
||||
"For public marketing assets that's usually fine. For product screenshots, ID scans, or anything with customer data in frame, you have handed a copy to a vendor whose retention you do not control. Free tiers also cap image count and size, so batch jobs stall.",
|
||||
howSnapotter:
|
||||
"SnapOtter's compress tool runs on your own instance. Point the UI at a batch or POST files to the API, get back optimized JPG, PNG, or WebP, and nothing leaves the box. The only limit is your hardware.",
|
||||
toolIds: ["compress"],
|
||||
toolRoutes: ["/tools/image/compress"],
|
||||
alternativeSlug: "tinypng",
|
||||
docker: DOCKER_CMD,
|
||||
curl: apiExample({
|
||||
isLong: false,
|
||||
section: "image",
|
||||
tool: "compress",
|
||||
input: "photo.jpg",
|
||||
settings: '{"quality":75}',
|
||||
}),
|
||||
isAiBundle: false,
|
||||
faqs: [
|
||||
{
|
||||
q: "Does it upload my images anywhere?",
|
||||
a: "No. Compression runs inside your SnapOtter instance. If you deploy it inside your own network, the images stay there.",
|
||||
},
|
||||
{
|
||||
q: "Which formats does it handle?",
|
||||
a: "JPG, PNG, and WebP for compression, with the wider SnapOtter catalog available for conversion in the same deployment.",
|
||||
},
|
||||
{
|
||||
q: "Is there an API for batch jobs?",
|
||||
a: "Yes. POST files to /api/v1/tools/image/compress with an si_ API key, or chain compression into a pipeline for whole folders.",
|
||||
},
|
||||
],
|
||||
sources: [{ label: "TinyPNG", url: "https://tinypng.com" }],
|
||||
lastReviewed: REVIEWED,
|
||||
},
|
||||
{
|
||||
slug: "pdf-ocr",
|
||||
pageTitle: "Self-Hosted PDF OCR (Private, On-Premise)",
|
||||
h1: "Self-hosted PDF OCR that never uploads your documents",
|
||||
metaDescription:
|
||||
"Make scanned PDFs searchable on your own server. Private, self-hosted OCR with local models and a REST API. Air-gap capable, no per-page fees, no cloud upload.",
|
||||
intro:
|
||||
"Most OCR services process your documents on their servers. SnapOtter runs OCR locally, so contracts and records stay on infrastructure you control.",
|
||||
primaryKeyword: "self-hosted pdf ocr",
|
||||
whatYouDoToday:
|
||||
"You upload a scanned PDF to an online OCR tool and download searchable text or a tagged PDF. The document leaves your network to get there.",
|
||||
whyRisky:
|
||||
"Scanned documents are exactly the sensitive kind: contracts, medical records, statements. Online OCR services process and often retain uploads under terms you do not set, which is hard to square with GDPR or HIPAA and impossible to audit.",
|
||||
howSnapotter:
|
||||
"SnapOtter runs the OCR model inside your instance. Install the OCR bundle once, then it works offline, air-gapped included. The PDF never leaves your network, and there is no per-page charge.",
|
||||
toolIds: ["ocr-pdf"],
|
||||
toolRoutes: ["/tools/pdf/ocr-pdf"],
|
||||
docker: DOCKER_CMD,
|
||||
curl: apiExample({
|
||||
isLong: true,
|
||||
section: "pdf",
|
||||
tool: "ocr-pdf",
|
||||
input: "scan.pdf",
|
||||
settings: '{"language":"en"}',
|
||||
}),
|
||||
isAiBundle: true,
|
||||
bundleId: "ocr",
|
||||
faqs: [
|
||||
{
|
||||
q: "Does it work air-gapped?",
|
||||
a: "Yes. After the one-time OCR bundle install, OCR runs entirely offline, so it works in air-gapped and classified networks.",
|
||||
},
|
||||
{
|
||||
q: "Are my documents retained anywhere?",
|
||||
a: "No. The PDF is processed inside your instance. There is no auto-save; you choose whether to keep a result.",
|
||||
},
|
||||
{
|
||||
q: "Which languages are supported?",
|
||||
a: "The OCR bundle covers a range of languages; pass the target language in the tool settings.",
|
||||
},
|
||||
],
|
||||
sources: [],
|
||||
lastReviewed: REVIEWED,
|
||||
},
|
||||
{
|
||||
slug: "video-converter",
|
||||
pageTitle: "Self-Hosted Video Converter (Private, No Upload)",
|
||||
h1: "A self-hosted video converter that keeps your footage on your server",
|
||||
metaDescription:
|
||||
"Convert video formats on your own server. A private, self-hosted video converter with a REST API. No upload to a third-party service, no file-size caps beyond your hardware.",
|
||||
intro:
|
||||
"CloudConvert and the online transcoders do the work on their servers, which means your footage goes to them first. SnapOtter runs FFmpeg on your own hardware, so the files never leave your network.",
|
||||
primaryKeyword: "self-hosted video converter",
|
||||
whatYouDoToday:
|
||||
"You upload a clip to an online converter, wait for the transcode, and download the result. Large files mean long uploads, and the footage sits on someone else's server while it processes.",
|
||||
whyRisky:
|
||||
"Video is heavy and often sensitive: raw interviews, internal training, unreleased product footage, camera pulls with people in frame. Online converters process uploads on infrastructure you do not control, and free tiers cap file size and minutes, so real work stalls or forces a paid plan.",
|
||||
howSnapotter:
|
||||
"SnapOtter transcodes with a static FFmpeg build inside your instance. Convert between MP4, WebM, MOV, MKV, and AVI through the UI or the REST API, with no size cap beyond your disk and no footage leaving the box. Long jobs report progress over a live stream.",
|
||||
toolIds: ["convert-video"],
|
||||
toolRoutes: ["/tools/video/convert-video"],
|
||||
alternativeSlug: "cloudconvert",
|
||||
docker: DOCKER_CMD,
|
||||
curl: apiExample({
|
||||
isLong: true,
|
||||
section: "video",
|
||||
tool: "convert-video",
|
||||
input: "clip.mov",
|
||||
settings: '{"format":"mp4"}',
|
||||
}),
|
||||
isAiBundle: false,
|
||||
faqs: [
|
||||
{
|
||||
q: "Is there a file-size limit?",
|
||||
a: "No hard limit from SnapOtter. You are bounded by your own disk and CPU, not by a vendor's plan tier.",
|
||||
},
|
||||
{
|
||||
q: "Does my footage get uploaded anywhere?",
|
||||
a: "No. Transcoding runs inside your instance, so the source and output stay on your infrastructure.",
|
||||
},
|
||||
{
|
||||
q: "Which formats are supported?",
|
||||
a: "The common web and editing formats via FFmpeg, including MP4, WebM, MOV, MKV, and AVI.",
|
||||
},
|
||||
],
|
||||
sources: [{ label: "CloudConvert", url: "https://cloudconvert.com" }],
|
||||
lastReviewed: REVIEWED,
|
||||
},
|
||||
{
|
||||
slug: "background-removal",
|
||||
pageTitle: "Self-Hosted Background Removal (remove.bg Alternative)",
|
||||
h1: "Self-hosted background removal that runs on your own hardware",
|
||||
metaDescription:
|
||||
"Remove image backgrounds on your own server with local AI. A private, self-hosted remove.bg alternative. No per-image credits, no upload to a cloud API.",
|
||||
intro:
|
||||
"remove.bg does the cutout in the cloud, one credit at a time. SnapOtter runs the same kind of AI locally, so product shots and photos of people never go to a third-party service.",
|
||||
primaryKeyword: "self-hosted background removal",
|
||||
whatYouDoToday:
|
||||
"You upload an image to an online background remover, it returns a cutout, and you pay per image or per credit. Every image you process is sent to the vendor's API first.",
|
||||
whyRisky:
|
||||
"Background removal usually runs on the images you care about most: product photography before launch, headshots, user-uploaded photos. Sending those to a per-credit cloud API means a third party sees them, and the cost scales with every image at exactly the moment you want to batch thousands.",
|
||||
howSnapotter:
|
||||
"SnapOtter runs background removal with a local model after a one-time bundle install. Drop images in the UI or POST them to the API, get transparent PNGs back, and nothing leaves your network. No per-image credit, and a GPU is used automatically if you have one.",
|
||||
toolIds: ["remove-background"],
|
||||
toolRoutes: ["/tools/image/remove-background"],
|
||||
alternativeSlug: "removebg",
|
||||
docker: DOCKER_CMD,
|
||||
curl: apiExample({
|
||||
isLong: true,
|
||||
section: "image",
|
||||
tool: "remove-background",
|
||||
input: "photo.jpg",
|
||||
}),
|
||||
isAiBundle: true,
|
||||
bundleId: "background-removal",
|
||||
faqs: [
|
||||
{
|
||||
q: "Does it need a GPU?",
|
||||
a: "No. It runs on CPU and uses a GPU automatically if one is available for faster batches.",
|
||||
},
|
||||
{
|
||||
q: "Are my images uploaded anywhere?",
|
||||
a: "No. The cutout runs inside your instance, so the images stay on your infrastructure.",
|
||||
},
|
||||
{
|
||||
q: "Is it really free per image?",
|
||||
a: "Yes. After the one-time model install there is no per-image charge; you are bounded only by your hardware.",
|
||||
},
|
||||
],
|
||||
sources: [{ label: "remove.bg", url: "https://www.remove.bg" }],
|
||||
lastReviewed: REVIEWED,
|
||||
},
|
||||
{
|
||||
slug: "transcription",
|
||||
pageTitle: "Self-Hosted Transcription (Private, Local Whisper)",
|
||||
h1: "Self-hosted transcription that keeps recordings on your infrastructure",
|
||||
metaDescription:
|
||||
"Transcribe audio on your own server with local Whisper models. A private, self-hosted Otter.ai alternative. No upload to a transcription cloud, no per-minute fees.",
|
||||
intro:
|
||||
"Otter.ai and the online transcribers upload your recordings to their servers to turn them into text. SnapOtter runs Whisper locally, so meetings and interviews stay on infrastructure you control.",
|
||||
primaryKeyword: "self-hosted transcription",
|
||||
whatYouDoToday:
|
||||
"You upload a recording to a transcription service and get text back a few minutes later. The audio, and everything said in it, is processed and stored on the vendor's servers.",
|
||||
whyRisky:
|
||||
"Recordings are dense with sensitive content: internal meetings, customer calls, interviews, legal discussions. Transcription SaaS processes and retains that audio under terms you do not set, which is a hard fit for confidential or regulated material and impossible to audit after the fact.",
|
||||
howSnapotter:
|
||||
"SnapOtter transcribes with faster-whisper inside your instance. Install the transcription bundle once, then it runs offline, air-gapped included. The audio never leaves your network, and there is no per-minute charge. Export plain text, SRT, or VTT.",
|
||||
toolIds: ["transcribe-audio"],
|
||||
toolRoutes: ["/tools/audio/transcribe-audio"],
|
||||
alternativeSlug: "otter-ai",
|
||||
docker: DOCKER_CMD,
|
||||
curl: apiExample({
|
||||
isLong: true,
|
||||
section: "audio",
|
||||
tool: "transcribe-audio",
|
||||
input: "meeting.m4a",
|
||||
settings: '{"outputFormat":"srt"}',
|
||||
}),
|
||||
isAiBundle: true,
|
||||
bundleId: "transcription",
|
||||
faqs: [
|
||||
{
|
||||
q: "Does it work offline?",
|
||||
a: "Yes. After the one-time transcription bundle install it runs fully offline, so recordings never need a network round-trip.",
|
||||
},
|
||||
{
|
||||
q: "Is the audio retained anywhere?",
|
||||
a: "No. The recording is processed inside your instance. There is no auto-save; you choose whether to keep the transcript.",
|
||||
},
|
||||
{
|
||||
q: "Which languages and formats?",
|
||||
a: "Whisper handles a wide range of languages, with plain text, SRT, or VTT output selected in the tool settings.",
|
||||
},
|
||||
],
|
||||
sources: [{ label: "Otter.ai", url: "https://otter.ai" }],
|
||||
lastReviewed: REVIEWED,
|
||||
},
|
||||
{
|
||||
slug: "file-conversion-api",
|
||||
pageTitle: "Self-Hosted File Conversion API (Private, REST)",
|
||||
h1: "A self-hosted file conversion API you run yourself",
|
||||
metaDescription:
|
||||
"A self-hosted REST API for converting and compressing files. OpenAPI 3.1, bearer-key auth, sync and async jobs. The private alternative to a hosted file conversion API.",
|
||||
intro:
|
||||
"Hosted conversion APIs send every file through their infrastructure and bill per job. SnapOtter gives you the same kind of REST API, running entirely on your own servers.",
|
||||
primaryKeyword: "self-hosted file conversion api",
|
||||
whatYouDoToday:
|
||||
"You wire your app to a hosted conversion API, ship files to it over the network, and pay per conversion. Every file your users touch passes through a third party, and the bill scales with usage.",
|
||||
whyRisky:
|
||||
"An API in your critical path that ships user files to a vendor is a data-residency and cost problem at once. You inherit their retention terms, their uptime, and their per-job pricing, and you cannot run any of it in an air-gapped or on-prem environment.",
|
||||
howSnapotter:
|
||||
"SnapOtter exposes a documented REST API: an OpenAPI 3.1 spec at /api/v1/openapi.yaml and interactive docs at /api/docs. Authenticate with an si_ bearer key, POST a file to /api/v1/tools/{section}/{toolId}, and get a downloadUrl back for fast tools or a 202 plus live progress for long ones. It runs in CI, cron, and internal services, on your own hardware.",
|
||||
toolIds: ["compress", "convert-video"],
|
||||
toolRoutes: ["/tools/image/compress", "/tools/video/convert-video"],
|
||||
alternativeSlug: "cloudconvert",
|
||||
docker: DOCKER_CMD,
|
||||
curl: FULL_API_EXAMPLE,
|
||||
isAiBundle: false,
|
||||
faqs: [
|
||||
{
|
||||
q: "Is the API documented?",
|
||||
a: "Yes. There is an OpenAPI 3.1 spec at /api/v1/openapi.yaml and an interactive reference at /api/docs.",
|
||||
},
|
||||
{
|
||||
q: "How does authentication work?",
|
||||
a: "Create an API key (prefixed si_) and send it as an Authorization: Bearer header. Keys carry scoped permissions.",
|
||||
},
|
||||
{
|
||||
q: "Sync or async?",
|
||||
a: "Fast tools return 200 with a downloadUrl. Long tools return 202 and stream progress over server-sent events.",
|
||||
},
|
||||
],
|
||||
sources: [{ label: "CloudConvert API", url: "https://cloudconvert.com/api/v2" }],
|
||||
lastReviewed: REVIEWED,
|
||||
},
|
||||
{
|
||||
slug: "metadata-removal",
|
||||
pageTitle: "Self-Hosted Metadata Removal (EXIF, PDF)",
|
||||
h1: "Self-hosted metadata removal for images and PDFs",
|
||||
metaDescription:
|
||||
"Strip EXIF and GPS from images and scrub PDF metadata on your own server. A private, self-hosted metadata remover with a REST API. Batch-ready, no upload.",
|
||||
intro:
|
||||
"Photos carry GPS coordinates, device IDs, and timestamps. PDFs carry author names and software fingerprints. SnapOtter strips all of it on your own server, before anything gets shared.",
|
||||
primaryKeyword: "self-hosted metadata removal",
|
||||
whatYouDoToday:
|
||||
"You paste a photo or PDF into an online metadata remover to clean it before publishing or sending. To strip the data, you first hand the original, metadata and all, to the tool.",
|
||||
whyRisky:
|
||||
"Metadata is the leak you forget about: home GPS in a photo, a client's name in a PDF author field, the exact software and version that made a file. Uploading the original to an online scrubber to remove that data means the untouched file, with everything you wanted to hide, reaches a third party first.",
|
||||
howSnapotter:
|
||||
"SnapOtter strips EXIF and GPS from images and scrubs PDF metadata locally. Clean single files or whole batches through the UI or the REST API, with the originals never leaving your network. For documents that need more than metadata removal, the redact-pdf tool permanently removes the content itself.",
|
||||
toolIds: ["strip-metadata", "pdf-metadata"],
|
||||
toolRoutes: ["/tools/image/strip-metadata", "/tools/pdf/pdf-metadata"],
|
||||
docker: DOCKER_CMD,
|
||||
curl: apiExample({
|
||||
isLong: false,
|
||||
section: "image",
|
||||
tool: "strip-metadata",
|
||||
input: "photo.jpg",
|
||||
}),
|
||||
isAiBundle: false,
|
||||
faqs: [
|
||||
{
|
||||
q: "Does it remove GPS location?",
|
||||
a: "Yes. Stripping image metadata removes EXIF including GPS coordinates, device info, and timestamps.",
|
||||
},
|
||||
{
|
||||
q: "Can it batch many files?",
|
||||
a: "Yes, through a pipeline or the REST API, so you can scrub whole folders in one run.",
|
||||
},
|
||||
{
|
||||
q: "What about redaction?",
|
||||
a: "Metadata removal strips hidden data. To remove visible content from a PDF, use the redact-pdf tool, which performs verified true redaction.",
|
||||
},
|
||||
],
|
||||
sources: [],
|
||||
lastReviewed: REVIEWED,
|
||||
},
|
||||
];
|
||||
@@ -14,8 +14,8 @@ interface Props {
|
||||
}
|
||||
|
||||
const toolCount = TOOLS.length;
|
||||
const defaultTitle = `SnapOtter | Self-Hosted File Processing (Image, Video, Audio, PDF, Files)`;
|
||||
const defaultDescription = `Self-hosted, open-source file toolkit. ${toolCount} tools across image, video, audio, PDF, and files, plus a free layer-based image editor. The open-source alternative to Smallpdf, iLovePDF, and CloudConvert, on infrastructure you own.`;
|
||||
const defaultTitle = `SnapOtter | Self-Hosted File-Processing Infrastructure`;
|
||||
const defaultDescription = `Open-source, self-hosted file-processing infrastructure. Convert, compress, OCR, transcribe, and run local AI across image, video, audio, PDF, and documents, via UI, REST API, and pipelines. ${toolCount > 200 ? "200+" : toolCount} tools in one stack. Your files never leave your network.`;
|
||||
|
||||
const {
|
||||
title = defaultTitle,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
---
|
||||
// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes component imports.
|
||||
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||
import EnterpriseCtaBand from "@/components/EnterpriseCtaBand.astro";
|
||||
import Footer from "@/components/Footer.astro";
|
||||
import JsonLd from "@/components/JsonLd.astro";
|
||||
import Navbar from "@/components/Navbar.astro";
|
||||
import QuickstartBlock from "@/components/QuickstartBlock.astro";
|
||||
import RiskCallout from "@/components/RiskCallout.astro";
|
||||
import { ALTERNATIVES } from "@/data/alternatives";
|
||||
import Base from "@/layouts/Base.astro";
|
||||
|
||||
@@ -193,6 +196,39 @@ const allJsonLd = [breadcrumbJsonLd, ...(faqJsonLd ? [faqJsonLd] : [])];
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- What you do today + why risky (optional deepening) -->
|
||||
{(alt.whatYouDoToday || alt.whyRisky) && (
|
||||
<section class="border-t border-border bg-background-alt py-16">
|
||||
<div class="mx-auto max-w-4xl px-6">
|
||||
{alt.whatYouDoToday && (
|
||||
<div class="reveal">
|
||||
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">What you do today with {alt.competitor}</h2>
|
||||
<p class="mt-4 text-sm leading-relaxed text-muted md:text-base">{alt.whatYouDoToday}</p>
|
||||
</div>
|
||||
)}
|
||||
{alt.whyRisky && (
|
||||
<div class="mt-8">
|
||||
<RiskCallout title="Why that matters for sensitive files" body={alt.whyRisky} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<!-- Run it yourself (optional deepening) -->
|
||||
{(alt.dockerSnippet || alt.apiSnippet) && (
|
||||
<section class="py-16">
|
||||
<div class="mx-auto max-w-4xl px-6">
|
||||
<div class="reveal">
|
||||
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">Run it yourself</h2>
|
||||
<div class="mt-8">
|
||||
<QuickstartBlock docker={alt.dockerSnippet ?? ""} curl={alt.apiSnippet} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<!-- FAQs -->
|
||||
{alt.faqs.length > 0 && (
|
||||
<section class="border-t border-border bg-background-alt py-16">
|
||||
@@ -267,6 +303,8 @@ const allJsonLd = [breadcrumbJsonLd, ...(faqJsonLd ? [faqJsonLd] : [])];
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EnterpriseCtaBand />
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
|
||||
@@ -15,9 +15,9 @@ import Base from "@/layouts/Base.astro";
|
||||
|
||||
const toolCount = TOOLS.length;
|
||||
|
||||
const pageTitle = "SnapOtter | Self-Hosted File Processing for Privacy-Sensitive Teams";
|
||||
const pageTitle = "SnapOtter | Self-Hosted File-Processing Infrastructure";
|
||||
const pageDescription =
|
||||
"Self-hosted file processing for privacy-sensitive teams. Image, video, audio, PDF, and file tools, plus a free layer-based image editor, local AI, and enterprise controls on infrastructure you own. The open-source alternative to Smallpdf, iLovePDF, and CloudConvert.";
|
||||
"Open-source, self-hosted file-processing infrastructure. Convert, compress, OCR, transcribe, and run local AI across image, video, audio, PDF, and documents, through a UI, REST API, and pipelines. The private, self-hosted alternative to CloudConvert, Smallpdf, and TinyPNG.";
|
||||
|
||||
const websiteSchema = {
|
||||
"@context": "https://schema.org",
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
---
|
||||
// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes component imports.
|
||||
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||
import EnterpriseCtaBand from "@/components/EnterpriseCtaBand.astro";
|
||||
import Footer from "@/components/Footer.astro";
|
||||
import JsonLd from "@/components/JsonLd.astro";
|
||||
import Navbar from "@/components/Navbar.astro";
|
||||
import QuickstartBlock from "@/components/QuickstartBlock.astro";
|
||||
import RiskCallout from "@/components/RiskCallout.astro";
|
||||
import { ALTERNATIVES } from "@/data/alternatives";
|
||||
import { USE_CASES } from "@/data/use-cases";
|
||||
import Base from "@/layouts/Base.astro";
|
||||
|
||||
export function getStaticPaths() {
|
||||
return USE_CASES.map((uc) => ({ params: { slug: uc.slug }, props: { uc } }));
|
||||
}
|
||||
|
||||
const { uc } = Astro.props;
|
||||
const canonical = `https://snapotter.com/self-hosted/${uc.slug}`;
|
||||
const alt = uc.alternativeSlug
|
||||
? ALTERNATIVES.find((a) => a.slug === uc.alternativeSlug)
|
||||
: undefined;
|
||||
const others = USE_CASES.filter((u) => u.slug !== uc.slug);
|
||||
const reviewedOn = new Intl.DateTimeFormat("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${uc.lastReviewed}T00:00:00Z`));
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Home", item: "https://snapotter.com" },
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: "Self-hosted",
|
||||
item: "https://snapotter.com/self-hosted",
|
||||
},
|
||||
{ "@type": "ListItem", position: 3, name: uc.h1, item: canonical },
|
||||
],
|
||||
};
|
||||
const faqJsonLd =
|
||||
uc.faqs.length > 0
|
||||
? {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
mainEntity: uc.faqs.map((f) => ({
|
||||
"@type": "Question",
|
||||
name: f.q,
|
||||
acceptedAnswer: { "@type": "Answer", text: f.a },
|
||||
})),
|
||||
}
|
||||
: null;
|
||||
const allJsonLd = [breadcrumbJsonLd, ...(faqJsonLd ? [faqJsonLd] : [])];
|
||||
---
|
||||
|
||||
<Base title={`${uc.pageTitle} | SnapOtter`} description={uc.metaDescription} canonical={canonical}>
|
||||
<Fragment slot="head">
|
||||
<JsonLd data={allJsonLd} />
|
||||
</Fragment>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<main id="main">
|
||||
<nav class="mx-auto max-w-4xl px-6 pt-28 md:pt-36" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center gap-2 text-sm text-muted">
|
||||
<li><a href="/" class="transition-colors hover:text-foreground">Home</a></li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li><a href="/self-hosted" class="transition-colors hover:text-foreground">Self-hosted</a></li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li class="font-medium text-foreground">{uc.h1}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="mx-auto max-w-4xl px-6 pt-8 pb-12">
|
||||
<div class="reveal">
|
||||
<h1 class="font-display text-4xl font-bold tracking-tight md:text-5xl">{uc.h1}</h1>
|
||||
<p class="mt-4 text-lg leading-relaxed text-muted md:text-xl">{uc.intro}</p>
|
||||
{uc.isAiBundle && (
|
||||
<p class="mt-4 inline-flex rounded-full border border-primary/20 bg-primary/5 px-3 py-1 text-xs font-semibold text-primary-dark">
|
||||
Local AI, one-time bundle install
|
||||
</p>
|
||||
)}
|
||||
<div class="mt-8 flex flex-wrap gap-4">
|
||||
<a href="https://docs.snapotter.com/guide/getting-started" target="_blank" rel="noopener noreferrer" class="btn-primary inline-flex items-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold">
|
||||
Deploy with Docker
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||
</a>
|
||||
<a href="/contact" class="inline-flex items-center gap-2 rounded-lg border border-border px-6 py-3 text-sm font-semibold transition-colors hover:bg-background-alt">Book a demo</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- What you do today + why risky -->
|
||||
<section class="border-t border-border bg-background-alt py-16">
|
||||
<div class="mx-auto max-w-4xl px-6">
|
||||
<div class="reveal">
|
||||
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">What most teams do today</h2>
|
||||
<p class="mt-4 text-sm leading-relaxed text-muted md:text-base">{uc.whatYouDoToday}</p>
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<RiskCallout title="Why that's a problem for sensitive files" body={uc.whyRisky} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How SnapOtter does it + quickstart -->
|
||||
<section class="py-16">
|
||||
<div class="mx-auto max-w-4xl px-6">
|
||||
<div class="reveal">
|
||||
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">How SnapOtter does it privately</h2>
|
||||
<p class="mt-4 text-sm leading-relaxed text-muted md:text-base">{uc.howSnapotter}</p>
|
||||
<div class="mt-8">
|
||||
<QuickstartBlock docker={uc.docker} curl={uc.curl} />
|
||||
</div>
|
||||
<div class="mt-6 flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
||||
{uc.toolRoutes.map((route, i) => (
|
||||
<a href={route} class="text-primary transition-colors hover:text-primary-dark">
|
||||
Open the {uc.toolIds[i]} tool
|
||||
</a>
|
||||
))}
|
||||
{alt && (
|
||||
<a href={`/alternatives/${alt.slug}`} class="text-primary transition-colors hover:text-primary-dark">
|
||||
Compare with {alt.competitor}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<p class="mt-6 text-xs text-muted">
|
||||
Last reviewed {reviewedOn}. Commands match the current single-container image and REST API.
|
||||
{uc.sources.length > 0 && " Competitor references: "}
|
||||
{uc.sources.map((s, i) => (
|
||||
<Fragment>
|
||||
{i > 0 && ", "}
|
||||
<a href={s.url} target="_blank" rel="noopener noreferrer" class="text-primary hover:text-primary-dark">{s.label}</a>
|
||||
</Fragment>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQs -->
|
||||
{uc.faqs.length > 0 && (
|
||||
<section class="border-t border-border bg-background-alt py-16">
|
||||
<div class="mx-auto max-w-4xl px-6">
|
||||
<div class="reveal">
|
||||
<h2 class="font-display text-2xl font-bold tracking-tight md:text-3xl">Frequently asked questions</h2>
|
||||
<dl class="mt-8 space-y-6">
|
||||
{uc.faqs.map((f) => (
|
||||
<div class="rounded-xl border border-border bg-surface p-6">
|
||||
<dt class="text-sm font-semibold">{f.q}</dt>
|
||||
<dd class="mt-3 text-sm leading-relaxed text-muted">{f.a}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<!-- More self-hosted use cases -->
|
||||
<section class="py-16">
|
||||
<div class="mx-auto max-w-4xl px-6">
|
||||
<div class="reveal">
|
||||
<h2 class="font-display text-2xl font-bold tracking-tight">More self-hosted workflows</h2>
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{others.map((o) => (
|
||||
<a href={`/self-hosted/${o.slug}`} class="group flex flex-col rounded-xl border border-border bg-surface p-5 transition-all hover:border-primary hover:shadow-md">
|
||||
<span class="text-sm font-bold">{o.h1}</span>
|
||||
<span class="mt-1 text-xs text-muted">{o.primaryKeyword}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EnterpriseCtaBand />
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</Base>
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes component imports.
|
||||
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||
import EnterpriseCtaBand from "@/components/EnterpriseCtaBand.astro";
|
||||
import Footer from "@/components/Footer.astro";
|
||||
import Navbar from "@/components/Navbar.astro";
|
||||
import { USE_CASES } from "@/data/use-cases";
|
||||
import Base from "@/layouts/Base.astro";
|
||||
|
||||
const canonical = "https://snapotter.com/self-hosted";
|
||||
const title = "Self-Hosted File Processing | SnapOtter";
|
||||
const description =
|
||||
"Self-hosted, private alternatives to the online file tools your team pastes sensitive files into. Image compression, PDF OCR, video conversion, background removal, transcription, and a REST API, on infrastructure you control.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description} canonical={canonical}>
|
||||
<Navbar />
|
||||
<main id="main">
|
||||
<section class="mx-auto max-w-4xl px-6 pt-28 pb-12 text-center md:pt-36">
|
||||
<div class="reveal">
|
||||
<h1 class="font-display text-4xl font-bold tracking-tight md:text-5xl">Self-hosted file processing</h1>
|
||||
<p class="mx-auto mt-4 max-w-2xl text-lg text-muted md:text-xl">
|
||||
Every online file tool your team pastes sensitive files into, running on infrastructure you control. Open source, air-gap capable, with a REST API.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="py-8">
|
||||
<div class="mx-auto max-w-5xl px-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{USE_CASES.map((uc) => (
|
||||
<a href={`/self-hosted/${uc.slug}`} class="group flex flex-col rounded-xl border border-border bg-surface p-6 transition-all hover:border-primary hover:shadow-md">
|
||||
<span class="font-display text-lg font-bold">{uc.h1}</span>
|
||||
<span class="mt-2 text-sm text-muted">{uc.intro}</span>
|
||||
<span class="mt-4 text-xs font-semibold text-primary">Read more →</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EnterpriseCtaBand />
|
||||
</main>
|
||||
<Footer />
|
||||
</Base>
|
||||
@@ -1,6 +1,6 @@
|
||||
# SnapOtter
|
||||
|
||||
Open-source, self-hostable file processing suite with 241 catalog tool routes across image, video, audio, PDF, and files, plus a layer-based image editor and local AI. Runs as a single container with embedded Postgres + Redis for quick start, or a Docker Compose stack for production. No external services. Dual-licensed AGPLv3 and commercial.
|
||||
SnapOtter is open-source, self-hosted file-processing infrastructure. It runs conversion, compression, OCR, transcription, metadata scrubbing, and local AI across image, video, audio, PDF, and documents, through a web UI, REST API, and automation pipelines. Everything runs on your own infrastructure; files never leave your network. Runs as a single container with embedded Postgres + Redis for quick start, or a Docker Compose stack for production. No external services. Dual-licensed AGPLv3 and commercial. The catalog spans 241 tool routes across the five modalities, plus a layer-based image editor.
|
||||
|
||||
All processing happens locally. Files never leave your infrastructure.
|
||||
|
||||
|
||||
@@ -6,9 +6,7 @@ test.describe("Landing Homepage", () => {
|
||||
});
|
||||
|
||||
test("page loads with self-hosted title", async ({ page }) => {
|
||||
await expect(page).toHaveTitle(
|
||||
/SnapOtter \| Self-Hosted File Processing for Privacy-Sensitive Teams/,
|
||||
);
|
||||
await expect(page).toHaveTitle(/SnapOtter \| Self-Hosted File-Processing Infrastructure/);
|
||||
});
|
||||
|
||||
test("navbar renders brand and navigation links", async ({ page }) => {
|
||||
@@ -23,12 +21,12 @@ test.describe("Landing Homepage", () => {
|
||||
});
|
||||
|
||||
test("hero renders the headline", async ({ page }) => {
|
||||
await expect(page.locator("h1")).toContainText("File processing for privacy-sensitive teams.");
|
||||
await expect(page.locator("h1")).toContainText("Private file processing for data that");
|
||||
});
|
||||
|
||||
test("hero renders the subtitle", async ({ page }) => {
|
||||
await expect(
|
||||
page.getByText("Run image, video, audio, PDF, and file tools with local AI"),
|
||||
page.getByText("run local AI across image, video, audio, PDF, and documents"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const SLUGS = [
|
||||
"image-compressor",
|
||||
"pdf-ocr",
|
||||
"video-converter",
|
||||
"background-removal",
|
||||
"transcription",
|
||||
"file-conversion-api",
|
||||
"metadata-removal",
|
||||
];
|
||||
|
||||
test.describe("Self-hosted hub", () => {
|
||||
test("renders the hub heading and lists use-case cards", async ({ page }) => {
|
||||
const res = await page.goto("/self-hosted");
|
||||
expect(res?.status()).toBeLessThan(400);
|
||||
await expect(page.getByRole("heading", { name: "Self-hosted file processing" })).toBeVisible();
|
||||
await expect(page.locator('a[href^="/self-hosted/"]')).not.toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Self-hosted spokes", () => {
|
||||
for (const slug of SLUGS) {
|
||||
test(`/self-hosted/${slug} renders and sets a unique canonical`, async ({ page }) => {
|
||||
const res = await page.goto(`/self-hosted/${slug}`);
|
||||
expect(res?.status()).toBeLessThan(400);
|
||||
await expect(page.locator("main h1")).toBeVisible();
|
||||
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
|
||||
"href",
|
||||
`https://snapotter.com/self-hosted/${slug}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("pdf-ocr spoke details", () => {
|
||||
test("emits FAQ JSON-LD and links to a real tool page", async ({ page }) => {
|
||||
await page.goto("/self-hosted/pdf-ocr");
|
||||
await expect(page.locator("main h1")).toContainText("Self-hosted PDF OCR");
|
||||
|
||||
const ld = await page.locator('script[type="application/ld+json"]').allTextContents();
|
||||
expect(ld.some((t) => t.includes('"FAQPage"'))).toBe(true);
|
||||
|
||||
const toolLink = page.locator('a[href="/tools/pdf/ocr-pdf"]').first();
|
||||
await expect(toolLink).toBeVisible();
|
||||
const toolRes = await page.request.get("/tools/pdf/ocr-pdf");
|
||||
expect(toolRes.status()).toBeLessThan(400);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user