Save ChatGPT as PDF

Turn your chats into neatly formatted PDF.

当前为 2024-01-13 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Save ChatGPT as PDF
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.2
  5. // @description Turn your chats into neatly formatted PDF.
  6. // @author Pdfcrowd (https://pdfcrowd.com/)
  7. // @match https://chat.openai.com/*
  8. // @icon64 https://github.com/pdfcrowd/save-chatgpt-as-pdf/raw/master/icons/icon64.png
  9. // @run-at document-end
  10. // @grant GM_xmlhttpRequest
  11. // @connect api.pdfcrowd.com
  12. // @license MIT
  13. // ==/UserScript==
  14. /* globals pdfcrowdChatGPT */
  15.  
  16. // do not modify or delete the following line, it serves as a placeholder for
  17. // the common.js contents which is copied here by "make build-userscript-single-file"
  18. //
  19. // common.js placeholder
  20. 'use strict';
  21.  
  22. const pdfcrowdChatGPT = {};
  23.  
  24. pdfcrowdChatGPT.pdfcrowdAPI = 'https://api.pdfcrowd.com/convert/latest/';
  25. pdfcrowdChatGPT.username = 'chat-gpt';
  26. pdfcrowdChatGPT.apiKey = '29d211b1f6924c22b7a799b4e8fecb7e';
  27.  
  28. pdfcrowdChatGPT.init = function() {
  29. const urlPattern = /^.*?:\/\/chat\.openai\.com\/((c|g|share)\/.*)?$/;
  30. let currentUrl = '';
  31.  
  32. function checkUrlChange() {
  33. const newUrl = window.location.href;
  34. if(currentUrl !== newUrl) {
  35. currentUrl = newUrl;
  36.  
  37. const blocks = document.getElementsByClassName('pdfcrowd-block');
  38. if(urlPattern.test(currentUrl)) {
  39. for(let i = 0; i < blocks.length; i++) {
  40. blocks[i].classList.remove('pdfcrowd-hidden');
  41. }
  42. } else {
  43. for(let i = 0; i < blocks.length; i++) {
  44. blocks[i].classList.add('pdfcrowd-hidden');
  45. }
  46. }
  47. }
  48. }
  49.  
  50. setInterval(checkUrlChange, 2000);
  51.  
  52. // remote images live at least 1 minute
  53. const minImageDuration = 60000;
  54.  
  55. const buttonIconFill = (typeof GM_xmlhttpRequest !== 'undefined')
  56. ? '#A72C16' : '#EA4C3A';
  57.  
  58. const pdfcrowdBlockHtml = `
  59. <style>
  60. .pdfcrowd-block {
  61. position: fixed;
  62. height: 36px;
  63. top: 10px;
  64. right: 55px;
  65. }
  66.  
  67. @media (min-width: 768px) {
  68. .pdfcrowd-lg {
  69. display: block;
  70. }
  71.  
  72. .pdfcrowd-sm {
  73. display: none;
  74. }
  75. }
  76.  
  77. @media (max-width: 767px) {
  78. .pdfcrowd-block {
  79. top: 4px;
  80. right: 36px;
  81. }
  82.  
  83. .pdfcrowd-lg {
  84. display: none;
  85. }
  86.  
  87. .pdfcrowd-sm {
  88. display: block;
  89. }
  90. }
  91.  
  92. svg.pdfcrowd-btn-content {
  93. width: 1rem;
  94. height: 1rem;
  95. }
  96.  
  97. #pdfcrowd-convert-main {
  98. padding-right: 0;
  99. }
  100.  
  101. #pdfcrowd-convert-main:disabled {
  102. cursor: wait;
  103. filter: none;
  104. opacity: 1;
  105. }
  106.  
  107. .pdfcrowd-dropdown-arrow::after {
  108. display: inline-block;
  109. width: 0;
  110. height: 0;
  111. vertical-align: .255em;
  112. content: "";
  113. border-top: .3em solid;
  114. border-right: .3em solid transparent;
  115. border-bottom: 0;
  116. border-left: .3em solid transparent;
  117. }
  118.  
  119. .pdfcrowd-convert {
  120. font-size: .875rem;
  121. }
  122.  
  123. #pdfcrowd-more {
  124. cursor: pointer;
  125. padding: .5rem;
  126. border-top-right-radius: .5rem;
  127. border-bottom-right-radius: .5rem;
  128. }
  129.  
  130. #pdfcrowd-more:hover {
  131. background-color: rgba(0,0,0,.1);
  132. }
  133.  
  134. #pdfcrowd-extra-btns {
  135. border: 1px solid rgba(0,0,0,.1);
  136. background-color: #fff;
  137. color: #000;
  138. }
  139.  
  140. .pdfcrowd-extra-btn:hover {
  141. background-color: rgba(0,0,0,.1);
  142. }
  143.  
  144. .pdfcrowd-hidden {
  145. display: none;
  146. }
  147.  
  148. #pdfcrowd-spinner {
  149. position: absolute;
  150. width: 100%;
  151. height: 100%;
  152. }
  153.  
  154. .pdfcrowd-spinner {
  155. border: 4px solid #ccc;
  156. border-radius: 50%;
  157. border-top: 4px solid #ffc107;
  158. width: 1.5rem;
  159. height: 1.5rem;
  160. -webkit-animation: spin 1.5s linear infinite;
  161. animation: spin 1.5s linear infinite;
  162. }
  163.  
  164. @-webkit-keyframes spin {
  165. 0% { -webkit-transform: rotate(0deg); }
  166. 100% { -webkit-transform: rotate(360deg); }
  167. }
  168.  
  169. @keyframes spin {
  170. 0% { transform: rotate(0deg); }
  171. 100% { transform: rotate(360deg); }
  172. }
  173.  
  174. .pdfcrowd-invisible {
  175. visibility: hidden;
  176. }
  177.  
  178. .pdfcrowd-overlay {
  179. z-index: 10000;
  180. display: none;
  181. position: fixed;
  182. top: 0;
  183. left: 0;
  184. width: 100%;
  185. height: 100%;
  186. background: rgba(0, 0, 0, 0.5);
  187. justify-content: center;
  188. align-items: center;
  189. }
  190.  
  191. .pdfcrowd-dialog {
  192. background: #fff;
  193. padding: 20px;
  194. border-radius: 5px;
  195. box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
  196. text-align: center;
  197. }
  198. .pdfcrowd-dialog a {
  199. color: revert;
  200. }
  201. </style>
  202.  
  203. <div class="pdfcrowd-block text-right">
  204. <button
  205. id="pdfcrowd-convert-main"
  206. type="button"
  207. role="button"
  208. tabindex="0"
  209. aria-label="Save as PDF"
  210. data-conv-options='{"page_size": "a4"}'
  211. class="btn btn-neutral btn-small h-9 pdfcrowd-convert">
  212. <svg class="mr-1 pdfcrowd-btn-content" version="1.1" viewBox="0 0 30 30" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><polyline clip-rule="evenodd" fill="${buttonIconFill}" fill-rule="evenodd" points="30,30 0,30 0,0 30,0 30,30 "/><path d="M15.372,4.377 c0.452,0.213,0.358,0.489,0.219,1.793c-0.142,1.345-0.618,3.802-1.535,6.219c-0.918,2.413-2.28,4.784-3.467,6.539 c-1.186,1.756-2.201,2.897-2.975,3.556c-0.777,0.659-1.314,0.835-1.665,0.893c-0.348,0.058-0.506,0-0.6-0.177 c-0.094-0.176-0.127-0.466-0.046-0.82c0.079-0.35,0.268-0.76,0.804-1.285c0.541-0.527,1.426-1.172,2.661-1.771 c1.235-0.6,2.817-1.156,4.116-1.537c1.299-0.379,2.311-0.585,3.197-0.746c0.888-0.162,1.647-0.277,2.391-0.337 c0.744-0.056,1.474-0.056,2.186,0c0.712,0.06,1.408,0.175,2.011,0.323c0.6,0.146,1.108,0.321,1.551,0.601 c0.442,0.276,0.823,0.657,1.012,1.083c0.192,0.423,0.192,0.893,0.033,1.228c-0.158,0.337-0.476,0.541-0.839,0.66 c-0.364,0.115-0.775,0.144-1.267,0c-0.49-0.148-1.062-0.47-1.662-0.894c-0.601-0.425-1.235-0.952-2.057-1.771 c-0.824-0.819-1.838-1.93-2.692-3.013c-0.854-1.083-1.553-2.136-2.028-3.029c-0.473-0.893-0.727-1.624-0.933-2.355 c-0.206-0.733-0.364-1.464-0.427-2.122S13.326,6.17,13.39,5.701c0.063-0.466,0.16-0.82,0.317-1.055 c0.158-0.23,0.381-0.35,0.539-0.408s0.254-0.058,0.348-0.073c0.094-0.015,0.188-0.044,0.333,0c0.138,0.042,0.321,0.154,0.504,0.268" fill="none" stroke="#FFFFFF" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="1.4"/></svg>
  213. <div class="pdfcrowd-lg pdfcrowd-btn-content">
  214. Save as PDF
  215. </div>
  216. <div class="pdfcrowd-sm pdfcrowd-btn-content">
  217. PDF
  218. </div>
  219. <div id="pdfcrowd-more" class="pdfcrowd-dropdown-arrow">
  220. </div>
  221. <div id="pdfcrowd-spinner" class="pdfcrowd-hidden">
  222. <div class="flex justify-center items-center mr-4" style="height: 100%;">
  223. <div class="pdfcrowd-spinner">
  224. </div>
  225. </div>
  226. </div>
  227. </button>
  228.  
  229. <div id="pdfcrowd-extra-btns" class="pdfcrowd-hidden text-left">
  230. <div
  231. id="pdfcrowd-extra-a4p"
  232. type="button"
  233. role="button"
  234. tabindex="0"
  235. aria-label="Save as A4 portrait PDF"
  236. data-conv-options='{"page_size": "a4"}'
  237. class="pdfcrowd-extra-btn pdfcrowd-convert px-2 py-1">
  238. A4 Portrait
  239. </div>
  240. <div
  241. id="pdfcrowd-extra-a4l"
  242. type="button"
  243. role="button"
  244. tabindex="0"
  245. aria-label="Save as A4 landscape PDF"
  246. class="pdfcrowd-extra-btn pdfcrowd-convert px-2 py-1"
  247. data-conv-options='{"orientation": "landscape", "viewport_width": 1200, "page_size": "a4"}'>
  248. A4 Landscape
  249. </div>
  250. <div
  251. id="pdfcrowd-extra-lp"
  252. type="button"
  253. role="button"
  254. tabindex="0"
  255. aria-label="Save as letter portrait PDF"
  256. data-conv-options='{"page_size": "letter"}'
  257. class="pdfcrowd-extra-btn pdfcrowd-convert px-2 py-1">
  258. Letter Portrait
  259. </div>
  260. <div
  261. id="pdfcrowd-extra-ll"
  262. type="button"
  263. role="button"
  264. tabindex="0"
  265. aria-label="Save as letter landscape PDF"
  266. class="pdfcrowd-extra-btn pdfcrowd-convert px-2 py-1"
  267. data-conv-options='{"orientation": "landscape", "viewport_width": 1200, "page_size": "letter"}'>
  268. Letter Landscape
  269. </div>
  270. </div>
  271.  
  272. <div class="pdfcrowd-overlay" id="pdfcrowd-error-overlay" style="color: #000">
  273. <div class="pdfcrowd-dialog">
  274. <p id="pdfcrowd-error-message" class="my-2"></p>
  275. <button id="pdfcrowd-close-btn" class="btn btn-neutral">Close</button>
  276. </div>
  277. </div>
  278. </div>
  279. `;
  280.  
  281. function prepareContent(element) {
  282. element = element.cloneNode(true);
  283.  
  284. // fix nested buttons error
  285. element.querySelectorAll('button button').forEach(button => {
  286. button.parentNode.removeChild(button);
  287. });
  288.  
  289. // solve user icons
  290. element.querySelectorAll('.gizmo-shadow-stroke').forEach(icon => {
  291. let parent = icon.parentNode;
  292. while(parent) {
  293. const label = parent.querySelector('.font-semibold');
  294. if(label) {
  295. label.insertBefore(icon, label.firstChild);
  296. label.style.marginTop = '1.5rem';
  297. label.style.marginBottom = '.25rem';
  298. break;
  299. }
  300. parent = parent.parentNode;
  301. }
  302. });
  303.  
  304. // solve expired images
  305. element.querySelectorAll('.grid img').forEach(img => {
  306. img.setAttribute(
  307. 'alt', 'The image has expired. Refresh ChatGPT page and retry saving to PDF.');
  308. });
  309.  
  310. element.classList.add('chat-gpt-custom');
  311.  
  312. return element.outerHTML;
  313. }
  314.  
  315. function convert(event) {
  316. let trigger = event.target;
  317. document.getElementById('pdfcrowd-extra-btns').classList.add(
  318. 'pdfcrowd-hidden');
  319.  
  320. const btnConvert = document.getElementById('pdfcrowd-convert-main');
  321. btnConvert.disabled = true;
  322. const spinner = document.getElementById('pdfcrowd-spinner');
  323. spinner.classList.remove('pdfcrowd-hidden');
  324. const btnElems = document.getElementsByClassName('pdfcrowd-btn-content');
  325. for(let i = 0; i < btnElems.length; i++) {
  326. btnElems[i].classList.add('pdfcrowd-invisible');
  327. }
  328.  
  329. function cleanup() {
  330. btnConvert.disabled = false;
  331. spinner.classList.add('pdfcrowd-hidden');
  332. for(let i = 0; i < btnElems.length; i++) {
  333. btnElems[i].classList.remove('pdfcrowd-invisible');
  334. }
  335. }
  336.  
  337. const main = document.getElementsByTagName('main')[0];
  338. const content = prepareContent(main);
  339.  
  340. let body;
  341. let title = '';
  342. const h1 = main.querySelector('h1');
  343. if(h1) {
  344. title = h1.textContent;
  345. body = content;
  346. } else {
  347. title = document.getElementsByTagName('title')[0].textContent;
  348. body = `<h1 class="main-title">${title}</h1>` + content;
  349. }
  350.  
  351. const data = {
  352. text: `<!DOCTYPE html><html><head><meta charSet="utf-8"/></head><body>${body}</body>`,
  353. jpeg_quality: 70,
  354. image_dpi: 150,
  355. convert_images_to_jpeg: 'all',
  356. title: title,
  357. rendering_mode: 'viewport',
  358. smart_scaling_mode: 'viewport-fit'
  359. };
  360.  
  361. if(trigger.id) {
  362. localStorage.setItem('pdfcrowd-btn', trigger.id);
  363. } else {
  364. let lastBtn = localStorage.getItem('pdfcrowd-btn');
  365. if(lastBtn) {
  366. lastBtn = document.getElementById(lastBtn);
  367. if(lastBtn) {
  368. trigger = lastBtn;
  369. }
  370. }
  371. }
  372.  
  373. const convOptions = JSON.parse(trigger.dataset.convOptions || '{}');
  374.  
  375. for(let key in convOptions) {
  376. data[key] = convOptions[key];
  377. }
  378.  
  379. if(!('viewport_width' in convOptions)) {
  380. data.viewport_width = 800;
  381. }
  382.  
  383. pdfcrowdChatGPT.doRequest(data, title + '.pdf', cleanup);
  384. }
  385.  
  386. function showButton() {
  387. let buttons = document.querySelectorAll('.pdfcrowd-convert');
  388. if(buttons.length > 0) {
  389. return;
  390. }
  391. const container = document.createElement('div');
  392. container.innerHTML = pdfcrowdBlockHtml;
  393. document.body.appendChild(container);
  394.  
  395. checkUrlChange();
  396.  
  397. buttons = document.querySelectorAll('.pdfcrowd-convert');
  398. buttons.forEach(element => {
  399. element.addEventListener('click', convert);
  400. });
  401.  
  402. document.getElementById('pdfcrowd-more').addEventListener('click', event => {
  403. event.stopPropagation();
  404. const moreButtons = document.getElementById(
  405. 'pdfcrowd-extra-btns');
  406. if(moreButtons.classList.contains('pdfcrowd-hidden')) {
  407. moreButtons.classList.remove('pdfcrowd-hidden');
  408. } else {
  409. moreButtons.classList.add('pdfcrowd-hidden');
  410. }
  411. });
  412.  
  413. document.addEventListener('click', event => {
  414. const moreButtons = document.getElementById('pdfcrowd-extra-btns');
  415.  
  416. if (!moreButtons.contains(event.target)) {
  417. moreButtons.classList.add('pdfcrowd-hidden');
  418. }
  419. });
  420.  
  421. document.getElementById('pdfcrowd-close-btn').addEventListener(
  422. 'click', () => {
  423. document.getElementById(
  424. 'pdfcrowd-error-overlay').style.display = 'none';
  425. });
  426. }
  427.  
  428. let buttonVisible = false;
  429.  
  430. function checkForContent() {
  431. if(!buttonVisible) {
  432. const mainElement = document.querySelector(
  433. 'main > div:first-child');
  434.  
  435. if (mainElement && mainElement.textContent.trim().length > 0) {
  436. buttonVisible = true;
  437. showButton();
  438. } else {
  439. // content not found, continue checking
  440. setTimeout(checkForContent, 1000);
  441. }
  442. }
  443. }
  444.  
  445. setTimeout(checkForContent, 1000);
  446. }
  447.  
  448. pdfcrowdChatGPT.showError = function(status, text) {
  449. let html;
  450. if (status == 432) {
  451. html = [
  452. "<strong>Fair Use Notice</strong><br>",
  453. "Current usage is over the limit. Please wait a while before trying again.<br><br>",
  454. ];
  455. } else {
  456. html = ['<strong>Error occurred</strong>'];
  457. if (status) {
  458. html.push(`Code: ${status}`);
  459. html.push("Please try again later");
  460. } else {
  461. html.push(text);
  462. }
  463. html.push(`If the problem persists, contact us at
  464. <a href="mailto:support@pdfcrowd.com?subject=ChatGPT%20error">
  465. support@pdfcrowd.com
  466. </a>`);
  467. }
  468. html = html.join('<br>');
  469. document.getElementById('pdfcrowd-error-overlay').style.display = 'flex';
  470. document.getElementById('pdfcrowd-error-message').innerHTML = html;
  471. };
  472.  
  473. pdfcrowdChatGPT.saveBlob = function(url, filename) {
  474. const a = document.createElement('a');
  475. a.href = url;
  476. a.download = filename;
  477. a.click();
  478. setTimeout(() => {
  479. window.URL.revokeObjectURL(url);
  480. }, 100);
  481. };
  482.  
  483. (function() {
  484. pdfcrowdChatGPT.doRequest = function(data, fileName, fnCleanup) {
  485. const formData = new FormData();
  486. for(let key in data) {
  487. formData.append(key, data[key]);
  488. }
  489. GM_xmlhttpRequest({
  490. url: pdfcrowdChatGPT.pdfcrowdAPI,
  491. method: 'POST',
  492. data: formData,
  493. responseType: 'blob',
  494. headers: {
  495. 'Authorization': 'Basic ' + btoa(
  496. pdfcrowdChatGPT.username + ':' + pdfcrowdChatGPT.apiKey),
  497. },
  498. onload: response => {
  499. fnCleanup();
  500. if(response.status == 200) {
  501. const url = window.URL.createObjectURL(response.response);
  502. pdfcrowdChatGPT.saveBlob(url, fileName);
  503. } else {
  504. pdfcrowdChatGPT.showError(
  505. response.status, response.responseText);
  506. }
  507. },
  508. onerror: error => {
  509. console.error('conversion error:', error);
  510. fnCleanup();
  511. pdfcrowdChatGPT.showError(500, error.responseText);
  512. }
  513. });
  514. };
  515.  
  516. pdfcrowdChatGPT.init();
  517. })();