Metadata-Version: 2.4
Name: kliz
Version: 0.2.0
Summary: Bot d'indexation SEO agnostique pour notifier les moteurs de recherche.
Author: Freddy Choudja
License-Expression: MIT
Project-URL: Homepage, https://github.com/freddychoudja/kliz-
Project-URL: Repository, https://github.com/freddychoudja/kliz-.git
Project-URL: Issues, https://github.com/freddychoudja/kliz-/issues
Project-URL: Documentation, https://github.com/freddychoudja/kliz-#readme
Keywords: seo,indexing,indexnow,google
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Internet :: WWW/HTTP
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.28.0
Requires-Dist: google-auth>=2.0.0
Requires-Dist: google-auth-httplib2>=0.1.0
Requires-Dist: google-api-python-client>=2.0.0
Requires-Dist: httplib2<1.0.0,>=0.19.0
Provides-Extra: test
Requires-Dist: pytest<10.0,>=8.0; extra == "test"
Requires-Dist: pytest-cov<8.0,>=5.0; extra == "test"
Provides-Extra: dev
Requires-Dist: build<2.0,>=1.2; extra == "dev"
Requires-Dist: mypy<3.0,>=1.11; extra == "dev"
Requires-Dist: pip-audit<3.0,>=2.7; extra == "dev"
Requires-Dist: pytest<10.0,>=8.0; extra == "dev"
Requires-Dist: pytest-cov<8.0,>=5.0; extra == "dev"
Requires-Dist: ruff<1.0,>=0.9; extra == "dev"
Requires-Dist: twine<8.0,>=5.1; extra == "dev"
Requires-Dist: types-requests>=2.28.0; extra == "dev"
Dynamic: license-file

# kliz

