下載北科i學員PDF

下載北科i學員上面的教材檔案

// ==UserScript==
// @name         下載北科i學員PDF
// @namespace    http://tampermonkey.net/
// @version      1.0.4
// @description  下載北科i學員上面的教材檔案
// @author       Umeow
// @match        https://istudy.ntut.edu.tw/learn/index.php
// @connect      istream.ntut.edu.tw
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @license      MIT
// ==/UserScript==

const parser = new DOMParser();
const ErrorFileChar = [ "/" , "|" ,'\\',"?",'"' ,'*' ,":" ,"<" ,">" , "/" , ":"];

const GM_fetch = getGM_fetch();

const saveData = (function () {
  const a = document.createElement("a");
  document.body.appendChild(a);
  a.style = "display: none";
  return function (blob, fileName) {
    const url = window.URL.createObjectURL(blob);
    a.href = url;
    a.download = fileName;
    a.click();
    window.URL.revokeObjectURL(url);
  };
}());

const GET = async (url, old = false, headers = {}, args = {}) => {
  const res = await GM_fetch(url, {
    ...args,
    headers,
    method: "GET"
  });

  if (old) {
    const data = await res.text();
    const status = res.status;
    return { data, status }
  }

  return res;
}

const POST = async (url, data = {}, dataType = "form", headers = {}, args = {}) => {
  const ContentType = dataType === "form" ?
    "application/x-www-form-urlencoded" :
    "application/json"

  const body = dataType === "form" ?
    new URLSearchParams(data).toString() :
    JSON.stringify(data);

  const res = await GM_fetch(url, {
    ...args,
    body,
    headers: {
      ...headers,
      "Content-Type": ContentType,
      "Access-Control-Allow-Origin": "*"
    },
    method: "POST"
  });

  return res;
}

const get_cid = async () => {
  const cidURL = "https://istudy.ntut.edu.tw/learn/path/launch.php";
  
  const cidResponse = await GET(cidURL, old = true);
  
  const cidHTML = parser.parseFromString(cidResponse['data'], 'text/html');
  
  const cidScriptHTML = cidHTML.getElementsByTagName('script')[0].innerHTML;
  
  const cidLine = cidScriptHTML.split("\n").filter(str => str.includes('cid'))[0];
  
  const cidStartIndex = cidLine.indexOf('/learn');
  
  const cidResultURL = 'https://istudy.ntut.edu.tw' + cidLine.slice(cidStartIndex).slice(0, -3);
  
  const cidResultURLObj = new URL(cidResultURL);
  
  const cid = cidResultURLObj.searchParams.get('cid');
  return cid;
}

const getDownloadArguments = async (cid) => {
  const URL = `https://istudy.ntut.edu.tw/learn/path/pathtree.php?cid=${cid}`;
  const DownloadData = {
    'is_player'			: false,
    'href'				: '',
    'prev_href'			: '',
	  'prev_node_id'		: '',
    'prev_node_title'	: '',
	  'is_download'		: false,
	  'begin_time'		: '',
	  'course_id'			: '',
	  'read_key'			: ''
  }

  const Response = await GET(URL, old = true);
  const HTML = parser.parseFromString(Response['data'], 'text/html');
  const FormElement = HTML.getElementById('fetchResourceForm');
  const InputList = [...FormElement.getElementsByTagName('input')];

  InputList.forEach((InputElement) => {
    const key = InputElement.getAttribute('name');
    if(key === "is_download" || key === "is_player") return;

    const value = InputElement.getAttribute('value') || '';

    DownloadData[key] = value;
  });

  return DownloadData
}

const getFileList = async () => {
  const FileList = [];
  const FileListURL = 'https://istudy.ntut.edu.tw/learn/path/SCORM_loadCA.php';
  const FileListResponse = await GET(FileListURL, old = true);
  const FileListXML = parser.parseFromString(FileListResponse['data'], "text/xml");
  const FileListItems = [...FileListXML.getElementsByTagName('item')].filter((ele) => ele.getAttribute('identifierref'));
  const FileListElements = [...FileListXML.getElementsByTagName('resource')].filter((ele) => ele.getAttribute('identifier'));

  FileListElements.forEach((element) => {
    const identifier = element.getAttribute('identifier');
    const href = '@' + element.getAttribute('href');

    const item = FileListItems.filter((ele) => ele.getAttribute('identifierref') === identifier)[0];
    const name = item.getElementsByTagName('title')[0].innerHTML.split("\t")[0].replace("\n" , "");

    const file = { href, name };
    FileList.push(file);
  });

  return FileList;
}

