Skip to content

Instantly share code, notes, and snippets.

@htsign
Last active March 9, 2026 17:09
Show Gist options
  • Select an option

  • Save htsign/700c4ea725acb9e38c97900b1aa04633 to your computer and use it in GitHub Desktop.

Select an option

Save htsign/700c4ea725acb9e38c97900b1aa04633 to your computer and use it in GitHub Desktop.
// ==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