main.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. //////////////////////////////////////////////////////////////////////////////
  2. /// @file main.js
  3. /// @author SENOO, Ken
  4. /// @copyright CC0
  5. //////////////////////////////////////////////////////////////////////////////
  6. "use strict";
  7. document.querySelector('#button').onclick = async function() {
  8. let input = new URL(document.querySelector('#input').value);
  9. const id = input.pathname.replace(/\/+$/, '').replace(/.*\//, '');
  10. const handle = document.querySelector('#handle').value;
  11. const password = document.querySelector('#password').value;
  12. let post;
  13. do {
  14. const funcs = [getMisskey, getMastodon, getTwitter, getATProtocol];
  15. for (const key in funcs) {
  16. if (post = await funcs[key](input, id, handle, password)) {
  17. break;
  18. }
  19. }
  20. } while (false);
  21. format(post);
  22. return;
  23. function removeScheme(url) {
  24. return url.replace(/^[^:]*:/, '');
  25. }
  26. function format(post) {
  27. const bq = document.createElement('blockquote');
  28. bq.setAttribute('style', `background:${post.color};background-image:`
  29. + 'linear-gradient(hsla(0,0%,100%,0.5),hsla(0,0%,100%,0.5))');
  30. function createPost(post) {
  31. const article = document.createElement('article');
  32. const header = document.createElement('header');
  33. header.setAttribute('style', 'display:flex;line-height:1.5');
  34. const img = document.createElement('img');
  35. img.setAttribute('referrerpolicy', 'no-referrer');
  36. img.setAttribute('alt', 'avatar');
  37. img.setAttribute('src', removeScheme(post.avatar));
  38. img.setAttribute('style', 'height:4.5em');
  39. header.appendChild(img);
  40. let profile = `<div><a href="${post.from.url}">${post.date}</a></div><div
  41. >${htmlspecialchars(post.from.display_name)}|<a
  42. href="${post.from.profile_url}">${post.from.acct}</a></div>`;
  43. if (post.to) {
  44. profile += `<div><a href="${post.to.url}">replying to</a>
  45. ${htmlspecialchars(post.to.display_name)}|<a
  46. href="${post.to.profile_url}">${post.to.acct}</a></div>`;
  47. }
  48. header.innerHTML +=
  49. `<div>${profile.replace(/&nbsp;/g, '&#x00A0;')}</div>`;
  50. article.appendChild(header);
  51. const main = document.createElement('main');
  52. if (post.html) {
  53. // Mastodon returns invalid XML &nbsp;.
  54. main.innerHTML = post.html.replace(/&nbsp;/g, '&#x00A0;');
  55. } else if (post.text) {
  56. const span = document.createElement('span');
  57. span.textContent = post.text;
  58. span.setAttribute('style', 'white-space:pre-wrap;word-wrap:word-break');
  59. main.appendChild(span);
  60. }
  61. if (post.cw) {
  62. main.innerHTML = `<p>${post.cw}</p>${main.innerHTML}`;
  63. }
  64. article.appendChild(main);
  65. const footer = document.createElement('footer');
  66. const media_types = {unknown: 'a',
  67. image: 'img', gifv: 'img', video: 'video', audio: 'audio'};
  68. for (const key in post.attachments) {
  69. const media = post.attachments[key];
  70. if (!media.tag) {
  71. console.log('No media.');
  72. continue;
  73. }
  74. const el = document.createElement(media.tag);
  75. el.setAttribute('referrerpolicy', 'no-referrer');
  76. if (media.alt) {
  77. el.setAttribute('alt', media.alt);
  78. }
  79. const src = media.tag === 'a' ? 'href' : 'src';
  80. el.setAttribute(src, removeScheme(media.url));
  81. if (media.tag === 'a') {
  82. el.textContent = 'attachment' + key;
  83. }
  84. if (media.width) {
  85. el.setAttribute('width', media.width);
  86. }
  87. footer.appendChild(el);
  88. }
  89. if (footer.hasChildNodes()) {
  90. article.appendChild(footer);
  91. }
  92. return article;
  93. }
  94. let parent = createPost(post);
  95. if (post.cite) {
  96. let child = createPost(post.cite);
  97. child.setAttribute('style', 'border:thin solid');
  98. parent.getElementsByTagName('main')[0].appendChild(child);
  99. }
  100. bq.appendChild(parent);
  101. // Output
  102. document.querySelector("#output").textContent = bq.outerHTML;
  103. document.querySelector("#view").innerHTML = bq.outerHTML;
  104. navigator.clipboard.writeText(bq.outerHTML);
  105. }
  106. async function getMastodon(permalink, id) {
  107. const gw = permalink.origin + "/api/v1/statuses/"
  108. const url = new URL(gw + id);
  109. return await fetch(url.href).then(res => res.json()).then(async data => {
  110. let post = {
  111. attachments: [],
  112. avatar: data.account.avatar,
  113. // https://joinmastodon.org/
  114. color: 'rgb(99,100,255)',
  115. // It seems remote post response <br> (not XML <br /> tag ex: https://mastodon-japan.net/@upasampada@fedibird.com/109776574601158598)
  116. html: data.content.replace(/\u003cbr\u003e/g, '\u003cbr /\u003e'),
  117. cw: data.spoiler_text,
  118. date: data.created_at,
  119. from: {
  120. url: data.url,
  121. acct: data.account.acct +
  122. (data.account.acct.includes('@') ? '' : '@'+url.host),
  123. display_name: data.account.display_name,
  124. profile_url: data.account.url,
  125. },
  126. };
  127. if (data.in_reply_to_id) {
  128. post.to = await fetch(gw+data.in_reply_to_id)
  129. .then(res => res.json()).then(data => { return {
  130. url: data.url,
  131. acct: data.account.acct +
  132. (data.account.acct.includes('@') ? '' : '@'+url.host),
  133. display_name: data.account.display_name
  134. ? data.account.display_name : data.account.username,
  135. profile_url: data.account.url,
  136. }}).catch(e => console.log(e));
  137. }
  138. const media_types = {unknown: 'a',
  139. image: 'img', gifv: 'img', video: 'video', audio: 'audio'};
  140. for (const key in data.media_attachments) {
  141. const media = data.media_attachments[key];
  142. const attachment = {
  143. tag: media_types[media.type],
  144. url: media.url,
  145. };
  146. if (media.meta) {
  147. if (media.meta.description) {
  148. attachment.alt = media.meta.description;
  149. }
  150. if (media.meta.small) {
  151. attachment.width = media.meta.small.width;
  152. }
  153. }
  154. post.attachments.push(attachment);
  155. }
  156. return post;
  157. }).catch(e => console.log(e));
  158. }
  159. async function getMisskey(permalink, id) {
  160. const gw = new URL(permalink.origin + '/api/notes/show');
  161. return await fetch(gw.href, {method: 'POST', body: `{"noteId": "${id}"}`,
  162. headers: {'Content-Type': 'application/json'}}
  163. ).then(res => res.json()).then(async data => {
  164. function getMP(data) {
  165. let host = data.user.host ? data.user.host : gw.host;
  166. let post = {
  167. attachments: [],
  168. avatar: data.user.avatarUrl,
  169. // https://github.com/misskey-dev/misskey/blob/develop/packages/backend/assets/icons/192.png
  170. color: 'rgb(158,194,63)',
  171. text: data.text,
  172. cw: data.cw,
  173. date: data.createdAt,
  174. from: {
  175. url: `${gw.origin}/notes/${data.id}`,
  176. acct: `${data.user.username}@${host}`,
  177. display_name: data.user.name ? data.user.name : data.user.username,
  178. profile_url: `${gw.origin}/@${data.user.username}@${host}`,
  179. },
  180. };
  181. if (data.reply) {
  182. const host = data.reply.user.host ? data.reply.user.host : gw.host;
  183. post.to = {
  184. url: `${gw.origin}/notes/${data.reply.id}`,
  185. acct: `${data.reply.user.username}@${host}`,
  186. display_name: data.reply.user.name
  187. ? data.reply.user.name : data.reply.user.username,
  188. profile_url: `${gw.origin}/@${data.reply.user.username}@${host}`,
  189. };
  190. }
  191. for (const key in data.files) {
  192. const media = data.files[key];
  193. const attachment = {url: media.url};
  194. if ((""+media.type).startsWith('image')) {
  195. attachment.tag = 'img';
  196. } else if ((""+media.type).startsWith('video')) {
  197. attachment.tag = 'video';
  198. } else if ((""+media.type).startsWith('audio')) {
  199. attachment.tag = 'audio';
  200. } else if (media.type) {
  201. attachment.tag = 'a';
  202. }
  203. if (media.name) {
  204. attachment.alt = media.name;
  205. }
  206. if (media.properties) {
  207. attachment.width = media.properties.width;
  208. }
  209. post.attachments.push(attachment);
  210. }
  211. return post;
  212. }
  213. let post = getMP(data);
  214. post.cite = data.renote ? getMP(data.renote) : null;
  215. return post;
  216. }).catch(e => null);
  217. }
  218. async function getTwitter(permalink, id) {
  219. let gw = permalink.origin + '/api/';
  220. if (permalink.host === 'twitter.com') {
  221. gw = 'https://api.twitter.com/1.1/'
  222. }
  223. const url = new URL(gw + 'statuses/show.json?id='+ id);
  224. return await fetch(url.href).then(res => res.json()).then(async data => {
  225. let post = {
  226. attachments: [],
  227. avatar: data.user.profile_image_url,
  228. color: 'rgb(29,155,240)',
  229. text: data.text,
  230. cw: '',
  231. date: (new Date(data.created_at)).toISOString(),
  232. from: {
  233. url: url.origin + '/notice/' + data.id,
  234. acct: `${data.user.screen_name}@${url.host}`,
  235. display_name: data.user.name,
  236. profile_url: url.origin + '/' + data.user.screen_name,
  237. },
  238. };
  239. if (data.in_reply_to_status_id) {
  240. post.to = {
  241. url: url.origin + '/notice/' + data.in_reply_to_status_id,
  242. acct: data.in_reply_to_screen_name + '@' + url.host,
  243. profile_url: url.origin + '/' + data.in_reply_to_screen_name,
  244. };
  245. post.to.display_name = await fetch(
  246. gw+'users/show.json?screen_name='+data.in_reply_to_screen_name)
  247. .then(res => res.json()).then(data => data.name)
  248. // It seems GNU social does not support remote user.
  249. // And return fail back for deleted user.
  250. .catch(e => data.in_reply_to_screen_name);
  251. }
  252. return post;
  253. }).catch(e => null);
  254. }
  255. async function getATProtocol(permalink, id, handle, password) {
  256. const gw = 'https://bsky.social/xrpc/'
  257. const jwt = await fetch(gw + 'com.atproto.server.createSession',
  258. {method: 'POST', headers: {'Content-Type': 'application/json'},
  259. body: `{"identifier":"${handle}", "password":"${password}"}`})
  260. .then(res => res.json()).then(async data => data.accessJwt)
  261. .catch(e => console.log(e));
  262. const paths = permalink.pathname.split('/');
  263. const did = await fetch(gw + 'com.atproto.identity.resolveHandle?handle=' +
  264. ((paths.length > 1) ? paths[2] : ''))
  265. .then(res => res.json()).then(async data => data.did)
  266. .catch(e => console.log(e));
  267. const uri = 'at://' + did + '/app.bsky.feed.post/' + paths.slice(-1);
  268. return await fetch(gw + 'app.bsky.feed.getPostThread?depth=0&uri=' + uri,
  269. {headers: {'Authorization':`Bearer ${jwt}`}})
  270. .then(res => res.json()).then(async data => {
  271. const appUrl = 'https://bsky.app/profile/';
  272. let post = {
  273. attachments: [],
  274. avatar: data.thread.post.author.avatar ?
  275. data.thread.post.author.avatar : '',
  276. // bsky.social default banner color.
  277. color: 'rgb(0,112,255)',
  278. text: data.thread.post.record.text,
  279. cw: '',
  280. date: data.thread.post.record.createdAt,
  281. from: {
  282. url: permalink,
  283. acct: '@' + data.thread.post.author.handle,
  284. display_name: data.thread.post.author.displayName ?
  285. data.thread.post.author.displayName
  286. : data.thread.post.author.handle,
  287. profile_url: appUrl + data.thread.post.author.handle,
  288. },
  289. };
  290. if (data.thread.parent) {
  291. post.to = {
  292. url: appUrl + data.thread.parent.post.author.handle + '/post/' +
  293. data.thread.parent.post.uri.split('/').slice(-1),
  294. acct: '@' + data.thread.parent.post.author.handle,
  295. profile_url: appUrl + data.thread.parent.post.author.handle,
  296. display_name: data.thread.parent.post.author.displayName ?
  297. data.thread.parent.post.author.displayName
  298. : data.thread.parent.post.author.handle,
  299. };
  300. }
  301. function getAttachmentsFromEmbeds(embeds) {
  302. let attachments = [];
  303. for (const eKey in embeds) {
  304. const embed = embeds[eKey];
  305. if (embed['$type'] === 'app.bsky.embed.images#view') {
  306. attachments = getAttachmentsFromImages(embed.images);
  307. } else
  308. if (embed['$type'] === 'app.bsky.embed.recordWithMedia#view') {
  309. attachments = getAttachmentsFromImages(embed.media.images);
  310. }
  311. }
  312. return attachments;
  313. }
  314. // app.bsky.embed.images#view
  315. function getAttachmentsFromImages(images) {
  316. let attachments = [];
  317. for (const iKey in images) {
  318. const image = images[iKey];
  319. const attachment = {
  320. tag: 'img',
  321. url: image.thumb,
  322. };
  323. if (image.alt) {
  324. attachment.alt = image.alt;
  325. }
  326. attachments.push(attachment);
  327. }
  328. return attachments;
  329. }
  330. // Embed (attachment/quote etc.)
  331. if (data.thread.post.embed) {
  332. const embed = data.thread.post.embed;
  333. // Attachments
  334. if (embed.media &&
  335. (embed.media['$type'] === 'app.bsky.embed.images#view')) {
  336. post.attachments = getAttachmentsFromImages(embed.media.images);
  337. }
  338. // Quote post
  339. let record = embed.record;
  340. if (embed['$type'] === 'app.bsky.embed.recordWithMedia#view') {
  341. record = embed.record.record;
  342. }
  343. if (record) {
  344. if (record.value) {
  345. post.cite = {
  346. attachments: [],
  347. avatar: record.author.avatar ? record.author.avatar : '',
  348. color: 'rgb(0,112,255)',
  349. text: record.value.text,
  350. cw: '',
  351. date: record.value.createdAt,
  352. from: {
  353. url: appUrl+record.author.handle+'/post/'+
  354. record.uri.split('/').slice(-1),
  355. acct: '@' + record.author.handle,
  356. display_name: record.author.displayName ?
  357. record.author.displayName : record.author.handle,
  358. profile_url: appUrl + record.author.handle,
  359. },
  360. };
  361. post.cite.attachments = getAttachmentsFromEmbeds(record.embeds);
  362. }
  363. }
  364. }
  365. return post;
  366. }).catch(e => console.log(e));
  367. }
  368. /// @sa https://pisuke-code.com/javascript-imple-htmlspecialchars/
  369. /// @sa http://var.blog.jp/archives/77442554.html
  370. function htmlspecialchars(unsafeText){
  371. if (typeof unsafeText !== 'string') {
  372. return unsafeText;
  373. }
  374. let escaped = document.createElement('div');
  375. escaped.textContent = unsafeText;
  376. console.log(escaped.innerHTML);
  377. return escaped.innerHTML;
  378. }
  379. };