(draft)URLを外部リンク記法に変換するUserScript@0.2.0
/icons/hr.icon
TamperMonkeyがあるときは直接web pageを取得してタイトルを抜き出す
UTF-8以外の文字コードに対応できる
非同期変換しなかったときは、同期的にテキストを返す
code:script.ts
import { convertURL, ConvertConfig, getTweet, getWebPage } from "./mod.ts";
import { insertText } from "../scrapbox-userscript-std/dom.ts";
export { getTweet, getWebPage };
export const setup = (config?: ConvertConfig[]) => {
scrapbox.PopupMenu.addButton({
title: (text) => /https?:\/\/\S+/.test(text) ? "URL" : "",
onClick: (text) => {
const promise = convert(text, config);
if (typeof promise === "string") {
if (promise === text) return;
return promise;
}
(async () => {
await insertText(await promise);
})();
},
});
};
export const convert = (text: string, config?: ConvertConfig[]): Promise<string> | string => {
let total = 0;
let done = 0;
const { render, dispose } = useStatusBar();
const promises = text.split(/(https?:\/\/\S+)/g).map((fragment) => {
if (!/^https?:\/\/\S+$/.test(fragment)) return fragment;
total++;
try {
const promise = convertURL(new URL(fragment), config);
if (!(promise instanceof Promise)) {
done++;
return promise;
}
return promise.then((text) => {
done++;
render({ type: "text", text: [${done}/${total}] converting URLs... });
return text;
}).catch(() => { ... });
} catch(e: unknown) {
// ...
}
});
if (promises.every((p) => typeof p = "string")) {
render({ type: "text", text: converted ${total} URLs. });
return (promises as string[]).join("");
}
render({ type: "text", text: [${done}/${total}] converting URLs... });
return Promise.all(promises).then((fragments) => {
render({ type: "text", text: converted ${total} URLs. });
return fragments.join("");
});
} finally {
setTimeout(dispose, 1000);
}
};
code:mod.ts
import { getTweet } from "./getTweet.ts";
import { getPageContent } from "./getPageContent.ts";
import { upload } from "../deno-gyazo/mod.ts";
import { getGyazoToken } from "../scrapbox-userscript-std/rest.ts";
export { getTweet, getPageContent };
export type ConvertConfig = [
RegExp | (url: URL) => boolean,
(url: URL) => string | Promise<string>,
];
export const convertURL = (url: URL, config?: ConvertConfig[]): Promise<string> | string => {
const convert = [...(config ?? []), defaultConfig0].find( (test) => test instanceof RegExp ? test.test(url.href) : test(url) return convert(url);
};
convertURL,
];
const convertTweet = async () => {
}
const res = await GM_fetch(url);
const html = new TextDecoder(await detectEncoding(res.clone())).decode(await res.arrayBuffer());
const dom = new DOMParser().parseFromString(html, "text/html");
code:mod.ts
const result = await getTweet(id);
if (result.ok) {
const { text, user, urlMap } = result.value;
}
抽象化レイヤー
tweet取得
短縮URLは展開先URLに置き換え、あらかじめscrapbox記法に変換しておく
画像はgyazoにuploadする
Gyazo access tokenがなかったら、直リンクにする
動画もuploadする?
返信先があるときは、それも取得する
やること
返り値の型定義を作る
code:getTweet.ts
declare const GM_fetch: (typeof fetch) | undefined;
export interface NetworkError {
status: number;
statusText: string;
body: string;
}
export interface Tweet {
/** tweet本文
*
* scrapbox記法になおしてある
*/
content: string;
/** 投稿者 */
user: User;
/** 投稿日時 (UNIX time) */
created: number;
viewed: number;
likes: number;
retweets: number;
/** 返信先tweetのid */
replyTo: string;
}
export interface User {
name: string;
screenName: string;
icon: string;
}
export const getTweet = (id: string): Result<
Tweet,
NetworkError> | undefined => {
if (!globalThis.GM_fetch) return;
const params = new URLSearchParams([
]);
const res = await GM_fetch(
https://cdn.syndication.twimg.com/tweet-result?${params},
{
headers: {
"content-type": "application/json; charset=utf-8",
},
});
if (!res.ok) return {
status: res.status,
statusText: res.statusText,
body: await res.text();
};
const { text, entities, video, photes } = await res.json();
// URLの置換
const replacedText = tweet.entities.urls.reduce((acc, urlObj) => {
return acc.replace(urlObj.url, urlObj.expanded_url);
}, text);
lines.push(replacedText)
// TODO: Gyazoにアップロードする
if(tweet.photos) {
lines.push(tweet.photos.map(u => [${u.url}]).join('\n'))
}
if(tweet.video) {
const highestResolutionVideoSrc = getHighestResolutionVideoSrc(tweet.video)
lines.push([${highestResolutionVideoSrc}#.mp4])
}
// 各種情報を詰め込んで返す
const user = tweet.user;
return {
author: {
name: user.name,
screenName: user.screen_name,
},
content: lines.join('\n').split('\n'),
date : {
href: tweetUrl,
// "2023-03-19T12:41:58.000Z" => '2023-03-19 12:41:58'
text: tweet.created_at.replace("T", " ").split(".")0 },
};
} catch(e) {
console.error(e);
}
}
if (!isTweetURL(url)) return;
// URLが不正なはずがないので、InvalidURLErrorが出たらthrowする
return globalThis.GM_getTweet?.(url) ?? SC_getTweet(url);
};
code:tweet.ts
web page取得
HTML以外のcontentsだった場合は、undefinedを返す
ページタイトルとHTMLDocumentを返す
code:ts
const getWebPage: (url: URL) => Promise<{
title: string;
} | {
title: string;
encoding: string;
dom: HTMLDocument;
headers: Headers;
} | undefined>;
code:encoding.ts
export const detectEncoding = async (res: Response): Promise<string> => {
const contentType = res.headers.get("content-type");
if (contentType) return contentType;
const dom = new DOMParser().parseFromString(await res.text(), "text/html");
return dom.querySelector("metacharset")?.getAttribute?.("charset") ?.content?.match?.(/charset=(^;+)/)?.1 ?? "utf-8";
};
TamperMonkeyから提供するコード