gemini_export2 (Go)
//Go言語のコマンドラインツールです。
//geminiのやり取りを右クリックからの保存で保存し、
//Google Gemini.html となった物を整形してみれるHTMLにするツール。
//・User vs Gemini の対話構造
//・左右のよけいなものは除去
//・コードはcopyできる
//※そのままでは掲示板(Junkerstock)には貼れない。サニタイズの必要があるので
//専用ツール gemini_sanitize gemini_sanitize2 をつかうこと
package main
import (
"fmt"
"io/ioutil"
"os"
"regexp"
"sort"
"strings"
"unicode/utf8"
)
const htmlTemplateHeader = `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Gemini Conversation Export</title>
<style>
body { font-family: 'Segoe UI', Meiryo, sans-serif; color: #1f1f1f; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px; background-color: #fff; }
button, .buttons, .mat-icon, .mat-focus-indicator, message-actions, tts-control { display: none !important; }
.copy-btn { display: inline-block !important; background: #444; color: #fff; border: 1px solid #555; border-radius: 4px; padding: 4px 10px; cursor: pointer; font-size: 12px; font-family: sans-serif; }
.copy-btn:hover { background: #555; }
</style>
<script>
function copyToClipboard(btn) {
var wrapper = btn.parentNode.parentNode;
var pre = wrapper.querySelector('pre');
var codeText = pre.textContent;
var textArea = document.createElement("textarea");
textArea.value = codeText;
textArea.style.position = "fixed"; textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.focus(); textArea.select();
try { document.execCommand('copy'); btn.innerText = "Copied!"; setTimeout(function(){ btn.innerText = "Copy"; }, 2000); } catch (err) {}
document.body.removeChild(textArea);
}
</script>
</head>
<body>
<div style="font-family: 'Segoe UI', Meiryo, sans-serif; color: #1f1f1f; line-height: 1.6;">
`
const htmlTemplateFooter = `
</div>
</body>
</html>`
func main() {
inputFile := "Google Gemini.html"
data, err := ioutil.ReadFile(inputFile)
if err != nil {
fmt.Printf("エラー: %s が見つかりません。\n", inputFile)
pauseAndExit()
}
content := string(data)
// 1. 会話の抽出と最初の質問の取得
outputHTML, firstQuestion := extractConversation(content)
// 2. ファイル名の生成 (最初の質問から20文字)
safeTitle := sanitizeFilename(firstQuestion, 20)
baseName := "Gemini_Exported_" + safeTitle
outputFile := getUniqueFilename(baseName, ".html")
// 3. ファイル書き出し
err = ioutil.WriteFile(outputFile, []byte(outputHTML), 0644)
if err != nil {
fmt.Printf("保存エラー: %v\n", err)
pauseAndExit()
}
fmt.Printf("完了: %s に保存しました。\n", outputFile)
}
func extractConversation(content string) (string, string) {
reUserStart := regexp.MustCompile(`<user-query`)
reModelStart := regexp.MustCompile(`<model-response`)
userIndices := reUserStart.FindAllStringIndex(content, -1)
modelIndices := reModelStart.FindAllStringIndex(content, -1)
type Block struct {
Start int
Type string
}
var blocks []Block
for _, idx := range userIndices {
blocks = append(blocks, Block{Start: idx[0], Type: "user"})
}
for _, idx := range modelIndices {
blocks = append(blocks, Block{Start: idx[0], Type: "model"})
}
sort.Slice(blocks, func(i, j int) bool { return blocks[i].Start < blocks[j].Start })
if len(blocks) == 0 {
return "会話データが見つかりませんでした。", "No_Content"
}
var sb strings.Builder
var firstQuestion string
sb.WriteString(htmlTemplateHeader)
for i, block := range blocks {
end := len(content)
if i+1 < len(blocks) {
end = blocks[i+1].Start
}
chunk := content[block.Start:end]
if block.Type == "user" {
if endTagIdx := strings.Index(chunk, "</user-query>"); endTagIdx != -1 {
chunk = chunk[:endTagIdx]
}
qStartRe := regexp.MustCompile(`class="[^"]*query-text[^"]*"[^>]*>`)
if loc := qStartRe.FindStringIndex(chunk); loc != nil {
textPart := chunk[loc[1]:]
if divEnd := strings.Index(textPart, "</div>"); divEnd != -1 {
rawText := textPart[:divEnd]
// 最初のユーザー発言を保存
if firstQuestion == "" {
firstQuestion = cleanTextForFilename(rawText)
}
text := processText(rawText, true)
sb.WriteString(fmt.Sprintf("<div style=\"margin-bottom:20px;\"><div style=\"background-color:#f0f4f9;padding:15px;border-radius:12px;border:1px solid #dcdcdc;\"><div style=\"font-weight:bold;color:#0b57d0;margin-bottom:8px;font-size:0.9em;\">User</div><div style=\"white-space:pre-wrap;\">%s</div></div></div>\n", text))
}
}
} else {
if endTagIdx := strings.Index(chunk, "</message-content>"); endTagIdx != -1 {
chunk = chunk[:endTagIdx]
}
mStartRe := regexp.MustCompile(`class="[^"]*markdown[^"]*"[^>]*>`)
if loc := mStartRe.FindStringIndex(chunk); loc != nil {
htmlPart := chunk[loc[1]:]
if divEnd := strings.LastIndex(htmlPart, "</div>"); divEnd != -1 {
htmlText := processText(htmlPart[:divEnd], false)
sb.WriteString(fmt.Sprintf("<div style=\"margin-bottom:40px;\"><div style=\"padding:5px 15px;\"><div style=\"font-weight:bold;color:#9c27b0;margin-bottom:8px;font-size:0.9em;\">Gemini</div><div style=\"line-height:1.7;\">%s</div></div><hr style=\"border:0;border-top:1px solid #eee;margin-top:30px;\"></div>\n", htmlText))
}
}
}
}
sb.WriteString(htmlTemplateFooter)
return sb.String(), firstQuestion
}
func processText(text string, isUser bool) string {
if isUser {
re := regexp.MustCompile(`<[^>]*>`)
return bbsEscape(re.ReplaceAllString(text, ""))
}
// コードブロックの処理 (二重エスケープを防止)
var codeBlocks []string
rePre := regexp.MustCompile(`(?s)<pre.*?</pre>`)
text = rePre.ReplaceAllStringFunc(text, func(match string) string {
match = strings.Replace(match, "<pre", `<pre style="margin: 0; background-color: #1e1e1e; color: #e6e6e6; padding: 15px; border-radius: 0 0 8px 8px; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word; font-family: monospace;"`, 1)
match = strings.ReplaceAll(match, "<code", `<code style="font-family: monospace;"`)
wrapper := fmt.Sprintf(`<div style="position: relative; margin: 15px 0; border-radius: 8px; overflow: hidden; border: 1px solid #444;"><div style="display: flex; justify-content: flex-end; background-color: #2d2d2d; padding: 5px 10px;"><button type="button" class="copy-btn" onclick="copyToClipboard(this)">Copy</button></div>%s</div>`, match)
codeBlocks = append(codeBlocks, wrapper)
return fmt.Sprintf("###CODE_BLOCK_%d###", len(codeBlocks)-1)
})
// 不要な属性やタグの除去
text = regexp.MustCompile(` (class|id|jslog|data-[a-z\-]+|role|tabindex|aria-[a-z]+|jsname| BardVeMetadataKey)="[^"]*"`).ReplaceAllString(text, "")
text = regexp.MustCompile(`(?s)<button.*?</button>`).ReplaceAllString(text, "")
text = regexp.MustCompile(`(?s)<mat-icon.*?</mat-icon>`).ReplaceAllString(text, "")
text = strings.ReplaceAll(text, "<table>", `<table style="border-collapse: collapse; width: 100%; margin: 15px 0; border: 1px solid #ddd;">`)
text = strings.ReplaceAll(text, "<th>", `<th style="border: 1px solid #ddd; padding: 8px; background-color: #f2f2f2; font-weight: bold;">`)
text = strings.ReplaceAll(text, "<td>", `<td style="border: 1px solid #ddd; padding: 8px;">`)
// コードブロックを戻す (ここでは二重エスケープしない)
for i, codeBlockHtml := range codeBlocks {
text = strings.Replace(text, fmt.Sprintf("###CODE_BLOCK_%d###", i), codeBlockHtml, 1)
}
return text
}
func bbsEscape(text string) string {
text = strings.ReplaceAll(text, "&", "&")
text = strings.ReplaceAll(text, "<", "<")
text = strings.ReplaceAll(text, ">", ">")
return text
}
// ファイル名として使えない文字を排除し、指定文字数で切る
func sanitizeFilename(s string, maxLen int) string {
// ファイル名禁止文字を置換
reg := regexp.MustCompile(`[\\/:*?"<>| \t\n\r]`)
s = reg.ReplaceAllString(s, "_")
if utf8.RuneCountInString(s) > maxLen {
runes := []rune(s)
return string(runes[:maxLen])
}
return s
}
func cleanTextForFilename(htmlText string) string {
// タグ除去
re := regexp.MustCompile(`<[^>]*>`)
text := re.ReplaceAllString(htmlText, "")
// 前後の空白削除
return strings.TrimSpace(text)
}
func getUniqueFilename(base, ext string) string {
filename := base + ext
if _, err := os.Stat(filename); os.IsNotExist(err) {
return filename
}
for i := 1; i <= 9999; i++ {
filename = fmt.Sprintf("%s_(%d)%s", base, i, ext)
if _, err := os.Stat(filename); os.IsNotExist(err) {
return filename
}
}
return base + "_overflow" + ext
}
func pauseAndExit() {
fmt.Println("Enterキーを押して終了してください...")
var s string
fmt.Scanln(&s)
os.Exit(1)
}