Files
BookIA/check_plagiat.py
Vincent 78dbc48055 Isole le manuscrit dans Manuscrit/ et passe l'Introduction et la Partie 1 au brouillage algorithmique
Ce commit rassemble trois chantiers distincts, restés non commités.

== 1. Isolation du manuscrit dans Manuscrit/ (section 11) ==

Les 25 fichiers destinés à l'impression ou à l'EPUB quittent la racine pour
le dossier Manuscrit/ : 12 chapitres, introduction, conclusion, préface,
mentions légales, 4e de couverture et les 8 séparateurs de partie. La racine
ne garde que l'outillage (scripts, préambule, feuilles de style, filtres Lua)
et le suivi éditorial. Déplacements faits par git mv, historique conservé.

Les trois outils qui référençaient les chemins racine ont été alignés :
compiler-livre.sh (variable MANUSCRIT_DIR appliquée à la liste CHAPITRES, qui
définit l'ordre de montage réel), compter-mots.py et detecteur-ia.py (tableaux
MANUSCRIT, résolution par pathlib depuis le dossier du script). check_plagiat.py
et structure-epub.lua suivent. Les identifiants de notes n'ont pas bougé :
c<NN>-<n> est indexé sur le numéro de section, pas sur le nom de fichier.

== 2. Passe de brouillage algorithmique sur 4 sections (section 3 decies) ==

Diagnostic obtenu avec DetectIA (CLI externe, modèle sonnet, texte découpé en
blocs de 650 mots), puis correction et contrôle avec detecteur-ia.py. Aucun
fait, chiffre, citation ni note de bas de page modifié ; aucun appel de note
déplacé.

Introduction — 14 réécritures. Les 7 phrases au-dessus de 75 et 10 balancements.
Les trois « Et l'on » + futur programmatique de l'annonce du plan sont
éliminés, avec trois ouvertures différentes pour ne pas recréer un motif.
Antithèses 4 -> 1. Tirets 2,9 pour mille. 3 810 mots, 9 notes.

Chapitre 1 — 8 réécritures. Les 3 phrases à 75+ et 5 balancements, dont le seul
« non pas » du chapitre. Phrases courtes 12,7 -> 13,2 %. Antithèses 4.
8 435 mots, 15 notes.

Chapitre 2 — 15 réécritures en deux passes. D'abord la phrase à 80 (cascade
« non par X, non par Y, mais par Z ») et 10 balancements ; puis une passe
dédiée sur la construction « Non pas / Non par » en attaque de phrase,
ramenée de 6 à 2 occurrences. Antithèses 4 -> 2. Tirets 3,2 -> 2,9 pour mille.
10 896 mots, 20 notes.

Chapitre 3 — 9 réécritures. La phrase à 80, 6 balancements et 3 échafaudages
méta (dont « Il vaut la peine de noter que », cousin d'un tic de la liste
noire). « Non pas / Non par » ramené de 5 à 2. Antithèses 5 -> 4.
Phrases courtes 12,2 -> 12,4 %, marge la plus fine du manuscrit.
11 512 mots, 27 notes.

Verdict detecteur-ia.py : conforme aux quatre règles sur les quatre sections.
Aucun indicateur en recul, hors un plateau rythmique de plus au chapitre 2,
artefact du raccourcissement de phrases voisines.

Formules protégées volontairement conservées, conformément à la section 5 :
« il ne produit pas de lumière, il renvoie une image », « une technologie
cognitive ne se juge jamais à ce qu'elle ajoute, mais au troc qu'elle impose »,
le motif du magicien et du contrat de l'illusion, la plasticité comme
comptabilité à somme contrainte, et la thèse-pivot du chapitre 2 sur la cible
prédictive.

Deux tics restent identifiés et non traités : 11 « Voilà » au chapitre 3
(0,9 pour mille contre 0,3 ailleurs), et 115 phrases de 30 mots ou plus dans
ce même chapitre pour 12,4 % de phrases courtes.

== 3. Matière de recherche de la Partie 4 (section 10.3) ==

Arrivée de 13 dossiers pour les chapitres 10, 11 et 12 : 10A_precedents,
10C_leviers, 10D_contradiction, 11B_organisation, 11C_humain, 12A_incarnation,
12B_demographie, 12C_substitution, 12-Cbis_Relance substitution, 12D_finvie,
12E_prixdelavie, 12-F1_Réserver des décisions à un humain, 12-F2_Financer et
transmettre. Le registre des sources sort de 00-COORDINATION.md pour vivre
dans registre-des-sources.md, la section 8 du fichier de coordination ne
portant plus qu'un renvoi.

Réserve : Recherches/10C_leviers.md.md porte une double extension, à renommer.
2026-07-29 00:48:30 +02:00

201 lines
6.9 KiB
Python

#!/usr/bin/env python3
"""
Vérification anti-plagiat via l'API Copyscape Premium.
Envoie le texte d'un chapitre (fichier .md) à Copyscape (opération "csearch",
recherche sur Internet public) et affiche un rapport des pages trouvées
présentant des correspondances de texte.
Usage :
python3 check_plagiat.py Manuscrit/01-chap01-mort-de-la-verite.md
python3 check_plagiat.py Manuscrit/01-chap01-mort-de-la-verite.md --full-compare 5
python3 check_plagiat.py --balance
Identifiants lus depuis .copyscape-credentials (voir ce fichier pour le format).
Coût : Copyscape facture 3 cents pour les 200 premiers mots, puis 1 cent par
100 mots supplémentaires. Un chapitre de 5000 mots coûte environ 0,50 $.
Utiliser --full-compare avec parcimonie (chaque comparaison complète a un coût
et un délai supplémentaire).
"""
import argparse
import re
import sys
from pathlib import Path
import requests
CREDENTIALS_FILE = Path(__file__).parent / ".copyscape-credentials"
API_URL = "https://www.copyscape.com/api/"
def load_credentials():
if not CREDENTIALS_FILE.exists():
sys.exit(f"Erreur : fichier introuvable {CREDENTIALS_FILE}")
creds = {}
with open(CREDENTIALS_FILE, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
creds[k.strip()] = v.strip()
username = creds.get("COPYSCAPE_USERNAME", "")
api_key = creds.get("COPYSCAPE_API_KEY", "")
if not username or not api_key:
sys.exit(
"Erreur : COPYSCAPE_USERNAME ou COPYSCAPE_API_KEY manquant dans "
f"{CREDENTIALS_FILE}"
)
return username, api_key
def clean_markdown_for_check(raw_text: str) -> str:
"""Nettoie le Markdown pour ne soumettre que la prose : retire les
définitions de notes de bas de page, les titres Markdown (#), les
tableaux et les citations en bloc ne sont volontairement CONSERVÉS
car ils font partie du texte réel du chapitre (les citations d'experts
sont attendues et légitimes, elles ne doivent pas déclencher de faux
positifs excessifs mais restent utiles à vérifier)."""
text = raw_text
# Retire les blocs LaTeX ou HTML bruts : ```{=latex} ... ``` ou ```{=html} ... ```
text = re.sub(r"```\{=\w+\}.*?```", "", text, flags=re.DOTALL)
# Retire les commentaires HTML : <!-- ... -->
text = re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
# Retire les définitions de notes de bas de page : [^c01-1]: texte...
text = re.sub(r"^\[\^[^\]]+\]:.*$", "", text, flags=re.MULTILINE)
# Retire les appels de note de bas de page dans le texte : [^c01-1]
text = re.sub(r"\[\^[^\]]+\]", "", text)
# Retire les marqueurs de titres Markdown mais garde le texte du titre
text = re.sub(r"^#{1,6}\s*", "", text, flags=re.MULTILINE)
# Compresse les lignes vides multiples
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def check_balance(username: str, api_key: str):
resp = requests.get(
API_URL,
params={"u": username, "k": api_key, "o": "balance", "f": "json"},
timeout=30,
)
data = resp.json()
if "error" in data:
sys.exit(f"Erreur API : {data['error']}")
print(f"Solde restant : {data.get('value', '?')} $")
print(f"Recherches Internet restantes aujourd'hui : {data.get('today', '?')}")
def search_text(username: str, api_key: str, text: str, full_compare: int = 0):
payload = {
"u": username,
"k": api_key,
"o": "csearch",
"e": "UTF-8",
"t": text,
"c": str(full_compare),
"f": "json",
}
resp = requests.post(API_URL, data=payload, timeout=120)
resp.raise_for_status()
return resp.json()
def print_report(data: dict, chapter_name: str):
print(f"\n{'=' * 70}")
print(f"RAPPORT ANTI-PLAGIAT — {chapter_name}")
print(f"{'=' * 70}")
if "error" in data:
print(f"ERREUR API : {data['error']}")
return
words_checked = data.get("querywords", "?")
cost = data.get("cost", "?")
count = data.get("count", 0)
print(f"Mots analysés : {words_checked}")
print(f"Coût de cette vérification : {cost} $")
print(f"Pages avec correspondances trouvées : {count}")
if count == 0:
print("\nAucune correspondance trouvée sur Internet. Texte original. ✅")
return
if "allpercentmatched" in data:
print(f"Pourcentage total du texte retrouvé ailleurs : {data['allpercentmatched']}%")
print(f"\n{'-' * 70}")
results = data.get("result", [])
for r in results:
print(f"\n[{r.get('index')}] {r.get('title', '(sans titre)')}")
print(f" URL : {r.get('url')}")
if "percentmatched" in r:
print(f" Correspondance exacte : {r['percentmatched']}% ({r.get('wordsmatched', '?')} mots)")
else:
print(f" Indication de correspondance (mots min.) : {r.get('minwordsmatched', '?')}")
snippet = r.get("textsnippet", "")
if snippet:
print(f" Extrait : {snippet}")
print(f"\n{'-' * 70}")
print("ATTENTION : la présence de correspondances ne signifie pas toujours")
print("un plagiat. Vérifier s'il s'agit de citations attribuées (légitimes)")
print("ou de passages recopiés sans reformulation (à corriger, voir section")
print("3 septies de 00-COORDINATION.md).")
def main():
parser = argparse.ArgumentParser(description="Vérification anti-plagiat via Copyscape Premium.")
parser.add_argument("chapter_file", nargs="?", help="Fichier .md du chapitre à vérifier")
parser.add_argument(
"--full-compare",
type=int,
default=0,
metavar="N",
help="Nombre de comparaisons complètes à effectuer (0-10, coût et délai supplémentaires)",
)
parser.add_argument("--balance", action="store_true", help="Afficher le solde du compte Copyscape")
args = parser.parse_args()
username, api_key = load_credentials()
if args.balance:
check_balance(username, api_key)
return
if not args.chapter_file:
parser.error("Fournir un fichier de chapitre, ou utiliser --balance")
path = Path(args.chapter_file)
if not path.exists():
sys.exit(f"Erreur : fichier introuvable {path}")
raw_text = path.read_text(encoding="utf-8")
text_to_check = clean_markdown_for_check(raw_text)
word_count = len(text_to_check.split())
if word_count == 0:
sys.exit("Erreur : le texte nettoyé est vide.")
print(f"Envoi de {word_count} mots à Copyscape Premium...")
if word_count > 30000:
print(
"Attention : Copyscape limite généralement la taille du texte par requête. "
"Si l'appel échoue, découper le chapitre en sections plus courtes."
)
data = search_text(username, api_key, text_to_check, full_compare=args.full_compare)
print_report(data, path.name)
if __name__ == "__main__":
main()