乐于分享
好东西不私藏

ChatGPT生成的文件无法下载?一招解决

ChatGPT生成的文件无法下载?一招解决
最近在使用ChatGPT生成文件时,尽管页面已经显示出对应的文件,但点击之后不能正常下载。经过摸索,发现了两种解决方案:1. 使用手机APP下载文件;2. 在ChatGPT 网页中使用js脚本下载,本文主要记录第二种方案。
这类问题出现的原因很多,文件可能没有生成成功,下载链接可能已经失效,浏览器也可能拦截了下载行为。但在 ChatGPT 的 iOS 客户端中,文件是可以正常下载的。结合这一点看,问题大概率不在文件本身,而更可能出在网页前端流程或浏览器环境上。

一、问题排查

打开ChatGPT对话页面后,按F12打开开发者工具,切换到Network面板,选择Fetch/XHR,刷新页面后,点击一次文件,观察新出现的请求。
很快,请求列表里出现了一个比较关键的地址:
download?message_id=...
显然,这说明网页端的下载动作已经发出去了。接着点开这条请求,查看它的Response。返回内容是一段JSON:
{    "status": "success",    "download_url": "https://chatgpt.com/backend-api/estuary/content?id=...",    "metadata": null,    "file_name": null,    "creation_time": null,    "no_auth_user_upload": null,    "mime_type": null,    "file_size_bytes": null}
这个结果说明服务端已经收到了请求,并返回了文件对应的真实下载地址。此时将download_url复制到浏览器中,结果发现可以正常下载文件。
这说明问题大概率不在文件生成或服务端返回结果这一步,而更可能出在网页前端后续的下载处理上。更准确地说,是前端拿到了真实下载地址,但没有继续触发浏览器下载。

二、解决思路

既然 download_url 是有效的,而且手动打开之后浏览器可以正常下载,那么思路就很直接了:让脚本在点击文件之后接管这段流程,监听这次点击对应的下载请求,从返回结果里自动提取 download_url,再补上最后一步浏览器下载。为了避免误触发,我又加了一个短时间窗口和去重逻辑,只处理当前这次人工触发的下载动作。

三、脚本文件

使用方法很简单:
  1. 在浏览器里安装Tampermonkey
  2. 新建一个脚本
  3. 把完整代码粘贴进去并保存
  4. 打开 ChatGPT 对话页面,刷新一次页面
  5. 再去点击需要下载的文件
