Last active
March 9, 2026 17:09
-
-
Save htsign/700c4ea725acb9e38c97900b1aa04633 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Hatena bookmark compatibility for DuckDuckGo | |
| // @description add Hatebu images for DuckDuckGo | |
| // @namespace https://htsign.hateblo.jp | |
| // @version 0.0.11 | |
| // @author htsign | |
| // @match https://duckduckgo.com/?* | |
| // @updateURL https://gist.github.com/htsign/700c4ea725acb9e38c97900b1aa04633/raw/hatebu-for-duckduckgo.user.js | |
| // @downloadURL https://gist.github.com/htsign/700c4ea725acb9e38c97900b1aa04633/raw/hatebu-for-duckduckgo.user.js | |
| // @grant GM_xmlhttpRequest | |
| // ==/UserScript== | |
| /** | |
| * @typedef {{ | |
| * arraybuffer: ArrayBuffer; | |
| * blob: Blob; | |
| * document: Document; | |
| * json: string; | |
| * text: string; | |
| * }} GMResponseType | |
| */ | |
| /** | |
| * @typedef {{ | |
| * finalUrl: string; | |
| * readyState: number; | |
| * status: number; | |
| * statusText: string; | |
| * responseHeaders: Record<string, string>; | |
| * response?: GMResponseType[ResponseType]; | |
| * responseXML?: XMLDocument; | |
| * responseText: string; | |
| * }} GMResponse | |
| * @template {keyof GMResponseType} ResponseType | |
| */ | |
| /** | |
| * @typedef {{ | |
| * url: string | URL; | |
| * method: string; | |
| * headers?: Record<string, string>; | |
| * data?: string | Blob | File | Object | unknown[] | FormData | URLSearchParams; | |
| * redirect?: 'follow' | 'error' | 'manual'; | |
| * cookie?: string; | |
| * cookiePartition?: { topLevelSite: string | undefined } | (string & {}); | |
| * binary?: string; | |
| * nocache?: boolean; | |
| * revalidate?: unknown; | |
| * timeout?: number; | |
| * context?: unknown; | |
| * responseType?: ResponseType; | |
| * overrideMimeType?: string; | |
| * anonymous?: boolean; | |
| * fetch?: boolean; | |
| * user?: string; | |
| * password?: string; | |
| * onabort?: (response: GMResponse<ResponseType>) => void; | |
| * onerror?: (response: GMResponse<ResponseType>) => void; | |
| * onload: (response: GMResponse<ResponseType>) => void; | |
| * onprogress?: (response: GMResponse<ResponseType>) => void; | |
| * onreadystagechange?: (response: GMResponse<ResponseType>) => void; | |
| * ontimeout?: (response: GMResponse<ResponseType>) => void; | |
| * }} GMXmlHttpRequestOptions | |
| * @template {keyof GMResponseType} ResponseType | |
| */ | |
| /** | |
| * @param {string} url | |
| * @param {{ method?: string, options?: Omit<GMXmlHttpRequestOptions<ResponseType>, 'url' | 'method' | 'onload' | 'onprogress'>, signal?: AbortSignal }} [options={}] | |
| * @returns {Promise<GMResponse<ResponseType>>} | |
| * @template {keyof GMResponseType} [ResponseType='text'] | |
| */ | |
| const request = (url, { method = 'GET', options = {}, signal } = {}) => { | |
| return new Promise((resolve, reject) => { | |
| /** @type {{ abort: () => void }} */ | |
| const request = | |
| /** @ts-ignore */ | |
| GM_xmlhttpRequest(/** @type {GMXmlHttpRequestOptions<ResponseType>} */ ({ | |
| url, | |
| method, | |
| onload: resolve, | |
| onerror: reject, | |
| onabort: reject, | |
| ontimeout: reject, | |
| onprogress(response) { | |
| if (signal?.aborted) { | |
| request.abort(); | |
| reject(response); | |
| } | |
| }, | |
| ...options, | |
| })); | |
| }); | |
| }; | |
| /** | |
| * | |
| * @param {() => T | null | undefined} expr | |
| * @param {{ | |
| * timeout?: number; | |
| * checkInterval?: number; | |
| * }} [options={}] | |
| * @returns {Promise<T>} | |
| * @template {object} T | |
| */ | |
| const wait = (expr, { timeout = Infinity, checkInterval = 0 } = {}) => new Promise((resolve, reject) => { | |
| const start = performance.now(); | |
| const f = () => { | |
| const obj = expr(); | |
| if (obj != null) { | |
| return resolve(obj); | |
| } | |
| if (performance.now() - start >= timeout) { | |
| return reject(); | |
| } | |
| setTimeout(f, checkInterval); | |
| }; | |
| setTimeout(f); | |
| }); | |
| (async () => { | |
| 'use strict'; | |
| const CLASS = 'article-hatebu-count'; | |
| const ORIGIN = 'https://b.hatena.ne.jp'; | |
| const ANCHOR_SELECTOR = 'a[data-testid="result-extras-url-link"]'; | |
| const style = document.createElement('style'); | |
| document.head.append(style); | |
| style.sheet.insertRule(` | |
| .${CLASS} { | |
| margin-left: auto; | |
| } | |
| `); | |
| const range = document.createRange(); | |
| range.selectNode(document.documentElement); | |
| /** | |
| * @param {Blob} blob | |
| * @returns {Promise<`data:${string};base64,${string}`>} | |
| */ | |
| const blobToDataURL = blob => new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = () => resolve(reader.result); | |
| reader.onerror = reject; | |
| reader.readAsDataURL(blob); | |
| }); | |
| /** | |
| * @param {string} href | |
| * @returns {Promise<DocumentFragment | null>} | |
| */ | |
| const createHatebuImage = async href => { | |
| const imageUrl = `${ORIGIN}/entry/image/${href}`; | |
| const { response, status } = await request(imageUrl, { options: { responseType: 'blob' } }); | |
| if (((status / 100) | 0) !== 2) return null; | |
| const dataUrl = await blobToDataURL(response); | |
| return range.createContextualFragment(` | |
| <a class="${CLASS}" href="${ORIGIN}/entry/${href}" target="_blank" rel="noopener"> | |
| <img src="${dataUrl}"> | |
| </a> | |
| `); | |
| }; | |
| /** | |
| * @param {Iterable<HTMLLIElement>} listItems | |
| */ | |
| const processResults = async listItems => { | |
| const promises = new Map(); | |
| for (const li of listItems) { | |
| if (!li.matches('[data-layout="organic"]')) continue; | |
| const linkDiv = li.querySelector(`div:has(${ANCHOR_SELECTOR})`); | |
| const linkUrl = linkDiv?.querySelector(ANCHOR_SELECTOR)?.href; | |
| if (linkUrl == null) continue; | |
| promises.set(linkDiv, createHatebuImage(linkUrl)); | |
| } | |
| await Promise.allSettled(promises.values()); | |
| for (const [k, v] of promises) { | |
| v.then( | |
| anchor => anchor != null && k.append(anchor), | |
| error => console.error(error), | |
| ); | |
| } | |
| promises.clear(); | |
| }; | |
| const result = await wait(() => document.querySelector('.react-results--main'), { timeout: 3000 }); | |
| processResults(result.querySelectorAll('li[data-layout="organic"]')); | |
| const mo = new MutationObserver(results => { | |
| const listItems = results.values().flatMap(r => r.addedNodes).filter(node => node instanceof HTMLLIElement); | |
| processResults(listItems); | |
| }); | |
| mo.observe(result, { childList: true }); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment