Converter PDF para Markdown em Go
Uma forma tipada e sem dependências de converter PDF em Markdown a partir de Go. O exemplo usa apenas net/http e encoding/json da biblioteca padrão, decodifica o trabalho em um struct e compila para um único binário estático que você pode colocar em um serviço.
Um struct, três requisições
Modele o trabalho como um struct de Go com tags json, depois faça três chamadas com net/http: POST do PDF para /api/v2/jobs para obter um job_id, consulte /api/v2/jobs/{job_id} até que Status seja ready, e faça GET de /api/v2/jobs/{job_id}/download para o Markdown. Sem SDK, sem cgo, sem GPU: a conversão, o OCR e o trabalho de tabelas acontecem todos no servidor.
O programa completo
Salve como convert.go e execute go run convert.go. Apenas a biblioteca padrão.
// 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")
}
Por que um struct: decodificar em um job tipado te dá segurança em tempo de compilação sobre Status, ErrorCode e Truncated, e o decodificador simplesmente ignora qualquer campo da resposta que você não modele.
Converta muitos PDFs de uma vez
Onde Go brilha é no fan-out. Para converter um lote, execute a sequência de criar-consultar-baixar de cada arquivo em uma goroutine e limite-as com um semáforo para não sobrecarregar a API.
Pool de workers limitado
Use um canal com buffer como semáforo: sem := make(chan struct{}, 5). Cada goroutine pega um espaço antes de começar e o libera ao terminar, assim no máximo cinco conversões rodam ao mesmo tempo. Recolha os resultados com um sync.WaitGroup.
Context e tempos limite
NewRequestWithContext cancela de forma limpa uma requisição travada.processing_timeout ou conversion_failed te diz por que um trabalho falhou; truncated marca um resultado parcial.Prefere outra linguagem? Veja o tutorial de Node.js, o tutorial de Python, ou a receita de cURL.
Integrando em um serviço?
A conversão também é um endpoint MCP hospedado, então um agente baseado em Go pode chamá-la como uma ferramenta. A referência completa e a especificação OpenAPI estão no hub para desenvolvedores.
Um pool de workers limitado
Envolva o fluxo de um único arquivo de cima em convertOne, depois abra o leque sobre uma lista de PDFs com um semáforo que limita quantos rodam ao mesmo tempo.
// 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()
}
O canal com buffer é todo o limitador de velocidade: uma goroutine não pode começar o trabalho real até ter colocado algo em sem, e só existem cinco fichas. Aumente o buffer para mais vazão em um plano superior, reduza-o para ser gentil com o plano gratuito. Combine isso com um Idempotency-Key por arquivo para que uma nova tentativa após uma falha não reconverta o que já terminou, e o mesmo pool lida com uma pasta de dez PDFs ou de dez mil.
Perguntas comuns
O exemplo de Go precisa de dependências?
Não. Ele usa apenas a biblioteca padrão (net/http e encoding/json), então go run convert.go funciona sem módulos para adicionar e compila para um único binário estático.
Como mapeio a resposta JSON em Go?
Defina um struct com tags json para job_id, status, error_code, error_message e truncated, depois decodifique com json.NewDecoder. Os campos que você não listar são simplesmente ignorados.
Como converto muitos PDFs de forma concorrente em Go?
Execute a sequência de criar, consultar e baixar dentro de goroutines e limite-as com um canal com buffer usado como semáforo, assim você converte vários arquivos de uma vez sem sobrecarregar a API.
Como adiciono um tempo limite ou cancelamento?
Use context.WithTimeout com http.NewRequestWithContext para que uma requisição travada seja cancelada. O servidor também impõe seu próprio orçamento de tempo e retorna error_code processing_timeout quando um documento é grande demais.
Como envio um arquivo PDF local em Go?
Monte um corpo multipart/form-data com mime/multipart, escreva o PDF em um campo file, defina o Content-Type para o boundary do multipart, e faça POST para /api/v2/jobs em vez do corpo JSON com url.