// ==UserScript==// @name         ChatGPT Download Helper// @namespace    local// @version      2.0// @description  Recovers file downloads when ChatGPT receives a download URL but does not trigger the browser download.// @match        https://chatgpt.com/*// @match        https://chat.openai.com/*// @grant        none// ==/UserScript==(function () {  'use strict';  const LOG_PREFIX = '[ChatGPT Download Helper]';  const USER_GESTURE_WINDOW_MS = 8000;  const handledDownloads = new Set();  let lastTrustedGestureAt = 0;  let lastTrustedGestureLabel = '';  function log(...args) {    console.log(LOG_PREFIX, ...args);  }  function debug(...args) {    console.debug(LOG_PREFIX, ...args);  }  function normalizeUrl(url) {    try {      return new URL(url, location.href).toString();    } catch {      return String(url || '');    }  }  function describeTarget(target) {    const node = target && target.nodeType === Node.ELEMENT_NODE      ? target.closest('button, a, [role="button"], li, div, span')      : null;    if (!node) return 'unknown';    const text = (node.innerText || node.textContent || '').trim().replace(/\s+/g' ');    const aria = (node.getAttribute?.('aria-label') || '').trim();    const testId = (node.getAttribute?.('data-testid') || '').trim();    return [node.tagName?.toLowerCase(), text, aria, testId].filter(Boolean).join(' | ').slice(0160);  }  function markTrustedGesture(event) {    if (!event.isTrustedreturn;    if (event.type === 'keydown' && event.key !== 'Enter' && event.key !== ' ') {      return;    }    lastTrustedGestureAt = Date.now();    lastTrustedGestureLabel = describeTarget(event.target);    debug('trusted gesture detected:', event.type, lastTrustedGestureLabel);  }  function hasRecentTrustedGesture() {    return Date.now() - lastTrustedGestureAt <= USER_GESTURE_WINDOW_MS;  }  function isDownloadRequest(rawUrl) {    try {      const url = new URL(rawUrl, location.href);      const path = url.pathname.toLowerCase();      if (url.searchParams.has('message_id') && path.includes('/download')) {        return true;      }      if (path.includes('/interpreter/download')) {        return true;      }      return false;    } catch {      return false;    }  }  function extractDownloadUrl(payload, depth = 0) {    if (!payload || depth > 3return null;    if (typeof payload.download_url === 'string' && payload.download_url) {      return payload.download_url;    }    if (Array.isArray(payload)) {      for (const item of payload) {        const nested = extractDownloadUrl(item, depth + 1);        if (nested) return nested;      }      return null;    }    if (typeof payload === 'object') {      for (const value of Object.values(payload)) {        const nested = extractDownloadUrl(value, depth + 1);        if (nested) return nested;      }    }    return null;  }  function triggerDownload(downloadUrl, sourceUrl) {    const normalizedDownloadUrl = normalizeUrl(downloadUrl);    if (!normalizedDownloadUrl) return;    if (handledDownloads.has(normalizedDownloadUrl)) {      debug('skip duplicate download:', normalizedDownloadUrl);      return;    }    handledDownloads.add(normalizedDownloadUrl);    const anchor = document.createElement('a');    anchor.href = normalizedDownloadUrl;    anchor.rel = 'noopener noreferrer';    anchor.style.display = 'none';    document.body.appendChild(anchor);    anchor.click();    anchor.remove();    log('download triggered:', {      requestnormalizeUrl(sourceUrl),      download: normalizedDownloadUrl,      gesture: lastTrustedGestureLabel || 'recent-user-action'    });  }  async function inspectDownloadResponse(response, requestUrl) {    if (!hasRecentTrustedGesture()) {      debug('ignored download response without a recent trusted gesture:', requestUrl);      return;    }    const contentType = response.headers.get('content-type') || '';    if (!contentType.toLowerCase().includes('application/json')) {      debug('ignored non-json download response:', requestUrl, contentType);      return;    }    const payload = await response.clone().json().catch(() => null);    const downloadUrl = extractDownloadUrl(payload);    if (!downloadUrl) {      debug('download response did not contain a download_url:', requestUrl, payload);      return;    }    triggerDownload(downloadUrl, requestUrl);  }  document.addEventListener('pointerdown', markTrustedGesture, true);  document.addEventListener('click', markTrustedGesture, true);  document.addEventListener('keydown', markTrustedGesture, true);  const originalFetch = window.fetch;  window.fetch = async function (...args) {    const response = await originalFetch.apply(this, args);    try {      const requestUrl = String(args[0]?.url || args[0] || '');      if (isDownloadRequest(requestUrl)) {        inspectDownloadResponse(response, requestUrl).catch((error) => {          debug('fetch inspection failed:', error);        });      }    } catch (error) {      debug('fetch interception failed:', error);    }    return response;  };  const originalOpen = XMLHttpRequest.prototype.open;  const originalSend = XMLHttpRequest.prototype.send;  XMLHttpRequest.prototype.open = function (method, url, ...rest) {    this.__downloadHelperUrl = String(url || '');    return originalOpen.call(this, method, url, ...rest);  };  XMLHttpRequest.prototype.send = function (...args) {    if (isDownloadRequest(this.__downloadHelperUrl)) {      this.addEventListener('load'() => {        try {          if (!hasRecentTrustedGesture()) {            return;          }          const contentType = this.getResponseHeader('content-type') || '';          if (!contentType.toLowerCase().includes('application/json')) {            return;          }          const payload = JSON.parse(this.responseText);          const downloadUrl = extractDownloadUrl(payload);          if (downloadUrl) {            triggerDownload(downloadUrl, this.__downloadHelperUrl);          }        } catch (error) {          debug('xhr inspection failed:', error);        }      });    }    return originalSend.apply(this, args);  };  log('enabled');})();