링크 일괄 열기

Shift+Alt+Q를 눌러 페이지 메인 목록의 링크를 일괄 열기합니다. 검색 결과나 목록 페이지의 상세 페이지를 빠르게 탐색하는 데 유용합니다.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name               Batch Open Links
// @name:zh-CN         批量打开链接
// @name:zh-TW         批次開啟連結
// @name:es            Abrir enlaces en lote
// @name:hi            लिंक बैच में खोलें
// @name:ar            فتح الروابط دفعة واحدة
// @name:bn            লিঙ্ক ব্যাচে খুলুন
// @name:pt            Abrir links em lote
// @name:ru            Пакетное открытие ссылок
// @name:ja            リンク一括オープン
// @name:de            Links stapelweise öffnen
// @name:fr            Ouvrir les liens en lot
// @name:ko            링크 일괄 열기
// @name:tr            Bağlantıları toplu aç
// @name:vi            Mở hàng loạt liên kết
// @name:it            Apri link in blocco
// @name:th            เปิดลิงก์เป็นชุด
// @name:pl            Otwórz linki zbiorczo
// @name:nl            Links in bulk openen
// @name:uk            Пакетне відкриття посилань
// @name:id            Buka tautan secara massal
//
// @namespace          [email protected]
// @version            2.2.0
// @author             [email protected]
//
// @description        Press Shift+Alt+Q to batch open links in the main list on a page. Handy for exploring all search engine results or detail pages in a list page.
// @description:zh-CN  按 Shift+Alt+Q 批量打开页面主列表中的链接。适用于快速浏览搜索引擎结果或列表页中的详情页。
// @description:zh-TW  按 Shift+Alt+Q 批次開啟頁面主列表中的連結。適用於快速瀏覽搜尋引擎結果或列表頁中的詳情頁。
// @description:es     Presiona Shift+Alt+Q para abrir en lote los enlaces de la lista principal de una página. Útil para explorar resultados de búsqueda o páginas de detalle.
// @description:hi     पेज की मुख्य सूची के लिंक बैच में खोलने के लिए Shift+Alt+Q दबाएँ। खोज इंजन परिणामों या सूची पृष्ठों को तेज़ी से देखने के लिए उपयोगी।
// @description:ar     اضغط Shift+Alt+Q لفتح روابط القائمة الرئيسية دفعة واحدة. مفيد لاستعراض نتائج محركات البحث أو صفحات التفاصيل.
// @description:bn     পৃষ্ঠার প্রধান তালিকার লিঙ্কগুলো ব্যাচে খুলতে Shift+Alt+Q চাপুন। সার্চ ইঞ্জিনের ফলাফল বা তালিকা পৃষ্ঠা দ্রুত দেখার জন্য কার্যকর।
// @description:pt     Pressione Shift+Alt+Q para abrir em lote os links da lista principal de uma página. Útil para explorar resultados de busca ou páginas de detalhes.
// @description:ru     Нажмите Shift+Alt+Q, чтобы пакетно открыть ссылки из основного списка на странице. Удобно для просмотра результатов поиска или страниц с деталями.
// @description:ja     Shift+Alt+Qでページのメインリストのリンクを一括で開きます。検索結果や一覧ページの詳細を素早く閲覧するのに便利です。
// @description:de     Drücken Sie Shift+Alt+Q, um Links der Hauptliste einer Seite gebündelt zu öffnen. Praktisch zum Durchsuchen von Suchergebnissen oder Detailseiten.
// @description:fr     Appuyez sur Shift+Alt+Q pour ouvrir en lot les liens de la liste principale d'une page. Pratique pour explorer les résultats de recherche ou les pages de détail.
// @description:ko     Shift+Alt+Q를 눌러 페이지 메인 목록의 링크를 일괄 열기합니다. 검색 결과나 목록 페이지의 상세 페이지를 빠르게 탐색하는 데 유용합니다.
// @description:tr     Shift+Alt+Q ile sayfadaki ana listedeki bağlantıları toplu olarak açın. Arama motoru sonuçlarını veya liste sayfalarını hızlıca incelemek için kullanışlıdır.
// @description:vi     Nhấn Shift+Alt+Q để mở hàng loạt các liên kết trong danh sách chính trên trang. Tiện lợi để duyệt nhanh kết quả tìm kiếm hoặc trang chi tiết.
// @description:it     Premi Shift+Alt+Q per aprire in blocco i link della lista principale di una pagina. Utile per esplorare risultati di ricerca o pagine di dettaglio.
// @description:th     กด Shift+Alt+Q เพื่อเปิดลิงก์ในรายการหลักของหน้าเว็บพร้อมกัน สะดวกสำหรับการดูผลการค้นหาหรือหน้ารายละเอียดอย่างรวดเร็ว
// @description:pl     Naciśnij Shift+Alt+Q, aby zbiorczo otworzyć linki z głównej listy na stronie. Przydatne do przeglądania wyników wyszukiwania lub stron szczegółów.
// @description:nl     Druk op Shift+Alt+Q om links in de hoofdlijst van een pagina in bulk te openen. Handig om zoekresultaten of detailpagina's snel te verkennen.
// @description:uk     Натисніть Shift+Alt+Q, щоб пакетно відкрити посилання з основного списку на сторінці. Зручно для перегляду результатів пошуку або сторінок з деталями.
// @description:id     Tekan Shift+Alt+Q untuk membuka tautan daftar utama halaman secara massal. Berguna untuk menjelajahi hasil pencarian atau halaman detail dengan cepat.
//
// @match              http://*/*
// @match              https://*/*
// @grant              none
//
// @supportURL         https://github.com/snomiao/batch-open-links/issues
// ==/UserScript==

