Script de recherche interactive de posts Hugo
Le script suivant fournit une fonctionnalité de recherche interactive pour les posts Hugo. Il vous permet de rechercher des posts par titre et fournit une interface interactive basée sur ncurses pour naviguer dans les résultats de recherche.
Lors de l’appui sur Enter, le script copie un lien Markdown dans le presse-papiers, qui peut être collé dans votre post Hugo.

hugo_post_search.py
#!/usr/bin/env python3
"""
Outil de recherche de posts de blog Hugo
Un outil basé sur ncurses pour rechercher dans les posts de blog Hugo, générer des URL,
et copier des liens Markdown dans le presse-papiers.
"""
import os
import sys
import json
import re
import curses
import argparse
import hashlib
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Tuple, Optional
import subprocess
try:
import pyperclip
except ImportError:
pyperclip = None
class HugoPost:
"""Représente un post de blog Hugo avec ses métadonnées."""
def __init__(self, filepath: Path):
self.filepath = filepath
self.title = ""
self.slug = ""
self.date = None
self.url = ""
self.mtime = 0
def parse_frontmatter(self) -> bool:
"""Analyser le frontmatter YAML du post."""
try:
with open(self.filepath, 'r', encoding='utf-8') as f:
content = f.read()
# Extraire le frontmatter
if not content.startswith('---'):
return False
end_marker = content.find('---', 3)
if end_marker == -1:
return False
frontmatter = content[3:end_marker].strip()
# Analyser le titre
title_match = re.search(r'^title:\s*["\']?(.*?)["\']?\s*$', frontmatter, re.MULTILINE)
if title_match:
self.title = title_match.group(1).strip()
# Analyser le slug
slug_match = re.search(r'^slug:\s*["\']?(.*?)["\']?\s*$', frontmatter, re.MULTILINE)
if slug_match:
self.slug = slug_match.group(1).strip()
# Analyser la date
date_match = re.search(r'^date:\s*["\']?(.*?)["\']?\s*$', frontmatter, re.MULTILINE)
if date_match:
date_str = date_match.group(1).strip()
try:
# Essayer différents formats de date
for fmt in ['%Y-%m-%d', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%dT%H:%M:%SZ']:
try:
self.date = datetime.strptime(date_str.split('T')[0] if 'T' in date_str else date_str, '%Y-%m-%d')
break
except ValueError:
continue
except:
pass
# Obtenir l'heure de modification du fichier
self.mtime = os.path.getmtime(self.filepath)
# Générer l'URL
if self.date and self.slug:
self.url = f"/{self.date.year:04d}/{self.date.month:02d}/{self.date.day:02d}/{self.slug}/"
return bool(self.title and self.slug and self.date)
except Exception as e:
return False
class PostCache:
"""Gère le cache des posts Hugo."""
def __init__(self, posts_dir: Path, cache_file: Path):
self.posts_dir = posts_dir
self.cache_file = cache_file
self.posts: List[HugoPost] = []
def get_directory_hash(self) -> str:
"""Obtenir un hash de l'état du répertoire des posts."""
hash_obj = hashlib.md5()
if not self.posts_dir.exists():
return hash_obj.hexdigest()
for md_file in sorted(self.posts_dir.glob('**/*.md')):
try:
stat = md_file.stat()
hash_obj.update(f"{md_file}:{stat.st_mtime}:{stat.st_size}".encode())
except:
continue
return hash_obj.hexdigest()
def load_cache(self) -> bool:
"""Charger les posts depuis le cache s'il est valide."""
if not self.cache_file.exists():
return False
try:
with open(self.cache_file, 'r', encoding='utf-8') as f:
cache_data = json.load(f)
# Vérifier si le cache est encore valide
if cache_data.get('directory_hash') != self.get_directory_hash():
return False
# Charger les posts depuis le cache
self.posts = []
for post_data in cache_data.get('posts', []):
post = HugoPost(Path(post_data['filepath']))
post.title = post_data['title']
post.slug = post_data['slug']
if post_data['date']:
post.date = datetime.fromisoformat(post_data['date'])
post.url = post_data['url']
post.mtime = post_data['mtime']
self.posts.append(post)
return True
except Exception as e:
return False
def save_cache(self):
"""Sauvegarder les posts dans le cache."""
posts_data = []
for post in self.posts:
post_data = {
'filepath': str(post.filepath),
'title': post.title,
'slug': post.slug,
'date': post.date.isoformat() if post.date else None,
'url': post.url,
'mtime': post.mtime
}
posts_data.append(post_data)
cache_data = {
'directory_hash': self.get_directory_hash(),
'posts': posts_data
}
# Assurer que le répertoire de cache existe
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.cache_file, 'w', encoding='utf-8') as f:
json.dump(cache_data, f, indent=2)
def scan_posts(self):
"""Scanner le répertoire des posts et construire le cache."""
self.posts = []
if not self.posts_dir.exists():
return
for md_file in self.posts_dir.glob('**/*.md'):
post = HugoPost(md_file)
if post.parse_frontmatter():
self.posts.append(post)
# Trier par date (plus récent en premier)
self.posts.sort(key=lambda p: p.date or datetime.min, reverse=True)
def get_posts(self) -> List[HugoPost]:
"""Obtenir tous les posts, chargeant depuis le cache ou scannant si nécessaire."""
if not self.load_cache():
self.scan_posts()
self.save_cache()
return self.posts
class PostSearchUI:
"""Interface de recherche basée sur ncurses pour les posts Hugo."""
def __init__(self, posts: List[HugoPost]):
self.posts = posts
self.filtered_posts = posts[:]
self.search_term = ""
self.selected_index = 0
self.scroll_offset = 0
def filter_posts(self, search_term: str):
"""Filtrer les posts selon le terme de recherche."""
if not search_term.strip():
self.filtered_posts = self.posts[:]
else:
term_lower = search_term.lower()
self.filtered_posts = [
post for post in self.posts
if term_lower in post.title.lower() or term_lower in post.slug.lower()
]
# Réinitialiser la sélection
self.selected_index = 0
self.scroll_offset = 0
def copy_to_clipboard(self, text: str) -> bool:
"""Copier le texte dans le presse-papiers."""
if pyperclip:
try:
pyperclip.copy(text)
return True
except:
pass
# Solution de secours : essayer les commandes système
try:
# Essayer xclip (Linux)
subprocess.run(['xclip', '-selection', 'clipboard'],
input=text.encode(), check=True)
return True
except:
try:
# Essayer pbcopy (macOS)
subprocess.run(['pbcopy'], input=text.encode(), check=True)
return True
except:
pass
return False
def run(self, stdscr):
"""Exécuter l'interface ncurses."""
# Initialiser les couleurs
curses.curs_set(1) # Afficher le curseur
curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE) # Sélectionné
curses.init_pair(2, curses.COLOR_BLUE, curses.COLOR_BLACK) # Recherche
while True:
height, width = stdscr.getmaxyx()
stdscr.clear()
# Dessiner la ligne de recherche
search_line = f"Search: {self.search_term}"
stdscr.addstr(0, 0, search_line[:width-1], curses.color_pair(2))
# Dessiner la liste des posts
visible_height = height - 2
start_idx = self.scroll_offset
end_idx = min(start_idx + visible_height, len(self.filtered_posts))
for i in range(start_idx, end_idx):
row = i - start_idx + 1
if row >= height - 1:
break
post = self.filtered_posts[i]
title = post.title[:width-1]
if i == self.selected_index:
stdscr.addstr(row, 0, title, curses.color_pair(1))
else:
stdscr.addstr(row, 0, title)
# Afficher la ligne de statut
if self.filtered_posts:
status = f"{len(self.filtered_posts)} posts found"
if len(self.filtered_posts) > visible_height:
status += f" (showing {start_idx+1}-{end_idx})"
else:
status = "No posts found"
if height > 1:
stdscr.addstr(height-1, 0, status[:width-1])
# Positionner le curseur dans le champ de recherche
cursor_pos = min(len(search_line), width-1)
stdscr.move(0, cursor_pos)
stdscr.refresh()
# Gérer les entrées
key = stdscr.getch()
if key == 27: # Échap
return None
elif key in (10, 13): # Entrée
if self.filtered_posts and 0 <= self.selected_index < len(self.filtered_posts):
selected_post = self.filtered_posts[self.selected_index]
markdown_link = f"[{selected_post.title}]({selected_post.url})"
return markdown_link
elif key == curses.KEY_UP:
if self.selected_index > 0:
self.selected_index -= 1
if self.selected_index < self.scroll_offset:
self.scroll_offset = self.selected_index
elif key == curses.KEY_DOWN:
if self.selected_index < len(self.filtered_posts) - 1:
self.selected_index += 1
if self.selected_index >= self.scroll_offset + visible_height:
self.scroll_offset = self.selected_index - visible_height + 1
elif key == curses.KEY_BACKSPACE or key == 127:
if self.search_term:
self.search_term = self.search_term[:-1]
self.filter_posts(self.search_term)
elif 32 <= key <= 126: # Caractères imprimables
self.search_term += chr(key)
self.filter_posts(self.search_term)
def main():
parser = argparse.ArgumentParser(description='Hugo Blog Post Search Tool')
parser.add_argument('--posts-dir', type=Path, default=Path('content/post'),
help='Path to Hugo posts directory (default: content/post)')
parser.add_argument('--cache-file', type=Path, default=Path('.hugo_post_cache.json'),
help='Path to cache file (default: .hugo_post_cache.json)')
args = parser.parse_args()
# Initialiser le cache
cache = PostCache(args.posts_dir, args.cache_file)
try:
posts = cache.get_posts()
except Exception as e:
print(f"Error loading posts: {e}", file=sys.stderr)
return 1
if not posts:
print("No valid Hugo posts found.", file=sys.stderr)
return 1
# Initialiser et exécuter l'interface
ui = PostSearchUI(posts)
try:
result = curses.wrapper(ui.run)
if result:
print(result)
if ui.copy_to_clipboard(result):
print("Copied to clipboard!", file=sys.stderr)
else:
print("Could not copy to clipboard. Install pyperclip or xclip/pbcopy.", file=sys.stderr)
return 0
except KeyboardInterrupt:
return 1
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
if __name__ == '__main__':
sys.exit(main())If this post helped you, please consider buying me a coffee or donating via PayPal to support research & publishing of new posts on TechOverflow