const getFileType = async (html) => {
    const LocationStartIndex = html.indexOf('location.replace(');
    if(LocationStartIndex !== -1) {
        console.log(html);
        let ResponseLocation = html.slice(LocationStartIndex + 18);
        const EndIndex = ResponseLocation.indexOf(')');
        ResponseLocation = ResponseLocation.slice(0, EndIndex - 1);
        console.log(ResponseLocation);
        
        if(ResponseLocation.includes("viewPDF.php")) {
            const ViewPDFURL = 'https://istudy.ntut.edu.tw/learn/path/' + ResponseLocation;
            const ViewPDF = await GET(ViewPDFURL, old = true);
            const getPDFLine = ViewPDF['data'].split('\n').filter(str => str.includes('getPDF.php'))[0];
            const StartIndex = getPDFLine.indexOf('"');
            const EndIndex = getPDFLine.lastIndexOf('"');
            
            const getPDFURL = getPDFLine.slice(StartIndex + 1, EndIndex);
            const DownloadLink = 'https://istudy.ntut.edu.tw/learn/path/' + getPDFURL;
            return { type: "pdf", DownloadLink , headers: { referer: ViewPDFURL } }
        }

        if(ResponseLocation.includes("player.php")) {
          const PlayerResponse = await GET(ResponseLocation, false);
          const PlayerHTML = await PlayerResponse.text();
          const PlayerHTMLObj = parser.parseFromString(PlayerHTML, "text/html");
          const PlayerList = [...PlayerHTMLObj.getElementsByTagName("video")];

          const DownloadLink = PlayerList.map(ele => {
            const source = ele.getElementsByTagName('source')[0]
            const href = source.getAttribute('src')
            return 'https://istream.ntut.edu.tw/videoplayer/' + href;
          });

          return { type: "record", DownloadLink }
        }

        return { type: "link", DownloadLink: ResponseLocation }
    }

    if(html.includes(`<button onClick="download('`)) {
        const type = "file";
        const ButtonStartIndex = html.indexOf(`<button onClick="download('`);
        const pathStart = html.slice(ButtonStartIndex +  27);

        const ButtonEndIndex = pathStart.indexOf("'");
        const path = pathStart.slice(0, ButtonEndIndex);

        const DownloadLink = "https://istudy.ntut.edu.tw/learn/path/download.php?path=" + encodeURIComponent(path);

        return { type, DownloadLink }
    }

    return { type: null }
}

const DownloadFile = async (DownloadData, file) => {
  let filename = file['name'];
  while(ErrorFileChar.map( c => filename.includes(c) ).filter(bool => bool).length) ErrorFileChar.forEach( c => filename = filename.replace(c, ' '));

  DownloadData['href'] = file['href'];

  const URL =  "https://istudy.ntut.edu.tw/learn/path/SCORM_fetchResource.php";
  
  const Response = await POST(URL, DownloadData);
  const ResponseText = await Response.text();
  
  const FileData = await getFileType(ResponseText);

  const headers = FileData["headers"] || {};
  console.log("headers: ", headers);

  console.log(FileData);

  if(!FileData['type']) {
    alert("無法擷取檔案連結");
    return;
  }

  if(FileData['type'] === 'record') {
    if(!confirm(`即將開啟 ${FileData['DownloadLink'].length} 個錄影影像分頁 是否確定?`)) return;
    for(let i = 0 ; i < FileData['DownloadLink'].length ; i++) {
      const url = FileData['DownloadLink'][i];
      window.open(url);
    }
    return;
  }

  if(FileData['type'] === 'link') {
    if(!confirm(`此教材為外部連結 是否開啟?`)) return;
    window.open(FileData['DownloadLink']);
    return;
  }

  const FileResponse = await GET(FileData['DownloadLink'],
    old = false,
    headers,
  );

  console.log(FileResponse);
    
  const FileBlob = await FileResponse.blob();
  saveData(FileBlob, filename);
}

const getFileListWithDownload = async () => {
  const cid = await get_cid();

  const DownloadData = await getDownloadArguments(cid);
  const FileList = await getFileList();

  FileList.forEach( file => {
    file['download'] = () => DownloadFile(DownloadData, file)
  });

  return FileList;
} 

