modifica fluxo de salvamento dos modelos e altera a biblioteca de leitura de pdf para pdfplumber
This commit is contained in:
@ -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} nº {self.numero}, {self.data_formatada}"
|
return f"Diário {tipo_nome} nº {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}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user