您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
adds a "view source" button to posts on cohost
// ==UserScript== // @name cohost view post source // @namespace https://github.com/adrianmgg // @version 1.0.9 // @description adds a "view source" button to posts on cohost // @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== (function() { // ============================================================================= // not all userscript managers have unsafeWindow const _unsafeWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; // ======== https://github.com/adrianmgg/elhelper ======== function setup(elem,{style:{vars:styleVars={},...style}={},attrs={},dataset={},events={},classList=[],children=[],parent=null,insertBefore=null,...props}){for(const k in style)elem.style[k]=style[k];for(const k in styleVars)elem.style.setProperty(k,styleVars[k]);for(const k in attrs)elem.setAttribute(k,attrs[k]);for(const k in dataset)elem.dataset[k]=dataset[k];for(const k in events)elem.addEventListener(k,events[k]);for(const c of classList)elem.classList.add(c);for(const k in props)elem[k]=props[k];for(const c of children)elem.appendChild(c);if(parent!==null){if(insertBefore!==null)parent.insertBefore(elem,insertBefore);else parent.appendChild(elem);}return elem;} function create(tagName,options={}){return setup(document.createElement(tagName),options);} function createNS(namespace,tagName,options={}){return setup(document.createElementNS(namespace,tagName),options);} // ====================================================== // ============================================================================= 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; } // ============================================================================= // ======== css stuff ======== function inject_stylesheet() { create('style', { parent: document.head, textContent: ` .amgg__viewsource__wrap-text .amgg__viewsource__source-view-container pre { white-space: pre-wrap; word-break: normal; overflow-wrap: anywhere; } .amgg__viewsource__post-hide-source-view .amgg__viewsource__source-view-container { display: none; } `, }); } // ========= ======== function render_blocks_list(blocks) { const markdown_blocks = blocks.filter(block => block.type === 'markdown').map(block => block.markdown.content); if(markdown_blocks.length === 0) { return null; } return create('pre', { children: [create('code', { textContent: markdown_blocks.join('\n\n'), })], }); } function handle_show_post_source_click(article, view_source_btn, post_id) { const post_data = post_id_to_post_data[post_id]; if('alreadyRendered' in view_source_btn.dataset) { article.classList.toggle('amgg__viewsource__post-hide-source-view'); } else { if(post_data === undefined) { alert(`data for post #${post_id} not found, unable to show source`); } else { // mark ourself as already having created the source view - any further clicks can just toggle its visibility instead view_source_btn.dataset.alreadyRendered = ''; // create('div', { parent: article, classList: ['co-prose', 'prose', 'px-3', 'amgg__viewsource__source-view-container'], children: [ // "wrap text" checkbox create('label', { textContent: 'wrap text ', children: [create('input', { type: 'checkbox', checked: false, events: { change: e => { article.classList[e.target.checked ? 'add' : 'remove']('amgg__viewsource__wrap-text'); }, }, })], }), // the posts create('div', { children: [...(post_data.shareTree.map(p=>p.blocks)), post_data.blocks].map(render_blocks_list).filter(x => x !== null && x !== undefined), }), ], }); } } } // ======== 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 put a view source button on this post we don't want to add a second one // if(post_elem.querySelector('.amgg__viewsource__view-source-button') !== null) { if('amggViewsourceDiscovered' in post_elem.dataset) { return; } post_elem.dataset.amggViewsourceDiscovered = ''; // 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_article = post_elem.querySelector(':scope > article'); const post_footer = post_article.querySelector(':scope > footer > div'); // set up the "show source" button const view_source_btn = create('div', { classList: ['amgg__viewsource__view-source-button'], children: [create('span', { textContent: 'view source', classList: ['cursor-pointer', 'pb-3', 'text-sm', 'font-bold', 'text-cherry', 'hover:underline'], })], events: { click: (e) => { handle_show_post_source_click(post_article, view_source_btn, post_id); }, }, }); post_footer.insertBefore(view_source_btn, post_footer.childNodes[1]); } 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 ======== document.addEventListener('amgg__viewsource__foundpost', (e) => { handle_post_data(e.detail.post); }); // (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) observer_helper_chain( (body) => { inject_stylesheet(); 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 // TODO - is project-post-feed used anymore? cohost_loader_state['project-post-feed']?.posts?.forEach?.(handle_post_data); cohost_loader_state['dashboard-nonlive-post-feed']?.posts?.forEach?.(handle_post_data); // posts on a tag search page cohost_loader_state['tagged-post-feed']?.posts?.forEach?.(handle_post_data); // bookmarks cohost_loader_state['bookmarked-tag-feed']?.posts?.forEach?.(handle_post_data); // per-user tag pages (/username/tagged/tag) cohost_loader_state['project-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__viewsource__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; }; })(); `); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址