[![CI](https://github.com/freddychoudja/kliz-/actions/workflows/ci.yml/badge.svg)](https://github.com/freddychoudja/kliz-/actions/workflows/ci.yml)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/kliz)](https://pypi.org/project/kliz/)
[![GitHub issues](https://img.shields.io/github/issues/freddychoudja/kliz-)](https://github.com/freddychoudja/kliz-/issues)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

`kliz` est un bot d'indexation SEO agnostique. Il permet à une application de
notifier plusieurs moteurs de recherche dès qu'une URL est créée ou mise à
jour.

Le package ne dépend ni de Django, ni de Celery, ni de Redis. Il expose une API
Python synchrone que l'application appelante peut exécuter directement ou
encapsuler dans le système de tâches de son choix.

## Installation

```bash
pip install kliz
```

Une documentation web statique est disponible dans
[`docs/index.html`](docs/index.html). Elle peut aussi être publiée via GitHub
Pages avec le workflow fourni.

Une traduction anglaise est disponible dans
[`README.en.md`](README.en.md).

Pour contribuer et exécuter les tests :

```bash
python -m pip install -e ".[dev]"
pytest --cov=kliz
```

## Démarrage rapide

```python
from kliz import GoogleProvider, IndexNowProvider, Kliz

indexer = Kliz(
    [
        IndexNowProvider(
            api_key="votre-cle-indexnow",
            key_location="https://example.com/votre-cle-indexnow.txt",
        ),
        GoogleProvider("/run/secrets/google-service-account.json"),
    ]
)

statuses = indexer.notify_all("https://example.com/articles/nouvel-article")
# {
#     "IndexNowProvider": True,
#     "GoogleProvider": True,
# }
```

Pour soumettre plusieurs URL d'un coup, `notify_many` découpe selon
`max_urls_per_request` (lots IndexNow) et retombe sur une boucle `notify` pour
les autres providers :

```python
statuses = indexer.notify_many(
    [
        "https://example.com/articles/a",
        "https://example.com/articles/b",
    ]
)
```

Le retry intégré est **désactivé par défaut** (`max_attempts=1`). Pour l'activer
avec backoff exponentiel et jitter :

```python
indexer = Kliz(
    [IndexNowProvider(api_key="votre-cle-indexnow")],
    max_attempts=3,
)
```

`notify_all` continue d'appeler les autres fournisseurs lorsqu'un fournisseur
échoue. Son statut vaut alors `False`. Un appel direct à `provider.notify(url)`
laisse en revanche remonter une `ProviderError` afin que l'application puisse
appliquer sa propre politique de retry.

Pour obtenir la cause, le statut HTTP et l'indication de retry :

```python
results = indexer.notify_all_detailed(
    "https://example.com/articles/nouvel-article"
)

for name, result in results.items():
    print(name, result.success, result.retryable, result.error)
```

Si plusieurs instances ont le même nom, leurs clés sont suffixées :
`IndexNowProvider`, `IndexNowProvider#2`, etc.

## Architecture agnostique

`BaseProvider` définit une stratégie minimale : `notify(url) -> bool`. Chaque
adaptateur traduit ce contrat vers l'API distante concernée :

- `IndexNowProvider` envoie une requête HTTP à l'API IndexNow ;
- `GoogleProvider` publie une notification `URL_UPDATED` via l'API Google
  Indexing ;
- `Kliz` orchestre les stratégies injectées dans son constructeur.

Cette séparation permet d'ajouter un moteur sans modifier l'orchestrateur et
laisse l'application libre de choisir son framework web, sa file d'attente et
sa politique de retry.

Un fournisseur personnalisé doit uniquement hériter de `BaseProvider` :

```python
from kliz import BaseProvider


class CustomProvider(BaseProvider):
    def notify(self, url: str) -> bool:
        # Appel vers l'API du moteur concerné
        return True
```

Pour un moteur qui accepte des lots d'URL sur le même hôte, héritez de
`BatchProvider` : `notify` et la validation (hôte commun, taille max, URL
propres) sont fournis ; il reste à implémenter `_notify_many`.

```python
from urllib.parse import SplitResult

from kliz import BatchProvider


class CustomBatchProvider(BatchProvider):
    max_urls_per_request = 100

    def _notify_many(
        self, urls: list[str], parsed_urls: list[SplitResult]
    ) -> bool:
        # Appel HTTP groupé vers le moteur
        return True
```

## Validation des URL

Toutes les URL soumises à un provider sont contrôlées avant tout envoi :

- le schéma doit être `http` ou `https` et l'hôte doit être présent ;
- les identifiants (`https://user:pass@...`) sont interdits ;
- les fragments (`#...`) sont toujours rejetés : ils ne sont jamais transmis au
  serveur et ne peuvent donc désigner un contenu distinct ;
- les chaînes de requête (`?...`) sont rejetées pour les notifications : seule
  une URL canonique propre est soumise aux moteurs.

La fonction partagée `parse_http_url(url, require_clean=True)` applique ces
règles. `require_clean` vaut `False` par défaut afin de ne pas casser les
usages existants ; seules les notifications exigent une URL propre.

## Configuration des fournisseurs

### IndexNow

La clé doit être publiée conformément aux règles d'IndexNow. Si
`key_location` est fourni, il est transmis dans le champ `keyLocation`.

```python
from kliz import IndexNowProvider

provider = IndexNowProvider(
    api_key="votre-cle-valide",
    key_location="https://example.com/votre-cle-valide.txt",  # optionnel
    timeout=10.0,
)
provider.notify("https://example.com/page")
```

Le provider réutilise une connexion HTTP persistante (`requests.Session`) entre
les notifications, afin de ne pas reconstruire une connexion et une poignée de
main TLS à chaque appel. Vous pouvez injecter votre propre session (tests,
configuration réseau partagée, proxies) :

```python
import requests

provider = IndexNowProvider(
    api_key="votre-cle-valide",
    session=requests.Session(),
)
```

La session interne garde les connexions ouvertes ; appelez `provider.close()` à
l'arrêt de votre application pour les libérer proprement.

Pour soumettre plusieurs URL du même hôte dans un seul appel :

```python
provider.notify_many(
    [
        "https://example.com/page-1",
        "https://example.com/page-2",
    ]
)
```

IndexNow accepte jusqu'à 10 000 URL par requête. `kliz` classe les erreurs
`429` et `5xx` comme retentables.

### Google

Activez l'API Google Indexing pour votre projet, créez un compte de service et
autorisez-le sur la propriété concernée. Ne versionnez jamais le fichier JSON
du compte de service.

> **Restriction importante :** l'API Google Indexing est officiellement
> réservée aux pages contenant un `JobPosting` ou un `BroadcastEvent` intégré
> dans un `VideoObject`. N'utilisez pas ce provider comme API d'indexation
> générique pour les autres contenus ; utilisez notamment un sitemap pour leur
> couverture.

```python
from kliz import GoogleProvider

provider = GoogleProvider(
    "/run/secrets/google-service-account.json",
    timeout=60.0,
    num_retries=2,
)
provider.notify("https://example.com/jobs/backend-python")
```

L'API Google Indexing est soumise aux règles d'éligibilité et aux quotas de
Google. Une notification ne garantit pas l'indexation de l'URL.

Le client Indexing est construit de manière paresseuse : le fichier de compte
de service n'est lu qu'au premier appel de `notify`, puis réutilisé pour les
appels suivants. La création du provider ne déclenche donc aucune lecture de
fichier. Les erreurs de configuration (fichier absent, JSON invalide)
remontent au moment de la notification, sont marquées comme non retentables, et
le provider se rétablit dès que le fichier est corrigé.

## Recettes / Intégration Asynchrone

`kliz` reste volontairement synchrone. Pour une exécution asynchrone, placez
l'appel dans un worker, une tâche ou un job appartenant à votre application.
Ainsi, les dépendances d'infrastructure ne contaminent pas le package.

### Tâche Celery (Python/Django)

Dans un projet Django utilisant déjà Celery, la tâche peut lire sa
configuration depuis les settings et laisser Celery gérer les retries :

```python
# myapp/tasks.py — ce code appartient à l'application, pas à kliz
from dataclasses import asdict

from celery import shared_task
from django.conf import settings

from kliz import IndexNowProvider, Kliz


@shared_task(bind=True, max_retries=5)
def notify_search_engines(self, url: str) -> dict[str, dict[str, object]]:
    indexer = Kliz(
        [
            IndexNowProvider(
                api_key=settings.INDEXNOW_API_KEY,
                key_location=settings.INDEXNOW_KEY_LOCATION,
            ),
        ]
    )
    results = indexer.notify_all_detailed(url)
    retryable = [result for result in results.values() if result.retryable]

    if retryable:
        raise self.retry(
            exc=RuntimeError("temporary indexing provider failure"),
            countdown=min(60 * (2**self.request.retries), 3600),
        )

    return {name: asdict(result) for name, result in results.items()}
```

Depuis une vue, un signal ou un service Django :

```python
from myapp.tasks import notify_search_engines

notify_search_engines.delay("https://example.com/articles/nouveau")
```

Pour isoler les retries et quotas de chaque moteur, utilisez idéalement une
tâche par provider. Le provider Google ne doit être ajouté que pour les pages
officiellement éligibles.

### Job générique

Le même principe fonctionne avec un scheduler, un worker maison, RQ, Dramatiq,
une fonction serverless ou un cron. Le job ne connaît que l'API publique de
`kliz` :

```python
from kliz import IndexNowProvider, Kliz


class ContentIndexingJob:
    def __init__(self, api_key: str) -> None:
        self.indexer = Kliz([IndexNowProvider(api_key=api_key)])

    def run(self, payload: dict[str, str]) -> dict[str, bool]:
        return self.indexer.notify_all(payload["url"])


# Le système de jobs choisi sérialise ce payload et appelle job.run(payload).
job = ContentIndexingJob(api_key="votre-cle")
result = job.run({"url": "https://example.com/page-modifiee"})
```

## Interface en ligne de commande

L'installation fournit aussi une commande `kliz` :

```bash
export KLIZ_INDEXNOW_API_KEY="votre-cle"
export KLIZ_INDEXNOW_KEY_LOCATION="https://example.com/votre-cle.txt"

kliz notify https://example.com/page  # une URL
kliz notify --batch urls.txt          # une URL par ligne, `#` pour un commentaire
kliz providers                        # liste des providers configurés
kliz --version
```

Les crédits se passent aussi en options (`--indexnow-api-key`,
`--indexnow-key-location`, `--google-service-account-file`). Le processus
termine avec le code `0` si tout a réussi, `1` en cas d'échec de notification
et `2` en cas de configuration invalide.

## Tests

Les tests mockent les appels `requests` et le client Google. Ils ne nécessitent
donc ni accès réseau, ni clé IndexNow, ni compte de service Google.

La validation complète locale est :

```bash
ruff format --check src tests
ruff check src tests
mypy src
pytest --cov=kliz
python -m build
twine check --strict dist/*
pip-audit . --strict
```

## Exploitation en production

Le package ne stocke aucun secret et n'impose aucun système de tâches. Dans
l'application qui l'utilise :

- injectez les clés par un gestionnaire de secrets ;
- activez le retry opt-in de `Kliz` (`max_attempts`) ou appliquez un backoff
  applicatif aux résultats `retryable=True` ;
- placez les échecs définitifs dans une dead-letter queue ;
- mesurez latence, taux de succès, codes HTTP et quotas par provider ;
- ne partagez pas une même instance `GoogleProvider` entre plusieurs threads ;
- conservez un sitemap à jour : une notification ne garantit jamais
  l'indexation.

## Publication

Les tags `vX.Y.Z` déclenchent le workflow de release. Le tag doit correspondre
exactement à la version de `pyproject.toml`. La publication utilise le Trusted
Publishing PyPI et ne nécessite aucun token PyPI permanent dans GitHub.

Avant la première release, configurez sur PyPI un publisher avec le dépôt
`freddychoudja/kliz-`, le workflow `release.yml` et l'environnement `pypi`.

## Contribuer

Les contributions sont les bienvenues. Consultez
[CONTRIBUTING.md](CONTRIBUTING.md) avant d'ouvrir une issue ou une pull
request.

Le code source et le suivi du projet sont disponibles sur
[GitHub](https://github.com/freddychoudja/kliz-).

## Licence

`kliz` est distribué sous la [licence MIT](LICENSE). Copyright © 2026 Freddy
Choudja.
