Greasy Fork

Greasy Fork is available in English.

[Twitter]長いツイートをTLで展開

長いツイートを「更に表示」を押さなくてもTLで展開します。

当前为 2025-03-14 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name			[Twitter]長いツイートをTLで展開
// @name:ja			[Twitter]長いツイートをTLで展開
// @name:en			[Twitter]Note_Tweet expander
// @version			1145141919810.0.21
// @description			長いツイートを「更に表示」を押さなくてもTLで展開します。
// @description:ja			長いツイートを「更に表示」を押さなくてもTLで展開します。
// @description:en			Long tweets will expand in the TimeLine without having to press "Show More".
// @author			ゆにてぃー
// @match			https://twitter.com/*
// @match			https://mobile.twitter.com/*
// @match			https://x.com/*
// @match			https://X.com/*
// @connect			api.twitter.com
// @icon			data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAJkSURBVHgB7VZBbtpQEH3zIW0WVYuXVaH4Bs0NSk4AOUFhEarskhMknIDsqkKlcIT0BNAT1D1B3ZJK3dmVuirwp/MhVmzAxiagKBJv9+ePZ97M/JkxsMMODwzChlD84FWQp3MxeCDHAhiumB+MJrr1+8Ryw3p/9+H4DctfIPCq49Xlw8Kv99YlMuB19885gy/i7llziwGfFFWJyR02XzSCuwiBUse7BlFVaz5LS8KQVkRXaXRJsqImfDjKSZBNyzEyFWFKVJ4KFbWLElUao6KbSk8i9TXgTPaorxTskPwOxa7/9baGt4zg8oQbNyfWYJlRU0/KUx9ZwNwYNq1ecFRzl18QpW0bB0Ks//KjV1uwlbuLJA3GxEdh5wb5yGEPl3qMd2xecYQHKnlFlVLX95kxYCFKGg5IlU2a0uLpCM68LEJA+sJ/Dm6Jy3aMjQIRakRUm+UuvfOp/X34iQSejeFo0Hdx4optG5uFH/R+GHNvANcm3VtwLs+Lvy2TRwhIOnrYHhysIuDKcCDwGbYAjglOzQt+HssElF6dvoNNOZeuCSbfSgIGMjILMo4/ExZf7TqghNLmlwm1gpSC2tmaLAZMvWGz0Iu7XpqBm2NrQNN5cD+Y5ZOTdZyok3RZMusZOJUN+QZrQFb0oQkG6xIIYHe8A03Unx/Ryd6jS2ctAsbxmFRVynGKlM5na5ePVkUe0p+h9MmraS2zXqYgmSWjOPtElHbLTVB3Q79gqQlMScxqXpeav0UWiGMmXKSNOpZAAPvKs/U/1MRoxRxl+5WD+psUy2D5IdmRVoWjnqDnLlkyO+zwaPAf1zXwZL751PUAAAAASUVORK5CYII=
// @grant			GM_xmlhttpRequest
// @license			MIT
// @namespace			http://greasyfork.icu/ja/users/1023652
// ==/UserScript==

