PDF in Markdown umwandeln mit Go
Ein typisierter, abhängigkeitsfreier Weg, PDFs aus Go in Markdown umzuwandeln. Das Beispiel nutzt nur net/http und encoding/json aus der Standardbibliothek, dekodiert den Job in ein Struct und kompiliert zu einer einzigen statischen Binärdatei, die du in einen Dienst einbauen kannst.
Ein Struct, drei Anfragen
Modelliere den Job als Go-Struct mit json-Tags, dann mache drei net/http-Aufrufe: sende die PDF per POST an /api/v2/jobs, um eine job_id zu erhalten, frage /api/v2/jobs/{job_id} ab, bis Status auf ready steht, und rufe /api/v2/jobs/{job_id}/download per GET für das Markdown ab. Kein SDK, kein cgo, keine GPU – die Konvertierung, das OCR und die Tabellenarbeit passieren alle serverseitig.
Das vollständige Programm
Speichere es als convert.go und führe go run convert.go aus. Nur Standardbibliothek.
// Go 1.21+, standard library only. go run convert.go
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
const api = "https://pdf2md.dev/api/v2"
type job struct {
JobID string `json:"job_id"`
Status string `json:"status"`
ErrorCode string `json:"error_code"`
ErrorMessage string `json:"error_message"`
Truncated bool `json:"truncated"`
}
func authed(ctx context.Context, method, url string, body io.Reader) *http.Request {
req, _ := http.NewRequestWithContext(ctx, method, url, body)
req.Header.Set("Authorization", "Bearer p2m_your_key")
return req
}
func main() {
ctx := context.Background()
// 1) create a job from a PDF URL
payload, _ := json.Marshal(map[string]string{"url": "https://example.com/report.pdf"})
req := authed(ctx, "POST", api+"/jobs", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "report-2026-01")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
var j job
json.NewDecoder(res.Body).Decode(&j)
res.Body.Close()
// 2) poll until ready or error
for j.Status != "ready" && j.Status != "error" {
time.Sleep(3 * time.Second)
res, _ := http.DefaultClient.Do(authed(ctx, "GET", api+"/jobs/"+j.JobID, nil))
json.NewDecoder(res.Body).Decode(&j)
res.Body.Close()
}
if j.Status == "error" {
fmt.Fprintf(os.Stderr, "conversion failed: %s %s\n", j.ErrorCode, j.ErrorMessage)
os.Exit(1)
}
// 3) download the Markdown
res, _ = http.DefaultClient.Do(authed(ctx, "GET", api+"/jobs/"+j.JobID+"/download", nil))
md, _ := io.ReadAll(res.Body)
res.Body.Close()
os.WriteFile("report.md", md, 0o644)
fmt.Println("saved report.md")
}
Warum ein Struct: die Dekodierung in einen typisierten job gibt dir Compile-Zeit-Sicherheit bei Status, ErrorCode und Truncated, und der Decoder ignoriert einfach alle Antwortfelder, die du nicht modellierst.
Wandle viele PDFs gleichzeitig um
Wo Go glänzt, ist beim Fan-out. Um einen Stapel zu konvertieren, führe die Anlegen-Abfragen-Herunterladen-Abfolge jeder Datei in einer Goroutine aus und begrenze sie mit einem Semaphor, um die API nicht zu überlasten.
Begrenzter Worker-Pool
Nutze einen gepufferten Channel als Semaphor: sem := make(chan struct{}, 5). Jede Goroutine belegt einen Slot, bevor sie startet, und gibt ihn beim Beenden frei, so laufen höchstens fünf Konvertierungen gleichzeitig. Sammle die Ergebnisse mit einer sync.WaitGroup.
Context und Timeouts
NewRequestWithContext bricht eine hängende Anfrage sauber ab.processing_timeout oder conversion_failed sagt dir, warum ein Job fehlgeschlagen ist; truncated markiert ein Teilergebnis.Bevorzugst du eine andere Sprache? Sieh dir das Node.js-Tutorial, das Python-Tutorial oder das cURL-Rezept an.
Baust du es in einen Dienst ein?
Die Konvertierung ist auch ein gehosteter MCP-Endpunkt, sodass ein Go-basierter Agent sie als Tool aufrufen kann. Die vollständige Referenz und die OpenAPI-Spezifikation findest du im Entwickler-Hub.
Ein begrenzter Worker-Pool
Kapsele den Einzeldatei-Ablauf von oben in convertOne und fächere dann über eine Liste von PDFs mit einem Semaphor auf, das begrenzt, wie viele gleichzeitig laufen.
// convertOne runs the create -> poll -> download flow for one URL.
func convertAll(ctx context.Context, urls []string) {
sem := make(chan struct{}, 5) // at most 5 conversions in flight
var wg sync.WaitGroup
for _, u := range urls {
wg.Add(1)
go func(url string) {
defer wg.Done()
sem <- struct{}{} // take a slot (blocks if full)
defer func() { <-sem }() // release it
if err := convertOne(ctx, url); err != nil {
log.Printf("skip %s: %v", url, err)
}
}(u)
}
wg.Wait()
}
Der gepufferte Channel ist der gesamte Ratenbegrenzer: eine Goroutine kann die eigentliche Arbeit nicht beginnen, bevor sie etwas in sem geschoben hat, und es existieren nur fünf Tokens. Erhöhe den Puffer für mehr Durchsatz auf einem höheren Tarif, verringere ihn, um den Gratis-Tarif zu schonen. Kombiniere das mit einem Idempotency-Key pro Datei, damit ein erneuter Versuch nach einem Absturz nicht neu konvertiert, was bereits fertig war, und derselbe Pool bewältigt einen Ordner mit zehn PDFs oder zehntausend.
Häufige Fragen
Braucht das Go-Beispiel Abhängigkeiten?
Nein. Es nutzt nur die Standardbibliothek (net/http und encoding/json), daher funktioniert go run convert.go ohne hinzuzufügende Module und kompiliert zu einer einzigen statischen Binärdatei.
Wie mappe ich die JSON-Antwort in Go?
Definiere ein Struct mit json-Tags für job_id, status, error_code, error_message und truncated, dann dekodiere mit json.NewDecoder. Felder, die du nicht aufführst, werden einfach ignoriert.
Wie konvertiere ich viele PDFs nebenläufig in Go?
Führe die Abfolge aus Anlegen, Abfragen und Herunterladen in Goroutinen aus und begrenze sie mit einem gepufferten Channel, der als Semaphor dient, so konvertierst du mehrere Dateien gleichzeitig, ohne die API zu überlasten.
Wie füge ich ein Timeout oder eine Abbruchmöglichkeit hinzu?
Verwende context.WithTimeout mit http.NewRequestWithContext, damit eine hängende Anfrage abgebrochen wird. Der Server erzwingt zudem sein eigenes Zeitbudget und liefert error_code processing_timeout, wenn ein Dokument zu groß ist.
Wie lade ich eine lokale PDF-Datei in Go hoch?
Baue einen multipart/form-data-Body mit mime/multipart, schreibe die PDF in ein file-Feld, setze den Content-Type auf die Multipart-Boundary und sende ihn per POST an /api/v2/jobs statt des JSON-Bodys mit url.