Selecciona imágenes por lotes en ChatGPT y descárgalas como ZIP tras un aviso opcional de apoyo.
// ==UserScript==
// @name ChatGPT Image Batch Downloader
// @name:zh-CN ChatGPT 图片批量下载
// @name:zh-TW ChatGPT 圖片批次下載
// @name:ja ChatGPT 画像一括ダウンロード
// @name:ko ChatGPT 이미지 일괄 다운로드
// @name:es Descarga por lotes de imágenes de ChatGPT
// @name:fr Téléchargement groupé d’images ChatGPT
// @name:de ChatGPT Bilder gesammelt laden
// @name:pt-BR Download em lote de imagens do ChatGPT
// @name:ru Пакетная загрузка изображений ChatGPT
// @namespace https://example.com/
// @version 0.3.0
// @license MIT
// @description Batch-select images on ChatGPT and download them as a ZIP after an optional support prompt.
// @description:zh-CN 在 ChatGPT 图片库页面批量选择图片,并在赞赏提示后打包下载 ZIP。
// @description:zh-TW 在 ChatGPT 圖片庫頁面批次選擇圖片,並在贊助提示後打包下載 ZIP。
// @description:ja ChatGPT の画像ページで画像を一括選択し、支援案内の後に ZIP で保存します。
// @description:ko ChatGPT 이미지 페이지에서 이미지를 일괄 선택하고 후원 안내 후 ZIP으로 다운로드합니다.
// @description:es Selecciona imágenes por lotes en ChatGPT y descárgalas como ZIP tras un aviso opcional de apoyo.
// @description:fr Sélectionnez des images par lots dans ChatGPT et téléchargez-les en ZIP après une invitation de soutien.
// @description:de Bilder in ChatGPT gesammelt auswählen und nach einem optionalen Unterstützungsdialog als ZIP herunterladen.
// @description:pt-BR Selecione imagens em lote no ChatGPT e baixe como ZIP após um aviso opcional de apoio.
// @description:ru Выбирайте изображения в ChatGPT пакетно и скачивайте ZIP после необязательного окна поддержки.
// @match https://chatgpt.com/images
// @match https://chatgpt.com/images/*
// @grant none
// @run-at document-end
// @noframes
// ==/UserScript==
(function () {
'use strict';
const CARD_ATTR = 'data-cg-batch-card';
const CARD_KEY_ATTR = 'data-cg-batch-key';
const SELECT_ATTR = 'data-cg-batch-selected';
const CONTROL_ATTR = 'data-cg-batch-control';
const DELAY_BETWEEN_DOWNLOADS_MS = 550;
const DONATION_CHANNELS = [
{ id: 'wechat', labelKey: 'channelWechat', imageUrl: 'data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2037%2037%22%20shape-rendering%3D%22crispEdges%22%3E%3Cpath%20fill%3D%22%23ffffff%22%20d%3D%22M0%200h37v37H0z%22%2F%3E%3Cpath%20stroke%3D%22%23111111%22%20d%3D%22M2%202.5h7m1%200h1m3%200h3m1%200h4m1%200h1m4%200h7M2%203.5h1m5%200h1m2%200h7m1%200h1m1%200h1m4%200h1m1%200h1m5%200h1M2%204.5h1m1%200h3m1%200h1m2%200h1m4%200h2m1%200h7m2%200h1m1%200h3m1%200h1M2%205.5h1m1%200h3m1%200h1m1%200h1m7%200h2m1%200h1m2%200h1m3%200h1m1%200h3m1%200h1M2%206.5h1m1%200h3m1%200h1m1%200h6m2%200h1m1%200h1m3%200h2m2%200h1m1%200h3m1%200h1M2%207.5h1m5%200h1m1%200h1m1%200h1m4%200h1m1%200h1m1%200h2m1%200h1m3%200h1m5%200h1M2%208.5h7m1%200h1m1%200h1m1%200h1m1%200h1m1%200h1m1%200h1m1%200h1m1%200h1m1%200h1m1%200h7M10%209.5h1m2%200h1m1%200h2m2%200h3m1%200h2M2%2010.5h1m3%200h1m1%200h3m1%200h1m1%200h1m1%200h2m1%200h2m1%200h3m2%200h5m2%200h1M2%2011.5h6m3%200h2m6%200h1m5%200h2m6%200h2M5%2012.5h1m1%200h2m2%200h1m1%200h4m3%200h1m4%200h3m1%200h1m1%200h1m2%200h1M2%2013.5h1m1%200h1m1%200h2m2%200h1m3%200h2m2%200h1m2%200h5m1%200h2m1%200h1m2%200h2M2%2014.5h2m1%200h1m1%200h3m1%200h1m1%200h3m2%200h1m5%200h3m1%200h1m1%200h2m1%200h1M3%2015.5h5m3%200h1m1%200h4m3%200h2m2%200h1m2%200h3m1%200h2M3%2016.5h1m4%200h1m3%200h1m4%200h1m1%200h2m3%200h3m4%200h1m1%200h1M3%2017.5h1m1%200h1m3%200h4m3%200h4m1%200h1m1%200h2m1%200h1m1%200h2m1%200h4M8%2018.5h4m3%200h1m2%200h3m1%200h3m1%200h1m1%200h1m1%200h3m1%200h1M2%2019.5h4m4%200h1m1%200h2m1%200h2m1%200h1m2%200h1m2%200h1m2%200h1m1%200h1m2%200h1m1%200h1M4%2020.5h2m2%200h3m2%200h1m3%200h3m2%200h2m1%200h1m1%200h1m3%200h1M3%2021.5h1m1%200h2m3%200h1m2%200h2m1%200h5m2%200h4m1%200h1m2%200h1m1%200h2M5%2022.5h1m1%200h2m4%200h1m1%200h2m3%200h1m2%200h1m2%200h3m2%200h1m2%200h1M2%2023.5h1m1%200h1m1%200h1m3%200h1m7%200h1m5%200h2m3%200h1m1%200h2M8%2024.5h1m1%200h3m2%200h2m1%200h3m1%200h1m2%200h1m1%200h1m3%200h1m1%200h1M5%2025.5h3m1%200h3m1%200h1m5%200h2m2%200h1m2%200h1m1%200h4m2%200h1M2%2026.5h5m1%200h3m1%200h2m1%200h3m4%200h2m2%200h8M10%2027.5h1m3%200h4m1%200h1m4%200h3m3%200h1M2%2028.5h7m1%200h2m2%200h2m1%200h1m2%200h1m3%200h3m1%200h1m1%200h3m1%200h1M2%2029.5h1m5%200h1m2%200h1m2%200h3m4%200h1m1%200h2m1%200h1m3%200h2M2%2030.5h1m1%200h3m1%200h1m1%200h2m2%200h1m1%200h3m3%200h3m1%200h5m2%200h2M2%2031.5h1m1%200h3m1%200h1m2%200h2m1%200h1m5%200h1m2%200h1m2%200h3m1%200h1m1%200h1m1%200h1M2%2032.5h1m1%200h3m1%200h1m3%200h4m1%200h1m5%200h3m1%200h4M2%2033.5h1m5%200h1m2%200h9m1%200h1m1%200h1m1%200h3M2%2034.5h7m1%200h2m2%200h1m1%200h1m1%200h1m2%200h1m2%200h3m1%200h4m2%200h1%22%2F%3E%3C%2Fsvg%3E%0A', noteKey: 'channelWechatNote' },
{ id: 'alipay', labelKey: 'channelAlipay', imageUrl: 'data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2037%2037%22%20shape-rendering%3D%22crispEdges%22%3E%3Cpath%20fill%3D%22%23ffffff%22%20d%3D%22M0%200h37v37H0z%22%2F%3E%3Cpath%20stroke%3D%22%23111111%22%20d%3D%22M2%202.5h7m3%200h1m1%200h1m1%200h1m3%200h2m1%200h1m1%200h1m2%200h7M2%203.5h1m5%200h1m3%200h2m3%200h2m1%200h1m1%200h3m1%200h1m1%200h1m5%200h1M2%204.5h1m1%200h3m1%200h1m1%200h2m1%200h1m2%200h1m1%200h1m3%200h2m4%200h1m1%200h3m1%200h1M2%205.5h1m1%200h3m1%200h1m1%200h1m2%200h2m2%200h1m2%200h2m1%200h1m1%200h1m2%200h1m1%200h3m1%200h1M2%206.5h1m1%200h3m1%200h1m1%200h1m1%200h1m2%200h5m2%200h1m2%200h2m1%200h1m1%200h3m1%200h1M2%207.5h1m5%200h1m1%200h2m2%200h1m4%200h1m1%200h1m1%200h1m4%200h1m5%200h1M2%208.5h7m1%200h1m1%200h1m1%200h1m1%200h1m1%200h1m1%200h1m1%200h1m1%200h1m1%200h1m1%200h7M10%209.5h4m4%200h1m2%200h4m1%200h1M2%2010.5h1m1%200h5m2%200h1m1%200h1m1%200h2m1%200h2m1%200h1m2%200h1m1%200h1m1%200h5M4%2011.5h1m2%200h1m2%200h1m3%200h2m3%200h4m1%200h2m2%200h2m1%200h2m1%200h1M5%2012.5h6m1%200h2m1%200h3m1%200h1m2%200h1m1%200h1m5%200h1m1%200h2M4%2013.5h1m7%200h4m1%200h1m1%200h4m3%200h2m2%200h5M3%2014.5h1m1%200h2m1%200h1m1%200h2m4%200h3m2%200h4m1%200h2m2%200h2m1%200h2M2%2015.5h3m2%200h1m1%200h2m2%200h2m2%200h1m2%200h1m2%200h1m1%200h1m2%200h2m1%200h1m1%200h2M2%2016.5h3m1%200h1m1%200h1m2%200h1m1%200h1m1%200h1m1%200h3m2%200h2m2%200h3m2%200h3M2%2017.5h2m9%200h1m1%200h1m1%200h1m2%200h1m2%200h1m3%200h2m1%200h3M5%2018.5h1m2%200h1m2%200h1m1%200h7m4%200h1m1%200h2m1%200h2m3%200h1M2%2019.5h2m1%200h2m2%200h6m1%200h1m3%200h4m1%200h1m1%200h3m1%200h2m1%200h1M2%2020.5h2m4%200h4m1%200h1m3%200h2m1%200h1m1%200h2m2%200h1m2%200h2m1%200h2M3%2021.5h1m2%200h2m5%200h1m2%200h1m4%200h1m1%200h3m1%200h1m1%200h4m1%200h1M5%2022.5h2m1%200h5m2%200h3m2%200h1m2%200h1m1%200h1m1%200h1m1%200h3M2%2023.5h1m2%200h2m3%200h4m1%200h5m2%200h4m1%200h2m2%200h1m2%200h1M2%2024.5h1m3%200h8m1%200h1m3%200h3m1%200h1m5%200h5M2%2025.5h1m2%200h1m1%200h1m4%200h1m5%200h1m2%200h7m3%200h3M2%2026.5h1m3%200h5m4%200h2m1%200h2m1%200h1m2%200h8m1%200h2M10%2027.5h1m1%200h2m5%200h1m1%200h2m1%200h1m1%200h1m3%200h1m1%200h1m1%200h1M2%2028.5h7m2%200h1m2%200h2m1%200h3m4%200h3m1%200h1m1%200h1m1%200h2M2%2029.5h1m5%200h1m1%200h2m1%200h5m1%200h4m3%200h1m3%200h3M2%2030.5h1m1%200h3m1%200h1m1%200h2m1%200h2m1%200h3m2%200h4m1%200h6m1%200h2M2%2031.5h1m1%200h3m1%200h1m1%200h1m3%200h1m2%200h1m1%200h2m6%200h1m2%200h1m3%200h1M2%2032.5h1m1%200h3m1%200h1m1%200h2m1%200h1m1%200h4m1%200h1m1%200h2m2%200h4m1%200h2M2%2033.5h1m5%200h1m4%200h1m1%200h3m2%200h1m2%200h2m5%200h3M2%2034.5h7m1%200h1m1%200h1m4%200h4m4%200h2m1%200h2m3%200h1%22%2F%3E%3C%2Fsvg%3E%0A', noteKey: 'channelAlipayNote' },
];
const DONATION_MIN_DISPLAY_MS = 2200;
const I18N = {
zh: {
appTitle: '图片批量下载',
openPanel: '打开图片批量下载',
openPanelAria: '打开图片批量下载面板',
closePanel: '收起图片批量下载',
closePanelAria: '收起图片批量下载面板',
collapsePanel: '收起面板',
selectedCount: '已选 {count}',
scanning: '正在扫描图片...',
selectAll: '全选',
clearSelection: '取消全选',
downloadThumb: '下载缩略图',
downloadOriginal: '下载原图',
selectImageTitle: '选择这张图片(Shift 点击可区间选择)',
selectImageAria: '选择这张图片',
noSelection: '还没有选择图片。',
thumbnailLabel: '图片',
originalLabel: '原图',
fetching: '正在获取{label} {current}/{total}...',
reading: '正在读取{label} {current}/{total}...',
readyDonation: '原图读取完成,等待赞赏码展示...',
generatingZip: '正在生成压缩包...',
packed: '已打包 {count} 张{label}。',
interrupted: '下载中断:{message}',
noImageUrl: '没有找到图片地址',
noOpenButton: '没有找到打开预览原图的按钮',
noPreviewUrl: '预览原图没有可用地址',
waitTimeout: '等待元素超时',
fetchFailed: '读取图片失败:HTTP {status}',
donationTitle: '原图已准备好',
donationDesc: '已帮你准备好 {count} 张原图。这个工具能省下逐张打开和保存的时间;如果它对你有用,欢迎用微信或支付宝支持一下,作者会继续适配 ChatGPT 页面变化。',
donationWaiting: '请稍候...',
continueZip: '继续下载 ZIP',
donationFallbackLabel: '赞赏',
donationPlaceholder: '请在脚本顶部配置 {label} 收款码',
donationQrAlt: '{label}赞赏收款码',
currentChannel: '当前渠道:{label}',
currentChannelWithNote: '当前渠道:{label}。{note}',
recognizedSelected: '已识别 {total} 张,已选择 {selected} 张。',
channelWechat: '微信',
channelWechatNote: '微信赞赏,哪怕是一杯咖啡,也能支持后续维护。',
channelAlipay: '支付宝',
channelAlipayNote: '支付宝支持,感谢你让这个工具继续变得更好用。',
},
en: {
appTitle: 'Batch image download',
openPanel: 'Open batch image downloader',
openPanelAria: 'Open batch image download panel',
closePanel: 'Collapse batch image downloader',
closePanelAria: 'Collapse batch image download panel',
collapsePanel: 'Collapse panel',
selectedCount: 'Selected {count}',
scanning: 'Scanning images...',
selectAll: 'Select all',
clearSelection: 'Clear selection',
downloadThumb: 'Download thumbnails',
downloadOriginal: 'Download originals',
selectImageTitle: 'Select this image (Shift-click for range selection)',
selectImageAria: 'Select this image',
noSelection: 'No images selected yet.',
thumbnailLabel: 'image',
originalLabel: 'original',
fetching: 'Getting {label} {current}/{total}...',
reading: 'Reading {label} {current}/{total}...',
readyDonation: 'Originals are ready. Showing support QR...',
generatingZip: 'Creating ZIP...',
packed: 'Packed {count} {label}(s).',
interrupted: 'Download interrupted: {message}',
noImageUrl: 'Image URL not found',
noOpenButton: 'Open original preview button not found',
noPreviewUrl: 'Preview original URL is unavailable',
waitTimeout: 'Timed out waiting for element',
fetchFailed: 'Failed to read image: HTTP {status}',
donationTitle: 'Your originals are ready',
donationDesc: '{count} originals are ready. This tool saves the time of opening and saving images one by one. If it helped you, a WeChat or Alipay tip supports ongoing maintenance as ChatGPT changes.',
donationWaiting: 'Please wait...',
continueZip: 'Continue to ZIP download',
donationFallbackLabel: 'Support',
donationPlaceholder: 'Configure the {label} payment QR at the top of the script',
donationQrAlt: '{label} support QR code',
currentChannel: 'Current channel: {label}',
currentChannelWithNote: 'Current channel: {label}. {note}',
recognizedSelected: 'Found {total}; selected {selected}.',
channelWechat: 'WeChat',
channelWechatNote: 'A WeChat tip, even coffee-sized, helps keep the tool maintained.',
channelAlipay: 'Alipay',
channelAlipayNote: 'Alipay support helps keep this tool useful and up to date.',
},
ja: {
appTitle: '画像一括ダウンロード',
openPanel: '画像一括ダウンローダーを開く',
openPanelAria: '画像一括ダウンロードパネルを開く',
closePanel: '画像一括ダウンローダーを閉じる',
closePanelAria: '画像一括ダウンロードパネルを閉じる',
collapsePanel: 'パネルを閉じる',
selectedCount: '選択済み {count}',
scanning: '画像をスキャン中...',
selectAll: 'すべて選択',
clearSelection: '選択解除',
downloadThumb: 'サムネイルをダウンロード',
downloadOriginal: '原寸をダウンロード',
selectImageTitle: 'この画像を選択(Shift クリックで範囲選択)',
selectImageAria: 'この画像を選択',
noSelection: '画像が選択されていません。',
thumbnailLabel: '画像',
originalLabel: '原寸画像',
fetching: '{label}を取得中 {current}/{total}...',
reading: '{label}を読み込み中 {current}/{total}...',
readyDonation: '原寸画像の読み込みが完了しました。支援QRを表示します...',
generatingZip: 'ZIPを作成中...',
packed: '{count} 枚の{label}をパックしました。',
interrupted: 'ダウンロード中断:{message}',
noImageUrl: '画像URLが見つかりません',
noOpenButton: '原寸プレビューを開くボタンが見つかりません',
noPreviewUrl: '原寸プレビューURLが利用できません',
waitTimeout: '要素の待機がタイムアウトしました',
fetchFailed: '画像の読み込みに失敗しました:HTTP {status}',
donationTitle: '原寸画像の準備ができました',
donationDesc: '{count} 枚の原寸画像を準備しました。このツールは1枚ずつ開いて保存する手間を省きます。役に立ったら、WeChat または Alipay での支援が今後のメンテナンスの励みになります。',
donationWaiting: '少々お待ちください...',
continueZip: 'ZIPダウンロードへ進む',
donationFallbackLabel: '支援',
donationPlaceholder: 'スクリプト先頭で {label} のQRを設定してください',
donationQrAlt: '{label} 支援QRコード',
currentChannel: '現在の方法:{label}',
currentChannelWithNote: '現在の方法:{label}。{note}',
recognizedSelected: '{total} 枚検出、{selected} 枚選択済み。',
channelWechat: 'WeChat',
channelWechatNote: 'コーヒー1杯分でも、継続メンテナンスの支えになります。',
channelAlipay: 'Alipay',
channelAlipayNote: 'ご支援ありがとうございます。ツール改善の励みになります。',
},
ko: {
appTitle: '이미지 일괄 다운로드',
openPanel: '이미지 일괄 다운로더 열기',
openPanelAria: '이미지 일괄 다운로드 패널 열기',
closePanel: '이미지 일괄 다운로더 접기',
closePanelAria: '이미지 일괄 다운로드 패널 접기',
collapsePanel: '패널 접기',
selectedCount: '선택 {count}',
scanning: '이미지 스캔 중...',
selectAll: '전체 선택',
clearSelection: '선택 해제',
downloadThumb: '썸네일 다운로드',
downloadOriginal: '원본 다운로드',
selectImageTitle: '이 이미지 선택 (Shift 클릭으로 범위 선택)',
selectImageAria: '이 이미지 선택',
noSelection: '아직 선택한 이미지가 없습니다.',
thumbnailLabel: '이미지',
originalLabel: '원본',
fetching: '{label} 가져오는 중 {current}/{total}...',
reading: '{label} 읽는 중 {current}/{total}...',
readyDonation: '원본 준비 완료. 후원 QR을 표시합니다...',
generatingZip: 'ZIP 생성 중...',
packed: '{count}개의 {label}을(를) 압축했습니다.',
interrupted: '다운로드 중단: {message}',
noImageUrl: '이미지 주소를 찾을 수 없습니다',
noOpenButton: '원본 미리보기 버튼을 찾을 수 없습니다',
noPreviewUrl: '원본 미리보기 주소를 사용할 수 없습니다',
waitTimeout: '요소 대기 시간 초과',
fetchFailed: '이미지 읽기 실패: HTTP {status}',
donationTitle: '원본이 준비되었습니다',
donationDesc: '{count}개의 원본을 준비했습니다. 이 도구는 이미지를 하나씩 열고 저장하는 시간을 줄여줍니다. 도움이 되었다면 WeChat 또는 Alipay 후원으로 유지보수를 응원해 주세요.',
donationWaiting: '잠시만 기다려 주세요...',
continueZip: 'ZIP 다운로드 계속',
donationFallbackLabel: '후원',
donationPlaceholder: '스크립트 상단에서 {label} 결제 QR을 설정하세요',
donationQrAlt: '{label} 후원 QR 코드',
currentChannel: '현재 채널: {label}',
currentChannelWithNote: '현재 채널: {label}. {note}',
recognizedSelected: '{total}개 감지, {selected}개 선택.',
channelWechat: 'WeChat',
channelWechatNote: '커피 한 잔만큼의 후원도 유지보수에 도움이 됩니다.',
channelAlipay: 'Alipay',
channelAlipayNote: 'Alipay 후원은 도구를 계속 개선하는 데 도움이 됩니다.',
},
es: {
appTitle: 'Descarga por lotes',
openPanel: 'Abrir descarga por lotes',
openPanelAria: 'Abrir panel de descarga por lotes',
closePanel: 'Contraer descarga por lotes',
closePanelAria: 'Contraer panel de descarga por lotes',
collapsePanel: 'Contraer panel',
selectedCount: 'Seleccionadas {count}',
scanning: 'Buscando imágenes...',
selectAll: 'Seleccionar todo',
clearSelection: 'Limpiar selección',
downloadThumb: 'Descargar miniaturas',
downloadOriginal: 'Descargar originales',
selectImageTitle: 'Seleccionar esta imagen (Shift+clic para rango)',
selectImageAria: 'Seleccionar esta imagen',
noSelection: 'Aún no hay imágenes seleccionadas.',
thumbnailLabel: 'imagen',
originalLabel: 'original',
fetching: 'Obteniendo {label} {current}/{total}...',
reading: 'Leyendo {label} {current}/{total}...',
readyDonation: 'Originales listos. Mostrando QR de apoyo...',
generatingZip: 'Creando ZIP...',
packed: 'Empaquetadas {count} {label}.',
interrupted: 'Descarga interrumpida: {message}',
noImageUrl: 'No se encontró la URL de la imagen',
noOpenButton: 'No se encontró el botón de vista previa original',
noPreviewUrl: 'La URL original no está disponible',
waitTimeout: 'Tiempo de espera agotado',
fetchFailed: 'Error al leer la imagen: HTTP {status}',
donationTitle: 'Tus originales están listos',
donationDesc: '{count} originales están listos. Esta herramienta ahorra abrir y guardar imágenes una por una. Si te ayudó, una propina por WeChat o Alipay apoya el mantenimiento.',
donationWaiting: 'Espera...',
continueZip: 'Continuar con ZIP',
donationFallbackLabel: 'Apoyar',
donationPlaceholder: 'Configura el QR de {label} al inicio del script',
donationQrAlt: 'QR de apoyo {label}',
currentChannel: 'Canal actual: {label}',
currentChannelWithNote: 'Canal actual: {label}. {note}',
recognizedSelected: 'Detectadas {total}; seleccionadas {selected}.',
channelWechat: 'WeChat',
channelWechatNote: 'Una pequeña propina por WeChat ayuda a mantener la herramienta.',
channelAlipay: 'Alipay',
channelAlipayNote: 'Tu apoyo por Alipay ayuda a mejorar esta herramienta.',
},
fr: {
appTitle: 'Téléchargement groupé',
openPanel: 'Ouvrir le téléchargement groupé',
openPanelAria: 'Ouvrir le panneau de téléchargement groupé',
closePanel: 'Réduire le téléchargement groupé',
closePanelAria: 'Réduire le panneau de téléchargement groupé',
collapsePanel: 'Réduire le panneau',
selectedCount: 'Sélectionnées {count}',
scanning: 'Analyse des images...',
selectAll: 'Tout sélectionner',
clearSelection: 'Effacer la sélection',
downloadThumb: 'Télécharger les miniatures',
downloadOriginal: 'Télécharger les originaux',
selectImageTitle: 'Sélectionner cette image (Maj-clic pour une plage)',
selectImageAria: 'Sélectionner cette image',
noSelection: 'Aucune image sélectionnée.',
thumbnailLabel: 'image',
originalLabel: 'original',
fetching: 'Récupération {label} {current}/{total}...',
reading: 'Lecture {label} {current}/{total}...',
readyDonation: 'Originaux prêts. Affichage du QR de soutien...',
generatingZip: 'Création du ZIP...',
packed: '{count} {label}(s) compressées.',
interrupted: 'Téléchargement interrompu : {message}',
noImageUrl: 'URL de l’image introuvable',
noOpenButton: 'Bouton d’aperçu original introuvable',
noPreviewUrl: 'URL originale indisponible',
waitTimeout: 'Délai d’attente dépassé',
fetchFailed: 'Échec de lecture de l’image : HTTP {status}',
donationTitle: 'Vos originaux sont prêts',
donationDesc: '{count} originaux sont prêts. Cet outil évite d’ouvrir et d’enregistrer chaque image une par une. S’il vous aide, un soutien via WeChat ou Alipay contribue à sa maintenance.',
donationWaiting: 'Veuillez patienter...',
continueZip: 'Continuer vers le ZIP',
donationFallbackLabel: 'Soutien',
donationPlaceholder: 'Configurez le QR {label} en haut du script',
donationQrAlt: 'QR de soutien {label}',
currentChannel: 'Canal actuel : {label}',
currentChannelWithNote: 'Canal actuel : {label}. {note}',
recognizedSelected: '{total} détectées ; {selected} sélectionnées.',
channelWechat: 'WeChat',
channelWechatNote: 'Même un petit soutien aide à maintenir l’outil.',
channelAlipay: 'Alipay',
channelAlipayNote: 'Votre soutien Alipay aide à améliorer cet outil.',
},
de: {
appTitle: 'Bilder gesammelt laden',
openPanel: 'Batch-Downloader öffnen',
openPanelAria: 'Panel für Batch-Download öffnen',
closePanel: 'Batch-Downloader einklappen',
closePanelAria: 'Panel für Batch-Download einklappen',
collapsePanel: 'Panel einklappen',
selectedCount: 'Ausgewählt {count}',
scanning: 'Bilder werden gesucht...',
selectAll: 'Alle auswählen',
clearSelection: 'Auswahl löschen',
downloadThumb: 'Thumbnails laden',
downloadOriginal: 'Originale laden',
selectImageTitle: 'Dieses Bild auswählen (Shift-Klick für Bereich)',
selectImageAria: 'Dieses Bild auswählen',
noSelection: 'Noch keine Bilder ausgewählt.',
thumbnailLabel: 'Bild',
originalLabel: 'Original',
fetching: '{label} {current}/{total} wird geholt...',
reading: '{label} {current}/{total} wird gelesen...',
readyDonation: 'Originale bereit. Unterstützungs-QR wird angezeigt...',
generatingZip: 'ZIP wird erstellt...',
packed: '{count} {label}(e) gepackt.',
interrupted: 'Download unterbrochen: {message}',
noImageUrl: 'Bild-URL nicht gefunden',
noOpenButton: 'Button für Originalvorschau nicht gefunden',
noPreviewUrl: 'Original-URL ist nicht verfügbar',
waitTimeout: 'Zeitüberschreitung beim Warten',
fetchFailed: 'Bild konnte nicht gelesen werden: HTTP {status}',
donationTitle: 'Originale sind bereit',
donationDesc: '{count} Originale sind bereit. Dieses Tool spart das einzelne Öffnen und Speichern. Wenn es geholfen hat, unterstützt ein Tipp über WeChat oder Alipay die weitere Pflege.',
donationWaiting: 'Bitte warten...',
continueZip: 'ZIP-Download fortsetzen',
donationFallbackLabel: 'Unterstützen',
donationPlaceholder: 'QR für {label} oben im Skript konfigurieren',
donationQrAlt: '{label} Unterstützungs-QR',
currentChannel: 'Aktueller Kanal: {label}',
currentChannelWithNote: 'Aktueller Kanal: {label}. {note}',
recognizedSelected: '{total} gefunden; {selected} ausgewählt.',
channelWechat: 'WeChat',
channelWechatNote: 'Auch ein kleiner WeChat-Tipp hilft bei der Wartung.',
channelAlipay: 'Alipay',
channelAlipayNote: 'Alipay-Unterstützung hilft, dieses Tool aktuell zu halten.',
},
pt: {
appTitle: 'Download em lote',
openPanel: 'Abrir download em lote',
openPanelAria: 'Abrir painel de download em lote',
closePanel: 'Recolher download em lote',
closePanelAria: 'Recolher painel de download em lote',
collapsePanel: 'Recolher painel',
selectedCount: 'Selecionadas {count}',
scanning: 'Procurando imagens...',
selectAll: 'Selecionar tudo',
clearSelection: 'Limpar seleção',
downloadThumb: 'Baixar miniaturas',
downloadOriginal: 'Baixar originais',
selectImageTitle: 'Selecionar esta imagem (Shift-clique para intervalo)',
selectImageAria: 'Selecionar esta imagem',
noSelection: 'Nenhuma imagem selecionada.',
thumbnailLabel: 'imagem',
originalLabel: 'original',
fetching: 'Obtendo {label} {current}/{total}...',
reading: 'Lendo {label} {current}/{total}...',
readyDonation: 'Originais prontos. Mostrando QR de apoio...',
generatingZip: 'Criando ZIP...',
packed: '{count} {label}(ns) empacotadas.',
interrupted: 'Download interrompido: {message}',
noImageUrl: 'URL da imagem não encontrada',
noOpenButton: 'Botão de prévia original não encontrado',
noPreviewUrl: 'URL original indisponível',
waitTimeout: 'Tempo de espera esgotado',
fetchFailed: 'Falha ao ler imagem: HTTP {status}',
donationTitle: 'Originais prontos',
donationDesc: '{count} originais estão prontos. Esta ferramenta economiza abrir e salvar imagens uma por uma. Se ajudou, um apoio via WeChat ou Alipay ajuda na manutenção.',
donationWaiting: 'Aguarde...',
continueZip: 'Continuar para ZIP',
donationFallbackLabel: 'Apoiar',
donationPlaceholder: 'Configure o QR de {label} no topo do script',
donationQrAlt: 'QR de apoio {label}',
currentChannel: 'Canal atual: {label}',
currentChannelWithNote: 'Canal atual: {label}. {note}',
recognizedSelected: '{total} encontradas; {selected} selecionadas.',
channelWechat: 'WeChat',
channelWechatNote: 'Um pequeno apoio no WeChat ajuda a manter a ferramenta.',
channelAlipay: 'Alipay',
channelAlipayNote: 'Seu apoio no Alipay ajuda a melhorar esta ferramenta.',
},
ru: {
appTitle: 'Пакетная загрузка',
openPanel: 'Открыть пакетную загрузку',
openPanelAria: 'Открыть панель пакетной загрузки',
closePanel: 'Свернуть пакетную загрузку',
closePanelAria: 'Свернуть панель пакетной загрузки',
collapsePanel: 'Свернуть панель',
selectedCount: 'Выбрано {count}',
scanning: 'Поиск изображений...',
selectAll: 'Выбрать все',
clearSelection: 'Снять выбор',
downloadThumb: 'Скачать миниатюры',
downloadOriginal: 'Скачать оригиналы',
selectImageTitle: 'Выбрать это изображение (Shift-клик для диапазона)',
selectImageAria: 'Выбрать это изображение',
noSelection: 'Изображения пока не выбраны.',
thumbnailLabel: 'изображение',
originalLabel: 'оригинал',
fetching: 'Получение {label} {current}/{total}...',
reading: 'Чтение {label} {current}/{total}...',
readyDonation: 'Оригиналы готовы. Показываем QR поддержки...',
generatingZip: 'Создание ZIP...',
packed: 'Упаковано {count} {label}.',
interrupted: 'Загрузка прервана: {message}',
noImageUrl: 'URL изображения не найден',
noOpenButton: 'Кнопка предпросмотра оригинала не найдена',
noPreviewUrl: 'URL оригинала недоступен',
waitTimeout: 'Время ожидания истекло',
fetchFailed: 'Не удалось прочитать изображение: HTTP {status}',
donationTitle: 'Оригиналы готовы',
donationDesc: '{count} оригиналов готовы. Инструмент экономит время на открытие и сохранение по одному. Если он помог, поддержка через WeChat или Alipay поможет дальнейшей поддержке.',
donationWaiting: 'Подождите...',
continueZip: 'Продолжить загрузку ZIP',
donationFallbackLabel: 'Поддержать',
donationPlaceholder: 'Настройте QR {label} в начале скрипта',
donationQrAlt: 'QR поддержки {label}',
currentChannel: 'Текущий канал: {label}',
currentChannelWithNote: 'Текущий канал: {label}. {note}',
recognizedSelected: 'Найдено {total}; выбрано {selected}.',
channelWechat: 'WeChat',
channelWechatNote: 'Даже небольшая поддержка WeChat помогает обслуживать инструмент.',
channelAlipay: 'Alipay',
channelAlipayNote: 'Поддержка Alipay помогает развивать этот инструмент.',
},
};
const UI_LANGUAGE = getUiLanguage();
const textEncoder = new TextEncoder();
let crcTable = null;
const state = {
selectedKeys: new Set(),
downloading: false,
observer: null,
scanTimer: 0,
launcher: null,
toolbar: null,
statusEl: null,
countEl: null,
panelOpen: false,
initialized: false,
lastRangeKey: '',
};
function getUiLanguage() {
const language = (navigator.language || navigator.userLanguage || 'en').toLowerCase();
const primary = language.split('-')[0];
if (language.startsWith('zh')) {
return 'zh';
}
return I18N[primary] ? primary : 'en';
}
function t(key, vars = {}) {
const messages = I18N[UI_LANGUAGE] || I18N.en;
const template = messages[key] || I18N.en[key] || key;
return template.replace(/\{(\w+)\}/g, (match, name) => {
return Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match;
});
}
init();
function init() {
waitForPageReady().then(start).catch((error) => {
console.error('[ChatGPT Image Batch Download]', error);
});
window.addEventListener('beforeunload', () => {
state.observer?.disconnect();
window.clearTimeout(state.scanTimer);
});
}
function start() {
if (state.initialized) {
return;
}
state.initialized = true;
injectStyle();
createToolbar();
scheduleScan(0);
state.observer = new MutationObserver(() => {
scheduleScan(1500);
});
state.observer.observe(document.body, {
childList: true,
subtree: true,
});
}
async function waitForPageReady() {
if (document.readyState !== 'complete') {
await new Promise((resolve) => window.addEventListener('load', resolve, { once: true }));
}
await sleep(2500);
await waitForIdle(2500);
}
function waitForIdle(timeoutMs) {
return new Promise((resolve) => {
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(() => resolve(), { timeout: timeoutMs });
return;
}
window.setTimeout(resolve, timeoutMs);
});
}
function scheduleScan(delayMs) {
if (state.scanTimer) {
return;
}
state.scanTimer = window.setTimeout(() => {
state.scanTimer = 0;
waitForIdle(1200).then(() => {
scanCards();
syncToolbar();
});
}, delayMs);
}
function injectStyle() {
if (document.querySelector('#__cg_batch_download_style__')) {
return;
}
const style = document.createElement('style');
style.id = '__cg_batch_download_style__';
style.textContent = `
[${CARD_ATTR}="1"] {
position: relative !important;
}
html.cg-batch-panel-open [${SELECT_ATTR}="1"] {
outline: 3px solid var(--selection, #10a37f) !important;
outline-offset: -3px !important;
}
.cg-batch-check {
position: absolute;
left: 10px;
top: 10px;
z-index: 30;
width: 30px;
height: 30px;
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 999px;
background: rgba(0, 0, 0, 0.24);
color: var(--main-surface-primary, #fff);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.22);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
opacity: 0;
pointer-events: none;
transform: scale(0.94);
transition: background 120ms ease, border-color 120ms ease, box-shadow 120ms ease, opacity 140ms ease, transform 120ms ease;
}
html.cg-batch-panel-open .cg-batch-check {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
.cg-batch-check:hover {
background: rgba(0, 0, 0, 0.42);
transform: scale(1.04);
}
.cg-batch-check::after {
content: "";
width: 11px;
height: 6px;
border-left: 2.5px solid currentColor;
border-bottom: 2.5px solid currentColor;
transform: rotate(-45deg) translate(1px, -1px);
opacity: 0;
}
[${SELECT_ATTR}="1"] .cg-batch-check {
background: var(--selection, #10a37f);
border-color: rgba(255, 255, 255, 0.9);
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.24);
}
[${SELECT_ATTR}="1"] .cg-batch-check::after {
opacity: 1;
}
#__cg_batch_launcher__ {
position: fixed;
right: max(18px, env(safe-area-inset-right));
bottom: max(18px, env(safe-area-inset-bottom));
z-index: 2147483647;
width: 54px;
height: 54px;
border: 1px solid var(--border-light, rgba(0, 0, 0, 0.1));
border-radius: 999px;
background: color-mix(in srgb, var(--main-surface-primary, #fff) 94%, transparent);
color: var(--text-primary, #0d0d0d);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 14px 42px rgba(0, 0, 0, 0.16), 0 2px 10px rgba(0, 0, 0, 0.08);
backdrop-filter: blur(22px) saturate(1.35);
-webkit-backdrop-filter: blur(22px) saturate(1.35);
transition: transform 160ms ease, background 160ms ease, box-shadow 160ms ease, color 160ms ease;
}
#__cg_batch_launcher__:hover {
transform: translateY(-2px);
background: var(--surface-hover, rgba(0, 0, 0, 0.06));
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.18), 0 4px 14px rgba(0, 0, 0, 0.1);
}
#__cg_batch_launcher__:focus-visible {
outline: 2px solid color-mix(in srgb, var(--text-primary, #0d0d0d) 35%, transparent);
outline-offset: 3px;
}
#__cg_batch_launcher__[aria-expanded="true"] {
background: var(--text-primary, #0d0d0d);
color: var(--main-surface-primary, #fff);
transform: translateY(-1px) scale(0.98);
}
#__cg_batch_launcher__ svg {
width: 22px;
height: 22px;
transition: transform 180ms ease, opacity 160ms ease;
}
#__cg_batch_launcher__[aria-expanded="true"] svg {
transform: translateY(1px);
}
#__cg_batch_launcher__ .cg-batch-launch-count {
position: absolute;
right: -4px;
top: -4px;
min-width: 20px;
height: 20px;
padding: 0 6px;
border: 2px solid var(--main-surface-primary, #fff);
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--selection, #10a37f);
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 1;
opacity: 0;
transform: scale(0.82);
transition: opacity 140ms ease, transform 140ms ease;
}
#__cg_batch_launcher__[data-count-visible="1"] .cg-batch-launch-count {
opacity: 1;
transform: scale(1);
}
#__cg_batch_toolbar__ {
position: fixed;
right: max(16px, env(safe-area-inset-right));
bottom: calc(max(18px, env(safe-area-inset-bottom)) + 66px);
z-index: 2147483647;
width: min(360px, calc(100vw - 32px));
padding: 10px;
border: 1px solid var(--border-light, rgba(0, 0, 0, 0.1));
border-radius: 18px;
background: color-mix(in srgb, var(--main-surface-primary, #fff) 92%, transparent);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.16), 0 2px 10px rgba(0, 0, 0, 0.08);
color: var(--text-primary, #0d0d0d);
font: 13px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
backdrop-filter: blur(22px) saturate(1.4);
-webkit-backdrop-filter: blur(22px) saturate(1.4);
opacity: 0;
pointer-events: none;
transform: translateY(12px) scale(0.98);
transform-origin: right bottom;
transition: opacity 170ms ease, transform 170ms cubic-bezier(0.2, 0, 0, 1), visibility 170ms ease;
visibility: hidden;
}
#__cg_batch_toolbar__[data-open="1"] {
opacity: 1;
pointer-events: auto;
transform: translateY(0) scale(1);
visibility: visible;
}
#__cg_batch_donation_overlay__ {
position: fixed;
inset: 0;
z-index: 2147483647;
padding: 18px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.32);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
opacity: 0;
transition: opacity 180ms ease;
}
#__cg_batch_donation_overlay__[data-open="1"] {
opacity: 1;
}
#__cg_batch_donation_overlay__ .cg-batch-donation-card {
width: min(340px, calc(100vw - 36px));
padding: 16px;
border: 1px solid var(--border-light, rgba(0, 0, 0, 0.1));
border-radius: 18px;
background: color-mix(in srgb, var(--main-surface-primary, #fff) 96%, transparent);
color: var(--text-primary, #0d0d0d);
box-shadow: 0 22px 64px rgba(0, 0, 0, 0.24), 0 3px 12px rgba(0, 0, 0, 0.1);
font: 13px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
transform: translateY(10px) scale(0.97);
transition: transform 180ms cubic-bezier(0.2, 0, 0, 1);
}
#__cg_batch_donation_overlay__[data-open="1"] .cg-batch-donation-card {
transform: translateY(0) scale(1);
}
#__cg_batch_donation_overlay__ .cg-batch-donation-title {
margin: 0;
font-size: 16px;
font-weight: 650;
letter-spacing: 0;
}
#__cg_batch_donation_overlay__ .cg-batch-donation-desc {
margin: 6px 0 14px;
color: var(--text-secondary, #676767);
font-size: 12px;
}
#__cg_batch_donation_overlay__ .cg-batch-donation-tabs {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
gap: 6px;
margin: 0 0 14px;
padding: 3px;
border-radius: 12px;
background: var(--surface-secondary, rgba(0, 0, 0, 0.04));
}
#__cg_batch_donation_overlay__ .cg-batch-donation-tab {
min-width: 0;
height: 32px;
border: 0;
border-radius: 9px;
background: transparent;
color: var(--text-secondary, #676767);
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#__cg_batch_donation_overlay__ .cg-batch-donation-tab[data-active="1"] {
background: var(--main-surface-primary, #fff);
color: var(--text-primary, #0d0d0d);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
#__cg_batch_donation_overlay__ .cg-batch-donation-qr {
width: 224px;
aspect-ratio: 1;
margin: 0 auto 14px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: var(--surface-secondary, rgba(0, 0, 0, 0.04));
}
#__cg_batch_donation_overlay__ .cg-batch-donation-qr img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
#__cg_batch_donation_overlay__ .cg-batch-donation-placeholder {
width: 100%;
height: 100%;
padding: 18px;
border: 1px dashed var(--border-light, rgba(0, 0, 0, 0.18));
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary, #676767);
text-align: center;
box-sizing: border-box;
}
#__cg_batch_donation_overlay__ .cg-batch-donation-channel {
margin: -4px 0 12px;
color: var(--text-secondary, #676767);
font-size: 12px;
text-align: center;
}
#__cg_batch_donation_overlay__ .cg-batch-donation-button {
width: 100%;
height: 40px;
border: 0;
border-radius: 10px;
background: var(--text-primary, #0d0d0d);
color: var(--main-surface-primary, #fff);
cursor: pointer;
font: inherit;
font-weight: 600;
}
#__cg_batch_donation_overlay__ .cg-batch-donation-button:disabled {
cursor: wait;
opacity: 0.5;
}
#__cg_batch_toolbar__ .cg-batch-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 2px 2px 8px;
}
#__cg_batch_toolbar__ strong {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
letter-spacing: 0;
}
#__cg_batch_toolbar__ .cg-batch-icon {
width: 28px;
height: 28px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--surface-tertiary, rgba(0, 0, 0, 0.06));
color: var(--text-primary, #0d0d0d);
flex: 0 0 auto;
}
#__cg_batch_toolbar__ .cg-batch-head-actions {
display: inline-flex;
align-items: center;
gap: 6px;
}
#__cg_batch_toolbar__ .cg-batch-icon svg,
#__cg_batch_toolbar__ button svg {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
#__cg_batch_toolbar__ .cg-batch-count {
min-width: 26px;
height: 24px;
padding: 0 8px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--text-primary, #0d0d0d);
color: var(--main-surface-primary, #fff);
font-size: 12px;
font-weight: 600;
}
#__cg_batch_toolbar__ .cg-batch-row {
display: flex;
gap: 6px;
margin-top: 8px;
}
#__cg_batch_toolbar__ button {
height: 36px;
border: 0;
border-radius: 10px;
background: transparent;
color: var(--text-primary, #0d0d0d);
cursor: pointer;
font: inherit;
padding: 0 10px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background 120ms ease, color 120ms ease, opacity 120ms ease;
}
#__cg_batch_toolbar__ button:hover {
background: var(--surface-hover, rgba(0, 0, 0, 0.06));
}
#__cg_batch_toolbar__ button:focus-visible {
outline: 2px solid color-mix(in srgb, var(--text-primary, #0d0d0d) 35%, transparent);
outline-offset: 2px;
}
#__cg_batch_toolbar__ button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
#__cg_batch_toolbar__ .cg-batch-primary {
flex: 1;
min-width: 0;
background: var(--text-primary, #0d0d0d);
color: var(--main-surface-primary, #fff);
font-weight: 600;
}
#__cg_batch_toolbar__ .cg-batch-primary:hover {
background: color-mix(in srgb, var(--text-primary, #0d0d0d) 86%, transparent);
}
#__cg_batch_toolbar__ .cg-batch-secondary {
flex: 1;
background: var(--surface-secondary, rgba(0, 0, 0, 0.04));
}
#__cg_batch_toolbar__ .cg-batch-muted {
min-height: 20px;
padding: 0 2px 2px;
color: var(--text-secondary, #676767);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (prefers-color-scheme: dark) {
#__cg_batch_launcher__ {
border-color: var(--border-light, rgba(255, 255, 255, 0.12));
background: color-mix(in srgb, var(--main-surface-primary, #212121) 90%, transparent);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.42), 0 2px 10px rgba(0, 0, 0, 0.28);
}
#__cg_batch_launcher__:hover {
background: var(--surface-hover, rgba(255, 255, 255, 0.1));
}
#__cg_batch_toolbar__ {
border-color: var(--border-light, rgba(255, 255, 255, 0.12));
background: color-mix(in srgb, var(--main-surface-primary, #212121) 88%, transparent);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.42), 0 2px 10px rgba(0, 0, 0, 0.28);
}
#__cg_batch_donation_overlay__ .cg-batch-donation-card {
border-color: var(--border-light, rgba(255, 255, 255, 0.12));
background: color-mix(in srgb, var(--main-surface-primary, #212121) 94%, transparent);
box-shadow: 0 22px 64px rgba(0, 0, 0, 0.48), 0 3px 12px rgba(0, 0, 0, 0.26);
}
#__cg_batch_toolbar__ .cg-batch-icon,
#__cg_batch_toolbar__ .cg-batch-secondary,
#__cg_batch_donation_overlay__ .cg-batch-donation-tabs,
#__cg_batch_donation_overlay__ .cg-batch-donation-qr {
background: var(--surface-secondary, rgba(255, 255, 255, 0.08));
}
#__cg_batch_donation_overlay__ .cg-batch-donation-tab[data-active="1"] {
background: var(--surface-tertiary, rgba(255, 255, 255, 0.12));
}
}
@media (max-width: 520px) {
#__cg_batch_launcher__ {
right: max(14px, env(safe-area-inset-right));
bottom: max(14px, env(safe-area-inset-bottom));
}
#__cg_batch_toolbar__ {
left: 12px;
right: 12px;
bottom: calc(max(14px, env(safe-area-inset-bottom)) + 66px);
width: auto;
transform-origin: right bottom;
}
}
`;
document.head.appendChild(style);
}
function createToolbar() {
if (state.toolbar) {
return;
}
const launcher = document.createElement('button');
launcher.id = '__cg_batch_launcher__';
launcher.type = 'button';
launcher.title = t('openPanel');
launcher.setAttribute('aria-label', t('openPanelAria'));
launcher.setAttribute('aria-expanded', 'false');
launcher.setAttribute(CONTROL_ATTR, '1');
launcher.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 4.75v9.5m0 0 3.75-3.75M12 14.25 8.25 10.5" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.75 17.75h12.5" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>
</svg>
<span class="cg-batch-launch-count" id="__cg_batch_launch_count__">0</span>
`;
const toolbar = document.createElement('div');
toolbar.id = '__cg_batch_toolbar__';
toolbar.setAttribute('data-open', '0');
toolbar.setAttribute('aria-hidden', 'true');
toolbar.setAttribute(CONTROL_ATTR, '1');
toolbar.innerHTML = `
<div class="cg-batch-head">
<strong>
<span class="cg-batch-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path d="M8 3.75h8a4.25 4.25 0 0 1 4.25 4.25v8A4.25 4.25 0 0 1 16 20.25H8A4.25 4.25 0 0 1 3.75 16V8A4.25 4.25 0 0 1 8 3.75Z" stroke="currentColor" stroke-width="1.7"/>
<path d="m7.75 15.25 2.55-2.55a1.35 1.35 0 0 1 1.9 0l.55.55 1.55-1.55a1.35 1.35 0 0 1 1.9 0l2.05 2.05" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.75 8.75h.01" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/>
</svg>
</span>
${t('appTitle')}
</strong>
<span class="cg-batch-head-actions">
<span id="__cg_batch_count__" class="cg-batch-count">${t('selectedCount', { count: 0 })}</span>
<button id="__cg_batch_close__" type="button" title="${t('collapsePanel')}" aria-label="${t('collapsePanel')}">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M7 7 17 17M17 7 7 17" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>
</svg>
</button>
</span>
</div>
<div id="__cg_batch_status__" class="cg-batch-muted">${t('scanning')}</div>
<div class="cg-batch-row">
<button id="__cg_batch_all__" class="cg-batch-secondary" type="button">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m7 12 3 3 7-7" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.5 4.75h13.25a.5.5 0 0 1 .5.5V18.5a.75.75 0 0 1-.75.75H5.25a.5.5 0 0 1-.5-.5V5.5a.75.75 0 0 1 .75-.75Z" stroke="currentColor" stroke-width="1.6"/>
</svg>
${t('selectAll')}
</button>
<button id="__cg_batch_clear__" class="cg-batch-secondary" type="button">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M7 7 17 17M17 7 7 17" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>
</svg>
${t('clearSelection')}
</button>
</div>
<div class="cg-batch-row">
<button id="__cg_batch_download_thumb__" class="cg-batch-secondary" type="button">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M8 5.75h8A2.25 2.25 0 0 1 18.25 8v8A2.25 2.25 0 0 1 16 18.25H8A2.25 2.25 0 0 1 5.75 16V8A2.25 2.25 0 0 1 8 5.75Z" stroke="currentColor" stroke-width="1.7"/>
<path d="m7.75 15.25 2.25-2.25a1.2 1.2 0 0 1 1.7 0l.55.55 1.15-1.15a1.2 1.2 0 0 1 1.7 0l1.15 1.15" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
${t('downloadThumb')}
</button>
<button id="__cg_batch_download__" class="cg-batch-primary" type="button">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 4.75v9.5m0 0 3.75-3.75M12 14.25 8.25 10.5" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.75 17.75h12.5" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>
</svg>
${t('downloadOriginal')}
</button>
</div>
`;
document.body.appendChild(launcher);
document.body.appendChild(toolbar);
state.launcher = launcher;
state.toolbar = toolbar;
state.statusEl = toolbar.querySelector('#__cg_batch_status__');
state.countEl = toolbar.querySelector('#__cg_batch_count__');
launcher.addEventListener('click', () => {
setPanelOpen(!state.panelOpen);
});
toolbar.querySelector('#__cg_batch_close__').addEventListener('click', () => {
setPanelOpen(false);
});
toolbar.querySelector('#__cg_batch_all__').addEventListener('click', () => {
scanCards();
getCards().forEach((card) => {
const key = getCardKey(card);
if (key) {
state.selectedKeys.add(key);
setCardSelected(card, true);
}
});
syncToolbar();
});
toolbar.querySelector('#__cg_batch_clear__').addEventListener('click', () => {
state.selectedKeys.clear();
state.lastRangeKey = '';
getCards().forEach((card) => setCardSelected(card, false));
syncToolbar();
});
toolbar.querySelector('#__cg_batch_download__').addEventListener('click', () => {
downloadSelected('full');
});
toolbar.querySelector('#__cg_batch_download_thumb__').addEventListener('click', () => {
downloadSelected('thumbnail');
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && state.panelOpen) {
setPanelOpen(false);
}
});
setPanelOpen(true);
}
function setPanelOpen(open) {
state.panelOpen = open;
document.documentElement.classList.toggle('cg-batch-panel-open', open);
if (state.toolbar) {
state.toolbar.setAttribute('data-open', open ? '1' : '0');
state.toolbar.setAttribute('aria-hidden', open ? 'false' : 'true');
}
if (state.launcher) {
state.launcher.setAttribute('aria-expanded', open ? 'true' : 'false');
state.launcher.title = open ? t('closePanel') : t('openPanel');
state.launcher.setAttribute('aria-label', open ? t('closePanelAria') : t('openPanelAria'));
}
if (open) {
scanCards();
syncToolbar();
}
}
function scanCards() {
const buttons = Array.from(document.querySelectorAll('button[aria-label^="Open image:"]'));
buttons.forEach((openButton) => {
const card = findImageCard(openButton);
if (!card || card.getAttribute(CARD_ATTR) === '1') {
return;
}
card.setAttribute(CARD_ATTR, '1');
const key = getCardKey(card);
if (key) {
card.setAttribute(CARD_KEY_ATTR, key);
}
card.appendChild(createCheckButton(card));
if (key && state.selectedKeys.has(key)) {
setCardSelected(card, true);
}
});
}
function findImageCard(openButton) {
let node = openButton.parentElement;
for (let depth = 0; node && depth < 6; depth += 1) {
if (node.querySelector('img[src*="/backend-api/estuary/content"]')) {
return node;
}
node = node.parentElement;
}
return null;
}
function createCheckButton(card) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'cg-batch-check';
button.title = t('selectImageTitle');
button.setAttribute('aria-label', t('selectImageAria'));
button.setAttribute('role', 'checkbox');
button.setAttribute('aria-checked', 'false');
button.setAttribute(CONTROL_ATTR, '1');
['pointerdown', 'mousedown', 'mouseup', 'touchstart', 'dblclick'].forEach((eventName) => {
button.addEventListener(eventName, (event) => {
event.stopPropagation();
});
});
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const key = getCardKey(card);
if (!key) {
return;
}
if (event.shiftKey && state.lastRangeKey && selectCardRange(state.lastRangeKey, card)) {
state.lastRangeKey = key;
syncToolbar();
return;
}
const selected = card.getAttribute(SELECT_ATTR) !== '1';
if (selected) {
state.selectedKeys.add(key);
} else {
state.selectedKeys.delete(key);
}
setCardSelected(card, selected);
state.lastRangeKey = key;
syncToolbar();
});
return button;
}
function getCards() {
return Array.from(document.querySelectorAll(`[${CARD_ATTR}="1"]`));
}
function getSelectedCards() {
return getCards().filter((card) => {
const key = getCardKey(card);
return key && state.selectedKeys.has(key);
});
}
function selectCardRange(anchorKey, targetCard) {
const cards = getCards();
const anchorIndex = cards.findIndex((card) => getCardKey(card) === anchorKey);
const targetIndex = cards.indexOf(targetCard);
if (anchorIndex === -1 || targetIndex === -1) {
return false;
}
const start = Math.min(anchorIndex, targetIndex);
const end = Math.max(anchorIndex, targetIndex);
for (let index = start; index <= end; index += 1) {
const card = cards[index];
const key = getCardKey(card);
if (key) {
state.selectedKeys.add(key);
setCardSelected(card, true);
}
}
return true;
}
function setCardSelected(card, selected) {
card.setAttribute(SELECT_ATTR, selected ? '1' : '0');
const check = card.querySelector('.cg-batch-check');
if (check) {
check.setAttribute('aria-checked', selected ? 'true' : 'false');
}
}
function getCardKey(card) {
const cachedKey = card.getAttribute(CARD_KEY_ATTR);
if (cachedKey) {
return cachedKey;
}
const img = getCardImage(card);
const src = img?.currentSrc || img?.src || '';
if (src) {
card.setAttribute(CARD_KEY_ATTR, src);
return src;
}
const openButton = card.querySelector('button[aria-label^="Open image:"]');
const label = openButton?.getAttribute('aria-label') || '';
if (label) {
card.setAttribute(CARD_KEY_ATTR, label);
}
return label;
}
function getCardImage(card) {
return card.querySelector('img[src*="/backend-api/estuary/content"], img[alt]');
}
async function downloadSelected(mode) {
if (state.downloading) {
return;
}
scanCards();
const cards = getSelectedCards();
if (!cards.length) {
setStatus(t('noSelection'));
return;
}
state.downloading = true;
syncToolbar();
let finalStatus = '';
const isThumbnailMode = mode === 'thumbnail';
const imageLabel = isThumbnailMode ? t('thumbnailLabel') : t('originalLabel');
try {
const entries = [];
const usedNames = new Set();
for (let index = 0; index < cards.length; index += 1) {
const card = cards[index];
setStatus(t('fetching', { label: imageLabel, current: index + 1, total: cards.length }));
const imageUrl = isThumbnailMode ? getThumbnailImageUrl(card) : await getImageUrlFromViewer(card);
setStatus(t('reading', { label: imageLabel, current: index + 1, total: cards.length }));
const blob = await fetchImageBlob(imageUrl);
const filename = uniquifyFilename(buildFilename(card, index + 1, imageUrl, blob.type), usedNames);
entries.push({
name: filename,
data: new Uint8Array(await blob.arrayBuffer()),
});
await sleep(DELAY_BETWEEN_DOWNLOADS_MS);
}
if (!isThumbnailMode) {
setStatus(t('readyDonation'));
await showDonationPrompt(entries.length);
}
setStatus(t('generatingZip'));
saveBlob(createZipBlob(entries), buildZipFilename(mode));
finalStatus = t('packed', { count: entries.length, label: imageLabel });
} catch (error) {
console.error('[ChatGPT Image Batch Download]', error);
finalStatus = t('interrupted', { message: error.message || error });
} finally {
state.downloading = false;
syncToolbar();
if (finalStatus) {
setStatus(finalStatus);
}
}
}
function getThumbnailImageUrl(card) {
const img = getCardImage(card);
const url = img?.currentSrc || img?.src || '';
if (!url) {
throw new Error(t('noImageUrl'));
}
return url;
}
async function showDonationPrompt(imageCount) {
const existingOverlay = document.querySelector('#__cg_batch_donation_overlay__');
if (existingOverlay) {
existingOverlay.remove();
}
const channels = getDonationChannels();
const overlay = document.createElement('div');
overlay.id = '__cg_batch_donation_overlay__';
overlay.setAttribute(CONTROL_ATTR, '1');
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.innerHTML = `
<div class="cg-batch-donation-card">
<p class="cg-batch-donation-title">${t('donationTitle')}</p>
<p class="cg-batch-donation-desc">${t('donationDesc', { count: imageCount })}</p>
<div class="cg-batch-donation-tabs">
${channels.map((channel, index) => `
<button class="cg-batch-donation-tab" type="button" data-index="${index}" data-active="${index === 0 ? '1' : '0'}">${escapeHtml(channel.label)}</button>
`).join('')}
</div>
<div class="cg-batch-donation-qr"></div>
<div class="cg-batch-donation-channel"></div>
<button class="cg-batch-donation-button" type="button" disabled>${t('donationWaiting')}</button>
</div>
`;
document.body.appendChild(overlay);
setActiveDonationChannel(overlay, channels, 0);
overlay.querySelectorAll('.cg-batch-donation-tab').forEach((button) => {
button.addEventListener('click', () => {
setActiveDonationChannel(overlay, channels, Number(button.dataset.index || 0));
});
});
await nextPaint();
overlay.setAttribute('data-open', '1');
await waitForDonationQrReady(overlay);
await nextPaint();
await sleep(DONATION_MIN_DISPLAY_MS);
const continueButton = overlay.querySelector('.cg-batch-donation-button');
continueButton.disabled = false;
continueButton.textContent = t('continueZip');
continueButton.focus();
await new Promise((resolve) => {
continueButton.addEventListener('click', resolve, { once: true });
});
overlay.setAttribute('data-open', '0');
await sleep(180);
overlay.remove();
}
function getDonationChannels() {
const channels = DONATION_CHANNELS
.filter((channel) => channel && channel.id)
.map((channel) => ({
id: String(channel.id),
label: String(channel.labelKey ? t(channel.labelKey) : channel.label || channel.id),
imageUrl: String(channel.imageUrl || ''),
note: String(channel.noteKey ? t(channel.noteKey) : channel.note || ''),
}));
return channels.length ? channels : [{ id: 'donation', label: t('donationFallbackLabel'), imageUrl: '', note: '' }];
}
function setActiveDonationChannel(overlay, channels, index) {
const channel = channels[index] || channels[0];
overlay.querySelectorAll('.cg-batch-donation-tab').forEach((button) => {
button.dataset.active = button.dataset.index === String(index) ? '1' : '0';
});
const qr = overlay.querySelector('.cg-batch-donation-qr');
const label = overlay.querySelector('.cg-batch-donation-channel');
if (qr) {
qr.innerHTML = channel.imageUrl
? `<img src="${escapeHtml(channel.imageUrl)}" alt="${escapeHtml(t('donationQrAlt', { label: channel.label }))}">`
: `<div class="cg-batch-donation-placeholder">${escapeHtml(t('donationPlaceholder', { label: channel.label }))}</div>`;
}
if (label) {
label.textContent = channel.note ? t('currentChannelWithNote', { label: channel.label, note: channel.note }) : t('currentChannel', { label: channel.label });
}
}
function waitForDonationQrReady(overlay) {
const image = overlay.querySelector('.cg-batch-donation-qr img');
if (!image) {
return Promise.resolve();
}
return waitForImageReady(image);
}
function waitForImageReady(image) {
if (image.complete && image.naturalWidth > 0) {
return Promise.resolve();
}
return new Promise((resolve) => {
const done = () => resolve();
image.addEventListener('load', done, { once: true });
image.addEventListener('error', done, { once: true });
});
}
function nextPaint() {
return new Promise((resolve) => {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(resolve);
});
});
}
async function getImageUrlFromViewer(card) {
const openButton = card.querySelector('button[aria-label^="Open image:"]');
if (!openButton) {
throw new Error(t('noOpenButton'));
}
openButton.click();
try {
const viewerImage = await waitForElement(() => {
return findViewerImageForCard(card);
}, 5000);
const imageUrl = viewerImage.currentSrc || viewerImage.src;
if (!imageUrl) {
throw new Error(t('noPreviewUrl'));
}
return imageUrl;
} finally {
await closeImageViewer();
}
}
function findViewerImageForCard(card) {
const cardImage = getCardImage(card);
const cardFileId = getEstuaryFileId(cardImage?.currentSrc || cardImage?.src || '');
const cardAlt = cardImage?.alt || getOpenImageLabel(card);
const images = Array.from(document.querySelectorAll('img[src*="/backend-api/estuary/content"]'));
return images.find((img) => {
const src = img.currentSrc || img.src || '';
if (!src || isThumbnailUrl(src) || !isVisible(img)) {
return false;
}
const fileId = getEstuaryFileId(src);
if (cardFileId && fileId) {
return fileId === cardFileId;
}
return cardAlt && img.alt === cardAlt;
}) || null;
}
function getOpenImageLabel(card) {
const openButton = card.querySelector('button[aria-label^="Open image:"]');
return openButton?.getAttribute('aria-label')?.replace(/^Open image:\s*/i, '') || '';
}
function getEstuaryFileId(url) {
try {
const id = new URL(url, location.href).searchParams.get('id') || '';
return decodeURIComponent(id).match(/file_[a-z0-9]+/i)?.[0] || '';
} catch {
return '';
}
}
function isThumbnailUrl(url) {
try {
const id = new URL(url, location.href).searchParams.get('id') || '';
return /(?:%23|#)thumbnail/i.test(id) || /#thumbnail/i.test(decodeURIComponent(id));
} catch {
return /thumbnail/i.test(url);
}
}
function isVisible(element) {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0
&& rect.height > 0
&& style.visibility !== 'hidden'
&& style.display !== 'none'
&& Number(style.opacity) !== 0;
}
async function closeImageViewer() {
const fullscreenCloseButton = document.querySelector('button[aria-label="Close fullscreen view"]');
if (fullscreenCloseButton && isVisible(fullscreenCloseButton)) {
fullscreenCloseButton.click();
await sleep(250);
return;
}
const closeButton = Array.from(document.querySelectorAll('button, [role="button"]'))
.find((control) => {
if (control.closest(`[${CONTROL_ATTR}="1"]`)) {
return false;
}
const label = [
control.getAttribute('aria-label'),
control.getAttribute('title'),
control.textContent,
]
.filter(Boolean)
.join(' ')
.trim();
return /close|关闭|back|返回/i.test(label) && isVisible(control);
});
if (closeButton) {
closeButton.click();
await sleep(250);
return;
}
document.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
keyCode: 27,
which: 27,
bubbles: true,
cancelable: true,
}));
await sleep(250);
}
function waitForElement(getter, timeoutMs) {
const startedAt = Date.now();
return new Promise((resolve, reject) => {
const timer = window.setInterval(() => {
const element = getter();
if (element) {
window.clearInterval(timer);
resolve(element);
return;
}
if (Date.now() - startedAt >= timeoutMs) {
window.clearInterval(timer);
reject(new Error(t('waitTimeout')));
}
}, 120);
});
}
async function fetchImageBlob(url) {
const response = await fetch(url, {
credentials: 'include',
cache: 'no-store',
});
if (!response.ok) {
throw new Error(t('fetchFailed', { status: response.status }));
}
return response.blob();
}
function saveBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 60 * 1000);
}
function createZipBlob(entries) {
const files = [];
const centralDirectory = [];
let offset = 0;
const modifiedAt = new Date();
entries.forEach((entry) => {
const nameBytes = textEncoder.encode(entry.name);
const checksum = crc32(entry.data);
const localHeader = createLocalFileHeader(nameBytes, entry.data, checksum, modifiedAt);
const centralHeader = createCentralDirectoryHeader(nameBytes, entry.data, checksum, modifiedAt, offset);
files.push(localHeader, entry.data);
centralDirectory.push(centralHeader);
offset += localHeader.length + entry.data.length;
});
const centralDirectoryStart = offset;
centralDirectory.forEach((header) => {
files.push(header);
offset += header.length;
});
files.push(createEndOfCentralDirectory(entries.length, offset - centralDirectoryStart, centralDirectoryStart));
return new Blob(files, { type: 'application/zip' });
}
function createLocalFileHeader(nameBytes, data, checksum, modifiedAt) {
const header = new Uint8Array(30 + nameBytes.length);
const view = new DataView(header.buffer);
const dosDateTime = getDosDateTime(modifiedAt);
view.setUint32(0, 0x04034b50, true);
view.setUint16(4, 20, true);
view.setUint16(6, 0x0800, true);
view.setUint16(8, 0, true);
view.setUint16(10, dosDateTime.time, true);
view.setUint16(12, dosDateTime.date, true);
view.setUint32(14, checksum, true);
view.setUint32(18, data.length, true);
view.setUint32(22, data.length, true);
view.setUint16(26, nameBytes.length, true);
view.setUint16(28, 0, true);
header.set(nameBytes, 30);
return header;
}
function createCentralDirectoryHeader(nameBytes, data, checksum, modifiedAt, offset) {
const header = new Uint8Array(46 + nameBytes.length);
const view = new DataView(header.buffer);
const dosDateTime = getDosDateTime(modifiedAt);
view.setUint32(0, 0x02014b50, true);
view.setUint16(4, 20, true);
view.setUint16(6, 20, true);
view.setUint16(8, 0x0800, true);
view.setUint16(10, 0, true);
view.setUint16(12, dosDateTime.time, true);
view.setUint16(14, dosDateTime.date, true);
view.setUint32(16, checksum, true);
view.setUint32(20, data.length, true);
view.setUint32(24, data.length, true);
view.setUint16(28, nameBytes.length, true);
view.setUint16(30, 0, true);
view.setUint16(32, 0, true);
view.setUint16(34, 0, true);
view.setUint16(36, 0, true);
view.setUint32(38, 0, true);
view.setUint32(42, offset, true);
header.set(nameBytes, 46);
return header;
}
function createEndOfCentralDirectory(entryCount, centralDirectorySize, centralDirectoryStart) {
const header = new Uint8Array(22);
const view = new DataView(header.buffer);
view.setUint32(0, 0x06054b50, true);
view.setUint16(4, 0, true);
view.setUint16(6, 0, true);
view.setUint16(8, entryCount, true);
view.setUint16(10, entryCount, true);
view.setUint32(12, centralDirectorySize, true);
view.setUint32(16, centralDirectoryStart, true);
view.setUint16(20, 0, true);
return header;
}
function getDosDateTime(date) {
const year = Math.max(date.getFullYear(), 1980);
return {
time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
};
}
function crc32(data) {
if (!crcTable) {
crcTable = createCrcTable();
}
let crc = 0xffffffff;
for (let index = 0; index < data.length; index += 1) {
crc = (crc >>> 8) ^ crcTable[(crc ^ data[index]) & 0xff];
}
return (crc ^ 0xffffffff) >>> 0;
}
function createCrcTable() {
const table = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1);
}
table[index] = value >>> 0;
}
return table;
}
function buildFilename(card, index, url, mimeType) {
const img = getCardImage(card);
const alt = sanitizeFilename(img?.alt || '');
const extension = getExtensionFromMimeType(mimeType) || getExtensionFromUrl(url) || 'jpg';
const prefix = String(index).padStart(3, '0');
return `${prefix}-${alt || 'chatgpt-image'}.${extension}`;
}
function buildZipFilename(mode) {
const date = new Date();
const stamp = [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
'-',
String(date.getHours()).padStart(2, '0'),
String(date.getMinutes()).padStart(2, '0'),
String(date.getSeconds()).padStart(2, '0'),
].join('');
const suffix = mode === 'thumbnail' ? 'images' : 'originals';
return `chatgpt-images-${suffix}-${stamp}.zip`;
}
function uniquifyFilename(filename, usedNames) {
if (!usedNames.has(filename)) {
usedNames.add(filename);
return filename;
}
const extensionIndex = filename.lastIndexOf('.');
const basename = extensionIndex > 0 ? filename.slice(0, extensionIndex) : filename;
const extension = extensionIndex > 0 ? filename.slice(extensionIndex) : '';
let counter = 2;
let nextName = `${basename}-${counter}${extension}`;
while (usedNames.has(nextName)) {
counter += 1;
nextName = `${basename}-${counter}${extension}`;
}
usedNames.add(nextName);
return nextName;
}
function getExtensionFromMimeType(mimeType) {
const normalizedType = (mimeType || '').split(';')[0].trim().toLowerCase();
const extensionMap = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
'image/avif': 'avif',
};
return extensionMap[normalizedType] || '';
}
function getExtensionFromUrl(url) {
try {
const pathname = new URL(url, location.href).pathname;
const match = pathname.match(/\.([a-z0-9]{2,5})$/i);
return match?.[1]?.toLowerCase() || '';
} catch {
return '';
}
}
function sanitizeFilename(value) {
return value
.replace(/<\|endoftext\|>/g, '')
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 80);
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '<')
.replace(/>/g, '>');
}
function syncToolbar() {
if (!state.toolbar) {
return;
}
const cards = getCards();
const selectedCount = cards.filter((card) => {
const key = getCardKey(card);
return key && state.selectedKeys.has(key);
}).length;
const download = state.toolbar.querySelector('#__cg_batch_download__');
const downloadThumb = state.toolbar.querySelector('#__cg_batch_download_thumb__');
if (state.countEl) {
state.countEl.textContent = t('selectedCount', { count: selectedCount });
}
if (state.launcher) {
const launchCount = state.launcher.querySelector('#__cg_batch_launch_count__');
if (launchCount) {
launchCount.textContent = String(selectedCount);
}
state.launcher.dataset.countVisible = selectedCount > 0 ? '1' : '0';
}
download.disabled = state.downloading || selectedCount === 0;
downloadThumb.disabled = state.downloading || selectedCount === 0;
if (!state.downloading) {
setStatus(t('recognizedSelected', { total: cards.length, selected: selectedCount }));
}
}
function setStatus(text) {
if (state.statusEl) {
state.statusEl.textContent = text;
}
}
function sleep(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
})();