はじめに

Hypothes.isは、Webページ上にハイライトやコメントを付けられるオープンソースのアノテーションツールです。ブラウザ拡張やJavaScriptの埋め込みで手軽に使えますが、蓄積したアノテーションをバックアップしたい、あるいはTEI/XMLなど別の形式で活用したいケースもあります。

本記事では、Hypothes.is APIを使ってアノテーションをエクスポートし、TEI/XMLに変換する方法を紹介します。

APIキーの取得

  1. Hypothes.isにログイン
  2. Developer settings にアクセス
  3. 「Generate your API token」でAPIキーを生成

取得したキーを.envファイルに保存します。

cp .env.example .env
# .env を編集してAPIキーを設定
HYPOTHESIS_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

アノテーションのエクスポート

APIの基本

Hypothes.is APIのベースURLは https://api.hypothes.is/api です。認証はAuthorization: Bearer <API_KEY>ヘッダーで行います。

主要なエンドポイント:

エンドポイント用途
GET /api/profile認証ユーザーのプロフィール取得
GET /api/searchアノテーション検索
GET /api/annotations/{id}個別アノテーション取得

スクリプト

エクスポートからTEI/XML変換までを1つのスクリプト hypothes_export.py にまとめています。

https://github.com/nakamura196/hypothes-export/blob/main/hypothes_export.py

以下、主要な処理を抜粋して説明します。

.envの読み込みとAPI呼び出し

def load_env():
    env_path = Path(__file__).parent / ".env"
    with open(env_path) as f:
        for line in f:
            line = line.strip()
            if line and not line.startswith("#") and "=" in line:
                k, v = line.split("=", 1)
                os.environ[k.strip()] = v.strip()


def api_get(endpoint, params=None):
    api_key = os.environ["HYPOTHESIS_API_KEY"]
    url = f"https://api.hypothes.is/api/{endpoint}"
    if params:
        url += "?" + urllib.parse.urlencode(params)
    req = urllib.request.Request(url)
    req.add_header("Authorization", f"Bearer {api_key}")
    with urllib.request.urlopen(req) as resp:
        return json.loads(resp.read().decode())

全アノテーションの取得(ページネーション対応)

Search APIは1リクエストあたり最大200件なので、offsetをずらして全件取得します。

def fetch_all_annotations():
    profile = api_get("profile")
    user = profile["userid"]

    all_annotations = []
    limit = 200
    offset = 0

    result = api_get("search", {"user": user, "limit": limit, "offset": 0})
    total = result["total"]
    all_annotations.extend(result["rows"])
    offset += limit

    while offset < total:
        result = api_get("search", {"user": user, "limit": limit, "offset": offset})
        all_annotations.extend(result["rows"])
        offset += limit

    return all_annotations

実行

# JSON + TEI/XML を出力
python hypothes_export.py

# JSONのみ出力
python hypothes_export.py --json-only

# 既存JSONからTEI/XML変換のみ
python hypothes_export.py --tei-only
User: acct:your_username@hypothes.is
Total: 6 annotations
Saved JSON: output/annotations.json (6 annotations)
Saved TEI/XML: output/annotations.xml

アノテーションのデータ構造

エクスポートされたJSONの各アノテーションは、W3C Web Annotation Data Modelに基づいた構造を持っています。

{
  "id": "a1lBUhPdEfG-Lk8iV7GT3w",
  "created": "2026-02-27T13:08:33.427772+00:00",
  "user": "acct:your_username@hypothes.is",
  "uri": "https://example.com/page",
  "text": "これは正しい?",
  "tags": ["メモ"],
  "target": [
    {
      "source": "https://example.com/page",
      "selector": [
        {
          "type": "RangeSelector",
          "startContainer": "/main[1]/div[1]/p[1]",
          "startOffset": 335,
          "endContainer": "/main[1]/div[1]/p[1]/span[4]",
          "endOffset": 0
        },
        {
          "type": "TextPositionSelector",
          "start": 1663,
          "end": 1667
        },
        {
          "type": "TextQuoteSelector",
          "exact": "此詩乃是",
          "prefix": "人樂太平無事日   鶯花無限日高眠 \n        ",
          "suffix": "宋太祖朝中一個名儒姓邵諱尭堯夫道號康節先生所作為"
        }
      ]
    }
  ]
}

