// ==UserScript== // @name ヤフコメ ユーザー評価表示 // @namespace https://github.com/zszushi/YahooCommentRatio // @version 2.1.1 // @description Yahoo!ニュースのコメント欄にユーザーの評価(共感した/なるほど/うーん)を表示 // @author zszushi, Google Antigravity // @match https://news.yahoo.co.jp/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @connect news.yahoo.co.jp // @license MIT // @run-at document-idle // @downloadURL https://update.greasyfork.org/scripts/563665/%E3%83%A4%E3%83%95%E3%82%B3%E3%83%A1%20%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E8%A9%95%E4%BE%A1%E8%A1%A8%E7%A4%BA.user.js // @updateURL https://update.greasyfork.org/scripts/563665/%E3%83%A4%E3%83%95%E3%82%B3%E3%83%A1%20%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E8%A9%95%E4%BE%A1%E8%A1%A8%E7%A4%BA.meta.js // ==/UserScript== (function () { "use strict"; const defaultSettings = { showSympathized: true, showUnderstood: true, showHmm: true, showPercentage: true, showBar: true, showCommentCount: true, barHeight: 3, barWidth: 60, enableCache: true, fetchInterval: 30000, initialDelay: 100, showTotal: false, compactMode: false, enableHideComments: false, hideThreshold: 40, hideMinTotal: 10, enableRateLimit: true, rateLimitDelay: 1000, // エキスパート用設定 showExpertHelpful: true, showExpertCommentCount: true, showExpertArticleCount: true, enableBackgroundColor: true, bgColorBasis: "account", // "account" or "comment" roundTotal: false, hideBasis: "account", // "account", "comment", or "either" enableTurboMode: false, showCommentBar: true, // 個別コメント評価バー(デフォルト有効) }; /** * 評価実績から比率と色を計算する */ function calculateRating(pos, neg) { const total = pos + neg; const rate = total > 0 ? Math.round((pos / total) * 100) : -1; let color = "#ccc"; if (rate >= 0) { if (rate >= 50) { const ratio = (rate - 50) / 50; const r = Math.round(255 - (255 - 76) * ratio); const g = Math.round(193 + (175 - 193) * ratio); const b = Math.round(7 + (80 - 7) * ratio); color = `rgb(${r}, ${g}, ${b})`; } else { const ratio = rate / 50; const r = 244; const g = Math.round(67 + (193 - 67) * ratio); const b = Math.round(54 + (7 - 54) * ratio); color = `rgb(${r}, ${g}, ${b})`; } } return { rate, color }; } /** * 比率に基づく色を取得する (フラット版) */ function getRatingColor(rate) { const { color } = calculateRating(rate, 100 - rate); return color; } /** * 評価バーの要素を作成する */ function createBarElement(rate, color, height = "6px", margin = "0") { const container = document.createElement("div"); container.style.cssText = ` height: ${height}; background: #bbbbbb; /* コントラスト向上のためさらに濃く変更 */ margin: ${margin}; border-radius: ${parseInt(height)}px; overflow: hidden; width: 100%; `; const bar = document.createElement("div"); // フラットな色と統一された太さ bar.style.cssText = ` height: 100%; width: ${rate}%; background: ${color}; border-radius: ${parseInt(height)}px; transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1); `; container.appendChild(bar); return container; } let settings = { ...defaultSettings }; let updateInterval = null; // eslint-disable-line no-unused-vars let hasUnsavedChanges = false; const userRatingsCache = new Map(); const processedElements = new Set(); const fetchQueue = []; let isFetching = false; function loadSettings() { Object.keys(defaultSettings).forEach((key) => { const value = GM_getValue(key); if (value !== undefined) { settings[key] = value; } }); } function saveSettings() { Object.keys(settings).forEach((key) => { GM_setValue(key, settings[key]); }); } function exportSettings() { const settingsJson = JSON.stringify(settings, null, 2); const blob = new Blob([settingsJson], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "yahoo-rating-settings.json"; a.click(); URL.revokeObjectURL(url); } function importSettings() { const input = document.createElement("input"); input.type = "file"; input.accept = "application/json"; input.onchange = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { try { const imported = JSON.parse(event.target.result); Object.keys(defaultSettings).forEach((key) => { if (imported[key] !== undefined) { settings[key] = imported[key]; } }); saveSettings(); alert("設定をインポートしました。ページを再読み込みします。"); location.reload(); } catch (err) { alert("設定ファイルの読み込みに失敗しました: " + err.message); } }; reader.readAsText(file); }; input.click(); } function parseNumber(text) { const match = text.match(/([\d.]+)万/); if (match) { return Math.round(parseFloat(match[1]) * 10000); } return parseInt(text.replace(/,/g, "")); } function formatNumber(num, keepManjuIfParsed = false) { if (keepManjuIfParsed && num >= 10000) { return (num / 10000).toFixed(1) + "万"; } return num.toLocaleString("ja-JP"); } async function processFetchQueue() { if (isFetching || fetchQueue.length === 0) return; isFetching = true; while (fetchQueue.length > 0) { const { userId, resolve } = fetchQueue.shift(); try { let result; if (userId.startsWith("expert_")) { const actualUserId = userId.replace("expert_", ""); result = await fetchExpertRatingInternal(actualUserId); } else { result = await fetchUserRatingInternal(userId); } resolve(result); } catch (err) { resolve({ sympathized: 0, understood: 0, hmm: 0, commentCount: 0, articleCount: 0, isExpert: userId.startsWith("expert_"), helpfulCount: 0, }); } if (settings.enableRateLimit && fetchQueue.length > 0 && !settings.enableTurboMode) { await new Promise((r) => setTimeout(r, settings.rateLimitDelay)); } } isFetching = false; } async function fetchUserRating(userId) { if (settings.enableCache && userRatingsCache.has(userId)) { return userRatingsCache.get(userId); } return new Promise((resolve) => { fetchQueue.push({ userId, resolve }); processFetchQueue(); }); } async function fetchExpertRating(userId) { const cacheKey = `expert_${userId}`; if (settings.enableCache && userRatingsCache.has(cacheKey)) { return userRatingsCache.get(cacheKey); } return new Promise((resolve) => { fetchQueue.push({ userId: cacheKey, resolve }); processFetchQueue(); }); } async function fetchUserRatingInternal(userId) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: "GET", url: `https://news.yahoo.co.jp/users/${userId}`, onload: function (response) { try { const parser = new DOMParser(); const doc = parser.parseFromString( response.responseText, "text/html", ); const ratings = { sympathized: 0, understood: 0, hmm: 0, commentCount: 0, isExpert: false, helpfulCount: 0, }; const statsText = doc.body.textContent; let dataFound = false; // 優先度1: __PRELOADED_STATE__ (JSON) const script = Array.from(doc.querySelectorAll("script")).find((s) => (s.textContent || s.innerText || "").includes("__PRELOADED_STATE__"), ); const scriptText = script ? (script.textContent || script.innerText || "") : ""; if (scriptText) { try { const jsonMatch = scriptText.match(/__PRELOADED_STATE__\s*=\s*(\{[\s\S]*?\})(?:;|\n|$)/); if (jsonMatch) { const state = JSON.parse(jsonMatch[1]); const detail = state.profileDetail; if (detail) { ratings.sympathized = detail.totalEmpathyCount || 0; ratings.understood = detail.totalInsightCount || detail.totalGoodCount || 0; ratings.hmm = detail.totalNegativeCount || detail.totalBadCount || 0; ratings.commentCount = detail.totalCommentCount || 0; dataFound = true; } } } catch (e) { // 失敗時は続行 } } if (!dataFound) { // DOMベースのパース: 特定のラベルを持つ要素を探索して数値を取得 const findNumByLabel = (label) => { const elements = Array.from(doc.querySelectorAll("span, div, b, strong")); const labelEl = elements.find((el) => el.textContent.trim() === label); if (!labelEl) return 0; // 探索範囲: 隣接要素、親の隣接、または同じコンテナ内の要素 const searchArea = [ labelEl.nextElementSibling, labelEl.parentElement?.nextElementSibling, labelEl.parentElement?.querySelector("span:last-child, b, strong, [class*='count']"), // labelEl.parentElement, // 危険: コンテナ全体のテキスト(PV数など)を拾う可能性があるため廃止 ]; for (const area of searchArea) { if (!area) continue; const text = area.textContent.trim(); // 30文字以上はプロフィールの自己紹介文などの可能性が高いため無視(統計数値は短い) if (text.length > 30) continue; const val = parseNumber(text); if (!isNaN(val) && val > 0) return val; } return 0; }; ratings.sympathized = findNumByLabel("共感した"); ratings.understood = findNumByLabel("なるほど"); ratings.hmm = findNumByLabel("うーん"); ratings.commentCount = findNumByLabel("投稿コメント") || findNumByLabel("コメント"); // フォールバック1: 正規表現 if (ratings.sympathized === 0) { const findMatch = (pattern) => { const m = statsText.match(pattern); return m ? parseNumber(m[1]) : 0; }; ratings.sympathized = findMatch(/共感した\s*([\d.万,]+)/); ratings.understood = findMatch(/なるほど\s*([\d.万,]+)/); ratings.hmm = findMatch(/うーん\s*([\d.万,]+)/); ratings.commentCount = ratings.commentCount || findMatch(/(?:投稿)?コメント\s*([\d.万,]+)/); } } if (settings.enableCache) { userRatingsCache.set(userId, ratings); } resolve(ratings); } catch (e) { resolve({ sympathized: 0, understood: 0, hmm: 0, commentCount: 0, isExpert: false }); } }, onerror: function () { resolve({ sympathized: 0, understood: 0, hmm: 0, commentCount: 0, isExpert: false }); }, }); }); } async function fetchExpertRatingInternal(userId) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: "GET", url: `https://news.yahoo.co.jp/profile/commentator/${userId}`, onload: function (response) { try { const parser = new DOMParser(); const doc = parser.parseFromString( response.responseText, "text/html", ); const ratings = { sympathized: 0, understood: 0, hmm: 0, commentCount: 0, articleCount: 0, isExpert: true, helpfulCount: 0, }; const statsText = doc.body.textContent; // 参考になったを取得 (DOMセレクタを優先) const helpfulElement = doc.querySelector('span[class*="bmUQoP"], .sc-livk2j-2') || Array.from(doc.querySelectorAll('span')).find(el => el.textContent.includes('参考になった'))?.nextElementSibling; if (helpfulElement) { ratings.helpfulCount = parseNumber(helpfulElement.textContent); } else { const helpfulMatch = statsText.match(/参考になった([\d.万,]+)/); if (helpfulMatch) ratings.helpfulCount = parseNumber(helpfulMatch[1]); } // コメント数を取得 const commentElement = doc.querySelector('a[href*="/comments"] span:last-child') || doc.querySelector('a[href*="expert"] span:nth-child(2)') || Array.from(doc.querySelectorAll('span')).find(el => el.textContent === 'コメント')?.nextElementSibling; if (commentElement) { ratings.commentCount = parseNumber(commentElement.textContent); } else { const commentCountMatch = statsText.match(/コメント([\d.万,]+)/); if (commentCountMatch) ratings.commentCount = parseNumber(commentCountMatch[1]); } // 記事数を取得 const articleElement = doc.querySelector('a[href*="/articles"] span:last-child'); if (articleElement) { ratings.articleCount = parseNumber(articleElement.textContent); } else { const articleCountMatch = statsText.match(/記事([\d.万,]+)/); if (articleCountMatch) ratings.articleCount = parseNumber(articleCountMatch[1]); } if (settings.enableCache) { userRatingsCache.set(`expert_${userId}`, ratings); } resolve(ratings); } catch (e) { resolve({ sympathized: 0, understood: 0, hmm: 0, commentCount: 0, articleCount: 0, isExpert: true, helpfulCount: 0 }); } }, onerror: function () { resolve({ sympathized: 0, understood: 0, hmm: 0, commentCount: 0, articleCount: 0, isExpert: true, helpfulCount: 0 }); }, }); }); } function getCommentBackgroundColor(rate) { const { color } = calculateRating(rate, 100 - rate); // 背景用により透明度を高く (0.08) return color.replace("rgb", "rgba").replace(")", ", 0.08)"); } function shouldHideComment(ratings, commentStats) { if (!settings.enableHideComments) return false; const isBelowThreshold = (stats) => { if (!stats) return false; const positive = stats.sympathized + (stats.understood || 0); const negative = stats.hmm; const total = positive + negative; if (total === 0 || total < settings.hideMinTotal) return false; const positiveRate = Math.round((positive / total) * 100); return positiveRate < settings.hideThreshold; }; const accountStats = { sympathized: ratings.sympathized, understood: ratings.understood, hmm: ratings.hmm }; const accountBelow = isBelowThreshold(accountStats); const commentBelow = isBelowThreshold(commentStats); if (settings.hideBasis === "account") return accountBelow; if (settings.hideBasis === "comment") return commentBelow; if (settings.hideBasis === "either") return accountBelow || commentBelow; return false; } function createRatingBadge(ratings) { const container = document.createElement("span"); container.style.cssText = ` display: inline-block; margin-left: 8px; vertical-align: middle; pointer-events: auto; z-index: 10; `; container.className = "yahoo-user-rating-badge"; const positive = ratings.sympathized + ratings.understood; const negative = ratings.hmm; const total = ratings.isExpert ? ratings.helpfulCount : positive + negative; const { rate: positiveRate, color: ratingColor } = calculateRating(positive, negative); if (positiveRate === -1 && (!ratings.isExpert || ratings.commentCount === 0)) { const noRatingSpan = document.createElement("span"); noRatingSpan.style.cssText = ` font-size: ${settings.compactMode ? "10px" : "11px"}; color: #999; padding: ${settings.compactMode ? "1px 4px" : "2px 6px"}; background: #fafafa; border-radius: 4px; border: 1px solid #e8e8e8; `; noRatingSpan.textContent = "- 評価なし"; container.appendChild(noRatingSpan); return container; } const parts = []; if (ratings.isExpert) { if (settings.showExpertHelpful) parts.push(`💡${formatNumber(ratings.helpfulCount, false)}`); if (settings.showExpertArticleCount) parts.push(`📰${formatNumber(ratings.articleCount, false)}`); if (settings.showExpertCommentCount) parts.push(`💬${formatNumber(ratings.commentCount, false)}`); } else { if (settings.showSympathized) parts.push(`👍${formatNumber(ratings.sympathized, true)}`); if (settings.showUnderstood) parts.push(`💡${formatNumber(ratings.understood, true)}`); if (settings.showHmm) parts.push(`👎${formatNumber(ratings.hmm, true)}`); if (settings.showCommentCount) parts.push(`💬${formatNumber(ratings.commentCount, true)}`); } if (settings.showTotal && !ratings.isExpert) { parts.push(`計${formatNumber(total, settings.roundTotal)}`); } if (settings.showPercentage && !ratings.isExpert) { parts.push(`${positiveRate}%`); } const isMobile = window.innerWidth <= 768; const textSpan = document.createElement("span"); textSpan.style.cssText = ` font-size: ${settings.compactMode ? "10px" : "11px"}; color: #666; white-space: ${isMobile ? "normal" : "nowrap"}; overflow: hidden; text-overflow: ellipsis; max-width: 100%; `; textSpan.textContent = parts.join(" "); const wrapper = document.createElement("span"); const bgColor = ratings.isExpert ? "#e3f2fd" : "#f5f5f5"; const borderColor = ratings.isExpert ? "#90caf9" : "#e0e0e0"; wrapper.style.cssText = ` display: inline-flex; flex-wrap: ${isMobile ? "wrap" : "nowrap"}; align-items: center; gap: 6px; padding: ${settings.compactMode ? "2px 6px" : "3px 8px"}; background: ${bgColor}; border-radius: 6px; border: 1px solid ${borderColor}; box-shadow: 0 1px 2px rgba(0,0,0,0.05); line-height: 1.2; max-width: 100%; box-sizing: border-box; overflow: hidden; `; wrapper.appendChild(textSpan); if (settings.showBar && !ratings.isExpert) { const bar = createBarElement(positiveRate, ratingColor, `${settings.barHeight}px`, "0 0 0 4px"); bar.style.width = `${settings.barWidth}px`; bar.style.display = "inline-block"; bar.style.verticalAlign = "middle"; wrapper.appendChild(bar); } container.appendChild(wrapper); return container; } function getIndividualCommentRating(commentElement) { if (!commentElement) return { sympathized: 0, hmm: 0, total: 0, time: 0 }; // 投稿時間の取得 (ソート用) const timeLink = commentElement.querySelector('a[href*="comments"]'); const timeText = timeLink ? timeLink.textContent : commentElement.innerText.match(/(\d+[^ ]+前|昨日|202\d[^\s]*)/)?.[0] || ""; /** * 時間文字列を数値(分)に変換してソート可能にする */ const parseTimeToMinutes = (str) => { if (!str) return 9999999; let match; if (match = str.match(/(\d+)秒前/)) return parseInt(match[1]) / 60; if (match = str.match(/(\d+)分前/)) return parseInt(match[1]); if (match = str.match(/(\d+)時間前/)) return parseInt(match[1]) * 60; if (str.includes("昨日")) return 1440; if (match = str.match(/(\d+)日前/)) return parseInt(match[1]) * 1440; // 「1/23(金) 12:34」や「2024/1/23」などの形式 if (match = str.match(/(\d+)\/(\d+)\(.\)\s+(\d+):(\d+)/)) { return parseInt(match[1]) * 43200 + parseInt(match[2]) * 1440 + parseInt(match[3]) * 60 + parseInt(match[4]); } if (match = str.match(/(\d{4})\/(\d+)\/(\d+)/)) { return (parseInt(match[1]) - 2000) * 525600 + parseInt(match[2]) * 43200 + parseInt(match[3]) * 1440; } return 9999999; }; const timeVal = parseTimeToMinutes(timeText); // ボタンの特定 (表示されているボタンのみを対象) const buttons = Array.from(commentElement.querySelectorAll("button")); // 表示されているボタンのみをフィルタ (隠しボタンを除外) const findBtn = (text) => buttons.find(b => { if (!b.innerText.includes(text)) return false; // 表示状態をチェック: offsetParentがnullなら非表示 if (b.offsetParent === null) return false; return true; }); const agreeBtn = findBtn("共感した"); const understoodBtn = findBtn("なるほど"); const disagreeBtn = findBtn("うーん"); const extract = (btn) => { if (!btn) return 0; // ボタン内の最後のspan要素に数値が入っている const spans = btn.querySelectorAll("span"); if (spans.length > 0) { const lastSpan = spans[spans.length - 1]; const text = lastSpan.textContent.trim(); // 文字列が数字のみで構成されているかチェック(万単位も考慮) if (/^[\d,]+$/.test(text)) { return parseNumber(text); } if (/^[\d.]+万$/.test(text)) { return Math.round(parseFloat(text) * 10000); } } return 0; }; const sympathized = extract(agreeBtn); const understood = extract(understoodBtn); const hmm = extract(disagreeBtn); return { sympathized, understood, hmm, total: sympathized + understood + hmm, time: timeVal }; } /** * 背景色を直ちに反映させる(リロードを伴わない設定変更時) */ function applySettingsLive() { const badges = document.querySelectorAll(".yahoo-user-rating-badge"); badges.forEach((b) => b.remove()); const commentBars = document.querySelectorAll(".yahoo-comment-rating-bar-container"); commentBars.forEach((b) => b.remove()); const comments = document.querySelectorAll( 'article, [class*="CommentItem"], [class*="CommentReply"]', ); comments.forEach((c) => { c.style.backgroundColor = ""; delete c.dataset.accountRatio; delete c.dataset.commentRatio; delete c.dataset.postTime; }); processedElements.clear(); findAndProcessUserLinks(); } /** * 設定変更時に保存ボタンの状態を更新する */ function markUnsavedChanges(panel, needsReload = false) { hasUnsavedChanges = true; if (needsReload) panel.dataset.needsReload = "true"; const saveButton = panel.querySelector("#settings-save"); if (saveButton) { if (panel.dataset.needsReload === "true") { saveButton.textContent = "保存して再読み込み"; saveButton.style.background = "#d32f2f"; } else { saveButton.textContent = "保存"; saveButton.style.background = "#0078d4"; } } } function createSettingsPanel() { const panel = document.createElement("div"); panel.id = "yahoo-rating-settings-panel"; const isMobile = window.innerWidth <= 768; panel.style.cssText = ` display: none; position: fixed; top: ${isMobile ? "0" : "50%"}; left: ${isMobile ? "0" : "50%"}; transform: ${isMobile ? "none" : "translate(-50%, -50%)"}; background: white; border: 1px solid #ccc; border-radius: ${isMobile ? "0" : "8px"}; padding: ${isMobile ? "15px" : "20px"}; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 10000; width: ${isMobile ? "100%" : "auto"}; min-width: ${isMobile ? "auto" : "400px"}; max-width: ${isMobile ? "100%" : "500px"}; height: ${isMobile ? "100%" : "auto"}; max-height: ${isMobile ? "100%" : "80vh"}; overflow-y: auto; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; `; panel.innerHTML = `