const makeHTML = (FileList) => {
  const doc = createElement({ tag: "html" });
  createElement({ 
    tag: "head", 
    childs: [
      createElement({ 
        tag: "meta", 
        charset: "utf-8", 
      })
    ],
    appendTo: doc 
  });

  const body = createElement({ 
    tag: "body", 
    style: 'display: flex;flex-direction: column;', 
    appendTo: doc 
  });

  FileList.forEach(file => 
    createElement({
      tag: "div",
      
      childs: [
        createElement({
          tag: "span",
          innerHTML: file['name'],
          style: "margin-right: 10px;"
        }),
        
        createElement({
          tag: "button",
          innerHTML: "下載",
          onclick: file.download
        }),

        createElement({ tag: "br" })
      ],

      style: "margin: 8px;",
      appendTo: body
    })
  );

  return doc;
}

const main = async () => {
  try {
    
    const FileList = await getFileListWithDownload();

    const html = makeHTML(FileList);

    window.open('', 'i學員檔案列表', config='height=500,width=500')
      .document.body.appendChild(html);
  
  } catch(err) {
    
    console.error(err);
    
    alert("請確認是否位於正確的頁面");
    
    return;

  }
}

GM_registerMenuCommand('擷取目前頁面的檔案', main, 'r');

//前端 Element Manager author: Umeow

function createElement({
  tag = 'div',
  classes = [],
  innerHTML = '',
  childs = [],
  appendTo = null,
  onclick = null,
  ...attrs
}) {
  const result = document.createElement(tag)

  classes.filter((c) => c).forEach((c) => result.classList.add(c))
  Object.keys(attrs)
    .filter((attr) => attrs[attr] !== null)
    .forEach((attr) => result.setAttribute(attr, attrs[attr]))
  childs.filter((c) => c).forEach((c) => result.appendChild(c))

  if (innerHTML) result.innerHTML += innerHTML
  if (appendTo instanceof HTMLElement) appendTo.appendChild(result)
  if (onclick instanceof Function) result.onclick = onclick

  return result
}