/**
 * Page Flood - Batch open links from the dominant list on any page.
 *
 * Algorithm:
 * 1. Collect all anchor elements on the page
 * 2. Extract feature vectors (depth, area, class names, attributes) per link
 * 3. Group similar links by cosine similarity of their feature vectors
 * 4. Score groups by occupied screen area
 * 5. Present the highest-scoring group for batch opening
 *
 * Press Shift+Alt+Q to cycle through link groups and open them in new tabs.
 */

main();

/**
 * Initializes the userscript. Aborts any previous instance via a global
 * AbortController and registers the hotkey listener.
 */
function main() {
  const g = window;
  g.PageFloodController?.abort();
  const ac = (g.PageFloodController = new AbortController());
  const signal = ac.signal;
  document.addEventListener("DOMContentLoaded", () => {
    // console.debug(getArrayOfArrayOfAnchors());
  });

  hotkeys({
    "alt+shift+q": ((allLists = null) => {
      return async function openLinksInList() {
        allLists ??= getArrayOfArrayOfAnchors();
        const list = allLists.shift() || DIES(alert, "no more link lists");
        list.forEach((el, i, a) =>
          flashBorder(el, getOklch(i / a.length), 500 + (a.length - i) * 500),
        );
        await sleep(0) // next 
        return await openLinks(list);
      };
    })(),
    signal,
  });
}
/**
 * Calls a function with the given args, then throws an Error.
 * Used as a fallback expression that halts execution (e.g. `value || DIES(alert, "msg")`).
 * @param {Function} fn - Side-effect function to call before throwing (e.g. `alert`).
 * @param {...*} args - Arguments forwarded to `fn`. The first arg is used as the error message.
 * @returns {never}
 * @throws {Error}
 */
function DIES(fn, ...args) {
  fn(...args);
  throw new Error(args.at(0) ?? "Unknown Error");
}
/**
 * Registers global keydown (and optionally keyup) listeners for hotkey combos.
 * Modifier keys are normalized to alphabetical order: alt+ctrl+meta+shift.
 *
 * @param {Object<string, Function> & { signal?: AbortSignal }} m
 *   Map of hotkey strings (e.g. `"alt+shift+Q"`) to async handler functions.
 *   Append `" up"` to a key for keyup handlers. Include a `signal` property
 *   to allow aborting the listeners.
 */