(function() {
	'use strict';
	let updating = false;
	const userAgent = navigator.userAgent || navigator.vendor || window.opera;
	const isFirefox = !!userAgent.match("Firefox")
	let fetchedTweets = {};
	let fetchedTweetsUserData = {};
	let fetchedTweetsUserDataByUserName = {};
	window.addEventListener("scroll", update);
	init();
	async function main(){
		const link_class = "css-1jxf684 r-bcqeeo r-1ttztb7 r-qvutc0 r-poiln3 r-1loqt21";
		document.querySelectorAll('article[data-testid="tweet"]:not(.tweetExpanderChecked)').forEach(async function(tweet){
			tweet.classList.add('tweetExpanderChecked');
			const elements = tweet.querySelectorAll('[data-testid="tweetText"]');
			const show_more_link = tweet.querySelectorAll('[data-testid="tweet-text-show-more-link"]');
			const tweetId = tweet.querySelector(`[data-testid="User-Name"] a[aria-label], .css-1dbjc4n.r-1d09ksm.r-1471scf.r-18u37iz.r-1wbh5a2 a[aria-label]`)?.href.match(/[\w]{1,}\.com\/[^/]+\/status\/(\d+)/)[1];
			if(!tweetId)return;
			if(show_more_link[0])show_more_link[0].style.display = "none";
			if(show_more_link[1])show_more_link[1].style.display = "none";
			elements.forEach(async (element,index) =>{
				if(!(element.innerText.match(/…$/) || (show_more_link[index]?.tagName.toLowerCase().match(/div|button/)))){
					element.classList.add('tweetExpanderChecked');
					//if(element.innerText.split('\n').length >= 10)
					element.style.webkitLineClamp = null;
					return;
				}
				let tweet_data,note_tweet;
				if(index == 0){
					tweet_data = await getTweetData(tweetId,"graphQL");
					note_tweet = tweet_data.note_tweet?.note_tweet_results.result || tweet_data.note_tweet?.note_tweet_results.result || null;
				}else{
					tweet_data = await getTweetData(tweetId,"graphQL");
					tweet_data = await getTweetData(tweet_data.legacy.quoted_status_id_str,"graphQL");
					note_tweet = tweet_data.note_tweet?.note_tweet_results.result || tweet_data.note_tweet?.note_tweet_results.result || null;
				}
				if(!note_tweet){
					element.style.webkitLineClamp = null;
					element.classList.add('tweetExpanderChecked');
					return;
				}
				const hashtags = note_tweet.entity_set?.hashtags || [];
				const user_mentions = note_tweet.entity_set?.user_mentions || [];
				const symbols = note_tweet.entity_set?.symbols || [];
				const urls = note_tweet.entity_set?.urls;
				var new_tweet_text = note_tweet.text;
				function countSurrogatePairs(str){
					return Array.from(str).filter(char => char.match(/[\uD800-\uDBFF][\uDC00-\uDFFF]/)).length;
				}
				let combined = [].concat(
					hashtags.map(tag => ({
						type: 'hashtag',
						indices: tag.indices,
						text: tag.text
					})),
					user_mentions.map(mention => ({
						type: 'mention',
						indices: mention.indices,
						text: mention.screen_name
					})),
					symbols.map(symbol => ({
						type: 'symbol',
						indices: symbol.indices,
						text: symbol.text
					}))
				);
				// combinedをindicesの順にソート
				combined.sort((a, b) => b.indices[0] - a.indices[0]);
				let transformedText = new_tweet_text;

				combined.forEach(item => {
					let start = item.indices[0];
					let end = item.indices[1];

					// サロゲートペアの数をカウントして調整
					const adjustment = countSurrogatePairs(transformedText.slice(0, end));
					start += adjustment;
					end += adjustment;

					let replacement = '';
					switch(item.type){
						case 'hashtag':
							replacement = `<a class="${link_class}" style="text-decoration: none;color:rgb(29, 155, 240)" dir="ltr" role="link" href="https://twitter.com/hashtag/${item.text}" target="_blank" rel="noopener">#${item.text}</a>`;
							break;
						case 'mention':
							replacement = `<a class="${link_class}" style="text-decoration: none;color:rgb(29, 155, 240)" dir="ltr" role="link" href="https://twitter.com/${item.text}" target="_blank" rel="noopener">@${item.text}</a>`;
							break;
						case 'symbol':
							replacement = `<a class="${link_class}" style="text-decoration: none;color:rgb(29, 155, 240)" dir="ltr" role="link" href="https://twitter.com/search?q=%24${item.text}&src=cashtag_click" target="_blank" rel="noopener">$${item.text}</a>`;
							break;
					}
					transformedText = transformedText.slice(0, start) + replacement + transformedText.slice(end);
				});
				new_tweet_text = transformedText;
				const seen = new Set();
				urls.filter(target => !seen.has(target.url) && seen.add(target.url)).forEach(target =>{
					new_tweet_text = new_tweet_text.replace(new RegExp(`${target.url}(?=(\\s|$|\\u3000|\\W)(?!\\.|,))`, 'gu'), `<a class="${link_class}" style="text-decoration: none;color:rgb(29, 155, 240)" dir="ltr" role="link" href="${target.url}" target="_blank" rel="noopener">${target.display_url}</a>`);
				});
				var new_tweet_node = document.createElement("span");
				new_tweet_node.className = 'css-901oao css-16my406 r-1tl8opc r-bcqeeo r-qvutc0';
				new_tweet_node.innerHTML = new_tweet_text;
				while(element.firstChild){
					element.removeChild(element.firstChild);
				}
				element.appendChild(new_tweet_node);
				element.style.webkitLineClamp = null;
			});
		});
	}
	function init() {
		main();
	}
	function update() {
		if(updating) return;
		updating = true;
		init();
		setTimeout(() => {updating = false;}, 1500);
	}
	function GetCookie(name){
		let arr, reg = new RegExp("(^| )" + name + "=([^;]*)(;|$)");
		if(arr = document.cookie.match(reg)){
			return decodeURIComponent(arr[2]);
		}else{
			return null;
		}
	}
	function findParent(element, selector){
		let current = element;
		while(current !== null){
			if(current.matches(selector)){
				return current;
			}
			current = current.parentNode;
		}
		return null;
	}
	async function fetchAndProcessTwitterApi(method,id = undefined,forceFetch = false){
		return new Promise(async (resolve, reject) => {
			try{
				switch(method){
					case 'graphQL':
						if(fetchedTweets[id] && !(forceFetch === true))return resolve(fetchedTweets[id]);
						await graphQL();
						break;
					default:
						console.warn("なにか間違ってないか?")
						return reject("something wrong.");
				}
				return resolve("OK!");
			}catch(error){
				console.error(error);
				throw new Error(`Failed to fetch API data.\nmethod: ${method}\nid: ${id}`);
			}
		});
		async function graphQL(){
			const response = await request(new requestObject_twitter_api_graphql(id));
			if(!response.status === "200")throw new Error(`Failed to fetch`);
			processgraphQL(response.response.data.threaded_conversation_with_injections_v2.instructions[0].entries);
		}
		function processgraphQL(entries){
			if(!entries)return null;
			entries.forEach(entry=>{
				const tmpData = entry.content?.itemContent?.tweet_results || entry.item?.itemContent?.tweet_results;
				let tweetData = tmpData?.result?.tweet || tmpData?.result;
				if(tweetData?.tombstone)return;
				if(!tweetData)return;
				try{
					if(tweetData.quoted_status_result){
						let quoted = tweetData.quoted_status_result.result?.tweet || tweetData.quoted_status_result.tweet || tweetData.quoted_status_result?.result;
						fetchedTweetsUserData[quoted.core.user_results.result.rest_id] = {...quoted.core.user_results.result,"API_type": "graphQL"};
						fetchedTweetsUserDataByUserName[quoted.core.user_results.result.legacy.screen_name] = fetchedTweetsUserData[quoted.core.user_results.result.rest_id];
						quoted.core.user_results.result = fetchedTweetsUserData[quoted.core.user_results.result.rest_id];
						fetchedTweets[quoted.rest_id] = {...quoted,"API_type": "graphQL"};
						tweetData.quoted_status_result.result = fetchedTweets[quoted.rest_id];
					}
					fetchedTweetsUserData[tweetData.core.user_results.result.rest_id] = {...tweetData.core.user_results.result,"API_type": "graphQL"};
					fetchedTweetsUserDataByUserName[tweetData.core.user_results.result.legacy.screen_name] = fetchedTweetsUserData[tweetData.core.user_results.result.rest_id];
					tweetData.core.user_results.result = fetchedTweetsUserData[tweetData.core.user_results.result.rest_id];
					fetchedTweets[tweetData.rest_id] = {...tweetData,"API_type": "graphQL"};
				}catch(error){
					console.error
					console.error({error: `processgraphQL error.\ndetails: ${error}`,apiResponse:tweetData});
				}
			});
		}
	}
	async function getTweetData(id, method = 'graphQL', forceFetch = false){
		const dataStore = fetchedTweets;
		if(typeof id === 'string'){
			if(dataStore[id] && !forceFetch)return dataStore[id];
		}else{
			throw new Error("Invalid ID type.");
		}
		await fetchAndProcessTwitterApi(method, id, forceFetch);
		if(dataStore[id]){
			return dataStore[id];
		}else{
			throw new Error("Failed to fetch tweet data for ID: " + id);
		}
	}
	async function request(object, maxRetries = 0, timeout = 60000){
		let retryCount = 0;
		while(retryCount <= maxRetries){
			try{
				return await new Promise((resolve, reject) => {
					GM_xmlhttpRequest({
						method: object.method,
						url: object.url,
						headers: object.headers,
						responseType: object.respType,
						data: object.body,
						anonymous: object.anonymous,
						timeout: timeout,
						onload: function(responseDetails){
							return resolve(responseDetails);
						},
						ontimeout: function(responseDetails){
							reject(`[request]time out:\nresponse ${responseDetails}`);
						},
						onerror: function(responseDetails){
							reject(`[request]error:\nresponse ${responseDetails}`);
						}
					});
				});
			}catch(error){
				retryCount++;
				console.warn(`Retry ${retryCount}: Failed to fetch ${object.url}. Reason: ${error}`);
				if(retryCount === maxRetries){
					throw new Error(`Failed to fetch ${object.url} after ${maxRetries} retries.`);
				}
			}
		}
	}
	class requestObject_twitter_api_graphql{
		constructor(ID) {
			this.method = 'GET';
			this.respType = 'json';
			this.url = `https://${window.location.hostname}/i/api/graphql/TuC3CinYecrqAyqccUyFhw/TweetDetail?variables=%7B%22focalTweetId%22%3A%22${ID}%22%2C%22referrer%22%3A%22home%22%2C%22with_rux_injections%22%3Afalse%2C%22includePromotedContent%22%3Atrue%2C%22withCommunity%22%3Atrue%2C%22withQuickPromoteEligibilityTweetFields%22%3Atrue%2C%22withArticleRichContent%22%3Atrue%2C%22withBirdwatchNotes%22%3Atrue%2C%22withVoice%22%3Atrue%2C%22withV2Timeline%22%3Atrue%7D&features=%7B%22rweb_lists_timeline_redesign_enabled%22%3Atrue%2C%22responsive_web_graphql_exclude_directive_enabled%22%3Atrue%2C%22verified_phone_label_enabled%22%3Afalse%2C%22creator_subscriptions_tweet_preview_api_enabled%22%3Atrue%2C%22responsive_web_graphql_timeline_navigation_enabled%22%3Atrue%2C%22responsive_web_graphql_skip_user_profile_image_extensions_enabled%22%3Afalse%2C%22tweetypie_unmention_optimization_enabled%22%3Atrue%2C%22responsive_web_edit_tweet_api_enabled%22%3Atrue%2C%22graphql_is_translatable_rweb_tweet_is_translatable_enabled%22%3Atrue%2C%22view_counts_everywhere_api_enabled%22%3Atrue%2C%22longform_notetweets_consumption_enabled%22%3Atrue%2C%22responsive_web_twitter_article_tweet_consumption_enabled%22%3Atrue%2C%22tweet_awards_web_tipping_enabled%22%3Afalse%2C%22freedom_of_speech_not_reach_fetch_enabled%22%3Atrue%2C%22standardized_nudges_misinfo%22%3Atrue%2C%22tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled%22%3Atrue%2C%22longform_notetweets_rich_text_read_enabled%22%3Atrue%2C%22longform_notetweets_inline_media_enabled%22%3Atrue%2C%22responsive_web_media_download_video_enabled%22%3Atrue%2C%22responsive_web_enhance_cards_enabled%22%3Afalse%7D&fieldToggles=%7B%22withArticleRichContentState%22%3Atrue%7D`;
			this.body = null;
			this.headers = {
				"Content-Type": "application/json",
				'User-agent': navigator.userAgent || navigator.vendor || window.opera,
				'accept': '*/*',
				'authorization': `Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA`,
				'Accept-Encoding': 'br, gzip, deflate',
				'x-csrf-token': GetCookie("ct0"),
			};
			this.package = null;
			this.anonymous = false;
		}
	}
})();