adiciona views, templates, urls e documentos do elastic search
This commit is contained in:
@ -6,4 +6,4 @@ set -o nounset
|
|||||||
|
|
||||||
|
|
||||||
python manage.py migrate
|
python manage.py migrate
|
||||||
exec python manage.py runserver_plus 0.0.0.0:8000
|
exec python manage.py runserver_plus 0.0.0.0:8005
|
||||||
|
|||||||
@ -82,7 +82,8 @@ THIRD_PARTY_APPS = [
|
|||||||
|
|
||||||
LOCAL_APPS = [
|
LOCAL_APPS = [
|
||||||
"diários_oficiais_alems.users",
|
"diários_oficiais_alems.users",
|
||||||
# Your stuff: custom apps go here
|
"diarios",
|
||||||
|
'django_elasticsearch_dsl',
|
||||||
]
|
]
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
@ -287,7 +288,8 @@ STATICFILES_FINDERS += ["compressor.finders.CompressorFinder"]
|
|||||||
# Elastic Search
|
# Elastic Search
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
ELASTICSEARCH_DSL = {
|
ELASTICSEARCH_DSL = {
|
||||||
'default': {
|
'default': {
|
||||||
'hosts': env('ELASTICSEARCH_HOSTS', default='localhost:9200')
|
'hosts': 'http://elasticsearch:9200' # same as above
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
ELASTICSEARCH_HOSTS="http://elasticsearch:9200"
|
||||||
|
|||||||
@ -14,7 +14,7 @@ SECRET_KEY = env(
|
|||||||
default="tYdYl0MP5zgpMlMmjBuYHvH4Dp3JDN5q3sxWBdFejemZSr0qpI9IrvrvTm17F0aW",
|
default="tYdYl0MP5zgpMlMmjBuYHvH4Dp3JDN5q3sxWBdFejemZSr0qpI9IrvrvTm17F0aW",
|
||||||
)
|
)
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
|
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
|
||||||
ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1"] # noqa: S104
|
ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1", "109.199.98.226"] # noqa: S104
|
||||||
|
|
||||||
# CACHES
|
# CACHES
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
|||||||
@ -20,7 +20,7 @@ urlpatterns = [
|
|||||||
path("users/", include("diários_oficiais_alems.users.urls", namespace="users")),
|
path("users/", include("diários_oficiais_alems.users.urls", namespace="users")),
|
||||||
path("accounts/", include("allauth.urls")),
|
path("accounts/", include("allauth.urls")),
|
||||||
# Your stuff: custom urls includes go here
|
# Your stuff: custom urls includes go here
|
||||||
# ...
|
path("diarios/", include("diarios.urls")),
|
||||||
# Media files
|
# Media files
|
||||||
*static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT),
|
*static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT),
|
||||||
]
|
]
|
||||||
|
|||||||
0
diarios/__init__.py
Normal file
0
diarios/__init__.py
Normal file
8
diarios/admin.py
Normal file
8
diarios/admin.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import PDFDocument
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PDFDocument)
|
||||||
|
class PDFDocumentAdmin(admin.ModelAdmin):
|
||||||
|
pass
|
||||||
6
diarios/apps.py
Normal file
6
diarios/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class DiariosConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'diarios'
|
||||||
68
diarios/documents.py
Normal file
68
diarios/documents.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
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')
|
||||||
|
|
||||||
|
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']
|
||||||
24
diarios/migrations/0001_initial.py
Normal file
24
diarios/migrations/0001_initial.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.0.12 on 2025-03-06 16:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PDFDocument',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=255)),
|
||||||
|
('file', models.FileField(upload_to='pdfs/')),
|
||||||
|
('content', models.TextField(blank=True)),
|
||||||
|
('uploaded_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
0
diarios/migrations/__init__.py
Normal file
0
diarios/migrations/__init__.py
Normal file
23
diarios/models.py
Normal file
23
diarios/models.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from django.db import models
|
||||||
|
import PyPDF2
|
||||||
|
|
||||||
|
|
||||||
|
class PDFDocument(models.Model):
|
||||||
|
title = models.CharField(max_length=255)
|
||||||
|
file = models.FileField(upload_to='pdfs/')
|
||||||
|
content = models.TextField(blank=True)
|
||||||
|
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.file:
|
||||||
|
pdf = PyPDF2.PdfReader(self.file)
|
||||||
|
texto = []
|
||||||
|
for pagina in pdf.pages:
|
||||||
|
texto.append(pagina.extract_text())
|
||||||
|
self.content = '\n'.join(texto)
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
18
diarios/signals.py
Normal file
18
diarios/signals.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from django.db.models.signals import post_save
|
||||||
|
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'])
|
||||||
|
|
||||||
310
diarios/templates/diarios/search_results.html
Normal file
310
diarios/templates/diarios/search_results.html
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% if query %}{{ query }} - {% endif %}Pesquisa de Documentos</title>
|
||||||
|
<!-- Bootstrap 5 CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<!-- Fonte personalizada -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<!-- Ícones do Bootstrap -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.1/font/bootstrap-icons.css">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Roboto', Arial, sans-serif;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
color: #202124;
|
||||||
|
}
|
||||||
|
.search-container {
|
||||||
|
max-width: 650px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.main-container {
|
||||||
|
max-width: 650px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.search-box {
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid #dfe1e5;
|
||||||
|
box-shadow: none;
|
||||||
|
height: 44px;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
.search-box:focus {
|
||||||
|
box-shadow: 0 1px 6px rgba(32,33,36,.28);
|
||||||
|
border-color: rgba(223,225,229,0);
|
||||||
|
}
|
||||||
|
.search-button {
|
||||||
|
border-radius: 24px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.result-item {
|
||||||
|
padding: 20px 0;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
.result-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.result-title {
|
||||||
|
color: #1a0dab;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.result-title a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.result-title a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.result-content {
|
||||||
|
color: #4d5156;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
line-height: 1.58;
|
||||||
|
}
|
||||||
|
.result-meta {
|
||||||
|
color: #70757a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.suggestion {
|
||||||
|
color: #1a0dab;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.suggestion:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
mark {
|
||||||
|
background-color: #ffffc2;
|
||||||
|
padding: 0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.related-term {
|
||||||
|
color: #70757a;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.pagination-link {
|
||||||
|
color: #1a0dab;
|
||||||
|
padding: 0 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.pagination-link.active {
|
||||||
|
color: #202124;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.pagination-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.search-stats {
|
||||||
|
color: #70757a;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
padding: 20px 0;
|
||||||
|
background-color: white;
|
||||||
|
border-bottom: 1px solid #dfe1e5;
|
||||||
|
}
|
||||||
|
.badge-exact-match {
|
||||||
|
background-color: #e8f0fe;
|
||||||
|
color: #1a73e8;
|
||||||
|
border: 1px solid #d2e3fc;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
.search-tip {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #70757a;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="position-relative">
|
||||||
|
<input type="text" name="q" class="form-control search-box"
|
||||||
|
id="searchInput" autocomplete="off">
|
||||||
|
<div id="suggestionsBox" class="position-absolute w-100 bg-white shadow"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// JavaScript para sugestões em tempo real
|
||||||
|
document.getElementById('searchInput').addEventListener('input', function(e) {
|
||||||
|
const query = e.target.value;
|
||||||
|
if(query.length > 2) {
|
||||||
|
fetch(`/diarios/spellcheck/?q=${encodeURIComponent(query)}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
const suggestionsBox = document.getElementById('suggestionsBox');
|
||||||
|
suggestionsBox.innerHTML = data.suggestions.map(sug =>
|
||||||
|
`<div class="suggestion-item p-2 border-bottom cursor-pointer">
|
||||||
|
${sug}
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clique na sugestão
|
||||||
|
document.getElementById('suggestionsBox').addEventListener('click', function(e) {
|
||||||
|
if(e.target.classList.contains('suggestion-item')) {
|
||||||
|
document.getElementById('searchInput').value = e.target.textContent;
|
||||||
|
this.innerHTML = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Cabeçalho com barra de pesquisa -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-auto">
|
||||||
|
<a href="/" class="text-decoration-none">
|
||||||
|
<h3 class="mb-0 text-primary"><i class="bi bi-search"></i> BuscaDocs</h3>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<form action="{% url 'search_view' %}" method="get" class="d-flex">
|
||||||
|
<input type="text" name="q" class="form-control search-box" value="{{ query }}" placeholder="Pesquisar documentos..." aria-label="Pesquisar">
|
||||||
|
<button class="btn btn-primary search-button" type="submit"><i class="bi bi-search"></i></button>
|
||||||
|
</form>
|
||||||
|
<div class="search-tip">
|
||||||
|
Use aspas duplas para buscar frases exatas, ex: "documento oficial"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="container py-4 main-container">
|
||||||
|
{% if query %}
|
||||||
|
<!-- Estatísticas da busca -->
|
||||||
|
<div class="search-stats">
|
||||||
|
{% if total_hits > 0 %}
|
||||||
|
<p>Cerca de {{ total_hits }} resultados encontrados para "{{ query }}"</p>
|
||||||
|
{% else %}
|
||||||
|
<p>Nenhum resultado encontrado para "{{ query }}"</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Correção ortográfica -->
|
||||||
|
{% if spelling_correction %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<p>Você quis dizer: <a href="?q={{ spelling_correction|urlencode }}" class="suggestion">{{ spelling_correction }}</a>?</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Sugestões de termos -->
|
||||||
|
{% if suggestions %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<p>Talvez você esteja procurando por:
|
||||||
|
{% for suggestion in suggestions %}
|
||||||
|
<a href="?q={{ suggestion|urlencode }}" class="suggestion me-2">{{ suggestion }}</a>{% if not forloop.last %}, {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Resultados da busca -->
|
||||||
|
{% if results %}
|
||||||
|
<div class="results-container">
|
||||||
|
{% for result in results %}
|
||||||
|
<div class="result-item">
|
||||||
|
<div class="d-flex gap-2 mb-2">
|
||||||
|
{% if result.is_exact_match %}
|
||||||
|
<span class="badge badge-exact-match">Correspondência exata</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if result.is_related %}
|
||||||
|
<span class="badge bg-secondary">Termo relacionado</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<h5 class="result-title">
|
||||||
|
<a href="#">{{ result.highlighted_title|safe }}</a>
|
||||||
|
</h5>
|
||||||
|
<div class="result-content">{{ result.highlighted_content|safe }}</div>
|
||||||
|
<div class="result-meta">
|
||||||
|
<i class="bi bi-calendar-date"></i> {{ result.uploaded_at|date:"d/m/Y" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Paginação -->
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<nav aria-label="Paginação de resultados" class="my-4">
|
||||||
|
<div class="d-flex justify-content-center">
|
||||||
|
{% if page > 1 %}
|
||||||
|
<a href="?q={{ query|urlencode }}&page={{ page|add:'-1' }}" class="pagination-link">
|
||||||
|
<i class="bi bi-chevron-left"></i> Anterior
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for p in page_range %}
|
||||||
|
<a href="?q={{ query|urlencode }}&page={{ p }}" class="pagination-link {% if p == page %}active{% endif %}">
|
||||||
|
{{ p }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if page < total_pages %}
|
||||||
|
<a href="?q={{ query|urlencode }}&page={{ page|add:'1' }}" class="pagination-link">
|
||||||
|
Próxima <i class="bi bi-chevron-right"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill me-2"></i>
|
||||||
|
Nenhum documento corresponde aos termos de pesquisa. Tente usar palavras-chave diferentes ou mais gerais.
|
||||||
|
{% if has_exact_phrases %}
|
||||||
|
<p class="mt-2 mb-0">Você pesquisou por frases exatas. Tente remover as aspas para uma busca mais ampla.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<!-- Página inicial de pesquisa -->
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<h1 class="display-4 mb-4 text-primary"><i class="bi bi-search"></i> BuscaDocs</h1>
|
||||||
|
<div class="search-container mb-4">
|
||||||
|
<form action="{% url 'search_view' %}" method="get">
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input type="text" name="q" class="form-control search-box py-3" placeholder="Pesquisar documentos..." aria-label="Pesquisar">
|
||||||
|
<button class="btn btn-primary search-button px-4" type="submit">
|
||||||
|
<i class="bi bi-search"></i> Pesquisar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p class="text-muted">Pesquise em nossa biblioteca de documentos digitalizados</p>
|
||||||
|
<div class="mt-3 text-start p-3 border rounded bg-light">
|
||||||
|
<h5>Dicas de pesquisa:</h5>
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li>Use <strong>aspas duplas</strong> para buscar frases exatas: <code>"documento oficial"</code></li>
|
||||||
|
<li>Tente usar sinônimos se não encontrar resultados</li>
|
||||||
|
<li>Seja específico para encontrar documentos relevantes</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="bg-light py-3 mt-5">
|
||||||
|
<div class="container text-center">
|
||||||
|
<p class="text-muted mb-0">© 2025 BuscaDocs - Sistema de Pesquisa de Documentos</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- Bootstrap JS Bundle with Popper -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Script para sugestões em tempo real (opcional) -->
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const searchInput = document.querySelector('input[name="q"]');
|
||||||
|
searchInput.focus();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
3
diarios/tests.py
Normal file
3
diarios/tests.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
7
diarios/urls.py
Normal file
7
diarios/urls.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from .views import search_view, spellcheck_view
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('pesquisa/', search_view, name='search_view'),
|
||||||
|
path('spellcheck/', spellcheck_view, name='spellcheck_view'),
|
||||||
|
]
|
||||||
235
diarios/views.py
Normal file
235
diarios/views.py
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
from elasticsearch_dsl import Search, Q
|
||||||
|
from elasticsearch_dsl.connections import connections
|
||||||
|
from django.conf import settings
|
||||||
|
import re
|
||||||
|
|
||||||
|
from django.http import JsonResponse
|
||||||
|
|
||||||
|
|
||||||
|
# Configuração da conexão com o Elasticsearch
|
||||||
|
connections.create_connection(hosts=[settings.ELASTICSEARCH_HOSTS])
|
||||||
|
|
||||||
|
|
||||||
|
def spellcheck_view(request):
|
||||||
|
query = request.GET.get('q', '')
|
||||||
|
suggestions = []
|
||||||
|
|
||||||
|
if query:
|
||||||
|
s = Search(index='pdf_documents')
|
||||||
|
s = s.suggest('auto_correct', query,
|
||||||
|
phrase={
|
||||||
|
'field': 'suggest',
|
||||||
|
'size': 3,
|
||||||
|
'gram_size': 3,
|
||||||
|
'confidence': 2.0,
|
||||||
|
'direct_generator': [{
|
||||||
|
'field': 'suggest',
|
||||||
|
'suggest_mode': 'popular'
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
response = s.execute()
|
||||||
|
|
||||||
|
if hasattr(response.suggest, 'auto_correct'):
|
||||||
|
for option in response.suggest.auto_correct[0].options:
|
||||||
|
suggestions.append(option.text)
|
||||||
|
|
||||||
|
return JsonResponse({'suggestions': suggestions})
|
||||||
|
|
||||||
|
def search_view(request):
|
||||||
|
query = request.GET.get('q', '') # Obtém o termo de pesquisa da URL
|
||||||
|
page = int(request.GET.get('page', 1))
|
||||||
|
|
||||||
|
results = []
|
||||||
|
suggestions = []
|
||||||
|
spelling_correction = None
|
||||||
|
total_hits = 0
|
||||||
|
per_page = 10
|
||||||
|
|
||||||
|
if query:
|
||||||
|
# Processamento especial para termos entre aspas
|
||||||
|
exact_phrases = re.findall(r'"([^"]*)"', query)
|
||||||
|
|
||||||
|
# Remove os termos entre aspas da consulta principal
|
||||||
|
cleaned_query = query
|
||||||
|
for phrase in exact_phrases:
|
||||||
|
cleaned_query = cleaned_query.replace(f'"{phrase}"', '')
|
||||||
|
|
||||||
|
# Remove espaços extras e pontuação desnecessária
|
||||||
|
cleaned_query = re.sub(r'\s+', ' ', cleaned_query).strip()
|
||||||
|
|
||||||
|
# Cria uma consulta no Elasticsearch
|
||||||
|
search = Search(index='pdf_documents')
|
||||||
|
|
||||||
|
# Lista para armazenar todas as consultas
|
||||||
|
queries = []
|
||||||
|
|
||||||
|
# Adiciona consulta para termos gerais (com fuzziness para tolerância a erros)
|
||||||
|
if cleaned_query:
|
||||||
|
queries.append(
|
||||||
|
Q('multi_match',
|
||||||
|
query=cleaned_query,
|
||||||
|
fields=['title^3', 'content^2', 'synonyms^1'],
|
||||||
|
fuzziness='AUTO',
|
||||||
|
boost=2,)
|
||||||
|
)
|
||||||
|
queries.append(
|
||||||
|
Q('match',
|
||||||
|
synonyms={
|
||||||
|
'query': cleaned_query,
|
||||||
|
'boost': 0.5
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Adiciona consultas exatas para frases entre aspas (sem fuzziness)
|
||||||
|
for phrase in exact_phrases:
|
||||||
|
if phrase.strip():
|
||||||
|
# Consulta de frase exata para o título com peso alto
|
||||||
|
queries.append(
|
||||||
|
Q('match_phrase',
|
||||||
|
title={
|
||||||
|
'query': phrase,
|
||||||
|
'boost': 3,
|
||||||
|
'slop': 0 # Sem flexibilidade na ordem das palavras
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Consulta de frase exata para o conteúdo com peso médio
|
||||||
|
queries.append(
|
||||||
|
Q('match_phrase',
|
||||||
|
content={
|
||||||
|
'query': phrase,
|
||||||
|
'boost': 2,
|
||||||
|
'slop': 0 # Sem flexibilidade na ordem das palavras
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Combina as consultas com OR (se houver alguma)
|
||||||
|
if queries:
|
||||||
|
search = search.query(
|
||||||
|
Q('bool', should=queries, minimum_should_match=1)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configuração do highlight para mostrar mais contexto
|
||||||
|
search = search.highlight('content', fragment_size=300, number_of_fragments=2, pre_tags=['<mark>'], post_tags=['</mark>'])
|
||||||
|
search = search.highlight('title', fragment_size=300, number_of_fragments=1, pre_tags=['<mark>'], post_tags=['</mark>'])
|
||||||
|
|
||||||
|
# Paginação
|
||||||
|
search = search[(page-1)*per_page:page*per_page]
|
||||||
|
|
||||||
|
# Executa a consulta
|
||||||
|
response = search.execute()
|
||||||
|
total_hits = response.hits.total.value
|
||||||
|
|
||||||
|
# Processa os resultados
|
||||||
|
for hit in response:
|
||||||
|
# Extrai o conteúdo destacado ou usa o original
|
||||||
|
if hasattr(hit.meta, 'highlight') and hasattr(hit.meta.highlight, 'content'):
|
||||||
|
highlighted_content = ' ... '.join(hit.meta.highlight.content)
|
||||||
|
else:
|
||||||
|
# Se não houver highlight, pegue os primeiros 300 caracteres
|
||||||
|
highlighted_content = hit.content[:300] + '...' if len(hit.content) > 300 else hit.content
|
||||||
|
|
||||||
|
# Extrai o título destacado ou usa o original
|
||||||
|
if hasattr(hit.meta, 'highlight') and hasattr(hit.meta.highlight, 'title'):
|
||||||
|
highlighted_title = hit.meta.highlight.title[0]
|
||||||
|
else:
|
||||||
|
highlighted_title = hit.title
|
||||||
|
|
||||||
|
# Verifica se o resultado corresponde a uma frase exata
|
||||||
|
is_exact_match = any(phrase.lower() in hit.content.lower() or
|
||||||
|
phrase.lower() in hit.title.lower()
|
||||||
|
for phrase in exact_phrases)
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'id': hit.meta.id,
|
||||||
|
'title': hit.title,
|
||||||
|
'highlighted_title': highlighted_title,
|
||||||
|
'highlighted_content': highlighted_content,
|
||||||
|
'uploaded_at': hit.uploaded_at,
|
||||||
|
'score': hit.meta.score,
|
||||||
|
'is_exact_match': is_exact_match
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sugestões "Você quis dizer" (apenas para termos fora de aspas)
|
||||||
|
if total_hits < 5 and cleaned_query:
|
||||||
|
suggestion_search = Search(index='pdf_documents')
|
||||||
|
suggestion_search = suggestion_search.suggest(
|
||||||
|
'term_suggestion',
|
||||||
|
cleaned_query,
|
||||||
|
term={
|
||||||
|
'field': 'content',
|
||||||
|
'suggest_mode': 'popular',
|
||||||
|
'size': 5
|
||||||
|
}
|
||||||
|
)
|
||||||
|
suggestion_response = suggestion_search.execute()
|
||||||
|
|
||||||
|
if hasattr(suggestion_response, 'suggest') and hasattr(suggestion_response.suggest, 'term_suggestion'):
|
||||||
|
for suggestion in suggestion_response.suggest.term_suggestion:
|
||||||
|
for option in suggestion.options:
|
||||||
|
suggestions.append(option.text)
|
||||||
|
|
||||||
|
# Cria uma correção ortográfica se necessário
|
||||||
|
if suggestions and total_hits == 0:
|
||||||
|
corrected_query = cleaned_query
|
||||||
|
for suggestion_term in suggestion_response.suggest.term_suggestion:
|
||||||
|
if suggestion_term.options:
|
||||||
|
# Substitui palavras incorretas por sugestões
|
||||||
|
word_to_replace = suggestion_term.text
|
||||||
|
corrected_word = suggestion_term.options[0].text
|
||||||
|
corrected_query = re.sub(r'\b' + re.escape(word_to_replace) + r'\b',
|
||||||
|
corrected_word,
|
||||||
|
corrected_query,
|
||||||
|
flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
# Reconstrói a consulta original mantendo as frases entre aspas
|
||||||
|
if corrected_query != cleaned_query:
|
||||||
|
spelling_correction = corrected_query
|
||||||
|
for phrase in exact_phrases:
|
||||||
|
spelling_correction += f' "{phrase}"'
|
||||||
|
spelling_correction = spelling_correction.strip()
|
||||||
|
|
||||||
|
# Busca por termos relacionados (apenas se houver poucos resultados)
|
||||||
|
if total_hits < 3 and cleaned_query:
|
||||||
|
related_terms = Search(index='pdf_documents')
|
||||||
|
related_terms = related_terms.query(
|
||||||
|
'more_like_this',
|
||||||
|
fields=['content', 'title'],
|
||||||
|
like=cleaned_query,
|
||||||
|
min_term_freq=1,
|
||||||
|
max_query_terms=10,
|
||||||
|
min_doc_freq=1
|
||||||
|
)
|
||||||
|
related_terms = related_terms[:5]
|
||||||
|
related_response = related_terms.execute()
|
||||||
|
|
||||||
|
for hit in related_response:
|
||||||
|
# Verifica se este documento já está nos resultados
|
||||||
|
if not any(r.get('id') == hit.meta.id for r in results):
|
||||||
|
results.append({
|
||||||
|
'id': hit.meta.id,
|
||||||
|
'title': hit.title,
|
||||||
|
'highlighted_title': hit.title,
|
||||||
|
'highlighted_content': hit.content[:300] + '...' if len(hit.content) > 300 else hit.content,
|
||||||
|
'uploaded_at': hit.uploaded_at,
|
||||||
|
'score': hit.meta.score,
|
||||||
|
'is_related': True
|
||||||
|
})
|
||||||
|
|
||||||
|
# Calcula a paginação
|
||||||
|
total_pages = (total_hits + per_page - 1) // per_page if total_hits > 0 else 0
|
||||||
|
|
||||||
|
# Renderiza o template com os resultados
|
||||||
|
return render(request, 'diarios/search_results.html', {
|
||||||
|
'query': query,
|
||||||
|
'results': results,
|
||||||
|
'suggestions': suggestions[:5], # Limita a 5 sugestões
|
||||||
|
'spelling_correction': spelling_correction,
|
||||||
|
'total_hits': total_hits,
|
||||||
|
'page': page,
|
||||||
|
'total_pages': total_pages,
|
||||||
|
'page_range': range(max(1, page-2), min(total_pages+1, page+3)),
|
||||||
|
'has_exact_phrases': bool(exact_phrases)
|
||||||
|
})
|
||||||
|
|
||||||
@ -1,9 +1,18 @@
|
|||||||
volumes:
|
volumes:
|
||||||
diarios_oficiais_alems_local_postgres_data: {}
|
diarios_oficiais_alems_local_postgres_data: {}
|
||||||
diarios_oficiais_alems_local_postgres_data_backups: {}
|
diarios_oficiais_alems_local_postgres_data_backups: {}
|
||||||
|
esdata:
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
elasticsearch:
|
||||||
|
image: docker.elastic.co/elasticsearch/elasticsearch:7.10.0
|
||||||
|
environment:
|
||||||
|
- discovery.type=single-node
|
||||||
|
ports:
|
||||||
|
- "9200:9200"
|
||||||
|
volumes:
|
||||||
|
- esdata:/usr/share/elasticsearch/data
|
||||||
|
|
||||||
django:
|
django:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@ -18,7 +27,7 @@ services:
|
|||||||
- ./.envs/.local/.django
|
- ./.envs/.local/.django
|
||||||
- ./.envs/.local/.postgres
|
- ./.envs/.local/.postgres
|
||||||
ports:
|
ports:
|
||||||
- '8000:8000'
|
- '8005:8005'
|
||||||
command: /start
|
command: /start
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
@ -32,3 +41,17 @@ services:
|
|||||||
- diarios_oficiais_alems_local_postgres_data_backups:/backups
|
- diarios_oficiais_alems_local_postgres_data_backups:/backups
|
||||||
env_file:
|
env_file:
|
||||||
- ./.envs/.local/.postgres
|
- ./.envs/.local/.postgres
|
||||||
|
|
||||||
|
elasticsearch:
|
||||||
|
image: docker.elastic.co/elasticsearch/elasticsearch:8.17.3
|
||||||
|
environment:
|
||||||
|
- discovery.type=single-node
|
||||||
|
environment:
|
||||||
|
- discovery.type=single-node
|
||||||
|
- xpack.security.enabled=false
|
||||||
|
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
|
||||||
|
ports:
|
||||||
|
- "9200:9200"
|
||||||
|
volumes:
|
||||||
|
- esdata:/usr/share/elasticsearch/data
|
||||||
|
|
||||||
|
|||||||
@ -16,3 +16,7 @@ django-crispy-forms==2.3 # https://github.com/django-crispy-forms/django-crispy
|
|||||||
crispy-bootstrap5==2024.10 # https://github.com/django-crispy-forms/crispy-bootstrap5
|
crispy-bootstrap5==2024.10 # https://github.com/django-crispy-forms/crispy-bootstrap5
|
||||||
django-compressor==4.5.1 # https://github.com/django-compressor/django-compressor
|
django-compressor==4.5.1 # https://github.com/django-compressor/django-compressor
|
||||||
django-redis==5.4.0 # https://github.com/jazzband/django-redis
|
django-redis==5.4.0 # https://github.com/jazzband/django-redis
|
||||||
|
|
||||||
|
elasticsearch
|
||||||
|
django-elasticsearch-dsl
|
||||||
|
PyPDF2
|
||||||
|
|||||||
0
synonyms.txt
Normal file
0
synonyms.txt
Normal file
Reference in New Issue
Block a user