function hotkeys(m) {
  window.addEventListener(
    "keydown",
    async (e) => {
      if (e.repeat) return;
      const mods = "alt+ctrl+meta+shift"
        .split("+")
        .filter((m) => e[m + "Key"]);
      const key = e.code.replace(/^Key/i, "").toLowerCase();
      const combo = [...mods, key].join("+");
      const fn = m[combo];
      fn && (e.stopPropagation(), e.preventDefault(), await fn());
    },
    { signal: m.signal },
  );
  Object.keys(m).join("\n").match(/ up$/m) &&
    window.addEventListener(
      "keyup",
      async (e) => {
        const mods = "alt+ctrl+meta+shift"
          .split("+")
          .filter((m) => e[m + "Key"]);
        const key = e.code.replace(/^Key/i, "").toLowerCase();
        const combo = [...mods, key].join("+") + " up";
        const fn = m[combo];
        fn && (e.stopPropagation(), e.preventDefault(), await fn());
      },
      { signal: m.signal },
    );
}

/**
 * Opens a list of URLs in batches of 8, with user confirmation for each batch.
 * Waits for the user to return to the current tab before opening the next batch.
 *
 * @param {string[]} links - Array of URLs to open.
 * @returns {Promise<void>}
 * @throws Alerts and throws if the user cancels a batch.
 */
async function openLinks(links) {
  const urlss = Object.values(Object.groupBy(links, (url, i) => String(Math.floor(i / 8))));
  for await (const urls of urlss) {
    const urlList = urls.join("\n");
    const confirmMsg = `confirm to open ${urls.length} pages?\n\n${urlList}`;
    if (!confirm(confirmMsg)) throw alert("cancelled by user");
    urls.toReversed().map(openDeduplicatedUrl);
    await sleep(1e3); // 1s cd
    await new Promise((r) => document.addEventListener("visibilitychange", r, { once: true })); // wait for page visible
  }
}
async function sleep(ms) {
  await new Promise((r) => setTimeout(r, ms))
}
/**
 * Opens a URL in a new tab, skipping duplicates already opened in this session.
 * Tracks opened URLs in `window.openDeduplicatedUrl_opened`.
 *
 * @param {string} url - The URL to open.
 * @returns {boolean|undefined} `false` if already opened, otherwise the result of `Set.add`.
 */
function openDeduplicatedUrl(url) {
  const opened = (window.openDeduplicatedUrl_opened ??= new Set());
  return opened.has(url) || (open(url, "_blank") && opened.add(url));
}
/**
 * Opens a URL by programmatically clicking a dynamically created anchor element.
 * This bypasses popup blockers that may block `window.open`.
 *
 * @param {string} href - The URL to navigate to.
 * @param {string} target - The link target (e.g. `"_blank"`).
 */
function open(href, target) {
  Object.assign(document.createElement("a"), { href, target }).click();
}

/**
 * Creates a simple bag-of-words model for converting text into binary feature vectors.
 * Call `fit` with training texts to build the vocabulary, then `transform` to vectorize.
 *
 * @returns {{ wordSet: Set<string>, fit: (texts: string[]) => void, transform: (text: string) => number[] }}
 */
function BagOfWordsModel() {
  const wordSet = new Set();
  return {
    wordSet,
    fit: (texts) => {
      texts.forEach((text) =>
        text
          .toLowerCase()
          .split(/\W+/)
          .forEach((word) => wordSet.add(word)),
      );
    },
    transform: (text) => {
      const words = text.toLowerCase().split(/\W+/);
      const vec = Array.from(wordSet).map((word) => (words.includes(word) ? 1 : 0));
      return vec;
    },
  };
}

