ChatGPT 画像一括ダウンロード

ChatGPT の画像ページで画像を一括選択し、支援案内の後に ZIP で保存します。

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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, '&amp;')
      .replace(/"/g, '&quot;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;');
  }

  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));
  }
})();