diff --git a/config/settings/base.py b/config/settings/base.py index 25df074..9006d99 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -84,6 +84,7 @@ LOCAL_APPS = [ "diários_oficiais_alems.users", "diarios", "django_elasticsearch_dsl", + "rest_framework", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS @@ -293,3 +294,36 @@ ELASTICSEARCH_DSL = { "default": {"hosts": "http://elasticsearch:9200"}, # same as above } ELASTICSEARCH_HOSTS = "http://elasticsearch:9200" + +ELASTICSEARCH_INDEX_SETTINGS = { + 'number_of_shards': 1, + 'number_of_replicas': 0, + 'analysis': { + 'filter': { + 'portuguese_stop': { + 'type': 'stop', + 'stopwords': '_portuguese_' + }, + 'portuguese_stemmer': { + 'type': 'stemmer', + 'language': 'portuguese' + }, + 'synonym_filter': { + 'type': 'synonym', + 'synonyms_path': 'analysis/sinonimos.txt', + } + }, + 'analyzer': { + 'pt_analyzer': { + 'tokenizer': 'standard', + 'filter': [ + 'lowercase', + 'portuguese_stop', + 'portuguese_stemmer', + 'synonym_filter' + ] + } + } + } +} + diff --git a/diarios/admin.py b/diarios/admin.py index 4641b0b..96074c4 100644 --- a/diarios/admin.py +++ b/diarios/admin.py @@ -1,8 +1,18 @@ from django.contrib import admin -from .models import PDFDocument +from .models import PDFDocument, DiarioOficial, TipoDiarioOficial from django.db import models @admin.register(PDFDocument) class PDFDocumentAdmin(admin.ModelAdmin): pass + + +@admin.register(DiarioOficial) +class DiarioOficialAdmin(admin.ModelAdmin): + pass + + +@admin.register(TipoDiarioOficial) +class TipoDiarioOficialAdmin(admin.ModelAdmin): + pass diff --git a/diarios/custom_filters.py b/diarios/custom_filters.py new file mode 100644 index 0000000..7f0b7d1 --- /dev/null +++ b/diarios/custom_filters.py @@ -0,0 +1,8 @@ +from django import template + +register = template.Library() + +@register.filter +def get_range(value): + return range(value) + diff --git a/diarios/documents.py b/diarios/documents.py index a05ea81..e4f7fbb 100644 --- a/diarios/documents.py +++ b/diarios/documents.py @@ -1,157 +1,91 @@ -from django_elasticsearch_dsl import Document, fields -from django_elasticsearch_dsl.registries import registry -from .models import PDFDocument - - -@registry.register_document -class PDFDocumentDocument(Document): - title = fields.TextField() - content = fields.TextField(analyzer="portuguese") - pages = fields.NestedField( - properties={ - "number": fields.IntegerField(), - "content": fields.TextField(analyzer="portuguese"), - } - ) - - class Index: - name = "pdf_documents" - settings = { - "number_of_shards": 1, - "number_of_replicas": 0, - "analysis": { - "analyzer": { - "portuguese": { - "type": "custom", - "tokenizer": "standard", - "filter": [ - "lowercase", - "ascii_folding", - "portuguese_stemmer", - "stop", - "portuguese_synonyms", - ], - }, - "portuguese_search": { - "type": "custom", - "tokenizer": "standard", - "filter": [ - "lowercase", - "ascii_folding", - "portuguese_stemmer", - "stop", - "suggest_shingle", - ], - }, - }, - "filter": { - "suggest_shingle": { - "type": "shingle", - "min_shingle_size": 2, - "max_shingle_size": 3, - }, - "stop": {"type": "stop", "stopwords": "_portuguese_"}, - "ascii_folding": {"type": "asciifolding"}, - "portuguese_stemmer": {"type": "stemmer", "language": "portuguese"}, - "portuguese_synonyms": { - "type": "synonym", - "synonyms_path": "synonyms.txt", - "expand": True, - }, - }, - }, - } - - class Django: - model = PDFDocument - fields = ["uploaded_at", "file"] - from django_elasticsearch_dsl import Document, fields from django_elasticsearch_dsl.registries import registry from .models import DiarioOficial @registry.register_document class DiarioOficialDocument(Document): - # Campos principais - title = fields.TextField() - tipo = fields.KeywordField() - - # Campo para arquivo PDF (se aplicável) - arquivo = fields.TextField(attr="arquivo.url") - - # Nested field para páginas (usando o page_content) - pages = fields.NestedField( - properties={ - "number": fields.IntegerField(), - "content": fields.TextField(analyzer="portuguese") - } + tipo = fields.ObjectField(properties={ + 'nome': fields.TextField() + }) + + numero = fields.TextField() + data = fields.DateField() + link = fields.TextField() + + # Campo para armazenar todas as páginas para busca + content = fields.TextField( + analyzer='pt_analyzer', ) + + # Campo para armazenar páginas individualmente + pages = fields.NestedField(properties={ + 'number': fields.IntegerField(), + 'content': fields.TextField( + analyzer='pt_analyzer', + ) + }) class Index: - name = "diarios_oficiais" + name = 'diarios_oficiais' settings = { - "number_of_shards": 1, - "number_of_replicas": 0, - "analysis": { - "analyzer": { - "portuguese": { - "type": "custom", - "tokenizer": "standard", - "filter": [ - "lowercase", - "ascii_folding", - "portuguese_stemmer", - "stop", - "portuguese_synonyms", - ] + 'number_of_shards': 1, + 'number_of_replicas': 0, + 'analysis': { + 'filter': { + 'portuguese_stop': { + 'type': 'stop', + 'stopwords': '_portuguese_' }, - "portuguese_search": { - "type": "custom", - "tokenizer": "standard", - "filter": [ - "lowercase", - "ascii_folding", - "portuguese_stemmer", - "stop", - "suggest_shingle", + 'portuguese_stemmer': { + 'type': 'stemmer', + 'language': 'portuguese' + }, + 'synonym_filter': { + 'type': 'synonym', + 'synonyms': [ + 'lei, legislação, norma', + 'processo, procedimento, autos', + 'contrato, acordo, convênio', + # Adicione mais sinônimos relevantes para o contexto legal ] } }, - "filter": { - "suggest_shingle": { - "type": "shingle", - "min_shingle_size": 2, - "max_shingle_size": 3 - }, - "stop": {"type": "stop", "stopwords": "_portuguese_"}, - "ascii_folding": {"type": "asciifolding"}, - "portuguese_stemmer": {"type": "stemmer", "language": "portuguese"}, - "portuguese_synonyms": { - "type": "synonym", - "synonyms_path": "synonyms.txt", - "expand": True + 'analyzer': { + 'pt_analyzer': { + 'tokenizer': 'standard', + 'filter': [ + 'lowercase', + 'portuguese_stop', + 'portuguese_stemmer', + 'synonym_filter' + ] } } } } - + class Django: model = DiarioOficial fields = [ - "data", - "numero", - "link", + 'id' ] - + def prepare_tipo(self, instance): - return instance.tipo.nome if instance.tipo else None - - def prepare_title(self, instance): - return f"{instance.tipo.nome if instance.tipo else 'Diário'} {instance.numero}" - - def prepare_pages(self, instance): - # Prepara o campo pages usando o page_content + if instance.tipo: + return { + 'nome': instance.tipo.nome + } + return {} + + def prepare_content(self, instance): + """Concatena todo o conteúdo de todas as páginas em um único campo para busca""" if instance.page_content: - return instance.page_content # page_content já é uma lista de dicionários + return " ".join([page.get('content', '') for page in instance.page_content]) + return "" + + def prepare_pages(self, instance): + """Prepara o campo de páginas individuais para exibição e destaque""" + if instance.page_content: + return instance.page_content return [] diff --git a/diarios/management/commands/reindex_diarios.py b/diarios/management/commands/reindex_diarios.py new file mode 100644 index 0000000..6ccf046 --- /dev/null +++ b/diarios/management/commands/reindex_diarios.py @@ -0,0 +1,24 @@ +from django.core.management.base import BaseCommand +from django_elasticsearch_dsl.registries import registry + +class Command(BaseCommand): + help = 'Reindexar todos os Diários Oficiais no Elasticsearch' + + def handle(self, *args, **options): + self.stdout.write('Iniciando reindexação...') + + # Recria os índices + registry.delete_indices() + registry.create_indices() + + # Reindexar documentos + for index in registry.get_indices(): + self.stdout.write(f'Reindexando {index}...') + documents = [] + for doc in registry.get_documents(): + if index == doc._index._name: + self.stdout.write(f' + {doc.__name__}') + doc().update() + + self.stdout.write(self.style.SUCCESS('Reindexação concluída!')) + diff --git a/diarios/models.py b/diarios/models.py index 3afc642..1d013a4 100644 --- a/diarios/models.py +++ b/diarios/models.py @@ -1,7 +1,12 @@ +import requests +import os +from urllib.parse import urlparse +from django.core.files.base import ContentFile from django.db import models import PyPDF2 import json from django.core.serializers.json import DjangoJSONEncoder +from babel.dates import format_date class PDFDocument(models.Model): @@ -34,6 +39,7 @@ class PDFDocument(models.Model): super().save(*args, **kwargs) + class TipoDiarioOficial(models.Model): nome = models.CharField(max_length=100, unique=True) @@ -57,24 +63,51 @@ class DiarioOficial(models.Model): numero = models.CharField(max_length=20, unique=True) link = models.URLField(blank=True, null=True, unique=True) page_content = models.JSONField(encoder=DjangoJSONEncoder, blank=True, null=True) - + def save(self, *args, **kwargs): - if self.file: - pdf = PyPDF2.PdfReader(self.file) - pages_data = [] + # Se houver um link, baixa o PDF e extrai o conteúdo + if self.link and not self.arquivo: + try: + # Faz o download do PDF + response = requests.get(self.link) + response.raise_for_status() # Verifica se o download foi bem-sucedido - for i, pagina in enumerate(pdf.pages): - page_text = pagina.extract_text() - pages_data.append( - { - "number": i + 1, - "content": page_text, - } + # Define o nome do arquivo a partir do link + parsed_url = urlparse(self.link) + file_name = ( + os.path.basename(parsed_url.path) or f"diario_{self.numero}.pdf" ) - self.page_content = json.dumps(pages_data) + # Salva o arquivo no campo `arquivo` + self.arquivo.save(file_name, ContentFile(response.content), save=False) + + # Extrai o conteúdo do PDF + pdf = PyPDF2.PdfReader(self.arquivo) + pages_data = [] + + for i, pagina in enumerate(pdf.pages): + page_text = pagina.extract_text() + if page_text: # Ignora páginas sem conteúdo + pages_data.append( + { + "number": i + 1, + "content": page_text, + } + ) + + # Salva o conteúdo das páginas no campo `page_content` + self.page_content = pages_data + + except requests.RequestException as e: + print(f"Erro ao baixar o PDF: {e}") + except PyPDF2.PdfReadError as e: + print(f"Erro ao ler o PDF: {e}") + except Exception as e: + print(f"Erro inesperado: {e}") + + # Salva o modelo super().save(*args, **kwargs) - + @property def data_formatada(self): return format_date(self.data, format="long", locale="pt_BR") @@ -89,4 +122,3 @@ class DiarioOficial(models.Model): class Meta: constraints = [models.UniqueConstraint(fields=["numero"], name="unique_numero")] verbose_name_plural = "Diários Oficiais" - diff --git a/diarios/search_service.py b/diarios/search_service.py new file mode 100644 index 0000000..f72c755 --- /dev/null +++ b/diarios/search_service.py @@ -0,0 +1,67 @@ +from elasticsearch_dsl import Q, Search +from .documents import DiarioOficialDocument + +class DiarioOficialSearchService: + @staticmethod + def search(query, highlight=True, fuzziness=1, page=1, page_size=10, tipos=None, data_inicio=None, data_fim=None): + # Configura a busca básica + s = DiarioOficialDocument.search().source(excludes=['page_content.content']) + + # Filtros + if tipos: + s = s.filter('terms', tipo_nome=tipos) + if data_inicio and data_fim: + s = s.filter('range', data={'gte': data_inicio, 'lte': data_fim}) + + # Query principal com fuzziness e sinônimos + main_query = Q( + 'multi_match', + query=query, + fields=[ + 'numero^3', # Maior peso para o número + 'tipo_nome^2', # Peso médio para o tipo + 'page_content.content' # Peso padrão para o conteúdo + ], + fuzziness=fuzziness, + analyzer='portuguese_synonyms' + ) + s = s.query(main_query) + + # Highlighting + if highlight: + s = s.highlight( + 'page_content.content', + fragment_size=150, + number_of_fragments=3, + pre_tags=[''], + post_tags=[''] + ) + + # Paginação + start = (page - 1) * page_size + end = start + page_size + s = s[start:end] + + # Executa a busca + response = s.execute() + + # Formata os resultados + results = [] + for hit in response: + result = { + 'id': hit.id, + 'numero': hit.numero, + 'data': hit.data, + 'link': hit.link, + 'tipo_nome': hit.tipo_nome, + 'score': hit.meta.score + } + if highlight and hasattr(hit.meta, 'highlight'): + result['highlights'] = hit.meta.highlight['page_content.content'].to_dict() + results.append(result) + + return { + 'total': response.hits.total.value, + 'results': results + } + diff --git a/diarios/signals.py b/diarios/signals.py index ff262d7..ec755b1 100644 --- a/diarios/signals.py +++ b/diarios/signals.py @@ -1,17 +1,15 @@ -from django.db.models.signals import post_save +from django.db.models.signals import post_save, post_delete from django.dispatch import receiver -import PyPDF2 -from io import BytesIO -from .models import PDFDocument -@receiver(post_save, sender=PDFDocument) -def extract_text(sender, instance, created, **kwargs): - print("Signal disparado!") # Teste se o Signal está funcionando - if created and instance.file: - pdf = PyPDF2.PdfReader(instance.file) - text = [] - for page in pdf.pages: - text.append(page.extract_text()) - instance.content = "\n".join(text) - instance.save(update_fields=["content"]) +@receiver(post_save, sender=DiarioOficial) +def update_document(sender, instance, **kwargs): + """Atualizar documento no Elasticsearch quando o objeto for salvo""" + DiarioOficialDocument.update_document(instance) + + +@receiver(post_delete, sender=DiarioOficial) +def delete_document(sender, instance, **kwargs): + """Deletar documento do Elasticsearch quando o objeto for deletado""" + document = DiarioOficialDocument.get(id=instance.id) + document.delete() diff --git a/diarios/templates/diarios/diarios_search.html b/diarios/templates/diarios/diarios_search.html new file mode 100644 index 0000000..956643b --- /dev/null +++ b/diarios/templates/diarios/diarios_search.html @@ -0,0 +1,124 @@ +{% extends "base.html" %} + +{% block content %} +
Encontrados {{ total }} resultados
+Data: {{ result.data }}
+...{{ highlight|safe }}...
+ {% endfor %} +Não encontramos resultados para "{{ query }}". Tente ajustar seus termos de busca.
+Digite um termo de busca para encontrar diários oficiais
+Cerca de {{ total_hits }} resultados encontrados para "{{ query }}"
- {% else %} -Nenhum resultado encontrado para "{{ query }}"
- {% endif %} -Você quis dizer: {{ spelling_correction }}?
-Talvez você esteja procurando por: - {% for suggestion in suggestions %} - {{ suggestion }}{% if not forloop.last %}, {% endif %} - {% endfor %} -
-Você pesquisou por frases exatas. Tente remover as aspas para uma busca mais ampla.
- {% endif %} -Pesquise em nossa biblioteca de documentos digitalizados
-"documento oficial"