Otwórz linki zbiorczo

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.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

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

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

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

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==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;
}