Last active
February 21, 2026 05:10
-
-
Save xdegeneratex/6633074876935306eedacfe9932363a8 to your computer and use it in GitHub Desktop.
Coomer / Kemono Video Filter v3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Tampermonkey Video Filter v3 | |
| // @namespace Simpcity Scripts | |
| // @match https://*.coomer.party/*/user/* | |
| // @match https://*.kemono.party/*/user/* | |
| // @match https://*.coomer.st/*/user/* | |
| // @match https://*.coomer.su/*/user/* | |
| // @match https://*.kemono.su/*/user/* | |
| // @grant GM_xmlhttpRequest | |
| // @grant GM_setClipboard | |
| // @version 1.3 | |
| // @author harryangstrom, xdegeneratex | |
| // @description Filters posts with videos using dynamic content detection and improved performance. | |
| // ==/UserScript== | |
| let error = null; | |
| const videoExtensions = ['mp4', 'm4v', 'mov']; | |
| const extensionsPattern = `${videoExtensions.join('|')}`; | |
| const isVideoPath = (path, extensionsPattern) => new RegExp(`\.(${extensionsPattern})$`, 'i').test(path); | |
| const filterVideos = async (updateCB, service, user, extensions) => { | |
| const apiURL = `https://coomer.st/api/v1/${service}/user/${user}/posts`; | |
| const posts = []; | |
| let offset = 0; | |
| let page = 1; | |
| try { | |
| while (true) { | |
| const requestURL = `${apiURL}?o=${offset}`; | |
| const response = await new Promise((resolve) => { | |
| GM_xmlhttpRequest({ | |
| url: requestURL, | |
| headers: { Accept: 'text/css' }, | |
| method: 'GET', | |
| responseType: 'json', | |
| onload: (response) => { | |
| resolve(response.response); | |
| }, | |
| onerror: (response) => { | |
| resolve(null); | |
| } | |
| }); | |
| }); | |
| if (response) { | |
| const postData = response; | |
| if (!postData || !postData.length) { | |
| break; | |
| } | |
| updateCB(page); | |
| const isVideo = (path) => isVideoPath(path, extensionsPattern); | |
| posts.push( | |
| ...postData.map(post => { | |
| const hasVideo = post.file && (isVideo(post.file.name) || isVideo(post.file.path)) || post.attachments.some(attachment => isVideo(attachment.name) || isVideo(attachment.path)); | |
| return { | |
| ...post, | |
| hasVideo, | |
| }; | |
| }).filter(post => post.hasVideo) | |
| ); | |
| } else if (response.status === 503) { | |
| error = '[WARN] 503 Service unavailable.'; | |
| break; | |
| } else { | |
| error = '[WARN] Call to API failed. API or network might be unvailable.'; | |
| break; | |
| } | |
| page++; | |
| offset +=50; | |
| // Wait 2 seconds before making the next API call. | |
| await new Promise((resolve) => setTimeout(() => resolve(true), 2000)); | |
| } | |
| } catch (e) { | |
| error = '[WARN] Call to API failed. API or network might be unvailable.'; | |
| } | |
| return posts; | |
| } | |
| const getFilterButton = () => { | |
| const filterBtn = document.createElement("button"); | |
| filterBtn.textContent = "Filter Videos"; | |
| filterBtn.style.margin = "10px"; | |
| filterBtn.style.padding = "5px 10px"; | |
| filterBtn.style.background = "#007bff"; | |
| filterBtn.style.color = "#fff"; | |
| filterBtn.style.border = "none"; | |
| filterBtn.style.cursor = "pointer"; | |
| filterBtn.style.position = "fixed"; // Ensure it's always visible | |
| filterBtn.style.top = "10px"; | |
| filterBtn.style.right = "10px"; | |
| filterBtn.style.zIndex = "1000"; | |
| return filterBtn; | |
| } | |
| (async () => { | |
| const posts = []; | |
| const btnFilter = getFilterButton(); | |
| document.body.appendChild(btnFilter); | |
| btnFilter.addEventListener('click', async () => { | |
| if (btnFilter.textContent.includes('Copied')) { | |
| return; | |
| } | |
| if (btnFilter.textContent.includes('Copy')) { | |
| const content = posts.flatMap(post => { | |
| const paths = []; | |
| paths.push((post.file && (isVideoPath(post.file.name, extensionsPattern) || isVideoPath(post.file.path, extensionsPattern))) ? post.file.path : null); | |
| paths.push(...(post.attachments || []).map(attachment => (isVideoPath(attachment.name, extensionsPattern) || isVideoPath(attachment.path, extensionsPattern)) ? attachment.path : null)); | |
| return paths.filter(path => path !== null); | |
| }) | |
| .filter(path => path !== null) | |
| .map(path => `https://coomer.st/data${path}`) | |
| .join('\n'); | |
| console.log('[INFO] Copying the following URLs to clipboard:'); | |
| console.log(content); | |
| GM_setClipboard(content); | |
| const textContent = btnFilter.textContent; | |
| btnFilter.textContent = `Copied urls for ${posts.length} video posts to clipboard`; | |
| setTimeout(() => { | |
| btnFilter.textContent = textContent; | |
| }, 3000); | |
| return; | |
| } | |
| btnFilter.textContent = 'Filtering...'; | |
| const segments = document.location.pathname.replace(/\?.*/, '').split('/').filter(segment => segment.trim() !== ''); | |
| if (segments.length < 3) { | |
| btnFilter.textContent = 'Unable to Filter'; | |
| return; | |
| } | |
| const service = segments[0]; | |
| const username = segments[2]; | |
| let totalPages = 1; | |
| const lastPageNavEl = document.querySelector('menu > a[aria-current="page"]:last-child'); | |
| if (lastPageNavEl) { | |
| const matches = /(?<=\?o=)\d+$/.exec(lastPageNavEl.href); | |
| if (matches && matches.length) { | |
| totalPages = (Number(matches[0]) + 50) / 50; | |
| } | |
| } | |
| const filteredPosts = await filterVideos((page) => { | |
| btnFilter.textContent = `Filtering Page ${page} / ${totalPages}...`; | |
| }, service, username, videoExtensions); | |
| console.log(error ? `[INFO] Filter complete but with an error: ${error}` : '[INFO] Filter completed successfully.'); | |
| const totalFiltered = filteredPosts.length; | |
| if (totalPages > 1) { | |
| document.querySelectorAll('.paginator > small').forEach(el => { | |
| el.innerHTML = `Showing ${totalFiltered} video posts`; | |
| }); | |
| document.querySelectorAll('menu').forEach(menu => menu.remove()); | |
| } | |
| btnFilter.textContent = `Copy URLs for ${totalFiltered} videos`; | |
| const postsContainerEl = document.querySelector('.card-list__items'); | |
| if (!postsContainerEl) { | |
| return; | |
| } | |
| if (posts.length) { | |
| postsContainerEl.innerHTML = ''; | |
| } | |
| for (const post of filteredPosts) { | |
| const isImage = (path) => /\.(jpg|jpeg|png)$/i.test(path); | |
| const hasAttachments = post.attachments && post.attachments.length; | |
| const attachementsDiv = hasAttachments ? `<div>${post.attachments.length} Attachments</div>` : '<div>No Attachments</div>'; | |
| const postHTML = ` | |
| <article | |
| class="post-card post-card--preview" | |
| data-id="${post.id}" | |
| data-service="${post.service}" | |
| data-user="${post.user}" | |
| > | |
| <a | |
| class="fancy-link fancy-link--kemono" | |
| href="/${post.service}/user/${post.user}/post/${post.id}" | |
| > | |
| <header class="post-card__header">${post.title}</header> | |
| <footer class="post-card__footer"> | |
| <div> | |
| <div> | |
| <time class="timestamp" datetime="${post.published}">${post.published}</time> | |
| ${attachementsDiv} | |
| </div> | |
| </div> | |
| </footer> | |
| </a> | |
| </article> | |
| `; | |
| posts.push({ ...post, html: postHTML }) | |
| } | |
| postsContainerEl.innerHTML = posts.map(post => post.html).join(''); | |
| }); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment