Tutorial de Go

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.

Respuesta breve

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.

Cómo

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.

La fuerza de Go

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

context.WithTimeout más NewRequestWithContext cancela limpiamente una petición bloqueada.
error_code 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.

Concurrencia en la práctica

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 frecuentes

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.