您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Makes GitHub job listing tables user-friendly by adding location-based filtering with customizable city dropdown - Perfect for browsing careers pages
// ==UserScript== // @name GitHub Location Filter // @namespace http://tampermonkey.net/ // @version 1.5 // @description Makes GitHub job listing tables user-friendly by adding location-based filtering with customizable city dropdown - Perfect for browsing careers pages // @author sacrosaunt // @match https://github.com/* // @grant GM_setValue // @grant GM_getValue // ==/UserScript== (function() { 'use strict'; // Default target cities let targetCities = ['remote']; // Track filtered state let isFiltered = false; let originalRows = new Map(); // Drag state variables let isDragging = false; let dragStarted = false; let dragOffset = { x: 0, y: 0 }; let currentPosition = { x: 0, y: 0 }; let ensureBoundsTimeoutId = null; let dragRectWidth = null; let dragRectHeight = null; // Storage functions function loadCitiesFromStorage() { try { const storedCities = GM_getValue('locationFilterCities', null); if (storedCities) { const cities = JSON.parse(storedCities); if (Array.isArray(cities) && cities.length > 0) { targetCities = cities; } } } catch (error) { console.warn('Failed to load cities from storage:', error); } } function saveCitiesToStorage() { try { GM_setValue('locationFilterCities', JSON.stringify(targetCities)); } catch (error) { console.warn('Failed to save cities to storage:', error); } } function loadFilterStateFromStorage() { try { const storedState = GM_getValue('locationFilterEnabled', false); return storedState; } catch (error) { console.warn('Failed to load filter state from storage:', error); return false; } } function saveFilterStateToStorage(enabled) { try { GM_setValue('locationFilterEnabled', enabled); } catch (error) { console.warn('Failed to save filter state to storage:', error); } } // Create and inject the dropdown interface function createDropdownInterface() { // Remove existing interface if present const existingInterface = document.getElementById('location-filter-interface'); if (existingInterface) { existingInterface.remove(); } // Create main container const container = document.createElement('div'); container.id = 'location-filter-interface'; container.innerHTML = ` <div class="filter-header" id="filter-header"> <span class="filter-title">Location Filter</span> <div class="header-controls"> <button class="toggle-filter-btn" id="toggle-filter-btn">OFF</button> </div> </div> <div class="filter-content collapsed" id="filter-content"> <div class="city-input-section"> <label for="city-input">Add City:</label> <div class="input-group"> <input type="text" id="city-input" placeholder="Enter city name" /> <button id="add-city-btn">Add</button> </div> </div> <div class="cities-section"> <label>Selected Cities:</label> <div class="cities-list" id="cities-list"></div> </div> </div> `; // Add styles with dark mode support const styles = ` #location-filter-interface { position: fixed; top: 100px; right: 20px; width: 240px; background: #1976d2; border: 2px solid #1976d2; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 10000; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; transition: none; overflow: hidden; scroll-behavior: auto; transform-origin: right top; user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; } #location-filter-interface.dragging { transition: none; box-shadow: 0 8px 20px rgba(0,0,0,0.3); } #location-filter-interface.repositioning { transition: left 0.3s ease, top 0.3s ease; } /* Ensure dragging always disables transitions, even if repositioning is active */ #location-filter-interface.repositioning.dragging { transition: none; } #location-filter-interface:not(.collapsed) { width: 240px; } #location-filter-interface.active { background: #4caf50; border-color: #4caf50; } .filter-header { background: #1976d2; color: white; padding: 8px 12px; display: flex; justify-content: space-between; align-items: center; cursor: move; transition: background-color 0.3s ease; gap: 4px; margin: -2px -2px 0 -2px; border-radius: 8px 8px 0 0; position: relative; } .filter-header:active { cursor: grabbing; } .filter-header.dragging { cursor: grabbing; background: #1565c0; } .filter-header.active { background: #4caf50; } .filter-title { font-weight: bold; font-size: 13px; } .header-controls { display: flex; align-items: center; gap: 8px; } .toggle-filter-btn { background: rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.3); color: white; cursor: pointer; font-size: 11px; padding: 3px 8px; border-radius: 12px; font-weight: 500; transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease; min-width: 35px; user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; } .toggle-filter-btn:hover { background: rgba(255,255,255,0.3); } .toggle-filter-btn.active { background: rgba(255,255,255,0.9); color: #4caf50; } .filter-content { padding: 0 15px; background: white; border-radius: 0 0 6px 6px; max-height: 0; opacity: 0; overflow: hidden; transition: max-height 0.3s ease, opacity 0.2s ease, padding 0.3s ease; } .filter-content:not(.collapsed) { padding: 15px; max-height: 1000px; /* sufficient for content */ opacity: 1; } .city-input-section { margin-bottom: 15px; } .city-input-section label { display: block; margin-bottom: 5px; font-weight: 500; color: #333; } .input-group { display: flex; gap: 5px; width: 100%; } #city-input { flex: 1 1 auto; width: auto; min-width: 0; padding: 6px 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px; background: white; color: #333; } #add-city-btn { background: #4caf50; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 500; white-space: nowrap; } #add-city-btn:hover { background: #45a049; } .cities-section { margin-bottom: 15px; } .cities-section label { display: block; margin-bottom: 8px; font-weight: 500; color: #333; } .cities-list { display: flex; flex-wrap: wrap; gap: 5px; min-height: 30px; padding: 8px; border: 1px solid #eee; border-radius: 4px; background: #f9f9f9; } .city-tag { background: #e3f2fd; color: #1976d2; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500; display: flex; align-items: center; gap: 5px; } .city-remove { background: none; border: none; color: #1976d2; cursor: pointer; font-size: 14px; padding: 0; width: 16px; height: 16px; border-radius: 50%; display: flex; align-items: center; justify-content: center; } .city-remove:hover { background: #1976d2; color: white; } .cities-list:empty::after { content: "No cities added"; color: #999; font-style: italic; font-size: 12px; } /* Responsive width adjustments */ @media (max-width: 768px) { #location-filter-interface { width: 220px; } #location-filter-interface:not(.collapsed) { width: 220px; } #city-input { flex: 1 1 auto; width: auto; min-width: 0; } } @media (max-width: 480px) { #location-filter-interface { width: 200px; } #location-filter-interface:not(.collapsed) { width: 200px; } #city-input { flex: 1 1 auto; width: auto; min-width: 0; } } /* Dark mode styles */ @media (prefers-color-scheme: dark) { #location-filter-interface { box-shadow: 0 4px 12px rgba(0,0,0,0.4); } .filter-content { background: #2d2d2d; color: #e0e0e0; } .city-input-section label, .cities-section label { color: #e0e0e0; } #city-input { background: #404040; border: 1px solid #555; color: #e0e0e0; } #city-input::placeholder { color: #999; } #city-input:focus { outline: none; border-color: #1976d2; box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.2); } .cities-list { background: #404040; border: 1px solid #555; } .city-tag { background: #1e3a5f; color: #64b5f6; } .city-remove { color: #64b5f6; } .city-remove:hover { background: #64b5f6; color: #1e3a5f; } .cities-list:empty::after { color: #888; } } `; // Inject styles const styleSheet = document.createElement('style'); styleSheet.textContent = styles; document.head.appendChild(styleSheet); // Add to page document.body.appendChild(container); // Set up event listeners setupEventListeners(); // Initialize cities display updateCitiesDisplay(); // Start in collapsed state container.classList.add('collapsed'); } // Drag functionality function constrainPosition(x, y, forceCurrentWidth = false) { const container = document.getElementById('location-filter-interface'); if (!container) return { x, y }; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; // Get current dimensions, accounting for expanded/collapsed state let width, height; if (forceCurrentWidth) { if (dragRectWidth !== null && dragRectHeight !== null) { width = dragRectWidth; height = dragRectHeight; } else { const rect = container.getBoundingClientRect(); width = rect.width; height = rect.height; } } else { // Use fixed width by viewport to keep width constant across states if (viewportWidth <= 480) { width = 200; } else if (viewportWidth <= 768) { width = 220; } else { width = 240; } const rect = container.getBoundingClientRect(); height = rect.height; } // Constrain to 15px from edges const minX = 15; const maxX = viewportWidth - width - 15; const minY = 15; const maxY = viewportHeight - height - 15; return { x: Math.max(minX, Math.min(maxX, x)), y: Math.max(minY, Math.min(maxY, y)) }; } function updatePosition(x, y, animate = false) { const container = document.getElementById('location-filter-interface'); if (!container) return; const constrained = constrainPosition(x, y); currentPosition.x = constrained.x; currentPosition.y = constrained.y; // Add animation class if needed if (animate) { container.classList.add('repositioning'); // Remove animation class after transition setTimeout(() => { container.classList.remove('repositioning'); }, 400); } // Use left positioning for manual dragging container.style.left = constrained.x + 'px'; container.style.top = constrained.y + 'px'; container.style.right = 'auto'; // Remove right positioning } function animatedRepositionToConstraints(x, y) { if (isDragging) { updatePosition(x, y, false); return; } updatePosition(x, y, true); } function setInitialPosition() { const container = document.getElementById('location-filter-interface'); if (!container) return; // Calculate initial position for right-aligned interface const viewportWidth = window.innerWidth; const rect = container.getBoundingClientRect(); // Find the GitHub header const header = document.querySelector('.AppHeader'); let topOffset = 120; // Default fallback if (header) { topOffset = header.offsetHeight + 20; // 20px padding below header } // Position in top-right corner with responsive margins let rightMargin = 20; if (viewportWidth <= 768) { rightMargin = 15; } if (viewportWidth <= 480) { rightMargin = 10; } // Keep using CSS right positioning for initial state container.style.right = rightMargin + 'px'; container.style.top = Math.max(topOffset, 15) + 'px'; container.style.left = 'auto'; // Don't set currentPosition yet - let it remain 0,0 for initial state } function setupDragFunctionality() { const header = document.getElementById('filter-header'); const container = document.getElementById('location-filter-interface'); if (!header || !container) return; header.addEventListener('mousedown', (e) => { // Only start drag on left mouse button if (e.button !== 0) return; // Don't drag if clicking on the toggle button if (e.target.id === 'toggle-filter-btn' || e.target.closest('.toggle-filter-btn')) { return; } isDragging = true; dragStarted = false; const rect = container.getBoundingClientRect(); dragOffset.x = e.clientX - rect.left; dragOffset.y = e.clientY - rect.top; dragRectWidth = rect.width; dragRectHeight = rect.height; // Set initial position if not already set if (currentPosition.x === 0 && currentPosition.y === 0) { currentPosition.x = rect.left; currentPosition.y = rect.top; } header.classList.add('dragging'); container.classList.add('dragging'); container.classList.remove('repositioning'); if (ensureBoundsTimeoutId) { clearTimeout(ensureBoundsTimeoutId); ensureBoundsTimeoutId = null; } e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!isDragging) return; dragStarted = true; const newX = e.clientX - dragOffset.x; const newY = e.clientY - dragOffset.y; // Use current width during drag to ensure proper constraints const constrained = constrainPosition(newX, newY, true); currentPosition.x = constrained.x; currentPosition.y = constrained.y; const container = document.getElementById('location-filter-interface'); if (container) { container.style.left = constrained.x + 'px'; container.style.top = constrained.y + 'px'; container.style.right = 'auto'; } e.preventDefault(); }); document.addEventListener('mouseup', (e) => { if (!isDragging) return; isDragging = false; header.classList.remove('dragging'); container.classList.remove('dragging'); dragRectWidth = null; dragRectHeight = null; // Prevent click event if we actually dragged if (dragStarted) { setTimeout(() => { dragStarted = false; }, 10); } }); // Handle window resize to reposition if needed (throttled) window.addEventListener('resize', throttle(() => { if (isDragging) return; if (currentPosition.x !== 0 || currentPosition.y !== 0) { animatedRepositionToConstraints(currentPosition.x, currentPosition.y); } else { // If no manual position set, reposition automatically adjustFilterPosition(); } }, 100)); } function setupEventListeners() { // Toggle dropdown by clicking header const header = document.getElementById('filter-header'); const content = document.getElementById('filter-content'); const container = document.getElementById('location-filter-interface'); // Setup drag functionality setupDragFunctionality(); header.addEventListener('click', (e) => { // Don't toggle if clicking the toggle filter button or if we just finished dragging if (e.target.id === 'toggle-filter-btn' || dragStarted) { return; } content.classList.toggle('collapsed'); container.classList.toggle('collapsed', content.classList.contains('collapsed')); // Ensure interface stays within bounds after expansion/collapse if (ensureBoundsTimeoutId) { clearTimeout(ensureBoundsTimeoutId); } ensureBoundsTimeoutId = setTimeout(() => { ensureBoundsTimeoutId = null; ensureWithinBounds(); }, 0); // Run on next tick so layout updates are applied, no extra delay }); // After expand/collapse animation completes, re-ensure bounds in case height changed further content.addEventListener('transitionend', (e) => { if (e.target !== content) return; if (e.propertyName === 'max-height' || e.propertyName === 'opacity' || e.propertyName === 'padding') { ensureWithinBounds(); } }); // Toggle filter on/off const toggleFilterBtn = document.getElementById('toggle-filter-btn'); toggleFilterBtn.addEventListener('click', (e) => { e.stopPropagation(); if (isFiltered) { clearFilter(); } else { applyFilter(true); // Show alert if no cities when manually toggling on } }); // Add city const addBtn = document.getElementById('add-city-btn'); const cityInput = document.getElementById('city-input'); addBtn.addEventListener('click', addCity); cityInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') { addCity(); } }); } function addCity() { const cityInput = document.getElementById('city-input'); const cityName = cityInput.value.trim().toLowerCase(); if (cityName && !targetCities.includes(cityName)) { targetCities.push(cityName); cityInput.value = ''; saveCitiesToStorage(); updateCitiesDisplay(); // Automatically apply filter if there are tables on the page const tables = document.querySelectorAll('table'); if (tables.length > 0) { applyFilter(); } } } function removeCity(cityName) { targetCities = targetCities.filter(city => city !== cityName); saveCitiesToStorage(); updateCitiesDisplay(); // If filter is active, reapply it if (isFiltered) { applyFilter(); } } function updateCitiesDisplay() { const citiesList = document.getElementById('cities-list'); citiesList.innerHTML = ''; targetCities.forEach(city => { const cityTag = document.createElement('div'); cityTag.className = 'city-tag'; cityTag.innerHTML = ` ${city} <button class="city-remove" title="Remove city">×</button> `; // Add event listener to the remove button const removeBtn = cityTag.querySelector('.city-remove'); removeBtn.addEventListener('click', () => removeCity(city)); citiesList.appendChild(cityTag); }); } function findLocationColumnIndex(table) { const headerRow = table.querySelector('thead tr, tr:first-child'); if (!headerRow) return -1; const headers = headerRow.querySelectorAll('th, td'); for (let i = 0; i < headers.length; i++) { const headerText = headers[i].textContent.toLowerCase().trim(); if (headerText.includes('location') || headerText.includes('city') || headerText.includes('office')) { return i; } } return -1; } function containsTargetCity(locationCell) { // Get all text content including from details/summary elements let text = ''; // If it's a DOM element, extract all text including hidden content if (locationCell && locationCell.textContent !== undefined) { text = locationCell.textContent; } else { // Fallback for string input text = locationCell || ''; } // Clean up the text - remove extra whitespace and normalize const textLower = text.toLowerCase().replace(/\s+/g, ' ').trim(); return targetCities.some(city => { const cityLower = city.toLowerCase().trim(); // Try multiple matching strategies // 1. Exact word boundary match const wordBoundaryRegex = new RegExp(`\\b${cityLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i'); if (wordBoundaryRegex.test(textLower)) { return true; } // 2. Simple substring match (for cases where word boundaries might not work) if (textLower.includes(cityLower)) { return true; } // 3. Match with common separators (comma, space, etc.) const separatorRegex = new RegExp(`(^|[,\\s])${cityLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([,\\s]|$)`, 'i'); if (separatorRegex.test(textLower)) { return true; } return false; }); } function filterTable(table) { const locationColumnIndex = findLocationColumnIndex(table); if (locationColumnIndex === -1) return; const tbody = table.querySelector('tbody'); const rows = tbody ? tbody.querySelectorAll('tr') : table.querySelectorAll('tr:not(:first-child)'); // Store original rows if not already stored if (!originalRows.has(table)) { originalRows.set(table, Array.from(rows).map(row => ({ element: row, display: row.style.display }))); } rows.forEach(row => { const cells = row.querySelectorAll('td, th'); if (cells.length > locationColumnIndex) { const locationCell = cells[locationColumnIndex]; if (!containsTargetCity(locationCell)) { row.style.display = 'none'; row.classList.add('location-filtered'); } else { row.style.display = ''; row.classList.remove('location-filtered'); } } }); } function restoreTable(table) { const storedRows = originalRows.get(table); if (!storedRows) return; storedRows.forEach(rowData => { rowData.element.style.display = rowData.display; rowData.element.classList.remove('location-filtered'); }); } function applyFilter(showAlertIfNoCities = false) { if (targetCities.length === 0) { if (showAlertIfNoCities) { alert('Please add at least one city to filter by.'); } clearFilter(); return; } const tables = document.querySelectorAll('table'); tables.forEach(table => filterTable(table)); isFiltered = true; saveFilterStateToStorage(true); updateFilterStatus(); } function clearFilter() { const tables = document.querySelectorAll('table'); tables.forEach(table => restoreTable(table)); isFiltered = false; saveFilterStateToStorage(false); updateFilterStatus(); } function updateFilterStatus() { const header = document.getElementById('filter-header'); const toggleBtn = document.getElementById('toggle-filter-btn'); const container = document.getElementById('location-filter-interface'); if (isFiltered) { header.classList.add('active'); toggleBtn.classList.add('active'); container.classList.add('active'); toggleBtn.textContent = 'ON'; } else { header.classList.remove('active'); toggleBtn.classList.remove('active'); container.classList.remove('active'); toggleBtn.textContent = 'OFF'; } } function applyFilterToNewTables() { if (isFiltered) { const tables = document.querySelectorAll('table'); tables.forEach(table => { if (!originalRows.has(table)) { filterTable(table); } }); } } // Observer for dynamically added tables const observer = new MutationObserver((mutations) => { let hasNewTables = false; mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { if (node.tagName === 'TABLE' || node.querySelector('table')) { hasNewTables = true; } } }); }); if (hasNewTables) { // Check if interface should be shown when new tables are added const existingInterface = document.getElementById('location-filter-interface'); if (!existingInterface && hasLocationColumns()) { // Location columns found and no interface exists, create it loadCitiesFromStorage(); const savedFilterState = loadFilterStateFromStorage(); createDropdownInterface(); if (savedFilterState && targetCities.length > 0) { setTimeout(() => { isFiltered = savedFilterState; if (isFiltered) { const tables = document.querySelectorAll('table'); tables.forEach(table => filterTable(table)); } updateFilterStatus(); }, 100); } } applyFilterToNewTables(); } }); // Check if any tables have location columns function hasLocationColumns() { const tables = document.querySelectorAll('table'); for (let table of tables) { if (findLocationColumnIndex(table) !== -1) { return true; } } return false; } // Throttle function for performance function throttle(func, limit) { let inThrottle; return function() { const args = arguments; const context = this; if (!inThrottle) { func.apply(context, args); inThrottle = true; setTimeout(() => inThrottle = false, limit); } } } // Function to adjust filter position based on header height and responsiveness function adjustFilterPosition() { const filterInterface = document.getElementById('location-filter-interface'); if (!filterInterface) return; // If the element has been manually positioned by dragging, reapply constraints if (currentPosition.x !== 0 || currentPosition.y !== 0) { updatePosition(currentPosition.x, currentPosition.y); return; } // For initial positioning, use CSS right positioning setInitialPosition(); } // Function to ensure interface stays within bounds when expanding/collapsing function ensureWithinBounds() { const filterInterface = document.getElementById('location-filter-interface'); if (!filterInterface) return; if (isDragging) return; // If already manually positioned (dragged), reapply constraints with current state and animation if (currentPosition.x !== 0 || currentPosition.y !== 0) { animatedRepositionToConstraints(currentPosition.x, currentPosition.y); return; } // For right-positioned interface, check if it goes off screen when expanded const rect = filterInterface.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; let targetX = rect.left; let targetY = rect.top; let needsReposition = false; // Horizontal bounds if (rect.left < 15) { targetX = 15; needsReposition = true; } else if (rect.right > viewportWidth - 15) { targetX = viewportWidth - rect.width - 15; needsReposition = true; } // Vertical bounds if (rect.top < 15) { targetY = 15; needsReposition = true; } else if (rect.bottom > viewportHeight - 15) { targetY = Math.max(15, viewportHeight - rect.height - 15); needsReposition = true; } if (needsReposition) { // Convert to left/top baseline at current visual position to allow smooth transition filterInterface.style.left = rect.left + 'px'; filterInterface.style.top = rect.top + 'px'; filterInterface.style.right = 'auto'; animatedRepositionToConstraints(targetX, targetY); } } // Initialize when DOM is ready function initialize() { // Check if there are any tables with location columns if (!hasLocationColumns()) { // No location columns found, don't display the interface return; } // Load cities from storage first loadCitiesFromStorage(); // Load filter state from storage const savedFilterState = loadFilterStateFromStorage(); createDropdownInterface(); // Set initial position after creating interface setTimeout(setInitialPosition, 100); // Restore filter state if it was previously enabled if (savedFilterState && targetCities.length > 0) { // Wait a bit for tables to load, then apply filter setTimeout(() => { isFiltered = savedFilterState; if (isFiltered) { const tables = document.querySelectorAll('table'); tables.forEach(table => filterTable(table)); } updateFilterStatus(); }, 100); } // Start observing for new tables observer.observe(document.body, { childList: true, subtree: true }); // Listen for window resize to adjust position (throttled) window.addEventListener('resize', throttle(() => { if (isDragging) return; if (currentPosition.x !== 0 || currentPosition.y !== 0) { animatedRepositionToConstraints(currentPosition.x, currentPosition.y); } else { adjustFilterPosition(); } }, 100)); // Listen for scroll to ensure filter stays visible (throttled) window.addEventListener('scroll', throttle(adjustFilterPosition, 100)); // Listen for navigation changes (GitHub uses pjax) document.addEventListener('pjax:end', adjustFilterPosition); // Also adjust position periodically to catch any dynamic changes setInterval(adjustFilterPosition, 2000); } // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initialize); } else { initialize(); } })();