Update search function

This commit is contained in:
2025-11-05 21:18:26 +11:00
parent 5a59c6f3a5
commit d8b763592f
14 changed files with 551 additions and 67 deletions
BIN
View File
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
from django.apps import AppConfig
class LinksConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'links'
def ready(self):
# Import signals to register them
import links.signals
View File
@@ -0,0 +1,14 @@
from django.core.management.base import BaseCommand
from links.search_backend import search_backend
class Command(BaseCommand):
help = 'Rebuild the full-text search index'
def handle(self, *args, **options):
self.stdout.write('Rebuilding search index...')
try:
search_backend.rebuild_index()
self.stdout.write(self.style.SUCCESS('Successfully rebuilt search index'))
except Exception as e:
self.stdout.write(self.style.ERROR(f'Failed to rebuild index: {e}'))
@@ -0,0 +1,58 @@
# Generated by Django 5.1.13 on 2025-11-05 10:14
from django.db import migrations
def remove_webpage_content_column(apps, schema_editor):
"""Remove webpage_content column if it exists"""
with schema_editor.connection.cursor() as cursor:
# Check if column exists
cursor.execute("PRAGMA table_info(links_page)")
columns = [row[1] for row in cursor.fetchall()]
if 'webpage_content' in columns:
# SQLite doesn't support DROP COLUMN directly, need to recreate table
cursor.execute("""
CREATE TABLE links_page_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url varchar(2000) NOT NULL,
title varchar(200) NOT NULL,
summary TEXT NOT NULL,
content TEXT NOT NULL,
created_at datetime NOT NULL,
updated_at datetime NOT NULL,
error_message TEXT NOT NULL,
last_retry_at datetime,
process_status varchar(20) NOT NULL,
retry_count INTEGER NOT NULL,
screenshot_error TEXT,
screenshot_last_attempt datetime,
screenshot_path varchar(255) NOT NULL
)
""")
# Copy data
cursor.execute("""
INSERT INTO links_page_new
SELECT id, url, title, summary, content, created_at, updated_at,
error_message, last_retry_at, process_status, retry_count,
screenshot_error, screenshot_last_attempt, screenshot_path
FROM links_page
""")
# Drop old table
cursor.execute("DROP TABLE links_page")
# Rename new table
cursor.execute("ALTER TABLE links_page_new RENAME TO links_page")
class Migration(migrations.Migration):
dependencies = [
('links', '0034_post_summary'),
]
operations = [
migrations.RunPython(remove_webpage_content_column, migrations.RunPython.noop),
]
+266
View File
@@ -0,0 +1,266 @@
"""
Full-text search backend using Whoosh
"""
import os
import logging
from django.conf import settings
from whoosh import index
from whoosh.fields import Schema, TEXT, ID, DATETIME, KEYWORD, NUMERIC
from whoosh.qparser import MultifieldParser, OrGroup
from whoosh.analysis import StemmingAnalyzer
from .models import Link, Page, Post
logger = logging.getLogger(__name__)
# Define index directory
INDEX_DIR = os.path.join(settings.BASE_DIR, 'data', 'search_index')
# Define schema for search index
SCHEMA = Schema(
id=ID(stored=True, unique=True),
model_type=ID(stored=True),
title=TEXT(stored=True, analyzer=StemmingAnalyzer()),
content=TEXT(analyzer=StemmingAnalyzer()),
url=TEXT(stored=True),
alias=TEXT(stored=True),
description=TEXT(stored=True, analyzer=StemmingAnalyzer()),
summary=TEXT(stored=True, analyzer=StemmingAnalyzer()),
tags=KEYWORD(stored=True, commas=True, scorable=True),
created_at=DATETIME(stored=True, sortable=True),
click_count=NUMERIC(stored=True, sortable=True)
)
class SearchBackend:
"""
Search backend powered by Whoosh for full-text indexing and searching
"""
def __init__(self):
self.index_dir = INDEX_DIR
self.ensure_index_exists()
def ensure_index_exists(self):
"""Create index directory and index if they don't exist"""
if not os.path.exists(self.index_dir):
os.makedirs(self.index_dir, exist_ok=True)
index.create_in(self.index_dir, SCHEMA)
logger.info(f"Created new search index at {self.index_dir}")
def get_index(self):
"""Get or create the search index"""
if not index.exists_in(self.index_dir):
return index.create_in(self.index_dir, SCHEMA)
return index.open_dir(self.index_dir)
def index_link(self, link):
"""Index a single link"""
ix = self.get_index()
writer = ix.writer()
try:
# Get tags as comma-separated string
tags = ','.join([tag.name for tag in link.tags.all()])
writer.update_document(
id=f"link_{link.id}",
model_type="link",
title=link.alias or "",
content=link.text or "",
url=link.original_url or "",
alias=link.alias or "",
description=link.description or "",
tags=tags,
created_at=link.created_at,
click_count=link.click_count
)
writer.commit()
logger.debug(f"Indexed link: {link.id}")
except Exception as e:
writer.cancel()
logger.error(f"Error indexing link {link.id}: {e}", exc_info=True)
def index_page(self, page):
"""Index a single page"""
ix = self.get_index()
writer = ix.writer()
try:
# Get tags as comma-separated string
tags = ','.join([tag.name for tag in page.tags.all()])
writer.update_document(
id=f"page_{page.id}",
model_type="page",
title=page.title or "",
content=page.content or "",
url=page.url or "",
summary=page.summary or "",
tags=tags,
created_at=page.created_at
)
writer.commit()
logger.debug(f"Indexed page: {page.id}")
except Exception as e:
writer.cancel()
logger.error(f"Error indexing page {page.id}: {e}", exc_info=True)
def index_post(self, post):
"""Index a single post"""
ix = self.get_index()
writer = ix.writer()
try:
# Get tags as comma-separated string
tags = ','.join([tag.name for tag in post.tags.all()])
writer.update_document(
id=f"post_{post.id}",
model_type="post",
title=post.title or "",
content=post.content or "",
summary=post.summary or "",
tags=tags,
created_at=post.created_at
)
writer.commit()
logger.debug(f"Indexed post: {post.id}")
except Exception as e:
writer.cancel()
logger.error(f"Error indexing post {post.id}: {e}", exc_info=True)
def remove_from_index(self, model_type, model_id):
"""Remove a document from the index"""
ix = self.get_index()
writer = ix.writer()
try:
writer.delete_by_term('id', f"{model_type}_{model_id}")
writer.commit()
logger.debug(f"Removed {model_type} {model_id} from index")
except Exception as e:
writer.cancel()
logger.error(f"Error removing {model_type} {model_id} from index: {e}", exc_info=True)
def rebuild_index(self):
"""Rebuild the entire search index from scratch"""
logger.info("Starting search index rebuild...")
# Delete and recreate index
ix = index.create_in(self.index_dir, SCHEMA)
writer = ix.writer()
try:
# Index all links
for link in Link.objects.all().prefetch_related('tags'):
tags = ','.join([tag.name for tag in link.tags.all()])
writer.add_document(
id=f"link_{link.id}",
model_type="link",
title=link.alias or "",
content=link.text or "",
url=link.original_url or "",
alias=link.alias or "",
description=link.description or "",
tags=tags,
created_at=link.created_at,
click_count=link.click_count
)
# Index all pages
for page in Page.objects.all().prefetch_related('tags'):
tags = ','.join([tag.name for tag in page.tags.all()])
writer.add_document(
id=f"page_{page.id}",
model_type="page",
title=page.title or "",
content=page.content or "",
url=page.url or "",
summary=page.summary or "",
tags=tags,
created_at=page.created_at
)
# Index all posts
for post in Post.objects.all().prefetch_related('tags'):
tags = ','.join([tag.name for tag in post.tags.all()])
writer.add_document(
id=f"post_{post.id}",
model_type="post",
title=post.title or "",
content=post.content or "",
summary=post.summary or "",
tags=tags,
created_at=post.created_at
)
writer.commit()
logger.info("Search index rebuild completed successfully")
except Exception as e:
writer.cancel()
logger.error(f"Error rebuilding index: {e}", exc_info=True)
raise
def search(self, query_string, model_type=None, limit=100, page=1, per_page=20):
"""
Search the index with the given query
Args:
query_string: Search query
model_type: Filter by model type (link, page, post) or None for all
limit: Maximum number of results to return
page: Page number for pagination
per_page: Results per page
Returns:
dict with results and metadata
"""
if not query_string.strip():
return {'results': [], 'total': 0, 'page': page, 'per_page': per_page}
ix = self.get_index()
with ix.searcher() as searcher:
# Create multifield parser - search across multiple fields
fields = ['title', 'content', 'url', 'alias', 'description', 'summary', 'tags']
parser = MultifieldParser(fields, schema=ix.schema, group=OrGroup)
try:
query = parser.parse(query_string)
except Exception as e:
logger.error(f"Error parsing query '{query_string}': {e}")
return {'results': [], 'total': 0, 'page': page, 'per_page': per_page}
# Apply model type filter if specified
if model_type:
from whoosh.query import And, Term
query = And([query, Term('model_type', model_type)])
# Execute search
results = searcher.search(query, limit=limit)
# Extract results with pagination
start = (page - 1) * per_page
end = start + per_page
total = len(results)
result_list = []
for hit in results[start:end]:
result_dict = dict(hit)
# Extract model ID from composite ID
model_id = result_dict['id'].split('_')[1]
result_dict['model_id'] = int(model_id)
result_list.append(result_dict)
return {
'results': result_list,
'total': total,
'page': page,
'per_page': per_page,
'has_next': end < total,
'has_prev': page > 1
}
# Global search backend instance
search_backend = SearchBackend()
+4 -2
View File
@@ -1,6 +1,8 @@
from django.urls import path
from . import views
from .search_views import SearchView, search, search_aliases
urlpatterns = [
path('', views.search_aliases, name='search_aliases'),
path('', SearchView.as_view(), name='search'),
path('api/', search, name='search_api'),
path('aliases/', search_aliases, name='search_aliases'),
]
+72 -62
View File
@@ -6,6 +6,7 @@ from django.db.models import Q, Value, CharField
from django.db import models
from django.utils.text import slugify
from .models import Link, Page, Post
from .search_backend import search_backend
logger = logging.getLogger(__name__)
@@ -17,77 +18,86 @@ class SearchView(ListView):
def get_queryset(self):
query = self.request.GET.get('q', '').strip()
type_filter = self.request.GET.get('type', '')
sort_order = self.request.GET.get('sort', 'newest')
page_num = self.request.GET.get('page', 1)
try:
page_num = int(page_num)
except (ValueError, TypeError):
page_num = 1
if query:
results = []
# Apply type filter
if type_filter:
if type_filter == 'link':
results = self._get_link_results(query)
elif type_filter == 'page':
results = self._get_page_results(query)
elif type_filter == 'post':
results = self._get_post_results(query)
else:
# Get all results
results = self._get_all_results(query)
# Apply sorting
results = self._sort_results(results, sort_order)
return results
# Use Whoosh search backend for full-text search
search_results = search_backend.search(
query_string=query,
model_type=type_filter if type_filter else None,
page=page_num,
per_page=self.paginate_by
)
# Enrich results with actual model data
enriched_results = []
for result in search_results['results']:
enriched_result = self._enrich_result(result)
if enriched_result:
enriched_results.append(enriched_result)
# Store search metadata for pagination
self.search_total = search_results['total']
self.search_has_next = search_results['has_next']
self.search_has_prev = search_results['has_prev']
return enriched_results
return []
def _get_link_results(self, query):
return Link.objects.filter(
Q(alias__icontains=query) |
Q(original_url__icontains=query) |
Q(description__icontains=query)
).values('id', 'alias', 'original_url', 'click_count', 'created_at').annotate(
model_type=Value('link', output_field=CharField())
)
def _get_page_results(self, query):
return Page.objects.filter(
Q(url__icontains=query) |
Q(title__icontains=query) |
Q(summary__icontains=query) |
Q(content__icontains=query)
).values('id', 'url', 'title', 'summary', 'created_at').annotate(
model_type=Value('page', output_field=CharField())
)
def _get_post_results(self, query):
return Post.objects.filter(
Q(title__icontains=query) |
Q(content__icontains=query)
).values('id', 'title', 'created_at').annotate(
model_type=Value('post', output_field=CharField())
)
def _get_all_results(self, query):
links = self._get_link_results(query)
pages = self._get_page_results(query)
posts = self._get_post_results(query)
return list(chain(links, pages, posts))
def _sort_results(self, results, sort_order):
if sort_order == 'newest':
return sorted(results, key=lambda x: x.get('created_at', ''), reverse=True)
elif sort_order == 'oldest':
return sorted(results, key=lambda x: x.get('created_at', ''))
elif sort_order == 'type':
return sorted(results, key=lambda x: (x.get('model_type', ''), x.get('created_at', '')), reverse=True)
return results
def _enrich_result(self, result):
"""Fetch full model data for search result"""
model_type = result['model_type']
model_id = result['model_id']
try:
if model_type == 'link':
link = Link.objects.get(id=model_id)
return {
'id': link.id,
'model_type': 'link',
'alias': link.alias,
'original_url': link.original_url,
'description': link.description,
'click_count': link.click_count,
'created_at': link.created_at,
'tags': list(link.tags.all())
}
elif model_type == 'page':
page = Page.objects.get(id=model_id)
return {
'id': page.id,
'model_type': 'page',
'title': page.title,
'url': page.url,
'summary': page.summary,
'created_at': page.created_at,
'tags': list(page.tags.all())
}
elif model_type == 'post':
post = Post.objects.get(id=model_id)
return {
'id': post.id,
'model_type': 'post',
'title': post.title,
'summary': post.summary,
'created_at': post.created_at,
'tags': list(post.tags.all())
}
except Exception as e:
logger.error(f"Error enriching result {model_type} {model_id}: {e}")
return None
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update({
'query': self.request.GET.get('q', ''),
'selected_type': self.request.GET.get('type', ''),
'sort_order': self.request.GET.get('sort', 'newest'),
'total_results': getattr(self, 'search_total', 0),
})
return context
+94
View File
@@ -0,0 +1,94 @@
"""
Django signals for search index maintenance
"""
from django.db.models.signals import post_save, post_delete, m2m_changed
from django.dispatch import receiver
from .models import Link, Page, Post
from .search_backend import search_backend
import logging
logger = logging.getLogger(__name__)
@receiver(post_save, sender=Link)
def update_link_index(sender, instance, created, **kwargs):
"""Update search index when a link is saved"""
try:
search_backend.index_link(instance)
except Exception as e:
logger.error(f"Error indexing link {instance.id}: {e}")
@receiver(post_save, sender=Page)
def update_page_index(sender, instance, created, **kwargs):
"""Update search index when a page is saved"""
try:
search_backend.index_page(instance)
except Exception as e:
logger.error(f"Error indexing page {instance.id}: {e}")
@receiver(post_save, sender=Post)
def update_post_index(sender, instance, created, **kwargs):
"""Update search index when a post is saved"""
try:
search_backend.index_post(instance)
except Exception as e:
logger.error(f"Error indexing post {instance.id}: {e}")
@receiver(post_delete, sender=Link)
def remove_link_from_index(sender, instance, **kwargs):
"""Remove link from search index when deleted"""
try:
search_backend.remove_from_index('link', instance.id)
except Exception as e:
logger.error(f"Error removing link {instance.id} from index: {e}")
@receiver(post_delete, sender=Page)
def remove_page_from_index(sender, instance, **kwargs):
"""Remove page from search index when deleted"""
try:
search_backend.remove_from_index('page', instance.id)
except Exception as e:
logger.error(f"Error removing page {instance.id} from index: {e}")
@receiver(post_delete, sender=Post)
def remove_post_from_index(sender, instance, **kwargs):
"""Remove post from search index when deleted"""
try:
search_backend.remove_from_index('post', instance.id)
except Exception as e:
logger.error(f"Error removing post {instance.id} from index: {e}")
@receiver(m2m_changed, sender=Link.tags.through)
def update_link_tags_index(sender, instance, action, **kwargs):
"""Update link index when tags change"""
if action in ['post_add', 'post_remove', 'post_clear']:
try:
search_backend.index_link(instance)
except Exception as e:
logger.error(f"Error updating link {instance.id} tags in index: {e}")
@receiver(m2m_changed, sender=Page.tags.through)
def update_page_tags_index(sender, instance, action, **kwargs):
"""Update page index when tags change"""
if action in ['post_add', 'post_remove', 'post_clear']:
try:
search_backend.index_page(instance)
except Exception as e:
logger.error(f"Error updating page {instance.id} tags in index: {e}")
@receiver(m2m_changed, sender=Post.tags.through)
def update_post_tags_index(sender, instance, action, **kwargs):
"""Update post index when tags change"""
if action in ['post_add', 'post_remove', 'post_clear']:
try:
search_backend.index_post(instance)
except Exception as e:
logger.error(f"Error updating post {instance.id} tags in index: {e}")
+15 -1
View File
@@ -79,6 +79,11 @@
<h2 class="text-lg font-medium text-gray-900">
{% trans "Search results for" %}: <span class="font-semibold">{{ query }}</span>
</h2>
{% if total_results %}
<p class="mt-1 text-sm text-gray-500">
{% trans "Found" %} <span class="font-medium">{{ total_results }}</span> {% trans "results" %}
</p>
{% endif %}
</div>
{% if results %}
@@ -127,7 +132,7 @@
{{ result.title|default:"Untitled" }}
</a>
</h3>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
{% trans "Page" %}
</span>
</div>
@@ -138,6 +143,15 @@
{% if result.summary %}
<p class="mt-2 text-sm text-gray-600 line-clamp-2">{{ result.summary }}</p>
{% endif %}
{% if result.tags %}
<div class="mt-2 flex flex-wrap gap-1">
{% for tag in result.tags %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700">
{{ tag.name }}
</span>
{% endfor %}
</div>
{% endif %}
{% endif %}
</div>
<div class="ml-4 flex-shrink-0 flex space-x-2">
-2
View File
@@ -16,8 +16,6 @@ urlpatterns = [
path('delete-selected/', views.delete_selected, name='delete_selected'),
path('detail/<int:pk>/', views.LinkDetailView.as_view(), name='link_detail'),
path('ui/search/', search_views.SearchView.as_view(), name='search'),
path('search/', search_views.search, name='search-links'),
path('search-aliases/', search_views.search_aliases, name='search-aliases'),
path('link/<int:pk>/', views.LinkDetailView.as_view(), name='link_detail'),
path('link/<int:pk>/edit/', views.LinkUpdateView.as_view(), name='link_update'),
path('ui/tools/', views.ToolsView.as_view(), name='tools'),
+1
View File
@@ -22,6 +22,7 @@ dependencies = [
"boto3>=1.35.0",
"python-magic>=0.4.27",
"pillow~=11.2.1",
"whoosh==2.7.4",
]
[build-system]
Generated
+17
View File
@@ -244,6 +244,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" },
{ url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" },
{ url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" },
{ url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" },
{ url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" },
{ url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" },
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
@@ -253,6 +255,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
{ url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
{ url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
@@ -260,6 +264,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
{ url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
{ url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
]
@@ -628,6 +634,7 @@ dependencies = [
{ name = "requests" },
{ name = "sqlparse" },
{ name = "whitenoise" },
{ name = "whoosh" },
]
[package.optional-dependencies]
@@ -664,6 +671,7 @@ requires-dist = [
{ name = "requests", specifier = "==2.32.4" },
{ name = "sqlparse", specifier = "==0.5.1" },
{ name = "whitenoise", specifier = "==5.3.0" },
{ name = "whoosh", specifier = "==2.7.4" },
]
provides-extras = ["dev"]
@@ -684,3 +692,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/78/29/84c808294f76d854e
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/1e/ec69984d05e570ec7c61d28cbce51b8f4623e4121ca57ac6ad76e4f5ffe8/whitenoise-5.3.0-py2.py3-none-any.whl", hash = "sha256:d963ef25639d1417e8a247be36e6aedd8c7c6f0a08adcb5a89146980a96b577c", size = 19822, upload-time = "2021-07-16T16:59:52.113Z" },
]
[[package]]
name = "whoosh"
version = "2.7.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/25/2b/6beed2107b148edc1321da0d489afc4617b9ed317ef7b72d4993cad9b684/Whoosh-2.7.4.tar.gz", hash = "sha256:7ca5633dbfa9e0e0fa400d3151a8a0c4bec53bd2ecedc0a67705b17565c31a83", size = 968741, upload-time = "2016-04-04T01:19:32.327Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/19/24d0f1f454a2c1eb689ca28d2f178db81e5024f42d82729a4ff6771155cf/Whoosh-2.7.4-py2.py3-none-any.whl", hash = "sha256:aa39c3c3426e3fd107dcb4bde64ca1e276a65a889d9085a6e4b54ba82420a852", size = 468790, upload-time = "2016-04-04T01:19:40.379Z" },
]