/**
 * Analyzes all anchor elements on the page and groups them by visual/structural
 * similarity using a bag-of-words model on class names and attributes, combined
 * with layout features (element depth, parent bounding rect).
 *
 * Groups are ranked by a score derived from the bounding area they collectively
 * occupy, so the "main content list" typically ranks first.
 *
 * @returns {HTMLAnchorElement[][]} Array of link groups, sorted by descending score.
 */
function getArrayOfArrayOfAnchors() {
  // groupBy words and then return map
  return (
    [{ sel: "a" }]
      .map((e) => ({ ...e, list: [...document.querySelectorAll(e.sel)] }))
      .map((e) => ({
        ...e,
        bow: BagOfWordsModel(),
      }))
      .map((e) => ({
        ...e,
        _: e.bow.fit(e.list.map((el) => el.className + " " + getElementAttributeNames(el))),
      }))
      .map((e) => ({
        ...e,
        vec: e.list.map((el, i) => [
          elementDepth(el),
          area(el.parentElement?.getBoundingClientRect()),
          el.parentElement?.getBoundingClientRect().width,
          el.parentElement?.getBoundingClientRect().height,
          ...e.bow.transform(el.className + " " + getElementAttributeNames(el)),
        ]),
      }))
      .map((e) => ({ ...e, nor: normalize(e.vec) }))
      .map((e) => ({ ...e, vecGrp: groupByCosineSimilarity(e.nor, 0.99) }))
      .map((e) => ({ ...e, grp: e.vecGrp.map((g) => g.map((i) => e.list[i])) }))
      .map((e) => ({
        ...e,
        rank: e.grp
          .map((g) => ({
            links: g,
            area: area(maxRect(g.map((el) => el.getBoundingClientRect()))),
            areaSum: g.map((el) => area(el.getBoundingClientRect())).reduce((a, b) => a + b, 0),
          }))
          .map((g) => ({ ...g, score: Math.log(g.area * g.areaSum) }))
          .toSorted(compareBy((g) => -g.score)),
      }))
      // .map((e) => ({
      //   ...e,
      //   _: e.rank
      //     .slice(0, 1)
      //     .map((grp, i, a) =>
      //       grp.links.map((el) =>
      //         flashBorder(
      //           el,
      //           getOklch(i / a.length),
      //           500 + (a.length - i) * 500
      //         )
      //       )
      //     ),
      // }))
      // debug
      // .map((e) => (console.log(e), { ...e }))
      .map((e) => e.rank.map((e) => e.links)) // a
      .at(0)
  );
}

/**
 * Generates an OKLCH color string from a normalized parameter `t` in [0, 1].
 * Used to assign visually distinct colors when highlighting link groups.
 *
 * @param {number} t - Normalized value in [0, 1] controlling hue, chroma, and lightness.
 * @returns {string} CSS `oklch()` color value.
 */
function getOklch(t) {
  const l = 0.9 - 0.5 * t;
  const c = 0.2 + 0.3 * t;
  const h = 360 * t;
  return `oklch(${l} ${c} ${h})`;
}
/**
 * Temporarily highlights an element with a colored outline, then restores the original.
 *
 * @param {HTMLElement} el - The element to highlight.
 * @param {string} color - CSS color value for the outline.
 * @param {number} [duration=1000] - How long the highlight lasts in milliseconds.
 * @returns {number} The timeout ID (can be used with `clearTimeout`).
 */
function flashBorder(el, color, duration = 1000) {
  const orig = el.style.outline;
  el.style.outline = `3px solid ${color}`;
  return setTimeout(() => (el.style.outline = orig), duration);
}
/**
 * Returns a comparator function that sorts by the numeric value of `fn(item)`.
 * Use with `Array.prototype.toSorted`. Negate `fn` for descending order.
 *
 * @param {(item: *) => number} fn - Key extraction function.
 * @returns {(a: *, b: *) => number} Comparator function.
 */
