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.
201 lines
6.9 KiB
Python
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()
|