Greasy Fork is available in English.

P2P+ | Hive5+

Enhances Hive5 with advanced analytics.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 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();
  }
})();