本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

以前 researchmapの科研費と業績の紐付けをPlaywrightで自動化した という記事を書きました。今回はその続編として、業績そのものの登録を自動化した話をまとめます。

執筆途中でファクトチェックを行い、当初想定していた前提(「公式の書き込み手段はWeb UIに限られる」)が誤っていることが分かりました。この記事ではその過程も含めて、現時点の選択肢を整理した上で、なお自前のPlaywrightスクリプトに利点が残る部分を示します。

researchmap への書き込み手段の現状

1. 書き込みAPI(researchmap.v2 API)

公式仕様書: researchmap.v2 API設計書(V4.7)

19の業績種別すべてに対して、追加・更新・削除が可能なAPIが公開されています。認証はOAuth 2.0(JWT Bearer Flow)で、https://api.researchmap.jp/oauth2/token でアクセストークンを取得します。

利用には申請が必要で、現時点で公開されている運用は次のような形です。

  • 申請書フォーマットは 大学等研究機関用 が用意されている
  • WebAPI利用規約 第2条第3項(4) では「大学・研究機関等の機関としての申請でなく、個人としての申請と認められる場合(ただし、合理的な理由がある場合を除く。)」は承認しないことがあるとされている
  • IPアドレスの固定指定が必要、利用は年度単位で継続申請

文言上は「合理的な理由がある場合」の例外条項があり、機関単位での運用が中心であることが想定されています。個人研究者として手元の業績だけを登録したい用途では、後述のCSV/JSONインポートやWeb UI操作が現実的な選択肢になります。

2. 公式CSV / JSON / JSONLインポート(個人ユーザー対象)

設定 > 「研究者・業績インポート」画面から、ログイン中の研究者本人の業績を一括登録できます。

  • 対応フォーマット: JSON / CSV / JSONL / ZIP
  • 全19業績種別に対応
  • 1ファイル10MBまで
  • 仕様書: v2CSV項目定義書API設計書 に内部フォーマットとして記載

つまり個人ユーザーでも、JSONLを書ければ事実上の一括書き込みは可能です。

いくつか補足しておきたい点があります:

  • 添付ファイル(PDF等)はインポートの対象外です。API設計書で dataset 配下のフィールドは全業績種別で「更新不可」と明記されています(presentations, published_papers, misc, works など全タイプ)。access_url 等は読み取り専用の出力フィールドです
  • ZIPアップロードは「JSON/CSVファイルを複数同梱する」用途で定義されており、PDFを同梱する仕様ではありません
  • インポート画面でのファイルアップロード操作はユーザー側で行う必要があります

3. Web UI 手動操作

1件単位の追加・修正なら最速です。ただし数十件規模になるとつらく、Git等での履歴管理もできません。

4. Playwrightによるブラウザ自動化(本記事)

公式CSVインポートで対応できる範囲はそちらを使えばよく、私が手元で自動化したかったのは次のような場面でした。

  • JSONLを書いたら、ファイルアップロード操作も含めて自動で完了させたい
  • PDFを発表資料として添付したい:これは公式インポートのスコープ外
  • 既存エントリをピンポイントで更新したい:「現在まで」を具体的な終了年月に置き換える等の単発編集をJSONL化して残しておきたい

これらに対応するために自作したスクリプトを以下で紹介します。

JSONLフォーマット

researchmap/
├── migrations/
│   ├── awards/
│   ├── books_etc/
│   ├── committee_memberships/
│   ├── misc/
│   ├── presentations/
│   ├── profile/                # research_experience を含む
│   ├── published_papers/
│   ├── research_projects/      # 紐付け用設定
│   └── works/
└── scripts/
    ├── link_research_project.py    # 科研費紐付け(前回記事)
    └── register.py                 # 今回の汎用登録スクリプト

新規登録の最小例(presentations):

{
  "insert": {"type": "presentations"},
  "merge": {
    "display": "disclosed",
    "presentation_title": {"ja": "NDL古典籍OCR-Liteを活用したiOSアプリの開発"},
    "presenters": {"ja": [{"name": "中村覚"}]},
    "event": {"ja": "次世代システム開発研究室 オンライン勉強会(2026年3月)"},
    "publication_date": "2026-03-19",
    "from_event_date": "2026-03-19",
    "to_event_date": "2026-03-19",
    "invited": false,
    "languages": ["jpn"],
    "presentation_type": "oral_presentation",
    "is_international_presentation": false
  }
}

更新は insert.id に既存エントリのIDを指定します。例えば「現在まで」となっている経歴を2026-03で締める場合:

{
  "insert": {"type": "research_experience", "id": "35987576"},
  "merge": {"to_date": "2026-03"}
}

merge には更新したいフィールドだけ書けば、他のフィールドはそのまま残ります。

公式フォーマットとの互換性

上記のJSONLは researchmap.v2 API の入力フォーマットと互換です。

API設計書 p.25 の「パラメーター(POST BODY)」で、トップレベルに insert / update / delete、その兄弟キーとして merge / similar_merge / force / doc を置く構造が定義されており、p.27 では insertid を指定すると既存エントリの更新になる旨が記載されています。presentation_title.ja, presenters.ja[].name, from_event_date 等のフィールド名も p.101–105 のサンプルと一致します。

このため同じJSONLを以下のどちらでも利用できます:

  • researchmap の「研究者・業績インポート」画面に手動アップロード
  • 自作Playwrightスクリプトで自動入力

JSONLを公式互換で保っておけば、用途に応じて公式インポートとPlaywrightを切り替えやすくなります。

実行コマンド

# 新規登録(JSONLの全行を順次処理)
python3 scripts/register.py migrations/presentations/0009_xxx.jsonl

# 特定の行のみ
python3 scripts/register.py migrations/awards/0001_jsda_2026_awards.jsonl --line 1

# PDFを発表資料として添付
python3 scripts/register.py migrations/presentations/0012_icadl2025.jsonl \
    --pdf /path/to/slides.pdf

# 既存エントリにPDFのみ追加
python3 scripts/register.py --type presentations --id 53536588 --pdf slides.pdf

# ドライラン(送信せず確認)
python3 scripts/register.py <jsonl> --dry-run

8タイプ汎用化のポイント

