Enhances Hive5 with advanced analytics.
// ==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();
}
})();