Convertir PDF a Markdown en Go
Una forma tipada y sin dependencias de convertir PDF en Markdown desde Go. El ejemplo usa solo net/http y encoding/json de la librería estándar, decodifica el trabajo en un struct y compila a un único binario estático que puedes meter en un servicio.
Un struct, tres peticiones
Modela el trabajo como un struct de Go con tags json, luego haz tres llamadas con net/http: POST del PDF a /api/v2/jobs para obtener un job_id, consulta /api/v2/jobs/{job_id} hasta que Status sea ready, y haz GET de /api/v2/jobs/{job_id}/download para el Markdown. Sin SDK, sin cgo, sin GPU: la conversión, el OCR y el trabajo de tablas ocurren todos en el servidor.
El programa completo
Guárdalo como convert.go y ejecuta go run convert.go. Solo librería estándar.
// Go 1.21+, solo librería estándar. 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) crear un trabajo desde una URL de PDF
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) consultar hasta ready o 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) descargar el 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("guardado report.md")
}
Por qué un struct: decodificar en un job tipado te da seguridad en tiempo de compilación sobre Status, ErrorCode y Truncated, y el decodificador simplemente ignora cualquier campo de la respuesta que no modeles.
Convierte muchos PDF a la vez
Donde Go brilla es en el fan-out. Para convertir un lote, ejecuta la secuencia de crear-consultar-descargar de cada archivo en una goroutine y acótalas con un semáforo para no saturar la API.
Pool de workers acotado
Usa un canal con búfer como semáforo: sem := make(chan struct{}, 5). Cada goroutine toma un hueco antes de empezar y lo libera al terminar, así corren como mucho cinco conversiones a la vez. Recoge los resultados con un sync.WaitGroup.
Context y tiempos de espera
NewRequestWithContext cancela limpiamente una petición bloqueada.processing_timeout o conversion_failed te dice por qué falló un trabajo; truncated marca un resultado parcial.¿Prefieres otro lenguaje? Consulta el tutorial de Node.js, el tutorial de Python, o la receta de cURL.
Un pool de workers acotado
Envuelve el flujo de un solo archivo de arriba en convertOne, luego abre el abanico sobre una lista de PDF con un semáforo que limita cuántos corren a la vez.
// convertOne ejecuta el flujo crear -> consultar -> descargar de una URL.
func convertAll(ctx context.Context, urls []string) {
sem := make(chan struct{}, 5) // como mucho 5 conversiones a la vez
var wg sync.WaitGroup
for _, u := range urls {
wg.Add(1)
go func(url string) {
defer wg.Done()
sem <- struct{}{} // toma un hueco (bloquea si está lleno)
defer func() { <-sem }() // libéralo
if err := convertOne(ctx, url); err != nil {
log.Printf("skip %s: %v", url, err)
}
}(u)
}
wg.Wait()
}
El canal con búfer es todo el limitador de velocidad: una goroutine no puede empezar el trabajo real hasta que haya metido algo en sem, y solo existen cinco fichas. Aumenta el búfer para más rendimiento en un plan superior, redúcelo para ser suave con el plan gratuito. Combínalo con un Idempotency-Key por archivo para que un reintento tras un fallo no reconvierta lo ya terminado, y el mismo pool maneja una carpeta de diez PDF o de diez mil.
Preguntas habituales
¿El ejemplo de Go necesita dependencias?
No. Usa solo la librería estándar (net/http y encoding/json), así go run convert.go funciona sin módulos que añadir y compila a un único binario estático.
¿Cómo mapeo la respuesta JSON en Go?
Define un struct con tags json para job_id, status, error_code, error_message y truncated, luego decodifica con json.NewDecoder. Los campos que no listes simplemente se ignoran.
¿Cómo convierto muchos PDF de forma concurrente en Go?
Ejecuta la secuencia de crear, consultar y descargar dentro de goroutines y acótalas con un canal con búfer usado como semáforo, así conviertes varios archivos a la vez sin saturar la API.
¿Cómo añado un tiempo de espera o cancelación?
Usa context.WithTimeout con http.NewRequestWithContext para que una petición bloqueada se cancele. El servidor también impone su propio presupuesto de tiempo y devuelve error_code processing_timeout cuando un documento es demasiado grande.
¿Cómo subo un archivo PDF local en Go?
Construye un cuerpo multipart/form-data con mime/multipart, escribe el PDF en un campo file, pon el Content-Type al boundary del multipart, y haz POST a /api/v2/jobs en vez del cuerpo JSON con url.