researchmapのフォームは(CakePHP + NetCommonsベースで)どのタイプも次のような共通構造を持っています。

  • フォームID: {Type}IndexAddDetailForm (例: PresentationsIndexAddDetailForm
  • 入力欄の命名: data[{Type}Index][_source][{field}]
  • 共通フィールド: display, see_also, dataset(添付対応typeのみ)

これを使ってスクリプト側では次のような辞書で各タイプを定義しています。

TYPE_META = {
    "presentations": {
        "form_id": "PresentationsIndexAddDetailForm",
        "index_camel": "PresentationsIndex",
        "title_field": "presentation_title",
        "supports_dataset": True,
    },
    "awards": {
        "form_id": "AwardsIndexAddDetailForm",
        "index_camel": "AwardsIndex",
        "title_field": "award_name",
        "supports_dataset": False,
    },
    # ... 他6タイプ
}

FILLERS = {
    "presentations": fill_presentations,
    "awards": fill_awards,
    # ... 各タイプの入力ロジック
}

新タイプを足すときは TYPE_META と該当 filler 関数を1つ追加するだけで済むようにしました。

詰まったポイント:CakePHPの隠しフィールド

経歴の終了年月を更新するスクリプトを書いたところ、フォーム送信は成功するのに to_datenull になる現象に遭遇しました。

DOMを覗いてみると、同じ name 属性を持つ要素が2つ並んでいることが分かりました。

INPUT type=hidden name="...[to_date][year]" value=""        (visible: False)
INPUT type=number name="...[to_date][year]" value=""        (visible: True)

これはCakePHPが生成する隠しフィールド(おそらくAntiArrayAttack対策等のプロテクション)で、document.querySelector で同じ name を引くと先頭の hidden 側が返ってきます。私のJS経由の代入ロジックは hidden に空文字を書き込んでいて、見えている入力欄は触れずに送信していたわけです。

修正として、querySelectorAll で全要素を取り、type !== 'hidden' の最後の要素を選ぶようにしました。

const els = Array.from(document.querySelectorAll(`[name="${n}"]`));
const real = els.filter(e => (e.type || '').toLowerCase() !== 'hidden');
const target = real.length > 0 ? real[real.length - 1] : els[els.length - 1];
target.value = v;
target.dispatchEvent(new Event('input', {bubbles: true}));
target.dispatchEvent(new Event('change', {bubbles: true}));

「現在まで」マーカーとして to_date: "9999" を採用しているので、9999 が来たら is_current チェックボックスをONにして日付欄はスキップする、という分岐も入れています。

発表資料PDFの添付

公式インポートでは扱えない部分です。フォーム上は「詳細」を展開した先に dataset 入力欄があり、ファイル種別を published(発表資料)にしてPDFをアップロードできます。

file_input = page.locator(
    f'input[type="file"][name="{src}[dataset][dataset_name]"]').first
await file_input.set_input_files(pdf_abs)
await select_option(page, f"{src}[dataset][dataset_type]", "published")

過去に手動で登録した発表に後から資料を添付したいケースは多く、 --id モードでよく使っています。

未登録業績を見つける補助

researchmapに登録漏れがないか調べたいときは、ORCIDをキーにOpenAlexのAPIを叩き、researchmapの既登録DOI・タイトルと突合する小さなスクリプトを使っています。

ORCID = "0000-0001-8245-7925"
r = requests.get(
    f"https://api.openalex.org/works?filter=author.orcid:{ORCID}&per-page=200")

ORCIDフィルタでも同名異人の業績が紛れ込むことがあるので、最終的には人間が確認する前提です。それでも完全な手動チェックよりは効率的です。

ヘッドレス化の扱い

「ブラウザを表示せずにバックグラウンドで動かしたい」と考えるのは自然ですが、調査した限りでは現状で素直なヘッドレス化は難しいようです。

headless=True を指定すると、ログイン画面アクセスの時点で 403 Forbidden が返ってきます。検証してみたところ、User-Agent文字列に HeadlessChromepython-requests といった文字列が含まれているとそのまま403が返るシンプルな挙動でした。curl の素のリクエストや、UAを通常のブラウザに差し替えたものは200で通ります。

参考までにレスポンスヘッダから判別できる範囲では:

  • IPは 160.74.72.121(JSTNET、JSTがホスティング)
  • TS012e09a0=... のような Cookie シグネチャが付与されており、F5 BIG-IP ASM 系の保護機構が動いていそう
  • フロントは nginx

技術的にヘッドレスを通す手段(UA文字列の差し替え、playwright-stealth 等によるwebdriverフラグの抑制、bundled ChromiumではなくシステムのChromeを使う等)はいくつか知られています。ただ、いずれも自動アクセスを抑制している運営側の意図と異なる動かし方になります。

加えて、researchmapのサービス基本規約 第4条第10号には「機械的及びそれに準じた手段を用いて…情報を大量にダウンロードする行為」を控える旨が明記されており、robots.txt でも研究者ページ配下のクロールが Disallow 指定されています。

私的なスクリプトとはいえ、こうした運営側の意向や規約は尊重したいので、本記事のスクリプトではUA偽装やフラグ抑制系の手段は採用していません。

一方、storage_state によるcookie永続化 は通常のブラウザがログイン状態を覚えておくのと同じ仕組みで、偽装ではありません。

  • 初回(headed):通常通りログイン → cookieを scripts/.session.json に保存
  • 2回目以降(headed):保存済みセッションを読み込み、ログイン画面を踏まずに直接フォームへ
ctx_kwargs = {"viewport": {"width": 1400, "height": 1000}}
if SESSION_PATH.exists():
    ctx_kwargs["storage_state"] = str(SESSION_PATH)
context = await browser.new_context(**ctx_kwargs)
# ... 必要ならログイン後に
await context.storage_state(path=str(SESSION_PATH))

実測で1回あたり10秒程度の短縮になりました。画面は出ていますが、その間に自分は別作業ができるので、運用上の不便はそれほど大きくありません。

スクリプトには --headless フラグも残してあります。現状では先述の通り通らない場面が多いので、主に内部的なフラグとして置いている形です。

個人ユーザーの落とし所

整理すると、選択肢は次のようになります。

手段個人利用ファイル渡しPDF添付UI変更への耐性
書き込みAPI v2△(機関単位での申請が中心)△(dataset は対象外)
公式CSV/JSONインポートUI手動対象外
Playwright自動化完全自動
Web UI手動UI手動

Playwrightの自動化は、フォームのID(PresentationsIndexAddDetailForm 等)やフィールド命名規約(data[XxxIndex][_source][...])に依存しています。研究者・業績の登録画面の構造が更新されると、セレクタが合わなくなってスクリプトが動かなくなる可能性があります。CSV/JSONインポートやAPIは入力スキーマに対応する形で運用されるため、画面リニューアルの影響は比較的受けにくいはずです。

JSONLを公式互換で保っておけば、Playwright側が動かなくなった場合でも、同じファイルをそのまま公式インポート画面に持ち込んで作業を継続できます。Gitリポジトリに migrations/ を残しておくと、いつ何を登録したかの履歴も自然に残ります。

スクリプトは個人の業務改善用です。SLUG = "nakamura.satoru" を書き換えて、migrations/ 配下のJSONLを自分の業績に置き換えれば、同様のことができます。利用規約の趣旨を踏まえ、自分自身のアカウントで自分の業績を登録する用途で使っています。