// ==UserScript==
// @name ChatGPT 图片生成优化提示词提取器
// @namespace https://github.com/kadevin/chatgpt-revised-prompt
// @version 5.2.1
// @description 手动提取 ChatGPT 图片生成优化提示词,支持缩略图预览、多选批量下载
// @author iLab
// @match https://chatgpt.com/*
// @match https://chat.openai.com/*
// @run-at document-idle
// @grant none
// @require https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const DEBUG = true;
function log(...a) { if (DEBUG) console.log('%c[RP]', 'color:#10a37f;font-weight:bold', ...a); }
function getConversationId() {
const m = location.pathname.match(/\/c\/([a-f0-9-]+)/);
return m ? m[1] : null;
}
let _cachedToken = null;
let _tokenExpiry = 0;
function getAccessToken() {
// 先从 DOM 获取
try {
const el = document.getElementById('client-bootstrap');
if (el) {
const d = JSON.parse(el.textContent);
const t = d?.accessToken || d?.session?.accessToken || null;
if (t) { _cachedToken = t; _tokenExpiry = Date.now() + 8 * 60 * 1000; return t; }
}
} catch(e) {}
// 返回缓存的 token(如果未过期)
if (_cachedToken && Date.now() < _tokenExpiry) return _cachedToken;
return null;
}
async function refreshAccessToken() {
try {
log('🔑 刷新 access token...');
const resp = await fetch('https://chatgpt.com/api/auth/session', { credentials: 'include' });
if (resp.ok) {
const data = await resp.json();
const t = data?.accessToken;
if (t) {
_cachedToken = t;
_tokenExpiry = Date.now() + 8 * 60 * 1000; // 缓存 8 分钟
log('🔑 Token 已刷新');
return t;
}
}
} catch(e) { log('⚠️ 刷新 token 失败:', e.message); }
return null;
}
// 全局状态:按轮次分组
let allRounds = []; // [{roundIndex, userText, prompts:[{prompt,source,imageUrls,selected,id}]}]
const seenPrompts = new Set();
let lastFetchTime = 0; // 请求节流
let lastFetchConvId = ''; // 避免重复请求同一对话
const FETCH_COOLDOWN = 5000; // 最小请求间隔 5 秒
let isFetchingPrompts = false;
let _userUploadedFileIds = new Set(); // 用户上传图片的 file ID 排除集
function injectStyles() {
if (document.getElementById('rp-styles')) return;
const s = document.createElement('style');
s.id = 'rp-styles';
s.textContent = `
#rp-fab{position:fixed;bottom:80px;right:20px;z-index:99998;width:44px;height:44px;border-radius:50%;
border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;
background:linear-gradient(135deg,#10a37f,#1a7f64);color:#fff;
box-shadow:0 4px 16px rgba(16,163,127,.35);transition:all .25s;user-select:none}
#rp-fab:hover{transform:scale(1.08)}
#rp-fab .rp-badge{position:absolute;top:-4px;right:-4px;min-width:18px;height:18px;
border-radius:9px;background:#ef4444;color:#fff;font-size:10px;font-weight:700;
display:flex;align-items:center;justify-content:center;padding:0 4px}
#rp-panel{position:fixed;bottom:136px;right:20px;z-index:99997;width:400px;max-height:68vh;
background:#fff;border-radius:14px;overflow:hidden;display:none;flex-direction:column;
box-shadow:0 8px 40px rgba(0,0,0,.15),0 0 0 1px rgba(0,0,0,.05);
animation:rp-up .25s ease;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
#rp-panel.open{display:flex}
html.dark #rp-panel{background:#1e1e2e;box-shadow:0 8px 40px rgba(0,0,0,.4),0 0 0 1px rgba(255,255,255,.06)}
@keyframes rp-up{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
.rp-hdr{display:flex;align-items:center;gap:8px;padding:12px 14px;flex-shrink:0;
border-bottom:1px solid rgba(0,0,0,.06);font-weight:600;font-size:13px;color:#202123}
html.dark .rp-hdr{border-bottom-color:rgba(255,255,255,.06);color:#e5e5e5}
.rp-hdr-right{margin-left:auto;display:flex;align-items:center;gap:6px}
.rp-sel-all{font-size:11px;padding:3px 8px;border:1px solid rgba(16,163,127,.3);border-radius:5px;
background:none;color:#10a37f;cursor:pointer;font-family:inherit}
.rp-sel-all:hover{background:rgba(16,163,127,.07)}
.rp-refresh{font-size:11px;padding:3px 8px;border:1px solid rgba(16,163,127,.3);border-radius:5px;
background:#10a37f;color:#fff;cursor:pointer;font-family:inherit;display:inline-flex;align-items:center;gap:4px}
.rp-refresh:hover{background:#0d8a6b}
.rp-refresh:disabled{background:#9ca3af;border-color:#9ca3af;cursor:not-allowed}
.rp-count-badge{font-size:11px;font-weight:500;color:#6e6e80;background:rgba(0,0,0,.05);
padding:2px 8px;border-radius:10px}
html.dark .rp-count-badge{background:rgba(255,255,255,.08);color:#9ca3af}
.rp-body{overflow-y:auto;flex:1;padding:6px}
.rp-round-divider{display:flex;align-items:center;gap:8px;margin:10px 4px 6px;font-size:11px;
font-weight:600;color:#9ca3af}
.rp-round-divider::before{content:'';flex:1;height:1px;background:rgba(0,0,0,.08)}
.rp-round-divider::after{content:'';flex:1;height:1px;background:rgba(0,0,0,.08)}
html.dark .rp-round-divider::before,html.dark .rp-round-divider::after{background:rgba(255,255,255,.08)}
.rp-round-dl{font-size:10px;padding:2px 7px;border:1px solid rgba(16,163,127,.3);border-radius:4px;
background:none;color:#10a37f;cursor:pointer;font-family:inherit;white-space:nowrap;
flex-shrink:0;display:inline-flex;align-items:center;gap:3px}
.rp-round-dl:hover{background:rgba(16,163,127,.08)}
html.dark .rp-round-dl{border-color:rgba(16,163,127,.4);color:#10a37f}
.rp-card{border-radius:10px;margin-bottom:5px;overflow:hidden;
border:1px solid rgba(0,0,0,.07);transition:border-color .18s,background .18s}
.rp-card:hover{border-color:rgba(16,163,127,.25)}
html.dark .rp-card{border-color:rgba(255,255,255,.07)}
html.dark .rp-card:hover{border-color:rgba(16,163,127,.3)}
.rp-card.selected{border-color:#10a37f;background:rgba(16,163,127,.04)}
html.dark .rp-card.selected{background:rgba(16,163,127,.08)}
.rp-card-hdr{display:flex;align-items:center;gap:7px;padding:8px 10px;cursor:pointer;
user-select:none;font-size:11px;font-weight:500;color:#6e6e80;transition:background .13s}
.rp-card-hdr:hover{background:rgba(0,0,0,.02)}
html.dark .rp-card-hdr{color:#9ca3af}
html.dark .rp-card-hdr:hover{background:rgba(255,255,255,.03)}
.rp-cb{appearance:none;-webkit-appearance:none;width:16px;height:16px;
border:1px solid rgba(0,0,0,.2);border-radius:4px;background:#fff;
cursor:pointer;flex-shrink:0;margin:0;position:relative;transition:all .15s}
html.dark .rp-cb{background:transparent;border-color:rgba(255,255,255,.3)}
.rp-cb:checked{background:#10a37f;border-color:#10a37f}
html.dark .rp-cb:checked{background:#10a37f;border-color:#10a37f}
.rp-cb:checked::after{content:'';position:absolute;left:4.5px;top:1.5px;width:4px;height:8px;
border:solid #fff;border-width:0 2px 2px 0;transform:rotate(45deg)}
.rp-thumb-strip{display:flex;gap:3px;flex-shrink:0;position:relative}
.rp-thumb{width:48px;height:48px;object-fit:cover;border-radius:6px;
border:1px solid rgba(0,0,0,.08);background:#f3f4f6;cursor:pointer}
html.dark .rp-thumb{border-color:rgba(255,255,255,.1);background:#374151}
.rp-thumb-ph{width:48px;height:48px;border-radius:6px;border:1px dashed rgba(0,0,0,.12);
background:rgba(0,0,0,.03);display:flex;align-items:center;justify-content:center;flex-shrink:0}
html.dark .rp-thumb-ph{border-color:rgba(255,255,255,.1);background:rgba(255,255,255,.03)}
.rp-hover-preview{position:fixed;z-index:100000;pointer-events:none;
max-width:320px;max-height:420px;width:auto;height:auto;
border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.35);
opacity:0;transition:opacity .18s}
.rp-hover-preview.show{opacity:1}
.rp-card-meta{display:flex;flex-direction:column;gap:3px;flex:1;min-width:0}
.rp-tag{padding:1px 5px;border-radius:4px;font-size:10px;font-weight:600;align-self:flex-start;
background:rgba(0,0,0,.06);color:#9ca3af;line-height:1.4;font-family:'SF Mono',Menlo,monospace}
html.dark .rp-tag{background:rgba(255,255,255,.08);color:#6b7280}
.rp-preview{font-size:11px;color:#aaa;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
html.dark .rp-preview{color:#555}
.rp-arrow{flex-shrink:0;transition:transform .18s;color:#10a37f;opacity:.7}
.rp-card.open .rp-arrow{transform:rotate(90deg)}
.rp-card-body{display:none;padding:0 10px 10px}
.rp-card.open .rp-card-body{display:block}
.rp-txt{background:rgba(0,0,0,.03);border-radius:7px;padding:9px 11px;
font-family:'SF Mono',Menlo,Consolas,monospace;font-size:11px;line-height:1.6;
white-space:pre-wrap;word-break:break-word;max-height:160px;overflow-y:auto;color:#374151}
html.dark .rp-txt{background:rgba(255,255,255,.04);color:#d1d5db}
.rp-card-acts{display:flex;gap:6px;margin-top:8px}
.rp-copy-btn,.rp-dl-btn{display:inline-flex;align-items:center;justify-content:center;gap:5px;
padding:7px 12px;border:none;border-radius:7px;font-size:12px;font-weight:500;
cursor:pointer;font-family:inherit;transition:all .15s;flex:1}
.rp-copy-btn{background:rgba(16,163,127,.1);color:#10a37f}
.rp-copy-btn:hover{background:rgba(16,163,127,.2)}
.rp-copy-btn.ok{background:#10a37f;color:#fff}
.rp-dl-btn{background:rgba(59,130,246,.08);color:#3b82f6}
.rp-dl-btn:hover{background:rgba(59,130,246,.15)}
.rp-footer{padding:8px 10px;border-top:1px solid rgba(0,0,0,.06);display:flex;gap:6px;flex-shrink:0}
html.dark .rp-footer{border-top-color:rgba(255,255,255,.06)}
.rp-dl-sel-btn,.rp-dl-all-btn{display:inline-flex;align-items:center;justify-content:center;
gap:5px;padding:7px 10px;border:none;border-radius:8px;font-size:12px;font-weight:500;
cursor:pointer;font-family:inherit;transition:all .15s}
.rp-dl-sel-btn{background:#10a37f;color:#fff;flex:1}
.rp-dl-sel-btn:hover{background:#0d8a6b}
.rp-dl-sel-btn:disabled{background:#9ca3af;cursor:not-allowed}
html.dark .rp-dl-sel-btn:disabled{background:rgba(255,255,255,.15);color:rgba(255,255,255,.3)}
.rp-dl-all-btn{background:rgba(16,163,127,.1);color:#10a37f;flex:1}
.rp-dl-all-btn:hover{background:rgba(16,163,127,.2)}
html.dark .rp-dl-all-btn{background:rgba(16,163,127,.15);color:#10a37f}
html.dark .rp-dl-all-btn:hover{background:rgba(16,163,127,.25)}
.rp-toast{position:fixed;bottom:80px;left:50%;transform:translateX(-50%) translateY(16px);
background:#10a37f;color:#fff;padding:7px 18px;border-radius:8px;font-size:13px;
z-index:99999;opacity:0;transition:all .28s;pointer-events:none}
.rp-toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
.rp-status{position:fixed;bottom:10px;right:10px;z-index:99999;background:rgba(0,0,0,.8);
color:#0f0;font-size:11px;padding:3px 9px;border-radius:5px;font-family:monospace}
`;
document.head.appendChild(s);
}
const SVG = {
brush: ``,
arrow: ``,
copy: ``,
check: ``,
refresh: ``,
download: ``,
img: ``,
};
function toast(m) {
let t = document.querySelector('.rp-toast');
if (!t) { t = document.createElement('div'); t.className = 'rp-toast'; document.body.appendChild(t); }
t.textContent = m; t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 1800);
}
function showStatus(msg) {
let el = document.querySelector('.rp-status');
if (!el) { el = document.createElement('div'); el.className = 'rp-status'; document.body.appendChild(el); }
el.textContent = '[RP] ' + msg;
clearTimeout(el._t);
el._t = setTimeout(() => el.remove(), 8000);
}
function escHtml(s) { return s.replace(/&/g,'&').replace(//g,'>'); }
function createFab() {
if (document.getElementById('rp-fab')) return;
const fab = document.createElement('button');
fab.id = 'rp-fab'; fab.title = '手动提取图片优化提示词';
fab.innerHTML = SVG.brush;
fab.style.display = getConversationId() ? 'flex' : 'none';
fab.onclick = async () => {
const p = document.getElementById('rp-panel');
const wasOpen = p?.classList.contains('open');
if (p) p.classList.toggle('open');
if (wasOpen) return;
await manualFetchPrompts();
};
document.body.appendChild(fab);
}
function createPanel() {
if (document.getElementById('rp-panel')) return;
const panel = document.createElement('div');
panel.id = 'rp-panel';
panel.innerHTML = `
${SVG.brush.replace('width="20" height="20"','width="16" height="16"')}
优化提示词
0
`;
document.body.appendChild(panel);
document.getElementById('rp-refresh').onclick = manualFetchPrompts;
document.getElementById('rp-sel-all').onclick = toggleSelectAll;
document.getElementById('rp-dl-sel').onclick = downloadSelected;
document.getElementById('rp-dl-all').onclick = downloadAll;
}
function updateFab() {
const fab = document.getElementById('rp-fab');
if (!fab) return;
const total = allRounds.reduce((s,r) => s + r.prompts.length, 0);
fab.style.display = getConversationId() ? 'flex' : 'none';
const old = fab.querySelector('.rp-badge'); if (old) old.remove();
if (total > 0) {
const b = document.createElement('span'); b.className = 'rp-badge'; b.textContent = total;
fab.appendChild(b);
}
const ce = document.getElementById('rp-count'); if (ce) ce.textContent = total + ' 条';
}
function updateFooter() {
const allP = allRounds.flatMap(r => r.prompts);
const selCount = allP.filter(p => p.selected).length;
const btn = document.getElementById('rp-dl-sel');
if (btn) {
btn.disabled = selCount === 0;
btn.innerHTML = `${SVG.download} 下载选中 (${selCount})`;
}
const allBtn = document.getElementById('rp-dl-all');
if (allBtn) allBtn.innerHTML = `${SVG.download} 全部下载 (${allP.length})`;
}
function toggleSelectAll() {
const allP = allRounds.flatMap(r => r.prompts);
const allSelected = allP.every(p => p.selected);
allP.forEach(p => p.selected = !allSelected);
document.querySelectorAll('.rp-cb').forEach(cb => cb.checked = !allSelected);
document.querySelectorAll('.rp-card').forEach(card => card.classList.toggle('selected', !allSelected));
document.getElementById('rp-sel-all').textContent = allSelected ? '全选' : '取消全选';
updateFooter();
}
async function downloadImage(url, filename) {
try {
const r = await fetch(url, { credentials: 'include' });
const blob = await r.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = filename; a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 3000);
} catch(e) { window.open(url, '_blank'); }
}
// ===== ZIP 打包下载 =====
function getJSZip() {
// 通过 @require 加载,直接使用全局 JSZip
if (typeof JSZip !== 'undefined') return JSZip;
if (window.JSZip) return window.JSZip;
return null;
}
async function downloadAsZip(urls, zipName) {
if (!urls.length) { toast('没有可下载的图片'); return; }
try {
toast(`正在打包 ${urls.length} 张图片...`);
const ZipClass = getJSZip();
if (!ZipClass) throw new Error('JSZip 未加载');
const zip = new ZipClass();
let done = 0;
for (let i = 0; i < urls.length; i++) {
try {
const r = await fetch(urls[i], { credentials: 'include' });
const blob = await r.blob();
const ext = blob.type?.includes('png') ? 'png' : blob.type?.includes('webp') ? 'webp' : 'jpg';
zip.file(`image-${i+1}.${ext}`, blob);
done++;
} catch(e) { log('⚠️ 图片下载失败:', urls[i]); }
}
if (done === 0) { toast('所有图片下载失败'); return; }
const content = await zip.generateAsync({ type: 'blob' });
const a = document.createElement('a');
a.href = URL.createObjectURL(content);
a.download = zipName;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 3000);
toast(`已打包 ${done} 张图片`);
} catch(e) {
log('❌ ZIP 打包失败:', e.message);
toast('打包失败,改为逐张下载...');
for (let i = 0; i < urls.length; i++) {
await downloadImage(urls[i], `chatgpt-img-${i+1}.png`);
await new Promise(r => setTimeout(r, 300));
}
}
}
async function downloadSelected() {
const selP = allRounds.flatMap(r => r.prompts).filter(p => p.selected);
const urls = selP.flatMap(p => p.imageUrls);
if (!urls.length) return;
await downloadAsZip(urls, `chatgpt-selected-${urls.length}imgs.zip`);
}
async function downloadAll() {
const urls = allRounds.flatMap(r => r.prompts).flatMap(p => p.imageUrls);
if (!urls.length) { toast('没有可下载的图片'); return; }
await downloadAsZip(urls, `chatgpt-all-${urls.length}imgs.zip`);
}
// 懒解析:在渲染前尝试从 DOM 匹配 fileIds 到图片 URL
function resolveFileIds(excludeFileIds = _userUploadedFileIds) {
const allP = allRounds.flatMap(r => r.prompts);
for (const item of allP) {
if (item.imageUrls.length > 0 || !item.fileIds || item.fileIds.length === 0) continue;
for (const fid of item.fileIds) {
// 跳过用户上传的图片
if (excludeFileIds.has(fid)) continue;
const img = document.querySelector(`img[src*="${fid}"]`);
if (img && img.src && !item.imageUrls.includes(img.src)) {
item.imageUrls.push(img.src);
}
}
}
// 兜底:收集所有 DOM 图片按顺序分配
if (allP.some(p => p.imageUrls.length === 0 && p.fileIds?.length > 0)) {
const domImgs = getAllDomImages(excludeFileIds);
const usedUrls = new Set(allP.flatMap(p => p.imageUrls));
const unused = domImgs.filter(u => !usedUrls.has(u));
let idx = 0;
for (const item of allP) {
if (item.imageUrls.length === 0 && idx < unused.length) {
item.imageUrls = [unused[idx++]];
}
}
}
}
function renderPanel() {
resolveFileIds(); // 每次渲染前尝试解析图片
const body = document.getElementById('rp-body');
if (!body) return;
body.innerHTML = '';
let globalIdx = 0;
for (const round of allRounds) {
if (!round.prompts.length) continue;
const div = document.createElement('div');
div.className = 'rp-round-divider';
div.innerHTML = `第 ${round.roundIndex} 轮`;
// 本轮下载按钮(紧跟标题)
const roundImgs = round.prompts.flatMap(p => p.imageUrls);
if (roundImgs.length > 0) {
const btn = document.createElement('button');
btn.className = 'rp-round-dl';
btn.innerHTML = `${SVG.download} 下载 (${roundImgs.length})`;
btn.onclick = async (e) => {
e.stopPropagation();
await downloadAsZip(roundImgs, `chatgpt-round${round.roundIndex}-${roundImgs.length}imgs.zip`);
};
div.appendChild(btn);
}
body.appendChild(div);
for (const item of round.prompts) {
globalIdx++;
body.appendChild(buildCard(item, globalIdx));
}
}
updateFab(); updateFooter();
}
function buildCard(item, index) {
const card = document.createElement('div');
card.className = 'rp-card';
card.dataset.id = item.id;
const preview = item.prompt.substring(0,55).replace(/\n/g,' ') + (item.prompt.length>55?'...':'');
// Build thumbnail strip (show up to 2 thumbs in header)
let thumbsHtml = '';
if (item.imageUrls.length > 0) {
thumbsHtml = '';
item.imageUrls.slice(0, 2).forEach(url => {
thumbsHtml += `
})
`;
});
thumbsHtml += '
';
} else {
thumbsHtml = `${SVG.img}
`;
}
card.innerHTML = `
${thumbsHtml}
#${index || '?'}
${escHtml(preview)}
${SVG.arrow}
${escHtml(item.prompt)}
${item.imageUrls.length > 0 ? `` : ''}
`;
const hdr = card.querySelector('.rp-card-hdr');
const cb = card.querySelector('.rp-cb');
cb.onclick = e => {
e.stopPropagation();
item.selected = cb.checked;
card.classList.toggle('selected', cb.checked);
updateFooter();
};
// 缩略图悬浮预览 + 点击新标签打开
card.querySelectorAll('.rp-thumb').forEach(thumb => {
const previewUrl = thumb.dataset.previewUrl;
let previewEl = null;
thumb.addEventListener('mouseenter', e => {
e.stopPropagation();
if (!previewUrl) return;
if (!previewEl) {
previewEl = document.createElement('img');
previewEl.className = 'rp-hover-preview';
previewEl.src = previewUrl;
document.body.appendChild(previewEl);
}
const rect = thumb.getBoundingClientRect();
previewEl.style.top = Math.max(8, rect.top - 96) + 'px';
previewEl.style.left = Math.max(8, rect.left - 252) + 'px';
requestAnimationFrame(() => previewEl.classList.add('show'));
});
thumb.addEventListener('mouseleave', () => {
if (previewEl) previewEl.classList.remove('show');
});
thumb.addEventListener('click', e => {
e.stopPropagation();
if (previewUrl) window.open(previewUrl, '_blank');
});
});
hdr.onclick = e => {
if (e.target === cb || e.target.classList?.contains('rp-thumb')) return;
card.classList.toggle('open');
};
const copyBtn = card.querySelector('.rp-copy-btn');
copyBtn.onclick = e => {
e.stopPropagation();
navigator.clipboard.writeText(item.prompt).catch(()=>{});
copyBtn.classList.add('ok'); copyBtn.innerHTML = SVG.check + ' 已复制'; toast('已复制到剪贴板');
setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.innerHTML = SVG.copy + ' 复制提示词'; }, 1500);
};
const dlBtn = card.querySelector('.rp-dl-btn');
if (dlBtn) dlBtn.onclick = async e => {
e.stopPropagation();
toast(`下载 ${item.imageUrls.length} 张图片...`);
for (let i = 0; i < item.imageUrls.length; i++) {
await downloadImage(item.imageUrls[i], `chatgpt-img-${i+1}.png`);
await new Promise(r => setTimeout(r, 300));
}
};
return card;
}
// ===== 对话路径排序 =====
// 深度优先遍历整棵对话树,按 create_time 排序,确保覆盖所有分支节点
function getOrderedPath(mapping) {
const childrenOf = {};
for (const [id, node] of Object.entries(mapping)) {
const pid = node.parent;
if (pid) { if (!childrenOf[pid]) childrenOf[pid] = []; childrenOf[pid].push(id); }
}
// 对每个节点的 children 按 create_time 排序(旧 → 新)
for (const pid of Object.keys(childrenOf)) {
childrenOf[pid].sort((a, b) => {
const ta = mapping[a]?.message?.create_time || 0;
const tb = mapping[b]?.message?.create_time || 0;
return ta - tb;
});
}
const root = Object.keys(mapping).find(id => !mapping[id].parent);
if (!root) return Object.keys(mapping);
// 深度优先遍历,收集所有节点
const path = [];
const visited = new Set();
const stack = [root];
while (stack.length > 0) {
const cur = stack.pop();
if (visited.has(cur)) continue;
visited.add(cur);
path.push(cur);
// 逆序入栈,保证先遍历排序靠前(时间较早)的子节点
const ch = childrenOf[cur] || [];
for (let i = ch.length - 1; i >= 0; i--) {
stack.push(ch[i]);
}
}
// 最终按 create_time 排序保证时间顺序
path.sort((a, b) => {
const ta = mapping[a]?.message?.create_time || 0;
const tb = mapping[b]?.message?.create_time || 0;
return ta - tb;
});
return path;
}
// ===== 提取图片 URL =====
// ChatGPT 图片在 API 中使用 asset_pointer 格式: "file-service://file-xxxx"
// 需要从 DOM 中匹配对应的
元素获取真实 URL
function extractImageUrlsFromParts(parts, excludeFileIds = _userUploadedFileIds) {
const urls = [];
const fileIds = [];
for (const part of parts) {
if (!part || typeof part !== 'object') continue;
// asset_pointer: "file-service://file-xxxx" (最常见的格式)
if (part.asset_pointer && typeof part.asset_pointer === 'string') {
const fid = part.asset_pointer.replace('file-service://', '');
if (fid) fileIds.push(fid);
}
// Direct URL fallbacks
if (typeof part.url === 'string' && part.url.startsWith('http')) urls.push(part.url);
if (part.image_url?.url) urls.push(part.image_url.url);
// metadata sources
const gen = part.metadata?.generation;
if (gen?.image_url) urls.push(gen.image_url);
if (gen?.url) urls.push(gen.url);
const dalle = part.metadata?.dalle;
if (dalle?.image_url) urls.push(dalle.image_url);
if (dalle?.url) urls.push(dalle.url);
}
// 从 DOM 中通过 file ID 查找实际图片 URL(排除用户上传的)
for (const fid of fileIds) {
if (excludeFileIds.has(fid)) {
log('🚫 extractImageUrls: 跳过用户上传 file ID:', fid);
continue;
}
const img = document.querySelector(`img[src*="${fid}"]`);
if (img && img.src) {
urls.push(img.src);
log('🖼️ 通过 asset_pointer 匹配到图片:', fid);
} else {
log('⚠️ DOM 中未找到 asset_pointer 图片:', fid);
}
}
return [...new Set(urls)];
}
// 收集 parts 中所有 file ID (用于延迟匹配)
function extractFileIdsFromParts(parts) {
const ids = [];
for (const part of parts) {
if (!part || typeof part !== 'object') continue;
if (part.asset_pointer && typeof part.asset_pointer === 'string') {
ids.push(part.asset_pointer.replace('file-service://', ''));
}
}
return ids;
}
// 判断一个 img 元素是否位于用户消息区域内
function isImageInUserMessage(img) {
// ChatGPT DOM 中用户消息的容器带有 data-message-author-role="user"
const msgEl = img.closest('[data-message-author-role]');
if (msgEl && msgEl.getAttribute('data-message-author-role') === 'user') return true;
// 备用检测:向上查找带 data-message-id 的元素,检查其内部是否标记为 user
const msgContainer = img.closest('[data-message-id]');
if (msgContainer) {
const roleEl = msgContainer.querySelector('[data-message-author-role="user"]');
if (roleEl) return true;
}
return false;
}
function getAllDomImages(excludeFileIds = _userUploadedFileIds) {
// 在主聊天区内查找所有可能的生成图片
const mainArea = document.querySelector('#thread') || document.querySelector('main') || document.body;
const allImgs = [...mainArea.querySelectorAll('img[src^="https"]')];
const results = allImgs
.filter(img => {
const s = img.src;
if (s.includes('cdn.openai.com') || s.includes('favicon') || s.includes('sprites') || s.includes('avatar') || s.includes('og.png')) return false;
// 排除用户消息中的图片(通过 DOM 位置判断)
if (isImageInUserMessage(img)) {
log('🚫 排除用户消息中的图片 (DOM位置)');
return false;
}
// 排除用户上传的图片(通过 file ID 匹配)
for (const fid of excludeFileIds) {
if (s.includes(fid)) {
log('🚫 排除用户上传图片 (fileID):', fid);
return false;
}
}
if (s.includes('oaiusercontent') || s.includes('openai.com/file') || s.includes('dalleprodsec')) return true;
if (img.naturalWidth >= 100 || img.width >= 100) return true;
if (img.alt && img.alt.length > 5) return true;
return false;
})
.map(img => img.src);
log('🖼️ getAllDomImages:', results.length, '张 (排除', excludeFileIds.size, '个用户上传)');
return [...new Set(results)];
}
function getImagesFromDomByMsgId(msgId, excludeFileIds = _userUploadedFileIds) {
if (!msgId) return [];
let el = document.querySelector(`[data-message-id="${msgId}"]`);
if (el) {
const imgs = [...el.querySelectorAll('img[src^="https"]')]
.filter(img => {
const s = img.src;
if (s.includes('cdn.openai.com') || s.includes('favicon') || s.includes('sprites')) return false;
// 排除用户消息中的图片
if (isImageInUserMessage(img)) return false;
// 排除用户上传的文件
for (const fid of excludeFileIds) {
if (s.includes(fid)) return false;
}
return true;
})
.map(img => img.src);
if (imgs.length) return imgs;
}
return [];
}
// ===== 核心提取 =====
function buildRounds(conversationData) {
const mapping = conversationData?.mapping;
if (!mapping) return { rounds: [], userUploadedFileIds: new Set() };
const path = getOrderedPath(mapping);
const rounds = [];
let currentRound = null;
let lastAssistantMsgId = null;
const userUploadedFileIds = new Set(); // 收集用户上传图片的 file ID
// Helper: add prompt to current round
function addPrompt(prompt, source, imageUrls, toolMsgId, fileIds) {
if (!currentRound) {
currentRound = { roundIndex: rounds.length + 1, userText: '...', prompts: [] };
rounds.push(currentRound);
}
const cleaned = prompt.replace(/<\|[a-z_]+\|>/gi, '').replace(/\s+$/, '');
if (cleaned.length < 10 || seenPrompts.has(cleaned)) return;
seenPrompts.add(cleaned);
currentRound.prompts.push({
id: String(Math.random()).slice(2),
prompt: cleaned,
source,
imageUrls,
fileIds: fileIds || [],
selected: false,
toolMsgId,
lastAssistantMsgId,
});
}
for (const nodeId of path) {
const node = mapping[nodeId];
const msg = node?.message;
if (!msg) continue;
const role = msg.author?.role;
const ct = msg.content?.content_type;
const parts = msg.content?.parts;
const msgId = msg.id;
if (role === 'user') {
// 跳过 system context 等非真实用户消息
const firstPart = parts?.[0];
if (ct === 'user_editable_context') continue;
currentRound = { roundIndex: rounds.length + 1, userText: '', prompts: [] };
const up = typeof firstPart === 'string' ? firstPart.substring(0,40) : '';
currentRound.userText = up;
rounds.push(currentRound);
lastAssistantMsgId = null;
// 收集用户上传图片的 file ID(用于后续排除)
if (Array.isArray(parts)) {
for (const part of parts) {
if (part && typeof part === 'object' && part.asset_pointer && typeof part.asset_pointer === 'string') {
const fid = part.asset_pointer.replace('file-service://', '');
if (fid) {
userUploadedFileIds.add(fid);
log('📎 记录用户上传图片:', fid);
}
}
}
}
}
// 跳过 system 角色
if (role === 'system') continue;
if (role === 'assistant') lastAssistantMsgId = msgId;
if (role === 'assistant' && ct === 'code' && Array.isArray(parts)) {
for (const part of parts) {
if (typeof part !== 'string') continue;
for (const cp of extractPromptsFromCode(part)) addPrompt(cp, 'code', [], msgId);
}
}
if (role === 'tool' && ct === 'multimodal_text' && Array.isArray(parts)) {
const imgUrls = extractImageUrlsFromParts(parts);
const fids = extractFileIdsFromParts(parts);
for (const part of parts) {
if (typeof part === 'string' && part.startsWith('Model caption:')) {
const cap = part.substring('Model caption:'.length).trim();
if (cap.length > 20) addPrompt(cap, 'caption', imgUrls, msgId, fids);
}
if (part && typeof part === 'object' && part.metadata) {
const gen = part.metadata.generation;
if (typeof gen === 'string' && gen.length > 20) addPrompt(gen, 'generation', imgUrls, msgId, fids);
else if (gen?.prompt?.length > 20) addPrompt(gen.prompt, 'gen.prompt', imgUrls, msgId, fids);
const pd = part.metadata.dalle;
if (pd) {
const rp = pd.revised_prompt || (pd.prompt?.length > 10 ? pd.prompt : null);
if (rp) addPrompt(rp, 'dalle', imgUrls, msgId, fids);
}
}
}
}
const dalle = msg.metadata?.dalle;
if (dalle) {
const rp = dalle.revised_prompt || (dalle.prompt?.length > 10 ? dalle.prompt : null);
if (rp) addPrompt(rp, 'meta.dalle', [], msgId);
}
const igMeta = msg.metadata?.image_generation || msg.metadata?.image_gen_metadata;
if (igMeta?.revised_prompt) addPrompt(igMeta.revised_prompt, 'ig_meta', [], msgId);
const aggP = msg.metadata?.aggregate_result?.dalle?.prompts;
if (Array.isArray(aggP)) for (const dp of aggP) {
if (dp?.revised_prompt) addPrompt(dp.revised_prompt, 'agg', [], msgId);
}
}
// 重新编号轮次(过滤空轮后)
const filtered = rounds.filter(r => r.prompts.length > 0);
filtered.forEach((r, i) => r.roundIndex = i + 1);
log('📎 用户上传图片 file ID 共', userUploadedFileIds.size, '个:', [...userUploadedFileIds]);
return { rounds: filtered, userUploadedFileIds };
}
function extractPromptsFromCode(codeText) {
const prompts = [];
const r1 = /prompt\s*=\s*(?:"""([\s\S]*?)"""|'''([\s\S]*?)''')/g;
let m;
while ((m = r1.exec(codeText)) !== null) { const p=(m[1]||m[2]||'').trim(); if(p.length>20) prompts.push(p); }
if (!prompts.length) {
const r2 = /prompt\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')/g;
while ((m = r2.exec(codeText)) !== null) { const p=(m[1]||m[2]||'').trim(); if(p.length>20) prompts.push(p); }
}
if (!prompts.length) {
const r3 = /text2im\s*\(\s*(?:"""([\s\S]*?)"""|'''([\s\S]*?)'''|"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')/g;
while ((m = r3.exec(codeText)) !== null) { const p=(m[1]||m[2]||m[3]||m[4]||'').trim(); if(p.length>20) prompts.push(p); }
}
return prompts;
}
// ===== 用 DOM 补充图片 URL =====
function enrichWithDomImages(rounds, conversationData, excludeFileIds = _userUploadedFileIds) {
const domImgs = getAllDomImages(excludeFileIds);
log('🖼️ DOM 中找到图片数:', domImgs.length, domImgs.slice(0, 3));
const allP = rounds.flatMap(r => r.prompts);
const noImg = allP.filter(p => p.imageUrls.length === 0);
if (!noImg.length && domImgs.length === 0) return;
// 策略1: 用 file ID 从 API 数据重新匹配 DOM
if (conversationData?.mapping) {
for (const item of noImg) {
if (!item.toolMsgId) continue;
// 找到对应的 tool message
for (const [, node] of Object.entries(conversationData.mapping)) {
if (node?.message?.id === item.toolMsgId) {
const parts = node.message.content?.parts;
if (!Array.isArray(parts)) break;
const fileIds = extractFileIdsFromParts(parts);
for (const fid of fileIds) {
// 跳过用户上传的图片
if (excludeFileIds.has(fid)) continue;
const domMatch = domImgs.find(url => url.includes(fid));
if (domMatch && !item.imageUrls.includes(domMatch)) {
item.imageUrls.push(domMatch);
log('🖼️ 延迟匹配 file ID:', fid);
}
}
break;
}
}
}
}
// 策略2: 通过 data-message-id 在 DOM 中查找
for (const item of allP.filter(p => p.imageUrls.length === 0)) {
const tryIds = [item.toolMsgId, item.lastAssistantMsgId].filter(Boolean);
for (const mid of tryIds) {
const urls = getImagesFromDomByMsgId(mid);
if (urls.length) { item.imageUrls = urls; break; }
}
}
// 策略3: 按顺序分配剩余 DOM 图片
if (domImgs.length > 0) {
const usedUrls = new Set(allP.flatMap(p => p.imageUrls));
const unusedDomImgs = domImgs.filter(u => !usedUrls.has(u));
let idx = 0;
for (const item of allP.filter(p => p.imageUrls.length === 0)) {
if (idx < unusedDomImgs.length) {
item.imageUrls = [unusedDomImgs[idx++]];
}
}
}
}
// ===== API 主流程 =====
async function fetchAndExtractPrompts(forceRefresh) {
const convId = getConversationId();
if (!convId) return;
// 节流:避免频繁请求
const now = Date.now();
if (!forceRefresh && now - lastFetchTime < FETCH_COOLDOWN) {
log('⏸️ 请求节流,跳过');
return;
}
// 避免重复请求同一对话(已有数据时)
if (!forceRefresh && convId === lastFetchConvId && allRounds.length > 0) {
log('⏸️ 对话已加载,跳过重复请求');
return;
}
let token = getAccessToken();
if (!token) {
// 尝试刷新 token
token = await refreshAccessToken();
if (!token) { showStatus('无法获取 token,请刷新页面'); return; }
}
lastFetchTime = now;
log('📡 请求对话数据:', convId);
showStatus('正在获取对话数据...');
try {
let resp = await fetch(`https://chatgpt.com/backend-api/conversation/${convId}`, {
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
credentials: 'include',
});
// Token 过期:自动刷新并重试一次
if (resp.status === 401 || resp.status === 403) {
log('🔑 Token 过期,尝试刷新...');
token = await refreshAccessToken();
if (token) {
resp = await fetch(`https://chatgpt.com/backend-api/conversation/${convId}`, {
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
credentials: 'include',
});
}
}
// 限流处理
if (resp.status === 429) {
log('⚠️ 请求被限流');
showStatus('请求太频繁,请稍后手动重试');
return;
}
if (!resp.ok) {
log('❌ API 返回:', resp.status);
showStatus(`API错误: ${resp.status}`);
return;
}
const data = await resp.json();
lastFetchConvId = convId;
seenPrompts.clear();
const result = buildRounds(data);
allRounds = result.rounds;
_userUploadedFileIds = result.userUploadedFileIds;
// 第一次尝试匹配图片
enrichWithDomImages(allRounds, data, _userUploadedFileIds);
const total = allRounds.reduce((s,r) => s + r.prompts.length, 0);
log(`✅ 提取 ${total} 个提示词,${allRounds.length} 轮对话`);
showStatus(`找到 ${total} 个提示词 / ${allRounds.length} 轮`);
renderPanel();
// 延迟 3 秒再试一次 (等待 DOM 图片加载)
const noImgCount = allRounds.flatMap(r => r.prompts).filter(p => p.imageUrls.length === 0).length;
if (noImgCount > 0) {
log(`⏳ ${noImgCount} 个提示词无图片,3秒后重试DOM匹配...`);
setTimeout(() => {
enrichWithDomImages(allRounds, data, _userUploadedFileIds);
renderPanel();
log('🔄 延迟图片匹配完成');
}, 3000);
}
} catch(e) {
log('❌', e.message);
showStatus('请求失败: ' + e.message);
}
}
async function manualFetchPrompts() {
if (!getConversationId()) {
toast('请先打开一个对话');
updateFab();
return;
}
if (isFetchingPrompts) return;
const refreshBtn = document.getElementById('rp-refresh');
const fab = document.getElementById('rp-fab');
isFetchingPrompts = true;
if (refreshBtn) {
refreshBtn.disabled = true;
refreshBtn.innerHTML = `${SVG.refresh} 提取中`;
}
if (fab) fab.disabled = true;
try {
await fetchAndExtractPrompts(true);
} finally {
isFetchingPrompts = false;
if (refreshBtn) {
refreshBtn.disabled = false;
refreshBtn.innerHTML = `${SVG.refresh} 提取`;
}
if (fab) fab.disabled = false;
}
}
// ===== 主循环 =====
let lastUrl = '';
function startMonitoring() {
const checkUrl = () => {
if (location.href !== lastUrl) {
lastUrl = location.href;
allRounds = []; seenPrompts.clear();
_userUploadedFileIds = new Set(); // 重置用户上传图片排除集
lastFetchConvId = ''; // 重置,允许新对话请求
const body = document.getElementById('rp-body'); if (body) body.innerHTML = '';
const panel = document.getElementById('rp-panel'); if (panel) panel.classList.remove('open');
updateFab(); updateFooter();
}
};
setInterval(checkUrl, 1000);
let debounce = null;
const obs = new MutationObserver(() => {
if (debounce) clearTimeout(debounce);
debounce = setTimeout(() => {
const total = allRounds.reduce((s,r) => s + r.prompts.length, 0);
if (total === 0) return;
// 已手动提取到提示词后,仅补充图片匹配,不再重复 API 请求
const noImgCount = allRounds.flatMap(r => r.prompts).filter(p => p.imageUrls.length === 0).length;
if (noImgCount === 0) return;
const imgs = getAllDomImages(_userUploadedFileIds);
if (imgs.length > 0) { enrichWithDomImages(allRounds, null, _userUploadedFileIds); renderPanel(); }
}, 5000); // 从 3 秒改为 5 秒,减少触发频率
});
obs.observe(document.body, { childList: true, subtree: true });
if (getConversationId()) {
log('当前对话:', getConversationId());
updateFab();
}
}
function boot() {
log('🎨 v5.2 启动 (修复多轮提取+排除用户上传图)');
injectStyles(); createFab(); createPanel(); startMonitoring();
}
if (document.readyState === 'complete') boot();
else window.addEventListener('load', boot);
})();