2018年10月27日

Google検索結果ページにWiktionaryボタンをつけてみる


ユーザースクリプトを使ってGoogleの検索結果ページにWiktionaryへのリンクを追加する方法をメモメモ…



普段めったにJavaScriptを使う機会はないのですが、一応勉強しておいたほうがいいかな…と思い、練習と実用を兼ねてためしにスクリプトを書いてみました。Googleの検索結果に "Wiktionaryで表示" ボタンを追加するユーザースクリプトです。ちょっとハマった所もあったのでメモしておきます。

機能としてはこんな感じです。

  1. Google検索結果ページの入力欄の端にボタン(リンク)を追加
  2. ボタンを押すと入力された単語のWiktionaryページに飛ぶ
  3. 単語が存在しなければGoogleで検索しなおす

というわけで、以下は作成したユーザースクリプトです。GreaseMonkeyTamperMonkeyなどのアドオン・拡張機能を使うと簡単に追加や編集ができます。


// ==UserScript==
// @name     WiktionaryButton
// @version  1
// @match    https://www.google.com/search*
// @grant    none
// ==/UserScript==

// Wiktionaryの言語 (日本語: ja 英語: en など)
var wiktionaryLanguage = 'en';

// ボタンの画像
var buttonImageSrc = 'https://en.wiktionary.org/favicon.ico';

// Wiktionary側でリダイレクトされそうな場合は直接そのリダイレクト先のページに飛ぶかどうか
var instantRedirect = true;


window.addEventListener("DOMContentLoaded", addButton);

// 入力欄の隣にWiktionaryボタンを追加する
function addButton()
{
  let button = document.createElement("span");
  button.setAttribute("id", "wiktionarybtn");
  button.setAttribute("style", "background: transparent; border: 0; float: right; height: 44px; line-height: 44px; outline: 0; padding-left: 6px; padding-right: 6px; position: relative;");
  button.addEventListener("click", jump, false);

  let buttonImg = document.createElement("img");
  buttonImg.setAttribute("src", buttonImageSrc);
  buttonImg.setAttribute("style", "vertical-align: middle; margin-top: -4px; width: 24px; height: 24px;");
  button.appendChild(buttonImg);

  let sibling = document.getElementsByClassName("lst-c")[0];
  if(sibling) sibling.parentElement.insertBefore(button, sibling);
}

// 入力欄の単語に応じて適切なページに移動する
function jump()
{
  const input = document.querySelector('input[name=q]');
  if(!input) return;

  const word = input.value;
  const url = "https://" + wiktionaryLanguage + ".wiktionary.org/wiki/";

  fetch(url + word)
  .then(response => {
    if(response.ok)
    {
      // 単語そのものズバリのページがあればそのまま移動
      location = url + word;
    }
    else
    {
      response.text().then(text => {
        const parser = new DOMParser();
        const htmlDoc = parser.parseFromString(text, "text/html");
        if(redirect = htmlDoc.getElementById("did-you-mean"))
        {
          // 似た単語にリダイレクトされそうならそちらに移動
          if(instantRedirect) location = url + redirect.querySelector("a").getAttribute("title");
          else location = url + word;
        }
        else
        {
          // 似た単語すら存在しない場合はGoogle検索に投げる あとは知らん
          const q = word.match("wiktionary") ? word :  word + " wiktionary";
          location = "https://www.google.com/search?q=" + q;
        }
      })
    }
  });
}


jump()関数内のネストが深くなってしまい最後のほうはなんかもういろいろひどくなっていますが、見て見ぬふりをしておいてください。では、個人的にハマったポイントだけ簡単にメモ&解説しておきます。

addEventListener("DOMContentLoaded", 関数)

window.addEventListener("DOMContentLoaded", addButton);

まずは18行目。ページのHTMLドキュメントの読み込みが終わった段階で関数を実行したい場合にはこのように記述します。ただ、多くの解説サイトでは document.addEventListener... のようにdocumentに対してリスナーを追加する例が掲載されているのですが、自分の環境だとなぜかこれが機能せず、小一時間悩んだ末 window.addEventListener... にすることできちんと発火するようになりました。うーん、よくわからん…。

あ、それとどうやらGreaseMonkeyがユーザースクリプトを実行するタイミングはDOMの構築が終わった段階(つまり'DOMContentLoaded')らしいので、GreaseMonkeyを使っている場合は別に上のようにわざわざaddEventListenerで実行タイミングを指定しなくても

addButton();

これだけでちゃんと動作します。

Google検索結果ページのクラス名には要注意

let sibling = document.getElementsByClassName("lst-c")[0];
if(sibling) sibling.parentElement.insertBefore(button, sibling);

33行目~34行目ではまず"lst-c"という名前のクラスを持つ要素を探し、その要素の1つ前に新しく作ったボタンの要素(button)を挿入しています。この"lst-c"というのは検索画面上部にある入力欄のクラス名なのですが、実は他言語版やスマホ版のGoogle検索だとクラス名が異なっていることがあるため、いつでもこの"lst-c"が使えるというわけではありません。また仕様変更でいつクラス名が変わってもおかしくないので、できればあんまりDOMの各要素に依存しないほうがいいと思います。今回は練習なのでこれでよしとします。

2018年12月4日 追記

Google検索のデザインが若干変わったようなので調べてみると、案の定クラス名も変更されていました。現在は"lst-c"の部分を"Tg7LZd"にすれば動作します。ただし今後もクラス名は変更され続けると思うので、適宜正しい文字列に置き換えるようにしてください。


