modifica fluxo de salvamento dos modelos e altera a biblioteca de leitura de pdf para pdfplumber

This commit is contained in:
2025-06-30 13:27:17 -04:00
parent 23e19c3ee1
commit f5ceea0dae

View File

@ -3,16 +3,21 @@ from urllib.parse import urlparse
import requests import requests
from babel.dates import format_date from babel.dates import format_date
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.db import models, transaction from django.db import models
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
import fitz # PyMuPDF import PyPDF2
import pdfplumber
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
import fitz
class TipoDiarioOficial(models.Model): class TipoDiarioOficial(models.Model):
"""Representa um tipo de Diário Oficial (e.g., Municipal, Estadual, Federal)."""
nome = models.CharField(max_length=100, unique=True) nome = models.CharField(max_length=100, unique=True)
def __str__(self): def __str__(self):
"""Retorna o nome do tipo de Diário Oficial."""
return self.nome return self.nome
class Meta: class Meta:
@ -20,6 +25,16 @@ class TipoDiarioOficial(models.Model):
class DiarioOficial(models.Model): class DiarioOficial(models.Model):
"""Modelo que representa um Diário Oficial, contendo data, arquivo PDF, tipo e link.
Attributes:
data (DateField): Data de publicação do Diário Oficial.
arquivo (FileField): Arquivo PDF do Diário Oficial (opcional).
tipo (ForeignKey): Tipo do Diário Oficial (Municipal, Estadual, etc.).
numero (CharField): Número de identificação único do Diário.
link (URLField): URL para o Diário Oficial (opcional).
"""
data = models.DateField() data = models.DateField()
arquivo = models.FileField(upload_to="diarios_oficiais/", blank=True, null=True) arquivo = models.FileField(upload_to="diarios_oficiais/", blank=True, null=True)
tipo = models.ForeignKey( tipo = models.ForeignKey(
@ -33,30 +48,34 @@ class DiarioOficial(models.Model):
link = models.URLField(blank=True, null=True, unique=True) link = models.URLField(blank=True, null=True, unique=True)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
updated = False """Salva o Diário Oficial, baixa o PDF (se houver link) e extrai páginas."""
super().save(*args, **kwargs) super().save(*args, **kwargs)
if self.link and not self.arquivo: if self.link and not self.arquivo:
self._download_pdf_from_link() self._download_pdf_from_link()
updated = True
if self.arquivo and not self.paginas.exists(): if self.arquivo and not self.paginas:
self._extract_pdf_pages() self._extract_pdf_pages()
updated = True
if updated: super().save(*args, **kwargs)
super().save(*args, **kwargs)
def clean(self): def clean(self):
"""Valida o modelo antes de salvar (chamado automaticamente no admin/form)."""
super().clean() super().clean()
if not self.arquivo and not self.link: if not self.arquivo and not self.link:
raise ValidationError("Informe um arquivo ou um link para o Diário.") raise ValidationError("Informe um arquivo ou um link para o Diário.")
def _validar_link(self): def _validar_link(self):
"""Verifica se o link é um PDF válido."""
if not self.link.lower().endswith(".pdf"): if not self.link.lower().endswith(".pdf"):
raise ValidationError("O link deve apontar para um arquivo PDF.") raise ValidationError("O link deve apontar para um arquivo PDF.")
def _download_pdf_from_link(self): def _download_pdf_from_link(self):
"""Faz download do PDF a partir do link e salva no campo `arquivo`.
Raises:
ValidationError: Se o download falhar.
"""
try: try:
response = requests.get(self.link) response = requests.get(self.link)
response.raise_for_status() response.raise_for_status()
@ -68,74 +87,77 @@ class DiarioOficial(models.Model):
except requests.RequestException as e: except requests.RequestException as e:
raise ValidationError(f"Não foi possível baixar o PDF: {e}") raise ValidationError(f"Não foi possível baixar o PDF: {e}")
def _extract_pdf_pages(self): def _extract_pdf_pages(self):
"""Extrai o texto de cada página do PDF usando PyMuPDF."""
try: try:
# Salvar temporariamente o PDF para abrir com o PyMuPDF
with self.arquivo.open("rb") as pdf_file: with self.arquivo.open("rb") as pdf_file:
temp_pdf_path = f"/tmp/diario_{self.id}.pdf" pdf_document = fitz.open(stream=pdf_file.read(), filetype="pdf")
with open(temp_pdf_path, "wb") as temp_file: self.paginas.all().delete()
temp_file.write(pdf_file.read())
# Abrir e processar com fitz for i, page in enumerate(pdf_document):
doc = fitz.open(temp_pdf_path) try:
self._process_pdf_pages(doc) # Extração simples
doc.close() page_text = page.get_text("text")
# Você pode experimentar com outros métodos:
# page.get_text("blocks")
# page.get_text("words")
# para melhor tratamento de duas colunas
# Remover arquivo temporário if page_text and page_text.strip():
os.remove(temp_pdf_path) PageDiarioOficial.objects.create(
diario=self,
numero=i + 1,
conteudo=page_text.strip(),
)
except Exception as page_error:
PageDiarioOficial.objects.create(
diario=self,
numero=i + 1,
conteudo=f"[Erro na extração do texto: {str(page_error)}]",
)
except Exception as pdf_error: except Exception as pdf_error:
raise ValidationError(f"Não foi possível processar o PDF: {pdf_error}") raise ValidationError(f"Não foi possível processar o PDF: {pdf_error}")
def _process_pdf_pages(self, doc): def _process_pdf_pages(self, pdf):
with transaction.atomic(): """Processa cada página do PDF e salva seu conteúdo.
self.paginas.all().delete()
for i, page in enumerate(doc): Args:
try: pdf (PdfReader): Objeto PDF carregado.
blocks = page.get_text("blocks") """
# Ordenar os blocos por coordenadas (y, x) para manter a ordem de leitura self.paginas.all().delete()
blocks.sort(key=lambda b: (b[1], b[0]))
page_text = ""
for block in blocks:
text = block[4].strip()
if text:
page_text += text + "\n"
# Crucial: Remove NULL bytes from the extracted text for i, pagina in enumerate(pdf.pages):
# PostgreSQL text fields cannot contain NUL (0x00) bytes try:
cleaned_text = page_text.strip().replace('\x00', '') page_text = pagina.extract_text()
if page_text and page_text.strip():
if cleaned_text:
PageDiarioOficial.objects.create(
diario=self,
numero=i + 1,
conteudo=cleaned_text,
)
else:
PageDiarioOficial.objects.create(
diario=self,
numero=i + 1,
conteudo="[Conteúdo não extraído ou vazio]",
)
except Exception as page_error:
PageDiarioOficial.objects.create( PageDiarioOficial.objects.create(
diario=self, diario=self,
numero=i + 1, numero=i + 1,
conteudo=f"[Erro na extração do texto: {str(page_error)}]", conteudo=page_text.strip(),
) )
print(f"Erro ao processar a página {i+1} no Diario ID {self.id}: {page_error}") except Exception as page_error:
PageDiarioOficial.objects.create(
diario=self,
numero=i + 1,
conteudo=f"[Erro na extração do texto: {str(page_error)}]",
)
continue
@property @property
def data_formatada(self): def data_formatada(self):
"""Retorna a data formatada em português (e.g., '1 de Janeiro de 2023')."""
return format_date(self.data, format="long", locale="pt_BR") return format_date(self.data, format="long", locale="pt_BR")
@property @property
def is_online(self): def is_online(self):
"""Verifica se o Diário possui um link (online)."""
return bool(self.link) return bool(self.link)
def __str__(self): def __str__(self):
"""Representação em string do Diário Oficial."""
tipo_nome = self.tipo.nome if self.tipo else "Sem Tipo" tipo_nome = self.tipo.nome if self.tipo else "Sem Tipo"
return f"Diário {tipo_nome}{self.numero}, {self.data_formatada}" return f"Diário {tipo_nome}{self.numero}, {self.data_formatada}"
@ -145,6 +167,15 @@ class DiarioOficial(models.Model):
class PageDiarioOficial(models.Model): class PageDiarioOficial(models.Model):
"""Representa uma página de um Diário Oficial com seu conteúdo textual.
Attributes:
diario (ForeignKey): Diário Oficial associado.
layout_duas_colunas (BooleanField): Indica se a página tem duas colunas.
numero (PositiveIntegerField): Número da página no Diário.
conteudo (TextField): Texto extraído da página.
"""
diario = models.ForeignKey( diario = models.ForeignKey(
DiarioOficial, on_delete=models.CASCADE, related_name="paginas" DiarioOficial, on_delete=models.CASCADE, related_name="paginas"
) )
@ -157,11 +188,17 @@ class PageDiarioOficial(models.Model):
verbose_name = "Página de Diário Oficial" verbose_name = "Página de Diário Oficial"
verbose_name_plural = "Páginas de Diários Oficiais" verbose_name_plural = "Páginas de Diários Oficiais"
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.diario:
self.diario.save()
def __str__(self): def __str__(self):
"""Representação em string da página (e.g., 'Página 1 do Diário 123')."""
return f"Página {self.numero} do Diário {self.diario.numero}" return f"Página {self.numero} do Diário {self.diario.numero}"
def save(self, *args, **kwargs):
from diarios.documents import DiarioOficialDocument
super().save(*args, **kwargs)
try:
print(f"Reindexando diario {self.diario}")
DiarioOficialDocument().update(self.diario)
except Exception as e:
print(f"Erro ao reindexar DiarioOficial {self.diario.id} (pai da página {self.id}) no Elasticsearch: {e}")