From f5ceea0dae5270ed840287efec309907bc0a994f Mon Sep 17 00:00:00 2001 From: Antonio Roberto Date: Mon, 30 Jun 2025 13:27:17 -0400 Subject: [PATCH] modifica fluxo de salvamento dos modelos e altera a biblioteca de leitura de pdf para pdfplumber --- diarios/models.py | 149 +++++++++++++++++++++++++++++----------------- 1 file changed, 93 insertions(+), 56 deletions(-) diff --git a/diarios/models.py b/diarios/models.py index bd448f1..06435a7 100644 --- a/diarios/models.py +++ b/diarios/models.py @@ -3,16 +3,21 @@ from urllib.parse import urlparse import requests from babel.dates import format_date from django.core.files.base import ContentFile -from django.db import models, transaction +from django.db import models from django.core.exceptions import ValidationError -import fitz # PyMuPDF +import PyPDF2 +import pdfplumber from asgiref.sync import async_to_sync +import fitz class TipoDiarioOficial(models.Model): + """Representa um tipo de Diário Oficial (e.g., Municipal, Estadual, Federal).""" + nome = models.CharField(max_length=100, unique=True) def __str__(self): + """Retorna o nome do tipo de Diário Oficial.""" return self.nome class Meta: @@ -20,6 +25,16 @@ class TipoDiarioOficial(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() arquivo = models.FileField(upload_to="diarios_oficiais/", blank=True, null=True) tipo = models.ForeignKey( @@ -33,30 +48,34 @@ class DiarioOficial(models.Model): link = models.URLField(blank=True, null=True, unique=True) 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) if self.link and not self.arquivo: 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() - updated = True - if updated: - super().save(*args, **kwargs) + super().save(*args, **kwargs) def clean(self): + """Valida o modelo antes de salvar (chamado automaticamente no admin/form).""" super().clean() if not self.arquivo and not self.link: raise ValidationError("Informe um arquivo ou um link para o Diário.") def _validar_link(self): + """Verifica se o link é um PDF válido.""" if not self.link.lower().endswith(".pdf"): raise ValidationError("O link deve apontar para um arquivo PDF.") 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: response = requests.get(self.link) response.raise_for_status() @@ -68,74 +87,77 @@ class DiarioOficial(models.Model): except requests.RequestException as e: raise ValidationError(f"Não foi possível baixar o PDF: {e}") + def _extract_pdf_pages(self): + """Extrai o texto de cada página do PDF usando PyMuPDF.""" try: - # Salvar temporariamente o PDF para abrir com o PyMuPDF with self.arquivo.open("rb") as pdf_file: - temp_pdf_path = f"/tmp/diario_{self.id}.pdf" - with open(temp_pdf_path, "wb") as temp_file: - temp_file.write(pdf_file.read()) + pdf_document = fitz.open(stream=pdf_file.read(), filetype="pdf") + self.paginas.all().delete() - # Abrir e processar com fitz - doc = fitz.open(temp_pdf_path) - self._process_pdf_pages(doc) - doc.close() + for i, page in enumerate(pdf_document): + try: + # Extração simples + 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 - os.remove(temp_pdf_path) + if page_text and page_text.strip(): + 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: raise ValidationError(f"Não foi possível processar o PDF: {pdf_error}") - def _process_pdf_pages(self, doc): - with transaction.atomic(): - self.paginas.all().delete() + def _process_pdf_pages(self, pdf): + """Processa cada página do PDF e salva seu conteúdo. - for i, page in enumerate(doc): - try: - blocks = page.get_text("blocks") - # Ordenar os blocos por coordenadas (y, x) para manter a ordem de leitura - 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" + Args: + pdf (PdfReader): Objeto PDF carregado. + """ + self.paginas.all().delete() - # Crucial: Remove NULL bytes from the extracted text - # PostgreSQL text fields cannot contain NUL (0x00) bytes - cleaned_text = page_text.strip().replace('\x00', '') - - 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: + for i, pagina in enumerate(pdf.pages): + try: + page_text = pagina.extract_text() + if page_text and page_text.strip(): PageDiarioOficial.objects.create( diario=self, 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 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") @property def is_online(self): + """Verifica se o Diário possui um link (online).""" return bool(self.link) def __str__(self): + """Representação em string do Diário Oficial.""" tipo_nome = self.tipo.nome if self.tipo else "Sem Tipo" return f"Diário {tipo_nome} nº {self.numero}, {self.data_formatada}" @@ -145,6 +167,15 @@ class DiarioOficial(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( 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_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): + """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}" + 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}") +