kliz 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kliz-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Freddy Choudja
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
kliz-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,436 @@
1
+ Metadata-Version: 2.4
2
+ Name: kliz
3
+ Version: 0.2.0
4
+ Summary: Bot d'indexation SEO agnostique pour notifier les moteurs de recherche.
5
+ Author: Freddy Choudja
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/freddychoudja/kliz-
8
+ Project-URL: Repository, https://github.com/freddychoudja/kliz-.git
9
+ Project-URL: Issues, https://github.com/freddychoudja/kliz-/issues
10
+ Project-URL: Documentation, https://github.com/freddychoudja/kliz-#readme
11
+ Keywords: seo,indexing,indexnow,google
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Internet :: WWW/HTTP
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: requests>=2.28.0
27
+ Requires-Dist: google-auth>=2.0.0
28
+ Requires-Dist: google-auth-httplib2>=0.1.0
29
+ Requires-Dist: google-api-python-client>=2.0.0
30
+ Requires-Dist: httplib2<1.0.0,>=0.19.0
31
+ Provides-Extra: test
32
+ Requires-Dist: pytest<10.0,>=8.0; extra == "test"
33
+ Requires-Dist: pytest-cov<8.0,>=5.0; extra == "test"
34
+ Provides-Extra: dev
35
+ Requires-Dist: build<2.0,>=1.2; extra == "dev"
36
+ Requires-Dist: mypy<3.0,>=1.11; extra == "dev"
37
+ Requires-Dist: pip-audit<3.0,>=2.7; extra == "dev"
38
+ Requires-Dist: pytest<10.0,>=8.0; extra == "dev"
39
+ Requires-Dist: pytest-cov<8.0,>=5.0; extra == "dev"
40
+ Requires-Dist: ruff<1.0,>=0.9; extra == "dev"
41
+ Requires-Dist: twine<8.0,>=5.1; extra == "dev"
42
+ Requires-Dist: types-requests>=2.28.0; extra == "dev"
43
+ Dynamic: license-file
44
+
45
+ # kliz
46
+
47
+ [![CI](https://github.com/freddychoudja/kliz-/actions/workflows/ci.yml/badge.svg)](https://github.com/freddychoudja/kliz-/actions/workflows/ci.yml)
48
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/kliz)](https://pypi.org/project/kliz/)
49
+ [![GitHub issues](https://img.shields.io/github/issues/freddychoudja/kliz-)](https://github.com/freddychoudja/kliz-/issues)
50
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
51
+
52
+ `kliz` est un bot d'indexation SEO agnostique. Il permet à une application de
53
+ notifier plusieurs moteurs de recherche dès qu'une URL est créée ou mise à
54
+ jour.
55
+
56
+ Le package ne dépend ni de Django, ni de Celery, ni de Redis. Il expose une API
57
+ Python synchrone que l'application appelante peut exécuter directement ou
58
+ encapsuler dans le système de tâches de son choix.
59
+
60
+ ## Installation
61
+
62
+ ```bash
63
+ pip install kliz
64
+ ```
65
+
66
+ Une documentation web statique est disponible dans
67
+ [`docs/index.html`](docs/index.html). Elle peut aussi être publiée via GitHub
68
+ Pages avec le workflow fourni.
69
+
70
+ Une traduction anglaise est disponible dans
71
+ [`README.en.md`](README.en.md).
72
+
73
+ Pour contribuer et exécuter les tests :
74
+
75
+ ```bash
76
+ python -m pip install -e ".[dev]"
77
+ pytest --cov=kliz
78
+ ```
79
+
80
+ ## Démarrage rapide
81
+
82
+ ```python
83
+ from kliz import GoogleProvider, IndexNowProvider, Kliz
84
+
85
+ indexer = Kliz(
86
+ [
87
+ IndexNowProvider(
88
+ api_key="votre-cle-indexnow",
89
+ key_location="https://example.com/votre-cle-indexnow.txt",
90
+ ),
91
+ GoogleProvider("/run/secrets/google-service-account.json"),
92
+ ]
93
+ )
94
+
95
+ statuses = indexer.notify_all("https://example.com/articles/nouvel-article")
96
+ # {
97
+ # "IndexNowProvider": True,
98
+ # "GoogleProvider": True,
99
+ # }
100
+ ```
101
+
102
+ Pour soumettre plusieurs URL d'un coup, `notify_many` découpe selon
103
+ `max_urls_per_request` (lots IndexNow) et retombe sur une boucle `notify` pour
104
+ les autres providers :
105
+
106
+ ```python
107
+ statuses = indexer.notify_many(
108
+ [
109
+ "https://example.com/articles/a",
110
+ "https://example.com/articles/b",
111
+ ]
112
+ )
113
+ ```
114
+
115
+ Le retry intégré est **désactivé par défaut** (`max_attempts=1`). Pour l'activer
116
+ avec backoff exponentiel et jitter :
117
+
118
+ ```python
119
+ indexer = Kliz(
120
+ [IndexNowProvider(api_key="votre-cle-indexnow")],
121
+ max_attempts=3,
122
+ )
123
+ ```
124
+
125
+ `notify_all` continue d'appeler les autres fournisseurs lorsqu'un fournisseur
126
+ échoue. Son statut vaut alors `False`. Un appel direct à `provider.notify(url)`
127
+ laisse en revanche remonter une `ProviderError` afin que l'application puisse
128
+ appliquer sa propre politique de retry.
129
+
130
+ Pour obtenir la cause, le statut HTTP et l'indication de retry :
131
+
132
+ ```python
133
+ results = indexer.notify_all_detailed(
134
+ "https://example.com/articles/nouvel-article"
135
+ )
136
+
137
+ for name, result in results.items():
138
+ print(name, result.success, result.retryable, result.error)
139
+ ```
140
+
141
+ Si plusieurs instances ont le même nom, leurs clés sont suffixées :
142
+ `IndexNowProvider`, `IndexNowProvider#2`, etc.
143
+
144
+ ## Architecture agnostique
145
+
146
+ `BaseProvider` définit une stratégie minimale : `notify(url) -> bool`. Chaque
147
+ adaptateur traduit ce contrat vers l'API distante concernée :
148
+
149
+ - `IndexNowProvider` envoie une requête HTTP à l'API IndexNow ;
150
+ - `GoogleProvider` publie une notification `URL_UPDATED` via l'API Google
151
+ Indexing ;
152
+ - `Kliz` orchestre les stratégies injectées dans son constructeur.
153
+
154
+ Cette séparation permet d'ajouter un moteur sans modifier l'orchestrateur et
155
+ laisse l'application libre de choisir son framework web, sa file d'attente et
156
+ sa politique de retry.
157
+
158
+ Un fournisseur personnalisé doit uniquement hériter de `BaseProvider` :
159
+
160
+ ```python
161
+ from kliz import BaseProvider
162
+
163
+
164
+ class CustomProvider(BaseProvider):
165
+ def notify(self, url: str) -> bool:
166
+ # Appel vers l'API du moteur concerné
167
+ return True
168
+ ```
169
+
170
+ Pour un moteur qui accepte des lots d'URL sur le même hôte, héritez de
171
+ `BatchProvider` : `notify` et la validation (hôte commun, taille max, URL
172
+ propres) sont fournis ; il reste à implémenter `_notify_many`.
173
+
174
+ ```python
175
+ from urllib.parse import SplitResult
176
+
177
+ from kliz import BatchProvider
178
+
179
+
180
+ class CustomBatchProvider(BatchProvider):
181
+ max_urls_per_request = 100
182
+
183
+ def _notify_many(
184
+ self, urls: list[str], parsed_urls: list[SplitResult]
185
+ ) -> bool:
186
+ # Appel HTTP groupé vers le moteur
187
+ return True
188
+ ```
189
+
190
+ ## Validation des URL
191
+
192
+ Toutes les URL soumises à un provider sont contrôlées avant tout envoi :
193
+
194
+ - le schéma doit être `http` ou `https` et l'hôte doit être présent ;
195
+ - les identifiants (`https://user:pass@...`) sont interdits ;
196
+ - les fragments (`#...`) sont toujours rejetés : ils ne sont jamais transmis au
197
+ serveur et ne peuvent donc désigner un contenu distinct ;
198
+ - les chaînes de requête (`?...`) sont rejetées pour les notifications : seule
199
+ une URL canonique propre est soumise aux moteurs.
200
+
201
+ La fonction partagée `parse_http_url(url, require_clean=True)` applique ces
202
+ règles. `require_clean` vaut `False` par défaut afin de ne pas casser les
203
+ usages existants ; seules les notifications exigent une URL propre.
204
+
205
+ ## Configuration des fournisseurs
206
+
207
+ ### IndexNow
208
+
209
+ La clé doit être publiée conformément aux règles d'IndexNow. Si
210
+ `key_location` est fourni, il est transmis dans le champ `keyLocation`.
211
+
212
+ ```python
213
+ from kliz import IndexNowProvider
214
+
215
+ provider = IndexNowProvider(
216
+ api_key="votre-cle-valide",
217
+ key_location="https://example.com/votre-cle-valide.txt", # optionnel
218
+ timeout=10.0,
219
+ )
220
+ provider.notify("https://example.com/page")
221
+ ```
222
+
223
+ Le provider réutilise une connexion HTTP persistante (`requests.Session`) entre
224
+ les notifications, afin de ne pas reconstruire une connexion et une poignée de
225
+ main TLS à chaque appel. Vous pouvez injecter votre propre session (tests,
226
+ configuration réseau partagée, proxies) :
227
+
228
+ ```python
229
+ import requests
230
+
231
+ provider = IndexNowProvider(
232
+ api_key="votre-cle-valide",
233
+ session=requests.Session(),
234
+ )
235
+ ```
236
+
237
+ La session interne garde les connexions ouvertes ; appelez `provider.close()` à
238
+ l'arrêt de votre application pour les libérer proprement.
239
+
240
+ Pour soumettre plusieurs URL du même hôte dans un seul appel :
241
+
242
+ ```python
243
+ provider.notify_many(
244
+ [
245
+ "https://example.com/page-1",
246
+ "https://example.com/page-2",
247
+ ]
248
+ )
249
+ ```
250
+
251
+ IndexNow accepte jusqu'à 10 000 URL par requête. `kliz` classe les erreurs
252
+ `429` et `5xx` comme retentables.
253
+
254
+ ### Google
255
+
256
+ Activez l'API Google Indexing pour votre projet, créez un compte de service et
257
+ autorisez-le sur la propriété concernée. Ne versionnez jamais le fichier JSON
258
+ du compte de service.
259
+
260
+ > **Restriction importante :** l'API Google Indexing est officiellement
261
+ > réservée aux pages contenant un `JobPosting` ou un `BroadcastEvent` intégré
262
+ > dans un `VideoObject`. N'utilisez pas ce provider comme API d'indexation
263
+ > générique pour les autres contenus ; utilisez notamment un sitemap pour leur
264
+ > couverture.
265
+
266
+ ```python
267
+ from kliz import GoogleProvider
268
+
269
+ provider = GoogleProvider(
270
+ "/run/secrets/google-service-account.json",
271
+ timeout=60.0,
272
+ num_retries=2,
273
+ )
274
+ provider.notify("https://example.com/jobs/backend-python")
275
+ ```
276
+
277
+ L'API Google Indexing est soumise aux règles d'éligibilité et aux quotas de
278
+ Google. Une notification ne garantit pas l'indexation de l'URL.
279
+
280
+ Le client Indexing est construit de manière paresseuse : le fichier de compte
281
+ de service n'est lu qu'au premier appel de `notify`, puis réutilisé pour les
282
+ appels suivants. La création du provider ne déclenche donc aucune lecture de
283
+ fichier. Les erreurs de configuration (fichier absent, JSON invalide)
284
+ remontent au moment de la notification, sont marquées comme non retentables, et
285
+ le provider se rétablit dès que le fichier est corrigé.
286
+
287
+ ## Recettes / Intégration Asynchrone
288
+
289
+ `kliz` reste volontairement synchrone. Pour une exécution asynchrone, placez
290
+ l'appel dans un worker, une tâche ou un job appartenant à votre application.
291
+ Ainsi, les dépendances d'infrastructure ne contaminent pas le package.
292
+
293
+ ### Tâche Celery (Python/Django)
294
+
295
+ Dans un projet Django utilisant déjà Celery, la tâche peut lire sa
296
+ configuration depuis les settings et laisser Celery gérer les retries :
297
+
298
+ ```python
299
+ # myapp/tasks.py — ce code appartient à l'application, pas à kliz
300
+ from dataclasses import asdict
301
+
302
+ from celery import shared_task
303
+ from django.conf import settings
304
+
305
+ from kliz import IndexNowProvider, Kliz
306
+
307
+
308
+ @shared_task(bind=True, max_retries=5)
309
+ def notify_search_engines(self, url: str) -> dict[str, dict[str, object]]:
310
+ indexer = Kliz(
311
+ [
312
+ IndexNowProvider(
313
+ api_key=settings.INDEXNOW_API_KEY,
314
+ key_location=settings.INDEXNOW_KEY_LOCATION,
315
+ ),
316
+ ]
317
+ )
318
+ results = indexer.notify_all_detailed(url)
319
+ retryable = [result for result in results.values() if result.retryable]
320
+
321
+ if retryable:
322
+ raise self.retry(
323
+ exc=RuntimeError("temporary indexing provider failure"),
324
+ countdown=min(60 * (2**self.request.retries), 3600),
325
+ )
326
+
327
+ return {name: asdict(result) for name, result in results.items()}
328
+ ```
329
+
330
+ Depuis une vue, un signal ou un service Django :
331
+
332
+ ```python
333
+ from myapp.tasks import notify_search_engines
334
+
335
+ notify_search_engines.delay("https://example.com/articles/nouveau")
336
+ ```
337
+
338
+ Pour isoler les retries et quotas de chaque moteur, utilisez idéalement une
339
+ tâche par provider. Le provider Google ne doit être ajouté que pour les pages
340
+ officiellement éligibles.
341
+
342
+ ### Job générique
343
+
344
+ Le même principe fonctionne avec un scheduler, un worker maison, RQ, Dramatiq,
345
+ une fonction serverless ou un cron. Le job ne connaît que l'API publique de
346
+ `kliz` :
347
+
348
+ ```python
349
+ from kliz import IndexNowProvider, Kliz
350
+
351
+
352
+ class ContentIndexingJob:
353
+ def __init__(self, api_key: str) -> None:
354
+ self.indexer = Kliz([IndexNowProvider(api_key=api_key)])
355
+
356
+ def run(self, payload: dict[str, str]) -> dict[str, bool]:
357
+ return self.indexer.notify_all(payload["url"])
358
+
359
+
360
+ # Le système de jobs choisi sérialise ce payload et appelle job.run(payload).
361
+ job = ContentIndexingJob(api_key="votre-cle")
362
+ result = job.run({"url": "https://example.com/page-modifiee"})
363
+ ```
364
+
365
+ ## Interface en ligne de commande
366
+
367
+ L'installation fournit aussi une commande `kliz` :
368
+
369
+ ```bash
370
+ export KLIZ_INDEXNOW_API_KEY="votre-cle"
371
+ export KLIZ_INDEXNOW_KEY_LOCATION="https://example.com/votre-cle.txt"
372
+
373
+ kliz notify https://example.com/page # une URL
374
+ kliz notify --batch urls.txt # une URL par ligne, `#` pour un commentaire
375
+ kliz providers # liste des providers configurés
376
+ kliz --version
377
+ ```
378
+
379
+ Les crédits se passent aussi en options (`--indexnow-api-key`,
380
+ `--indexnow-key-location`, `--google-service-account-file`). Le processus
381
+ termine avec le code `0` si tout a réussi, `1` en cas d'échec de notification
382
+ et `2` en cas de configuration invalide.
383
+
384
+ ## Tests
385
+
386
+ Les tests mockent les appels `requests` et le client Google. Ils ne nécessitent
387
+ donc ni accès réseau, ni clé IndexNow, ni compte de service Google.
388
+
389
+ La validation complète locale est :
390
+
391
+ ```bash
392
+ ruff format --check src tests
393
+ ruff check src tests
394
+ mypy src
395
+ pytest --cov=kliz
396
+ python -m build
397
+ twine check --strict dist/*
398
+ pip-audit . --strict
399
+ ```
400
+
401
+ ## Exploitation en production
402
+
403
+ Le package ne stocke aucun secret et n'impose aucun système de tâches. Dans
404
+ l'application qui l'utilise :
405
+
406
+ - injectez les clés par un gestionnaire de secrets ;
407
+ - activez le retry opt-in de `Kliz` (`max_attempts`) ou appliquez un backoff
408
+ applicatif aux résultats `retryable=True` ;
409
+ - placez les échecs définitifs dans une dead-letter queue ;
410
+ - mesurez latence, taux de succès, codes HTTP et quotas par provider ;
411
+ - ne partagez pas une même instance `GoogleProvider` entre plusieurs threads ;
412
+ - conservez un sitemap à jour : une notification ne garantit jamais
413
+ l'indexation.
414
+
415
+ ## Publication
416
+
417
+ Les tags `vX.Y.Z` déclenchent le workflow de release. Le tag doit correspondre
418
+ exactement à la version de `pyproject.toml`. La publication utilise le Trusted
419
+ Publishing PyPI et ne nécessite aucun token PyPI permanent dans GitHub.
420
+
421
+ Avant la première release, configurez sur PyPI un publisher avec le dépôt
422
+ `freddychoudja/kliz-`, le workflow `release.yml` et l'environnement `pypi`.
423
+
424
+ ## Contribuer
425
+
426
+ Les contributions sont les bienvenues. Consultez
427
+ [CONTRIBUTING.md](CONTRIBUTING.md) avant d'ouvrir une issue ou une pull
428
+ request.
429
+
430
+ Le code source et le suivi du projet sont disponibles sur
431
+ [GitHub](https://github.com/freddychoudja/kliz-).
432
+
433
+ ## Licence
434
+
435
+ `kliz` est distribué sous la [licence MIT](LICENSE). Copyright © 2026 Freddy
436
+ Choudja.