generated at
Tweetを取り込むPopup menu v2
新規の利用は非推奨
/villagepump/scrapbox-url-customizerが上位互換だからこっちを使うのがよさそう。後で差し替える

開発の経緯
2023/3ごろにTwitterのAPIが不調になった(ユーザー視点で言うとScrapboxのツイート引用機能が使えなくなった)
これに対応するためのスクリプト
お行儀は悪いので自己責任で

必要なもの
GM_fetchをUserScriptとしてTamperMonkeyにインストール

TODO
scrapbox-url-customizerと統合したい
Gyazoアップロードはオプションとしたい
Twitter blueの動画と写真まぜこぜのツイートには現時点では非対応
一部のユーザーを展開できない問題
センシティブユーザーはログインしないと取得できない
json
{ "__typename": "TweetTombstone", "tombstone": { "text": { "text": "Age-restricted adult content. This content might not be appropriate for people under 18 years old. To view this media, you\u2019ll need to log in to X. Learn more", "entities": [ { "from_index": 134, "to_index": 140, "ref": { "__typename": "TimelineUrl", "url": "https://twitter.com", "url_type": "ExternalUrl" } }, { "from_index": 147, "to_index": 157, "ref": { "__typename": "TimelineUrl", "url": "https://help.twitter.com/rules-and-policies/notices-on-twitter", "url_type": "ExternalUrl" } } ], "rtl": false } }

更新履歴
✅2023-09-14 TwitterからのURLコピーがx.comメインに変わった
✅2023/7/25 レスポンス形式が変わって動画URLが取得できない
✅URLが消える場合がある
t.coを削除する処理の順番が早くてバグってた
✅動画がGIFの場合に失敗する
、variant.srcから解像度情報(幅と高さ)がレスポンスに存在しない
json
"aspectRatio": [ 124, 83 ],
この要素がある場合これが解像度情報になるように変更した
https://t.co/NSoBhpgdiO のようなURLをtextから除去する
✅複数の画像は改行しない方がいい

script.js
import {convertWholeText} from '/api/code/motoso/Tweetを取り込むPopup menu v2/convert.js'; // https://scrapbox.io/customize/scrapbox-insert-text export function insertText({text}) { const cursor = document.getElementById('text-input'); cursor.focus(); const start = cursor.selectionStart; // in this case maybe 0 cursor.setRangeText(text); cursor.selectionStart = cursor.selectionEnd = start + text.length; const uiEvent = document.createEvent('UIEvent'); uiEvent.initEvent('input', true, false); cursor.dispatchEvent(uiEvent); } scrapbox.PopupMenu.addButton({ title: 'Embed a tweet', onClick: text => { if (!/https:\/\/(twitter|x)\.com\S+\/status\/\d+/.test(text)) { return; // URLがなければ何もしない } const cursor = document.getElementById('text-input') Promise.all(text.split('\n').map(line => { const matches = line.match(/^\s+|.*/g); const indent = /^\s+$/.test(matches[0])? matches[0] : ''; const content = /^\s+$/.test(matches[0])? matches[1] : matches[0]; return convertWholeText(content, indent) })).then(lines => insertText({text: lines.join('\n'), cursor: cursor})); // 入力しやすいよう選択範囲を先に消しておく return ''; }, });

tweet情報を整形して返す
convert.js
import {getTweetInfo} from '/api/code/motoso/Tweetを取り込むPopup menu v2/getTweetInfo.js'; // 複数のURLを含んだテキストをまとめて変換する export async function convertWholeText(text,indent) { const tweetRegExp = /https:\/\/(twitter|x)\.com\S+\/status\/\d+(?:\?s=\d+)?/g; const urls = text.match(tweetRegExp) ?? []; if (urls.length === 0) return undefined; const tweets = (await Promise.all(urls.map(url => getTweetInfo({tweetUrl: url})))) .map(tweetInfo => { return convert({ tweetInfo, indent, }) }); let map = {}; for (const originalUrl of urls) { const i = urls.indexOf(originalUrl); if (!tweets[i]) break; map[originalUrl]= tweets[i]; } //console.log(map); const result = text.replace(tweetRegExp, match => map[match] ?? match); //console.log(result); return result; }

引用の形を決めるところ
この関数をいじってお好みのformatにしてください 
convert.js
function convert({tweetInfo, indent}) { return [ ...tweetInfo.content.map((text, i) => { // 先頭だけに名前をつける if(i===0){ return `${indent}> [@${tweetInfo.author.screenName} ${tweetInfo.date.href}]: ${text}` } else { return `${indent}> ${text}` } }) ].join('\n'); }
こうなる
> @motoso: Twitterのようなバイラルを求めつつ規制を受けないというのは多分無理なので棲み分けがめちゃくちゃ重要だと思うんだよね

tweet取得処理
getTweetInfo.js
export async function getTweetInfo({tweetUrl} = {}) { if (!window.GM_fetch) { alert('Please install GM_fetch'); return; } const getHighestResolutionVideoSrc = (json) => { const variants = json.variants; let highestResolution = -1; let highestResolutionVideoSrc = null; for (const variant of variants) { if (variant.type === 'video/mp4') { let resolution; let width, height; if (/\d+x\d+/.test(variant.src)) {   // URLに解像度情報が含まれている(長い動画)の場合それを利用する   [width, height] = variant.src.match(/\d+x\d+/)[0].split('x').map(Number); } else if (json.aspectRatio) {   // 短い動画の場合この要素に解像度情報が含まれている    [width, height] = json.aspectRatio; } else {     continue; } resolution = width * height; if (resolution > highestResolution) { highestResolution = resolution; highestResolutionVideoSrc = variant.src; } } } return highestResolutionVideoSrc; } const getTweetInfo = async (id, lang = "en") => { const params = new URLSearchParams([ ["features", "tfw_timeline_list:linktr.ee,tr.ee,terra.com.br,www.linktr.ee,www.tr.ee,www.terra.com.br;tfw_horizon_timeline_12034:treatment;tfw_tweet_edit_backend:on;tfw_refsrc_session:on;tfw_chin_pills_14741:color_icons;tfw_tweet_result_migration_13979:tweet_result;tfw_sensitive_media_interstitial_13963:interstitial;tfw_experiments_cookie_expiration:1209600;tfw_duplicate_scribes_to_settings:on;tfw_video_hls_dynamic_manifests_15082:true_bitrate;tfw_show_blue_verified_badge:off;tfw_related_videos_15128:many_vids;tfw_tweet_edit_frontend:on"], ["id", id],
2023年8月の/villagepump/Twitter(X)のツイート取得APIの変更によりtokenが必要になった
getTweetInfo.js
["token", "detarame"], // 不要だった // ["lang", lang], ]); const res = await GM_fetch(`https://cdn.syndication.twimg.com/tweet-result?${params}`, { headers: { "content-type": "application/json; charset=utf-8", }, }); return await res.json(); }; const tweetIdRegex = /\/status\/(\d+)/; const tweetIdMatch = tweetUrl.match(tweetIdRegex); const tweetId = tweetIdMatch ? tweetIdMatch[1] : null; const removeTcoUrls = (text) => { const tcoUrlRegex = /https?:\/\/t\.co\/\S+/g; return text.replace(tcoUrlRegex, '').trim(); } try { const tweet = await getTweetInfo(`${tweetId}`) // textを整形 const lines = [];  console.log(tweet) const text = tweet.text // URLを短縮URLからもとのURLに戻す const replacedText = tweet.entities.urls.reduce((acc, urlObj) => { return acc.replace(urlObj.url, urlObj.expanded_url); }, text); lines.push(removeTcoUrls(replacedText)) // Gyazoにアップロードしたいならここでやる if(tweet.photos) { lines.push(tweet.photos.map(u => `[${u.url}]`).join(' ')) } if(tweet.video) { const highestResolutionVideoSrc = getHighestResolutionVideoSrc(tweet.video) lines.push(`[${highestResolutionVideoSrc}#.mp4]`) } // 各種情報を詰め込んで返す const user = tweet.user; return { author: { name: user.name, screenName: user.screen_name, }, content: lines.join('\n').split('\n'), date : { href: tweetUrl, // "2023-03-19T12:41:58.000Z" => '2023-03-19 12:41:58' text: tweet.created_at.replace("T", " ").split(".")[0] }, }; } catch(e) { console.error(e); } }