Récemment, Musk a open-sourcé l’algorithme de recommandation de Twitter. Est-il possible de le mettre en œuvre dans ce plugin ?
Voici un script Tampermonkey écrit par un utilisateur de forum pour implémenter l’algorithme de recommandation de Twitter ; théoriquement, il est conçu pour fonctionner sur n’importe quel forum Discourse :
// ==UserScript==
// @name *** Algorithm
// @namespace http://tampermonkey.net/
// @version 0.9
// @description Système de recommandation intelligent combinant l'algorithme X et DeepSeek AI, offrant des recommandations de sujets personnalisées
// @author ***
// @match ****
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @connect 2c2ch1u11-share-api-0.hf.space
// ==/UserScript==
(function() {
'use strict';
const API_KEY = '***';
const API_URL = '***/v1/chat/completions';
const MODEL = 'deepseek-chat';
// ========== Paramètres de configuration de l'algorithme (ajustables pour optimisation) ==========
const CONFIG = {
// Paramètres de poids de l'algorithme X
WEIGHT_LIKES: 0.5,
WEIGHT_REPLIES: 13.5,
WEIGHT_VIEWS: 0.015,
PINNED_BOOST: 2.0,
TIME_DECAY_FACTOR: 1.5,
TIME_DECAY_OFFSET: 2,
// Paramètres de score maximum
X_SCORE_MAX: 45,
AI_SCORE_MAX: 55,
// Paramètres de seuil de score
MAX_SCORE: 100,
MIN_DISPLAY_SCORE: 20,
// Paramètres de cache
PROFILE_CACHE_TTL: 86400000, // Cache du profil utilisateur : 24 heures
TOPIC_SCORE_CACHE_TTL: 3600000, // Cache du score AI pour un sujet individuel : 1 heure
// Paramètres API
MAX_TOPICS_PER_BATCH: 30,
MAX_LIKED_TITLES: 15, // Nombre de sujets likés
MAX_REPLIED_TITLES: 10, // Nombre de sujets replyés
MAX_CREATED_TITLES: 5, // Nombre de sujets créés
API_RETRY_COUNT: 2,
API_RETRY_DELAY: 1000,
// Dimensions d'analyse du profil utilisateur
PROFILE_CATEGORIES: ['Intérêts techniques', 'Préférences de contenu', 'Habitudes d\'interaction', 'Domaines d\'expertise', 'Profondeur de lecture'],
};
let isRecommendMode = false;
let scoreMap = {};
let allTopicsData = {};
let sortTimeout = null;
let userProfile = "";
let isLoading = false;
let isProcessingNewTopics = false;
// ========== Styles CSS ==========
GM_addStyle(`
.nav-pills > li > a,
.nav-pills > li.ember-view > a,
.navigation-container .nav-pills > li > a {
border-bottom: 3px solid transparent !important;
transition: all 0.2s ease;
}
body.recommend-mode-active .nav-pills > li:not(#nav-item-recommend) > a,
body.recommend-mode-active .nav-pills > li.ember-view:not(#nav-item-recommend) > a,
body.recommend-mode-active .navigation-container .nav-pills > li:not(#nav-item-recommend) > a {
color: var(--primary-medium) !important;
border-bottom-color: transparent !important;
}
.nav-pills li#nav-item-recommend.active > a,
body.recommend-mode-active .nav-pills li#nav-item-recommend > a {
color: var(--tertiary) !important;
border-bottom: 3px solid var(--tertiary) !important;
font-weight: 600;
}
.nav-pills li#nav-item-recommend > a:hover {
color: var(--tertiary) !important;
opacity: 0.8;
}
.recommend-loading-container {
width: 100%;
padding: 60px 20px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 300px;
}
.recommend-loading-text {
color: var(--primary-medium);
font-size: 15px;
margin-bottom: 24px;
text-align: center;
}
.recommend-spinner {
width: 36px;
height: 36px;
border: 3px solid var(--primary-low);
border-top: 3px solid var(--primary-medium);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.manus-score-badge {
font-size: 0.75em;
color: var(--tertiary);
margin-left: 8px;
padding: 2px 6px;
background: var(--tertiary-very-low);
border-radius: 4px;
font-weight: 500;
transition: all 0.3s ease;
}
.manus-score-badge.calculating {
color: var(--primary-medium);
background: var(--primary-very-low);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.manus-score-loading {
font-size: 0.75em;
color: var(--primary-medium);
margin-left: 8px;
}
.recommend-full-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--secondary);
z-index: 100;
min-height: 500px;
}
.recommend-loading-row {
position: relative;
z-index: 101;
}
`);
// ========== Fonctions de cache des scores AI des sujets ==========
function getTopicScoreCache() {
return GM_getValue('topic_ai_scores_cache', {});
}
function setTopicScoreCache(topicId, aiScore) {
const cache = getTopicScoreCache();
cache[topicId] = { score: aiScore, time: Date.now() };
GM_setValue('topic_ai_scores_cache', cache);
}
function getCachedTopicScore(topicId) {
const cache = getTopicScoreCache();
const entry = cache[topicId];
if (entry && (Date.now() - entry.time < CONFIG.TOPIC_SCORE_CACHE_TTL)) {
return entry.score;
}
return null;
}
function cleanExpiredCache() {
const cache = getTopicScoreCache();
const now = Date.now();
let cleaned = false;
for (const [id, entry] of Object.entries(cache)) {
if (now - entry.time > CONFIG.TOPIC_SCORE_CACHE_TTL) {
delete cache[id];
cleaned = true;
}
}
if (cleaned) {
GM_setValue('topic_ai_scores_cache', cache);
}
}
// ========== Injection de l'onglet de recommandation ==========
function injectRecommendTab() {
if (document.querySelector('#nav-item-recommend')) return;
const navPills = document.querySelector('.nav-pills');
if (!navPills) return;
const recommendTab = document.createElement('li');
recommendTab.id = 'nav-item-recommend';
recommendTab.className = 'ember-view nav-item_recommend';
recommendTab.innerHTML = `<a href="javascript:void(0)">Recommandé</a>`;
const latestTab = navPills.querySelector('.nav-item_latest') ||
navPills.querySelector('[data-filter-type="latest"]')?.parentElement ||
navPills.firstChild;
navPills.insertBefore(recommendTab, latestTab);
recommendTab.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
if (isLoading) return;
isRecommendMode = true;
document.body.classList.add('recommend-mode-active');
navPills.querySelectorAll('li').forEach(li => {
li.classList.remove('active');
li.querySelector('a')?.classList.remove('active');
});
recommendTab.classList.add('active');
cleanExpiredCache();
showLoading(true);
await startRecommendation();
showLoading(false);
sortAndDisplayRows();
});
navPills.querySelectorAll('li:not(#nav-item-recommend)').forEach(tab => {
tab.addEventListener('click', () => {
isRecommendMode = false;
document.body.classList.remove('recommend-mode-active');
recommendTab.classList.remove('active');
document.querySelectorAll('.manus-score-badge').forEach(b => b.remove());
document.querySelectorAll('.manus-score-loading').forEach(b => b.remove());
});
});
window.addEventListener('popstate', () => {
if (isRecommendMode) {
isRecommendMode = false;
document.body.classList.remove('recommend-mode-active');
recommendTab.classList.remove('active');
}
});
}
// ========== Indicateur de chargement ==========
let originalTableContent = null;
function showLoading(show, text = 'Fusion de l\'algorithme X et des recommandations DeepSeek AI en cours...') {
isLoading = show;
const topicListContainer = document.querySelector('.topic-list-container') ||
document.querySelector('.topic-list')?.parentElement ||
document.querySelector('.topic-list');
const tbody = document.querySelector('.topic-list tbody');
if (!topicListContainer) return;
if (show) {
if (!originalTableContent && tbody) {
originalTableContent = tbody.innerHTML;
}
if (tbody) {
tbody.innerHTML = `
<tr class="recommend-loading-row">
<td colspan="100%">
<div class="recommend-loading-container">
<div class="recommend-loading-text">${text}</div>
<div class="recommend-spinner"></div>
</div>
</td>
</tr>
`;
}
topicListContainer.style.position = 'relative';
let overlay = topicListContainer.querySelector('.recommend-full-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'recommend-full-overlay';
topicListContainer.appendChild(overlay);
}
} else {
topicListContainer.querySelector('.recommend-full-overlay')?.remove();
if (originalTableContent && tbody) {
tbody.innerHTML = originalTableContent;
originalTableContent = null;
}
}
}
// ========== Fonction auxiliaire pour extraire les titres ==========
function extractTitles(data, maxCount) {
if (!data?.user_actions) return '';
const titles = [...new Set(
data.user_actions
.filter(a => a.title)
.map(a => a.title)
)].slice(0, maxCount);
return titles.length > 0 ? titles.join('\n') : '';
}
// ========== Optimisation : Récupération plus détaillée du profil utilisateur ==========
async function fetchUserProfile(username) {
if (!username) {
console.log('[Recommandation] Non connecté, utilisation du profil par défaut');
return "Par défaut";
}
const cacheKey = `user_profile_v9_${username}`;
const cached = GM_getValue(cacheKey);
if (cached && (Date.now() - cached.time < CONFIG.PROFILE_CACHE_TTL)) {
console.log(`[Recommandation] Profil utilisateur (cache) :\n${cached.profile}`);
return cached.profile;
}
try {
// Récupération de diverses données de comportement utilisateur
const [likedData, repliedData, createdData] = await Promise.all([
fetch(`/user_actions.json?username=${username}&filter=1`).then(r => r.json()).catch(() => null), // J'aime
fetch(`/user_actions.json?username=${username}&filter=5`).then(r => r.json()).catch(() => null), // Réponses
fetch(`/user_actions.json?username=${username}&filter=4`).then(r => r.json()).catch(() => null), // Création
]);
// Extraction des différents types de titres
const likedTitles = extractTitles(likedData, CONFIG.MAX_LIKED_TITLES);
const repliedTitles = extractTitles(repliedData, CONFIG.MAX_REPLIED_TITLES);
const createdTitles = extractTitles(createdData, CONFIG.MAX_CREATED_TITLES);
if (!likedTitles && !repliedTitles && !createdTitles) return "Par défaut";
// Construction d'un prompt d'analyse plus détaillé
const prompt = `Vous êtes un analyste professionnel du comportement utilisateur. Veuillez générer un profil utilisateur détaillé basé sur les données de comportement suivantes :
**Sujets aimés par l'utilisateur** (reflètent le mieux les intérêts) :
${likedTitles || 'Pas de données'}
**Sujets auxquels l'utilisateur a répondu** (reflètent l'engagement et le domaine d'expertise) :
${repliedTitles || 'Pas de données'}
**Sujets créés par l'utilisateur** (reflètent les points d'attention actifs) :
${createdTitles || 'Pas de données'}
Veuillez analyser le profil utilisateur selon les dimensions suivantes, en résumant chaque dimension en une phrase :
1. **Intérêts techniques** : Quelles stacks techniques, outils ou plateformes l'utilisateur suit-il
2. **Préférences de contenu** : Préfère-t-il les tutoriels, les discussions, les actualités ou les questions/réponses
3. **Habitudes d'interaction** : Est-il un participant profond ou un simple navigateur
4. **Domaine d'expertise** : Le domaine technique ou sectoriel principal sur lequel il se concentre
5. **Profondeur de lecture** : Préfère-t-il le niveau débutant, intermédiaire ou expert
Format de sortie (pas de numéros, sortie directe de 5 lignes) :
Intérêts techniques : [Résultat de l'analyse]
Préférences de contenu : [Résultat de l'analyse]
Habitudes d'interaction : [Résultat de l'analyse]
Domaine d'expertise : [Résultat de l'analyse]
Profondeur de lecture : [Résultat de l'analyse]`;
const profile = await callDeepSeek(prompt, false);
if (profile && profile.length > 0) {
GM_setValue(cacheKey, { time: Date.now(), profile: profile });
console.log(`[Recommandation] Profil utilisateur :\n${profile}`);
return profile;
}
return "Par défaut";
} catch (e) {
console.error('[Recommandation] Échec de la récupération du profil utilisateur :', e);
return "Par défaut";
}
}
// ========== Démarrage du processus de recommandation ==========
async function startRecommendation() {
try {
const currentUser = getCurrentUserInfo();
if (!userProfile) {
userProfile = await fetchUserProfile(currentUser?.username);
}
const response = await fetch('/latest.json');
const data = await response.json();
const topics = data.topic_list.topics;
topics.forEach(t => {
allTopicsData[t.id] = t;
});
const uncachedTopics = [];
const xScoresRaw = {};
const aiScoresMap = {};
topics.forEach(topic => {
xScoresRaw[topic.id] = calculateXScore(topic);
const cachedScore = getCachedTopicScore(topic.id);
if (cachedScore !== null) {
aiScoresMap[topic.id] = cachedScore;
} else {
uncachedTopics.push(topic);
}
});
if (uncachedTopics.length > 0) {
await batchScoreTopics(uncachedTopics, aiScoresMap);
}
calculateFinalScores(topics, xScoresRaw, aiScoresMap);
} catch (e) {
console.error('[Recommandation] Échec du processus de recommandation :', e);
}
}
// ========== Notation par lots des sujets ==========
async function batchScoreTopics(topics, aiScoresMap) {
for (let i = 0; i < topics.length; i += CONFIG.MAX_TOPICS_PER_BATCH) {
const batch = topics.slice(i, i + CONFIG.MAX_TOPICS_PER_BATCH);
const aiScores = await getAIScoresForBatch(batch);
batch.forEach(topic => {
const aiScore = aiScores[topic.id] || 5;
setTopicScoreCache(topic.id, aiScore);
aiScoresMap[topic.id] = aiScore;
});
}
}
// ========== Optimisation : Notation par lots des sujets plus précise ==========
async function getAIScoresForBatch(topics) {
const topicList = topics.map((t, idx) =>
`${idx + 1}. [ID:${t.id}] ${t.title}`
).join('\n');
const prompt = `Vous êtes un expert en recommandation de contenu. Veuillez noter les sujets suivants en fonction du profil utilisateur.
**Profil utilisateur** :
${userProfile}
**Sujets à noter** :
${topicList}
**Critères de notation** (0-10 points) :
- **9-10 points** : Correspondance élevée avec le profil utilisateur, l'utilisateur est très susceptible d'être intéressé
- **7-8 points** : Correspondance assez bonne avec le profil utilisateur, l'utilisateur est probablement intéressé
- **5-6 points** : Correspondance partielle avec le profil utilisateur, l'utilisateur pourrait être intéressé
- **3-4 points** : Liaison faible avec le profil utilisateur, l'utilisateur n'est probablement pas intéressé
- **0-2 points** : Sans rapport ou opposé au profil utilisateur, l'utilisateur n'est pas intéressé
**Facteurs de notation** :
1. Correspondance entre le contenu du sujet et les intérêts techniques de l'utilisateur
2. Cohérence entre le type de sujet et les préférences de contenu de l'utilisateur
3. Adaptabilité entre la profondeur du sujet et la profondeur de lecture de l'utilisateur
4. Pertinence entre le domaine du sujet et le domaine d'expertise de l'utilisateur
Veuillez retourner au format JSON, la clé étant l'ID du sujet (nombre pur) et la valeur étant la note (nombre de 0 à 10).
Exemple : {"123": 8.5, "456": 5.0}
Retournez uniquement le JSON, pas d'autre texte.`;
const scores = await callDeepSeek(prompt, true);
// Nettoyage et validation des notes
const cleanedScores = {};
for (const [id, score] of Object.entries(scores)) {
const numId = parseInt(id, 10);
const numScore = parseFloat(score);
if (!isNaN(numId) && !isNaN(numScore) && numScore >= 0 && numScore <= 10) {
cleanedScores[numId] = Math.round(numScore * 10) / 10;
}
}
return cleanedScores;
}
// ========== Optimisation : Calcul de note finale plus robuste ==========
function calculateFinalScores(topics, xScoresRaw, aiScoresMap) {
const xScores = Object.values(xScoresRaw);
if (xScores.length === 0) return;
// Utilisation de la normalisation par quantiles pour éviter l'influence des valeurs extrêmes
const sortedX = [...xScores].sort((a, b) => a - b);
const p5 = sortedX[Math.floor(sortedX.length * 0.05)]; // Quantile 5%
const p95 = sortedX[Math.floor(sortedX.length * 0.95)]; // Quantile 95%
const rangeX = p95 - p5;
topics.forEach(topic => {
const id = topic.id;
let xScoreNormalized;
if (rangeX === 0) {
xScoreNormalized = CONFIG.X_SCORE_MAX / 2;
} else {
// Limiter entre p5 et p95
const clampedX = Math.max(p5, Math.min(p95, xScoresRaw[id]));
xScoreNormalized = ((clampedX - p5) / rangeX) * CONFIG.X_SCORE_MAX;
}
const aiScore = aiScoresMap[id] || 5;
const aiScoreNormalized = (aiScore / 10) * CONFIG.AI_SCORE_MAX;
// Note finale : Algorithme X (45%) + Note IA (55%)
const finalScore = xScoreNormalized + aiScoreNormalized;
scoreMap[id] = Math.round(finalScore * 10) / 10;
});
}
// ========== Calcul de la note finale pour les nouveaux sujets ==========
function calculateFinalScoresForNew(topicIds, xScoresRaw, aiScoresMap) {
const allXScores = Object.values(scoreMap).length > 0
? [...Object.values(xScoresRaw), ...Object.keys(allTopicsData).map(id => calculateXScore(allTopicsData[id]))]
: Object.values(xScoresRaw);
if (allXScores.length === 0) return;
const sortedX = [...allXScores].sort((a, b) => a - b);
const p5 = sortedX[Math.floor(sortedX.length * 0.05)];
const p95 = sortedX[Math.floor(sortedX.length * 0.95)];
const rangeX = p95 - p5;
topicIds.forEach(id => {
if (xScoresRaw[id] === undefined) return;
let xScoreNormalized;
if (rangeX === 0) {
xScoreNormalized = CONFIG.X_SCORE_MAX / 2;
} else {
const clampedX = Math.max(p5, Math.min(p95, xScoresRaw[id]));
xScoreNormalized = ((clampedX - p5) / rangeX) * CONFIG.X_SCORE_MAX;
}
const aiScore = aiScoresMap[id] || 5;
const aiScoreNormalized = (aiScore / 10) * CONFIG.AI_SCORE_MAX;
const finalScore = xScoreNormalized + aiScoreNormalized;
scoreMap[id] = Math.round(finalScore * 10) / 10;
});
}
// ========== Traitement des nouveaux sujets chargés (chargement par défilement) ==========
async function processNewTopics(newRows) {
if (!isRecommendMode || isProcessingNewTopics) return;
isProcessingNewTopics = true;
try {
const newTopicIds = [];
newRows.forEach(row => {
const id = row.getAttribute('data-topic-id');
if (id && !scoreMap[id]) {
newTopicIds.push(id);
}
});
if (newTopicIds.length === 0) {
isProcessingNewTopics = false;
return;
}
newTopicIds.forEach(id => {
const row = document.querySelector(`tr[data-topic-id="${id}"]`);
if (row) {
addCalculatingBadge(row);
}
});
const uncachedTopics = [];
const xScoresRaw = {};
const aiScoresMap = {};
for (const id of newTopicIds) {
const topicData = allTopicsData[id] || await fetchTopicData(id);
if (!topicData) continue;
allTopicsData[id] = topicData;
xScoresRaw[id] = calculateXScore(topicData);
const cachedScore = getCachedTopicScore(id);
if (cachedScore !== null) {
aiScoresMap[id] = cachedScore;
} else {
uncachedTopics.push(topicData);
}
}
if (uncachedTopics.length > 0) {
await batchScoreTopics(uncachedTopics, aiScoresMap);
}
calculateFinalScoresForNew(newTopicIds, xScoresRaw, aiScoresMap);
updateRowBadges();
} catch (e) {
console.error('[Recommandation] Échec du traitement des nouveaux sujets :', e);
}
isProcessingNewTopics = false;
}
// ========== Récupération des données d'un seul sujet ==========
async function fetchTopicData(topicId) {
try {
const row = document.querySelector(`tr[data-topic-id="${topicId}"]`);
if (row) {
const title = row.querySelector('.title')?.textContent?.trim() || '';
const replies = parseInt(row.querySelector('.posts')?.textContent) || 0;
const views = parseInt(row.querySelector('.views')?.textContent?.replace(/[k,]/gi, '')) || 0;
const likes = parseInt(row.querySelector('.likes')?.textContent) || 0;
return {
id: topicId,
title: title,
posts_count: replies,
views: views,
like_count: likes,
created_at: new Date().toISOString(),
pinned: row.classList.contains('pinned')
};
}
return null;
} catch (e) {
return null;
}
}
// ========== Tri et affichage ==========
function sortAndDisplayRows() {
if (!isRecommendMode) return;
const tbody = document.querySelector('.topic-list tbody');
if (!tbody) return;
const rows = Array.from(tbody.querySelectorAll('tr.topic-list-item'));
rows.sort((a, b) => {
const idA = a.getAttribute('data-topic-id');
const idB = b.getAttribute('data-topic-id');
const scoreA = scoreMap[idA] || 0;
const scoreB = scoreMap[idB] || 0;
return scoreB - scoreA;
});
let hiddenCount = 0;
rows.forEach(row => {
const id = row.getAttribute('data-topic-id');
const score = scoreMap[id];
if (score === undefined) {
row.style.display = '';
addCalculatingBadge(row);
} else if (score < CONFIG.MIN_DISPLAY_SCORE) {
row.style.display = 'none';
hiddenCount++;
} else {
row.style.display = '';
addScoreBadge(row);
}
tbody.appendChild(row);
});
}
// ========== Mise à jour de tous les badges des lignes ==========
function updateRowBadges() {
if (!isRecommendMode) return;
const rows = document.querySelectorAll('tr.topic-list-item');
rows.forEach(row => {
const id = row.getAttribute('data-topic-id');
const score = scoreMap[id];
if (score === undefined) {
addCalculatingBadge(row);
} else if (score < CONFIG.MIN_DISPLAY_SCORE) {
row.style.display = 'none';
} else {
row.style.display = '';
addScoreBadge(row);
}
});
}
// ========== Ajout du badge de note ==========
function addScoreBadge(row) {
const id = row.getAttribute('data-topic-id');
const score = scoreMap[id];
row.querySelector('.manus-score-loading')?.remove();
if (score !== undefined) {
let badge = row.querySelector('.manus-score-badge');
if (!badge) {
badge = document.createElement('span');
badge.className = 'manus-score-badge';
const titleContainer = row.querySelector('.link-top-line') ||
row.querySelector('.title')?.parentElement ||
row.querySelector('.main-link') ||
row.querySelector('td:first-child');
if (titleContainer) {
titleContainer.appendChild(badge);
}
}
if (badge) {
badge.textContent = `🔥 ${score.toFixed(1)}`;
badge.classList.remove('calculating');
}
}
}
// ========== Ajout du badge "Calcul en cours" ==========
function addCalculatingBadge(row) {
const id = row.getAttribute('data-topic-id');
if (scoreMap[id] !== undefined) return;
let badge = row.querySelector('.manus-score-badge');
if (!badge) {
badge = document.createElement('span');
badge.className = 'manus-score-badge calculating';
const titleContainer = row.querySelector('.link-top-line') ||
row.querySelector('.title')?.parentElement ||
row.querySelector('.main-link') ||
row.querySelector('td:first-child');
if (titleContainer) {
titleContainer.appendChild(badge);
}
}
if (badge) {
badge.textContent = '⏳ Calcul en cours...';
badge.classList.add('calculating');
}
}
// ========== Optimisation : Amélioration de la logique de nettoyage JSON ==========
function cleanJsonResponse(content) {
if (!content) return content;
let cleaned = content.trim();
// Suppression des marqueurs de blocs de code Markdown
cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '');
// Suppression des éventuelles explications textuelles
const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
if (jsonMatch) {
cleaned = jsonMatch[0];
}
return cleaned.trim();
}
// ========== Appel API ==========
async function callDeepSeek(prompt, isJson = true, retryCount = 0) {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: "POST",
url: API_URL,
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${API_KEY}`
},
data: JSON.stringify({
model: MODEL,
messages: [{ role: "user", content: prompt }],
temperature: 0.3,
max_tokens: 1000
}),
timeout: 30000,
onload: (res) => {
try {
if (res.status >= 200 && res.status < 300) {
let content = JSON.parse(res.responseText).choices[0].message.content;
if (isJson) {
content = cleanJsonResponse(content);
resolve(JSON.parse(content));
} else {
resolve(content);
}
} else {
throw new Error(`HTTP ${res.status}`);
}
} catch(e) {
console.error('[Recommandation] Échec de l'analyse de la réponse API :', e);
if (retryCount < CONFIG.API_RETRY_COUNT) {
setTimeout(() => {
callDeepSeek(prompt, isJson, retryCount + 1).then(resolve);
}, CONFIG.API_RETRY_DELAY);
} else {
resolve(isJson ? {} : "");
}
}
},
onerror: (err) => {
console.error('[Recommandation] Échec de l'appel API :', err);
if (retryCount < CONFIG.API_RETRY_COUNT) {
setTimeout(() => {
callDeepSeek(prompt, isJson, retryCount + 1).then(resolve);
}, CONFIG.API_RETRY_DELAY);
} else {
resolve(isJson ? {} : "");
}
},
ontimeout: () => {
console.error('[Recommandation] Timeout de l'appel API');
if (retryCount < CONFIG.API_RETRY_COUNT) {
setTimeout(() => {
callDeepSeek(prompt, isJson, retryCount + 1).then(resolve);
}, CONFIG.API_RETRY_DELAY);
} else {
resolve(isJson ? {} : "");
}
}
});
});
}
// ========== Optimisation : Algorithme X de notation plus intelligent ==========
function calculateXScore(topic) {
const now = new Date();
const created = new Date(topic.created_at);
const hoursOld = Math.max(0, (now - created) / (1000 * 60 * 60));
// Note d'interaction de base
const likeScore = (topic.like_count || 0) * CONFIG.WEIGHT_LIKES;
const replyScore = (topic.posts_count || 0) * CONFIG.WEIGHT_REPLIES;
const viewScore = (topic.views || 0) * CONFIG.WEIGHT_VIEWS;
// Calcul du bonus de taux d'engagement (un taux d'engagement élevé indique une meilleure qualité de contenu)
const views = topic.views || 1;
const engagementRate = ((topic.like_count || 0) + (topic.posts_count || 0)) / views;
const engagementBoost = 1 + Math.min(engagementRate * 10, 2); // Bonus maximal de 2x
let score = (likeScore + replyScore + viewScore) * engagementBoost;
// Bonus de fixation
if (topic.pinned) {
score *= CONFIG.PINNED_BOOST;
}
// Décroissance temporelle (fraîcheur)
const timeFactor = Math.pow(hoursOld + CONFIG.TIME_DECAY_OFFSET, CONFIG.TIME_DECAY_FACTOR);
return score / timeFactor;
}
// ========== Récupération des informations de l'utilisateur actuel ==========
function getCurrentUserInfo() {
try {
// Méthode 1 : Conteneur Discourse
const container = window.Discourse?.__container__;
if (container) {
const currentUser = container.lookup('service:current-user');
if (currentUser?.username) {
return {
username: currentUser.username,
id: currentUser.id,
name: currentUser.name,
trust_level: currentUser.trust_level
};
}
}
// Méthode 2 : User.current()
if (window.User?.current?.()?.username) {
const user = window.User.current();
return { username: user.username, id: user.id };
}
// Méthode 3 : #current-user
const userLink = document.querySelector('#current-user a[data-user-card]');
if (userLink) {
return { username: userLink.getAttribute('data-user-card') };
}
// Méthode 4 : header
const headerUser = document.querySelector('.header-dropdown-toggle.current-user button');
if (headerUser) {
const img = headerUser.querySelector('img');
if (img?.alt) {
return { username: img.alt };
}
}
// Méthode 5 : d-header
const anyUserCard = document.querySelector('.d-header [data-user-card]');
if (anyUserCard) {
return { username: anyUserCard.getAttribute('data-user-card') };
}
// Méthode 6 : données preload
const preloadData = document.querySelector('#data-preloaded');
if (preloadData) {
try {
const data = JSON.parse(preloadData.dataset.preloaded || '{}');
const currentUser = JSON.parse(data.currentUser || '{}');
if (currentUser.username) {
return { username: currentUser.username };
}
} catch (e) {}
}
return null;
} catch (e) {
return null;
}
}
// ========== Écoute des changements DOM ==========
const listObserver = new MutationObserver((mutations) => {
if (!isRecommendMode || isLoading) return;
const newRows = [];
mutations.forEach(m => {
m.addedNodes.forEach(node => {
if (node.nodeType === 1) {
if (node.classList?.contains('topic-list-item')) {
newRows.push(node);
} else if (node.querySelectorAll) {
node.querySelectorAll('.topic-list-item').forEach(row => newRows.push(row));
}
}
});
});
if (newRows.length > 0) {
newRows.forEach(row => {
const id = row.getAttribute('data-topic-id');
if (id && scoreMap[id] === undefined) {
addCalculatingBadge(row);
} else if (scoreMap[id] !== undefined) {
if (scoreMap[id] >= CONFIG.MIN_DISPLAY_SCORE) {
addScoreBadge(row);
} else {
row.style.display = 'none';
}
}
});
clearTimeout(sortTimeout);
sortTimeout = setTimeout(() => processNewTopics(newRows), 500);
}
});
const navObserver = new MutationObserver(() => {
injectRecommendTab();
const tbody = document.querySelector('.topic-list tbody');
if (tbody && !tbody.dataset.observing) {
tbody.dataset.observing = 'true';
listObserver.observe(tbody, { childList: true });
}
});
// ========== Démarrage du script ==========
navObserver.observe(document.body, { childList: true, subtree: true });
injectRecommendTab();
console.log('[Système de recommandation] Chargé - Fusion de l\'algorithme X et de DeepSeek AI');
})();
Voici le résultat sur le forum Discourse :

