P2P+ | Hive5+

Enhances Hive5 with advanced analytics.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

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();
  }
})();