Skip to content

Instantly share code, notes, and snippets.

@roflsunriz
Last active May 24, 2025 07:56
Show Gist options
  • Save roflsunriz/3f78f147f1e93cc80bea80490a0989c6 to your computer and use it in GitHub Desktop.
Save roflsunriz/3f78f147f1e93cc80bea80490a0989c6 to your computer and use it in GitHub Desktop.
chatgpt-copy-summary : ChatGPTのサマリーを「Q.[ユーザーの入力] A.[ChatGPTの応答先頭300文字]... [URL]」形式でコピーします
// ==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