Bilibili-Markdown

B站专栏 Markdown 编辑器

当前为 2023-02-20 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Bilibili-Markdown
  3. // @namespace https://github.com/LuckyPuppy514
  4. // @version 1.0.3
  5. // @author LuckyPuppy514
  6. // @copyright 2023, Grant LuckyPuppy514 (https://github.com/LuckyPuppy514)
  7. // @license MIT
  8. // @description B站专栏 Markdown 编辑器
  9. // @homepage https://github.com/LuckyPuppy514/Bilibili-Markdown
  10. // @icon https://article.biliimg.com/bfs/article/3e927f211d063b57cd39c4041ac2d07fd959726c.png
  11. // @match https://member.bilibili.com/article-text/home*
  12. // @require https://unpkg.com/jquery@3.2.1/dist/jquery.min.js
  13. // ==/UserScript==
  14.  
  15. "use strict";
  16.  
  17. console.log(`
  18.  
  19. 🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️
  20. Ⓜ️ 🅱️
  21. 🅱️ Bilibili-Markdown Ⓜ️
  22. Ⓜ️ 🅱️
  23. 🅱️ https://github.com/LuckyPuppy514/Bilibili-Markdown Ⓜ️
  24. Ⓜ️ 🅱️
  25. 🅱️ 2023 @LuckyPuppy514 Ⓜ️
  26. Ⓜ️ 🅱️
  27. 🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️🅱️Ⓜ️
  28.  
  29. `);
  30.  
  31. // markdown 编辑器地址
  32. // const BILIBILI_MARKDOWN_URL = "http://127.0.0.1:5500/web/tampermonkey/Bilibili-Markdown/index.html";
  33. const BILIBILI_MARKDOWN_URL = "https://www.lckp.top/bilibili-markdown/index.html";
  34. // id / name 公共前缀
  35. const PREFIX = "bilibili-markdown-";
  36. // 等待时间(ms)
  37. const waitTime = {
  38. long: 2500,
  39. normal: 1000,
  40. short: 600,
  41. };
  42. // localStorage key
  43. const key = {
  44. isMarkdown: "isMarkdown",
  45. isFullscreen: "isFullscreen"
  46. }
  47. // element id
  48. const eid = {
  49. button: {
  50. switchToHtmlEditor: `${PREFIX}switch-to-html-editor-button`
  51. },
  52. iframe: {
  53. main: `${PREFIX}main-iframe`
  54. }
  55. };
  56. // element
  57. const elements = {
  58. // 附加
  59. switchToMarkdownEditorButton: undefined,
  60. mainIframe: undefined,
  61. // 原有
  62. editorBox: undefined,
  63. loading: undefined,
  64. save: undefined,
  65. mbpreview: undefined
  66. };
  67. // class name
  68. const cname = {
  69. fullscreen: `${PREFIX}fullscreen`,
  70. toast: `${PREFIX}toast`,
  71. };
  72. // z-index
  73. const zIndex = {
  74. first: 999999,
  75. second: 999998
  76. };
  77. // display
  78. const display = {
  79. none: "none",
  80. block: "block"
  81. }
  82.  
  83. var needReload;
  84. var bilibili;
  85. var bilibiliMarkdown;
  86.  
  87. const CSS = `
  88. /*切换 markdown 编辑器按钮*/
  89. #${eid.button.switchToHtmlEditor} {
  90. font-size: 22px;
  91. border-width: 0px 1px 0px 0px;
  92. border-style: solid;
  93. border-color: white;
  94. margin-left: -9px;
  95. padding-right: 5px;
  96. }
  97. /*markdown 编辑器 iframe*/
  98. #${eid.iframe.main} {
  99. width: 100%;
  100. height: 480px;
  101. z-index: ${zIndex.second};
  102. border: none;
  103. display: none;
  104. }
  105. /*全屏*/
  106. .${cname.fullscreen} {
  107. position: fixed !important;
  108. top: 0 !important;
  109. left: 0 !important;
  110. bottom: 0 !important;
  111. right: 0 !important;
  112. width: 100% !important;
  113. height: 100% !important;
  114. border: none !important;
  115. margin: 0 !important;
  116. padding: 0 !important;
  117. overflow: hidden !important;
  118. z-index: ${zIndex.second} !important;
  119. }
  120. /*消息*/
  121. .${cname.toast} {
  122. max-width: 60%;
  123. min-width: 160px;
  124. padding: 0 14px;
  125. height: 50px;
  126. color: rgb(255, 255, 255);
  127. line-height: 50px;
  128. text-align: center;
  129. border-radius: 4px;
  130. position: fixed;
  131. top: 6%;
  132. left: 50%;
  133. transform: translate(-50%, -50%);
  134. z-index: ${zIndex.first};
  135. background: rgba(119, 199, 104, 0.9);
  136. font-size: 14px;
  137. box-shadow: 0px 0px 10px rgba(119, 199, 104, 0.9);
  138. }
  139. /*手机端预览*/
  140. .preview-mask,
  141. .preview-mask .preview-content {
  142. padding-top: 35px !important;
  143. z-index: ${zIndex.first} !important;
  144. }
  145. `;
  146. const HTML = `
  147. <iframe id="${eid.iframe.main}" src="${BILIBILI_MARKDOWN_URL}"></iframe>
  148. `;
  149.  
  150. function appendCSS() {
  151. let css = document.createElement("style");
  152. css.innerHTML = CSS.trim();
  153. document.head.appendChild(css);
  154. }
  155. function appendHTML() {
  156. let div = document.createElement("div");
  157. div.innerHTML = HTML.trim();
  158. document.getElementsByClassName("editor-wrap")[0].appendChild(div);
  159. }
  160. function appendSwitchToMarkdownEditorButton() {
  161. let button = document.createElement('li');
  162. button.id = eid.button.switchToHtmlEditor;
  163. button.className = 'toolbar-item left';
  164. button.innerHTML = 'Ⓜ️';
  165. document.getElementsByClassName('editor-toolbar clearfix')[0].prepend(button);
  166. }
  167. function getAllElement() {
  168. elements.switchToMarkdownEditorButton = document.getElementById(eid.button.switchToHtmlEditor);
  169. elements.mainIframe = document.getElementById(eid.iframe.main);
  170.  
  171. elements.editorBox = document.getElementsByClassName("editor-box")[0];
  172. elements.save = document.getElementsByClassName("ui-btn white")[0];
  173. elements.mbpreview = document.getElementsByClassName("ui-btn white")[1];
  174. elements.loading = document.getElementById("loading");
  175. elements.loading.innerHTML = elements.loading.innerHTML.replace("玩儿命加载中", "处理中,请稍后");
  176. elements.loading.style.zIndex = zIndex.first;
  177. }
  178. function addListener() {
  179. elements.switchToMarkdownEditorButton.onclick = async function () {
  180. if (!bilibili.aid) {
  181. bilibili.aid = await bilibili.getAidFromLocalStorage();
  182. }
  183. if (bilibili.aid) {
  184. bilibili.switchToMarkdownEditor();
  185. } else {
  186. Toast("矮油,起码写个标题嘛~");
  187. }
  188. }
  189. elements.save.onclick = function () {
  190. if (localStorage.getItem(key.isMarkdown)) {
  191. bilibili.loading();
  192. setTimeout(() => {
  193. bilibiliMarkdown.save();
  194. }, waitTime.normal);
  195. }
  196. }
  197. elements.mbpreview.onclick = function () {
  198. if (localStorage.getItem(key.isMarkdown) && needReload) {
  199. setTimeout(() => {
  200. bilibiliMarkdown.save();
  201. bilibili.mbpreview();
  202. }, waitTime.normal);
  203. }
  204. }
  205. }
  206. // 显示消息
  207. function Toast(msg, duration) {
  208. duration = isNaN(duration) ? 2000 : duration;
  209. let div = document.createElement("div");
  210. div.innerHTML = msg;
  211. div.className = cname.toast;
  212. document.body.appendChild(div);
  213. setTimeout(function () {
  214. div.style.opacity = "0";
  215. setTimeout(function () { document.body.removeChild(div) }, 500);
  216. }, duration);
  217. }
  218. // webp 转 jpg
  219. function webpToJpg(webp) {
  220. return new Promise(function (resolve, reject) {
  221. let image = new Image();
  222. image.src = URL.createObjectURL(webp);
  223. image.onload = function () {
  224. let canvas = document.createElement("canvas");
  225. canvas.width = image.width;
  226. canvas.height = image.height;
  227. canvas.getContext("2d").drawImage(image, 0, 0);
  228. let blob = dataURLtoBlob(canvas.toDataURL("image/jpeg"));
  229. file = new File([blob], blob.name, {
  230. type: blob.type,
  231. });
  232. resolve(file);
  233. }
  234. });
  235. }
  236. function dataURLtoBlob(dataurl) {
  237. var arr = dataurl.split(','),
  238. mime = arr[0].match(/:(.*?);/)[1],
  239. bstr = atob(arr[1]),
  240. n = bstr.length,
  241. u8arr = new Uint8Array(n);
  242. while (n--) {
  243. u8arr[n] = bstr.charCodeAt(n);
  244. }
  245. return new Blob([u8arr], { type: mime });
  246. }
  247. function sleep(ms) {
  248. return new Promise(resolve => setTimeout(resolve, ms));
  249. }
  250.  
  251. class Bilibili {
  252. constructor() {
  253. this.api = {
  254. upcover: "https://api.bilibili.com/x/article/creative/article/upcover",
  255. addupdate: "https://api.bilibili.com/x/article/creative/draft/addupdate",
  256. }
  257. this.page = {
  258. edit: "https://member.bilibili.com/platform/upload/text/edit",
  259. pcpreview: "https://www.bilibili.com/read/pcpreview",
  260. home: "https://member.bilibili.com/article-text/home"
  261. }
  262. this.csrf = this.getCsrf();
  263. this.aid = this.getAidFromLocation();
  264. this.addListener();
  265. this.uploading = 0;
  266. this.uploadList = new Map();
  267. }
  268. getCsrf() {
  269. let cookie = document.cookie;
  270. let csrf = cookie.substring(cookie.indexOf("bili_jct"));
  271. csrf = csrf.substring(9, csrf.indexOf(";"));
  272. return csrf;
  273. }
  274. getAidFromLocation() {
  275. let aid = undefined;
  276. let aids = window.location.href.match(/aid=[0-9]+/g);
  277. if (aids && aids.length > 0) {
  278. aid = aids[0].replace("aid=", "");
  279. }
  280. if (aid && aid.toString().length > 5) {
  281. return aid;
  282. }
  283. return undefined;
  284. }
  285. async getAidFromLocalStorage() {
  286. let aid = undefined;
  287. // 等待 TIMEOUT_TIME 后读取 localStorage (更新需要时间)
  288. this.loading();
  289. await new Promise((resolve, reject) => {
  290. setTimeout(() => {
  291. aid = JSON.parse(localStorage.bili_localDraft).id;
  292. resolve();
  293. }, waitTime.long);
  294. })
  295. this.hideLoading();
  296.  
  297. if (aid && aid.toString().length > 5) {
  298. // 新建专栏跳转编辑页面
  299. if (window.location.href.endsWith("?")) {
  300. top.location.href = this.page.edit + "?aid=" + aid;
  301. }
  302. return aid;
  303. }
  304. return undefined;
  305. }
  306. addListener() {
  307. window.addEventListener("message", function (event) {
  308. bilibili[event.data.method](event.data.param);
  309. }, false);
  310. }
  311. switchToMarkdownEditor() {
  312. localStorage.setItem(key.isMarkdown, true);
  313. elements.mainIframe.style.display = display.block;
  314. elements.editorBox.style.display = display.none;
  315. if (localStorage.getItem(key.isFullscreen)) {
  316. localStorage.removeItem(key.isFullscreen);
  317. this.switchToFullscreen();
  318. }
  319. }
  320. switchToHtmlEditor() {
  321. localStorage.removeItem(key.isMarkdown);
  322. elements.mainIframe.style.display = display.none;
  323. elements.editorBox.style.display = display.block;
  324. document.body.style.overflowY = "";
  325. if (needReload) {
  326. needReload = false;
  327. location.reload();
  328. }
  329. }
  330. switchToFullscreen(param) {
  331. if (param && param.isFullscreen != undefined) {
  332. if (param.isFullscreen === true) {
  333. fullscreen();
  334. if (top != self) {
  335. top.location.href = bilibili.page.home + "?aid=" + bilibili.aid;
  336. }
  337. } else {
  338. exitFullscreen();
  339. }
  340. } else {
  341. if (localStorage.getItem(key.isFullscreen)) {
  342. exitFullscreen();
  343. } else {
  344. fullscreen();
  345. }
  346. }
  347.  
  348. function fullscreen() {
  349. localStorage.setItem(key.isFullscreen, true);
  350. elements.mainIframe.className = cname.fullscreen;
  351. document.body.style.overflowY = "hidden";
  352. }
  353. function exitFullscreen() {
  354. localStorage.removeItem(key.isFullscreen);
  355. elements.mainIframe.className = "";
  356. document.body.style.overflowY = "";
  357. }
  358. }
  359. loading() {
  360. elements.loading.style.display = display.block;
  361. setTimeout(this.hideLoading, waitTime.long);
  362. }
  363. hideLoading() {
  364. elements.loading.style.display = display.none;
  365. }
  366. pcpreview() {
  367. window.open(this.page.pcpreview + "?aid=" + this.aid);
  368. }
  369. mbpreview() {
  370. if (needReload) {
  371. localStorage.setItem(key.needMbpreview, true);
  372. location.reload();
  373. } else {
  374. localStorage.removeItem(key.needMbpreview);
  375. document.getElementsByClassName("ui-btn white")[1].click();
  376. }
  377. }
  378. async appendImage(param) {
  379. bilibiliMarkdown.appendImage(await this.uploadImage(param.image));
  380. }
  381. toBLink(param) {
  382. this.loading();
  383. let link = param.link;
  384. let xhr = new XMLHttpRequest();
  385. xhr.open("get", link, true);
  386. xhr.responseType = "blob";
  387. xhr.onload = async function () {
  388. let image = new File([xhr.response], link.substring(link.lastIndexOf('/') + 1));
  389. let bLink = await bilibili.uploadImage(image);
  390. bilibiliMarkdown.toBLink(link, bLink);
  391. bilibili.hideLoading();
  392. }
  393. xhr.send();
  394. }
  395. async uploadImage(image) {
  396. let name = image.name;
  397. let bLink = this.uploadList.get(name);
  398. if (bLink) {
  399. if(bLink == "uploading"){
  400. return undefined;
  401. } else {
  402. return bLink;
  403. }
  404. } else {
  405. this.uploadList.set(name, "uploading");
  406. }
  407. // webp 转 jpg
  408. if (name.endsWith(".webp")) {
  409. image = await webpToJpg(image);
  410. }
  411. bLink = "图片上传B站失败,请重试";
  412. let formData = new FormData();
  413. formData.append("binary", image);
  414. formData.append("csrf", this.csrf);
  415.  
  416. // 限制上传频率
  417. let that = this;
  418. while (that.uploading > 0) {
  419. await sleep(waitTime.normal);
  420. }
  421. that.uploading++;
  422. $.ajax({
  423. type: "POST",
  424. contentType: false,
  425. processData: false,
  426. async: false,
  427. data: formData,
  428. url: bilibili.api.upcover,
  429. xhrFields: {
  430. withCredentials: true
  431. },
  432. success: function (res) {
  433. if (res && res.data) {
  434. bLink = res.data.url;
  435. that.uploadList.set(name, bLink);
  436. } else {
  437. that.uploadList.delete(name);
  438. Toast("上传失败:" + JSON.stringify(res));
  439. }
  440. }
  441. })
  442.  
  443. // 释放限制频率锁
  444. setTimeout(() => {
  445. that.uploading--;
  446. }, waitTime.normal);
  447. return bLink;
  448. }
  449. async tableToImage(html, tables) {
  450. if (tables && tables.size > 0) {
  451. for (let [oldHtml, image] of tables) {
  452. let bLink = await this.uploadImage(image);
  453. let newHtml = `<figure contenteditable="false" class="img-box"><img referrerpolicy="no-referrer" src="${bLink}"><figcaption class="caption" contenteditable="false"></figcaption></figure>`;
  454. html = html.replaceAll(oldHtml, newHtml);
  455. }
  456. }
  457. return html;
  458. }
  459. async save(param) {
  460. let html = param.html ? param.html : "";
  461. // 保存到本地
  462. localStorage.setItem(PREFIX + this.aid, param.markdown);
  463. // 表格转图片
  464. html = await this.tableToImage(html, param.tables);
  465. // 提取内容
  466. let words = html.replace(/<(h[1-6]|code)[^>]*>[^<]*<\/\1>/g, "")
  467. .replace(/<[^>]*>/g, "")
  468. .replace(/[\s| |\n\|\r]*/g, "");
  469. // 提取总结
  470. let summary = words.slice(0, 100);
  471. // B站接口参数
  472. let biliLocalDraft = JSON.parse(localStorage.bili_localDraft);
  473. $.ajax({
  474. type: "POST",
  475. data: {
  476. title: biliLocalDraft.title,
  477. content: html,
  478. summary: summary,
  479. words: words.length,
  480. category: biliLocalDraft.category,
  481. list_id: biliLocalDraft.list_id,
  482. tid: biliLocalDraft.template.id,
  483. reprint: 0,
  484. media_id: biliLocalDraft.media_id,
  485. spoiler: biliLocalDraft.is_spoiler ? "1" : "0",
  486. original: biliLocalDraft.isOriginal,
  487. aid: biliLocalDraft.id,
  488. csrf: this.csrf
  489. },
  490. url: bilibili.api.addupdate,
  491. xhrFields: {
  492. withCredentials: true
  493. },
  494. success: function (res) {
  495. bilibili.hideLoading();
  496. if (res && res.code == 0) {
  497. if (localStorage.getItem(key.needMbpreview)) {
  498. location.reload();
  499. } else {
  500. needReload = true;
  501. Toast(" 草稿已保存 ");
  502. }
  503. } else {
  504. Toast("保存失败: " + JSON.stringify(res));
  505. }
  506. },
  507. error: function (err) {
  508. Toast("保存失败: " + JSON.stringify(err.message));
  509. }
  510. });
  511. }
  512. }
  513.  
  514. class BilibiliMarkdown {
  515. constructor() {
  516. setTimeout(() => {
  517. this.hello();
  518.  
  519. if (bilibili.aid) {
  520. if (localStorage.getItem(key.isMarkdown)) {
  521. bilibili.switchToMarkdownEditor();
  522. }
  523.  
  524. let markdown = localStorage.getItem(PREFIX + bilibili.aid);
  525. if (markdown) {
  526. this.setMarkdown(markdown);
  527. }
  528. if (localStorage.getItem(key.needMbpreview)) {
  529. bilibili.mbpreview();
  530. }
  531. }
  532. }, waitTime.short);
  533. }
  534. message(method, param) {
  535. elements.mainIframe.contentWindow.postMessage({ method: method, param: param }, BILIBILI_MARKDOWN_URL);
  536. }
  537. hello() {
  538. this.message(this.hello.name);
  539. }
  540. save() {
  541. this.message(this.save.name);
  542. }
  543. toBLink(link, bLink) {
  544. if(bLink){
  545. this.message(this.toBLink.name, { link: link, bLink: bLink });
  546. }
  547. }
  548. appendImage(bLink) {
  549. this.message(this.appendImage.name, { bLink: bLink });
  550. }
  551. setMarkdown(markdown) {
  552. this.message(this.setMarkdown.name, { markdown: markdown });
  553. }
  554. }
  555.  
  556. window.onload = function () {
  557. setTimeout(() => {
  558. let saveButton = document.getElementsByClassName("ui-btn white")[0];
  559. if (!saveButton || saveButton.innerHTML != "存草稿") {
  560. console.log("文章已提交");
  561. return;
  562. }
  563.  
  564. appendCSS();
  565. appendHTML();
  566. appendSwitchToMarkdownEditorButton();
  567. getAllElement();
  568. addListener();
  569.  
  570. bilibili = new Bilibili();
  571. bilibiliMarkdown = new BilibiliMarkdown();
  572. }, waitTime.short);
  573. }