Last active
May 24, 2025 07:56
-
-
Save roflsunriz/3f78f147f1e93cc80bea80490a0989c6 to your computer and use it in GitHub Desktop.
chatgpt-copy-summary : ChatGPTのサマリーを「Q.[ユーザーの入力] A.[ChatGPTの応答先頭300文字]... [URL]」形式でコピーします
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name ChatGPT サマリーコピー | |
// @namespace chatgpt-copy-summary | |
// @version 1.1 | |
// @description ChatGPTのサマリーを「Q.[ユーザーの入力] A.[ChatGPTの応答先頭300文字]... [URL]」形式でコピーします | |
// @author roflsunriz | |
// @match https://chatgpt.com/* | |
// @match https://chat.openai.com/* | |
// @match https://chat.com/* | |
// @icon https://chat.openai.com/favicon.ico | |
// @grant GM_setClipboard | |
// @grant GM_registerMenuCommand | |
// @grant GM_notification | |
// @run-at document-idle | |
// @updateURL https://gist.githubusercontent.com/roflsunriz/3f78f147f1e93cc80bea80490a0989c6/raw/chatgpt-copy-summary.user.js | |
// @downloadURL https://gist.githubusercontent.com/roflsunriz/3f78f147f1e93cc80bea80490a0989c6/raw/chatgpt-copy-summary.user.js | |
// ==/UserScript== | |
(function () { | |
"use strict"; | |
// ------------------------------ | |
// 0. 設定と初期化 | |
// ------------------------------ | |
// デバッグ用のログ関数 | |
const debug = { | |
log: (message) => { | |
console.log("[ChatGPT Summary Copier]", message); | |
}, | |
error: (message) => { | |
console.error("[ChatGPT Summary Copier]", message); | |
}, | |
}; | |
// ------------------------------ | |
// 1. ユーティリティ関数 | |
// ------------------------------ | |
/** | |
* 指定されたテキストをクリップボードにコピーし、通知を表示する | |
* @param {string} text - コピーするテキスト | |
*/ | |
function copyToClipboard(text) { | |
if (!text) { | |
debug.error("コピーするテキストがありません"); | |
return; | |
} | |
try { | |
GM_setClipboard(text); | |
// コピー成功通知 | |
GM_notification({ | |
text: "サマリーをクリップボードにコピーしました", | |
title: "ChatGPT サマリーコピー", | |
timeout: 2000, | |
}); | |
debug.log("コピーしました: " + text); | |
} catch (error) { | |
debug.error("コピーに失敗しました: " + error); | |
} | |
} | |
/** | |
* 現在のURLを取得する | |
* @returns {string} 現在のURL | |
*/ | |
function getCurrentURL() { | |
return window.location.href; | |
} | |
// ------------------------------ | |
// 2. ChatGPT会話データ取得関数 | |
// ------------------------------ | |
/** | |
* 現在の会話からユーザーの質問とChatGPTの回答を取得する | |
* @returns {Array} 質問と回答のペアの配列 | |
*/ | |
function getChatData() { | |
const conversations = []; | |
try { | |
const userText = document.querySelector(".group\\/conversation-turn.flex-col").textContent; | |
const assistantText = document.querySelector(".group\\/conversation-turn.agent-turn").textContent; | |
// テキストのクリーンアップ処理 | |
const cleanedAssistantText = assistantText | |
.replace(/\n{2,}/g, '\n') // 2つ以上連続する改行を1つに置換 | |
.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, '') // 絵文字の除去 | |
.replace(/\s{2,}/g, ' ') // 連続する空白を1つにまとめる | |
.trim(); // 前後の空白を除去 | |
conversations.push({ | |
question: userText, | |
answer: cleanedAssistantText, | |
}); | |
} catch (error) { | |
debug.error("会話データの取得中にエラーが発生しました: " + error); | |
} | |
return conversations; | |
} | |
/** | |
* 会話データをサマリー形式に整形する | |
* @param {Array} conversations - 会話データの配列 | |
* @returns {string} 整形されたサマリーテキスト | |
*/ | |
function formatSummary(conversations) { | |
if (!conversations || conversations.length === 0) { | |
return ""; | |
} | |
const summaryParts = []; | |
conversations.forEach((conversation) => { | |
if (conversation.question && conversation.answer) { | |
// 回答は最初の300文字までを取得し、それ以上は「...」で省略 | |
const truncatedAnswer = | |
conversation.answer.length > 300 | |
? conversation.answer.substring(0, 300) + "..." | |
: conversation.answer; | |
summaryParts.push(`Q.${conversation.question}\nA.${truncatedAnswer}`); | |
} | |
}); | |
// URLを追加 | |
const summary = summaryParts.join("\n\n") + "\n\n" + getCurrentURL(); | |
return summary; | |
} | |
/** | |
* 現在の会話のサマリーをコピーする | |
*/ | |
function copyChatSummary() { | |
const conversations = getChatData(); | |
const summary = formatSummary(conversations); | |
if (summary) { | |
copyToClipboard(summary); | |
} else { | |
debug.error("コピーするサマリーがありません"); | |
GM_notification({ | |
text: "会話データが見つかりませんでした", | |
title: "ChatGPT サマリーコピー", | |
timeout: 2000, | |
}); | |
} | |
} | |
/** | |
* 特定の会話のサマリーをコピーする | |
* @param {Object} conversation - 質問と回答のペア | |
*/ | |
function copySingleConversation(conversation) { | |
if (!conversation || !conversation.question || !conversation.answer) { | |
debug.error("コピーする会話データがありません"); | |
return; | |
} | |
// 回答は最初の300文字までを取得し、それ以上は「...」で省略 | |
const truncatedAnswer = | |
conversation.answer.length > 300 | |
? conversation.answer.substring(0, 300) + "..." | |
: conversation.answer; | |
const summary = `Q.${conversation.question}\nA.${truncatedAnswer}\n\n${getCurrentURL()}`; | |
copyToClipboard(summary); | |
} | |
// ------------------------------ | |
// 3. UIの追加 | |
// ------------------------------ | |
/** | |
* 各会話にコピーボタンを追加する | |
*/ | |
function addInlineButtons() { | |
try { | |
// チャットのメインコンテナを取得(複数のセレクタパターンを試す) | |
let chatContainer = document.querySelector('div[class*="react-scroll-to-bottom"]'); | |
// 最新UIの場合も対応 | |
if (!chatContainer) { | |
chatContainer = document.querySelector('main div[class*="overflow-y-auto"]'); | |
} | |
// それでもなければ他のパターンを試す | |
if (!chatContainer) { | |
chatContainer = document.querySelector("main div.flex.flex-col.items-center"); | |
} | |
if (!chatContainer) { | |
debug.error("チャットコンテナが見つかりません"); | |
return; | |
} | |
// ユーザーのメッセージとChatGPTの回答のペアを取得 | |
const conversations = getChatData(); | |
if (conversations.length === 0) { | |
debug.error("会話データが見つかりません"); | |
return; | |
} | |
// 既存のインラインボタンを削除(数が多い場合はDOMの負荷が高くなる可能性があるため、慎重に行う) | |
const existingButtons = document.querySelectorAll(".chatgpt-inline-copy-button"); | |
if (existingButtons.length > 30) { | |
// ボタンが多すぎる場合は全削除して再追加 | |
existingButtons.forEach((btn) => btn.remove()); | |
} | |
// 回答の各ターンにボタンを追加 | |
let index = 0; | |
// 会話ターンを取得(複数のセレクタパターンを試す) | |
let messageTurns = []; | |
// 新しいUIでは、data-testid="conversation-turn"を持つ要素が会話ターン | |
const newUITurns = chatContainer.querySelectorAll( | |
'div[data-testid="conversation-turn"][data-message-author-role="assistant"]' | |
); | |
if (newUITurns.length > 0) { | |
messageTurns = Array.from(newUITurns); | |
} else { | |
// 従来のUIでは、data-message-author-role="assistant"を持つ要素が会話ターン | |
const oldUITurns = chatContainer.querySelectorAll('div[data-message-author-role="assistant"]'); | |
if (oldUITurns.length > 0) { | |
messageTurns = Array.from(oldUITurns); | |
} else { | |
// それでもなければ別のセレクタで試す | |
const alternateTurns = chatContainer.querySelectorAll("div.group:not(.user-message)"); | |
messageTurns = Array.from(alternateTurns); | |
} | |
} | |
if (messageTurns.length === 0) { | |
debug.error("会話ターンが見つかりません"); | |
return; | |
} | |
// コンバセーションインデックスを追跡 | |
let conversationIndex = 0; | |
// 各メッセージターンを処理 | |
messageTurns.forEach((turn) => { | |
try { | |
// 会話データがない場合はスキップ | |
if (conversationIndex >= conversations.length) { | |
return; | |
} | |
// 既にボタンがある場合はスキップ | |
if (turn.querySelector(".chatgpt-inline-copy-button")) { | |
conversationIndex++; | |
return; | |
} | |
// 挿入先の適切な場所を探す(新UIと従来のUIで異なる) | |
let targetContainer = null; | |
// 1. 新UIのコントロールメニュー("..."がある場所) | |
const menuContainer = turn.querySelector( | |
'div[class*="flex items-center"] button[aria-haspopup="menu"]' | |
); | |
if (menuContainer) { | |
targetContainer = menuContainer.parentElement; | |
} | |
// 2. ChatGPTアイコンの近くのコントロール | |
if (!targetContainer) { | |
const controlContainer = turn.querySelector("div.flex.items-center"); | |
if (controlContainer) { | |
targetContainer = controlContainer; | |
} | |
} | |
// 3. その他の場合はヘッダー要素を探す | |
if (!targetContainer) { | |
const header = turn.querySelector( | |
".flex-shrink-0, .flex.items-center, .flex.justify-between" | |
); | |
if (header) { | |
targetContainer = header; | |
} | |
} | |
// 4. それでもなければ最初の子要素を対象にする | |
if (!targetContainer && turn.firstElementChild) { | |
targetContainer = turn.firstElementChild; | |
} | |
// ボタンの作成 | |
const button = document.createElement("button"); | |
button.className = "chatgpt-inline-copy-button"; | |
button.dataset.index = conversationIndex; | |
button.innerHTML = ` | |
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect> | |
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path> | |
</svg> | |
<span class="tooltip">サマリーをコピー</span> | |
`; | |
button.title = "この会話をコピー"; | |
// クリックイベント | |
button.addEventListener("click", (e) => { | |
e.stopPropagation(); | |
const idx = parseInt(button.dataset.index, 10); | |
if (idx >= 0 && idx < conversations.length) { | |
copySingleConversation(conversations[idx]); | |
// 成功エフェクト | |
button.classList.add("success"); | |
setTimeout(() => { | |
button.classList.remove("success"); | |
}, 1000); | |
} | |
}); | |
// ボタンをターゲットコンテナに追加 | |
if (targetContainer) { | |
if (targetContainer.classList.contains("flex")) { | |
// flexコンテナに追加する場合 | |
targetContainer.appendChild(button); | |
} else { | |
// 通常のコンテナの場合は相対位置を設定 | |
targetContainer.style.position = "relative"; | |
button.style.position = "absolute"; | |
button.style.right = "10px"; | |
button.style.top = "10px"; | |
button.style.zIndex = "10"; | |
targetContainer.appendChild(button); | |
} | |
} else { | |
// ターゲットコンテナが見つからない場合は要素自体に追加 | |
turn.style.position = "relative"; | |
button.style.position = "absolute"; | |
button.style.top = "10px"; | |
button.style.right = "10px"; | |
button.style.zIndex = "10"; | |
turn.appendChild(button); | |
} | |
// インデックスを進める | |
conversationIndex++; | |
} catch (buttonError) { | |
debug.error("ボタン追加中にエラー: " + buttonError); | |
} | |
}); | |
debug.log("インラインコピーボタンを追加しました"); | |
} catch (error) { | |
debug.error("インラインボタン追加中にエラーが発生しました: " + error); | |
} | |
} | |
// インラインボタン用のスタイルを追加 | |
function addStyles() { | |
// すでに追加済みならスキップ | |
if (document.querySelector("#chatgpt-summary-copy-styles")) return; | |
const style = document.createElement("style"); | |
style.id = "chatgpt-summary-copy-styles"; | |
style.textContent = ` | |
.chatgpt-inline-copy-button { | |
width: 28px; | |
height: 28px; | |
border-radius: 4px; | |
background-color: transparent; | |
color: #8e8ea0; | |
border: none; | |
cursor: pointer; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
transition: all 0.2s ease; | |
margin-left: 8px; | |
position: relative; | |
} | |
.chatgpt-inline-copy-button:hover { | |
background-color: rgba(0, 0, 0, 0.1); | |
color: #10a37f; | |
} | |
.chatgpt-inline-copy-button.success { | |
color: #4CAF50; | |
} | |
.chatgpt-inline-copy-button .tooltip { | |
position: absolute; | |
top: -25px; | |
left: 50%; | |
transform: translateX(-50%); | |
background-color: rgba(0, 0, 0, 0.7); | |
color: white; | |
padding: 4px 8px; | |
border-radius: 4px; | |
font-size: 12px; | |
white-space: nowrap; | |
opacity: 0; | |
visibility: hidden; | |
transition: all 0.2s ease; | |
} | |
.chatgpt-inline-copy-button:hover .tooltip { | |
opacity: 1; | |
visibility: visible; | |
} | |
/* ダークモード対応 */ | |
.dark .chatgpt-inline-copy-button { | |
color: #ececf1; | |
} | |
.dark .chatgpt-inline-copy-button:hover { | |
background-color: rgba(255, 255, 255, 0.1); | |
color: #10a37f; | |
} | |
`; | |
document.head.appendChild(style); | |
debug.log("インラインボタン用スタイルを追加しました"); | |
} | |
/** | |
* マテリアルデザインのコピーボタンを作成して追加(シャドウDOM版) | |
*/ | |
function addCopyButton() { | |
// すでにボタンがある場合は何もしない | |
if (document.querySelector("#chatgpt-summary-copy-button-container")) { | |
return; | |
} | |
// シャドウDOMコンテナの作成 | |
const container = document.createElement("div"); | |
container.id = "chatgpt-summary-copy-button-container"; | |
// シャドウルートの作成 | |
const shadowRoot = container.attachShadow({ mode: 'closed' }); | |
// スタイルの定義 | |
const style = document.createElement("style"); | |
style.textContent = ` | |
.chatgpt-summary-copy-button { | |
position: fixed; | |
bottom: 20px; | |
right: 20px; | |
width: 48px; | |
height: 48px; | |
border-radius: 50%; | |
background-color: #10a37f; | |
color: white; | |
border: none; | |
cursor: pointer; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
z-index: 9999; | |
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); | |
transition: all 0.3s ease; | |
} | |
.chatgpt-summary-copy-button:hover { | |
transform: scale(1.1); | |
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3); | |
} | |
.chatgpt-summary-copy-button.success { | |
background-color: #4CAF50; | |
} | |
`; | |
// ボタンの作成 | |
const button = document.createElement("button"); | |
button.className = "chatgpt-summary-copy-button"; | |
button.innerHTML = ` | |
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect> | |
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path> | |
</svg> | |
`; | |
button.title = "サマリーをコピー"; | |
// ボタンクリックイベント | |
button.addEventListener("click", () => { | |
copyChatSummary(); | |
// 成功エフェクト | |
button.classList.add("success"); | |
setTimeout(() => { | |
button.classList.remove("success"); | |
}, 1000); | |
}); | |
// シャドウDOMに要素を追加 | |
shadowRoot.appendChild(style); | |
shadowRoot.appendChild(button); | |
// DOMに追加 | |
document.body.appendChild(container); | |
debug.log("シャドウDOM化されたコピーボタンを追加しました"); | |
} | |
// ------------------------------ | |
// 4. 初期化と実行 | |
// ------------------------------ | |
// DOM変更を監視して動的にボタンを追加 | |
function observeDOM() { | |
// すでに監視中なら何もしない | |
if (window._chatgptSummaryObserver) return; | |
// DOM変更を監視 | |
const observer = new MutationObserver((mutations) => { | |
// トロットリングのための変数 | |
if (window._observerThrottled) return; | |
window._observerThrottled = true; | |
// 50ms後に実行(パフォーマンス対策) | |
setTimeout(() => { | |
// ChatGPTのUIがロードされているか確認 | |
const chatContainer = document.querySelector( | |
'div[class*="react-scroll-to-bottom"], main div[class*="overflow-y-auto"], main div.flex.flex-col.items-center' | |
); | |
if (chatContainer) { | |
addInlineButtons(); | |
} | |
window._observerThrottled = false; | |
}, 50); | |
}); | |
// 監視を開始 | |
observer.observe(document.body, { | |
childList: true, | |
subtree: true, | |
}); | |
// グローバル参照を保存 | |
window._chatgptSummaryObserver = observer; | |
debug.log("DOM変更の監視を開始しました"); | |
} | |
// ページ変更を監視する | |
function observeURLChanges() { | |
// すでに監視中なら何もしない | |
if (window._chatgptSummaryUrlObserver) return; | |
let lastUrl = location.href; | |
const observer = new MutationObserver(() => { | |
const url = location.href; | |
if (url !== lastUrl) { | |
lastUrl = url; | |
debug.log("URLが変更されました: " + url); | |
// URLが変わったらボタンを再追加(少し遅延させて確実にDOM更新後に実行) | |
setTimeout(() => { | |
addStyles(); | |
addCopyButton(); | |
addInlineButtons(); | |
}, 1000); | |
} | |
}); | |
observer.observe(document, { subtree: true, childList: true }); | |
// グローバル参照を保存 | |
window._chatgptSummaryUrlObserver = observer; | |
debug.log("URL変更の監視を開始しました"); | |
} | |
// スクリプトの初期化 | |
function init() { | |
debug.log("ChatGPT サマリーコピースクリプトを初期化します"); | |
// メニューコマンドの登録 | |
GM_registerMenuCommand("サマリーをコピー", copyChatSummary); | |
// 一定時間後に初期化処理を実行(サイトのロードを待つ) | |
setTimeout(() => { | |
// スタイルの追加 | |
addStyles(); | |
// UIの追加 | |
addCopyButton(); | |
addInlineButtons(); | |
// DOM変更の監視 | |
observeDOM(); | |
// URL変更の監視 | |
observeURLChanges(); | |
debug.log("初期化が完了しました"); | |
}, 1500); | |
} | |
// 初期化を実行 | |
if (document.readyState === "loading") { | |
document.addEventListener("DOMContentLoaded", () => setTimeout(init, 500)); | |
} else { | |
// すでにDOMが読み込まれている場合は少し遅延させて実行 | |
setTimeout(init, 500); | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment