<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cat's Eye SEO · Voit ce que Google voit</title>
<meta name="description" content="Audit SEO Google complet en un clic. PageSpeed, Knowledge Graph, Safe Browsing, sitemap, robots, analyse éditoriale. Aucun score inventé par LLM, que des vraies mesures.">
<meta name="author" content="Christophe Scarcelli (@ChrisWaoo)">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:title" content="Cat's Eye SEO · Voit ce que Google voit">
<meta property="og:description" content="Audit SEO Google complet. PageSpeed, Knowledge Graph, GBP, sitemap, analyse éditoriale. Aucun score inventé par LLM.">
<meta property="og:url" content="https://catseyeweb.com/">
<meta property="og:site_name" content="Cat's Eye SEO">
<meta property="og:locale" content="fr_FR">
<!-- Twitter Cards -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Cat's Eye SEO · Voit ce que Google voit">
<meta name="twitter:description" content="Audit SEO Google complet. Aucun score inventé par LLM, que des vraies mesures.">
<meta name="twitter:creator" content="@ChrisWaoo">
<!-- Schema.org optimisé pour Google Search + LLMs (ChatGPT, Claude, Perplexity) -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://catseyeweb.com/#website",
"url": "https://catseyeweb.com/",
"name": "Cat's Eye SEO",
"description": "Outil d'audit SEO Google gratuit, basé sur les vraies APIs Google. Sans LLM, sans estimation.",
"inLanguage": "fr-FR",
"publisher": { "@id": "https://catseyeweb.com/#person" },
"potentialAction": {
"@type": "SearchAction",
"target": {
"@type": "EntryPoint",
"urlTemplate": "https://catseyeweb.com/?domain={search_term_string}"
},
"query-input": "required name=search_term_string"
}
},
{
"@type": ["WebApplication", "SoftwareApplication"],
"@id": "https://catseyeweb.com/#app",
"name": "Cat's Eye SEO",
"url": "https://catseyeweb.com/",
"description": "Audit SEO Google complet basé sur de vraies mesures API : PageSpeed Insights pour la performance Lighthouse, Knowledge Graph pour la détection de marque, Safe Browsing pour la sécurité, analyse éditoriale automatique des 5 derniers articles avec 22 critères (Schema, dates, hero image Discover, content effort), détection des signaux du Google Leak (siteFocusScore, EMD, hostAge), constructeur SERP avancé avec uule géolocalisé, décodeur protobuf pour URLs Google opaques. Outil gratuit, sans inscription, sans backend qui stocke vos données.",
"slogan": "Voit ce que Google voit.",
"applicationCategory": "BusinessApplication",
"applicationSubCategory": "SEO Tool",
"operatingSystem": "Web Browser (Chrome, Firefox, Safari, Edge)",
"browserRequirements": "Requires JavaScript and modern browser",
"softwareVersion": "1.0",
"inLanguage": "fr-FR",
"isAccessibleForFree": true,
"license": "https://catseyeweb.com/",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "EUR",
"availability": "https://schema.org/InStock"
},
"creator": { "@id": "https://catseyeweb.com/#person" },
"author": { "@id": "https://catseyeweb.com/#person" },
"publisher": { "@id": "https://catseyeweb.com/#person" },
"audience": {
"@type": "Audience",
"audienceType": "SEO professionals, digital marketers, content creators, web agencies"
},
"featureList": [
"Audit Lighthouse complet via PageSpeed Insights API (Performance, SEO, Accessibilité, Bonnes pratiques)",
"Détection automatique du Knowledge Graph Google (entités, types)",
"Vérification Google Safe Browsing (malware, phishing, social engineering)",
"Détection Google Business Profile via Places API (rating, avis, cohérence site web)",
"Parsing complet sitemap.xml (index + sous-sitemaps, URLs et images)",
"Vérification robots.txt avec détection des blocages",
"Analyse éditoriale des 5 articles les plus récents par date de publication",
"Mode analyse d'article unique sur 22 critères SEO",
"Détection des signaux du Google Leak : siteFocusScore, contentEffort, EMD, hostAge, dateCoherence, shingleInfo",
"Hero image check pour éligibilité Google Discover (dimensions ≥ 1200px, poids < 1MB)",
"Parsing dimensions images PNG/JPEG/WebP/GIF/AVIF sans télécharger le fichier complet",
"Constructeur SERP avancé : udm, tbs, gl (190 pays), hl, pws, kgmid, uule",
"Générateur uule pour simuler une recherche depuis n'importe quelle ville",
"Keyword harvest via Google Suggest API (modes alphabet, questions, préfixes commerciaux)",
"Verticaux YouTube, Shopping, News, Images dans Suggest",
"Décodeur protobuf générique pour URLs Google opaques (Maps, YouTube, Drive)",
"Inspection byte par byte pour les payloads base64",
"Mode batch jusqu'à 1000 domaines avec export CSV",
"Bookmarklet pour audit en un clic depuis n'importe quel site",
"Wayback Machine pour l'âge du domaine",
"Aucune donnée stockée sur serveur tiers"
],
"keywords": "audit SEO, Google PageSpeed, Lighthouse, Knowledge Graph, Safe Browsing, sitemap analyzer, robots.txt checker, hero image Discover, Google leak, contentEffort, siteFocusScore, EMD, uule generator, SERP builder, keyword harvest, protobuf decoder, audit éditorial, Schema Article, E-E-A-T",
"screenshot": "https://catseyeweb.com/screenshot.png",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://catseyeweb.com/"
}
},
{
"@type": "Person",
"@id": "https://catseyeweb.com/#person",
"name": "Christophe Scarcelli",
"alternateName": "ChrisWaoo",
"url": "https://x.com/ChrisWaoo",
"sameAs": [
"https://x.com/ChrisWaoo"
],
"jobTitle": "SEO Consultant",
"knowsAbout": ["SEO", "Google Search", "Technical SEO", "Audit SEO", "Content Strategy"]
},
{
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "Cat's Eye SEO est-il gratuit ?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Oui, Cat's Eye SEO est 100% gratuit. L'utilisateur doit cependant configurer sa propre clé Google Cloud API (gratuite aussi, avec free tier généreux). L'outil ne stocke aucune donnée sur serveur tiers : tout passe par le navigateur de l'utilisateur."
}
},
{
"@type": "Question",
"name": "Quelle est la différence entre Cat's Eye SEO et un outil comme Ahrefs ou Semrush ?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Cat's Eye SEO utilise uniquement les APIs officielles Google (PageSpeed, Knowledge Graph, Safe Browsing, Custom Search) pour fournir des mesures réelles, sans estimation ni score inventé par LLM. Ahrefs et Semrush crawlent des millions de sites pour estimer des métriques. Cat's Eye SEO est complémentaire : il vérifie ce que Google voit vraiment, pas ce que des bots estiment."
}
},
{
"@type": "Question",
"name": "Quels critères Cat's Eye SEO vérifie-t-il sur un article ?",
"acceptedAnswer": {
"@type": "Answer",
"text": "22 critères : Schema Article, auteur, sameAs E-E-A-T, publisher, datePublished, dateModified, cohérence des dates, image dans le schema, max-image-preview:large, og:image, og:title, H1 unique, canonical, meta description, longueur ≥ 300 mots, longueur ≥ 800 mots, ≥ 2 images, ≥ 2 sources externes, content effort ≥ 60, faible clutter ads/popups, hero image ≥ 1200px (Discover), hero image < 1MB."
}
},
{
"@type": "Question",
"name": "Qu'est-ce que le Google Leak et pourquoi est-ce important pour cet outil ?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Le Google Leak de 2024 a révélé des attributs internes utilisés par Google pour ranker les sites : siteFocusScore (cohérence thématique), contentEffort (qualité du contenu), EMD (Exact Match Domain), hostAge (âge du site), shingleInfo (détection de duplicate). Cat's Eye SEO détecte ces signaux automatiquement pour vous montrer comment Google peut vous percevoir."
}
}
]
}
]
}
</script>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%234f46e5'/%3E%3Cstop offset='100%25' stop-color='%237c3aed'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='100' height='100' rx='22' fill='url(%23g)'/%3E%3Ccircle cx='42' cy='42' r='18' fill='none' stroke='white' stroke-width='7'/%3E%3Cline x1='56' y1='56' x2='76' y2='76' stroke='white' stroke-width='8' stroke-linecap='round'/%3E%3C/svg%3E">
<link rel="apple-touch-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%234f46e5'/%3E%3Cstop offset='100%25' stop-color='%237c3aed'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='100' height='100' rx='22' fill='url(%23g)'/%3E%3Ccircle cx='42' cy='42' r='18' fill='none' stroke='white' stroke-width='7'/%3E%3Cline x1='56' y1='56' x2='76' y2='76' stroke='white' stroke-width='8' stroke-linecap='round'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
:root {
--bs-primary: #4f46e5;
--brand-grad: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
--brand-soft: #eef2ff;
--ink: #0f172a;
--muted: #64748b;
--bg: #f8fafc;
--card-border: #e2e8f0;
--green: #16a34a; --green-soft: #dcfce7;
--yellow: #ca8a04; --yellow-soft: #fef3c7;
--red: #dc2626; --red-soft: #fee2e2;
--gray: #64748b; --gray-soft: #f1f5f9;
}
html { overflow-x: hidden; max-width: 100vw; }
body { font-family: 'Inter', system-ui, sans-serif; background: var(--bg); color: var(--ink); -webkit-font-smoothing: antialiased; overflow-x: hidden; max-width: 100vw; position: relative; }
/* Garde-fou : aucun contenu ne doit pouvoir déborder horizontalement */
img, video, iframe, pre, table { max-width: 100%; }
.container, .container-fluid { overflow-x: hidden; }
code, .mono { font-family: 'JetBrains Mono', monospace; font-size: 0.875em; word-break: break-word; }
.navbar-brand { font-weight: 800; letter-spacing: -0.02em; color: var(--ink) !important; }
.navbar-brand i { color: var(--bs-primary); }
.navbar { backdrop-filter: blur(10px); background: rgba(255,255,255,0.85) !important; border-bottom: 1px solid var(--card-border); }
.hero { background: var(--brand-grad); color: white; padding: 4rem 1rem 5rem; margin-bottom: 2rem; position: relative; overflow: hidden; border-radius: 14px; }
.hero::before { content: ''; position: absolute; inset: 0; background-image: radial-gradient(circle at 20% 30%, rgba(255,255,255,.1) 0%, transparent 50%), radial-gradient(circle at 80% 70%, rgba(255,255,255,.08) 0%, transparent 50%); pointer-events: none; }
.hero-content { position: relative; max-width: 720px; margin: 0 auto; }
.hero h1 { font-weight: 800; letter-spacing: -0.04em; font-size: clamp(2.2rem, 6vw, 3.5rem); line-height: 1; margin-bottom: 0.8rem; }
.hero .lead { color: rgba(255,255,255,0.85); font-size: 1.05rem; margin-bottom: 1.8rem; }
.launcher-form { background: white; padding: 0.5rem; border-radius: 14px; box-shadow: 0 20px 60px -15px rgba(0,0,0,0.3); display: flex; gap: 0.4rem; }
.launcher-form input { border: none !important; box-shadow: none !important; font-size: 1.05rem; padding: 0.85rem 1rem; color: var(--ink); }
.launcher-form button { background: var(--ink); color: white; border: none; padding: 0.85rem 1.5rem; border-radius: 10px; font-weight: 600; white-space: nowrap; transition: all 0.15s; }
.launcher-form button:hover { background: #1e293b; transform: translateY(-1px); }
.history-pills { margin-top: 1.2rem; display: flex; gap: 0.4rem; flex-wrap: wrap; align-items: center; }
.history-label { font-size: 0.8rem; color: rgba(255,255,255,0.7); }
.history-pill { background: rgba(255,255,255,0.15); color: white; border: 1px solid rgba(255,255,255,0.25); padding: 0.3rem 0.75rem; border-radius: 100px; font-size: 0.8rem; cursor: pointer; font-family: 'JetBrains Mono', monospace; }
.history-pill:hover { background: rgba(255,255,255,0.3); }
.section-title { font-weight: 700; letter-spacing: -0.01em; color: var(--ink); }
.section-subtitle { color: var(--muted); margin-bottom: 1.5rem; }
.group-heading { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); margin: 1.5rem 0 0.75rem; display: flex; align-items: center; gap: 0.4rem; }
.group-heading i { color: var(--bs-primary); }
.health-card { background: white; border: 1px solid var(--card-border); border-radius: 16px; padding: 1.5rem; margin-bottom: 1.5rem; display: grid; grid-template-columns: auto 1fr; gap: 1.5rem; align-items: center; }
@media (max-width: 576px) { .health-card { grid-template-columns: 1fr; text-align: center; } }
.score-circle { width: 120px; height: 120px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-direction: column; background: var(--gray-soft); color: var(--gray); font-weight: 800; transition: all 0.6s; }
.score-circle.computing { animation: pulse 1.5s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
.score-circle .score-value { font-size: 2.2rem; line-height: 1; }
.score-circle .score-max { font-size: 0.85rem; opacity: 0.7; margin-top: 0.2rem; font-weight: 500; }
.score-circle.good { background: var(--green-soft); color: var(--green); }
.score-circle.warn { background: var(--yellow-soft); color: var(--yellow); }
.score-circle.bad { background: var(--red-soft); color: var(--red); }
.health-breakdown .item { display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0; font-size: 0.85rem; }
.health-breakdown .item-label { color: var(--muted); flex: 1; }
.health-disclaimer { margin-top: 0.8rem; padding-top: 0.8rem; border-top: 1px solid var(--card-border); font-size: 0.78rem; color: var(--muted); line-height: 1.5; }
.status-badge { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.2rem 0.55rem; border-radius: 6px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
.status-badge.ok { background: var(--green-soft); color: var(--green); }
.status-badge.warn { background: var(--yellow-soft); color: var(--yellow); }
.status-badge.bad { background: var(--red-soft); color: var(--red); }
.status-badge.pending { background: var(--gray-soft); color: var(--gray); }
.status-badge.manual { background: var(--gray-soft); color: var(--gray); }
.card { border: 1px solid var(--card-border); border-radius: 12px; transition: all 0.2s; background: white; height: 100%; }
.card:hover { box-shadow: 0 8px 24px -8px rgba(15,23,42,0.12); border-color: #cbd5e1; }
.card-body { display: flex; flex-direction: column; }
.card-header-row { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.4rem; margin-bottom: 0.4rem; }
.card-title { font-weight: 600; font-size: 0.95rem; margin-bottom: 0; flex: 1; }
.card-text { color: var(--muted); font-size: 0.85rem; margin-bottom: 0.75rem; flex: 1; }
.card-action { font-size: 0.8rem; font-weight: 500; color: var(--bs-primary); text-decoration: none; display: inline-flex; align-items: center; gap: 0.3rem; }
.card-action:hover { color: #3730a3; }
.card-action.copy { color: var(--muted); border: none; background: none; padding: 0; font-size: 0.8rem; margin-left: 0.8rem; cursor: pointer; }
.card-action.copy:hover { color: var(--ink); }
.info-icon { color: var(--muted); cursor: help; font-size: 0.95rem; opacity: 0.7; transition: opacity 0.15s; margin-left: 0.3rem; }
.info-icon:hover { opacity: 1; }
.feature-card { background: var(--brand-soft); border: 1px solid #c7d2fe; padding: 1.3rem; border-radius: 12px; }
.feature-card .url-display { background: white; padding: 0.75rem 1rem; border-radius: 8px; font-family: 'JetBrains Mono', monospace; font-size: 0.78rem; word-break: break-all; margin: 0.6rem 0; border: 1px solid #c7d2fe; }
.feature-card .url-display a { color: var(--bs-primary); text-decoration: none; }
.form-label { font-size: 0.8rem; font-weight: 500; color: var(--muted); margin-bottom: 0.3rem; display: flex; align-items: center; gap: 0.3rem; }
.form-control, .form-select { border-radius: 8px; border-color: var(--card-border); font-size: 0.92rem; }
/* Fix iOS zoom-on-focus : tout input < 16px déclenche un zoom auto. On force 16px sur mobile. */
@media (max-width: 768px) {
.form-control, .form-select, input, textarea, select { font-size: 16px !important; }
}
.form-control:focus, .form-select:focus { border-color: var(--bs-primary); box-shadow: 0 0 0 3px rgba(79,70,229,0.1); }
.btn { border-radius: 8px; font-weight: 500; font-size: 0.92rem; }
.btn-primary { background: var(--bs-primary); border-color: var(--bs-primary); }
.btn-primary:hover { background: #4338ca; border-color: #4338ca; }
.btn-light { background: white; border-color: var(--card-border); color: var(--ink); }
.btn-light:hover { background: var(--bg); }
.live-preview, .url-box { background: var(--ink); color: #e2e8f0; padding: 0.9rem 1.1rem; border-radius: 10px; font-family: 'JetBrains Mono', monospace; font-size: 0.78rem; word-break: break-all; line-height: 1.6; }
.live-preview a, .url-box a { color: #c7d2fe; text-decoration: none; }
.live-preview a:hover, .url-box a:hover { color: white; text-decoration: underline; }
.decoded-tree { background: var(--ink); color: #e2e8f0; padding: 1rem 1.2rem; border-radius: 10px; font-family: 'JetBrains Mono', monospace; font-size: 0.78rem; line-height: 1.7; overflow-x: auto; white-space: pre; }
.decoded-tree .field-num { color: #fbbf24; font-weight: 700; }
.decoded-tree .str { color: #86efac; }
.decoded-tree .num { color: #93c5fd; }
.decoded-tree .hex { color: #94a3b8; }
.decoded-tree .kind { color: #94a3b8; font-style: italic; }
.decoded-tree .err { color: #fca5a5; }
.bytes-display { background: #fef3c7; border-left: 3px solid #f59e0b; padding: 1rem 1.2rem; border-radius: 8px; font-family: 'JetBrains Mono', monospace; font-size: 0.78rem; line-height: 1.7; overflow-x: auto; white-space: pre-wrap; word-break: break-all; }
.bytes-display .ann { color: #b45309; font-weight: 700; }
.keywords-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.4rem; }
.kw-item { background: white; border: 1px solid var(--card-border); padding: 0.5rem 0.75rem; border-radius: 6px; font-family: 'JetBrains Mono', monospace; font-size: 0.78rem; }
.batch-row { padding: 0.7rem 0; border-bottom: 1px solid var(--card-border); font-family: 'JetBrains Mono', monospace; font-size: 0.82rem; }
.batch-row:last-child { border-bottom: none; }
.batch-row .domain { font-weight: 700; color: var(--ink); }
.batch-row .url { color: var(--muted); word-break: break-all; }
.batch-row .url a { color: var(--bs-primary); }
.copy-toast { position: fixed; bottom: 2rem; left: 50%; transform: translateX(-50%) translateY(20px); background: var(--ink); color: white; padding: 0.7rem 1.4rem; border-radius: 10px; font-size: 0.85rem; font-weight: 500; opacity: 0; transition: all 0.25s; pointer-events: none; z-index: 1100; box-shadow: 0 10px 30px -10px rgba(0,0,0,0.3); }
.copy-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.disclosure-card { background: white; border: 1px solid var(--card-border); border-radius: 12px; padding: 1.5rem; height: 100%; }
.disclosure-card h3 { font-size: 1rem; font-weight: 700; margin-bottom: 0.6rem; }
.disclosure-card ul { padding-left: 1.2rem; color: var(--muted); }
.disclosure-card li { margin-bottom: 0.3rem; font-size: 0.9rem; }
.accordion-button { font-weight: 600; background: white; }
.accordion-button:not(.collapsed) { background: var(--brand-soft); color: var(--bs-primary); }
.accordion-button:focus { box-shadow: 0 0 0 3px rgba(79,70,229,0.1); }
.accordion-item { border-color: var(--card-border); }
/* Onglets éditoriaux : contour visible pour clairement signaler que ce sont des boutons cliquables */
.editorial-tabs .nav-link { border: 1.5px solid var(--card-border); background: white; color: var(--ink); font-weight: 500; transition: all 0.15s; }
.editorial-tabs .nav-link:hover { border-color: var(--bs-primary); color: var(--bs-primary); background: var(--brand-soft); }
.editorial-tabs .nav-link.active { background: var(--bs-primary); color: white !important; border-color: var(--bs-primary); box-shadow: 0 4px 12px -2px rgba(79,70,229,0.3); }
.editorial-tabs .nav-item + .nav-item { margin-left: 0.5rem; }
@media (max-width: 576px) { .editorial-tabs .nav-item + .nav-item { margin-left: 0; margin-top: 0.4rem; } .editorial-tabs .nav-item { width: 100%; } .editorial-tabs .nav-link { width: 100%; } }
.bookmarklet-link { display: inline-block; background: var(--brand-grad); color: white !important; padding: 0.6rem 1.2rem; border-radius: 8px; font-weight: 600; text-decoration: none; font-size: 0.9rem; cursor: grab; }
.tooltip { font-family: 'Inter', sans-serif; --bs-tooltip-bg: var(--ink); --bs-tooltip-color: white; --bs-tooltip-max-width: 280px; --bs-tooltip-font-size: 0.78rem; }
.tooltip-inner { padding: 0.7rem 0.9rem; line-height: 1.5; text-align: left; border-radius: 8px; }
@media (max-width: 576px) {
.launcher-form { flex-direction: column; gap: 0.5rem; padding: 0.6rem; }
.launcher-form button { width: 100%; }
.hero { padding: 2.5rem 1rem 3rem; }
.hero h1 { font-size: 1.75rem; }
.hero .lead { font-size: 0.95rem; }
.keywords-grid { grid-template-columns: 1fr; }
.live-preview, .url-box, .decoded-tree, .bytes-display { font-size: 0.7rem; padding: 0.7rem 0.85rem; }
.navbar-brand { font-size: 1rem; }
.navbar .btn-sm { padding: 0.25rem 0.5rem; font-size: 0.8rem; }
.accordion-button { font-size: 0.9rem; padding: 0.75rem 1rem; }
.accordion-body { padding: 1rem; font-size: 0.85rem; }
.health-card { padding: 1rem; }
.score-circle { width: 100px; height: 100px; }
.score-circle .score-value { font-size: 1.8rem; }
.card-body { padding: 1rem; }
.site-footer .d-flex { flex-direction: column; align-items: flex-start !important; text-align: left; }
.site-footer .footer-meta { order: 3; }
}
/* Site footer */
.site-footer { color: var(--muted); font-size: 0.88rem; }
.site-footer .footer-brand strong { color: var(--ink); font-size: 0.95rem; }
.site-footer a { color: var(--bs-primary); }
.site-footer a:hover { color: #3730a3; text-decoration: underline !important; }
/* Anti-overflow horizontal global */
pre, code, .mono { max-width: 100%; overflow-wrap: break-word; word-break: break-word; }
table { max-width: 100%; }
img { max-width: 100%; height: auto; }
/* Skeleton loaders */
@keyframes skeleton-pulse {
0% { background-position: -200px 0; }
100% { background-position: calc(200px + 100%) 0; }
}
.skel { display: inline-block; background: linear-gradient(90deg, #e2e8f0 0%, #f1f5f9 50%, #e2e8f0 100%); background-size: 200px 100%; background-repeat: no-repeat; border-radius: 4px; animation: skeleton-pulse 1.4s ease-in-out infinite; }
.skel-line { height: 12px; margin: 4px 0; }
.skel-badge { height: 18px; width: 70px; border-radius: 6px; }
.skel-text { display: block; }
/* Loader spinner */
.loader-spinner { display: inline-block; width: 1em; height: 1em; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.75s linear infinite; vertical-align: -0.15em; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Progress bar pour éditorial */
.editorial-progress { background: white; border: 1px solid var(--card-border); border-radius: 12px; padding: 1rem 1.25rem; margin-top: 1rem; }
.editorial-progress-bar { background: var(--gray-soft); height: 6px; border-radius: 3px; overflow: hidden; margin-top: 0.5rem; }
.editorial-progress-fill { background: var(--brand-grad); height: 100%; transition: width 0.4s ease; border-radius: 3px; }
.editorial-progress-label { font-size: 0.85rem; color: var(--muted); display: flex; justify-content: space-between; align-items: center; }
.editorial-progress-label strong { color: var(--ink); }
</style>
</head>
<body>
<nav class="navbar navbar-light sticky-top">
<div class="container">
<a class="navbar-brand" href="#"><i class="bi bi-search-heart"></i> Cat's Eye <span class="text-muted fw-normal small ms-1">SEO</span></a>
<div class="d-flex gap-2">
<a href="#advanced-tools" class="btn btn-light btn-sm d-none d-md-inline">Outils avancés</a>
<a href="#docs" class="btn btn-light btn-sm d-none d-md-inline">Cas d'usage</a>
<a href="/setup.html" class="btn btn-light btn-sm" title="Comment configurer l'outil"><i class="bi bi-question-circle"></i><span class="d-none d-md-inline ms-1">Configuration</span></a>
<button class="btn btn-light btn-sm" data-bs-toggle="modal" data-bs-target="#settingsModal" title="Réglages API"><i class="bi bi-gear"></i><span class="d-none d-md-inline ms-1">Réglages</span></button>
</div>
</div>
</nav>
<div class="container py-3">
<div id="welcome-banner" class="d-none alert alert-light border mb-3" style="border-left: 4px solid var(--bs-primary) !important;">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<strong><i class="bi bi-stars text-primary me-1"></i>Bienvenue sur Cat's Eye SEO</strong>
<span class="text-muted small d-block d-md-inline ms-md-2">Pour des résultats complets, configurez votre clé Google Cloud (gratuit, 10 min).</span>
</div>
<div class="d-flex gap-2">
<a href="/setup.html" class="btn btn-primary btn-sm"><i class="bi bi-question-circle me-1"></i>Guide de configuration</a>
<button class="btn btn-light btn-sm" onclick="dismissWelcome()" title="Masquer"><i class="bi bi-x"></i></button>
</div>
</div>
</div>
<section class="hero">
<div class="hero-content">
<h1>Voit ce que Google voit.</h1>
<p class="lead">Audit Google complet en un clic. Notes basées sur des <strong>vraies mesures</strong> (PageSpeed API, Knowledge Graph, sitemap.xml, analyse éditoriale). <strong>Aucun score inventé par LLM.</strong></p>
<form class="launcher-form" onsubmit="runFullAudit(event)">
<input type="text" id="audit-domain" class="form-control" placeholder="exemple.com" autocomplete="off" spellcheck="false" required>
<button type="submit"><i class="bi bi-lightning-fill me-1"></i> Lancer l'audit</button>
</form>
<div class="history-pills" id="history-pills" style="display:none;"><span class="history-label">Récents :</span></div>
</div>
</section>
<section id="dashboard" class="d-none mb-5">
<div id="file-protocol-warning" class="alert alert-warning small mb-3 d-none">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Vous exécutez le fichier en local (<code>file://</code>).</strong>
Le navigateur applique les restrictions CORS les plus strictes — toute origine est traitée comme <code>null</code>. Hébergez le fichier sur un domaine (OSwitch, Netlify, GitHub Pages...) pour de meilleurs résultats. Pour les sites qui bloquent CORS même depuis un vrai domaine, configurez un proxy CORS dans les <a href="#" onclick="bootstrap.Modal.getOrCreateInstance(document.getElementById('settingsModal')).show(); return false;">Réglages</a>.
</div>
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div>
<h2 class="section-title mb-0">Audit de <span id="dashboard-domain" class="text-primary"></span></h2>
<p class="section-subtitle mb-0">Cliquez sur n'importe quelle carte pour ouvrir l'analyse correspondante.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-light btn-sm" onclick="copyShareLink()" data-bs-toggle="tooltip" data-bs-title="Copie un lien qui relance automatiquement l'audit pour ce domaine"><i class="bi bi-share me-1"></i> Partager</button>
<button class="btn btn-light btn-sm" onclick="copyAllUrls()" data-bs-toggle="tooltip" data-bs-title="Copie toutes les URLs d'audit dans le presse-papier"><i class="bi bi-clipboard me-1"></i> Tout copier</button>
</div>
</div>
<div class="health-card">
<div class="score-circle pending computing" id="score-circle">
<div class="score-value">…</div>
<div class="score-max">/ 100</div>
</div>
<div>
<div class="d-flex justify-content-between align-items-baseline mb-2">
<strong>Score de santé technique</strong>
<i class="bi bi-info-circle info-icon" data-bs-toggle="tooltip" data-bs-title="Score basé sur les 4 catégories Lighthouse (Performance, SEO, Bonnes pratiques, Accessibilité) via l'API PageSpeed officielle, plus l'indexabilité robots.txt et la présence d'un sitemap. Aucune estimation."></i>
</div>
<div class="health-breakdown" id="health-breakdown">
<div class="item"><span class="item-label">En cours d'analyse…</span></div>
</div>
<div class="health-disclaimer">
<i class="bi bi-shield-check me-1"></i>
Ce score reflète <strong>uniquement</strong> ce qui est mesurable automatiquement (performance technique + indexabilité). Il ne remplace pas Search Console pour le suivi de vos performances réelles.
</div>
</div>
</div>
<div class="feature-card mb-4">
<div class="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-2">
<strong><i class="bi bi-google me-2"></i>URL profile.google.com <i class="bi bi-info-circle info-icon" data-bs-toggle="tooltip" data-bs-title="Endpoint d'origine du whitepaper. Affiche les métadonnées de surface du domaine (snippet, nom). Utile pour vérifier ce que Google a stocké sur un site sans passer par Search Console."></i></strong>
<div class="d-flex gap-2 align-items-center">
<span id="profile-richness"></span>
<span class="badge bg-light text-muted" id="profile-meta"></span>
</div>
</div>
<div class="url-display"><a id="profile-url" href="#" target="_blank" rel="noopener"></a></div>
<small class="text-muted">Métadonnées de surface uniquement.</small>
</div>
<div id="combos-dashboard"></div>
<!-- Analyse éditoriale (avec toggle 5 récents / URL précise) -->
<div class="mt-5 pt-3 border-top">
<div class="mb-3">
<h3 class="section-title h5 mb-1">Analyse éditoriale</h3>
<p class="section-subtitle mb-0 small">22 critères SEO par article : Schema, dates, hero image, content effort, clutter…</p>
</div>
<!-- Toggle de mode -->
<ul class="nav nav-pills mb-3 editorial-tabs" id="editorial-mode-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-recents" data-mode="recents" type="button" role="tab">
<i class="bi bi-collection me-1"></i> 5 articles récents
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-single" data-mode="single" type="button" role="tab">
<i class="bi bi-file-text me-1"></i> Analyser un seul article
</button>
</li>
</ul>
<!-- Mode 1 : 5 récents -->
<div id="editorial-recents-pane">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
<p class="text-muted small mb-0">Échantillon des 5 articles les plus récents (par date de publication).</p>
<button class="btn btn-primary btn-sm" id="btn-editorial" onclick="runEditorialAnalysis()"><i class="bi bi-newspaper me-1"></i> Lancer l'analyse éditoriale</button>
</div>
</div>
<!-- Mode 2 : article précis -->
<div id="editorial-single-pane" class="d-none">
<form onsubmit="runSingleArticleAnalysis(event)" class="d-flex flex-wrap gap-2 mb-2">
<input type="url" id="single-article-url" class="form-control flex-grow-1" placeholder="https://exemple.com/article/mon-titre" required style="min-width: 200px;">
<button type="submit" class="btn btn-primary btn-sm" id="btn-single-article"><i class="bi bi-search me-1"></i> Analyser cet article</button>
</form>
<p class="text-muted small mb-0"><i class="bi bi-info-circle me-1"></i>Collez l'URL complète d'un article. Mêmes 22 critères que l'analyse en lot.</p>
</div>
<div id="editorial-output" class="mt-3"></div>
</div>
</section>
<section id="advanced-tools" class="mb-5">
<h2 class="section-title">Outils avancés</h2>
<p class="section-subtitle">Pour aller plus loin — ou décoder une URL bizarre.</p>
<div class="accordion" id="toolsAccordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#serp-tool">
<i class="bi bi-sliders me-2"></i> Constructeur de requête SERP
<i class="bi bi-info-circle info-icon ms-2" data-bs-toggle="tooltip" data-bs-title="Construit une URL google.com/search avec tous les paramètres avancés. Idéal pour simuler une SERP depuis n'importe quel pays sans VPN."></i>
</button>
</h2>
<div id="serp-tool" class="accordion-collapse collapse" data-bs-parent="#toolsAccordion">
<div class="accordion-body">
<div class="mb-3"><label class="form-label">Requête</label><input type="text" id="serp-q" class="form-control" placeholder="seo technique"></div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Mode (udm) <i class="bi bi-info-circle info-icon" data-bs-toggle="tooltip" data-bs-title="udm=14 force la SERP web classique (10 liens bleus, sans AI Overview). Indispensable pour mesurer un ranking organique réel."></i></label>
<select id="serp-udm" class="form-select">
<option value="">Mixte (par défaut)</option>
<option value="14" selected>14 — Web (10 liens bleus)</option>
<option value="2">2 — Images</option><option value="7">7 — Vidéos</option>
<option value="12">12 — News</option><option value="18">18 — Forums</option>
<option value="28">28 — Shopping</option><option value="50">50 — AI Mode</option><option value="51">51 — Maps</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Filtre temporel <i class="bi bi-info-circle info-icon" data-bs-toggle="tooltip" data-bs-title="Limite les résultats à une période. Utile pour la veille."></i></label>
<select id="serp-time" class="form-select">
<option value="">Toutes dates</option>
<option value="qdr:h">Dernière heure</option><option value="qdr:d">24h</option>
<option value="qdr:w">Semaine</option><option value="qdr:m">Mois</option><option value="qdr:y">Année</option>
</select>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Pays (gl) <i class="bi bi-info-circle info-icon" data-bs-toggle="tooltip" data-bs-title="Simule une recherche depuis ce pays. Affecte le mix de résultats locaux."></i></label>
<select id="serp-gl" class="form-select">
<option value="">(aucun)</option>
<optgroup label="Europe">
<option value="al">al — Albanie</option>
<option value="de">de — Allemagne</option>
<option value="ad">ad — Andorre</option>
<option value="at">at — Autriche</option>
<option value="be">be — Belgique</option>
<option value="by">by — Biélorussie</option>
<option value="ba">ba — Bosnie-Herzégovine</option>
<option value="bg">bg — Bulgarie</option>
<option value="cy">cy — Chypre</option>
<option value="hr">hr — Croatie</option>
<option value="dk">dk — Danemark</option>
<option value="es">es — Espagne</option>
<option value="ee">ee — Estonie</option>
<option value="fi">fi — Finlande</option>
<option value="fr" selected>fr — France</option>
<option value="gr">gr — Grèce</option>
<option value="hu">hu — Hongrie</option>
<option value="ie">ie — Irlande</option>
<option value="is">is — Islande</option>
<option value="it">it — Italie</option>
<option value="lv">lv — Lettonie</option>
<option value="li">li — Liechtenstein</option>
<option value="lt">lt — Lituanie</option>
<option value="lu">lu — Luxembourg</option>
<option value="mk">mk — Macédoine du Nord</option>
<option value="mt">mt — Malte</option>
<option value="md">md — Moldavie</option>
<option value="mc">mc — Monaco</option>
<option value="me">me — Monténégro</option>
<option value="no">no — Norvège</option>
<option value="nl">nl — Pays-Bas</option>
<option value="pl">pl — Pologne</option>
<option value="pt">pt — Portugal</option>
<option value="cz">cz — République tchèque</option>
<option value="ro">ro — Roumanie</option>
<option value="gb">gb — Royaume-Uni</option>
<option value="ru">ru — Russie</option>
<option value="sm">sm — Saint-Marin</option>
<option value="rs">rs — Serbie</option>
<option value="sk">sk — Slovaquie</option>
<option value="si">si — Slovénie</option>
<option value="se">se — Suède</option>
<option value="ch">ch — Suisse</option>
<option value="tr">tr — Turquie</option>
<option value="ua">ua — Ukraine</option>
<option value="va">va — Vatican</option>
</optgroup>
<optgroup label="Amérique du Nord">
<option value="ca">ca — Canada</option>
<option value="us">us — États-Unis</option>
<option value="mx">mx — Mexique</option>
</optgroup>
<optgroup label="Amérique centrale & Caraïbes">
<option value="ai">ai — Anguilla</option>
<option value="ag">ag — Antigua-et-Barbuda</option>
<option value="aw">aw — Aruba</option>
<option value="bs">bs — Bahamas</option>
<option value="bb">bb — Barbade</option>
<option value="bz">bz — Belize</option>
<option value="bm">bm — Bermudes</option>
<option value="cr">cr — Costa Rica</option>
<option value="cu">cu — Cuba</option>
<option value="dm">dm — Dominique</option>
<option value="sv">sv — Salvador</option>
<option value="gd">gd — Grenade</option>
<option value="gp">gp — Guadeloupe</option>
<option value="gt">gt — Guatemala</option>
<option value="ht">ht — Haïti</option>
<option value="hn">hn — Honduras</option>
<option value="ky">ky — Îles Caïmans</option>
<option value="vg">vg — Îles Vierges britanniques</option>
<option value="vi">vi — Îles Vierges (US)</option>
<option value="jm">jm — Jamaïque</option>
<option value="mq">mq — Martinique</option>
<option value="ms">ms — Montserrat</option>
<option value="ni">ni — Nicaragua</option>
<option value="pa">pa — Panama</option>
<option value="pr">pr — Porto Rico</option>
<option value="do">do — République dominicaine</option>
<option value="kn">kn — Saint-Christophe-et-Niévès</option>
<option value="lc">lc — Sainte-Lucie</option>
<option value="vc">vc — Saint-Vincent-et-les-Grenadines</option>
<option value="tt">tt — Trinité-et-Tobago</option>
</optgroup>
<optgroup label="Amérique du Sud">
<option value="ar">ar — Argentine</option>
<option value="bo">bo — Bolivie</option>
<option value="br">br — Brésil</option>
<option value="cl">cl — Chili</option>
<option value="co">co — Colombie</option>
<option value="ec">ec — Équateur</option>
<option value="gy">gy — Guyana</option>
<option value="py">py — Paraguay</option>
<option value="pe">pe — Pérou</option>
<option value="sr">sr — Suriname</option>
<option value="uy">uy — Uruguay</option>
<option value="ve">ve — Venezuela</option>
</optgroup>
<optgroup label="Afrique">
<option value="za">za — Afrique du Sud</option>
<option value="dz">dz — Algérie</option>
<option value="ao">ao — Angola</option>
<option value="bj">bj — Bénin</option>
<option value="bw">bw — Botswana</option>
<option value="bf">bf — Burkina Faso</option>
<option value="bi">bi — Burundi</option>
<option value="cm">cm — Cameroun</option>
<option value="cv">cv — Cap-Vert</option>
<option value="cf">cf — République centrafricaine</option>
<option value="td">td — Tchad</option>
<option value="km">km — Comores</option>
<option value="cg">cg — République du Congo</option>
<option value="cd">cd — République démocratique du Congo</option>
<option value="ci">ci — Côte d'Ivoire</option>
<option value="dj">dj — Djibouti</option>
<option value="eg">eg — Égypte</option>
<option value="er">er — Érythrée</option>
<option value="sz">sz — Eswatini</option>
<option value="et">et — Éthiopie</option>
<option value="ga">ga — Gabon</option>
<option value="gm">gm — Gambie</option>
<option value="gh">gh — Ghana</option>
<option value="gn">gn — Guinée</option>
<option value="gq">gq — Guinée équatoriale</option>
<option value="gw">gw — Guinée-Bissau</option>
<option value="ke">ke — Kenya</option>
<option value="ls">ls — Lesotho</option>
<option value="lr">lr — Libéria</option>
<option value="ly">ly — Libye</option>
<option value="mg">mg — Madagascar</option>
<option value="mw">mw — Malawi</option>
<option value="ml">ml — Mali</option>
<option value="ma">ma — Maroc</option>
<option value="mu">mu — Maurice</option>
<option value="mr">mr — Mauritanie</option>
<option value="yt">yt — Mayotte</option>
<option value="mz">mz — Mozambique</option>
<option value="na">na — Namibie</option>
<option value="ne">ne — Niger</option>
<option value="ng">ng — Nigeria</option>
<option value="ug">ug — Ouganda</option>
<option value="re">re — La Réunion</option>
<option value="rw">rw — Rwanda</option>
<option value="st">st — Sao Tomé-et-Principe</option>
<option value="sn">sn — Sénégal</option>
<option value="sc">sc — Seychelles</option>
<option value="sl">sl — Sierra Leone</option>
<option value="so">so — Somalie</option>
<option value="sd">sd — Soudan</option>
<option value="ss">ss — Soudan du Sud</option>
<option value="tz">tz — Tanzanie</option>
<option value="tg">tg — Togo</option>
<option value="tn">tn — Tunisie</option>
<option value="zm">zm — Zambie</option>
<option value="zw">zw — Zimbabwe</option>
</optgroup>
<optgroup label="Asie">
<option value="af">af — Afghanistan</option>
<option value="sa">sa — Arabie saoudite</option>
<option value="am">am — Arménie</option>
<option value="az">az — Azerbaïdjan</option>
<option value="bh">bh — Bahreïn</option>
<option value="bd">bd — Bangladesh</option>
<option value="bt">bt — Bhoutan</option>
<option value="mm">mm — Myanmar (Birmanie)</option>
<option value="bn">bn — Brunei</option>
<option value="kh">kh — Cambodge</option>
<option value="cn">cn — Chine</option>
<option value="kp">kp — Corée du Nord</option>
<option value="kr">kr — Corée du Sud</option>
<option value="ae">ae — Émirats arabes unis</option>
<option value="ge">ge — Géorgie</option>
<option value="hk">hk — Hong Kong</option>
<option value="in">in — Inde</option>
<option value="id">id — Indonésie</option>
<option value="iq">iq — Irak</option>
<option value="ir">ir — Iran</option>
<option value="il">il — Israël</option>
<option value="jp">jp — Japon</option>
<option value="jo">jo — Jordanie</option>
<option value="kz">kz — Kazakhstan</option>
<option value="kg">kg — Kirghizistan</option>
<option value="kw">kw — Koweït</option>
<option value="la">la — Laos</option>
<option value="lb">lb — Liban</option>
<option value="mo">mo — Macao</option>
<option value="my">my — Malaisie</option>
<option value="mv">mv — Maldives</option>
<option value="mn">mn — Mongolie</option>
<option value="np">np — Népal</option>
<option value="om">om — Oman</option>
<option value="uz">uz — Ouzbékistan</option>
<option value="pk">pk — Pakistan</option>
<option value="ps">ps — Palestine</option>
<option value="ph">ph — Philippines</option>
<option value="qa">qa — Qatar</option>
<option value="sg">sg — Singapour</option>
<option value="lk">lk — Sri Lanka</option>
<option value="sy">sy — Syrie</option>
<option value="tj">tj — Tadjikistan</option>
<option value="tw">tw — Taïwan</option>
<option value="th">th — Thaïlande</option>
<option value="tl">tl — Timor oriental</option>
<option value="tm">tm — Turkménistan</option>
<option value="vn">vn — Vietnam</option>
<option value="ye">ye — Yémen</option>
</optgroup>
<optgroup label="Océanie">
<option value="au">au — Australie</option>
<option value="ck">ck — Îles Cook</option>
<option value="fj">fj — Fidji</option>
<option value="gu">gu — Guam</option>
<option value="ki">ki — Kiribati</option>
<option value="mh">mh — Îles Marshall</option>
<option value="fm">fm — Micronésie</option>
<option value="nr">nr — Nauru</option>
<option value="nu">nu — Niue</option>
<option value="nc">nc — Nouvelle-Calédonie</option>
<option value="nz">nz — Nouvelle-Zélande</option>
<option value="mp">mp — Îles Mariannes du Nord</option>
<option value="pw">pw — Palaos</option>
<option value="pg">pg — Papouasie-Nouvelle-Guinée</option>
<option value="pf">pf — Polynésie française</option>
<option value="sb">sb — Îles Salomon</option>
<option value="ws">ws — Samoa</option>
<option value="to">to — Tonga</option>
<option value="tv">tv — Tuvalu</option>
<option value="vu">vu — Vanuatu</option>
<option value="wf">wf — Wallis-et-Futuna</option>
</optgroup>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Langue (hl)</label>
<select id="serp-hl" class="form-select"><option value="fr" selected>fr</option><option value="en">en</option><option value="de">de</option><option value="es">es</option><option value="">aucun</option></select>
</div>
</div>
<div class="row g-2 mb-3">
<div class="col-6 col-md-4"><div class="form-check"><input type="checkbox" id="serp-pws" class="form-check-input" checked><label class="form-check-label small" for="serp-pws" data-bs-toggle="tooltip" data-bs-title="Désactive la personnalisation Google. Indispensable pour un audit SEO neutre.">pws=0 (no perso)</label></div></div>
<div class="col-6 col-md-4"><div class="form-check"><input type="checkbox" id="serp-filter" class="form-check-input"><label class="form-check-label small" for="serp-filter" data-bs-toggle="tooltip" data-bs-title="Désactive le dédoublonnage. Permet de voir TOUTES les pages indexées.">filter=0</label></div></div>
<div class="col-6 col-md-4"><div class="form-check"><input type="checkbox" id="serp-verbatim" class="form-check-input"><label class="form-check-label small" for="serp-verbatim" data-bs-toggle="tooltip" data-bs-title="Force la recherche EXACTE. Si vos résultats s'effondrent, votre ranking dépend de la reformulation Google.">verbatim</label></div></div>
<div class="col-6 col-md-4"><div class="form-check"><input type="checkbox" id="serp-sortdate" class="form-check-input"><label class="form-check-label small" for="serp-sortdate" data-bs-toggle="tooltip" data-bs-title="Trie par date.">tri par date</label></div></div>
<div class="col-6 col-md-4"><div class="form-check"><input type="checkbox" id="serp-safe" class="form-check-input"><label class="form-check-label small" for="serp-safe">SafeSearch off</label></div></div>
<div class="col-6 col-md-4"><div class="form-check"><input type="checkbox" id="serp-nfpr" class="form-check-input"><label class="form-check-label small" for="serp-nfpr" data-bs-toggle="tooltip" data-bs-title="Pas de correction orthographique automatique.">no spell correct</label></div></div>
</div>
<details class="mb-3">
<summary class="text-muted small" style="cursor:pointer">Paramètres avancés</summary>
<div class="row g-3 mt-1">
<div class="col-md-6"><label class="form-label">Limiter au site</label><input type="text" id="serp-site" class="form-control" placeholder="abondance.com"></div>
<div class="col-md-6"><label class="form-label">Type de fichier</label><select id="serp-filetype" class="form-select"><option value="">aucun</option><option value="pdf">pdf</option><option value="doc">doc</option><option value="xls">xls</option><option value="ppt">ppt</option></select></div>
<div class="col-md-6"><label class="form-label">Knowledge Graph MID</label><input type="text" id="serp-kgmid" class="form-control" placeholder="/m/02h40lc"></div>
<div class="col-md-6"><label class="form-label">uule (géo)</label><input type="text" id="serp-uule" class="form-control" placeholder="w+CAIQICI..."></div>
</div>
</details>
<label class="form-label">URL générée</label>
<div class="live-preview"><a id="serp-preview-link" href="#" target="_blank" rel="noopener">https://www.google.com/search</a></div>
<div class="d-flex gap-2 mt-3">
<button class="btn btn-primary" onclick="copySerp()"><i class="bi bi-clipboard me-1"></i> Copier</button>
<button class="btn btn-light" onclick="openSerp()"><i class="bi bi-box-arrow-up-right me-1"></i> Ouvrir</button>
<button class="btn btn-light" onclick="resetSerp()">Réinitialiser</button>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#uule-tool">
<i class="bi bi-geo-alt me-2"></i> Générateur uule (géolocalisation)
<i class="bi bi-info-circle info-icon ms-2" data-bs-toggle="tooltip" data-bs-title="Génère un paramètre uule pour simuler une recherche depuis une ville précise. Plus précis que gl=fr."></i>
</button>
</h2>
<div id="uule-tool" class="accordion-collapse collapse" data-bs-parent="#toolsAccordion">
<div class="accordion-body">
<div class="mb-3">
<label class="form-label">Nom canonique du lieu</label>
<input type="text" id="uule-name" class="form-control" placeholder="Saint-Étienne, France">
<small class="text-muted">Ex: « Lyon, France », « Berlin, Germany », « New York, NY, USA »</small>
</div>
<div class="d-flex gap-2 mb-3 flex-wrap">
<button class="btn btn-primary" onclick="generateUule()"><i class="bi bi-pin-map me-1"></i> Générer</button>
<button class="btn btn-light" onclick="injectUule()">Injecter dans SERP →</button>
</div>
<div id="result-uule" class="d-none">
<label class="form-label">uule généré</label>
<div class="url-box" id="uule-output"></div>
<button class="btn btn-light btn-sm mt-2" onclick="copyUule()"><i class="bi bi-clipboard me-1"></i> Copier</button>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#suggest-tool">
<i class="bi bi-key me-2"></i> Keyword harvest (Suggest API)
<i class="bi bi-info-circle info-icon ms-2" data-bs-toggle="tooltip" data-bs-title="Récolte les requêtes que Google complète automatiquement pour un seed. Donne ce que les internautes tapent réellement."></i>
</button>
</h2>
<div id="suggest-tool" class="accordion-collapse collapse" data-bs-parent="#toolsAccordion">
<div class="accordion-body">
<div class="mb-3"><label class="form-label">Seed keyword</label><input type="text" id="suggest-seed" class="form-control" placeholder="seo"></div>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label">Mode <i class="bi bi-info-circle info-icon" data-bs-toggle="tooltip" data-bs-title="Alphabet: ajoute a-z après le seed. Questions: préfixe avec comment/pourquoi/etc. Préfixes: meilleur/prix/avis pour les intentions commerciales."></i></label>
<select id="suggest-mode" class="form-select"><option value="alphabet">Alphabet</option><option value="questions">Questions</option><option value="prefix">Préfixes commerciaux</option><option value="single">Une requête</option></select>
</div>
<div class="col-md-4">
<label class="form-label">Vertical <i class="bi bi-info-circle info-icon" data-bs-toggle="tooltip" data-bs-title="Le contexte Google où Suggest pioche. Les suggestions diffèrent selon le vertical."></i></label>
<select id="suggest-vertical" class="form-select"><option value="">Web</option><option value="yt">YouTube</option><option value="sh">Shopping</option><option value="n">News</option><option value="i">Images</option></select>
</div>
<div class="col-md-4"><label class="form-label">Langue</label><select id="suggest-hl" class="form-select"><option value="fr" selected>fr</option><option value="en">en</option><option value="de">de</option><option value="es">es</option></select></div>
</div>
<div class="d-flex gap-2 mb-3 flex-wrap">
<button class="btn btn-primary" onclick="harvestSuggest()"><i class="bi bi-cloud-download me-1"></i> Récolter</button>
<button class="btn btn-light" onclick="exportKeywords()"><i class="bi bi-file-earmark-spreadsheet me-1"></i> Export CSV</button>
</div>
<div class="text-muted small mono mb-2" id="suggest-progress"></div>
<div id="suggest-results"></div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#decoder-tool">
<i class="bi bi-bug me-2"></i> Décodeur protobuf générique
<i class="bi bi-info-circle info-icon ms-2" data-bs-toggle="tooltip" data-bs-title="Décompose n'importe quelle URL Google contenant du base64 (Maps, YouTube, Drive...) ou un blob brut."></i>
</button>
</h2>
<div id="decoder-tool" class="accordion-collapse collapse" data-bs-parent="#toolsAccordion">
<div class="accordion-body">
<div class="mb-3"><label class="form-label">URL Google ou blob base64</label><textarea id="decode-input" class="form-control" rows="3" placeholder="https://profile.google.com/cp/Eg8KDWFib25kYW5jZS5jb20="></textarea></div>
<div class="d-flex gap-2 mb-3"><button class="btn btn-primary" onclick="decodeBlob()"><i class="bi bi-search me-1"></i> Décoder</button><button class="btn btn-light" onclick="document.getElementById('decode-input').value=''; document.getElementById('decode-output').innerHTML=''">Effacer</button></div>
<div id="decode-output"></div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#inspect-tool">
<i class="bi bi-eye me-2"></i> Inspection byte par byte
<i class="bi bi-info-circle info-icon ms-2" data-bs-toggle="tooltip" data-bs-title="Pédagogique. Décompose le payload protobuf généré pour un domaine, byte par byte."></i>
</button>
</h2>
<div id="inspect-tool" class="accordion-collapse collapse" data-bs-parent="#toolsAccordion">
<div class="accordion-body">
<div class="mb-3"><label class="form-label">Domaine</label><input type="text" id="inspect-domain" class="form-control" placeholder="abondance.com"></div>
<button class="btn btn-primary mb-3" onclick="inspectBytes()"><i class="bi bi-eye me-1"></i> Inspecter</button>
<div id="result-inspect" class="d-none"><div class="bytes-display" id="bytes-display"></div></div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#batch-tool">
<i class="bi bi-list-ul me-2"></i> Mode batch (jusqu'à 1000 domaines)
<i class="bi bi-info-circle info-icon ms-2" data-bs-toggle="tooltip" data-bs-title="Génère les URLs profile.google.com pour une liste de domaines en masse."></i>
</button>
</h2>
<div id="batch-tool" class="accordion-collapse collapse" data-bs-parent="#toolsAccordion">
<div class="accordion-body">
<div class="mb-3"><label class="form-label">Domaines (un par ligne)</label><textarea id="domains-batch" class="form-control" rows="6"></textarea></div>
<div class="form-check mb-2">
<input type="checkbox" id="batch-check-dns" class="form-check-input">
<label for="batch-check-dns" class="form-check-label small">
Vérifier l'existence DNS de chaque domaine
<i class="bi bi-info-circle info-icon" data-bs-toggle="tooltip" data-bs-title="DNS lookup via Cloudflare DoH (gratuit). Marque les domaines NXDOMAIN ou sans A record. Délai : ~80ms/domaine. Si activé avec la richesse de fiche, les domaines inexistants sont automatiquement skip pour ne pas gaspiller de temps."></i>
</label>
</div>
<div class="form-check mb-3">
<input type="checkbox" id="batch-check-richness" class="form-check-input">
<label for="batch-check-richness" class="form-check-label small">
Vérifier la richesse de la fiche <code>profile.google.com</code> de chaque domaine
<i class="bi bi-info-circle info-icon" data-bs-toggle="tooltip" data-bs-title="Analyse en séquentiel chaque fiche profile.google.com et détermine 5 niveaux : Excellent / Bon / Squelette / Vide / Fantôme. Coût : 0 (via proxy CORS). Délai : ~200ms/domaine = 10s pour 50 domaines."></i>
</label>
</div>
<div class="d-flex gap-2 mb-3 flex-wrap"><button class="btn btn-primary" onclick="generateBatch()"><i class="bi bi-play-fill me-1"></i> Générer</button><button class="btn btn-light" onclick="exportBatchCSV()"><i class="bi bi-file-earmark-spreadsheet me-1"></i> Export CSV</button></div>
<div id="batch-progress" class="text-muted small mono mb-2"></div>
<div id="batch-results"></div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#rank-tool">
<i class="bi bi-graph-up me-2"></i> Rank checker (positions Google)
<i class="bi bi-info-circle info-icon ms-2" data-bs-toggle="tooltip" data-bs-title="Vérifie si votre domaine apparaît dans le top 10 Google pour une liste de mots-clés. Utilise la Custom Search API. Quota: 1 requête par mot-clé, 100/jour gratuit."></i>
</button>
</h2>
<div id="rank-tool" class="accordion-collapse collapse" data-bs-parent="#toolsAccordion">
<div class="accordion-body">
<div class="alert alert-light border small mb-3">
<i class="bi bi-info-circle me-1"></i>
<strong>À savoir :</strong> cette mesure n'est pas équivalente à Ahrefs/Semrush qui scrapent des millions de keywords. Elle vérifie une liste <em>finie</em> que vous fournissez. Le chiffre dépend donc de la qualité de votre liste — alimentez-la via le Keyword Harvest pour une couverture représentative.
</div>
<div class="mb-3">
<label class="form-label">Domaine</label>
<input type="text" id="rank-domain" class="form-control" placeholder="exemple.com">
</div>
<div class="mb-3">
<label class="form-label">Mots-clés (un par ligne)</label>
<textarea id="rank-keywords" class="form-control" rows="6" placeholder="seo technique référencement google agence digitale luxembourg"></textarea>
<small class="text-muted">Coût : 1 requête Custom Search par mot-clé. Quota gratuit 100/jour.</small>
</div>
<div class="d-flex gap-2 mb-3 flex-wrap">
<button class="btn btn-primary" onclick="checkRanks()"><i class="bi bi-search me-1"></i> Vérifier positions</button>
<button class="btn btn-light" onclick="importHarvestedKeywords()"><i class="bi bi-arrow-down-square me-1"></i> Importer depuis Suggest</button>
<button class="btn btn-light" onclick="exportRanks()"><i class="bi bi-file-earmark-spreadsheet me-1"></i> Export CSV</button>
</div>
<div class="text-muted small mono mb-2" id="rank-progress"></div>
<div id="rank-results"></div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#bookmarklet-tool">
<i class="bi bi-bookmark-star me-2"></i> Bookmarklet (analyse en un clic)
<i class="bi bi-info-circle info-icon ms-2" data-bs-toggle="tooltip" data-bs-title="Bouton à glisser dans la barre de favoris. Sur n'importe quel site, un clic relance l'audit."></i>
</button>
</h2>
<div id="bookmarklet-tool" class="accordion-collapse collapse" data-bs-parent="#toolsAccordion">
<div class="accordion-body">
<p class="mb-3">Glissez ce bouton dans votre barre de favoris.</p>
<a class="bookmarklet-link" id="bookmarklet-link" href="#" onclick="return false;"><i class="bi bi-bookmark-star me-1"></i> Auditer ce site</a>
<p class="mt-3 mb-0 small text-muted">Nécessite que le fichier soit hébergé sur un domaine.</p>
</div>
</div>
</div>
</div>
</section>
<section id="docs" class="mb-5">
<h2 class="section-title">Cas d'usage concrets</h2>
<p class="section-subtitle">Comment vous servir de l'outil dans des situations réelles.</p>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="disclosure-card">
<h3><i class="bi bi-search text-primary me-2"></i>Auditer un domaine expiré avant achat</h3>
<p class="text-muted mb-2" style="font-size:0.9rem">Vous regardez un domaine sur Sedo ou un drop. Avant de lâcher 500 €, vérifiez :</p>
<ul class="mb-0" style="font-size:0.88rem">
<li><strong>Wayback Machine</strong> — voir l'historique réel du site</li>
<li><strong>Safe Browsing</strong> — Google n'a pas flaggé le domaine pour spam/malware</li>
<li><strong>Sitemap</strong> — combien de pages le site avait avant expiration</li>
<li><strong>Recherche brevets</strong> — vérifier qu'aucune marque déposée ne traîne</li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="disclosure-card">
<h3><i class="bi bi-binoculars text-primary me-2"></i>Veiller un concurrent</h3>
<p class="text-muted mb-2" style="font-size:0.9rem">Vous voulez savoir ce qu'un concurrent fait sans Ahrefs ni Semrush :</p>
<ul class="mb-0" style="font-size:0.88rem">
<li><strong>Sitemap count</strong> — taille réelle du site (pages + images)</li>
<li><strong>Veille 30 derniers jours</strong> — leur fréquence éditoriale</li>
<li><strong>Mentions forums</strong> — niveau de notoriété user-generated</li>
<li><strong>PageSpeed</strong> — leur performance technique</li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="disclosure-card">
<h3><i class="bi bi-bug text-primary me-2"></i>Diagnostiquer un problème d'indexation</h3>
<p class="text-muted mb-2" style="font-size:0.9rem">Vous publiez 1000 pages mais Google n'en indexe que 200 :</p>
<ul class="mb-0" style="font-size:0.88rem">
<li><strong>Sitemap count</strong> vs <strong>Audit indexation</strong> dans la SERP — le delta est le nombre de pages écartées</li>
<li><strong>filter=0</strong> — révèle les doublons que Google groupait</li>
<li><strong>robots.txt status</strong> — vérifier qu'aucune section n'est bloquée par erreur</li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="disclosure-card">
<h3><i class="bi bi-globe-europe-africa text-primary me-2"></i>Tester un ranking depuis l'étranger</h3>
<p class="text-muted mb-2" style="font-size:0.9rem">Vous ciblez Berlin mais vous êtes à Paris, sans VPN :</p>
<ul class="mb-0" style="font-size:0.88rem">
<li><strong>Générateur uule</strong> — entrez « Berlin, Germany », générez le code</li>
<li><strong>Constructeur SERP</strong> — injectez le uule + gl=de + hl=de</li>
<li><strong>pws=0</strong> — désactive la personnalisation pour un résultat neutre</li>
<li>Vous voyez exactement la SERP qu'un Berlinois voit pour votre keyword</li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="disclosure-card">
<h3><i class="bi bi-lightbulb text-primary me-2"></i>Trouver les vraies questions de votre audience</h3>
<p class="text-muted mb-2" style="font-size:0.9rem">Vous voulez ce que les gens tapent vraiment, pas ce que Semrush extrapole :</p>
<ul class="mb-0" style="font-size:0.88rem">
<li><strong>Suggest Mode Questions</strong> — préfixe avec comment/pourquoi/quel</li>
<li><strong>Suggest Mode Alphabet</strong> — découvre les longues traînes a-z</li>
<li><strong>Verticaux YouTube/News</strong> — différentes intentions par contexte</li>
<li>Données fraîches, directement depuis l'API Google publique</li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="disclosure-card">
<h3><i class="bi bi-graph-up text-primary me-2"></i>Tracker vos positions sur des keywords</h3>
<p class="text-muted mb-2" style="font-size:0.9rem">Vous voulez savoir où vous rankez sur 50 keywords précis, sans payer Ahrefs :</p>
<ul class="mb-0" style="font-size:0.88rem">
<li><strong>Keyword Harvest</strong> — récoltez 100 keywords sur votre seed</li>
<li><strong>Rank Checker</strong> — importez la liste, vérifiez positions top 10</li>
<li><strong>Export CSV</strong> — gardez l'historique pour comparer mois après mois</li>
<li>Coût : votre quota Custom Search (100 req/jour gratuit)</li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="disclosure-card">
<h3><i class="bi bi-people text-primary me-2"></i>Industrialiser des audits clients</h3>
<p class="text-muted mb-2" style="font-size:0.9rem">Vous suivez 50 clients et voulez automatiser :</p>
<ul class="mb-0" style="font-size:0.88rem">
<li><strong>Mode batch</strong> — collez 50 domaines, générez toutes les URLs profile.google.com d'un coup</li>
<li><strong>Export CSV</strong> — intégrez dans votre outil de reporting</li>
<li><strong>Bookmarklet</strong> — un clic depuis n'importe quel site client pour relancer un audit</li>
<li><strong>URL partageable</strong> — envoyez le lien d'audit directement au client</li>
</ul>
</div>
</div>
</div>
<h2 class="section-title">Mesures réelles vs vérification manuelle</h2>
<p class="section-subtitle">Ce que l'outil mesure automatiquement, et ce qui nécessite votre œil.</p>
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="disclosure-card">
<h3><i class="bi bi-check-circle text-success me-2"></i>Mesuré automatiquement</h3>
<ul class="mb-0">
<li><strong>Performance + SEO + Bonnes pratiques + Accessibilité</strong> — 4 catégories Lighthouse via API PageSpeed</li>
<li><strong>Audits SEO Lighthouse à corriger</strong> — liste des points concrets (meta-description, alt, viewport, canonical...)</li>
<li><strong>Indexabilité</strong> — fetch et parsing de robots.txt</li>
<li><strong>Sitemap.xml</strong> — comptage des URLs et images</li>
<li><strong>HTTPS</strong> — protocole du domaine</li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="disclosure-card">
<h3><i class="bi bi-eye text-secondary me-2"></i>Vérification manuelle (cliquez les liens)</h3>
<ul class="mb-0">
<li>Knowledge Panel et présence dans le Knowledge Graph</li>
<li>Mentions de la marque dans les forums</li>
<li>Stabilité en mode verbatim</li>
<li>Validation Mobile-Friendly et Rich Results</li>
<li>Statut Safe Browsing et historique Wayback</li>
</ul>
</div>
</div>
</div>
<div class="alert alert-light border" style="font-size: 0.9rem;">
<strong><i class="bi bi-info-circle me-1"></i> Sitemap ≠ pages indexées.</strong>
Le comptage des URLs et images vient du sitemap.xml (pages <em>soumises</em> à Google), pas de l'index Google. Pour le vrai nombre indexé, seul Search Console donne la donnée. Le delta sitemap-vs-indexé révèle les pages que Google a écartées (duplicate, qualité, canonical, etc.) — un signal SEO utile.
</div>
</section>
<footer class="site-footer mt-5 pt-4 pb-4 border-top">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
<div class="footer-brand">
<strong><i class="bi bi-search-heart me-1" style="color: var(--bs-primary);"></i> Cat's Eye SEO</strong>
<span class="text-muted small ms-2">Voit ce que Google voit</span>
</div>
<div class="footer-meta text-muted small">
Inspiré du whitepaper <em>« Au-delà du base64 »</em> (Yakiseo, 2026)<br>
Merci à <strong>Damien (@Andell)</strong> qui partage beaucoup sur Twitter et son outil <strong>1492.vision</strong>
</div>
<div class="footer-author">
<a href="https://x.com/ChrisWaoo" target="_blank" rel="noopener" class="text-decoration-none small">
<i class="bi bi-twitter-x me-1"></i>@ChrisWaoo
</a>
</div>
</div>
</footer>
</div>
<!-- Settings Modal -->
<div class="modal fade" id="settingsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-gear me-2"></i>Réglages API</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fermer"></button>
</div>
<div class="modal-body">
<div class="alert alert-light border small mb-4">
<i class="bi bi-shield-lock me-1"></i>
<strong>Vos clés ne quittent pas votre navigateur.</strong> Stockées uniquement dans le localStorage de cette page. Aucun serveur tiers ne les voit.
<a href="/setup.html" class="d-block mt-2"><i class="bi bi-book me-1"></i>Première configuration ? Suivez le guide complet (10 min)</a>
</div>
<div class="mb-4">
<label class="form-label">Google Cloud API Key <i class="bi bi-info-circle info-icon" data-bs-toggle="tooltip" data-bs-title="Une seule clé Google Cloud peut activer plusieurs APIs si vous les avez enabled dans votre projet."></i></label>
<input type="password" id="settings-google-key" class="form-control" placeholder="AIzaSy..." autocomplete="off">
<small class="text-muted d-block mt-2">
Active automatiquement, si l'API est enabled dans votre projet :<br>
• <strong>PageSpeed Insights</strong> — quota plus élevé<br>
• <strong>Custom Search</strong> — comptage des pages indexées (avec cx ci-dessous)<br>
• <strong>Knowledge Graph Search</strong> — détection automatique du Knowledge Panel<br>
• <strong>Safe Browsing</strong> — vérification auto du statut sécurité<br>
• <strong>Places API (New)</strong> — détection auto de la fiche Google Business Profile<br>
• <strong>Chrome UX Report</strong> — métriques utilisateurs réelles (LCP/INP/CLS)
</small>
<a href="https://console.cloud.google.com/apis/credentials" target="_blank" rel="noopener" class="small d-block mt-2">→ Obtenir une clé sur Google Cloud Console</a>
</div>
<div class="mb-4">
<label class="form-label">Custom Search Engine ID (cx) <i class="bi bi-info-circle info-icon" data-bs-toggle="tooltip" data-bs-title="Identifiant d'un Programmable Search Engine configuré pour rechercher tout le web. Optionnel — utilisé uniquement pour le comptage des pages indexées."></i></label>
<input type="text" id="settings-cx" class="form-control" placeholder="017576662512468239146:omuauf_lfve" autocomplete="off">
<small class="text-muted d-block mt-2">
Créez un Programmable Search Engine sur <a href="https://programmablesearchengine.google.com/" target="_blank" rel="noopener">programmablesearchengine.google.com</a>, activez « Search the entire web », puis copiez le cx ici.
</small>
</div>
<div class="mb-4">
<label class="form-label">Proxy CORS (optionnel) <i class="bi bi-info-circle info-icon" data-bs-toggle="tooltip" data-bs-title="Beaucoup de sites n'envoient pas de header Access-Control-Allow-Origin sur leurs robots.txt/sitemap.xml. Un proxy CORS relaie la requête côté serveur et renvoie le contenu avec les bons headers."></i></label>
<input type="text" id="settings-cors-proxy" class="form-control" placeholder="https://corsproxy.io/?" autocomplete="off">
<small class="text-muted d-block mt-2">
URL préfixe d'un proxy CORS qui accepte l'URL cible URL-encodée en suffixe. Exemples : <code>https://corsproxy.io/?</code> ou <code>https://api.allorigins.win/raw?url=</code>.<br>
<strong>Privacy</strong> : le proxy voit tous les domaines que vous auditez. Self-hostez sur Cloudflare Workers pour un contrôle total. Laissez vide pour ne pas utiliser de proxy.
</small>
</div>
<div id="settings-status"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" onclick="clearSettings()"><i class="bi bi-trash me-1"></i>Effacer</button>
<button type="button" class="btn btn-light" onclick="testApiKeys()"><i class="bi bi-check2-circle me-1"></i>Tester</button>
<button type="button" class="btn btn-primary" onclick="saveSettings()"><i class="bi bi-save me-1"></i>Sauvegarder</button>
</div>
</div>
</div>
</div>
<div class="copy-toast" id="toast">URL copiée</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>
'use strict';
function encodeVarint(n) { const o=[]; while(n>0x7F){o.push((n&0x7F)|0x80);n>>>=7;} o.push(n&0x7F); return o; }
function cleanDomain(s) { return s.trim().replace(/^https?:\/\//i,'').replace(/\/+$/,'').replace(/\s+/g,''); }
function buildPayload(d) { const b=new TextEncoder().encode(d); const i=[0x0A,...encodeVarint(b.length),...b]; const p=[0x12,...encodeVarint(i.length),...i]; return {payload:p,inner:i,bytes:b}; }
function toBase64(bytes) { return btoa(String.fromCharCode(...bytes)); }
function buildProfileUrl(d) { d=cleanDomain(d); if(!d) return null; const {payload}=buildPayload(d); const b64=toBase64(payload); return {domain:d,url:`https://profile.google.com/cp/${b64}`,payloadLen:payload.length,b64Len:b64.length}; }
// Vérifie si un domaine existe via DNS-over-HTTPS Cloudflare (gratuit, pas de clé)
// Retourne { exists: boolean, status: 'ok'|'nxdomain'|'no-a-record'|'error', error?: string }
async function checkDomainExists(domain) {
const apex = domain.replace(/^www\./i, '');
try {
const r = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(apex)}&type=A`, {
headers: { 'Accept': 'application/dns-json' }
});
if (!r.ok) return { exists: null, status: 'error', error: `DoH HTTP ${r.status}` };
const data = await r.json();
// Status DoH : 0=NOERROR, 3=NXDOMAIN, autres=erreurs
if (data.Status === 3) return { exists: false, status: 'nxdomain' };
if (data.Status !== 0) return { exists: null, status: 'error', error: `DNS status ${data.Status}` };
// Status OK mais pas de A record → domaine "parqué" sans IP
const hasARecord = Array.isArray(data.Answer) && data.Answer.some(a => a.type === 1);
if (!hasARecord) {
// Tentative www.domain comme fallback
const r2 = await fetch(`https://cloudflare-dns.com/dns-query?name=www.${encodeURIComponent(apex)}&type=A`, {
headers: { 'Accept': 'application/dns-json' }
});
if (r2.ok) {
const data2 = await r2.json();
if (data2.Status === 0 && Array.isArray(data2.Answer) && data2.Answer.some(a => a.type === 1)) {
return { exists: true, status: 'ok' };
}
}
return { exists: false, status: 'no-a-record' };
}
return { exists: true, status: 'ok' };
} catch (e) {
return { exists: null, status: 'error', error: e.message };
}
}
// Détecte la richesse d'une fiche profile.google.com/cp/ via parsing fiable du HTML
// Approche : on cherche les URLs cibles directement dans le HTML, indépendamment du markup
async function checkProfileRichness(profileUrl) {
try {
const r = await fetch(buildFetchUrl(profileUrl), { mode: 'cors' });
if (!r.ok) return { level: 'error', score: null, signals: {}, label: `HTTP ${r.status}`, details: [`Le proxy a renvoyé HTTP ${r.status}`] };
const html = await r.text();
// Diagnostic : si la réponse est trop courte, on signale
if (html.length < 1000) {
return {
level: 'error', score: null,
signals: { responseLength: html.length },
label: 'Réponse vide',
details: [`Réponse trop courte (${html.length} caractères). Le proxy a peut-être un souci.`]
};
}
// Le HTML brut de profile.google.com fait 100-1000 KB. On ne cherche pas la structure (markdown vs HTML)
// mais directement les URLs et marqueurs.
// 1. Fiche fantôme (texte fixe, présent même dans le HTML brut)
const isUnavailable = /Creator Profile page unavailable|profile_unavailable|unavailable.{0,50}logo/i.test(html);
if (isUnavailable) {
return {
level: 'ghost', score: 0,
signals: { isUnavailable: true, hasFavicon: false, hasWebsiteLink: false, socialNetworks: [], postsCount: 0, noRecentPosts: true, responseLength: html.length },
label: 'Fantôme', details: ['Page unavailable explicite de Google']
};
}
// 2. Favicon : pattern technique stable (URL gstatic)
const hasFavicon = /faviconV2/i.test(html);
// 3. Lien Website : on cherche le DOMAINE de la fiche dans une URL externe (différente de google.com/gstatic)
// Extrait le domaine de la fiche depuis l'URL profile.google.com/cp/{base64}
// On va le récupérer en décodant le base64 du profile URL
let targetDomain = '';
try {
const m = profileUrl.match(/\/cp\/([A-Za-z0-9_\-+\/=]+)/);
if (m) {
const decoded = atob(m[1].replace(/-/g, '+').replace(/_/g, '/'));
const dm = decoded.match(/([a-zA-Z0-9\-]+\.[a-z]{2,}(?:\.[a-z]{2,})?)/);
if (dm) targetDomain = dm[1].toLowerCase();
}
} catch(e) {}
// hasWebsiteLink : on trouve une URL contenant ce domaine en dehors de gstatic/google
let hasWebsiteLink = false;
if (targetDomain) {
const escDomain = targetDomain.replace(/\./g, '\\.');
const re = new RegExp(`https?://(?:www\\.)?${escDomain}(?:/|"|\\s|<|$)`, 'i');
hasWebsiteLink = re.test(html);
}
// 4. Avatar
const hasAvatar = /Creator Profile Avatar/i.test(html);
// 5. Réseaux sociaux : on cherche directement les URLs des plateformes dans le HTML
const socialPatterns = {
Facebook: /https?:\/\/(?:www\.)?facebook\.com\/[a-zA-Z0-9._\-]+/i,
'X (Twitter)': /https?:\/\/(?:www\.)?(?:twitter|x)\.com\/[a-zA-Z0-9._\-]+/i,
YouTube: /https?:\/\/(?:www\.)?youtube\.com\/(?:channel\/|user\/|c\/|@)[a-zA-Z0-9._\-]+/i,
Instagram: /https?:\/\/(?:www\.)?instagram\.com\/[a-zA-Z0-9._\-]+/i,
LinkedIn: /https?:\/\/(?:www\.)?linkedin\.com\/(?:company|in|school)\/[a-zA-Z0-9._\-]+/i,
TikTok: /https?:\/\/(?:www\.)?tiktok\.com\/@?[a-zA-Z0-9._\-]+/i,
Pinterest: /https?:\/\/(?:www\.)?pinterest\.[a-z.]+\/[a-zA-Z0-9._\-]+/i
};
const socialNetworks = Object.keys(socialPatterns).filter(name =>
socialPatterns[name].test(html)
);
// 6. Posts récents : on cherche le mot "Latest posts" dans le HTML
// Puis on compte les URLs du domaine cible (qui sont les liens de posts)
let postsCount = 0;
const hasLatestPosts = /Latest posts/i.test(html);
if (hasLatestPosts && targetDomain) {
// Compter les URLs du domaine cible (autre que la racine) après "Latest posts"
const idx = html.search(/Latest posts/i);
if (idx >= 0) {
const after = html.substring(idx);
const escDomain = targetDomain.replace(/\./g, '\\.');
const re = new RegExp(`https?://(?:www\\.)?${escDomain}/[^"'\\s<>]+`, 'gi');
const matches = after.match(re) || [];
// Dédupe + ignore les variantes racine
const uniqueUrls = [...new Set(matches)].filter(u => u.replace(/https?:\/\/(?:www\.)?[^/]+/, '').length > 1);
postsCount = Math.min(uniqueUrls.length, 10);
}
}
// 7. No recent posts
const noRecentPosts = /No recent posts|haven't posted on social media|hasn't posted/i.test(html);
// Calcul du score 0-100
let score = 0;
if (hasFavicon) score += 25;
if (hasWebsiteLink) score += 25;
if (hasAvatar) score += 5;
score += Math.min(socialNetworks.length * 10, 40);
score += Math.min(postsCount * 5, 25);
if (noRecentPosts && socialNetworks.length === 0 && postsCount === 0) score -= 10;
score = Math.max(0, Math.min(100, score));
// Niveau
let level, label;
if (score >= 85) { level = 'excellent'; label = 'Excellent'; }
else if (score >= 60) { level = 'good'; label = 'Bon'; }
else if (score >= 30) { level = 'skeleton'; label = 'Squelette'; }
else if (score >= 10) { level = 'sparse'; label = 'Vide'; }
else { level = 'ghost'; label = 'Fantôme'; }
// Détails
const details = [];
if (hasFavicon) details.push('✓ Favicon récupéré par Google');
if (hasWebsiteLink) details.push('✓ Lien Website indexé');
if (hasAvatar) details.push('✓ Avatar affiché');
if (socialNetworks.length > 0) details.push(`✓ ${socialNetworks.length} réseau(x) social(aux) : ${socialNetworks.join(', ')}`);
if (postsCount > 0) details.push(`✓ ${postsCount} post(s) récent(s) indexé(s) par Google`);
if (noRecentPosts && socialNetworks.length === 0 && postsCount === 0) details.push('⚠ Aucun signal social récent');
if (!hasFavicon && !hasWebsiteLink) details.push(`⚠ Aucun signal de base (HTML ${html.length} char, domaine cible "${targetDomain}")`);
return {
level, score, label, details,
signals: { hasFavicon, hasWebsiteLink, hasAvatar, socialNetworks, postsCount, noRecentPosts, isUnavailable: false, responseLength: html.length, targetDomain }
};
} catch (e) {
return { level: 'error', score: null, signals: {}, label: 'CORS/proxy', details: [`Erreur : ${e.message}`] };
}
}
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
function showToast(msg) { const t=document.getElementById('toast'); t.textContent=msg||'Copié'; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1500); }
const HIST_KEY='profile-finder-history';
function getHistory(){ try { return JSON.parse(localStorage.getItem(HIST_KEY)||'[]'); } catch(e){ return []; } }
function pushHistory(d){ let h=getHistory().filter(x=>x!==d); h.unshift(d); h=h.slice(0,5); try{localStorage.setItem(HIST_KEY,JSON.stringify(h));}catch(e){} renderHistory(); }
function renderHistory(){ const h=getHistory(); const c=document.getElementById('history-pills'); if(!h.length){c.style.display='none';return;} c.style.display='flex'; c.innerHTML='<span class="history-label">Récents :</span>'+h.map(d=>`<span class="history-pill" onclick="quickAudit('${escapeHtml(d).replace(/'/g,"\\'")}')">${escapeHtml(d)}</span>`).join(''); }
function quickAudit(d){ document.getElementById('audit-domain').value=d; runFullAudit({preventDefault:()=>{}}); }
// ============== SETTINGS ==============
const SETTINGS_KEY = 'profile-finder-settings';
function getSettings() { try { return JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}'); } catch(e) { return {}; } }
function saveSettings() {
const settings = {
googleKey: document.getElementById('settings-google-key').value.trim(),
cx: document.getElementById('settings-cx').value.trim(),
corsProxy: document.getElementById('settings-cors-proxy').value.trim()
};
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
document.getElementById('settings-status').innerHTML = '<div class="alert alert-success small mb-0 mt-2">✓ Sauvegardé. Relancez l\'audit pour utiliser les nouvelles clés.</div>';
setTimeout(() => { try { bootstrap.Modal.getInstance(document.getElementById('settingsModal')).hide(); } catch(e){} }, 1200);
} catch(e) {
document.getElementById('settings-status').innerHTML = '<div class="alert alert-danger small mb-0 mt-2">✗ Erreur de sauvegarde</div>';
}
}
function clearSettings() {
localStorage.removeItem(SETTINGS_KEY);
document.getElementById('settings-google-key').value = '';
document.getElementById('settings-cx').value = '';
document.getElementById('settings-cors-proxy').value = '';
document.getElementById('settings-status').innerHTML = '<div class="alert alert-light border small mb-0 mt-2">Toutes les clés ont été effacées.</div>';
}
function loadSettingsIntoForm() {
const s = getSettings();
document.getElementById('settings-google-key').value = s.googleKey || '';
document.getElementById('settings-cx').value = s.cx || '';
document.getElementById('settings-cors-proxy').value = s.corsProxy || '';
document.getElementById('settings-status').innerHTML = '';
}
async function testApiKeys() {
const googleKey = document.getElementById('settings-google-key').value.trim();
const cx = document.getElementById('settings-cx').value.trim();
const status = document.getElementById('settings-status');
if (!googleKey) { status.innerHTML = '<div class="alert alert-warning small mb-0 mt-2">Saisissez d\'abord une clé Google.</div>'; return; }
status.innerHTML = '<div class="text-muted small mt-2"><i class="bi bi-hourglass-split me-1"></i>Test en cours…</div>';
const tests = [];
tests.push((async () => {
try {
const r = await fetch(`https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=https://google.com&strategy=mobile&category=performance&key=${encodeURIComponent(googleKey)}`);
return { name: 'PageSpeed Insights', ok: r.ok, error: r.ok ? null : `HTTP ${r.status}` };
} catch(e) { return { name: 'PageSpeed Insights', ok: false, error: e.message }; }
})());
tests.push((async () => {
try {
const r = await fetch(`https://kgsearch.googleapis.com/v1/entities:search?query=Google&key=${encodeURIComponent(googleKey)}&limit=1`);
return { name: 'Knowledge Graph', ok: r.ok, error: r.ok ? null : `HTTP ${r.status}` };
} catch(e) { return { name: 'Knowledge Graph', ok: false, error: e.message }; }
})());
tests.push((async () => {
try {
const r = await fetch(`https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${encodeURIComponent(googleKey)}`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ client:{clientId:'profile-finder',clientVersion:'1.0'}, threatInfo:{threatTypes:['MALWARE'],platformTypes:['ANY_PLATFORM'],threatEntryTypes:['URL'],threatEntries:[{url:'https://google.com'}]} })
});
return { name: 'Safe Browsing', ok: r.ok, error: r.ok ? null : `HTTP ${r.status}` };
} catch(e) { return { name: 'Safe Browsing', ok: false, error: e.message }; }
})());
tests.push((async () => {
try {
const r = await fetch('https://places.googleapis.com/v1/places:searchText', {
method:'POST',
headers: {
'Content-Type':'application/json',
'X-Goog-Api-Key': googleKey,
'X-Goog-FieldMask': 'places.id,places.displayName'
},
body: JSON.stringify({ textQuery: 'Eiffel Tower', maxResultCount: 1 })
});
return { name: 'Places API (GBP)', ok: r.ok, error: r.ok ? null : `HTTP ${r.status}` };
} catch(e) { return { name: 'Places API (GBP)', ok: false, error: e.message }; }
})());
if (cx) {
tests.push((async () => {
try {
const r = await fetch(`https://customsearch.googleapis.com/customsearch/v1?key=${encodeURIComponent(googleKey)}&cx=${encodeURIComponent(cx)}&q=test&num=1`);
return { name: 'Custom Search', ok: r.ok, error: r.ok ? null : `HTTP ${r.status}` };
} catch(e) { return { name: 'Custom Search', ok: false, error: e.message }; }
})());
}
const results = await Promise.all(tests);
let html = '<div class="border rounded p-3 mt-2 small" style="background: var(--bg);">';
for (const r of results) {
if (r.ok) html += `<div class="text-success mb-1"><i class="bi bi-check-circle-fill me-1"></i>${r.name}</div>`;
else html += `<div class="text-danger mb-1"><i class="bi bi-x-circle-fill me-1"></i>${r.name} <span class="text-muted">— ${escapeHtml(r.error || '')}</span></div>`;
}
if (!cx) html += '<div class="text-muted mt-2"><i class="bi bi-info-circle me-1"></i>Custom Search non testé (cx manquant).</div>';
html += '</div>';
status.innerHTML = html;
}
// ============== API CHECKS (require keys) ==============
async function checkIndexedCount(domain, apiKey, cx) {
if (!apiKey || !cx) return null;
try {
const url = `https://customsearch.googleapis.com/customsearch/v1?key=${encodeURIComponent(apiKey)}&cx=${encodeURIComponent(cx)}&q=${encodeURIComponent('site:'+domain)}&num=1`;
const r = await fetch(url);
if (!r.ok) {
let errMsg = `HTTP ${r.status}`;
let closedToNew = false;
try {
const errData = await r.json();
if (errData.error?.message) {
errMsg = errData.error.message.substring(0, 120);
if (/closed|not.*access|PERMISSION_DENIED/i.test(errMsg)) closedToNew = true;
}
} catch(e) {}
return { ok:false, error: errMsg, status: r.status, closedToNew };
}
const data = await r.json();
const total = data?.searchInformation?.totalResults;
return { ok:true, count: total ? parseInt(total) : 0 };
} catch(e) { return { ok:false, error:e.message }; }
}
async function checkKnowledgeGraph(brand, apiKey) {
if (!apiKey) return null;
try {
const url = `https://kgsearch.googleapis.com/v1/entities:search?query=${encodeURIComponent(brand)}&key=${encodeURIComponent(apiKey)}&limit=1`;
const r = await fetch(url);
if (!r.ok) return { ok:false, error:`HTTP ${r.status}` };
const data = await r.json();
const item = data?.itemListElement?.[0];
if (!item) return { ok:true, found:false };
const result = item.result || {};
const types = Array.isArray(result['@type']) ? result['@type'].filter(t => t !== 'Thing') : [];
return { ok:true, found:true, name:result.name, types, score:item.resultScore, description:result.description };
} catch(e) { return { ok:false, error:e.message }; }
}
async function checkSafeBrowsing(domain, apiKey) {
if (!apiKey) return null;
try {
const r = await fetch(`https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${encodeURIComponent(apiKey)}`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
client:{clientId:'profile-finder',clientVersion:'1.0'},
threatInfo:{
threatTypes:['MALWARE','SOCIAL_ENGINEERING','UNWANTED_SOFTWARE','POTENTIALLY_HARMFUL_APPLICATION'],
platformTypes:['ANY_PLATFORM'], threatEntryTypes:['URL'],
threatEntries:[{url:'https://'+domain}]
}
})
});
if (!r.ok) return { ok:false, error:`HTTP ${r.status}` };
const data = await r.json();
return { ok:true, clean: !data.matches || data.matches.length === 0, matches: data.matches || [] };
} catch(e) { return { ok:false, error:e.message }; }
}
// ============== REAL CHECKS ==============
function getDomainVariants(domain) {
const apex = domain.replace(/^www\./i, '');
const www = 'www.' + apex;
// Return [user input first, then alternate]
if (domain === apex) return [apex, www];
return [www, apex];
}
function buildFetchUrl(targetUrl) {
const settings = getSettings();
if (settings.corsProxy) return settings.corsProxy + encodeURIComponent(targetUrl);
return targetUrl;
}
async function pageSpeedQuery(domain) {
const settings = getSettings();
const keyParam = settings.googleKey ? `&key=${encodeURIComponent(settings.googleKey)}` : '';
const categories = ['performance','seo','accessibility','best-practices'].map(c => `&category=${c}`).join('');
const url = `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent('https://'+domain)}&strategy=mobile${categories}${keyParam}`;
try {
const r = await fetch(url);
if (!r.ok) {
let errMsg = `HTTP ${r.status}`;
try {
const errData = await r.json();
if (errData.error?.message) errMsg = errData.error.message.substring(0, 100);
} catch(e) {}
return {ok:false, error: errMsg, status: r.status};
}
const data = await r.json();
const cats = data?.lighthouseResult?.categories || {};
const audits = data?.lighthouseResult?.audits || {};
const getScore = (key) => {
const s = cats[key]?.score;
return s === undefined || s === null ? null : Math.round(s * 100);
};
const seoFailed = (cats.seo?.auditRefs || [])
.map(ref => audits[ref.id])
.filter(a => a && a.score !== null && a.score < 1 && a.scoreDisplayMode !== 'manual' && a.scoreDisplayMode !== 'notApplicable')
.map(a => ({ id: a.id, title: a.title, description: a.description }));
return {
ok: true,
performance: getScore('performance'),
seo: getScore('seo'),
accessibility: getScore('accessibility'),
bestPractices: getScore('best-practices'),
seoFailed
};
} catch (e) { return {ok:false, error: e.message || 'Network error'}; }
}
async function checkPageSpeed(domain) {
let lastError = null;
for (const d of getDomainVariants(domain)) {
const result = await pageSpeedQuery(d);
if (result.ok) { result.resolvedDomain = d; return result; }
lastError = result;
// Don't retry: same quota/auth issue on both variants
if (result.status === 429 || result.status === 403) break;
}
return { ok: false, error: lastError?.error || 'PSI a échoué', status: lastError?.status };
}
async function robotsQuery(domain) {
try {
const r = await fetch(buildFetchUrl(`https://${domain}/robots.txt`), {mode:'cors'});
if (!r.ok) return {ok:false, exists:r.status!==404, status:r.status};
const text = await r.text();
const ua = text.match(/User-agent:\s*\*[^]*?(?=User-agent:|$)/i);
let blocksAll = false;
if (ua) blocksAll = /^\s*Disallow:\s*\/\s*$/im.test(ua[0]);
const hasSitemap = /Sitemap:/i.test(text);
return {ok:true, exists:true, blocksAll, hasSitemap};
} catch (e) { return {ok:false, exists:null}; }
}
async function checkRobotsTxt(domain) {
for (const d of getDomainVariants(domain)) {
const result = await robotsQuery(d);
if (result.ok) { result.resolvedDomain = d; return result; }
// If 404 on first variant, the file just doesn't exist - try other variant anyway
}
// Both failed
return {ok:false, exists:null};
}
async function sitemapQuery(domain) {
try {
const r = await fetch(buildFetchUrl(`https://${domain}/sitemap.xml`), {mode:'cors'});
if (!r.ok) return {ok:false, exists:r.status!==404, status:r.status};
const text = await r.text();
const isIndex = /<sitemapindex/i.test(text);
if (isIndex) {
const childUrls = [...text.matchAll(/<loc>([^<]+)<\/loc>/gi)].map(m => m[1].trim()).slice(0, 10);
let urls = 0, images = 0, fetched = 0;
const childResults = await Promise.all(childUrls.map(async url => {
try {
const cr = await fetch(buildFetchUrl(url), {mode:'cors'});
if (!cr.ok) return null;
const ct = await cr.text();
return {
urls: (ct.match(/<url[\s>]/gi) || []).length,
images: (ct.match(/<image:image[\s>]/gi) || []).length
};
} catch(e) { return null; }
}));
childResults.forEach(r => { if (r) { urls += r.urls; images += r.images; fetched++; } });
return {ok:true, exists:true, urls, images, sitemapCount:childUrls.length, fetched, isIndex:true};
} else {
const urls = (text.match(/<url[\s>]/gi) || []).length;
const images = (text.match(/<image:image[\s>]/gi) || []).length;
return {ok:true, exists:true, urls, images, isIndex:false};
}
} catch (e) { return {ok:false, exists:null}; }
}
async function checkSitemap(domain) {
for (const d of getDomainVariants(domain)) {
const result = await sitemapQuery(d);
if (result.ok) { result.resolvedDomain = d; return result; }
}
return {ok:false, exists:null};
}
function statusBadge(status, label) {
const icons = {ok:'check-circle-fill', warn:'exclamation-triangle-fill', bad:'x-circle-fill', pending:'hourglass-split', manual:'eye'};
return `<span class="status-badge ${status}"><i class="bi bi-${icons[status]}"></i> ${label}</span>`;
}
// ============== ADDITIONAL DOMAIN CHECKS (Leak-inspired) ==============
function detectEMD(domain) {
const apex = domain.replace(/^www\./i, '').toLowerCase();
const name = apex.split('.')[0];
const commercialKw = ['cheap','best','top','meilleur','meilleure','pas-cher','pascher','prix','avis','luxe','occasion','gratuit','online','shop','store','24','express','rapide','officiel','discount','promo','deal','black','vente','achat','acheter','site','direct','low','cost','luxury'];
const hyphens = (name.match(/-/g) || []).length;
const length = name.length;
let score = 0;
const reasons = [];
if (hyphens >= 2) { score += 1; reasons.push(`${hyphens} tirets`); }
if (hyphens >= 3) { score += 1; }
if (length >= 25) { score += 1; reasons.push(`${length} caractères`); }
const kwFound = commercialKw.filter(k => name.includes(k));
if (kwFound.length >= 1) { score += 1; reasons.push(`Mot-clé : « ${kwFound.join(', ')} »`); }
if (kwFound.length >= 2) { score += 2; }
return { isEmd: score >= 2, risk: score >= 4 ? 'high' : score >= 2 ? 'medium' : 'low', score, reasons };
}
async function checkDomainAge(domain) {
try {
const apex = domain.replace(/^www\./i, '');
const url = `https://web.archive.org/cdx/search/cdx?url=${encodeURIComponent(apex)}&output=json&limit=1&from=19960101&fl=timestamp`;
// Timeout à 8s — Wayback est parfois très lent
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const r = await fetch(buildFetchUrl(url), { signal: controller.signal });
clearTimeout(timeoutId);
if (!r.ok) return { ok: false, status: r.status };
const data = await r.json();
if (!Array.isArray(data) || data.length < 2) return { ok: true, found: false };
const ts = data[1][0];
const year = parseInt(ts.substring(0,4)), month = parseInt(ts.substring(4,6)) || 1, day = parseInt(ts.substring(6,8)) || 1;
const date = new Date(year, month-1, day);
const ageDays = (Date.now() - date.getTime()) / (1000*60*60*24);
return { ok: true, found: true, firstSnapshot: date, ageDays: Math.floor(ageDays), ageYears: Math.floor(ageDays / 365 * 10) / 10 };
} catch (e) {
if (e.name === 'AbortError') return { ok: false, error: 'Wayback timeout (>8s)' };
return { ok: false, error: e.message };
}
}
// Detection Google Business Profile via Places API (New)
// Hybride : essai par domaine, fallback Text Search enrichi
async function checkGooglePlaces(domain) {
const settings = getSettings();
if (!settings.googleKey) return null;
const apex = domain.replace(/^www\./i, '');
const brand = apex.split('.')[0];
const tld = apex.split('.').pop();
const countryHint = tldToCountry(tld);
// Stratégie hybride
const queries = [];
queries.push({ q: apex, label: 'domaine' });
if (countryHint) {
queries.push({ q: `${brand} ${countryHint}`, label: 'marque+pays' });
} else {
queries.push({ q: brand, label: 'marque' });
}
// Headers Places API (New) — FieldMask obligatoire
const fields = [
'places.id', 'places.displayName', 'places.formattedAddress',
'places.location', 'places.nationalPhoneNumber', 'places.internationalPhoneNumber',
'places.websiteUri', 'places.googleMapsUri',
'places.rating', 'places.userRatingCount',
'places.primaryType', 'places.primaryTypeDisplayName', 'places.types',
'places.businessStatus', 'places.regularOpeningHours.openNow'
].join(',');
let lastError = null;
for (const { q, label } of queries) {
try {
const r = await fetch('https://places.googleapis.com/v1/places:searchText', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': settings.googleKey,
'X-Goog-FieldMask': fields
},
body: JSON.stringify({
textQuery: q,
maxResultCount: 5,
languageCode: countryHint ? 'fr' : 'en'
})
});
if (!r.ok) {
let errMsg = `HTTP ${r.status}`;
let closedToNew = false;
try {
const errData = await r.json();
if (errData.error?.message) {
errMsg = errData.error.message.substring(0, 120);
if (/closed|not.*access|PERMISSION_DENIED/i.test(errMsg)) closedToNew = true;
}
} catch(e) {}
lastError = { error: errMsg, status: r.status, closedToNew, queryLabel: label };
continue;
}
const data = await r.json();
const places = data?.places || [];
if (places.length === 0) {
lastError = { error: 'Aucun résultat', queryLabel: label };
continue;
}
// Trouver la place dont le websiteUri matche le domaine audité
const match = places.find(p => {
if (!p.websiteUri) return false;
try {
const placeHost = new URL(p.websiteUri).hostname.replace(/^www\./i, '');
return placeHost === apex || placeHost.endsWith('.' + apex);
} catch(e) { return false; }
});
if (match) {
return { ok: true, found: true, exactMatch: true, place: match, queryUsed: label, totalResults: places.length };
}
// Pas de match exact mais on retourne le premier résultat avec un flag de "non confirmé"
// (utile pour l'utilisateur de voir s'il y a des homonymes)
return { ok: true, found: true, exactMatch: false, place: places[0], queryUsed: label, totalResults: places.length, allPlaces: places.slice(0, 3) };
} catch (e) {
lastError = { error: e.message, queryLabel: label };
}
}
return { ok: false, ...lastError, found: false };
}
// Mapping TLD → pays (en français pour affichage)
function tldToCountry(tld) {
const map = {
fr: 'France', lu: 'Luxembourg', be: 'Belgique', ch: 'Suisse',
de: 'Allemagne', es: 'Espagne', it: 'Italie', pt: 'Portugal',
nl: 'Pays-Bas', uk: 'Royaume-Uni', ie: 'Irlande',
ca: 'Canada', us: 'États-Unis', au: 'Australie',
ma: 'Maroc', dz: 'Algérie', tn: 'Tunisie', sn: 'Sénégal', ci: "Côte d'Ivoire"
};
return map[tld] || null;
}
async function checkCrux(domain) {
const settings = getSettings();
if (!settings.googleKey) return null;
try {
const url = `https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=${encodeURIComponent(settings.googleKey)}`;
const r = await fetch(url, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ origin: 'https://' + domain, formFactor: 'PHONE' })
});
if (!r.ok) {
if (r.status === 404) return { ok: true, hasData: false }; // pas assez de trafic
let closedToNew = false;
try {
const errData = await r.json();
if (errData.error?.message && /closed|not.*access|PERMISSION_DENIED/i.test(errData.error.message)) {
closedToNew = true;
}
} catch(e) {}
return { ok: false, status: r.status, closedToNew };
}
const data = await r.json();
const m = data?.record?.metrics || {};
const lcp = m.largest_contentful_paint?.percentiles?.p75;
const inp = m.interaction_to_next_paint?.percentiles?.p75;
const cls = m.cumulative_layout_shift?.percentiles?.p75;
return {
ok: true, hasData: true, lcp, inp, cls,
rateLcp: lcp === undefined ? null : (lcp <= 2500 ? 'ok' : lcp <= 4000 ? 'warn' : 'bad'),
rateInp: inp === undefined ? null : (inp <= 200 ? 'ok' : inp <= 500 ? 'warn' : 'bad'),
rateCls: cls === undefined ? null : (cls <= 0.1 ? 'ok' : cls <= 0.25 ? 'warn' : 'bad')
};
} catch (e) { return { ok: false, error: e.message }; }
}
function formatMs(ms) {
if (ms === undefined || ms === null) return 'N/A';
if (ms < 1000) return ms + ' ms';
return (ms / 1000).toFixed(2) + ' s';
}
async function runHealthChecks(domain) {
const breakdownEl = document.getElementById('health-breakdown');
const settings = getSettings();
const brand = domain.split('.')[0];
const [psi, robots, sitemap, indexed, kg, safe, crux, age, places] = await Promise.all([
checkPageSpeed(domain),
checkRobotsTxt(domain),
checkSitemap(domain),
checkIndexedCount(domain, settings.googleKey, settings.cx),
checkKnowledgeGraph(brand, settings.googleKey),
checkSafeBrowsing(domain, settings.googleKey),
checkCrux(domain),
checkDomainAge(domain),
checkGooglePlaces(domain)
]);
const emd = detectEMD(domain);
let scores = []; let weights = []; let html = '';
// Detect resolved domain (if PSI/robots/sitemap found a different variant)
const resolved = psi.resolvedDomain || robots.resolvedDomain || sitemap.resolvedDomain;
if (resolved && resolved !== domain) {
html += `<div class="item" style="padding-bottom:0.5rem;border-bottom:1px solid var(--card-border);margin-bottom:0.4rem;"><span class="item-label"><i class="bi bi-arrow-right-circle me-1"></i>Domaine canonique détecté</span> <span class="item-value">${escapeHtml(resolved)}</span></div>`;
}
// Performance
if (psi.ok && psi.performance !== null) {
const stat = psi.performance >= 80 ? 'ok' : psi.performance >= 50 ? 'warn' : 'bad';
html += `<div class="item"><span class="item-label">Performance (Lighthouse mobile)</span> ${statusBadge(stat, psi.performance+'/100')}</div>`;
scores.push(psi.performance); weights.push(30);
} else {
const errLabel = psi.status === 429 ? 'Rate limit (429)' : (psi.error ? escapeHtml(psi.error).slice(0, 30) : 'API échouée');
html += `<div class="item"><span class="item-label">Performance (Lighthouse mobile)</span> ${statusBadge('bad', errLabel)}</div>`;
}
// SEO (Lighthouse)
if (psi.ok && psi.seo !== null) {
const stat = psi.seo >= 80 ? 'ok' : psi.seo >= 50 ? 'warn' : 'bad';
html += `<div class="item"><span class="item-label">SEO technique (Lighthouse)</span> ${statusBadge(stat, psi.seo+'/100')}</div>`;
scores.push(psi.seo); weights.push(30);
}
// Best Practices
if (psi.ok && psi.bestPractices !== null) {
const stat = psi.bestPractices >= 80 ? 'ok' : psi.bestPractices >= 50 ? 'warn' : 'bad';
html += `<div class="item"><span class="item-label">Bonnes pratiques (Lighthouse)</span> ${statusBadge(stat, psi.bestPractices+'/100')}</div>`;
scores.push(psi.bestPractices); weights.push(15);
}
// Accessibility
if (psi.ok && psi.accessibility !== null) {
const stat = psi.accessibility >= 80 ? 'ok' : psi.accessibility >= 50 ? 'warn' : 'bad';
html += `<div class="item"><span class="item-label">Accessibilité (Lighthouse)</span> ${statusBadge(stat, psi.accessibility+'/100')}</div>`;
scores.push(psi.accessibility); weights.push(10);
}
// Indexable
if (robots.ok && robots.blocksAll) {
html += `<div class="item"><span class="item-label">Indexable (robots.txt)</span> ${statusBadge('bad', 'Bloqué')}</div>`;
scores.push(0); weights.push(10);
} else if (robots.ok) {
html += `<div class="item"><span class="item-label">Indexable (robots.txt)</span> ${statusBadge('ok', 'Autorisé')}</div>`;
scores.push(100); weights.push(10);
} else {
html += `<div class="item"><span class="item-label">Indexable (robots.txt)</span> ${statusBadge('manual', robots.exists===false ? 'Absent' : 'CORS bloqué')}</div>`;
}
// Sitemap
if (sitemap.ok && sitemap.exists) {
const detail = sitemap.urls > 0
? `${sitemap.urls.toLocaleString('fr-FR')} URLs${sitemap.isIndex ? ` (${sitemap.fetched}/${sitemap.sitemapCount} sous-sitemaps)` : ''}`
: 'Présent';
html += `<div class="item"><span class="item-label">Sitemap.xml</span> ${statusBadge('ok', detail)}</div>`;
scores.push(100); weights.push(5);
if (sitemap.images > 0) {
html += `<div class="item"><span class="item-label">Images dans le sitemap</span> ${statusBadge('ok', sitemap.images.toLocaleString('fr-FR') + ' images')}</div>`;
}
} else if (robots.ok && robots.hasSitemap) {
html += `<div class="item"><span class="item-label">Sitemap.xml</span> ${statusBadge('ok', 'Via robots.txt')}</div>`;
scores.push(100); weights.push(5);
} else if (sitemap.exists === false) {
html += `<div class="item"><span class="item-label">Sitemap.xml</span> ${statusBadge('warn', 'Absent')}</div>`;
scores.push(50); weights.push(5);
} else {
html += `<div class="item"><span class="item-label">Sitemap.xml</span> ${statusBadge('manual', 'CORS bloqué')}</div>`;
}
// Indexed pages (via Custom Search API, if available)
if (indexed?.ok && indexed.count !== null) {
html += `<div class="item"><span class="item-label">Pages indexées (Google Custom Search)</span> ${statusBadge('ok', indexed.count.toLocaleString('fr-FR') + ' pages')}</div>`;
} else if (indexed && !indexed.ok && indexed.closedToNew) {
html += `<div class="item"><span class="item-label">Pages indexées (Custom Search)</span> ${statusBadge('manual', 'API fermée')}</div>`;
} else if (indexed && !indexed.ok) {
html += `<div class="item"><span class="item-label">Pages indexées (Custom Search)</span> ${statusBadge('warn', indexed.error || 'API erreur')}</div>`;
}
// Safe Browsing (via API, if available)
if (safe?.ok) {
html += `<div class="item"><span class="item-label">Safe Browsing</span> ${statusBadge(safe.clean ? 'ok' : 'bad', safe.clean ? 'Clean' : 'Flagged')}</div>`;
}
// Knowledge Graph (via API, if available)
if (kg?.ok && kg.found) {
const types = kg.types?.length ? kg.types.slice(0,2).join(', ') : 'Entité';
html += `<div class="item"><span class="item-label">Knowledge Graph</span> ${statusBadge('ok', types)}</div>`;
} else if (kg?.ok && !kg.found) {
html += `<div class="item"><span class="item-label">Knowledge Graph</span> ${statusBadge('warn', 'Pas trouvée')}</div>`;
}
// Google Business Profile (Places API)
if (places?.ok && places.found && places.exactMatch) {
const p = places.place;
const rating = p.rating ? ` · ${p.rating}/5 (${p.userRatingCount || 0} avis)` : '';
html += `<div class="item"><span class="item-label">Fiche Google Business Profile</span> ${statusBadge('ok', 'Trouvée' + rating)}</div>`;
} else if (places?.ok && places.found && !places.exactMatch) {
html += `<div class="item"><span class="item-label">Fiche Google Business Profile</span> ${statusBadge('warn', 'Site web différent')}</div>`;
} else if (places?.ok && !places.found) {
html += `<div class="item"><span class="item-label">Fiche Google Business Profile</span> ${statusBadge('warn', 'Pas trouvée')}</div>`;
} else if (places && !places.ok && places.closedToNew) {
html += `<div class="item"><span class="item-label">Fiche Google Business Profile</span> ${statusBadge('manual', 'API fermée')}</div>`;
} else if (places && !places.ok && places.status) {
html += `<div class="item"><span class="item-label">Fiche Google Business Profile</span> ${statusBadge('warn', `HTTP ${places.status}`)}</div>`;
}
// CrUX — vraies métriques utilisateur Chrome (si clé API)
if (crux?.ok && crux.hasData) {
html += `<div class="item"><span class="item-label">LCP réel (CrUX p75)</span> ${statusBadge(crux.rateLcp || 'manual', formatMs(crux.lcp))}</div>`;
html += `<div class="item"><span class="item-label">INP réel (CrUX p75)</span> ${statusBadge(crux.rateInp || 'manual', formatMs(crux.inp))}</div>`;
html += `<div class="item"><span class="item-label">CLS réel (CrUX p75)</span> ${statusBadge(crux.rateCls || 'manual', crux.cls?.toFixed(2) || 'N/A')}</div>`;
} else if (crux?.ok && !crux.hasData) {
html += `<div class="item"><span class="item-label">CrUX (utilisateurs réels)</span> ${statusBadge('manual', 'Trafic insuffisant')}</div>`;
} else if (crux && !crux.ok && crux.closedToNew) {
html += `<div class="item"><span class="item-label">CrUX (utilisateurs réels)</span> ${statusBadge('manual', 'API fermée')}</div>`;
}
// Âge du domaine (Wayback)
if (age?.ok && age.found) {
const stat = age.ageYears >= 2 ? 'ok' : age.ageYears >= 0.5 ? 'warn' : 'bad';
const label = age.ageYears >= 1 ? `${age.ageYears} ans` : `${Math.round(age.ageDays / 30)} mois`;
html += `<div class="item"><span class="item-label">Âge du domaine (Wayback)</span> ${statusBadge(stat, label)}</div>`;
}
// EMD (Exact Match Domain)
if (emd.isEmd) {
const stat = emd.risk === 'high' ? 'bad' : 'warn';
html += `<div class="item"><span class="item-label">Domaine EMD (sur-optimisé)</span> ${statusBadge(stat, emd.risk === 'high' ? 'Risque élevé' : 'Risque moyen')}</div>`;
}
// Failed SEO audits (actionable items)
if (psi.ok && psi.seoFailed && psi.seoFailed.length > 0) {
html += `<div class="item mt-2 pt-2 border-top" style="display:block"><details><summary class="text-muted small" style="cursor:pointer; font-weight:600;"><i class="bi bi-exclamation-triangle me-1"></i>Audits SEO Lighthouse à corriger (${psi.seoFailed.length})</summary><ul class="mt-2 mb-0" style="font-size:0.8rem; padding-left:1.2rem;">`;
psi.seoFailed.forEach(a => {
html += `<li><strong>${escapeHtml(a.title)}</strong></li>`;
});
html += `</ul></details></div>`;
}
// Coverage indicator
const totalPossibleWeight = 100; // 30 perf + 30 seo + 15 bp + 10 a11y + 10 robots + 5 sitemap
const totalActualWeight = weights.reduce((a,b) => a+b, 0);
const coverage = Math.round((totalActualWeight / totalPossibleWeight) * 100);
const coverageStat = coverage >= 75 ? 'ok' : coverage >= 50 ? 'warn' : 'bad';
html += `<div class="item mt-2 pt-2 border-top"><span class="item-label"><strong>Couverture des mesures</strong></span> ${statusBadge(coverageStat, coverage + '%')}</div>`;
// Rate-limit warning if PSI failed with quota error
const isRateLimit = !psi.ok && (psi.status === 429 || (psi.error && /quota|rate|limit/i.test(psi.error)));
if (isRateLimit && !settings.googleKey) {
html += `<div class="alert alert-warning small mt-3 mb-0"><i class="bi bi-key-fill me-1"></i><strong>Quota PSI anonyme atteint.</strong> Configurez une clé API Google dans les Réglages pour un quota beaucoup plus élevé (gratuit).</div>`;
}
// Insufficient data warning
if (coverage < 50) {
html += `<div class="alert alert-warning small mt-2 mb-0"><i class="bi bi-exclamation-triangle me-1"></i><strong>Données insuffisantes.</strong> Trop de mesures ont échoué (${100-coverage}% des poids manquants). Le score affiché ne reflète pas la santé réelle du site.</div>`;
}
// EMD warning
if (emd.isEmd) {
html += `<div class="alert alert-warning small mt-2 mb-0"><i class="bi bi-exclamation-triangle me-1"></i><strong>Domaine EMD détecté</strong> : ${emd.reasons.join(' · ')}. Le leak Google confirme l'existence d'<code>exactMatchDomainDemotion</code> qui pénalise les domaines sur-optimisés sur la requête cible sans signal de marque.</div>`;
}
// Young domain warning
if (age?.ok && age.found && age.ageYears < 0.5) {
html += `<div class="alert alert-warning small mt-2 mb-0"><i class="bi bi-clock-history me-1"></i><strong>Domaine récent</strong> : premier snapshot Wayback il y a ${Math.round(age.ageDays / 30)} mois. Le leak confirme l'existence d'un attribut <code>hostAge</code> appliqué aux nouveaux domaines (sandbox-like) sur les requêtes concurrentielles.</div>`;
}
// Custom Search / CrUX closed to new customers
if ((indexed?.closedToNew) || (crux?.closedToNew)) {
const apis = [];
if (indexed?.closedToNew) apis.push('Custom Search JSON API');
if (crux?.closedToNew) apis.push('Chrome UX Report API');
html += `<div class="alert alert-light border small mt-2 mb-0"><i class="bi bi-info-circle me-1"></i><strong>${apis.join(' + ')} indisponible(s).</strong> Google a fermé ${apis.length>1?'ces APIs':'cette API'} aux nouveaux comptes Cloud créés après 2025. <a href="https://discuss.google.dev/t/custom-search-json-api-returns-403-permission-denied-on-new-org-new-account-restriction/347093" target="_blank" rel="noopener">Plus d'infos</a>. Utilisez les liens manuels du dashboard à la place.</div>`;
}
// Bloc détaillé Google Business Profile si fiche trouvée
if (places?.ok && places.found) {
const p = places.place;
const name = p.displayName?.text || 'Sans nom';
const addr = p.formattedAddress || '';
const phone = p.internationalPhoneNumber || p.nationalPhoneNumber || '';
const rating = p.rating;
const reviewCount = p.userRatingCount || 0;
const mapsUrl = p.googleMapsUri || '';
const website = p.websiteUri || '';
const primaryType = p.primaryTypeDisplayName?.text || p.primaryType || '';
const status = p.businessStatus;
const statusLabel = status === 'OPERATIONAL' ? '' : status === 'CLOSED_TEMPORARILY' ? ' · ⚠️ Fermé temporairement' : status === 'CLOSED_PERMANENTLY' ? ' · ❌ Fermé définitivement' : '';
let alertClass = places.exactMatch ? 'alert-light' : 'alert-warning';
let alertIntro = places.exactMatch
? '<i class="bi bi-geo-alt-fill me-1 text-success"></i><strong>Fiche Google Business Profile trouvée</strong> (site web cohérent avec le domaine audité).'
: `<i class="bi bi-exclamation-triangle me-1 text-warning"></i><strong>Fiche trouvée mais site web officiel différent.</strong> La fiche pointe vers <code>${escapeHtml(website)}</code> alors que vous auditez <code>${escapeHtml(domain)}</code>. Concurrent qui squatte ? Ancien domaine ? Fiche d'une autre entité ?`;
html += `<div class="alert ${alertClass} border small mt-2 mb-0">`;
html += `${alertIntro}<div class="mt-2"><strong>${escapeHtml(name)}</strong>${primaryType ? ` <span class="text-muted">· ${escapeHtml(primaryType)}</span>` : ''}${statusLabel}</div>`;
if (addr) html += `<div><i class="bi bi-pin-map me-1"></i>${escapeHtml(addr)}</div>`;
if (phone) html += `<div><i class="bi bi-telephone me-1"></i>${escapeHtml(phone)}</div>`;
if (rating) html += `<div><i class="bi bi-star-fill text-warning me-1"></i><strong>${rating}/5</strong> · ${reviewCount.toLocaleString('fr-FR')} avis</div>`;
if (mapsUrl) html += `<div class="mt-1"><a href="${escapeHtml(mapsUrl)}" target="_blank" rel="noopener" class="card-action">Voir sur Google Maps <i class="bi bi-arrow-up-right"></i></a></div>`;
html += '</div>';
} else if (places?.ok && !places.found) {
// Pas de fiche trouvée — suggérer d'en créer une si business local
html += `<div class="alert alert-light border small mt-2 mb-0"><i class="bi bi-info-circle me-1"></i><strong>Aucune fiche Google Business Profile détectée.</strong> Si c'est un business local (commerce, agence, restaurant…), <a href="https://business.google.com/" target="_blank" rel="noopener">créer une fiche</a> est un quick-win SEO local majeur (signal géo + avis + photos UGC).</div>`;
}
breakdownEl.innerHTML = html;
let total = 0, weight = 0;
for (let i = 0; i < scores.length; i++) { total += scores[i] * weights[i]; weight += weights[i]; }
// Don't compute score if coverage is too low
const finalScore = (weight > 0 && coverage >= 50) ? Math.round(total / weight) : null;
const circle = document.getElementById('score-circle');
circle.classList.remove('computing', 'pending', 'good', 'warn', 'bad');
if (finalScore !== null) {
if (finalScore >= 80) circle.classList.add('good');
else if (finalScore >= 50) circle.classList.add('warn');
else circle.classList.add('bad');
circle.querySelector('.score-value').textContent = finalScore;
} else {
circle.classList.add('pending');
circle.querySelector('.score-value').textContent = coverage > 0 ? '?' : '—';
}
// Update combo cards
if (psi.ok && psi.performance !== null) {
const stat = psi.performance >= 80 ? 'ok' : psi.performance >= 50 ? 'warn' : 'bad';
const el = document.querySelector(`[data-combo="pagespeed"] .combo-status`);
if (el) el.innerHTML = statusBadge(stat, `${psi.performance}/100`);
} else {
const el = document.querySelector(`[data-combo="pagespeed"] .combo-status`);
if (el) el.innerHTML = statusBadge('manual', 'API échouée');
}
// Indexation: Custom Search API > sitemap count > robots status > manual
const indexEl = document.querySelector(`[data-combo="indexation"] .combo-status`);
if (indexEl) {
if (robots.ok && robots.blocksAll) {
indexEl.innerHTML = statusBadge('bad', 'Bloqué');
} else if (indexed?.ok && indexed.count !== null) {
indexEl.innerHTML = statusBadge('ok', `${indexed.count.toLocaleString('fr-FR')} indexées`);
} else if (sitemap.ok && sitemap.urls > 0) {
indexEl.innerHTML = statusBadge('ok', `${sitemap.urls.toLocaleString('fr-FR')} URLs sitemap`);
} else if (robots.ok) {
indexEl.innerHTML = statusBadge('ok', 'Indexable');
} else {
indexEl.innerHTML = statusBadge('manual', 'Manuel');
}
}
// Images
const imgEl = document.querySelector(`[data-combo="images"] .combo-status`);
if (imgEl) {
if (sitemap.ok && sitemap.images > 0) {
imgEl.innerHTML = statusBadge('ok', `${sitemap.images.toLocaleString('fr-FR')} dans sitemap`);
} else if (sitemap.ok) {
imgEl.innerHTML = statusBadge('manual', 'Pas dans sitemap');
} else {
imgEl.innerHTML = statusBadge('manual', 'Manuel');
}
}
// Knowledge Panel (via Knowledge Graph API)
const kpEl = document.querySelector(`[data-combo="kp"] .combo-status`);
if (kpEl && kg) {
if (kg.ok && kg.found) {
const types = kg.types?.length ? kg.types[0] : 'Entité';
kpEl.innerHTML = statusBadge('ok', `${types} (KG)`);
} else if (kg.ok) {
kpEl.innerHTML = statusBadge('warn', 'Pas dans KG');
} else {
kpEl.innerHTML = statusBadge('manual', 'API erreur');
}
}
// Safe Browsing
const safeEl = document.querySelector(`[data-combo="safe"] .combo-status`);
if (safeEl && safe) {
if (safe.ok && safe.clean) {
safeEl.innerHTML = statusBadge('ok', 'Clean');
} else if (safe.ok) {
safeEl.innerHTML = statusBadge('bad', `Flagged: ${safe.matches[0]?.threatType || 'Threat'}`);
} else {
safeEl.innerHTML = statusBadge('manual', 'API erreur');
}
}
// Google Business Profile combo card
const gbpEl = document.querySelector(`[data-combo="gbp"] .combo-status`);
if (gbpEl && places) {
if (places.ok && places.found && places.exactMatch) {
const rating = places.place.rating ? `${places.place.rating}/5` : 'Trouvée';
gbpEl.innerHTML = statusBadge('ok', rating);
} else if (places.ok && places.found && !places.exactMatch) {
gbpEl.innerHTML = statusBadge('warn', 'Site différent');
} else if (places.ok && !places.found) {
gbpEl.innerHTML = statusBadge('warn', 'Pas trouvée');
} else if (places.closedToNew) {
gbpEl.innerHTML = statusBadge('manual', 'API fermée');
} else {
gbpEl.innerHTML = statusBadge('manual', 'API erreur');
}
}
}
let currentDomain = '';
async function runFullAudit(e) {
e.preventDefault();
const d = cleanDomain(document.getElementById('audit-domain').value); if (!d) return;
// Pré-check DNS : si le domaine n'existe pas, on arrête tout
const launcherBtn = document.querySelector('.launcher-form button');
const originalBtnHTML = launcherBtn ? launcherBtn.innerHTML : '';
if (launcherBtn) {
launcherBtn.disabled = true;
launcherBtn.innerHTML = '<span class="loader-spinner me-2"></span>Vérification DNS…';
}
const dns = await checkDomainExists(d);
if (dns.exists === false) {
// Domaine inexistant : on bloque
document.getElementById('dashboard').classList.add('d-none');
const errMsg = dns.status === 'nxdomain'
? `Le domaine <code>${escapeHtml(d)}</code> n'existe pas (réponse DNS : NXDOMAIN).`
: `Le domaine <code>${escapeHtml(d)}</code> existe mais n'a aucun enregistrement A — il ne pointe vers aucun serveur web.`;
showToast('Domaine inexistant');
// Affiche une alerte au-dessus du hero
let alertEl = document.getElementById('dns-error-alert');
if (!alertEl) {
alertEl = document.createElement('div');
alertEl.id = 'dns-error-alert';
alertEl.className = 'alert alert-danger mb-3';
const banner = document.getElementById('welcome-banner');
banner.parentNode.insertBefore(alertEl, banner);
}
alertEl.innerHTML = `<strong><i class="bi bi-x-octagon-fill me-1"></i>Domaine inexistant</strong> · ${errMsg} <br><span class="small text-muted">Vérifiez l'orthographe ou utilisez un domaine valide. Aucun audit lancé.</span>`;
alertEl.scrollIntoView({behavior:'smooth', block:'center'});
if (launcherBtn) { launcherBtn.disabled = false; launcherBtn.innerHTML = originalBtnHTML; }
return;
}
// Nettoie une éventuelle alerte précédente
const oldAlert = document.getElementById('dns-error-alert');
if (oldAlert) oldAlert.remove();
// Restore bouton
if (launcherBtn) { launcherBtn.disabled = false; launcherBtn.innerHTML = originalBtnHTML; }
// Reset complet si on change de domaine (sinon l'ancienne analyse persiste)
const isNewDomain = currentDomain !== d;
if (isNewDomain) {
// 1. Analyse éditoriale
document.getElementById('editorial-output').innerHTML = '';
const singleUrlInput = document.getElementById('single-article-url');
if (singleUrlInput) singleUrlInput.value = '';
// Reset au mode "5 récents" si on était sur "article précis"
const tabRecents = document.getElementById('tab-recents');
const tabSingle = document.getElementById('tab-single');
if (tabRecents && tabSingle && tabSingle.classList.contains('active')) {
tabRecents.click();
}
// 2. Inspection byte par byte (pré-rempli avec le nouveau domaine)
const inspectDomain = document.getElementById('inspect-domain');
const inspectResult = document.getElementById('result-inspect');
const inspectBytes = document.getElementById('bytes-display');
if (inspectDomain) inspectDomain.value = d;
if (inspectResult) inspectResult.classList.add('d-none');
if (inspectBytes) inspectBytes.innerHTML = '';
// 3. Constructeur SERP (champs et résultats)
const serpQ = document.getElementById('serp-q');
if (serpQ) serpQ.value = '';
const serpSite = document.getElementById('serp-site');
if (serpSite) serpSite.value = '';
const serpKgmid = document.getElementById('serp-kgmid');
if (serpKgmid) serpKgmid.value = '';
const serpUule = document.getElementById('serp-uule');
if (serpUule) serpUule.value = '';
if (typeof updateSerpPreview === 'function') updateSerpPreview();
// 4. Générateur uule
const uuleName = document.getElementById('uule-name');
const uuleResult = document.getElementById('result-uule');
const uuleOutput = document.getElementById('uule-output');
if (uuleName) uuleName.value = '';
if (uuleResult) uuleResult.classList.add('d-none');
if (uuleOutput) uuleOutput.textContent = '';
// 5. Keyword harvest (Suggest)
const suggestSeed = document.getElementById('suggest-seed');
const suggestResults = document.getElementById('suggest-results');
const suggestProgress = document.getElementById('suggest-progress');
if (suggestSeed) suggestSeed.value = '';
if (suggestResults) suggestResults.innerHTML = '';
if (suggestProgress) suggestProgress.textContent = '';
lastKeywords = [];
// 6. Décodeur protobuf
const decodeInput = document.getElementById('decode-input');
const decodeOutput = document.getElementById('decode-output');
if (decodeInput) decodeInput.value = '';
if (decodeOutput) decodeOutput.innerHTML = '';
// 7. Mode batch
const domainsBatch = document.getElementById('domains-batch');
const batchResults = document.getElementById('batch-results');
const batchProgress = document.getElementById('batch-progress');
if (domainsBatch) domainsBatch.value = '';
if (batchResults) batchResults.innerHTML = '';
if (batchProgress) batchProgress.textContent = '';
lastBatchData = [];
// 8. Rank checker
const rankKeywords = document.getElementById('rank-keywords');
const rankResults = document.getElementById('rank-results');
const rankProgress = document.getElementById('rank-progress');
if (rankKeywords) rankKeywords.value = '';
if (rankResults) rankResults.innerHTML = '';
if (rankProgress) rankProgress.textContent = '';
lastRankResults = [];
}
currentDomain = d; pushHistory(d);
const profile = buildProfileUrl(d);
const brand = d.split('.')[0];
const enc = encodeURIComponent;
document.getElementById('dashboard-domain').textContent = d;
document.getElementById('profile-url').href = profile.url;
document.getElementById('profile-url').textContent = profile.url;
document.getElementById('profile-meta').textContent = `${profile.payloadLen} octets · ${profile.b64Len} chars`;
// Reset + lance la vérification de richesse de la fiche profile.google.com
const richnessEl = document.getElementById('profile-richness');
if (richnessEl) {
richnessEl.innerHTML = '<span class="skel skel-badge"></span>';
checkProfileRichness(profile.url).then(r => {
const badgeMap = { excellent:'ok', good:'ok', skeleton:'warn', sparse:'warn', ghost:'bad', error:'manual' };
const iconMap = {
excellent: 'check-circle-fill', good: 'check-circle',
skeleton: 'exclamation-triangle-fill', sparse: 'exclamation-triangle',
ghost: 'x-circle-fill', error: 'eye'
};
const tooltipText = r.details && r.details.length ? r.details.join(' · ') : (r.label || 'Statut inconnu');
const scoreText = r.score !== null ? ` ${r.score}/100` : '';
richnessEl.innerHTML = `<span class="status-badge ${badgeMap[r.level] || 'manual'}" data-bs-toggle="tooltip" data-bs-title="${escapeHtml(tooltipText)}"><i class="bi bi-${iconMap[r.level] || 'eye'}"></i> Fiche ${escapeHtml(r.label)}${scoreText}</span>`;
initTooltips();
});
}
const circle = document.getElementById('score-circle');
circle.className = 'score-circle pending computing';
circle.querySelector('.score-value').textContent = '…';
const skelItems = [
'Performance (Lighthouse mobile)',
'SEO technique (Lighthouse)',
'Bonnes pratiques (Lighthouse)',
'Accessibilité (Lighthouse)',
'Indexable (robots.txt)',
'Sitemap.xml',
'Knowledge Graph'
];
document.getElementById('health-breakdown').innerHTML = skelItems.map(label =>
`<div class="item"><span class="item-label">${label}</span> <span class="skel skel-badge"></span></div>`
).join('');
const groups = [
{ name: "Indexation & contenu", icon: "bi-list-task", combos: [
{ key:'indexation', title:"Audit d'indexation propre", desc:"Toutes les pages indexées, sans dédoublonnage.", url:`https://www.google.com/search?q=site:${enc(d)}&filter=0&pws=0&udm=14&gl=fr&hl=fr`, tip:"Compte automatique: URLs dans le sitemap (pages soumises à Google). Pour le vrai nombre indexé, ouvrez le lien et regardez le compteur de la SERP, ou consultez Search Console.", auto:true },
{ key:'recent', title:"Veille — 30 derniers jours", desc:"Dernières publications, triées par date.", url:`https://www.google.com/search?q=site:${enc(d)}&tbs=qdr:m,sbd:1&udm=14`, tip:"Vérifiez la régularité de publication. Le silence éditorial = mort lente en SEO." },
{ key:'images', title:"Images indexées", desc:"Toutes les images vues par Google.", url:`https://www.google.com/search?q=site:${enc(d)}&udm=2&filter=0`, tip:"Compte automatique : nombre d'images dans le sitemap (si présent). Pour le vrai nombre indexé par Google, ouvrez le lien et regardez la SERP. Indicateur de la richesse visuelle d'un site.", auto:true },
]},
{ name: "Performance technique", icon: "bi-speedometer2", combos: [
{ key:'pagespeed', title:"Lighthouse / PageSpeed", desc:"Performance + SEO + Bonnes pratiques + A11y.", url:`https://pagespeed.web.dev/analysis?url=https://${enc(d)}&form_factor=mobile&hl=fr`, tip:"Audit Lighthouse complet : 4 catégories notées sur 100. Le badge ici montre uniquement le score Performance ; les 3 autres scores apparaissent dans la carte « Score de santé » au-dessus.", auto:true },
{ key:'mobile', title:"Test Mobile-Friendly", desc:"Verdict mobile officiel.", url:`https://search.google.com/test/mobile-friendly?url=https://${enc(d)}`, tip:"Verdict pass/fail. Indispensable depuis 2021 : Google indexe la version mobile en priorité (mobile-first indexing)." },
{ key:'rich', title:"Test Rich Results", desc:"Validation des données structurées.", url:`https://search.google.com/test/rich-results?url=https://${enc(d)}`, tip:"Vérifie que vos JSON-LD sont valides. Schema correct = éligibilité aux résultats enrichis (étoiles, FAQ, recettes, breadcrumbs, etc.)." },
]},
{ name: "Marque & notoriété", icon: "bi-stars", combos: [
{ key:'kp', title:"Knowledge Panel forcé", desc:"Recherche du nom de marque.", url:`https://www.google.com/search?q=${enc(brand)}&hl=fr`, tip:"Si le panel apparaît à droite, vous êtes dans le Knowledge Graph. Avec une clé API, la détection est automatique.", auto: !!getSettings().googleKey },
{ key:'gbp', title:"Fiche Google Business Profile", desc:"Présence locale (Maps, avis, horaires).", url:`https://www.google.com/maps/search/${enc(brand)}`, tip:"Détection automatique via Places API : la fiche GBP est-elle présente, le site web correspond-il, quel rating ? Bouton ouvre Maps pour vérification manuelle.", auto: !!getSettings().googleKey },
{ key:'forums', title:"Mentions dans les forums", desc:"Reddit & communautés.", url:`https://www.google.com/search?q=${enc(brand)}&udm=18&hl=fr`, tip:"Beaucoup de mentions = signal de notoriété user-generated, fort pour le ranking." },
{ key:'verbatim', title:"Test verbatim", desc:"Recherche sans reformulation.", url:`https://www.google.com/search?q=${enc(brand)}&tbs=li:1&udm=14`, tip:"Si votre site disparaît en verbatim, votre ranking dépend de la reformulation Google. Signal de fragilité." },
{ key:'patents', title:"Recherche brevets", desc:"Brevets liés à la marque.", url:`https://patents.google.com/?q=${enc(brand)}`, tip:"Pertinent en B2B/tech. Présence de brevets = signal d'autorité industrielle." },
]},
{ name: "Sécurité & vérifications", icon: "bi-shield-check", combos: [
{ key:'safe', title:"Statut Safe Browsing", desc:"Flag malware/phishing.", url:`https://transparencyreport.google.com/safe-browsing/search?url=${enc(d)}`, tip:"Critique avant achat de domaine expiré. Avec une clé API, la vérification est automatique.", auto: !!getSettings().googleKey },
{ key:'wayback', title:"Wayback Machine", desc:"Historique du site.", url:`https://web.archive.org/web/*/${enc(d)}`, tip:"Voir l'évolution du site dans le temps. Indispensable pour évaluer un domaine acheté ou un concurrent." },
]}
];
let html = '';
for (const g of groups) {
html += `<div class="group-heading"><i class="bi ${g.icon}"></i> ${g.name}</div><div class="row g-3">`;
for (const c of g.combos) {
const badge = c.auto ? statusBadge('pending', '…') : statusBadge('manual', 'Manuel');
html += `<div class="col-md-6 col-lg-4"><div class="card" data-combo="${c.key}"><div class="card-body"><div class="card-header-row"><div class="card-title">${escapeHtml(c.title)} <i class="bi bi-info-circle info-icon" data-bs-toggle="tooltip" data-bs-title="${escapeHtml(c.tip)}"></i></div><span class="combo-status">${badge}</span></div><p class="card-text">${escapeHtml(c.desc)}</p><div><a href="${escapeHtml(c.url)}" target="_blank" rel="noopener" class="card-action">Ouvrir <i class="bi bi-arrow-up-right"></i></a><button class="card-action copy" onclick="copyComboUrl('${escapeHtml(c.url).replace(/'/g,"\\'")}', this)">Copier</button></div></div></div></div>`;
}
html += `</div>`;
}
document.getElementById('combos-dashboard').innerHTML = html;
document.getElementById('editorial-output').innerHTML = '';
const editBtn = document.getElementById('btn-editorial');
if (editBtn) { editBtn.disabled = false; editBtn.innerHTML = '<i class="bi bi-newspaper me-1"></i> Lancer l\'analyse éditoriale'; }
document.getElementById('dashboard').classList.remove('d-none');
// Show file:// warning if running locally
if (window.location.protocol === 'file:') {
document.getElementById('file-protocol-warning').classList.remove('d-none');
}
// Pre-fill rank checker with current domain
const rankDomainInput = document.getElementById('rank-domain');
if (rankDomainInput) rankDomainInput.value = d;
initTooltips();
const url = new URL(window.location); url.searchParams.set('domain', d);
window.history.replaceState({}, '', url);
document.getElementById('dashboard').scrollIntoView({behavior:'smooth', block:'start'});
runHealthChecks(d);
}
function copyComboUrl(url, btn) {
navigator.clipboard.writeText(url).then(() => {
const o = btn.textContent; btn.textContent = '✓ Copié'; btn.style.color = '#16a34a';
setTimeout(() => { btn.textContent = o; btn.style.color = ''; }, 1200);
});
}
function copyShareLink() {
if (!currentDomain) return;
const url = `${window.location.origin}${window.location.pathname}?domain=${encodeURIComponent(currentDomain)}`;
navigator.clipboard.writeText(url).then(() => showToast('Lien partageable copié'));
}
function copyAllUrls() {
if (!currentDomain) return;
const links = [...document.querySelectorAll('#combos-dashboard a.card-action[href]')].map(a => a.href);
const profileUrl = document.getElementById('profile-url').href;
navigator.clipboard.writeText([profileUrl, ...links].join('\n')).then(() => showToast(`${links.length+1} URLs copiées`));
}
// SERP BUILDER
function buildSerpUrl() {
const params = new URLSearchParams();
const q = document.getElementById('serp-q').value.trim(); if (q) params.set('q', q);
const udm = document.getElementById('serp-udm').value; if (udm) params.set('udm', udm);
const tbsParts = []; const time = document.getElementById('serp-time').value; if (time) tbsParts.push(time);
if (document.getElementById('serp-verbatim').checked) tbsParts.push('li:1');
if (document.getElementById('serp-sortdate').checked) tbsParts.push('sbd:1');
if (tbsParts.length) params.set('tbs', tbsParts.join(','));
const gl = document.getElementById('serp-gl').value; if (gl) params.set('gl', gl);
const hl = document.getElementById('serp-hl').value; if (hl) params.set('hl', hl);
if (document.getElementById('serp-pws').checked) params.set('pws', '0');
if (document.getElementById('serp-filter').checked) params.set('filter', '0');
if (document.getElementById('serp-safe').checked) params.set('safe', 'off');
if (document.getElementById('serp-nfpr').checked) params.set('nfpr', '1');
const site = document.getElementById('serp-site').value.trim(); if (site) params.set('as_sitesearch', site);
const filetype = document.getElementById('serp-filetype').value; if (filetype) params.set('as_filetype', filetype);
const kgmid = document.getElementById('serp-kgmid').value.trim(); if (kgmid) params.set('kgmid', kgmid);
const uule = document.getElementById('serp-uule').value.trim(); if (uule) params.set('uule', uule);
const qs = params.toString();
return qs ? `https://www.google.com/search?${qs}` : 'https://www.google.com/search';
}
function updateSerpPreview() { const url = buildSerpUrl(); const link = document.getElementById('serp-preview-link'); link.href = url; link.textContent = url; }
function copySerp() { navigator.clipboard.writeText(buildSerpUrl()).then(() => showToast('URL SERP copiée')); }
function openSerp() { window.open(buildSerpUrl(), '_blank'); }
function resetSerp() {
['serp-q','serp-site','serp-kgmid','serp-uule'].forEach(id => document.getElementById(id).value = '');
document.getElementById('serp-udm').value = '14'; document.getElementById('serp-time').value = '';
document.getElementById('serp-gl').value = 'fr'; document.getElementById('serp-hl').value = 'fr';
document.getElementById('serp-filetype').value = '';
document.getElementById('serp-pws').checked = true;
['serp-filter','serp-verbatim','serp-sortdate','serp-safe','serp-nfpr'].forEach(id => document.getElementById(id).checked = false);
updateSerpPreview();
}
['serp-q','serp-udm','serp-time','serp-gl','serp-hl','serp-pws','serp-filter','serp-verbatim','serp-sortdate','serp-safe','serp-nfpr','serp-site','serp-filetype','serp-kgmid','serp-uule'].forEach(id => {
const el = document.getElementById(id); if (!el) return;
el.addEventListener('input', updateSerpPreview); el.addEventListener('change', updateSerpPreview);
});
updateSerpPreview();
// UULE
function generateUule() {
const name = document.getElementById('uule-name').value.trim(); if (!name) return;
const utf8 = new TextEncoder().encode(name);
const b64 = toBase64(Array.from(utf8));
const lenChar = String.fromCharCode(name.length);
const uule = `w+CAIQICI${lenChar}${b64}`;
document.getElementById('uule-output').textContent = uule;
document.getElementById('result-uule').classList.remove('d-none');
}
function copyUule() { navigator.clipboard.writeText(document.getElementById('uule-output').textContent).then(() => showToast('uule copié')); }
function injectUule() {
generateUule();
const u = document.getElementById('uule-output').textContent; if (!u) return;
document.getElementById('serp-uule').value = u; updateSerpPreview();
bootstrap.Collapse.getOrCreateInstance(document.getElementById('serp-tool')).show();
document.getElementById('serp-tool').scrollIntoView({behavior:'smooth'});
showToast('Injecté dans SERP');
}
// SUGGEST
function buildSuggestUrl(q, vertical, hl) { const ds = vertical?`&ds=${vertical}`:''; return `https://suggestqueries.google.com/complete/search?client=firefox&hl=${hl}${ds}&q=${encodeURIComponent(q)}`; }
let lastKeywords = [];
async function harvestSuggest() {
const seed = document.getElementById('suggest-seed').value.trim(); if (!seed) return;
const mode = document.getElementById('suggest-mode').value;
const vertical = document.getElementById('suggest-vertical').value;
const hl = document.getElementById('suggest-hl').value;
let queries = [];
if (mode==='single') queries=[seed];
else if (mode==='alphabet') queries=[seed,...'abcdefghijklmnopqrstuvwxyz'.split('').map(c=>`${seed} ${c}`)];
else if (mode==='questions') queries=['comment','pourquoi','qui','quel','quelle','quand','où','que','combien','est-ce que'].map(p=>`${p} ${seed}`);
else if (mode==='prefix') queries=['meilleur','meilleure','top','prix','avis','exemple','définition','comparatif','alternative','vs'].map(p=>`${p} ${seed}`);
const progress = document.getElementById('suggest-progress'); const out = document.getElementById('suggest-results');
out.innerHTML = ''; progress.textContent = `0 / ${queries.length}…`;
const all = new Set(); let errors = 0; let done = 0;
for (const q of queries) {
try {
const r = await fetch(buildSuggestUrl(q, vertical, hl));
if (r.ok) { const data = await r.json(); if (Array.isArray(data) && Array.isArray(data[1])) data[1].forEach(s => all.add(s)); }
else errors++;
} catch (e) { errors++; }
done++; progress.textContent = `${done} / ${queries.length} · ${all.size} keywords · ${errors} erreurs`;
}
if (all.size === 0 && errors > 0) {
const curls = queries.slice(0,5).map(q => `curl "${buildSuggestUrl(q, vertical, hl)}"`).join('\n');
out.innerHTML = `<div class="alert alert-warning small"><strong>Récolte échouée (CORS).</strong> Hébergez sur un domaine, ou exécutez en terminal :</div><pre class="bytes-display">${escapeHtml(curls)}</pre>`;
return;
}
lastKeywords = Array.from(all).sort();
out.innerHTML = `<div class="text-muted small mb-2">${lastKeywords.length} keywords uniques</div><div class="keywords-grid">${lastKeywords.map(k => `<div class="kw-item">${escapeHtml(k)}</div>`).join('')}</div>`;
}
function exportKeywords() {
if (!lastKeywords.length) { showToast('Aucun keyword'); return; }
const csv = ['keyword', ...lastKeywords.map(k => `"${k.replace(/"/g,'""')}"`)].join('\n');
const blob = new Blob([csv],{type:'text/csv;charset=utf-8'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'keywords.csv'; a.click();
URL.revokeObjectURL(url);
}
// ============== RANK CHECKER ==============
let lastRankResults = [];
async function checkSingleRank(domain, keyword, apiKey, cx) {
const url = `https://customsearch.googleapis.com/customsearch/v1?key=${encodeURIComponent(apiKey)}&cx=${encodeURIComponent(cx)}&q=${encodeURIComponent(keyword)}&num=10`;
try {
const r = await fetch(url);
if (!r.ok) {
let errMsg = `HTTP ${r.status}`;
try { const errData = await r.json(); if (errData.error?.message) errMsg = errData.error.message.substring(0, 80); } catch(e) {}
return { keyword, position: null, error: errMsg, status: r.status };
}
const data = await r.json();
const items = data.items || [];
const domainLower = domain.toLowerCase().replace(/^www\./, '');
for (let i = 0; i < items.length; i++) {
const itemUrl = (items[i].link || '').toLowerCase();
try {
const itemHost = new URL(itemUrl).hostname.replace(/^www\./, '');
if (itemHost === domainLower || itemHost.endsWith('.' + domainLower)) {
return { keyword, position: i + 1, url: items[i].link, title: items[i].title };
}
} catch(e) {}
}
return { keyword, position: null };
} catch (e) {
return { keyword, position: null, error: e.message };
}
}
async function checkRanks() {
const settings = getSettings();
const out = document.getElementById('rank-results');
if (!settings.googleKey || !settings.cx) {
out.innerHTML = `<div class="alert alert-warning small"><i class="bi bi-key me-1"></i>Configurez <strong>Google API Key + Custom Search Engine ID</strong> dans <a href="#" onclick="bootstrap.Modal.getOrCreateInstance(document.getElementById('settingsModal')).show(); return false;">Réglages</a> avant.</div>`;
return;
}
const domain = cleanDomain(document.getElementById('rank-domain').value);
if (!domain) { showToast('Domaine manquant'); return; }
const keywords = document.getElementById('rank-keywords').value.split('\n').map(k => k.trim()).filter(Boolean);
if (!keywords.length) { showToast('Aucun mot-clé'); return; }
const progress = document.getElementById('rank-progress');
out.innerHTML = '';
progress.textContent = `0 / ${keywords.length}…`;
const results = [];
let done = 0; let errors = 0; let inTop10 = 0; let quotaHit = false;
for (const kw of keywords) {
const result = await checkSingleRank(domain, kw, settings.googleKey, settings.cx);
results.push(result);
done++;
if (result.error) errors++;
if (result.position !== null) inTop10++;
progress.textContent = `${done} / ${keywords.length} · ${inTop10} en top 10 · ${errors} erreurs`;
if (result.status === 403 && result.error && /closed|not.*access|PERMISSION_DENIED/i.test(result.error)) {
out.innerHTML = `<div class="alert alert-light border small"><i class="bi bi-info-circle me-1"></i><strong>Custom Search JSON API indisponible.</strong> Google a fermé cette API aux nouveaux comptes Cloud (créés après 2025). <a href="https://discuss.google.dev/t/custom-search-json-api-returns-403-permission-denied-on-new-org-new-account-restriction/347093" target="_blank" rel="noopener">Détails</a>. Le Rank Checker n'est pas utilisable sur votre compte. Alternatives : <a href="https://serpapi.com" target="_blank" rel="noopener">SerpAPI</a>, <a href="https://www.scaleserp.com" target="_blank" rel="noopener">ScaleSerp</a>, ou consultation manuelle via le Constructeur SERP.</div>`;
progress.textContent = '';
return;
}
if (result.status === 429 || (result.error && /quota|rate|limit/i.test(result.error))) {
quotaHit = true;
progress.textContent += ' · QUOTA ATTEINT';
break;
}
}
lastRankResults = results;
// Render
const positioned = results.filter(r => r.position !== null);
const avgPosition = positioned.length > 0 ? (positioned.reduce((s, r) => s + r.position, 0) / positioned.length).toFixed(1) : '—';
const top3 = positioned.filter(r => r.position <= 3).length;
let html = '';
if (quotaHit) {
html += `<div class="alert alert-warning small"><i class="bi bi-key me-1"></i><strong>Quota Custom Search atteint.</strong> Le free tier est de 100 requêtes/jour. Réessayez demain ou activez la facturation Google Cloud pour un quota plus élevé.</div>`;
}
html += `<div class="row g-2 mb-3">
<div class="col-md-3"><div class="card"><div class="card-body p-2 text-center"><div class="text-muted small">Positionnés</div><div class="fs-4 fw-bold">${inTop10}/${results.length}</div></div></div></div>
<div class="col-md-3"><div class="card"><div class="card-body p-2 text-center"><div class="text-muted small">Top 3</div><div class="fs-4 fw-bold">${top3}</div></div></div></div>
<div class="col-md-3"><div class="card"><div class="card-body p-2 text-center"><div class="text-muted small">Position moyenne</div><div class="fs-4 fw-bold">${avgPosition}</div></div></div></div>
<div class="col-md-3"><div class="card"><div class="card-body p-2 text-center"><div class="text-muted small">Couverture</div><div class="fs-4 fw-bold">${Math.round(inTop10/results.length*100)}%</div></div></div></div>
</div>`;
html += '<table class="table table-sm small"><thead><tr><th>Mot-clé</th><th style="width:90px">Position</th><th>URL trouvée</th></tr></thead><tbody>';
for (const r of results) {
if (r.error) {
html += `<tr><td>${escapeHtml(r.keyword)}</td><td colspan="2" class="text-danger small">${escapeHtml(r.error)}</td></tr>`;
} else if (r.position !== null) {
const stat = r.position <= 3 ? 'ok' : r.position <= 10 ? 'warn' : 'bad';
html += `<tr><td>${escapeHtml(r.keyword)}</td><td>${statusBadge(stat, '#' + r.position)}</td><td class="small text-muted">${escapeHtml(r.url || '')}</td></tr>`;
} else {
html += `<tr><td>${escapeHtml(r.keyword)}</td><td>${statusBadge('manual', 'Hors top 10')}</td><td class="small text-muted">—</td></tr>`;
}
}
html += '</tbody></table>';
out.innerHTML = html;
}
function importHarvestedKeywords() {
if (!lastKeywords || lastKeywords.length === 0) {
showToast('Lancez d\'abord le Keyword Harvest');
return;
}
document.getElementById('rank-keywords').value = lastKeywords.join('\n');
showToast(`${lastKeywords.length} keywords importés`);
}
function exportRanks() {
if (!lastRankResults.length) { showToast('Aucun résultat à exporter'); return; }
const csv = ['keyword,position,url', ...lastRankResults.map(r => `"${r.keyword.replace(/"/g,'""')}",${r.position || ''},"${(r.url || '').replace(/"/g,'""')}"`)].join('\n');
const blob = new Blob([csv],{type:'text/csv;charset=utf-8'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'ranks.csv'; a.click();
URL.revokeObjectURL(url);
}
// DECODER
function decodeRaw(bytes, depth = 0) {
if (depth > 10) return [{error:'Max recursion'}];
const result = []; let pos = 0;
function readVarint() {
let v = 0n, s = 0n;
while (true) {
if (pos >= bytes.length) throw new Error('EOF in varint');
const b = bytes[pos++]; v |= BigInt(b & 0x7F) << s;
if ((b & 0x80) === 0) return v; s += 7n; if (s > 70n) throw new Error('Varint too long');
}
}
while (pos < bytes.length) {
try {
const tag = readVarint(); const fn = Number(tag >> 3n); const wt = Number(tag & 7n);
if (wt === 0) result.push({field:fn, type:'varint', value:readVarint().toString()});
else if (wt === 1) { if(pos+8>bytes.length)throw new Error('EOF'); result.push({field:fn,type:'fixed64',hex:bytesToHex(bytes.slice(pos,pos+8))}); pos+=8; }
else if (wt === 2) {
const len = Number(readVarint()); if (pos+len > bytes.length || len < 0) throw new Error('EOF length-delimited');
const data = bytes.slice(pos, pos+len); pos += len;
const asString = tryString(data); let asSubmessage = null;
if (data.length > 0) { try { const sub = decodeRaw(data, depth+1); if (sub.length > 0 && !sub.some(x => x.error)) asSubmessage = sub; } catch(e) {} }
result.push({field:fn, type:'lendelim', length:len, string:asString, submessage:asSubmessage, hex:bytesToHex(data)});
}
else if (wt === 5) { if(pos+4>bytes.length)throw new Error('EOF'); result.push({field:fn,type:'fixed32',hex:bytesToHex(bytes.slice(pos,pos+4))}); pos+=4; }
else throw new Error('Unknown wire type ' + wt);
} catch (e) { result.push({error:e.message}); break; }
}
return result;
}
function bytesToHex(bytes) { return Array.from(bytes).map(b => b.toString(16).padStart(2,'0').toUpperCase()).join(' '); }
function tryString(bytes) {
if (bytes.length === 0) return null;
try {
const s = new TextDecoder('utf-8',{fatal:true}).decode(new Uint8Array(bytes));
let p = 0; for (let i=0; i<s.length; i++) { const c=s.charCodeAt(i); if ((c>=32 && c<127) || c>=160) p++; }
if (s.length > 0 && p/s.length >= 0.85) return s;
} catch (e) {}
return null;
}
function looksMostlyText(s) { if (!s||s.length===0) return false; let t=0; for(let i=0;i<s.length;i++){const c=s.charCodeAt(i); if((c>=0x20&&c<0x7F)||c>=0xA0)t++;} return t/s.length>0.95; }
function renderDecoded(items, indent = 0) {
const pad = ' '.repeat(indent); let html = '';
for (const item of items) {
if (item.error) { html += `${pad}<span class="err">[error: ${escapeHtml(item.error)}]</span>\n`; continue; }
if (item.type === 'varint') html += `${pad}<span class="field-num">${item.field}</span>: <span class="num">${escapeHtml(item.value)}</span> <span class="kind">(varint)</span>\n`;
else if (item.type==='fixed32'||item.type==='fixed64') html += `${pad}<span class="field-num">${item.field}</span>: <span class="hex">${escapeHtml(item.hex)}</span> <span class="kind">(${item.type})</span>\n`;
else if (item.type === 'lendelim') {
const hasString = item.string !== null; const hasSub = item.submessage !== null;
const preferSub = hasSub && (!hasString || (item.submessage.length >= 1 && !looksMostlyText(item.string)));
if (preferSub && hasSub) {
html += `${pad}<span class="field-num">${item.field}</span>: <span class="kind">{</span>\n${renderDecoded(item.submessage, indent+1)}${pad}<span class="kind">}</span>\n`;
} else if (hasString) html += `${pad}<span class="field-num">${item.field}</span>: <span class="str">"${escapeHtml(item.string)}"</span>\n`;
else html += `${pad}<span class="field-num">${item.field}</span>: <span class="hex">${escapeHtml(item.hex)}</span> <span class="kind">(${item.length} bytes)</span>\n`;
}
}
return html;
}
function fixBase64Padding(s) { s=s.replace(/-/g,'+').replace(/_/g,'/'); while(s.length%4) s+='='; return s; }
function tryDecodeBase64(s) { try { const f=fixBase64Padding(s); const bin=atob(f); const b=new Uint8Array(bin.length); for(let i=0;i<bin.length;i++) b[i]=bin.charCodeAt(i); return b; } catch (e) { return null; } }
function extractBlobsFromUrl(input) {
const candidates = []; let url; try { url = new URL(input); } catch(e) { return []; }
url.pathname.split('/').filter(Boolean).forEach(seg => { if (seg.length>=8 && /^[A-Za-z0-9_\-+/=]+$/.test(seg)) candidates.push({source:'path',label:seg.slice(0,30)+(seg.length>30?'…':''),value:seg}); });
['data','ved','ei','si','uule','kgmid'].forEach(k => { const v = url.searchParams.get(k); if (v && v.length>=4 && /^[A-Za-z0-9_\-+/=:%,]+$/.test(v)) candidates.push({source:'query',label:`?${k}=${v.slice(0,25)}${v.length>25?'…':''}`,value:v}); });
return candidates;
}
function decodeBlob() {
const input = document.getElementById('decode-input').value.trim();
const out = document.getElementById('decode-output'); if (!input) { out.innerHTML = ''; return; }
let blobs = [];
if (input.startsWith('http')) {
blobs = extractBlobsFromUrl(input);
if (!blobs.length) { out.innerHTML = `<div class="alert alert-warning"><strong>Aucun blob détecté.</strong></div>`; return; }
} else blobs = [{source:'raw',label:'Input direct',value:input}];
let html = '';
for (const blob of blobs) {
const bytes = tryDecodeBase64(blob.value);
if (!bytes) { html += `<div class="text-muted small mt-3 mb-1">${escapeHtml(blob.source)} · ${escapeHtml(blob.label)}</div><div class="alert alert-danger small">Impossible de décoder en base64</div>`; continue; }
let decoded; try { decoded = decodeRaw(Array.from(bytes)); }
catch (e) { html += `<div class="text-muted small mt-3 mb-1">${escapeHtml(blob.source)} · ${escapeHtml(blob.label)}</div><div class="alert alert-danger small">Erreur protobuf : ${escapeHtml(e.message)}</div>`; continue; }
const errs = decoded.some(x => x.error);
html += `<div class="text-muted small mt-3 mb-1">${escapeHtml(blob.source)} · ${escapeHtml(blob.label)} · ${bytes.length} octets${errs?' <span class="text-danger">(parsing partiel)</span>':''}</div><div class="decoded-tree">${renderDecoded(decoded)}</div>`;
if (errs) html += `<div class="alert alert-warning small mt-2"><strong>Parsing incomplet.</strong></div><div class="bytes-display">${bytesToHex(Array.from(bytes))}</div>`;
}
out.innerHTML = html;
}
// INSPECTION
function inspectBytes() {
const d = cleanDomain(document.getElementById('inspect-domain').value); if (!d) return;
const {payload, inner, bytes} = buildPayload(d);
const hex = b => b.toString(16).padStart(2,'0').toUpperCase();
const ascii = b => (b>=32 && b<127) ? String.fromCharCode(b) : '·';
let html = '';
html += `<span class="ann">Payload complet (${payload.length} octets) :</span>\n${payload.map(hex).join(' ')}\n`;
html += `<span style="opacity:0.6">${payload.map(ascii).join(' ')}</span>\n\n<span class="ann">Décomposition :</span>\n`;
html += `<strong>${hex(payload[0])}</strong> → tag : champ 2, sous-message\n<strong>${encodeVarint(inner.length).map(hex).join(' ')}</strong> → longueur sous-message (${inner.length})\n`;
html += `<strong>${hex(inner[0])}</strong> → tag : champ 1, string\n<strong>${encodeVarint(bytes.length).map(hex).join(' ')}</strong> → longueur string (${bytes.length})\n`;
html += `<strong>${Array.from(bytes).map(hex).join(' ')}</strong> → "${escapeHtml(d)}" en UTF-8\n\n<span class="ann">Base64 final :</span>\n${toBase64(payload)}`;
document.getElementById('bytes-display').innerHTML = html;
document.getElementById('result-inspect').classList.remove('d-none');
}
// BATCH
let lastBatchData = [];
async function generateBatch() {
const raw = document.getElementById('domains-batch').value;
const domains = raw.split('\n').map(cleanDomain).filter(Boolean); if (!domains.length) return;
const checkRichness = document.getElementById('batch-check-richness')?.checked;
const checkDns = document.getElementById('batch-check-dns')?.checked;
lastBatchData = domains.slice(0,1000).map(d => ({ ...buildProfileUrl(d), richness: null, dns: null })).filter(r => r.domain);
// Affichage initial
renderBatchResults();
// 1. Pré-check DNS si demandé (séquentiel pour pas spammer DoH)
if (checkDns) {
await runBatchDnsCheck();
}
// 2. Vérification richesse (uniquement sur domaines qui existent)
if (checkRichness) {
await runBatchRichness();
}
}
async function runBatchDnsCheck() {
const progress = document.getElementById('batch-progress');
for (let i = 0; i < lastBatchData.length; i++) {
const r = lastBatchData[i];
if (progress) progress.textContent = `Vérification DNS : ${i+1}/${lastBatchData.length}`;
r.dns = await checkDomainExists(r.domain);
// Mise à jour live de la ligne
renderBatchResults();
await new Promise(resolve => setTimeout(resolve, 50));
}
if (progress) progress.textContent = `DNS vérifié : ${lastBatchData.filter(r=>r.dns?.exists).length}/${lastBatchData.length} existants`;
}
function renderBatchResults() {
const checkRichness = document.getElementById('batch-check-richness')?.checked;
const checkDns = document.getElementById('batch-check-dns')?.checked;
const html = lastBatchData.map((r, i) => {
let dnsHtml = '';
if (checkDns) {
if (r.dns === null) {
dnsHtml = `<span class="skel skel-badge" id="batch-dns-${i}"></span>`;
} else if (r.dns.exists === true) {
dnsHtml = `<span class="status-badge ok" title="Domaine existe et résout (A record)">DNS OK</span>`;
} else if (r.dns.exists === false) {
const label = r.dns.status === 'nxdomain' ? 'NXDOMAIN' : 'Pas d\'IP';
dnsHtml = `<span class="status-badge bad" title="Domaine inexistant ou parqué">${label}</span>`;
} else {
dnsHtml = `<span class="status-badge manual" title="${escapeHtml(r.dns.error || '')}">DNS ?</span>`;
}
}
let richnessHtml = '';
if (checkRichness) {
if (r.dns?.exists === false) {
// Pas la peine d'analyser la fiche si le domaine n'existe pas
richnessHtml = `<span class="status-badge manual" title="Pas vérifié (domaine inexistant)">—</span>`;
} else if (r.richness === null) {
richnessHtml = `<span class="skel skel-badge" id="batch-rich-${i}"></span>`;
} else {
const badgeMap = { excellent:'ok', good:'ok', skeleton:'warn', sparse:'warn', ghost:'bad', error:'manual' };
const scoreText = r.richness.score !== null ? ` ${r.richness.score}` : '';
const tooltip = r.richness.details && r.richness.details.length ? r.richness.details.join(' · ') : '';
richnessHtml = `<span class="status-badge ${badgeMap[r.richness.level] || 'manual'}" id="batch-rich-${i}" data-bs-toggle="tooltip" data-bs-title="${escapeHtml(tooltip)}">${escapeHtml(r.richness.label)}${scoreText}</span>`;
}
}
const badges = [dnsHtml, richnessHtml].filter(Boolean).join(' ');
return `<div class="batch-row d-flex flex-wrap gap-2 align-items-center"><div class="domain flex-grow-1">${escapeHtml(r.domain)}${badges ? ' ' + badges : ''}</div><div class="url small flex-grow-1"><a href="${escapeHtml(r.url)}" target="_blank" rel="noopener">${escapeHtml(r.url)}</a></div></div>`;
}).join('');
document.getElementById('batch-results').innerHTML = html;
initTooltips();
}
async function runBatchRichness() {
const progress = document.getElementById('batch-progress');
// Ne vérifier que les domaines qui existent (si DNS check fait), sinon tous
const toCheck = lastBatchData.filter(r => r.dns === null || r.dns.exists === true);
let done = 0;
for (let i = 0; i < lastBatchData.length; i++) {
const r = lastBatchData[i];
// Skip les domaines inexistants
if (r.dns?.exists === false) continue;
done++;
if (progress) progress.textContent = `Vérification fiches : ${done}/${toCheck.length}`;
r.richness = await checkProfileRichness(r.url);
const el = document.getElementById(`batch-rich-${i}`);
if (el) {
const badgeMap = { excellent:'ok', good:'ok', skeleton:'warn', sparse:'warn', ghost:'bad', error:'manual' };
const scoreText = r.richness.score !== null ? ` ${r.richness.score}` : '';
const tooltip = r.richness.details && r.richness.details.length ? r.richness.details.join(' · ') : '';
el.outerHTML = `<span class="status-badge ${badgeMap[r.richness.level] || 'manual'}" id="batch-rich-${i}" data-bs-toggle="tooltip" data-bs-title="${escapeHtml(tooltip)}">${escapeHtml(r.richness.label)}${scoreText}</span>`;
}
await new Promise(resolve => setTimeout(resolve, 200));
}
if (progress) progress.textContent = `Terminé : ${done} fiches analysées${lastBatchData.filter(r=>r.dns?.exists===false).length ? ` · ${lastBatchData.filter(r=>r.dns?.exists===false).length} domaines inexistants ignorés` : ''}`;
initTooltips();
}
function exportBatchCSV() {
if (!lastBatchData.length) { showToast('Rien à exporter'); return; }
const hasRichness = lastBatchData.some(r => r.richness !== null);
const hasDns = lastBatchData.some(r => r.dns !== null);
const headers = ['domain', 'url'];
if (hasDns) headers.push('dns_status');
if (hasRichness) headers.push('fiche_niveau', 'fiche_score', 'fiche_reseaux', 'fiche_posts');
const rows = lastBatchData.map(r => {
const parts = [`"${r.domain.replace(/"/g,'""')}"`, `"${r.url.replace(/"/g,'""')}"`];
if (hasDns) parts.push(`"${r.dns?.exists === true ? 'existe' : (r.dns?.exists === false ? r.dns.status : 'inconnu')}"`);
if (hasRichness) {
parts.push(`"${r.richness?.label || 'N/A'}"`);
parts.push(`"${r.richness?.score ?? ''}"`);
parts.push(`"${r.richness?.signals?.socialNetworks?.join('|') || ''}"`);
parts.push(`"${r.richness?.signals?.postsCount ?? ''}"`);
}
return parts.join(',');
});
const csv = [headers.join(','), ...rows].join('\n');
const blob = new Blob([csv],{type:'text/csv;charset=utf-8'}); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'profile-urls.csv'; a.click();
URL.revokeObjectURL(url);
}
// BOOKMARKLET
function buildBookmarklet() {
const baseUrl = window.location.origin + window.location.pathname;
const code = `javascript:(function(){var d=window.location.hostname;window.open('${baseUrl}?domain='+encodeURIComponent(d),'_blank');})();`;
document.getElementById('bookmarklet-link').href = code;
}
buildBookmarklet();
// ============== EDITORIAL ANALYSIS ==============
// --- Helpers article-level ---
function detectClutter(doc, html) {
const adPatterns = /class\s*=\s*["'][^"']*(adsense|adsbygoogle|google_ads|advertisement|sponsored|interstitial|popup|modal-overlay|cookie-banner|gdpr-banner|cookieconsent|fixed-bar|sticky-banner|sticky-bottom|paywall)[^"']*["']/gi;
const adMatches = (html.match(adPatterns) || []).length;
const iframes = doc.querySelectorAll('iframe').length;
const fixedMatches = (html.match(/(?:position\s*:\s*fixed|position\s*:\s*sticky)/gi) || []).length;
let score = 0;
const warnings = [];
if (adMatches >= 3) { score += 2; warnings.push(`${adMatches} éléments ad/popup détectés`); }
else if (adMatches >= 1) { score += 1; }
if (iframes >= 5) { score += 2; warnings.push(`${iframes} iframes`); }
else if (iframes >= 2) { score += 1; }
if (fixedMatches >= 5) { score += 1; warnings.push(`${fixedMatches} éléments fixed/sticky`); }
return { score, level: score >= 4 ? 'high' : score >= 2 ? 'medium' : 'low', warnings, iframes, fixedMatches, adMatches };
}
function checkDateCoherence(doc, url, articleSchema) {
const dates = {};
const urlMatch = url.match(/\/(\d{4})[\/-](\d{1,2})(?:[\/-](\d{1,2}))?/);
if (urlMatch) {
const [, y, m, d] = urlMatch;
dates.url = `${y}-${m.padStart(2,'0')}-${(d||'01').padStart(2,'0')}`;
}
const metaPub = doc.querySelector('meta[property="article:published_time"]')?.getAttribute('content');
if (metaPub) dates.metaPublished = metaPub.substring(0,10);
const metaMod = doc.querySelector('meta[property="article:modified_time"]')?.getAttribute('content');
if (metaMod) dates.metaModified = metaMod.substring(0,10);
if (articleSchema?.datePublished) dates.schemaPublished = String(articleSchema.datePublished).substring(0,10);
if (articleSchema?.dateModified) dates.schemaModified = String(articleSchema.dateModified).substring(0,10);
const timeTags = [...doc.querySelectorAll('time[datetime]')];
if (timeTags.length > 0) {
dates.timeTags = timeTags.map(t => t.getAttribute('datetime')?.substring(0,10)).filter(Boolean);
}
const publishedSources = [dates.url, dates.metaPublished, dates.schemaPublished].filter(Boolean);
const uniquePub = [...new Set(publishedSources)];
return {
dates, sourcesCount: publishedSources.length,
coherent: uniquePub.length <= 1,
discrepancies: uniquePub.length > 1 ? uniquePub : null
};
}
function computeContentEffort(article) {
let score = 0;
const factors = [];
if (article.wordCount >= 1500) { score += 30; factors.push('1500+ mots'); }
else if (article.wordCount >= 800) { score += 20; factors.push('800+ mots'); }
else if (article.wordCount >= 300) { score += 10; factors.push('300+ mots'); }
if (article.h2Count >= 5) { score += 20; factors.push(`${article.h2Count} H2`); }
else if (article.h2Count >= 3) { score += 10; }
else if (article.h2Count >= 1) { score += 5; }
if (article.imageCount >= 5) { score += 20; factors.push(`${article.imageCount} images`); }
else if (article.imageCount >= 2) { score += 10; }
else if (article.imageCount >= 1) { score += 5; }
if (article.externalLinks >= 5) { score += 15; factors.push(`${article.externalLinks} sources externes`); }
else if (article.externalLinks >= 2) { score += 10; }
const rich = (article.listCount||0) + (article.tableCount||0) + (article.blockquoteCount||0);
if (rich >= 3) { score += 15; factors.push(`${rich} éléments riches`); }
else if (rich >= 1) { score += 7; }
return { score, factors };
}
// --- Helpers site-level (cross-articles) ---
const STOP_WORDS_FR_EN = new Set(['le','la','les','un','une','des','de','du','et','est','en','au','aux','avec','sur','par','pour','dans','que','qui','ce','ces','cette','son','sa','ses','votre','vos','nos','notre','plus','moins','très','tout','tous','toute','toutes','être','avoir','faire','pas','ou','ni','si','sans','sous','vers','entre','contre','peut','peuvent','sont','aussi','où','mais','donc','car','quand','comme','même','déjà','encore','ainsi','alors','après','avant','the','and','for','with','this','that','from','have','what','when','which','about','their','there','your','you','will','can','our','was','are','one','more','than','also','some','only','who','his','her','its','has','been','being','were','they','them','these','those','such','into','out','off','over','under']);
function computeSiteFocus(results) {
const ok = results.filter(r => r.ok);
if (ok.length < 2) return null;
const tokens = [];
for (const r of ok) {
const text = `${r.title || ''} ${r.metaDesc || ''} ${r.h1Text || ''}`.toLowerCase();
const words = text.match(/[a-zàâäéèêëïîôöùûüçœæ']{4,}/gi) || [];
for (const w of words) {
const cleaned = w.toLowerCase();
if (!STOP_WORDS_FR_EN.has(cleaned)) tokens.push(cleaned);
}
}
if (tokens.length === 0) return null;
const freq = {};
tokens.forEach(t => { freq[t] = (freq[t] || 0) + 1; });
const sorted = Object.entries(freq).sort((a,b) => b[1] - a[1]);
const topN = sorted.slice(0, 10);
const top10Sum = topN.reduce((s, [_, c]) => s + c, 0);
const focusRatio = top10Sum / tokens.length;
let score;
if (focusRatio >= 0.5) score = 100;
else if (focusRatio >= 0.3) score = 50 + (focusRatio - 0.3) * 250;
else score = focusRatio * 250;
score = Math.min(100, Math.max(0, Math.round(score)));
return { score, topTerms: topN, focusRatio: Math.round(focusRatio * 100) };
}
function stringSimilarity(a, b) {
if (!a || !b) return 0;
const sa = a.toLowerCase().replace(/\s+/g,' ').trim();
const sb = b.toLowerCase().replace(/\s+/g,' ').trim();
if (sa === sb) return 1;
if (sa.length < 4 || sb.length < 4) return 0;
const bigrams = (s) => { const bi = new Set(); for (let i = 0; i < s.length - 1; i++) bi.add(s.substring(i, i+2)); return bi; };
const ba = bigrams(sa);
const bb = bigrams(sb);
let common = 0;
for (const x of ba) if (bb.has(x)) common++;
return (2 * common) / (ba.size + bb.size);
}
function detectInternalDuplicates(results) {
const ok = results.filter(r => r.ok);
if (ok.length < 2) return { issues: [], count: 0 };
const issues = [];
for (let i = 0; i < ok.length; i++) {
for (let j = i+1; j < ok.length; j++) {
const a = ok[i], b = ok[j];
const titleSim = stringSimilarity(a.title || '', b.title || '');
if (titleSim > 0.7 && a.title && b.title) {
issues.push({type:'title', sim: Math.round(titleSim*100), a: a.url, b: b.url, sampleA: a.title, sampleB: b.title});
}
const descSim = stringSimilarity(a.metaDesc || '', b.metaDesc || '');
if (descSim > 0.85 && a.metaDesc && b.metaDesc && a.metaDesc.length > 30) {
issues.push({type:'description', sim: Math.round(descSim*100), a: a.url, b: b.url});
}
}
}
return { issues, count: issues.length };
}
// --- Source d'articles ---
// Retourne toutes les sources trouvées dans l'ordre de préférence
async function findArticleSources(domain) {
const sources = [];
// 1. News-sitemaps classiques (priorité haute pour la fraîcheur)
const newsCandidates = ['sitemap-news.xml','news-sitemap.xml','news.xml','post-sitemap.xml','sitemap_news.xml','sitemap_index.xml','sitemap-posts.xml'];
for (const path of newsCandidates) {
for (const d of getDomainVariants(domain)) {
try {
const r = await fetch(buildFetchUrl(`https://${d}/${path}`), {mode:'cors'});
if (r.ok) {
const text = await r.text();
if (text.includes('<url') || text.includes('<sitemap')) {
sources.push({ type:'sitemap', url:`https://${d}/${path}`, content:text });
}
}
} catch(e) {}
}
}
// 2. Sitemap principal : chercher des sous-sitemaps qui ressemblent à des articles
for (const d of getDomainVariants(domain)) {
try {
const r = await fetch(buildFetchUrl(`https://${d}/sitemap.xml`), {mode:'cors'});
if (!r.ok) continue;
const text = await r.text();
if (/<sitemapindex/i.test(text)) {
// Index : récupérer plusieurs sous-sitemaps susceptibles d'avoir des articles
const subUrls = [...text.matchAll(/<loc>([^<]+)<\/loc>/gi)].map(m => m[1].trim());
// Priorité : ceux qui matchent news/post/article/actualit
const priority = subUrls.filter(u => /news|post|article|actualit|blog/i.test(u));
const fallback = subUrls.filter(u => !priority.includes(u)).slice(0, 5);
const toFetch = [...priority, ...fallback].slice(0, 8);
for (const u of toFetch) {
try {
const sub = await fetch(buildFetchUrl(u), {mode:'cors'});
if (sub.ok) {
const subText = await sub.text();
if (/<url[\s>]/i.test(subText)) sources.push({ type:'sitemap', url:u, content:subText });
}
} catch(e) {}
}
} else if (/<url[\s>]/i.test(text)) {
sources.push({ type:'sitemap', url:`https://${d}/sitemap.xml`, content:text });
}
} catch(e) {}
if (sources.length > 0) break; // une variante apex ou www suffit pour le sitemap principal
}
// 3. Feeds RSS/Atom
const feedCandidates = ['feed','rss','feed.xml','rss.xml','atom.xml','feed/','rss/','blog/feed','actualites/feed','news/feed','?feed=rss2','?feed=atom','feed/atom','feed/rdf/'];
for (const d of getDomainVariants(domain)) {
for (const path of feedCandidates) {
try {
const r = await fetch(buildFetchUrl(`https://${d}/${path}`), {mode:'cors'});
if (r.ok) {
const text = await r.text();
if (/<rss|<feed|<channel/i.test(text)) {
sources.push({ type:'feed', url:`https://${d}/${path}`, content:text });
}
}
} catch(e) {}
}
if (sources.some(s => s.type === 'feed')) break;
}
return sources;
}
// Extraction des articles depuis sitemap ou feed : retourne {url, date} sans limite
function extractArticleEntries(source) {
const entries = [];
if (source.type === 'sitemap') {
const matches = [...source.content.matchAll(/<url>([\s\S]*?)<\/url>/gi)];
for (const m of matches) {
const loc = m[1].match(/<loc>([^<]+)<\/loc>/i);
// Priorité : news:publication_date > lastmod
const newsDate = m[1].match(/<news:publication_date>([^<]+)<\/news:publication_date>/i);
const lastmod = m[1].match(/<lastmod>([^<]+)<\/lastmod>/i);
const dateStr = (newsDate || lastmod)?.[1]?.trim();
if (loc) entries.push({ url: loc[1].trim(), date: dateStr || null, sourceType: 'sitemap' });
}
} else if (source.type === 'feed') {
// RSS : <item><link>X</link><pubDate>Y</pubDate></item>
const rssItems = [...source.content.matchAll(/<item>([\s\S]*?)<\/item>/gi)];
for (const m of rssItems) {
const link = m[1].match(/<link[^>]*>([^<]+)<\/link>/i);
// pubDate (RSS standard) — converti depuis RFC 822 en ISO
const pubDate = m[1].match(/<pubDate>([^<]+)<\/pubDate>/i);
const dcDate = m[1].match(/<dc:date>([^<]+)<\/dc:date>/i);
let dateStr = null;
if (pubDate?.[1]) {
try { dateStr = new Date(pubDate[1].trim()).toISOString(); } catch(e) { dateStr = pubDate[1].trim(); }
} else if (dcDate?.[1]) {
dateStr = dcDate[1].trim();
}
if (link) entries.push({ url: link[1].trim(), date: dateStr, sourceType: 'feed' });
}
// Atom : <entry><link href="X"/><published>Y</published></entry>
if (entries.length === 0) {
const atomEntries = [...source.content.matchAll(/<entry>([\s\S]*?)<\/entry>/gi)];
for (const m of atomEntries) {
const link = m[1].match(/<link[^>]*href=["']([^"']+)["']/i);
const published = m[1].match(/<published>([^<]+)<\/published>/i);
const updated = m[1].match(/<updated>([^<]+)<\/updated>/i);
const dateStr = (published || updated)?.[1]?.trim();
if (link) entries.push({ url: link[1].trim(), date: dateStr || null, sourceType: 'feed' });
}
}
}
return entries;
}
// Compat ancienne signature (utilisée nulle part désormais mais on garde au cas où)
function extractArticleUrls(source, limit = 5) {
return extractArticleEntries(source).slice(0, limit).map(e => e.url);
}
async function checkHeroImage(imageUrl, declaredWidth, declaredHeight) {
const result = {
url: imageUrl,
declaredWidth: declaredWidth ? parseInt(declaredWidth) : null,
declaredHeight: declaredHeight ? parseInt(declaredHeight) : null,
actualWidth: null,
actualHeight: null,
sizeBytes: null,
method: null,
error: null
};
// Fast path : déclaration présente et plausible (≥ 200px, format raisonnable)
if (result.declaredWidth && result.declaredWidth >= 200) {
result.method = 'declared';
return result;
}
// Fallback : fetch HEAD pour récupérer Content-Length, puis Range request pour les bytes header
try {
// 1. HEAD pour la taille (rapide)
const headResp = await fetch(buildFetchUrl(imageUrl), { method: 'HEAD', mode: 'cors' });
if (headResp.ok) {
const len = headResp.headers.get('content-length');
if (len) result.sizeBytes = parseInt(len);
}
// 2. Range request pour les premiers 64KB (suffit pour headers PNG/JPEG/WebP/GIF)
const rangeResp = await fetch(buildFetchUrl(imageUrl), {
method: 'GET',
headers: { 'Range': 'bytes=0-65535' },
mode: 'cors'
});
if (!rangeResp.ok && rangeResp.status !== 206) {
result.error = `HTTP ${rangeResp.status}`;
result.method = 'fetch-failed';
return result;
}
const buf = await rangeResp.arrayBuffer();
const dims = parseImageDimensions(new Uint8Array(buf));
if (dims) {
result.actualWidth = dims.width;
result.actualHeight = dims.height;
result.method = 'measured';
} else {
result.method = 'measure-failed';
}
// Si on n'avait pas le size via HEAD et que la requête Range ramène toute l'image
if (!result.sizeBytes && rangeResp.status === 200) {
result.sizeBytes = buf.byteLength;
}
} catch (e) {
result.error = e.message;
result.method = 'fetch-error';
}
return result;
}
// Parse les dimensions PNG / JPEG / WebP / GIF depuis les premiers bytes
function parseImageDimensions(bytes) {
if (bytes.length < 16) return null;
// PNG : 89 50 4E 47 0D 0A 1A 0A puis IHDR à offset 16
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) {
if (bytes.length < 24) return null;
const w = (bytes[16] << 24) | (bytes[17] << 16) | (bytes[18] << 8) | bytes[19];
const h = (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23];
return { width: w, height: h };
}
// JPEG : FF D8, parcourir les markers SOFn
if (bytes[0] === 0xFF && bytes[1] === 0xD8) {
let pos = 2;
while (pos < bytes.length - 8) {
if (bytes[pos] !== 0xFF) { pos++; continue; }
const marker = bytes[pos + 1];
// SOF markers : C0-C3, C5-C7, C9-CB, CD-CF
if ((marker >= 0xC0 && marker <= 0xC3) || (marker >= 0xC5 && marker <= 0xC7) ||
(marker >= 0xC9 && marker <= 0xCB) || (marker >= 0xCD && marker <= 0xCF)) {
if (pos + 9 >= bytes.length) return null;
const h = (bytes[pos + 5] << 8) | bytes[pos + 6];
const w = (bytes[pos + 7] << 8) | bytes[pos + 8];
return { width: w, height: h };
}
// Skip segment
const segLen = (bytes[pos + 2] << 8) | bytes[pos + 3];
pos += 2 + segLen;
}
return null;
}
// GIF : "GIF87a" ou "GIF89a"
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) {
const w = bytes[6] | (bytes[7] << 8);
const h = bytes[8] | (bytes[9] << 8);
return { width: w, height: h };
}
// WebP : "RIFF....WEBP"
if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) {
// Trois variantes : VP8 / VP8L / VP8X
if (bytes[12] === 0x56 && bytes[13] === 0x50 && bytes[14] === 0x38) {
// VP8 simple
if (bytes[15] === 0x20) {
const w = ((bytes[26] | (bytes[27] << 8)) & 0x3FFF);
const h = ((bytes[28] | (bytes[29] << 8)) & 0x3FFF);
return { width: w, height: h };
}
// VP8L lossless
if (bytes[15] === 0x4C) {
const b1 = bytes[21], b2 = bytes[22], b3 = bytes[23], b4 = bytes[24];
const w = 1 + (((b2 & 0x3F) << 8) | b1);
const h = 1 + (((b4 & 0x0F) << 10) | (b3 << 2) | ((b2 & 0xC0) >> 6));
return { width: w, height: h };
}
// VP8X extended
if (bytes[15] === 0x58) {
const w = 1 + (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16));
const h = 1 + (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16));
return { width: w, height: h };
}
}
}
// AVIF : ISO BMFF avec ftyp avif/avis
if (bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70) {
// Détection sommaire AVIF (parsing complet trop lourd ici)
// Cherche ispe box (item spatial extents)
for (let i = 0; i < bytes.length - 12; i++) {
if (bytes[i] === 0x69 && bytes[i+1] === 0x73 && bytes[i+2] === 0x70 && bytes[i+3] === 0x65) {
// ispe found, dimensions à offset +8
const w = (bytes[i+8] << 24) | (bytes[i+9] << 16) | (bytes[i+10] << 8) | bytes[i+11];
const h = (bytes[i+12] << 24) | (bytes[i+13] << 16) | (bytes[i+14] << 8) | bytes[i+15];
return { width: w, height: h };
}
}
}
return null;
}
// Analyse d'une page article
async function analyzeArticle(url) {
try {
const r = await fetch(buildFetchUrl(url), {mode:'cors'});
if (!r.ok) return { url, ok: false, error: `HTTP ${r.status}` };
const html = await r.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const title = doc.querySelector('title')?.textContent?.trim() || null;
const metaDesc = doc.querySelector('meta[name="description"]')?.getAttribute('content')?.trim() || null;
const metaRobots = doc.querySelector('meta[name="robots"]')?.getAttribute('content') || '';
const hasMaxImageLarge = /max-image-preview\s*:\s*large/i.test(metaRobots);
const canonical = doc.querySelector('link[rel="canonical"]')?.getAttribute('href') || null;
const ogTitle = doc.querySelector('meta[property="og:title"]')?.getAttribute('content') || null;
const ogImage = doc.querySelector('meta[property="og:image"]')?.getAttribute('content') || null;
const ogImageWidth = doc.querySelector('meta[property="og:image:width"]')?.getAttribute('content') || null;
const ogImageHeight = doc.querySelector('meta[property="og:image:height"]')?.getAttribute('content') || null;
const ogType = doc.querySelector('meta[property="og:type"]')?.getAttribute('content') || null;
const twitterCard = doc.querySelector('meta[name="twitter:card"]')?.getAttribute('content') || null;
const h1s = doc.querySelectorAll('h1');
const h2s = doc.querySelectorAll('h2');
const h1Text = doc.querySelector('h1')?.textContent?.trim() || null;
const jsonLdScripts = doc.querySelectorAll('script[type="application/ld+json"]');
let articleSchema = null;
let authorPerson = null;
let allSchemas = [];
for (const s of jsonLdScripts) {
try {
const data = JSON.parse(s.textContent);
const items = Array.isArray(data) ? data : (data['@graph'] || [data]);
for (const item of items) {
if (!item || typeof item !== 'object') continue;
const type = item['@type'];
const types = Array.isArray(type) ? type : [type].filter(Boolean);
types.forEach(t => allSchemas.push(t));
if (!articleSchema && types.some(t => /^(Article|NewsArticle|BlogPosting|TechArticle|ReportAge)/i.test(t))) {
articleSchema = item;
}
if (!authorPerson && types.includes('Person')) {
authorPerson = item;
}
}
} catch(e) {}
}
// If author was nested in articleSchema and is a Person object, use that
if (!authorPerson && articleSchema?.author && typeof articleSchema.author === 'object' && !Array.isArray(articleSchema.author)) {
const at = articleSchema.author['@type'];
if (at === 'Person' || (Array.isArray(at) && at.includes('Person'))) {
authorPerson = articleSchema.author;
}
}
const hasAuthorSameAs = authorPerson?.sameAs && (Array.isArray(authorPerson.sameAs) ? authorPerson.sameAs.length > 0 : !!authorPerson.sameAs);
const hasAuthorBio = !!(authorPerson?.description || authorPerson?.jobTitle);
const authorSocialLinks = Array.isArray(authorPerson?.sameAs) ? authorPerson.sameAs : (authorPerson?.sameAs ? [authorPerson.sameAs] : []);
// Identifier la zone article si possible
const articleEl = doc.querySelector('article') || doc.querySelector('[role="main"]') || doc.querySelector('main') || doc.body;
const articleClone = articleEl?.cloneNode(true);
if (articleClone) {
articleClone.querySelectorAll('script, style, nav, footer, header, aside, .comments, #comments, .related, .sidebar').forEach(el => el.remove());
}
const articleText = articleClone?.textContent?.replace(/\s+/g, ' ').trim() || '';
const wordCount = articleText.split(/\s+/).filter(w => w.length > 1).length;
const articleImgs = articleClone ? articleClone.querySelectorAll('img') : [];
const imageCount = articleImgs.length;
const listCount = articleClone ? articleClone.querySelectorAll('ul, ol').length : 0;
const tableCount = articleClone ? articleClone.querySelectorAll('table').length : 0;
const blockquoteCount = articleClone ? articleClone.querySelectorAll('blockquote').length : 0;
let articleHost = '';
try { articleHost = new URL(url).hostname.replace(/^www\./i, ''); } catch(e) {}
const allLinks = articleClone ? [...articleClone.querySelectorAll('a[href]')] : [];
const externalLinks = allLinks.filter(a => {
try {
const linkHost = new URL(a.getAttribute('href'), url).hostname.replace(/^www\./i, '');
return linkHost && linkHost !== articleHost;
} catch(e) { return false; }
}).length;
let maxImgWidth = 0;
for (const img of articleImgs) {
const w = parseInt(img.getAttribute('width') || '0');
if (w > maxImgWidth) maxImgWidth = w;
}
const hasOgImage = !!ogImage;
const clutter = detectClutter(doc, html);
const dateCoherence = checkDateCoherence(doc, url, articleSchema);
const contentEffort = computeContentEffort({
wordCount, imageCount, h2Count: h2s.length,
externalLinks, listCount, tableCount, blockquoteCount
});
// Hero image check (Discover-compatible)
let heroImage = null;
let heroImageUrl = ogImage;
let heroImageDeclaredW = ogImageWidth;
let heroImageDeclaredH = ogImageHeight;
// Fallback : prendre image du schema Article si og:image absent
if (!heroImageUrl && articleSchema?.image) {
const img = articleSchema.image;
if (typeof img === 'string') heroImageUrl = img;
else if (Array.isArray(img) && img.length > 0) {
const first = img[0];
if (typeof first === 'string') heroImageUrl = first;
else if (first?.url) { heroImageUrl = first.url; heroImageDeclaredW = first.width; heroImageDeclaredH = first.height; }
} else if (img?.url) {
heroImageUrl = img.url;
heroImageDeclaredW = heroImageDeclaredW || img.width;
heroImageDeclaredH = heroImageDeclaredH || img.height;
}
}
// Résoudre URL relative
if (heroImageUrl && !heroImageUrl.startsWith('http')) {
try { heroImageUrl = new URL(heroImageUrl, url).href; } catch(e) {}
}
if (heroImageUrl) {
heroImage = await checkHeroImage(heroImageUrl, heroImageDeclaredW, heroImageDeclaredH);
}
return {
url, ok: true,
title, metaDesc, canonical,
hasMaxImageLarge,
ogTitle, ogImage, ogType, twitterCard,
h1Count: h1s.length, h2Count: h2s.length, h1Text,
hasArticleSchema: !!articleSchema,
articleHeadline: articleSchema?.headline,
articleDatePublished: articleSchema?.datePublished,
articleDateModified: articleSchema?.dateModified,
articleAuthor: articleSchema?.author?.name || (typeof articleSchema?.author === 'string' ? articleSchema.author : (articleSchema?.author?.[0]?.name)),
articlePublisher: articleSchema?.publisher?.name,
articleImage: !!articleSchema?.image,
allSchemaTypes: [...new Set(allSchemas)],
wordCount, imageCount, listCount, tableCount, blockquoteCount, externalLinks,
maxImgWidth, hasOgImage,
hasAuthorSameAs, hasAuthorBio, authorSocialLinks,
clutter, dateCoherence, contentEffort, heroImage
};
} catch(e) {
return { url, ok: false, error: e.message };
}
}
// Toggle entre 5 récents et article précis
function setupEditorialTabs() {
const tabs = document.querySelectorAll('#editorial-mode-tabs button');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const mode = tab.dataset.mode;
document.getElementById('editorial-recents-pane').classList.toggle('d-none', mode !== 'recents');
document.getElementById('editorial-single-pane').classList.toggle('d-none', mode !== 'single');
// Vide la zone de résultats au changement
document.getElementById('editorial-output').innerHTML = '';
});
});
}
// Analyse d'un article unique fourni par l'utilisateur
async function runSingleArticleAnalysis(e) {
e.preventDefault();
const url = document.getElementById('single-article-url').value.trim();
if (!url) return;
if (!/^https?:\/\//i.test(url)) {
document.getElementById('editorial-output').innerHTML = '<div class="alert alert-warning small mb-0">L\'URL doit commencer par http:// ou https://</div>';
return;
}
// Si pas de currentDomain, on déduit depuis l'URL
if (!currentDomain) {
try { currentDomain = new URL(url).hostname.replace(/^www\./i, ''); } catch(e) {}
}
const btn = document.getElementById('btn-single-article');
const out = document.getElementById('editorial-output');
btn.disabled = true;
btn.innerHTML = '<span class="loader-spinner me-2"></span>Analyse…';
// Affiche un loader skeleton
out.innerHTML = `
<div class="editorial-progress">
<div class="editorial-progress-label">
<span><span class="loader-spinner me-2"></span>Récupération de la page et analyse des 22 critères…</span>
</div>
<div class="editorial-progress-bar"><div class="editorial-progress-fill" style="width:50%"></div></div>
</div>`;
const result = await analyzeArticle(url);
renderSingleArticleResult(result);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-clockwise me-1"></i> Relancer';
}
// Render dédié pour un seul article (pas de synthèse cohérence ni Site Focus)
function renderSingleArticleResult(r) {
const criteria = getEditorialCriteria();
const out = document.getElementById('editorial-output');
if (!r.ok) {
out.innerHTML = `<div class="alert alert-danger small mb-0"><i class="bi bi-x-circle me-1"></i><strong>Échec de l'analyse</strong> · ${escapeHtml(r.error || 'Erreur inconnue')}<br><span class="text-muted">Causes possibles : URL inaccessible, CORS bloqué, ou page qui n'est pas un article HTML.</span></div>`;
return;
}
const passed = criteria.filter(c => c.test(r)).length;
const stat = passed >= criteria.length * 0.8 ? 'ok' : passed >= criteria.length * 0.5 ? 'warn' : 'bad';
let html = `<div class="alert alert-light border small mb-3"><i class="bi bi-file-text me-1"></i><strong>Article analysé :</strong> <code>${escapeHtml(r.url)}</code></div>`;
// Score global de l'article
html += `<div class="card mb-3"><div class="card-body">`;
html += `<div class="d-flex justify-content-between align-items-center mb-3"><h6 class="mb-0"><i class="bi bi-clipboard-check me-1"></i>Qualité SEO de l'article</h6>${statusBadge(stat, `${passed}/${criteria.length} critères passés`)}</div>`;
if (r.title) html += `<div class="mb-1"><strong>Title :</strong> ${escapeHtml(r.title.slice(0, 200))}${r.title.length > 200 ? '…' : ''}</div>`;
if (r.metaDesc) html += `<div class="mb-1"><strong>Description :</strong> ${escapeHtml(r.metaDesc.slice(0, 200))}${r.metaDesc.length > 200 ? '…' : ''}</div>`;
// Content Effort
if (r.contentEffort) {
const ceStat = r.contentEffort.score >= 70 ? 'ok' : r.contentEffort.score >= 40 ? 'warn' : 'bad';
html += `<div class="mt-3 d-flex justify-content-between align-items-center"><strong>Content Effort</strong> ${statusBadge(ceStat, r.contentEffort.score + '/100')}</div>`;
if (r.contentEffort.factors.length) {
html += `<div class="text-muted small mt-1">${r.contentEffort.factors.map(escapeHtml).join(' · ')}</div>`;
}
}
// Clutter
if (r.clutter && r.clutter.level !== 'low') {
const cStat = r.clutter.level === 'high' ? 'bad' : 'warn';
html += `<div class="mt-2 d-flex justify-content-between align-items-center"><strong>Clutter (ads/popups)</strong> ${statusBadge(cStat, r.clutter.level)}</div>`;
if (r.clutter.warnings.length) {
html += `<div class="text-muted small mt-1">${r.clutter.warnings.map(escapeHtml).join(' · ')}</div>`;
}
}
// Hero image
if (r.heroImage) {
const h = r.heroImage;
const w = h.actualWidth || h.declaredWidth;
const heightVal = h.actualHeight || h.declaredHeight;
if (w) {
const widthStat = w >= 1200 ? 'ok' : w >= 800 ? 'warn' : 'bad';
const sourceLabel = h.method === 'measured' ? 'mesurée' : (h.method === 'declared' ? 'déclarée' : 'erreur');
html += `<div class="mt-2 d-flex justify-content-between align-items-center"><strong>Image hero</strong> ${statusBadge(widthStat, `${w}${heightVal ? '×' + heightVal : ''}px`)}</div>`;
html += `<div class="text-muted small mt-1">Source : ${sourceLabel}`;
if (h.sizeBytes !== null) {
const kb = Math.round(h.sizeBytes / 1024);
const sizeBadge = h.sizeBytes < 1024*1024 ? '' : ` <span class="text-warning">⚠ > 1MB</span>`;
html += ` · Poids : ${kb >= 1024 ? (kb/1024).toFixed(1) + ' MB' : kb + ' KB'}${sizeBadge}`;
}
if (w < 1200) html += ` · <span class="text-danger">Insuffisant pour Discover</span>`;
html += `</div>`;
} else if (h.error) {
html += `<div class="mt-2 small text-muted">Image hero : impossible de mesurer (${escapeHtml(h.error)})</div>`;
}
} else {
html += `<div class="mt-2 small text-muted">Image hero : aucune (og:image et schema absents)</div>`;
}
// Date coherence
if (r.dateCoherence && r.dateCoherence.sourcesCount >= 2) {
if (!r.dateCoherence.coherent) {
html += `<div class="mt-2"><strong class="text-danger">Dates incohérentes :</strong> <span class="mono small">${r.dateCoherence.discrepancies.map(escapeHtml).join(' ≠ ')}</span></div>`;
} else {
html += `<div class="mt-2 small text-muted">Dates cohérentes sur ${r.dateCoherence.sourcesCount} sources</div>`;
}
}
html += `</div></div>`;
// Grille des 22 critères
html += `<div class="card"><div class="card-body">`;
html += `<h6 class="mb-3"><i class="bi bi-list-check me-1"></i>Détail des 22 critères</h6>`;
html += `<div class="row g-2">`;
for (const c of criteria) {
const ok = c.test(r);
html += `<div class="col-md-6"><div class="d-flex justify-content-between" style="font-size:0.85rem;"><span class="text-muted">${escapeHtml(c.label)}</span> ${statusBadge(ok ? 'ok' : 'bad', ok ? '✓' : '✗')}</div></div>`;
}
html += `</div>`;
// Stats techniques
html += `<div class="mt-3 small text-muted">`;
html += `Mots : <strong>${r.wordCount}</strong> · H1 : <strong>${r.h1Count}</strong> · H2 : <strong>${r.h2Count}</strong> · Images : <strong>${r.imageCount}</strong> · Sources externes : <strong>${r.externalLinks}</strong>`;
if (r.allSchemaTypes.length) html += `<br>Schemas détectés : ${r.allSchemaTypes.map(escapeHtml).join(', ')}`;
if (r.articleAuthor) {
html += `<br>Auteur : <strong>${escapeHtml(r.articleAuthor)}</strong>`;
if (r.authorSocialLinks?.length) html += ` · sameAs : ${r.authorSocialLinks.length} lien(s)`;
}
if (r.articleDatePublished) html += ` · Publié : <strong>${escapeHtml(String(r.articleDatePublished).substring(0,10))}</strong>`;
if (r.articleDateModified) html += ` · Modifié : <strong>${escapeHtml(String(r.articleDateModified).substring(0,10))}</strong>`;
html += `</div></div></div>`;
out.innerHTML = html;
}
// Helper : exporte la liste de critères pour partage entre renderEditorialResults et renderSingleArticleResult
function getEditorialCriteria() {
return [
{ key:'hasArticleSchema', label:'Schema Article', test: r => r.hasArticleSchema },
{ key:'hasAuthor', label:'Auteur dans le schema', test: r => !!r.articleAuthor },
{ key:'hasAuthorSameAs', label:'sameAs auteur (E-E-A-T)', test: r => r.hasAuthorSameAs },
{ key:'hasPublisher', label:'Publisher dans le schema', test: r => !!r.articlePublisher },
{ key:'hasDatePub', label:'datePublished', test: r => !!r.articleDatePublished },
{ key:'hasDateMod', label:'dateModified', test: r => !!r.articleDateModified },
{ key:'datesCoherent', label:'Dates cohérentes (URL/meta/schema)', test: r => !r.dateCoherence?.discrepancies },
{ key:'hasArticleImage', label:'image dans schema', test: r => r.articleImage },
{ key:'hasMaxImageLarge', label:'max-image-preview:large', test: r => r.hasMaxImageLarge },
{ key:'hasOgImage', label:'og:image', test: r => r.hasOgImage },
{ key:'hasOgTitle', label:'og:title spécifique', test: r => !!r.ogTitle },
{ key:'singleH1', label:'H1 unique', test: r => r.h1Count === 1 },
{ key:'hasCanonical', label:'canonical', test: r => !!r.canonical },
{ key:'hasMetaDesc', label:'meta description', test: r => !!r.metaDesc },
{ key:'longContent', label:'≥ 300 mots', test: r => r.wordCount >= 300 },
{ key:'longerContent', label:'≥ 800 mots', test: r => r.wordCount >= 800 },
{ key:'hasImages', label:'≥ 2 images', test: r => r.imageCount >= 2 },
{ key:'hasSources', label:'≥ 2 sources externes', test: r => r.externalLinks >= 2 },
{ key:'goodEffort', label:'Content effort ≥ 60', test: r => (r.contentEffort?.score || 0) >= 60 },
{ key:'lowClutter', label:'Faible clutter (ads/popups)', test: r => r.clutter?.level === 'low' },
{ key:'heroImage1200', label:'Hero image ≥ 1200px (Discover)', test: r => {
const h = r.heroImage; if (!h) return false;
return (h.actualWidth || h.declaredWidth || 0) >= 1200;
}
},
{ key:'heroImageWeight', label:'Hero image < 1MB (perf)', test: r => {
const h = r.heroImage; if (!h || h.sizeBytes === null) return true;
return h.sizeBytes < 1024 * 1024;
}
},
];
}
async function runEditorialAnalysis() {
if (!currentDomain) return;
const out = document.getElementById('editorial-output');
const btn = document.getElementById('btn-editorial');
btn.disabled = true;
btn.innerHTML = '<span class="loader-spinner me-2"></span>Recherche d\'articles…';
// Étape 1 : découverte des sources
out.innerHTML = `
<div class="editorial-progress">
<div class="editorial-progress-label">
<span><span class="loader-spinner me-2"></span>Détection des sources d'articles…</span>
<strong>1/3</strong>
</div>
<div class="editorial-progress-bar"><div class="editorial-progress-fill" style="width:33%"></div></div>
</div>`;
const sources = await findArticleSources(currentDomain);
if (sources.length === 0) {
out.innerHTML = '<div class="alert alert-warning small mb-0"><i class="bi bi-info-circle me-1"></i><strong>Aucune source d\'articles trouvée.</strong> Pas de news-sitemap, pas de RSS détecté, pas de sous-sitemap éditorial reconnaissable. Si vous savez que ce site publie des articles, configurez un proxy CORS dans les Réglages — beaucoup de sources sont CORS-bloquées sans proxy.</div>';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-newspaper me-1"></i> Relancer';
return;
}
// Étape 2 : agréger TOUTES les entries de TOUTES les sources, dédupliquer, trier par date globale
const TARGET = 5;
const allEntries = new Map(); // url -> { url, date, sourceType, sourceUrl }
for (const source of sources) {
const entries = extractArticleEntries(source);
for (const e of entries) {
const existing = allEntries.get(e.url);
// On garde la meilleure entrée (avec date si disponible)
if (!existing || (!existing.date && e.date)) {
allEntries.set(e.url, { ...e, sourceUrl: source.url });
}
}
}
// Tri par date publication décroissante (les plus récents d'abord)
const sorted = [...allEntries.values()].sort((a, b) => {
// Articles avec date avant ceux sans date
if (a.date && !b.date) return -1;
if (!a.date && b.date) return 1;
if (!a.date && !b.date) return 0;
// Comparaison ISO/RFC tolérante
const da = new Date(a.date).getTime() || 0;
const db = new Date(b.date).getTime() || 0;
return db - da;
});
const collectedUrls = sorted.slice(0, TARGET).map(e => e.url);
// Stats sur les sources réellement utilisées (pour affichage)
const usedSourceUrls = new Set(sorted.slice(0, TARGET).map(e => e.sourceUrl));
const usedSources = sources
.filter(s => usedSourceUrls.has(s.url))
.map(s => ({ source: s, count: sorted.slice(0, TARGET).filter(e => e.sourceUrl === s.url).length }));
// Date du plus récent et du plus ancien échantillon pour info
const sampleDates = sorted.slice(0, TARGET).map(e => e.date).filter(Boolean);
const newestDate = sampleDates[0] || null;
const oldestDate = sampleDates[sampleDates.length - 1] || null;
if (collectedUrls.length === 0) {
out.innerHTML = `<div class="alert alert-warning small mb-0"><i class="bi bi-info-circle me-1"></i>${sources.length} source(s) trouvée(s) mais aucune URL d'article extractible.</div>`;
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-newspaper me-1"></i> Relancer';
return;
}
// Étape 3 : analyse parallèle avec progress
btn.innerHTML = `<span class="loader-spinner me-2"></span>Analyse en cours…`;
let analysisLog = `${collectedUrls.length} articles trouvés via ${usedSources.length} source(s)`;
out.innerHTML = `
<div class="editorial-progress">
<div class="editorial-progress-label">
<span><span class="loader-spinner me-2"></span>${escapeHtml(analysisLog)} · analyse en cours…</span>
<strong id="editorial-progress-count">0/${collectedUrls.length}</strong>
</div>
<div class="editorial-progress-bar"><div class="editorial-progress-fill" id="editorial-progress-fill" style="width:0%"></div></div>
</div>`;
// Analyse en parallèle, mais on incrémente un compteur live
let done = 0;
const results = await Promise.all(collectedUrls.map(async (u) => {
const r = await analyzeArticle(u);
done++;
const pct = Math.round((done / collectedUrls.length) * 100);
const fill = document.getElementById('editorial-progress-fill');
const count = document.getElementById('editorial-progress-count');
if (fill) fill.style.width = pct + '%';
if (count) count.textContent = `${done}/${collectedUrls.length}`;
return r;
}));
renderEditorialResults(results, usedSources, { newestDate, oldestDate, totalCandidates: sorted.length });
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-clockwise me-1"></i> Relancer';
}
function renderEditorialResults(results, usedSources, meta) {
const ok = results.filter(r => r.ok);
const failed = results.filter(r => !r.ok);
const criteria = getEditorialCriteria();
// Bandeau sources : peut être un objet (ancien format) ou un array (nouveau format)
let sourcesHtml = '';
if (Array.isArray(usedSources)) {
if (usedSources.length === 1) {
const s = usedSources[0].source;
sourcesHtml = `<strong>Source :</strong> <code>${escapeHtml(s.url)}</code> (${s.type})`;
} else {
sourcesHtml = `<strong>${usedSources.length} sources combinées :</strong><ul class="mt-1 mb-0" style="padding-left:1.2rem; font-size:0.78rem;">`;
for (const {source, count} of usedSources) {
sourcesHtml += `<li><code>${escapeHtml(source.url)}</code> <span class="text-muted">(${source.type}, ${count} article${count>1?'s':''})</span></li>`;
}
sourcesHtml += '</ul>';
}
} else if (usedSources?.url) {
// Compat ancien format
sourcesHtml = `<strong>Source :</strong> <code>${escapeHtml(usedSources.url)}</code> (${usedSources.type})`;
}
let html = `<div class="alert alert-light border small mb-3">${sourcesHtml} · <strong>${ok.length}/${results.length}</strong> articles analysés${failed.length ? ` · <span class="text-danger">${failed.length} échecs</span>` : ''}`;
// Affichage de la fenêtre temporelle si dispo
if (meta && (meta.newestDate || meta.oldestDate)) {
const fmt = (d) => {
if (!d) return '?';
try { return new Date(d).toLocaleDateString('fr-FR', { year:'numeric', month:'short', day:'numeric' }); }
catch(e) { return String(d).substring(0, 10); }
};
if (meta.newestDate && meta.oldestDate && meta.newestDate !== meta.oldestDate) {
html += `<br><small class="text-muted"><i class="bi bi-calendar3 me-1"></i>Échantillon : du <strong>${fmt(meta.oldestDate)}</strong> au <strong>${fmt(meta.newestDate)}</strong>${meta.totalCandidates ? ` · ${meta.totalCandidates} articles candidats au total` : ''}</small>`;
} else if (meta.newestDate) {
html += `<br><small class="text-muted"><i class="bi bi-calendar3 me-1"></i>Plus récent : <strong>${fmt(meta.newestDate)}</strong>${meta.totalCandidates ? ` · ${meta.totalCandidates} articles candidats au total` : ''}</small>`;
}
}
html += `</div>`;
if (ok.length === 0) {
html += '<div class="alert alert-danger small">Aucun article n\'a pu être fetché. Probablement CORS bloqué — configurez un proxy CORS dans les Réglages.</div>';
document.getElementById('editorial-output').innerHTML = html;
return;
}
// Synthèse cohérence éditoriale
html += '<div class="card mb-3"><div class="card-body">';
html += '<h6 class="mb-3"><i class="bi bi-bar-chart me-1"></i>Cohérence éditoriale</h6>';
html += '<div class="row g-2">';
for (const c of criteria) {
const passed = ok.filter(c.test).length;
const ratio = passed / ok.length;
const stat = ratio === 1 ? 'ok' : ratio >= 0.6 ? 'warn' : 'bad';
html += `<div class="col-md-6"><div class="d-flex justify-content-between align-items-center" style="font-size:0.85rem;"><span class="text-muted">${escapeHtml(c.label)}</span> ${statusBadge(stat, `${passed}/${ok.length}`)}</div></div>`;
}
html += '</div></div></div>';
// Site Focus Score
const focus = computeSiteFocus(ok);
if (focus) {
const stat = focus.score >= 70 ? 'ok' : focus.score >= 40 ? 'warn' : 'bad';
html += `<div class="card mb-3"><div class="card-body">`;
html += `<div class="d-flex justify-content-between align-items-center mb-2"><h6 class="mb-0"><i class="bi bi-bullseye me-1"></i>Site Focus Score (cohérence thématique)</h6> ${statusBadge(stat, focus.score + '/100')}</div>`;
html += `<p class="text-muted small mb-2">Concentration thématique des ${ok.length} articles. Top 10 termes représentent <strong>${focus.focusRatio}%</strong> du vocabulaire significatif. Plus c'est haut, plus le site est spécialisé (signal positif). Le leak Google mentionne <code>siteFocusScore</code> et <code>siteRadius</code>.</p>`;
html += `<div class="d-flex flex-wrap gap-1">`;
for (const [term, count] of focus.topTerms) {
html += `<span class="badge bg-light text-dark border" style="font-weight:500;">${escapeHtml(term)} <span class="text-muted">×${count}</span></span>`;
}
html += `</div></div></div>`;
}
// Duplicate content détection
const dupes = detectInternalDuplicates(ok);
if (dupes.count > 0) {
html += `<div class="alert alert-warning small mb-3"><strong><i class="bi bi-files me-1"></i>${dupes.count} similarité(s) suspecte(s) détectée(s)</strong> entre articles. Le leak mentionne <code>shingleInfo</code> qui détecte les contenus dupliqués/quasi-dupliqués.<details class="mt-2"><summary style="cursor:pointer;">Détails</summary><ul class="mt-2 mb-0" style="font-size:0.8rem; padding-left:1.2rem;">`;
for (const issue of dupes.issues) {
const labelType = issue.type === 'title' ? 'Titres' : 'Meta-descriptions';
html += `<li><strong>${labelType} similaires à ${issue.sim}%</strong><br><span class="mono text-muted">${escapeHtml(issue.a.slice(-60))}</span><br><span class="mono text-muted">${escapeHtml(issue.b.slice(-60))}</span></li>`;
}
html += '</ul></details></div>';
}
// Détail par article
html += '<div class="accordion" id="articlesAccordion">';
results.forEach((r, i) => {
const id = `article-${i}`;
const shortUrl = r.url.length > 70 ? r.url.slice(0, 70) + '…' : r.url;
let summary = '';
if (r.ok) {
const passed = criteria.filter(c => c.test(r)).length;
const stat = passed >= criteria.length * 0.8 ? 'ok' : passed >= criteria.length * 0.5 ? 'warn' : 'bad';
summary = statusBadge(stat, `${passed}/${criteria.length}`);
} else {
summary = statusBadge('bad', r.error || 'échec');
}
html += `<div class="accordion-item">`;
html += `<h2 class="accordion-header"><button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#${id}"><span class="me-2 mono small text-muted text-truncate" style="max-width:75%;">${escapeHtml(shortUrl)}</span><span class="ms-auto me-3">${summary}</span></button></h2>`;
html += `<div id="${id}" class="accordion-collapse collapse" data-bs-parent="#articlesAccordion"><div class="accordion-body" style="font-size:0.85rem;">`;
if (!r.ok) {
html += `<div class="text-danger">Échec : ${escapeHtml(r.error)}</div>`;
} else {
html += `<div class="mb-2"><a href="${escapeHtml(r.url)}" target="_blank" rel="noopener" class="card-action">Ouvrir l'article <i class="bi bi-arrow-up-right"></i></a></div>`;
if (r.title) html += `<div><strong>Title :</strong> ${escapeHtml(r.title.slice(0, 120))}${r.title.length > 120 ? '…' : ''}</div>`;
if (r.metaDesc) html += `<div><strong>Description :</strong> ${escapeHtml(r.metaDesc.slice(0, 160))}${r.metaDesc.length > 160 ? '…' : ''}</div>`;
// Content Effort score
if (r.contentEffort) {
const ceStat = r.contentEffort.score >= 70 ? 'ok' : r.contentEffort.score >= 40 ? 'warn' : 'bad';
html += `<div class="mt-2 d-flex justify-content-between align-items-center"><strong>Content Effort</strong> ${statusBadge(ceStat, r.contentEffort.score + '/100')}</div>`;
if (r.contentEffort.factors.length) {
html += `<div class="text-muted small mt-1">${r.contentEffort.factors.map(escapeHtml).join(' · ')}</div>`;
}
}
// Clutter
if (r.clutter && r.clutter.level !== 'low') {
const cStat = r.clutter.level === 'high' ? 'bad' : 'warn';
html += `<div class="mt-2 d-flex justify-content-between align-items-center"><strong>Clutter (ads/popups)</strong> ${statusBadge(cStat, r.clutter.level)}</div>`;
if (r.clutter.warnings.length) {
html += `<div class="text-muted small mt-1">${r.clutter.warnings.map(escapeHtml).join(' · ')}</div>`;
}
}
// Hero image
if (r.heroImage) {
const h = r.heroImage;
const w = h.actualWidth || h.declaredWidth;
const heightVal = h.actualHeight || h.declaredHeight;
if (w) {
const widthStat = w >= 1200 ? 'ok' : w >= 800 ? 'warn' : 'bad';
const sourceLabel = h.method === 'measured' ? 'mesurée' : (h.method === 'declared' ? 'déclarée' : 'erreur');
html += `<div class="mt-2 d-flex justify-content-between align-items-center"><strong>Image hero</strong> ${statusBadge(widthStat, `${w}${heightVal ? '×' + heightVal : ''}px`)}</div>`;
html += `<div class="text-muted small mt-1">Source : ${sourceLabel}`;
if (h.sizeBytes !== null) {
const kb = Math.round(h.sizeBytes / 1024);
const sizeBadge = h.sizeBytes < 1024*1024 ? '' : ` <span class="text-warning">⚠ > 1MB</span>`;
html += ` · Poids : ${kb >= 1024 ? (kb/1024).toFixed(1) + ' MB' : kb + ' KB'}${sizeBadge}`;
}
if (w < 1200) {
html += ` · <span class="text-danger">Insuffisant pour Discover</span>`;
}
html += `</div>`;
} else if (h.error) {
html += `<div class="mt-2 small text-muted">Image hero : impossible de mesurer (${escapeHtml(h.error)})</div>`;
}
} else {
html += `<div class="mt-2 small text-muted">Image hero : aucune (og:image et schema absents)</div>`;
}
// Date coherence
if (r.dateCoherence && r.dateCoherence.sourcesCount >= 2) {
if (!r.dateCoherence.coherent) {
html += `<div class="mt-2"><strong class="text-danger">Dates incohérentes :</strong> <span class="mono small">${r.dateCoherence.discrepancies.map(escapeHtml).join(' ≠ ')}</span></div>`;
} else {
html += `<div class="mt-2 small text-muted">Dates cohérentes sur ${r.dateCoherence.sourcesCount} sources</div>`;
}
}
// Tous les critères en grille
html += `<div class="row g-2 mt-2">`;
for (const c of criteria) {
const passed = c.test(r);
html += `<div class="col-md-6"><div class="d-flex justify-content-between"><span class="text-muted">${escapeHtml(c.label)}</span> ${statusBadge(passed ? 'ok' : 'bad', passed ? '✓' : '✗')}</div></div>`;
}
html += `</div>`;
// Détails techniques
html += `<div class="mt-3 small text-muted">`;
html += `Mots: <strong>${r.wordCount}</strong> · H1: <strong>${r.h1Count}</strong> · H2: <strong>${r.h2Count}</strong> · Images: <strong>${r.imageCount}</strong> · Sources externes: <strong>${r.externalLinks}</strong>`;
if (r.allSchemaTypes.length) html += `<br>Schemas détectés: ${r.allSchemaTypes.map(escapeHtml).join(', ')}`;
if (r.articleAuthor) {
html += `<br>Auteur: <strong>${escapeHtml(r.articleAuthor)}</strong>`;
if (r.authorSocialLinks?.length) {
html += ` · sameAs: ${r.authorSocialLinks.length} lien(s)`;
}
}
if (r.articleDatePublished) html += ` · Publié: <strong>${escapeHtml(String(r.articleDatePublished).substring(0,10))}</strong>`;
if (r.articleDateModified) html += ` · Modifié: <strong>${escapeHtml(String(r.articleDateModified).substring(0,10))}</strong>`;
html += `</div>`;
}
html += `</div></div></div>`;
});
html += '</div>';
document.getElementById('editorial-output').innerHTML = html;
initTooltips();
}
// TOOLTIPS
let tooltipInstances = [];
function initTooltips() {
tooltipInstances.forEach(t => { try { t.dispose(); } catch(e){} });
tooltipInstances = [];
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
tooltipInstances.push(new bootstrap.Tooltip(el, {container:'body', trigger:'hover focus'}));
});
}
initTooltips();
// Settings modal: load values when shown
document.getElementById('settingsModal').addEventListener('show.bs.modal', loadSettingsIntoForm);
// INIT
renderHistory();
// Welcome banner : affichage si pas de clé Google configurée ET pas encore dismissed
function checkWelcomeBanner() {
const settings = getSettings();
const dismissed = localStorage.getItem('welcome-banner-dismissed') === '1';
if (!settings.googleKey && !dismissed) {
document.getElementById('welcome-banner').classList.remove('d-none');
}
}
function dismissWelcome() {
localStorage.setItem('welcome-banner-dismissed', '1');
document.getElementById('welcome-banner').classList.add('d-none');
}
checkWelcomeBanner();
setupEditorialTabs();
// Auto-lancement de l'audit UNIQUEMENT au premier chargement (lien externe partagé),
// PAS au refresh de la page (sinon F5 relance l'audit, ce qui frustre l'utilisateur)
const urlParams = new URLSearchParams(window.location.search);
const domainParam = urlParams.get('domain');
if (domainParam) {
document.getElementById('audit-domain').value = domainParam;
// sessionStorage survit au refresh mais pas à la fermeture d'onglet
// → si la clé existe déjà, c'est un refresh, on ne relance pas
const alreadyLoaded = sessionStorage.getItem('audit-loaded-' + domainParam);
if (!alreadyLoaded) {
sessionStorage.setItem('audit-loaded-' + domainParam, '1');
setTimeout(() => runFullAudit({preventDefault:()=>{}}), 100);
}
}
</script>
</body>
</html>