/*! GM_fetch — v0.3.6-2022.06.04-dev — https://github.com/AlttiRi/gm_fetch */
function getGM_fetch() {
  const GM_XHR = (typeof GM_xmlhttpRequest === "function") ? GM_xmlhttpRequest : (GM?.xmlHttpRequest);
  const isStreamSupported = GM_XHR?.RESPONSE_TYPE_STREAM;
  let firefoxFixedFetch = false;
  const fetch = getWebPageFetch();

  const crError = new Error().stack.startsWith("Error"); // Chromium Error
  // In Chromium original `DOMException` contains stack trace, however, manually created does not have it.

  /**
   * @param {string, URL, Request} resource
   * @param fetchInit */
  async function handleBaseParams(resource, fetchInit = {}) {
      let url;
      if (resource?.url) {
          const {url: u, init} = await destroyRequest(resource);
          url = u;
          fetchInit = {...init, ...fetchInit};
      } else {
          url = new URL(resource, location).href;
      }
      return {url, fetchInit};
  }
  /** @param {Request} request */
  async function destroyRequest(request) {
      const url = request.url;
      const method = request.method;
      const headers = request.headers;
      const signal = request.signal;
      const referrer = request.referrer !== "referrer" ? request.referrer : undefined; // todo test

      let body;
      if (!["GET", "HEAD"].includes(method)) {
          body = await request.blob();
      }
      return {url, init: {method, signal, headers, body}};
  }

  function getWebPageFetch() {
      let fetch = globalThis.fetch;
      // [VM/GM/FM + Firefox with "Enhanced Tracking Protection" set to "Strict" (Or "Custom" with enabled "Fingerprinters" option)
      // on sites with CSP (like Twitter, GitHub)] requires this fix.
      // They run the code as a content script. TM disables CSP with extra HTTP headers.
      // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Sharing_objects_with_page_scripts
      function fixFirefoxFetchOnPageWithCSP() {
          const wrappedJSObject = globalThis.wrappedJSObject;
          const fixRequired = wrappedJSObject && typeof wrappedJSObject.fetch === "function";
          if (!fixRequired) {
              return;
          }
          const isTM = (function() {
              const request = new wrappedJSObject.Request(""); // Firefox content script's `Request` does not support relative URLs
              try {
                  return request === cloneInto(request);
              } catch {
                  console.log("[ujs][fixFirefoxFetchOnPageWithCSP] Request:", Request);
                  return false;
              }
          })();
          if (isTM) {
              return;
          }
          async function fixedFetch(resource, opts = {}) {
              const {url, fetchInit: init} = await handleBaseParams(resource, opts);
              if (init.headers instanceof Headers) {
                  console.log("[ujs][fixedFetch] Headers", init.headers);
                  // Since `Headers` are not allowed for structured cloning.
                  init.headers = Object.fromEntries(init.headers.entries());
              }
              if (/** @type {AbortSignal} */ init.signal) {
                  if (init.signal.aborted) {
                      throw new DOMException("The user aborted a request." + (crError ? new Error().stack.slice(5) : ""), "AbortError");
                  }
                  console.warn("[ujs][fixedFetch] delete signal");
                  delete init.signal; // Can't be structured cloned
              }
              return wrappedJSObject.fetch(cloneInto(url, document), cloneInto(init, document/*, {cloneFunctions: true}*/));
          }
          fetch = fixedFetch;
          firefoxFixedFetch = true;
      }
      fixFirefoxFetchOnPageWithCSP();
      console.log({firefoxFixedFetch});

      async function enhancedFetch(resource, opts) {
          const onprogress = opts.extra?.onprogress;
          delete opts.extra;
          const response = await fetch(resource, opts);
          if (onprogress) {
              return responseProgressProxy(response, onprogress);
          }
          return response;
      }

      return enhancedFetch;
  }

  /** The default Response always has {type: "default", redirected: false, url: ""} */
  class ResponseEx extends Response {
      [Symbol.toStringTag] = "ResponseEx";
      constructor(body, {headers, status, statusText, url, redirected, type, ok}) {
          super(body, {status, statusText, headers: {
                  ...headers,
                  "content-type": headers.get("content-type")?.split("; ")[0] // Fixes Blob type ("text/html; charset=UTF-8") in TM
              }});
          this._type = type;
          this._url = url;
          this._redirected = redirected;
          this._ok = ok;
          this._headers = headers; // `HeadersLike` is more user-friendly for debug than the original `Headers` object
      }
      get redirected() { return this._redirected; }
      get url() { return this._url; }
      get type() { return this._type || "basic"; } // todo: if "cors"
      get ok() { return this._ok; }
      /** @returns {HeadersLike} */
      get headers() { return this._headers; }
  }
  class HeadersLike { // Note: the original `Headers` throws an error if `key` requires `.trim()`
      constructor(headers) {
          headers && Object.entries(headers).forEach(([key, value]) => {
              this.append(key, value);
          });
      }
      get(key) {
          const value = this[key.trim().toLowerCase()];
          return value === undefined ? null : value;
      }
      append(key, value) {
          this[key.trim().toLowerCase()] = value.trim();
      }
      has(key) {
          return this.get(key) !== null;
      }
  }
  /**
   * Parses headers from `XMLHttpRequest.getAllResponseHeaders()` string
   * @returns {HeadersLike} */
  function parseHeaders(headersString) {
      const headers = new HeadersLike();
      for (const line of headersString.trim().split("\n")) {
          const [key, ...valueParts] = line.split(":"); // last-modified: Fri, 21 May 2021 14:46:56 GMT
          const value = valueParts.join(":");
          headers.append(key, value);
      }
      return headers;
  }

  class ReaderLike {
      constructor(blobPromise, body) {
          /** @type {Promise<Blob>} */
          this._blobPromise = blobPromise;
          /** @type {ReadableStreamDefaultReader} */
          this._reader = null;
          /** @type {ReadableStreamLike} */
          this._body = body;
          this._released = false;
      }
      /** @return {Promise<{value: Uint8Array, done: boolean}>} */
      read() {
          if (this._released) {
              throw new TypeError("This readable stream reader has been released and cannot be used to read from its previous owner stream");
          }
          this._body._used = true;
          if (this._reader === null) {
              return new Promise(async (resolve) => {
                  const blob = await this._blobPromise;
                  const response = new Response(blob);
                  this._reader = response.body.getReader();
                  const result = await this._reader.read();
                  resolve(result);
              });
          }
          return this._reader.read();
      }
      releaseLock() {
          this._body.locked = false;
          this._released = true;
      }
  }
  class ReadableStreamLike { // BodyLike
      constructor(blobPromise) {
          this.locked = false;
          this._used = false;
          this._blobPromise = blobPromise;
      }
      getReader() {
          if (this.locked) {
              throw new TypeError("ReadableStreamReader constructor can only accept readable streams that are not yet locked to a reader");
          }
          this._reader = new ReaderLike(this._blobPromise, this);
          this.locked = true;
          return this._reader;
      }
  }
  class ResponseLike {
      constructor(blobPromise, {headers, status, statusText, url, finalUrl}) {
          /** @type {Promise<Blob>} */
          this._blobPromise = blobPromise;
          this.headers = headers;
          this.status = status;
          this.statusText = statusText;
          this.url = finalUrl;
          this.redirected = url !== finalUrl;
          this.type = "basic"; // todo: if "cors"
          this.ok = status.toString().startsWith("2");
          this._bodyUsed = false;
          this.body = new ReadableStreamLike(blobPromise);
      }
      get bodyUsed() {
          return this._bodyUsed || this.body._used;
      }
      blob() {
          if (this.bodyUsed) {
              throw new TypeError("body stream already read");
          }
          if (this.body.locked) {
              throw new TypeError("body stream is locked");
          }
          this._bodyUsed = true;
          this.body.locked = true;
          return this._blobPromise;
      }
      arrayBuffer() { return this.blob().then(blob => blob.arrayBuffer()); }
      text() {        return this.blob().then(blob => blob.text()); }
      json() {        return this.text().then(text => JSON.parse(text)); }
  }

  const identityContentEncodings = new Set([null, "identity", "no encoding"]);
  function getOnProgressProps(response) {
      const {headers, status, statusText, url, redirected, ok} = response;
      const isIdentity = identityContentEncodings.has(headers.get("Content-Encoding"));
      const compressed = !isIdentity;
      const _contentLength = parseInt(headers.get("Content-Length")); // `get()` returns `null` if no header present
      const contentLength = isNaN(_contentLength) ? null : _contentLength;
      const lengthComputable = isIdentity && _contentLength !== null;

      // Original XHR behaviour; in TM it equals to `contentLength`, or `-1` if `contentLength` is `null` (and `0`?).
      const total = lengthComputable ? contentLength : 0;
      const gmTotal = contentLength > 0 ? contentLength : -1; // Like `total` is in TM and GM.

      return {
          gmTotal, total, lengthComputable,
          compressed, contentLength,
          headers, status, statusText, url, redirected, ok
      };
  }

  function responseProgressProxy(response, onProgress) {
      const onProgressProps = getOnProgressProps(response);
      let loaded = 0;
      const reader = response.body.getReader();
      const readableStream = new ReadableStream({
          async start(controller) {
              while (true) {
                  const {done, /** @type {Uint8Array} */ value} = await reader.read();
                  if (done) {
                      break;
                  }
                  loaded += value.length;
                  try {
                      onProgress({loaded, ...onProgressProps});
                  } catch (e) {
                      console.error("[onProgress]:", e);
                  }
                  controller.enqueue(value);
              }
              controller.close();
              reader.releaseLock();
          },
          cancel() {
              void reader.cancel();
          }
      });
      return new ResponseEx(readableStream, response);
  }



  /**
   * The simplified `fetch` — a wrapper for `GM_xmlHttpRequest`.
   * @example
   // @grant       GM_xmlhttpRequest
   const response = await fetch(url);
   const {status, statusText} = response;
   const lastModified = response.headers.get("last-modified");
   const blob = await response.blob();
   * @return {Promise<Response>} */
  async function GM_fetch(url, fetchInit = {}) {
      ({url, fetchInit} = await handleBaseParams(url, fetchInit));

      if (fetchInit.extra?.webContext) {
          delete fetchInit.extra;
          return fetch(url, fetchInit);
      }

      function handleParams(fetchInit) {
          const defaultFetchInit = {method: "GET", headers: {}};
          const defaultExtra = {useStream: true, onprogress: null};
          const opts = {
              ...defaultFetchInit,
              ...fetchInit,
              extra: {
                  ...defaultExtra,
                  ...fetchInit.extra
              }
          };

          const {headers, method, body, referrer, signal, extra: {useStream, onprogress}} = opts;
          delete opts.extra.useStream;
          delete opts.extra.onprogress;

          const _headers = new HeadersLike(headers);
          if (referrer && !_headers.has("referer")) {
              _headers.append("referer", referrer); // todo: handle referrer
          }

          return {
              method, headers: _headers, body, signal,
              useStream, onprogress, extra: opts.extra
          };
      }

      const {
          method, headers, body, signal,
          useStream, onprogress, extra
      } = handleParams(fetchInit);

      if (signal?.aborted) {
          throw new DOMException("The user aborted a request." + (crError ? new Error().stack.slice(5) : ""), "AbortError");
      }
      let abortCallback;
      let done = false;
      function handleAbort(gmAbort) {
          if (!signal) {
              return;
          }
          if (signal.aborted) {
              gmAbort();
              const id = setInterval(() => done ? clearInterval(id) : gmAbort(), 1); // VM fix.
              return;
          }
          abortCallback = () => gmAbort();
          signal.addEventListener("abort", abortCallback);
      }
      function onDone() {
          signal?.removeEventListener("abort", abortCallback);
          done = true;
      }

      const HEADERS_RECEIVED = 2;
      const DONE = 4;
      function getOnReadyStateChange({onHeadersReceived}) {
          return function onReadyStatechange(gmResponse) {
              const {readyState} = gmResponse;
              if (readyState === HEADERS_RECEIVED) {
                  onHeadersReceived(gmResponse);
              }
              // It does not trigger on `abort` and `error`, while native XHR does. (In both TM and VM)
              // Fires only on `onload`. Is a bug? // Also it fires (`readyState === DONE`) multiple times in non the latest VM beta.
              // else if (readyState === DONE) {
              //     onDone();
              // }
          }
      }

      function getOnDones({resolve, reject}) {
          return {
              onload(gmResponse) {
                  onDone();
                  resolve?.(gmResponse.response); // Not required for `responseType: "stream"`
              },
              onerror() {
                  onDone();
                  reject(new TypeError("Failed to fetch"));
              },
              onabort() {
                  onDone();
                  reject(new DOMException("The user aborted a request." + (crError ? new Error().stack.slice(5) : ""), "AbortError"));
              }
          };
      }

      function nonStreamFetch() {
          const _onprogress = onprogress;
          let onProgressProps = {}; // Will be inited on HEADERS_RECEIVED. It used to have the same behaviour in TM and VM.
          return new Promise((resolve, _reject) => {
              function onHeadersReceived(gmResponse) {
                  const {responseHeaders, status, statusText, finalUrl} = gmResponse;
                  const headers = parseHeaders(responseHeaders);
                  const response = new ResponseLike(blobPromise, {
                      headers, status, statusText, url, finalUrl
                  });
                  onProgressProps = getOnProgressProps(response);
                  resolve(response);
              }
              const onreadystatechange = getOnReadyStateChange({onHeadersReceived});
              const blobPromise = new Promise((resolve, reject) => {
                  const {onload, onabort, onerror} = getOnDones({resolve, reject});
                  const {abort} = GM_XHR({
                      ...extra,
                      url,
                      method,
                      headers,
                      responseType: "blob",
                      onreadystatechange,
                      onprogress: _onprogress ? ({loaded/*, total, lengthComputable*/}) => {
                          _onprogress({loaded, ...onProgressProps});
                      } : undefined,
                      onload,
                      onerror,
                      onabort,
                      data: body,
                  });
                  handleAbort(abort);
              });
              blobPromise.catch(_reject);
          });
      }

      function streamFetch() {
          return new Promise((resolve, reject) => {
              function onHeadersReceived(gmResponse) {
                  const {
                      responseHeaders, status, statusText, finalUrl, response: readableStream
                  } = gmResponse;
                  const headers = parseHeaders(responseHeaders);
                  const redirected = url !== finalUrl;
                  let response = new ResponseEx(readableStream, {headers, status, statusText, url: finalUrl, redirected});
                  if (onprogress) {
                      response = responseProgressProxy(response, onprogress);
                  }
                  resolve(response);
              }
              const onreadystatechange = getOnReadyStateChange({onHeadersReceived});
              const {onload, onabort, onerror} = getOnDones({reject});
              const {abort} = GM_XHR({
                  ...extra,
                  url,
                  method,
                  headers,
                  responseType: "stream",
                  /* fetch: true, */ // Not required, since it already has `responseType: "stream"`.
                  onreadystatechange,
                  onload,
                  onerror,
                  onabort,
                  data: body,
              });
              handleAbort(abort);
          });
      }

      if (!isStreamSupported || !useStream) {
          return nonStreamFetch();
      } else {
          return streamFetch();
      }
  }

  GM_fetch.isStreamSupported = isStreamSupported;
  GM_fetch.webContextFetch = fetch;
  GM_fetch.firefoxFixedFetch = firefoxFixedFetch;

  return GM_fetch;
}