どんなURLでも理論上8文字まで短縮するやり方
使い方
にアクセスし、短縮したいurlを入れ、urlとして表示させたい絵文字を入力してください。
そうするとこのようなurlを取得できます。これを踏むと目的のurlにリダイレクトすることができます。
"https://cwabara.github.io/emojiurl.github.io/?q=😀󠅘󠅤󠅤󠅠󠅣󠄪󠄟󠄟󠅧󠅧󠅧󠄞󠅗󠅟󠅟󠅗󠅜󠅕󠄞󠅓󠅟󠅝󠄟"
github pageで作っているのでURLが長くなってしまっていますが理論上は表面上、8文字で全てのサイトの短縮urlを表すことができます。(ドメイン名+クエリ e.cf?q=😀󠅘󠅤󠅤󠅠󠅣󠄪󠄟󠄟󠅧󠅧󠅧󠄞󠅗󠅟󠅟󠅗󠅜󠅕󠄞󠅓󠅟󠅝󠄟など)
さらに言うと元のURL情報自体が短縮URLに埋め込まれているためdbを使う必要がないです。
悪用厳禁
はじめに:なぜ「絵文字の裏にURLを隠す」ことができるのか?
コンピューターでは「あ」とか「A」とか「😀」という文字を全部数字として扱っています。
例えば、「A」という文字はコンピューターの中では「65」という数字で保存されています。「あ」は「227, 129, 130」という3つの数字のセットで保存されています。
この「数字」の並びを利用して情報を表現するのですが、今回の技術の最大のポイントは異体字セレクタという特殊な文字を使う点にあります。
1. 「異体字セレクタ」って何?
先ほど書いた通り私たちが普段使っている文字(「あ」「A」「😀」など)には、それぞれにユニークな「コードポイント」という番号が割り当てられています。
例えば:
A は U+0041
あ は U+3042
😀 は U+1F600
異体字セレクタは、これらの通常の文字とは少し異なる特殊な「制御文字」です。その役割は、直前にある文字の『見た目』を、特定のバリエーションに変えることです。
例え話で考えてみましょう。
ある人がパソコンで「さかな」という言葉を書きたいとします。
ある人は「魚」と書きました。
別の人は「𩵾」(魚へんに支)と書きました。
どちらも「さかな」ですが、見た目が違います。Unicodeでは、この「魚」と「𩵾」のように、同じ意味でも見た目が異なるものを「異体字」と呼びます。
異体字セレクタは、この異体字を明確に指定するために使われます。例えば、
「魚」のコードポイント + 「異体字セレクタA」 = 「魚」(デフォルトの見た目)
「魚」のコードポイント + 「異体字セレクタB」 = 「𩵾」(特定の見た目)
他にも同じ「葛」という漢字でも、人名や地名で使われる「葛󠄀」のように、線の形が微妙に違うバージョンが存在します。異体字セレクタは、こうした「どちらの字形を使うか」をコンピューターに正確に伝えるための、目に見えない記号だと考えてください。
異体字セレクタは、それ自体が単独で表示されることはありません。常に直前の文字に影響を与えます。そして、もし直前の文字に対応する異体字が存在しない場合、異体字セレクタはレンダリング上、無視されるか、表示されない空白として扱われるのが一般的です。
この「無視される(=見た目が変わらない)」という特性が、今回のシステムでURLの情報を隠すための鍵となります。
さらに異体字セレクタには、全部で256種類あることも重要な点です。(U+FE00からU+FE0Fまでの16個と、U+E0100からU+E01EFまでの240個)
この「256」という数がミソです。コンピューターがデータを扱う基本単位である1バイトは、0〜255の256通りの値を表現できます。つまり、URLを構成する1バイトのデータを、256種類ある異体字セレクタの1つに1対1で変換できるのです。
2. URLを絵文字に「エンコード」する手順
URL(例: https://example.com/ )を絵文字の中に隠す手順を具体的に解説します。
先にソースコードを載せますね
encoderDecoder.ts
function byteToVariationSelector(byte: number): string {
if (byte < 16) {
return String.fromCodePoint(0xFE00 + byte);
} else {
return String.fromCodePoint(0xE0100 + (byte - 16));
}
}
function variationSelectorToByte(variationSelector: string): number | undefined {
const codePoint = variationSelector.codePointAt(0);
if (codePoint === undefined) {
return undefined;
}
if (codePoint >= 0xFE00 && codePoint <= 0xFE0F) {
return (codePoint - 0xFE00);
} else if (codePoint >= 0xE0100 && codePoint <= 0xE01EF) {
return (codePoint - 0xE0100 + 16);
}
return undefined;
}
export function encodeUrlInEmoji(baseEmoji: string, url: string): string {
const encoder = new TextEncoder();
const bytes = encoder.encode(url);
let result = baseEmoji;
for (const byte of bytes) {
result += byteToVariationSelector(byte);
}
return result;
}
export function decodeUrlFromEmoji(encodedEmoji: string): string | undefined {
const decoder = new TextDecoder();
const bytes: number[] = [];
let foundFirstVariationSelector = false;
let i = 0;
while (i < encodedEmoji.length) {
const codePoint = encodedEmoji.codePointAt(i);
if (codePoint === undefined) {
break;
}
const char = String.fromCodePoint(codePoint);
const byte = variationSelectorToByte(char);
if (byte !== undefined) {
bytes.push(byte);
foundFirstVariationSelector = true;
} else {
if (foundFirstVariationSelector) {
break;
}
}
i += char.length;
}
if (bytes.length === 0) {
return undefined;
}
try {
return decoder.decode(new Uint8Array(bytes));
} catch (e) {
console.error("Failed to decode bytes to URL:", e);
return undefined;
}
}
encode.ts
import { encodeUrlInEmoji } from './encoderDecoder';
document.addEventListener('DOMContentLoaded', () => {
const urlInput = document.getElementById('urlInput');
const emojiInput = document.getElementById('emojiInput');
const encodeButton = document.getElementById('encodeButton');
const encodedEmojiOutput = document.getElementById('encodedEmojiOutput');
const fullUrlOutput = document.getElementById('fullUrlOutput');
if (!urlInput || !emojiInput || !encodeButton || !encodedEmojiOutput || !fullUrlOutput) {
console.error('必要なDOM要素が見つかりません。');
return;
}
encodeButton.addEventListener('click', () => {
const originalUrl = (urlInput as HTMLInputElement).value;
const baseEmoji = (emojiInput as HTMLInputElement).value;
if (!originalUrl || !baseEmoji) {
alert('URLとベース絵文字の両方を入力してください。');
return;
}
const encodedEmoji = encodeUrlInEmoji(baseEmoji, originalUrl);
encodedEmojiOutput.textContent = encodedEmoji;
const currentOrigin = window.location.origin;
const currentPath = window.location.pathname.replace('encode.html', '');
const githubPagesBaseUrl = `${currentOrigin}${currentPath}`;
const fullRedirectUrl = `${githubPagesBaseUrl}?q=${encodedEmoji}`;
fullUrlOutput.textContent = fullRedirectUrl;
navigator.clipboard.writeText(fullRedirectUrl).then(() => {
console.log('GitHub Pages URL copied to clipboard');
}).catch(err => {
console.error('Failed to copy text: ', err);
});
});
});
main.ts
import { decodeUrlFromEmoji } from './encoderDecoder';
document.addEventListener('DOMContentLoaded', () => {
const params = new URLSearchParams(window.location.search);
const encodedEmoji = params.get('q');
const statusElement = document.getElementById('status');
if (statusElement) {
if (encodedEmoji) {
const decodedUrl = decodeUrlFromEmoji(encodedEmoji);
if (decodedUrl) {
statusElement.textContent = `元のURL: ${decodedUrl} へリダイレクトします。`;
window.location.replace(decodedUrl);
} else {
statusElement.textContent = 'エラー: 絵文字からURLをデコードできませんでした。';
console.error('Failed to decode emoji to URL:', encodedEmoji);
}
} else {
statusElement.textContent = 'エラー: URLに絵文字のクエリパラメータ "q" が見つかりません。';
console.warn('No "q" parameter found in URL.');
}
}
});
encode.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>絵文字URL</title>
<style>
...
</style>
</head>
<body>
<div class="container">
<h1>絵文字URL</h1>
<p>隠したいURLとベース絵文字を入力してください。</p>
<label for="urlInput">元のURL:</label>
<input type="text" id="urlInput" value="https://www.google.com/" placeholder="https://example.com">
<label for="emojiInput">ベース絵文字:</label>
<input type="text" id="emojiInput" value="😀"> <button id="encodeButton">エンコード</button>
<div id="resultContainer">
<h2>結果</h2>
<p><strong>エンコードされた絵文字:</strong></p>
<div id="encodedEmojiOutput"></div>
<p><strong>変更されたURL:</strong></p>
<div id="fullUrlOutput"></div>
<p>上記のURLをコピーして使用してください。</p>
</div>
</div>
<script type="module" src="/src/encode.ts"></script>
</body>
</html>
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>絵文字URLリダイレクター</title>
<style>
...
</style>
</head>
<body>
<div class="container">
<h1>リダイレクト中...</h1>
<p>絵文字URLから元のURLへリダイレクトしています。</p>
<p id="status">絵文字をデコード中...</p>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
本番環境では時間がなかったのでひとまずjsにコンパイルしていますがあとで変える予定です。
追記:vite環境に移行しました。
URLをバラバラの「数字(バイト)」にする
まず、隠したいURLをコンピューターが理解できる数字の並び(バイト列)に変換します。
これには「TextEncoder」という関数を使います。
例: もし隠したいURLが「A」だけだとします。
TextEncoderで「A」を数字に変換すると、[65]という1つの数字になります。(「A」のバイト値は65だからです)
もしこれが「あ」だとしたら
TextEncoderで「あ」を数字に変換すると、[227, 129, 130]という3つの数字のセットとなります。
例: https://example.com/ をバイト列に変換する
https://example.com/ を TextEncoder でバイト列に変換すると、以下のような数字の並びになります。
h -> 104
t -> 116
t -> 116
p -> 112
s -> 115
: -> 58
/ -> 47
/ -> 47
e -> 101
x -> 120
a -> 97
m -> 109
p -> 112
l -> 108
e -> 101
. -> 46
c -> 99
o -> 111
m -> 109
/ -> 47
つまり、[104, 116, 116, 112, 115, 58, 47, 47, 101, 120, 97, 109, 112, 108, 101, 46, 99, 111, 109, 47]のようなバイト列になります。
バラバラの「数字」を「異体字セレクタ」に変える
次に、先ほどのバイトをそれぞれ対応する「異体字セレクタ」に変えます。
これには自作関数byteToVariationSelectorを使っています。
function byteToVariationSelector(byte: number): string {
if (byte < 16) {
return String.fromCodePoint(0xFE00 + byte);
} else {
return String.fromCodePoint(0xE0100 + (byte - 16));
}
}
「ベース絵文字」の後に異体字セレクタをずらっと並べる
最後に、ユーザーが選んだ「ベース絵文字」(例: 😀)の直後に、変換した異体字セレクタの文字を順番にくっつけます。
例: URLが「A」の場合
ベース絵文字: 😀
URL「A」の数字: [65]
数字65に対応する異体字セレクタ: U+E0131
完成する絵文字文字列: 😀 + U+E0131
(見た目は「😀」一つに見えることが多い)
例: URLが「Hi!」の場合
「H」はバイト値 72
「i」はバイト値 105
「!」はバイト値 33
ベース絵文字: 👍
完成する絵文字文字列: 👍 + VS(72) + VS(105) + VS(33)
(VS()は異体字セレクタに変換された文字を表すとします)
見た目は「👍」一つに見えることが多いですが、その直後には3つの目に見えない異体字セレクタが並んでいます。
例: https://example.com/ をエンコードした最終的な絵文字文字列
ユーザーがベース絵文字として「🌐」を選んだとします。
上記の https://example.com/ の各バイトが異体字セレクタに変換された後それらが「🌐」の直後に連結されます。
完成する絵文字文字列(イメージ):
🌐 + VS(104) + VS(116) + VS(116) + VS(112) + VS(115) + VS(58) + VS(47) + VS(47) + VS(101) + VS(120) + VS(97) + VS(109) + VS(112) + VS(108) + VS(101) + VS(46) + VS(99) + VS(111) + VS(109) + VS(47)
表面上では「🌐」一つしかありませんが、その直後には https://example.com/ の情報が隠された多数の目に見えない異体字セレクタが並んでいます。
これでURLが絵文字の中に隠されました。
コード例(エンコード部分):
// 実際のコードは TextEncoder や Variation Selector を使っています。
function encodeUrlSimplified(baseEmoji, url) {
let hiddenMessage = "";
for (let i = 0; i < url.length; i++) {
// 文字のASCIIコードをそのまま「隠しコード」にする
hiddenMessage += String.fromCharCode(url.charCodeAt(i) + 1000); // 適当に大きな数字を足して「隠れた文字」にする
}
return baseEmoji + hiddenMessage;
}
// 例:
// const myUrl = "https://example.com";
// const encodedEmoji = encodeUrlSimplified("🌟", myUrl);
// console.log("エンコードされた絵文字(簡略版):", encodedEmoji);
// // 見た目は「🌟」に見えてもその裏に隠れたメッセージがある、というイメージ
3. 絵文字から元のURLを「デコード」する手順
隠されたURLを元に戻すにはエンコードの逆をすればいいです。
絵文字文字列を端から調べていく
受け取った絵文字文字列(例: 😀 + U+E0131)を、左から1文字ずつ丁寧に見ていきます。
ここで大事なのは、コンピューターが文字を「文字(コードポイント)」の単位で正しく認識することです。絵文字は見た目1文字でも、内部的には複数の文字の塊になっていることがあるからです。
「異体字セレクタ」を見つける
調べている文字が「異体字セレクタ」かどうかを判断します。
最初に見つかる文字は、たいてい「ベース絵文字」(例: 😀)です。これは異体字セレクタではないので、無視して次の文字に進み、最初の異体字セレクタに目印をつけます。(foundFirstVariationSelectorというフラグがこの目印です)
目印をつけたら、その後の異体字セレクタをどんどん集めていきます。
もし、途中で異体字セレクタではない文字がまた出てきたら、それはURLの情報の終わりだと判断してそこで調べるのをやめます。
異体字セレクタを元の「数字(バイト)」に戻す
集めた異体字セレクタを、それぞれエンコードのときに使ったルールを逆算して元の「数字(バイト)」に戻します。
これは自作関数variationSelectorToByteを使っています。
function variationSelectorToByte(variationSelector: string): number | undefined {
const codePoint = variationSelector.codePointAt(0);
if (codePoint === undefined) {
return undefined;
}
if (codePoint >= 0xFE00 && codePoint <= 0xFE0F) {
return (codePoint - 0xFE00);
} else if (codePoint >= 0xE0100 && codePoint <= 0xE01EF) {
return (codePoint - 0xE0100 + 16);
}
return undefined;
}
例えば異体字セレクタ U+E0131 をこれで元に戻すと
U+E0131は、元の数字 65 に戻されます。
「バイト」を元のURL文字列に戻す
バイトが順番に並んだら、それをTextDecoderと使って元のURL文字列に変換します。
コード例(デコード部分):
// 実際のコードでは TextDecoder や Variation Selector を使っています。
function decodeUrlSimplified(encodedEmoji) {
// 最初の1文字はベース絵文字なのでスキップ
const hiddenMessage = encodedEmoji.substring(1);
let decodedUrl = "";
for (let i = 0; i < hiddenMessage.length; i++) {
// 隠しコードを元の文字のASCIIコードに戻す
decodedUrl += String.fromCharCode(hiddenMessage.charCodeAt(i) - 1000);
}
return decodedUrl;
}
// 例:
// const encoded = "🌟ĀăāĂ"; // エンコードされた絵文字(簡略版の例)
// const decodedUrl = decodeUrlSimplified(encoded);
// console.log("デコードされたURL(簡略版):", decodedUrl); // 例えば "https://example.com" が出てくるイメージ
4. リダイレクトの仕組み (index.htmlの役割)
例えば、https://cwabara.github.io/emojiurl.github.io/?q=😀󠅘󠅤󠅤󠅠󠅣󠄪󠄟󠄟󠅧󠅧󠅧󠄞󠅗󠅟󠅟󠅗󠅜󠅕󠄞󠅓󠅟󠅝󠄟
にアクセスするとgoogle.comサイトに飛びます。
ブラウザがリダイレクト用URLにアクセスする
例えばhttps://cwabara.github.io/emojiurl.github.io/index.html/?q=😀󠅘󠅤󠅤󠅠󠅣󠄪󠄟󠄟󠅧󠅧󠅧󠄞󠅗󠅟󠅟󠅗󠅜󠅕󠄞󠅓󠅟󠅝 にアクセスすると?q=😀󠄱...の部分を3.の手法を使いデコードしリダイレクトします。
おまけ
もはや異体字セレクタを挿入することができればurlを作成できるので絵文字に限らずアルファベットや日本語で表すことも可能です
"https://cwabara.github.io/emojiurl.github.io/?q=(´・ω・`)󠅘󠅤󠅤󠅠󠅣󠄪󠄟󠄟󠅧󠅧󠅧󠄞󠅗󠅟󠅟󠅗󠅜󠅕󠄞󠅓󠅟󠅝󠄟"
追記:予想以上の反響をいただきました。ありがとうございます!
そのため悪用防止に伴い本サイトがフィッシング詐欺やマルウェア関連に巻き込まれないようGoogle Safe Browsing APIを導入しました。(全ての悪質サイトをブロックできるわけではありません。)
ご指摘ありがとうございます。
追記:本技術の利用に関する注意事項
本記事で紹介した絵文字にURLを埋め込む技術は、コンテストの主題に基づき行なったUnicodeの異体字セレクタの特性を活用した実験的なアプローチです。
したがって、この技術をご自身のサービスやプロダクトに導入することは、私は推奨しませんが、止めもしないので自己責任でお願いします。
参考サイト
Discussion
視覚的に面白いというメリットがある一方、絵文字の仕様を利用したURLのビジュアルスプーフィングなどが問題ですね