GitHub Advanced Search Builder

Advanced filter modal for GitHub search with OR/AND/NOT logic and native look.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         GitHub Advanced Search Builder
// @namespace    https://github.com/quantavil/userscript
// @version      1.8
// @description  Advanced filter modal for GitHub search with OR/AND/NOT logic and native look.
// @author       quantavil
// @match        https://github.com/*
// @license      MIT
// @icon         https://github.githubassets.com/favicons/favicon.svg
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    // Config
    const TRIGGER_ID = 'gh-adv-search-btn';
    const MODAL_ID = 'gh-adv-search-modal';

    // Icons
    const FILTER_ICON = `<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" fill="currentColor"><path d="M.75 3h14.5a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1 0-1.5ZM3 7.75A.75.75 0 0 1 3.75 7h8.5a.75.75 0 0 1 0 1.5h-8.5A.75.75 0 0 1 3 7.75Zm3 4.75a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"></path></svg>`;

    function createUI() {
        if (document.getElementById(TRIGGER_ID)) return;

        // Find the global search input container
        const headerSearch = document.querySelector('.header-search-wrapper, .AppHeader-search');
        if (!headerSearch) return;

        // Create Trigger Button
        const btn = document.createElement('button');
        btn.id = TRIGGER_ID;
        btn.className = 'btn btn-sm ml-2';
        btn.style.display = 'inline-flex';
        btn.style.alignItems = 'center';
        btn.style.gap = '4px';
        btn.innerHTML = `${FILTER_ICON} Filter`;
        btn.title = "Advanced Search Builder (Ctrl+Shift+F)";

        // Insert Button
        if (headerSearch.parentNode) {
            headerSearch.parentNode.insertBefore(btn, headerSearch.nextSibling);
        }

        // Create Modal (Hidden by default)
        const modal = document.createElement('div');
        modal.id = MODAL_ID;
        modal.style.cssText = `
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            width: 95%;
            max-width: 500px;
            max-height: 90vh;
            overflow-y: auto;
            z-index: 9999;
            background-color: var(--bgColor-default, #fff);
            border: 1px solid var(--borderColor-default, #d0d7de);
            border-radius: 6px;
            box-shadow: var(--shadow-large, 0 8px 24px rgba(140,149,159,0.2));
            display: none;
            padding: 16px;
            font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;
            color: var(--fgColor-default, #24292f);
            box-sizing: border-box;
        `;

        // Add responsive grid style
        const style = document.createElement('style');
        style.innerHTML = `
            #${MODAL_ID} .responsive-grid {
                display: grid;
                grid-template-columns: 1fr 1fr;
                gap: 10px;
            }
            @media (max-width: 480px) {
                #${MODAL_ID} .responsive-grid {
                    grid-template-columns: 1fr;
                }
                #${MODAL_ID} {
                    top: 10px;
                    transform: translateX(-50%);
                }
            }
        `;
        document.head.appendChild(style);

        modal.innerHTML = `
            <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
                <h3 style="margin:0; font-size:16px;">Advanced Search</h3>
                <button id="${MODAL_ID}-close" class="btn-octicon" type="button">
                   <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"></path></svg>
                </button>
            </div>

            <form id="${MODAL_ID}-form">
                <div style="display:grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom:12px;">
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600; margin-bottom:4px;">Search Type</label>
                        <select id="sel-type" class="form-select select-sm" style="width:100%;">
                            <option value="repositories">Repositories</option>
                            <option value="code">Code</option>
                            <option value="issues">Issues</option>
                            <option value="pullrequests">Pull Requests</option>
                            <option value="discussions">Discussions</option>
                            <option value="users">Users</option>
                        </select>
                    </div>
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600; margin-bottom:4px;">Sort By</label>
                        <select id="sel-sort" class="form-select select-sm" style="width:100%;">
                            <option value="">Best Match</option>
                            <option value="stars">Most Stars</option>
                            <option value="forks">Most Forks</option>
                            <option value="updated">Recently Updated</option>
                        </select>
                    </div>
                </div>

                <div class="form-group" style="margin-bottom:12px;">
                    <label style="display:block; font-size:12px; font-weight:600; margin-bottom:4px;">Must contain ALL (AND)</label>
                    <input type="text" id="inp-and" class="form-control input-sm input-block" placeholder="rust async tokio" style="width:100%;">
                </div>

                <div class="form-group" style="margin-bottom:12px;">
                    <label style="display:block; font-size:12px; font-weight:600; margin-bottom:4px;">Must contain ONE OF (OR)</label>
                    <input type="text" id="inp-or" class="form-control input-sm input-block" placeholder="api, library" style="width:100%;">
                </div>

                <div class="form-group" style="margin-bottom:12px;">
                    <label style="display:block; font-size:12px; font-weight:600; margin-bottom:4px; color:var(--fgColor-danger, #cf222e);">Exclude (NOT)</label>
                    <input type="text" id="inp-not" class="form-control input-sm input-block" placeholder="deprecated" style="width:100%;">
                </div>

                <hr style="border:0; border-top:1px solid var(--borderColor-muted); margin: 12px 0;">

                <div class="responsive-grid">
                     <div>
                        <label style="display:block; font-size:12px; font-weight:600;">Owner/User</label>
                        <input type="text" id="inp-user" class="form-control input-sm" placeholder="e.g. facebook" style="width:100%;">
                    </div>
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600;">Repository</label>
                        <input type="text" id="inp-repo" class="form-control input-sm" placeholder="e.g. react" style="width:100%;">
                    </div>
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600;">Language</label>
                        <input type="text" id="inp-lang" class="form-control input-sm" placeholder="e.g. python" style="width:100%;">
                    </div>
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600;">Extension</label>
                        <input type="text" id="inp-ext" class="form-control input-sm" placeholder="e.g. md" style="width:100%;">
                    </div>
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600;">Created Date</label>
                        <input type="text" id="inp-created" class="form-control input-sm" placeholder="e.g. >2023-01-01" style="width:100%;">
                    </div>
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600;">Pushed Date</label>
                        <input type="text" id="inp-pushed" class="form-control input-sm" placeholder="e.g. >2024-01-01" style="width:100%;">
                    </div>
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600;">Stars (>=)</label>
                        <input type="number" id="inp-stars" class="form-control input-sm" style="width:100%;">
                    </div>
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600;">Forks (>=)</label>
                        <input type="number" id="inp-forks" class="form-control input-sm" style="width:100%;">
                    </div>
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600;">Size (KB)</label>
                        <input type="text" id="inp-size" class="form-control input-sm" placeholder="e.g. >1000" style="width:100%;">
                    </div>
                    <div>
                        <label style="display:block; font-size:12px; font-weight:600;">Topics</label>
                        <input type="text" id="inp-topics" class="form-control input-sm" placeholder="e.g. machine-learning" style="width:100%;">
                    </div>
                </div>

                 <div style="margin-top:12px;">
                    <label style="display:block; font-size:12px; font-weight:600;">In Path</label>
                    <input type="text" id="inp-path" class="form-control input-sm" placeholder="src/main" style="width:100%;">
                </div>

                <div style="margin-top:16px; display:flex; justify-content:space-between; align-items:center;">
                    <button type="button" id="${MODAL_ID}-clear" class="btn btn-sm btn-muted">Clear all</button>
                    <button type="submit" class="btn btn-primary btn-sm">Search</button>
                </div>
            </form>

        `;

        document.body.appendChild(modal);

        // Events
        btn.addEventListener('click', (e) => {
            e.preventDefault();
            const isOpening = modal.style.display !== 'block';
            modal.style.display = isOpening ? 'block' : 'none';
            if (isOpening) {
                populateFieldsFromURL();
                document.getElementById('inp-and').focus();
            }
        });

        document.getElementById(`${MODAL_ID}-close`).addEventListener('click', () => {
            modal.style.display = 'none';
        });

        document.getElementById(`${MODAL_ID}-form`).addEventListener('submit', (e) => {
            e.preventDefault();
            executeSearch();
        });

        document.getElementById(`${MODAL_ID}-clear`).addEventListener('click', () => {
            const ids = ['inp-and', 'inp-or', 'inp-not', 'inp-user', 'inp-repo', 'inp-lang', 'inp-ext', 'inp-stars', 'inp-forks', 'inp-path', 'inp-topics', 'inp-created', 'inp-pushed', 'inp-size', 'sel-type', 'sel-sort'];
            ids.forEach(id => {
                const el = document.getElementById(id);
                if (el) {
                    if (el.tagName === 'SELECT') el.selectedIndex = 0;
                    else el.value = '';
                }
            });
        });

        // Close on escape
        document.addEventListener('keydown', (e) => {
            if (e.key === 'Escape') modal.style.display = 'none';
            if (e.ctrlKey && e.shiftKey && e.key === 'F') {
                modal.style.display = 'block';
                populateFieldsFromURL();
                document.getElementById('inp-and').focus();
            }
        });
    }

    function populateFieldsFromURL() {
        const params = new URLSearchParams(window.location.search);
        const query = params.get('q');
        const type = params.get('type');
        const sort = params.get('s');

        // Reset fields
        const allIds = ['inp-and', 'inp-or', 'inp-not', 'inp-user', 'inp-repo', 'inp-lang', 'inp-ext', 'inp-stars', 'inp-forks', 'inp-path', 'inp-topics', 'inp-created', 'inp-pushed', 'inp-size', 'sel-type', 'sel-sort'];
        allIds.forEach(id => {
            const el = document.getElementById(id);
            if (el) {
                if (el.tagName === 'SELECT') el.selectedIndex = 0;
                else el.value = '';
            }
        });

        if (type) document.getElementById('sel-type').value = type;
        if (sort) document.getElementById('sel-sort').value = sort;

        if (!query) return;

        let remainingQuery = query;

        // 1. Extract metadata filters
        const metadataMap = {
            'user': 'inp-user',
            'repo': 'inp-repo',
            'language': 'inp-lang',
            'extension': 'inp-ext',
            'stars': 'inp-stars',
            'forks': 'inp-forks',
            'path': 'inp-path',
            'topic': 'inp-topics',
            'created': 'inp-created',
            'pushed': 'inp-pushed',
            'size': 'inp-size'
        };

        for (const [key, id] of Object.entries(metadataMap)) {
            const regex = new RegExp(`${key}:(\\S+)`, 'i');
            const match = remainingQuery.match(regex);
            if (match) {
                let val = match[1];
                if (key === 'stars' || key === 'forks') {
                    val = val.replace('>=', '');
                }
                document.getElementById(id).value = val;
                remainingQuery = remainingQuery.replace(match[0], '');
            }
        }

        // 2. Extract OR groups: (A OR B OR C)
        const orMatch = remainingQuery.match(/\(([^)]+ OR [^)]+)\)/i);
        if (orMatch) {
            const terms = orMatch[1].split(/\s+OR\s+/i);
            document.getElementById('inp-or').value = terms.join(', ');
            remainingQuery = remainingQuery.replace(orMatch[0], '');
        }

        // 3. Extract NOT terms: -term
        const notTerms = [];
        remainingQuery = remainingQuery.replace(/-(\S+)/g, (match, term) => {
            notTerms.push(term);
            return '';
        });
        if (notTerms.length > 0) {
            document.getElementById('inp-not').value = notTerms.join(', ');
        }

        // 4. Remaining goes to AND
        const andVal = remainingQuery.trim().replace(/\s+/g, ' ');
        if (andVal) {
            document.getElementById('inp-and').value = andVal;
        }
    }

    function executeSearch() {
        let queryParts = [];
        const getVal = (id) => document.getElementById(id).value.trim();

        // Helper to split by space, comma, or semicolon
        const parseList = (val) => val.split(/[\s,;]+/).filter(t => t.length > 0);

        // 1. Handle AND
        const andVal = getVal('inp-and');
        if (andVal) queryParts.push(andVal);

        // 2. Handle OR
        const orVal = getVal('inp-or');
        if (orVal) {
            const terms = parseList(orVal);
            if (terms.length > 1) queryParts.push(`(${terms.join(' OR ')})`);
            else if (terms.length === 1) queryParts.push(terms[0]);
        }

        // 3. Handle NOT
        const notVal = getVal('inp-not');
        if (notVal) {
            const terms = parseList(notVal);
            terms.forEach(t => queryParts.push(`-${t}`));
        }

        // 4. Metadata
        const metadata = {
            'user': 'inp-user',
            'repo': 'inp-repo',
            'language': 'inp-lang',
            'extension': 'inp-ext',
            'stars': 'inp-stars',
            'forks': 'inp-forks',
            'path': 'inp-path',
            'topic': 'inp-topics',
            'created': 'inp-created',
            'pushed': 'inp-pushed',
            'size': 'inp-size'
        };

        for (const [key, id] of Object.entries(metadata)) {
            let val = getVal(id);
            if (val) {
                // Auto-add >= to stars/forks if missing and only a number
                if ((key === 'stars' || key === 'forks') && !val.match(/[<>=]/)) val = `>=${val}`;
                queryParts.push(`${key}:${val}`);
            }
        }

        const type = document.getElementById('sel-type').value;
        const sort = document.getElementById('sel-sort').value;

        // Construct final URL
        const finalQuery = encodeURIComponent(queryParts.join(' '));
        let url = `https://github.com/search?q=${finalQuery}&type=${type}`;
        if (sort) url += `&s=${sort}&o=desc`;

        window.location.href = url;
    }

    // Init and Observe for Turbo/PJAX
    createUI();
    const observer = new MutationObserver(() => {
        if (!document.getElementById(TRIGGER_ID)) createUI();
    });
    observer.observe(document.body, { childList: true, subtree: true });

})();