3種類のセレクタ

Hypothes.isは、アノテーション対象のテキスト位置を3種類のセレクタで記録しています。

セレクタ仕組み頑健性
RangeSelectorDOM上のXPathで位置を指定△ HTML構造の変更に弱い
TextPositionSelector文字のオフセット位置で指定△ テキストの増減でズレる
TextQuoteSelector対象テキスト+前後の文脈で指定◎ fuzzy matchで再アンカリング可能

元文書が変更された場合、Hypothes.isはこれらのセレクタをフォールバックとして順に試みます。TextQuoteSelectorprefix/suffixを含むfuzzy matchを行うため最も頑健ですが、対象テキスト自体が削除・大幅変更された場合はアノテーションが「orphaned(孤立)」状態になります。

TEI/XMLへの変換

エクスポートしたJSONをTEI/XML形式に変換します。

マッピング方針

Hypothes.isTEI/XML
対象文書(URI・タイトル)<sourceDesc><bibl>
文書ごとのグループ<div>
各アノテーション<ab>
ハイライトテキスト(TextQuoteSelector.exact<quote>
コメント本文<note type="annotation">
タグ<note type="tag">

変換ロジック

TextQuoteSelectorから引用テキストを抽出し、TEI要素にマッピングします。

def get_text_quote(annotation):
    """TextQuoteSelectorからexact/prefix/suffixを取得"""
    for target in annotation.get("target", []):
        for sel in target.get("selector", []):
            if sel.get("type") == "TextQuoteSelector":
                return sel
    return None

アノテーションをURIごとにグループ化し、<div><ab><quote> / <note> の構造で出力します。詳細はソースコードを参照してください。

出力例

<?xml version="1.0" encoding="UTF-8"?>
<TEI xmlns="http://www.tei-c.org/ns/1.0">
  <teiHeader>
    <fileDesc>
      <titleStmt>
        <title>Hypothes.is Annotations Export</title>
      </titleStmt>
      <publicationStmt>
        <p>Exported from Hypothes.is API</p>
      </publicationStmt>
      <sourceDesc>
        <bibl xml:id="src-0">
          <title>巻首題:新刻全像水滸傳</title>
          <ref target="https://example.com/page">https://example.com/page</ref>
        </bibl>
      </sourceDesc>
    </fileDesc>
  </teiHeader>
  <text>
    <body>
      <div corresp="#src-0">
        <head>巻首題:新刻全像水滸傳</head>
        <ab xml:id="ann-a1lBUhPdEfG">
          <quote>此詩乃是</quote>
          <note type="annotation"
                corresp="https://hypothes.is/a/a1lBUhPdEfG"
                when="2026-02-27T13:08:33.427772+00:00">
            これは正しい?
          </note>
        </ab>
      </div>
    </body>
  </text>
</TEI>

元文書の変更とアノテーションの整合性

Hypothes.isのアノテーションは「スタンドオフ注釈」方式で、元文書とは別に保存されます。そのため、元文書が変更されるとアノテーションの位置がズレる可能性があります。

  • 軽微な変更 : TextQuoteSelectorのfuzzy matchにより再アンカリングされることが多い
  • 大幅な変更 : アノテーションが「orphaned」状態になり、対象箇所に紐づかなくなる

TEI/XMLにエクスポートしておけば、<quote>要素にハイライト対象テキストが記録されるため、元文書との対応関係は少なくとも記録として残ります。

まとめ

  • Hypothes.is APIを使えば、自分のアノテーションをプログラムから取得できる
  • TextQuoteSelectorexact/prefix/suffixが、アノテーション対象テキストの特定に最も重要
  • TEI/XMLへの変換により、人文学研究で広く使われるフォーマットで保存・活用できる
  • ただし元文書の変更によるアンカリングのズレには注意が必要

ソースコードはGitHubで公開しています。