P2P+ | Hive5+

Enhances Hive5 with advanced analytics.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Advertisement:

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

Advertisement:

// ==UserScript==
// @name         P2P+ | Hive5+
// @name:fr      P2P+ | Hive5+
// @license      MIT
// @namespace    https://github.com/local/hive5-plus
// @version      0.2.3
// @description  Enhances Hive5 with advanced analytics.
// @description:fr  Améliore Hive5 avec des statistiques avancées, une analyse automatique du marché et des outils d'aide à l'investissement.
// @author       P2P+
// @match        https://hive5.com/*
// @match        https://*.hive5.com/*
// @match        https://hive5.eu/*
// @match        https://*.hive5.eu/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function Hive5PlusUserscript() {
  "use strict";

  const Hive5Plus = {
    meta: {
      name: "Hive5+",
      version: "0.2.3",
    },
    state: {
      debug: window.localStorage.getItem("hive5plus:debug") === "1",
      lastDiagnostics: null,
    },
    modules: {},
  };

  const log = (...args) => {
    if (Hive5Plus.state.debug) console.info("[Hive5+]", ...args);
  };

  if (!window.location.hostname.includes("hive5")) return;

  window.Hive5Plus = {
    status: "loading",
    diagnose() {
      console.info("[Hive5+] Still loading. Try again in one second.");
      return { status: this.status };
    },
  };

  console.info(`[Hive5+] loaded v${Hive5Plus.meta.version}`, window.location.href);

  Hive5Plus.utils = (() => {
    const text = (node) => {
      if (!node) return "";
      const clone = node.cloneNode(true);
      clone.querySelectorAll?.("#hive5-plus-badge, #hive5-plus-portfolio-summary").forEach((item) => item.remove());
      return clone.textContent.replace(/\s+/g, " ").trim();
    };

    const normalize = (value) =>
      String(value || "")
        .normalize("NFD")
        .replace(/[\u0300-\u036f]/g, "")
        .toLowerCase()
        .replace(/\s+/g, " ")
        .trim();

    const parseNumber = (value) => {
      const raw = String(value || "")
        .replace(/[^\d,.\-]/g, "")
        .trim();
      const lastComma = raw.lastIndexOf(",");
      const lastDot = raw.lastIndexOf(".");
      const decimalSeparator = lastComma > lastDot ? "," : ".";
      const cleaned = raw
        .replace(/[,.]/g, (separator, offset) => (separator === decimalSeparator && offset === Math.max(lastComma, lastDot) ? "." : ""));
      const parsed = Number.parseFloat(cleaned);
      return Number.isFinite(parsed) ? parsed : 0;
    };

    const parsePercent = (value) => parseNumber(value) / 100;

    const weightedAverage = (items, valueKey, weightKey) => {
      const totalWeight = items.reduce((sum, item) => sum + Math.max(0, item[weightKey] || 0), 0);
      if (!totalWeight) return 0;
      return items.reduce((sum, item) => sum + (item[valueKey] || 0) * (item[weightKey] || 0), 0) / totalWeight;
    };

    const formatCurrency = (value) =>
      new Intl.NumberFormat(undefined, {
        style: "currency",
        currency: "EUR",
        maximumFractionDigits: 2,
      }).format(value || 0);

    const formatPercent = (value) =>
      new Intl.NumberFormat(undefined, {
        style: "percent",
        minimumFractionDigits: 1,
        maximumFractionDigits: 2,
      }).format(value || 0);

    const formatDays = (value) => {
      if (!value) return "-";
      return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(value)} j`;
    };

    const debounce = (fn, wait = 150) => {
      let timeout;
      return (...args) => {
        window.clearTimeout(timeout);
        timeout = window.setTimeout(() => fn(...args), wait);
      };
    };

    return {
      debounce,
      formatCurrency,
      formatDays,
      formatPercent,
      normalize,
      parseNumber,
      parsePercent,
      text,
      weightedAverage,
    };
  })();

  Hive5Plus.ui = (() => {
    const ROOT_CLASS = "hive5-plus";

    const ensureStyles = () => {
      if (document.getElementById("hive5-plus-styles")) return;

      const style = document.createElement("style");
      style.id = "hive5-plus-styles";
      style.textContent = `
        .${ROOT_CLASS}-panel {
          margin: 16px 0 20px;
          padding: 16px;
          border: 1px solid rgba(31, 41, 55, 0.14);
          border-radius: 8px;
          background: #ffffff;
          box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06);
          color: #111827;
          font-family: inherit;
        }
        .${ROOT_CLASS}-header {
          display: flex;
          align-items: baseline;
          justify-content: space-between;
          gap: 12px;
          margin-bottom: 12px;
        }
        .${ROOT_CLASS}-title {
          margin: 0;
          font-size: 16px;
          font-weight: 700;
          line-height: 1.3;
        }
        .${ROOT_CLASS}-meta {
          color: #6b7280;
          font-size: 12px;
          white-space: nowrap;
        }
        .${ROOT_CLASS}-stats {
          display: grid;
          grid-template-columns: repeat(4, minmax(120px, 1fr));
          gap: 10px;
          margin: 0 0 14px;
        }
        .${ROOT_CLASS}-stat {
          min-width: 0;
          padding: 10px 12px;
          border: 1px solid rgba(31, 41, 55, 0.1);
          border-radius: 6px;
          background: #f9fafb;
        }
        .${ROOT_CLASS}-label {
          display: block;
          color: #6b7280;
          font-size: 12px;
          line-height: 1.25;
        }
        .${ROOT_CLASS}-value {
          display: block;
          margin-top: 4px;
          overflow-wrap: anywhere;
          font-size: 16px;
          font-weight: 700;
          line-height: 1.25;
        }
        .${ROOT_CLASS}-table-wrap {
          overflow-x: auto;
        }
        .${ROOT_CLASS}-table {
          width: 100%;
          min-width: 760px;
          border-collapse: collapse;
          font-size: 13px;
        }
        .${ROOT_CLASS}-table th,
        .${ROOT_CLASS}-table td {
          padding: 9px 10px;
          border-top: 1px solid rgba(31, 41, 55, 0.1);
          text-align: right;
          white-space: nowrap;
        }
        .${ROOT_CLASS}-table th:first-child,
        .${ROOT_CLASS}-table td:first-child {
          text-align: left;
        }
        .${ROOT_CLASS}-table th {
          color: #4b5563;
          font-size: 12px;
          font-weight: 700;
          background: #f9fafb;
        }
        .${ROOT_CLASS}-empty {
          margin: 0;
          color: #6b7280;
          font-size: 13px;
        }
        .${ROOT_CLASS}-banner-panel {
          width: 100%;
          padding: 16px;
          border: 1px solid rgba(31, 41, 55, 0.14);
          border-radius: 8px;
          background: #ffffff;
          color: #111827;
          font-family: inherit;
        }
        .${ROOT_CLASS}-summary-line {
          margin: -4px 0 14px;
          color: #6b7280;
          font-size: 13px;
          line-height: 1.4;
        }
        .${ROOT_CLASS}-header-actions {
          display: flex;
          align-items: center;
          gap: 10px;
        }
        .${ROOT_CLASS}-button {
          appearance: none;
          padding: 6px 10px;
          border: 1px solid rgba(31, 41, 55, 0.18);
          border-radius: 6px;
          background: #ffffff;
          color: #374151;
          cursor: pointer;
          font: inherit;
          font-size: 12px;
          line-height: 1.2;
        }
        .${ROOT_CLASS}-button:hover {
          background: #f9fafb;
        }
        .${ROOT_CLASS}-button:disabled {
          cursor: default;
          opacity: 0.6;
        }
        .${ROOT_CLASS}-badge {
          position: fixed;
          right: 12px;
          bottom: 12px;
          z-index: 2147483647;
          max-width: min(320px, calc(100vw - 24px));
          padding: 8px 10px;
          border: 1px solid rgba(31, 41, 55, 0.18);
          border-radius: 6px;
          background: #111827;
          color: #ffffff;
          box-shadow: 0 8px 24px rgba(15, 23, 42, 0.24);
          font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
          font-size: 12px;
          line-height: 1.3;
        }
        .${ROOT_CLASS}-badge strong {
          font-weight: 700;
        }
        .${ROOT_CLASS}-badge span {
          color: #d1d5db;
        }
        @media (max-width: 760px) {
          .${ROOT_CLASS}-panel {
            padding: 12px;
          }
          .${ROOT_CLASS}-header {
            display: block;
          }
          .${ROOT_CLASS}-meta {
            display: block;
            margin-top: 4px;
            white-space: normal;
          }
          .${ROOT_CLASS}-stats {
            grid-template-columns: repeat(2, minmax(0, 1fr));
          }
        }
      `;
      document.head.appendChild(style);
    };

    const create = (tagName, options = {}, children = []) => {
      const element = document.createElement(tagName);
      if (options.className) element.className = options.className;
      if (options.text != null) element.textContent = options.text;
      if (options.html != null) element.innerHTML = options.html;
      children.filter(Boolean).forEach((child) => element.appendChild(child));
      return element;
    };

    const stat = (label, value) =>
      create("div", { className: `${ROOT_CLASS}-stat` }, [
        create("span", { className: `${ROOT_CLASS}-label`, text: label }),
        create("span", { className: `${ROOT_CLASS}-value`, text: value }),
      ]);

    const setBadge = (message) => {
      ensureStyles();

      let badge = document.getElementById("hive5-plus-badge");
      if (!badge) {
        badge = create("div", { className: `${ROOT_CLASS}-badge` }, [
          create("strong", { text: "Hive5+ " }),
          create("span", { text: message }),
        ]);
        badge.id = "hive5-plus-badge";
        document.body.appendChild(badge);
      }

      const detail = badge.querySelector("span");
      if (detail) detail.textContent = message;
      window.Hive5Plus.status = message;
    };

    return {
      ROOT_CLASS,
      create,
      ensureStyles,
      setBadge,
      stat,
    };
  })();

  Hive5Plus.modules.portfolio = (() => {
    const SUMMARY_ID = "hive5-plus-portfolio-summary";
    const { create, ensureStyles, setBadge, stat } = Hive5Plus.ui;
    const utils = Hive5Plus.utils;

    const state = {
      analyzing: false,
      analyzedKey: "",
    };

    const headerAliases = {
      originator: ["originator", "loan originator", "company", "provider"],
      capital: ["invested", "capital", "principal", "outstanding", "amount"],
      count: ["loans", "loan count", "number of loans", "nombre de prets", "nb prets", "prets"],
      type: ["type", "business", "personal", "purpose", "loan type", "category"],
      rate: ["rate", "interest", "interest rate", "taux", "yield"],
      duration: ["duration", "term", "remaining term", "maturity", "duree"],
    };

    const excludedHeaderWords = {
      capital: ["id", "agreement", "loan id", "investment id"],
      count: ["id"],
      rate: ["id"],
      duration: ["id"],
    };

    const findPortfolioHeading = (doc = document) => {
      const candidates = doc.querySelectorAll("h1, h2, h3, h4, [role='heading'], .page-title");
      return Array.from(candidates).find((node) => {
        const value = utils.normalize(utils.text(node));
        return value === "portfolio" || value.includes("portfolio") || value.includes("my investments") || value.includes("mes investissements");
      });
    };

    const firstExternalTable = (root) => {
      if (!root) return null;
      const tables = root.matches?.("table") ? [root] : Array.from(root.querySelectorAll?.("table") || []);
      return tables.find((table) => !table.closest(`#${SUMMARY_ID}`)) || null;
    };

    const findPortfolioTable = (heading) => {
      if (!heading) return null;

      let cursor = heading;
      for (let i = 0; i < 8 && cursor; i += 1) {
        const localTable = firstExternalTable(cursor);
        if (localTable) return localTable;

        let sibling = cursor.nextElementSibling;
        while (sibling) {
          if (sibling.id !== SUMMARY_ID) {
            const siblingTable = firstExternalTable(sibling);
            if (siblingTable) return siblingTable;
          }
          sibling = sibling.nextElementSibling;
        }

        cursor = cursor.parentElement;
      }

      return null;
    };

    const pageUrl = (page) => {
      const url = new URL(window.location.href);
      url.searchParams.set("page", String(page));
      return url.toString();
    };

    const getAnalysisKey = () => {
      const url = new URL(window.location.href);
      url.searchParams.delete("page");
      return `${url.pathname}${url.search}`;
    };

    const getTotalPages = (doc = document) => {
      const pages = new Set([Number(new URL(window.location.href).searchParams.get("page")) || 1]);
      const pageNumberFromText = (value) => {
        const match = utils.text(value).match(/\b\d+\b/);
        return match ? Number.parseInt(match[0], 10) : NaN;
      };

      doc.querySelectorAll("a[href*='page='], .pagination a, .pagination button, .page-link, nav a, nav button").forEach((link) => {
        const labelPage = pageNumberFromText(link);
        if (Number.isFinite(labelPage)) pages.add(labelPage);

        const href = link.getAttribute("href");
        if (!href) return;

        try {
          const hrefPage = Number.parseInt(new URL(href, window.location.href).searchParams.get("page"), 10);
          if (Number.isFinite(hrefPage)) pages.add(hrefPage);
        } catch (error) {
          log("Portfolio pagination link ignored", href, error);
        }
      });

      return Math.max(...pages);
    };

    const getHeaders = (table) => {
      const headerCells = table.querySelectorAll("thead th, thead td");
      const cells = headerCells.length ? headerCells : table.querySelectorAll("tr:first-child th, tr:first-child td");
      return Array.from(cells).map((cell) => utils.normalize(utils.text(cell)));
    };

    const columnIndex = (headers, key) => {
      const aliases = headerAliases[key];
      const excludedWords = excludedHeaderWords[key] || [];
      const isAllowed = (header) => !excludedWords.some((word) => header.includes(utils.normalize(word)));

      const exactMatch = headers.findIndex((header) => isAllowed(header) && aliases.includes(header));
      if (exactMatch >= 0) return exactMatch;

      return headers.findIndex((header) => isAllowed(header) && aliases.some((alias) => header.includes(utils.normalize(alias))));
    };

    const getDiagnostics = () => {
      const heading = findPortfolioHeading();
      const table = findPortfolioTable(heading);
      const headers = table ? getHeaders(table) : [];
      const rows = table ? readRows(table) : [];
      const diagnostics = {
        href: window.location.href,
        scriptLoaded: true,
        headingFound: Boolean(heading),
        headingText: heading ? utils.text(heading) : "",
        tableFound: Boolean(table),
        headers,
        totalPages: getTotalPages(),
        rawTableRows: table ? table.rows.length : 0,
        parsedRows: rows.length,
        summaryMounted: Boolean(document.getElementById(SUMMARY_ID)),
        analyzing: state.analyzing,
      };

      Hive5Plus.state.lastDiagnostics = diagnostics;
      return diagnostics;
    };

    const readRows = (table) => {
      const headers = getHeaders(table);
      const indexes = {
        originator: columnIndex(headers, "originator"),
        capital: columnIndex(headers, "capital"),
        count: columnIndex(headers, "count"),
        type: columnIndex(headers, "type"),
        rate: columnIndex(headers, "rate"),
        duration: columnIndex(headers, "duration"),
      };

      if (indexes.originator < 0 || indexes.capital < 0) return [];

      const bodyRows = table.tBodies.length
        ? Array.from(table.tBodies).flatMap((body) => Array.from(body.rows))
        : Array.from(table.rows).slice(1);

      return bodyRows
        .map((row) => {
          const cells = Array.from(row.cells);
          const cellText = (index) => (index >= 0 ? utils.text(cells[index]) : "");
          const originator = cellText(indexes.originator);
          const typeText = utils.normalize(cellText(indexes.type) || originator);

          const capital = utils.parseNumber(cellText(indexes.capital));
          const count = Math.max(1, Math.round(utils.parseNumber(cellText(indexes.count)) || 1));
          const isBusiness = typeText.includes("business");
          const isPersonal = typeText.includes("personal") || typeText.includes("perso") || typeText.includes("consumer");

          return {
            originator,
            capital,
            count,
            businessCapital: isBusiness ? capital : 0,
            personalCapital: !isBusiness && isPersonal ? capital : 0,
            rate: utils.parsePercent(cellText(indexes.rate)),
            duration: utils.parseNumber(cellText(indexes.duration)),
          };
        })
        .filter((row) => row.originator && row.capital > 0);
    };

    const summarize = (rows) => {
      const totalCapital = rows.reduce((sum, row) => sum + row.capital, 0);
      const grouped = new Map();

      rows.forEach((row) => {
        const key = row.originator || "Unknown";
        if (!grouped.has(key)) grouped.set(key, []);
        grouped.get(key).push(row);
      });

      return Array.from(grouped.entries())
        .map(([originator, items]) => {
          const capital = items.reduce((sum, item) => sum + item.capital, 0);
          const count = items.reduce((sum, item) => sum + item.count, 0);
          return {
            originator,
            capital,
            count,
            businessCapital: items.reduce((sum, item) => sum + item.businessCapital, 0),
            personalCapital: items.reduce((sum, item) => sum + item.personalCapital, 0),
            share: totalCapital ? capital / totalCapital : 0,
            averageRate: utils.weightedAverage(items, "rate", "capital"),
            averageDuration: utils.weightedAverage(items, "duration", "capital"),
          };
        })
        .sort((a, b) => b.capital - a.capital);
    };

    const renderLoading = (heading, current, total) => {
      ensureStyles();

      const existing = document.getElementById(SUMMARY_ID);
      if (existing) existing.remove();

      const panel = create("section", { className: "hive5-plus-panel" });
      panel.id = SUMMARY_ID;
      panel.appendChild(
        create("div", { className: "hive5-plus-header" }, [
          create("h2", { className: "hive5-plus-title", text: "Hive5+" }),
          create("span", { className: "hive5-plus-meta", text: `${current} / ${total} pages` }),
        ]),
      );
      panel.appendChild(create("p", { className: "hive5-plus-empty", text: "Analyse de toutes les pages d'investissements..." }));
      heading.insertAdjacentElement("afterend", panel);
    };

    const render = (heading, rows, options = {}) => {
      ensureStyles();

      const fingerprint = JSON.stringify({ rows, analyzedPages: options.analyzedPages || 1 });
      const existing = document.getElementById(SUMMARY_ID);
      if (existing?.dataset.source === fingerprint) return;
      if (existing) existing.remove();

      const summaryRows = summarize(rows);
      const totalCapital = rows.reduce((sum, row) => sum + row.capital, 0);
      const totalLoans = rows.reduce((sum, row) => sum + row.count, 0);
      const averageRate = utils.weightedAverage(rows, "rate", "capital");
      const averageDuration = utils.weightedAverage(rows, "duration", "capital");

      const panel = create("section", { className: "hive5-plus-panel" });
      panel.id = SUMMARY_ID;
      panel.dataset.source = fingerprint;

      panel.appendChild(
        create("div", { className: "hive5-plus-header" }, [
          create("h2", { className: "hive5-plus-title", text: "Hive5+" }),
          create("span", {
            className: "hive5-plus-meta",
            text: `${summaryRows.length} originator${summaryRows.length > 1 ? "s" : ""} • ${options.analyzedPages || 1} page${(options.analyzedPages || 1) > 1 ? "s" : ""} analysee${(options.analyzedPages || 1) > 1 ? "s" : ""}`,
          }),
        ]),
      );

      panel.appendChild(
        create("div", { className: "hive5-plus-stats" }, [
          stat("Capital total", utils.formatCurrency(totalCapital)),
          stat("Nombre de prets", String(totalLoans)),
          stat("Taux moyen", utils.formatPercent(averageRate)),
          stat("Duree moyenne", utils.formatDays(averageDuration)),
        ]),
      );

      if (!summaryRows.length) {
        panel.appendChild(create("p", { className: "hive5-plus-empty", text: "Aucune ligne exploitable trouvee." }));
      } else {
        const table = create("table", { className: "hive5-plus-table" });
        table.appendChild(
          create("thead", {}, [
            create("tr", {}, [
              create("th", { text: "Originator" }),
              create("th", { text: "Capital" }),
              create("th", { text: "Prets" }),
              create("th", { text: "Business" }),
              create("th", { text: "Perso" }),
              create("th", { text: "%" }),
              create("th", { text: "Taux moyen" }),
              create("th", { text: "Duree moyenne" }),
            ]),
          ]),
        );

        const tbody = create("tbody");
        summaryRows.forEach((row) => {
          tbody.appendChild(
            create("tr", {}, [
              create("td", { text: row.originator }),
              create("td", { text: utils.formatCurrency(row.capital) }),
              create("td", { text: String(row.count) }),
              create("td", { text: utils.formatCurrency(row.businessCapital) }),
              create("td", { text: utils.formatCurrency(row.personalCapital) }),
              create("td", { text: utils.formatPercent(row.share) }),
              create("td", { text: utils.formatPercent(row.averageRate) }),
              create("td", { text: utils.formatDays(row.averageDuration) }),
            ]),
          );
        });
        table.appendChild(tbody);
        panel.appendChild(create("div", { className: "hive5-plus-table-wrap" }, [table]));
      }

      heading.insertAdjacentElement("afterend", panel);
    };

    const fetchPage = async (page) => {
      if (page === (Number(new URL(window.location.href).searchParams.get("page")) || 1)) return document;

      const response = await fetch(pageUrl(page), {
        credentials: "include",
      });
      if (!response.ok) throw new Error(`Page ${page} returned ${response.status}`);

      const html = await response.text();
      return new DOMParser().parseFromString(html, "text/html");
    };

    const readRowsFromDocument = (doc) => {
      const heading = findPortfolioHeading(doc);
      const table = findPortfolioTable(heading);
      return table ? readRows(table) : [];
    };

    const analyze = async () => {
      const heading = findPortfolioHeading();
      if (!heading) {
        if (!window.location.pathname.includes("portfolio") && !window.location.pathname.includes("investment")) return;
        setBadge("actif - titre Portfolio introuvable");
        log("Portfolio module waiting", getDiagnostics());
        return;
      }

      const table = findPortfolioTable(heading);
      if (!table) {
        setBadge("actif - tableau Portfolio introuvable");
        log("Portfolio module waiting", getDiagnostics());
        return;
      }

      const totalPages = getTotalPages();
      const rows = [];

      state.analyzing = true;

      try {
        for (let page = 1; page <= totalPages; page += 1) {
          renderLoading(heading, page, totalPages);
          const doc = await fetchPage(page);
          rows.push(...readRowsFromDocument(doc));
        }

        log("Portfolio module rendering", getDiagnostics());
        render(heading, rows, { analyzedPages: totalPages });
        setBadge(`synthese OK - ${rows.length} ligne${rows.length > 1 ? "s" : ""} lue${rows.length > 1 ? "s" : ""} sur ${totalPages} page${totalPages > 1 ? "s" : ""}`);
      } catch (error) {
        console.warn("[Hive5+] Impossible d'analyser toutes les pages d'investissements.", error);
        state.analyzedKey = "";
        const currentRows = readRows(table);
        render(heading, currentRows, { analyzedPages: 1 });
        setBadge(`synthese partielle - ${currentRows.length} ligne${currentRows.length > 1 ? "s" : ""} lue${currentRows.length > 1 ? "s" : ""}`);
      } finally {
        state.analyzing = false;
      }
    };

    const run = () => {
      if (state.analyzing) return;

      const heading = findPortfolioHeading();
      const table = findPortfolioTable(heading);
      if (!heading || !table) {
        analyze();
        return;
      }

      const key = `${getAnalysisKey()}|pages:${getTotalPages()}`;
      if (state.analyzedKey === key) return;

      state.analyzedKey = key;
      analyze();
    };

    return {
      id: "portfolio",
      description: "Adds an automatic summary below the Portfolio title.",
      diagnose: getDiagnostics,
      run,
    };
  })();

  Hive5Plus.modules.investmentRecap = (() => {
    const MODULE_ID = "investmentRecap";
    const CACHE_KEY = "hive5plus:investmentRecap";
    const CACHE_VERSION = "0.2.2";
    const CACHE_TTL = 5 * 60 * 1000;
    const ROOT_ID = "hive5-plus-investment-recap";
    const { create, ensureStyles, setBadge } = Hive5Plus.ui;
    const utils = Hive5Plus.utils;

    const state = {
      analyzing: false,
      analyzedUrl: "",
      lastDiagnostics: null,
    };

    const isInvestmentPrimaryPage = () => window.location.pathname.replace(/\/+$/, "") === "/investment/primary";

    const getBanner = (doc = document) => doc.querySelector(".investment-banner");

    const getOriginatorOptions = (doc = document) => {
      const select = doc.querySelector("select#filter-originator");
      if (!select) return [];

      return Array.from(select.options)
        .map((option) => utils.text(option) || option.getAttribute("label") || option.value)
        .filter((value) => {
          const normalized = utils.normalize(value);
          return value && !["all", "originator", "select originator"].includes(normalized) && !normalized.includes("all originator");
        });
    };

    const loose = (value) => utils.normalize(value).replace(/[^a-z0-9]+/g, " ").trim();

    const findOriginator = (rowText, originators) => {
      const normalizedRow = loose(rowText);
      return originators.find((originator) => normalizedRow.includes(loose(originator))) || "";
    };

    const getCache = () => {
      try {
        const cached = JSON.parse(window.localStorage.getItem(CACHE_KEY) || "null");
        if (!cached || Date.now() - cached.createdAt > CACHE_TTL) return null;
        if (cached.version !== CACHE_VERSION) return null;
        return cached.payload;
      } catch (error) {
        return null;
      }
    };

    const setCache = (payload) => {
      try {
        window.localStorage.setItem(CACHE_KEY, JSON.stringify({ createdAt: Date.now(), version: CACHE_VERSION, payload }));
      } catch (error) {
        log("Investment recap cache write failed", error);
      }
    };

    const clearCache = () => {
      try {
        window.localStorage.removeItem(CACHE_KEY);
      } catch (error) {
        log("Investment recap cache clear failed", error);
      }
    };

    const pageUrl = (page) => {
      const url = new URL(window.location.href);
      url.searchParams.set("page", String(page));
      return url.toString();
    };

    const getTotalPages = (doc = document) => {
      const pages = new Set([Number(new URL(window.location.href).searchParams.get("page")) || 1]);

      doc.querySelectorAll("a[href*='page='], .pagination a, .page-link").forEach((link) => {
        const labelPage = Number.parseInt(utils.text(link), 10);
        if (Number.isFinite(labelPage)) pages.add(labelPage);

        const href = link.getAttribute("href");
        if (!href) return;

        try {
          const hrefPage = Number.parseInt(new URL(href, window.location.href).searchParams.get("page"), 10);
          if (Number.isFinite(hrefPage)) pages.add(hrefPage);
        } catch (error) {
          log("Investment recap pagination link ignored", href, error);
        }
      });

      return Math.max(...pages);
    };

    const getHeaders = (table) => {
      const headerCells = table.querySelectorAll("thead th, thead td");
      const cells = headerCells.length ? headerCells : table.querySelectorAll("tr:first-child th, tr:first-child td");
      return Array.from(cells).map((cell) => utils.normalize(utils.text(cell)));
    };

    const columnIndex = (headers, aliases, excluded = []) => {
      const isAllowed = (header) => !excluded.some((word) => header.includes(utils.normalize(word)));
      const exactMatch = headers.findIndex((header) => isAllowed(header) && aliases.includes(header));
      if (exactMatch >= 0) return exactMatch;

      return headers.findIndex((header) => isAllowed(header) && aliases.some((alias) => header.includes(utils.normalize(alias))));
    };

    const getBodyRows = (table) =>
      table.tBodies.length
        ? Array.from(table.tBodies).flatMap((body) => Array.from(body.rows))
        : Array.from(table.rows).slice(1);

    const getLoanTable = (doc = document, originators = []) => {
      const tables = Array.from(doc.querySelectorAll("table"));
      const headerMatch = tables.find((table) => {
        const headers = getHeaders(table);
        return (
          (columnIndex(headers, ["originator", "loan originator"]) >= 0 ||
            getBodyRows(table).some((row) => findOriginator(utils.text(row), originators))) &&
          columnIndex(headers, ["interest", "rate", "interest rate", "annual interest", "apr"]) >= 0
        );
      });

      if (headerMatch) return headerMatch;

      return tables.find((table) => getBodyRows(table).some((row) => findOriginator(utils.text(row), originators)));
    };

    const extractPercentFromText = (value) => {
      const match = String(value || "").match(/-?\d+(?:[.,]\d+)?\s*%/);
      return match ? utils.parsePercent(match[0]) : 0;
    };

    const extractNumbersFromText = (value) =>
      Array.from(String(value || "").matchAll(/-?\d+(?:[.,]\d+)?/g)).map((match) => utils.parseNumber(match[0]));

    const parseCellNumber = (cells, index) => (index >= 0 ? utils.parseNumber(utils.text(cells[index])) : 0);

    const isPlausibleInvestmentAmount = (value) => value > 0 && value < 100000;

    const parseAmount = (cells, indexes, rowText, rate = 0, duration = 0) => {
      const indexed = parseCellNumber(cells, indexes.amount);
      if (isPlausibleInvestmentAmount(indexed)) return indexed;

      const rateAsDisplayNumber = rate ? Math.round(rate * 10000) / 100 : 0;
      const numbers = extractNumbersFromText(rowText).filter((value) => {
        if (!isPlausibleInvestmentAmount(value)) return false;
        if (duration && value === duration) return false;
        if (rateAsDisplayNumber && value === rateAsDisplayNumber) return false;
        if (Number.isInteger(value) && value >= 100000) return false;
        return true;
      });

      return numbers[numbers.length - 1] || 0;
    };

    const parseRate = (cells, indexes, rowText) => {
      if (indexes.rate >= 0) {
        const indexed = utils.parsePercent(utils.text(cells[indexes.rate]));
        if (indexed > 0) return indexed;
      }
      return extractPercentFromText(rowText);
    };

    const parseDuration = (cells, indexes, rowText, amount, rate) => {
      const indexed = parseCellNumber(cells, indexes.duration);
      if (indexed > 0) return indexed;

      const rateAsDisplayNumber = rate ? Math.round(rate * 10000) / 100 : 0;
      const numbers = extractNumbersFromText(rowText).filter(
        (value) => value > 0 && value !== amount && value !== rateAsDisplayNumber && value < 10000,
      );
      return numbers[numbers.length - 1] || 0;
    };

    const readLoans = (doc, originators) => {
      const table = getLoanTable(doc, originators);
      if (!table) return [];

      const headers = getHeaders(table);
      const indexes = {
        originator: columnIndex(headers, ["originator", "loan originator"]),
        type: columnIndex(headers, ["loan type", "type", "category"]),
        amount: columnIndex(headers, ["available", "available amount", "amount", "capital", "invest", "investment"], ["id", "agreement", "loan id"]),
        rate: columnIndex(headers, ["interest", "rate", "interest rate", "annual interest", "apr"]),
        duration: columnIndex(headers, ["remaining term", "term", "duration", "maturity", "period"]),
      };

      const bodyRows = getBodyRows(table);

      return bodyRows
        .map((row) => {
          const cells = Array.from(row.cells);
          const cellText = (index) => (index >= 0 ? utils.text(cells[index]) : "");
          const rowText = utils.text(row);
          const exactOriginator =
            findOriginator(rowText, originators) ||
            findOriginator(`${cellText(indexes.originator)} ${cellText(indexes.type)}`, originators);
          const rate = parseRate(cells, indexes, rowText);
          const duration = parseDuration(cells, indexes, rowText, 0, rate);
          const amount = parseAmount(cells, indexes, rowText, rate, duration);

          return {
            originator: exactOriginator,
            amount,
            rate,
            duration,
          };
        })
        .filter((loan) => loan.originator && loan.amount > 0);
    };

    const summarize = (loans, originators, analyzedPages) => {
      const groups = new Map(originators.map((originator) => [originator, []]));
      loans.forEach((loan) => {
        if (!groups.has(loan.originator)) groups.set(loan.originator, []);
        groups.get(loan.originator).push(loan);
      });

      const rows = Array.from(groups.entries())
        .map(([originator, items]) => {
          const rates = items.map((item) => item.rate).filter((value) => value > 0);
          const durations = items.map((item) => item.duration).filter((value) => value > 0);
          return {
            originator,
            loans: items.length,
            capital: items.reduce((sum, item) => sum + item.amount, 0),
            minRate: rates.length ? Math.min(...rates) : 0,
            maxRate: rates.length ? Math.max(...rates) : 0,
            minDuration: durations.length ? Math.min(...durations) : 0,
            maxDuration: durations.length ? Math.max(...durations) : 0,
          };
        })
        .filter((row) => row.loans > 0)
        .sort((a, b) => b.capital - a.capital);

      return {
        analyzedPages,
        rows,
        totalCapital: rows.reduce((sum, row) => sum + row.capital, 0),
        totalLoans: rows.reduce((sum, row) => sum + row.loans, 0),
        totalOriginators: rows.length,
      };
    };

    const formatRange = (min, max, formatter) => {
      if (!min && !max) return "-";
      if (min === max) return formatter(min);
      return `${formatter(min)} - ${formatter(max)}`;
    };

    const renderLoading = (current, total) => {
      const banner = getBanner();
      if (!banner) return;

      ensureStyles();
      banner.replaceChildren(
        create("section", { className: "hive5-plus-banner-panel" }, [
          create("div", { className: "hive5-plus-header" }, [
            create("h2", { className: "hive5-plus-title", text: "Hive5+" }),
          ]),
          create("p", {
            className: "hive5-plus-empty",
            text: `Analyse du marche disponible... ${current} / ${total} pages`,
          }),
        ]),
      );
    };

    const renderError = (payload = null) => {
      if (payload) render(payload, { stale: true });

      const banner = getBanner();
      if (!banner) return;

      const panel = banner.querySelector(`#${ROOT_ID}`) || banner.querySelector(".hive5-plus-banner-panel");
      const message = create("p", {
        className: "hive5-plus-empty",
        text: "Impossible d'analyser toutes les pages.",
      });
      if (panel) panel.appendChild(message);
      else banner.replaceChildren(create("section", { className: "hive5-plus-banner-panel" }, [message]));
    };

    const render = (payload, options = {}) => {
      const banner = getBanner();
      if (!banner) return;

      ensureStyles();
      const panel = create("section", { className: "hive5-plus-banner-panel" });
      panel.id = ROOT_ID;

      const button = create("button", { className: "hive5-plus-button", text: state.analyzing ? "Analyse..." : "Reanalyser" });
      button.type = "button";
      button.disabled = state.analyzing;
      button.addEventListener("click", () => analyze({ force: true }));

      panel.appendChild(
        create("div", { className: "hive5-plus-header" }, [
          create("h2", { className: "hive5-plus-title", text: "Hive5+" }),
          create("div", { className: "hive5-plus-header-actions" }, [
            options.stale ? create("span", { className: "hive5-plus-meta", text: "Dernier resultat connu" }) : null,
            button,
          ]),
        ]),
      );

      panel.appendChild(
        create("p", {
          className: "hive5-plus-summary-line",
          text: `${payload.totalLoans} prets • ${utils.formatCurrency(payload.totalCapital)} disponibles • ${payload.totalOriginators} originators • ${payload.analyzedPages} pages analysees`,
        }),
      );

      if (!payload.rows.length) {
        panel.appendChild(create("p", { className: "hive5-plus-empty", text: "Aucun pret disponible detecte." }));
      } else {
        const table = create("table", { className: "hive5-plus-table" });
        table.appendChild(
          create("thead", {}, [
            create("tr", {}, [
              create("th", { text: "Originator" }),
              create("th", { text: "Prets" }),
              create("th", { text: "Capital dispo" }),
              create("th", { text: "Taux min/max" }),
              create("th", { text: "Duree min/max" }),
            ]),
          ]),
        );

        const tbody = create("tbody");
        payload.rows.forEach((row) => {
          tbody.appendChild(
            create("tr", {}, [
              create("td", { text: row.originator }),
              create("td", { text: String(row.loans) }),
              create("td", { text: utils.formatCurrency(row.capital) }),
              create("td", { text: formatRange(row.minRate, row.maxRate, utils.formatPercent) }),
              create("td", { text: formatRange(row.minDuration, row.maxDuration, utils.formatDays) }),
            ]),
          );
        });
        table.appendChild(tbody);
        panel.appendChild(create("div", { className: "hive5-plus-table-wrap" }, [table]));
      }

      banner.replaceChildren(panel);
    };

    const fetchPage = async (page) => {
      if (page === (Number(new URL(window.location.href).searchParams.get("page")) || 1)) return document;

      const response = await fetch(pageUrl(page), {
        credentials: "include",
      });
      if (!response.ok) throw new Error(`Page ${page} returned ${response.status}`);

      const html = await response.text();
      return new DOMParser().parseFromString(html, "text/html");
    };

    const analyze = async ({ force = false } = {}) => {
      if (!isInvestmentPrimaryPage()) return;

      const banner = getBanner();
      if (!banner || state.analyzing) return;

      if (force) clearCache();

      const cached = getCache();
      if (!force && cached) {
        render(cached);
        return;
      }

      const originators = getOriginatorOptions();
      const totalPages = getTotalPages();
      const allLoans = [];
      state.analyzing = true;
      renderLoading(0, totalPages);

      try {
        for (let page = 1; page <= totalPages; page += 1) {
          renderLoading(page, totalPages);
          const doc = await fetchPage(page);
          allLoans.push(...readLoans(doc, originators));
        }

        const payload = summarize(allLoans, originators, totalPages);
        setCache(payload);
        state.analyzing = false;
        render(payload);
        setBadge(`marche analyse - ${payload.totalLoans} prets`);
      } catch (error) {
        console.warn("[Hive5+] Impossible d'analyser toutes les pages.", error);
        state.analyzing = false;
        renderError(cached);
      } finally {
        state.analyzing = false;
      }
    };

    const run = () => {
      if (!isInvestmentPrimaryPage()) return;

      const banner = getBanner();
      if (!banner) return;

      const cache = getCache();
      if (cache && !banner.querySelector(`#${ROOT_ID}`)) render(cache);

      const urlKey = `${window.location.pathname}${window.location.search}`;
      if (state.analyzedUrl === urlKey || state.analyzing) return;
      state.analyzedUrl = urlKey;
      analyze();
    };

    const diagnose = () => {
      const originators = getOriginatorOptions();
      const table = getLoanTable(document, originators);
      const rows = table ? getBodyRows(table) : [];
      const diagnostics = {
        href: window.location.href,
        isInvestmentPrimary: isInvestmentPrimaryPage(),
        bannerFound: Boolean(getBanner()),
        originators,
        totalPages: getTotalPages(),
        tableFound: Boolean(table),
        tableHeaders: table ? getHeaders(table) : [],
        rowCount: rows.length,
        sampleRows: rows.slice(0, 3).map((row) => utils.text(row)),
        parsedCurrentPageLoans: readLoans(document, originators).length,
        cached: Boolean(getCache()),
        analyzing: state.analyzing,
      };
      state.lastDiagnostics = diagnostics;
      return diagnostics;
    };

    return {
      id: MODULE_ID,
      description: "Replaces the Investment Primary banner with an informational market recap.",
      diagnose,
      run,
    };
  })();

  Hive5Plus.modules.investments = {
    id: "investments",
    description: "Reserved for future investment page enhancements.",
    run() {},
  };

  Hive5Plus.modules.review = {
    id: "review",
    description: "Reserved for future review workflow enhancements.",
    run() {},
  };

  Hive5Plus.modules.autoinvest = {
    id: "autoinvest",
    description: "Reserved for future autoinvest page enhancements.",
    run() {},
  };

  Hive5Plus.core = (() => {
    const runModules = () => {
      Object.values(Hive5Plus.modules).forEach((module) => {
        try {
          module.run();
        } catch (error) {
          console.warn(`[${Hive5Plus.meta.name}] Module failed: ${module.id}`, error);
        }
      });
    };

    const start = () => {
      runModules();

      const rerun = Hive5Plus.utils.debounce(runModules, 250);
      const observer = new MutationObserver(rerun);
      observer.observe(document.body, {
        childList: true,
        subtree: true,
      });

      window.addEventListener("popstate", rerun);
      window.addEventListener("hashchange", rerun);
    };

    return {
      start,
    };
  })();

  Object.assign(window.Hive5Plus, {
    diagnose() {
      const diagnostics = {};
      Object.values(Hive5Plus.modules).forEach((module) => {
        if (typeof module.diagnose === "function") diagnostics[module.id] = module.diagnose();
      });
      console.log("[Hive5+] diagnostics", diagnostics);
      console.table(diagnostics.investmentRecap || diagnostics.portfolio || diagnostics);
      return diagnostics;
    },
    rerun() {
      Object.values(Hive5Plus.modules).forEach((module) => module.run());
      return this.diagnose();
    },
    enableDebug() {
      window.localStorage.setItem("hive5plus:debug", "1");
      Hive5Plus.state.debug = true;
      console.info("[Hive5+] Debug enabled. Reload the page to see startup logs.");
    },
    disableDebug() {
      window.localStorage.removeItem("hive5plus:debug");
      Hive5Plus.state.debug = false;
      console.info("[Hive5+] Debug disabled.");
    },
  });

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", Hive5Plus.core.start, { once: true });
  } else {
    Hive5Plus.core.start();
  }
})();