HTML要素を動的に追加するときはその並び順に気をつけましょう。「追加する階層が正しくても隣り合う要素どうしの順序が違えば表示結果もまったく変わってくる」という当たり前のことに気づかず、しばらく無駄に悩んでしまいました…。「意図した場所に配置されない!」という場合はたいてい要素どうしの並び順がおかしいか、floatやdisplay、padding、marginなどのプロパティ指定がおかしい場合が多いです。「配置はあってるっぽいけどなぜか画像や文字が見えない!」という場合はz-indexで前後関係を見直したり配色を確認したりしてみましょう。

fetch(パス).then(関数)

fetch(url + word)
.then(response => {
  if(response.ok)
  {
    ...

指定したURLに対してネットワーク通信を行うには fetch(パス).then(関数) を使えば簡単です。「パス」から情報を取ってきて(fetch)、それが完了ししだい(then)指定した「関数」を実行せよ、という非常にわかりやすい構文ですね。

ページの取得に成功したかどうかはfetchから返ってきたResponseオブジェクトの ok プロパティでさくっとチェックできます。成功すると HTTP 200 OK などが返され、失敗したときは HTTP 404 などのステータスコードが返ってくるわけですが、これを自動で判別してくれる便利機能になっています。ネットワークエラーなどでサーバ側までたどり着けなかった場合などにはreject状態になるので、できれば横着せずに.catchでちゃんとエラーを受け取りましょう。今回は練習なのでこれでよしとします。

ResponseオブジェクトをDOMとして読み取る

response.text().then(text => {
  const parser = new DOMParser();
  const htmlDoc = parser.parseFromString(text, "text/html");

ResponseオブジェクトをDOMとして扱いたい場合は55行目~57行目のように DOMParser を使えばOK。一旦DOMに変換してしまえばあとは通常のドキュメントと同様に getElementById や querySelector などが普通に使えるようになります。この方法は是非そのまま覚えておきたいところです。

Wiktionaryの Did you mean ...? を取得する

if(redirect = htmlDoc.getElementById("did-you-mean"))

Wiktionaryには「そのものズバリ」な単語がない場合、大文字小文字を入れ替えた似た単語をサジェストし、さらにその似た単語のページに自動でリダイレクトする機能があります。たとえば"JAVASCRIPT"のページを開いてみると、 "Did you mean javascript?" と "Did you mean Javascript?" と2つの単語がサジェストされ、数秒後に"javascript"のページに飛ばされます。58行目ではfetchで取得したWiktionaryのページから "did-you-mean" という名前のIDを調べることで、このサジェストされた単語を取得しているわけです。
JAVASCRIPTのページを開いたときの "Did you mean (もしかして)" サジェスト

単語を追加してGoogleで再検索する

const q = word.match("wiktionary") ? word :  word + " wiktionary";
location = "https://www.google.com/search?q=" + q;

指定した単語のページがWiktionaryになく、似た単語もサジェストされなかった場合は単語に"wiktionary"を付け加えてもう一度Google検索を行います。「スペルミスとかがあってもGoogle検索ならちゃんと推測して存在するページを探し出してくれるだろう」という100%丸投げのテキトー仕様なのですが、まあ大目に見てください。Google検索に丸投げせずにもう少しきちんと実装するなら、たとえばWiktionary用のAPIなんかで条件を変えつつ検索して…みたいなこともできると思いますが、めんどくさいので今回はこれでよしとします。

というわけで、要点だけ簡単に解説してみました。なんか妥協点がやけに多かった気がしますがたぶん気のせいです。なにぶんJavaScriptに不慣れなので、間違いや勘違いがあったらすみません…。


リファラを取得する

上記のスクリプトだとGoogleの検索結果で常にボタンが表示されますが、これを「Google Play Books のリーダーから移動してきた場合のみボタンを表示する」ようにしてみます。以下のコードをaddButton()の冒頭に追加しましょう。

if(!document.referrer.match(/:\/\/books\.googleusercontent\.com\/books\/reader*/)) return;

document.refererプロパティを使えばリファラ(移動元のURL)を取得できるので、その中に特定の文字が含まれなければ処理を実行しないようにすればいいというわけです。

そもそも今回このスクリプトを作ったのは、Google Play Books のリーダーの翻訳機能がショボいことがきっかけでした。Google Play Books リーダー自体は割と使いやすく、単語を選択するだけで言葉の意味や地図などの情報がサクサク表示されたり、マーカーで簡単に色分け出来たりと機能的には十分なのですが、翻訳に関しては「Google翻訳そのまま!」という感じで言語学習にはあまり役立ちません。

ギリシャ語→英語に翻訳した例 翻訳精度は問題ないが単語ごとの詳しい解説などが一切出ない

そこで外国語→英語用の辞書としてわりと充実しているWiktionaryにすぐにアクセスする方法を模索した結果が上記のスクリプトだったわけです…。本当はリーダー内で表示されるメニューに「Wiktionaryで検索」みたいなボタンを追加したかったのですが、基本的に本のコンテンツ部分はすべてインラインフレーム(iframe)で別個に読み込み、かつクロスドメインで守られているという状態でなかなか手に負えなさそうだったため、早々に諦めました。Google Play Books のリーダー内で現在選択中の単語を取得できる方法などがもしあれば誰か教えてください…。

0 件のコメント:

コメントを投稿