function compareBy(fn) {
  return (a, b) => fn(a) - fn(b);
}
/**
 * Computes the bounding rectangle that encloses all given rectangles.
 *
 * @param {DOMRect[]} rects - Array of DOMRect-like objects.
 * @returns {{ left: number, top: number, right: number, bottom: number }}
 */
function maxRect(rects) {
  return {
    left: Math.min(...rects.map((e) => e.left)),
    top: Math.min(...rects.map((e) => e.top)),
    right: Math.max(...rects.map((e) => e.right)),
    bottom: Math.max(...rects.map((e) => e.bottom)),
  };
}
/**
 * Calculates the area of a rectangle from its edges.
 *
 * @param {{ left: number, right: number, top: number, bottom: number }} rect
 * @returns {number} The area in square pixels.
 */
function area({ left, right, top, bottom }) {
  return (right - left) * (bottom - top);
}
/**
 * Returns the nesting depth of an element in the DOM tree.
 *
 * @param {HTMLElement|null} e - The element to measure.
 * @returns {number} Depth (0 for null/document).
 */
function elementDepth(e) {
  return !e ? 0 : 1 + elementDepth(e.parentElement);
}
/**
 * Column-wise max-normalizes a 2D array of numbers so each feature is in [0, 1].
 *
 * @param {number[][]} arr - Matrix of feature vectors (rows = items, cols = features).
 * @returns {number[][]} Normalized matrix.
 */
function normalize(arr) {
  const maxs = arr.reduce(
    (a, b) => a.map((e, i) => Math.max(e, b[i])),
    Array(arr[0].length).fill(-Infinity),
  );
  return arr.map((e) => e.map((v, i) => v / maxs[i]));
}
/**
 * Computes the dot product of two numeric arrays.
 *
 * @param {number[]} a
 * @param {number[]} b
 * @returns {number}
 */
function dot(a, b) {
  return a.reduce((s, v, i) => s + v * b[i], 0);
}
/**
 * Computes the Euclidean magnitude (L2 norm) of a vector.
 *
 * @param {number[]} a
 * @returns {number}
 */
function magnitude(a) {
  return Math.sqrt(dot(a, a));
}
/**
 * Computes the cosine similarity between two vectors.
 *
 * @param {number[]} a
 * @param {number[]} b
 * @returns {number} Value in [-1, 1] where 1 means identical direction.
 */
function cosineSimilarity(a, b) {
  return dot(a, b) / (magnitude(a) * magnitude(b));
}
/**
 * Groups vectors by greedy single-linkage clustering using cosine similarity.
 * Each unvisited vector starts a new group and absorbs all remaining vectors
 * whose cosine similarity exceeds the threshold.
 *
 * @param {number[][]} arr - Array of feature vectors to cluster.
 * @param {number} [threshold=0.99] - Minimum cosine similarity to join a group.
 * @returns {number[][]} Array of groups, where each group is an array of original indices.
 */
function groupByCosineSimilarity(arr, threshold = 0.99) {
  const groups = [];
  const visited = new Set();
  arr.forEach((vec, i) => {
    if (visited.has(i)) return;
    const group = [i];
    visited.add(i);
    for (let j = i + 1; j < arr.length; j++) {
      if (cosineSimilarity(vec, arr[j]) > threshold) {
        group.push(j);
        visited.add(j);
      }
    }
    groups.push(group);
  });
  return groups;
}

/**
 * Returns a space-separated string of all attribute names on an element.
 * Used as a feature for the bag-of-words model to distinguish link types.
 *
 * @param {HTMLElement|null} el
 * @returns {string} Space-separated attribute names, or empty string if `el` is null.
 */
function getElementAttributeNames(el) {
  if (!el) return "";
  const attrs = Array.from(el.attributes || [])
    .map((attr) => attr.name)
    .join(" ");
  return attrs;
}