crwflags tool

tool for crwflags.com

目前為 2025-08-11 提交的版本,檢視 最新版本

// ==UserScript==
// @name         crwflags tool
// @license MIT
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  tool for crwflags.com
// @match        *://www.crwflags.com/fotw/flags/*
// @grant        GM_addStyle
// @require      https://unpkg.com/[email protected]/dist/fuse.min.js
// @run-at       document-idle
// ==/UserScript==

(async function () {
  'use strict';

  /* ========== CONFIG ========== */
  const BASE = 'https://www.crwflags.com/fotw/flags/';
  const DB_NAME = 'crwflags_tool';
  const STORE_NAME = 'flags';
  const FETCH_TIMEOUT_MS = 12_000;
  const KEYWORD_THROTTLE_MS = 90;
  const FAV_KEY = 'fotw_favs_v6';
  const LONG_NAME_MIN_LENGTH = 40;

  /* ========== UI ========== */
  GM_addStyle(`
    #fotwPanel{ position:fixed; top:48px; right:12px; width:360px; max-height:82vh; overflow-y:auto; background:#003399; padding:14px; border-radius:10px; font-family:Arial,sans-serif; color:#fff; box-shadow:0 0 18px rgba(0,0,0,0.7); z-index:2147483647; }
    #fotwHeader{ font-size:18px; font-weight:700; margin-bottom:10px; text-align:center; user-select:none; }
    #fotwSearchInput, #fotwCategoryFilter{ width:100%; padding:8px; font-size:14px; border-radius:6px; border:none; margin-bottom:8px; box-sizing:border-box; }
    #fotwButtonsRow{ display:grid; grid-template-columns:repeat(2,1fr); gap:6px; justify-items:stretch; margin-bottom:8px; }
    #fotwButtonsRow button{ background:#0055cc; border:none; border-radius:6px; padding:8px 6px; color:#fff; font-size:13px; cursor:pointer; }
    #fotwButtonsRow button:hover{ background:#0077ff; }
    #fotwSearchResults{ background:#fff; color:#000; max-height:260px; overflow-y:auto; border-radius:6px; display:none; font-size:13px; margin-bottom:8px; }
    #fotwSearchResults div{ padding:8px 10px; border-bottom:1px solid #ddd; cursor:pointer; }
    #fotwSearchResults div:hover{ background:#eee; }
    #fotwFlagInfo{ background:#002266; border-radius:8px; padding:10px; font-size:13px; min-height:56px; line-height:1.3em; word-break:break-word; }
    button[disabled]{ opacity:0.55; cursor:not-allowed; }
  `);

  const panel = document.createElement('div');
  panel.id = 'fotwPanel';
  panel.innerHTML = `
    <div id="fotwHeader">⚑ crwflags tool</div>
    <input id="fotwSearchInput" type="search" placeholder="Search flags..." disabled>
    <select id="fotwCategoryFilter" disabled>
      <option value="all">All Categories</option>
      <option value="country">Countries</option>
      <option value="historical">Historical</option>
      <option value="regional">Regional</option>
      <option value="maritime">Maritime</option>
      <option value="obscure">Obscure</option>
    </select>
    <div id="fotwButtonsRow">
      <button id="btnRandom" disabled>🎲 Random</button>
      <button id="btnRandomLongName" disabled>📝 Long Name</button>
      <button id="btnRandomHistorical" disabled>🏛 Historical</button>
      <button id="btnRandomObscure" disabled>🏴 Obscure</button>
      <button id="btnPrev" disabled>⬅ Prev</button>
      <button id="btnNext" disabled>Next ➡</button>
      <button id="btnFavToggle" disabled>⭐ Fav</button>
      <button id="btnViewFavs" disabled>📂 View Favs</button>
      <button id="btnRandomFav" disabled>🎯 Random Fav</button>
      <button id="btnRebuild" disabled>🔄 Rebuild Full DB</button>
    </div>
    <div id="fotwStatus" style="font-size:13px;min-height:18px;margin-bottom:6px;">Initializing...</div>
    <div id="fotwSearchResults"></div>
    <div id="fotwFlagInfo"></div>
  `;
  document.body.appendChild(panel);

  const statusEl = document.getElementById('fotwStatus');
  const searchEl = document.getElementById('fotwSearchInput');
  const resultsEl = document.getElementById('fotwSearchResults');
  const infoEl = document.getElementById('fotwFlagInfo');
  const categoryFilterEl = document.getElementById('fotwCategoryFilter');

  const btnRandom = document.getElementById('btnRandom');
  const btnRandomLongName = document.getElementById('btnRandomLongName');
  const btnRandomHistorical = document.getElementById('btnRandomHistorical');
  const btnRandomObscure = document.getElementById('btnRandomObscure');
  const btnPrev = document.getElementById('btnPrev');
  const btnNext = document.getElementById('btnNext');
  const btnFavToggle = document.getElementById('btnFavToggle');
  const btnViewFavs = document.getElementById('btnViewFavs');
  const btnRandomFav = document.getElementById('btnRandomFav');
  const btnRebuild = document.getElementById('btnRebuild');

  /* ========== UTIL ========== */
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  function escapeHtml(s) { return (s||'').replace(/[&<>\"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
  async function fetchWithTimeout(url, timeoutMs = FETCH_TIMEOUT_MS) {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), timeoutMs);
    try {
      const res = await fetch(url, { signal: controller.signal });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return await res.text();
    } finally {
      clearTimeout(timeout);
    }
  }
  async function waitForFuse(timeoutMs = 5000) {
    const start = Date.now();
    while (typeof window.Fuse === 'undefined') {
      if (Date.now() - start > timeoutMs) throw new Error("Fuse.js failed to load");
      await sleep(50);
    }
  }

  /* ========== IndexedDB helpers ========== */
  function openDB() { return new Promise((resolve, reject) => { const r=indexedDB.open(DB_NAME,1); r.onupgradeneeded=e=>{const d=e.target.result;if(!d.objectStoreNames.contains(STORE_NAME))d.createObjectStore(STORE_NAME,{keyPath:'id'})}; r.onsuccess=()=>resolve(r.result); r.onerror=()=>reject(r.error) }); }
  function idbGetAll(db) { return new Promise((resolve, reject) => { try{const t=db.transaction(STORE_NAME,'readonly').objectStore(STORE_NAME).getAll();t.onsuccess=()=>resolve(t.result||[]); t.onerror=()=>reject(t.error)}catch(e){reject(e)} }); }
  function idbPutBatch(db, items) { return new Promise((resolve, reject) => { try{const t=db.transaction(STORE_NAME,'readwrite');const s=t.objectStore(STORE_NAME);items.forEach(it=>s.put(it));t.oncomplete=()=>resolve();t.onerror=()=>reject(t.error)}catch(e){reject(e)} }); }

  /* ========== STATE ========== */
  let fuse = null;
  let masterList = [];
  let dbInst = null;
  let rebuilding = false;
  let currentList = [];
  let currentIndex = -1;
  let currentItem = null;

  function safeFuseCreate() { try { fuse = new Fuse(masterList, { keys: ['name'], threshold: 0.3 }); } catch (e) { console.warn('Fuse creation failed', e); fuse = null; } }
  function enableUI() { document.querySelectorAll('#fotwPanel button, #fotwPanel input, #fotwPanel select').forEach(el => el.disabled = false); }
  function disableUI() { document.querySelectorAll('#fotwPanel button, #fotwPanel input, #fotwPanel select').forEach(el => el.disabled = true); }
  function setStatus(msg) { statusEl.textContent = msg; }

  /* ========== CORE ID HELPERS (DO NOT CHANGE) ========== */
  function filenameFromId(id) {
    if (!id) return '';
    let seg = id.split('/').pop();
    return seg.split('?')[0].split('#')[0].toLowerCase();
  }
  function isTwoLetterCountryId(id) {
    const fn = filenameFromId(id);
    return /^[a-z]{2}(?:\.html?)?$/.test(fn);
  }
  function isIndexPageId(id) {
    const fn = filenameFromId(id);
    return /^keyword[a-z]\.html?$/.test(fn);
  }

  /* ========== Rebuild masterList ========== */
  async function rebuildMasterList(db) {
    if (rebuilding) return;
    rebuilding = true;
    disableUI();
    btnRebuild.disabled = true;
    setStatus('Rebuilding DB...');
    const newMap = new Map();
    try {
      const letters = 'abcdefghijklmnopqrstuvwxyz';
      for (const ch of letters) {
        const path = `${BASE}keyword${ch}.html`;
        let html = '';
        try { html = await fetchWithTimeout(path); }
        catch (e) { console.warn(`letter ${ch} failed`, e); await sleep(KEYWORD_THROTTLE_MS); continue; }
        const doc = new DOMParser().parseFromString(html, 'text/html');
        for (const a of doc.querySelectorAll('a[href]')) {
            const href = a.getAttribute('href');
            if (!href) continue;
            const url = new URL(href, path).href;
            if (url.startsWith(BASE)) {
                const id = url.slice(BASE.length);
                const name = (a.textContent || '').trim() || id;
                if (!newMap.has(id)) newMap.set(id, { id, name, url, category: 'misc' });
            }
        }
        setStatus(`Rebuild: scanned letter '${ch.toUpperCase()}' (${newMap.size} items)`);
        await sleep(KEYWORD_THROTTLE_MS);
      }
      let allItems = Array.from(newMap.values());
      setStatus(`Categorizing ${allItems.length} links...`);
      await sleep(100);

      const obscureKeywords = /\b(proposed|fictional|micronation|unofficial|club|football|society|organization|personal|local custom|movement|variant|political|party|separatist|ethnic|company|house flag|aspirant|cultural|sports|association|UFE|unidentified)\b/i;

      for (const item of allItems) {
        if (isTwoLetterCountryId(item.id)) { item.category = 'country'; continue; }
        if (isIndexPageId(item.id)) { continue; } // Keep as misc, will be filtered out
        const name = item.name.toLowerCase();
        if (/\b(historical|history|former|formerly|obsolete)\b/i.test(name)) item.category = 'historical';
        else if (/\b(ensign|naval|jack|pennant|burgee|yacht|ship|maritime|merchant)\b/i.test(name)) item.category = 'maritime';
        else if (/\b(region|province|municipal|city|county|prefecture|oblast|krai|state flag|provincial)\b/i.test(name)) item.category = 'regional';
        else if (obscureKeywords.test(name)) item.category = 'obscure';
        else if (item.name.split(/\s+/).length <= 3) item.category = 'regional';
        else {
            // --- THIS IS THE KEY CHANGE ---
            // If it's none of the above, it's obscure by default.
            item.category = 'obscure';
        }
      }
      masterList = allItems.filter(it => it.category !== 'misc'); // Remove index pages from final list
      await idbPutBatch(db, masterList);
      safeFuseCreate();
      setStatus(`Rebuild complete: ${masterList.length} items.`);
    } catch (e) {
      setStatus(`Rebuild failed: ${e.message}`);
      console.error(e);
    } finally {
      rebuilding = false;
      enableUI();
    }
  }

  /* ========== Navigation and Selection ========== */
  function updateCurrentItemDisplay() {
      if (!currentItem) {
          infoEl.innerHTML = 'No item selected.';
          btnPrev.disabled = btnNext.disabled = btnFavToggle.disabled = true;
          return;
      }
      infoEl.innerHTML = `<b>${escapeHtml(currentItem.name)}</b> <br><small style="opacity:.7;">[${escapeHtml(currentItem.category)}]</small> <br><a href="${escapeHtml(currentItem.url)}" target="_blank" rel="noopener noreferrer">Open page</a>`;
      updateFavButton();
      btnPrev.disabled = currentIndex <= 0;
      btnNext.disabled = currentIndex >= currentList.length - 1;
      btnFavToggle.disabled = false;
  }

  function goToItem(item) {
      if (!item) return;
      location.href = item.url;
  }

  // --- BULLETPROOF FUNCTION ---
  // This function performs a LIVE check and is guaranteed to work.
  function pickTrulyRandomObscure() {
    if (!masterList.length) return null;
    // 1. Start with the entire list of non-country flags. This is our potential pool.
    let pool = masterList.filter(it => !isTwoLetterCountryId(it.id));
    // 2. Further refine by removing any index pages that might have slipped through.
    pool = pool.filter(it => !isIndexPageId(it.id));
    // 3. For the best experience, also remove major, well-defined categories.
    pool = pool.filter(it => it.category !== 'country' && it.category !== 'historical' && it.category !== 'maritime');
    if (!pool.length) return null;
    return pool[Math.floor(Math.random() * pool.length)];
  }

  /* ========== Favorites ========== */
  let favorites = new Set(JSON.parse(localStorage.getItem(FAV_KEY) || '[]'));
  function saveFavs() { localStorage.setItem(FAV_KEY, JSON.stringify(Array.from(favorites))); }
  function toggleFav(id) { if(id){ if(favorites.has(id))favorites.delete(id); else favorites.add(id); saveFavs(); updateFavButton(); } }
  function updateFavButton() { const curId = currentItem ? currentItem.id : null; btnFavToggle.textContent = favorites.has(curId) ? '⭐ Unfav' : '⭐ Fav'; }


  /* ========== Events ========== */
  searchEl.addEventListener('input', e => {
    const term = (e.target.value||'').trim();
    if (!term) { resultsEl.style.display = 'none'; resultsEl.innerHTML = ''; return; }
    const cat = categoryFilterEl.value;
    let results = fuse.search(term).slice(0, 80).map(r => r.item);
    if (cat && cat !== 'all') results = results.filter(it => it.category === cat);
    resultsEl.innerHTML = results.map(it => `<div data-url="${escapeHtml(it.url)}">${escapeHtml(it.name)} <span style="opacity:.6;font-size:11px">[${escapeHtml(it.category||'misc')}]</span></div>`).join('');
    resultsEl.style.display = 'block';
  });
  resultsEl.addEventListener('click', e => { const div = e.target.closest('div[data-url]'); if(div) location.href = div.dataset.url; });
  categoryFilterEl.addEventListener('change', () => searchEl.dispatchEvent(new Event('input')));

  btnRandom.addEventListener('click', () => {
    const cat = categoryFilterEl.value;
    let item;
    if (cat === 'obscure') item = pickTrulyRandomObscure();
    else {
      const pool = cat === 'all' ? masterList : masterList.filter(it => it.category === cat);
      if (pool.length) item = pool[Math.floor(Math.random() * pool.length)];
    }
    if (item) goToItem(item);
    else alert(`No items found in category "${cat}".`);
  });
  btnRandomObscure.addEventListener('click', () => {
    const r = pickTrulyRandomObscure();
    if (r) goToItem(r);
    else alert('No obscure flags found. Please try rebuilding the database.');
  });
  btnRandomLongName.addEventListener('click', () => {
    const pool = masterList.filter(it => it.name.length >= LONG_NAME_MIN_LENGTH);
    if (pool.length) goToItem(pool[Math.floor(Math.random() * pool.length)]);
    else alert(`No flags found with long names (≥ ${LONG_NAME_MIN_LENGTH} chars).`);
  });
  btnRandomHistorical.addEventListener('click', () => {
      const pool = masterList.filter(it => it.category === 'historical');
      if (pool.length) goToItem(pool[Math.floor(Math.random() * pool.length)]);
  });
  btnPrev.addEventListener('click', () => { if (currentIndex > 0) goToItem(currentList[currentIndex - 1]); });
  btnNext.addEventListener('click', () => { if (currentIndex < currentList.length - 1) goToItem(currentList[currentIndex + 1]); });
  btnFavToggle.addEventListener('click', () => { if (currentItem) toggleFav(currentItem.id); });
  btnViewFavs.addEventListener('click', () => {
    const favArray = masterList.filter(it => favorites.has(it.id));
    if (!favArray.length) { alert('No favorites yet.'); return; }
    goToItem(favArray[0]);
  });
  btnRandomFav.addEventListener('click', () => {
    const favArray = masterList.filter(it => favorites.has(it.id));
    if (!favArray.length) { alert('No favorites yet.'); return; }
    goToItem(favArray[Math.floor(Math.random() * favArray.length)]);
  });
  btnRebuild.addEventListener('click', () => {
    if (rebuilding) return;
    if (confirm('This will rebuild the flag database with the new, broader "obscure" category.\nThis is recommended and can take a minute.\nContinue?')) {
      rebuildMasterList(dbInst);
    }
  });

  /* ========== INITIALIZE ========== */
  async function initialize() {
    setStatus('Loading data...');
    disableUI();
    try {
      await waitForFuse();
      dbInst = await openDB();
      masterList = await idbGetAll(dbInst);
      if (!masterList.length) {
        setStatus('No local DB. Rebuilding...');
        await rebuildMasterList(dbInst);
      }
      setStatus(`Loaded ${masterList.length} flags.`);
      safeFuseCreate();
      enableUI();

      const currentPageId = location.href.startsWith(BASE) ? location.href.slice(BASE.length) : null;
      currentItem = currentPageId ? masterList.find(it => it.id === currentPageId) : null;
      if (currentItem) {
          currentList = masterList.filter(it => it.category === currentItem.category && !isIndexPageId(it.id) && !isTwoLetterCountryId(it.id));
          currentIndex = currentList.findIndex(it => it.id === currentPageId);
      }
      updateCurrentItemDisplay();

    } catch (e) {
      setStatus('Error on init: ' + e.message);
      console.error(e);
      btnRebuild.disabled = false;
    }
  }

  initialize();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址