您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
https://cohost.org/lunasorcery/post/315741-imagine-if-you-will
// ==UserScript== // @name rechost chain thing // @namespace https://github.com/adrianmgg // @version 1.0.0 // @description https://cohost.org/lunasorcery/post/315741-imagine-if-you-will // @author amgg // @match https://cohost.org/* // @icon https://cohost.org/static/a4f72033a674e35d4cc9.png // @grant unsafeWindow // @run-at document-start // @compatible firefox // @compatible chrome // @license MIT // ==/UserScript== // this is just a modified version of version 1.0.5 of my 'view post source' cohost userscript // https://greasyfork.org/en/scripts/448841-cohost-view-post-source (function() { // not all userscript managers have unsafeWindow const _unsafeWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; // ============================================================================= function observer_helper(filter, on_node_found, options) { const once = options?.once ?? false; const subtree = options?.subtree ?? false; return (target) => { for(const node of target.childNodes) { if(filter(node)) { on_node_found(node); if(once) return; // don't need to bother setting up the observer } } const observer = new MutationObserver((mutations, observer) => { for(const mutation of mutations) { if(mutation.type === 'childList') { for(const node of mutation.addedNodes) { if(filter(node)) { on_node_found(node); if(once) { observer.disconnect(); return; } } } } } }); observer.observe(target, { childList: true, subtree: subtree }); } } function observer_helper_chain(on_leaf_found, ...stuff) { let cur = observer_helper(stuff[stuff.length - 1][0], on_leaf_found, stuff[stuff.length - 1][1]); for(let i = stuff.length - 1 - 1; i >= 0; i--) { cur = observer_helper(stuff[i][0], cur, stuff[i][1]); } return cur; } function observer_helper_promise(target, filter, options) { return new Promise((resolve) => { observer_helper(filter, resolve, {...options, once: true})(target); }); } // ============================================================================= function arrayEqualsStrict(a, b) { if(a === b) return true; if(a.length !== b.length) return false; for(let i = 0; i < a.length; i++) { if(a[i] !== b[i]) return false; } return true; } // ============================================================================= // ======== keeping track of post data ======== let post_id_to_post_data = {}; function handle_post_data(post) { post_id_to_post_data[post.postId] = post; if(post.transparentShareOfPostId !== undefined) { post_id_to_post_data[post.transparentShareOfPostId] = post; } } // ======== watching for new post elements ======== async function handle_post_element(post_elem) { // if we already modified this post we don't want to do it again if('amggRechostChainAdded' in post_elem.dataset) { return; } post_elem.dataset.amggRechostChainAdded = ''; // these have the post id in their id. the last one should be the one with the id // for the current post (rather than another post further up in the thread) const post_id_elems = post_elem.querySelectorAll('[id^="post-"]'); const post_id_elem = post_id_elems[post_id_elems.length - 1]; const post_id = parseInt(post_id_elem.id.slice('post-'.length)); const post_data = post_id_to_post_data[post_id]; if(post_data === undefined) { console.warn(`unable to add rechost chain thing to post #${post_id} because no post data was found`); return; } const post_article = post_elem.querySelector(':scope > article'); const post_header = post_article.querySelector(':scope > header'); const header_share_info = post_header.querySelector(':scope > div:not(:has(button))'); if(post_data.shareTree.length < 2) { return; } if(header_share_info.childNodes.length !== 8) { console.warn('unable to add rechost chain thing, unexpected page layout (site updated?)'); return; } // skipped elements are sharing user's icon, name, and handle, and then the rechost timestamp const [, , , , share_svg, sharesource_icon_container, sharesource_name, sharesource_handle] = header_share_info.childNodes; const sharesource_icon = sharesource_icon_container.querySelector(':scope > img'); // duplicate the share icon as needed for(let i = 1; i < post_data.shareTree.length; i++) { share_svg.parentElement.insertBefore(share_svg.cloneNode(true), share_svg); } // replace the direct source's info with op const op_info = post_data.shareTree[0].postingProject; const op_profile_url = `https://cohost.org/${op_info.handle}`; sharesource_icon.src = op_info.avatarPreviewURL; sharesource_icon.alt = op_info.handle; sharesource_name.href = op_profile_url; sharesource_name.title = op_info.displayName; sharesource_name.textContent = op_info.displayName; sharesource_handle.href = op_profile_url; sharesource_handle.textContent = `@${op_info.handle}`; } function attach_post_observer() { // TODO probably not the best performance-wise to observe mutations on the entire page, // would be better to first identify the container posts live in, then just observe // childList mutations on that container (new MutationObserver((mutations) => { for(const mutation of mutations) { if(mutation.type === 'childList') { for(const node of mutation.addedNodes) { if(node.nodeType === Node.ELEMENT_NODE) { if(node.getAttribute('data-view') === 'post-preview') { handle_post_element(node); } node.querySelectorAll('[data-view="post-preview"]').forEach(handle_post_element); } } } } })).observe(document, { subtree: true, childList: true }); // for any that're already there before we start observing mutations document.querySelectorAll('[data-view="post-preview"]').forEach(handle_post_element); } if(document.readyState === 'interactive') { attach_post_observer(); } else { document.addEventListener('readystatechange', e => { if(document.readyState === 'interactive') { attach_post_observer(); } }); } // ======== handle the posts that are on the page initially ======== // (initially I did this by just grabbing the desired elements out of the head // as soon as they were present, but with longer data and a slower connection // that ended up trying to parse the json data before the content of the node // had fully loaded, which would fail. because of that, i switched to waiting // for the body to exist, since that should mean all the head nodes have been // fully downloaded) document.addEventListener('amgg__rechostchainthing__foundpost', (e) => { handle_post_data(e.detail.post); }); observer_helper_chain( (body) => { const dehydrated_state_elem = document.getElementById('trpc-dehydrated-state'); if(dehydrated_state_elem !== null) { const dehydrated_state = JSON.parse(dehydrated_state_elem.textContent); for(const query of dehydrated_state.queries) { if(Array.isArray(query.queryKey) && Array.isArray(query.queryKey[0])) { const queryPath = query.queryKey[0]; // TODO: does posts.byProject show up anywhere anymore? leaving it in for now just in case if(arrayEqualsStrict(queryPath, ['posts', 'byProject']) || arrayEqualsStrict(queryPath, ['posts', 'profilePosts'])) { // posts on someone's profile query.state.data.posts.forEach(handle_post_data); } else if(arrayEqualsStrict(queryPath, ['posts', 'singlePost'])) { // viewing a single post handle_post_data(query.state.data.post); } } } } const cohost_loader_state_elem = document.getElementById('__COHOST_LOADER_STATE__'); if(cohost_loader_state_elem !== null) { const cohost_loader_state = JSON.parse(cohost_loader_state_elem.textContent); // initial posts on home page (first page only) cohost_loader_state.dashboard?.posts?.forEach?.(handle_post_data); // posts on home but not first page cohost_loader_state['project-post-feed']?.posts?.forEach?.(handle_post_data); // posts on a tag search page cohost_loader_state['tagged-post-feed']?.posts?.forEach?.(handle_post_data); } }, [n => n.nodeType === Node.ELEMENT_NODE && n.nodeName === 'HTML', {once: true}], [n => n.nodeType === Node.ELEMENT_NODE && n.nodeName === 'BODY', {once: true}], )(document); // ======== handle posts that get loaded later ======== // originally i was just getting the original functions from unsafeWindow, // wrapping them, and replacing them, but that doesn't seem to work on // greasemonkey, where the site's code errors out trying to call the wrapped // function (i guess they do their sandboxing differently from tampermonkey & // violentmonkey? idk). I don't actually need any privelaged usersript // functions, so in theory i could `@grant none` and just not run sandboxed, but // as far as i can tell the problem *still* happens on greasemonkey even then. // doing it this way with events seems to work everywhere through, so that's // what i'll go with _unsafeWindow.eval(` (() => { function return_post_to_sandbox(post) { document.dispatchEvent(new CustomEvent('amgg__rechostchainthing__foundpost', { detail: { post: post, }, })); } const original_fetch = window.fetch; window.fetch = function(resource, options) { if(resource?.constructor === String) { try { const url = new URL(resource, window.location.href); if(url.hostname === 'cohost.org' && url.pathname.startsWith('/api/v1/trpc/')) { const requested_things = url.pathname.slice('/api/v1/trpc/'.length).split(','); // TODO only need to bother wrapping it if one if the things we care about is in this fetch return original_fetch.apply(this, arguments).then(response => { return new Promise(async (resolve) => { // the requests get aborted if we give back the original response before we're done awaiting the .json() on the cloned response const json = await response.clone().json(); for(const i in requested_things) { // for posts viewed on user profiles if(requested_things[i] === 'posts.byProject' || requested_things[i] === 'posts.profilePosts') { json[i].result.data.posts.forEach(return_post_to_sandbox); } } resolve(response); }); }); } } catch(e) { console.error('view post source: error in fetch wrapper', e); } } return original_fetch.apply(this, arguments); } const original_EventSource = window.EventSource; window.EventSource = function(...args) { const ret = new original_EventSource(...args); if(ret.url === 'https://cohost.org/rc/dashboard/event-stream') { ret.addEventListener('message', (e) => { const data = JSON.parse(e.data); data.add.forEach(return_post_to_sandbox); }); } return ret; }; })(); `); })();